diff --git a/.claude/commands/check-patterns.md b/.claude/commands/check-patterns.md index 8599de483..b958c4c00 100644 --- a/.claude/commands/check-patterns.md +++ b/.claude/commands/check-patterns.md @@ -2,15 +2,24 @@ description: Find deprecated access patterns in code and documentation --- -## Reference Document +## Run the CI scanner first -**Read the authoritative patterns guide first:** -`docs/developer/UW3_Style_and_Patterns_Guide.qmd` +The `src/` pattern list is machine-enforced; the scanner is the single source of +truth for what is banned and what is allowlisted legacy: -Key sections: -- Section 4 "Array and Data Management" - array indexing patterns -- Section 5 "Context Managers" - deprecated vs current access patterns -- Section 6 "MPI and Parallel Patterns" - parallel safety +```bash +pixi run -e amr-dev python scripts/check_deprecated_patterns.py +python scripts/check_deprecated_patterns.py --include-docs # report-only docs inventory +``` + +Policy and pattern definitions: `docs/developer/guides/style-gates.md` +(allowlist shrinks only — never add entries to make a PR pass). + +## Reference Documents + +- `docs/developer/UW3_STYLE_CHARTER.md` — normative rules (§3 naming, §4 comments, §7 data access) +- `docs/developer/subsystems/data-access.md` — governing data-access reference +- `docs/developer/UW3_Style_and_Patterns_Guide.md` — broader style reference --- diff --git a/.claude/skills/adapt-on-top-faults/SKILL.md b/.claude/skills/adapt-on-top-faults/SKILL.md new file mode 100644 index 000000000..9fec8795a --- /dev/null +++ b/.claude/skills/adapt-on-top-faults/SKILL.md @@ -0,0 +1,369 @@ +--- +name: adapt-on-top-faults +description: Recipe for Underworld3 FAULT models on an NVB adapt-on-top mesh — resolve a fault Surface by LOCAL refinement (mesh.adapt(metric, max_levels=...) returns a child; NVB is the 2D default engine), drive it from the fault's EXACT signed distance, use rotated strong free-slip (composes with transverse-isotropy where Nitsche does not), recover dynamic topography from the constraint reaction, and run advection-diffusion on the adapted mesh with field transfer across re-adaptation. Reach for THIS for instantaneous/coupled fault-flow problems. For MMPDE node-movement convection use the `adaptive-meshing` skill instead; for rendering use `uw-visualisation`. +--- + +# adapt-on-top-faults + +The validated recipe for **fault problems on a locally-refined (adapt-on-top) mesh**. +Distilled from the annulus fault study (2026-07, `feature/adapt-on-top`). + +**This is the REFINEMENT paradigm**, not the mover one: +- `mesh.adapt(metric, max_levels=...)` bisects the base finest **locally** and returns + a **new child mesh** (`child.parent is mesh`). It is *adapt / re-adapt*, NOT node + movement — non-cumulative (each call re-marks from the static base). The child owns + a custom-P geometric-MG (FMG) tail so solvers on it get multigrid for free. +- For **MMPDE / equidistribution node movement** (deforming the same mesh to a field) + use the **`adaptive-meshing`** skill instead. Different tool; don't mix them up. + +Reference implementations (copy from these — all run np1/2/4): +`~/+Simulations/nvb_parallel_fault_study/` (weak-fault Stokes + FMG), +`~/+Simulations/shear_box_fault_study/` (iso vs TI, orientation sweeps), +`~/+Simulations/annulus_fault_study/` (rotated free-slip + topography + moving fault + +advection-diffusion). Companion skills: `uw-visualisation`, `adaptive-meshing`. + +--- + +## The core loop (fault → metric → adapt → child) + +```python +import underworld3 as uw, numpy as np, sympy + +# Base mesh MUST be built with refinement>=1 (supplies the coarse MG tail that NVB +# extends). Base cellSize ~ 2x the target h_near is the SWEET SPOT (see gotchas). +base = uw.meshing.Annulus(radiusInner=0.55, radiusOuter=1.0, cellSize=0.08, + refinement=1, qdegree=3) # or UnstructuredSimplexBox(..., refinement=2) + +# fault as a Surface (polyline control points, N x 3 with z=0 in 2D) +fault = uw.meshing.Surface("fault", base, fault_pts, symbol="F") +fault.discretize() + +# METRIC = a CALLABLE built from the fault's EXACT signed distance. This is the key +# to clean, non-patchy grading: it is evaluated at each refined level's centroids, +# so it resolves itself at the new resolution (no P1-field aliasing). +metric = fault.refinement_metric_function(h_near=0.02, h_far=0.08, width=0.05, + profile="linear") +child = base.adapt(metric, max_levels=3) # -> graded child (NVB is the 2D default) +``` + +- NVB (the 2D default engine) = graded newest-vertex bisection (bounded closure, + parallel via the native `uwnvb` transform; bit-confluent serial↔parallel); + `engine=` is the advanced selector. `engine="sbr"` = uniform + patch (the default; not graded). NVB is 2D only for now. +- `max_levels` is the isotropic-equivalent depth (NVB runs `2*max_levels` bisection + passes). The metric shape decides the grading; `max_levels` just caps it. + +### Metric options (all accepted by `adapt`) +1. **callable** `metric(centroids)->M` — **preferred for faults**. Evaluated per level. +2. MeshVariable / sympy expression — sampled via `uw.function.evaluate` from the BASE + mesh → a peaked `M=1/h²` aliases → *patchy* levels. Avoid for thin features. + +**Custom refinement shape** — pass any callable. For a *fat, uniformly-fine* band +(not just a thin line at the fault), a flat-core metric: +```python +def metric(pts, _f=fault): + d = _f.unsigned_distance(pts) # EXACT distance at arbitrary points + core, ramp, hn, hf = 0.02, 0.05, 0.01, 0.08 + h = np.where(d < core, hn, np.minimum(hn + (hf-hn)*(d-core)/ramp, hf)) + return 1.0 / h**2 +``` + +`Surface` distance API (all exact, arbitrary query points): +`fault.signed_distance(coords)`, `fault.unsigned_distance(coords)`, +`fault.director` (unit normal = normalised ∇(signed distance); the TI weak-plane +director), `fault.refinement_metric_function(...)`. + +--- + +## Engines: `nvb` vs `edge_split` — and what runs in parallel + +`engine="nvb"` (default) is graded newest-vertex bisection: bounded conforming +closure, a similarity-class bound that keeps child quality tied to the base, and +**partition-independent** output. `engine="edge_split"` splits the **longest edge** +of every cell coarser than the metric asks for and needs **no conforming closure +at all**, because splitting an edge divides every incident cell at the same new +vertex. Consequences: + +- refinement **cannot escape the marked region** — the band hugs the feature + instead of a halo around it; +- it marks on the cell **DIAMETER**, not `(dim!·vol)^(1/dim)`. The volume proxy + reported the target met while the mesh was **3.2× coarser** across the feature; +- it gives up the similarity-class bound, so quality at depth is not guaranteed + the way bisection's is — that is what `repair=` and `relax()` are for. + +**Both run in parallel, 2-D and 3-D, and both are bit-confluent** (identical mesh +at any communicator size). `edge_split` drives the same compiled `uwnvb_bisect` +transform as NVB, so it inherits star-forest propagation, co-partitioning, labels +and coordinates. Verified at np=1/2/3/4 up to 56k cells. + +```python +child = base.adapt(metric, max_levels=3, engine="edge_split") +child = base.adapt(metric, max_levels=3, engine="edge_split", repair=True) +``` + +`repair=True` runs a **reconnection (Lawson flip) pass** after each generation — +2-D and `edge_split` only; it raises rather than silently doing nothing otherwise. +It gates on **reducing the largest angle**, NOT on Delaunay: Delaunay maximises the +*minimum* angle while P1 interpolation depends on the *maximum* (Babuška–Aziz), and +flipping a gmsh mesh toward Delaunay was measured to RAISE the 99th-percentile max +angle 126.8° → 129.3°. gmsh optimises shape, not the empty-circle property. + +- **worth it on a POOR base** — anisotropic, graded, relaxed, or read from a file: + 99th-pct max angle 156° → 115°, slivers below q=0.1 3.84 % → 0.00 %. On a clean + gmsh base it moves 124.7° → 120.5° and the error not at all. +- ⚠️ **it gives up bit-confluence.** Which cavities may be flipped depends on where + the partitioner cut (no cavity may contain a cell incident on a shared point). + Conformity, orientation, volume, labels and the SF stay exact at every rank + count; only the choice of flips near a seam differs. Hence opt-in. +- Seam cost is small and **shrinks with resolution**: frozen repair sites 0.9–3.5 % + at 56k cells, np=2..8, halving with every halving of the target size. In a fault + band specifically, 5.5 % at np=2 and 13 % at np=4 on a 4k-cell mesh. +- ⚠️ the 99th-pct angle recovers under a frozen seam but the **absolute max does + not** — a few worst cells sit on the seam (148° vs 123° serial). +- it invalidates the cell-parent map for the any-degree MG transfer (a flipped + cell can straddle two coarse cells), so degree ≥ 2 falls back to the geometric + prolongation builder. The exact vertex prolongation survives — flips move no + vertex. + +## Relaxing an adapted fault mesh — PIN THE BAND + +`child.relax()` on a mesh refined onto an interface **makes things worse**. The +MMPDE mover optimises element shape against an equilateral reference and knows +nothing about where the material changes, so it slides the small cells that +refinement placed on the interface *off* it. Measured on a step-edged fault: +manufactured stress across the interface **+77 %**, and it stopped being confined +to the fault. Counter-intuitively it *reduces* the number of straddling cells +(1343 → 965) and is still worse, because the survivors are bigger. + +```python +child.relax(pin_bands=[fault]) # interface = the surface itself +child.relax(pin_bands=[(fault, 0.02)], pin_halo=2) # weak zone of half-width 0.02 +``` + +Leak unchanged to five decimal places (0.03075 → 0.03076), confinement preserved, +straddling count identical — while the mover still reshapes the rest of the +domain. `pin_halo` (default 1) pins extra rings; pinning only the cut cells lets +the mover pull on them from outside. `pin_bands` **merges** with the auto-pinned +boundaries, so it cannot silently release the domain edge. + +## Fault as a constitutive weak zone (iso and TI) + +The metric only needs the fault GEOMETRY (pure distance). The constitutive weak zone +needs the fault's distance/normal ON THE CHILD. Two ways: + +```python +# (A) re-home the SAME fault onto the child (cleanest; distance recomputes on child) +fault.remap_to(child) +eta = fault.influence_function(width=0.04, value_near=1e-3, value_far=1.0, + profile="smoothstep") # isotropic weak zone + +# (B) or build a child-side Surface (needed if the base fault is still in use for a +# base-mesh metric — symbol disambiguation refuses a base-mesh symbol in a child +# solver). fault_c = uw.meshing.Surface("fault_c", child, fault_pts); fault_c.discretize() +``` + +Isotropic weak zone: +```python +stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel +stokes.constitutive_model.Parameters.shear_viscosity_0 = eta # drops near fault +``` + +Transverse-isotropic weak PLANE (the physical fault; low fault-parallel shear): +```python +stokes.constitutive_model = uw.constitutive_models.TransverseIsotropicFlowModel +stokes.constitutive_model.Parameters.shear_viscosity_0 = eta_bulk # normal viscosity (constant) +stokes.constitutive_model.Parameters.shear_viscosity_1 = eta # weak near fault -> bulk far +stokes.constitutive_model.Parameters.director = fault.director # unit fault normal +``` +The **TI Jacobian is the full consistent tangent** (not isotropic + defect +correction — that framing is WRONG). The TI velocity FMG V-cycle needs a few more +iters than iso (~8 vs ~2) because of a directional near-null mode the isotropic +point-smoother doesn't damp — bounded and contrast-independent, not a bug. TI needs +~2 elements across the weak zone to resolve (iso ~1). + +--- + +## Rotated strong free-slip (the reason to use this, not Nitsche) + +Nitsche free-slip is INCOMPATIBLE with the TI model (its penalty scales by an +isotropic viscosity). Rotated strong free-slip imposes `u·n̂=0` as an ESSENTIAL +constraint in a per-node (n,t) frame → machine-zero leakage AND composes with TI. + +```python +# normal=None (the default) is measure-weighted and consistent with the assembly — +# prefer it. An analytic nhat is exact for the TRUE circle but keeps a consistency +# error against the faceted integral (#560); use it only when the constraint must +# follow the geometry rather than the mesh. +nhat = mesh.CoordinateSystem.unit_e_0 # exact radial normal (annulus/sphere) +stokes.add_rotated_freeslip_bc(0, "Upper", normal=nhat) +stokes.add_rotated_freeslip_bc(0, "Lower", normal=nhat) +stokes.petsc_use_pressure_nullspace = True # enclosed -> pressure gauge +stokes.solve() # rigid-rotation gauge auto-removed +# Convergence status: read stokes._rotated_freeslip_info = {ksp_reason, +# nonlinear_iterations, rotation_gauge_removed, reaction} — NOT s.snes.getConvergedReason() +# (the rotated solve is a manual loop, not snes.solve). Also sanity-check v·n leakage (~1e-16). +``` + +**Nonlinear rheology / warm-start / timestepping (PR #298, `feature/rotated-snes`):** +rotated free-slip now works *inside* the nonlinear iteration — a nonlinearity probe +auto-dispatches power-law / VEP / TI-with-yield models to a Newton/Picard loop +(`solve_rotated_freeslip_nonlinear`), so warm-started time loops are correct. On the +worktree state *before* #298 lands, the rotated path is a SINGLE linear solve — it +silently returns one Newton linearisation from `u=0` for a nonlinear model. If you +run nonlinear TI + timestepping with rotated free-slip, make sure #298 is in. + +**Dynamic topography** from the constraint reaction (the reason to bother): +```python +h = uw.discretisation.MeshVariable("h", mesh, 1, degree=1) +stokes.dynamic_topography("Upper", h, buoyancy_scale=rho_g) # h = -(σ_nn - mean)/ρg +xs, sig = stokes.boundary_normal_traction("Upper") # or the raw σ_nn (lumped-mass) +``` + +--- + +## FMG under rotated free-slip + +`rotated_bc.solve_rotated_freeslip` builds its OWN fieldsplit KSP, but it resolves +the multigrid hierarchy through the same `custom_mg.build_transfers` rule as the +standard path: an explicit `set_custom_fmg` registration wins, otherwise a +mesh-owned adapt tail is picked up opportunistically. So: +- On an `adapt()` **child**, the mesh-owned custom-P tail is auto-picked-up for the + velocity block → FMG for free, **rotated free-slip included** (the old + unreachability — rotated solves silently falling back to GAMG on adapt + children — was #467, fixed). +- On a plain **refined `Annulus`** with **rotated** free-slip, the native + `dm_hierarchy` is still not read; to get FMG you must build a coarse-mesh tail + and call: + ```python + from underworld3.utilities.custom_mg import set_custom_fmg + set_custom_fmg(stokes, [Annulus(cs=0.16), Annulus(cs=0.08)], field_id=0) # velocity block + ``` + (~4 velocity iters vs ~26 GAMG). Otherwise it falls back to GAMG (fine, just slower). + +The default velocity-block preconditioner and the pressure Schur (`1/η`) are already +near-optimal for TI — do **not** hand-roll a "TI-aware Schur"; measured, the default +`1/η₀` beats every alternative (the weak TI mode is fault-parallel shear, which is +volume-preserving, so pressure sees η₀). + +--- + +## Moving fault + re-adaptation + field transfer + +`adapt()` is non-cumulative and deterministic. Move the fault, re-adapt from the +static base, carry any field by interpolation: + +```python +for step in range(N): + fault_pts = move(fault_pts, step) # kinematics + fault = uw.meshing.Surface(f"fault{step}", base, fault_pts, symbol="F"); fault.discretize() + child = base.adapt(fault.refinement_metric_function(...), max_levels=3) + # verify: folded=0 (all cell |vol|>0), base unchanged (non-cumulative) + # carry a field child_{k-1} -> child_k by interpolation: + T = uw.discretisation.MeshVariable(f"T{step}", child, 1, degree=1) + if T_prev is None: + T.data[:,0] = uw.function.evaluate(T0_expr, T.coords) + else: + T.data[:,0] = uw.function.evaluate(T_prev.sym, T.coords) # mesh->mesh interp + T_prev = T +``` +Transfer error is **non-accumulating** for a smooth field (bounded by the per-mesh P1 +representation floor, ~0.2% on a fine base, ~3% on a coarse base) — repeated +re-meshing does not diffuse a smooth field away. Sharp fronts lose more per transfer. + +--- + +## Advection-diffusion on the adapted mesh + +```python +T = uw.discretisation.MeshVariable("T", child, 1, degree=2) +adv = uw.systems.AdvDiffusionSLCN(child, u_Field=T, V_fn=stokes.u.sym, order=1, + monotone_mode="clamp") # clamp = bounded, no overshoot +adv.constitutive_model = uw.constitutive_models.DiffusionModel +adv.constitutive_model.Parameters.diffusivity = 2e-4 +adv.f = 0.0 +dt = 0.015 # FIXED dt — SLCN is semi-Lagrangian (unconditionally + # stable). estimate_dt() reports the tiny fault-band + # Courant limit; do NOT use it to size the step. +for step in range(nsteps): + adv.solve(timestep=dt) +``` +- **SLCN dt is NOT Courant-limited** by the fine fault-band cells — pick dt from the + coarse-region advection, verify accuracy. +- The scalar AD auto-FMG-injection bug (velocity-block custom-P mismatching a scalar + operator on adapt children → PtAP error 60) is **FIXED**: `auto_inject_custom_mg` + now calls `snes.setUp()` before reading the finest reduced map (so the DM section + is the finalized space the operator lives on) and validates that map against the + assembled operator. Custom-P installs successfully on scalar SLCN AdvDiffusion on + NVB adapt children (no skip-guard, no workaround) — `bugfix/custom-mg-parallel`. + +--- + +## Sizing the band, and how the fault margin is represented + +The artefact that matters for a fault is **stress manufactured by elements that +straddle the weak-zone margin** — high strain rate at one end, high viscosity at +the other. It is exactly + +```python +leak = 2 * (eta.mean(axis=1) * edot.mean(axis=1) - (eta * edot).mean(axis=1)) +``` + +per cell (vertex values), i.e. `−2 Cov(η, ε̇)`: **zero** for any cell wholly inside +or wholly outside the weak zone, positive only across the transition. It lives +strictly *inside* elements — plotting nodal `2ηε̇` cannot show it, because at a node +the two fields are sampled at the same point and are consistent by construction. + +Measured guidance, all at matched cell count: + +- **Band width.** The answer depends on what you are minimising, and the two + objectives disagree. *Total* leak: narrower is better (concentrate cells where + ∇η is steepest). Leak **into the matrix** (what usually matters): an optimum at + core half-width ≈ the **influence width**, 2.6× better than a narrow band. + Straddling-cell count: wider is monotonically better. +- **Don't invent a marking rule.** Marking on within-cell η variation is + intuitive and measurably *worse* per DOF than the plain distance size field: + N^-0.37 (absolute jump) or a complete stall (log ratio) against **N^-1.04**. The + leak is spread across the whole transition, not concentrated in a few cells, so + there is nothing for a targeting rule to target. ⚠️ The log ratio is largest + where η is *smallest* — it refines the fault core, the opposite end from the + problem. +- **A step-edged margin confines it.** `influence_function(profile="step")` (the + DEFAULT profile) plus marking on the distance level set puts essentially **0 %** + of the leak beyond d=0.03, against 11.4 % for a smooth blend, and converges + slightly faster (N^-1.32). The price: total leak 2.5× higher and the **worst + single cell 20× worse** (21.4 vs 1.04) — concentrated into a one-cell collar + welded to the interface rather than spread. For a viscous solve that is a clear + win; for a yielding model the worst cell is what reaches yield first, so weigh + it. Mark geometrically on the level set: once the edge is sharp, sampled η + depends on which side a vertex happens to fall. +- **Exact fixes.** An element-wise constant (P0) viscosity makes `Cov(η, ε̇) ≡ 0` + on any mesh — not reduced, zero. So does aligning the interface with element + boundaries — and there are now primitives that do exactly that: `place_sheet` / + `place_thin_volume` / `remove_embedded` in `utilities/place_surface.py` + (#517–#526). Both fixes move the error from *inside* elements to *where the + element boundaries fall*, which makes `relax(pin_bands=...)` the lever rather + than shape repair. ⚠️ P0 also breaks any within-cell marking rule (contrast is + identically zero) — it would have to be reposed on the facet jump. + +## Gotchas / rough edges (candidates to fix as we go) + +| symptom / edge | cause & handling | +|---|---| +| patchy along-fault refinement (level 4 here, 2 there) | P1-interpolated `M=1/h²` aliasing. Use the **callable exact-distance** metric. | +| child solver rejects `fault.distance.sym` (foreign-mesh error) | symbol disambiguation. `fault.remap_to(child)` or build a child-side `Surface`. **Rough edge**: needing two Surface objects. | +| adapt added-cell count jitters ±30% step-to-step | small-number geometry on a thin band. Deterministic; quality (near-fault h) is constant. Base ≈ **2× target** minimises it (CoV ~10% at 0.08 base vs 19% at 0.14, 24% at 0.05). | +| tiny AD timestep / slow advection | `estimate_dt` returns the fault-band Courant limit. Use a fixed dt (SLCN is unconditionally stable). **Rough edge**: `estimate_dt` not adapt-aware. | +| surface-breaking fault: huge local vmax; topo peak keeps growing | real stress singularity at the outcrop. Topo peak **saturates** (integrable/log, bounded by finite buoyancy) — far field is fine; only the pointwise outcrop value is mesh-dependent. | +| rotated free-slip Stokes "not converged" | `s.snes` isn't the solving object (manual loop). Read `stokes._rotated_freeslip_info['ksp_reason']` / `['nonlinear_iterations']` (PR #298); sanity-check v·n leakage. | +| nonlinear TI/VEP + rotated free-slip + timestepping gives a wrong (frozen) answer | pre-#298 the rotated path is ONE linear solve (one Newton step from u=0). PR #298 runs it inside a Newton/Picard loop — ensure it's merged for nonlinear/warm-start runs. | +| FMG under rotated free-slip on a plain (non-adapt) refined mesh | the rotated KSP resolves hierarchies via `custom_mg.build_transfers`: an adapt child's mesh-owned tail is picked up AUTOMATICALLY (#467 fixed the old silent GAMG fallback), but the native `dm_hierarchy` is still not read — on a plain refined mesh, `set_custom_fmg(..., field_id=0)`. | +| NVB at np>1 raises NotImplementedError | native `_nvb_transform` extension not built (needs the custom-PETSc/amr env). Both `nvb` and `edge_split` are otherwise fully parallel, 2-D and 3-D. | +| high stress appears in the matrix beside the fault | elements STRADDLING the weak-zone margin: one end sees high strain rate, the other high viscosity. The FE forms `mean(η)·mean(ε̇)`; the honest cell average is `mean(η ε̇)`, and the difference is `−2 Cov(η, ε̇)` across the cell. Zero for any cell wholly in or wholly out. See the band-width section below. | +| scattered 1-level refinement across the WHOLE domain; far-field quality drops | the metric's far clip ``h_far`` sits below the base mesh's cell DIAMETERS (gmsh ``cellSize`` is a target edge length; diameters run 1.2–2.5x it — measured 0.108–0.223 for cellSize 0.18+ref 1). ``edge_split`` marks on diameter, so 100% of the domain refines once and the unrequested bisection DE-CONDITIONS the grid (far-field median q 0.372 -> 0.295, measured). Set ``h_far >= 1.05 * cell_diameters(base.dm).max()``. | +| refinement band narrower than the fault's INFLUENCE | measured: η still 0.07 at d=0.06 while the mesh has already coarsened 4×, so the artefact peaks on the transition flank, not on the fault. **85 % of it sits at d>0.01.** Size the flat core from the *influence* width, not the fault. | +| `uw.function.evaluate` fails "Total components 8 != 6" | cached-interpolation mismatch on a mesh already carrying several solver variables. Sample the field numerically from `surface.unsigned_distance` instead. | +| bare SIGSEGV, no traceback, after building a Mesh from a raw DM | `uw.discretisation.Mesh(dm, ...)` TAKES THE DM OVER. Read geometry from `child.dm`, never the handle you passed in. | +| `KeyError: 'Left'` from a Mesh built on a refined DM | `Mesh(dm)` without `boundaries=` loses the boundary ENUM even though the labels are on the DM. Pass `boundaries=base.boundaries`. | + +**Build/run**: this lives on the `feature/adapt-on-top` worktree; env +`.pixi/envs/amr-dev/bin/{python,mpirun}`; `./uw build` after source changes. diff --git a/.claude/skills/adaptive-meshing/SKILL.md b/.claude/skills/adaptive-meshing/SKILL.md new file mode 100644 index 000000000..c2c98da26 --- /dev/null +++ b/.claude/skills/adaptive-meshing/SKILL.md @@ -0,0 +1,405 @@ +--- +name: adaptive-meshing +description: The canonical, workable recipe for Underworld3 moving-mesh / adaptive-mesh convection (annulus stagnant-lid, faults, free surface). Reach for THIS first when setting up any model with a deforming or adapted mesh — it encodes the combination that does not blow up, tangle, or inject spurious energy, and explains the failure modes so you don't re-derive them. Use before choosing movers, free-slip BCs, restart, or field-transfer options. +--- + +# adaptive-meshing + +The one workable combination for UW3 moving/adaptive-mesh convection, distilled +from many sessions that each re-picked options and stomped on each other's +defaults. **Start from this recipe; change one thing at a time and verify.** + +Reference implementation (current, validated): the **`underworld3.workflows` +adaptive-convection example** — +`docs/examples/workflows/adaptive_convection/` on the `feature/adaptive-convection` +worktree (`config.py`+`simulate.py` no-fault; `fault_config.py`+`fault_simulate.py` +fault; `diagnostics.py`, `render.py`, `compare.py`). Express adaptive runs as a +WORKFLOW (a `WorkflowConfig` + `@workflow_step` DAG + `Run`), NOT a monolithic +driver. The older `scripts/fault_convection_adapt_loop.py` (feature/fault-convection) +is superseded — its ideas are folded into the workflow + this skill. +Companion: the `uw-visualisation` skill for rendering results. + +**Choosing the paradigm:** THIS skill is the **mover** (node movement / +equidistribution, `smooth_mesh_interior`) — the mesh deforms to follow a field. For +**local refinement** instead (`mesh.adapt(...)` returns a refined CHILD; a +fault resolved by a fine band + custom-P FMG + rotated free-slip + dynamic topography ++ advection-diffusion), use the **`adapt-on-top-faults`** skill. Different tools — +don't mix them. For the FMG setup that consumes an adapt child's hierarchy, see the +**`nonlinear-solver`** skill. + +--- + +## PIN THE INTERFACE when you relax a mesh that was refined onto one + +The two operations fight. The mover optimises element **shape** against an +equilateral reference and knows nothing about where the material changes, so it +slides the small cells that refinement placed on an interface *off* it. Measured +on a step-edged fault: manufactured stress across the interface **+77 %**, and it +stopped being confined to the fault. It even *reduces* the number of straddling +cells (1343 → 965) while making things worse, because the survivors are bigger — +leak per straddling cell up 2.5×. + +```python +child.relax(pin_bands=[fault]) # interface = the surface +child.relax(pin_bands=[(fault, 0.02)], pin_halo=2) # weak zone, half-width 0.02 +``` + +Leak unchanged to five decimals, confinement preserved, straddling count identical +— and the mover still reshapes everywhere else. Notes: + +- `pin_halo` (default 1) pins extra rings. Pinning only the cut cells lets the + mover pull on them from outside and drag the pinned ring out of shape anyway. +- `pin_bands` **merges** with `pinned_labels`. Passing `pinned_labels` yourself + REPLACES the default of "pin every named boundary", so a hand-rolled version + that substitutes the band label silently lets the mover deform the domain. +- `mesh.label_interface_band(surface, offset, halo)` is the underlying helper if + you want the label for something else. It uses the SIGNED distance at offset 0 + and the UNSIGNED distance at a non-zero offset — the unsigned distance is never + negative, so a straddle test against it at offset 0 can never fire, and a weak + zone has two margins that the unsigned form catches at once. + +--- + +## Mover quick-start (copy-paste — this is the hard-to-discover bit) + +The user entry is `uw.meshing.node_redistribution(mesh, metric, ...)` (the +purposeful spelling; it dispatches to `mesh.redistribute_nodes`, which drives +the MMPDE mover on 2D simplex meshes — `smooth_mesh_interior` is the +machinery underneath and takes the same kwargs). Minimal correct setup to +adapt a mesh to a field `T` each step: + +```python +import underworld3 as uw + +# metric from |grad T|: refinement=R is a factor on the BACKGROUND spacing h0, +# not a finest:coarsest ratio. The envelope is h in [h0/R, h0*coarsening], and +# coarsening="auto" is R**(1/d) — so R=5 in 2-D spans h0/5 to 2.2*h0, a ratio +# of R**(1+1/d) ~ 11. Use refinement=R, NOT strategy= (caps at ~2, under-grades). +rho = uw.meshing.metric_density_from_gradient( + mesh, T, refinement=5, coarsening="auto", metric_choice="front-following") + +# move the mesh — the mover (Huang-Kamenski MMPDE) is variational, +# non-folding, clusters AND aligns cells. It OWNS field transfer +# (remaps T + SLCN history, fires on_remesh hooks). +uw.meshing.node_redistribution( + mesh, rho, + method_kwargs=dict(step_frac=0.2, accel="cg", momentum=0.0), # mmpde's OWN kwargs + slip_surfaces=True, # boundary nodes slide tangentially (parallel-safe) + skip_threshold=0.9) # skip the move when the mesh is already aligned +``` + +For a **sharp feature (fault)** pass an anisotropic SPD TENSOR metric instead of +the scalar `rho` (thin ACROSS the feature normal n) and bake a gmsh base: + +```python +import sympy +n = sympy.Matrix([nx, ny]) # constant fault-normal unit vector +d = dfac.sym[0] # DIRECT unsigned distance field (P1) +M = rho * sympy.eye(2) + (Rf**2 - 1.0) * sympy.exp(-(d/w)**2) * (n * n.T) +# mesh built with: uw.meshing.Annulus(..., refine_lines=[xy], refine_size_min=smin) +uw.meshing.node_redistribution(mesh, M, + method_kwargs=dict(step_frac=0.2, accel="cg", momentum=0.0), + slip_surfaces=True, skip_threshold=None) # tensor metric: do the skip check yourself +``` + +Pitfalls that make it "not work": `method="anisotropic"`/`"ot"`/`"spring"`/`"ma"` +(RETIRED 2026-07 — they now raise ValueError; mmpde is the default and only +metric mover); injecting `relax`/`n_outer` (starves mmpde's CG); `strategy=` instead of +`refinement=R` (under-grades); a scalar bump for a fault (refines a fat corridor, +leaves the centre coarse); signed `Surface.distance.sym` for `d` (bleeds along the +line extension — use a direct unsigned distance). Full rationale + the rest of the +recipe (BCs, restart, field transfer, cadence) below. + +--- + +## The canonical recipe (defaults that work) + +### 1. Mover — mmpde +`uw.meshing.smooth_mesh_interior(mesh, metric=..., method="mmpde", +method_kwargs=dict(step_frac=0.2, accel="cg", momentum=0.0), slip_surfaces=True)`. +- mmpde = Huang–Kamenski variational, **non-folding** (energy → ∞ as detJ → 0), + clusters AND aligns cells. It is the only clean mover. +- **NOT** the `anisotropic` mover (shreds/freezes on static features) or `OT` + (slivers — OT is optimal transport, sliver-prone, not a route around the cap). +- Do **not** inject `relax`/`n_outer` into mmpde (those are the anisotropic + mover's knobs and starve mmpde's internal CG). + +### 2. Metric +- Thermal: `metric_density_from_gradient(mesh, T, refinement=R, + metric_choice="front-following")`. `refinement=R` (≈5) is the maximum local + refinement **on the background cell size h0**, not the finest:coarsest ratio: + the metric targets `h ∈ [h0/R, h0·coarsening]`, and `coarsening="auto"` takes + the budget-conserving `R**(1/d)`. So R=5 in 2-D asks for h0/5 up to 2.2·h0 — + a finest:coarsest ratio of `R**(1+1/d)` ≈ 11, and ≈ 8.5 in 3-D. Named + `strategy=` caps at ~2 and under-grades. R≈5 extracts ~all the grading the + node budget/layout allows; don't over-tune R (benign no-op above budget). + Passing `refinement` takes the **envelope branch**, which ignores `amp`, + `lo/hi_percentile`, `mode` and `power`. +- Fault / sharp feature: a **hand-built anisotropic SPD tensor** + `M = ρ·I + (Rf²−1)·exp(−(d/w)²)·n nᵀ` (thin ACROSS the feature normal n). A + scalar bump refines a fat isotropic corridor and leaves the centre-line coarse. +- Use a DIRECT unsigned distance field (geometry_tools) for `d`, NOT the Surface's + signed `.distance.sym` (its zero-contour bleeds the metric along the line + extension). + +### 3. Creation vs maintenance (the cap) — and the gmsh base COMPOUNDS +mmpde **cannot create** strong refinement from a uniform mesh — it saturates at a +fixed-topology cap (~1.8× on the fault, re-measured), because the mover has a FIXED +node budget (it redistributes, never adds nodes). To go finer you need MORE NODES, +which only gmsh can add at construction. **Bake refinement into the gmsh base** +(`Annulus(refine_lines=[xy], refine_size_min=...)` — now real, see the Faults +section) and the mover doesn't just MAINTAIN it: the extra gmsh nodes lift it off +its budget cap so it **compounds** (gmsh f2 base 0.44 → mover 0.29; f3 base 0.30 → +mover 0.19 ≈ 5× finer). Measured (Ra1e6 Rf8 res24, all folded=0): +uniform 0.55 (~1.8×) → gmsh-f2 0.29 (~3.4×) → gmsh-f3 0.19 (~5×). Judge by +fault/bulk nearest-neighbour spacing RATIO, never by global misalignment. + +### 4. Cadence — adapt as often as you like (forced every step is FINE) +Adapt every step or every few — on a CORRECT build (see §5) the mover converges +dead-flat and forced every-step adaptation under vigorous convection is stable +(validated: 39/39 forced adapts, mesh folded=0, area-ratio ~14 flat). Skipping +when aligned just saves cost. (An earlier claim that forcing adaptation +"tangles / over-injects energy" was WRONG — that was the deformed-eval bug in §5.) + +### 5. THE bug that wrecked adaptation (holes) — and the fix +SYMPTOM: giant empty cells / holes in the adapted mesh, intermittent area-ratio +spikes, convection wrecked. ROOT CAUSE (proven): `uw.function.evaluate` +**mis-locates points on a deformed mesh** (the nav kd-tree `mesh._nav_coords` was +captured from the ORIGINAL coords and never refreshed), so the metric — built with +strictly-positive nodal values — evaluates to **NEGATIVE garbage** (even at its own +DOFs) → non-SPD → the mover wrecks the mesh. FIX (both): +- **`8a9d2ff2`** (refresh `_nav_coords` + projected normals on every deform) — + the real fix; makes `function.evaluate`/`points_in_domain` track deformation. +- **Monotone RBF metric bake** (in `_mmpde_mover`, formerly `_winslow_mmpde`): Shepard-interpolate the + metric from its **positive nodal values** — a convex average is guaranteed ≥0 + (monotone) + fast (no cell-location). Use RBF for the metric; it doesn't need + high-precision eval. +With these, the metric stays positive/SPD; #259's SPD-floor never fires (harmless). +The mover, `accel`, and `refinement=R` (e.g. R=5) are all FINE — they were +red-herring symptoms of the eval bug. Always confirm a CLEAN BUILD first +(`./uw build`; `md5 site-packages/.../smoothing.py == src`); a stale build is its +own cause of holes. + +### 6. Stokes free-slip +Penalty (`add_natural_bc(KFS·v·n·n)`, KFS≈1e6) or Nitsche γ=10 both work on a +UNIFORM mesh. Nitsche preferred for sharp fault corners. Do NOT diagnose free-slip +with nodal v·n: Nitsche enforces v·n=0 weakly, so nodal v·n is large even when +correct — use vrms (kinetic energy). (An earlier "warm-restart × Nitsche → blow-up, +use cold restart" diagnosis was WRONG / confounded by the §5 eval bug + a stale build.) + +**On an ADAPTIVE mesh, Nitsche free-slip γ=10 is TOO SOFT → intermittent vrms +spikes at adapt steps** (under-enforcement, NOT a blow-up: single-step velocity +garbage that T survives because the solve is cold-restarted — e.g. vrms +144→8887→144). The mover pins the boundary and refines just beneath it, creating +high-aspect-ratio near-boundary cells whose Nitsche inverse-estimate constant needs +a bigger penalty. Two fixes, BOTH good (corroborated across sessions): +- **Nitsche γ=100** (not 10) for a free-slip top on an adaptive mesh — clean, smooth + signal. This is INDEPENDENT of the penalty's mesh-size scaling: local vs global `h` + are equivalent here (both garbage at γ=10, both clean at γ=100). +- **Don't fully pin the boundary in the mover** — let it tangential-slip + (`slip_surfaces=True`, the §1 default), NOT `pinned_labels=[Upper,...]`. Avoiding + the distorted near-boundary cells lets γ=10 work. Pin a boundary only when you must + hold a prescribed shape (e.g. a free-surface height the integrator just set). + +Aside — free-SURFACE held-lid is the OPPOSITE problem: the GLOBAL `h = +get_min_radius` drifts BELOW the surface cell as the interior refines → the Nitsche +penalty OVER-stiffens → a spurious one-step surface "mountain" (vhmax spike). Fix = +a LOCAL per-cell `h` via `mesh.cell_size()` (deformation/adaptation-tracking); +`add_nitsche_bc(..., local_h=True)` is the default (PR #275). So held-lid wants LESS +penalty (local-h), free-slip-adaptive wants MORE (γ=100) — different knobs. + +### 7. Advection + timestep +`AdvDiffusionSLCN(mesh, u_Field=T, V_fn=V.sym, theta=1.0, monotone_mode='clamp')`. +theta=1.0 (backward Euler) for stability; monotone clamp bounds SL overshoot; +`V_fn=V.sym` is the PHYSICAL velocity. **dt can be LARGE — SLCN is unconditionally +stable; the smallest/median adapted cell does NOT have to govern dt.** Use +`estimate_dt(percentile=50)` × a multiplier (e.g. dt_mult 3–5) to advance physical +time faster (needed to develop convection in stiff stagnant-lid regimes). + +### 8. Rheology +- **isotropic** (linear) ⇒ `snes_type=ksponly` (one exact KSP solve; default + newtonls rejects steps on vigorous flow). Cheap. Use for resolved features. +- **TI / anisotropic** weak fault (`TransverseIsotropicFlowModel`: + `shear_viscosity_0=η_FK`, `shear_viscosity_1=η_weak`, `director`=fault normal): + keep the **default newtonls** (NOT ksponly — ksponly converges to the WRONG + answer); use **penalty** free-slip (Nitsche trips the anisotropic-Jacobian bug); + **GAMG** (FMG ~7× slower). Measured on the gmsh-resolved + one-sided base + (Ra1e6 Δη1e3): ran clean to t=0.06, folded=0, vrms→20, **~10×** the isotropic + cost per step (GAMG eats the 1000× anisotropic contrast — well under the feared + 20×). The TI fault visibly steers the flow (persistent recirculation at the trace). +- Viscosity-bearing fields are **P0/P1 only** (positivity; higher order overshoots). + FK viscosity `η = exp(θ(1−T))`, θ = ln(Δη). Floor a weak zone, don't multiply → 0. + +### 9. Build discipline +- `./uw build` after ANY source change; verify `uw.__file__` is in site-packages + and (when debugging) that `site-packages/.../smoothing.py` matches `src/...`. A + **stale mover build is a top cause of "giant empty elements / holes"** in the + adapted mesh. +- **NEVER** `pip install -e .` (contaminates all envs). Run from inside the worktree + (`pixi run -e amr-dev`). + +--- + +## Faults — the gmsh-resolved, on-fault, one-sided recipe (validated 2026-06-23) + +The full fault recipe, hard-won. Reference implementation: the +`underworld3.workflows` adaptive-convection example +(`docs/examples/workflows/adaptive_convection/{config,fault_config}.py`, on the +`feature/adaptive-convection` worktree — built on the FIXED mover base). Use the +WORKFLOW system, not a monolithic driver. + +### gmsh line refinement (`refine_lines`) — NOW IMPLEMENTED in `Annulus` +```python +uw.meshing.Annulus(radiusOuter=1, radiusInner=0.5, cellSize=1/24, + refine_lines=[xy], # list of (N,2) polylines (model coords) + refine_size_min=cellSize/3, # cell size ON the line (factor 2-3 is plenty) + refine_dist_min=0.02, refine_dist_max=0.12) # size ramps back to cellSize +``` +A gmsh **Distance + Threshold** field along the polyline; INTERIOR points are +embedded so nodes land ON the line. Backward-compatible (default `None`). It is a +core meshing change → lands as its own small meshing PR, separate from the workflow +example. (Before this session it was only *called* in old scripts and never existed +— don't trust `refine_lines` on any branch but the one carrying this commit.) + +### Keeping refinement ON the fault (the metric-composition trap) +The fault metric is `M = ρ·I + (Rf²−1)·exp(−(d/wₙ)²)·n nᵀ` with the isotropic SIZE +density `ρ`. **Do NOT fuse the fault density into ρ_T by PRODUCT** — the cold +surface thermal BL (ρ_T ~ R^d ~ 20–25) out-competes the fault near its top and the +refinement drifts ABOVE the fault, starving the deep fault ("seems to repel"): +- Use `ρ = max(ρ_T, fault_ρ)` (NOT `ρ_T · fault_ρ`). +- Make `fault_ρ = 1 + amp·gauss` with **amp > ρ_T** (~25 at R=5) so the fault wins + the max along its whole length. +- DIAGNOSE this by comparing the gmsh BASE (step 0, refinement centered on the + fault — correct) vs the DEVELOPED mesh (drifted above) → it's the MOVER's metric, + not gmsh. Render the mesh with the **fault trace overlaid** (`render.py --fault`). + +### One-sided fault influence (the clean control) +Even with max+amp, the symmetric metric DEMANDS both flanks while realized nodes +drift to the hanging wall. Make it one-sided: +- Store the **SIGNED** distance in `dfac` (the gaussians square it, so magnitude is + unchanged — the sign only feeds a `0.5(1+tanh(m·d/w))` gate). Probe the + radially-outward side once to define "upper" regardless of the distance tool's + orientation convention. +- `fault_metric_side` (both/upper/lower) gates the refinement; `fault_rheology_side` + gates the weak zone. **`both=upper`** is the physical recipe: a one-sided + hanging-wall damage zone with the mesh refined on the same side (refinement and + rheology coincide; gmsh-f3 → fault/bulk ~0.19, folded=0). `metric_side=lower` + instead pulls refinement onto the footwall to counter the upward drift. + +### The wedge fill (anti-collision) +The fault pull and the surface-BL pull compete for the coarse cells in the radial +sliver BETWEEN them. `fault_wedge=True` gmsh-fills that wedge (sample radial +segments from each fault point up to the surface, add as a second `refine_lines` +point set) so both pulls have their own budget and merge into one coherent fine +wedge instead of colliding. + +### Weak zone (rheology) — geometric blend + gaussian PEAKED on the fault (2026-06-24) +The weak-zone viscosity blends `η_FK` (background) and `floor` (fault) by the +influence `f`∈[0,1] (P1, positive). Get it right with TWO rules (verify by +reconstructing + rendering the REALIZED η field — `render_fields.py` — and the +combined `η_FK(T)^(1−f)` field; never assume the floor is reached): +- **GEOMETRIC blend `η_weak = η_FK^(1−f)·floor^f`** (NOT arithmetic + `η_FK·(1−f)+floor·f`, whose `(1−f)` term leaks the stiff-lid background through + → η≈8 at f=0.97 in a 1000× lid). Geometric reaches the floor genuinely (η≈1.2 at + f=0.97) AND ties the contrast to the LOCAL η_FK — so the fault automatically + bites hardest where it cuts the cold stiff lid (physically correct), nothing in + the hot interior. +- **`f` must be PEAKED on the fault (gaussian), NOT a TOPHAT block.** A top-hat + makes a uniform weak BLOCK; its sharp edges mean strain follows ∇f (TWO parallel + lines at the block edges, not on the fault), and a one-sided block is offset to + the hanging wall (η_1=1 core sits ABOVE the drawn line). Use a **gaussian + `f=exp(−(d/w)²)`** (peak f=1 ON the fault → η_1=1 on the drawn line, strain + localizes INTO the slot as a single feature). side=both = symmetric; side=upper = + hanging-wall halo (gaussian taper up, sharp footwall recovery — NO halving gate). + Centre the METRIC too (`metric_side=both`) so nodes refine on the line. +THERMAL CONTRAST (Louis's insight, confirmed): even a symmetric gaussian gives a +much bigger η-contrast on the COLD (upper/surface) side than the warm (footwall) +side, because η_FK rises ~1000× toward the surface — so the fault's dynamical +prominence naturally concentrates in the cold lid. This is a feature, not a bug. +Verified (gmsh-f3, gaussian width=0.025, side+metric=both): weak zone centred on +the drawn fault on the adapted mesh, folded=0. TUNE AT STEP 0 (build mesh+fields, +no solve — fast; plot the 1D η_1(d) profile + the 2D field). COST: genuine 1000× +TI contrast ~25–145 s/step (cold-start steps slow, ~25 s once developed; GAMG). +For TI see §8. (`fault_config.py`: `fault_profile=gaussian` (default still tophat — +pass gaussian), geometric blend in `create_solvers`; `render_fields.py` light maps.) + +--- + +## Failure modes — symptom → cause → fix + +| Symptom | Cause | Fix | +|---|---|---| +| Giant empty cells / holes in adapted mesh; intermittent area-ratio spikes | `function.evaluate` mis-locates on deformed mesh → metric → negative/non-SPD (the real bug). OR stale build. | `8a9d2ff2` + monotone RBF metric bake; `./uw build` + verify md5 site-packages==src | +| Decays when it should convect | over-diffusion OR under-resolution OR dt too small to develop | check a resolved arbiter (finer space+time); raise node budget; **larger dt** (dt_mult 3–5) | +| Fault won't refine under convection | mmpde creation cap; field gives no signal in cold lid | gmsh `refine_lines` base — the gmsh nodes let mmpde COMPOUND past the cap (~5×) | +| Refinement drifts ABOVE the fault / "repels", deep fault starved | fault density fused by PRODUCT with ρ_T → thermal BL out-competes the fault near the surface | `ρ = max(ρ_T, fault_ρ)`; `amp > ρ_T` (~25); or one-sided `fault_metric_side`; +`fault_wedge` | +| Refinement on both flanks but you want one side | symmetric (unsigned-distance) metric | signed `dfac` + `fault_metric_side`/`fault_rheology_side` tanh gate (`both=upper` = physical) | +| TI fault solve: ksponly gives wrong answer | ksponly skips the Picard the inexact GAMG inner solve needs | keep default newtonls; penalty free-slip; GAMG (~10× isotropic cost, fine) | +| free-slip ADAPTIVE: vrms spikes at adapt steps (e.g. 144→8887→144), T stays bounded | Nitsche γ=10 too soft on the distorted near-boundary cells the pinned-top+interior-refine creates (under-enforcement) | **Nitsche γ=100** (not 10); OR don't pin the boundary (tangential-slip `slip_surfaces=True`). h-scaling (local vs global) is moot here | +| free-SURFACE held-lid: spurious one-step surface "mountain" / vhmax spike | global `h=get_min_radius` drifts below the surface cell as interior refines → Nitsche OVER-stiff | LOCAL per-cell h: `add_nitsche_bc(local_h=True)` (default, PR #275) = `mesh.cell_size()` | + +**Diagnose by:** vrms (KE), Nu (`BdIntegral` surface flux), fault/bulk NN-spacing +RATIO (cKDTree), folded-element count + min cell area. Compare runs **at matched +physical time t** (dt differs between meshes), not by step number. When unsure +whether behaviour is physical, build a **resolved arbiter** (uniform mesh finer in +BOTH space and time) — if it agrees with one candidate, that's the truth. + +## Diagnostics (in the workflow example, reusable) +`diagnostics.py`: `mesh_quality` (folded / area-ratio / aspect), `nn_spacing_ratios` +(BL + fault/bulk), `NusseltSurface`, `vrms`, `History`. `render.py`: T+mesh+ +streamlines on the `Run` layout, **`--fault`** overlays the fault trace (read from +the run manifest) + **`--focus-fault`** auto-crops on it + **`--mesh-only`** for the +clean mesh, **`--all`** for every frame. `compare.py`/`fault_refine_plot.py`: +matched-physical-time comparison + fault/bulk-ratio time series. +**Rendering long runs as they go:** a completion-only Monitor is NOT enough — arm a +Monitor that POLLS for new `run.mesh.NNNNN.xdmf` checkpoints and emits the index, so +you render each step as it lands. +**Checkpoint an ADAPTIVE mesh with `meshUpdates=True`** (per-step geometry) or the +saved frames pair deformed fields with stale step-0 geometry. + +## The verified canonical command + +Requires the fix (§5): `8a9d2ff2` (deformed-mesh point-location) + the monotone RBF +metric bake in `smoothing.py`. Validated 2026-06-22 at vigorous Ra1e6/Δη1e3 +stagnant-lid: forced adapt EVERY step (39/39), larger dt, → vrms→18, Nu→1.86, +|v|max 61, mesh CLEAN every frame (folded=0, area-ratio ~14 flat), no abort. + +```bash +# no-fault baseline (the workflow CLI; one flag per config field) +pixi run -e amr-dev python docs/examples/workflows/adaptive_convection/simulate.py \ + --output-dir ~/+Simulations//baseline \ + --rayleigh 1e6 --delta-eta 1e3 --cellsize 0.0417 \ + --resolution-ratio 5 --adapt-every 1 --dt-mult 4 --max-steps 80 --max-t 0.06 + +# resolved fault: gmsh base (factor 3) + on-fault + one-sided hanging wall + TI +pixi run -e amr-dev python docs/examples/workflows/adaptive_convection/fault_simulate.py \ + --output-dir ~/+Simulations//fault \ + --rayleigh 1e6 --delta-eta 1e3 --cellsize 0.0417 --resolution-ratio 5 \ + --fault-base-smin 0.0139 --fault-anisotropy 8 \ + --metric-combine max --fault-refine-amp 25 \ + --fault-rheology-side upper --fault-metric-side upper \ + --rheology ti --dt-mult 4 --max-steps 40 --max-t 0.06 +``` + +Key choices, verified: +- **`--resolution-ratio 5`** (R=5) is fine — the mover handles it on a correct build. +- **`--adapt-every 1`** force adapt every step (the strongest mesh test). +- **`--dt-mult 4`** — larger dt for STABILITY is fine (SLCN unconditional); but it + costs transient ACCURACY (over-diffusive backward-Euler DELAYS the convective + onset vs a resolved arbiter — dt×1.5 recovers it). Use small dt_mult for faithful + transients, large to reach quasi-steady fast. +- **`--freeslip penalty`** (default) — REQUIRED for TI (Nitsche trips the + anisotropic-Jacobian bug). It's the raw velocity penalty `kfs·(v·n)n`. +- Fault: `--fault-base-smin` (gmsh resolve), `--metric-combine max` + + `--fault-refine-amp 25` (keep refinement ON the fault), `--fault-*-side` (one-sided), + `--rheology ti` (real fault). Drop the fault flags for the no-fault control. + +Render with `render.py` (`--fault --focus-fault` to see the trace + refinement +coincide; `--mesh-only`; `--all`). Judge the mesh by folded/area-ratio, the physics +by vrms/Nu, ALWAYS at matched physical time. + +## Related memory +`project_adaptive_convection_as_workflow` (THIS session: workflow port, gmsh +refine_lines, on-fault/one-sided/wedge, TI, dt-accuracy), `project_mmpde_holes_real_root_cause`, +`project_fault_refine_fixed_topology_cap`, `project_uw_workflow_landing`, +`feedback_debug_adaptive_solver_method`, `project_fault_convection_working_settings`. diff --git a/.claude/skills/cetz-figures/SKILL.md b/.claude/skills/cetz-figures/SKILL.md new file mode 100644 index 000000000..f65e81a3a --- /dev/null +++ b/.claude/skills/cetz-figures/SKILL.md @@ -0,0 +1,143 @@ +--- +name: cetz-figures +description: Build schematic / labelled-geometry figures for underworld3 papers using Typst + cetz. Use when the figure is primarily about topology, annotation, and math-typeset labels (meshes, solver diagrams, flow charts). Prefer Python → SVG → `#image()` for data-heavy figures (fields, colormaps, arrow plots) instead. +--- + +# cetz-figures + +Scaffold for Typst/cetz figures in `publications/**/figures/` alongside the +existing `arrays-sync-flow.typ`. This skill exists because upstream Claude +sessions draft cetz blind — this one actually compiles. + +## When to use cetz + +- Mesh schematics with a few labelled triangles / vertices / control points. +- Solver / data-flow diagrams (see `arrays-sync-flow.typ` in this repo). +- Anything where labels should render in the paper's math/text fonts. +- Anything that benefits from recompiling with the paper. + +## When to use something else + +- **Data-heavy plots** (scalar fields, colormaps, quiver plots, anything with + dense per-pixel or per-cell data) — generate SVG from Python/matplotlib, + include via `#image("foo.svg")`. cetz will fight you here. +- **Geometry computation** (Delaunay, intersections, interpolation) — do it in + Python offline, emit JSON with the shape `{"vertices": [...], + "triangles": [...], ...}`, let Typst just draw. + +## When NOT to use TikZ + +Evaluated and rejected: +- Slower compile than Typst. +- Drags in a LaTeX toolchain that isn't otherwise required by the project. +- No Typst math-font advantage over cetz for labels in our paper context. + +Keep TikZ in back pocket only if a co-author insists on TikZ source. + +## Before you draw (thesis-first discipline) + +When a user hands you a figure request — especially one replacing an +existing ASCII sketch, whiteboard photo, or reference figure — **do not +start transcribing**. The source artefact is a hypothesis about what +to communicate, not a specification. Before opening cetz: + +1. **State the thesis in one sentence.** What is the figure arguing? + If you can't write it plainly, you don't yet understand the figure. + Ask the user to articulate it. + +2. **Honestly audit the source.** If the original ASCII / sketch is + being replaced, it's often replaced *because it doesn't land well*. + Say what's broken about it before proposing the replacement — the + user often agrees and the new figure can do more than the old. + +3. **Enumerate design decisions as explicit questions, not assumptions.** + For a curved-boundary normals figure that's typically: + - Geometry (circle / ellipse / arc span / zoom level) + - Sampling density (number of facets, quadrature points per facet) + - Overlay vs. side-by-side + - Whether to show error quantitatively (arcs, annotations) or leave + it as a visible angle + - Where the figure lives in the repo (which doc / which branch) + Present a proposed interpretation with the decisions flagged; let + the user resolve them before you compile. + +4. **Only then open cetz.** Iterate visually — the discipline above is + about not committing to a design prematurely, not about planning + exhaustively. Once you start, compile often. + +The user's phrase "I'm not quite sure what this is intended to +illustrate" is the canonical trigger for this discipline. If you hear +it (or catch yourself about to transcribe without checking), stop and +do the four steps above. + +## Key gotchas (hit during iteration — not hypothetical) + +1. **Don't give a helper a parameter named after a `cetz.draw` export** + — `anchor`, `fill`, `stroke`. The cause is the `import cetz.draw: *` the + helper needs (gotcha 6): it runs *inside* the function body and shadows the + parameter, so the parameter name resolves to cetz's function rather than to + the value you passed. `anchor` panics with `"Unknown anchor 'anchor' for + element 'none'"`; `fill` gives `"expected color, gradient, tiling, or none, + found function"` pointing into `canvas.typ`, nowhere near your code. Rename + to `align-to`, `bg`, `edge`. See `cetz-cheatsheet.md`. + +2. **Clipping is a Typst concern, not a cetz one.** cetz has no `\clip`. + Wrap the canvas in `#box(clip: true, width: ..., height: ..., ...)` and + draw slightly oversized inside the canvas — the box clips the overflow. + +3. **Painter's algorithm — order matters.** Draw the background first, the + highlight second. No z-index exists. Verified in `mesh-demo.typ`. + +4. **Semi-transparency via `rgb(r, g, b, a)`** (alpha 0–255) or + `color.transparentize(col, 50%)`. Both work for fill and stroke. + +5. **Math in labels just works.** `content(pos, $v_1$)` renders in the + document math font. No escape hatch needed. This is a real cetz win over + SVG. + +6. **`import cetz.draw: *` inside the canvas closure.** Without it, `line`, + `circle`, `content` aren't in scope. Helper functions that draw need + their own `import cetz.draw: *` line inside. + +## Project layout pattern + +Each blog post or paper section gets its own subdirectory under +`figures/`, so a post's figures travel together: + +``` +publications/blog-posts/figures/ +└── / + ├── .typ # cetz drawing + ├── .png # committed output + ├── -data.json # (optional) precomputed geometry + └── generate-.py # (optional) Python that writes the JSON +``` + +Concrete example: `publications/blog-posts/figures/finding-particles/` +holds `mesh-demo.*` and `domain-demo.*` for the post +`finding-particles.md`. + +The JSON intermediate is the forward bridge to underworld3 — see +`underworld-bridge.md`. + +## Reference files + +- `cetz-cheatsheet.md` — what worked from memory vs. needed lookup. +- `underworld-bridge.md` — JSON schema for future `uw.meshing` export. +- `examples/` — self-contained copies (each with `.typ`, `.png`, `.json`, + and generator `.py`) of the figures this skill is scaffolded from. + These are snapshots; the live versions may have drifted if a post or + doc was iterated on further. + - `mesh-demo.*` — element-level point-in-cell test. + Live: `publications/blog-posts/figures/finding-particles/`. + - `domain-demo.*` — parallel domain centroid ambiguity. + Live: `publications/blog-posts/figures/finding-particles/`. + - `facet-vs-true-normals.*` — facet normal vs. smooth-surface normal + on a curved boundary. Live: + `docs/advanced/figures/curved-bc/`. + +## Canonical reference in the repo + +`publications/blog-posts/figures/arrays-sync-flow.typ` — prior cetz figure +in the repo, established version (0.3.4) and house style (hex colours, +helper-function pattern). Follow its conventions. diff --git a/.claude/skills/cetz-figures/cetz-cheatsheet.md b/.claude/skills/cetz-figures/cetz-cheatsheet.md new file mode 100644 index 000000000..bd7704684 --- /dev/null +++ b/.claude/skills/cetz-figures/cetz-cheatsheet.md @@ -0,0 +1,390 @@ +# cetz cheatsheet — honest edition + +Separates **what I used correctly from memory** (so you probably can too) +from **what actually needed diagnosis** (so you should check the manual +at https://cetz-package.github.io/docs/ rather than trust the pattern). + +Version: cetz 0.3.4, Typst 0.13.1. Don't assume these patterns hold for +later cetz versions — the API has been unstable between minor versions. + +**External reference worth checking first:** + — curated gallery of ~130 scientific +diagrams with source (~120 in cetz, ~110 in TikZ). Heaviest coverage is +theoretical physics and ML; lighter on FE/meshes specifically, but +strong patterns for coordinate frames, Euler angles, 3D orientation, +and lattice structures. Worth browsing before inventing a new figure +from scratch — the answer to "how do people draw X in cetz" is often +sitting there. + +## Worked from memory (verified correct) + +```typst +#import "@preview/cetz:0.3.4" + +#cetz.canvas(length: 4cm, { + import cetz.draw: * + + // Straight line / polyline / closed polygon + line((0, 0), (1, 0), (1, 1), close: true, + fill: rgb(70, 120, 180, 60), + stroke: 1.2pt + rgb("#1f3a6b")) + + // Filled dot + circle((0.5, 0.5), radius: 0.02, fill: black, stroke: none) + + // Label at a position — math content renders in document math font + content((0.5, 0.5), $v_1$, anchor: "west") +}) +``` + +- `#box(clip: true, width: 8cm, height: 8cm, cetz.canvas(...))` — the + *Typst* box does the clipping. cetz itself doesn't clip. +- `rgb("#b8b8b8")` for hex colours, `rgb(r, g, b, a)` for RGBA (alpha 0–255). +- `json("file.json")` from outside the canvas, then `.at(i)` and field + access inside. Values flow through into cetz coordinates cleanly. +- `for tri in mesh.triangles { line(...) }` — plain Typst for-loops work + inside the canvas closure. + +## Needed diagnosis (check the manual if you do this) + +### Parameter names shadowed by `import cetz.draw: *` + +A helper that draws needs `import cetz.draw: *` in its own body (see below). +That import runs *inside* the function, **after** the parameters are bound, and +shadows any parameter sharing a name with a `cetz.draw` export — `anchor`, +`fill`, `stroke`. The parameter name then resolves to cetz's function instead of +your value. + +```typst +// BROKEN — panic: "Unknown anchor 'anchor' for element 'none'" +#let dot(p, label, anchor: "west") = { + import cetz.draw: * + circle(p, radius: 0.02, fill: black) + content(p, label, anchor: anchor) +} + +// BROKEN — "expected color, gradient, tiling, or none, found function", +// reported inside cetz's own canvas.typ, nowhere near your code +#let panel(tl, br, fill, edge) = { + import cetz.draw: * + rect(tl, br, fill: fill, stroke: (paint: edge, thickness: 1pt)) +} +``` + +```typst +// FIX — rename the parameter +#let dot(p, label, align-to: "west") = { ... content(p, label, anchor: align-to) } +#let panel(tl, br, bg, edge) = { ... rect(tl, br, fill: bg, ...) } +``` + +Verified by isolation: the same helper **without** the inner import compiles +fine, which is what identifies the import as the culprit rather than the +parameter name itself. So the rule is not a blacklist of names — it is "do not +name a parameter after anything `cetz.draw` exports", and if the helper does not +actually draw, drop the inner import instead. + +Diagnosis cost: two compile failures across two sessions. Neither error message +points at the parameter. + +### Shadowing a Typst builtin, especially `h` + +Same class, wider blast radius. `h` is Typst's horizontal-spacing function, so a +figure that defines a surface height as `h(x)` breaks every `#h(0.4em)` used for +spacing in a label — with an error that names neither: + +```typst +let h(x) = 0.9 * calc.exp(-x * x) // BROKEN +content(p, [$a$, #h(0.6em) $b$]) // "cannot add length and float" +``` + +Rename the domain function (`surf-y`, `depth`, `topo`). `v`, `text`, `line`, +`rect`, `circle`, `content` and `place` are all live names too. + +### `let f(x) = ...` ends at the line break + +A multi-line arithmetic expression needs parentheses, or the statement ends at +the first newline and the continuation is parsed as a fresh expression — +reported as `unknown variable: x`, which sends you looking in the wrong place: + +```typst +// BROKEN +let surf-y(x) = 0.95 * calc.exp(-calc.pow((x + 1.75) / 1.3, 2)) + - 0.80 * calc.exp(-calc.pow((x - 1.70) / 1.15, 2)) + +// FIX — wrap the whole expression +let surf-y(x) = (0.95 * calc.exp(-calc.pow((x + 1.75) / 1.3, 2)) + - 0.80 * calc.exp(-calc.pow((x - 1.70) / 1.15, 2))) +``` + +### Multi-line `content()` anchored `"west"` rides up over what is above it + +`"west"` centres the block vertically on the anchor point, so a two- or +three-line caption placed below a heading grows *upwards* into it. Nothing +errors; the text simply overlaps and you only notice in the PNG. + +```typst +content((x, y - 0.3), anchor: "west", note) // overlaps when note wraps +content((x, y - 0.3), anchor: "north-west", note) // grows downward — use this +``` + +Rule of thumb: any label whose text might wrap gets a `north-*` anchor. + +### Anchor values for `content()` + +Confirmed to work: `"north"`, `"south"`, `"east"`, `"west"`, +`"north-east"`, `"south-east"`, `"north-west"`, `"south-west"`, `"center"`. + +Not tried in this session: named anchors of other elements (e.g. +`"my-rect.east"`), element-specific anchors beyond compass. Verify +against the cetz anchors docs if you need those. + +### Content wider than the Typst box needs `align()` to re-centre + +`#box(clip: true, width: W, height: H, cetz.canvas(...))` does not centre +the cetz canvas if the cetz bounding box is larger than `W × H` (e.g. a +mesh drawn oversize for clipping). Typst default-aligns the canvas to +the top-left, which shifts the interesting region off-frame. Wrap: + +```typst +#box(clip: true, width: W, height: H, + align(center + horizon, cetz.canvas(length: L, { ... }))) +``` + +Encountered when the background mesh extent grew past `[-1, 1]` — +first compile looked fine because content fit; the next compile didn't. + +### Dashed / dotted strokes in cetz + +Pass a dict for the stroke: + +```typst +stroke: (paint: rgb("#1f3a6b"), thickness: 0.5pt, dash: "dotted") +stroke: (paint: rgb("#059669"), thickness: 0.7pt, dash: "dashed") +``` + +Valid `dash` values include `"dotted"`, `"dashed"`, `"densely-dotted"`, +`"loosely-dotted"`, etc. (Typst stroke spec, not a cetz extension.) + +### Line-to-viewport clipping is your job + +cetz does not clip lines at an imaginary viewport. If you want to +extend the line through `(p, q)` out to the edge of a `[-V, V]²` box, +compute the `t` values at each axis crossing, take the inner `t-enter` +and `t-exit`, and evaluate the line there. See `extend-to-viewport` in +`examples/mesh-demo.typ`. + +### Draw helpers that take `cetz.draw` primitives + +Helper functions defined **outside** `cetz.canvas({...})` need their +own `import cetz.draw: *` inside the function body: + +```typst +#let dot(p, label, ...) = { + import cetz.draw: * // REQUIRED — cetz.draw symbols not in scope otherwise + circle(p, ...) + content(...) +} +``` + +This feels like it shouldn't be necessary but is (cetz 0.3.4). + +## Probably wrong / would need checking + +These are *guesses* from adjacent Typst/cetz knowledge, not verified here: + +- `plot` module usage (axes, line plots, scatter plots). Upstream sessions + were shaky on this; assume the API has drifted and check the cetz plot + docs before using. +- `decorations` module (brace, waveform, etc.). Not touched in this session. +- Named-anchor references across draw calls via `name:` — syntax is + `element-name.anchor-name` but context behaviour needs testing. +- Gradient fills — not used, not verified. +- Transformations (`translate`, `rotate`, `scale`) — likely work but not + exercised here. + +## Compile workflow + +```bash +# First run downloads cetz and its deps (small, ~90 KB total) +typst compile mesh-demo.typ mesh-demo.png + +# PDF is default if no output specified +typst compile mesh-demo.typ # → mesh-demo.pdf + +# Watch mode for iteration +typst watch mesh-demo.typ mesh-demo.png +``` + +## Data-driven figures with JSON + +The preferred pattern for mesh/particle figures is to generate data in +Python (scipy.spatial.Delaunay, numpy), export as JSON, and render in +Typst/cetz. This separates the geometry computation from the drawing. + +```typst +#let mesh = json("mesh-data.json") +#let pt(i) = { + let v = mesh.vertices.at(i) + (v.at(0), v.at(1)) +} +// Loop over triangles +for tri in mesh.triangles { + line(pt(tri.at(0)), pt(tri.at(1)), pt(tri.at(2)), + close: true, fill: fill-col, stroke: stroke-mesh) +} +``` + +### Connect nodes by triangulating, never by distance + +A tempting shortcut for a mesh figure is to join node pairs closer than some +radius. It is not a triangulation: it silently omits nodes near the edge of the +domain and in sparse regions, and the figure looks *almost* right, which is +worse than looking wrong. Run a real Delaunay and draw its triangles. + +Two follow-on details, both of which show up immediately on a geophysical +domain: + +- **Delaunay fills the convex hull.** A domain bounded above by topography or a + free surface is not convex, so triangles appear above the surface. Cull them + by centroid: `if centroid_y <= surface_y(centroid_x)`. +- **A perfect lattice gives degenerate triangles.** Stagger alternate rows by + half a spacing (or jitter) so Delaunay has an unambiguous choice and produces + triangles rather than near-right-angle slivers. + +### Python generator pattern + +```python +from scipy.spatial import Delaunay +# Jittered equilateral lattice → Delaunay → JSON +tri = Delaunay(points) +data = { + "vertices": [[float(x), float(y)] for x, y in points], + "triangles": [[int(i), int(j), int(k)] for i, j, k in tri.simplices], + # ... domain membership, centroids, test points, etc. +} +json.dump(data, open("mesh-data.json", "w"), indent=2) +``` + +### Enumeration with index + +```typst +// .enumerate() gives (index, value) pairs — used for domain colouring +for (idx, tri) in mesh.triangles.enumerate() { + let d = get-domain(idx) + let fill-col = if d == "A" { fill-a } else { fill-b } + line(pt(tri.at(0)), pt(tri.at(1)), pt(tri.at(2)), + close: true, fill: fill-col, stroke: stroke-mesh) +} +``` + +### High-DPI PNG output + +```bash +typst compile figure.typ figure.png --ppi 300 +``` + +## Lessons from domain-demo figure + +- **Multiple domain colours**: Use low-alpha fills (25–45) so mesh edges + show through. Peripheral domains use even lower alpha to stay in background. +- **No heavy boundary lines needed**: Domain colours communicate the + partition without explicit boundary drawing. This looks more natural. +- **Smaller dots and tight labels** for a cleaner look: `radius: 0.028–0.032`, + label offsets of `(0.06, 0.06)` or `(0.08, 0.06)`. +- **Finer mesh** (SPACING ≈ 0.28, ~400 vertices, ~800 triangles) reads as + a realistic FE mesh. Coarser meshes (SPACING ≈ 1.25) work for + element-level diagrams. +- **View clipping**: Use `VIEW` parameter in JSON and `in-view()` check + to clip triangles at the edge of the rendered region. + +## Visual idioms that worked + +### Dashed-on-top-of-solid reveals coincidence + +When two arrows (or two paths) represent quantities that *sometimes* +coincide and *sometimes* diverge, draw the **dashed** one LAST so it +layers on top of the **solid** one. Where they overlap, the dashes +interleave with the solid underneath and both colours remain visible +— reading unmistakably as "they coincide here". If the solid were on +top, it would simply cover the dashed and the coincidence would be +invisible. + +Applied in `facet-vs-true-normals.typ`: the rust `mesh.Gamma` arrow is +drawn first, the green dashed true normal on top. At each facet +midpoint the green dashes sit over the rust — the coincidence is +obvious. At off-centre quadrature points both arrows are visible as +distinct directions. + +### Quadrature choice encodes pedagogy + +If your figure illustrates *within-element variation* of a quantity, +the quadrature rule you pick determines whether the variation is +visible at all: + +- **2-point Gauss–Legendre** (±1/√3 on [-1, 1]): both samples off + the element midpoint. Can show *average* per-element behaviour + but hides any quantity that's zero at the midpoint. +- **3-point Gauss–Legendre** (0, ±√(3/5)): one sample AT the + midpoint plus two off-centre. Reveals both the midpoint case and + the at-the-edge case in the same picture. + +Switching from 2-point to 3-point was the key move that made the +curved-boundary figure communicate "the error is zero at facet +midpoints" — not just "there is an error". + +### Legend in empty interior space beats per-arrow labels + +Per-arrow labels near the canvas edge fight cetz's bbox calculation +and often clip at the PNG boundary. If the figure has an obvious +empty interior region, put a small 2-row legend there: + +```typst +// Row 1 +line((lx, ly), (lx + 0.28, ly), stroke: gamma-stroke, + mark: (end: ">", fill: gamma-colour)) +content((lx + 0.36, ly), text(fill: gamma-colour, $hat(n)_Gamma$ + [ (label)]), + anchor: "west") +// Row 2, below +``` + +The short sample stroke uses the *same* stroke dict as the arrows in +the figure, so it looks identical. No leader lines, no per-arrow +crowding. + +### Short arrows read better than long + +Arrow lengths around **0.25–0.3 world units** (with canvas `length: +3cm`) stay visually subordinate to the geometry they annotate. +Longer arrows (0.4+) tend to dominate and obscure the mesh. Start +short; lengthen only if the arrow directions are genuinely hard to +read. + +### Explicit page dimensions + aligned canvas = reliable padding + +For docs figures that must look crisp in a PNG of specific dimensions: + +```typst +#set page(width: 12cm, height: 8cm, margin: 6pt) +#align(center + horizon, cetz.canvas(length: 3cm, { ... })) +``` + +This decouples the *rendered size* from cetz's (sometimes generous, +sometimes flaky) auto-bbox. Your content scales predictably and text +labels near the edges have room. Use it any time the figure goes +into a doc as a fixed-size image. + +## House style in this repo + +Figures live in per-post subdirectories under `publications/blog-posts/figures/`: +- `finding-particles/mesh-demo.typ` — element-level inside/outside test with control points +- `finding-particles/domain-demo.typ` — multi-domain parallel mesh with centroid ambiguity +- `arrays-sync-flow.typ` — flow diagram with nodes, regions, bezier connectors + (older figure, not yet moved into a per-post subdirectory) + +Patterns: +- Custom helper functions declared at the top of the canvas closure + (`node`, `region`, `elabel`, `dot`). +- Hex colours via `rgb("#...")`. +- Arrows via `mark: (end: ">", fill: black)`. +- Bezier connectors via `bezier(start, end, ctrl1, ctrl2, ...)`. +- Data from JSON files generated by companion Python scripts. diff --git a/.claude/skills/cetz-figures/examples/curved-bc-data.json b/.claude/skills/cetz-figures/examples/curved-bc-data.json new file mode 100644 index 000000000..0abb07578 --- /dev/null +++ b/.claude/skills/cetz-figures/examples/curved-bc-data.json @@ -0,0 +1,652 @@ +{ + "centre": [ + 0.0, + 0.0 + ], + "radius": 1.5, + "arc_points": [ + [ + 1.299, + 0.75 + ], + [ + 1.2858, + 0.7726 + ], + [ + 1.2721, + 0.7949 + ], + [ + 1.258, + 0.817 + ], + [ + 1.2436, + 0.8388 + ], + [ + 1.2287, + 0.8604 + ], + [ + 1.2135, + 0.8817 + ], + [ + 1.198, + 0.9027 + ], + [ + 1.182, + 0.9235 + ], + [ + 1.1657, + 0.944 + ], + [ + 1.1491, + 0.9642 + ], + [ + 1.1321, + 0.9841 + ], + [ + 1.1147, + 1.0037 + ], + [ + 1.097, + 1.023 + ], + [ + 1.079, + 1.042 + ], + [ + 1.0607, + 1.0607 + ], + [ + 1.042, + 1.079 + ], + [ + 1.023, + 1.097 + ], + [ + 1.0037, + 1.1147 + ], + [ + 0.9841, + 1.1321 + ], + [ + 0.9642, + 1.1491 + ], + [ + 0.944, + 1.1657 + ], + [ + 0.9235, + 1.182 + ], + [ + 0.9027, + 1.198 + ], + [ + 0.8817, + 1.2135 + ], + [ + 0.8604, + 1.2287 + ], + [ + 0.8388, + 1.2436 + ], + [ + 0.817, + 1.258 + ], + [ + 0.7949, + 1.2721 + ], + [ + 0.7726, + 1.2858 + ], + [ + 0.75, + 1.299 + ], + [ + 0.7272, + 1.3119 + ], + [ + 0.7042, + 1.3244 + ], + [ + 0.681, + 1.3365 + ], + [ + 0.6576, + 1.3482 + ], + [ + 0.6339, + 1.3595 + ], + [ + 0.6101, + 1.3703 + ], + [ + 0.5861, + 1.3808 + ], + [ + 0.5619, + 1.3908 + ], + [ + 0.5376, + 1.4004 + ], + [ + 0.513, + 1.4095 + ], + [ + 0.4884, + 1.4183 + ], + [ + 0.4635, + 1.4266 + ], + [ + 0.4386, + 1.4345 + ], + [ + 0.4135, + 1.4419 + ], + [ + 0.3882, + 1.4489 + ], + [ + 0.3629, + 1.4554 + ], + [ + 0.3374, + 1.4616 + ], + [ + 0.3119, + 1.4672 + ], + [ + 0.2862, + 1.4724 + ], + [ + 0.2605, + 1.4772 + ], + [ + 0.2347, + 1.4815 + ], + [ + 0.2088, + 1.4854 + ], + [ + 0.1828, + 1.4888 + ], + [ + 0.1568, + 1.4918 + ], + [ + 0.1307, + 1.4943 + ], + [ + 0.1046, + 1.4963 + ], + [ + 0.0785, + 1.4979 + ], + [ + 0.0523, + 1.4991 + ], + [ + 0.0262, + 1.4998 + ], + [ + 0.0, + 1.5 + ], + [ + -0.0262, + 1.4998 + ], + [ + -0.0523, + 1.4991 + ], + [ + -0.0785, + 1.4979 + ], + [ + -0.1046, + 1.4963 + ], + [ + -0.1307, + 1.4943 + ], + [ + -0.1568, + 1.4918 + ], + [ + -0.1828, + 1.4888 + ], + [ + -0.2088, + 1.4854 + ], + [ + -0.2347, + 1.4815 + ], + [ + -0.2605, + 1.4772 + ], + [ + -0.2862, + 1.4724 + ], + [ + -0.3119, + 1.4672 + ], + [ + -0.3374, + 1.4616 + ], + [ + -0.3629, + 1.4554 + ], + [ + -0.3882, + 1.4489 + ], + [ + -0.4135, + 1.4419 + ], + [ + -0.4386, + 1.4345 + ], + [ + -0.4635, + 1.4266 + ], + [ + -0.4884, + 1.4183 + ], + [ + -0.513, + 1.4095 + ], + [ + -0.5376, + 1.4004 + ], + [ + -0.5619, + 1.3908 + ], + [ + -0.5861, + 1.3808 + ], + [ + -0.6101, + 1.3703 + ], + [ + -0.6339, + 1.3595 + ], + [ + -0.6576, + 1.3482 + ], + [ + -0.681, + 1.3365 + ], + [ + -0.7042, + 1.3244 + ], + [ + -0.7272, + 1.3119 + ], + [ + -0.75, + 1.299 + ], + [ + -0.7726, + 1.2858 + ], + [ + -0.7949, + 1.2721 + ], + [ + -0.817, + 1.258 + ], + [ + -0.8388, + 1.2436 + ], + [ + -0.8604, + 1.2287 + ], + [ + -0.8817, + 1.2135 + ], + [ + -0.9027, + 1.198 + ], + [ + -0.9235, + 1.182 + ], + [ + -0.944, + 1.1657 + ], + [ + -0.9642, + 1.1491 + ], + [ + -0.9841, + 1.1321 + ], + [ + -1.0037, + 1.1147 + ], + [ + -1.023, + 1.097 + ], + [ + -1.042, + 1.079 + ], + [ + -1.0607, + 1.0607 + ], + [ + -1.079, + 1.042 + ], + [ + -1.097, + 1.023 + ], + [ + -1.1147, + 1.0037 + ], + [ + -1.1321, + 0.9841 + ], + [ + -1.1491, + 0.9642 + ], + [ + -1.1657, + 0.944 + ], + [ + -1.182, + 0.9235 + ], + [ + -1.198, + 0.9027 + ], + [ + -1.2135, + 0.8817 + ], + [ + -1.2287, + 0.8604 + ], + [ + -1.2436, + 0.8388 + ], + [ + -1.258, + 0.817 + ], + [ + -1.2721, + 0.7949 + ], + [ + -1.2858, + 0.7726 + ], + [ + -1.299, + 0.75 + ] + ], + "facet_vertices": [ + [ + 1.299, + 0.75 + ], + [ + 0.513, + 1.4095 + ], + [ + -0.513, + 1.4095 + ], + [ + -1.299, + 0.75 + ] + ], + "quadrature": [ + { + "pos": [ + 1.2105, + 0.8243 + ], + "facet_normal": [ + 0.6428, + 0.766 + ], + "true_normal": [ + 0.8265, + 0.5629 + ], + "facet_idx": 0 + }, + { + "pos": [ + 0.906, + 1.0798 + ], + "facet_normal": [ + 0.6428, + 0.766 + ], + "true_normal": [ + 0.6428, + 0.766 + ], + "facet_idx": 0 + }, + { + "pos": [ + 0.6016, + 1.3352 + ], + "facet_normal": [ + 0.6428, + 0.766 + ], + "true_normal": [ + 0.4108, + 0.9117 + ], + "facet_idx": 0 + }, + { + "pos": [ + 0.3974, + 1.4095 + ], + "facet_normal": [ + 0.0, + 1.0 + ], + "true_normal": [ + 0.2714, + 0.9625 + ], + "facet_idx": 1 + }, + { + "pos": [ + 0.0, + 1.4095 + ], + "facet_normal": [ + 0.0, + 1.0 + ], + "true_normal": [ + 0.0, + 1.0 + ], + "facet_idx": 1 + }, + { + "pos": [ + -0.3974, + 1.4095 + ], + "facet_normal": [ + 0.0, + 1.0 + ], + "true_normal": [ + -0.2714, + 0.9625 + ], + "facet_idx": 1 + }, + { + "pos": [ + -0.6016, + 1.3352 + ], + "facet_normal": [ + -0.6428, + 0.766 + ], + "true_normal": [ + -0.4108, + 0.9117 + ], + "facet_idx": 2 + }, + { + "pos": [ + -0.906, + 1.0798 + ], + "facet_normal": [ + -0.6428, + 0.766 + ], + "true_normal": [ + -0.6428, + 0.766 + ], + "facet_idx": 2 + }, + { + "pos": [ + -1.2105, + 0.8243 + ], + "facet_normal": [ + -0.6428, + 0.766 + ], + "true_normal": [ + -0.8265, + 0.5629 + ], + "facet_idx": 2 + } + ], + "radius_end": [ + 0.0, + 1.5 + ] +} \ No newline at end of file diff --git a/.claude/skills/cetz-figures/examples/domain-demo-data.json b/.claude/skills/cetz-figures/examples/domain-demo-data.json new file mode 100644 index 000000000..537920bb0 --- /dev/null +++ b/.claude/skills/cetz-figures/examples/domain-demo-data.json @@ -0,0 +1,6275 @@ +{ + "vertices": [ + [ + -2.4847, + -2.5034 + ], + [ + -2.1999, + -2.4889 + ], + [ + -1.9627, + -2.4734 + ], + [ + -1.6454, + -2.484 + ], + [ + -1.4008, + -2.5028 + ], + [ + -1.1072, + -2.4761 + ], + [ + -0.8119, + -2.4819 + ], + [ + -0.5432, + -2.5153 + ], + [ + -0.2569, + -2.5244 + ], + [ + 0.0383, + -2.4926 + ], + [ + 0.3145, + -2.5081 + ], + [ + 0.6064, + -2.478 + ], + [ + 0.8756, + -2.5171 + ], + [ + 1.1381, + -2.5255 + ], + [ + 1.4006, + -2.4897 + ], + [ + 1.7137, + -2.4738 + ], + [ + 1.9702, + -2.5073 + ], + [ + 2.2583, + -2.5174 + ], + [ + -2.3807, + -2.2589 + ], + [ + -2.0953, + -2.248 + ], + [ + -1.8035, + -2.2389 + ], + [ + -1.5088, + -2.268 + ], + [ + -1.2214, + -2.2404 + ], + [ + -0.9663, + -2.2694 + ], + [ + -0.6698, + -2.2777 + ], + [ + -0.4168, + -2.2851 + ], + [ + -0.1039, + -2.2483 + ], + [ + 0.1715, + -2.2418 + ], + [ + 0.4377, + -2.2537 + ], + [ + 0.6998, + -2.2791 + ], + [ + 1.0094, + -2.2591 + ], + [ + 1.2837, + -2.2427 + ], + [ + 1.5675, + -2.2545 + ], + [ + 1.8433, + -2.2685 + ], + [ + 2.0937, + -2.2611 + ], + [ + 2.384, + -2.2626 + ], + [ + -2.4802, + -2.0299 + ], + [ + -2.2447, + -2.0273 + ], + [ + -1.9516, + -2.006 + ], + [ + -1.6568, + -1.9991 + ], + [ + -1.3708, + -2.0203 + ], + [ + -1.0824, + -2.0337 + ], + [ + -0.8467, + -2.038 + ], + [ + -0.5275, + -2.0172 + ], + [ + -0.279, + -2.015 + ], + [ + 0.0005, + -2.004 + ], + [ + 0.297, + -2.0217 + ], + [ + 0.5689, + -2.0077 + ], + [ + 0.8523, + -2.0381 + ], + [ + 1.1186, + -1.9892 + ], + [ + 1.4429, + -2.0038 + ], + [ + 1.6869, + -1.9888 + ], + [ + 1.9956, + -2.0029 + ], + [ + 2.2572, + -2.0278 + ], + [ + -2.3826, + -1.75 + ], + [ + -2.0825, + -1.7892 + ], + [ + -1.8109, + -1.7681 + ], + [ + -1.5381, + -1.7526 + ], + [ + -1.2255, + -1.7602 + ], + [ + -0.9638, + -1.7654 + ], + [ + -0.6753, + -1.7641 + ], + [ + -0.4233, + -1.7773 + ], + [ + -0.1457, + -1.7729 + ], + [ + 0.1505, + -1.7924 + ], + [ + 0.4178, + -1.7676 + ], + [ + 0.7016, + -1.7487 + ], + [ + 1.0045, + -1.7811 + ], + [ + 1.2851, + -1.7993 + ], + [ + 1.5857, + -1.7735 + ], + [ + 1.8558, + -1.7959 + ], + [ + 2.1193, + -1.7731 + ], + [ + 2.4245, + -1.7685 + ], + [ + -2.5015, + -1.5431 + ], + [ + -2.2294, + -1.5289 + ], + [ + -1.9434, + -1.5568 + ], + [ + -1.6417, + -1.5079 + ], + [ + -1.4001, + -1.527 + ], + [ + -1.1219, + -1.5204 + ], + [ + -0.8323, + -1.5211 + ], + [ + -0.5273, + -1.515 + ], + [ + -0.282, + -1.5068 + ], + [ + 0.0049, + -1.556 + ], + [ + 0.3031, + -1.5373 + ], + [ + 0.5985, + -1.5128 + ], + [ + 0.8498, + -1.5047 + ], + [ + 1.1283, + -1.5292 + ], + [ + 1.4063, + -1.5056 + ], + [ + 1.6812, + -1.5555 + ], + [ + 1.9764, + -1.5025 + ], + [ + 2.2819, + -1.5161 + ], + [ + -2.3381, + -1.2655 + ], + [ + -2.0789, + -1.2979 + ], + [ + -1.7848, + -1.2785 + ], + [ + -1.5271, + -1.3103 + ], + [ + -1.2262, + -1.3009 + ], + [ + -0.9355, + -1.3021 + ], + [ + -0.7011, + -1.269 + ], + [ + -0.4194, + -1.3055 + ], + [ + -0.1144, + -1.2666 + ], + [ + 0.143, + -1.2982 + ], + [ + 0.4555, + -1.2611 + ], + [ + 0.72, + -1.3075 + ], + [ + 0.9728, + -1.3027 + ], + [ + 1.2594, + -1.2776 + ], + [ + 1.5388, + -1.2872 + ], + [ + 1.8509, + -1.283 + ], + [ + 2.1032, + -1.2705 + ], + [ + 2.4121, + -1.2742 + ], + [ + -2.5207, + -1.0661 + ], + [ + -2.1961, + -1.0508 + ], + [ + -1.9511, + -1.0457 + ], + [ + -1.6509, + -1.0196 + ], + [ + -1.392, + -1.0213 + ], + [ + -1.1266, + -1.042 + ], + [ + -0.8125, + -1.0671 + ], + [ + -0.5601, + -1.0496 + ], + [ + -0.2339, + -1.0397 + ], + [ + 0.0442, + -1.028 + ], + [ + 0.2982, + -1.0291 + ], + [ + 0.553, + -1.067 + ], + [ + 0.8784, + -1.0285 + ], + [ + 1.125, + -1.0434 + ], + [ + 1.4259, + -1.0245 + ], + [ + 1.7058, + -1.05 + ], + [ + 1.973, + -1.0492 + ], + [ + 2.2685, + -1.0245 + ], + [ + -2.3626, + -0.8167 + ], + [ + -2.0947, + -0.7888 + ], + [ + -1.7823, + -0.8247 + ], + [ + -1.5443, + -0.7973 + ], + [ + -1.2598, + -0.7844 + ], + [ + -0.9706, + -0.8225 + ], + [ + -0.6564, + -0.8213 + ], + [ + -0.4121, + -0.822 + ], + [ + -0.1415, + -0.8294 + ], + [ + 0.1351, + -0.8208 + ], + [ + 0.415, + -0.7975 + ], + [ + 0.7301, + -0.8085 + ], + [ + 0.9898, + -0.8023 + ], + [ + 1.301, + -0.7829 + ], + [ + 1.5344, + -0.8204 + ], + [ + 1.8253, + -0.8166 + ], + [ + 2.124, + -0.8073 + ], + [ + 2.3748, + -0.8097 + ], + [ + -2.4987, + -0.5824 + ], + [ + -2.2013, + -0.5852 + ], + [ + -1.9162, + -0.5826 + ], + [ + -1.6408, + -0.5376 + ], + [ + -1.3531, + -0.5432 + ], + [ + -1.0843, + -0.5521 + ], + [ + -0.8044, + -0.5806 + ], + [ + -0.538, + -0.5593 + ], + [ + -0.24, + -0.5622 + ], + [ + 0.0136, + -0.5523 + ], + [ + 0.2869, + -0.5803 + ], + [ + 0.5788, + -0.5648 + ], + [ + 0.845, + -0.5675 + ], + [ + 1.1325, + -0.5698 + ], + [ + 1.4132, + -0.5497 + ], + [ + 1.6886, + -0.535 + ], + [ + 2.0033, + -0.5612 + ], + [ + 2.2504, + -0.5581 + ], + [ + -2.3405, + -0.3091 + ], + [ + -2.063, + -0.3158 + ], + [ + -1.7926, + -0.3295 + ], + [ + -1.5068, + -0.3343 + ], + [ + -1.2291, + -0.2974 + ], + [ + -0.9806, + -0.3112 + ], + [ + -0.7027, + -0.305 + ], + [ + -0.4233, + -0.2932 + ], + [ + -0.1403, + -0.2919 + ], + [ + 0.1768, + -0.3124 + ], + [ + 0.4558, + -0.3011 + ], + [ + 0.745, + -0.3314 + ], + [ + 1.005, + -0.3403 + ], + [ + 1.2865, + -0.336 + ], + [ + 1.5636, + -0.3136 + ], + [ + 1.8381, + -0.3163 + ], + [ + 2.1348, + -0.3009 + ], + [ + 2.3996, + -0.312 + ], + [ + -2.4759, + -0.0964 + ], + [ + -2.2414, + -0.0982 + ], + [ + -1.9312, + -0.0797 + ], + [ + -1.6446, + -0.0655 + ], + [ + -1.3893, + -0.0528 + ], + [ + -1.0853, + -0.088 + ], + [ + -0.8276, + -0.0855 + ], + [ + -0.5592, + -0.0949 + ], + [ + -0.2356, + -0.0786 + ], + [ + 0.0135, + -0.0623 + ], + [ + 0.303, + -0.0507 + ], + [ + 0.5957, + -0.0763 + ], + [ + 0.8531, + -0.0479 + ], + [ + 1.1522, + -0.0499 + ], + [ + 1.3986, + -0.0555 + ], + [ + 1.7077, + -0.0963 + ], + [ + 1.9849, + -0.0647 + ], + [ + 2.2327, + -0.0777 + ], + [ + -2.3418, + 0.1559 + ], + [ + -2.0823, + 0.1641 + ], + [ + -1.8111, + 0.1908 + ], + [ + -1.5042, + 0.1456 + ], + [ + -1.2122, + 0.1886 + ], + [ + -0.9721, + 0.1862 + ], + [ + -0.702, + 0.1953 + ], + [ + -0.3907, + 0.1758 + ], + [ + -0.1429, + 0.1896 + ], + [ + 0.1336, + 0.1528 + ], + [ + 0.42, + 0.1829 + ], + [ + 0.7031, + 0.1904 + ], + [ + 1.0088, + 0.1414 + ], + [ + 1.2523, + 0.1423 + ], + [ + 1.5659, + 0.1842 + ], + [ + 1.8254, + 0.1869 + ], + [ + 2.0952, + 0.1842 + ], + [ + 2.424, + 0.1826 + ], + [ + -2.4889, + 0.4288 + ], + [ + -2.2458, + 0.3931 + ], + [ + -1.961, + 0.4101 + ], + [ + -1.6463, + 0.4171 + ], + [ + -1.3603, + 0.3905 + ], + [ + -1.0869, + 0.3927 + ], + [ + -0.8328, + 0.4216 + ], + [ + -0.5131, + 0.4161 + ], + [ + -0.2849, + 0.4164 + ], + [ + -0.0056, + 0.4314 + ], + [ + 0.3117, + 0.3915 + ], + [ + 0.5571, + 0.3921 + ], + [ + 0.8869, + 0.4075 + ], + [ + 1.1559, + 0.4175 + ], + [ + 1.4241, + 0.39 + ], + [ + 1.725, + 0.3987 + ], + [ + 1.9844, + 0.421 + ], + [ + 2.2684, + 0.4345 + ], + [ + -2.3797, + 0.6528 + ], + [ + -2.0854, + 0.6509 + ], + [ + -1.8213, + 0.6318 + ], + [ + -1.5324, + 0.6414 + ], + [ + -1.244, + 0.6585 + ], + [ + -0.9525, + 0.6474 + ], + [ + -0.6851, + 0.6365 + ], + [ + -0.3951, + 0.6421 + ], + [ + -0.146, + 0.6478 + ], + [ + 0.1586, + 0.637 + ], + [ + 0.4441, + 0.656 + ], + [ + 0.7313, + 0.6606 + ], + [ + 1.0085, + 0.642 + ], + [ + 1.2961, + 0.6551 + ], + [ + 1.5562, + 0.6594 + ], + [ + 1.8322, + 0.653 + ], + [ + 2.1333, + 0.674 + ], + [ + 2.4236, + 0.6525 + ], + [ + -2.4989, + 0.9116 + ], + [ + -2.2304, + 0.9137 + ], + [ + -1.9403, + 0.8733 + ], + [ + -1.684, + 0.914 + ], + [ + -1.4049, + 0.8825 + ], + [ + -1.1093, + 0.8765 + ], + [ + -0.8304, + 0.9084 + ], + [ + -0.5672, + 0.9131 + ], + [ + -0.24, + 0.8877 + ], + [ + 0.0006, + 0.9005 + ], + [ + 0.2787, + 0.8873 + ], + [ + 0.6057, + 0.9226 + ], + [ + 0.8752, + 0.8842 + ], + [ + 1.1505, + 0.9063 + ], + [ + 1.4137, + 0.9027 + ], + [ + 1.6726, + 0.8785 + ], + [ + 1.9814, + 0.876 + ], + [ + 2.2413, + 0.9137 + ], + [ + -2.3326, + 1.1404 + ], + [ + -2.061, + 1.1648 + ], + [ + -1.8201, + 1.1344 + ], + [ + -1.526, + 1.1138 + ], + [ + -1.2257, + 1.1336 + ], + [ + -0.9617, + 1.1177 + ], + [ + -0.6979, + 1.1601 + ], + [ + -0.4255, + 1.1223 + ], + [ + -0.1316, + 1.1368 + ], + [ + 0.1648, + 1.1369 + ], + [ + 0.4167, + 1.123 + ], + [ + 0.7392, + 1.145 + ], + [ + 1.0084, + 1.1468 + ], + [ + 1.2947, + 1.1126 + ], + [ + 1.5525, + 1.1395 + ], + [ + 1.831, + 1.1566 + ], + [ + 2.119, + 1.1523 + ], + [ + 2.4197, + 1.1376 + ], + [ + -2.4771, + 1.3847 + ], + [ + -2.2004, + 1.3709 + ], + [ + -1.9401, + 1.3816 + ], + [ + -1.6821, + 1.3741 + ], + [ + -1.3566, + 1.3871 + ], + [ + -1.1181, + 1.3708 + ], + [ + -0.8373, + 1.3532 + ], + [ + -0.5161, + 1.3769 + ], + [ + -0.2708, + 1.3853 + ], + [ + -0.0076, + 1.3674 + ], + [ + 0.3114, + 1.3873 + ], + [ + 0.607, + 1.3865 + ], + [ + 0.8587, + 1.3944 + ], + [ + 1.1626, + 1.3922 + ], + [ + 1.4459, + 1.3956 + ], + [ + 1.7205, + 1.3582 + ], + [ + 1.993, + 1.3764 + ], + [ + 2.263, + 1.3884 + ], + [ + -2.3337, + 1.6494 + ], + [ + -2.0919, + 1.6354 + ], + [ + -1.786, + 1.6137 + ], + [ + -1.5411, + 1.5966 + ], + [ + -1.2245, + 1.6217 + ], + [ + -0.9328, + 1.6203 + ], + [ + -0.6532, + 1.6173 + ], + [ + -0.3836, + 1.599 + ], + [ + -0.1169, + 1.6392 + ], + [ + 0.1838, + 1.6403 + ], + [ + 0.4141, + 1.6152 + ], + [ + 0.6947, + 1.6004 + ], + [ + 1.0098, + 1.6342 + ], + [ + 1.2953, + 1.6427 + ], + [ + 1.5734, + 1.6391 + ], + [ + 1.8147, + 1.6074 + ], + [ + 2.1268, + 1.6423 + ], + [ + 2.3723, + 1.6231 + ], + [ + -2.4901, + 1.8384 + ], + [ + -2.2255, + 1.8869 + ], + [ + -1.9304, + 1.8501 + ], + [ + -1.6402, + 1.8563 + ], + [ + -1.3602, + 1.8535 + ], + [ + -1.0949, + 1.859 + ], + [ + -0.8326, + 1.8864 + ], + [ + -0.5575, + 1.8415 + ], + [ + -0.2689, + 1.877 + ], + [ + 0.0372, + 1.8927 + ], + [ + 0.2886, + 1.8596 + ], + [ + 0.5597, + 1.869 + ], + [ + 0.8879, + 1.876 + ], + [ + 1.1453, + 1.8587 + ], + [ + 1.4433, + 1.8646 + ], + [ + 1.6795, + 1.8572 + ], + [ + 1.9558, + 1.8481 + ], + [ + 2.233, + 1.8622 + ], + [ + -2.3525, + 2.0985 + ], + [ + -2.0845, + 2.133 + ], + [ + -1.7859, + 2.1095 + ], + [ + -1.5321, + 2.1295 + ], + [ + -1.2548, + 2.0975 + ], + [ + -0.9371, + 2.1089 + ], + [ + -0.6664, + 2.1123 + ], + [ + -0.3914, + 2.096 + ], + [ + -0.1345, + 2.0973 + ], + [ + 0.1407, + 2.1282 + ], + [ + 0.4279, + 2.1107 + ], + [ + 0.7364, + 2.1231 + ], + [ + 0.9965, + 2.1059 + ], + [ + 1.3077, + 2.117 + ], + [ + 1.5776, + 2.1298 + ], + [ + 1.8561, + 2.0896 + ], + [ + 2.1235, + 2.085 + ], + [ + 2.4086, + 2.1328 + ], + [ + -2.4993, + 2.346 + ], + [ + -2.246, + 2.3755 + ], + [ + -1.9622, + 2.324 + ], + [ + -1.6742, + 2.3254 + ], + [ + -1.3825, + 2.3506 + ], + [ + -1.1105, + 2.3246 + ], + [ + -0.8418, + 2.3433 + ], + [ + -0.5646, + 2.3608 + ], + [ + -0.2764, + 2.3387 + ], + [ + 0.0141, + 2.3451 + ], + [ + 0.2721, + 2.328 + ], + [ + 0.6003, + 2.3218 + ], + [ + 0.8605, + 2.3492 + ], + [ + 1.1307, + 2.3459 + ], + [ + 1.4357, + 2.3688 + ], + [ + 1.6866, + 2.3398 + ], + [ + 1.9656, + 2.3486 + ], + [ + 2.2703, + 2.3345 + ], + [ + -2.3695, + 2.6163 + ], + [ + -2.1053, + 2.59 + ], + [ + -1.7882, + 2.5727 + ], + [ + -1.5453, + 2.572 + ], + [ + -1.2165, + 2.5647 + ], + [ + -0.9775, + 2.566 + ], + [ + -0.7018, + 2.599 + ], + [ + -0.4145, + 2.5961 + ], + [ + -0.1149, + 2.6118 + ], + [ + 0.1323, + 2.612 + ], + [ + 0.4467, + 2.5733 + ], + [ + 0.7353, + 2.6121 + ], + [ + 0.9862, + 2.6157 + ], + [ + 1.2774, + 2.598 + ], + [ + 1.5871, + 2.5845 + ], + [ + 1.8575, + 2.5821 + ], + [ + 2.1368, + 2.5979 + ], + [ + 2.3841, + 2.5874 + ] + ], + "triangles": [ + [ + 107, + 89, + 71 + ], + [ + 252, + 360, + 108 + ], + [ + 0, + 72, + 108 + ], + [ + 216, + 252, + 108 + ], + [ + 107, + 179, + 143 + ], + [ + 380, + 363, + 381 + ], + [ + 251, + 250, + 233 + ], + [ + 370, + 387, + 369 + ], + [ + 36, + 37, + 54 + ], + [ + 36, + 72, + 0 + ], + [ + 72, + 36, + 54 + ], + [ + 37, + 55, + 54 + ], + [ + 18, + 36, + 0 + ], + [ + 36, + 18, + 37 + ], + [ + 198, + 216, + 180 + ], + [ + 216, + 198, + 217 + ], + [ + 234, + 216, + 217 + ], + [ + 234, + 253, + 252 + ], + [ + 216, + 234, + 252 + ], + [ + 150, + 149, + 131 + ], + [ + 20, + 21, + 39 + ], + [ + 21, + 20, + 3 + ], + [ + 21, + 40, + 39 + ], + [ + 40, + 21, + 22 + ], + [ + 40, + 41, + 58 + ], + [ + 41, + 40, + 22 + ], + [ + 40, + 57, + 39 + ], + [ + 57, + 40, + 58 + ], + [ + 144, + 216, + 108 + ], + [ + 216, + 144, + 180 + ], + [ + 146, + 163, + 145 + ], + [ + 364, + 363, + 345 + ], + [ + 363, + 364, + 381 + ], + [ + 381, + 384, + 378 + ], + [ + 215, + 179, + 107 + ], + [ + 215, + 107, + 71 + ], + [ + 215, + 251, + 233 + ], + [ + 161, + 179, + 178 + ], + [ + 160, + 161, + 178 + ], + [ + 179, + 161, + 143 + ], + [ + 161, + 142, + 143 + ], + [ + 142, + 161, + 160 + ], + [ + 177, + 160, + 178 + ], + [ + 270, + 289, + 288 + ], + [ + 253, + 270, + 252 + ], + [ + 270, + 288, + 252 + ], + [ + 307, + 289, + 290 + ], + [ + 308, + 307, + 290 + ], + [ + 307, + 308, + 326 + ], + [ + 308, + 327, + 326 + ], + [ + 327, + 308, + 309 + ], + [ + 362, + 363, + 380 + ], + [ + 212, + 194, + 195 + ], + [ + 175, + 194, + 193 + ], + [ + 250, + 232, + 233 + ], + [ + 267, + 268, + 285 + ], + [ + 250, + 269, + 268 + ], + [ + 269, + 250, + 251 + ], + [ + 287, + 269, + 251 + ], + [ + 305, + 287, + 323 + ], + [ + 301, + 302, + 319 + ], + [ + 287, + 359, + 323 + ], + [ + 370, + 352, + 371 + ], + [ + 394, + 390, + 391 + ], + [ + 391, + 390, + 373 + ], + [ + 389, + 390, + 387 + ], + [ + 388, + 370, + 371 + ], + [ + 389, + 388, + 371 + ], + [ + 370, + 388, + 387 + ], + [ + 388, + 389, + 387 + ], + [ + 372, + 389, + 371 + ], + [ + 390, + 372, + 373 + ], + [ + 372, + 390, + 389 + ], + [ + 38, + 55, + 37 + ], + [ + 38, + 20, + 39 + ], + [ + 20, + 2, + 3 + ], + [ + 35, + 34, + 17 + ], + [ + 181, + 198, + 180 + ], + [ + 181, + 163, + 182 + ], + [ + 235, + 234, + 217 + ], + [ + 254, + 235, + 236 + ], + [ + 235, + 254, + 253 + ], + [ + 234, + 235, + 253 + ], + [ + 149, + 130, + 131 + ], + [ + 168, + 167, + 150 + ], + [ + 167, + 149, + 150 + ], + [ + 166, + 167, + 185 + ], + [ + 167, + 166, + 149 + ], + [ + 227, + 226, + 208 + ], + [ + 225, + 226, + 243 + ], + [ + 245, + 227, + 228 + ], + [ + 5, + 23, + 22 + ], + [ + 23, + 41, + 22 + ], + [ + 23, + 5, + 6 + ], + [ + 4, + 5, + 22 + ], + [ + 21, + 4, + 22 + ], + [ + 4, + 21, + 3 + ], + [ + 4, + 7, + 6 + ], + [ + 5, + 4, + 6 + ], + [ + 8, + 4, + 0 + ], + [ + 7, + 4, + 8 + ], + [ + 76, + 57, + 58 + ], + [ + 126, + 144, + 108 + ], + [ + 144, + 126, + 145 + ], + [ + 72, + 90, + 108 + ], + [ + 55, + 73, + 54 + ], + [ + 73, + 72, + 54 + ], + [ + 73, + 90, + 72 + ], + [ + 90, + 73, + 91 + ], + [ + 164, + 163, + 146 + ], + [ + 163, + 164, + 182 + ], + [ + 255, + 254, + 236 + ], + [ + 255, + 256, + 273 + ], + [ + 272, + 255, + 273 + ], + [ + 255, + 272, + 254 + ], + [ + 309, + 292, + 310 + ], + [ + 292, + 293, + 310 + ], + [ + 240, + 222, + 223 + ], + [ + 187, + 168, + 169 + ], + [ + 188, + 187, + 169 + ], + [ + 206, + 188, + 189 + ], + [ + 242, + 225, + 243 + ], + [ + 364, + 382, + 381 + ], + [ + 382, + 364, + 365 + ], + [ + 382, + 384, + 381 + ], + [ + 346, + 364, + 345 + ], + [ + 364, + 346, + 365 + ], + [ + 346, + 347, + 365 + ], + [ + 347, + 346, + 329 + ], + [ + 125, + 107, + 143 + ], + [ + 142, + 125, + 143 + ], + [ + 177, + 159, + 160 + ], + [ + 291, + 272, + 273 + ], + [ + 292, + 291, + 273 + ], + [ + 291, + 292, + 309 + ], + [ + 272, + 291, + 290 + ], + [ + 291, + 308, + 290 + ], + [ + 308, + 291, + 309 + ], + [ + 270, + 271, + 289 + ], + [ + 289, + 271, + 290 + ], + [ + 271, + 272, + 290 + ], + [ + 271, + 270, + 253 + ], + [ + 254, + 271, + 253 + ], + [ + 272, + 271, + 254 + ], + [ + 325, + 307, + 326 + ], + [ + 289, + 306, + 288 + ], + [ + 307, + 306, + 289 + ], + [ + 325, + 306, + 307 + ], + [ + 362, + 344, + 363 + ], + [ + 363, + 344, + 345 + ], + [ + 344, + 327, + 345 + ], + [ + 327, + 344, + 326 + ], + [ + 379, + 362, + 380 + ], + [ + 379, + 381, + 378 + ], + [ + 379, + 380, + 381 + ], + [ + 325, + 343, + 342 + ], + [ + 343, + 325, + 326 + ], + [ + 344, + 343, + 326 + ], + [ + 343, + 344, + 362 + ], + [ + 227, + 209, + 228 + ], + [ + 192, + 209, + 191 + ], + [ + 209, + 208, + 191 + ], + [ + 209, + 227, + 208 + ], + [ + 210, + 229, + 228 + ], + [ + 210, + 192, + 193 + ], + [ + 209, + 210, + 228 + ], + [ + 210, + 209, + 192 + ], + [ + 248, + 267, + 266 + ], + [ + 194, + 176, + 195 + ], + [ + 176, + 194, + 175 + ], + [ + 176, + 177, + 195 + ], + [ + 176, + 159, + 177 + ], + [ + 176, + 175, + 158 + ], + [ + 159, + 176, + 158 + ], + [ + 211, + 194, + 212 + ], + [ + 194, + 211, + 193 + ], + [ + 211, + 210, + 193 + ], + [ + 210, + 211, + 229 + ], + [ + 213, + 212, + 195 + ], + [ + 214, + 215, + 233 + ], + [ + 232, + 214, + 233 + ], + [ + 213, + 214, + 232 + ], + [ + 284, + 267, + 285 + ], + [ + 267, + 284, + 266 + ], + [ + 302, + 320, + 319 + ], + [ + 337, + 318, + 319 + ], + [ + 318, + 301, + 319 + ], + [ + 359, + 341, + 323 + ], + [ + 394, + 377, + 395 + ], + [ + 377, + 359, + 395 + ], + [ + 392, + 394, + 391 + ], + [ + 334, + 333, + 315 + ], + [ + 336, + 318, + 337 + ], + [ + 318, + 336, + 317 + ], + [ + 38, + 56, + 55 + ], + [ + 57, + 56, + 39 + ], + [ + 56, + 38, + 39 + ], + [ + 1, + 18, + 0 + ], + [ + 2, + 1, + 3 + ], + [ + 4, + 1, + 0 + ], + [ + 1, + 4, + 3 + ], + [ + 38, + 19, + 20 + ], + [ + 19, + 2, + 20 + ], + [ + 19, + 38, + 37 + ], + [ + 19, + 1, + 2 + ], + [ + 18, + 19, + 37 + ], + [ + 1, + 19, + 18 + ], + [ + 53, + 35, + 71 + ], + [ + 53, + 34, + 35 + ], + [ + 16, + 13, + 17 + ], + [ + 34, + 16, + 17 + ], + [ + 14, + 15, + 32 + ], + [ + 16, + 14, + 13 + ], + [ + 14, + 16, + 15 + ], + [ + 15, + 33, + 32 + ], + [ + 33, + 16, + 34 + ], + [ + 16, + 33, + 15 + ], + [ + 163, + 162, + 145 + ], + [ + 181, + 162, + 163 + ], + [ + 162, + 144, + 145 + ], + [ + 144, + 162, + 180 + ], + [ + 162, + 181, + 180 + ], + [ + 218, + 235, + 217 + ], + [ + 235, + 218, + 236 + ], + [ + 218, + 219, + 236 + ], + [ + 219, + 218, + 200 + ], + [ + 148, + 130, + 149 + ], + [ + 148, + 166, + 165 + ], + [ + 166, + 148, + 149 + ], + [ + 229, + 246, + 228 + ], + [ + 246, + 245, + 228 + ], + [ + 245, + 244, + 227 + ], + [ + 244, + 262, + 243 + ], + [ + 226, + 244, + 243 + ], + [ + 244, + 226, + 227 + ], + [ + 175, + 157, + 158 + ], + [ + 25, + 7, + 8 + ], + [ + 168, + 151, + 169 + ], + [ + 151, + 168, + 150 + ], + [ + 130, + 113, + 131 + ], + [ + 118, + 99, + 100 + ], + [ + 25, + 44, + 43 + ], + [ + 64, + 46, + 47 + ], + [ + 46, + 64, + 63 + ], + [ + 23, + 42, + 41 + ], + [ + 60, + 42, + 43 + ], + [ + 75, + 76, + 93 + ], + [ + 76, + 75, + 57 + ], + [ + 75, + 56, + 57 + ], + [ + 92, + 110, + 91 + ], + [ + 92, + 75, + 93 + ], + [ + 148, + 129, + 130 + ], + [ + 109, + 90, + 91 + ], + [ + 110, + 109, + 91 + ], + [ + 109, + 126, + 108 + ], + [ + 90, + 109, + 108 + ], + [ + 127, + 146, + 145 + ], + [ + 127, + 109, + 110 + ], + [ + 126, + 127, + 145 + ], + [ + 109, + 127, + 126 + ], + [ + 256, + 237, + 238 + ], + [ + 219, + 237, + 236 + ], + [ + 237, + 255, + 236 + ], + [ + 255, + 237, + 256 + ], + [ + 201, + 219, + 200 + ], + [ + 274, + 275, + 293 + ], + [ + 274, + 292, + 273 + ], + [ + 292, + 274, + 293 + ], + [ + 256, + 274, + 273 + ], + [ + 240, + 239, + 222 + ], + [ + 186, + 203, + 185 + ], + [ + 187, + 186, + 168 + ], + [ + 167, + 186, + 185 + ], + [ + 186, + 167, + 168 + ], + [ + 224, + 206, + 225 + ], + [ + 242, + 224, + 225 + ], + [ + 205, + 187, + 188 + ], + [ + 206, + 205, + 188 + ], + [ + 205, + 224, + 223 + ], + [ + 224, + 205, + 206 + ], + [ + 207, + 226, + 225 + ], + [ + 206, + 207, + 225 + ], + [ + 207, + 206, + 189 + ], + [ + 226, + 207, + 208 + ], + [ + 296, + 314, + 313 + ], + [ + 333, + 314, + 315 + ], + [ + 314, + 332, + 313 + ], + [ + 314, + 333, + 332 + ], + [ + 241, + 242, + 260 + ], + [ + 259, + 241, + 260 + ], + [ + 241, + 259, + 240 + ], + [ + 241, + 240, + 223 + ], + [ + 224, + 241, + 223 + ], + [ + 241, + 224, + 242 + ], + [ + 383, + 382, + 365 + ], + [ + 382, + 383, + 384 + ], + [ + 332, + 331, + 313 + ], + [ + 331, + 312, + 313 + ], + [ + 294, + 275, + 276 + ], + [ + 275, + 294, + 293 + ], + [ + 295, + 296, + 313 + ], + [ + 312, + 295, + 313 + ], + [ + 295, + 294, + 276 + ], + [ + 294, + 295, + 312 + ], + [ + 327, + 328, + 345 + ], + [ + 328, + 346, + 345 + ], + [ + 328, + 309, + 310 + ], + [ + 328, + 327, + 309 + ], + [ + 329, + 328, + 310 + ], + [ + 346, + 328, + 329 + ], + [ + 331, + 349, + 348 + ], + [ + 349, + 331, + 332 + ], + [ + 349, + 367, + 348 + ], + [ + 367, + 349, + 368 + ], + [ + 384, + 386, + 378 + ], + [ + 387, + 386, + 369 + ], + [ + 386, + 368, + 369 + ], + [ + 386, + 390, + 378 + ], + [ + 390, + 386, + 387 + ], + [ + 351, + 352, + 370 + ], + [ + 351, + 370, + 369 + ], + [ + 351, + 334, + 352 + ], + [ + 334, + 351, + 333 + ], + [ + 324, + 325, + 342 + ], + [ + 324, + 306, + 325 + ], + [ + 306, + 324, + 288 + ], + [ + 360, + 324, + 342 + ], + [ + 324, + 360, + 252 + ], + [ + 288, + 324, + 252 + ], + [ + 379, + 361, + 362 + ], + [ + 361, + 343, + 362 + ], + [ + 360, + 361, + 378 + ], + [ + 361, + 379, + 378 + ], + [ + 361, + 360, + 342 + ], + [ + 343, + 361, + 342 + ], + [ + 230, + 211, + 212 + ], + [ + 211, + 230, + 229 + ], + [ + 231, + 213, + 232 + ], + [ + 230, + 231, + 248 + ], + [ + 213, + 231, + 212 + ], + [ + 231, + 230, + 212 + ], + [ + 214, + 197, + 215 + ], + [ + 179, + 197, + 178 + ], + [ + 215, + 197, + 179 + ], + [ + 196, + 213, + 195 + ], + [ + 196, + 214, + 213 + ], + [ + 196, + 197, + 214 + ], + [ + 177, + 196, + 195 + ], + [ + 196, + 177, + 178 + ], + [ + 197, + 196, + 178 + ], + [ + 303, + 284, + 285 + ], + [ + 284, + 303, + 302 + ], + [ + 303, + 320, + 302 + ], + [ + 304, + 303, + 285 + ], + [ + 284, + 283, + 266 + ], + [ + 283, + 301, + 282 + ], + [ + 301, + 283, + 302 + ], + [ + 283, + 284, + 302 + ], + [ + 304, + 286, + 305 + ], + [ + 286, + 269, + 287 + ], + [ + 305, + 286, + 287 + ], + [ + 286, + 304, + 285 + ], + [ + 268, + 286, + 285 + ], + [ + 269, + 286, + 268 + ], + [ + 322, + 305, + 323 + ], + [ + 322, + 304, + 305 + ], + [ + 341, + 322, + 323 + ], + [ + 322, + 341, + 340 + ], + [ + 355, + 338, + 356 + ], + [ + 320, + 338, + 319 + ], + [ + 338, + 337, + 319 + ], + [ + 338, + 355, + 337 + ], + [ + 300, + 318, + 317 + ], + [ + 300, + 281, + 282 + ], + [ + 301, + 300, + 282 + ], + [ + 318, + 300, + 301 + ], + [ + 281, + 264, + 282 + ], + [ + 246, + 264, + 245 + ], + [ + 263, + 281, + 280 + ], + [ + 263, + 244, + 245 + ], + [ + 264, + 263, + 245 + ], + [ + 263, + 264, + 281 + ], + [ + 262, + 263, + 280 + ], + [ + 244, + 263, + 262 + ], + [ + 299, + 300, + 317 + ], + [ + 300, + 299, + 281 + ], + [ + 299, + 298, + 280 + ], + [ + 281, + 299, + 280 + ], + [ + 392, + 393, + 394 + ], + [ + 358, + 341, + 359 + ], + [ + 377, + 358, + 359 + ], + [ + 341, + 358, + 340 + ], + [ + 358, + 357, + 340 + ], + [ + 374, + 355, + 356 + ], + [ + 374, + 392, + 391 + ], + [ + 374, + 391, + 373 + ], + [ + 355, + 374, + 373 + ], + [ + 316, + 334, + 315 + ], + [ + 298, + 316, + 315 + ], + [ + 316, + 299, + 317 + ], + [ + 299, + 316, + 298 + ], + [ + 336, + 335, + 317 + ], + [ + 334, + 335, + 352 + ], + [ + 335, + 316, + 317 + ], + [ + 316, + 335, + 334 + ], + [ + 372, + 354, + 373 + ], + [ + 354, + 336, + 337 + ], + [ + 354, + 355, + 373 + ], + [ + 355, + 354, + 337 + ], + [ + 73, + 74, + 91 + ], + [ + 74, + 92, + 91 + ], + [ + 74, + 73, + 55 + ], + [ + 56, + 74, + 55 + ], + [ + 75, + 74, + 56 + ], + [ + 92, + 74, + 75 + ], + [ + 89, + 70, + 71 + ], + [ + 70, + 53, + 71 + ], + [ + 31, + 14, + 32 + ], + [ + 14, + 31, + 13 + ], + [ + 198, + 199, + 217 + ], + [ + 199, + 218, + 217 + ], + [ + 218, + 199, + 200 + ], + [ + 200, + 199, + 182 + ], + [ + 199, + 181, + 182 + ], + [ + 181, + 199, + 198 + ], + [ + 247, + 246, + 229 + ], + [ + 230, + 247, + 229 + ], + [ + 247, + 230, + 248 + ], + [ + 247, + 248, + 266 + ], + [ + 208, + 190, + 191 + ], + [ + 207, + 190, + 208 + ], + [ + 171, + 190, + 189 + ], + [ + 190, + 207, + 189 + ], + [ + 119, + 118, + 100 + ], + [ + 120, + 121, + 138 + ], + [ + 122, + 121, + 103 + ], + [ + 121, + 102, + 103 + ], + [ + 102, + 121, + 120 + ], + [ + 141, + 142, + 160 + ], + [ + 159, + 141, + 160 + ], + [ + 172, + 171, + 154 + ], + [ + 155, + 172, + 154 + ], + [ + 190, + 172, + 191 + ], + [ + 172, + 190, + 171 + ], + [ + 137, + 120, + 138 + ], + [ + 137, + 119, + 120 + ], + [ + 173, + 192, + 191 + ], + [ + 172, + 173, + 191 + ], + [ + 173, + 172, + 155 + ], + [ + 7, + 24, + 6 + ], + [ + 25, + 24, + 7 + ], + [ + 24, + 23, + 6 + ], + [ + 24, + 42, + 23 + ], + [ + 24, + 25, + 43 + ], + [ + 42, + 24, + 43 + ], + [ + 117, + 99, + 118 + ], + [ + 99, + 117, + 98 + ], + [ + 171, + 153, + 154 + ], + [ + 113, + 114, + 131 + ], + [ + 114, + 113, + 95 + ], + [ + 113, + 94, + 95 + ], + [ + 76, + 94, + 93 + ], + [ + 29, + 48, + 47 + ], + [ + 64, + 82, + 63 + ], + [ + 99, + 82, + 100 + ], + [ + 26, + 25, + 8 + ], + [ + 26, + 44, + 25 + ], + [ + 11, + 28, + 10 + ], + [ + 28, + 11, + 29 + ], + [ + 28, + 29, + 47 + ], + [ + 46, + 28, + 47 + ], + [ + 59, + 42, + 60 + ], + [ + 78, + 59, + 60 + ], + [ + 41, + 59, + 58 + ], + [ + 42, + 59, + 41 + ], + [ + 127, + 128, + 146 + ], + [ + 128, + 127, + 110 + ], + [ + 164, + 147, + 165 + ], + [ + 147, + 164, + 146 + ], + [ + 147, + 148, + 165 + ], + [ + 147, + 129, + 148 + ], + [ + 128, + 147, + 146 + ], + [ + 147, + 128, + 129 + ], + [ + 94, + 112, + 93 + ], + [ + 112, + 94, + 113 + ], + [ + 112, + 113, + 130 + ], + [ + 129, + 112, + 130 + ], + [ + 183, + 200, + 182 + ], + [ + 183, + 201, + 200 + ], + [ + 164, + 183, + 182 + ], + [ + 183, + 164, + 165 + ], + [ + 201, + 220, + 219 + ], + [ + 237, + 220, + 238 + ], + [ + 220, + 237, + 219 + ], + [ + 274, + 257, + 275 + ], + [ + 239, + 257, + 238 + ], + [ + 257, + 256, + 238 + ], + [ + 257, + 274, + 256 + ], + [ + 259, + 258, + 240 + ], + [ + 258, + 239, + 240 + ], + [ + 275, + 258, + 276 + ], + [ + 258, + 259, + 276 + ], + [ + 257, + 258, + 275 + ], + [ + 258, + 257, + 239 + ], + [ + 186, + 204, + 203 + ], + [ + 204, + 186, + 187 + ], + [ + 205, + 204, + 187 + ], + [ + 203, + 204, + 222 + ], + [ + 222, + 204, + 223 + ], + [ + 204, + 205, + 223 + ], + [ + 261, + 278, + 260 + ], + [ + 262, + 261, + 243 + ], + [ + 261, + 242, + 243 + ], + [ + 242, + 261, + 260 + ], + [ + 297, + 298, + 315 + ], + [ + 314, + 297, + 315 + ], + [ + 297, + 314, + 296 + ], + [ + 278, + 297, + 296 + ], + [ + 347, + 366, + 365 + ], + [ + 366, + 383, + 365 + ], + [ + 366, + 347, + 348 + ], + [ + 367, + 366, + 348 + ], + [ + 383, + 366, + 384 + ], + [ + 366, + 367, + 384 + ], + [ + 347, + 330, + 348 + ], + [ + 330, + 331, + 348 + ], + [ + 330, + 347, + 329 + ], + [ + 331, + 330, + 312 + ], + [ + 277, + 259, + 260 + ], + [ + 278, + 277, + 260 + ], + [ + 259, + 277, + 276 + ], + [ + 277, + 295, + 276 + ], + [ + 277, + 278, + 296 + ], + [ + 295, + 277, + 296 + ], + [ + 333, + 350, + 332 + ], + [ + 350, + 349, + 332 + ], + [ + 351, + 350, + 333 + ], + [ + 350, + 351, + 369 + ], + [ + 368, + 350, + 369 + ], + [ + 349, + 350, + 368 + ], + [ + 385, + 367, + 368 + ], + [ + 386, + 385, + 368 + ], + [ + 367, + 385, + 384 + ], + [ + 385, + 386, + 384 + ], + [ + 249, + 231, + 232 + ], + [ + 249, + 250, + 268 + ], + [ + 249, + 232, + 250 + ], + [ + 231, + 249, + 248 + ], + [ + 267, + 249, + 268 + ], + [ + 248, + 249, + 267 + ], + [ + 321, + 303, + 304 + ], + [ + 303, + 321, + 320 + ], + [ + 321, + 322, + 340 + ], + [ + 322, + 321, + 304 + ], + [ + 283, + 265, + 266 + ], + [ + 265, + 247, + 266 + ], + [ + 265, + 283, + 282 + ], + [ + 264, + 265, + 282 + ], + [ + 265, + 264, + 246 + ], + [ + 247, + 265, + 246 + ], + [ + 376, + 377, + 394 + ], + [ + 393, + 376, + 394 + ], + [ + 376, + 358, + 377 + ], + [ + 358, + 376, + 357 + ], + [ + 338, + 339, + 356 + ], + [ + 339, + 357, + 356 + ], + [ + 357, + 339, + 340 + ], + [ + 339, + 338, + 320 + ], + [ + 339, + 321, + 340 + ], + [ + 321, + 339, + 320 + ], + [ + 375, + 374, + 356 + ], + [ + 374, + 375, + 392 + ], + [ + 357, + 375, + 356 + ], + [ + 376, + 375, + 357 + ], + [ + 375, + 393, + 392 + ], + [ + 375, + 376, + 393 + ], + [ + 353, + 335, + 336 + ], + [ + 354, + 353, + 336 + ], + [ + 352, + 353, + 371 + ], + [ + 335, + 353, + 352 + ], + [ + 353, + 372, + 371 + ], + [ + 353, + 354, + 372 + ], + [ + 52, + 33, + 34 + ], + [ + 53, + 52, + 34 + ], + [ + 70, + 52, + 53 + ], + [ + 69, + 52, + 70 + ], + [ + 33, + 51, + 32 + ], + [ + 52, + 51, + 33 + ], + [ + 51, + 52, + 69 + ], + [ + 139, + 121, + 122 + ], + [ + 157, + 139, + 158 + ], + [ + 139, + 157, + 138 + ], + [ + 121, + 139, + 138 + ], + [ + 124, + 125, + 142 + ], + [ + 141, + 124, + 142 + ], + [ + 119, + 136, + 118 + ], + [ + 137, + 136, + 119 + ], + [ + 136, + 137, + 155 + ], + [ + 136, + 155, + 154 + ], + [ + 192, + 174, + 193 + ], + [ + 173, + 174, + 192 + ], + [ + 174, + 175, + 193 + ], + [ + 174, + 157, + 175 + ], + [ + 137, + 156, + 155 + ], + [ + 156, + 173, + 155 + ], + [ + 156, + 137, + 138 + ], + [ + 156, + 174, + 173 + ], + [ + 157, + 156, + 138 + ], + [ + 174, + 156, + 157 + ], + [ + 79, + 78, + 60 + ], + [ + 117, + 116, + 98 + ], + [ + 116, + 117, + 134 + ], + [ + 133, + 116, + 134 + ], + [ + 116, + 133, + 115 + ], + [ + 117, + 135, + 134 + ], + [ + 135, + 153, + 134 + ], + [ + 153, + 135, + 154 + ], + [ + 135, + 136, + 154 + ], + [ + 135, + 117, + 118 + ], + [ + 136, + 135, + 118 + ], + [ + 153, + 152, + 134 + ], + [ + 133, + 152, + 151 + ], + [ + 152, + 133, + 134 + ], + [ + 151, + 152, + 169 + ], + [ + 170, + 171, + 189 + ], + [ + 170, + 153, + 171 + ], + [ + 188, + 170, + 189 + ], + [ + 170, + 152, + 153 + ], + [ + 170, + 188, + 169 + ], + [ + 152, + 170, + 169 + ], + [ + 132, + 151, + 150 + ], + [ + 132, + 133, + 151 + ], + [ + 133, + 132, + 115 + ], + [ + 132, + 114, + 115 + ], + [ + 132, + 150, + 131 + ], + [ + 114, + 132, + 131 + ], + [ + 77, + 78, + 95 + ], + [ + 94, + 77, + 95 + ], + [ + 59, + 77, + 58 + ], + [ + 77, + 59, + 78 + ], + [ + 77, + 76, + 58 + ], + [ + 77, + 94, + 76 + ], + [ + 30, + 48, + 29 + ], + [ + 31, + 30, + 13 + ], + [ + 30, + 31, + 49 + ], + [ + 48, + 30, + 49 + ], + [ + 82, + 81, + 63 + ], + [ + 81, + 82, + 99 + ], + [ + 81, + 62, + 63 + ], + [ + 62, + 81, + 80 + ], + [ + 81, + 98, + 80 + ], + [ + 81, + 99, + 98 + ], + [ + 44, + 61, + 43 + ], + [ + 62, + 61, + 44 + ], + [ + 61, + 60, + 43 + ], + [ + 61, + 79, + 60 + ], + [ + 61, + 62, + 80 + ], + [ + 79, + 61, + 80 + ], + [ + 10, + 9, + 8 + ], + [ + 9, + 26, + 8 + ], + [ + 45, + 62, + 44 + ], + [ + 26, + 45, + 44 + ], + [ + 62, + 45, + 63 + ], + [ + 45, + 46, + 63 + ], + [ + 128, + 111, + 129 + ], + [ + 111, + 112, + 129 + ], + [ + 92, + 111, + 110 + ], + [ + 111, + 128, + 110 + ], + [ + 111, + 92, + 93 + ], + [ + 112, + 111, + 93 + ], + [ + 183, + 184, + 201 + ], + [ + 184, + 166, + 185 + ], + [ + 166, + 184, + 165 + ], + [ + 184, + 183, + 165 + ], + [ + 221, + 203, + 222 + ], + [ + 239, + 221, + 222 + ], + [ + 221, + 239, + 238 + ], + [ + 220, + 221, + 238 + ], + [ + 261, + 279, + 278 + ], + [ + 279, + 297, + 278 + ], + [ + 279, + 262, + 280 + ], + [ + 279, + 261, + 262 + ], + [ + 298, + 279, + 280 + ], + [ + 297, + 279, + 298 + ], + [ + 311, + 330, + 329 + ], + [ + 330, + 311, + 312 + ], + [ + 311, + 294, + 312 + ], + [ + 311, + 329, + 310 + ], + [ + 293, + 311, + 310 + ], + [ + 294, + 311, + 293 + ], + [ + 68, + 69, + 87 + ], + [ + 68, + 51, + 69 + ], + [ + 86, + 68, + 87 + ], + [ + 68, + 86, + 67 + ], + [ + 67, + 66, + 49 + ], + [ + 66, + 48, + 49 + ], + [ + 83, + 82, + 64 + ], + [ + 82, + 83, + 100 + ], + [ + 140, + 141, + 159 + ], + [ + 140, + 159, + 158 + ], + [ + 139, + 140, + 158 + ], + [ + 140, + 139, + 122 + ], + [ + 88, + 70, + 89 + ], + [ + 88, + 69, + 70 + ], + [ + 69, + 88, + 87 + ], + [ + 88, + 105, + 87 + ], + [ + 97, + 79, + 80 + ], + [ + 97, + 116, + 115 + ], + [ + 98, + 97, + 80 + ], + [ + 116, + 97, + 98 + ], + [ + 30, + 12, + 13 + ], + [ + 12, + 11, + 10 + ], + [ + 11, + 12, + 29 + ], + [ + 12, + 30, + 29 + ], + [ + 13, + 12, + 8 + ], + [ + 12, + 10, + 8 + ], + [ + 27, + 28, + 46 + ], + [ + 9, + 27, + 26 + ], + [ + 28, + 27, + 10 + ], + [ + 27, + 9, + 10 + ], + [ + 45, + 27, + 46 + ], + [ + 27, + 45, + 26 + ], + [ + 202, + 220, + 201 + ], + [ + 184, + 202, + 201 + ], + [ + 221, + 202, + 203 + ], + [ + 202, + 221, + 220 + ], + [ + 203, + 202, + 185 + ], + [ + 202, + 184, + 185 + ], + [ + 50, + 68, + 67 + ], + [ + 68, + 50, + 51 + ], + [ + 31, + 50, + 49 + ], + [ + 50, + 67, + 49 + ], + [ + 50, + 31, + 32 + ], + [ + 51, + 50, + 32 + ], + [ + 48, + 65, + 47 + ], + [ + 66, + 65, + 48 + ], + [ + 65, + 64, + 47 + ], + [ + 65, + 83, + 64 + ], + [ + 65, + 66, + 84 + ], + [ + 83, + 65, + 84 + ], + [ + 84, + 85, + 102 + ], + [ + 66, + 85, + 84 + ], + [ + 102, + 85, + 103 + ], + [ + 85, + 86, + 103 + ], + [ + 86, + 85, + 67 + ], + [ + 85, + 66, + 67 + ], + [ + 83, + 101, + 100 + ], + [ + 101, + 119, + 100 + ], + [ + 101, + 84, + 102 + ], + [ + 101, + 83, + 84 + ], + [ + 119, + 101, + 120 + ], + [ + 101, + 102, + 120 + ], + [ + 104, + 86, + 87 + ], + [ + 105, + 104, + 87 + ], + [ + 104, + 122, + 103 + ], + [ + 86, + 104, + 103 + ], + [ + 123, + 124, + 141 + ], + [ + 123, + 105, + 124 + ], + [ + 123, + 140, + 122 + ], + [ + 140, + 123, + 141 + ], + [ + 104, + 123, + 122 + ], + [ + 123, + 104, + 105 + ], + [ + 105, + 106, + 124 + ], + [ + 88, + 106, + 105 + ], + [ + 125, + 106, + 107 + ], + [ + 124, + 106, + 125 + ], + [ + 107, + 106, + 89 + ], + [ + 106, + 88, + 89 + ], + [ + 114, + 96, + 115 + ], + [ + 96, + 97, + 115 + ], + [ + 96, + 114, + 95 + ], + [ + 97, + 96, + 79 + ], + [ + 78, + 96, + 95 + ], + [ + 79, + 96, + 78 + ] + ], + "domains": { + "A": [ + 19, + 53, + 83, + 84, + 85, + 86, + 87, + 117, + 118, + 119, + 120, + 121, + 160, + 161, + 164, + 166, + 175, + 176, + 235, + 237, + 238, + 239, + 240, + 265, + 269, + 270, + 271, + 272, + 273, + 274, + 275, + 276, + 277, + 278, + 279, + 281, + 282, + 288, + 289, + 290, + 291, + 292, + 293, + 298, + 299, + 302, + 429, + 430, + 431, + 432, + 433, + 434, + 435, + 437, + 440, + 441, + 442, + 443, + 444, + 445, + 446, + 447, + 448, + 455, + 456, + 457, + 458, + 459, + 494, + 495, + 498, + 499, + 500, + 501, + 502, + 503, + 504, + 505, + 506, + 507, + 508, + 509, + 510, + 512, + 513, + 517, + 528, + 529, + 530, + 531, + 532, + 533, + 589, + 590, + 591, + 592, + 595, + 596, + 597, + 598, + 599, + 600, + 601, + 602, + 603, + 604, + 605, + 606, + 607, + 608, + 610, + 611, + 612, + 613, + 614, + 615, + 616, + 617, + 618, + 619, + 620, + 621, + 622, + 623, + 624, + 625, + 626, + 627, + 628, + 629, + 630, + 631, + 632, + 633, + 634, + 635, + 674, + 675, + 676, + 678, + 679, + 707, + 724, + 726, + 750, + 768 + ], + "B": [ + 6, + 7, + 36, + 54, + 55, + 56, + 57, + 58, + 59, + 60, + 61, + 62, + 63, + 64, + 65, + 66, + 67, + 68, + 69, + 70, + 71, + 72, + 88, + 89, + 90, + 124, + 127, + 128, + 159, + 162, + 163, + 165, + 167, + 177, + 179, + 180, + 181, + 182, + 183, + 184, + 185, + 186, + 187, + 188, + 189, + 190, + 191, + 192, + 193, + 229, + 230, + 231, + 232, + 233, + 234, + 280, + 283, + 284, + 285, + 286, + 287, + 294, + 295, + 296, + 297, + 300, + 301, + 303, + 310, + 311, + 312, + 313, + 314, + 315, + 316, + 317, + 318, + 319, + 320, + 321, + 322, + 335, + 336, + 337, + 338, + 339, + 340, + 345, + 350, + 351, + 352, + 353, + 354, + 355, + 356, + 357, + 358, + 359, + 360, + 361, + 362, + 363, + 364, + 365, + 366, + 367, + 368, + 369, + 370, + 371, + 372, + 373, + 374, + 375, + 376, + 377, + 378, + 379, + 380, + 381, + 382, + 383, + 384, + 385, + 386, + 387, + 388, + 389, + 390, + 391, + 392, + 393, + 394, + 395, + 396, + 397, + 398, + 399, + 400, + 401, + 402, + 403, + 404, + 405, + 406, + 407, + 408, + 425, + 426, + 427, + 428, + 511, + 514, + 515, + 516, + 518, + 519, + 520, + 521, + 522, + 523, + 524, + 525, + 526, + 527, + 534, + 535, + 536, + 537, + 538, + 539, + 540, + 541, + 542, + 543, + 544, + 545, + 546, + 547, + 548, + 549, + 550, + 551, + 552, + 553, + 554, + 555, + 556, + 557, + 558, + 559, + 560, + 561, + 562, + 563, + 564, + 565, + 566, + 567, + 568, + 569, + 570, + 571, + 572, + 573, + 574, + 575, + 576, + 577, + 578, + 579, + 580, + 581, + 680, + 681, + 682, + 683, + 684, + 685, + 686, + 687, + 688, + 689 + ], + "C": [ + 1, + 2, + 3, + 5, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30, + 31, + 32, + 33, + 43, + 44, + 45, + 46, + 47, + 48, + 49, + 50, + 51, + 73, + 74, + 75, + 77, + 78, + 79, + 80, + 81, + 82, + 94, + 95, + 96, + 99, + 101, + 102, + 103, + 104, + 105, + 106, + 107, + 108, + 109, + 110, + 111, + 112, + 113, + 114, + 115, + 116, + 122, + 123, + 125, + 126, + 132, + 133, + 134, + 135, + 136, + 137, + 138, + 139, + 140, + 141, + 142, + 143, + 144, + 145, + 146, + 147, + 148, + 149, + 150, + 151, + 152, + 153, + 154, + 155, + 156, + 157, + 158, + 194, + 195, + 196, + 197, + 198, + 199, + 200, + 201, + 202, + 203, + 204, + 205, + 206, + 217, + 218, + 219, + 220, + 221, + 222, + 223, + 224, + 225, + 226, + 227, + 228, + 246, + 247, + 248, + 249, + 250, + 251, + 252, + 253, + 254, + 255, + 256, + 257, + 258, + 259, + 260, + 261, + 262, + 263, + 264, + 266, + 267, + 268, + 304, + 305, + 306, + 307, + 308, + 309, + 323, + 324, + 325, + 326, + 327, + 328, + 329, + 330, + 331, + 332, + 333, + 334, + 409, + 410, + 411, + 412, + 413, + 414, + 419, + 420, + 421, + 422, + 423, + 424, + 461, + 475, + 476, + 477, + 478, + 479, + 480, + 481, + 482, + 483, + 484, + 485, + 486, + 487, + 488, + 489, + 490, + 491, + 492, + 493, + 496, + 497, + 640, + 641, + 664, + 665, + 666, + 667, + 668, + 669, + 670, + 671, + 672, + 673, + 677, + 722, + 723, + 725, + 727 + ], + "D": [ + 0, + 4, + 34, + 35, + 37, + 38, + 39, + 40, + 41, + 42, + 52, + 76, + 91, + 92, + 93, + 97, + 98, + 100, + 129, + 130, + 131, + 168, + 169, + 170, + 171, + 172, + 173, + 174, + 178, + 207, + 208, + 209, + 210, + 211, + 212, + 213, + 214, + 215, + 216, + 236, + 241, + 242, + 243, + 244, + 245, + 341, + 342, + 343, + 344, + 346, + 347, + 348, + 349, + 415, + 416, + 417, + 418, + 436, + 438, + 439, + 449, + 450, + 451, + 452, + 453, + 454, + 460, + 462, + 463, + 464, + 465, + 466, + 467, + 468, + 469, + 470, + 471, + 472, + 473, + 474, + 582, + 583, + 584, + 585, + 586, + 587, + 588, + 593, + 594, + 609, + 636, + 637, + 638, + 639, + 642, + 643, + 644, + 645, + 646, + 647, + 648, + 649, + 650, + 651, + 652, + 653, + 654, + 655, + 656, + 657, + 658, + 659, + 660, + 661, + 662, + 663, + 690, + 691, + 692, + 693, + 694, + 695, + 696, + 697, + 698, + 699, + 700, + 701, + 702, + 703, + 704, + 705, + 706, + 708, + 709, + 710, + 711, + 712, + 713, + 714, + 715, + 716, + 717, + 718, + 719, + 720, + 721, + 728, + 729, + 730, + 731, + 732, + 733, + 734, + 735, + 736, + 737, + 738, + 739, + 740, + 741, + 742, + 743, + 744, + 745, + 746, + 747, + 748, + 749, + 751, + 752, + 753, + 754, + 755, + 756, + 757, + 758, + 759, + 760, + 761, + 762, + 763, + 764, + 765, + 766, + 767, + 769, + 770, + 771, + 772, + 773 + ] + }, + "domain_centroids": { + "A": [ + -0.1029, + -0.1178 + ], + "B": [ + 0.6824, + 1.1166 + ], + "C": [ + -1.4938, + -0.0269 + ], + "D": [ + 0.5989, + -1.2335 + ] + }, + "centroid_a": [ + -0.1029, + -0.1178 + ], + "centroid_b": [ + 0.6824, + 1.1166 + ], + "test_point": [ + 0.3, + 0.3 + ], + "view": 1.8 +} \ No newline at end of file diff --git a/.claude/skills/cetz-figures/examples/domain-demo.png b/.claude/skills/cetz-figures/examples/domain-demo.png new file mode 100644 index 000000000..f439f3548 Binary files /dev/null and b/.claude/skills/cetz-figures/examples/domain-demo.png differ diff --git a/.claude/skills/cetz-figures/examples/domain-demo.typ b/.claude/skills/cetz-figures/examples/domain-demo.typ new file mode 100644 index 000000000..52ef2c2be --- /dev/null +++ b/.claude/skills/cetz-figures/examples/domain-demo.typ @@ -0,0 +1,124 @@ +#import "@preview/cetz:0.3.4" + +#set page(width: auto, height: auto, margin: 8pt) +#set text(size: 9pt) + +// ── Mesh data ───────────────────────────────────────────────────────── +#let mesh = json("domain-demo-data.json") + +#let pt(i) = { + let v = mesh.vertices.at(i) + (v.at(0), v.at(1)) +} + +#let VIEW = mesh.view +#let ca = (mesh.centroid_a.at(0), mesh.centroid_a.at(1)) +#let cb = (mesh.centroid_b.at(0), mesh.centroid_b.at(1)) +#let cc = (mesh.domain_centroids.C.at(0), mesh.domain_centroids.C.at(1)) +#let cd = (mesh.domain_centroids.D.at(0), mesh.domain_centroids.D.at(1)) +#let xp = (mesh.test_point.at(0), mesh.test_point.at(1)) + +// ── Helpers ────────────────────────────────────────────────────────── +#let tri-centroid(tri) = { + let a = pt(tri.at(0)) + let b = pt(tri.at(1)) + let c = pt(tri.at(2)) + ((a.at(0) + b.at(0) + c.at(0)) / 3, + (a.at(1) + b.at(1) + c.at(1)) / 3) +} + +#let in-view(p) = { + calc.abs(p.at(0)) <= VIEW and calc.abs(p.at(1)) <= VIEW +} + +// ── Domain lookup ──────────────────────────────────────────────────── +#let domain-a-set = mesh.domains.A +#let domain-b-set = mesh.domains.B +#let domain-c-set = mesh.domains.C +#let domain-d-set = mesh.domains.D + +#let get-domain(idx) = { + if domain-a-set.contains(idx) { "A" } + else if domain-b-set.contains(idx) { "B" } + else if domain-c-set.contains(idx) { "C" } + else { "D" } +} + +// ── Colours ────────────────────────────────────────────────────────── +#let fill-a = rgb(70, 110, 180, 45) // blue tint +#let fill-b = rgb(180, 70, 70, 45) // red tint +#let fill-c = rgb(90, 155, 90, 40) // green tint +#let fill-d = rgb(180, 160, 70, 40) // yellow tint + +#let stroke-mesh = 0.3pt + rgb("#b0b0b0") + +#let colour-a = rgb("#2563eb") // blue +#let colour-b = rgb("#dc2626") // red +#let colour-c = rgb("#16a34a") // green +#let colour-d = rgb("#ca8a04") // amber +#let colour-xp = rgb("#7c3aed") // violet + +// ── Dot helper ─────────────────────────────────────────────────────── +#let dot(p, label: none, direction: (0.06, 0.06), align-to: "west", + radius: 0.028, colour: black, label-colour: none) = { + import cetz.draw: * + circle(p, radius: radius, fill: colour, stroke: none) + if label != none { + let lcolour = if label-colour == none { black } else { label-colour } + content( + (p.at(0) + direction.at(0), p.at(1) + direction.at(1)), + text(fill: lcolour, label), + anchor: align-to, + ) + } +} + +// ── Figure ─────────────────────────────────────────────────────────── +#box( + clip: true, + width: 10cm, + height: 10cm, + stroke: 0.5pt + luma(50%), + align(center + horizon, cetz.canvas(length: 2.5cm, { + import cetz.draw: * + + // 1. Draw all triangles with domain shading + for (idx, tri) in mesh.triangles.enumerate() { + let c = tri-centroid(tri) + if in-view(c) { + let d = get-domain(idx) + let fill-col = if d == "A" { fill-a } + else if d == "B" { fill-b } + else if d == "C" { fill-c } + else { fill-d } + line( + pt(tri.at(0)), pt(tri.at(1)), pt(tri.at(2)), + close: true, + fill: fill-col, + stroke: stroke-mesh, + ) + } + } + + // 2. Dashed lines from test point to A and B centroids + let dashed-a = (paint: colour-a, thickness: 0.8pt, dash: "dashed") + let dashed-b = (paint: colour-b, thickness: 0.8pt, dash: "dashed") + line(xp, ca, stroke: dashed-a) + line(xp, cb, stroke: dashed-b) + + // 3. All domain centroids + dot(ca, label: $c _ A$, direction: (-0.12, -0.08), align-to: "east", + radius: 0.032, colour: colour-a, label-colour: colour-a) + dot(cb, label: $c _ B$, direction: (0.08, 0.06), align-to: "west", + radius: 0.032, colour: colour-b, label-colour: colour-b) + dot(cc, label: $c _ C$, direction: (-0.10, 0.06), align-to: "east", + radius: 0.032, colour: colour-c, label-colour: colour-c) + dot(cd, label: $c _ D$, direction: (0.08, -0.08), align-to: "west", + radius: 0.032, colour: colour-d, label-colour: colour-d) + + // 4. Test particle + dot(xp, label: $x _ p$, direction: (0.08, 0.06), align-to: "west", + radius: 0.028, colour: colour-xp, label-colour: colour-xp) + + })), +) diff --git a/.claude/skills/cetz-figures/examples/facet-vs-true-normals.png b/.claude/skills/cetz-figures/examples/facet-vs-true-normals.png new file mode 100644 index 000000000..dd387c665 Binary files /dev/null and b/.claude/skills/cetz-figures/examples/facet-vs-true-normals.png differ diff --git a/.claude/skills/cetz-figures/examples/facet-vs-true-normals.typ b/.claude/skills/cetz-figures/examples/facet-vs-true-normals.typ new file mode 100644 index 000000000..8f1b71691 --- /dev/null +++ b/.claude/skills/cetz-figures/examples/facet-vs-true-normals.typ @@ -0,0 +1,119 @@ +#import "@preview/cetz:0.3.4" + +// Explicit page size + aligned-centred canvas lets us control padding +// independently of whatever bbox cetz reports for its content. +#set page(width: 12cm, height: 8cm, margin: 6pt) +#set text(size: 10pt) + +#let data = json("curved-bc-data.json") + +// ── Colours ─────────────────────────────────────────────────────────── +#let arc-colour = rgb("#b0b0b0") // true smooth curve +#let facet-colour = rgb("#1f3a6b") // navy — mesh facets +#let gamma-colour = rgb("#c2410c") // rust — PETSc facet normal +#let true-colour = rgb("#059669") // emerald — true surface normal +#let centre-colour = black + +// ── Geometry access ─────────────────────────────────────────────────── +#let centre = (data.centre.at(0), data.centre.at(1)) +#let radius-end = (data.radius_end.at(0), data.radius_end.at(1)) + +// ── Figure ──────────────────────────────────────────────────────────── +#align(center + horizon, cetz.canvas(length: 3cm, { + import cetz.draw: * + + // 1. True smooth arc — thin dashed grey polyline. + let arc-stroke = (paint: arc-colour, thickness: 0.6pt, dash: "dashed") + for i in range(data.arc_points.len() - 1) { + let p0 = data.arc_points.at(i) + let p1 = data.arc_points.at(i + 1) + line((p0.at(0), p0.at(1)), (p1.at(0), p1.at(1)), stroke: arc-stroke) + } + + // 2. Radius-of-curvature indicator from centre to the arc midpoint. + line(centre, radius-end, + stroke: (paint: arc-colour, thickness: 0.5pt, dash: "dotted")) + // Label the radius midway along it, offset perpendicular. + let r-label-pos = ( + 0.5 * (centre.at(0) + radius-end.at(0)) + 0.07, + 0.5 * (centre.at(1) + radius-end.at(1)), + ) + content(r-label-pos, text(fill: arc-colour, size: 10pt, $R$), + anchor: "west") + + // 3. Facet polyline — thin black. The control points (vertex dots, + // Gauss-point dots) carry the visual weight; the segments between + // them are just the domain boundary. + for i in range(data.facet_vertices.len() - 1) { + let p0 = data.facet_vertices.at(i) + let p1 = data.facet_vertices.at(i + 1) + line((p0.at(0), p0.at(1)), (p1.at(0), p1.at(1)), + stroke: 0.6pt + black) + } + // Facet vertex markers + for v in data.facet_vertices { + circle((v.at(0), v.at(1)), radius: 0.038, + fill: facet-colour, stroke: none) + } + + // 4. Quadrature points with their two normals. + // - true normal (emerald, dashed) ← what free-slip wants + // - facet normal (rust, solid) ← what mesh.Gamma gives + let arrow-len = 0.28 + let true-stroke = (paint: true-colour, thickness: 0.9pt, dash: "dashed") + let gamma-stroke = 1.3pt + gamma-colour + for q in data.quadrature { + let pos = (q.pos.at(0), q.pos.at(1)) + let tn = q.true_normal + let fn = q.facet_normal + + // Facet normal arrow drawn FIRST, so the dashed true normal + // layers on top. Where the two coincide (facet midpoints), the + // green dashes interleave with the rust underneath and both + // colours stay visible — it reads as "they overlap here". + let tip-f = (pos.at(0) + arrow-len * fn.at(0), + pos.at(1) + arrow-len * fn.at(1)) + line(pos, tip-f, stroke: gamma-stroke, + mark: (end: ">", fill: gamma-colour)) + + // True normal arrow on top + let tip-t = (pos.at(0) + arrow-len * tn.at(0), + pos.at(1) + arrow-len * tn.at(1)) + line(pos, tip-t, stroke: true-stroke, + mark: (end: ">", fill: true-colour)) + + // Small quadrature dot + circle(pos, radius: 0.024, fill: black, stroke: none) + } + + // 5. Centre dot + label + circle(centre, radius: 0.038, fill: centre-colour, stroke: none) + content((centre.at(0) + 0.06, centre.at(1) - 0.06), + $O$, anchor: "north-west") + + // 6. Legend in the empty space below the arc. Short sample strokes + // next to each label, so the figure is self-explaining without + // leader lines or per-arrow annotations. + let lx = 0.25 // legend x start (inside the open area) + let ly = 0.55 // top row y + let row = 0.22 // row spacing + let sample-len = 0.28 + + // Row 1: facet normal sample + line((lx, ly), (lx + sample-len, ly), + stroke: gamma-stroke, + mark: (end: ">", fill: gamma-colour)) + content((lx + sample-len + 0.08, ly), + text(fill: gamma-colour, size: 9.5pt, + $hat(n)_Gamma$ + [ (facet, `mesh.Gamma`)]), + anchor: "west") + + // Row 2: true normal sample + line((lx, ly - row), (lx + sample-len, ly - row), + stroke: true-stroke, + mark: (end: ">", fill: true-colour)) + content((lx + sample-len + 0.08, ly - row), + text(fill: true-colour, size: 9.5pt, + $hat(n)_"true"$ + [ (smooth surface)]), + anchor: "west") +})) diff --git a/.claude/skills/cetz-figures/examples/generate-curved-bc-data.py b/.claude/skills/cetz-figures/examples/generate-curved-bc-data.py new file mode 100644 index 000000000..5af372f8b --- /dev/null +++ b/.claude/skills/cetz-figures/examples/generate-curved-bc-data.py @@ -0,0 +1,127 @@ +""" +Data for the "facet normals vs. true normals" figure in +`docs/advanced/curved-boundary-conditions.md`. + +The figure illustrates why `mesh.Gamma` (PETSc facet normals) diverges +from the true smooth-surface normal on a curved boundary: + + - Three straight facets approximate a circular arc. + - At each Gauss quadrature point (2 per facet), the facet normal + is constant across the facet while the true (radial) normal + rotates with position. + +Output schema: + + { + "centre": [cx, cy], + "radius": R, + "arc_points": [[x, y], ...], # densely sampled true arc + "facet_vertices": [[x, y], ...], # points on the circle + "quadrature": [ + {"pos": [x, y], + "facet_normal": [nx, ny], + "true_normal": [nx, ny], + "facet_idx": int}, + ... + ], + "radius_end": [x, y] # arc point at mid-angle + # (end of the radius indicator) + } +""" +import json +import math +from pathlib import Path + +CENTRE = (0.0, 0.0) +RADIUS = 1.5 +ANGLE_START = 30.0 # degrees — chosen so the error angle at +ANGLE_END = 150.0 # quadrature points is visually obvious (~12°) +N_FACETS = 3 +ARC_SAMPLES = 120 + + +def point_at_angle(deg, r=RADIUS): + rad = math.radians(deg) + return (CENTRE[0] + r * math.cos(rad), + CENTRE[1] + r * math.sin(rad)) + + +# 1. Dense sampling of the true arc (for a dashed polyline in Typst) +arc_points = [ + list(point_at_angle( + ANGLE_START + (i / ARC_SAMPLES) * (ANGLE_END - ANGLE_START) + )) + for i in range(ARC_SAMPLES + 1) +] + +# 2. Facet vertices — N_FACETS + 1 points evenly spaced on the arc +facet_vertices = [ + list(point_at_angle( + ANGLE_START + (i / N_FACETS) * (ANGLE_END - ANGLE_START) + )) + for i in range(N_FACETS + 1) +] + +# 3. Three-point Gauss–Legendre quadrature on [-1, 1]: 0, ±sqrt(3/5). +# Mapped to t ∈ [0, 1]: 0.5 ± sqrt(3/5)/2 and 0.5. +# The middle node lies exactly at the chord midpoint, where the +# facet normal and the radial true normal coincide — that's the +# "error vanishes at facet midpoint" case the figure needs to show. +_HALF = 0.5 * math.sqrt(0.6) +GAUSS_T = ( + 0.5 - _HALF, + 0.5, + 0.5 + _HALF, +) + +quadrature = [] +for facet_idx in range(N_FACETS): + p0 = facet_vertices[facet_idx] + p1 = facet_vertices[facet_idx + 1] + dx, dy = p1[0] - p0[0], p1[1] - p0[1] + length = math.hypot(dx, dy) + + # Outward perpendicular to the chord: pick the rotation of (dx, dy) + # whose dot-product with (midpoint - centre) is positive. + mid = ((p0[0] + p1[0]) / 2, (p0[1] + p1[1]) / 2) + cand = (-dy / length, dx / length) + if (cand[0] * (mid[0] - CENTRE[0]) + + cand[1] * (mid[1] - CENTRE[1])) < 0: + cand = (dy / length, -dx / length) + facet_normal = cand + + for t in GAUSS_T: + pos = (p0[0] + t * dx, p0[1] + t * dy) + # True normal: unit radial vector from circle centre through pos. + rx, ry = pos[0] - CENTRE[0], pos[1] - CENTRE[1] + rlen = math.hypot(rx, ry) + true_normal = (rx / rlen, ry / rlen) + quadrature.append({ + "pos": [pos[0], pos[1]], + "facet_normal": [facet_normal[0], facet_normal[1]], + "true_normal": [true_normal[0], true_normal[1]], + "facet_idx": facet_idx, + }) + +# 4. Radius indicator: from centre to arc midpoint +radius_end = list(point_at_angle((ANGLE_START + ANGLE_END) / 2)) + +data = { + "centre": list(CENTRE), + "radius": RADIUS, + "arc_points": [[round(x, 4), round(y, 4)] for x, y in arc_points], + "facet_vertices": [[round(x, 4), round(y, 4)] for x, y in facet_vertices], + "quadrature": [ + {k: ([round(v[0], 4), round(v[1], 4)] if isinstance(v, list) else v) + for k, v in q.items()} + for q in quadrature + ], + "radius_end": [round(radius_end[0], 4), round(radius_end[1], 4)], +} + +out = Path(__file__).with_name("curved-bc-data.json") +out.write_text(json.dumps(data, indent=2)) +print(f"wrote {out.name}: " + f"{len(arc_points)} arc samples, " + f"{len(facet_vertices)} facet vertices, " + f"{len(quadrature)} quadrature points") diff --git a/.claude/skills/cetz-figures/examples/generate-domain-demo-data.py b/.claude/skills/cetz-figures/examples/generate-domain-demo-data.py new file mode 100644 index 000000000..1e4b9990c --- /dev/null +++ b/.claude/skills/cetz-figures/examples/generate-domain-demo-data.py @@ -0,0 +1,154 @@ +""" +Generate a 2D triangular mesh partitioned into two domains for the +parallel particle migration blog post figure. + +The mesh is larger than the element-level demo, with more equant +triangles. Two domains (A and B) are defined by a partition line. +A test particle is placed so it is closer to domain A's centroid +but actually inside domain B — illustrating why centroid-nearest +is insufficient for parallel migration. + +Output schema: + { + "vertices": [[x, y], ...], + "triangles": [[i, j, k], ...], + "domain_a": [tri_index, ...], # triangle indices in domain A + "domain_b": [tri_index, ...], # triangle indices in domain B + "centroid_a": [x, y], + "centroid_b": [x, y], + "test_point": [x, y] + } + +Run: python3 generate-domain-demo-data.py +""" +import json +from pathlib import Path + +import numpy as np +from scipy.spatial import Delaunay + +SEED = 42 +BOX = 2.5 +SPACING = 0.28 +JITTER = 0.10 + +rng = np.random.default_rng(SEED) + +# Jittered equilateral-triangular lattice +dy = SPACING * np.sqrt(3) / 2 +lattice = [] +ys = np.arange(-BOX, BOX + dy, dy) +for row, y in enumerate(ys): + offset = SPACING / 2 if row % 2 else 0.0 + for x in np.arange(-BOX + offset, BOX + 1e-9, SPACING): + lattice.append([x, y]) +lattice = np.array(lattice) + +# Jitter +lattice += rng.uniform(-JITTER * SPACING, JITTER * SPACING, size=lattice.shape) + +# Triangulate +tri = Delaunay(lattice) + +# Compute triangle centroids +centroids = lattice[tri.simplices].mean(axis=1) + +# Partition into four real domains, all with centroids: +# - Domain A (blue): L-shaped, left and lower +# - Domain B (red): upper-right plus top strip (contiguous) +# - Domain C (green): left strip +# - Domain D (yellow): bottom strip plus right edge of A's boundary + +def assign_domain(cx, cy): + """Assign a triangle centroid to a domain.""" + # Domain C (green): far left + if cx <= -1.2: + return "C" + # Domain D (yellow): far bottom, plus right boundary strip + if cy <= -1.2 or (cx > 1.4 and cy <= 0.1): + return "D" + # Domain B (red): upper-right quadrant plus entire top strip + if (cx > 0.1 and cy > 0.1) or cy > 1.4: + return "B" + # Domain A (blue): everything else (L-shaped, central-left and lower) + return "A" + +domains = {} +for label in ["A", "B", "C", "D"]: + domains[label] = [] + +for i, (cx, cy) in enumerate(centroids): + label = assign_domain(cx, cy) + domains[label].append(i) + +# Compute domain centroids +VIEW = 1.8 + +# Compute domain centroids using only triangles visible in the view, +# so the dots match what the reader sees in the clipped figure. +domain_centroids = {} +for label, indices in domains.items(): + visible = [i for i in indices + if abs(centroids[i, 0]) <= VIEW and abs(centroids[i, 1]) <= VIEW] + if visible: + domain_centroids[label] = centroids[visible].mean(axis=0) + elif indices: + domain_centroids[label] = centroids[indices].mean(axis=0) + +centroid_a = domain_centroids["A"] +centroid_b = domain_centroids["B"] + +# Place test particle: inside domain B but closer to centroid A. +test_point = [0.30, 0.30] + +dist_a = np.hypot(test_point[0] - centroid_a[0], test_point[1] - centroid_a[1]) +dist_b = np.hypot(test_point[0] - centroid_b[0], test_point[1] - centroid_b[1]) + +print(f"Domain A centroid: ({centroid_a[0]:.3f}, {centroid_a[1]:.3f})") +print(f"Domain B centroid: ({centroid_b[0]:.3f}, {centroid_b[1]:.3f})") +print(f"Test point: ({test_point[0]:.3f}, {test_point[1]:.3f})") +print(f"Distance to A: {dist_a:.3f}, Distance to B: {dist_b:.3f}") +print(f"Closer to A: {dist_a < dist_b}") + +if dist_a >= dist_b: + for ty in np.arange(0.12, 0.8, 0.02): + for tx in np.arange(0.12, 0.8, 0.02): + if assign_domain(tx, ty) != "B": + continue + da = np.hypot(tx - centroid_a[0], ty - centroid_a[1]) + db = np.hypot(tx - centroid_b[0], ty - centroid_b[1]) + if da < db: + test_point = [tx, ty] + print(f"Adjusted to ({tx:.3f}, {ty:.3f}), dist_a={da:.3f}, dist_b={db:.3f}") + break + else: + continue + break + +# All domain centroids +domain_centroids_out = {} +for label, indices in domains.items(): + if indices: + c = domain_centroids[label] + domain_centroids_out[label] = [round(float(c[0]), 4), round(float(c[1]), 4)] + +data = { + "vertices": [[round(float(x), 4), round(float(y), 4)] for x, y in lattice], + "triangles": [[int(i), int(j), int(k)] for i, j, k in tri.simplices], + "domains": {label: [int(i) for i in indices] for label, indices in domains.items()}, + "domain_centroids": domain_centroids_out, + "centroid_a": [round(float(centroid_a[0]), 4), round(float(centroid_a[1]), 4)], + "centroid_b": [round(float(centroid_b[0]), 4), round(float(centroid_b[1]), 4)], + "test_point": [round(float(test_point[0]), 4), round(float(test_point[1]), 4)], + "view": VIEW, +} + +out = Path(__file__).with_name("domain-demo-data.json") +out.write_text(json.dumps(data, indent=2)) + +print(f"\nWrote {out.name}: " + f"{len(data['vertices'])} vertices, " + f"{len(data['triangles'])} triangles") +for label, indices in domains.items(): + c = domain_centroids[label] + print(f" Domain {label}: {len(indices)} triangles, centroid ({c[0]:.3f}, {c[1]:.3f})") diff --git a/.claude/skills/cetz-figures/examples/generate-mesh-data.py b/.claude/skills/cetz-figures/examples/generate-mesh-data.py new file mode 100644 index 000000000..9fe4804af --- /dev/null +++ b/.claude/skills/cetz-figures/examples/generate-mesh-data.py @@ -0,0 +1,107 @@ +""" +Generate a deterministic 2D triangular mesh for the cetz demo figure. + +The central "highlight" triangle is forced into the Delaunay output by +seeding its vertices first and rejecting any other interior point that +would fall inside its circumcircle (Delaunay-empty-circle property). + +Output schema (identical to what a future underworld3 export will emit): + + { + "vertices": [[x, y], ...], # N x 2 + "triangles": [[i, j, k], ...], # M x 3 indices + "highlight": int # index into triangles + } + +Run: python3 generate-mesh-data.py +""" +import json +from pathlib import Path + +import numpy as np +from scipy.spatial import Delaunay + +SEED = 3 +BOX = 2.0 # point cloud extent — further out to ensure + # enough lattice points survive for cut edges on + # every side at the coarse spacing below. +SPACING = 1.25 # target edge length of the FE-like lattice — + # matched to the central triangle's average edge + # (~1.29) so it reads as a typical cell. +JITTER = 0.15 # fraction of SPACING — small randomness so the + # mesh reads as a real FE discretisation rather + # than a perfect lattice + +# Central triangle — deliberately asymmetric. +TRI = np.array([ + [-0.60, -0.38], + [ 0.58, -0.45], # v2 pulled in slightly from (0.62, -0.48) + # so it isn't flush with the frame. + [ 0.32, 0.70], # v3 shifted up-right so CA is the longest edge. +]) + + +def circumcircle(a, b, c): + """Return (cx, cy, r) of the circumscribed circle of triangle abc.""" + ax, ay = a; bx, by = b; cx_, cy_ = c + d = 2 * (ax * (by - cy_) + bx * (cy_ - ay) + cx_ * (ay - by)) + ux = ((ax**2 + ay**2) * (by - cy_) + + (bx**2 + by**2) * (cy_ - ay) + + (cx_**2 + cy_**2) * (ay - by)) / d + uy = ((ax**2 + ay**2) * (cx_ - bx) + + (bx**2 + by**2) * (ax - cx_) + + (cx_**2 + cy_**2) * (bx - ax)) / d + r = float(np.hypot(ux - ax, uy - ay)) + return float(ux), float(uy), r + + +cx, cy, r = circumcircle(*TRI) +SAFETY = 0.05 # keep candidates a little further out than the circle + +rng = np.random.default_rng(SEED) + +# Jittered equilateral-triangular lattice — visually uniform triangle +# sizes, like an FE mesh from gmsh with a single size field. Every other +# row is offset by half a spacing to build equilateral triangles. +dy = SPACING * np.sqrt(3) / 2 +lattice = [] +ys = np.arange(-BOX, BOX + dy, dy) +for row, y in enumerate(ys): + offset = SPACING / 2 if row % 2 else 0.0 + for x in np.arange(-BOX + offset, BOX + 1e-9, SPACING): + lattice.append([x, y]) +lattice = np.array(lattice) + +# Add a small jitter (after placing) so the mesh doesn't read as a +# perfect lattice — closer to a real FE mesh in appearance. +lattice += rng.uniform(-JITTER * SPACING, JITTER * SPACING, + size=lattice.shape) + +# Keep only lattice points outside the central triangle's circumcircle. +dist = np.hypot(lattice[:, 0] - cx, lattice[:, 1] - cy) +lattice = lattice[dist > r + SAFETY] + +points = np.vstack([TRI, lattice]) +tri = Delaunay(points) + +# Locate the central triangle among the simplices (any vertex order), +# then canonicalise its row to (0, 1, 2) so the Typst figure can rely +# on triangle[highlight].at(k) referring to the kth original TRI vertex. +target = {0, 1, 2} +highlight = next(i for i, s in enumerate(tri.simplices) if set(s) == target) +simplices = tri.simplices.copy() +simplices[highlight] = [0, 1, 2] + +data = { + "vertices": [[round(float(x), 4), round(float(y), 4)] for x, y in points], + "triangles": [[int(i), int(j), int(k)] for i, j, k in simplices], + "highlight": int(highlight), +} + +out = Path(__file__).with_name("mesh-data.json") +out.write_text(json.dumps(data, indent=2)) + +print(f"wrote {out.name}: " + f"{len(data['vertices'])} vertices, " + f"{len(data['triangles'])} triangles, " + f"highlight={highlight}") diff --git a/.claude/skills/cetz-figures/examples/mesh-data.json b/.claude/skills/cetz-figures/examples/mesh-data.json new file mode 100644 index 000000000..83d31e859 --- /dev/null +++ b/.claude/skills/cetz-figures/examples/mesh-data.json @@ -0,0 +1,252 @@ +{ + "vertices": [ + [ + -0.6, + -0.38 + ], + [ + 0.58, + -0.45 + ], + [ + 0.32, + 0.7 + ], + [ + -2.1554, + -2.0987 + ], + [ + -0.637, + -1.9692 + ], + [ + 0.3478, + -2.0251 + ], + [ + 1.7421, + -2.1276 + ], + [ + -1.287, + -1.0623 + ], + [ + -0.1658, + -0.9112 + ], + [ + 1.099, + -0.8849 + ], + [ + -1.9108, + 0.3362 + ], + [ + -0.8309, + 0.2208 + ], + [ + 1.5631, + 0.3426 + ], + [ + -1.4506, + 1.1778 + ], + [ + 0.0219, + 1.2795 + ], + [ + 1.1142, + 1.3501 + ], + [ + -2.1761, + 2.4077 + ], + [ + -0.7972, + 2.1767 + ], + [ + 0.5602, + 2.4919 + ], + [ + 1.6402, + 2.3789 + ] + ], + "triangles": [ + [ + 15, + 19, + 18 + ], + [ + 3, + 10, + 16 + ], + [ + 10, + 13, + 16 + ], + [ + 4, + 7, + 3 + ], + [ + 7, + 10, + 3 + ], + [ + 7, + 8, + 0 + ], + [ + 8, + 7, + 4 + ], + [ + 17, + 18, + 16 + ], + [ + 13, + 17, + 16 + ], + [ + 14, + 17, + 13 + ], + [ + 14, + 15, + 18 + ], + [ + 17, + 14, + 18 + ], + [ + 19, + 12, + 6 + ], + [ + 15, + 12, + 19 + ], + [ + 5, + 8, + 4 + ], + [ + 5, + 3, + 6 + ], + [ + 5, + 4, + 3 + ], + [ + 11, + 14, + 13 + ], + [ + 10, + 11, + 13 + ], + [ + 11, + 7, + 0 + ], + [ + 7, + 11, + 10 + ], + [ + 12, + 9, + 6 + ], + [ + 9, + 5, + 6 + ], + [ + 5, + 9, + 8 + ], + [ + 2, + 11, + 0 + ], + [ + 11, + 2, + 14 + ], + [ + 14, + 2, + 15 + ], + [ + 2, + 12, + 15 + ], + [ + 8, + 1, + 0 + ], + [ + 0, + 1, + 2 + ], + [ + 9, + 1, + 8 + ], + [ + 1, + 9, + 12 + ], + [ + 2, + 1, + 12 + ] + ], + "highlight": 29 +} \ No newline at end of file diff --git a/.claude/skills/cetz-figures/examples/mesh-demo.png b/.claude/skills/cetz-figures/examples/mesh-demo.png new file mode 100644 index 000000000..d634ae214 Binary files /dev/null and b/.claude/skills/cetz-figures/examples/mesh-demo.png differ diff --git a/.claude/skills/cetz-figures/examples/mesh-demo.typ b/.claude/skills/cetz-figures/examples/mesh-demo.typ new file mode 100644 index 000000000..331bdea80 --- /dev/null +++ b/.claude/skills/cetz-figures/examples/mesh-demo.typ @@ -0,0 +1,204 @@ +#import "@preview/cetz:0.3.4" + +#set page(width: auto, height: auto, margin: 8pt) +#set text(size: 10pt) + +// ── Mesh data ───────────────────────────────────────────────────────── +#let mesh = json("mesh-data.json") + +#let pt(i) = { + let v = mesh.vertices.at(i) + (v.at(0), v.at(1)) +} + +// The central triangle is a genuine Delaunay cell. Python forces it +// into the triangulation and canonicalises its vertex order so +// triangle[highlight].at(k) is always TRI[k]. +#let h = mesh.triangles.at(mesh.highlight) +#let a = pt(h.at(0)) +#let b = pt(h.at(1)) +#let c = pt(h.at(2)) + +#let centroid = ( + (a.at(0) + b.at(0) + c.at(0)) / 3, + (a.at(1) + b.at(1) + c.at(1)) / 3, +) + +// ── Vector helpers ──────────────────────────────────────────────────── +#let vsub(p, q) = (p.at(0) - q.at(0), p.at(1) - q.at(1)) +#let vadd(p, q) = (p.at(0) + q.at(0), p.at(1) + q.at(1)) +#let vscale(p, s) = (s * p.at(0), s * p.at(1)) +#let vlen(p) = calc.sqrt(p.at(0) * p.at(0) + p.at(1) * p.at(1)) +#let vnorm(p) = { let m = vlen(p); (p.at(0) / m, p.at(1) / m) } +#let vmid(p, q) = ((p.at(0) + q.at(0)) / 2, (p.at(1) + q.at(1)) / 2) +#let dist(p, q) = vlen(vsub(p, q)) + +// ── Edge-midpoint markers (pair per edge: inside + outside) ─────────── +// Dot radius is 0.022 world units; gap 0.030 places the dot just clear. +#let edge-marker(p, q, gap: 0.030, side: "inside") = { + let m = vmid(p, q) + let dir = vnorm(vsub(centroid, m)) + let sign = if side == "outside" { -1.0 } else { 1.0 } + vadd(m, vscale(dir, sign * gap)) +} + +#let m-ab = edge-marker(a, b) +#let m-bc = edge-marker(b, c) +#let m-ca = edge-marker(c, a) + +#let m-ab-out = edge-marker(a, b, side: "outside") +#let m-bc-out = edge-marker(b, c, side: "outside") +#let m-ca-out = edge-marker(c, a, side: "outside") + +// For each face we store (endpoint0, endpoint1, inside-marker, outside-marker). +#let faces = ( + (a, b, m-ab, m-ab-out), + (b, c, m-bc, m-bc-out), + (c, a, m-ca, m-ca-out), +) + +// 2D cross product — used for the side-of-line test below. +#let cross2d(u, v) = u.at(0) * v.at(1) - u.at(1) * v.at(0) + +// Per-face rule: connect the test point to the *inside* marker if the +// test point lies on the same side of the edge as the centroid, else +// to the *outside* marker. A point inside the triangle lands on the +// centroid side of all three edges (→ all black); a point outside +// crosses at least one edge to the other side (→ at least one red). +#let marker-for(test, e0, e1, inside-marker, outside-marker) = { + let edge = vsub(e1, e0) + let s-test = cross2d(edge, vsub(test, e0)) + let s-ref = cross2d(edge, vsub(centroid, e0)) + if s-test * s-ref > 0 { inside-marker } else { outside-marker } +} + +// ── Sample (test) points ────────────────────────────────────────────── +// x_q: inside the triangle, pulled toward v2 so it sits closer to AB +// and BC, but shifted left enough that its line to the CA midpoint +// clears the centroid. +#let x-q = ( + 0.25 * a.at(0) + 0.60 * b.at(0) + 0.15 * c.at(0), + 0.25 * a.at(1) + 0.60 * b.at(1) + 0.15 * c.at(1), +) +// x_p: outside CA, nudged right so the connector to the AB midpoint +// doesn't graze the CA outside marker en route. +#let x-p = (-0.15, 0.80) + +// ── Line-through-edge extended to the viewport boundary ─────────────── +// Returns (before, after) — the two points where the infinite line +// through (p, q) crosses the axis-aligned box [-VIEW, VIEW]^2. +#let VIEW = 1.0 +#let extend-to-viewport(p, q) = { + let dx = q.at(0) - p.at(0) + let dy = q.at(1) - p.at(1) + let BIG = 1e6 + let tx-lo = if calc.abs(dx) < 1e-9 { -BIG } else { (-VIEW - p.at(0)) / dx } + let tx-hi = if calc.abs(dx) < 1e-9 { BIG } else { ( VIEW - p.at(0)) / dx } + let ty-lo = if calc.abs(dy) < 1e-9 { -BIG } else { (-VIEW - p.at(1)) / dy } + let ty-hi = if calc.abs(dy) < 1e-9 { BIG } else { ( VIEW - p.at(1)) / dy } + let tx-min = calc.min(tx-lo, tx-hi) + let tx-max = calc.max(tx-lo, tx-hi) + let ty-min = calc.min(ty-lo, ty-hi) + let ty-max = calc.max(ty-lo, ty-hi) + let t-enter = calc.max(tx-min, ty-min) + let t-exit = calc.min(tx-max, ty-max) + ( + (p.at(0) + t-enter * dx, p.at(1) + t-enter * dy), + (p.at(0) + t-exit * dx, p.at(1) + t-exit * dy), + ) +} + +// ── Colours ─────────────────────────────────────────────────────────── +#let tri-stroke = rgb("#1f3a6b") // navy — triangle +#let tri-fill = rgb(70, 120, 180, 55) // tinted navy +#let out-marker = rgb("#c2410c") // rust — outside midpoint dots +#let x-q-colour = rgb("#059669") // emerald — inside sample +#let x-p-colour = rgb("#7c3aed") // violet — outside sample + +// ── Labelled dot helper ─────────────────────────────────────────────── +#let dot(p, label: none, direction: (0.08, 0.08), align-to: "west", + radius: 0.022, colour: black, label-colour: none) = { + import cetz.draw: * + circle(p, radius: radius, fill: colour, stroke: none) + if label != none { + let lcolour = if label-colour == none { black } else { label-colour } + content( + (p.at(0) + direction.at(0), p.at(1) + direction.at(1)), + text(fill: lcolour, label), + anchor: align-to, + ) + } +} + +#let outward(p, gap: 0.11) = vscale(vnorm(vsub(p, centroid)), gap) + +// ── Figure ──────────────────────────────────────────────────────────── +#box( + clip: true, + width: 8cm, + height: 8cm, + stroke: 0.5pt + luma(50%), + align(center + horizon, cetz.canvas(length: 4cm, { + import cetz.draw: * + + // 1. Background mesh + for tri in mesh.triangles { + line( + pt(tri.at(0)), pt(tri.at(1)), pt(tri.at(2)), + close: true, + stroke: 0.4pt + rgb("#c0c0c0"), + ) + } + + // 2. Dotted extensions — triangle edges continued to the viewport. + let dotted = (paint: tri-stroke, thickness: 0.5pt, dash: "dotted") + for pair in ((a, b), (b, c), (c, a)) { + let p = pair.at(0) + let q = pair.at(1) + let ends = extend-to-viewport(p, q) + line(ends.at(0), p, stroke: dotted) + line(q, ends.at(1), stroke: dotted) + } + + // 3. Main triangle — fill + bold stroke on top of mesh and dots. + line(a, b, c, close: true, fill: tri-fill, stroke: 1.3pt + tri-stroke) + + // 4. Per-face connectors. Each test point draws three dashed lines, + // one per face, landing on black or red by the side-of-edge rule. + // All-black ⇒ inside; any-red ⇒ outside. + let dashed-stroke(col) = (paint: col, thickness: 0.7pt, dash: "dashed") + for face in faces { + let e0 = face.at(0) + let e1 = face.at(1) + let m-in = face.at(2) + let m-out = face.at(3) + line(x-q, marker-for(x-q, e0, e1, m-in, m-out), + stroke: dashed-stroke(x-q-colour)) + line(x-p, marker-for(x-p, e0, e1, m-in, m-out), + stroke: dashed-stroke(x-p-colour)) + } + + // 5. Vertex dots + labels outward from centroid. + let side-of(p) = if p.at(0) < centroid.at(0) { "east" } else { "west" } + dot(a, label: $v_1$, direction: outward(a), align-to: side-of(a)) + dot(b, label: $v_2$, direction: outward(b), align-to: side-of(b)) + dot(c, label: $v_3$, direction: outward(c), align-to: side-of(c)) + + // 6. Midpoint markers — inside (black) + outside companions (rust). + dot(m-ab); dot(m-bc); dot(m-ca) + dot(m-ab-out, colour: out-marker) + dot(m-bc-out, colour: out-marker) + dot(m-ca-out, colour: out-marker) + + // 7. Centroid + two sample points (each in its own colour). + // Centroid dot is smaller and its label tight against it so it + // reads as a derived reference rather than competing with the + // test points. + dot(centroid, label: $c$, direction: (0.055, 0), align-to: "west", + radius: 0.014) + dot(x-q, label: $x_q$, direction: (0.10, 0.05), align-to: "west", + colour: x-q-colour, label-colour: x-q-colour) + dot(x-p, label: $x_p$, direction: (0.10, 0.05), align-to: "west", + colour: x-p-colour, label-colour: x-p-colour) + })), +) diff --git a/.claude/skills/cetz-figures/underworld-bridge.md b/.claude/skills/cetz-figures/underworld-bridge.md new file mode 100644 index 000000000..f26c5791a --- /dev/null +++ b/.claude/skills/cetz-figures/underworld-bridge.md @@ -0,0 +1,75 @@ +# Bridging cetz figures to underworld3 mesh objects (sketch) + +This is a **sketch, not an implementation**. When we wire figures up to +real `underworld3` data, this is the contract the drawing code expects. + +## The JSON schema + +Any cetz figure that draws a mesh reads the same shape: + +```json +{ + "vertices": [[x, y], ...], // N x 2 floats + "triangles": [[i, j, k], ...], // M x 3 int indices into vertices + "highlight": 17 // optional: triangle of interest +} +``` + +Keep it minimal — cetz script should not have to do geometry or case +analysis. Anything derived (centroids, boundary detection, highlight +selection) is computed upstream and baked into the JSON. + +## How this maps to underworld3 + +Provisional — needs verification against the current `underworld3` mesh +API once we start using it: + +| JSON field | `underworld3` source | Notes | +|---|---|---| +| `vertices` | `mesh.data` (or `mesh.X.coords` — see repo style guide) | 2D only for this skill; 3D needs a projection | +| `triangles` | PETSc `DMPlex` cell-vertex connectivity | Simplex meshes only; quads/hexes need different rendering | +| `highlight` | Caller-provided or heuristic (e.g. closest-to-origin) | Chosen offline, not in Typst | + +Repo's data-access pattern guide: +`docs/developer/UW3_Style_and_Patterns_Guide.md`. + +## Python exporter sketch + +```python +# Not the current implementation — a target shape +def mesh_to_figure_json(mesh, *, highlight=None, path): + """Dump a uw.meshing object to the schema the cetz figures expect.""" + import json + # 2D coordinates + coords = mesh.X.coords # (N, 2) numpy, already preferred over mesh.data + cells = mesh.cell_vertex_indices # placeholder — actual accessor TBD + data = { + "vertices": [[round(float(x), 4), round(float(y), 4)] for x, y in coords], + "triangles": [[int(i) for i in cell] for cell in cells], + } + if highlight is not None: + data["highlight"] = int(highlight) + with open(path, "w") as f: + json.dump(data, f) +``` + +## What this intentionally does NOT do + +- **No coordinate transform / projection.** Caller passes whatever world + coordinates it wants; Typst draws them as-is. Transforming a spherical + mesh to a 2D projection is a Python concern. +- **No per-cell data.** This schema is for topology/geometry only. For + scalar fields, vector fields, colormaps → go the Python-generated-SVG + route and include via `#image()`. +- **No parallel-safe collection logic.** Caller is expected to gather the + mesh to rank 0 before exporting. Don't invent a parallel JSON writer. + +## Next steps (when we get here) + +1. Implement `mesh_to_figure_json` using the correct underworld3 accessors. + Verify which is idiomatic in the current codebase — `mesh.data` is + deprecated per `CLAUDE.md`. +2. Write a test figure consuming output from a small real mesh (e.g. one + of the existing example meshes). +3. Decide whether to ship the exporter as a `uw.utilities.export_figure` + helper or leave it as a per-paper script. diff --git a/.claude/skills/free-surface-convection/SKILL.md b/.claude/skills/free-surface-convection/SKILL.md new file mode 100644 index 000000000..b4e4c4bfb --- /dev/null +++ b/.claude/skills/free-surface-convection/SKILL.md @@ -0,0 +1,224 @@ +--- +name: free-surface-convection +description: The Underworld3 free-surface convection method we are hardening — the THREE-NUMBER pointwise topography integrator (held-lid stress equilibrium h_∞ + L-stable exponential relaxation), NOT FSSA. Reach for THIS before touching any free-surface / dynamic-topography convection run, choosing a surface-update scheme, or "stabilising" a free surface. It records the method, why FSSA is explicitly rejected, and the failure modes. +--- + +# free-surface-convection + +The free-surface scheme used in `~/+Simulations/FreeSurface/convection/fs4_compare.py` +and the design doc `docs/developer/design/FREESLIP_DYNAMIC_TOPOGRAPHY_FREESURFACE.md`. +**This is the method we are HARDENING — do not replace it; do not add FSSA.** + +> The 3-number integrator is necessary but NOT sufficient — see **Hardening strategies +> (2026-06)** below for the material-surface advection, tangential topography term, +> free-slip-inner nullspace, and graded/higher-order-mesh fixes that make it actually +> work. Reference impl + diagnostic tools live in `~/+Simulations/FreeSurface/convection/`. + +## ⚠️ NOT FSSA + +The `docs/examples/free_surface/advanced/Annulus*FS.py` examples use **FSSA** +(`add_natural_bc(δt·(Γ·v)Γ/2, "Upper")`). **That is NOT our method.** FSSA buys +stability by adding an implicit surface traction that **UNDER-deforms the surface** +— it trades accuracy for stability. Our scheme is designed to be stable **and** +accurate. If you find yourself adding `FSSA`, an `add_natural_bc` traction on the +free surface, or a `Gamma.dot(v)` stabiliser — STOP, you have the wrong method. +(Those example files are a template for a *different* approach, not this one.) + +## The three-number pointwise integrator (THE method) + +Two Stokes solves per step on the SAME mesh, then a pointwise surface update: + +1. **Free solve** — stress-free top (NO velocity BC on `Upper`; pressure datum is + pinned by the stress-free condition → no pressure nullspace). The surface + normal velocity `u_n` of this solve IS the kinematic rate `ḣ`. +2. **Held-lid solve** — a second Stokes solve with a RIGID free-slip held lid + (`u_n = 0`, via `add_nitsche_bc(0.0, "Upper", local_h=True)` — see [[project_nitsche_local_h_pr275]]) + and a DRIVING-ONLY body force. Its surface normal stress `σ_nn` gives the + equilibrium topography `h_∞ = -(σ_nn - mean)/ρg`. (The free solve forces + `σ_nn = 0`, so the equilibrium MUST come from the held-lid stress.) +3. **Pointwise exp step**, per surface node, from THREE numbers (`h`, `ḣ=u_n`, + `h_∞`): + ``` + γ = ḣ / (h_∞ − h) # local relaxation rate, clamp γ ≥ 0 + h ← h_∞ + (h − h_∞)·exp(−γ·dt) + ``` + L-stable: the step is bounded between `h` and `h_∞`, so it **cannot overshoot** + regardless of a noisy local `γ` (no "drunken sailor"). 1 extra solve/step; + beats RK4 at large dt. NO per-node freeze-clamp (that was the old `relax` bug). +4. The nodal surface increment is **carried inward by a Laplacian diffuser** + (smooth, minimal mesh deformation — NOT full mmpde adaptation), then + `mesh.deform()`. Uniform meshes are fine; adaptivity is NOT required. + +Reference impl: `fs4_compare.py` → `_surface_step`, `_h_inf` (held-lid σ_nn via a +`Projection`), `_surf_un`, `_carry_diffuser`. The free-slip RIGID-top run (no +surface motion) is the reference; the free-surface run is the same driving solve +PLUS this surface update. See [[project_fs4_adaptive_2x2]], +[[project_stress_equilibrium_freesurface]], [[project_freeslip_topo_freesurface]]. + +## Performance + +- Free-slip (rigid top) stagnant-lid runs are FAST. The free-surface cost is the + extra held-lid solve **plus** that moving the surface forces a COLD-START Stokes + each step (can't warm-start across a deformed mesh). +- Use **uniform** meshes for this problem — the diffuser gives minimal deformation, + no mmpde needed. (FMG works on a uniform `refinement=N` hierarchy; scalar solvers + must avoid FMG — PETSc err62, issue underworldcode/underworld3#276.) + +## Hardening strategies (2026-06) — the integrator alone is not enough + +The 3-number integrator moves the surface correctly, but several *other* things must +be right or it runs away / tangles. All implemented in `fs4_compare.py` (flags noted). + +### 1. Material-surface advection — THE key fix (`--advect-velocity`) +The runaway (`u_n` 42→125→285→445, cold lid leaking in, plumes punching through) was +NOT an `h_∞`/BC bug (`h_∞` is verified correct, even in the stagnant FK lid — held-lid +free-slip is the EASY case there). The bug: the surface moves by the L-stable relaxed +rate `ũ_n = Δh/Δt ≤ u_n`, but T was advected with the **stress-free solve velocity** +(surface-normal = full `u_n`). Net material then crosses the surface. A free surface is +a MATERIAL boundary: advect T with a velocity whose surface-normal = `ũ_n`. Modes: +- `consistent` (the right way): a THIRD Stokes solve, same buoyancy, `v·n̂ = ũ_n` + PRESCRIBED at the surface (penalty), tangential stress-free. `ũ_n = (shape_new−shape0)/dt` + = the full ∂h/∂t at fixed θ (correct ALE target). +- `blend`: `α·v_free + (1−α)·v_held`, `α = φ1(γΔt) = (1−e^{−γΔt})/(γΔt)` (the exp-decay + time-average). By Stokes LINEARITY this *is* the prescribed-`ũ_n` solve for UNIFORM α + (and free for FK, which is linear in v). BUT the single mean-α collapse is NOT close + enough once γ varies per surface node — the planform diverges (mode-3 vs mode-1/2), + throughflow ~23 vs ~0.08. Per-node α breaks div-free (∇α·(v_free−v_held)). So the + per-node `consistent` 3rd solve is REQUIRED for structured planforms. +- `free`: advect with stress-free v (the inconsistent baseline — the runaway). + +### 2. Tangential topography advection (`--no-tangent-advect` to disable; default ON) +The pointwise relaxation omits the `v_t·∂_s h` term — a surface rotation/convergence +should carry the topography pattern along the surface; without it you get edge artefacts +where ∂_s h is large (plume-bulge edges). Fix = operator split per step: (1) departure- +point semi-Lagrangian transport of the surface shape in θ by `ω = v_t/r`, then (2) the +L-stable normal relaxation. Lowers throughflow + improves mesh quality. + +### 3. Free-slip inner boundary — rotation nullspace (`--inner freeslip`) +The rigid rotation `[-y,x]` is a velocity nullspace ONLY while the boundary is CIRCULAR. +Once the free surface DEFORMS, do NOT attach it to the held/consistent solves (→ held +22 s/`DIVERGED_LINEAR_SOLVE`, throughflow blow-up). Keep `petsc_use_pressure_nullspace`; +strip the gauge with the exact post-solve projection `_project_out_rotation` on `v`, +`v_cons` (drives advection) AND `v_h` (one consistent non-rotating frame). The undeformed +free-slip *reference* (`--surface freeslip`) is fine WITH the nullspace attached. + +### 4. Graded / higher-order meshes (drive node movement consistently) +- **Surface-ring detection**: tie the tolerance to the FINEST cell + (`0.5·mesh.get_min_radius()`), NOT the nominal `cellsize`. On a gmsh-graded mesh + (`cellSizeOuter`) the old tolerance scoops the first interior ring → a 2%-thick + "surface band" → tangling (looks like the surface "destroying itself"; it isn't — + the diffuser was fed a corrupt surface). BETTER (TODO): build the ring from the DMPlex + `Upper` label (`dm.getLabel("Upper").getStratumIS`), removing the tolerance entirely. +- **Node movement**: the solve velocity is P2; the mesh geometry is P1. Drive `u_n` and + the tangential transport from a P1 length-smoothed `Vector_Projection` of V (`v_p1`), + NOT a point-evaluation of the P2 field. +- **Stress smoothing**: `topo_proj.smoothing_length` = a fixed PHYSICAL length + (`--smooth-length`), not cell-count, so `h_∞` is mesh/order-independent. + +### 5. Cost — there is no acceleration win (don't chase it) +3 Stokes solves/step (free→u_n, held→h_∞, consistent→advect). Warm-start does NOT help +(outer KSP already 1 iter; FMG supplies its own nested guess — measured SLOWER). Blend- +skip rarely fires (α-spread always large). Operator/PC reuse: already reused across +`solve()`s (first 5.5 s setup, steady ~945 ms = irreducible FMG solve; RHS-only resolve +same cost). The per-step cost is the geometric FMG hierarchy REBUILD on the deforming +mesh — intrinsic to moving meshes, "live with it." The UNIFIED-PENALTY single solver +(`penalty·(v·n̂ − V₁·n̂)·n̂`; penalty=0→free, V₁=0→held, V₁=ũ_n→consistent — held & +consistent share the matrix) is the cleanest formulation (no recompile on the constant) +but doesn't cut the irreducible solve. + +### Elastic-plate flexure `h_∞` — IMPLEMENTED (`--flexure-D`) +Generalizes the LOCAL Airy `h_∞ = −σ_nn/ρg` (the D=0 limit) to a flexed plate +`(D ∂_s^4 + ρg) h_∞ = −σ_nn`, solved SPECTRALLY on the ring (serial Fourier — the +feasible substitute for UW3's blocked 1D-manifold FE solve): per mode +`h_∞(m) = −σ_nn(m)/(ρg + D(m/r_o)⁴)`. `D` sets the flexural wavelength `(D/ρg)^{1/4}` +and damps short-wavelength loads — the physically-grounded, mesh-independent length- +smoothing. In `_h_inf` (h_∞-ONLY — the stable form). **PROTOTYPE — amplitude response correct +(stiffer plate → less deflection) but it does NOT low-pass the SURFACE**: filtering `h_∞` only +sets a smooth set-point; the surface still picks up short-wavelength content from the SL +tangential transport + partial relaxation. "Filter every surface number (h, ḣ, h_∞)" was +TRIED and REJECTED — filtering the GEOMETRY `h` injects a spurious smooth-the-mesh motion into +`ũ_n` → flow runs away (Vrms 50→345); filtering `ḣ` alone is stable but elevates Vrms with no +benefit. So making flexure a TRUE surface low-pass without destabilising is OPEN/hard +(`_flex_filter` helper is in place). Examples: `stagnant_lid_mode1_study/{figures/flexure_*.png, +runs/flexure_D*}`. + +### Open / next (not yet done) +- **Label-based surface ring** (replace the radial heuristic with the `Upper` stratum). +- **Flexure D calibration** to a realistic lithospheric flexural wavelength. + +### Diagnostic tools (`~/+Simulations/FreeSurface/convection/stagnant_lid_mode1_study/scripts/`) +- `heldlid_hinf_check.py` — verify `h_∞` via 4 independent free-slip enforcements × Δη sweep +- `stitch_compare.py` — side-by-side montage of per-run dirs (`--dirs a,b,c`) +- `unified_penalty_solver.py` — the one-solver penalty formulation probe +- `resolve_timing_probe.py` — repeated-solve / lag-Jacobian / reuse-PC timing + +## ★★ Body force must be FULL Boussinesq on a deforming mesh (2026-07-26) + +**On any run where the mesh surface actually moves, the body force must retain the +ρ₀ background: `bodyforce = (thermal_buoyancy − rho_0_g)·r̂` (i.e. ρ = ρ₀(1−αΔT)).** +The reduced (driving-only) form has **NO restoring force for surface deformation +anywhere in the momentum system** — `buoyancy_scale` enters only the kinematic target +h∞ = −σ_nn/ρg, which exerts zero force on the flow. Consequences and evidence (all +measured, `~/+Simulations/FreeSurface/annulus_fs_convection/teaching/`): + +- **Relaxation A/B (`relaxation_test.py`)** — imposed 5% mode-4 topography, no thermal + driving: reduced form = surface FROZEN (velocities are round-off); full density = + **262→5 km in 10 steps at the Cathles rate** (measured 2.1e4 vs ρ₀g/(2ηk) = 2.5e4, + within 20% at res 0.06). +- **Convection A/B (`restoring_force_demo.png`, gap-Ra 3e4, ρ₀g 1e6)** — reduced h + grows monotonically without limit; full density *rings* about its supported amplitude + early (damped), then tracks the developing flow ~35% lower with HIGHER Nu (1.93 vs + 1.65). Without the term, soft surfaces run away entirely (measured to 50% of radius). +- **The FreeSurface manager needs NO change** — held/consistent solves inherit + `stokes.bodyforce`; h∞ then self-consistently includes the self-load (a moving target + the integrator follows cleanly — verified in the relaxation test). + +Rules that come with it: +1. **ρ₀g and Ra are COUPLED: αΔT = (thermal buoyancy coeff)/ρ₀g must be ≲ 0.3.** + Softness is not a free knob — the old "soft" runs at ρg=2e5 were αΔT = 1.2–4 (no + such fluid) and their runaway was partly parameter nonsense. Soft surfaces require + weak driving. +2. **Tighten the solver tolerance (~1e-8)**: the hydrostatic RHS dominates the dynamic + signal by 1e3–1e4, and a relative tolerance judges the total. +3. Prefer `snes_type=ksponly` for the isoviscous solves; the huge RHS makes `newtonls` + thrash worse. +4. Driver switch: `fs_convection.py -uw_full 1`. +5. **`FreeSurface(background_buoyancy="analytic")` is REQUIRED with full density** + (98961147): the recovered reaction contains the self-load +h_current and the + reduced-form negation otherwise flips it -> h_inf = -h + drive/rho_g (parks at HALF + equilibrium; -1 eigenvalue = period-2 ringing; steady flow THROUGH the stationary + surface). "analytic" subtracts the geometric height - no extra solve. The CBF + recovery itself is exact (probe lesson: select boundary DOFs by LABEL, never a + radius mask, on a deformed mesh). `background_buoyancy=` = exact two-reaction + reference mode. + +Related transport fact (same campaign): the serial T-blow-up on deforming FS meshes was +the **old-frame SL reach-back** amplifying per loop cycle (issue #423; smaller dt makes +it WORSE) — retired in `b507aca1`; the manager now uses the standard ALE path + clamp + +deform-aware foot restore. The parallel datum defect is #421. + +## Failure modes — symptom → cause + +| Symptom | Cause | +|---|---| +| Surface deforms but `u_n` RUNS AWAY (e.g. u_n 42→125→285→445), cold lid leaks in, plumes punch through | **RESOLVED**: material-surface advection inconsistency — T advected with stress-free `u_n` while surface moves by relaxed `ũ_n`. Fix = `--advect-velocity consistent` (Hardening §1). NOT an h_∞ bug, NOT fixed by FSSA. | +| `held` solve 22 s / `DIVERGED_LINEAR_SOLVE`, throughflow blows up, with `--inner freeslip` | rigid-rotation `[-y,x]` attached as a nullspace on the DEFORMED (non-circular) surface — invalid. Don't attach it on the moving surface; use the post-solve projection (Hardening §3). | +| Graded-mesh surface "destroys itself" (q→0.2, h_max 2% at step 1) | surface-detection tolerance scooped the first interior ring → 2%-thick band, NOT real deformation. Tie tolerance to finest cell (Hardening §4). The diffuser is innocent. | +| Topography GROWS without saturating; flow-through persists even at huge deformation | **missing ρ₀ background** — reduced body force has no surface restoring force (see the Full-Boussinesq section above). Fix = ρ = ρ₀(1−αΔT); check αΔT ≲ 0.3 | +| T leaves [0,1] on a deforming mesh, mesh-locked hot/cold spikes in the squeezed band, worse at SMALLER dt | old-frame SL reach-back amplifying per loop cycle (issue #423) — use the standard ALE path (`old_frame_traceback=False`, the manager default since b507aca1) | +| Stress-free top but surface not updated each step | nothing stops throughflow (the stress-free top is an open boundary unless the integrator moves the surface to track `u_n`) | +| Nu decays when it should be steady (kinematic free surface) | LAG: the SL foot reaches beyond an under-moved surface → cold pump. Fix = the h_∞ relaxation, not more smoothing | +| Surface "mountain" / one-step spike on adaptive mesh | held-lid Nitsche penalty over-stiffened by GLOBAL h; use `local_h=True` (default, PR #275) = `mesh.cell_size()` | + +## Dead ends (already tried — do NOT repeat) + +- **FSSA** signed-traction free-surface: diverges / under-deforms — rejected. +- **High-k post-smoothing** of the surface: the instability is low-m, smoothing + the wrong band. +- Per-node freeze-clamp in the relaxation (the old `relax` fatal bug). + +## Diagnose by + +`h_max` (deflection, as % of r_o), `u_n` / `vhmax` (surface throughflow — should NOT +grow unbounded), `hinf_max` (the equilibrium target), `vrms`, `Nu`. Compare the +free-surface run against the free-slip RIGID-top reference at matched physical time. diff --git a/.claude/skills/nonlinear-solver/SKILL.md b/.claude/skills/nonlinear-solver/SKILL.md new file mode 100644 index 000000000..297b3df85 --- /dev/null +++ b/.claude/skills/nonlinear-solver/SKILL.md @@ -0,0 +1,321 @@ +--- +name: nonlinear-solver +description: How to make a hard nonlinear Stokes solve (Drucker-Prager / yield-stress viscoplastic) CONVERGE reliably in Underworld3 the way the working recipe actually does it — automatic warm-start (one Picard step on a cold start) plus a MULTI-SOLVE δ-continuation (constant δ per solve, warm-start the next, sharper δ), the consistent-Newton tangent, and a non-symmetry-safe multigrid smoother. Reach for THIS when a viscoplastic solve stalls / diverges and you are about to hand-tune PETSc options, ramp δ, or "just add a monitor". It carries the CONFIG TRAP LIST — the setup mistakes that each produce a different failure a few steps in — and the one thing you must NOT do (ramp δ inside a single SNES solve). For the yield-law maths and which tangent per model, see `plasticity-solvers`. +--- + +# nonlinear-solver + +The recipe that gets a **hard viscoplastic (Drucker–Prager) Stokes** problem to +converge, and — more importantly — the list of setup mistakes that stop it. The +central lesson from the Spiegelman hard-case study (`η_bg=1e26`, `V=10`): every +failure was a **solver-configuration** error, not a bad Jacobian. If the correct +setup is a minefield for an expert, that is an API regression — so the goal is to +make the correct path the default path. + +Design of record: `docs/developer/design/nonlinear-solver-homotopy-warmstart.md`. +Yield-law maths, tangent-per-model, quadratic-convergence check: `plasticity-solvers`. + +--- + +## The recipe (what actually converges) + +1. **Warm start.** Start the continuation at **large δ**, where the yield surface is + smooth and the problem is easy, and take **one Picard step** into the Newton + basin. One Picard step is defect-correction iteration 1 — contractive, cheap. From + a *warm* iterate, take **no** Picard step (it wastes the good quadratic start). + A cold `v=0` start is safe on its own terms: `ε̇=0` makes `η_pl` infinite, which + the soft-min carries to the viscous branch (see the trap list for the one form + that must be written carefully). + +2. **If a single solve at the sharp surface fails**, escalate in this order: + **grid sequencing first** (solve coarse, transfer, re-solve fine — the + measured 2-3x win at the notch), and only then a **multi-solve + δ-continuation** as the rescue of last resort. The δ-discipline, when you do + reach for it: hold δ **constant** for a full nonlinear solve to tolerance; + warm-start the next, smaller δ from that converged state; march down to the + sharp surface. δ is a `constants[]` atom, so each step is a recompile-free + `PetscDSSetConstants` update. + + The packaged driver is `stokes.solve(homotopy=True)` (also callable directly + as `underworld3.systems.yield_continuation`; tune with + `homotopy_options=dict(delta0=…, down=…, dmin=…, entry_maxit=…, + step_maxit=…)`). **Treat it as a rescue, not the default**: the evidence + that once made a δ-march the recommended entry point was retracted (it + rested on a unit-scaling error — `plasticity-solvers` carries the ruling + and the surviving evidence), and the driver's documented cold-start + guarantee does not currently hold (issue #473: entry can fail on a + pressure-dependent yield, and the step control is effectively one-shot). + Newton + the automatic Picard entry handles the standard cases without it. + +3. **Consistent-Newton tangent** for non-elastic DP (`consistent_jacobian=True`); + **Picard** for elastic VEP — see `plasticity-solvers` for the per-model table. + +4. **`bt` line search** with the consistent tangent on a smooth (δ>0) surface. + +--- + +## DO NOT ramp δ inside one SNES solve + +Ramping δ **inside a single SNES solve** (a `SNESSetUpdate` callback that sharpens +the yield surface between Newton iterations) is **proven dead** — it diverges +`DIVERGED_LINEAR_SOLVE` after ~2 iterations and grinds for ~2 hours, **even on the +proven solver config**. Mechanism: the continuation only sharpens δ from a +**converged**, well-conditioned iterate; the in-SNES ramp sharpens δ **mid-solve** +at a far-from-solution iterate where the consistent-Newton Jacobian on a sharpening +surface is ill-conditioned and the linear solve fails. **Hide the *continuation*, +not the *ramp*.** (An in-SNES ramp API once shipped and has been removed from the +source entirely — use the multi-solve continuation above; `plasticity-solvers` +carries the yield-law substrate and the evidence on when a δ-march is worth it +at all.) + +--- + +## CONFIG TRAP LIST + +Each of these produces a *different* failure a few steps in — that is why the hard +case felt like whack-a-mole. Check them first. + +| Trap | Symptom | Fix | +|---|---|---| +| **Perfect plasticity's consistent tangent is SINGULAR along the flow**: on the hard-`Min` plastic branch η = τ_y/2ε̇_II, so 2η + 2η′ε̇_II = 0 — the velocity block is symmetric but semi-definite in every yielded cell. (An earlier version of this row blamed *asymmetry*; that is wrong for any η(ε̇_II) law — the rank-one term η′ ε̇⊗ε̇/ε̇_II is symmetric. Pressure-dependent yield adds a non-symmetric v–p coupling, not a non-symmetric velocity block. Corrected 2026-08-26, maintainer review.) | benign while yielded cells are few (the viscous neighbours regularise); with a large yielded fraction the velocity sub-solve caps out and Newton stalls at ~1e-3, no failure reason | give the plastic branch a positive tangent: a small δ soft-min (`yield_mode="softmin"`, powermean, `yield_anchor="yield"`), a rounded viscosity floor, or rate-strengthening ξ; Picard converges regardless (full 2η stiffness) but is linear-rate. The FMG bundle's `gmres`+`sor` smoother is Newton-safe either way | +| `preconditioner="fmg"` (vs explicit `pc_type=mg` + manual mg opts) | outer KSP "converges" in **1 iteration** → no real Newton correction → stall → `DIVERGED_LINE_SEARCH` | use explicit `pc_type=mg` with the smoother opts above; bound the outer KSP (`ksp_max_it`~80) so a hostile step fails fast | +| Cold plastic start `v=0`, or any rigid/unyielded point | `DIVERGED_FNORM_NAN` at iteration 0 | **Not** a div/0: `ε̇=0` gives `η_pl=+inf`, which `Min` and the sqrt soft-min carry correctly to the viscous branch. Only a soft-min form that computes `η_ve·η_pl/(η_ve+η_pl)` breaks (`inf/inf`). Fixed in the power-mean; if you hand-roll a blend, write the harmonic mean as `η_ve/(1+η_ve/η_pl)`. **Do not reach for a strain-rate floor** — it hides this rather than fixing it | +| LU velocity block with all-Dirichlet-ish BC | pressure nullspace singular | attach the Stokes nullspace / avoid a bare LU there | +| Hand-rolled `snes_monitor` to "see what's happening" | you read residuals but miss the tell | use `solve_with_diagnostics` / `get_snes_diagnostics` instead (below) | + +**The diagnostic tell:** `solver.get_snes_diagnostics()["linear_iterations"] ≈ 1` +per Newton step means the linear solve is doing **no real work** (the FMG-1-iteration +trap). A healthy consistent-Newton solve does real Krylov work each step and +converges quadratically. Use `solve_with_diagnostics()`, not a hand-rolled monitor. + +--- + +## Automatic warm-start (Layer 1 — landed) + +`solver.has_solution` is a **public, read-only** status flag: `True` only after a +solve whose SNES converged; reset on a structural rebuild (remesh / adapt / +mesh-mover — the `is_setup=False` hook); kept through coefficient changes (viscosity, +δ, BC values, time step). A **diverged** solve leaves it `False`, so the next solve +auto-cold-starts rather than warming off a corrupted iterate. + +On a **cold** (`zero_init_guess=True`) Stokes solve under the **consistent-Newton +tangent**, a single Picard step is now taken automatically (reusing the existing +`picard=1` machinery). The default (frozen) tangent path is bit-identical. + +```python +stokes.consistent_jacobian = True +stokes.solve() # cold → one automatic Picard step, then Newton +if stokes.has_solution: + ... +``` + +--- + +## Implementation status (this line of work) + +- **Layer 1a — DONE:** `has_solution` + cold consistent-Newton Picard warm-up + (`petsc_generic_snes_solvers.pyx`; test `test_0201`). +- **Layer 1b — DONE:** `zero_init_guess` is tri-state — `None` (default) auto-detects + from `has_solution`, `True` forces fresh, `False` insists on warm. Note warm and cold + agree only to the *convergence tolerance*, not bitwise. +- **Layer 3 — DONE:** the FMG velocity smoother defaults to `gmres`+`sor` with + `mg_levels_ksp_norm_type=none` (fixed-cost V-cycle), unconditionally — see + "Multigrid depth" below. +- **Layer 2 — SHIPPED, DEMOTED TO RESCUE:** the model advertises the homotopy + (`supports_yield_homotopy` / `_yield_homotopy_control`) and + `stokes.solve(homotopy=True, homotopy_options=...)` runs the residual-guided + continuation, returning the march summary. The doctrine that made this the + recommended entry point was retracted (unit-scaling error — see + `plasticity-solvers`), and its cold-start guarantee is broken (issue #473); + use it after Newton + Picard entry and grid sequencing have failed. + +--- + +## Multigrid depth — how to measure a smoother honestly + +**A two-level hierarchy is a coarse-grid correction, not a V-cycle.** Smoother +comparisons made on one are misleading: the gmres-over-richardson margin measured on +the Spiegelman notch is only 5 % at 3 levels but **25 % at 4** (ρ per V-cycle 0.746 → +0.560), because a deeper cycle applies the smoother on more coarse operators. Judge a +smoother at depth or not at all. + +To get depth without a monster problem, refine a **deliberately ultra-coarse NESTED +base**: `make_notch_mesh.py 1` (492 cells) + uniform `refinement=N` gives 3 levels / +7,872 cells at `N=2` and 4 levels / 31,488 at `N=3` — deeper *and* smaller than the old +2-level 38,580-cell setup. In MG you want the coarsest grid as coarse as it can be +before the problem breaks down. + +- **Never use a non-nested hierarchy** here — it does not give strong MG convergence + (maintainer ruling). Uniform refinement nests by construction. +- Accepted tradeoff: uniform refinement does **not** snap new boundary nodes back to + the analytic notch arcs (no CAD/EGADS model attached), so the corner geometry is + frozen at the coarse mesh's chords on every level. + +Measure with `fmg_contraction_probe.py` (ρ_MG per V-cycle; `<0.5` healthy, `0.8–0.95` +struggling, `≥0.98` hangs) or `smoother_depth_sweep.py` (pays the mesh build + viscous +seed once, sweeps smoothers in-process) in the Spiegelman study. + +**`solve_report` cannot see the smoother.** It records the *Newton* contraction; the +outer KSP is Eisenstat–Walker-collapsed to ~1 iteration/step, so the smoother's work +hides inside the velocity sub-block. Probe the `fieldsplit_velocity_` sub-KSP directly. + +**A smoother will not rescue small ξ.** At the hard corner the failure is operator +conditioning — the coarsest grid cannot represent the viscosity contrast — and at 4 +levels *every* smoother fails there (richardson outright, gmres with ρ>1). Use the δ/ξ +continuation to stay in the solvable region. + +## FMG on an ADAPT-ON-TOP child (locally refined meshes) + +An `adapt()` child carries its **own custom-P geometric MG tail** — subsampled to +one level per **DOUBLING of h** (`mg_coarsening_ratio=2.0`, the `adapt()` default) +— on `child._custom_mg_coarse_meshes`, and solvers built on it pick it up +automatically. So the usual advice above ("never use a non-nested hierarchy") is +satisfied without you assembling anything: + +```python +child = base.adapt(metric, max_levels=3, engine="edge_split") +stokes = uw.systems.Stokes(child, velocityField=v, pressureField=p) +stokes.solve() # pc=mg auto-attached off the child's tail +``` + +Requirements and traps, all measured: + +- **Build the base with `refinement>=1` for a deeper tail.** The custom-P tail + always starts at the BASE mesh — with `refinement=0` it is + `[base] + the intermediate doubling levels`, so there IS a coarse grid — but + the uniform base levels extend it downward, and in MG you want the coarsest + grid as coarse as it can be. +- **Keep the GRADED tail.** `_adapt_nested` stores one MG level per doubling of + resolution (`_subsample_mg_levels`; per-bisection-pass levels were measured + 2.3–7.3× slower). Handing the solver a base-only tail instead — coarse base + straight to the fully adapted mesh — **triples the V-cycle count**. +- **V-cycle counts are insensitive to element quality here, and that is a PASS not + a failed measurement.** On a fault child the velocity block takes 2 iterations + (iso) or 2–3 (TI) across meshes ranging from 156° to 105° max angle. The + geometric hierarchy's coarse spaces come from the mesh hierarchy, not from the + fine operator, so shape does not move it — which is exactly what makes + adapt-on-top viable. **If you want a solver-side probe of mesh quality, use + GAMG**, which does respond (iso 79 → 64 velocity iterations with `repair=True`). + That is now actionable: `solver.preconditioner = "gamg"` is **respected** on an + adapt child (#530) — before that guard the opportunistic pickup silently + clobbered it back to `pc=mg`, so any FMG-vs-GAMG comparison was vacuous. +- **Single-field solvers get FMG too** (#478/#534): `preconditioner = "fmg"` on a + Poisson/projection-class solver builds the custom-P tail over the mesh's own + `dm_hierarchy` — the section is not Stokes-or-adapt-child only. +- **`relax()` can trip #424.** On a relaxed, unrepaired child the barycentric + transfer hit 22 zero columns and fell back to the DENSE global RBF builder — a + performance cliff, not just a warning. +- **Every PC degradation is recorded in `solver.pc_fallbacks`** (#534) — the + requested/installed/reason record for the #424 barycentric→rbf retry, a + collapsed hierarchy, a declined pickup. Read that, don't scrape warnings. +- **`repair=True` invalidates the any-degree nested transfer** (a flipped cell can + straddle two coarse cells), so degree ≥ 2 falls back to the geometric builder. + The exact ½,½ vertex prolongation survives, because flips move no vertex. +- Under **rotated free-slip** the mesh-owned adapt tail is picked up automatically + too — the rotated KSP resolves hierarchies through the same + `custom_mg.build_transfers` rule (#467 fixed the old silent GAMG fallback). See + the `adapt-on-top-faults` skill for the plain-refined-mesh case, which still + needs `set_custom_fmg`. + +Companion skills: **`adapt-on-top-faults`** (building the child, engines, repair, +band sizing), **`adaptive-meshing`** (the mover, and `relax(pin_bands=...)` for +relaxing a mesh that was refined onto an interface). + +## The Schur complement: pair the penalty with FMG, never with GAMG + +**Symptom this is for**: the velocity block's iteration count is rock solid but +the pressure sub-solve wanders into the hundreds and eventually stops +converging. + +**First: it is probably not the pressure block.** `S = -B A^-1 B^T` is applied +*through* the velocity solve, so a velocity solve that exits at its iteration +cap makes the Schur operator inconsistent between applications — and no Krylov +method converges against an operator that moves under it. The pressure block +then caps too, and the outer flounders. Measured on SolCx (eta 1e6, P2-P0disc, +h=1/30), changing **only** `fieldsplit_velocity_ksp_max_it`: + +| velocity cap | sec | outer | pressure/app | velocity/app | +|---|---|---|---|---| +| 200 (default) | 976.0 | 44 | **200.0** | **200.0** | +| 5000 | **25.6** | **2** | **30.0** | 618.0 | + +**38x from a number that is not in the pressure block**, and the velocity error +is identical in both rows. Before tuning the Schur solve, check whether either +block sat at exactly its cap — `solve_report.sub` gives iterations and +applications per block, and a per-application count equal to the cap to the +digit is the tell. + +**Then: the penalty is the lever on the Schur count, and it needs FMG.** +`stokes.penalty = lambda` adds `lambda*mu*(div u)(div v)`, which makes the +eta-scaled mass matrix a better approximation to S. Matched on one mesh +(2592 cells), same discrete solve, only the velocity preconditioner differs: + +| lambda | velocity PC | sec | outer | Schur/app | velocity/app | velocity total | +|---|---|---|---|---|---|---| +| 0 | GAMG | 15.49 | 2 | 125.5 | 94.7 | 24802 | +| 0 | **FMG** | **3.88** | 1 | **59.0** | **8.8** | **546** | +| 10 | GAMG | 20.68 | 7 | 22.3 | **199.9 capped** | 33976 | +| 10 | **FMG** | **3.06** | 1 | **18.0** | **13.5** | **270** | + +- **With FMG, `penalty = 10` improves every axis at once**: 21% faster, Schur + count 3.3x smaller, total velocity work halved. FMG absorbs grad-div + augmentation (8.8 -> 13.5 iterations per application); GAMG does not + (94.7 -> capped). +- **With GAMG, do not use it at all.** The same `penalty = 10` makes the solve + *slower* (15.49 -> 20.68 s), because augmentation is exactly what drives GAMG + into its cap. Uncapping rescues it to 11.03 s but it still needs **833** + iterations per application, and FMG is 3.6x faster on the same mesh. + Feasible is not competitive. + +**The accuracy cost is consistent, so it is safe to pair by default.** The +penalty is grad-div, not a true augmented Lagrangian — `div(P2)` is not inside +`P0`, so the term does not vanish at the discrete solution and it does perturb +the answer. But the perturbation converges away: same rate, and the gap shrinks +under refinement. + +| cells | lambda=0 v err | rate | lambda=10 v err | rate | gap | +|---|---|---|---|---|---| +| 648 | 2.112e-1 | — | 2.327e-1 | — | 1.102 | +| 2592 | 1.266e-1 | 1.67 | 1.376e-1 | 1.69 | 1.087 | +| 10368 | 8.727e-2 | 1.45 | 9.305e-2 | 1.48 | **1.066** | + +For a pressure-dependent constitutive law use the mechanical pressure, +`p_mech = p - lambda*mu*(div u)`; the raw `p` is the multiplier. + +**Traps.** + +- **FMG needs a refined base or you silently get GAMG.** Measured: + `refinement=0` -> one hierarchy level -> default velocity PC is `gamg`; + `refinement=2` -> `mg`. So `penalty` set "with FMG" on an unrefined mesh is + actually the harmful GAMG pairing. Check + `snes.getKSP().getPC().getFieldSplitSubKSP()[0].getPC().getType()`, or read + `solver.pc_fallbacks`. +- **Scaling `saddle_preconditioner` by a constant does nothing** — it does not + change the Krylov subspace. `1/eta` and `101/eta` both give 28 iterations, + identical to every digit, so an "AL-matched" `1/(eta*(1+lambda))` cannot help. + The 1/eta *weighting* itself is worth 1.9x (28 vs 52 with a flat `1`). +- **Eisenstat-Walker is inert under `snes_type=ksponly`** — identical iterations + and error on or off. And `outer 1` is not an EW artefact: it is what a full + Schur factorisation gives when the Schur complement is solved well. +- Measurements: `~/+Simulations/pressure_schur_625/` (#625). + +## Gotchas + +- **`./uw build` → `amr-dev` env**; verify `uw.__file__` is the worktree site-packages. +- **Run VEP/consistent-Newton tests UNFORKED** — `pytest --forked` SIGABRTs (fork of + multithreaded PETSc). +- Benchmark **every** default change — "Solver Stability is Paramount". +- ξ (rate-strengthening) is a **non-homotopic** regularisation: put a user loop + *around* `solve()`, never inside the δ-march. + +## Reference + +- Design: `docs/developer/design/nonlinear-solver-homotopy-warmstart.md`, + `jacobian-consistent-tangent.md`, `solver-strategies-catalogue.md`. +- Continuation driver: `underworld3.systems.yield_continuation`. +- Diagnostics: `SNES_*.get_snes_diagnostics()` / `solve_with_diagnostics()`. +- Related skills: `plasticity-solvers` (yield law + tangent per model), + `free-surface-convection`, `adaptive-meshing` (mover + `relax(pin_bands=...)`), + `adapt-on-top-faults` (locally refined children and their MG tail). +- Reconnection / refinement engines: + `docs/developer/design/mesh-reconnection-and-delaunay-adapt.md`. diff --git a/.claude/skills/plasticity-solvers/SKILL.md b/.claude/skills/plasticity-solvers/SKILL.md new file mode 100644 index 000000000..d5f83bfd0 --- /dev/null +++ b/.claude/skills/plasticity-solvers/SKILL.md @@ -0,0 +1,226 @@ +--- +name: plasticity-solvers +description: How to get hard-Min viscoplastic / visco-elastic-plastic (VEP) Stokes solves to CONVERGE in Underworld3 — Newton with the automatic Picard entry (solver.consistent_jacobian), which tangent per model, grid sequencing for the hard cases, and the δ-soft-min substrate (yield_mode / yield_smoother / yield_anchor) as a modelling choice. Reach for THIS first when a Drucker-Prager / yield-stress Stokes solve stalls, diverges (DIVERGED_LINEAR_SOLVE / line-search fail), or grinds through ~20+ nonlinear iterations. Tells you which tangent to use per model, how to confirm you are actually running Newton, and the measured failure modes. For the solver-config trap list and multigrid, see `nonlinear-solver`. +--- + +# plasticity-solvers + +The workable recipe for **nonlinear convergence of yielding (viscoplastic / VEP) +Stokes** in Underworld3. Hard-`Min` yield laws have a non-differentiable kink that +breaks naive solvers; this encodes what the yield campaigns measured actually works — +and records what was retired. + +**The default call is now just:** + +```python +stokes.constitutive_model = cm # ViscoPlastic / ViscoElasticPlastic / TI-VEP +cm.Parameters.yield_stress = tau_y # finite -> plasticity active +stokes.consistent_jacobian = True # Newton tangent (non-elastic DP; see table) +stokes.solve() # cold start takes ONE Picard step automatically +``` + +--- + +## The doctrine (measured, 2026-07 campaigns) + +Yielding viscoplasticity is `η_eff = Min(η_visc, η_yield)`, +`η_yield = τ_y/(2·ε̇_II)`. The `Min` kink is what makes it hard. + +1. **Picard is an ENTRY requirement, not an accelerator.** On a cold start under + the consistent tangent, `solve()` takes one automatic Picard (frozen-tangent) + step and then runs Newton (fires only when `picard==0`, + `consistent_jacobian is True`, and the start is cold — from a warm iterate, + 0 Picard really is 0). Do NOT front-load Picard where Newton works: measured, + opening with 5 / 25 Picard steps cost 12 / 30 total iterations against pure + Newton's 7. + +2. **Newton-first; spend Picard only to rescue.** When Newton fails + (`DIVERGED_LINE_SEARCH` / `DIVERGED_LINEAR_SOLVE`) **or stalls admissibly** + (steps accepted, residual flat — no FAIL reason ever fires), revert to the best + iterate and buy a Picard block: `solve(picard=N)`, or + `consistent_jacobian="continuation"` (staged Picard→Newton α-blend; α is a + `constants[]` atom, no recompile). Rescue on *stagnation*, not only on a + failure reason — the failure-only trigger measured byte-identical to doing + nothing at the cliff. + +3. **Grid sequencing is the validated warm start for hard problems.** Solve + coarse (it finds the localisation structure cheaply), transfer the state up + (the linear-exact local RBF, #430), warm-start the fine solve. Measured on the + notch: 2–3× deeper residual, more localised, fewer iterations than any cold + fine strategy. No packaged API yet — hand-roll the cascade with + `uw.function.evaluate` per level; PETSc's `-snes_grid_sequence` does NOT work + on UW3 meshes. See `docs/developer/design/multilevel-nonlinear-stokes-strategy.md`. + +4. **`solver.has_solution` / tri-state `zero_init_guess`** make warm-started + campaigns safe: `None` (default) auto-detects; a diverged solve or a remesh + clears the flag so the next solve cold-starts (with its Picard entry) instead + of warming off a corrupted iterate. + +--- + +## The retired doctrine — do not resurrect it + +An earlier line of work paired the δ-soft-min with a **yield homotopy** and shipped +a model-level enable method for an in-SNES δ-ramp. That API **has been removed from +the source**, and the doctrine it taught rested on a unit-scaling error in the +campaign that motivated it. +Re-measured on the correctly-scaled problem (13 points across two parameter axes): + +- the δ-march **never succeeded where a direct hard-Min solve failed**, and where + both work the direct solve is 4–5× faster with better residuals; +- homotopy rescues **Picard**, not Newton — under the consistent tangent it adds + nothing; +- ramping δ **inside** a single SNES solve is separately proven dead (diverges + `DIVERGED_LINEAR_SOLVE` within ~2 iterations even on the proven config). + +The ruling that closed the campaign: **regularise the PROBLEM (give the shear band +a physical length scale), not the solver.** Where a hard-Min solve will not +converge, sharpening δ is not the missing lever — a viscous seed, the Picard +rescue, and grid sequencing are. + +--- + +## Which tangent for which model (measured) + +`solver.consistent_jacobian` takes `False` | `True` | `"continuation"`: + +| Model | Use | Why | +|-------|-----|-----| +| `ViscoPlasticFlowModel` (non-elastic) | **`True`** (Newton) | Quadratic near the solution; the automatic Picard entry handles the cold start. | +| `ViscoElasticPlasticFlowModel` (VEP) | **`False`** (Picard) | The consistent yield tangent over the elastic stress-history block makes the Jacobian **indefinite → `DIVERGED_LINEAR_SOLVE`**. Picard is contractive. | +| `TransverseIsotropicVEPFlowModel` (TI-VEP) | **`False`** (Picard) | Same as VEP (elastic). | +| Any, far from the solution | **`"continuation"`** | Staged Picard→Newton; Picard locates the basin, Newton finishes. Beat pure Newton at every notch point measured — but its stage switch is a residual LEVEL and one-way, so it can overspend Picard on easy problems. | + +> Measured: VEP loading-through-yield — Picard converges (σ locks at τ_y), +> Newton diverges every step (`DIVERGED_LINEAR_SOLVE`). + +--- + +## Confirm you are actually running Newton + +A consistent-Newton solve on a smooth-enough problem converges **quadratically** — +the residual roughly squares each iteration and reaches ~1e-12 in 3–6 nonlinear +steps. A **linear** tail (a roughly constant reduction factor over ~15–25 steps) +means you are on the Picard tangent — check `solver.consistent_jacobian is True` +and that the viscosity is a function of the unknowns, not a constant. (On genuinely +hard localising problems the quadratic phase may never be reached — that is the +problem, not the tangent; see the doctrine above.) + +Direct symbolic check that the Newton term is present (`dF1/dL` differs between the +frozen and unwrapped flux by exactly the `∂η/∂(grad v)` term): + +```python +import sympy +from underworld3.function.expressions import unwrap_expression +F1 = sympy.Array(stokes.F1.sym) +L = sympy.Array(stokes.Unknowns.L) +G_picard = sympy.derive_by_array(F1, L) +F1_unwrapped = sympy.Array( + [unwrap_expression(e, mode="symbolic_keep_constants") for e in F1], F1.shape) +G_newton = sympy.derive_by_array(F1_unwrapped, L) +# a nonzero difference == the Newton form is present +``` + +--- + +## δ smoothing — a modelling choice, not a convergence strategy (#475 substrate) + +If you want a *rounded* yield law at all (as physics or as a formulation choice), +the substrate is three model properties; δ is a `constants[]` atom, so changing it +never recompiles: + +- **`yield_mode`**: `"min"` (default — exact hard `Min`), `"softmin"` (the + δ-parameterised family below), `"harmonic"` (a **distinct physical model**, a + parallel blend — not an approximation to `Min`). +- **`yield_smoother`**: `"sqrt"` or `"powermean"`. **δ is NOT the same parameter + in the two families**: the power mean's sharpness is `s = 1/(δ + 0.001)`, so + δ ≤ 1 and δ = 1 IS the harmonic mean; the sqrt family's δ is a percentage stress + deviation, generous entry O(10), and δ = 0 is exactly `Min`. The power mean at + δ = 0 lands within 0.07 % of `Min` — an order of magnitude inside a 1e-8 solver + tolerance. +- **`yield_anchor`**: which point is pinned to the exact law — the SIDE of `Min` + belongs to the anchor, not the family. `"onset"` (default, historical) is exact + on the unyielded branch but sits BELOW `Min` at and above yield — a *weaker* + problem than the sharp one. `"yield"` pins τ/τ_y = 1 exactly and sits on-or-above + `Min` everywhere; the cost is stiffer unyielded material (bounded ×2 sqrt, + ×2^δ powermean, both → 1 as δ → 0). + +**If you march δ toward the sharp law, the only sound discipline is multi-solve:** +hold δ constant for a full solve to tolerance, warm-start the next smaller δ, +sharpen only between converged solves. Never ramp δ inside one SNES solve. The +packaged march is `stokes.solve(homotopy=True)` / +`underworld3.systems.yield_continuation` — usable, with two open caveats (#473): +its documented cold-start guarantee does NOT hold on a multi-material +(`Piecewise`) yield stress, so give it a viscous pre-solve anyway; and its +adaptive step control is effectively one-shot (one early decision pins the step +for the whole march). Do not expect it to cross a cliff the direct solve cannot — +measured, it never has. + +--- + +## Floors + +- **`shear_viscosity_min`** (default `-oo` = off) is applied through + `uw.maths.smooth_max`, but the default rounding scale is zero under + `yield_mode="min"` and `δ·|floor|` under the smooth modes — so it **vanishes as + δ → 0**, leaving an exact `Max` corner that kills the consistent tangent + (`nl=0, DIVERGED_LINEAR_SOLVE`). Set **`viscosity_min_rounding`** (a few per + cent of the floor) and the cutoff is differentiable at any δ, including 0. +- A viscosity floor bounds the viscosity contrast and therefore how localised the + solution can be — relaxing it toward zero is a solution-SELECTION continuation, + independent of δ. Use it deliberately. +- **Do not add a strain-rate floor for the cold start.** At `ε̇=0`, `η_pl=+inf` + is carried correctly to the viscous branch by `Min` and by both smooth families; + only a hand-rolled product-over-sum harmonic blend breaks (`inf/inf`) — write it + as `η_ve/(1+f)`. + +--- + +## Failure modes → fixes + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `DIVERGED_LINEAR_SOLVE`, 0 iters, VEP | consistent Newton over the elastic block → indefinite | Picard (`consistent_jacobian=False`) | +| `DIVERGED_LINEAR_SOLVE` at nl=0 with a viscosity floor set | δ→0 leaves the floor's `Max` corner exact | set `viscosity_min_rounding` | +| Newton stalls with no divergence reason | admissible uselessness — steps accepted, residual flat | revert to best iterate, Picard block (`picard=N` / `"continuation"`); consider grid sequencing | +| Converges but σ sits **below** τ_y | a fixed δ>0 soft-min under the default `"onset"` anchor is a WEAKER law | that is the modelling choice you made — use `yield_anchor="yield"`, or δ→0 / `yield_mode="min"` for the exact surface | +| Linear (~20-iter) convergence | Picard tangent when you wanted Newton | `consistent_jacobian=True` on a non-elastic model (see "Confirm" above) | + +--- + +## Gotchas + +- **`./uw build` → `amr-dev` env.** Verify `uw.__file__` is the worktree site-packages. +- **Run VEP tests UNFORKED** — `pytest --forked` SIGABRTs here (fork of multithreaded PETSc). +- `harmonic` yield mode is a **distinct physical model**, not an approximation to Min. +- If you project η, use a **low-order** field (P0/P1) — higher order overshoots and η + is not guaranteed positive. + +--- + +## Reference + +- Yield law: `ViscousFlowModel._combine_yield`, `yield_anchor`, `yield_smoother`, + `viscosity_min_rounding` in `constitutive_models.py`. +- Tangent: `solver.consistent_jacobian` / `_jacobian_source` in + `petsc_generic_snes_solvers.pyx`; design + `docs/developer/design/jacobian-consistent-tangent.md`. +- Warm start / continuation: `docs/developer/design/nonlinear-solver-homotopy-warmstart.md`; + grid sequencing: `docs/developer/design/multilevel-nonlinear-stokes-strategy.md`. +- Tests: `test_0201_solver_has_solution_warmstart.py`, `test_1055_yield_smoother.py`, + `test_1057_yield_homotopy_solve.py`, `test_1059_yield_anchor.py`. +- Solver-config traps, smoother, FMG/multigrid: the `nonlinear-solver` skill. + +Footnote: before this work UW3 differentiated the flux with the viscosity still +wrapped, so `∂η/∂(grad v)` was dropped and viscoplastic solves silently ran the Picard +tangent — the origin of the "~20 iterations is intrinsic" folklore. + +## SNESFAS — do not reach for it + +Nonlinear multigrid (SNESFAS) looks tempting for hard viscoplastic solves but is +**not a viable option** at present (maintainer ruling 2026-07-17): there are no +good preconditioners for the nonlinear hierarchy, and it abandons the robust +linear-solver path (consistent tangent / continuation + fieldsplit + MG) that +this skill is built around. It stays options-only for experiments; treat it as a +future investigation. See `docs/developer/design/solver-strategies-catalogue.md` +and `MULTIGRID_MINIMAL_CONTROL_2026-07.md` (ruling 6). diff --git a/.claude/skills/uw-visualisation/SKILL.md b/.claude/skills/uw-visualisation/SKILL.md new file mode 100644 index 000000000..1a43f6ea5 --- /dev/null +++ b/.claude/skills/uw-visualisation/SKILL.md @@ -0,0 +1,125 @@ +--- +name: uw-visualisation +description: Render Underworld3 mesh fields (T, V, viscosity, the adapted mesh) correctly with PyVista. Use whenever you need to SEE a UW3 result — a field colormap, the moving/adapted mesh, streamlines, or compare runs. Reach for THIS before hand-rolling a renderer; getting the four cosmetic settings wrong makes renders look grey/patchy/blocky and wastes a round-trip with Louis. +--- + +# uw-visualisation + +Canonical PyVista recipe for Underworld3 fields. This exists because every fresh +Claude session re-derives the renderer and gets the colormap / background / +lighting / DOF-sampling wrong, producing "grey/patchy/weird" images Louis +rejects. The settings below match his reference renders exactly. + +**Use PyVista (`underworld3.visualisation`), NOT matplotlib.** Louis reaffirmed +this even after seeing the legacy matplotlib renderer +(`scripts/fault_convection_frames.py`) — that one is NOT preferred. + +## Hard rules (artifacts + output location) + +- **Outputs go under `~/+Simulations/...`, NEVER `/tmp`** (Louis can't view /tmp + or harness task paths). Mirror the run's `--sim-dir`; write `T_.png` into + the run directory, comparison figures into the sim-dir root. +- `pv.OFF_SCREEN = True` at import; finish with `pl.screenshot(path); pl.close()`. + +## The field+mesh pattern (copy this exactly) + +```python +import numpy as np, underworld3 as uw, underworld3.visualisation as vis, pyvista as pv +pv.OFF_SCREEN = True + +mesh = uw.discretisation.Mesh(f"{label}.mesh.00000.h5") # or the live mesh +T = uw.discretisation.MeshVariable("T_v2p1", mesh, 1, degree=3, continuous=True) +T.read_timestep(label, "T_v2p1", 0, outputPath=D) # or use the live var + +pv_T = vis.meshVariable_to_pv_mesh_object(T) # Delaunay through T's OWN DOFs +pv_T.point_data["T"] = np.asarray(T.data[:, 0]) # attach DOF values DIRECTLY (P3-faithful) +edges = vis.mesh_to_pv_mesh(mesh).extract_all_edges() + +pl = pv.Plotter(off_screen=True, window_size=(1000, 1000)) +pl.set_background("white") # rule 2 +pl.add_mesh(pv_T, scalars="T", cmap="RdBu_r", clim=(0, 1), # rules 1 + clim required + show_edges=False, lighting=False) # rule 3 +pl.add_mesh(edges, color="black", line_width=0.5, lighting=False) # mesh overlay +pl.view_xy(); pl.camera.zoom(1.3) +pl.screenshot(out); pl.close() +``` + +## The four things that make renders look bad (all COSMETIC) + +1. `cmap="coolwarm"` → muddy grey-lavender midtone — this IS the "blue/grey/red" + Louis rejects. **Use `cmap="RdBu_r"`** (clean blue→white→red). +2. PyVista's default grey background bleeds through RdBu_r's white (T≈0.5) → dirty + grey. **Always `pl.set_background("white")`.** +3. Default lighting darkens the colormap. **Always `lighting=False`** on every + `add_mesh`. +4. Re-evaluating via `scalar_fn_to_pv_points` / vertex-only sampling drops the + high-order DOFs → blocky. **Attach `T.data[:,0]` directly** to the DOF-cloud + mesh from `meshVariable_to_pv_mesh_object` (it is correct for annulus/box/disc + — do NOT avoid it). `clim` MUST be passed (default `clim=""` trips `np.any`). +5. Resampling ANY field (even P1) onto a regular pixel grid via + `uw.function.evaluate` **dapples at element boundaries** — grid points that + straddle a facet get located into a neighbouring cell with slightly-off + reference coords (Louis: "artefacts across the elements", S-fault rig). + Render derived fields NODALLY on the mesh's own triangulation instead: + evaluate at `mesh_to_pv_mesh(mesh).points` (exact at vertices for P1, + whichever cell the locator picks), attach as point_data, let VTK + interpolate WITHIN elements. On a SPLIT mesh never Delaunay the DOF cloud + (it re-triangulates across the slit) — use the mesh's own cells. + +## Seeing the MESH (adaptation / moving mesh) + +The full-annulus T colormap **washes out mesh detail** — at whole-domain zoom the +grading is invisible. To judge adaptation you MUST crop: + +- Zoom the feature region with a parallel camera: + `pl.camera.parallel_projection = True; pl.camera.parallel_scale = half_width; + pl.camera.focal_point = (cx, cy, 0)`. +- For mesh-only views, drop the field and draw `edges` on white, `line_width≈0.7`. +- Real corruption vs render artifact: apparent "holes / lumps" are often a + mesh-overlay/low-res artifact. Before calling adaptation broken, CHECK the + field's value range is bounded and count folded elements (negative cell area) + programmatically — do NOT diagnose from a render alone. +- **Overlay the feature you're refining to** (a fault trace, an interface): draw it + as a red `pv.PolyData` line over the mesh. Without it you cannot tell whether the + refinement sits ON the feature or has drifted off it (a real failure mode — see + the `adaptive-meshing` skill). Read the geometry from the run manifest so any run + renders the same way. + +## Adaptive / long runs + +- **Render each checkpoint as it lands**, not just the last frame: arm a Monitor that + polls for new `run.mesh.NNNNN.{xdmf,h5}` and emits the index → render on each + event. A completion-only watch leaves you blind for a multi-hour (e.g. TI) run. +- The per-step mesh GEOMETRY must have been written (`write_timestep(..., + meshUpdates=True)`) or you'll render deformed fields on the stale step-0 mesh. + Load the per-step `run.mesh.NNNNN.h5` as the mesh, then `read_timestep` the vars. + +## Velocity + +Same pattern; use **streamlines, not glyphs**. Build a pv mesh for V, add +`pv_mesh.streamlines(...)` or evaluate V on a line seed. Magnitude with the same +white-bg / lighting=False rules. + +## Quantities to judge a convection run (not just pretty pictures) + +- `vrms` from `uw.function.evaluate(V.sym.dot(V.sym), mesh.X.coords)` → the clean + kinetic-energy indicator (more reliable than nodal boundary metrics). +- Surface heat flux Nu via `uw.maths.BdIntegral` on the Upper boundary. +- Mesh quality: fault/bulk nearest-neighbour spacing RATIO (cKDTree) for refinement; + folded-element count + min cell area for tangling. + +## Templates in this skill + +- `render_field.py` — single/`--all`-steps T+mesh render of a run directory. +- `render_field_streamlines.py` — T colormap + mesh + **V streamlines** (sparse + seeds, thin lines, short integration so weak/closed cells read clearly, not + black spiral-blobs). Use for convection. `--tag --all`. +- `zoom_compare.py` — side-by-side cropped mesh+field for N runs at one step. + +Copy these into the run's `scripts/` (or run in place), point `--sim-dir` at the +run, and adjust the field/variable names. They already encode every rule above. + +## Related memory + +`feedback_use_uw_pyvista_visualisation.md`, `feedback_pyvista_viz_pattern.md`, +`feedback_render_all_steps.md`, `project_adaptation_corruption_was_render_artifact.md`. diff --git a/.claude/skills/uw-visualisation/render_field.py b/.claude/skills/uw-visualisation/render_field.py new file mode 100644 index 000000000..c02e29c86 --- /dev/null +++ b/.claude/skills/uw-visualisation/render_field.py @@ -0,0 +1,57 @@ +"""Render a UW3 scalar MeshVariable + mesh edges for one or all snapshots of a +run directory, following the canonical UW3 PyVista pattern (see SKILL.md). + +Outputs _.png into the run directory. Outputs live under ~/+Simulations. + +Usage: + python render_field.py --tag --sim-dir ~/+Simulations/ [--step stepNNNN | --all] + python render_field.py --tag myrun --var T_v2p1 --degree 3 --clim 0 1 --all +""" +import os, glob, re, argparse +import numpy as np, underworld3 as uw, underworld3.visualisation as vis, pyvista as pv + +pv.OFF_SCREEN = True +ap = argparse.ArgumentParser() +ap.add_argument("--tag", required=True) +ap.add_argument("--sim-dir", default="~/+Simulations/StagnantLid") +ap.add_argument("--var", default="T_v2p1", help="MeshVariable name in the checkpoint") +ap.add_argument("--degree", type=int, default=3) +ap.add_argument("--clim", type=float, nargs=2, default=[0.0, 1.0]) +ap.add_argument("--cmap", default="RdBu_r") +ap.add_argument("--step", default="", help="stepNNNN; empty = latest") +ap.add_argument("--all", action="store_true", help="render every snapshot") +args = ap.parse_args() +D = os.path.expanduser(os.path.join(args.sim_dir, args.tag)) + +cands = sorted(glob.glob(os.path.join(D, "step*.mesh.00000.h5")), + key=lambda c: int(re.search(r"step(\d+)\.mesh", c).group(1))) +if args.all: + labels = [re.search(r"(step\d+)\.mesh", os.path.basename(c)).group(1) for c in cands] +elif args.step: + labels = [args.step] +else: + labels = [re.search(r"(step\d+)\.mesh", os.path.basename(cands[-1])).group(1)] + + +def render(label): + mesh = uw.discretisation.Mesh(os.path.join(D, f"{label}.mesh.00000.h5")) + var = uw.discretisation.MeshVariable(args.var, mesh, 1, degree=args.degree, + continuous=True) + var.read_timestep(label, args.var, 0, outputPath=D) + pv_v = vis.meshVariable_to_pv_mesh_object(var) + pv_v.point_data["f"] = np.asarray(var.data[:, 0]) + edges = vis.mesh_to_pv_mesh(mesh).extract_all_edges() + pl = pv.Plotter(off_screen=True, window_size=(1000, 1000)) + pl.set_background("white") + pl.add_mesh(pv_v, scalars="f", cmap=args.cmap, clim=tuple(args.clim), + show_edges=False, lighting=False, + scalar_bar_args={"title": args.var}) + pl.add_mesh(edges, color="black", line_width=0.5, lighting=False) + pl.view_xy(); pl.camera.zoom(1.3) + out = os.path.join(D, f"{args.var}_{label}.png") + pl.screenshot(out); pl.close() + print("->", out, flush=True) + + +for lab in labels: + render(lab) diff --git a/.claude/skills/uw-visualisation/render_field_streamlines.py b/.claude/skills/uw-visualisation/render_field_streamlines.py new file mode 100644 index 000000000..c0fc4752c --- /dev/null +++ b/.claude/skills/uw-visualisation/render_field_streamlines.py @@ -0,0 +1,80 @@ +"""Render a UW3 run: T colormap + mesh edges + VELOCITY STREAMLINES. +Follows the canonical UW3 PyVista pattern (RdBu_r, white bg, lighting=False, +DOF-faithful) and overlays streamlines of V. Outputs _.png. + +Usage: + python render_field_streamlines.py --tag --sim-dir ~/+Simulations/ [--step stepNNNN | --all] +""" +import os, glob, re, argparse +import numpy as np, underworld3 as uw, underworld3.visualisation as vis, pyvista as pv +pv.OFF_SCREEN = True + +ap = argparse.ArgumentParser() +ap.add_argument("--tag", required=True) +ap.add_argument("--sim-dir", default="~/+Simulations/StagnantLid") +ap.add_argument("--tvar", default="T_v2p1"); ap.add_argument("--tdeg", type=int, default=3) +ap.add_argument("--vvar", default="V_v2p1"); ap.add_argument("--vdeg", type=int, default=2) +ap.add_argument("--clim", type=float, nargs=2, default=[0.0, 1.0]) +ap.add_argument("--step", default=""); ap.add_argument("--all", action="store_true") +ap.add_argument("--rin", type=float, default=0.5); ap.add_argument("--rout", type=float, default=1.0) +args = ap.parse_args() +D = os.path.expanduser(os.path.join(args.sim_dir, args.tag)) + +cands = sorted(glob.glob(os.path.join(D, "step*.mesh.00000.h5")), + key=lambda c: int(re.search(r"step(\d+)\.mesh", c).group(1))) +if args.all: + labels = [re.search(r"(step\d+)\.mesh", os.path.basename(c)).group(1) for c in cands] +elif args.step: + labels = [args.step] +else: + labels = [re.search(r"(step\d+)\.mesh", os.path.basename(cands[-1])).group(1)] + + +def render(label): + mesh = uw.discretisation.Mesh(os.path.join(D, f"{label}.mesh.00000.h5")) + T = uw.discretisation.MeshVariable(args.tvar, mesh, 1, degree=args.tdeg, continuous=True) + T.read_timestep(label, args.tvar, 0, outputPath=D) + V = uw.discretisation.MeshVariable(args.vvar, mesh, mesh.dim, degree=args.vdeg, continuous=True) + V.read_timestep(label, args.vvar, 0, outputPath=D) + + pv_T = vis.meshVariable_to_pv_mesh_object(T) + pv_T.point_data["T"] = np.asarray(T.data[:, 0]) + edges = vis.mesh_to_pv_mesh(mesh).extract_all_edges() + + # velocity mesh + 3D vectors for streamlines + pv_V = vis.meshVariable_to_pv_mesh_object(V) + Vd = np.asarray(V.data) + vec = np.zeros((pv_V.n_points, 3)); vec[:, 0] = Vd[:, 0]; vec[:, 1] = Vd[:, 1] + pv_V["V"] = vec; pv_V.set_active_vectors("V") + vmax = float(np.linalg.norm(Vd, axis=1).max()) + + # seed a sparse disc of points across the annulus (sparse + short + # integration so streamlines read as flow direction, not wound-up spirals + # that black out the field — low-Ra cells are tight closed loops). + rng = np.linspace(args.rin + 0.05, args.rout - 0.05, 4) + th = np.linspace(0, 2 * np.pi, 14, endpoint=False) + R, TH = np.meshgrid(rng, th) + seed = pv.PolyData(np.c_[(R * np.cos(TH)).ravel(), + (R * np.sin(TH)).ravel(), + np.zeros(R.size)]) + strm = pv_V.streamlines_from_source( + seed, vectors="V", integration_direction="both", + max_step_length=0.5, initial_step_length=0.02, + max_steps=300, terminal_speed=max(vmax * 1e-4, 1e-9)) + + pl = pv.Plotter(off_screen=True, window_size=(1000, 1000)) + pl.set_background("white") + pl.add_mesh(pv_T, scalars="T", cmap="RdBu_r", clim=tuple(args.clim), + show_edges=False, lighting=False, scalar_bar_args={"title": "T"}) + pl.add_mesh(edges, color="grey", line_width=0.3, lighting=False, opacity=0.4) + if strm.n_points > 0: + pl.add_mesh(strm, color="black", line_width=1.4, lighting=False) + pl.add_text(f"{label} |v|max={vmax:.2f}", font_size=10, color="black") + pl.view_xy(); pl.camera.zoom(1.3) + out = os.path.join(D, f"TV_{label}.png") + pl.screenshot(out); pl.close() + print("->", out, flush=True) + + +for lab in labels: + render(lab) diff --git a/.claude/skills/uw-visualisation/zoom_compare.py b/.claude/skills/uw-visualisation/zoom_compare.py new file mode 100644 index 000000000..0025aaab7 --- /dev/null +++ b/.claude/skills/uw-visualisation/zoom_compare.py @@ -0,0 +1,59 @@ +"""Zoomed side-by-side of the fault region (12 o'clock) showing T + the +adapted mesh for several runs at one step. The full-annulus render washes out +fault mesh detail (memory note); this crops to the fault neighbourhood so the +clustering (or lack of it) is visible. + +Usage: + python fault_zoom_compare.py --step step0060 \ + --tags rq_passive_uniform rq_passive_gmsh --labels "uniform 1.6x" "gmsh 4.7x" +""" +import os, argparse +import numpy as np, underworld3 as uw, underworld3.visualisation as vis, pyvista as pv + +pv.OFF_SCREEN = True +ap = argparse.ArgumentParser() +ap.add_argument("--step", default="step0060") +ap.add_argument("--tags", nargs="+", required=True) +ap.add_argument("--labels", nargs="+", default=None) +ap.add_argument("--sim-dir", default="~/+Simulations/StagnantLid+Fault") +ap.add_argument("--out", default="fault_zoom_compare.png") +# Fault is at theta=90 (top), dipping east. Crop a box around the trace. +ap.add_argument("--cx", type=float, default=0.12) +ap.add_argument("--cy", type=float, default=0.82) +ap.add_argument("--half", type=float, default=0.34) +args = ap.parse_args() + +SIM = os.path.expanduser(args.sim_dir) +labels = args.labels or args.tags +n = len(args.tags) + +pl = pv.Plotter(off_screen=True, shape=(1, n), window_size=(700 * n, 760), + border=False) +pl.set_background("white") +for i, (tag, lab) in enumerate(zip(args.tags, labels)): + D = os.path.join(SIM, tag) + mp = os.path.join(D, f"{args.step}.mesh.00000.h5") + if not os.path.exists(mp): + continue + mesh = uw.discretisation.Mesh(mp) + T = uw.discretisation.MeshVariable("T_v2p1", mesh, 1, degree=3, + continuous=True, varsymbol="T") + T.read_timestep(args.step, "T_v2p1", 0, outputPath=D) + pv_T = vis.meshVariable_to_pv_mesh_object(T) + pv_T.point_data["T"] = np.asarray(T.data[:, 0]) + edges = vis.mesh_to_pv_mesh(mesh).extract_all_edges() + pl.subplot(0, i) + pl.add_text(lab, font_size=11, color="black") + pl.add_mesh(pv_T, scalars="T", cmap="RdBu_r", clim=(0, 1), + show_edges=False, lighting=False, show_scalar_bar=False) + pl.add_mesh(edges, color="black", line_width=0.7, lighting=False) + pl.view_xy() + pl.camera.focal_point = (args.cx, args.cy, 0.0) + pl.camera.position = (args.cx, args.cy, 10.0) + pl.camera.parallel_projection = True + pl.camera.parallel_scale = args.half + +out = os.path.join(SIM, args.out) +pl.screenshot(out) +pl.close() +print("->", out) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..29182f977 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +# Host pixi environments — wrong arch (osx-arm64), multi-GB. +# Without this, COPY . . sends them into the build context and they +# clobber the freshly-installed linux runtime env in stage 1. +.pixi/ + +# Host-compiled Cython extensions — wrong arch for linux targets. +build/ + +# Git history not needed in build context. +.git/ + +# Large legacy content not needed in the container. +docs_legacy/ + +# Python cache. +**/__pycache__/ +**/*.pyc diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..5952dfd1d --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,16 @@ + + +## Checklist (UW3 Style Charter — docs/developer/UW3_STYLE_CHARTER.md) + +- [ ] I have read the Style Charter and followed it over the surrounding code (S2) +- [ ] No drive-by refactors, renames, or "while I was here" cleanups (S9) +- [ ] Bug fixes ship the regression test, written first, with `level_*` and `tier_*` markers (S8) +- [ ] No hedging names (`maybe_` / `try_` / `do_`) and no commented-out code (S3, S4) +- [ ] Every exception swallow states its sanctioned failure mode in a comment (S4) +- [ ] New data access uses `.array` / `mesh.X.coords` — no `with ....access(...)`, no `mesh.data` (S7) +- [ ] Parallel safety considered — np2/np4 checked where swarm/mesh/solver dispatch is touched (S11) +- [ ] No `pixi.toml` / `pixi.lock` or other dependency changes riding in an unrelated PR +- [ ] AI-assisted work carries the attribution line below + + diff --git a/.github/workflows/build_uw3_and_test.yaml b/.github/workflows/build_uw3_and_test.yaml index 3918c0f3e..f9520f557 100644 --- a/.github/workflows/build_uw3_and_test.yaml +++ b/.github/workflows/build_uw3_and_test.yaml @@ -19,10 +19,43 @@ on: workflow_dispatch: +env: + # Some Azure runners carry a MANA NIC whose InfiniBand verbs device UCX + # advertises but cannot open: uct_iface_open(ud_verbs/mana_0) fails inside + # MPI_Init, so `import underworld3` dies before any UW3 code runs (issues + # #461/#528 — four hits in the first week of 2026-08, each costing a manual + # re-roll). A single-node CI runner needs only shared memory and TCP, so pin + # the UCX transports and retire the whole flake class. + UCX_TLS: tcp,sm,self + + # Distribute the serial batches across worker processes. This was held back + # until #567 was fixed: worker counts change which files share a process, and + # a units test that switched the units system on at import time then reached + # a module-scoped fixture in the point-locator suite before anything reset it. + # That is fixed at source in tests/conftest.py, and the whole suite is green + # at 4, 8 and 16 workers. + # + # 4 is the vCPU count of the runner. Do NOT try to detect it: the first + # attempt derived the count inside the job, got 1, and ran xdist with a single + # worker — a spawn and a fresh underworld3 import per batch, parallelising + # nothing, for 52m37s. The MPI batches are unaffected either way; they run + # their own mpirun. + WORKERS: 4 + jobs: test: runs-on: ubuntu-latest - timeout-minutes: 60 + # Raised past 60 minutes on a 2026-08-15 measurement (serial phase ~55 min). + # xdist has since cut that: the five successful runs before 2026-09-12 spent + # 34, 35, 43, 44 and 48 minutes in "Run tests", well inside this budget. The + # headroom is why the previously-deferred bands could simply be switched on + # (#721 follow-up) rather than traded against something else. + # + # Sharding the batches across parallel jobs remains the durable fix if this + # tightens again; it was referenced here as "the CI-runtime issue" but never + # actually filed, so the numbers above are recorded in the issue that this + # change opens. + timeout-minutes: 120 steps: - uses: actions/checkout@v4 @@ -44,5 +77,25 @@ jobs: # editable installs are project policy violation, see CLAUDE.md). run: pixi run -e dev build + # Import smoke test: a hard crash during `import underworld3` (segfault + # / abort in a compiled module, e.g. from a poisoned env cache) kills + # pytest BEFORE its first stdout flush, so every batch below fails with + # zero output and the run is undiagnosable from the log (this happened + # on PR #394 — a runner-side flake that cost a full debug round-trip). + # This 5-second step makes any import-time crash loud and localised: + # faulthandler prints the native traceback here rather than nothing + # there. + - name: Import smoke test (faulthandler) + run: pixi run -e dev python -u -X faulthandler -c "import faulthandler; faulthandler.enable(); import underworld3; print('import OK', underworld3.__version__)" + - name: Run tests run: pixi run -e dev ./scripts/test.sh --p 2 + + # The tier C characterisations are a REPORT, not a gate, so they run on a + # push to development rather than on every pull request. They include the + # slow level_3 cases and add ~42 min, which a PR should not pay to learn + # something that cannot block it. Failures here are expected to be read + # and answered, not reverted. + - name: Tier C characterisations (report only) + if: github.event_name == 'push' + run: pixi run -e dev ./scripts/test.sh --tier-c-report diff --git a/.github/workflows/docker-image.yaml b/.github/workflows/docker-image.yaml index 897089063..ad3a3d300 100644 --- a/.github/workflows/docker-image.yaml +++ b/.github/workflows/docker-image.yaml @@ -1,7 +1,7 @@ name: "GHCR: Command-Line Container" # Builds the Underworld3 command-line Docker image and pushes to GHCR. -# Uses container/Containerfile (micromamba-based image for local use). +# Uses container/Containerfile (pixi-based two-stage image for local use). # # This is separate from binder-image.yml which builds Binder-ready images # optimized for mybinder.org. @@ -12,7 +12,8 @@ on: paths: # Only rebuild when these files change - 'container/Containerfile' - - 'environment.yaml' + - 'pixi.toml' + - 'pixi.lock' - 'src/**/*.pyx' - 'src/**/*.c' - 'setup.py' @@ -23,6 +24,8 @@ on: description: 'Force full rebuild (no cache)' type: boolean default: false + release: + types: [published] jobs: build-and-push: @@ -30,16 +33,29 @@ jobs: permissions: contents: read packages: write + actions: write steps: - name: Checkout repository uses: actions/checkout@v4 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - - name: Extract branch name - run: echo "BRANCH=${GITHUB_REF##*/}" >> $GITHUB_ENV + #- name: Extract branch name + # run: echo "BRANCH=${GITHUB_REF##*/}" >> $GITHUB_ENV + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/underworldcode/underworld3 + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} - name: Login to GHCR uses: docker/login-action@v3 @@ -54,8 +70,14 @@ jobs: context: . push: true file: container/Containerfile - platforms: linux/amd64 + platforms: linux/amd64,linux/arm64 no-cache: ${{ inputs.force_rebuild || false }} - tags: | - ghcr.io/underworldcode/underworld3:${{ env.BRANCH }} - ghcr.io/underworldcode/underworld3:latest + build-args: | + SETUPTOOLS_SCM_PRETEND_VERSION=${{ steps.meta.outputs.version }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + # tags: | + # ghcr.io/underworldcode/underworld3:${{ env.BRANCH }} + # ghcr.io/underworldcode/underworld3:latest diff --git a/.github/workflows/draft-pdf.yaml b/.github/workflows/draft-pdf.yaml index dc0164501..020a9234d 100644 --- a/.github/workflows/draft-pdf.yaml +++ b/.github/workflows/draft-pdf.yaml @@ -2,8 +2,8 @@ name: Draft PDF (JOSS) on: push: paths: - - docs/joss-paper/paper.md - - docs/joss-paper/paper.bib + - publications/joss-paper/paper.md + - publications/joss-paper/paper.bib # - assets/img1.png # - assets/img2.png - .github/workflows/draft-pdf.yml @@ -20,7 +20,7 @@ jobs: with: journal: joss # This should be the path to the paper within your repo. - paper-path: docs/joss-paper/paper.md + paper-path: publications/joss-paper/paper.md - name: Upload uses: actions/upload-artifact@v4 with: @@ -28,4 +28,4 @@ jobs: # This is the output path where Pandoc will write the compiled # PDF. Note, this should be the same directory as the input # paper.md. In the case we have (symlink), the paper is in the subdir - path: docs/joss-paper/paper.pdf + path: publications/joss-paper/paper.pdf diff --git a/.github/workflows/hpc-base-images.yml b/.github/workflows/hpc-base-images.yml new file mode 100644 index 000000000..bc83c477d --- /dev/null +++ b/.github/workflows/hpc-base-images.yml @@ -0,0 +1,112 @@ +name: "GHCR: HPC platform base images" + +# Builds docs/developer/hpc_containers/base.* — one image per platform family, +# published as :runtime and :builder tags. Everything OS/MPI-specific lives in +# these; hpc-petsc-image.yml and hpc-uw3-image.yml stack on them. +# +# Changes rarely. Both downstream workflows pull the tags by name, so after a +# base change rebuild PETSc (hpc-petsc-image.yml), then UW3. + +on: + push: + branches: + - main + - development + paths: + - 'docs/developer/hpc_containers/base.*' + - '.github/workflows/hpc-base-images.yml' + workflow_dispatch: + inputs: + force_rebuild: + description: 'Force full rebuild (no cache)' + type: boolean + default: false + +concurrency: + group: hpc-base-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + base: [rocky-ompi, ubuntu-mpich] + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Resolve image name + id: meta + run: | + OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') + echo "image=ghcr.io/${OWNER}/uw3-base-${{ matrix.base }}" >> "$GITHUB_OUTPUT" + + # Two targets of one file, pushed as two tags. Docker v2 media types and + # no attestations, for the same Singularity reasons as hpc-uw3-image.yml. + - name: Build and push :runtime + uses: docker/build-push-action@v6 + with: + context: . + file: docs/developer/hpc_containers/base.${{ matrix.base }} + target: runtime + outputs: type=image,push=true,oci-mediatypes=false + platforms: linux/amd64 + provenance: false + sbom: false + no-cache: ${{ inputs.force_rebuild || false }} + build-args: PYTHON_VERSION=3.12 + tags: ${{ steps.meta.outputs.image }}:runtime + + - name: Build and push :builder + uses: docker/build-push-action@v6 + with: + context: . + file: docs/developer/hpc_containers/base.${{ matrix.base }} + target: builder + outputs: type=image,push=true,oci-mediatypes=false + platforms: linux/amd64 + provenance: false + sbom: false + no-cache: ${{ inputs.force_rebuild || false }} + build-args: PYTHON_VERSION=3.12 + tags: ${{ steps.meta.outputs.image }}:builder + + # The contract the recipes rely on: mpicc and a venv python on PATH. + # command -v, not which: Rocky's minimal image has no `which`. + - name: Smoke test + run: | + docker run --rm ${{ steps.meta.outputs.image }}:builder bash -c ' + set -e + for t in mpicc g++ gfortran cmake flex bison patch git wget; do command -v $t >/dev/null || { echo "missing $t"; exit 1; }; done + mpicc --version | head -1 + python -c "import sys; assert sys.prefix == \"/opt/venv\", sys.prefix; print(sys.version)"' + docker run --rm ${{ steps.meta.outputs.image }}:runtime bash -c ' + set -e + for t in mpiexec python g++; do command -v $t >/dev/null || { echo "missing $t"; exit 1; }; done + id jovyan' + + - name: Delete old untagged image versions + continue-on-error: true + uses: actions/delete-package-versions@v5 + with: + owner: ${{ github.repository_owner }} + package-name: uw3-base-${{ matrix.base }} + package-type: container + min-versions-to-keep: 2 + delete-only-untagged-versions: true diff --git a/.github/workflows/hpc-petsc-image.yml b/.github/workflows/hpc-petsc-image.yml new file mode 100644 index 000000000..eb4e50ab1 --- /dev/null +++ b/.github/workflows/hpc-petsc-image.yml @@ -0,0 +1,164 @@ +name: "GHCR: HPC PETSc images" + +# Builds docs/developer/hpc_containers/petsc.hpc once per platform base — the +# from-source PETSc that hpc-uw3-image.yml layers on. +# +# Triggers are deliberately narrow: this compiles PETSc with mumps, hypre, +# superlu_dist, mmg/parmmg, ptscotch and slepc (~25 min each), and only +# petsc.hpc, its patches and the bases can change the result. + +on: + push: + branches: + - main + - development + paths: + - 'docs/developer/hpc_containers/petsc.hpc' + - 'petsc-custom/patches/**' + - '.github/workflows/hpc-petsc-image.yml' + workflow_dispatch: + inputs: + petsc_version: + description: 'PETSc version to build' + type: string + default: '3.25.0' + make_np: + description: 'PETSc build parallelism (--with-make-np)' + type: string + default: '4' + force_rebuild: + description: 'Force full rebuild (no cache)' + type: boolean + default: false + +concurrency: + group: hpc-petsc-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + # Measured at ~25 min with ~25% run-to-run variance; ~5x headroom. + timeout-minutes: 120 + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - platform: gadi + base: rocky-ompi + mpi: ompi + mpi_name: "Open MPI" + - platform: setonix + base: ubuntu-mpich + mpi: mpich + mpi_name: "MPICH" + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Report disk headroom + run: df -h / + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Resolve names and tags + id: meta + run: | + set -euo pipefail + OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') + IMAGE="ghcr.io/${OWNER}/petsc-${{ matrix.platform }}" + BASE="ghcr.io/${OWNER}/uw3-base-${{ matrix.base }}" + + PETSC_VERSION="${{ inputs.petsc_version }}" + PETSC_VERSION="${PETSC_VERSION:-3.25.0}" + MAKE_NP="${{ inputs.make_np }}" + MAKE_NP="${MAKE_NP:-4}" + + { + echo "image=${IMAGE}:${PETSC_VERSION}-${{ matrix.mpi }}" + echo "tags=${IMAGE}:${PETSC_VERSION}-${{ matrix.mpi }},${IMAGE}:latest" + echo "base=${BASE}" + echo "petsc_version=${PETSC_VERSION}" + echo "make_np=${MAKE_NP}" + } >> "$GITHUB_OUTPUT" + echo "Building ${IMAGE}:${PETSC_VERSION}-${{ matrix.mpi }} on ${BASE} with --with-make-np=${MAKE_NP}" + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: docs/developer/hpc_containers/petsc.hpc + # Docker v2 media types, no attestations — see hpc-uw3-image.yml. + outputs: type=image,push=true,oci-mediatypes=false + platforms: linux/amd64 + provenance: false + sbom: false + no-cache: ${{ inputs.force_rebuild || false }} + build-args: | + BASE_BUILDER=${{ steps.meta.outputs.base }}:builder + BASE_RUNTIME=${{ steps.meta.outputs.base }}:runtime + PETSC_VERSION=${{ steps.meta.outputs.petsc_version }} + PETSC_MAKE_NP=${{ steps.meta.outputs.make_np }} + tags: ${{ steps.meta.outputs.tags }} + + - name: Report build size + run: | + docker image ls ${{ steps.meta.outputs.image }} || true + df -h / + + - name: Pull image + run: docker pull ${{ steps.meta.outputs.image }} + + # petsc4py importing is what actually breaks when the build goes subtly + # wrong (shared libraries, PYTHONPATH, the meson petsc4py step). NumPy is + # checked too: petsc4py is compiled against it (see petsc.hpc). + - name: Import petsc4py and slepc4py; check NumPy range + run: | + docker run --rm ${{ steps.meta.outputs.image }} python -c " + import numpy, petsc4py, slepc4py + petsc4py.init() + from petsc4py import PETSc + v = numpy.lib.NumpyVersion(numpy.__version__) + assert v >= '2.0.0' and v < '2.5.0', numpy.__version__ + print('numpy', numpy.__version__) + print('petsc4py', petsc4py.__version__) + print('slepc4py', slepc4py.__version__) + print('PETSc', PETSc.Sys.getVersion()) + " + + # Assert the MPI flavour: a silently wrong libmpi is exactly the failure + # that would otherwise only surface on a compute node. + - name: MPI sanity (2 ranks, expect ${{ matrix.mpi_name }}) + run: | + docker run --rm ${{ steps.meta.outputs.image }} \ + mpiexec -n 2 python -c " + from mpi4py import MPI + c = MPI.COMM_WORLD + v = MPI.Get_library_version().splitlines()[0] + print(f'rank {c.rank} of {c.size}: {v}') + assert c.size == 2 + assert '${{ matrix.mpi_name }}' in v, v + " + + # Housekeeping only; the action 404s partway through on user-owned + # packages, having already deleted what it could. + - name: Delete old untagged image versions + continue-on-error: true + uses: actions/delete-package-versions@v5 + with: + owner: ${{ github.repository_owner }} + package-name: petsc-${{ matrix.platform }} + package-type: container + min-versions-to-keep: 2 + delete-only-untagged-versions: true diff --git a/.github/workflows/hpc-uw3-image.yml b/.github/workflows/hpc-uw3-image.yml new file mode 100644 index 000000000..fbac9eef0 --- /dev/null +++ b/.github/workflows/hpc-uw3-image.yml @@ -0,0 +1,240 @@ +name: "GHCR: HPC Underworld3 images" + +# Builds docs/developer/hpc_containers/underworld3.hpc once per platform and +# pushes to GHCR for `singularity pull docker://...` on Gadi, Kaiju and Setonix. +# +# This is the layer that moves; the expensive PETSc images under it are built +# rarely by hpc-petsc-image.yml, and the platform bases rarer still by +# hpc-base-images.yml. Unlike docker-image.yaml and binder-image.yml these +# images match each HPC system's OS and MPI ABI. + +on: + push: + branches: + - main + - development + tags: ['v*'] + paths: + - 'docs/developer/hpc_containers/underworld3.hpc' + - '.github/workflows/hpc-uw3-image.yml' + - 'pixi.toml' + - 'pyproject.toml' + - 'setup.py' + - 'src/**' + # Releases are the guaranteed path for a tagged image: the `tags:` filter above + # is combined with `paths:`, so a tag whose diff touches nothing in that list + # would not build on its own. + release: + types: [published] + workflow_dispatch: + inputs: + uw3_branch: + description: 'UW3 branch/tag to clone and build (e.g. development, v3.1.0). Empty = triggering ref.' + type: string + default: '' + petsc_image: + description: 'PETSc base image override. Applies to EVERY platform in the matrix, so only useful with a single-platform matrix edit. Empty = per-platform default.' + type: string + default: '' + image_tag: + description: 'Image tag override (e.g. ci-test). Empty = derive from ref.' + type: string + default: '' + force_rebuild: + description: 'Force full rebuild (no cache)' + type: boolean + default: false + +concurrency: + group: hpc-uw3-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 90 + permissions: + contents: read + packages: write + strategy: + fail-fast: false + matrix: + include: + - platform: gadi + base: rocky-ompi + mpi: ompi + mpi_name: "Open MPI" + petsc_var: GADI_PETSC_IMAGE + - platform: setonix + base: ubuntu-mpich + mpi: mpich + mpi_name: "MPICH" + petsc_var: SETONIX_PETSC_IMAGE + env: + # Repository variables that let a fork reuse a PETSc image it already + # published under another name. Indexed by matrix.petsc_var below. + GADI_PETSC_IMAGE: ${{ vars.GADI_PETSC_IMAGE }} + SETONIX_PETSC_IMAGE: ${{ vars.SETONIX_PETSC_IMAGE }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Resolve names and tags + id: meta + run: | + set -euo pipefail + + # Image names must be lowercase; repository_owner may not be. + OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') + IMAGE="ghcr.io/${OWNER}/underworld3-${{ matrix.platform }}" + BASE="ghcr.io/${OWNER}/uw3-base-${{ matrix.base }}" + + # Same ref-name / IS_RELEASE convention as binder-image.yml. + if [[ "$GITHUB_REF" == refs/tags/* ]]; then + REF_NAME=${GITHUB_REF#refs/tags/} + IS_RELEASE=true + else + REF_NAME=${GITHUB_REF#refs/heads/} + IS_RELEASE=false + fi + + if [[ -n "${{ inputs.uw3_branch }}" ]]; then + REF_NAME="${{ inputs.uw3_branch }}" + [[ "$REF_NAME" == v* ]] && IS_RELEASE=true + fi + if [[ -n "${{ inputs.image_tag }}" ]]; then + DOCKER_TAG="${{ inputs.image_tag }}" + else + # Docker tags cannot contain '/' + DOCKER_TAG=$(echo "$REF_NAME" | tr '/' '-') + fi + + # Dispatch input wins, then the per-platform repo variable, then the + # canonical name. + VAR_NAME="${{ matrix.petsc_var }}" + PETSC_IMAGE="${{ inputs.petsc_image }}" + PETSC_IMAGE="${PETSC_IMAGE:-${!VAR_NAME}}" + PETSC_IMAGE="${PETSC_IMAGE:-ghcr.io/${OWNER}/petsc-${{ matrix.platform }}:3.25.0-${{ matrix.mpi }}}" + + TAGS="${IMAGE}:${DOCKER_TAG}" + if [[ "$IS_RELEASE" == "true" ]]; then + TAGS="${TAGS},${IMAGE}:latest" + fi + + # A release tag is the version; anything else gets a PEP440 dev + # version carrying the commit, since branch names are not versions. + if [[ "$IS_RELEASE" == "true" ]]; then + UW3_VERSION="${REF_NAME#v}" + else + UW3_VERSION="0.0.0.dev0+g${GITHUB_SHA:0:7}" + fi + + { + echo "image=${IMAGE}:${DOCKER_TAG}" + echo "tags=${TAGS}" + echo "base=${BASE}" + echo "uw3_branch=${REF_NAME}" + echo "petsc_image=${PETSC_IMAGE}" + echo "uw3_version=${UW3_VERSION}" + } >> "$GITHUB_OUTPUT" + + echo "Building ${IMAGE}:${DOCKER_TAG}" + echo " UW3 ref: ${REF_NAME}" + echo " PETSc base: ${PETSC_IMAGE}" + echo " builder: ${BASE}:builder" + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: docs/developer/hpc_containers/underworld3.hpc + # Docker v2 media types rather than buildx's OCI default, matching the + # podman `--format docker` images already validated on Gadi. Older + # Singularity builds read OCI patchily; this removes the variable. + outputs: type=image,push=true,oci-mediatypes=false + # All three systems are x86_64. No arm64: it would need QEMU for no + # benefit, and the gmsh / vtk-osmesa wheels are x86_64-only anyway. + platforms: linux/amd64 + # Attestations turn the result into a multi-manifest index containing an + # "unknown/unknown" entry, which some Singularity/Apptainer versions + # refuse to pull. Keep these off — see README "Automated builds". + provenance: false + sbom: false + no-cache: ${{ inputs.force_rebuild || false }} + build-args: | + UW3_BRANCH=${{ steps.meta.outputs.uw3_branch }} + UW3_REPO=${{ github.server_url }}/${{ github.repository }}.git + PETSC_IMAGE=${{ steps.meta.outputs.petsc_image }} + BASE_BUILDER=${{ steps.meta.outputs.base }}:builder + BASE_RUNTIME=${{ steps.meta.outputs.base }}:runtime + SETUPTOOLS_SCM_PRETEND_VERSION=${{ steps.meta.outputs.uw3_version }} + tags: ${{ steps.meta.outputs.tags }} + # Cache lives in its own package: sharing one with the image put stale + # cache manifests into the same untagged pool the cleanup step prunes, + # so they competed with old images for min-versions-to-keep. + cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/underworld3-${{ matrix.platform }}-buildcache:latest + cache-to: type=registry,ref=ghcr.io/${{ github.repository_owner }}/underworld3-${{ matrix.platform }}-buildcache:latest,mode=max + + - name: Pull image + run: docker pull ${{ steps.meta.outputs.image }} + + # A bare import is the cheapest check and catches a missing runtime dep + # (this is exactly how the absent `requests` once showed up). + - name: Import underworld3 + run: | + docker run --rm ${{ steps.meta.outputs.image }} \ + python -c "import underworld3; print('underworld3', underworld3.__version__)" + + # The recipe installs gmsh and vtk-osmesa behind `|| echo skip`, so a + # failed install is silent. Import them explicitly or it ships broken. + # NumPy is checked too: the compiled extensions depend on its range. + - name: Import optional stack (gmsh, vtk, pyvista, h5py, petsc4py); check NumPy range + run: | + docker run --rm ${{ steps.meta.outputs.image }} python -c " + import gmsh, vtk, pyvista, h5py, petsc4py, numpy + v = numpy.lib.NumpyVersion(numpy.__version__) + assert v >= '2.0.0' and v < '2.5.0', numpy.__version__ + print('optional stack OK, numpy', numpy.__version__) + " + + # Assert the MPI flavour too: a silently wrong libmpi is exactly the + # failure that would otherwise only surface on a compute node. + - name: MPI sanity (2 ranks, expect ${{ matrix.mpi_name }}) + run: | + docker run --rm ${{ steps.meta.outputs.image }} \ + mpiexec -n 2 python -c " + import underworld3 as uw + from mpi4py import MPI + c = MPI.COMM_WORLD + v = MPI.Get_library_version().splitlines()[0] + assert c.size == 2 + assert '${{ matrix.mpi_name }}' in v, v + print(f'rank {c.rank} of {c.size}, underworld3 {uw.__version__}, {v}') + " + + # Only prune once the image is known good — a failed step above stops + # the job before this runs, so a broken build never deletes the last + # working one. Housekeeping only: a 404 partway through must not fail + # a run whose image is good. + - name: Delete old untagged image versions + continue-on-error: true + uses: actions/delete-package-versions@v5 + with: + owner: ${{ github.repository_owner }} + package-name: underworld3-${{ matrix.platform }} + package-type: container + min-versions-to-keep: 3 + # Load-bearing: every TAGGED image (:latest, :v3.1.0, :main, and the + # current :development) is exempt regardless of age. Only superseded + # rolling builds are eligible. + delete-only-untagged-versions: true diff --git a/.github/workflows/style-gates.yml b/.github/workflows/style-gates.yml new file mode 100644 index 000000000..2f577d0d3 --- /dev/null +++ b/.github/workflows/style-gates.yml @@ -0,0 +1,53 @@ +name: Style Gates + +# Machine-enforced subset of docs/developer/UW3_STYLE_CHARTER.md (S3, S4, S7). +# Fast (<1 min): no PETSc build, no package install — the scanner is stdlib-only. +# +# The gate fails on deprecated patterns NOT recorded in +# scripts/deprecated_pattern_allowlist.txt. That allowlist records pre-existing +# legacy hits and may only SHRINK: fix the code your PR adds; only a maintainer +# adds allowlist entries. + +on: + pull_request: + branches: + - development + - main + types: [opened, synchronize, reopened] + +jobs: + deprecated-patterns: + name: Deprecated-pattern scan (Charter S3/S4/S7) + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Scan src/ for Charter-banned patterns + run: | + echo "Running the deprecated-pattern scanner (stdlib only)..." + if ! python scripts/check_deprecated_patterns.py; then + echo "" + echo "FAILED: your change introduces a pattern banned by the UW3 Style Charter." + echo "" + echo " Charter: docs/developer/UW3_STYLE_CHARTER.md (S3 naming, S4 comments, S7 data access)" + echo " Guide: docs/developer/guides/style-gates.md" + echo " Run local: pixi run -e amr-dev python scripts/check_deprecated_patterns.py" + echo "" + echo "Fix the flagged code. The allowlist" + echo "(scripts/deprecated_pattern_allowlist.txt) records pre-existing legacy" + echo "hits only and may only SHRINK — only a maintainer adds entries." + exit 1 + fi + + - name: Report deprecated patterns in docs/ (informational) + run: | + # Never fails: docs cleanup is tracked separately. This keeps the + # inventory visible in the job log without blocking PRs. + python scripts/check_deprecated_patterns.py --include-docs || true diff --git a/.gitignore b/.gitignore index 7e9621773..cd3992068 100644 --- a/.gitignore +++ b/.gitignore @@ -204,12 +204,21 @@ Jupyterbook/Notebooks/.DS_Store Jupyterbook/Notebooks/Examples-NavierStokes/order_1/NS_test_Re_250_0.06_step_0.png Documentation/index.aux -# Claude Code files (track commands, ignore settings) +# Claude Code files (track commands and skills, ignore settings) CLAUDE.md .claude/* !.claude/commands/ .claude/commands/* !.claude/commands/*.md +# Ship skills with the repo: allow SKILL.md + supporting assets, ignore junk +!.claude/skills/ +.claude/skills/**/* +!.claude/skills/**/ +!.claude/skills/**/*.md +!.claude/skills/**/*.py +!.claude/skills/**/*.typ +!.claude/skills/**/*.json +!.claude/skills/**/*.png # Prevent future markdown clutter in root (allow essential docs only) /*_ANALYSIS.md @@ -257,3 +266,8 @@ docs/beginner/tutorials/html5/*.html petsc-custom/petsc petsc-custom/.petsc-version Untitled*.ipynb + +# Simulation output dumped at repo root (belongs in ~/+Simulations) +Expt_Grains/ +*.passive_swarm.*.h5 +*.passive_swarm.*.xdmf diff --git a/CLAUDE.md b/CLAUDE.md index 73b2ec4b6..b49f666e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,529 +1,197 @@ # Underworld3 AI Assistant Context -> **Note**: Human-readable developer documentation is in `docs/developer/` (Sphinx/MyST format). -> For development history and completed migrations, see `docs/developer/ai-notes/historical-notes.md` +> **MANDATORY**: Read `docs/developer/UW3_STYLE_CHARTER.md` before writing any code. +> It is the normative style contract for every session, it is two pages, and it WINS +> over the surrounding code and over any other style document on conflict. +> Core clause: match the Charter, not the code next door — and flag deviations you find. ---- - -## Extended AI Context (Optional) +This file is a router, not a manual. It carries what a session needs *before* it +knows where to look; everything else is one hop away through the authority map in +`docs/developer/index.md`. -You can configure additional AI instruction files that live outside the Underworld repository. This is useful for: -- Personal coding style preferences -- Project planning and coordination systems -- Private instructions not shared with other contributors - -**Setup**: If the environment variable `UW_AI_TOOLS_PATH` is set (colon-separated directories, like `PATH`), check each directory for `.md` files containing additional context. Configure this via `./uw setup` or manually in your shell profile. +--- -**At conversation start**: If `UW_AI_TOOLS_PATH` is set, look for `underworld.md` (or other relevant files) in those directories. If found, report relevant Active/Bugs items briefly. If the variable is unset or files aren't found, proceed normally with repo-local context only. +## Session bootstrap -### Responding to External Planning Items +**External planning.** If `UW_AI_TOOLS_PATH` is set (colon-separated directories), +check each for `.md` files — `underworld.md` especially — and report relevant +Active/Bugs items briefly. If it is unset, proceed with repo-local context. Set it +via `./uw setup`. -When completing a task from an external planning file, add an annotation directly below the item: +When you complete a task from an external planning file, annotate it in place: ```markdown ``` -### Adding New Items to External Planning - -If you discover bugs, identify new tasks, or have items that should be tracked in the external planning file: -- **Don't create local TODO files or add to CLAUDE.md** -- **Do add to the external planning file** under the appropriate section (Bugs, Active, Nice to Have) -- Use the project tag: `` - -### Inline TODO Comments in Code - -Use inline `TODO` comments to mark **problem locations** in the source code. These provide self-documentation and help future developers (human or AI) find the relevant code quickly. - -**Format:** -```python -# TODO(BUG): Brief description of the issue -# More context if needed -# See planning file: underworld.md (section, date) -``` +Add newly discovered work to the external planning file under the appropriate +section with a `` tag — not to a local TODO +file, and not here. Don't rewrite strategic paragraphs, move items between +sections, or restructure the document; those carry cross-project context and are +handled by the planning tools. -**When to use:** -- Mark the exact location of a known bug -- Flag code that needs enhancement or refactoring -- Note incomplete implementations +**Inline TODOs** mark the *place*; the planning file tracks the *work*: -**Example:** ```python # TODO(BUG): add_natural_bc() causes PETSc error 73 # The Stokes solver works; issue is specific to scalar Poisson setup. # See planning file: underworld.md (Bugs section, 2026-01-19) -self.natural_bcs = [] ``` -This complements the planning file — the planning file tracks *what* needs doing, inline TODOs mark *where* in the code. - -### What Not to Do - -- Don't rewrite strategic paragraphs — they contain cross-project context -- Don't move items between sections (Active → Done) — planning-claude handles that -- Don't restructure the document - -If something needs more than an annotation or simple addition, mention it in conversation for the user to handle. - --- -## Documentation Requests - -**⚠️ MANDATORY - READ BEFORE WRITING ANY DOCUMENTATION ⚠️** - -- **ALL documentation MUST go in `docs/` directory** - NO exceptions -- **NEVER create .md files in the repository root, src/, tests/, or anywhere else** -- **NEVER create planning/design documents outside `docs/developer/design/`** -- If you're tempted to create a file like `SOME-FEATURE-NOTES.md` in the repo root - **DON'T**. Put it in `docs/developer/` instead. -- This applies to: design docs, how-to guides, technical notes, implementation plans, reviews, audits - EVERYTHING goes in `docs/` - -**Where to put documentation:** - -| Content Type | Location | -|--------------|----------| -| System documentation (meshing, solvers, swarms) | `docs/developer/subsystems/` | -| Architecture and design decisions | `docs/developer/design/` | -| How-to guides and best practices | `docs/developer/guides/` | -| User tutorials | `docs/beginner/tutorials/` | -| Advanced user guides | `docs/advanced/` | - -**Format** - Use MyST Markdown (`.md` files) compatible with Sphinx: -- Standard markdown with MyST extensions -- Use ```` ```python ```` for code blocks (not `{python}`) -- Use `{note}`, `{warning}`, `{tip}` for admonitions -- Math: `$inline$` and `$$display$$` - -**Style** - Concise, helpful, standalone: -- Self-contained explanations (don't assume reader has context) -- Include practical code examples -- Link to related documentation where appropriate -- Focus on "why" and "how to use", not just "what" -- Follow the notebook style guide for tutorials - -**Integration** - Link into the documentation system: -- Add to appropriate toctree in parent `index.md` -- Cross-reference related docs with `:doc:` or relative links -- Build and verify: `pixi run docs-build` - -**Style references**: -- Notebook writing: `docs/developer/guides/notebook-style-guide.md` -- Code patterns: `docs/developer/UW3_Style_and_Patterns_Guide.md` - ---- - -## Git and Branching Strategy - -**Full guide**: `docs/developer/guides/branching-strategy.md` - -### Branch Roles -- **`main`** — stable releases (tagged quarterly). No direct pushes. -- **`development`** — integration branch. Bug fixes land here. Features merge here via PR. -- **`feature/*`** — long-lived feature work. Branch from and PR back to `development`. - -### Key Discipline: Separate API from Implementation -Feature branches must not introduce API changes (new methods, changed signatures) that other branches can't access. When a feature needs an API change: -1. Extract the interface (stub or minimal implementation) into a separate commit. -2. Merge that to `development` first (or extract after the fact). -3. The feature PR should only contain implementation behind already-merged interfaces. +## Hard constraints -This keeps feature branches independent and makes cross-pollination of fixes straightforward. +**Rebuild after every source change.** `./uw build`. Underworld3 is installed into +the environment, not imported from `src/`; verify with `uw.__file__`. -### Bug Fix Flow -- Fix on `development` (commit or small PR) -- Cherry-pick to `main` if critical → tag patch release -- Cherry-pick to active feature branches (underworld-claude handles this) +**Never use an editable install.** `pip install -e .` contaminates every pixi +environment sharing the source tree and persists after uninstall. No exceptions. -### Git Worktrees for Session Isolation -**Use a worktree for any multi-file change** (docs cleanup, refactoring, features). -Multiple Claude sessions sharing one working directory will overwrite each other's work. +**Never move `/Users/lmoresi/+Underworld/underworld-pixi-2/petsc/`.** PETSc +hardcodes paths at configure time; moving it costs a full rebuild. -Each worktree gets its **own pixi environment** (isolated site-packages, own -compiled extensions). Only PETSc is shared via symlink (non-relocatable, -expensive to rebuild). `./uw build` from inside a worktree installs that -worktree's source into the worktree's own environment. +**Worktrees are never on `development` or `main`.** All work happens on a side +branch (`feature/…`, `bugfix/…`, `docs/…`) and merges via PR. Use a worktree for +any multi-file change — concurrent sessions sharing one checkout overwrite each +other. Each worktree builds into its own environment, so enter it *first*, then +build. -**Full documentation**: `docs/developer/guides/branching-strategy.md` (Git Worktrees section) +Detail: [`guides/development-setup.md`](docs/developer/guides/development-setup.md), +[`guides/branching-strategy.md`](docs/developer/guides/branching-strategy.md). -#### Worktree branch policy - -**Worktrees must NEVER be on `development` or `main` directly.** - -In this repo, `main` is the *release* branch (tagged quarterly, essentially -read-only history) and `development` is the integration trunk where active -work converges. The default repository checkout (`~/+Underworld/underworld3-pixi`) -should usually sit on `development` — that's where you read the current -working state and pull updates. All work — even work intended to land -on `development` — happens on a side branch (`feature/...`, `bugfix/...`, -`docs/...`) in a worktree, then merges to `development` via PR. - -`./uw worktree create` enforces this for new worktrees (always on a -side branch, reset to `origin/development`). It's on you not to break -it manually: - -- Never `git checkout development` (or `main`) inside a worktree -- Never `git worktree add ... development` to put a worktree on - `development` directly -- If you find a worktree on `development` (e.g. from older tooling), - branch off immediately (`git switch -c bugfix/whatever`) before - committing - -#### Creating and using a worktree - -```bash -# Create — own .pixi env, shared PETSc, names the branch -./uw worktree create # → feature/ -./uw worktree create bugfix # → bugfix/ - -# Work — drops you into a shell cd'd to the worktree -./uw worktree shell -./uw build # builds from THIS source into THIS worktree's env -./uw test # runs tests -exit # leave - -# List worktrees with branch and status -./uw worktree list - -# Bring files from other branches without switching: -git checkout origin/ -- path/to/file -``` - -#### Cleanup - -```bash -# Removes worktree directory and deletes the branch -./uw worktree remove -``` - -#### Important: always build and run from inside the worktree - -Each worktree has its own pixi environment. `./uw build` installs into the -environment of whichever worktree (or main repo) you run it from. Always: - -1. `./uw worktree shell ` (or `cd` into the worktree) -2. `./uw build` -3. Run your code / tests from there - -### AI-Assisted Attribution (Commits and PRs) -When committing code or creating pull requests with AI assistance, end the -message/body with: +**Attribution.** End commit messages and PR bodies with: ``` -Underworld development team with AI support from [Claude Code](https://claude.com/claude-code) -``` - -(In commit messages, use the plain-text form without the markdown link.) - -**Do NOT use**: -- `Co-Authored-By:` with a noreply email (useless for soliciting responses) -- Generic AI attribution without team context -- Emoji in PR descriptions - ---- - -## CRITICAL BUILD CONSTRAINTS - -### PETSc Directory (DO NOT MOVE) -**WARNING**: `/Users/lmoresi/+Underworld/underworld-pixi-2/petsc/` MUST NOT be moved. -- PETSc is NOT relocatable after compilation (hardcoded paths) -- Moving breaks petsc4py bindings and all pixi tasks -- Requires complete rebuild (~1 hour) if relocated - -### Rebuild After Source Changes -**After modifying source files, always run `./uw build`!** -- Underworld3 is installed as a package in the pixi environment -- Changes go to `.pixi/envs//lib/python3.12/site-packages/underworld3/` -- Verify with `uw.__file__` (should show site-packages path, NOT `src/`) - -**Note**: `./uw build` uses `--no-cache-dir` to prevent pip from reusing stale -wheels (UW3 is always version `0.0.0`). If you still suspect stale code, clean -the build directory: `rm -rf build/lib.* build/bdist.*` then rebuild. - -### NEVER Use Editable Installs -**DO NOT use `pip install -e .` (editable/development mode)!** -This is a hard rule — there are no exceptions. - -Editable installs create `.pth` files and `.so` symlinks in the source tree that: -- **Contaminate all pixi environments** sharing the same source directory -- **Break worktree isolation** (worktrees share pixi envs via symlinks) -- **Persist after uninstall** — stale `.pth` files redirect Python imports to `src/` - even after a proper `./uw build`, causing import errors or wrong library loading -- **Mix debug/release builds** — `.so` compiled against one PETSc arch get loaded - by environments expecting another, causing `dlopen` symbol errors - -Always use `./uw build` which runs `pip install .` (non-editable). If `./uw build` -is not available, use `pixi run -e pip install . --no-build-isolation --no-cache-dir`. - -**Recovery from editable install contamination:** -```bash -# Remove stale .pth files from ALL environments -find .pixi/envs -name "__editable__*underworld*" -delete -# Remove .so from source tree (they belong in site-packages) -find src/underworld3 -name "*.so" -delete -# Clean build cache -rm -rf build/ -# Rebuild properly -./uw build +Underworld development team with AI support from Claude Code ``` -### Test Quality Principles -**New tests must be validated before making code changes to fix them!** -- Validate test correctness before changing main code -- If core tests (0000-0599) pass, the system is working correctly -- Disable problematic new tests, validate core functionality, then fix test structure - -### JOSS Paper (FROZEN) -**Location**: `publications/joss-paper/` - Publication of record, DO NOT modify. - ---- - -## Units System Principles - -**Authoritative design doc**: `docs/developer/design/UNITS_SIMPLIFIED_DESIGN_2025-11.md` +(PR bodies may link it.) Do not use `Co-Authored-By:` with a noreply address, and +no emoji in PR descriptions. -- Accept strings for convenience, store/return Pint objects: `uw.quantity(1e21, "Pa*s")` -- `.units` returns a Pint **Unit** (not string) — call `.to("m")` on the **Quantity**, not on `.units` -- UWexpression derives `.units` from contents (atomic: stored value; composite: `get_units(self._sym)`) +**Solver stability is paramount.** The PETSc-based solvers in +`petsc_generic_snes_solvers` are carefully tuned and validated. No changes +without extensive benchmarking. ---- - -## Parallel Computing Patterns - -PETSc handles all parallel synchronization — avoid direct mpi4py unless necessary. -Use `uw.pprint()` and `uw.selective_ranks()` for rank-safe output and code blocks. - -**Implementation**: `src/underworld3/mpi.py` -**Documentation**: `docs/advanced/parallel-computing.md` +**The JOSS paper (`publications/joss-paper/`) is frozen.** It is the publication of +record; do not modify it. --- -## Architecture Priorities +## Where things go -### Solver Stability is Paramount -The PETSc-based solvers are carefully optimized and validated. **NO CHANGES without extensive benchmarking.** - -### Module Boundaries +| Content | Location | +|---|---| +| Subsystem documentation | `docs/developer/subsystems/` | +| Architecture and design decisions | `docs/developer/design/` | +| How-to guides | `docs/developer/guides/` | +| User tutorials | `docs/beginner/tutorials/` | +| Advanced user guides | `docs/advanced/` | +| Session scripts, benchmarks, profilers | `scripts/sessions/` | +| Simulation output worth keeping | `~/+Simulations/` | -| Module | Purpose | Access Pattern | -|--------|---------|----------------| -| **Solvers** (`petsc_generic_snes_solvers`) | High-performance PETSc solving | Direct `vec` property | -| **Mesh Variables** | User-facing field data | `array` property (new) | -| **Swarm Variables** | Particle data with mesh proxies | `data` property | +**All documentation goes under `docs/`** — never the repository root, `src/`, or +`tests/`. MyST Markdown for Sphinx: ` ```python ` blocks, `{note}`/`{warning}` +admonitions, `$inline$` and `$$display$$` math. Verify with `pixi run docs-build`. -### Conservative Migration Strategy -- **User-facing code**: Use `array` property with automatic sync -- **Solver internals**: Keep using `vec` property with direct PETSc access -- **Gradual transition**: Only change when driven by actual needs +Plan files in `~/.claude/plans/` take descriptive kebab-case names that say what +they are about (`mesh-adaptation-architecture.md`), never whimsical ones. --- -## Data Access Patterns +## Rulings a session needs in hand -**Authoritative Reference**: `docs/developer/UW3_Style_and_Patterns_Guide.md` -**Pattern Checker**: Use `/check-patterns` to scan for deprecated patterns +**Free-slip: prefer rotated strong free-slip.** +`solver.add_rotated_freeslip_bc(conds, boundary, normal=None)`, value first +(`conds=0` is free-slip). It enforces `v·n̂ = 0` to machine precision where +Nitsche/penalty leaks ~1e-3, is correct on curved and deformed boundaries, works +inside the nonlinear SNES and with geometric FMG, and its reaction is the boundary +normal traction. Leave `normal=None` unless the constraint must follow the true +surface rather than the mesh. Reserve `add_nitsche_bc` for BCs that must *evolve in +time* — a hard rotated constraint cannot morph. +Governing doc: [`subsystems/rotated-freeslip.md`](docs/developer/subsystems/rotated-freeslip.md). -### Quick Summary -| Pattern | Status | Use Instead | -|---------|--------|-------------| -| `with mesh.access(var):` | **Deprecated** | Direct: `var.data[...]` | -| `with swarm.access(var):` | **Deprecated** | Direct: `var.data[...]` | -| `mesh.data` (coordinates) | **Deprecated** | `mesh.X.coords` | +**Data access.** New code uses `.array` with three-index shapes — scalars +`(N,1,1)`, vectors `(N,1,dim)`, tensors `(N,dim,dim)` — and `mesh.X.coords` for +coordinates. `with mesh.access(...)`, `with swarm.access(...)`, `mesh.data` and +`mesh.points` exist only so old code keeps running. The flat `.data` has exactly +one sanctioned use: a raw variable-to-variable copy inside the +non-dimensionalisation boundary. Solver internals use `vec`. +Governing doc: [`subsystems/data-access.md`](docs/developer/subsystems/data-access.md). -See `docs/developer/UW3_Style_and_Patterns_Guide.md` and `docs/developer/subsystems/data-access.md` for full patterns, array shapes, and cache safety details. - ---- - -## Expression Processing - -### Unwrap Before Extracting Atoms -When extracting `.atoms()` or `.free_symbols` from expressions before compilation: +**Unwrap before extracting atoms.** UWexpressions hide coordinates, so order +matters: ```python -# CORRECT ORDER: -# 1. First unwrap UWexpressions to reveal hidden coordinates -if any_uwexpressions_in_expression: - expr = _unwrap_for_compilation(expr, keep_constants=False, return_self=False) -# 2. Then extract atoms/symbols from the FULLY PROCESSED expression -symbols = expr.atoms(...) +expr = _unwrap_for_compilation(expr, keep_constants=False, return_self=False) +symbols = expr.atoms(...) # only now ``` -**Safe locations**: JIT Compiler (`utilities/_jitextension.py`), `extract_expressions()` -**Check if issues**: `is_pure_sympy_expression()` in `function/pure_sympy_evaluator.py`, `utilities/nondimensional.py` +**Never name a variable `model`.** Use `constitutive_model` (material behaviour) or +`orchestration_model` / `uw.get_default_model()` (serialization). Two unrelated +concepts share the word. ---- - -## Swarm Concepts +**Units.** Accept strings, store and return Pint objects: `uw.quantity(1e21, "Pa*s")`. +`.units` returns a Pint *Unit*; call `.to("m")` on the Quantity, not on `.units`. +Governing doc: [`design/UNITS_SIMPLIFIED_DESIGN_2025-11.md`](docs/developer/design/UNITS_SIMPLIFIED_DESIGN_2025-11.md). -### Migration -Migration moves particles between processors based on spatial location. -- Happens automatically when particles move -- Use `migration_disabled()` context for batch operations -- Essential for parallel correctness +**Parallelism.** PETSc handles synchronisation — avoid direct mpi4py. Use +`uw.pprint()` and `uw.selective_ranks()`; a user should never see an MPI call. -### Proxy Mesh Variables -Swarm variables with `proxy_degree > 0` create proxy mesh variables using RBF interpolation. -- Used for integration and derivative calculations -- Must be updated when swarm data/positions change -- Update happens automatically via `swarmVar._update()` - ---- +**Swarms.** Migration moves particles between processors automatically; batch with +`migration_disabled()`. Swarm variables with `proxy_degree > 0` carry a proxy mesh +variable that must be refreshed (`swarmVar._update()`) when data or positions move. -## Mathematical Objects - -Variables support natural mathematical syntax: - -```python -# Direct arithmetic (no .sym needed) -momentum = density * velocity -strain_rate = velocity[0].diff(x) + velocity[1].diff(y) - -# Full SymPy Matrix API available -velocity.T # Transpose -velocity.dot(other) # Dot product -velocity.norm() # Magnitude -``` - -**Implementation**: `MathematicalMixin` in `utilities/mathematical_mixin.py` - ---- +**Prefer Glob and Grep over `find`/`grep` in Bash** — safer, faster, no approval +prompt. -## Coding Conventions +**Desktop notification** from a background monitor — be quiet unless something +needs attention: -### Prefer Glob and Grep Over find -**Use Glob and Grep tools instead of `find` or `grep` in Bash.** They are safer (no `-exec`), faster, and don't require user approval. Only fall back to `find` via Bash if Glob/Grep genuinely cannot express the query. - -### Desktop Notifications for Background Monitoring -When using CronCreate for background monitoring (CI status, issues, etc.), use -platform-appropriate notification commands. Both are in the allowed tools list: -- **macOS**: `osascript -e 'display notification "message" with title "title" sound name "Glass"'` -- **Linux**: `notify-send "title" "message"` - -Be quiet when everything is fine — only notify when something needs attention. - -### Plan File Naming Policy -**Plan files must have descriptive names that indicate their content.** - -``` -# GOOD - Descriptive names -mesh-adaptation-architecture.md -gradient-evaluation-p2-fix.md -units-system-refactor-plan.md - -# BAD - Random/whimsical names -proud-petting-pretzel.md -happy-dancing-dolphin.md -``` - -When creating plan files in `~/.claude/plans/`, use kebab-case names that describe: -- The feature or subsystem being worked on -- The type of work (architecture, fix, refactor, feature) - -### Avoid Ambiguous 'model' -Two different "model" concepts exist: -- `uw.Model`: Serialization/orchestration system -- Constitutive models: Material behavior (ViscousFlowModel, etc.) - -```python -# GOOD - Clear and unambiguous -constitutive_model = stokes.constitutive_model -orchestration_model = uw.get_default_model() - -# AVOID - Ambiguous -model = stokes.constitutive_model +```bash +osascript -e 'display notification "msg" with title "title" sound name "Glass"' # macOS +notify-send "title" "msg" # Linux ``` --- -## Test Classification +## Tests -### By Complexity Level (pytest markers) -- `@pytest.mark.level_1`: Quick core tests (seconds) -- `@pytest.mark.level_2`: Intermediate tests (minutes) -- `@pytest.mark.level_3`: Physics/solver tests (minutes to hours) - -### By Reliability Tier -- `@pytest.mark.tier_a`: Production-ready (TDD-safe) -- `@pytest.mark.tier_b`: Validated (use with caution) -- `@pytest.mark.tier_c`: Experimental (development only) +Files are `tests/test_NNNN_description.py` and carry both a level +(`level_1`/`level_2`/`level_3`) and a tier (`tier_a`/`tier_b`/`tier_c`). ```bash -# Quick validation -pytest -m "level_1 and tier_a" - -# Full validation -pytest -m "tier_a or tier_b" +pytest -m "level_1 and tier_a" # quick +pytest -m "tier_a or tier_b" # full validation ``` -**Details**: `docs/developer/TESTING-RELIABILITY-SYSTEM.md` - ---- - -## On-Demand Documentation References - -When working on specific subsystems, these documents provide detailed guidance. -**Read them on demand using the Read tool** — do NOT load them all at conversation start. - -> **AI Assistant Protocol**: When you need deeper context, explicitly tell the user -> what you're reading and why. Use the Read tool to load the specific file. -> Example: "Let me check the units design doc for this..." - -### Units & Scaling -- `docs/developer/design/UNITS_SIMPLIFIED_DESIGN_2025-11.md` - **Authoritative** units architecture -- `docs/developer/ai-notes/COORDINATE-UNITS-TECHNICAL-NOTE.md` - Coordinate unit handling -- `docs/developer/design/WHY_UNITS_NOT_DIMENSIONALITY.md` - Design rationale - -### Testing -- `docs/developer/TESTING-RELIABILITY-SYSTEM.md` - Test tier classification (A/B/C) -- `docs/developer/ai-notes/TEST-CLASSIFICATION-2025-11-15.md` - Current test status - -### Code Style, Workflow & Patterns -- `docs/developer/guides/branching-strategy.md` - Branching, releases, API change discipline -- `docs/developer/UW3_Style_and_Patterns_Guide.md` - Development standards - -### Data Access & Variables -- `docs/developer/subsystems/data-access.md` - Data access patterns, self-validating cache -- `docs/developer/UW3_Developers_NDArrays.md` - NDArray_With_Callback internals +Tier A is hardened and reviewed — safe to build code around. Tier C is not mature +enough to drive coding. Every bug fix ships the regression test that would have +caught it, written first and shown to fail. Validate a new test's own correctness +before changing library code to satisfy it. -### Architecture & Design -- `docs/developer/design/ARCHITECTURE_ANALYSIS.md` - System structure analysis -- `docs/developer/design/MATHEMATICAL_MIXIN_DESIGN.md` - Mathematical objects internals -- `docs/developer/design/GEOGRAPHIC_COORDINATE_SYSTEM_DESIGN.md` - Spherical/planetary meshes -- `docs/developer/design/SYMBOL_DISAMBIGUATION_2025-12.md` - Multi-mesh symbol identity -- `docs/developer/TEMPLATE_EXPRESSION_PATTERN.md` - Solver template expressions - -### Coordinates & Mesh -- `docs/developer/design/COORDINATE_MIGRATION_GUIDE.md` - Coordinate system changes -- `docs/developer/design/mesh-geometry-audit.md` - Mesh geometry patterns - -### Development History -- `docs/developer/ai-notes/historical-notes.md` - Completed migrations, fixed bugs +Every test file must be reachable from a glob in `scripts/test.sh`; +`scripts/check_test_coverage.py` enforces it. --- -## Quick Reference +## Finding the rest -### Build & Test Commands -```bash -./uw build # Rebuild after source changes (preferred) -./uw test # Run test suite -pixi run -e default python # Run Python in environment -``` +`docs/developer/index.md` carries the **authority map** — one governing document +per topic. Read it rather than guessing which of several documents on a topic is +current. Development history and completed migrations are in +`docs/developer/ai-notes/historical-notes.md`. -### Key Files -- `src/underworld3/mpi.py` - Parallel safety implementation -- `src/underworld3/scaling/` - Units system -- `src/underworld3/utilities/mathematical_mixin.py` - Mathematical objects -- `src/underworld3/function/expressions.py` - UWexpression (lazy evaluation, symbol disambiguation) -- `src/underworld3/function/_function.pyx` - UnderworldFunction (mesh variable symbols) -- `src/underworld3/discretisation/enhanced_variables.py` - EnhancedMeshVariable (units, math ops, persistence) -- `src/underworld3/discretisation/persistence.py` - Stub for future persistence features - -### Historical Notes -For development history, completed migrations, and fixed bugs: -See `docs/developer/ai-notes/historical-notes.md` - ---- +Key implementation files: -*Reorganized 2025-12-13: Historical content moved to docs/developer/ai-notes/historical-notes.md* +| | | +|---|---| +| `src/underworld3/mpi.py` | parallel-safe output | +| `src/underworld3/scaling/` | units system | +| `src/underworld3/function/expressions.py` | UWexpression, lazy evaluation | +| `src/underworld3/function/_function.pyx` | mesh-variable symbols | +| `src/underworld3/utilities/mathematical_mixin.py` | mathematical objects | +| `src/underworld3/utilities/rotated_bc.py` | rotated free-slip | +| `src/underworld3/discretisation/enhanced_variables.py` | units, math ops, persistence | diff --git a/container/Containerfile b/container/Containerfile index e4233f8e7..e1e2cf993 100644 --- a/container/Containerfile +++ b/container/Containerfile @@ -1,59 +1,139 @@ # syntax=docker/dockerfile:1.7-labs - -### how to build Underworld3 image -### MUST run from the underworld3 top directory -# podman build . --rm \ -# --format docker \ -# -f ./container/Containerfile \ -# -t underworldcode/underworld3: # -### requires the '--format docker' to run on podman, but no under docker. +# Lean two-stage command-line container for Underworld3. +# Uses pixi (same package manager as the Binder image) to resolve packages +# from pixi.lock, eliminating PETSc version drift between container types. +# Drops vtk-osmesa (amd64-only wheel); uses conda-forge vtk + Mesa apt packages +# for headless rendering on both amd64 and arm64. +# +# Build (from underworld3 root): +# podman build . --format docker \ +# --build-arg SETUPTOOLS_SCM_PRETEND_VERSION=3.1.0 \ +# -f container/Containerfile \ +# -t underworldcode/underworld3:latest # +# Run: +# podman run --rm -p 8888:8888 \ +# -v "${HOME}/uw_space":/home/jovyan/host \ +# underworldcode/underworld3:latest # -### Example usage (taken from - https://jupyter-docker-stacks.readthedocs.io/en/latest/using/running.html#using-the-podman-cli ) -# uid=57439 -# gid=57439 +# MPI parallel (terminal tab inside the running container): +# mpirun -n 4 python my_script.py -# subuidSize=$(( $(podman info --format "{{ range .Host.IDMappings.UIDMap }}+{{.Size }}{{end }}" ) - 1 )) -# subgidSize=$(( $(podman info --format "{{ range .Host.IDMappings.GIDMap }}+{{.Size }}{{end }}" ) - 1 )) -# podman run -it --rm -p 10000:8888 -v "${HOME}/uw_space":/home/mambauser/host --uidmap $uid:0:1 --uidmap 0:1:$uid --uidmap $(($uid+1)):$(($uid+1)):$(($subuidSize-$uid)) --gidmap $gid:0:1 --gidmap 0:1:$gid --gidmap $(($gid+1)):$(($gid+1)):$(($subgidSize-$gid)) underworldcode/underworld3: +# ============================================================ +# Stage 1: builder — install pixi runtime env, compile UW3 +# ============================================================ +FROM ubuntu:24.04 AS builder -FROM docker.io/mambaorg/micromamba:2.3.0 +ENV DEBIAN_FRONTEND=noninteractive -# install os requirements for headless pyvista -USER root RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Ubuntu 24.04 ships 'ubuntu' at UID 1000 — remove it before creating jovyan +RUN userdel -r ubuntu 2>/dev/null || true && \ + useradd -m -s /bin/bash -u 1000 jovyan + +USER jovyan +WORKDIR /home/jovyan/underworld3 + +# Install pixi (pinned to match the lock file format) +RUN curl -fsSL https://pixi.sh/install.sh | PIXI_VERSION=0.63.2 sh +ENV PATH="/home/jovyan/.pixi/bin:$PATH" + +# Install the pixi runtime environment (resolved from pixi.lock — no solver run) +COPY --chown=jovyan:jovyan pixi.toml pixi.lock ./ +RUN pixi install -e runtime + +# Copy source, then compile and install UW3 into the runtime env. +# SETUPTOOLS_SCM_PRETEND_VERSION sets the version string (fixes 0.0.0+unknown +# on builds without a .git checkout). +ARG SETUPTOOLS_SCM_PRETEND_VERSION=0.0.0+unknown +COPY --chown=jovyan:jovyan \ + --exclude=**/.git \ + --exclude=**/docs_legacy \ + --exclude=**/__pycache__ \ + . . +RUN SETUPTOOLS_SCM_PRETEND_VERSION=${SETUPTOOLS_SCM_PRETEND_VERSION} \ + pixi run -e runtime python -m pip install --no-build-isolation --no-cache-dir . + +# Register a named Jupyter kernel so users see "Underworld3" in the kernel picker +RUN pixi run -e runtime python -m ipykernel install \ + --user --name=uw3 --display-name="Underworld3" + +# Clean up to reduce layer size +RUN pixi clean cache --yes 2>/dev/null || true && \ + find /home/jovyan -name "*.pyc" -delete 2>/dev/null || true && \ + find /home/jovyan -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true && \ + rm -rf /home/jovyan/underworld3/.pixi/envs/runtime/conda-meta && \ + rm -rf /home/jovyan/underworld3/.pixi/envs/runtime/man && \ + rm -rf /home/jovyan/.cache/pip + +# ============================================================ +# Stage 2: runtime — lean image with Mesa apt deps + pixi env +# ============================================================ +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive + +# Runtime deps for headless pyvista/vtk (same as Dockerfile.base.optimized) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libgl1 \ + libglu1-mesa \ + libosmesa6 \ + libglx-mesa0 \ + libxft2 \ + libxinerama1 \ + libxcursor1 \ + libxrender1 \ + libxext6 \ + libglib2.0-0 \ + libfreetype6 \ xvfb \ - libgl1-mesa-dev \ && rm -rf /var/lib/apt/lists/* -USER $MAMBA_USER -# create the conda environment with all dependencies -# see https://micromamba-docker.readthedocs.io/en/latest/quick_start.html#quick-start -COPY --chown=$MAMBA_USER:$MAMBA_USER environment.yaml /tmp/env.yaml -RUN micromamba install -y -v -n base -f /tmp/env.yaml && \ - micromamba clean --all --yes +RUN userdel -r ubuntu 2>/dev/null || true && \ + useradd -m -s /bin/bash -u 1000 jovyan -# activate mamba env during podman build -ARG MAMBA_DOCKERFILE_ACTIVATE=1 +# Copy the compiled pixi runtime environment (includes UW3 in site-packages) +COPY --from=builder --chown=jovyan:jovyan \ + /home/jovyan/underworld3/.pixi/envs/runtime \ + /home/jovyan/underworld3/.pixi/envs/runtime -# pyvista - taken from pyvista docker files -# see https://github.com/pyvista/pyvista/tree/main/docker -RUN pip install --no-cache-dir --extra-index-url https://wheels.vtk.org vtk-osmesa +# Copy Jupyter kernel config — gives users a named "Underworld3" kernel +COPY --from=builder --chown=jovyan:jovyan \ + /home/jovyan/.local \ + /home/jovyan/.local -# install UW3 -COPY --chown=$MAMBA_USER:$MAMBA_USER \ - --exclude=**/.git \ - . /home/$MAMBA_USER/underworld3 -WORKDIR /home/$MAMBA_USER/underworld3 -RUN pip install --no-build-isolation --no-cache-dir . +# Copy full documentation, source, and beginner tutorials shortcut +COPY --from=builder --chown=jovyan:jovyan \ + /home/jovyan/underworld3/docs \ + /home/jovyan/underworld3/docs -# allow jupyterlab for ipyvtk -ENV PYVISTA_OFF_SCREEN=true -ENV JUPYTER_ENABLE_LAB=yes -ENV PYVISTA_TRAME_SERVER_PROXY_PREFIX='/proxy/' +COPY --from=builder --chown=jovyan:jovyan \ + /home/jovyan/underworld3/src \ + /home/jovyan/underworld3/src -WORKDIR /home/$MAMBA_USER/ +# Tutorials shortcut at ~/Tutorials — same layout as the Binder image +COPY --from=builder --chown=jovyan:jovyan \ + /home/jovyan/underworld3/docs/beginner/tutorials \ + /home/jovyan/Tutorials + +USER jovyan +WORKDIR /home/jovyan/underworld3 + +# Use the runtime env's bin directly — no pixi overhead at runtime +ENV PATH="/home/jovyan/underworld3/.pixi/envs/runtime/bin:$PATH" + +# Headless rendering (same settings as Binder) +ENV PYVISTA_OFF_SCREEN=true +ENV LIBGL_ALWAYS_SOFTWARE=1 +ENV MESA_GL_VERSION_OVERRIDE=3.3 +ENV GALLIUM_DRIVER=llvmpipe +ENV VTK_DEFAULT_OPENGL_WINDOW=vtkOSOpenGLRenderWindow +ENV DISPLAY=:99 EXPOSE 8888 -CMD ["jupyter-lab", "--no-browser", "--ip=0.0.0.0"] +CMD ["jupyter", "lab", "--ip=0.0.0.0", "--no-browser"] diff --git a/docs/advanced/crossing-fault-zones.md b/docs/advanced/crossing-fault-zones.md new file mode 100644 index 000000000..497cf0d55 --- /dev/null +++ b/docs/advanced/crossing-fault-zones.md @@ -0,0 +1,346 @@ +# Fault zones that cross + +Two fault zones that cross are the geometry the finite-width +representation exists for. Thickening each trace and letting the CAD +kernel fuse the results turns the junction into ordinary cells of a +single region: nothing downstream is told which branch a cell came from, +and no junction geometry is ever constructed. The zero-thickness +representation cannot take this route at all — the split refuses +overlapping cuts. + +That ignorance is deliberate, and it has a price. The rheology at the +overlap comes entirely from the `Surface` lookup, which gives every point +the **director of the nearest surface**. Where the two zones cross, the +material is weak in two directions and a single director represents one +of them. This page works out when that matters. + +Scripts: `~/+Simulations/ribbon_network_2d/`. + +## Building the crossing + +One call places the whole network. The two ribbons may overlap — that is +the point — and `assembly="fuse"` returns their union as one region with +no internal seam: + +```python +from underworld3.utilities.place_surface import place_thin_volume + +dm, info = place_thin_volume(mesh0.dm, [trace_a, trace_b], width=0.02, + label="Zone", label_value=31, + size=0.005, assembly="fuse") +``` + +Two details in that call decide whether the experiment can measure +anything. + +**`size` must be passed.** It defaults to `0.9 * width`, which puts about +two elements across the band at every width. Two elements cannot resolve +which half of an overlap a rule owns, and the ownership split is the +whole question. `width/4` is used throughout here. + +**`fragment` is the wrong assembly for a crossing.** It keeps each +overlap piece as its own region, so the mesh conforms to the boundaries +of the overlap; where two zones converge at a shallow angle the overlap +is a spike, and its fragmented tip meshes to arbitrarily bad angles. The +fused union has no such tip. + +## What the overlap should be + +Before solving anything, the question has a closed-form answer in 2-D, +and the closed form says where to look. + +In plane strain with an incompressible medium the deviatoric strain-rate +space is **two-dimensional**, spanned by + +$$ +\mathbf{e}_1 = \tfrac{1}{\sqrt 2}\begin{pmatrix} 1 & 0\\ 0 & -1\end{pmatrix}, +\qquad +\mathbf{e}_2 = \tfrac{1}{\sqrt 2}\begin{pmatrix} 0 & 1\\ 1 & 0\end{pmatrix}, +$$ + +and rotating the material by $\theta$ rotates a state in that space by +$2\theta$. A weak plane with normal at $\phi$ is compliant in exactly one +mode — on-plane shear — so it occupies a single **line** in that space, +at angle $2\phi + 90^\circ$. + +Everything follows from the doubling. Two planes separated by $\Delta$ in +the rock are separated by $2\Delta$ there, so: + +- $\Delta = 0^\circ$ and $\Delta = 90^\circ$ put both compliances on the + **same line**. Shear on a plane with normal $\hat{\mathbf x}$ *is* + shear on a plane with normal $\hat{\mathbf y}$ — the same $\tau_{xy}$. + A 90-degree X is degenerate in 2-D. +- $\Delta = 45^\circ$ makes the two compliances **orthogonal**. Together + they span the whole deviatoric space, and the overlap is isotropic. + +So the geometry the phrase "high angle" suggests — the square X — is the +one geometry where the question cannot be asked. The tensors to compare +differ most at 45 degrees. + +```{warning} +**This degeneracy is two-dimensional and does not carry to 3-D.** It +exists because the deviatoric space here has only two dimensions, so two +lines in it must either coincide or be distinct in one way. In 3-D that +space has five dimensions, two weak planes at any angle occupy different +subspaces, and there is no null. Do not carry "a 90-degree crossing is +safe" into a 3-D model — the 3-D question is open and nothing on this +page answers it. +``` + +### The orthotropic tensor is a TI tensor + +Summing the two planes' compliances and inverting gives a tensor with two +eigenvalues and one principal direction in the deviatoric plane, which is +precisely the structure of +{py:class}`~underworld3.constitutive_models.TransverseIsotropicFlowModel`. +So the orthotropic treatment needs no new constitutive machinery in 2-D: +read off an effective director and effective $\eta_0$, $\eta_1$ and use +the shipped model. + +```python +S = np.eye(2) / (2 * eta_0) +s = 1 / (2 * eta_1) - 1 / (2 * eta_0) +for phi, lam in zip(normal_angles, weights): + u = weak_mode(phi) # unit vector at 2*phi + 90 + S = S + lam * s * np.outer(u, u) +C = np.linalg.inv(S) +eta_1_eff, eta_0_eff = sorted(np.linalg.eigvalsh(C) / 2) +``` + +### Two candidate rules, not one + +The weights are a modelling choice and the two ends of the family behave +differently: + +| $\Delta$ | compliance **sum** ($\lambda = 1$ each) | compliance **average** ($\lambda = \tfrac12$ each) | +|---|---|---| +| $0^\circ$ | anisotropy 199 — twice as weak as one plane | exactly the single plane | +| $45^\circ$ | isotropic, $\eta_1$ | isotropic, $2\eta_1$ | +| $90^\circ$ | anisotropy 199 | exactly the single plane | + +The sum is what "add the compliances" means literally: two independent +fabrics superposed. Read it at $\Delta = 0$ — two *coincident* weak +planes come out twice as weak as one — and note that the excess does not +switch off away from coincidence. The average treats the overlap as a +50/50 mixture of the two fabrics and degenerates to the single plane +exactly. Both are carried below; neither is obviously right. + +```{important} +There is **no external reference solution** here, and there cannot be one +without modelling the fabric itself. Both constructions are assumptions, +so what is measured below is the distance from the shipped rule to a +*choice*, not to a truth. What makes that worth reading is that the two +choices differ by a factor of two in how weak they make the overlap and +still bracket the answer tightly — 61.8% against 70.0% at the worst +angle, 92.3% against 93.0% at $45^\circ$. The conclusion is insensitive +to the part that had to be assumed. +``` + +## The experiment + +Two straight ribbons crossing at the centre of a unit box, fused into one +zone, driven in horizontal extension with a free top. The crossing angle +$\Delta$ is swept, and five rheologies are solved **on the same mesh**: + +| case | the zone | the overlap | +|---|---|---| +| `none` | uniform $\eta_0$ | — (the no-fault control) | +| `isotropic` | weak in every mode | weak in every mode | +| `nearest` | TI, nearest trace's director | TI, nearest trace's director | +| `ortho_sum` | TI, nearest trace's director | the compliance-sum tensor | +| `ortho_avg` | TI, nearest trace's director | the compliance-average tensor | + +The last three are identical everywhere except the overlap cells, so any +difference between them is the overlap rheology and nothing else. +`isotropic` weakens the arms too, so it is not a candidate rule — only a +bracket on how compliant the network could be. + +The metric is the **dissipation weakening** + +$$ +W = 1 - \frac{D}{D_{\rm no\ fault}}, +$$ + +referenced to the `none` control on the same mesh at the same angle. It +is a single number with no probe geometry in it. (The control dissipates +exactly 4.000000 in this box, which is the analytic value, so the +integral is checked as well.) + +Note what $W$ is made of at a shallow crossing: at $\Delta = 10^\circ$ +the two dissipations are 3.9521 and 3.9225 against a control of 4.0000, +so the whole signal is a difference of about 0.03 between numbers that +agree to 1%. The exact control and the resolution study below are what +make that difference readable; a single run at one resolution would not +be. + +### What comes out + +| $\Delta$ | $W$ nearest | $W$ orthotropic | $W$ isotropic | nearest, as % of orthotropic | +|---|---|---|---|---| +| $10^\circ$ | 1.198% | 1.710% | 3.628% | **70.0%** | +| $20^\circ$ | 3.687% | 4.520% | 6.256% | 81.6% | +| $30^\circ$ | 6.618% | 7.679% | 9.240% | 86.2% | +| $45^\circ$ | 11.566% | 12.435% | 13.646% | 93.0% | +| $60^\circ$ | 15.789% | 16.253% | 17.110% | 97.1% | +| $75^\circ$ | 18.322% | 18.476% | 19.121% | 99.2% | +| $90^\circ$ | 18.918% | 18.918% | 19.465% | **100.0%** | + +```{figure} figures/crossing_weakening.png +:width: 100% + +Left: what the two faults are worth, against the no-fault control on the +same mesh. Right: the overlap rule on its own — the two runs share the +mesh and the arms exactly, so the ratio is the overlap and nothing else. +``` + +Read the last row first. At $\Delta = 90^\circ$ the two dissipations +agree to seven significant figures (3.243286 against 3.243286). That is +not a tuned result: the algebra says the orthotropic overlap tensor there +is *identically* the single plane's, and a 2-D director is only defined +mod $90^\circ$, so the nearest-fault rule assigns that same tensor on +both sides of the medial axis. The experiment reproduces it exactly, and +that is what licenses the other six rows. + +Now read up the column. The nearest-fault rule loses a substantial part +of the network's weakening at a shallow crossing, and the error closes +monotonically as the crossing squares up. The percentages in that table +are computed at a zone mesh of $w/4$ and are **bounds**, not estimates — +refining the zone mesh moves the rule closer to the orthotropic one, and +the converged values are about 76% at $10^\circ$ and 95% at $45^\circ$ +(see the resolution control below). + +```{figure} figures/crossing_map.png +:width: 100% + +The same three answers in physical space at $\Delta = 45^\circ$, on one +colour scale. They are indistinguishable by eye, and the region in +dispute is 34 cells out of 1480 (right). A difference of this kind is +only visible in an integral — which is why the metric above is a +dissipation and not a picture. +``` + +### The error is largest where the issue expected none + +The per-cell tensors differ most at $\Delta = 45^\circ$, and the measured +curve shows no feature there at all. Both facts are right, and the +resolution is geometric: the overlap **area** goes as $w^2/\sin\Delta$. +A shallow crossing has a long lens of shared material, a square one has +almost none — 141 overlap cells at $10^\circ$ against 24 at $90^\circ$. +The tensor error peaks at $45^\circ$, the area falls monotonically, and +the product is dominated by the area. + +So the geometry to worry about is the **shallow** crossing, not the +high-angle one — the opposite of what "high angle" suggests, and the +opposite of the case a designer would think to check. + +## Measuring the slip is harder than it looks + +Slip is read as the tangential velocity jump between probes either side +of the band. A **fixed** standoff — some fraction of the width — is wrong +at a crossing: near a shallow one, a probe placed at $0.75\,w$ from one +trace sits *inside the other ribbon*, in weak material, and reports +almost no jump. At $\Delta = 10^\circ$ that read 0.006 against a peak of +0.18 on the same fault. + +The probes have to be walked clear of **every** ribbon before they are +read. The jump then means the same thing at every angle: the velocity +difference across the whole weak region. + +Even walked out, the local probe **cannot** be trusted at the crossing +itself: that is exactly where the standoff changes discontinuously, and +the reading there dips sharply — at $\Delta = 90^\circ$ it goes negative +once the no-fault baseline is subtracted, which is not a physical slip. +This is the reason the metric above is a dissipation integral. Use the +slip probe for the arms and for pictures, not for the number that decides +the question. + +Two further cautions on reading absolute slip here: + +- The pair is symmetric about the loading axes, so changing $\Delta$ + necessarily changes how well each plane is oriented for slip. At + $\Delta = 10^\circ$ both planes are nearly vertical — nearly principal + planes — and barely slip at all. Compare ratios at fixed angle, not + slip across angles. +- The no-fault control read through the same probes gives the wall rock's + own contribution over the probe separation. Subtract it. + +## How much of this is the mesh + +The overlap is a few tens of cells, so the numbers above are worth +nothing without a resolution control. At $\Delta = 45^\circ$: + +| host cell | zone $h$ | zone cells | overlap | $W$ nearest | $W$ orthotropic | ratio | +|---|---|---|---|---|---|---| +| 0.030 | 0.0100 | 380 | 12 | 10.9522% | 12.4148% | 88.2% | +| 0.030 | 0.0050 | 1480 | 34 | 11.5662% | 12.4351% | 93.0% | +| 0.030 | 0.0025 | 5342 | 130 | 11.7717% | 12.4565% | 94.5% | +| 0.040 | 0.0050 | 1480 | 34 | 11.5593% | 12.4278% | 93.0% | +| 0.020 | 0.0050 | 1480 | 34 | 11.5714% | 12.4404% | 93.0% | + +The **host** mesh does not enter: 0.04, 0.03 and 0.02 give 93.0% three +times over, with $W$ agreeing to four significant figures. The embedded +zone mesh is identical in all three, which is the embedding working as +designed and worth having as a measurement rather than a claim. + +The **zone** mesh does. Halving it twice moves the ratio 88.2 → 93.0 → +94.5%, and the increments fall by about a factor of three, so the +converged value here is near 95%. + +The same check at the worst angle, $\Delta = 10^\circ$, where the overlap +is largest and the resolution matters most: + +| host cell | zone $h$ | zone cells | overlap | $W$ nearest | $W$ orthotropic | ratio | +|---|---|---|---|---|---|---| +| 0.030 | 0.0050 | 1374 | 141 | 1.1980% | 1.7103% | 70.0% | +| 0.030 | 0.0025 | 4910 | 522 | 1.2731% | 1.7252% | 73.8% | +| 0.030 | 0.0013 | 19562 | 2051 | 1.3030% | 1.7361% | 75.1% | + +Increments of +3.8 then +1.3, the same threefold contraction, giving a +converged value near 76%. + +Refinement always moves the nearest-fault rule *closer* to the +orthotropic one, so the sweep table earlier on this page **overstates the +error** everywhere. The corrected statement is that the rule loses about +a quarter of the network's weakening at the worst crossing angle, not a +third — which changes the size of the effect and none of its structure. + +## `clearance` is not monotone + +The carve deletes host vertices near the assembly's skin, and whether +what is left is one simple hole is a topological accident of the host +mesh against a plus-shaped cavity. It does not improve monotonically with +`clearance`: a 30-degree crossing builds only at `clearance=1.3`, a +45-degree crossing at 1.0 but *not* at 1.3. Walk a ladder and record +which value was used. + +```python +for clearance in (1.0, 0.9, 1.1, 0.8, 1.2, 0.7, 1.3): + try: + dm, info = place_thin_volume(..., clearance=clearance) + except RuntimeError: + continue + break +``` + +## Postscript: this test was too kind + +Everything above is measured in pure extension with the fault pair +symmetric about the load — a drive with **no rotation**. Repeating the +question in a shear box with an asymmetric branching geometry (spin in +the kinematics, nothing mirror-symmetric) inverts the verdict: the +difference between director rules stays immaterial, but the *entire +TI-arms family* loses factors — not percent — against the isotropic +reference wherever the network must hand slip between differently +oriented members. The overlap tensor was never the dominant term; the +arms' constraint is. See +[Branching Faults and Junctions](fault-branching-junctions.md), which +supersedes the recommendation implied here for any network that +redistributes slip. + +## See also + +- [Fault networks](fault-networks.md) — the zero-thickness toolkit, and + the offset-junction preparation the ribbon does not need. +- [Split-node faults](split-node-faults.md) — the contact representation. +- [Transverse isotropy](vep-transverse-isotropy-faults.md) — the weak-plane + rheology and its parameters. diff --git a/docs/advanced/curved-boundary-conditions.md b/docs/advanced/curved-boundary-conditions.md index 9e36615eb..ebdbebe10 100644 --- a/docs/advanced/curved-boundary-conditions.md +++ b/docs/advanced/curved-boundary-conditions.md @@ -41,7 +41,7 @@ Nitsche's method provides a variationally consistent alternative to penalty that is insensitive to the penalty magnitude and gives optimal convergence: ```python -stokes.add_nitsche_bc("Upper", gamma=10) +stokes.add_nitsche_bc(0.0, "Upper", gamma=10) ``` The method automatically constructs penalty, consistency (stress flux), @@ -51,13 +51,13 @@ resolution or viscosity. **Prescribed normal velocity:** ```python -stokes.add_nitsche_bc("Inlet", g=1.0, gamma=10) +stokes.add_nitsche_bc(1.0, "Inlet", gamma=10) ``` **Custom constraint direction** (e.g., fault normal different from surface normal): ```python fault_normal = sympy.Matrix([0.6, 0.8]) -stokes.add_nitsche_bc("Fault", direction=fault_normal, gamma=10) +stokes.add_nitsche_bc(0.0, "Fault", direction=fault_normal, gamma=10) ``` **When to use:** @@ -71,16 +71,31 @@ stokes.add_nitsche_bc("Fault", direction=fault_normal, gamma=10) at 1e4 gives 0.15%). -### 2. Penalty Free-Slip (Simple but Fragile) - -Use the mesh-derived normals directly with a penalty parameter: +### 2. Penalty Free-Slip (Simple, and Only With the Node Normal) ```python -Gamma = mesh.Gamma +n = mesh.boundary_normal("Boundary") # measure-weighted node normal penalty = 10000 -stokes.add_natural_bc(penalty * Gamma.dot(v.sym) * Gamma, "Boundary") +stokes.add_natural_bc(penalty * n.dot(v.sym) * n, "Boundary") ``` +**Use `mesh.boundary_normal(boundary)`, not `mesh.Gamma`.** A penalty written +against the per-facet normal asks a node shared by two facets to satisfy two +different constraints, which on a two-component velocity leaves nothing: push +the coefficient up and the boundary freezes. Measured on an annulus with an +exact solution (Kramer et al. 2021), coefficient `1e6`, cell 0.15 → 0.035: + +| normal | leak `u·n` | velocity error | surface stress error | +|---|---|---|---| +| `mesh.Gamma` (facet) | 1e-5 | 0.60, flat under refinement | 0.21 → 0.26, growing | +| `mesh.boundary_normal` (node) | 3e-5 | 1.0e-2 → 4.9e-4 | 2.4e-2 → 1.4e-3 | + +The facet-normal row does not converge, and the leak cannot see it: at `1e3` that +penalty leaks 3e-2 and gets the surface stress right to 2e-3, while at `1e8` it +leaks 1e-7 and is 26% wrong. This is the classical over-constraint that the +*consistent* normal was introduced to avoid (Engelman, Sani & Gresho 1982), and +`mesh.boundary_normal` is that normal. + **When to use:** - Quick prototyping where high accuracy isn't critical - When Nitsche is not yet available for your solver type @@ -88,12 +103,16 @@ stokes.add_natural_bc(penalty * Gamma.dot(v.sym) * Gamma, "Boundary") **Limitations:** - Penalty must be tuned: too small → loose constraint, too large → ill-conditioning - On spherical shells, penalty can become unstable at moderate resolution -- ~25-30% error on elliptical boundaries when using raw facet normals +- Check the answer, not just the leak: a constraint that is satisfied is not + evidence that the solution is right -### 3. Projected Normals (For Curved Boundaries with Penalty) +### 3. Projected Normals (Superseded by `mesh.boundary_normal`) -Project `mesh.Gamma` onto a continuous mesh variable, which interpolates and smooths the normals: +`mesh.boundary_normal(boundary)` assembles the measure-weighted node normal +directly, tracks mesh deformation, and is correct in parallel, so the recipe +below is kept for reference rather than recommended. Project `mesh.Gamma` onto a +continuous mesh variable, which interpolates and smooths the normals: ```python import sympy @@ -300,7 +319,7 @@ in 3D spherical models where penalty sensitivity becomes problematic. ## Tips for Success -1. **Start with Nitsche**: `stokes.add_nitsche_bc("Upper", gamma=10)` — no penalty tuning needed +1. **Start with Nitsche**: `stokes.add_nitsche_bc(0.0, "Upper", gamma=10)` — no penalty tuning needed 2. **For penalty BCs, always normalize**: Analytical formulas need explicit normalization 3. **Check orientation**: Ensure normals point outward (use `sign(r.dot(normal))`) 4. **Verify visually**: Plot the normal field to catch errors diff --git a/docs/advanced/eulerian-advection-diffusion.md b/docs/advanced/eulerian-advection-diffusion.md new file mode 100644 index 000000000..2b7a1bafc --- /dev/null +++ b/docs/advanced/eulerian-advection-diffusion.md @@ -0,0 +1,153 @@ +# Advection-diffusion composed from a transport manager (Eulerian SUPG by default) + +`uw.systems.AdvDiffusion` is the general scalar transport solver. It assembles the +diffusive flux and the source itself and takes the transport from the history manager +it holds (`DuDt`); with the default manager, `uw.systems.ddt.EulerianSUPG`, it is the +Eulerian SUPG scheme this page describes, a drop-in for the semi-Lagrangian solver +`uw.systems.AdvDiffusionSLCN`. Both solve + +$$ +\frac{\partial \phi}{\partial t} + \mathbf{u}\cdot\nabla\phi + - \nabla\cdot(\kappa\nabla\phi) = f , +$$ + +but assembles every term on the mesh, implicit in time, with streamline-upwind +(SUPG) stabilisation. There is no trace-back and no departure point. The two +classes share their interface, so switching is one line: + +```python +adv = uw.systems.AdvDiffusion(mesh, T, v.sym, order=1) # was AdvDiffusionSLCN +adv.constitutive_model = uw.constitutive_models.DiffusionModel +adv.constitutive_model.Parameters.diffusivity = 1.0e-3 +adv.add_dirichlet_bc(1.0, "Bottom") +adv.add_dirichlet_bc(0.0, "Top") + +dt = adv.estimate_dt() # accuracy-based: 2% of the field's range per step +adv.solve(timestep=dt) +``` + +The one deliberate difference is the timestep estimate. The semi-Lagrangian +`estimate_dt` reports the cell-crossing time, which for this solver is neither +a stability limit nor an accuracy one. The Eulerian solver's `estimate_dt` +instead returns the step at which the field changes by a given fraction of its +range (0.02 by default), from the advective rate before the first solve and +from the rate the last step actually produced after it. It does not depend on +the mesh, so cells refined for the Stokes problem do not shrink it. A script +that sizes its step in Courant numbers can still ask for +`estimate_dt(basis="resolution")`. + +## What carries over + +| SLCN | SUPG | note | +|---|---|---| +| `order=1, theta=0.5` | same | Crank-Nicolson, the default for both | +| `order=1, theta=1.0` | same | backward Euler | +| `order=2, theta=1.0` | same | SL-BDF2 becomes BDF2 | +| `order=2, theta=0.5` | refused | refused for the same reason: a BDF stencil does not pair with a centred flux | +| `f`, `V_fn`, `constitutive_model`, `delta_t` | same | | +| `estimate_dt()` | accuracy-based by default | the field may change by `fraction` (0.02) of its range per step; `basis="resolution"` returns the cell-crossing time SLCN reports | +| `solve(zero_init_guess, timestep, ...)` | same | | +| `DuDt.set_initial_history(values, dt)` | same | plant an exact history to start at full order | +| `restore_points_func`, `monotone_mode`, `old_frame_traceback`, `DFDt` | ignored, with a warning | they configure the trace-back | + +`order=3` (BDF3) is available; see below for when it is safe. + +## When to use which + +Both solvers are free of any stability limit on the timestep, so cells refined +for the Stokes problem never dictate the transport step. They differ in what +bounds their accuracy and in what a step costs. + +**Eulerian SUPG.** The error is set by how far the transported feature moves per +step relative to its own width, as $(\mathbf{u}\Delta t)^2$ for the second-order +schemes. It does not depend on the cell size at all: on a rotating Gaussian a band +refined to $h/9$, with its cells at a local Courant number of 13, changes the error +in the third digit only. A step costs one nonsymmetric solve, four to six times +less than a semi-Lagrangian step in serial, and it needs no departure points in +parallel. On a moving mesh the field and its history are re-interpolated by the +ordinary remesh transfer, so no special staging is needed. + +**Semi-Lagrangian.** The error is nearly independent of the timestep but +accumulates one interpolation per step, so at small Courant numbers it is the +worse scheme (21% against 0.6% after one revolution at Courant 0.5 on the same +mesh). Its limit is the arc a characteristic turns per step, about 10 degrees for +the RK2 trace-back, a property of the flow rather than the mesh. Above roughly +Courant 2 on the feature's own scale it keeps its accuracy where the Eulerian +scheme loses it. + +A practical rule: if the timestep is chosen so that the temperature field itself +is resolved in time (a fraction of a feature width per step), the Eulerian solver +is cheaper and more accurate; if the step is deliberately long relative to the +transported features, the semi-Lagrangian solver is the one that survives it. + +## Choosing the time scheme + +Measured on a rotating Gaussian, one revolution, relative $L_2$ error; the full +tables are in the design note. + +| scheme | behaviour | +|---|---| +| Crank-Nicolson (`order=1`) | three to four times more accurate than BDF2 at the same timestep below Courant 2; rings once the feature is under-resolved in time | +| BDF2 (`order=2`) | damped and stable at every Courant number; the choice for sharp or under-resolved fields | +| BDF3 (`order=3`) | the most accurate scheme below Courant 1 when diffusion is present; on pure advection it grows slowly at any Courant number, so use it only with diffusion | +| backward Euler (`order=1, theta=1.0`) | 20 to 40% error at any practical timestep; not for transport | +| Adams-Moulton 2, 3 (not offered) | third and fourth order below Courant 1 but blow up on advection from about Courant 1, which is why there is no knob for them | + +All schemes cost the same per step: the history terms are extra kernel inputs, +not extra solves. Changing the timestep between steps changes a runtime constant +of the compiled kernels; nothing is recompiled. + +## Details that differ from SLCN + +- The strong residual used in the SUPG term carries the time derivative and the + advection but no diffusion term, because PETSc's pointwise kernels see first + derivatives only. For linear elements the missing term is identically zero. +- The stabilisation parameter uses the local cell size (`mesh.cell_size()`) and + three weights that are runtime constants (`solver.tau_weights`); + `solver.supg_weight = 0` gives the plain Galerkin scheme for comparison. The term is + also weighted by the cell Péclet number, $Pe^2/(Pe^2 + Pe_c^2)$ with + $Pe = |\mathbf{u}| h / 2\kappa$ and $Pe_c$ the `peclet_weight` argument (default 4), + so the stabilisation is off where a cell is diffusion-dominated and full where advection + dominates (pure advection, $\kappa = 0$, is unaffected); `peclet_weight=0` gives the + uniform weight. +- The linear system is nonsymmetric, so the solver uses GMRES with an + additive-Schwarz ILU preconditioner, with the Krylov tolerance matched to the + SNES tolerance so that a step is one Newton iteration. Measured, this is the + cheaper solve at every Courant number up to eight ranks and its iteration + count does not grow with the rank count. `solver.preconditioner = "fmg"` + switches to geometric multigrid over the mesh's refinement hierarchy + (`refinement >= 1`) for very large rank counts. Every option can be + overridden through `solver.petsc_options`. + +## The history manager is the transport plugin + +The solver does not assemble its transport itself. Its history manager (`solver.DuDt`) +contributes three symbolic terms, and the solver composes its residual from them: +the time derivative of the scheme, the advection, and the stabilisation flux of the +strong residual. The default manager is `uw.systems.ddt.EulerianSUPG`, which owns the +advecting velocity (`V_fn` is data on it), the time scheme (`order`, `theta`), and the +stabilisation knobs (`supg_weight`, `tau_weights`, `tau_shape`, `peclet_weight`); the +solver's properties of the same names pass through to it. + +Any history manager that follows the contract can be supplied instead. A semi-Lagrangian +manager answers zero for the advection and the stabilisation, because its history is +already traced back along the characteristics, so the same solver becomes a +semi-Lagrangian scheme on the field history: + +```python +history = uw.systems.ddt.SemiLagrangian(mesh, T.sym, v.sym, vtype=uw.VarType.SCALAR, + degree=T.degree, continuous=True, order=1) +adv = uw.systems.AdvDiffusion(mesh, T, v.sym, DuDt=history) # no assembled advection +``` + +On pure advection this reproduces `AdvDiffusionSLCN` to the solver tolerance; with +diffusion the two differ in where the diffusive flux history comes from (the traced-back +field here, the traced-back flux there). The manager works for a vector or tensor unknown +as well (`vtype`), applying the advection component by component, which is how the +Navier-Stokes solver and a transported stress use it. + +## Further reading + +- Design note and measurements: `docs/developer/design/eulerian-supg-transport.md` +- The semi-Lagrangian schemes: {doc}`semi-lagrangian-time-integration` +- Example: `docs/examples/convection/advanced/Ex_AdvectionDiffusionSUPG_RotationTest.py` diff --git a/docs/advanced/eulerian-navier-stokes.md b/docs/advanced/eulerian-navier-stokes.md new file mode 100644 index 000000000..957b04114 --- /dev/null +++ b/docs/advanced/eulerian-navier-stokes.md @@ -0,0 +1,81 @@ +# Navier-Stokes composed from a transport manager (Eulerian SUPG by default) + +`uw.systems.NavierStokes` solves the incompressible Navier-Stokes equations with +the momentum transport taken from the history manager it holds. With the default +manager, `uw.systems.ddt.EulerianSUPG`, the momentum advection is assembled implicitly +in the Stokes saddle-point residual and stabilised by the streamline-upwind +Petrov-Galerkin term, which is the scheme this page describes; the semi-Lagrangian +solver with a stress history is `uw.systems.NavierStokesSLCN`. It is the vector counterpart of {doc}`eulerian-advection-diffusion` and +takes the same constructor as `uw.systems.Stokes` plus the density and the time +scheme: + +```python +ns = uw.systems.NavierStokes(mesh, v, p, rho=1.0, order=1) # Crank-Nicolson +ns.constitutive_model = uw.constitutive_models.ViscousFlowModel +ns.constitutive_model.Parameters.shear_viscosity_0 = 1.0 / Re +ns.add_dirichlet_bc((0.0, 0.0), "Bottom") +... +for step in range(n): + ns.solve(timestep=dt) +``` + +`order=1` is the theta rule (Crank-Nicolson at the default `theta=0.5`), `order=2` +is BDF2. The velocity history lives on the mesh; there is no stress history, the +viscous stress at an earlier level is rebuilt from the stored velocity. Pressure +has no history. + +## The advecting velocity + +The nonlinear term is $(\mathbf{a}\cdot\nabla)\mathbf{u}^{n+1}$ with $\mathbf{a}$ +chosen by `advection=`: + +- `"extrapolated"` (default): $\mathbf{a} = 2\mathbf{u}^n - \mathbf{u}^{n-1}$, a + second-order lag. Each step is one linear solve through the Stokes fieldsplit. +- `picard_iterations=n` re-solves up to `n` more times with the latest iterate as + $\mathbf{a}$, stopping when the velocity stops changing (`picard_tolerance`). + The fixed point is the fully implicit scheme. +- `"implicit"`: $\mathbf{a} = \mathbf{u}^{n+1}$ and the SNES takes Newton steps on + the quadratic term. + +The stabilisation parameter is +$\tau = [(C_t/\Delta t)^2 + (C_u |\mathbf{a}|/h)^2 + (C_\nu \nu/h^2)^2]^{-1/2}$ +with $h$ the local cell size and the three weights in `ns.tau_weights`; +`ns.supg_weight = 0` gives the plain Galerkin scheme. The term is also weighted by the +cell Péclet number, $Pe^2/(Pe^2 + Pe_c^2)$ with $Pe = |\mathbf{a}| h / 2\nu$ and +$Pe_c$ the `peclet_weight` argument (default 4), so the stabilisation is off where a cell +is diffusion-dominated, where it is not needed and costs a fixed multiple of the Galerkin +error, and full where advection dominates; `peclet_weight=0` gives the uniform weight. +`tau_shape` selects the Brooks-Hughes or doubly asymptotic form of $\tau$ in place of the +inverse sum. The strong residual the +term acts on carries the time derivative, the advection, the pressure gradient +and the body force, but not the viscous term (the kernels see first derivatives +only), so on a smooth, well-resolved flow the Galerkin form is the more accurate +one and the stabilisation earns its place where the element Reynolds number +$\rho|\mathbf{a}|h/\eta$ exceeds one. + +## Timestep + +`ns.estimate_dt()` returns the step at which the velocity changes by a fraction +(default 0.02) of its range, from the realised rate of the last step; before the +first solve, and with `basis="resolution"`, it returns the Stokes solver's +cell-crossing time. + +## The history manager is the transport plugin + +The momentum transport is not written into the solver. Its history manager +(`ns.DuDt`, an `EulerianSUPG` on the velocity) contributes the time derivative, the +implicit advection $\sum_k w_k (\mathbf{a}_k\cdot\nabla)\mathbf{u}^{(k)}$ and the +stabilisation flux $\tau\,\mathbf{R}\otimes\mathbf{a}$; the solver adds the density, +the pressure gradient and the body force to form $\mathbf{R}$, the viscous flux of the +scheme, and the pressure. The advecting velocity is data on the manager: the solver sets +it to the extrapolated field, the latest Picard iterate, or the unknown itself according +to `advection`, and names the stored velocity as the carrier of the stored levels +(`DuDt.V_fn_history`). The stabilisation knobs (`supg_weight`, `tau_weights`, +`tau_shape`, `peclet_weight`) and `delta_t` live on the manager and the solver's +properties pass through. The scalar solver `AdvDiffusion` composes the same three +terms from the same class, and a semi-Lagrangian manager can be supplied to either. + +## Further reading + +- Design note and measurements: `docs/developer/design/eulerian-supg-transport.md` +- The semi-Lagrangian Navier-Stokes solver: `uw.systems.NavierStokesSLCN` diff --git a/docs/advanced/fault-branching-junctions.md b/docs/advanced/fault-branching-junctions.md new file mode 100644 index 000000000..6847acee8 --- /dev/null +++ b/docs/advanced/fault-branching-junctions.md @@ -0,0 +1,187 @@ +# Branching faults, junctions, and what a crack model cannot do + +The [crossing-zones page](crossing-fault-zones.md) compared overlap +rheologies for fused fault zones in pure extension and found the +differences modest at ordinary angles. That test had a hidden weakness: +the configuration is maximally symmetric about the load, and the drive +carries **no rotation**. This page repeats the question with both +removed — and the verdict inverts. It ends at the reason: two of our +fault representations are crack models, one is not, and the difference +is not a numerical detail but the mechanics of branching itself. + +Scripts and caches: `~/+Simulations/ribbon_network_2d/` +(`branch_study.py`, `branch_width.py`, `branch_damage.py`). + +## The shear box + +Simple shear supplies what extension cannot: spin, $\omega = +\dot\gamma/2$. The test frame is a driven box — top plate $v = (+0.5, +0)$, bottom $v = (-0.5, 0)$, sides open (traction-free) — holding an +asymmetric Y: a **sub-horizontal main fault** at $\theta = 20/30/40^\circ$ +and a **horizontal branch** running in from the side and fusing into it. +Nothing is mirror-symmetric; the branch lies in the shear plane +(optimally oriented) while the main degrades with $\theta$ (resolved +drive $\propto \cos 2\theta$). Bands at the union-jack default +resolution, structure verified per build; the isotropic weak band is the +reference throughout, with transversely isotropic (TI) variants against +it. Everything is measured from the P0 cell strain rate — local, no +recovery step, no probes. + +```{figure} figures/branch_maps_z20.png +:width: 100% + +Strain rate (P0 invariant, log scale, one range). Rows: $\theta$ = +20/30/40$^\circ$; columns: the isotropic reference and three TI +variants differing only at the junction. In the reference the junction +is the hottest point of the network and intensifies with $\theta$; in +every TI variant it cools, and the misoriented main fades until its +lower arm is a ghost. +``` + +## The junction is a slip exchanger — and the crack constraint cannot run it + +Slip on each fault, at the junction, TI variants as a fraction of the +reference: + +| $\theta$ | main fault | branch | +|---|---|---| +| $20^\circ$ | 77% | ~75% | +| $30^\circ$ | **46%** | ~78% | +| $40^\circ$ | **19%** | ~52% | + +Three facts organise this. First, the reference's main-fault slip at the +junction **rises** as the fault becomes misoriented (0.30 → 0.36): the +optimally-oriented branch delivers its offset *into* the main through +the junction, the isotropic corner rotating the deformation between the +two orientations. Second, the three TI overlap treatments (nearest +director, harmonic average, isotropic overlap cells) are +indistinguishable from each other while all collapsing against the +reference — the overlap rule was never the dominant term. Third, the +mechanism is general: **a gradient of slip along a fault is a +volumetric exchange between band and surroundings**, and that is +precisely the deformation mode the TI constraint (like the slip +surface) removes. Crack kinematics carry slip *along* a fault +faithfully and cannot hand it *between* faults. + +Imposed isotropic "damage halos" around the junction recover the +exchange roughly linearly in their radius (42% at $3w$, 73% at $5w$) +with no plateau: there is no compact junction patch, because the +constraint taxes the mechanics wherever slip varies. + +```{figure} figures/halo_recovery.png +:width: 100% + +Slip profiles and recovery against halo radius: freeing the constraint +where the slip gradient lives, by hand. +``` + +## The exit throat, and a law with nothing fitted + +Where is the reference's peak? Not at the junction point. It sits on +the main's up-dip arm at $r \approx 2.1\,w$ — a **self-similar** +position (2.05–2.11 $w$ across a fourfold width sweep) — where the +fused Y narrows back to a single band. The combined slip of both faults +must exit through that neck, and flux conservation predicts its +intensity outright: + +$$ +\gamma_{\rm peak} \;\approx\; \frac{s_{\rm main} + s_{\rm branch}}{w}, +$$ + +measured at 0.96–1.09 of the prediction over the three resolved widths. + +```{figure} figures/junction_peak_zoom.png +:width: 100% + +The peak (circled) at four band widths, one log scale: always at the +exit throat, always at $r \approx 2.1\,w$, in healthy cells. +``` + +The limit is the point: as $w \to 0$ the throat slides onto the corner +and its amplitude diverges. **A branch point is a corner singularity of +the continuum problem, and the band width is its regularisation +length.** Neither description converges there — the crack models +*exclude* the corner physics; the continuum model *concentrates* it +without bound. The junction's width is therefore not a numerical +parameter to refine away but a physical one: the process-zone size. (It +is the [gouge page's](gouge-zones.md) "width is physics", at the place +where it matters most.) + +## What the country rock feels + +Deviatoric stress in the matrix (where $\tau = 2\dot\varepsilon$ +exactly), zones masked: + +```{figure} figures/branch_stress_matrix.png +:width: 100% + +Matrix stress, one linear scale. Means agree to 2–4% across all six +cases — the load is conserved; its **location** is not. +``` + +The locked TI junctions park a wall-rock stress concentration at the +elbow (the tail rises 11%) that the reference does not possess; freeing +the junction trades it back for **tip lobes**, which is where the +reference — and a real fault — concentrates stress. Ranking these +models by peak stress alone would order them backwards; the location is +the diagnostic. A constrained-network model read at face value predicts +wall-rock damage and seismicity at junctions where the physics puts +none, and under-predicts tip process zones. + +## Offered plasticity, the crack model builds a bypass + +A deliberately primitive damage law — any matrix cell whose stress +exceeds $\tau_c$ becomes isotropic $\eta_1$, instantly and irreversibly; +re-solve; repeat — asks what a yielding background does with each +representation. With a negative control: + +```{figure} figures/damage_control_compare.png +:width: 100% + +Damaged cells coloured by the pass at which they yielded, same law and +threshold. Left, the isotropic reference: growth at the free **tips** +only — the physical pattern — with the junction silent throughout. +Right, the fully-TI network: the same tip growth **plus** a +junction-nucleated front marching along the shear plane — a +through-going bypass that retires the misoriented fault. +``` + +Both models grow faults at tips; that part is physics. Only the +constrained model manufactures growth at the junction — nucleated by +the stress its own kinematic restriction parks there — and its response +to that stress is not to heal the junction but to **re-route the +network around it**. The law is a cartoon (no softening, no length +scale, grid-width bands), so the nucleation sites and topology are the +trustworthy content, not rates or widths. One further consequence of +the corner singularity: at realistic (thinner) widths the *reference's* +corner stress also exceeds any finite yield threshold — so in a +yielding crust a branch point **must** develop a damage zone. The +damage halo at a junction is not a modelling patch; it is the +material's own regularisation of a corner that crack mechanics cannot +represent and continuum mechanics cannot bound. + +## Choosing, finally + +One of these is a crack model twice over — the split surface imposes +slip-only kinematics exactly; the TI band imposes the same as a +$10^{3}$ constitutive penalty — and one is not: the isotropic band is a +model of fault *rock*, not fault *kinematics*. The crack assumptions +fight each other at branches. Hence: + +- **Large-scale fault networks**: build from surfaces (contacts) — the + crack idealisation at its best, where segments are distinct, + well-oriented, and slip-dominated. +- **Zones with interior physics** (gouge, heating, damage evolution): + thin volumes/ribbons — with TI where the zone should stay + crack-like, isotropic where it must evolve internally. +- **Crossings and joins**: make them kinematically compatible by + construction — bend faults in to meet tangentially rather than + abutting at high angle, and surround the joins with isotropic damage + zones sized as process zones. Do not expect any director rule, patch, + or refinement to buy back a high-angle locked junction: the failure + is the crack assumption itself. +- **Evolution in time** — where a misoriented fault would dismember + into en-échelon segments and the network would rebuild itself through + its damage field — requires transporting faults as material chains + and re-deriving them with the evolving damage. That is a programme, + not a feature; this page records the mechanics that motivates it. diff --git a/docs/advanced/fault-mechanics-examples.md b/docs/advanced/fault-mechanics-examples.md new file mode 100644 index 000000000..f8bf4d053 --- /dev/null +++ b/docs/advanced/fault-mechanics-examples.md @@ -0,0 +1,354 @@ +--- +title: "Fault Mechanics Teaching Examples" +--- + +# Fault Mechanics: Teaching Examples + +Three worked examples built on [split-node faults](split-node-faults.md), +each a single short script (in `figures/fault-examples/`, sharing one +harness `common.py`). All run in minutes on a laptop: the meshes are +modest, because a zero-thickness fault needs no thin feature resolved. + +## The fault-strength ladder + +One fault, one far-field shear drive ($\tau_\infty = 1$ resolved on the +fault plane), and the whole constitutive ladder — the slip-rate profile +$V(s)$ tells each law's story: + +![The fault-strength ladder](figures/fault-examples/ladder.png) + +The right-hand column shows the shear-stress field $\sigma_{xy}$ for +each rung (colour range $0 \to 2\tau_\infty$, white at the far-field +value): a slipping fault shadows itself with a stress drop and +concentrates stress at its tips, in proportion to how much of +$\tau_\infty$ its law lets go; the stuck fault leaves the field +untouched. + +- **Frictionless** — the free crack: full stress drop, elliptical + profile (the dashed shape), slip vanishing at the unsplit tips. +- **Viscous, $\eta_f = \eta/a$** — the natural interface-viscosity + scale: the fault slips at roughly half its free rate. +- **Coulomb, weak** ($\mu\sigma_n < \tau_\infty$) — slides at the + reduced stress drop $\tau_\infty - \mu\sigma_n$, profile still + elliptical: the constant-strength crack. +- **Rate-and-state** — at steady state, a velocity-dependent strength + between the Coulomb end-members. +- **Coulomb, strong** ($\mu\sigma_n > \tau_\infty$) — stuck: creep at + the regularisation velocity, invisible at plot scale. + +Script: `figures/fault-examples/ladder.py`. + +## The Mohr circle, measured by faults + +All the stress-plane figures below use the **geological sign +convention**: compression positive, tension on the negative axis. (The +solver's tractions are tension-positive; only the plots flip.) There is +no confining pressure in these problems: the walls prescribe velocity +and the flow is incompressible, so pressure exists only up to the +nullspace constant and the solver's gauge centres the Mohr circle on +the origin — which is exactly why the tensile sector is reachable. + +A *welded* split fault (interface dashpot with large $\eta_f$) does not +perturb the flow — the welded limit recovers the uncut continuum — but +its machinery still reports the tractions across it: the no-opening +constraint's reaction gives $\sigma_n$, and the dashpot law reads the +signed shear traction off its own slip, $\tau = \eta_f V$. Each fault +orientation is therefore a passive stress probe, and sweeping the +orientation through $180°$ traces the full Mohr circle of the ambient +stress state: + +![Mohr circle from welded faults](figures/fault-examples/mohr-circle.png) + +The boundary condition is Dirichlet velocity on all four walls, +imposing the homogeneous flow $\mathbf v = (a(x-\tfrac12) + +\gamma(y-\tfrac12),\; -a(y-\tfrac12))$ with $a = 0.5$, $\gamma = 1$ +— a pure-shear (stretching) part plus a simple-shear part. Stress sees +only the symmetric gradient, so this is equivalent to pure shear of +magnitude $R = \eta\sqrt{4a^2+\gamma^2}$ with principal axes at +$22.5°$ to the box: deliberately not axis-aligned, so the circle's +orientation must be measured, not guessed. Because the linear flow is +an exact homogeneous Stokes solution, the stress is uniform to the +walls and every probe samples the same state; the pressure gauge (the +nullspace constant) sets the circle's centre. The probes (labelled by +fault angle) +land on that circle, centred at the (gauge-fixed) mean pressure. The +classical double-angle rule is the labels' spacing: $22.5°$ of fault +rotation moves a probe $45°$ around the circle, and $180°$ of rotation +closes it. + +Script: `figures/fault-examples/mohr_circle.py`. + +The measured points land on the circle too neatly to teach from a +static figure — the lesson is in the *construction*. The animated +version rotates the fault through $180°$ while its probe sweeps the +full circle at $2\theta$, one welded-fault solve per frame: + +![Building the Mohr circle](figures/fault-examples/mohr-circle-build.gif) + +Script: `figures/fault-examples/mohr_animate.py`. + +### The circle meets the friction envelope + +Give the rotating fault Coulomb friction (reaction-fed normal stress) +and the probe can no longer go everywhere the ambient stress points. +Three regimes appear in one sweep: + +![Coulomb fault vs the Mohr circle](figures/fault-examples/mohr-friction.png) + +- **Stuck** (circles): the ambient resolved stress lies inside the + envelope $|\tau| < \mu\sigma$ — the fault transmits it and the + probe sits on the Mohr circle; +- **Sliding** (squares): the ambient stress would exceed the envelope — + the fault slips, drops the shear traction to its strength, and the + probe is pinned to the yield line; +- **Held shut** ($\sigma < 0$, grey crosses): bare friction has no + strength under tension — a real fault would OPEN, and no static + solution exists. The bilateral no-opening constraint manufactures one + by gluing the surfaces together (a tensile reaction), so the solver + still converges; the probes ride the axis at $\tau = 0$, but they + are marked unphysical. The tell, in any real model, is the sign of + the recovered normal traction. + +The shear traction here is read from the Coulomb law at the measured +slip rate — which is exact in every regime, because the regularised +law *is* the traction the fault carries. The animated build shows the +switch happening as the fault rotates, with the slip sense drawn as +half-arrows when it unlocks: + +![A frictional fault against the Mohr circle](figures/fault-examples/mohr-friction-build.gif) + +Script: `figures/fault-examples/mohr_friction.py`. + +### Cohesion keeps more of the circle + +Add cohesion — the envelope becomes $\tau = \pm(C + \mu\sigma)$ — +and strength survives into MILD tension, declining along the envelope +and reaching zero at $\sigma = -C/\mu$. Stuck arcs now survive +around **both** principal poles, with envelope-pinned sliding between; +beyond the cutoff the fault is in the same held-shut (unphysical) +regime as the cohesionless case, just pushed further into tension. + +![Cohesive Mohr-Coulomb fault](figures/fault-examples/mohr-cohesion.png) + +The cohesive law is not a canned option — it is registered as a sympy +expression in the canonical interface symbols, which is the intended +extension path for any fault rheology: + +```python +V = fault_contact.slip_rate +S = fault_contact.normal_stress # reaction-fed, clamped >= 0 +law = fault_contact.SymbolicFaultLaw( + (C + mu * S) * (2 / sympy.pi) * sympy.atan(V / V0)) +``` + +![Cohesion keeps more of the circle](figures/fault-examples/mohr-cohesion-build.gif) + +Script: `figures/fault-examples/mohr_cohesion.py`. + +### The graded fault: depth-dependent stress along one surface + +Add a modest hydrostatic load (constant density, closed box — the flow +is untouched, pressure absorbs gravity exactly) and the per-node +character of the stress recovery becomes visible: every fault node sits +at its own depth, so a single welded fault contributes a **streak** of +probes, not a point. The streak is horizontal (only the pressure part +of $\sigma$ varies along the fault), longest for the vertical fault, +and collapses to a dot for the horizontal one: + +![The graded fault](figures/fault-examples/mohr-graded.png) + +Points are coloured by depth; the grey circles are the Mohr circles of +the shallowest, central, and deepest fault points. Nothing here is +averaged — $\sigma_n$ comes from the constraint reaction de-smeared +node by node, $\tau$ from the weld's own law at each node — which is +exactly the machinery that lets a friction law feel depth-dependent +strength along a single fault (the locking-depth structure of a +seismogenic zone). The closed-box pressure gauge is re-anchored in the +plot so $p = 0$ at the top surface; the shift is the analytically known +$\rho g H/2$. + +Script: `figures/fault-examples/mohr_graded.py`. + +## Orientation and slip: the circle's other face + +The same orientation sweep with *frictionless* faults under pure shear. +Now each fault drops whatever shear stress is resolved on its plane, so +the peak slip rate follows $|\cos 2\theta|$ — the slip-rate reading of +the Mohr circle. A fault at $45°$ to the shear plane feels no resolved +shear and barely slips; the aligned fault slips fully: + +![Orientation vs slip](figures/fault-examples/orientations.png) + +Script: `figures/fault-examples/orientations.py`. + +## Interacting faults: stress transfer on the Mohr plane + +Two faults via `add_fault`'s network form: the *source* slips freely, +the *receiver* is welded — the passive probe from the Mohr examples. +Differencing two solves on the same mesh gives the classical Coulomb +stress transfer, and the receiver's probes show it in the diagram +students already know: each node's point MOVES, and its colour is its +own $\Delta$CFF ($\mu' = 0.4$, King's value): + +![En echelon interaction](figures/fault-examples/interacting-faults.png) + +The receiver sits in the source's along-strike tip lobe, and the +friction bookkeeping is dressed so the crossing is visible: a declared +confining pressure $P_0 = 1$ puts the whole circle in compression, and +a cohesive envelope $\tau = \pm(C + \mu'\sigma)$ with $C = 0.75$ +passes just below the ambient cloud. Neither constant changes +$\Delta$CFF — they only place the failure line where the physics can +reach it. The loaded near-tip nodes then cross into the shaded failure +region while the far end barely moves. Rotating the regional +compression axis (the boundary velocities rotate with it) relocates +the ambient cloud around the circle, and what rotation controls is the +MARGIN to the envelope. (Fields here and below are P0 cell stress on +the split mesh's true connectivity, linear colour at $\pm 1$ — see +the California example for why.) + +![Rotating the regional stress](figures/fault-examples/interacting-rotation.png) + +Two gauge points, handled explicitly in the scripts because a closed +velocity-driven box fixes pressure only up to a constant: differenced +fields are anchored to zero in the far field (a slip event changes +nothing far away), and each solve's absolute probe pressures are +anchored to the analytic ambient state. Both constants are printed, +never silently absorbed. + +Script: `figures/fault-examples/interacting_faults.py`. + +### A schematic southern California + +The San Andreas as ONE continuous dextral trace with a smooth tanh +S-bend — the Big Bend, the smoothed version of a left-stepping +stepover, restraining under right-lateral slip. The Garlock, three +East California Shear Zone strands and a San Jacinto-like fault are +welded as probes — all inboard, in the continental crust. The drive is +right-lateral simple shear parallel to the plate boundary (~N40W), +which resolves dextral on the NW-striking faults and sinistral on the +Garlock, exactly the real senses; the slip arrows on the map are drawn +from the *measured* jump, not assumed: + +![Schematic southern California](figures/fault-examples/california.png) + +The curved trace is sampled as a polyline, and the fault carries the +smooth curve's **analytic normal** +(`add_fault_bc(..., normal=...)`). This matters: with the default +facet-averaged normals a sampled curve develops slip notches and +stress sawteeth at every sampling kink — roughness that *grows* under +refinement, because the constraint direction itself zig-zags (see the +practical notes in the split-node fault guide). With the analytic +normal the trace slips smoothly through the bend. + +One trunk-spanning event, and the map teaches by geography: the +restraining bend fills with strong compression — $\Delta$CFF deeply +negative in a bowtie exactly where the Transverse Ranges belong. The +neighbours give three verdicts: the parallel San Jacinto is deeply +relaxed ($\Delta$CFF $-0.4$, its cloud retreating into the safe +wedge); the conjugate Garlock and the distant ECSZ strands are mildly +loaded ($+0.05$ to $+0.06$). (Schematic geometry, not to scale; same +$P_0 = 1$, $C = 0.75$ envelope dressing.) + +Script: `figures/fault-examples/california.py`. + +### A branching rupture on an intersecting network + +Real trace sets cross and branch, and the split-node pipeline refuses +shared vertices — so intersecting geometry goes through +`uw.meshing.prepare_fault_network` first, which converts every +junction (X crossing, T abutment, near-miss) to the offset-junction +form: the traces stop short of the intersection, leaving a ligament of +intact material about a cell across, with angle-corrected pull-backs +and a printed report of every action. Here the raw input genuinely +intersects — a dextral trunk, a splay branching off its midpoint, and +a conjugate crossing it — and the trunk + splay rupture together while +the conjugate sits welded: + +![Branching rupture](figures/fault-examples/branching.png) + +The brightest features are the **junction plugs**: the intact +ligaments take the concentrated load shed by their slipped neighbours +on both sides, which is exactly where a through-going event would +break next. The ligament approximation is measured, not asserted: at a +T-junction the branch's slip profile is *identical* at two mesh +resolutions for a fixed plug size, and its peak slip changes by only a +few percent between ligaments of one and two cells (the study is +recorded in the developer notes). Keep ligaments at one to two local +cell sizes. + +Script: `figures/fault-examples/branching.py`. + +And the question the offset form begs — what does interrupting a fault +cost? — has a measured answer. The same network prepared two ways +(`through=["Trunk"]` keeps the trunk continuous, so the conjugate +yields on both sides of the crossing; the default cuts both): + +![Continuous vs interrupted](figures/fault-examples/branching-compare.png) + +The through-going trunk slips slightly *more* than it would alone (the +slipping splay feeds it); cutting it at the crossing pins it locally +and costs about a quarter of its peak slip — but the profiles converge +away from the junction, so an interruption's reach is the segment +scale, not the system scale. T abutments never cut the through-going +trace; declare master faults with `through=` where a crossing must not +either. + +Script: `figures/fault-examples/branching_compare.py`. + +### A true Y-branch, and which continuity to declare + +A genuine three-arm branch can be decomposed two ways: trunk +continuous with the splay abutting (A), or the *bent* west-arm-plus- +splay fault continuous with the east arm abutting (B). Running both, +with the gap swept in A: + +![The true branch, bracketed](figures/fault-examples/true-branch.png) + +Two conclusions. The **gap is second-order**: A at 1h, 2h and 4h +ligaments overlays on every arm except the splay's near-junction toe, +which simply moves with the trimmed tip. The **decomposition is +first-order**: B is a different mechanical system, because the +continuous bent fault LOCKS at its 33-degree kink — slip through a +kink is geometrically incompatible with the no-opening constraint +(the same mechanism that makes sampled curves rough, here as genuine +physics) — so both trunk arms lose a third of their slip and pinch to +zero at the branch point. The rule for real networks: **declare the +straightest path continuous; never route continuity around a +corner.** The energetically sensible decomposition (A) is admissible +for the true junction and differs from it only in the splay's +ligament-scale toe. + +The stress fields make the distinction physical. With the trunk +continuous, the stress shadow runs straight through the junction and +the branch point carries no feature at all — the junction is +mechanically invisible. With continuity routed around the bend, the +locked kink acts as a barrier: both arms' shadows terminate against it +and load the corner, the classic signature of a rupture-arresting +bend: + +![The branch point in stress](figures/fault-examples/true-branch-stress.png) + +Script: `figures/fault-examples/true_branch.py`. + +## What these models are, and are not + +For the static problem, incompressible linear elasticity and Stokes +are the same mathematics (displacement ↔ velocity, shear modulus ↔ +viscosity), so every stress pattern here is exactly the elastic +$\nu = 1/2$ pattern with slip rate standing in for coseismic slip. +What is deliberately absent: depth and half-space geometry (these are +plane-strain boxes with velocity-driven walls — no free-surface +amplification), topography, gravity except where stated (the graded +fault), compressibility ($\nu \ne 1/2$ shifts normal-stress lobes +modestly), and postseismic processes of any kind. The slipping faults +are completely weak during their event (frictionless: the full +ambient resolved shear drops); because the problem is linear, +$\Delta$CFF scales essentially linearly with the dropped stress, so +the transfer maps read as stress change per unit stress drop. A +partial-drop event is the Coulomb rung applied to the source fault. + +## Where next + +Remaining extensions on the same harness: tip-to-tip vs overlapped +en echelon arrangements side by side, a denser fault network, and the +graded (gravity-loaded) versions of the interaction cases. diff --git a/docs/advanced/fault-networks.md b/docs/advanced/fault-networks.md new file mode 100644 index 000000000..2424b00f7 --- /dev/null +++ b/docs/advanced/fault-networks.md @@ -0,0 +1,308 @@ +# Fault networks: hierarchy, junctions, and damage-zone glue + +Real fault systems cross, branch, and abut. The split-node formulation +(see [Split-node faults](split-node-faults.md)) deliberately refuses +shared vertices — a junction vertex would need a non-binary contact +pairing — so a network is represented in the **offset form**: at every +junction the junior trace stops a cell or two short of the senior one, +and a small **damage zone** of viscoplastic material connects them. +`FaultNetwork` packages that whole recipe. + +```python +import underworld3 as uw + +net = uw.meshing.FaultNetwork( + [("Main", main_pts), ("Splay", splay_pts), ("Cross", cross_pts)], + hierarchy=["Main", "Splay", "Cross"]) # seniority order + +mesh = net.prepare(h=0.006).build(width=0.01) # junctions -> mesh -> split + +v = uw.discretisation.MeshVariable("V", mesh, 2, degree=2) +p = uw.discretisation.MeshVariable("P", mesh, 1, degree=0, + continuous=False) +stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) +stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel +stokes.constitutive_model.Parameters.shear_viscosity_0 = \ + net.junction_patch(eta_0=1.0) # the junction glue (linear) +net.apply(stokes) # no-opening pairs, all pieces +# ... wall boundary conditions ... +info = net.solve(stokes) +print(net.slips(stokes)) # peak slip per piece +``` + +## One specification, two realisations + +A fault is specified once — a trace, its rank in the hierarchy, and the +properties it carries — and then *realised*. Which realisation you get +is a keyword on `build`, not a different set of calls: + +```python +net.prepare(h=0.006) +mesh = net.build(width=0.01) # cut, node-pair contact +mesh = net.build(width=0.002, realisation="ti") # volumetric weak plane +net.apply(stokes, eta_1=0.01) # eta_1: TI only +``` + +Both realisations place the same ribbon band along the same prepared +pieces, so the cells are identical and results from the two may be +compared directly. The band is meshed around the trace's own points and +segments, which become mesh vertices and edges, so **the mesh can be cut +whatever the width is** — measured complete, with exact vertex +coincidence, down to a band a tenth of the background element size. The +realisation is a free choice, not something the mesh grants or refuses. + +What differs is what `width` *means*. For the split it is a resolution +parameter: the band exists to give the cut its own vertices, and its +thickness is not physics. For the weak plane it is constitutive — the +layer thickness that sets the slip rate through `V = 2 e_nt w` — so it +wants two or three elements across it. That is the whole asymmetry, and +it is about the rheology rather than the mesh. + +`slips()` reports each realisation in its own quantity: the tangential +jump between the two nodes of a cut pair, or the jump in tangential +velocity across the layer, sampled one half-width plus a cell either +side of the spine. Both are the fault's own throughput; a probe placed +further out reads the surrounding flow as well and over-reads short +strands. The gauge is rank-local: each rank reports only the probe +pairs it owns and omits a piece it holds no pair of, so a +max-reduction across ranks recovers the network's answer. This +matters because `evaluate` answers for any point it is handed, +extrapolating from the nearest local cell when the point is not in +the local mesh — a band-less rank would otherwise report a far-field +extrapolation as the band's slip. + +`build(width=None)` keeps the older no-band path — graded refinement +cut directly. It is split-only, and its mesh is not the one a weak +plane would use, so do not compare across that choice. + +**The band is material, not scaffolding.** It is easy to read the band +as something the weak plane needs and the split merely tolerates. It is +not: the band is a meshed region of material *around* the fault, and a +segmented fault does its interesting work exactly there — at the strand +tips, and in the ligaments where one cut stops short of the next. Damage +in those places needs cells to live in, and the band is where they are. +`net.band` is the mask, `net.footprints` the per-strand ones, in either +realisation. + +`net.band_yield(tau_y)` gives a rheology for the whole band: von +Mises yield confined to it, everything outside set far too strong to +yield. Read the next paragraph before using it as glue. + +```python +stokes.constitutive_model = uw.constitutive_models.ViscoPlasticFlowModel +stokes.constitutive_model.Parameters.yield_stress = net.band_yield(4.0) +stokes.consistent_jacobian = True +``` + +A released fault flank sits far below `tau_y` and is untouched, so +the breakdown appears where the mechanics puts it — but that is tips +and bends as much as joints. Measured on the S-fault rig: at the +strength that repairs a stepover, more than half the yielded band cells +were on strand flanks and free tips, and one step weaker the whole +main strand had become a weak fault. A uniform threshold cannot pick +out the welds alone, because the stress concentration at a weld is not +far enough above the tip and bend concentrations. `band_yield` is a +damage model for the band; the junction glue is `junction_patch`. + +The two realisations' interface parameters correspond, which is worth +keeping in view when comparing them: the zero-thickness limit of a band +of viscosity `eta_band` and width `w` is an interface viscosity +`eta_f = eta_band / w`, which is the `conds` argument of +`add_fault_bc`. The weak plane's `V = 2 e_nt w` is precisely what the +contact replaces with a genuine slip rate. + +**Properties belong to the fault.** `net.surface(name)` returns the +retained {class}`~underworld3.meshing.surfaces.Surface` for a piece. +Friction, accumulated slip, a damage state live there, on the fault, +and outlive any one realisation of it: + +```python +main = net.surface("Main") +friction = main.add_variable("mu", size=1) +friction.data[:] = 0.6 +``` + +## The recipe, and why each piece is the way it is + +**Hierarchy.** At an X crossing the senior fault runs through and the +junior is severed and pulled back (`hierarchy`, pairwise). A trace that +*ends* on another (T abutment) is always the one trimmed — an endpoint +cannot run through. Absolute masters (`through=`) override rank. + +**Small junctions.** The pull-back is `ligament * h` (default 2 +elements). Measured on a collinear bridge: gaps from 1 to 6 elements +all solve at identical cost, slip transmission varies by only ~7% +(monotone — shorter is better), and one element of gap resolves at the +same answer when the junction patch is refined 2x. Make the join as +small as the mesh allows; buy fidelity with elements, not physical +size. + +**The glue, and where it goes.** The split only goes wrong at the +joints: away from them the cut *is* the target every volumetric +representation converges to, and adding weakness along a whole strand +makes the fault over-weak in a way that depends on the band width. So +the glue is placed, not found. `junction_cells()` reads the places off +the mesh itself: the ribbon (the band with its extrapolated margins) +is everything the weak-plane realisation would treat as fault, the cut +chains are what the split sliced, and a band cell whose nearest spine +point lies in a piece's margin *and* which sits inside a second +piece's ribbon is where two pieces meet without being joined — a +kissing branch, an abutting pair, the intact bridge of a stepover. +Free tips are excluded on purpose: a margin that runs into intact +material is a tip, and damage there lengthens the fault instead of +joining it (measured: with the free tips included, nearly every +yielded cell was at a tip and the main strand grew 1-6% longer in +slip). The cells are dilated by one vertex ring, and that ring is +not optional: the weld's stiffness lives in the intact material +around the two tips, and the bare junction cells recover only a +fifth to a quarter of the deficit even when fully plastic. + +Two pieces that continue one another along a line — an abutting pair, +a stepover's continuation — are placed on **one spine**: two ribbons +laid along the same line interleave their vertices into sliver cells +(measured: 7800 cells below 1e-6 in area, and the velocity solve +five times slower). `build()` groups such pieces (end tangents within +25 degrees, the far start within half a width of the line, within the +margins' reach), bridges the gap with spine vertices at the local rung, +and cuts each piece at its own ends; the gap is spine the split does +not cut, which is exactly what the junction rule reads. + +For the rule to see a joint, the ribbons have to meet across it. +`build()` sees to that: at an end that sits on a prepared junction the +tip margin is extended until the ribbon reaches the other piece's cut, +so the whole ligament lies in both ribbons; free tips keep the default +margin. An abutting pair that `prepare()` did not record as a junction +(a gap wider than the ligament) is covered as far as the default +margins overlap — a gap wider than that is two faults, and stays +welded, which is what a gap of intact rock means. + +`junction_patch(eta_0, ratio=0.01)` then makes those cells weak +isotropic material, `eta = ratio * eta_0`. A viscosity ratio rather +than a yield stress, because the joint only has to be broken and a +ratio needs no stress scale — nothing about the block or the loading +has to be known to set it. Measured against the two end members on +the S-fault rig (the fault *longer*, one continuous cut, and the fault +*cut*, abutting cuts, at two resolutions): the patch recovers +0.8-0.97 of the continuous fault's transmission across the joint; the +slip crosses on the cut itself (the segment's pair jump reaches the +continuous fault's); the rest of the network keeps the split's answer +(main strand within 2.5%); the weak patch reproduces a fully plastic +patch on the same cells to 1-2% and is insensitive to the ratio from +0.01 to 0.001; the solve is linear and costs the split's velocity +iterations, with only the pressure block noticing the contrast +(hence 0.01, not smaller). Gluing a joint does change the partition +between the strands that meet there — a reconnected main line takes +back slip a through-going branch was carrying past the weld — which +is the junction working, not the patch leaking. + +`damage_yield` is the older glue: a viscoplastic plug of radius +`max(2.5 h, 1.2 pull)` at each *prepared* junction point, yield +`dial * (1 + 2 * edot_II)` inside, strength and rate-regularisation on +one dial, sharp `Piecewise` boundaries. It stays available for +studies of the glue itself, and it does not see stepover bridges, +which are not prepared junctions. + +**No prescribed reconnection.** Nothing tells the network how to link +up: the stress lobes of the abutting tips decide. A collinear gap +grows a straight band; a branch junction feeds whichever limb the +drive favours, and switches when the drive rotates. + +**Isotropic glue, deliberately.** Oriented (transversely isotropic) +weakness in the plug cannot relax the corner-turning deformation a +junction must accommodate — a weak-plane fabric only softens shear ON +the plane, and the measured junction stress stays high no matter how +weak the plane is made. Use TI damage along fault *zones* (where +deformation is plane-parallel); junctions get isotropic breakdown, +which is also what junction breccia looks like in the field. + +## Baseline discipline + +The stateless control for any network experiment is a plug of constant +weak viscosity — it transmits and partitions slip essentially +identically to the damage glue (measured within ~5%). What damage adds +is **state**: wear-in and healing with a slip-rate memory, which only +changes the *instantaneous* mechanics once elasticity (stored stress) +enters. Judge any refinement of the glue against that control. + +## 3-D networks: planar patches + +Pass triangulated `FaultSurface` objects instead of 2-D traces and the +same object runs the 3-D pipeline: + +```python +fsA = uw.meshing.FaultSurface("Main", main_pts); fsA.triangulate() +fsB = uw.meshing.FaultSurface("Cross", cross_pts); fsB.triangulate() +net = uw.meshing.FaultNetwork([fsA, fsB], hierarchy=["Main", "Cross"]) +mesh = net.prepare(h=0.06).build() # trim -> embed -> split each +``` + +In 3-D two planar patches meet along a **segment** (the plane–plane +line clipped to both rims). The junior patch is cut into two pieces by +removing a ligament band about that line — *in its own plane* — so the +mesher (`BoxInternalPatch`, which now embeds a list of disjoint +patches with gmsh grading from every patch) never sees intersecting +surfaces: prepare-first, exactly as in 2-D. The junction glue becomes +a **tube** (distance to the segment) instead of a disc; `damage_yield` +handles both automatically. Pieces that fall below a minimum area +after trimming are dropped and reported — small patches with large +ligaments can disappear entirely, so watch the report. + +Two meshers share the contract (`build(mesher=...)`): +`"embed"` (default) is the gmsh conforming multi-patch embed — fast +and proven for networks; `"place"` is the native placed-sheet route +(`place_surface.place_sheet`), non-cumulative from a static base (the +fault position is a design variable) and parallel-capable end to end +— the composed chain place -> split -> contact converges at np=2 at +serial speed with the serial answer (ptest_0852). Placement needs +sheet triangulations with no all-rim triangles; the toolkit +triangulates prepared rims itself (interior grid, centreline points +for narrow strips, edge flips) so users never meet that contract +directly. Place is currently OPT-IN for networks: on graded +(edge_split) bases the composed mesh builds and converges but the +solve is pathological — an open operator-health work item; on uniform +bases it is healthy. + +`build(width=...)` gives the 3-D network the same finite-width +contract as 2-D: the margin-expanded patches are thickened by +`±width/2` into ONE fused band (junctions free), and each un-expanded +patch is embedded in the band as a conforming mid-surface — so the +same mesh is cut and split (`realisation="split"`) or left whole for +the volumetric weak plane (`realisation="ti"`), exactly as in 2-D. +The honoured footprints are exact for planar patches (band cells +within the patch's own in-plane outline and half a width of its +plane), the weak-plane director is the patch normal, and `slips()` +reports the plane form of each gauge: the tangential pair jump for +the split, the in-plane velocity jump across the layer for the weak +plane. Junction glue in 3-D remains `damage_yield`'s tubes about the +junction segments — `junction_cells` is the 2-D ribbon rule and +refuses in 3-D. Interior networks only: a band that reaches the +domain boundary is refused loudly (its embedded mid-surface cannot +yet be clipped against the boundary). + +v1 scope, refused loudly outside it: planar patches (the +`rim_polygon` contract), convex rims, genuine X crossings (a +near-miss — close but not crossing — is refused rather than guessed +at). Multi-fault networks split and solve in parallel: `split_faults` +redistributes ONCE, keyed on the union of the network's facets, and +every split then runs with serial topology. Either realisation runs +its velocity block on the geometric multigrid tail the band's base +mesh owns (`build` adopts it on the final mesh; `net.solve` says so +when a solve falls back to algebraic multigrid). Placement is +gather-first, so the cells gmsh fills into the carved cavity — the +band and its graded surround — live on one rank; only the base's far +field is balanced. On the crossing-patches fixture that is 8012 of +8405 cells on one rank at np=4, and the solve is not faster than +serial: parallel is a correctness mode for this path, not a speed-up, +until the placed region is rebalanced. + +## Limitations + +- 3-D: planar convex patches, X crossings only (above). Finite-width + bands are interior-only (an outcropping band is refused), and + `junction_cells` is the 2-D ribbon rule — 3-D junction glue is + `damage_yield`'s tubes. +- One damage dial per network in `damage_yield` (per-junction values: + build the expression with `uw.meshing.damage_zone_yield` directly). +- Time-dependent damage (wear-in/healing) is study-level for now: see + `~/+Simulations/fault_junction_rheology/damage_switch.py` for the + pattern (per-fault scalar states driven by each fault's slip rate). diff --git a/docs/advanced/figures/branch_maps_z20.png b/docs/advanced/figures/branch_maps_z20.png new file mode 100644 index 000000000..455a574d3 Binary files /dev/null and b/docs/advanced/figures/branch_maps_z20.png differ diff --git a/docs/advanced/figures/branch_slip_z20.png b/docs/advanced/figures/branch_slip_z20.png new file mode 100644 index 000000000..7775a5f23 Binary files /dev/null and b/docs/advanced/figures/branch_slip_z20.png differ diff --git a/docs/advanced/figures/branch_stress_matrix.png b/docs/advanced/figures/branch_stress_matrix.png new file mode 100644 index 000000000..204e77c05 Binary files /dev/null and b/docs/advanced/figures/branch_stress_matrix.png differ diff --git a/docs/advanced/figures/crossing_map.png b/docs/advanced/figures/crossing_map.png new file mode 100644 index 000000000..7e5f411b4 Binary files /dev/null and b/docs/advanced/figures/crossing_map.png differ diff --git a/docs/advanced/figures/crossing_weakening.png b/docs/advanced/figures/crossing_weakening.png new file mode 100644 index 000000000..ee8dcd615 Binary files /dev/null and b/docs/advanced/figures/crossing_weakening.png differ diff --git a/docs/advanced/figures/damage_control_compare.png b/docs/advanced/figures/damage_control_compare.png new file mode 100644 index 000000000..104160871 Binary files /dev/null and b/docs/advanced/figures/damage_control_compare.png differ diff --git a/docs/advanced/figures/fault-examples/.gitignore b/docs/advanced/figures/fault-examples/.gitignore new file mode 100644 index 000000000..04a1f04a5 --- /dev/null +++ b/docs/advanced/figures/fault-examples/.gitignore @@ -0,0 +1,2 @@ +_*.png +*.log diff --git a/docs/advanced/figures/fault-examples/_california_probes.npz b/docs/advanced/figures/fault-examples/_california_probes.npz new file mode 100644 index 000000000..6c0bef08f Binary files /dev/null and b/docs/advanced/figures/fault-examples/_california_probes.npz differ diff --git a/docs/advanced/figures/fault-examples/_interacting_probes.npz b/docs/advanced/figures/fault-examples/_interacting_probes.npz new file mode 100644 index 000000000..e0a3b4c70 Binary files /dev/null and b/docs/advanced/figures/fault-examples/_interacting_probes.npz differ diff --git a/docs/advanced/figures/fault-examples/_mohr_cohesion_probes.npz b/docs/advanced/figures/fault-examples/_mohr_cohesion_probes.npz new file mode 100644 index 000000000..85e285682 Binary files /dev/null and b/docs/advanced/figures/fault-examples/_mohr_cohesion_probes.npz differ diff --git a/docs/advanced/figures/fault-examples/_mohr_friction_probes.npz b/docs/advanced/figures/fault-examples/_mohr_friction_probes.npz new file mode 100644 index 000000000..d6fd0213a Binary files /dev/null and b/docs/advanced/figures/fault-examples/_mohr_friction_probes.npz differ diff --git a/docs/advanced/figures/fault-examples/_mohr_graded_probes.npz b/docs/advanced/figures/fault-examples/_mohr_graded_probes.npz new file mode 100644 index 000000000..95ff368ed Binary files /dev/null and b/docs/advanced/figures/fault-examples/_mohr_graded_probes.npz differ diff --git a/docs/advanced/figures/fault-examples/_mohr_probes.npz b/docs/advanced/figures/fault-examples/_mohr_probes.npz new file mode 100644 index 000000000..129bd50ff Binary files /dev/null and b/docs/advanced/figures/fault-examples/_mohr_probes.npz differ diff --git a/docs/advanced/figures/fault-examples/branching-compare.png b/docs/advanced/figures/fault-examples/branching-compare.png new file mode 100644 index 000000000..58deb39fa Binary files /dev/null and b/docs/advanced/figures/fault-examples/branching-compare.png differ diff --git a/docs/advanced/figures/fault-examples/branching.png b/docs/advanced/figures/fault-examples/branching.png new file mode 100644 index 000000000..67c4ca9c3 Binary files /dev/null and b/docs/advanced/figures/fault-examples/branching.png differ diff --git a/docs/advanced/figures/fault-examples/branching.py b/docs/advanced/figures/fault-examples/branching.py new file mode 100644 index 000000000..f55ac9405 --- /dev/null +++ b/docs/advanced/figures/fault-examples/branching.py @@ -0,0 +1,178 @@ +"""A branching rupture: intersecting fault traces and their Delta CFF. + +The raw geometry INTERSECTS: a dextral trunk, a splay branching off its +midpoint at ~30 degrees (a T junction), and a conjugate fault crossing +it outright (an X junction). ``prepare_fault_network`` converts both to +offset-junction form automatically (angle-corrected ligaments, loudly), +which is what makes the set splittable at all. The trunk and the splay +then rupture TOGETHER (frictionless) while the conjugate is welded as a +receiver, and the map shows Delta CFF on trunk-parallel planes — with +zooms at the two junctions, where the ligament-scale stress transfer +lives. Measured ligament sensitivity for this representation: +~/+Simulations/fault_junction_ligament/ (the branch response is +converged for a given plug size; ligaments of 1-2 local cells). +""" +import os +import time + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +import pyvista as pv + +import underworld3 as uw +from underworld3.meshing.surfaces import prepare_fault_network +from underworld3.utilities import fault_contact + +import common + +pv.OFF_SCREEN = True +D = os.path.dirname(os.path.abspath(__file__)) +MU_P = 0.4 +H = 0.012 +LIG_F = 1.5 + +# raw, INTERSECTING traces — the preparer makes them legal +TREND = np.degrees(np.arctan2(0.10, 0.70)) # trunk trend ~8 deg +M_RAW = ("Trunk", np.array([[0.15, 0.45], [0.85, 0.55]])) +S_RAW = ("Splay", np.array([[0.50, 0.50], [0.80, 0.76]])) # T at trunk +C_RAW = ("Conj", np.array([[0.30, 0.33], [0.42, 0.64]])) # X crossing + +prepared, report = prepare_fault_network( + [M_RAW, S_RAW, C_RAW], spacing=H, ligament=LIG_F, verbose=True) +names = [n for n, _ in prepared] +SLIP = [n for n in names if n.startswith(("Trunk", "Splay"))] +WELD = [n for n in names if n.startswith("Conj")] +ETA_WELD = 200.0 * common.ETA / 0.2 + +T_J = np.array([0.50, 0.50]) # the T junction +X_J = M_RAW[1][0] + (M_RAW[1][1] - M_RAW[1][0]) * 0.293 # approx X point + + +def solve_state(child, free): + stokes = common.stokes_on(child, + common.boundary_simple_shear(child, TREND)) + for n in names: + stokes.add_fault_bc(0.0 if (free and n in SLIP) else ETA_WELD, + boundary=n) + fault_contact.solve_with_fault(stokes, picard=2) + x, y = child.X + v, p = stokes.Unknowns.u, stokes.Unknowns.p + comps = {} + for cname, expr in ( + ("sxx", -p.sym[0] + 2 * common.ETA * v.sym[0].diff(x)), + ("syy", -p.sym[0] + 2 * common.ETA * v.sym[1].diff(y)), + ("sxy", common.ETA * (v.sym[0].diff(y) + v.sym[1].diff(x)))): + s_var = uw.discretisation.MeshVariable( + f"{cname}_{'a' if free else 'b'}", child, 1, degree=0, + continuous=False) + proj = uw.systems.Projection(child, s_var) + proj.uw_function = expr + proj.smoothing = 0.0 + proj.solve() + row = common.split_mesh_cell_rows(child, s_var) + comps[cname] = np.asarray(s_var.data[:, 0])[row].copy() + return stokes, comps + + +t0 = time.perf_counter() +child = common.base_mesh(H).add_fault(prepared) +s1, c1 = solve_state(child, free=True) +print(f"[timing] slipping solve: {time.perf_counter() - t0:.1f} s", + flush=True) +for n in SLIP: + coords, jumps, normals = fault_contact.fault_pair_jumps( + s1, n, s1._rotated_freeslip_info) + tang = np.column_stack([-normals[:, 1], normals[:, 0]]) + V = np.einsum("ij,ij->i", jumps, tang) + print(f" {n:8s} peak slip {np.abs(V).max():.4f}", flush=True) +t0 = time.perf_counter() +_s0, c0 = solve_state(child, free=False) +print(f"[timing] welded solve: {time.perf_counter() - t0:.1f} s", + flush=True) + +# Delta CFF on trunk-parallel receiver planes +beta = np.radians(TREND) +nx, ny = -np.sin(beta), np.cos(beta) +tx, ty = np.cos(beta), np.sin(beta) + + +def resolve(c): + s_nn = c["sxx"] * nx * nx + 2 * c["sxy"] * nx * ny + c["syy"] * ny * ny + s_t = (c["sxx"] * tx * nx + c["sxy"] * (tx * ny + ty * nx) + + c["syy"] * ty * ny) + return s_nn, s_t + + +nn0, t_0 = resolve(c0) +nn1, t_1 = resolve(c1) +tau_dir = np.sign(np.median(t_0)) +dcff = tau_dir * (t_1 - t_0) + MU_P * (nn1 - nn0) + +pts, faces = common.split_mesh_cell_render(child) +fc = np.asarray(faces).reshape(-1, 4)[:, 1:] +cent = np.asarray(pts)[fc].mean(axis=1) +dcff, gauge = common.far_field_anchor( + cent, dcff, [p for _n, p in prepared], cut=0.18) +print(f"far-field gauge removed: {gauge:+.4f}", flush=True) + +# ---- renders: the map and the two junction zooms --------------------------- +COLOUR = {"Trunk": "black", "Splay": "black", "Conj": "#6a1b9a"} + + +def render(png, scale, focal): + pvm = pv.PolyData(np.asarray(pts, dtype=float), + faces=np.asarray(faces, dtype=np.int64)) + pvm.cell_data["dcff"] = dcff + pl = pv.Plotter(off_screen=True, window_size=(900, 850)) + pl.set_background("white") + pl.add_mesh(pvm, scalars="dcff", cmap="RdBu_r", clim=(-1.0, 1.0), + lighting=False, show_scalar_bar=False) + for n, p in prepared: + line = pv.lines_from_points( + np.column_stack([p, np.full(len(p), 1e-3)])) + pl.add_mesh(line, color=COLOUR[n.split("_")[0]], + line_width=5 if n.startswith("Trunk") else 4, + lighting=False) + pl.view_xy() + pl.camera.parallel_projection = True + pl.camera.parallel_scale = scale + pl.camera.focal_point = (focal[0], focal[1], 0.0) + pl.screenshot(png) + pl.close() + return png + + +map_png = render(os.path.join(D, "_branching_map.png"), 0.42, (0.5, 0.52)) +tz_png = render(os.path.join(D, "_branching_tzoom.png"), 0.075, T_J) +xz_png = render(os.path.join(D, "_branching_xzoom.png"), 0.075, X_J) + +fig = plt.figure(figsize=(12.6, 6.4)) +gs = fig.add_gridspec(2, 3, width_ratios=[2.1, 1.0, 0.05]) +axm = fig.add_subplot(gs[:, 0]) +axm.imshow(plt.imread(map_png)) +axm.set_xticks([]) +axm.set_yticks([]) +axm.set_title(r"$\Delta$CFF on trunk-parallel planes ($\mu' = 0.4$): " + "trunk + splay rupture together,\nconjugate welded; " + "junctions are auto-converted offset ligaments", fontsize=9.5) +for row, (png, label) in enumerate( + ((tz_png, "the branch point (T): splay fed through the ligament"), + (xz_png, "the crossing (X): four tips, one intact plug"))): + ax = fig.add_subplot(gs[row, 1]) + ax.imshow(plt.imread(png)) + ax.set_xticks([]) + ax.set_yticks([]) + ax.set_title(label, fontsize=8.5) + +from matplotlib import cm, colors as mcolors +sm = cm.ScalarMappable(norm=mcolors.Normalize(-1, 1), cmap="RdBu_r") +cax = fig.add_subplot(gs[:, 2]) +fig.colorbar(sm, cax=cax, label=r"$\Delta$CFF (per unit stress drop)") +fig.suptitle("A branching rupture on an intersecting fault network", + fontsize=11.5) +fig.tight_layout() +out = os.path.join(D, "branching.png") +fig.savefig(out, dpi=200) +print("wrote", out, flush=True) diff --git a/docs/advanced/figures/fault-examples/branching_compare.py b/docs/advanced/figures/fault-examples/branching_compare.py new file mode 100644 index 000000000..6f59e7d72 --- /dev/null +++ b/docs/advanced/figures/fault-examples/branching_compare.py @@ -0,0 +1,205 @@ +"""What does interrupting the trunk cost? Continuous vs cut at the +junction, against the isolated trunk. + +Same network as ``branching.py`` (trunk + splay slipping, conjugate +welded), prepared two ways: + +- ``through=["Trunk"]`` — the trunk is the MASTER: never cut; the + conjugate yields on both sides of the crossing and the splay is + trimmed where it abuts (T junctions never cut the through-going + trace). +- default — the X crossing cuts both traces, so the trunk is + interrupted mid-length by the junction plug. + +The isolated continuous trunk (no other faults) is the reference +profile. The comparison answers: how much slip does the interruption +forfeit, and what does it do to the Delta CFF field? +""" +import os +import time + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +import pyvista as pv + +import underworld3 as uw +from underworld3.meshing.surfaces import prepare_fault_network +from underworld3.utilities import fault_contact + +import common + +pv.OFF_SCREEN = True +D = os.path.dirname(os.path.abspath(__file__)) +MU_P = 0.4 +H = 0.012 +LIG_F = 1.5 +TREND = np.degrees(np.arctan2(0.10, 0.70)) +M_RAW = ("Trunk", np.array([[0.15, 0.45], [0.85, 0.55]])) +S_RAW = ("Splay", np.array([[0.50, 0.50], [0.80, 0.76]])) +C_RAW = ("Conj", np.array([[0.30, 0.33], [0.42, 0.64]])) +T_HAT = (M_RAW[1][1] - M_RAW[1][0]) +T_HAT = T_HAT / np.linalg.norm(T_HAT) +ETA_WELD = 200.0 * common.ETA / 0.2 + + +def run_case(tag, faults, weld_names, want_field): + child = common.base_mesh(H).add_fault(faults) + names = [n for n, _ in faults] + + def one_state(free): + stokes = common.stokes_on( + child, common.boundary_simple_shear(child, TREND)) + for n in names: + stokes.add_fault_bc( + 0.0 if (free and n not in weld_names) else ETA_WELD, + boundary=n) + fault_contact.solve_with_fault(stokes, picard=2) + return stokes + + t0 = time.perf_counter() + s1 = one_state(True) + # trunk slip vs GLOBAL trunk arc length, across however many pieces + ss, vv = [], [] + for n in names: + if not n.startswith("Trunk"): + continue + coords, jumps, normals = fault_contact.fault_pair_jumps( + s1, n, s1._rotated_freeslip_info) + tang = np.column_stack([-normals[:, 1], normals[:, 0]]) + ss.append((coords - M_RAW[1][0]) @ T_HAT) + vv.append(np.abs(np.einsum("ij,ij->i", jumps, tang))) + s_all = np.concatenate(ss) + v_all = np.concatenate(vv) + order = np.argsort(s_all) + profile = (s_all[order], v_all[order]) + + dcff_pack = None + if want_field: + def stress(stokes, tagc): + x, y = child.X + v, p = stokes.Unknowns.u, stokes.Unknowns.p + comps = {} + for cname, expr in ( + ("sxx", -p.sym[0] + 2 * common.ETA * v.sym[0].diff(x)), + ("syy", -p.sym[0] + 2 * common.ETA * v.sym[1].diff(y)), + ("sxy", common.ETA * (v.sym[0].diff(y) + + v.sym[1].diff(x)))): + s_var = uw.discretisation.MeshVariable( + f"{cname}_{tagc}", child, 1, degree=0, + continuous=False) + proj = uw.systems.Projection(child, s_var) + proj.uw_function = expr + proj.smoothing = 0.0 + proj.solve() + row = common.split_mesh_cell_rows(child, s_var) + comps[cname] = np.asarray(s_var.data[:, 0])[row].copy() + return comps + + c1 = stress(s1, "a") + s0 = one_state(False) + c0 = stress(s0, "b") + beta = np.radians(TREND) + nx, ny = -np.sin(beta), np.cos(beta) + tx, ty = np.cos(beta), np.sin(beta) + + def resolve(c): + s_nn = (c["sxx"] * nx * nx + 2 * c["sxy"] * nx * ny + + c["syy"] * ny * ny) + s_t = (c["sxx"] * tx * nx + c["sxy"] * (tx * ny + ty * nx) + + c["syy"] * ty * ny) + return s_nn, s_t + + nn0, t_0 = resolve(c0) + nn1, t_1 = resolve(c1) + dcff = np.sign(np.median(t_0)) * (t_1 - t_0) + MU_P * (nn1 - nn0) + pts, faces = common.split_mesh_cell_render(child) + fc = np.asarray(faces).reshape(-1, 4)[:, 1:] + cent = np.asarray(pts)[fc].mean(axis=1) + dcff, gauge = common.far_field_anchor( + cent, dcff, [p for _n, p in faults], cut=0.18) + print(f"[{tag}] gauge {gauge:+.4f}", flush=True) + dcff_pack = (pts, faces, dcff) + + print(f"[{tag}] trunk peak slip {profile[1].max():.4f} " + f"({time.perf_counter() - t0:.1f} s)", flush=True) + return profile, dcff_pack, faults + + +# case i: trunk through-going (master) +prep_i, _ = prepare_fault_network( + [(n, p.copy()) for n, p in (M_RAW, S_RAW, C_RAW)], spacing=H, + ligament=LIG_F, through=["Trunk"], verbose=True) +weld_i = [n for n, _p in prep_i if n.startswith("Conj")] +prof_i, field_i, faults_i = run_case("through", prep_i, weld_i, True) + +# case ii: default — the crossing cuts the trunk +prep_ii, _ = prepare_fault_network( + [(n, p.copy()) for n, p in (M_RAW, S_RAW, C_RAW)], spacing=H, + ligament=LIG_F, verbose=True) +weld_ii = [n for n, _p in prep_ii if n.startswith("Conj")] +prof_ii, field_ii, faults_ii = run_case("cut", prep_ii, weld_ii, True) + +# reference: the trunk alone +prof_ref, _, _ = run_case("isolated", [("Trunk", M_RAW[1].copy())], + [], False) + + +def render(pack, faults, png): + pts, faces, dcff = pack + pvm = pv.PolyData(np.asarray(pts, dtype=float), + faces=np.asarray(faces, dtype=np.int64)) + pvm.cell_data["dcff"] = dcff + pl = pv.Plotter(off_screen=True, window_size=(900, 850)) + pl.set_background("white") + pl.add_mesh(pvm, scalars="dcff", cmap="RdBu_r", clim=(-1.0, 1.0), + lighting=False, show_scalar_bar=False) + for n, p in faults: + line = pv.lines_from_points( + np.column_stack([p, np.full(len(p), 1e-3)])) + pl.add_mesh(line, color=("#6a1b9a" if n.startswith("Conj") + else "black"), + line_width=5 if n.startswith("Trunk") else 4, + lighting=False) + pl.view_xy() + pl.camera.parallel_projection = True + pl.camera.parallel_scale = 0.42 + pl.camera.focal_point = (0.5, 0.52, 0.0) + pl.screenshot(png) + pl.close() + return png + + +png_i = render(field_i, faults_i, os.path.join(D, "_bcmp_through.png")) +png_ii = render(field_ii, faults_ii, os.path.join(D, "_bcmp_cut.png")) + +fig = plt.figure(figsize=(13.0, 5.4)) +gs = fig.add_gridspec(1, 4, width_ratios=[1.35, 1.0, 1.0, 0.05]) +axp = fig.add_subplot(gs[0, 0]) +axp.plot(*prof_ref, "k--", lw=1.3, label="trunk alone (continuous)") +axp.plot(*prof_i, "o-", ms=2.5, lw=1.0, color="#1565c0", + label="network, trunk through-going") +axp.plot(*prof_ii, "o-", ms=2.5, lw=1.0, color="#e65100", + label="network, trunk cut at the crossing") +axp.set_xlabel("arc length along the trunk") +axp.set_ylabel("|slip|") +axp.set_title("what the interruption costs the trunk", fontsize=10) +axp.legend(fontsize=8) +for png, col, title in ((png_i, 1, "trunk through-going"), + (png_ii, 2, "trunk cut at the crossing")): + ax = fig.add_subplot(gs[0, col]) + ax.imshow(plt.imread(png)) + ax.set_xticks([]) + ax.set_yticks([]) + ax.set_title(title, fontsize=10) +from matplotlib import cm, colors as mcolors +sm = cm.ScalarMappable(norm=mcolors.Normalize(-1, 1), cmap="RdBu_r") +cax = fig.add_subplot(gs[0, 3]) +fig.colorbar(sm, cax=cax, label=r"$\Delta$CFF") +fig.suptitle("Continuous vs interrupted: the same network, two " + "junction policies", fontsize=11.5) +fig.tight_layout() +out = os.path.join(D, "branching-compare.png") +fig.savefig(out, dpi=200) +print("wrote", out, flush=True) diff --git a/docs/advanced/figures/fault-examples/california.png b/docs/advanced/figures/fault-examples/california.png new file mode 100644 index 000000000..cec34eec6 Binary files /dev/null and b/docs/advanced/figures/fault-examples/california.png differ diff --git a/docs/advanced/figures/fault-examples/california.py b/docs/advanced/figures/fault-examples/california.py new file mode 100644 index 000000000..953714bcd --- /dev/null +++ b/docs/advanced/figures/fault-examples/california.py @@ -0,0 +1,334 @@ +"""A schematic southern California: the San Andreas with a smooth +restraining bend (the Big Bend), read by its neighbours. + +Geography (schematic, not to scale, x = east / y = north): the SAN +ANDREAS is ONE continuous dextral trace with a smooth tanh S-bend — the +smoothed version of a left-stepping stepover. Under right-lateral shear +the bend is RESTRAINING: the bend zone is where the TRANSVERSE RANGES +belong, right beside the Garlock. The GARLOCK (resolving sinistral — +the real sense, from the kinematics), three EAST CALIFORNIA SHEAR ZONE +strands and a SAN JACINTO-like fault sit inboard as welded probes. + +The curved trace is sampled as a polyline, and the fault's constraint +frame uses the curve's ANALYTIC normal (``add_fault_bc(normal=...)``). +Without it the per-node normal averages the adjacent facet normals and +zig-zags at the sampling kinks; the no-opening constraint then forbids +smooth slip past each kink — sawtooth tractions that GROW under mesh +refinement (measured: ~/+Simulations/curved_fault_roughness/). With the +analytic normal the polyline behaves as the smooth fault it represents. + +The San Andreas slips freely under right-lateral simple shear parallel +to the plate-boundary trend; the slip half-arrows on the map come from +the MEASURED jump. Field: Delta CFF on boundary-parallel planes, P0 +(cell) stress — continuous-P1 projection of the rough near-fault stress +rings at the node scale (measured) — rendered on the split mesh's true +connectivity. +""" +import os +import time + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +import sympy +import pyvista as pv + +import underworld3 as uw +from underworld3.utilities import fault_contact + +import common + +pv.OFF_SCREEN = True +D = os.path.dirname(os.path.abspath(__file__)) +MU_P = 0.4 +TAU0 = 1.0 +TREND = 132.0 # plate-boundary trend (~N42W), degrees CCW of E +ETA_WELD = 200.0 * common.ETA / 0.2 +P0 = 1.0 +COH = 0.75 + +# One continuous dextral trace with a smooth restraining bend: the +# centreline runs along the trend from A, offset by a tanh step of +# BEND_W toward the SW (the CCW normal) — an S in map view, stepping +# LEFT, so right-lateral slip must converge through the bend. Max +# deviation from the trend: atan(BEND_W / (2 LAM)) ~ 30 degrees, about +# the real Big Bend. +_A = np.array([0.88, 0.06]) +_t = np.array([np.cos(np.radians(TREND)), np.sin(np.radians(TREND))]) +_n = np.array([-_t[1], _t[0]]) # CCW normal (points SW) +S_END = 0.94 +BEND_W = 0.07 # total SW offset +BEND_S0 = 0.45 # bend centre, arc parameter +LAM = 0.06 # bend half-width + + +def _w(s): + return 0.5 * BEND_W * (1.0 + np.tanh((s - BEND_S0) / LAM)) + + +def saf_trace(n_seg=47): + """The smooth trace sampled as a polyline (kinks land on mesh + vertices; the analytic normal makes them harmless).""" + s = np.linspace(0.0, S_END, n_seg + 1) + return _A + np.outer(s, _t) + np.outer(_w(s), _n) + + +def saf_normal(child): + """The EXACT unit-normal direction of the smooth trace as a sympy + row matrix in mesh coordinates: with X(s) = A + s t + w(s) n and + s(X) = (X - A).t, the tangent is t + w'(s) n and the normal its + quarter turn, n - w'(s) t (normalisation is the caller's).""" + x, y = child.X + s = (x - _A[0]) * _t[0] + (y - _A[1]) * _t[1] + wp = (0.5 * BEND_W / LAM) * (1 - sympy.tanh((s - BEND_S0) / LAM) ** 2) + return sympy.Matrix([[_n[0] - wp * _t[0], _n[1] - wp * _t[1]]]) + + +SAF_PTS = saf_trace() + +MINORS = { + "Garlock": np.array([[0.52, 0.44], [0.80, 0.53]]), + "E1": np.array([[0.62, 0.64], [0.56, 0.78]]), + "E2": np.array([[0.72, 0.62], [0.66, 0.76]]), + "E3": np.array([[0.81, 0.68], [0.75, 0.82]]), + "SJF": np.array([[0.84, 0.24], [0.70, 0.38]]), +} +GROUPS = (("Garlock (sinistral)", ("Garlock",), "#6a1b9a"), + ("ECSZ, 3 strands (dextral)", ("E1", "E2", "E3"), "#1a6b1a"), + ("San Jacinto (dextral)", ("SJF",), "#e65100")) +COLOUR = {"Garlock": "#6a1b9a", "E1": "#1a6b1a", "E2": "#1a6b1a", + "E3": "#1a6b1a", "SJF": "#e65100"} + + +def build_and_solve(trunk_free): + faults = [("SAF", SAF_PTS)] + [(k, v) for k, v in MINORS.items()] + child = common.base_mesh(0.012).add_fault(faults) + stokes = common.stokes_on(child, + common.boundary_simple_shear(child, TREND, + TAU0)) + # the analytic normal in BOTH states, so the slipping and welded + # solves share one constraint frame and difference cleanly + stokes.add_fault_bc(0 if trunk_free else ETA_WELD, boundary="SAF", + normal=saf_normal(child)) + for k in MINORS: + stokes.add_fault_bc(ETA_WELD, boundary=k) + fault_contact.solve_with_fault(stokes, picard=2) + probes = {} + for k, pts in MINORS.items(): + _s, _xy, sig, tau = common.probe_nodes(stokes, k, pts[1] - pts[0], + ETA_WELD) + probes[k] = (sig, tau) + return child, stokes, probes + + +def stress_components(child, stokes, tag): + x, y = child.X + v = stokes.Unknowns.u + p = stokes.Unknowns.p + exprs = dict( + sxx=-p.sym[0] + 2 * common.ETA * v.sym[0].diff(x), + syy=-p.sym[0] + 2 * common.ETA * v.sym[1].diff(y), + sxy=common.ETA * (v.sym[0].diff(y) + v.sym[1].diff(x))) + out = {} + for name, expr in exprs.items(): + # P0 (cell) stress: projecting the rough near-fault stress onto + # CONTINUOUS P1 rings at the node scale (measured: residual rms + # 0.26 at half-wavelength h/2); cellwise averages are honest and + # pixel-scale at this resolution. + s_var = uw.discretisation.MeshVariable(f"{name}_{tag}", child, 1, + degree=0, continuous=False) + proj = uw.systems.Projection(child, s_var) + proj.uw_function = expr + proj.smoothing = 0.0 + proj.solve() + row = common.split_mesh_cell_rows(child, s_var) + out[name] = np.asarray(s_var.data[:, 0])[row].copy() + return out + + +cache = os.path.join(D, "_california_probes.npz") +if os.path.exists(cache): + data = dict(np.load(cache, allow_pickle=True)) + print("loaded cached run") +else: + t_wall = time.perf_counter() + child, s1, probes1 = build_and_solve(trunk_free=True) + print(f"[timing] slipping solve + probes: " + f"{time.perf_counter() - t_wall:.1f} s") + t_wall = time.perf_counter() + s_saf, V_saf = common.slip_vs_position( + s1, _t, centre=_A + 0.47 * _t, name="SAF") + comp1 = stress_components(child, s1, "a") + + s0 = common.stokes_on(child, + common.boundary_simple_shear(child, TREND, + TAU0)) + s0.add_fault_bc(ETA_WELD, boundary="SAF", normal=saf_normal(child)) + for k in MINORS: + s0.add_fault_bc(ETA_WELD, boundary=k) + fault_contact.solve_with_fault(s0, picard=2) + comp0 = stress_components(child, s0, "b") + print(f"[timing] welded solve + all projections: " + f"{time.perf_counter() - t_wall:.1f} s") + probes0 = {} + for k, pts in MINORS.items(): + _s, _xy, sig, tau = common.probe_nodes(s0, k, pts[1] - pts[0], + ETA_WELD) + probes0[k] = (sig, tau) + + # Delta CFF on boundary-parallel planes (per cell) + beta = np.radians(TREND) + nx, ny = -np.sin(beta), np.cos(beta) + tx, ty = np.cos(beta), np.sin(beta) + + def resolve(c): + s_nn = (c["sxx"] * nx * nx + 2 * c["sxy"] * nx * ny + + c["syy"] * ny * ny) + s_t = (c["sxx"] * tx * nx + c["sxy"] * (tx * ny + ty * nx) + + c["syy"] * ty * ny) + return s_nn, s_t + + nn0, t0 = resolve(comp0) + nn1, t1 = resolve(comp1) + tau_dir = np.sign(np.median(t0)) + _pts, _faces = common.split_mesh_cell_render(child) + # cell centroids, for the far-field gauge anchor + fc = np.asarray(_faces).reshape(-1, 4)[:, 1:] + _cent = np.asarray(_pts)[fc].mean(axis=1) + data = dict(field_dcff=tau_dir * (t1 - t0) + MU_P * (nn1 - nn0), + field_points=_pts, field_faces=_faces, + field_centroids=_cent, saf_v=V_saf) + for k in MINORS: + data[f"{k}_sig0"], data[f"{k}_tau0"] = probes0[k] + data[f"{k}_sig1"], data[f"{k}_tau1"] = probes1[k] + np.savez(cache, **data) + data = dict(np.load(cache, allow_pickle=True)) + +v_med = float(np.median(data["saf_v"])) +# dextral: with the tangent pointing NW and the split's Plus side on +# its LEFT (SW, the Pacific side), a POSITIVE jump (v+ - v-) along +t +# means the Pacific side moves NW relative to North America. +sense = "right-lateral" if v_med > 0 else "LEFT-LATERAL?!" +print(f"SAF slip: median tangential jump {v_med:+.3f} ({sense})") + +# ---- the field render ------------------------------------------------------ +dcff_field, GAUGE_C = common.far_field_anchor( + data["field_centroids"], data["field_dcff"], + [SAF_PTS[:26], SAF_PTS[24:]] + list(MINORS.values()), cut=0.18) +print(f"far-field gauge constant removed: {GAUGE_C:+.4f}") +pvm = pv.PolyData(np.asarray(data["field_points"], dtype=float), + faces=np.asarray(data["field_faces"], dtype=np.int64)) +pvm.cell_data["dcff"] = dcff_field +pl = pv.Plotter(off_screen=True, window_size=(1000, 950)) +pl.set_background("white") +pl.add_mesh(pvm, scalars="dcff", cmap="RdBu_r", clim=(-1.0, 1.0), + show_edges=False, lighting=False, + scalar_bar_args=dict(title="dCFF", color="black")) + + +def polyline(pts): + return pv.lines_from_points( + np.column_stack([pts, np.full(len(pts), 0.001)])) + + +pl.add_mesh(polyline(SAF_PTS), color="black", line_width=5.0, + lighting=False) +for k, pts in MINORS.items(): + pl.add_mesh(polyline(pts), color=COLOUR[k], line_width=4.0, + lighting=False) + +# measured slip sense, half-arrows either side of the southern leg +mid = _A + 0.20 * _t +sense_sign = np.sign(v_med) +for pm in (+1.0, -1.0): + base = mid + pm * 0.05 * _n - pm * sense_sign * 0.06 * _t + pl.add_arrows(np.array([np.append(base, 0.002)]), + np.array([np.append(pm * sense_sign * _t, 0.0)]), + mag=0.11, color="black") + +pl.add_point_labels( + np.array([[0.30, 0.44, 0.002], [0.86, 0.12, 0.002], + [0.68, 0.50, 0.002], [0.60, 0.82, 0.002], + [0.84, 0.20, 0.002], [0.40, 0.24, 0.002]]), + ["San Andreas (N)", "San Andreas (S)", "Garlock", "ECSZ", + "San Jacinto", "Transverse Ranges\n(restraining bend)"], + font_size=20, text_color="black", shape=None, always_visible=True, + show_points=False) + +pl.view_xy() +pl.camera.parallel_projection = True +pl.camera.parallel_scale = 0.48 +pl.camera.focal_point = (0.48, 0.44, 0.0) +field_png = os.path.join(D, "_california_field.png") +pl.screenshot(field_png) +pl.close() + +# ---- the figure: field + one Mohr panel per fault group -------------------- +fig = plt.figure(figsize=(13.2, 6.6)) +gs = fig.add_gridspec(3, 3, width_ratios=[2.6, 1.15, 0.06]) + +axf = fig.add_subplot(gs[:, 0]) +axf.imshow(plt.imread(field_png)) +axf.set_xticks([]) +axf.set_yticks([]) +axf.set_title(r"$\Delta$CFF on boundary-parallel planes ($\mu' = 0.4$);" + "\nthe San Andreas slips (right-lateral) through its " + "restraining bend, neighbours welded as probes\n" + "(schematic geometry, not to scale)", + fontsize=9) + +for row, (label, members, col) in enumerate(GROUPS): + ax = fig.add_subplot(gs[row, 1]) + ss = np.linspace(-0.4, P0 + 1.9, 80) + strength = np.maximum(COH + MU_P * ss, 0.0) + for sgn in (+1, -1): + ax.plot(ss, sgn * strength, "-", color="0.4", lw=0.9) + ax.fill_between(ss, strength, 2.6, color="#c62828", alpha=0.06, lw=0) + ax.fill_between(ss, -strength, -2.6, color="#c62828", alpha=0.06, + lw=0) + tt = np.linspace(0, 2 * np.pi, 150) + ax.plot(P0 + TAU0 * np.cos(tt), TAU0 * np.sin(tt), "-", color="0.88", + lw=0.7) + medians = [] + for k in members: + t_hat = MINORS[k][1] - MINORS[k][0] + c0 = float(np.median(data[f"{k}_sig0"]) + - common.ambient_sigma_n_simple(TREND, t_hat, TAU0)) + sig0 = data[f"{k}_sig0"] - c0 + tau0 = data[f"{k}_tau0"] + sig1 = data[f"{k}_sig1"] - c0 - GAUGE_C / MU_P + tau1 = data[f"{k}_tau1"] + tau_dir = np.sign(np.median(tau0)) + dcff = tau_dir * (tau1 - tau0) + MU_P * (sig1 - sig0) + medians.append(np.median(dcff)) + sc0, sc1 = P0 - sig0, P0 - sig1 + ax.scatter(sc0, tau0, s=9, facecolors="none", edgecolors="0.6", + linewidths=0.7) + for j in range(0, len(sc0), 3): + ax.annotate("", xytext=(sc0[j], tau0[j]), + xy=(sc1[j], tau1[j]), + arrowprops=dict(arrowstyle="->", lw=0.45, + color="0.6")) + pts = ax.scatter(sc1, tau1, c=dcff, cmap="RdBu_r", s=16, + vmin=-0.3, vmax=0.3, zorder=5, + edgecolors="0.3", linewidths=0.2) + ax.axhline(0, color="0.92", lw=0.5) + ax.set_aspect("equal") + ax.set_xlim(-0.4, P0 + 1.9) + ax.set_ylim(-1.7, 1.7) + ax.tick_params(labelsize=7) + for spine in ax.spines.values(): + spine.set_color(col) + spine.set_linewidth(1.8) + ax.set_title(f"{label}: median $\\Delta$CFF " + f"{np.median(medians):+.2f}", fontsize=8.5, color=col) + +cax = fig.add_subplot(gs[:, 2]) +fig.colorbar(pts, cax=cax, label=r"node $\Delta$CFF") +fig.suptitle("A San Andreas slip event (Big Bend), read by its " + "neighbours", fontsize=11.5) +fig.tight_layout() +out = os.path.join(D, "california.png") +fig.savefig(out, dpi=200) +print("wrote", out) diff --git a/docs/advanced/figures/fault-examples/common.py b/docs/advanced/figures/fault-examples/common.py new file mode 100644 index 000000000..d55abe74d --- /dev/null +++ b/docs/advanced/figures/fault-examples/common.py @@ -0,0 +1,326 @@ +"""Shared harness for the split-node fault teaching examples. + +One base mesh, re-faulted per case (the non-cumulative add_fault +pattern); P2 velocity / P0 discontinuous pressure per the fault +pressure-space ruling; slip and traction read through the DOF pairing. +Run inside the fault-split-node worktree env with its bin on PATH. +""" +import numpy as np + +import underworld3 as uw +from underworld3.utilities import fault_contact + +ETA = 1.0 +CENTRE = np.array([0.5, 0.5]) + + +def base_mesh(cell_size=0.04): + return uw.meshing.UnstructuredSimplexBox(cellSize=cell_size) + + +def fault_segment(angle_deg, half_length=0.2, centre=CENTRE): + t = np.array([np.cos(np.radians(angle_deg)), + np.sin(np.radians(angle_deg))]) + return np.array([centre - half_length * t, centre + half_length * t]) + + +def split_with_fault(mesh, points, name="Fault"): + return mesh.add_fault((name, points)) + + +def stokes_on(child, drive, name="Fault"): + """Stokes with the wall drive on all four walls; fault BC added by + the caller. Variable names are derived from the mesh instance so + repeated calls on fresh children never collide.""" + tag = f"f{stokes_on.counter}" + stokes_on.counter += 1 + v = uw.discretisation.MeshVariable(f"V_{tag}", child, child.dim, + degree=2) + p = uw.discretisation.MeshVariable(f"P_{tag}", child, 1, degree=0, + continuous=False) + stokes = uw.systems.Stokes(child, velocityField=v, pressureField=p) + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = ETA + stokes.bodyforce = [0.0, 0.0] + for wall in ("Bottom", "Top", "Left", "Right"): + stokes.add_dirichlet_bc(drive, wall) + # all-Dirichlet walls leave the pressure level to the solver unless + # the constant nullspace is declared — without it, two solves with + # different fault laws land on DIFFERENT gauges and any differenced + # quantity (Delta CFF above all) inherits a spurious constant + stokes.petsc_use_pressure_nullspace = True + stokes.tolerance = 1e-6 + return stokes + + +stokes_on.counter = 0 + + +def simple_shear(child, rate=1.0): + """v = (rate (y - 1/2), 0): sigma_xy = ETA * rate everywhere.""" + x, y = child.X + return (rate * (y - 0.5), 0.0) + + +def shear_plus_stretch(child, a=0.5, gamma=1.0): + """v = (a(x-c) + gamma(y-c), -a(y-c)): deviatoric stress + [[2 eta a, eta gamma], [eta gamma, -2 eta a]], Mohr radius + eta sqrt(4 a^2 + gamma^2).""" + x, y = child.X + return (a * (x - 0.5) + gamma * (y - 0.5), -a * (y - 0.5)) + + +def slip_profile(stokes, name="Fault"): + return fault_contact.fault_slip(stokes, name, + stokes._rotated_freeslip_info) + + +def normal_traction(stokes, name="Fault"): + return fault_contact.fault_normal_traction( + stokes, name, stokes._rotated_freeslip_info) + + +def inner(s, fraction=0.6): + """Mask selecting the central `fraction` of the along-fault range — + tip fields carry the crack singularity, the middle is the gauge.""" + lo = s.min() + (1 - fraction) / 2 * (s.max() - s.min()) + hi = s.max() - (1 - fraction) / 2 * (s.max() - s.min()) + return (s >= lo) & (s <= hi) + + +def slip_vs_position(stokes, tangent, centre=CENTRE, name="Fault"): + """(s, V) read through the pairing with EXACT positions: s is the + signed along-fault coordinate of each pair about the fault centre, + V the signed tangential jump. `fault_slip`'s arc-length origin sits + at the first PAIR, not the tip, so profiles built from it are offset + by half a node — this is the plotting-grade version.""" + coords, jumps, _normals = fault_contact.fault_pair_jumps( + stokes, name, stokes._rotated_freeslip_info) + t = np.asarray(tangent, dtype=float) + t = t / np.linalg.norm(t) + s = (coords - centre) @ t + V = jumps @ t + order = np.argsort(s) + return s[order], V[order] + + +def pure_shear_drive(child, phi_deg, tau0=1.0): + """Uniform pure shear with the COMPRESSION axis at ``phi_deg`` to x: + sigma' = tau0 (e_perp e_perp - e_phi e_phi), i.e. sigma'_xx = + -tau0 cos 2phi and sigma'_xy = -tau0 sin 2phi. (Check: compression + at phi = 45 deg gives sigma_xy = -tau0; a plane of strike + phi - 45 deg carries the full resolved shear.) Irrotational + velocity field, imposed as Dirichlet on all walls — rotating phi + rotates the whole regional stress field.""" + x, y = child.X + two_phi = 2.0 * np.radians(phi_deg) + exx = -tau0 * np.cos(two_phi) / (2.0 * ETA) + exy = -tau0 * np.sin(two_phi) / (2.0 * ETA) + return (exx * (x - 0.5) + exy * (y - 0.5), + exy * (x - 0.5) - exx * (y - 0.5)) + + +def boundary_simple_shear(child, trend_deg, rate=1.0): + """RIGHT-LATERAL simple shear parallel to a plate-boundary trend: + v = rate ((X - c) . n) t with t the boundary-parallel direction and + n its CCW normal — material on the +n side moves along +t, which an + observer on the fault sees moving to the RIGHT (dextral). Resolved + shear tau0 = ETA * rate on boundary-parallel planes.""" + x, y = child.X + t = np.array([np.cos(np.radians(trend_deg)), + np.sin(np.radians(trend_deg))]) + n = np.array([-t[1], t[0]]) + s = (x - 0.5) * n[0] + (y - 0.5) * n[1] + return (rate * s * t[0], rate * s * t[1]) + + +def ambient_sigma_n_simple(trend_deg, tangent, tau0=1.0): + """Analytic ambient normal stress (tension-positive) on a plane of + direction ``tangent`` under boundary_simple_shear(trend): + sigma_nn = -tau0 sin 2(theta - trend).""" + theta = np.degrees(np.arctan2(tangent[1], tangent[0])) + return -tau0 * np.sin(2.0 * np.radians(theta - trend_deg)) + + +def probe_nodes(stokes, name, tangent, eta_weld): + """Per-node stress probes on a WELDED fault: along-fault coordinate, + position, signed normal traction (tension-positive, as measured) and + signed shear traction from the weld's own law tau = eta_f V. + + Works with SEVERAL law-carrying faults on one mesh: the interface + assembler holds every fault's nodes, so this fault's are selected + through its OWN pairing (the plus points), never by position.""" + from underworld3.utilities.rotated_bc import _point_coord + + assembler = fault_contact._InterfaceAssembler(stokes, include=(name,)) + sig_all = assembler.nodal_normal_traction( + stokes, stokes._rotated_freeslip_info["reaction"]) + plus = set(stokes.mesh._fault_point_pairs[name].values()) + + dm = stokes.dm + dim = stokes.mesh.dim + csec = dm.getCoordinateSection() + cvec = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, dim) + v0, v1 = dm.getDepthStratum(0) + rows = [(np.asarray(_point_coord(dm, dim, cvec, csec, v0, v1, q)), + sig_all[k]) for q, k in assembler._points.items() + if q in plus] + xy_sig = np.array([r[0] for r in rows]) + sig = np.array([r[1] for r in rows]) + + t = np.asarray(tangent, dtype=float) + t = t / np.linalg.norm(t) + order_sig = np.argsort(xy_sig @ t) + xy_sig, sig = xy_sig[order_sig], sig[order_sig] + + coords, jumps, _normals = fault_contact.fault_pair_jumps( + stokes, name, stokes._rotated_freeslip_info) + s = coords @ t + order = np.argsort(s) + V = (jumps @ t)[order] + assert len(sig) == len(V), "pair-node sets disagree" + assert np.allclose(coords[order], xy_sig), "node ordering disagrees" + return (s[order], coords[order], sig, eta_weld * V) + + +def mohr_probe(theta, a_rate=0.5, gamma=1.0, eta_weld=None, + half_length=0.2, cell_size=0.04): + """One welded-fault stress probe: (sigma_n, tau_signed) at fault + angle `theta` under the shear_plus_stretch drive. The weld's own + law reads the shear traction (tau = eta_f V); the no-opening + reaction reads the normal traction.""" + if eta_weld is None: + eta_weld = 200.0 * ETA / half_length + child = split_with_fault(base_mesh(cell_size), + fault_segment(theta, half_length)) + stokes = stokes_on(child, shear_plus_stretch(child, a_rate, gamma)) + stokes.add_fault_bc(eta_weld, boundary="Fault") + fault_contact.solve_with_fault(stokes, picard=2) + s, V, _leak = slip_profile(stokes) + s_n, sig = normal_traction(stokes) + tau = eta_weld * float(np.median(V[inner(s)])) + sigma_n = float(np.median(sig[inner(s_n)])) + return sigma_n, tau + + +def ambient_sigma_n(phi_deg, tangent, tau0=1.0): + """The analytic ambient normal stress (tension-positive) on a plane + of tangent direction ``tangent`` under pure_shear_drive(phi): + sigma_nn = tau0 cos 2(phi - theta). Used to anchor a probe set's + absolute pressure gauge, which a closed-box solve does not fix.""" + theta = np.degrees(np.arctan2(tangent[1], tangent[0])) + return tau0 * np.cos(2.0 * np.radians(phi_deg - theta)) + + +def far_field_anchor(points, dcff, segments, cut=0.3): + """Gauge the DIFFERENCED stress to the physics: a slip event changes + nothing far from the faults, so the far-field median of Delta CFF is + the spurious pressure-gauge constant between the two solves. Returns + (anchored dcff, the constant) — apply c / mu' to the probes' sigma.""" + p = np.asarray(points, dtype=float)[:, :2] + far = np.ones(len(p), dtype=bool) + for seg in segments: + a, b = np.asarray(seg[0]), np.asarray(seg[-1]) + t = b - a + L = np.linalg.norm(t) + t = t / L + s = np.clip((p - a) @ t, 0.0, L) + far &= np.linalg.norm(p - (a + s[:, None] * t), axis=1) > cut + c = float(np.median(np.asarray(dcff)[far])) + return np.asarray(dcff) - c, c + + +def signed_log(x, linthresh=0.02): + """Symmetric log transform for signed stress-change fields: linear + inside |x| < linthresh, logarithmic beyond — the near-fault values + saturate any linear colour scale and hide the far-field lobes.""" + x = np.asarray(x, dtype=float) + return np.sign(x) * np.log10(1.0 + np.abs(x) / linthresh) + + +def signed_log_annotations(values, linthresh=0.02): + """Scalar-bar tick positions/labels in transformed units.""" + return {float(np.sign(v) * np.log10(1.0 + abs(v) / linthresh)): + f"{v:+.2f}" if v else "0" for v in values} + + +def dedupe_average(points, values, decimals=9): + """Merge geometrically coincident points, averaging their values — + for RENDERING split-mesh fields only. The split mesh carries + coincident duplicated nodes along each fault; a Delaunay of the raw + cloud picks sides arbitrarily and paints alternating plus/minus + values along the trace (beading). Averaging collapses the jump in a + one-node strip that the drawn fault line covers.""" + pts = np.asarray(points, dtype=float) + vals = np.asarray(values, dtype=float) + key = np.round(pts[:, :2], decimals) + _uniq, inverse, counts = np.unique(key, axis=0, return_inverse=True, + return_counts=True) + sums = np.zeros(len(counts)) + np.add.at(sums, inverse, vals) + first = np.full(len(counts), -1, dtype=int) + for i, g in enumerate(inverse): + if first[g] < 0: + first[g] = i + return pts[first], sums / counts + + +def split_mesh_cell_render(child): + """Points (mesh vertices) + faces (true cell connectivity) for + rendering CELL data on a split mesh. Node-order mapping is trivial + (v - vS) because the points ARE the vertices; pair cell values via + split_mesh_cell_rows.""" + dm = child.dm + vS, vE = dm.getDepthStratum(0) + cS, cE = dm.getHeightStratum(0) + X = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, 2) + faces = [] + for c in range(cS, cE): + closure, _ = dm.getTransitiveClosure(c) + tri = [int(p) - vS for p in closure if vS <= int(p) < vE] + faces.extend([3, *tri]) + return (np.column_stack([X, np.zeros(len(X))]), + np.asarray(faces, dtype=np.int64)) + + +def split_mesh_cell_rows(child, var): + """Data-row index of a P0 variable for each cell, in plex cell + order (rank of the combined-section offsets).""" + dm = child.dm + sec = dm.getLocalSection() + cS, cE = dm.getHeightStratum(0) + offs = np.array([sec.getFieldOffset(c, var.field_id) + for c in range(cS, cE)]) + row = np.empty(len(offs), dtype=np.int64) + row[np.argsort(offs)] = np.arange(len(offs)) + return row + + +def split_mesh_faces(child, var): + """The mesh's own triangulation as a pyvista faces array, indexed in + ``var``'s DOF order (P1 vertex variable). Rendering a split-mesh + field through delaunay_2d of the DOF cloud is WRONG twice over: the + coincident fault pairs get arbitrary side-picking, and unconstrained + Delaunay edges hop across a curved trace — both paint beading along + the fault. The true connectivity keeps the two sheets separate and + renders the jump sharply.""" + dm = child.dm + sec = dm.getLocalSection() + vS, vE = dm.getDepthStratum(0) + cS, cE = dm.getHeightStratum(0) + fid = var.field_id + # getFieldOffset indexes the COMBINED local vector; the variable's + # own rows are the RANKS of those offsets (dof order = offset order) + offs = np.array([sec.getFieldOffset(v, fid) for v in range(vS, vE)]) + row = np.empty(len(offs), dtype=np.int64) + row[np.argsort(offs)] = np.arange(len(offs)) + X = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, 2) + assert np.allclose(np.asarray(var.coords)[row], X), \ + "variable DOF order does not match the offset ranking" + faces = [] + for c in range(cS, cE): + closure, _ = dm.getTransitiveClosure(c) + tri = [int(row[int(p) - vS]) for p in closure + if vS <= int(p) < vE] + faces.extend([3, *tri]) + return np.asarray(faces, dtype=np.int64) diff --git a/docs/advanced/figures/fault-examples/interacting-faults.png b/docs/advanced/figures/fault-examples/interacting-faults.png new file mode 100644 index 000000000..0720487f7 Binary files /dev/null and b/docs/advanced/figures/fault-examples/interacting-faults.png differ diff --git a/docs/advanced/figures/fault-examples/interacting-rotation.png b/docs/advanced/figures/fault-examples/interacting-rotation.png new file mode 100644 index 000000000..0498ad47b Binary files /dev/null and b/docs/advanced/figures/fault-examples/interacting-rotation.png differ diff --git a/docs/advanced/figures/fault-examples/interacting_faults.py b/docs/advanced/figures/fault-examples/interacting_faults.py new file mode 100644 index 000000000..7e4bc647e --- /dev/null +++ b/docs/advanced/figures/fault-examples/interacting_faults.py @@ -0,0 +1,251 @@ +"""Interacting faults, King-style: stress transfer read on the Mohr plane. + +Two en echelon faults (right-stepping, overlapping). The SOURCE slips +freely; the RECEIVER is welded — a passive per-node stress probe, the +instrument built in the Mohr examples. Two solves on the SAME mesh: + + (0) both faults welded -> the ambient regional state + (1) source slips, receiver weld -> the perturbed state + +The difference is the classical Coulomb stress transfer: + +- the FIELD: Delta CFF = tau_dir * d sigma_xy + mu' * d sigma_yy on + receiver-parallel planes (King's mu' = 0.4), the familiar lobes; +- ON the receiver: each node's probe MOVES in the (sigma, tau) plane, + and the displacement decomposes the CFF change into its shear and + normal(unclamping) parts — Coulomb stress transfer in the diagram + students already know how to read. + +Rotating the regional compression axis phi (the boundary velocities +rotate with it) changes the interaction pattern; the phi sweep shows +the receiver cloud pushed toward failure under one orientation and +away under another. +""" +import os +import time + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +import pyvista as pv + +import underworld3 as uw +import underworld3.visualisation as vis +from underworld3.utilities import fault_contact + +import common + +pv.OFF_SCREEN = True +D = os.path.dirname(os.path.abspath(__file__)) +MU_P = 0.4 # King's effective friction +TAU0 = 1.0 +ETA_WELD = 200.0 * common.ETA / 0.18 +PHIS = (20.0, 45.0, 70.0) # regional compression axis + +SOURCE = np.array([[0.22, 0.46], [0.58, 0.46]]) +# The receiver sits in the source's ALONG-STRIKE tip lobe, where the +# transfer is strongly positive at its near end and fades along it. +RECEIVER = np.array([[0.65, 0.44], [0.86, 0.44]]) +T_HAT = np.array([1.0, 0.0]) + +# The friction bookkeeping is dressed with a declared confining +# pressure and cohesion — NEITHER changes Delta CFF (both are +# constants under differencing), they only place the failure envelope +# where the teaching needs it: P0 shifts the whole circle into +# compression (no tensile regime anywhere), and the cohesive envelope +# tau = +-(C + mu' sigma) arcs over it with the phi = 45 receiver +# ambient sitting ~0.15 below the line, so the loaded nodes CROSS. +P0 = 1.0 +COH = 0.75 + + +def solve_pair(phi, source_free): + child = common.base_mesh(0.016).add_fault( + [("Source", SOURCE), ("Receiver", RECEIVER)]) + stokes = common.stokes_on(child, common.pure_shear_drive(child, phi, + TAU0)) + stokes.add_fault_bc(0 if source_free else ETA_WELD, boundary="Source") + stokes.add_fault_bc(ETA_WELD, boundary="Receiver") + fault_contact.solve_with_fault(stokes, picard=2) + s, xy, sig, tau = common.probe_nodes(stokes, "Receiver", T_HAT, + ETA_WELD) + return child, stokes, (s, xy, sig, tau) + + +def stress_fields(child, stokes): + """sigma_xy and sigma_yy (full stress, pressure included) projected + to continuous P1 — the components CFF on horizontal planes needs.""" + x, y = child.X + v = stokes.Unknowns.u + p = stokes.Unknowns.p + out = [] + for name, expr in (("sxy", common.ETA * (v.sym[0].diff(y) + + v.sym[1].diff(x))), + ("syy", -p.sym[0] + 2 * common.ETA + * v.sym[1].diff(y))): + # P0 cell stress: continuous-P1 projection rings at node scale + # near the fault (see california.py) + s_var = uw.discretisation.MeshVariable( + f"{name}_{stress_fields.counter}", child, 1, degree=0, + continuous=False) + proj = uw.systems.Projection(child, s_var) + proj.uw_function = expr + proj.smoothing = 0.0 + proj.solve() + row = common.split_mesh_cell_rows(child, s_var) + out.append(np.asarray(s_var.data[:, 0])[row].copy()) + stress_fields.counter += 1 + return out, s_var # arrays share the P1 layout + + +stress_fields.counter = 0 + +cache = os.path.join(D, "_interacting_probes.npz") +if os.path.exists(cache): + data = dict(np.load(cache, allow_pickle=True)) + print("loaded cached sweep") +else: + data = {} + for phi in PHIS: + t_wall = time.perf_counter() + _c0, s0, probe0 = solve_pair(phi, source_free=False) + child, s1, probe1 = solve_pair(phi, source_free=True) + data[f"probe0_{phi}"] = np.array(probe0[0]), probe0[1], probe0[2], \ + probe0[3] + data[f"p0_s_{phi}"], data[f"p0_xy_{phi}"] = probe0[0], probe0[1] + data[f"p0_sig_{phi}"], data[f"p0_tau_{phi}"] = probe0[2], probe0[3] + data[f"p1_sig_{phi}"], data[f"p1_tau_{phi}"] = probe1[2], probe1[3] + if phi == 45.0: + # the Delta CFF FIELD needs both solves' stress components on + # one mesh: redo the welded reference ON the slipping child + (sxy1, syy1), s_var = stress_fields(child, s1) + s0b = common.stokes_on(child, common.pure_shear_drive( + child, phi, TAU0)) + s0b.add_fault_bc(ETA_WELD, boundary="Source") + s0b.add_fault_bc(ETA_WELD, boundary="Receiver") + fault_contact.solve_with_fault(s0b, picard=2) + (sxy0, syy0), _ = stress_fields(child, s0b) + tau_dir = np.sign(np.median(data[f"p0_tau_{phi}"])) + dcff = tau_dir * (sxy1 - sxy0) + MU_P * (syy1 - syy0) + _pts, _faces = common.split_mesh_cell_render(child) + data["field_points"] = _pts + data["field_faces"] = _faces + data["field_dcff"] = dcff + print(f"phi {phi}: receiver ambient tau " + f"{np.median(data[f'p0_tau_{phi}']):+.3f}, sigma_n " + f"{np.median(data[f'p0_sig_{phi}']):+.3f} " + f"[{time.perf_counter() - t_wall:.1f} s]") + np.savez(cache, **{k: v for k, v in data.items() + if not k.startswith("probe0")}) + data = dict(np.load(cache, allow_pickle=True)) + +# ---- figure A: the field + the receiver's Mohr move (phi = 45) ----------- +# gauge the difference to the far field (see common.far_field_anchor) +_fc = np.asarray(data["field_faces"]).reshape(-1, 4)[:, 1:] +_cent = np.asarray(data["field_points"])[_fc].mean(axis=1) +dcff_field, GAUGE_C = common.far_field_anchor( + _cent, data["field_dcff"], (SOURCE, RECEIVER)) +print(f"far-field gauge constant removed: {GAUGE_C:+.4f}") +pvm = pv.PolyData(np.asarray(data["field_points"], dtype=float), + faces=np.asarray(data["field_faces"], dtype=np.int64)) +pvm.cell_data["dcff"] = dcff_field +pl = pv.Plotter(off_screen=True, window_size=(1050, 820)) +pl.set_background("white") +# linear colour scale with generous limits (the gate-study +# convention): pale far field, graded lobes +pl.add_mesh(pvm, scalars="dcff", cmap="RdBu_r", clim=(-1.0, 1.0), + show_edges=False, lighting=False, + scalar_bar_args=dict(title="dCFF", color="black")) +for pts, col, w in ((SOURCE, "black", 4.0), (RECEIVER, "#1a6b1a", 4.0)): + line = pv.Line(tuple(pts[0]) + (0.001,), tuple(pts[1]) + (0.001,)) + pl.add_mesh(line, color=col, line_width=w, lighting=False) +pl.view_xy() +pl.camera.parallel_projection = True +pl.camera.parallel_scale = 0.33 +pl.camera.focal_point = (0.5, 0.5, 0.0) +field_png = os.path.join(D, "_interacting_field.png") +pl.screenshot(field_png) +pl.close() + + +def mohr_panel(ax, phi, legend=False): + # anchor the welded probes to the analytic ambient for this drive, + # the after-probes to the far-field-anchored difference on top + c0 = float(np.median(data[f"p0_sig_{phi}"]) + - common.ambient_sigma_n(phi, T_HAT, TAU0)) + sig0 = data[f"p0_sig_{phi}"] - c0 + tau0 = data[f"p0_tau_{phi}"] + sig1 = data[f"p1_sig_{phi}"] - c0 - GAUGE_C / MU_P + tau1 = data[f"p1_tau_{phi}"] + tau_dir = np.sign(np.median(tau0)) + dcff = tau_dir * (tau1 - tau0) + MU_P * (sig1 - sig0) + # geo convention, with the declared confining pressure superposed + sc0, sc1 = P0 - sig0, P0 - sig1 + ss = np.linspace(-0.4, P0 + 1.8, 90) + strength = np.maximum(COH + MU_P * ss, 0.0) + for sgn in (+1, -1): + ax.plot(ss, sgn * strength, "-", color="0.35", lw=1.1, + label=(r"envelope $\tau = \pm(C + \mu'\sigma)$" + if sgn > 0 and legend else None)) + ax.fill_between(ss, strength, 2.4, color="#c62828", alpha=0.06, + lw=0) + ax.fill_between(ss, -strength, -2.4, color="#c62828", alpha=0.06, + lw=0) + tt = np.linspace(0, 2 * np.pi, 200) + ax.plot(P0 + TAU0 * np.cos(tt), TAU0 * np.sin(tt), "-", + color="0.85", lw=0.8, + label="regional circle" if legend else None) + ax.scatter(sc0, tau0, s=16, facecolors="none", edgecolors="0.55", + linewidths=1.0, + label="receiver nodes, before" if legend else None) + for k in range(0, len(sc0), 2): + ax.annotate("", xytext=(sc0[k], tau0[k]), xy=(sc1[k], tau1[k]), + arrowprops=dict(arrowstyle="->", lw=0.6, + color="0.65")) + pts = ax.scatter(sc1, tau1, c=dcff, cmap="RdBu_r", s=26, + vmin=-0.4, vmax=0.4, zorder=5, edgecolors="0.3", + linewidths=0.3, + label="after (colour: dCFF)" if legend else None) + ax.axhline(0, color="0.9", lw=0.5) + ax.set_xlim(-0.4, P0 + 1.8) + ax.set_ylim(-1.75, 1.75) + ax.set_aspect("equal") + ax.set_title(rf"$\phi = {phi:.0f}°$ " + rf"(median dCFF {np.median(dcff):+.2f})", fontsize=9) + ax.set_xlabel(r"$\sigma$ (compression +, $P_0 = 1$)", fontsize=8) + return pts + + +fig = plt.figure(figsize=(11.5, 4.9)) +axf = fig.add_subplot(1, 2, 1) +axf.imshow(plt.imread(field_png)) +axf.set_xticks([]) +axf.set_yticks([]) +axf.set_title(r"$\Delta$CFF field ($\mu' = 0.4$), source slips freely;" + "\nreceiver (green) welded as a probe", fontsize=9) +axm = fig.add_subplot(1, 2, 2) +pts = mohr_panel(axm, 45.0, legend=True) +axm.set_ylabel(r"$\tau$", fontsize=9) +axm.legend(fontsize=7, loc="lower right") +fig.colorbar(pts, ax=axm, label=r"node $\Delta$CFF", shrink=0.8) +fig.suptitle("En echelon interaction: the receiver's probes move in the " + "Mohr plane", fontsize=11) +fig.tight_layout() +out = os.path.join(D, "interacting-faults.png") +fig.savefig(out, dpi=200) +print("wrote", out) + +# ---- figure B: rotate the regional stress -------------------------------- +fig, axes = plt.subplots(1, len(PHIS), figsize=(11.5, 4.2), + sharey=True) +for ax, phi in zip(axes, PHIS): + pts = mohr_panel(ax, phi, legend=(phi == PHIS[0])) +axes[0].set_ylabel(r"$\tau$", fontsize=9) +axes[0].legend(fontsize=6.5, loc="lower right") +fig.colorbar(pts, ax=axes, label=r"node $\Delta$CFF", shrink=0.8) +fig.suptitle("Rotating the regional compression axis changes the " + "interaction", fontsize=11) +out = os.path.join(D, "interacting-rotation.png") +fig.savefig(out, dpi=200) +print("wrote", out) diff --git a/docs/advanced/figures/fault-examples/ladder.png b/docs/advanced/figures/fault-examples/ladder.png new file mode 100644 index 000000000..1b9292773 Binary files /dev/null and b/docs/advanced/figures/fault-examples/ladder.png differ diff --git a/docs/advanced/figures/fault-examples/ladder.py b/docs/advanced/figures/fault-examples/ladder.py new file mode 100644 index 000000000..8dd1d67b9 --- /dev/null +++ b/docs/advanced/figures/fault-examples/ladder.py @@ -0,0 +1,130 @@ +"""The fault-strength ladder: one fault, one drive, four laws. + +A horizontal fault under far-field simple shear (resolved shear stress +tau_infty = eta * rate = 1 on the fault plane), solved with each rung of +the constitutive ladder. Left: the slip profiles V(s), with the exact +zero-slip tips appended (positions read through the DOF pairing — the +generic fault_slip arc-length starts at the first pair, not the tip). +Right: the shear-stress field sigma_xy for each rung, stacked for +context — the stress drop shadows the fault where it slips, and the +welded/stuck rungs leave the far-field stress untouched. +""" +import os + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import matplotlib.gridspec as gridspec +import numpy as np +import pyvista as pv + +import underworld3 as uw +import underworld3.visualisation as vis +from underworld3.utilities import fault_contact + +import common + +pv.OFF_SCREEN = True +D = os.path.dirname(os.path.abspath(__file__)) +HALF = 0.2 +SIGMA_N = 2.0 # prescribed normal stress for friction + +segment = common.fault_segment(0.0, half_length=HALF) +runs = [] + + +def render_shear_stress(stokes, child, tag): + """sigma_xy projected to continuous P1 (smoothing 0), rendered as a + cropped planform panel with the fault trace overlaid.""" + x, y = child.X + v = stokes.Unknowns.u + s_var = uw.discretisation.MeshVariable(f"Sxy_{tag}", child, 1, degree=1) + proj = uw.systems.Projection(child, s_var) + proj.uw_function = common.ETA * (v.sym[0].diff(y) + v.sym[1].diff(x)) + proj.smoothing = 0.0 + proj.solve() + + pv_m = vis.meshVariable_to_pv_mesh_object(s_var) + pv_m.point_data["S"] = np.asarray(s_var.data[:, 0]) + pl = pv.Plotter(off_screen=True, window_size=(560, 340)) + pl.set_background("white") + pl.add_mesh(pv_m, scalars="S", cmap="RdBu_r", clim=(0.0, 2.0), + show_edges=False, lighting=False, show_scalar_bar=False) + trace = pv.Line((0.5 - HALF, 0.5, 0.001), (0.5 + HALF, 0.5, 0.001)) + pl.add_mesh(trace, color="black", line_width=2.5, lighting=False) + pl.view_xy() + pl.camera.parallel_projection = True + pl.camera.parallel_scale = 0.26 + pl.camera.focal_point = (0.5, 0.5, 0.0) + out = os.path.join(D, f"_ladder_stress_{tag}.png") + pl.screenshot(out) + pl.close() + return out + + +def solve_case(label, register, tag): + child = common.split_with_fault(common.base_mesh(0.04), segment) + stokes = common.stokes_on(child, common.simple_shear(child)) + register(stokes) + fault_contact.solve_with_fault(stokes, picard=2) + s, V = common.slip_vs_position(stokes, tangent=(1.0, 0.0)) + # the unsplit tips carry exactly zero slip by construction + s = np.concatenate([[-HALF], s, [HALF]]) + V = np.concatenate([[0.0], V, [0.0]]) + panel = render_shear_stress(stokes, child, tag) + runs.append((label, s, np.abs(V), panel)) + print(f"{label:34s} peak slip {np.abs(V).max():.4f}") + + +solve_case("frictionless", + lambda st: st.add_fault_bc(0, boundary="Fault"), "free") +solve_case(r"viscous, $\eta_f = \eta/a$", + lambda st: st.add_fault_bc(common.ETA / HALF, boundary="Fault"), + "visc") +solve_case(r"Coulomb, $\mu\sigma_n = 0.6 < \tau_\infty$", + lambda st: fault_contact.add_coulomb_fault_bc( + st, 0.3, "Fault", sigma_n=SIGMA_N, V0=1e-4), "weak") +solve_case(r"rate-state (steady, $f_{ss}\sigma_n \approx 0.86$)", + lambda st: fault_contact.add_rate_state_fault_bc( + st, 0.42, "Fault", a=0.02, b=0.01, V0=1e-3, Dc=1e-2, + sigma_n=SIGMA_N), "rs") +solve_case(r"Coulomb, $\mu\sigma_n = 1.2 > \tau_\infty$ (stuck)", + lambda st: fault_contact.add_coulomb_fault_bc( + st, 0.6, "Fault", sigma_n=SIGMA_N, V0=1e-4), "stuck") + +colors = ["#c62828", "#e57373", "#d9960a", "#4a7bf7", "#555555"] + +fig = plt.figure(figsize=(9.8, 6.2)) +gs = gridspec.GridSpec(len(runs), 2, width_ratios=[1.7, 1.0], + wspace=0.12, hspace=0.15) + +ax = fig.add_subplot(gs[:, 0]) +for (label, s, V, _panel), col in zip(runs, colors): + ax.plot(s, V, ".-", ms=3.5, lw=1.0, color=col, label=label) +ss = np.linspace(-HALF, HALF, 200) +ax.plot(ss, np.sqrt(np.maximum(HALF**2 - ss**2, 0)) / HALF + * runs[0][2].max(), "k--", lw=0.8, + label="elliptical profile (shape)") +ax.set_xlabel("position along the fault $s$") +ax.set_ylabel("slip rate $|V(s)|$") +ax.set_title("The fault-strength ladder: one fault, four laws " + r"($\tau_\infty = 1$)") +ax.legend(fontsize=8, loc="upper right") +ax.set_xlim(-HALF * 1.15, HALF * 1.15) + +for k, ((label, _s, _V, panel), col) in enumerate(zip(runs, colors)): + axi = fig.add_subplot(gs[k, 1]) + axi.imshow(plt.imread(panel)) + axi.set_xticks([]) + axi.set_yticks([]) + for spine in axi.spines.values(): + spine.set_color(col) + spine.set_linewidth(2.2) + if k == 0: + axi.set_title(r"shear stress $\sigma_{xy}$ (0 → 2, " + r"white $= \tau_\infty$)", fontsize=8) + +fig.tight_layout() +out = os.path.join(D, "ladder.png") +fig.savefig(out, dpi=200) +print("wrote", out) diff --git a/docs/advanced/figures/fault-examples/mohr-circle-build.gif b/docs/advanced/figures/fault-examples/mohr-circle-build.gif new file mode 100644 index 000000000..1ae195223 Binary files /dev/null and b/docs/advanced/figures/fault-examples/mohr-circle-build.gif differ diff --git a/docs/advanced/figures/fault-examples/mohr-circle.png b/docs/advanced/figures/fault-examples/mohr-circle.png new file mode 100644 index 000000000..da35e6ea9 Binary files /dev/null and b/docs/advanced/figures/fault-examples/mohr-circle.png differ diff --git a/docs/advanced/figures/fault-examples/mohr-cohesion-build.gif b/docs/advanced/figures/fault-examples/mohr-cohesion-build.gif new file mode 100644 index 000000000..7f65e260a Binary files /dev/null and b/docs/advanced/figures/fault-examples/mohr-cohesion-build.gif differ diff --git a/docs/advanced/figures/fault-examples/mohr-cohesion.png b/docs/advanced/figures/fault-examples/mohr-cohesion.png new file mode 100644 index 000000000..46354fe38 Binary files /dev/null and b/docs/advanced/figures/fault-examples/mohr-cohesion.png differ diff --git a/docs/advanced/figures/fault-examples/mohr-friction-build.gif b/docs/advanced/figures/fault-examples/mohr-friction-build.gif new file mode 100644 index 000000000..c1b8e3a22 Binary files /dev/null and b/docs/advanced/figures/fault-examples/mohr-friction-build.gif differ diff --git a/docs/advanced/figures/fault-examples/mohr-friction.png b/docs/advanced/figures/fault-examples/mohr-friction.png new file mode 100644 index 000000000..1dd769024 Binary files /dev/null and b/docs/advanced/figures/fault-examples/mohr-friction.png differ diff --git a/docs/advanced/figures/fault-examples/mohr-graded.png b/docs/advanced/figures/fault-examples/mohr-graded.png new file mode 100644 index 000000000..e6edcafc8 Binary files /dev/null and b/docs/advanced/figures/fault-examples/mohr-graded.png differ diff --git a/docs/advanced/figures/fault-examples/mohr_animate.py b/docs/advanced/figures/fault-examples/mohr_animate.py new file mode 100644 index 000000000..ba47c43de --- /dev/null +++ b/docs/advanced/figures/fault-examples/mohr_animate.py @@ -0,0 +1,129 @@ +"""The Mohr circle, built frame by frame as the fault rotates. + +The teaching version of mohr_circle.py: the left panel shows the fault +physically rotating in the box; the right panel shows its stress probe +sweeping around the Mohr circle at TWICE the rate — the double-angle +rule as motion, not as a formula. Each frame is a full welded-fault +solve (the probes are measured, not drawn), assembled into a GIF. +""" +import os + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +from PIL import Image + +import common + +D = os.path.dirname(os.path.abspath(__file__)) +HALF = 0.2 +A_RATE, GAMMA = 0.5, 1.0 +R_ANALYTIC = common.ETA * np.sqrt(4 * A_RATE**2 + GAMMA**2) +STEP = 7.5 +angles = np.arange(0.0, 180.0 + 1e-9, STEP) + +# The sweep is 25 welded-fault solves; cache it so iterating on the +# animation's look does not re-measure the physics. +cache = os.path.join(D, "_mohr_probes.npz") +if os.path.exists(cache): + probes = np.load(cache)["probes"] + assert len(probes) == len(angles) + print(f"loaded {len(probes)} cached probes") +else: + probes = [] + for theta in angles: + sigma_n, tau = common.mohr_probe(theta, A_RATE, GAMMA, + half_length=HALF) + probes.append((theta, sigma_n, tau)) + print(f"theta {theta:6.1f}: sigma_n {sigma_n:8.4f} " + f"tau {tau:8.4f}") + probes = np.array(probes) + np.savez(cache, probes=probes) +centre = float(np.mean(probes[:, 1])) +# GEOLOGICAL convention on the stress plane: compression positive. +# The left panel's traction vector stays in physical (tension-positive) +# components; only the Mohr diagram flips. +cg = -centre +scg = -probes[:, 1] + +frames = [] +for k in range(len(probes)): + theta, sig_k, tau_k = probes[k] + fig, (axl, axr) = plt.subplots( + 1, 2, figsize=(9.6, 4.6), + gridspec_kw=dict(width_ratios=[1.0, 1.25])) + + # ---- left: the fault in the box ------------------------------------- + axl.add_patch(plt.Rectangle((0, 0), 1, 1, fill=False, lw=1.0, + edgecolor="0.4")) + t = np.array([np.cos(np.radians(theta)), np.sin(np.radians(theta))]) + n = np.array([-t[1], t[0]]) + c = common.CENTRE + axl.plot([c[0] - HALF * t[0], c[0] + HALF * t[0]], + [c[1] - HALF * t[1], c[1] + HALF * t[1]], + "-", color="#c62828", lw=2.5) + axl.annotate("", xytext=c, xy=c + 0.14 * n, + arrowprops=dict(arrowstyle="->", lw=1.0, color="0.35")) + axl.text(*(c + 0.18 * n), r"$\hat n$", fontsize=9, ha="center", + color="0.35") + # the traction vector on the fault plane, from the MEASURED probe: + # purely normal (aligned with n-hat) exactly at the principal + # orientations — the same instant the probe crosses tau = 0 + T = sig_k * n + tau_k * t + scale = 0.16 / R_ANALYTIC + axl.annotate("", xytext=c, xy=c + scale * T, + arrowprops=dict(arrowstyle="-|>", lw=2.2, + color="#4a7bf7")) + axl.text(0.06, 0.9, rf"$\theta = {theta:.1f}°$", fontsize=12) + axl.text(0.06, 0.135, r"$\sigma\cdot\hat n$: traction on the plane", + fontsize=9, color="#4a7bf7", transform=axl.transAxes) + if abs(tau_k) < 0.05 * R_ANALYTIC: + axl.text(0.5, 0.06, "principal orientation: traction ∥ normal", + fontsize=10, color="#c62828", ha="center", + transform=axl.transAxes) + axl.set_xlim(-0.06, 1.06) + axl.set_ylim(-0.06, 1.06) + axl.set_aspect("equal") + axl.set_xticks([]) + axl.set_yticks([]) + axl.set_title("the (welded) fault rotates ...", fontsize=10) + + # ---- right: the probe sweeps the circle, twice as fast -------------- + tt = np.linspace(0, 2 * np.pi, 300) + axr.plot(cg + R_ANALYTIC * np.cos(tt), R_ANALYTIC * np.sin(tt), + "-", color="0.75", lw=0.9) + axr.axhline(0, color="0.8", lw=0.6) + axr.axvline(cg, color="0.8", lw=0.6) + axr.plot([cg - R_ANALYTIC, cg + R_ANALYTIC], [0, 0], "D", + ms=5, color="0.3", zorder=4) + axr.text(cg + R_ANALYTIC, -0.16 * R_ANALYTIC, + "principal\nstresses", fontsize=7, ha="center", va="top", + color="0.3") + axr.plot(scg[:k + 1], probes[:k + 1, 2], "o", ms=5, + mfc="none", mec="#c62828", mew=1.2) + axr.plot([cg, -sig_k], [0.0, tau_k], "-", + color="#4a7bf7", lw=1.2) + axr.plot([-sig_k], [tau_k], "o", ms=9, color="#c62828", zorder=5) + axr.text(0.04, 0.94, r"... its stress probe sweeps at $2\theta$", + fontsize=10, transform=axr.transAxes) + axr.set_xlabel(r"normal stress $\sigma$ (compression positive)") + axr.set_ylabel(r"shear traction $\tau$") + axr.set_xlim(cg - 1.35 * R_ANALYTIC, cg + 1.35 * R_ANALYTIC) + axr.set_ylim(-1.35 * R_ANALYTIC, 1.35 * R_ANALYTIC) + axr.set_aspect("equal") + + fig.suptitle("Building the Mohr circle with a rotating fault", + fontsize=11) + fig.tight_layout() + frame = os.path.join(D, f"_mohr_frame_{k:03d}.png") + fig.savefig(frame, dpi=110) + plt.close(fig) + frames.append(frame) + +images = [Image.open(f) for f in frames] +out = os.path.join(D, "mohr-circle-build.gif") +images[0].save(out, save_all=True, append_images=images[1:] + + [images[-1]] * 6, # hold the completed circle + duration=280, loop=0) +print("wrote", out, f"({len(frames)} frames)") diff --git a/docs/advanced/figures/fault-examples/mohr_circle.py b/docs/advanced/figures/fault-examples/mohr_circle.py new file mode 100644 index 000000000..f670ea0f2 --- /dev/null +++ b/docs/advanced/figures/fault-examples/mohr_circle.py @@ -0,0 +1,87 @@ +"""The Mohr circle, measured by faults. + +A WELDED split fault transmits the full stress across itself, so it is +a passive stress probe: the no-opening constraint's reaction gives the +normal traction sigma_n, and the stiff interface dashpot reads the +shear traction off its own law, tau = eta_f V. Sweeping the fault +orientation theta and plotting (−sigma_n, |tau|) traces the Mohr +circle of the ambient stress state — measured by the machinery itself, +against the analytic circle of the imposed flow. + +Boundary conditions: Dirichlet velocity on all four walls imposing +the homogeneous flow v = (a(x−c) + gamma(y−c), −a(y−c)) — a PURE-SHEAR +(irrotational stretching) part a plus a SIMPLE-SHEAR part gamma. +Stress sees only the symmetric gradient, so the state is equivalent to +pure shear of magnitude R = eta sqrt(4 a^2 + gamma^2) with principal +axes at 22.5 degrees to the box (deliberately not axis-aligned: the +circle's orientation must be measured, not guessed). The linear flow +is an exact homogeneous Stokes solution — constant stress, no wall +boundary layers — and the centre sits at the (gauge-fixed) mean +pressure, which the fit reports rather than assumes. +""" +import os + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np + +import underworld3 as uw +from underworld3.utilities import fault_contact + +import common + +D = os.path.dirname(os.path.abspath(__file__)) +HALF = 0.2 +A_RATE, GAMMA = 0.5, 1.0 +ETA_WELD = 200.0 * common.ETA / HALF # welded: slip ~ tau/eta_f -> 0 +R_ANALYTIC = common.ETA * np.sqrt(4 * A_RATE**2 + GAMMA**2) + +angles = np.arange(0.0, 180.0 - 1e-9, 22.5) +points = [] +for theta in angles: + sigma_n, tau = common.mohr_probe(theta, A_RATE, GAMMA, + eta_weld=ETA_WELD, half_length=HALF) + points.append((theta, sigma_n, tau)) + print(f"theta {theta:5.1f}: sigma_n {sigma_n:8.4f} tau {tau:7.4f}") + +points = np.array(points) +# GEOLOGICAL sign convention for the stress plane: compression positive, +# tension on the negative axis (the solver's tractions are +# tension-positive; only the plot flips). +points[:, 1] *= -1.0 + +# circle fit: centre c on the sigma axis, radius R, from the probes +sig, tau = points[:, 1], points[:, 2] +c0 = float(np.mean(sig)) +for _ in range(60): # two-parameter Gauss fit + r = np.sqrt((sig - c0) ** 2 + tau ** 2) + R0 = r.mean() + c0 -= float(np.mean((c0 - sig) * (1 - R0 / np.maximum(r, 1e-30)))) +print(f"fit: centre {c0:.4f}, radius {R0:.4f} " + f"(analytic radius {R_ANALYTIC:.4f})") + +fig, ax = plt.subplots(figsize=(6.8, 4.8)) +tt = np.linspace(0, 2 * np.pi, 300) +ax.plot(c0 + R_ANALYTIC * np.cos(tt), R_ANALYTIC * np.sin(tt), "k-", + lw=1.0, label=f"analytic circle, $R = {R_ANALYTIC:.3f}$") +ax.plot(c0 + R0 * np.cos(tt), R0 * np.sin(tt), "--", lw=0.9, + color="#4a7bf7", label=f"fit through the probes, $R = {R0:.3f}$") +ax.plot(sig, tau, "o", ms=6, color="#c62828", zorder=5, + label="welded-fault probes") +ax.axvline(c0, color="0.6", lw=0.6) +for theta, sg, tu in points: + ax.annotate(f"{theta:.0f}°", (sg, tu), textcoords="offset points", + xytext=(6, 5), fontsize=8) +ax.axhline(0, color="0.6", lw=0.6) +ax.set_xlabel(r"normal stress $\sigma$ (compression positive)") +ax.set_ylabel(r"shear traction $\tau$") +ax.set_title("The Mohr circle, measured by welded split-node faults") +ax.set_aspect("equal") +ax.legend(fontsize=8, loc="center") +fig.tight_layout() +out = os.path.join(D, "mohr-circle.png") +fig.savefig(out, dpi=200) +print("wrote", out) + +assert abs(R0 - R_ANALYTIC) < 0.06 * R_ANALYTIC, "radius off by > 6%" diff --git a/docs/advanced/figures/fault-examples/mohr_cohesion.py b/docs/advanced/figures/fault-examples/mohr_cohesion.py new file mode 100644 index 000000000..720f9870d --- /dev/null +++ b/docs/advanced/figures/fault-examples/mohr_cohesion.py @@ -0,0 +1,211 @@ +"""Cohesive Mohr-Coulomb: strength survives into the tensile sector. + +The cohesive sequel to mohr_friction: the fault's yield envelope is +tau = +-(C + mu sigma) (compression positive), declining through mild +tension and reaching ZERO at sigma = -C/mu. Three physical regimes and +one UNPHYSICAL one appear as the fault rotates: + +- stuck (on the circle) — now around BOTH principal poles, because + cohesion holds shear through mild tension; +- sliding, pinned to the declining envelope; +- HELD SHUT — beyond sigma = -C/mu the strength is zero and the normal + stress is tensile: a real fault would OPEN, no static solution + exists, and the bilateral no-opening constraint manufactures one by + gluing the surfaces together (a tensile constraint reaction). The + solver converges; the physics has failed. Detect it from the SIGN of + the recovered normal traction. + +The law is not a canned option — it is registered as a sympy +expression in the canonical symbols, which is the whole design: a new +fault rheology is four lines. + +All Mohr figures on this page use the GEOLOGICAL sign convention: +compression positive, tension on the negative axis. +""" +import os + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +import sympy +from PIL import Image + +import underworld3 as uw +from underworld3.utilities import fault_contact + +import common + +D = os.path.dirname(os.path.abspath(__file__)) +HALF = 0.2 +A_RATE, GAMMA = 0.5, 1.0 +MU, C, V0 = 0.6, 0.6, 1e-4 +R_ANALYTIC = common.ETA * np.sqrt(4 * A_RATE**2 + GAMMA**2) +STEP = 7.5 +angles = np.arange(0.0, 180.0 + 1e-9, STEP) + + +def register_cohesive_law(stokes): + """Mohr-Coulomb with cohesion as a symbolic law. normal_stress is + the SIGNED effective normal stress (reaction-fed; negative in + tension), and the strength clamp is the law's own: Max(0, C + mu s) + declines through mild tension and vanishes at s = -C/mu.""" + V = fault_contact.slip_rate + S = fault_contact.normal_stress + law = fault_contact.SymbolicFaultLaw( + sympy.Max(C + MU * S, 0) * (2 / sympy.pi) * sympy.atan(V / V0)) + fault_contact.add_frictionless_fault_bc(stokes, "Fault") + fault_contact._register_law(stokes, "Fault", law) + + +cache = os.path.join(D, "_mohr_cohesion_probes.npz") +if os.path.exists(cache): + probes = np.load(cache)["probes"] + assert len(probes) == len(angles) + print(f"loaded {len(probes)} cached probes") +else: + rows = [] + for theta in angles: + child = common.split_with_fault( + common.base_mesh(0.04), common.fault_segment(theta, HALF)) + stokes = common.stokes_on( + child, common.shear_plus_stretch(child, A_RATE, GAMMA)) + register_cohesive_law(stokes) + fault_contact.solve_with_fault(stokes, picard=3) + s, V, _leak = common.slip_profile(stokes) + s_n, sig = common.normal_traction(stokes) + v_med = float(np.median(V[common.inner(s)])) + sigma_n = float(np.median(sig[common.inner(s_n)])) + strength = max(C + MU * (-sigma_n), 0.0) + tau = strength * (2 / np.pi) * np.arctan(v_med / V0) + rows.append((theta, sigma_n, tau, v_med)) + print(f"theta {theta:6.1f}: sigma_n {sigma_n:8.4f} " + f"tau {tau:8.4f} V {v_med:9.5f}") + probes = np.array(rows) + np.savez(cache, probes=probes) + +# GEO convention for plotting: compression positive +sc = -probes[:, 1] +tau = probes[:, 2] +# beyond sigma = -C/mu the strength is zero and the fault is in +# tension: it would open — the constraint holds it shut (unphysical) +held_shut = sc < -C / MU + 1e-6 +sliding = (np.abs(probes[:, 3]) > 5 * V0) & ~held_shut + + +def draw_stress_plane(ax): + tt = np.linspace(0, 2 * np.pi, 300) + ax.plot(R_ANALYTIC * np.cos(tt), R_ANALYTIC * np.sin(tt), + "-", color="0.8", lw=0.9, + label="ambient stress (welded circle)") + s_env = np.linspace(-C / MU, 1.5 * R_ANALYTIC, 60) + for sgn in (+1, -1): + ax.plot(s_env, sgn * (C + MU * s_env), "--", color="0.35", + lw=1.0, label=(r"envelope $\tau = \pm(C + \mu\sigma)$," + r" zero at $\sigma = -C/\mu$" + if sgn > 0 else None)) + ax.axvspan(-1.6 * R_ANALYTIC, -C / MU, color="0.92", zorder=0) + ax.text(-1.5 * R_ANALYTIC, 1.1 * R_ANALYTIC, + "fault would open:\nno static solution\n(held shut by the\n" + "no-opening constraint)", fontsize=7.5, va="top", + color="0.35") + ax.axhline(0, color="0.85", lw=0.6) + ax.axvline(0, color="0.85", lw=0.6) + ax.set_xlabel(r"normal stress $\sigma$ (compression positive)") + ax.set_ylabel(r"shear traction $\tau$") + ax.set_xlim(-1.6 * R_ANALYTIC, 1.6 * R_ANALYTIC) + ax.set_ylim(-1.35 * R_ANALYTIC, 1.35 * R_ANALYTIC) + ax.set_aspect("equal") + + +fig, ax = plt.subplots(figsize=(7.6, 5.4)) +draw_stress_plane(ax) +stuck = ~sliding & ~held_shut +ax.plot(sc[stuck], tau[stuck], "o", ms=7, color="#c62828", + label="stuck: on the circle", zorder=5) +ax.plot(sc[sliding], tau[sliding], "s", ms=6, color="#d9960a", + label="sliding: on the envelope", zorder=5) +ax.plot(sc[held_shut], tau[held_shut], "x", ms=8, mew=2.0, + color="0.45", label="held shut (unphysical)", zorder=5) +ax.legend(fontsize=8, loc="lower right") +ax.set_title(rf"Cohesive Mohr-Coulomb fault, $C = {C}$, $\mu = {MU}$") +fig.tight_layout() +out = os.path.join(D, "mohr-cohesion.png") +fig.savefig(out, dpi=200) +print("wrote", out) + +# ---- the animated build ------------------------------------------------------ +frames = [] +for k in range(len(probes)): + theta, sig_k, tau_k, v_k = probes[k] + shut_k = bool(held_shut[k]) + slide_k = (abs(v_k) > 5 * V0) and not shut_k + fig, (axl, axr) = plt.subplots( + 1, 2, figsize=(9.6, 4.6), + gridspec_kw=dict(width_ratios=[1.0, 1.25])) + + axl.add_patch(plt.Rectangle((0, 0), 1, 1, fill=False, lw=1.0, + edgecolor="0.4")) + t = np.array([np.cos(np.radians(theta)), np.sin(np.radians(theta))]) + n = np.array([-t[1], t[0]]) + c = common.CENTRE + axl.plot([c[0] - HALF * t[0], c[0] + HALF * t[0]], + [c[1] - HALF * t[1], c[1] + HALF * t[1]], + "-", color="#c62828", lw=2.5) + T = sig_k * n + tau_k * t + scale = 0.16 / R_ANALYTIC + axl.annotate("", xytext=c, xy=c + scale * T, + arrowprops=dict(arrowstyle="-|>", lw=2.2, + color="#4a7bf7")) + if slide_k: + off = 0.035 * n + sgn = np.sign(v_k) + for pm in (+1, -1): + axl.annotate("", xytext=c + pm * off - pm * sgn * 0.09 * t, + xy=c + pm * off + pm * sgn * 0.09 * t, + arrowprops=dict(arrowstyle="->", lw=1.4, + color="#d9960a")) + axl.text(0.06, 0.9, rf"$\theta = {theta:.1f}°$", fontsize=12) + status, scol = (("HELD SHUT (unphysical)", "0.45") if shut_k + else ("SLIDING", "#d9960a") if slide_k + else ("stuck", "#c62828")) + axl.text(0.06, 0.82, status, fontsize=10, color=scol) + axl.set_xlim(-0.06, 1.06) + axl.set_ylim(-0.06, 1.06) + axl.set_aspect("equal") + axl.set_xticks([]) + axl.set_yticks([]) + axl.set_title(rf"cohesive fault, $C = {C}$, $\mu = {MU}$ ...", + fontsize=10) + + draw_stress_plane(axr) + axr.legend(fontsize=7, loc="lower right") + stuck_prev = (~sliding & ~held_shut)[:k + 1] + slide_prev = sliding[:k + 1] + shut_prev = held_shut[:k + 1] + axr.plot(sc[:k + 1][stuck_prev], tau[:k + 1][stuck_prev], "o", ms=5, + mfc="none", mec="#c62828", mew=1.2) + axr.plot(sc[:k + 1][slide_prev], tau[:k + 1][slide_prev], "s", ms=5, + mfc="none", mec="#d9960a", mew=1.2) + axr.plot(sc[:k + 1][shut_prev], tau[:k + 1][shut_prev], "x", ms=6, + mew=1.6, color="0.45") + mark, mcol = (("x", "0.45") if shut_k else + ("s", "#d9960a") if slide_k else ("o", "#c62828")) + axr.plot([sc[k]], [tau[k]], mark, ms=9, mew=2.2, color=mcol, + zorder=6) + axr.set_title("... strength declines to zero, then the fault " + "would open", fontsize=10) + + fig.suptitle("Cohesion keeps more of the Mohr circle", fontsize=11) + fig.tight_layout() + frame = os.path.join(D, f"_mohrc_frame_{k:03d}.png") + fig.savefig(frame, dpi=110) + plt.close(fig) + frames.append(frame) + +images = [Image.open(f) for f in frames] +out = os.path.join(D, "mohr-cohesion-build.gif") +images[0].save(out, save_all=True, + append_images=images[1:] + [images[-1]] * 6, + duration=280, loop=0) +print("wrote", out, f"({len(frames)} frames)") diff --git a/docs/advanced/figures/fault-examples/mohr_friction.py b/docs/advanced/figures/fault-examples/mohr_friction.py new file mode 100644 index 000000000..a4731bbaf --- /dev/null +++ b/docs/advanced/figures/fault-examples/mohr_friction.py @@ -0,0 +1,204 @@ +"""The Mohr circle meets the friction envelope. + +The frictional sequel to mohr_circle/mohr_animate: the same rotating +fault, but now carrying Coulomb friction with reaction-fed normal +stress. Three regimes appear as the fault rotates: + +- STUCK — the ambient resolved stress lies inside the envelope + |tau| < mu |sigma_n| (compressive side): the fault transmits the + full stress and its probe sits ON the Mohr circle; +- SLIDING — the ambient stress would exceed the envelope: the fault + slips, drops the shear traction to its strength, and the probe is + pinned to the yield line tau = ±mu |sigma_n|; +- HELD SHUT — under tensile normal stress bare friction has no + strength: a real fault would OPEN, no static solution exists, and + the bilateral no-opening constraint manufactures one by gluing the + surfaces (tensile reaction). The solver converges; the physics has + failed. The probes ride the axis at tau = 0, marked as unphysical. + +sigma_n comes from the no-opening constraint's reaction; tau is read +from the Coulomb law at the measured slip rate — exact in both +regimes, because the regularised law IS the traction the fault +carries. Outputs: a static summary figure and the animated build. +""" +import os + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +from PIL import Image + +import underworld3 as uw +from underworld3.utilities import fault_contact + +import common + +D = os.path.dirname(os.path.abspath(__file__)) +HALF = 0.2 +A_RATE, GAMMA = 0.5, 1.0 +MU, V0 = 0.7, 1e-4 +R_ANALYTIC = common.ETA * np.sqrt(4 * A_RATE**2 + GAMMA**2) +STEP = 7.5 +angles = np.arange(0.0, 180.0 + 1e-9, STEP) + +cache = os.path.join(D, "_mohr_friction_probes.npz") +if os.path.exists(cache): + probes = np.load(cache)["probes"] + assert len(probes) == len(angles) + print(f"loaded {len(probes)} cached probes") +else: + rows = [] + for theta in angles: + child = common.split_with_fault( + common.base_mesh(0.04), common.fault_segment(theta, HALF)) + stokes = common.stokes_on( + child, common.shear_plus_stretch(child, A_RATE, GAMMA)) + fault_contact.add_coulomb_fault_bc(stokes, MU, "Fault", + sigma_n="reaction", V0=V0) + fault_contact.solve_with_fault(stokes, picard=3) + s, V, _leak = common.slip_profile(stokes) + s_n, sig = common.normal_traction(stokes) + v_med = float(np.median(V[common.inner(s)])) + sigma_n = float(np.median(sig[common.inner(s_n)])) + sigma_eff = max(-sigma_n, 0.0) + tau = MU * sigma_eff * (2 / np.pi) * np.arctan(v_med / V0) + rows.append((theta, sigma_n, tau, v_med)) + print(f"theta {theta:6.1f}: sigma_n {sigma_n:8.4f} " + f"tau {tau:8.4f} V {v_med:9.5f}") + probes = np.array(rows) + np.savez(cache, probes=probes) + +centre = 0.0 # the welded sweep's measured gauge +# GEOLOGICAL convention on the stress plane: compression positive. +scg = -probes[:, 1] +# bare friction: strength vanishes the moment the stress turns tensile +held_shut = scg < -1e-6 +sliding = (np.abs(probes[:, 3]) > 5 * V0) & ~held_shut + + +def draw_stress_plane(ax): + tt = np.linspace(0, 2 * np.pi, 300) + ax.plot(centre + R_ANALYTIC * np.cos(tt), R_ANALYTIC * np.sin(tt), + "-", color="0.8", lw=0.9, + label="ambient stress (welded circle)") + ss = np.linspace(0, 1.35 * R_ANALYTIC, 50) + for sgn in (+1, -1): + ax.plot(ss, sgn * MU * ss, "--", color="0.35", lw=1.0, + label=(r"friction envelope $\tau = \pm\mu\sigma$" + if sgn > 0 else None)) + ax.axhline(0, color="0.85", lw=0.6) + ax.axvline(centre, color="0.85", lw=0.6) + ax.axvspan(-1.6 * R_ANALYTIC, 0, color="0.92", zorder=0) + ax.text(-1.45 * R_ANALYTIC, 1.1 * R_ANALYTIC, + "fault would open:\nno static solution\n(held shut by the\n" + "no-opening constraint)", fontsize=7.5, va="top", + color="0.35") + ax.set_xlabel(r"normal stress $\sigma$ (compression positive)") + ax.set_ylabel(r"shear traction $\tau$") + ax.set_xlim(centre - 1.5 * R_ANALYTIC, centre + 1.5 * R_ANALYTIC) + ax.set_ylim(-1.35 * R_ANALYTIC, 1.35 * R_ANALYTIC) + ax.set_aspect("equal") + + +# ---- the static summary ----------------------------------------------------- +fig, ax = plt.subplots(figsize=(7.2, 5.4)) +draw_stress_plane(ax) +stuck = ~sliding & ~held_shut +ax.plot(scg[stuck], probes[stuck, 2], "o", ms=7, + color="#c62828", label="stuck: on the circle", zorder=5) +ax.plot(scg[sliding], probes[sliding, 2], "s", ms=6, + color="#d9960a", label="sliding: on the envelope", zorder=5) +ax.plot(scg[held_shut], probes[held_shut, 2], "x", ms=8, mew=2.0, + color="0.45", label="held shut (unphysical)", zorder=5) +ax.legend(fontsize=8, loc="upper left") +ax.set_title(rf"Coulomb fault probes, $\mu = {MU}$: " + "the stress switches to the yield envelope") +fig.tight_layout() +out = os.path.join(D, "mohr-friction.png") +fig.savefig(out, dpi=200) +print("wrote", out) + +# ---- the animated build ------------------------------------------------------ +frames = [] +for k in range(len(probes)): + theta, sig_k, tau_k, v_k = probes[k] + shut_k = bool(held_shut[k]) + slide_k = (abs(v_k) > 5 * V0) and not shut_k + fig, (axl, axr) = plt.subplots( + 1, 2, figsize=(9.6, 4.6), + gridspec_kw=dict(width_ratios=[1.0, 1.25])) + + axl.add_patch(plt.Rectangle((0, 0), 1, 1, fill=False, lw=1.0, + edgecolor="0.4")) + t = np.array([np.cos(np.radians(theta)), np.sin(np.radians(theta))]) + n = np.array([-t[1], t[0]]) + c = common.CENTRE + axl.plot([c[0] - HALF * t[0], c[0] + HALF * t[0]], + [c[1] - HALF * t[1], c[1] + HALF * t[1]], + "-", color="#c62828", lw=2.5) + axl.annotate("", xytext=c, xy=c + 0.14 * n, + arrowprops=dict(arrowstyle="->", lw=1.0, color="0.35")) + axl.text(*(c + 0.18 * n), r"$\hat n$", fontsize=9, ha="center", + color="0.35") + T = sig_k * n + tau_k * t + scale = 0.16 / R_ANALYTIC + axl.annotate("", xytext=c, xy=c + scale * T, + arrowprops=dict(arrowstyle="-|>", lw=2.2, + color="#4a7bf7")) + if slide_k: + # half-arrows for the slip sense + off = 0.035 * n + sgn = np.sign(v_k) + for pm in (+1, -1): + axl.annotate("", xytext=c + pm * off - pm * sgn * 0.09 * t, + xy=c + pm * off + pm * sgn * 0.09 * t, + arrowprops=dict(arrowstyle="->", lw=1.4, + color="#d9960a")) + axl.text(0.06, 0.9, rf"$\theta = {theta:.1f}°$", fontsize=12) + status, scol = (("HELD SHUT (unphysical)", "0.45") if shut_k + else ("SLIDING", "#d9960a") if slide_k + else ("stuck", "#c62828")) + axl.text(0.06, 0.82, status, fontsize=10, color=scol) + axl.text(0.06, 0.135, r"$\sigma\cdot\hat n$: traction on the plane", + fontsize=9, color="#4a7bf7", transform=axl.transAxes) + axl.set_xlim(-0.06, 1.06) + axl.set_ylim(-0.06, 1.06) + axl.set_aspect("equal") + axl.set_xticks([]) + axl.set_yticks([]) + axl.set_title(rf"Coulomb fault, $\mu = {MU}$, rotating ...", + fontsize=10) + + draw_stress_plane(axr) + axr.legend(fontsize=7, loc="upper left") + stuck_prev = (~sliding & ~held_shut)[:k + 1] + slide_prev = sliding[:k + 1] + shut_prev = held_shut[:k + 1] + axr.plot(scg[:k + 1][stuck_prev], probes[:k + 1][stuck_prev, 2], + "o", ms=5, mfc="none", mec="#c62828", mew=1.2) + axr.plot(scg[:k + 1][slide_prev], probes[:k + 1][slide_prev, 2], + "s", ms=5, mfc="none", mec="#d9960a", mew=1.2) + axr.plot(scg[:k + 1][shut_prev], probes[:k + 1][shut_prev, 2], + "x", ms=6, mew=1.6, color="0.45") + mark, mcol = (("x", "0.45") if shut_k else + ("s", "#d9960a") if slide_k else ("o", "#c62828")) + axr.plot([-sig_k], [tau_k], mark, ms=9, mew=2.2, color=mcol, + zorder=6) + axr.set_title("... its traction cannot leave the envelope", + fontsize=10) + + fig.suptitle("A frictional fault against the Mohr circle", + fontsize=11) + fig.tight_layout() + frame = os.path.join(D, f"_mohrf_frame_{k:03d}.png") + fig.savefig(frame, dpi=110) + plt.close(fig) + frames.append(frame) + +images = [Image.open(f) for f in frames] +out = os.path.join(D, "mohr-friction-build.gif") +images[0].save(out, save_all=True, + append_images=images[1:] + [images[-1]] * 6, + duration=280, loop=0) +print("wrote", out, f"({len(frames)} frames)") diff --git a/docs/advanced/figures/fault-examples/mohr_graded.py b/docs/advanced/figures/fault-examples/mohr_graded.py new file mode 100644 index 000000000..c2d1fde97 --- /dev/null +++ b/docs/advanced/figures/fault-examples/mohr_graded.py @@ -0,0 +1,130 @@ +"""The graded fault: depth-dependent strength sampled along one fault. + +Gravity joins the Mohr experiment. With constant density and closed +(velocity-Dirichlet) walls the flow is untouched — pressure absorbs the +body force exactly — but the WELDED fault's per-node probes now sample +the hydrostatic gradient: every node sits at its own depth, so each +fault orientation contributes a horizontal STREAK of points in the +(sigma, tau) plane rather than a single value. The streak is longest +for the vertical fault (largest depth range along the fault), a single +dot for the horizontal one, and the whole cloud scatters about the +family of Mohr circles between the shallowest and deepest fault points. + +The per-node sampling is native: sigma_n comes from the no-opening +reaction de-smeared NODE BY NODE, and tau from the weld's own law +tau = eta_f V(s) at each node — nothing is averaged. + +Gauge: a closed box fixes pressure only up to a constant (the solver +uses a mean-zero gauge). The plot re-anchors it so p = 0 at the top +surface — the shift is exactly rho g H / 2, known analytically, and is +applied to the plotted sigma only. +""" +import os + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np + +import underworld3 as uw +from underworld3.utilities import fault_contact + +import common + +D = os.path.dirname(os.path.abspath(__file__)) +HALF = 0.2 +A_RATE, GAMMA = 0.5, 1.0 +RHO_G = 0.75 # modest: streaks visible, circle intact +ETA_WELD = 200.0 * common.ETA / HALF +R_ANALYTIC = common.ETA * np.sqrt(4 * A_RATE**2 + GAMMA**2) + +angles = np.arange(0.0, 180.0 - 1e-9, 22.5) + +cache = os.path.join(D, "_mohr_graded_probes.npz") +if os.path.exists(cache): + d = np.load(cache) + theta_all, y_all, sig_all, tau_all = (d["theta"], d["y"], d["sig"], + d["tau"]) + print(f"loaded {len(sig_all)} cached node probes") +else: + theta_all, y_all, sig_all, tau_all = [], [], [], [] + for theta in angles: + child = common.split_with_fault( + common.base_mesh(0.04), common.fault_segment(theta, HALF)) + stokes = common.stokes_on( + child, common.shear_plus_stretch(child, A_RATE, GAMMA)) + stokes.bodyforce = [0.0, -RHO_G] + stokes.add_fault_bc(ETA_WELD, boundary="Fault") + fault_contact.solve_with_fault(stokes, picard=2) + + # per-NODE probes, matched by the along-fault coordinate + coords, jumps, normals = fault_contact.fault_pair_jumps( + stokes, "Fault", stokes._rotated_freeslip_info) + t_hat = np.array([np.cos(np.radians(theta)), + np.sin(np.radians(theta))]) + s_v = (coords - common.CENTRE) @ t_hat + order_v = np.argsort(s_v) + V = (jumps @ t_hat)[order_v] + y = coords[order_v, 1] + s_n, sig = common.normal_traction(stokes) + assert len(sig) == len(V), "pair-node sets disagree" + mid = common.inner(s_v[order_v]) + theta_all.extend([theta] * int(mid.sum())) + y_all.extend(y[mid]) + sig_all.extend(sig[mid]) + tau_all.extend(ETA_WELD * V[mid]) + print(f"theta {theta:6.1f}: {int(mid.sum())} nodes, " + f"sigma_n [{sig[mid].min():.3f}, {sig[mid].max():.3f}]") + theta_all, y_all, sig_all, tau_all = (np.array(theta_all), + np.array(y_all), + np.array(sig_all), + np.array(tau_all)) + np.savez(cache, theta=theta_all, y=y_all, sig=sig_all, tau=tau_all) + +# GEO convention + the top-zero pressure anchor (mean-zero solver gauge +# shifted by the known rho g H / 2) +sc = -sig_all + RHO_G * 0.5 +depth = 1.0 - y_all + +fig, ax = plt.subplots(figsize=(7.8, 5.6)) +# the family of circles between the shallowest and deepest fault points: +# fault nodes span y in [0.3, 0.7] -> pressure rho g (1 - y) +tt = np.linspace(0, 2 * np.pi, 300) +for pshift, style in ((RHO_G * 0.3, ":"), (RHO_G * 0.5, "-"), + (RHO_G * 0.7, ":")): + ax.plot(pshift + R_ANALYTIC * np.cos(tt), R_ANALYTIC * np.sin(tt), + style, color="0.75", lw=0.9, + label=("Mohr circles: shallowest / centre / deepest" + if style == "-" else None)) +ax.axhline(0, color="0.85", lw=0.6) +ax.axvline(0, color="0.85", lw=0.6) + +pts = ax.scatter(sc, tau_all, c=depth, cmap="viridis", s=22, zorder=5, + edgecolors="none") +fig.colorbar(pts, ax=ax, label="depth below surface", shrink=0.8) + +# annotate one streak: the vertical fault has the largest depth range +vert = theta_all == 90.0 +if vert.any(): + ax.annotate("one fault (90°):\na streak, not a point", + xy=(sc[vert].min(), tau_all[vert].mean()), + xytext=(-1.30, -1.30), fontsize=8, ha="left", + arrowprops=dict(arrowstyle="->", lw=0.8, color="0.3")) +flat = theta_all == 0.0 +if flat.any(): + ax.annotate("horizontal fault (0°):\none depth, one point", + xy=(sc[flat].mean(), tau_all[flat].mean()), + xytext=(-1.15, 1.22), fontsize=8, ha="center", + arrowprops=dict(arrowstyle="->", lw=0.8, color="0.3")) + +ax.set_xlabel(r"normal stress $\sigma$ (compression positive, " + r"$p = 0$ at the surface)") +ax.set_ylabel(r"shear traction $\tau$") +ax.set_title(rf"The graded fault: hydrostatic load $\rho g = {RHO_G}$, " + "per-node probes") +ax.set_aspect("equal") +ax.legend(fontsize=8, loc="center") +fig.tight_layout() +out = os.path.join(D, "mohr-graded.png") +fig.savefig(out, dpi=200) +print("wrote", out) diff --git a/docs/advanced/figures/fault-examples/orientations.png b/docs/advanced/figures/fault-examples/orientations.png new file mode 100644 index 000000000..ae9ebd2a1 Binary files /dev/null and b/docs/advanced/figures/fault-examples/orientations.png differ diff --git a/docs/advanced/figures/fault-examples/orientations.py b/docs/advanced/figures/fault-examples/orientations.py new file mode 100644 index 000000000..4b2e9c148 --- /dev/null +++ b/docs/advanced/figures/fault-examples/orientations.py @@ -0,0 +1,62 @@ +"""Fault orientation vs slip: the Mohr circle's other face. + +The same orientation sweep as the Mohr demo, but with FRICTIONLESS +faults under pure simple shear: each fault drops the shear stress +resolved on its own plane, so the peak slip rate follows the resolved +shear tau(theta) = tau_infty cos(2 theta) — the slip-rate version of +reading the Mohr circle. Faults near 45 degrees to the shear plane +barely slip; the fault aligned with it slips fully. +""" +import os + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np + +import underworld3 as uw + +import common + +D = os.path.dirname(os.path.abspath(__file__)) +HALF = 0.2 + +angles = np.arange(0.0, 90.0 + 1e-9, 15.0) +peaks, profiles = [], [] +for theta in angles: + child = common.split_with_fault( + common.base_mesh(0.04), common.fault_segment(theta, HALF)) + stokes = common.stokes_on(child, common.simple_shear(child)) + stokes.add_fault_bc(0, boundary="Fault") + stokes.solve(verbose=False) + s, V, leak = common.slip_profile(stokes) + assert np.abs(leak).max() < 1e-10 + peaks.append(np.abs(V).max()) + profiles.append((theta, s - s.min() - HALF, np.abs(V))) + print(f"theta {theta:5.1f}: peak slip {peaks[-1]:.4f}") + +peaks = np.array(peaks) + +fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10.4, 4.4)) +cmap = plt.cm.magma +for (theta, s, V) in profiles: + ax1.plot(s, V, ".-", ms=3, lw=0.9, color=cmap(theta / 120.0), + label=f"{theta:.0f}°") +ax1.set_xlabel("position along the fault $s$") +ax1.set_ylabel("slip rate $|V(s)|$") +ax1.set_title("frictionless slip profiles by orientation") +ax1.legend(fontsize=8, title="fault angle", ncol=2) + +tt = np.linspace(0, 90, 200) +ax2.plot(tt, np.abs(np.cos(np.radians(2 * tt))) * peaks[0], "k-", + lw=1.0, label=r"$|\cos 2\theta|$ (resolved shear)") +ax2.plot(angles, peaks, "o", ms=6, color="#c62828", + label="measured peak slip") +ax2.set_xlabel(r"fault angle $\theta$ to the shear plane") +ax2.set_ylabel("peak slip rate") +ax2.set_title("peak slip follows the resolved shear stress") +ax2.legend(fontsize=8) +fig.tight_layout() +out = os.path.join(D, "orientations.png") +fig.savefig(out, dpi=200) +print("wrote", out) diff --git a/docs/advanced/figures/fault-examples/true-branch-stress.png b/docs/advanced/figures/fault-examples/true-branch-stress.png new file mode 100644 index 000000000..d33cdb1f2 Binary files /dev/null and b/docs/advanced/figures/fault-examples/true-branch-stress.png differ diff --git a/docs/advanced/figures/fault-examples/true-branch.png b/docs/advanced/figures/fault-examples/true-branch.png new file mode 100644 index 000000000..e8e918d00 Binary files /dev/null and b/docs/advanced/figures/fault-examples/true-branch.png differ diff --git a/docs/advanced/figures/fault-examples/true_branch.py b/docs/advanced/figures/fault-examples/true_branch.py new file mode 100644 index 000000000..8036821e0 --- /dev/null +++ b/docs/advanced/figures/fault-examples/true_branch.py @@ -0,0 +1,240 @@ +"""A true Y-branch, bracketed: does the near-miss tributary capture it? + +A genuine branch (three arms meeting at a point) cannot be split — but +it can be DECOMPOSED two ways, and the pair brackets the truth: + +- A: the TRUNK is continuous (west + east arms as one fault); the + splay abuts, stopping a ligament short. +- B: the BENT fault is continuous (west arm + splay as one deliberately + kinked polyline — the kink response is the physics, so no smoothed + normal); the east arm abuts. + +Each decomposition welds a different pair of arms through the junction +and feeds the third across a ligament. If both give the same slip on +every arm at a small gap, the offset representation reproduces the true +branch; sweeping the gap in decomposition A shows how close "close by" +has to be. All three arms slip freely under the same drive. +""" +import os +import time + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +import pyvista as pv + +import underworld3 as uw +from underworld3.meshing.surfaces import prepare_fault_network +from underworld3.utilities import fault_contact + +import common + +pv.OFF_SCREEN = True +D = os.path.dirname(os.path.abspath(__file__)) +H = 0.012 +MU_P = 0.4 +TREND = np.degrees(np.arctan2(0.10, 0.70)) + +J = np.array([0.50, 0.50]) # the branch point +W_END = np.array([0.15, 0.45]) +E_END = np.array([0.85, 0.55]) +S_END = np.array([0.80, 0.76]) +ARMS = {"west": (W_END - J) / np.linalg.norm(W_END - J), + "east": (E_END - J) / np.linalg.norm(E_END - J), + "splay": (S_END - J) / np.linalg.norm(S_END - J)} + + +def decomposition(kind, lig): + if kind == "A": # trunk continuous + faults = [("Trunk", np.array([W_END, E_END])), + ("Splay", np.array([J, S_END]))] + through = ["Trunk"] + else: # bent fault continuous + faults = [("Bent", np.array([W_END, J, S_END])), + ("East", np.array([J, E_END]))] + through = ["Bent"] + return prepare_fault_network(faults, spacing=H, ligament=lig, + through=through, verbose=False) + + +ETA_WELD = 200.0 * common.ETA / 0.2 + + +def _stress_p0(child, stokes, tagc): + x, y = child.X + v, p = stokes.Unknowns.u, stokes.Unknowns.p + comps = {} + for cname, expr in ( + ("sxx", -p.sym[0] + 2 * common.ETA * v.sym[0].diff(x)), + ("syy", -p.sym[0] + 2 * common.ETA * v.sym[1].diff(y)), + ("sxy", common.ETA * (v.sym[0].diff(y) + v.sym[1].diff(x)))): + s_var = uw.discretisation.MeshVariable(f"{cname}_{tagc}", child, 1, + degree=0, continuous=False) + proj = uw.systems.Projection(child, s_var) + proj.uw_function = expr + proj.smoothing = 0.0 + proj.solve() + row = common.split_mesh_cell_rows(child, s_var) + comps[cname] = np.asarray(s_var.data[:, 0])[row].copy() + return comps + + +def run(tag, prepared, want_field=False): + child = common.base_mesh(H).add_fault(prepared) + stokes = common.stokes_on( + child, common.boundary_simple_shear(child, TREND)) + for n, _p in prepared: + stokes.add_fault_bc(0, boundary=n) + t0 = time.perf_counter() + fault_contact.solve_with_fault(stokes, picard=2) + + field = None + if want_field: + c1 = _stress_p0(child, stokes, f"a{run.n}") + s0 = common.stokes_on( + child, common.boundary_simple_shear(child, TREND)) + for n, _p in prepared: + s0.add_fault_bc(ETA_WELD, boundary=n) + fault_contact.solve_with_fault(s0, picard=2) + c0 = _stress_p0(child, s0, f"b{run.n}") + run.n += 1 + beta = np.radians(TREND) + nx, ny = -np.sin(beta), np.cos(beta) + tx, ty = np.cos(beta), np.sin(beta) + + def resolve(c): + s_nn = (c["sxx"] * nx * nx + 2 * c["sxy"] * nx * ny + + c["syy"] * ny * ny) + s_t = (c["sxx"] * tx * nx + c["sxy"] * (tx * ny + ty * nx) + + c["syy"] * ty * ny) + return s_nn, s_t + + nn0, t_0 = resolve(c0) + nn1, t_1 = resolve(c1) + dcff = np.sign(np.median(t_0)) * (t_1 - t_0) + MU_P * (nn1 - nn0) + pts, faces = common.split_mesh_cell_render(child) + fc = np.asarray(faces).reshape(-1, 4)[:, 1:] + cent = np.asarray(pts)[fc].mean(axis=1) + dcff, gauge = common.far_field_anchor( + cent, dcff, [p for _n, p in prepared], cut=0.18) + print(f"[{tag}] gauge {gauge:+.4f}", flush=True) + field = (pts, faces, dcff) + arms = {k: ([], []) for k in ARMS} + for n, _p in prepared: + coords, jumps, normals = fault_contact.fault_pair_jumps( + stokes, n, stokes._rotated_freeslip_info) + if not len(coords): + continue + tang = np.column_stack([-normals[:, 1], normals[:, 0]]) + V = np.abs(np.einsum("ij,ij->i", jumps, tang)) + R = coords - J + # classify each pair node by the arm it lies along + proj = {k: R @ t for k, t in ARMS.items()} + dist2 = {k: np.einsum("ij,ij->i", R - np.outer( + np.clip(proj[k], 0, None), ARMS[k]), + R - np.outer(np.clip(proj[k], 0, None), ARMS[k])) + for k in ARMS} + best = np.argmin(np.vstack([dist2[k] for k in ARMS]), axis=0) + for i, k in enumerate(ARMS): + sel = best == i + arms[k][0].extend(proj[k][sel]) + arms[k][1].extend(V[sel]) + out = {} + for k in ARMS: + s = np.asarray(arms[k][0]) + v = np.asarray(arms[k][1]) + order = np.argsort(s) + out[k] = (s[order], v[order]) + print(f"[{tag}] peaks " + " ".join( + f"{k} {out[k][1].max():.4f}" for k in ARMS) + + f" ({time.perf_counter() - t0:.1f} s)", flush=True) + return out, field, prepared + + +run.n = 0 + +cases = [("A, gap 1h", "A", 1.0, "#1565c0", "-"), + ("A, gap 2h", "A", 2.0, "#64b5f6", "--"), + ("A, gap 4h", "A", 4.0, "#b3d7f7", ":"), + ("B, gap 1h", "B", 1.0, "#c62828", "-")] +profiles, fields = {}, {} +for tag, kind, lig, _c, _ls in cases: + prepared, _rep = decomposition(kind, lig) + want = tag in ("A, gap 1h", "B, gap 1h") + profiles[tag], field, prep = run(tag, prepared, want_field=want) + if field is not None: + fields[tag] = (field, prep) + +fig, axes = plt.subplots(1, 3, figsize=(13.2, 4.3), sharey=True) +for ax, arm in zip(axes, ("west", "east", "splay")): + for tag, _k, _l, col, ls in cases: + s, v = profiles[tag][arm] + ax.plot(s, v, ls, lw=1.4, color=col, label=tag) + ax.set_title(f"the {arm} arm", fontsize=10.5) + ax.set_xlabel("distance from the branch point") +axes[0].set_ylabel("|slip|") +axes[0].legend(fontsize=8, title="decomposition, ligament") +fig.suptitle( + "One true Y-branch, two decompositions: continuous-trunk (A) vs " + "continuous-bend (B).\nAgreement between A and B at small gap = the " + "near-miss tributary reproduces the true branch", fontsize=10.5) +fig.tight_layout() +out = os.path.join(D, "true-branch.png") +fig.savefig(out, dpi=200) +print("wrote", out, flush=True) + +# ---- the stress maps: the kink-lock made visible --------------------------- + + +def render(field, prepared, png, scale, focal): + pts, faces, dcff = field + pvm = pv.PolyData(np.asarray(pts, dtype=float), + faces=np.asarray(faces, dtype=np.int64)) + pvm.cell_data["dcff"] = dcff + pl = pv.Plotter(off_screen=True, window_size=(850, 800)) + pl.set_background("white") + pl.add_mesh(pvm, scalars="dcff", cmap="RdBu_r", clim=(-1.0, 1.0), + lighting=False, show_scalar_bar=False) + for n, p in prepared: + line = pv.lines_from_points( + np.column_stack([p, np.full(len(p), 1e-3)])) + pl.add_mesh(line, color="black", line_width=4, lighting=False) + pl.view_xy() + pl.camera.parallel_projection = True + pl.camera.parallel_scale = scale + pl.camera.focal_point = (focal[0], focal[1], 0.0) + pl.screenshot(png) + pl.close() + return png + + +panels = [] +for tag, sub in (("A, gap 1h", "a"), ("B, gap 1h", "b")): + field, prep = fields[tag] + panels.append((render(field, prep, os.path.join( + D, f"_tb_map_{sub}.png"), 0.40, (0.5, 0.55)), + f"{tag.split(',')[0]}: " + + ("trunk continuous" if sub == "a" else "bend continuous"))) + panels.append((render(field, prep, os.path.join( + D, f"_tb_zoom_{sub}.png"), 0.09, J), + "the branch point")) + +fig = plt.figure(figsize=(12.8, 11.6)) +gs = fig.add_gridspec(2, 3, width_ratios=[1.25, 1.0, 0.05]) +for k, (png, title) in enumerate(panels): + ax = fig.add_subplot(gs[k // 2, k % 2]) + ax.imshow(plt.imread(png)) + ax.set_xticks([]) + ax.set_yticks([]) + ax.set_title(title, fontsize=10.5) +from matplotlib import cm, colors as mcolors +sm = cm.ScalarMappable(norm=mcolors.Normalize(-1, 1), cmap="RdBu_r") +cax = fig.add_subplot(gs[:, 2]) +fig.colorbar(sm, cax=cax, label=r"$\Delta$CFF (per unit stress drop)") +fig.suptitle("The same Y-branch, two continuity choices: " + r"$\Delta$CFF on trunk-parallel planes", fontsize=12) +fig.tight_layout() +out2 = os.path.join(D, "true-branch-stress.png") +fig.savefig(out2, dpi=200) +print("wrote", out2, flush=True) diff --git a/docs/advanced/figures/gouge_insulation.png b/docs/advanced/figures/gouge_insulation.png new file mode 100644 index 000000000..503683c9c Binary files /dev/null and b/docs/advanced/figures/gouge_insulation.png differ diff --git a/docs/advanced/figures/gouge_mechanics.png b/docs/advanced/figures/gouge_mechanics.png new file mode 100644 index 000000000..c931a042f Binary files /dev/null and b/docs/advanced/figures/gouge_mechanics.png differ diff --git a/docs/advanced/figures/gouge_thermal.png b/docs/advanced/figures/gouge_thermal.png new file mode 100644 index 000000000..8c59057b8 Binary files /dev/null and b/docs/advanced/figures/gouge_thermal.png differ diff --git a/docs/advanced/figures/halo_recovery.png b/docs/advanced/figures/halo_recovery.png new file mode 100644 index 000000000..15f4e9196 Binary files /dev/null and b/docs/advanced/figures/halo_recovery.png differ diff --git a/docs/advanced/figures/junction_peak_zoom.png b/docs/advanced/figures/junction_peak_zoom.png new file mode 100644 index 000000000..4934334a0 Binary files /dev/null and b/docs/advanced/figures/junction_peak_zoom.png differ diff --git a/docs/advanced/figures/material_index.png b/docs/advanced/figures/material_index.png new file mode 100644 index 000000000..3fcb3826f Binary files /dev/null and b/docs/advanced/figures/material_index.png differ diff --git a/docs/advanced/figures/material_share.png b/docs/advanced/figures/material_share.png new file mode 100644 index 000000000..0a1b28a40 Binary files /dev/null and b/docs/advanced/figures/material_share.png differ diff --git a/docs/advanced/figures/repopulation_counts.png b/docs/advanced/figures/repopulation_counts.png new file mode 100644 index 000000000..a1a6545b8 Binary files /dev/null and b/docs/advanced/figures/repopulation_counts.png differ diff --git a/docs/advanced/figures/repopulation_particles.png b/docs/advanced/figures/repopulation_particles.png new file mode 100644 index 000000000..c028586dd Binary files /dev/null and b/docs/advanced/figures/repopulation_particles.png differ diff --git a/docs/advanced/figures/stress_glyphs_enechelon.png b/docs/advanced/figures/stress_glyphs_enechelon.png new file mode 100644 index 000000000..068730433 Binary files /dev/null and b/docs/advanced/figures/stress_glyphs_enechelon.png differ diff --git a/docs/advanced/figures/stress_glyphs_listric.png b/docs/advanced/figures/stress_glyphs_listric.png new file mode 100644 index 000000000..9e479496f Binary files /dev/null and b/docs/advanced/figures/stress_glyphs_listric.png differ diff --git a/docs/advanced/figures/stress_glyphs_sinker3d.png b/docs/advanced/figures/stress_glyphs_sinker3d.png new file mode 100644 index 000000000..577c157a6 Binary files /dev/null and b/docs/advanced/figures/stress_glyphs_sinker3d.png differ diff --git a/docs/advanced/figures/stress_glyphs_thrust.png b/docs/advanced/figures/stress_glyphs_thrust.png new file mode 100644 index 000000000..6ee9196c6 Binary files /dev/null and b/docs/advanced/figures/stress_glyphs_thrust.png differ diff --git a/docs/advanced/gouge-zones.md b/docs/advanced/gouge-zones.md new file mode 100644 index 000000000..eea767c5c --- /dev/null +++ b/docs/advanced/gouge-zones.md @@ -0,0 +1,165 @@ +# Gouge zones + +A fault with a gouge zone can be built either way. As a **ribbon** it is a +band of crushed rock of width $w$ with its own material viscosity +$\eta_1$. As a **contact** it is a surface with an interface law +$\eta_I$, and the two agree when + +$$ +\eta_I = \frac{\eta_1}{w}, +$$ + +which is the matched collapse the campaign established and this page +re-checks rather than assumes. + +The collapse is exact for the slip. What it is not exact for is anything +that depends on the state *inside* the zone, and the reason is visible in +the formula: it preserves the **ratio** $\eta_1/w$ and destroys the pair. +Any quantity that needs $\eta_1$ and $w$ separately is gone. + +Scripts: `~/+Simulations/ribbon_network_2d/`. + +## The premise, checked + +One straight normal fault in an extending unit box, both ways, at four +widths with the gouge viscosity held at $\eta_1 = 10^{-3}$ so the +interface law changes with the width: + +| $w$ | $\eta_I = \eta_1/w$ | ribbon peak slip | contact peak slip | ratio | +|---|---|---|---|---| +| 0.020 | 0.05 | 0.50255 | 0.44947 | 111.8% | +| 0.010 | 0.10 | 0.48200 | 0.45374 | 106.2% | +| 0.005 | 0.20 | 0.46351 | 0.44800 | 103.5% | +| 0.002 | 0.50 | 0.42800 | 0.42101 | **101.7%** | + +```{figure} figures/gouge_mechanics.png +:width: 100% + +Left: slip along the fault, ribbon (solid) against the matched contact +(dashed), at each width. Right: the two agree in the limit and not +before it. +``` + +The collapse is a $w \to 0$ limit and behaves like one: it is worth 12% at +$w = 0.02$ and 1.7% at $w = 0.002$. Quote it as an identity only when the +zone is thin against the fault length. + +```{note} +The contact runs through the rotated machinery, which builds its **own** +prefixed KSP that `stokes.petsc_options` never reaches. Left on its +iterative default the velocity sub-solve here hit its 200-iteration cap +and warned, while the outer SNES still reported convergence. Set +`stokes._rotated_use_lu = True` on problems this size. + +The verdict then has to be physical, because the outer SNES reason does +not describe that solver either. Use the constraint: the no-opening +condition is strong, so the **normal** part of the pair jump is machine +zero when the increment was really solved. It reads $5.5\times10^{-17}$ +against a tangential jump of 0.47 here. +``` + +## What the collapse loses + +Dissipation per unit **length** of fault is $\eta_I V^2$ — preserved. The +far-field temperature is therefore the same either way. Dissipation per +unit **volume** in the gouge is + +$$ +\Phi = \eta_1 \left(\frac{V}{w}\right)^2 = \frac{\eta_I V^2}{w}, +$$ + +which is not preserved: it rises as the gouge narrows at fixed interface +law. Feed that into conduction across a thin band with its edge +temperatures as boundary data and the excess at the centre is + +$$ +\Delta T = \frac{\Phi\, w^2}{8\kappa} = \frac{\eta_1 V^2}{8\kappa} + = \frac{\eta_I V^2}{8\kappa}\, w . +$$ + +At fixed $\eta_I$ that is **proportional to the width**. Two models with +the same interface law and different widths have the same slip and the +same far field, and gouge temperatures in the ratio of their widths. For +a contact it is zero at every width, because a contact has no width. + +Peak gouge temperature is what controls thermal weakening and frictional +melting, so this is not a diagnostic nicety. + +```{figure} figures/gouge_thermal.png +:width: 100% + +Left: the gouge's own temperature excess, measured as the departure from +the straight line through its two edges — the part a contact leaves out. +Right: measured against predicted, with nothing fitted. $\eta_1$ and +$\kappa$ are set, $V$ and $\Delta T$ are measured independently. +``` + +| $w$ | $V$ measured | $\eta_1V^2/8\kappa$ | $\Delta T$ measured | agreement | +|---|---|---|---|---| +| 0.020 | 0.50255 | 0.003157 | 0.004119 | 130.5% | +| 0.010 | 0.48200 | 0.002904 | 0.002945 | **101.4%** | +| 0.005 | 0.46351 | 0.002686 | 0.002715 | **101.1%** | +| 0.002 | 0.42800 | 0.002290 | 0.002300 | **100.4%** | + +The $w = 0.02$ row is the thin-band reduction failing where $w/L = 0.033$ +is not small; the exact 1-D quadrature on the measured dissipation +profile converges the same way (58.1%, 78.6%, 92.3%, 98.1%). + +```{warning} +Do not take the dissipation profile through `uw.function.evaluate`. It +L2-projects any derivative composite, and viscous dissipation is one, so +the smoothing lands exactly on the across-band structure being measured. +Project to a discontinuous degree-0 variable instead — on that space the +L2 projection is the cell average and nothing crosses a cell boundary. + +The first attempt at an oracle here also failed for a physical reason +worth recording: treating the band as a uniformly heated slab against a +uniform *background*, and subtracting a far-field $\Phi$, misses by a +factor of six and gets the sign wrong. There is no background to +subtract — the wall rock enters through the measured edge temperatures, +and the source inside the band is the gouge's own dissipation alone. +``` + +## A split mesh is not a home for a second equation + +The other failure is structural rather than quantitative, and it does not +involve the mechanics at all. Solve plain conduction between a hot wall +and a cold wall — uniform diffusivity, no fault properties anywhere — on +a plain mesh and on the split-node mesh: + +```{figure} figures/gouge_insulation.png +:width: 100% + +The same problem, twice. A split-node mesh is split for the **mechanics**; +heat does not jump across a fault, but the doubled nodes let it. +``` + +| | flux through the cold wall | $T$ across the fault line | +|---|---|---| +| plain mesh | −0.010000 (analytic $-\kappa\Delta T/L$ = −0.01) | +0.008660, the smooth ramp over the probe offset | +| split mesh | −0.008091, 19% of the heat stopped | **+0.471241**, a real discontinuity | + +The plain row reproduces the analytic flux exactly, so the split row is +the mesh and not the solver. The fault behaves as a perfect insulator, +and no choice of fault parameter changes that — there is no thermal +interface condition on a split node to set. + +That is the sharpest form of the argument for the ribbon. Where a second +equation has to see the fault, the finite-width zone gives it cells, and +everything is continuous across them. + +## Choosing + +- **Ribbon** when the width is physics: a gouge with its own rheology, a + damage zone another equation has to see, anything where a quantity + *inside* the zone is wanted. It costs DOFs as $\mathrm{length}/w$. +- **Contact** when the fault is genuinely a surface at the scale of the + problem and slip on it is the quantity of interest. It costs DOFs as + $\mathrm{length}$, with no $w$ in it at all. + +## See also + +- [Crossing fault zones](crossing-fault-zones.md) — networks, where the + ribbon's other advantage lives. +- [Split-node faults](split-node-faults.md) — the contact representation. +- [Fault networks](fault-networks.md) — the zero-thickness toolkit. diff --git a/docs/advanced/index.md b/docs/advanced/index.md index eaf2d99b3..51e5c081e 100644 --- a/docs/advanced/index.md +++ b/docs/advanced/index.md @@ -35,6 +35,31 @@ fault-plane yield for modelling fault zones. **[→ VEP + Transverse Isotropy](vep-transverse-isotropy-faults.md)** +### Fault Zones That Cross +Finite-width fault zones fused into one region, and what the rheology of +the overlap is worth. + +**[→ Crossing Fault Zones](crossing-fault-zones.md)** + +### Gouge Zones +What a fault zone's width is for, and what collapsing it to a surface +throws away. + +**[→ Gouge Zones](gouge-zones.md)** + +### Branching Faults and Junctions +What a crack model cannot do at a branch point, measured in a shear box +— and how to choose representations because of it. + +**[→ Branching Faults and Junctions](fault-branching-junctions.md)** + +### Visualising the Stress Tensor +Principal-stress crosses sampled at seed points (2-D and 3-D) and +stress trajectories — the tensor equivalents of velocity arrows and +streamlines. + +**[→ Stress Visualisation](stress-visualisation.md)** + ### Custom Meshes Create complex geometries with gmsh for research problems. @@ -50,6 +75,13 @@ Dynamic remeshing and adaptive refinement strategies. **[→ Mesh Adaptation](mesh-adaptation.md)** +### Particles: Population Control and Materials +Keep every cell sampled as the flow deforms the swarm, and declare materials +on a `MaterialSwarm` so an interface stays where the particles put it — read +at the integration points, with no level sets in the model script. + +**[→ Particle Population and Materials](particle-population-and-materials.md)** + ### Semi-Lagrangian Time Integration (SLCN / SL-BDF2) How `AdvDiffusionSLCN` discretizes advection–diffusion in time: the BDF time-derivative and Adams-Moulton/θ flux knobs, and how to pair them @@ -103,10 +135,20 @@ multigrid-preconditioning solver-iteration-callbacks complex-rheologies vep-transverse-isotropy-faults +split-node-faults +fault-networks +crossing-fault-zones +gouge-zones +fault-branching-junctions +fault-mechanics-examples +stress-visualisation custom-meshes curved-boundary-conditions mesh-adaptation +particle-population-and-materials semi-lagrangian-time-integration +eulerian-advection-diffusion +eulerian-navier-stokes porous-flow snapshot-restore troubleshooting diff --git a/docs/advanced/mesh-adaptation.md b/docs/advanced/mesh-adaptation.md index 6e5a39667..83a976d28 100644 --- a/docs/advanced/mesh-adaptation.md +++ b/docs/advanced/mesh-adaptation.md @@ -30,7 +30,7 @@ metric = uw.adaptivity.metric_from_gradient( ) # Adapt the mesh -mesh.adapt(metric) +mesh.remesh(metric) ``` ## Core Concepts @@ -65,7 +65,7 @@ for each edge vector $\mathbf{e}$. Edges that are too long get subdivided; regio UW3 offers **two complementary** ways to put resolution where it is needed: -| | `mesh.adapt(...)` (this page) | `smooth_mesh_interior(method="anisotropic")` | +| | `mesh.remesh(...)` (this page) | `uw.meshing.node_redistribution(...)` | |---|---|---| | Mechanism | **Re-mesh** (MMG): insert/remove/retriangulate | **Redistribute** the existing nodes (move only) | | Node budget | *Changes* — targets an **absolute** edge length `h` | **Fixed** — relative redistribution to a target *density* | @@ -75,7 +75,7 @@ needed: | Cost | Re-mesh + full variable transfer | A few cheap SPD elliptic solves (no re-mesh) | | Parallel | MMG re-partition | O(N), GAMG-parallelisable, no transfer | -Use `mesh.adapt` when you need a genuinely finer mesh (more +Use `mesh.remesh` when you need a genuinely finer mesh (more elements) and can afford to rebuild the problem. Use the **node-snuggling** redistribution when you want to *reshape* the existing mesh toward a feature every timestep cheaply, keeping the @@ -241,10 +241,10 @@ metric = fault.refinement_metric( ) # Adapt mesh -mesh.adapt(metric) +mesh.remesh(metric) # Check result -print(f"Adapted mesh: {mesh.data.shape[0]} nodes") +print(f"Adapted mesh: {mesh.X.coords.shape[0]} nodes") # Now set up physics on the adapted mesh... v = uw.discretisation.MeshVariable("v", mesh, 2, degree=2) @@ -258,7 +258,7 @@ p = uw.discretisation.MeshVariable("p", mesh, 1, degree=1) ### Variables Are Reset -After `mesh.adapt()`, all variables on the old mesh become invalid. Variables on the new mesh start uninitialized. +After `mesh.remesh()`, all variables on the old mesh become invalid. Variables on the new mesh start uninitialized. **For analytical initialization:** ```python @@ -347,7 +347,7 @@ pixi run -e amr python -c "import underworld3; print('AMR ready')" | Function | Purpose | |----------|---------| -| `mesh.adapt(metric)` | Adapt mesh using metric field | +| `mesh.remesh(metric)` | Re-mesh (in place) using metric field | | `uw.adaptivity.create_metric(mesh, h)` | Convert h-field to metric | | `uw.adaptivity.metric_from_gradient(field, ...)` | Metric from field gradient | | `uw.adaptivity.metric_from_field(indicator, ...)` | Metric from indicator field | @@ -379,13 +379,18 @@ For the mathematically inclined, see the [Developer Design Document](../develope When you want to concentrate resolution on an evolving feature **every timestep** without re-meshing — keeping the topology and -all field data intact — use `smooth_mesh_interior` (the node-moving -mover) instead of `mesh.adapt`: +all field data intact — use **node redistribution** instead of +`mesh.remesh`. The purposeful spelling is +`uw.meshing.node_redistribution(mesh, metric)` (equivalently the +mesh-controlled method `mesh.redistribute_nodes(metric)`); it names +the capability — the algorithm behind it (the Huang–Kamenski MMPDE +mover) is an implementation detail documented on the machinery, +`smooth_mesh_interior`: ```python import underworld3 as uw from underworld3.meshing import ( - smooth_mesh_interior, metric_density_from_gradient) + node_redistribution, metric_density_from_gradient) # ... mesh + a temperature field T after some solve ... @@ -396,18 +401,25 @@ from underworld3.meshing import ( rho = metric_density_from_gradient(mesh, T, amp=8.0) # Move the nodes to that metric (topology / DOFs / variables -# all preserved — no transfer needed). method="mmpde" is the -# DEFAULT and may be omitted; shown here for clarity. -smooth_mesh_interior(mesh, metric=rho, method="mmpde", - boundary_slip=True) +# all preserved — no transfer needed). +node_redistribution(mesh, rho, boundary_slip=True) ``` +Node redistribution is implemented for **2D simplex (triangle) +meshes**; other mesh types (quad/hex, 3D, manifolds) raise an +honest `NotImplementedError` stating what exists. To *add* +resolution locally instead of moving it, use the nested +adapt-on-top: `child = mesh.adapt(metric, max_levels=2)` — no +`engine=` needed (the graded newest-vertex-bisection engine is the +default on 2D meshes; `engine=` remains as an advanced selector). + ```{tip} **`method="mmpde"` is the default mover** (since this release): the variational moving-mesh adaptation of Huang & Kamenski. It is -dimension-general (2D/3D), matrix-free (no PETSc solve — small -per-cell dense algebra plus a parallel `Vec` assembly), provably -non-folding, and — uniquely among the movers here — genuinely +matrix-free (no PETSc solve — small per-cell dense algebra plus a +parallel `Vec` assembly), provably non-folding, currently 2D +(triangle meshes; the method itself is dimension-general but the +3D discretization is not implemented), and genuinely *clusters and aligns* to an **anisotropic tensor** metric. It is both the most capable and the most straightforward to reason about, which is why it is now the default. Pass a **scalar** density (as @@ -417,13 +429,13 @@ above; it is promoted to the isotropic tensor `ρ·I`) or a `d×d` long-along refinement. Full design + derivation: {doc}`/developer/design/anisotropic-mmpde-mover`. -The earlier movers remain available via `method=`: -`"spring"` (fast volumetric equant-cell smoother), `"ma"` -(isotropic Monge–Ampère), `"ot"` (linear OT-improvement step), -`"anisotropic"` (decoupled-Winslow tensor smoother — reshapes but -does not cluster). Use them only when you specifically need their -behaviour; `"mmpde"` supersedes `"anisotropic"` for fault / front -refinement. +The earlier movers — `"spring"` (volumetric equant-cell smoother), +`"ma"` (isotropic Monge–Ampère), `"ot"` (linear OT-improvement +step) and `"anisotropic"` (decoupled-Winslow tensor smoother) — +were **retired in 2026-07**: `"mmpde"` with a scalar metric +reproduces their isotropic equidistribution, and with a tensor +metric it clusters and aligns where they could not. The retired +spellings now raise a `ValueError` pointing here. Key `mmpde` knobs (via `method_kwargs`): `p` (functional exponent, 1.5–2), `theta` (Huang alignment/equidistribution balance, 1/3), @@ -439,9 +451,6 @@ T| - g_{lo})/(g_{hi}-g_{lo}),0,1\big)$ with $g_{lo},g_{hi}$ the lo/hi percentiles of $|\nabla T|$ — deliberately the same shape as {py:func}`underworld3.adaptivity.metric_from_gradient`, so the *intent* you express is identical whichever family you choose. -The mover then builds a gradient-derived **anisotropic tensor** -metric internally and solves an M-weighted Laplace (Winslow) -coordinate map. ```{important} This is a **gradient** metric: it resolves where the field @@ -455,13 +464,8 @@ on general non-separable features and on cell-alignment / quality (it never produces slivers). ``` -Key knobs (via `method_kwargs`): `aniso_cap` (max cell anisotropy -— the binding stability lever; ≈2 robust, ≳6 folds), `relax` -(damping), `n_outer` (composed damped steps), `linear_solver` -(`"direct"` MUMPS, or `"gamg"` for the parallel-scalable path — -validated bit-parity). The full mathematical derivation (OT / -Monge–Ampère, the metric-tensor / Winslow mover, dynamic field -handling, Nusselt) is in +The full mathematical derivation (including the retired OT / +Monge–Ampère and Winslow movers, kept as an R&D record) is in {doc}`/developer/design/mesh-adaptation-formulation`; operational detail in {doc}`/developer/subsystems/mesh-metric-redistribution`; the dated R&D log in diff --git a/docs/advanced/multigrid-preconditioning.md b/docs/advanced/multigrid-preconditioning.md index fd8369582..b34a5b9c1 100644 --- a/docs/advanced/multigrid-preconditioning.md +++ b/docs/advanced/multigrid-preconditioning.md @@ -71,7 +71,8 @@ pc_mg_galerkin = "both" # RAP coarse operators (see below) mg_levels_ksp_type = "chebyshev" mg_levels_pc_type = "sor" mg_levels_ksp_max_it = 4 -mg_coarse_pc_type = "lu" # direct coarse solve +mg_coarse_pc_type = "redundant" # direct coarse solve, on every rank +mg_coarse_redundant_pc_type = "lu" ``` **Why Galerkin coarse operators?** Underworld3 does not install @@ -83,8 +84,8 @@ re-discretising on the coarse mesh. `pc_mg_galerkin = both` does this. This is the key reason to prefer FMG when you adapt: -- The coordinate-deforming adaptation movers (Winslow / anisotropic / - `OT_adapt` / `follow_metric`) **preserve mesh topology**. The refinement +- The coordinate-deforming adaptation movers (the MMPDE mover / + `follow_metric`) **preserve mesh topology**. The refinement hierarchy survives them, so geometric multigrid keeps working as the mesh deforms — precisely where GAMG struggles with the resulting anisotropy. - A **true remesh** (a topology change) collapses the hierarchy to a single @@ -92,23 +93,165 @@ This is the key reason to prefer FMG when you adapt: GAMG on the next solve, so nothing breaks — you simply lose the geometric path until a hierarchy is available again. -## Parallel coarse solve +## The coarse level must be small, or the bottom solve stops being free -The default coarse solver is a serial direct solve (`mg_coarse_pc_type = "lu"`), -which is the fast, simple choice in serial and for modest core counts. For large -parallel partitions, replicate or gather the (small) coarse grid instead: +Multigrid is cheap because each level costs a fraction of the one above it, and +the bottom of the hierarchy is solved **exactly** at negligible cost. The word +doing the work in that sentence is *negligible*. A direct (or SVD) coarse solve +is only free if the coarsest level is genuinely tiny; nothing in the method makes +it so, and nothing warns you when it is not. + +### What the default does, and why + +The bundle sets a redundant direct solve: + +```python +mg_coarse_pc_type = "redundant" +mg_coarse_redundant_pc_type = "lu" +``` + +`redundant` copies the whole coarse system to **every rank** and factors it +there. For a genuinely small system that is the right trade: communicating +across ranks to solve a few thousand unknowns collectively costs more than +solving the same thing everywhere. It is also np-safe, which a bare `lu` is not +— a serial LU cannot factor a distributed matrix and fails at `np > 1` with +`DIVERGED_LINEAR_SOLVE` after zero iterations. In serial, `redundant` + `lu` is +identical to `lu`. + +This is not an Underworld idiosyncrasy, and it is worth being clear about that +before blaming the choice for a memory problem. **PETSc makes the same choice on +its own.** With nothing set under `mg_coarse_*`, `PCSetUp_MG` selects +`PCREDUNDANT` when the communicator has more than one rank and `PCLU` when it +does not — its own source comment reads "coarse solve is (redundant) LU by +default" (`src/ksp/pc/impls/mg/mg.c`). Confirmed by inspection at `np` = 1, 2 +and 4 on PETSc 3.25. + +Underworld sets it explicitly rather than inheriting it, so a bundle applied +after another cannot pick up a sibling's leftover options (#468) — but the value +is the one PETSc would have picked anyway. Deleting these keys and letting PETSc +decide would change nothing here; the leverage is in the hierarchy, not the +bottom solve. + +### The failure mode: replicated memory + +Because the factorization is replicated, its memory is paid **per rank**, and +the total scales with the coarse system size *times* the core count. That is +harmless for a small bottom level and fatal for a large one. + +Measured on Setonix (issue #644), a production model at `np = 30` under a +900 MB/rank cgroup cap: + +| Base mesh | Refinement | Coarsest velocity DOFs | Peak RSS/rank | Linear solve | +|---|---|---|---|---| +| 10 km | 2 | 14,195 | **683 MB** — OOM killed | ~100 s | +| 20 km | 3 | ~1,900 | **416 MB** | 111 s (+11%) | + +Both configurations have essentially the same *finest* grid (233k vs 227k +velocity DOFs) and converge identically — the same SNES iteration sequence. The +only real difference is how far down the hierarchy goes. Shrinking the coarsest +level alone removed 261 MB/rank, which matches the gather-plus-fill arithmetic +(~45 MB for the gathered copy, ~232 MB for the factor at a 5.16x fill ratio). + +### The fix is the hierarchy, not the coarse solver + +The instinct on an OOM is to reach for a different bottom solve. That treats the +symptom. A 14,000-DOF coarsest level means the hierarchy was built from too fine +a base with too few levels — the bottom of the ladder never got small, so the +"solve the coarse problem exactly, for free" premise never held. + +Keep the finest resolution you need and add levels underneath it: + +```python +# OOM: fine base, few levels -> the bottom is still large +mesh = uw.meshing.UnstructuredSimplexBox(..., cellSize=0.10, refinement=2) + +# same finest grid, one more level, a much smaller bottom +mesh = uw.meshing.UnstructuredSimplexBox(..., cellSize=0.20, refinement=3) +``` + +Each refinement level divides cell size by two, so the level above has 4x the +cells in 2-D and 8x in 3-D — measured exactly, 66 / 264 / 1056 and +184 / 1472 / 11776 on a `refinement=2` box. Doubling the base cell size and +adding one level therefore lands on the same finest grid while making every +coarser level — the bottom one included — about 4x (or 8x) smaller. + +"The same finest grid" is approximate, because the base mesh is unstructured and +gmsh does not produce exactly 4x the cells for half the target size. Measured on +a unit box, `cellSize=0.10, refinement=2` gives 3,872 finest cells against +`cellSize=0.20, refinement=3` at 4,224 — 9% more, while the coarsest level drops +to 27% of its former size. The production model in #644 landed the other way: +227k against 233k. Expect a few percent either side, and check rather than +assume. + +Check what you actually built rather than assuming; `dm_hierarchy[0]` is the +coarsest level and `[-1]` is the mesh you solve on: ```python -# redundant: copy the coarse grid to every rank and solve it there -stokes.petsc_options["fieldsplit_velocity_mg_coarse_pc_type"] = "redundant" -stokes.petsc_options["fieldsplit_velocity_mg_coarse_redundant_pc_type"] = "lu" +for i, dm in enumerate(mesh.dm_hierarchy): + cells = uw.mpi.comm.allreduce(dm.getStratumSize("depth", mesh.dim)) + uw.pprint(f"level {i}: {cells} cells") +``` -# or telescope onto a sub-communicator for very large runs -# stokes.petsc_options["fieldsplit_velocity_mg_coarse_pc_type"] = "telescope" +There is no single DOF number that is "small enough", because the quantity that +has to fit is the replicated factor **per rank**, against your per-rank memory +budget — the core count is in the arithmetic, not just the coarse size. The #644 +measurement calibrates it: ~14k velocity DOFs cost ~300 MB/rank at a 5.16x LU +fill, so a 900 MB/rank cap is already lost before the rest of the solver is +counted, while ~1.9k DOFs cost little enough to disappear. + +As a working rule, keep the coarsest level in the low thousands of DOFs, and +treat five figures as a bug in the hierarchy rather than a tuning question. If +you are near the line, estimate it rather than guessing: the factor grows faster +than the DOF count, and you pay it on every rank at once. + +### When the coarse operator has a null space: SVD + +One case does require a different bottom solve, and the solver handles it for +you. A **rotated** velocity block whose domain leaves rigid rotations free — a +closed circle (one mode), a spherical shell (three) — hands those modes to the +Galerkin-coarsened coarse operator, because the rotations survive the +constraint. `redundant`/LU hits a zero pivot on a singular coarse operator and +fails with `SUBPC_ERROR` (outer reason `-11`), so the rotated path selects SVD +instead. + +It is deliberately **not** a blanket choice for every rotated problem. It keys +on the count of *verified rotation* modes, because a rotated problem with +Dirichlet walls, or a split-fault contact box, has none — and there an SVD is +pure cost. A 3-D P2 coarse level is a **dense** factorization: measured, it +accounted for most of ~0.8 s per V-cycle application at healthy iteration counts +(#622). + +```{warning} +There is no `petsc_options` override for the coarse solve **on the rotated +path**. That route writes its multigrid bundle under the velocity sub-PC's own +prefix, applies it, and deletes the keys again; anything you set through +`solver.petsc_options[...]` lives under the solver's SNES prefix and never +reaches it. The override below works on the standard path only. ``` -Because `"auto"` does not overwrite options you set yourself, you can keep -`preconditioner = "auto"` and just override the coarse solver as above. +Because SVD is dense, it is affordable only on a genuinely small coarse level — +which makes the sizing guidance above a hard requirement on this path rather +than a preference. + +### Overriding the coarse solve yourself + +On the standard (non-rotated) path, `"auto"` does not overwrite options you set +yourself, so you can keep `preconditioner = "auto"` and change just the bottom +solve: + +```python +stokes.preconditioner = "auto" +stokes.petsc_options["fieldsplit_velocity_mg_coarse_pc_type"] = "svd" +``` + +```{note} +`PCTELESCOPE` — the usual PETSc answer to "replicating onto every rank is too +many copies", which gathers the coarse system onto a sub-communicator instead — +**is not available here**. It has no DMPlex support, and every Underworld mesh +is a DMPlex, so requesting it aborts the solve with `Support for DMPLEX is +currently not available` (PETSc error 56). There is no bottom-solve escape hatch +from an over-large coarse level: the hierarchy is the lever. +``` ## Benchmark: FMG vs GAMG on a deforming adaptive mesh diff --git a/docs/advanced/particle-population-and-materials.md b/docs/advanced/particle-population-and-materials.md new file mode 100644 index 000000000..2cdf5d2a2 --- /dev/null +++ b/docs/advanced/particle-population-and-materials.md @@ -0,0 +1,508 @@ +--- +title: "Particles: population control and materials" +--- + +# Particles: population control and materials + +Two things a particle method has to get right, and how Underworld3 does them. + +**Population control** keeps every cell holding enough particles to be worth +integrating, however hard the flow deforms the swarm. + +**A material index read where the assembler actually looks** keeps a material +interface where the particles put it, instead of smearing it over a cell. + +Both are demonstrated below with runnable scripts. Neither changes how you +write anything: a particle field reaches the mathematics through a proxy, and +what the proxy changes is where the sampling happens, not what you can say. + +## Population control + +A swarm is not conserved cell by cell. Particles leave through outflow +boundaries and nothing arrives through inflow boundaries unless you put it +there; strong deformation sweeps them out of some cells and piles them into +others. A cell that ends up with too few particles cannot support the +reconstruction that gives the mesh its material properties, and one with none +carries no information at all. + +`Swarm.repopulate()` takes a census of the particles per cell and refills the +starved ones from the cell's own lattice, choosing the lattice points furthest +from the particles already there. A new particle takes each swarm variable +from its neighbours: a bounded reconstruction for a continuous field, and its +nearest neighbour's value whole for an integer field, because the average of +two material labels is not a material label. + +Set `swarm.population_control` and every `advection()` ends with a +repopulation, before anything reads the swarm again: + +```python +swarm.population_control = dict() # back to the populate() density +swarm.population_control = dict(min_per_cell=8) # or a floor you choose +swarm.population_control = dict(min_per_cell=8, values={T: 0.0}) # an inflow datum +``` + +Or call it yourself, which is what you want if the swarm moves by some route +of your own: + +```python +added, removed = swarm.repopulate(min_per_cell=8) +``` + +`values` overrides what a new particle receives, per variable, with a constant +or a callable of the coordinates. That is how an inflow boundary gets the +right material or temperature rather than a copy of whatever drifted past. +`max_per_cell` will also thin over-full cells, by dropping the particles +closest to a neighbour; it is off by default because discarding particles +costs accuracy. + +### Demonstration + +`docs/examples/utilities/intermediate/Ex_Swarm_Population_Control.py`. Pure-shear extension +$\mathbf{v} = (x, -y)$ on a fixed mesh, so the side walls are outflow and the +top and bottom are inflow, with a marker layer through the middle. Particles +that leave are deleted (`mesh.return_coords_to_bounds = None`), which is what +an open boundary means. + +```{figure} figures/repopulation_particles.png +:alt: Particles in an extending box, with and without population control + +Without population control (top) the box drains: after two time units 1020 of +the original 7640 particles remain and 626 of the 764 cells are empty, and the +marker layer has shredded into streaks. With it (bottom) no cell is ever +empty, and the layer thins as the extension requires rather than falling apart. +``` + +```{figure} figures/repopulation_counts.png +:alt: Particle count and starved-cell count against time + +The count in the box and the number of starved cells. Population control is +not fighting the outflow, which is physical and correct; it is refilling the +inflow side, where the flow brings nothing. +``` + +### What it is worth, in a number + +The figures above are particle scatters. The field-level cost depends on how +the material is read, and it is worth knowing which case needs the refilling. +Pure shear thins a marker layer by $e^{-t}$, so after $t = 2$ its area should +be 13.5% of where it started; anything else is the representation failing: + +| `proxy_location` | population control | layer area vs exact | +|---|---|---| +| `"cells"` | on | 1.03x | +| `"cells"` | off | **5.03x** | +| `"integration_points"` | on | 1.06x | +| `"integration_points"` | off | 1.07x | + +Starvation wrecks the per-cell fit, which needs particles *in that cell*. The +nearest-particle mapping degrades gracefully — an empty cell still finds a +plausible particle nearby — so with the default mapping the field-level answer +barely moves, and losing particles shows up only in the swarm's own +bookkeeping. Population control is cheap and always safe; `"cells"` is where +it is *necessary*. + +## Materials + +Name the materials, say where they are, and stop. + +```python +materials = uw.swarm.MaterialSwarm(mesh, fill_param=3) + +materials.add("mantle", shear_viscosity_0=1.0, density=3300) +materials.add("slab", shear_viscosity_0=1.0e3, density=3400) + +materials["slab"] = mesh.X[1] > 0.53 + +stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel +stokes.materials = materials +stokes.bodyforce = -materials.density * mesh.CoordinateSystem.unit_e_1 +``` + +That is the whole interface. `stokes.materials = materials` sets every +constitutive-model parameter the materials declare *and* the model recognises, +by name — here `shear_viscosity_0`. A property the model does not own, +`density`, is not pushed anywhere; it is available as a blended symbol for the +model script to use where it belongs. Properties can be changed and regions +repainted afterwards: the blend is symbolic and the push repeats. + +A `MaterialSwarm` **is** a `Swarm`, so everything on the previous page still +applies to it. Declare any extra per-particle state **before** the materials +are first read, though — the first read allocates the particles, and a swarm +cannot gain variables once it holds them: + +```python +materials = uw.swarm.MaterialSwarm(mesh, fill_param=3) +materials.add("mantle", shear_viscosity_0=1.0) +materials.add("slab", shear_viscosity_0=1.0e3) + +strain = uw.swarm.SwarmVariable("eps", materials, 1, # per-particle state + proxy_location="integration_points", + proxy_sampling="share") + +materials["slab"] = mesh.X[1] > 0.53 # reads nothing yet +stokes.materials = materials # this allocates + +materials.population_control = dict(min_per_cell=8) +materials.advection(v.sym, dt) +``` + +```{warning} +Allocating the level sets is **collective**: every rank must reach it +together. It happens on the first *read* of any material property — +`materials.density`, `materials["x"].mask`, `materials.index`, or +`stokes.materials = ...` — so a read inside a rank-local branch +(`if rank == 0: ...`) deadlocks. Call `materials.build()` at a point where +every rank is together if the ordering is ever in doubt. +``` + +Regions can be a symbolic condition on the mesh coordinates (and `&`, `|`, `~` +combinations of them), a boolean array over the particles, or a callable of the +coordinate array. `materials.add(..., where=...)` is the same thing said in one +line. + +Assignment **replaces**: `materials["slab"] = A` followed by +`materials["slab"] = B` leaves the material at B, and anything it held outside +B reverts to the first material declared. Across *different* materials the +later assignment wins where they overlap. + +A few things are refused rather than half-done, because each of them used to +produce a plausible-looking wrong answer: + +| | | +|---|---| +| adding or deleting a material after the first read | the index of a material *is* which level set is its | +| two distributions sharing an explicit `name` on one mesh | they would share level sets, silently | +| `mixing(...)` naming a property no material declares | it would have no effect | +| harmonic mixing of a property some material sets to zero | `1/Σ(φᵢ/vᵢ)` divides by it | +| a mesh label value that does not exist | asking PETSc for it aborts the run | + +Dimensional values are non-dimensionalised when they are **read**, not when +they are declared, so a registry written before the model's reference +quantities are set means the same thing as one written after. + +### What a material is, and where it is + +Those are two questions, and Underworld3 keeps them apart. + +**What** is a `uw.MaterialRegistry` entry: a name and a table of properties, +with optional description and reference. A registry knows nothing about +geometry, so it can be written before there is a mesh, shared between models, +and exported and read back as plain data. + +```python +rocks = uw.MaterialRegistry() +rocks.add("mantle", shear_viscosity_0=1.0, density=3300, + description="upper mantle", reference="Turcotte & Schubert (2014)") +rocks.add("slab", shear_viscosity_0=1.0e3, density=3400) +``` + +**Where** is a *distribution*, and there are two. `MaterialSwarm` carries the +materials on particles, so they advect with the flow. `MaterialRegions` ties +them to the mesh: + +```python +materials = uw.MaterialRegions(mesh, registry=rocks) +materials["slab"] = "Slab" # a gmsh physical group +materials["slab"] = mesh.X[1] > 0.53 # or a geometric condition + +stokes.materials = materials +``` + +Both take the same registry, declare materials the same way, and hand the same +thing to the solver. `materials.add(...)` on either is shorthand that writes +into its registry, so a two-material script never has to mention one. + +| | `MaterialSwarm` | `MaterialRegions` | +|---|---|---| +| the material moves | yes | no | +| level sets are | sampled from the particles | exact, 0 or 1 | +| needs population control | yes | no | +| interface position | sub-cell, to the particle density | sub-cell, to the rule | +| cost per step | a proxy fill | nothing; built once | + +Use regions when the geometry is fixed — a layered model, an inclusion, a +basin. It is exact and free. Use the swarm when the material is carried by +the flow, or when it has to carry per-particle history as well. + +### A property can be a law, not just a number + +This is the whole reason for the machinery underneath: + +```python +materials.add("crust", shear_viscosity_0=eta_0 * sympy.exp(-T.sym[0])) +``` + +There is no number to store at an integration point for that material, so it +cannot be handled by sampling a viscosity field. It is carried symbolically and +combined with the other materials' laws by the level sets described below. + +### There is no route that puts the property on the particles + +Carrying the viscosity itself on the particles and sampling it looks simpler +and is the wrong shape. PETSc calls the compiled pointwise functions with +values tabulated at the rule points, so a solver handed a sampled *answer* +cannot reach the parts of the constitutive law it needs — the tangent, the +yield surface, the history update. Keeping the law symbolic and letting the +materials weight it is what makes a material model composable. (The mechanical +symptom is easy to see too: the linear-exact reconstruction a smooth field +wants overshoots a factor-1000 viscosity jump to −219, and a negative viscosity +is not a viscosity. Asking for nearest-particle sampling on a plain +`SwarmVariable` raises, and the error says so.) + +### What is underneath + +A `MaterialSwarm` carries one integer label per particle and presents the mesh +with one *level set* per material — a partition of unity — so that a property +is `Σ φᵢ · valueᵢ`. Where the masks are 0 or 1 that sum is a select, and if +every property were a number a single stored coefficient field would do the +same job; the sum earns its place the moment a property is a law, because there +is then no other way to combine N expressions into one symbol the assembler can +compile. + +That machinery is `uw.swarm.IndexSwarmVariable` and its `createMask`. Models +written before `MaterialSwarm` use it directly and still work; new models +should not need to see it. The two things worth knowing about it are the two +arguments `MaterialSwarm` passes through, below. + +#### `proxy_location` — where the level sets live + +Where the level sets are stored is what the assembler actually reads: + +```python +materials = uw.swarm.MaterialSwarm(mesh, proxy_location="cells") +``` + +| `proxy_location` | the level sets are | at an interface | +|---|---|---| +| `"integration_points"` **(default)** | a value at each point of the quadrature rule | exactly 0 or 1, and the interface keeps its sub-cell position | +| `"cells"` | a polynomial material fraction per cell | a sharp step at cell edges, with a gradient inside the cell | +| `"nodes"` | a continuous field per material | a node on the interface averages both, so the cells either side see a viscosity that is neither | + +The default is the integration points: it is the classic particle-in-cell +material mapping of Ellipsis and Underworld, and it is measurably the best of +the three. The nodal option is kept for continuity with existing models, but +its smear is about one cell wide *however many particles you add* — that is a +property of the basis, not of the swarm — and the current distance-weighted +fill actually *widens* the band as the swarm is refined. + +#### `proxy_sampling` — what each point reads + +How the particles in a cell become the value at each of its integration +points. Applies only to `proxy_location="integration_points"`. + +| `proxy_sampling` | each point takes | masks | +|---|---|---| +| `"nearest"` **(default)** | the material of its nearest particle | exactly 0 or 1 | +| `"share"` | the material fractions of the particles it speaks for — those whose nearest integration point *within their own cell* is this one | fractional where a cell is crossed | + +`"nearest"` is sharp and assumption-free: nothing is mixed, so no mixing rule +is implied. It sub-samples, though — at ten particles per cell and six rule +points, most particles never reach the assembly. + +`"share"` is the cell-restricted Voronoi share, and it is the closest thing to +the true Voronoi integration that a fixed quadrature rule allows: the rule +points partition their own cell between them, every particle lands in exactly +one part, and nothing crosses a cell boundary. For an *identity* that buys +fractional masks in the crossed cells, and then the answer depends on how you +blend — see the trap below. + +#### What the choice is worth + +Interface at $y = 0.53$ on an irregular mesh, so no scheme can be exact (the +P2 velocity cannot hold a kink inside a cell), viscosity contrast 1000, +against the exact layered Couette profile: + +| particles per cell | `"nearest"` | `"share"` + `createMask` | `"share"`, blended harmonically | +|---|---|---|---| +| 3 (2 420) | 3.48e-2 | 3.53e-2 | 1.83e-2 | +| 8 (10 890) | **1.85e-2** | 3.53e-2 | 1.27e-2 | +| 15 (32 912) | 1.85e-2 | 3.61e-2 | **1.24e-2** | + +```{figure} figures/material_share.png +:alt: Identity error against particle density, and the history overshoot + +Left: only `"nearest"` and the harmonically-blended share improve as particles +are added; the arithmetic (Voigt) blend of fractional masks is flat. Right: the +share is bounded by the particle values because it is an average of them, while +the reconstruction's overshoot grows with the swarm. +``` + +Three things to read off it. + +**`"nearest"` converges to a floor and then stops.** By about eight particles +per cell the error stops moving: what limits it is the quadrature rule and the +velocity space, not the swarm. Past that point, refine the mesh — more +particles buy nothing. + +**Fractional masks need a mixing rule you have chosen.** `createMask` blends +*arithmetically*, which for a flux at a common strain rate is a Voigt average, +and on a sharp contrast a Voigt average does not converge with particle +density at all — the middle column is flat. The same masks blended +harmonically (Reuss, `1.0 / material.createMask([1/η₀, 1/η₁])`) converge, and +past the `"nearest"` floor. So fractions are worth having when the material +genuinely *is* a sub-cell mixture and you have picked the rule on physical +grounds; for an interface, `"nearest"` says the true thing (nothing is mixed) +without you having to. + +**Cost is not the discriminator.** Filling the level sets took 1.0 / 1.7 / +3.7 ms for `"nearest"` and 0.7 / 1.3 / 3.9 ms for `"share"` at the three +densities. + +With the interface on mesh edges instead, the exact velocity lies in the P2 +space and the only error left is the material representation: + +| `proxy_location` | assembled $\int \eta$ (exact 500.5) | velocity $L_2$ error | +|---|---|---| +| `"nodes"` | 500.5000 | 8.0e-2 | +| `"integration_points"` | 500.5000 | **1.8e-7** | +| `"cells"` | 500.5000 | **1.8e-7** | + +All three integrate the viscosity correctly *in the mean*, which is exactly +why a bulk diagnostic cannot see the difference. Only the placement differs, +and the placement is what the solve feels. + +```{figure} figures/material_index.png +:alt: The material mask across the interface and the resulting velocity error + +Left: the upper-material mask along a line crossing the interface, as the weak +form sees it. The nodal level set ramps linearly across a whole cell; the other +two are a step in the right place and lie on top of each other. Right: the +resulting error in the velocity against the exact layered flow. +``` + +`docs/examples/utilities/intermediate/Ex_Swarm_Material_Index.py` runs it. + +### Mixing, when the masks are fractional + +`materials.mixing(shear_viscosity_0="harmonic")` chooses how a property is +blended. With the default sampling exactly one mask is 1 at every integration +point, so every rule gives the same answer and there is nothing to choose. With +`proxy_sampling="share"` there is, and the table above is the reason to choose +it on physical grounds rather than by trying both. + +## Material state and history + +Identity is not the only thing particles carry. A viscoelastic stress, an +accumulated strain, a damage variable — these are *state*, earned by being +advected, and every particle's is different. They ride on ordinary +`SwarmVariable`s, and they get the same choice of where the assembler reads +them and how: + +```python +stress = uw.swarm.SwarmVariable( + "tau", materials, (2, 2), # the MaterialSwarm is the swarm + proxy_location="integration_points", + proxy_sampling="share") # every particle's history contributes +``` + +`Lagrangian_Swarm` takes the same argument, so a viscoelastic stress history +carried on the particles is read the same way: + +```python +DFDt = uw.systems.ddt.Lagrangian_Swarm( + swarm=materials, psi_fn=sympy.Matrix.zeros(2, 2), + vtype=uw.VarType.SYM_TENSOR, degree=1, continuous=False, + order=2, step_averaging=1, + proxy_location="integration_points", proxy_sampling="share") +``` + +Build it before anything reads the materials: it adds swarm variables, and a +swarm cannot gain variables once it holds particles. + +For state, `"share"` is the one to reach for, and for a different reason than +it was rejected for identity: + +- **It uses the whole swarm.** Each point averages the particles it represents + rather than adopting one of them, so a history that varies within a cell is + represented by all of it. This is the "more PIC than not" part: the + quadrature rule is fixed, but what it reads can still be a weighted account + of every particle. +- **It cannot cross a material boundary.** The default `"reconstruct"` gathers + from the nearest particles by distance, and that stencil ignores cell walls; + across a jump it both smears and overshoots. Measured on a discontinuous + particle field read at the integration points (interface at $y = 0.53$, + $h = 0.1$): + + | particles per cell | `"reconstruct"` range | `"share"` range | + |---|---|---| + | 3 | −0.051 … +1.137 | 0.000 … 1.000 | + | 8 | −0.068 … +1.108 | 0.000 … 1.000 | + | 15 | −0.109 … +1.097 | 0.000 … 1.000 | + + The share is bounded by the particle values by construction — it is an + average of them — so it cannot invent a stress the swarm never held. The + reconstruction's overshoot *grows* as particles are added. + +Keep `"reconstruct"` (the default) for a field that really is smooth: it is +exact for linear fields, where the share carries a small averaging error. + +**Cost.** The share needs to know each particle's cell, and locating particles +is the expensive half: 18.8 ms for 32 912 particles against 3.2 ms for the +share itself, on a mesh of 242 cells. That location is cached on the swarm +until the particles move and is reused by every share variable and by +`repopulate`, so a model with several history fields and population control +pays it once per step. For comparison, the `"reconstruct"` path costs 8.1 ms +per step on the same swarm (its cached operator is geometry-only, so it is +rebuilt whenever the particles move). + +## What can be said about a particle field + +A particle field is a first-class citizen of the symbolic algebra: it carries a +symbol, and that symbol goes wherever a mesh variable's symbol goes. The one +exception is a derivative of the integration-point form. + +| | `"nodes"` | `"integration_points"` | `"cells"` | +|---|---|---|---| +| arithmetic with mesh variables and `sympy` | yes | yes | yes | +| `uw.maths.Integral` | yes | yes | yes | +| `uw.function.evaluate` anywhere | yes | yes | yes | +| projection onto a mesh variable | yes | yes | yes | +| viscosity, body force, any solver term | yes | yes | yes | +| a **gradient** of the expression | yes | refused | yes | + +The element that holds a value at each integration point has no gradient to +give, so the compiler refuses one rather than returning the silent zero its +tabulation would produce. The gradient is still available from a projection of +the same data, and `"cells"` is that projection: its level sets are a +least-squares polynomial per cell, so they differentiate directly and with no +global solve. Recovering $\partial_x$ of a quadratic particle field: + +| where the proxy lives | gradient error | +|---|---| +| `"nodes"`, degree 1 | 2.1e-3 | +| `"nodes"`, degree 2 | 1.1e-4 | +| `"cells"`, degree 1 | 1.3e-3 | +| `"cells"`, degree 2 | **2.4e-7** | +| a global L2 projection onto P2, then differentiate | 1.1e-4 | +| `"integration_points"` | refused | + +The per-cell fit is the most accurate of them because it is local and exact for +polynomials up to its degree, and no further projection follows it. + +**Two paths, and the difference is deliberate.** A weak form and a query are +not the same thing: + +- **In a weak form**, a derivative of an integration-point field is *refused*. + Answering would mean either the silent zero its own tabulation gives, or a + reconstruction chosen behind your back and paid for at every assembly. + Which discretisation the gradient comes from is a modelling decision, so it + stays yours: build the variable with `proxy_location="cells"` and the level + sets are already polynomials. +- **`uw.function.evaluate`** is a query, and it *answers*. It fits the + integration-point values cell by cell and differentiates that, once, for + this call. The result converges (2.4e-3, 6.1e-4, 2.6e-4 for a quadratic + field as the cell size halves from 1/5 to 1/20) but it is a *recovered* + gradient, so treat it as a diagnostic rather than as the field's own + derivative. The `"cells"` route reaches 2.4e-7 on the same field. + +So the rule is: sample at the integration points when you want a value placed +exactly, fit per cell when you want to differentiate, and expect `evaluate` to +help you look at a gradient either way. + +## What this rests on + +The reconstruction behind `"cells"`, the delta element behind +`"integration_points"`, the share, and the guards that keep them well posed +are described in {doc}`../developer/subsystems/integration-point-variables`. +The same machinery carries semi-Lagrangian and fully Lagrangian histories, +including the viscoelastic stress history. diff --git a/docs/advanced/performance.md b/docs/advanced/performance.md index 8bf2113b6..6f55498ad 100644 --- a/docs/advanced/performance.md +++ b/docs/advanced/performance.md @@ -22,6 +22,71 @@ Tools for identifying bottlenecks: - Batch operations for better performance - Parallel scaling +## Choosing MPI ranks for a MatMult-bound Stokes solve (#635) + +A large nonlinear Stokes solve is usually **memory-bandwidth bound, not +compute bound**. In a controlled Gadi campaign on a 2-D power-law Barr–Houseman +fault benchmark (P2/P1, cell size 0.01, `n=3`, viscosity contrast 1e5, tolerance +1e-8), roughly **97% of solve time was inside PETSc `MatMult`**, and every +configuration ran the identical numerical workload — four SNES solves, 24 KSP +solves, the same mesh SHA-256. + +That is the useful diagnostic: when the solver path is fixed and the runtime still +moves, you are looking at throughput, not convergence. + +| queue / launch | ranks | Stokes solve (s) | peak memory | `MatMult` (Mflop/s) | +|---|---:|---:|---:|---:| +| `normal`, default placement | 8 | 4932 | 8.99 GB | 6707 | +| `normal`, explicit core binding | 8 | 5751 | 9.13 GB | 5738 | +| `normal`, binding + no-ML attempt | 8 | 5708 | 8.97 GB | 5782 | +| `normal`, binding + no-ML attempt | 4 | 8964 | 7.31 GB | 3842 | +| `normal`, binding + no-ML attempt | 12 | **3288** | 10.1 GB | **9995** | +| `normalsr` (Sapphire Rapids) | 8 | 3992 | 9.25 GB | 8308 | + +What this supports: + +- **Runtime tracks `MatMult` throughput**, not nonlinear behaviour. Twelve ranks gave + the best wall time in this sample (56:02); four ranks cut peak memory to 7.3 GB but + took 2:30:48. +- **Fewer ranks buy memory, not speed.** If a job is near a memory ceiling, dropping + ranks is a legitimate trade — but expect the wall time to move roughly inversely. +- **Newer nodes help.** The `normalsr` 8-rank run beat the `normal` 8-rank run, though + it was still slower than 12 ranks on `normal`. + +```{warning} +**Explicit core binding did not help here**, and this campaign cannot say whether it +ever does. `--map-by core --bind-to core` was *slower* than default placement at 8 +ranks (5751 s vs 4932 s), but the configurations ran concurrently on different nodes, +and a repeat of the same default-placement case varied by 8.6% on its own (4540 s vs +4932 s). Node-to-node variation is the same size as the effect. A controlled same-node +comparison, or a socket-distributed mapping, is needed before drawing any affinity +conclusion — do not read this table as a recommendation against binding. +``` + +**Harmless Open MPI noise.** Messages of the form + +```text +[LOG_CAT_ML] component basesmuma is not available but requested in hierarchy +[LOG_CAT_ML] ml_discover_hierarchy exited with error +``` + +are **nonfatal**. Setting `OMPI_MCA_coll=^ml` neither suppressed them nor changed the +timings materially. + +**Serialise your BLAS.** Every run above set one thread per rank, which is what you +want when MPI already owns the cores: + +```bash +export OPENBLAS_NUM_THREADS=1 +export OMP_NUM_THREADS=1 +export MKL_NUM_THREADS=1 +export NUMEXPR_NUM_THREADS=1 +``` + +**Still open.** A recommended Gadi rank placement for bandwidth-bound solves, whether +UW3's examples should ship explicit mapping/binding options, and a small repeatable +PETSc timing benchmark to accompany the setup notes — see #635. + ## Related Documentation - [Developer: Performance Guidelines](../developer/guidelines/performance-optimization.md) diff --git a/docs/advanced/semi-lagrangian-time-integration.md b/docs/advanced/semi-lagrangian-time-integration.md index 23a935db2..68b12b609 100644 --- a/docs/advanced/semi-lagrangian-time-integration.md +++ b/docs/advanced/semi-lagrangian-time-integration.md @@ -112,6 +112,19 @@ $[\theta,\,1-\theta]$: `theta` is settable after construction: `adv_diff.DFDt.theta = 1.0`. +## The Eulerian alternative + +`uw.systems.AdvDiffusion` solves the same equation without a trace-back: +all terms are assembled on the mesh, implicit in time, with SUPG +stabilisation. Its `order=` and `theta=` arguments mean what they mean here: +`order=1, theta=0.5` is Crank-Nicolson, `order=2` is BDF2, built from the same +stored history as above. The scheme is stable at any +cell Courant number, so cells refined for a Stokes problem never limit the +transport timestep; its accuracy is set by how far the transported feature +moves per step. The semi-Lagrangian scheme's accuracy is instead set by how +far a characteristic turns per step. The measurements behind that split are +in `docs/developer/design/eulerian-supg-transport.md`. + ## Related options - **`monotone_mode`** (`"clamp"` / `"pick"`) bounds the semi-Lagrangian diff --git a/docs/advanced/split-node-faults.md b/docs/advanced/split-node-faults.md new file mode 100644 index 000000000..51750cd6a --- /dev/null +++ b/docs/advanced/split-node-faults.md @@ -0,0 +1,303 @@ +--- +title: "Split-Node Faults: Zero-Thickness Frictional Surfaces" +--- + +# Split-Node Faults + +A fault, mechanically, is a surface across which the velocity field jumps: +slip, not strain. Underworld3 can represent a fault exactly that way — the +mesh nodes along a conforming fault surface are duplicated so the two +sides share no degrees of freedom, the two coincident copies are tied by a +strong no-opening constraint, and the tangential jump (the slip rate) +either emerges freely or is governed by a friction law. There is no weak +layer, no viscosity contrast, and no band width to resolve: the fault has +genuinely zero thickness, and the surrounding mesh can be uniform. + +This is one of two fault representations in Underworld3, and they are +complementary rather than competing: + +- **Split-node surface** (this page) — the fault is a sharp interface. + The right tool when slip is the quantity of interest: earthquake-cycle + models, stress transfer, plate-boundary-style discontinuities. +- **[Finite-width TI weak zone](vep-transverse-isotropy-faults.md)** — + the fault is a thin volume with anisotropic rheology. The right tool + when the fault-zone width is itself constitutive (rate-and-state + linked to a physical gouge width), or when the same fault must appear + as a damage zone for another equation (e.g. porous flow). + +## Quick start (2-D) + +A fault is a polyline with both tips strictly inside the domain. One call +places its points onto mesh vertices, cuts the mesh so the fault becomes +a conforming facet chain, and splits it: + +```python +import numpy as np +import underworld3 as uw + +mesh = uw.meshing.UnstructuredSimplexBox(cellSize=0.05) +fault_points = np.array([[0.3, 0.4], [0.7, 0.6]]) + +child = mesh.add_fault(("Fault", fault_points)) +``` + +`child` is a new, standalone mesh carrying boundaries `FaultPlus` and +`FaultMinus` — two geometrically coincident copies of the fault — plus +the record of which degree of freedom pairs with which. The source mesh +is untouched: when the fault moves, call `add_fault` again on the base +mesh at the new position (nothing is cumulative). + +A frictionless (perfectly slippery) fault is then a one-line boundary +condition, and the solve is an ordinary `solve()`: + +```python +v = uw.discretisation.MeshVariable("V", child, child.dim, degree=2) +p = uw.discretisation.MeshVariable("P", child, 1, degree=0, + continuous=False) +stokes = uw.systems.Stokes(child, velocityField=v, pressureField=p) +stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel +stokes.constitutive_model.Parameters.shear_viscosity_0 = 1.0 + +x, y = child.X +for wall in ("Bottom", "Top", "Left", "Right"): + stokes.add_dirichlet_bc((y - 0.5, 0.0), wall) # far-field shear + +stokes.add_fault_bc(0, boundary="Fault") # frictionless +stokes.solve() +``` + +The no-opening constraint $[\mathbf v]\cdot\hat n = 0$ is always imposed +strongly (the measured normal jump is machine zero), and with `conds = 0` +the shear traction on the fault is exactly zero, so slip develops freely +— the stress-driven crack. Read the slip through the pairing: + +```python +from underworld3.utilities import fault_contact + +s, V, leak = fault_contact.fault_slip( + stokes, "Fault", stokes._rotated_freeslip_info) +# s: along-fault coordinate; V: slip rate; leak: normal jump (~1e-17) +``` + +```{warning} +Always read fault quantities through the pairing-based helpers +(`fault_slip`, `fault_pair_jumps`, `fault_normal_traction`). The two +sides of the fault are *geometrically coincident*, so any query by +coordinate — `uw.function.evaluate` included — sees one side only and +silently averages or picks arbitrarily. +``` + +## Quick start (3-D) + +In 3-D the fault is a triangulated patch whose rim stays strictly inside +the domain. `uw.meshing.BoxInternalPatch` embeds a planar polygon patch +conformingly in a simplex box (a disc is a many-sided polygon), and +`split_fault` does the rest: + +The preferred input is a `uw.meshing.FaultSurface` — the object names +the patch, its rim polygon becomes the conforming embed, and it rides +onto the split mesh so the constraint frame can come from the surface's +own face normals: + +```python +from underworld3.utilities.fault_split import split_fault + +fault = uw.meshing.FaultSurface("Fault", points) # or .from_vtk(...) +fault.triangulate() +mesh = uw.meshing.BoxInternalPatch(cellSize=0.05, patch_points=fault, + patch_cellSize=0.02) +child = split_fault(mesh, "Fault") +... +stokes.add_fault_bc(0, boundary="Fault", normal="surface") +``` + +The surface must be planar for now (`rim_polygon` refuses otherwise — +a genuinely curved sheet waits on the discrete-entity embed). A raw +`(N, 3)` polygon via `patch_points=` still works for quick setups: + +```python +patch = np.array([[0.5, 0.3, 0.3], [0.5, 0.7, 0.3], + [0.5, 0.7, 0.7], [0.5, 0.3, 0.7]]) +mesh = uw.meshing.BoxInternalPatch(cellSize=0.05, patch_points=patch, + patch_name="Fault") +child = split_fault(mesh, "Fault") +``` + +Everything downstream is identical to 2-D — `add_fault_bc`, `solve()`, +and the law functions below all work unchanged. In 3-D the slip is an +in-plane vector; read it with `fault_pair_jumps`: + +```python +coords, jumps, normals = fault_contact.fault_pair_jumps( + stokes, "Fault", stokes._rotated_freeslip_info) +leak = np.einsum("ij,ij->i", jumps, normals) # ~ machine zero +slip_vec = jumps - leak[:, None] * normals # the slip vectors +``` + +## The fault laws + +The tangential condition on the fault is an interface constitutive law +$\tau(V, \sigma_n, \theta)$ relating the shear traction to the slip rate +$V$, the effective normal stress $\sigma_n$, and (for rate-and-state) the +state variable $\theta$. Laws are sympy expressions; their consistent +Newton tangents are derived symbolically, so every law converges with +full Newton — none of them is Picard-limited. + +| law | call | condition | +|-----|------|-----------| +| frictionless | `stokes.add_fault_bc(0, boundary="Fault")` | $\tau = 0$ | +| viscous | `stokes.add_fault_bc(eta_f, boundary="Fault")` | $\tau = \eta_f V$ | +| Coulomb | `fault_contact.add_coulomb_fault_bc(stokes, mu, "Fault", sigma_n="reaction", V0=1e-5)` | $\tau = \mu\,\sigma_n\,\tfrac{2}{\pi}\arctan(V/V_0)$ | +| rate-and-state | `fault_contact.add_rate_state_fault_bc(stokes, f0, "Fault", a=..., b=..., V0=..., Dc=...)` | regularised arcsinh form, ageing law | + +Notes on each: + +- **Viscous**: `eta_f` is a viscosity per unit length — the + zero-thickness limit of a shear band is $\eta_f = \eta_{\rm band}/w$ + with the *band's own* (weak-zone) viscosity over its width. Small + `eta_f` approaches the frictionless crack; large `eta_f` *welds* the + fault — and welding recovers the uncut continuum exactly, because the + law penalises only the jump. The natural scale is $\eta/a$ (bulk + viscosity over fault half-length), at which the fault slips at roughly + half its free rate. +- **Coulomb**: `sigma_n` may be a prescribed number or `"reaction"`, in + which case the effective normal stress is recovered from the + no-opening constraint's own reaction — no auxiliary projection, and + compressive stress generated by the flow feeds straight into the + frictional strength. The reaction-fed value is SIGNED: where it turns + tensile the strength is zero, and a real fault would OPEN — the + bilateral no-opening constraint holds it shut instead (a tensile + reaction), so check the sign of `fault_normal_traction` before + trusting results on faults that see tension. `V0` is the regularisation velocity: choose it + well below the slip rates the flow produces. Below it the fault sticks + (creep $\sim V_0$); above it the traction saturates at + $\mu\,\sigma_n$ and the fault slides at constant stress drop. +- **Rate-and-state**: the state $\theta$ advances *between* solves by + the ageing law $\dot\theta = 1 - V\theta/D_c$, integrated exactly over + the interval. Nonlinear fault solves go through `solve_with_fault`, + and a time loop alternates solve and state update: + +```python +fault_contact.add_rate_state_fault_bc( + stokes, 0.6, "Fault", a=0.015, b=0.010, V0=1e-6, Dc=1e-2) + +for step in range(n_steps): + result = fault_contact.solve_with_fault(stokes, picard=2) + monitor = fault_contact.update_fault_state( + stokes, "Fault", dt, solve_result=result) + # monitor -> 1.0 as the fault approaches steady state (theta V = Dc) +``` + +The recovered normal traction along the fault (also the quantity a +Coulomb law reads when `sigma_n="reaction"`): + +```python +s, sigma_nn = fault_contact.fault_normal_traction( + stokes, "Fault", stokes._rotated_freeslip_info) +# negative in compression; in 3-D the first return is pair coordinates +``` + +## Prescribed (kinematic) slip + +Because the two sides are ordinary named boundaries, a *prescribed* slip +distribution is just Dirichlet data — no law involved. The slip must +taper to zero at the tips (2-D) or rim (3-D), which stay unsplit: + +```python +import sympy + +x, y = child.X +u = ((x - cx) * tx + (y - cy) * ty) / half_length # along-fault coord +taper = sympy.sqrt(sympy.Max(1 - u**2, 0)) # elliptical profile + +stokes.add_dirichlet_bc((vx_bg + s0 / 2 * taper * tx, + vy_bg + s0 / 2 * taper * ty), "FaultPlus") +stokes.add_dirichlet_bc((vx_bg - s0 / 2 * taper * tx, + vy_bg - s0 / 2 * taper * ty), "FaultMinus") +``` + +The elliptical taper is the constant-stress-drop crack profile; because +it vanishes at the tips, the shared tip vertex receives the same value +from both sides and no special treatment is needed. + +## Practical notes + +- **Pressure space**: use P2 velocity with P0 *discontinuous* pressure + on split meshes. A continuous pressure space smears the pressure jump + across the fault and measurably pollutes the near-fault stress. +- **Solvers**: split meshes carry no geometric-multigrid tail (the + coarse levels never contain the fault), so the velocity block takes + its algebraic-multigrid default. Set accuracy with + `stokes.tolerance = ...` — never raw `ksp_rtol`, which the solver + configuration overrides silently. +- **Conditioning**: the contact formulation is dramatically better + conditioned than a thin weak inclusion of the same fault — there is + no thin feature and no viscosity contrast for the Schur complement + to fight (measured: 10 outer iterations vs 147 for a $10^{-4}$ + contrast band on the same mesh). +- **Curved faults need their analytic normal**: a curved trace is + sampled as a polyline, and by default each fault node's normal is the + average of its adjacent facet normals — exact on a straight fault, but + zig-zagging at the sampling kinks of a curve. The no-opening + constraint then forbids smooth slip past each kink, producing slip + notches and normal-traction sawteeth that *grow* under mesh + refinement. Pass the smooth curve's normal instead: + + ```python + x, y = mesh.X + # e.g. a circular arc about (cx, cy): the radial direction + stokes.add_fault_bc(0, boundary="Arc", + normal=sympy.Matrix([[x - cx, y - cy]])) + ``` + + Same conventions as `add_rotated_freeslip_bc`: a sympy `1×dim` matrix + in `mesh.X` (need not be unit length; it is normalised per node and + sign-aligned to the split's Plus→Minus orientation), or a constant + array. For a **digitized trace with no analytic formula** — real + mapped faults — pass `normal="trace"`: the smoothed normal is built + from the fault's own control polyline (central-difference tangents at + the control points, tangent angle interpolated along each segment), + which `add_fault` stores on the mesh. Every fault-law variant + (`add_coulomb_fault_bc`, `add_rate_state_fault_bc`, ...) accepts the + same `normal=` argument, and the slip/traction diagnostics read the + same frame automatically. Measured on a sampled circular arc: the + smooth normal reduces the kink sawtooth by an order of magnitude and + restores convergence under refinement. On straight faults it changes + nothing — omit it. And a deliberately *kinked* fault should not be + smoothed: there the kink response is the physics. +- **Moving faults**: re-derive, don't update. Cut and split again from + the static base mesh at the new fault position; transfer fields with + the standard re-adaptation machinery. +- **Networks**: pass a list of faults to `add_fault`. Segments must not + share vertices — represent a branch or crossing as offset segments + separated by a ligament of one or two cell sizes. +- **Parallel**: `add_fault` / `split_fault` REDISTRIBUTE first, in + both 2-D and 3-D: the fault's cell star (plus one growth layer — a + thin skin, not the refined band) is gathered onto the rank that + already owns most of it, everything else stays with the + load-balanced partition, and the split then runs with serial + topology at any rank count. In 2-D the move happens only when the + cut chain actually touches the partition seam, and a network passed + to one `add_fault` call is redistributed ONCE, keyed on all its + faults together — which is also what keeps every fault's pairing + valid, since a pairing cannot yet migrate through a redistribution. + In 3-D the move is unconditional. The cost is a bounded imbalance on + the fault-owning rank (measured ~1.8x at np = 8 on a graded box). + One current exception follows from the pairing rule: adding several + faults ONE SPLIT AT A TIME in parallel is refused when a later fault + needs redistribution — pass the whole network to one `add_fault` + call (2-D), or split 3-D networks in serial. All refusals are + collective and name the actual problem. + +## Current limitations + +Refused loudly rather than mishandled: closed-loop faults (rings, +spheres); faults that reach the domain boundary (daylighting); junctions +sharing vertices (use the offset form — [Fault +networks](fault-networks.md) prepares crossings and abutments +automatically and supplies the damage-zone glue); 3-D multi-fault +networks in parallel (the pairing does +not yet migrate through the redistribution). The design +documents in `docs/developer/design/` (`SPLIT_NODE_FAULT_METHOD_2026-08` +and `FAULT_CONTACT_DEPLOYMENT_2026-08`) record the method, the +validation benchmarks, and the roadmap for these extensions. diff --git a/docs/advanced/stress-visualisation.md b/docs/advanced/stress-visualisation.md new file mode 100644 index 000000000..0c186620f --- /dev/null +++ b/docs/advanced/stress-visualisation.md @@ -0,0 +1,197 @@ +# Visualising the Stress Tensor + +Scalar fields get colormaps and velocity gets arrows; the stress tensor +needs its own glyph. `underworld3.visualisation` provides +principal-stress glyphs — sampled at seed points, the way velocity +arrows sample the velocity — and stress trajectories, the curvilinear +net traced by the principal directions. Both work from the same +recovered stress fields you already checkpoint. + +## Principal-stress glyphs + +At each seed point we diagonalise the stress and draw one bar per +principal axis: bar length proportional to the principal-value +magnitude, blue for compressive ($\lambda < 0$), red for tensile. In +2-D this is the classical stress cross; in 3-D each seed carries three +orthogonal bars, best drawn on one or two section planes rather than a +filled volume. + +```python +import underworld3 as uw +import underworld3.visualisation as vis +import sympy + +# Recovered stress components (P1 projections of the deviatoric +# stress) plus pressure give the full stress. Project once after the +# solve; do not pass raw solver derivative expressions to a plot. +# A scalar variable's .sym is a 1x1 Matrix — index it before +# assembling the tensor. +stress = sympy.Matrix([[Txx.sym[0] - P.sym[0], Txy.sym[0]], + [Txy.sym[0], Tyy.sym[0] - P.sym[0]]]) + +pl = vis.plot_stress_glyphs(mesh, stress, num_seeds=24, + save_png=True, dir_fname="stress_glyphs.png") +``` + +Seeds default to a regular grid over the mesh bounding box, filtered +to points inside the mesh (an annulus seeds no glyphs in its hole). +Pass `seeds` explicitly to sample section planes in 3-D or to avoid +regions — in fault models, keep seeds out of the weak zones, where the +recovered stress mixes materials across the interface. + +```{figure} figures/stress_glyphs_thrust.png +:alt: Two-panel figure for a thrust-ramp model. Top panel, principal stress crosses on a 26 by 13 seed grid over a 2 by 1 section with two grey parabolic ramps rising from a basal decollement, drawn over a faint peach strain-rate wash in which the quiet wedges riding each ramp glow white and the blind ramp tips smudge darker. Far-field crosses are blue-horizontal (compression); in the wedges the crosses turn red and near-vertical (tension), largest just above the blind tips. Bottom panel, stress trajectories over the same wash: dark sigma-1 lines run horizontally and arch smoothly over each ramp tip, pale sigma-3 lines rise near-vertically between them, crossing at right angles everywhere. +:name: fig-stress-glyphs-thrust + +Principal-stress crosses (top) and stress trajectories (bottom) for a +blind-thrust model, over a faint strain-rate wash (log scale, +background-percentile limits, low opacity — the second invariant of +the strain rate from the same recovered stress). The colour +convention matches the RdBu_r field convention used across the +documentation: blue compressive, red tensile. The wash ties the +stress geometry to the deformation it drives: the near-rigid thrust +wedges glow white while the blind tips concentrate both quantities. +``` + +### The pressure gauge matters for colours, not directions + +For incompressible models the full stress is +$\sigma = \tau - p\,I$ and the pressure datum is a gauge choice. +Shifting that datum shifts every principal value equally, so it can +flip bars between red and blue — but it cannot rotate the principal +directions or reorder the principal values. State the gauge in your +caption (the examples here demean the pressure), or add the +lithostatic reference before plotting if absolute compression +matters. + +The same argument answers a common question about map-view regime +colouring in the style of the World Stress Map (red normal, green +strike-slip, blue thrust): the regime classification is +gauge-invariant, but in a 2-D incompressible plane-strain model it is +also *degenerate* — the out-of-plane deviatoric stress is zero while +the in-plane deviatoric principals are $\pm s$, so the out-of-plane +stress is always the intermediate principal stress and every map-view +point classifies as strike-slip. Regime colouring only carries +information in 3-D models. + +## Stress trajectories (2-D) + +Trajectories integrate the principal *direction* field into curves — +the classical stress-trajectory diagrams of structural geology. A +principal direction is defined only modulo 180°, so ordinary +streamline tools cannot draw this field: the integrator in +`direction_trajectories` sign-aligns each evaluated eigenvector with +the previous heading, and places lines evenly (Jobard–Lehmann +occupancy) so the figure stays legible. The two families cross at +right angles wherever both are drawn — a built-in correctness check. + +The direction callable is yours to build, which keeps the integrator +independent of how the stress is stored. From nodal arrays: + +```python +import numpy as np + +def make_direction(interpolate_stress, family="compressive"): + # interpolate_stress(p) -> (sxx, syy, sxy) at point p, or None + def direction_at(p): + values = interpolate_stress(p) + if values is None: + return None + sxx, syy, sxy = values + mean_dev = 0.5 * (sxx - syy) + radius = np.hypot(mean_dev, sxy) + if radius < 1.0e-12: # isotropic point: direction undefined + return None + angle = 0.5 * np.arctan2(sxy, mean_dev) # most-tensile axis + if family == "compressive": + angle += 0.5 * np.pi + return np.array([np.cos(angle), np.sin(angle)]) + return direction_at + +lines = vis.direction_trajectories( + direction_at, candidate_seeds, inside, + step=0.008, separation=0.04, +) +trajectory_lines = vis.trajectories_to_pv_lines(lines) +``` + +Draw the compressive family dark and the tensile family pale, as in +the figure above. In 3-D the analogue of a trajectory is a surface; +we do not attempt those — draw glyphs on section planes instead. + +## Two more regimes, same recipe + +The same two panels for the extensional and strike-slip companions of +the thrust model — nested listric normal faults above a detachment, +and en-echelon segments in dextral simple shear. Nothing changes in +the code except the checkpoint being loaded. + +```{figure} figures/stress_glyphs_listric.png +:alt: Two-panel figure for three nested listric normal faults soling into a basal detachment, over a faint peach strain-rate wash with white quiet triangles in the footwall beneath each sole. Top panel, principal stress crosses: red-horizontal tension dominates the upper plate and deepens between the fault traces; blue compression concentrates near the detachment, with strongly rotated mixed crosses hugging each curved fault. Below the detachment the crosses return to uniform red-horizontal. Bottom panel, trajectories: dark sigma-1 lines hang near-vertically in the extending upper plate, bending to meet each listric sole, while pale sigma-3 lines run horizontally; beneath the detachment the net is an undisturbed rectangular grid. +:name: fig-stress-glyphs-listric + +Nested listric normal faults (extension): horizontal tension aloft, +compression concentrating under the soles, and an undisturbed +trajectory grid beneath the detachment — the faults decouple the two +plates. +``` + +```{figure} figures/stress_glyphs_enechelon.png +:alt: Two-panel map-view figure for three en-echelon fault segments in dextral simple shear, over a faint strain-rate wash with bright white lobes at the relay steps between segment tips. Top panel, principal stress crosses at 45 degrees far from the faults, shrinking and rotating through the relay zones, with a blue compressive bridge linking overlapping tips. Bottom panel, trajectories: the dark sigma-1 family sweeps diagonally across the box and kinks sharply as it hands across each relay step; the pale sigma-3 family crosses it orthogonally. +:name: fig-stress-glyphs-enechelon + +En-echelon segments in dextral simple shear (map view): the conjugate +cross pattern of the far field collapses through the relay steps, and +the trajectory families kink as stress hands across from segment to +segment. +``` + +## 3-D glyphs + +`principal_stress_glyphs` accepts `(n, 3, 3)` tensors and returns +three bars per seed. Seed one or two planes through the feature of +interest: + +```python +u = np.linspace(0.05, 0.95, 13) +gx, gz = np.meshgrid(u, u) +plane = np.column_stack([gx.ravel(), + np.full(gx.size, 0.5), # y = centre plane + gz.ravel()]) +pl = vis.plot_stress_glyphs(mesh, stress, seeds=plane) +``` + +```{figure} figures/stress_glyphs_sinker3d.png +:alt: A unit cube drawn in outline with a grey sphere of radius 0.16 near the centre, slightly above mid-height. Three-bar principal stress glyphs are drawn on a vertical section plane and a horizontal section plane through the sphere, via the one-call plot_stress_glyphs with the cube edges and sphere added to the returned plotter. Above the sphere the bars are red and near-vertical (tension as material is pulled down behind the sinker); below and beside it they are blue (compression), fanning outward on the horizontal plane beneath the sphere. Bar length decays with distance from the sphere. +:name: fig-stress-glyphs-sinker + +Three-bar principal-stress glyphs on two section planes through a +Stokes sinker: a tensile (red) column above the sinking sphere, a +compressive (blue) fan below and around it. Rendered with +`plot_stress_glyphs(..., show=False)` so the cube outline and sphere +could be added to the returned plotter before the screenshot. +``` + +## Building blocks + +The plot function is a convenience wrapper; every step is available +separately for custom figures: + +| Function | Purpose | +|---|---| +| `tensor_fn_to_pv_points(pv_mesh, uw_fn)` | Evaluate a `dim`×`dim` sympy tensor at points | +| `principal_stress_glyphs(coords, stress, scale)` | Bar segments + `"tensile"` cell array | +| `direction_trajectories(direction_at, seeds, inside, step, separation)` | Evenly spaced mod-180° trajectories | +| `trajectories_to_pv_lines(lines)` | Bundle polylines for `add_mesh` | +| `plot_stress_glyphs(mesh, stress, ...)` | One-call cross plot | + +The figures on this page come from checkpointed models (the three +fault-interaction examples and a Stokes sinker): the solve writes the +mesh, the velocity, the pressure, and the recovered stress components +with `mesh.write_timestep`, and the glyph plots load them back with +`read_timestep` — no re-solving to restyle a figure. The faint +background in the 2-D panels is the strain-rate second invariant from +the same recovered stress ($\dot\varepsilon = \tau/2$ at matrix +viscosity 1), drawn first at low opacity with log scaling and +background-percentile colour limits, so the glyphs carry the figure +and the wash only whispers where deformation concentrates. diff --git a/docs/advanced/troubleshooting.md b/docs/advanced/troubleshooting.md index ddd7eaf96..829b44273 100644 --- a/docs/advanced/troubleshooting.md +++ b/docs/advanced/troubleshooting.md @@ -1,13 +1,104 @@ --- -title: "trouuleshooting" +title: "Troubleshooting" --- -# trouuleshooting +# Troubleshooting -```{note} Documentation In Progress -Content being developed for advanced users. +Notes on behaviour changes and common pitfalls that show up as "my old script +runs but gives different answers / different performance". + +## Stokes penalty is now viscosity-scaled (June 2026) + +The Stokes augmented-Lagrangian (grad-div) incompressibility penalty changed +from a bare constant to a **viscosity-scaled** term. The operator changed +from + +$$ +\sigma + \lambda \, (\nabla \cdot \mathbf{u}) \, I +$$ + +to + +$$ +\sigma + \lambda \, \mu \, (\nabla \cdot \mathbf{u}) \, I +$$ + +where $\mu$ is the local viscosity (`constitutive_model.K`) and $\lambda$ is +the value you set with `stokes.penalty`. + +Why: with spatially variable viscosity, a constant penalty is enormous +relative to the stress in low-viscosity regions (over-stiffening them into +velocity locking) and negligible in high-viscosity regions. Scaling by the +local viscosity keeps the penalty proportional to the local stress scale +everywhere. + +**What to change in older scripts:** + +1. `stokes.penalty` is now a *dimensionless* number of order 1. Scripts that + tuned a large constant against the viscosity magnitude (say `penalty=1e6` + for a model with $\mu \sim 1$, or a value chosen to sit above the largest + viscosity in a contrast model) should drop that tuning — use `1.0` (or a + modest multiple) instead. Keeping the old large value multiplies it by + the viscosity again and can lock the velocity field or destroy the + conditioning of the velocity block. +2. A penalty is still **off by default** (`penalty = 0`) and usually + unnecessary: the pressure Schur complement is already preconditioned with + the local $1/\mu$ scaling. +3. The penalty term is part of the operator, so converged pressures include + the grad-div contribution; diagnostics that compared pressure against a + constant-penalty run will differ at the level of the divergence residual. + +See the `Stokes.penalty` property docstring for the full description, and the +`CONSTRAINED_FREESLIP_MULTIPLIER` design note for the derivation. + +## A long output path can segfault parallel HDF5 output (#645) + +**Symptom.** A native segmentation fault inside `mesh.write()` or +`mesh.write_timestep()` on an HPC filesystem, with no Python traceback. The mesh, +labels, fields, solve, MPI size and output calls are all valid, and the same script +succeeds when only the output directory is renamed. + +**Cause.** The failure follows the *length of the full generated filename*, not the +validity of the path. UW3 appends its own suffixes to whatever you pass — a mesh write +becomes `output.mesh.00000.h5`, and a P2 velocity variable becomes +`output.mesh.U.00000.h5` — so a descriptive case directory can push the complete path +past what the native PETSc/HDF5/MPI-I/O stack tolerates. + +```{warning} +This happens **well below** the advertised limits. In the reported case every path +component was under `NAME_MAX` (255), the filesystem allowed `PATH_MAX=4096`, and PETSc +was configured with `PETSC_MAX_PATH_LEN=4096`. A 286-character filename still crashed. ``` -## Overview +**Reported thresholds.** On Gadi (PETSc 3.25.4, HDF5 1.12.2p, Open MPI 4.1.7, MPI-enabled +h5py 3.16.0, GPFS) a staged test failed at a **259-character** filename, and 252 +characters is the shortest length observed to fail on that stack. The exact first unsafe +length was not established, so treat these as observations on one stack rather than a +portable threshold. A macOS stack (PETSc 3.25.0, HDF5 1.14.6, Open MPI 5.0.10) did not +reproduce the original failure, though the full threshold matrix was not repeated there. + +**What to do.** + +1. Keep the output root and the generated case identifier **compact**, especially on HPC. +2. Put fixed configuration in HDF5 metadata or a metrics file rather than encoding every + parameter in the directory name. A directory named for the two or three parameters + that actually vary across a campaign is enough to tell runs apart. +3. If output segfaults natively, **print the full generated filename first** — including + the UW3 suffixes — before investigating the mesh, the labels or the solver. + +Shortening the longest filename from 286 to 189 characters made an otherwise unchanged +eight-rank benchmark pass end to end, and a subsequent 192-rank run at 1/64 resolution +completed every mesh, Stokes, HDF5, XDMF and postprocessing stage. + +**One source of path growth has since been removed.** The snapshot backend used to +expand `mesh.name` — which is the full source path for a mesh loaded from file — into +every generated filename, so a 123-character mesh name became a 346-character output +filename on its own. It now writes deterministic `mesh_0000` stems and calls native +PETSc/HDF5 from the artifact directory using short relative names, keeping the original +name in the wrapper metadata. Snapshot writes are therefore no longer a path-length +amplifier. -Advanced topic documentation coming soon. +That fix does not make the advice above unnecessary. It covers the snapshot backend +only: `Mesh.write_timestep()` and anything else that builds its own output filenames +still passes whatever you give it straight through to the native stack, and the +underlying limit — wherever it actually sits — has not moved. diff --git a/docs/api/analytic.md b/docs/api/analytic.md new file mode 100644 index 000000000..e4ff784e2 --- /dev/null +++ b/docs/api/analytic.md @@ -0,0 +1,37 @@ +# Analytic Solutions + +```{eval-rst} +.. automodule:: underworld3.analytic + :members: + :show-inheritance: +``` + +## The contract + +Every solution satisfies the same contract, so a validation run reads the same way +whichever one you use. + +```{eval-rst} +.. automodule:: underworld3.analytic._base + :members: + :show-inheritance: +``` + +## Zhong spherical-shell response oracle + +`Zhong2008` is a mesh-independent propagator-matrix oracle. It returns the +boundary response coefficients used by the Zhong et al. (2008) benchmark; it +does not implement the symbolic mesh-field contract above. + +```{eval-rst} +.. automodule:: underworld3.analytic.zhong2008 + :members: + :show-inheritance: +``` + +## See also + +- {doc}`solvers` — the solvers these solutions validate. +- `docs/developer/subsystems/analytic-solutions.md` — the implementation form, + the validation protocol every transcription must pass, and the provenance of + each vendored reference kernel. diff --git a/docs/api/function.md b/docs/api/function.md index b3a052b37..9ac50e271 100644 --- a/docs/api/function.md +++ b/docs/api/function.md @@ -78,8 +78,5 @@ Factory function for creating UWQuantity objects with units. ## Analytic Functions -```{eval-rst} -.. automodule:: underworld3.function.analytic - :members: - :show-inheritance: -``` +The analytic solutions have moved to {doc}`analytic` — `underworld3.function.analytic` +is a deprecation shim. Use `uw.analytic.SolCx(mesh, ...)`. diff --git a/docs/api/index.md b/docs/api/index.md index ab7ac16fb..f7489d2cd 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -29,6 +29,8 @@ model utilities visualisation adaptivity +analytic +postprocessing ``` ## Quick Links @@ -50,6 +52,12 @@ adaptivity - **{doc}`scaling`** - Units, quantities, and non-dimensionalisation - **{doc}`maths`** - Mathematical operations and integrals +### Validation +- **{doc}`analytic`** - Exact solutions for benchmarking and convergence testing + +### Post-processing +- **{doc}`postprocessing`** - Boundary-response, geoid, and self-gravity coefficients + ### Infrastructure - **{doc}`model`** - Model management and configuration - **{doc}`utilities`** - I/O, mesh import, and helper functions diff --git a/docs/api/materials.md b/docs/api/materials.md index ad8476be5..5ca84b32d 100644 --- a/docs/api/materials.md +++ b/docs/api/materials.md @@ -1,27 +1,72 @@ # Materials +Underworld3 keeps two questions apart: **what** a material is, and **where** it +is. + +*What* is a {py:class}`~underworld3.MaterialRegistry` entry — a name and a +table of properties, where a property may be a number, a quantity, or a *law* +such as `eta_0 * sympy.exp(-T.sym[0])`. A registry knows nothing about +geometry, so it can be built before there is a mesh and shared between models. + +*Where* is a **distribution**, and there are two: + +- {py:class}`~underworld3.swarm.MaterialSwarm` — carried by particles, so the + materials advect with the flow. +- {py:class}`~underworld3.MaterialRegions` — tied to the mesh, from gmsh + physical groups or from a geometric condition. Exact, and needs no particles. + +Both present the same face to a solver — `stokes.materials = materials`, which +sets every constitutive-model parameter the materials declare and the model +recognises — and both build it from the same partition of unity, one level set +per material. + +See {doc}`../advanced/particle-population-and-materials` for the user guide. + ```{eval-rst} .. automodule:: underworld3.materials :no-members: ``` -## Material Property +## Defining materials ```{eval-rst} +.. autoclass:: underworld3.MaterialRegistry + :members: + :show-inheritance: + +.. autoclass:: underworld3.MaterialDefinition + :members: + :show-inheritance: + .. autoclass:: underworld3.MaterialProperty :members: :show-inheritance: ``` -## Material Registry +## Distributing materials ```{eval-rst} -.. autoclass:: underworld3.MaterialRegistry +.. autoclass:: underworld3.swarm.MaterialSwarm + :members: + :show-inheritance: + +.. autoclass:: underworld3.MaterialRegions :members: :show-inheritance: + +.. autoclass:: underworld3.materials.MaterialDistribution + :members: + :show-inheritance: + +.. autoclass:: underworld3.materials.BoundMaterial + :members: ``` -## Multi-Material Models +## Different laws per material + +A distribution blends per-material *parameter values* into one constitutive +model. When the materials need genuinely different constitutive **laws** — +one viscous, one viscoelastic — compose the models instead: ```{eval-rst} .. autoclass:: underworld3.MultiMaterialConstitutiveModel diff --git a/docs/api/postprocessing.md b/docs/api/postprocessing.md new file mode 100644 index 000000000..07512bcc0 --- /dev/null +++ b/docs/api/postprocessing.md @@ -0,0 +1,19 @@ +# Post-processing + +```{eval-rst} +.. automodule:: underworld3.postprocessing + :members: + :show-inheritance: +``` + +## Geoid and self-gravity responses + +The geoid module provides coefficient-only spherical-shell and +cylindrical-annulus gravity operators plus adapters for completed +rotated-free-slip Stokes solves. + +```{eval-rst} +.. automodule:: underworld3.postprocessing.geoid + :members: + :show-inheritance: +``` diff --git a/docs/api/solvers.md b/docs/api/solvers.md index 6b5c464f3..55027e56e 100644 --- a/docs/api/solvers.md +++ b/docs/api/solvers.md @@ -46,6 +46,25 @@ Viscoelastic extension of the Stokes solver. :show-inheritance: ``` +### SNES_AdvectionDiffusion_Composed (`uw.systems.AdvDiffusion`) + +The scalar transport solver composed from a DDt transport manager; with the +default `EulerianSUPG` manager it is the implicit Eulerian SUPG scheme. + +```{eval-rst} +.. autoclass:: underworld3.systems.advection_diffusion_eulerian.SNES_AdvectionDiffusion_Composed + :members: + :show-inheritance: +``` + +### EulerianSUPG (the transport manager) + +```{eval-rst} +.. autoclass:: underworld3.systems.ddt.EulerianSUPG + :members: + :show-inheritance: +``` + ### SNES_Diffusion ```{eval-rst} @@ -81,3 +100,14 @@ Viscoelastic extension of the Stokes solver. :members: :show-inheritance: ``` + +### SNES_NavierStokes_Composed (`uw.systems.NavierStokes`) + +Navier-Stokes composed from a DDt transport manager (Eulerian SUPG momentum +transport by default); the semi-Lagrangian class above is `uw.systems.NavierStokesSLCN`. + +```{eval-rst} +.. autoclass:: underworld3.systems.navier_stokes_eulerian.SNES_NavierStokes_Composed + :members: + :show-inheritance: +``` diff --git a/docs/beginner/tutorials/14-Timestepping-with-physical-units.ipynb b/docs/beginner/tutorials/14-Timestepping-with-physical-units.ipynb index 21930707d..f7527ec1b 100644 --- a/docs/beginner/tutorials/14-Timestepping-with-physical-units.ipynb +++ b/docs/beginner/tutorials/14-Timestepping-with-physical-units.ipynb @@ -209,7 +209,7 @@ "outputs": [], "source": [ "# Create advection-diffusion solver\n", - "adv_diff = uw.systems.AdvDiffusion(\n", + "adv_diff = uw.systems.AdvDiffusionSLCN(\n", " mesh,\n", " u_Field=T,\n", " V_fn=v,\n", diff --git a/docs/beginner/tutorials/15-Thermal-convection-with-units.ipynb b/docs/beginner/tutorials/15-Thermal-convection-with-units.ipynb index 762c45a68..2efcd6b14 100644 --- a/docs/beginner/tutorials/15-Thermal-convection-with-units.ipynb +++ b/docs/beginner/tutorials/15-Thermal-convection-with-units.ipynb @@ -247,7 +247,7 @@ "source": [ "# Create solver for the energy equation (Advection-Diffusion of temperature)\n", "\n", - "adv_diff = uw.systems.AdvDiffusion(\n", + "adv_diff = uw.systems.AdvDiffusionSLCN(\n", " meshball,\n", " u_Field=t_soln,\n", " V_fn=v_soln,\n", diff --git a/docs/beginner/tutorials/7-Timestepping-simple.ipynb b/docs/beginner/tutorials/7-Timestepping-simple.ipynb index 85d5094ef..fd90fe3bb 100644 --- a/docs/beginner/tutorials/7-Timestepping-simple.ipynb +++ b/docs/beginner/tutorials/7-Timestepping-simple.ipynb @@ -157,7 +157,7 @@ "T_initial_field = uw.discretisation.MeshVariable(\"T0\", mesh, 1, degree=3)\n", "\n", "# Create advection-diffusion solver\n", - "adv_diff = uw.systems.AdvDiffusion(\n", + "adv_diff = uw.systems.AdvDiffusionSLCN(\n", " mesh,\n", " u_Field=T,\n", " V_fn=v,\n", @@ -265,7 +265,7 @@ "## Time Stepping\n", "\n", "In many time-stepping schemes, the time step is constrained by the CFL (Courant-Friedricks-Levy) condition for stability. In the case of the \n", - "Semi-Lagrange advection scheme which is used by default by `uw.systems.AdvDiffusion`, the method is implicit and\n", + "Semi-Lagrange advection scheme which is used by default by `uw.systems.AdvDiffusionSLCN`, the method is implicit and\n", "should work for large timesteps. However, there remains the concept of an element-crossing time that is fundamental\n", "in understanding how numerical timestepping operates.\n", "\n", diff --git a/docs/beginner/tutorials/8-Timestepping-coupled.ipynb b/docs/beginner/tutorials/8-Timestepping-coupled.ipynb index 45ed4cdb2..045cb6ad9 100644 --- a/docs/beginner/tutorials/8-Timestepping-coupled.ipynb +++ b/docs/beginner/tutorials/8-Timestepping-coupled.ipynb @@ -167,7 +167,7 @@ "source": [ "# Create solver for the energy equation (Advection-Diffusion of temperature)\n", "\n", - "adv_diff = uw.systems.AdvDiffusion(\n", + "adv_diff = uw.systems.AdvDiffusionSLCN(\n", " meshball,\n", " u_Field=t_soln,\n", " V_fn=v_soln,\n", @@ -196,7 +196,7 @@ }, "outputs": [], "source": [ - "uw.systems.AdvDiffusion.view()" + "uw.systems.AdvDiffusionSLCN.view()" ] }, { diff --git a/docs/beginner/tutorials/9-Unsteady_Flow.ipynb b/docs/beginner/tutorials/9-Unsteady_Flow.ipynb index 9f4aba69c..d5ba45a8b 100644 --- a/docs/beginner/tutorials/9-Unsteady_Flow.ipynb +++ b/docs/beginner/tutorials/9-Unsteady_Flow.ipynb @@ -4,7 +4,7 @@ "cell_type": "markdown", "id": "f7a4cbb2-6265-48bd-a646-0e1df6c569de", "metadata": {}, - "source": "# Notebook 7: Unsteady Flow\n\n[![Run this notebook on Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/underworldcode/uw3-binder-launcher/development?labpath=docs%2Fbeginner%2Ftutorials%2F9-Unsteady_Flow.ipynb)\n\n\n
\n\n![](media/CompositeImage.png)\n\n_Flow in a pipe with inflow at the left boundary\n after 50, 100, 150 timesteps (top to bottom) showing the\n progression of the impulsive initial condition. For details,\n see the notebook code._\n\n
\n\nWe'll look at tracking an unsteady flow using a swarm of particle flow-tracers. In this case, the flow is unsteady because we solve the Navier-Stokes equation (that is, the flow has inertia) and we impose an impulsive, initial boundary velocity. \n\nTo begin with, the set up follows the same path as all previous notebooks:\n - Create a mesh\n - Add some variables\n - Create the solver we need (`NavierStokes` this time)\n - Add boundary conditions and constitutive properties.\n\nWe also add a projection solver to compute the vorticity of the flow as we did in Notebook 4 when we needed to compute a heat-flux (thermal gradient) term.\n\nTo track the time evolution of the flow, we introduce a \"passive\" particle\nswarm. Passive, here, refers to the fact that the flow is not changed by the \npresence of the marker particles. \n\nIn the time-loop we have to update the particle locations and we keep this\nas an explicity operation, in general, because it provide the opportunity for\nyou to make changes or perform analyses. In this case, we are adding new particles near the inflow to track the flow.\n\nTo learn more about flow goverened by the Navier-Stokes equation, it may be he helpful to read an elementary fluid dynamics textbook and reproduce some of the simple \"toy\" examples. For example, Acheson, 1990. \n" + "source": "# Notebook 7: Unsteady Flow\n\n[![Run this notebook on Binder](https://mybinder.org/badge_logo.svg)](https://mybinder.org/v2/gh/underworldcode/uw3-binder-launcher/development?labpath=docs%2Fbeginner%2Ftutorials%2F9-Unsteady_Flow.ipynb)\n\n\n
\n\n![](media/CompositeImage.png)\n\n_Flow in a pipe with inflow at the left boundary\n after 50, 100, 150 timesteps (top to bottom) showing the\n progression of the impulsive initial condition. For details,\n see the notebook code._\n\n
\n\nWe'll look at tracking an unsteady flow using a swarm of particle flow-tracers. In this case, the flow is unsteady because we solve the Navier-Stokes equation (that is, the flow has inertia) and we impose an impulsive, initial boundary velocity. \n\nTo begin with, the set up follows the same path as all previous notebooks:\n - Create a mesh\n - Add some variables\n - Create the solver we need (`NavierStokesSLCN` this time, the semi-Lagrangian Navier-Stokes solver)\n - Add boundary conditions and constitutive properties.\n\nWe also add a projection solver to compute the vorticity of the flow as we did in Notebook 4 when we needed to compute a heat-flux (thermal gradient) term.\n\nTo track the time evolution of the flow, we introduce a \"passive\" particle\nswarm. Passive, here, refers to the fact that the flow is not changed by the \npresence of the marker particles. \n\nIn the time-loop we have to update the particle locations and we keep this\nas an explicity operation, in general, because it provide the opportunity for\nyou to make changes or perform analyses. In this case, we are adding new particles near the inflow to track the flow.\n\nTo learn more about flow goverened by the Navier-Stokes equation, it may be he helpful to read an elementary fluid dynamics textbook and reproduce some of the simple \"toy\" examples. For example, Acheson, 1990. \n" }, { "cell_type": "code", @@ -83,7 +83,7 @@ "metadata": {}, "outputs": [], "source": [ - "navier_stokes = uw.systems.NavierStokes(\n", + "navier_stokes = uw.systems.NavierStokesSLCN(\n", " mesh,\n", " velocityField=v_soln,\n", " pressureField=p_soln,\n", @@ -126,7 +126,7 @@ "metadata": {}, "outputs": [], "source": [ - "uw.systems.NavierStokes.view()" + "uw.systems.NavierStokesSLCN.view()" ] }, { diff --git a/docs/blog/free-surface-without-advecting-it.md b/docs/blog/free-surface-without-advecting-it.md new file mode 100644 index 000000000..df4120bd9 --- /dev/null +++ b/docs/blog/free-surface-without-advecting-it.md @@ -0,0 +1,188 @@ +# A free surface you never advect + +*Draft blog post — Underworld development team. Status: held pending a possible +method paper (the scheme is plausibly novel; see the +[method note](../developer/design/FREESLIP_DYNAMIC_TOPOGRAPHY_FREESURFACE.md) +and its cited novelty assessment). Not wired into the published docs toctree +yet — release at the team's discretion.* + +--- + +## The mystery: convection that quietly gives up + +We were running a very ordinary experiment: isoviscous convection in an annulus, +Rayleigh number 10⁵, with a deforming free surface on top. Nothing exotic. The +kind of thing that should just work. + +It didn't. The Nusselt number — our measure of how vigorously the system carries +heat — started at a healthy 13.6 and then *decayed*, drifting down toward +sub-critical values as if the convection were running out of steam. The interior +slowly cooled. Swap in a different free-surface scheme and the same thing +happened. Relax-to-equilibrium, kinematic finite-element, kinematic-exact — every +way we knew to move a free surface produced the same slow death. + +The frustrating part: by every conservation check we threw at it, the simulation +was *fine*. Mass was conserved. The surface amplitude was reasonable. There was +no obvious blow-up, no NaN, no tangled mesh. The model just... lost interest in +convecting. + +## The diagnosis: it's a lag, and the lag is a cold pump + +The breakthrough was realising the failure had nothing to do with how much the +surface moved, and everything to do with *when* it moved relative to the flow. + +Here is the mechanism. We advect temperature semi-Lagrangian: to find the new +temperature at a point, we trace backwards along the velocity to where that +parcel came from, and read the old temperature there. At a downwelling — cold +material plunging into the interior — the flow at the surface is pushing +*inward*. If the surface itself hasn't kept up (because the integrator +under-moved it, or smoothed it, or relaxed it too slowly), then the backward +trace lands *outside* the lagging surface — in the thin layer the surface should +have occupied but doesn't quite, where the only thing to read is the cold `T = 0` +boundary condition. + +So every step, at every downwelling, the scheme reaches past its own surface and +pulls in a little bit of cold. A **cold pump**, running on the lag between where +the surface *is* and where the velocity *thinks* it is. It is utterly silent — +mass is conserved, nothing diverges — but it steadily drains the thermal energy +that drives the convection. Hence the slow death. + +Any surface lag does it. Fourier truncation of the surface shape: lag. Smoothing +to keep the mesh clean: lag. Relaxation that under-moves: lag. There was no +setting that made the lag zero, because the lag is structural. + +## The trap inside the trap + +Our first instinct was to make the surface track the velocity *better* — reduce +the lag rather than live with it. That is when we found the second, deeper +problem. + +We isolated the surface normal velocity — the `v·n` that a kinematic scheme +integrates to move the surface — and watched it while holding the surface +essentially at its equilibrium shape. It should have been tiny: if the surface is +already where it wants to be, it shouldn't be moving. Instead it was *enormous* — +around 65 in our non-dimensional units — and it barely decayed. + +That number isn't the surface trying to move. It's **convective throughflow**: +downwellings slamming into the top boundary and the return flow sweeping back. +The fluid is moving vigorously *past* the surface, not *carrying* it. Reading +`v·n` as a rate-of-change-of-topography is reading the wrong quantity entirely. +Any scheme that integrates it is integrating noise a hundred times larger than +the signal, and it will overshoot no matter how carefully you step it. + +So the kinematic free surface is built on a contradiction: the very velocity it +uses to move the surface is dominated by flow that isn't moving the surface at +all. + +## The fix: stop advecting the surface + +If `v·n` at the surface is mostly throughflow, the cleanest thing to do is *not +let there be any throughflow*. Make the top boundary **free-slip**: `v·n = 0` by +construction. The fluid slides along the boundary but never crosses it. The +backward trace can no longer reach past the surface, because there is no normal +velocity carrying material across it. The cold pump is gone. + +But now the surface can't move kinematically — we just forbade the normal +velocity that would move it. So where does topography come from? + +From the **stress**. A free-slip lid still feels a normal stress pushing up under +upwellings and down under downwellings. Geodynamicists have computed *dynamic +topography* from exactly this normal stress for decades — it's the height the +surface would take if it were free to deform and compensate that stress +instantaneously. The twist is that this quantity has always been a **diagnostic**: +something you compute and plot *after* the fact, on a surface you keep flat. We +use it as a **prognostic** variable — we let it actually move the surface. + +So the surface is no longer carried by the flow. It *relaxes toward* the +stress-derived equilibrium topography. The flow solve stays clean and free-slip; +the surface evolves on its own, driven by the stress the flow produces. + +## Three numbers and a guarantee + +How do you step a relaxing surface? The naive answer — height plus rate, stepped +forward — is exactly the scheme that gives geodynamics its infamous "drunken +sailor" instability: the surface overshoots its target, the overshoot drives a +stress that pushes it back too hard, and it sloshes itself to pieces unless you +take agonizingly small time steps. + +The cure is a third number. Track the height `h`, its rate `ḣ`, *and* the +equilibrium it's heading for, `h_∞`. Those three pin a simple local model — the +surface decays exponentially toward equilibrium — with a rate + +> γ = ḣ / (h_∞ − h) + +and an exact update over the step: + +> h ← h_∞ + (h − h_∞) · e^(−γ Δt) + +This is **L-stable by construction**. The update always lands *between* the +current height and the equilibrium — it physically cannot overshoot, for any time +step, ever. The drunken sailor can't stagger because the scheme won't let him +past the target. And it's robust even when `ḣ` is corrupted by that throughflow +noise, because it only needs the *direction* of relaxation (`γ > 0`), not an +accurate rate. + +We'll be honest about lineage here: this exponential step is not something we +invented. It's first-order *exponential time differencing*, well known in applied +mathematics (Cox & Matthews, 2002; the no-overshoot property is proven cleanly by +Aursand et al., 2014). What appears to be new is bringing it to a geodynamic free +surface, and pairing it with a stress-derived equilibrium instead of a +kinematically-advected one. + +## One solve, two answers + +There's a small elegance that makes the whole thing cheap. You might expect to +need two Stokes solves per step: one to get the velocity for advecting +temperature, and another, with a held lid, to get the normal stress for `h_∞`. + +You don't. Under free-slip, the lithostatic part of the body force is a pure +gradient — it produces hydrostatic pressure and no flow. So a single *free-slip, +driving-only* solve gives you the advection velocity directly, and its normal +stress is exactly the `h_∞` you need, with no double-counting of the topographic +load. **One solve supplies both.** + +## What it buys + +With free-slip on top and the surface relaxing exponentially toward its +stress-equilibrium, the convection stops dying. Nusselt stays vigorous and +steady. The temperature field stays clean in [0, 1] — no cold injection, no +speckle. The surface tracks `h_∞` and oscillates *physically* with the flow +instead of marching off in a monotone runaway. The residual normal velocity at +the boundary drops from ~65 to ~0.4 — a hundred-fold — which is the throughflow +finally gone. + +It is worth being clear about where this lives. The scheme assumes surface +evolution is relaxation toward instantaneous isostatic compensation — exact in +the long-wavelength limit that matters for whole-mantle dynamic topography, where +the surface is stiff (buoyancy small compared to the restoring force) and +deflections are a few percent. Push it into a genuinely soft regime, where +buoyancy overwhelms the surface, and there simply isn't a smooth surface to track +— that's physics, not a bug. + +## So is it new? + +We ran a careful literature check, including a targeted sweep of the major +geodynamics codes' manuals and changelogs. The verdict: a **novel combination of +established parts**. Computing free-slip dynamic topography from stress is old +(Zhong, Gurnis & Moresi, 1996; Crameri et al., 2012). The exponential integrator +is older still, just from a different field. The closest relative — the implicit +free surface in Fluidity / G-ADOPT (Kramer, Wilson & Davies, 2012) — co-solves +the surface height with no time-step penalty, but it uses a stress boundary +condition rather than free-slip, and the height *is* the evolving surface rather +than an equilibrium target it relaxes toward. + +What we couldn't find anywhere was the synthesis: keep the lid free-slip, treat +the stress-derived dynamic topography as the equilibrium a separate surface +variable relaxes toward, and step that relaxation exponentially. Each ingredient +is published; the recipe doesn't appear to be. + +That's why this post is a draft. If the novelty holds up to a proper paper +search, the right venue for this story is a methods paper, not a blog. But the +core idea is too clean not to write down: **the best way to move a free surface +might be to stop advecting it.** + +--- + +*Technical details, the stability argument, the two deformed-geometry bugs we had +to fix first, and full citations are in the +[developer method note](../developer/design/FREESLIP_DYNAMIC_TOPOGRAPHY_FREESURFACE.md).* diff --git a/docs/developer/CHANGELOG.md b/docs/developer/CHANGELOG.md index 165571d7d..0856c8483 100644 --- a/docs/developer/CHANGELOG.md +++ b/docs/developer/CHANGELOG.md @@ -6,6 +6,321 @@ This log tracks significant development work at a conceptual level, suitable for ## 2026 Q3 (July – September) +### The Multiplier Was Not the Whole Traction (August 2026) + +**`Stokes_Constrained.topography()` now returns the traction the boundary is +actually held with**, and a new `traction()` exposes it directly. The momentum +row carries `λ + r(n·u − g)`, so the bare multiplier is short by the +augmented-Lagrangian share — `r` times the discrete constraint residual. With the +viscosity-weighted default `r = 1e4·μ(x)` that share is a few per cent of the +surface traction on a uniform-viscosity annulus and most of it across a `1e6` +viscosity step, where `λ` alone reads a tenth of the exact SolCx topography and +is anti-correlated with it. `multiplier()` still returns `λ` and now says what it +is not. + +The defect survived because the validation scored a **correlation** (0.9999) +between the multiplier and the recovered normal stress. A correlation is +scale-free and cannot see a systematic amplitude deficit, which is precisely what +a missing share of the load is. The new guard, +`tests/test_1063_constrained_traction.py`, scores a relative `l2` against the +exact SolCx surface topography and carries the bare multiplier as its negative +control. + +The corrected quantity is the consistent boundary flux: at convergence +`M_Γ(λ + r(n·u − g))` balances the volume residual restricted to the boundary, +which is the CBF nodal load (Zhong, Gurnis & Hulbert 1993). So the multiplier +route and the rotated constraint's `boundary_normal_traction` are the same +computation, and they agree to 3–5% — inside each route's own error against the +exact answer. + +Documentation: `docs/advanced/curved-boundary-conditions.md` now writes the +penalty free-slip recipe against `mesh.boundary_normal` rather than `mesh.Gamma`. +A penalty against the per-facet normal over-constrains the shared nodes and does +not converge — measured on an annulus at coefficient `1e6`, the velocity error +stays at 0.60 and the surface-stress error grows from 0.21 to 0.26 as the mesh is +refined, while the leak reads 1e-5 throughout. (underworld3#607, #608, #614) + +### A Singular Recovery Mass, Mistaken for a Penalty Defect (August 2026) + +**The grad-div penalty default stays off**, but the reason it was held off turned out to +be a defect somewhere else entirely (#633) — so the objection that had blocked it is gone, +and a different one took its place. + +With the penalty at 10, the spherical dynamic topography recovered from the rotated +free-slip reaction dropped 28% at *vertices* while the facet-integrated value stayed +correct. The natural reading — that grad-div augmentation corrupts the de-smearing from +reaction loads to pointwise stress — was wrong. + +The de-smearing mass for a 3-D **P2 triangular** trace has vertex rows that sum to +**exactly zero**. Those rows annihilate a constant, so solving `M σ = R` amplifies any +perturbation of the nodal load at vertices by O(1) — and, being an instability rather +than a discretisation error, does so independently of mesh resolution. The recovery was +already 7.6% low with no penalty at all; the penalty only made it large enough to fail a +test whose 12% tolerance had been hiding it. + +The discrimination needed a case that was curved but not 3-D. A 2-D annulus reproduces +the signature exactly and then parts company under refinement: its error falls ~O(h²) +while the shell's stays flat at ~0.28 over a 3.2× node-count range. The 2-D P2 **line** +mass has positive vertex row sums, which is why 2-D never showed the defect and why +dimension, curvature and the rotated constraint were all red herrings. + +- The zero-mean P2 vertex basis was **already known and documented in #414**, which + recorded the same drift-away-under-refinement we re-measured here. Its mechanism is + the sharper one and is adopted: because the vertex basis has zero surface mean, the + vertex reaction carries essentially only the O(h) facet-normal/geometry error, and the + consistent solve *faithfully reconstructs that error* — it is not amplifying noise. + What #633 adds is the separation from the grad-div penalty (which was blamed for it) + and the fix below, which is #414's own unactioned recommendation (2). +- So `mass="auto"` stops asking. On a 3-D P2 trace it now takes the **consistent** solve, + keeps its superconvergent midpoints, and **reconstructs the vertices from them**: the + three midpoints of a facet determine a unique linear function, so a vertex reads its + two adjacent midpoints and subtracts the opposite one, averaged over incident facets. + Worst-node error against the analytic coefficient, over cellSize 0.25 → 0.11: + + | | 0.25 | 0.20 | 0.16 | 0.13 | 0.11 | + |---|---|---|---|---|---| + | surface, P1-projected | 0.041 | 0.026 | 0.018 | 0.013 | 0.008 | + | surface, reconstructed | 0.016 | 0.012 | 0.012 | 0.003 | 0.004 | + | CMB, P1-projected | 0.116 | 0.067 | 0.047 | 0.030 | 0.025 | + | CMB, reconstructed | 0.094 | 0.058 | 0.043 | 0.024 | 0.015 | + + Better at every resolution on both boundaries, by 1.8x to 4.9x, and converging. The + simpler P1-projected recovery stays available as `mass="p1"` — it is sound, it just + discards the good data along with the bad. +- `FreeSurface` already used the P1-projected recovery in 3-D, so production dynamic + topography was never affected. The exposure was `mass="auto"`. +- The spherical topography test is refined (cellSize 0.25 → 0.13) and its tolerances + tightened from 0.10/0.12 to 0.01/0.05, set from measured discretisation error with + ~2× headroom, and now assert every node class rather than the aggregate — the failure + was confined to one class and an aggregate assertion passed straight through it. +- Filed #637: 3-D recovery accepts only P1/P2 triangular traces, so dynamic topography + has exactly one supported discretisation there and cannot be cross-validated. That + blocked the P3/hex arm of this investigation. +- `Stokes.DEFAULT_PENALTY` was flipped to 10 on the #625 evidence and then **reverted**. + Three tier-A/B tests fail at 10 and pass at 0, and the same three run **8.3x slower** + (9.27 s to 76.92 s, warm cache both ways). The #625 win needs an FMG hierarchy; without + `refinement>=1` the velocity block falls back to GAMG, which is where grad-div + augmentation drives the solve into its iteration cap. The default path is the one + without a hierarchy, so the default serves it; set `penalty=10` explicitly where FMG + is available. +- Two of those three failures are not penalty defects. The Nitsche free-slip leak + (1.234e-4 against a 1e-4 bound) is augmentation perturbing a *weakly* imposed + constraint — a strong rotated constraint is untouched. The swarm one exposed #641: + `evaluate` returns −0.4976 for `sqrt((E**2).trace()/2)` at in-domain points near the + lid-corner singularity, at `penalty=0` as well; the penalty merely moved an accumulated + total across zero. + +### The Free Surface Reaches the Spherical Shell (July 2026) + +**`uw.systems.FreeSurface` now runs in 3D on a spherical shell** — the same +exponential three-number integrator, held-lid σ_nn recovery and strong +material-boundary datum, with the surface machinery made dimension-general +rather than ported piecewise: + +- The datum gauge (mean removal) is an FE trace-mass reduction over + owned boundary facets — no ordered ring, no gather; the same code is the + 2D line gauge and the 3D area gauge. On the way it resolved a real 2D + defect: the deforming-ring strong-datum solves used to stall at a ~2e-3 + residual floor, which turned out to be three stacked causes (arc-length vs + FE trace weights; the datum's *directed* mean flux through the deformed + facet normals, now stripped with the same FE surface integral the residual + uses; and the constant-pressure gauge mode, which the inner solver projects + and the outer loop therefore now measures in the quotient space). With all + three closed, every step of the power-law acceptance run converges. +- σ_nn on a 3D P2 boundary is recovered by **P1 projection** (edge-midpoint + loads folded exactly onto vertices, sound P1 lumped triangle mass) — chosen + over the consistent P2 mass because its vertex-integral checkerboard sits + exactly at the vertices the P1 topography field consumes. +- The two genuinely 2D features (ring Taubin filter, tangential transport) + are refused explicitly in 3D; everything else is shared code. + +First 3D evidence (spherical Y20 topographic relaxation, constant-density +shell): exponential decay at an O(1) shell correction below the half-space +Cathles rate, in the physically correct direction, with the equilibrium +modal bias falling 16% → 2% of the initial amplitude over one resolution +step (the known discrete recovery defect, resolution-convergent). The +detailed benchmarking — analytic shell-rate comparison, convergence study, +low-Ra spherical convection, 3D parallel — is deliberately left to the +review pass. + +### One Owner for the Geometric-Multigrid Option Bundle (July 2026) + +**The PETSc option bundle that configures a Stokes velocity block's multigrid +now lives in exactly one module, and all three routes that reach that block read +it from there** (#468), **and rotated free-slip now picks up a mesh-owned +multigrid hierarchy instead of silently discarding it** (#467). + +Three routes reach a multigrid velocity block: native (PETSc interpolation +between refined DMPlex levels), custom-P on the standard solve path, and custom-P +through the rotated free-slip path. They are the same preconditioner reached +three ways, not alternatives — custom-P is *mandatory* wherever native cannot go, +namely rotated boundary conditions and `adapt()` children. The bundle was +written in two places and had drifted: the native path had been moved to a +`gmres`+`sor` smoother on a recorded measurement, and the custom-P routes had +not. Worse, the custom-P writer never *set* the smoother iteration count at all, +so it inherited whatever had last written that options prefix — 3 left behind by +the GAMG bundle on the standard path, PETSc's own default of 2 on the rotated +path. The same function smoothed differently depending on what had run before +it. + +Unifying the bundle recovers, on the same operator, right-hand side and coarse +solve: rotated custom-P velocity-block iterations 11 → 5 (0.68 s → 0.39 s of +linear solve, timed in isolation), standard custom-P 5 → 4, on a *two-level* +hierarchy — the depth at which the native measurement says the gmres margin is +smallest. The bundle also now derives which stale keys it must clear rather than +carrying a hand-maintained list, which is what let the iteration count go unset +in the first place. + +Separately, `mesh.adapt()` leaves a coarse tail on its refinement child so that +every solver on an adapted mesh gets geometric multigrid with no per-solver call. +The rotated path never consulted it — the standard path's injection hook runs +after the rotated dispatch has already returned — so an adapt child under rotated +free-slip fell back to algebraic multigrid, indistinguishable from having no +hierarchy at all. That is the `adapt-on-top-faults` workflow's own configuration +(a fault resolved by local refinement, with rotated free-slip chosen because it +composes with transverse isotropy). Both paths now resolve the hierarchy through +one shared rule, with the same opportunistic degrade-to-GAMG behaviour. + +The regression test reads the smoother configuration back off the **live PETSc +objects** for all three routes and asserts they agree. An options-database +assertion would not have caught the original drift, because the drift was +precisely a key nobody wrote. + +### One Rotated Free-Slip Path, Now With a Prescribed Wall-Normal Velocity (July 2026) + +**Rotated strong free-slip now takes a prescribed wall-normal velocity datum +(`add_rotated_freeslip_bc(conds, boundary, ...)` with non-zero `conds`) through +the full nonlinear Newton machinery, and the separate linear and nonlinear +solve paths have been unified into one** (#438; #403 items 2 and 4). + +The rotated constraint `u·n̂ = ũ_n` is the primitive behind both the held free-slip +lid and the free-surface material-boundary condition — they differ only in the +constraint right-hand side. Previously the non-zero datum existed only on a +linear one-shot path, so a power-law or anisotropic rheology silently fell back +to a weak penalty (which leaks worst exactly where anisotropy makes it matter). +Now every rotated solve runs a single Newton/Picard loop in which accepted +iterates carry the datum exactly; a cold start imposes it through the first +increment's affine lift at the rest-state tangent (snapping a zero state onto a +datum creates a boundary strain state a shear-thinning tangent cannot recover +from — measured, not assumed); a linear model simply converges after that first +increment, so the up-front nonlinearity probe is gone from the dispatch and +every linear rotated solve saves two Jacobian assemblies. + +Three latent solver defects were found and fixed by making the loop report +honestly along the way: + +- Branching on rank-local datum bookkeeping desynchronised the ranks' + collective sequences (an np>1 deadlock class); the datum-activity decision is + now a collective PETSc reduction. +- A rigid-rotation mode pinned by an essential condition on *another* boundary + could still enter the solver null space (only the rotated rows were checked), + silently projecting an irreducible component out of every increment — Newton + converged superlinearly and then floored, far above tolerance. Candidate + modes are now verified as null vectors of the assembled operator, which + catches any form of pinning. +- A tiny Newton step was reported as convergence even when the residual was + still large (a stiff tangent also produces tiny steps); the step-norm exit is + now verified against the problem's rest-state residual scale, which is also + the convergence reference for warm starts (rtol relative to a good warm + start's own small initial residual demands ever-more absolute accuracy). + +The free-surface manager's `consistent_constraint="strong"` therefore works for +nonlinear rheologies: the penalty fallback is removed, and the consistent solve +warm-starts from the free solve's converged fields. Acceptance: power-law +annulus free-surface convection holds the material boundary at the strong-datum +level (5e-3, versus 4e-2 for the penalty) with net surface flux 1e-4; a +transversely isotropic fault-bearing smoke test converges through the same +path. Direct LU per Newton increment remains available as a serial, +preconditioner-free diagnostic (`solver._rotated_use_lu`). + +New subsystem documentation: `subsystems/rotated-freeslip.md`. + +### Local Interpolation That Reproduces Linear Fields (July 2026) + +**The local scattered-point interpolator now has a linear-reproduction +guarantee**, and the swarm proxy variables use it by default (#430). + +Underworld's local interpolator was inverse-distance (Shepard) weighting. +Its weights are positive and sum to one, so it reproduced a *constant* exactly +but not a linear field: any field with a gradient was smeared, and the error +did not fall as the points crowded together. Measured on an exactly linear +field — which lies inside both the P1 and P2 proxy space, so the finite element +discretisation contributes nothing and all of the error is particle-to-node +transfer — the swarm proxy carried 1e-3 to 2e-2 relative error, falling only +first order with refinement and **not at all** with stencil size. + +- New `order=1` scheme on `uw.kdtree.KDTree.rbf_interpolator_local`: a + polyharmonic kernel with an affine tail, solved per target point on its own + nearest-neighbour stencil. Constants and linear fields are exact by + construction, and the result stays sparse at `nnn` non-zeros per row. The + existing inverse-distance path is unchanged and remains the KDTree default. +- Proxy error on a linear field falls to round-off; on a quadratic field it + improves roughly ninety-fold in 2D and thirty-fold in 3D. Swarm proxy + variables now default to `order=1`. +- `KDTree.interpolation_matrix()` returns the transfer as a sparse operator. + The weights depend only on geometry, so one build serves every field and + component — the form a multigrid prolongation or a remesh transfer wants. +- Stencils that cannot support an affine fit (collinear in 2D, coplanar in 3D) + are detected, retried on a wider neighbourhood, and only then fall back to + inverse distance with a warning. They never return `NaN`, and never silently. +- An opt-in limiter bounds the non-affine part of the interpolant while leaving + the linear reconstruction untouched, so limiting does not cost the guarantee. + +Two deliberate exclusions, both measured rather than assumed: +`MeshVariable.rbf_interpolate` keeps inverse distance because it is the +fallback rung of the point-location ladder, whose documented contract is that +it is bounded; and `IndexSwarmVariable` material level sets keep it because +they estimate a fraction from a handful of *integer* samples, where the error +is dominated by variance rather than bias. Signed weights amplify that variance +by roughly an order of magnitude and push level sets outside `[0, 1]`, while +the bias they would remove is already negligible. + +Related: swarm proxy refresh no longer fails under an active units model +(#426, #434); the units the proxy advertises are tracked separately (#439). +New subsystem documentation: `subsystems/interpolation.md`. + +### Purposeful Adapt / Redistribution Naming (July 2026) + +**User-facing mesh-modification names now state the capability** (maintainer +naming ruling 2026-07-16); the algorithm names (NVB, MMPDE) stay in internals +and docstrings: + +- New user entry `uw.meshing.node_redistribution(mesh, metric, ...)`, + dispatching through the mesh-controlled `Mesh.redistribute_nodes(metric)` + method — the architecture by which each mesh type controls how it can be + modified. The base implementation supports 2D simplex (triangle) meshes + (via the MMPDE mover); quad/hex, 3D and manifold meshes raise an honest + `NotImplementedError` stating what exists. `smooth_mesh_interior` remains + as the machinery underneath. +- `mesh.adapt(metric, max_levels=...)` no longer needs `engine=`: the graded + newest-vertex-bisection engine is the default on 2D meshes (NVB is 2D-only + this pass, so 3D meshes resolve to SBR); `engine=` stays as the + advanced/internal selector. + +### Retired Interior Movers — MMPDE Is the Mover (July 2026) + +**The superseded fixed-topology interior movers were retired** (maintainer +ruling 2026-07): the spring-equilibrium, Monge–Ampère, OT-improvement-step +and anisotropic-Winslow movers were deleted, together with `mesh.OT_adapt()` +(built on the OT step; closes #346, whose latent MPI deadlock dies with the +spring mover, and #353, whose `strategy=` TypeError dies with the dispatch). + +- `smooth_mesh_interior(method=...)` now defaults to **`"mmpde"`** (was + `"spring"`) — a sanctioned behaviour change: with a scalar metric the + MMPDE mover reproduces the retired movers' isotropic equidistribution + (the isotropic-metric equivalence), and with a tensor metric it clusters + and aligns where they could not. Retired spellings raise a `ValueError` + naming the replacement. +- `follow_metric(...)` (the two-knob adapter) now drives the MMPDE mover; + `mesh.OT_adapt()` raises a `RuntimeError` tombstone pointing at + `follow_metric` / `smooth_mesh_interior` / `mesh.adapt`. +- The graph-Laplacian Jacobi smoother and the Taubin surface-field smoother + (`smooth_surface_field`) are separate, current tools and are unchanged. +- The boundary-facet / boundary-slip primitives shared with surviving code + moved from `meshing/_ot_adapt.py` into `meshing/smoothing/graph.py`; + the style-gate allowlist shrank by the deleted files' entries. + + ### July 2026 Quality Campaign — Audit, Style Charter, Remediation Waves (July 2026) **A systematic post-development-burst quality campaign**: six adversarially @@ -53,8 +368,33 @@ component exactly — correct on curved, tilted, and deformed boundaries (#293). - A general **consistent boundary flux (CBF) primitive** recovers boundary fluxes for any solver — surface heat flux / Nusselt number for scalar diffusion, boundary traction σ·n for Stokes (#294). +- Three-dimensional CBF recovery now assembles the exact triangular trace mass: + P1 supports lumped or consistent recovery, while P2 uses the required + consistent six-node surface-mass solve. The default `mass="auto"` selects the + valid method; explicit P2 lumping and non-triangular 3D traces raise instead + of returning a non-pointwise reaction scaling. Strict MPI invariance of a + vector normal projection requires an analytic normal; geometric facet-normal + seam sensitivity is unchanged (#404). - Recorded as the preferred free-slip BC in the project guidance (#300); conda PETSc floor raised to ≥ 3.25 for FMG/rotation API consistency (#304). +- `uw.postprocessing.geoid` provides generic spherical-shell geoid and + self-gravity coefficient functions. Its rotated-Stokes adapter projects the + existing boundary traction onto an axisymmetric harmonic; the pure functions + also accept coefficients recovered by other methods and an optional internal + load. +- Rotated free slip exposes + `Stokes.boundary_normal_traction_integral(boundary, fn)` for a distributed + weak contraction of the assembled normal reaction. Cylindrical-annulus + Stokes responses use this fitted integral and its matching finite-element + boundary norm instead of gathering pointwise samples for angular quadrature. +- The spherical-shell geoid adapter accepts `projection="reaction"` to use + the same fitted integral without pointwise P2 recovery or a rank-zero + surface triangulation; `projection="centroid"` remains the default. +- `uw.analytic.Zhong2008` implements the Hager--O'Connell propagator-matrix + oracle used for the Zhong et al. spherical-shell response benchmark. It + supports piecewise-constant radial viscosity and reproduces every analytical + response printed in Zhong Tables 2 and 3; geoid and self-gravity are delegated + to the generic postprocessing functions above. - **Bug fix**: the zero-datum guard in `add_rotated_freeslip_bc` now uses `is_zero` instead of sympy structural equality, so the value-first call `add_rotated_freeslip_bc(0.0, boundary)` — the exact form the deprecation @@ -163,6 +503,11 @@ in `Stokes_Constrained` (#224), then made parallel-correct. - `selfp` Schur preconditioner default, viscosity-scaled penalty, and nullspace re-setup fix (#229); over-conservative serial guard removed (#240); gauge, convergence, knockout, and rotation-gauge fixes (#265). +- The main `Stokes.penalty` (augmented-Lagrangian grad-div) is likewise + viscosity-scaled since June 2026: the parameter is now a dimensionless + O(1) number, not a large constant tuned against the viscosity magnitude. + Migration note for older scripts in `docs/advanced/troubleshooting.md` + (#292). ### Boundary Conditions: Local-h Nitsche and Boundary-Slip Surfaces (June 2026) @@ -170,6 +515,11 @@ in `Stokes_Constrained` (#224), then made parallel-correct. minimum radius, restoring correct stiffness on graded and adapted meshes (#275). +- The local size now comes from each cell's own geometry instead of a kd-tree + over the centroids held by the current MPI rank. The old field changed at + partition boundaries and moved the default ``local_h=True`` Nitsche velocity + answer by 6.6e-3 between rank counts; the replacement is cell-by-cell + identical from one to eight ranks (#569, #687). - `mesh.boundary_slip` API with `BoundingSurface` objects for boundary tangent-slip (#225); `Surface.influence_function` respects finite edges (#241). diff --git a/docs/developer/TESTING-RELIABILITY-SYSTEM.md b/docs/developer/TESTING-RELIABILITY-SYSTEM.md index c8f791dcc..4e8d072c8 100644 --- a/docs/developer/TESTING-RELIABILITY-SYSTEM.md +++ b/docs/developer/TESTING-RELIABILITY-SYSTEM.md @@ -1,6 +1,6 @@ # Test Reliability Classification System -**Last Updated**: 2025-11-15 +**Last Updated**: 2026-09-12 **Status**: Active - Use for all new tests and test reviews ## Overview @@ -64,40 +64,69 @@ Underworld3 uses a three-tier reliability classification system (A/B/C) to ensur 3. Core maintainer review confirms test quality 4. Add to Tier A suite via PR review -### Tier C: Experimental (Development) -**Use for**: Feature Development, Debugging, Test Development - -**Characteristics**: -- 🚧 Test OR code (or both!) may be incorrect -- 🚧 Actively under development -- 🚧 Used to explore expected behavior -- 🚧 May test unimplemented or partially implemented features -- 🚧 Failures are EXPECTED and informative -- 🚧 Not suitable for any automated testing - -**Examples**: -- Tests written for not-yet-implemented features -- Exploratory tests to understand API design -- Tests for actively debugged features -- Tests with known issues (mark with `@pytest.mark.xfail` + reason) - -**Pytest Markers**: +### Tier C: Does Not Gate + +**The defining property**: a Tier C failure NEVER blocks a change. It demands an +explanation. Tier C tests still run in CI and are still read — they are excluded +from what gates a merge, not from what is executed. + +Two different populations share that property. + +**C1 — Characterisation.** The test validates that the code works, but asserts a +relationship that may legitimately stop holding when something improves: a +comparison between two methods, a recorded measurement, a ratio that is true of +today's defaults. These CAN fail because the code got better, and that failure is +information, not a regression. + +- Give the assertion a failure message saying so outright — the reader must not + reach for a revert. +- Record the measured numbers and the configuration that produced them in the + docstring, dated. +- Do NOT change library code to make one pass. Re-characterise it and say why. +- If an assertion would break when the code gets better, this is its home. + +Prefer a hard baseline wherever one exists — Charter §8 requires it, and a +characterisation is the fallback for when none does, not a place to park an +assertion that was easier to write. Four method-comparison tests were moved here +in 2026-09 and then removed again, because in every case the baseline was already +available: the comparisons were less informative than the numbers they were +computed from. That included the last one: `test_1060_nitsche_freeslip.py` asserted that the +weak constraints must STAY inaccurate (`leak > 1e-5`), which would have failed if +Nitsche improved. Its exact half — an essential BC holds `v.n` to machine +precision — is a hard baseline and is now a tier B contract; the weak-path leaks +are printed for a reader instead of gated on. + +C1 is therefore rare by design. Reach for it only when you have measured +something worth recording and can show that no baseline exists. + +**C2 — Experimental.** Test or code (or both) may be incorrect: written for a +feature that is not finished, exploring what the behaviour should be, or +reproducing a bug under investigation. Failures are expected and informative. +Mark with `@pytest.mark.xfail(reason=...)` or `@pytest.mark.skip(reason=...)` +where the failure is known. + +**Neither is a basis for coding.** Tier A is what you build code around; Tier C +must not drive a code change. That constraint is the original reason the tiers +exist — it keeps a freshly written test, which may simply be wrong, from +steering the implementation it was written against. It applies to C1 and C2 +alike, and it is separate from whether the test runs. + +**Pytest markers**: - `@pytest.mark.tier_c` -- `@pytest.mark.xfail(reason="Feature not yet implemented")` -- `@pytest.mark.skip(reason="Waiting for X to be fixed")` +- plus `@pytest.mark.xfail(reason=...)` / `@pytest.mark.skip(reason=...)` for C2 + where relevant -**When to Use**: -- Feature development (write test first, then implement) -- Debugging complex issues (write test to reproduce bug) -- API design exploration (what SHOULD the behavior be?) -- NEVER for automated CI/TDD - -**Promotion Path**: C → B -1. Feature fully implemented -2. Test passes consistently -3. Developer confirms test is correct -4. Remove xfail/skip markers -5. Promote to Tier B for further validation +**One tier per test, and it goes on the test.** pytest MERGES a module-level +`pytestmark` with a function's own marks — it does not override them. A `tier_c` +test inside a `tier_a` module therefore carries both, and `tier_a or tier_b` (the +default selector in `scripts/release_gate.py`) still picks it up, so a +characterisation could gate after all. Where the tests in a file do not share a +tier, put the LEVEL on the module and the TIER on each test, and say why in a +comment next to `pytestmark` so the split does not read as an oversight. + +**Promotion path**: C2 → B once the feature is implemented, the test passes +consistently and a developer confirms the test itself is correct. C1 does not +promote — a characterisation is Tier C permanently, by its nature. ## Implementation in Pytest @@ -109,7 +138,7 @@ markers = # Reliability tiers (how much to trust the test) tier_a: Production-ready tests (trusted, use for TDD and CI) tier_b: Validated tests (use with caution, manual review recommended) - tier_c: Experimental tests (development only, not for automation) + tier_c: Does not gate. Runs and is reported, but a failure demands an explanation, not a revert (characterisations, and work in progress) # Complexity levels (what kind of test, independent of number prefix) level_1: Quick core tests - imports, basic setup, no solving (~seconds) diff --git a/docs/developer/UW3_Developers_NDArrays.md b/docs/developer/UW3_Developers_NDArrays.md index cf568f2ee..f950d2b1d 100644 --- a/docs/developer/UW3_Developers_NDArrays.md +++ b/docs/developer/UW3_Developers_NDArrays.md @@ -438,21 +438,13 @@ The NDArray_With_Callback system intercepts NumPy operations: ```python def __setitem__(self, key, value): - # Capture old value for callback info - old_value = self[key].copy() if self._track_changes else None - # Perform the actual assignment super().__setitem__(key, value) - - # Trigger callbacks with operation info - if self._callbacks_enabled: - change_info = { - 'operation': 'setitem', - 'indices': key, - 'old_value': old_value, - 'new_value': self[key], - } - self._trigger_callback(change_info) + + # Notify callbacks. old_value is always None: no registered callback + # ever read prior values, and snapshotting them cost a full-array + # copy per write (the key is retained for dict-shape compatibility). + self._trigger_callback("setitem", indices=key, new_value=value) ``` ## PETSc Synchronization Details diff --git a/docs/developer/UW3_STYLE_CHARTER.md b/docs/developer/UW3_STYLE_CHARTER.md new file mode 100644 index 000000000..8b574da46 --- /dev/null +++ b/docs/developer/UW3_STYLE_CHARTER.md @@ -0,0 +1,208 @@ +# UW3 Style Charter + +**Status**: Normative. Every development session — human or AI — loads this document +and follows it. It is deliberately short; there is no excuse for not reading all of it. + +## 1. The Founding Rule + +Anyone — specifically, any working geodynamicist — must be able to read this code and +understand it. Over-complication and inconsistent patterns are the enemies of that +rule: a clever construction that saves ten lines but costs the reader a detour has +made the code worse. When in doubt, write the plain version. + +Don't forget this: Underworld3 is a Python API for building mathematical models, +particularly for geoscience. The founding rule applies as much to the Python scripts +and Jupyter notebooks as it does to the code we write under the hood. We write code +that makes models easy to read, and we always hide complex or irrelevant detail at +least one level below the notebooks and scripts. Inner workings (private +`_method_xxx()`) are never visible to the user. + +Consistent patterns: common choices are the defaults; uncommon choices are harder to +find. Documentation is great — the docstrings turn into the documentation and are +Jupyter-friendly. We do not shy away from equations in docstrings, and we do provide +good, simple examples. We don't overdo it, we don't force-feed our users with emoji, +and we don't pat ourselves on the back for our cleverness. We also don't care how +complicated something was to code or debug; we just report how it works. + +## 2. Match the Charter, Not the Surrounding Code + +**This is a load-bearing clause.** A month of drift means existing code is not a +reliable pattern to copy — drifted code copied becomes drift squared. You MUST follow +this Charter even where nearby code disagrees with it. When you find such a +disagreement, do not silently imitate it and do not silently "fix" it either: follow +the Charter in the code you write, and flag the existing deviation with a +`# TODO(BUG):` marker or a note to the maintainer. NEVER treat "the file next door +does it this way" as authority. + +## 3. Naming + +- Names state what a thing IS or DOES. No hedging prefixes — `maybe_`, `try_`, `do_` + are banned (this rule has been enforced twice already; it keeps coming back). +- Private helpers and attributes take a single underscore prefix; everything without + one is public API and must behave like it. +- Never name a variable `model`: use `constitutive_model` (material behaviour) or + `orchestration_model` / `uw.get_default_model()` (serialization system). + +```python +# BAD: def _maybe_install_snes_update(self): ... +# GOOD: def _attach_snes_update_dispatcher(self): ... +``` + +- Usually default to the mathematical naming - surface_flux_recovery is better than compute_topography. Geographic meshes might be the main exception. + +## 4. Comments + +- Comments state the physical or mathematical intent and the constraints the code + cannot show: why this term is in the weak form, why this barrier must precede that + collective, why a tolerance has this value. +- Never narrate mechanics (`# increment i`), never address the reviewer + (`# as requested`), never leave editorial musings (`# why is this here??`) — file + those as `# TODO(DESIGN):` or delete them. +- Delete commented-out code. Git remembers; the reader should not have to. Leave a TODO if there is reason to look up the deleted code for another time. +- Every intentional exception swallow states its sanctioned failure mode on the same + block. A bare `except Exception: pass` with no comment is a defect. +- Refer to methods by name, never by line number — line references rot within weeks. + +## 5. Structure + +- DRY after the 2nd occurrence: the moment you would paste a block a second time, + extract it instead. Copy-paste-as-reuse is the single worst drift pattern found in + the 2026-07 audit (2–6 diverging copies of the same machinery per hotspot file). +- No speculative generality: no parameters "for forward-compatibility", no reserved + branches that `pass`, no dead flags. Build it when it is needed. +- No defensive bloat: do not guard states that cannot occur (e.g. `try/except` around + importing a hard dependency). Guards imply the state is reachable; false guards lie. +- Before writing a helper, look for the existing one: `uw.function`, `uw.maths`, and + `src/underworld3/utilities/` are the discovery paths. Prefer a battle-tested + package over a hand-rolled implementation. + +## 6. API Conventions + +These settle the June-2026 drift (see `docs/reviews/2026-07/API-CONSISTENCY-REVIEW.md`). + +| Topic | Rule | +|---|---| +| BC argument order | `add__bc(conds, boundary, ...)` — value first, matching the original trio, the published examples/benchmarks, and previous major versions (maintainer decision 2026-07-04). The newer boundary-first methods (`add_nitsche_bc`, `add_rotated_freeslip_bc`, `add_constraint_bc`) migrate to it; their current signatures are shimmed. | +| BC value parameter | ONE name across all BC methods: **`conds`** (maintainer decision 2026-07-06, recorded in `docs/reviews/2026-07/REMEDIATION-WORKLIST.md`). Other spellings (`g`, `value`, ...) exist only as deprecated keyword aliases where they existed before the decision; never introduce a new spelling. | +| Direction selection | Component masking via `None`/`sympy.oo` entries in the value vector; `direction=` only for a scalar constraint along a vector (defaults to outward normal); `normal=` strictly overrides the geometric surface-normal source. `components=` is deprecated — never in new code. | +| Solver capabilities | Anything that configures or reads one solver is a METHOD on that solver, lazily importing its `utilities/*` implementation (the `boundary_flux` pattern) — never a free function as the documented entry point. | +| Namespaces | Every user-facing module is exported from its subpackage `__init__`/`__all__` in the PR that creates it. No deep-import-only features. | +| Docstrings | NumPy/Sphinx style with RST `:math:`. This SUPERSEDES the Markdown-for-pdoc prescription still printed in `UW3_Style_and_Patterns_Guide.md` — that section is wrong; do not follow it. | + +## 7. Data Access + +- Variable data in new code uses the `array` property with the three-index shapes: + scalars `(N, 1, 1)`, vectors `(N, 1, dim)`, tensors `(N, dim, dim)`. +- Mesh coordinates are `mesh.X.coords`. +- NEVER in new code: `with mesh.access(...)` / `with swarm.access(...)`, `mesh.data`, + or `mesh.points`. They exist only so old code keeps running. +- The flat `.data` property carries ONE sanctioned exception: it bypasses all units + evaluation and re-packing, so it may be used for a raw variable-to-variable copy + inside the non-dimensionalisation boundary (where values are already consistent). + Any other use in new code goes through `.array`. + +```python +temperature.array[:, 0, 0] = values # GOOD — scalar, three indices +temperature.data[:, 0] = values # BAD — compatibility layer in new code +``` + +## 8. Tests + +- Every bug fix ships the regression test that would have caught the bug — written + first, shown to fail, then fixed. +- Test files follow `tests/test_NNNN_description.py` numbering and carry both markers: + a level (`level_1`/`level_2`/`level_3`) and a tier (`tier_a`/`tier_b`/`tier_c`). + The number sets a broad sequence, nothing more — selection is by marker, and a + shared number is not a conflict. +- Validate a new test's own correctness before changing library code to satisfy it. +- **Every test asserts against a HARD BASELINE.** An analytic solution, a published + value, a closed-form geometric quantity, a conservation identity, an exactness + property. If a failure cannot name what is broken, it is not a test — it is a + drift detector, and it belongs in a benchmark rather than the suite. +- **A baseline must be tight enough to fail the moment a default changes.** This is + the point of the rule. A loose test does not fail when behaviour moves: it absorbs + the change, drifts inside its own margin, and fails later somewhere else for + reasons that are hard to trace back. #692 redefined `mesh.cell_size()`, nothing + failed, the Nitsche penalty moved 43%, and a spherical-shell benchmark slid from + 0.2% to 2.4% — still inside its 5% tolerance — before breaking months later on one + platform's triangulation (#734). The test that would have caught it on the day is + `cell_size` pinned to its closed form on a known simplex. +- **Never assert that one method beats another.** A ratio between two errors moves + when either moves, so it cannot say which; it is strictly less informative than the + numbers it was computed from, and it encodes a preference rather than a contract. + Assert each method against the baseline instead. Convergence ORDER and mathematical + exactness are contracts and may be asserted freely. +- **Prefer a relative bound to an absolute one** where the scale is set elsewhere. An + absolute threshold silently tracks whatever sets that scale — a free-slip test + asserting `|v_n| < 1e-4` was really asserting 5.7e-3 relative, and tracked the + buoyancy forcing rather than the method under test. + +### The tiers + +| Tier | Meaning | +|---|---| +| `tier_a` | Hardened and reviewed. Safe to build code around, and safe to gate a merge on. | +| `tier_b` | Validated; trustworthy but not yet hardened. | +| `tier_c` | Validates that the code works, but MUST NOT block a change. A failure demands an EXPLANATION, not a revert. | + +Tier C is where a characterisation lives: a comparison between methods, a recorded +measurement, a relationship that holds today and may legitimately stop holding when +something improves. Give such a test a failure message that says so, and record the +measured numbers and their configuration in the docstring, dated. Do not revert code +to make a tier C test pass — re-characterise it and say why. + +**A test carries exactly one tier, and it goes on the test, not the module.** +pytest MERGES a module-level `pytestmark` with a function's own marks rather than +overriding them, so a `tier_c` test inside a `tier_a` module carries BOTH and is +still selected by `tier_a or tier_b` — which is what `scripts/release_gate.py` +asks for. Where the tests in a file do not share a tier, put the level on the +module and the tier on each test. + +## 9. Scope Discipline for AI Sessions + +Fix what you were asked to fix. No drive-by refactors, renames, or "while I was here" +cleanups — they turn a reviewable diff into an unreviewable one and have been the +main vector of drift. When you find a real problem outside your task, mark the exact +location with the project's `# TODO(BUG): description` format (see CLAUDE.md) and +mention it in your report; do not fix it silently. + +## 10. Authority + +This Charter wins on any conflict. One governing document per topic: + +| Topic | Governing document | +|---|---| +| Data access | `docs/developer/subsystems/data-access.md` | +| Units | `docs/developer/design/UNITS_SIMPLIFIED_DESIGN_2025-11.md` | +| Testing tiers | `docs/developer/TESTING-RELIABILITY-SYSTEM.md` | +| Branching & releases | `docs/developer/guides/branching-strategy.md` | + +`UW3_Style_and_Patterns_Guide.md` remains as the detailed reference, but where it and +this Charter disagree (notably docstring format and coordinate access), the Charter wins. + +**Machine enforcement**: the cheapest-to-check rules here (§3 hedging names, §4 silent +swallows, §7 deprecated data access) are enforced in CI by +`scripts/check_deprecated_patterns.py` via `.github/workflows/style-gates.yml`; its +allowlist records pre-existing legacy hits and may only shrink. See `guides/style-gates.md`. + +## 11. Things to Remember + +Underworld is a parallel code — no feature is complete if it only works in serial. + +Underworld functions are a better choice than PETSc, PETSc is a better choice than MPI, +and serial libraries like scipy are usually a poor choice, generally only for +prototypes. Do not leave them in code without checking. + +Examples: + +- `uw.print` or `uw.timing` are better than PETSc because they wrap the best of the + PETSc tools in a uw-styled interface. Avoid `.npz` or `.csv` logging when we have + good parallel output that is properly flushed. +- The user should never see parallel / mpi calls. `uw.mpi.rank` or `mpi.rank` is a + mistake — there should be a wrapper already and it will work better. Guide the users! +- Scripts and drivers written during development sessions use uw's own machinery, not + hand-rolled equivalents. Option parsing is the canonical example: use `uw.Params` / + `Param` (notebook-editable defaults, units-aware, `-uw_name value` CLI overrides via + PETSc options) — not `argparse`, not ad-hoc `sys.argv` handling, not a config dict at + the top of the file. A session script is a model a reader should be able to run and + read; the founding rule applies to it in full. diff --git a/docs/developer/UW3_Style_and_Patterns_Guide.md b/docs/developer/UW3_Style_and_Patterns_Guide.md index 1b76f6ce4..10d086677 100644 --- a/docs/developer/UW3_Style_and_Patterns_Guide.md +++ b/docs/developer/UW3_Style_and_Patterns_Guide.md @@ -1,27 +1,12 @@ --- title: "Underworld3 Style and Patterns Guide" subtitle: "Development Standards and Architectural Patterns" -author: "Underworld Development Team" -date: today -execute: - enabled: false -format: - html: - toc: true - toc-depth: 3 - toc-location: left - number-sections: true - code-fold: true - theme: cosmo - pdf: - documentclass: report - geometry: margin=1in - toc: true - number-sections: true --- ```{note} Document Purpose -This guide documents the established patterns, conventions, and architectural decisions for Underworld3 development. It serves as a reference for maintaining consistency across the codebase. +This guide documents the established patterns, conventions, and architectural decisions for Underworld3 development. It serves as a detailed reference for maintaining consistency across the codebase. + +The normative style contract is the [UW3 Style Charter](UW3_STYLE_CHARTER.md) — where this guide and the Charter disagree, **the Charter wins**. See the authority map in [the developer documentation index](index.md). ``` # Code Organization @@ -67,22 +52,22 @@ Properties should return array-like objects that can trigger updates when modifi ```python -class Mesh: +class Field: @property def data(self): - """Mesh coordinate data with reactive callbacks.""" + """Field data with reactive callbacks.""" if self._cached_data is None: self._cached_data = NDArray_With_Callback( - self._coordinates, + self._values, owner=self ) - self._cached_data.set_callback(self._on_coordinates_changed) + self._cached_data.set_callback(self._on_values_changed) return self._cached_data - - def _on_coordinates_changed(self, array, change_info): - # Invalidate cached computations - self._jacobians = None - self._mesh_quality = None + + def _on_values_changed(self, array, change_info): + # Invalidate cached computations that depend on the values + self._interpolant = None + self._stats_cache = None ``` ## Property with Getter/Setter Pattern @@ -104,57 +89,60 @@ When properties need to behave like arrays but with additional functionality: ```python -# Users should access: mesh.data[...] instead of mesh.data +# Users index into the property: var.data[...] rather than rebinding var.data # Properties return NDArray_With_Callback for transparent numpy compatibility ``` # Documentation Style -## Markdown Docstrings for pdoc/pdoc3 +## NumPy/Sphinx Docstrings -Use markdown format with mathematics support: +Docstrings use **NumPy style with RST markup** (`:math:`, ``double backticks`` +for code). This is the settled standard (Style Charter §6); the docstrings turn +into the Sphinx documentation and render well in Jupyter. The conversion of older +docstrings is tracked in `docs/plans/docstring-conversion-plan.md`. ```python -class MyClass: - """ - # MyClass - - Brief description with **bold** and *italic* formatting. - - ## Mathematical Representation - - Given an array $\\mathbf{A} \\in \\mathbb{R}^{n \\times m}$, operations follow: - - $$\\mathbf{A}' = \\mathcal{O}(\\mathbf{A}) \\implies \\text{callback}(\\mathbf{A}', \\text{info})$$ - - ## Usage Examples - - ### Basic Usage - ```python - obj = MyClass([1, 2, 3]) - obj.set_callback(my_callback) - ``` - - ## Advanced Features - - - **Feature 1**: Description - - **Feature 2**: Description - - ## Performance Notes - - - **Zero overhead** when disabled - - **Minimal impact** during normal operations +def solve_diffusion(kappa, delta_t, monotone=False): + r"""Advance the diffusion equation by one timestep. + + Integrates :math:`\partial_t T = \nabla \cdot (\kappa \nabla T)` with an + implicit theta-scheme. + + Parameters + ---------- + kappa : float or sympy expression + Diffusivity :math:`\kappa`. May depend on position or on other + mesh variables. + delta_t : float + Timestep. Non-dimensionalised internally when the model carries units. + monotone : bool, default=False + Clamp interpolation overshoot during the semi-Lagrangian trace-back. + + Returns + ------- + MeshVariable + The updated temperature field. + + Examples + -------- + >>> T_new = solve_diffusion(1.0, 0.01) + + Notes + ----- + We do not shy away from equations in docstrings: state the weak form or the + scheme where it aids the reader. """ ``` ## Key Documentation Elements -- Use `#` headers for structure -- Include mathematical notation with LaTeX +- NumPy sections in the standard order: summary line, extended description, + ``Parameters``, ``Returns``, ``Examples``, ``Notes``, ``See Also`` +- Mathematical notation via RST ``:math:`` roles (raw strings ``r"""`` for backslashes) - Provide complete, runnable examples -- Use tables for parameter documentation -- Include performance considerations +- Include performance considerations where they matter (in ``Notes``) # Array and Data Management @@ -171,8 +159,14 @@ arr = NDArray_With_Callback(data, owner=self, callback=func) # With callback # Callback signature def callback(array: NDArray_With_Callback, change_info: dict) -> None: - # change_info contains: operation, indices, old_value, new_value, array_shape, array_dtype + # change_info contains: operation, indices, old_value (always None, + # retained for compatibility), new_value, array_shape, array_dtype pass + +# For callbacks that synchronise a variable's CANONICAL storage, register +# with add_canonical_callback(func): it guards against firing on derived +# views/copies (rank-asymmetric collectives, #376) and hands the callback +# the canonical array every time. ``` ## Array vs Data Property Shapes @@ -200,25 +194,22 @@ vector.array[:, 0, :] = all_components # Full vector ```python # Preferred: Direct array access with proper indexing -temperature.array[:, 0, 0] = temp_values # Scalar -velocity.array[:, 0, :] = vel_field # Vector -mesh.data[0] = new_position # Mesh coordinates -swarm.data += displacement # Swarm positions +temperature.array[:, 0, 0] = temp_values # Scalar +velocity.array[:, 0, :] = vel_field # Vector + +# Mesh coordinates: read via mesh.X.coords; deform via mesh.deform() +coords = mesh.X.coords # (N, dim) read access +mesh.deform(mesh.X.coords + displacement) # coordinate changes go through deform() + +# Swarm particle positions: the public coords property (getter and setter) +swarm.coords = swarm.coords + displacement # Avoid: Incorrect indexing # scalar.array[:, 0] = values # Missing third index! # vector.array[:, i] = values # Missing middle index! -``` -## Coordinate System Transformations - -```python - -# Reference changes throughout codebase -# OLD: swarm.particle_coordinates -# NEW: swarm._particle_coordinates -# OLD: mesh.deform_mesh -# NEW: mesh._deform_mesh +# Avoid: deprecated coordinate accessors (kept only so old code runs) +# mesh.data, mesh.points, swarm.data, swarm.points ``` # Context Managers @@ -265,11 +256,12 @@ with arr.delay_callback("batch update"): arr[2] = 3 # All callbacks fire here with MPI barriers -# Global coordination -with NDArray_With_Callback.delay_callbacks_global("mesh deformation"): - mesh.data += displacement - swarm.data += velocity * dt +# Global coordination across several variables +with NDArray_With_Callback.delay_callbacks_global("field update"): + temperature.array[:, 0, 0] = temp_values + velocity.array[:, 0, :] = vel_values # Synchronized execution across all arrays +# (uw.synchronised_array_update() is the public spelling of this context) ``` ## Custom Context Managers @@ -419,8 +411,11 @@ def test_callback_triggering(): ## Documentation Files - **Developer docs**: `underworld3/docs/developer/` -- **Format**: Quarto markdown (`.qmd`) -- **Naming**: Descriptive names with purpose (e.g., `UW3_Developers_NDArrays.qmd`) +- **Format**: MyST Markdown (`.md`), built with Sphinx — see the + "Documentation Requests" section of `CLAUDE.md` for admonition/math syntax +- **Naming**: Descriptive names with purpose (e.g., `UW3_Developers_NDArrays.md`) +- **Integration**: add new documents to the appropriate `toctree` in the parent + `index.md` and verify with `pixi run docs-build` ## Test Files @@ -456,7 +451,7 @@ def test_callback_triggering(): 1. **Reactive Properties**: Return NDArray_With_Callback with owner and callbacks 2. **Context Managers**: Use for state management and batch operations 3. **MPI Integration**: Always include barriers with error handling -4. **Documentation**: Markdown with mathematics for pdoc/jupyter compatibility +4. **Documentation**: NumPy/Sphinx docstrings with RST `:math:`; MyST `.md` docs 5. **Testing**: Comprehensive callback and functionality testing 6. **Error Handling**: Graceful degradation and logging 7. **Performance**: Provide enable/disable mechanisms for expensive operations @@ -467,7 +462,7 @@ def test_callback_triggering(): |---------|--------|---------|--------| | **Array Access** | `with mesh.access(var): var.data[...] = values` | `var.array[:, 0, 0] = values` | Direct access preferred | | **Multi-Variable** | `with mesh.access(var1, var2):` | `with uw.synchronised_array_update():` | Batch context | -| **Documentation** | Plain markdown | Quarto markdown | Enhanced features | +| **Documentation** | Plain markdown | MyST markdown (Sphinx) | Enhanced features | | **Testing** | Ad-hoc patterns | Structured fixtures | Comprehensive coverage | ## Quality Guidelines @@ -487,5 +482,5 @@ def test_callback_triggering(): ```{tip} Contributing This guide should be updated as new patterns emerge and existing patterns evolve. For questions or suggestions, please see the Contributing Guidelines or open an issue on the Underworld3 repository. -*Last updated: After NDArray migration and synchronised_array_update implementation* +*Last updated: 2026-07 (Wave E docs alignment — docstring/doc-format/coordinate sections brought in line with the UW3 Style Charter)* ``` \ No newline at end of file diff --git a/docs/developer/ai-notes/fault-teaching-examples-handoff-2026-08.md b/docs/developer/ai-notes/fault-teaching-examples-handoff-2026-08.md new file mode 100644 index 000000000..d7c810ff5 --- /dev/null +++ b/docs/developer/ai-notes/fault-teaching-examples-handoff-2026-08.md @@ -0,0 +1,224 @@ +# Fault-mechanics teaching examples: curation handoff + +Written 2026-08-05 for the session curating the teaching materials. +Everything described here is committed and pushed on +`origin/feature/fault-split-node` (through the commit adding this +file). Read this document first; every path below is repo-relative on +that branch. + +## The one critical dependency + +**These examples run ONLY on `feature/fault-split-node`.** They are +built on the split-node fault capability (`fault_split.py`, +`fault_contact.py`, the pair blocks in `rotated_bc.py`), which is not +on `development` yet. Curate the *placement and prose* freely, but the +examples cannot execute from any other branch, and they should not be +merged into user-facing docs ahead of the capability itself. The +figures, however, are committed PNGs/GIFs — prose pages that embed +them render fine anywhere. + +## Where everything is + +- **Page**: `docs/advanced/fault-mechanics-examples.md` (in the + `docs/advanced` toctree). Nine figures + three animations, each + with a short teaching narrative and a closing "what these models + are, and are not" caveat section. +- **Scripts, figures, caches**: `docs/advanced/figures/fault-examples/` + — one Python script per example, committed output PNG/GIF alongside, + probe caches as `_*.npz` (committed: they let anyone re-style every + figure and both animations without re-running ~100 solves). + `_*.png` frame intermediates and `*.log` files are gitignored. +- **Shared harness**: `figures/fault-examples/common.py` — every + example builds on it (see contracts below). +- **Companion user documentation**: `docs/advanced/split-node-faults.md` + (the API guide the examples assume) and the method/benchmark + write-up `docs/developer/design/SPLIT_NODE_FAULT_METHOD_2026-08.md` + (with its own figure set under + `docs/developer/design/figures/split-node-faults/` — cetz sources, + generators, and PNGs for the split anatomy, pair transform, mesh + stack, and grid hierarchy; those serve the method paper / blog + posts rather than the teaching page). + +## The example inventory (teaching order) + +1. **`ladder.py` → `ladder.png`** — *the fault-strength ladder.* One + fault, one shear drive, five solves down the constitutive ladder + (frictionless / viscous eta/a / Coulomb-weak / rate-state / + Coulomb-stuck), slip profiles against the elliptical crack shape, + plus a stacked column of per-rung shear-stress panels. Teaches: what + an interface law IS. ~5 solves. +2. **`mohr_circle.py` → `mohr-circle.png`** — welded faults as passive + stress probes trace the Mohr circle (fitted radius 1.411 vs analytic + sqrt(2)). Teaches: the probe instrument + the circle exists. +3. **`mohr_animate.py` → `mohr-circle-build.gif`** — the circle built + frame by frame as the fault rotates; the measured traction vector + snaps to the fault normal at the principal orientations, flagged on + both panels. Teaches: the 2-theta rule as motion. 25 solves, cached. +4. **`mohr_friction.py` → `mohr-friction.png` + `-build.gif`** — give + the rotating fault Coulomb friction: stuck probes reproduce the + circle, sliding probes pin to the envelope, tensile probes are + HELD SHUT (unphysical — the bilateral constraint glues an opening + fault; the tell is the sign of the normal traction). Teaches: the + yield envelope truncates the circle. +5. **`mohr_cohesion.py` → `mohr-cohesion.png` + `-build.gif`** — + cohesion: strength declines through mild tension to zero at + sigma = -C/mu; stuck arcs at both poles; the law is registered as a + four-line sympy expression (the extension path for any rheology). +6. **`mohr_graded.py` → `mohr-graded.png`** — hydrostatic load: each + welded fault becomes a depth-coloured STREAK spanning the family of + Mohr circles between its shallowest and deepest points. Teaches: + per-node stress recovery = depth-dependent strength along one fault. +7. **`orientations.py` → `orientations.png`** — frictionless faults at + swept orientations: peak slip follows |cos 2 theta| with an exact + zero at 45 degrees. Teaches: resolved stress controls slip. +8. **`interacting_faults.py` → `interacting-faults.png` + + `interacting-rotation.png`** — King-style stress transfer: the + source slips, the receiver is welded in the source's along-strike + tip lobe. Delta CFF as a field (linear RdBu_r at +-1, P0 cells) + AND as the receiver's probe cloud moving in the Mohr plane, with + the loaded near-tip nodes CROSSING the cohesive envelope into the + shaded failure zone while the far end retreats. The friction + dressing (P0 = 1 confining pressure, C = 0.75 cohesion) enters the + ENVELOPE only, never Delta CFF — both docs say so explicitly. The + rotation sweep's message: the regional stress orientation controls + the MARGIN to the envelope (grazing at phi = 20, crossing at 45, + parked safe at 70). 6 solves at h = 0.016, 13-18 s each. +9. **`california.py` → `california.png`** — schematic southern + California: the San Andreas as ONE continuous dextral trace with a + smooth tanh S-bend (the Big Bend — the smoothed stepover; left + step = restraining). The curved trace is sampled as a polyline and + carries the smooth curve's ANALYTIC NORMAL via + `add_fault_bc(..., normal=...)` — the capability that made curved + traces viable (see the rendering/roughness rules below). The + restraining bend fills with strong compression: a dCFF bowtie + exactly where the Transverse Ranges belong — the model puts the + mountains where the mountains are. Garlock (resolves sinistral + from the kinematics), three ECSZ strands and a San Jacinto + analogue welded as probes, all inboard. Verdicts: SJF relaxed + (-0.41), Garlock and ECSZ mildly loaded (+0.05/+0.06); slip + arrows from the MEASURED jump (+0.242 right-lateral — a + continuous trace slips more than the two offset segments did). + ~50 s of compute at h = 0.012. The capstone. + + RENDERING RULES the interaction fields obey: see the revised + field-rendering entry in the harness contracts below (P0 and P1 + are both legitimate now — choose by physics; never Delaunay a + split-mesh DOF cloud; RdBu_r +-1 for dCFF, Oranges for strain + rate). + +## The harness contracts (`common.py`) + +- `base_mesh(h)` + `mesh.add_fault([...])`: one static base, re-faulted + per case (the non-cumulative pattern). Networks = list form; + segments must not share vertices (ligament >= 2h). +- `stokes_on(child, drive)`: P2 velocity / P0 *discontinuous* pressure + (the fault pressure-space ruling), all-wall Dirichlet drive, + `petsc_use_pressure_nullspace = True`, `stokes.tolerance` (never raw + ksp_rtol). +- Drives: `simple_shear`, `shear_plus_stretch(a, gamma)` (Mohr radius + eta sqrt(4a^2+gamma^2)), `pure_shear_drive(phi)` (phi = COMPRESSION + axis — an earlier sign error made it the extension axis; fixed), + `boundary_simple_shear(trend)` (right-lateral along a plate-boundary + trend; dextral verified from the measured jump). +- **Read fault quantities through the DOF pairing only** + (`slip_vs_position`, `probe_nodes`, `fault_pair_jumps`): the two + sides are geometrically coincident — coordinate queries see one side. + `probe_nodes` selects the named fault's nodes via its own pairing + (several law-carrying faults share one assembler). +- **Pressure-gauge discipline** (closed velocity-driven boxes fix + pressure only up to a per-solve constant; observed drifts up to + ~300 stress units on large slip events): differenced fields are + anchored with `far_field_anchor` (a slip event changes nothing far + away), absolute probe pressures with `ambient_sigma_n` / + `ambient_sigma_n_simple` (analytic ambient states). Every removed + constant is printed, never silently absorbed. +- Field rendering (the standing rules, measured not guessed — REVISED + 2026-08-07): render on the split mesh's TRUE connectivity via + `split_mesh_cell_render`/`split_mesh_cell_rows` (P0 cells) or + `split_mesh_faces` (P1 nodes). Never Delaunay the DOF point cloud of + a split mesh (coincident fault pairs + trace-crossing edges paint + false beading). BOTH projections are now legitimate: the old "never + P1" rule was calibrated against the kinked-normal sawtooth fields we + later cured at the source, and the split mesh's P1 space is + naturally discontinuous across faults (doubled nodes), so the jump + is representable. Choose by physics: P1 nodal for smooth fields + (dCFF away from tips renders smoothly, no triangle facets); P0 cells + where the field carries genuine cell-scale structure (yield-zone + boundaries, material interfaces — P1 blurs them by a node spacing; + measured side by side in + ~/+Simulations/fault_junction_rheology/p1-vs-p0.png). Recover + COMPONENTS and form invariants in numpy, never project an invariant + directly. Colour: linear RdBu_r at +-1 for dCFF; one-sided Oranges + on white for strain rate (maintainer convention — no dark + backgrounds, keep the mesh edges visible). (`signed_log` remains in + the harness but the maintainer rejected it for these figures.) +- Curved faults carry their ANALYTIC NORMAL + (`add_fault_bc(..., normal=sympy 1×dim Matrix in mesh.X)`). The kink + roughness of a sampled curve was diagnosed (2026-08-05, + ~/+Simulations/curved_fault_roughness/): NOT the meshing, NOT the + integration — the default per-node normal AVERAGES the adjacent + facet normals and zig-zags at the sampling kinks, so the no-opening + constraint forbids smooth slip past each kink (sawteeth that GROW + under refinement). The analytic normal on the same kinked mesh cuts + the sawtooth 7-17x and restores h-convergence. Straight faults need + nothing (the average is exact there); a deliberately kinked fault + should NOT be given a smooth normal — the kink response is then the + physics. + +## Conventions (consistent across every figure) + +- Mohr planes in the GEOLOGICAL sign convention: compression positive, + tension on the negative axis (solver tractions are tension-positive; + only plots flip). +- The interaction figures dress friction with a declared confining + pressure P0 = 1 and cohesion C = 0.75 (envelope + tau = +-(C + mu' sigma)); **neither enters Delta CFF** (constants + under differencing) — they place the failure line where teaching + needs it, and the pages say so. +- mu' = 0.4 (King's value) for all Delta CFF. +- Fields render in pyvista (RdBu_r, white background, lighting off); + line/scatter plots in matplotlib. Method-paper figures (cetz) use + sans labels (Noto Sans -> Helvetica fallback) at small sizes. + +## Regeneration + +Everything runs inside the worktree env, with its bin on PATH (the JIT +needs mpicc): + + cd /docs/advanced/figures/fault-examples + PATH=/.pixi/envs/runtime/bin:$PATH \ + /.pixi/envs/runtime/bin/python -u