239 lines
8.3 KiB
Python
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))
|