Skip to content
SciStack
Concept Python Beginner 30 min

Finite elements: hat functions, the weak form, and a heated bar

Afterwards you can say what a finite element solution is, build a 1D stiffness matrix from the weak form, and tell how the error falls with element size.

Field
Engineering, Mathematics, Physics
Prerequisites
none beyond Python basics
Libraries
matplotlib 3.11.2numpy 2.4.3skfem 12.0.2
Download notebook Save Mark as done

py-finite-elements.ipynb, executed with the versions above. The download needs a free account

Run it yourself. In a terminal, this installs exactly the versions above:

pip install numpy==2.4.3 skfem==12.0.2 matplotlib==3.11.2 jupyterlab

The question

A wire that carries a current heats itself evenly along its length, q watts per cubic meter, and its two ends are clamped to blocks at the temperature of the room. With conductivity k, the excess temperature u(x) of a bar of length L obeys \(-k\,u'' = q\) with \(u(0) = u(L) = 0\), and the answer is a parabola, \(u = q\,x(L - x)/(2k)\), with its peak \(qL^2/(8k)\) in the middle. Measure x in units of L and u in units of \(qL^2/k\), and every such bar becomes the same bar, with a peak of 0.125. Here it is next to its finite element solution with four elements:

Show code
import numpy as np
import matplotlib.pyplot as plt

plt.rcParams.update({
    "figure.figsize": (8, 3.2), "figure.dpi": 110,
    "axes.spines.top": False, "axes.spines.right": False,
    "axes.grid": True, "grid.alpha": 0.25,
    "font.size": 11, "lines.linewidth": 1.8,
})
INK, ACCENT, SECOND, MUTED = "#1f2a44", "#c8553d", "#2a7f9e", "#8a8f98"

L, q, k = 1.0, 1.0, 1.0                     # u in units of qL²/k, x in units of L
x = np.linspace(0, L, 4001)

def exact(x):
    return q * x * (L - x) / (2 * k)

def fem_bar(n):
    """Nodes and nodal temperatures of the n-element solution (assembled step by step under Formalization)."""
    h = L / n
    K = k / h * (2 * np.eye(n - 1) - np.eye(n - 1, k=1) - np.eye(n - 1, k=-1))
    U = np.linalg.solve(K, np.full(n - 1, q * h))
    return np.linspace(0, L, n + 1), np.r_[0, U, 0]

nodes4, U4 = fem_bar(4)
x_mid = 0.125                                # midpoint of the first element, where the gap is largest
fig, ax = plt.subplots()
ax.plot(x, exact(x), color=INK)
ax.plot(nodes4, U4, "o-", color=ACCENT, ms=6)
gap = exact(x_mid) - np.interp(x_mid, nodes4, U4)
ax.annotate(f"largest gap {gap:.4f}", xy=(x_mid, exact(x_mid) - gap / 2), xytext=(0.17, 0.018),
            color=MUTED, arrowprops=dict(arrowstyle="-", color=MUTED, lw=1))
ax.text(0.5, exact(0.5) + 0.006, f"peak {exact(0.5):.3f}", ha="center")
ax.text(0.13, 0.095, "exact", color=INK, ha="right")              # direct labels instead of a legend
ax.text(0.83, 0.043, "4 elements", color=ACCENT, ha="right")
ax.set(xlabel="x / L", ylabel="u / (qL²/k)", xlim=(0, 1), ylim=(0, 0.145))
plt.show()
Temperature along a heated bar with both ends held cold, in units of qL²/k. Line: exact parabola, peak 0.125. Dots joined by straight lines: four-element finite element solution, exact at the nodes, at most 0.0078 below in between.

Some of it is obvious. The finite element solution is five points joined by straight lines, the points sit on the parabola, and the straight pieces cut below the arch, by at most 0.0078 at the middle of each element, against a peak of 0.125.

