Skip to content

Unify boundary-normal interface behind mesh.Gamma (#327) + np=4 label deadlock fix#361

Open
lmoresi wants to merge 4 commits into
developmentfrom
bugfix/gamma-unified-normal
Open

Unify boundary-normal interface behind mesh.Gamma (#327) + np=4 label deadlock fix#361
lmoresi wants to merge 4 commits into
developmentfrom
bugfix/gamma-unified-normal

Conversation

@lmoresi

@lmoresi lmoresi commented Jul 14, 2026

Copy link
Copy Markdown
Member

Summary

One user-facing normal symbol. mesh.Gamma now works on any boundary; the four alternative interfaces (Gamma_N, Gamma_P1, boundary_normal(b), canonical_normal(b)) are no longer needed in user code (all retained for back-compat; the first two are documented as deprecated).

Consumers that know their boundary — uw.maths.BdIntegral and natural BCs (add_condition / add_natural_bc) — resolve Gamma per boundary via the new Mesh._resolve_boundary_normals:

  • External boundary — unchanged: Gamma compiles to PETSc's exact per-quadrature outward unit normal petsc_n[]. This path is byte-identical to before, so zero solver risk.
  • Internal boundarypetsc_n[] orientation follows the partition-dependent DMPlex support[0] (Internal-facet normal orientation is rank-dependent at partition seams (vector surface integrals wrong at np>1) #327: mpirun -n 2 gave 0.9375 instead of 1.0 for a unit signed-normal integral). Gamma components are substituted with the mesh factory's declared analytic normal (canonical_normal), which is partition-independent by construction.
  • Internal boundary, no declared normal — loud RuntimeError. There is no orientation convention for an arbitrary internal surface until DMPlexOrientLabel handles open surfaces (pending upstream with PETSc); failing loudly beats integrating a partition-dependent sign. (Note boundary_normal() silently returns zeros there — it skips support-size-2 facets — which is why it is not a fallback.)

Internal-boundary detection: any labeled facet with support size 2, MAX-allreduced across ranks and cached per boundary.

Orientation convention (behaviour change)

Declared internal-boundary normals point from region Inner to region Outer:

  • 2D BoxInternalBoundary: Internal flipped [0,-1][0,1] (now consistent with 3D [0,0,1])
  • AnnulusInternalBoundary / SphericalShellInternalBoundary: radially outward unit_e_0 (the annulus Enum existed but was never attached to the mesh; the spherical factory had none — both now attached)

Code that consumed the old 2D [0,-1] declaration via canonical_normal will see a sign flip.

Second commit: np≥4 deadlock in _dm_unstack_bcs (pre-existing)

While validating at np=4, every BoxInternalBoundary construction deadlocked — on development too, i.e. pre-existing and independent of this change. _dm_unstack_bcs iterated the rank-local getNonEmptyStratumValuesIS() while making collective labelComplete calls per value; ranks whose partition misses a boundary make fewer collective calls → deadlock (np=2 escaped because both halves touch all boundaries). Fixed by iterating the allgathered union of values, with local stratum access guarded. Kept as its own commit for cherry-pick ease.

Validation

  • test_0502_boundary_integrals.py: 24/24 at np=1, np=2, np=4 (np=4 previously hung indefinitely; now 5s)
  • Regression trio: test_1000_poissonNaturalBC (external natural BCs through the new resolver), test_1060_nitsche_freeslip (mesh.Gamma in add_natural_bc), test_1061_constrained_freeslip (new boundary_normal(boundary) default) — 23 passed
  • New/updated tests: plain-mesh.Gamma internal signed-normal integrals with 1e-6 assertions and the Inner→Outer sign; canonical_normal accessor back-compat; the Internal-facet normal orientation is rank-dependent at partition seams (vector surface integrals wrong at np>1) #327 off-grid zintCoord=0.55 partition-seam regression

Relationship to PR #359

This branch contains #359's commit (canonical_normal accessor) and supersedes it — the accessor alone still required users to pick the right interface. Suggest closing #359 in favour of this PR (leaving that to the maintainers).

Deferred (flagged, not in this PR)

  • Doc sweep: 16 user-doc references to Gamma_N/Gamma_P1 (not mechanical — curved-boundary guides should point at boundary_normal)
  • TODO(BUG) in cartesian.py: Box external boundary_normals entries are inward-pointing while canonical_normal documents outward — needs a consumer audit before flipping
  • _assemble_boundary_normal parallel seam ADD-reduce TODO
  • Report back on Internal-facet normal orientation is rank-dependent at partition seams (vector surface integrals wrong at np>1) #327 that the UW3-side fix landed via declared analytic normals, with DMPlexOrientLabel open-surface support still pending upstream

Underworld development team with AI support from Claude Code

lmoresi added 3 commits July 14, 2026 04:56
…rnal-boundary normals (#327)

`mesh.Gamma[i]` JIT-substitutes `petsc_n[i]`, PETSc's per-quadrature
outward-normal-from-`support[0]` in `DMPlexComputeBdIntegral`. On an
internal boundary at a partition seam, different ranks disagree on
which cell is `support[0]` for the one seam facet, so the outward
normal flips sign there and a signed integral like `∫ n_y dS` is
silently off by O(seam facets / total facets) in parallel — the exact
`1 − 2/32 = 0.9375` signature #327 reports.

The mesh factory Enums (`BoxInternalBoundary.boundary_normals_{2,3}D`,
`AnnulusInternalBoundary.boundary_normals`,
`SphericalShellInternalBoundary.boundary_normals`) already declare the
analytic outward-pointing normal for each boundary, but nothing
consumed them. Wire that up: `Mesh.canonical_normal(boundary_name)`
returns the declared sympy Matrix (or None if not declared), giving
callers a partition-independent alternative to `Gamma` for meshes that
know their internal-boundary geometry analytically.

The `test_0502` "internal_normal_ny" / "internal_normal_weighted"
tests are un-skipped (they were serial-only with a TODO(BUG) marker
citing #327) and now use the canonical accessor. A new
`test_bd_integral_internal_canonical_normal_off_grid_zint` regression
constructs the failing `zint=0.55` configuration that reliably picks
a partition seam through the internal boundary at np=2 (from the
sweep in the /remote-control session's #327 investigation).

23/23 pass in `tests/test_0502_boundary_integrals.py` at both np=1
and np=2.

Not fixed: `Gamma[i]` itself remains partition-dependent on internal
boundaries — users writing code that reads `Gamma[i]` on an internal
boundary still get the wrong answer at np>1 unless they switch to
`canonical_normal`. The full fix (canonicalise `support[0]` at
construction, or expose `DMPlexOrientLabel` via a Cython wrapper) is
larger and out of scope for this PR — see the #327 investigation
notes.

Underworld development team with AI support from Claude Code
…match)

getNonEmptyStratumValuesIS() is rank-local, but the per-value loop makes
collective calls (labelComplete). When a rank's partition touches no facet
of some boundary — routine at np>=4 on BoxInternalBoundary — ranks make
different numbers of collective calls and the run deadlocks in mesh
construction (stacks: one rank in labelComplete, others in
markBoundaryFaces). np=2 escaped because both halves touch every boundary.

Iterate the allgathered union of stratum values so every rank makes the
same sequence of collective calls; guard the local setStratumIS to values
live on this rank. Verified: BoxInternalBoundary construction and the full
0502 suite now pass at np=4 (24/24, previously hung indefinitely).

Underworld development team with AI support from Claude Code
mesh.Gamma is now the single user-facing boundary-normal symbol. The
consumers that know their boundary (uw.maths.BdIntegral, natural BCs via
add_condition) resolve it per boundary through the new
Mesh._resolve_boundary_normals:

- external boundaries: unchanged — Gamma compiles to PETSc's exact
  per-quadrature outward unit normal petsc_n[] (zero solver risk);
- internal boundaries: petsc_n[] orientation follows the
  partition-dependent DMPlex support[0] (issue #327), so Gamma components
  are substituted with the mesh factory's declared analytic normal
  (canonical_normal), partition-independent by construction;
- internal boundary with no declared normal: loud RuntimeError — there is
  no orientation convention for an arbitrary internal surface until
  DMPlexOrientLabel supports open surfaces (pending upstream).

Internal-boundary detection (_boundary_is_internal): any labeled facet
with support size 2, MAX-allreduced and cached per boundary.

Supporting changes:
- Convention: declared internal normals point from region Inner to region
  Outer. 2D BoxInternalBoundary Internal flipped [0,-1] -> [0,1] to match
  3D (+z) and the radial (unit_e_0) annulus/spherical convention.
- AnnulusInternalBoundary: attach the existing boundary_normals Enum
  (was defined but never attached); SphericalShellInternalBoundary: add
  and attach one. TODO(BUG) noted on Box external entries, which are
  inward-pointing.
- Gamma_N docstring: deprecated alias of Gamma (numerically identical).
  Gamma_P1 docstring: deprecated, with diagnosis (off-kernel petsc_n
  fallback + sign cancellation on internal facets -> sub-unit vectors).
- SNES_Stokes_Constrained.add_constraint_bc default normal now
  boundary_normal(boundary) (oriented-then-averaged per-boundary field)
  instead of the deprecated global Gamma_P1.
- tests(0502): internal-normal tests use plain mesh.Gamma with tight
  1e-6 assertions and the Inner->Outer (+y) sign; back-compat test for
  the canonical_normal accessor; #327 off-grid zint regression kept.

Verified: 0502 suite 24/24 at np=1, np=2, np=4; natural-BC/Nitsche/
constrained-freeslip regression trio 23 passed.

Underworld development team with AI support from Claude Code
Copilot AI review requested due to automatic review settings July 14, 2026 00:04

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

This PR makes mesh.Gamma the single user-facing boundary-normal symbol across the codebase by resolving it per-boundary in boundary-integrand consumers (exact PETSc quadrature normals on external boundaries; declared analytic normals on internal boundaries to avoid rank-dependent orientation at partition seams, #327). It also fixes a pre-existing MPI deadlock in _dm_unstack_bcs at np>=4 by ensuring every rank performs the same sequence of collective calls.

Changes:

  • Add Mesh.canonical_normal() plus _boundary_is_internal() and _resolve_boundary_normals() to transparently substitute analytic normals for internal-boundary mesh.Gamma usage in BdIntegral and natural BCs.
  • Attach/adjust mesh-factory boundary_normals declarations (including updated BoxInternalBoundary internal normal convention) and update solver defaults to use per-boundary assembled normals for constraint BCs.
  • Fix _dm_unstack_bcs deadlock by iterating the globally unioned set of label values; update/extend regression tests around internal-boundary normals in parallel.

Reviewed changes

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

Show a summary per file
File Description
tests/test_0502_boundary_integrals.py Unskips and strengthens internal-boundary normal integral tests; adds regression for off-grid zintCoord seam case.
src/underworld3/systems/solvers.py Changes default constraint-BC normal to mesh.boundary_normal(boundary) (per-boundary assembled normal).
src/underworld3/meshing/spherical.py Adds boundary_normals Enum attachment to spherical internal-boundary meshes for canonical/resolved normals.
src/underworld3/meshing/cartesian.py Flips 2D internal boundary declared normal to +y (Inner→Outer convention) and adds an explicit TODO note about external normal direction.
src/underworld3/meshing/annulus.py Attaches boundary_normals Enum to annulus internal-boundary meshes for canonical/resolved normals.
src/underworld3/discretisation/discretisation_mesh.py Introduces canonical_normal, internal-boundary detection/cache, and Gamma resolution logic; refactors boundary-facet enumeration helper.
src/underworld3/cython/petsc_maths.pyx Resolves mesh.Gamma per boundary before JIT-compiling boundary integrands.
src/underworld3/cython/petsc_generic_snes_solvers.pyx Resolves mesh.Gamma per boundary for Neumann/natural-BC expressions.
src/underworld3/adaptivity.py Fixes np>=4 deadlock in _dm_unstack_bcs by iterating the allgathered union of label values.

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

Comment on lines +2727 to +2728
r"""Analytic outward-pointing normal for a boundary, or ``None``
if no analytic normal was declared for that boundary.
Comment on lines +2758 to +2761
sympy.Matrix or None
Row matrix of length :attr:`cdim` giving the outward-pointing
normal, or ``None`` if this mesh factory did not declare
an analytic normal for ``boundary_name``.

The integrand may reference the outward unit normal via ``mesh.Gamma_N``
(components map to ``petsc_n[0], petsc_n[1], ...`` in the generated C code).
The integrand may reference the outward unit normal via ``mesh.Gamma``
The factory-declared analytic normal (canonical_normal, consumed when
mesh.Gamma resolves on an internal boundary) describes the ORIGINAL
factory geometry. After mesh.deform() / adapt() the internal surface may
have warped, and silently integrating the stale declaration would be
wrong.

- nuke_coords_and_rebuild bumps a _geometry_version counter (0 at
  construction; every later call is a genuine coordinate change — deform,
  adapt, re-extract and direct coordinate writes all funnel through it).
- boundary_normals is now a property whose setter stamps the declaration
  with the current geometry version.
- canonical_normal raises RuntimeError when the declaration predates the
  current geometry, telling the user to re-declare
  (mesh.boundary_normals = mesh.boundary_normals when the expression is
  still valid, e.g. radial normals after a radius-preserving remesh) or
  to use an explicit normal expression.

External boundaries are unaffected: Gamma -> petsc_n[] is exact on the
deformed geometry and never consults the declaration.

Test: deform a BoxInternalBoundary with a displacement vanishing on the
internal plane; Gamma resolution raises; re-declaration restores the
exact +1 integral. Serial-only until #360 (deform crashes at np>1).

Verified: 0502 25 passed serial, 24 passed + 1 skip at np=2 and np=4;
natural-BC/Nitsche/constrained regression trio 23 passed.

Underworld development team with AI support from Claude Code
@lmoresi

lmoresi commented Jul 14, 2026

Copy link
Copy Markdown
Member Author

Added 2f98dec: declared analytic normals are invalidated by mesh deformation.

The declaration describes the factory geometry, so after mesh.deform() / adapt() resolving mesh.Gamma on an internal boundary (or calling canonical_normal) now raises a clear RuntimeError instead of silently integrating a stale normal. Re-assigning mesh.boundary_normals re-declares against the current geometry (e.g. radial normals remain valid after a radius-preserving remesh: mesh.boundary_normals = mesh.boundary_normals).

Mechanism: nuke_coords_and_rebuild bumps a _geometry_version counter (all deformation paths funnel through it); the boundary_normals setter stamps the declaration with the current version. External boundaries unaffected — petsc_n[] is exact on deformed geometry.

New regression test (serial-only until #360: deform itself crashes at np>1). Re-validated: 0502 serial 25 passed, np=2/np=4 24 passed + 1 skip; natural-BC/Nitsche/constrained trio 23 passed.

Underworld development team with AI support from Claude Code

Sign up for free to 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