|
| 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