Skip to content

Add smooth_mesh_interior — Winslow Jacobi smoother (PR A)#190

Open
lmoresi wants to merge 5 commits into
developmentfrom
feature/winslow-mesh-smoother
Open

Add smooth_mesh_interior — Winslow Jacobi smoother (PR A)#190
lmoresi wants to merge 5 commits into
developmentfrom
feature/winslow-mesh-smoother

Conversation

@lmoresi
Copy link
Copy Markdown
Member

@lmoresi lmoresi commented May 14, 2026

Summary

Promotes a Winslow-style Jacobi mesh smoother from an experimental runner into the underworld3.meshing module. 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.

from underworld3.meshing import smooth_mesh_interior

# After some deformation that left bad cells:
smooth_mesh_interior(mesh, n_iters=5, alpha=0.5)

Topology, vertex IDs, DOF mappings, and parallel partitioning are 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

smooth_mesh_interior(
    mesh,
    pinned_labels=None,    # None = auto-detect all named boundaries
    n_iters=5,
    alpha=0.5,
    verbose=False,
)
  • pinned_labels=None (default): inspect mesh.boundaries and 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

  • Adjacency: row-normalised vertex-vertex graph from DMPlex edge iteration, including ghost rows / columns in parallel.
  • Mat-Vec: scipy CSR per sweep, vectorised over all local vertices.
  • Ghost identification via dm.getPointSF().getGraph() — only owned interior vertices are written.
  • Cache: adjacency + owned mask stored at module level keyed by (mesh-id, pinned-label-tuple, topology), rebuilt on topology change.

Parallel

Per-sweep halo exchange via coordDM.localToGlobal (INSERT) followed by globalToLocal. 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. Final mesh._deform_mesh invalidates all the standard caches (already landed in #188).

Follow-up PRs

  • PR B (planned): callable / mask-based pinning API for arbitrary fixed-point patterns (currently labels only).
  • PR C (planned): non-uniform metric via swarm variable, mirroring mesh.adapt semantics. 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:
    • boundary vertices bit-identical before/after
    • per-sweep displacement decreases monotonically
    • aspect-ratio does not worsen on a perturbed mesh
    • explicit pinned_labels list works
    • empty pinned_labels list is legal
    • None auto-detects mesh.boundaries
  • tests/parallel/test_0855_mesh_smoothing_parallel.py — 3 parallel cases (pass on np=2 and np=4):
    • boundary vertices bit-identical on every rank
    • ghost vertices agree exactly with owners after smoothing (halo exchange verification)
    • global per-sweep displacement decreases monotonically (allreduce-aware)
  • CI suite — purely additive API; no behaviour change for existing code

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

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
Copilot AI review requested due to automatic review settings May 14, 2026 23:44
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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 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 thread src/underworld3/meshing/smoothing.py Outdated
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 thread src/underworld3/meshing/smoothing.py Outdated
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)
@lmoresi lmoresi marked this pull request as draft May 14, 2026 23:55
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
@lmoresi lmoresi marked this pull request as ready for review May 15, 2026 00:00
lmoresi added 3 commits May 15, 2026 12:38
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
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