Multigrid method in Python
Solve on a hierarchy of coarser grids to damp errors at every frequency.
- File
multigrid_method.py, 115 lines- Level
- I0 not checked yet
- Toolchain
- Python 3.14, pytest 9
- Test framework
- pytest
- Run the tests
python3 -m pytest
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/multigrid-method/. The authors are listed in prov/ZXX910.jsonl.
"""Geometric multigrid V-cycles for the model problem -u'' = f on (0, 1), u(0) = u(1) = 0.
The grid on level l has n_l = 2^l - 1 interior points and spacing h_l = 1/2^l;
A_l = tridiag(-1, 2, -1) / h_l^2. Restriction is full weighting, prolongation
is linear interpolation, and the coarsest level (one unknown) is solved exactly.
The V-cycle is written as two loops over the levels, without recursion.
Values may be floats or Fractions (with a Fraction omega).
"""
import math
from fractions import Fraction
def level_of(n: int) -> int:
"""L with n = 2^L - 1, or ValueError."""
L = (n + 1).bit_length() - 1
if n < 1 or (1 << L) - 1 != n:
raise ValueError("n must be 2^L - 1 with L >= 1, got %d" % n)
return L
def apply_a(u: list, h2inv) -> list:
"""A u for A = tridiag(-1, 2, -1) / h^2 with zero boundary values; 4n flops."""
n = len(u)
return [(2 * u[i] - (u[i - 1] if i > 0 else 0) - (u[i + 1] if i < n - 1 else 0)) * h2inv
for i in range(n)]
def residual(f: list, u: list, h2inv) -> list:
au = apply_a(u, h2inv)
return [fi - ai for fi, ai in zip(f, au)]
def smooth(u: list, f: list, h2inv, sweeps: int, kind: str = "jacobi", omega=None) -> list:
"""sweeps of weighted Jacobi (default omega 2/3) or forward Gauss-Seidel."""
n = len(u)
if kind == "jacobi":
w = Fraction(2, 3) if omega is None and isinstance(u[0], Fraction) else (2 / 3 if omega is None else omega)
for _ in range(sweeps):
r = residual(f, u, h2inv)
u = [ui + w * ri / (2 * h2inv) for ui, ri in zip(u, r)]
return u
if kind == "gs":
u = list(u)
h2 = 1 / h2inv
for _ in range(sweeps):
for i in range(n):
s = f[i] * h2 + (u[i - 1] if i > 0 else 0) + (u[i + 1] if i < n - 1 else 0)
u[i] = s / 2
return u
raise ValueError("unknown smoother " + kind)
def restrict(r: list) -> list:
"""Full weighting: coarse i <- (r[2i] + 2 r[2i+1] + r[2i+2]) / 4."""
return [(r[2 * i] + 2 * r[2 * i + 1] + r[2 * i + 2]) / 4 for i in range((len(r) - 1) // 2)]
def prolong(c: list) -> list:
"""Linear interpolation from n_c to 2 n_c + 1 points."""
nf = 2 * len(c) + 1
zero = c[0] * 0
e = [zero] * nf
for i, ci in enumerate(c):
e[2 * i + 1] = ci
for j in range(0, nf, 2):
left = c[j // 2 - 1] if j // 2 - 1 >= 0 else zero
right = c[j // 2] if j // 2 < len(c) else zero
e[j] = (left + right) / 2
return e
def v_cycle(u: list, f: list, nu1: int = 2, nu2: int = 1, kind: str = "jacobi", omega=None) -> list:
"""One V(nu1, nu2)-cycle for A_L u = f on the finest level L; returns the new u."""
L = level_of(len(f))
us, fs = {L: list(u)}, {L: list(f)}
for l in range(L, 1, -1): # @line L3
h2inv = 4 ** l
us[l] = smooth(us[l], fs[l], h2inv, nu1, kind, omega) # @line L4
fs[l - 1] = restrict(residual(fs[l], us[l], h2inv)) # @line L5
us[l - 1] = [fs[l - 1][0] * 0] * len(fs[l - 1])
us[1] = [fs[1][0] / (2 * 4)] # @line L6
for l in range(2, L + 1): # @line L7
e = prolong(us[l - 1])
us[l] = [a + b for a, b in zip(us[l], e)] # @line L8
us[l] = smooth(us[l], fs[l], 4 ** l, nu2, kind, omega) # @line L9
return us[L]
def multigrid(f: list, u0: list | None = None, tol: float = 1e-10, maxiter: int = 100,
nu1: int = 2, nu2: int = 1, kind: str = "jacobi", omega=None,
history: list | None = None) -> tuple[list, int, bool]:
"""Repeat V-cycles until ||f - A u||_2 <= tol ||f||_2. Returns (u, cycles, converged)."""
n = len(f)
L = level_of(n)
u = list(u0) if u0 is not None else [f[0] * 0] * n # @line L1
nf = math.sqrt(sum(float(v) ** 2 for v in f)) or 1.0
for it in range(1, maxiter + 1): # @line L2
u = v_cycle(u, f, nu1, nu2, kind, omega)
rn = math.sqrt(sum(float(v) ** 2 for v in residual(f, u, 4 ** L)))
if history is not None:
history.append(rn / nf)
if not rn < 1e300:
return u, it, False
if rn <= tol * nf: # @line L10
return u, it, True
return u, maxiter, False # @line L11
def jacobi_damping(n: int, k: int, omega: float) -> float:
"""Factor by which one weighted Jacobi sweep multiplies the k-th sine mode: 1 - 2 omega sin^2(k pi / (2(n+1)))."""
return 1 - 2 * omega * math.sin(k * math.pi / (2 * (n + 1))) ** 2
test_multigrid_method.py 140 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/multigrid-method/. The authors are listed in prov/ZXX910.jsonl.
import math
import random
from fractions import Fraction
import pytest
from multigrid_method import (
apply_a, jacobi_damping, level_of, multigrid, prolong, restrict, smooth, v_cycle,
)
def exact_solve(f, h2inv):
"""Thomas algorithm for tridiag(-1, 2, -1) h^-2 u = f in exact arithmetic."""
n = len(f)
d = [Fraction(v) / h2inv for v in f]
c, dd = [Fraction(0)] * n, [Fraction(0)] * n
piv = Fraction(2)
c[0], dd[0] = Fraction(-1) / piv, d[0] / piv
for i in range(1, n):
piv = 2 + c[i - 1]
c[i] = Fraction(-1) / piv
dd[i] = (d[i] + dd[i - 1]) / piv
u = [Fraction(0)] * n
u[-1] = dd[-1]
for i in range(n - 2, -1, -1):
u[i] = dd[i] - c[i] * u[i + 1]
return u
def kappa(n):
return (math.sin(n * math.pi / (2 * (n + 1))) / math.sin(math.pi / (2 * (n + 1)))) ** 2
def norm(v):
return math.sqrt(sum(float(x) ** 2 for x in v))
def test_level_of():
assert [level_of(n) for n in (1, 3, 7, 1023)] == [1, 2, 3, 10]
for bad in (0, 2, 6, 8):
with pytest.raises(ValueError):
level_of(bad)
def test_single_unknown_is_solved_exactly():
u, k, conv = multigrid([Fraction(3)], tol=0)
assert u == [Fraction(3, 8)] and k == 1 and conv
u, k, conv = multigrid([3.0])
assert conv and k == 1 and u == [3.0 / 8]
def test_galerkin_property_exact():
# R A_h P = A_2h for full weighting and linear interpolation
for L in (2, 3, 4):
nc = 2 ** (L - 1) - 1
for j in range(nc):
e = [Fraction(int(i == j)) for i in range(nc)]
lhs = restrict(apply_a(prolong(e), 4 ** L))
rhs = apply_a(e, 4 ** (L - 1))
assert lhs == rhs
def test_exact_solution_is_a_fixed_point():
rng = random.Random(3)
f = [Fraction(rng.randint(-9, 9)) for _ in range(15)]
u = exact_solve(f, 4 ** 4)
assert v_cycle(u, f) == u
assert v_cycle(u, f, 1, 1, "gs") == u
def test_exact_two_grid_contraction():
rng = random.Random(4)
for _ in range(5):
f = [Fraction(rng.randint(-9, 9)) for _ in range(15)]
x = exact_solve(f, 4 ** 4)
u = [Fraction(0)] * 15
e0 = norm([a - b for a, b in zip(u, x)])
for _ in range(3):
u = v_cycle(u, f)
e3 = norm([a - b for a, b in zip(u, x)])
assert e3 <= 0.2 ** 3 * e0
def test_iteration_count_independent_of_n():
for kind, nu, cap in (("jacobi", (2, 1), 13), ("gs", (1, 1), 14)):
counts = []
for L in range(3, 12):
n = 2 ** L - 1
rng = random.Random(100 + L)
f = [rng.uniform(-1, 1) for _ in range(n)]
u, k, conv = multigrid(f, tol=1e-10, nu1=nu[0], nu2=nu[1], kind=kind)
assert conv and k <= cap
if L >= 6:
counts.append(k)
assert max(counts) - min(counts) <= 2 # h-independent once past small grids
def test_random_rhs_error_bound():
for seed in range(12):
rng = random.Random(seed)
L = rng.randint(2, 8)
n = 2 ** L - 1
f = [rng.uniform(-5, 5) for _ in range(n)]
u, k, conv = multigrid(f, tol=1e-9)
x = [float(v) for v in exact_solve(f, 4 ** L)]
rel = norm([a - b for a, b in zip(f, apply_a(u, 4 ** L))]) / norm(f)
err = norm([a - b for a, b in zip(u, x)]) / norm(x)
assert conv and rel <= 1e-9
assert err <= kappa(n) * rel * 1.001 + 1e-14
def test_jacobi_smoothing_factor():
n, w = 63, 2 / 3
for k in range((n + 1) // 2, n + 1):
assert abs(jacobi_damping(n, k, w)) <= 1 / 3 + 1e-12
assert jacobi_damping(n, 1, w) > 0.998 # smooth modes are barely touched
for k in (1, 17, 40, 63):
mode = [math.sin(k * math.pi * (i + 1) / (n + 1)) for i in range(n)]
out = smooth(mode, [0.0] * n, 4 ** 6, 1, "jacobi", w)
g = jacobi_damping(n, k, w)
assert max(abs(a - g * b) for a, b in zip(out, mode)) <= 1e-12
def test_divergence_with_bad_relaxation():
h = []
u, k, conv = multigrid([1.0] * 63, omega=1.5, maxiter=30, history=h)
assert not conv and h[-1] > 1e10
def test_stagnation_at_rounding_level():
rng = random.Random(8)
f = [rng.uniform(-1, 1) for _ in range(255)]
h = []
u, k, conv = multigrid(f, tol=1e-18, maxiter=25, history=h)
assert not conv
assert 1e-18 < min(h) < 1e-12
assert max(h[-5:]) / min(h[-5:]) < 100
What the next level needs
- the tests did not run in this build