What is not obvious is how the points were found. The method never asked for the temperature at a point. It asked for the best combination of tent-shaped functions, one per node, and it asks the same question on a mesh of triangles fitted to any outline, a disk or a turbine blade, where a method built on point values lays its points in rows and columns and has to patch every place where a curved edge cuts between them. So three questions. What is the combination, what makes it "best", and why do the nodes come out exact when nothing aimed at them?

The idea: build the temperature from hats

Put a node at each end of every element: with four elements on the bar, that is five nodes, three of them inside. Over each interior node j stands a hat \(\varphi_j\), a tent that is 1 at node j, 0 at every other node, and straight in between. Add the hats with weights, \(u_h = \sum_j U_j \varphi_j\), and you get a broken line. At node j only hat j is nonzero, and there it is 1, so the broken line passes through the point \((x_j, U_j)\).

The weight is the temperature at the node. Choosing a solution means choosing three numbers.

Show code
def hat(j, nodes, x):
    """Hat function of node j: 1 at that node, 0 at all others, linear in between."""
    return np.interp(x, nodes, np.eye(nodes.size)[j])

U_guess = np.array([0, 0.05, 0.15, 0.05, 0])   # an arbitrary choice, for comparison

fig, axes = plt.subplots(3, 1, figsize=(8, 6.3), sharex=True)
for j in (1, 2, 3):
    axes[0].plot(x, hat(j, nodes4, x), color=MUTED, lw=1.2)
axes[0].set(ylabel="hat", ylim=(0, 1.1))
for ax, U, color in ((axes[1], U_guess, SECOND), (axes[2], U4, ACCENT)):
    ax.plot(x, exact(x), color=INK)
    if ax is axes[2]:
        for j in (1, 2, 3):
            ax.plot(x, U[j] * hat(j, nodes4, x), color=ACCENT, lw=1.0)
    ax.plot(nodes4, U, "o-", color=color, ms=6)
    for xj, Uj in zip(nodes4[1:-1], U[1:-1]):
        ax.text(xj, Uj + 0.012, f"{Uj:.3f}", ha="center", color=color)
    ax.set(ylabel="u / (qL²/k)", ylim=(0, 0.175))
axes[-1].set(xlabel="x / L", xlim=(0, 1))
plt.show()
Three panels over the bar. Top: three tent-shaped hat functions, each 1 at its node. Middle: hats weighted 0.05, 0.15, 0.05 give a broken line far from the parabola. Bottom: weights 0.094, 0.125, 0.094 give a broken line through the parabola at every node.

The top panel shows the three hats. In the middle, weights 0.05, 0.15, and 0.05 give a broken line that is nowhere near the parabola: a legitimate member of the family, and a bad one. In the bottom panel the finite element weights, 0.094, 0.125, and 0.094, scale each hat to its node's height (the thin lines), and their sum runs through the parabola at every node.

With more elements the family grows and the broken line hugs the curve more closely. On eight elements there are seven hats, and the sum builds up one weighted hat at a time:

Seven hat functions on an eight-element bar are scaled to their weights one after another and added; the running sum, a broken line, closes in on the exact parabola.

Show code
"""Hat sum: a finite element solution built one weighted hat function at a time.

Renders ../../assets/hat-sum.gif for the heated bar of py-finite-elements, eight elements.
Run it from any directory:

    python scene.py
"""
from pathlib import Path

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.animation import FuncAnimation, PillowWriter
from PIL import Image

OUT = Path(__file__).resolve().parents[2] / "assets" / "hat-sum.gif"
INK, ACCENT, SECOND, MUTED = "#1f2a44", "#c8553d", "#2a7f9e", "#8a8f98"
plt.rcParams.update({"axes.spines.top": False, "axes.spines.right": False,
                     "axes.grid": True, "grid.alpha": 0.25, "font.size": 11})

