Skip to content

Constrained free-slip via recoverable Lagrange multiplier (dynamic topography) - #219

Closed
lmoresi wants to merge 3 commits into
developmentfrom
feature/constrained-freeslip-topography
Closed

Constrained free-slip via recoverable Lagrange multiplier (dynamic topography)#219
lmoresi wants to merge 3 commits into
developmentfrom
feature/constrained-freeslip-topography

Conversation

@lmoresi

Copy link
Copy Markdown
Member

Summary

Adds uw.systems.Stokes_Constrained (SNES_Stokes_Constrained): enforce u·n = g
on curved boundaries with a recoverable Lagrange multiplier instead of a tuned
penalty. The converged multiplier is the normal traction holding the boundary — a
direct dynamic-topography estimate h = λ/(Δρ g).

Motivated by the fragility of penalty/Nitsche free-slip on annulus/spherical
boundaries (the magnitude must be tuned against forcing strength and viscosity; too
weak gives spurious radial throughflow, too strong diverges).

How it works

An augmented-Lagrangian (ALG2) outer loop reusing the existing penalty BC: each
Stokes solve carries t = [λ + r(u·n − g)]·n, and λ ← λ + r(u·n − g). The penalty
term preconditions every boundary mode (fast convergence); the multiplier removes the
penalty's accuracy bias (exact constraint from a moderate, well-conditioned r).

Purely additive — the validated 2×2 saddle-point assembly and fieldsplit
configuration are untouched (honours "solver stability is paramount"). The outer loop
lives inside solve().

Hands-off solve() (no tuning)

  • Relative tolerance: RMS(u·n − g) < rtol·RMS|u| (default rtol=1e-3) — problem-independent.
  • Viscosity-weighted augmentation: default r = 10³·μ(x), so high-viscosity
    boundary regions are constrained as well as low-viscosity ones (a flat r is not).
stokes=uw.systems.Stokes_Constrained(mesh, velocityField=v, pressureField=p)
stokes.constitutive_model=uw.constitutive_models.ViscousFlowModelstokes.constitutive_model.Parameters.shear_viscosity_0=mulam=stokes.add_constraint_bc("Upper", g=0.0) # free-slip, no penalty coefficientstokes.solve()
topo=stokes.topography("Upper", buoyancy_scale=delta_rho_g)

Validation

  • Annulus (tests/test_1061_constrained_freeslip_annulus.py, 5 tests, level_2/tier_b):
    RMS(u·n)=6.5e-5, velocity within 0.3% of the penalty reference, 2 outer iterations,
    interior λ exactly 0, boundary corr(λ, −n·σ·n) = 0.9999.
  • SolCx (variable viscosity): ~2e-4 vs Dirichlet reference in 3 outer iterations across
    contrast 1 → 10⁶, no manual tuning.

Trade-offs and roadmap

The augmentation r is a speed knob, not an accuracy knob (accuracy is
r-independent — the AL property): too small just costs iterations, too large stiffens
the inner solve, the answer is never wrong. True-work is U-shaped with an efficient
basin r ∈ [300, 10⁴].

Deferred (see docs/developer/design/CONSTRAINED_FREESLIP_MULTIPLIER.md):
adaptive r₀ for very coarse meshes; parallel (mask + nodal update are serial);
rotation-null-space removal for both-boundaries free-slip; and the monolithic
P'=[p,h] fieldsplit ("arbitrary constraints in the saddle point") as a separate
feasibility spike.

Design note: docs/developer/design/CONSTRAINED_FREESLIP_MULTIPLIER.md.

Underworld development team with AI support from Claude Code

