diff --git a/.claude/skills/adapt-on-top-faults/SKILL.md b/.claude/skills/adapt-on-top-faults/SKILL.md index baaf9285..207bf3bd 100644 --- a/.claude/skills/adapt-on-top-faults/SKILL.md +++ b/.claude/skills/adapt-on-top-faults/SKILL.md @@ -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 @@ -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. @@ -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. @@ -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) diff --git a/.claude/skills/adaptive-meshing/SKILL.md b/.claude/skills/adaptive-meshing/SKILL.md index 36102de0..d54697f1 100644 --- a/.claude/skills/adaptive-meshing/SKILL.md +++ b/.claude/skills/adaptive-meshing/SKILL.md @@ -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 @@ -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 @@ -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 ``` diff --git a/docs/advanced/mesh-adaptation.md b/docs/advanced/mesh-adaptation.md index 86a27859..83a976d2 100644 --- a/docs/advanced/mesh-adaptation.md +++ b/docs/advanced/mesh-adaptation.md @@ -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* | @@ -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 ... @@ -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 diff --git a/docs/developer/CHANGELOG.md b/docs/developer/CHANGELOG.md index 39060345..e39058ce 100644 --- a/docs/developer/CHANGELOG.md +++ b/docs/developer/CHANGELOG.md @@ -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 diff --git a/src/underworld3/discretisation/discretisation_mesh.py b/src/underworld3/discretisation/discretisation_mesh.py index d33fc64e..32e57fa0 100644 --- a/src/underworld3/discretisation/discretisation_mesh.py +++ b/src/underworld3/discretisation/discretisation_mesh.py @@ -6087,6 +6087,77 @@ def _coarse_level_meshes(self): self._coarse_level_meshes_cache = cached return cached + def redistribute_nodes(self, metric, *, verbose=False, **kwargs): + r"""Move this mesh's nodes so cell sizes follow ``metric``, with + the topology fixed. + + Node **redistribution** concentrates the existing node budget + where the metric demands resolution: vertex count, vertex ids, + DOF layout and the parallel partition are all preserved — only + coordinates move. Mesh variables (and solver / transport + history) are transferred onto the moved nodes automatically. + Contrast :meth:`adapt`, which *refines* (returns a child mesh + with more cells), and :meth:`remesh`, which regenerates the + mesh in place. + + This method is how each mesh type controls whether (and how) it + can be modified: the base implementation supports **2D simplex + (triangle) meshes**, where it drives the Huang–Kamenski + variational MMPDE mover (non-folding by construction, + parallel-safe; scalar metric → isotropic equidistribution, + tensor metric → anisotropic clustering and alignment). + Quadrilateral / hexahedral meshes, 3D meshes and constrained + manifolds raise ``NotImplementedError`` — no mover is + implemented for them yet. + + Parameters + ---------- + metric : sympy expression, MeshVariable, or sympy Matrix + Target density :math:`\rho(x)` (scalar, larger ⇒ finer + cells) or a full :math:`d \times d` SPD metric tensor + (anisotropic: small across a feature, base along it). + verbose : bool, default False + Print mover progress. + **kwargs + Forwarded to + :func:`underworld3.meshing.smooth_mesh_interior` — e.g. + ``pinned_labels``, ``slip_surfaces``, ``skip_threshold``, + ``method_kwargs`` (the mover tunables). + + Examples + -------- + >>> x, y = mesh.CoordinateSystem.X + >>> rho = 1 + 8 * sympy.exp(-(((x - 0.5)**2 + (y - 0.5)**2) / 0.05)) + >>> mesh.redistribute_nodes(rho) + + See Also + -------- + underworld3.meshing.node_redistribution : The free-function + spelling of this operation. + underworld3.meshing.follow_metric : Two-knob adapter that + builds the metric from a field gradient. + adapt : Add resolution (topology change, returns a child mesh). + """ + if self.dim != self.cdim: + raise NotImplementedError( + "node redistribution is not implemented for constrained-" + f"manifold meshes (mesh.dim={self.dim} != mesh.cdim=" + f"{self.cdim}): every node would have to be constrained " + "to the surface. Implemented today: 2D simplex (triangle) " + "meshes, via the MMPDE mover.") + if not bool(self.dm.isSimplex()) or self.cdim != 2: + kind = "simplex" if bool(self.dm.isSimplex()) else "tensor-product (quad/hex)" + raise NotImplementedError( + f"node redistribution is not implemented for {self.cdim}D " + f"{kind} meshes. Implemented today: 2D simplex (triangle) " + "meshes, via the MMPDE mover (its 3D / quad discretization " + "does not exist yet). To add resolution instead, use " + "mesh.adapt(metric_field, max_levels=...) — a topology " + "change.") + from underworld3.meshing.smoothing import smooth_mesh_interior + smooth_mesh_interior(self, metric=metric, method="mmpde", + verbose=verbose, **kwargs) + def adapt(self, metric_field, max_levels=None, node_budget=None, builder=None, adapter=None, engine=None, verbose=False): r""" @@ -6099,26 +6170,34 @@ def adapt(self, metric_field, max_levels=None, node_budget=None, base finest, so successive adapts are non-cumulative (cf. :meth:`remesh`, which regenerates the mesh in place via MMG and may redistribute). - Two refinement **engines** (``engine=``): - - * ``"sbr"`` (default) — PETSc skeleton-based (longest-edge) bisection. Each + The default call needs no engine choice — + ``mesh.adapt(metric, max_levels=...)`` refines with the graded + newest-vertex-bisection engine. This is **2D (triangle meshes) only** + for now: an engine-less 3D call raises ``NotImplementedError`` (3D + refinement is planned work; ``engine="sbr"`` opts a 3D mesh into the + simple isotropic engine explicitly). ``engine=`` remains available + as an **advanced / internal selector** (the algorithm names live + here, not in the everyday call): + + * ``"nvb"`` (default) — newest-vertex bisection, a **graded** engine with a + *bounded* conforming closure: a marked cell adds O(1) cells locally, so + successive levels grade (a level+1 ring around a finer core) and DOFs + concentrate near the feature. Runs **in parallel** via the native + ``uwnvb`` ``DMPlexTransform`` (in-place, co-partitioned with the parent, + bit-confluent serial↔parallel); when that compiled extension is absent it + falls back to the serial ``NVBMesh`` cell-list engine + (``NotImplementedError`` at np>1). Bisects 1→2, so one + isotropic-equivalent ``max_levels`` is run as **two** NVB passes. + * ``"sbr"`` — PETSc skeleton-based (longest-edge) bisection. Each pass refines marked cells isotropically (1→4). Its conforming closure is *unbounded for region marking*, so it produces a **uniform-finest patch**, not a graded mesh (a marked cell drains the longest-edge path to the patch edge). Robust and fine for the MG hierarchy. - * ``"nvb"`` — newest-vertex bisection, a **graded** engine with a *bounded* - conforming closure: a marked cell adds O(1) cells locally, so successive - levels grade (a level+1 ring around a finer core) and DOFs concentrate near - the feature. Runs **in parallel** via the native ``uwnvb`` ``DMPlexTransform`` - (in-place, co-partitioned with the parent, bit-confluent serial↔parallel); - when that compiled extension is absent it falls back to the serial - ``NVBMesh`` cell-list engine (``NotImplementedError`` at np>1). Bisects 1→2, - so one isotropic-equivalent ``max_levels`` is run as **two** NVB passes. - - The child owns a custom-P geometric-MG hierarchy with **one level per SBR - refinement step** — ``[base L0 … base finest, SBR-1, …, SBR-n(child)]`` — + + The child owns a custom-P geometric-MG hierarchy with **one level per + refinement step** — ``[base L0 … base finest, refine-1, …, refine-n]`` — so every solver built on it drives geometric multigrid on the refined - operator with no per-solver setup. (Each ``max_levels`` SBR pass adds its + operator with no per-solver setup. (Each ``max_levels`` pass adds its own MG level; the transfers between consecutive levels each span a single refinement.) @@ -6142,8 +6221,8 @@ def adapt(self, metric_field, max_levels=None, node_budget=None, to a clean, uniform-width band instead of a P1-aliased *patchy* one. Same interface as :meth:`remesh` / ``adaptivity.create_metric``. max_levels : int, default 2 - Maximum SBR depth applied on top of the base finest (bounds the - on-rank imbalance). Each level re-marks against the metric. + Maximum refinement depth applied on top of the base finest (bounds + the on-rank imbalance). Each level re-marks against the metric. node_budget : int or None Optional cap on the number of *seed* cells marked per level (highest- metric first). **Caveat:** this caps the marked seeds, *not* the @@ -6162,10 +6241,12 @@ def adapt(self, metric_field, max_levels=None, node_budget=None, ``"sbr"`` (default) is the nested adapt-on-top path (the refinement engine is then chosen by ``engine``). ``"mmg"`` is a **deprecated shim** that forwards to :meth:`remesh` (in-place, returns ``self``). - engine : {"sbr", "nvb"} - The nested refinement engine (ignored when ``adapter="mmg"``). ``"sbr"`` - (default) is longest-edge bisection (uniform patch); ``"nvb"`` is - newest-vertex bisection (graded, serial only). See above. + engine : {"nvb", "sbr"}, optional + Advanced selector for the nested refinement engine (ignored when + ``adapter="mmg"``). Default ``"nvb"`` — graded newest-vertex + bisection; ``"sbr"`` is longest-edge bisection (uniform patch, + still the right choice when a uniform-finest MG patch is wanted). + See above. verbose : bool Returns @@ -6216,7 +6297,20 @@ def adapt(self, metric_field, max_levels=None, node_budget=None, if adapter is None: adapter = "sbr" if engine is None: - engine = "sbr" + # NVB is the default refinement engine (2026-07 naming ruling): + # graded, bounded conforming closure, parallel via the native + # uwnvb transform. NVB is 2D (triangles) only, and the maintainer + # ruled (2026-07-17) that an engine-less 3D adapt must say so + # honestly rather than silently selecting a different algorithm — + # 3D refinement is committed future work (the MMPDE+NVB capstone). + if self.cdim != 2: + raise NotImplementedError( + "Default adaptive mesh refinement is 2D (triangle meshes) " + "only in this release: the NVB refinement engine has no 3D " + "(tetrahedral) implementation yet — 3D refinement is " + "planned work. To use the simple isotropic SBR engine on " + "a 3D mesh explicitly, call mesh.adapt(..., engine='sbr').") + engine = "nvb" if adapter == "mmg": warnings.warn( @@ -6237,7 +6331,7 @@ def adapt(self, metric_field, max_levels=None, node_budget=None, ) def _adapt_nested(self, metric_field, max_levels=2, node_budget=None, - builder="barycentric", engine="sbr", verbose=False): + builder="barycentric", engine="nvb", verbose=False): """Core nested adapt-on-top (SBR or NVB engine). See :meth:`adapt`.""" import math from underworld3.utilities import custom_mg diff --git a/src/underworld3/meshing/__init__.py b/src/underworld3/meshing/__init__.py index 896e8481..0d60bb08 100644 --- a/src/underworld3/meshing/__init__.py +++ b/src/underworld3/meshing/__init__.py @@ -58,6 +58,7 @@ from .smoothing import ( smooth_mesh_interior, smooth_surface_field, + node_redistribution, metric_density_from_gradient, mesh_metric_mismatch, follow_metric, @@ -108,9 +109,10 @@ # Backward compatibility aliases "FaultSurface", "FaultCollection", - # Mesh smoothing + # Mesh smoothing / node redistribution "smooth_mesh_interior", "smooth_surface_field", + "node_redistribution", "metric_density_from_gradient", "mesh_metric_mismatch", "follow_metric", diff --git a/src/underworld3/meshing/smoothing/__init__.py b/src/underworld3/meshing/smoothing/__init__.py index 1071e098..0295838c 100644 --- a/src/underworld3/meshing/smoothing/__init__.py +++ b/src/underworld3/meshing/smoothing/__init__.py @@ -101,6 +101,7 @@ smooth_mesh_interior, _smooth_mesh_interior_bare, follow_metric, + node_redistribution, _RETIRED_MOVER_MESSAGE, ) diff --git a/src/underworld3/meshing/smoothing/api.py b/src/underworld3/meshing/smoothing/api.py index a9d3e51d..9f48ebbc 100644 --- a/src/underworld3/meshing/smoothing/api.py +++ b/src/underworld3/meshing/smoothing/api.py @@ -37,12 +37,13 @@ _RETIRED_MOVER_MESSAGE = ( "the spring-equilibrium, Monge-Ampère, OT-step and anisotropic-Winslow " "interior movers were retired 2026-07, superseded by the variational " - "MMPDE mover. Use smooth_mesh_interior(mesh, metric=..., " - "method='mmpde') — a scalar metric gives the same isotropic " - "equidistributed grading, a tensor metric adds genuine anisotropic " - "clustering — or uw.meshing.follow_metric(...) for the two-knob " - "refinement/coarsening form. For more resolution than a fixed node " - "budget can grade, change topology with mesh.adapt(...) instead.") + "MMPDE mover. Use uw.meshing.node_redistribution(mesh, metric) (or " + "mesh.redistribute_nodes(metric)) — a scalar metric gives the same " + "isotropic equidistributed grading, a tensor metric adds genuine " + "anisotropic clustering — or uw.meshing.follow_metric(...) for the " + "two-knob refinement/coarsening form. For more resolution than a " + "fixed node budget can grade, change topology with mesh.adapt(...) " + "instead.") # Cached adjacency keyed by (mesh-id, pinned-label-tuple, topology). @@ -67,6 +68,12 @@ def smooth_mesh_interior( r"""Smooth a mesh's interior vertices, optionally toward a spatially-varying target spacing. + This is the underlying machinery; for metric-driven node + redistribution the purposeful user entry point is + :func:`node_redistribution` (equivalently + ``mesh.redistribute_nodes``), which dispatches here on supported + mesh types. + **Default (``metric=None``)** — graph-Laplacian Jacobi: each interior vertex is blended toward the plain mean of its edge neighbours, @@ -812,3 +819,49 @@ def _polish(moved): remesh_with_field_transfer(mesh, _do_move, verbose=verbose) return _state["moved"] + + +def node_redistribution(mesh, metric, *, verbose=False, **kwargs): + r"""Move a mesh's nodes so cell sizes follow ``metric`` (fixed + topology). + + The purposeful, user-facing spelling of node **redistribution**: + the existing node budget is concentrated where the metric demands + resolution, with vertex count, DOF layout and the parallel + partition preserved. This is a thin dispatch to + ``mesh.redistribute_nodes(metric, ...)`` — the mesh type controls + whether (and how) it can be modified. The base implementation + supports 2D simplex (triangle) meshes via the Huang–Kamenski + variational MMPDE mover; unsupported mesh types (quad/hex, 3D, + manifolds) raise ``NotImplementedError`` stating what exists. + + Parameters + ---------- + mesh : underworld3.discretisation.Mesh + The mesh to modify in place. + metric : sympy expression, MeshVariable, or sympy Matrix + Target density :math:`\rho(x)` (scalar, larger ⇒ finer cells) + or a full :math:`d \times d` SPD metric tensor. + verbose : bool, default False + Print mover progress. + **kwargs + Forwarded to the mesh's redistribution machinery + (:func:`smooth_mesh_interior`) — e.g. ``pinned_labels``, + ``slip_surfaces``, ``skip_threshold``, ``method_kwargs``. + + Examples + -------- + >>> x, y = mesh.CoordinateSystem.X + >>> rho = 1 + 8 * sympy.exp(-(((x - 0.5)**2 + (y - 0.5)**2) / 0.05)) + >>> uw.meshing.node_redistribution(mesh, rho) + + See Also + -------- + underworld3.discretisation.Mesh.redistribute_nodes : The method + this function dispatches to. + follow_metric : Two-knob adapter that builds the metric from a + field gradient. + underworld3.discretisation.Mesh.adapt : Add resolution (topology + change, returns a child mesh). + """ + return mesh.redistribute_nodes(metric, verbose=verbose, **kwargs) diff --git a/tests/test_0764_node_redistribution.py b/tests/test_0764_node_redistribution.py new file mode 100644 index 00000000..9a685c99 --- /dev/null +++ b/tests/test_0764_node_redistribution.py @@ -0,0 +1,137 @@ +"""Contract tests for the purposeful adapt/redistribute naming (2026-07). + +The 2026-07 naming ruling: user-facing names state the CAPABILITY; +algorithm names (NVB, MMPDE) live in internals and docs. Locked here: + +* ``uw.meshing.node_redistribution(mesh, metric)`` — the user entry for + fixed-topology node redistribution — dispatches through the + mesh-controlled ``Mesh.redistribute_nodes`` method (mesh types control + how they can be modified); +* supported: 2D simplex (triangle) meshes, warning-free; +* unsupported mesh types (quad/hex, 3D simplex, manifolds) raise an + honest ``NotImplementedError`` stating what exists; +* ``mesh.adapt(metric, max_levels=...)`` needs no ``engine=`` and + defaults to the graded NVB engine on 2D meshes. +""" + +import numpy as np +import pytest +import sympy + +import underworld3 as uw + + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + + +def _tri_box(cs=1.0 / 6): + return uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=cs) + + +def _bump_metric(mesh): + x, y = mesh.CoordinateSystem.X + return 1 + 4 * sympy.exp(-(((x - 0.5) ** 2 + (y - 0.5) ** 2) / 0.05)) + + +class TestNodeRedistribution: + def test_moves_2d_simplex_zero_warnings(self): + import warnings + mesh = _tri_box() + rho = _bump_metric(mesh) + n_verts = np.asarray(mesh.X.coords).shape[0] + before = np.asarray(mesh.X.coords).copy() + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + uw.meshing.node_redistribution( + mesh, rho, method_kwargs=dict(n_outer=3)) + after = np.asarray(mesh.X.coords) + assert after.shape[0] == n_verts # topology preserved + assert np.all(np.isfinite(after)) + assert not np.allclose(before, after) # nodes actually moved + + def test_free_function_matches_method(self): + """The free function is a thin dispatch to the mesh-controlled + method — identical result on twin meshes.""" + m1 = _tri_box() + m2 = _tri_box() + uw.meshing.node_redistribution( + m1, _bump_metric(m1), method_kwargs=dict(n_outer=3)) + m2.redistribute_nodes( + _bump_metric(m2), method_kwargs=dict(n_outer=3)) + assert np.array_equal(np.asarray(m1.X.coords), + np.asarray(m2.X.coords)) + + def test_quad_mesh_raises_not_implemented(self): + mesh = uw.meshing.StructuredQuadBox(elementRes=(4, 4)) + with pytest.raises(NotImplementedError, + match="2D simplex"): + uw.meshing.node_redistribution(mesh, sympy.sympify(1)) + + def test_3d_simplex_raises_not_implemented(self): + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0, 0.0), maxCoords=(1.0, 1.0, 1.0), + cellSize=0.5) + with pytest.raises(NotImplementedError, + match="2D simplex"): + uw.meshing.node_redistribution(mesh, sympy.sympify(1)) + + def test_exported_from_meshing_namespace(self): + assert "node_redistribution" in uw.meshing.__all__ + assert callable(uw.meshing.node_redistribution) + + +class TestEngineLessAdapt: + """mesh.adapt(metric, max_levels=...) works without engine= and + resolves to the graded NVB engine on a 2D mesh (the algorithm name + stays an advanced selector).""" + + def _base_and_metric(self): + # adapt() needs a base built with refinement>=1 (the dm_hierarchy + # supplies the geometric-MG coarse tail). + base = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), + cellSize=0.25, refinement=1, qdegree=3) + # 1/h^2 metric demanding refinement in a central band + M = uw.discretisation.MeshVariable("Mnr", base, 1, degree=1) + band = sympy.exp(-(((base.N.x - 0.5) / 0.08) ** 2)) + vals = uw.function.evaluate( + 1.0 / (0.07 + (1.0 / 60 - 0.07) * band) ** 2, + np.asarray(M.coords)) + M.data[:, 0] = np.asarray(vals).reshape(-1) + return base, M + + def test_engine_less_adapt_returns_child(self): + base, M = self._base_and_metric() + child = base.adapt(M, max_levels=1) + assert child is not base + assert child.parent is base + + def test_engine_less_adapt_3d_raises_not_implemented(self): + # Maintainer ruling 2026-07-17: an engine-less 3D adapt must refuse + # honestly (3D refinement is planned work), not silently select SBR. + # engine="sbr" remains the explicit 3D opt-in. + base3d = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0, 0.0), maxCoords=(1.0, 1.0, 1.0), + cellSize=0.5, refinement=1, + ) + M = uw.discretisation.MeshVariable("M3d", base3d, 1, degree=1) + M.data[:, 0] = 1.0 + with pytest.raises(NotImplementedError, match="planned work"): + base3d.adapt(M, max_levels=1) + + def test_engine_less_adapt_is_nvb(self): + """The engine-less child matches an explicit engine='nvb' child + cell-for-cell (and differs from SBR's uniform-patch closure).""" + base1, M1 = self._base_and_metric() + c_default = base1.adapt(M1, max_levels=1) + base2, M2 = self._base_and_metric() + c_nvb = base2.adapt(M2, max_levels=1, engine="nvb") + + def n_cells(m): + cS, cE = m.dm.getHeightStratum(0) + return cE - cS + + assert n_cells(c_default) == n_cells(c_nvb) + assert np.array_equal(np.asarray(c_default.X.coords), + np.asarray(c_nvb.X.coords)) diff --git a/tests/test_0835_sbr_adapt_on_top.py b/tests/test_0835_sbr_adapt_on_top.py index 9452b55e..4308c9d9 100644 --- a/tests/test_0835_sbr_adapt_on_top.py +++ b/tests/test_0835_sbr_adapt_on_top.py @@ -1,4 +1,8 @@ -"""Layer-2 SBR adapt-on-top: mesh.adapt(adapter='sbr') returns a refined CHILD. +"""Layer-2 SBR adapt-on-top: mesh.adapt(..., engine="sbr") returns a refined CHILD. + +(engine="sbr" is pinned explicitly throughout: since 2026-07 the engine-less +call defaults to the graded NVB engine on 2D meshes; this file locks the SBR +engine's own contract.) The new nested adapter (no MMG, on-rank, no redistribute) refines the static base finest where a metric demands resolution and returns a child mesh that owns a @@ -64,7 +68,7 @@ def _poisson(mesh): def test_adapt_returns_refinement_child(): base = _base() n0 = _ncell(base) - child = base.adapt(_metric(base, 0.7), max_levels=1) + child = base.adapt(_metric(base, 0.7), max_levels=1, engine="sbr") assert child is not base assert child.parent is base @@ -76,7 +80,7 @@ def test_adapt_returns_refinement_child(): def test_child_solver_auto_picks_up_custom_mg(): base = _base() - child = base.adapt(_metric(base, 0.7), max_levels=1) + child = base.adapt(_metric(base, 0.7), max_levels=1, engine="sbr") s = _poisson(child) s.solve() # NO set_custom_fmg @@ -93,7 +97,7 @@ def test_child_solver_auto_picks_up_custom_mg(): def test_copy_into_prolongate_and_restrict(): base = _base() - child = base.adapt(_metric(base, 0.7), max_levels=1) + child = base.adapt(_metric(base, 0.7), max_levels=1, engine="sbr") fn = sympy.sin(2 * sympy.pi * base.N.x) * sympy.cos(sympy.pi * base.N.y) Tb = uw.discretisation.MeshVariable("Tb", base, 1, degree=2) @@ -115,9 +119,9 @@ def test_copy_into_prolongate_and_restrict(): def test_readapt_is_non_cumulative(): base = _base() n0 = _ncell(base) - c1 = base.adapt(_metric(base, 0.7), max_levels=1) + c1 = base.adapt(_metric(base, 0.7), max_levels=1, engine="sbr") n1 = _ncell(c1) - c2 = base.adapt(_metric(base, 0.35), max_levels=1) + c2 = base.adapt(_metric(base, 0.35), max_levels=1, engine="sbr") # each adapt re-marks from the SAME static base finest assert _ncell(base) == n0 assert _ncell(c2) == n1 # symmetric band -> same count, different place @@ -127,8 +131,8 @@ def test_readapt_is_non_cumulative(): def test_node_budget_localises_refinement(): base = _base() sharp = _metric(base, 0.7, width=0.06) - full = base.adapt(sharp, max_levels=1) - budgeted = base.adapt(sharp, max_levels=1, node_budget=40) + full = base.adapt(sharp, max_levels=1, engine="sbr") + budgeted = base.adapt(sharp, max_levels=1, node_budget=40, engine="sbr") assert _ncell(budgeted) < _ncell(full) @@ -159,7 +163,7 @@ def test_stokes_velocity_block_auto_picks_up_fmg(): M = uw.discretisation.MeshVariable("Mjump", base, 1, degree=1) band = sympy.exp(-(((base.N.x - 0.5) / 0.08) ** 2)) M.data[:, 0] = _ev(1.0 / (0.07 + (1.0 / 80 - 0.07) * band) ** 2, M.coords) - child = base.adapt(M, max_levels=1) + child = base.adapt(M, max_levels=1, engine="sbr") assert _ncell(child) < n_uniform # local, not a uniform refine def _solcx(mesh): @@ -206,12 +210,12 @@ def test_each_sbr_level_is_its_own_mg_level(): n_base = len(base.dm_hierarchy) sharp = _metric(base, 0.5, h_fine=1.0 / 150, width=0.06) for ml, want_levels in [(1, n_base + 1), (2, n_base + 2)]: - child = base.adapt(sharp, max_levels=ml) + child = base.adapt(sharp, max_levels=ml, engine="sbr") # coarse tail + the child itself assert len(child._custom_mg_coarse_meshes) + 1 == want_levels # a Poisson solve on a 2-level child must actually drive all n_base+2 levels - child2 = base.adapt(sharp, max_levels=2) + child2 = base.adapt(sharp, max_levels=2, engine="sbr") s = _poisson(child2) s.solve() assert s.snes.getKSP().getPC().getType() == "mg" @@ -237,8 +241,8 @@ def test_wedge_metric_funnels_finest_level_to_feature(): gauss = fault.refinement_metric(h_near=0.0625 / 8, h_far=0.07, width=0.05, profile="gaussian", name="gauss_m") - cw = base.adapt(wedge, max_levels=3) - cg = base.adapt(gauss, max_levels=3) + cw = base.adapt(wedge, max_levels=3, engine="sbr") + cg = base.adapt(gauss, max_levels=3, engine="sbr") # per-level marked counts are stashed for checkpoint-by-marker nw = [len(m) for m in cw._adapt_markers] ng = [len(m) for m in cg._adapt_markers] @@ -260,4 +264,4 @@ def test_adapt_requires_base_hierarchy(): H = uw.discretisation.MeshVariable("Hflat", flat, 1, degree=1) H.data[:, 0] = 1.0 / 0.02**2 with pytest.raises(RuntimeError): - flat.adapt(H, max_levels=1) + flat.adapt(H, max_levels=1, engine="sbr")