# ---- data: the bar in units of qL²/k, eight elements, seven interior nodes
n = 8
h = 1 / n
nodes = np.linspace(0, 1, n + 1)
x = np.linspace(0, 1, 801)
exact = x * (1 - x) / 2
K = (2 * np.eye(n - 1) - np.eye(n - 1, k=1) - np.eye(n - 1, k=-1)) / h
U = np.linalg.solve(K, np.full(n - 1, h))            # the finite element weights, one per interior node

def hat(j):
    """Hat function of interior node j (1 to 7) on its two elements: 1 at its node, 0 at the neighbors."""
    xs = np.linspace(nodes[j - 1], nodes[j + 1], 3)
    return xs, np.array([0.0, 1.0, 0.0])

SHRINK, ADD, HOLD = 8, 4, 16                         # frames per hat: scale to its weight, then add it
frames = [(j, k) for j in range(1, n) for k in range(SHRINK + ADD)] + [(n, 0)] * HOLD

# ---- figure, drawn once
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(7, 4.8), dpi=80, sharex=True, layout="constrained")
for j in range(1, n):
    ax1.plot(*hat(j), color=MUTED, lw=1.0)
(current,) = ax1.plot([], [], color=ACCENT, lw=2.2)  # the hat being scaled
ax2.plot(x, exact, color=INK, lw=1.8)
(piece,) = ax2.plot([], [], color=ACCENT, lw=1.0)    # the same hat at its weight, on the scale of the sum
(total,) = ax2.plot([], [], color=ACCENT, lw=1.8)
(dots,) = ax2.plot([], [], "o", color=ACCENT, ms=5)
ax1.set(ylabel="hat", ylim=(0, 1.08))
ax2.set(xlabel="x / L", ylabel="u / (qL²/k)", xlim=(0, 1), ylim=(0, 0.145))
title = ax1.set_title("", loc="left")
ax2.set_title("sum", loc="left")
ax2.text(0.84, 0.084, "exact", color=INK)                # direct label above the parabola

# ---- one frame: hat j shrinks from height 1 to its weight, then joins the sum
def update(frame):
    j, k = frame
    done = min(j, n) - 1                            # hats already in the sum
    if j < n:
        xs, ys = hat(j)
        s = min(k / (SHRINK - 1), 1.0)
        current.set_data(xs, (1 + s * (U[j - 1] - 1)) * ys)
        piece.set_data(xs, U[j - 1] * ys)
        title.set_text(f"hat {j} of {n - 1}, weight {U[j - 1]:.4f}")
        if k >= SHRINK:                              # the add phase: the sum grows toward the new hat
            done += (k - SHRINK + 1) / ADD
    else:
        current.set_data([], [])
        piece.set_data([], [])
        title.set_text(f"all {n - 1} hats added")
    weights = np.r_[0, U * np.clip(done - np.arange(n - 1), 0, 1), 0]
    total.set_data(x, np.interp(x, nodes, weights))
    added = np.r_[True, np.arange(n - 1) < done, True]   # dots on the held ends and the hats in the sum
    dots.set_data(nodes[added], weights[added])

# ---- render, and read back what was written
OUT.parent.mkdir(exist_ok=True)
FuncAnimation(fig, update, frames=frames).save(OUT, writer=PillowWriter(fps=12))
plt.close(fig)
with Image.open(OUT) as im:
    delay = im.info["duration"]
    print(f"{OUT.name}: {im.width} x {im.height} px, {im.n_frames} frames at {delay} ms, "
          f"{im.n_frames * delay / 1000:.1f} s, {OUT.stat().st_size / 1024:,.0f} kB")

Every finite element solution is a picture like this one. Elements of higher order use curved pieces instead of straight ones, and in two dimensions the tents stand over triangles, but the solution is always a weighted sum of known shapes, and the method is a rule for the weights.

Testing the equation with each hat

Which weights, then? You cannot demand \(-k\,u'' = q\) point by point of a broken line. Inside each element its second derivative is zero, and at the kinks it is infinite. The finite element method asks for less: multiply the equation by one hat and integrate it along the bar. That turns the equation into a heat balance over the stretch the hat covers, two elements wide, weighted toward its node.

