|
| 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. |
0 commit comments