Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
b3439e1
Add mesh.adapt(engine="edge_split"): longest-edge refinement, no closure
lmoresi Jul 30, 2026
7115810
Add reconnection repair: mesh.adapt(engine="edge_split", repair=True)
lmoresi Jul 30, 2026
79e0a5d
docs: record the repair pass and three corrections to the reconnectio…
lmoresi Jul 30, 2026
e249a43
Fix reconnect locking a bulk cell label as if it were an interface
lmoresi Jul 30, 2026
1382880
Add mesh.relax(pin_bands=...): hold an interface while relaxing every…
lmoresi Jul 31, 2026
f4d0a5c
docs: record the stress-leak metric, band sizing, and interface pinning
lmoresi Jul 31, 2026
4b041f7
Review fixes: orientation predicate must not invent a sign; pin_bands…
lmoresi Jul 31, 2026
1710f5a
Add a conforming surface to an existing mesh, carrying a boundary con…
lmoresi Aug 1, 2026
e7c32d4
One multigrid level per doubling of resolution, not one per engine pass
lmoresi Aug 2, 2026
b6acb71
Fault networks: a junction is a tip, once vertices already on the sur…
lmoresi Aug 2, 2026
6949d48
Review remediation: collective error paths, the stress leak asserted,…
lmoresi Aug 2, 2026
258e18f
The fault zone: facet support, a Surface-shaped API, and junctions in…
lmoresi Aug 2, 2026
483745b
Docs for conforming surfaces, and the surface as its own PyVista object
lmoresi Aug 2, 2026
c128bff
Snapping: a quality veto, and a point-snap the along-edge test cannot…
lmoresi Aug 2, 2026
89e91a1
Repair was locked out of every adapted mesh by the newest-vertex slot…
lmoresi Aug 2, 2026
32443ee
A conforming cut is not a multigrid level: use the rule adapt already…
lmoresi Aug 3, 2026
8ecbaa8
The DELETE primitive: remove a vertex and retriangulate its link
lmoresi Aug 3, 2026
8abbf95
Repair a conforming cut: add_conforming_surface(repair=True)
lmoresi Aug 3, 2026
c04cd6b
Draw a continuous P1 field on the mesh's own cells, not a Delaunay of…
lmoresi Aug 3, 2026
51be715
A standard view of a stacked-on mesh: levels by colour, faults in red
lmoresi Aug 3, 2026
718c245
Draw the fault as its labelled facets, not the cells around it
lmoresi Aug 3, 2026
d22c2ad
Mark the nodes: circles for the base, squares for stacked levels, tri…
lmoresi Aug 3, 2026
6d494db
Give the legend the shapes the figure actually uses
lmoresi Aug 3, 2026
b907c83
Place an embedded surface instead of cutting for one (2-D, serial)
lmoresi Aug 4, 2026
f449049
Hold an embedded surface's cells: a second placement was eating the f…
lmoresi Aug 5, 2026
304d2d0
Fix the two CI failures: a gmsh-lottery guard and a tolerance calibra…
lmoresi Aug 6, 2026
462eb12
label_interface_band: judge band emptiness COLLECTIVELY, not per rank
lmoresi Aug 6, 2026
debeec4
label_interface_band: the pinned set must not depend on the partition
lmoresi Aug 6, 2026
7cbb598
Merge remote-tracking branch 'origin/development' into review/r488-ci…
lmoresi Aug 8, 2026
ebe314c
An explicit GAMG choice must opt out of the custom-P pickup, not cras…
lmoresi Aug 8, 2026
469b5e5
Pair the Krylov-smoothed custom-P multigrid with a flexible outer solve
lmoresi Aug 8, 2026
2d6d120
Disarm the #492 trap in test_0842 and make CI crashes print a stack
lmoresi Aug 9, 2026
379d2fc
Merge origin/development: bundle-owned fgmres pairing (#515) and Null…
lmoresi Aug 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
549 changes: 549 additions & 0 deletions docs/developer/design/mesh-reconnection-and-delaunay-adapt.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/developer/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ CHANGELOG

subsystems/meshing
subsystems/mesh-shape-relaxation
subsystems/conforming-surfaces-and-fault-zones
subsystems/discretisation
subsystems/solvers
subsystems/boundary-stress-and-projection-postprocessing
Expand Down
325 changes: 325 additions & 0 deletions docs/developer/subsystems/conforming-surfaces-and-fault-zones.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions scripts/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ if [ $PARALLEL_ONLY -eq 1 ] && [ $PARALLEL_RANKS -eq 0 ]; then
fi

export UW_NO_USAGE_METRICS=0
# A hard crash must print a Python stack, not just "Segmentation fault".
export PYTHONFAULTHANDLER=1
PYTEST="pytest --config-file=tests/pytest.ini"

# Run serial tests (unless --parallel-only specified)
Expand Down
939 changes: 923 additions & 16 deletions src/underworld3/discretisation/discretisation_mesh.py

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions src/underworld3/utilities/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,6 @@ def _append_petsc_path():
from . import boundary_flux
from . import custom_mg
from .custom_mg import set_custom_fmg
from . import edge_split
from . import line_cut
from . import reconnect
27 changes: 25 additions & 2 deletions src/underworld3/utilities/custom_mg.py
Original file line number Diff line number Diff line change
Expand Up @@ -1040,6 +1040,24 @@ def build_transfers(solver, field_id=None):
if coarse is None:
return None, None # nothing to inject

# An EXPLICIT preconditioner choice beats the opportunistic pickup. Before
# this guard, `solver.preconditioner = "gamg"` on an adapt child was
# silently clobbered back to the custom-P PCMG at solve time (measured:
# both arms of test_0842's fmg-vs-gamg comparison ran pc_type=mg), so a
# user could not opt out and any FMG-vs-GAMG comparison was vacuous.
# `_pc_user_override` is the same statement in the other spelling: the
# solver's option manager has latched "the user owns this block's pc_type"
# (they wrote a pc_type of their own into petsc_options), and an
# opportunistic pickup must stand down for exactly the same reason.
# "auto" (the default) still picks up the mesh-owned hierarchy.
# NOTE the arity: this function returns a 2-tuple, never bare None — a bare
# `return` here is what turned the gate into a TypeError at the call site
# when this hunk migrated from auto_inject_custom_mg (which returns nothing)
# during the #488 x #471 merge.
if (getattr(solver, "_preconditioner", "auto") == "gamg"
or getattr(solver, "_pc_user_override", False)):
return None, None

builder = getattr(solver.mesh, "_custom_mg_builder", "barycentric")
# Retry with the RBF builder before abandoning geometric MG. The
# barycentric builder has LOCAL support: it re-triangulates the coarse
Expand Down Expand Up @@ -1098,8 +1116,13 @@ def auto_inject_custom_mg(solver, field_id=None):
inject_custom_mg(solver)
return

h, Ps = build_transfers(solver, field_id=field_id)
if h is None:
# build_transfers' contract is a 2-tuple, but a "no hierarchy" answer has
# been written as a bare `return` before (the #488 x #471 merge shipped
# exactly that inside the explicit-gamg gate): a None here must mean
# "nothing to inject", never a TypeError mid-solve.
resolved = build_transfers(solver, field_id=field_id)
h, Ps = resolved if resolved is not None else (None, None)
if h is None or Ps is None:
return

# Dimensional guard (checkable for the monolithic operator, field_id is None):
Expand Down
290 changes: 290 additions & 0 deletions src/underworld3/utilities/edge_split.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
"""Longest-edge refinement without a conforming closure.

An alternative refinement engine for :meth:`Mesh.adapt`. Where newest-vertex
bisection chooses which edge to split from a combinatorial tagging rule and then
pays a *conforming closure* to repair the hanging nodes that choice creates, this
engine splits the edge the geometry asks for — the longest edge of every cell
that is still coarser than the metric wants — and needs no closure at all,
because splitting an edge divides **every** cell incident on it at the same new
vertex. There is therefore no hanging node to repair, and no
longest-edge-propagation chain: we never require a neighbour to split its *own*
preferred edge.

Two consequences that matter for adaptation:

* refinement does not spread beyond the cells the metric marked, so the refined
region hugs the feature rather than a bounded halo around it;
* the marking criterion is the cell **diameter**, not :math:`(d!\\,V)^{1/d}`.
For bisection the two shrink together and either will do. For any engine that
reduces volume without shortening the longest edge they diverge badly — a
measured factor of 3.2 on a centroid-refined mesh, where the volume proxy
reports the target as met while the mesh is nowhere near resolved.

The topology, coordinates, labels and parallel star-forest are all handled by the
``uwnvb_bisect`` :c:type:`DMPlexTransform` (see
``docs/developer/design/NVB_GRADED_ADAPT.md``), which is the same primitive the
newest-vertex engine uses for each of its sub-passes. That transform bisects a
set of edges named in a per-edge label and requires them to be **pairwise
independent** — no cell may carry two marked edges in one pass — so a pass here
splits an independent subset and the caller iterates.

Notes
-----
One pass does not necessarily satisfy every marked cell: independence caps how
many edges can be split at once. Drive it in a loop that re-marks from the
current mesh, as :meth:`Mesh.adapt` does.

Status
------
Wired into :meth:`Mesh.adapt` as ``engine="edge_split"``. Validated serial and
parallel in 2-D and 3-D: conforming, refinement confined to the marked region,
and the refined mesh identical at np=1/2/3/4. Tests in
``tests/test_0843_edge_split_adapt.py`` and
``tests/parallel/ptest_0843_edge_split_parallel.py``.

Not yet done: the reconnection (flip) pass that repairs element shape. It is not
expressible as a ``DMPlexTransform`` — a flip's output cells span two parents'
closures, while a transform's children may only reference their own parent's —
so it needs a separate parallel design and is tracked outside this module.
"""

import numpy as np
from mpi4py import MPI
from petsc4py import PETSc

import underworld3 as uw

_BISECT_LABEL = "uwnvb_bisect_edges"


def _register_transform():
"""Import the compiled extension that registers ``uwnvb_bisect`` in PETSc."""
from underworld3.utilities import _nvb_transform # noqa: F401 (registers on import)


def _edge_lengths(dm):
"""Length of every edge, indexed by ``edge_point - edge_start``."""
cdim = dm.getCoordinateDim()
vS, _vE = dm.getDepthStratum(0)
eS, eE = dm.getDepthStratum(1)
X = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, cdim)
ends = np.array([dm.getCone(e) for e in range(eS, eE)], dtype=np.int64) - vS
d = X[ends[:, 0]] - X[ends[:, 1]]
return np.sqrt(np.einsum("ij,ij->i", d, d))


