Skip to content
13 changes: 7 additions & 6 deletions .claude/skills/adapt-on-top-faults/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: adapt-on-top-faults
description: Recipe for Underworld3 FAULT models on an NVB adapt-on-top mesh — resolve a fault Surface by LOCAL refinement (mesh.adapt(engine="nvb") returns a child), drive it from the fault's EXACT signed distance, use rotated strong free-slip (composes with transverse-isotropy where Nitsche does not), recover dynamic topography from the constraint reaction, and run advection-diffusion on the adapted mesh with field transfer across re-adaptation. Reach for THIS for instantaneous/coupled fault-flow problems. For MMPDE node-movement convection use the `adaptive-meshing` skill instead; for rendering use `uw-visualisation`.
description: Recipe for Underworld3 FAULT models on an NVB adapt-on-top mesh — resolve a fault Surface by LOCAL refinement (mesh.adapt(metric, max_levels=...) returns a child; NVB is the 2D default engine), drive it from the fault's EXACT signed distance, use rotated strong free-slip (composes with transverse-isotropy where Nitsche does not), recover dynamic topography from the constraint reaction, and run advection-diffusion on the adapted mesh with field transfer across re-adaptation. Reach for THIS for instantaneous/coupled fault-flow problems. For MMPDE node-movement convection use the `adaptive-meshing` skill instead; for rendering use `uw-visualisation`.
---

# adapt-on-top-faults
Expand All @@ -9,7 +9,7 @@ The validated recipe for **fault problems on a locally-refined (adapt-on-top) me
Distilled from the annulus fault study (2026-07, `feature/adapt-on-top`).

**This is the REFINEMENT paradigm**, not the mover one:
- `mesh.adapt(metric, engine="nvb")` bisects the base finest **locally** and returns
- `mesh.adapt(metric, max_levels=...)` bisects the base finest **locally** and returns
a **new child mesh** (`child.parent is mesh`). It is *adapt / re-adapt*, NOT node
movement — non-cumulative (each call re-marks from the static base). The child owns
a custom-P geometric-MG (FMG) tail so solvers on it get multigrid for free.
Expand Down Expand Up @@ -43,11 +43,12 @@ fault.discretize()
# so it resolves itself at the new resolution (no P1-field aliasing).
metric = fault.refinement_metric_function(h_near=0.02, h_far=0.08, width=0.05,
profile="linear")
child = base.adapt(metric, max_levels=3, engine="nvb") # -> graded child mesh
child = base.adapt(metric, max_levels=3) # -> graded child (NVB is the 2D default)
```

- `engine="nvb"` = graded newest-vertex bisection (bounded closure, parallel via the
native `uwnvb` transform; bit-confluent serial↔parallel). `engine="sbr"` = uniform
- NVB (the 2D default engine) = graded newest-vertex bisection (bounded closure,
parallel via the native `uwnvb` transform; bit-confluent serial↔parallel);
`engine=` is the advanced selector. `engine="sbr"` = uniform
patch (the default; not graded). NVB is 2D only for now.
- `max_levels` is the isotropic-equivalent depth (NVB runs `2*max_levels` bisection
passes). The metric shape decides the grading; `max_levels` just caps it.
Expand Down Expand Up @@ -176,7 +177,7 @@ static base, carry any field by interpolation:
for step in range(N):
fault_pts = move(fault_pts, step) # kinematics
fault = uw.meshing.Surface(f"fault{step}", base, fault_pts, symbol="F"); fault.discretize()
child = base.adapt(fault.refinement_metric_function(...), max_levels=3, engine="nvb")
child = base.adapt(fault.refinement_metric_function(...), max_levels=3)
# verify: folded=0 (all cell |vol|>0), base unchanged (non-cumulative)
# carry a field child_{k-1} -> child_k by interpolation:
T = uw.discretisation.MeshVariable(f"T{step}", child, 1, degree=1)
Expand Down
16 changes: 10 additions & 6 deletions .claude/skills/adaptive-meshing/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@ don't mix them.

## Mover quick-start (copy-paste — this is the hard-to-discover bit)

The mesh mover is `uw.meshing.smooth_mesh_interior`. Minimal correct setup to
The user entry is `uw.meshing.node_redistribution(mesh, metric, ...)` (the
purposeful spelling; it dispatches to `mesh.redistribute_nodes`, which drives
the MMPDE mover on 2D simplex meshes — `smooth_mesh_interior` is the
machinery underneath and takes the same kwargs). Minimal correct setup to
adapt a mesh to a field `T` each step:

```python
Expand All @@ -41,10 +44,11 @@ import underworld3 as uw
rho = uw.meshing.metric_density_from_gradient(
mesh, T, refinement=5, coarsening="auto", metric_choice="front-following")

