Add smooth_mesh_interior — Winslow Jacobi smoother (PR A)#190
Open
lmoresi wants to merge 5 commits into
Open
Conversation
Promotes a Winslow-style mesh smoother out of an experimental runner
(docs/developer/design/_phase_i_fs_convection_zoo.py) into the
underworld3.meshing module. Use case: after a deformation that has
crushed some cells (e.g. free-surface convection that has compressed
elements near the surface), apply Jacobi smoothing to interior
vertices to recover triangle quality. Topology, vertex IDs, DOF
mappings, and parallel partitioning are all preserved — only
coordinates change. The final mesh._deform_mesh call fires once at
the end of all sweeps so the rebuild/cache-invalidation cost is paid
once, not per sweep.
API:
from underworld3.meshing import smooth_mesh_interior
smooth_mesh_interior(
mesh,
pinned_labels=None, # None = auto-detect all named boundaries
n_iters=5,
alpha=0.5,
verbose=False,
)
The pinned_labels argument accepts a sequence of label names whose
vertices are held fixed. None (default) inspects mesh.boundaries and
pins every non-sentinel label — the usual case. An empty list pins
nothing (boundary vertices drift; mesh contracts).
Implementation: scipy CSR Mat-Vec on the row-normalised
vertex-vertex adjacency built from DMPlex edge iteration. Adjacency
is cached at module level keyed by (mesh-id, pinned-label-tuple,
topology) so repeated calls on the same mesh skip the rebuild.
Parallel: currently serial-only (raises NotImplementedError under
mpi.size > 1). A future change will swap the scipy Mat-Vec for a
PETSc Mat-Vec with halo exchange between sweeps.
Tests in tests/test_0850_mesh_smoothing.py cover:
- boundary vertices bit-identical before/after
- per-sweep displacement decreases monotonically (graph Laplacian
Jacobi is a contraction on the pinned-boundary problem)
- aspect-ratio quality does not worsen on a perturbed-interior mesh
- explicit pinned_labels list works
- empty pinned_labels list is legal (boundary drifts)
- pinned_labels=None auto-detects mesh.boundaries
Underworld development team with AI support from Claude Code
Contributor
There was a problem hiding this comment.
Pull request overview
This PR adds a new mesh-utility API, underworld3.meshing.smooth_mesh_interior, implementing a Winslow-style Jacobi smoother that relaxes interior vertex coordinates while keeping selected boundary-label vertices pinned. It promotes functionality into the supported meshing module and introduces regression tests for pinning behavior and smoothing outcomes.
Changes:
- Add
smooth_mesh_interior()implementation with adjacency construction and caching. - Export the new API from
underworld3.meshing. - Add a new pytest module covering boundary pinning and basic smoothing behavior.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 7 comments.
| File | Description |
|---|---|
src/underworld3/meshing/smoothing.py |
Implements the serial Winslow/Jacobi interior-vertex smoother with pinned-boundary handling and adjacency caching. |
src/underworld3/meshing/__init__.py |
Re-exports smooth_mesh_interior at module level via __all__. |
tests/test_0850_mesh_smoothing.py |
Adds regression tests for pinning semantics and smoothing behavior/quality checks. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+20
to
+28
|
|
||
| import numpy as np | ||
|
|
||
| import underworld3 as uw | ||
|
|
||
|
|
||
| # Cached adjacency keyed by (mesh-id, pinned-label-tuple, topology). | ||
| # Rebuilt automatically when the mesh topology changes. | ||
| _ADJ_CACHE: dict = {} |
Comment on lines
+185
to
+195
| dm = mesh.dm | ||
| pStart, pEnd = dm.getDepthStratum(0) | ||
| cStart, cEnd = dm.getHeightStratum(0) | ||
| cone_size = dm.getConeSize(cStart) if cEnd > cStart else 0 | ||
| cache_key = (id(mesh), pinned_labels, | ||
| pEnd - pStart, cEnd - cStart, cone_size) | ||
|
|
||
| cache = _ADJ_CACHE.get(cache_key) | ||
| if cache is None: | ||
| A, is_pinned = _build_adjacency(mesh, pinned_labels) | ||
| _ADJ_CACHE[cache_key] = (A, is_pinned) |
Comment on lines
+178
to
+179
| "Parallel (PETSc Mat-Vec) implementation pending — " | ||
| "see docs/developer/subsystems/mesh-smoothing.md.") |
Comment on lines
+101
to
+105
| mesh, | ||
| pinned_labels: Optional[Sequence[str]] = None, | ||
| n_iters: int = 5, | ||
| alpha: float = 0.5, | ||
| verbose: bool = False, |
Comment on lines
+212
to
+214
|
|
||
| # Single DM-coords update at the end: one rebuild, not N. | ||
| mesh._deform_mesh(coords) |
Comment on lines
+126
to
+132
| # Strictly decreasing — graph Laplacian Jacobi is a | ||
| # contraction on the (pinned-boundary) problem. | ||
| for k in range(1, len(disps)): | ||
| assert disps[k] < disps[k - 1], ( | ||
| f"Sweep {k+1} displacement {disps[k]:.3e} not " | ||
| f"less than sweep {k} displacement " | ||
| f"{disps[k-1]:.3e}; full series: {disps}") |
|
|
||
| if pinned_labels is None: | ||
| pinned_labels = _auto_pinned_labels(mesh) | ||
| pinned_labels = tuple(pinned_labels) |
Removes the serial-only restriction on smooth_mesh_interior. The
per-sweep update remains a local scipy CSR Mat-Vec (which already
includes ghost vertices in its rows/columns because DMPlex's local
chart contains the overlap layer), but between sweeps we now do a
PETSc halo exchange so each rank's ghost copies see the updated
owned values from neighbouring ranks.
Per sweep:
1. avg = A @ coords (local Mat-Vec; owned + ghost rows)
2. update owned interior (write to local numpy buffer)
3. localToGlobal INSERT (push owned values out to global)
4. globalToLocal (pull updated owned back into ghosts)
5. read refreshed local Vec into the numpy buffer for next sweep
Ghost-vertex identification via dm.getPointSF().getGraph() — the
SF leaves are the ghost points on this rank; everything else is
owned. The pinned-label set still pins both owned and ghost
boundary vertices (ghost boundary updates are dictated by their
owners anyway).
Parallel regression tests in
tests/parallel/test_0855_mesh_smoothing_parallel.py:
- boundary vertices bit-identical on every rank after smoothing
- ghost-vertex values agree exactly with their owners after the
smoother returns (verified by doing a fresh globalToLocal and
checking the local Vec is unchanged — invariance under halo
refresh = correct consistent state)
- global per-sweep interior displacement decreases monotonically
(matches the serial guarantee using MPI allreduce)
Tests pass under mpirun -np 2 and mpirun -np 4. Serial tests
(tests/test_0850_mesh_smoothing.py) still pass.
Underworld development team with AI support from Claude Code
Reason: UW3 distributes its DMs with cell-overlap-0. For an owned vertex on the rank partition cut, some of its incident edges live in cells owned by another rank that aren't in this rank's local stratum, so the per-rank scipy adjacency was missing entries and the smoother produced visibly wrong updates along every rank seam (np=2 lost 38 edges, np=4 lost 79 in a 16x16 box). Communicating coords between sweeps did not help because the matrix itself was incomplete. Replace the scipy CSR with a parallel PETSc AIJ Mat. Each rank inserts entries for every locally-visible edge using GLOBAL vertex indices, and mat.assemble() routes cross-rank contributions so that owned-vertex rows are complete. Per sweep: A.mult on each coord component into a global Vec, pointwise-divide by the precomputed degree vector, blend, write back to owned interior in the local numpy buffer. Results are bit-identical (one ULP) between serial and parallel runs at any rank count, regardless of where the partition cut lands. Add tests/parallel/test_0855 ::test_parallel_matches_serial_bit _identical which spawns a serial reference subprocess (with MPI env vars stripped so PETSc doesn't try to attach to the parent mpirun) and compares the gathered parallel result to the serial final coords, matching vertices by their pre-perturbation initial coordinate. Underworld development team with AI support from Claude Code
smooth_mesh_interior auto-pinning iterated every label in mesh.boundaries, but Annulus exposes a "Centre" pressure-pin label whose underlying DMLabel has an invalid communicator — any PETSc call on it (getNumValues, getValueIS, view) hard-aborts the interpreter (not a Python-catchable exception). Add "Centre" to the auto-pin skip list (it's a single-point pressure marker, not a geometric boundary) and keep the existing try/except guards in _pinned_mask for any future similar quirks. Annulus + smoother now runs end-to-end. AdvDiffusionSLCN.__init__: forward a new `theta` kwarg to both internally-constructed SemiLagrangian_DDt instances, mirroring the existing monotone_mode forwarding from #189. Lets callers configure the Adams-Moulton θ at construction time instead of patching adv_diff.DuDt.theta / DFDt.theta after the fact. Tested in the free-surface convection zoo: rk4 + theta=1.0 (BE) + monotone_mode="clamp" + smooth_mesh_interior every 2 steps runs 6 steps with T staying in [0,1] and no integrator instability. Underworld development team with AI support from Claude Code
Reason: UW3 mesh generators tag boundaries by EDGE. The vertex stratum of a boundary label sometimes misses 1-2 endpoint vertices at the gmsh seam (observed: θ=0°/180° on the Annulus outer rim — 100 of 102 rim vertices tagged, 2 seam vertices missed; similarly 50 of 52 on the inner rim). Pinning by vertex-stratum-only left those seam vertices free, so the smoother pulled them inward, producing visible dimples in the deformed surface that corrupted free-surface convection runs. Fix: in _pinned_mask, when a label tags an edge, pin both of the edge's endpoint vertices (closure of the tagged edges). Verified on res=16 Annulus: outer rim 102/102 and inner rim 52/52 pinned after the fix (was 100/102, 50/52). Underworld development team with AI support from Claude Code
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Promotes a Winslow-style Jacobi mesh smoother from an experimental runner into the
underworld3.meshingmodule. Use case: after a deformation that has crushed some cells (e.g. free-surface convection compressing elements near the surface), apply Jacobi smoothing to interior vertices to recover triangle quality without changing topology.Topology, vertex IDs, DOF mappings, and parallel partitioning are preserved — only coordinates change. The final
mesh._deform_meshcall fires once at the end of all sweeps so the rebuild / cache-invalidation cost is paid once, not per sweep.API
pinned_labels=None(default): inspectmesh.boundariesand pin every non-sentinel label. The usual case.pinned_labels=['Top', 'Bottom']: pin only those boundaries; others drift.pinned_labels=[]: pin nothing — boundary vertices drift, mesh contracts.Implementation
dm.getPointSF().getGraph()— only owned interior vertices are written.(mesh-id, pinned-label-tuple, topology), rebuilt on topology change.Parallel
Per-sweep halo exchange via
coordDM.localToGlobal(INSERT) followed byglobalToLocal. Each rank computes local Mat-Vec on its own chart (including ghosts populated by the previous sync), updates only owned interior vertices, then pushes those updates out to receivers' ghost copies before the next sweep. Finalmesh._deform_meshinvalidates all the standard caches (already landed in #188).Follow-up PRs
mesh.adaptsemantics. Design notes in session memory; mid-loop swarm migration impractical so the spec accepts a per-sweep metric lag (small for typical N).Test plan
tests/test_0850_mesh_smoothing.py— 6 serial cases:pinned_labelslist workspinned_labelslist is legalNoneauto-detectsmesh.boundariestests/parallel/test_0855_mesh_smoothing_parallel.py— 3 parallel cases (pass on np=2 and np=4):Files changed
src/underworld3/meshing/smoothing.py(new, ~260 lines including parallel halo logic)src/underworld3/meshing/__init__.py(+5 lines: import + export)tests/test_0850_mesh_smoothing.py(new, ~180 lines)tests/parallel/test_0855_mesh_smoothing_parallel.py(new, ~120 lines)Underworld development team with AI support from Claude Code