Adaptive Mesh Refinement Design Document¶
Status: Implemented Date: January 2026 Authors: Louis Moresi, Claude Code
Overview¶
Underworld3 provides adaptive mesh refinement (AMR) capabilities through integration with MMG/PETSc mesh adaptation algorithms. This document describes the design, implementation, and mathematical foundations of the system.
Architecture¶
Component Overview¶
flowchart TB
subgraph API["User-Facing API"]
direction LR
A1["mesh.adapt(metric)"]
A2["surface.refinement_metric()"]
A3["uw.adaptivity.metric_from_*()"]
end
subgraph Metric["Metric System"]
direction LR
M1["create_metric()"]
M2["metric_from_gradient()"]
M3["metric_from_field()"]
end
subgraph PETSc["PETSc/MMG Integration"]
direction LR
P1["mesh_adapt_meshVar()"]
P2["_dm_stack_bcs()"]
P3["_dm_unstack_bcs()"]
end
subgraph External["External Libraries"]
direction LR
E1["MMG (mmg2d, mmg3d)"]
E2["ParMMG"]
E3["PETSc DMPlex"]
end
API --> Metric
Metric --> PETSc
PETSc --> External
Key Files¶
File |
Purpose |
|---|---|
|
Core adaptation functions and metric creation |
|
Surface class with |
|
|
Metric Tensor Mathematics¶
Fundamental Relationship¶
For isotropic mesh adaptation, MMG/PETSc uses a metric tensor:
where:
\(h\) is the target edge length
\(I\) is the identity matrix (2×2 in 2D, 3×3 in 3D)
This relationship is dimension-independent — the same formula applies in 2D and 3D because the metric defines edge lengths, not areas or volumes.
How Adaptation Works¶
The adaptation algorithm seeks to make all edges have unit length in metric space:
for edge vector \(\mathbf{e}\). When an edge is longer than the target (in metric space), it gets subdivided. When shorter, elements may be merged.
Key Insight: Metric Drives to Unity¶
During refinement, the mesh metric at each point is driven toward unity. This means:
Higher metric values → finer mesh (smaller elements)
Lower metric values → coarser mesh (larger elements)
The relationship between metric value \(M\) and element density is:
\(M = 1/h^2\), so \(h = 1/\sqrt{M}\)
Doubling \(M\) reduces edge length by \(\sqrt{2}\)
100× larger \(M\) gives 10× smaller edges
Metric Value Interpretation¶
The metric values are dimensionless and relate to target edge lengths:
h-value |
Metric (M = 1/h²) |
Element Size Relative to h=1 |
|---|---|---|
1.0 |
1.0 |
Reference |
0.5 |
4.0 |
2× finer |
0.1 |
100.0 |
10× finer |
0.01 |
10,000.0 |
100× finer |
2.0 |
0.25 |
2× coarser |
Metric Creation Functions¶
create_metric(mesh, h_values, name)¶
The core function that converts an h-field (target edge lengths) to metric values:
def create_metric(mesh, h_values, name=None):
"""Convert h-field to metric tensor format."""
metric = uw.discretisation.MeshVariable(name, mesh, 1, degree=1)
with mesh.access(metric):
# M = 1/h² (isotropic, dimension-independent)
metric.data[:, 0] = 1.0 / (h_values ** 2)
return metric
metric_from_gradient(field, h_min, h_max, …)¶
Creates adaptation metric from the gradient of a scalar field:
Steep gradients → small h → large metric → finer mesh
Smooth regions → large h → small metric → coarser mesh
Uses Clement interpolant for gradient computation:
gradient_at_nodes = uw.function.compute_clement_gradient_at_nodes(field)
grad_mag = np.sqrt(np.sum(gradient_at_nodes ** 2, axis=1))
Profiles available:
"linear": Direct mapping from gradient to h"smoothstep": S-curve transition (C¹ continuous)"power": h ∝ |∇φ|^(-1/2) for error equidistribution
metric_from_field(indicator, h_min, h_max, …)¶
General-purpose metric from any indicator field:
Error estimates
Phase fields (refine at interfaces)
Distance fields
Material composition
Surface.refinement_metric(h_near, h_far, width, …)¶
Specialized for surface-based refinement:
fault = uw.meshing.Surface("fault", mesh, fault_points)
metric = fault.refinement_metric(
h_near=0.005, # Fine mesh near surface
h_far=0.05, # Coarse mesh far away
width=0.1, # Transition zone
profile="linear"
)
mesh.adapt(metric)
Boundary Label Handling¶
PETSc’s DMPlexAdaptMetric only interpolates one boundary label during adaptation. Underworld handles this transparently by using a unified UW_Boundaries label at mesh creation time, where different boundaries are distinguished by numerical values rather than separate label names.
This means boundary conditions are preserved automatically through adaptation — users don’t need to manage label stacking manually.
Element Count Control¶
The Challenge¶
Refining near features (faults, boundaries) increases element count. To maintain roughly constant total elements, we need to coarsen far from features.
Strategy¶
Total elements ≈ (refined region elements) + (coarse region elements)
≈ (small area × high density) + (large area × low density)
For a fault that affects ~10% of the domain with 10× refinement:
Refined region: 0.1 × 100 = 10 units
Coarse region: 0.9 × 0.5 = 0.45 units (if coarsened 2×)
Total: ~10.45 vs original 1.0 × 1 = 1.0
To compensate, coarsen the far-field:
Use
h_far > h_originalThe refined region is small, so slight coarsening elsewhere compensates
Empirical Calibration¶
In practice, metric values require empirical calibration:
Parameter |
Purpose |
Typical Range |
|---|---|---|
|
Target edge length near feature |
0.1× to 0.01× of domain scale |
|
Target edge length far from feature |
1× to 2× of original cell size |
|
Transition zone width |
2× to 5× h_far |
Example (from shear_box_2d_fault_adaptive.py):
params = uw.Params(
uw_cell_size_km = 0.5, # Initial mesh: coarser to allow refinement room
uw_adapt_h_near_km = 0.05, # 10× finer than initial
uw_adapt_h_far_km = 0.9, # Slight coarsening to compensate
uw_adapt_width_km = 1.5, # Transition zone
)
Variable Handling After Adaptation¶
The Problem¶
After mesh adaptation, variables on the old mesh cannot be used directly — the nodes have changed.
Solution: Lazy Recomputation¶
Variables are marked “stale” after adaptation and recomputed lazily when accessed:
class Surface:
def _mark_all_proxies_stale(self):
"""Mark all variable proxies as needing recomputation."""
for var in self._variables.values():
var._proxy_stale = True
For MeshVariables, users must either:
Reinitialize from analytical functions
Use
mesh2mesh_meshVariable()to transfer data
mesh2mesh_meshVariable()¶
Transfers data between meshes using a swarm intermediary:
def mesh2mesh_meshVariable(from_var, to_var):
"""Transfer MeshVariable data via swarm interpolation."""
# Create temporary swarm on new mesh
# Interpolate old field to swarm positions
# Project swarm data to new mesh variable
Performance Considerations¶
Adaptation Cost¶
Mesh adaptation with MMG is typically:
2D: Fast (seconds for 100k elements)
3D: Slower (minutes for complex geometries)
The cost scales with:
Initial element count
Refinement ratio (h_max/h_min)
Boundary complexity
Memory Requirements¶
MMG requires memory for both old and new meshes during adaptation. For very large meshes or aggressive refinement, memory can be a bottleneck.
Error message to watch for:
## Error: unable to allocate larger solution.
## Check the mesh size or increase maximal authorized memory with the -m option.
Solution: Use more moderate refinement ratios or start with coarser initial mesh.
Parallel Considerations¶
ParMMGprovides parallel adaptationRequires PETSc built with ParMMG support
Domain decomposition is handled automatically
Environment Requirements¶
AMR features require the amr environment with custom PETSc build:
# Install AMR environment
pixi install -e amr
# Build custom PETSc (takes ~1 hour)
pixi run -e amr petsc-build
# Run with AMR support
pixi run -e amr python my_adaptive_script.py
The custom PETSc includes:
MMG (2D and 3D mesh adaptation)
ParMMG (parallel adaptation)
Pragmatic (alternative adaptation library)
API Reference Summary¶
Core Functions¶
# Create metric from h-field
metric = uw.adaptivity.create_metric(mesh, h_values)
# Create metric from gradient
metric = uw.adaptivity.metric_from_gradient(field, h_min, h_max)
# Create metric from indicator
metric = uw.adaptivity.metric_from_field(indicator, h_min, h_max)
# Adapt mesh
new_mesh = mesh.adapt(metric)
# Transfer variable data
uw.adaptivity.mesh2mesh_meshVariable(old_var, new_var)
Surface-Based Refinement¶
# Create surface from points
surface = uw.meshing.Surface("fault", mesh, points)
surface.discretize()
# Get distance field
dist = surface.distance # MeshVariable with signed distance
# Create refinement metric
metric = surface.refinement_metric(
h_near=0.01, # Fine near surface
h_far=0.1, # Coarse far away
width=0.2, # Transition zone
profile="linear"
)
# Adapt
mesh.adapt(metric)
References¶
MMG Platform documentation: https://www.mmgtools.org/
Alauzet, F. “Metric-based anisotropic mesh adaptation” (2010)
PETSc DMPlex documentation: https://petsc.org/main/manual/dmplex/
ParMMG: https://github.com/MmgTools/ParMmg