# move the mesh — mmpde: variational, non-folding, clusters AND aligns cells.
# It OWNS field transfer (remaps T + SLCN history, fires on_remesh hooks).
uw.meshing.smooth_mesh_interior(
mesh, metric=rho, method="mmpde",
# move the mesh — the mover (Huang-Kamenski MMPDE) is variational,
# non-folding, clusters AND aligns cells. It OWNS field transfer
# (remaps T + SLCN history, fires on_remesh hooks).
uw.meshing.node_redistribution(
mesh, rho,
method_kwargs=dict(step_frac=0.2, accel="cg", momentum=0.0), # mmpde's OWN kwargs
slip_surfaces=True, # boundary nodes slide tangentially (parallel-safe)
skip_threshold=0.9) # skip the move when the mesh is already aligned
Expand All @@ -59,7 +63,7 @@ n = sympy.Matrix([nx, ny]) # constant fault-normal unit ve
d = dfac.sym[0] # DIRECT unsigned distance field (P1)
M = rho * sympy.eye(2) + (Rf**2 - 1.0) * sympy.exp(-(d/w)**2) * (n * n.T)
# mesh built with: uw.meshing.Annulus(..., refine_lines=[xy], refine_size_min=smin)
uw.meshing.smooth_mesh_interior(mesh, metric=M, method="mmpde",
uw.meshing.node_redistribution(mesh, M,
method_kwargs=dict(step_frac=0.2, accel="cg", momentum=0.0),
slip_surfaces=True, skip_threshold=None) # tensor metric: do the skip check yourself
```
Expand Down
27 changes: 19 additions & 8 deletions docs/advanced/mesh-adaptation.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ for each edge vector $\mathbf{e}$. Edges that are too long get subdivided; regio
UW3 offers **two complementary** ways to put resolution where it is
needed:

| | `mesh.remesh(...)` (this page) | `smooth_mesh_interior(method="mmpde")` |
| | `mesh.remesh(...)` (this page) | `uw.meshing.node_redistribution(...)` |
|---|---|---|
| Mechanism | **Re-mesh** (MMG): insert/remove/retriangulate | **Redistribute** the existing nodes (move only) |
| Node budget | *Changes* — targets an **absolute** edge length `h` | **Fixed** — relative redistribution to a target *density* |
Expand Down Expand Up @@ -379,13 +379,18 @@ For the mathematically inclined, see the [Developer Design Document](../develope

When you want to concentrate resolution on an evolving feature
**every timestep** without re-meshing — keeping the topology and
all field data intact — use `smooth_mesh_interior` (the node-moving
mover) instead of `mesh.remesh`:
all field data intact — use **node redistribution** instead of
`mesh.remesh`. The purposeful spelling is
`uw.meshing.node_redistribution(mesh, metric)` (equivalently the
mesh-controlled method `mesh.redistribute_nodes(metric)`); it names
the capability — the algorithm behind it (the Huang–Kamenski MMPDE
mover) is an implementation detail documented on the machinery,
`smooth_mesh_interior`:

```python
import underworld3 as uw
from underworld3.meshing import (
smooth_mesh_interior, metric_density_from_gradient)
node_redistribution, metric_density_from_gradient)

# ... mesh + a temperature field T after some solve ...

Expand All @@ -396,12 +401,18 @@ from underworld3.meshing import (
rho = metric_density_from_gradient(mesh, T, amp=8.0)

# Move the nodes to that metric (topology / DOFs / variables
# all preserved — no transfer needed). method="mmpde" is the
# DEFAULT and may be omitted; shown here for clarity.
smooth_mesh_interior(mesh, metric=rho, method="mmpde",
boundary_slip=True)
# all preserved — no transfer needed).
node_redistribution(mesh, rho, boundary_slip=True)
```

Node redistribution is implemented for **2D simplex (triangle)
meshes**; other mesh types (quad/hex, 3D, manifolds) raise an
honest `NotImplementedError` stating what exists. To *add*
resolution locally instead of moving it, use the nested
adapt-on-top: `child = mesh.adapt(metric, max_levels=2)` — no
`engine=` needed (the graded newest-vertex-bisection engine is the
default on 2D meshes; `engine=` remains as an advanced selector).

```{tip}
**`method="mmpde"` is the default mover** (since this release): the
variational moving-mesh adaptation of Huang & Kamenski. It is
Expand Down
18 changes: 18 additions & 0 deletions docs/developer/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@ This log tracks significant development work at a conceptual level, suitable for

## 2026 Q3 (July – September)

### Purposeful Adapt / Redistribution Naming (July 2026)

**User-facing mesh-modification names now state the capability** (maintainer
naming ruling 2026-07-16); the algorithm names (NVB, MMPDE) stay in internals
and docstrings:

- New user entry `uw.meshing.node_redistribution(mesh, metric, ...)`,
dispatching through the mesh-controlled `Mesh.redistribute_nodes(metric)`
method — the architecture by which each mesh type controls how it can be
modified. The base implementation supports 2D simplex (triangle) meshes
(via the MMPDE mover); quad/hex, 3D and manifold meshes raise an honest
`NotImplementedError` stating what exists. `smooth_mesh_interior` remains
as the machinery underneath.
- `mesh.adapt(metric, max_levels=...)` no longer needs `engine=`: the graded
newest-vertex-bisection engine is the default on 2D meshes (NVB is 2D-only
this pass, so 3D meshes resolve to SBR); `engine=` stays as the
advanced/internal selector.

### Retired Interior Movers — MMPDE Is the Mover (July 2026)

**The superseded fixed-topology interior movers were retired** (maintainer
Expand Down
Loading
Loading