Files
sim-numerics/relax/sor.py
T

239 lines
8.3 KiB
Python

"""Relaxation for iterative linear solvers, with the relaxation factor
pluggable.
The relaxation parameter omega of SOR is the oldest hand-tuned constant in
numerical linear algebra. Convergence requires 0 < omega < 2; the optimal
value is known in closed form only for model problems, and everywhere else it
is guessed, swept, or left at a folklore value - the under-relaxation factors
of 0.7 and 0.3 in every SIMPLE-type CFD code being the best-known example.
The similarity formulation. SOR over-relaxes because the Gauss-Seidel
correction under-shoots CONSISTENTLY IN THE SAME DIRECTION: the iteration
creeps towards the solution instead of stepping to it. How consistent the
direction is, is a similarity between successive corrections:
s = cos(d_k, d_{k-1}) in [-1, 1], omega = 1 + max(0, s) in [1, 2]
s = 1 means the correction is repeating itself exactly, so the step should be
doubled - the maximum over-relaxation the theory allows. s <= 0 means the
corrections are fighting each other, so no over-relaxation is warranted and
the method falls back to Gauss-Seidel. The admissible interval of the theory
and the range of the similarity coincide, and nothing is chosen.
The sweeps below are written out because SciPy ships no SOR. They are
validated against theory: for the model Poisson problem the SOR spectral
radius is exactly omega_opt - 1, which the measured convergence rate must
reproduce.
"""
from __future__ import annotations
import math
import numpy as np
def poisson_rhs(n, rng=None):
"""A right-hand side on an n x n interior grid."""
if rng is None:
rng = np.random.default_rng(0)
return rng.standard_normal((n, n))
def sweep_gs(u, f, h2, omega, coeff=None):
"""One Gauss-Seidel / SOR sweep in place, returning the correction.
coeff is None for the 5-point Laplacian; otherwise (cw, ce, cs, cn, cc)
arrays giving a variable-coefficient or anisotropic stencil.
"""
n = u.shape[0]
before = u.copy()
if coeff is None:
for i in range(n):
for j in range(n):
s = 0.0
if i > 0:
s += u[i - 1, j]
if i < n - 1:
s += u[i + 1, j]
if j > 0:
s += u[i, j - 1]
if j < n - 1:
s += u[i, j + 1]
new = (s - h2 * f[i, j]) / 4.0
u[i, j] += omega * (new - u[i, j])
else:
cw, ce, cs, cn, cc = coeff
for i in range(n):
for j in range(n):
s = 0.0
if i > 0:
s += cw[i, j] * u[i - 1, j]
if i < n - 1:
s += ce[i, j] * u[i + 1, j]
if j > 0:
s += cs[i, j] * u[i, j - 1]
if j < n - 1:
s += cn[i, j] * u[i, j + 1]
new = (s - h2 * f[i, j]) / cc[i, j]
u[i, j] += omega * (new - u[i, j])
return u - before
def residual(u, f, h2, coeff=None):
n = u.shape[0]
r = np.zeros_like(u)
if coeff is None:
for i in range(n):
for j in range(n):
s = -4.0 * u[i, j]
if i > 0:
s += u[i - 1, j]
if i < n - 1:
s += u[i + 1, j]
if j > 0:
s += u[i, j - 1]
if j < n - 1:
s += u[i, j + 1]
r[i, j] = h2 * f[i, j] - s
else:
cw, ce, cs, cn, cc = coeff
for i in range(n):
for j in range(n):
s = -cc[i, j] * u[i, j]
if i > 0:
s += cw[i, j] * u[i - 1, j]
if i < n - 1:
s += ce[i, j] * u[i + 1, j]
if j > 0:
s += cs[i, j] * u[i, j - 1]
if j < n - 1:
s += cn[i, j] * u[i, j + 1]
r[i, j] = h2 * f[i, j] - s
return r
class Fixed:
def __init__(self, omega):
self.omega = omega
self.name = f"omega = {omega:g}"
self.constants = 1
def reset(self):
pass
def next_omega(self, d_prev, d_now):
return self.omega
class OptimalPoisson(Fixed):
"""The closed-form optimum for the model problem - an oracle that needs no
tuning because the answer is a theorem."""
def __init__(self, n):
w = 2.0 / (1.0 + math.sin(math.pi / (n + 1)))
super().__init__(w)
self.name = f"omega* = {w:.4f} (theory)"
class SimilarityOmega:
"""omega = 1 + max(0, cos(d_k, d_{k-1})).
The first sweep has no predecessor to compare with, so it runs at
Gauss-Seidel - the same licence granted in the root-finding safeguard.
"""
name = "similarity (no constants)"
constants = 0
def reset(self):
pass
def next_omega(self, d_prev, d_now):
if d_prev is None or d_now is None:
return 1.0
a = float(np.vdot(d_prev, d_now))
na = float(np.linalg.norm(d_prev)) * float(np.linalg.norm(d_now))
if na <= 0.0 or not math.isfinite(na):
return 1.0
return 1.0 + max(0.0, a / na)
class ContractionOmega:
"""omega driven by the contraction the iteration actually achieves.
The direction-similarity rule above over-relaxes: the cosine between
successive SOR corrections sits near 1 almost always, so 1 + cos saturates
at 2. Measured, it picks 1.87 where the optimum is 1.777 and 1.43 where
it is 1.2. The measure was right and the map into omega was not.
The map that is derived rather than chosen comes from the theory itself:
for omega >= omega* the SOR spectral radius is exactly omega - 1. So the
observed contraction of the residual, rho = ||r_k|| / ||r_{k-1}||, says on
which side of the optimum the iteration is sitting:
rho > omega - 1 -> below the optimum, lengthen
rho ~ omega - 1 -> at or above it, hold
and the correction is the discrepancy itself,
omega <- omega + (rho - (omega - 1)), clipped to [1, 2).
The clip is the convergence condition of the method, not a tuning range,
and the first sweep runs at omega = 1 for want of a previous residual.
REFUTED, and worse than the rule it was meant to repair. Measured: 235
sweeps against the direction rule's 107 on the model problem, 589 against
102 on the anisotropic one. The update is undamped, so on the first
sweep, with omega = 1 and rho near 1, it sets omega = 1 + (1 - 0) = 2 and
stays pinned at the limit. Damping it would take a constant, which is the
thing the exercise is trying to avoid, so the line stops here rather than
trying a third map.
"""
name = "contraction-driven (no constants)"
constants = 0
def reset(self):
self.omega = 1.0
self.r_prev = None
def next_omega(self, d_prev, d_now):
return self.omega
def note_residual(self, rn):
if self.r_prev is not None and self.r_prev > 0.0:
rho = rn / self.r_prev
if math.isfinite(rho):
w = self.omega + (rho - (self.omega - 1.0))
# 2 is the convergence limit of SOR, 1 is Gauss-Seidel
self.omega = min(1.999, max(1.0, w))
self.r_prev = rn
def solve(f, rule, tol=1e-8, maxit=20000, coeff=None):
"""Iterate to ||r|| <= tol * ||r_0||. Cost is sweeps, which is what a
practitioner pays. Everything except `rule` is shared."""
n = f.shape[0]
h2 = 1.0 / (n + 1) ** 2
u = np.zeros_like(f)
r0 = float(np.linalg.norm(residual(u, f, h2, coeff)))
if r0 == 0.0:
return 0, 0.0
rule.reset()
d1 = d2 = None # the two most recent corrections, newest first
omegas = []
for k in range(1, maxit + 1):
# the rule sees the last two corrections; on the first two sweeps one
# of them does not exist yet and the rule returns 1 (Gauss-Seidel)
omega = rule.next_omega(d2, d1)
omegas.append(omega)
d1, d2 = sweep_gs(u, f, h2, omega, coeff), d1
rn = float(np.linalg.norm(residual(u, f, h2, coeff)))
if hasattr(rule, "note_residual"):
rule.note_residual(rn)
if not math.isfinite(rn):
return maxit, float("nan")
if rn <= tol * r0:
return k, float(np.mean(omegas))
return maxit, float(np.mean(omegas))