Skip to content

Commit 4b55df6

Browse files
authored
Merge pull request #235 from underworldcode/feature/constant-nullspace-test
tests: cover SNES_Scalar.constant_nullspace
2 parents da4573a + 3ca9449 commit 4b55df6

1 file changed

Lines changed: 125 additions & 0 deletions

File tree

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""Regression test for ``SNES_Scalar.constant_nullspace``.
2+
3+
A scalar Poisson problem with no Dirichlet boundary conditions has a
4+
constant kernel (any constant solves :math:`-\\Delta u = 0`). The linear
5+
system is singular and the solution is determined only up to an additive
6+
constant.
7+
8+
The ``constant_nullspace`` flag tells PETSc about the
9+
constant nullspace via ``MatNullSpace().create(constant=True)``. PETSc
10+
projects each KSP right-hand side onto the orthogonal complement of the
11+
nullspace before solving and returns the minimum-norm solution within
12+
the affine null space — i.e. the solution with (discrete) zero mean.
13+
14+
We verify:
15+
1. The shape of the solution (non-constant part) is the same with or
16+
without the flag — PETSc's projection lives entirely in the
17+
constant kernel, so the non-constant component is unaffected.
18+
2. With the flag, the discrete mean of the solution is small
19+
(minimum-norm pinning).
20+
3. The recovered field agrees with the analytic solution
21+
:math:`u(x, y) = -x^3/6 + x^2/4 + C` to FE-discretisation
22+
accuracy.
23+
"""
24+
import numpy as np
25+
import pytest
26+
import sympy
27+
28+
import underworld3 as uw
29+
from underworld3.systems import Poisson
30+
31+
pytestmark = [pytest.mark.tier_a, pytest.mark.level_1]
32+
33+
34+
def _build_neumann_poisson(mesh, use_nullspace):
35+
"""Build a pure-Neumann Poisson with f = x - 1/2 (zero mean)."""
36+
T = uw.discretisation.MeshVariable(
37+
f"T_ns_{use_nullspace}", mesh, num_components=1, degree=2,
38+
)
39+
poisson = Poisson(mesh, u_Field=T)
40+
poisson.constitutive_model = uw.constitutive_models.DiffusionModel
41+
poisson.constitutive_model.Parameters.diffusivity = 1.0
42+
poisson.constant_nullspace = use_nullspace
43+
x, _ = mesh.X
44+
poisson.f = x - sympy.Rational(1, 2)
45+
return poisson, T
46+
47+
48+
def _shape_error(T, mesh):
49+
"""Discretisation error in the non-constant part of the solution."""
50+
coords = np.asarray(T.coords)
51+
expected = -coords[:, 0] ** 3 / 6.0 + coords[:, 0] ** 2 / 4.0
52+
diff = T.data[:, 0] - expected
53+
diff_centered = diff - diff.mean()
54+
return np.abs(diff_centered).max()
55+
56+
57+
def test_constant_nullspace_canonical_solution():
58+
"""With ``constant_nullspace=True`` the solver returns the
59+
minimum-norm (near-zero-mean) solution."""
60+
mesh = uw.meshing.UnstructuredSimplexBox(
61+
minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.1,
62+
)
63+
64+
poisson_off, T_off = _build_neumann_poisson(mesh, use_nullspace=False)
65+
poisson_off.solve()
66+
assert poisson_off.snes.getConvergedReason() > 0, (
67+
"Pure-Neumann Poisson without nullspace declaration failed to "
68+
f"converge: SNES reason {poisson_off.snes.getConvergedReason()}"
69+
)
70+
71+
poisson_on, T_on = _build_neumann_poisson(mesh, use_nullspace=True)
72+
poisson_on.solve()
73+
assert poisson_on.snes.getConvergedReason() > 0, (
74+
"Pure-Neumann Poisson with nullspace declaration failed to "
75+
f"converge: SNES reason {poisson_on.snes.getConvergedReason()}"
76+
)
77+
78+
# Both should recover the analytic shape to FE accuracy.
79+
err_off = _shape_error(T_off, mesh)
80+
err_on = _shape_error(T_on, mesh)
81+
assert err_off < 1.0e-4, f"shape error off = {err_off:.3e}"
82+
assert err_on < 1.0e-4, f"shape error on = {err_on:.3e}"
83+
84+
# The flag should leave the non-constant part of the solution
85+
# essentially unchanged — PETSc's projection lives in the kernel.
86+
assert abs(err_off - err_on) < 1.0e-5, (
87+
f"shape error differs more than expected between paths: "
88+
f"off={err_off:.3e}, on={err_on:.3e}"
89+
)
90+
91+
# With the flag, the mean of the solution should be much closer to
92+
# zero than without (canonical minimum-norm pinning). The exact
93+
# value depends on the discrete inner product against the constant
94+
# mode, but the flag should move it toward zero by a clear margin.
95+
mean_off = T_off.data[:, 0].mean()
96+
mean_on = T_on.data[:, 0].mean()
97+
assert abs(mean_on) < abs(mean_off), (
98+
f"with nullspace declaration, |mean(T)| should drop; "
99+
f"got |mean_off|={abs(mean_off):.3e}, |mean_on|={abs(mean_on):.3e}"
100+
)
101+
102+
103+
def test_constant_nullspace_property_setter():
104+
"""Property setter round-trip and default."""
105+
mesh = uw.meshing.UnstructuredSimplexBox(
106+
minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.2,
107+
)
108+
T = uw.discretisation.MeshVariable(
109+
"T_setter", mesh, num_components=1, degree=1,
110+
)
111+
poisson = Poisson(mesh, u_Field=T)
112+
113+
assert poisson.constant_nullspace is False, "Default should be False"
114+
115+
poisson.constant_nullspace = True
116+
assert poisson.constant_nullspace is True
117+
118+
poisson.constant_nullspace = False
119+
assert poisson.constant_nullspace is False
120+
121+
122+
if __name__ == "__main__":
123+
test_constant_nullspace_property_setter()
124+
test_constant_nullspace_canonical_solution()
125+
print("PASSED")

0 commit comments

Comments
 (0)