The integral is easier than it looks. The second derivative of a broken line lives only at its kinks: there it is a spike, a Dirac delta, whose area is the jump in slope across the kink. Multiplied by hat \(\varphi_i\), the spike at node i counts with weight 1, and the spikes at the neighboring nodes with weight 0, because that is the value of the hat at each of them. So the integral of \(k\,u_h''\varphi_i\) is k times the slope jump at node i, and the integral of \(q\varphi_i\) is q times the area under the hat, a triangle of base 2h and height 1, which is \(qh\). With the minus sign of the equation, the balance at node i reads

\[k\,\frac{U_i - U_{i-1}}{h} - k\,\frac{U_{i+1} - U_i}{h} = q\,h .\]

The heat flux is minus k times the slope, counted positive to the right, so the first term is the heat conducted out of node i's patch to the left, the second the heat conducted out to the right, and the right side is the heat generated in it. One balance per hat, so three hats give three equations for the three weights. Here are both sides for the arbitrary weights and for the finite element weights:

Show code
h = L / 4
print("node   arbitrary: out  generated   finite element: out  generated")
for i in (1, 2, 3):
    out_guess = k * (U_guess[i] - U_guess[i - 1]) / h - k * (U_guess[i + 1] - U_guess[i]) / h
    out_fem = k * (U4[i] - U4[i - 1]) / h - k * (U4[i + 1] - U4[i]) / h
    print(f"{i:4d}   {out_guess:15.3f}  {q * h:9.3f}   {out_fem:20.3f}  {q * h:9.3f}")
node   arbitrary: out  generated   finite element: out  generated
   1            -0.200      0.250                  0.250      0.250
   2             0.800      0.250                  0.250      0.250
   3            -0.200      0.250                  0.250      0.250

With the arbitrary weights, node 2 conducts away 0.8 while its patch generates 0.25, and nodes 1 and 3 even take heat in. With the finite element weights, outflow and generation agree at 0.25 in every patch. The balance holds at every node for one set of weights only, and those are the weights.

Formalization

