diff --git a/CLAUDE.md b/CLAUDE.md index 30cd06d59..95432ad1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,24 @@ fenced block for display, LaTeX not unicode). Private cross-project context, when a session has it, still arrives through `UW_AI_TOOLS_PATH` (colon-separated directories of `.md` files; set via `./uw setup`). +**Pending work, before anything else.** Repeated work has come from investigating +without looking at what is already in flight. Before an investigation, a new branch +or a design decision, and again when a related PR merges mid-session: + +```bash +git fetch origin development +git log --oneline --merges -20 origin/development # what merged recently +``` + +plus the open PRs (GitHub MCP `list_pull_requests`, or `gh pr list --state open`). Read +every open or recently merged PR that touches the subsystem before writing a line, name +them in the opening report, and base the work on current development (branch from it, +or merge it into the base branch first). A related PR open for more than about a week +is itself a finding: say so at the start and ask whether to merge it, build on it, or +wait. Do not rely on the maintainer noticing the overlap. (Case: the fault multigrid +session of 2026-10-13 re-diagnosed parallel fault defects that PR #672, open for +weeks, had already fixed, then paid a seven-file merge.) + **Inline TODOs** mark the *place*; the roadmap item or issue tracks the *work*: ```python diff --git a/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md b/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md new file mode 100644 index 000000000..dd36ac4c6 --- /dev/null +++ b/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md @@ -0,0 +1,185 @@ +# Multigrid hierarchies for split-node faults + +Technical note, 2026-10-13. The exploration that chose the hierarchy; the library +implementation (C, below) is governed by +[subsystems/fault-split-multigrid.md](../subsystems/fault-split-multigrid.md). +The exploration drivers are kept with the campaign, outside this repository +(`stage*.py` monkey-patch the solver; `library_c*.py` and `thick_parallel.py` go +through the library). + +## The question + +A split-node fault lives on the finest mesh. The geometric-multigrid levels below it +come from the add-on hierarchy: the static background mesh, which is never cut, and +the add-on levels refined from it toward the fault. How the fault is represented on +those coarser levels decides whether the velocity solve stays mesh-independent as the +fault is refined. + +## Alternatives + +| | finest | add-on levels below it | background levels | +|---|---|---|---| +| **A** | split | welded, Galerkin | welded, Galerkin | +| **B2** | split | welded, TI weak band, rediscretised | Galerkin from the TI levels | +| **C** | split | split, Galerkin | welded, Galerkin | +| **D** | split | split, Galerkin | broad TI weak zone, rediscretised | + +A is the current add-on behaviour (`mesh.adapt` then `add_fault`). + +TI band: a Gaussian in the distance to the trace, half-width equal to the level's +near-fault cell size, director along the fault normal, $\eta_1 = \eta^{1-f}\eta_w^{f}$ +with $\eta_w = 0.1\,\eta$ for a frictionless fault and $\eta_w = \eta_f w$ (the +compliance of the interface) for a viscous one. + +Split levels (C, D) need two things the current hierarchy does not provide. + +**A side-aware transfer.** For each fine node, one fine cell containing it is taken, and +that cell's centroid is located in the coarse mesh. A centroid is never on the fault, so +the coarse cell is on the node's own side. The coarse cell's Lagrange interpolant is +then evaluated at the node (dual-basis identity $W = BM^{-1}$, as in +`custom_mg.nested_refine_pair_prolongation`), so a node on the slit interpolates from +its own side. The node-cloud builders cannot do this: they triangulate the coarse +nodes, and coincident slit nodes make the transfer degenerate. The levels are not +nested near the trace, because each cut pulls vertices onto it, so a fine node can lie +slightly outside its located coarse cell. The evaluation point is therefore clamped +into the cell (its barycentric coordinates clipped and renormalised). Without the +clamp, extrapolated weights reached 5.4 near a tip, two coarse unknowns were reached +only through them, and the Galerkin operator had a near-zero diagonal that made SOR +return NaN. With it, every weight is at most 1. + +**A per-level pair frame.** Each split level carries its own pair rotation (mean and +jump), and its opening (normal-jump) unknowns are removed: +$\hat P_l = Q_{l+1} P_l Q_l^{T}$, restricted to admissible unknowns. Coarse unknowns +that no fine unknown reaches at all (a few near the tips) are dropped too; they are null +modes of the Galerkin coarse operator. + +## Test problem + +- Box $[0,1]^2$ with simple shear on the walls; incompressible Stokes, $\eta = 1$. +- A straight fault, $(0.30, 0.45)$ to $(0.70, 0.55)$, frictionless or viscous ($\eta_f = 1$). +- Background: `UnstructuredSimplexBox(cellSize=0.1, refinement=1)` (levels $h = 0.1, 0.05$). +- Add-on level $j$: `base.adapt` to near-fault $h = 0.05/2^j$, built independently from the + background for each $j$, so the levels are conforming but not nested; split by + `add_fault` where the alternative asks. +- $k$ add-on levels in total, the finest at $h = 0.05/2^k$. +- Rotated fieldsplit solve: velocity block custom FMG, pressure Schur 1/μ mass. +- The measure is velocity-block iterations per solve. + +## Results + +Frictionless fault: + +| $k$ | near-fault $h$ | A | B2 | C | D | +|---|---|---|---|---|---| +| 1 | 0.025 | 9 | 10 | 10 | 14 | +| 2 | 0.0125 | 13 | 14 | 7 | 9 | +| 3 | 0.00625 | 18 | 22 | 7 | 6 | +| 4 | 0.0031 | 23 | 30 | 6 | 6 | +| 5 | 0.0016 | 32 | 46 | 5 | 5 | + +Viscous fault: + +| $k$ | A | B2 | C | D | +|---|---|---|---|---| +| 1 | 9 | 9 | 9 | 17 | +| 2 | 12 | 19 | 7 | 9 | +| 3 | 17 | 53 | 6 | 8 | +| 4 | 22 | 200 (cap) | 6 | 7 | +| 5 | 30 | 200 (cap; stopped at 1 h) | 5 | 6 | + +The peak slip agrees to five figures across all alternatives at each $k$, so only the +solver path changes. + +Coulomb fault ($\mu = 0.6$, $\sigma_n = 0.8$; four Newton steps in every case), +velocity iterations per Newton step: + +| $k$ | A | C | +|---|---|---| +| 1 | 5, 15, 17, 17 | 5, 15, 17, 17 | +| 3 | 9, 21, 24, 24 | 4, 14, 15, 16 | +| 5 | 15, 38, 45, 50 | 3, 13, 15, 15 | + +Supporting measurements: + +- **A single TI level.** One TI level directly below the split finest level, with + Galerkin below it, is worse than A at every band strength. At $k = 4$ and a band + half-width of half a cell, A takes 27; the band at 0.1, 0.03 and 0.01 takes 29, 46 + and 73. With a half-width of a full cell the band stalls. +- **Transfer alone.** C's levels left welded but with the side-aware transfer behave + like A (35 iterations at $k = 5$). The gain comes from splitting the levels. +- **3-D extruded layer (preliminary).** Only the through-thickness levels split, the + lateral levels welded: 11 to 16 iterations as $h$ halves, against 14 to 25 for A. + +## A moving fault + +The same box and fault, translated in 8 steps from $x = 0.3$ to $x = 0.7$. The +background is built once; each step re-adapts the add-on part from it with one +`mesh.adapt` call, splits the finest level and, for C and D, splits each intermediate +level of that same call. The table gives the mean seconds per step. The runs shared a +busy machine, so absolute times are good to a factor of 1.5 to 2; the ratios between +strategies and the iteration counts are not affected. + +$k = 5$ (seven levels, near-fault $h = 0.0016$): + +| | adapt | split finest | split levels | level frames | background TI | solve | total | velocity its | +|---|---|---|---|---|---|---|---|---| +| A | 3.1 | 1.9 | – | – | – | 137 | 143 | 30–34 | +| C | 2.7 | 1.7 | 2.8 | 1.0 | – | 38 | 46 | 5–6 | +| D | 4.7 | 2.9 | 5.1 | 1.8 | 2.4 | 61 | 79 | 4–5 | +| GAMG | 3.8 | 2.4 | – | – | – | 127 | 134 | 200 (cap) | + +At $k = 3$ the totals are 27 (A), 15 (C), 17 (D) and 28 (GAMG) seconds per step. The +solve includes building the transfers: 2 s for A, 3.4 s for C. With the clamped +transfer, C takes 5 to 6 velocity iterations at every position at $k = 3$ and 4 to 8 at +$k = 5$. + +The peak slip is identical across A, C and D at every position. Level frames come from +throwaway solvers in the drivers (`build_rotation`); they could be computed from the +pair geometry directly. + +## Parallel + +A, the existing path, had three defects at np > 1: + +1. **Fixed** (`test_1086`). The rotated solve zeroed the opening rows of its fine + transfer by local position instead of global row. +2. **Open, now harmless.** `mesh.adapt` keeps a different intermediate level for the + multigrid tail in parallel than in serial (1358 against 1520 cells; the finest mesh + is the same). The levels need not match: the transfer locates nodes. +3. **Fixed** (`test_1089`). `add_fault` gathers the fault's cell star onto one rank, so + the split finest level is not co-partitioned with its tail. The node-cloud transfer + left 618 coarse unknowns with no fine image, their nearest-node injection + duplicated 512 columns, the Galerkin coarse operators became singular, and PETSc's + inode SOR returned NaN. Every pair touching a split level now takes the + cell-located transfer, which asks the other ranks for the nodes it cannot locate + locally, and unreached unknowns are dropped rather than repaired: A converges at + np = 2 (15 iterations against 13 serial), C in 6 at np = 2 and 7 at np = 4 (k = 2). + +The pairing rides every redistribution as a pair of DM labels. The migration star +forest PETSc returns for an initial distribution does not index the stratified point +numbering (three quarters of the points mapped to the wrong depth), so it is not used. + +## What works best + +**C: split every add-on level and leave the background welded.** The iteration count +falls slightly with refinement, for frictionless, viscous and Coulomb faults alike. +It needs nothing rediscretised and no TI parameters. With a moving fault its extra +surgery and transfers cost about 5 s per step against a solve that is three times +cheaper than A's at $k = 5$. + +D matches C at depth but is worse on shallow hierarchies, and it adds a rediscretised +background operator to re-assemble at every step and band parameters to choose for +every problem, for no gain. B2 is worse than A, and badly so for the +viscous fault. A smeared band on a coarse level is a continuous shear that the split +fine operator rejects, so the coarse space has to contain the discontinuity itself. + +## State (library, 2026-10-13) + +In the library: the add-on levels of one `adapt` call, each split by `add_fault`; the +cell-located transfer (serial and parallel) and the per-level frames in +`utilities/fault_mg.py`, installed through `custom_mg` and the rotated solve; the 3-D +layer with every level split, extruded in parallel; 3-D sheet faults on add-on meshes. +Not yet: fault networks through the levels (a network is replayed on every level, but +a one-cell junction ligament closes at the coarser spacing and the replay then stops +there, leaving that level and those beneath welded), faults near a boundary, +co-locating the levels with the finest as an optimisation. diff --git a/docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md b/docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md new file mode 100644 index 000000000..a9e0d7745 --- /dev/null +++ b/docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md @@ -0,0 +1,140 @@ +# Thin viscoelastic sheet with faults — flat and on the sphere + +**Status**: In progress (started 2026-10-08). Branch `feature/thin-sheet`. + +## Goal + +A vertically averaged (thin-sheet) model of the lithosphere carrying faults, on a +flat 2-D mesh and on the surface of a sphere, with a Maxwell viscoelastic +rheology and a viscous substrate. Headline result: the loading, slip and +postseismic relaxation of a strike-slip fault in a viscoelastic sheet, flat first +and then on the sphere. + +## The sheet + +With thickness $L$, tangential velocity $\mathbf u$ and the plane-stress closure +$\sigma_{nn} = 0$ on the dynamic stress, the pressure of an incompressible +material is eliminated: $p = \tau_{nn} = -\operatorname{tr}_s\boldsymbol\tau$, so + +$$ +\boldsymbol\sigma = \boldsymbol\tau + \operatorname{tr}(\boldsymbol\tau)\,\mathbf P, +\qquad \mathbf P = \mathbf I - \hat{\mathbf n}\hat{\mathbf n}^T, +\qquad +-\nabla_s\cdot(L\boldsymbol\sigma) + c\,\mathbf u = \mathbf f . +$$ + +$\boldsymbol\tau$ is any deviatoric constitutive law of the in-plane strain rate +$\dot{\boldsymbol\varepsilon}_s = \mathbf P\,\mathrm{sym}(\nabla_s\mathbf u)\,\mathbf P$ +— Newtonian, Maxwell (the stress history is the in-plane deviatoric stress; +$\tau_{nn}$ follows from the trace), or yielding. $c$ is a basal drag +($\eta_a/h_a$ for a viscous substrate); $\mathbf f$ a force per unit area. + +Consequences used as hard baselines: + +- uniaxial stretch with free lateral edges: $\sigma_{xx} = 3\eta\dot\varepsilon$ (Trouton); +- rigid motion: $\boldsymbol\sigma = 0$ exactly; +- Maxwell uniaxial loading: $\sigma = 3\eta\dot\varepsilon\,(1 - e^{-t\mu/\eta})$; +- a fault in a thin sheet: Barr & Houseman (1996) Appendix (A10)–(A13) — the + plane-strain (A1)–(A9) solution is already in `underworld3.analytic`; the + thin-sheet one is to be added beside it; +- stress diffusion with basal drag after imposed slip (Elsasser; Lehner, Li & + Rice 1981). + +Not in the first round (recorded so they are not forgotten): thickness +evolution $\dot L = -L\,\nabla_s\cdot\mathbf u$ with the gravitational potential +energy gradient (England & McKenzie's Argand number); yield in the sheet, whose +invariant must include $\tau_{nn} = -\operatorname{tr}\boldsymbol\tau$ (the +plane-strain code omits it). + +## Implementation + +`uw.systems.ThinSheet` (`systems/thin_sheet.py`) is an `SNES_Vector`: there is +no pressure unknown, so no saddle point. Its inner `_Unknowns` hands the +constitutive model the projected strain rate through `Unknowns.E` (the VEP model +already reads it; `ViscousFlowModel.grad_u` now does too — identical on a volume +mesh). Templates: `F1 = L (τ + tr τ P)`, `F0 = -f + c u`. + +### On the sphere + +The velocity is carried in the embedding space (3 components on a +`SphericalManifold`, `dim=2`, `cdim=3`). PETSc already supplies the surface +gradient along the flat facets in the derivative index; the outer projection in +$\dot{\boldsymbol\varepsilon}_s$ supplies the curvature term +$-\mathbf u\cdot\nabla_s\hat{\mathbf n}$. Two normals are involved and they +are deliberately different: + +- the **projector** uses the facet's own normal (a cell-wise constant field + built once per mesh). The gradient is taken along the flat facet, so a rigid + rotation — a Killing field, strain-free on the sphere — is strain-free in the + discrete form only with the facet normal; with the analytic normal it keeps a + spurious strain of order $h$ (measured 0.7% of the velocity at $h = 0.25$); +- the **tangency constraint** $\mathbf u\cdot\hat{\mathbf n}=0$ uses the + analytic normal of the surface, imposed strongly at every node through the + per-node rotation of `utilities/rotated_bc.py` (`SHEET_TANGENCY`, the same + machinery as rotated free-slip and the split-node fault constraint, so faults + compose with it). The nodes lie on the sphere, so a rigid rotation is exactly + admissible and is reproduced to solver tolerance. + +A penalty was used only to probe feasibility and is not an API. The toroidal +manufactured solution converges at second order (the flat-facet geometry limit +for P2) with tangency at machine precision. + +Vector solves on a manifold needed four `dim → cdim` corrections (the manifold +vector-FE gap): the `SNES_Vector` FE component count, its PC block size and +default unknown, `maths/vector_calculus` (component count), and the +`sympy.vector` truncation in `_jitextension`. + +### Faults on the sphere (Phase 5) + +The split-node pipeline is topological and survives; what has to generalise is +geometry: great-circle crossings in `line_cut`, cell orientation by the outward +normal in `reconnect`, the contact frame $\{\hat\nu = \hat r\times\hat t,\ \hat t,\ +\hat r\}$ in `fault_contact` (PETSc's FVM edge normal is wrong for `cdim=3`), and +the `mesh.dim` strides in `rotated_bc`, whose rigid-rotation gauge must offer the +three Killing fields. `mesh.adapt` (NVB) runs on the manifold already; refined +vertices need snapping back to the sphere. + +## Phases + +0. Feasibility probes (sheet operator on S² with penalty and with the strong + rotation; NVB adapt on S²; splitting an arc) — scratch, not committed. +1. Flat viscous `ThinSheet` + tests (`tests/test_1075_thin_sheet.py`). +2. Viscoelastic sheet (Maxwell; small-strain non-advected history for fault + problems; Elsasser check). +3. Faults in the flat sheet (rotated/fault BCs hoisted off the saddle class; a + vector-only branch of the rotated solve; BH96 thin-sheet solution). +4. Sheet on the sphere with strong tangency and the Killing null space. +5. Faults on the sphere. +6. Results and examples. + +## One model, thin or thick + +Principle (maintainer, 2026-10-10): the same physics is the same model script. A model +must move between the thin sheet (`ThinSheet` on a surface mesh) and a thick sheet +(`Stokes` on a thin 3-D mesh) by changing the mesh and the solver class, nothing else. +The solver owns the kinematics, the closure and the geometry; the model owns the +rheology. What is already shared: the constitutive models, the fault interface API, the +boundary-condition methods, the stress history and `solve(timestep=)`. + +State of the alignment (2026-10-12): + +| | thin sheet | thick sheet | aligned form | state | +|---|---|---|---|---| +| construction | `ThinSheet(mesh, v, p, degree, p_continuous)` | `Stokes(mesh, v, p, ...)` | same signature; the sheet's `p` is the diagnostic $-\operatorname{tr}\tau$, refreshed after each solve | done | +| mesh | the sheet mesh | `mesh.extrude(L, layers)` | the thick twin keeps the sheet's boundaries as walls (same names) and adds `Base` / `Surface` | done (serial); MG tail coarsens through the thickness first | +| forcing | body force per unit volume, times $L$ | per unit volume | `bodyforce` means the same on both | done | +| basal coupling | `add_basal_drag(c, "Base", reference_velocity=)`, `add_basal_traction(t, "Base")` | the same, as natural conditions on `Base` | one line serves both; the sheet ignores the boundary name | done | +| fault geometry | `mesh.add_fault` | extruded with the mesh: a vertical fault through the layer, split at surface and base, tips shared | `add_fault_bc` on either | done flat (test_1083: velocity 0.5 %, slip 1.6 %); sphere: first order in L, 1.35 % at L = 0.02; the multigrid tail uses fault-free coarse levels | +| stress | `solver.stress` (in-plane, tension positive) | `solver.stress` (3-D) | `uw.maths.tensor.resolved_traction` / `coulomb_failure_stress` on `solver.stress` | done | +| nonlinear invariants | `Unknowns.second_invariant` adds $M_{nn} = -\operatorname{tr}M$ | plain contraction | every model invariant goes through the solver | done | +| Newton tangent | `_flux_closure` | `_flux_closure` | the model's `flux_jacobian` is composed with the solver's closure (it used to replace the whole flux, and was ignored on vector solvers) | done | +| topography | implied isostatic rate | free surface | with thickness transport (round 2) | open | + +Mesh-pairing notes. A thin 3-D fault cannot come from the 3-D splitter: it keeps +the patch rim welded (the tip rule one dimension up), so a fault through a layer +would be locked at the surface and the base, and it needs about nine cells of +depth. Extruding the already-split sheet avoids both: the slit is duplicated at +every level, so the fault is open where it meets the faces, and the two sides are +cut into tetrahedra identically (the minus copy of a vertex takes its plus copy's +place in the vertex order), so the pairing is rebuilt by joining mapped vertex +tuples. diff --git a/docs/developer/guides/adapt-on-top-faults.md b/docs/developer/guides/adapt-on-top-faults.md index 43b130479..a2260be08 100644 --- a/docs/developer/guides/adapt-on-top-faults.md +++ b/docs/developer/guides/adapt-on-top-faults.md @@ -247,6 +247,15 @@ near-optimal for TI — do **not** hand-roll a "TI-aware Schur"; measured, the d `1/η₀` beats every alternative (the weak TI mode is fault-parallel shear, which is volume-preserving, so pressure sees η₀). +**Split-node faults on an add-on mesh.** `child.add_fault(...)` carries the fault +through every add-on level of the tail (the background is never cut), so the coarse +spaces contain the discontinuity and the velocity iterations FALL with refinement +(6, 5, 4 at k = 2, 3, 4 against 13, 18, 23 with the welded levels; slip identical). +This works in serial and in parallel (the pairing rides every redistribution as +labels) and for the extruded thick twin. `split_levels=False` keeps the welded +tail. Governing doc: +[`subsystems/fault-split-multigrid.md`](../subsystems/fault-split-multigrid.md). + --- ## Moving fault + re-adaptation + field transfer diff --git a/docs/developer/guides/branching-strategy.md b/docs/developer/guides/branching-strategy.md index 6d3323612..eb3e1c332 100644 --- a/docs/developer/guides/branching-strategy.md +++ b/docs/developer/guides/branching-strategy.md @@ -51,9 +51,14 @@ feature/Z ────────────────────┘ ### `feature/*` — long-lived feature work -- Branch from `development`. PR back to `development` when ready. +- Branch from `development`, **current** `development`: fetch first, and read the open + and recently merged PRs that touch the subsystem before starting (the session + bootstrap in `CLAUDE.md` spells out the check). A branch cut from a stale base or + from another feature branch re-does work that is already in a PR. +- PR back to `development` when ready. - Reviewed by human + Copilot. CI runs on the PR. -- Periodically incorporate changes from `development` (merge or rebase, developer's choice). +- Incorporate `development` whenever a related PR merges, not only periodically + (merge or rebase, developer's choice). - No direct API changes — see next section. ### Retired: `uw3-release-candidate` diff --git a/docs/developer/index.md b/docs/developer/index.md index 9aaa0a868..162a2dfd5 100644 --- a/docs/developer/index.md +++ b/docs/developer/index.md @@ -37,6 +37,8 @@ same topic are reference or historical material subordinate to the governing doc | Data access | [subsystems/data-access.md](subsystems/data-access.md) (internals reference: [NDArray System](UW3_Developers_NDArrays.md)) | | Local scattered-point interpolation | [subsystems/interpolation.md](subsystems/interpolation.md) | | Rotated free-slip & wall-normal datum | [subsystems/rotated-freeslip.md](subsystems/rotated-freeslip.md) | +| Thin sheet (plane stress, sphere, faults) | [subsystems/thin-sheet.md](subsystems/thin-sheet.md) | +| Fault multigrid (split-level hierarchy, parallel pairing) | [subsystems/fault-split-multigrid.md](subsystems/fault-split-multigrid.md) | | Fault networks in parallel (placement, split, tail) | [design/fault-parallel-placement-2026-09.md](design/fault-parallel-placement-2026-09.md) (subsystem reference: [conforming-surfaces-and-fault-zones.md](subsystems/conforming-surfaces-and-fault-zones.md)) | | Analytic & benchmark solutions | [subsystems/analytic-solutions.md](subsystems/analytic-solutions.md) | | Units | [design/UNITS_SIMPLIFIED_DESIGN_2025-11.md](design/UNITS_SIMPLIFIED_DESIGN_2025-11.md) | @@ -191,6 +193,7 @@ design/mesh-adaptation-formulation design/LAYER2_SBR_ADAPT_ON_TOP design/NVB_GRADED_ADAPT design/MULTIGRID_MINIMAL_CONTROL_2026-07 +design/FAULT_MULTIGRID_HIERARCHIES design/solver-wall-clock-guard design/ARCHITECTURE_ANALYSIS design/MATHEMATICAL_MIXIN_DESIGN @@ -233,6 +236,8 @@ subsystems/integration-point-variables subsystems/solvers subsystems/boundary-stress-and-projection-postprocessing subsystems/rotated-freeslip +subsystems/thin-sheet +subsystems/fault-split-multigrid subsystems/petsc-jacobian-layout subsystems/constitutive-models subsystems/constitutive-models-theory diff --git a/docs/developer/subsystems/conforming-surfaces-and-fault-zones.md b/docs/developer/subsystems/conforming-surfaces-and-fault-zones.md index ac76dd8e6..8bc12808c 100644 --- a/docs/developer/subsystems/conforming-surfaces-and-fault-zones.md +++ b/docs/developer/subsystems/conforming-surfaces-and-fault-zones.md @@ -437,6 +437,27 @@ Refusals, stated: one flat wall per object (box-edge outcrops and multi-wall contact refuse); curved or deformed tops refuse (the cap's manifold-prep slot); removal of an outcropping object is not yet wired. +### The ladder through the wall + +The structured ladder (`mesher="ladder"`: rungs on the spine's samples, one element +either side, nothing snapped) used to be refused wherever it protruded past the +domain, in either realisation. It now outcrops: the ladder runs on the sample rows +inside the domain to one rung short of the wall, and a small unstructured cap, +clipped against the domain polygon exactly as the default band is, closes it to +the outcrop (`_ladder_outcrop_assembly_2d`). The spine keeps its rung vertices up +to the last rung, so a split walks them; the cap's cells are where a weak layer +bridges a split that must stop blind of the surface. On the ribbon placer give the +polyline that reaches past the wall as the `spines` entry and the samples at least +three spacings inside as the trace (a trace nearer the wall meets a wall vertex in +the end-vertex pull). Two defects on curved walls were fixed with it: the domain +tool now winds every boundary loop counter-clockwise (a hole loop wound against the +exterior was added as area, so an annulus at some cell sizes met nothing), and the +2-D outcrop frame groups the wall's chords into smooth walls as the 3-D rule does, +keeping the curve's own vertices through the splice (a dipping band's clearance +cavity spans a few chords beside the one it crosses, and chording across the kept +vertices lost the sagitta the area guard refuses). +`tests/test_1091_place_outcrop_curved_wall.py`. + ## Limitations * **An essential boundary condition on the surface is not sound** under the geometric multigrid hierarchy. The coarse levels do not carry the surface, so diff --git a/docs/developer/subsystems/fault-split-multigrid.md b/docs/developer/subsystems/fault-split-multigrid.md new file mode 100644 index 000000000..6a0207c38 --- /dev/null +++ b/docs/developer/subsystems/fault-split-multigrid.md @@ -0,0 +1,189 @@ +# Split-level multigrid for split-node faults + +Governing document for the fault multigrid hierarchy: `utilities/fault_mg.py`, the +split-pair branches of `utilities/custom_mg.py` and `utilities/rotated_bc.py`, the +level replay in `Mesh.add_fault`, and the fault-carrying tail of `meshing/extrude.py`. +The exploration that chose this design is +[FAULT_MULTIGRID_HIERARCHIES](../design/FAULT_MULTIGRID_HIERARCHIES.md). + +## What it is + +A split-node fault lives on the finest mesh. Its multigrid levels come from the add-on +hierarchy: the static background, which is never cut, and the add-on levels refined +from it toward the fault. `mesh.add_fault` now carries the fault through **every +add-on level** of the inherited tail, cut and split exactly as the finest is, so the +coarse spaces contain the discontinuity. The background levels are the base's own +wraps, untouched (`_mg_static`). + +```python +child = base.adapt(metric, max_levels=k) # add-on levels toward the fault +mesh = child.add_fault([("Flt", trace)]) # finest AND every add-on level split +stokes = uw.systems.Stokes(mesh) +stokes.add_fault_bc(0, "Flt") +stokes.solve() # geometric multigrid, no setup +mesh = child.add_fault([("Flt", trace)], split_levels=False) # the welded tail +``` + +In 3-D a fault is a triangulated sheet (`("Flt", points, triangles)` or a +`FaultSurface`), placed with the sheet placement and split; the levels are replayed +the same way. The extruded thick twin of a faulted sheet (`mesh.extrude`) extrudes the +split sheet and its split levels, so the layer's hierarchy is the sheet's one dimension +up. + +## The three pieces + +**Side-aware transfer** (`cell_located_prolongation`, `cell_located_transfer_parallel`). +For each fine node one fine cell containing it is taken, and that cell's *centroid* is +located in the coarse mesh. A centroid is never on the fault, so the coarse cell is on +the node's own side; a node on the slit interpolates from its own side. The coarse +cell's Lagrange interpolant is evaluated at the node by the dual-basis identity +$W = BM^{-1}$ (as the nested native-pair transfer does), batched over cells. The levels +are conforming but not nested near the trace, so a fine node can lie slightly outside +its located cell: the evaluation point is clamped into the cell (barycentric +coordinates clipped and renormalised), which keeps every weight within the Lagrange +basis on the simplex. Every pair that touches a split level takes this transfer; the +node-cloud builders triangulate the coarse nodes and degenerate on a slit. + +**Per-level pair frame** (`level_pair_rotation`). Each split level carries its own pair +rotation $Q_l$ (mean and jump in the fault frame, the same construction as +`build_rotation`, from the level's own pairing and facet normals), built on the +solver's discretisation copied onto the level DM. The rotated transfer is +$\hat P_l = Q_{l+1} P_l Q_l^{T}$ with the level's opening rows removed (and, on a +surface mesh, the jump and mean along the surface normal). + +**Unreached unknowns dropped** (`rotated_split_transfers`, `restrict_unreached`). A +coarse unknown no fine unknown reaches (a few near the tips) is a null mode of the +Galerkin coarse operator. It is removed from the level, fine to coarse, never repaired +by injection. This is now the rule on every custom-P hierarchy, split or not +(`CustomMGHierarchy.drop_orphans`): the nearest-node injection put a weight-1 entry +on a fine row that already carried one, which is two identical columns and a singular +coarse operator. Measured: NaN on a two-mid placed-ladder chain in serial (four +orphans, with and without a split finest) and 512 duplicated columns at np = 2. The +coarse levels' FAC patch rows do not survive the renumbering and are cleared; the +finest's are unchanged. + +## Rotated walls on every level + +A hierarchy under rotated free-slip walls (an annulus, a spherical shell) rotates +its coarse levels too (`level_wall_rotation`, composed with the pair rotation of a +split level by `level_rotation`; `_build_rotated_custom_Pl` routes any hierarchy +with rotated walls through `rotated_split_transfers`). Each coarse level takes the +same per-node frame as the finest, built on the level's own wall nodes from the +solver's boundary specifications (the analytic normal evaluated at the level's +nodes, or the level's own measure-weighted facet normals), and its normal rows are +removed. Before this the coarse levels were Cartesian and only the finest's normal +rows were zeroed in the transfer. A coarse wall node's normal Cartesian component +was then reached only through the fine tangential rows, weighted by the sine of the +angle between the two nodes' normals: a Galerkin nodal block of 1e-3 against 2.5 at +a node near an axis, 1e-20 on the axis. The inode SOR smoother inverts those 2x2 +blocks and reported a zero pivot, and the velocity solve failed before its first +iteration (measured on the annulus of the Zhong & Gurnis analogue; a box with +Dirichlet walls never showed it because an axis-aligned wall's sines are exactly +zero). Two further rules came out of the same case: + +- the reach test of the orphan rule is relative (`UNREACHED_REL`, 1e-8 of the + level's largest column, squared norms) and runs after the finest's constrained + rows are zeroed, not before; +- the Galerkin operators of the rotated coarse levels do not use PETSc's inode + block grouping (`MAT_USE_INODES` off, set after the velocity PC's set-up; the + finest operator is untouched). A rotated wall node keeps one tangential row + beside the two-row nodes, and on the developed temperature field of the + annulus run at four ranks the inode SOR's 2x2 block inversion met a zero pivot + on the base's two finest levels with every scalar diagonal healthy (minimum + 2.6 against a maximum of 3e4); without the grouping the same solve converged + in 9 iterations to the two-rank answer. Which rows the grouping pairs there is + not yet identified (a row-level inspection is in the driver's history); +- the free-slip gauge mode must verify for the SVD coarse solve to be selected, + which on a faceted circle needs the exact radial normal + (`normal=mesh.CoordinateSystem.unit_e_0`); and that SVD needs a small coarsest + level (14k unknowns took over 30 minutes, 900 is instant), so a base with three + halvings rather than one. + +## Parallel + +Every split level is redistributed so its fault star is rank-interior (the split's own +rule), so the pair blocks are rank-local on every level. The transfer locates each +owned fine node in the *local* coarse mesh (owned plus ghost cells); the nodes whose +coarse cell lives elsewhere are all-gathered as query points, answered by the rank +holding the cell with global column indices, and the asker keeps the best answer. The +levels need not be co-partitioned. + +A coincident pairing rides a redistribution as a pair of DM labels +(`fault_split.stamp_point_pairs` / `collect_point_pairs`): one value per pair on its +Minus and Plus points. The migration star forest PETSc returns for an *initial* +distribution does not index the stratified numbering (measured: three quarters of the +points mapped to the wrong depth), so the forest is not used. `extrude_sheet` builds the +layer on one rank, distributes it with the labels on, redistributes the fault stars +rank-interior and reads the pairing back; a balanced partition readily puts the whole +Minus side on one rank and the whole Plus side on another, since the slit disconnects +them. + +## Measured (box in simple shear, straight fault, 2026-10-13) + +Velocity iterations per solve, split levels (C) against the welded tail (A): + +| add-on levels $k$ | near-fault $h$ | C | A | +|---|---|---|---| +| 2 | 0.0125 | 6 | 13 | +| 3 | 0.00625 | 5 | 18 | +| 4 | 0.0031 | 4 | 23 | + +Viscous fault at $k = 3$: 5 against 17. The peak slip agrees to six figures. In +parallel, C at $k = 2, 3$: 6, 5 iterations at np = 2 and 7, 6 at np = 4; A runs at np = 2 +too (15), where before it returned NaN. + +A moving fault at $k = 4$ (three positions, the add-on levels rebuilt and re-split at +each): 4 to 5 iterations in serial, 7 at np = 8, the slip equal to six figures. Per +step at np = 8: adapt 0.8 to 1.3 s, cut and split of every level 1.5 to 2.1 s, solve +11 to 14 s (serial: 2.7, 4.5 and 26 to 30 s). In parallel `adapt` keeps one fewer +intermediate level (a known difference in its subsampling), which the transfer does +not mind. + +The faulted thick plate of `test_1083` (two layers, 6024 tetrahedra, 261 pairs): +11 velocity iterations in serial and 13 at np = 2, slip 0.19356 on both; with one +add-on level in the sheet (7524 tetrahedra, 681 pairs): 16 in serial and 20 at +np = 2, slip 0.19382 on both. The thin layer's iteration count still grows with +refinement; the through-thickness coupling and the pressure Schur complement on a +thin layer are their own question, outside this hierarchy. + +A planar fault patch in a 3-D box (sheet placed on the NVB-refined child with +`clearance=0.9`, 35712 tetrahedra at one add-on level): 5 velocity iterations, slip +0.18113. One isotropic add-on level in 3-D is three bisection passes, which the +tail's one-level-per-halving rule folds into the finest, so there is no intermediate +level to split at k = 1 and the two tails coincide there; k = 2 is the first with a +split 3-D level. + +## Tests + +`tests/test_1091_place_outcrop_curved_wall.py` (the placer on an annulus: loop +orientation, the dipping band through the curved wall, the outcropping ladder on +both placement routes) and +`tests/test_1087_fault_split_multigrid.py` (tail structure, iterations falling with +refinement against the welded tail, viscous fault, a moving fault, the transfer's side +awareness and quadratic exactness) and +`tests/parallel/test_1089_fault_split_multigrid_parallel.py` (np = 2 and 4: split and +welded tails converge with the serial slip; the thick twin extruded with its pairing). + +## Not yet done + +- A regression test for the coarse-level wall rotation itself (a rotated free-slip + annulus with a placed band and the custom tail), beyond the campaign's annulus + drivers. +- The 3-D sheet placement on an NVB-refined child needs `clearance` of about 0.9 at + one add-on level (its volume guard fires at the default 0.6) and refuses the + two-level child at every clearance and sheet size tried (volume change 4e-6 to + 5e-5 against a guard of 1e-9). General 3-D patches on add-on meshes wait on the + placement; `add_fault` passes the placement options through. +- A placed band's spines (`cut=False`) are not replayed on the levels; such a fault + keeps the welded tail and the FAC patch of the fault-network work. +- The replay runs from the finest add-on level down and stops, with a warning, at the + first level the faults cannot be carried onto; that level and those beneath it stay + welded. A network whose junction ligament is one fine cell wide closes at the next + coarser spacing (the two labels meet), so such a network gets split levels only where + the ligament survives — on `test_0866`'s kissing Y with one add-on level, none. +- In parallel each split level's fault star is moved onto the rank holding the + finest level's star (`fault_mg.colocate_split_level`), so a transfer between + levels locates most fault-band nodes locally (without it, 48 % of the finest + nodes were located remotely at np = 2, 58 % at np = 8); the remote queries + that remain are the band's rim. Not measured at scale. +- The one-rank build of `extrude_sheet` bounds the layer size by one rank's memory. diff --git a/docs/developer/subsystems/rotated-freeslip.md b/docs/developer/subsystems/rotated-freeslip.md index abe8c2e6d..4354917c4 100644 --- a/docs/developer/subsystems/rotated-freeslip.md +++ b/docs/developer/subsystems/rotated-freeslip.md @@ -200,6 +200,14 @@ Galerkin-coarsened rotated block inherits the rigid-rotation null space where `redundant`/LU hits a zero pivot. See [the solvers subsystem doc](solvers.md) for the bundle itself. +The coarse levels are rotated as well (`fault_mg.level_wall_rotation`, since +2026-10-14): each takes the per-node wall frame on its own nodes and loses its +normal rows, so the coarse space is exactly the constrained motion. A Cartesian +coarse level under a rotated finest keeps wall-normal unknowns the fine operator +reaches only through vanishing sines, and the smoother's nodal block inversion +meets a zero pivot there. Details in +[the split-level multigrid doc](fault-split-multigrid.md). + ### The velocity sub-KSP must converge, not just apply The velocity block is preconditioned by multigrid, but the KSP *around* that diff --git a/docs/developer/subsystems/thin-sheet.md b/docs/developer/subsystems/thin-sheet.md new file mode 100644 index 000000000..4cdf1f4e7 --- /dev/null +++ b/docs/developer/subsystems/thin-sheet.md @@ -0,0 +1,152 @@ +# Thin sheet: `uw.systems.ThinSheet` + +A vertically averaged (plane-stress) sheet solver for the lithosphere, on a flat +mesh or on the surface of a sphere, with any deviatoric constitutive model — +Newtonian, Maxwell viscoelastic, or yielding — and the split-node fault machinery. +A transversely isotropic model is refused on the sphere (its strain rate is not +projected onto the surface). + +Design record: `docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md`. +Tests: `tests/test_1075`–`test_1084`. Session drivers: `scripts/sessions/thin_sheet_*.py`. + +## The equations + +With thickness $L$, tangential velocity $\mathbf u$ and the plane-stress closure +$\sigma_{nn} = 0$ on the dynamic stress, the pressure of an incompressible material is +$p = \tau_{nn} = -\operatorname{tr}\boldsymbol\tau$ and + +$$ +-\nabla_s\cdot\big(L\,\boldsymbol\sigma\big) + c\,(\mathbf u - \mathbf u_\mathrm{ref}) += L\,\mathbf f + \mathbf t_b, +\qquad +\boldsymbol\sigma = \boldsymbol\tau + \operatorname{tr}(\boldsymbol\tau)\,\mathbf P, +\qquad +\dot{\boldsymbol\varepsilon}_s = \mathbf P\,\mathrm{sym}(\nabla_s\mathbf u)\,\mathbf P . +$$ + +$\boldsymbol\tau$ is the deviatoric stress the constitutive model returns for the +in-plane strain rate $\dot{\boldsymbol\varepsilon}_s$; $c$ is a basal drag +coefficient ($\eta_a/h_a$ for a viscous channel) against the velocity of what lies +beneath; $\mathbf f$ a body force per unit **volume** (as for `Stokes`) and +$\mathbf t_b$ a basal traction. There is no pressure unknown: the solver is an +`SNES_Vector`, and $p = -\operatorname{tr}\boldsymbol\tau$ is available as the +diagnostic `sheet.p`. Every invariant a constitutive model forms includes the +out-of-plane component $M_{nn} = -\operatorname{tr}M$ (`Unknowns.second_invariant`), +so a yielding sheet yields at the von Mises stress. + +Closed forms the tests pin: uniaxial stretch with free edges gives the Trouton +stress $3\eta\dot\varepsilon$; a rigid motion carries no stress; the Maxwell +uniaxial response is $3\eta\dot\varepsilon(1-e^{-t\mu/\eta})$; Elsasser stress +diffusion into an elastic sheet over a viscous substrate has $D = \mu L/c$; a +frictionless fault under remote shear slips as the plane-stress Griffith crack +with $E' = 3\eta$. + +```python +sheet = uw.systems.ThinSheet(mesh) +sheet.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + sheet.Unknowns, order=1, integrator="etd") +sheet.constitutive_model.Parameters.shear_viscosity_0 = eta +sheet.constitutive_model.Parameters.shear_modulus = mu +sheet.thickness = L +sheet.add_basal_drag(c, "Base", reference_velocity=u_substrate) +sheet.add_fault_bc(0, "Flt") # after mesh.add_fault(("Flt", points)) +sheet.solve(timestep=dt) +``` + +## One model, thin or thick + +The same model function runs as a thin sheet and as a thick one — `Stokes` on a +thin 3-D layer — by changing the mesh and the solver class: + +```python +def model(mesh, Solver): + solver = Solver(mesh) + solver.constitutive_model = uw.constitutive_models.ViscousFlowModel + solver.constitutive_model.Parameters.shear_viscosity_0 = eta + solver.add_basal_drag(c, "Base", reference_velocity=u_base) + solver.add_fault_bc(0, "Flt") + return solver + +sheet = model(sheet_mesh, uw.systems.ThinSheet) # sheet.thickness = L +layer = model(sheet_mesh.extrude(L, layers=2), uw.systems.Stokes) +``` + +`mesh.extrude(L, layers)` (`uw.meshing.extrude_sheet`) builds the layer from the +sheet mesh: the sheet's boundaries become its walls under the same names, its faces +are `Base` and `Surface`, and every split-node fault of the sheet becomes a vertical +fault through the whole layer — open at the surface and the base, its tips shared +vertical edges — with the side labels and the pairing rebuilt, so +`Stokes.add_fault_bc` works on it. The thin sheet accepts the boundary name of +`add_basal_drag` / `add_basal_traction` and ignores it (it is its own base). +`uw.maths.tensor.resolved_traction` and `coulomb_failure_stress` act on either +solver's `stress`. A constitutive model's tangent flux (`flux_jacobian`) is +composed with each solver's own closure (`_flux_closure`). + +Measured (`test_1083`, $L = 0.04$, $cL/\eta = 0.04$): the layer's mid-plane +velocity matches the sheet's to 0.7 % unfaulted and 0.5 % faulted, the slip to +1.6 %. In parallel the sheet is gathered onto one rank, extruded there, and +the layer distributed with its fault pairing, every fault's cell star +rank-interior; the one-rank build bounds the layer by one rank's memory. The +layer's multigrid tail coarsens through the thickness first, then laterally +through the sheet's own levels; a split level of the sheet's tail is extruded +split, so the layer carries the split-level multigrid of the sheet one dimension +up, and the background levels stay welded. On the sphere (radial extrusion) the +difference is first order in $L$ — the curvature the sheet neglects and the +drag-driven shear: 3.3 % at $L = 0.04$, 1.35 % at $L = 0.02$. + +## On a sphere + +The velocity on a `SphericalManifold` (`dim=2`, `cdim=3`) is carried in the +embedding space. Two normals are involved, and they are different on purpose: + +- the **in-plane projector** uses the cell's facet normal (a cell-wise constant + field built once per mesh): the assembler differentiates along the flat facets, + and only with the facet normal is a rigid rotation — a Killing field — + strain-free in the discrete form. With the analytic normal it keeps a spurious + strain of order $h$; +- the **tangency constraint** $\mathbf u\cdot\hat{\mathbf n} = 0$ uses the analytic + surface normal at every node, imposed strongly through the per-node rotation of + `utilities/rotated_bc.py` (`SHEET_TANGENCY`), so a rigid rotation is exactly + admissible. Faults compose with it because the split-node pair blocks use the + same machinery. + +The rigid-rotation gauge offers the three Killing fields; with basal drag they are +not null modes and are rejected by the operator test, as they should be. + +The stress history is carried on the grid (`stress_transport = "eulerian"`, the +streamline-upwind transport the Eulerian solvers use) by default. On a manifold it +is the only choice; the semi-Lagrangian trace-back is refused there. On a flat +mesh it can be chosen, but next to a split-node fault its point evaluation reads +across the slit: with a sliding Coulomb fault it produced a period-2-step +oscillation of the near-tip slip rate and patchy stress along the trace, both of +which the grid history removes, at a sixth of the Newton work. + +## Faults on a sphere + +`mesh.add_fault(("name", points))` with three-coordinate points reads the polyline +as great-circle arcs about the origin. The coincident pair at a fault node carries +the frame $\{\hat\nu, \hat t, \hat r\}$ — the in-surface conormal of the fault edge +(orthogonalised against the node's surface normal), the trace tangent, the surface +normal — and pins the jump along $\hat\nu$ (no opening) and both sides' tangency +(the jump and the mean along $\hat r$); the slip row stays free, or carries the +interface law. Diagnostics (`fault_slip`, `fault_pair_jumps`, +`fault_normal_traction`) read the same frame. + +Known limits: faults may not reach the mesh boundary (2-D rule); on a split mesh +`uw.function.evaluate` within about $h/2$ of the slit is unreliable — read nodal or +pair data there; `mesh.adapt` runs on a manifold but does not yet snap the refined +vertices back to the sphere. + +## The quadratic benchmark + +On the sphere a divergence-free toroidal drag thins a corotational Maxwell sheet at +second order (the Weissenberg effect): $-\nabla_s\cdot\mathbf u/A^2 = +(24/91)P_2 - (24/287)P_4$ in steady state, scaled by ETD-1's effective relaxation +time $\alpha\Delta t/(1-\alpha)$. `test_1084`; the closed form is derived with +sympy (the derivation is kept with the campaign, outside this repository). + +## Round 2 (not yet) + +Thickness evolution with the gravitational potential energy term (England & +McKenzie's Argand number) — on the sphere with the Eulerian advection–diffusion — +and topography from it. diff --git a/scripts/test.sh b/scripts/test.sh index c70b567bb..d3d6d9a78 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -326,6 +326,12 @@ if [ $PARALLEL_ONLY -eq 0 ]; then # hardened, fast, and exactly what CI exists to run. test_1064 is the slow one # and it is excluded by WHAT IT IS (tier_c) below, not by its number. batch 1 tests/test_106*py tests/test_107*py + # test_108*: thin sheet (objective step, the Newton tangent's closure, thin vs + # thick, the quadratic benchmark); the two slow sphere tests (1081, 1084) are + # tier C, so the "not tier_c" filter leaves them out. test_109*: the fault + # placement and split-level multigrid tests that follow. Shard 3: the lightest + # measured (17.3 min on run 40638600008); not yet timed in a sharded run. + batch 3 tests/test_108*py tests/test_109*py # # test_1072 used to be pulled forward out of test_107* by name, because that # band was not batched and it is the ONLY guard on the 3-D free-surface sign diff --git a/src/underworld3/constitutive_models.py b/src/underworld3/constitutive_models.py index 21aaf32f5..3ec515937 100644 --- a/src/underworld3/constitutive_models.py +++ b/src/underworld3/constitutive_models.py @@ -546,6 +546,19 @@ def Unknowns(self, unknowns): self._solver_is_setup = False return + def _second_invariant(self, M): + r"""Second invariant of a deviatoric tensor of this model (a strain + rate, a stress), as the solver's kinematics define it. + + The solver supplies it (``Unknowns.second_invariant``) because only the + solver knows what its tensors leave out: the thin sheet's in-plane + tensors stand for 3-D ones with :math:`M_{nn} = -\operatorname{tr}M`. + Otherwise the plain contraction :math:`\sqrt{M_{ij}M_{ij}/2}`.""" + invariant = getattr(self.Unknowns, "second_invariant", None) + if invariant is None: + return sympy.sqrt((M**2).trace() / 2) + return invariant(M) + @property def K(self): r"""Primary constitutive property (viscosity, diffusivity, etc.). @@ -1075,13 +1088,9 @@ def grad_u(self): \dot{\varepsilon}_{ij} = \frac{1}{2}\left(\frac{\partial u_i}{\partial x_j} + \frac{\partial u_j}{\partial x_i}\right) """ - mesh = self.Unknowns.u.mesh - - return mesh.vector.strain_tensor(self.Unknowns.u.sym) - - # ddu = self.Unknowns.u.sym.jacobian(mesh.CoordinateSystem.N) - # edot = (ddu + ddu.T) / 2 - # return edot + # The solver owns the kinematics: Unknowns.E is sym(grad u) on a volume + # mesh and the in-plane (projected) strain rate on a thin sheet. + return sympy.Matrix(self.Unknowns.E) @property def plastic_fraction(self): @@ -1703,7 +1712,7 @@ def __init__(self, unknowns, material_name: str = None): self._strainrate_inv_II = expression( r"\dot\varepsilon_{II}", - sympy.sqrt((self.grad_u**2).trace() / 2), + self._second_invariant(self.grad_u), "Strain rate 2nd Invariant", ) @@ -1950,7 +1959,7 @@ def plastic_correction(self) -> float: # The yield criterion in this case is assumed to be a bound on the second invariant of the stress - stress_II = sympy.sqrt((stress**2).trace() / 2) + stress_II = self._second_invariant(stress) correction = parameters.yield_stress / stress_II @@ -2083,7 +2092,8 @@ class ViscoElasticPlasticFlowModel(_ExponentialCoefficients, ViscousFlowModel): def __init__(self, unknowns, order=1, integrator: str = "bdf", material_name: str = None, objective_rate: str = "none", - stress_history: str = "stress", convected_step: str = None): + stress_history: str = "stress", convected_step: str = None, + objective_order=None): """Construct a viscoelastic-plastic flow model. Parameters @@ -2140,6 +2150,28 @@ def __init__(self, unknowns, order=1, integrator: str = "bdf", stretching of the relaxation target is completed the same way). Default ``"linear"``, or ``"deformation"`` with the log-conformation history, which requires it. + objective_order : int or "cayley", optional + How much of the step that carries the stress is taken, with + ``convected_step="linear"``. Over one step the convected stress is + :math:`M\sigma^*M^T` with :math:`M = e^{\Delta t K}` (:math:`K = W` + for the Jaumann rate, :math:`L` for the upper-convected); order + :math:`q` keeps the terms of total order up to :math:`q` in + :math:`\Delta t`, + + .. math:: + \sum_{i+j \le q} \frac{\Delta t^{i+j}}{i!\,j!}\, + K^i\sigma^*(K^T)^j , + + so ``1`` is :math:`\sigma^* + \Delta t\,(K\sigma^* + \sigma^*K^T)`, + the explicit source, and each order adds the next terms. + ``"cayley"`` (Jaumann only) rotates by + :math:`R = (I - \Delta tW/2)^{-1}(I + \Delta tW/2)`: exactly + orthogonal, so a rigid rotation carries the stress rigidly at any + step, with an angle :math:`2\arctan(\omega\Delta t/2)` against + :math:`\omega\Delta t`. The step acts on the newest history level: + orders above one, and ``"cayley"``, need a first-order history. + Default ``1``; a thin sheet takes ``"cayley"`` with the Jaumann + rate and a first-order history unless an order is given here. """ if integrator not in ("bdf", "etd"): raise ValueError( @@ -2169,6 +2201,20 @@ def __init__(self, unknowns, order=1, integrator: str = "bdf", convected_step = "deformation" if stress_history == "log_conformation" else "linear" if convected_step not in ("linear", "deformation"): raise ValueError(f"convected_step must be 'linear' or 'deformation', got {convected_step!r}") + # left to the solver when not given: 1, the explicit source, unless the + # solver selects otherwise (a thin sheet takes "cayley") + self._objective_order_given = objective_order is not None + if objective_order is None: + objective_order = 1 + if objective_order != "cayley" and not (isinstance(objective_order, int) and objective_order >= 1): + raise ValueError("objective_order must be a positive integer (the order in dt of the " + f"step that carries the stress) or 'cayley', got {objective_order!r}") + if objective_order == "cayley" and objective_rate != "jaumann": + raise ValueError("objective_order='cayley' is the orthogonal rotation by the spin: it " + "needs objective_rate='jaumann'") + if convected_step == "deformation" and objective_order != 1: + raise ValueError("convected_step='deformation' takes its own positive-definite step; " + "objective_order applies to convected_step='linear'") if stress_history == "log_conformation" and convected_step != "deformation": raise ValueError("the log-conformation history needs convected_step='deformation': the " "linear step can hand it an indefinite conformation, which has no logarithm") @@ -2181,6 +2227,7 @@ def __init__(self, unknowns, order=1, integrator: str = "bdf", "logarithm and exponential; 3-D is not implemented") self._stress_history = stress_history self._convected_step = convected_step + self._objective_order = objective_order # Store material_name before creating expressions (needed by create_unique_symbol) self._material_name = material_name @@ -2557,6 +2604,10 @@ def _check_order_supported(self, order): if order != 1 and self._convected_step == "deformation": raise NotImplementedError("convected_step='deformation' and the log-conformation history " "are first order only") + if order != 1 and self._objective_rate != "none" and self._objective_order != 1: + raise NotImplementedError(f"objective_order={self._objective_order!r} carries the newest " + "history level only: it needs a first-order history (use " + "objective_order=1 with order 2)") def _carried_stress_sym(self, level=0): r"""The carried stress :math:`\sigma^*` of history level ``level``: the @@ -2643,13 +2694,22 @@ def E_eff(self): else: edot_star = sympy.zeros(*E.shape) eta_raw = self.Parameters.shear_viscosity_0 - E_eff = ( - (1 - phi) * E - + (alpha / (2 * eta_raw)) * sigma_star - + (phi - alpha) * edot_star - # objective rate: the flux gains alpha * dt * S(sigma*) - + alpha * self.Parameters.dt_elastic * self._objective_term(sigma_star) / (2 * eta_raw) - ) + if self._objective_rate != "none" and self._objective_order != 1: + # the stress carried through the step at the chosen order + # replaces sigma* + dt S(sigma*) + E_eff = ( + (1 - phi) * E + + (alpha / (2 * eta_raw)) * self._carried_through_step(sigma_star) + + (phi - alpha) * edot_star + ) + else: + E_eff = ( + (1 - phi) * E + + (alpha / (2 * eta_raw)) * sigma_star + + (phi - alpha) * edot_star + # objective rate: the flux gains alpha * dt * S(sigma*) + + alpha * self.Parameters.dt_elastic * self._objective_term(sigma_star) / (2 * eta_raw) + ) if self._convected_step == "deformation": # The relaxation target is stretched during the step too. The # exponential step carries that as (1-a) I + b (L + L^T) with @@ -2670,6 +2730,12 @@ def E_eff(self): # BDF default mu_dt = self.Parameters.dt_elastic * self.Parameters.shear_modulus bdf_cs = [self._bdf_c1, self._bdf_c2, self._bdf_c3] + if self._objective_rate != "none" and self._objective_order != 1: + # first-order history (checked): the newest level carried through + # the step at the chosen order replaces sigma* + dt S(sigma*) + E += -bdf_cs[0] * self._carried_through_step(self._carried_stress_sym(0)) / (2 * mu_dt) + self._E_eff.sym = E + return self._E_eff for i in range(DDt.order): E += -bdf_cs[i] * self._carried_stress_sym(i) / (2 * mu_dt) # objective rate on the newest level, explicit and first order: the @@ -2681,14 +2747,102 @@ def E_eff(self): self._E_eff.sym = E return self._E_eff + def _carried_through_step(self, sigma): + r"""The stress carried through one step at ``objective_order``: + :math:`\sum_{i+j\le q} \Delta t^{i+j} K^i\sigma(K^T)^j/(i!\,j!)`, the + expansion of :math:`e^{\Delta tK}\sigma\,e^{\Delta tK^T}` to total order + :math:`q` (:math:`K = W` Jaumann, :math:`L` upper-convected), or for + ``"cayley"`` :math:`R\sigma R^T` with + :math:`R = (I - A)^{-1}(I + A) = I + 2(A + A^2)/(1 + \theta^2)`, + :math:`A = \Delta t\,W/2`, :math:`\theta^2 = -\mathrm{tr}(A^2)/2` + (closed form for a skew :math:`A`, in 2-D and 3-D).""" + dim = self.Unknowns.u.mesh.cdim + dt = self.Parameters.dt_elastic + sigma = sympy.Matrix(sigma) + if self._objective_rate == "jaumann": + K = sympy.Matrix(self.Unknowns.W) + else: + K = sympy.Matrix(self.Unknowns.u.sym).jacobian(self.Unknowns.u.mesh.X) + # the first-order step, kept as the Newton tangent's stand-in for the + # frozen carried stress (see _tangent_surrogates) + self._carried_step_linear = sigma + dt * (K * sigma + sigma * K.T) + if self._objective_order == "cayley": + A = dt * K / 2 + A2 = A * A + theta2 = -A2.trace() / 2 + R = sympy.eye(dim) + 2 * (A + A2) / (1 + theta2) + carried = R * sigma * R.T + else: + q = self._objective_order + powers = [sympy.eye(dim)] + for _ in range(q): + powers.append(powers[-1] * dt * K) + carried = sympy.zeros(dim, dim) + for i in range(q + 1): + for j in range(q + 1 - i): + carried += powers[i] * sigma * powers[j].T / (sympy.factorial(i) * sympy.factorial(j)) + return self._frozen_carried_stress(carried) + + @property + def _tangent_surrogates(self): + r"""Frozen atoms of this model's flux and the differentiable stand-in + the Newton tangent uses for each, ``{atom: expression}``; empty when + nothing is frozen. + + The carried stress after an objective step above first order is frozen + (see :meth:`_frozen_carried_stress`): differentiating the Cayley rotation + or a long series directly is prohibitively expensive. Its first-order + form :math:`\sigma^* + \Delta t\,(K\sigma^* + \sigma^*K^T)` carries the + same leading dependence on the velocity gradient and is cheap to + differentiate, so the Newton tangent (``consistent_jacobian``) uses it in + place of each frozen entry. The residual keeps the exact step; the solver + applies the substitution to its own flux, so a solver's closure (the + thin sheet's plane-stress trace term) stays in the tangent.""" + atoms = getattr(self, "_carried_step_atoms", None) + linear = getattr(self, "_carried_step_linear", None) + if atoms is None or linear is None or self._objective_order == 1: + return {} + dim = atoms.shape[0] + return {atoms[i, j]: (linear[i, j] + linear[j, i]) / 2 + for i in range(dim) for j in range(i, dim)} + + def _frozen_carried_stress(self, carried): + r"""The carried stress as one wrapped atom per independent entry. + + The same freezing contract as the effective viscosity: the residual + compiles the exact entries, while the default (Picard) tangent sees each + atom as a constant, and only the Newton tangent (``consistent_jacobian``) + differentiates through it. Differentiating the Cayley rotation or a + high-order series directly — a rational function of all the velocity + gradients, in every entry — took tens of minutes per setup on a sphere + (measured 2026-10-11); its velocity dependence is O(dt), which the frozen + tangent omits at no cost to the converged answer.""" + dim = carried.shape[0] + atoms = getattr(self, "_carried_step_atoms", None) + if atoms is None or atoms.shape != (dim, dim): + atoms = sympy.zeros(dim, dim) + for i in range(dim): + for j in range(i, dim): + atoms[i, j] = expression( + rf"{{\sigma^{{\circ}}_{{{i}{j}}}}}", 0, + "carried stress after the objective step (frozen in the Picard tangent)") + atoms[j, i] = atoms[i, j] + self._carried_step_atoms = atoms + for i in range(dim): + for j in range(i, dim): + atoms[i, j].sym = (carried[i, j] + carried[j, i]) / 2 + return atoms + def _objective_term(self, sigma): r"""The stretching / rotation source of the chosen objective rate, :math:`S(\sigma)`, on the carried stress with the current velocity gradient :math:`L_{ij} = \partial u_i / \partial x_j`: zero for ``"none"``, :math:`L\sigma + \sigma L^T` for ``"upper_convected"``, - :math:`W\sigma - \sigma W` with :math:`W = (L - L^T)/2` for - ``"jaumann"``. Symmetric whenever ``sigma`` is.""" - dim = self.Unknowns.u.mesh.dim + :math:`W\sigma - \sigma W` with the solver's spin :math:`W` + (:math:`(L - L^T)/2` on a volume mesh) for ``"jaumann"``. Symmetric + whenever ``sigma`` is.""" + # the carried stress has one component per embedding dimension + dim = self.Unknowns.u.mesh.cdim if self._objective_rate == "none": return sympy.zeros(dim, dim) sigma = sympy.Matrix(sigma) @@ -2702,7 +2856,11 @@ def _objective_term(self, sigma): G = self.Parameters.shear_modulus return L * sigma + sigma * L.T + dt * (L * sigma * L.T + G * L * L.T) return L * sigma + sigma * L.T - W = (L - L.T) / 2 + # The spin is the solver's: on a volume mesh it is (L - L^T)/2 of the + # gradient above, identically; a sheet on a curved surface supplies the + # spin of a material element including the turning of its normal, which + # the surface gradient alone does not carry. + W = sympy.Matrix(self.Unknowns.W) return W * sigma - sigma * W # ----- Health of the carried stress (#768) ----- @@ -2755,7 +2913,8 @@ def max_elastic_timestep(self, safety: float = 0.3) -> float: for any timestep, so this limit does not apply) or with no flow; a velocity that is not finite raises rather than returning a silent ``nan``. """ - if self._objective_rate == "none" or self._convected_step == "deformation": + if (self._objective_rate == "none" or self._convected_step == "deformation" + or self._objective_order == "cayley"): return float("inf") E = sympy.Matrix(self.Unknowns.E) rate = sympy.sqrt(2 * (E.T * E).trace()) @@ -2829,7 +2988,7 @@ def conformation_min_eigenvalue(self): def E_eff_inv_II(self): r"""Second invariant of effective strain rate: :math:`\dot{\varepsilon}_{II} = \sqrt{\frac{1}{2}\dot{\varepsilon}_{ij}\dot{\varepsilon}_{ij}}`.""" E_eff = self.E_eff.sym - self._E_eff_inv_II.sym = sympy.sqrt((E_eff**2).trace() / 2) + self._E_eff_inv_II.sym = self._second_invariant(E_eff) return self._E_eff_inv_II @@ -2945,7 +3104,7 @@ def _plastic_effective_viscosity(self): strainrate_inv_II = expression( R"{\dot\varepsilon_{II}'}", - sympy.sqrt((Edot**2).trace() / 2), + self._second_invariant(Edot), "Strain rate 2nd Invariant including elastic strain rate term", ) @@ -2987,7 +3146,7 @@ def plastic_correction(self): stress = self.stress_projection() # The yield criterion in this case is assumed to be a bound on the second invariant of the stress - stress_inv_II = sympy.sqrt((stress**2).trace() / 2) + stress_inv_II = self._second_invariant(stress) correction = parameters.yield_stress / stress_inv_II return correction @@ -4315,7 +4474,7 @@ def E_eff(self): def E_eff_inv_II(self): r"""Second invariant of effective strain rate.""" E_eff = self.E_eff.sym - self._E_eff_inv_II.sym = sympy.sqrt((E_eff**2).trace() / 2) + self._E_eff_inv_II.sym = self._second_invariant(E_eff) return self._E_eff_inv_II @property diff --git a/src/underworld3/cython/petsc_generic_snes_solvers.pyx b/src/underworld3/cython/petsc_generic_snes_solvers.pyx index c959702b6..4ca3545d4 100644 --- a/src/underworld3/cython/petsc_generic_snes_solvers.pyx +++ b/src/underworld3/cython/petsc_generic_snes_solvers.pyx @@ -363,6 +363,10 @@ class SolverBaseClass(uw_object): This class is not intended to be used directly """ + # whether adjoint_solve takes the transpose of a rotated operator + _adjoint_routes_rotated_constraints = False + + def __init__(self, mesh): super().__init__() @@ -473,6 +477,25 @@ class SolverBaseClass(uw_object): self._velocity_is = None self._pressure_is = None self._subdict = {} + # Rotated strong free-slip BCs: [(boundary, normal), ...]. Registered via + # add_rotated_freeslip_bc; when non-empty, solve() delegates to + # underworld3.utilities.rotated_bc (per-node DOF rotation + strong v_n = u_n + # + reaction = sigma_nn). Empty by default → the solve path is unchanged. + # _rotated_freeslip_datum maps boundary → prescribed wall-normal datum + # (the non-zero `conds` of add_rotated_freeslip_bc; absent ⇒ u.n = 0). + self._rotated_freeslip_bcs = [] + self._rotated_freeslip_datum = {} + self._rotated_freeslip_info = None + # Cross-solve rotated workspace (rotated_bc cache, issue #417): + # populated/keyed/invalidated entirely inside solve_rotated_freeslip; + # torn down here by _reset_rotated_solver_cache on any DM rebuild. + self._rotated_linear_cache = None + # Split-fault interface conditions (add_fault_bc): fault names whose + # coincident DOF pairs carry a contact (the laws themselves live in + # _fault_interface_laws, set lazily by utilities/fault_contact.py). + # Non-empty => solve() takes the rotated path, where fault_contact + # supplies the pair blocks and the interface operator. + self._fault_contact_faults = [] # Per-Newton-iteration callbacks (PETSc SNESSetUpdate). Empty by default # -> no hook is installed and the solve path is unchanged. See @@ -853,7 +876,14 @@ class SolverBaseClass(uw_object): if not mode: return expr if newton_expr is None: - newton_expr = _jacobian_unwrap(expr, route=self._jit_route_in_use()) + # Atoms the constitutive model freezes because they are too costly to + # differentiate are swapped for its differentiable stand-ins first + # (its private _tangent_surrogates); applied to the solver's own + # flux, so the solver's closure terms stay in the tangent. + surrogates = getattr(getattr(self, "_constitutive_model", None), + "_tangent_surrogates", None) or {} + source = expr.xreplace(surrogates) if surrogates else expr + newton_expr = _jacobian_unwrap(source, route=self._jit_route_in_use()) if mode == "continuation" or (mode is True and getattr(self, "_picard_blend", False)): a = self._get_newton_alpha() if isinstance(expr, sympy.MatrixBase): @@ -874,12 +904,22 @@ class SolverBaseClass(uw_object): def _newton_flux(self, exact_F1): """Newton (consistent) flux for the bulk ``F1`` Jacobian source. - Returns the constitutive model's own smooth tangent law - (``constitutive_model.flux_jacobian``) when it supplies one — matched to - the container/shape of ``exact_F1`` — otherwise ``None`` so that - :meth:`_jacobian_source` falls back to the exact flux unwrapped. Lets a - model whose flux has a non-smooth yield kink provide a physically - motivated smooth tangent without changing the residual. + When the constitutive model supplies a tangent flux + (``constitutive_model.flux_jacobian``), it is a DEVIATORIC stress, like + the model's ``flux``: the solver applies its own closure to it + (``_flux_closure`` — Stokes: less the mechanical pressure with the + penalty, plus DEVSS; the thin sheet: the plane-stress trace term times + the thickness), so the tangent differentiates the same momentum flux as + the residual with only the model's part exchanged. A solver without a + closure uses the model flux as its F1, and the tangent flux stands in for + it directly. Returns ``None`` when there is no tangent flux, so that + :meth:`_jacobian_source` differentiates the exact flux. + + Before 2026-10-12 the tangent flux replaced the WHOLE F1 (Stokes lost the + pressure, penalty and DEVSS terms from its tangent), and on vector and + scalar solvers it was silently ignored: a 2-D Array was flattened with + ``list()`` (which yields rows) and the error swallowed. See + ``tests/test_1082_flux_jacobian_closure.py``. """ # The frozen (Picard) path never needs the Newton flux — short-circuit so # the (potentially expensive) model.flux_jacobian is not even evaluated, @@ -890,16 +930,27 @@ class SolverBaseClass(uw_object): smooth = getattr(cm, "flux_jacobian", None) if cm is not None else None if smooth is None: return None - try: - sm = sympy.Array(smooth) - ex = sympy.Array(exact_F1) - if sm.shape != ex.shape: - sm = sm.reshape(*ex.shape) - if isinstance(exact_F1, sympy.MatrixBase): - return sympy.Matrix(exact_F1.shape[0], exact_F1.shape[1], list(sm)) - return sm - except Exception: - return None + closure = getattr(self, "_flux_closure", None) + if closure is not None: + smooth = closure(sympy.Matrix(smooth)) + n_exact = int(np.prod(sympy.Array(exact_F1).shape)) + flat = list(sympy.Array(smooth).reshape(n_exact)) \ + if int(np.prod(sympy.Array(smooth).shape)) == n_exact else None + if flat is None: + raise ValueError( + f"constitutive_model.flux_jacobian has shape " + f"{sympy.Array(smooth).shape}; this solver's flux has shape " + f"{sympy.Array(exact_F1).shape}") + shape = sympy.Array(exact_F1).shape + if isinstance(exact_F1, sympy.MatrixBase): + composed = sympy.Matrix(shape[0], shape[1], flat) + else: + composed = sympy.Array(flat, shape) + surrogates = getattr(cm, "_tangent_surrogates", None) or {} + if surrogates: + composed = composed.xreplace(surrogates) + # differentiated as the exact flux would be: named quantities unwrapped + return _jacobian_unwrap(composed, route=self._jit_route_in_use()) def _get_newton_alpha(self): """Lazily construct the Picard->Newton continuation parameter. @@ -1927,6 +1978,17 @@ class SolverBaseClass(uw_object): """Second invariant of strain rate tensor.""" return inner_self._Einv2 + def second_invariant(inner_self, M): + r"""Second invariant :math:`\sqrt{M_{ij} M_{ij} / 2}` of a deviatoric + tensor built on these kinematics (a strain rate, a stress). + + Constitutive models take every invariant through here, so a solver + whose tensors are a reduced representation of the 3-D ones supplies + the missing components: the thin sheet adds the out-of-plane + component :math:`M_{nn} = -\operatorname{tr} M` that incompressibility + implies. The default is the plain contraction.""" + return sympy.sqrt((M**2).trace() / 2) + @property def CoordinateSystem(inner_self): """Coordinate system of the underlying mesh.""" @@ -2003,9 +2065,16 @@ class SolverBaseClass(uw_object): f"{len(contact)} fault contact(s): the rotated operator " f"carries an additive interface tangent, reassembled at every " f"iterate, whose transpose is not routed into the adjoint") - # Rotated FREE-SLIP does admit one — the transpose of the rotated - # operator, taken in the boundary frame. The solver that owns that path - # says so in its own verdict; nothing is refused here. + # Rotated FREE-SLIP admits one — the transpose of the rotated operator, + # taken in the boundary frame — where the solver routes its adjoint + # through it (the Stokes saddle point, rotated_bc.solve_rotated_adjoint). + # Elsewhere adjoint_solve would transpose the unrotated operator. + rotated = mechanisms["rotated_freeslip"] + if rotated and not self._adjoint_routes_rotated_constraints: + return (False, + f"{len(rotated)} rotated constraint(s): this solver's " + f"adjoint_solve transposes the unrotated operator, and the " + f"rotated transpose is routed only for the Stokes saddle point") if not self.consistent_jacobian and not self._residual_is_linear_in_unknown(): return (True, "implicit residual: Jacobian transpose for the state, symbolic " @@ -5407,130 +5476,531 @@ class SolverBaseClass(uw_object): ## Specific to dimensionality -class SNES_Scalar(SolverBaseClass): - r""" - General scalar equation solver using PETSc SNES. - - Solves the scalar conservation problem for unknown :math:`u`: - - .. math:: - - \nabla \cdot \mathbf{F}(u, \nabla u, \dot{u}, \nabla\dot{u}) - - f(u, \nabla u, \dot{u}, \nabla\dot{u}) = 0 - - where :math:`f` is a source term, :math:`\mathbf{F}` is a flux term relating - :math:`u` to its gradients :math:`\nabla u`, and :math:`\dot{u}` is the - Lagrangian time derivative. - - The unknown :math:`u` is a scalar mesh variable, and :math:`f`, :math:`\mathbf{F}` - are arbitrary sympy expressions of mesh coordinate variables. + def _solve_rotated_if_registered(self, zero_init_guess, verbose, picard, time): + """Run the rotated strong-constraint solve when a rotated free-slip + boundary or a split-fault contact is registered, and return True; return + False when neither is, so the caller continues on the standard SNES path. - This class is the base layer for building solvers that translate physical - conservation laws into this general mathematical form. + Shared by the saddle-point (Stokes) and vector (thin sheet) solvers: the + rotation, the Newton loop and the reaction recovery live in + ``utilities/rotated_bc.py`` and address the velocity field by name. + """ + cdef DM _time_dm + if not (self._rotated_freeslip_bcs or self._fault_contact_faults): + return False + # Rotated strong free-slip: delegate to the rotated_bc module (per-node DOF + # rotation + strong v_n=0 + reaction=sigma_nn). Handles the whole assemble/ + # solve/rotate-back/gauge-removal; stashes info for boundary_normal_traction. + # Run the same pre-solve preamble as the standard path so the pointwise + # functions see the DM time, the auxiliary vector, and updated constants + # (needed for problems whose coefficients live in auxiliary fields). This + # must precede the nonlinearity probe below so the trial assemblies see + # the correct coefficients. + if time is not None: + t_nd = self._nondimensional_time(time) + _time_dm = self.dm + UW_DMSetTime(_time_dm.dm, t_nd) + self.mesh.update_lvec() + self.dm.setAuxiliaryVec(self.mesh.lvec, None) + self._update_constants(record=True) - Parameters - ---------- - mesh : underworld3.discretisation.Mesh - The computational mesh. - u_Field : MeshVariable, optional - Pre-existing scalar field variable. If None, creates a new variable. - degree : int, default=2 - Polynomial degree for finite element discretization. - verbose : bool, default=False - Enable verbose solver output (monitors convergence). - DuDt : SemiLagrangian or Lagrangian, optional - Time derivative handler for the unknown (advection-diffusion problems). - DFDt : SemiLagrangian or Lagrangian, optional - Time derivative handler for flux (viscoelastic problems). + # guard() refuses rotated free-slip, but the BC can be added AFTER arming. + # Re-check here: this path never reaches the instrumentation, so an armed + # guard would be silently inert -- the exact state guard() exists to refuse. + if (getattr(self, "_instrumentation", None) is not None + and self._instrumentation.armed): + raise NotImplementedError( + "a wall-clock guard is armed but this solve takes the rotated " + "free-slip path, which runs its own Krylov loop outside self.snes " + "and cannot be bounded by it. Call unguard() first." + ) + # ONE path for linear and nonlinear models: the manual outer + # Newton/Picard loop that rotates F(u), J(u) and the strong + # v_n = u_n constraint every iteration. It honours zero_init_guess + # (warm start), the Picard warmup count, and the consistent_jacobian + # tangent (Picard / Newton / continuation); a linear model converges + # after its first increment, so no up-front nonlinearity probe is + # paid (the loop self-terminates; _residual_is_nonlinear survives as + # a lazy guard inside the picard+pure-Newton corner). + from underworld3.utilities.rotated_bc import solve_rotated_freeslip + self._rotated_freeslip_info = solve_rotated_freeslip( + self, self._rotated_freeslip_bcs, verbose=verbose, + zero_init_guess=zero_init_guess, picard=picard, + # An explicit `time=` reaches the kernels through petsc_t on + # the DM — invisible to every state counter and constant + # value, so it must veto the cached-operator fast path. + force_operator_refresh=time is not None) + # This path solves via ksp.solve on the rotated operator (not self.snes), + # so give it a report from the rotated result rather than leaving a stale one. + _rotated_report = self._capture_rotated_report(self._rotated_freeslip_info) + # The warm-start flag must follow the rotated solve's own verdict — the SNES + # was never run, so the generic reader would latch a stale reason. + self._record_convergence_status(converged=_rotated_report.converged) + return True - Attributes - ---------- - u : MeshVariable - The scalar unknown being solved for. - F0 : UWexpression - Source/force term :math:`f`. - F1 : UWexpression - Flux term :math:`\mathbf{F}`. - constitutive_model : Constitutive_Model - Material model defining flux-gradient relationship. - tolerance : float - Solver convergence tolerance. + def _reject_mixed_constraint_mechanisms(self, adding): + """Refuse a rotated constraint and a block constraint on one solver (#464). - Examples - -------- - >>> import underworld3 as uw - >>> mesh = uw.meshing.StructuredQuadBox(elementRes=(16, 16)) - >>> poisson = uw.systems.Poisson(mesh) - >>> poisson.constitutive_model = uw.constitutive_models.DiffusionModel - >>> poisson.constitutive_model.Parameters.diffusivity = 1.0 - >>> poisson.f = 1.0 # Source term - >>> poisson.add_dirichlet_bc(0.0, "Bottom") - >>> poisson.solve() + The rotated driver builds its own index-set fieldsplit over exactly two + fields (``rotated_bc._solve_rotated_iterative``), and ``build_rotation`` + addresses velocity and pressure by field number. A block constraint + registers a multiplier field of its own, and those DOFs are in neither + index set — the preconditioner would then be built over a strict subset + of the operator's rows, with nothing said about it. - See Also - -------- - SNES_Vector : For vector-valued equations. - SNES_Stokes_SaddlePt : For coupled velocity-pressure (Stokes) problems. - """ + The two are alternative ways to impose the same wall-normal condition, + so asking for both is a configuration error rather than a case to + support. Supporting it would need a third split for the multipliers and + a ``build_rotation`` that knows about them. - @timing.routine_timer_decorator - def __init__(self, - mesh : uw.discretisation.Mesh, - u_Field : uw.discretisation.MeshVariable = None, - degree: int = 2, - verbose = False, - DuDt : Union[uw.systems.ddt.SemiLagrangian, uw.systems.ddt.Lagrangian] = None, - DFDt : Union[uw.systems.ddt.SemiLagrangian, uw.systems.ddt.Lagrangian] = None, - ): + Parameters + ---------- + adding : str + Name of the method being called, so the message can say which + mechanism is already in place and which one was refused. + """ - super().__init__(mesh) + mechanisms = self._constraint_mechanisms() + rotated = mechanisms["rotated_freeslip"] + mechanisms["fault_contact"] + multipliers = mechanisms["multipliers"] - ## Keep track + if adding == "solve": + # The dispatch reads both lists, so it can only report the pair. + if not (rotated and multipliers): + return + raise RuntimeError( + f"solve(): this solver carries {len(rotated)} rotated " + f"(free-slip or fault contact) and {len(multipliers)} " + f"block-constraint boundary condition(s). The rotated solve " + f"splits velocity and pressure by field number and the " + f"multiplier fields lie outside that split, so the " + f"preconditioner would cover only part of the operator. Both " + f"impose the same wall-normal condition — use one of them " + f"(issue #464)." + ) - ## Todo: some validity checking on the size / type of u_Field supplied - if u_Field is None: - # A scalar unknown has one component. (MeshVariable already forced - # this: vtype=SCALAR overrides num_components, so the old - # num_components=mesh.dim here was ignored, never over-allocated — - # issue #367.) - self.Unknowns.u = uw.discretisation.MeshVariable( mesh=mesh, num_components=1, - varname="Us{}".format(SNES_Scalar._obj_count), - vtype=uw.VarType.SCALAR, degree=degree, ) + if adding == "add_constraint_bc": + if not rotated: + return + present, refused = len(rotated), "a block constraint" + present_kind = "rotated (free-slip or fault contact)" else: - self.Unknowns.u = u_Field - self.Unknowns.DuDt = DuDt - self.Unknowns.DFDt = DFDt - - self.verbose = verbose - self._tolerance = 1.0e-4 + if not multipliers: + return + present, refused = len(multipliers), "a rotated constraint" + present_kind = "block-constraint multiplier" - # Here we can set some defaults for this set of KSP / SNES solvers + raise RuntimeError( + f"{adding}(): this solver already carries {present} " + f"{present_kind} boundary condition(s), so it cannot also take " + f"{refused}. The rotated solve splits velocity and pressure by " + f"field number and a block constraint adds a multiplier field " + f"outside that split, so the preconditioner would cover only part " + f"of the operator. Both impose the same wall-normal condition — " + f"use one of them (issue #464)." + ) - ## FAST as possible for simple problems: - ## MG, + def add_rotated_freeslip_bc(self, conds=None, boundary=None, normal=None): + r"""Add STRONG free-slip (:math:`\mathbf{u}\cdot\hat{\mathbf n}=0`) by rotating + the boundary velocity DOFs into a per-node (normal, tangential) frame and + imposing the rotated normal component as an exact Dirichlet constraint. - # ROBUST and general GAMG, heavy-duty solvers in the suite + Unlike Nitsche/penalty free-slip (weak, leaks :math:`\mathcal O(10^{-3})`), + this enforces zero wall-normal flow to machine precision, and the constraint + **reaction** is the consistent boundary normal traction + :math:`\sigma_{nn}` (see :meth:`boundary_normal_traction`) with no + augmented-Lagrangian splitting. Correct on deformed / tilted / curved + boundaries because the normal is taken per node. - # Participate in the auto FMG/GAMG switch (see the `preconditioner` - # property). The pc/mg keys below are the GAMG default; - # _apply_preconditioner_options() upgrades this block to geometric FMG - # at build time when the mesh carries a refinement hierarchy, and - # otherwise leaves these defaults in place. - self._pc_option_prefix = "" + Parameters + ---------- + conds : float, scalar sympy expression, or None, optional + Prescribed wall-normal velocity datum :math:`\tilde u_n` (the SCALAR + component along the outward normal), in the canonical value-first BC + order (Style Charter, API conventions). Zero (or ``None``) is pure + free-slip :math:`\mathbf{u}\cdot\hat{\mathbf n}=0`. A non-zero value + — a number, a sympy expression of ``mesh.X``, or a scalar field read + such as ``h_dot.sym[0]`` — is imposed strongly (machine precision) as + :math:`\mathbf{u}\cdot\hat{\mathbf n}=\tilde u_n`, evaluated at the + boundary nodes at each ``solve()``. On an enclosed boundary the + datum must be discretely flux-free (:math:`\oint \tilde u_n = 0`) + for incompressibility. A corner/edge node shared between rotated + boundaries has no single normal and stays at the free-slip pinning + (datum ignored there). Vector/matrix values are rejected. + boundary : str + Boundary label to constrain. + normal : None or sympy 1×dim Matrix or array, optional + Per-node outward normal source. ``None`` (the default, and normally + the right choice) uses the geometric facet normal, measure-weighted + so that it is consistent with the straight-facet boundary integral + the assembler evaluates. A sympy ``1×dim`` matrix supplies an + analytic normal (``X/|X|`` on a spherical cap, a constant on a + planar face): exact for the TRUE surface, but the assembler still + integrates over the facets, so it keeps a consistency error that + grows with facet non-uniformity. Use it when the constraint must + follow the geometry rather than the mesh. A constant array is also + accepted. See ``docs/developer/subsystems/rotated-freeslip.md``. - self.petsc_options["snes_type"] = "newtonls" - self._push_managed_option("ksp_type", "gmres") - for key, value in multigrid_options.gamg_bundle().settings.items(): - self._push_managed_option(key, value) + Notes + ----- + A node shared by several rotated-free-slip boundaries (a box corner, a 3D + edge) is constrained on the whole span of its accumulated normals: a 3D + face frees two tangential directions, a 3D edge frees one (the edge + tangent), a corner is fully pinned. Registering delegates the solve to + :mod:`underworld3.utilities.rotated_bc`. - self.petsc_options["snes_rtol"] = 1.0e-4 + The legacy boundary-first call ``add_rotated_freeslip_bc(boundary, + normal)`` is detected conservatively (the first positional argument is + a string — the datum is never a string) and shimmed with one + DeprecationWarning: the string becomes ``boundary`` and a second + positional argument, if present, becomes ``normal``. + """ + self._reject_mixed_constraint_mechanisms("add_rotated_freeslip_bc") - if self.verbose == True: - self.petsc_options["ksp_monitor"] = None - self.petsc_options["snes_converged_reason"] = None - self.petsc_options["snes_monitor_short"] = None - else: - self.petsc_options.delValue("ksp_monitor") + if isinstance(conds, str): + # legacy boundary-first call: (boundary[, normal]) + if boundary is not None: + if normal is not None: + raise TypeError( + "add_rotated_freeslip_bc() received 'normal' twice " + "(positionally, legacy order, and as a keyword)") + normal = boundary + boundary = conds + conds = None + import warnings + warnings.warn( + "add_rotated_freeslip_bc(boundary, normal) is deprecated; " + "use add_rotated_freeslip_bc(conds, boundary, normal=...) " + "with conds=0 (or boundary=... by keyword)", + DeprecationWarning, stacklevel=2) + if not isinstance(boundary, str): + raise TypeError( + f"add_rotated_freeslip_bc() requires a boundary label string; " + f"got {type(boundary).__name__}") + if conds is not None: + if isinstance(conds, (sympy.MatrixBase, list, tuple, np.ndarray)): + raise TypeError( + "add_rotated_freeslip_bc(conds=...) takes the SCALAR " + "wall-normal datum u.n; got a vector/matrix value") + # Value comparison, not sympy's structural ==: Float(0.0) != Integer(0) + # structurally, but both are zero data (the free-slip member of the + # family). is_zero is True only when sympy can PROVE zero, so a + # symbolic datum (field read, expression) is correctly kept. + if sympy.sympify(conds).is_zero is not True: + self._rotated_freeslip_datum[boundary] = conds + self._rotated_freeslip_bcs.append((boundary, normal)) + self.is_setup = False + return + + def add_fault_bc(self, conds=0, boundary=None, normal=None): + r"""Interface condition on a split-node fault (value-first). + + ``boundary`` names a fault split by ``Mesh.add_fault`` (or + ``fault_split.split_fault``); the mesh carries its coincident DOF + pairing. The no-opening constraint :math:`[\mathbf v]\cdot\hat n = 0` + is always imposed strongly; ``conds`` sets the tangential law: + + * ``conds = 0`` — frictionless (perfectly slippery): zero shear + traction, the slip emerges (the stress-driven crack). + * ``conds`` > 0 — viscous interface :math:`\tau = \eta_f V` with + ``conds`` = :math:`\eta_f` (viscosity per unit length; the + zero-thickness limit of a band is :math:`\eta_f = \eta_{band}/w`, + where :math:`\eta_{band}` is the band's OWN weak-zone viscosity — + not the background's — and :math:`w` its width). ``conds`` large + removes the fault (only the JUMP is penalised — nothing becomes + rigid). + + ``normal`` optionally supplies the fault's smooth unit normal — + a sympy ``1×dim`` Matrix in ``mesh.X`` (same conventions as + :meth:`add_rotated_freeslip_bc`), the string ``"trace"`` (2-D: the + smoothed normal is built from the fault's own stored polyline — + the right choice for digitized traces with no analytic formula), + or a constant ``(dim,)`` array. Use it whenever the fault trace is + a SAMPLED SMOOTH CURVE: the default per-node normal averages the + adjacent facet normals, which zig-zags at the sampling kinks, and + the no-opening constraint then forbids smooth slip past each kink — + slip notches and normal-traction sawteeth that GROW under mesh + refinement. The smooth normal restores the smooth curve's + mechanics on the same polyline mesh. On a straight fault the + default is already exact, and a deliberately KINKED fault should + NOT be smoothed — there the kink response is the physics. + + The solve then takes the rotated strong-constraint path + (``utilities/rotated_bc.py`` with the pair blocks of + ``utilities/fault_contact.py``); ``guard()`` / + ``estimate_difficulty()`` are unavailable, as for rotated free-slip. + Slip and leak per coincident pair afterwards: + ``fault_contact.fault_slip(solver, boundary, + solver._rotated_freeslip_info)``. + """ + from underworld3.utilities import fault_contact + + self._reject_mixed_constraint_mechanisms("add_fault_bc") + + if not isinstance(boundary, str): + raise TypeError( + f"add_fault_bc() requires the fault's boundary name string; " + f"got {type(boundary).__name__}") + eta_f = float(conds) + if eta_f == 0.0: + fault_contact.add_frictionless_fault_bc(self, boundary, + normal=normal) + else: + fault_contact.add_viscous_fault_bc(self, eta_f, boundary, + normal=normal) + self.is_setup = False + return + + def _residual_is_nonlinear(self, tol=1e-8): + r"""True if the Stokes residual is nonlinear in the unknowns :math:`(v, p)` + — i.e. the assembled Jacobian depends on the solution, so Newton / Picard + iteration is required. + + Detected by a NUMERICAL probe: assemble the Jacobian at two distinct + velocity states and compare. A symbolic test on ``F1.sym`` cannot see the + nonlinearity — the effective viscosity's strain-rate (velocity-gradient) + dependence is carried as a JIT-substituted *placeholder* symbol + (``\dot\varepsilon_{II}``) in the flux, decoupled from the gradient + ``L`` in the symbolic form, so it only becomes visible once the operator + is assembled at a concrete iterate. Constant- or temperature-dependent + viscosity ⇒ ``J`` independent of ``v`` ⇒ the two assemblies are + bit-identical ⇒ linear. + + Used as a LAZY guard in the rotated-free-slip loop's picard + pure-Newton + corner (a Picard warmup is meaningless for a linear residual and impossible + for pure Newton) — the loop itself needs no up-front probe, it + self-terminates. The caller must have run the pre-solve preamble + (auxiliary vector + constants) so the assembly sees the correct + coefficients. + """ + snes = self.snes + dm = self.dm + snes.setUp() + J = snes.getJacobian()[0] + U1 = dm.getGlobalVec(); U2 = dm.getGlobalVec() + # two distinct smooth, bounded states with non-zero velocity gradients + # (a linear operator gives the SAME J for both; only a solution-dependent + # viscosity makes them differ). Ownership-relative index keeps it + # partition-independent enough for the norm comparison. + rs, re = U1.getOwnershipRange() + idx = np.arange(rs, re, dtype=float) + # write IN PLACE into each vec's PETSc-owned array (getArray returns a writable + # view). setArray with a fresh numpy temporary on a POOLED getGlobalVec is risky: + # its storage lifetime/pool reuse is not guaranteed for the later computeJacobian. + a1 = U1.getArray(); a1[:] = 0.1 * np.sin(0.7 * idx + 0.3) + a2 = U2.getArray(); a2[:] = 0.1 * np.sin(1.3 * idx + 1.1) + J1 = J.copy(); J2 = J.copy() + try: + snes.computeJacobian(U1, J1) + snes.computeJacobian(U2, J2) + J2.axpy(-1.0, J1) # J2 <- J(U2) - J(U1) + rel = J2.norm() / (J1.norm() + 1e-300) + finally: + dm.restoreGlobalVec(U1); dm.restoreGlobalVec(U2) + J1.destroy(); J2.destroy() + return rel > tol + + def boundary_normal_traction(self, boundary, mass="auto"): + r"""Return the boundary normal traction :math:`\sigma_{nn}` on a + rotated-free-slip ``boundary`` as the constraint reaction from the last + solve — the smooth, bounded quantity used for dynamic topography + (:math:`h_\infty=-(\sigma_{nn}-\overline{\sigma_{nn}})/\rho g`). Requires a + prior :meth:`add_rotated_freeslip_bc` on ``boundary`` and a completed + :meth:`solve`. + + ``mass="auto"`` (default) uses lumped recovery for 2D traces and 3D P1 + triangles, and MIDPOINT-RECONSTRUCTED recovery for 3D P2 triangles (the + consistent solve, keeping its superconvergent midpoints, with vertices rebuilt + from them). ``"p1"`` selects the simpler P1-projected recovery; explicit + ``"lumped"`` and ``"consistent"`` remain available where mathematically valid; + ``"consistent"`` is pointwise-exact on a P2 trace in exact arithmetic but + its zero vertex row sums amplify any load perturbation at VERTICES by O(1), + independently of h (#404, measured in #633). Three-dimensional recovery + currently supports triangular P1/P2 traces only (#637). + + .. warning:: + On CURVED boundaries, P2 vertex values of :math:`\sigma_{nn}` converge + only slowly (the vertex basis has zero surface mean, so vertex reactions + carry only the O(h) facet-geometry error); edge-midpoint values are + superconvergent. Pointwise consumers on curved boundaries should use + midpoint or integral/fitted quantities (issue #414).""" + if self._rotated_freeslip_info is None: + raise RuntimeError( + "boundary_normal_traction requires a completed rotated-free-slip solve.") + from underworld3.utilities.rotated_bc import boundary_normal_traction as _bnt + return _bnt(self, boundary, self._rotated_freeslip_info, mass=mass) + + def dynamic_topography(self, boundary, field, buoyancy_scale=1.0, mass="auto"): + r"""Write the dynamic topography + :math:`h = -(\sigma_{nn}-\overline{\sigma_{nn}})/(\Delta\rho\,g)` on a + rotated-free-slip ``boundary`` onto a scalar MeshVariable ``field``, from the + constraint reaction of the last solve. This is the hand-off to the free-surface + machinery — the 3-number topography integrator drives node motion from a surface + field, so create a scalar ``field`` (P1 recommended, continuous) up front and + pass it here after each :meth:`solve`; its boundary nodes are filled and the + interior left untouched. + + ``buoyancy_scale`` is :math:`\Delta\rho\,g` (traction → length). + ``mass="auto"`` selects lumped recovery where valid and + midpoint-reconstructed recovery for 3D P2 triangles. Requires a prior + :meth:`add_rotated_freeslip_bc` on ``boundary`` and a completed :meth:`solve`. + + .. warning:: + On CURVED boundaries (annulus/spherical free surfaces), the P2 VERTEX + values written into ``field`` converge only slowly; edge-midpoint values + are superconvergent. Downstream pointwise use of curved-boundary + topography should rely on midpoint/fitted quantities (issue #414).""" + if self._rotated_freeslip_info is None: + raise RuntimeError( + "dynamic_topography requires a completed rotated-free-slip solve.") + from underworld3.utilities.rotated_bc import dynamic_topography_field as _dtf + return _dtf(self, boundary, self._rotated_freeslip_info, field, + buoyancy_scale=buoyancy_scale, mass=mass) + + def boundary_normal_traction_integral(self, boundary, fn, remove_mean=True): + r"""Return the boundary integral of ``sigma_nn * fn`` directly from the + rotated-free-slip constraint reaction. + + With ``remove_mean=True`` (default), the constant normal-traction gauge + is removed before projection. Unlike pointwise + :meth:`boundary_normal_traction`, this weak projection does not recover + nodal traction values or gather a global boundary mesh. It is therefore + suitable for harmonic and integral diagnostics on curved P2 boundaries, + whose recovered vertex values converge slowly (issue #414). + """ + if self._rotated_freeslip_info is None: + raise RuntimeError( + "boundary_normal_traction_integral requires a completed " + "rotated-free-slip solve.") + from underworld3.utilities.rotated_bc import boundary_normal_traction_integral as _bnti + return _bnti(self, boundary, self._rotated_freeslip_info, fn, + remove_mean=remove_mean) + + +class SNES_Scalar(SolverBaseClass): + r""" + General scalar equation solver using PETSc SNES. + + Solves the scalar conservation problem for unknown :math:`u`: + + .. math:: + + \nabla \cdot \mathbf{F}(u, \nabla u, \dot{u}, \nabla\dot{u}) + - f(u, \nabla u, \dot{u}, \nabla\dot{u}) = 0 + + where :math:`f` is a source term, :math:`\mathbf{F}` is a flux term relating + :math:`u` to its gradients :math:`\nabla u`, and :math:`\dot{u}` is the + Lagrangian time derivative. + + The unknown :math:`u` is a scalar mesh variable, and :math:`f`, :math:`\mathbf{F}` + are arbitrary sympy expressions of mesh coordinate variables. + + This class is the base layer for building solvers that translate physical + conservation laws into this general mathematical form. + + Parameters + ---------- + mesh : underworld3.discretisation.Mesh + The computational mesh. + u_Field : MeshVariable, optional + Pre-existing scalar field variable. If None, creates a new variable. + degree : int, default=2 + Polynomial degree for finite element discretization. + verbose : bool, default=False + Enable verbose solver output (monitors convergence). + DuDt : SemiLagrangian or Lagrangian, optional + Time derivative handler for the unknown (advection-diffusion problems). + DFDt : SemiLagrangian or Lagrangian, optional + Time derivative handler for flux (viscoelastic problems). + + Attributes + ---------- + u : MeshVariable + The scalar unknown being solved for. + F0 : UWexpression + Source/force term :math:`f`. + F1 : UWexpression + Flux term :math:`\mathbf{F}`. + constitutive_model : Constitutive_Model + Material model defining flux-gradient relationship. + tolerance : float + Solver convergence tolerance. + + Examples + -------- + >>> import underworld3 as uw + >>> mesh = uw.meshing.StructuredQuadBox(elementRes=(16, 16)) + >>> poisson = uw.systems.Poisson(mesh) + >>> poisson.constitutive_model = uw.constitutive_models.DiffusionModel + >>> poisson.constitutive_model.Parameters.diffusivity = 1.0 + >>> poisson.f = 1.0 # Source term + >>> poisson.add_dirichlet_bc(0.0, "Bottom") + >>> poisson.solve() + + See Also + -------- + SNES_Vector : For vector-valued equations. + SNES_Stokes_SaddlePt : For coupled velocity-pressure (Stokes) problems. + """ + + @timing.routine_timer_decorator + def __init__(self, + mesh : uw.discretisation.Mesh, + u_Field : uw.discretisation.MeshVariable = None, + degree: int = 2, + verbose = False, + DuDt : Union[uw.systems.ddt.SemiLagrangian, uw.systems.ddt.Lagrangian] = None, + DFDt : Union[uw.systems.ddt.SemiLagrangian, uw.systems.ddt.Lagrangian] = None, + ): + + super().__init__(mesh) + + ## Keep track + + ## Todo: some validity checking on the size / type of u_Field supplied + if u_Field is None: + # A scalar unknown has one component. (MeshVariable already forced + # this: vtype=SCALAR overrides num_components, so the old + # num_components=mesh.dim here was ignored, never over-allocated — + # issue #367.) + self.Unknowns.u = uw.discretisation.MeshVariable( mesh=mesh, num_components=1, + varname="Us{}".format(SNES_Scalar._obj_count), + vtype=uw.VarType.SCALAR, degree=degree, ) + else: + self.Unknowns.u = u_Field + self.Unknowns.DuDt = DuDt + self.Unknowns.DFDt = DFDt + + self.verbose = verbose + self._tolerance = 1.0e-4 + + # Here we can set some defaults for this set of KSP / SNES solvers + + ## FAST as possible for simple problems: + ## MG, + + # ROBUST and general GAMG, heavy-duty solvers in the suite + + # Participate in the auto FMG/GAMG switch (see the `preconditioner` + # property). The pc/mg keys below are the GAMG default; + # _apply_preconditioner_options() upgrades this block to geometric FMG + # at build time when the mesh carries a refinement hierarchy, and + # otherwise leaves these defaults in place. + self._pc_option_prefix = "" + + self.petsc_options["snes_type"] = "newtonls" + self._push_managed_option("ksp_type", "gmres") + for key, value in multigrid_options.gamg_bundle().settings.items(): + self._push_managed_option(key, value) + + self.petsc_options["snes_rtol"] = 1.0e-4 + + if self.verbose == True: + self.petsc_options["ksp_monitor"] = None + self.petsc_options["snes_converged_reason"] = None + self.petsc_options["snes_monitor_short"] = None + else: + self.petsc_options.delValue("ksp_monitor") self.petsc_options.delValue("snes_monitor") self.petsc_options.delValue("snes_monitor_short") self.petsc_options.delValue("snes_converged_reason") @@ -6433,9 +6903,11 @@ class SNES_Vector(SolverBaseClass): self.petsc_options["snes_type"] = "newtonls" self.petsc_options["ksp_rtol"] = 1.0e-3 self._push_managed_option("ksp_type", "gmres") - # A vector unknown: mesh.dim components per node, which GAMG has to be - # told. See multigrid_options._gamg_settings. - self._pc_block_size = self.mesh.dim + # A vector unknown: one component per EMBEDDING dimension (cdim) at each + # node, which GAMG has to be told. On a volume mesh cdim == dim; on a + # manifold (dim < cdim) the vector lives in the embedding space. See + # multigrid_options._gamg_settings. + self._pc_block_size = self.mesh.cdim for key, value in multigrid_options.gamg_bundle( block_size=self._pc_block_size).settings.items(): self._push_managed_option(key, value) @@ -6457,7 +6929,7 @@ class SNES_Vector(SolverBaseClass): # here we only auto-create the default unknown when none was supplied) if u_Field is None: self.Unknowns.u = uw.discretisation.MeshVariable( mesh=mesh, - num_components=mesh.dim, varname="Uv{}".format(SNES_Vector._obj_count), + num_components=mesh.cdim, varname="Uv{}".format(SNES_Vector._obj_count), vtype=uw.VarType.VECTOR, degree=degree ) @@ -6749,7 +7221,11 @@ class SNES_Vector(SolverBaseClass): options.setValue("private_{}_u_petscdualspace_lagrange_continuity".format(self.petsc_options_prefix), self.u.continuous) options.setValue("private_{}_u_petscdualspace_lagrange_node_endpoints".format(self.petsc_options_prefix), False) - self.petsc_fe_u = PETSc.FE().createDefault(mesh.dim, mesh.dim, mesh.isSimplex, mesh.qdegree, "private_{}_u_".format(self.petsc_options_prefix), PETSc.COMM_SELF) + # The FE carries cdim components: the unknown MeshVariable is sized by the + # embedding dimension, and the residual / Jacobian blocks below are too. + # (A dim-component FE against a cdim-component variable was the manifold + # vector-FE gap: a Vec-size mismatch on SphericalManifold.) + self.petsc_fe_u = PETSc.FE().createDefault(mesh.dim, mesh.cdim, mesh.isSimplex, mesh.qdegree, "private_{}_u_".format(self.petsc_options_prefix), PETSc.COMM_SELF) self.petsc_fe_u_id = self.dm.getNumFields() self.dm.setField( self.petsc_fe_u_id, self.petsc_fe_u ) self.petsc_fe_u.setName("_vector_unknown_") @@ -6836,10 +7312,23 @@ class SNES_Vector(SolverBaseClass): self.essential_bcs[index] = self.essential_bcs[index]._replace(PETScID=bc, boundary_label_val=value) + # The coarse levels take the fields first: the decomposition below builds + # a sub-DM, which recurses into the coarse DMs (measured: PETSc error 62, + # "number of requested fields 1 greater than number of DM fields 0", on + # any mesh with refinement levels). for coarse_dm in self.dm_hierarchy: self.dm.copyFields(coarse_dm) self.dm.copyDS(coarse_dm) + # The single field, registered AFTER the boundaries are on the DM (the + # decomposition finalises the DM, and DMAddBoundary refuses afterwards), + # under the name the rotated solve and the copy-back address it by — + # the saddle solver names its fields the same way. The index set is the + # whole vector; the sub-DM carries its local/global scatter. + names, isets, dms = self.dm.createFieldDecomposition() + self.fields = {"velocity": self.u} + self._subdict = {"velocity": (isets[0], dms[0])} + self.is_setup = False return @@ -7300,6 +7789,8 @@ class SNES_Vector(SolverBaseClass): self._build(verbose, debug, debug_name) if self._picard_blend and self.consistent_jacobian is True: self._set_newton_alpha(1.0) # Newton unless the warm-up says otherwise + if self._solve_rotated_if_registered(zero_init_guess, verbose, 0, None): + return gvec = self.dm.getGlobalVec() @@ -8139,6 +8630,10 @@ class SNES_Stokes_SaddlePt(SolverBaseClass): SNES_Vector : For vector-valued equations without constraints. """ + # adjoint_solve routes a rotated solve through solve_rotated_adjoint + _adjoint_routes_rotated_constraints = True + + class _Unknowns(SolverBaseClass._Unknowns): '''Extend the unknowns with the constraint parameter''' @@ -8152,590 +8647,233 @@ class SNES_Stokes_SaddlePt(SolverBaseClass): return - @property - def p(inner_self): - return inner_self._p - - @p.setter - def p(inner_self, new_p): - inner_self._p = new_p - inner_self._owning_solver.is_setup = False - return - - - @timing.routine_timer_decorator - def __init__(self, - mesh : underworld3.discretisation.Mesh, - velocityField : Optional[underworld3.discretisation.MeshVariable] = None, - pressureField : Optional[underworld3.discretisation.MeshVariable] = None, - degree : Optional[int] = 2, - p_continuous : Optional[bool] = True, - verbose : Optional[bool] =False, - DuDt : Union[uw.systems.ddt.SemiLagrangian, uw.systems.ddt.Lagrangian] = None, - DFDt : Union[uw.systems.ddt.SemiLagrangian, uw.systems.ddt.Lagrangian] = None, - ): - - - super().__init__(mesh) - - self.verbose = verbose - self.dm = None - - self.Unknowns.u = velocityField - self.Unknowns.p = pressureField - self.Unknowns.DuDt = DuDt - self.Unknowns.DFDt = DFDt - - # Optional saddle-point Lagrange multiplier fields (block-constrained - # Stokes). Each entry is a full-domain scalar MeshVariable registered - # as an extra DM field (id 2, 3, ...), grouped with pressure into the - # Schur split. EMPTY for ordinary Stokes — every multiplier-aware code - # path below is guarded by `if self._multipliers:` so that with no - # multiplier the emitted DS is bit-identical to the 2-field solver. - # _multiplier_screening[k] is the interior screening coefficient - # (eps M de-singularises the interior h block); see - # docs/developer/design/CONSTRAINED_FREESLIP_MULTIPLIER.md. - self._multipliers = [] - self._multiplier_screening = [] - self._block_constraint_bcs = [] - # Rotated strong free-slip BCs: [(boundary, normal), ...]. Registered via - # add_rotated_freeslip_bc; when non-empty, solve() delegates to - # underworld3.utilities.rotated_bc (per-node DOF rotation + strong v_n = u_n - # + reaction = sigma_nn). Empty by default → the solve path is unchanged. - # _rotated_freeslip_datum maps boundary → prescribed wall-normal datum - # (the non-zero `conds` of add_rotated_freeslip_bc; absent ⇒ u.n = 0). - self._rotated_freeslip_bcs = [] - self._rotated_freeslip_datum = {} - self._rotated_freeslip_info = None - # Cross-solve rotated workspace (rotated_bc cache, issue #417): - # populated/keyed/invalidated entirely inside solve_rotated_freeslip; - # torn down here by _reset_rotated_solver_cache on any DM rebuild. - self._rotated_linear_cache = None - # Split-fault interface conditions (add_fault_bc): fault names whose - # coincident DOF pairs carry a contact (the laws themselves live in - # _fault_interface_laws, set lazily by utilities/fault_contact.py). - # Non-empty => solve() takes the rotated path, where fault_contact - # supplies the pair blocks and the interface operator. - self._fault_contact_faults = [] - # Give the Lagrange-multiplier (lambda) block its own viscosity-scaled - # Schur preconditioner. The constraint Schur complement S_lambda = C A^-1 C^T - # scales as 1/mu (since A ~ mu K), exactly like the pressure Schur S_p ~ mu^-1 M_p - # and independent of the augmentation r. When True, the lambda block's - # PRECONDITIONER mass (Pmat only) uses 1/mu (= saddle_preconditioner) while the - # true operator (Amat) (lambda,lambda) block stays = eps (exact Newton). This is - # the boundary-trace analog of _pp_G0 = 1/mu on pressure, and decouples - # convergence from r so r can shrink -> cleaner recovered lambda (= topography). - # When on, the lambda block reuses pressure's already-compiled _pp_G0 = 1/mu - # for its preconditioner block (same scaling, no extra JIT term). On uniform mu - # (fieldsplit) it is bit-identical; on moderate contrast it cracks the wall and - # makes the augmentation optional. Default OFF (opt-in): a monolithic lu solve - # factorizes the Pmat (pc_use_amat is a no-op here), so this Pmat term is NOT - # inert for direct solves — flipping the default on regresses the direct-lu - # constraint enforcement. See the design note - # docs/developer/design/CONSTRAINED_FREESLIP_MULTIPLIER.md (the warning on the - # coupled vs sub-block preconditioner placement). - self._multiplier_schur_pc = False - # Pin interior (off-boundary) multiplier DOFs to 0 so the solved [p,h] - # block carries only the boundary trace (~√ndof instead of ~ndof/3 DOFs); - # the boundary trace is the only physical part. Done by constraining the - # interior h DOFs directly in the fine local PetscSection - # (_constrain_interior_multipliers_in_section) — lossless (correct - # constraint-boundary closure) and refined/FMG-safe (no DMAddBoundary - # ordering restriction). Default ON: it is both a correctness-neutral DOF - # reduction AND a conditioning win — leaving the interior h DOFs in place - # keeps the near-singular ε-screened interior block in the solved system, - # which an iterative ([p,h] Schur) solve handles poorly. See that method's - # docstring for why disabling it is ill-conditioned, not "more accurate". - self._reduce_interior_multiplier = True - - self._degree = degree - - ## Any problem with U,P, just define our own - if velocityField == None or pressureField == None: - - # Note, ensure names are unique for each solver type - i = SNES_Stokes_SaddlePt._obj_count - self.Unknowns.u = uw.discretisation.MeshVariable(f"V{i}", self.mesh, self.mesh.dim, degree=degree, varsymbol=rf"{{\mathbf{{u}}^{{[{i}]}} }}" ) - self.Unknowns.p = uw.discretisation.MeshVariable(f"P{i}", self.mesh, 1, degree=degree-1, continuous=p_continuous, varsymbol=rf"{{\mathbf{{p}}^{{[{i}]}} }}") - - if self.verbose and uw.mpi.rank == 0: - print(f"SNES Saddle Point Solver {self.instance_number}: creating new mesh variables for unknowns") - - # I expect the following to break for anyone who wants to name their solver _stokes__ etc etc (LM) - - # options = PETSc.Options() - # options["dm_adaptor"]= "pragmatic" - # Here we can set some defaults for this set of KSP / SNES solvers - - if self.verbose == True: - self.petsc_options["ksp_monitor"] = None - self.petsc_options["snes_converged_reason"] = None - self.petsc_options["snes_monitor_short"] = None - else: - self.petsc_options.delValue("ksp_monitor") - self.petsc_options.delValue("snes_monitor") - self.petsc_options.delValue("snes_monitor_short") - self.petsc_options.delValue("snes_converged_reason") - - self._tolerance = 1.0e-4 - self._strategy = "default" - # the velocity split's Krylov restart, beside the outer one set in the base - # constructor (see there): the yielded layer's mechanism mode lives in this block - self._push_managed_option("fieldsplit_velocity_ksp_gmres_restart", _DEFAULT_KRYLOV_RESTART) - - # Owned-option latch state (see _resolve_owned_option): the value THIS - # solver last pushed per key, and any user value latched per key. - # Ownership is RECORDED, never inferred — the same doctrine as - # _managed_pc_options. - self._owned_option_pushes = {} - self._owned_option_user = {} - - # Participate in the auto FMG/GAMG switch on the velocity fieldsplit - # block (see the `preconditioner` property). The velocity pc/mg keys - # set below are the GAMG default; _apply_preconditioner_options() - # upgrades the velocity block to geometric FMG at build time when the - # mesh carries a refinement hierarchy, and otherwise leaves them. - self._pc_option_prefix = "fieldsplit_velocity_" - - self.petsc_options["snes_rtol"] = self._tolerance - self.petsc_options["snes_ksp_ew"] = None - self.petsc_options["snes_ksp_ew_version"] = 3 - - # The OUTER Krylov must be FLEXIBLE: both sub-blocks below are - # themselves Krylov solves run to a tolerance, so the operator the - # outer method applies differs from one outer iteration to the next - # and plain GMRES's residual recurrence does not hold — the - # velocity-block FGMRES reasoning (#147), one level up. PETSc's - # default is `gmres`; invisible on easy problems (isoviscous SolKz - # converges in two outer iterations), ruinous on hard ones: 14,400 - # inner iterations / ~540 s on an 85k-cell contrast problem for both - # velocity preconditioners (#624), and on the Spiegelman notch at - # refinement 3, 983 velocity iterations per step and - # DIVERGED_LINEAR_SOLVE against 58 for fgmres — raising the velocity - # cap changes nothing (byte-identical residuals), so the failure is - # inconsistency, not iteration count (#576). Managed, so an explicit - # user ksp_type still wins. - self._push_managed_option("ksp_type", "fgmres") - - self.petsc_options["pc_type"] = "fieldsplit" - self.petsc_options["pc_fieldsplit_type"] = "schur" - self.petsc_options["pc_fieldsplit_schur_fact_type"] = "full" # diag is an alternative (quick/dirty) - self.petsc_options["pc_fieldsplit_schur_precondition"] = "a11" # despite what the docs say for saddle points - - self.petsc_options["pc_fieldsplit_diag_use_amat"] = None - self.petsc_options["pc_fieldsplit_off_diag_use_amat"] = None - # self.petsc_options["pc_use_amat"] = None # Using this puts more pressure on the inner solve - - p_name = "pressure" # pressureField.clean_name - v_name = "velocity" # velocityField.clean_name - - # Pressure subsolve — flexible GMRES + GASM. (FGMRES is right- - # preconditioned by construction; no need to set pc_side explicitly.) - self.petsc_options[f"fieldsplit_{p_name}_ksp_type"] = "fgmres" - self.petsc_options[f"fieldsplit_{p_name}_ksp_max_it"] = 200 - self.petsc_options[f"fieldsplit_{p_name}_ksp_rtol"] = self._tolerance - self.petsc_options[f"fieldsplit_{p_name}_pc_type"] = "gasm" - # self.petsc_options[f"fieldsplit_{p_name}_pc_gasm_type"] = "basic" - - ## may be more robust but usually slower - # self.petsc_options[f"fieldsplit_{p_name}_ksp_type"] = "fgmres" - # self.petsc_options[f"fieldsplit_{p_name}_ksp_rtol"] = self._tolerance * 0.1 - # self.petsc_options[f"fieldsplit_{p_name}_pc_type"] = "gamg" - # self.petsc_options[f"fieldsplit_{p_name}_pc_gamg_type"] = "agg" - # self.petsc_options[f"fieldsplit_{p_name}_pc_gamg_repartition"] = True - - # Velocity subsolve — flexible GMRES + GAMG. - # - # FGMRES (not CG/FCG) is the default to remain robust under non-stationary - # preconditioning and weakly-indefinite coarse operators. The mg_levels - # KSP is bounded but variable-iteration (mg_levels_ksp_converged_maxits) - # so the GAMG application is non-linear; CG/FCG's residual recurrence - # cannot accommodate this, and at scale (large parallel partitions, sharp - # internal sources, free-slip Nitsche) PETSc reports DIVERGED_PC_FAILED - # / indefinite matrix or GMRES residual-recursion mismatch. See issue - # #147 for the spherical-Kramer benchmark on Gadi that drove this change; - # gthyagi's validated FGMRES configuration completed at np=144, - # cellsize=1/32 where CG/FCG and standard GMRES both failed. - self.petsc_options[f"fieldsplit_{v_name}_ksp_type"] = "fgmres" - self.petsc_options[f"fieldsplit_{v_name}_ksp_max_it"] = 200 - self.petsc_options[f"fieldsplit_{v_name}_ksp_rtol"] = self._tolerance * 0.1 - # The velocity field carries mesh.dim components per node; say so, or - # GAMG aggregates scalars. See multigrid_options._gamg_settings. - self._pc_block_size = self.mesh.dim - for key, value in multigrid_options.gamg_bundle( - block_size=self._pc_block_size).settings.items(): - self._push_managed_option(f"fieldsplit_{v_name}_{key}", value) - - # Create this dict - self.fields = {} - self.fields[p_name] = self.p - self.fields[v_name] = self.u - - # Some other setup - - self.mesh._equation_systems_register.append(self) - self._rebuild_after_mesh_update = self._build - - self.essential_bcs = [] - - self.natural_bcs = [] - self.bcs = self.essential_bcs - self.boundary_conditions = False - self._saddle_preconditioner = None - self._petsc_use_pressure_nullspace = False - self._petsc_velocity_nullspace_basis = () - self._stokes_nullspace = None - self._stokes_nullspace_basis = () - self._velocity_rotation_nullspace = None - - # Construct strainrate tensor for future usage. - # Grab gradients, and let's switch out to sympy.Matrix notation - # immediately as it is probably cleaner for this. - N = mesh.N + @property + def p(inner_self): + return inner_self._p - ## sympy.Matrix - gradient tensors - self._G = self.p.sym.jacobian(self.mesh.CoordinateSystem.N) + @p.setter + def p(inner_self, new_p): + inner_self._p = new_p + inner_self._owning_solver.is_setup = False + return - # this attrib records if we need to re-setup - self.is_setup = False - def _reject_mixed_constraint_mechanisms(self, adding): - """Refuse a rotated constraint and a block constraint on one solver (#464). + @timing.routine_timer_decorator + def __init__(self, + mesh : underworld3.discretisation.Mesh, + velocityField : Optional[underworld3.discretisation.MeshVariable] = None, + pressureField : Optional[underworld3.discretisation.MeshVariable] = None, + degree : Optional[int] = 2, + p_continuous : Optional[bool] = True, + verbose : Optional[bool] =False, + DuDt : Union[uw.systems.ddt.SemiLagrangian, uw.systems.ddt.Lagrangian] = None, + DFDt : Union[uw.systems.ddt.SemiLagrangian, uw.systems.ddt.Lagrangian] = None, + ): - The rotated driver builds its own index-set fieldsplit over exactly two - fields (``rotated_bc._solve_rotated_iterative``), and ``build_rotation`` - addresses velocity and pressure by field number. A block constraint - registers a multiplier field of its own, and those DOFs are in neither - index set — the preconditioner would then be built over a strict subset - of the operator's rows, with nothing said about it. - The two are alternative ways to impose the same wall-normal condition, - so asking for both is a configuration error rather than a case to - support. Supporting it would need a third split for the multipliers and - a ``build_rotation`` that knows about them. + super().__init__(mesh) - Parameters - ---------- - adding : str - Name of the method being called, so the message can say which - mechanism is already in place and which one was refused. - """ + self.verbose = verbose + self.dm = None - mechanisms = self._constraint_mechanisms() - rotated = mechanisms["rotated_freeslip"] + mechanisms["fault_contact"] - multipliers = mechanisms["multipliers"] + self.Unknowns.u = velocityField + self.Unknowns.p = pressureField + self.Unknowns.DuDt = DuDt + self.Unknowns.DFDt = DFDt - if adding == "solve": - # The dispatch reads both lists, so it can only report the pair. - if not (rotated and multipliers): - return - raise RuntimeError( - f"solve(): this solver carries {len(rotated)} rotated " - f"(free-slip or fault contact) and {len(multipliers)} " - f"block-constraint boundary condition(s). The rotated solve " - f"splits velocity and pressure by field number and the " - f"multiplier fields lie outside that split, so the " - f"preconditioner would cover only part of the operator. Both " - f"impose the same wall-normal condition — use one of them " - f"(issue #464)." - ) + # Optional saddle-point Lagrange multiplier fields (block-constrained + # Stokes). Each entry is a full-domain scalar MeshVariable registered + # as an extra DM field (id 2, 3, ...), grouped with pressure into the + # Schur split. EMPTY for ordinary Stokes — every multiplier-aware code + # path below is guarded by `if self._multipliers:` so that with no + # multiplier the emitted DS is bit-identical to the 2-field solver. + # _multiplier_screening[k] is the interior screening coefficient + # (eps M de-singularises the interior h block); see + # docs/developer/design/CONSTRAINED_FREESLIP_MULTIPLIER.md. + self._multipliers = [] + self._multiplier_screening = [] + self._block_constraint_bcs = [] + # Give the Lagrange-multiplier (lambda) block its own viscosity-scaled + # Schur preconditioner. The constraint Schur complement S_lambda = C A^-1 C^T + # scales as 1/mu (since A ~ mu K), exactly like the pressure Schur S_p ~ mu^-1 M_p + # and independent of the augmentation r. When True, the lambda block's + # PRECONDITIONER mass (Pmat only) uses 1/mu (= saddle_preconditioner) while the + # true operator (Amat) (lambda,lambda) block stays = eps (exact Newton). This is + # the boundary-trace analog of _pp_G0 = 1/mu on pressure, and decouples + # convergence from r so r can shrink -> cleaner recovered lambda (= topography). + # When on, the lambda block reuses pressure's already-compiled _pp_G0 = 1/mu + # for its preconditioner block (same scaling, no extra JIT term). On uniform mu + # (fieldsplit) it is bit-identical; on moderate contrast it cracks the wall and + # makes the augmentation optional. Default OFF (opt-in): a monolithic lu solve + # factorizes the Pmat (pc_use_amat is a no-op here), so this Pmat term is NOT + # inert for direct solves — flipping the default on regresses the direct-lu + # constraint enforcement. See the design note + # docs/developer/design/CONSTRAINED_FREESLIP_MULTIPLIER.md (the warning on the + # coupled vs sub-block preconditioner placement). + self._multiplier_schur_pc = False + # Pin interior (off-boundary) multiplier DOFs to 0 so the solved [p,h] + # block carries only the boundary trace (~√ndof instead of ~ndof/3 DOFs); + # the boundary trace is the only physical part. Done by constraining the + # interior h DOFs directly in the fine local PetscSection + # (_constrain_interior_multipliers_in_section) — lossless (correct + # constraint-boundary closure) and refined/FMG-safe (no DMAddBoundary + # ordering restriction). Default ON: it is both a correctness-neutral DOF + # reduction AND a conditioning win — leaving the interior h DOFs in place + # keeps the near-singular ε-screened interior block in the solved system, + # which an iterative ([p,h] Schur) solve handles poorly. See that method's + # docstring for why disabling it is ill-conditioned, not "more accurate". + self._reduce_interior_multiplier = True - if adding == "add_constraint_bc": - if not rotated: - return - present, refused = len(rotated), "a block constraint" - present_kind = "rotated (free-slip or fault contact)" - else: - if not multipliers: - return - present, refused = len(multipliers), "a rotated constraint" - present_kind = "block-constraint multiplier" + self._degree = degree - raise RuntimeError( - f"{adding}(): this solver already carries {present} " - f"{present_kind} boundary condition(s), so it cannot also take " - f"{refused}. The rotated solve splits velocity and pressure by " - f"field number and a block constraint adds a multiplier field " - f"outside that split, so the preconditioner would cover only part " - f"of the operator. Both impose the same wall-normal condition — " - f"use one of them (issue #464)." - ) + ## Any problem with U,P, just define our own + if velocityField == None or pressureField == None: - def add_rotated_freeslip_bc(self, conds=None, boundary=None, normal=None): - r"""Add STRONG free-slip (:math:`\mathbf{u}\cdot\hat{\mathbf n}=0`) by rotating - the boundary velocity DOFs into a per-node (normal, tangential) frame and - imposing the rotated normal component as an exact Dirichlet constraint. + # Note, ensure names are unique for each solver type + i = SNES_Stokes_SaddlePt._obj_count + self.Unknowns.u = uw.discretisation.MeshVariable(f"V{i}", self.mesh, self.mesh.dim, degree=degree, varsymbol=rf"{{\mathbf{{u}}^{{[{i}]}} }}" ) + self.Unknowns.p = uw.discretisation.MeshVariable(f"P{i}", self.mesh, 1, degree=degree-1, continuous=p_continuous, varsymbol=rf"{{\mathbf{{p}}^{{[{i}]}} }}") - Unlike Nitsche/penalty free-slip (weak, leaks :math:`\mathcal O(10^{-3})`), - this enforces zero wall-normal flow to machine precision, and the constraint - **reaction** is the consistent boundary normal traction - :math:`\sigma_{nn}` (see :meth:`boundary_normal_traction`) with no - augmented-Lagrangian splitting. Correct on deformed / tilted / curved - boundaries because the normal is taken per node. + if self.verbose and uw.mpi.rank == 0: + print(f"SNES Saddle Point Solver {self.instance_number}: creating new mesh variables for unknowns") - Parameters - ---------- - conds : float, scalar sympy expression, or None, optional - Prescribed wall-normal velocity datum :math:`\tilde u_n` (the SCALAR - component along the outward normal), in the canonical value-first BC - order (Style Charter, API conventions). Zero (or ``None``) is pure - free-slip :math:`\mathbf{u}\cdot\hat{\mathbf n}=0`. A non-zero value - — a number, a sympy expression of ``mesh.X``, or a scalar field read - such as ``h_dot.sym[0]`` — is imposed strongly (machine precision) as - :math:`\mathbf{u}\cdot\hat{\mathbf n}=\tilde u_n`, evaluated at the - boundary nodes at each ``solve()``. On an enclosed boundary the - datum must be discretely flux-free (:math:`\oint \tilde u_n = 0`) - for incompressibility. A corner/edge node shared between rotated - boundaries has no single normal and stays at the free-slip pinning - (datum ignored there). Vector/matrix values are rejected. - boundary : str - Boundary label to constrain. - normal : None or sympy 1×dim Matrix or array, optional - Per-node outward normal source. ``None`` (the default, and normally - the right choice) uses the geometric facet normal, measure-weighted - so that it is consistent with the straight-facet boundary integral - the assembler evaluates. A sympy ``1×dim`` matrix supplies an - analytic normal (``X/|X|`` on a spherical cap, a constant on a - planar face): exact for the TRUE surface, but the assembler still - integrates over the facets, so it keeps a consistency error that - grows with facet non-uniformity. Use it when the constraint must - follow the geometry rather than the mesh. A constant array is also - accepted. See ``docs/developer/subsystems/rotated-freeslip.md``. + # I expect the following to break for anyone who wants to name their solver _stokes__ etc etc (LM) - Notes - ----- - A node shared by several rotated-free-slip boundaries (a box corner, a 3D - edge) is constrained on the whole span of its accumulated normals: a 3D - face frees two tangential directions, a 3D edge frees one (the edge - tangent), a corner is fully pinned. Registering delegates the solve to - :mod:`underworld3.utilities.rotated_bc`. + # options = PETSc.Options() + # options["dm_adaptor"]= "pragmatic" + # Here we can set some defaults for this set of KSP / SNES solvers - The legacy boundary-first call ``add_rotated_freeslip_bc(boundary, - normal)`` is detected conservatively (the first positional argument is - a string — the datum is never a string) and shimmed with one - DeprecationWarning: the string becomes ``boundary`` and a second - positional argument, if present, becomes ``normal``. - """ - self._reject_mixed_constraint_mechanisms("add_rotated_freeslip_bc") + if self.verbose == True: + self.petsc_options["ksp_monitor"] = None + self.petsc_options["snes_converged_reason"] = None + self.petsc_options["snes_monitor_short"] = None + else: + self.petsc_options.delValue("ksp_monitor") + self.petsc_options.delValue("snes_monitor") + self.petsc_options.delValue("snes_monitor_short") + self.petsc_options.delValue("snes_converged_reason") - if isinstance(conds, str): - # legacy boundary-first call: (boundary[, normal]) - if boundary is not None: - if normal is not None: - raise TypeError( - "add_rotated_freeslip_bc() received 'normal' twice " - "(positionally, legacy order, and as a keyword)") - normal = boundary - boundary = conds - conds = None - import warnings - warnings.warn( - "add_rotated_freeslip_bc(boundary, normal) is deprecated; " - "use add_rotated_freeslip_bc(conds, boundary, normal=...) " - "with conds=0 (or boundary=... by keyword)", - DeprecationWarning, stacklevel=2) - if not isinstance(boundary, str): - raise TypeError( - f"add_rotated_freeslip_bc() requires a boundary label string; " - f"got {type(boundary).__name__}") - if conds is not None: - if isinstance(conds, (sympy.MatrixBase, list, tuple, np.ndarray)): - raise TypeError( - "add_rotated_freeslip_bc(conds=...) takes the SCALAR " - "wall-normal datum u.n; got a vector/matrix value") - # Value comparison, not sympy's structural ==: Float(0.0) != Integer(0) - # structurally, but both are zero data (the free-slip member of the - # family). is_zero is True only when sympy can PROVE zero, so a - # symbolic datum (field read, expression) is correctly kept. - if sympy.sympify(conds).is_zero is not True: - self._rotated_freeslip_datum[boundary] = conds - self._rotated_freeslip_bcs.append((boundary, normal)) - self.is_setup = False - return + self._tolerance = 1.0e-4 + self._strategy = "default" + # the velocity split's Krylov restart, beside the outer one set in the base + # constructor (see there): the yielded layer's mechanism mode lives in this block + self._push_managed_option("fieldsplit_velocity_ksp_gmres_restart", _DEFAULT_KRYLOV_RESTART) - def add_fault_bc(self, conds=0, boundary=None, normal=None): - r"""Interface condition on a split-node fault (value-first). + # Owned-option latch state (see _resolve_owned_option): the value THIS + # solver last pushed per key, and any user value latched per key. + # Ownership is RECORDED, never inferred — the same doctrine as + # _managed_pc_options. + self._owned_option_pushes = {} + self._owned_option_user = {} - ``boundary`` names a fault split by ``Mesh.add_fault`` (or - ``fault_split.split_fault``); the mesh carries its coincident DOF - pairing. The no-opening constraint :math:`[\mathbf v]\cdot\hat n = 0` - is always imposed strongly; ``conds`` sets the tangential law: + # Participate in the auto FMG/GAMG switch on the velocity fieldsplit + # block (see the `preconditioner` property). The velocity pc/mg keys + # set below are the GAMG default; _apply_preconditioner_options() + # upgrades the velocity block to geometric FMG at build time when the + # mesh carries a refinement hierarchy, and otherwise leaves them. + self._pc_option_prefix = "fieldsplit_velocity_" - * ``conds = 0`` — frictionless (perfectly slippery): zero shear - traction, the slip emerges (the stress-driven crack). - * ``conds`` > 0 — viscous interface :math:`\tau = \eta_f V` with - ``conds`` = :math:`\eta_f` (viscosity per unit length; the - zero-thickness limit of a band is :math:`\eta_f = \eta_{band}/w`, - where :math:`\eta_{band}` is the band's OWN weak-zone viscosity — - not the background's — and :math:`w` its width). ``conds`` large - removes the fault (only the JUMP is penalised — nothing becomes - rigid). + self.petsc_options["snes_rtol"] = self._tolerance + self.petsc_options["snes_ksp_ew"] = None + self.petsc_options["snes_ksp_ew_version"] = 3 - ``normal`` optionally supplies the fault's smooth unit normal — - a sympy ``1×dim`` Matrix in ``mesh.X`` (same conventions as - :meth:`add_rotated_freeslip_bc`), the string ``"trace"`` (2-D: the - smoothed normal is built from the fault's own stored polyline — - the right choice for digitized traces with no analytic formula), - or a constant ``(dim,)`` array. Use it whenever the fault trace is - a SAMPLED SMOOTH CURVE: the default per-node normal averages the - adjacent facet normals, which zig-zags at the sampling kinks, and - the no-opening constraint then forbids smooth slip past each kink — - slip notches and normal-traction sawteeth that GROW under mesh - refinement. The smooth normal restores the smooth curve's - mechanics on the same polyline mesh. On a straight fault the - default is already exact, and a deliberately KINKED fault should - NOT be smoothed — there the kink response is the physics. + # The OUTER Krylov must be FLEXIBLE: both sub-blocks below are + # themselves Krylov solves run to a tolerance, so the operator the + # outer method applies differs from one outer iteration to the next + # and plain GMRES's residual recurrence does not hold — the + # velocity-block FGMRES reasoning (#147), one level up. PETSc's + # default is `gmres`; invisible on easy problems (isoviscous SolKz + # converges in two outer iterations), ruinous on hard ones: 14,400 + # inner iterations / ~540 s on an 85k-cell contrast problem for both + # velocity preconditioners (#624), and on the Spiegelman notch at + # refinement 3, 983 velocity iterations per step and + # DIVERGED_LINEAR_SOLVE against 58 for fgmres — raising the velocity + # cap changes nothing (byte-identical residuals), so the failure is + # inconsistency, not iteration count (#576). Managed, so an explicit + # user ksp_type still wins. + self._push_managed_option("ksp_type", "fgmres") - The solve then takes the rotated strong-constraint path - (``utilities/rotated_bc.py`` with the pair blocks of - ``utilities/fault_contact.py``); ``guard()`` / - ``estimate_difficulty()`` are unavailable, as for rotated free-slip. - Slip and leak per coincident pair afterwards: - ``fault_contact.fault_slip(solver, boundary, - solver._rotated_freeslip_info)``. - """ - from underworld3.utilities import fault_contact + self.petsc_options["pc_type"] = "fieldsplit" + self.petsc_options["pc_fieldsplit_type"] = "schur" + self.petsc_options["pc_fieldsplit_schur_fact_type"] = "full" # diag is an alternative (quick/dirty) + self.petsc_options["pc_fieldsplit_schur_precondition"] = "a11" # despite what the docs say for saddle points - self._reject_mixed_constraint_mechanisms("add_fault_bc") + self.petsc_options["pc_fieldsplit_diag_use_amat"] = None + self.petsc_options["pc_fieldsplit_off_diag_use_amat"] = None + # self.petsc_options["pc_use_amat"] = None # Using this puts more pressure on the inner solve - if not isinstance(boundary, str): - raise TypeError( - f"add_fault_bc() requires the fault's boundary name string; " - f"got {type(boundary).__name__}") - eta_f = float(conds) - if eta_f == 0.0: - fault_contact.add_frictionless_fault_bc(self, boundary, - normal=normal) - else: - fault_contact.add_viscous_fault_bc(self, eta_f, boundary, - normal=normal) - self.is_setup = False - return + p_name = "pressure" # pressureField.clean_name + v_name = "velocity" # velocityField.clean_name - def _residual_is_nonlinear(self, tol=1e-8): - r"""True if the Stokes residual is nonlinear in the unknowns :math:`(v, p)` - — i.e. the assembled Jacobian depends on the solution, so Newton / Picard - iteration is required. + # Pressure subsolve — flexible GMRES + GASM. (FGMRES is right- + # preconditioned by construction; no need to set pc_side explicitly.) + self.petsc_options[f"fieldsplit_{p_name}_ksp_type"] = "fgmres" + self.petsc_options[f"fieldsplit_{p_name}_ksp_max_it"] = 200 + self.petsc_options[f"fieldsplit_{p_name}_ksp_rtol"] = self._tolerance + self.petsc_options[f"fieldsplit_{p_name}_pc_type"] = "gasm" + # self.petsc_options[f"fieldsplit_{p_name}_pc_gasm_type"] = "basic" - Detected by a NUMERICAL probe: assemble the Jacobian at two distinct - velocity states and compare. A symbolic test on ``F1.sym`` cannot see the - nonlinearity — the effective viscosity's strain-rate (velocity-gradient) - dependence is carried as a JIT-substituted *placeholder* symbol - (``\dot\varepsilon_{II}``) in the flux, decoupled from the gradient - ``L`` in the symbolic form, so it only becomes visible once the operator - is assembled at a concrete iterate. Constant- or temperature-dependent - viscosity ⇒ ``J`` independent of ``v`` ⇒ the two assemblies are - bit-identical ⇒ linear. + ## may be more robust but usually slower + # self.petsc_options[f"fieldsplit_{p_name}_ksp_type"] = "fgmres" + # self.petsc_options[f"fieldsplit_{p_name}_ksp_rtol"] = self._tolerance * 0.1 + # self.petsc_options[f"fieldsplit_{p_name}_pc_type"] = "gamg" + # self.petsc_options[f"fieldsplit_{p_name}_pc_gamg_type"] = "agg" + # self.petsc_options[f"fieldsplit_{p_name}_pc_gamg_repartition"] = True - Used as a LAZY guard in the rotated-free-slip loop's picard + pure-Newton - corner (a Picard warmup is meaningless for a linear residual and impossible - for pure Newton) — the loop itself needs no up-front probe, it - self-terminates. The caller must have run the pre-solve preamble - (auxiliary vector + constants) so the assembly sees the correct - coefficients. - """ - snes = self.snes - dm = self.dm - snes.setUp() - J = snes.getJacobian()[0] - U1 = dm.getGlobalVec(); U2 = dm.getGlobalVec() - # two distinct smooth, bounded states with non-zero velocity gradients - # (a linear operator gives the SAME J for both; only a solution-dependent - # viscosity makes them differ). Ownership-relative index keeps it - # partition-independent enough for the norm comparison. - rs, re = U1.getOwnershipRange() - idx = np.arange(rs, re, dtype=float) - # write IN PLACE into each vec's PETSc-owned array (getArray returns a writable - # view). setArray with a fresh numpy temporary on a POOLED getGlobalVec is risky: - # its storage lifetime/pool reuse is not guaranteed for the later computeJacobian. - a1 = U1.getArray(); a1[:] = 0.1 * np.sin(0.7 * idx + 0.3) - a2 = U2.getArray(); a2[:] = 0.1 * np.sin(1.3 * idx + 1.1) - J1 = J.copy(); J2 = J.copy() - try: - snes.computeJacobian(U1, J1) - snes.computeJacobian(U2, J2) - J2.axpy(-1.0, J1) # J2 <- J(U2) - J(U1) - rel = J2.norm() / (J1.norm() + 1e-300) - finally: - dm.restoreGlobalVec(U1); dm.restoreGlobalVec(U2) - J1.destroy(); J2.destroy() - return rel > tol + # Velocity subsolve — flexible GMRES + GAMG. + # + # FGMRES (not CG/FCG) is the default to remain robust under non-stationary + # preconditioning and weakly-indefinite coarse operators. The mg_levels + # KSP is bounded but variable-iteration (mg_levels_ksp_converged_maxits) + # so the GAMG application is non-linear; CG/FCG's residual recurrence + # cannot accommodate this, and at scale (large parallel partitions, sharp + # internal sources, free-slip Nitsche) PETSc reports DIVERGED_PC_FAILED + # / indefinite matrix or GMRES residual-recursion mismatch. See issue + # #147 for the spherical-Kramer benchmark on Gadi that drove this change; + # gthyagi's validated FGMRES configuration completed at np=144, + # cellsize=1/32 where CG/FCG and standard GMRES both failed. + self.petsc_options[f"fieldsplit_{v_name}_ksp_type"] = "fgmres" + self.petsc_options[f"fieldsplit_{v_name}_ksp_max_it"] = 200 + self.petsc_options[f"fieldsplit_{v_name}_ksp_rtol"] = self._tolerance * 0.1 + # The velocity field carries mesh.dim components per node; say so, or + # GAMG aggregates scalars. See multigrid_options._gamg_settings. + self._pc_block_size = self.mesh.dim + for key, value in multigrid_options.gamg_bundle( + block_size=self._pc_block_size).settings.items(): + self._push_managed_option(f"fieldsplit_{v_name}_{key}", value) - def boundary_normal_traction(self, boundary, mass="auto"): - r"""Return the boundary normal traction :math:`\sigma_{nn}` on a - rotated-free-slip ``boundary`` as the constraint reaction from the last - solve — the smooth, bounded quantity used for dynamic topography - (:math:`h_\infty=-(\sigma_{nn}-\overline{\sigma_{nn}})/\rho g`). Requires a - prior :meth:`add_rotated_freeslip_bc` on ``boundary`` and a completed - :meth:`solve`. + # Create this dict + self.fields = {} + self.fields[p_name] = self.p + self.fields[v_name] = self.u - ``mass="auto"`` (default) uses lumped recovery for 2D traces and 3D P1 - triangles, and MIDPOINT-RECONSTRUCTED recovery for 3D P2 triangles (the - consistent solve, keeping its superconvergent midpoints, with vertices rebuilt - from them). ``"p1"`` selects the simpler P1-projected recovery; explicit - ``"lumped"`` and ``"consistent"`` remain available where mathematically valid; - ``"consistent"`` is pointwise-exact on a P2 trace in exact arithmetic but - its zero vertex row sums amplify any load perturbation at VERTICES by O(1), - independently of h (#404, measured in #633). Three-dimensional recovery - currently supports triangular P1/P2 traces only (#637). + # Some other setup - .. warning:: - On CURVED boundaries, P2 vertex values of :math:`\sigma_{nn}` converge - only slowly (the vertex basis has zero surface mean, so vertex reactions - carry only the O(h) facet-geometry error); edge-midpoint values are - superconvergent. Pointwise consumers on curved boundaries should use - midpoint or integral/fitted quantities (issue #414).""" - if self._rotated_freeslip_info is None: - raise RuntimeError( - "boundary_normal_traction requires a completed rotated-free-slip solve.") - from underworld3.utilities.rotated_bc import boundary_normal_traction as _bnt - return _bnt(self, boundary, self._rotated_freeslip_info, mass=mass) + self.mesh._equation_systems_register.append(self) + self._rebuild_after_mesh_update = self._build - def dynamic_topography(self, boundary, field, buoyancy_scale=1.0, mass="auto"): - r"""Write the dynamic topography - :math:`h = -(\sigma_{nn}-\overline{\sigma_{nn}})/(\Delta\rho\,g)` on a - rotated-free-slip ``boundary`` onto a scalar MeshVariable ``field``, from the - constraint reaction of the last solve. This is the hand-off to the free-surface - machinery — the 3-number topography integrator drives node motion from a surface - field, so create a scalar ``field`` (P1 recommended, continuous) up front and - pass it here after each :meth:`solve`; its boundary nodes are filled and the - interior left untouched. + self.essential_bcs = [] - ``buoyancy_scale`` is :math:`\Delta\rho\,g` (traction → length). - ``mass="auto"`` selects lumped recovery where valid and - midpoint-reconstructed recovery for 3D P2 triangles. Requires a prior - :meth:`add_rotated_freeslip_bc` on ``boundary`` and a completed :meth:`solve`. + self.natural_bcs = [] + self.bcs = self.essential_bcs + self.boundary_conditions = False + self._saddle_preconditioner = None + self._petsc_use_pressure_nullspace = False + self._petsc_velocity_nullspace_basis = () + self._stokes_nullspace = None + self._stokes_nullspace_basis = () + self._velocity_rotation_nullspace = None - .. warning:: - On CURVED boundaries (annulus/spherical free surfaces), the P2 VERTEX - values written into ``field`` converge only slowly; edge-midpoint values - are superconvergent. Downstream pointwise use of curved-boundary - topography should rely on midpoint/fitted quantities (issue #414).""" - if self._rotated_freeslip_info is None: - raise RuntimeError( - "dynamic_topography requires a completed rotated-free-slip solve.") - from underworld3.utilities.rotated_bc import dynamic_topography_field as _dtf - return _dtf(self, boundary, self._rotated_freeslip_info, field, - buoyancy_scale=buoyancy_scale, mass=mass) + # Construct strainrate tensor for future usage. + # Grab gradients, and let's switch out to sympy.Matrix notation + # immediately as it is probably cleaner for this. + N = mesh.N - def boundary_normal_traction_integral(self, boundary, fn, remove_mean=True): - r"""Return the boundary integral of ``sigma_nn * fn`` directly from the - rotated-free-slip constraint reaction. + ## sympy.Matrix - gradient tensors + self._G = self.p.sym.jacobian(self.mesh.CoordinateSystem.N) - With ``remove_mean=True`` (default), the constant normal-traction gauge - is removed before projection. Unlike pointwise - :meth:`boundary_normal_traction`, this weak projection does not recover - nodal traction values or gather a global boundary mesh. It is therefore - suitable for harmonic and integral diagnostics on curved P2 boundaries, - whose recovered vertex values converge slowly (issue #414). - """ - if self._rotated_freeslip_info is None: - raise RuntimeError( - "boundary_normal_traction_integral requires a completed " - "rotated-free-slip solve.") - from underworld3.utilities.rotated_bc import boundary_normal_traction_integral as _bnti - return _bnti(self, boundary, self._rotated_freeslip_info, fn, - remove_mean=remove_mean) + # this attrib records if we need to re-setup + self.is_setup = False def add_nitsche_bc(self, conds=None, boundary=None, direction=None, normal=None, gamma=12.5, theta=1, mask=None, local_h=True, g=None): @@ -12207,55 +12345,7 @@ class SNES_Stokes_SaddlePt(SolverBaseClass): # Non-dimensionalise if the scaling system is active. cdef DM _time_dm_stokes - # Rotated strong free-slip: delegate to the rotated_bc module (per-node DOF - # rotation + strong v_n=0 + reaction=sigma_nn). Handles the whole assemble/ - # solve/rotate-back/gauge-removal; stashes info for boundary_normal_traction. - if self._rotated_freeslip_bcs or self._fault_contact_faults: - # Run the same pre-solve preamble as the standard path so the pointwise - # functions see the DM time, the auxiliary vector, and updated constants - # (needed for problems whose coefficients live in auxiliary fields). This - # must precede the nonlinearity probe below so the trial assemblies see - # the correct coefficients. - if time is not None: - t_nd = self._nondimensional_time(time) - _time_dm_stokes = self.dm - UW_DMSetTime(_time_dm_stokes.dm, t_nd) - self.mesh.update_lvec() - self.dm.setAuxiliaryVec(self.mesh.lvec, None) - self._update_constants(record=True) - - # guard() refuses rotated free-slip, but the BC can be added AFTER arming. - # Re-check here: this path never reaches the instrumentation, so an armed - # guard would be silently inert -- the exact state guard() exists to refuse. - if (getattr(self, "_instrumentation", None) is not None - and self._instrumentation.armed): - raise NotImplementedError( - "a wall-clock guard is armed but this solve takes the rotated " - "free-slip path, which runs its own Krylov loop outside self.snes " - "and cannot be bounded by it. Call unguard() first." - ) - # ONE path for linear and nonlinear models: the manual outer - # Newton/Picard loop that rotates F(u), J(u) and the strong - # v_n = u_n constraint every iteration. It honours zero_init_guess - # (warm start), the Picard warmup count, and the consistent_jacobian - # tangent (Picard / Newton / continuation); a linear model converges - # after its first increment, so no up-front nonlinearity probe is - # paid (the loop self-terminates; _residual_is_nonlinear survives as - # a lazy guard inside the picard+pure-Newton corner). - from underworld3.utilities.rotated_bc import solve_rotated_freeslip - self._rotated_freeslip_info = solve_rotated_freeslip( - self, self._rotated_freeslip_bcs, verbose=verbose, - zero_init_guess=zero_init_guess, picard=picard, - # An explicit `time=` reaches the kernels through petsc_t on - # the DM — invisible to every state counter and constant - # value, so it must veto the cached-operator fast path. - force_operator_refresh=time is not None) - # This path solves via ksp.solve on the rotated operator (not self.snes), - # so give it a report from the rotated result rather than leaving a stale one. - _rotated_report = self._capture_rotated_report(self._rotated_freeslip_info) - # The warm-start flag must follow the rotated solve's own verdict — the SNES - # was never run, so the generic reader would latch a stale reason. - self._record_convergence_status(converged=_rotated_report.converged) + if self._solve_rotated_if_registered(zero_init_guess, verbose, picard, time): return if time is not None: diff --git a/src/underworld3/discretisation/discretisation_mesh.py b/src/underworld3/discretisation/discretisation_mesh.py index 1854514c0..589354972 100644 --- a/src/underworld3/discretisation/discretisation_mesh.py +++ b/src/underworld3/discretisation/discretisation_mesh.py @@ -7375,6 +7375,8 @@ def _coarse_level_meshes(self): token = object() for k, w in enumerate(cached): w._refine_slot = (token, k) + # the static background: never cut by a fault (add-on principle) + w._mg_static = True self._refine_slot = (token, len(cached) - 1) self._coarse_level_meshes_cache = cached return cached @@ -8305,8 +8307,25 @@ def add_conforming_sheet(self, points, triangles, name, *, return self._adopt_cut_child(cut_dm, boundaries, info, mg_coarsening_ratio, verbose) + def extrude(self, thickness, layers=2, base="Base", surface="Surface", + qdegree=None, verbose=False): + r"""The thick twin of this sheet mesh: a layer of tetrahedra + ``thickness`` deep, centred on the sheet. + + The sheet's boundaries become the layer's walls under the same names, + its two faces are ``base`` and ``surface``, and every split-node fault + of the sheet becomes a vertical fault through the whole layer, with its + side labels and pairing. A model written for ``ThinSheet`` on this mesh + runs as ``Stokes`` on the result by changing the mesh and the solver + class. See :func:`underworld3.meshing.extrude_sheet`. + """ + from underworld3.meshing.extrude import extrude_sheet + return extrude_sheet(self, thickness, layers=layers, base=base, + surface=surface, qdegree=qdegree, verbose=verbose) + def add_fault(self, faults, verbose=False, cut=True, exclude=None, - blind=1, across_seams=False): + blind=1, across_seams=False, split_levels=True, + **place_kwargs): """Cut AND split one or more faults; return the split mesh. The split-node fault pipeline in one call: each fault becomes a @@ -8336,25 +8355,64 @@ def add_fault(self, faults, verbose=False, cut=True, exclude=None, meshed through them, ``seams="conform"``) instead of redistributing — see ``fault_split.split_fault``. - Implementation and design: ``underworld3.utilities.fault_split`` - and ``docs/developer/design/FAULT_CONTACT_DEPLOYMENT_2026-08.md``. + In 3-D a fault is a triangulated sheet: a ``FaultSurface`` or a + ``(name, points, triangles)`` triple, placed as + :meth:`add_conforming_sheet` places it and split; ``place_kwargs`` + are the placement's options (``clearance`` about 0.9 on a + bisection-refined mesh, where the default cavity cannot be closed). + + ``split_levels`` (default True) carries the fault through the ADD-ON + levels of the inherited multigrid tail — every level refined from the + static background toward the fault is cut and split the same way, so + the coarse spaces contain the discontinuity and the velocity solve + stays mesh-independent as the fault is refined (the split-level + multigrid of ``utilities.fault_mg``; measured 10 -> 5 velocity + iterations over four halvings against 9 -> 32 with welded levels). + The background levels are never cut. ``False`` keeps the welded tail. + + Implementation and design: ``underworld3.utilities.fault_split``, + ``utilities.fault_mg``, + ``docs/developer/design/FAULT_CONTACT_DEPLOYMENT_2026-08.md`` and + ``docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md``. """ from underworld3.utilities.fault_split import add_fault child = add_fault(self, faults, verbose=verbose, cut=cut, exclude=exclude, blind=blind, - across_seams=across_seams) + across_seams=across_seams, **place_kwargs) # The split mesh INHERITS a mesh-owned geometric-MG tail: a cut # re-represents the same grid with the surface conformed (finer only # by the duplicated vertices), so the parent's coarse levels serve - # unchanged with the cut mesh as the finest level — the coarse - # levels do not need the fault (#620/#629). Without this every - # solver on a split mesh fell to GAMG unless it called - # set_custom_fmg by hand. The FAC zone is NOT inherited: a split - # fault needs no patch (the keying ruling). + # with the cut mesh as the finest level. The static background + # levels serve unchanged; the add-on levels are split too (above). + # Without this every solver on a split mesh fell to GAMG unless it + # called set_custom_fmg by hand. The FAC zone is NOT inherited: a + # split fault needs no patch (the keying ruling). own_tail = getattr(self, "_custom_mg_coarse_meshes", None) if (own_tail is not None and getattr(child, "_custom_mg_coarse_meshes", None) is None): - child._custom_mg_coarse_meshes = list(own_tail) + tail = list(own_tail) + # a placed band's spines (cut=False) have no counterpart on the + # levels, which were never placed: only a CUT fault is replayed + if split_levels and cut: + from underworld3.utilities.fault_mg import ( + addon_level_indices, split_tail_levels) + idx = addon_level_indices(tail) + if idx: + split = split_tail_levels([tail[k] for k in idx], + child._fault_specs, + verbose=verbose) + if self.dm.getComm().getSize() > 1: + # each level's star onto the rank holding the finest's, + # so the transfers locate the band rank-locally + from underworld3.utilities.fault_mg import ( + colocate_split_level, fault_star_owner) + target = fault_star_owner(child) + split = [colocate_split_level(m, target) + if getattr(m, "_fault_point_pairs", None) else m + for m in split] + for k, m in zip(idx, split): + tail[k] = m + child._custom_mg_coarse_meshes = tail child._custom_mg_builder = getattr(self, "_custom_mg_builder", "barycentric") child._custom_mg_fac_zone = None @@ -9344,7 +9402,9 @@ def _relax_generation(engine_obj, carry, rcarry): # the finest base level of the MG tail must carry the SAME moved # geometry the child was refined from; coarser levels keep their # original coordinates (custom-P transfers accept non-nested pairs) - coarse_tail = coarse_tail[:-1] + [self._wrap_coarse_level(base_finest)] + _moved_wrap = self._wrap_coarse_level(base_finest) + _moved_wrap._mg_static = True + coarse_tail = coarse_tail[:-1] + [_moved_wrap] child._custom_mg_coarse_meshes = coarse_tail + intermediate child._custom_mg_builder = builder @@ -10059,6 +10119,10 @@ def units_information(units, indent): topology_type = "Quadrilateral" geomType = "XY" else: + # TODO(BUG): a manifold mesh (triangles in three-space, e.g. + # SphericalManifold) lands here with numCorners == 3 and is written as + # Hexahedron; it should be Triangle with XYZ geometry. Found 2026-10-09 + # (thin-sheet work), not fixed — out of that scope. See issue #869. if numCorners == 4: topology_type = "Tetrahedron" elif numCorners == 8: diff --git a/src/underworld3/discretisation/discretisation_mesh_variables.py b/src/underworld3/discretisation/discretisation_mesh_variables.py index d948aac90..160508c77 100644 --- a/src/underworld3/discretisation/discretisation_mesh_variables.py +++ b/src/underworld3/discretisation/discretisation_mesh_variables.py @@ -1966,6 +1966,15 @@ def read_checkpoint( if self._lvec is None: self._set_vec(available=True) + # TODO(BUG): on a manifold mesh (dim < cdim, e.g. SphericalManifold) the + # default (reconstructed-mesh, migration-SF) path below segfaults inside + # PETSc when called on the mesh object that wrote the file; the same + # call on a volume mesh round-trips exactly, and same_layout=True + # round-trips exactly on the manifold. The path's intended use — a mesh + # reconstructed from its checkpoint — is untested on a manifold: the h5 + # mesh round-trip drops the coordinate dimension first (see the + # SphericalManifold factory). Found 2026-10-09 (thin-sheet work), not fixed. + # See issue #870. import h5py if uw.mpi.rank == 0: @@ -2165,7 +2174,8 @@ def sym_1d(self) -> sympy.Basic: if self.vtype != uw.VarType.SYM_TENSOR: return self._sym.reshape(1, len(self._sym)) else: - if self.mesh.dim == 2: + # Voigt order of a tensor with one index per EMBEDDING dimension + if self.mesh.cdim == 2: return sympy.Matrix( [ self._sym[0, 0], @@ -2186,22 +2196,23 @@ def sym_1d(self) -> sympy.Basic: ).T def _data_layout(self, i, j=None): - # mapping + # mapping; vectors and tensors carry one component per EMBEDDING + # dimension (cdim), which is what sizes the layout on a manifold mesh if self.vtype == uw.VarType.SCALAR: return 0 if self.vtype == uw.VarType.VECTOR: if i < 0 or j < 0: - return self.mesh.dim + return self.mesh.cdim else: if j is None: return i elif i == 0: return j else: - raise IndexError(f"Vectors have shape {self.mesh.dim} or {(1, self.mesh.dim)} ") + raise IndexError(f"Vectors have shape {self.mesh.cdim} or {(1, self.mesh.cdim)} ") if self.vtype == uw.VarType.TENSOR: - if self.mesh.dim == 2: + if self.mesh.cdim == 2: if i < 0 or j < 0: return 4 else: @@ -2213,7 +2224,7 @@ def _data_layout(self, i, j=None): return ((0, 1, 2), (3, 4, 5), (6, 7, 8))[i][j] if self.vtype == uw.VarType.SYM_TENSOR: - if self.mesh.dim == 2: + if self.mesh.cdim == 2: if i < 0 or j < 0: return 3 else: diff --git a/src/underworld3/function/field_projection.py b/src/underworld3/function/field_projection.py index 4ed0c713f..d8922776e 100644 --- a/src/underworld3/function/field_projection.py +++ b/src/underworld3/function/field_projection.py @@ -500,6 +500,7 @@ def write_coordinates_to_viewer( Dataset name (default ``coordinates``). """ coord_gvec = mesh.dm.getCoordinates() + # one coordinate per EMBEDDING dimension (a surface mesh in three-space) coords = coord_gvec.array.reshape(-1, mesh.cdim).copy() coords, _ = _physical_visualisation_values(coords, mesh.units) _write_vec_to_group(viewer, coords, name, group, PETSc.COMM_WORLD) diff --git a/src/underworld3/maths/tensors.py b/src/underworld3/maths/tensors.py index 68fa4f909..5db4b10d8 100644 --- a/src/underworld3/maths/tensors.py +++ b/src/underworld3/maths/tensors.py @@ -395,3 +395,54 @@ def rank2_inner_product(A, B): return sympy.tensorcontraction( sympy.tensorcontraction(sympy.tensorproduct(A, B), (1, 3)), (0, 1) ) + + +def resolved_traction(stress, normal, direction): + r"""Normal and shear traction of a stress on a plane. + + .. math:: + + \sigma_n = \hat{\mathbf{n}}\cdot\boldsymbol\sigma\cdot\hat{\mathbf{n}}, + \qquad + \tau_s = \hat{\mathbf{s}}\cdot\boldsymbol\sigma\cdot\hat{\mathbf{n}}, + + with :math:`\hat{\mathbf{n}}` the plane's unit normal and + :math:`\hat{\mathbf{s}}` a unit direction in the plane (the slip direction + of a receiver fault). Tension is positive, as in the solvers' ``stress``. + + Written on any solver's ``stress``, so the same line resolves a thin + sheet's in-plane stress (normal and direction in the sheet) and a thick + sheet's 3-D stress. + + Parameters + ---------- + stress : sympy.Matrix + A ``(d, d)`` stress, e.g. ``solver.stress``. + normal, direction : sequence or sympy.Matrix + ``d`` components each; normalised here. + + Returns + ------- + (sympy.Expr, sympy.Expr) + ``(sigma_n, tau_s)``. + """ + sigma = sympy.Matrix(stress) + d = sigma.shape[0] + n = sympy.Matrix(list(normal)).reshape(d, 1) + s = sympy.Matrix(list(direction)).reshape(d, 1) + n = n / sympy.sqrt((n.T * n)[0, 0]) + s = s / sympy.sqrt((s.T * s)[0, 0]) + return (n.T * sigma * n)[0, 0], (s.T * sigma * n)[0, 0] + + +def coulomb_failure_stress(stress, normal, direction, friction): + r"""Coulomb failure stress on a receiver plane, + :math:`\mathrm{CFS} = \tau_s + \mu\,\sigma_n` (tension positive, shear + signed along ``direction``): a rise moves the receiver towards failure. + + The change against a reference state, :math:`\Delta\mathrm{CFS}`, is the + difference of two of these (the same receivers, two stresses). See + :func:`resolved_traction` for the arguments; ``friction`` is :math:`\mu`. + """ + sigma_n, tau_s = resolved_traction(stress, normal, direction) + return tau_s + friction * sigma_n diff --git a/src/underworld3/maths/vector_calculus.py b/src/underworld3/maths/vector_calculus.py index 2c65dc3c5..0ad10000d 100644 --- a/src/underworld3/maths/vector_calculus.py +++ b/src/underworld3/maths/vector_calculus.py @@ -62,7 +62,9 @@ class mesh_vector_calculus: def __init__(self, mesh): self.mesh = mesh - self.dim = self.mesh.dim + # Vectors carry one component per EMBEDDING dimension: on a manifold + # (dim < cdim) a tangential field is still a cdim-component row. + self.dim = self.mesh.cdim def cross(self, vector1, vector2): r""" diff --git a/src/underworld3/meshing/__init__.py b/src/underworld3/meshing/__init__.py index 69582f8fa..9b9403a1f 100644 --- a/src/underworld3/meshing/__init__.py +++ b/src/underworld3/meshing/__init__.py @@ -78,6 +78,7 @@ register_plane_surfaces, register_box_face_surfaces, ) +from .extrude import extrude_sheet # Make all functions available at module level for backward compatibility __all__ = [ @@ -133,4 +134,6 @@ "register_radial_surfaces", "register_plane_surfaces", "register_box_face_surfaces", + # A thin sheet extruded into a layer (faults carried through) + "extrude_sheet", ] diff --git a/src/underworld3/meshing/extrude.py b/src/underworld3/meshing/extrude.py new file mode 100644 index 000000000..218931438 --- /dev/null +++ b/src/underworld3/meshing/extrude.py @@ -0,0 +1,395 @@ +r"""The thick twin of a sheet mesh: extrude a surface mesh through a thickness. + +A thin sheet (``ThinSheet`` on a surface mesh) and a thick one (``Stokes`` on a +thin 3-D layer) are the same model when they share everything but the solver +class and the mesh. :func:`extrude_sheet` makes the second mesh from the first, +so the two carry the same lateral boundaries under the same names and the same +faults: + +* every in-plane boundary of the sheet becomes the vertical wall it bounds, under + the same name (a model's ``add_essential_bc(..., "Left")`` means the same + thing on both); +* the two faces of the layer are added as ``Base`` and ``Surface`` (the drag and + traction of ``add_basal_drag`` / ``add_basal_traction`` go on ``Base``); +* a split-node fault of the sheet becomes a vertical fault through the whole + layer. Its slit is extruded with it, so the fault is split at the surface and + at the base, as a fault through a plate is, and its tips become shared + vertical edges — the tip rule of the sheet one dimension up. The fault's + side labels and the minus-to-plus point pairing are rebuilt for the layer, so + ``Stokes.add_fault_bc`` works on it as ``ThinSheet.add_fault_bc`` does on the + sheet. + +The layer is centred on the sheet: on a flat sheet it spans +:math:`z \in [-L/2, L/2]`, on a spherical manifold of radius :math:`R` the shell +:math:`r \in [R - L/2, R + L/2]` (each node moved along its own radius). + +Each prism of the layer is cut into three tetrahedra by the global vertex order, +which makes the cut of every shared quadrilateral face the same from both +sides. The two copies of a fault vertex take the SAME place in that order (their +original's), so the two sides of a fault are triangulated identically and their +faces coincide pair by pair. + +In parallel the sheet is gathered onto one rank, extruded there, and the layer +distributed; the fault pairing rides each redistribution as a pair of labels +(:func:`fault_split.stamp_point_pairs`) and the layer is finally redistributed +so every fault's cell star is rank-interior, the rule the split contact solve +relies on. The one-rank build bounds the layer size by one rank's +memory; a rank-local extrusion is the follow-up. +""" + +from enum import Enum + +import numpy as np +from mpi4py import MPI +from petsc4py import PETSc + +import underworld3 as uw + + +def _sheet_topology(dm): + """Vertex coordinates, triangles (as vertex indices) and the point-to-index + map of a 2-D (or surface) simplex DM; empty arrays on a rank holding no + cells.""" + v_start, v_end = dm.getDepthStratum(0) + c_start, c_end = dm.getHeightStratum(0) + cdim = dm.getCoordinateDim() + coords = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, cdim).copy() + triangles = np.empty((c_end - c_start, 3), dtype=np.int64) + for c in range(c_start, c_end): + closure = dm.getTransitiveClosure(c)[0] + verts = [int(p) - v_start for p in closure if v_start <= p < v_end] + if len(verts) != 3: + raise ValueError("extrude_sheet needs a triangle mesh") + triangles[c - c_start] = verts + return coords, triangles, v_start + + +def _edge_vertices(dm, edge, v_start): + cone = dm.getCone(edge) + return int(cone[0]) - v_start, int(cone[1]) - v_start + + +def _gather_sheet(dm, pairs): + """Everything onto rank 0: ``(dm, pairs)`` in the gathered numbering.""" + from underworld3.utilities.fault_split import redistribute_by_assignment + cS, cE = dm.getHeightStratum(0) + gathered, pairs, _dropped = redistribute_by_assignment( + dm, np.zeros(cE - cS, dtype=np.int32), pairs=pairs) + return gathered, pairs or {} + + +def _extrude_dm(dm2, boundaries, pairs2, thickness, layers, base, surface, + spherical, comm): + """Extrude the sheet DM ``dm2`` (all of it on this rank; empty on the other + ranks in parallel) into a layer DM on ``comm``: ``(dm, members, pairs3)``. + The boundary name/value table ``members`` is decided collectively, so the + layer's boundaries are the same on every rank.""" + m4 = comm.tompi4py() + coords, triangles, v_start = _sheet_topology(dm2) + nv = len(coords) + + # The order that cuts each prism: a fault's minus copy of a vertex takes its + # plus copy's place, so both sides cut their prisms alike. + key = np.arange(nv) + for pairs in pairs2.values(): + for q_minus, q_plus in pairs.items(): + if v_start <= q_minus < v_start + nv: + key[q_minus - v_start] = q_plus - v_start + + # vertices, layer by layer + offsets = -0.5 * thickness + thickness * np.arange(layers + 1) / layers + if spherical: + radius = np.linalg.norm(coords, axis=1) + rhat = coords / radius[:, None] if nv else coords + xyz = np.concatenate([rhat * (radius + s)[:, None] for s in offsets]) + else: + xyz = np.concatenate([np.column_stack([coords, np.full(nv, s)]) for s in offsets]) + + # tetrahedra, three per prism + cells = [] + for k in range(layers): + lo, hi = k * nv, (k + 1) * nv + for tri in triangles: + a, b, c = sorted(tri, key=lambda v: key[v]) + A, B, C = lo + a, lo + b, lo + c + A1, B1, C1 = hi + a, hi + b, hi + c + cells += [[A, B, C, A1], [B, C, A1, B1], [C, A1, B1, C1]] + cells = np.array(cells, dtype=np.int32).reshape(-1, 4) + # PETSc's orientation: the first three vertices see the fourth on the side + # their right-hand normal points AWAY from (a negative triple product). With + # the other sign every volume integral changes sign against the facet ones — + # measured: the domain volume came out -0.1 and a basal drag pushed. + if len(cells): + p = xyz[cells] + vol = np.einsum("ij,ij->i", np.cross(p[:, 1] - p[:, 0], p[:, 2] - p[:, 0]), + p[:, 3] - p[:, 0]) + flip = vol > 0 + cells[flip, 2], cells[flip, 3] = cells[flip, 3].copy(), cells[flip, 2].copy() + + # the cell list on rank 0 only (the other ranks hand in nothing) + dm = PETSc.DMPlex().createFromCellList(3, cells, xyz.reshape(-1, 3), + interpolate=True, comm=comm) + v3_start, _ = dm.getDepthStratum(0) + + def vertex(k, i): + return v3_start + k * nv + i + + def face(verts): + join = dm.getFullJoin(verts) + if len(join) != 1: + raise RuntimeError(f"extrude_sheet internal: no face on vertices {verts}") + return int(join[0]) + + def quad_faces(a, b): + """The two triangles of each layer's quad over the sheet edge (a, b).""" + if key[a] > key[b]: + a, b = b, a + out = [] + for k in range(layers): + A, B, A1, B1 = vertex(k, a), vertex(k, b), vertex(k + 1, a), vertex(k + 1, b) + out += [face([A, B, A1]), face([B, A1, B1])] + return out + + def label_with_closure(name, value, points): + dm.createLabel(name) + for q in points: + for r in dm.getTransitiveClosure(q)[0]: + dm.setLabelValue(name, int(r), value) + + def label_faces(name, value, points): + dm.createLabel(name) + for q in points: + dm.setLabelValue(name, int(q), value) + + # the sheet's boundaries -> walls (fault labels: faces only, as on the sheet) + members = {} + fault_side_names = set() + for name in pairs2: + fault_side_names |= {name, f"{name}Plus", f"{name}Minus"} + e_start, e_end = dm2.getDepthStratum(1) + for b in boundaries: + if b.name == "All_Boundaries": + continue + lab = dm2.getLabel(b.name) + edges = [] + if lab and lab.getStratumSize(b.value) > 0: + edges = [int(q) for q in lab.getStratumIS(b.value).getIndices() + if e_start <= q < e_end] + # a boundary of the sheet's AREA has edges (somewhere); a manifold + # labels the whole surface, for one, and has nothing to stand up as a + # wall. Decided collectively: the table must agree on every rank. + if not m4.allreduce(bool(edges), op=MPI.LOR): + continue + if b.name in (base, surface): + raise ValueError( + f"the sheet has a boundary called {b.name!r}, which the layer's " + f"{'base' if b.name == base else 'surface'} would take; pass " + "another name for it") + members[b.name] = b.value + faces3 = [f for e in edges for f in quad_faces(*_edge_vertices(dm2, e, v_start))] + if b.name in fault_side_names: + label_faces(b.name, b.value, faces3) + else: + label_with_closure(b.name, b.value, faces3) + + taken = set(members.values()) + for name in (base, surface): + # the next value above the ordinary boundaries, below the sentinels + # Null_Boundary (666) and All_Boundaries (1001), as fault_split numbers them + value = max([v for v in taken if v < 666] + [0]) + 1 + while value in taken: + value += 1 + members[name] = value + taken.add(value) + for name, k in ((base, 0), (surface, layers)): + label_with_closure(name, members[name], + [face([vertex(k, i) for i in tri]) for tri in triangles]) + + # the fault pairing: every point on the minus side whose vertices include a + # duplicated one, paired with the point on the same plus-copy vertices + pairs3 = {} + for name, pairs in pairs2.items(): + vmap = {} + for q_minus, q_plus in pairs.items(): + if v_start <= q_minus < v_start + nv: + for k in range(layers + 1): + vmap[vertex(k, q_minus - v_start)] = vertex(k, q_plus - v_start) + value = boundaries[f"{name}Minus"].value + lab = dm.getLabel(f"{name}Minus") + minus_faces = (lab.getStratumIS(value).getIndices() + if lab and lab.getStratumSize(value) > 0 else []) + found = {} + for f in minus_faces: + for q in dm.getTransitiveClosure(int(f))[0]: + q = int(q) + if q in found: + continue + if v3_start <= q < v3_start + nv * (layers + 1): + verts = [q] + else: + verts = [int(r) for r in dm.getTransitiveClosure(q)[0] + if v3_start <= r < v3_start + nv * (layers + 1)] + if not any(v in vmap for v in verts): + continue + mapped = [vmap.get(v, v) for v in verts] + found[q] = mapped[0] if len(mapped) == 1 else face(mapped) + pairs3[name] = found + + # every exterior face, as the sheet's All_Boundaries; a split fault's faces + # have one supporting cell each, so they are marked too and taken out again + dm.markBoundaryFaces("All_Boundaries", 1001) + all_label = dm.getLabel("All_Boundaries") + for name in pairs2: + lab = dm.getLabel(name) + value = boundaries[name].value + if lab and lab.getStratumSize(value): + for q in lab.getStratumIS(value).getIndices(): + all_label.clearValue(int(q), 1001) + return dm, members, pairs3 + + +def extrude_sheet(mesh, thickness, layers=2, base="Base", surface="Surface", + qdegree=None, verbose=False, with_tail=True): + r"""Extrude a sheet mesh through ``thickness`` into a layer of tetrahedra. + + Parameters + ---------- + mesh : Mesh + A 2-D triangle mesh, or a triangulated spherical manifold, possibly + carrying split-node faults (``mesh.add_fault``). + thickness : float + Layer thickness :math:`L`, centred on the sheet. + layers : int, default 2 + Elements through the thickness. + base, surface : str + Names of the two faces of the layer. + qdegree : int, optional + Quadrature degree of the new mesh; the sheet's by default. + with_tail : bool, default True + Give the layer a geometric-multigrid tail: the sheet with the layer + count halved down to one, then the sheet's own coarse levels at one + layer (coarsened through the thickness first, see the note in the code). + + Returns + ------- + Mesh + The layer, with the sheet's boundaries as walls, ``base`` and + ``surface``, and the sheet's faults as vertical faults with their pairing. + """ + from underworld3.coordinates import CoordinateSystemType + from underworld3.utilities.fault_split import ( + _fault_labels_touch_seam, _redistribute_fault_interior, + collect_point_pairs, stamp_point_pairs) + + if layers < 1: + raise ValueError("layers must be at least 1") + spherical = mesh.dim < mesh.cdim + if spherical and (mesh.CoordinateSystem.coordinate_type + is not CoordinateSystemType.SPHERICAL): + raise NotImplementedError("extrude_sheet: only flat sheets and spherical manifolds") + + comm = mesh.dm.comm + parallel = comm.getSize() > 1 + pairs2 = dict(getattr(mesh, "_fault_point_pairs", {}) or {}) + dm2 = mesh.dm + if parallel: + dm2, pairs2 = _gather_sheet(dm2, pairs2) + + dm, members, pairs3 = _extrude_dm(dm2, mesh.boundaries, pairs2, thickness, layers, + base, surface, spherical, comm) + if parallel: + # the layer lives on rank 0: distribute it, carry the pairing through, + # and make every fault's cell star rank-interior (the split's rule) + # The slit disconnects the two sides, so the balanced partition + # readily puts the whole Minus side on one rank and the whole Plus + # side on another (measured at np = 2: 261 of 261 pairs apart). The + # pair labels stay on through BOTH moves and are read once the stars + # are rank-interior. + names = stamp_point_pairs(dm, pairs3) + dm.distribute() + if names: + labels = [(f"{name}{side}", members[f"{name}{side}"]) + for name in names for side in ("Plus", "Minus")] + if _fault_labels_touch_seam(dm, labels): + dm = _redistribute_fault_interior(dm, labels, verbose=verbose) + pairs3, dropped = collect_point_pairs(dm, names) + if dropped: + raise RuntimeError( + f"extrude_sheet: {dropped} fault pair(s) have no rank holding " + "both sides after the fault-interior redistribution") + + boundaries = Enum("boundaries", members) + thick = uw.discretisation.Mesh( + dm, simplex=True, + coordinate_system_type=mesh.CoordinateSystem.coordinate_type, + qdegree=qdegree if qdegree is not None else mesh.qdegree, + boundaries=boundaries, verbose=verbose) + if pairs3: + p_start, p_end = thick.dm.getChart() + if (p_start, p_end) != dm.getChart(): + raise RuntimeError("extrude_sheet internal: the mesh renumbered its points") + thick._fault_point_pairs = pairs3 + if getattr(mesh, "_fault_traces", None): + thick._fault_traces = dict(mesh._fault_traces) + thick._sheet_extrusion = {"sheet": mesh, "thickness": thickness, "layers": layers, + "base": base, "surface": surface} + + # The multigrid tail. A thin layer couples strongly through its thickness + # (the vertical spacing is the smallest), and point smoothers only smooth + # along the strong coupling, so the layer is coarsened through its thickness + # FIRST — the fine sheet with the layer count halved down to one — and only + # then laterally, through the sheet's own coarse levels at one layer. The + # coarse end of the hierarchy is then essentially the thin sheet. Without a + # tail the solve falls back to algebraic multigrid. The faults go with the + # levels (see _extruded_tail). + if with_tail: + tail = _extruded_tail(mesh, thickness, layers, base, surface, qdegree) + if tail: + thick._custom_mg_coarse_meshes = tail + thick._custom_mg_builder = getattr(mesh, "_custom_mg_builder", "barycentric") + thick._custom_mg_fac_zone = None + return thick + + +def _extruded_tail(mesh, thickness, layers, base, surface, qdegree): + """The coarse levels of the layer, coarsest first (see the note in + :func:`extrude_sheet`): the sheet with the layer count halved down to one, + then the sheet's own coarse levels at one layer. + + The faults go with the levels. The through-thickness levels are extrusions + of the sheet itself, slit and pairing included, and a split level of the + sheet's own tail (the add-on levels ``add_fault`` splits) is extruded split + too, so the layer's hierarchy is the split-level multigrid of the sheet one + dimension up (``utilities.fault_mg``: the cell-located transfer reads the + cells of a split level, where the node-cloud builders degenerated on its + coincident nodes and returned NaN — the earlier reason for keeping every + level fault-free). The background levels stay as they are. Each extruded + level keeps its sheet level's static flag. + """ + def ancestors(m): + while m is not None: + yield m + m = getattr(m, "parent", None) + + own = getattr(mesh, "_custom_mg_coarse_meshes", None) + if own is not None: + sheet_levels = list(own) + else: + refined = next((m for m in ancestors(mesh) + if len(getattr(m, "dm_hierarchy", []) or []) > 1), None) + if refined is None: + return [] + sheet_levels = list(refined._coarse_level_meshes())[:-1] + vertical = [] + n = layers // 2 + while n >= 1: + vertical.append(extrude_sheet(mesh, thickness, layers=n, base=base, + surface=surface, qdegree=qdegree, with_tail=False)) + n //= 2 + lateral = [] + for level in sheet_levels: + thick_level = extrude_sheet(level, thickness, layers=1, base=base, surface=surface, + qdegree=qdegree, with_tail=False) + thick_level._mg_static = getattr(level, "_mg_static", False) + lateral.append(thick_level) + return lateral + list(reversed(vertical)) diff --git a/src/underworld3/meshing/spherical.py b/src/underworld3/meshing/spherical.py index 1e2a7618c..4355ddcbb 100644 --- a/src/underworld3/meshing/spherical.py +++ b/src/underworld3/meshing/spherical.py @@ -427,10 +427,16 @@ class boundaries(Enum): # h5 round-trip, which currently loses the explicit coordinate # dimension), then hand the DM to Mesh() which accepts either a # filename or a DMPlex object. + # The option is global: left in place it would load every LATER gmsh + # mesh in this process with three coordinates (a 2-D box after a sphere + # failed on a 3:2 vector-length mismatch), so it is cleared once consumed. PETSc.Options().setValue("dm_plex_gmsh_spacedim", 3) - surface_dm = PETSc.DMPlex().createFromFile( - uw_filename, interpolate=True, comm=PETSc.COMM_WORLD, - ) + try: + surface_dm = PETSc.DMPlex().createFromFile( + uw_filename, interpolate=True, comm=PETSc.COMM_WORLD, + ) + finally: + PETSc.Options().delValue("dm_plex_gmsh_spacedim") surface_dm.setName("uw_mesh") surface_dm.markBoundaryFaces("All_Boundaries", 1001) assert surface_dm.getCoordinateDim() == 3, ( diff --git a/src/underworld3/systems/__init__.py b/src/underworld3/systems/__init__.py index baef1b927..1eacd95ef 100644 --- a/src/underworld3/systems/__init__.py +++ b/src/underworld3/systems/__init__.py @@ -56,6 +56,7 @@ from .solvers import SNES_Poisson as Poisson from .solvers import SNES_Darcy as SteadyStateDarcy from .solvers import SNES_Stokes as Stokes +from .thin_sheet import SNES_ThinSheet as ThinSheet from .solvers import SNES_Stokes_Constrained as Stokes_Constrained from .solvers import SNES_VE_Stokes as VE_Stokes from .solvers import SNES_Projection as Projection diff --git a/src/underworld3/systems/advection_diffusion_eulerian.py b/src/underworld3/systems/advection_diffusion_eulerian.py index 5861d31c8..368d79016 100644 --- a/src/underworld3/systems/advection_diffusion_eulerian.py +++ b/src/underworld3/systems/advection_diffusion_eulerian.py @@ -491,7 +491,8 @@ def V_fn(self): @V_fn.setter def V_fn(self, value): - self.DuDt.V_fn = _as_row_vector(value, self.mesh.dim) + # one velocity component per EMBEDDING dimension (three on a surface mesh) + self.DuDt.V_fn = _as_row_vector(value, self.mesh.cdim) self.is_setup = False @property @@ -534,8 +535,9 @@ def tau_weights(self, values): # ------------------------------------------------------------------ def _diffusive_flux(self): - r"""``(1, dim)`` flux :math:`\sum_k w_k\,\nabla\phi^{(k)}\cdot\kappa` from the constitutive tensor.""" - dim = self.mesh.dim + r"""``(1, cdim)`` flux :math:`\sum_k w_k\,\nabla\phi^{(k)}\cdot\kappa` from the constitutive tensor.""" + # the gradient has one component per EMBEDDING dimension + dim = self.mesh.cdim c = self.constitutive_model.c total = sympy.zeros(1, dim) for w, phi in zip(self.DuDt.spatial_weights(), self.DuDt.states()): diff --git a/src/underworld3/systems/ddt.py b/src/underworld3/systems/ddt.py index 5c39887fe..dd4c65d4a 100644 --- a/src/underworld3/systems/ddt.py +++ b/src/underworld3/systems/ddt.py @@ -1868,7 +1868,9 @@ def _setup_projections(self): self.mesh, self.psi_star[0], verbose=False ) elif self.vtype == uw.VarType.SYM_TENSOR or self.vtype == uw.VarType.TENSOR: - dim = self.mesh.dim + # a tensor history has one index per EMBEDDING dimension (a 3x3 + # stress on a sheet in three-space) + dim = self.mesh.cdim if self.vtype == uw.VarType.SYM_TENSOR: Nc = math.comb(dim + 1, 2) # 3 in 2D, 6 in 3D self._psi_star_indep_indices = [ @@ -2249,7 +2251,7 @@ def V_fn(self): @V_fn.setter def V_fn(self, value): - self._V_fn = None if value is None else _as_row_vector(value, self.mesh.dim) + self._V_fn = None if value is None else _as_row_vector(value, self.mesh.cdim) @property def integrator(self) -> str: @@ -2259,7 +2261,7 @@ def advecting_velocity(self, level: int = 0): """The velocity carrying the unknown at ``states()[level]``.""" if level == 0 or not self.V_fn_history: return self.V_fn - return _as_row_vector(self.V_fn_history[level - 1], self.mesh.dim) + return _as_row_vector(self.V_fn_history[level - 1], self.mesh.cdim) @property def tau_shape(self) -> str: @@ -2292,7 +2294,8 @@ def tau_weights(self, values): def _convective(self, a, psi): r"""``(a . grad) psi`` entry by entry, a matrix of ``psi``'s shape.""" - dim = self.mesh.dim + # velocity components: one per EMBEDDING dimension (three on a surface mesh) + dim = self.mesh.cdim grad = self.mesh.vector.gradient def entry(r, c): @@ -2312,7 +2315,8 @@ def advection(self): def tau(self): r"""The stabilisation parameter :math:`\tau` (times the weights).""" - dim = self.mesh.dim + # velocity components: one per EMBEDDING dimension (three on a surface mesh) + dim = self.mesh.cdim a = self.advecting_velocity(0) a_mag2 = sum(a[0, i] ** 2 for i in range(dim)) h = self.mesh.cell_size() @@ -2405,7 +2409,8 @@ def _transport_residual(self, solver): The theta rule at :attr:`transport_theta`, Crank-Nicolson by default. """ - dim = self.mesh.dim + # velocity components: one per EMBEDDING dimension (three on a surface mesh) + dim = self.mesh.cdim S, S_old = solver.u.sym, self._transport_old.sym gradient = self.mesh.vector.gradient a = self.advecting_velocity(0) @@ -2591,7 +2596,7 @@ def velocity_units(self): def _new_level(self, k): snap = uw.discretisation.MeshVariable( - f"vtrace_{self.instance_number}_n{k}", self.mesh, self.mesh.dim, + f"vtrace_{self.instance_number}_n{k}", self.mesh, self.mesh.cdim, degree=self.velocity_degree(), continuous=True, varsymbol=rf"{{ V^{{ (n-{k}) }}_{{ [{self.instance_number}] }} }}", units=self.velocity_units(), @@ -2617,7 +2622,7 @@ def level_valid(self, k): def _evaluate_into(self, level): vals = uw.function.evaluate(self.V_matrix(), np.asarray(level["var"].coords_nd)) vals = _to_nondim_ndarray(vals, units=self.velocity_units()) - level["var"].data[...] = np.asarray(vals).reshape(-1, self.mesh.dim) + level["var"].data[...] = np.asarray(vals).reshape(-1, self.mesh.cdim) def initialise_levels(self, n): """Every cached level ``1 .. n-1`` <- ``V_fn`` now (a history starting @@ -2678,7 +2683,7 @@ def velocity_at(self, expr, coords, use_global=False, evalf=False, v_at_pts = np.asarray(v_result) v_at_pts = v_at_pts[:, 0, :] if v_at_pts.ndim == 3 else v_at_pts out = _to_nondim_ndarray(v_at_pts, units=uw.get_units(self.V_fn)) - return np.asarray(out).reshape(coords.shape[0], self.mesh.dim) + return np.asarray(out).reshape(coords.shape[0], self.mesh.cdim) # -- the steps and the trace ----------------------------------------------- @@ -3893,6 +3898,15 @@ def _sample_history_at_departure( # here as dead code — see git history for the sketch if the # `preserve_moments` option is ever implemented. + # TODO(BUG): on a mesh with a split-node fault the trace-back samples the + # history with a point evaluation, which is unreliable within about h/2 of + # the slit and can read the other side of the fault. Measured 2026-10-09 on + # a Maxwell thin sheet with a sliding Coulomb fault: a period-2-step + # oscillation of the near-tip slip rate (amplitude doubling at half dt, + # unchanged by a 1e-11 solver tolerance) and patchy stress along the trace, + # both gone with the grid (Eulerian SUPG) history. A Stokes viscoelastic + # model with a split fault uses this history by default and is exposed too. + # See issue #871. def update_pre_solve( self, dt: float, diff --git a/src/underworld3/systems/navier_stokes_eulerian.py b/src/underworld3/systems/navier_stokes_eulerian.py index 3e9715c90..1144cbfe9 100644 --- a/src/underworld3/systems/navier_stokes_eulerian.py +++ b/src/underworld3/systems/navier_stokes_eulerian.py @@ -237,6 +237,12 @@ def __init__( # solve: the extrapolation, or the latest Picard iterate) and the # level n-1 the extrapolation needs beyond what the history holds. u = self.Unknowns.u + # TODO(BUG): this solver sizes its velocity-shaped fields and loops by + # mesh.dim (here, and in the SUPG terms and speed estimates below). On a + # surface mesh the velocity has mesh.cdim components; the same sizing was + # fixed in EulerianSUPG and the Eulerian AdvDiffusion (2026-10-09, + # thin-sheet work). Not fixed here: not used on a surface mesh yet. + # See issue #872. self._a_var = uw.discretisation.MeshVariable( f"a_NSSUPG_{tag}", self.mesh, self.mesh.dim, degree=u.degree, continuous=u.continuous, varsymbol=rf"\mathbf{{a}}_{{{tag}}}") @@ -427,9 +433,12 @@ def _viscous_stress(self, u_row): eta = self.constitutive_model.K return 2 * eta * sympy.Matrix(self.mesh.vector.strain_tensor(u_row)) - def _viscous_flux(self): + def _viscous_flux(self, tau_new=None): r"""The flux of the time scheme: the new stress, blended with the stored levels. + ``tau_new`` replaces the constitutive model's stress at the new level (the + Newton tangent passes the model's tangent flux through here). + The theta rule weights the flux across time levels, so it needs the stress at the stored levels as well as the new one. For a viscous fluid that is rebuilt from the stored velocity, :math:`2\eta\dot\varepsilon(\mathbf{u}^{n})`. @@ -442,7 +451,8 @@ def _viscous_flux(self): """ states = self.DuDt.states() weights = self.DuDt.spatial_weights() - total = weights[0] * self.stress_deviator + total = weights[0] * (self.stress_deviator if tau_new is None + else sympy.Matrix(tau_new)) stress_history = self.Unknowns.DFDt for level, (w, u_k) in enumerate(zip(weights[1:], states[1:])): if w == 0: @@ -484,18 +494,25 @@ def F0(self): @property def F1(self): """Pointwise flux: weighted viscous stress, mechanical pressure, SUPG term.""" - dim = self.mesh.dim - mechanical_pressure = ( - self.p.sym[0] - self.penalty * self.constitutive_model.K * self.div_u) F1 = public_expression( r"\mathbf{F}_1\left( \mathbf{u} \right)", - self._viscous_flux() + self._devss_flux() - sympy.eye(dim) * mechanical_pressure - + self._stabilisation_flux(), + self._flux_closure(self.stress_deviator), "Navier-Stokes SUPG: viscous flux of the time scheme, pressure, tau R (x) a", ) self._u_f1 = F1 return F1 + def _flux_closure(self, tau): + """The momentum flux built on the new-level deviatoric stress ``tau``: + the time scheme's viscous flux, DEVSS, the mechanical pressure and the + SUPG term. ``F1`` applies it to the constitutive model's flux, the Newton + tangent to the model's tangent flux (``flux_jacobian``).""" + dim = self.mesh.dim + mechanical_pressure = ( + self.p.sym[0] - self.penalty * self.constitutive_model.K * self.div_u) + return (self._viscous_flux(tau) + self._devss_flux() + - sympy.eye(dim) * mechanical_pressure + self._stabilisation_flux()) + # ------------------------------------------------------------------ # Timestep and solve # ------------------------------------------------------------------ diff --git a/src/underworld3/systems/solvers.py b/src/underworld3/systems/solvers.py index 3f165fa6e..fcd7fe7fb 100644 --- a/src/underworld3/systems/solvers.py +++ b/src/underworld3/systems/solvers.py @@ -86,6 +86,24 @@ def expression(*args, **kwargs): return public_expression(*args, _unique_name_generation=True, **kwargs) +def _nondimensional_row(value, cdim): + """A ``(1, cdim)`` sympy row from a sequence whose entries may be + dimensional ``UWQuantity`` values (converted to the solver's non-dimensional + scale) or plain numbers / expressions.""" + # TODO(DESIGN): SNES_Stokes.bodyforce repeats this conversion inline; share it. + row = [] + for item in value: + if isinstance(item, uw.function.quantities.UWQuantity): + nd = uw.non_dimensionalise(item) + nd = nd.magnitude if hasattr(nd, "magnitude") else nd + if hasattr(nd, "shape") and tuple(nd.shape) == (1, 1): + nd = nd[0, 0] + row.append(nd) + else: + row.append(item) + return sympy.Matrix(row).reshape(1, cdim) + + def _history_record(constitutive_model): """What a stress history stores: the model's memory part of the flux when it separates one out (a solvent viscosity is rebuilt each step), else the @@ -493,6 +511,298 @@ def constitutive_model_is_setup(self): CM_is_setup = constitutive_model_is_setup +class _StressHistoryMixin: + """The viscoelastic stress history, shared by every solver whose + constitutive model carries one (Stokes, the thin sheet). + + A model with ``requires_stress_history`` gets a history manager + (``Unknowns.DFDt``) from :meth:`_create_stress_history_ddt`; a solve then + runs :meth:`_stress_history_prepare` before the build, + :meth:`_stress_history_advance` once per step before the momentum solve, + and :meth:`_stress_history_post_solve` after it. The DEVSS hook is a no-op + here; the Stokes solver, which carries the split, overrides it. + """ + + def _devss_refresh(self, verbose=False): + """No DEVSS split on this solver.""" + return + + @property + def stress_transport(self) -> str: + """How a viscoelastic stress history is carried: ``"semi_lagrangian"`` + (default), ``"integration_point"``, ``"forward"``, ``"lagrangian"`` or + ``"eulerian"``. + + ``"forward"`` carries the stress from a fixed set of launch points inside + the cells (the integration points), one forward trajectory a step, and + fits the arrivals per cell; the constitutive flux is read at the launch + points through a continuous P1 projection. It holds the Maxwell + start-up below Courant one where the integration-point history rings + (see :class:`~underworld3.systems.ddt.ForwardSemiLagrangian`). + + The semi-Lagrangian history traces the stress back along characteristics + and stores it on a nodal field, which the assembler then interpolates to + the integration points: two interpolations a step. ``"integration_point"`` + traces back to the integration points themselves and holds the history + there, so it carries one evaluation error and needs no projection. The + Eulerian one transports the stress on the grid with the same + streamline-upwind stabilisation the Eulerian solvers use, and gives the + same answer on any partition. ``"lagrangian"`` carries the stress on a + swarm of material points the solver creates and advects, reading the + constitutive flux at the particles each step and never projecting it + back to the mesh: no numerical diffusion of the history, at the cost of + the swarm (see :class:`~underworld3.systems.ddt.Lagrangian`). The default + is ``"semi_lagrangian"``. Set + it before the constitutive model is assigned: assigning the model + creates the history, and the choice cannot change after that. + """ + return getattr(self, "_stress_transport", "semi_lagrangian") + + @stress_transport.setter + def stress_transport(self, value): + value = str(value) + if value not in ("semi_lagrangian", "integration_point", "forward", + "lagrangian", "eulerian"): + raise ValueError( + "stress_transport must be 'semi_lagrangian', 'integration_point', " + f"'forward', 'lagrangian' or 'eulerian', not {value!r}.") + if self.Unknowns.DFDt is not None: + raise RuntimeError( + "the stress history already exists: set stress_transport before the " + "constitutive model that asks for one.") + self._stress_transport = value + + + def _stress_history_prepare(self, timestep, _force_setup=False): + """Set the elastic timestep and the flags a rebuild depends on. + + Runs BEFORE the solver is built. The effective order of the stress + history ramps over the opening steps, and when it changes the compiled + functions must be rewired; setting that flag after the build has already + decided whether to set up leaves the managed multigrid block asking a + preconditioner for sub-solvers it has not created (#727). + """ + # dt_elastic must always equal the solve timestep. The constitutive + # model's VE formulas (eta_eff, stress history terms) all reference + # Parameters.dt_elastic. If it differs from the actual timestep, + # the stress computation is inconsistent with the time integration. + self.constitutive_model.Parameters.dt_elastic = timestep + # The integrator coefficients must be current BEFORE the history is + # first carried: a trace-back history initialises its first level from + # the constitutive flux of the velocity it finds, and with the + # exponential integrator that flux read the viscous-limit coefficients + # (alpha = phi = 0) until the update that used to follow the carry + # (#740). The BDF coefficients are refreshed again after the carry, as + # before, since they read the step history the carry updates. + self.constitutive_model._update_history_coefficients() + + if _force_setup: + self._needs_function_rewire = True + + # Re-setup when effective_order changes (DDt history ramp-up) + _current_eff_order = self.constitutive_model.effective_order + if _current_eff_order != self._prev_effective_order: + self._needs_function_rewire = True + self.constitutive_model._solver_is_setup = False + self._prev_effective_order = _current_eff_order + + if not self.constitutive_model._solver_is_setup: + self._needs_function_rewire = True + self.DFDt.psi_fn = _history_psi_fn(self.constitutive_model) + # D starts from the velocity as it is now, so the DEVSS pair + # cancels on the first step as it does on every later one. + self._devss_refresh() + + def _stress_history_advance(self, timestep, verbose=False, evalf=False): + """Carry the stress history to where the momentum solve will read it. + + Runs AFTER the solver is built, and once per step: a solver that takes + several passes over the momentum equation (the Navier-Stokes one, with + its Picard corrections) must not advance the history once per pass. + """ + if uw.mpi.rank == 0 and verbose: + print("Stokes solver - carry the stress history", flush=True) + + self.DFDt.update_pre_solve(timestep, verbose=verbose, evalf=evalf, + store_result=False) + # Uniform pre-solve coefficient hook: each model refreshes its own BDF + # or exponential (α, φ) coefficients. No isinstance checks at the + # solver layer. + self.constitutive_model._update_history_coefficients() + + def _stress_history_post_solve(self, timestep, verbose=False, evalf=False): + """Commit the stress the solve produced and shift the history levels.""" + # The history manager places the new stress in level 0 and shifts the + # levels. A particle-carried history does that itself in its post-solve, + # by evaluating the new stress at its own particles. + if not self.DFDt.commits_flux_in_post_solve: + self.DFDt.commit_flux_to_history(_history_record(self.constitutive_model), verbose=verbose) + + self.DFDt.update_post_solve(timestep, verbose=verbose, evalf=evalf) + self._devss_refresh(verbose=verbose) + + # Uniform post-solve hook for any extra integrator-state storage. + # VEP: no-op. ETD-2 / MaxwellExponentialFlowModel: refresh + # forcing_star with current ε̇^{n+1} so the next step's history + # term has access to ε̇ⁿ. + self.constitutive_model._update_history_post_solve() + + self.is_setup = True + self.constitutive_model._solver_is_setup = True + + def _create_stress_history_ddt(self, order=2): + """Create DFDt for stress history tracking (VE/VEP models). + + Called automatically when a constitutive model with + ``requires_stress_history = True`` is assigned. Can also be called + explicitly to pre-create the DFDt with a specific order. + :attr:`stress_transport` chooses which flavour carries it. + + Constitutive models can inject extra SemiLagrangian kwargs via the + ``stress_history_ddt_kwargs`` property — used e.g. by + ``MaxwellExponentialFlowModel`` to set ``with_forcing_history=True``. + """ + if self.Unknowns.DFDt is not None: + return # already created + + self._order = order + # Constitutive model may request extra SemiLagrangian kwargs (e.g. + # with_forcing_history for ETD-2 integration). + cm = getattr(self, "constitutive_model", None) + ddt_kwargs = {} + if cm is not None: + ddt_kwargs = dict(getattr(cm, "stress_history_ddt_kwargs", {})) + + common = dict( + vtype=uw.VarType.SYM_TENSOR, + degree=self.u.degree - 1, + continuous=True, + varsymbol=rf"{{F[ {self.u.symbol} ] }}", + verbose=self.verbose, + bcs=None, + order=order, + smoothing=0.0001, + # the history carries a stress (the flux handed over at construction + # is a zero placeholder, so it cannot say so itself), or the + # dimensionless log-conformation of one + units=uw.units.Pa if getattr(cm, "_stress_history", "stress") == "stress" else None, + ) + if self.stress_transport == "integration_point": + unsupported = set(ddt_kwargs) - {"with_forcing_history"} + if unsupported: + raise NotImplementedError( + f"{type(cm).__name__} asks its stress history for " + f"{sorted(unsupported)}, which the integration-point flavour " + "does not provide; use stress_transport='semi_lagrangian'.") + self.Unknowns.DFDt = uw.systems.ddt.IntegrationPointSemiLagrangian( + self.mesh, + sympy.Matrix.zeros(self.mesh.cdim, self.mesh.cdim), + self.u.sym, + **ddt_kwargs, + **{k: v for k, v in common.items() if k != "smoothing"}, + ) + elif self.stress_transport == "forward": + if ddt_kwargs: + raise NotImplementedError( + f"{type(cm).__name__} asks its stress history for " + f"{sorted(ddt_kwargs)}, which the forward flavour does not provide; " + "use stress_transport='semi_lagrangian' for it.") + self.Unknowns.DFDt = uw.systems.ddt.ForwardSemiLagrangian( + self.mesh, + sympy.Matrix.zeros(self.mesh.cdim, self.mesh.cdim), + self.u.sym, + vtype=common["vtype"], varsymbol=common["varsymbol"], order=order, + units=common["units"], + ) + elif self.stress_transport == "lagrangian": + if ddt_kwargs: + raise NotImplementedError( + f"{type(cm).__name__} asks its stress history for " + f"{sorted(ddt_kwargs)}, which the particle Lagrangian flavour does " + "not provide; use stress_transport='semi_lagrangian' for it.") + # Order 1 BDF only for now: the exponential integrator is not + # validated on the particle flavour, and neither is order 2. Refuse cleanly rather than crash inside the first solve. + if getattr(cm, "_integrator", "bdf") != "bdf": + raise NotImplementedError( + "the particle Lagrangian stress history supports the BDF " + "integrator only; use stress_transport='semi_lagrangian' for the " + "exponential one.") + if order > 1: + raise NotImplementedError( + "the particle Lagrangian stress history is first order for now; " + "use stress_transport='semi_lagrangian' for order 2.") + # The solver owns the swarm: Lagrangian creates and populates it, and + # carries the stress on it. Lagrangian_Swarm (a user-supplied swarm) + # stays available by passing DFDt= to the constructor. + # A cells proxy (discontinuous, reconstructed from the particles in + # each cell) is what the particle history is validated on + # (test_0070); the continuous nodal store the mesh flavours use is + # not the right target for a swarm-carried field. + lag_common = {k: v for k, v in common.items() if k != "continuous"} + self.Unknowns.DFDt = uw.systems.ddt.Lagrangian( + self.mesh, + sympy.Matrix.zeros(self.mesh.cdim, self.mesh.cdim), + self.u.sym, + continuous=False, + proxy_location="cells", + **lag_common, + ) + elif self.stress_transport == "eulerian": + if ddt_kwargs: + raise NotImplementedError( + f"{type(cm).__name__} asks its stress history for " + f"{sorted(ddt_kwargs)}, which only the semi-Lagrangian flavour " + "provides; use stress_transport='semi_lagrangian' for it.") + self.Unknowns.DFDt = uw.systems.ddt.EulerianSUPG( + self.mesh, + sympy.Matrix.zeros(self.mesh.cdim, self.mesh.cdim), + self.u.sym, + transport_on_update=True, + **common, + ) + else: + self.Unknowns.DFDt = uw.systems.ddt.SemiLagrangian( + self.mesh, + sympy.Matrix.zeros(self.mesh.cdim, self.mesh.cdim), + self.u.sym, + **ddt_kwargs, + **common, + ) + # Stress flux = 2·viscosity·E_eff references psi_star[0] in E_eff's + # history term — without snapshot substitution the projection of + # flux→psi_star[0] becomes implicit in psi_star[0] and Min-mode at + # yield admits the wrong fixed point under timestep change. + self.Unknowns.DFDt.enable_source_snapshot() + # the history stores the model's encoding of a stress; an inflow datum + # is given as a stress and stored through the same encoding + self.Unknowns.DFDt._encode = getattr(cm, "encode_history", None) + + @property + def tau(self): + r"""Deviatoric stress from the most recent solve. + + When stress history is active (VEP), returns ``psi_star[0]`` which + contains the actual projected stress; a log-conformation history is + decoded into a projected stress variable. Otherwise falls through to + the base class lazy projection. + """ + if self.Unknowns.DFDt is not None: + cm = self.constitutive_model + if getattr(cm, "_stress_history", "stress") == "stress": + return self.DFDt.psi_star[0] + if getattr(self, "_tau_decoded", None) is None: + self._tau_decoded = uw.discretisation.MeshVariable( + "tau_decoded", self.mesh, (self.mesh.cdim, self.mesh.cdim), + vtype=uw.VarType.SYM_TENSOR, degree=self.DFDt.psi_star[0].degree, + continuous=True, units=uw.units.Pa if uw.get_default_model().has_units() else None) + self._tau_decode = uw.systems.Tensor_Projection(self.mesh, self._tau_decoded) + self._tau_decode.smoothing = 0.0 + self._tau_decode.uw_function = cm._carried_stress_sym(0) + self._tau_decode.solve() + return self._tau_decoded + return super().tau + + class SNES_Poisson(_ConstitutiveModelStateMixin, SNES_Scalar): r"""Poisson equation solver. @@ -1374,7 +1684,7 @@ def _penalty_value(penalty_expression): return float("nan") # symbolic: not a number, so not zero -class SNES_Stokes(_ConstitutiveModelStateMixin, SNES_Stokes_SaddlePt): +class SNES_Stokes(_StressHistoryMixin, _ConstitutiveModelStateMixin, SNES_Stokes_SaddlePt): r""" Stokes equation solver for incompressible viscous flow. @@ -1552,65 +1862,53 @@ def set_jacobian_F1_source(self, F1_source, linesearch="cp"): Alternative expression of the same shape as ``F1.sym``. linesearch : str or None, default ``"cp"`` SNES linesearch type to install when ``F1_source`` is set. - Defaults to ``"cp"`` (critical-point) because inexact-Newton - steps don't reliably reduce the residual norm and PETSc's - default ``bt`` (backtracking) consequently rejects useful - steps with ``DIVERGED_LINE_SEARCH``. ``cp`` accepts the - predicted step at the optimum of the local linearisation and - converges cleanly on the same problems where ``bt`` flails. - Set to ``None`` to leave the linesearch type untouched (e.g. - if you've already configured one via ``petsc_options``). - Has no effect when ``F1_source is None``. - """ - self._F1_jacobian_source = F1_source - self._needs_function_rewire = True - if F1_source is not None and linesearch is not None: - self.petsc_options["snes_linesearch_type"] = linesearch - - @property - def stress_transport(self) -> str: - """How a viscoelastic stress history is carried: ``"semi_lagrangian"`` - (default), ``"integration_point"``, ``"forward"``, ``"lagrangian"`` or - ``"eulerian"``. - - ``"forward"`` carries the stress from a fixed set of launch points inside - the cells (the integration points), one forward trajectory a step, and - fits the arrivals per cell; the constitutive flux is read at the launch - points through a continuous P1 projection. It holds the Maxwell - start-up below Courant one where the integration-point history rings - (see :class:`~underworld3.systems.ddt.ForwardSemiLagrangian`). - - The semi-Lagrangian history traces the stress back along characteristics - and stores it on a nodal field, which the assembler then interpolates to - the integration points: two interpolations a step. ``"integration_point"`` - traces back to the integration points themselves and holds the history - there, so it carries one evaluation error and needs no projection. The - Eulerian one transports the stress on the grid with the same - streamline-upwind stabilisation the Eulerian solvers use, and gives the - same answer on any partition. ``"lagrangian"`` carries the stress on a - swarm of material points the solver creates and advects, reading the - constitutive flux at the particles each step and never projecting it - back to the mesh: no numerical diffusion of the history, at the cost of - the swarm (see :class:`~underworld3.systems.ddt.Lagrangian`). The default - is ``"semi_lagrangian"``. Set - it before the constitutive model is assigned: assigning the model - creates the history, and the choice cannot change after that. + Defaults to ``"cp"`` (critical-point) because inexact-Newton + steps don't reliably reduce the residual norm and PETSc's + default ``bt`` (backtracking) consequently rejects useful + steps with ``DIVERGED_LINE_SEARCH``. ``cp`` accepts the + predicted step at the optimum of the local linearisation and + converges cleanly on the same problems where ``bt`` flails. + Set to ``None`` to leave the linesearch type untouched (e.g. + if you've already configured one via ``petsc_options``). + Has no effect when ``F1_source is None``. """ - return getattr(self, "_stress_transport", "semi_lagrangian") + self._F1_jacobian_source = F1_source + self._needs_function_rewire = True + if F1_source is not None and linesearch is not None: + self.petsc_options["snes_linesearch_type"] = linesearch - @stress_transport.setter - def stress_transport(self, value): - value = str(value) - if value not in ("semi_lagrangian", "integration_point", "forward", - "lagrangian", "eulerian"): - raise ValueError( - "stress_transport must be 'semi_lagrangian', 'integration_point', " - f"'forward', 'lagrangian' or 'eulerian', not {value!r}.") - if self.Unknowns.DFDt is not None: - raise RuntimeError( - "the stress history already exists: set stress_transport before the " - "constitutive model that asks for one.") - self._stress_transport = value + def add_basal_drag(self, conds, boundary, reference_velocity=None): + r"""Couple the model to what lies beneath ``boundary`` by a drag + :math:`-c\,(\mathbf{u} - \mathbf{u}_\mathrm{ref})` per unit area: a + natural condition :math:`c\,(\mathbf{u} - \mathbf{u}_\mathrm{ref})`. + + The thick form of ``ThinSheet.add_basal_drag``: a thin sheet modelled as + a thin 3-D layer takes the same line. ``reference_velocity`` is the + velocity of the substrate (``None``: a fixed base); for a viscous + substrate of viscosity :math:`\eta_a` and thickness :math:`h_a`, + :math:`c = \eta_a / h_a`. The drag acts on every component, so the base + also resists normal motion, which the sheet never has. ``conds`` is the + drag coefficient :math:`c`.""" + u = self.Unknowns.u.sym + dim = self.mesh.dim + if reference_velocity is None: + reference = sympy.zeros(1, dim) + elif isinstance(reference_velocity, uw.discretisation.MeshVariable): + reference = sympy.Matrix(reference_velocity.sym).reshape(1, dim) + else: + reference = _nondimensional_row(reference_velocity, dim) + self.add_natural_bc(conds * (u - reference), boundary) + + def add_basal_traction(self, conds, boundary): + r"""Apply a traction :math:`\mathbf{t}` (``conds``, force per unit area, + exerted on the model) on ``boundary``; the thick form of + ``ThinSheet.add_basal_traction``.""" + dim = self.mesh.dim + if isinstance(conds, uw.discretisation.MeshVariable): + row = sympy.Matrix(conds.sym).reshape(1, dim) + else: + row = _nondimensional_row(conds, dim) + self.add_natural_bc(-row, boundary) # ----- DEVSS: stabilising a discontinuous elastic stress ----- @@ -1700,210 +1998,6 @@ def _devss_refresh(self, verbose=False): if i != j: self._devss_D.array[:, j, i] = values - def _stress_history_prepare(self, timestep, _force_setup=False): - """Set the elastic timestep and the flags a rebuild depends on. - - Runs BEFORE the solver is built. The effective order of the stress - history ramps over the opening steps, and when it changes the compiled - functions must be rewired; setting that flag after the build has already - decided whether to set up leaves the managed multigrid block asking a - preconditioner for sub-solvers it has not created (#727). - """ - # dt_elastic must always equal the solve timestep. The constitutive - # model's VE formulas (eta_eff, stress history terms) all reference - # Parameters.dt_elastic. If it differs from the actual timestep, - # the stress computation is inconsistent with the time integration. - self.constitutive_model.Parameters.dt_elastic = timestep - # The integrator coefficients must be current BEFORE the history is - # first carried: a trace-back history initialises its first level from - # the constitutive flux of the velocity it finds, and with the - # exponential integrator that flux read the viscous-limit coefficients - # (alpha = phi = 0) until the update that used to follow the carry - # (#740). The BDF coefficients are refreshed again after the carry, as - # before, since they read the step history the carry updates. - self.constitutive_model._update_history_coefficients() - - if _force_setup: - self._needs_function_rewire = True - - # Re-setup when effective_order changes (DDt history ramp-up) - _current_eff_order = self.constitutive_model.effective_order - if _current_eff_order != self._prev_effective_order: - self._needs_function_rewire = True - self.constitutive_model._solver_is_setup = False - self._prev_effective_order = _current_eff_order - - if not self.constitutive_model._solver_is_setup: - self._needs_function_rewire = True - self.DFDt.psi_fn = _history_psi_fn(self.constitutive_model) - # D starts from the velocity as it is now, so the DEVSS pair - # cancels on the first step as it does on every later one. - self._devss_refresh() - - def _stress_history_advance(self, timestep, verbose=False, evalf=False): - """Carry the stress history to where the momentum solve will read it. - - Runs AFTER the solver is built, and once per step: a solver that takes - several passes over the momentum equation (the Navier-Stokes one, with - its Picard corrections) must not advance the history once per pass. - """ - if uw.mpi.rank == 0 and verbose: - print("Stokes solver - carry the stress history", flush=True) - - self.DFDt.update_pre_solve(timestep, verbose=verbose, evalf=evalf, - store_result=False) - # Uniform pre-solve coefficient hook: each model refreshes its own BDF - # or exponential (α, φ) coefficients. No isinstance checks at the - # solver layer. - self.constitutive_model._update_history_coefficients() - - def _stress_history_post_solve(self, timestep, verbose=False, evalf=False): - """Commit the stress the solve produced and shift the history levels.""" - # The history manager places the new stress in level 0 and shifts the - # levels. A particle-carried history does that itself in its post-solve, - # by evaluating the new stress at its own particles. - if not self.DFDt.commits_flux_in_post_solve: - self.DFDt.commit_flux_to_history(_history_record(self.constitutive_model), verbose=verbose) - - self.DFDt.update_post_solve(timestep, verbose=verbose, evalf=evalf) - self._devss_refresh(verbose=verbose) - - # Uniform post-solve hook for any extra integrator-state storage. - # VEP: no-op. ETD-2 / MaxwellExponentialFlowModel: refresh - # forcing_star with current ε̇^{n+1} so the next step's history - # term has access to ε̇ⁿ. - self.constitutive_model._update_history_post_solve() - - self.is_setup = True - self.constitutive_model._solver_is_setup = True - - def _create_stress_history_ddt(self, order=2): - """Create DFDt for stress history tracking (VE/VEP models). - - Called automatically when a constitutive model with - ``requires_stress_history = True`` is assigned. Can also be called - explicitly to pre-create the DFDt with a specific order. - :attr:`stress_transport` chooses which flavour carries it. - - Constitutive models can inject extra SemiLagrangian kwargs via the - ``stress_history_ddt_kwargs`` property — used e.g. by - ``MaxwellExponentialFlowModel`` to set ``with_forcing_history=True``. - """ - if self.Unknowns.DFDt is not None: - return # already created - - self._order = order - # Constitutive model may request extra SemiLagrangian kwargs (e.g. - # with_forcing_history for ETD-2 integration). - cm = getattr(self, "constitutive_model", None) - ddt_kwargs = {} - if cm is not None: - ddt_kwargs = dict(getattr(cm, "stress_history_ddt_kwargs", {})) - - common = dict( - vtype=uw.VarType.SYM_TENSOR, - degree=self.u.degree - 1, - continuous=True, - varsymbol=rf"{{F[ {self.u.symbol} ] }}", - verbose=self.verbose, - bcs=None, - order=order, - smoothing=0.0001, - # the history carries a stress (the flux handed over at construction - # is a zero placeholder, so it cannot say so itself), or the - # dimensionless log-conformation of one - units=uw.units.Pa if getattr(cm, "_stress_history", "stress") == "stress" else None, - ) - if self.stress_transport == "integration_point": - unsupported = set(ddt_kwargs) - {"with_forcing_history"} - if unsupported: - raise NotImplementedError( - f"{type(cm).__name__} asks its stress history for " - f"{sorted(unsupported)}, which the integration-point flavour " - "does not provide; use stress_transport='semi_lagrangian'.") - self.Unknowns.DFDt = uw.systems.ddt.IntegrationPointSemiLagrangian( - self.mesh, - sympy.Matrix.zeros(self.mesh.dim, self.mesh.dim), - self.u.sym, - **ddt_kwargs, - **{k: v for k, v in common.items() if k != "smoothing"}, - ) - elif self.stress_transport == "forward": - if ddt_kwargs: - raise NotImplementedError( - f"{type(cm).__name__} asks its stress history for " - f"{sorted(ddt_kwargs)}, which the forward flavour does not provide; " - "use stress_transport='semi_lagrangian' for it.") - self.Unknowns.DFDt = uw.systems.ddt.ForwardSemiLagrangian( - self.mesh, - sympy.Matrix.zeros(self.mesh.dim, self.mesh.dim), - self.u.sym, - vtype=common["vtype"], varsymbol=common["varsymbol"], order=order, - units=common["units"], - ) - elif self.stress_transport == "lagrangian": - if ddt_kwargs: - raise NotImplementedError( - f"{type(cm).__name__} asks its stress history for " - f"{sorted(ddt_kwargs)}, which the particle Lagrangian flavour does " - "not provide; use stress_transport='semi_lagrangian' for it.") - # Order 1 BDF only for now: the exponential integrator is not - # validated on the particle flavour, and neither is order 2. Refuse cleanly rather than crash inside the first solve. - if getattr(cm, "_integrator", "bdf") != "bdf": - raise NotImplementedError( - "the particle Lagrangian stress history supports the BDF " - "integrator only; use stress_transport='semi_lagrangian' for the " - "exponential one.") - if order > 1: - raise NotImplementedError( - "the particle Lagrangian stress history is first order for now; " - "use stress_transport='semi_lagrangian' for order 2.") - # The solver owns the swarm: Lagrangian creates and populates it, and - # carries the stress on it. Lagrangian_Swarm (a user-supplied swarm) - # stays available by passing DFDt= to the constructor. - # A cells proxy (discontinuous, reconstructed from the particles in - # each cell) is what the particle history is validated on - # (test_0070); the continuous nodal store the mesh flavours use is - # not the right target for a swarm-carried field. - lag_common = {k: v for k, v in common.items() if k != "continuous"} - self.Unknowns.DFDt = uw.systems.ddt.Lagrangian( - self.mesh, - sympy.Matrix.zeros(self.mesh.dim, self.mesh.dim), - self.u.sym, - continuous=False, - proxy_location="cells", - **lag_common, - ) - elif self.stress_transport == "eulerian": - if ddt_kwargs: - raise NotImplementedError( - f"{type(cm).__name__} asks its stress history for " - f"{sorted(ddt_kwargs)}, which only the semi-Lagrangian flavour " - "provides; use stress_transport='semi_lagrangian' for it.") - self.Unknowns.DFDt = uw.systems.ddt.EulerianSUPG( - self.mesh, - sympy.Matrix.zeros(self.mesh.dim, self.mesh.dim), - self.u.sym, - transport_on_update=True, - **common, - ) - else: - self.Unknowns.DFDt = uw.systems.ddt.SemiLagrangian( - self.mesh, - sympy.Matrix.zeros(self.mesh.dim, self.mesh.dim), - self.u.sym, - **ddt_kwargs, - **common, - ) - # Stress flux = 2·viscosity·E_eff references psi_star[0] in E_eff's - # history term — without snapshot substitution the projection of - # flux→psi_star[0] becomes implicit in psi_star[0] and Min-mode at - # yield admits the wrong fixed point under timestep change. - self.Unknowns.DFDt.enable_source_snapshot() - # the history stores the model's encoding of a stress; an inflow datum - # is given as a stress and stored through the same encoding - self.Unknowns.DFDt._encode = getattr(cm, "encode_history", None) - @timing.routine_timer_decorator @memprobe.instrument("Stokes.solve") def solve( @@ -2129,31 +2223,6 @@ def solve( ("penalty", "augmented-Lagrangian grad-div penalty (0 = off)"), ) - @property - def tau(self): - r"""Deviatoric stress from the most recent solve. - - When stress history is active (VEP), returns ``psi_star[0]`` which - contains the actual projected stress; a log-conformation history is - decoded into a projected stress variable. Otherwise falls through to - the base class lazy projection. - """ - if self.Unknowns.DFDt is not None: - cm = self.constitutive_model - if getattr(cm, "_stress_history", "stress") == "stress": - return self.DFDt.psi_star[0] - if getattr(self, "_tau_decoded", None) is None: - self._tau_decoded = uw.discretisation.MeshVariable( - "tau_decoded", self.mesh, (self.mesh.dim, self.mesh.dim), - vtype=uw.VarType.SYM_TENSOR, degree=self.DFDt.psi_star[0].degree, - continuous=True, units=uw.units.Pa if uw.get_default_model().has_units() else None) - self._tau_decode = uw.systems.Tensor_Projection(self.mesh, self._tau_decoded) - self._tau_decode.smoothing = 0.0 - self._tau_decode.uw_function = cm._carried_stress_sym(0) - self._tau_decode.solve() - return self._tau_decoded - return super().tau - # ========================================================================= # PETSc Residual Templates # These define the weak form terms assembled by PETSc's finite element system. @@ -2172,7 +2241,7 @@ def tau(self): F1 = Template( r"\mathbf{F}_1\left( \mathbf{u} \right)", - lambda self: self.stress + self._devss_flux(), + lambda self: self._flux_closure(self.stress_deviator), r"""Velocity equation flux/stress term (pointwise). The $\mathbf{F}_1$ tensor represents the stress response of the fluid, @@ -2353,11 +2422,23 @@ def stress(self): stress_deviator : Deviatoric (traceless) part, penalty-free. penalty : The augmentation, and its effect on the recovered pressure. """ + return self._stress_from_deviator(self.stress_deviator) + + def _stress_from_deviator(self, tau): + """The total stress built on a deviatoric stress ``tau``: less the + mechanical pressure, which carries the grad-div penalty.""" mechanical_pressure = ( self.p.sym[0] - self.penalty * self.constitutive_model.K * self.div_u ) - return self.stress_deviator - sympy.eye(self.mesh.dim) * mechanical_pressure + return sympy.Matrix(tau) - sympy.eye(self.mesh.dim) * mechanical_pressure + + def _flux_closure(self, tau): + """The momentum flux built on a deviatoric stress: the total stress + plus the DEVSS term. ``F1`` is this applied to the constitutive model's + flux; the Newton tangent applies it to the model's tangent flux + (``flux_jacobian``), so those terms stay in the tangent.""" + return self._stress_from_deviator(tau) + self._devss_flux() @property def stress_1d(self): diff --git a/src/underworld3/systems/thin_sheet.py b/src/underworld3/systems/thin_sheet.py new file mode 100644 index 000000000..81cf5e973 --- /dev/null +++ b/src/underworld3/systems/thin_sheet.py @@ -0,0 +1,595 @@ +r"""Thin-sheet solver: a vertically averaged (plane-stress) viscous or +viscoelastic sheet, flat or on a surface embedded in three dimensions. + +The sheet carries a tangential velocity :math:`\mathbf{u}` and a thickness +:math:`L`. Vertical averaging with the plane-stress closure +:math:`\sigma_{nn} = 0` on the dynamic stress eliminates the pressure: +with :math:`\boldsymbol{\tau}` the deviatoric stress of an incompressible +material, :math:`p = \tau_{nn} = -\operatorname{tr}_s \boldsymbol{\tau}`, so the +in-plane stress is + +.. math:: + + \boldsymbol{\sigma} = \boldsymbol{\tau} + + \operatorname{tr}(\boldsymbol{\tau})\,\mathbf{P}, + \qquad \mathbf{P} = \mathbf{I} - \hat{\mathbf{n}}\hat{\mathbf{n}}^T, + +(England & McKenzie 1982). Any deviatoric constitutive law applies unchanged, +a Maxwell viscoelastic one included (a transversely isotropic one on a flat +sheet only); for a Newtonian sheet +:math:`\boldsymbol{\sigma} = 2\eta\,(\dot{\boldsymbol\varepsilon}_s ++ \operatorname{tr}\dot{\boldsymbol\varepsilon}_s\,\mathbf{P})`, whose uniaxial +limit is the Trouton viscosity :math:`3\eta`. The momentum balance solved is + +.. math:: + + -\nabla_s \cdot \left( L\,\boldsymbol{\sigma} \right) + + c\,(\mathbf{u} - \mathbf{u}_\mathrm{ref}) + = L\,\mathbf{f} + \mathbf{t}_b, + +with :math:`c` a basal drag coefficient (:math:`\eta_a/h_a` for a viscous +substrate) against a reference velocity, :math:`\mathbf{f}` a body force per +unit volume (as for ``Stokes``) and :math:`\mathbf{t}_b` a basal traction. There +is no pressure unknown, so this is a vector problem, not a saddle point; the +plane-stress pressure :math:`-\operatorname{tr}\boldsymbol\tau` is available as +the diagnostic ``p``. + +On a flat mesh :math:`\mathbf{P} = \mathbf{I}` and the velocity has two +components. On a manifold (``mesh.dim < mesh.cdim``, e.g. +:class:`~underworld3.meshing.SphericalManifold`) the velocity is carried in the +embedding space with :math:`\mathbf{P}` built from the surface normal; the +in-plane strain rate is :math:`\dot{\boldsymbol\varepsilon}_s = \mathbf{P}\, +\operatorname{sym}(\nabla_s \mathbf{u})\,\mathbf{P}`, the outer projection +supplying the curvature term :math:`-\mathbf{u}\cdot\nabla_s\hat{\mathbf{n}}`. +""" + +from typing import Optional, Union + +import numpy as np +import sympy + +import underworld3 as uw +import underworld3.timing as timing +from underworld3.coordinates import CoordinateSystemType +from underworld3.systems import SNES_Vector +from underworld3.utilities._api_tools import Template + +# the solvers' factory: a sheet's L and c_b are its own, never a user's +# same-named expression and never another sheet's +from .solvers import (_ConstitutiveModelStateMixin, _StressHistoryMixin, _nondimensional_row, + expression) +from .ddt import SemiLagrangian as SemiLagrangian_DDt +from .ddt import Lagrangian as Lagrangian_DDt + + +def _is_transversely_isotropic(model_or_class): + """Whether a constitutive model (a class, an instance, or a composite's + constituents) is transversely isotropic.""" + ti = uw.constitutive_models.TransverseIsotropicFlowModel + if isinstance(model_or_class, type): + return issubclass(model_or_class, ti) + parts = getattr(model_or_class, "_constitutive_models", None) or [model_or_class] + return any(isinstance(part, ti) for part in parts) + + +def _sheet_normal(mesh): + r"""Unit normal :math:`\hat{\mathbf{n}}` of the sheet as a ``(1, cdim)`` + row, or ``None`` on a flat (volume) mesh.""" + if mesh.dim == mesh.cdim: + return None + if mesh.CoordinateSystem.coordinate_type is CoordinateSystemType.SPHERICAL: + return sympy.Matrix(mesh.CoordinateSystem.unit_e_0).reshape(1, mesh.cdim) + raise NotImplementedError( + "ThinSheet needs the surface normal of the sheet: only a flat mesh " + "or a spherical manifold (CoordinateSystemType.SPHERICAL) is supported.") + + +def _facet_normal_field(mesh): + r"""Unit normal of each (flat) cell of a manifold mesh as a cell-wise + constant vector field, built once per mesh. + + The strain rate the assembler sees is the gradient along the flat facets, + so the in-plane projector has to use the facet's own normal: with the + analytic normal of the true surface a rigid rotation (a Killing field, + zero strain on the sphere) keeps a spurious strain of order :math:`h` + (measured: 0.7% of the velocity at h = 0.25). The sign of the normal does + not enter :math:`\mathbf{n}\mathbf{n}^T`. + """ + field = getattr(mesh, "_sheet_facet_normal", None) + if field is None: + field = uw.discretisation.MeshVariable( + "sheet_facet_normal", mesh, mesh.cdim, degree=0, continuous=False) + c_start, c_end = mesh.dm.getHeightStratum(0) + normals = np.array([mesh.dm.computeCellGeometryFVM(c)[2] for c in range(c_start, c_end)]) + normals /= np.linalg.norm(normals, axis=1)[:, None] + field.array[:, 0, :] = normals + mesh._sheet_facet_normal = field + return field + + +def _tangent_projector(mesh): + r""":math:`\mathbf{P} = \mathbf{I} - \mathbf{n}\mathbf{n}^T` with the + facet normal of the cell (the identity on a flat mesh), ``(cdim, cdim)``.""" + if mesh.dim == mesh.cdim: + return sympy.eye(mesh.cdim) + n = sympy.Matrix(_facet_normal_field(mesh).sym).reshape(1, mesh.cdim) + return sympy.eye(mesh.cdim) - n.T * n + + +def _surface_spin(mesh, L): + r"""Spin of a material element of a sheet on a curved surface, + + .. math:: + + \mathbf{W} = \mathrm{skew}(\mathbf{P}\mathbf{L}\mathbf{P}) + + \dot{\mathbf{n}}\mathbf{n}^T - \mathbf{n}\dot{\mathbf{n}}^T, + \qquad \dot{\mathbf{n}} = -\mathbf{L}^T\mathbf{n}, + + with :math:`\mathbf{n}` the cell's facet normal, :math:`\mathbf{P}` its + projector and :math:`\mathbf{L}` the surface velocity gradient the + assembler supplies (projected on the derivative index only). The first term + is the rotation within the surface; the second turns the normal, which for + a tangential flow rotates at :math:`-\mathbf{L}^T\mathbf{n}` because + :math:`\nabla_s(\mathbf{u}\cdot\mathbf{n}) = 0`. For a rigid rotation + :math:`\mathbf{u} = \boldsymbol\omega\times\mathbf{X}` (exact in the P2 + space) this is the rotation tensor :math:`\boldsymbol\Omega` itself, so a + Jaumann-rate stress is carried rigidly; :math:`\mathrm{skew}(\mathbf{L})` + alone misses the turning of the normal.""" + n = sympy.Matrix(_facet_normal_field(mesh).sym).reshape(mesh.cdim, 1) + P = sympy.eye(mesh.cdim) - n * n.T + L = sympy.Matrix(L) + PLP = P * L * P + ndot = -L.T * n + return (PLP - PLP.T) / 2 + ndot * n.T - n * ndot.T + + +class SNES_ThinSheet(_StressHistoryMixin, _ConstitutiveModelStateMixin, SNES_Vector): + r"""Vertically averaged thin sheet (plane stress), viscous or viscoelastic. + + Solves + + .. math:: + + -\nabla_s \cdot \left( L\,\boldsymbol{\sigma} \right) + + c\,(\mathbf{u} - \mathbf{u}_\mathrm{ref}) + = L\,\mathbf{f} + \mathbf{t}_b, + \qquad + \boldsymbol{\sigma} = \boldsymbol{\tau} + + \operatorname{tr}(\boldsymbol{\tau})\,\mathbf{P}, + + for the sheet velocity :math:`\mathbf{u}`, with :math:`\boldsymbol{\tau}` + the deviatoric stress of the constitutive model evaluated on the in-plane + strain rate :math:`\dot{\boldsymbol\varepsilon}_s`. See the module + docstring for the closure. + + Parameters + ---------- + mesh : Mesh + A flat mesh (``dim == cdim``) or a spherical manifold. + velocityField : MeshVariable, optional + Sheet velocity, ``cdim`` components. Created if not supplied. + pressureField : MeshVariable, optional + Receives the plane-stress pressure :math:`p = -\operatorname{tr}\boldsymbol\tau` + after each solve (a diagnostic; the sheet has no pressure unknown). + Created on first access of :attr:`p` if not supplied. The signature is + Stokes', so a model script moves between a thin sheet and a thick one + (``Stokes`` on a thin 3-D mesh) by changing the mesh and the class. + degree : int, default 2 + Polynomial degree of the velocity space. + p_continuous : bool, default True + Continuity of the pressure diagnostic (degree ``degree - 1``). + verbose : bool, default False + DuDt, DFDt : optional + Time-derivative managers; a viscoelastic constitutive model creates + its own stress history when none is supplied. + + Examples + -------- + A Newtonian sheet stretched along :math:`x` with free lateral edges + carries :math:`\sigma_{xx} = 3\eta\dot\varepsilon` (the Trouton ratio): + + >>> sheet = uw.systems.ThinSheet(mesh) + >>> sheet.constitutive_model = uw.constitutive_models.ViscousFlowModel + >>> sheet.constitutive_model.Parameters.shear_viscosity_0 = 1 + >>> sheet.add_essential_bc([-1, None], "Left") + >>> sheet.add_essential_bc([1, None], "Right") + >>> sheet.solve() + """ + + class _Unknowns(SNES_Vector._Unknowns): + """The sheet's kinematics: the strain rate handed to the constitutive + model is the in-plane one, :math:`\mathbf{P}\,\mathrm{sym}(\nabla_s + \mathbf{u})\,\mathbf{P}`.""" + + @SNES_Vector._Unknowns.u.setter + def u(inner_self, new_u): + SNES_Vector._Unknowns.u.fset(inner_self, new_u) + if new_u is not None: + P = _tangent_projector(new_u.mesh) + inner_self._E = P * inner_self._E * P + inner_self._Einv2 = inner_self.second_invariant(sympy.Matrix(inner_self._E)) + if new_u.mesh.dim < new_u.mesh.cdim: + inner_self._W = _surface_spin(new_u.mesh, inner_self._L) + + def second_invariant(inner_self, M): + r"""Second invariant of the 3-D deviatoric tensor that an in-plane + tensor :math:`M` stands for: incompressibility puts + :math:`M_{nn} = -\operatorname{tr} M` on the sheet normal, so + + .. math:: M_{II} = \sqrt{\tfrac12\left(M_{ij}M_{ij} + + (\operatorname{tr} M)^2\right)}. + + Without the normal component a plane-stress uniaxial stretch yields + at :math:`\sqrt{18/5}\,\tau_y` instead of the von Mises + :math:`\sqrt{3}\,\tau_y` (the sheet thins where it stretches).""" + return sympy.sqrt(((M**2).trace() + M.trace() ** 2) / 2) + + _solver_terms = ( + ("thickness", "sheet thickness L multiplying the in-plane stress"), + ("bodyforce", "body force per unit volume; F0 carries -L f"), + ("basal_drag", "drag coefficient c on the sheet velocity (eta_a / h_a)"), + ) + + @timing.routine_timer_decorator + def __init__( + self, + mesh: uw.discretisation.Mesh, + velocityField: Optional[uw.discretisation.MeshVariable] = None, + pressureField: Optional[uw.discretisation.MeshVariable] = None, + degree: Optional[int] = 2, + p_continuous: Optional[bool] = True, + verbose: Optional[bool] = False, + DuDt: Union[SemiLagrangian_DDt, Lagrangian_DDt] = None, + DFDt: Union[SemiLagrangian_DDt, Lagrangian_DDt] = None, + ): + super().__init__(mesh, velocityField, degree, verbose, DuDt=DuDt, DFDt=DFDt) + + self._P = _tangent_projector(mesh) + self._thickness = expression(R"L", 1, "Sheet thickness") + self._basal_drag = expression(R"c_b", 0, "Basal drag coefficient") + self._bodyforce = expression( + Rf"\mathbf{{f}}_0\left( {self.Unknowns.u.symbol} \right)", + sympy.Matrix([[0] * self.mesh.cdim]), + "Thin-sheet body force per unit volume: f_0(u)", + ) + # basal coupling added by add_basal_drag / add_basal_traction: a list of + # (1, cdim) rows entering F0 as residual terms (a drag c (u - u_ref), + # a traction with its sign reversed) + self._basal_terms = [] + # The pressure is not an unknown of the sheet; p = -tr(tau) is recovered + # after each solve into a diagnostic field, created on demand so a script + # written for Stokes (which reads solver.p) runs unchanged. + self._p_degree = max(degree - 1, 1) + self._p_continuous = p_continuous + self._p_field = pressureField + self._p_projection = None + self._needs_function_rewire = True + self._constitutive_model = None + # On a manifold the velocity is carried in the embedding space; its + # tangency u . n = 0 is a strong rotated constraint at every node (the + # same per-node rotation as rotated free-slip and the split-node fault + # pairs, so faults compose with it). Registering it routes solve() + # through the rotated path. + if mesh.dim < mesh.cdim: + from underworld3.utilities.rotated_bc import SHEET_TANGENCY + self._rotated_freeslip_bcs.append((SHEET_TANGENCY, _sheet_normal(mesh))) + # The stress history is carried on the grid (the streamline-upwind + # transport the Eulerian solvers use). On a surface mesh the + # semi-Lagrangian trace-back leaves the faceted surface; next to a + # split-node fault its point evaluation reads across the slit (measured: + # a period-2-step slip-rate oscillation that the grid history removes, + # at a sixth of the Newton work). + self._stress_transport = "eulerian" + # Last-seen effective order of the stress history (it ramps over the + # opening steps); a change rewires the pointwise functions. + self._prev_effective_order = None + + @timing.routine_timer_decorator + def solve( + self, + zero_init_guess: bool = None, + timestep: float = None, + _force_setup: bool = False, + verbose: bool = False, + evalf: bool = False, + divergence_retries: int = 0, + ): + """Solve the sheet momentum balance; with a viscoelastic constitutive + model, carry the stress history across the step first. + + Parameters + ---------- + timestep : float + Required when the constitutive model carries a stress history: the + elastic timestep of the Maxwell element. + zero_init_guess, verbose, divergence_retries + As for :meth:`SNES_Vector.solve`. + evalf : bool, default False + Numerical evaluation during the history carry. + """ + if self.Unknowns.DFDt is None: + super().solve(zero_init_guess, _force_setup=_force_setup, verbose=verbose, + divergence_retries=divergence_retries) + self._refresh_pressure(verbose) + return + if timestep is None: + raise ValueError( + "timestep is required for a viscoelastic sheet: call " + "sheet.solve(timestep=dt)") + if (self.mesh.dim < self.mesh.cdim + and getattr(self.constitutive_model, "_objective_rate", "none") == "upper_convected"): + raise NotImplementedError( + "the upper-convected rate needs the full velocity gradient of a " + "material element, which a sheet on a curved surface does not yet " + "supply; use objective_rate='jaumann' (the spin is supplied) or 'none'.") + self._stress_history_prepare(timestep, _force_setup=_force_setup) + if not self.is_setup: + self._setup_pointwise_functions(verbose) + self._setup_discretisation(verbose) + self._setup_solver(verbose) + self._stress_history_advance(timestep, verbose=verbose, evalf=evalf) + super().solve(zero_init_guess, _force_setup=_force_setup, verbose=verbose, + divergence_retries=divergence_retries) + self._stress_history_post_solve(timestep, verbose=verbose, evalf=evalf) + self._keep_history_tangent() + self._refresh_pressure(verbose) + + def _keep_history_tangent(self): + r"""On a manifold, project every stored history level onto the tangent + plane, :math:`\boldsymbol\sigma^* \leftarrow \mathbf{P}\boldsymbol\sigma^*\mathbf{P}`, + with the analytic surface normal at each history node. + + The history is transported component by component in the embedding + space, so a tensor carried along the curved surface keeps its Cartesian + components while the tangent plane under it turns: it acquires normal + components, which the plane-stress closure reads as a trace and so as a + spurious pressure. Measured without this, an unfaulted Maxwell sheet + driven by a divergence-free flow carried normal components up to 31% of + the stress by t = 3. The projection removes the normal part left after + the rotation. + + It is applied to the COMMITTED stress, after the solve, never between the + transport and the solve. After the transport the history at a node still + carries the components it had upstream, tangent to the upstream plane; + the objective rotation inside the solve (the spin includes the turning of + the normal) turns them into the node's own plane. Projecting first throws + those components away before the rotation can use them: measured on a + rigid quarter-turn of the sphere, a first-order error (4.0e-2 at 40 steps, + 1.9e-2 at 80) where the flat sheet carries the stress to 5e-4.""" + if self.mesh.dim == self.mesh.cdim: + return + for level in self.Unknowns.DFDt.psi_star: + coords = level.coords + rhat = coords / np.linalg.norm(coords, axis=1)[:, None] + P = np.eye(self.mesh.cdim)[None] - rhat[:, :, None] * rhat[:, None, :] + projected = P @ np.asarray(level.array) @ P + # symmetric to round-off; a symmetric tensor variable stores one + # value per off-diagonal pair and refuses two that differ + level.array[...] = 0.5 * (projected + projected.transpose(0, 2, 1)) + + @property + def constitutive_model(self): + """Constitutive model of the sheet (see + :attr:`SNES_Stokes.constitutive_model`). A Jaumann stress history with a + first-order step is carried by the Cayley rotation unless the model was + given an ``objective_order``: exactly orthogonal, so a rigid rotation of + the sheet carries its stress rigidly at any step.""" + return SNES_Vector.constitutive_model.fget(self) + + @constitutive_model.setter + def constitutive_model(self, model_or_class): + # refused before it is installed: its strain rate is the full symmetric + # gradient, not the sheet's projected one, so a rigid rotation of the + # sphere would strain it + if self.mesh.dim < self.mesh.cdim and _is_transversely_isotropic(model_or_class): + raise NotImplementedError( + "a transversely isotropic model on a manifold sheet: its strain " + "rate is not projected onto the surface; use it on a flat sheet") + SNES_Vector.constitutive_model.fset(self, model_or_class) + cm = self._constitutive_model + if (getattr(cm, "_objective_rate", "none") == "jaumann" + and not cm._objective_order_given and cm.order == 1): + cm._objective_order = "cayley" + + @property + def stress_transport(self) -> str: + """How a viscoelastic stress history is carried (see + :attr:`SNES_Stokes.stress_transport`). The default is the grid + (``"eulerian"``, the streamline-upwind transport). On a manifold it is + the only one: the semi-Lagrangian trace-back leaves the faceted surface. + On a flat mesh the others may be chosen, but next to a split-node fault + the trace-back reads across the slit.""" + return super().stress_transport + + @stress_transport.setter + def stress_transport(self, value): + if self.mesh.dim < self.mesh.cdim and str(value) != "eulerian": + raise ValueError( + f"stress_transport={value!r} is not available on a manifold " + "mesh: the thin sheet carries its history on the grid " + "('eulerian'); the semi-Lagrangian trace-back leaves the " + "faceted surface.") + _StressHistoryMixin.stress_transport.fset(self, value) + + # ------------------------------------------------------------------ + # geometry + # ------------------------------------------------------------------ + + @property + def tangent_projector(self): + r""":math:`\mathbf{P} = \mathbf{I} - \mathbf{n}\mathbf{n}^T` with the + cell's facet normal; the identity on a flat mesh. The tangency + constraint at the nodes uses the analytic normal of the surface.""" + return self._P + + # ------------------------------------------------------------------ + # parameters + # ------------------------------------------------------------------ + + @property + def thickness(self): + r"""Sheet thickness :math:`L` (a constant or an expression).""" + return self._thickness + + @thickness.setter + def thickness(self, value): + self._needs_function_rewire = True + self._thickness.sym = value + + @property + def basal_drag(self): + r"""Basal drag coefficient :math:`c` in :math:`c\,\mathbf{u}`; for a + viscous substrate of viscosity :math:`\eta_a` and thickness + :math:`h_a`, :math:`c = \eta_a / h_a`.""" + return self._basal_drag + + @basal_drag.setter + def basal_drag(self, value): + self._needs_function_rewire = True + self._basal_drag.sym = value + + def add_basal_drag(self, conds, boundary=None, reference_velocity=None): + r"""Couple the sheet to its base by a drag + :math:`-c\,(\mathbf{u} - \mathbf{u}_\mathrm{ref})` per unit area. + + ``reference_velocity`` is the velocity of what lies beneath (a viscous + substrate's flow, a mantle convection model's surface velocity, plate + motions); ``None`` drags against a fixed base. For a viscous substrate + of viscosity :math:`\eta_a` and thickness :math:`h_a`, + :math:`c = \eta_a / h_a`; ``conds`` is :math:`c`. Calls accumulate. + + ``boundary`` names the base of a thick sheet (``Stokes`` on a thin 3-D + mesh applies the same drag there as a natural condition). The thin sheet + is its own base, so it is accepted and not used — the same model line + serves both.""" + self._needs_function_rewire = True + u = self.Unknowns.u.sym + if reference_velocity is None: + reference = sympy.zeros(1, self.mesh.cdim) + elif isinstance(reference_velocity, uw.discretisation.MeshVariable): + reference = sympy.Matrix(reference_velocity.sym).reshape(1, self.mesh.cdim) + else: + reference = _nondimensional_row(reference_velocity, self.mesh.cdim) + self._basal_terms.append(conds * (u - reference)) + + def add_basal_traction(self, conds, boundary=None): + r"""Apply a traction :math:`\mathbf{t}` (``conds``, force per unit area, + exerted on the sheet) at its base — the shear stress of a convection model + resolved at the base of the lithosphere, say. Calls accumulate. + ``boundary`` as in :meth:`add_basal_drag`.""" + self._needs_function_rewire = True + if isinstance(conds, uw.discretisation.MeshVariable): + row = sympy.Matrix(conds.sym).reshape(1, self.mesh.cdim) + else: + row = _nondimensional_row(conds, self.mesh.cdim) + self._basal_terms.append(-row) + + def _basal_residual(self): + total = self.basal_drag.sym * self.Unknowns.u.sym + for term in self._basal_terms: + total = total + term + return total + + @property + def p(self): + r"""Plane-stress pressure :math:`p = -\operatorname{tr}\boldsymbol\tau` + (the out-of-plane deviatoric stress), refreshed after each solve. A + diagnostic: the sheet has no pressure unknown.""" + if self._p_field is None: + self._p_field = uw.discretisation.MeshVariable( + f"p_sheet_{self.instance_number}", self.mesh, 1, + degree=self._p_degree, continuous=self._p_continuous) + # asked for after a solve: filled from that solve, not left at zero + # until the next one + if self.is_setup: + self._refresh_pressure() + return self._p_field + + def _refresh_pressure(self, verbose=False): + if self._p_field is None: + return + if self._p_projection is None: + self._p_projection = uw.systems.Projection(self.mesh, self._p_field) + self._p_projection.smoothing = 0.0 + # After a viscoelastic solve the newest history level is the Maxwell + # stress just committed (the flux reads the history and so, by now, the + # shifted one); the solvent's Newtonian part, which the history does not + # carry, is the flux less the history flux (the history terms cancel). + if self.Unknowns.DFDt is not None: + cm = self.constitutive_model + stress = sympy.Matrix(self.tau.sym) + history_flux = getattr(cm, "history_flux", None) + if history_flux is not None: + stress = stress + sympy.Matrix(cm.flux) - sympy.Matrix(history_flux) + else: + stress = self.stress_deviator + self._p_projection.uw_function = -sympy.Matrix(stress).trace() + self._p_projection.solve(verbose=verbose) + + @property + def bodyforce(self): + r"""Body force per unit VOLUME :math:`\mathbf{f}`, as for ``Stokes``; + the sheet integrates it through its thickness, :math:`L\mathbf{f}`. + Forcing that acts on the base of the sheet (per unit area) belongs in + :meth:`add_basal_traction` or :meth:`add_basal_drag`.""" + return self._bodyforce + + @bodyforce.setter + def bodyforce(self, value): + self._needs_function_rewire = True + if isinstance(value, uw.function.expressions.UWexpression): + self._bodyforce.sym = value.sym + else: + self._bodyforce.sym = _nondimensional_row(value, self.mesh.cdim) + + # ------------------------------------------------------------------ + # kinematics and stress + # ------------------------------------------------------------------ + + @property + def strainrate(self): + r"""In-plane strain rate :math:`\dot{\boldsymbol\varepsilon}_s = + \mathbf{P}\,\mathrm{sym}(\nabla_s \mathbf{u})\,\mathbf{P}`.""" + return sympy.Matrix(self.Unknowns.E) + + @property + def stress_deviator(self): + r"""In-plane deviatoric stress :math:`\boldsymbol{\tau}` from the + constitutive model.""" + return sympy.Matrix(self.constitutive_model.flux) + + @property + def stress(self): + r"""In-plane sheet stress :math:`\boldsymbol{\sigma} = \boldsymbol{\tau} + + \operatorname{tr}(\boldsymbol{\tau})\,\mathbf{P}` (plane-stress + closure, :math:`p = -\operatorname{tr}\boldsymbol{\tau}`).""" + tau = self.stress_deviator + return tau + tau.trace() * self._P + + def _flux_closure(self, tau): + r"""The momentum flux built on a deviatoric stress, + :math:`L(\boldsymbol\tau + \operatorname{tr}(\boldsymbol\tau)\mathbf{P})`. + ``F1`` is this applied to the constitutive model's flux; the Newton + tangent applies it to the model's tangent flux (``flux_jacobian``), so + the plane-stress closure stays in the tangent.""" + tau = sympy.Matrix(tau) + return self.thickness.sym * (tau + tau.trace() * self._P) + + # ------------------------------------------------------------------ + # PETSc residual templates + # ------------------------------------------------------------------ + + F0 = Template( + r"\mathbf{f}_0\left( \mathbf{u} \right)", + lambda self: -self.thickness.sym * self.bodyforce.sym + self._basal_residual(), + r"""Pointwise term: the basal coupling (drag + :math:`c\,(\mathbf{u} - \mathbf{u}_\mathrm{ref})`, less any basal + traction) less the depth-integrated body force :math:`L\mathbf{f}`.""", + ) + + F1 = Template( + r"\mathbf{F}_1\left( \mathbf{u} \right)", + lambda self: self._flux_closure(self.stress_deviator), + r"""Flux term: the depth-integrated in-plane stress :math:`L\, + \boldsymbol{\sigma}`.""", + ) diff --git a/src/underworld3/utilities/_jit_graph.py b/src/underworld3/utilities/_jit_graph.py index f2fd1f612..7d5cf8956 100644 --- a/src/underworld3/utilities/_jit_graph.py +++ b/src/underworld3/utilities/_jit_graph.py @@ -444,9 +444,10 @@ def lower_callbacks(fns, mesh): fn = fn.doit() fn = graph.lower(fn) if isinstance(fn, sympy.vector.Vector): - fn = fn.to_matrix(mesh.N)[0:mesh.dim, 0] + # one component per EMBEDDING dimension (three on a surface mesh in 3-D) + fn = fn.to_matrix(mesh.N)[0:mesh.cdim, 0] elif isinstance(fn, sympy.vector.Dyadic): - fn = fn.to_matrix(mesh.N)[0:mesh.dim, 0:mesh.dim] + fn = fn.to_matrix(mesh.N)[0:mesh.cdim, 0:mesh.cdim] else: fn = sympy.Matrix([fn]) out.append(fn) diff --git a/src/underworld3/utilities/_jitextension.py b/src/underworld3/utilities/_jitextension.py index 503eef789..1230eaf5f 100644 --- a/src/underworld3/utilities/_jitextension.py +++ b/src/underworld3/utilities/_jitextension.py @@ -1354,9 +1354,9 @@ def _basescalar_ccode(self, printer): ) if isinstance(fn, sympy.vector.Vector): - fn = fn.to_matrix(mesh.N)[0 : mesh.dim, 0] + fn = fn.to_matrix(mesh.N)[0 : mesh.cdim, 0] elif isinstance(fn, sympy.vector.Dyadic): - fn = fn.to_matrix(mesh.N)[0 : mesh.dim, 0 : mesh.dim] + fn = fn.to_matrix(mesh.N)[0 : mesh.cdim, 0 : mesh.cdim] else: fn = sympy.Matrix([fn]) diff --git a/src/underworld3/utilities/custom_mg.py b/src/underworld3/utilities/custom_mg.py index e0b1fb659..79928544a 100644 --- a/src/underworld3/utilities/custom_mg.py +++ b/src/underworld3/utilities/custom_mg.py @@ -983,127 +983,6 @@ def _count_zero_columns_parallel(P, comm): return nzero -def _repair_zero_columns_parallel(P, coords_f, fine_layout, coords_u, cols_u, - ncomp, comm): - """Give each unreached coarse DOF a nearest-fine-DOF entry (parallel). - - The counterpart of :func:`_repair_zero_columns_serial` for the - cross-partition build: a coarse DOF no fine node reaches (the fine - mesh gathered onto a surgery rank, a placed band, a relaxed child) - leaves an empty column and a singular Galerkin coarse operator. Each - rank reads the empty columns it OWNS off P^T.1, the orphan list is - all-gathered (small), every rank offers its nearest OWNED fine DOF - of the same component, and the rank holding the nearest one sets the - injection entry. Returns ``(P, n_repaired)``; ``P`` is re-assembled. - """ - m4 = comm.tompi4py() - ones_f = P.createVecLeft() - ones_f.set(1.0) - colsum = P.createVecRight() - P.multTranspose(ones_f, colsum) - cstart, _cend = colsum.getOwnershipRange() - zero_local = np.flatnonzero(colsum.array == 0.0) + cstart - ones_f.destroy() - colsum.destroy() - zero = np.concatenate(m4.allgather(zero_local.astype(np.int64))) - if zero.size == 0: - return P, 0 - # global column -> (coarse node coordinate, component) via the gathered cloud - where = {int(cols_u[n, c]): (n, c) - for n in range(cols_u.shape[0]) for c in range(ncomp) - if cols_u[n, c] >= 0} - from scipy.spatial import cKDTree - l2g_f, fstart, fend = fine_layout.l2g, fine_layout.rstart, fine_layout.rend - tree = cKDTree(coords_f) if coords_f.shape[0] else None - offers = [] # (distance, global fine row) - for col in zero.tolist(): - node_c, comp = where[int(col)] - best = (np.inf, -1) - if tree is not None: - k = min(8, coords_f.shape[0]) - d, idx = tree.query(coords_u[node_c], k=k) - for dd, i in zip(np.atleast_1d(d), np.atleast_1d(idx)): - grow = int(l2g_f[int(i) * ncomp + comp]) - if fstart <= grow < fend: # an OWNED fine row - best = (float(dd), grow) - break - offers.append(best) - # the nearest offer across ranks wins each orphan - dist = np.array([o[0] for o in offers]) - rows = np.array([o[1] for o in offers], dtype=np.int64) - all_dist = np.vstack(m4.allgather(dist)) - all_rows = np.vstack(m4.allgather(rows)) - winner = np.argmin(all_dist, axis=0) - for j, col in enumerate(zero.tolist()): - if winner[j] == m4.rank and np.isfinite(all_dist[winner[j], j]): - P.setValues([int(all_rows[winner[j], j])], [int(col)], [1.0], - addv=PETSc.InsertMode.INSERT_VALUES) - P.assemble() - return P, int(zero.size) - - -def _assert_no_zero_columns_parallel(P, comm): - """Parallel zero-column guard: a coarse DOF with no fine image -> singular - Galerkin coarse operator.""" - nzero = _count_zero_columns_parallel(P, comm) - if nzero: - raise RuntimeError( - f"parallel transfer has {nzero} zero columns (coarse DOFs with no fine " - f"image) — BC-per-level reduction failed; coarse operator would be singular.") - - -def _assert_no_zero_columns_serial(P_csr, level): - """Serial zero-column guard — same physics as the parallel guard above: a - coarse DOF with no fine image makes the Galerkin coarse operator singular.""" - nzero = int((np.asarray((P_csr != 0).sum(axis=0)).ravel() == 0).sum()) - if nzero: - raise RuntimeError( - f"transfer {level - 1}->{level} has {nzero} zero columns (coarse DOFs " - f"with no fine image) — BC-per-level reduction failed; coarse " - f"operator would be singular.") - - -def _repair_zero_columns_serial(P_csr, coords_c, coords_f, map_c, map_f, - nc, level): - """Give each unreached coarse DOF a nearest-fine-DOF entry (serial). - - The barycentric builder has LOCAL support, so on NON-NESTED level pairs - — two independently PLACED meshes (#626), a relaxed child (#424) — a - coarse DOF can lose every fine image and its Galerkin column goes - singular. The dense-RBF rescue fixes that globally at a performance - cliff; when only a handful of columns are empty, a surgical injection - entry (weight 1 at the nearest fine DOF of the same component) makes - the RAP nonsingular at zero cost. These are preconditioner transfers, - not the discretisation — a few injected rows cost iterations at worst, - never correctness. Returns the (possibly repaired) matrix and the - repair count; a column it cannot repair is left empty for the guard - to refuse loudly. - """ - colsum = np.asarray((P_csr != 0).sum(axis=0)).ravel() - zero = np.flatnonzero(colsum == 0) - if not len(zero): - return P_csr, 0 - from scipy.spatial import cKDTree - - n_full_f = coords_f.shape[0] * nc - inv_f = -np.ones(n_full_f, dtype=np.int64) - inv_f[np.asarray(map_f, dtype=np.int64)] = np.arange(len(map_f)) - tree = cKDTree(coords_f) - P = P_csr.tolil() - repaired = 0 - for j in zero: - full_c = int(map_c[j]) - node_c, comp = divmod(full_c, nc) - k = min(8, coords_f.shape[0]) - for i in np.atleast_1d(tree.query(coords_c[node_c], k=k)[1]): - full_f = int(i) * nc + comp - if inv_f[full_f] >= 0: - P[int(inv_f[full_f]), int(j)] = 1.0 - repaired += 1 - break - return P.tocsr(), repaired - - def _configure_pcmg(pc, Ps, coarse="redundant", smoother="robust", owned=None, ksp=None, patch_rows=None): """Reconfigure ``pc`` as a fresh PCMG (FMG F-cycle) driven by the supplied @@ -1441,6 +1320,20 @@ def __init__(self, level_meshes, builder="barycentric", field_id=None, # cost is bounded by the cap however long the band grows. self.fac_block_rows = int(fac_block_rows) self.transfers = None + # Split-level multigrid (utilities.fault_mg): a COARSE level carrying a + # split-node fault takes the side-aware cell-located transfer, and its + # unreached unknowns are dropped at install rather than repaired. + from underworld3.utilities.fault_mg import is_split_level + self.has_split_levels = any(is_split_level(m) for m in self.level_meshes[:-1]) + # A coarse unknown no fine unknown reaches (a non-nested level: a + # cut, a placed band, a relaxed child) is DROPPED at install, never + # repaired by nearest-node injection. The injection put a weight-1 + # entry on a fine row that already carried one, two identical + # columns, a singular Galerkin coarse operator: NaN on a two-mid + # ladder chain (serial, 4 orphans, split and unsplit finest alike) + # and 512 duplicated columns at np = 2. Kept as an attribute so the + # install paths read one rule. + self.drop_orphans = True def _validated_fac_zone(self, fac_zone): """Normalise ``fac_zone`` to None or a list of boolean cell masks. @@ -1582,8 +1475,41 @@ def build(self, solver): # patch is read off its own transfer ``Ps[l-1]``, in the level # operator's row numbering (global rows in parallel, #670). self.level_patch_rows = [None] * nlev + # per level, the parallel cell-located transfer's hit-rate record + self.level_transfer_stats = [None] * nlev comm = solver.dm.comm + from underworld3.utilities import fault_mg for l in range(1, nlev): + # Split-level multigrid: every pair touching a split level takes + # the cell-located transfer. A split COARSE level defeats the + # node-cloud builders (they triangulate the coarse nodes, and + # coincident slit nodes make that degenerate); the transfer reads + # the coarse CELLS instead and keeps each side's nodes on their + # own side. Its unreached coarse unknowns (a few near the tips) + # are dropped at install time — never repaired by injection, which + # duplicated columns and made the Galerkin operators singular in + # parallel. A hierarchy whose ONLY split level is the finest (a + # welded tail under a split fault, the placed ribbon of #670) + # keeps the builders, the orphan repair and the FAC patch it + # always had. + if self.has_split_levels and ( + fault_mg.is_split_level(self.level_meshes[l - 1]) + or fault_mg.is_split_level(self.level_meshes[l])): + if parallel: + P = fault_mg.cell_located_transfer_parallel( + self.level_meshes[l - 1], self.level_meshes[l], degree, + continuous, coords[l - 1], coords[l], maps[l - 1], + maps[l], nc, comm) + self.level_transfer_stats[l] = fault_mg.last_transfer_stats + else: + Pn = fault_mg.cell_located_prolongation( + self.level_meshes[l - 1], self.level_meshes[l], degree, + continuous, coarse_coords=coords[l - 1], + fine_coords=coords[l]) + P = _to_petsc_aij( + _reduced_from_node_transfer(Pn, maps[l - 1], maps[l], nc)) + Ps.append(P) + continue if parallel: args = (coords[l - 1], coords[l], maps[l - 1], maps[l], nc, self.builder, comm) @@ -1618,21 +1544,8 @@ def build(self, solver): f"without a fine image where the " f"co-partitioned one left {n_zero}; kept " f"the co-partitioned transfer (#671).") - # the same orphan repair the serial path has: a coarse DOF no - # fine node reaches gets its nearest fine DOF as an injection - if _count_zero_columns_parallel(P, comm) > 0: - coords_u, cols_u = _gather_coarse_cloud( - coords[l - 1], maps[l - 1], nc, comm) - P, n_rep = _repair_zero_columns_parallel( - P, coords[l], maps[l], coords_u, cols_u, nc, comm) - if n_rep: - import warnings - warnings.warn( - f"custom_mg: parallel transfer {l - 1}->{l} had " - f"{n_rep} coarse DOF(s) with no fine image " - f"(non-nested levels); repaired by " - f"nearest-fine-DOF injection.") - _assert_no_zero_columns_parallel(P, comm) + # an orphan coarse DOF (no fine image) is dropped at install + # (restrict_unreached), never injected — see drop_orphans # FAC in parallel (#670): the same blocks as the serial branch # below, in GLOBAL row numbering — the layout's ghost-resolved # l2g puts a masked cell's off-rank nodes into this rank's @@ -1724,17 +1637,8 @@ def build(self, solver): else: Pr = _reduced_transfer(coords[l - 1], coords[l], maps[l - 1], maps[l], nc, self.builder) - Pr, n_rep = _repair_zero_columns_serial( - Pr, coords[l - 1], coords[l], maps[l - 1], maps[l], - nc, l) - if n_rep: - import warnings - warnings.warn( - f"custom_mg: transfer {l - 1}->{l} had {n_rep} coarse " - f"DOF(s) with no fine image (non-nested levels); " - f"repaired by nearest-fine-DOF injection. Costs " - f"iterations at worst, never correctness.") - _assert_no_zero_columns_serial(Pr, l) + # an orphan coarse DOF (no fine image) is dropped at install + # (restrict_unreached), never injected — see drop_orphans # TODO(MEASURE): A/B affordance for the #629 campaign, like # the nested-native flag above; remove when settled. if not os.environ.get("UW_CUSTOM_MG_DISABLE_FAC"): @@ -1844,8 +1748,22 @@ def _assert_finest_matches_operator(solver, finest_map, parallel): def install(self, solver, verbose=False): if self.transfers is None: raise RuntimeError("call build() before install()") - _install_transfers(solver, self.transfers, verbose=verbose, - patch_rows=getattr(self, "level_patch_rows", None)) + Ps = self.transfers + patch_rows = getattr(self, "level_patch_rows", None) + if self.drop_orphans: + # a coarse level the restriction renumbered (it dropped unreached + # columns) loses its recorded FAC patch rows; a level it left whole, + # and the finest, keep theirs + from underworld3.utilities.fault_mg import restrict_unreached + restricted = restrict_unreached(Ps, solver.dm.comm) + if patch_rows is not None: + patch_rows = [ + None if level < len(Ps) + and restricted[level].getSize()[1] != Ps[level].getSize()[1] + else rows + for level, rows in enumerate(patch_rows)] + Ps = restricted + _install_transfers(solver, Ps, verbose=verbose, patch_rows=patch_rows) # Record what is live on this solver's PC, so a repeat solve can # skip the re-install (see _pcmg_still_installed). solver._custom_mg_live = {"h": id(self), @@ -2332,10 +2250,12 @@ def _colocate_level(coarse_mesh, fine_mesh): sizes = np.bincount(assign, minlength=comm.size).astype(np.int32) part.setShellPartition(comm.size, sizes=sizes, points=order) work.distribute() - return uw.discretisation.Mesh( + moved = uw.discretisation.Mesh( work, simplex=coarse_mesh.dm.isSimplex(), qdegree=coarse_mesh.qdegree, coordinate_system_type=coarse_mesh.CoordinateSystem.coordinate_type, boundaries=coarse_mesh.boundaries, verbose=False) + moved._mg_static = getattr(coarse_mesh, "_mg_static", False) + return moved def adopt_hierarchy(mesh, base_mesh, fac_zone=None, builder=None): diff --git a/src/underworld3/utilities/fault_contact.py b/src/underworld3/utilities/fault_contact.py index f204c891c..e1239a8c6 100644 --- a/src/underworld3/utilities/fault_contact.py +++ b/src/underworld3/utilities/fault_contact.py @@ -156,7 +156,7 @@ def _compile_normal_spec(spec, mesh, boundary): result need not be unit length — the caller normalises — but must be nonzero at every fault node. """ - dim = mesh.dim + dim = mesh.cdim if isinstance(spec, str): if spec == "trace": if dim != 2: @@ -513,7 +513,99 @@ def solve_with_fault(solver, verbose=False, zero_init_guess=True, picard=0): return info +def _manifold_edge_conormal(dm, f): + """Length and unit conormal of a fault edge of a surface mesh: in the + tangent plane of the edge's single support cell, perpendicular to the + edge, pointing away from that cell (the Plus side's cell after the + split, so Plus -> Minus like the volume-mesh facet normal). PETSc's FVM + facet normal is not defined for an edge in three dimensions.""" + cdim = dm.getCoordinateDim() + csec = dm.getCoordinateSection() + cvec = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, cdim) + v0, v1 = dm.getDepthStratum(0) + P, Q = (cvec[csec.getOffset(int(v)) // cdim] for v in dm.getCone(f)) + cell = int(dm.getSupport(f)[0]) + verts = [int(p) for p in dm.getTransitiveClosure(cell)[0] if v0 <= p < v1] + Xc = np.array([cvec[csec.getOffset(v) // cdim] for v in verts]) + n_cell = np.cross(Xc[1] - Xc[0], Xc[2] - Xc[0]) + nu = np.cross(n_cell, Q - P) + nu = nu / (np.linalg.norm(nu) + 1e-30) + if (Xc.mean(axis=0) - 0.5 * (P + Q)) @ nu > 0.0: + nu = -nu + return float(np.linalg.norm(Q - P)), nu + + +def _radial_fn(mesh): + """A numpy callable of the mesh coordinates giving the surface normal of a + manifold mesh — its radial unit vector ``unit_e_0``, unwrapped to mesh + coordinates exactly as ``rotated_bc._normal_source`` does for an analytic + normal. Build it once per mesh, not per pair.""" + from underworld3.function.expressions import unwrap + normal = mesh.CoordinateSystem.unit_e_0 + comps = [sympy.sympify(unwrap(normal[0, k], keep_constants=False, + return_self=False)) + for k in range(mesh.cdim)] + return sympy.lambdify(list(mesh.X), comps, "numpy") + + +def _pair_frame(solver, q, nrm, radial=None): + """The rotation frame of a coincident pair at point ``q`` of the SOLVER's + mesh — see :func:`_pair_frame_on`, the mesh-level form a coarse level of + the split-level multigrid uses too.""" + mesh = solver.mesh + if mesh.dim < mesh.cdim and radial is None: + radial = _radial_fn(mesh) + return _pair_frame_on(mesh, solver.dm, q, nrm, radial) + + +def _pair_frame_on(mesh, dm, q, nrm, radial=None): + """The rotation frame of a coincident pair at point ``q``: the fault + normal first, then the slip direction(s). On a volume mesh that is + ``[n] + _tangent_frame(n)``; on a surface mesh the fault normal is the + in-surface conormal, the slip direction is the trace tangent and the + third vector is the surface normal, whose jump AND mean rows the + tangency constraint pins (see ``rotated_bc.build_rotation``). The SINGLE + authority for the pair frame: the rotated pair blocks, the interface + assembler's slip rows and the coarse levels' pair blocks must agree row + for row. ``radial`` is :func:`_radial_fn` of the mesh (surface meshes).""" + if mesh.dim == mesh.cdim: + return [np.asarray(nrm, dtype=float)] + _tangent_frame(nrm) + from underworld3.utilities.rotated_bc import _point_coord + cdim = mesh.cdim + csec = dm.getCoordinateSection() + cvec = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, cdim) + v0, v1 = dm.getDepthStratum(0) + if radial is None: + radial = _radial_fn(mesh) + X = _point_coord(dm, cdim, cvec, csec, v0, v1, int(q)) + r = np.asarray(radial(*X), dtype=float).ravel() + r = r / (np.linalg.norm(r) + 1e-30) + # The conormal is perpendicular to the FACET normal, the surface normal is + # the analytic one at the node: they differ by O(h), and a frame that is + # not orthonormal makes the pair rotation non-orthogonal — the pinned row + # along r then leaks into the free row along nu and releases a normal + # mode the sheet has no stiffness against (measured: a Killing field + # returned 5e-2 off near the fault, the same from LU). Orthogonalise. + nu = np.asarray(nrm, dtype=float) + nu = nu - (nu @ r) * r + nu = nu / (np.linalg.norm(nu) + 1e-30) + t = np.cross(r, nu) + t = t / (np.linalg.norm(t) + 1e-30) + return [nu, t, r] + + def _fault_pair_nodes(solver, boundary): + """The velocity-carrying coincident pairs of a split fault of the + SOLVER's mesh, with normals — :func:`_fault_pair_nodes_on` with the + solver's analytic-normal override for this fault, if one is registered.""" + return _fault_pair_nodes_on(solver.mesh, solver.dm, + solver.dm.getLocalSection(), boundary, + field=_VELOCITY_FIELD, + override=_compiled_normal_override(solver, boundary)) + + +def _fault_pair_nodes_on(mesh, dm, lsec, boundary, field=_VELOCITY_FIELD, + override=None): """The velocity-carrying coincident pairs of a split fault, with normals. Returns ``[(q_plus, q_minus, n_hat), ...]`` over this rank's pairs, where @@ -533,11 +625,13 @@ def _fault_pair_nodes(solver, boundary): rotated pair blocks, the interface-law rows, and every diagnostic (slip, leak, normal traction) all read the frame from here, so the override is consistent everywhere by construction. + + ``mesh`` is any mesh carrying the pairing (the solver's, or a coarse + level of the split-level multigrid), ``dm`` a DM over it that carries the + velocity field ``field`` in its local section ``lsec``, and ``override`` + the compiled analytic normal (``None`` for the facet-derived one). """ - mesh = solver.mesh - dm = solver.dm - dim = mesh.dim - lsec = dm.getLocalSection() + dim = mesh.cdim fS, fE = dm.getHeightStratum(1) pairs = mesh._fault_point_pairs[boundary] @@ -573,10 +667,14 @@ def _fault_pair_nodes(solver, boundary): # verbatim copy of those six lines, and copies of them drifting apart is what # produced #560 and then #564. nacc = {} + manifold = mesh.dim < mesh.cdim for f in facets: - vol, ne, _exterior = facet_measure_and_normal(dm, f, orient="support0") + if manifold: + vol, ne = _manifold_edge_conormal(dm, f) + else: + vol, ne, _exterior = facet_measure_and_normal(dm, f, orient="support0") for q in (int(c) for c in dm.getTransitiveClosure(f)[0]): - if lsec.getFieldDof(q, _VELOCITY_FIELD) > 0: + if lsec.getFieldDof(q, field) > 0: nacc[q] = nacc.get(q, np.zeros(dim)) + float(vol) * ne # A pair node ON the partition seam (the split through the seam, # fault_split.split_fault(across_seams=True)) has one of its two @@ -588,7 +686,6 @@ def _fault_pair_nodes(solver, boundary): if int(q) in nacc: nacc[int(q)] = np.asarray(whole, dtype=float) - override = _compiled_normal_override(solver, boundary) if override is not None: from underworld3.utilities.rotated_bc import _point_coord csec = dm.getCoordinateSection() @@ -597,7 +694,7 @@ def _fault_pair_nodes(solver, boundary): out = [] for q_minus, q_plus in pairs.items(): - if lsec.getFieldDof(int(q_plus), _VELOCITY_FIELD) <= 0: + if lsec.getFieldDof(int(q_plus), field) <= 0: continue acc = nacc.get(int(q_plus)) if acc is None: @@ -691,8 +788,11 @@ class _InterfaceAssembler: def __init__(self, solver, include=()): dm = solver.dm - dim = solver.mesh.dim - ncomp = dim - 1 # slip components per node + dim = solver.mesh.cdim + # slip components per node: one along the trace on a surface mesh + # (the third frame vector is the pinned surface normal), dim - 1 on + # a volume mesh + ncomp = 1 if solver.mesh.dim < dim else dim - 1 lsec = dm.getLocalSection() l2g = dm.getLGMap() csec = dm.getCoordinateSection() @@ -749,7 +849,7 @@ def node_index(q): for a in range(ncomp)]) lo_p.append(lsec.getFieldOffset(q, _VELOCITY_FIELD)) lo_m.append(lo) - tans.append(_tangent_frame(nrm)) + tans.append(_pair_frame(solver, q, nrm)[1:1 + ncomp]) nrms.append(nrm) return index_of[q] @@ -765,7 +865,7 @@ def node_index(q): .getStratumIS(value).getIndices()): if not (fS <= f < fE): continue - if dim == 2: + if solver.mesh.dim == 2: va, vb = (int(q) for q in dm.getCone(f)) L = float(np.linalg.norm( cvec[csec.getOffset(va) // dim] @@ -800,8 +900,11 @@ def node_index(q): self._lo_m = np.asarray(lo_m, dtype=np.int64) self._tan = np.asarray(tans, dtype=float).reshape(-1, ncomp, dim) self._nrm = np.asarray(nrms, dtype=float).reshape(-1, dim) - self._NQ = _NQ if dim == 2 else _NQ3 - self._WQ = _WQ if dim == 2 else _WQ3 + # the trace quadrature follows the facet TOPOLOGY (an edge on a 2-D + # or surface mesh, a triangle in 3-D); the strides above follow the + # component count + self._NQ = _NQ if solver.mesh.dim == 2 else _NQ3 + self._WQ = _WQ if solver.mesh.dim == 2 else _WQ3 self._laws = laws_of self._facets = facets self._dim = dim @@ -1048,7 +1151,7 @@ def fault_normal_traction(solver, boundary, solve_result): sig = assembler.nodal_normal_traction(solver, solve_result["reaction"]) dm = solver.dm - dim = solver.mesh.dim + dim = solver.mesh.cdim csec = dm.getCoordinateSection() cvec = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, dim) v0, v1 = dm.getDepthStratum(0) @@ -1092,7 +1195,7 @@ def fault_pair_jumps(solver, boundary, solve_result, gather=False): comm = solver.mesh.dm.comm.tompi4py() if comm.size == 1: return coords, jumps, normals - dim = solver.mesh.dim + dim = solver.mesh.cdim parts = comm.allgather((np.asarray(coords, dtype=float).reshape(-1, dim), np.asarray(jumps, dtype=float).reshape(-1, dim), np.asarray(normals, dtype=float).reshape(-1, dim))) @@ -1102,7 +1205,7 @@ def fault_pair_jumps(solver, boundary, solve_result, gather=False): def _fault_pair_jumps_local(solver, boundary, solve_result): """The rank-local half of :func:`fault_pair_jumps`.""" dm = solver.dm - dim = solver.mesh.dim + dim = solver.mesh.cdim lsec = dm.getLocalSection() l2g = dm.getLGMap() U = solve_result["U"] @@ -1153,7 +1256,14 @@ def fault_slip(solver, boundary, solve_result): raise NotImplementedError( "fault_slip's arc-length profile is 2-D; use fault_pair_jumps " "for a 3-D patch.") - tangents = np.column_stack([-normals[:, 1], normals[:, 0]]) + if coords.shape[1] == 3: + # a surface mesh: the trace tangent is the surface normal (radial on + # the sphere) crossed with the in-surface conormal + rhat = coords / np.linalg.norm(coords, axis=1)[:, None] + tangents = np.cross(rhat, normals) + tangents /= np.linalg.norm(tangents, axis=1)[:, None] + else: + tangents = np.column_stack([-normals[:, 1], normals[:, 0]]) # Along-fault coordinate from the mean tangent, origin at the trailing end. tbar = tangents.mean(axis=0) tbar /= np.linalg.norm(tbar) + 1e-30 diff --git a/src/underworld3/utilities/fault_mg.py b/src/underworld3/utilities/fault_mg.py new file mode 100644 index 000000000..a9d0a0abd --- /dev/null +++ b/src/underworld3/utilities/fault_mg.py @@ -0,0 +1,922 @@ +r"""Split-level geometric multigrid for split-node faults. + +A split-node fault lives on the finest mesh. The coarse levels under it come from +the add-on hierarchy: the static background, which is never cut, and the add-on +levels refined from it toward the fault. This module carries the fault through +the add-on levels — every add-on level is split the same way as the finest — so +the coarse spaces contain the discontinuity and the velocity solve stays +mesh-independent as the fault is refined (iterations fall from 10 to 5 over four +halvings, against 9 to 32 with welded levels; see +``docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md``). + +Three pieces make a split level usable as a multigrid level: + +* **A side-aware transfer.** For each fine node one fine cell containing it is + taken and that cell's CENTROID is located in the coarse mesh. A centroid is + never on the fault, so the coarse cell is on the node's own side, and a node on + the slit interpolates from its own side. The coarse cell's Lagrange interpolant + is evaluated at the node by the dual-basis identity :math:`W = BM^{-1}` (as in + :func:`custom_mg.nested_refine_pair_prolongation`). The levels are conforming + but not nested near the trace (each cut pulls vertices onto it), so a fine node + can lie a little outside its located coarse cell: the evaluation point is + clamped into the cell (barycentric coordinates clipped and renormalised), which + keeps every weight bounded by the Lagrange basis inside the simplex. +* **A per-level pair frame.** Each split level carries its own pair rotation + :math:`Q_l` (mean and jump in the fault frame) and its opening rows are removed: + :math:`\hat P_l = Q_{l+1} P_l Q_l^T` restricted to the admissible unknowns. +* **Unreached coarse unknowns dropped.** A coarse unknown no fine unknown reaches + (a few near the tips) is a null mode of the Galerkin coarse operator; it is + removed from the level rather than repaired by injection. + +Parallel: each split level is redistributed so its fault star is rank-interior +(the split's own rule), so pair blocks are rank-local on every level. The transfer +locates each owned fine node in the rank's own coarse cells (the solver DMs carry +no overlap); the nodes whose coarse cell lives elsewhere are gathered as query +points and answered by the rank that holds the cell, with global column indices. +""" + +import itertools + +import numpy as np +from types import SimpleNamespace +from mpi4py import MPI +import scipy.sparse as sp +from petsc4py import PETSc +from scipy.spatial import cKDTree + +import underworld3 as uw + +# Barycentric slack for "inside": a fine node this far outside (in reference +# coordinates) still counts as located in the cell; beyond it the clamp acts. +_INSIDE_TOL = 1e-8 +# Chunk size for the batched weight evaluation (bounds the (n, nb, nb) temporaries). +_CHUNK = 1 << 16 +# The last parallel transfer's hit-rate record (see cell_located_transfer_parallel); +# CustomMGHierarchy.build copies it per level into ``level_transfer_stats``. +last_transfer_stats = None + + +# --------------------------------------------------------------------------- # +# Geometry +# --------------------------------------------------------------------------- # +def is_split_level(mesh): + """Does this level carry a split-node fault (a coincident DOF pairing)?""" + return bool(getattr(mesh, "_fault_point_pairs", None)) + + +def _cell_vertex_table(mesh): + """Vertex coordinates ``(n_v, cdim)`` and the ``(n_cells, dim + 1)`` table of + vertex rows per cell — the P1 node table, which the mesh caches.""" + vxy = np.asarray(mesh._get_coords_for_basis(1, True)) + cv = np.asarray(mesh._cell_node_indices(1, True)) + return vxy, cv + + +def _barycentric(P, V): + """Barycentric coordinates of points ``P (n, cdim)`` in the simplices + ``V (n, dim + 1, cdim)``. For an embedded surface (``cdim > dim``) the point is + projected onto the simplex plane first (normal equations).""" + A = V[:, 1:] - V[:, :1] # (n, dim, cdim) + b = P - V[:, 0] # (n, cdim) + dim, cdim = A.shape[1], A.shape[2] + if dim == cdim: + lam = np.linalg.solve(np.transpose(A, (0, 2, 1)), b[..., None])[..., 0] + else: + G = np.einsum("nik,njk->nij", A, A) + rhs = np.einsum("nik,nk->ni", A, b) + lam = np.linalg.solve(G, rhs[..., None])[..., 0] + return np.concatenate([1.0 - lam.sum(axis=1, keepdims=True), lam], axis=1) + + +def locate_in_cells(points, vxy, cv, k=16): + """Locate ``points`` among the simplices ``cv`` (vertex rows into ``vxy``). + + Returns ``(cell, lam)``: the best candidate cell per point (the one whose + minimum barycentric coordinate is largest among the ``k`` nearest by centroid) + and its barycentric coordinates there. A point is INSIDE its cell when + ``lam.min() >= -_INSIDE_TOL``; otherwise the caller clamps or asks another rank. + Empty ``cv`` returns cells of -1.""" + n = points.shape[0] + dim1 = cv.shape[1] + best = np.full(n, -1, dtype=np.int64) + lam_best = np.full((n, dim1), -np.inf) + if n == 0 or cv.shape[0] == 0: + return best, lam_best + cent = vxy[cv].mean(axis=1) + kk = min(k, cv.shape[0]) + _, cand = cKDTree(cent).query(points, k=kk) + cand = np.asarray(cand).reshape(n, kk) + bestmin = np.full(n, -np.inf) + for j in range(kk): + c = cand[:, j] + lam = _barycentric(points, vxy[cv[c]]) + m = lam.min(axis=1) + better = m > bestmin + best[better] = c[better] + lam_best[better] = lam[better] + bestmin[better] = m[better] + return best, lam_best + + +def _clamp(lam): + """Clip barycentric coordinates into the simplex and renormalise — the + nearest point of the cell in reference coordinates for a node a little + outside it (non-nested levels near a pulled tip). Every weight then stays + bounded by the Lagrange basis inside the simplex.""" + out = lam.copy() + bad = (out < 0).any(axis=1) + if bad.any(): + b = np.clip(out[bad], 0.0, None) + out[bad] = b / b.sum(axis=1, keepdims=True) + return out + + +def _monomial_exponents(degree, dim): + return np.asarray([e for e in itertools.product(range(degree + 1), repeat=dim) + if sum(e) <= degree]) + + +def _vander(L, E): + """Monomials ``E`` of the pulled-back coordinates ``L (n, dim)``: ``(n, nb)``.""" + return np.prod(L[:, None, :] ** E[None, :, :], axis=2) + + +def _pullback(X, V): + """Reference coordinates of points ``X (n, cdim)`` in simplices ``V (n, dim + 1, + cdim)`` — the barycentric vector without its first entry.""" + return _barycentric(X, V)[:, 1:] + + +def _node_weights(Xf, cells, lam, Xc, cn, vxy_c, cv_c, degree): + """Weights of the fine nodes ``Xf`` on the nodes of their located coarse + ``cells`` (barycentric ``lam`` there, clamped): ``(n, nb)``. + + Batched dual-basis evaluation: per distinct coarse cell the inverse nodal + Vandermonde ``M^{-1}`` (nodes pulled back through the cell's own affine map), + then ``W = B M^{-1}`` row by row in chunks.""" + dim = cv_c.shape[1] - 1 + E = _monomial_exponents(degree, dim) + nb = E.shape[0] + if cn.shape[1] != nb: + raise RuntimeError( + f"fault_mg: {cn.shape[1]} nodes per cell against {nb} monomials " + f"of degree {degree} in {dim}-D") + used, inv = np.unique(cells, return_inverse=True) + Vc = vxy_c[cv_c[used]] # (nu, dim+1, cdim) + # coarse nodes of each used cell, pulled back through that cell's map + Xn = Xc[cn[used]] # (nu, nb, cdim) + Lc = _pullback(Xn.reshape(-1, Xn.shape[-1]), + np.repeat(Vc, nb, axis=0)).reshape(len(used), nb, dim) + M = _vander(Lc.reshape(-1, dim), E).reshape(len(used), nb, nb) + Minv = np.linalg.inv(M) + Lf = _clamp(lam)[:, 1:] # (n, dim) + W = np.empty((Xf.shape[0], nb)) + for s in range(0, Xf.shape[0], _CHUNK): + e = min(s + _CHUNK, Xf.shape[0]) + B = _vander(Lf[s:e], E) # (m, nb) + W[s:e] = np.einsum("tm,tmk->tk", B, Minv[inv[s:e]]) + return W + + +def _fine_cell_of_node(fn, n_nodes): + """One fine cell containing each fine node (the last cell listing it). A + node on the slit is listed only by cells on its own side.""" + owner = np.full(n_nodes, -1, dtype=np.int64) + for k in range(fn.shape[0]): + owner[fn[k]] = k + return owner + + +# --------------------------------------------------------------------------- # +# Serial transfer +# --------------------------------------------------------------------------- # +def cell_located_prolongation(coarse, fine, degree, continuous=True, + coarse_coords=None, fine_coords=None): + """Side-aware, cell-located node prolongation ``(n_fine_nodes, n_coarse_nodes)`` + as scipy CSR (serial). Exact for nested pairs; on non-nested pairs it + reproduces the located coarse cell's polynomial, with the evaluation point + clamped into the cell.""" + cn = np.asarray(coarse._cell_node_indices(degree, continuous)) + fn = np.asarray(fine._cell_node_indices(degree, continuous)) + Xc = (np.asarray(coarse_coords) if coarse_coords is not None + else np.asarray(coarse._get_coords_for_basis(degree, continuous))) + Xf = (np.asarray(fine_coords) if fine_coords is not None + else np.asarray(fine._get_coords_for_basis(degree, continuous))) + vxy_c, cv_c = _cell_vertex_table(coarse) + vxy_f, cv_f = _cell_vertex_table(fine) + owner = _fine_cell_of_node(fn, Xf.shape[0]) + if (owner < 0).any(): + raise RuntimeError("fault_mg: a fine node belongs to no cell") + fcent = vxy_f[cv_f].mean(axis=1) + pcell, _lam_c = locate_in_cells(fcent, vxy_c, cv_c) + cells = pcell[owner] + lam = _barycentric(Xf, vxy_c[cv_c[cells]]) + W = _node_weights(Xf, cells, lam, Xc, cn, vxy_c, cv_c, degree) + nb = W.shape[1] + rows = np.repeat(np.arange(Xf.shape[0]), nb) + cols = cn[cells].ravel() + vals = W.ravel() + keep = np.abs(vals) > 1e-13 + P = sp.csr_matrix((vals[keep], (rows[keep], cols[keep])), + shape=(Xf.shape[0], Xc.shape[0])) + P.sum_duplicates() + return P + + +# --------------------------------------------------------------------------- # +# Parallel transfer +# --------------------------------------------------------------------------- # +def cell_located_transfer_parallel(coarse, fine, degree, continuous, + coarse_coords, fine_coords, coarse_layout, + fine_layout, ncomp, comm): + """The same transfer as :func:`cell_located_prolongation`, reduced->reduced, + as an MPIAIJ matrix. + + Every rank locates its OWNED fine rows in its LOCAL coarse mesh (owned plus + ghost cells, whose nodes carry the owner's global index through the layout). + A fine node whose coarse cell is not local is a MISS: the misses are + all-gathered as (centroid, node position) queries, every rank answers the + ones it finds in its own cells, and the asker takes the best answer (the + largest minimum barycentric coordinate). A query nobody finds — a node outside + every coarse cell, as at a curved boundary — keeps the asker's own clamped + best. ``coarse_layout`` / ``fine_layout`` are :class:`custom_mg.LevelLayout`. + """ + m4 = comm.tompi4py() + cn = np.asarray(coarse._cell_node_indices(degree, continuous)) + fn = np.asarray(fine._cell_node_indices(degree, continuous)) + Xc = np.asarray(coarse_coords) + Xf = np.asarray(fine_coords) + vxy_c, cv_c = _cell_vertex_table(coarse) + vxy_f, cv_f = _cell_vertex_table(fine) + l2g_c = np.asarray(coarse_layout.l2g, dtype=np.int64).reshape(-1, ncomp) + l2g_f = np.asarray(fine_layout.l2g, dtype=np.int64).reshape(-1, ncomp) + fstart, fend = fine_layout.rstart, fine_layout.rend + cstart, cend = coarse_layout.rstart, coarse_layout.rend + n_f = Xf.shape[0] + nb = cn.shape[1] + + owner = _fine_cell_of_node(fn, n_f) + if (owner < 0).any(): + raise RuntimeError("fault_mg: a fine node belongs to no cell") + # the fine nodes with at least one owned row (ghost nodes are another rank's) + mine = np.flatnonzero(((l2g_f >= fstart) & (l2g_f < fend)).any(axis=1)) + fcent = vxy_f[cv_f].mean(axis=1) + pcell, lam_c = (locate_in_cells(fcent[owner[mine]], vxy_c, cv_c) + if cv_c.shape[0] else + (np.full(mine.size, -1), np.full((mine.size, cv_c.shape[1]), -np.inf))) + inside = lam_c.min(axis=1) >= -_INSIDE_TOL + + # --- local hits ------------------------------------------------------- # + hit = mine[inside] + cells = pcell[inside] + gcols = np.empty((0, nb, ncomp), dtype=np.int64) + W = np.empty((0, nb)) + if hit.size: + lam = _barycentric(Xf[hit], vxy_c[cv_c[cells]]) + W = _node_weights(Xf[hit], cells, lam, Xc, cn, vxy_c, cv_c, degree) + gcols = l2g_c[cn[cells]] # (nh, nb, ncomp) + hit_nodes, hit_W, hit_cols = [hit], [W], [gcols] + + # --- misses: ask the other ranks -------------------------------------- # + miss = mine[~inside] + # a record of the rank-local hit rate, for the hierarchy's report + global last_transfer_stats + last_transfer_stats = { + "owned_nodes": int(m4.allreduce(int(mine.size))), + "misses": int(m4.allreduce(int(miss.size))), + "local_coarse_cells": m4.allgather(int(cv_c.shape[0])), + "local_fine_cells": m4.allgather(int(cv_f.shape[0])), + } + q_cent = fcent[owner[miss]] + q_node = Xf[miss] + all_cent = m4.allgather(q_cent) + all_node = m4.allgather(q_node) + counts = np.array([a.shape[0] for a in all_cent]) + total = int(counts.sum()) + if total: + Qc = (np.vstack(all_cent) if total else + np.zeros((0, Xf.shape[1]))) + Qn = np.vstack(all_node) + offs = np.concatenate([[0], np.cumsum(counts)]) + if cv_c.shape[0]: + qcell, qlam = locate_in_cells(Qc, vxy_c, cv_c) + qin = qlam.min(axis=1) >= -_INSIDE_TOL + else: + qcell = np.full(total, -1) + qlam = np.full((total, cv_c.shape[1]), -np.inf) + qin = np.zeros(total, dtype=bool) + found = np.flatnonzero(qin) + if found.size: + lamn = _barycentric(Qn[found], vxy_c[cv_c[qcell[found]]]) + Wq = _node_weights(Qn[found], qcell[found], lamn, Xc, cn, + vxy_c, cv_c, degree) + colsq = l2g_c[cn[qcell[found]]] + score = qlam[found].min(axis=1) + else: + Wq = np.zeros((0, nb)) + colsq = np.zeros((0, nb, ncomp), dtype=np.int64) + score = np.zeros(0) + # answers travel back to every rank; each keeps those for its own queries + ans_idx = m4.allgather(found) + ans_W = m4.allgather(Wq) + ans_cols = m4.allgather(colsq) + ans_score = m4.allgather(score) + lo, hi = offs[m4.rank], offs[m4.rank + 1] + best_score = np.full(miss.size, -np.inf) + best_W = np.zeros((miss.size, nb)) + best_cols = np.full((miss.size, nb, ncomp), -1, dtype=np.int64) + answered = np.zeros(miss.size, dtype=bool) + for idx, Wa, ca, sa in zip(ans_idx, ans_W, ans_cols, ans_score): + sel = (idx >= lo) & (idx < hi) + if not sel.any(): + continue + local = idx[sel] - lo + better = sa[sel] > best_score[local] + local = local[better] + best_score[local] = sa[sel][better] + best_W[local] = Wa[sel][better] + best_cols[local] = ca[sel][better] + answered[local] = True + # nobody found it: this rank's own clamped best (if it has coarse cells) + rest = np.flatnonzero(~answered) + if rest.size and cv_c.shape[0]: + cells_r = pcell[~inside][rest] + lam_r = _barycentric(Xf[miss[rest]], vxy_c[cv_c[cells_r]]) + best_W[rest] = _node_weights(Xf[miss[rest]], cells_r, lam_r, Xc, cn, + vxy_c, cv_c, degree) + best_cols[rest] = l2g_c[cn[cells_r]] + answered[rest] = True + if not answered.all(): + raise RuntimeError( + "fault_mg: a fine node could not be located in any coarse cell " + "on any rank") + hit_nodes.append(miss) + hit_W.append(best_W) + hit_cols.append(best_cols) + + nodes = np.concatenate(hit_nodes) + Wall = np.vstack(hit_W) + Call = np.concatenate(hit_cols) if len(hit_cols) > 1 else hit_cols[0] + + # --- assemble owned rows ---------------------------------------------- # + nloc_f = fend - fstart + nloc_c = cend - cstart + rows_l, cols_l, vals_l = [], [], [] + for c in range(ncomp): + grow = l2g_f[nodes, c] + own = (grow >= fstart) & (grow < fend) + if not own.any(): + continue + gr = np.repeat(grow[own], nb) + gc = Call[own][:, :, c].ravel() + gv = Wall[own].ravel() + keep = (gc >= 0) & (np.abs(gv) > 1e-13) + rows_l.append(gr[keep] - fstart) + cols_l.append(gc[keep]) + vals_l.append(gv[keep]) + if rows_l: + rows_l = np.concatenate(rows_l) + cols_l = np.concatenate(cols_l) + vals_l = np.concatenate(vals_l) + else: + rows_l = np.zeros(0, dtype=np.int64) + cols_l = np.zeros(0, dtype=np.int64) + vals_l = np.zeros(0) + loc = sp.csr_matrix((vals_l, (rows_l, cols_l)), + shape=(nloc_f, int(m4.allreduce(nloc_c)))) + loc.sum_duplicates() + loc.sort_indices() + P = PETSc.Mat().createAIJ( + size=((nloc_f, None), (nloc_c, None)), + csr=(loc.indptr.astype(PETSc.IntType), loc.indices.astype(PETSc.IntType), + loc.data), comm=comm) + P.assemble() + return P + + +# --------------------------------------------------------------------------- # +# Splitting the add-on levels +# --------------------------------------------------------------------------- # +def split_tail_levels(levels, fault_specs, verbose=False): + """Replay the finest mesh's faults on each add-on level; return the levels + (same order), split where the replay succeeds. + + ``fault_specs`` is the list ``fault_split.add_fault`` records on its child: + ``("line", name, polyline)`` in 2-D, ``("sheet", name, points, triangles, + placement options)`` in 3-D. Each level is cut and split exactly as the + finest was, so its slit lies on the same geometry and the centroid rule of + the transfer assigns sides consistently across levels. A level carries no + tail of its own. + + The replay runs from the finest level down. A level the faults cannot be + carried onto — a network whose one-cell ligament closes at the coarser + spacing, so two labels meet; a sheet the placement refuses — is left WELDED, + and so is every level beneath it (a split level under a welded one would + hand the welded level's single trace node one side's value). The hierarchy + then has split levels down to the finest coarsening the geometry allows, + and a warning says where the replay stopped and why. + """ + import warnings + + from underworld3.utilities.fault_contact import _collective_raise + from underworld3.utilities.fault_split import add_fault + + entries = [tuple(spec[1:4]) for spec in fault_specs] + kwargs = {} + for spec in fault_specs: + if spec[0] == "sheet" and len(spec) > 4: + kwargs.update(spec[4]) + out = list(levels) + if not entries: + return out + for k in range(len(levels) - 1, -1, -1): + level = levels[k] + problem = None + try: + split = add_fault(level, entries, verbose=verbose, **kwargs) + except (ValueError, RuntimeError, NotImplementedError) as exc: + problem = str(exc) + # the split's refusals are collective; a failure on one rank after its + # last collective is not, and every rank must stop at the same level or + # the ranks hold different hierarchies (a rank-local raise inside the + # split, before its own collectives, still hangs there: issue #874) + try: + _collective_raise(problem) + except RuntimeError as exc: + warnings.warn( + f"fault_mg: the fault(s) could not be carried onto add-on " + f"level {k} of {len(levels)} ({exc}); that level and the " + f"levels beneath it stay welded, the {len(levels) - 1 - k} " + f"finer add-on level(s) are split.") + break + split._custom_mg_coarse_meshes = None + out[k] = split + return out + + +def _fault_star_cells(mesh): + """The local cells of every split fault's star on ``mesh``: cells incident to a + vertex of a Plus or Minus facet, plus one growth layer (the same two-layer + rule as the split's own redistribution, so a moved star stays + rank-interior). Returns local cell indices (``cS``-based).""" + dm = mesh.dm + cS, cE = dm.getHeightStratum(0) + fS, fE = dm.getHeightStratum(1) + vS, vE = dm.getDepthStratum(0) + names = [b.name for b in mesh.boundaries] + verts = set() + for name in getattr(mesh, "_fault_point_pairs", {}): + for side in ("Plus", "Minus"): + lab_name = f"{name}{side}" + if lab_name not in names: + continue + lab = dm.getLabel(lab_name) + value = mesh.boundaries[lab_name].value + if lab and lab.getStratumSize(value) > 0: + for f in lab.getStratumIS(value).getIndices(): + if fS <= int(f) < fE: + verts.update(int(q) for q in dm.getTransitiveClosure(int(f))[0] + if vS <= int(q) < vE) + + def star(vs): + cells = set() + for v in vs: + cells.update(int(q) for q in dm.getTransitiveClosure(v, useCone=False)[0] + if cS <= int(q) < cE) + return cells + + cells = star(verts) + ring = set() + for c in cells: + ring.update(int(q) for q in dm.getTransitiveClosure(c)[0] if vS <= int(q) < vE) + cells |= star(ring) + return np.array(sorted(c - cS for c in cells), dtype=np.int64) + + +def fault_star_owner(mesh): + """The rank holding most of the split faults' star on ``mesh`` (collective).""" + comm = mesh.dm.getComm().tompi4py() + counts = np.asarray(comm.allgather(int(_fault_star_cells(mesh).size))) + return int(np.argmax(counts)) + + +def colocate_split_level(level, target): + """Move a split level's fault star (two layers, as the split gathers it) onto + rank ``target`` — the rank holding the FINEST level's star — carrying the + pairing through as labels. The split gathers each level's star onto the + rank already holding most of it, which differs from level to level, and a + transfer between stars on different ranks locates most of the fault-band + nodes remotely (measured: 48 % of the finest nodes at np = 2, 58 % at + np = 8). With the stars on one rank the misses are the band's rim only. + Returns ``level`` itself when nothing moves. Collective.""" + from underworld3.utilities.fault_split import redistribute_by_assignment + + comm = level.dm.getComm().tompi4py() + if comm.size == 1: + return level + cS, cE = level.dm.getHeightStratum(0) + assign = np.full(cE - cS, comm.rank, dtype=np.int32) + assign[_fault_star_cells(level)] = target + dm, pairs, dropped = redistribute_by_assignment( + level.dm, assign, pairs=dict(level._fault_point_pairs)) + if dm is level.dm: + return level + if dropped: + raise RuntimeError( + f"fault_mg: {dropped} pair(s) lost co-locating a level's fault star") + moved = uw.discretisation.Mesh( + dm, simplex=level.dm.isSimplex(), qdegree=level.qdegree, + coordinate_system_type=level.CoordinateSystem.coordinate_type, + boundaries=level.boundaries, verbose=False) + moved._fault_point_pairs = pairs + for attr in ("_fault_traces", "_fault_specs", "_fault_surfaces", "_fault_seam_normals"): + if getattr(level, attr, None) is not None: + setattr(moved, attr, getattr(level, attr)) + moved._custom_mg_coarse_meshes = None + moved._mg_static = False + return moved + + +# --------------------------------------------------------------------------- # +# Per-level pair rotation +# --------------------------------------------------------------------------- # +def level_pair_rotation(level, solver, field_id, cdm=None, layout=None): + """The pair rotation of a split coarse level in the level's reduced + velocity layout: ``(Q, Qt, normal_rows, layout)`` with ``Q`` an AIJ matrix + (identity except one orthogonal ``2 dim`` block per coincident pair, mean rows + on the Plus point and jump rows on the Minus point — the same construction as + ``rotated_bc.build_rotation``) and ``normal_rows`` the global rows constrained + out of the level (the opening jump; on a surface mesh also the jump and mean + along the surface normal). The level's DOF layout is derived from the solver's + discretisation copied onto the level DM (no throwaway solver). Not cached: + the layout depends on the solver's essential conditions, and the rotated + solve already keeps the composed prolongations across its Newton steps.""" + from underworld3.utilities import custom_mg, fault_contact + + if cdm is None: + cdm = custom_mg._clone_dm_with_solver_discretisation(solver, level) + if layout is None: + layout = custom_mg._level_dof_layout(cdm, field_id) + sub = custom_mg._field_subdm(cdm, field_id) + lsec_sub = sub.getLocalSection() + lsec = cdm.getLocalSection() + dim = level.cdim + manifold = level.dim < level.cdim + l2g = np.asarray(layout.l2g, dtype=np.int64) + rstart, rend = layout.rstart, layout.rend + nloc = rend - rstart + comm = cdm.getComm() + N = int(comm.tompi4py().allreduce(nloc)) + + # identity rows, replaced block by block + rows = [np.arange(rstart, rend)] + cols = [np.arange(rstart, rend)] + vals = [np.ones(nloc)] + is_block_row = np.zeros(nloc, dtype=bool) + normal_rows = [] + s2 = 1.0 / np.sqrt(2.0) + radial = fault_contact._radial_fn(level) if manifold else None + problem = None + try: + for fname in level._fault_point_pairs: + pairs = fault_contact._fault_pair_nodes_on( + level, cdm, lsec, fname, field=field_id or 0) + for q_plus, q_minus, nrm in pairs: + frame = fault_contact._pair_frame_on(level, cdm, q_plus, nrm, + radial) + op, om = lsec_sub.getOffset(q_plus), lsec_sub.getOffset(q_minus) + gp = [int(l2g[op + c]) for c in range(dim)] + gm = [int(l2g[om + c]) for c in range(dim)] + if any(g < 0 for g in gp + gm): + continue + owned_p = rstart <= gp[0] < rend + owned_m = rstart <= gm[0] < rend + if owned_p != owned_m: + raise RuntimeError( + "fault_mg: a coincident pair straddles ranks on a " + "coarse level — the split's redistribution should " + "make this impossible.") + if not owned_p: + continue + is_block_row[np.array(gp + gm) - rstart] = True + for i, e in enumerate(frame): + for row, sgn in ((gp[i], 1.0), (gm[i], -1.0)): + rows.append(np.full(2 * dim, row)) + cols.append(np.array(gp + gm)) + vals.append(np.concatenate([s2 * e, sgn * s2 * e])) + normal_rows.append(gm[0]) + if manifold: + normal_rows.append(gm[2]) + normal_rows.append(gp[2]) + except (RuntimeError, ValueError) as err: + problem = str(err) + fault_contact._collective_raise(problem) + + rows = np.concatenate(rows) + cols = np.concatenate(cols) + vals = np.concatenate(vals) + # drop the identity entry of every block row (the first nloc entries) + keep = np.ones(len(rows), dtype=bool) + keep[:nloc] = ~is_block_row + loc = sp.csr_matrix((vals[keep], (rows[keep] - rstart, cols[keep])), + shape=(nloc, N)) + loc.sum_duplicates() + loc.sort_indices() + Q = PETSc.Mat().createAIJ( + size=((nloc, None), (nloc, None)), + csr=(loc.indptr.astype(PETSc.IntType), loc.indices.astype(PETSc.IntType), + loc.data), comm=comm) + Q.assemble() + Qt = Q.transpose(PETSc.Mat()) + return Q, Qt, np.array(sorted(set(normal_rows)), dtype=np.int64), layout + + +class _LevelMeshProxy: + """The level mesh wearing the solver mesh's coordinate symbols, so the + solver's analytic wall normals (expressions in ``solver.mesh.X``) evaluate + on the level's nodes through the finest-level helpers unchanged.""" + + def __init__(self, level, X): + self._level = level + self.X = X + + def __getattr__(self, name): + return getattr(self._level, name) + + +def level_wall_rotation(level, solver, field_id, cdm=None, layout=None): + """The rotated free-slip wall rotation of a coarse level in the level's + reduced velocity layout: ``(Q, Qt, normal_rows, layout)`` with one + orthonormal ``dim x dim`` block per velocity node on each of the solver's + rotated boundaries (the same frame rule as ``rotated_bc.build_rotation``, + on the level's own nodes and normals) and ``normal_rows`` the rows of the + rotated normal components, which the level constrains out. ``None`` when + the solver has no rotated walls. + + Why the coarse levels need it: with Cartesian coarse levels under a + rotated finest whose normal rows take no coarse correction, a coarse wall + node's normal Cartesian component is reached only through the fine + tangential rows, weighted by the sine of the angle between the two + nodes' normals. Its Galerkin block is then nearly singular (measured + 1e-3 against 2.5 on an annulus at a node near an axis), the inode SOR + smoother's 2x2 block inversion reports a zero pivot, and the velocity + solve fails before its first iteration. Rotating the level and removing + the normal rows gives the coarse space exactly the constrained motion. + """ + from underworld3.utilities import custom_mg, rotated_bc + + specs = list(getattr(solver, "_rotated_freeslip_bcs", None) or []) + if not specs: + return None + if cdm is None: + cdm = custom_mg._clone_dm_with_solver_discretisation(solver, level) + if layout is None: + layout = custom_mg._level_dof_layout(cdm, field_id) + sub = custom_mg._field_subdm(cdm, field_id) + lsec_sub = sub.getLocalSection() + dim = level.cdim + manifold = level.dim < level.cdim + proxy = SimpleNamespace(dm=cdm, mesh=_LevelMeshProxy(level, solver.mesh.X), + snes=None) + + node_normals = {} + for spec in specs: + name, normal = rotated_bc._boundary_spec(spec) + if name == rotated_bc.SHEET_TANGENCY: + nodes = rotated_bc._manifold_velocity_nodes(proxy, normal) + else: + nodes = rotated_bc._boundary_velocity_nodes(proxy, name, normal=normal) + for q, nrm in nodes: + node_normals.setdefault(q, []).append(nrm) + if manifold and is_split_level(level): + # a paired node takes the sheet tangency through its pair block + for pairs in level._fault_point_pairs.values(): + for q_plus, q_minus in (pairs.items() if isinstance(pairs, dict) + else pairs): + node_normals.pop(int(q_plus), None) + node_normals.pop(int(q_minus), None) + + l2g = np.asarray(layout.l2g, dtype=np.int64) + rstart, rend = layout.rstart, layout.rend + nloc = rend - rstart + comm = cdm.getComm() + N = int(comm.tompi4py().allreduce(nloc)) + rows = [np.arange(rstart, rend)] + cols = [np.arange(rstart, rend)] + vals = [np.ones(nloc)] + is_block_row = np.zeros(nloc, dtype=bool) + normal_rows = [] + for q, nrms in node_normals.items(): + off = lsec_sub.getOffset(q) + g = [int(l2g[off + c]) for c in range(dim)] + if any(gi < 0 for gi in g): + # a component pinned by an essential condition on the level: the + # wall meets an essential wall there; left Cartesian, as the + # finest's partially constrained nodes are handled separately + continue + if not (rstart <= g[0] < rend): + continue + M = np.array(nrms, dtype=float) + _u, sv, Vt = np.linalg.svd(M) + r = int((sv > 1e-8 * (sv[0] if sv.size else 1.0)).sum()) + is_block_row[np.array(g) - rstart] = True + for i in range(dim): + rows.append(np.full(dim, g[i])) + cols.append(np.array(g)) + vals.append(np.asarray(Vt[i], dtype=float)) + normal_rows.extend(g[:r]) + rows = np.concatenate(rows) + cols = np.concatenate(cols) + vals = np.concatenate(vals) + keep = np.ones(len(rows), dtype=bool) + keep[:nloc] = ~is_block_row + loc = sp.csr_matrix((vals[keep], (rows[keep] - rstart, cols[keep])), + shape=(nloc, N)) + loc.sum_duplicates() + loc.sort_indices() + Q = PETSc.Mat().createAIJ( + size=((nloc, None), (nloc, None)), + csr=(loc.indptr.astype(PETSc.IntType), loc.indices.astype(PETSc.IntType), + loc.data), comm=comm) + Q.assemble() + Qt = Q.transpose(PETSc.Mat()) + return Q, Qt, np.array(sorted(set(normal_rows)), dtype=np.int64), layout + + +def level_rotation(level, solver, field_id): + """The full rotation of a coarse level: its fault pairs (a split level) + and its rotated walls, composed. The two act on disjoint node blocks (a + paired node is never a wall node on a volume mesh; on a surface mesh the + paired nodes are removed from the wall set), so the product is a single + orthogonal matrix. Returns ``(Q, Qt, normal_rows)`` or ``None`` when the + level has neither.""" + from underworld3.utilities import custom_mg + + split = is_split_level(level) + walls = bool(getattr(solver, "_rotated_freeslip_bcs", None)) + if not split and not walls: + return None + cdm = custom_mg._clone_dm_with_solver_discretisation(solver, level) + layout = custom_mg._level_dof_layout(cdm, field_id) + parts = [] + if split: + parts.append(level_pair_rotation(level, solver, field_id, cdm=cdm, layout=layout)) + if walls: + w = level_wall_rotation(level, solver, field_id, cdm=cdm, layout=layout) + if w is not None: + parts.append(w) + if not parts: + return None + if len(parts) == 1: + Q, Qt, nr, _lay = parts[0] + return Q, Qt, nr + Q = parts[0][0].matMult(parts[1][0]) + Qt = Q.transpose(PETSc.Mat()) + nr = np.array(sorted(set(parts[0][2].tolist()) | set(parts[1][2].tolist())), + dtype=np.int64) + return Q, Qt, nr + + +# --------------------------------------------------------------------------- # +# Static / add-on levels +# --------------------------------------------------------------------------- # +def addon_level_indices(tail): + """Indices of the ADD-ON levels of a mesh-owned tail — every level that is + not a static background wrap (``_mg_static``). These are the levels a fault + is carried through; the background is never cut.""" + return [k for k, m in enumerate(tail) if not getattr(m, "_mg_static", False)] + + +# A coarse unknown whose transfer column carries less than this fraction of +# the level's largest column (squared 2-norms) is UNREACHED: its Galerkin +# diagonal is that fraction of the fine operator's, and SOR divides by it. +# Exactly-zero columns are the orphans of a split tip; the near-zero ones +# arise under rotation, where a coarse wall node on an axis has its normal +# Cartesian component reached only through fine tangential rows weighted by +# the sine of a vanishing angle (measured 1e-10 on an annulus, NaN at the +# first sweep). +UNREACHED_REL = 1e-8 + + +def _reached_columns(T, rel=None): + rel = UNREACHED_REL if rel is None else rel + n2 = np.asarray(_column_norms_sq(T).array) + big = T.comm.tompi4py().allreduce(float(n2.max()) if n2.size else 0.0, op=MPI.MAX) + return n2 > rel * big + + +def restrict_unreached(Ps, comm): + """The prolongation list with every coarse unknown no fine unknown reaches + dropped, fine to coarse, each level's rows restricted to the set the level + above kept. The unrotated twin of :func:`rotated_split_transfers`, for the + standard (non-rotated) install of a hierarchy with split levels. "Reaches" + is relative (``UNREACHED_REL``), so a vanishing column counts as none.""" + nlev = len(Ps) + 1 + kept = [None] * nlev + out = [None] * (nlev - 1) + for l in range(nlev - 2, -1, -1): + T = Ps[l] + if kept[l + 1] is not None: + T = T.createSubMatrix( + _owned_is(kept[l + 1], comm), + _owned_is(np.arange(*T.getOwnershipRangeColumn()), comm)) + reached = _reached_columns(T) + cols = np.arange(*T.getOwnershipRangeColumn()) + if comm.tompi4py().allreduce(int((~reached).sum())): + T = T.createSubMatrix( + _owned_is(np.arange(*T.getOwnershipRange()), comm), + _owned_is(cols[reached], comm)) + kept[l] = cols[reached] + out[l] = T + return out + + +# --------------------------------------------------------------------------- # +# Composition for the rotated solve +# --------------------------------------------------------------------------- # +def _owned_is(indices, comm): + return PETSc.IS().createGeneral(np.asarray(indices, dtype=PETSc.IntType), + comm=comm) + + +def _column_norms_sq(P): + """Squared 2-norm of every column of ``P``, in the column layout (a Vec).""" + v = P.createVecRight() + v.set(0.0) + _indptr, indices, data = P.getValuesCSR() + if indices.size: + v.setValues(indices.astype(PETSc.IntType), data * data, + addv=PETSc.InsertMode.ADD_VALUES) + v.assemblyBegin() + v.assemblyEnd() + return v + + +def rotated_split_transfers(solver, hierarchy, Ps, Q, normal_rows): + r"""The rotated prolongation list for a hierarchy with split levels. + + Level by level from the finest down: :math:`\hat P_l = Q_{l+1} P_l Q_l^T` + with the finest's opening rows zeroed (its size is the operator's) and every + coarse level restricted to its admissible unknowns — the level's own opening + rows removed, then the unknowns no fine unknown reaches dropped. Welded levels + (the background) have no rotation; their columns are still restricted to the + reached set. Returns the list ``[P_hat_0, ..., P_hat_{n-2}]`` and records the + kept index sets on the hierarchy (``hierarchy.level_kept``).""" + from underworld3.utilities import custom_mg + + levels = hierarchy.level_meshes + nlev = len(levels) + comm = solver.dm.comm + vel_is = solver._subdict["velocity"][0] + vis = np.asarray(vel_is.getIndices()) + Qv = Q.createSubMatrix(vel_is, vel_is) + rstart = Qv.getOwnershipRange()[0] + g2blk = {int(g): rstart + k for k, g in enumerate(vis)} + fine_normal = sorted({g2blk[g] for g in normal_rows if g in g2blk}) + + rot = [None] * nlev + for l in range(nlev - 1): + rot[l] = level_rotation(levels[l], solver, hierarchy.field_id) + + kept = [None] * nlev # owned kept global indices per coarse level + out = [None] * (nlev - 1) + for l in range(nlev - 2, -1, -1): + T = Ps[l] + if l + 1 == nlev - 1: + T = Qv.matMult(T) + elif rot[l + 1] is not None: + T = rot[l + 1][0].matMult(T) + if rot[l] is not None: + T = T.matMult(rot[l][1]) + if l + 1 == nlev - 1: + T.zeroRows(fine_normal, diag=0.0) + row_is = None + else: + row_is = _owned_is(kept[l + 1], comm) + # admissible columns at level l: owned, minus the opening rows + cs, ce = T.getOwnershipRangeColumn() + adm = np.arange(cs, ce) + if rot[l] is not None and rot[l][2].size: + adm = np.setdiff1d(adm, rot[l][2]) + col_is = _owned_is(adm, comm) + T = T.createSubMatrix(row_is, col_is) if row_is is not None \ + else T.createSubMatrix(_owned_is(np.arange(*T.getOwnershipRange()), comm), + col_is) + # drop the coarse unknowns no fine unknown reaches + reached = _reached_columns(T) + n_drop = int(comm.tompi4py().allreduce(int((~reached).sum()))) + if n_drop: + adm = adm[reached] + T = T.createSubMatrix( + _owned_is(np.arange(*T.getOwnershipRange()), comm), + _owned_is(np.arange(*T.getOwnershipRangeColumn())[reached], comm)) + kept[l] = adm + out[l] = T + hierarchy.level_kept = kept + # The restriction renumbers every coarse level, so any FAC patch rows the + # builder recorded for them (keyed to the unrestricted layouts) no longer + # apply; the finest's layout is untouched and its patch rows stay. + rows = getattr(hierarchy, "level_patch_rows", None) + hierarchy.level_patch_rows = ([None] * (nlev - 1) + [rows[-1]] + if rows is not None else [None] * nlev) + return out diff --git a/src/underworld3/utilities/fault_split.py b/src/underworld3/utilities/fault_split.py index db0759287..736d9ba74 100644 --- a/src/underworld3/utilities/fault_split.py +++ b/src/underworld3/utilities/fault_split.py @@ -189,7 +189,7 @@ def _fault_chain(dm, fault_edges, X, vS, shared, pStart, orientation=None): # side is the LEFT of the directed line the USER drew, not of the # coordinate-order tip rule. if orientation is not None and len(chain) >= 2: - d = np.asarray(orientation, dtype=float).ravel()[:2] + d = np.asarray(orientation, dtype=float).ravel()[:X.shape[1]] if float((X[chain[-1] - vS] - X[chain[0] - vS]) @ d) < 0.0: chain.reverse() return None, chain @@ -1598,7 +1598,7 @@ def _label_embedded_edges(dm, segments, values, exclude=None, verbose=False, def add_fault(mesh, faults, verbose=False, cut=True, exclude=None, - blind=1, across_seams=False): + blind=1, across_seams=False, **place_kwargs): """Cut AND split one or more faults into a mesh; return the split Mesh. The one-call form of the split-node pipeline: for each fault, the tips @@ -1648,6 +1648,11 @@ def add_fault(mesh, faults, verbose=False, cut=True, exclude=None, split through it — a chain end on the seam is a crossing, not a clipped end, so it is neither blinded nor pinned; nothing is redistributed. See :func:`split_fault`. + **place_kwargs + 3-D only: options of :func:`place_surface.place_sheet` (``clearance``, + ``size``, ``setback``). A bisection-refined mesh needs ``clearance`` + of about 0.9 (the default 0.6 leaves a cavity the fill cannot close + on it; the placement's volume guard refuses). Returns ------- @@ -1663,9 +1668,15 @@ def add_fault(mesh, faults, verbose=False, cut=True, exclude=None, pull_vertex_onto) if isinstance(faults, (Surface, tuple)) or ( - hasattr(faults, "name") and hasattr(faults, "control_points")): + hasattr(faults, "name") and hasattr(faults, "control_points")) or ( + hasattr(faults, "name") and hasattr(faults, "triangles")): faults = [faults] + if mesh.dim == 3: + return _add_fault_sheets(mesh, faults, verbose=verbose, **place_kwargs) + if place_kwargs: + raise TypeError(f"add_fault: {sorted(place_kwargs)} apply to 3-D sheets only") + segments = [] for entry in faults: if isinstance(entry, tuple) and len(entry) == 2 \ @@ -1758,6 +1769,10 @@ def add_fault(mesh, faults, verbose=False, cut=True, exclude=None, # same thing in 2-D as it does for a 3-D FaultSurface out._fault_traces = {name: np.array(poly, dtype=float) for name, poly in segments} + # the recipe, for replaying the same faults on the add-on multigrid + # levels (utilities.fault_mg.split_tail_levels) + out._fault_specs = [("line", name, np.array(poly, dtype=float)) + for name, poly in segments] surfaces = {entry.name: entry for entry in faults if hasattr(entry, "control_points") and hasattr(entry, "name")} @@ -1766,6 +1781,56 @@ def add_fault(mesh, faults, verbose=False, cut=True, exclude=None, return out +def _add_fault_sheets(mesh, faults, verbose=False, **place_kwargs): + """The 3-D form of :func:`add_fault`: each fault is a triangulated sheet + (a ``FaultSurface`` or a ``(name, points, triangles)`` triple), placed into + the mesh with :func:`place_surface.place_sheet` so its triangles become + interior faces, then every sheet is split (:func:`split_faults`, which + redistributes the network's cell stars rank-interior first). Like the 2-D + cut, the placed mesh carries NO multigrid tail of its own: the caller + (``Mesh.add_fault``) hands the parent's tail down, with its add-on levels + split the same way.""" + from underworld3.discretisation import Mesh + from underworld3.utilities.place_surface import place_sheet + + sheets = [] + for entry in faults: + if isinstance(entry, tuple) and len(entry) == 3 \ + and isinstance(entry[0], str): + name, points, triangles = entry + else: + name = entry.name + if not getattr(entry, "is_triangulated", True): + entry.triangulate() + points, triangles = entry.points, entry.triangles + sheets.append((name, np.asarray(points, dtype=float), + np.asarray(triangles, dtype=np.int64))) + + cut = mesh + for name, points, triangles in sheets: + boundaries = cut._boundaries_with(name) + cut_dm, info = place_sheet(cut.dm, points, triangles, label=name, + label_value=boundaries[name].value, + verbose=verbose, **place_kwargs) + child = Mesh(cut_dm, simplex=cut.dm.isSimplex(), + coordinate_system_type=cut.CoordinateSystem.coordinate_type, + qdegree=cut.qdegree, boundaries=boundaries, verbose=False) + child.parent = cut + child._relationship_kind = "refinement" + child._refine_dofs_coincide = False + child.regions = cut.regions + child._parent_mesh_version = cut._mesh_version + child._surface_info = info + cut._registered_children.add(child) + cut = child + out = split_faults(cut, [name for name, _p, _t in sheets], verbose=verbose) + out._fault_specs = [("sheet", name, points, triangles, dict(place_kwargs)) + for name, points, triangles in sheets] + out._fault_surfaces = {entry.name: entry for entry in faults + if hasattr(entry, "name") and hasattr(entry, "triangles")} + return out + + def _boundaries_with_sides(mesh, name): """The mesh's boundary enum extended with ``Plus`` / ``Minus``. @@ -1829,7 +1894,8 @@ def _fault_labels_touch_seam(dm, labels): return bool(uw.mpi.comm.allreduce(touch, op=MPI.LOR)) -def _redistribute_fault_interior(dm, labels, verbose=False, groups=None): +def _redistribute_fault_interior(dm, labels, verbose=False, groups=None, + pairs=None): """Redistribute a cut mesh so each fault's cell star is rank-interior. The default partition's balance cuts are ATTRACTED to the locally @@ -1855,12 +1921,15 @@ def _redistribute_fault_interior(dm, labels, verbose=False, groups=None): moved at all when its star is already interior to one — so two faults a domain apart are never gathered together (#670). Without ``groups`` the whole network is one region, as before. + ``_fault_point_pairs``) the pairing is carried through the move and + ``(dm, pairs, n_dropped)`` comes back — see + :func:`redistribute_by_assignment`. """ from underworld3.utilities.place_surface import _gather_regions comm = dm.getComm().tompi4py() if comm.size == 1: - return dm + return (dm, pairs, 0) if pairs is not None else dm if groups is None: groups = [list(labels)] @@ -1883,16 +1952,122 @@ def _redistribute_fault_interior(dm, labels, verbose=False, groups=None): names = sorted(name for name, _value in labels) raise RuntimeError( f"fault_split: no facets labelled {names} found on any rank.") + # the pairing rides the move as labels (stamped on the input, removed + # again below, so the input stays untouched) + names = stamp_point_pairs(dm, pairs) if pairs is not None else [] work, n_region, n_moved, owner, _canon = _gather_regions(dm, ids, layers=1) if work is dm: work = dm.clone() # the contract: a new dm, untouched input + new_pairs, dropped = (collect_point_pairs(work, names) + if pairs is not None else (None, 0)) + for lab in [l for n in names for l in _pair_label_names(n)]: + if dm.hasLabel(lab): + dm.removeLabel(lab) if verbose: names = sorted(name for name, _value in labels) uw.pprint(f"[fault_split] {names}: {len(owner)} region(s) of " f"{n_region} cells, {n_moved} moved (owners " f"{sorted(owner.values())}); local cells now " f"{work.getHeightStratum(0)[1]}") - return work + return (work, new_pairs, dropped) if pairs is not None else work + + +def _pair_label_names(name): + return f"_pair_minus_{name}", f"_pair_plus_{name}" + + +def stamp_point_pairs(dm, pairs_by_fault): + """Write a coincident-point pairing into two labels per fault (one value + per pair, on its Minus point and on its Plus point) so a redistribution + carries it: labels ride every point migration, where the migration star + forest PETSc returns for an INITIAL distribution does not index the + stratified numbering (measured: three quarters of the points mapped to the + wrong depth). The ids are globally unique (each rank's pairs offset by the + ranks before it). Returns the fault names, for :func:`collect_point_pairs`. + Collective.""" + comm = dm.getComm().tompi4py() + names = sorted(set().union(*comm.allgather(set(pairs_by_fault)))) + for name in names: + pairs = pairs_by_fault.get(name, {}) + counts = comm.allgather(len(pairs)) + offset = int(sum(counts[:comm.rank])) + minus, plus = _pair_label_names(name) + for lab in (minus, plus): + if dm.hasLabel(lab): + dm.removeLabel(lab) + dm.createLabel(lab) + for k, (qm, qp) in enumerate(pairs.items()): + dm.setLabelValue(minus, int(qm), offset + k + 1) + dm.setLabelValue(plus, int(qp), offset + k + 1) + return names + + +def collect_point_pairs(dm, names, remove=True): + """Read back a pairing :func:`stamp_point_pairs` wrote, after a + redistribution: ``(pairs_by_fault, n_dropped)``. A rank keeps every pair + whose two points it holds; ``n_dropped`` counts, over all ranks, the pairs + no single rank holds both sides of — zero once the fault stars are + rank-interior (the split's rule). The labels are removed unless asked + otherwise. Collective.""" + comm = dm.getComm().tompi4py() + out = {} + n_dropped = 0 + for name in names: + minus, plus = _pair_label_names(name) + + def points_by_value(lab_name): + table = {} + if not dm.hasLabel(lab_name): + return table + lab = dm.getLabel(lab_name) + vis = lab.getValueIS() + values = vis.getIndices() if vis is not None else [] + for v in values: + v = int(v) + if lab.getStratumSize(v) > 0: + table[v] = int(lab.getStratumIS(v).getIndices()[0]) + return table + + tm, tp = points_by_value(minus), points_by_value(plus) + common = set(tm) & set(tp) + out[name] = {tm[v]: tp[v] for v in common} + seen = set(tm) | set(tp) + all_seen = set().union(*comm.allgather(seen)) + all_common = set().union(*comm.allgather(common)) + n_dropped += len(all_seen - all_common) + if remove: + for lab in (minus, plus): + if dm.hasLabel(lab): + dm.removeLabel(lab) + return out, n_dropped + + +def redistribute_by_assignment(dm, assign, pairs=None): + """Redistribute ``dm`` so local cell ``k`` goes to rank ``assign[k]``. + Returns ``(new_dm, new_pairs, n_dropped)``: the pairing ``pairs`` (a + mesh's ``_fault_point_pairs``, or None) carried through the move with + :func:`stamp_point_pairs` / :func:`collect_point_pairs`. Nothing moves when + every cell is already where it is asked to be (the input comes back). The + shell-partitioner idiom the fault-interior move and the level co-location + both use.""" + comm = dm.getComm().tompi4py() + assign = np.asarray(assign, dtype=np.int32) + if comm.size == 1 or not comm.allreduce(int((assign != comm.rank).sum())): + return dm, pairs, 0 + work = dm.clone() + names = stamp_point_pairs(work, pairs) if pairs else [] + part = work.getPartitioner() + part.setType(PETSc.Partitioner.Type.SHELL) + order = np.argsort(assign, kind="stable").astype(np.int32) + sizes = np.bincount(assign, minlength=comm.size).astype(np.int32) + part.setShellPartition(comm.size, sizes=sizes, points=order) + work.distribute() + new_pairs, dropped = (collect_point_pairs(work, names) if pairs + else (None, 0)) + for lab in [l for n in names for l in _pair_label_names(n)]: + if dm.hasLabel(lab): + dm.removeLabel(lab) + return work, new_pairs, dropped def split_faults(mesh, names, verbose=False, groups=None, @@ -2236,6 +2411,8 @@ def carry_keys(d, what): child._fault_traces = dict(mesh._fault_traces) if getattr(mesh, "_fault_surfaces", None): child._fault_surfaces = dict(mesh._fault_surfaces) + if getattr(mesh, "_fault_specs", None): + child._fault_specs = list(mesh._fault_specs) child.regions = mesh.regions child._parent_mesh_version = mesh._mesh_version child._fault_point_pairs = dict(prior) diff --git a/src/underworld3/utilities/line_cut.py b/src/underworld3/utilities/line_cut.py index 8e27293b8..515baf5ee 100644 --- a/src/underworld3/utilities/line_cut.py +++ b/src/underworld3/utilities/line_cut.py @@ -139,13 +139,76 @@ def _global_extent(dm): return float((-box[cdim:] - box[:cdim]).max()) +class _Chord: + """A straight segment A -> B of a planar polyline.""" + + def __init__(self, A, B): + self.A, self.B = A, B + self.d = B - A + self.dd = float(self.d @ self.d) + self.nrm = np.array([-self.d[1], self.d[0]]) + + def foot(self, X): + u = np.clip(((X - self.A) @ self.d) / self.dd, 0.0, 1.0) + return self.A + u[:, None] * self.d + + def distance(self, X): + return np.linalg.norm(X - self.foot(X), axis=1) + + def side(self, X): + """Zero on the line, opposite signs either side.""" + return (X - self.A) @ self.nrm + + def within(self, F): + """Points of the line between A and B, not on its infinite extension: + a polyline is a chain of finite segments.""" + u = ((F - self.A) @ self.d) / self.dd + return (u >= 0.0) & (u <= 1.0) + + +class _GreatCircleArc: + """The short great-circle arc A -> B about the origin: a segment of a + polyline on a sphere. A polyline given with three coordinates is read this + way — the surface meshes this module cuts are spheres centred on the + origin (``SphericalManifold``). The plane through A, B and the origin plays + the part of the chord's line; a point is on the arc when it lies in that + plane between the end points.""" + + def __init__(self, A, B): + self.A, self.B = A, B + m = np.cross(A, B) + self.m = m / np.linalg.norm(m) + + def within(self, F): + return ((np.cross(self.A, F) @ self.m) >= 0.0) & ((np.cross(F, self.B) @ self.m) >= 0.0) + + def foot(self, X): + # drop the out-of-plane component and keep the point's own radius, so + # a vertex moved onto the arc stays on its sphere + Fp = X - np.outer(X @ self.m, self.m) + radius = np.linalg.norm(X, axis=1) / np.maximum(np.linalg.norm(Fp, axis=1), 1e-300) + Fp = Fp * radius[:, None] + inside = self.within(Fp) + nearer_A = np.linalg.norm(X - self.A, axis=1) <= np.linalg.norm(X - self.B, axis=1) + end = np.where(nearer_A[:, None], self.A, self.B) + return np.where(inside[:, None], Fp, end) + + def distance(self, X): + return np.linalg.norm(X - self.foot(X), axis=1) + + def side(self, X): + return X @ self.m + + def _segments(lines): - """Every (A, B) segment of every polyline.""" + """Every segment of every polyline: chords of a planar polyline, great-circle + arcs of one given on a sphere (three coordinates).""" for pts in lines: - pts = np.asarray(pts, dtype=float)[:, :2] + pts = np.asarray(pts, dtype=float) + kind = _GreatCircleArc if pts.shape[1] == 3 else _Chord for A, B in zip(pts[:-1], pts[1:]): if np.any(B != A): - yield A, B + yield kind(A, B) def _distance_to_lines(X, lines): @@ -155,10 +218,8 @@ def _distance_to_lines(X, lines): attracting vertices beyond its own end. """ best = np.full(len(X), np.inf) - for A, B in _segments(lines): - d = B - A - u = np.clip(((X - A) @ d) / (d @ d), 0.0, 1.0) - best = np.minimum(best, np.linalg.norm(X - (A + u[:, None] * d), axis=1)) + for seg in _segments(lines): + best = np.minimum(best, seg.distance(X)) return best @@ -166,10 +227,8 @@ def _project_onto_lines(X, lines): """The nearest point of the polylines to each point.""" best = np.full(len(X), np.inf) out = X.copy() - for A, B in _segments(lines): - d = B - A - u = np.clip(((X - A) @ d) / (d @ d), 0.0, 1.0) - foot = A + u[:, None] * d + for seg in _segments(lines): + foot = seg.foot(X) dist = np.linalg.norm(X - foot, axis=1) closer = dist < best best = np.where(closer, dist, best) @@ -191,23 +250,17 @@ def _crossing_parameters(X, ends, lines, on_line): """ P, Q = X[ends[:, 0]], X[ends[:, 1]] touches = on_line[ends[:, 0]] | on_line[ends[:, 1]] - t = np.full(len(P), np.nan) multiply_crossed = np.zeros(len(P), dtype=bool) - for A, B in _segments(lines): - d = B - A - nrm = np.array([-d[1], d[0]]) - sP, sQ = (P - A) @ nrm, (Q - A) @ nrm + for seg in _segments(lines): + sP, sQ = seg.side(P), seg.side(Q) straddles = (sP * sQ < 0.0) & ~touches - with np.errstate(invalid="ignore", divide="ignore"): tk = np.where(straddles, sP / (sP - sQ), np.nan) - # The crossing must lie between A and B, not merely on the infinite line - # through them: a polyline is a chain of finite segments. - foot = P + tk[:, None] * (Q - P) - u = ((foot - A) @ d) / (d @ d) - hit = straddles & (u >= 0.0) & (u <= 1.0) - + # The crossing must lie between the segment's ends, not merely on the + # infinite line (or great circle) through them. + foot = P + np.where(straddles, tk, 0.0)[:, None] * (Q - P) + hit = straddles & seg.within(foot) multiply_crossed |= hit & np.isfinite(t) t = np.where(hit, tk, t) return t, np.flatnonzero(multiply_crossed) @@ -401,21 +454,38 @@ def _minimal_face_vertices(X, cell_verts, point): any size — which embedded meshes need, their cell sizes differing by an order of magnitude either side of a zone skin. """ - A = X[cell_verts[:, 0], :2] - B = X[cell_verts[:, 1], :2] - C = X[cell_verts[:, 2], :2] - v0, v1, v2 = C - A, B - A, np.asarray(point, dtype=float)[:2] - A - den = v0[:, 0] * v1[:, 1] - v1[:, 0] * v0[:, 1] - # A degenerate cell has no interior to be inside of; it cannot hold the - # point and must not divide. - alive = np.abs(den) > 0.0 - den = np.where(alive, den, 1.0) - lc = (v2[:, 0] * v1[:, 1] - v1[:, 0] * v2[:, 1]) / den - lb = (v0[:, 0] * v2[:, 1] - v2[:, 0] * v0[:, 1]) / den - la = 1.0 - lb - lc - + A = X[cell_verts[:, 0]] + B = X[cell_verts[:, 1]] + C = X[cell_verts[:, 2]] + point = np.asarray(point, dtype=float) tol = 1e-9 - inside = np.flatnonzero(alive & (la >= -tol) & (lb >= -tol) & (lc >= -tol)) + if X.shape[1] == 2: + v0, v1, v2 = C - A, B - A, point[:2] - A + den = v0[:, 0] * v1[:, 1] - v1[:, 0] * v0[:, 1] + alive = np.abs(den) > 0.0 + den = np.where(alive, den, 1.0) + lc = (v2[:, 0] * v1[:, 1] - v1[:, 0] * v2[:, 1]) / den + lb = (v0[:, 0] * v2[:, 1] - v2[:, 0] * v0[:, 1]) / den + la = 1.0 - lb - lc + inside = np.flatnonzero(alive & (la >= -tol) & (lb >= -tol) & (lc >= -tol)) + else: + # a surface cell in three dimensions: barycentric coordinates of the + # point's projection onto the cell's plane, the point no further from + # that plane than the cell's own size + e1, e2, v = B - A, C - A, point - A + g11, g12, g22 = (e1 * e1).sum(1), (e1 * e2).sum(1), (e2 * e2).sum(1) + r1, r2 = (v * e1).sum(1), (v * e2).sum(1) + den = g11 * g22 - g12 * g12 + alive = den > 0.0 + den = np.where(alive, den, 1.0) + lb = (r1 * g22 - r2 * g12) / den + lc = (r2 * g11 - r1 * g12) / den + la = 1.0 - lb - lc + n = np.cross(e1, e2) + area2 = np.maximum(np.linalg.norm(n, axis=1), 1e-300) + height = np.abs((v * n).sum(1)) / area2 + near = height < 0.5 * np.sqrt(area2) + inside = np.flatnonzero(alive & near & (la >= -tol) & (lb >= -tol) & (lc >= -tol)) if not len(inside): return np.empty(0, dtype=np.int64) # One containing cell settles it; the first keeps the answer independent @@ -439,12 +509,28 @@ def _cell_quality(X, cell_verts): P = X[cell_verts] e = np.stack([P[:, 2] - P[:, 1], P[:, 0] - P[:, 2], P[:, 1] - P[:, 0]], axis=1) - twice_area = ((P[:, 1, 0] - P[:, 0, 0]) * (P[:, 2, 1] - P[:, 0, 1]) - - (P[:, 1, 1] - P[:, 0, 1]) * (P[:, 2, 0] - P[:, 0, 0])) + twice_area = _signed_twice_areas(P) return 2.0 * np.sqrt(3.0) * twice_area / np.maximum( (e ** 2).sum(axis=(1, 2)), np.finfo(float).tiny) +def _signed_twice_areas(P): + """Twice the signed area of each triangle ``P[:, 0:3]``: the planar cross + product in two dimensions; on a surface mesh in three, the cell normal + against the outward direction (the centroid's, the surface being a sphere + about the origin). A mesh is wound one way throughout, so the convention + is read off the majority and an inverted cell is one against it.""" + if P.shape[2] == 2: + return ((P[:, 1, 0] - P[:, 0, 0]) * (P[:, 2, 1] - P[:, 0, 1]) + - (P[:, 1, 1] - P[:, 0, 1]) * (P[:, 2, 0] - P[:, 0, 0])) + n = np.cross(P[:, 1] - P[:, 0], P[:, 2] - P[:, 0]) + c = P.sum(axis=1) + signed = (n * c).sum(axis=1) / np.maximum(np.linalg.norm(c, axis=1), 1e-300) + if (signed < 0.0).sum() > 0.5 * len(signed): + signed = -signed + return signed + + def _cell_edge_counts(dm, crossed_edges, on_line_vertices): """Per cell: how many of its edges are crossed, how many corners are on a line.""" cS, cE = dm.getHeightStratum(0) @@ -546,15 +632,15 @@ def cell_areas(dm): X = _coords(dm) vS, _vE = dm.getDepthStratum(0) cS, cE = dm.getHeightStratum(0) - out = np.empty(cE - cS) + rings = [] for c in range(cS, cE): ring = [] for e, o in zip(dm.getCone(c), dm.getConeOrientation(c)): a, b = (int(v) - vS for v in dm.getCone(e)) ring.append(a if o >= 0 else b) - p, q, r = X[ring[0]], X[ring[1]], X[ring[2]] - out[c - cS] = 0.5 * ((q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0])) - return out + rings.append(ring[:3]) + P = X[np.asarray(rings, dtype=np.int64)] if rings else np.empty((0, 3, X.shape[1])) + return 0.5 * _signed_twice_areas(P) def min_angles(dm): @@ -872,7 +958,8 @@ def pull_vertex_onto(dm, targets): dm : PETSc.DMPlex **Not modified.** The pull is returned as a new mesh. targets : array_like - An ``(N, 2)`` array of points, or one point. + An ``(N, cdim)`` array of points, or one point — three coordinates on + a sphere surface mesh. Returns ------- @@ -906,7 +993,8 @@ def pull_vertex_onto(dm, targets): scale = _global_extent(dm) cell_verts = _cell_vertices(dm) - for t in np.atleast_2d(np.asarray(targets, dtype=float))[:, :2]: + cdim = X.shape[1] + for t in np.atleast_2d(np.asarray(targets, dtype=float))[:, :cdim]: # Candidates are the vertices of the smallest face CONTAINING the # target, not every vertex in the mesh. The nearest vertex overall can # belong to a neighbouring cell, and moving it leaves the containing @@ -919,7 +1007,7 @@ def pull_vertex_onto(dm, targets): face = _minimal_face_vertices(X, cell_verts, t) held = len(face) > 0 candidates = face if held else np.arange(len(X)) - d = np.linalg.norm(X[candidates, :2] - t, axis=1) + d = np.linalg.norm(X[candidates] - t, axis=1) # Reduced as (outside, distance, x, y) so both decisions ride one # reduction: tuples compare lexicographically, so MIN prefers ANY rank # that holds a containing cell over every rank that does not, then the @@ -933,16 +1021,15 @@ def pull_vertex_onto(dm, targets): # containment rule, kept because a target off the mesh still has to # land somewhere. local = ((0 if held else 1, float(d.min()), - *X[candidates[int(d.argmin())], :2]) - if d.size else (1, np.inf, np.inf, np.inf)) - _outside, _dist, tx, ty = uw.mpi.comm.allreduce(local, op=MPI.MIN) + *X[candidates[int(d.argmin())]]) + if d.size else (1, np.inf, *([np.inf] * cdim))) + reduced = uw.mpi.comm.allreduce(local, op=MPI.MIN) # Move it by POSITION, not by index: the chosen vertex may be a ghost # here and an owned point there, and both copies have to end up in the # same place without a star-forest exchange. - hit = np.flatnonzero(np.linalg.norm(X[:, :2] - np.array([tx, ty]), - axis=1) < 1e-12 * scale) - arr[hit, :2] = t + hit = np.flatnonzero(np.linalg.norm(X - np.array(reduced[2:]), axis=1) < 1e-12 * scale) + arr[hit] = t out = dm.clone() _set_coordinates(out, np.arange(len(arr)), arr) diff --git a/src/underworld3/utilities/place_surface.py b/src/underworld3/utilities/place_surface.py index 508eb6e68..becd3a07e 100644 --- a/src/underworld3/utilities/place_surface.py +++ b/src/underworld3/utilities/place_surface.py @@ -3596,7 +3596,16 @@ def area(P): order = sorted(range(len(loops)), key=lambda k: -abs(area(loops[k]))) rings = [] for k in order: - P = loops[k] + P = np.asarray(loops[k], dtype=float) + # Every loop counter-clockwise. The walk returns each loop in + # whichever direction it started, and OCC reads a hole loop wound + # against the exterior as ADDED area: on an annulus whose inner loop + # came out clockwise the tool had the area of the full disc plus the + # hole, and a boolean against it met nothing (measured: tool area + # 3.93 against 2.36 for cell sizes 0.04 and 0.08, correct for 0.05 + # and 0.06, which happened to wind both loops the same way). + if area(P) < 0.0: + P = P[::-1] pts = [occ.addPoint(x, y, 0.0) for x, y in P] lines = [occ.addLine(pts[i], pts[(i + 1) % len(pts)]) for i in range(len(pts))] @@ -4372,6 +4381,13 @@ def find(v): "multiply-outcropping zone is not built.") +# Adjacent boundary facets whose normals differ by less than this are the +# same SMOOTH WALL: the faceting of a curved boundary (angle -> 0 under +# refinement) groups into one wall, while a box-like corner (90 degrees) +# separates two. The outcrop bowl may open anywhere on a band-touched wall. +_WALL_DIHEDRAL_COS = np.cos(np.deg2rad(45.0)) + + def _outcrop_frame_2d(comp_loops, asm_pts, band_pairs, X, on_wall): """Which mesh boundary vertices an outcrop may DELETE, and which ring vertices count as boundary contact AWAY from the outcrop. @@ -4389,18 +4405,48 @@ def _outcrop_frame_2d(comp_loops, asm_pts, band_pairs, X, on_wall): """ band_nodes = sorted({int(v) for pair in band_pairs for v in pair}) B = asm_pts[band_nodes] + # A compressed loop's segments group into SMOOTH WALLS across + # low-angle turns (the 2-D reading of ``_boundary_walls``): the + # faceting of a curved boundary is one wall, a box corner separates + # two. A band-touched segment opens its whole wall, and only a sharp + # corner is the domain's shape. Before this, every chord of an annulus + # was its own wall, so the clearance cavity of a dipping band, which + # spans a few chords beside the one the band crosses, was refused as + # contact away from the outcrop. seg_pts, seg_edges, corners = [], [], [] for L in comp_loops: - corners.append(L) + L = np.asarray(L, dtype=float) n = len(L) + e_in = L - np.roll(L, 1, axis=0) + e_out = np.roll(L, -1, axis=0) - L + e_in = e_in / np.linalg.norm(e_in, axis=1)[:, None] + e_out = e_out / np.linalg.norm(e_out, axis=1)[:, None] + sharp = np.einsum("ij,ij->i", e_in, e_out) < _WALL_DIHEDRAL_COS + corners.append(L) + # walls: runs of segments between sharp corners (segment i runs + # from vertex i to i+1; a wall break sits at a sharp vertex) + wall_of = np.zeros(n, dtype=np.int64) + w = 0 + for i in range(n): + if i > 0 and sharp[i]: + w += 1 + wall_of[i] = w + if not sharp.any(): + wall_of[:] = 0 + elif not sharp[0]: + wall_of[wall_of == w] = 0 # the run wraps around vertex 0 + touched = set() for i in range(n): A, C = L[i], L[(i + 1) % n] e = C - A u = np.clip(((B - A) @ e) / float(e @ e), 0.0, 1.0) d = np.linalg.norm(B - (A + u[:, None] * e), axis=1) if (d < 1e-9).any(): + touched.add(int(wall_of[i])) + for i in range(n): + if int(wall_of[i]) in touched: seg_edges.append((len(seg_pts), len(seg_pts) + 1)) - seg_pts += [A, C] + seg_pts += [L[i], L[(i + 1) % n]] if not seg_edges: raise RuntimeError( "the outcrop band lies on no boundary segment; the clip and " @@ -4412,17 +4458,13 @@ def _outcrop_frame_2d(comp_loops, asm_pts, band_pairs, X, on_wall): for c in np.vstack(corners): is_corner |= np.linalg.norm(X - c, axis=1) < 1e-9 near_touched = d_seg < 1e-9 + # A compressed vertex off the band is never deleted: on a straight wall + # none exist, on a curved wall every vertex is one, and keeping them is + # what keeps the domain's area (the splice below carries them). deletable = on_wall & near_touched & (~is_corner | (d_band < 1e-9)) return deletable, near_touched -# Adjacent boundary facets whose normals differ by less than this are the -# same SMOOTH WALL: the faceting of a curved boundary (angle -> 0 under -# refinement) groups into one wall, while a box-like corner (90 degrees) -# separates two. The outcrop bowl may open anywhere on a band-touched wall. -_WALL_DIHEDRAL_COS = np.cos(np.deg2rad(45.0)) - - def _boundary_walls(dom_verts, dom_tris, region): """Group coplanar regions into SMOOTH WALLS across low-angle creases. @@ -5386,21 +5428,161 @@ def _ladder_curved_assembly_2d(P, width, size, reach=None): return pts, tris, float(np.abs(twice).sum() / 2.0) +def _inside_domain_2d(pts, domain): + """Which of ``pts`` lie inside the domain: in its largest-area loop and + in none of the others (the holes).""" + from matplotlib.path import Path + + def area(P): + return 0.5 * float(P[:, 0] @ np.roll(P[:, 1], -1) + - P[:, 1] @ np.roll(P[:, 0], -1)) + + loops = [np.asarray(L, dtype=float) for L in domain] + order = sorted(range(len(loops)), key=lambda k: -abs(area(loops[k]))) + inside = Path(loops[order[0]]).contains_points(pts) + for k in order[1:]: + inside &= ~Path(loops[k]).contains_points(pts) + return inside + + def _ladder_domain_gate_2d(pts, domain): - """Refuse a structured band that protrudes past the domain bbox.""" + """Refuse a structured band that protrudes past the domain.""" if domain is None: return - lo = np.min([np.min(np.asarray(Lp, dtype=float), axis=0) - for Lp in domain], axis=0) - hi = np.max([np.max(np.asarray(Lp, dtype=float), axis=0) - for Lp in domain], axis=0) - if (pts < lo - 1e-12).any() or (pts > hi + 1e-12).any(): + if not _inside_domain_2d(np.asarray(pts, dtype=float), domain).all(): raise ValueError( "the ladder band protrudes past the domain; it is structured " "and cannot be clipped. Shorten the trace or use the default " "band (which clips).") +def _ladder_outcrop_assembly_2d(S, R, width, size, domain): + """The ladder that OUTCROPS: the structured band on the samples inside + the domain, stopping one rung short of the wall, closed by a small + unstructured CAP between its last rung and the wall, clipped against + the domain polygon exactly as the default band is. + + The spine keeps its rung vertices (a split walks them, nothing snaps) + up to the last rung; the cap carries the band to the wall and its + clipped edge is the outcrop band. The cap's cells are where a weak + layer bridges a split that must stop blind of the surface (the trench + of a convection model), so their being unstructured costs nothing the + split needs. Either end may protrude; a sample row counts as inside + only when its spine and both rail points are. Returns + ``(points, triangles, area)`` with the cap's nodes merged onto the + ladder's rung vertices. + """ + import gmsh + + S = np.asarray(S, dtype=float) + R = np.asarray(R, dtype=float) + rows = np.stack([S + 0.5 * width * R, S, S - 0.5 * width * R], axis=1) + inside = _inside_domain_2d(rows.reshape(-1, 2), domain).reshape(-1, 3).all(axis=1) + if inside.all(): + return _ladder_curved_assembly_2d(S, width, size, reach=R) + idx = np.flatnonzero(inside) + if idx.size < 2: + raise ValueError( + "the ladder band has fewer than two rungs inside the domain") + # the inside run must be contiguous (a band leaving and re-entering + # the domain is two bands) + if idx[-1] - idx[0] + 1 != idx.size: + raise ValueError( + "the ladder band leaves the domain and re-enters it; place it " + "as two bands") + k0, k1 = int(idx[0]), int(idx[-1]) + caps = [] # (row index, outward dir) + if k1 < len(S) - 1: + caps.append((k1, S[k1] - S[k1 - 1])) + if k0 > 0: + caps.append((k0, S[k0] - S[k0 + 1])) + pts_l, tris_l, area_l = _ladder_curved_assembly_2d( + S[k0:k1 + 1], width, size, reach=R[k0:k1 + 1]) + + comp = [_compress_collinear_loop(np.asarray(L, dtype=float)) for L in domain] + pts_all = [pts_l] + tris_all = [tris_l] + area = float(area_l) + n_off = len(pts_l) + gmsh.initialize() + gmsh.option.setNumber("General.Terminal", 0) + try: + for k, t in caps: + t = t / np.linalg.norm(t) + rung = rows[k] # rail+, spine, rail- + # reach past the wall by the band's own scale; the clip trims it + ext = 3.0 * size + width + gmsh.model.add(f"uw_ladder_cap_{k}") + occ = gmsh.model.occ + corners = [rung[0], rung[1], rung[2], rung[2] + ext * t, + rung[0] + ext * t] + ptags = [occ.addPoint(q[0], q[1], 0.0) for q in corners] + ltags = [occ.addLine(ptags[i], ptags[(i + 1) % 5]) for i in range(5)] + face = occ.addPlaneSurface([occ.addCurveLoop(ltags)]) + occ.synchronize() + tool = _occ_domain_2d(occ, comp) + occ.intersect([(2, face)], [(2, tool)]) + occ.synchronize() + faces = gmsh.model.getEntities(2) + if not faces: + raise ValueError( + "the ladder's outcrop cap meets no part of the domain") + area += sum(occ.getMass(2, f) for _d, f in faces) + # the two rung segments stay single edges: their nodes are the + # ladder's rung vertices and nothing may be inserted on them + for _d, c in gmsh.model.getEntities(1): + ends = gmsh.model.getBoundary([(1, c)], oriented=False) + xy = [np.asarray(gmsh.model.getValue(0, e[1], []))[:2] for e in ends] + if len(xy) == 2 and all( + min(np.linalg.norm(q - r) for r in rung) < 1e-9 for q in xy): + gmsh.model.mesh.setTransfiniteCurve(c, 2) + gmsh.option.setNumber("Mesh.MeshSizeMin", 0.7 * size) + gmsh.option.setNumber("Mesh.MeshSizeMax", 1.3 * size) + gmsh.model.mesh.generate(2) + tags, xyz, _ = gmsh.model.mesh.getNodes() + xy = np.asarray(xyz).reshape(-1, 3)[:, :2] + xy = _snap_to_boundary_2d(xy, comp) + renum = {int(tg): i for i, tg in enumerate(tags)} + tris = [] + for _d, f in faces: + et, _ei, en = gmsh.model.mesh.getElements(2, f) + for ty, nodes in zip(et, en): + if ty == 2: + tris.append(np.array([renum[int(x)] for x in nodes], + dtype=np.int64).reshape(-1, 3)) + if not tris: + raise RuntimeError("the ladder's outcrop cap meshed to no triangles") + tris = np.vstack(tris) + # merge the cap's rung nodes onto the ladder's + ladder_rung = [k - k0, (k - k0) + (k1 - k0 + 1), (k - k0) + 2 * (k1 - k0 + 1)] + remap = np.arange(len(xy)) + n_off + keep = np.ones(len(xy), dtype=bool) + for r_xy, r_id in zip(rung, ladder_rung): + hit = np.flatnonzero(np.linalg.norm(xy - r_xy, axis=1) < 1e-9) + if hit.size != 1: + raise RuntimeError( + "the ladder's outcrop cap does not carry its rung " + "vertices as nodes") + remap[hit[0]] = r_id + keep[hit[0]] = False + new_ids = np.cumsum(keep) - 1 + n_off + remap[keep] = new_ids[keep] + pts_all.append(xy[keep]) + tris_all.append(remap[tris]) + n_off += int(keep.sum()) + gmsh.model.remove() + finally: + gmsh.finalize() + pts = np.vstack(pts_all) + tris = np.vstack(tris_all) + Q = pts[tris] + twice = ((Q[:, 1, 0] - Q[:, 0, 0]) * (Q[:, 2, 1] - Q[:, 0, 1]) + - (Q[:, 1, 1] - Q[:, 0, 1]) * (Q[:, 2, 0] - Q[:, 0, 0])) + flip = twice < 0.0 + tris[flip] = tris[flip][:, [0, 2, 1]] + return pts, tris, float(area) + + def _occ_ladder_assembly_2d(polylines, width, size, assembly="fuse", domain=None): """The cut-ready band: transfinite, THREE nodes across, spine included. @@ -5441,10 +5623,9 @@ def _occ_ladder_assembly_2d(polylines, width, size, assembly="fuse", # rung positions and mitred reach vectors from ONE parametrisation # (a coarser level passes S[::2], reach[::2]). Used verbatim. S, R = (np.asarray(q, dtype=float) for q in p0) - pts_c, tris_c, area_c = _ladder_curved_assembly_2d( - S, width, size, reach=R) - _ladder_domain_gate_2d(pts_c, domain) - return pts_c, tris_c, area_c + if domain is not None: + return _ladder_outcrop_assembly_2d(S, R, width, size, domain) + return _ladder_curved_assembly_2d(S, width, size, reach=R) P = np.asarray(p0, dtype=float)[:, :2] a, b = P[0], P[-1] t = b - a @@ -5459,21 +5640,26 @@ def _occ_ladder_assembly_2d(polylines, width, size, assembly="fuse", # same three-rail structure, mitred. The straight path below # stays on gmsh transfinite so the recorded composed benchmark # remains bit-identical. - pts_c, tris_c, area_c = _ladder_curved_assembly_2d(P, width, size) - _ladder_domain_gate_2d(pts_c, domain) - return pts_c, tris_c, area_c + if domain is not None: + seg = np.linalg.norm(np.diff(P, axis=0), axis=1) + arc = np.concatenate([[0.0], np.cumsum(seg)]) + n_s = max(2, int(round(float(arc[-1]) / size))) + s_ = np.linspace(0.0, float(arc[-1]), n_s + 1) + S = np.column_stack([np.interp(s_, arc, P[:, 0]), + np.interp(s_, arc, P[:, 1])]) + return _ladder_outcrop_assembly_2d(S, _mitred_reach_2d(S), width, + size, domain) + return _ladder_curved_assembly_2d(P, width, size) corners = np.array([a + 0.5 * width * n, b + 0.5 * width * n, b - 0.5 * width * n, a - 0.5 * width * n]) - if domain is not None: - lo = np.min([np.min(np.asarray(Lp, dtype=float), axis=0) - for Lp in domain], axis=0) - hi = np.max([np.max(np.asarray(Lp, dtype=float), axis=0) - for Lp in domain], axis=0) - if (corners < lo - 1e-12).any() or (corners > hi + 1e-12).any(): - raise ValueError( - "the ladder band protrudes past the domain; it is meshed " - "transfinite and cannot be clipped. Shorten the trace or " - "use the default band (which clips).") + if domain is not None and not _inside_domain_2d(corners, domain).all(): + # a straight band reaching the wall: the numpy ladder with the + # outcrop cap (the gmsh transfinite path below stays for the + # interior band, bit-identical to the recorded benchmark) + n_s = max(2, int(round(L / size))) + S = a[None, :] + np.linspace(0.0, L, n_s + 1)[:, None] * t[None, :] + return _ladder_outcrop_assembly_2d(S, np.tile(n, (len(S), 1)), width, + size, domain) n_along = max(2, int(round(L / size))) gmsh.initialize() @@ -5766,7 +5952,8 @@ def _arc_project(polyline, p): return best_d, best_s -def _outcrop_ring_splice(ring, X, boundary_pairs, chain_ids, chain_ends): +def _outcrop_ring_splice(ring, X, boundary_pairs, chain_ids, chain_ends, + keep=None): """Replace the cavity ring's boundary span with the ribbon's interior chain. @@ -5787,6 +5974,13 @@ def _outcrop_ring_splice(ring, X, boundary_pairs, chain_ids, chain_ends): ``(spliced_ring, removed_wall_pairs)``, the removed pairs as old vertex rows for the wall-label discovery. A second boundary run refuses — the carve spilled onto the boundary away from the outcrop. + + ``keep`` (a vertex mask) names span vertices that SURVIVE: on a curved + wall the boundary's own vertices beside the band are protected (each + is a corner of the compressed boundary), and the cap must run through + them rather than chord across — the chord loses the sagitta, and the + area guard refuses. Only the span between the last kept vertex before + the band and the first after it is removed. """ n = len(ring) wall_edge = [frozenset((ring[i], ring[(i + 1) % n])) in boundary_pairs @@ -5820,7 +6014,25 @@ def _outcrop_ring_splice(ring, X, boundary_pairs, chain_ids, chain_ends): # its corner_l-side end first — the loop's orientation is preserved. tail = [ring[(i0 + k + j) % n] for j in range(n - k + 1)] seq = chain_ids if ends[0][1] <= ends[1][1] else chain_ids[::-1] - return tail + list(seq), removed + s_lo, s_hi = sorted(e[1] for e in ends) + if keep is None: + return tail + list(seq), removed + # arc parameter of every span vertex, in the span's own polyline + span_ids = [ring[(i0 + j) % n] for j in range(k + 1)] + s_v = [_arc_project(span, X[v])[1] for v in span_ids] + before = [(v, sv) for v, sv in zip(span_ids[1:-1], s_v[1:-1]) + if sv < s_lo - 1e-12 and keep[v]] + after = [(v, sv) for v, sv in zip(span_ids[1:-1], s_v[1:-1]) + if sv > s_hi + 1e-12 and keep[v]] + before.sort(key=lambda t: t[1]) + after.sort(key=lambda t: t[1]) + # the removed wall pairs: a span edge that loses a vertex, or whose arc + # overlaps the band's clipped edge between the corners + removed = [(span_ids[j], span_ids[j + 1]) for j in range(k) + if not (keep[span_ids[j]] and keep[span_ids[j + 1]]) + or (s_v[j] < s_hi + 1e-12 and s_v[j + 1] > s_lo - 1e-12)] + return (tail + [v for v, _sv in before] + list(seq) + + [v for v, _sv in after]), removed def _ring_growing(cells, drop, held_mask): @@ -7297,7 +7509,8 @@ def _place_thin_volume_2d(dm, polylines, width, label, label_value, chain_ids = [len(X) + int(v) for v in chain_k] chain_ends = (asm_pts[chain_k[0]], asm_pts[chain_k[-1]]) ring, removed_wall = _outcrop_ring_splice( - ring, X, boundary_pairs, chain_ids, chain_ends) + ring, X, boundary_pairs, chain_ids, chain_ends, + keep=~victim) off_band = [v for v in ring if v < len(X) and on_wall[v] and not near_touched[v]] if off_band: @@ -7356,14 +7569,18 @@ def _place_thin_volume_2d(dm, polylines, width, label, label_value, span_labels.append(pairs_p) span_segments.append((X[int(a)].copy(), X[int(b)].copy())) - # The splice ends: the surviving corner vertices and the - # chain ends they meet — the chain is the ring's tail, in - # the orientation the splice chose. + # The splice ends: the surviving corner vertices (the ends + # of the removed span) and the chain ends they meet, in + # the orientation the splice chose. The chain is the ring's + # one run of assembly rows; on a curved wall the splice + # keeps the wall's own vertices after it, so the run is + # located, not assumed to be the tail. + chain_pos = [i for i, v in enumerate(ring) if v >= len(X)] outcrop = (span_labels, span_segments, int(removed_wall[0][0]), int(removed_wall[-1][1]), - int(ring[-len(chain_k)]) - len(X), - int(ring[-1]) - len(X)) + int(ring[chain_pos[0]]) - len(X), + int(ring[chain_pos[-1]]) - len(X)) kept_all = np.zeros(len(asm_tris), dtype=bool) ring_band = [] diff --git a/src/underworld3/utilities/reconnect.py b/src/underworld3/utilities/reconnect.py index 16169382e..4ca089f58 100644 --- a/src/underworld3/utilities/reconnect.py +++ b/src/underworld3/utilities/reconnect.py @@ -228,6 +228,17 @@ def _orient2d(pa, pb, pc): Returns ``_UNCERTAIN`` when the filter cannot resolve the sign. """ + if len(pa) == 3: + # A triangle of a surface mesh in three dimensions, anticlockwise as + # seen from outside: the outward direction is the centroid's, the + # surface being a sphere about the origin. + n = np.cross(np.asarray(pa) - np.asarray(pc), np.asarray(pb) - np.asarray(pc)) + c = np.asarray(pa) + np.asarray(pb) + np.asarray(pc) + det = float(n @ c) + bound = float(np.linalg.norm(n) * np.linalg.norm(c)) + if det == 0.0 or abs(det) < _ORIENT_BOUND * bound: + return _UNCERTAIN + return 1 if det > 0.0 else -1 acx, acy = pa[0] - pc[0], pa[1] - pc[1] bcx, bcy = pb[0] - pc[0], pb[1] - pc[1] left, right = acx * bcy, acy * bcx diff --git a/src/underworld3/utilities/rotated_bc.py b/src/underworld3/utilities/rotated_bc.py index d3beda7e4..0a8213c2b 100644 --- a/src/underworld3/utilities/rotated_bc.py +++ b/src/underworld3/utilities/rotated_bc.py @@ -83,6 +83,12 @@ def _warn_if_ksp_diverged(ksp, kind): # PETSc section field ids of the Stokes saddle unknowns (fixed by the solver's # DM field registration order: velocity first, pressure second). _VELOCITY_FIELD = 0 + +#: Boundary spec name for the tangency constraint of a sheet on a manifold: +#: ``(SHEET_TANGENCY, normal)`` in ``solver._rotated_freeslip_bcs`` constrains +#: ``u . n = 0`` at EVERY velocity node, with the analytic surface normal, so a +#: velocity carried in the embedding space stays in the tangent plane. +SHEET_TANGENCY = "__sheet_tangency__" _PRESSURE_FIELD = 1 @@ -104,6 +110,62 @@ def _point_coord(dm, dim, cvec, csec, v0, v1, q): return np.mean([cvec[csec.getOffset(v) // dim] for v in verts], axis=0) +def _normal_source(solver, normal, boundary): + """``(sym_fn, const_normal)`` for an analytic / constant normal spec: a + lambdified numpy callable of ``mesh.X`` for a sympy normal (unwrapped first, + so a curvilinear ``unit_e_0`` reduces to mesh coordinates), or the constant + vector; ``(None, None)`` for the geometric facet normal.""" + cdim = solver.mesh.cdim + if normal is None: + return None, None + if isinstance(normal, sympy.Matrix): + from underworld3.function.expressions import unwrap + comps = [sympy.sympify(unwrap(normal[0, k], keep_constants=False, + return_self=False)) + for k in range(cdim)] + stray = set().union(*[c.free_symbols for c in comps]) \ + - set(solver.mesh.X) + if stray: + raise ValueError( + f"analytic normal for boundary '{boundary}' contains " + f"symbols {sorted(map(str, stray))} that are not mesh " + "coordinates — express it in mesh.X (e.g. radial X/|X|).") + return sympy.lambdify(list(solver.mesh.X), comps, "numpy"), None + return None, np.asarray(normal, dtype=float).ravel() + + +def _manifold_velocity_nodes(solver, normal): + """Every DMPlex point carrying velocity DOFs, each with the analytic unit + normal of the surface at its coordinate — the sheet-tangency constraint of + a velocity carried in the embedding space of a manifold mesh. The normal is + a function of the node coordinate, so every rank computes the same value + and nothing is reduced.""" + if normal is None: + raise ValueError( + "the sheet-tangency constraint needs the analytic surface normal " + "(e.g. mesh.CoordinateSystem.unit_e_0 on a sphere); a manifold has " + "no facet normal to fall back on.") + dm = solver.dm + cdim = solver.mesh.cdim + csec = dm.getCoordinateSection() + cvec = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, cdim) + v0, v1 = dm.getDepthStratum(0) + lsec = dm.getLocalSection() + sym_fn, const_normal = _normal_source(solver, normal, SHEET_TANGENCY) + pStart, pEnd = lsec.getChart() + nodes = [] + for q in range(pStart, pEnd): + if lsec.getFieldDof(q, _VELOCITY_FIELD) <= 0: + continue + if sym_fn is not None: + ne = np.asarray(sym_fn(*_point_coord(dm, cdim, cvec, csec, v0, v1, q)), + dtype=float).ravel() + else: + ne = const_normal.copy() + nodes.append((q, ne / (np.linalg.norm(ne) + 1e-30))) + return nodes + + def _boundary_velocity_nodes(solver, boundary, normal=None): """DMPlex points carrying velocity DOFs on `boundary`, each with an outward unit normal. Dimension-general (2D edges, 3D faces). @@ -176,7 +238,7 @@ def _boundary_velocity_nodes(solver, boundary, normal=None): only rescale it (and by a rank-count-dependent factor). """ dm = solver.dm - dim = solver.mesh.dim + dim = solver.mesh.cdim csec = dm.getCoordinateSection() cvec = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, dim) v0, v1 = dm.getDepthStratum(0) @@ -203,24 +265,7 @@ def coord(q): # whose UWexpressions hide the Cartesian coordinates behind ``r`` etc.) reduces # to pure mesh.X before lambdify — otherwise the generated function references # the curvilinear symbol by bare name and dies with a NameError at call time. - sym_fn = None - const_normal = None - if normal is not None: - if isinstance(normal, sympy.Matrix): - from underworld3.function.expressions import unwrap - comps = [sympy.sympify(unwrap(normal[0, k], keep_constants=False, - return_self=False)) - for k in range(dim)] - stray = set().union(*[c.free_symbols for c in comps]) \ - - set(solver.mesh.X) - if stray: - raise ValueError( - f"analytic normal for boundary '{boundary}' contains " - f"symbols {sorted(map(str, stray))} that are not mesh " - "coordinates — express it in mesh.X (e.g. radial X/|X|).") - sym_fn = sympy.lambdify(list(solver.mesh.X), comps, "numpy") - else: - const_normal = np.asarray(normal, dtype=float).ravel() + sym_fn, const_normal = _normal_source(solver, normal, boundary) nacc = {} # velocity node → Σ_f measure · n̂_f for f in facets: @@ -285,7 +330,7 @@ def _sum_facet_normals_across_ranks(solver, contribs): COLLECTIVE — a rank owning no facet of this boundary still takes part. """ dm = solver.dm - dim = solver.mesh.dim + dim = solver.mesh.cdim if dm.comm.getSize() == 1: return contribs @@ -381,24 +426,50 @@ def build_rotation(solver, boundaries, datum_specs=None): dm = solver.dm lsec = dm.getLocalSection() l2g = dm.getLGMap() - dim = solver.mesh.dim + dim = solver.mesh.cdim # gather all normals per velocity node across the boundaries (and, per node, the # datum of the boundary it came from — the surface's ũ_n for a prescribed-normal # solve; None for free-slip). node_normals = {} node_dspec = {} + node_specs = {} for spec in boundaries: name, normal = _boundary_spec(spec) dspec = None if datum_specs is None else datum_specs.get(name) - for q, nrm in _boundary_velocity_nodes(solver, name, normal=normal): + nodes = (_manifold_velocity_nodes(solver, normal) if name == SHEET_TANGENCY + else _boundary_velocity_nodes(solver, name, normal=normal)) + for q, nrm in nodes: node_normals.setdefault(q, []).append(nrm) + node_specs.setdefault(q, set()).add(name) if dspec is not None: node_dspec.setdefault(q, dspec) # Split-fault contact pairs (fault_contact.add_frictionless_fault_bc): # their 2·dim mean/jump blocks widen the widest row of Q. fault_names = getattr(solver, "_fault_contact_faults", []) + # On a surface mesh every node carries the sheet-tangency constraint; a + # paired fault node takes it through its PAIR block instead (the mean and + # jump rows along the surface normal), so it leaves the wall-block set. + manifold = solver.mesh.dim < solver.mesh.cdim + pair_nodes_by_fault = {} + if fault_names and manifold: + from underworld3.utilities import fault_contact + # the pairing fails rank-locally (a label mismatch, a vanishing analytic + # normal); the verdict is exchanged before any rank goes on to the + # collective Q assembly, as in the pair-block assembly below + problem = None + try: + for fname in fault_names: + pair_nodes_by_fault[fname] = fault_contact._fault_pair_nodes(solver, fname) + except (RuntimeError, ValueError) as err: + problem = str(err) + fault_contact._collective_raise(problem) + for fname in fault_names: + for q_plus, q_minus, _nrm in pair_nodes_by_fault[fname]: + for q in (q_plus, q_minus): + if q in node_normals and node_specs[q] <= {SHEET_TANGENCY}: + del node_normals[q] # Distributed Q with the assembled operator's ROW layout. Q is identity except a # per-node dim×dim orthonormal block; because a node's dim velocity components @@ -408,7 +479,14 @@ def build_rotation(solver, boundaries, datum_specs=None): # rows fall outside [rstart, rend)). A fault PAIR block spans two points, but # both are rank-local (a seam-touching fault is redistributed onto one rank # before the split), so the diagonal-portion argument carries over unchanged. + # The row layout comes from the solver's Jacobian. _build() leaves the SNES + # set up only on some paths (with essential conditions registered it is; + # without them the matrix is never created), so set it up here rather than + # read a null matrix — that was a segmentation fault (test_1085). A = solver.snes.getJacobian()[0] + if A is None or A.handle == 0: + solver.snes.setUp() + A = solver.snes.getJacobian()[0] rstart, rend = A.getOwnershipRange() nloc = rend - rstart N = A.getSize()[0] @@ -510,8 +588,9 @@ def build_rotation(solver, boundaries, datum_specs=None): problem = None try: for fname in fault_names: - for q_plus, q_minus, nrm in fault_contact._fault_pair_nodes( - solver, fname): + pairs = (pair_nodes_by_fault[fname] if fname in pair_nodes_by_fault + else fault_contact._fault_pair_nodes(solver, fname)) + for q_plus, q_minus, nrm in pairs: if q_plus in node_normals or q_minus in node_normals: raise ValueError( f"fault {fname!r} shares a node with a rotated " @@ -523,7 +602,7 @@ def build_rotation(solver, boundaries, datum_specs=None): # specific in-plane directions carry no physics: both # slip rows are free (frictionless) or enter through # |V| (laws). - frame = [nrm] + fault_contact._tangent_frame(nrm) + frame = fault_contact._pair_frame(solver, q_plus, nrm) gp, gm = [], [] for q, rows in ((q_plus, gp), (q_minus, gm)): lo = lsec.getFieldOffset(q, _VELOCITY_FIELD) @@ -552,6 +631,12 @@ def build_rotation(solver, boundaries, datum_specs=None): Q.setValue(row, gm[j], sgn * s2 * float(e[j])) normal_rows.append(gm[0]) # [v]·n̂ = 0, datum 0 + if manifold: + # both sides tangent to the surface: the jump and the + # mean along the surface normal (the frame's last + # vector) are pinned; the slip row stays free + normal_rows.append(gm[2]) + normal_rows.append(gp[2]) except (RuntimeError, ValueError) as err: problem = str(err) fault_contact._collective_raise(problem) @@ -1100,7 +1185,7 @@ def solve_rotated_freeslip(solver, boundaries, remove_rotation_gauge=True, # defined as unreachable. Measured before this was hoisted: the velocity # residual reached 6e-12 at the first increment and the remaining |F̂| was # the pinned DOF alone, bit-identical for eight further no-op iterations. - lu_pin = _naive_pressure_pin(dm) if use_lu else None + lu_pin = _naive_pressure_pin(dm) if (use_lu and "pressure" in solver._subdict) else None interface_laws = bool(getattr(solver, "_fault_interface_laws", {})) # ---- cross-solve workspace cache (issue #417; see the block comment @@ -1235,7 +1320,11 @@ def solve_rotated_freeslip(solver, boundaries, remove_rotation_gauge=True, _impose_normal_constraint(u, Q, Qt, normal_rows, datum_map) J, Jp = snes.getJacobian()[:2] - pres_is = solver._subdict["pressure"][0] + # A vector solver (the thin sheet) has the velocity alone: no pressure + # block, no Schur complement, no pressure gauge. Every pressure site below + # is gated on this. + has_pressure = "pressure" in solver._subdict + pres_is = solver._subdict["pressure"][0] if has_pressure else None Fc = dm.createGlobalVec() reaction = dm.createGlobalVec() # the final (un-zeroed) Cartesian residual @@ -1267,7 +1356,7 @@ def solve_rotated_freeslip(solver, boundaries, remove_rotation_gauge=True, # stalls against the constant offset). This mirrors PETSc's own projected # residual for singular systems. The Cartesian reaction stash is UNPROJECTED # (σ_nn reads velocity rows only). - use_pnull = bool(getattr(solver, "_petsc_use_pressure_nullspace", False)) + use_pnull = has_pressure and bool(getattr(solver, "_petsc_use_pressure_nullspace", False)) # L2 norm of the LAST projected-out gauge component (|mean|·√N over the # pressure rows) — kept observable so an INCOMPATIBLE datum cannot hide # behind the projection (see the guard after the Newton loop). @@ -1403,7 +1492,7 @@ def rotated_residual(uvec, keep_cartesian=False): Aop = Ahat if not use_lu: if ctx is None: - Mp = _pressure_mass_schur_pmat(solver) + Mp = _pressure_mass_schur_pmat(solver) if has_pressure else None nsp = _rotated_nullspace(solver, Q, normal_rows) # J assembled above elif assemble and Mp is not None: Jp.createSubMatrix(pres_is, pres_is, submat=Mp) # viscosity may be u-dependent @@ -1446,11 +1535,15 @@ def rotated_residual(uvec, keep_cartesian=False): # parity with the iterative path's exact v_n cleanup (LU is exact only # to factorisation round-off at the decoupled constraint rows) _zero_rows_local(dhat, normal_rows) - else: + elif has_pressure: dhat, last_reason, ctx = _solve_rotated_iterative( solver, Aop, bhat, Q, Qt, normal_rows, custom_Pl=custom_Pl, nsp=nsp, Mp=Mp, verbose=False, ctx=ctx, refresh_operator=assemble) + else: + dhat, last_reason, ctx = _solve_rotated_vector( + solver, Aop, bhat, normal_rows, + custom_Pl=custom_Pl, nsp=nsp, ctx=ctx, refresh_operator=assemble) lin_its.append(ctx["ksp"].getIterationNumber()) vel_its_last.append(ctx.get("vel_its_last")) pres_its_last.append(ctx.get("pres_its_last")) @@ -1808,6 +1901,23 @@ def _build_rotated_custom_Pl(solver, Q, normal_rows): h, Ps = resolved if resolved is not None else (None, None) if h is None or Ps is None: return None + walls = bool(getattr(solver, "_rotated_freeslip_bcs", None)) + if getattr(h, "has_split_levels", False) or \ + (walls and getattr(h, "drop_orphans", False)): + # Per-level rotation: a split coarse level takes its own pair + # rotation and loses its opening rows; under rotated free-slip walls + # every coarse level takes the wall rotation and loses its normal + # rows (a Cartesian coarse level under a rotated finest keeps wall + # unknowns the fine operator barely reaches, and the smoother's + # nodal block inversion meets a zero pivot); the unknowns no fine + # unknown reaches are then dropped (utilities.fault_mg). + from underworld3.utilities.fault_mg import rotated_split_transfers + Pl = rotated_split_transfers(solver, h, Ps, Q, normal_rows) + solver._custom_mg_rotated_levels = True + if getattr(solver, "_custom_mg", None) is None: + solver._custom_mg = {"mode": "hierarchy", "hierarchy": h, + "verbose": False} + return Pl vel_is = solver._subdict["velocity"][0] vis = np.asarray(vel_is.getIndices()) g2blk = {int(g): k for k, g in enumerate(vis)} @@ -1828,11 +1938,24 @@ def _build_rotated_custom_Pl(solver, Q, normal_rows): nrows_blk = sorted({rstart + g2blk[g] for g in normal_rows if g in g2blk}) Pfine.zeroRows(nrows_blk, diag=0.0) + Pl = list(Ps[:-1]) + [Pfine] + if getattr(h, "drop_orphans", False): + # Unreached coarse unknowns are dropped, never injected, AFTER the + # finest's constrained rows are zeroed: a coarse wall node's normal + # component is reached only through those rows (or, on an axis, + # through tangential rows weighted by a vanishing sine), and kept it + # is a zero Galerkin diagonal. The finest's layout (and its FAC + # patch rows) is untouched. + from underworld3.utilities.fault_mg import restrict_unreached + Pl = restrict_unreached(Pl, solver.dm.comm) + rows = getattr(h, "level_patch_rows", None) + if rows is not None: + h.level_patch_rows = [None] * (len(rows) - 1) + [rows[-1]] # Remember an opportunistic mesh-owned pickup, so a later solve on this solver # (rotated or not) reuses the hierarchy instead of re-resolving it. if getattr(solver, "_custom_mg", None) is None: solver._custom_mg = {"mode": "hierarchy", "hierarchy": h, "verbose": False} - return list(Ps[:-1]) + [Pfine] + return Pl def _pressure_mass_schur_pmat(solver): @@ -2076,6 +2199,19 @@ def _solve_rotated_iterative(solver, Ahat, bhat, Q, Qt, normal_rows, verbose=Fal smoother=solver._mg_smoother_variant, patch_rows=getattr(_hier, "level_patch_rows", None)) vel_pc.setUp() + if getattr(solver, "_custom_mg_rotated_levels", False): + # Rotated coarse levels: a wall node keeps one tangential + # row beside the two-row nodes, and the inode SOR's 2x2 + # block inversion met a zero pivot on the Galerkin + # operators of a developed temperature field (four + # ranks on the Zhong & Gurnis annulus, 2026-10-14) with + # every scalar diagonal healthy; without the inode + # grouping the same solve converged in 9 iterations to + # the two-rank answer. The finest operator is untouched. + for _l in range(vel_pc.getMGLevels() - 1): + _A = vel_pc.getMGSmoother(_l).getOperators()[0] + _A.setOption(PETSc.Mat.Option.USE_INODES, False) + _A.assemble() # Force the FAC levels' smoother setup NOW: PCASM creates its # sub-KSP (which reads mg_levels__sub_* from the DB) lazily # at first application, which on this path is AFTER the key @@ -2191,6 +2327,98 @@ def _solve_rotated_iterative(solver, Ahat, bhat, Q, Qt, normal_rows, verbose=Fal return Uhat, ksp.getConvergedReason(), ctx +def _solve_rotated_vector(solver, Ahat, bhat, normal_rows, custom_Pl=None, nsp=None, + ctx=None, refresh_operator=True): + """Solve the rotated operator of a single-field (vector) solver — the thin + sheet — with one KSP on the whole matrix: geometric FMG on the custom + prolongation when the solver has a hierarchy, else the shared GAMG bundle + with the solver's block size. The saddle-point counterpart is + ``_solve_rotated_iterative``; this one has no Schur complement and no + pressure gauge. Same ``(Uhat, reason, ctx)`` contract and the same context + reuse across Newton iterations.""" + from underworld3.utilities import custom_mg, multigrid_options + dm = solver.dm + if ctx is None: + if nsp is not None: + Ahat.setNullSpace(nsp) + Ahat.setTransposeNullSpace(nsp) + global _ROT_SOLVE_COUNT + _ROT_SOLVE_COUNT += 1 + pfx = f"rotvec{_ROT_SOLVE_COUNT}_" + opts = PETSc.Options() + tol = float(solver.tolerance) + cfg = {"ksp_type": "fgmres", "ksp_rtol": str(tol), "ksp_max_it": "500"} + if custom_Pl is None: + cfg.update(multigrid_options.gamg_bundle( + block_size=getattr(solver, "_pc_block_size", 1)).settings) + for k, v in cfg.items(): + opts[pfx + k] = v + try: + ksp = PETSc.KSP().create(comm=dm.comm) + ksp.setOptionsPrefix(pfx) + ksp.setOperators(Ahat) + pc = ksp.getPC() + ksp.setFromOptions() + if custom_Pl is not None: + # coarse="svd" only when verified rotation modes make the + # Galerkin-coarsened operator singular (see the saddle route). + _coarse = ("svd" if getattr(solver, "_rotated_velocity_null_modes", 0) + else "redundant") + _hier = (getattr(solver, "_custom_mg", None) or {}).get("hierarchy") + _fac_keys = custom_mg._configure_pcmg( + pc, custom_Pl, coarse=_coarse, + smoother=solver._mg_smoother_variant, + patch_rows=getattr(_hier, "level_patch_rows", None)) + pc.setUp() + for _l in range(1, pc.getMGLevels()): + if _fac_keys and any(k.startswith(f"mg_levels_{_l}_") + for k in _fac_keys): + pc.getMGSmoother(_l).setUp() + vpfx = pc.getOptionsPrefix() or "" + for key in multigrid_options.geometric_mg_bundle( + coarse=_coarse, + smoother=solver._mg_smoother_variant).settings: + opts.delValue(vpfx + key) + for key in _fac_keys or (): + opts.delValue(vpfx + key) + else: + pc.setUp() + finally: + for k in cfg: + # an option PETSc already consumed and dropped is not an error + try: + opts.delValue(pfx + k) + except Exception: + pass + ctx = {"ksp": ksp, "pc": pc, "Mp": None, "nsp": nsp, "cns": None, + "custom_Pl": custom_Pl, "pfx": pfx, + "velocity_pc": "custom-FMG" if custom_Pl is not None else "GAMG", + "schur_pre": "none"} + if custom_Pl is None: + solver._record_pc_fallback( + "rotated.velocity_pc", + requested="custom-P geometric MG on the rotated operator", + installed="GAMG", + reason="unavailable", + detail="no multigrid hierarchy (set_custom_fmg or a mesh-owned " + "adapt tail) is available to the rotated path") + else: + ksp = ctx["ksp"] + nsp = ctx["nsp"] + if refresh_operator: + ksp.setOperators(Ahat) + if nsp is not None: + nsp.remove(bhat) + Uhat = Ahat.createVecRight() + Uhat.set(0.0) + ksp.solve(bhat, Uhat) + _warn_if_ksp_diverged(ksp, kind="rotated vector") + # exact at the decoupled constraint rows, independent of the iterative tolerance + _zero_rows_local(Uhat, normal_rows) + ctx["velocity_pc_type"] = ksp.getPC().getType() + return Uhat, ksp.getConvergedReason(), ctx + + def _rotated_nullspace(solver, Q, normal_rows): """Coupled Stokes null space in the rotated frame: constant pressure, plus every rigid rotation Q·mode that is a genuine null space of the constraints — one @@ -2381,7 +2609,7 @@ def boundary_normal_traction(solver, boundary, solve_result, mass="auto"): triangular P1/P2 traces are supported in 3D. """ dm = solver.dm - dim = solver.mesh.dim + dim = solver.mesh.cdim # Cartesian nodal reaction r_c = F(u) at the converged state, stashed by the # solve driver (the final ``computeFunction`` residual; for a linear residual # this equals the assembled A·u−b exactly). @@ -2439,7 +2667,7 @@ def boundary_normal_traction_integral(solver, boundary, solve_result, fn, fn = sympy.sympify(fn) dm = solver.dm comm = dm.comm.tompi4py() - dim = solver.mesh.dim + dim = solver.mesh.cdim rc = solve_result["reaction"] rstart, rend = rc.getOwnershipRange() rcl = dm.getLocalVec() @@ -2512,7 +2740,7 @@ def dynamic_topography_field(solver, boundary, solve_result, field, safe: the recovery is partition-independent and the write is local (each rank fills its own boundary nodes, matched by coordinate to the recovery output). """ - dim = solver.mesh.dim + dim = solver.mesh.cdim xs, sig = boundary_normal_traction(solver, boundary, solve_result, mass=mass) # σ_nn is already mean-removed (the ρg·h gauge); topography h = -σ_nn / (Δρ g). def key(c): @@ -2526,7 +2754,8 @@ def _rigid_rotation_modes(solver): """The Cartesian rigid-body rotation mode(s) as composite GLOBAL vectors (velocity DOFs only, zero pressure): ``[(-y,x)]`` in 2D, ``[e_k×r]`` for k=x,y,z in 3D — a spherical shell with free-slip on both boundaries has all - THREE. Parallel-safe: built via localToGlobal on the velocity sub-DM, so + THREE, and so does a sheet on the surface of a sphere (embedding dimension + three), whose tangency constraint every Killing field satisfies. Parallel-safe: built via localToGlobal on the velocity sub-DM, so shared nodes are handled by PETSc, not double-counted. The vectors are borrowed from the DM pool — every one must go back via ``dm.restoreGlobalVec``. COLLECTIVE: all ranks build the same mode list.""" @@ -2534,7 +2763,7 @@ def _rigid_rotation_modes(solver): v = solver.Unknowns.u c = v.coords saved = v.data.copy() - if solver.mesh.dim == 2: + if solver.mesh.cdim == 2: fields = [np.column_stack([-c[:, 1], c[:, 0]])] else: x, y, z = c[:, 0], c[:, 1], c[:, 2] diff --git a/tests/parallel/test_1086_rotated_fault_transfer_rows.py b/tests/parallel/test_1086_rotated_fault_transfer_rows.py new file mode 100644 index 000000000..6ee912818 --- /dev/null +++ b/tests/parallel/test_1086_rotated_fault_transfer_rows.py @@ -0,0 +1,65 @@ +"""The rotated solve's fine transfer zeroes the opening rows of a split fault by +GLOBAL row of the velocity block. + +``_build_rotated_custom_Pl`` forms Q_v P and zeroes the rows that carry the +constrained (opening) component. It took the row positions from +``vel_is.getIndices()`` — this rank's entries only — and passed them to +``zeroRows`` as global rows. Serial, where the two coincide, was right; at np=2 +the wrong rows were zeroed, 232 coarse unknowns lost every fine image, and the +Galerkin product failed on empty rows (found 2026-10-13). Contract, at any rank +count: every opening row is zero. Since the add-on levels are split by default +the split tail builds its transfers in ``fault_mg``, so the test runs the welded +tail (``split_levels=False``, the code it was written for) and the split one. +Both drop the coarse columns no fine unknown reaches, so "no empty column" is +true by construction and is not asserted. + + mpirun -n 2 python -m pytest --with-mpi tests/parallel/test_1086_rotated_fault_transfer_rows.py +""" + +import numpy as np +import pytest + +import underworld3 as uw + +pytestmark = [pytest.mark.mpi(min_size=2), pytest.mark.level_2, pytest.mark.tier_b, + pytest.mark.timeout(900)] + + +@pytest.mark.parametrize("split_levels", [False, True]) +def test_rotated_fine_transfer_zeroes_opening_rows_by_global_row(split_levels): + from underworld3.utilities import rotated_bc + + tip_a, tip_b = np.array([0.30, 0.45]), np.array([0.70, 0.55]) + base = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.1, refinement=1, qdegree=2) + pts = np.column_stack([np.linspace(tip_a[0], tip_b[0], 9), + np.linspace(tip_a[1], tip_b[1], 9), np.zeros(9)]) + guide = uw.meshing.Surface("guide", base, pts) + guide.discretize() + child = base.adapt(guide.refinement_metric_function( + h_near=0.0125, h_far=0.05, width=0.08, profile="linear"), max_levels=2) + mesh = child.add_fault([("Flt", np.vstack([tip_a, tip_b]))], split_levels=split_levels) + + x, y = mesh.X + stokes = uw.systems.Stokes(mesh) + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = 1 + for side in ("Top", "Bottom", "Left", "Right"): + stokes.add_dirichlet_bc((y - 0.5, 0.0), side) + stokes.add_fault_bc(0, "Flt") + stokes._build() + Q, Qt, normal_rows, _ = rotated_bc.build_rotation(stokes, stokes._rotated_freeslip_bcs) + Pl = rotated_bc._build_rotated_custom_Pl(stokes, Q, normal_rows) + assert Pl is not None, "no multigrid hierarchy on the add-on mesh" + P = Pl[-1] + + # the opening rows, as global rows of the velocity block + vel_is = stokes._subdict["velocity"][0] + rstart = P.getOwnershipRange()[0] + position = {int(g): rstart + k for k, g in enumerate(vel_is.getIndices())} + opening = [position[g] for g in normal_rows if g in position] + ai, aj, av = P.getValuesCSR() + nonzero_in_opening = sum( + int(np.any(av[ai[r - rstart]:ai[r - rstart + 1]] != 0.0)) for r in opening) + assert uw.mpi.comm.allreduce(len(opening)) > 0 + assert uw.mpi.comm.allreduce(nonzero_in_opening) == 0 diff --git a/tests/parallel/test_1089_fault_split_multigrid_parallel.py b/tests/parallel/test_1089_fault_split_multigrid_parallel.py new file mode 100644 index 000000000..6bc6cb665 --- /dev/null +++ b/tests/parallel/test_1089_fault_split_multigrid_parallel.py @@ -0,0 +1,146 @@ +"""Split-level fault multigrid at any rank count. + +The welded tail under a split finest did not run at np > 1 (found 2026-10-13): +the split gathers the fault's cell star onto one rank, the node-cloud transfer +left hundreds of coarse unknowns with no fine image, their injection repair +duplicated columns, the Galerkin coarse operators went singular and the inode +SOR returned NaN. The cell-located transfer locates every owned fine node in +the local coarse mesh and asks the other ranks for the rest, and unreached +coarse unknowns are dropped, never repaired. Contract: the split-level +hierarchy (and the welded one) converges at np = 2 and 4 in as few velocity +iterations as in serial (6 at k = 2; serial 6, np = 4 measured 7), with the +serial slip. The thick twin of the faulted sheet is extruded in parallel with +its pairing complete and rank-local. + + mpirun -n 2 python -m pytest --with-mpi tests/parallel/test_1089_fault_split_multigrid_parallel.py + mpirun -n 4 python -m pytest --with-mpi tests/parallel/test_1089_fault_split_multigrid_parallel.py +""" + +import numpy as np +import pytest +import sympy + +import underworld3 as uw + +pytestmark = [pytest.mark.mpi(min_size=2), pytest.mark.level_2, pytest.mark.tier_b, + pytest.mark.timeout(1800)] + +TIP_A, TIP_B = np.array([0.30, 0.45]), np.array([0.70, 0.55]) +SERIAL_PEAK_SLIP = 0.159581 # k = 2, frictionless, solve tolerance 1e-6 + + +def _faulted_box(k, split_levels=True): + base = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.1, refinement=1, + qdegree=2) + pts = np.column_stack([np.linspace(TIP_A[0], TIP_B[0], 9), + np.linspace(TIP_A[1], TIP_B[1], 9), np.zeros(9)]) + guide = uw.meshing.Surface("guide", base, pts) + guide.discretize() + child = base.adapt(guide.refinement_metric_function( + h_near=0.05 / 2**k, h_far=0.05, width=0.08, profile="linear"), max_levels=k) + return child.add_fault([("Flt", np.vstack([TIP_A, TIP_B]))], + split_levels=split_levels) + + +def _solve(mesh): + x, y = mesh.X + stokes = uw.systems.Stokes(mesh) + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = 1 + stokes.tolerance = 1.0e-6 + stokes.petsc_use_pressure_nullspace = True + for side in ("Top", "Bottom", "Left", "Right"): + stokes.add_dirichlet_bc((y - 0.5, 0.0), side) + stokes.add_fault_bc(0, "Flt") + stokes.solve() + info = stokes._rotated_freeslip_info + from underworld3.utilities.fault_contact import fault_slip + _s, V, _leak = fault_slip(stokes, "Flt", info) + local = float(np.abs(V).max()) if len(V) else 0.0 + return info, float(uw.mpi.comm.allreduce(local, op=max)) + + +def test_split_levels_converge_like_serial(): + info, slip = _solve(_faulted_box(2)) + assert info["velocity_pc"] == "custom-FMG" and info["converged"], info + assert info["vel_its_last"][-1] <= 9, info["vel_its_last"] + assert abs(slip - SERIAL_PEAK_SLIP) < 1.0e-4 * SERIAL_PEAK_SLIP, slip + + +def test_the_welded_tail_runs_in_parallel_too(): + info, slip = _solve(_faulted_box(2, split_levels=False)) + assert info["velocity_pc"] == "custom-FMG" and info["converged"], info + assert abs(slip - SERIAL_PEAK_SLIP) < 1.0e-4 * SERIAL_PEAK_SLIP, slip + + +def test_the_thick_twin_is_extruded_with_its_pairing(): + """The faulted plate of test_1083, extruded at np > 1: every pair of the + serial layer is present on exactly one rank with its two points coincident, + the layer's levels are split, and the plate solves with geometric + multigrid.""" + base = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.1, refinement=1, + qdegree=3) + sheet = base.add_fault([("Flt", np.array([[0.25, 0.5], [0.75, 0.5]]))]) + layer = sheet.extrude(0.04, layers=2) + pairs = layer._fault_point_pairs["Flt"] + n_pairs = uw.mpi.comm.allreduce(len(pairs)) + assert n_pairs == 261, n_pairs # the serial layer's count + dm = layer.dm + cs, cv = dm.getCoordinateSection(), dm.getCoordinatesLocal().array + vS, vE = dm.getDepthStratum(0) + + def centroid(q): + verts = [int(v) for v in dm.getTransitiveClosure(int(q))[0] if vS <= v < vE] + return np.array([cv[cs.getOffset(v):cs.getOffset(v) + 3] for v in verts]).mean(axis=0) + + gap = max([np.linalg.norm(centroid(a) - centroid(b)) for a, b in pairs.items()] or [0.0]) + assert uw.mpi.comm.allreduce(gap, op=max) < 1.0e-12 + tail = layer._custom_mg_coarse_meshes + assert tail and any(getattr(m, "_fault_point_pairs", None) for m in tail) + + stokes = uw.systems.Stokes(layer) + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = 1 + y = layer.X[1] + stokes.add_basal_drag(1, "Base", reference_velocity=[y - sympy.Rational(1, 2), 0, 0]) + stokes.add_fault_bc(0, "Flt") + stokes.tolerance = 1.0e-5 + stokes.solve() + info = stokes._rotated_freeslip_info + assert info["velocity_pc"] == "custom-FMG" and info["converged"], info + from underworld3.utilities.fault_contact import fault_pair_jumps + _c, jumps, normals = fault_pair_jumps(stokes, "Flt", info, gather=True) + tangential = jumps - np.einsum("ij,ij->i", jumps, normals)[:, None] * normals + assert np.linalg.norm(tangential, axis=1).max() > 1.0e-2 + assert np.abs(np.einsum("ij,ij->i", jumps, normals)).max() < 1.0e-8 + + +def test_a_failure_on_one_rank_stops_the_replay_on_every_rank(monkeypatch): + """A failure of the split on one rank after its last collective (a final + consistency check, rank-local) stopped the replay of the fault on that rank + alone: the ranks kept different split levels and the next collective on the + hierarchy met mismatched partners. The verdict is exchanged per level, so + every rank keeps the same split levels and the levels from the failure down + stay welded. (A rank-local raise BEFORE the split's own collectives still + hangs inside the split; issue #874.)""" + import underworld3.utilities.fault_split as fault_split + original, calls = fault_split.add_fault, [0] + + def fails_on_one_rank_after_the_second_add_on_split(mesh, *args, **kwargs): + calls[0] += 1 # 1: the finest; 2, 3: the add-on levels + split = original(mesh, *args, **kwargs) + if calls[0] == 3 and uw.mpi.rank == 1: + raise RuntimeError("an internal failure on one rank") + return split + + monkeypatch.setattr(fault_split, "add_fault", + fails_on_one_rank_after_the_second_add_on_split) + with pytest.warns(UserWarning, match="could not be carried"): + mesh = _faulted_box(3) + split = [bool(getattr(m, "_fault_point_pairs", None)) + for m in mesh._custom_mg_coarse_meshes] + everywhere = uw.mpi.comm.allgather(split) + assert all(s == everywhere[0] for s in everywhere), everywhere + assert sum(split) == 1, split diff --git a/tests/test_0018_adjoint_support_record.py b/tests/test_0018_adjoint_support_record.py index dd3bd1e91..5838456c4 100644 --- a/tests/test_0018_adjoint_support_record.py +++ b/tests/test_0018_adjoint_support_record.py @@ -117,6 +117,31 @@ def test_a_rotated_constraint_is_supported_on_the_rotated_operator(mesh): assert "null space" in verdict["reason"] +@pytest.mark.tier_b +def test_a_rotated_constraint_without_a_rotated_adjoint_is_refused(): + """A manifold sheet carries its tangency as a rotated constraint, and its + adjoint_solve (the vector solvers') transposes the unrotated operator: the + answer would ignore the constraint. Only the Stokes saddle point routes a + rotated adjoint, so this solve is refused, naming the rotated constraint.""" + uw, model = _fresh_model() + sphere = uw.meshing.SphericalManifold(radius=1.0, cellSize=0.4, degree=1, qdegree=3) + x, y, z = sphere.X + sheet = uw.systems.ThinSheet(sphere) + sheet.constitutive_model = uw.constitutive_models.ViscousFlowModel + sheet.constitutive_model.Parameters.shear_viscosity_0 = 1 + sheet.add_basal_drag(1, reference_velocity=[2 * y * z, -2 * x * z, 0]) + model.tracker.time, model.tracker.step = 0.0, 0 + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + with model.step(0.1): + sheet.solve() + verdict = _adjoint_of(model, "solve") + assert verdict["supported"] is False + assert "rotated constraint" in verdict["reason"] + with pytest.raises(RuntimeError, match="refuses an adjoint"): + sheet.adjoint_solve(sheet.dm.getGlobalVec()) + + def test_an_unconverged_solve_is_refused_after_the_fact(mesh): """The structural verdict is written before the solve. A solve that then diverged is linearised about a state it never reached — the outcome must diff --git a/tests/test_1075_thin_sheet.py b/tests/test_1075_thin_sheet.py new file mode 100644 index 000000000..5db5e2d93 --- /dev/null +++ b/tests/test_1075_thin_sheet.py @@ -0,0 +1,219 @@ +"""Thin viscous sheet (plane stress) on a flat mesh: closed-form checks. + +The sheet closure is :math:`\\sigma = \\tau + \\mathrm{tr}(\\tau)\\,\\mathbf{I}` +with :math:`\\tau = 2\\eta\\dot\\varepsilon`. Every test here has an exact +answer the discrete solution must reproduce to solver tolerance, except the +manufactured-solution test, whose contract is the convergence order. +""" + +import numpy as np +import pytest +import sympy + +import underworld3 as uw + +pytestmark = [pytest.mark.level_1] + + +def _box(cell_size=0.1): + return uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=cell_size, qdegree=3) + + +def _sheet(mesh, eta=1): + sheet = uw.systems.ThinSheet(mesh) + sheet.constitutive_model = uw.constitutive_models.ViscousFlowModel + sheet.constitutive_model.Parameters.shear_viscosity_0 = eta + sheet.tolerance = 1.0e-10 + return sheet + + +def _probe_points(n=7): + s = np.linspace(0.15, 0.85, n) + return np.array([[a, b] for a in s for b in s]) + + +@pytest.mark.tier_b +def test_trouton_uniaxial_stretch(): + """Uniaxial stretch with free lateral edges: the lateral contraction is + half the stretch and the axial stress is the Trouton value 3 eta edot.""" + mesh = _box() + x, y = mesh.X + edot = sympy.Rational(1, 2) + eta = 2 + sheet = _sheet(mesh, eta=eta) + # exact solution u = (edot x, -edot (y - 1/2) / 2): prescribe it on the + # loaded ends, leave the lateral edges traction-free + u_y = -edot * (y - sympy.Rational(1, 2)) / 2 + sheet.add_essential_bc([0, u_y], "Left") + sheet.add_essential_bc([edot, u_y], "Right") + sheet.solve() + + pts = _probe_points() + u = uw.function.evaluate(sheet.u.sym, pts) + exact = np.column_stack([edot * pts[:, 0], -edot * (pts[:, 1] - 0.5) / 2]) + assert np.abs(u.reshape(-1, 2) - exact).max() < 1.0e-8 + + # the stress is a derivative of the P2 field sampled at arbitrary points: + # point-evaluation noise sits ~1/h above the velocity's 1e-8 + sigma = uw.function.evaluate(sheet.stress, pts).reshape(-1, 2, 2) + trouton = 3 * eta * float(edot) + assert np.abs(sigma[:, 0, 0] - trouton).max() < 1.0e-6 * trouton + assert np.abs(sigma[:, 1, 1]).max() < 1.0e-6 * trouton + assert np.abs(sigma[:, 0, 1]).max() < 1.0e-6 * trouton + + +@pytest.mark.tier_b +def test_rigid_motion_carries_no_stress(): + """A rigid translation plus rotation prescribed on the whole boundary is + reproduced inside, and the sheet stress vanishes.""" + mesh = _box() + x, y = mesh.X + omega, cx, cy = sympy.Rational(3, 10), sympy.Rational(1, 5), sympy.Rational(-1, 7) + rigid = [cx - omega * y, cy + omega * x] + sheet = _sheet(mesh) + for boundary in ("Left", "Right", "Top", "Bottom"): + sheet.add_essential_bc(rigid, boundary) + sheet.solve() + + pts = _probe_points() + u = uw.function.evaluate(sheet.u.sym, pts).reshape(-1, 2) + exact = np.column_stack([float(cx) - float(omega) * pts[:, 1], + float(cy) + float(omega) * pts[:, 0]]) + assert np.abs(u - exact).max() < 1.0e-8 + sigma = uw.function.evaluate(sheet.stress, pts) + assert np.abs(sigma).max() < 1.0e-6 + + +@pytest.mark.tier_b +def test_basal_drag_balances_a_uniform_load(): + """With no kinematic boundary condition, a uniform body force against a + basal drag c gives the uniform velocity u = L f / c exactly (L = 1).""" + mesh = _box() + sheet = _sheet(mesh) + c = sympy.Rational(5, 2) + f = (sympy.Rational(3, 4), sympy.Rational(-1, 3)) + sheet.basal_drag = c + sheet.bodyforce = f + sheet.solve() + + pts = _probe_points() + u = uw.function.evaluate(sheet.u.sym, pts).reshape(-1, 2) + exact = np.array([float(f[0] / c), float(f[1] / c)]) + assert np.abs(u - exact).max() < 1.0e-8 + + +@pytest.mark.tier_b +def test_manufactured_solution_converges_at_third_order(): + """P2 velocity on a smooth manufactured solution: the L2 error of the + sheet momentum balance with basal drag converges at order 3 in h.""" + errors = {} + for h in (0.2, 0.1): + mesh = _box(cell_size=h) + x, y = mesh.X + eta = sympy.Rational(3, 2) + c = sympy.Integer(2) + u_e = sympy.Matrix([[sympy.sin(sympy.pi * x) * sympy.cos(sympy.pi * y), + sympy.cos(sympy.pi * x) * sympy.sin(sympy.pi * y) / 3]]) + G = u_e.jacobian(sympy.Matrix([x, y])) + E = (G + G.T) / 2 + sigma = 2 * eta * (E + E.trace() * sympy.eye(2)) + div_sigma = sympy.Matrix([[sum(sigma[i, j].diff(v) for j, v in enumerate((x, y))) + for i in range(2)]]) + sheet = _sheet(mesh, eta=eta) + sheet.basal_drag = c + sheet.bodyforce = list(-div_sigma + c * u_e) + for boundary in ("Left", "Right", "Top", "Bottom"): + sheet.add_essential_bc(list(u_e), boundary) + sheet.solve() + err2 = uw.maths.Integral(mesh, ((sheet.u.sym - u_e) * (sheet.u.sym - u_e).T)[0, 0]).evaluate() + errors[h] = np.sqrt(err2) + + order = np.log(errors[0.2] / errors[0.1]) / np.log(2.0) + assert order > 2.6, f"convergence order {order:.2f}, errors {errors}" + + +@pytest.mark.tier_b +def test_basal_coupling_and_body_force_per_unit_volume(): + """The aligned forcing: a body force per unit VOLUME (integrated through + the thickness L, as Stokes integrates it through a thick layer), a drag + against a moving base and a basal traction. Uniform, so exactly + u = u_ref + (L f + t) / c.""" + mesh = _box() + sheet = _sheet(mesh) + L, c = sympy.Integer(2), sympy.Rational(5, 2) + f = (sympy.Rational(3, 4), sympy.Rational(-1, 3)) + t = (sympy.Rational(1, 5), sympy.Rational(1, 2)) + u_ref = (sympy.Rational(-1, 2), sympy.Rational(1, 7)) + sheet.thickness = L + sheet.bodyforce = f + sheet.add_basal_drag(c, "Bottom", reference_velocity=u_ref) + sheet.add_basal_traction(t, "Bottom") + sheet.solve() + + u = np.asarray(sheet.u.array).reshape(-1, 2) + exact = np.array([float(u_ref[i] + (L * f[i] + t[i]) / c) for i in range(2)]) + assert np.abs(u - exact).max() < 1.0e-8 + + +@pytest.mark.tier_b +def test_pressure_diagnostic_is_minus_the_trace(): + """Uniaxial stretch: the plane-stress pressure p = -tr(tau) = -eta edot, + recovered into the diagnostic field ``sheet.p`` after the solve.""" + mesh = _box() + x, y = mesh.X + edot, eta = sympy.Rational(1, 2), 2 + sheet = _sheet(mesh, eta=eta) + u_y = -edot * (y - sympy.Rational(1, 2)) / 2 + sheet.add_essential_bc([0, u_y], "Left") + sheet.add_essential_bc([edot, u_y], "Right") + p = sheet.p # created before the solve + sheet.solve() + assert np.abs(np.asarray(p.array) + eta * float(edot)).max() < 1.0e-6 + + +@pytest.mark.tier_b +def test_plastic_sheet_yields_at_the_von_mises_stress(): + """A perfectly plastic sheet (viscosity far above tau_y / edot) in + uniaxial stretch carries sigma_xx = sqrt(3) tau_y, the von Mises uniaxial + yield stress: the invariant includes the out-of-plane strain rate + eps_nn = -tr(eps_s) (with the in-plane invariant alone the sheet yields at + sqrt(18/5) tau_y = 1.897 tau_y).""" + mesh = _box() + x, y = mesh.X + edot, tau_y = sympy.Rational(1, 2), 1 + sheet = uw.systems.ThinSheet(mesh) + sheet.constitutive_model = uw.constitutive_models.ViscoPlasticFlowModel + sheet.constitutive_model.Parameters.shear_viscosity_0 = 1.0e4 + sheet.constitutive_model.Parameters.yield_stress = tau_y + sheet.tolerance = 1.0e-10 + u_y = -edot * (y - sympy.Rational(1, 2)) / 2 + sheet.add_essential_bc([0, u_y], "Left") + sheet.add_essential_bc([edot, u_y], "Right") + # start from the uniform stretch: the plastic viscosity is singular at rest + sheet.u.array[:, 0, 0] = float(edot) * sheet.u.coords[:, 0] + sheet.u.array[:, 0, 1] = -float(edot) * (sheet.u.coords[:, 1] - 0.5) / 2 + sheet.solve(zero_init_guess=False) + + # magnitudes: in a model without units the yield stress's declared unit + # still tags the evaluated stress (issue #881) + sigma = np.asarray(uw.function.evaluate(sheet.stress, _probe_points())).reshape(-1, 2, 2) + assert np.abs(sigma[:, 0, 0] - np.sqrt(3.0) * tau_y).max() < 1.0e-3 + assert np.abs(sigma[:, 1, 1]).max() < 1.0e-3 + + +@pytest.mark.tier_b +def test_sheet_solves_on_a_refined_mesh_with_a_hierarchy(): + """On a mesh with refinement levels the field decomposition (which the + rotated solves address the field by) recurses into the coarse levels; it + must run after the fields are copied there. Before, it raised PETSc error + 62 ("number of requested fields 1 greater than number of DM fields 0").""" + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.25, refinement=1, qdegree=3) + sheet = _sheet(mesh) + c = sympy.Rational(5, 2) + sheet.basal_drag = c + sheet.bodyforce = (1, 0) + sheet.solve() + u = np.asarray(sheet.u.array).reshape(-1, 2) + assert np.abs(u - np.array([1 / 2.5, 0.0])).max() < 1.0e-8 diff --git a/tests/test_1076_thin_sheet_viscoelastic.py b/tests/test_1076_thin_sheet_viscoelastic.py new file mode 100644 index 000000000..1adddbdbf --- /dev/null +++ b/tests/test_1076_thin_sheet_viscoelastic.py @@ -0,0 +1,106 @@ +"""Maxwell viscoelastic thin sheet on a flat mesh: closed-form checks. + +Uniaxial loading at constant strain rate with free lateral edges relaxes +exactly as sigma_xx = 3 eta edot (1 - exp(-t mu / eta)): the Trouton stress +with the Maxwell time. The exponential integrator reproduces that exactly at +every step (the forcing is constant over a step); backward Euler reproduces +its own recursion exactly. Stress diffusion into an elastic sheet over a +viscous substrate (Elsasser) follows v = V0 erfc(x / sqrt(4 D t)) with +D = mu L / c, approached at first order in the timestep. +""" + +import numpy as np +import pytest +import sympy +from scipy.special import erfc + +import underworld3 as uw + +pytestmark = [pytest.mark.level_2] + +ETA, MU, EDOT = 2.0, 4.0, 0.5 # t_r = eta / mu = 0.5 + + +def _uniaxial(integrator, n_steps, dt_over_tr): + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.2, qdegree=3) + x, y = mesh.X + sheet = uw.systems.ThinSheet(mesh) + sheet.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + sheet.Unknowns, order=1, integrator=integrator) + sheet.constitutive_model.Parameters.shear_viscosity_0 = ETA + sheet.constitutive_model.Parameters.shear_modulus = MU + sheet.tolerance = 1.0e-10 + edot = sympy.Rational(1, 2) + u_y = -edot * (y - sympy.Rational(1, 2)) / 2 + sheet.add_essential_bc([0, u_y], "Left") + sheet.add_essential_bc([edot, u_y], "Right") + + t_r = ETA / MU + dt = dt_over_tr * t_r + centre = np.array([[0.5, 0.5]]) + times, sigma_xx = [], [] + for step in range(1, n_steps + 1): + sheet.solve(timestep=dt, zero_init_guess=False) + tau = uw.function.evaluate(sheet.tau.sym, centre).reshape(2, 2) + # plane stress: sigma = tau + tr(tau) I + sigma_xx.append(2.0 * tau[0, 0] + tau[1, 1]) + times.append(step * dt) + return np.array(times), np.array(sigma_xx), dt + + +@pytest.mark.tier_b +def test_exponential_integrator_matches_the_maxwell_response_exactly(): + """ETD-1 integrates the relaxation analytically, so at constant strain + rate it lands on 3 eta edot (1 - exp(-t/t_r)) at every step.""" + times, sigma, _ = _uniaxial("etd", n_steps=8, dt_over_tr=0.5) + exact = 3.0 * ETA * EDOT * (1.0 - np.exp(-times * MU / ETA)) + assert np.abs(sigma - exact).max() < 1.0e-6 * 3.0 * ETA * EDOT + + +@pytest.mark.tier_b +def test_backward_euler_follows_its_own_recursion_exactly(): + """BDF-1 on the Maxwell element: sigma_n = (sigma_{n-1} + 3 mu dt edot) / (1 + dt/t_r).""" + times, sigma, dt = _uniaxial("bdf", n_steps=8, dt_over_tr=0.5) + recursion, s = [], 0.0 + for _ in times: + s = (s + 3.0 * MU * dt * EDOT) / (1.0 + dt * MU / ETA) + recursion.append(s) + assert np.abs(sigma - np.array(recursion)).max() < 1.0e-6 * 3.0 * ETA * EDOT + + +def _elsasser(dt, t_end, V0=1.0, length=4.0, cell=0.1): + """Anti-plane shear v(x, t) in an elastic sheet (t_r >> t_end) over a + viscous substrate: v_t = D v_xx with D = mu L / c.""" + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(length, 1.0), cellSize=cell, qdegree=3) + sheet = uw.systems.ThinSheet(mesh) + sheet.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + sheet.Unknowns, order=1, integrator="etd") + sheet.constitutive_model.Parameters.shear_viscosity_0 = 1.0e6 # t_r = 1e6 >> t_end + sheet.constitutive_model.Parameters.shear_modulus = 1.0 + sheet.basal_drag = 1 # D = mu L / c = 1 + sheet.tolerance = 1.0e-10 + sheet.add_essential_bc([0, V0], "Left") + sheet.add_essential_bc([0, 0], "Right") + sheet.add_essential_bc([0, None], "Top") + sheet.add_essential_bc([0, None], "Bottom") + n_steps = int(round(t_end / dt)) + for _ in range(n_steps): + sheet.solve(timestep=dt, zero_init_guess=False) + xs = np.linspace(0.05, 2.5, 30) + pts = np.column_stack([xs, np.full_like(xs, 0.5)]) + v = uw.function.evaluate(sheet.u.sym, pts).reshape(-1, 2)[:, 1] + exact = V0 * erfc(xs / np.sqrt(4.0 * 1.0 * t_end)) + return np.abs(v - exact).max() + + +@pytest.mark.tier_b +def test_elsasser_stress_diffusion_converges_in_the_timestep(): + """The erfc front is approached at first order in dt (backward Euler in + time), and is within 5% of V0 at dt = 0.025.""" + err_coarse = _elsasser(dt=0.05, t_end=0.5) + err_fine = _elsasser(dt=0.025, t_end=0.5) + order = np.log(err_coarse / err_fine) / np.log(2.0) + assert err_fine < 0.05, f"error {err_fine:.3e} against the erfc front" + assert order > 0.8, f"timestep convergence order {order:.2f} ({err_coarse:.3e} -> {err_fine:.3e})" diff --git a/tests/test_1077_thin_sheet_faults.py b/tests/test_1077_thin_sheet_faults.py new file mode 100644 index 000000000..afabba4a4 --- /dev/null +++ b/tests/test_1077_thin_sheet_faults.py @@ -0,0 +1,130 @@ +"""Split-node faults in a thin viscous sheet: closed-form checks. + +The thin viscous sheet is plane-stress elasticity with nu = 1/2 (velocity for +displacement, eta for G), so its effective modulus is E' = 2 eta (1 + nu) = +3 eta — the Trouton value — and the crack solutions of plane-stress elasticity +carry over. A frictionless fault is the mode-II crack whose faces stay in +contact: zero shear traction, continuous normal velocity and normal traction. +""" + +import numpy as np +import pytest +import sympy + +import underworld3 as uw +from underworld3.utilities.fault_contact import fault_pair_jumps, fault_slip + +pytestmark = [pytest.mark.level_2] + + +def _box(cell_size): + return uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=cell_size, qdegree=3) + + +def _sheet(mesh, eta=1): + sheet = uw.systems.ThinSheet(mesh) + sheet.constitutive_model = uw.constitutive_models.ViscousFlowModel + sheet.constitutive_model.Parameters.shear_viscosity_0 = eta + sheet.tolerance = 1.0e-10 + return sheet + + +def _probe_points(n=8): + # an even count keeps the grid off the fault line y = 0.5 (and x = 0.5), + # where a point evaluation on coincident split nodes is ambiguous + s = np.linspace(0.1, 0.9, n) + return np.array([[a, b] for a in s for b in s]) + + +@pytest.mark.tier_b +@pytest.mark.parametrize("fault", [ + np.array([[0.3, 0.5], [0.7, 0.5]]), # along the stretch + np.array([[0.5, 0.3], [0.5, 0.7]]), # across it +]) +def test_a_fault_free_of_shear_traction_changes_nothing(fault): + """Under uniaxial stretch the principal axes are x and y, so a frictionless + fault along either carries no shear traction: the faulted solution is the + unfaulted one (u = (edot x, -edot (y - 1/2) / 2)) and the slip is zero.""" + split = _box(0.1).add_fault(("Flt", fault)) + x, y = split.X + edot = sympy.Rational(1, 2) + u_y = -edot * (y - sympy.Rational(1, 2)) / 2 + sheet = _sheet(split, eta=2) + sheet.add_essential_bc([0, u_y], "Left") + sheet.add_essential_bc([edot, u_y], "Right") + sheet.add_fault_bc(0, "Flt") + sheet.solve() + info = sheet._rotated_freeslip_info + assert info["converged"] + + # The exact field is linear, so the P2 nodal values carry it exactly. + # TODO(BUG): uw.function.evaluate mislocates points within ~h/2 of the slit + # of a split mesh (coincident nodes) and returns O(1e-3) errors there while + # the nodal solution is exact to 1e-9 (measured 2026-10-08, cellSize 0.1). + c = sheet.u.coords + u = sheet.u.array[:, 0, :] + exact = np.column_stack([0.5 * c[:, 0], -0.5 * (c[:, 1] - 0.5) / 2]) + assert np.abs(u - exact).max() < 1.0e-8 + _s, V, leak = fault_slip(sheet, "Flt", info) + assert np.abs(V).max() < 1.0e-8 + assert np.abs(leak).max() < 1.0e-13 + + +def _remote_shear(split, eta_f): + """Remote simple shear u = (y, 0) (tau_inf = eta * 1) on every boundary with + an interior fault carrying the viscous law tau = eta_f V (0 = frictionless).""" + x, y = split.X + sheet = _sheet(split, eta=1) + for boundary in ("Left", "Right", "Top", "Bottom"): + sheet.add_essential_bc([y, 0], boundary) + sheet.add_fault_bc(eta_f, "Flt") + sheet.solve() + info = sheet._rotated_freeslip_info + assert info["converged"] + return sheet, info + + +@pytest.mark.tier_b +def test_a_viscous_fault_approaches_the_welded_sheet_at_first_order(): + """tau = eta_f V penalises the slip: the slip, and with it the deviation + from the unfaulted solution (u = (y, 0) exactly), is first order in + 1 / eta_f.""" + split = _box(0.1).add_fault(("Flt", np.array([[0.3, 0.5], [0.7, 0.5]]))) + pts = _probe_points() + exact = np.column_stack([pts[:, 1], np.zeros(len(pts))]) + slip, deviation = {}, {} + for eta_f in (1.0e2, 1.0e3): + sheet, info = _remote_shear(split, eta_f) + _s, V, _leak = fault_slip(sheet, "Flt", info) + slip[eta_f] = np.abs(V).max() + u = uw.function.evaluate(sheet.u.sym, pts).reshape(-1, 2) + deviation[eta_f] = np.abs(u - exact).max() + order = np.log(slip[1.0e2] / slip[1.0e3]) / np.log(10.0) + assert order > 0.9, f"slip order in 1/eta_f {order:.2f}: {slip}" + assert deviation[1.0e3] < deviation[1.0e2] + + +@pytest.mark.tier_b +def test_frictionless_fault_slips_as_the_griffith_crack(): + """A frictionless fault of half-length a under remote shear tau slips as + the mode-II crack of plane-stress elasticity, delta u = (4 tau / E') + sqrt(a^2 - x^2) with E' = 3 eta. Infinite-plane solution: the box is + 2 x 2 against a = 0.2 (walls at 5 a) and the P2 tip resolution is h = 1/25, + which the 8% margins cover (measured 2026-10, recorded in the commit that + added this test).""" + a = 0.2 + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(-1.0, -1.0), maxCoords=(1.0, 1.0), cellSize=1.0 / 25, qdegree=3) + split = mesh.add_fault(("Flt", np.array([[-a, 0.0], [a, 0.0]]))) + sheet, info = _remote_shear(split, 0) + coords, jumps, normals = fault_pair_jumps(sheet, "Flt", info) + tangents = np.column_stack([-normals[:, 1], normals[:, 0]]) + V = np.einsum("ij,ij->i", jumps, tangents) + leak = np.einsum("ij,ij->i", jumps, normals) + assert np.abs(leak).max() < 1.0e-13 + ellipse = (4.0 / 3.0) * np.sqrt(np.maximum(a**2 - coords[:, 0] ** 2, 0.0)) + peak = 4.0 * a / 3.0 + assert abs(np.abs(V).max() - peak) < 0.08 * peak, f"peak slip {np.abs(V).max():.4f} vs {peak:.4f}" + rms = np.sqrt(np.mean((np.abs(V) - ellipse) ** 2)) + assert rms < 0.08 * peak, f"rms profile error {rms:.4f} vs peak {peak:.4f}" diff --git a/tests/test_1078_thin_sheet_sphere.py b/tests/test_1078_thin_sheet_sphere.py new file mode 100644 index 000000000..e1c13b40b --- /dev/null +++ b/tests/test_1078_thin_sheet_sphere.py @@ -0,0 +1,243 @@ +"""Thin sheet on the surface of a sphere: the velocity lives in the embedding +space with a strong tangency constraint at every node. + +Closed forms: a rigid rotation is a Killing field of the sphere (zero surface +strain rate), so a force c (omega x X) against basal drag c returns exactly +u = omega x X; a toroidal manufactured solution of the sheet operator +converges at second order (flat-facet geometry) with machine-exact tangency. +""" + +import numpy as np +import pytest +import sympy + +import underworld3 as uw + +pytestmark = [pytest.mark.level_2] + + +def _sphere(cell_size, degree=2): + return uw.meshing.SphericalManifold(radius=1.0, cellSize=cell_size, degree=1, + qdegree=2 * degree + 1) + + +def _sheet(sphere, eta=1): + sheet = uw.systems.ThinSheet(sphere) + sheet.constitutive_model = uw.constitutive_models.ViscousFlowModel + sheet.constitutive_model.Parameters.shear_viscosity_0 = eta + sheet.tolerance = 1.0e-9 + return sheet + + +def _leak(sheet): + c = sheet.u.coords + u = sheet.u.array[:, 0, :] + rhat = c / np.linalg.norm(c, axis=1)[:, None] + return np.abs((u * rhat).sum(axis=1)).max() + + +@pytest.mark.tier_b +def test_rigid_rotation_is_a_killing_field(): + """u = omega x X has no surface strain rate: with basal drag c and the + force c (omega x X) the sheet returns the rotation exactly (P2 carries the + linear field) and the tangency constraint holds to machine precision.""" + sphere = _sphere(0.25) + X = sphere.X + x, y, z = X[0], X[1], X[2] + omega = sympy.Matrix([[sympy.Rational(1, 3), sympy.Rational(-1, 2), sympy.Rational(2, 7)]]) + rot = sympy.Matrix([[omega[1] * z - omega[2] * y, + omega[2] * x - omega[0] * z, + omega[0] * y - omega[1] * x]]) + sheet = _sheet(sphere) + sheet.basal_drag = 2 + sheet.bodyforce = list(2 * rot) + sheet.solve() + assert sheet._rotated_freeslip_info["converged"] + c = sheet.u.coords + exact = np.cross(np.array([1 / 3, -1 / 2, 2 / 7]), c) + assert np.abs(sheet.u.array[:, 0, :] - exact).max() < 1.0e-7 + assert _leak(sheet) < 1.0e-13 + + +def _toroidal_mms(sphere, eta, alpha): + """Manufactured toroidal l=2 field u_e = rhat x grad(z^2) = (2yz, -2xz, 0) + and the force that makes it the sheet solution with basal drag alpha.""" + X = sphere.X + x, y, z = X[0], X[1], X[2] + r = sympy.sqrt(x**2 + y**2 + z**2) + rhat = sympy.Matrix([[x, y, z]]) / r + P = sympy.eye(3) - rhat.T * rhat + u_e = sympy.Matrix([[2 * y * z, -2 * x * z, 0]]) + G = u_e.jacobian(sympy.Matrix([x, y, z])) * P + E = P * ((G + G.T) / 2) * P + sigma = 2 * eta * (E + E.trace() * P) + Xs = [x, y, z] + divs = sympy.Matrix([[sum(sigma[i, j].diff(Xs[k]) * P[k, j] + for j in range(3) for k in range(3)) for i in range(3)]]) + return u_e, (-divs + alpha * u_e).subs(r, 1) + + +@pytest.mark.tier_b +def test_manufactured_toroidal_solution_converges_at_second_order(): + """Relative nodal L2 error of the tangential velocity at h = 0.2 and 0.1: + second order (the flat-facet geometry limit for P2 on a curved surface), + tangency exact. Measured 2026-10-08: 6.9e-3 -> 1.8e-3.""" + eta, alpha = sympy.Integer(1), sympy.Integer(1) + errors = {} + for h in (0.2, 0.1): + sphere = _sphere(h) + u_e, force = _toroidal_mms(sphere, eta, alpha) + sheet = _sheet(sphere, eta=eta) + sheet.basal_drag = alpha + sheet.bodyforce = list(force) + sheet.solve() + assert sheet._rotated_freeslip_info["converged"] + assert _leak(sheet) < 1.0e-13 + c = sheet.u.coords + exact = np.column_stack([2 * c[:, 1] * c[:, 2], -2 * c[:, 0] * c[:, 2], np.zeros(len(c))]) + err = sheet.u.array[:, 0, :] - exact + errors[h] = np.sqrt((err**2).sum()) / np.sqrt((exact**2).sum()) + order = np.log(errors[0.2] / errors[0.1]) / np.log(2.0) + assert errors[0.1] < 3.0e-3, errors + assert order > 1.7, f"order {order:.2f}: {errors}" + + +@pytest.mark.tier_b +def test_a_maxwell_sheet_keeps_a_rigid_rotation_stress_free(): + """With the grid-carried stress history, the Killing field stays the exact + solution step after step and the carried stress stays at round-off.""" + sphere = _sphere(0.25) + X = sphere.X + x, y, z = X[0], X[1], X[2] + omega = sympy.Matrix([[sympy.Rational(1, 3), sympy.Rational(-1, 2), sympy.Rational(2, 7)]]) + rot = sympy.Matrix([[omega[1] * z - omega[2] * y, + omega[2] * x - omega[0] * z, + omega[0] * y - omega[1] * x]]) + sheet = uw.systems.ThinSheet(sphere) + sheet.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + sheet.Unknowns, order=1, integrator="etd") + sheet.constitutive_model.Parameters.shear_viscosity_0 = 10 + sheet.constitutive_model.Parameters.shear_modulus = 1 + sheet.basal_drag = 2 + sheet.bodyforce = list(2 * rot) + sheet.tolerance = 1.0e-9 + c = sheet.u.coords + exact = np.cross(np.array([1 / 3, -1 / 2, 2 / 7]), c) + for _ in range(4): + sheet.solve(timestep=0.1, zero_init_guess=False) + assert np.abs(sheet.u.array[:, 0, :] - exact).max() < 1.0e-7 + assert np.abs(np.asarray(sheet.tau.array)).max() < 1.0e-7 + assert _leak(sheet) < 1.0e-13 + + +@pytest.mark.tier_b +def test_a_manifold_sheet_refuses_a_semi_lagrangian_history(): + """The history is carried on the grid on a manifold; asking for the + trace-back is refused before any history exists.""" + sheet = uw.systems.ThinSheet(_sphere(0.4)) + assert sheet.stress_transport == "eulerian" + with pytest.raises(ValueError, match="manifold"): + sheet.stress_transport = "semi_lagrangian" + sheet.stress_transport = "eulerian" + + +@pytest.mark.tier_b +def test_reloadable_checkpoint_round_trips_on_a_manifold(tmp_path): + """write_timestep(petsc_reload=True) writes the coordinates with one + component per embedding dimension; it used to reshape them by the + topological dimension and fail on a surface mesh. The written vector + reloads exactly.""" + sphere = _sphere(0.3) + v = uw.discretisation.MeshVariable("v", sphere, 3, degree=2) + v.array[:, 0, :] = v.coords + sphere.write_timestep("manifold", 0, outputPath=str(tmp_path), meshVars=[v], + petsc_reload=True) + w = uw.discretisation.MeshVariable("w", sphere, 3, degree=2) + # the in-place reload (same mesh object); the DMPlex-migration path + # segfaults on a manifold mesh (TODO(BUG) in read_checkpoint) + w.read_checkpoint(str(tmp_path / "manifold.mesh.v.00000.h5"), data_name="v", + same_layout=True) + assert np.abs(w.array[:, 0, :] - w.coords).max() < 1.0e-12 + + +@pytest.mark.tier_b +def test_the_grid_history_carries_a_tensor_round_the_sphere(): + """The grid (Eulerian SUPG) history advects each component as a scalar. + A rigid rotation about the x axis, u = (0, -z, y), carries a component + planted as z to -y after a quarter turn; the rotation has a z velocity, so a + transport that sums only the first mesh.dim velocity components (as it + once did on a surface mesh) leaves the field where it started (measured + 1.126 then). Measured 2026-10-09, h = 0.15, 40 steps: 2.27e-3.""" + sphere = _sphere(0.15) + X = sphere.X + x, y, z = X[0], X[1], X[2] + stress = uw.discretisation.MeshVariable( + "S_turn", sphere, vtype=uw.VarType.SYM_TENSOR, degree=2) + manager = uw.systems.ddt.EulerianSUPG( + sphere, stress, sympy.Matrix([[0, -z, y]]), vtype=uw.VarType.SYM_TENSOR, + degree=2, continuous=True, order=1, transport_on_update=True) + c = stress.coords + stress.array[:, 0, 0] = c[:, 2] + manager.initialise_history() + dt, steps = np.pi / 2 / 40, 40 # a quarter turn + for _ in range(steps): + manager.update_pre_solve(dt) + stress.array[...] = manager.psi_star[0].array[...] + carried = np.asarray(manager.psi_star[0].array[:, 0, 0]) + turned = -c[:, 1] + error = np.sqrt(((carried - turned) ** 2).sum() / (turned ** 2).sum()) + assert error < 5.0e-3, f"relative error {error:.2e} after a quarter turn" + + +@pytest.mark.tier_b +def test_supg_advection_turns_a_scalar_round_the_sphere(): + """The Eulerian SUPG advection-diffusion solver on a surface mesh: a rigid + rotation about the x axis, u = (0, -z, y), carries T = z to T = -y after a + quarter turn (no diffusion). The velocity has three components and the + surface gradient three entries; the solver must use all of them. + Measured 2026-10-09, h = 0.15, 40 steps: 2.27e-3.""" + sphere = _sphere(0.15) + X = sphere.X + x, y, z = X[0], X[1], X[2] + T = uw.discretisation.MeshVariable("T_turn", sphere, 1, degree=2) + T.array[:, 0, 0] = T.coords[:, 2] + adv = uw.systems.AdvDiffusion(sphere, T, sympy.Matrix([[0, -z, y]])) + adv.constitutive_model = uw.constitutive_models.DiffusionModel + adv.constitutive_model.Parameters.diffusivity = 0 + dt, steps = np.pi / 2 / 40, 40 # a quarter turn + for _ in range(steps): + adv.solve(timestep=dt) + turned = -T.coords[:, 1] + error = np.sqrt(((T.array[:, 0, 0] - turned) ** 2).sum() / (turned ** 2).sum()) + assert error < 5.0e-3, f"relative error {error:.2e} after a quarter turn" + + +@pytest.mark.tier_b +def test_a_maxwell_sheet_keeps_its_stress_tangent(): + """The carried stress of a Maxwell sheet on the sphere stays tangent to the + surface. Transported component by component in the embedding space it drifted + out of the tangent plane (normal part 31% of the stress by t = 3, measured + 2026-10-09, h = 0.12, dt = 0.1) and the plane-stress closure read the normal + part as a pressure. Driven by the divergence-free toroidal flow + rhat x grad(z^2), the sheet still thickens at both poles (an uplift rate of + 0.15) with the history tangent: that is the advection of the stress, a + large-strain effect quadratic in the flow (Weissenberg number ~2 here) — with + the transport switched off it is 6e-6.""" + sphere = _sphere(0.12) + X = sphere.X + x, y, z = X[0], X[1], X[2] + sheet = uw.systems.ThinSheet(sphere) + sheet.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + sheet.Unknowns, order=1, integrator="etd") + sheet.constitutive_model.Parameters.shear_viscosity_0 = 1 + sheet.constitutive_model.Parameters.shear_modulus = 1 + sheet.basal_drag = 4 + sheet.bodyforce = list(4 * sympy.Matrix([[2 * y * z, -2 * x * z, 0]])) + sheet.tolerance = 1.0e-9 + for _ in range(12): + sheet.solve(timestep=0.1, zero_init_guess=False) + tau = np.asarray(sheet.tau.array) + c = sheet.tau.coords + rhat = c / np.linalg.norm(c, axis=1)[:, None] + normal = np.linalg.norm(np.einsum("nij,nj->ni", tau, rhat), axis=1) + assert normal.max() < 1.0e-12 * np.abs(tau).max() diff --git a/tests/test_1079_thin_sheet_sphere_faults.py b/tests/test_1079_thin_sheet_sphere_faults.py new file mode 100644 index 000000000..53d42c74b --- /dev/null +++ b/tests/test_1079_thin_sheet_sphere_faults.py @@ -0,0 +1,143 @@ +"""Split-node faults in a thin sheet on the surface of a sphere. + +The fault is a great-circle arc cut into the SphericalManifold; its coincident +pairs carry the no-opening constraint along the in-surface conormal and the +tangency of both sides, and slip along the trace. A rigid rotation is a Killing +field (zero surface strain), so it loads a frictionless fault with no traction +at all: the faulted solution must be the rotation exactly, with zero slip. A +short arc under the shear of the toroidal flow slips as the Griffith mode-II +crack of the plane-stress sheet, E' = 3 eta, up to the curvature of the sphere. +""" + +import numpy as np +import pytest +import sympy + +import underworld3 as uw +from underworld3.utilities.fault_contact import fault_pair_jumps, fault_slip + +pytestmark = [pytest.mark.level_2] + + +def _equator_arc(half_angle, n=9): + phi = np.linspace(-half_angle, half_angle, n) + return np.column_stack([np.cos(phi), np.sin(phi), np.zeros_like(phi)]) + + +def _faulted_sphere(cell_size, half_angle): + sphere = uw.meshing.SphericalManifold(radius=1.0, cellSize=cell_size, degree=1, qdegree=5) + return sphere.add_fault(("Flt", _equator_arc(half_angle))) + + +def _sheet(split, eta=1): + sheet = uw.systems.ThinSheet(split) + sheet.constitutive_model = uw.constitutive_models.ViscousFlowModel + sheet.constitutive_model.Parameters.shear_viscosity_0 = eta + sheet.tolerance = 1.0e-9 + return sheet + + +@pytest.mark.tier_b +def test_a_killing_field_leaves_the_fault_untouched(): + """u = omega x X has no surface strain, so the frictionless fault sees no + traction: the solution is the rotation exactly, the slip and the opening + are zero, and every node (both sides of the fault included) stays tangent.""" + split = _faulted_sphere(0.2, np.pi / 6) + X = split.X + x, y, z = X[0], X[1], X[2] + omega = (sympy.Rational(1, 3), sympy.Rational(-1, 2), sympy.Rational(2, 7)) + rot = sympy.Matrix([[omega[1] * z - omega[2] * y, + omega[2] * x - omega[0] * z, + omega[0] * y - omega[1] * x]]) + sheet = _sheet(split) + sheet.basal_drag = 2 + sheet.bodyforce = list(2 * rot) + sheet.add_fault_bc(0, "Flt") + sheet.solve() + info = sheet._rotated_freeslip_info + assert info["converged"] + c = sheet.u.coords + exact = np.cross(np.array([1 / 3, -1 / 2, 2 / 7]), c) + assert np.abs(sheet.u.array[:, 0, :] - exact).max() < 1.0e-7 + rhat = c / np.linalg.norm(c, axis=1)[:, None] + assert np.abs((sheet.u.array[:, 0, :] * rhat).sum(axis=1)).max() < 1.0e-13 + _s, V, leak = fault_slip(sheet, "Flt", info) + assert np.abs(V).max() < 1.0e-7 + assert np.abs(leak).max() < 1.0e-13 + + +@pytest.mark.tier_b +def test_short_arc_slips_as_the_griffith_crack(): + """The toroidal field u = rhat x grad(z^2) shears the equator with + E_yz = -x: at the arc's centre (1, 0, 0) the fault-parallel shear traction + is tau = 2 eta. A frictionless arc of half-length a slips as the plane-stress + mode-II crack, (4 tau / 3 eta) sqrt(a^2 - s^2), with the curvature of the + sphere (a / R = 0.25) and the basal screening inside the 12% margin + (measured 2026-10-09, recorded in the commit that added this test).""" + a = 0.25 + split = _faulted_sphere(0.05, a) + X = split.X + x, y, z = X[0], X[1], X[2] + r = sympy.sqrt(x**2 + y**2 + z**2) + rhat = sympy.Matrix([[x, y, z]]) / r + P = sympy.eye(3) - rhat.T * rhat + u_e = sympy.Matrix([[2 * y * z, -2 * x * z, 0]]) + G = u_e.jacobian(sympy.Matrix([x, y, z])) * P + E = P * ((G + G.T) / 2) * P + sigma = 2 * (E + E.trace() * P) + Xs = [x, y, z] + divs = sympy.Matrix([[sum(sigma[i, j].diff(Xs[k]) * P[k, j] + for j in range(3) for k in range(3)) for i in range(3)]]) + sheet = _sheet(split, eta=1) + sheet.basal_drag = 1 + sheet.bodyforce = list((-divs + u_e).subs(r, 1)) + sheet.add_fault_bc(0, "Flt") + sheet.solve() + info = sheet._rotated_freeslip_info + assert info["converged"] + coords, jumps, normals = fault_pair_jumps(sheet, "Flt", info) + leak = np.einsum("ij,ij->i", jumps, normals) + assert np.abs(leak).max() < 1.0e-13 + s_arc = np.arctan2(coords[:, 1], coords[:, 0]) # arc length on the unit sphere + V = np.linalg.norm(jumps, axis=1) + tau = 2.0 + ellipse = (4.0 * tau / 3.0) * np.sqrt(np.maximum(a**2 - s_arc**2, 0.0)) + peak = 4.0 * tau * a / 3.0 + assert abs(V.max() - peak) < 0.12 * peak, f"peak slip {V.max():.4f} vs {peak:.4f}" + rms = np.sqrt(np.mean((V - ellipse) ** 2)) + assert rms < 0.12 * peak, f"rms profile error {rms:.4f} vs peak {peak:.4f}" + + +@pytest.mark.tier_b +def test_a_viscous_interface_law_penalises_the_slip_on_the_sphere(): + """tau = eta_f V on the arc under the toroidal shear: the interface law is + assembled on the edge facets of the surface mesh (one slip component along + the trace), and the slip falls at first order in 1 / eta_f.""" + a = 0.25 + split = _faulted_sphere(0.1, a) + X = split.X + x, y, z = X[0], X[1], X[2] + r = sympy.sqrt(x**2 + y**2 + z**2) + rhat = sympy.Matrix([[x, y, z]]) / r + P = sympy.eye(3) - rhat.T * rhat + u_e = sympy.Matrix([[2 * y * z, -2 * x * z, 0]]) + G = u_e.jacobian(sympy.Matrix([x, y, z])) * P + E = P * ((G + G.T) / 2) * P + sigma = 2 * (E + E.trace() * P) + Xs = [x, y, z] + divs = sympy.Matrix([[sum(sigma[i, j].diff(Xs[k]) * P[k, j] + for j in range(3) for k in range(3)) for i in range(3)]]) + slip = {} + for eta_f in (1.0e1, 1.0e2): + sheet = _sheet(split, eta=1) + sheet.basal_drag = 1 + sheet.bodyforce = list((-divs + u_e).subs(r, 1)) + sheet.add_fault_bc(eta_f, "Flt") + sheet.solve() + info = sheet._rotated_freeslip_info + assert info["converged"] + _s, V, leak = fault_slip(sheet, "Flt", info) + assert np.abs(leak).max() < 1.0e-13 + slip[eta_f] = np.abs(V).max() + order = np.log(slip[1.0e1] / slip[1.0e2]) / np.log(10.0) + assert order > 0.8, f"slip order in 1/eta_f {order:.2f}: {slip}" diff --git a/tests/test_1080_objective_step.py b/tests/test_1080_objective_step.py new file mode 100644 index 000000000..221226280 --- /dev/null +++ b/tests/test_1080_objective_step.py @@ -0,0 +1,133 @@ +"""The objective step of the viscoelastic model: how the carried stress is taken +through one step (objective_order), on a flat thin sheet. + +A rigid rotation must carry a stress rigidly: sigma(X) = R sigma1(R^T X) R^T for +a quarter turn R. That is exact for any objective rate, so the rigid carry is a +hard baseline for the transport and the rotation together. +""" + +import numpy as np +import pytest +import sympy + +import underworld3 as uw + +pytestmark = [pytest.mark.level_2] + + +def _box(cell_size=0.5): + return uw.meshing.UnstructuredSimplexBox( + minCoords=(-1.0, -1.0), maxCoords=(1.0, 1.0), cellSize=cell_size, qdegree=3) + + +def _model(rate, order=None, history_order=1, convected_step=None): + sheet = uw.systems.ThinSheet(_box()) + return uw.constitutive_models.ViscoElasticPlasticFlowModel( + sheet.Unknowns, order=history_order, integrator="etd", objective_rate=rate, + objective_order=order, convected_step=convected_step) + + +@pytest.mark.tier_b +def test_the_objective_order_defaults_and_refusals(): + """First order by default for every rate, so a Stokes model is as it was + (Louis, 2026-10-28); a thin sheet takes the Cayley step for a first-order + Jaumann history given no order; Cayley needs the Jaumann rate; the + deformation step takes no order; an order above one needs a first-order + history.""" + assert _model("jaumann")._objective_order == 1 + sheet = uw.systems.ThinSheet(_box()) + sheet.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + sheet.Unknowns, order=1, integrator="etd", objective_rate="jaumann") + assert sheet.constitutive_model._objective_order == "cayley" + sheet.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + sheet.Unknowns, order=1, integrator="etd", objective_rate="jaumann", + objective_order=1) + assert sheet.constitutive_model._objective_order == 1 + assert _model("upper_convected")._objective_order == 1 + assert _model("none")._objective_order == 1 + with pytest.raises(ValueError, match="cayley"): + _model("upper_convected", order="cayley") + with pytest.raises(ValueError, match="positive integer"): + _model("jaumann", order=0) + with pytest.raises(ValueError, match="deformation"): + _model("upper_convected", order=2, convected_step="deformation") + with pytest.raises(NotImplementedError, match="first-order history"): + _model("jaumann", order=2, history_order=2) + + +@pytest.mark.tier_b +def test_the_cayley_step_is_the_orthogonal_rotation_of_the_stress(): + """At a spin W = [[0, -w], [w, 0]] the Cayley step carries sigma to + R sigma R^T with R = (I - dt W/2)^{-1}(I + dt W/2), an exact rotation.""" + from underworld3.function.expressions import unwrap + model = _model("jaumann", order="cayley") + model.Parameters.dt_elastic = 0.1 + sigma = sympy.Matrix([[1, 2], [2, -3]]) + carried = model._carried_through_step(sigma) + L = sympy.Matrix(model.Unknowns.L) + w = 0.7 + spin = {L[0, 0]: 0, L[0, 1]: -w, L[1, 0]: w, L[1, 1]: 0} + got = np.array([[float(unwrap(carried[i, j].sym, keep_constants=False).xreplace(spin)) + for j in range(2)] for i in range(2)]) + A = 0.1 * np.array([[0.0, -w], [w, 0.0]]) / 2 + R = np.linalg.solve(np.eye(2) - A, np.eye(2) + A) + assert np.abs(R @ R.T - np.eye(2)).max() < 1.0e-14 + assert np.abs(got - R @ np.array([[1.0, 2.0], [2.0, -3.0]]) @ R.T).max() < 1.0e-13 + + +def _rigid_carry(steps, tangent=False, cell_size=0.1): + """Build an elastic stress on a flat sheet, then turn the sheet rigidly a + quarter turn about z through a stiff basal drag; return the carried stress + and the rigidly carried phase-one stress, within r < 0.6.""" + mesh = _box(cell_size) + x, y = mesh.X + sheet = uw.systems.ThinSheet(mesh) + sheet.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + sheet.Unknowns, order=1, integrator="etd", objective_rate="jaumann") + sheet.constitutive_model.Parameters.shear_viscosity_0 = 1.0e6 # elastic over the run + sheet.constitutive_model.Parameters.shear_modulus = 1 + sheet.tolerance = 1.0e-10 + sheet.consistent_jacobian = tangent + sheet.basal_drag = 1 + sheet.bodyforce = list(sympy.Matrix([[sympy.sin(2 * y) + x * y, sympy.cos(x) * y]])) + for _ in range(5): + sheet.solve(timestep=0.1, zero_init_guess=False) + sigma1 = uw.discretisation.MeshVariable( + f"sigma1_{steps}_{int(tangent)}", mesh, vtype=uw.VarType.SYM_TENSOR, degree=1) + sigma1.array[...] = np.asarray(sheet.tau.array) + c = 1.0e4 + sheet.basal_drag = c + sheet.bodyforce = list(c * sympy.Matrix([[-y, x]])) + # the history is advected with the previous step's velocity: start the + # turn with that velocity already the rotation + sheet.u.array[:, 0, :] = np.column_stack([-sheet.u.coords[:, 1], sheet.u.coords[:, 0]]) + angle = np.pi / 2 + for _ in range(steps): + sheet.solve(timestep=angle / steps, zero_init_guess=False) + R = np.array([[np.cos(angle), -np.sin(angle)], [np.sin(angle), np.cos(angle)]]) + pts = sheet.tau.coords + inside = np.hypot(pts[:, 0], pts[:, 1]) < 0.6 + got = np.asarray(sheet.tau.array)[inside] + s1 = np.asarray(uw.function.evaluate(sigma1.sym, pts[inside] @ R)).reshape(-1, 2, 2) + return got, np.einsum("ij,njk,lk->nil", R, s1, R) + + +@pytest.mark.tier_b +def test_a_rigid_rotation_carries_the_stress_rigidly(): + """The Cayley step and the grid transport together carry a stress through a + rigid quarter turn: relative error 2.94e-3 measured 2026-10-12 (h = 0.1, + 40 steps; the error is spatial: 3.97e-3 at 80 steps, from the transport's + upwinding). Without the rotation the components ride unrotated (O(1)).""" + got, expected = _rigid_carry(40) + error = np.linalg.norm(got - expected) / np.linalg.norm(expected) + assert error < 6.0e-3, f"relative error {error:.3e}" + + +@pytest.mark.tier_b +def test_the_newton_tangent_converges_to_the_same_stress(): + """The Newton tangent replaces the frozen carried stress by its first-order + form (the private _tangent_surrogates); the residual is unchanged, so the converged + stress is the frozen-tangent one to solver tolerance.""" + picard, _ = _rigid_carry(8, tangent=False, cell_size=0.2) + newton, _ = _rigid_carry(8, tangent=True, cell_size=0.2) + assert np.abs(newton - picard).max() < 1.0e-7 * np.abs(picard).max() diff --git a/tests/test_1081_objective_step_sphere.py b/tests/test_1081_objective_step_sphere.py new file mode 100644 index 000000000..a2270c3cb --- /dev/null +++ b/tests/test_1081_objective_step_sphere.py @@ -0,0 +1,60 @@ +"""The objective step on a curved sheet: a rigid rotation of the sphere carries +a tangent stress rigidly, sigma(X) = R sigma1(R^T X) R^T. + +This exercises everything a curved surface adds: the grid transport of the +Cartesian components, the spin of a material element including the turning +of its normal, the Cayley rotation through the step, and the tangent-plane +projection of the committed history (after the rotation, never before it). +""" + +import numpy as np +import pytest +import sympy + +import underworld3 as uw + +pytestmark = [pytest.mark.level_3] + + +# tier C for its cost (about 13 min): reported, not gated, as test_1061 and +# test_1064 are; it was shown to fail on its bug and would be tier B otherwise +@pytest.mark.tier_c +def test_a_rigid_rotation_of_the_sphere_carries_the_stress_rigidly(): + """A quarter turn about x in 20 steps, h = 0.15. Measured 2026-10-12: + relative error 9.4e-3 (8.4e-3 at 40 steps, 8.5e-3 at 80: the error is + spatial; 2.5e-3 at h = 0.075, order 1.77). Before the history was projected + only after the rotation it was 4.0e-2, first order in dt; without an + objective rate the stress is not rotated at all (error O(1)).""" + sphere = uw.meshing.SphericalManifold(radius=1.0, cellSize=0.15, degree=1, qdegree=5) + x, y, z = sphere.X[0], sphere.X[1], sphere.X[2] + sheet = uw.systems.ThinSheet(sphere) + sheet.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + sheet.Unknowns, order=1, integrator="etd", objective_rate="jaumann") + sheet.constitutive_model.Parameters.shear_viscosity_0 = 1.0e6 # elastic over the run + sheet.constitutive_model.Parameters.shear_modulus = 1 + sheet.tolerance = 1.0e-10 + # phase 1: an elastic stress from the toroidal drag rhat x grad(z^2) + sheet.basal_drag = 1 + sheet.bodyforce = list(sympy.Matrix([[2 * y * z, -2 * x * z, 0]])) + for _ in range(5): + sheet.solve(timestep=0.1, zero_init_guess=False) + sigma1 = uw.discretisation.MeshVariable("sigma1", sphere, vtype=uw.VarType.SYM_TENSOR, degree=1) + sigma1.array[...] = np.asarray(sheet.tau.array) + # phase 2: a rigid rotation about x at unit rate through a stiff drag, the + # velocity set to it first (the history is advected with the previous step's) + c = 1.0e4 + sheet.basal_drag = c + sheet.bodyforce = list(c * sympy.Matrix([[0, -z, y]])) + sheet.u.array[:, 0, :] = np.cross(np.array([1.0, 0.0, 0.0]), sheet.u.coords) + angle, steps = np.pi / 2, 20 + for _ in range(steps): + sheet.solve(timestep=angle / steps, zero_init_guess=False) + R = np.array([[1, 0, 0], [0, np.cos(angle), -np.sin(angle)], [0, np.sin(angle), np.cos(angle)]]) + pts = sheet.tau.coords + s1 = np.asarray(uw.function.evaluate(sigma1.sym, pts @ R)).reshape(-1, 3, 3) + expected = np.einsum("ij,njk,lk->nil", R, s1, R) + got = np.asarray(sheet.tau.array) + error = np.linalg.norm(got - expected) / np.linalg.norm(expected) + assert error < 1.9e-2, f"relative error {error:.3e}" + rhat = pts / np.linalg.norm(pts, axis=1)[:, None] + assert np.abs(np.einsum("nij,nj->ni", got, rhat)).max() < 1.0e-12 * np.abs(got).max() diff --git a/tests/test_1082_flux_jacobian_closure.py b/tests/test_1082_flux_jacobian_closure.py new file mode 100644 index 000000000..d6a509f19 --- /dev/null +++ b/tests/test_1082_flux_jacobian_closure.py @@ -0,0 +1,80 @@ +"""A constitutive model's tangent flux (``flux_jacobian``) is composed with the +solver's own closure, never substituted for the whole momentum flux. + +The model supplies the deviatoric stress; the solver turns it into its momentum +flux (Stokes: less the mechanical pressure, with the augmented-Lagrangian +penalty, plus DEVSS; the thin sheet: the plane-stress trace term times the +thickness). The Newton tangent has to see the same closure applied to the +model's tangent flux. Contract used here: on a LINEAR problem, a tangent flux +equal to the flux leaves Newton exactly as fast as no tangent flux at all — +the tangent is exact either way. When the tangent flux replaced the whole +flux, the penalty, DEVSS and pressure terms (Stokes) fell out of the tangent +and the linear solve diverged; on a vector solver (the sheet) the tangent flux +was silently ignored altogether (a flattening error swallowed by a bare +except), so the companion check is that a WRONG tangent flux (twice the flux) +does slow Newton down: the hook is live. +""" + +import pytest +import sympy + +import underworld3 as uw + +pytestmark = [pytest.mark.level_1] + + +def _newton_iterations(build, tangent_scale=None): + solver = build() + solver.consistent_jacobian = True + if tangent_scale is not None: + solver.constitutive_model.flux_jacobian = ( + tangent_scale * sympy.Matrix(solver.constitutive_model.flux)) + solver.solve() + return solver.snes.getIterationNumber(), solver + + +def _stokes(): + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.25, qdegree=3) + x, y = mesh.X + stokes = uw.systems.Stokes(mesh) + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = 1 + stokes.penalty = 10 + stokes.bodyforce = sympy.Matrix([[0, sympy.sin(sympy.pi * x)]]) + for wall in ("Left", "Right", "Top", "Bottom"): + stokes.add_essential_bc([0, 0], wall) + stokes.tolerance = 1.0e-8 + stokes.petsc_options["ksp_rtol"] = 1.0e-12 + return stokes + + +def _sheet(): + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.25, qdegree=3) + x, y = mesh.X + sheet = uw.systems.ThinSheet(mesh) + sheet.constitutive_model = uw.constitutive_models.ViscousFlowModel + sheet.constitutive_model.Parameters.shear_viscosity_0 = 1 + sheet.thickness = 2 + sheet.basal_drag = 1 + sheet.bodyforce = sympy.Matrix([[sympy.sin(sympy.pi * y), x * y]]) + sheet.add_essential_bc([0, 0], "Left") + sheet.tolerance = 1.0e-8 + sheet.petsc_options["ksp_rtol"] = 1.0e-12 + return sheet + + +@pytest.mark.tier_b +@pytest.mark.parametrize("build", [_stokes, _sheet], ids=["stokes", "thin_sheet"]) +def test_tangent_flux_keeps_the_solver_closure(build): + exact, _ = _newton_iterations(build) + composed, solver = _newton_iterations(build, tangent_scale=1) + assert solver.snes.getConvergedReason() > 0 + assert composed == exact, ( + f"Newton took {composed} iterations with a tangent flux equal to the " + f"flux, {exact} without one: the tangent lost the solver's closure") + wrong, _ = _newton_iterations(build, tangent_scale=2) + assert wrong > exact, ( + f"a tangent flux of twice the flux left Newton at {wrong} iterations " + f"({exact} exact): the tangent flux is not being used") diff --git a/tests/test_1083_thin_thick_alignment.py b/tests/test_1083_thin_thick_alignment.py new file mode 100644 index 000000000..211e9abb7 --- /dev/null +++ b/tests/test_1083_thin_thick_alignment.py @@ -0,0 +1,191 @@ +"""One model, thin or thick: the same model function runs as a thin sheet +(``ThinSheet`` on a surface mesh) and as a thick one (``Stokes`` on the extruded +layer, ``mesh.extrude``), changing only the mesh and the solver class. + +The thin sheet is the depth-averaged, plane-stress limit of the layer, so the +two agree to the thin-sheet error: O(c L / eta) for the shear the basal drag +drives through the layer and O((L / ell)^2) for the vertical averaging, with +ell the drag coupling length sqrt(eta L / c). Here L = 0.04, eta = 1, c = 1: +ell = 0.2, c L / eta = 0.04. Measured 2026-10-12 (h = 0.05, two layers): the +mid-plane velocity of the layer differs from the sheet's by 0.72 % unfaulted and +0.53 % faulted (away from the slit), the slip along the fault by 1.6 %. +""" + +import numpy as np +import pytest +import sympy + +import underworld3 as uw + +pytestmark = [pytest.mark.level_2] + +L, ETA, C = 0.04, 1, 1 + + +def _sheet_mesh(faulted): + # refinement=1: the sheet's multigrid level is extruded with it, so the + # layer's Stokes solve gets geometric multigrid (h = 0.05 on the fine level) + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.1, refinement=1, qdegree=3) + if faulted: + mesh = mesh.add_fault([("Flt", np.array([[0.25, 0.5], [0.75, 0.5]]))]) + return mesh + + +def _solver_on(mesh, Solver): + """The model, written once: a viscous plate dragged by a base moving in + simple shear, free at its edges, optionally cut by a frictionless fault.""" + solver = Solver(mesh) + solver.constitutive_model = uw.constitutive_models.ViscousFlowModel + solver.constitutive_model.Parameters.shear_viscosity_0 = ETA + y = mesh.X[1] + base_velocity = [y - sympy.Rational(1, 2), 0] + [0] * (mesh.cdim - 2) + solver.add_basal_drag(C, "Base", reference_velocity=base_velocity) + if "Flt" in [b.name for b in mesh.boundaries]: + solver.add_fault_bc(0, "Flt") + # the contract is the thin-sheet error (percent), not solver round-off + solver.tolerance = 1.0e-5 + return solver + + +def _run(faulted): + sheet_mesh = _sheet_mesh(faulted) + sheet = _solver_on(sheet_mesh, uw.systems.ThinSheet) + sheet.thickness = L + sheet.solve() + layer = _solver_on(sheet_mesh.extrude(L, layers=2), uw.systems.Stokes) + layer.solve() + return sheet, layer + + +def _mid_plane(layer, xy): + """The layer's in-plane velocity on its mid-plane, at the sheet's nodes.""" + pts = np.column_stack([xy, np.zeros(len(xy))]) + return np.asarray(uw.function.evaluate(layer.u.sym, pts)).reshape(-1, 3)[:, :2] + + +@pytest.mark.tier_b +def test_extruded_layer_geometry_and_pairing(): + """The layer: positive volume (area x L, every cell oriented as PETSc + expects — inverted cells flip every volume integral against the facet ones, + and a basal drag pushed), the sheet's walls under their names plus Base and + Surface, and the fault pairing coincident and open at the surface and the + base (paired vertices at both faces).""" + sheet = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.1, qdegree=3) + split = sheet.add_fault([("Flt", np.array([[0.3, 0.5], [0.7, 0.5]]))]) + layer = split.extrude(L, layers=2) + uw.discretisation.MeshVariable("probe", layer, 1, degree=1) # Integral needs a field + assert abs(uw.maths.Integral(layer, 1).evaluate() - L) < 1.0e-12 + names = {b.name for b in layer.boundaries} + assert {"Left", "Right", "Top", "Bottom", "Base", "Surface", + "Flt", "FltPlus", "FltMinus"} <= names + + dm = layer.dm + v_start, v_end = dm.getDepthStratum(0) + coords = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, 3) + + def centroid(q): + verts = [int(r) - v_start for r in dm.getTransitiveClosure(q)[0] + if v_start <= r < v_end] + return coords[verts].mean(axis=0) + + pairs = layer._fault_point_pairs["Flt"] + assert len(pairs) > 0 + gap = max(np.linalg.norm(centroid(a) - centroid(b)) for a, b in pairs.items()) + assert gap < 1.0e-12 + paired_vertex_z = {round(float(coords[q - v_start, 2]), 12) + for q in pairs if v_start <= q < v_end} + assert round(-L / 2, 12) in paired_vertex_z and round(L / 2, 12) in paired_vertex_z + + +# tier C: one model against another at one thickness, a characterisation; +# the contract is agreement to first order in L +@pytest.mark.tier_c +def test_dragged_plate_thin_and_thick_agree(): + sheet, layer = _run(faulted=False) + xy = sheet.u.coords + u_sheet = np.asarray(sheet.u.array).reshape(-1, 2) + u_layer = _mid_plane(layer, xy) + error = np.linalg.norm(u_layer - u_sheet) / np.linalg.norm(u_sheet) + assert error < 2.0e-2, f"relative difference {error:.3e}" + + +# tier C: one model against another at one thickness, a characterisation; +# the contract is agreement to first order in L +@pytest.mark.tier_c +def test_faulted_plate_thin_and_thick_agree(): + """The same plate cut by a frictionless fault: on the layer the fault is the + sheet's, extruded through the thickness (open at the surface and base, + shared at its tip columns), with ``Stokes.add_fault_bc``. Compared away from + the slit (point evaluation within a cell of a slit reads across it); the + fault must slip, or the comparison is vacuous.""" + sheet, layer = _run(faulted=True) + # the layer's velocity block is geometric multigrid over fault-free coarse + # levels; a split coarse level (coincident nodes on the slit) made the + # transfers degenerate and the first cycle returned NaN + info = layer._rotated_freeslip_info + assert info["velocity_pc"] == "custom-FMG" and info["converged"], info + xy = sheet.u.coords + u_sheet = np.asarray(sheet.u.array).reshape(-1, 2) + away = np.abs(xy[:, 1] - 0.5) > 0.06 + u_layer = _mid_plane(layer, xy[away]) + error = np.linalg.norm(u_layer - u_sheet[away]) / np.linalg.norm(u_sheet[away]) + assert error < 2.0e-2, f"relative difference {error:.3e}" + + # the slip: coincident nodes on the slit carry two velocities each; their + # spread in the fault-parallel component is the slip + s_sheet = np.asarray(sheet.u.array).reshape(-1, 2) + on_fault = (np.abs(xy[:, 1] - 0.5) < 1.0e-10) & (np.abs(xy[:, 0] - 0.5) < 0.2) + assert on_fault.sum() >= 4 + # coincident nodes: two velocities per position, their spread is the slip + positions = np.round(xy[on_fault, 0], 10) + spread = [np.ptp(s_sheet[on_fault][positions == p, 0]) for p in np.unique(positions)] + assert max(spread) > 1.0e-2, f"the sheet's fault does not slip ({max(spread):.2e})" + xyz = layer.u.coords + layer_on_fault = ((np.abs(xyz[:, 1] - 0.5) < 1.0e-10) & (np.abs(xyz[:, 0] - 0.5) < 0.2) + & (np.abs(xyz[:, 2]) < 1.0e-10)) + u_l = np.asarray(layer.u.array).reshape(-1, 3)[layer_on_fault] + pos_l = np.round(xyz[layer_on_fault, 0], 10) + spread_l = {p: np.ptp(u_l[pos_l == p, 0]) for p in np.unique(pos_l)} + spread_s = {p: np.ptp(s_sheet[on_fault][positions == p, 0]) for p in np.unique(positions)} + common = sorted(set(spread_l) & set(spread_s)) + assert len(common) >= 4 + slip_l = np.array([spread_l[p] for p in common]) + slip_s = np.array([spread_s[p] for p in common]) + assert np.linalg.norm(slip_l - slip_s) < 0.05 * np.linalg.norm(slip_s), ( + f"slip thick {slip_l} thin {slip_s}") + + +# tier C: one model against another at one thickness, a characterisation; +# the contract is agreement to first order in L +@pytest.mark.tier_c +def test_dragged_shell_thin_and_thick_agree_on_the_sphere(): + """The same model on the sphere: a sheet on the unit-sphere manifold and the + shell extruded radially from it, dragged by a base in the toroidal flow + rhat x grad(z^2). The difference is first order in L — the curvature the + thin sheet neglects (the base has 1 - L/R of the mid-surface's area) and the + drag-driven shear c L / eta both scale with L. Measured 2026-10-12 + (h = 0.15, two layers): 3.32 % at L = 0.04, 1.35 % at L = 0.02.""" + sphere = uw.meshing.SphericalManifold(radius=1.0, cellSize=0.15, degree=1, qdegree=3) + + def _shell_solver_on(mesh, Solver): + solver = Solver(mesh) + solver.constitutive_model = uw.constitutive_models.ViscousFlowModel + solver.constitutive_model.Parameters.shear_viscosity_0 = 1 + x, y, z = mesh.X + solver.add_basal_drag(1, "Base", reference_velocity=[2 * y * z, -2 * x * z, 0]) + solver.tolerance = 1.0e-5 + return solver + + thickness = 0.02 + sheet = _shell_solver_on(sphere, uw.systems.ThinSheet) + sheet.thickness = thickness + sheet.solve() + shell = _shell_solver_on(sphere.extrude(thickness, layers=2), uw.systems.Stokes) + shell.solve() + pts = sheet.u.coords + u_sheet = np.asarray(sheet.u.array).reshape(-1, 3) + u_shell = np.asarray(uw.function.evaluate(shell.u.sym, pts)).reshape(-1, 3) + error = np.linalg.norm(u_shell - u_sheet) / np.linalg.norm(u_sheet) + assert error < 2.5e-2, f"relative difference {error:.3e}" diff --git a/tests/test_1084_quadratic_stress_sphere.py b/tests/test_1084_quadratic_stress_sphere.py new file mode 100644 index 000000000..852fc09e9 --- /dev/null +++ b/tests/test_1084_quadratic_stress_sphere.py @@ -0,0 +1,77 @@ +"""The quadratic (normal-stress) benchmark of a corotational Maxwell sheet on +the sphere: a divergence-free flow thins the sheet at second order. + +An unfaulted Maxwell sheet (eta = mu = L = 1) on the unit sphere, dragged +(c = 4) by the toroidal force 4 A rhat x grad_s(z^2). At first order the flow is +the zonal toroidal field u_1 = (A/2) rhat x grad_s(z^2), divergence-free: the +sheet neither thins nor thickens. At second order the corotational (Jaumann) +rate turns the shear stress into normal stress differences — the Weissenberg +effect of a Maxwell fluid — whose divergence drives a poloidal flow, and the +sheet thins. The steady second-order perturbation (sympy, derived 2026-10-12; +the derivation is kept with the campaign, outside this repository) gives + + -div_s u / A^2 = -3 (65 z^4 - 126 z^2 + 29) / 533 + = (24/91) P_2(z) - (24/287) P_4(z), + +with no P_0 (the sheet's mean thickness is conserved) and no odd degrees (the +forcing is symmetric about the equator). The linear (A^1) problem carries no +thinning at all, so the benchmark isolates the objective rate. + +ETD-1 carries the relaxation of the corotational term with an effective time +lambda' = alpha dt / (1 - alpha), alpha = exp(-dt / lambda), in steady state +(lambda' = 0.903 lambda at dt = 0.2), and the second-order thinning is linear in +lambda, so the discrete steady state is the closed form times lambda' / lambda. + +Measured (A = 0.1, steady by t = 4), P_2 / A^2 against (24/91) lambda': +h = 0.2, dt = 0.2: -0.65 %; h = 0.12, dt = 0.1: -0.18 %. P_4 / A^2 against +-(24/287) lambda': +14 % at h = 0.2, +5.6 % at h = 0.12 (second order in h). +""" + +import numpy as np +import pytest +import sympy + +import underworld3 as uw + +pytestmark = [pytest.mark.level_3] + + +# tier C for its cost (about 7 min): reported, not gated, as test_1061 and +# test_1064 are; it was shown to fail on its bug and would be tier B otherwise +@pytest.mark.tier_c +def test_quadratic_thinning_matches_the_second_order_closed_form(): + A, h, dt, steps = 0.1, 0.2, 0.2, 20 + sphere = uw.meshing.SphericalManifold(radius=1.0, cellSize=h, degree=1, qdegree=5) + x, y, z = sphere.X[0], sphere.X[1], sphere.X[2] + sheet = uw.systems.ThinSheet(sphere) + sheet.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + sheet.Unknowns, order=1, integrator="etd", objective_rate="jaumann") + sheet.constitutive_model.Parameters.shear_viscosity_0 = 1 + sheet.constitutive_model.Parameters.shear_modulus = 1 + sheet.add_basal_drag(4, "Base") + sheet.bodyforce = list(4 * sympy.nsimplify(A) * sympy.Matrix([[2 * y * z, -2 * x * z, 0]])) + sheet.tolerance = 1.0e-9 + sheet.consistent_jacobian = True + for _ in range(steps): + sheet.solve(timestep=dt, zero_init_guess=False) + + thinning = uw.discretisation.MeshVariable("thinning", sphere, 1, degree=1) + projection = uw.systems.Projection(sphere, thinning) + projection.uw_function = -sphere.vector.divergence(sheet.u.sym) + projection.smoothing = 0.0 + projection.solve() + coefficients = [] + for l in range(5): + P_l = sympy.legendre(l, z) + num = uw.maths.Integral(sphere, thinning.sym[0, 0] * P_l).evaluate() + den = uw.maths.Integral(sphere, P_l * P_l).evaluate() + coefficients.append(num / den / A**2) + c0, c1, c2, c3, c4 = coefficients + + alpha = np.exp(-dt) + lam = alpha * dt / (1 - alpha) + p2, p4 = 24 / 91 * lam, -24 / 287 * lam + assert abs(c2 - p2) < 0.02 * abs(p2), f"P2/A^2 {c2:.5f}, closed form {p2:.5f}" + assert abs(c4 - p4) < 0.25 * abs(p4), f"P4/A^2 {c4:.5f}, closed form {p4:.5f}" + for name, c in (("P0", c0), ("P1", c1), ("P3", c3)): + assert abs(c) < 2.0e-3 * abs(c2), f"{name}/A^2 {c:.3e} against P2 {c2:.3e}" diff --git a/tests/test_1085_build_rotation_setup.py b/tests/test_1085_build_rotation_setup.py new file mode 100644 index 000000000..856d30dc0 --- /dev/null +++ b/tests/test_1085_build_rotation_setup.py @@ -0,0 +1,31 @@ +"""``build_rotation`` reads the solver's Jacobian for the operator's row layout. +``_build()`` sets the SNES up only on some paths (with essential boundary +conditions registered it ends up set up; without them the Jacobian is never +created), so ``build_rotation`` must set the SNES up itself rather than +dereference a null matrix. Before the guard: a segmentation fault (found +2026-10-13 building a rotation for a drag-only faulted layer).""" + +import numpy as np +import pytest + +import underworld3 as uw + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_b] + + +def test_build_rotation_on_a_built_solver_without_essential_bcs(): + from underworld3.utilities.rotated_bc import build_rotation + + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.1, qdegree=2) + split = mesh.add_fault([("Flt", np.array([[0.25, 0.5], [0.75, 0.5]]))]) + stokes = uw.systems.Stokes(split) + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = 1 + stokes.add_basal_drag(1, "Bottom") # natural condition only + stokes.add_fault_bc(0, "Flt") + stokes._build() + Q, Qt, normal_rows, _ = build_rotation(stokes, stokes._rotated_freeslip_bcs) + n = stokes.snes.getJacobian()[0].getSize()[0] + assert Q.getSize() == (n, n) + assert len(normal_rows) > 0 diff --git a/tests/test_1087_fault_split_multigrid.py b/tests/test_1087_fault_split_multigrid.py new file mode 100644 index 000000000..7ce98ad79 --- /dev/null +++ b/tests/test_1087_fault_split_multigrid.py @@ -0,0 +1,169 @@ +"""Split-level multigrid for split-node faults (``utilities.fault_mg``). + +``mesh.add_fault`` on an add-on mesh carries the fault through every add-on +level of the inherited multigrid tail and leaves the static background +untouched. The coarse spaces then contain the discontinuity, and the velocity +solve stays mesh-independent as the fault is refined — measured on this box +(2026-10-13): 6, 5 and 4 velocity iterations at k = 2, 3, 4 add-on levels +against 13, 18 and 23 with the welded tail, the peak slip identical to six +figures. Design note: ``docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md``. +""" + +import numpy as np +import pytest + +import underworld3 as uw + +pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] + +TIP_A, TIP_B = np.array([0.30, 0.45]), np.array([0.70, 0.55]) +H_BASE = 0.05 + + +@pytest.fixture(scope="module") +def base(): + return uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.1, refinement=1, + qdegree=2) + + +def _adapted(base, k, centre=(0.5, 0.5)): + """The add-on mesh of ``k`` levels toward the fault, and the fault trace.""" + half = (TIP_B - TIP_A) / 2 + a, b = np.asarray(centre) - half, np.asarray(centre) + half + pts = np.column_stack([np.linspace(a[0], b[0], 9), np.linspace(a[1], b[1], 9), + np.zeros(9)]) + guide = uw.meshing.Surface(f"guide_{k}_{centre[0]:.2f}", base, pts) + guide.discretize() + child = base.adapt(guide.refinement_metric_function( + h_near=H_BASE / 2**k, h_far=H_BASE, width=0.08, profile="linear"), + max_levels=k) + return child, np.vstack([a, b]) + + +def _solve(mesh, law=0): + x, y = mesh.X + stokes = uw.systems.Stokes(mesh) + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = 1 + stokes.tolerance = 1.0e-6 + stokes.petsc_use_pressure_nullspace = True + for side in ("Top", "Bottom", "Left", "Right"): + stokes.add_dirichlet_bc((y - 0.5, 0.0), side) + stokes.add_fault_bc(law, "Flt") + stokes.solve() + info = stokes._rotated_freeslip_info + from underworld3.utilities.fault_contact import fault_slip + _s, V, _leak = fault_slip(stokes, "Flt", info) + return info, float(np.abs(V).max()) + + +def test_the_add_on_levels_are_split_and_the_background_is_not(base): + from underworld3.utilities.fault_mg import is_split_level + + child, trace = _adapted(base, 2) + mesh = child.add_fault([("Flt", trace)]) + tail = mesh._custom_mg_coarse_meshes + background = base._coarse_level_meshes() + assert len(tail) == len(background) + 1 + # the static background levels are the base's own wraps, untouched + for level, wrap in zip(tail, background): + assert level is wrap and not is_split_level(level) + # every add-on level and the finest carry the fault's pairing + assert all(is_split_level(level) for level in tail[len(background):]) + assert is_split_level(mesh) + # the welded tail is still available + welded = child.add_fault([("Flt", trace)], split_levels=False) + assert not any(is_split_level(level) for level in welded._custom_mg_coarse_meshes) + + +def test_iterations_fall_with_refinement_and_the_slip_is_unchanged(base): + its, slips = {}, {} + for k in (2, 3): + child, trace = _adapted(base, k) + info, slip = _solve(child.add_fault([("Flt", trace)])) + assert info["velocity_pc"] == "custom-FMG" and info["converged"], info + its[k] = info["vel_its_last"][-1] + slips[k] = slip + # measured 5 and 5 (2026-10-28); the welded tail takes 13 at k = 3 and + # grows with k, so the bound tells the split levels from the welded tail + assert its[2] <= 8 and its[3] <= 8, its + assert its[3] <= its[2] + 1, its + # the welded tail solves the same problem: the same slip + child, trace = _adapted(base, 3) + _info_w, slip_w = _solve(child.add_fault([("Flt", trace)], split_levels=False)) + assert abs(slip_w - slips[3]) < 1.0e-4 * slip_w, (slip_w, slips[3]) + + +def test_a_viscous_fault_solves_in_as_few_iterations(base): + child, trace = _adapted(base, 3) + info, slip = _solve(child.add_fault([("Flt", trace)]), law=1.0) + assert info["converged"] and info["vel_its_last"][-1] <= 8, info["vel_its_last"] + assert slip > 0.1 + + +def test_a_moving_fault_re_splits_the_levels_at_every_position(base): + for cx in (0.35, 0.5, 0.65): + child, trace = _adapted(base, 2, centre=(cx, 0.5)) + mesh = child.add_fault([("Flt", trace)]) + info, slip = _solve(mesh) + assert info["converged"] and info["vel_its_last"][-1] <= 8, (cx, info["vel_its_last"]) + assert slip > 0.1, (cx, slip) + + +def test_the_cell_located_transfer_keeps_each_side_on_its_own_side(base): + """A coarse field equal to +1 on one side of the fault and -1 on the other + prolongs to the same values on the fine level, at the slit nodes included: a + node on the slit interpolates from its own side only. The side of a node is + that of a cell listing it — a slit node is listed only by its own side's + cells — so no pairing orientation is assumed. Nodes within two coarse cells of + a tip are left out (the tip vertex is shared by both sides; measured, the + disagreement reaches 0.033 from a tip on this hierarchy). And a quadratic + field is reproduced exactly between the split levels.""" + from underworld3.utilities.fault_mg import cell_located_prolongation + + child, trace = _adapted(base, 2) + mesh = child.add_fault([("Flt", trace)]) + coarse = mesh._custom_mg_coarse_meshes[-1] + assert getattr(coarse, "_fault_point_pairs", None) + degree, continuous = 2, True + P = cell_located_prolongation(coarse, mesh, degree, continuous) + Xc = np.asarray(coarse._get_coords_for_basis(degree, continuous)) + Xf = np.asarray(mesh._get_coords_for_basis(degree, continuous)) + assert P.shape == (Xf.shape[0], Xc.shape[0]) + # partition of unity, bounded weights (the clamp) + assert np.abs(np.asarray(P.sum(axis=1)).ravel() - 1.0).max() < 1.0e-10 + assert np.abs(P.data).max() <= 1.0 + 1.0e-10 + + a, b = trace + n = np.array([-(b - a)[1], (b - a)[0]]) + n /= np.linalg.norm(n) + + def side_by_cell(m, X): + cn = np.asarray(m._cell_node_indices(degree, continuous)) + cen = X[cn].mean(axis=1) # P2 node centroid = cell centroid + side = np.zeros(X.shape[0]) + for k in range(cn.shape[0]): + side[cn[k]] = np.sign((cen[k] - a) @ n) + return side + + fc = side_by_cell(coarse, Xc) + ff = P @ fc + expected = side_by_cell(mesh, Xf) + away_from_tips = np.minimum(np.linalg.norm(Xf - a, axis=1), + np.linalg.norm(Xf - b, axis=1)) > 0.05 + on_slit = np.abs((Xf - a) @ n) < 1.0e-10 + assert (on_slit & away_from_tips).sum() > 20 + assert np.abs(ff - expected)[away_from_tips].max() < 1.0e-10 + + # the levels are nested away from the trace (one adapt call's bisections), + # so a quadratic is reproduced exactly there; within a coarse cell or two of + # the trace each cut pulls vertices onto it, the levels are not nested, and + # the clamp bounds the weights at the cost of exactness (measured: 0.018 at + # worst, 3e-4 at 0.03 from the trace, 3e-14 beyond 0.04) + q = lambda X: (1.0 + 2.0 * X[:, 0] - X[:, 1] + 3.0 * X[:, 0]**2 - X[:, 0] * X[:, 1] + + 0.5 * X[:, 1]**2) + err = np.abs(P @ q(Xc) - q(Xf)) + off_trace = np.abs((Xf - a) @ n) > 0.05 + assert err[off_trace].max() < 1.0e-9 + assert err.max() < 0.05 diff --git a/tests/test_1091_place_outcrop_curved_wall.py b/tests/test_1091_place_outcrop_curved_wall.py new file mode 100644 index 000000000..818aada2c --- /dev/null +++ b/tests/test_1091_place_outcrop_curved_wall.py @@ -0,0 +1,123 @@ +"""A ribbon placed into an annulus: every cell size, and a DIPPING band that +outcrops through the curved wall (the Zhong & Gurnis trench, 2026-10-14). + +Two placer defects, each with the probe that found it: + +* the domain tool's boundary loops arrived with whichever winding the walk + started on; a hole loop wound against the exterior was ADDED by OCC, the + tool had the area of the full disc plus the hole, and the ribbon's + intersection with it was empty ("meshed to no triangles") — for cell sizes + 0.04 and 0.08 but not 0.05 or 0.06, which happened to wind both loops the + same way; +* the 2-D outcrop frame read every chord of the faceted wall as its own + wall, so a dipping band's clearance cavity, spanning a few chords beside + the one it crosses, was refused as contact "away from the outcrop"; and + the splice then chorded across the wall's own vertices, losing the sagitta + the area guard refuses. The frame now groups chords into smooth walls + (the 3-D rule) and the splice keeps the surviving wall vertices. +""" +import numpy as np +import pytest + +import underworld3 as uw +from underworld3.utilities.place_surface import place_thin_volume + +pytestmark = [pytest.mark.level_2, pytest.mark.tier_b, + pytest.mark.skipif(uw.mpi.size > 1, reason="serial suite")] + + +def _census(new, zone_value): + fS, fE = new.getHeightStratum(1) + lower, upper, trace = (new.getLabel(n) for n in ("Lower", "Upper", "Zone_trace")) + n_trace = n_bare = 0 + for f in range(fS, fE): + if len(new.getSupport(f)) != 1: + continue + if lower.getValue(f) < 0 and upper.getValue(f) < 0: + n_bare += 1 + if trace.getValue(f) == zone_value: + n_trace += 1 + return n_trace, n_bare + + +def _dipping(theta, top_r, length, dip=np.deg2rad(45.0)): + t_hat = np.array([np.cos(theta), np.sin(theta)]) + s_hat = np.array([-np.sin(theta), np.cos(theta)]) + d_hat = -np.cos(dip) * t_hat + np.sin(dip) * s_hat + return np.array([t_hat * top_r, t_hat * top_r + d_hat * length]) + + +@pytest.mark.parametrize("cell_size", [0.04, 0.05, 0.06]) +def test_an_interior_ribbon_places_at_every_cell_size(cell_size): + from underworld3.utilities.line_cut import cell_areas + + base = uw.meshing.Annulus(radiusOuter=1.0, radiusInner=0.5, cellSize=cell_size, qdegree=2) + zv = base._boundaries_with("Zone")["Zone"].value + ray = np.array([np.cos(0.4), np.sin(0.4)]) + before = float(cell_areas(base.dm).sum()) + new, info = place_thin_volume(base.dm, [np.array([0.62 * ray, 0.88 * ray])], + width=0.03, label="Zone", label_value=zv) + assert info["n_zone_cells"] > 0 + assert float(cell_areas(new).sum()) == pytest.approx(before, rel=1e-12) + + +def test_a_dipping_band_outcrops_through_the_curved_wall(): + from underworld3.utilities.line_cut import cell_areas + + base = uw.meshing.Annulus(radiusOuter=1.0, radiusInner=0.55, cellSize=0.04, + refinement=1, qdegree=2) + zv = base._boundaries_with("Zone")["Zone"].value + before = float(cell_areas(base.dm).sum()) + new, info = place_thin_volume(base.dm, [_dipping(0.0, 1.03, 0.25)], width=0.02, + label="Zone", label_value=zv, size=0.005, clearance=0.7) + n_trace, n_bare = _census(new, zv) + assert info["n_trace_facets"] > 0 and n_trace == info["n_trace_facets"], \ + "the dipping band left no outcrop trace on the wall" + assert n_bare == 0, "the relabel left boundary edges without a wall label" + assert float(cell_areas(new).sum()) == pytest.approx(before, rel=1e-9), \ + "the splice chorded across the wall's vertices" + assert float(cell_areas(new).min()) > 0.0 + + +def test_the_structured_ladder_outcrops_through_a_cap(): + """The ladder (rungs on the spine, one element either side) used to be + refused at a wall outright. It now runs to one rung short of the wall + and a clipped unstructured cap closes it to the outcrop, so a TI band + reaches the surface and a split spine stops on its own rung vertices + below it. Both placement routes: the thin volume, and the ribbon placer + with a trace shorter than its spine.""" + from underworld3.utilities.line_cut import cell_areas + from underworld3.utilities.place_surface import place_fault_ribbon_2d + + S = 0.005 + base = uw.meshing.Annulus(radiusOuter=1.0, radiusInner=0.55, cellSize=0.16, + refinement=3, qdegree=2) + zv = base._boundaries_with("Zone")["Zone"].value + dip = np.deg2rad(45.0) + d_hat = np.array([-np.cos(dip), np.sin(dip)]) + top = np.array([1.02, 0.0]) + n = int(round(0.2 / S)) + spine = np.array([top + d_hat * (0.2 * i / n) for i in range(n + 1)]) + before = float(cell_areas(base.dm).sum()) + new, info = place_thin_volume(base.dm, [spine], width=2 * S, label="Zone", + label_value=zv, size=S, clearance=0.7, mesher="ladder") + n_trace, n_bare = _census(new, zv) + assert info["n_trace_facets"] > 0 and n_trace == info["n_trace_facets"] + assert n_bare == 0 + assert float(cell_areas(new).sum()) == pytest.approx(before, rel=1e-9) + assert float(cell_areas(new).min()) > 0.0 + + r = np.hypot(spine[:, 0], spine[:, 1]) + trace = spine[r <= 1.0 - 3 * S] + mesh, rinfo = place_fault_ribbon_2d(base, [("Trench", trace)], 2 * S, + spines=[("Trench", spine)], margin_rings=1, + clearance=0.7, split=True, mesher="ladder") + pairs = mesh._fault_point_pairs["Trench"] + # interior vertices plus the edges between them (P2 carries edge points) + assert len(pairs) == 2 * len(trace) - 3, "the spine is split on its own rung vertices" + X = np.asarray(mesh.dm.getCoordinatesLocal().array).reshape(-1, 2) + vS, vE = mesh.dm.getDepthStratum(0) + pp = np.array([int(q) for q in pairs] + [int(q) for q in pairs.values()]) + pv = pp[(pp >= vS) & (pp < vE)] - vS # the vertex points of the pairing + assert np.hypot(X[pv, 0], X[pv, 1]).max() < 1.0 - 2 * S, "no pair node near the wall" + assert len(mesh._custom_mg_coarse_meshes) == 4, "the base tail is adopted"