def _cell_edges(dm):
"""Edge points of each cell, as a list indexed by ``cell - cell_start``.

In 2-D a cell's cone is already its edges; in 3-D the cone holds faces, so
the edges come from the transitive closure filtered to the edge stratum.
"""
eS, eE = dm.getDepthStratum(1)
cS, cE = dm.getHeightStratum(0)
if dm.getDimension() == 2:
return [np.asarray(dm.getCone(c), dtype=np.int64) for c in range(cS, cE)]
out = []
for c in range(cS, cE):
closure = dm.getTransitiveClosure(c)[0]
out.append(np.array([p for p in closure if eS <= p < eE], dtype=np.int64))
return out


def cell_diameters(dm):
"""Longest edge length of every cell, in plex cell order.

This is the quantity the interpolation error of a linear element depends on,
and the one this engine marks against.
"""
L = _edge_lengths(dm)
eS, _eE = dm.getDepthStratum(1)
return np.array([L[edges - eS].max() for edges in _cell_edges(dm)])


def _sf_logical_or(dm, flag):
"""Logical-OR a point-indexed flag array over the point star-forest, in place.

Every rank holding a copy of a shared point ends up with the same value, so a
shared edge chosen for bisection anywhere is split everywhere — the condition
``uwnvb_bisect`` needs to keep the child point star-forest conforming.

For a plex point star-forest the leaf and root spaces are BOTH the local point
chart, so the SAME array is passed as leaf data and root data. This mirrors
``uwnvb_sf_lor`` in ``nvb_transform.c``, which is the proven form. Gathering
the leaves into a separately-indexed buffer first — the obvious reading of the
PetscSF signature — mis-sizes the reduce and corrupts the heap.
"""
# COLLECTIVE, so every rank must reach it: a rank owning no shared point
# still has to participate or its peers block forever. Only a genuinely
# serial run may skip, and that is a communicator-size test — never a test
# of what this rank happens to own.
if uw.mpi.size == 1:
return flag
sf = dm.getPointSF()
try:
nroots, _ilocal, _iremote = sf.getGraph()
except (ValueError, TypeError):
# An unpopulated star-forest reports a negative root count that petsc4py
# cannot shape an array from; nothing is shared, so nothing to reconcile.
return flag
if nroots < 0:
return flag

