Design Assessment: Adaptive Mesh Refinement (AMR) for 2D Models#
Status: Proposal / scoping document — no implementation yet. Scope: 2D quadrilateral meshes, quadtree (2:1) h-refinement built on the implemented mortar interface support, spectral (Legendre modal) refinement indicator, CPU and GPU backends, MPI-parallel.
Revision note: the original version of this document scoped AMR under a "conforming elements only, no mortars" requirement, which forced a template-based 3-refinement design (9 children per level, transition-element closures). That constraint has since been dropped: 2:1 nonconforming mortar interfaces are now implemented in SELF (see Nonconforming (Mortar) Interfaces and the companion mortar design document). This document has been rewritten around the mortar-based quadtree design, which is the industry-standard AMR structure (FLEXI, Trixi.jl, Nektar++). The retired conforming-template analysis is preserved in the git history of this file for reference.
1. Requirements#
- A new mesh type supporting adaptive refinement/coarsening of 2D quad meshes, built on the existing 2:1 mortar interface machinery.
- New model type(s) that leverage the AMR mesh (starting from
DGModel2D). - Refinement indicator built from Legendre polynomial projection coefficients of the element-local solution: refine when the modal spectrum is "too shallow" (slow decay) or when high-order modes carry too much energy.
- CPU and GPU backends.
2. Current-state audit#
What exists today:
| Capability | Status | Where |
|---|---|---|
| Conforming quad mesh, side pairing, geometry, halo exchange | ✅ | Mesh2D_t, SEMQuad, mapped data classes |
2:1 mortar interfaces (restriction/L2-projection operators, mortarInfo connectivity, MortarExchange/MortarFluxCollect, CPU + GPU + MPI) |
✅ | Lagrange_t%mortarR/mortarP, src/SELF_Mesh_2D_t.f90, mapped 2-D data classes, src/gpu/SELF_Mortar.cpp |
| Nonconforming test mesh constructor | ✅ | SimpleMortarMesh |
| Mortar validation tests (operators, exchange, conservation, dynamics; serial + MPI) | ✅ | test/mortar*, test/*mortar* |
| Aggregated GPU halo exchange with per-field packed buffers and persistent requests | ✅ | SideExchangeStart/Finish, decomp%halo_* tables |
| Legendre polynomial evaluation | ✅ | LegendrePolynomial, src/SELF_Quadrature.f90 |
| Nodal→modal (Legendre) transform, spectral indicator | ❌ none | — |
| Quadtree/forest structures, refine/coarsen operators | ❌ none | — |
Dynamic nElem (reallocatable fields, resizable GPU mirrors) |
❌ none | all data objects sized at Init |
Load-bearing consequences:
- The hard interface problem is solved. With mortars in place, an adapted
quadtree mesh is directly representable: coarse/fine element interfaces
become
mortarInfoentries, and the entire solver stack (side exchange, mortar exchange, flux collect, DG operators) already runs on such meshes on both backends with MPI. AMR reduces to mesh management + indicator + solution transfer. - Nothing is sized dynamically.
Mesh2D,SEMQuad, and all field objects (host arrays andc_ptrdevice mirrors) are allocated once for a fixednElem. Adaptation implies free/re-init of mesh, geometry, and model storage — acceptable when adaptation is infrequent relative to time steps. - Per-field exchange caches must be invalidated on adaptation. The
aggregated halo exchange lazily builds packed device buffers, persistent MPI
requests (
halo_reqs), and shared side tables (decomp%halo_*); the mortar exchange lazily allocatesmortarBuff. All of these are sized by the current mesh/partition and must be torn down and rebuilt when the mesh changes. - BC bookkeeping is element-indexed.
MapBoundaryConditionscaches(element, side)lists per BC with device copies; these must be rebuilt after every adaptation.
3. Mesh design: forest of quadtrees over a base mesh#
The authoritative adaptive structure is a forest of binary-refinement quadtrees, one tree per element of the user-supplied base mesh ("root elements"). A refined element splits into 4 congruent children (2×2 in reference space); each level halves h.
Each adaptation cycle produces an ordinary Mesh2D (the "leaf mesh") plus its
mortar table:
- Evaluate the spectral indicator per element → refine/coarsen flags.
- Smooth flags: enforce 2:1 balance (edge-neighbor tree levels differ by at most 1); coarsen only complete 4-sibling groups whose removal keeps the balance valid.
- Update the trees (split/merge nodes).
- Emit leaves: assign global corner-node IDs (hash on quantized parametric position within the root) and a deterministic global ordering (roots in base-mesh order, Z-order/depth-first within each tree — locality-preserving for the block partition).
- Build connectivity: equal-level edge pairings become conforming
sideInfoentries (reusing the corner-pair hash matching already insrc/SELF_Mesh_2D_t.f90, lifted into a shared helper, plusRecalculateFlip); level-differing edge pairings becomemortarInfoentries, with sub-edge order and flips fixed by the tree geometry. - Regenerate
SEMQuadgeometry via the existingGenerateFromMeshpath.
Design points:
- Regeneration over incremental surgery. Rebuilding the leaf mesh wholesale reuses the existing, MPI-correct construction path and is far easier to verify; adaptation cost is amortized over many steps. Incremental updates are a later optimization if profiling demands it.
- No transition elements, no promotion passes. Unlike the retired conforming design, tree leaves are the mesh; closure is the 2:1 balance constraint alone. Coarsening is a local 4-sibling merge.
- Geometry of children. Every leaf carries its affine map (corner points)
in the root element's reference square plus
(rootElem, level). FornGeo = 1base meshes, child corners are the bilinear image of the dyadic sub-squares — exact, and the mortar interface's watertightness requirement is satisfied by construction. For curved elements (nGeo > 1), child geometry nodes are sampled from the parent's isoparametric map, preserving boundary geometry to the parent's order (both sides of a mortar then derive their metrics from the same parent map, which keeps the discrete free-stream property). - Boundary conditions. A leaf edge on a root-element boundary edge
inherits that edge's
sideInfo(5)BC id; interior leaf edges get 0.BCType/BCNamescopy from the base mesh unchanged. - Containment, not inheritance:
type :: AMRMesh2D_t
type(Mesh2D) :: base ! user-supplied root mesh (never modified)
type(Mesh2D) :: leaf ! regenerated mesh (+ mortarInfo); models point here
! forest state
integer :: nRoots, nLeaves, maxLevel
integer, allocatable :: leafRoot(:) ! root element of each leaf
integer, allocatable :: leafLevel(:) ! tree level of each leaf
real(prec), allocatable :: leafMap(:,:) ! (2,nLeaves) dyadic offset in root ref square
type(...), allocatable :: trees(:) ! per-root quadtree (packed arrays)
contains
procedure :: Init ! from an existing Mesh2D (structured or file-read)
procedure :: Adapt ! flags in -> new leaf mesh + transfer maps out
procedure :: Free
endtype
Models only need a type(Mesh2D), pointer at amrMesh%leaf, keeping
DGModel2D, the exchanges, geometry, and I/O unaware of AMR.
4. Spectral refinement indicator (Legendre modal analysis)#
4.1 Nodal → modal transform#
New machinery (currently absent — only LegendrePolynomial exists as a
building block). For control points ξ_i with quadrature weights w_i (Gauss
or Gauss–Lobatto, degree N), define the discrete Legendre coefficients of a
nodal field u_i = u(ξ_i):
Using the discrete norms γ_k makes the transform exactly invertible on the
nodal space for both node families (for Gauss, γ_k = 2/(2k+1) exactly for
k ≤ N; for GLL, γ_N = 2/N). In matrix form â = P u with P = Γ⁻¹ Lᵀ W;
the 2D transform is the tensor application  = P U Pᵀ per element and
variable — the same two-pass batched shape as the existing derivative and
interpolation operators, so the GPU implementation follows the established
kernel pattern.
Implementation home: add pMatrix(0:N,0:N) (nodal→modal) and its inverse to
Lagrange_t, built in Init_Lagrange_t next to the mortar matrices, with
device mirrors following the mortarR_gpu/mortarP_gpu pattern. This also
unlocks future modal filtering/de-aliasing work.
4.2 Element indicator#
Per element and indicator variable, form directional spectra from the 2D modal
matrix Â_{mn}:
Two criteria, matching the stated requirement:
- Spectral decay rate ("too shallow") — Mavriplis-style least-squares fit
q_m ≈ C e^(−σ m)over the upper half of the spectrum (m = max(1, N/2) … N, skipping near-zero modes below a floor to avoid log-of-noise). Small σ ⇒ under-resolved. - Tail energy ("too much energy in high modes") — Persson–Peraire-style
ratio using the top two modes (two, to be robust to odd/even cancellation):
η = (q_N² + q_{N−1}²) / Σ_m q_m².
Decision logic with hysteresis (two threshold pairs, so elements don't flip refine/coarsen every cycle):
refine if min(σξ, ση) < σ_refine or max(ηξ, ηη) > η_refine
coarsen if min(σξ, ση) > σ_coarsen and max(ηξ, ηη) < η_coarsen
(and level > 0, and all 3 siblings also vote coarsen)
Defaults to be calibrated against manufactured cases (smooth Gaussian: no
flags; near-discontinuity: flags). All thresholds, the adapt cadence, the max
refinement level, and the indicator-variable set are user-facing parameters;
the variable set defaults to the nstepped prognostic variables (max over
variables), overridable per model (e.g. pressure only for LinearEuler2D).
The indicator is computed in reference space, so element size and mapping do not bias it.
4.3 Backends#
- CPU:
do concurrentover elements; two small matmuls + O(N²) reduction per element. Negligible cost relative to one RHS evaluation. - GPU: modal transform with
pMatrix_gpukernels; one custom kernel (SpectralIndicator_2D_gpu, following theSELF_Mortar.cppfile pattern) computesq, the decay fit, tail ratio, and writes per-elementintflags; a single small D2H copy brings flags to the host, where all adaptation logic runs. The indicator runs only at adaptation checkpoints — no new host↔device traffic inside time stepping.
5. Solution transfer between meshes#
Dyadic refinement makes the transfer operators tensor products of the 1-D mortar matrices that already exist:
- Refinement (parent → 4 children): each child's nodal values are the
parent polynomial evaluated on a half-interval in each direction — exactly
the sub-edge restriction
mortarR(:,:,k)applied per direction:U_child(kx,ky) = R_kx U_parent R_kyᵀ. Exact for polynomial data, hence conservative. - Coarsening (4 children → parent): the volume L2 projection is the tensor
product of the 1-D trace projections:
U_parent = Σ_{kx,ky} P_kx U_child(kx,ky) P_kyᵀ, wheremortarPalready carries the 1/2 sub-interval Jacobian per direction (1/4 per child in 2-D, as required). The same exactness argument as the mortar operators givesΣ P R = I(refine-then-coarsen is the identity) and preservation of the element mean to roundoff on affine children. - Unchanged leaf: direct copy (element index remap only).
Because refinement level changes by one per adaptation cycle (2:1 balance is enforced before splitting), transfer never needs to traverse more than one tree level per cycle.
A MeshTransfer2D object encapsulates old→new leaf correspondence; it is
built on the host during adaptation and applied as batched tensor-product
operations (host first; device application is a Phase-5 optimization, and the
operator matrices are already resident on the GPU).
Numerical-correctness note (CLAUDE.md §3): transfer is an
approximation-theoretic operation (interpolation/L2 projection), not a change
to the discretization. Entropy may increase slightly at coarsening (L2
projection is not entropy-stable); this is standard for AMR and will be
measured in validation. The same caveat already applies to the mortar flux
projection, and the same v1 policy is adopted: standard DGModel2D models
only — the EC split-form models reject nonconforming meshes at init and are
likewise out of scope for AMR v1.
6. Model integration#
Adaptation is model-agnostic given class(DGModel2D_t); physics lives in
overridden flux2d/riemannflux2d/BCs and is untouched. An
AMRController2D holds the AMRMesh2D, thresholds, cadence, and performs:
AdaptModel(model):
1. indicator (device or host) -> flags
2. amrMesh%Adapt(flags) -> new leaf Mesh2D (+ mortarInfo) + MeshTransfer2D
3. regenerate SEMQuad geometry (existing GenerateFromMesh)
4. capture old solution; Free + re-Init model fields; re-AssociateGeometry;
rebuild BC lists (MapBoundaryConditions + device copies);
drop per-field exchange caches (packed halo buffers, persistent halo
requests, mortarBuff) so they rebuild lazily against the new mesh
5. apply MeshTransfer2D to fill solution (+ UpdateDevice on GPU builds)
6. refresh decomposition (MPI: repartition + redistribute; §7)
New model types are thin wrappers that own a controller and trigger it from
the existing hook system (PostStepHook, every nStepsPerAdapt steps —
between complete steps, never inside an RK stage):
type, extends(LinearEuler2D) :: AMRLinearEuler2D
type(AMRController2D) :: amr
contains
procedure :: PostStepHook => AdaptHook_AMRLinearEuler2D
endtype
Pilot models: amr_advection_diffusion_2d and AMRLinearEuler2D (CPU + GPU
variants following the existing backend file pattern). Metadata, equation
parsers, and integrator state carry over; workSol is rebuilt (zeroed) at
adaptation, which is correct because adaptation happens between complete
steps.
Time step: explicit CFL shrinks with the smallest h (factor 2 per level).
v1 keeps the user-controlled global dt contract: the controller exposes
hMin() and the examples rescale dt on adaptation. Local time stepping is
out of scope.
I/O: Write_DGModel2D_t already handles arbitrary meshes, but element
counts change between files. Add the leaf metadata
(leafRoot/leafLevel/leafMap) to the output so post-processing (pyself)
can reconstruct geometry per snapshot. Static-mesh outputs are untouched.
7. MPI strategy#
- Ordering & partition: global leaf order = base-mesh root order, Z-order
within each tree (deterministic, locality-preserving). Reuse the contiguous
block partition (
GenerateDecomposition) on the newnLeaves; equal element count is a valid balance measure because all elements share one polynomial degree. - Consistent flags: refine/coarsen flags for ghost-adjacent elements must
agree across ranks before 2:1 balancing. v1:
MPI_Allgathervof flags (one int per element) so every rank runs identical balancing — deterministic and simple; tree metadata is tiny compared to field data. Rank-local balancing with ghost-layer exchange is a scalability follow-up. - Redistribution: old and new partitions are both contiguous over the same
deterministic ordering, so each rank computes exact send/recv ranges
(
MPI_Alltoallvon packed solution payloads). Coarsening groups are applied on the receiving side after redistribution so all four siblings are resident. - Exchange table rebuild: the aggregated halo tables (
decomp%halo_*) and every field's persistent halo requests are partition-specific and are rebuilt lazily after step 4/6 above. The mortar sub-edge global side ids in the regeneratedmortarInfokeep the MPI tag convention valid without any new machinery. - Constraint compliance: all collectives happen inside the adaptation
checkpoint (between time steps), never inside
CalculateTendencyor RK stages, per CLAUDE.md §5.
8. GPU strategy#
Division of labor: numerics on device, adaptation logic on host — the same split the mortar implementation uses.
Runs on device:
- Spectral indicator (pMatrix_gpu transforms + SpectralIndicator_2D_gpu
reduction → per-element flags, one small D2H copy).
- All existing solver machinery on the adapted mesh (RHS, conforming and
mortar exchanges, RK updates) — unchanged; this is already exercised by the
mortar GPU tests.
- Solution transfer apply (Phase 5): batched tensor-product kernels using the
device-resident mortarR_gpu/mortarP_gpu.
Runs on host:
- Tree updates, flag balancing, leaf emission, corner hashing,
RecalculateFlip, mortar-table emission, partition computation.
Memory churn: each adaptation frees and re-allocates every field's device
mirrors plus the lazily-built exchange buffers (existing Init/Free/
UpdateDevice machinery). v1 accepts this (adaptation is infrequent). Phase-5
optimization: capacity-based allocation (nElemCapacity = growthFactor*nElem)
inside the data-object Inits so most adaptations skip reallocation.
One environment constraint to respect (and a reason to land issue #151 first if convenient): device selection currently happens in the mesh's domain-decomposition init, so the AMR controller must never allocate device resources before the base mesh exists — the same ordering rule the mortar tests document.
9. Testing & validation plan#
Unit (serial, both precisions, precision-aware tolerances):
- modaltransform_roundtrip: P⁻¹ P u = u to roundoff; Gauss and GL nodes.
- spectralindicator_smooth / spectralindicator_front: manufactured fields
(low-degree polynomial ⇒ no refine flags; tanh front ⇒ flags exactly in
front-crossing elements).
- amrmesh2d_adapt: randomized refine/coarsen cycles preserve mesh validity —
every interior side is either a matched conforming pair or a well-formed
mortarInfo entry; 2:1 balance holds; corner hashing watertight;
RecalculateFlip idempotent.
- meshtransfer_exactness: refine transfer exact for polynomial data of
degree ≤ N; refine-then-coarsen is the identity; coarsen preserves
∫ u dA to roundoff (affine geometry).
Integration:
- amr_advection_2d_gaussian: advected Gaussian tracked by the indicator;
error vs. a uniform-fine reference within tolerance; conservation error at
roundoff.
- amr_lineareuler2d_wave: acoustic pulse; entropy non-increase across
multiple adaptation cycles; refinement follows the wavefront.
- amr_*_mpi variants on 2 ranks, including at least one adaptation that
changes the partition (elements migrate between ranks).
- GPU CI: the same integration tests on the Buildkite MI210/V100 pipelines;
CPU/GPU agreement of indicator flags and post-adapt solutions to tolerance.
The mortar interface itself is already covered by the existing mortar test suite and needs no re-validation here — AMR tests target the tree logic, indicator, transfer, and re-initialization plumbing.
10. Work breakdown & phasing#
| Phase | Content | New/changed code (est.) |
|---|---|---|
| 0. Modal infrastructure | pMatrix in Lagrange_t + device mirrors, indicator math + unit tests |
~600 LOC |
| 1. Quadtree mesh core | forest, 2:1 balancing, leaf + sideInfo/mortarInfo emission, AMRMesh2D_t, transfer operators (tensor products of mortar matrices), validity/transfer tests |
~1,500 LOC |
| 2. Model integration (CPU) | AMRController2D, field re-init + BC/cache rebuild path, amr_advection_diffusion_2d, AMRLinearEuler2D, integration tests, examples |
~1,200 LOC |
| 3. MPI | flag allgather, repartition + Alltoallv redistribution, halo/persistent-request invalidation, 2-rank tests |
~800 LOC |
| 4. GPU | indicator kernels + interfaces, device transfer apply, GPU CI tests | ~800 LOC |
| 5. Hardening/perf | capacity-based device allocation, adaptation-cost profiling, threshold calibration docs, user docs | ~500 LOC |
Rough total ~5–5.5k LOC plus tests — meaningfully smaller than the retired conforming-template design (~6–7k), because the interface treatment, its GPU kernels, its MPI paths, and its tests already exist. Phases 0–2 deliver a usable serial CPU capability; 3 and 4 are independent once 2 lands.
11. Compliance with repository constraints (CLAUDE.md)#
- No change to formulation, discretization order, basis, quadrature, nodal ordering, flux routines, or time integrators — AMR acts between steps; the interface discretization it relies on (mortars) is already merged and validated.
- No renamed/moved modules; all existing public interfaces preserved; new functionality is additive.
- No collectives inside time-stepping loops; halo and mortar exchange patterns unchanged — adaptation rebuilds their tables between steps.
- No external dependencies (forest is home-grown; no p4est).
- EC split-form models excluded (consistent with the existing mortar guard).
- Fortran 2008 only; formatting via
fprettify.
12. Open questions for maintainers#
- Adaptation cadence & placement:
PostStepHookeverynStepsPerAdaptsteps — acceptable, or should adaptation align with IO intervals inForwardStep? dtpolicy on adaptation: keep user-controlled globaldt(examples rescale viahMin()), or auto-rescale inside the controller?- Indicator variables: default to all prognostic (
nstepped) variables with max-combining — confirm, and confirm the per-model override hook. - Curved base meshes (
nGeo > 1) in v1, or restrict v1 tonGeo = 1and defer curved-element child geometry? - Coarsening policy: strict 4-sibling groups only (proposed), or a more aggressive region-based merge?
- Output format for time-varying meshes: embed leaf metadata per snapshot (proposed) vs. separate mesh-series files — coordinate with pyself.
- Sequencing with issue #151 (library-level MPI/device init): not a hard dependency, but landing it first removes the initialization-order constraint the AMR controller would otherwise have to respect.
13. References#
- Y. Maday, C. Mavriplis, A. Patera, "Nonconforming mortar element methods: application to spectral discretizations," Domain Decomposition Methods, 1989.
- D. A. Kopriva, "A conservative staggered-grid Chebyshev multidomain method for compressible flows. II. A semi-structured method," JCP 128 (1996) (the DGSEM 2:1 mortar algorithm underlying the implemented interfaces).
- P.-O. Persson, J. Peraire, "Sub-Cell Shock Capturing for Discontinuous Galerkin Methods," AIAA 2006-112 (modal tail-energy sensor).
- C. Mavriplis, "Adaptive mesh strategies for the spectral element method," CMAME 116 (1994) (spectral decay-rate estimator).
- D. A. Kopriva, Implementing Spectral Methods for Partial Differential
Equations (algorithms referenced throughout
SELF_Lagrange_t.f90/SELF_Quadrature.f90).