The usual route avoids the spikes and works for curved pieces as well as straight ones. Multiply \(-k\,u'' = q\) by a test function v, a weight like the hat that vanishes at the held ends, \(v(0) = v(L) = 0\). Integrate by parts to move one derivative from u to v. The boundary term \([-k\,u'v]_0^L\) drops out because v is zero at both ends, and what is left is the weak form:

\[\int_0^L k\,u'\,v'\,dx = \int_0^L q\,v\,dx .\]

It only asks for first derivatives, which a broken line has. If one end of your bar is insulated instead, the test function no longer has to vanish there: that end keeps its node, and the boundary term drops out anyway, because the flux \(k\,u'\) is zero. Only a held temperature removes a node; if the block is warmer than the room, its known value times its column of K moves to the right side.

Insert \(u = \sum_j U_j\varphi_j\) and \(v = \varphi_i\), one equation per hat, and you get \(KU = f\) with

\[K_{ij} = \int_0^L k\,\varphi_i'\varphi_j'\,dx, \qquad f_i = \int_0^L q\,\varphi_i\,dx .\]

K is the stiffness matrix and f the load vector. On one element only two hats are nonzero, with slopes \(+1/h\) and \(-1/h\), so each entry is slope times slope times k times the length: \((\pm 1/h)(\pm 1/h)\,k\,h = \pm k/h\). The element matrix is \((k/h)\begin{bmatrix}1 & -1\\ -1 & 1\end{bmatrix}\) and the element load \((qh/2)[1, 1]\), half a hat's area per node. The loop adds them up and drops the rows and columns of the two held end nodes:

n = 4
h = L / n
nodes = np.linspace(0, L, n + 1)
K_all, f_all = np.zeros((n + 1, n + 1)), np.zeros(n + 1)
for e in range(n):                                 # element e joins nodes e and e + 1
    pair = [e, e + 1]
    K_all[np.ix_(pair, pair)] += k / h * np.array([[1, -1], [-1, 1]])   # np.ix_: the 2 x 2 block of the pair
    f_all[pair] += q * h / 2
K, f = K_all[1:-1, 1:-1], f_all[1:-1]              # the ends are held: no unknowns there
U = np.linalg.solve(K, f)

print(f"K =\n{K}\nf = {f}\nU = {U}")
print("U equals the exact nodal values:", np.allclose(U, exact(nodes[1:-1]), rtol=0, atol=1e-15))
tridiag = 2 * np.eye(n - 1) - np.eye(n - 1, k=1) - np.eye(n - 1, k=-1)
print("h K / k equals the [-1, 2, -1] matrix:", np.allclose(h * K / k, tridiag))
K =
[[ 8. -4.  0.]
 [-4.  8. -4.]
 [ 0. -4.  8.]]
f = [0.25 0.25 0.25]
U = [0.09375 0.125   0.09375]
U equals the exact nodal values: True
h K / k equals the [-1, 2, -1] matrix: True

Each row of K is the balance of the previous section, and the solve returns 0.09375, 0.125, and 0.09375.

The same matrix as finite differences, on this mesh. Divide the balance by h and it reads \(k(-U_{i-1} + 2U_i - U_{i+1})/h^2 = q\). The fraction is the change of slope across one spacing divided by the spacing, the approximation of \(u''\) that finite differences write at every node, so \(hK/k\) has rows \([-1, 2, -1]\), as the output confirms. On unequal elements each row carries the k/h of its own two elements, so no single h turns K into \([-1, 2, -1]\), and a varying q is averaged against the hat where finite differences take its value at the node. Finite differences: the heat equation as a matrix, and why the time step has a limit takes the other side.

Best in the energy sense, and why the nodes are exact. Measure how wrong the slopes of a broken line w are by \(E(w) = \int_0^L (u' - w')^2\,dx\); with k in front it is the squared energy norm, and for the bar it measures the error in the heat flux. Among all broken lines on the mesh, \(u_h\) makes E smallest. Take \(\int a'b'\,dx\) as the dot product of two functions a and b; the squared length of \(u - w\) is then E. Subtract the weak form for \(u_h\) from the one for u, and the error \(e = u - u_h\) satisfies \(\int e'\varphi_i'\,dx = 0\) for every hat: in this dot product the error is perpendicular to every hat. As in least squares, the closest point of a plane is the one whose error is perpendicular to the plane, and there is only one closest point.

Show code
def energy_error(U_inner, n=4):
    """E(w) = integral of (u' - w')², with the slope of w constant on each element."""
    xm = (np.arange(400_000) + 0.5) / 400_000      # midpoints of a fine grid, never on a kink
    slope = np.diff(np.r_[0, U_inner, 0]) * n / L
    element = np.minimum((xm * n).astype(int), n - 1)
    return np.mean((q * (L - 2 * xm) / (2 * k) - slope[element]) ** 2) * L

for label, U_try in [("finite element weights", U),
                     ("middle weight + 0.01", U + [0, 0.01, 0]),
                     ("arbitrary weights", U_guess[1:-1])]:
    print(f"{label:24s} E = {energy_error(U_try):.5f}")
finite element weights   E = 0.00521
middle weight + 0.01     E = 0.00601
arbitrary weights        E = 0.05833

Moving the middle weight by 0.01 raises E from 0.00521 to 0.00601, and the arbitrary weights reach 0.0583, eleven times the minimum.

Now the nodes. On each element \(\varphi_i'\) is constant, and the chord through the true end values has the same mean slope as the parabola, so the interpolant, the broken line through the exact nodal values, already makes every \(\int e'\varphi_i'\,dx\) zero. It is the closest point, so it is \(u_h\). Exact nodes are special to one dimension. On a triangle mesh in two dimensions the nodal values carry error in general.

The error between nodes and in the slope. Because \(u_h\) is the interpolant, the gap on each element is the chord-to-parabola distance, largest at the midpoint, \(h^2|u''|/8 = h^2/8\) in these units.

Show code
print(" n   max |u - u_h|   ratio   end slope (exact 0.5)")
previous = None
for n_el in (4, 8, 16):
    nd, Un = fem_bar(n_el)
    gap = np.max(np.abs(exact(x) - np.interp(x, nd, Un)))    # on 4001 points, not only at the nodes
    ratio = f"{previous / gap:5.2f}" if previous else "     "
    print(f"{n_el:2d}   {gap:13.8f}   {ratio}   {Un[1] / (L / n_el):.5f}")
    previous = gap

nodes8, U8 = fem_bar(8)
fig, axes = plt.subplots(2, 1, figsize=(8, 4.4), sharex=True)
axes[0].plot(x, exact(x), color=INK)
axes[0].plot(nodes4, U4, "o-", color=ACCENT, alpha=0.5, ms=5)
axes[0].plot(nodes8, U8, "o-", color=ACCENT, ms=4)
axes[0].text(0.5, 0.133, "exact", color=INK, ha="center")
axes[0].annotate("4 elements", xy=(0.15, U4[1] * 0.15 / 0.25), xytext=(0.2, 0.025), color=ACCENT, alpha=0.6,
                 arrowprops=dict(arrowstyle="-", color=ACCENT, alpha=0.6, lw=1))
axes[0].annotate("8 elements", xy=(0.875, U8[7]), xytext=(0.8, 0.025), color=ACCENT, ha="right",
                 arrowprops=dict(arrowstyle="-", color=ACCENT, lw=1))
axes[0].set(ylabel="u / (qL²/k)", ylim=(0, 0.15))
for nd, Un, alpha, n_el in ((nodes4, U4, 0.5, 4), (nodes8, U8, 1.0, 8)):
    axes[1].plot(x, exact(x) - np.interp(x, nd, Un), color=ACCENT, alpha=alpha)
    level = (L / n_el) ** 2 / 8
    axes[1].axhline(level, color=MUTED, ls="--", lw=1)
    axes[1].text(1.01, level, f"{level:.4f}", color=MUTED, va="center", clip_on=False)  # beside the axes, off the arches
axes[1].set(xlabel="x / L", ylabel="error / (qL²/k)", xlim=(0, 1), ylim=(0, 0.0095),
            yticks=[0, 0.002, 0.004, 0.006, 0.008])
plt.show()
 n   max |u - u_h|   ratio   end slope (exact 0.5)
 4      0.00781250           0.37500
 8      0.00195312    4.00   0.43750
16      0.00048828    4.00   0.46875
Two panels over the bar, in units of qL²/k. Top: exact parabola and the four- and eight-element solutions. Bottom: the error between nodes, arches of height 0.0078 for four elements and 0.0020 for eight, zero at every node.

Four elements leave a gap of 0.0078, eight 0.0020, sixteen 0.00049: a factor of 4.00 per halving, and the arches touch zero at every node, so a convergence check at the nodes alone would show nothing. The slope error is h/2, so the heat flux into the blocks comes out as 0.375 against the exact 0.5 with four elements and 0.4375 with eight. It halves with h instead of quartering. The quantity you report decides how fine the mesh must be.

See it in code

The scikit-fem library does the assembly from the weak form, written once for any mesh. Here is the same bar, with four and eight elements:

Show code
from skfem import Basis, ElementLineP1, MeshLine, asm, condense, solve
from skfem.models.poisson import laplace, unit_load

for n_el, U_hand in ((4, U4), (8, U8)):
    mesh = MeshLine(np.linspace(0, L, n_el + 1))
    basis = Basis(mesh, ElementLineP1())
    A = asm(laplace, basis)                        # the integral of u'v' for every pair of hats
    b = asm(unit_load, basis)                      # the integral of v, q = 1, for every hat
    held = condense(A, b, D=mesh.boundary_nodes())  # drop the rows and columns of the end nodes
    u_fem = solve(*held)                           # solves, then puts the held zeros back in
    A_inner = held[0]
    if n_el == 4:
        print(f"stiffness matrix without the end nodes:\n{A_inner.toarray()}")
    print(f"{n_el} elements: U = {np.round(u_fem[1:-1], 5)}")
    print(f"  largest difference from the hand-built U: {np.max(np.abs(u_fem - U_hand)):.0e}")
stiffness matrix without the end nodes:
[[ 8. -4.  0.]
 [-4.  8. -4.]
 [ 0. -4.  8.]]
4 elements: U = [0.09375 0.125   0.09375]
  largest difference from the hand-built U: 1e-17
8 elements: U = [0.05469 0.09375 0.11719 0.125   0.11719 0.09375 0.05469]
  largest difference from the hand-built U: 1e-17

The matrix scikit-fem prints is the hand-built one, rows of 8 and −4, and its nodal values agree with the hand-built ones to rounding, for four elements and for eight. laplace is the left side of the weak form with k = 1, unit_load the right side with q = 1, and condense removes the two held end nodes, as the slicing did above. What the library adds is generality: the element matrices come from numerical integration over a reference element, so the same three lines run on a triangle mesh in two dimensions, which is where scikit-fem from the ground up takes them.

Where it shows up

The heated bar is the simplest case of a pattern that runs through engineering and physics: a quantity spreads by a flux proportional to its gradient, inside a shape too awkward for a formula.

  • Stress in structures. Linear elasticity is the vector version of the bar, with displacement for temperature and Young's modulus for conductivity. The words "stiffness matrix" and "load vector" come from here: Boeing engineers in the 1950s computed aircraft wing panels as assemblies of small elastic elements.
  • Heat in housings. An electronics enclosure or an engine part obeys \(-\nabla\cdot(k\nabla T) = q\), with k jumping where an aluminum wall meets an air gap. Each element carries its own k, which is the same freedom the bar had with unequal element lengths.
  • Groundwater under a dam. Darcy flow, \(-\nabla\cdot(K\nabla h) = 0\) for the hydraulic head, gives the seepage under a foundation and the uplift pressure on its base. findiff.PDE with mixed boundary conditions: seepage under a dam solves it on a grid.
  • Electrostatics. The potential around electrodes obeys \(-\nabla\cdot(\varepsilon\nabla\phi) = \rho\), and the field and capacitance of electrodes of arbitrary shape come from its finite element solution. Poisson's equation with scipy.sparse: two plates in a grounded box does the box-shaped case on a grid.
  • Bone mechanics. Finite element models of a femur or a vertebra are built from CT scans, one element per voxel, each with a stiffness that follows from the bone density the scan measured. The model predicts the load at which the bone will fracture.

In every case the number that matters is a gradient (stress, heat flux, seepage velocity, field strength), the quantity whose error on the bar fell only in proportion to h.

Further reading

Was this tutorial helpful? Sign in to tell the author with one click.

Found a mistake, or something unclear? Report a problem (with a free account).

Cite this tutorial

SciStack (2026). Finite elements: hat functions, the weak form, and a heated bar. https://scistack.dev/t/py-finite-elements/ (accessed 2026-10-17).

@online{scistack-py-finite-elements,
  author  = {{SciStack}},
  title   = {Finite elements: hat functions, the weak form, and a heated bar},
  date    = {2026-10-17},
  url     = {https://scistack.dev/t/py-finite-elements/},
  urldate = {2026-10-17},
  note    = {numpy 2.4.3, skfem 12.0.2, matplotlib 3.11.2}
}

Tags

asmcondenseelementlinep1matplotlibmeshlinenumpynumpy.linalg.solvescikit-femskfemskfem.models.poisson

Comments

No comments yet.

Sign in to comment, with a free account.