sf.reduceBegin(MPI.INT32_T, flag, flag, MPI.LOR)
sf.reduceEnd(MPI.INT32_T, flag, flag, MPI.LOR)
sf.bcastBegin(MPI.INT32_T, flag, flag, MPI.REPLACE)
sf.bcastEnd(MPI.INT32_T, flag, flag, MPI.REPLACE)
return flag


def _owned_count(dm, points):
"""How many of ``points`` this rank owns, i.e. holds as a root not a leaf."""
if uw.mpi.size == 1:
return len(points)
try:
_nroots, ilocal, _iremote = dm.getPointSF().getGraph()
except (ValueError, TypeError):
# Unpopulated star-forest: nothing is shared, so every point is owned.
return len(points)
if ilocal is None or len(ilocal) == 0:
return len(points)
leaves = set(int(p) for p in ilocal)
return sum(1 for p in points if int(p) not in leaves)


def _edge_strength(dm):
"""Per-edge sort key making "the strongest candidate in a cell" well defined.

Length decides; the midpoint coordinate breaks ties. Both are computed from
the coordinates alone, so the key is identical on every rank holding the edge
and the selection below is independent of the partition — the property that
makes the refined mesh the same at any communicator size.
"""
cdim = dm.getCoordinateDim()
vS, _vE = dm.getDepthStratum(0)
eS, eE = dm.getDepthStratum(1)
X = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, cdim)
ends = np.array([dm.getCone(e) for e in range(eS, eE)], dtype=np.int64) - vS
d = X[ends[:, 0]] - X[ends[:, 1]]
length = np.sqrt(np.einsum("ij,ij->i", d, d))
mid = 0.5 * (X[ends[:, 0]] + X[ends[:, 1]])
return length, mid


