Skip to content

Commit e26ed09

Browse files
authored
Merge pull request #119 from underworldcode/feature/region-ds-cell-labels
Submesh infrastructure, region labels, and projected normals
2 parents 0dcd61f + 7dc50d4 commit e26ed09

29 files changed

Lines changed: 3760 additions & 109 deletions
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
# Submesh Solver Architecture: Multi-Domain Equation Systems
2+
3+
## Context
4+
5+
Underworld3 needs to support solving different equations on different subsets of a mesh while maintaining a unified field representation. Use cases include:
6+
7+
- **Air/rock**: Stokes on rock only, full mesh for temperature/gravity
8+
- **Surface evolution**: deforming air mesh coupled to rock Stokes
9+
- **Gravity**: Poisson on full domain, density source from rock only
10+
- **Multi-physics**: different equations on different subdomains (Stokes, Darcy, etc.)
11+
12+
### What we've established (2026-04-05)
13+
14+
1. **`DMPlexFilter`** extracts a submesh with exact shared nodes. The submesh carries a subpoint IS mapping back to the parent via `getSubpointIS()`.
15+
16+
2. **PETSc Region DS** (`DMSetRegionDS`) segfaults during assembly — no examples exist in PETSc, likely incomplete infrastructure. Dead end for now.
17+
18+
3. **Solver `part` parameter** in PETSc boundary assembly (`support[key.part]`) — controls which cell's closure is used for internal boundary integrals. Useful for one-sided boundary assembly but doesn't address the core problem of restricting volume assembly to a subdomain.
19+
20+
4. **Low-viscosity air layer** with discontinuous pressure works reasonably but the air's incompressibility constraint acts as an unintended physical boundary condition. Not equivalent to solving on rock alone.
21+
22+
5. **Normalised `Gamma_N`** (merged) — `mesh.Gamma_N` now returns a unit normal. Penalty and Nitsche BCs are mesh-independent.
23+
24+
## Design Principles
25+
26+
### 1. Separate meshes, separate variables, explicit copies
27+
28+
Each mesh has its own MeshVariables. The user decides when data moves between meshes. There are no hidden globals or auto-managed shared fields.
29+
30+
```python
31+
# Each mesh owns its own variables
32+
v_rock = MeshVariable("v", rock_mesh, ...)
33+
v_full = MeshVariable("v", full_mesh, ...)
34+
35+
# Solver works on submesh variables directly
36+
stokes = Stokes(rock_mesh, velocityField=v_rock, ...)
37+
stokes.solve()
38+
39+
# Explicit copy to full mesh when needed (e.g., for visualisation or coupling)
40+
rock_mesh.prolongate(v_rock, v_full)
41+
```
42+
43+
### 2. Meshes know their lineage
44+
45+
Every mesh has a `parent` attribute and a `subpoint_is` mapping. Top-level meshes have `parent=None` and `subpoint_is=None`. Submeshes reference their parent and carry the IS.
46+
47+
```python
48+
full_mesh.parent # None
49+
full_mesh.subpoint_is # None
50+
51+
rock_mesh = full_mesh.extract_region("Inner")
52+
rock_mesh.parent # full_mesh
53+
rock_mesh.subpoint_is # IS mapping submesh points -> parent points
54+
```
55+
56+
### 3. Restrict/prolongate as mesh operations
57+
58+
```python
59+
mesh.restrict(var) # parent -> submesh DOFs (no-op if parent is None)
60+
mesh.prolongate(var) # submesh DOFs -> parent (no-op if parent is None)
61+
```
62+
63+
Solvers call these uniformly. On a top-level mesh they're no-ops. On a submesh they gather/scatter via the subpoint IS. The solver code doesn't branch.
64+
65+
### 4. One mesh per expression
66+
67+
An expression passed to a solver must only contain MeshVariable symbols from that solver's mesh. The JIT compiler evaluates all symbols against one DM's auxiliary vector and one coordinate system — mixing meshes is undefined.
68+
69+
The user must restrict cross-mesh data before building expressions:
70+
71+
```python
72+
# T lives on full_mesh, but Stokes is on rock_mesh
73+
rock_mesh.restrict(T_full, T_rock)
74+
75+
# Expression uses only rock_mesh variables — safe
76+
stokes.bodyforce = rho_rock.sym * alpha * T_rock.sym * gravity
77+
```
78+
79+
If meshes are mixed in an expression, detect it (check `var.mesh` for all MeshVariable atoms) and raise an error at solver setup.
80+
81+
### 5. Boundary mapping is automatic
82+
83+
When `extract_region("Inner")` creates a submesh, boundaries are remapped:
84+
- Full mesh "Lower" (r=r_inner) → submesh "Lower"
85+
- Full mesh "Internal" (r=r_internal) → submesh outer boundary
86+
- Full mesh "Upper" (r=r_outer) → not present on submesh
87+
88+
The label names are preserved from the parent (they survive `DMPlexFilter`). The user refers to boundaries by the same names.
89+
90+
## PETSc Infrastructure Available
91+
92+
| API | What it does | Status |
93+
|-----|-------------|--------|
94+
| `DMPlexFilter(dm, label, value, ...)` | Extract cells by label → new DMPlex | **Works**, tested |
95+
| `DMPlex.getSubpointIS()` | IS mapping submesh → parent points | Available in petsc4py |
96+
| `DMSetRegionDS(dm, label, fields, ds, dsIn)` | Per-region discrete system | **Segfaults**, no examples |
97+
| `DMGetCellDS(dm, point, &ds, &dsIn)` | Per-cell DS dispatch in assembly | Works but requires Region DS |
98+
| `DMPlexCreateSubmesh(dm, label, value, ...)` | Co-dimension 1 submesh (boundaries) | Works but wrong dimension |
99+
| `VecScatter` / `PetscSF` | Parallel data transfer | Standard PETSc |
100+
101+
### PETSc Alternatives Investigated (2026-04-05)
102+
103+
**DMComposite** — packs multiple DMs into one composite. Tested 2026-04-05.
104+
105+
- Accepts DMPlex sub-DMs from DMPlexFilter. Scatter/gather works correctly.
106+
- Interface nodes appear in both sub-DMs (102 shared vertices + 102 shared edges confirmed).
107+
- Composite Vec concatenates sub-DM DOFs — interface DOFs are **duplicated**, not shared. Synchronisation after each solve is still required.
108+
- **Verdict**: Designed for **combining** separate problems (fluid + structure), not **subdividing** one mesh. Doesn't simplify our use case — the core challenge (interface DOF ownership, restrict/prolongate) remains the same either way. The direct subpoint IS approach is simpler and more natural.
109+
110+
**PCFIELDSPLIT with spatial IS** — split by region, not field.
111+
112+
- `PCFieldSplitSetIS()` accepts arbitrary IS — confirmed no restriction to field-based splits.
113+
- Supports Schur complement strategies between spatial blocks.
114+
- **Problem**: This is a preconditioner, not an assembly strategy. Both blocks still assemble from the same DS. Doesn't let you have different equations per region.
115+
- **Verdict**: Useful for preconditioning variable-viscosity systems, but doesn't solve the core problem.
116+
117+
**DMCreateDomainDecomposition** — PETSc's native spatial decomposition.
118+
119+
- `DMCreateDomainDecomposition_Plex()` returns inner/outer IS with configurable overlap.
120+
- `DMCreateDomainDecompositionScatters_Plex()` creates VecScatter for restrict/prolongate.
121+
- **Problem**: Designed for PCASM/PCGASM where the *same* equations are solved on each subdomain. Not for different physics per region.
122+
- **Verdict**: Scatter infrastructure is useful but intent doesn't match multi-physics.
123+
124+
### Assessment
125+
126+
None of the PETSc mechanisms directly solve "different equations on different subsets of the same mesh with shared fields." They each address adjacent problems:
127+
128+
| Mechanism | Different equations? | Shared fields? | Fits? |
129+
|-----------|---------------------|----------------|-------|
130+
| DMComposite | Yes | No (different vector layout) | Partial |
131+
| PCFIELDSPLIT | No (same assembly) | Yes | No |
132+
| DomainDecomp | No (same equations) | Yes | No |
133+
| Region DS | Yes (in theory) | Yes | Segfaults |
134+
135+
The **DMPlexFilter + subpoint IS + UW3-level restrict/prolongate** approach remains the best fit. PETSc provides the building blocks (mesh filtering, IS mapping, parallel SF), UW3 handles the multi-physics orchestration.
136+
137+
## Open Questions
138+
139+
1. **DM lifecycle**: The solver currently clones DMs freely (`clone_dm_hierarchy`). If the submesh also clones, DMs proliferate with no clear ownership. Need a cleanup strategy.
140+
141+
2. **Mesh adaptation**: If the full mesh adapts (refinement, coarsening, surface deformation), the submesh must be re-extracted and the IS rebuilt. All in-flight MeshVariables need re-projection. How does this interact with the existing `refinement_callback` infrastructure?
142+
143+
3. **Parallel decomposition**: `DMPlexFilter` builds a new SF for the submesh. If the partition differs from the parent, restrict/prolongate need MPI communication. How expensive is this? Does it matter for the target use cases?
144+
145+
4. **Coupled solves**: If two solvers on different submeshes need to iterate (e.g., rock Stokes + air transport), the restrict/prolongate happens every outer iteration. Is the data copy overhead acceptable, or do we need shared vectors?
146+
147+
5. **Pressure space**: Discontinuous pressure (dP1) is required for viscosity contrasts at internal boundaries. Should this be the default for submesh solvers, or should the user choose?
148+
149+
## Implementation Plan
150+
151+
### Immediate: `Mesh.extract_region()`
152+
153+
The minimum viable feature. Everything else follows from existing UW3 patterns.
154+
155+
```python
156+
rock_mesh = full_mesh.extract_region("Inner")
157+
```
158+
159+
Wraps `DMPlexFilter`, returns a new `Mesh` with:
160+
- `parent` reference to the full mesh
161+
- `subpoint_is` from `getSubpointIS()` (stored for future optimisation)
162+
- Boundaries inherited from parent labels (they survive DMPlexFilter)
163+
- Coordinate system inherited from parent
164+
165+
The extracted mesh is fully independent — users create their own MeshVariables on it, set up solvers normally, and transfer data between parent and submesh via restrict/prolongate:
166+
167+
```python
168+
# Separate variables on separate meshes
169+
v_rock = MeshVariable("v", rock_mesh, ...)
170+
rho_rock = MeshVariable("rho", rock_mesh, ...)
171+
rho_full = MeshVariable("rho", full_mesh, ...)
172+
173+
# Transfer density from full mesh to rock submesh
174+
rock_mesh.restrict(rho_full, rho_rock)
175+
176+
# Stokes on rock submesh — standard solver, nothing special
177+
stokes = Stokes(rock_mesh, velocityField=v_rock, ...)
178+
stokes.add_natural_bc(penalty * Gamma_N.dot(v_rock.sym) * Gamma_N, "Internal")
179+
stokes.solve()
180+
181+
# Transfer rock velocity back to full mesh
182+
rock_mesh.prolongate(v_rock, v_full)
183+
184+
# Gravity on full mesh using transferred data
185+
gravity = Poisson(full_mesh, ...)
186+
gravity.solve()
187+
```
188+
189+
The restrict/prolongate use the subpoint IS from `DMPlexFilter` — a direct index mapping with exact point correspondence. No kd-tree search, no interpolation, no error. This is the preferred transfer mechanism between parent and submesh.
190+
191+
For transfer between unrelated meshes (no parent relationship), the existing `uw.function.evaluate(expr, coords)` path still works.
192+
193+
### Restrict / Prolongate
194+
195+
```python
196+
rock_mesh.restrict(parent_var, sub_var) # gather parent DOFs at subpoint IS
197+
rock_mesh.prolongate(sub_var, parent_var) # scatter submesh DOFs back to parent
198+
```
199+
200+
- No-op when `parent is None` (top-level mesh)
201+
- The subpoint IS maps submesh points → parent points
202+
- Translation from point IS to DOF IS uses the PETSc section (offset lookup per point)
203+
- Exact — same nodes, no interpolation
204+
205+
### Why not auto-managed globals?
206+
207+
We considered having MeshVariables live on the parent mesh with solvers auto-restricting/prolongating. This hides data flow, makes the solver more complex, and the user loses track of where data lives. The explicit approach is clearer: each mesh owns its variables, copies are visible.
208+
209+
### Mesh deformation and adaptation
210+
211+
Changes to the parent mesh must propagate to submeshes. Two cases:
212+
213+
**Coordinate deformation** (ALE, surface evolution): Parent node positions change but topology is unchanged. The subpoint IS remains valid — restrict the parent's coordinate Vec to update submesh node positions. The submesh DM's internal geometry (Jacobians, normals, quadrature) must then be rebuilt.
214+
215+
```python
216+
# After deforming parent mesh coordinates
217+
rock_mesh.sync_coordinates() # restrict parent coords via subpoint IS, rebuild geometry
218+
```
219+
220+
This should be automatic: if the submesh detects that its parent's coordinates have changed (version counter on the parent mesh, which we already have via `_mesh_version`), it updates on next access.
221+
222+
**Topology change** (adaptation, remeshing): The parent mesh gains/loses cells and vertices. The subpoint IS is invalidated — the submesh must be re-extracted from scratch. All submesh MeshVariables need re-projection onto the new submesh (interpolation from old to new via the usual adaptation path).
223+
224+
```python
225+
# After parent mesh adapts
226+
rock_mesh = full_mesh.extract_region("Inner") # fresh extraction
227+
# Old submesh variables are orphaned — user must re-create and re-project
228+
```
229+
230+
This is the expensive case. The parent mesh already has `refinement_callback` infrastructure for post-adaptation fixups. The submesh re-extraction could hook into this: the parent notifies registered submeshes that topology has changed, and they invalidate themselves.
231+
232+
The parent `Mesh` should track its submeshes (weak references, like the existing `_registered_swarms` pattern) so it can notify them of coordinate or topology changes.
233+
234+
### Other items
235+
236+
- **Boundary remapping**: Document which parent labels map to submesh boundaries. DMPlexFilter preserves labels; "Internal" on the parent becomes an exterior boundary on the submesh.
237+
- **DM lifecycle**: Audit clone/destroy patterns, ensure submesh DMs are cleaned up.
238+
- **Parallel**: `DMPlexFilter` builds a new SF. Test in MPI before relying on it.
239+
240+
## Additional Findings
241+
242+
### Discontinuous pressure required for viscosity contrasts
243+
244+
Continuous P1 pressure cannot represent the pressure jump at a viscosity discontinuity (scales with viscosity ratio). With eta_rock/eta_air = 1000, the pressure smears across interface elements and corrupts velocity direction up to 177 degrees. Discontinuous P1 handles each side independently — velocity direction error drops to <5 degrees.
245+
246+
### Normalised boundary normal (Gamma_N)
247+
248+
`mesh.Gamma_N` now returns `Gamma / |Gamma|` — a unit normal regardless of element size. The raw `mesh.Gamma` magnitude scales with edge length (2D) / face area (3D). This affects penalty scaling: `penalty * Gamma.dot(v) * Gamma` has effective penalty ~ penalty * h², while `penalty * Gamma_N.dot(v) * Gamma_N` is mesh-independent. Nitsche's `gamma * mu / h` term now has correct 1/h scaling with normalised normals.
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""
2+
Bootstrap through decreasing air viscosity contrasts.
3+
4+
Start from eta_air=1e-3 checkpoint, solve at 1e-4, use that to
5+
initialise 1e-5, and so on down to 1e-6. Each step uses the
6+
previous solution as initial guess (zero_init_guess=False).
7+
8+
All use dP1 pressure, normalised Gamma_N, penalty=1e4.
9+
"""
10+
11+
import underworld3 as uw
12+
from underworld3.systems import Stokes
13+
import numpy as np
14+
import sympy
15+
import os
16+
17+
r_internal = 1.0; r_inner = 0.5; r_outer_full = 1.5; cellsize = 1/16
18+
n = 2; k = 1; vel_penalty = 1e4; stokes_tol = 1e-4
19+
20+
# --- Create mesh ---
21+
print("Creating mesh...", flush=True)
22+
mesh = uw.meshing.AnnulusInternalBoundary(
23+
radiusOuter=r_outer_full, radiusInternal=r_internal,
24+
radiusInner=r_inner, cellSize=cellsize)
25+
26+
v = uw.discretisation.MeshVariable("V", mesh, mesh.dim, degree=2)
27+
p = uw.discretisation.MeshVariable("P", mesh, 1, degree=1, continuous=False)
28+
eta_var = uw.discretisation.MeshVariable("eta", mesh, 1, degree=1, continuous=False)
29+
bf_mask = uw.discretisation.MeshVariable("mask", mesh, 1, degree=1, continuous=False)
30+
31+
r_at = np.sqrt(eta_var.coords[:, 0]**2 + eta_var.coords[:, 1]**2)
32+
is_rock = r_at < r_internal
33+
bf_mask.data[is_rock, 0] = 1.0
34+
bf_mask.data[~is_rock, 0] = 0.0
35+
36+
r_f, th_f = mesh.CoordinateSystem.xR
37+
unit_r_f = mesh.CoordinateSystem.unit_e_0
38+
G_N = mesh.Gamma_N
39+
v_theta = r_f * mesh.CoordinateSystem.rRotN.T * sympy.Matrix((0, 1))
40+
41+
# --- Load eta=1e-3 checkpoint as starting point ---
42+
print("Loading eta=1e-3 checkpoint...", flush=True)
43+
v.read_timestep("nitsche", "V", 0, outputPath="output/normalised_nitsche/")
44+
p.read_timestep("nitsche", "P", 0, outputPath="output/normalised_nitsche/")
45+
46+
# Set initial viscosity
47+
eta_var.data[is_rock, 0] = 1.0
48+
eta_var.data[~is_rock, 0] = 1e-3
49+
50+
# --- Bootstrap through decreasing viscosity ---
51+
eta_steps = [1e-4, 1e-5, 1e-6]
52+
53+
for eta_air in eta_steps:
54+
print(f"\n{'='*60}", flush=True)
55+
print(f"Solving with eta_air = {eta_air:.0e}", flush=True)
56+
print(f"{'='*60}", flush=True)
57+
58+
# Update viscosity
59+
eta_var.data[~is_rock, 0] = eta_air
60+
61+
# Create fresh solver (needed because constitutive model refs change)
62+
stokes = Stokes(mesh, velocityField=v, pressureField=p)
63+
stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel
64+
stokes.constitutive_model.Parameters.shear_viscosity_0 = eta_var.sym[0, 0]
65+
stokes.saddle_preconditioner = 1.0 / eta_var.sym[0, 0]
66+
67+
rho_f = ((r_f / r_internal) ** k) * sympy.cos(n * th_f)
68+
stokes.bodyforce = bf_mask.sym[0, 0] * rho_f * (-1.0 * unit_r_f)
69+
70+
stokes.add_natural_bc(vel_penalty * G_N.dot(v.sym) * G_N, "Upper")
71+
stokes.add_natural_bc(vel_penalty * G_N.dot(v.sym) * G_N, "Lower")
72+
stokes.add_natural_bc(vel_penalty * v.sym.dot(unit_r_f) * unit_r_f, "Internal")
73+
74+
stokes.tolerance = stokes_tol
75+
stokes.petsc_options["snes_type"] = "newtonls"
76+
stokes.petsc_options["ksp_type"] = "fgmres"
77+
stokes.petsc_options["ksp_monitor"] = None
78+
stokes.petsc_options.setValue("fieldsplit_velocity_pc_mg_type", "kaskade")
79+
stokes.petsc_options.setValue("fieldsplit_velocity_pc_mg_cycle_type", "w")
80+
stokes.petsc_options["fieldsplit_velocity_mg_coarse_pc_type"] = "svd"
81+
stokes.petsc_options["fieldsplit_velocity_ksp_type"] = "fcg"
82+
stokes.petsc_options["fieldsplit_velocity_mg_levels_ksp_type"] = "chebyshev"
83+
stokes.petsc_options["fieldsplit_velocity_mg_levels_ksp_max_it"] = 5
84+
stokes.petsc_options["fieldsplit_velocity_mg_levels_ksp_converged_maxits"] = None
85+
86+
# Use previous solution as initial guess
87+
stokes.solve(zero_init_guess=False, verbose=True)
88+
89+
# Null space removal
90+
I0 = uw.maths.Integral(mesh, v_theta.dot(v.sym))
91+
ns = I0.evaluate()
92+
I0.fn = v_theta.dot(v_theta)
93+
nn = I0.evaluate()
94+
dv = uw.function.evaluate(ns * v_theta, v.coords).reshape(-1, 2) / nn
95+
v.data[...] -= dv
96+
97+
# Norms
98+
rock_mask = sympy.Piecewise((1.0, r_f < r_internal), (0.0, True))
99+
v_l2 = np.sqrt(uw.maths.Integral(mesh, rock_mask * v.sym.dot(v.sym)).evaluate())
100+
print(f" Inner velocity L2: {v_l2:.6e}", flush=True)
101+
102+
# Checkpoint
103+
eta_str = f"{eta_air:.0e}".replace("-", "m")
104+
out_dir = f"./output/bootstrap_eta{eta_str}/"
105+
if uw.mpi.rank == 0:
106+
os.makedirs(out_dir, exist_ok=True)
107+
mesh.write_timestep(f"eta{eta_str}", meshVars=[v, p, eta_var], outputPath=out_dir, index=0)
108+
print(f" Checkpoint: {out_dir}", flush=True)
109+
110+
print("\nDone.", flush=True)

0 commit comments

Comments
 (0)