Algorithm Search⌘K

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
multigrid_method.pyRawGitHub
# 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

Read the full page about Multigrid method