Rotated strong free-slip loses the pressure gauge as soon as the boundary is curved. On a straight-walled box the constant pressure is pinned to machine zero and the solve is reproducible; deform the top of the same box and the pressure level runs away to ~1e4 while the pressure variation is 6.6e-2, and the answer stops being unique: a change of two machine epsilons in the mesh coordinates moves the velocity by 13%.
This is not a parallel effect (serial, np=1), not the workspace cache (the solver below is built fresh, after the deform), and not a convergence failure — every solve reports converged with a relative residual around 3e-10 against a tolerance of 1e-9.
Measured
Same 10x10 StructuredQuadBox, same load, penalty = 0, tolerance = 1e-9, rotated free-slip on all four walls, petsc_use_pressure_nullspace = True. Each configuration solved twice, the second time with every coordinate multiplied by 1 + 2*eps (a relative change of 4.44e-16):
| configuration |
mean(p) |
std(p) |
rel. move in v under a 2-eps coordinate change |
| rotated free-slip, straight walls |
6.50e-14 |
6.42e-2 |
5.9e-15 |
rotated free-slip, top deformed by 0.02 y sin(pi x) |
+1.86e+04 |
6.64e-2 |
1.33e-01 |
| essential free-slip, same deformed mesh |
-1.06e-16 |
6.73e-2 |
4.8e-13 |
The essential free-slip control is the point: on the same deformed mesh, with the same nullspace flag, the constant is pinned to 1e-16 and the answer is stable to 5e-13. Only the rotated path loses it.
The pressure constant is not merely large, it is arbitrary — the two runs above differ by +1.69e4 in the constant, and the size of the jump does not track the size of the perturbation (4.4e-16, 2.2e-15, 1e-14, 1e-12 and 1e-9 coordinate changes all move the velocity by between 4e-2 and 2e-1). That is the signature of a direction the operator does not pin at all: its amplitude is set by round-off, not by the data.
Decomposing the difference between two such solves: 99.95% of it is off the rigid-body span, so it is not the rigid-rotation gauge (which _mode_satisfies_constraints correctly rejects on a box — measured violation 3.045e-01 against a threshold of 1e-8, a margin of seven orders of magnitude). It is the constant-pressure mode, which drags the velocity with it because penalty = 0 leaves the constraint rows to couple them.
Why it matters beyond a test
- Any rotated free-slip model on a curved, deformed or adapted boundary — annulus, spherical shell, free surface, adapted meshes — has a pressure field offset by an arbitrary constant of order 1e4, and a velocity carrying a round-off-determined component of order 1e-1.
- Dynamic topography and boundary normal traction are recovered from the constraint reaction of this solve (
boundary_normal_traction, dynamic_topography_field), which is exactly the workload that runs on curved boundaries.
- It makes results irreproducible across platforms: two builds that assemble the same operator to the last bit agree exactly, and two that differ in the last bit disagree at 1e-1.
How it surfaced
tests/test_1018_rotated_freeslip.py::test_rotated_workspace_deform_invalidates (from #543) compares a post-deform solve against a fresh solver on the deformed mesh and asserts they agree to 1e-6. That assertion is only satisfiable when the two assemblies agree bitwise. On macOS/arm64 they do, and err is exactly 0.0 in 81 consecutive runs across two PETSc toolchains and nine PYTHONHASHSEED values. On CI they do not, and the test fails intermittently with err around 7e-2 to 1.2e-1 — the size of the unpinned component, not a drift.
The CI failure reports are consistent with this: both solves converge from an identical |r0| = 0.02785134866629431 but to different residuals (1.7517680045617482e-11 and 1.9700774924116398e-11), where locally both give 8.267546030330813e-12. Perturbed runs here reproduce that residual range exactly (1.71e-11 to 1.79e-11).
Not caused by the branch that found it
Checked directly: the probe below is self-contained and runs unchanged against the merge base. Built at e475246f and at bugfix/issue-551-locator, the output is identical to every digit, including mean(p) = +1.860217e+04 and the 1.325836e-01 move. The point-locator work in #556 is not involved (measured separately: during the deform, 8 location calls over 2270 points, 38 returning -1, and 0 answers changed by the new rejection radius).
Reproduce
import numpy as np, sympy, underworld3 as uw
def rotated_solve(tag, deform, coord_scale=1.0):
mesh = uw.meshing.StructuredQuadBox(
elementRes=(10, 10), minCoords=(0, 0), maxCoords=(1, 1), qdegree=3)
c = mesh.X.coords.copy()
if deform:
c[:, 1] += 0.02 * c[:, 1] * np.sin(np.pi * c[:, 0])
if deform or coord_scale != 1.0:
mesh.deform(c * coord_scale)
v = uw.discretisation.MeshVariable(f"vR{tag}", mesh, mesh.dim, degree=2)
p = uw.discretisation.MeshVariable(f"pR{tag}", mesh, 1, degree=1, continuous=False)
s = uw.systems.Stokes(mesh, velocityField=v, pressureField=p)
s.constitutive_model = uw.constitutive_models.ViscousFlowModel
s.constitutive_model.Parameters.shear_viscosity_0 = 1.0
x, y = mesh.X
s.bodyforce = sympy.Matrix([[0.0, sympy.sin(sympy.pi * x) * sympy.cos(sympy.pi * y)]])
s.penalty = 0.0
s.tolerance = 1e-9
for wall in ("Top", "Bottom", "Left", "Right"):
s.add_rotated_freeslip_bc(0, wall)
s.petsc_use_pressure_nullspace = True
s.solve()
return np.asarray(v.data).copy(), np.asarray(p.data).ravel().copy()
eps = np.finfo(float).eps
for label, deform in (("straight", False), ("deformed", True)):
out = [rotated_solve(f"{label}{i}", deform, cs)
for i, cs in enumerate((1.0, 1.0 + 2 * eps))]
(v0, p0), (v1, p1) = out
print(label, "mean(p) =", p0.mean(),
"| 2-eps move in v =", np.linalg.norm(v1 - v0) / np.linalg.norm(v0))
Suggested next steps
- Find out why
petsc_use_pressure_nullspace stops binding once the boundary is curved — whether the constant-pressure vector is still in the operator's null space after the rotation/elimination, or whether the nullspace is attached to a matrix the rotated path replaces.
- Until then,
test_rotated_workspace_deform_invalidates is asserting a property the solver does not have on a deformed mesh. It should either pin the gauge before comparing (compare velocity with the unpinned component projected out, and pressure with the constant removed) or be marked xfail against this issue — a green from it currently means "these two assemblies happened to agree bitwise", not "the solver is right".
Related: #348 (rotated LU path, pressure datum pin partition-dependent at np>1) is the parallel cousin of this; #543 introduced the test that exposed it.
Underworld development team with AI support from Claude Code
Rotated strong free-slip loses the pressure gauge as soon as the boundary is curved. On a straight-walled box the constant pressure is pinned to machine zero and the solve is reproducible; deform the top of the same box and the pressure level runs away to ~1e4 while the pressure variation is 6.6e-2, and the answer stops being unique: a change of two machine epsilons in the mesh coordinates moves the velocity by 13%.
This is not a parallel effect (serial, np=1), not the workspace cache (the solver below is built fresh, after the deform), and not a convergence failure — every solve reports converged with a relative residual around 3e-10 against a tolerance of 1e-9.
Measured
Same 10x10
StructuredQuadBox, same load,penalty = 0,tolerance = 1e-9, rotated free-slip on all four walls,petsc_use_pressure_nullspace = True. Each configuration solved twice, the second time with every coordinate multiplied by1 + 2*eps(a relative change of 4.44e-16):0.02 y sin(pi x)The essential free-slip control is the point: on the same deformed mesh, with the same nullspace flag, the constant is pinned to 1e-16 and the answer is stable to 5e-13. Only the rotated path loses it.
The pressure constant is not merely large, it is arbitrary — the two runs above differ by +1.69e4 in the constant, and the size of the jump does not track the size of the perturbation (4.4e-16, 2.2e-15, 1e-14, 1e-12 and 1e-9 coordinate changes all move the velocity by between 4e-2 and 2e-1). That is the signature of a direction the operator does not pin at all: its amplitude is set by round-off, not by the data.
Decomposing the difference between two such solves: 99.95% of it is off the rigid-body span, so it is not the rigid-rotation gauge (which
_mode_satisfies_constraintscorrectly rejects on a box — measured violation 3.045e-01 against a threshold of 1e-8, a margin of seven orders of magnitude). It is the constant-pressure mode, which drags the velocity with it becausepenalty = 0leaves the constraint rows to couple them.Why it matters beyond a test
boundary_normal_traction,dynamic_topography_field), which is exactly the workload that runs on curved boundaries.How it surfaced
tests/test_1018_rotated_freeslip.py::test_rotated_workspace_deform_invalidates(from #543) compares a post-deform solve against a fresh solver on the deformed mesh and asserts they agree to 1e-6. That assertion is only satisfiable when the two assemblies agree bitwise. On macOS/arm64 they do, anderris exactly 0.0 in 81 consecutive runs across two PETSc toolchains and ninePYTHONHASHSEEDvalues. On CI they do not, and the test fails intermittently witherraround 7e-2 to 1.2e-1 — the size of the unpinned component, not a drift.The CI failure reports are consistent with this: both solves converge from an identical
|r0| = 0.02785134866629431but to different residuals (1.7517680045617482e-11 and 1.9700774924116398e-11), where locally both give 8.267546030330813e-12. Perturbed runs here reproduce that residual range exactly (1.71e-11 to 1.79e-11).Not caused by the branch that found it
Checked directly: the probe below is self-contained and runs unchanged against the merge base. Built at
e475246fand atbugfix/issue-551-locator, the output is identical to every digit, includingmean(p) = +1.860217e+04and the 1.325836e-01 move. The point-locator work in #556 is not involved (measured separately: during the deform, 8 location calls over 2270 points, 38 returning -1, and 0 answers changed by the new rejection radius).Reproduce
Suggested next steps
petsc_use_pressure_nullspacestops binding once the boundary is curved — whether the constant-pressure vector is still in the operator's null space after the rotation/elimination, or whether the nullspace is attached to a matrix the rotated path replaces.test_rotated_workspace_deform_invalidatesis asserting a property the solver does not have on a deformed mesh. It should either pin the gauge before comparing (compare velocity with the unpinned component projected out, and pressure with the constant removed) or be markedxfailagainst this issue — a green from it currently means "these two assemblies happened to agree bitwise", not "the solver is right".Related: #348 (rotated LU path, pressure datum pin partition-dependent at np>1) is the parallel cousin of this; #543 introduced the test that exposed it.
Underworld development team with AI support from Claude Code