…graphy
Enforce u.n = g on curved boundaries with a recoverable Lagrange multiplier
instead of a tuned penalty. An augmented-Lagrangian (ALG2) outer loop carries
both the existing penalty BC and the multiplier; the multiplier removes the
penalty's accuracy bias, so a moderate, well-conditioned augmentation gives an
exact constraint in 2-3 Stokes solves. The converged multiplier is the normal
traction holding the boundary -- a direct dynamic-topography estimate
(h = lambda / (drho g)), recoverable as a clean MeshVariable (interior exactly
zero; boundary trace correlates with -n.sigma.n at 1.0000).
Purely additive: the validated 2x2 saddle-point assembly and fieldsplit config
are untouched. New class behind uw.systems.Stokes_Constrained.
Hands-off solve(): relative constraint tolerance (RMS(u.n-g) < rtol*RMS|u|) and
viscosity-weighted augmentation (1e3*mu(x)) by default -- no user tuning. On the
SolCx benchmark this gives ~2e-4 accuracy in 3 outer iterations across viscosity
contrast 1 -> 1e6.
Validation: tests/test_1061_constrained_freeslip_annulus.py (5 tests). Design
note and production roadmap in docs/developer/design/.
Underworld development team with AI support from Claude Code
CopilotAI review requested due to automatic review settings June 5, 2026 15:23

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new Stokes solver variant that enforces curved-boundary normal-velocity constraints (u·n=g) using an augmented-Lagrangian outer loop with a recoverable Lagrange multiplier, enabling direct access to the converged boundary traction (dynamic-topography proxy) without penalty tuning.

Changes:

  • Introduces SNES_Stokes_Constrained (exported as uw.systems.Stokes_Constrained) with add_constraint_bc(), multiplier(), and topography() APIs.
  • Implements the augmented-Lagrangian outer iteration inside solve() and a boundary-only multiplier update strategy via a nodal mask.
  • Adds annulus validation tests and a developer design note documenting formulation, trade-offs, and validation results.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.