def _independent_edges(dm, candidates):
"""The candidates that beat every competing candidate sharing a cell.

This replaces a greedy sweep, which would depend on iteration order and
therefore on the partition (measured: 414 cells at np=1/2 but 463 at np=3 and
925 at np=4). A candidate is *vetoed* when a stronger candidate shares one of
its cells; vetoes are OR-ed across ranks so a shared edge is judged against
the cells on both sides. What survives is independent by construction — two
edges in the same cell cannot both beat the other — and is a function of the
geometry only.
"""
eS, eE = dm.getDepthStratum(1)
cS, cE = dm.getHeightStratum(0)
pStart, pEnd = dm.getChart()

is_candidate = np.zeros(pEnd - pStart, dtype=np.int32)
if len(candidates):
is_candidate[np.asarray(candidates, dtype=np.int64) - pStart] = 1
_sf_logical_or(dm, is_candidate)

length, mid = _edge_strength(dm)
veto = np.zeros(pEnd - pStart, dtype=np.int32)
edges_of = _cell_edges(dm)
for c in range(cS, cE):
edges = edges_of[c - cS]
rival = edges[is_candidate[edges - pStart] == 1]
if len(rival) < 2:
continue
keys = [(length[e - eS], *mid[e - eS]) for e in rival]
winner = rival[int(np.lexsort(np.array(keys).T[::-1])[-1])]
veto[rival[rival != winner] - pStart] = 1
_sf_logical_or(dm, veto)

chosen = np.flatnonzero((is_candidate == 1) & (veto == 0)) + pStart
return chosen[(chosen >= eS) & (chosen < eE)]


def _cells_on_edge(dm, edge):
"""Cells incident on an edge — the star of the vertex a split would insert.

The walk up from an edge is dimension-dependent and getting it wrong fails
silently rather than loudly. In 2-D an edge *is* a face, so its support is
already the cells; in 3-D the support holds faces and the cells are one level
further up. Applying the 3-D walk in 2-D asks for the support of a cell, which
is empty, so the function returns no cells at all and every caller reads "this
edge touches nothing".
"""
cS, cE = dm.getHeightStratum(0)
if dm.getDimension() == 2:
return sorted(int(c) for c in dm.getSupport(edge) if cS <= c < cE)
seen = set()
for f in dm.getSupport(edge):
for c in dm.getSupport(f):
if cS <= c < cE:
seen.add(int(c))
return sorted(seen)


def bisect_longest_edges(dm, cells):
"""Split the longest edge of as many of ``cells`` as one pass allows.

Parameters
----------
dm : PETSc.DMPlex
Simplex mesh to refine. Not modified.
cells : array of int
Plex cell points to refine.

Returns
-------
refined : PETSc.DMPlex
A fresh DM, co-partitioned with ``dm`` and carrying its labels forward.
n_split : int
Number of edges bisected globally. Zero means the pass was empty and the
caller should stop.

Notes
-----
Independence caps one pass, so a cell marked here may still exceed the metric
afterwards. Re-mark from the returned mesh and call again.
"""
_register_transform()

cS, _cE = dm.getHeightStratum(0)
eS, _eE = dm.getDepthStratum(1)
L = _edge_lengths(dm)
edges_of = _cell_edges(dm)

wanted = {int(edges_of[int(c) - cS][np.argmax(L[edges_of[int(c) - cS] - eS])])
for c in cells}
chosen = _independent_edges(dm, np.array(sorted(wanted), dtype=np.int64))

# Count OWNED edges only: a shared edge is held by every rank on the seam, so
# summing local counts would report it once per sharer and overstate the pass.
n_split = uw.mpi.comm.allreduce(int(_owned_count(dm, chosen)), op=MPI.SUM)
if n_split == 0:
return dm, 0

work = dm.clone()
work.createLabel(_BISECT_LABEL)
label = work.getLabel(_BISECT_LABEL)
label.setDefaultValue(0)
for e in chosen:
label.setValue(int(e), 1)

transform = PETSc.DMPlexTransform().create(comm=work.comm)
transform.setType("uwnvb_bisect")
transform.setDM(work)
transform.setUp()
refined = transform.apply(work)
transform.destroy()

# The transform copies its driving label onto the output, where it would be
# read as a stale request by the next pass.
if refined.hasLabel(_BISECT_LABEL):
refined.removeLabel(_BISECT_LABEL)
return refined, n_split
Loading
Loading