FileDescription
src/underworld3/systems/solvers.pyAdds constrained Stokes solver, constraint BC registration, multiplier/topography accessors, and augmented-Lagrangian outer loop.
src/underworld3/systems/__init__.pyExports the new solver as Stokes_Constrained.
tests/test_1061_constrained_freeslip_annulus.pyAdds regression/validation coverage comparing constrained vs penalty free-slip on an annulus and checks multiplier/topography behavior.
docs/developer/design/CONSTRAINED_FREESLIP_MULTIPLIER.mdDesign/validation documentation for the constrained free-slip multiplier approach and roadmap.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +2125 to +2133
point_indices = petsc_dm_find_labeled_points_local(
self.mesh.dm,
"UW_Boundaries",
getattr(self.mesh.boundaries, boundary).value,
sectionIndex=False,
)
if point_indices is not None:
marker.data[point_indices] = 1.0
mask = (
Comment on lines +2271 to +2277
# Augmented-Lagrangian (ALG2) multiplier update, same r as the
# forward-problem penalty augmentation. Monotone-convergent for r>0.
# Restricted to boundary nodes so lambda stays a clean topography field.
for cbc in self._constraint_bcs:
resid = self._nodal_constraint_residual(cbc)
cbc.lam.data[cbc.mask, 0] += cbc.r_nodal[cbc.mask] * resid[cbc.mask]

Comment on lines +2224 to +2226
# Sample the augmentation r at the multiplier nodes once (it may be a
# spatial / viscosity-weighted expression). Used for the dual update.
for cbc in self._constraint_bcs:
lmoresi added 2 commits June 5, 2026 16:30
Confirmed at the PETSc level that a 3-field DM (u, p, h) with p and h grouped
yields a 2-way u | [p,h] Schur split (no nested fieldsplit), de-risking the
monolithic "arbitrary saddle-point constraints" direction. Captures the bounded
assembly touch-points and remaining caveats for a future block-Schur PR.
Underworld development team with AI support from Claude Code
- add_constraint_bc validates the boundary name and the 'UW_Boundaries' label,
and rejects empty/None point sets (petsc_dm_find_labeled_points_local returns
the vertex-0 sentinel np.array([0]) when the label is absent, which an
`is not None` check would silently accept and corrupt the mask).
- The outer loop no longer applies a final, never-solved multiplier update when
the iteration cap is hit (u/p stay consistent with lambda) and now warns
loudly (RuntimeWarning) on non-convergence.
- Raise NotImplementedError when run on more than one MPI rank (the mask and the
node-wise update are serial-only), instead of silently producing wrong results.
Adds a test that add_constraint_bc rejects an unknown boundary name.
Underworld development team with AI support from Claude Code
@lmoresi

Copy link
Copy Markdown
MemberAuthor

Superseded by #224. The constrained free-slip work is replaced by the in-saddle (single coupled solve)Stokes_Constrained solver, which is more production-ready: one mesh-independent solve (no fragile outer loop), validated against the exact SolCx analytic solution, with a lossless boundary-only multiplier reduction. The augmented-Lagrangian outer-loop variant introduced here is removed in #224 (it's easy to reproduce in Python if wanted). Closing in favour of #224.

@lmoresilmoresi closed this Jun 8, 2026
lmoresi added a commit that referenced this pull request Jun 8, 2026
…rsedes #219) (#224)
* Add SNES_Stokes_Constrained: non-penalty free-slip + recoverable topography
Enforce u.n = g on curved boundaries with a recoverable Lagrange multiplier
instead of a tuned penalty. An augmented-Lagrangian (ALG2) outer loop carries
both the existing penalty BC and the multiplier; the multiplier removes the
penalty's accuracy bias, so a moderate, well-conditioned augmentation gives an
exact constraint in 2-3 Stokes solves. The converged multiplier is the normal
traction holding the boundary -- a direct dynamic-topography estimate
(h = lambda / (drho g)), recoverable as a clean MeshVariable (interior exactly
zero; boundary trace correlates with -n.sigma.n at 1.0000).
Purely additive: the validated 2x2 saddle-point assembly and fieldsplit config
are untouched. New class behind uw.systems.Stokes_Constrained.
Hands-off solve(): relative constraint tolerance (RMS(u.n-g) < rtol*RMS|u|) and
viscosity-weighted augmentation (1e3*mu(x)) by default -- no user tuning. On the
SolCx benchmark this gives ~2e-4 accuracy in 3 outer iterations across viscosity
contrast 1 -> 1e6.
Validation: tests/test_1061_constrained_freeslip_annulus.py (5 tests). Design
note and production roadmap in docs/developer/design/.
Underworld development team with AI support from Claude Code
* docs: record P'=[p,h] monolithic fieldsplit feasibility spike
Confirmed at the PETSc level that a 3-field DM (u, p, h) with p and h grouped
yields a 2-way u | [p,h] Schur split (no nested fieldsplit), de-risking the
monolithic "arbitrary saddle-point constraints" direction. Captures the bounded
assembly touch-points and remaining caveats for a future block-Schur PR.
Underworld development team with AI support from Claude Code
* Address Copilot review on #219: guards for label, non-convergence, MPI
- add_constraint_bc validates the boundary name and the 'UW_Boundaries' label,
and rejects empty/None point sets (petsc_dm_find_labeled_points_local returns
the vertex-0 sentinel np.array([0]) when the label is absent, which an
`is not None` check would silently accept and corrupt the mask).
- The outer loop no longer applies a final, never-solved multiplier update when
the iteration cap is hit (u/p stay consistent with lambda) and now warns
loudly (RuntimeWarning) on non-convergence.
- Raise NotImplementedError when run on more than one MPI rank (the mask and the
node-wise update are serial-only), instead of silently producing wrong results.
Adds a test that add_constraint_bc rejects an unknown boundary name.
Underworld development team with AI support from Claude Code
* Add SNES_Stokes_BlockConstrained: in-saddle multiplier for free-slip + topography
Enforces u.n = g on a boundary via a Lagrange multiplier h carried INSIDE the
saddle-point system (grouped u | [p,h] 2-way Schur split), in one coupled solve.
The converged boundary trace h|_G = -n.sigma.n is dynamic topography directly,
with no penalty/Nitsche parameter to tune.
- Guarded generalisation of SNES_Stokes_SaddlePt: every multiplier block wrapped
`if self._multipliers:` so the 2-field path stays bit-identical (M1 gate).
- Augmented-Lagrangian conditioning of the constraint Schur (r = 1e3.mu,
viscosity-weighted); combined (p,h) gauge nullspace for enclosed domains.
- FMG-ready: field-index fieldsplit grouping (pc_fieldsplit_N_fields) + option
mirror, so geometric multigrid works on the velocity block.
- Boundary-only multiplier reduction (pin interior h) DEFAULT OFF: refined-safe
via DMPlexLabelComplete closure but not yet lossless on the clone DM (degrades
boundary trace ~5e-4); the lossless PetscSection-constraint path is next.
- add_constraint_bc(boundary, g, normal, screening, augmentation, degree);
multiplier()/topography() recovery API.
- tests/test_1062: 11 cases (box + annulus; constraint / Dirichlet-match /
topography / variable-viscosity to 1e6), all passing.
Key finding: on hard constraints (SolCx, 4-wall free-slip + viscosity jump) the
block does one mesh-independent solve where the outer-loop Uzawa needs 9-11 and
explodes to 33x Dirichlet -- the two methods fail in opposite places (block:
per-solve cost from the fat [p,h] Schur; outer-loop: outer-iteration count).
Underworld development team with AI support from Claude Code
* Lossless boundary-only multiplier reduction (default on)
Constrain the interior (off-boundary) multiplier DOFs directly in the fine
local PetscSection rather than via DMAddBoundary. This is both lossless (the
constraint-boundary closure is correct because createClosureIndex has finalised
the section) and refined/FMG-safe (no "after section creation" restriction), so
the solved [p,h] Schur block carries only the boundary trace (~sqrt(ndof) DOFs).
Collapses the multiplier-block DOF premium from ~3x to ~1.1x Dirichlet with no
accuracy loss (box+lu reduced-vs-full velocity relL2 ~1e-9).
Underworld development team with AI support from Claude Code
* Promote in-saddle solver to Stokes_Constrained; remove outer-loop variant
The in-saddle (single coupled solve) constrained free-slip solver is now the
production path, exported as uw.systems.Stokes_Constrained. It is validated
against the exact SolCx analytic solution and matches a Dirichlet free-slip
reference to discretisation error, with the constraint enforced to machine
precision.
- rename SNES_Stokes_BlockConstrained -> SNES_Stokes_Constrained
- remove the augmented-Lagrangian outer-loop solver + helper (_ConstraintBC);
the Uzawa scheme is easy to reproduce in Python if wanted
- single public export Stokes_Constrained (drop Stokes_BlockConstrained)
- raise default augmentation_base 1e3 -> 1e4 (accuracy is r-independent; larger
sits in the low-iteration plateau, well below roundoff)
- tests: retarget the constrained test, add test_1062_constrained_solcx.py
(free-slip vs the SolCx analytic); outer-loop annulus test removed
- docs: update CONSTRAINED_FREESLIP_MULTIPLIER.md status
Underworld development team with AI support from Claude Code
* Address Copilot review on #224
- remove the unused _BlockConstraintBC.interior_mask and its construction (a P1
marker field + evaluate) — dead since the section-based reduction; avoidable
setup cost
- tests: use the public petsc_use_pressure_nullspace property (its setter resets
cached nullspace state); fix the stale run-command path in test_1061
- docs/CONSTRAINED_FREESLIP_MULTIPLIER.md: rewrite the outer-loop sections to the
shipped in-saddle formulation (no outer iteration; r is AL stabilisation only;
augmentation_base 1e4; boundary-only reduction); fix the Files list and the
option-table verdicts; mark the r-sweep table as historical outer-loop data
Underworld development team with AI support from Claude Code
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@lmoresi