diff --git a/.github/workflows/build_uw3_and_test.yaml b/.github/workflows/build_uw3_and_test.yaml index dbcbaa107..3918c0f3e 100644 --- a/.github/workflows/build_uw3_and_test.yaml +++ b/.github/workflows/build_uw3_and_test.yaml @@ -1,7 +1,12 @@ name: test_uw3 -# We should trigger this from an upload event. Note that pdoc requires us to import the -# built code, so this is a building test as well as documentation deployment +# Build + test on every PR / push to main / development. +# +# Uses pixi (with the committed pixi.lock) for a deterministic, fast install +# rather than micromamba + environment.yaml, which had been backtracking for +# 60+ minutes on conda-forge solves and timing the runner out before tests +# could start. The pixi.lock matches what local development uses, so CI now +# runs against the same dependency state developers see. on: push: @@ -17,27 +22,27 @@ on: jobs: test: runs-on: ubuntu-latest + timeout-minutes: 60 + steps: - uses: actions/checkout@v4 - - name: Install Conda environment with Micromamba - uses: mamba-org/setup-micromamba@v2 + - name: Install pixi from lockfile + uses: prefix-dev/setup-pixi@v0.9.4 with: - environment-file: ./environment.yaml - cache-downloads: true - cache-environment: true + # Use the env that matches local development for tests. + # `dev` = conda-petsc + runtime + dev features (pytest, jupyter, etc.) + environments: dev + # Hard-fail if pixi.lock disagrees with pixi.toml — never re-solve + # silently in CI; that's exactly the failure mode we're escaping. + frozen: true + cache: true - name: Build UW3 - shell: bash -l {0} - run: | - export PETSC_DIR="/home/runner/micromamba/envs/uw3_test/lib" - VERSION=`python3 setup.py --version` - echo "UW - version " ${VERSION} - - ## TODO. Use scripts/compile.sh once it is in development - pip install -e . --no-build-isolation + # `pixi run -e dev build` invokes the build task from pixi.toml, + # which is `pip install . --no-build-isolation` (non-editable — + # editable installs are project policy violation, see CLAUDE.md). + run: pixi run -e dev build - name: Run tests - shell: bash -l {0} - run: | - ./scripts/test.sh --p 2 + run: pixi run -e dev ./scripts/test.sh --p 2 diff --git a/.gitignore b/.gitignore index 8c632ef74..7e9621773 100644 --- a/.gitignore +++ b/.gitignore @@ -255,4 +255,5 @@ docs/beginner/tutorials/html5/*.html .pixi-env # PETSc build (directory in main repo, symlink in worktrees) petsc-custom/petsc +petsc-custom/.petsc-version Untitled*.ipynb diff --git a/CHANGES.md b/CHANGES.md index d1a1f6bc5..ad9cbd366 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,75 @@ # CHANGES: Underworld3 +## 2026-05-20 + + - **Snapshot toolkit (PRs #195, #196, #198)**: unified + state-capture mechanism for Underworld3 models. + + User-facing API on `Model`: + + ```python + token = model.save_state() # in-memory "stash for timesteps" + model.load_state(token) # exact restore from token + + model.save_state(file="step42.snap.h5") # persistent on-disk snapshot + model.load_state("step42.snap.h5") # restore from disk + ``` + + Same call, two storage modes. Captures the full model — every + registered mesh and mesh-variable, every swarm with per-particle + data, every solver-internal state-bearer (`ModelTracker`, `DDt` + instances, anything exposing the `Snapshottable` contract). User + guide: `docs/advanced/snapshot-restore.md`. + + - **In-memory mode** (#195): bit-exact discard-of-a-step + guarantee, proven through real PETSc solves; parallel-correct + under MPI at any fixed rank count (recovers from genuine + cross-rank particle loss); rebuild-on-restore semantics for + swarms (the discarded step *is* what restore exists to undo). + - **`Model.tracker`** (#196): model-dwelling, snapshot-managed + record of where a run is. Holds `time` / `step` / `dt` plus + any quantity the user parks on it (`model.tracker.foo = ...`) + — anything on the tracker reverts with the model. Solvers do + not depend on it; using it is optional. A loose Python + variable is not reverted by `load_state`; the same value on + the tracker is. + - **On-disk mode (v1.1, #198)**: HDF5 wrapper file + companion + `.bulk/` directory. Wrapper is `h5ls`-inspectable without UW3 + in the loop (carries run name, schema version, sim time, step, + MPI rank count, mesh/swarm/variable inventories). Bulk data + uses PR #146's PETSc DMPlex primitives for mesh + meshvars; + swarms get per-rank h5py sidecars for parallel correctness. + Same-rank-count restart contract; clean errors on rank-count + mismatch. + + Related API changes: + + - `MeshVariable.read_timestep` is now format-aware: detects + whether the file is a legacy `write_timestep` per-variable + file or a v1.1 snapshot wrapper and dispatches internally. + Existing scripts that call `var.read_timestep(...)` work + transparently against new files via a KDTree bridge over + `MeshVariable.read_checkpoint` (#146). + - `mesh.write_timestep()` / `mesh.write_checkpoint()` (PR #146) + remain unchanged — they serve different use cases + (visualisation + flexible/cross-resolution restart; + memory-efficient same-rank PETSc reload for postprocessing). + See "Choosing between paths" in the user guide. + + State-as-dataclass contract for solver helpers: + `docs/developer/guides/state-as-dataclass.md` — declare + mutable evolution state as a `SnapshottableState` dataclass + and the snapshot mechanism captures/restores it with no extra + plumbing. Retrofitted for all five DDt flavors in this work + (`Symbolic`, `Eulerian`, `SemiLagrangian`, `Lagrangian`, + `Lagrangian_Swarm`). + + - **Fix(ddt): `Lagrangian.__init__` typo (`uw.swarm.UWSwarm` → + `uw.swarm.Swarm`)** (PR #184). Lagrangian DDt had been + unconstructible since commit `0778b7d` (2025-07-07) — typo + introduced during the unrelated `evalf` cleanup. Surfaced + during the snapshot toolkit's retrofit work. + ## 2026-03-14 - **Release v3.0.0**: Merged development (398 commits) to main, tagged v3.0.0 diff --git a/CLAUDE.md b/CLAUDE.md index 6119ee888..73b2ec4b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,21 +70,6 @@ If something needs more than an annotation or simple addition, mention it in con --- -## Pending Release Actions - -**⚠️ REMINDER: Tag v3.0.0 when `uw3-release-candidate` merges to `main`** - -The AI-friendly codebase with improved documentation, patterns, and tooling should be released as Underworld3 version 3.0.0. After merging the release candidate branch to main: - -```bash -git tag -a v3.0.0 -m "Underworld3 Release 3.0.0" -git push origin v3.0.0 -``` - -See `docs/developer/guides/version-management.md` for details. - ---- - ## Documentation Requests **⚠️ MANDATORY - READ BEFORE WRITING ANY DOCUMENTATION ⚠️** @@ -155,22 +140,46 @@ This keeps feature branches independent and makes cross-pollination of fixes str **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. -Worktrees share the main repo's pixi environment and PETSc build via symlinks — -there is one set of dependencies, not one per worktree. `./uw build` from inside -a worktree installs that worktree's source into the shared environment. +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. **Full documentation**: `docs/developer/guides/branching-strategy.md` (Git Worktrees section) +#### 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 — resets to development, sets up symlinks, names the branch +# 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 the shared env +./uw build # builds from THIS source into THIS worktree's env ./uw test # runs tests exit # leave @@ -190,9 +199,8 @@ git checkout origin/ -- path/to/file #### Important: always build and run from inside the worktree -Because there is one shared environment, `./uw build` installs whichever source -tree you run it from. If you build from the main repo then run code expecting -worktree changes, the worktree edits will not be active. Always: +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` @@ -226,13 +234,40 @@ Underworld development team with AI support from [Claude Code](https://claude.co ### 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/default/lib/python3.12/site-packages/underworld3/` -- Verify with `uw.model.__file__` +- 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 +``` + ### Test Quality Principles **New tests must be validated before making code changes to fix them!** - Validate test correctness before changing main code @@ -244,84 +279,23 @@ the build directory: `rm -rf build/lib.* build/bdist.*` then rebuild. --- -## Design Documents Reference - -**Location**: `docs/developer/design/` - -| Document | Status | Purpose | -|----------|--------|---------| -| `UNITS_SIMPLIFIED_DESIGN_2025-11.md` | **AUTHORITATIVE** | Current units architecture | -| `PARALLEL_PRINT_SIMPLIFIED.md` | Implemented | `uw.pprint()` and `selective_ranks()` | -| `RANK_SELECTION_SPECIFICATION.md` | Implemented | Rank selection syntax | -| `mathematical_objects_plan.md` | Implemented | Mathematical objects design | - ---- - ## Units System Principles -### String Input, Pint Object Storage -**Accept strings for convenience, store/return Pint objects internally.** - -```python -# User creates with string (convenience) -viscosity = uw.quantity(1e21, "Pa*s") +**Authoritative design doc**: `docs/developer/design/UNITS_SIMPLIFIED_DESIGN_2025-11.md` -# Internally stored as Pint object -# .units returns Pint Unit object (NOT string!) -viscosity.units # - -# Arithmetic works correctly -Ra = (rho0 * alpha * g * DeltaT * L**3) / (eta0 * kappa) -``` - -### Unit vs Quantity Distinction -```python -# Pint Quantity = value + units (can convert) -qty = uw.quantity(2900, "km") -qty.to("m") # Returns new UWQuantity -qty.to_base_units() # Returns new UWQuantity - -# Pint Unit = just the unit (cannot convert!) -qty.units # -qty.units.to("m") # AttributeError! Use qty.to("m") instead -``` - -### Transparent Container Principle -**UWexpression is a container that derives properties from its contents.** -- Atomic (UWQuantity): `.units` comes from stored value -- Composite (SymPy tree): `.units` derived via `get_units(self._sym)` -- No cached state on composites - eliminates sync issues +- 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)`) --- ## Parallel Computing Patterns -### Key Understanding -**Underworld3 rarely uses MPI directly - PETSc handles all parallel synchronization.** - -- PETSc manages parallelism for mesh operations, solvers, vector updates -- UW3 API wraps PETSc collective operations correctly -- Avoid direct mpi4py usage unless absolutely necessary - -### Current Parallel Safety API - -```python -# OLD (deprecated) - DANGEROUS if stats() is collective -if uw.mpi.rank == 0: - print(f"Stats: {var.stats()}") - -# NEW (safe) - All ranks execute, only selected ranks print -uw.pprint(0, f"Stats: {var.stats()}") - -# For code blocks (visualization, etc.) -with uw.selective_ranks(0) as should_execute: - if should_execute: - import pyvista as pv - plotter = pv.Plotter() -``` +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.qmd` +**Documentation**: `docs/advanced/parallel-computing.md` --- @@ -357,30 +331,7 @@ The PETSc-based solvers are carefully optimized and validated. **NO CHANGES with | `with swarm.access(var):` | **Deprecated** | Direct: `var.data[...]` | | `mesh.data` (coordinates) | **Deprecated** | `mesh.X.coords` | -### Current Patterns -```python -# Single variable - direct access -var.data[...] = values -var.array[:, 0, 0] = scalar_values # Scalar -var.array[:, 0, :] = vector_values # Vector - -# Multiple variables - batch synchronization -with uw.synchronised_array_update(): - var1.data[...] = values1 - var2.data[...] = values2 - -# Coordinates -mesh.X.coords # Mesh vertex coordinates -var.coords # Variable DOF coordinates -swarm.data # Swarm particle positions -``` - -### Array Shapes -- **array**: `(N, a, b)` where scalar=`(N,1,1)`, vector=`(N,1,dim)`, tensor=`(N,dim,dim)` -- **data**: `(-1, num_components)` flat format for backward compatibility - -### Data Cache Safety -The `.data` property caches an `NDArray_With_Callback` view into the PETSc local vector. This cache self-validates via `id(self._lvec)` tracking — if the underlying vector is replaced (DM rebuild, mesh adaptation), the cache auto-rebuilds on next access. See `docs/developer/subsystems/data-access.md` for details. +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. --- @@ -398,8 +349,8 @@ if any_uwexpressions_in_expression: symbols = expr.atoms(...) ``` -**Safe locations**: JIT Compiler (`_jitextension.py`), `extract_expressions()` -**Check if issues**: `is_pure_sympy_expression()`, `nondimensional.py` +**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` --- @@ -441,12 +392,7 @@ velocity.norm() # Magnitude ## Coding Conventions ### Prefer Glob and Grep Over find -**Use the Glob and Grep tools instead of `find` in Bash.** -- `Glob` handles file pattern matching (e.g., `**/*.py`, `src/**/*.pyx`) -- `Grep` handles content search (e.g., searching for class definitions, imports) -- Both are faster, safer, and give the user better visibility than shell `find` -- `find` with `-exec`, `-execdir`, or `-delete` can execute arbitrary commands — avoid it -- Only fall back to `find` via Bash if Glob/Grep genuinely cannot express the query +**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 diff --git a/README.md b/README.md index bf7be33f5..8a9a5a563 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,11 @@ +[![Latest release](https://img.shields.io/github/v/release/underworldcode/underworld3?label=release)](https://github.com/underworldcode/underworld3/releases/latest) +[![License: LGPL-3.0](https://img.shields.io/github/license/underworldcode/underworld3)](https://www.gnu.org/licenses/lgpl-3.0) +[![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/) +[![Documentation](https://img.shields.io/readthedocs/underworld3)](https://underworld3.readthedocs.io/en/latest/) + Welcome to `Underworld3`, a mathematically self-describing, finite-element code for geodynamic modelling. This quick-start guide has basic installation instructions and a brief introduction to some of the concepts in the `Underworld3` code. All `Underworld3` source code is released under the LGPL-3 open source licence. This covers all files in `underworld3` constituting the Underworld3 Python module. Notebooks, stand-alone documentation and Python scripts which show how the code is used and run are licensed under the Creative Commons Attribution 4.0 International License. diff --git a/docs/advanced/benchmarks/_bench_helpers.py b/docs/advanced/benchmarks/_bench_helpers.py new file mode 100644 index 000000000..9c6dbe505 --- /dev/null +++ b/docs/advanced/benchmarks/_bench_helpers.py @@ -0,0 +1,306 @@ +"""Shared helpers for the VE/VEP analytical benchmark suite. + +Three benchmark cases share this module: + +* ``bench_ve_harmonic.py`` — Maxwell shear under :math:`V_{top}(t) = V_0 \\sin(\\omega t)` +* ``bench_ve_square.py`` — Maxwell shear under square-wave :math:`V_{top}` +* ``bench_vep_square.py`` — same square-wave forcing with Min-mode plasticity + +Common setup +------------ +* Mesh: ``StructuredQuadBox`` 16×8 over ``(±1, ±0.5)``. +* Velocity at top/bottom: ``±V_top(t)``, free at left/right. +* Pure shear with strain rate ``γ̇ = 2·V_top/H = V_top``. +* Centre-point stress sample. +* Scaling: ``η = μ = 1``, so Maxwell relaxation time ``t_r = 1`` and the + steady-state VE stress under sustained shear is ``η·γ̇``. + +Logging +------- +Each run writes a self-contained ``.npz`` to ``output/benchmarks/.npz`` +holding the simulation trace, the analytical reference, the parameter +dict, and metadata. Plotting is decoupled — see ``plot_benchmarks.py``. +""" + +import os +import time +import numpy as np +import sympy +import underworld3 as uw +from underworld3.function import expression + + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_REPO_ROOT = os.path.abspath(os.path.join(_HERE, "..", "..", "..")) +OUTPUT_DIR = os.path.join(_REPO_ROOT, "output", "benchmarks") +FIG_DIR = os.path.join(_REPO_ROOT, "docs", "advanced", "figures") + + +# --------------------------------------------------------------------------- +# Common parameters +# --------------------------------------------------------------------------- + +DEFAULT_PARAMS = dict( + eta=1.0, # shear viscosity + mu=1.0, # shear modulus + H=1.0, # box height (top–bottom) + W=2.0, # box width (left–right) + elementRes=(16, 8), + velocity_degree=2, + pressure_degree=1, + bdf_order=2, +) + + +def t_relax(params): + return params["eta"] / params["mu"] + + +# --------------------------------------------------------------------------- +# Analytical solutions +# --------------------------------------------------------------------------- + +def maxwell_oscillatory(t, eta, mu, gamma_dot_0, omega): + r"""Closed-form Maxwell shear stress under sinusoidal forcing + :math:`\dot\gamma(t) = \dot\gamma_0 \sin(\omega t)`. + + Solving :math:`\dot\sigma + \sigma/t_r = \mu\dot\gamma` with + :math:`\sigma(0) = 0` gives + + .. math:: + \sigma(t) = \frac{\eta\dot\gamma_0}{1+\mathrm{De}^2} + \left[\sin(\omega t) - \mathrm{De}\cos(\omega t) + \mathrm{De}\,e^{-t/t_r}\right] + + where :math:`\mathrm{De} = \omega t_r` is the Deborah number. After + transient decay (:math:`t \gg t_r`) the steady response has amplitude + :math:`\eta\dot\gamma_0/\sqrt{1+\mathrm{De}^2}` and phase lag + :math:`\varphi = \arctan(\mathrm{De})`. + """ + t_r = eta / mu + De = omega * t_r + pre = eta * gamma_dot_0 / (1.0 + De**2) + return pre * (np.sin(omega * t) - De * np.cos(omega * t) + De * np.exp(-t / t_r)) + + +def maxwell_square_wave(t, eta, mu, gamma_dot_0, half_period): + r"""Closed-form Maxwell shear stress under square-wave forcing. + + Within each half-period the stress relaxes exponentially toward the + steady-state value :math:`\pm\eta\dot\gamma_0` from the value at the + period boundary: + + .. math:: + \sigma(t) = s_n\sigma_{\mathrm{ss}} + (\sigma_{0,n} - s_n\sigma_{\mathrm{ss}})\, + e^{-(t - t_n)/t_r} + + where :math:`s_n = (-1)^n` is the sign in half-period :math:`n` and + :math:`\sigma_{0,n}` is the stress at the start of that half-period. + """ + t_r = eta / mu + sigma_ss = eta * gamma_dot_0 + out = np.zeros_like(np.asarray(t, dtype=float)) + sigma_start = 0.0 + for i, ti in enumerate(np.asarray(t, dtype=float)): + n = int(ti / half_period) + t_local = ti - n * half_period + # Replay periods 0..n-1 to find sigma at start of period n + sigma_n = 0.0 + for j in range(n): + sign = 1.0 if j % 2 == 0 else -1.0 + target = sign * sigma_ss + sigma_n = target + (sigma_n - target) * np.exp(-half_period / t_r) + sign = 1.0 if n % 2 == 0 else -1.0 + target = sign * sigma_ss + out[i] = target + (sigma_n - target) * np.exp(-t_local / t_r) + return out + + +def vep_square_wave(t, eta, mu, gamma_dot_0, tau_y, half_period): + r"""Closed-form VEP shear stress under square-wave forcing with + Min-mode plasticity. + + Within each half-period, the stress evolves under Maxwell: + + .. math:: + \sigma(t) = s_n\sigma_{\mathrm{ss}} + (\sigma_{0,n} - s_n\sigma_{\mathrm{ss}})\, + e^{-(t - t_n)/t_r} + + until :math:`|\sigma| = \tau_y`, after which the plastic flow holds + :math:`\sigma = \pm\tau_y`. The next half-period starts from the + *clipped* value (``±τ_y`` if the previous period yielded; otherwise + the unclipped end value). + + When :math:`\eta\dot\gamma_0 \le \tau_y` the solution coincides with + the unclipped Maxwell square-wave. + """ + t_arr = np.asarray(t, dtype=float) + t_r = eta / mu + sigma_ss = eta * gamma_dot_0 + out = np.zeros_like(t_arr) + + # Pre-compute σ at the start of each half-period (including clipping) + n_half_max = int(np.ceil(t_arr[-1] / half_period)) + 2 + sigma_at_start = [0.0] + for n in range(n_half_max): + sign = 1.0 if n % 2 == 0 else -1.0 + target = sign * sigma_ss + sigma_0 = sigma_at_start[-1] + sigma_end = target + (sigma_0 - target) * np.exp(-half_period / t_r) + # Clip to ±τ_y at the period boundary if the unclipped value would + # have exceeded the yield surface + sigma_end_clipped = np.clip(sigma_end, -tau_y, tau_y) + sigma_at_start.append(float(sigma_end_clipped)) + + # Evaluate at each requested t + for i, ti in enumerate(t_arr): + n = int(ti / half_period) + t_local = ti - n * half_period + sign = 1.0 if n % 2 == 0 else -1.0 + target = sign * sigma_ss + sigma_0 = sigma_at_start[n] + sigma_unclipped = target + (sigma_0 - target) * np.exp(-t_local / t_r) + out[i] = np.clip(sigma_unclipped, -tau_y, tau_y) + return out + + +# --------------------------------------------------------------------------- +# Stokes problem builder +# --------------------------------------------------------------------------- + +def build_stokes(label, params, yield_stress=None, yield_mode="min"): + """Construct a VE_Stokes problem with the standard mesh / BCs. + + Parameters + ---------- + label : str + Used to namespace the mesh variable names so multiple problems can + coexist in one Python session. + params : dict + Material parameters (see DEFAULT_PARAMS). + yield_stress : float or None + If ``None``, pure VE (yield_stress is set to a large finite value). + Otherwise enables VEP with the given yield stress. + yield_mode : str + Passed to ``constitutive_model._yield_mode``. ``"min"`` for + Min-mode plasticity (sharp yield), other options are smooth + approximations. + + Returns + ------- + mesh, stokes, V_top, params + ``V_top`` is the user-facing UWexpression for the top BC velocity. + """ + p = dict(params) + mesh = uw.meshing.StructuredQuadBox( + elementRes=p["elementRes"], + minCoords=(-p["W"] / 2.0, -p["H"] / 2.0), + maxCoords=(p["W"] / 2.0, p["H"] / 2.0), + ) + v = uw.discretisation.MeshVariable(f"U_{label}", mesh, mesh.dim, degree=p["velocity_degree"]) + pp = uw.discretisation.MeshVariable(f"P_{label}", mesh, 1, degree=p["pressure_degree"]) + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=pp) + stokes.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + stokes.Unknowns, order=p["bdf_order"], + ) + stokes.constitutive_model.Parameters.shear_viscosity_0 = p["eta"] + stokes.constitutive_model.Parameters.shear_modulus = p["mu"] + stokes.constitutive_model.Parameters.yield_stress = ( + yield_stress if yield_stress is not None else 1.0e6 + ) + stokes.constitutive_model.Parameters.strainrate_inv_II_min = 1.0e-6 + stokes.constitutive_model._yield_mode = yield_mode + + V_top = expression(rf"V_{{top,{label}}}", sympy.Float(0.0), "Top V") + stokes.add_dirichlet_bc((V_top, 0.0), "Top") + stokes.add_dirichlet_bc((-V_top, 0.0), "Bottom") + stokes.add_dirichlet_bc((sympy.oo, 0.0), "Left") + stokes.add_dirichlet_bc((sympy.oo, 0.0), "Right") + stokes.tolerance = 1.0e-6 + stokes.petsc_options["snes_force_iteration"] = True + + return mesh, stokes, V_top, p + + +# --------------------------------------------------------------------------- +# Per-step probe +# --------------------------------------------------------------------------- + +def probe_centre(stokes, c=np.array([[0.0, 0.0]])): + return float(uw.function.evaluate(stokes.tau.sym[0, 1], c).flatten()[0]) + + +# --------------------------------------------------------------------------- +# Self-contained npz logger +# --------------------------------------------------------------------------- + +def save_run(name, *, params, params_extra=None, **arrays): + """Save a benchmark run to ``output/benchmarks/.npz``. + + Parameters + ---------- + name : str + Output filename stem (no extension). + params : dict + Material/numerical parameters used for the run. Stored as a + single ``params`` field for re-creation/replotting. + params_extra : dict or None + Per-benchmark scalar metadata (omega, half_period, tau_y, …). + **arrays + Per-step arrays: times, sigma, sigma_ana, dt, gamma_dot, etc. + """ + os.makedirs(OUTPUT_DIR, exist_ok=True) + path = f"{OUTPUT_DIR}/{name}.npz" + payload = {f"arr_{k}": np.asarray(v) for k, v in arrays.items()} + payload["__params__"] = np.asarray(repr(dict(params)), dtype=object) + payload["__params_extra__"] = np.asarray(repr(dict(params_extra or {})), dtype=object) + payload["__keys__"] = np.asarray(list(arrays.keys()), dtype=object) + payload["__name__"] = np.asarray(name, dtype=object) + np.savez(path, **payload) + return path + + +def load_run(name): + """Reverse of :func:`save_run`. Returns ``(arrays, params, extra)``.""" + path = f"{OUTPUT_DIR}/{name}.npz" + with np.load(path, allow_pickle=True) as f: + keys = list(f["__keys__"]) + arrays = {k: f[f"arr_{k}"] for k in keys} + params = eval(str(f["__params__"])) + extra = eval(str(f["__params_extra__"])) + return arrays, params, extra + + +# --------------------------------------------------------------------------- +# Error metrics +# --------------------------------------------------------------------------- + +def error_metrics(sigma, sigma_ana): + """Standard error report: max and rms absolute error.""" + diff = sigma - sigma_ana + return dict( + max_abs=float(np.max(np.abs(diff))), + rms=float(np.sqrt(np.mean(diff**2))), + rel_max=float(np.max(np.abs(diff)) / (np.max(np.abs(sigma_ana)) + 1e-30)), + ) + + +def fit_amp_phase(t, sigma, omega): + """Least-squares fit of ``A·sin(ωt − φ)`` to ``sigma``. + + Returns ``(A, phi)``. Drops the first ``2*t_r`` to skip the + transient (assumes ``t_r = 1`` and that the array is long enough). + """ + mask = t > 4.0 # skip ~4 t_r of transient + if mask.sum() < 8: + mask = np.ones_like(t, dtype=bool) + ts = t[mask] + ss = sigma[mask] + # σ ≈ a·sin(ωt) + b·cos(ωt) — fit (a, b) by linear least squares + M = np.column_stack([np.sin(omega * ts), np.cos(omega * ts)]) + coeffs, *_ = np.linalg.lstsq(M, ss, rcond=None) + a, b = float(coeffs[0]), float(coeffs[1]) + A = np.sqrt(a**2 + b**2) + # σ = A·sin(ωt − φ) → A·(cos(φ)sin(ωt) − sin(φ)cos(ωt)) = a·sin + b·cos + # so a = A cos(φ), b = −A sin(φ). Hence φ = atan2(−b, a). + phi = float(np.arctan2(-b, a)) + return A, phi diff --git a/docs/advanced/benchmarks/_iso_pureve_vs_vep.py b/docs/advanced/benchmarks/_iso_pureve_vs_vep.py new file mode 100644 index 000000000..0933ace26 --- /dev/null +++ b/docs/advanced/benchmarks/_iso_pureve_vs_vep.py @@ -0,0 +1,101 @@ +"""Pin down whether iso BDF-2 instability is from VEP machinery or VE alone. + +Three iso cases at the same harmonic forcing, T=8, BDF-2, η=μ=1, on +the same mesh as the TI consistency test: + + pureve — VE only (yield_stress = sympy.oo, no plastic branch) + vep_huge_ty — VEP with yield_stress = 1e8 (yielding effectively off) + vep_active_ty — VEP with yield_stress = 0.30 (yielding active) + +If pureve is bounded but vep_huge_ty blows up, the BDF-2 instability +is in the VEP softmin/yield expression, not in the BDF-2 method. +""" + +import time +import numpy as np +import sympy +import underworld3 as uw +from underworld3.function import expression + + +V0 = 0.5; OMEGA = np.pi / 2.0; DT = 0.05; T_END = 8.0 +ETA = 1.0; MU = 1.0; RES = 16 + + +def build(label, *, yield_stress): + mesh = uw.meshing.StructuredQuadBox( + elementRes=(RES, RES), minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), + qdegree=3, + ) + v = uw.discretisation.MeshVariable(f"U_{label}", mesh, 2, degree=2, + vtype=uw.VarType.VECTOR) + p = uw.discretisation.MeshVariable(f"P_{label}", mesh, 1, degree=1, + continuous=True, vtype=uw.VarType.SCALAR) + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + cm = uw.constitutive_models.ViscoElasticPlasticFlowModel( + stokes.Unknowns, order=2, + ) + cm.Parameters.shear_viscosity_0 = ETA + cm.Parameters.shear_modulus = MU + cm.Parameters.yield_stress = yield_stress + cm.Parameters.shear_viscosity_min = ETA * 1.0e-3 + cm.Parameters.strainrate_inv_II_min = 1.0e-6 + cm.yield_mode = "softmin" + stokes.constitutive_model = cm + stokes.saddle_preconditioner = 1.0 / cm.K + stokes.tolerance = 1.0e-6 + stokes.petsc_options["ksp_type"] = "fgmres" + stokes.petsc_options["snes_force_iteration"] = True + + V_top = expression(rf"V_{{top,{label}}}", sympy.Float(0.0), "") + stokes.add_essential_bc(sympy.Matrix([V_top, 0.0]), "Top") + stokes.add_essential_bc(sympy.Matrix([0.0, 0.0]), "Bottom") + stokes.add_essential_bc((sympy.oo, 0.0), "Left") + stokes.add_essential_bc((sympy.oo, 0.0), "Right") + return stokes, V_top + + +def run(label, *, yield_stress): + stokes, V_top = build(label, yield_stress=yield_stress) + phi = float(np.arctan(OMEGA)) + n_steps = int(T_END / DT) + sxy = [] + div = 0; iters_total = 0 + t0 = time.time() + for step in range(n_steps): + t = (step + 1) * DT + v_now = V0 * float(np.cos(OMEGA * t + phi)) + V_top.sym = sympy.Float(v_now) + stokes.constitutive_model.Parameters.dt_elastic = DT + stokes.solve(zero_init_guess=False, timestep=DT, divergence_retries=2) + if stokes.snes.getConvergedReason() < 0: + div += 1 + iters_total += stokes.snes.getIterationNumber() + c = np.array([[0.5, 0.5]]) + td = stokes.tau.data + idx = int(np.argmin(np.linalg.norm(stokes.tau.coords - c, axis=1))) + sxy.append(td[idx, 2]) + wall = time.time() - t0 + return dict(label=label, yield_stress=str(yield_stress), wall=wall, + peak=float(np.abs(np.array(sxy)).max()), + div=div, mean_its=iters_total / max(1, n_steps)) + + +def main(): + cases = [ + ("pureve", sympy.oo), + ("vep_huge_ty", 1e8), + ("vep_active_ty", 0.30), + ] + print(f"\n{'label':<14} {'yield_stress':>13} {'wall':>6} {'div':>4} {'its':>5} {'peak|σ_xy|':>11}", + flush=True) + for label, ty in cases: + print(f"--- running {label} ---", flush=True) + r = run(label, yield_stress=ty) + print(f"{r['label']:<14} {r['yield_stress']:>13} {r['wall']:>6.1f} " + f"{r['div']:>4d} {r['mean_its']:>5.2f} {r['peak']:>11.4e}", + flush=True) + + +if __name__ == "__main__": + main() diff --git a/docs/advanced/benchmarks/_iter_count_min_bdf2.py b/docs/advanced/benchmarks/_iter_count_min_bdf2.py new file mode 100644 index 000000000..599be1c71 --- /dev/null +++ b/docs/advanced/benchmarks/_iter_count_min_bdf2.py @@ -0,0 +1,91 @@ +"""Quick rerun: pure Min/Min BDF-2 with iter counts captured. + +Confirms (or refutes) the hypothesis that the cleaner SNES record of +the Min/Min BDF-2 run is masking very few actual Newton iterations, +which would explain its larger answer error vs the softJac variants. +""" + +import time +import numpy as np +import sympy +from _bench_helpers import ( + DEFAULT_PARAMS, build_stokes, probe_centre, + vep_square_wave, error_metrics, OUTPUT_DIR, +) + + +V0 = 0.5 +TAU_Y = 0.5 +HALF_PERIOD = 2.0 +N_PERIODS = 4 +T_END = N_PERIODS * 2.0 * HALF_PERIOD +DT_PLATEAU = 0.10 +DT_FINE = 0.01 +WINDOW = 0.1 * HALF_PERIOD + + +def schedule_dt(t_cur): + flip_times = [HALF_PERIOD * (k + 1) for k in range(N_PERIODS * 2 - 1)] + for f in flip_times: + if abs(t_cur - f) <= WINDOW: + return DT_FINE + return DT_PLATEAU + + +def main(): + params = dict(DEFAULT_PARAMS) + params["bdf_order"] = 2 + mesh, stokes, V_top, params = build_stokes( + "iter_min_o2", params, yield_stress=TAU_Y, yield_mode="min", + ) + times, dts, sigmas, gammas, reasons, iters = [], [], [], [], [], [] + t_cur = 0.0 + t0 = time.time() + while t_cur < T_END - 1e-9: + dt = schedule_dt(t_cur) + flip_next = next((HALF_PERIOD * (k + 1) for k in range(N_PERIODS * 2) + if HALF_PERIOD * (k + 1) > t_cur + 1e-9), T_END) + dt = min(dt, flip_next - t_cur, T_END - t_cur) + t_end_step = t_cur + dt + n_half = int(t_end_step / HALF_PERIOD - 1e-9) + sign = 1.0 if n_half % 2 == 0 else -1.0 + v_now = sign * V0 + V_top.sym = sympy.Float(v_now) + stokes.constitutive_model.Parameters.dt_elastic = dt + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=2) + sigmas.append(probe_centre(stokes)) + t_cur = t_end_step + times.append(t_cur); dts.append(dt); gammas.append(2.0 * v_now / params["H"]) + reasons.append(int(stokes.snes.getConvergedReason())) + iters.append(int(stokes.snes.getIterationNumber())) + wall = time.time() - t0 + + times = np.array(times); sigmas = np.array(sigmas) + reasons = np.array(reasons); iters = np.array(iters) + gamma_dot_0 = 2.0 * V0 / params["H"] + sigma_ana = vep_square_wave(times, params["eta"], params["mu"], + gamma_dot_0, TAU_Y, HALF_PERIOD) + err = error_metrics(sigmas, sigma_ana) + + from collections import Counter + print(f"\nMin/Min BDF-2 (var-dt VEP square): wall={wall:.1f}s steps={len(times)}", + flush=True) + print(f" reasons: {dict(sorted(Counter(reasons.tolist()).items()))}", flush=True) + print(f" iter dist: {dict(sorted(Counter(iters.tolist()).items()))}", flush=True) + print(f" iter mean: {iters.mean():.2f} median: {np.median(iters):.0f} " + f"max: {int(iters.max())}", flush=True) + print(f" fraction with iters==0: {(iters == 0).sum()}/{len(iters)} = {(iters==0).mean():.1%}", + flush=True) + print(f" fraction with iters==1: {(iters == 1).sum()}/{len(iters)} = {(iters==1).mean():.1%}", + flush=True) + print(f" peak|σ|={float(np.abs(sigmas).max()):.4f} " + f"max|err|={err['max_abs']:.3e} rms={err['rms']:.3e}", flush=True) + + import os + np.savez(os.path.join(OUTPUT_DIR, "iter_count_min_bdf2.npz"), + times=times, dts=dts, sigmas=sigmas, sigma_ana=sigma_ana, + reasons=reasons, iters=iters, wall=wall) + + +if __name__ == "__main__": + main() diff --git a/docs/advanced/benchmarks/_repro_min_bdf1_nan.py b/docs/advanced/benchmarks/_repro_min_bdf1_nan.py new file mode 100644 index 000000000..e1f57f5da --- /dev/null +++ b/docs/advanced/benchmarks/_repro_min_bdf1_nan.py @@ -0,0 +1,74 @@ +"""Reproduce the Min-BDF-1 NaN-on-plateau divergence with SNES monitoring.""" +import numpy as np +import sympy +from _bench_helpers import DEFAULT_PARAMS, build_stokes, probe_centre +V0 = 0.5 +TAU_Y = 0.5 +HALF_PERIOD = 2.0 +N_PERIODS = 4 +T_END = N_PERIODS * 2.0 * HALF_PERIOD +DT_PLATEAU = 0.10 +DT_FINE = 0.01 +WINDOW = 0.2 + +def schedule_dt(t_cur): + flip_times = [HALF_PERIOD * (k + 1) for k in range(N_PERIODS * 2 - 1)] + for f in flip_times: + if abs(t_cur - f) <= WINDOW: + return DT_FINE + return DT_PLATEAU + +params = dict(DEFAULT_PARAMS) +params["bdf_order"] = 1 +mesh, stokes, V_top, params = build_stokes( + "minfail_o1", params, yield_stress=TAU_Y, yield_mode="min", +) + +# enable SNES monitor — prints |F| at every SNES iteration +stokes.petsc_options["snes_monitor"] = None +stokes.petsc_options["snes_converged_reason"] = None + +t_cur = 0.0 +step_idx = 0 +target_steps = {348, 404, 405, 406} +# also peek at adjacent steps for context +context_steps = target_steps | {347, 403} + +while t_cur < T_END - 1e-9: + dt = schedule_dt(t_cur) + flip_next = next((HALF_PERIOD * (k + 1) for k in range(N_PERIODS * 2) + if HALF_PERIOD * (k + 1) > t_cur + 1e-9), T_END) + dt = min(dt, flip_next - t_cur, T_END - t_cur) + t_end_step = t_cur + dt + n_half = int(t_end_step / HALF_PERIOD - 1e-9) + sign = 1.0 if n_half % 2 == 0 else -1.0 + v_now = sign * V0 + V_top.sym = sympy.Float(v_now) + stokes.constitutive_model.Parameters.dt_elastic = dt + + # Only enable verbose monitoring at the steps we care about + verbose = (step_idx in context_steps) + if verbose: + print(f"\n===== step {step_idx} t={t_end_step:.3f} dt={dt:.4f} sign={sign:+.0f} =====", flush=True) + + if not verbose: + # silence the monitors temporarily + stokes.petsc_options.delValue("snes_monitor") + stokes.petsc_options.delValue("snes_converged_reason") + else: + stokes.petsc_options["snes_monitor"] = None + stokes.petsc_options["snes_converged_reason"] = None + + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=2) + + if verbose: + sigma = probe_centre(stokes) + reason = int(stokes.snes.getConvergedReason()) + its = int(stokes.snes.getIterationNumber()) + print(f" → sigma_xy={sigma:.6f} SNES reason={reason} iters={its}", flush=True) + t_cur = t_end_step + step_idx += 1 + if step_idx > max(target_steps) + 2: + break + +print("\n--- done ---", flush=True) diff --git a/docs/advanced/benchmarks/_ti_vep_alpha_sweep.py b/docs/advanced/benchmarks/_ti_vep_alpha_sweep.py new file mode 100644 index 000000000..50b9a63f3 --- /dev/null +++ b/docs/advanced/benchmarks/_ti_vep_alpha_sweep.py @@ -0,0 +1,73 @@ +"""Find the bdf_blend α threshold for TI-VEP + spatial τ_y stability. + +Known so far at T=16, harmonic forcing, BDF-2: + α = 1.0 → peak|σ_xy| ≈ 7-10 (blows up modestly) + α = 0.5 → peak|σ_xy| ≈ 7-30000 (blow-up reduced but still) + α = 0.0 → peak|σ_xy| ≈ 0.30 (BDF-1) (stable) + +What's the smallest α that still blows up? Sweep at θ=15° (the worst +case in earlier tests) and at θ=0° (where 1D-y blow-up is also seen). +Use the same setup as bench_ti_vep_harmonic_zeroIC at τ_y=0.30. +""" + +import time +import numpy as np +import sympy +from bench_ti_vep_harmonic import build_ti_stokes, probe_stress, V0, OMEGA, DT + + +T_END = 16.0 +TAU_Y = 0.30 + + +def run(theta_deg, alpha, label): + stokes, V_top, n_vec = (None, None, None) + mesh, stokes, V_top, n_vec = build_ti_stokes(label, theta_deg, TAU_Y, bdf_order=2) + stokes.constitutive_model._bdf_blend = alpha + + phi = float(np.arctan(OMEGA)) + n_steps = int(T_END / DT) + sxy = []; tres = [] + div = 0; iters_total = 0 + t0 = time.time() + for step in range(n_steps): + t = (step + 1) * DT + v_now = V0 * float(np.cos(OMEGA * t + phi)) + V_top.sym = sympy.Float(v_now) + stokes.constitutive_model.Parameters.dt_elastic = DT + stokes.solve(zero_init_guess=False, timestep=DT, divergence_retries=2) + if stokes.snes.getConvergedReason() < 0: + div += 1 + iters_total += stokes.snes.getIterationNumber() + sxy_v, tres_v = probe_stress(stokes, n_vec) + sxy.append(sxy_v); tres.append(tres_v) + wall = time.time() - t0 + sxy = np.array(sxy); tres = np.array(tres) + return dict(label=label, alpha=alpha, theta=theta_deg, wall=wall, + peak_sxy=float(np.abs(sxy).max()), + peak_tres=float(np.abs(tres).max()), + div=div, mean_its=iters_total / max(1, len(sxy))) + + +def main(): + cases = [] + # θ=0° — easier; α≥0.5 already blows up modestly + for alpha in (0.0, 0.25, 0.5, 0.75, 1.0): + cases.append((0.0, alpha)) + # θ=15° — harder; α≥0.5 still blows up massively + for alpha in (0.0, 0.10, 0.25, 0.50): + cases.append((15.0, alpha)) + + print(f"\n{'label':<22} {'θ°':>4} {'α':>5} {'wall':>6} {'div':>4} {'its':>5} " + f"{'peak|τ_res|':>11} {'peak|σ_xy|':>12}", flush=True) + for theta, alpha in cases: + label = f"th{theta:+.0f}_a{alpha:.2f}".replace(".", "p") + print(f"--- running {label} ---", flush=True) + r = run(theta, alpha, label) + print(f"{r['label']:<22} {r['theta']:>4.0f} {r['alpha']:>5.2f} " + f"{r['wall']:>6.1f} {r['div']:>4d} {r['mean_its']:>5.2f} " + f"{r['peak_tres']:>11.4e} {r['peak_sxy']:>12.4e}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/docs/advanced/benchmarks/_ti_vep_bdf2_isolation.py b/docs/advanced/benchmarks/_ti_vep_bdf2_isolation.py new file mode 100644 index 000000000..eacffedea --- /dev/null +++ b/docs/advanced/benchmarks/_ti_vep_bdf2_isolation.py @@ -0,0 +1,133 @@ +"""Isolate which factor triggers TI-VEP BDF-2 blow-up. + +Reference (working): tests/test_1052::test_ti_vep_yield_lock_variable_dt + - constant V_top, scalar τ_y, min yield, BDF-2 → stable +Failing: bench_ti_vep_harmonic at θ=0° + - harmonic V_top, spatial τ_y field, softmin yield, BDF-2 → blows up + +Variables to flip (3 dimensions, baseline + 3 single-flip variants): + + baseline (failing): harmonic forcing, spatial τ_y, softmin + variant A: const forcing, spatial τ_y, softmin + variant B: harmonic forcing, scalar τ_y, softmin + variant C: harmonic forcing, spatial τ_y, min + +Whichever flip stabilises BDF-2 identifies the trigger. +""" + +import os +import time +import numpy as np +import sympy +import underworld3 as uw +from underworld3.function import expression + + +V0 = 0.5 +OMEGA = np.pi / 2.0 +DT = 0.05 +T_END = 16.0 # 4 periods — match the original benchmark length +ETA_0 = 1.0; ETA_1 = 1.0; MU = 1.0 +TAU_Y = 0.30 +TAU_Y_BULK = 200.0 +RES = 16 + + +def build(label, *, spatial_yield, yield_mode): + mesh = uw.meshing.StructuredQuadBox( + elementRes=(RES, RES), minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), + qdegree=3, + ) + v = uw.discretisation.MeshVariable(f"U_{label}", mesh, 2, degree=2, + vtype=uw.VarType.VECTOR) + p = uw.discretisation.MeshVariable(f"P_{label}", mesh, 1, degree=1, + continuous=True, vtype=uw.VarType.SCALAR) + fault = uw.meshing.Surface( + f"fault_{label}", mesh, + np.array([[0.2, 0.5], [0.8, 0.5]]), # horizontal fault, θ=0 + symbol=f"F{label}", + ) + fault.discretize() + if spatial_yield: + weakness = fault.influence_function( + width=0.06, value_near=1.0/TAU_Y, value_far=1.0/TAU_Y_BULK, + profile="gaussian", + ) + ty = 1.0 / weakness + else: + ty = TAU_Y + + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + cm = uw.constitutive_models.TransverseIsotropicVEPFlowModel( + stokes.Unknowns, order=2, + ) + stokes.constitutive_model = cm + cm.Parameters.shear_viscosity_0 = ETA_0 + cm.Parameters.shear_viscosity_1 = ETA_1 + cm.Parameters.shear_modulus = MU + cm.Parameters.yield_stress = ty + cm.Parameters.director = sympy.Matrix([0.0, 1.0]) # θ=0 throughout + cm.Parameters.strainrate_inv_II_min = 1.0e-6 + cm._yield_mode = yield_mode + + V_top = expression(rf"V_{{top,{label}}}", sympy.Float(0.0), "") + stokes.add_essential_bc(sympy.Matrix([V_top, 0.0]), "Top") + stokes.add_essential_bc(sympy.Matrix([0.0, 0.0]), "Bottom") + stokes.add_essential_bc((sympy.oo, 0.0), "Left") + stokes.add_essential_bc((sympy.oo, 0.0), "Right") + stokes.tolerance = 1.0e-6 + stokes.petsc_options["snes_force_iteration"] = True + return stokes, V_top + + +def run(label, *, spatial_yield, yield_mode, harmonic): + stokes, V_top = build(label, spatial_yield=spatial_yield, yield_mode=yield_mode) + phi = float(np.arctan(OMEGA)) + n_steps = int(T_END / DT) + sxy = [] + div = 0 + iters_total = 0 + t0 = time.time() + for step in range(n_steps): + t = (step + 1) * DT + v_now = (V0 * float(np.cos(OMEGA * t + phi))) if harmonic else V0 + V_top.sym = sympy.Float(v_now) + stokes.constitutive_model.Parameters.dt_elastic = DT + stokes.solve(zero_init_guess=False, timestep=DT, divergence_retries=2) + if stokes.snes.getConvergedReason() < 0: + div += 1 + iters_total += stokes.snes.getIterationNumber() + # Probe centre + c = np.array([[0.5, 0.5]]) + td = stokes.tau.data + idx = int(np.argmin(np.linalg.norm(stokes.tau.coords - c, axis=1))) + sxy.append(td[idx, 2]) + wall = time.time() - t0 + sxy = np.array(sxy) + return dict(label=label, spatial_yield=spatial_yield, yield_mode=yield_mode, + harmonic=harmonic, peak_sxy=float(np.abs(sxy).max()), + div=div, mean_its=iters_total / n_steps, wall=wall) + + +def main(): + cases = [ + # baseline (failing at T=16): harmonic + spatial τ_y + softmin + ("baseline_fail", True, "softmin", True), + # B: harmonic + scalar τ_y + softmin (does it blow up at T=16?) + ("varB_scalarTY", False, "softmin", True), + # C: harmonic + spatial τ_y + min (regression-test style) + ("varC_min", True, "min", True), + ] + print(f"\n{'label':<18} {'spatial_τy':>11} {'yield':>8} {'forcing':>9} " + f"{'wall':>6} {'div':>4} {'its':>5} {'peak|σ_xy|':>11}", flush=True) + for label, sy, ym, harmonic in cases: + print(f"--- running {label} ---", flush=True) + r = run(label, spatial_yield=sy, yield_mode=ym, harmonic=harmonic) + print(f"{r['label']:<18} {str(r['spatial_yield']):>11} {r['yield_mode']:>8} " + f"{('harmonic' if r['harmonic'] else 'const'):>9} " + f"{r['wall']:>6.1f} {r['div']:>4d} {r['mean_its']:>5.2f} " + f"{r['peak_sxy']:>11.4e}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/docs/advanced/benchmarks/_ti_vs_iso_consistency.py b/docs/advanced/benchmarks/_ti_vs_iso_consistency.py new file mode 100644 index 000000000..0dd2bb2b0 --- /dev/null +++ b/docs/advanced/benchmarks/_ti_vs_iso_consistency.py @@ -0,0 +1,126 @@ +"""Consistency check: does TI reduce to iso when Δ=0 (no yield, η_0 = η_1)? + +The rank-4 TI tensor with η_0 = η_1_eff and Δ = 0 is mathematically +identical to 2·η·I_ijkl (the isotropic Newtonian tensor), regardless of +the director. At BDF-2, with the SAME ε̇_eff, the resulting stress +should be bit-equal between TI and iso. + +If TI matches iso here, the BDF-2 instability is *purely* in the yield +branch (where η_1_eff < η_0 and Δ ≠ 0 in the fault zone). If TI +diverges from iso even in this trivial case, the bug is more +fundamental — possibly a stray history term, missing factor, or +asymmetric tensor reduction. +""" + +import time +import numpy as np +import sympy +import underworld3 as uw +from underworld3.function import expression + + +V0 = 0.5; OMEGA = np.pi / 2.0; DT = 0.05; T_END = 8.0 +ETA = 1.0; MU = 1.0 +RES = 16 + + +def build(label, *, ti_model): + mesh = uw.meshing.StructuredQuadBox( + elementRes=(RES, RES), minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), + qdegree=3, + ) + v = uw.discretisation.MeshVariable(f"U_{label}", mesh, 2, degree=2, + vtype=uw.VarType.VECTOR) + p = uw.discretisation.MeshVariable(f"P_{label}", mesh, 1, degree=1, + continuous=True, vtype=uw.VarType.SCALAR) + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + if ti_model: + cm = uw.constitutive_models.TransverseIsotropicVEPFlowModel( + stokes.Unknowns, order=2, + ) + cm.Parameters.shear_viscosity_0 = ETA + cm.Parameters.shear_viscosity_1 = ETA # === η_0 + cm.Parameters.shear_modulus = MU + cm.Parameters.yield_stress = 1e8 # effectively infinite + cm.Parameters.director = sympy.Matrix([0.0, 1.0]) + cm.Parameters.shear_viscosity_min = ETA * 1.0e-3 + cm._bdf_blend = 1.0 # pure BDF-2 + else: + cm = uw.constitutive_models.ViscoElasticPlasticFlowModel( + stokes.Unknowns, order=2, + ) + cm.Parameters.shear_viscosity_0 = ETA + cm.Parameters.shear_modulus = MU + cm.Parameters.yield_stress = 1e8 + cm.Parameters.shear_viscosity_min = ETA * 1.0e-3 + cm.Parameters.strainrate_inv_II_min = 1.0e-6 + cm.yield_mode = "softmin" + stokes.constitutive_model = cm + stokes.saddle_preconditioner = 1.0 / cm.K + stokes.tolerance = 1.0e-6 + stokes.petsc_options["ksp_type"] = "fgmres" + stokes.petsc_options["snes_force_iteration"] = True + + V_top = expression(rf"V_{{top,{label}}}", sympy.Float(0.0), "") + stokes.add_essential_bc(sympy.Matrix([V_top, 0.0]), "Top") + stokes.add_essential_bc(sympy.Matrix([0.0, 0.0]), "Bottom") + stokes.add_essential_bc((sympy.oo, 0.0), "Left") + stokes.add_essential_bc((sympy.oo, 0.0), "Right") + return stokes, V_top + + +def run(label, *, ti_model): + stokes, V_top = build(label, ti_model=ti_model) + phi = float(np.arctan(OMEGA)) + n_steps = int(T_END / DT) + sxy = [] + div = 0; iters_total = 0 + t0 = time.time() + for step in range(n_steps): + t = (step + 1) * DT + v_now = V0 * float(np.cos(OMEGA * t + phi)) + V_top.sym = sympy.Float(v_now) + stokes.constitutive_model.Parameters.dt_elastic = DT + stokes.solve(zero_init_guess=False, timestep=DT, divergence_retries=2) + if stokes.snes.getConvergedReason() < 0: + div += 1 + iters_total += stokes.snes.getIterationNumber() + c = np.array([[0.5, 0.5]]) + td = stokes.tau.data + idx = int(np.argmin(np.linalg.norm(stokes.tau.coords - c, axis=1))) + sxy.append(td[idx, 2]) + wall = time.time() - t0 + return dict(label=label, ti=ti_model, wall=wall, + sxy=np.array(sxy), + div=div, mean_its=iters_total / max(1, n_steps)) + + +def main(): + print(f"\n{'label':<14} {'ti':>5} {'wall':>6} {'div':>4} {'its':>5} {'peak|σ_xy|':>11}", + flush=True) + iso = run("iso_noTY", ti_model=False) + print(f"{iso['label']:<14} {str(iso['ti']):>5} {iso['wall']:>6.1f} " + f"{iso['div']:>4d} {iso['mean_its']:>5.2f} " + f"{float(np.abs(iso['sxy']).max()):>11.4e}", flush=True) + ti = run("ti_noTY", ti_model=True) + print(f"{ti['label']:<14} {str(ti['ti']):>5} {ti['wall']:>6.1f} " + f"{ti['div']:>4d} {ti['mean_its']:>5.2f} " + f"{float(np.abs(ti['sxy']).max()):>11.4e}", flush=True) + + diff = ti['sxy'] - iso['sxy'] + print(f"\n=== consistency check ===", flush=True) + print(f" max|TI - iso| = {np.abs(diff).max():.6e}", flush=True) + print(f" max|iso| = {np.abs(iso['sxy']).max():.6e}", flush=True) + print(f" rel max diff = {np.abs(diff).max() / np.abs(iso['sxy']).max():.6e}", + flush=True) + print(f" rms TI-iso = {np.sqrt((diff**2).mean()):.6e}", flush=True) + if np.abs(diff).max() / np.abs(iso['sxy']).max() < 1e-3: + print(" → TI ≈ iso (consistent: bug is in yield branch only)", + flush=True) + else: + print(" → TI != iso (deeper inconsistency: BDF-2 TI tensor structure differs)", + flush=True) + + +if __name__ == "__main__": + main() diff --git a/docs/advanced/benchmarks/bench_convergence.py b/docs/advanced/benchmarks/bench_convergence.py new file mode 100644 index 000000000..d86dcf137 --- /dev/null +++ b/docs/advanced/benchmarks/bench_convergence.py @@ -0,0 +1,184 @@ +"""Convergence sweep for the three VE/VEP benchmarks. + +For each case (harmonic, square, VEP square) and each BDF order +(1, 2), runs the simulation at a range of timestep sizes and records +max-absolute and RMS error vs the closed-form solution. Writes +``output/benchmarks/convergence_.npz`` containing the full +sweep so the convergence figure can be regenerated without re-running. + +Run +--- +``pixi run -e amr-dev python docs/advanced/benchmarks/bench_convergence.py`` + +The full sweep is ~24 runs and takes a few minutes. +""" + +import os +import time +import numpy as np +import sympy +from _bench_helpers import ( + DEFAULT_PARAMS, t_relax, build_stokes, probe_centre, + maxwell_oscillatory, maxwell_square_wave, vep_square_wave, + save_run, error_metrics, OUTPUT_DIR, +) + + +# --------------------------------------------------------------------------- +# Per-case runners. Each takes (dt, bdf_order, **overrides) and returns +# (times, sigmas, sigma_ana, params). +# --------------------------------------------------------------------------- + +def run_ve_harmonic(dt, bdf_order, V0=0.5, omega=np.pi/2.0, n_periods=4): + """Endpoint V_top sampling — see bench_ve_harmonic.py for the rationale. + + Midpoint sampling is 1st-order accurate to the value BDF-2 wants + at the step endpoint and would limit BDF-2 to slope-1 convergence. + """ + label = f"ve_h_dt{dt:.4f}_o{bdf_order}" + params = dict(DEFAULT_PARAMS); params["bdf_order"] = bdf_order + _, stokes, V_top, params = build_stokes(label, params) + gd0 = 2.0 * V0 / params["H"] + t_end = n_periods * 2.0 * np.pi / omega + 0.5 + + times, sigmas = [], [] + t_cur = 0.0 + while t_cur < t_end - 1e-9: + ds = min(dt, t_end - t_cur) + t_end_step = t_cur + ds + v_now = V0 * float(np.sin(omega * t_end_step)) + V_top.sym = sympy.Float(v_now) + stokes.constitutive_model.Parameters.dt_elastic = ds + stokes.solve(zero_init_guess=False, timestep=ds, divergence_retries=2) + sigmas.append(probe_centre(stokes)) + t_cur = t_end_step + times.append(t_cur) + times = np.array(times); sigmas = np.array(sigmas) + sigma_ana = maxwell_oscillatory(times, params["eta"], params["mu"], gd0, omega) + return times, sigmas, sigma_ana, params + + +def run_ve_square(dt, bdf_order, V0=0.5, half_period=2.0, n_periods=4): + label = f"ve_s_dt{dt:.4f}_o{bdf_order}" + params = dict(DEFAULT_PARAMS); params["bdf_order"] = bdf_order + _, stokes, V_top, params = build_stokes(label, params) + gd0 = 2.0 * V0 / params["H"] + t_end = n_periods * 2.0 * half_period + + times, sigmas = [], [] + t_cur = 0.0 + while t_cur < t_end - 1e-9: + ds = min(dt, t_end - t_cur) + n_half = int((t_cur + 0.5 * ds) / half_period) + sign = 1.0 if n_half % 2 == 0 else -1.0 + V_top.sym = sympy.Float(sign * V0) + stokes.constitutive_model.Parameters.dt_elastic = ds + stokes.solve(zero_init_guess=False, timestep=ds, divergence_retries=2) + sigmas.append(probe_centre(stokes)) + t_cur += ds + times.append(t_cur) + times = np.array(times); sigmas = np.array(sigmas) + sigma_ana = maxwell_square_wave(times, params["eta"], params["mu"], gd0, half_period) + return times, sigmas, sigma_ana, params + + +def run_vep_square(dt, bdf_order, V0=0.5, tau_y=0.5, half_period=2.0, n_periods=4): + label = f"vep_s_dt{dt:.4f}_o{bdf_order}" + params = dict(DEFAULT_PARAMS); params["bdf_order"] = bdf_order + _, stokes, V_top, params = build_stokes( + label, params, yield_stress=tau_y, yield_mode="min", + ) + gd0 = 2.0 * V0 / params["H"] + t_end = n_periods * 2.0 * half_period + + times, sigmas = [], [] + t_cur = 0.0 + while t_cur < t_end - 1e-9: + ds = min(dt, t_end - t_cur) + n_half = int((t_cur + 0.5 * ds) / half_period) + sign = 1.0 if n_half % 2 == 0 else -1.0 + V_top.sym = sympy.Float(sign * V0) + stokes.constitutive_model.Parameters.dt_elastic = ds + stokes.solve(zero_init_guess=False, timestep=ds, divergence_retries=2) + sigmas.append(probe_centre(stokes)) + t_cur += ds + times.append(t_cur) + times = np.array(times); sigmas = np.array(sigmas) + sigma_ana = vep_square_wave(times, params["eta"], params["mu"], + gd0, tau_y, half_period) + return times, sigmas, sigma_ana, params + + +# --------------------------------------------------------------------------- +# Sweep driver +# --------------------------------------------------------------------------- + +def sweep(case_name, runner, dts, orders, **runner_kwargs): + """Run a sweep over (dt, order); return arrays + metrics dict. + + Also stores per-run traces so that re-plotting at any (order, dt) + combination doesn't require re-running. Trace arrays are stored + as ``trace_t_o_dt``, etc. + """ + results = [] + extra_arrays = {} + for order in orders: + for dt in dts: + t0 = time.time() + times, sigmas, sigma_ana, params = runner(dt, order, **runner_kwargs) + err = error_metrics(sigmas, sigma_ana) + wall = time.time() - t0 + print(f" [{case_name}] order={order} dt={dt:.4f} " + f"steps={len(times)} wall={wall:.1f}s " + f"max|err|={err['max_abs']:.4e} rms={err['rms']:.4e}", + flush=True) + results.append(dict( + order=order, dt=dt, n_steps=len(times), + max_abs=err["max_abs"], rms=err["rms"], wall=wall, + )) + # Store traces for replotting — keyed by (order, dt) + tag = f"o{order}_dt{dt:.4f}" + extra_arrays[f"trace_t_{tag}"] = times + extra_arrays[f"trace_sigma_{tag}"] = sigmas + extra_arrays[f"trace_ana_{tag}"] = sigma_ana + return dict( + order=np.array([r["order"] for r in results]), + dt=np.array([r["dt"] for r in results]), + n_steps=np.array([r["n_steps"] for r in results]), + max_abs=np.array([r["max_abs"] for r in results]), + rms=np.array([r["rms"] for r in results]), + wall=np.array([r["wall"] for r in results]), + **extra_arrays, + ) + + +def main(): + os.makedirs(OUTPUT_DIR, exist_ok=True) + + # Reasonable dt range for each case. + DTS_HARMONIC = [0.40, 0.20, 0.10, 0.05, 0.025] # 5 values × 2 orders + DTS_SQUARE = [0.40, 0.20, 0.10, 0.05] # 4 values; 0.025 not needed + DTS_VEP = [0.40, 0.20, 0.10, 0.05] # same as VE square + ORDERS = [1, 2] + + print("=== Convergence: VE harmonic (sin forcing) ===") + res = sweep("ve_h", run_ve_harmonic, DTS_HARMONIC, ORDERS) + save_run("convergence_ve_harmonic", params=DEFAULT_PARAMS, + params_extra=dict(orders=list(ORDERS), dts=list(DTS_HARMONIC)), + **res) + + print("\n=== Convergence: VE square wave ===") + res = sweep("ve_s", run_ve_square, DTS_SQUARE, ORDERS) + save_run("convergence_ve_square", params=DEFAULT_PARAMS, + params_extra=dict(orders=list(ORDERS), dts=list(DTS_SQUARE)), + **res) + + print("\n=== Convergence: VEP square wave (Min mode) ===") + res = sweep("vep_s", run_vep_square, DTS_VEP, ORDERS) + save_run("convergence_vep_square", params=DEFAULT_PARAMS, + params_extra=dict(orders=list(ORDERS), dts=list(DTS_VEP)), + **res) + + +if __name__ == "__main__": + main() diff --git a/docs/advanced/benchmarks/bench_ti_vep_harmonic.py b/docs/advanced/benchmarks/bench_ti_vep_harmonic.py new file mode 100644 index 000000000..cd03c7323 --- /dev/null +++ b/docs/advanced/benchmarks/bench_ti_vep_harmonic.py @@ -0,0 +1,313 @@ +"""Benchmark: Transverse-isotropic VEP fault under harmonic shear. + +Sister to ``bench_ve_harmonic.py`` (isotropic Maxwell): same peak-start +initial condition and cosine forcing, but with an embedded fault +modelled by ``TransverseIsotropicVEPFlowModel``. The point of the +benchmark is to confirm that BDF-1 / BDF-2 time integration are as +robust on the angled-fault problem as on the isotropic case — no new +SNES instabilities expected. + +Three fault angles run side-by-side: θ ∈ {0°, +15°, -15°}. + +Probes: +* ``sigma_xy`` — global shear stress at the fault centre (fault frame) +* ``tau_resolved`` — shear on the fault plane: t·σ·n with t the fault + tangent and n the fault normal. + +For θ = 0° the resolved shear equals σ_xy. For θ ≠ 0° the resolved +shear caps at τ_y while σ_xy keeps growing, since only the fault-plane +component yields. + +Forcing: V_top(t) = V0·cos(ωt + φ) with the same Deborah-number / phase +as the isotropic case so the analytical (sub-yield) reference is +identical: the resolved shear should track A_∞·cos(ωt) once any +plastic transients die out. + +Output: one ``.npz`` per (angle, τ_y) pair, BDF-1 and BDF-2 traces. +""" + +import os +import time +import numpy as np +import sympy + +import underworld3 as uw +from underworld3.function import expression +from _bench_helpers import OUTPUT_DIR + + +# --------------------------------------------------------------------------- +# Run-specific parameters (kept aligned with bench_ve_harmonic.py) +# --------------------------------------------------------------------------- + +V0 = 0.5 +OMEGA = np.pi / 2.0 # period 4·t_r +DT = 0.05 +N_PERIODS = 4 +T_END = N_PERIODS * 2.0 * np.pi / OMEGA + +ETA_0 = 1.0 # bulk shear viscosity +ETA_1 = 1.0 # fault-plane shear viscosity +MU = 1.0 # elastic shear modulus +TAU_Y_BULK = 200.0 # effectively infinite away from the fault + +# Geometry +RES = 16 # mesh resolution (RES x RES) — kept modest for benchmark turnaround +H = 1.0; W = 1.0 # domain size [0, W] × [0, H] +FAULT_LENGTH = 0.6 +FAULT_WIDTH = 0.06 # influence-function half-width + +# Sweep +ANGLES_DEG = (0.0, 15.0, -15.0) +TAU_Y_LIST = (0.15, 0.30) +BDF_ORDERS = (1, 2) + + +# --------------------------------------------------------------------------- +# Build helper +# --------------------------------------------------------------------------- + +def build_ti_stokes(label, theta_deg, tau_y, bdf_order): + """Construct a TI-VEP Stokes problem with an embedded fault. + + Parameters + ---------- + label : str + Used to namespace mesh-variable names. + theta_deg : float + Fault angle from horizontal, in degrees. + tau_y : float + Fault-plane yield stress. + bdf_order : int + BDF time-integration order (1 or 2). + + Returns + ------- + mesh, stokes, V_top_expr, n_vec + """ + mesh = uw.meshing.StructuredQuadBox( + elementRes=(RES, RES), + minCoords=(0.0, 0.0), maxCoords=(W, H), + qdegree=3, + ) + v = uw.discretisation.MeshVariable( + f"U_{label}", mesh, 2, degree=2, vtype=uw.VarType.VECTOR, + ) + p = uw.discretisation.MeshVariable( + f"P_{label}", mesh, 1, degree=1, + continuous=True, vtype=uw.VarType.SCALAR, + ) + + theta = np.radians(theta_deg) + cx, cy = 0.5 * W, 0.5 * H + dx = 0.5 * FAULT_LENGTH * np.cos(theta) + dy = 0.5 * FAULT_LENGTH * np.sin(theta) + fault = uw.meshing.Surface( + f"fault_{label}", mesh, + np.array([[cx - dx, cy - dy], [cx + dx, cy + dy]]), + symbol=f"F{label}", + ) + fault.discretize() + + n_x = -np.sin(theta) + n_y = np.cos(theta) + director = sympy.Matrix([n_x, n_y]) + + weakness = fault.influence_function( + width=FAULT_WIDTH, + value_near=1.0 / tau_y, + value_far=1.0 / TAU_Y_BULK, + profile="gaussian", + ) + tau_y_field = 1.0 / weakness + + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + cm = uw.constitutive_models.TransverseIsotropicVEPFlowModel( + stokes.Unknowns, order=bdf_order, + ) + stokes.constitutive_model = cm + cm.Parameters.shear_viscosity_0 = ETA_0 + cm.Parameters.shear_viscosity_1 = ETA_1 + cm.Parameters.shear_modulus = MU + cm.Parameters.yield_stress = tau_y_field + cm.Parameters.director = director + cm.Parameters.shear_viscosity_min = ETA_0 * 1.0e-3 + cm.Parameters.strainrate_inv_II_min = 1.0e-6 + cm.yield_mode = "softmin" # default; smooth and robust + + stokes.saddle_preconditioner = 1.0 / cm.K + stokes.tolerance = 1.0e-4 + stokes.petsc_options["ksp_type"] = "fgmres" + stokes.petsc_options["snes_force_iteration"] = True + + V_top = expression( + rf"V_{{top,{label}}}", sympy.Float(0.0), "Top BC velocity", + ) + stokes.add_essential_bc(sympy.Matrix([V_top, 0.0]), "Top") + stokes.add_essential_bc(sympy.Matrix([0.0, 0.0]), "Bottom") + stokes.add_essential_bc((sympy.oo, 0.0), "Left") + stokes.add_essential_bc((sympy.oo, 0.0), "Right") + stokes.bodyforce = sympy.Matrix([0.0, 0.0]) + + return mesh, stokes, V_top, np.array([n_x, n_y]) + + +# --------------------------------------------------------------------------- +# Probes +# --------------------------------------------------------------------------- + +def probe_stress(stokes, n_vec, c=np.array([[0.5, 0.5]])): + """Return σ_xy and resolved fault-plane shear at the fault centre.""" + tau = stokes.tau + dists = np.linalg.norm(tau.coords - c, axis=1) + idx = int(np.argmin(dists)) + s_xx, s_yy, s_xy = tau.data[idx, 0], tau.data[idx, 1], tau.data[idx, 2] + n_x, n_y = n_vec + t_x, t_y = n_y, -n_x # fault tangent (perp to normal) + resolved = (s_xx * t_x * n_x + s_xy * (t_x * n_y + t_y * n_x) + + s_yy * t_y * n_y) + return float(s_xy), float(resolved) + + +# --------------------------------------------------------------------------- +# Time-stepping core +# --------------------------------------------------------------------------- + +def _run_one(theta_deg, tau_y, bdf_order, label): + """One run. Returns dict of arrays.""" + mesh, stokes, V_top, n_vec = build_ti_stokes( + label, theta_deg, tau_y, bdf_order, + ) + + # Maxwell relaxation time and steady-state amplitude (sub-yield) + t_r = ETA_1 / MU + De = OMEGA * t_r + # BCs: Top moves at V_top, Bottom fixed → engineering shear rate + # γ̇_0 = V0/H (NOT 2·V0/H — that would be the antisymmetric case + # used by bench_ve_harmonic.py). Steady VE amplitude is then + # σ_∞ = 2η·ε̇/sqrt(1+De²) = η·γ̇_0/sqrt(1+De²) since ε̇ = γ̇/2. + gamma_dot_0 = V0 / H + A_inf = ETA_1 * gamma_dot_0 / np.sqrt(1.0 + De**2) + phi = float(np.arctan(De)) + + # Peak-start: plant ψ*[k] = (resolved shear at t=-k·dt) on the fault + # tangent direction in the SYM_TENSOR slot. For a 2D tensor, with + # the resolved shear along (t_x, t_y) and normal (n_x, n_y), the + # corresponding stress contribution is τ·(t_i n_j + n_i t_j). + n_x, n_y = n_vec + t_x, t_y = n_y, -n_x + n_nodes = stokes.DFDt.psi_star[0].array.shape[0] + history = [] + for k in range(stokes.DFDt.order): + val_k = A_inf * float(np.cos(OMEGA * k * DT)) + # symmetric tensor: σ = τ_resolved * (t⊗n + n⊗t) + arr = np.zeros((n_nodes, 2, 2)) + sxx = val_k * 2.0 * t_x * n_x + syy = val_k * 2.0 * t_y * n_y + sxy = val_k * (t_x * n_y + t_y * n_x) + arr[:, 0, 0] = sxx + arr[:, 1, 1] = syy + arr[:, 0, 1] = sxy + arr[:, 1, 0] = sxy + history.append(arr) + stokes.DFDt.set_initial_history(history, dt=DT) + + times, sxy_h, tres_h, reasons, iters = [], [], [], [], [] + t_cur = 0.0 + t0 = time.time() + while t_cur < T_END - 1e-9: + dt = min(DT, T_END - t_cur) + t_end_step = t_cur + dt + v_now = V0 * float(np.cos(OMEGA * t_end_step + phi)) + V_top.sym = sympy.Float(v_now) + stokes.constitutive_model.Parameters.dt_elastic = dt + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=2) + sxy, tres = probe_stress(stokes, n_vec) + t_cur = t_end_step + times.append(t_cur); sxy_h.append(sxy); tres_h.append(tres) + reasons.append(int(stokes.snes.getConvergedReason())) + iters.append(int(stokes.snes.getIterationNumber())) + wall = time.time() - t0 + + times = np.array(times); sxy_h = np.array(sxy_h); tres_h = np.array(tres_h) + reasons = np.array(reasons); iters = np.array(iters) + + # Sub-yield analytical: A_∞·cos(ωt). Above yield, this is the VE + # "no-yield" envelope and the actual response should track it until + # |τ| reaches τ_y, then plateau. + sigma_ve = A_inf * np.cos(OMEGA * times) + + return dict( + times=times, sigma_xy=sxy_h, tau_resolved=tres_h, + sigma_ve=sigma_ve, reasons=reasons, iters=iters, + wall=wall, A_inf=A_inf, phi=phi, De=De, gamma_dot_0=gamma_dot_0, + ) + + +def main(): + os.makedirs(OUTPUT_DIR, exist_ok=True) + summary = [] + for theta_deg in ANGLES_DEG: + for tau_y in TAU_Y_LIST: + results = {} + for bdf in BDF_ORDERS: + lbl = f"tivep_o{bdf}_th{theta_deg:+.0f}_ty{tau_y:.2f}".replace( + ".", "p" + ) + print(f"\n--- {lbl}: θ={theta_deg}°, τ_y={tau_y}, BDF-{bdf} ---", + flush=True) + results[bdf] = _run_one(theta_deg, tau_y, bdf, lbl) + r = results[bdf] + ndiv = int((r["reasons"] < 0).sum()) + print(f" wall={r['wall']:.1f}s steps={len(r['times'])} " + f"diverged={ndiv} mean_its={float(r['iters'].mean()):.2f} " + f"peak|τ_resolved|={float(np.abs(r['tau_resolved']).max()):.4f} " + f"peak|σ_xy|={float(np.abs(r['sigma_xy']).max()):.4f}", + flush=True) + summary.append(dict( + label=lbl, theta=theta_deg, tau_y=tau_y, bdf=bdf, + wall=r["wall"], diverged=ndiv, + mean_its=float(r["iters"].mean()), + peak_resolved=float(np.abs(r["tau_resolved"]).max()), + peak_sxy=float(np.abs(r["sigma_xy"]).max()), + )) + + # Save BDF-1 and BDF-2 traces side by side + tag = f"ti_vep_harmonic_th{theta_deg:+.0f}_ty{tau_y:.2f}".replace( + ".", "p" + ) + np.savez( + os.path.join(OUTPUT_DIR, f"{tag}.npz"), + theta_deg=theta_deg, tau_y=tau_y, + times=results[1]["times"], + sigma_xy_bdf1=results[1]["sigma_xy"], + sigma_xy_bdf2=results[2]["sigma_xy"], + tau_resolved_bdf1=results[1]["tau_resolved"], + tau_resolved_bdf2=results[2]["tau_resolved"], + sigma_ve=results[1]["sigma_ve"], + reasons_bdf1=results[1]["reasons"], + reasons_bdf2=results[2]["reasons"], + iters_bdf1=results[1]["iters"], + iters_bdf2=results[2]["iters"], + A_inf=results[1]["A_inf"], De=results[1]["De"], + gamma_dot_0=results[1]["gamma_dot_0"], + wall_bdf1=results[1]["wall"], wall_bdf2=results[2]["wall"], + V0=V0, OMEGA=OMEGA, DT=DT, T_END=T_END, + ETA_0=ETA_0, ETA_1=ETA_1, MU=MU, + FAULT_WIDTH=FAULT_WIDTH, FAULT_LENGTH=FAULT_LENGTH, RES=RES, + ) + print(f" saved → {tag}.npz", flush=True) + + print("\n=== summary ===", flush=True) + print(f"{'label':<36} {'θ°':>4} {'τ_y':>5} {'BDF':>4} {'wall':>6} " + f"{'div':>4} {'its':>5} {'peak|τ_res|':>11} {'peak|σ_xy|':>10}", + flush=True) + for s in summary: + print(f"{s['label']:<36} {s['theta']:>4.0f} {s['tau_y']:>5.2f} " + f"{s['bdf']:>4d} {s['wall']:>6.1f} {s['diverged']:>4d} " + f"{s['mean_its']:>5.2f} {s['peak_resolved']:>11.4f} " + f"{s['peak_sxy']:>10.4f}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/docs/advanced/benchmarks/bench_ti_vep_harmonic_zeroIC.py b/docs/advanced/benchmarks/bench_ti_vep_harmonic_zeroIC.py new file mode 100644 index 000000000..60dcb54fc --- /dev/null +++ b/docs/advanced/benchmarks/bench_ti_vep_harmonic_zeroIC.py @@ -0,0 +1,136 @@ +"""TI-VEP harmonic benchmark — variant with σ=0 initial condition. + +Sanity check on the previous run (peak-start IC) which produced +catastrophic BDF-2 blow-up. Hypothesis: planting σ_xy = A_∞ +uniformly puts the fault yield zone at 3-4× its yield stress at +t=0, and BDF-2's inconsistent ψ*₀/ψ*₁ history then drives an +unstable plastic correction that grows. σ=0 IC avoids that. + +Driving uses a cos forcing that *does* start at peak (V_top = V0 +at t=0), so we expect a transient before settling on the steady +cycle — but the solver should remain stable throughout. + +Same suite (3 angles × 2 τ_y × 2 BDF orders). +""" + +import os +import time +import numpy as np +import sympy + +from _bench_helpers import OUTPUT_DIR +from bench_ti_vep_harmonic import ( + V0, OMEGA, DT, T_END, ETA_0, ETA_1, MU, + FAULT_LENGTH, FAULT_WIDTH, RES, + ANGLES_DEG, TAU_Y_LIST, BDF_ORDERS, + build_ti_stokes, probe_stress, +) + + +def _run_one(theta_deg, tau_y, bdf_order, label): + mesh, stokes, V_top, n_vec = build_ti_stokes( + label, theta_deg, tau_y, bdf_order, + ) + # σ=0 IC — let DDt initialise history from current value (which is 0) + # on the first solve. No set_initial_history call. + + t_r = ETA_1 / MU + De = OMEGA * t_r + # BCs: Top moves, Bottom fixed → γ̇_0 = V0/H (not 2·V0/H). + gamma_dot_0 = V0 / 1.0 + A_inf = ETA_1 * gamma_dot_0 / np.sqrt(1.0 + De**2) + phi = float(np.arctan(De)) + + times, sxy_h, tres_h, reasons, iters = [], [], [], [], [] + t_cur = 0.0 + t0 = time.time() + while t_cur < T_END - 1e-9: + dt = min(DT, T_END - t_cur) + t_end_step = t_cur + dt + v_now = V0 * float(np.cos(OMEGA * t_end_step + phi)) + V_top.sym = sympy.Float(v_now) + stokes.constitutive_model.Parameters.dt_elastic = dt + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=2) + sxy, tres = probe_stress(stokes, n_vec) + t_cur = t_end_step + times.append(t_cur); sxy_h.append(sxy); tres_h.append(tres) + reasons.append(int(stokes.snes.getConvergedReason())) + iters.append(int(stokes.snes.getIterationNumber())) + wall = time.time() - t0 + + times = np.array(times); sxy_h = np.array(sxy_h); tres_h = np.array(tres_h) + reasons = np.array(reasons); iters = np.array(iters) + sigma_ve = A_inf * np.cos(OMEGA * times) + return dict( + times=times, sigma_xy=sxy_h, tau_resolved=tres_h, + sigma_ve=sigma_ve, reasons=reasons, iters=iters, + wall=wall, A_inf=A_inf, phi=phi, De=De, gamma_dot_0=gamma_dot_0, + ) + + +def main(): + os.makedirs(OUTPUT_DIR, exist_ok=True) + summary = [] + for theta_deg in ANGLES_DEG: + for tau_y in TAU_Y_LIST: + results = {} + for bdf in BDF_ORDERS: + lbl = f"tivep_zIC_o{bdf}_th{theta_deg:+.0f}_ty{tau_y:.2f}".replace( + ".", "p" + ) + print(f"\n--- {lbl}: θ={theta_deg}°, τ_y={tau_y}, BDF-{bdf} (σ=0 IC) ---", + flush=True) + results[bdf] = _run_one(theta_deg, tau_y, bdf, lbl) + r = results[bdf] + ndiv = int((r["reasons"] < 0).sum()) + print(f" wall={r['wall']:.1f}s steps={len(r['times'])} " + f"diverged={ndiv} mean_its={float(r['iters'].mean()):.2f} " + f"peak|τ_resolved|={float(np.abs(r['tau_resolved']).max()):.4f} " + f"peak|σ_xy|={float(np.abs(r['sigma_xy']).max()):.4f}", + flush=True) + summary.append(dict( + label=lbl, theta=theta_deg, tau_y=tau_y, bdf=bdf, + wall=r["wall"], diverged=ndiv, + mean_its=float(r["iters"].mean()), + peak_resolved=float(np.abs(r["tau_resolved"]).max()), + peak_sxy=float(np.abs(r["sigma_xy"]).max()), + )) + + tag = f"ti_vep_harmonic_zIC_th{theta_deg:+.0f}_ty{tau_y:.2f}".replace( + ".", "p" + ) + np.savez( + os.path.join(OUTPUT_DIR, f"{tag}.npz"), + theta_deg=theta_deg, tau_y=tau_y, + times=results[1]["times"], + sigma_xy_bdf1=results[1]["sigma_xy"], + sigma_xy_bdf2=results[2]["sigma_xy"], + tau_resolved_bdf1=results[1]["tau_resolved"], + tau_resolved_bdf2=results[2]["tau_resolved"], + sigma_ve=results[1]["sigma_ve"], + reasons_bdf1=results[1]["reasons"], + reasons_bdf2=results[2]["reasons"], + iters_bdf1=results[1]["iters"], + iters_bdf2=results[2]["iters"], + A_inf=results[1]["A_inf"], De=results[1]["De"], + gamma_dot_0=results[1]["gamma_dot_0"], + wall_bdf1=results[1]["wall"], wall_bdf2=results[2]["wall"], + V0=V0, OMEGA=OMEGA, DT=DT, T_END=T_END, + ETA_0=ETA_0, ETA_1=ETA_1, MU=MU, + FAULT_WIDTH=FAULT_WIDTH, FAULT_LENGTH=FAULT_LENGTH, RES=RES, + ) + print(f" saved → {tag}.npz", flush=True) + + print("\n=== summary (σ=0 IC) ===", flush=True) + print(f"{'label':<40} {'θ°':>4} {'τ_y':>5} {'BDF':>4} {'wall':>6} " + f"{'div':>4} {'its':>5} {'peak|τ_res|':>11} {'peak|σ_xy|':>11}", + flush=True) + for s in summary: + print(f"{s['label']:<40} {s['theta']:>4.0f} {s['tau_y']:>5.2f} " + f"{s['bdf']:>4d} {s['wall']:>6.1f} {s['diverged']:>4d} " + f"{s['mean_its']:>5.2f} {s['peak_resolved']:>11.4f} " + f"{s['peak_sxy']:>11.4f}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/docs/advanced/benchmarks/bench_ve_harmonic.py b/docs/advanced/benchmarks/bench_ve_harmonic.py new file mode 100644 index 000000000..c90c11fa5 --- /dev/null +++ b/docs/advanced/benchmarks/bench_ve_harmonic.py @@ -0,0 +1,163 @@ +"""Benchmark: Maxwell viscoelastic shear under sinusoidal forcing. + +Drives the shear box with :math:`V_{top}(t) = V_0 \\sin(\\omega t)` and +compares the centre-point shear stress against the closed-form +solution. Records amplitude, phase shift, and error norms. + +Closed form +----------- +For Maxwell with constant :math:`\\eta, \\mu` driven by +:math:`\\dot\\gamma(t) = \\dot\\gamma_0 \\sin(\\omega t)`, + +.. math:: + \\sigma(t) = \\frac{\\eta\\dot\\gamma_0}{1 + \\mathrm{De}^2} + \\bigl[\\sin(\\omega t) - \\mathrm{De}\\cos(\\omega t) + + \\mathrm{De}\\,e^{-t/t_r}\\bigr] + +with :math:`\\mathrm{De} = \\omega t_r` (Deborah number). Steady amplitude +:math:`A_{\\infty} = \\eta\\dot\\gamma_0 / \\sqrt{1+\\mathrm{De}^2}`, phase +lag :math:`\\varphi = \\arctan(\\mathrm{De})`. + +Run +--- +``pixi run -e amr-dev python docs/advanced/benchmarks/bench_ve_harmonic.py`` + +Output: ``output/benchmarks/ve_harmonic.npz`` containing the simulation +trace, the analytical reference at the same time points, and parameter +metadata. See ``plot_benchmarks.py`` for plotting from the npz. +""" + +import time +import numpy as np +import sympy +from _bench_helpers import ( + DEFAULT_PARAMS, t_relax, build_stokes, probe_centre, + maxwell_oscillatory, save_run, error_metrics, fit_amp_phase, +) + + +# Run-specific parameters +V0 = 0.5 # → γ̇₀ = 2·V0/H = 1.0 in the symmetric strain rate +OMEGA = np.pi / 2.0 # period 4·t_r → De = π/2 ≈ 1.57 +DT = 0.05 # ~80 steps per period; resolves the harmonic +N_PERIODS = 4 # 4 full periods (no warmup needed — see below) +T_END = N_PERIODS * 2.0 * np.pi / OMEGA # 4 periods exactly + +LABEL = "ve_harmonic" + +# Initial condition design: start at a point in the steady-state cycle +# where σ̇ = 0, so σ(0) is consistent with the analytical and there is +# *no* startup transient. Choose BC such that σ_ss(t) = A_∞·cos(ωt), +# i.e. peak at t=0. Working backwards through the Maxwell phase +# response (lag φ = arctan(De)), this requires +# V_top(t) = V_0 · cos(ωt + φ) +# so that ε̇_xy(t) = (V_0/H)·cos(ωt + φ) and the steady-state response +# σ_ss(t) = A_∞·cos(ωt + φ - φ) = A_∞·cos(ωt). +# +# The initial condition σ(0) = A_∞ matches the steady-state at t=0 +# exactly, leaving no homogeneous (decaying) component — so the entire +# recorded trace is on the steady cycle. + + +def _run_one(bdf_order): + """Run the simulation at one BDF order with peak-start initial condition. + + See module docstring above for why σ(0) = A_∞ paired with the cosine + forcing eliminates the startup transient. V_top is sampled at the + *endpoint* of each step (BDF expects the value at the new time). + """ + params = dict(DEFAULT_PARAMS) + params["bdf_order"] = bdf_order + mesh, stokes, V_top, params = build_stokes(f"{LABEL}_o{bdf_order}", params) + + t_r = params["eta"] / params["mu"] + De = OMEGA * t_r + gamma_dot_0 = 2.0 * V0 / params["H"] + A_inf = params["eta"] * gamma_dot_0 / np.sqrt(1.0 + De**2) + phi = float(np.arctan(De)) + + # Plant the steady-state cycle as the initial condition. σ_ss(t) = + # A_∞·cos(ωt) is the analytical solution under our cos forcing — + # zero homogeneous component, σ̇(0) = 0. History slot k is the + # value at t = -k·Δt, which by cosine evenness is A_∞·cos(k·ω·Δt). + # + # Using the *exact* per-slot value (not just A_∞ for all k) is what + # actually buys the benefit: a constant A_∞ across all slots drops + # O(Δt²) error into ψ*[1], contaminating BDF-2's truncation from + # step 1 — exactly the phase error we are trying to avoid. + n_nodes = stokes.DFDt.psi_star[0].array.shape[0] + history = [] + for k in range(stokes.DFDt.order): + arr = np.zeros((n_nodes, 2, 2)) + val_k = A_inf * float(np.cos(OMEGA * k * DT)) + arr[:, 0, 1] = val_k + arr[:, 1, 0] = val_k + history.append(arr) + stokes.DFDt.set_initial_history(history, dt=DT) + + times, dts, sigmas, gammas, reasons = [], [], [], [], [] + t_cur = 0.0 + t0 = time.time() + while t_cur < T_END - 1e-9: + dt = min(DT, T_END - t_cur) + t_end_step = t_cur + dt + # BC: V_top(t) = V_0·cos(ωt + φ) so σ_ss(t) = A_∞·cos(ωt). + v_now = V0 * float(np.cos(OMEGA * t_end_step + phi)) + V_top.sym = sympy.Float(v_now) + stokes.constitutive_model.Parameters.dt_elastic = dt + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=2) + s = probe_centre(stokes) + t_cur = t_end_step + times.append(t_cur); dts.append(dt); sigmas.append(s) + gammas.append(2.0 * v_now / params["H"]) + reasons.append(int(stokes.snes.getConvergedReason())) + return (np.array(times), np.array(dts), np.array(sigmas), + np.array(gammas), np.array(reasons), time.time() - t0, params) + + +def main(): + times1, dts1, sig1, gam1, rea1, wall1, params = _run_one(1) + times2, dts2, sig2, gam2, rea2, wall2, _ = _run_one(2) + assert np.allclose(times1, times2) + + t_r = t_relax(params) + De = OMEGA * t_r + gamma_dot_0 = 2.0 * V0 / params["H"] + A_inf = params["eta"] * gamma_dot_0 / np.sqrt(1.0 + De**2) + # Peak-start initial condition + cos(ωt + φ) forcing → no transient, + # so the analytical is the steady-state cycle σ(t) = A_∞·cos(ωt). + sigma_ana = A_inf * np.cos(OMEGA * times1) + + err1 = error_metrics(sig1, sigma_ana) + err2 = error_metrics(sig2, sigma_ana) + A1, phi1 = fit_amp_phase(times1, sig1, OMEGA) + A2, phi2 = fit_amp_phase(times2, sig2, OMEGA) + A_ana = params["eta"] * gamma_dot_0 / np.sqrt(1.0 + De**2) + phi_ana = float(np.arctan(De)) + + print(f"[{LABEL}] steps={len(times1)} De=ω·t_r={De:.4f}") + print(f" BDF-1 wall={wall1:.1f}s max|err|={err1['max_abs']:.4e} rms={err1['rms']:.4e}") + print(f" amp sim={A1:.4f} ana={A_ana:.4f} phi sim={phi1:.4f} ana={phi_ana:.4f}") + print(f" BDF-2 wall={wall2:.1f}s max|err|={err2['max_abs']:.4e} rms={err2['rms']:.4e}") + print(f" amp sim={A2:.4f} ana={A_ana:.4f} phi sim={phi2:.4f} ana={phi_ana:.4f}") + + save_run( + LABEL, + params=params, + params_extra=dict( + V0=V0, omega=OMEGA, gamma_dot_0=gamma_dot_0, De=De, + t_end=T_END, dt_nominal=DT, + A_bdf1=A1, A_bdf2=A2, A_ana=A_ana, + phi_bdf1=phi1, phi_bdf2=phi2, phi_ana=phi_ana, + err_max_bdf1=err1["max_abs"], err_rms_bdf1=err1["rms"], + err_max_bdf2=err2["max_abs"], err_rms_bdf2=err2["rms"], + wall_bdf1=wall1, wall_bdf2=wall2, + ), + times=times1, dts=dts1, gamma_dot=gam1, sigma_ana=sigma_ana, + sigma_bdf1=sig1, sigma_bdf2=sig2, + snes_reasons_bdf1=rea1, snes_reasons_bdf2=rea2, + ) + + +if __name__ == "__main__": + main() diff --git a/docs/advanced/benchmarks/bench_ve_square.py b/docs/advanced/benchmarks/bench_ve_square.py new file mode 100644 index 000000000..c33280d91 --- /dev/null +++ b/docs/advanced/benchmarks/bench_ve_square.py @@ -0,0 +1,97 @@ +"""Benchmark: Maxwell viscoelastic shear under square-wave forcing. + +Drives the shear box with a square-wave :math:`V_{top}(t)` (sign flips +every ``half_period``) and compares the centre-point shear stress +against the closed-form piecewise-exponential solution. + +Closed form +----------- +Within the n-th half-period (sign :math:`s_n = (-1)^n`): + +.. math:: + \\sigma(t) = s_n \\sigma_{\\mathrm{ss}} + + (\\sigma_{0,n} - s_n\\sigma_{\\mathrm{ss}})\\, e^{-(t-t_n)/t_r} + +with :math:`\\sigma_{\\mathrm{ss}} = \\eta\\dot\\gamma_0` and +:math:`\\sigma_{0,n}` the stress at the start of half-period n. + +Run +--- +``pixi run -e amr-dev python docs/advanced/benchmarks/bench_ve_square.py`` + +Output: ``output/benchmarks/ve_square.npz``. +""" + +import time +import numpy as np +import sympy +from _bench_helpers import ( + DEFAULT_PARAMS, t_relax, build_stokes, probe_centre, + maxwell_square_wave, save_run, error_metrics, +) + + +V0 = 0.5 +HALF_PERIOD = 2.0 # in units of t_r +N_PERIODS = 4 # → t_end = 4 · 2 · t_r = 8 t_r +DT = 0.10 # 20 steps per half-period +T_END = N_PERIODS * 2.0 * HALF_PERIOD + +LABEL = "ve_square" + + +def _run_one(bdf_order): + params = dict(DEFAULT_PARAMS) + params["bdf_order"] = bdf_order + mesh, stokes, V_top, params = build_stokes(f"{LABEL}_o{bdf_order}", params) + + times, dts, sigmas, gammas, reasons = [], [], [], [], [] + t_cur = 0.0 + t0 = time.time() + while t_cur < T_END - 1e-9: + dt = min(DT, T_END - t_cur) + n_half = int((t_cur + 0.5 * dt) / HALF_PERIOD) + sign = 1.0 if n_half % 2 == 0 else -1.0 + v_now = sign * V0 + V_top.sym = sympy.Float(v_now) + stokes.constitutive_model.Parameters.dt_elastic = dt + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=2) + sigmas.append(probe_centre(stokes)) + t_cur += dt + times.append(t_cur); dts.append(dt); gammas.append(2.0 * v_now / params["H"]) + reasons.append(int(stokes.snes.getConvergedReason())) + return (np.array(times), np.array(dts), np.array(sigmas), + np.array(gammas), np.array(reasons), time.time() - t0, params) + + +def main(): + times1, dts1, sig1, gam1, rea1, wall1, params = _run_one(1) + times2, dts2, sig2, gam2, rea2, wall2, _ = _run_one(2) + assert np.allclose(times1, times2) + gamma_dot_0 = 2.0 * V0 / params["H"] + sigma_ana = maxwell_square_wave(times1, params["eta"], params["mu"], + gamma_dot_0, HALF_PERIOD) + err1 = error_metrics(sig1, sigma_ana) + err2 = error_metrics(sig2, sigma_ana) + print(f"[{LABEL}] steps={len(times1)} σ_ss=η·γ̇₀={params['eta']*gamma_dot_0:.4f}") + print(f" BDF-1 wall={wall1:.1f}s max|err|={err1['max_abs']:.4e} rms={err1['rms']:.4e}") + print(f" BDF-2 wall={wall2:.1f}s max|err|={err2['max_abs']:.4e} rms={err2['rms']:.4e}") + + save_run( + LABEL, + params=params, + params_extra=dict( + V0=V0, half_period=HALF_PERIOD, n_periods=N_PERIODS, + gamma_dot_0=gamma_dot_0, t_end=T_END, dt_nominal=DT, + err_max_bdf1=err1["max_abs"], err_rms_bdf1=err1["rms"], + err_max_bdf2=err2["max_abs"], err_rms_bdf2=err2["rms"], + wall_bdf1=wall1, wall_bdf2=wall2, + ), + times=times1, dts=dts1, gamma_dot=gam1, sigma_ana=sigma_ana, + sigma_bdf1=sig1, sigma_bdf2=sig2, + snes_reasons_bdf1=rea1, snes_reasons_bdf2=rea2, + ) + + +if __name__ == "__main__": + main() diff --git a/docs/advanced/benchmarks/bench_ve_square_vardt.py b/docs/advanced/benchmarks/bench_ve_square_vardt.py new file mode 100644 index 000000000..999c03bd1 --- /dev/null +++ b/docs/advanced/benchmarks/bench_ve_square_vardt.py @@ -0,0 +1,114 @@ +"""Variable-dt VE square-wave benchmark. + +Same physics as :mod:`bench_ve_square` but with a non-uniform timestep +schedule: dt is reduced by a factor of 10 in a small window around each +BC flip and held at the larger value on plateaux. This tests the +projection-snapshot machinery on the exact path that previously +exhibited the implicit-projection drift (see +``tests/test_1052_VEP_stability_regression.py::test_vep_yield_lock_variable_dt``) +and confirms the same robustness on the pure-VE side. + +Schedule (with ``T_{1/2} = 2 t_r`` and a window of ``±0.1 T_{1/2}`` around +each flip): + plateau dt = ``DT_PLATEAU`` + flip-window dt = ``DT_PLATEAU / 10`` + +Run:: + + pixi run -e amr-dev python docs/advanced/benchmarks/bench_ve_square_vardt.py +""" + +import time +import numpy as np +import sympy +from _bench_helpers import ( + DEFAULT_PARAMS, build_stokes, probe_centre, + maxwell_square_wave, save_run, error_metrics, +) + + +V0 = 0.5 +HALF_PERIOD = 2.0 +N_PERIODS = 4 +T_END = N_PERIODS * 2.0 * HALF_PERIOD + +DT_PLATEAU = 0.10 # plateau dt (same as bench_ve_square) +DT_FINE_RATIO = 0.10 # flip-window dt is 0.10 × plateau +DT_FINE = DT_PLATEAU * DT_FINE_RATIO +WINDOW = 0.1 * HALF_PERIOD # ±0.20 t_r around each flip + +LABEL = "ve_square_vardt" + + +def schedule_dt(t_cur): + """Fine dt within ±WINDOW of any flip; plateau dt elsewhere.""" + flip_times = [HALF_PERIOD * (k + 1) for k in range(N_PERIODS * 2 - 1)] + for f in flip_times: + if abs(t_cur - f) <= WINDOW: + return DT_FINE + return DT_PLATEAU + + +def _run_one(bdf_order): + params = dict(DEFAULT_PARAMS) + params["bdf_order"] = bdf_order + mesh, stokes, V_top, params = build_stokes(f"{LABEL}_o{bdf_order}", params) + + times, dts, sigmas, gammas, reasons = [], [], [], [], [] + t_cur = 0.0 + t0 = time.time() + while t_cur < T_END - 1e-9: + dt = schedule_dt(t_cur) + # Don't step past a flip boundary or past T_END + flip_next = next((HALF_PERIOD * (k + 1) for k in range(N_PERIODS * 2) + if HALF_PERIOD * (k + 1) > t_cur + 1e-9), T_END) + dt = min(dt, flip_next - t_cur, T_END - t_cur) + t_end_step = t_cur + dt + n_half = int(t_end_step / HALF_PERIOD - 1e-9) + sign = 1.0 if n_half % 2 == 0 else -1.0 + v_now = sign * V0 + V_top.sym = sympy.Float(v_now) + stokes.constitutive_model.Parameters.dt_elastic = dt + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=2) + sigmas.append(probe_centre(stokes)) + t_cur = t_end_step + times.append(t_cur); dts.append(dt); gammas.append(2.0 * v_now / params["H"]) + reasons.append(int(stokes.snes.getConvergedReason())) + return (np.array(times), np.array(dts), np.array(sigmas), + np.array(gammas), np.array(reasons), time.time() - t0, params) + + +def main(): + times1, dts1, sig1, gam1, rea1, wall1, params = _run_one(1) + times2, dts2, sig2, gam2, rea2, wall2, _ = _run_one(2) + assert np.allclose(times1, times2) + gamma_dot_0 = 2.0 * V0 / params["H"] + sigma_ana = maxwell_square_wave(times1, params["eta"], params["mu"], + gamma_dot_0, HALF_PERIOD) + err1 = error_metrics(sig1, sigma_ana) + err2 = error_metrics(sig2, sigma_ana) + print(f"[{LABEL}] steps={len(times1)} σ_ss=η·γ̇₀={params['eta']*gamma_dot_0:.4f}") + print(f" schedule: plateau dt={DT_PLATEAU}, fine dt={DT_FINE} (×{DT_FINE_RATIO}), window=±{WINDOW}") + print(f" BDF-1 wall={wall1:.1f}s max|err|={err1['max_abs']:.4e} rms={err1['rms']:.4e}") + print(f" BDF-2 wall={wall2:.1f}s max|err|={err2['max_abs']:.4e} rms={err2['rms']:.4e}") + + save_run( + LABEL, + params=params, + params_extra=dict( + V0=V0, half_period=HALF_PERIOD, n_periods=N_PERIODS, + gamma_dot_0=gamma_dot_0, t_end=T_END, + dt_plateau=DT_PLATEAU, dt_fine=DT_FINE, dt_fine_ratio=DT_FINE_RATIO, + window=WINDOW, + err_max_bdf1=err1["max_abs"], err_rms_bdf1=err1["rms"], + err_max_bdf2=err2["max_abs"], err_rms_bdf2=err2["rms"], + wall_bdf1=wall1, wall_bdf2=wall2, + ), + times=times1, dts=dts1, gamma_dot=gam1, sigma_ana=sigma_ana, + sigma_bdf1=sig1, sigma_bdf2=sig2, + snes_reasons_bdf1=rea1, snes_reasons_bdf2=rea2, + ) + + +if __name__ == "__main__": + main() diff --git a/docs/advanced/benchmarks/bench_vep_square.py b/docs/advanced/benchmarks/bench_vep_square.py new file mode 100644 index 000000000..dce4177ae --- /dev/null +++ b/docs/advanced/benchmarks/bench_vep_square.py @@ -0,0 +1,110 @@ +"""Benchmark: visco-elastic-plastic shear under square-wave forcing. + +Same drive as :mod:`bench_ve_square` but with Min-mode plasticity +(yield stress :math:`\\tau_y < \\eta\\dot\\gamma_0`). The closed-form +solution is the *clipped* version of the VE square-wave: within each +half-period the stress evolves under Maxwell exponentially toward +:math:`\\pm\\eta\\dot\\gamma_0`, but is held at :math:`\\pm\\tau_y` while the +material is yielding. When the BC reverses, the next half-period +starts from the (clipped) value :math:`\\pm\\tau_y`. + +Closed form +----------- +.. math:: + \\sigma(t) = \\mathrm{clip}\\bigl(s_n\\sigma_{\\mathrm{ss}} + + (\\sigma_{0,n} - s_n\\sigma_{\\mathrm{ss}})\\, e^{-(t-t_n)/t_r},\\, + -\\tau_y, +\\tau_y\\bigr) + +with :math:`\\sigma_{0,n} = \\mathrm{clip}(\\sigma(t_n), \\pm\\tau_y)` — +i.e.\\ each new half-period starts from the clipped value at the +previous boundary. + +Run +--- +``pixi run -e amr-dev python docs/advanced/benchmarks/bench_vep_square.py`` + +Output: ``output/benchmarks/vep_square.npz``. +""" + +import time +import numpy as np +import sympy +from _bench_helpers import ( + DEFAULT_PARAMS, t_relax, build_stokes, probe_centre, + vep_square_wave, save_run, error_metrics, +) + + +V0 = 0.5 +TAU_Y = 0.5 # < η·γ̇₀ = 1, so material yields +HALF_PERIOD = 2.0 +N_PERIODS = 4 +DT = 0.10 +T_END = N_PERIODS * 2.0 * HALF_PERIOD + +LABEL = "vep_square" + + +def _run_one(bdf_order): + params = dict(DEFAULT_PARAMS) + params["bdf_order"] = bdf_order + mesh, stokes, V_top, params = build_stokes( + f"{LABEL}_o{bdf_order}", params, + yield_stress=TAU_Y, yield_mode="min", + ) + times, dts, sigmas, gammas, reasons = [], [], [], [], [] + t_cur = 0.0 + t0 = time.time() + while t_cur < T_END - 1e-9: + dt = min(DT, T_END - t_cur) + n_half = int((t_cur + 0.5 * dt) / HALF_PERIOD) + sign = 1.0 if n_half % 2 == 0 else -1.0 + v_now = sign * V0 + V_top.sym = sympy.Float(v_now) + stokes.constitutive_model.Parameters.dt_elastic = dt + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=2) + sigmas.append(probe_centre(stokes)) + t_cur += dt + times.append(t_cur); dts.append(dt); gammas.append(2.0 * v_now / params["H"]) + reasons.append(int(stokes.snes.getConvergedReason())) + return (np.array(times), np.array(dts), np.array(sigmas), + np.array(gammas), np.array(reasons), time.time() - t0, params) + + +def main(): + times1, dts1, sig1, gam1, rea1, wall1, params = _run_one(1) + times2, dts2, sig2, gam2, rea2, wall2, _ = _run_one(2) + assert np.allclose(times1, times2) + gamma_dot_0 = 2.0 * V0 / params["H"] + sigma_ana = vep_square_wave(times1, params["eta"], params["mu"], + gamma_dot_0, TAU_Y, HALF_PERIOD) + err1 = error_metrics(sig1, sigma_ana) + err2 = error_metrics(sig2, sigma_ana) + peak1 = float(np.abs(sig1).max()) + peak2 = float(np.abs(sig2).max()) + over1 = int((np.abs(sig1) > 1.001 * TAU_Y).sum()) + over2 = int((np.abs(sig2) > 1.001 * TAU_Y).sum()) + print(f"[{LABEL}] steps={len(times1)} τ_y={TAU_Y} η·γ̇₀={params['eta']*gamma_dot_0:.4f}") + print(f" BDF-1 wall={wall1:.1f}s peak|σ|={peak1:.4f} over={over1} max|err|={err1['max_abs']:.4e} rms={err1['rms']:.4e}") + print(f" BDF-2 wall={wall2:.1f}s peak|σ|={peak2:.4f} over={over2} max|err|={err2['max_abs']:.4e} rms={err2['rms']:.4e}") + + save_run( + LABEL, + params=params, + params_extra=dict( + V0=V0, half_period=HALF_PERIOD, n_periods=N_PERIODS, + tau_y=TAU_Y, gamma_dot_0=gamma_dot_0, t_end=T_END, dt_nominal=DT, + peak_bdf1=peak1, peak_bdf2=peak2, + n_over_bdf1=over1, n_over_bdf2=over2, + err_max_bdf1=err1["max_abs"], err_rms_bdf1=err1["rms"], + err_max_bdf2=err2["max_abs"], err_rms_bdf2=err2["rms"], + wall_bdf1=wall1, wall_bdf2=wall2, + ), + times=times1, dts=dts1, gamma_dot=gam1, sigma_ana=sigma_ana, + sigma_bdf1=sig1, sigma_bdf2=sig2, + snes_reasons_bdf1=rea1, snes_reasons_bdf2=rea2, + ) + + +if __name__ == "__main__": + main() diff --git a/docs/advanced/benchmarks/bench_vep_square_vardt.py b/docs/advanced/benchmarks/bench_vep_square_vardt.py new file mode 100644 index 000000000..e6f07512c --- /dev/null +++ b/docs/advanced/benchmarks/bench_vep_square_vardt.py @@ -0,0 +1,114 @@ +"""Variable-dt VEP square-wave benchmark. + +VEP analogue of :mod:`bench_ve_square_vardt`. The combination — Min-mode +plasticity, sharp BC discontinuities, and a 10× dt change around each +flip — is the regime that originally exhibited the variable-dt +yield-surface drift before the projection-snapshot fix. This benchmark +verifies that, with the fix in place, the simulation hits the analytical +clipped solution to the same accuracy as a fixed-dt run. + +Run:: + + pixi run -e amr-dev python docs/advanced/benchmarks/bench_vep_square_vardt.py +""" + +import time +import numpy as np +import sympy +from _bench_helpers import ( + DEFAULT_PARAMS, build_stokes, probe_centre, + vep_square_wave, save_run, error_metrics, +) + + +V0 = 0.5 +TAU_Y = 0.5 +HALF_PERIOD = 2.0 +N_PERIODS = 4 +T_END = N_PERIODS * 2.0 * HALF_PERIOD + +DT_PLATEAU = 0.10 +DT_FINE_RATIO = 0.10 +DT_FINE = DT_PLATEAU * DT_FINE_RATIO +WINDOW = 0.1 * HALF_PERIOD + +LABEL = "vep_square_vardt" + + +def schedule_dt(t_cur): + flip_times = [HALF_PERIOD * (k + 1) for k in range(N_PERIODS * 2 - 1)] + for f in flip_times: + if abs(t_cur - f) <= WINDOW: + return DT_FINE + return DT_PLATEAU + + +def _run_one(bdf_order): + params = dict(DEFAULT_PARAMS) + params["bdf_order"] = bdf_order + mesh, stokes, V_top, params = build_stokes( + f"{LABEL}_o{bdf_order}", params, + yield_stress=TAU_Y, yield_mode="min", + ) + times, dts, sigmas, gammas, reasons = [], [], [], [], [] + t_cur = 0.0 + t0 = time.time() + while t_cur < T_END - 1e-9: + dt = schedule_dt(t_cur) + flip_next = next((HALF_PERIOD * (k + 1) for k in range(N_PERIODS * 2) + if HALF_PERIOD * (k + 1) > t_cur + 1e-9), T_END) + dt = min(dt, flip_next - t_cur, T_END - t_cur) + t_end_step = t_cur + dt + n_half = int(t_end_step / HALF_PERIOD - 1e-9) + sign = 1.0 if n_half % 2 == 0 else -1.0 + v_now = sign * V0 + V_top.sym = sympy.Float(v_now) + stokes.constitutive_model.Parameters.dt_elastic = dt + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=2) + sigmas.append(probe_centre(stokes)) + t_cur = t_end_step + times.append(t_cur); dts.append(dt); gammas.append(2.0 * v_now / params["H"]) + reasons.append(int(stokes.snes.getConvergedReason())) + return (np.array(times), np.array(dts), np.array(sigmas), + np.array(gammas), np.array(reasons), time.time() - t0, params) + + +def main(): + times1, dts1, sig1, gam1, rea1, wall1, params = _run_one(1) + times2, dts2, sig2, gam2, rea2, wall2, _ = _run_one(2) + assert np.allclose(times1, times2) + gamma_dot_0 = 2.0 * V0 / params["H"] + sigma_ana = vep_square_wave(times1, params["eta"], params["mu"], + gamma_dot_0, TAU_Y, HALF_PERIOD) + err1 = error_metrics(sig1, sigma_ana) + err2 = error_metrics(sig2, sigma_ana) + peak1 = float(np.abs(sig1).max()); peak2 = float(np.abs(sig2).max()) + over1 = int((np.abs(sig1) > 1.001 * TAU_Y).sum()) + over2 = int((np.abs(sig2) > 1.001 * TAU_Y).sum()) + print(f"[{LABEL}] steps={len(times1)} τ_y={TAU_Y} η·γ̇₀={params['eta']*gamma_dot_0:.4f}") + print(f" schedule: plateau dt={DT_PLATEAU}, fine dt={DT_FINE} (×{DT_FINE_RATIO}), window=±{WINDOW}") + print(f" BDF-1 wall={wall1:.1f}s peak|σ|={peak1:.4f} over={over1} max|err|={err1['max_abs']:.4e} rms={err1['rms']:.4e}") + print(f" BDF-2 wall={wall2:.1f}s peak|σ|={peak2:.4f} over={over2} max|err|={err2['max_abs']:.4e} rms={err2['rms']:.4e}") + + save_run( + LABEL, + params=params, + params_extra=dict( + V0=V0, half_period=HALF_PERIOD, n_periods=N_PERIODS, + tau_y=TAU_Y, gamma_dot_0=gamma_dot_0, t_end=T_END, + dt_plateau=DT_PLATEAU, dt_fine=DT_FINE, dt_fine_ratio=DT_FINE_RATIO, + window=WINDOW, + peak_bdf1=peak1, peak_bdf2=peak2, + n_over_bdf1=over1, n_over_bdf2=over2, + err_max_bdf1=err1["max_abs"], err_rms_bdf1=err1["rms"], + err_max_bdf2=err2["max_abs"], err_rms_bdf2=err2["rms"], + wall_bdf1=wall1, wall_bdf2=wall2, + ), + times=times1, dts=dts1, gamma_dot=gam1, sigma_ana=sigma_ana, + sigma_bdf1=sig1, sigma_bdf2=sig2, + snes_reasons_bdf1=rea1, snes_reasons_bdf2=rea2, + ) + + +if __name__ == "__main__": + main() diff --git a/docs/advanced/benchmarks/bench_vep_square_vardt_minres_softjac.py b/docs/advanced/benchmarks/bench_vep_square_vardt_minres_softjac.py new file mode 100644 index 000000000..f790339d6 --- /dev/null +++ b/docs/advanced/benchmarks/bench_vep_square_vardt_minres_softjac.py @@ -0,0 +1,166 @@ +"""VEP variable-dt with Min residual and softmin Jacobian. + +Hypothesis: at the yield kink, Newton stalls because the true Min-mode +Jacobian has a slope discontinuity, so each Newton step is throttled by +line search and DIVERGED_MAX_IT fires (despite the residual already being +below any sensible tolerance). + +Try inexact Newton: keep the residual F1 = ``2·η_min·ε̇ + BDF-history`` +(so the answer lands on the true yield surface), but autodiff a softmin +version of the same expression to build the uu / up Jacobian blocks. +The Jacobian is then continuous; Newton sees no kink; convergence +should be at-or-near 1 iteration per step. + +Counterfactual: bench_vep_square_vardt.py (same problem, full Min for +both residual and Jacobian) recorded 4/413 BDF-1 steps as +DIVERGED_MAX_IT. We expect 0 here. +""" + +import time +import numpy as np +import sympy +from _bench_helpers import ( + DEFAULT_PARAMS, build_stokes, probe_centre, + vep_square_wave, save_run, error_metrics, +) + + +V0 = 0.5 +TAU_Y = 0.5 +HALF_PERIOD = 2.0 +N_PERIODS = 4 +T_END = N_PERIODS * 2.0 * HALF_PERIOD + +DT_PLATEAU = 0.10 +DT_FINE_RATIO = 0.10 +DT_FINE = DT_PLATEAU * DT_FINE_RATIO +WINDOW = 0.1 * HALF_PERIOD +JAC_SOFTNESS = 0.1 + +LABEL = "vep_square_vardt_minres_softjac" + + +def schedule_dt(t_cur): + flip_times = [HALF_PERIOD * (k + 1) for k in range(N_PERIODS * 2 - 1)] + for f in flip_times: + if abs(t_cur - f) <= WINDOW: + return DT_FINE + return DT_PLATEAU + + +def _capture_softmin_F1(stokes, softness): + """Build the alternative F1 that uses softmin viscosity throughout. + + The current ``stokes.F1.sym`` is built from ``cm.flux`` which uses + whichever yield_mode is set on the constitutive model. Briefly + flip the mode to ``softmin`` to grab the alternative ``cm.flux`` + expression, then restore. + """ + cm = stokes.constitutive_model + saved_mode = cm._yield_mode + saved_softness = cm._yield_softness + try: + cm._yield_mode = "softmin" + cm._yield_softness = softness + # Replicate F1.sym = stress + penalty * div_u * I, but using + # the freshly-recomputed (softmin) stress. + soft_stress = cm.flux + F1_softmin = soft_stress + stokes.penalty * stokes.div_u * sympy.eye(stokes.mesh.dim) + finally: + cm._yield_mode = saved_mode + cm._yield_softness = saved_softness + return F1_softmin + + +def _run_one(bdf_order): + params = dict(DEFAULT_PARAMS) + params["bdf_order"] = bdf_order + mesh, stokes, V_top, params = build_stokes( + f"{LABEL}_o{bdf_order}", params, + yield_stress=TAU_Y, yield_mode="min", + ) + # Inexact Newton: softmin Jacobian, Min residual. + # ``set_jacobian_F1_source`` defaults to installing the ``cp`` + # (critical-point) linesearch, which is the right pairing for an + # inexact Jacobian — the default ``bt`` rejects useful steps as + # ``DIVERGED_LINE_SEARCH`` because they don't strictly reduce the + # Min residual (only the softmin one). + F1_jac = _capture_softmin_F1(stokes, JAC_SOFTNESS) + stokes.set_jacobian_F1_source(F1_jac) + + times, dts, sigmas, gammas, reasons, iters = [], [], [], [], [], [] + t_cur = 0.0 + t0 = time.time() + while t_cur < T_END - 1e-9: + dt = schedule_dt(t_cur) + flip_next = next((HALF_PERIOD * (k + 1) for k in range(N_PERIODS * 2) + if HALF_PERIOD * (k + 1) > t_cur + 1e-9), T_END) + dt = min(dt, flip_next - t_cur, T_END - t_cur) + t_end_step = t_cur + dt + n_half = int(t_end_step / HALF_PERIOD - 1e-9) + sign = 1.0 if n_half % 2 == 0 else -1.0 + v_now = sign * V0 + V_top.sym = sympy.Float(v_now) + stokes.constitutive_model.Parameters.dt_elastic = dt + # divergence_retries=0 to expose true Newton behaviour. + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=0) + sigmas.append(probe_centre(stokes)) + t_cur = t_end_step + times.append(t_cur); dts.append(dt); gammas.append(2.0 * v_now / params["H"]) + reasons.append(int(stokes.snes.getConvergedReason())) + iters.append(int(stokes.snes.getIterationNumber())) + return (np.array(times), np.array(dts), np.array(sigmas), + np.array(gammas), np.array(reasons), np.array(iters), + time.time() - t0, params) + + +def main(): + times1, dts1, sig1, gam1, rea1, its1, wall1, params = _run_one(1) + times2, dts2, sig2, gam2, rea2, its2, wall2, _ = _run_one(2) + assert np.allclose(times1, times2) + + gamma_dot_0 = 2.0 * V0 / params["H"] + sigma_ana = vep_square_wave(times1, params["eta"], params["mu"], + gamma_dot_0, TAU_Y, HALF_PERIOD) + err1 = error_metrics(sig1, sigma_ana) + err2 = error_metrics(sig2, sigma_ana) + peak1 = float(np.abs(sig1).max()); peak2 = float(np.abs(sig2).max()) + over1 = int((np.abs(sig1) > 1.001 * TAU_Y).sum()) + over2 = int((np.abs(sig2) > 1.001 * TAU_Y).sum()) + div1 = int((rea1 < 0).sum()) + div2 = int((rea2 < 0).sum()) + print(f"[{LABEL}] steps={len(times1)} τ_y={TAU_Y} η·γ̇₀={params['eta']*gamma_dot_0:.4f}") + print(f" schedule: plateau dt={DT_PLATEAU}, fine dt={DT_FINE} (×{DT_FINE_RATIO}), window=±{WINDOW}") + print(f" Jacobian: softmin (δ={JAC_SOFTNESS}), residual: Min") + print(f" BDF-1 wall={wall1:.1f}s peak|σ|={peak1:.4f} over={over1} " + f"max|err|={err1['max_abs']:.4e} rms={err1['rms']:.4e} " + f"diverged={div1} mean_its={its1.mean():.2f}") + print(f" BDF-2 wall={wall2:.1f}s peak|σ|={peak2:.4f} over={over2} " + f"max|err|={err2['max_abs']:.4e} rms={err2['rms']:.4e} " + f"diverged={div2} mean_its={its2.mean():.2f}") + + save_run( + LABEL, + params=params, + params_extra=dict( + V0=V0, half_period=HALF_PERIOD, n_periods=N_PERIODS, + tau_y=TAU_Y, gamma_dot_0=gamma_dot_0, t_end=T_END, + dt_plateau=DT_PLATEAU, dt_fine=DT_FINE, dt_fine_ratio=DT_FINE_RATIO, + window=WINDOW, jac_softness=JAC_SOFTNESS, + peak_bdf1=peak1, peak_bdf2=peak2, + n_over_bdf1=over1, n_over_bdf2=over2, + n_diverged_bdf1=div1, n_diverged_bdf2=div2, + mean_its_bdf1=float(its1.mean()), mean_its_bdf2=float(its2.mean()), + err_max_bdf1=err1["max_abs"], err_rms_bdf1=err1["rms"], + err_max_bdf2=err2["max_abs"], err_rms_bdf2=err2["rms"], + wall_bdf1=wall1, wall_bdf2=wall2, + ), + times=times1, dts=dts1, gamma_dot=gam1, sigma_ana=sigma_ana, + sigma_bdf1=sig1, sigma_bdf2=sig2, + snes_reasons_bdf1=rea1, snes_reasons_bdf2=rea2, + snes_iters_bdf1=its1, snes_iters_bdf2=its2, + ) + + +if __name__ == "__main__": + main() diff --git a/docs/advanced/benchmarks/bench_vep_square_vardt_softmin.py b/docs/advanced/benchmarks/bench_vep_square_vardt_softmin.py new file mode 100644 index 000000000..9e02b2d41 --- /dev/null +++ b/docs/advanced/benchmarks/bench_vep_square_vardt_softmin.py @@ -0,0 +1,113 @@ +"""Variable-dt VEP square-wave benchmark — *softmin* yield mode. + +Counterpart to bench_vep_square_vardt (Min) and +bench_vep_square_vardt_smooth (smooth blend). Same dt schedule and +forcing; the only change is ``yield_mode = "softmin"`` with the +default softness δ = 0.1. + +Softmin replaces ``Min(η_ve, η_pl)`` with the smooth approximation + + η_eff = η_ve / g(f), g(f) = 1 + (f − 1 + √((f−1)² + δ²))/2 − offset + +which converges to true Min as δ → 0. Default δ = 0.1 keeps continuous +derivatives at the yield kink while staying close to Min in magnitude. +""" + +import time +import numpy as np +import sympy +from _bench_helpers import ( + DEFAULT_PARAMS, build_stokes, probe_centre, + vep_square_wave, save_run, error_metrics, +) + + +V0 = 0.5 +TAU_Y = 0.5 +HALF_PERIOD = 2.0 +N_PERIODS = 4 +T_END = N_PERIODS * 2.0 * HALF_PERIOD + +DT_PLATEAU = 0.10 +DT_FINE_RATIO = 0.10 +DT_FINE = DT_PLATEAU * DT_FINE_RATIO +WINDOW = 0.1 * HALF_PERIOD + +LABEL = "vep_square_vardt_softmin" + + +def schedule_dt(t_cur): + flip_times = [HALF_PERIOD * (k + 1) for k in range(N_PERIODS * 2 - 1)] + for f in flip_times: + if abs(t_cur - f) <= WINDOW: + return DT_FINE + return DT_PLATEAU + + +def _run_one(bdf_order): + params = dict(DEFAULT_PARAMS) + params["bdf_order"] = bdf_order + mesh, stokes, V_top, params = build_stokes( + f"{LABEL}_o{bdf_order}", params, + yield_stress=TAU_Y, yield_mode="softmin", + ) + times, dts, sigmas, gammas, reasons = [], [], [], [], [] + t_cur = 0.0 + t0 = time.time() + while t_cur < T_END - 1e-9: + dt = schedule_dt(t_cur) + flip_next = next((HALF_PERIOD * (k + 1) for k in range(N_PERIODS * 2) + if HALF_PERIOD * (k + 1) > t_cur + 1e-9), T_END) + dt = min(dt, flip_next - t_cur, T_END - t_cur) + t_end_step = t_cur + dt + n_half = int(t_end_step / HALF_PERIOD - 1e-9) + sign = 1.0 if n_half % 2 == 0 else -1.0 + v_now = sign * V0 + V_top.sym = sympy.Float(v_now) + stokes.constitutive_model.Parameters.dt_elastic = dt + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=2) + sigmas.append(probe_centre(stokes)) + t_cur = t_end_step + times.append(t_cur); dts.append(dt); gammas.append(2.0 * v_now / params["H"]) + reasons.append(int(stokes.snes.getConvergedReason())) + return (np.array(times), np.array(dts), np.array(sigmas), + np.array(gammas), np.array(reasons), time.time() - t0, params) + + +def main(): + times1, dts1, sig1, gam1, rea1, wall1, params = _run_one(1) + times2, dts2, sig2, gam2, rea2, wall2, _ = _run_one(2) + assert np.allclose(times1, times2) + gamma_dot_0 = 2.0 * V0 / params["H"] + sigma_ana_min = vep_square_wave(times1, params["eta"], params["mu"], + gamma_dot_0, TAU_Y, HALF_PERIOD) + err1 = error_metrics(sig1, sigma_ana_min) + err2 = error_metrics(sig2, sigma_ana_min) + peak1 = float(np.abs(sig1).max()); peak2 = float(np.abs(sig2).max()) + print(f"[{LABEL}] steps={len(times1)} τ_y={TAU_Y} η·γ̇₀={params['eta']*gamma_dot_0:.4f}") + print(f" schedule: plateau dt={DT_PLATEAU}, fine dt={DT_FINE} (×{DT_FINE_RATIO}), window=±{WINDOW}") + print(f" yield_mode = softmin (default δ = 0.1)") + print(f" BDF-1 wall={wall1:.1f}s peak|σ|={peak1:.4f} ({100*peak1/TAU_Y:.1f}% of τ_y) max|err vs Min-clip|={err1['max_abs']:.4e} rms={err1['rms']:.4e}") + print(f" BDF-2 wall={wall2:.1f}s peak|σ|={peak2:.4f} ({100*peak2/TAU_Y:.1f}% of τ_y) max|err vs Min-clip|={err2['max_abs']:.4e} rms={err2['rms']:.4e}") + + save_run( + LABEL, + params=params, + params_extra=dict( + V0=V0, half_period=HALF_PERIOD, n_periods=N_PERIODS, + tau_y=TAU_Y, gamma_dot_0=gamma_dot_0, t_end=T_END, + dt_plateau=DT_PLATEAU, dt_fine=DT_FINE, dt_fine_ratio=DT_FINE_RATIO, + window=WINDOW, yield_mode="softmin", yield_softness=0.1, + peak_bdf1=peak1, peak_bdf2=peak2, + err_max_bdf1=err1["max_abs"], err_rms_bdf1=err1["rms"], + err_max_bdf2=err2["max_abs"], err_rms_bdf2=err2["rms"], + wall_bdf1=wall1, wall_bdf2=wall2, + ), + times=times1, dts=dts1, gamma_dot=gam1, sigma_ana=sigma_ana_min, + sigma_bdf1=sig1, sigma_bdf2=sig2, + snes_reasons_bdf1=rea1, snes_reasons_bdf2=rea2, + ) + + +if __name__ == "__main__": + main() diff --git a/docs/advanced/benchmarks/check_saved_data.py b/docs/advanced/benchmarks/check_saved_data.py new file mode 100644 index 000000000..66c3b55dd --- /dev/null +++ b/docs/advanced/benchmarks/check_saved_data.py @@ -0,0 +1,85 @@ +"""Verify that the on-disk benchmark npz files contain everything +needed for any plot we'd want — without re-running the simulations. + +Lists keys in each .npz and asserts the per-config files have +``sigma_bdf1``, ``sigma_bdf2``, and ``sigma_ana`` (so the BDF-1 vs +BDF-2 overlay is reproducible from saved data alone), and that the +convergence file has ``trace_*`` arrays for every (order, dt) pair +recorded in the metrics arrays (so any per-run trace from the +convergence sweep is reproducible too). + +Run:: + + pixi run -e amr-dev python docs/advanced/benchmarks/check_saved_data.py +""" + +import os +import sys +import numpy as np +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _bench_helpers import OUTPUT_DIR, load_run + + +def _list_npz_keys(name): + path = f"{OUTPUT_DIR}/{name}.npz" + if not os.path.exists(path): + return None + with np.load(path, allow_pickle=True) as f: + return list(f.keys()) + + +def _check_per_case(name): + arrays, params, extra = load_run(name) + needed_arrays = {"times", "dts", "gamma_dot", "sigma_ana", + "sigma_bdf1", "sigma_bdf2"} + have = set(arrays.keys()) + missing = needed_arrays - have + print(f"\n[{name}.npz]") + print(f" arrays: {sorted(have)}") + print(f" params keys: {sorted(params.keys())}") + print(f" extra keys: {sorted(extra.keys())}") + if missing: + print(f" MISSING: {sorted(missing)}") + return False + print(" OK — has both BDF traces + analytical reference") + return True + + +def _check_convergence(name): + arrays, params, extra = load_run(name) + n_runs = len(arrays["order"]) + print(f"\n[{name}.npz]") + print(f" metrics arrays: order, dt, n_steps, max_abs, rms, wall ({n_runs} runs)") + expected_traces = [] + for order, dt in zip(arrays["order"], arrays["dt"]): + tag = f"o{int(order)}_dt{float(dt):.4f}" + expected_traces += [f"trace_t_{tag}", f"trace_sigma_{tag}", f"trace_ana_{tag}"] + have = set(arrays.keys()) + missing = [t for t in expected_traces if t not in have] + print(f" expected {len(expected_traces)} trace arrays; have {len(expected_traces) - len(missing)}") + if missing: + print(f" MISSING: {missing[:6]}{' …' if len(missing) > 6 else ''}") + return False + print(" OK — every (order, dt) trace is on disk") + return True + + +def main(): + ok = True + for name in ("ve_harmonic", "ve_square", "vep_square"): + if _list_npz_keys(name) is None: + print(f"\n[{name}.npz] not on disk — skipping") + continue + ok = _check_per_case(name) and ok + for name in ("convergence_ve_harmonic", "convergence_ve_square", + "convergence_vep_square"): + if _list_npz_keys(name) is None: + print(f"\n[{name}.npz] not on disk — skipping") + continue + ok = _check_convergence(name) and ok + print("\n=== overall:", "OK" if ok else "FAIL", "===") + sys.exit(0 if ok else 1) + + +if __name__ == "__main__": + main() diff --git a/docs/advanced/benchmarks/index.md b/docs/advanced/benchmarks/index.md new file mode 100644 index 000000000..cc987bad2 --- /dev/null +++ b/docs/advanced/benchmarks/index.md @@ -0,0 +1,61 @@ +--- +title: "Benchmarks" +--- + +# Solver Benchmarks + +Validation benchmarks comparing Underworld3 solvers against +closed-form analytical solutions. Each benchmark has three pieces: + +* a `bench_*.py` runner that solves the problem and writes a + self-contained `.npz` log to `output/benchmarks/`, +* `plot_benchmarks.py` that reads the logs and produces consistent-style + figures in `docs/advanced/figures/`, +* a Markdown page (this section) that documents the governing + equation, the closed-form solution, the test setup, and the result. + +The runner and the plotter are deliberately decoupled: each runner +saves the per-step trace, both BDF orders, the analytical reference, +and the parameter dict in one self-contained file; re-running the +plot script to tweak style does not re-run the (slow) simulation. +A separate `bench_convergence.py` runs each case at a sweep of +timestep sizes (and both BDF orders) and saves all per-run traces so +the convergence figure and any per-(order, dt) replot are equally +reproducible from saved data. + +## Workflow + +```bash +# Run a single per-case benchmark (both BDF orders, ~3-6 min each) +pixi run -e amr-dev python docs/advanced/benchmarks/bench_ve_harmonic.py +pixi run -e amr-dev python docs/advanced/benchmarks/bench_ve_square.py +pixi run -e amr-dev python docs/advanced/benchmarks/bench_vep_square.py + +# Run the convergence sweep (~30 min, all dts × both orders × all cases) +pixi run -e amr-dev python docs/advanced/benchmarks/bench_convergence.py + +# Replot from saved data — does NOT re-run simulations +pixi run -e amr-dev python docs/advanced/benchmarks/plot_benchmarks.py + +# Verify the on-disk data is complete (used as a sanity check before +# claiming a benchmark suite is "done") +pixi run -e amr-dev python docs/advanced/benchmarks/check_saved_data.py +``` + +## Cases + +```{toctree} +:maxdepth: 1 + +ve-harmonic +ve-square +vep-square +vardt-square +``` + +| Case | Driving | Closed form | What it tests | +|---|---|---|---| +| `ve-harmonic` | $V_{\mathrm{top}} = V_0\cos(\omega t + \varphi)$ | $A_\infty\cos\omega t$ | amplitude attenuation, phase lag, peak-start IC | +| `ve-square` | square-wave $V_{\mathrm{top}}$ | piecewise exponential | BDF history at BC discontinuities | +| `vep-square` | square-wave with yield | clipped Maxwell square-wave | Min-mode plasticity, projection-snapshot fix | +| `vardt-square` | square-wave + reduced $\Delta t$ near flips | same as `ve-square` / `vep-square` | snapshot machinery under variable timestep | diff --git a/docs/advanced/benchmarks/jit_cache_vs_recompile.py b/docs/advanced/benchmarks/jit_cache_vs_recompile.py new file mode 100644 index 000000000..854b9c4a6 --- /dev/null +++ b/docs/advanced/benchmarks/jit_cache_vs_recompile.py @@ -0,0 +1,87 @@ +"""Benchmark for issue #123: parameter-update recompile. + +Reproduces the VE square-wave toggle pattern from +https://github.com/underworldcode/underworld3/issues/123 — the original +report measured ~459 s of JIT time vs ~3.7 s of actual SNES solve over +99 timesteps that only changed dt_elastic and the top BC sign. + +With the C-source-hash JIT cache in place, the JIT time should collapse +to roughly the cost of the first cold compile (~10 s on this hardware), +because every later step generates the same C source and hits the cache. + +Run with: + + pixi run -e amr-dev python -u docs/advanced/benchmarks/jit_cache_vs_recompile.py + +Optional: set ``UW_JIT_CACHE=0`` to disable the disk cache and time the +in-memory cache only; or ``rm -rf ~/.cache/underworld3/jit`` first to +force a full cold start. +""" + +import time + +import sympy + +import underworld3 as uw +import underworld3.timing as uw_timing +from underworld3.function import expression +from underworld3.utilities._jitextension import _ext_dict + + +N_STEPS = 30 +ELEMENT_RES = (16, 8) + + +def main(): + mesh = uw.meshing.StructuredQuadBox( + elementRes=ELEMENT_RES, + minCoords=(0.0, 0.0), + maxCoords=(1.0, 0.5), + ) + + v = uw.discretisation.MeshVariable("V", mesh, mesh.dim, degree=2) + p = uw.discretisation.MeshVariable("P", mesh, 1, degree=1) + + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + stokes.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + stokes.Unknowns, order=2, + ) + stokes.constitutive_model.Parameters.shear_viscosity_0 = 1.0 + stokes.constitutive_model.Parameters.shear_modulus = 1.0 + + V_top = expression("V_top", 0.5, "Top BC amplitude") + stokes.add_dirichlet_bc((V_top, 0.0), "Top") + stokes.add_dirichlet_bc((0.0, 0.0), "Bottom") + stokes.add_dirichlet_bc((sympy.oo, 0.0), "Left") + stokes.add_dirichlet_bc((sympy.oo, 0.0), "Right") + + uw_timing.start() + t_total = time.time() + + n_compiles_initial = len(_ext_dict) + step_times = [] + for step in range(N_STEPS): + V_top.sym = sympy.Float(0.5 * (-1) ** step) + stokes.constitutive_model.Parameters.dt_elastic = 0.02 + t_step = time.time() + stokes.solve(zero_init_guess=False, timestep=0.02) + step_times.append(time.time() - t_step) + + elapsed = time.time() - t_total + n_compiles_after = len(_ext_dict) + + uw.pprint(f"\n=== JIT cache benchmark — issue #123 reproducer ===") + uw.pprint(f"Steps : {N_STEPS}") + uw.pprint(f"Mesh : {ELEMENT_RES}, BDF-2 VE Stokes") + uw.pprint(f"Wall time (total) : {elapsed:.1f} s") + uw.pprint(f"First step (cold) : {step_times[0]:.2f} s") + uw.pprint(f"Mean of remaining : {sum(step_times[1:]) / (N_STEPS - 1):.2f} s/step") + uw.pprint(f"Compiled bundles : {n_compiles_after - n_compiles_initial}") + uw.pprint(f" (issue #123: would have shown ~3 new compiles per step " + f"= ~{N_STEPS * 3} total before this fix)") + + uw_timing.print_summary() + + +if __name__ == "__main__": + main() diff --git a/docs/advanced/benchmarks/plot_benchmarks.py b/docs/advanced/benchmarks/plot_benchmarks.py new file mode 100644 index 000000000..42be45370 --- /dev/null +++ b/docs/advanced/benchmarks/plot_benchmarks.py @@ -0,0 +1,371 @@ +"""Plot the VE/VEP benchmark results from on-disk ``.npz`` files. + +Reads ``output/benchmarks/{ve_harmonic,ve_square,vep_square}.npz`` and +produces three figures in ``docs/advanced/figures/``. Style is shared +across the three so the plots can be compared directly. + +Each figure has the same layout: + + Top panel: σ_xy(t) — simulation markers, analytical solid line, + ±τ_y guide for the VEP case, light-blue filled driving + term γ̇(t) for context (rescaled to fit beside σ). + Middle panel: |error| log-scale. + Bottom panel: dt(t) (relevant once we add variable-dt benchmarks). + +Run after one or more of ``bench_*.py`` have produced their npz: + + pixi run -e amr-dev python docs/advanced/benchmarks/plot_benchmarks.py +""" + +import os +import numpy as np +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +from _bench_helpers import load_run, FIG_DIR + + +# Shared style --------------------------------------------------------- +plt.rcParams.update({ + "figure.figsize": (11, 8), + "axes.grid": True, + "grid.alpha": 0.3, + "axes.titlesize": 11, + "axes.labelsize": 11, + "legend.fontsize": 9, + "legend.framealpha": 0.92, +}) + +C_BDF1 = "#1F77B4" # blue circles — BDF-1 +C_BDF2 = "#D62728" # red squares — BDF-2 +C_ANA = "black" # solid black — analytical +C_DRIVE = "#1F77B4" # light blue — driving (filled) +C_ERR_BDF1 = "#1F77B4" +C_ERR_BDF2 = "#D62728" +C_DT = "#2CA02C" # green — dt +C_YIELD = "grey" + + +def _plot_three_panel(name, t_ana_grid, sigma_ana_grid, info, *, tau_y=None): + """Common three-panel layout used by all three benchmarks. + + Reads BDF-1 + BDF-2 traces from one npz and overlays both. The + npz's per-step arrays are: ``sigma_bdf1``, ``sigma_bdf2``, + ``sigma_ana``. + """ + arrays, params, extra = info + times = arrays["times"] + sigma_ana = arrays["sigma_ana"] + dts = arrays["dts"] + gamma_dot = arrays["gamma_dot"] + sigma_bdf1 = arrays["sigma_bdf1"] + sigma_bdf2 = arrays["sigma_bdf2"] + + fig, (ax_top, ax_err, ax_dt) = plt.subplots( + 3, 1, sharex=True, + gridspec_kw={"height_ratios": [3.5, 1.5, 1.0]}, + ) + + # If this is a variable-dt run, derive the fine-dt windows from + # the saved dt array (any step with dt < 0.5*max(dt) is "fine"), + # and shade those regions on every panel so the schedule is + # visually unambiguous against the marker density. + fine_thresh = 0.5 * float(np.max(dts)) + fine_mask = dts < fine_thresh + fine_windows = [] # list of (t_start, t_end) + if fine_mask.any() and not fine_mask.all(): + in_window = False + w_start = None + for i, (t, is_fine) in enumerate(zip(times, fine_mask)): + t_start = t - dts[i] + if is_fine and not in_window: + w_start = t_start + in_window = True + elif not is_fine and in_window: + fine_windows.append((w_start, times[i - 1])) + in_window = False + if in_window: + fine_windows.append((w_start, times[-1])) + for (a, b) in fine_windows: + for ax in (ax_top, ax_err, ax_dt): + ax.axvspan(a, b, color="0.85", alpha=0.5, linewidth=0, zorder=0) + + # --- Top: σ(t) + sigma_max = float(np.max(np.abs(sigma_ana_grid))) or 1.0 + gamma_max = float(np.max(np.abs(gamma_dot))) or 1.0 + drive_scale = 0.5 * sigma_max / gamma_max + ax_top.fill_between( + times, 0.0, drive_scale * gamma_dot, + color=C_DRIVE, alpha=0.18, linewidth=0, + label=fr"driving $\dot\gamma(t)$ (×{drive_scale:.2f})", + ) + ax_top.plot(t_ana_grid, sigma_ana_grid, "-", color=C_ANA, lw=1.4, + label="analytical") + ax_top.plot(times, sigma_bdf1, "o", color=C_BDF1, ms=4.2, alpha=0.78, + mec=C_BDF1, mfc="white", mew=1.3, + label="BDF-1") + ax_top.plot(times, sigma_bdf2, "s", color=C_BDF2, ms=3.8, alpha=0.85, + label="BDF-2") + if tau_y is not None: + ax_top.axhline(+tau_y, color=C_YIELD, ls="--", lw=0.9, alpha=0.7, + label=fr"$\pm\tau_y$ = $\pm${tau_y:g}") + ax_top.axhline(-tau_y, color=C_YIELD, ls="--", lw=0.9, alpha=0.7) + ax_top.axhline(0, color="grey", lw=0.4, alpha=0.4) + ax_top.set_ylabel(r"$\sigma_{xy}$") + + # Title with the headline numbers per order + bits = [name] + if "err_max_bdf1" in extra: + bits.append(fr"BDF-1 max|err|={extra['err_max_bdf1']:.2e}") + if "err_max_bdf2" in extra: + bits.append(fr"BDF-2 max|err|={extra['err_max_bdf2']:.2e}") + if "De" in extra: + bits.append(fr"De={extra['De']:.3f}") + if "tau_y" in extra: + bits.append(fr"$\tau_y={extra['tau_y']:g}$") + ax_top.set_title(" ".join(bits)) + ax_top.legend(loc="lower right", ncol=2) + + # --- Middle: |sigma − sigma_ana| for both orders + err1 = np.abs(sigma_bdf1 - sigma_ana) + err2 = np.abs(sigma_bdf2 - sigma_ana) + eps = 1e-9 + ax_err.semilogy(times, np.maximum(err1, eps), "-", color=C_ERR_BDF1, + lw=0.8, marker="o", ms=2.8, mec=C_ERR_BDF1, mfc="white", + label="BDF-1") + ax_err.semilogy(times, np.maximum(err2, eps), "-", color=C_ERR_BDF2, + lw=0.8, marker="s", ms=2.8, label="BDF-2") + ax_err.set_ylabel(r"$|\sigma_{\mathrm{sim}} - \sigma_{\mathrm{ana}}|$") + ax_err.legend(loc="upper right", ncol=2, fontsize=8) + ax_err.set_ylim(bottom=eps * 0.9) + + # --- Bottom: dt + ax_dt.step(times, dts, where="post", color=C_DT, lw=1.1) + ax_dt.set_xlabel(r"Time $t / t_r$") + ax_dt.set_ylabel(r"$\Delta t$") + ax_dt.set_ylim(0.0, max(dts) * 1.1) + + plt.tight_layout() + return fig + + +def plot_ve_harmonic(): + arrays, params, extra = load_run("ve_harmonic") + eta, mu = params["eta"], params["mu"] + omega = extra["omega"] + De = omega * eta / mu + gd0 = extra["gamma_dot_0"] + A_inf = eta * gd0 / np.sqrt(1.0 + De**2) + # Fine analytical grid for the smooth curve. The bench uses the + # peak-start IC: V_top(t) = V_0·cos(ωt + φ) with φ = arctan(De), so + # the steady-state σ_ss(t) = A_∞·cos(ωt). No transient. + t_grid = np.linspace(0, arrays["times"][-1], 2000) + sigma_grid = A_inf * np.cos(omega * t_grid) + fig = _plot_three_panel("VE harmonic", t_grid, sigma_grid, + (arrays, params, extra)) + ax_top = fig.axes[0] + info = ( + f"Amplitude ana={extra['A_ana']:.4f}\n" + f" BDF-1={extra['A_bdf1']:.4f} BDF-2={extra['A_bdf2']:.4f}\n" + f"Phase lag ana={extra['phi_ana']:.4f}\n" + f" BDF-1={extra['phi_bdf1']:.4f} BDF-2={extra['phi_bdf2']:.4f}" + ) + ax_top.text(0.02, 0.97, info, transform=ax_top.transAxes, + ha="left", va="top", + fontsize=8.5, family="monospace", + bbox=dict(facecolor="white", edgecolor="0.7", alpha=0.92, + boxstyle="round,pad=0.4")) + out = f"{FIG_DIR}/bench_ve_harmonic.png" + fig.savefig(out, dpi=150, bbox_inches="tight") + plt.close(fig) + print(f" wrote {out}") + + +def plot_ve_square(): + arrays, params, extra = load_run("ve_square") + eta, mu = params["eta"], params["mu"] + half_period = extra["half_period"] + gd0 = extra["gamma_dot_0"] + from _bench_helpers import maxwell_square_wave + t_grid = np.linspace(0, arrays["times"][-1], 2000) + sigma_grid = maxwell_square_wave(t_grid, eta, mu, gd0, half_period) + fig = _plot_three_panel("VE square wave", t_grid, sigma_grid, + (arrays, params, extra)) + out = f"{FIG_DIR}/bench_ve_square.png" + fig.savefig(out, dpi=150, bbox_inches="tight") + plt.close(fig) + print(f" wrote {out}") + + +def plot_vep_square(): + arrays, params, extra = load_run("vep_square") + eta, mu = params["eta"], params["mu"] + half_period = extra["half_period"] + tau_y = extra["tau_y"] + gd0 = extra["gamma_dot_0"] + from _bench_helpers import vep_square_wave + t_grid = np.linspace(0, arrays["times"][-1], 2000) + sigma_grid = vep_square_wave(t_grid, eta, mu, gd0, tau_y, half_period) + fig = _plot_three_panel("VEP square wave (Min mode)", t_grid, sigma_grid, + (arrays, params, extra), tau_y=tau_y) + out = f"{FIG_DIR}/bench_vep_square.png" + fig.savefig(out, dpi=150, bbox_inches="tight") + plt.close(fig) + print(f" wrote {out}") + + +def plot_ve_square_vardt(): + arrays, params, extra = load_run("ve_square_vardt") + eta, mu = params["eta"], params["mu"] + half_period = extra["half_period"] + gd0 = extra["gamma_dot_0"] + from _bench_helpers import maxwell_square_wave + t_grid = np.linspace(0, arrays["times"][-1], 4000) + sigma_grid = maxwell_square_wave(t_grid, eta, mu, gd0, half_period) + title = ( + f"VE square wave — variable dt " + fr"($\Delta t_{{\rm plat}}={extra['dt_plateau']:g}$, " + fr"$\Delta t_{{\rm fine}}={extra['dt_fine']:g}$, ±{extra['window']:g})" + ) + fig = _plot_three_panel(title, t_grid, sigma_grid, (arrays, params, extra)) + out = f"{FIG_DIR}/bench_ve_square_vardt.png" + fig.savefig(out, dpi=150, bbox_inches="tight") + plt.close(fig) + print(f" wrote {out}") + + +def plot_vep_square_vardt(): + arrays, params, extra = load_run("vep_square_vardt") + eta, mu = params["eta"], params["mu"] + half_period = extra["half_period"] + tau_y = extra["tau_y"] + gd0 = extra["gamma_dot_0"] + from _bench_helpers import vep_square_wave + t_grid = np.linspace(0, arrays["times"][-1], 4000) + sigma_grid = vep_square_wave(t_grid, eta, mu, gd0, tau_y, half_period) + title = ( + f"VEP square wave (Min) — variable dt " + fr"($\Delta t_{{\rm plat}}={extra['dt_plateau']:g}$, " + fr"$\Delta t_{{\rm fine}}={extra['dt_fine']:g}$, ±{extra['window']:g})" + ) + fig = _plot_three_panel(title, t_grid, sigma_grid, + (arrays, params, extra), tau_y=tau_y) + out = f"{FIG_DIR}/bench_vep_square_vardt.png" + fig.savefig(out, dpi=150, bbox_inches="tight") + plt.close(fig) + print(f" wrote {out}") + + +def plot_vep_square_vardt_softmin(): + arrays, params, extra = load_run("vep_square_vardt_softmin") + eta, mu = params["eta"], params["mu"] + half_period = extra["half_period"] + tau_y = extra["tau_y"] + gd0 = extra["gamma_dot_0"] + from _bench_helpers import vep_square_wave + t_grid = np.linspace(0, arrays["times"][-1], 4000) + sigma_grid = vep_square_wave(t_grid, eta, mu, gd0, tau_y, half_period) + title = ( + fr"VEP square wave (softmin, $\delta=0.1$) — variable dt " + fr"($\Delta t_{{\rm plat}}={extra['dt_plateau']:g}$, " + fr"$\Delta t_{{\rm fine}}={extra['dt_fine']:g}$, ±{extra['window']:g})" + ) + fig = _plot_three_panel(title, t_grid, sigma_grid, + (arrays, params, extra), tau_y=tau_y) + out = f"{FIG_DIR}/bench_vep_square_vardt_softmin.png" + fig.savefig(out, dpi=150, bbox_inches="tight") + plt.close(fig) + print(f" wrote {out}") + + +def plot_vep_square_vardt_smooth(): + arrays, params, extra = load_run("vep_square_vardt_smooth") + eta, mu = params["eta"], params["mu"] + half_period = extra["half_period"] + tau_y = extra["tau_y"] + gd0 = extra["gamma_dot_0"] + from _bench_helpers import vep_square_wave + t_grid = np.linspace(0, arrays["times"][-1], 4000) + # Reference shown is the Min-clipped solution — useful as a guide, + # but smooth mode is expected to under-clip below ±τ_y. + sigma_grid = vep_square_wave(t_grid, eta, mu, gd0, tau_y, half_period) + title = ( + f"VEP square wave (smooth) — variable dt " + fr"($\Delta t_{{\rm plat}}={extra['dt_plateau']:g}$, " + fr"$\Delta t_{{\rm fine}}={extra['dt_fine']:g}$, ±{extra['window']:g})" + ) + fig = _plot_three_panel(title, t_grid, sigma_grid, + (arrays, params, extra), tau_y=tau_y) + out = f"{FIG_DIR}/bench_vep_square_vardt_smooth.png" + fig.savefig(out, dpi=150, bbox_inches="tight") + plt.close(fig) + print(f" wrote {out}") + + +def plot_convergence(): + """Three-panel log-log convergence plot, one panel per case.""" + fig, axes = plt.subplots(1, 3, figsize=(14, 4.5), sharey=True) + cases = [ + ("convergence_ve_harmonic", "VE harmonic"), + ("convergence_ve_square", "VE square wave"), + ("convergence_vep_square", "VEP square wave"), + ] + for ax, (name, title) in zip(axes, cases): + try: + arrays, params, extra = load_run(name) + except FileNotFoundError: + ax.set_title(f"{title} — no data") + continue + order = arrays["order"]; dt = arrays["dt"] + max_abs = arrays["max_abs"]; rms = arrays["rms"] + + for o, marker, lbl_color in [(1, "o", C_BDF1), (2, "s", C_BDF2)]: + mask = order == o + if not mask.any(): + continue + d = dt[mask]; e = max_abs[mask]; r = rms[mask] + ax.loglog(d, e, marker=marker, color=lbl_color, ms=7, + lw=1.5, label=fr"BDF-{o} max$|\,\mathrm{{err}}\,|$") + ax.loglog(d, r, marker=marker, color=lbl_color, ms=5, + lw=1.0, ls=":", alpha=0.7, + label=fr"BDF-{o} rms") + + # Reference slopes — a guide line through the smallest-dt BDF-2 max-abs + if (order == 2).any(): + mask2 = order == 2 + d_ref = float(dt[mask2].min()) + e_ref = float(max_abs[mask2][np.argmin(dt[mask2])]) + d_grid = np.array([dt.min() * 0.7, dt.max() * 1.3]) + ax.loglog(d_grid, e_ref * (d_grid / d_ref) ** 2, + "k--", lw=0.8, alpha=0.5, label=r"slope 2") + ax.loglog(d_grid, e_ref * (d_grid / d_ref) ** 1, + "k:", lw=0.8, alpha=0.5, label=r"slope 1") + ax.set_title(title) + ax.set_xlabel(r"$\Delta t$") + ax.grid(True, which="both", alpha=0.3) + ax.legend(loc="lower right", fontsize=8) + + axes[0].set_ylabel("error") + plt.tight_layout() + out = f"{FIG_DIR}/bench_convergence.png" + fig.savefig(out, dpi=150, bbox_inches="tight") + plt.close(fig) + print(f" wrote {out}") + + +if __name__ == "__main__": + os.makedirs(FIG_DIR, exist_ok=True) + for plotter, name in [ + (plot_ve_harmonic, "ve_harmonic"), + (plot_ve_square, "ve_square"), + (plot_vep_square, "vep_square"), + (plot_ve_square_vardt, "ve_square_vardt"), + (plot_vep_square_vardt, "vep_square_vardt"), + (plot_vep_square_vardt_softmin, "vep_square_vardt_softmin"), + (plot_vep_square_vardt_smooth, "vep_square_vardt_smooth"), + (plot_convergence, "convergence"), + ]: + try: + plotter() + except FileNotFoundError: + print(f" skipping {name} — no .npz on disk") diff --git a/docs/advanced/benchmarks/plot_ti_vep_harmonic.py b/docs/advanced/benchmarks/plot_ti_vep_harmonic.py new file mode 100644 index 000000000..6edd607bd --- /dev/null +++ b/docs/advanced/benchmarks/plot_ti_vep_harmonic.py @@ -0,0 +1,122 @@ +"""Plot TI-VEP harmonic angled-fault benchmark traces from saved npz. + +Reads the σ=0-IC results saved by ``bench_ti_vep_harmonic_zeroIC.py`` +and produces one combined figure showing global σ_xy and resolved +fault-plane shear over time, for the three fault angles +(θ ∈ {0°, +15°, -15°}) and two yield stresses (τ_y ∈ {0.15, 0.30}). + +Output: ``docs/advanced/figures/bench_ti_vep_harmonic.png`` +""" + +import os +import numpy as np +import matplotlib +if not os.environ.get('DISPLAY') and not os.environ.get('WAYLAND_DISPLAY'): + matplotlib.use('Agg') +import matplotlib.pyplot as plt + +from _bench_helpers import OUTPUT_DIR, FIG_DIR + + +ANGLES = (0.0, 15.0, -15.0) +TAU_YS = (0.15, 0.30) + + +def _load(theta_deg, tau_y): + tag = f"ti_vep_harmonic_zIC_th{theta_deg:+.0f}_ty{tau_y:.2f}".replace(".", "p") + return np.load(os.path.join(OUTPUT_DIR, f"{tag}.npz")) + + +def main(): + fig, axes = plt.subplots( + len(TAU_YS), len(ANGLES), + figsize=(15, 8), sharex=True, sharey='row', + ) + + for row, ty in enumerate(TAU_YS): + for col, theta in enumerate(ANGLES): + ax = axes[row, col] + d = _load(theta, ty) + t = d['times'] + sxy_1 = d['sigma_xy_bdf1'] + sxy_2 = d['sigma_xy_bdf2'] + tres_1 = d['tau_resolved_bdf1'] + tres_2 = d['tau_resolved_bdf2'] + + # Reconstruct V_top(t) and the VE-no-yield envelope from + # saved scalars. Note: the bench's saved ``sigma_ve`` was + # computed with γ̇_0 = 2·V_0/H — wrong for these BCs (Top + # moves, Bottom fixed → γ̇_0 = V_0/H). Recompute here. + V0 = float(d['V0']) + omega = float(d['OMEGA']) + eta_1 = float(d['ETA_1']); mu = float(d['MU']) + t_r = eta_1 / mu + De = omega * t_r + phi = float(np.arctan(De)) + H = 1.0 # domain height in the bench + gamma_dot_0 = V0 / H + A_inf = 2.0 * eta_1 * (gamma_dot_0 / 2.0) / np.sqrt(1.0 + De**2) + # ↑ σ = 2η ε̇ ↑ ε̇ = γ̇/2 (tensor strain rate) + sigma_ve = A_inf * np.cos(omega * t) + v_top = V0 * np.cos(omega * t + phi) + + # Light-blue filled driving overlay, rescaled to half-peak σ + sig_max = max(float(np.abs(sxy_1).max()), + float(np.abs(sigma_ve).max())) or 1.0 + drive_scale = 0.5 * sig_max / V0 + ax.fill_between( + t, 0.0, drive_scale * v_top, + color="#1F77B4", alpha=0.18, linewidth=0, + label=fr"driving $V_{{\rm top}}(t)$ (×{drive_scale:.2f})", + ) + + # VE no-yield envelope (light grey, dashed) + ax.plot(t, sigma_ve, ':', color='0.4', linewidth=1, + label=r'VE (no yield)') + + # τ_y guidelines + ax.axhline(+ty, color='gray', linestyle=':', alpha=0.6, + linewidth=1, label=rf'$\pm\tau_y={ty}$') + ax.axhline(-ty, color='gray', linestyle=':', alpha=0.6, + linewidth=1) + + # Global σ_xy (BDF-1 line, BDF-2 dots) + ax.plot(t, sxy_1, '-', color='steelblue', linewidth=1.4, + alpha=0.8, label=r'$\sigma_{xy}$ (BDF-1)') + ax.plot(t, sxy_2, 'o', color='steelblue', markersize=2, + markerfacecolor='none', markeredgewidth=0.6, + label=r'$\sigma_{xy}$ (BDF-2)') + + # Resolved fault-plane shear + ax.plot(t, tres_1, '-', color='crimson', linewidth=1.4, + alpha=0.9, label=r'$\tau_{\rm resolved}$ (BDF-1)') + ax.plot(t, tres_2, 's', color='crimson', markersize=2, + markerfacecolor='none', markeredgewidth=0.6, + label=r'$\tau_{\rm resolved}$ (BDF-2)') + + ax.set_title(rf'$\theta = {theta:+.0f}°,\;\tau_y = {ty}$', + fontsize=11) + ax.grid(True, alpha=0.3) + if row == len(TAU_YS) - 1: + ax.set_xlabel(r'Time $t/t_r$') + if col == 0: + ax.set_ylabel(r'Stress') + if row == 0 and col == len(ANGLES) - 1: + ax.legend(fontsize=8, loc='upper right', framealpha=0.9) + + fig.suptitle( + "TI-VEP harmonic shear with embedded fault — " + r"$V_{\rm top}(t) = V_0\cos(\omega t + \varphi)$, " + r"$V_0 = 0.5$, $\omega = \pi/2$, $\eta = \mu = 1$, " + r"$\Delta t = 0.05$", + fontsize=12, y=0.995, + ) + + fig.tight_layout(rect=[0, 0, 1, 0.97]) + out_path = os.path.join(FIG_DIR, "bench_ti_vep_harmonic.png") + fig.savefig(out_path, dpi=150) + print(f" wrote {out_path}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/docs/advanced/benchmarks/probe_constitutive_eval.py b/docs/advanced/benchmarks/probe_constitutive_eval.py new file mode 100644 index 000000000..7b03a22a5 --- /dev/null +++ b/docs/advanced/benchmarks/probe_constitutive_eval.py @@ -0,0 +1,162 @@ +"""Evaluate the constitutive expressions DIRECTLY at the centre, using +specified pre-solve psi_star values. No FE solve involved — just plug +numbers into the symbolic stress() and viscosity formulas. If the +formulas give σ = τ_y under Min mode, the formulas are right. If the +SIM gives σ ≠ τ_y at the same input state, the bug is in solve/project +not in the formulas. +""" + +import numpy as np +import sympy +import underworld3 as uw +from underworld3.function import expression + + +# Build a fresh stokes problem +mesh = uw.meshing.StructuredQuadBox(elementRes=(16, 8), + minCoords=(-1, -0.5), maxCoords=(1, 0.5)) +v = uw.discretisation.MeshVariable("U", mesh, mesh.dim, degree=2) +p = uw.discretisation.MeshVariable("P", mesh, 1, degree=1) +stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) +stokes.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + stokes.Unknowns, order=2, +) +stokes.constitutive_model.Parameters.shear_viscosity_0 = 1.0 +stokes.constitutive_model.Parameters.shear_modulus = 1.0 +stokes.constitutive_model.Parameters.yield_stress = 0.5 +stokes.constitutive_model.Parameters.strainrate_inv_II_min = 1.0e-6 +stokes.constitutive_model._yield_mode = "min" +cm = stokes.constitutive_model +V_top = expression(R"V_{top}", sympy.Float(0.5), "Top V") +stokes.add_dirichlet_bc((V_top, 0.0), "Top") +stokes.add_dirichlet_bc((-V_top, 0.0), "Bottom") +stokes.add_dirichlet_bc((sympy.oo, 0.0), "Left") +stokes.add_dirichlet_bc((sympy.oo, 0.0), "Right") +stokes.tolerance = 1.0e-6 +stokes.petsc_options["snes_force_iteration"] = True + +# Solve a few steps at dt=0.20 to populate u to uniform shear at yield +for _ in range(5): + cm.Parameters.dt_elastic = 0.20 + stokes.solve(zero_init_guess=False, timestep=0.20, divergence_retries=1) + +centre = np.array([[0.0, 0.0]]) +print(f"\nAfter coarse warm-up:") +print(f" σ at centre = {float(uw.function.evaluate(stokes.tau.sym[0,1], centre).flatten()[0]):.4f}") + +# === EXPERIMENT: directly inject specific psi_star values, then EVALUATE +# the constitutive law without solving. We'll then compare with an +# actual solve at the same state. + +# Set ψ*[0] = 0.5 (yielded), ψ*[1] = 0.4268 (pre-yield from coarse step) +stokes.DFDt.psi_star[0].array[:] = 0 +stokes.DFDt.psi_star[0].array[:, 0, 1] = 0.5 +stokes.DFDt.psi_star[0].array[:, 1, 0] = 0.5 +stokes.DFDt.psi_star[1].array[:] = 0 +stokes.DFDt.psi_star[1].array[:, 0, 1] = 0.4268 +stokes.DFDt.psi_star[1].array[:, 1, 0] = 0.4268 +# Force dt_history[0] = 0.20 so that the next step at dt=0.10 is "halving" +stokes.DFDt._dt_history[0] = 0.20 +stokes.DFDt._dt_history[1] = 0.20 + +# Set dt = 0.10 and update BDF coefficients +cm.Parameters.dt_elastic = 0.10 +cm._update_bdf_coefficients() + +print(f"\nAfter setting state:") +print(f" ψ*[0] at centre = {float(uw.function.evaluate(stokes.DFDt.psi_star[0].sym[0,1], centre).flatten()[0]):.4f}") +print(f" ψ*[1] at centre = {float(uw.function.evaluate(stokes.DFDt.psi_star[1].sym[0,1], centre).flatten()[0]):.4f}") +print(f" dt_h[0] = {stokes.DFDt._dt_history[0]}, dt_e = {float(cm.Parameters.dt_elastic.sym):.3f}") +print(f" c_0 = {float(cm._bdf_c0.sym):.4f}") +print(f" c_1 = {float(cm._bdf_c1.sym):.4f}") +print(f" c_2 = {float(cm._bdf_c2.sym):.4f}") + +# Now WITHOUT solving, evaluate the symbolic formulas at the centre. +print("\n=== Direct evaluation of symbolic constitutive formulas ===") +print("(no FE solve; using the velocity field from the warm-up which is uniform shear)") +edot_xy = float(uw.function.evaluate(cm.grad_u[0,1], centre).flatten()[0]) +print(f" ε̇_xy = {edot_xy:.4f}") +E_eff_xy = float(uw.function.evaluate(cm.E_eff.sym[0,1], centre).flatten()[0]) +E_eff_inv_II = float(uw.function.evaluate(cm.E_eff_inv_II.sym, centre).flatten()[0]) +print(f" E_eff_xy = {E_eff_xy:.4f}") +print(f" E_eff_inv_II = {E_eff_inv_II:.4f}") +eta_ve = float(uw.function.evaluate(cm.Parameters.ve_effective_viscosity.sym, centre).flatten()[0]) +eta_pl = float(uw.function.evaluate(cm._plastic_effective_viscosity, centre).flatten()[0]) +eta_min = float(uw.function.evaluate(cm.viscosity, centre).flatten()[0]) +print(f" η_ve = {eta_ve:.4f}") +print(f" η_pl = {eta_pl:.4f}") +print(f" η = Min(η_ve, η_pl) = {eta_min:.4f} (expected min: {min(eta_ve, eta_pl):.4f})") + +# Direct evaluation of the stress() formula +stress_formula = cm.stress() +sigma_xy_direct = float(uw.function.evaluate(stress_formula[0,1], centre).flatten()[0]) +print(f" σ_xy direct evaluation of stress() formula = {sigma_xy_direct:.4f}") +print(f" Predicted from 2·η·E_eff = {2*eta_min*E_eff_xy:.4f}") + +# === Now solve and see what comes out +print("\n=== Run a SOLVE with ψ*[1] = 0.4268 (pre-yield) ===") +stokes.solve(zero_init_guess=False, timestep=0.10, divergence_retries=1) +sigma_after_solve_a = float(uw.function.evaluate(stokes.tau.sym[0,1], centre).flatten()[0]) +print(f" σ_xy after solve = {sigma_after_solve_a:.4f}") + +# === User's hypothesis: ψ*[1] = pre-yield is the issue. Try ψ*[1] = ψ*[0] +# (matches what FINE has — both at yield) +print("\n=== Reset and re-solve with ψ*[1] = ψ*[0] = 0.5 (both at yield) ===") +stokes.DFDt.psi_star[0].array[:] = 0 +stokes.DFDt.psi_star[0].array[:, 0, 1] = 0.5 +stokes.DFDt.psi_star[0].array[:, 1, 0] = 0.5 +stokes.DFDt.psi_star[1].array[:] = 0 +stokes.DFDt.psi_star[1].array[:, 0, 1] = 0.5 +stokes.DFDt.psi_star[1].array[:, 1, 0] = 0.5 +stokes.DFDt._dt_history[0] = 0.20 # still dt-halving +cm.Parameters.dt_elastic = 0.10 +cm._update_bdf_coefficients() +stokes.solve(zero_init_guess=False, timestep=0.10, divergence_retries=1) +sigma_after_solve_b = float(uw.function.evaluate(stokes.tau.sym[0,1], centre).flatten()[0]) +print(f" σ_xy after solve = {sigma_after_solve_b:.4f}") + +# === And also: ψ*[1] = ψ*[0] = 0.5 with dt_history = [0.10, 0.10] (consistent) +print("\n=== Reset, ψ*[1] = ψ*[0] = 0.5, dt_history = [0.10, 0.10] (fully consistent) ===") +stokes.DFDt.psi_star[0].array[:] = 0 +stokes.DFDt.psi_star[0].array[:, 0, 1] = 0.5 +stokes.DFDt.psi_star[0].array[:, 1, 0] = 0.5 +stokes.DFDt.psi_star[1].array[:] = 0 +stokes.DFDt.psi_star[1].array[:, 0, 1] = 0.5 +stokes.DFDt.psi_star[1].array[:, 1, 0] = 0.5 +stokes.DFDt._dt_history[0] = 0.10 +stokes.DFDt._dt_history[1] = 0.10 +cm.Parameters.dt_elastic = 0.10 +cm._update_bdf_coefficients() + +# Probe the symbolic formula once more to confirm Min selection is correct +sigma_direct = float(uw.function.evaluate(cm.stress()[0,1], centre).flatten()[0]) +eta_ve = float(uw.function.evaluate(cm.Parameters.ve_effective_viscosity.sym, centre).flatten()[0]) +eta_pl = float(uw.function.evaluate(cm._plastic_effective_viscosity, centre).flatten()[0]) +eta_min = float(uw.function.evaluate(cm.viscosity, centre).flatten()[0]) +print(f" Direct symbolic eval BEFORE solve: σ = {sigma_direct:.4f}, η_ve={eta_ve:.4f}, η_pl={eta_pl:.4f}, η_min={eta_min:.4f}") + +stokes.solve(zero_init_guess=False, timestep=0.10, divergence_retries=1) +sigma_after_solve_c = float(uw.function.evaluate(stokes.tau.sym[0,1], centre).flatten()[0]) +print(f" σ_xy after solve = {sigma_after_solve_c:.4f}") + +# === Also: solve TWICE (force re-iteration to settle) +print("\n=== Re-solve with same state to see if it self-corrects ===") +stokes.DFDt.psi_star[0].array[:] = 0 +stokes.DFDt.psi_star[0].array[:, 0, 1] = 0.5 +stokes.DFDt.psi_star[0].array[:, 1, 0] = 0.5 +stokes.DFDt.psi_star[1].array[:] = 0 +stokes.DFDt.psi_star[1].array[:, 0, 1] = 0.5 +stokes.DFDt.psi_star[1].array[:, 1, 0] = 0.5 +stokes.DFDt._dt_history[0] = 0.10 +stokes.DFDt._dt_history[1] = 0.10 +cm.Parameters.dt_elastic = 0.10 +cm._update_bdf_coefficients() +for k in range(5): + stokes.solve(zero_init_guess=False, timestep=0.10, divergence_retries=1) + s = float(uw.function.evaluate(stokes.tau.sym[0,1], centre).flatten()[0]) + print(f" After solve {k+1}: σ_xy = {s:.4f}") + +print(f"\n=== Summary ===") +print(f" (a) ψ*=[0.5, 0.4268], dt_h0=0.20 → σ = {sigma_after_solve_a:.4f} (large overshoot)") +print(f" (b) ψ*=[0.5, 0.5000], dt_h0=0.20 → σ = {sigma_after_solve_b:.4f} (clean halving)") +print(f" (c) ψ*=[0.5, 0.5000], dt_h0=0.10 → σ = {sigma_after_solve_c:.4f} (no dt change)") diff --git a/docs/advanced/benchmarks/probe_lockstep_dt.py b/docs/advanced/benchmarks/probe_lockstep_dt.py new file mode 100644 index 000000000..9544af2d5 --- /dev/null +++ b/docs/advanced/benchmarks/probe_lockstep_dt.py @@ -0,0 +1,117 @@ +"""Side-by-side: two simulations of the same physical problem, +one at dt = DT, one at dt = DT/2, stepped in lockstep. + +Compare every comparable quantity at common physical times. +""" + +import numpy as np +import sympy +import underworld3 as uw +from underworld3.function import expression + + +def make_stokes(label): + mesh = uw.meshing.StructuredQuadBox(elementRes=(16, 8), + minCoords=(-1, -0.5), maxCoords=(1, 0.5)) + v = uw.discretisation.MeshVariable(f"U_{label}", mesh, mesh.dim, degree=2) + p = uw.discretisation.MeshVariable(f"P_{label}", mesh, 1, degree=1) + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + stokes.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + stokes.Unknowns, order=2, + ) + stokes.constitutive_model.Parameters.shear_viscosity_0 = 1.0 + stokes.constitutive_model.Parameters.shear_modulus = 1.0 + stokes.constitutive_model.Parameters.yield_stress = 0.5 + stokes.constitutive_model.Parameters.strainrate_inv_II_min = 1.0e-6 + stokes.constitutive_model._yield_mode = "min" + V_top = expression(rf"V_{{{label},top}}", sympy.Float(0.5), "Top V") + stokes.add_dirichlet_bc((V_top, 0.0), "Top") + stokes.add_dirichlet_bc((-V_top, 0.0), "Bottom") + stokes.add_dirichlet_bc((sympy.oo, 0.0), "Left") + stokes.add_dirichlet_bc((sympy.oo, 0.0), "Right") + stokes.tolerance = 1.0e-6 + stokes.petsc_options["snes_force_iteration"] = True + return stokes, V_top + + +def probe(stokes, centre): + cm = stokes.constitutive_model + out = {} + out['sigma'] = float(uw.function.evaluate(stokes.tau.sym[0, 1], centre).flatten()[0]) + out['edot'] = float(uw.function.evaluate(cm.grad_u[0, 1], centre).flatten()[0]) + out['psi0'] = float(uw.function.evaluate(stokes.DFDt.psi_star[0].sym[0, 1], centre).flatten()[0]) + out['psi1'] = float(uw.function.evaluate(stokes.DFDt.psi_star[1].sym[0, 1], centre).flatten()[0]) + out['eta_ve'] = float(uw.function.evaluate(cm.Parameters.ve_effective_viscosity.sym, centre).flatten()[0]) + out['eta_pl'] = float(uw.function.evaluate(cm._plastic_effective_viscosity, centre).flatten()[0]) + out['eta'] = float(uw.function.evaluate(cm.viscosity, centre).flatten()[0]) + out['Eeff'] = float(uw.function.evaluate(cm.E_eff.sym[0, 1], centre).flatten()[0]) + out['EeffII'] = float(uw.function.evaluate(cm.E_eff_inv_II.sym, centre).flatten()[0]) + out['c0'] = float(cm._bdf_c0.sym) + out['c1'] = float(cm._bdf_c1.sym) + out['c2'] = float(cm._bdf_c2.sym) + out['dt_h0'] = stokes.DFDt._dt_history[0] + dt_e = cm.Parameters.dt_elastic + if hasattr(dt_e, 'sym'): + dt_e = dt_e.sym + try: + out['dt_e'] = float(dt_e) + except (TypeError, ValueError): + out['dt_e'] = float('nan') + return out + + +def step_one(stokes, V_top, dt, t_cur): + V_top.sym = sympy.Float(0.5) # constant +V0 — no BC flips, just pure loading + stokes.constitutive_model.Parameters.dt_elastic = dt + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=1) + + +def fmt_row(label, t, p): + return (f"{label:6s} t={t:.3f} dt_e={p['dt_e']:.3f} dt_h0={str(p['dt_h0']):>5s} " + f"σ={p['sigma']:.4f} ε̇={p['edot']:.4f} ψ*0={p['psi0']:.4f} ψ*1={p['psi1']:.4f} " + f"E={p['Eeff']:.3f} η_ve={p['eta_ve']:.4f} η_pl={p['eta_pl']:.4f} η={p['eta']:.4f} " + f"c012=[{p['c0']:.3f},{p['c1']:.3f},{p['c2']:.3f}]") + + +# Build three independent problems +print("Building COARSE simulation (always dt = 0.20)...") +coarse, V_top_c = make_stokes("coarse") +print("Building FINE simulation (always dt = 0.10)...") +fine, V_top_f = make_stokes("fine") +print("Building SWITCH simulation (dt = 0.20 → 0.10 at outer step 4)...") +switch, V_top_s = make_stokes("switch") + +centre = np.array([[0.0, 0.0]]) +DT_C = 0.20 +DT_F = 0.10 +N_OUTER = 6 # 6 outer steps; halve in SWITCH starting at outer step 4 +HALVE_AT = 4 + +print(f"\nLockstep: t advances by {DT_C} per outer step.") +print(f" COARSE: 1 step at dt={DT_C}") +print(f" FINE: 2 steps at dt={DT_F}") +print(f" SWITCH: 1 step at dt={DT_C} for outer<{HALVE_AT}; then 2 steps at dt={DT_F}\n") + +t_c, t_f, t_s = 0.0, 0.0, 0.0 +for k in range(N_OUTER): + step_one(coarse, V_top_c, DT_C, t_c) + t_c += DT_C + step_one(fine, V_top_f, DT_F, t_f); t_f += DT_F + step_one(fine, V_top_f, DT_F, t_f); t_f += DT_F + if k < HALVE_AT: + step_one(switch, V_top_s, DT_C, t_s); t_s += DT_C + else: + step_one(switch, V_top_s, DT_F, t_s); t_s += DT_F + step_one(switch, V_top_s, DT_F, t_s); t_s += DT_F + + pc = probe(coarse, centre) + pf = probe(fine, centre) + ps = probe(switch, centre) + marker = " <-- HALVING NOW" if k == HALVE_AT else "" + print(f"--- outer step {k+1}, t = {t_c:.3f} {marker}") + print(fmt_row("COARSE", t_c, pc)) + print(fmt_row("FINE", t_f, pf)) + print(fmt_row("SWITCH", t_s, ps)) + print(f" σ: coarse={pc['sigma']:.4f} fine={pf['sigma']:.4f} switch={ps['sigma']:.4f} " + f"Δ(switch-fine)={ps['sigma']-pf['sigma']:+.4f} Δ(switch-coarse)={ps['sigma']-pc['sigma']:+.4f}") + print() diff --git a/docs/advanced/benchmarks/probe_projection_drift.py b/docs/advanced/benchmarks/probe_projection_drift.py new file mode 100644 index 000000000..1b159b3a9 --- /dev/null +++ b/docs/advanced/benchmarks/probe_projection_drift.py @@ -0,0 +1,148 @@ +"""Replace the implicit projection of flux→psi_star[0] with a direct +one-shot pointwise evaluation. Test whether the drift disappears. + +Procedure per step: + 1. Snapshot pre-solve psi_star[0] as `ps0_pre` (this is what ψ*[1] will + become after the shift). + 2. Replace `_psi_star_projection_solver.solve` with a no-op so the main + stokes.solve() does NOT update ψ*[0]. + 3. Run stokes.solve() — Newton finds u, projection is a no-op, then + the shift sets ψ*[1] = ps0_pre. At this point ψ*[0] is still ps0_pre. + 4. Evaluate cm.flux at sample points using the just-solved u and the + frozen pre-solve ψ*[0]. This is a pure forward computation (no + fixed-point feedback). + 5. Assign the evaluated flux to ψ*[0].array. + +For our uniform-shear test, the field is uniform so step 4 just samples +the centre and step 5 assigns uniformly. +""" + +import numpy as np +import sympy +import underworld3 as uw +from underworld3.function import expression + + +def make_stokes(label): + mesh = uw.meshing.StructuredQuadBox(elementRes=(16, 8), + minCoords=(-1, -0.5), maxCoords=(1, 0.5)) + v = uw.discretisation.MeshVariable(f"U_{label}", mesh, mesh.dim, degree=2) + p = uw.discretisation.MeshVariable(f"P_{label}", mesh, 1, degree=1) + s = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + s.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + s.Unknowns, order=2, + ) + s.constitutive_model.Parameters.shear_viscosity_0 = 1.0 + s.constitutive_model.Parameters.shear_modulus = 1.0 + s.constitutive_model.Parameters.yield_stress = 0.5 + s.constitutive_model.Parameters.strainrate_inv_II_min = 1.0e-6 + s.constitutive_model._yield_mode = "min" + Vt = expression(rf"V_{{{label}}}", sympy.Float(0.5), "Top V") + s.add_dirichlet_bc((Vt, 0.0), "Top") + s.add_dirichlet_bc((-Vt, 0.0), "Bottom") + s.add_dirichlet_bc((sympy.oo, 0.0), "Left") + s.add_dirichlet_bc((sympy.oo, 0.0), "Right") + s.tolerance = 1.0e-6 + s.petsc_options["snes_force_iteration"] = True + return s, Vt + + +def patched_solve(stokes, dt, V_top, V_sign=1.0): + """Run the standard solve, then OVERWRITE psi_star[0] with the manually + computed σ from the formula evaluated against PRE-solve psi_star.""" + cm = stokes.constitutive_model + ddt = stokes.DFDt + centre = np.array([[0.0, 0.0]]) + + V_top.sym = sympy.Float(V_sign * 0.5) + cm.Parameters.dt_elastic = dt + + # Snapshot pre-solve psi_star (BOTH levels) — these are what the formula + # should use for the implicit step + ps0_pre = np.copy(ddt.psi_star[0].array) + ps1_pre = np.copy(ddt.psi_star[1].array) + + # Run the standard solve as-is. This will: + # - Run main Newton (finds u) + # - Run the (buggy) projection that writes into psi_star[0] + # - Shift: psi_star[1] = old (pre-solve) psi_star[0] ← correct + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=1) + + # Now manually compute the analytical σ at this state and overwrite + # the buggy projection's result. Use PRE-solve psi_star values + # in the formula (those are the "history" inputs to the implicit step). + edot_now = float(uw.function.evaluate(cm.grad_u[0, 1], centre).flatten()[0]) + # In our test, fields are uniform so a single node value suffices. + # Take the centre (or any node) of the snapshotted pre-solve arrays. + n_nodes = ps0_pre.shape[0] + ps0_use = float(ps0_pre[n_nodes // 2, 0, 1]) # pre-solve ψ*[0]_xy + ps1_use = float(ps1_pre[n_nodes // 2, 0, 1]) # pre-solve ψ*[1]_xy + c0 = float(cm._bdf_c0.sym); c1 = float(cm._bdf_c1.sym); c2 = float(cm._bdf_c2.sym) + + E_eff_xy = edot_now + (-c1) * ps0_use / (2 * 1 * dt) + (-c2) * ps1_use / (2 * 1 * dt) + eta_ve_manual = 1 * dt / (c0 * 1 + 1 * dt) + eta_pl_manual = 0.5 / (2 * abs(E_eff_xy)) if abs(E_eff_xy) > 1e-12 else 1e9 + eta_min = min(eta_ve_manual, eta_pl_manual) + sigma_manual = 2 * eta_min * E_eff_xy + + # Read what the buggy projection produced (for diagnostic) + sigma_buggy = float(uw.function.evaluate(stokes.tau.sym[0, 1], centre).flatten()[0]) + + print(f" [dt={dt:.3f}: pre ψ*=[{ps0_use:.4f},{ps1_use:.4f}] ε̇={edot_now:.4f} " + f"c=[{c0:.3f},{c1:.3f},{c2:.3f}] manual σ={sigma_manual:.4f} buggy σ={sigma_buggy:.4f}]") + + # Overwrite psi_star[0] with the manual σ (uniform in our test problem) + ddt.psi_star[0].array[:] = 0 + ddt.psi_star[0].array[:, 0, 1] = sigma_manual + ddt.psi_star[0].array[:, 1, 0] = sigma_manual + + +def step_one(stokes, V, dt): + V.sym = sympy.Float(0.5) + stokes.constitutive_model.Parameters.dt_elastic = dt + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=1) + + +def probe(stokes, c=np.array([[0.0, 0.0]])): + cm = stokes.constitutive_model + return { + 'sigma': float(uw.function.evaluate(stokes.tau.sym[0, 1], c).flatten()[0]), + 'edot': float(uw.function.evaluate(cm.grad_u[0, 1], c).flatten()[0]), + 'psi0': float(uw.function.evaluate(stokes.DFDt.psi_star[0].sym[0, 1], c).flatten()[0]), + 'psi1': float(uw.function.evaluate(stokes.DFDt.psi_star[1].sym[0, 1], c).flatten()[0]), + } + + +def fmt(label, p): + return f"{label:14s} σ={p['sigma']:.4f} ε̇={p['edot']:.4f} ψ*0={p['psi0']:.4f} ψ*1={p['psi1']:.4f}" + + +# === Build two simulations: ORIGINAL (uses implicit projection) and PATCHED. + +print("Building ORIGINAL stokes (implicit projection of flux→ψ*[0])...") +orig, V_o = make_stokes("orig") +print("Building PATCHED stokes (direct ptwise assign instead of projection)...") +patched, V_p = make_stokes("patched") + +print("\n=== Phase 1: Drive both to yield steady state at dt=0.20 ===") +for k in range(5): + step_one(orig, V_o, 0.20) + patched_solve(patched, 0.20, V_p) +print(fmt("ORIG (after warm-up)", probe(orig))) +print(fmt("PATCHED (after warm-up)", probe(patched))) + +print("\n=== Phase 2: switch to dt=0.10 (halving). Take 4 steps ===") +for k in range(4): + step_one(orig, V_o, 0.10) + patched_solve(patched, 0.10, V_p) + print(f"Step {k+1} after halving:") + print(" " + fmt("ORIG", probe(orig))) + print(" " + fmt("PATCHED", probe(patched))) + +print("\n=== Phase 3: switch back to dt=0.20 (doubling). Take 4 steps ===") +for k in range(4): + step_one(orig, V_o, 0.20) + patched_solve(patched, 0.20, V_p) + print(f"Step {k+1} after doubling:") + print(" " + fmt("ORIG", probe(orig))) + print(" " + fmt("PATCHED", probe(patched))) diff --git a/docs/advanced/benchmarks/sweep_bdf1_softjac.py b/docs/advanced/benchmarks/sweep_bdf1_softjac.py new file mode 100644 index 000000000..7516e7e34 --- /dev/null +++ b/docs/advanced/benchmarks/sweep_bdf1_softjac.py @@ -0,0 +1,136 @@ +"""Follow-up sweep — BDF-1 inexact-Newton softness sensitivity. + +Sister to ``sweep_bdf2_softjac.py``. In the headline experiment the +BDF-1 case (Min residual, softmin Jacobian δ=0.1) was perfect: 0/413 +divergences, 1.02 mean iter/step, identical answer to pure Min/Min. +That's already optimal so we don't expect changes — but we do want +to confirm that BDF-1 numerics are insensitive to δ. If that holds, +the BDF-2 line-search noise we're chasing is specifically about +residual/Jacobian disagreement at the second history term, not about +"smoother Jacobians help everywhere." + +Sweep: δ ∈ {0.05, 0.10, 0.20, 0.50}, with bt linesearch, no atol. +""" + +import os +import time +import numpy as np +import sympy + +from _bench_helpers import ( + DEFAULT_PARAMS, build_stokes, probe_centre, + vep_square_wave, error_metrics, +) + + +V0 = 0.5 +TAU_Y = 0.5 +HALF_PERIOD = 2.0 +N_PERIODS = 4 +T_END = N_PERIODS * 2.0 * HALF_PERIOD +DT_PLATEAU = 0.10 +DT_FINE = 0.01 +WINDOW = 0.1 * HALF_PERIOD +OUT_DIR = "../../../output/benchmarks/sweep_bdf2_softjac" + + +def schedule_dt(t_cur): + flip_times = [HALF_PERIOD * (k + 1) for k in range(N_PERIODS * 2 - 1)] + for f in flip_times: + if abs(t_cur - f) <= WINDOW: + return DT_FINE + return DT_PLATEAU + + +def _capture_softmin_F1(stokes, softness): + cm = stokes.constitutive_model + saved_mode, saved_softness = cm._yield_mode, cm._yield_softness + try: + cm._yield_mode = "softmin" + cm._yield_softness = softness + soft_stress = cm.flux + F1_softmin = soft_stress + stokes.penalty * stokes.div_u * sympy.eye(stokes.mesh.dim) + finally: + cm._yield_mode, cm._yield_softness = saved_mode, saved_softness + return F1_softmin + + +def run_variant(label, softness): + params = dict(DEFAULT_PARAMS) + params["bdf_order"] = 1 + mesh, stokes, V_top, params = build_stokes( + f"sweep_{label}", params, yield_stress=TAU_Y, yield_mode="min", + ) + + F1_jac = _capture_softmin_F1(stokes, softness) + stokes.set_jacobian_F1_source(F1_jac) + stokes.petsc_options["snes_linesearch_type"] = "bt" + + times, dts, sigmas, gammas, reasons, iters = [], [], [], [], [], [] + t_cur = 0.0 + t0 = time.time() + while t_cur < T_END - 1e-9: + dt = schedule_dt(t_cur) + flip_next = next((HALF_PERIOD * (k + 1) for k in range(N_PERIODS * 2) + if HALF_PERIOD * (k + 1) > t_cur + 1e-9), T_END) + dt = min(dt, flip_next - t_cur, T_END - t_cur) + t_end_step = t_cur + dt + n_half = int(t_end_step / HALF_PERIOD - 1e-9) + sign = 1.0 if n_half % 2 == 0 else -1.0 + v_now = sign * V0 + V_top.sym = sympy.Float(v_now) + stokes.constitutive_model.Parameters.dt_elastic = dt + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=0) + sigmas.append(probe_centre(stokes)) + t_cur = t_end_step + times.append(t_cur); dts.append(dt); gammas.append(2.0 * v_now / params["H"]) + reasons.append(int(stokes.snes.getConvergedReason())) + iters.append(int(stokes.snes.getIterationNumber())) + wall = time.time() - t0 + + times = np.array(times); sigmas = np.array(sigmas) + reasons = np.array(reasons); iters = np.array(iters) + gamma_dot_0 = 2.0 * V0 / params["H"] + sigma_ana = vep_square_wave(times, params["eta"], params["mu"], + gamma_dot_0, TAU_Y, HALF_PERIOD) + err = error_metrics(sigmas, sigma_ana) + + out = dict( + label=label, softness=softness, wall=wall, + peak=float(np.abs(sigmas).max()), + diverged=int((reasons < 0).sum()), + mean_its=float(iters.mean()), + max_err=float(err["max_abs"]), rms=float(err["rms"]), + ) + os.makedirs(OUT_DIR, exist_ok=True) + np.savez(os.path.join(OUT_DIR, f"{label}.npz"), **{ + "times": times, "sigmas": sigmas, "sigma_ana": sigma_ana, + "reasons": reasons, "iters": iters, + "softness": softness, "linesearch": "bt", "atol": -1.0, + "wall": wall, + }) + print(f" [{label:<28}] δ={softness:<5} ls=bt atol=None " + f"wall={wall:6.1f}s div={out['diverged']:3d} its={out['mean_its']:5.2f} " + f"peak={out['peak']:.4f} max|err|={out['max_err']:.3e} rms={out['rms']:.3e}", + flush=True) + return out + + +def main(): + print("\n=== sweep_bdf1_softjac (BDF-1, Min residual, softmin Jacobian) ===\n", + flush=True) + print("BDF-1 family: vary softness δ", flush=True) + runs = [run_variant(f"bdf1_delta_{d}", softness=d) for d in (0.05, 0.10, 0.20, 0.50)] + + print("\n\n=== summary ===", flush=True) + print(f"{'label':<28} {'δ':>5} {'wall':>7} {'div':>4} {'its':>5} {'peak|σ|':>7} {'max|err|':>10} {'rms':>10}", + flush=True) + for r in runs: + print(f"{r['label']:<28} {r['softness']:>5} {r['wall']:>7.1f} " + f"{r['diverged']:>4d} {r['mean_its']:>5.2f} " + f"{r['peak']:>7.4f} {r['max_err']:>10.3e} {r['rms']:>10.3e}", + flush=True) + + +if __name__ == "__main__": + main() diff --git a/docs/advanced/benchmarks/sweep_bdf2_softjac.py b/docs/advanced/benchmarks/sweep_bdf2_softjac.py new file mode 100644 index 000000000..4c93d2256 --- /dev/null +++ b/docs/advanced/benchmarks/sweep_bdf2_softjac.py @@ -0,0 +1,160 @@ +"""Sweep — BDF-2 inexact-Newton line-search behaviour with softmin Jacobian. + +Background: with Min residual + softmin Jacobian (δ=0.1), BDF-2 sees +195/413 line-search rejections and gives a more accurate answer than +pure Min/Min. Question: can we keep the accuracy and lose the noise? + +Axes: + A. Softness δ ∈ {0.05, 0.1, 0.2, 0.5} (Jacobian-only smoothing) + B. snes_linesearch_type ∈ {bt, cp, basic} (basic = no LS, accept Newton) + C. snes_atol ∈ {None, 1e-5} (early termination if residual tiny) + +We DON'T sweep the full cross product — too expensive. Run two +families in series, each with the matched axis fixed at the baseline +(δ=0.1, bt, atol=None): + Family A: vary δ + Family B: vary linesearch + Family C: try atol=1e-5 with the baseline + +Results land in output/benchmarks/sweep_bdf2_softjac/ — one .npz per +variant — and a summary table is printed at the end. +""" + +import os +import time +import numpy as np +import sympy + +from _bench_helpers import ( + DEFAULT_PARAMS, build_stokes, probe_centre, + vep_square_wave, error_metrics, +) + + +V0 = 0.5 +TAU_Y = 0.5 +HALF_PERIOD = 2.0 +N_PERIODS = 4 +T_END = N_PERIODS * 2.0 * HALF_PERIOD + +DT_PLATEAU = 0.10 +DT_FINE = 0.01 +WINDOW = 0.1 * HALF_PERIOD + +OUT_DIR = "../../../output/benchmarks/sweep_bdf2_softjac" + + +def schedule_dt(t_cur): + flip_times = [HALF_PERIOD * (k + 1) for k in range(N_PERIODS * 2 - 1)] + for f in flip_times: + if abs(t_cur - f) <= WINDOW: + return DT_FINE + return DT_PLATEAU + + +def _capture_softmin_F1(stokes, softness): + cm = stokes.constitutive_model + saved_mode, saved_softness = cm._yield_mode, cm._yield_softness + try: + cm._yield_mode = "softmin" + cm._yield_softness = softness + soft_stress = cm.flux + F1_softmin = soft_stress + stokes.penalty * stokes.div_u * sympy.eye(stokes.mesh.dim) + finally: + cm._yield_mode, cm._yield_softness = saved_mode, saved_softness + return F1_softmin + + +def run_variant(label, softness, linesearch="bt", atol=None): + """Run one BDF-2 variant. Returns dict with metrics + arrays.""" + params = dict(DEFAULT_PARAMS) + params["bdf_order"] = 2 + mesh, stokes, V_top, params = build_stokes( + f"sweep_{label}", params, yield_stress=TAU_Y, yield_mode="min", + ) + + F1_jac = _capture_softmin_F1(stokes, softness) + stokes.set_jacobian_F1_source(F1_jac) + stokes.petsc_options["snes_linesearch_type"] = linesearch + if atol is not None: + stokes.petsc_options["snes_atol"] = atol + + times, dts, sigmas, gammas, reasons, iters = [], [], [], [], [], [] + t_cur = 0.0 + t0 = time.time() + while t_cur < T_END - 1e-9: + dt = schedule_dt(t_cur) + flip_next = next((HALF_PERIOD * (k + 1) for k in range(N_PERIODS * 2) + if HALF_PERIOD * (k + 1) > t_cur + 1e-9), T_END) + dt = min(dt, flip_next - t_cur, T_END - t_cur) + t_end_step = t_cur + dt + n_half = int(t_end_step / HALF_PERIOD - 1e-9) + sign = 1.0 if n_half % 2 == 0 else -1.0 + v_now = sign * V0 + V_top.sym = sympy.Float(v_now) + stokes.constitutive_model.Parameters.dt_elastic = dt + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=0) + sigmas.append(probe_centre(stokes)) + t_cur = t_end_step + times.append(t_cur); dts.append(dt); gammas.append(2.0 * v_now / params["H"]) + reasons.append(int(stokes.snes.getConvergedReason())) + iters.append(int(stokes.snes.getIterationNumber())) + wall = time.time() - t0 + + times = np.array(times); sigmas = np.array(sigmas) + reasons = np.array(reasons); iters = np.array(iters) + gamma_dot_0 = 2.0 * V0 / params["H"] + sigma_ana = vep_square_wave(times, params["eta"], params["mu"], + gamma_dot_0, TAU_Y, HALF_PERIOD) + err = error_metrics(sigmas, sigma_ana) + + out = dict( + label=label, softness=softness, linesearch=linesearch, atol=atol, + wall=wall, peak=float(np.abs(sigmas).max()), + diverged=int((reasons < 0).sum()), mean_its=float(iters.mean()), + max_err=float(err["max_abs"]), rms=float(err["rms"]), + times=times, sigmas=sigmas, sigma_ana=sigma_ana, + reasons=reasons, iters=iters, + ) + os.makedirs(OUT_DIR, exist_ok=True) + np.savez(os.path.join(OUT_DIR, f"{label}.npz"), **{ + "times": times, "sigmas": sigmas, "sigma_ana": sigma_ana, + "reasons": reasons, "iters": iters, + "softness": softness, "linesearch": linesearch, + "atol": (atol if atol is not None else -1.0), + "wall": wall, + }) + print(f" [{label:<28}] δ={softness:<5} ls={linesearch:<5} atol={atol!r:<6} " + f"wall={wall:6.1f}s div={out['diverged']:3d} its={out['mean_its']:5.2f} " + f"peak={out['peak']:.4f} max|err|={out['max_err']:.3e} rms={out['rms']:.3e}", + flush=True) + return out + + +def main(): + print("\n=== sweep_bdf2_softjac (Min residual, softmin Jacobian) ===\n", flush=True) + print("Family A: vary softness δ (bt linesearch, no atol)", flush=True) + family_A = [run_variant(f"deltaA_{d}", softness=d) for d in (0.05, 0.10, 0.20, 0.50)] + + print("\nFamily B: vary linesearch (δ=0.10, no atol)", flush=True) + family_B = [] + for ls in ("cp", "basic", "l2"): + family_B.append(run_variant(f"lsB_{ls}", softness=0.10, linesearch=ls)) + + print("\nFamily C: snes_atol=1e-5 with baseline (δ=0.10, bt)", flush=True) + family_C = [run_variant("atolC_1e-5", softness=0.10, linesearch="bt", atol=1e-5)] + + all_runs = family_A + family_B + family_C + print("\n\n=== summary ===", flush=True) + print(f"{'label':<28} {'δ':>5} {'ls':>6} {'atol':>8} {'wall':>7} {'div':>4} {'its':>5} {'peak|σ|':>7} {'max|err|':>10} {'rms':>10}", + flush=True) + for r in all_runs: + atol_str = f"{r['atol']:.0e}" if r['atol'] is not None else "None" + print(f"{r['label']:<28} {r['softness']:>5} {r['linesearch']:>6} {atol_str:>8} " + f"{r['wall']:>7.1f} {r['diverged']:>4d} {r['mean_its']:>5.2f} " + f"{r['peak']:>7.4f} {r['max_err']:>10.3e} {r['rms']:>10.3e}", + flush=True) + + +if __name__ == "__main__": + main() diff --git a/docs/advanced/benchmarks/vardt-square.md b/docs/advanced/benchmarks/vardt-square.md new file mode 100644 index 000000000..f320c867d --- /dev/null +++ b/docs/advanced/benchmarks/vardt-square.md @@ -0,0 +1,140 @@ +--- +title: "Variable-dt square-wave (VE and VEP)" +--- + +# Square-wave shear with reduced timestep near BC discontinuities + +The VE and VEP square-wave cases concentrate their numerical error in a +small window around each BC flip — the discrete time derivative is +attempting to follow a corner in the analytical $\sigma(t)$. Reducing +$\Delta t$ inside that window and keeping it large on the plateaux is +exactly the kind of variable-timestep schedule that the projection +machinery has to handle robustly. + +This pair of benchmarks runs the VE and VEP square-wave problems with +$\Delta t$ = 0.10·$t_r$ on plateaux and 0.01·$t_r$ within ±0.20·$t_r$ +of every flip — a 10× reduction across the discontinuity. + +## Schedule + +``` +Δt(t) = 0.10·t_r on plateaux (≥ 0.20·t_r away from any flip) + 0.01·t_r within ±0.20·t_r of a flip +``` + +Step boundaries are clamped to the flip time so no step straddles a +discontinuity. + +## What this exercises + +* The DDt's snapshot machinery: every halve/double of $\Delta t$ + exposes a new $\Delta t$ ratio to the implicit projection. Without + the snapshot fix the previous-generation code drifted off the yield + surface by ~30% under exactly this schedule. +* The Picard / `divergence_retries` SNES rescue: VEP's first solve + inside a fine-window after a flip lands close to the yield kink and + occasionally takes a Newton step that fails the tolerance check + within 50 iterations; the retry mechanism recovers without manual + intervention. + +## Results + +### VE + +```{figure} ../figures/bench_ve_square_vardt.png +:width: 100% + +Top: BDF-1 (blue circles) and BDF-2 (red squares) overlaid on the +analytical (black) for the variable-Δt VE square wave. Driving γ̇ +shown in light blue fill. Middle: pointwise absolute error. Bottom: +the Δt schedule with the 10× drop visible at every flip. +``` + +| | BDF-1 | BDF-2 | +|---|---|---| +| max\|err\| | 2.38e-02 | 1.42e-02 | +| rms | 1.62e-02 | 6.17e-03 | + +For comparison, the fixed-Δt=0.10 run gave BDF-2 max\|err\| ≈ 8.07e-02 +— so the fine window around the flip is doing exactly what it should +(reducing the dominant per-flip error). + +### VEP (Min mode) + +```{figure} ../figures/bench_vep_square_vardt.png +:width: 100% + +Same layout, with τ_y = 0.5 yield surface guides (dashed grey). +``` + +| | BDF-1 | BDF-2 | +|---|---|---| +| peak\|σ\| | 0.5000 | 0.5004 | +| overshoots > 1.001·τ_y | 0 | 0 | +| max\|err\| | 2.15e-02 | 8.70e-02 | +| rms | 6.68e-03 | 1.53e-02 | + +**The yield surface holds**. With both the snapshot machinery and +the Picard-style SNES retry in place, σ stays clipped to ±τ_y under +the variable-Δt schedule that previously produced a ~30% drift. +Peak\|σ\| matches τ_y = 0.5 to four decimal places (BDF-1) and +within 0.1% (BDF-2 — the 0.0004 excess is a transient at one +loading-onset transition, not a sustained yield-surface violation). + +The BDF-2 max\|err\| being larger than BDF-1's is the same phase-lag +story as in the fixed-dt VEP case: at the loading→yield transition +the 2nd-order step occasionally lags by one fine-Δt step before +catching up. RMS — which is the more honest measure for a sharp +transition — is comparable to BDF-1's. + +### VEP (softmin mode, default δ = 0.1) + +```{figure} ../figures/bench_vep_square_vardt_softmin.png +:width: 100% + +Same problem with `yield_mode="softmin"` — replacing +$\min(\eta_{ve},\eta_{pl})$ with the smooth approximation +$\eta_{ve}/g(f)$, $g(f) = 1 + (f-1+\sqrt{(f-1)^2+\delta^2})/2$. +At δ = 0.1 the kink is differentiable but the plateau still tracks +the yield surface tightly. +``` + +| | BDF-1 | BDF-2 | +|---|---|---| +| peak\|σ\| | 0.4894 (97.9% of τ_y) | 0.4853 (97.1% of τ_y) | +| max\|err vs Min-clip\| | 8.39e-02 | 1.11e-01 | +| rms vs Min-clip | 6.12e-02 | 7.76e-02 | + +Softmin keeps the plateau within 2-3% of the true Min yield surface +while smoothing out the kink at $\eta_{ve} = \eta_{pl}$ — Newton sees +a continuous derivative and the SNES never needs the Picard retry +that Min mode occasionally triggers. The "error vs Min-clip" +figure-of-merit penalises the deliberately rounded transitions; it +is not an accuracy gap in any physically meaningful sense. + +### Why `yield_mode="smooth"` was retired + +The third yield-mode option, the harmonic-blend "smooth" formula +$\eta_{eff} = \eta_{ve}\,(1+f)/(1+f+f^2)$, was retired in this same +benchmark suite (commit 5936b46) after the variable-dt run made the +problem unmissable: + +```{figure} ../figures/bench_vep_square_vardt_smooth.png +:width: 100% + +`yield_mode="smooth"` plateaus at |σ| ≈ 0.24 — only ~50% of +τ_y = 0.5 — across every loading half-cycle. Both BDF orders +under-clip identically. This is not a transient; the driving +$\dot\gamma$ holds long enough to reach steady state on every plateau. +``` + +| | BDF-1 | BDF-2 | +|---|---|---| +| peak\|σ\| | 0.2599 (52.0% of τ_y) | 0.2355 (47.1% of τ_y) | +| max\|err vs Min-clip\| | 3.66e-01 | 3.81e-01 | + +The blend formula has the wrong asymptotic behaviour: at $f = 1$ +(the yield kink itself) it gives $\eta_{eff}/\eta_{ve} = 2/3$, and it +keeps reducing $\eta_{eff}$ deep into the plastic regime instead of +saturating at $\eta_{pl}$. `softmin` does not have this defect, so +`smooth` was removed and the setter now redirects users. diff --git a/docs/advanced/benchmarks/ve-harmonic.md b/docs/advanced/benchmarks/ve-harmonic.md new file mode 100644 index 000000000..e50a1c45b --- /dev/null +++ b/docs/advanced/benchmarks/ve-harmonic.md @@ -0,0 +1,109 @@ +--- +title: "VE — sinusoidal shear" +--- + +# Maxwell viscoelastic shear under sinusoidal forcing + +A Maxwell material driven by a sinusoidal shear-rate has a closed-form +stress response. This benchmark drives the simple-shear box with +$V_{\mathrm{top}}(t) = V_0 \sin(\omega t)$ and compares the centre-point +shear stress to the analytical solution. The check covers the +amplitude attenuation and phase lag that the Deborah number predicts. + +## Governing equation + +Maxwell constitutive law in shear: + +$$ +\dot\sigma + \frac{\sigma}{t_r} = \mu\,\dot\gamma(t), +\qquad t_r = \frac{\eta}{\mu}. +$$ + +For $\dot\gamma(t) = \dot\gamma_0 \sin(\omega t)$ with $\sigma(0) = 0$, +the closed-form solution is + +$$ +\sigma(t) = \frac{\eta\,\dot\gamma_0}{1 + \mathrm{De}^2} +\left[\sin(\omega t) - \mathrm{De}\,\cos(\omega t) ++ \mathrm{De}\,e^{-t/t_r}\right] +$$ + +with $\mathrm{De} = \omega\,t_r$ the Deborah number. After the +exponential transient (a few $t_r$) the stress oscillates as + +$$ +\sigma_\infty(t) = A_\infty \sin(\omega t - \varphi), +\qquad +A_\infty = \frac{\eta\,\dot\gamma_0}{\sqrt{1+\mathrm{De}^2}}, +\qquad +\varphi = \arctan(\mathrm{De}). +$$ + +## Setup + +| | | +|---|---| +| Mesh | `StructuredQuadBox` 16×8 over $\bigl(\pm 1,\pm 0.5\bigr)$ | +| Velocity field | $\mathbb{P}^2$ | +| Pressure field | $\mathbb{P}^1$ | +| Boundary conditions | top/bottom velocity = $\pm V_0\sin(\omega t)$, free at left/right | +| Time integration | BDF-1 *and* BDF-2 at $\Delta t = 0.05\,t_r$, plus a sweep over $\Delta t \in \{0.025, 0.05, 0.10, 0.20, 0.40\}\,t_r$ | +| BC sampling | $V_{\mathrm{top}}$ evaluated at the *endpoint* of each step | +| Shear viscosity | $\eta = 1$ | +| Shear modulus | $\mu = 1$ | +| Top velocity amplitude | $V_0 = 0.5$ → $\dot\gamma_0 = 1$ | +| Forcing frequency | $\omega = \pi/2$ → period $4\,t_r$, $\mathrm{De} = \pi/2 \approx 1.57$ | +| Run length | $4$ full periods | + +The strain rate uses the symmetric tensor convention +$\dot\varepsilon_{xy} = (\partial_y u_x + \partial_x u_y)/2$, so +$\dot\gamma = 2 V_0 / H = 1$ for $V_0 = 0.5$, $H = 1$. + +## Run + +```bash +pixi run -e amr-dev python docs/advanced/benchmarks/bench_ve_harmonic.py +pixi run -e amr-dev python docs/advanced/benchmarks/plot_benchmarks.py +``` + +The simulation logs to `output/benchmarks/ve_harmonic.npz` (per-step +trace + analytical reference at the same time points). Re-running the +plot script doesn't re-run the simulation. + +## Results + +```{figure} ../figures/bench_ve_harmonic.png +:width: 100% + +Top: BDF-1 (blue open circles) and BDF-2 (red filled squares) +overlaid on the closed-form solution (black) and the rescaled +sinusoidal forcing (light blue fill) for context. Middle: pointwise +absolute error for both orders. Bottom: time-step. Inset compares +fitted vs analytical amplitude and phase lag. +``` + +At $\mathrm{De} = \pi/2$ the analytical amplitude is +$A_\infty = 1/\sqrt{1+\pi^2/4} \approx 0.537$ and the phase lag is +$\varphi = \arctan(\pi/2) \approx 1.004$ rad. At $\Delta t = +0.05\,t_r$ BDF-2 recovers both the amplitude and the phase lag to +within $10^{-3}$ rad; BDF-1 is off by a few percent in the phase +(the residual O($\Delta t$) error of an implicit-Euler scheme). + +```{figure} ../figures/bench_convergence.png +:width: 100% + +Convergence sweep — left panel is the harmonic case. BDF-1 sits on +slope 1 (dotted reference); BDF-2 (rms, lower red dotted line) +hits slope 2 (dashed reference) cleanly between $\Delta t = 0.4$ and +$0.1$ before levelling off at the fine end (where the BDF-2 startup +transient — first one or two steps that effectively run at BDF-1 — +becomes the dominant contribution). +``` + +The benchmark surfaces a subtle but important detail: $V_{\mathrm{top}}$ +is sampled at the step *endpoint* (i.e.\\ the time BDF's implicit step +solves for), not at the midpoint. Midpoint sampling is only +1st-order accurate to the endpoint value; using it would limit BDF-2 +to slope-1 convergence even though the time integrator itself is +2nd-order. Same nominal mesh, dt schedule, and tolerance — only the +BC sampling differs. diff --git a/docs/advanced/benchmarks/ve-square.md b/docs/advanced/benchmarks/ve-square.md new file mode 100644 index 000000000..0cbfee078 --- /dev/null +++ b/docs/advanced/benchmarks/ve-square.md @@ -0,0 +1,100 @@ +--- +title: "VE — square-wave shear" +--- + +# Maxwell viscoelastic shear under square-wave forcing + +A Maxwell material driven by a square-wave shear rate also has a +closed-form solution: within each half-period the stress relaxes +exponentially toward the new steady-state value. This benchmark +exercises the BDF-2 stress-history integrator at the BC discontinuities, +where the time derivative has jumps. + +## Governing equation + +Same Maxwell ODE as the harmonic case, + +$$ +\dot\sigma + \frac{\sigma}{t_r} = \mu\,\dot\gamma(t), +$$ + +but now $\dot\gamma(t) = s_n\,\dot\gamma_0$ where $s_n = (-1)^n$ is the +sign during half-period $n$ of length $T_{1/2}$. Within half-period +$n$ (with $t_n = n\,T_{1/2}$ and initial value $\sigma_{0,n}$), + +$$ +\sigma(t) = s_n\sigma_{\mathrm{ss}} ++ \bigl(\sigma_{0,n} - s_n\sigma_{\mathrm{ss}}\bigr)\, +e^{-(t-t_n)/t_r}, +\qquad +\sigma_{\mathrm{ss}} = \eta\,\dot\gamma_0, +$$ + +and the next half-period's initial value is the previous one's end +value: + +$$ +\sigma_{0,n+1} = s_n\sigma_{\mathrm{ss}} ++ \bigl(\sigma_{0,n} - s_n\sigma_{\mathrm{ss}}\bigr)\, +e^{-T_{1/2}/t_r}. +$$ + +After a few periods the response settles into a periodic envelope +between $\pm\sigma_{\mathrm{ss}}\tanh\bigl(T_{1/2}/(2 t_r)\bigr)$. + +## Setup + +| | | +|---|---| +| Mesh | `StructuredQuadBox` 16×8 over $\bigl(\pm 1,\pm 0.5\bigr)$ | +| Velocity field | $\mathbb{P}^2$ | +| Pressure field | $\mathbb{P}^1$ | +| Time integration | BDF-2, $\Delta t = 0.10\,t_r$ | +| Shear viscosity | $\eta = 1$ | +| Shear modulus | $\mu = 1$ | +| Top velocity amplitude | $V_0 = 0.5$ → $\dot\gamma_0 = 1$ | +| Half-period | $T_{1/2} = 2\,t_r$ | +| Run length | 4 full periods (= $8\,T_{1/2}$) | + +## Run + +```bash +pixi run -e amr-dev python docs/advanced/benchmarks/bench_ve_square.py +pixi run -e amr-dev python docs/advanced/benchmarks/plot_benchmarks.py +``` + +Logs to `output/benchmarks/ve_square.npz`. + +## Results + +```{figure} ../figures/bench_ve_square.png +:width: 100% + +Top: BDF-1 (blue open circles) and BDF-2 (red filled squares) +overlaid on the analytical envelope (black) over four periods of the +square-wave forcing (light blue fill). Middle: pointwise absolute +error for both orders on a log scale; the bumps coincide with the BC +flips at $t = 2, 4, 6, \ldots\, t_r$ where the analytical +$\dot\sigma$ has a jump. Bottom: time-step (constant for this run). +``` + +Both orders track the analytical envelope. The asymptotic per-period +envelope amplitude is $\sigma_{\mathrm{ss}}\tanh(T_{1/2}/(2t_r)) = +\tanh(1) \approx 0.762$, reached within two periods. + +```{figure} ../figures/bench_convergence.png +:width: 100% + +Convergence sweep — middle panel is the square-wave case. BDF-1 +shows clean slope 1. BDF-2 starts steeper than slope 1 and trends +toward slope 2 as $\Delta t$ shrinks, but at the dt range plotted +the BC discontinuity at every half-period flip injects an +$O(\Delta t)$ contribution that masks BDF-2's slope-2 advantage — +the constant ratio between BDF-1 and BDF-2 errors is the asymptote +the BDF-2 line is bending towards as the BC-flip contribution +becomes subdominant. +``` + +The error decays exponentially within each half-period as the +discrete history catches up with the new ramp; the decay rate matches +the Maxwell relaxation time $t_r$. diff --git a/docs/advanced/benchmarks/vep-square.md b/docs/advanced/benchmarks/vep-square.md new file mode 100644 index 000000000..ad46fe982 --- /dev/null +++ b/docs/advanced/benchmarks/vep-square.md @@ -0,0 +1,122 @@ +--- +title: "VEP — square-wave shear (Min mode)" +--- + +# Visco-elastic-plastic shear under square-wave forcing + +Add a yield surface to the square-wave VE benchmark and the closed-form +solution is just the *clipped* version of the VE square-wave: within each +half-period the stress evolves exponentially toward +$\pm\sigma_{\mathrm{ss}}$ but is held at $\pm\tau_y$ while the material +is yielding. + +This benchmark verifies the implementation of Min-mode plasticity, the +yield-surface clip itself, and — under variable timestep — the +projection-snapshot machinery in `SemiLagrangian` DDt that prevents the +implicit-projection drift at the Min kink (see the regression test in +`tests/test_1052_VEP_stability_regression.py`). + +## Governing equation + +Maxwell evolution with a Min-mode yield surface: + +$$ +\dot\sigma + \frac{\sigma}{t_r} = \mu\,\dot\gamma(t), +\qquad +\eta_{\mathrm{eff}} = \min\bigl(\eta_{\mathrm{ve}},\,\eta_{\mathrm{pl}}\bigr), +\qquad +\eta_{\mathrm{pl}} = \frac{\tau_y}{2\,|\dot\varepsilon_{\mathrm{eff}}|}. +$$ + +Within each half-period the analytical solution is + +$$ +\sigma(t) = \mathrm{clip}\Bigl( +s_n\sigma_{\mathrm{ss}} ++ (\sigma_{0,n} - s_n\sigma_{\mathrm{ss}})\,e^{-(t-t_n)/t_r}, +\;-\tau_y,\;+\tau_y +\Bigr). +$$ + +Because the yielded portion holds $\sigma = \pm\tau_y$ exactly, the +*clipped* value carries forward as the next half-period's initial +condition: + +$$ +\sigma_{0,n+1} = \mathrm{clip}\bigl(\sigma(t_n+T_{1/2}), +\;-\tau_y,\;+\tau_y\bigr). +$$ + +When $\eta\,\dot\gamma_0 > \tau_y$ (yielding occurs) the response +saturates at $\pm\tau_y$ during the second half of each half-period. + +## Setup + +| | | +|---|---| +| Mesh | `StructuredQuadBox` 16×8 over $\bigl(\pm 1,\pm 0.5\bigr)$ | +| Velocity field | $\mathbb{P}^2$ | +| Pressure field | $\mathbb{P}^1$ | +| Time integration | BDF-2, $\Delta t = 0.10\,t_r$ | +| Shear viscosity | $\eta = 1$ | +| Shear modulus | $\mu = 1$ | +| Yield stress | $\tau_y = 0.5$ (so $\eta\dot\gamma_0 / \tau_y = 2$) | +| Yield mode | `min` | +| Top velocity amplitude | $V_0 = 0.5$ → $\dot\gamma_0 = 1$ | +| Half-period | $T_{1/2} = 2\,t_r$ | +| Run length | 4 full periods | + +## Run + +```bash +pixi run -e amr-dev python docs/advanced/benchmarks/bench_vep_square.py +pixi run -e amr-dev python docs/advanced/benchmarks/plot_benchmarks.py +``` + +Logs to `output/benchmarks/vep_square.npz`. + +## Results + +```{figure} ../figures/bench_vep_square.png +:width: 100% + +Top: BDF-1 (blue open circles) and BDF-2 (red filled squares) +overlaid on the analytical clipped solution (black), yield surface +guides $\pm\tau_y$ (dashed grey), and rescaled forcing (light blue +fill). Middle: pointwise absolute error for both orders on a log +scale — note the dramatic drop to $\sim 10^{-6}$ during yielded +plateaux where simulation and analytical both sit at $\pm\tau_y$ to +machine precision. Bottom: time-step. +``` + +Two things to read from the per-case plot: + +1. **The yield surface holds for both orders**. Peak $|\sigma|$ + matches $\tau_y = 0.5$ to four decimal places at the canonical dt + for both BDF-1 and BDF-2; the count of overshoots + ($|\sigma| > 1.001\,\tau_y$) is zero in both runs. This is the + regression that the + [variable-dt yield-lock test](../../../tests/test_1052_VEP_stability_regression.py) + protects against re-introduction. + +2. **The error has structure**. During yielded plateaux the + simulation matches the analytical to machine precision (the + $\sim 10^{-6}$ floor is the projection's L2 residual). During the + elastic loading/unloading transients the per-step truncation error + peaks just after each BC flip and decays within the half-period. + +```{figure} ../figures/bench_convergence.png +:width: 100% + +Convergence sweep — right panel is the VEP case. BDF-1 shows clean +slope 1. BDF-2 follows the same trend with a constant ratio above +BDF-1, until the smallest $\Delta t$ where a transient overshoot +arrives one step late on the yield-onset transition; this shows up +in the max-norm but not the rms. Peak $|\sigma|$ stays within 1.3 % +of $\tau_y$ at every $\Delta t$ tested. +``` + +The benchmark's strict accuracy requirement is the yield-surface peak, +not the transient error: any future change that produces $|\sigma| > +\tau_y$ on a fixed-dt yielded plateau by more than the yield-lock +test's tolerance fails the regression suite. diff --git a/docs/advanced/curved-boundary-conditions.md b/docs/advanced/curved-boundary-conditions.md index ef0861276..9e36615eb 100644 --- a/docs/advanced/curved-boundary-conditions.md +++ b/docs/advanced/curved-boundary-conditions.md @@ -33,11 +33,47 @@ This can cause significant errors in free-slip boundary conditions. --- -## Three Approaches +## Four Approaches -### 1. Raw `mesh.Gamma` (Simplest) +### 1. Nitsche Free-Slip (Recommended) -Use the mesh-derived normals directly: +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) +``` + +The method automatically constructs penalty, consistency (stress flux), +symmetry, and pressure coupling terms. The `gamma` parameter is dimensionless +and mesh-independent — `gamma=10` works for P2 elements regardless of +resolution or viscosity. + +**Prescribed normal velocity:** +```python +stokes.add_nitsche_bc("Inlet", g=1.0, 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) +``` + +**When to use:** +- Free-slip on any geometry (boxes, annuli, spherical shells) +- Spherical shell models where penalty is fragile +- When you don't want to tune a penalty parameter +- Basal shear constraints with custom direction + +**Accuracy:** Optimal convergence rate. On a Cartesian box test, Nitsche at +`gamma=10` gives 0.08% velocity error vs the essential BC solution (penalty +at 1e4 gives 0.15%). + + +### 2. Penalty Free-Slip (Simple but Fragile) + +Use the mesh-derived normals directly with a penalty parameter: ```python Gamma = mesh.Gamma @@ -46,14 +82,16 @@ stokes.add_natural_bc(penalty * Gamma.dot(v.sym) * Gamma, "Boundary") ``` **When to use:** -- Straight-edged boundaries (boxes, channels) -- Circular boundaries (normals are radial, so facet direction is correct) - Quick prototyping where high accuracy isn't critical +- When Nitsche is not yet available for your solver type -**Accuracy:** ~25-30% error on elliptical boundaries +**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 -### 2. Projected Normals (Recommended for Curved Boundaries) +### 3. Projected Normals (For Curved Boundaries with Penalty) Project `mesh.Gamma` onto a continuous mesh variable, which interpolates and smooths the normals: @@ -96,7 +134,7 @@ stokes.add_natural_bc(penalty * n_proj.sym.dot(v.sym) * n_proj.sym, "Boundary") **Why it works:** The projection solves a weak-form problem that naturally smooths the discontinuous facet normals into a continuous field. The finite element basis functions interpolate between facets, approximating the true surface direction. -### 3. Analytical Normals (Most Accurate) +### 4. Analytical Normals (Most Accurate) Derive the surface normal from the mathematical definition of the boundary: @@ -179,15 +217,18 @@ unit_normal = normal / sympy.sqrt(normal.dot(normal)) ## Experimental Comparison -We tested the three approaches on an elliptical annulus (ellipticity = 1.5) with a free-slip boundary condition: +We tested the approaches on an elliptical annulus (ellipticity = 1.5) with a free-slip boundary condition: -| Approach | Error vs Analytical | -|----------|---------------------| -| Raw `mesh.Gamma` | 26.88% | -| Projected normals | 0.06% | -| Analytical | 0% (reference) | +| Approach | Error vs Analytical | Notes | +|----------|---------------------|-------| +| Penalty + raw `mesh.Gamma` | 26.88% | Penalty-sensitive | +| Penalty + projected normals | 0.06% | Requires projection solve | +| Penalty + analytical normals | 0% (reference) | Requires surface formula | +| Nitsche (default) | ~0.1% | No penalty tuning needed | -The projected normals provide **99.8% improvement** over raw `mesh.Gamma` for curved boundaries. +Nitsche is recommended for most use cases — it matches the accuracy of +projected normals without requiring a separate projection solve or penalty +tuning. --- @@ -222,13 +263,50 @@ For complex geometries where the orientation varies spatially, the projection ap --- +## Internal Boundaries + +```{warning} +Nitsche BCs are designed for **exterior** boundaries only. Do not use +`add_nitsche_bc` on internal boundary labels (e.g., `"Internal"` from +`BoxInternalBoundary` or `AnnulusInternalBoundary`). +``` + +On an internal surface, PETSc evaluates boundary residuals from **both** +adjacent cells with opposite normals. The Nitsche consistency and pressure +terms flip sign between the two sides and partially cancel, injecting +a spurious residual that degrades the solution. Testing on an annulus +with an internal boundary showed that Nitsche produced *worse* constraint +enforcement than no constraint at all. + +**For internal boundary constraints, continue using the penalty approach:** + +```python +Gamma = mesh.Gamma +stokes.add_natural_bc(penalty * Gamma.dot(v.sym) * Gamma, "Internal") +``` + +The penalty term is quadratic in the normal (`n × n`), so both sides +reinforce correctly regardless of normal orientation. + +A proper Nitsche formulation for internal surfaces would require an +interior penalty (IP/SIP) method with explicit jump and average operators +across the interface — accessing the solution from both adjacent cells. +PETSc's current pointwise callbacks do not provide cross-cell access, +so this would require a different assembly strategy. This is an area +for future development, particularly for embedded impermeable surfaces +in 3D spherical models where penalty sensitivity becomes problematic. + +--- + ## Tips for Success -1. **Always normalize**: Analytical formulas need explicit normalization -2. **Check orientation**: Ensure normals point outward (use `sign(r.dot(normal))`) -3. **Verify visually**: Plot the normal field to catch errors -4. **Start with projection**: It's robust and doesn't require deriving formulas -5. **Use analytical for validation**: Compare projected normals against analytical when possible +1. **Start with Nitsche**: `stokes.add_nitsche_bc("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 +5. **Use analytical normals for validation**: Compare against exact surface geometry when possible +6. **Custom constraint direction**: Use `direction=` parameter when the constraint + direction differs from the surface normal (faults, basal shear) --- @@ -236,7 +314,8 @@ For complex geometries where the orientation varies spatially, the projection ap - [Custom Mesh Creation](custom-meshes.md) — Creating elliptical and complex meshes - [Stokes Ellipse Example](../examples/fluid_mechanics/intermediate/Ex_Stokes_Ellipse_Cartesian.py) — Complete worked example +- Sime & Wilson (2020), [arXiv:2001.10639](https://arxiv.org/abs/2001.10639) — Nitsche free-slip for geodynamics --- -*Last updated: January 2026* +*Last updated: April 2026* diff --git a/docs/advanced/figures/bench_convergence.png b/docs/advanced/figures/bench_convergence.png new file mode 100644 index 000000000..3020114b7 Binary files /dev/null and b/docs/advanced/figures/bench_convergence.png differ diff --git a/docs/advanced/figures/bench_fmg_vs_gamg_velocity_ksp.csv b/docs/advanced/figures/bench_fmg_vs_gamg_velocity_ksp.csv new file mode 100644 index 000000000..3b28a7d47 --- /dev/null +++ b/docs/advanced/figures/bench_fmg_vs_gamg_velocity_ksp.csv @@ -0,0 +1,51 @@ +adapt_step,fmg_vel_ksp_its,fmg_snes_reason,fmg_t_stokes_s,gamg_vel_ksp_its,gamg_snes_reason,gamg_t_stokes_s +1,5,3,2.97,113,3,4.02 +2,4,3,2.83,64,3,18.54 +3,3,3,5.85,111,3,6.18 +4,3,3,4.79,112,3,6.00 +5,4,3,2.93,71,3,15.83 +6,4,3,3.11,87,3,17.88 +7,4,3,2.98,90,3,17.81 +8,5,3,4.57,107,3,6.41 +9,4,3,10.12,89,3,15.76 +10,4,3,9.46,90,3,16.02 +11,4,3,9.42,112,3,12.17 +12,5,3,7.87,112,3,16.04 +13,5,3,5.01,79,3,17.48 +14,5,3,8.23,111,3,16.68 +15,4,3,8.09,113,3,18.80 +16,5,3,8.29,116,3,15.58 +17,5,3,8.23,116,3,18.02 +18,5,3,9.10,113,3,21.25 +19,5,3,10.11,115,3,21.99 +20,6,3,9.84,69,3,24.06 +21,6,3,10.44,123,3,22.41 +22,6,3,11.00,122,3,24.98 +23,5,3,12.64,117,3,25.87 +24,5,3,13.22,112,3,21.44 +25,5,3,16.53,87,3,37.25 +26,5,3,15.86,118,3,35.63 +27,5,3,20.67,122,3,29.64 +28,4,3,21.11,124,3,32.49 +29,5,3,32.59,131,3,31.71 +30,6,3,33.49,127,3,27.85 +31,5,3,21.81,127,3,21.79 +32,5,3,25.16,125,3,22.17 +33,5,3,20.27,122,3,22.54 +34,5,3,16.23,118,3,24.23 +35,6,3,15.01,117,3,20.54 +36,6,3,14.84,114,3,18.34 +37,6,3,12.57,114,3,18.48 +38,6,3,12.76,113,3,22.77 +39,5,3,13.56,108,3,21.46 +40,5,3,12.73,90,3,21.64 +41,6,3,12.76,115,3,18.44 +42,5,3,12.16,115,3,19.01 +43,5,3,11.32,115,3,17.96 +44,5,3,10.94,88,3,22.03 +45,5,3,10.85,118,3,22.92 +46,5,3,10.52,98,3,26.26 +47,5,3,9.12,108,3,16.03 +48,5,3,9.08,124,3,15.25 +49,5,3,8.91,124,3,15.04 +50,5,3,8.75,119,3,15.42 diff --git a/docs/advanced/figures/bench_fmg_vs_gamg_velocity_ksp.md b/docs/advanced/figures/bench_fmg_vs_gamg_velocity_ksp.md new file mode 100644 index 000000000..92157e4a9 --- /dev/null +++ b/docs/advanced/figures/bench_fmg_vs_gamg_velocity_ksp.md @@ -0,0 +1,121 @@ +--- +orphan: true +--- + +# Handover — FMG vs GAMG benchmark figure + adaptive-convection animation + +Produced 2026-06-11 from the boundary-slip / anisotropic-mover branch (PR #228). +Two assets: a solver-scaling **benchmark figure** (for `docs/advanced/`) and an +**animation** of the same run (for the PR description / a docs gallery). + +## Absolute locations (the worktree may differ — use these) + +- **Figure + CSV + this note** — currently *uncommitted* in the feature worktree: + `/Users/lmoresi/+Underworld/underworld3-pixi/.claude/worktrees/gmg-geometric-interp/docs/advanced/figures/` + Once PR #228 merges they live in the main checkout at + `/Users/lmoresi/+Underworld/underworld3-pixi/docs/advanced/figures/` + (repo-relative `docs/advanced/figures/` — stable in any checkout). +- **Animation, per-step PNGs, render scripts** — on disk, not in the repo: + `/Users/lmoresi/+Simulations/StagnantLid/anim_full_Ra1e7_dEta1e3_res32_R8_mode1/` + (frames + GIFs) and `/Users/lmoresi/+Simulations/StagnantLid/` (`_render_clean.py`, + `_render_watch.py`). +- **Harness** — `scripts/stagnant_lid_adapt_loop.py` in the repo (currently + `/Users/lmoresi/+Underworld/underworld3-pixi/.claude/worktrees/gmg-geometric-interp/scripts/stagnant_lid_adapt_loop.py`). + +The repo-relative paths used in the docs-reference snippets below are intentional +— image references resolve relative to the docs page, not the filesystem. + +--- + +## 1. Benchmark figure — `bench_fmg_vs_gamg_velocity_ksp.svg` + +**Files (here, `docs/advanced/figures/`):** +- `bench_fmg_vs_gamg_velocity_ksp.svg` — two-panel figure (text is `svg.fonttype=none`, so selectable/scalable). +- `bench_fmg_vs_gamg_velocity_ksp.csv` — the underlying per-step data: + `adapt_step, fmg_vel_ksp_its, fmg_snes_reason, fmg_t_stokes_s, gamg_vel_ksp_its, gamg_snes_reason, gamg_t_stokes_s`. + +**What it shows:** +- *Top* — inner velocity-block KSP iterations vs adaptation step. FMG (geometric full multigrid) holds a **mesh-independent ~5**; GAMG (algebraic) is **volatile ~64–131 (≈22×)** and does **not** cliff at R=8 over 50 steps. +- *Bottom* — Stokes-solve wall time. GAMG is only **~1.7× FMG**; FMG even spikes to match GAMG around the hardest adapts (steps 25–32) — that spike is the **cold-start Stokes solve** after an adapt (common to both engines), not the preconditioner. +- The **outer Schur-complement KSP converges in 1 iteration for both** — the entire difference lives in the inner velocity block. + +**Suggested caption:** +> **Velocity-block solver scaling under adaptive remeshing.** Inner velocity-block +> KSP iterations (top) and Stokes-solve wall time (bottom) versus adaptation step +> for a Ra = 10⁷, Δη = 10³ annulus convection model adapted every timestep with the +> MMPDE mover (res 32, resolution-ratio R = 8, np = 5). Geometric full multigrid +> (FMG) keeps a mesh-independent ≈ 5 inner iterations as the cells stretch, where +> algebraic multigrid (GAMG) runs a volatile ≈ 64–131 (≈ 22×) without cliffing at +> this anisotropy. The wall-clock gap is only ≈ 1.7×: each GAMG V-cycle is far +> cheaper than an FMG F-cycle, and the cold-start Stokes solve after each adapt +> (common to both) dominates the time. The outer Schur KSP converges in a single +> iteration for both — the difference is entirely in the inner velocity block. The +> value of geometric FMG here is *predictability and mesh-independence* (which widen +> with problem size and multigrid levels), not a large raw speed-up. + +**Docs wiring** (Markdown/MyST under the benchmark table): +```markdown +![Velocity-block solver scaling under adaptive remeshing](figures/bench_fmg_vs_gamg_velocity_ksp.svg) +``` +(Typst equivalent: `#image("figures/bench_fmg_vs_gamg_velocity_ksp.svg")`.) + +--- + +## 2. Animation companion — adaptive convection GIF + +**File (NOT in the repo — multi-MB — in the simulation directory):** +`/Users/lmoresi/+Simulations/StagnantLid/anim_full_Ra1e7_dEta1e3_res32_R8_mode1/` +- `anim_clean_Ra1e7_dEta1e3_res32_R8_mode1.gif` — **docs/PR size**: 8 MB, 63 frames (every 4th step), 480 px, clean style (T field + adaptive-mesh edges; no scalebar, streamlines, or text). +- `anim_clean_…_HQ.gif` — 24 MB, 125 frames, 600 px. +- `frame_clean_step0001…0250.png` — all 250 clean PNGs, kept for re-cutting cadence/size. + +**What it shows:** the 250-step run — a degree-1 perturbation breaking symmetry into +vigorous, irregular stagnant-lid convection (Nu 1 → 4.15), with the mesh +redistributing every step to track the inner thermal boundary layer and the plumes. +It is the *same run* whose Stokes solves stay flat under FMG in the figure above. + +**Suggested caption:** +> Adaptive-mesh convection in an annulus (Ra = 10⁷, Δη = 10³): temperature with the +> MMPDE-adapted mesh redistributing every timestep to follow the thermal boundary +> layer and plumes, mesh-owned tangent-slip keeping boundary nodes on the curved +> surface. 250 steps, np = 5 — the same run whose velocity solves stay +> mesh-independent under geometric FMG (see the benchmark figure). + +**Placement:** for the PR description, drag-drop the 8 MB GIF into the #228 comment +box via the GitHub web UI (under the 10 MB limit; `gh` can't upload binary +attachments). For docs, host/link it or commit a lighter cut — don't commit the +multi-MB GIF to the repo. + +--- + +## Reproduction + +Run (boundary-slip / mover branch; `scripts/stagnant_lid_adapt_loop.py`): +```bash +REFINE=2 PCVEL=gmg MG_TYPE=full MOVER=mmpde MMPDE_ACCEL=cg MOVER_SLIP=ring \ +mpirun -np 5 python scripts/stagnant_lid_adapt_loop.py \ + --from-perturbation --pert-mode 1 --Ra 1e7 --delta-eta 1e3 \ + --dt-cell-percentile 50 --skip-threshold 99 --adapt-every 1 \ + --resolution-ratio 8 --n-steps 250 --res 32 --snapshot-every 1 --out-tag +``` +- **FMG vs GAMG** (figure data): same config; `PCVEL=gmg MG_TYPE=full` (FMG) vs + `PCVEL=amg` (GAMG), 50 steps. Inner velocity iterations captured with a temporary + `FMG_DIAG` env-gated diagnostic in the harness + (`stokes.snes.getKSP().getPC().getFieldSplitSubKSP()[0].getIterationNumber()`, + plus `len(mesh.dm_hierarchy)` and the converged reason) — reverted after the runs. +- **Prerequisite** for FMG: the mesh must be built with `refinement` (so + `len(mesh.dm_hierarchy) > 1`) and adapted with a *coordinate-deforming* mover + (MMPDE preserves topology, so the hierarchy survives — confirmed `levels=3` on all + 50 steps). A checkpoint-resumed mesh has **no** hierarchy (it isn't persisted), so + `--resume` cannot be used for the FMG arm. +- **Render:** `_render_clean.py ` (clean — no scalebar/streamlines) or + `_render_watch.py ` (default — with scalebar + streamlines), both in + `/Users/lmoresi/+Simulations/StagnantLid/`. GIF assembled with Pillow (crop white margin → + resize → sample frames). + +## Status +- SVG + CSV + this note: **uncommitted** in `docs/advanced/figures/` — commit them + with the docs reference. +- The harness is at its committed state (the `FMG_DIAG` diagnostic was temporary). +- PR #228 (movers consume `mesh.boundary_slip` + the anisotropic-mover work) is + green and mergeable; this benchmark is its "why this works" companion. diff --git a/docs/advanced/figures/bench_fmg_vs_gamg_velocity_ksp.svg b/docs/advanced/figures/bench_fmg_vs_gamg_velocity_ksp.svg new file mode 100644 index 000000000..c670697c8 --- /dev/null +++ b/docs/advanced/figures/bench_fmg_vs_gamg_velocity_ksp.svg @@ -0,0 +1,946 @@ + + + + + + + + 2026-06-11T14:33:58.354179 + image/svg+xml + + + Matplotlib v3.10.8, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + 20 + + + + + + + + + + + + + 40 + + + + + + + + + + + + + 60 + + + + + + + + + + + + + 80 + + + + + + + + + + + + + 100 + + + + + + + + + + + + + 120 + + + + + + + + + + + + + 140 + + + + inner velocity-block + KSP iterations + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + GAMG: ~64–131 iters, volatile (no cliff at R=8) + + + FMG: flat ~5 (mesh-independent) + + + Velocity-block solver scaling under adaptive remeshing + + + + + + + + + + GAMG (algebraic) + + + + + + + + + FMG (geometric) + + + + + + + + + + + + + + + + + + + 10 + + + + + + + + + + + + + 20 + + + + + + + + + + + + + 30 + + + + + + + + + + + + + 40 + + + + + + + + + + + + + 50 + + + + adaptation step (anisotropy sharpens →) + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + 10 + + + + + + + + + + + + + 20 + + + + + + + + + + + + + 30 + + + + + + + + + + + + + 40 + + + + Stokes solve + wall time (s) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + wall: GAMG only ~1.7× FMG (cold-start-dominated; dilutes the 22× iteration gap) + + + + Annulus · Ra=1e7 · Δη=1e3 · res 32 · R=8 · mmpde + tangent-slip · np=5 | outer Schur KSP = 1 for both + + + + + + + + + + + diff --git a/docs/advanced/figures/bench_ti_vep_harmonic.png b/docs/advanced/figures/bench_ti_vep_harmonic.png new file mode 100644 index 000000000..1d2a91a3b Binary files /dev/null and b/docs/advanced/figures/bench_ti_vep_harmonic.png differ diff --git a/docs/advanced/figures/bench_ve_harmonic.png b/docs/advanced/figures/bench_ve_harmonic.png new file mode 100644 index 000000000..00ced4f4a Binary files /dev/null and b/docs/advanced/figures/bench_ve_harmonic.png differ diff --git a/docs/advanced/figures/bench_ve_square.png b/docs/advanced/figures/bench_ve_square.png new file mode 100644 index 000000000..f4ebb8a68 Binary files /dev/null and b/docs/advanced/figures/bench_ve_square.png differ diff --git a/docs/advanced/figures/bench_ve_square_vardt.png b/docs/advanced/figures/bench_ve_square_vardt.png new file mode 100644 index 000000000..83c36f193 Binary files /dev/null and b/docs/advanced/figures/bench_ve_square_vardt.png differ diff --git a/docs/advanced/figures/bench_vep_square.png b/docs/advanced/figures/bench_vep_square.png new file mode 100644 index 000000000..dbad9e82f Binary files /dev/null and b/docs/advanced/figures/bench_vep_square.png differ diff --git a/docs/advanced/figures/bench_vep_square_vardt.png b/docs/advanced/figures/bench_vep_square_vardt.png new file mode 100644 index 000000000..60da2589e Binary files /dev/null and b/docs/advanced/figures/bench_vep_square_vardt.png differ diff --git a/docs/advanced/figures/bench_vep_square_vardt_smooth.png b/docs/advanced/figures/bench_vep_square_vardt_smooth.png new file mode 100644 index 000000000..12139df6c Binary files /dev/null and b/docs/advanced/figures/bench_vep_square_vardt_smooth.png differ diff --git a/docs/advanced/figures/bench_vep_square_vardt_softmin.png b/docs/advanced/figures/bench_vep_square_vardt_softmin.png new file mode 100644 index 000000000..c0c0705e2 Binary files /dev/null and b/docs/advanced/figures/bench_vep_square_vardt_softmin.png differ diff --git a/docs/advanced/figures/cuboid-3d/cuboid-3d-data.json b/docs/advanced/figures/cuboid-3d/cuboid-3d-data.json new file mode 100644 index 000000000..d53b4c891 --- /dev/null +++ b/docs/advanced/figures/cuboid-3d/cuboid-3d-data.json @@ -0,0 +1,264 @@ +{ + "vertices_2d": [ + [ + -0.2165, + 0.375 + ], + [ + 1.5155, + -0.625 + ], + [ + 0.2165, + -1.375 + ], + [ + -1.5155, + -0.375 + ], + [ + -0.2165, + 1.375 + ], + [ + 1.5155, + 0.375 + ], + [ + 0.2165, + -0.375 + ], + [ + -1.5155, + 0.625 + ] + ], + "faces": { + "Upper": { + "vertex_indices": [ + 4, + 5, + 6, + 7 + ], + "visible": true, + "arrow_from": [ + 0.0, + 1.3 + ], + "arrow_to": [ + 0.0, + 0.5 + ], + "label_pos": [ + 0.0, + 1.65 + ], + "label_anchor": "south" + }, + "Lower": { + "vertex_indices": [ + 0, + 1, + 2, + 3 + ], + "visible": false, + "arrow_from": [ + 0.0, + -1.3 + ], + "arrow_to": [ + 0.0, + -0.5 + ], + "label_pos": [ + 0.0, + -1.65 + ], + "label_anchor": "north" + }, + "Front": { + "vertex_indices": [ + 0, + 1, + 5, + 4 + ], + "visible": false, + "arrow_from": [ + 1.3423, + 0.775 + ], + "arrow_to": [ + 0.6495, + 0.375 + ], + "label_pos": [ + 1.6454, + 0.95 + ], + "label_anchor": "west" + }, + "Back": { + "vertex_indices": [ + 2, + 3, + 7, + 6 + ], + "visible": true, + "arrow_from": [ + -1.3423, + -0.775 + ], + "arrow_to": [ + -0.6495, + -0.375 + ], + "label_pos": [ + -1.6454, + -0.95 + ], + "label_anchor": "east" + }, + "Left": { + "vertex_indices": [ + 0, + 3, + 7, + 4 + ], + "visible": false, + "arrow_from": [ + -1.5588, + 0.9 + ], + "arrow_to": [ + -0.866, + 0.5 + ], + "label_pos": [ + -1.862, + 1.075 + ], + "label_anchor": "east" + }, + "Right": { + "vertex_indices": [ + 1, + 2, + 6, + 5 + ], + "visible": true, + "arrow_from": [ + 1.5588, + -0.9 + ], + "arrow_to": [ + 0.866, + -0.5 + ], + "label_pos": [ + 1.862, + -1.075 + ], + "label_anchor": "west" + } + }, + "faces_back_to_front": [ + "Left", + "Front", + "Lower", + "Upper", + "Back", + "Right" + ], + "edges": [ + { + "vertices": [ + 0, + 1 + ], + "visible": false + }, + { + "vertices": [ + 1, + 2 + ], + "visible": true + }, + { + "vertices": [ + 2, + 3 + ], + "visible": true + }, + { + "vertices": [ + 3, + 0 + ], + "visible": false + }, + { + "vertices": [ + 4, + 5 + ], + "visible": true + }, + { + "vertices": [ + 5, + 6 + ], + "visible": true + }, + { + "vertices": [ + 6, + 7 + ], + "visible": true + }, + { + "vertices": [ + 7, + 4 + ], + "visible": true + }, + { + "vertices": [ + 0, + 4 + ], + "visible": false + }, + { + "vertices": [ + 1, + 5 + ], + "visible": true + }, + { + "vertices": [ + 2, + 6 + ], + "visible": true + }, + { + "vertices": [ + 3, + 7 + ], + "visible": true + } + ] +} \ No newline at end of file diff --git a/docs/advanced/figures/cuboid-3d/cuboid-faces.png b/docs/advanced/figures/cuboid-3d/cuboid-faces.png new file mode 100644 index 000000000..1e3c3d7a6 Binary files /dev/null and b/docs/advanced/figures/cuboid-3d/cuboid-faces.png differ diff --git a/docs/advanced/figures/cuboid-3d/cuboid-faces.typ b/docs/advanced/figures/cuboid-3d/cuboid-faces.typ new file mode 100644 index 000000000..aac73d584 --- /dev/null +++ b/docs/advanced/figures/cuboid-3d/cuboid-faces.typ @@ -0,0 +1,177 @@ +#import "@preview/cetz:0.3.4" + +// Explicit page dims + centred canvas gives reliable padding for the +// external labels, which can sit noticeably outside the cube silhouette. +#set page(width: 14cm, height: 10cm, margin: 6pt) +#set text(size: 10pt) + +#let data = json("cuboid-3d-data.json") + +// ── Colours ─────────────────────────────────────────────────────────── +// Each opposite-face pair is coloured by its coordinate axis (RGB-axes +// convention): x-pair (Left/Right) = rust, y-pair (Front/Back) = green, +// z-pair (Upper/Lower) = blue. Hidden faces use the same hue at lower +// alpha so the pairing is legible regardless of visibility. +#let face-fill(name, visible) = { + let (r, g, b) = if name == "Upper" or name == "Lower" { + (85, 130, 195) // blue — z-axis pair + } else if name == "Front" or name == "Back" { + (90, 160, 110) // green — y-axis pair + } else { + (200, 110, 85) // rust — x-axis pair + } + let a = if visible { 140 } else { 30 } + rgb(r, g, b, a) +} +#let visible-edge = 0.9pt + black +#let hidden-edge = (paint: rgb("#808080"), thickness: 0.7pt, dash: "dashed") +#let arrow-colour = rgb("#1f3a6b") +#let arrow-stroke = 0.9pt + arrow-colour + +#let v(i) = (data.vertices_2d.at(i).at(0), data.vertices_2d.at(i).at(1)) + +// ── Face label layout ───────────────────────────────────────────────── +// Two vertical columns at x = ±2, three labels each. Each label is +// paired with a face whose centroid lies on the same side of the +// figure, which prevents line crossings. Lines are bare (no arrow- +// heads) — labels, not symmetry axes. +#let label-overrides = ( + Upper: (pos: (-2.0, +1.5), anchor: "east"), + Left: (pos: (-2.0, 0.0), anchor: "east"), + Back: (pos: (-2.0, -1.5), anchor: "east"), + Front: (pos: (+2.0, +1.5), anchor: "west"), + Right: (pos: (+2.0, 0.0), anchor: "west"), + Lower: (pos: (+2.0, -1.5), anchor: "west"), +) + +// ── Axis triad at V6 (the cuboid corner nearest the viewer) ────────── +// The cube projection itself is unchanged (so the face labels Left/Right +// stay where they were). Only the triad's +y arrow is flipped from the +// projected-y direction so the triad reads as right-handed — pointing +// the +y axis away from the viewer instead of toward. +#let TRIAD-LEN = 0.45 +#let AXIS-X-2D = (0.86603, -0.5) // projected +x direction +#let AXIS-Y-2D = (0.86603, 0.5) // triad-only: flipped from (-0.866, -0.5) +#let AXIS-Z-2D = (0.0, 1.0) // projected +z direction + +#align(center + horizon, cetz.canvas(length: 1.5cm, { + import cetz.draw: * + + // Split back-to-front order into hidden and visible groups, preserving + // each group's internal painter-order for correct translucency stacking. + let hidden-names = data.faces_back_to_front.filter( + n => not data.faces.at(n).visible) + let visible-names = data.faces_back_to_front.filter( + n => data.faces.at(n).visible) + + let draw-face(name) = { + let face = data.faces.at(name) + let idxs = face.vertex_indices + line( + v(idxs.at(0)), v(idxs.at(1)), + v(idxs.at(2)), v(idxs.at(3)), + close: true, fill: face-fill(name, face.visible), stroke: none, + ) + } + + let draw-face-line(name) = { + // Bare line from the (column-aligned) label position to the face + // centroid — no arrowhead. Hidden-face lines are drawn before the + // visible face fills, so their inner segments fade behind the + // translucent fronts. + let face = data.faces.at(name) + let lbl-pos = label-overrides.at(name).pos + let tip = (face.arrow_to.at(0), face.arrow_to.at(1)) + line(lbl-pos, tip, stroke: arrow-stroke) + } + + // Small 3D "badge" at the end of each label line: a shrunken copy of + // the face's projected shape, sharing its axis-pair colour. + let draw-face-marker(name) = { + let face = data.faces.at(name) + let idxs = face.vertex_indices + let c = (face.arrow_to.at(0), face.arrow_to.at(1)) + let shrink-by = 0.18 + let shrunk(idx) = { + let vi = v(idx) + (c.at(0) + (vi.at(0) - c.at(0)) * shrink-by, + c.at(1) + (vi.at(1) - c.at(1)) * shrink-by) + } + let fill-col = { + let (r, g, b) = if name == "Upper" or name == "Lower" { + (85, 130, 195) + } else if name == "Front" or name == "Back" { + (90, 160, 110) + } else { + (200, 110, 85) + } + rgb(r, g, b, 230) + } + line(shrunk(idxs.at(0)), shrunk(idxs.at(1)), + shrunk(idxs.at(2)), shrunk(idxs.at(3)), + close: true, fill: fill-col, stroke: 0.7pt + arrow-colour) + } + + // 1. Hidden face fills (farthest-first painter order within group). + for name in hidden-names { draw-face(name) } + + // 2. Hidden-face label lines + markers — drawn BEFORE the visible + // face fills so their inner segments (and the small badges that + // sit at the face centroid) fade behind the translucent fronts. + for name in hidden-names { draw-face-line(name); draw-face-marker(name) } + + // 3. Hidden edges (dashed) — same "behind the translucent front" idea. + for edge in data.edges { + if not edge.visible { + let v0 = v(edge.vertices.at(0)) + let v1 = v(edge.vertices.at(1)) + line(v0, v1, stroke: hidden-edge) + } + } + + // 4. Visible face fills on top — translucently cover the hidden arrows + // and edges that lie inside the cube silhouette. + for name in visible-names { draw-face(name) } + + // 5. Visible-face label lines + markers — fully in front of everything. + for name in visible-names { draw-face-line(name); draw-face-marker(name) } + + // 6. Visible edges — solid black on top of everything structural. + for edge in data.edges { + if edge.visible { + let v0 = v(edge.vertices.at(0)) + let v1 = v(edge.vertices.at(1)) + line(v0, v1, stroke: visible-edge) + } + } + + // 7. Axis triad at V6 — the corner nearest the viewer. Short arrows + // in the projected +x, +y, +z directions, with italic-math labels. + let triad-origin = v(6) + let triad-stroke = 0.9pt + arrow-colour + let triad-mark = (end: ">", fill: arrow-colour) + let triad-arrow(dir-2d, label-offset-factor, lbl, lbl-anchor) = { + let tip = ( + triad-origin.at(0) + TRIAD-LEN * dir-2d.at(0), + triad-origin.at(1) + TRIAD-LEN * dir-2d.at(1), + ) + line(triad-origin, tip, stroke: triad-stroke, mark: triad-mark) + let lbl-pos = ( + triad-origin.at(0) + label-offset-factor * TRIAD-LEN * dir-2d.at(0), + triad-origin.at(1) + label-offset-factor * TRIAD-LEN * dir-2d.at(1), + ) + content(lbl-pos, + text(fill: arrow-colour, size: 10pt, style: "italic", lbl), + anchor: lbl-anchor) + } + triad-arrow(AXIS-X-2D, 1.30, $x$, "north-west") + triad-arrow(AXIS-Y-2D, 1.30, $y$, "north-east") + triad-arrow(AXIS-Z-2D, 1.22, $z$, "south") + + // 8. Face labels last — always on top; they live outside the cube. + for (name, override) in label-overrides.pairs() { + content(override.pos, + text(fill: black, size: 10pt, name), + anchor: override.anchor) + } +})) diff --git a/docs/advanced/figures/cuboid-3d/generate-cuboid-3d-data.py b/docs/advanced/figures/cuboid-3d/generate-cuboid-3d-data.py new file mode 100644 index 000000000..8f72b21e6 --- /dev/null +++ b/docs/advanced/figures/cuboid-3d/generate-cuboid-3d-data.py @@ -0,0 +1,197 @@ +""" +Data for the 3D cuboid "boundary labels" sketch. + +Isometric projection of a rectangular box with the standard UW3 face +labels (Upper / Lower / Left / Right / Front / Back). Each face gets +an arrow from outside the box toward its centroid, and a text label +at the arrow tail. The cetz figure just renders what this script +emits — no 3D math in Typst. + +Output schema: + + { + "vertices_2d": [[sx, sy], ... 8 entries], + "faces": { + "": { + "vertex_indices": [i, j, k, l], # CCW when looking at face + "visible": bool, # w.r.t. isometric viewer + "arrow_from": [sx, sy], # tail, outside the box + "arrow_to": [sx, sy], # tip, at face centroid + "label_pos": [sx, sy], # anchor for label text + "label_anchor": "west"|"east"|"north"|"south"|..., + }, ... + }, + "faces_back_to_front": [, ...], # painter's-algorithm order + "edges": [ + {"vertices": [i, j], "visible": bool}, ... + ] + } +""" +import json +import math +from pathlib import Path + +# Cuboid half-extents — deliberately non-cubic so the axes read differently. +X_HALF = 1.0 +Y_HALF = 0.75 +Z_HALF = 0.5 + +# Isometric projection. +# Axes: x → right-down, y → left-down, z → up +# Point (x, y, z) → 2D (sx, sy): +# sx = (x - y) * cos(30°) +# sy = z - (x + y) * sin(30°) +COS30 = math.cos(math.radians(30.0)) +SIN30 = 0.5 + + +def project(p): + x, y, z = p + return ((x - y) * COS30, z - (x + y) * SIN30) + + +def project_direction(nx, ny, nz): + """Project a 3D direction vector onto the screen plane.""" + return ((nx - ny) * COS30, nz - (nx + ny) * SIN30) + + +# ── 8 vertices, indexed by (xs, ys, zs) with xs, ys, zs ∈ {−1, +1} ─── +def vertex(xs, ys, zs): + return (xs * X_HALF, ys * Y_HALF, zs * Z_HALF) + + +V = [ + vertex(-1, -1, -1), # 0 + vertex(+1, -1, -1), # 1 + vertex(+1, +1, -1), # 2 + vertex(-1, +1, -1), # 3 + vertex(-1, -1, +1), # 4 + vertex(+1, -1, +1), # 5 + vertex(+1, +1, +1), # 6 + vertex(-1, +1, +1), # 7 +] + +# ── Faces: vertex indices, outward normals, and UW3 label names ────── +# In this convention +x → Right, -x → Left, +y → Back, -y → Front, +# +z → Upper, -z → Lower. +FACES = { + "Upper": {"vertex_indices": [4, 5, 6, 7], "normal": (0, 0, +1)}, + "Lower": {"vertex_indices": [0, 1, 2, 3], "normal": (0, 0, -1)}, + "Front": {"vertex_indices": [0, 1, 5, 4], "normal": (0, -1, 0)}, + "Back": {"vertex_indices": [2, 3, 7, 6], "normal": (0, +1, 0)}, + "Left": {"vertex_indices": [0, 3, 7, 4], "normal": (-1, 0, 0)}, + "Right": {"vertex_indices": [1, 2, 6, 5], "normal": (+1, 0, 0)}, +} + +# ── Edges and which two faces each one borders ──────────────────────── +EDGES = [ + # bottom square + ([0, 1], ("Lower", "Front")), + ([1, 2], ("Lower", "Right")), + ([2, 3], ("Lower", "Back")), + ([3, 0], ("Lower", "Left")), + # top square + ([4, 5], ("Upper", "Front")), + ([5, 6], ("Upper", "Right")), + ([6, 7], ("Upper", "Back")), + ([7, 4], ("Upper", "Left")), + # verticals + ([0, 4], ("Front", "Left")), + ([1, 5], ("Front", "Right")), + ([2, 6], ("Back", "Right")), + ([3, 7], ("Back", "Left")), +] + +# ── Visibility ──────────────────────────────────────────────────────── +# With the projection above (+x → right-down, +y → left-down, +z → up), +# the viewer sits at the (+1, +1, +1) corner direction — upper-back-right. +# A face is visible iff its outward normal has a positive component along +# that viewer direction, i.e. iff nx + ny + nz > 0. +# ───────────────────────────────────────────────────────────────────── +def face_centroid_3d(name): + idxs = FACES[name]["vertex_indices"] + return tuple(sum(V[i][k] for i in idxs) / 4 for k in range(3)) + + +def depth_from_viewer(p3d): + """Larger = closer to the viewer (at +x+y+z direction).""" + return p3d[0] + p3d[1] + p3d[2] + + +for name in FACES: + c3d = face_centroid_3d(name) + FACES[name]["centroid_3d"] = c3d + nx, ny, nz = FACES[name]["normal"] + FACES[name]["visible"] = (nx + ny + nz) > 0 + +# ── 2D projections ──────────────────────────────────────────────────── +vertices_2d = [project(v) for v in V] + +for name, face in FACES.items(): + c2d = project(face["centroid_3d"]) + face["centroid_2d"] = c2d + # Outward-normal direction in 2D, for placing arrow + label outside box. + nx, ny, nz = face["normal"] + dir_2d = project_direction(nx, ny, nz) + dlen = math.hypot(dir_2d[0], dir_2d[1]) + if dlen > 1e-9: + dir_2d = (dir_2d[0] / dlen, dir_2d[1] / dlen) + face["dir_2d"] = dir_2d + +# ── Arrow tail, tip, and label position for each face ──────────────── +# Arrow goes FROM outside (at distance TAIL_DIST along face's 2D outward +# normal) TO the face centroid. Label sits just beyond the arrow tail. +TAIL_DIST = 0.80 +LABEL_DIST = 1.15 + +for name, face in FACES.items(): + dx, dy = face["dir_2d"] + cx, cy = face["centroid_2d"] + face["arrow_from"] = [cx + TAIL_DIST * dx, cy + TAIL_DIST * dy] + face["arrow_to"] = [cx, cy] + face["label_pos"] = [cx + LABEL_DIST * dx, cy + LABEL_DIST * dy] + # Pick a text anchor so the label sits past the tail, not on top of it. + face["label_anchor"] = ( + "south" if dy > 0.5 else + "north" if dy < -0.5 else + "west" if dx > 0.0 else + "east" + ) + +# ── Edge visibility: an edge is hidden if *both* its faces are hidden ─ +edges_out = [] +for verts, (face_a, face_b) in EDGES: + visible = FACES[face_a]["visible"] or FACES[face_b]["visible"] + edges_out.append({"vertices": verts, "visible": visible}) + +# ── Painter's-algorithm order for fills: farthest first ────────────── +# Farthest from viewer ↔ smallest depth_from_viewer ↔ smallest x+y+z. +faces_back_to_front = sorted( + FACES.keys(), key=lambda n: depth_from_viewer(FACES[n]["centroid_3d"]) +) + +# ── Assemble JSON ───────────────────────────────────────────────────── +data = { + "vertices_2d": [[round(x, 4), round(y, 4)] for x, y in vertices_2d], + "faces": { + name: { + "vertex_indices": face["vertex_indices"], + "visible": face["visible"], + "arrow_from": [round(c, 4) for c in face["arrow_from"]], + "arrow_to": [round(c, 4) for c in face["arrow_to"]], + "label_pos": [round(c, 4) for c in face["label_pos"]], + "label_anchor": face["label_anchor"], + } + for name, face in FACES.items() + }, + "faces_back_to_front": faces_back_to_front, + "edges": edges_out, +} + +out = Path(__file__).with_name("cuboid-3d-data.json") +out.write_text(json.dumps(data, indent=2)) +print(f"wrote {out.name}: " + f"{len(data['vertices_2d'])} vertices, " + f"{len(data['faces'])} faces " + f"({sum(1 for f in data['faces'].values() if f['visible'])} visible), " + f"{len(data['edges'])} edges") diff --git a/docs/advanced/figures/curved-bc/curved-bc-data.json b/docs/advanced/figures/curved-bc/curved-bc-data.json new file mode 100644 index 000000000..0abb07578 --- /dev/null +++ b/docs/advanced/figures/curved-bc/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/docs/advanced/figures/curved-bc/facet-vs-true-normals.png b/docs/advanced/figures/curved-bc/facet-vs-true-normals.png new file mode 100644 index 000000000..dd387c665 Binary files /dev/null and b/docs/advanced/figures/curved-bc/facet-vs-true-normals.png differ diff --git a/docs/advanced/figures/curved-bc/facet-vs-true-normals.typ b/docs/advanced/figures/curved-bc/facet-vs-true-normals.typ new file mode 100644 index 000000000..8f1b71691 --- /dev/null +++ b/docs/advanced/figures/curved-bc/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/docs/advanced/figures/curved-bc/generate-curved-bc-data.py b/docs/advanced/figures/curved-bc/generate-curved-bc-data.py new file mode 100644 index 000000000..5af372f8b --- /dev/null +++ b/docs/advanced/figures/curved-bc/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/docs/advanced/figures/ti_vep_benchmark_final.png b/docs/advanced/figures/ti_vep_benchmark_final.png new file mode 100644 index 000000000..f23362660 Binary files /dev/null and b/docs/advanced/figures/ti_vep_benchmark_final.png differ diff --git a/docs/advanced/index.md b/docs/advanced/index.md index e83b680e2..eaf2d99b3 100644 --- a/docs/advanced/index.md +++ b/docs/advanced/index.md @@ -18,11 +18,23 @@ Profile, optimize, and scale your simulations. **[→ Performance Guide](performance.md)** +### Multigrid Preconditioning (FMG vs GAMG) +Robust, anisotropy-tolerant solves on adapted meshes — build a mesh with +`refinement` and the solver uses geometric Full Multigrid automatically. + +**[→ Multigrid Preconditioning](multigrid-preconditioning.md)** + ### Complex Rheologies Implement advanced material models and constitutive laws. **[→ Complex Rheologies](complex-rheologies.md)** +### VEP with Transverse Isotropy for Fault Mechanics +Viscoelastic-plastic rheology with anisotropic weak planes and resolved +fault-plane yield for modelling fault zones. + +**[→ VEP + Transverse Isotropy](vep-transverse-isotropy-faults.md)** + ### Custom Meshes Create complex geometries with gmsh for research problems. @@ -38,6 +50,25 @@ Dynamic remeshing and adaptive refinement strategies. **[→ Mesh Adaptation](mesh-adaptation.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 +(SLCN vs SL-BDF2). + +**[→ Semi-Lagrangian Time Integration](semi-lagrangian-time-integration.md)** + +### Porous Media Flow +Darcy flow, Richards equation, and variably-saturated groundwater modelling. + +**[→ Porous Media Flow](porous-flow.md)** + +### State Snapshots & Restore +A "stash for timesteps": snapshot the full model state, try a step, +restore exactly if you don't like it. For backtracking, adaptive Δt, +and predictor–corrector workflows. + +**[→ State Snapshots & Restore](snapshot-restore.md)** + ### Troubleshooting Common issues, debugging strategies, and solutions. @@ -68,10 +99,16 @@ Ready to contribute to Underworld3? parallel-computing performance +multigrid-preconditioning +solver-iteration-callbacks complex-rheologies +vep-transverse-isotropy-faults custom-meshes curved-boundary-conditions mesh-adaptation +semi-lagrangian-time-integration +porous-flow +snapshot-restore troubleshooting api-patterns SWARM-INTEGRATION-STATISTICS diff --git a/docs/advanced/mesh-adaptation.md b/docs/advanced/mesh-adaptation.md index 6120fa2c9..6e5a39667 100644 --- a/docs/advanced/mesh-adaptation.md +++ b/docs/advanced/mesh-adaptation.md @@ -60,6 +60,28 @@ for each edge vector $\mathbf{e}$. Edges that are too long get subdivided; regio **Key insight**: Higher metric values produce finer mesh. If you want 10× refinement, the metric values should be ~100× larger (since $M \propto 1/h^2$). ``` +### Two families of mesh adaptation + +UW3 offers **two complementary** ways to put resolution where it is +needed: + +| | `mesh.adapt(...)` (this page) | `smooth_mesh_interior(method="anisotropic")` | +|---|---|---| +| 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* | +| Topology | New mesh, **variables reset** (must transfer) | **Unchanged** — variables, DOFs, partition preserved | +| Grading reach | Strong (can add nodes → ~10×) | Capped by the node count (~1.5–2×) | +| Cell shape | Isotropic (`M = h⁻²I`) | **Anisotropic** — cells aligned to the feature | +| 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 +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 +topology (and all fields) intact — see the **Node redistribution** +section below. + --- ## Metric Creation Functions @@ -353,6 +375,100 @@ For the mathematically inclined, see the [Developer Design Document](../develope --- +## Node redistribution — the snuggling mover + +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`: + +```python +import underworld3 as uw +from underworld3.meshing import ( + smooth_mesh_interior, metric_density_from_gradient) + +# ... mesh + a temperature field T after some solve ... + +# Relative target DENSITY from |∇T| (the fixed-node-budget +# analogue of metric_from_gradient: same percentile-window idea, +# but ρ is a *density*, not an absolute h — there is no node +# budget to spend on an absolute size). +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) +``` + +```{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 +*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 +above; it is promoted to the isotropic tensor `ρ·I`) or a `d×d` +**tensor** metric (e.g. {py:func}`fault_metric_tensor`, or a +`grad T`-aligned boundary-layer tensor) to get true thin-across / +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. + +Key `mmpde` knobs (via `method_kwargs`): `p` (functional exponent, +1.5–2), `theta` (Huang alignment/equidistribution balance, 1/3), +`step_frac` (per-node move cap, 0.2), `tol` (scale-relative +convergence exit), `metric_eval` (`"rbf"` default — fast baked +metric interpolation). `boundary_slip=True` lets boundary nodes +slide tangentially (needed for surface-reaching features). +``` + +`metric_density_from_gradient` builds +$\rho = 1 + \mathrm{amp}\cdot t$, $t = \mathrm{clip}\big((|\nabla +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 +*changes* (boundary layers, fronts, plume edges), and is +isotropic-coarse at a smooth *peak* ($\nabla\rho=0$) — it +deliberately de-refines a feature's core. For core resolution a +curvature (Hessian) metric is the (future) tool. It also does +**not** beat the fixed node-count cap — for a *separable* feature +the explicit 1-D OT is exact and cheaper; the mover earns its keep +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 +{doc}`/developer/design/mesh-adaptation-formulation`; operational +detail in {doc}`/developer/subsystems/mesh-metric-redistribution`; +the dated R&D log in +`docs/developer/design/ma-newton-cofactor-exploration.md`. + +--- + ## References 1. MMG Platform: https://www.mmgtools.org/ diff --git a/docs/advanced/multigrid-preconditioning.md b/docs/advanced/multigrid-preconditioning.md new file mode 100644 index 000000000..fd8369582 --- /dev/null +++ b/docs/advanced/multigrid-preconditioning.md @@ -0,0 +1,174 @@ +--- +title: "Multigrid Preconditioning (FMG vs GAMG)" +--- + +# Multigrid Preconditioning: FMG vs GAMG + +Underworld3 solvers are preconditioned with multigrid. There are two flavours, +and choosing the right one — or letting the solver choose for you — makes a large +difference to robustness and cost, especially on adapted or anisotropic meshes. + +- **GAMG** (*algebraic* multigrid) builds its coarse levels from the assembled + operator's connection graph. It is general and needs no mesh hierarchy, but it + is **sensitive to anisotropy**: stretched cells (exactly what mesh adaptation + produces) degrade the aggregates and the iteration count can cliff. +- **FMG** (geometric *Full Multigrid*) builds its coarse levels from a genuine + **mesh refinement hierarchy**. Because the hierarchy is geometric, it is + **inherently robust to anisotropy** — the coarse spaces are correct regardless + of how the operator is stretched. + +## The one-liner: build a mesh with `refinement` + +Geometric multigrid needs a refinement hierarchy. Build one by passing +`refinement=N` to any mesh constructor: + +```python +import underworld3 as uw + +# refinement=2 -> a 3-level geometric hierarchy (coarse -> medium -> fine) +mesh = uw.meshing.Annulus(radiusInner=0.5, radiusOuter=1.0, + cellSize=0.1, refinement=2) + +stokes = uw.systems.Stokes(mesh) +# ... constitutive model, body force, boundary conditions ... +stokes.solve() # velocity block is preconditioned with geometric FMG +``` + +That is the whole story for the common case. When the mesh carries a hierarchy, +the solver **automatically** uses geometric Full Multigrid on the velocity block +(for Stokes) or the top-level preconditioner (for scalar/vector solvers). When it +does not, the solver falls back to GAMG. You do not need to set any PETSc options. + +## The `preconditioner` knob + +Every Stokes / scalar / vector solver exposes a `preconditioner` property: + +```python +stokes.preconditioner = "auto" # default: FMG if the mesh has a hierarchy, else GAMG +stokes.preconditioner = "fmg" # force geometric multigrid (warns + falls back if no hierarchy) +stokes.preconditioner = "gamg" # force algebraic multigrid (the historical default) +``` + +`"mg"` is accepted as an alias for `"fmg"`. + +```{note} +`"auto"` is conservative. It only ever *adds* geometric multigrid on top of an +untouched default — it never rewrites a preconditioner you configured yourself. If +you set `pc_type` directly through `solver.petsc_options[...]`, or a solver applies +its own tuned options internally, `"auto"` leaves those settings alone. +``` + +## What the FMG bundle actually sets + +For reference (you should rarely need to set these by hand), selecting geometric +multigrid is equivalent to the following options on the relevant block +(`fieldsplit_velocity_` for Stokes, top-level for scalar/vector): + +```python +pc_type = "mg" +pc_mg_type = "full" # Full Multigrid (F-cycle) +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 +``` + +**Why Galerkin coarse operators?** Underworld3 does not install +residual/Jacobian callbacks on the coarse DMs, so the coarse operators must be +formed by Galerkin projection ($R A P$) from the fine operator rather than by +re-discretising on the coarse mesh. `pc_mg_galerkin = both` does this. + +## Hierarchy and mesh adaptation + +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 + 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 + level. In `"auto"` mode the solver detects this and transparently reverts to + GAMG on the next solve, so nothing breaks — you simply lose the geometric path + until a hierarchy is available again. + +## Parallel coarse solve + +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: + +```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" + +# or telescope onto a sub-communicator for very large runs +# stokes.petsc_options["fieldsplit_velocity_mg_coarse_pc_type"] = "telescope" +``` + +Because `"auto"` does not overwrite options you set yourself, you can keep +`preconditioner = "auto"` and just override the coarse solver as above. + +## Benchmark: FMG vs GAMG on a deforming adaptive mesh + +The payoff is clearest on an aggressively adapted mesh. The figure below is a +Stokes convection run (annulus, Ra = 10⁷, Δη = 10³, res 32, resolution-ratio +R = 8, mode-1, `np = 5`) whose mesh is continuously deformed by the MMPDE +coordinate mover, adapted every timestep. The geometric hierarchy (3 levels) +survives every step — topology is preserved — and both engines converge cleanly +(`snes` reason 3) throughout. FMG (`PCVEL=gmg MG_TYPE=full`) and GAMG +(`PCVEL=amg`) ran the *identical* adapted-mesh sequence over 50 steps. + +```{figure} figures/bench_fmg_vs_gamg_velocity_ksp.svg +:alt: Inner velocity-block KSP iterations and Stokes-solve wall time versus adaptation step, for FMG and GAMG. + +**Velocity-block solver scaling under adaptive remeshing.** Inner velocity-block +KSP iterations (top) and Stokes-solve wall time (bottom) versus adaptation step. +Geometric full multigrid (FMG) keeps a mesh-independent ≈ 5 inner iterations as +the cells stretch, where algebraic multigrid (GAMG) runs a volatile ≈ 64–131 +(≈ 23×) without cliffing at this anisotropy. The wall-clock gap is only ≈ 1.8×: +each GAMG V-cycle is far cheaper than an FMG F-cycle, and the cold-start Stokes +solve after each adapt (common to both) dominates the time. The outer Schur KSP +converges in one iteration for both — the difference is entirely in the inner +velocity block. +``` + +The metric that matters is the **inner velocity-block KSP iteration count** — the +outer Schur KSP is one iteration for both engines, so the entire difference lives +in this inner block: + +- **FMG** holds a **mesh-independent ≈ 5** iterations (3–6) as the anisotropy + sharpens — the geometric-MG signature. +- **GAMG** runs a **volatile ≈ 64–131** (median ≈ 114, so ≈ 23×) as the algebraic + aggregates cope with the stretched cells. It does *not* cliff at R = 8 over + these 50 steps, but it is unpredictable from step to step. + +The wall-clock gap is only **≈ 1.8×**, not 23×: a single GAMG V-cycle is far +cheaper than an FMG F-cycle, and the cold-start Stokes solve after each adapt +(common to both engines) dominates the per-step time. So the value of geometric +FMG here is **predictability and mesh-independence** — properties that widen with +problem size and multigrid depth — rather than a large raw speed-up. The iteration +gap is also where GAMG eventually loses robustness on harder problems (higher +anisotropy, more levels). + +The per-step data is in +[`figures/bench_fmg_vs_gamg_velocity_ksp.csv`](figures/bench_fmg_vs_gamg_velocity_ksp.csv); +the reproduction command and full provenance are in the companion note +`figures/bench_fmg_vs_gamg_velocity_ksp.md`. + +## When to use which + +| Situation | Recommended | +|-----------|-------------| +| Adapted / deformed / anisotropic meshes | **FMG** (build with `refinement`) | +| Uniform mesh, want mesh-independent iteration counts | **FMG** | +| No refinement hierarchy available / quick prototype | GAMG (automatic fallback) | +| Reproducing historical results exactly | `preconditioner = "gamg"` | + +## See also + +- {doc}`mesh-adaptation` — the movers whose anisotropy FMG handles gracefully +- {doc}`performance` — profiling and scaling +- `docs/developer/design/solver-strategies-catalogue.md` — the solver-strategy + design notes (developer-facing) diff --git a/docs/advanced/parallel-computing.md b/docs/advanced/parallel-computing.md index 81aa1e069..7181f7c10 100644 --- a/docs/advanced/parallel-computing.md +++ b/docs/advanced/parallel-computing.md @@ -508,6 +508,26 @@ These operations require **ALL ranks** to participate: - [ ] Test with `mpirun -np 2` and `mpirun -np 4` - [ ] Check for deadlocks (script hangs = collective operation issue) +## Timing Output at Extreme Scale + +`uw.timing.print_table()` ultimately calls PETSc's `PetscLogView`. At very +high CPU counts (≳1000 ranks), the **ASCII output path** can hang — +typically appearing as a job that completes its computation cleanly but +never exits. The CSV write path uses a different, less collective-heavy +strategy and avoids the issue: + +```python +# Default — fine at small scale, can hang at ≳1000 ranks +uw.timing.print_table() +uw.timing.print_table("results.txt") + +# Safe at any scale — recommended for HPC runs +uw.timing.print_table("results.csv") +``` + +The behaviour is in PETSc, not Underworld; choosing CSV at scale is the +recommended workaround. (Issue #134.) + ## Summary **Key Takeaways:** @@ -517,5 +537,6 @@ These operations require **ALL ranks** to participate: 3. **Use `with uw.selective_ranks(ranks):`** for serial operations 4. **Collective operations must run on ALL ranks** - never inside rank conditionals 5. **Test with `mpirun -np N`** to catch issues early +6. **At ≳1000 ranks, write timing output as `.csv`** to avoid `PetscLogView` hangs The parallel safety system makes parallel programming in Underworld3 safer and more intuitive - collective operations are evaluated on all ranks automatically, preventing common deadlock scenarios! diff --git a/docs/advanced/porous-flow.md b/docs/advanced/porous-flow.md new file mode 100644 index 000000000..e3219009b --- /dev/null +++ b/docs/advanced/porous-flow.md @@ -0,0 +1,299 @@ +--- +title: "Porous Media Flow" +--- + +# Porous Media Flow + +Underworld3 provides a hierarchy of solvers for groundwater and variably-saturated +porous media flow. This guide explains when to use each solver, how to configure +retention curves, and practical tips for nonlinear convergence. + +## Solver Hierarchy + +Three solvers are available, each building on the previous one: + +| Solver | Equation | Use case | +|--------|----------|----------| +| {class}`~underworld3.systems.solvers.SNES_Darcy` | $-\nabla\cdot[K\nabla h - \mathbf{s}] = f$ | Steady-state, fully saturated | +| {class}`~underworld3.systems.solvers.SNES_TransientDarcy` | $S_s\,\partial h/\partial t - \nabla\cdot[K\nabla h - \mathbf{s}] = f$ | Transient, constant storage | +| {class}`~underworld3.systems.solvers.SNES_Richards` | $\partial\theta/\partial t - \nabla\cdot[K(\psi)(\nabla\psi - \mathbf{s})] = f$ | Variably-saturated, nonlinear | + +All three use the `DarcyFlowModel` constitutive model, which defines +permeability $K$ and a gravity-like source vector $\mathbf{s}$. + +### Choosing a Solver + +- **Steady-state, constant permeability** — use `SteadyStateDarcy` (alias for `SNES_Darcy`). + No time stepping needed; just call `solve()`. +- **Transient, constant storage** — use `TransientDarcy`. + Set `solver.storage` to the specific storage coefficient $S_s$ and advance with + `solve(timestep=dt)`. +- **Variably-saturated** — use `Richards`. + Permeability and storage depend nonlinearly on pressure head $\psi$. + This solver handles the stiff nonlinearities arising from soil-water retention curves. + +Python access: + +```python +import underworld3 as uw + +darcy = uw.systems.SteadyStateDarcy(mesh, h_Field=h, v_Field=v) +transient = uw.systems.TransientDarcy(mesh, h_Field=h, v_Field=v, order=1) +richards = uw.systems.Richards(mesh, psi_Field=psi, v_Field=v, order=1) +``` + +## Retention Curves + +The Richards equation requires soil-water retention curves that describe how +moisture content $\theta$ and hydraulic conductivity $K$ vary with +pressure head $\psi$. + +Underworld3 provides three models in +{mod}`underworld3.utilities.retention_curves`: + +### Van Genuchten--Mualem + +The most widely used model in hydrology. Parameters: $\alpha$, $n$, $K_s$, +$\theta_r$, $\theta_s$. + +```python +from underworld3.utilities.retention_curves import ( + van_genuchten_K, + van_genuchten_theta, +) + +psi_sym = psi.sym[0] + +K_expr = van_genuchten_K(psi_sym, Ks=1e-4, alpha=3.35, n=2.0) +theta_expr = van_genuchten_theta( + psi_sym, theta_r=0.045, theta_s=0.43, alpha=3.35, n=2.0 +) +``` + +Typical parameter ranges (SI units, $\psi$ in metres): + +| Soil type | $\alpha$ (1/m) | $n$ | $K_s$ (m/s) | $\theta_r$ | $\theta_s$ | +|-----------|----------------|-----|-------------|-------------|-------------| +| Sand | 14.5 | 2.68 | $8.25 \times 10^{-5}$ | 0.045 | 0.43 | +| Loam | 3.6 | 1.56 | $2.89 \times 10^{-6}$ | 0.078 | 0.43 | +| Clay | 0.8 | 1.09 | $5.56 \times 10^{-7}$ | 0.068 | 0.38 | + +### Gardner Exponential + +A simpler model with an analytical steady-state solution — ideal for +verification: + +$$K(\psi) = K_s \, e^{\alpha\psi}$$ + +```python +from underworld3.utilities.retention_curves import ( + gardner_K, + gardner_theta, + gardner_steady_state_psi, +) + +K_expr = gardner_K(psi_sym, Ks=1.0, alpha=2.0) +theta_expr = gardner_theta(psi_sym, theta_r=0.05, theta_s=0.4, alpha=2.0) + +# Exact steady-state profile for benchmarking +psi_exact = gardner_steady_state_psi(y_coords, psi_0=-3.0, psi_L=-0.5, L=1.0, alpha=2.0) +``` + +### Haverkamp + +A rational-function model where retention ($\alpha$, $\beta$) and +conductivity ($A$, $B$) have **independent** parameters, giving extra +flexibility when fitting laboratory data. Used in the +Vauclin (1979) water-table recharge benchmark. + +$$\theta(\psi) = \theta_r + \frac{\alpha\,(\theta_s - \theta_r)}{\alpha + |\psi|^{\beta}}, +\qquad +K(\psi) = K_s\,\frac{A}{A + |\psi|^B}$$ + +```python +from underworld3.utilities.retention_curves import ( + haverkamp_K, + haverkamp_theta, + haverkamp_C, +) + +# Vauclin (1979) benchmark parameters (CGS, ψ in cm) +K_expr = haverkamp_K(psi_sym, Ks=9.44e-5, A=1.175e6, B=4.74) +theta_expr = haverkamp_theta( + psi_sym, theta_r=0.075, theta_s=0.287, alpha=1.611e6, beta=3.96 +) +``` + +### Choosing a Retention Model + +| Model | Strengths | Typical use | +|-------|-----------|-------------| +| Van Genuchten--Mualem | Widely validated, coupled $K$--$\theta$ | General-purpose simulations | +| Gardner exponential | Admits analytical solutions | Verification benchmarks | +| Haverkamp | Independent $K$ and $\theta$ params | Lab data fitting, Vauclin benchmark | + +## Setting Up a Richards Solver + +### Mixed Form (Recommended) + +The **mixed form** discretises the storage term as +$(\theta(\psi^{n+1}) - \theta(\psi^n))/\Delta t$, which is exactly +mass-conservative. This is the preferred approach. + +```python +richards = uw.systems.Richards(mesh, psi_Field=psi, v_Field=v, order=1, theta=0.5) + +# Constitutive model (permeability + gravity) +richards.constitutive_model = uw.constitutive_models.DarcyFlowModel +richards.constitutive_model.Parameters.permeability = van_genuchten_K( + psi.sym[0], Ks=1e-4, alpha=3.35, n=2.0 +) +richards.constitutive_model.Parameters.s = sympy.Matrix([0, -1]).T + +# Mixed form: provide θ(ψ) directly +richards.water_content = van_genuchten_theta( + psi.sym[0], theta_r=0.045, theta_s=0.43, alpha=3.35, n=2.0 +) + +# Source term +richards.f = 0.0 +``` + +When `water_content` is set, the solver computes the Jacobian +$\partial\theta/\partial\psi = C(\psi)$ automatically via PETSc's +finite-difference colouring. You do **not** need to provide $C(\psi)$ +separately. + +### Head-Based Form (Backward Compatible) + +The head-based form discretises the storage as +$C(\psi)(\psi^{n+1} - \psi^n)/\Delta t$. This is simpler but not +mass-conservative when $C(\psi)$ varies sharply. + +```python +from underworld3.utilities.retention_curves import van_genuchten_C + +richards.capacity = van_genuchten_C( + psi.sym[0], theta_r=0.045, theta_s=0.43, alpha=3.35, n=2.0 +) +``` + +If both `water_content` and `capacity` are set, the mixed form takes precedence. + +## Time Stepping + +All transient porous flow solvers use BDF (Backward Differentiation Formula) +time integration with automatic order ramping: + +```python +# First call: BDF-1 (backward Euler) +richards.solve(timestep=dt) + +# Second call onwards: BDF-2 (if order=2 was requested) +richards.solve(timestep=dt) +``` + +The solver automatically: +- Initialises time-derivative history on the first solve call +- Ramps BDF order from 1 up to the requested `order` +- Tracks variable timesteps for correct BDF coefficients + +### Timestep Estimation + +`TransientDarcy` and `Richards` provide a diffusive CFL estimate: + +```python +dt = richards.estimate_dt() +``` + +For the Richards equation with strongly nonlinear retention curves, +you may need to use a smaller timestep than this estimate, especially +near wetting fronts. + +## Convergence Tips + +The Richards equation with Van Genuchten curves is a **stiff nonlinear problem**. +Here are practical strategies for reliable convergence: + +### 1. Use Backtracking Line Search + +```python +richards.petsc_options["snes_linesearch_type"] = "bt" +``` + +The backtracking line search (default is `basic`) helps SNES find a +descent direction when the initial Newton step overshoots. + +### 2. Increase SNES Iterations + +```python +richards.petsc_options["snes_max_it"] = 50 # default is 20 +``` + +Nonlinear problems near saturation may need more iterations. + +### 3. Start From a Smooth Initial Condition + +A linear profile between boundary values is a good starting guess: + +```python +y = mesh.X[1] +psi_init = psi_bottom + (psi_top - psi_bottom) * y +psi.array = uw.function.evaluate(psi_init, psi.coords) +``` + +Abrupt initial conditions (e.g., step functions) cause convergence +difficulties. + +### 4. Use Small Timesteps Initially + +Start with small $\Delta t$ and increase gradually, especially when +wetting fronts are developing: + +```python +dt = 0.001 +for step in range(n_steps): + richards.solve(timestep=dt) + dt = min(dt * 1.2, dt_max) # gradual increase +``` + +### 5. Monitor Convergence + +```python +richards.petsc_options["snes_monitor"] = None +richards.petsc_options["snes_converged_reason"] = None +``` + +## Boundary Conditions + +Boundary conditions follow the standard Underworld3 pattern: + +```python +# Fixed head / pressure head (Dirichlet) +richards.add_dirichlet_bc([0.0], "Top") # saturated surface +richards.add_dirichlet_bc([-5.0], "Bottom") # deep water table + +# Natural (Neumann) boundary conditions are set via richards.f +# and the constitutive model's flux term +``` + +For the Richards equation, typical boundary conditions are: +- **Saturated surface**: $\psi = 0$ (water table at the surface) +- **Deep dry condition**: $\psi = \psi_{\mathrm{init}}$ (initial pressure head) +- **No-flow boundaries**: Natural BC (the default on boundaries without Dirichlet conditions) + +## Tutorials + +For worked examples with complete code: + +- [Tutorial 16 — Richards Equation: Groundwater](../beginner/tutorials/16-Richards-Equation-Groundwater.ipynb): + Steady-state drainage with Gardner curves, comparison to analytical solution. +- [Tutorial 17 — Richards: Transient Wetting Front](../beginner/tutorials/17-Richards-Transient-Wetting-Front.ipynb): + Transient infiltration with Van Genuchten curves, wetting front propagation. + +## API Reference + +- {class}`~underworld3.systems.solvers.SNES_Darcy` — Steady-state Darcy +- {class}`~underworld3.systems.solvers.SNES_TransientDarcy` — Transient Darcy +- {class}`~underworld3.systems.solvers.SNES_Richards` — Richards equation +- {mod}`underworld3.utilities.retention_curves` — Retention curve functions diff --git a/docs/advanced/semi-lagrangian-time-integration.md b/docs/advanced/semi-lagrangian-time-integration.md new file mode 100644 index 000000000..23a935db2 --- /dev/null +++ b/docs/advanced/semi-lagrangian-time-integration.md @@ -0,0 +1,136 @@ +--- +title: "Semi-Lagrangian Time Integration (SLCN / SL-BDF2)" +--- + +# Semi-Lagrangian time integration: SLCN and SL-BDF2 + +`AdvDiffusionSLCN` (and the `SemiLagrangian` history operator behind it) +solves advection–diffusion + +$$\frac{\partial u}{\partial t} + \mathbf{v}\cdot\nabla u + - \nabla\cdot(\kappa\nabla u) = f$$ + +by tracing characteristics backward in time for the advective part and +treating diffusion implicitly. This page explains the **two independent +order knobs** in the scheme and how to pair them correctly — the common +pitfall is mixing them. + +## The scheme has two time-integration choices + +The discrete residual assembled by the solver is + +$$\underbrace{\frac{c_0\,u^{n+1} + c_1\,u^{*} + c_2\,u^{**} + \cdots}{\Delta t}}_{\textbf{time derivative — BDF}} + \;=\; + \nabla\cdot\underbrace{\big[a_0\,\mathbf{F}^{n+1} + a_1\,\mathbf{F}^{*} + \cdots\big]}_{\textbf{flux — Adams–Moulton / }\theta} + \;+\; f$$ + +where $u^{*}, u^{**}, \mathbf{F}^{*}$ are quantities sampled at the +**departure points** of the characteristics (one step, two steps, … +upstream). + +These two sides are controlled separately: + +| term | operator | knob | meaning | +|------|----------|------|---------| +| time derivative $\mathrm{D}u/\mathrm{D}t$ | `DuDt` | **BDF `order`** | backward-difference stencil along the characteristic | +| diffusive flux | `DFDt` | **`theta`** (Adams–Moulton) | time-centring of the flux | + +`DuDt` and `DFDt` are both `SemiLagrangian` operators, reachable as +`adv_diff.DuDt` and `adv_diff.DFDt`. + +:::{important} +**BDF** (backward differentiation) and **Adams–Moulton/θ** are *distinct* +linear-multistep families. A BDF stencil is one-sided implicit and expects +the flux evaluated **at $n+1$ only**; the trapezoidal (Crank–Nicolson) flux +is *centred* between $n$ and $n+1$ and pairs with a single-step difference. +They must be chosen as a **matched pair**. +::: + +## The two standard schemes + +### SLCN — Semi-Lagrangian Crank–Nicolson (default) + +`order = 1`, `theta = 0.5`: + +$$\frac{u^{n+1} - u^{*}}{\Delta t} + = \tfrac12\,\nabla\cdot(\mathbf{F}^{n+1} + \mathbf{F}^{*}) + f .$$ + +This is the classic scheme of Spiegelman & Katz (2006). Although the +stencil and the departure point are formally first order, the +trapezoidal-along-the-trajectory structure recovers **second-order** +accuracy. It is A-stable but not L-stable, so it can ring on stiff, +under-resolved gradients (see `theta` below). + +```python +adv = uw.systems.AdvDiffusionSLCN(mesh, u_Field=T, V_fn=v.sym, order=1) +# theta defaults to 0.5 (Crank-Nicolson) +``` + +### SL-BDF2 — Semi-Lagrangian BDF2 + +`DuDt` BDF `order = 2`, flux **`theta = 1.0`**: + +$$\frac{\tfrac32 u^{n+1} - 2u^{*} + \tfrac12 u^{**}}{\Delta t} + = \nabla\cdot\mathbf{F}^{n+1} + f .$$ + +BDF2 uses **two** departure points and evaluates the flux **implicitly at +$n+1$** (hence `theta = 1.0`, not Crank–Nicolson). It is also second-order +accurate but, unlike CN, avoids the spurious resonance / sign-flip ringing +CN can show on stiff modes (Bonaventura et al., 2021). + +Because the internally-built `DuDt` is fixed at BDF order 1, SL-BDF2 is +selected by supplying an explicit order-2 `DuDt` and setting the flux +`theta`: + +```python +duDt = uw.systems.ddt.SemiLagrangian( + mesh, T.sym, v.sym, + vtype=uw.VarType.SCALAR, degree=T.degree, + continuous=T.continuous, order=2, # BDF2 stencil +) +adv = uw.systems.AdvDiffusionSLCN(mesh, u_Field=T, V_fn=v.sym, DuDt=duDt, order=1) +adv.DFDt.theta = 1.0 # flux implicit at n+1 +``` + +:::{warning} +**Do not mix a BDF2 stencil with a Crank–Nicolson flux** (`order=2` + +`theta=0.5`). The left side is centred at $n+1$ while the right side is +centred at $n+\tfrac12$; the combination is *not* a consistent second-order +scheme. Use SLCN (`order=1`, `theta=0.5`) **or** SL-BDF2 (`order=2`, +`theta=1.0`), not a hybrid. +::: + +## `theta` — Crank–Nicolson vs Backward Euler (flux) + +For the order-1 flux integrator the Adams–Moulton coefficients are +$[\theta,\,1-\theta]$: + +- `theta = 0.5` — **Crank–Nicolson** (trapezoidal): second-order, A-stable. + The default. Can ring on stiff, under-resolved diffusion. +- `theta = 1.0` — **Backward Euler**: L-stable and monotone for diffusion, + first-order on its own — and the correct flux centring for SL-BDF2. + +`theta` is settable after construction: `adv_diff.DFDt.theta = 1.0`. + +## Related options + +- **`monotone_mode`** (`"clamp"` / `"pick"`) bounds the semi-Lagrangian + trace-back to the local data range, curing FE-overshoot ("pepper") + speckles in cells with sharp gradients. Forwarded to both `DuDt` and + `DFDt`. See the `AdvDiffusionSLCN` docstring. +- **`old_frame_traceback`** samples the history on the *previous-step* + mesh geometry rather than the new one — the cure for the semi-Lagrangian + blow-up on a moving (free-surface or adapting) mesh. It is order-agnostic + (works for SLCN and SL-BDF2 alike). See + [`docs/developer/design/lagged-clone-sl-history.md`](../developer/design/lagged-clone-sl-history.md). + +## References + +- Spiegelman, M., & Katz, R. F. (2006). *A semi-Lagrangian Crank–Nicolson + algorithm for the numerical solution of advection–diffusion problems.* + Geochemistry, Geophysics, Geosystems, 7(4). + +- Bonaventura, L., Calzola, E., Carlini, E., & Ferretti, R. (2021). + *Second order fully semi-Lagrangian discretizations of + advection–diffusion–reaction systems.* Journal of Scientific Computing, + 88, 23. diff --git a/docs/advanced/snapshot-restore.md b/docs/advanced/snapshot-restore.md new file mode 100644 index 000000000..2895f4b92 --- /dev/null +++ b/docs/advanced/snapshot-restore.md @@ -0,0 +1,257 @@ +--- +title: "State Snapshots & Restore" +--- + +# State Snapshots & Restore + +## Overview + +`Model.save_state()` and `Model.load_state()` are a *stash for timesteps* — +a quick "hold that thought, I might need to come back" mechanism for +time-stepping code. Take a snapshot, try a step, and if you don't like +the result, restore and try again. The system is put back exactly as it +was, as if the discarded step never happened. + +Typical uses: + +- **Backtrack past an instability** — a step blows up; restore and + continue with a smaller Δt or a different scheme. +- **Adaptive Δt with an error / CFL check** — take a step, measure it, + restore and retry if it violated your criterion. +- **Predictor–corrector probing** — try a predictor, inspect the + corrector, fall back if it isn't converging. +- **Multi-stage time integration** (RK-style) — restore to the start + of a step between stages. + +The same entry points serve two storage modes — in-memory for fast +intra-run stash, and on-disk for persistent restart / postprocessing. +You pick by giving (or not giving) a ``file=``. Mesh-variable output uses +``mesh.write_timestep()`` with explicit payload flags; reload uses either +``MeshVariable.read_timestep()`` for coordinate/KDTree remap or +``MeshVariable.read_checkpoint()`` for PETSc-native reload (see +"Choosing between paths" at the bottom). + +## The API + +```python +import underworld3 as uw + +model = uw.get_default_model() + +# ... set up mesh, variables, swarm, solvers, step a few times ... + +# In-memory: the "stash for timesteps" use case. +token = model.save_state() # capture everything, return a token + +# ... take a speculative step you might regret ... + +model.load_state(token) # put everything back exactly +``` + +To persist on disk instead, pass a path. The same call captures the +same state — only the storage layer differs: + +```python +# On-disk: persistent snapshot for restart, postprocessing, +# bisection studies, or transferring a run to another machine. +model.save_state(file="step42.snap.h5") +# ... later, or in a fresh process with the same model set up ... +model.load_state("step42.snap.h5") +``` + +``save_state()`` returns a :class:`Snapshot` token when called +without ``file``; you can hold several at once and restore any of +them. With ``file=...`` it writes a self-contained on-disk snapshot +and returns the path. + +``load_state()`` takes either a token or a path string — it figures +out which from the argument type, so the same call works for both +storage modes. + +## What is captured + +You do not enumerate anything — `save_state()` captures the full state +of the model automatically: + +- mesh coordinates, +- all mesh-variable values, +- all swarm particle positions and swarm-variable values, +- solver-internal time-integration history (the `DDt` operators that + drive `AdvDiffusion`, viscoelastic stress history, etc.), +- everything on the model tracker (see below). + +Restore rebuilds swarm populations from the snapshot, so it is correct +even if particles migrated, were added, or were lost between snapshot +and restore — that is exactly the situation restore exists for. + +## The model tracker: time, step, and your own quantities + +A subtle trap in time-stepping scripts: your loop counter and +simulation time usually live in plain Python variables, and +`load_state()` has no way to know about them. + +```python +model_time = 0.0 +token = model.save_state() +model_time = 5.0 # advance +model.load_state(token) +# model_time is still 5.0 — restore cannot reach a local variable +``` + +`Model.tracker` solves this. It is a model-dwelling record of where the +run is — and anything you put on it is automatically captured and +restored. + +```python +model.tracker.time = 0.0 +model.tracker.step = 0 + +token = model.save_state() + +model.tracker.time = 5.0 +model.tracker.step = 100 + +model.load_state(token) + +model.tracker.time # 0.0 — reverted automatically +model.tracker.step # 0 — reverted automatically +``` + +`time`, `step` and `dt` come pre-seeded as conventions, but they have +no special status. Any attribute you assign becomes managed state: + +```python +model.tracker.peak_velocity = 0.0 +model.tracker.energy_history = np.zeros(3) +``` + +These now travel with every snapshot and revert on every restore — no +extra code, no special handling in your solvers. Using the tracker is +optional; solvers do not depend on it. It is simply the place to keep +the things you want `load_state()` to manage. + +```{note} Reserved name +`state` is reserved on the tracker (it is the snapshot mechanism's own +hook). Do not use `model.tracker.state` for your own quantity. +``` + +```{note} git-stash semantics +Restore returns to *exactly* the captured point. A quantity you add to +the tracker *after* taking a snapshot is removed by a restore of that +snapshot — the same way `git stash pop` does not keep work you started +afterwards. +``` + +## Worked example: adaptive-Δt backtracking + +A canonical CFL-controlled stepping loop. The speculative step is +taken, checked, and either kept or discarded: + +```python +import numpy as np +import underworld3 as uw + +model = uw.get_default_model() +# ... mesh, swarm, velocity field V_fn, solvers set up ... + +cfl_limit = mesh.get_min_radius() +dt = 0.5 + +while model.tracker.time < t_end: + token = model.save_state() + coords_before = swarm._particle_coordinates.data.copy() + + # Speculative step at the current Δt. + swarm.advection(V_fn, delta_t=dt) + # ... your solves for this step ... + + # CFL check. + moved = np.linalg.norm( + swarm._particle_coordinates.data - coords_before, axis=1 + ).max() + + if moved > cfl_limit: + # Too big — discard and retry with a smaller Δt. + model.load_state(token) + dt *= 0.5 + continue + + # Good step — commit. + model.tracker.time += dt + model.tracker.step += 1 + dt = min(dt * 1.1, dt_max) # let Δt grow again +``` + +Because the swarm, fields, solver history *and* the tracker's `time` / +`step` are all captured, the `continue` path leaves no trace: the next +attempt starts from precisely where the failed one began. + +## Guarantees and scope + +```{note} What is guaranteed +- **Discarding a step leaves no trace.** A snapshot → speculative + step → restore → continue reproduces a run that never took the + speculative step *bit-for-bit*, including across MPI ranks and + through real PETSc solves. +- **Parallel-correct.** Works under MPI at any (fixed) rank count. + Restore recovers the exact global state even if the discarded step + migrated or lost particles across ranks. +``` + +```{warning} Limitations +- **In-memory tokens are intra-run only.** Tokens returned by + ``save_state()`` (no ``file``) live in process memory and do not + survive the process exiting. They are also a full copy of model + state — holding many large tokens at once costs memory. To persist + across runs, use ``save_state(file=…)`` instead. +- **Same rank count.** A snapshot written on *N* MPI ranks must be + read on *N* ranks. Cross-rank-count restart is not supported by + this mechanism (use the ``mesh.write_timestep`` path for that). +- **No mesh adaptation across a snapshot.** If the mesh is adapted + between save and load, ``load_state`` refuses with a clear error + rather than corrupting state. +- **Recovery vs. a never-snapshotted run** is bit-exact for the + *discarded-step* guarantee above. Continuing after a load_state + that ran a real solver may differ from a run that never + snapshotted by a small amount within solver tolerance — load_state + resyncs solver fields rather than reproducing their exact internal + buffers. This does not affect the correctness of backtracking. +``` + +## On-disk file layout (when ``save_state(file=…)`` is used) + +A disk snapshot is **two artifacts**: a small wrapper HDF5 file plus +a sibling ``.bulk/`` directory holding the bulk data. They are a +unit — move them together. The wrapper is rich in metadata and +inspectable with standard h5 tools without UW3 in the loop: + +```text +my_run.snap.h5 (~tens of KB; metadata, group structure) +my_run.snap.bulk/ (per-mesh + per-swarm sidecars) + {mesh}.mesh.00000.h5 + {mesh}.{var}.00000.h5 (one per mesh-variable) + {swarm}.swarm.h5 (one per swarm) +``` + +A quick ``h5ls -v my_run.snap.h5/metadata`` shows you the run name, +schema version, simulation time, step, dimensions, MPI rank count at +write, and inventories of meshes / swarms / variables / state-bearer +classes. For a Python-side summary use +``uw.checkpoint.inspect_snapshot(path)``. + +## Choosing between paths + +| Need | Use | +|---|---| +| Backtrack a few steps inside a running script (RK staging, adaptive Δt, predictor–corrector probes) | ``save_state()`` → token | +| Persist whole-model state across runs (crash recovery, bisection studies, full restart) | ``save_state(file=…)`` / ``load_state(file=…)`` | +| Restart from a previous run on a *different* rank count or remap onto a *different* resolution | ``mesh.write_timestep(...)`` / ``MeshVariable.read_timestep()`` (coordinate/KDTree remap) | +| Efficient same-rank restart writing only specific variables for postprocessing | ``mesh.write_timestep(petsc_reload=True)`` / ``MeshVariable.read_checkpoint()`` (PETSc DMPlex per-variable) | +| Visualisation for ParaView (XDMF + per-step HDF5) | ``mesh.write_timestep(create_xdmf=True)`` | + +## Related + +- [Parallel-Safe Scripting](parallel-computing.md) — MPI patterns; + snapshot/restore is parallel-correct at fixed rank count. +- Developer reference: the state-as-dataclass contract for adding new + snapshot-managed solver helpers lives in the developer guide. diff --git a/docs/advanced/solver-iteration-callbacks.md b/docs/advanced/solver-iteration-callbacks.md new file mode 100644 index 000000000..3b06103f7 --- /dev/null +++ b/docs/advanced/solver-iteration-callbacks.md @@ -0,0 +1,99 @@ +# Per-iteration solver callbacks + +Any UW3 solver can run a user callback **at the start of every nonlinear (Newton/SNES) +iteration**, via PETSc's `SNESSetUpdate`. This is the right hook whenever something +the solve depends on must be *recomputed from the current iterate* as the nonlinear +solve proceeds — rather than only once per timestep. + +```python +solver.add_update_callback(my_callback) # my_callback(solver, iteration) +``` + +Before each call the current Newton iterate is scattered into the solver's field +variable(s) — its single unknown for a scalar/vector solver, or velocity, pressure +(and any block-constraint multipliers) for Stokes — so the callback can read the +fields at the current iterate. After the call the (possibly modified) fields are +gathered back into the iterate, and the mesh auxiliary vector is refreshed so the +next residual evaluation sees any changes. The callbacks are also applied once to the +final converged iterate. With no callback registered the solve path is unchanged. + +The scattered fields are correct **everywhere**, including on driven (non-zero +Dirichlet) boundaries: the scatter mirrors the post-solve copy-back — +`globalToLocal` followed by `DMPlexSNESComputeBoundaryFEM` — so a callback that reads +a field next to a driven boundary sees the imposed value, not a stale one. + +```{note} +Callbacks run in registration order and receive `(solver, iteration)`. They may +**read** any field, **modify** a field (e.g. shift the pressure), or **fire another +solver** (e.g. a projection/Helmholtz smoother). +``` + +## Use case 1 — fix the pressure gauge (zero mean pressure on a surface) + +On enclosed / all-Dirichlet-velocity problems the pressure is determined only up to +an additive constant. A constant pressure null space makes the system *solvable*, but +leaves the solver free to pick any representative. To pin a **specific, physical** +gauge — for example zero mean pressure on the top surface — use: + +```python +stokes.petsc_use_pressure_nullspace = True # solvability +stokes.set_pressure_gauge("Top") # gauge: mean pressure on "Top" -> 0 +stokes.solve() +``` + +`set_pressure_gauge(boundary, reference=0.0)` registers a callback that, every +iteration, subtracts the surface-mean pressure from the whole pressure field so that + +$$\frac{1}{|\Gamma|}\int_\Gamma p\,dS = \texttt{reference}.$$ + +After the solve the mean pressure over the chosen boundary equals `reference` to +machine precision: + +```python +area = uw.maths.BdIntegral(mesh, 1.0, "Top").evaluate() +mean_top = uw.maths.BdIntegral(mesh, p.sym[0, 0], "Top").evaluate() / area +# mean_top ~ 0 +``` + +## Use case 2 — fire a Helmholtz/Projection smoother each iteration + +This is the mechanism behind gradient-plasticity / shear-band stabilisation: the +yield viscosity depends on a *smoothed* (nonlocal) strain-rate field obtained by a +screened-Poisson projection of the local strain rate. To keep that smoothed field +consistent with the velocity *as the nonlinear solve converges*, fire the projection +each iteration: + +```python +ebar = uw.discretisation.MeshVariable("ebar", mesh, 1, degree=1) + +edot = stokes.strainrate +e_local = sympy.sqrt((edot*edot).trace()/2 + e_min**2) + +smoother = uw.systems.Projection(mesh, ebar) +smoother.uw_function = e_local +smoother.smoothing_length = ell # internal length scale + +# yield viscosity uses the SMOOTHED field +stokes.constitutive_model.Parameters.shear_viscosity_0 = \ + 1 / (1/eta0 + 2*ebar.sym[0,0]/tau_y) + +# re-fire the smoother at every Newton iteration +stokes.add_update_callback(lambda solver, iteration: smoother.solve()) + +stokes.solve() +``` + +At convergence `ebar` is the nonlocal average of the strain rate of the converged +velocity, and the regularised yield law has been applied self-consistently within the +nonlinear solve (rather than lagged across timesteps). Because the scatter is +boundary-correct (see above), the smoother sees the imposed velocity even where the +shear band meets a driven wall. + +## When to use this vs. a timestep update + +- Use a **per-iteration callback** when the residual genuinely depends on a quantity + that must track the current iterate (regularised rheology, a per-iterate gauge). +- Use the **per-timestep** `DDt.update_pre_solve` / `update_post_solve` hooks for + history variables that are, by definition, lagged in time (advected fields, + accumulated plastic strain). +``` diff --git a/docs/advanced/vep-transverse-isotropy-faults.md b/docs/advanced/vep-transverse-isotropy-faults.md new file mode 100644 index 000000000..8bf3e56a4 --- /dev/null +++ b/docs/advanced/vep-transverse-isotropy-faults.md @@ -0,0 +1,265 @@ +--- +title: "Viscoelastic-Plastic Rheology with Transverse Isotropy for Fault Mechanics" +--- + +# Viscoelastic-Plastic Rheology with Transverse Isotropy + +Fault zones in the lithosphere are thin regions of localised deformation where the mechanical response differs from the surrounding rock. They are weaker in shear along the fault plane than in the bulk, they accumulate elastic stress between slip events, and they yield when that stress exceeds a threshold. Capturing all three behaviours -- anisotropic weakness, elastic memory, and plastic yield -- requires a constitutive model that combines transverse isotropy (TI) with viscoelastic-plastic (VEP) rheology. + +This document develops the mathematical formulation used in the `TransverseIsotropicVEPFlowModel` class, starting from the isotropic VEP model and the TI viscosity tensor, then showing how they combine through a resolved fault-plane yield criterion. + +## Isotropic Viscoelastic-Plastic Rheology + +### Maxwell Viscoelasticity + +A Maxwell viscoelastic material partitions the total strain rate into viscous and elastic contributions: + +$$ +\dot\varepsilon_{ij}^{\text{total}} = \dot\varepsilon_{ij}^{\text{viscous}} + \dot\varepsilon_{ij}^{\text{elastic}} += \frac{\sigma_{ij}}{2\eta} + \frac{1}{2\mu}\frac{D\sigma_{ij}}{Dt} +$$ + +where $\eta$ is the shear viscosity, $\mu$ is the shear modulus, and $D/Dt$ denotes the Jaumann (or other objective) derivative. + +Discretising the time derivative using a BDF-$k$ scheme with leading coefficient $c_0$ and history coefficients $c_1, c_2, \ldots$ gives: + +$$ +\dot\varepsilon_{ij}^{\text{total}} = \frac{\sigma_{ij}}{2\eta} + \frac{c_0 \sigma_{ij} + c_1 \sigma_{ij}^{*} + c_2 \sigma_{ij}^{**} + \cdots}{2\mu\Delta t} +$$ + +where $\sigma^{*}$ and $\sigma^{**}$ are the stress at the previous and second-previous timesteps, advected to the current particle positions (the Lagrangian stress history). Solving for the current stress: + +$$ +\sigma_{ij} = 2\eta_{\text{ve}}\,\dot\varepsilon_{ij}^{\text{eff}} +$$ + +with the **viscoelastic effective viscosity** and **effective strain rate**: + +$$ +\eta_{\text{ve}} = \frac{\eta\,\mu\,\Delta t}{c_0\,\eta + \mu\,\Delta t}, +\qquad +\dot\varepsilon_{ij}^{\text{eff}} = \dot\varepsilon_{ij}^{\text{total}} +- \frac{c_1 \sigma_{ij}^{*} + c_2 \sigma_{ij}^{**} + \cdots}{2\mu\,\Delta t} +$$ + +The effective strain rate incorporates stress history: it is the strain rate that a purely viscous material with viscosity $\eta_{\text{ve}}$ would need to produce the current stress. For BDF-1, $c_0 = 1$ and $c_1 = -1$; higher orders improve temporal accuracy. + +The Maxwell relaxation time $t_r = \eta / \mu$ controls the elastic-to-viscous transition. When $\Delta t \gg t_r$, the material behaves viscously ($\eta_{\text{ve}} \to \eta$); when $\Delta t \ll t_r$, it behaves elastically ($\eta_{\text{ve}} \to \mu\Delta t / c_0$). + +### Plastic Yield + +When stress exceeds a yield threshold $\tau_y$, the material yields plastically. In the isotropic case, yield is tested against the second invariant of the effective strain rate: + +$$ +\dot\varepsilon_{II} = \sqrt{\tfrac{1}{2}\dot\varepsilon_{ij}^{\text{eff}}\,\dot\varepsilon_{ij}^{\text{eff}}} +$$ + +The effective viscosity is capped so that the resulting stress does not exceed the yield stress: + +$$ +\eta_{\text{vep}} = \min\!\left(\eta_{\text{ve}},\;\frac{\tau_y}{2\,\dot\varepsilon_{II}}\right) +$$ + +This is the Drucker-Prager yield criterion expressed as a viscosity cap. The stress is then $\sigma_{ij} = 2\eta_{\text{vep}}\,\dot\varepsilon_{ij}^{\text{eff}}$. + +## Transverse Isotropy + +### The Muhlhaus-Moresi Viscosity Tensor + +A transversely isotropic material has a single weak plane defined by its unit normal $\hat{n}$ (the "director"). The fourth-rank viscosity tensor is: + +$$ +\eta_{ijkl} = 2\eta_0\,I_{ijkl} +- (\eta_0 - \eta_1)\left[ +\frac{1}{2}\!\left(n_i n_k \delta_{jl} + n_j n_k \delta_{il} ++ n_i n_l \delta_{jk} + n_j n_l \delta_{ik}\right) +- 2\,n_i n_j n_k n_l +\right] +$$ + +where $I_{ijkl} = \frac{1}{2}(\delta_{ik}\delta_{jl} + \delta_{il}\delta_{jk})$ is the symmetric identity tensor, $\eta_0$ is the bulk viscosity, and $\eta_1$ is the viscosity for shear along the weak plane. When $\eta_1 = \eta_0$, the anisotropic correction vanishes and the tensor reduces to the isotropic case. + +The stress is: + +$$ +\sigma_{ij} = \eta_{ijkl}\,\dot\varepsilon_{kl} +$$ + +The director $\hat{n}$ typically comes from the fault surface normals, transferred to the mesh via nearest-neighbour interpolation. Far from the fault, $\eta_1$ is set equal to $\eta_0$ (via an influence function), so the material reverts to isotropic. + +### Fault Representation + +In Underworld3, fault zones are represented as embedded surfaces. The `Surface` class provides: + +- A signed distance field from the fault +- Normal vectors at each surface vertex +- Influence functions that smoothly transition material properties from fault-zone values (near the surface) to background values (far away) + +Available influence profiles include Gaussian ($e^{-(d/w)^2}$), smoothstep ($3t^2 - 2t^3$), linear ramp, and step function. The width parameter $w$ controls the fault zone thickness. + +A typical setup transfers fault normals to the mesh as a director field, and uses an influence function to interpolate yield stress or viscosity ratio between fault-zone and background values. + +## Combined TI-VEP: Resolved Fault-Plane Yield + +The key insight in combining TI with VEP is that yield should be tested against the **resolved shear stress on the fault plane**, not the global stress invariant. A fault with normal $\hat{n}$ oriented at an angle to the imposed deformation may have a low global strain rate invariant while experiencing high shear along the fault plane itself. + +### Viscoelastic Effective Viscosities + +Both viscosity parameters receive the VE treatment: + +$$ +\eta_{0,\text{ve}} = \frac{\eta_0\,\mu\,\Delta t}{c_0\,\eta_0 + \mu\,\Delta t}, +\qquad +\eta_{1,\text{ve}} = \frac{\eta_1\,\mu\,\Delta t}{c_0\,\eta_1 + \mu\,\Delta t} +$$ + +Using $\eta_{0,\text{ve}}$ in the tensor (rather than the raw $\eta_0$) ensures that the anisotropic correction $\Delta = \eta_{0,\text{ve}} - \eta_{1,\text{eff}}$ vanishes when $\eta_1 = \eta_0$ and yield is inactive. Without this, the tensor would have a spurious anisotropic component even for isotropic materials. + +### Resolved Shear on the Fault Plane + +Given the effective strain rate tensor $\dot\varepsilon_{ij}^{\text{eff}}$ (which includes the stress history), the traction-like vector on the fault plane is: + +$$ +T_i = \dot\varepsilon_{ij}^{\text{eff}}\,n_j +$$ + +This has a component normal to the fault and a component tangent to it. The normal component is: + +$$ +\dot\varepsilon_n = T_i\,n_i +$$ + +The in-plane (tangential) shear magnitude follows from Pythagoras: + +$$ +|\dot\gamma| = \sqrt{|T|^2 - \dot\varepsilon_n^2} += \sqrt{T_i T_i - (T_j n_j)^2} +$$ + +This formulation works in both 2D and 3D without constructing an explicit tangent vector (which is not unique in 3D). The quantity $|\dot\gamma|$ is the magnitude of the shear strain rate resolved onto the fault plane. + +### Fault-Plane Yield Criterion + +The plastic viscosity is determined by the resolved fault-plane shear: + +$$ +\eta_{1,\text{pl}} = \frac{\tau_y}{2\,|\dot\gamma|} +$$ + +This is the same Drucker-Prager pattern as the isotropic case, but projected onto the fault plane. The yield-limited fault-plane viscosity is: + +$$ +\eta_{1,\text{eff}} = \min\!\left(\eta_{1,\text{ve}},\;\eta_{1,\text{pl}}\right) +$$ + +In practice, a smooth approximation replaces the $\min$ to aid solver convergence. The default "smooth" yield mode uses: + +$$ +\eta_{1,\text{eff}} = \eta_{1,\text{ve}}\,\frac{1 + f}{1 + f + f^2}, +\quad f = \frac{\eta_{1,\text{ve}}}{\eta_{1,\text{pl}}} +$$ + +which transitions smoothly from $\eta_{1,\text{ve}}$ (when $f \ll 1$, below yield) to $\eta_{1,\text{pl}}$ (when $f \gg 1$, above yield). + +### The Full Stress Formula + +The stress is computed from the anisotropic tensor with the yield-limited viscosities: + +$$ +\sigma_{ij} = C_{ijkl}(\eta_{0,\text{ve}},\;\eta_{1,\text{eff}},\;\hat{n}) +\;\dot\varepsilon_{kl}^{\text{eff}} +$$ + +where $C_{ijkl}$ is the Muhlhaus-Moresi tensor with $\eta_0 \to \eta_{0,\text{ve}}$ and $\eta_1 \to \eta_{1,\text{eff}}$. This naturally separates the response into: + +- **Normal to the fault**: governed by $\eta_{0,\text{ve}}$ (pure VE, no yield) +- **Shear along the fault**: governed by $\eta_{1,\text{eff}}$ (VEP, yield-limited) + +When the fault-plane shear stress reaches $\tau_y$, only the fault-plane component yields. Stress normal to the fault continues to build elastically. This is physically correct: faults slip in shear, not in compression. + +## Using `TransverseIsotropicVEPFlowModel` + +```python +import underworld3 as uw +import numpy as np +import sympy + +# Mesh and variables +mesh = uw.meshing.StructuredQuadBox(elementRes=(64, 64)) +v = uw.discretisation.MeshVariable("U", mesh, 2, degree=2, vtype=uw.VarType.VECTOR) +p = uw.discretisation.MeshVariable("P", mesh, 1, degree=1, + continuous=True, vtype=uw.VarType.SCALAR) + +stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + +# Create TI-VEP model (order=1 for BDF-1 time integration) +cm = uw.constitutive_models.TransverseIsotropicVEPFlowModel( + stokes.Unknowns, order=1 +) +stokes.constitutive_model = cm + +# Set parameters +cm.Parameters.shear_viscosity_0 = 1.0 # bulk viscosity +cm.Parameters.shear_viscosity_1 = 0.1 # fault-plane viscosity +cm.Parameters.shear_modulus = 1.0 # elastic shear modulus +cm.Parameters.yield_stress = 0.15 # fault-plane yield stress + +# Director from fault normal (e.g., fault at 15 degrees from horizontal) +theta = np.radians(15) +cm.Parameters.director = sympy.Matrix([-np.sin(theta), np.cos(theta)]) +``` + +The director can also be a spatially varying field (e.g., from a `Surface` object's normals transferred to a mesh variable), and the yield stress can vary spatially using an influence function to localise yielding near the fault. + +## Smooth Yield Approximations + +The `"softmin"` yield mode (default) uses a smooth approximation to $\min(\eta_{\text{ve}}, \eta_{\text{pl}})$ to avoid the non-differentiable kink that causes problems for the SNES solver. The approximation is: + +$$g(f) = 1 + \text{softplus}(f-1) - \text{softplus}(-1), \qquad \eta_{\text{eff}} = \eta_{\text{ve}} / g(f)$$ + +where $\text{softplus}(x) = (x + \sqrt{x^2 + \delta^2})/2$ and $f = \eta_{\text{ve}}/\eta_{\text{pl}}$. The offset correction ensures $g(0) = 1$ exactly, so there is no spurious yield correction when the material is below yield. + +The sharpness parameter $\delta$ (default 0.1) controls the width of the smooth transition around the yield point. Smaller $\delta$ gives a sharper cap (closer to the true $\min$) but a stiffer nonlinearity for the solver. + +### Choosing $\delta$ + +The accuracy of the smooth approximation depends on the ratio $f_{ss} = \eta_{\text{ve}} / \eta_{\text{pl}}$ at steady state. For a simple shear problem, this simplifies to: + +$$f_{ss} = \frac{\sigma_{\text{viscous}}}{\tau_y} = \frac{\eta\,\dot\gamma}{\tau_y}$$ + +The softmin is accurate when $\delta \ll f_{ss}$, i.e., when the viscous stress substantially exceeds the yield stress. Practical guidance: + +| $\delta$ | Accuracy at $f_{ss} = 1.5$ | Accuracy at $f_{ss} = 3$ | Solver cost | +|----------|---------------------------|--------------------------|-------------| +| 0.5 | ~85% of $\tau_y$ | ~99% | lowest | +| 0.1 | ~99% of $\tau_y$ | ~100% | low | +| 0.01 | ~100% | ~100% | moderate | + +The default $\delta = 0.1$ is accurate for all cases where the viscous stress exceeds the yield stress by at least 50% ($f_{ss} > 1.5$). For problems where SNES convergence is difficult at yield onset, increase $\delta$ toward 0.3--0.5 as a relaxation parameter. Set it via `cm.yield_softness = 0.1`. + +## Benchmark Results + +The figure below shows the TI-VEP model validated against the analytical Maxwell viscoelastic solution with plastic yield cap, for a simple shear box with an embedded fault. Two yield stresses are tested ($\tau_y = 0.15$ and $\tau_y = 0.30$) at both 0 and 15 degrees fault angle. Solid curves show the analytical VE solution capped at $\tau_y$; markers show the numerical results. + +```{figure} figures/ti_vep_benchmark_final.png +:name: fig-tivep-benchmark + +TI-VEP shear box benchmark. **Left**: horizontal fault ($\theta = 0°$), where resolved shear equals $\sigma_{xy}$. **Right**: angled fault ($\theta = 15°$), showing resolved fault-plane shear (circles) capping at $\tau_y$ while the global $\sigma_{xy}$ (crosses) continues to build as the bulk VE component grows. With the corrected softmin ($\delta = 0.1$), all cases reach within 1--2% of the analytical yield cap. +``` + +At 0 degrees, the resolved shear is simply $\sigma_{xy}$ and the yield cap is exact. At 15 degrees, the anisotropic tensor creates a mechanical coupling between normal and shear components on the fault plane: the resolved shear caps at $\tau_y$ while the global stress tensor reflects contributions from both the yielded fault-plane component (governed by $\eta_{1,\text{eff}}$) and the non-yielding bulk component (governed by $\eta_{0,\text{ve}}$). + +## Summary of Constitutive Models + +| Model | Viscosity | Elasticity | Yield | Anisotropy | +|-------|-----------|------------|-------|------------| +| `ViscousFlowModel` | $\eta$ | -- | -- | -- | +| `ViscoPlasticFlowModel` | $\eta$ | -- | $\dot\varepsilon_{II}$ | -- | +| `ViscoElasticPlasticFlowModel` | $\eta$ | $\mu$, BDF-$k$ | $\dot\varepsilon_{II}$ | -- | +| `TransverseIsotropicFlowModel` | $\eta_0, \eta_1, \hat{n}$ | -- | -- | TI tensor | +| `TransverseIsotropicVEPFlowModel` | $\eta_0, \eta_1, \hat{n}$ | $\mu$, BDF-$k$ | $\|\dot\gamma\|$ (fault-plane) | TI tensor | + +## References + +- Moresi, L., Muhlhaus, H.-B., 2006. Anisotropic viscous models of large-deformation Mohr-Coulomb failure. *Phil. Mag.*, 86, 3287-3305. +- Muhlhaus, H.-B., Moresi, L., Hobbs, B., Dufour, F., 2002. Large amplitude folding in finely layered viscoelastic rock structures. *Pure Appl. Geophys.*, 159, 2311-2333. diff --git a/docs/api/systems_ddt.md b/docs/api/systems_ddt.md index 1bc7e9428..d9ded7dfc 100644 --- a/docs/api/systems_ddt.md +++ b/docs/api/systems_ddt.md @@ -8,6 +8,10 @@ and ``update_post_solve(dt)`` after the solve completes. History is initialised automatically on the first solve call, and BDF order ramps from 1 up to the requested ``order`` over the first few timesteps. +For analytical-IC benchmarks (no startup transient) or checkpoint restarts, +``set_initial_history(values, dt=...)`` plants the BDF history directly and +bypasses the order ramp, so the first solve runs at full BDF order. + ## Base Class ### Symbolic diff --git a/docs/beginner/parameters.md b/docs/beginner/parameters.md index a82b48e23..045dc429d 100644 --- a/docs/beginner/parameters.md +++ b/docs/beginner/parameters.md @@ -16,16 +16,25 @@ This makes scripts portable between interactive development and HPC batch execut ## Basic Usage +The recommended pattern is to define default values as **named constants** before +the `uw.Params` block. This separates "what are the defaults" (easy to find and +edit in a notebook) from "how are they validated and overridden" (the `uw.Params` +machinery). + ```python import underworld3 as uw -# Define parameters with defaults +# --- Default values (edit these in a notebook) --- +RESOLUTION = 0.05 # cell size for mesh +DIFFUSIVITY = 1.0 # material property +MAX_STEPS = 100 # solver iterations + params = uw.Params( - uw_resolution = 0.05, # Cell size for mesh - uw_diffusivity = 1.0, # Material property - uw_max_steps = 100, # Integer parameter - uw_verbose = True, # Boolean flag - uw_solver = "mumps", # String option + uw_resolution = RESOLUTION, + uw_diffusivity = DIFFUSIVITY, + uw_max_steps = MAX_STEPS, + uw_verbose = True, # Boolean flag + uw_solver = "mumps", # String option ) # Use in your model @@ -203,22 +212,30 @@ Example: ```python import underworld3 as uw +# --- Default values (edit these in a notebook) --- +CELL_SIZE = 50.0 # km – target cell size +DEPTH = 660.0 # km – model depth +VISCOSITY = 1e21 # Pa·s – reference viscosity +DENSITY_DIFF = 50.0 # kg/m³ – density contrast +MAX_ITERATIONS = 50 +TOLERANCE = 1e-6 + # Define all configurable parameters at the top params = uw.Params( # Mesh parameters - uw_cell_size = uw.Param(50.0, units="km", + uw_cell_size = uw.Param(CELL_SIZE, units="km", bounds=(10, 200), description="Target cell size"), - uw_depth = uw.Param(660.0, units="km", + uw_depth = uw.Param(DEPTH, units="km", description="Model depth"), # Physical properties - uw_viscosity = uw.Param(1e21, units="Pa*s"), - uw_density_diff = uw.Param(50.0, units="kg/m^3"), + uw_viscosity = uw.Param(VISCOSITY, units="Pa*s"), + uw_density_diff = uw.Param(DENSITY_DIFF, units="kg/m^3"), # Solver settings - uw_max_iterations = 50, - uw_tolerance = 1e-6, + uw_max_iterations = MAX_ITERATIONS, + uw_tolerance = TOLERANCE, ) # Show help (useful at script start) diff --git a/docs/beginner/tutorials/16-Richards-Equation-Groundwater.ipynb b/docs/beginner/tutorials/16-Richards-Equation-Groundwater.ipynb new file mode 100644 index 000000000..c7892f848 --- /dev/null +++ b/docs/beginner/tutorials/16-Richards-Equation-Groundwater.ipynb @@ -0,0 +1,679 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Notebook 16: Richards Equation — Groundwater Flow\n", + "\n", + "This notebook introduces the Richards equation for variably-saturated\n", + "porous media flow. We solve a steady-state drainage problem in a\n", + "vertical soil column and validate the numerical solution against an\n", + "exact analytical benchmark.\n", + "\n", + "## Key Concepts\n", + "\n", + "- Richards equation — nonlinear PDE for unsaturated flow\n", + "- Gardner exponential conductivity model\n", + "- Analytical steady-state solution with gravity\n", + "- Darcy velocity field" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## The Richards Equation\n", + "\n", + "Water movement in unsaturated soil is governed by the Richards\n", + "equation (Richards, 1931). Three equivalent forms exist:\n", + "\n", + "| Form | Storage term | Flux term | Notes |\n", + "|------|-------------|-----------|-------|\n", + "| Head-based ($\\psi$) | $C(\\psi)\\,\\partial\\psi/\\partial t$ | $\\nabla\\cdot[K(\\psi)(\\nabla\\psi - \\mathbf{s})]$ | Simple, but poor mass balance |\n", + "| Moisture-based ($\\theta$) | $\\partial\\theta/\\partial t$ | $\\nabla\\cdot[D(\\theta)\\nabla\\theta]$ | Conservative, but $D(\\theta)$ singular at saturation |\n", + "| **Mixed** | $\\partial\\theta/\\partial t$ | $\\nabla\\cdot[K(\\psi)(\\nabla\\psi - \\mathbf{s})]$ | Conservative and well-behaved |\n", + "\n", + "The **mixed form** (Celia et al., 1990) is generally preferred because\n", + "writing the storage as $\\partial\\theta/\\partial t$ guarantees mass\n", + "conservation in the discrete system — the head-based form\n", + "$C(\\psi)\\,\\partial\\psi/\\partial t$ introduces balance errors because the\n", + "discrete chain rule $C(\\psi)\\Delta\\psi \\neq \\Delta\\theta$ when $C$ varies\n", + "sharply across a timestep.\n", + "\n", + "$$\\frac{\\partial \\theta}{\\partial t}\n", + " - \\nabla\\cdot\\bigl[K(\\psi)\\,(\\nabla\\psi - \\mathbf{s})\\bigr] = f$$\n", + "\n", + "where\n", + "- $\\psi$ is the **pressure head** (negative in unsaturated soil),\n", + "- $\\theta(\\psi)$ is the **volumetric water content**,\n", + "- $K(\\psi)$ is the **hydraulic conductivity** (decreases as soil dries out),\n", + "- $\\mathbf{s} = [0, -1]^T$ represents **gravity** (pointing downward),\n", + "- $f$ is any source/sink.\n", + "\n", + "The Underworld solver uses this mixed form when `water_content` is set\n", + "— discretising the storage term as\n", + "$(\\theta(\\psi^{n+1}) - \\theta(\\psi^n))/\\Delta t$.\n", + "The Jacobian $\\partial\\theta/\\partial\\psi = C(\\psi)$ is computed\n", + "automatically by PETSc. For steady-state problems (where\n", + "$\\partial/\\partial t = 0$) the forms are all identical." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Gardner Exponential Model\n", + "\n", + "The **Gardner (1958)** model uses an exponential relationship for\n", + "hydraulic conductivity:\n", + "\n", + "$$K(\\psi) = K_s\\,e^{\\alpha\\psi}, \\qquad \\psi < 0$$\n", + "\n", + "This is simpler than the Van Genuchten model and, crucially,\n", + "admits an **exact analytical solution** for the steady-state\n", + "Richards equation with gravity.\n", + "\n", + "The substitution $u = e^{\\alpha\\psi}$ linearises the ODE, giving\n", + "the exact pressure head profile:\n", + "\n", + "$$\\psi(y) = \\frac{1}{\\alpha}\\,\\ln\\!\\Bigl[\n", + " \\bigl(u_0 - q^*\\bigr)\\,e^{-\\alpha y} + q^*\n", + "\\Bigr]$$\n", + "\n", + "where $u_0 = e^{\\alpha\\psi_0}$, $u_L = e^{\\alpha\\psi_L}$, and\n", + "$q^* = q/K_s = (u_L - u_0\\,e^{-\\alpha L})/(1 - e^{-\\alpha L})$." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "execution": { + "iopub.execute_input": "2026-02-24T08:58:05.573720Z", + "iopub.status.busy": "2026-02-24T08:58:05.573578Z", + "iopub.status.idle": "2026-02-24T08:58:12.073192Z", + "shell.execute_reply": "2026-02-24T08:58:12.072718Z", + "shell.execute_reply.started": "2026-02-24T08:58:05.573690Z" + } + }, + "outputs": [], + "source": [ + "import numpy as np\n", + "import sympy\n", + "import underworld3 as uw\n", + "import matplotlib.pyplot as plt\n", + "\n", + "from underworld3.utilities.retention_curves import (\n", + " gardner_K,\n", + " gardner_theta,\n", + " gardner_steady_state_psi,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Configurable parameters\n", + "\n", + "Default values are defined as named constants below. From the\n", + "command line, override them with PETSc-style flags:\n", + "\n", + "```bash\n", + "python script.py -uw_Ks \"5e-5 m/s\" -uw_alpha \"2.0 1/m\"\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": { + "execution": { + "iopub.execute_input": "2026-02-24T08:58:12.074293Z", + "iopub.status.busy": "2026-02-24T08:58:12.074021Z", + "iopub.status.idle": "2026-02-24T08:58:12.076863Z", + "shell.execute_reply": "2026-02-24T08:58:12.076598Z", + "shell.execute_reply.started": "2026-02-24T08:58:12.074281Z" + } + }, + "outputs": [], + "source": [ + "# --- Default values (edit these in a notebook) ---\n", + "COLUMN_HEIGHT = 1.0 # m — soil column height\n", + "COLUMN_WIDTH = 0.1 # m — narrow (≈ 1-D)\n", + "RES = 32 # — vertical elements\n", + "KS = 1e-4 # m/s — saturated hydraulic conductivity\n", + "ALPHA_G = 3.5 # 1/m — Gardner sorptive number\n", + "THETA_R = 0.05 # — residual water content\n", + "THETA_S = 0.40 # — saturated water content\n", + "PSI_TOP = -0.5 # m — pressure head at top\n", + "PSI_BOTTOM = -3.0 # m — pressure head at bottom" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": { + "execution": { + "iopub.execute_input": "2026-02-24T08:58:12.077264Z", + "iopub.status.busy": "2026-02-24T08:58:12.077186Z", + "iopub.status.idle": "2026-02-24T08:58:12.087031Z", + "shell.execute_reply": "2026-02-24T08:58:12.086377Z", + "shell.execute_reply.started": "2026-02-24T08:58:12.077255Z" + } + }, + "outputs": [ + { + "data": { + "text/latex": [ + "$\\mathrm{K_s} = 1.00 \\times 10^{-04} \\; \\mathrm{meter / second}$" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Named expressions for display\n", + "Ks = uw.expression(r\"K_s\", uw.quantity(KS, \"m/s\"), \"saturated conductivity\")\n", + "alpha_g = uw.expression(r\"\\alpha\", uw.quantity(ALPHA_G, \"1/m\"), \"Gardner sorptive number\")\n", + "theta_r = uw.expression(r\"\\theta_r\", THETA_R, \"residual water content\")\n", + "theta_s = uw.expression(r\"\\theta_s\", THETA_S, \"saturated water content\")\n", + "\n", + "Ks" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Retention Curves\n", + "\n", + "Let’s visualise how hydraulic conductivity and water content\n", + "change with pressure head for these Gardner parameters." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": { + "execution": { + "iopub.execute_input": "2026-02-24T08:58:12.087748Z", + "iopub.status.busy": "2026-02-24T08:58:12.087662Z", + "iopub.status.idle": "2026-02-24T08:58:12.508149Z", + "shell.execute_reply": "2026-02-24T08:58:12.507784Z", + "shell.execute_reply.started": "2026-02-24T08:58:12.087739Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "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QECAasWWYFECUrlf2GNa9913H5YuXapakKXbuXTdltZzQ/Zp+PDhJjlWSTwm3bQlE7h0S5fgfPLkyXm2Ic8bMWKESqYmz5VuYtK6Ly36hZFWdU9PT8yaNUt1MZegXbq5S9I5SZxSVNAt3fanTJmiAvuSdtvXZ/v27erzIiIiy8b6mfUzUUkx6CayYO+++64KmmfMmKHm95bpslq0aIHXX39dTXMlGbRl2i8JUKUFXabI6t27t2oVLuwxLQkYZZsSbPr4+Kj3ultL9932yVTHKoHzypUrMX78eNWlXMZpz507V03RpeuTTz5BYmKimn5LTopeeukl1YJdVOvGmjVrVJbxL7/8Uq2TMpLpvaZOnZrb1Uwf6ZIuY95Xr16NUaNGGXTsEuhLL4RNmzYZtB0iIiobrJ9ZPxOVhINkUyvRK4iIbJDMnS5++OGHIoNtXRs3blQt9NI1vKgW9btZsGABNmzYoMbfExEREetnsi0c001EBODUqVNo3bp1sQNu0adPH9XKffXqVYPKULq162ayJyIiohysn8kWsKWbiOyeTOMmY8Ile7okaiMiIiLzY/1MtoJBNxEREREREZGJsHs5ERERERERkYkw6CYiIiIiIiIyEQbdRERERERERCbCoJuIiIiIiIjIRBh0ExEREREREZkIg24iIiIiIiIiE2HQTURERERERGQiDLqJiIiIiIiITIRBNxEREREREZGJMOgmIiIiIiIiMhEG3UREREREREQwjf8DAlKYE5/iojcAAAAASUVORK5CYII=", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "psi_range = np.linspace(-5, 0, 200)\n", + "\n", + "K_vals = KS * np.exp(ALPHA_G * psi_range)\n", + "K_vals[psi_range >= 0] = KS\n", + "\n", + "theta_vals = THETA_R + (THETA_S - THETA_R) * np.exp(ALPHA_G * psi_range)\n", + "theta_vals[psi_range >= 0] = THETA_S\n", + "\n", + "fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4))\n", + "\n", + "ax1.semilogy(psi_range, K_vals)\n", + "ax1.set_xlabel(r\"Pressure head $\\psi$ (m)\")\n", + "ax1.set_ylabel(r\"$K(\\psi)$ (m/s)\")\n", + "ax1.set_title(\"Hydraulic conductivity\")\n", + "ax1.grid(True, alpha=0.3)\n", + "\n", + "ax2.plot(psi_range, theta_vals)\n", + "ax2.set_xlabel(r\"Pressure head $\\psi$ (m)\")\n", + "ax2.set_ylabel(r\"$\\theta(\\psi)$\")\n", + "ax2.set_title(\"Volumetric water content\")\n", + "ax2.axhline(THETA_S, color=\"grey\", ls=\"--\", lw=0.8, label=r\"$\\theta_s$\")\n", + "ax2.axhline(THETA_R, color=\"grey\", ls=\":\", lw=0.8, label=r\"$\\theta_r$\")\n", + "ax2.legend()\n", + "ax2.grid(True, alpha=0.3)\n", + "\n", + "fig.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Analytical Solution\n", + "\n", + "For a column of height $L$ with boundary conditions\n", + "$\\psi(0) = \\psi_\\text{bottom}$ and $\\psi(L) = \\psi_\\text{top}$,\n", + "the exact steady-state profile is given by\n", + "`gardner_steady_state_psi`." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": { + "execution": { + "iopub.execute_input": "2026-02-24T08:58:12.508663Z", + "iopub.status.busy": "2026-02-24T08:58:12.508567Z", + "iopub.status.idle": "2026-02-24T08:58:12.588064Z", + "shell.execute_reply": "2026-02-24T08:58:12.587348Z", + "shell.execute_reply.started": "2026-02-24T08:58:12.508653Z" + }, + "scrolled": true + }, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAc4AAAIlCAYAAABcjRhsAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAAPYQAAD2EBqD+naQAATJpJREFUeJzt3Qd0FFXbwPEnhBQ60osIAaQJqFQBFVFBwAJWRKWjoiJSLCCKggVFBRSlKeVVURHF8imvglJEuoiKgFhAUQkm9CY1853n7jvJ7qbtbDbZ9v+dMzA7Ozs7c2cyz946MZZlWQIAAHxSyLfVAAAAgRMAAIfIcQIA4ACBEwAABwicAAA4QOAEAMABAicAAA4QOAEAcIDACQCAAwRO5LvZs2dLTExMttPSpUuDehbeeustmThxYsC2d/ToUXn88ceDflyatrofBenpp5+WDz/8MOzTLhAeeeQROeuss6Rw4cJSunRps+ySSy4xU7DPE/KmcB4/D/hs1qxZUq9evUzLGzRoEPTA+eOPP8rgwYMDsj29+Y8ePdrMe98kI50GzhtuuEG6du0a1Wn30UcfyVNPPSUjR46UTp06SUJCglk+efLkYO8aAoDAiQLTsGFDadasGSmOsPPvv/9KYmKiyR36Qn+IqUGDBkmFChVC5kciAoOiWoSMd955x9yYXn75ZY/ljz32mMTGxsqiRYvSl2mupGXLllKmTBkpWbKkNGnSRGbMmCFZPbNAc5StWrWS4sWLm+m8884z69q5mk8//VT++OMPj+LjnCxevNh8rmzZslKkSBFTHHf99deb3NLvv/8u5cuXT99He3u9e/dO//wvv/wit9xyi7mhak6kfv368sorr3h8x7Fjx2TYsGFmX0uVKmWOU49BczLeDh48KLfffrvZHz2+jh07ys8//+yxzvLly81+vP3225k+//rrr5v31q1bl+Nxb9iwQa666qr0/a5SpYpceeWV8tdff5n3dRtHjhyR//znP+nHbecaU1NT5e677zaBQ/dRt3HppZea/bIFKu2yo9saOHCgTJs2TerUqWM+r/uj111WVQsLFy6Uvn37mn0qWrSoHD9+XNLS0mTcuHGm5EQ/r/vRs2fP9DRQNWrUMMW0qmLFih5FsVkV1WZl165dcuedd8qZZ54p8fHxkpSUZNLk1KlTPh0r8pk+HQXIT7NmzdJoZq1evdo6efKkx3Tq1CmPdQcMGGDFx8db69atM6+//PJLq1ChQtYjjzzisV7v3r2tGTNmWIsWLTLTE088YRUpUsQaPXq0x3qPPvqo+e7rrrvOmjdvnrVw4UJr/PjxZrnatGmT1aZNG6tSpUrWqlWr0qfsbN++3UpMTLTat29vffjhh9bSpUutOXPmWD169LD27dtnHTt2zPrss8/Md/br1y99e7/++mv695UqVcpq1KiR9frrr5v9GTZsmDnGxx9/PP179u/fb47xjTfesBYvXmy2ef/995v1/vOf/6Svl5aWZrVr185KSEiwnnrqKbO9xx57zKpZs6bZB523nX/++eZYvTVv3txMOTl8+LBVtmxZq1mzZta7775rLVu2zJo7d645X5s3bzbr6HHqOejcuXP6cevxqp9++sm66667rHfeecek2SeffGLSR49nyZIlZp1ApV12dLvVqlWzGjRoYL399tvWxx9/bHXs2NEs12vD+3qtWrWqdccdd1j//e9/rffee89cq/pa3xs4cKDZ16lTp1rly5c3201NTTWf//bbb83+63q6jh7Dn3/+ad5r27atmbz3y/08JScnm+1Vr17dmjZtmvXFF1+Y61vPsV4TCD4CJ/KdfSPKaoqNjfVYV2+eeoNPSkoyN+SKFSuaG413gHV3+vRpE4THjBljbu4aTNS2bdvM9m+99dYc9+/KK680Nylf6A1U9/u7777Ldh29gXrfDG1XXHGFdeaZZ1oHDhzwWK43Yg3Ie/fuzXKbevx6jHpD1vSx6U1dv+vFF1/0WF+DqPc+2Odhw4YN6cvWrl1rlrkH46x88803Zj39sZCTYsWKWb169cpxHffjueyyy6xrr702X9POptvVwL5r1y6P/ahXr55Vu3btTOnUs2dPj89v2bLFLL/77rs9lq9Zs8Ysf/jhh9OX6f7rMjuY2nwJnHfeeadVvHhx648//vBY7/nnnzfr2j9GEDwU1aLAaJGgFge6T2vWrPFYR4u/3n33XdmzZ48pftX7ihYvalGtd3Hp5Zdfboox9b24uDgZNWqU+VxKSopZR4t2T58+Lffcc0/AjkGLTrXo7I477jBFktu2bfP5s1r8+uWXX8q1115riv602M2eOnfubN5fvXp1+vrz5s2TNm3amKJNbZmpx6hFzFu2bElfZ8mSJeb/W2+91eO7tDjTW/fu3U3RonvR5qRJk0xR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TpajR4/mGlw1SGgfIG7IoZ8uOsC5PiFEH5YNAE4QOHOgN/zt27ebHIp2iNWbbHY3fztXSk4mtNNF90d/DGnTdT23Z599dsjkhAGEBwJnDvQGq8FT+/Xk9gimUAsQoSIU06VIkSISFxdnnk2p5zgxMTHYuwQgjPBT25dEIkcScTinAPxF4AQAwAECJwAADhA4kSc1atSQiRMn5mkbS5cuNfWf+/fvD8jZ+P333832vvvuu4BsDwDcETgj3MqVK02r4I4dO0oouOSSS2Tw4MEey1q3bm26/JQqVSpo+wUAviJwRriZM2fKvffeK19//bXs2LFDQpF286lUqVLItLoFgJwQOCPYkSNH5N1335W77rpLrrrqKpk9e3am4tEvv/xSmjVrZrrbaM5v69at6ev89ttv0qVLF6lYsaIUL15cmjdvLl988UW239e3b1/zPe60K4p259EA3rt3b1m2bJm8+OKL5rt10mLVrIpqV6xYIW3btjX7dcYZZ8gVV1wh+/btM+999tlncuGFF0rp0qWlbNmy5jt1XwEg6gLnV199JVdffbUZbEBvpB9++GGun9EbcdOmTU1fvJo1a8rUqVPzfT+bNRPRZ5y6T/os7KSkwuZ/7/cCNen3OjF37lypW7eumW677TaZNWuW6VfpbuTIkfLCCy/IN998Y/paavCzHT58WDp37myC5YYNG0zw0vOTXc61f//+JqhpsattwYIFZjs33XSTCZitWrWS22+/3ayjU1YPEde6ycsuu0zOOeccWbVqlckt6/faQx7qD4KhQ4fKunXrTODXriXXXnut6XMLAPnOCiELFiywRo4cab3//vt6d7c++OCDHNfftm2bVbRoUeu+++6zNm/ebL366qtWXFyc9d577/n8nQcOHDDfpf97+/fff8129X93Vatq9Cn4Sb/XidatW1sTJ0408ydPnrTKlStnLVq0yLxesmSJOe4vvvgiff1PP/3ULPM+XncNGjSwJk2alP66evXq1oQJEzzef/bZZ9Nfd+3a1erZs6eVlpZmXrdt29acL3f2vuzbt8+87t69u9WmTRufjzMlJcV8fuPGjeb19u3bzesNGzZk+5nszm1BOn36tJWcnGz+B+nCNRP8v6Wc4oG7kBo5qFOnTmbyleYuzzrrrPRWnfXr1zc5p+eff16uv/76fNvPSpWyWuqek4spwO/Nmha5rl27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Ro2Sp556Sg4ePMj5QCYETkStjh07SnJysmzbtk2efPJJmTx5sgmeWdFnGoaaUNynYPr3339lxowZ0r9//zxvq3Hjxib4zpkzJyD7hshC4ETUSkhIMA/9rVatmtxyyy1y6623yocffphenKhPox86dKh5sG779u3Ncn1S0bhx40wOtUiRInLuuefKe++9l75NnW/UqJF5r2zZsnL55Zen52Jzek/pjXrixIke+3jeeefJ448/nqd9yk5aWpo8+OCDUqZMGZMO9vfYctvuZ599JhdeeKGULl3aHM9VV10lv/32m8c29Ph69uwpxYsXNw8LfuGFF3LdLz0HJUuWNN//66+/mlIBfQC47m+xYsXM92blv//9rxQuXNg85NimaXbvvfeaouEzzjhDKlasKNOnTzf71adPHylRooTUqlXLfNbbNddcI2+//Xau+4voQ+AE/keDw8mTJ9PT4z//+Y+5Ea9YsUKmTZtmlj3yyCMya9YsmTJlimzatEmGDBkit912myxbtszkXrt37y59+/aVLVu2yNKlS+W6664zASCn95xwuk+5bUsD0Zo1a0yAHDNmjCxatCj9/dy2q8FHg/i6devkyy+/lEKFCsm1115rApztgQcekCVLlsgHH3wgCxcuNMe9fv36HPfru+++M0FaA+YPP/xggnLVqlXl559/lqNHj5r3svLVV19Js2bNsjxO/aGxdu1aE0TvuusuufHGG6V169by7bffyhVXXCE9evQw23bXokUL85njx4/nuL+IQo6f4AlEAH2AbZcuXdJfr1mzxipbtqx10003mddt27a1zjvvPI/PHD582EpMTDRPkHfXr18/q3v37tb69evNg3N///33TN+X03u26tWrWxMmTPBYdu6555qHEvu7T9nRbV144YUey5o3b2499NBDfm83JSXFHKP90ORDhw5Z8fHx1jvvvOPx8OEiRYpY9913X7b71rVrV2vgwIFmftSoUdZll11m5nU7+hDi7Oj57Nu3b47HeerUKatYsWJWjx490pclJyeb/V61apXHZ7///vtczxmiU+FgB24gWD755BNThHjq1CmT0+zSpYtMmjQp/X3v3MvmzZvl2LFj6UWk7nWN559/vskJXXbZZaY4VnMxHTp0MK0ztYgwp/eccLpPudXjudOi1JSUFJ+3q8Wyjz76qKxevVp2796dntPcsWOHNGzY0Lyv67sXnWqxcN26dXPNcWqxr/r+++/Tc5h2TjSnOs7ExMQcjzM2NtbkYPU82LT4VtnH7l4CobxzogCBE1GrXbt2phhSW9VWqVLF/O9OizHd2YHh008/NUWH3vWlelPWos6VK1eaYkkNwiNHjjRFoUlJSTm+p7So07vo1r3o2J99yon38WrRqL09X7arrVe1fvjVV1816aef0YBpN1pyWgytDh06ZLqS2IFNA+f1119v5rVYVet8s6PFsfv27fPpON2X6Wv3Y7bt3bvX/F++fHnHx4HIRh0nopYGodq1a0v16tUz3Vyz0qBBAxM0NEeln3OfNIDYN+E2bdrI6NGjZcOGDRIfH2/q93J7z75Ba12oTbtCbN++Pc/75I/ctqv9JbWuVutBNSddv379TEFL19V01RypTdfRusrs2MevjXYOHDhggqgGy9TUVFO36p0Ddqc5Yc0pB8qPP/4oZ555pgnIgDtynICP9Gau3VW0kYzmTrRFqQY3zUVqkW+9evVMIxkthtVO+Jqb1Bu+BhWdz+4926WXXiqzZ882OTktwtViUM3F5mWfevXq5df5zW272phGizy1haoW8WqAHT58uMc2dL1+/fqZBkK6rhaJai5bc9bZ0dxt0aJFzeADWlyrgVeLjLUhlTbWySlwahH4iBEjTHB2WgSeleXLl5vzBXgjcAIOPPHEEybwaUd77f+pXTGaNGkiDz/8sOlCoS07tUuJBhnNyWr3i06dOpncWXbv2fSmr9vUgFGqVCnzXbnlOHPbp7zIabsa/N555x0ZNGiQKZ7VesuXXnrJdP9w99xzz8nhw4dN1w4NxsOGDTM5yZxKAebNm2dav7722mtmmaaRdh3RQQnsYtWsaPGu1gG/++67cuedd+bp2DVYa2nA559/nqftIDLFaAuhYO8EAHjTvrVKByHIKWC6W7BggckpazFrTjnb3Lzyyivy0UcfmfpowBt1nABC0tatW6V58+Y+B03VuXNnk9vUARPyQouI3VtYA+7IcQIIOdpFSOtItVWvNj4CQgmBEwAAByiqBQDAAQInAAAOEDgBAHCAwAkAgAMETgAAHCBwAgDgAIETAAAHCJwAADhA4AQAwAECJwAA4rv/B/9AQkojNGDSAAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "y_exact = np.linspace(0, COLUMN_HEIGHT, 200)\n", + "psi_exact = gardner_steady_state_psi(\n", + " y_exact,\n", + " psi_0=PSI_BOTTOM,\n", + " psi_L=PSI_TOP,\n", + " L=COLUMN_HEIGHT,\n", + " alpha=ALPHA_G,\n", + ")\n", + "\n", + "fig, ax = plt.subplots(figsize=(5, 6))\n", + "ax.plot(psi_exact, y_exact, \"b-\", lw=2, label=\"Analytical\")\n", + "ax.set_xlabel(r\"Pressure head $\\psi$ (m)\")\n", + "ax.set_ylabel(\"Height $y$ (m)\")\n", + "ax.set_title(\"Exact steady-state profile\")\n", + "ax.grid(True, alpha=0.3)\n", + "ax.legend()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Numerical Solution with Richards Solver\n", + "\n", + "We solve the same problem numerically using `uw.systems.Richards`,\n", + "stepping forward in time until the transient terms die out and\n", + "we reach steady state." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": { + "execution": { + "iopub.execute_input": "2026-02-24T08:58:12.593981Z", + "iopub.status.busy": "2026-02-24T08:58:12.593676Z", + "iopub.status.idle": "2026-02-24T08:58:12.838854Z", + "shell.execute_reply": "2026-02-24T08:58:12.838122Z", + "shell.execute_reply.started": "2026-02-24T08:58:12.593949Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Structured box element resolution 4 32\n" + ] + } + ], + "source": [ + "mesh = uw.meshing.StructuredQuadBox(\n", + " elementRes=(4, RES),\n", + " minCoords=(0.0, 0.0),\n", + " maxCoords=(COLUMN_WIDTH, COLUMN_HEIGHT),\n", + " qdegree=3,\n", + ")\n", + "\n", + "psi_var = uw.discretisation.MeshVariable(r\"\\psi\", mesh, 1, degree=2)\n", + "v_soln = uw.discretisation.MeshVariable(\"v\", mesh, mesh.dim, degree=1)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": { + "execution": { + "iopub.execute_input": "2026-02-24T08:58:12.839864Z", + "iopub.status.busy": "2026-02-24T08:58:12.839556Z", + "iopub.status.idle": "2026-02-24T08:58:12.860738Z", + "shell.execute_reply": "2026-02-24T08:58:12.860473Z", + "shell.execute_reply.started": "2026-02-24T08:58:12.839852Z" + } + }, + "outputs": [], + "source": [ + "richards = uw.systems.Richards(mesh, psi_var, v_soln, order=2, theta=0.5, degree=3)\n", + "richards.petsc_options.delValue(\"ksp_monitor\")\n", + "richards.petsc_options[\"snes_rtol\"] = 1.0e-6\n", + "\n", + "psi_sym = psi_var.sym[0]\n", + "\n", + "# Constitutive model: Gardner K(ψ) with gravity\n", + "richards.constitutive_model = uw.constitutive_models.DarcyFlowModel\n", + "richards.constitutive_model.Parameters.permeability = gardner_K(\n", + " psi_sym, Ks=KS, alpha=ALPHA_G\n", + ")\n", + "richards.constitutive_model.Parameters.s = sympy.Matrix([0, -1]).T\n", + "\n", + "# Mixed form: θ(ψ) for mass-conservative storage term\n", + "richards.water_content = gardner_theta(\n", + " psi_sym,\n", + " theta_r=THETA_R,\n", + " theta_s=THETA_S,\n", + " alpha=ALPHA_G,\n", + ")\n", + "\n", + "richards.f = 0.0\n", + "\n", + "# Boundary conditions\n", + "richards.add_dirichlet_bc([PSI_TOP], \"Top\")\n", + "richards.add_dirichlet_bc([PSI_BOTTOM], \"Bottom\")\n", + "\n", + "# Velocity projector settings\n", + "richards._v_projector.petsc_options[\"snes_rtol\"] = 1.0e-6\n", + "richards._v_projector.smoothing = 1.0e-3" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": { + "execution": { + "iopub.execute_input": "2026-02-24T08:58:12.861731Z", + "iopub.status.busy": "2026-02-24T08:58:12.861634Z", + "iopub.status.idle": "2026-02-24T08:58:12.867820Z", + "shell.execute_reply": "2026-02-24T08:58:12.867134Z", + "shell.execute_reply.started": "2026-02-24T08:58:12.861722Z" + } + }, + "outputs": [ + { + "data": { + "text/latex": [ + "$\\kappa = Piecewise((0.0001, {\\psi}(N.x, N.y) >= 0), (0.0001*exp(3.5*{\\psi}(N.x, N.y)), True))$" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# Inspect the solver expressions\n", + "richards.constitutive_model.Parameters.permeability" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": { + "execution": { + "iopub.execute_input": "2026-02-24T08:58:12.868793Z", + "iopub.status.busy": "2026-02-24T08:58:12.868456Z", + "iopub.status.idle": "2026-02-24T08:58:21.192358Z", + "shell.execute_reply": "2026-02-24T08:58:21.191855Z", + "shell.execute_reply.started": "2026-02-24T08:58:12.868635Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Converged after 25 steps (dt = 1000.0 s)\n" + ] + } + ], + "source": [ + "# Initial guess: linear profile from bottom to top\n", + "y = mesh.X[1]\n", + "psi_init = PSI_BOTTOM + (PSI_TOP - PSI_BOTTOM) * y / COLUMN_HEIGHT\n", + "psi_var.array = uw.function.evaluate(psi_init, psi_var.coords)\n", + "\n", + "# Step towards steady state\n", + "dt = 0.1 * COLUMN_HEIGHT / KS # a few diffusive time scales\n", + "\n", + "for step in range(25):\n", + " richards.solve(timestep=dt)\n", + "\n", + "print(f\"Converged after 25 steps (dt = {dt:.1f} s)\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Comparison\n", + "\n", + "Sample the numerical solution along a vertical profile and\n", + "compare with the exact analytical solution." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": { + "execution": { + "iopub.execute_input": "2026-02-24T08:58:21.193049Z", + "iopub.status.busy": "2026-02-24T08:58:21.192951Z", + "iopub.status.idle": "2026-02-24T08:58:21.378893Z", + "shell.execute_reply": "2026-02-24T08:58:21.378129Z", + "shell.execute_reply.started": "2026-02-24T08:58:21.193038Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA94AAAJRCAYAAACz/G+SAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAAPYQAAD2EBqD+naQAAnx9JREFUeJzt3Qd8U1X7wPEnKXvvWUCwZSgyBBRwgKig4l44wFUtivoXeV24N4oL9VUsWkQF916viAKKiijLiVJkGdl7jzb3/3lOSUxC2nSlN/fm9/18QshNcnvOzTh57jnPOR7LsiwBAAAAAABx4Y3PbgEAAAAAAIE3AAAAAABxRo83AAAAAABxROANAAAAAEAcEXgDAAAAABBHBN4AAAAAAMQRgTcAAAAAAHFE4A0AAAAAQBwReAMAAAAAEEcE3gAAAAAAxBGBNwAAAAAAcUTgDSDp/Pzzz5KRkSEHHnigVK1a1VzS09Nl6NChMnv27HIpw9133y0ej6dc/paTlOa4TJgwwTx36dKlJf77n376qdnHyy+/HLZ948aNcuKJJ0qlSpXkv//9r9hp69atctNNN0n//v2lYcOGprx63OJl27ZtMnz4cGnWrJlUqVJFunTpIq+//nqhz3nhhRdMuWrUqCHlKS8vTxo1aiRPPPGEuN38+fNl4MCB0rJlS/MdVq9ePenVq5dMnDixSM+fPn26eY2iXb7//vsilyOZjjkAlEaFUj0bABwmKytLrrnmGmnXrp1cd911cvDBB5sfmgsWLJDXXntNevToIYsWLTJBOZLP3LlzzXW3bt3CTtScccYZsn37dpk6daoceeSRNpZQZP369TJu3Djp3LmznH766SbIjaczzzxTfvzxR3nooYekbdu28uqrr8r5558vfr9fLrjggv0e/88//8gNN9xgAvXNmzdLefr6669l7dq1psxut2nTJmnRooV5LZo3b27en5MmTZIhQ4aYk0+33357kfbz4IMPyjHHHBO2rWPHjkUuRzIdcwAoDQJvAEnj22+/lWHDhpleorffftv0Xgb069dPrr76annrrbdM71FZ2LFjh1SrVk0STaKWK1ECb33927dvb25rz66OjujUqZO88847Jpi0W6tWrUwPvJ4wWrduXVwDbx0BMGXKlGCwrTRIW7Zsmdx4440yaNAgSUlJCXvOlVdeKUcffbTpgdXPWXnSv9e9e3dzjNyub9++5hLq5JNPliVLlpgTM0UNvHW0T8+ePUtcjmQ65gBQGgw1B5A0tGdHgwTt9Q4NukOdc845weBKe74vvfRS88NUA1XtVTrllFPkl19+KXCItAZuZ599ttStWzfYa/7JJ5+Y4bmVK1eW1q1by6OPPhr1bwf28dtvv5kgp3bt2tK4cWO57LLLovYc5uTkmB5HHeap++7QoYM888wzRS5XYWXQXl49FloGDaBGjBghubm58ueff8oJJ5wgNWvWlAMOOEBGjx693z6++eYbOfbYY81j9Lj17t3bHINIRT0uRa1rWZgzZ44pk9JeW30dLrzwQvnqq68SIuhWgeHARVHa4/bee++Z4eL6Xgiln4sVK1bIrFmzwrbrMGc9Vs8++6wUR1m87yzLMuU966yzgtu0JzYzM9P0DGv9dWj+EUccIV988UWxyhf6N5577jk55JBDzLD7tLQ0E3hq77/2Eut3jN0aNGggFSqUT79K5DEvi9cRANyKHm8ASUHzEKdNm2Z6Zpo2bVqk52hgUb9+fTPEVn+wb9iwQV566SU5/PDDZd68eWa4eiQdbnneeeeZXj8d+vnll1/KaaedZnIvtfdUy6E/NlevXl3g39UfsdqTqD2tGuSPHDnSbB8/fnzwMb///rsJaDW/87HHHpMmTZrI5MmT5f/+7/9ML+hdd91VaLliOffcc2Xw4MEm7117PLXMe/fuNQGLjhrQoFR7QW+++WYTfASGmWrQdfzxx5se4uzsbBPsaBCmJyx0KL/WSxXnuBS3rpE0EOjTp4/JaY01hHv58uWmTJo/rSMknn/+ebn88sultDRA0ToWRVkFTaU9burXX381wXpkmfT1Ddyvf0OtWbPG5ILr5yU1NbVEZS7p+0599913snLlyrDAW4dd60mnBx54wAyT1+HZeltf65K8hvr+/fjjj+Wee+6Rrl27ysMPPyyXXHKJbNmyxYxC0PqX9/tAg3696N/XETv6GhdnHgId6aPfDXqSTN/7d9xxR5HTKaId89K+jgDgWhYAJIFVq1ZZ+pV33nnn7Xdfbm6utXfv3uDF7/dH3Yc+bs+ePVZ6erp1/fXXh9131113mf3feeedYdsPP/xwq1mzZtbOnTuD27Zs2WLVq1fPPD7aPkaPHh22fdiwYVaVKlXCyjVgwAArNTXV2rx5c9hjr7nmGvPYDRs2FFquggQe/9hjj4Vt79Kli9n+7rvvBrfpsWrYsKF15plnBrf17NnTatSokbV169aw49axY0dT3kAdinNcilrXF1980Tx3yZIlYY9LSUmx+vXrF7Pun3/+uXm+XnS/33//vVVWpk2bFtx3rEtk+Quzdu1a8xx93SIV9bgVRt/rup9IK1asMH/3wQcfDG4766yzrN69ewdf44svvtiqXr16ubzv1PDhw61DDjkkbFuNGjXM9rIwfvx4U5ZJkyYFt3399ddmW926da3nn3/elvfB0KFDg8+pVKmS9eyzzxbpeXPnzrWuu+4667333jP10Pp16NDBfF4+++yzIu0j8piXxesIAG7FUHMASU8n0qpYsWLwor2DSodG6tDRgw46yAxN1x4ovdbhuzoZWzShPT/as6yTUmlvjg5LDdBhltoDXJBTTz11v97FXbt2mR5Fpf/XHmOd8Et7qbScgctJJ51k7o+clTiyRyoWzRUNpb2e2nOsM3sH6PHQ3irN9w3UV4ce65D20NmsdXi/9jz6fD4z1LQ4x6UkdY2kj9V9FGWYudKy6n510qiyfI9pnYtyKYsh7cU9bqH360V7ZgMKG9YeuE/z3z/66CMzQqA0s/WX5H0X8O677+73Pj/ssMPMbPf333+/qa/2upaU9iLrcPLQCeU0dUPpaAIdfm/H++DWW281j9fUDU1L0ckjC0vbCNAe+zFjxpgJ+o466ihTfu3B1hFBOmt+UUQ75qV9HQHArRhqDiApaN6jTpoV7UeeDnnUCcd0yGRo0Kt5iZoPq8Mhdaiy/sj2er1m6PHOnTuj/p3QYew69FOHgOqP8kjRtgXo8PZQOlxbBf6mDpPV4Ojpp582l2h0KHFB5SoKzcsMpSccNIALDZQD23WYbaC+GrBF+1uBIELLrkF5UY9LSepaUjoEWesXGNJ/yy23mHSCyBMh6o8//jABjj5H66zDagsqn9I6B3LHy2OoeXGOm86ArTn2oTQtQyfu0vditGHZmnYReJ/ocmM6XPnaa681r7MO51Z79uwx13pbT2hVr149Lu879cMPP5g0gcgg8I033jBBt05Ap0Oo9XXQkxE69Lmwz2AkfW9reom+J0IFho3rUPbISebK632gqQR6UXpSRWl6ysUXX2xSZIqjTp06JmjWPHb9vilsosmCjnlpXkcAcDMCbwBJQX8U68zln3/+uQmwQ4ND7dFWkes/60RRF1100X4TJmnAoj9Qownt7dNAXW+vWrVqv8dF21ZUut9AL7IGPNFEBlLlsWZ44MSEHt9o+fKBEyDFOS4lqWtJaRCtS3RpwKM9t4GJyXSyuMhgSSdc+89//mPyV3Vd7YULFxa6b819j1yyqSA6K7VOPFUaxTluGixrj2mowPwFOomY5uZrEB8aCAYmGNQeYP08aG6+jhQJjBaJLIvm87///vsSL9rjrjnckctg6ftNe3X1okHihx9+aIJnHT3y2WefFXn/OtGinmCJfK/p7OFKlyEsivJ4H2gvvwbOixcvLnbgrQKjHWJ9ZxR0zAEA0RF4A0ga2gv0v//9z0wwpjMRay9cYfSHZ6C3OUCHc+o6xTpEMhbt4dMfwToc85FHHgn29migpsNyS0p7jvTHu/bA6TD0gmZoL29aX514TuurQ10DvWXau60nMXTCLf2hrse1qMelvOqqs8ZroKKTqil93XW2Zi2n9nhr715oD6k+Vns7tW61atUyk/YVZYhxUZTFUPPiHDe9r6Dya++wnoTQICswMZ7SSQa1nPp66/Bt7SGPpJOsaaCpnzkNgONJy6cTehVGe4V1lIIOwdeJ84oj0Juts6SHpiYEAu/Qofl2vw/0tdATYG3atCn2c7VnXyeP0xNNkb3TJTnmAIB/EXgDSBq6jJAOHdchsYceeqhZZujggw8O9tLqD0mlgZTSIZeaH6prOmvwoj+0NVAszozN9913n1k+R2f61h5SDdZ0JmQNUgPDdUviySefNDMPa27mVVddZXrGNHDVnjkNXqdOnSp2GDVqlKmrBn06c7EGdTqruc5+rT2ngV604hyX0tZVe2o1VaCwPO/AkHENjAI00P7ggw/M39bgW4PIwMkErYsOL9a1rLVXWUdFFHYiR/PXYwXnxaHBrObK63EIzGAeWDNbhxtr4F0W7xHNydXXSJ+vQ4L1hJPWXXuL9WSKBqR6iVxPWulnp6D7ytL8+fPlr7/+2m/Is55M0fehjlrQz7C+Bhr0atkjZ9GONfO9fk9o7/ETTzxhjqN+Z2gqis5noLPyawAeWLasMGX5PtC/p99VenJIlx3UkQc6q7kOr9f3ZaC3W9+3urzfnXfeaS4Belz0ZISWR0+M6AgPHbGgoxf0tSvJMQcAFIzAG0BS0d5uXTJHgxL9Ea1DoPVHtwbTuiySBmc6JF3pYzSY0mBS81g1WNde2ttvv73If0+DFh1iq8/RHkMN5nQ5Hc2f1CWJSkqHx2uwqAGs7luHzurwd11zPJDnaQcNXjSg06WqdJkl7RHW4ds6xDd0wqXiHJfS1lWD+lhLOOn+lb7GkRNQae+u9uxpzqwGNfp+0ZMGetE5A44++mgT4JXncddAOHS+Ag249BI6RLms3iP6nr/ttttM0KYnRTSI1eBbl6BKBHrCrFWrVmEnTZT22GqP/CuvvGLSSLRnXgNNnbMhdPIw/WzHmgchMALiuuuuM5OQ6Qki/S7Rky8a/OpJo+bNm5tt5UW/x1588UXz/tQ8es0f18+a1lfnHIhcwkw/i6H0ZKK+n3VYuh4DzcvWEzX6/FhD5ws65gCAgnl0avNC7gcAABGBqAbkmu+rPX8agM+cObPMcs1RPHqCQXvmo+WXF8Wnn35qTgr99NNPJqcd8T/mAJCM6PEGAKAYdOiu9s5rL6Hm0eowY4Ju++gw+9LQnGjtvSfoLr9jDgDJiB5vAAAAAADiyBvPnQMAAAAAkOwIvAEAAAAAiCMCbwAAAAAA4ojAGwAAAACAOCLwBgAAAAAgjgi8AQAAAACIIwJvAAAAAADiiMAbAAAAAIA4IvAGAAAAACCOCLwBAAAAAIgjAm8AAAAAAOKIwBsAAAAAgDgi8AYAAAAAII4IvAEAAAAAiCMCbwAAAAAA4ojAGwAAAACAOCLwBgAAAAAgjgi8AQAAAACIIwJvAAAAAADiiMAbAAAAAIA4IvAGAAAAACCOCLwBAAAAAIgjAm8gid17771y0EEHid/vL/Zzs7OzpXnz5rJ9+3ZJRG+88YYcfPDBUrVqVfF4PDJ//nyZMGGC+f/SpUuDj4u2DQBQfIHv0ypVqsiyZcv2u79v377SsWNHRx9ard/dd98dt/0fcMABcskll4hdzjzzTFPHa665ptz+ph5P/Zsl8eCDD8r777+/3/bp06ebfep1POhvBt2/vueBoiLwBpLUihUrZPTo0Sb49nqL/1Vw8cUXS/Xq1c0+Es3atWtlyJAhcuCBB8pnn30mM2fOlLZt28rAgQPN/5s2bWp3EQHAtXbv3i233367uJG2IZdffrm40Zo1a+Tjjz82/580aZLs2rVLEl1Bgfehhx5qXiu9BhIFgTeQpJ588kmpU6eOObtdEhUqVJChQ4ea/ezYsUPiqbj7X7hwoezdu1cGDx4sffr0kZ49e0q1atWkYcOG5v+VK1eOW1kBINmdcMIJ8uqrr8pPP/0kbmBZluzcudP8X9uQ1NRUcaOXX37ZtJ16knrTpk3y7rvvilPVqlXLvFZ6DSQKAm/ARXTIeM2aNeXmm282t//8808zFOqtt94KNqp6W7frUPELLrhgv95uDcSj/ajIzc2VLl26yPHHHx/cduGFF8qWLVvk9ddfL/JQsnnz5pm/oY1h7dq1TXCsPdSRj5s7d66cffbZUrduXdNzHfDNN9/Isccea+qpwXTv3r3lk08+Cd6vQ/SOPPJI8/9BgwaZfenwxuIMK8/JyTHHplGjRiZI79ChgzzzzDMx6wgAELnpppukfv36wbaoJMN1I4d0B9qGn3/+Wc455xzTftSrV09GjBhh2idt1zTg17ZBh2tHG42l7dUNN9wgrVu3lkqVKpl0qeHDh++XMhUYav3cc8+Z739tB1566aWo5VL//POPZGZmSosWLcx+mzVrZtqv1atXm/u15/g///mPaUMD5e7Vq5d88MEHCfV2GT9+vDRu3NjUVdO09HakQDs6bdo0ueqqq6RBgwbmtdZ2XUfSRaZ89e/f34wy0/3psbzllltipqhlZGSYYxTtpHu/fv1MGpnScui+tLz6/9D2vqCh5rNmzZJTTjnFlFlTIvT3hb4HAhYtWiSXXnqppKenm98Y+h7Rx//yyy/FPJrA/gi8ARdZsGCBbNu2Tbp3725uz54921wHbs+ZM8c0+jqcbP369XLMMcfst4+jjz7a/IiIzM97/PHH5Y8//pBnn302uK1JkybSvn37sMA3ljPOOEPS0tLk7bffNj9edIjYgAEDzFn2UNqI6+P0pIH++FFfffWVaXQ3b95sThy89tpr5keWNorawKs77rgjGCTrEDQdahZa5lh+//136dGjh/z666/y2GOPmWF3evb///7v/+See+4p8n4AIFnp97IONZ88ebJMnTq1TPd97rnnSufOneWdd96RK664Qp544gm5/vrr5fTTTzff1e+9955pJzToD+2x1SBOR0BpkKbf5//73//MYzSQPPXUU02vdihtm8aOHSt33nmnqcdRRx0VtTzaXmqboX9XTwLofseMGWPa2o0bNwaH3m/YsMEE/bpfbbv0BLG2c3pCPBF899135jfERRddZILSs846y7x2S5Ysifp4HW5fsWJFM7JBT3JogKsn0iNP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pk2bCn3O6tWr5ZJLLjF10mNxwgknSE5OjiSSktRr27Ztcs0115j1jPW16tChg4wdO1acXCf9LEW7PPLII+LkeqkFCxbIqaeeap5Ts2ZN6dmzpyxfvlycWif9TEW+TlonFM2zzz4rrVu3lipVqki3bt1kxowZhT7+q6++Mo/Tx7dp00aee+65/R7zzjvvyEEHHWQmYtPr9957LymPhbZZZ511lhxwwAHmfTlmzBhxirI+Fs8//7wcddRRUrduXXM57rjj5IcffpBkPBb6u6179+7m95D+Hu3SpYu88sorkqzfFwGvv/66+Zycfvrp4hRlfTwmTJgQ9bfHrl27xLEsuMKdd95pPf7449aIESOs2rVrx3z8pk2brOOOO8564403rD/++MOaOXOmdfjhh1vdunWznFon9fLLL1v33HOP9fzzz1v69p43b56VaEpSr4ceesiqWbOm9c4771i//PKLNWjQIKtp06bWli1brERwwgknWB07drS+++47c9H/n3zyyQU+3u/3Wz179rSOOuoo64cffjDvwczMTKtly5bWtm3brERR3Hqpyy+/3DrwwAOtadOmWUuWLLGysrKslJQU6/3337ecWqeVK1eGXcaPH295PB7rr7/+shJFSeq1aNEiq169etaNN95ozZ0719Tn448/tlavXm05tU4XX3yxeV7o67V+/fpyK7OTvf7661bFihVN+/H7779b1113nVW9enVr2bJlUR+/ePFiq1q1auZx+nh9nj7/7bffDj5GXzf9/D/44IPWggULzHWFChWs77//3kq2Y6Hf9TfccIP12muvWU2aNLGeeOIJywnicSwuuOAC65lnnjG/UfR9cemll5rfAz6fz0q2Y6Ft5bvvvmvu1+/kMWPGmM/MZ599ZiXbsQhYunSp1bx5c/Mb6bTTTrOcIB7H48UXX7Rq1aq1328QJyPwdhl9kxY1mIukjaIGqwV9SJxUJw14EjXwLm69NEjVHykafAfs2rXLPPe5556z7KZfmHqsQ39I6okc3aYBdTR//vmnuf/XX38NbsvNzTVBkH75JoKS1EsdfPDB1r333hu27dBDD7Vuv/12y6l1iqQ/BPr162clipLWS09gDR482EpEJa2TBt5O+aGWaA477DDryiuvDNvWvn1765Zbbon6+JtuusncH2ro0KHmpGLAueeea06EhBowYIB13nnnWcl2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rVjPcVKdlP4/2nPuuusuK1GUpF66pJjWQZcVq1y5snX00UebADxR6AmPCy+80CyHohf9/8aNG8MeE1mnJ5980pz40S9aXb9blw5JlJM+pamX/ni65JJLrGbNmpmAWwMj/SGhr6FT66R0PfKqVatamzZtshJRSeuljX1aWpp5rTp37pww662XpE47duyw+vfvbzVs2DD4udLvm+XLl9tUA+fRtZX1JKAGRboM4FdffRW8T4+lnlgLNX36dLP0kT7+gAMOiHpi6q233jLfA/qa6PI577zzjpWMx6Kg3x2R+0mGY6H7SvTfKuV1LG677bbgd3DdunXNyUIN4pL1+8KpgXc8jsfw4cNNO6b3a7um7ZueWHYyj/5jd687AAAAAABuxazmAAAAAADEEYE3AAAAAABxROANAAAAAEAcEXgDAAAAABBHBN4AAAAAAMQRgTcAAAAAAHFE4A0AAAAAQBwReAMAAAAAEEcE3gAAAAAAxBGBNwAAAJAEhgwZIg8++GCp9vHxxx9L165dxe/3l1m5gGRA4A2g3PTt21eGDx/uur+9fv16adSokSxdurRU+zn77LPl8ccfL7NyAQDK1yWXXCIej2e/ywknnGD7S/Hzzz/LJ598Itdee22p9nPyySebOr366qtlVjYgGRB4AzY1yBUrVpQ2bdrIDTfcINu3b+d1cLBRo0bJKaecIgcccECp9nPnnXfKAw88IFu2bCmzsgEAypcG2StXrgy7vPbaawU+fu/evUXaVhSFPe+///2vnHPOOVKzZk0prUsvvVSefvrpUu8HSCYE3oBNDfLixYvl/vvvl2effdYE39Hs2bMn4V6fRCyTnXbu3CnZ2dly+eWXl3pfnTp1MsH7pEmTyqRsAIDyV7lyZWnSpEnYpW7dusH79eT7c889J6eddppUr17d/Ba4++67pUuXLjJ+/HhzUl73YVmWLF++3DyuRo0aUqtWLTn33HNl9erVwX0V9LxIOiz8rbfeklNPPTVsu7Y5+vcvuugi8zdatWolH3zwgaxduzb4dw855BCZPXt22PN0Pz/88IP5LQOgaAi8AZsa5BYtWsgFF1wgF154obz//vvB4dDXXHONjBgxQho0aCDHH3+82a6N6OjRo02jWrVqVencubO8/fbbwX3q/7Vh1Pvq168vxx13XLAXvbD7Ao3umDFjwsqojbg25qUpU0G08b/p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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Max absolute error: 5.4541e-02 m\n" + ] + } + ], + "source": [ + "n_sample = 100\n", + "sample_y = np.linspace(0.02, COLUMN_HEIGHT - 0.02, n_sample)\n", + "sample_x = np.full_like(sample_y, COLUMN_WIDTH / 2)\n", + "sample_pts = np.column_stack([sample_x, sample_y])\n", + "\n", + "psi_numerical = uw.function.evaluate(psi_var.sym[0], sample_pts).squeeze()\n", + "psi_analytical = gardner_steady_state_psi(\n", + " sample_y, PSI_BOTTOM, PSI_TOP, COLUMN_HEIGHT, ALPHA_G\n", + ")\n", + "\n", + "fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 6), sharey=True)\n", + "\n", + "# Pressure head profile\n", + "ax1.plot(psi_analytical, sample_y, \"b-\", lw=2, label=\"Analytical\")\n", + "ax1.plot(psi_numerical, sample_y, \"ro\", ms=3, label=\"Numerical\")\n", + "ax1.set_xlabel(r\"Pressure head $\\psi$ (m)\")\n", + "ax1.set_ylabel(\"Height $y$ (m)\")\n", + "ax1.set_title(r\"$\\psi(y)$ profile\")\n", + "ax1.legend()\n", + "ax1.grid(True, alpha=0.3)\n", + "\n", + "# Error\n", + "error = psi_numerical - psi_analytical\n", + "ax2.plot(error, sample_y, \"k-\", lw=1)\n", + "ax2.axvline(0, color=\"grey\", ls=\"--\", lw=0.5)\n", + "ax2.set_xlabel(\"Error (m)\")\n", + "ax2.set_title(\"Numerical − Analytical\")\n", + "ax2.grid(True, alpha=0.3)\n", + "\n", + "fig.suptitle(\n", + " f\"Gardner model: $K_s$ = {KS:.0e} m/s, \"\n", + " rf\"$\\alpha$ = {ALPHA_G} /m\",\n", + " fontsize=12,\n", + ")\n", + "fig.tight_layout()\n", + "plt.show()\n", + "\n", + "print(f\"Max absolute error: {np.max(np.abs(error)):.4e} m\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Darcy Velocity\n", + "\n", + "The Richards solver also computes the Darcy flux\n", + "$\\mathbf{q} = -K(\\psi)(\\nabla\\psi - \\mathbf{s})$.\n", + "At steady state the vertical component should be constant\n", + "(uniform flux through the column)." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": { + "execution": { + "iopub.execute_input": "2026-02-24T08:58:21.379648Z", + "iopub.status.busy": "2026-02-24T08:58:21.379307Z", + "iopub.status.idle": "2026-02-24T08:58:21.456018Z", + "shell.execute_reply": "2026-02-24T08:58:21.455068Z", + "shell.execute_reply.started": "2026-02-24T08:58:21.379637Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "vy_numerical = uw.function.evaluate(v_soln.sym[0, 1], sample_pts).squeeze()\n", + "\n", + "fig, ax = plt.subplots(figsize=(5, 6))\n", + "ax.plot(vy_numerical, sample_y, \"g-\", lw=1.5)\n", + "ax.set_xlabel(r\"Vertical Darcy flux $q_y$ (m/s)\")\n", + "ax.set_ylabel(\"Height $y$ (m)\")\n", + "ax.set_title(\"Darcy velocity (should be nearly constant)\")\n", + "ax.grid(True, alpha=0.3)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Try It Yourself\n", + "\n", + "Experiment with different parameters to build intuition:\n", + "\n", + "```python\n", + "# Larger α → sharper transition near saturation\n", + "ALPHA_G = 5.0\n", + "\n", + "# Wetter bottom boundary\n", + "PSI_BOTTOM = -1.0\n", + "\n", + "# Higher resolution\n", + "RES = 64\n", + "```\n", + "\n", + "- What happens as $\\alpha \\to 0$? (Hint: the profile should approach linear.)\n", + "- What if the top boundary is fully saturated ($\\psi_\\text{top} = 0$)?\n", + "- The Van Genuchten model is also available — try replacing\n", + " `gardner_K` with `van_genuchten_K` (no analytical solution,\n", + " but the solver still works).\n", + "- Can you compute mass conservation by integrating $\\theta(\\psi)$\n", + " over the column?\n", + "\n", + "## References\n", + "\n", + "Celia, M. A., Bouloutas, E. T. & Zarba, R. L. (1990). A general\n", + "mass-conservative numerical solution for the unsaturated flow equation.\n", + "*Water Resources Research*, 26(7), 1483–1496.\n", + "doi:[10.1029/WR026i007p01483](https://doi.org/10.1029/WR026i007p01483)\n", + "\n", + "Gardner, W. R. (1958). Some steady-state solutions of the unsaturated\n", + "moisture flow equation with application to evaporation from a water table.\n", + "*Soil Science*, 85(4), 228–232.\n", + "\n", + "Mualem, Y. (1976). A new model for predicting the hydraulic conductivity\n", + "of unsaturated porous media. *Water Resources Research*, 12(3), 513–522.\n", + "doi:[10.1029/WR012i003p00513](https://doi.org/10.1029/WR012i003p00513)\n", + "\n", + "Richards, L. A. (1931). Capillary conduction of liquids through porous\n", + "mediums. *Physics*, 1(5), 318–333.\n", + "doi:[10.1063/1.1745010](https://doi.org/10.1063/1.1745010)\n", + "\n", + "Van Genuchten, M. Th. (1980). A closed-form equation for predicting the\n", + "hydraulic conductivity of unsaturated soils. *Soil Science Society of\n", + "America Journal*, 44(5), 892–898.\n", + "doi:[10.2136/sssaj1980.03615995004400050002x](https://doi.org/10.2136/sssaj1980.03615995004400050002x)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.12" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/docs/beginner/tutorials/17-Richards-Transient-Wetting-Front.ipynb b/docs/beginner/tutorials/17-Richards-Transient-Wetting-Front.ipynb new file mode 100644 index 000000000..97b691372 --- /dev/null +++ b/docs/beginner/tutorials/17-Richards-Transient-Wetting-Front.ipynb @@ -0,0 +1,644 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cell-0", + "metadata": {}, + "source": [ + "# Notebook 17: Richards Equation — Transient Wetting Front\n", + "\n", + "This notebook solves a **transient** Richards equation problem\n", + "and validates the numerical solution against an exact analytical\n", + "benchmark. A wetting front propagates downward through an\n", + "initially dry soil column after a wet boundary condition is\n", + "applied at the top.\n", + "\n", + "## Key Concepts\n", + "\n", + "- Time-dependent Richards equation with the mixed form\n", + "- Gardner exponential model — linearisation trick\n", + "- Ogata–Banks advection–diffusion solution\n", + "- Wetting-front dynamics and mass conservation" + ] + }, + { + "cell_type": "markdown", + "id": "cell-1", + "metadata": {}, + "source": [ + "## Why a Transient Benchmark?\n", + "\n", + "[Notebook 16](16-Richards-Equation-Groundwater.ipynb) validated\n", + "the Richards solver at steady state, where the time derivative\n", + "vanishes and all formulations agree. A transient test is needed\n", + "to verify that the **mixed form** storage term\n", + "$\\partial\\theta/\\partial t$ is discretised correctly.\n", + "\n", + "### The Gardner Linearisation\n", + "\n", + "The substitution $u = \\exp(\\alpha\\psi)$ transforms the nonlinear\n", + "Richards equation with Gardner conductivity into a **linear**\n", + "advection–diffusion equation:\n", + "\n", + "$$\\frac{\\partial u}{\\partial t}\n", + " = D\\,\\frac{\\partial^2 u}{\\partial z^2}\n", + " + V\\,\\frac{\\partial u}{\\partial z}$$\n", + "\n", + "where $z = L - y$ is depth from the top,\n", + "$D = K_s / (\\alpha\\,\\Delta\\theta)$,\n", + "$V = K_s / \\Delta\\theta$, and\n", + "$\\Delta\\theta = \\theta_s - \\theta_r$.\n", + "\n", + "### Ogata–Banks Solution\n", + "\n", + "For a step change at the top ($z = 0$) from $u_{\\rm dry}$ to\n", + "$u_{\\rm wet}$ with a semi-infinite column, the\n", + "**Ogata–Banks (1961)** solution is:\n", + "\n", + "$$u(z,t) = u_{\\rm dry}\n", + " + (u_{\\rm wet} - u_{\\rm dry})\\,H(z,t)$$\n", + "\n", + "where\n", + "\n", + "$$H(z,t) = \\tfrac{1}{2}\\,\\operatorname{erfc}\\!\\left(\n", + " \\frac{z - Vt}{2\\sqrt{Dt}}\\right)\n", + " + \\tfrac{1}{2}\\,\\exp\\!\\left(\\frac{Vz}{D}\\right)\\,\n", + " \\operatorname{erfc}\\!\\left(\\frac{z + Vt}{2\\sqrt{Dt}}\\right)$$\n", + "\n", + "Converting back: $\\psi(y,t) = \\ln(u)/\\alpha$.\n", + "\n", + "The approximation is valid while the wetting front has not\n", + "yet reached the bottom boundary." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "cell-3", + "metadata": { + "execution": { + "iopub.execute_input": "2026-02-24T09:22:11.232839Z", + "iopub.status.busy": "2026-02-24T09:22:11.232279Z", + "iopub.status.idle": "2026-02-24T09:22:15.257488Z", + "shell.execute_reply": "2026-02-24T09:22:15.256902Z", + "shell.execute_reply.started": "2026-02-24T09:22:11.232819Z" + } + }, + "outputs": [], + "source": [ + "import numpy as np\n", + "import sympy\n", + "import underworld3 as uw\n", + "import matplotlib.pyplot as plt\n", + "\n", + "from underworld3.utilities.retention_curves import (\n", + " gardner_K,\n", + " gardner_theta,\n", + " gardner_transient_psi,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-4", + "metadata": {}, + "source": [ + "### Configurable parameters\n", + "\n", + "Default values are defined as named constants below. From the\n", + "command line, override them with PETSc-style flags:\n", + "\n", + "```bash\n", + "python script.py -uw_res 64 -uw_alpha \"4.0 1/m\"\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "cell-5", + "metadata": { + "execution": { + "iopub.execute_input": "2026-02-24T09:22:15.258545Z", + "iopub.status.busy": "2026-02-24T09:22:15.258091Z", + "iopub.status.idle": "2026-02-24T09:22:15.268344Z", + "shell.execute_reply": "2026-02-24T09:22:15.267546Z", + "shell.execute_reply.started": "2026-02-24T09:22:15.258524Z" + } + }, + "outputs": [], + "source": [ + "# --- Default values (edit these in a notebook) ---\n", + "COLUMN_HEIGHT = 3.0 # m — tall column so the front stays away from the bottom\n", + "COLUMN_WIDTH = 0.1 # m — narrow (effectively 1-D)\n", + "RES = 64 # — vertical elements\n", + "KS = 1.0 # m/s — saturated hydraulic conductivity (dimensionless-friendly)\n", + "ALPHA_G = 4.0 # 1/m — Gardner sorptive number\n", + "THETA_R = 0.05 # — residual water content\n", + "THETA_S = 0.40 # — saturated water content\n", + "PSI_DRY = -2.0 # m — initial (dry) pressure head\n", + "PSI_WET = -0.1 # m — wet boundary at top\n", + "DT = 0.005 # s — timestep (accuracy is time-dominated)\n", + "SNAPSHOTS = [0.05, 0.15, 0.30] # s — times to compare" + ] + }, + { + "cell_type": "markdown", + "id": "cell-6", + "metadata": {}, + "source": [ + "## Analytical Wetting Front\n", + "\n", + "Before running the solver, let's visualise what the\n", + "analytical solution predicts at the snapshot times." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "cell-7", + "metadata": { + "execution": { + "iopub.execute_input": "2026-02-24T09:22:15.269856Z", + "iopub.status.busy": "2026-02-24T09:22:15.269698Z", + "iopub.status.idle": "2026-02-24T09:22:15.801131Z", + "shell.execute_reply": "2026-02-24T09:22:15.799427Z", + "shell.execute_reply.started": "2026-02-24T09:22:15.269839Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "y_exact = np.linspace(0, COLUMN_HEIGHT, 500)\n", + "\n", + "fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 6), sharey=True)\n", + "\n", + "for t_snap in SNAPSHOTS:\n", + " psi_exact = gardner_transient_psi(\n", + " y_exact, t_snap,\n", + " psi_dry=PSI_DRY, psi_wet=PSI_WET,\n", + " L=COLUMN_HEIGHT, Ks=KS, alpha=ALPHA_G,\n", + " theta_r=THETA_R, theta_s=THETA_S,\n", + " )\n", + " theta_exact = THETA_R + (THETA_S - THETA_R) * np.exp(ALPHA_G * np.clip(psi_exact, None, 0))\n", + "\n", + " ax1.plot(psi_exact, y_exact, lw=2, label=f\"$t = {t_snap}$ s\")\n", + " ax2.plot(theta_exact, y_exact, lw=2, label=f\"$t = {t_snap}$ s\")\n", + "\n", + "# Initial condition\n", + "ax1.axvline(PSI_DRY, color=\"grey\", ls=\":\", lw=0.8, label=\"initial\")\n", + "ax2.axvline(\n", + " THETA_R + (THETA_S - THETA_R) * np.exp(ALPHA_G * PSI_DRY),\n", + " color=\"grey\", ls=\":\", lw=0.8, label=\"initial\",\n", + ")\n", + "\n", + "ax1.set_xlabel(r\"Pressure head $\\psi$ (m)\")\n", + "ax1.set_ylabel(\"Height $y$ (m)\")\n", + "ax1.set_title(r\"$\\psi(y, t)$\")\n", + "ax1.legend()\n", + "ax1.grid(True, alpha=0.3)\n", + "\n", + "ax2.set_xlabel(r\"Water content $\\theta$\")\n", + "ax2.set_title(r\"$\\theta(y, t)$\")\n", + "ax2.legend()\n", + "ax2.grid(True, alpha=0.3)\n", + "\n", + "fig.suptitle(\"Analytical wetting front (Gardner / Ogata–Banks)\", fontsize=12)\n", + "fig.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-8", + "metadata": {}, + "source": [ + "## Set Up the Richards Solver\n", + "\n", + "We create a vertical column mesh and configure the Richards\n", + "solver with Gardner constitutive curves. The `water_content`\n", + "property activates the mixed (mass-conservative) form.\n", + "\n", + "**Solver note:** The Gardner exponential creates steep\n", + "nonlinearity in dry regions where $K$ and $\\theta$ are\n", + "very small. The standard Newton method with backtracking\n", + "linesearch can overshoot into unphysical states.\n", + "A **trust-region** SNES (`newtontr`) constrains the\n", + "Newton step size and converges reliably." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "cell-9", + "metadata": { + "execution": { + "iopub.execute_input": "2026-02-24T09:22:15.802505Z", + "iopub.status.busy": "2026-02-24T09:22:15.802089Z", + "iopub.status.idle": "2026-02-24T09:22:16.009513Z", + "shell.execute_reply": "2026-02-24T09:22:16.007830Z", + "shell.execute_reply.started": "2026-02-24T09:22:15.802479Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Structured box element resolution 4 64\n" + ] + } + ], + "source": [ + "mesh = uw.meshing.StructuredQuadBox(\n", + " elementRes=(4, RES),\n", + " minCoords=(0.0, 0.0),\n", + " maxCoords=(COLUMN_WIDTH, COLUMN_HEIGHT),\n", + " qdegree=3,\n", + ")\n", + "\n", + "psi_var = uw.discretisation.MeshVariable(r\"\\psi\", mesh, 1, degree=2)\n", + "v_soln = uw.discretisation.MeshVariable(\"v\", mesh, mesh.dim, degree=1)" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "cell-10", + "metadata": { + "execution": { + "iopub.execute_input": "2026-02-24T09:22:16.011114Z", + "iopub.status.busy": "2026-02-24T09:22:16.010927Z", + "iopub.status.idle": "2026-02-24T09:22:16.087474Z", + "shell.execute_reply": "2026-02-24T09:22:16.082901Z", + "shell.execute_reply.started": "2026-02-24T09:22:16.011096Z" + } + }, + "outputs": [], + "source": [ + "richards = uw.systems.Richards(mesh, psi_var, v_soln, order=1, theta=0.5, degree=3)\n", + "richards.petsc_options.delValue(\"ksp_monitor\")\n", + "richards.petsc_options[\"snes_rtol\"] = 1.0e-6\n", + "richards.petsc_options[\"snes_max_it\"] = 50\n", + "\n", + "# Trust-region Newton method — robust for the steep Gardner nonlinearity\n", + "richards.petsc_options[\"snes_type\"] = \"newtontr\"\n", + "\n", + "psi_sym = psi_var.sym[0]\n", + "\n", + "# Constitutive model: Gardner K(ψ) with gravity\n", + "richards.constitutive_model = uw.constitutive_models.DarcyFlowModel\n", + "richards.constitutive_model.Parameters.permeability = gardner_K(\n", + " psi_sym, Ks=KS, alpha=ALPHA_G\n", + ")\n", + "richards.constitutive_model.Parameters.s = sympy.Matrix([0, -1]).T\n", + "\n", + "# Mixed form: θ(ψ) for mass-conservative storage\n", + "richards.water_content = gardner_theta(\n", + " psi_sym,\n", + " theta_r=THETA_R,\n", + " theta_s=THETA_S,\n", + " alpha=ALPHA_G,\n", + ")\n", + "\n", + "richards.f = 0.0\n", + "\n", + "# Boundary conditions\n", + "richards.add_dirichlet_bc([PSI_WET], \"Top\")\n", + "richards.add_dirichlet_bc([PSI_DRY], \"Bottom\")\n", + "\n", + "# Velocity projector\n", + "richards._v_projector.petsc_options[\"snes_rtol\"] = 1.0e-6\n", + "richards._v_projector.smoothing = 1.0e-3" + ] + }, + { + "cell_type": "markdown", + "id": "cell-11", + "metadata": {}, + "source": [ + "## Initial Condition and Timestepping\n", + "\n", + "We initialise with a smooth profile that interpolates between\n", + "the wet top and the dry interior (helps the first SNES iteration\n", + "converge). Then we step forward in time, saving snapshots at\n", + "the analytical comparison times." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "cell-12", + "metadata": { + "execution": { + "iopub.execute_input": "2026-02-24T09:22:16.095786Z", + "iopub.status.busy": "2026-02-24T09:22:16.092667Z", + "iopub.status.idle": "2026-02-24T09:22:20.237314Z", + "shell.execute_reply": "2026-02-24T09:22:20.234410Z", + "shell.execute_reply.started": "2026-02-24T09:22:16.095751Z" + } + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "[0]PETSC ERROR: --------------------- Error Message --------------------------------------------------------------\n", + "[0]PETSC ERROR: Object is in wrong state\n", + "[0]PETSC ERROR: Must call SNESSetFunction() or SNESSetDM() before SNESComputeFunction(), likely called from SNESSolve().\n", + "[0]PETSC ERROR: WARNING! There are unused option(s) set! Could be the program crashed before usage or a spelling mistake, etc!\n", + "[0]PETSC ERROR: Option left: name:-dm_plex_hash_location (no value) source: code\n", + "[0]PETSC ERROR: Option left: name:-options_left value: 0 source: code\n", + "[0]PETSC ERROR: Option left: name:-Solver_5_mg_levels_ksp_converged_maxits (no value) source: code\n", + "[0]PETSC ERROR: Option left: name:-Solver_5_mg_levels_ksp_max_it value: 3 source: code\n", + "[0]PETSC ERROR: Option left: name:-Solver_5_pc_mg_type value: additive source: code\n", + "[0]PETSC ERROR: Option left: name:-Solver_6_ksp_rtol value: 0.001 source: code\n", + "[0]PETSC ERROR: Option left: name:-Solver_6_ksp_type value: gmres source: code\n", + "[0]PETSC ERROR: Option left: name:-Solver_6_mg_levels_ksp_converged_maxits (no value) source: code\n", + "[0]PETSC ERROR: Option left: name:-Solver_6_mg_levels_ksp_max_it value: 3 source: code\n", + "[0]PETSC ERROR: Option left: name:-Solver_6_pc_gamg_agg_nsmooths value: 2 source: code\n", + "[0]PETSC ERROR: Option left: name:-Solver_6_pc_gamg_repartition value: true source: code\n", + "[0]PETSC ERROR: Option left: name:-Solver_6_pc_gamg_type value: agg source: code\n", + "[0]PETSC ERROR: Option left: name:-Solver_6_pc_mg_type value: additive source: code\n", + "[0]PETSC ERROR: Option left: name:-Solver_6_pc_type value: gamg source: code\n", + "[0]PETSC ERROR: Option left: name:-Solver_6_snes_rtol value: 1e-06 source: code\n", + "[0]PETSC ERROR: Option left: name:-Solver_6_snes_type value: newtonls source: code\n", + "[0]PETSC ERROR: See https://petsc.org/release/faq/ for trouble shooting.\n", + "[0]PETSC ERROR: PETSc Release Version 3.24.3, unknown\n", + "[0]PETSC ERROR: /Users/lmoresi/+Underworld/underworld3-pixi/.pixi/envs/amr-dev/lib/python3.12/site-packages/ipykernel_launcher.py with 1 MPI process(es) and PETSC_ARCH petsc-4-uw on Lyrebird.local by lmoresi Tue Feb 24 20:22:11 2026\n", + "[0]PETSC ERROR: Configure options: --with-petsc-arch=petsc-4-uw --download-bison --download-eigen --download-metis --download-mmg --download-mumps --download-parmetis --download-parmmg --download-pragmatic --download-ptscotch=/Users/lmoresi/+Underworld/underworld3-pixi/petsc-custom/patches/scotch-7.0.10-c23-fix.tar.gz --download-scalapack --download-slepc --with-debugging=0 --with-hdf5=1 --with-pragmatic=1 --with-x=0 --with-mpi-dir=/Users/lmoresi/+Underworld/underworld3-pixi/.pixi/envs/amr --with-hdf5-dir=/Users/lmoresi/+Underworld/underworld3-pixi/.pixi/envs/amr --download-hdf5=0 --download-mpich=0 --download-mpi4py=0 --with-petsc4py=0\n", + "[0]PETSC ERROR: #1 SNESComputeFunction() at /Users/lmoresi/+Underworld/underworld3-pixi/petsc-custom/petsc/src/snes/interface/snes.c:2486\n", + "[0]PETSC ERROR: #2 SNESSolve_NEWTONTR() at /Users/lmoresi/+Underworld/underworld3-pixi/petsc-custom/petsc/src/snes/impls/tr/tr.c:537\n", + "[0]PETSC ERROR: #3 SNESSolve() at /Users/lmoresi/+Underworld/underworld3-pixi/petsc-custom/petsc/src/snes/interface/snes.c:4905\n" + ] + }, + { + "ename": "Error", + "evalue": "error code 73", + "output_type": "error", + "traceback": [ + "\u001b[31m---------------------------------------------------------------------------\u001b[39m", + "\u001b[31mError\u001b[39m Traceback (most recent call last)", + "\u001b[36mCell\u001b[39m\u001b[36m \u001b[39m\u001b[32mIn[6]\u001b[39m\u001b[32m, line 26\u001b[39m\n\u001b[32m 23\u001b[39m n_steps = \u001b[38;5;28mint\u001b[39m(np.ceil(t_end / DT))\n\u001b[32m 25\u001b[39m \u001b[38;5;28;01mfor\u001b[39;00m step \u001b[38;5;129;01min\u001b[39;00m \u001b[38;5;28mrange\u001b[39m(n_steps):\n\u001b[32m---> \u001b[39m\u001b[32m26\u001b[39m \u001b[43mrichards\u001b[49m\u001b[43m.\u001b[49m\u001b[43msolve\u001b[49m\u001b[43m(\u001b[49m\u001b[43mtimestep\u001b[49m\u001b[43m=\u001b[49m\u001b[43mDT\u001b[49m\u001b[43m)\u001b[49m\n\u001b[32m 27\u001b[39m t_now += DT\n\u001b[32m 29\u001b[39m \u001b[38;5;66;03m# Check if we've passed a snapshot time\u001b[39;00m\n", + "\u001b[36mFile \u001b[39m\u001b[32m~/+Underworld/underworld3-pixi/.pixi/envs/amr-dev/lib/python3.12/site-packages/underworld3/timing.py:310\u001b[39m, in \u001b[36mroutine_timer_decorator..timed\u001b[39m\u001b[34m(*args, **kwargs)\u001b[39m\n\u001b[32m 308\u001b[39m event.begin()\n\u001b[32m 309\u001b[39m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[32m--> \u001b[39m\u001b[32m310\u001b[39m result = \u001b[43mroutine\u001b[49m\u001b[43m(\u001b[49m\u001b[43m*\u001b[49m\u001b[43margs\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43m*\u001b[49m\u001b[43m*\u001b[49m\u001b[43mkwargs\u001b[49m\u001b[43m)\u001b[49m\n\u001b[32m 311\u001b[39m \u001b[38;5;28;01mreturn\u001b[39;00m result\n\u001b[32m 312\u001b[39m \u001b[38;5;28;01mfinally\u001b[39;00m:\n", + "\u001b[36mFile \u001b[39m\u001b[32m~/+Underworld/underworld3-pixi/.pixi/envs/amr-dev/lib/python3.12/site-packages/underworld3/systems/solvers.py:825\u001b[39m, in \u001b[36mSNES_TransientDarcy.solve\u001b[39m\u001b[34m(self, zero_init_guess, timestep, _force_setup, verbose)\u001b[39m\n\u001b[32m 822\u001b[39m \u001b[38;5;28mself\u001b[39m.DFDt.update_pre_solve(timestep, verbose=verbose)\n\u001b[32m 824\u001b[39m \u001b[38;5;66;03m# Solve PDE (bypass SNES_Darcy.solve to avoid double setup/projection)\u001b[39;00m\n\u001b[32m--> \u001b[39m\u001b[32m825\u001b[39m \u001b[43mSNES_Scalar\u001b[49m\u001b[43m.\u001b[49m\u001b[43msolve\u001b[49m\u001b[43m(\u001b[49m\u001b[38;5;28;43mself\u001b[39;49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43mzero_init_guess\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43m_force_setup\u001b[49m\u001b[43m)\u001b[49m\n\u001b[32m 827\u001b[39m \u001b[38;5;66;03m# Invalidate cached data views\u001b[39;00m\n\u001b[32m 828\u001b[39m target_var = \u001b[38;5;28mgetattr\u001b[39m(\u001b[38;5;28mself\u001b[39m.u, \u001b[33m\"\u001b[39m\u001b[33m_base_var\u001b[39m\u001b[33m\"\u001b[39m, \u001b[38;5;28mself\u001b[39m.u)\n", + "\u001b[36mFile \u001b[39m\u001b[32m~/+Underworld/underworld3-pixi/.pixi/envs/amr-dev/lib/python3.12/site-packages/underworld3/timing.py:310\u001b[39m, in \u001b[36mroutine_timer_decorator..timed\u001b[39m\u001b[34m(*args, **kwargs)\u001b[39m\n\u001b[32m 308\u001b[39m event.begin()\n\u001b[32m 309\u001b[39m \u001b[38;5;28;01mtry\u001b[39;00m:\n\u001b[32m--> \u001b[39m\u001b[32m310\u001b[39m result = \u001b[43mroutine\u001b[49m\u001b[43m(\u001b[49m\u001b[43m*\u001b[49m\u001b[43margs\u001b[49m\u001b[43m,\u001b[49m\u001b[43m \u001b[49m\u001b[43m*\u001b[49m\u001b[43m*\u001b[49m\u001b[43mkwargs\u001b[49m\u001b[43m)\u001b[49m\n\u001b[32m 311\u001b[39m \u001b[38;5;28;01mreturn\u001b[39;00m result\n\u001b[32m 312\u001b[39m \u001b[38;5;28;01mfinally\u001b[39;00m:\n", + "\u001b[36mFile \u001b[39m\u001b[32msrc/underworld3/cython/petsc_generic_snes_solvers.pyx:1660\u001b[39m, in \u001b[36munderworld3.cython.generic_solvers.SNES_Scalar.solve\u001b[39m\u001b[34m()\u001b[39m\n", + "\u001b[36mFile \u001b[39m\u001b[32mpetsc4py/PETSc/SNES.pyx:1738\u001b[39m, in \u001b[36mpetsc4py.PETSc.SNES.solve\u001b[39m\u001b[34m()\u001b[39m\n", + "\u001b[31mError\u001b[39m: error code 73" + ] + } + ], + "source": [ + "# Initial condition: dry everywhere except a smooth transition\n", + "# near the top boundary to ease the first nonlinear solve.\n", + "y = mesh.X[1]\n", + "transition_width = 0.1 * COLUMN_HEIGHT\n", + "blend = sympy.Min(sympy.Max((COLUMN_HEIGHT - y) / transition_width, 0), 1)\n", + "psi_init = PSI_WET + (PSI_DRY - PSI_WET) * blend\n", + "\n", + "psi_var.array = uw.function.evaluate(psi_init, psi_var.coords)\n", + "\n", + "# IMPORTANT: the solver's time-derivative history was initialised at\n", + "# construction time (when psi_var was still zero). Re-sync it now\n", + "# so that ψ^n = the initial condition we just set.\n", + "richards.DuDt.initiate_history_fn()\n", + "\n", + "# Timestepping — collect snapshots\n", + "t_now = 0.0\n", + "snapshot_times = sorted(SNAPSHOTS)\n", + "snapshots = {} # {t: psi_data}\n", + "next_snap_idx = 0\n", + "\n", + "# Total time to run\n", + "t_end = snapshot_times[-1]\n", + "n_steps = int(np.ceil(t_end / DT))\n", + "\n", + "for step in range(n_steps):\n", + " richards.solve(timestep=DT)\n", + " t_now += DT\n", + "\n", + " # Check if we've passed a snapshot time\n", + " if next_snap_idx < len(snapshot_times) and t_now >= snapshot_times[next_snap_idx] - 1e-12:\n", + " t_snap = snapshot_times[next_snap_idx]\n", + " snapshots[t_snap] = np.array(psi_var.data)\n", + " next_snap_idx += 1\n", + "\n", + "print(f\"Completed {n_steps} steps, t = {t_now:.4f} s\")\n", + "print(f\"Snapshots saved at t = {list(snapshots.keys())}\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-13", + "metadata": {}, + "source": [ + "## Comparison with Analytical Solution\n", + "\n", + "We sample each snapshot along a vertical profile and compare\n", + "with the Ogata–Banks solution." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-14", + "metadata": { + "execution": { + "iopub.status.busy": "2026-02-24T09:22:20.237733Z", + "iopub.status.idle": "2026-02-24T09:22:20.237979Z", + "shell.execute_reply": "2026-02-24T09:22:20.237856Z", + "shell.execute_reply.started": "2026-02-24T09:22:20.237846Z" + } + }, + "outputs": [], + "source": [ + "n_sample = 200\n", + "sample_y = np.linspace(0.05, COLUMN_HEIGHT - 0.05, n_sample)\n", + "sample_x = np.full_like(sample_y, COLUMN_WIDTH / 2)\n", + "sample_pts = np.column_stack([sample_x, sample_y])\n", + "\n", + "fig, axes = plt.subplots(1, len(snapshots), figsize=(5 * len(snapshots), 6), sharey=True)\n", + "if len(snapshots) == 1:\n", + " axes = [axes]\n", + "\n", + "max_errors = []\n", + "\n", + "for ax, (t_snap, psi_snap) in zip(axes, sorted(snapshots.items())):\n", + " # Restore snapshot and evaluate\n", + " psi_var.data[...] = psi_snap\n", + " psi_numerical = uw.function.evaluate(psi_var.sym[0], sample_pts).squeeze()\n", + "\n", + " psi_analytical = gardner_transient_psi(\n", + " sample_y, t_snap,\n", + " psi_dry=PSI_DRY, psi_wet=PSI_WET,\n", + " L=COLUMN_HEIGHT, Ks=KS, alpha=ALPHA_G,\n", + " theta_r=THETA_R, theta_s=THETA_S,\n", + " )\n", + "\n", + " error = np.abs(psi_numerical - psi_analytical)\n", + " max_err = error.max()\n", + " max_errors.append(max_err)\n", + "\n", + " ax.plot(psi_analytical, sample_y, \"b-\", lw=2, label=\"Analytical\")\n", + " ax.plot(psi_numerical, sample_y, \"ro\", ms=2, label=\"Numerical\")\n", + " ax.set_xlabel(r\"$\\psi$ (m)\")\n", + " ax.set_title(f\"$t = {t_snap}$ s\\nmax err = {max_err:.3e}\")\n", + " ax.legend(fontsize=9)\n", + " ax.grid(True, alpha=0.3)\n", + "\n", + "axes[0].set_ylabel(\"Height $y$ (m)\")\n", + "fig.suptitle(\"Transient wetting front: numerical vs analytical\", fontsize=12)\n", + "fig.tight_layout()\n", + "plt.show()\n", + "\n", + "for t_snap, err in zip(sorted(snapshots.keys()), max_errors):\n", + " print(f\" t = {t_snap:.2f} s : max |error| = {err:.4e} m\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-15", + "metadata": {}, + "source": [ + "## Darcy Velocity Field\n", + "\n", + "The downward velocity should be highest at the wetting front\n", + "where the pressure gradient is steepest." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-16", + "metadata": { + "execution": { + "iopub.status.busy": "2026-02-24T09:22:20.238715Z", + "iopub.status.idle": "2026-02-24T09:22:20.239232Z", + "shell.execute_reply": "2026-02-24T09:22:20.238832Z", + "shell.execute_reply.started": "2026-02-24T09:22:20.238822Z" + } + }, + "outputs": [], + "source": [ + "# Use the final snapshot\n", + "t_final = sorted(snapshots.keys())[-1]\n", + "psi_var.data[...] = snapshots[t_final]\n", + "richards.solve(timestep=DT) # recompute velocity\n", + "\n", + "vy_numerical = uw.function.evaluate(v_soln.sym[0, 1], sample_pts).squeeze()\n", + "\n", + "# Analytical Darcy velocity: q_y = K(ψ) (∂ψ/∂y + 1)\n", + "# Compute from the analytical ψ profile\n", + "psi_anal = gardner_transient_psi(\n", + " sample_y, t_final,\n", + " psi_dry=PSI_DRY, psi_wet=PSI_WET,\n", + " L=COLUMN_HEIGHT, Ks=KS, alpha=ALPHA_G,\n", + " theta_r=THETA_R, theta_s=THETA_S,\n", + ")\n", + "K_anal = KS * np.exp(ALPHA_G * np.clip(psi_anal, None, 0))\n", + "dpsi_dy = np.gradient(psi_anal, sample_y)\n", + "vy_analytical = K_anal * (dpsi_dy + 1)\n", + "\n", + "fig, ax = plt.subplots(figsize=(5, 6))\n", + "ax.plot(vy_analytical, sample_y, \"b-\", lw=2, label=\"Analytical\")\n", + "ax.plot(vy_numerical, sample_y, \"ro\", ms=2, label=\"Numerical\")\n", + "ax.set_xlabel(r\"Vertical Darcy flux $q_y$ (m/s)\")\n", + "ax.set_ylabel(\"Height $y$ (m)\")\n", + "ax.set_title(f\"Darcy velocity at $t = {t_final}$ s\")\n", + "ax.legend()\n", + "ax.grid(True, alpha=0.3)\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-17", + "metadata": {}, + "source": [ + "## Try It Yourself\n", + "\n", + "Experiment with different parameters to build intuition:\n", + "\n", + "```python\n", + "# Larger α → sharper wetting front\n", + "ALPHA_G = 6.0\n", + "\n", + "# Smaller timestep for better accuracy (error is time-dominated)\n", + "DT = 0.002\n", + "\n", + "# Wetter initial condition\n", + "PSI_DRY = -1.0\n", + "\n", + "# Higher resolution (spatial error is already small at RES=64)\n", + "RES = 128\n", + "```\n", + "\n", + "- How does the front speed change with $\\alpha$?\n", + "- What happens when the front reaches the bottom boundary?\n", + " (The semi-infinite analytical solution breaks down.)\n", + "- Try computing total water content\n", + " $\\int_0^L \\theta(\\psi(y))\\,dy$ at each snapshot to check\n", + " mass conservation.\n", + "\n", + "## References\n", + "\n", + "Celia, M. A., Bouloutas, E. T. & Zarba, R. L. (1990). A general\n", + "mass-conservative numerical solution for the unsaturated flow equation.\n", + "*Water Resources Research*, 26(7), 1483–1496.\n", + "doi:[10.1029/WR026i007p01483](https://doi.org/10.1029/WR026i007p01483)\n", + "\n", + "Gardner, W. R. (1958). Some steady-state solutions of the unsaturated\n", + "moisture flow equation with application to evaporation from a water table.\n", + "*Soil Science*, 85(4), 228–232.\n", + "\n", + "Ogata, A. & Banks, R. B. (1961). A solution of the differential\n", + "equation of longitudinal dispersion in porous media.\n", + "*US Geological Survey Professional Paper* 411-A.\n", + "\n", + "Richards, L. A. (1931). Capillary conduction of liquids through porous\n", + "mediums. *Physics*, 1(5), 318–333.\n", + "doi:[10.1063/1.1745010](https://doi.org/10.1063/1.1745010)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cell-18", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.12.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/beginner/tutorials/Notebook_Index.ipynb b/docs/beginner/tutorials/Notebook_Index.ipynb index 71c47d216..07b04e3c3 100644 --- a/docs/beginner/tutorials/Notebook_Index.ipynb +++ b/docs/beginner/tutorials/Notebook_Index.ipynb @@ -22,7 +22,7 @@ }, "tags": [] }, - "source": "#### Notebook 1 - Meshes\n\n\ud83d\udd17 [**Meshes**](1-Meshes.ipynb): Introduces the mesh discretisation that we use in `Underworld3` and how you can build one of the pre-defined meshes. This notebook also show you how to use the `pyvista` visualisation tools for `Underworld3` objects. The mesh holds information on the mesh geometry, boundaries and coordinate systems." + "source": "#### Notebook 1 - Meshes\n\n🔗 [**Meshes**](1-Meshes.ipynb): Introduces the mesh discretisation that we use in `Underworld3` and how you can build one of the pre-defined meshes. This notebook also show you how to use the `pyvista` visualisation tools for `Underworld3` objects. The mesh holds information on the mesh geometry, boundaries and coordinate systems." }, { "cell_type": "markdown", @@ -34,67 +34,43 @@ }, "tags": [] }, - "source": "#### Notebook 2 - Mesh Variables\n\n\ud83d\udd17 [**Variables**](2-Variables.ipynb): Introduces the concept of `MeshVariables` in `Underworld3`. These are both data containers and `sympy` symbolic objects. We show you how to inspect a `meshVariable`, set the data values in the `MeshVariable` and visualise them." + "source": "#### Notebook 2 - Mesh Variables\n\n🔗 [**Variables**](2-Variables.ipynb): Introduces the concept of `MeshVariables` in `Underworld3`. These are both data containers and `sympy` symbolic objects. We show you how to inspect a `meshVariable`, set the data values in the `MeshVariable` and visualise them." }, { "cell_type": "markdown", "id": "26b5da69-1292-4988-93e4-591bcf607b32", "metadata": {}, - "source": "#### Notebook 3 - Symbols and sympy\n\n\ud83d\udd17 [**Symbols**](3-Symbolic_Forms.ipynb): `meshVariables` are `sympy` objects that can be composed with other symbolic objects and evaluated numerically when required. They can also be differentiated. Most importantly, `sympy` can manipulate expressions, simplify them and cancel terms." + "source": "#### Notebook 3 - Symbols and sympy\n\n🔗 [**Symbols**](3-Symbolic_Forms.ipynb): `meshVariables` are `sympy` objects that can be composed with other symbolic objects and evaluated numerically when required. They can also be differentiated. Most importantly, `sympy` can manipulate expressions, simplify them and cancel terms." }, { "cell_type": "markdown", "id": "6d091b23-709b-435c-ade1-9822ad09d567", "metadata": {}, - "source": "#### Notebook 4 - Example: Diffusion Equation\n\n\ud83d\udd17 [**Diffusion Solver**](4-Solvers-i-Poisson.ipynb): Introduces the various solver templates that are available in Underworld, starting with a steady-state diffusion problem. The template requires you to set some constitutive properties and define the unknowns. These are handled through subsitution into symbolic forms and the template equation can be inspected before you need to supply concrete expressions.\n\n#### Notebook 5 - Poisson Equation Validation\n\n\ud83d\udd17 [**Poisson Validation**](5-Solvers-i-Poisson-Validation.ipynb): Demonstrates how to validate the Poisson solver against known analytical solutions. We solve three progressively complex diffusion problems with linear, quadratic, and sinusoidal profiles, comparing numerical results against exact solutions." + "source": "#### Notebook 4 - Example: Diffusion Equation\n\n🔗 [**Diffusion Solver**](4-Solvers-i-Poisson.ipynb): Introduces the various solver templates that are available in Underworld, starting with a steady-state diffusion problem. The template requires you to set some constitutive properties and define the unknowns. These are handled through subsitution into symbolic forms and the template equation can be inspected before you need to supply concrete expressions.\n\n#### Notebook 5 - Poisson Equation Validation\n\n🔗 [**Poisson Validation**](5-Solvers-i-Poisson-Validation.ipynb): Demonstrates how to validate the Poisson solver against known analytical solutions. We solve three progressively complex diffusion problems with linear, quadratic, and sinusoidal profiles, comparing numerical results against exact solutions." }, { "cell_type": "markdown", "id": "eff58d66-8892-4af6-92b0-c18ee9cd2ad2", "metadata": {}, - "source": "#### Notebook 6 - Example: Stokes Equation\n\n\ud83d\udd17 [**Stokes Solver**](6-Solvers-ii-Stokes.ipynb): Stokes equation is a more complicated system of equations to solve. This complexity is mostly hidden when you set the problem up. There are some interesting ways to constrain boundary values which are demonstrated using an annulus mesh (curved, free-slip boundaries) and a $\\delta$ function buoyancy source." + "source": "#### Notebook 6 - Example: Stokes Equation\n\n🔗 [**Stokes Solver**](6-Solvers-ii-Stokes.ipynb): Stokes equation is a more complicated system of equations to solve. This complexity is mostly hidden when you set the problem up. There are some interesting ways to constrain boundary values which are demonstrated using an annulus mesh (curved, free-slip boundaries) and a $\\delta$ function buoyancy source." }, { "cell_type": "markdown", "id": "0a400f96-5908-4b60-ba0d-ef188c5a66cd", "metadata": {}, - "source": "#### Notebook 7 - Example: Time Dependence\n\n\ud83d\udd17 [**Timestepping**](7-Timestepping-simple.ipynb): Simple advection-diffusion problem. A step (or a top-hat function) moves left to right with constant velocity and diffusion occurs at the same time. This has an analytic solution so we can see the effect of changing timesteps and grid resolution very easily.\n\n#### Notebook 8 - Coupled Timestepping \n\n\ud83d\udd17 [**Coupled Timestepping**](8-Timestepping-coupled.ipynb): *Coupled* Stokes flow plus thermal advection-diffusion gives a simple convection solver. The timestepping loop is written by hand because usually you will want to do some analysis or output some checkpoints." + "source": "#### Notebook 7 - Example: Time Dependence\n\n🔗 [**Timestepping**](7-Timestepping-simple.ipynb): Simple advection-diffusion problem. A step (or a top-hat function) moves left to right with constant velocity and diffusion occurs at the same time. This has an analytic solution so we can see the effect of changing timesteps and grid resolution very easily.\n\n#### Notebook 8 - Coupled Timestepping \n\n🔗 [**Coupled Timestepping**](8-Timestepping-coupled.ipynb): *Coupled* Stokes flow plus thermal advection-diffusion gives a simple convection solver. The timestepping loop is written by hand because usually you will want to do some analysis or output some checkpoints." }, { "cell_type": "markdown", "id": "3b784a04-823e-4a88-9b18-a57f763dbf56", "metadata": {}, - "source": "#### Notebook 9 - Example: Navier-Stokes Equation\n\n\ud83d\udd17 [**Unsteady flow**](9-Unsteady_Flow.ipynb): Using a passive swarm to track the pattern of flow developing in a pipe after an impulsive application of a boundary condition at the inflow. Particles need to be added to the passive swarm close to the inflow at each timestep." + "source": "#### Notebook 9 - Example: Navier-Stokes Equation\n\n🔗 [**Unsteady flow**](9-Unsteady_Flow.ipynb): Using a passive swarm to track the pattern of flow developing in a pipe after an impulsive application of a boundary condition at the inflow. Particles need to be added to the passive swarm close to the inflow at each timestep." }, { "cell_type": "markdown", "id": "42f43817-0e0b-458b-9c5b-34799fb60487", "metadata": {}, - "source": [ - "#### Notebook 10 - Lagrangian Swarm Variables\n", - "\n", - "\ud83d\udd17 [**Swarm Variables**](10-Particle_Swarms.ipynb): Exploring how they work for specifying material properties with a swarm used to determine element viscosity. We learn how to use swarm variables in expressions generally and for boundary conditions.\n", - "\n", - "#### Notebook 11 - Multi-Material Constitutive Models\n", - "\n", - "\ud83d\udd17 [**Multi-Material SolCx**](11-Multi-Material_SolCx.ipynb): Demonstrates the multi-material constitutive model system by recreating the classic SolCx benchmark using IndexSwarmVariable to track different materials. Shows level-set weighted flux averaging and validation against piecewise viscosity solutions.\n", - "\n", - "#### Notebook 12 - Working with Physical Units\n", - "\n", - "\ud83d\udd17 [**Units System**](12-Units_System.ipynb): Introduces physical units in Underworld3 using the Pint library. Shows how to create physical quantities (temperatures, velocities, viscosities), convert between units, work with unit-aware arrays and coordinates, and leverage automatic unit tracking through derivatives.\n", - "\n", - "#### Notebook 13 - Non-Dimensional Scaling\n", - "\n", - "\ud83d\udd17 [**Non-Dimensional Scaling**](13-Scaling-problems-with-physical-units.ipynb): Demonstrates the non-dimensional scaling system for better numerical conditioning. Shows how to set reference quantities, solve Poisson and Stokes equations with automatic ND scaling, and validate that dimensional and non-dimensional solutions match perfectly.\n", - "\n", - "#### Notebook 14 - Time-Dependent Advection-Diffusion\n", - "\n", - "\ud83d\udd17 [**Timestepping with Units**](14-Timestepping-with-physical-units.ipynb): Time-dependent advection-diffusion with physical units. Tests numerical solutions against analytical solutions for advection and diffusion of temperature steps.\n", - "\n", - "#### Notebook 15 - Thermal Convection\n", - "\n", - "\ud83d\udd17 [**Rayleigh-B\u00e9nard Convection**](15-Thermal-convection-with-units.ipynb): Complete thermal convection example with physical units. Demonstrates coupled Stokes-temperature systems with buoyancy forcing in an annulus geometry, Rayleigh number computation, and time-stepping visualization." - ] + "source": "#### Notebook 10 - Lagrangian Swarm Variables\n\n🔗 [**Swarm Variables**](10-Particle_Swarms.ipynb): Exploring how they work for specifying material properties with a swarm used to determine element viscosity. We learn how to use swarm variables in expressions generally and for boundary conditions.\n\n#### Notebook 11 - Multi-Material Constitutive Models\n\n🔗 [**Multi-Material SolCx**](11-Multi-Material_SolCx.ipynb): Demonstrates the multi-material constitutive model system by recreating the classic SolCx benchmark using IndexSwarmVariable to track different materials. Shows level-set weighted flux averaging and validation against piecewise viscosity solutions.\n\n#### Notebook 12 - Working with Physical Units\n\n🔗 [**Units System**](12-Units_System.ipynb): Introduces physical units in Underworld3 using the Pint library. Shows how to create physical quantities (temperatures, velocities, viscosities), convert between units, work with unit-aware arrays and coordinates, and leverage automatic unit tracking through derivatives.\n\n#### Notebook 13 - Non-Dimensional Scaling\n\n🔗 [**Non-Dimensional Scaling**](13-Scaling-problems-with-physical-units.ipynb): Demonstrates the non-dimensional scaling system for better numerical conditioning. Shows how to set reference quantities, solve Poisson and Stokes equations with automatic ND scaling, and validate that dimensional and non-dimensional solutions match perfectly.\n\n#### Notebook 14 - Time-Dependent Advection-Diffusion\n\n🔗 [**Timestepping with Units**](14-Timestepping-with-physical-units.ipynb): Time-dependent advection-diffusion with physical units. Tests numerical solutions against analytical solutions for advection and diffusion of temperature steps.\n\n#### Notebook 15 - Thermal Convection\n\n🔗 [**Rayleigh-Bénard Convection**](15-Thermal-convection-with-units.ipynb): Complete thermal convection example with physical units. Demonstrates coupled Stokes-temperature systems with buoyancy forcing in an annulus geometry, Rayleigh number computation, and time-stepping visualization.\n\n#### Notebook 16 - Richards Equation (Groundwater)\n\n🔗 [**Richards Equation**](16-Richards-Equation-Groundwater.ipynb): Introduces the Richards equation for variably-saturated porous media flow. Solves a steady-state drainage problem in a vertical soil column using the Gardner exponential conductivity model and validates against an exact analytical solution.\n\n#### Notebook 17 - Richards Equation — Transient Wetting Front\n\n🔗 [**Transient Wetting Front**](17-Richards-Transient-Wetting-Front.ipynb): Solves a transient Richards equation problem where a wetting front propagates downward through a dry soil column. Validates the numerical solution against the Ogata–Banks analytical benchmark for the Gardner model." }, { "cell_type": "markdown", diff --git a/docs/developer/CHANGELOG.md b/docs/developer/CHANGELOG.md index b846305c7..165571d7d 100644 --- a/docs/developer/CHANGELOG.md +++ b/docs/developer/CHANGELOG.md @@ -4,6 +4,258 @@ This log tracks significant development work at a conceptual level, suitable for --- +## 2026 Q3 (July – September) + +### July 2026 Quality Campaign — Audit, Style Charter, Remediation Waves (July 2026) + +**A systematic post-development-burst quality campaign**: six adversarially +verified review dimensions (loose ends, API consistency, readability, swarm +subsystem, docs coherence, branch triage) produced a ranked remediation +worklist, and the first waves have landed (#317, #322, #325, #326, #309–#312, +#334). + +- **UW3 Style Charter** adopted as the normative coding contract for every + development session, human or AI (`docs/developer/UW3_STYLE_CHARTER.md`), + with the campaign's review documents under `docs/reviews/2026-07/` (#317). + The maintainer's 18 decision rulings are recorded in the worklist (#322). +- **Track-0 bug fixes**: RBF derivative path now uses the requested component + rather than component 0 (#312); `CellWiseIntegral` evaluates on the mesh DS + instead of a mis-cloned P1 DM (#311); Lagrangian history writes use the + modern data layout (#310); honest 2D-only guard on the MMPDE mover (#309); + and a medium-severity batch — parallel `BoxInternalBoundary`, SL theta + restore, viewer crash, projection double-count, units-boundary honesty (#326). +- **Wave A deletions**: the `persistence.py` stub module removed + (behaviour-neutral), the unsupported coordinate-units feature family pruned + with the mesh `units=` kwarg deprecated, and the design-directory experiment + artifacts re-filed under `design/experiments/` (#325). +- **Wave C API harmonization**: the newer BC methods (`add_nitsche_bc`, + `add_rotated_freeslip_bc`, `add_constraint_bc`) migrated to the canonical + value-first signature `(conds, boundary, ...)` with one-warning deprecation + shims for the legacy boundary-first / `g=` spellings; `conds` is the single + BC datum name across all BC methods (#334). + +### Rotated Strong Free-Slip, Boundary Traction and Dynamic Topography (July 2026) + +**New `solver.add_rotated_freeslip_bc(...)`** imposes free-slip +(v·n̂ = 0) to machine precision by rotating boundary velocity DOFs into a +per-node (normal, tangential) frame and constraining the rotated normal +component exactly — correct on curved, tilted, and deformed boundaries (#293). + +- The constraint **reaction** is the consistent boundary normal traction + σ_nn: `boundary_normal_traction()` / `dynamic_topography()` recover surface + topography with no augmented-Lagrangian splitting (#293), and the rotated + path now runs **inside the nonlinear SNES**, with a numerical nonlinearity + probe that fails fast instead of silently returning a single linearisation + for a nonlinear rheology (#298). +- Schur-preconditioning parity: native 1/μ-mass USER Schur preconditioner, + 3D rotation nullspace, and an SVD-robust FMG coarse solve took the Zhong + spherical benchmark from 44 outer iterations to 1 (#306). +- 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). +- 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). +- **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 + message recommends — is accepted for all numeric zero forms (#336, #339). + The Wave C deprecation shim messages now correctly name the legacy form + the caller actually used (#339). + +### Generalized Geometric Multigrid via Custom Prolongation (July 2026) + +**Geometric multigrid decoupled from the nested `refine()` hierarchy**: custom +(barycentric or RBF) prolongation operators drive PCMG across independent — +possibly non-nested — coarse/fine mesh pairs, with coarse operators assembled +by Galerkin RAP (#290). + +- Ties native FMG iteration-for-iteration on nested hierarchies while + supporting graded and adapted meshes that native FMG cannot nest. +- Native geometric FMG is locked out for single-field solvers where its DM + assumptions are invalid (#297). + +### Consistent Jacobian Tangent — Opt-In Newton (July 2026) + +**New opt-in `solver.consistent_jacobian`** (default `False` keeps the +bit-identical Picard tangent) fixes the unwrap-before-differentiate order so +the assembled Jacobian sees the strain-rate dependence of nonlinear +viscosities, plus a `"continuation"` mode that stages Picard → Newton for +robustness far from the solution (#258). + +### Swarm Correctness: Stale Caches and Parallel Checkpoint Restore (July 2026) + +**Swarm data-pipeline hardening across serial and parallel paths.** + +- Three stale-cache bugs after swarm particle addition fixed (#216), followed + by the campaign's Track-0 batch: cache invalidation ahead of the migrate() + early return, migration-suppression semantics, empty-rank KDTree guards, + and a pre-solve proxy refresh (#313). +- Parallel `read_timestep` restores each particle exactly once via a + rank-0-routed read (#329); reduction return types aligned to the + MeshVariable per-component contract, `NodalPointSwarm` deprecated, and the + never-functional `recycle_rate > 1` machinery excised (#323). + +### NumPy 2 and Environment Support (July 2026) + +**NumPy ≥ 2.0 supported** (#301), with a follow-up fix for 2-D `np.cross` on +`UnitAwareArray` under numpy 2 (#305). The repository now ships its Claude +Code skills for AI-assisted development sessions (#299). `UWQuantity` handles +offset temperature units (degC/degF) correctly (#295), and boundary rebuilds +avoid a PETSc IS size query that could abort on empty strata (#288). + +--- + +## 2026 Q2 (April – June) + +### Mesh Adaptation: Metric-Driven Movers and MMPDE Robustness (May – June 2026) + +**A family of metric-driven mesh-adaptation movers** landed and hardened: +`smooth_mesh_interior` (Winslow Jacobi smoother, #190), `follow_metric` with +optimal-transport movers and `mesh.OT_adapt()` (#209), and anisotropic movers +with mesh-owned boundary tangent-slip and MPI robustness (#228). + +- Parallel adaptive "seam-spike" fixed: mover heap corruption, point-locator + hardening, and a redesigned remesh field transfer (#213). +- MMPDE metric hardening: SPD floor stops silent NaN-bail on deformed meshes + (#259); monotone RBF metric bake from nodal values (#266). +- Deformed-mesh correctness: boundary normals and domain-membership tests + track mesh deformation (#264); `on_boundary` acceptance for on-face point + queries (#207); gmsh `spacedim` no longer leaks across imports (#238). + +### Moving-Mesh Field Transfer and deform() (June 2026) + +**Mesh coordinate mutation made foolproof**: a capability gate plus the +public `mesh.deform()` entry point, with semi-Lagrangian CARRY field transfer +across deformation (#246, locked by regression test in #249). + +- Old-frame semi-Lagrangian reach-back for moving meshes (SLCN / SL-BDF2) + traces advected histories in the pre-deformation frame (#251). +- Evaluate / DMInterp / topology caches are invalidated on mesh deformation + (#188). + +### Semi-Lagrangian Accuracy and Timestep Controls (May 2026) + +**Fixed the semi-Lagrangian trace-back FE overshoot and added a monotone +limiter** to the DDt schemes (#186), exposed as `monotone_mode` on +`AdvDiffusionSLCN` (#189) and promoted to a universal evaluator flag (#208). + +- `theta` exposed on the SemiLagrangian DDt for backward-Euler / + Crank-Nicolson selection (#187). +- Tensor evaluate path in `_project_to_work_variable` (#185); NavierStokes + SLCN projection shape mismatch fixed (#183); vector DMInterpolation + overshoot at cell-shared boundaries fixed (#164). +- `estimate_dt` gained opt-in median/percentile cell reduction for + sliver-robust timesteps (#220). + +### Snapshot and Checkpoint Toolkit (May 2026) + +**A snapshot toolkit — "git stash for timesteps"**: in-memory snapshots +(#195), `Model.tracker` for snapshot-managed run state (#196), and an on-disk +snapshot format v1.1 with a metadata wrapper around PETSc bulk data (#198, +docs in #199). PETSc DMPlex checkpoint reload for mesh variables landed as +the underlying primitive (#146, T. Gollapalli). + +### Stokes_Constrained: Multiplier Free-Slip and Parallel Correctness (June 2026) + +**In-saddle Lagrange-multiplier free-slip with surface topography recovery** +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). + +### Boundary Conditions: Local-h Nitsche and Boundary-Slip Surfaces (June 2026) + +**Nitsche penalty scaled by local per-cell mesh size** rather than the global +minimum radius, restoring correct stiffness on graded and adapted meshes +(#275). + +- `mesh.boundary_slip` API with `BoundingSurface` objects for boundary + tangent-slip (#225); `Surface.influence_function` respects finite edges + (#241). + +### Units System: Quantity Interoperability and the ND Boundary Contract (June 2026) + +**UWQuantity operands now work across the API surface**: MeshVariable +arithmetic (#283), the Stokes bodyforce setter (#284), and units-active +semi-Lagrangian trace-back (#277). + +- The non-dimensional ↔ units boundary contract is documented as a design + contract (#278). +- Tutorials and examples repaired for strict units: thermal convection + (#263), dimensionality demo (#261), unit-aware coordinate evaluation (#262). + +### Memory, Evaluation and Solver Infrastructure (May – June 2026) + +**Comprehensive memory-leak fixes** in solver setup, interpolation caching, +and SubDM synchronisation (#178), Cython deallocation and callback hardening +(#181), and a `memprobe` diagnostic module (#179); cached spatial indexing +(KDTree) consolidated (#182). + +- `global_evaluate`: faithful parallel `evaluate()` fixing out-of-domain + mislocation (#222); swarm particle loss across rank boundaries during + advection fixed (#177); empty-partition reshape crash in parallel + `read_timestep` fixed (#221). +- Manifold-mesh PDE support with `Mesh.extract_surface` for solving on + embedded surfaces (#237). +- Exponential time-differencing VE/VEP integrators, ETD-1 default (#161); + per-iteration SNES update callbacks with pressure gauge and + boundary-correct scatter (#250); SolCx analytic solution ported as + `uw.function.analytic.SolCx` with its exact stress tensor (#223, #226); + projection gained unit-aware `smoothing_length` (#234) and an opt-in + `linear_solver()` (#281). +- XDMF output moved to PETSc-native topology with explicit cell-to-vertex + connectivity for ParaView (#218, #205); Gadi Singularity container build + files (#133); maturity-gated release tooling `./uw dev` (#233); `-uw_*` + CLI overrides applied on all platforms (#280). + +### DDt.set_initial_history — Public API for BDF Restart (April 2026) + +**New `set_initial_history(values, dt=...)` method on `SemiLagrangian` and +`Eulerian` DDt classes** to plant BDF history at the start of a run. +Two use cases: + +- **Analytical IC for benchmarks** — populate ψ* from a known closed-form + solution so the very first solve runs at full BDF order with no startup + transient. The `bench_ve_harmonic.py` peak-start benchmark used the manual + pattern (poking four private attributes including `psi_star[k].array`, + `_n_solves_completed`, `_dt_history`); the new API wraps that cleanly. +- **Checkpoint restart** — resume a multistep history from disk without + re-ramping `effective_order` from BDF-1 over the first `order` steps. + +Sets `psi_star[0..order-1].array`, marks history initialised, +seeds `_dt_history` for variable-dt BDF coefficients, and warns when +`order >= 2` is called without `dt`. Six unit tests cover bookkeeping, +scalar broadcast, length validation, and the warning path. + +**Files**: `src/underworld3/systems/ddt.py`, +`docs/advanced/benchmarks/bench_ve_harmonic.py`, +`tests/test_1052_ddt_set_initial_history.py`, +`docs/api/systems_ddt.md`. + +### Multi-Component Projection Solver (April 2026) + +**New `SNES_MultiComponent_Projection` solver** that projects N scalar components in a single PETSc SNES solve sharing one DM, replacing the per-component cycling in `SNES_Tensor_Projection` (which tore down and rebuilt the DM on each inner iteration). The underlying `SNES_MultiComponent` Cython base decouples the FE component count from `mesh.dim` — PETSc's pointwise callback interface accepts any DOF count per node; the new class exposes that directly. + +- Wired into `SNES_VE_Stokes` via `_setup_tau_projection` for the symmetric-tensor tau projection (Nc=3 in 2D, Nc=6 in 3D). User-facing tau variable remains a `SYM_TENSOR` so downstream `.array[:, i, j]` reads are unchanged; a flat `(1, Nc)` MATRIX drives the actual solve and results fan out after each solve. +- DM build count scales with outer solves rather than `Nc × outer_solves` — the dominant cost in `SNES_Tensor_Projection` on the VE square-wave benchmark. +- 10 validation tests: `Nc=1` agrees with `SNES_Projection`, `Nc=3` symmetric-tensor agrees with `SNES_Tensor_Projection`, `Nc=4` full-tensor agreement, DM-rebuild count invariant, smoothing-parametrised agreement (1e-4, 1e-2, 1.0). + +**Files**: `cython/petsc_generic_snes_solvers.pyx` (new `SNES_MultiComponent` class, VE tau wiring), `systems/solvers.py` (new `SNES_MultiComponent_Projection`), `systems/__init__.py` (export), `tests/test_multicomponent_projection.py`. + +### PETSc Pointwise Jacobian Layout Fix (April 2026) + +**Documented PETSc's `[fc, gc, df, dg]` flat-index convention** for pointwise Jacobian arrays and fixed a latent layout bug in `SNES_Vector`. The `SNES_Vector` permutations `(0, 3, 1, 2)` for g3 and `(2, 1, 0)` for g1/g2 did not match PETSc's element-assembly index order (fe.c:2639–2790) — the bug was hidden by the trial-side symmetry of every in-repo consumer's F1 (strain-rate-based smoothing, deviatoric Stokes stress, divergence penalty). + +- Migrated `SNES_Vector._setup_pointwise_functions` (main residual and natural-BC Jacobian paths) from `derive_by_array` + `permutedims` to explicit nested-loop construction that writes directly into PETSc's expected row-major 2D layout. Same pattern as the new `SNES_MultiComponent`. +- Regression test with `F1 = smoothing * Unknowns.L` (raw gradient, not symmetrised) guards against the layout bug returning: at `smoothing > 0`, identical targets must give identical components, and results must match `SNES_MultiComponent_Projection` to rel-L2 ≤ 1e-8. +- Audit of other solvers: `SNES_Scalar` trivially correct (Nc=1); `SNES_Stokes_SaddlePt` and `SNES_NavierStokes` already use the correct `(0, 2, 1, 3)` permutation. +- New developer documentation: `docs/developer/subsystems/petsc-jacobian-layout.md` captures the convention, the sympy-to-PETSc axis mapping, and a checklist for new solvers (identical-targets + non-zero-smoothing validation tests required). + +**Files**: `cython/petsc_generic_snes_solvers.pyx` (`SNES_Vector` migration), `tests/test_snes_vector_asymmetric_jacobian.py`, `docs/developer/subsystems/petsc-jacobian-layout.md`, `docs/developer/index.md` (toctree). + +--- + ## 2026 Q1 (January – March) ### v3.0.0 Release (March 2026) diff --git a/docs/developer/design/CONSTRAINED_FREESLIP_MULTIPLIER.md b/docs/developer/design/CONSTRAINED_FREESLIP_MULTIPLIER.md new file mode 100644 index 000000000..4a9127c0e --- /dev/null +++ b/docs/developer/design/CONSTRAINED_FREESLIP_MULTIPLIER.md @@ -0,0 +1,472 @@ +# Constrained free-slip via a recoverable Lagrange multiplier (dynamic topography) + +**Status**: shipped as `uw.systems.Stokes_Constrained` (serial). The constraint +is enforced by a multiplier carried **inside** the saddle point (one coupled +solve); the converged boundary multiplier is the normal traction = dynamic +topography. An earlier augmented-Lagrangian **outer-loop** variant was removed in +favour of this in-saddle formulation (it is straightforward to reproduce in +Python if needed). Validated against the exact SolCx analytic solution +(`tests/test_1062_constrained_solcx.py`). + +## Motivation + +Free-slip / no-normal-flow on curved (annulus, spherical) boundaries is +currently enforced with **penalty-like** methods — a penalty natural BC +(`add_natural_bc(penalty · Γ·v · Γ, ...)`) or Nitsche. These are fragile: the +penalty magnitude must be tuned against the Rayleigh number and viscosity. Too +weak and a coherent radial throughflow appears (an under-scaled `1e4` natural BC +is ~100× too weak at Ra=1e6); too strong and the system ill-conditions and the +Stokes solve diverges in line search. + +This feature enforces `u·n = g` on a curved boundary with a **true Lagrange +multiplier** `λ` instead of a penalty. Because the converged multiplier *is* the +normal traction holding the boundary, it is simultaneously a direct estimate of +**dynamic surface topography**, `h = λ / (Δρ g)`. The equilibrium `λ` is also the +target end-state toward which a free surface can be integrated over a time +interval (connecting to the ETD free-surface work on +`feature/exp-integrator-freesurface`). + +## Formulation + +Stokes with a surface constraint `u·n = g` on Γ, multiplier `λ`: + +``` +[ A Bᵀ Cᵀ ] [u] [f] +[ B 0 0 ] [p] = [0] A = viscous, B = div, C = ∫_Γ (n·v) ψ +[ C 0 0 ] [λ] [g] (C couples only the boundary trace of u) +``` + +`C` is **co-dimension-1**: it touches only velocity DOFs on Γ. The **shipped** +solver carries `λ` as a third field **inside** the saddle point and solves the +whole 3×3 system in one coupled solve (the `[p, λ]` rows are grouped into a +single Schur factor — see the "Monolithic `P'=[p,λ]` fieldsplit" section). The +multiplier carries the *exact* constraint; there is **no outer loop**. + +### Augmented-Lagrangian stabilisation `r` + +The u-row carries `λ` plus an augmented-Lagrangian penalty: + +$$\mathbf{t} = \bigl[\lambda + r\,(\mathbf{u}\cdot\mathbf{n} - g)\bigr]\,\mathbf{n} +\quad\text{on } \Gamma,$$ + +which adds a `uu` boundary stiffness `r(n⊗n)` that conditions the `[p, λ]` Schur +complement **without biasing the multiplier** (the λ-row stays the exact +constraint). It is *not* an outer multiplier update — `λ` is solved +monolithically. Because accuracy is independent of `r`, `r` is a cost-only knob: +larger values reduce the iteration count up to a broad plateau, well below the +roundoff limit. Default `r = augmentation_base · μ(x)` with +`augmentation_base = 1e4` (viscosity-weighted, mesh-independent). + +> **Historical note.** An earlier *outer-loop* (Uzawa / ALG2) variant updated +> `λ ← λ + r(u·n − g)` between Stokes solves. It was superseded by — and removed +> in favour of — the in-saddle formulation above. The Phase-0 spike findings +> below motivated that exploration and are kept for context. + +## Phase-0 spike findings (what shaped the design) + +Spikes (`/tmp/s3_*.py`) on a 2D annulus (no-slip inner boundary to remove the +rigid-rotation null space, multiplier free-slip on the outer boundary): + +- **Plain Uzawa works but is slow.** A damped-Richardson update + `λ ← λ + ρ(u·n)` converges and matches the penalty solution, but a single + scalar `ρ` cannot kill both the fast and slow boundary-Schur modes — the + residual contracts the dominant mode in ~5 iterations then crawls. +- **`ρ ∝ μ`, NOT `ρ ∝ μ/h`.** The optimal Richardson step is `ρ ≈ C·μ` with `C` + a geometry constant, **independent of mesh resolution** (`ρ=8μ` converged in 5 + iterations at cellSize 0.1/0.05/0.025). The naive `μ/h` scaling over-steps on + refinement and stalls. +- **CG is the wrong accelerator.** CG on `S_λ` diverged: each matvec is an + *inexact* iterative Stokes solve (plus pressure-null-space noise), and the + nodal Euclidean inner product is not the one in which `S_λ` is SPD. Krylov + acceleration needs an exact symmetric operator; this is neither. +- **Augmented Lagrangian is the right accelerator** (per L. Moresi). It converges + in **2 iterations** where plain Uzawa took 21, reusing the existing penalty BC. + This is the implemented algorithm. + +## Implementation + +`SNES_Stokes_Constrained(SNES_Stokes)` in `src/underworld3/systems/solvers.py`, +exported as `uw.systems.Stokes_Constrained`. **Purely additive** — the validated +2×2 saddle-point assembly and fieldsplit configuration are untouched, honouring +"solver stability is paramount". + +```python +stokes = uw.systems.Stokes_Constrained(mesh, velocityField=v, pressureField=p) +stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel +stokes.constitutive_model.Parameters.shear_viscosity_0 = mu +stokes.bodyforce = buoyancy * unit_r +stokes.add_dirichlet_bc((0.0, 0.0), "Lower") # no-slip inner + +lam = stokes.add_constraint_bc("Upper", g=0.0) # free-slip outer (Gamma_P1) +stokes.solve() # no constraint tuning needed + +topo = stokes.topography("Upper", buoyancy_scale=delta_rho_g) # h = lambda/(drho g) +``` + +`solve()` does **one coupled solve** — no outer iteration or constraint tuning. +The augmentation defaults to `1e4·μ(x)` (local-viscosity-weighted); accuracy is +independent of it (the λ-row carries the exact constraint), so no per-problem +tuning is needed. + +Key design points: + +- **Multiplier representation.** `λ` is a full-mesh scalar field at the *velocity + degree* (P2). Only its trace on Γ enters the weak form. Matching the velocity + degree means the multiplier reaches every velocity normal-trace DOF (including + P2 mid-edge), so there is no constraint floor. +- **Boundary-only reduction → clean topography.** The interior (off-boundary) λ + DOFs are constrained directly in the PetscSection, so the solved `[p, λ]` block + carries only the boundary trace (~√ndof DOFs, ~1.1× Dirichlet rather than ~3×). + Interior `λ` is absent, so the boundary `λ` is a directly usable topography + field. The reduction is lossless (machine-precision constraint) and default-on. +- **Coupling registered once.** The boundary residual/Jacobian + (`λ·n`, the AL stiffness `r(n⊗n)`, and the `uλ`/`λu` couplings) are registered + a single time; nothing recompiles between solves. +- **`add_constraint_bc(boundary, g=0, normal=None, augmentation=None)`** — + `normal` defaults to the smooth projected normals `mesh.Gamma_P1`; + `augmentation` defaults to a viscosity-scaled `r = 10⁴·μ`. + +## Validation + +Two regression tests cover the shipped solver: + +- `tests/test_1061_constrained_freeslip.py` — box (vs an exact Dirichlet + free-slip reference) and buoyancy-driven annulus (vs a `1e6` penalty + reference): constraint enforced (`RMS(u·n)` small with no penalty coefficient), + velocity matches the reference, and `corr(λ, −n·σ·n) ≈ 0.9999` (topography). +- `tests/test_1062_constrained_solcx.py` — free-slip via four in-saddle + multipliers on the **SolCx** benchmark (1e6 viscosity jump) compared to the + **exact analytic** solution: velocity `rel ≈ 8.7e-6` (== the Dirichlet + baseline), constraint `RMS(u·n) ≈ 1.6e-10`. + +The consistent-boundary-flux identity `λ = −n·σ·n|_Γ` is the independent +cross-check: the multiplier's boundary trace equals the recovered normal Cauchy +stress (negative sign = the reaction traction holding the boundary), confirming +`λ` is the dynamic topography signal. + +## The augmentation parameter `r`: true-work trade-off + +`r` is a *speed* knob, not an *accuracy* knob — this is the key advantage over a +pure penalty, and it carries over to the in-saddle solver (accuracy is +`r`-independent; `r` only sets the iteration count). The sweep table below is from +the **historical outer-loop** variant (its "outer iterations" have no analogue in +the one-shot coupled solve), but the shape and the conclusion stand. For the +in-saddle solver with a scalable (FMG) inner solve the iteration count falls +monotonically with `r` to a saturation floor, with no high-`r` penalty until +roundoff; the default `r = 10⁴·μ` sits comfortably in that regime. + +Historical outer-loop sweep on the annulus (constraint tol 1e-4, wall time for one +cold solve; `tot_lin` = total outer Schur-KSP linear iterations across the loop): + +cellSize 0.1 (~2940 dof): + +| `r` | outer its | tot_lin | wall (s) | relL2 vs penalty | +|---:|---:|---:|---:|---:| +| 10 | 26 | 26 | 3.77 | 3.1e-3 | +| 100 | 4 | 4 | 0.61 | 3.2e-3 | +| 300 | 3 | 3 | 0.47 | 3.1e-3 | +| 1,000 | 3 | 3 | 0.53 | 3.1e-3 | +| 3,000 | 2 | 2 | **0.38** | 3.1e-3 | +| 10,000 | 2 | 2 | 0.51 | 2.9e-3 | +| 100,000 | 2 | 5 | 1.83 | 1.6e-3 | + +cellSize 0.05 (~10852 dof) shows the same shape (min wall ≈ 1.25 s at r=1e3; +6.98 s at r=10; 7.07 s at r=1e5). + +- **Outer iterations fall with `r`** (`26 → 4 → 3 → 2`) — bigger penalty, faster + dual convergence (`contraction ≈ ‖S_λ‖/(r+‖S_λ‖)`). +- **But the inner solve stiffens at large `r`.** Linear iterations per outer + solve stay at 1.0 up to `r=10⁴`, then rise (2.5 at `r=10⁵`), and wall time + balloons (the velocity sub-block conditioning degrades — visible in wall time + even before the outer KSP count moves). +- **True work is U-shaped**: both extremes are 3–8× slower than the optimum. The + efficient basin is `r ∈ [300, 10⁴]` (>1.5 decades) at both resolutions; the + default `r = 10³·μ` sits inside it. +- **Accuracy is `r`-independent** (relL2 ≈ 3.1e-3, flat across four decades of + `r`). So `r` is tuned for *speed* with a benign failure mode — too small just + costs iterations, too large just costs inner work; **the answer is never + wrong**. Contrast a pure penalty, where the magnitude must be tuned against + forcing strength and viscosity to get *accuracy* (too small ⇒ wrong), which is + the fragility this method removes. + +## Option trade-offs and what is deferred + +| Option | Verdict | +|---|---| +| (A) full-domain 3rd FE field + ε-screening | 3-way nested fieldsplit; ε re-introduces tuning. Rejected as primary. | +| (B1) boundary-stratum-only PetscFE field | No DMPlex support on the same DM. | +| (B2) co-dim-1 submesh + MATNEST | The honest monolithic form; deferred. | +| (C) reuse pressure / `_constraints` | `p` enforces `∇·u=0` interior, not `u·n=0` on Γ — not redundant. The CBF identity is a *validation* tool, not an implementation. | +| **(D) monolithic in-saddle multiplier (grouped `[p,λ]` Schur)** | **Implemented** (the shipped solver). One coupled solve, exact, recoverable topography, boundary-only reduction. | +| (E) augmented-Lagrangian outer loop | Earlier exploration; **removed** in favour of (D). Easy to reproduce in Python. | + +Deferred to follow-up PRs: a true co-dim-1 / MATNEST `λ` representation; 3D +spherical shells; **parallel** (the boundary handling is serial); the +**both-boundaries-free-slip** annulus case (rigid-rotation velocity null space +needing explicit removal); and live free-surface equilibrium integration (pass +`λ` as the target normal-stress end-state). + +## Monolithic `P'=[p,λ]` fieldsplit — the shipped design + +This is the implemented approach (and a step toward a general "inject arbitrary +constraints into the saddle point" capability): rather than a third field forcing +a nested 3-way Schur, group pressure and the multiplier into a composite +`P' = [p, λ]` and keep a **2-way `u | P'`** split. + +**Spike result (confirmed):** a 3-field DM `(u, p, h)` on a real mesh, with the `p` +and `h` index sets grouped (`pc_fieldsplit_1_fields 1,2`, or an explicit +concatenated IS), produces exactly a 2-block `u | [p,h]` Schur fieldsplit +(block sizes 84 | 84 = u | (p+h) on a coarse test). The nested-Schur / +KSP-reconfiguration concern is therefore moot — the split structure is identical to +the current `u | p` solver. (`/tmp/spike_pph.py`.) + +**What was implemented (behind the `SNES_Stokes_Constrained` subclass):** +- `λ` registered as field 2 (`dm.setField`). +- `λ`-equation residual: boundary part `∫_Γ ψ(n·u − g)` plus a small interior + screening `ε∫_Ω λ ψ` to de-singularise the interior block — which is then + **constrained away** by the boundary-only reduction (the interior λ DOFs are + pinned in the PetscSection, so only the boundary trace is solved). +- Boundary Jacobian blocks `uλ`, `λu` and the AL stiffness `uu += r(n⊗n)` + (`ph`/`hp` are zero); registered via the `UW_PetscDSSetBdJacobian` machinery. +- `[p,λ]` grouped in the fieldsplit by field index (keeps the velocity DM + hierarchy for geometric MG/FMG); the gauge nullspace handled as a combined + `(p, λ)` mode on enclosed problems. + +**Caveats:** the work touches the validated `uu/up/pu/pp` assembly, so it lives +behind the subclass and is regression-tested (`tier`-graded). Serial only for +now. A true *co-dimension-1* `λ` (boundary-only DOFs end-to-end) remains the +honest long-term form; the full-domain field + boundary-only reduction is the +pragmatic path that ships today. + +## Conditioning, the augmentation's true role, and rigid-body null spaces + +This section records what a focused study of strong **boundary viscosity +contrast** (annulus lateral `μ_hi ≥ 10³`, SolCx's `1e6` jump) revealed about +*why* the constraint block is sometimes hard to solve — and corrects an +over-simplification in the "augmentation is purely a speed knob" framing above. + +### The constraint Schur preconditioner (`selfp`, the shipped default) + +The grouped `[p, λ]` block is preconditioned through its Schur complement. With +the base-Stokes default `pc_fieldsplit_schur_precondition = a11`, that +preconditioner is the assembled `a11` block — a viscosity-scaled **mass** on +pressure (`1/μ`) and the small screening on `λ`. The pressure part is the right +scaling (`S_p ≈ μ⁻¹ M_p`); the **`λ` part is not** — the true constraint Schur is + +$$S_\lambda = C\,A^{-1}C^{\mathsf T},$$ + +a boundary operator that scales like `1/μ` but is *not* a simple `λ`-mass. Under +strong contrast the bare `a11` mass is a poor approximation of `S_λ` and the +solve walls (or needs a very large augmentation to compensate). + +`SNES_Stokes_Constrained` therefore defaults to +**`pc_fieldsplit_schur_precondition = selfp`** instead. `selfp` forms + +$$S \approx A_{11} - A_{10}\,\operatorname{diag}(A_{00})^{-1}A_{01}$$ + +from the *actual* operator blocks; its `λλ` corner is +`−C diag(A)⁻¹ Cᵀ`, i.e. the true constraint Schur, **automatically and at no +extra assembly**. On a smooth lateral viscosity ramp the outer Krylov count is +flat (≈2–6 iterations) across `μ_hi = 1 … 10⁶`, where the bare `a11` mass climbs +to ~25 and diverges. Override with +`solver.petsc_options["pc_fieldsplit_schur_precondition"] = "a11"` if needed. + +**Why `selfp` and the `r ∝ μ` augmentation are mutually consistent.** The bare +constraint Schur is `S₀ = C A⁻¹ Cᵀ ~ 1/μ`. The augmentation adds `r·N` to `A` +(with `N ~ CᵀC`, the boundary `n⊗n`), so by Woodbury the *augmented* Schur is +`S_r = C(A + rN)⁻¹Cᵀ ≈ S₀(I + r S₀)⁻¹` — equal to `S₀ ~ 1/μ` for small `r` and +tending to `1/r` for large `r`, with the **crossover at `r·S₀ ~ 1`, i.e. `r ~ μ`**. +So the default `r = augmentation_base · μ(x)` is the AL-natural (crossover) +scaling. And `selfp` builds its Schur from `diag(A + rN)`, whose boundary diagonal +is `μ + r = μ(1 + augmentation_base)` — so `selfp`'s `λλ` block `~ 1/r` +**automatically tracks the augmented Schur, because `diag(A)` already contains the +penalty.** This is *why* they compose so well: the approximate inverse "sees" the +augmentation. A **constant** `r` (instead of `∝ μ`) was tested and is strictly +worse — it is negligible on the stiff side (`r ≪ μ`, no regularisation) *and* +breaks the uniform `diag(A)` scaling that `selfp` relies on, giving garbage +velocity at SolCx 1e6 (`velerr 1–12` across `r = 10²–10⁴`, vs `1.3e-4` for +`r ∝ μ`). The `μ`-weighting is load-bearing; keep it. + +### The augmentation has two roles — conditioning *and* null-space regularisation + +The earlier sections describe `r` as a *speed knob* that conditions the `[p, λ]` +Schur complement. That is correct, but incomplete. `r(n⊗n)` does **two** +independent things: + +1. **Conditions `S_λ` from the velocity (A) side.** Stiffening the boundary + velocity is one way to make the constraint Schur well-behaved. `selfp` + conditions the *same* operator from the Schur side. They are **substitutes**: + on a viscosity-contrast sweep, increasing `r` *or* switching to `selfp` both + collapse the iteration count. With `selfp` the augmentation can drop to zero on + a **single-constraint** problem and the solve still converges. + +2. **Regularises the velocity block's rigid-body null space** — a *structural* + role that `selfp` alone cannot fill (see below). This is why an all-free-slip + enclosed box still needs `r > 0` even with `selfp`. + +So `r` is not "just an accelerator". On problems with a velocity anchor (a no-slip +patch) it is optional with `selfp`; on problems with **no** velocity anchor it is +doing genuine null-space regularisation. + +### Rigid-body null spaces: a general principle (not specific to constraints) + +The velocity operator `A = ∫ 2μ ε(u):ε(v)` has the **zero-strain rigid-body +motions in its kernel for *any* mesh** — **2 translations + 1 rotation in 2D, 3 + +3 in 3D** — removed *only* by Dirichlet velocity DOFs. In a monolithic direct +solve this is hidden, but a **Schur-factored** solve inverts `A` as an *inner* +block that sees only `A` (not the constraint rows or the pressure), so the +singularity surfaces there. + +```{important} +Whenever **nothing pins velocity anywhere** — all free-slip, all-natural, the +free-slip spherical shell — the inner `A`-solve is singular along the rigid-body +modes. The constraints kill those modes in the *full* system but never in the +*inner* `A`-solve, so it can drift before the outer coupling acts. In the +**block-constrained** solver the working remedy is the **augmentation** +`r(n⊗n)`: it stiffens those modes out of `A` directly, so the inner solve is +well-posed. This is a *third*, structural role of the augmentation (beyond +conditioning the Schur complement), and it is why an all-free-slip enclosed +problem needs `r > 0` even with `selfp`. +``` + +```{warning} +**Do not** try to remove the inner-`A` singularity by attaching the rigid-body +modes to the *coupled* null space (`petsc_velocity_nullspace_basis`) on a +**constrained** problem — neither the rotation nor the translations work cleanly: + +- The **rotation** carries a *real* part of the answer: a closed `u·n=0` + incompressible flow generically has nonzero circulation `∫(x u_y − y u_x) dV`, + so it is not orthogonal to the rotation mode. Projecting it out corrupts the + velocity (measured: SolCx velerr `3e-1`; with all three modes, `≈1.0`). +- The **translations** *are* orthogonal to the solution in **L2** + (`∫u_i dV = 0` for incompressible `u·n=0` on a closed boundary), so in principle + they are harmless to suppress. But PETSc projects against the **Euclidean nodal** + inner product, and the unweighted nodal sum `Σ u_i` is not zero even when the + integral is — so projecting the constant mode still injects error (SolCx velerr + `1e-4 → 1.4e-2`). Honouring the L2 orthogonality needs **mass-weighted** null + vectors. + +Attaching the modes to the **velocity sub-block** instead (the inner solve) was +prototyped and is *less wrong* than the coupled route — at `aug=10⁴` it degrades +SolCx velocity to `4e-3` rather than destroying it (`≈1.0`) — but it still does +not match the augmentation (`1.3e-4`) and does not enable small/zero `aug`. The +reason is that `selfp` builds its Schur preconditioner from `diag(A)`, which is +**not** rank-deficient even when `A` is, so the preconditioner is blind to the +sub-block null space. A clean version would need the null space attached natively +(`DMSetNullSpaceConstructor`, before setup) *and* a Schur PC that respects it — +an unproven, non-trivial enhancement. **In practice the augmentation is the +working remedy for unanchored free-slip** (it stiffens the modes out of `A` +directly, with no pseudo-inverse or inner-product subtleties). Note +`petsc_velocity_nullspace_basis` remains correct for a genuine *coupled* null +mode, e.g. the rigid **rotation** of a free-slip spherical shell solved *without* +a constraint multiplier. +``` + +The buoyancy-driven annulus escapes the singularity entirely because its +**no-slip inner boundary** already pins all rigid-body modes in `A`; there `aug` +is fully optional (aug=0 and aug=10⁴ velocities agree to `3e-4`) and small `aug` +gives clean velocity, pressure, *and* topography at once. + +One further consequence: + +- The bare velocity rigid-body modes are the **GAMG near-null space** of the + velocity block. Set as a *near*-null space on that sub-block + (`MatSetNearNullSpace`, distinct from the coupled-operator null-space projection + warned against above) they improve the velocity multigrid coarse spaces — + standard elasticity/Stokes-multigrid practice — independent of the singularity + question. This is the same sub-block plumbing the clean inner-`A` fix would use. + +### Solver type at extreme contrast + +A fixed-viscosity Stokes solve is **linear**. The default `newtonls` performs an +inexact-Newton defect correction; when the Schur approximation is stiff (extreme +contrast) it can take many "Newton" steps or stall, even though each linear solve +is cheap. Using `snes_type = "ksponly"` solves the linear system directly and is +markedly more robust (and faster) for constrained free-slip at strong contrast. + +### Topography: the mixed / penalty / augmented triad + +The boundary constraint mirrors the **incompressibility** triad, with `λ` playing +the role of `p` (`λ : u·n=g :: p : ∇·u=0`): + +| formulation | analogue | topography signal | +|---|---|---| +| **mixed** (`r = 0`, multiplier) | Taylor–Hood `p` (a real DOF) | `λ` *is* `−σ_nn`, directly | +| **penalty** (no `λ`, large `r`) | `p = −λ_pen ∇·u` | `−r(u·n)` (recovered from the residual) | +| **augmented** (`λ + r`) | Uzawa / ALG2 | `−σ_nn = λ + r(u·n)` — a **mix** | + +The δu equation gives the boundary traction `−(λ + r(u·n − g))`, so the recovered +topography splits between the multiplier `λ` and the penalty term `r(u·n)`. The +constraint residual `u·n ≈` (FE/KSP tolerance) is roughly `r`-independent, so the +penalty share `r(u·n)` grows **linearly with `r`** (measured: ≈`10⁻⁴`, `10⁻²`, +`1.0` of the signal at `r/μ = 1, 10², 10⁴`). At small `r`, `λ` *is* the clean +topography (`corr(λ, −σ_nn) = 0.99995`); at large `r` it inflates and the +correlation degrades. Crucially the penalty term is pointwise **noise** (`r ×` +the noisy residual), so adding it back does **not** recover a cleaner signal — +the fix is to use a **smaller `r`**. + +This used to be a trade-off (large `r` for conditioning vs small `r` for clean +topography). **`selfp` breaks it**: it conditions the constraint Schur from the +operator side, so a small (or, with a velocity anchor, zero) augmentation gives +both a well-conditioned solve *and* a clean multiplier. For topography work, keep +a *modest* `r` for constraint *tightness* (`u·n → 0`), not for conditioning. + +### Is there a workable-AL / clean-topography overlap? (the unanchored case) + +When the velocity is **unanchored** (all free-slip, no Dirichlet) the augmentation +is *required* for rigid-body regularisation, so it has a **floor**; topography +contamination gives it a **ceiling**. The two move oppositely with the boundary +viscosity contrast `μ`: + +- the **regularisation floor** *rises* with `μ` (a stiffer inner-`A` needs more + `r(n⊗n)` to lift the rigid modes) — `aug ≲ 1` at `μ=10²`, `aug ~ 10³` at `μ=10⁶`; +- the **topography ceiling** *falls* like `1/μ` (the penalty noise is + `r·(u·n) = aug·μ·ε`). + +Measured on the SolCx 4-wall (unanchored), velocity-vs-analytic and +`corr(λ, −σ_nn)` together: + +| regime | overlap | +|---|---| +| **anchored** (no-slip core/base, any `μ`) | floor = 0; `aug→0` gives clean `u`, `p`, *and* topography — overlap is everything | +| **unanchored, moderate `μ` (≲10⁴–10⁵)** | **wide** — the whole `aug ∈ [1, 10⁴]` range is accurate *and* `corr(λ,CBF)=1.0000` (`μ=10²`) | +| **unanchored, extreme `μ` (~10⁶)** | **none** — regularisation needs `aug≥10³`, where topography has collapsed (`corr 0.7–0.9`); the seemingly-clean low-`aug` corr is spurious (garbage `u` ↔ garbage CBF) | + +So the conflict appears only in the corner of *fully-unanchored* geometry **and** +`~10⁶` contrast. There, lean on the block solver's exact constraint enforcement +(`RMS(u·n) ~ 10⁻⁹`) and recover topography from the consistent-boundary-flux +stress `−n·σ·n` (no `r`-amplification), not from `λ` directly. + +### The same story for the interior incompressibility penalty + +The boundary constraint `u·n=g` (multiplier `λ`) is the surface analogue of the +interior constraint `∇·u=0` (multiplier `p`). UW3's Stokes carries an **optional +augmented-Lagrangian grad-div penalty** for incompressibility, `λ ∫ μ (∇·u)(∇·v)`, +on by setting `solver.penalty` (default `0`). Everything above transfers: + +- **Scaling.** The penalty is multiplied by the local viscosity `μ` + (`constitutive_model.K`) — exactly as the boundary AL is `∝ μ` — so the ratio + penalty/`μ` stays uniform. The `penalty` parameter is therefore a + *dimensionless* `O(1)` base. A bare constant (the previous behaviour) over- + stiffens low-`μ` regions into velocity locking under contrast (measured: a + constant `100` gave SolCx velocity error `0.1`, while the `μ`-scaled `O(1)` + penalty gives `~10⁻⁵`). +- **Pressure correction.** Because the penalty sits in the *operator*, the + recovered `p` is the multiplier, not the mechanical pressure: + `p_mech = p − penalty·μ·(∇·u)`. At convergence they agree; pointwise they differ + by ≈ a couple of percent of `|p|` at `penalty = O(1)`. For a pressure-dependent + constitutive law use `p_mech`; for visualisation, raw `p` is adequate. +- **Usually unnecessary.** The `1/μ` Schur preconditioner already conditions the + pressure block (outer KSP is unchanged with or without the penalty), so the + default `penalty = 0` — where `p` is the clean physical pressure and needs no + correction — is the right starting point. + +## Files + +- `src/underworld3/systems/solvers.py` — `SNES_Stokes_Constrained`, `_BlockConstraintBC`. +- `src/underworld3/cython/petsc_generic_snes_solvers.pyx` — multiplier-field + registration, boundary residual/Jacobian coupling, fieldsplit grouping, + nullspace, and the section-based interior-multiplier reduction. +- `src/underworld3/systems/__init__.py` — `Stokes_Constrained` export. +- `tests/test_1061_constrained_freeslip.py` — box + annulus validation. +- `tests/test_1062_constrained_solcx.py` — SolCx analytic validation. diff --git a/docs/developer/design/EXPONENTIAL_VE_INTEGRATOR.md b/docs/developer/design/EXPONENTIAL_VE_INTEGRATOR.md new file mode 100644 index 000000000..f9dfe42fd --- /dev/null +++ b/docs/developer/design/EXPONENTIAL_VE_INTEGRATOR.md @@ -0,0 +1,436 @@ +# Exponential Integrator for VE / VEP Constitutive Updates — Implementation Plan + +**Status**: **ETD-1 ships as the recommended default** (2026-04-29, 27 commits). ETD-1 reproduces BDF-1 essentially exactly on the deep-yield TI killer test (σ_∥ peak 1.04·τ_y, |u_y| peak 0.0320, SNES 1.8 mean iters — all identical to BDF-1) AND inherits ETD's analytical exponential factor for the linear-relaxation part. Phase B (ETD-2, single α/φ), Phase D (per-component split), and Phase E (hybrid BDF/ETD) remain on the branch as instructive failures — they don't ship. + +**TL;DR**: +- **The lesson**: the drift/blow-up on VEP+yield is order-driven, not algorithm-driven. **First-order methods (BDF-1, ETD-1) are L-stable and damp the high-frequency modes that plastic yield transitions excite**; higher-order methods (BDF-2, ETD-2 lumped/split/hybrid) preserve those modes and let them grow. Recognising this collapses the whole "ETD doesn't work for fault mechanics" narrative — it's *higher-order* ETD that doesn't work, same as higher-order BDF. +- **Production recommendation**: `integrator='etd', order=1` for everything. Single-step like BDF-1, no forcing-history mesh variable, fully L-stable, with the analytical exp factor for the linear part. Killer-test trajectory **byte-identical to BDF-1** in σ_∥ and |u_y|; ~5% slower wall-clock. +- **Higher-order ETD on smooth VE** (no yield): ETD-2 still beats BDF-2 by 4.3× on `bench_ve_harmonic`. Available as `integrator='etd', order=2` for users who know their problem is fully VE. +- **Higher-order anything on tight-yield TI**: don't use. BDF-2, ETD-2 lumped, ETD-2 split + lag + cap, ETD-2 hybrid — all show drift or blow-up of various flavours. + +**Branch**: `feature/exp-integrator-investigation` + +**API (production)**: +- `ViscoElasticPlasticFlowModel(unknowns, integrator='etd', order=1)` — ETD-1 (first-order). **Default-recommended for new code** — BDF-1 stability + analytical exp factor for the linear-relaxation part. +- `TransverseIsotropicVEPFlowModel(unknowns, integrator='etd', order=1)` — same, TI variant. +- `integrator='bdf'` on the same classes (with `order=1` or `2`) — production default, unchanged behaviour. Same accuracy class as ETD-1 but with rational rather than analytical relaxation factor. +- `integrator='etd', order=2` (Phase B ETD-2) — second-order, accurate on smooth VE (4.3× better than BDF-2 on `bench_ve_harmonic`); **avoid in VEP+yield regime** (catastrophic σ/u runaway documented in lessons #7, #9). +- Sibling `MaxwellExponentialFlowModel` / `TransverseIsotropicMaxwellExponentialFlowModel` survive as thin aliases for backwards compat. + +**API (experimental — investigative, not for production)**: +- `TransverseIsotropicVEPSplitFlowModel` (Phase D): per-component split with τ-cap. σ enforcement OK, `|u_y|` ratchets. +- `TransverseIsotropicVEPFlowModel(integrator='hybrid', fault_weight=...)` (Phase E): spatial blend. σ enforcement OK, `|u_y|` drifts. +- Both retained on the branch for reference; docstrings marked EXPERIMENTAL. + +--- + +## TL;DR + +For Maxwell-type viscoelasticity $\dot\sigma + \sigma/\tau = \mu\dot\gamma$, integrate the relaxation operator analytically and approximate only the forcing: + +$$\sigma^{n+1} = \alpha\,\sigma^n + \mu(A\,\dot\gamma^{n+1} + B\,\dot\gamma^n)$$ + +with $\alpha = e^{-\Delta t/\tau}$, $\varphi = (1-\alpha)\tau/\Delta t$, $A = \tau(1-\varphi)$, $B = \tau(\varphi-\alpha)$. + +Numerically validated: **5–12× more accurate than BDF-2** at small Δt, **decisively better at Δt ≈ τ** (where BDF-1/2 over-damp to near-zero output), **structurally avoids the BDF-2 multistep instability** seen in TI-VEP + spatial yield_stress (no second history term to amplify through the autodiff Jacobian). + +The integrator stores one slot of σ-history *and* one slot of γ̇-history. Yield handling via standard return-mapping. The DDt class hierarchy already supports multiple parallel integrator coefficient sets (`_bdf_coeffs`, `_am_coeffs`); adding `_exp_coeffs` and an optional forcing-history storage stream is a peer extension, ~200 lines. + +--- + +## Implementation phasing + +### Phase B — UW3 prototype (next session, est. 3–5 days) + +**Goal**: Match BDF-2's `bench_ve_harmonic` accuracy (1.34e-3) with single-step exponential, in a clean implementation. + +**Tasks** (in order): + +1. **Resolve UWexpression-to-JIT propagation** (~half day) + - The Phase B jury-rig (`_exp_integrator_uw3_jury_rig.py`) hit a JIT propagation snag: setting `cm._exp_alpha.sym = X` per step doesn't reach the JIT-compiled flux. The BDF path's `_bdf_c0..c3` *do* propagate via `_update_constants()` — replicate that mechanism for `_exp_alpha`, `_exp_phi`. + - Likely fix: subclass-level `_update_constants` or piggyback on the existing constants-manifest registration in `SolverBaseClass`. + +2. **Extend `SemiLagrangian` DDt with exponential integrator** (~1 day, ~200 lines) + - Add `_exp_coeffs = _create_exp_coefficients(...)` parallel to existing `_bdf_coeffs`/`_am_coeffs` + - Add `with_forcing_history=False` constructor parameter; when True, allocate `forcing_star` MeshVariable and wire projection-snapshot machinery (mirror what's done for `psi_star`) + - Add `update_post_solve` branch that calls `_update_exp_values(dt, tau_eff)` and projects the current strain rate into `forcing_star[0]` (use `SNES_MultiComponent_Projection` — already used for VE-Stokes' tau projection) + - Add `exp_history_term()` peer method to `bdf()` and `adams_moulton_flux()` + +3. **Add `MaxwellExponentialFlowModel`** (~half day, ~150 lines) + - Sibling of `ViscoElasticPlasticFlowModel`. `requires_stress_history = True`, but the auto-DDt creation path uses `with_forcing_history=True` instead of `order=k` + - Stress: `σ = 2η(1-φ)·ε̇ + DFDt.exp_history_term()` + - Yield handling: the `viscosity` property wraps with softmin/min as today, replacing η(1-φ) where it appears + - Lagged-τ: each `_update_constants()` call pulls τ_eff from the most recent post-solve projected stress and uses it for next step's α, φ, A, B + +4. **Validate on existing benchmarks** (~half day) + - `bench_ve_harmonic` — must match BDF-2's max\|err\| = 1.34e-3 at peak-start IC, or be stricter + - `bench_ve_square_vardt` — must match BDF-2's accuracy under variable Δt + - `bench_vep_square` (Min mode) — peak \|σ\| within 1% of τ_y, matching the snapshot-fix BDF-2 baseline + - All 20 existing VE/VEP regression tests still pass + +5. **The killer test** (~half day) + - `bench_ti_vep_harmonic` at θ ∈ {0°, ±15°}, τ_y ∈ {0.15, 0.30}, with the spatial yield_stress field + - **Decision gate**: peak \|σ_xy\| must stay bounded (≲ 1.1·τ_y in fault zone, ≲ A_∞ in bulk) for all 6 (θ, τ_y) combinations. BDF-2 currently produces 10⁸ blow-up here; exp should run cleanly. This is the empirical proof of the structural argument. + +### Phase C — Particle / Lagrangian extension (later session) + +`Lagrangian_DDt` and `Lagrangian_Swarm_DDt` are siblings of `SemiLagrangian`; they already share the BDF/AM coefficient API. Mirror the Phase B changes: +- Add `_exp_coeffs` and `exp_history_term()` +- Add forcing-history slot (a swarm variable in the `Lagrangian_Swarm` case) +- The integrator-method API is storage-agnostic; nothing the constitutive model calls needs to change + +### Phase D — Per-component (α_⊥, φ_⊥)/(α_∥, φ_∥) for TI VEP — DONE (2026-04-29) + +The rank-4 TI modulus splits cleanly into two orthogonal projectors: +$$\mathbf{C} = 2\eta_0 \, \mathbf{P}_\perp + 2\eta_\parallel^\text{eff} \, \mathbf{P}_\parallel$$ + +with `P_∥` the director-aligned projector (the `K` kernel of the original `_build_c_tensor`) and `P_⊥ = I_4 - P_∥`. Each projector has its own Maxwell relaxation time during yielding (τ_⊥ = η_0/μ stays at the matrix value while τ_∥ = η_∥_eff/μ collapses). Phase B's single lumped (α, φ) cannot represent both timescales; per-component decomposition can. + +**Validated in 1D cleanroom first** (`_exp_integrator_phase_d_split.py`): two parallel Maxwell branches with disparate τ, sinusoidal forcing, closed-form analytical reference. Per-component matches analytical to discretisation order (slope-2 in Δt, max\|err\|/A_∞ ≈ 5e-6 at Δt=0.005); every lumped variant carries Δt-independent error 7%-142% — the splitting is structurally required when τ_⊥ ≠ τ_∥, not a Δt issue. + +**UW3 implementation** as `TransverseIsotropicVEPSplitFlowModel` (`src/underworld3/constitutive_models.py`): + +1. **Sub-moduli**: `_build_split_c_tensors(η_⊥, η_∥)` returns `C_⊥ = 2η_⊥·(I-K)` and `C_∥ = 2η_∥·K` by zeroing one viscosity in the existing `_build_c_tensor` loop. Sum recovers original C. + +2. **Lagged η_∥ via `forcing_star`**: `_eta_par_eff_lagged()` reuses the parent's softmin envelope but reads the rate from `forcing_star.sym` (projected previous-step ε̇) instead of `self.E_eff.sym` (current Newton iterate). Breaks the per-quad-split's 1-iter trivial-Newton failure mode (where α_∥ depends on η_∥ depends on E_eff depends on Newton's u, collapsing to fixed point). + +3. **Explicit-parallel plasticity**: both `α_∥, φ_∥` AND the C_∥ multiplier use the lagged η — fully Picard for the parallel branch. ETD's E_eff has weak σ-history coupling (`α/(2η_1) ≈ 0.5` vs BDF's `1/(2μΔt) ≈ 10`) so the parent's _eta_par_eff would not see the yielded state on the current iterate; using forcing_star sees it because |γ̇*| is large there. BDF-1 effectively does the same Picard treatment via its E_eff magnification. + +4. **Soft cap on x_par** (recommendation #4): `x_eff = (1 - exp(-c·x_natural))/c` keeps `α_∥ ≥ exp(-1/c)`, equivalent to `τ_∥ ≥ c·Δt`. User-tunable via `cm.tau_par_cap_factor` (default c=1.0). This shape pre-evaluates to a finite scalar at codegen-time defaults (dt=∞, μ=∞, Pint(1, "Pa·s") for η) where additive forms hit `oo+Pint` dimensional clashes. + +5. **σ_∥ probe added** to all three killer-test runners: resolved fault-shear `|σ_∥| = √(|σ·n|² - (n·σ·n)²)` measured at fault centre per step. The previously-used `|σ_xy|` global-frame probe overshoots the yield surface in BDF too (2.15·τ_y) — `|σ_∥|` is the right comparator and shows BDF sits at 1.04·τ_y (essentially exact). + +**Killer-test outcome** (θ=+15°, τ_y=0.05, RES=32, 1.5T): + +| metric | BDF-1 | ETD lumped | split (Newton-impl, c=0) | split + cap (c=1.0) | +| --- | --- | --- | --- | --- | +| centre `\|σ_∥\|` peak | **1.04·τ_y** | 2.06·τ_y | 4.15·τ_y | **1.21·τ_y** | +| centre `\|σ_xy\|` peak | 2.15·τ_y | 29.10·τ_y | 4.92·τ_y | 2.47·τ_y | +| global max `\|σ\|_II` | 1.05 | 17.82 | 0.41 | 1.32 | +| global max `\|u_y\|` | **0.032** | 18.49 | 0.070 | 0.681 | +| SNES iters mean / max | 1.8 / 4 | 8.1 / 22 | 1.0 / 1 | 1.0 / 1 | +| wall / step | 1.7 s | 5.6 s | 4.1 s | 1.9 s | + +(τ_y=0.15 sanity check: split + cap gives σ_∥ = 1.03·τ_y, |u_y| = 0.012, SNES 1 iter mean — Phase B regime preserved.) + +**What works**: σ_∥ enforcement to within 21% of τ_y (vs BDF's 4%); no global runaway; physically correct fault-mechanics structure (PyVista plots `output/exp_integrator_phase_d_pyvista_split_*.png` show strain rate localised on fault, σ saturated at yield surface, bipolar u_y indicating along-fault slip). 1-iter Newton (linear in parallel branch) makes per-step cost competitive with BDF. + +**Open**: `|u_y|` is 16-21× BDF-1's. The yield surface is correctly enforced; the difference is in how much slip accumulates per yield cycle. Mechanism (lesson #9 below): BDF's E_eff = ε̇ + σ*/(2μΔt) has built-in elastic damping that absorbs boundary motion into elastic accumulation rather than slip. ETD's E_eff with α_∥ → 0 at yield wipes elastic memory each step; even with the soft cap, the flux structure keeps slip accumulating at near-boundary rate. Not a yield-criterion failure — both integrators sit on the yield surface — but a difference in how the constitutive law is integrated through the yielded regime. + +### Phase E — Hybrid BDF/ETD with spatial fault weight — DONE (2026-04-29) + +User-suggested structural insight: in the TI fault model the user already supplies the fault geometry through `yield_stress(x)`; we know a priori where yielding *can* happen. So let each integrator handle its sweet spot: + +- Inside the fault zone (where `τ_y(x)` is reachable): **BDF-1** — its `σ*/(2μΔt)` magnification provides the elastic damping that the cyclic-yield regime needs. +- Outside the fault (where `τ_y(x) → τ_y_bulk` ≫ A_∞ and yielding is structurally unreachable): **ETD-2** — strictly more accurate VE; its lack of plastic damping doesn't matter because plasticity isn't activated. + +**Math**: `σ(x) = w(x)·σ_BDF + (1-w(x))·σ_ETD` with `w(x) = (1/τ_y(x) - 1/τ_y_bulk) / (1/τ_y_fault - 1/τ_y_bulk) ∈ [0, 1]`. + +**Implementation** (in `TransverseIsotropicVEPFlowModel`): +- New `integrator='hybrid'` option; constructor takes `fault_weight` (sympy expression). +- `_eta_for_tensor(integrator_mode, apply_yield)` extracts (η_0, η_1_eff) per integrator/yield combination. +- `_assemble_c_tensor(η_0, η_1_eff)` builds the rank-4 tensor from given values. +- `_build_c_tensor` for `'hybrid'` builds both `_c_bdf` (yield-clipped) and `_c_etd` (raw). +- `_e_eff_for(integrator_mode)` returns the right E_eff form. +- `stress()` for `'hybrid'` blends `w·(C_BDF:E_eff_BDF) + (1-w)·(C_ETD:E_eff_ETD)`. +- Both BDF and ETD coefficients update each step. Single shared psi_star + forcing_star. + +**Killer-test outcome** (θ=+15°, τ_y=0.05, RES=32, 1.5T): + +| metric | BDF-1 | ETD lumped | split + cap | **hybrid** | +| --- | --- | --- | --- | --- | +| centre `\|σ_∥\|` peak | **1.04·τ_y** | 2.06·τ_y | 1.21·τ_y | 1.12·τ_y | +| centre `\|σ_xy\|` peak | 2.15·τ_y | 29·τ_y | 2.47·τ_y | 2.35·τ_y | +| global max `\|σ\|_II` | 1.05 | 17.82 | 1.32 | **0.95** | +| global max `\|u_y\|` | **0.032** | 18.49 | 0.681 | 0.109 | +| SNES iters mean / max | 1.8 / 4 | 8.1 / 22 | 1.0 / 1 | 2.1 / 4 | +| wall / step | 1.7 s | 5.6 s | 1.9 s | 2.3 s | + +(τ_y=0.15 sanity: σ_∥=1.05·τ_y, |u_y|=0.014, SNES 1.5 mean — matches BDF.) + +**What works**: σ_∥ peak 1.12·τ_y (closest to BDF's 1.04 of any ETD variant), |σ|_II peak 0.95 (actually slightly tighter than BDF's 1.05 at this snapshot), Newton iterates normally (2.1 vs split's degenerate 1.0). PyVista field plots show physically clean structure: u_y range ±0.017 at chosen step (no boundary overshoot), strain-rate localised on the fault band, no fault-tip stress concentrations. + +**Why we still don't ship it**: the trajectory plot reveals `|u_y|` ramps monotonically from ~1e-5 to 0.109 over 1.5 periods — slow accumulation, not bounded oscillation like BDF-1 (which oscillates around 0.01-0.03 returning to baseline between yield events). At any single snapshot the field looks BDF-class; over cycles, drift accumulates. + +**Likely cause**: shared σ* history. Both BDF and ETD branches read from the same `psi_star`, but `psi_star` is updated to the *blended* σ each step. Inside the fault, the BDF branch's σ* is "previous step's blended σ" — not "previous step's BDF-pure σ". Bulk's ETD-stored history leaks into the fault's BDF computation, slowly amplifying fault slip over cycles. Fixing this would need two independent history fields with parallel updates — a significant refactor. + +**Decision**: Phase E as committed is the cleanest hybrid we tried, but doesn't deliver BDF-class temporal behaviour and fundamentally can't without the independent-history rework. Keep on branch as documented investigation; not advertised in user-facing API. + +### Phase F — Generic `TimeIntegrator` refactor (deferred — only if needed) + +If we end up with five-plus integrator methods on the DDt class and want to add another (e.g., Crank-Nicolson or higher-order ETD), refactor to separate `HistoryStorage` from a `TimeIntegrator` strategy object. Not needed for current scope. + +--- + +## Open architectural questions to resolve during Phase B + +1. **Lagged-τ vs SNES sub-iteration for VEP** + + For yield-active VEP, $\tau_{\text{eff}} = \eta_{\text{eff}}/\mu$ depends on σ (nonlinear). Two strategies: + - *Lagged-τ (Picard)*: Compute α, φ, A, B from previous step's η_eff. First-order in the nonlinear coupling, trivial to implement. **Phase B starts with this.** + - *Self-consistent τ via SNES*: Include τ in the iterate so the inner Newton converges τ↔σ together. More accurate but couples the time-integration to the SNES tolerance. Add only if lagged-τ shows insufficient accuracy. + +2. **Per-quad α, φ when τ is spatial** + + When η_eff is a spatial field (yield zone, weakness map), α = exp(-Δt/τ) becomes a spatial expression. Sympy handles `exp(spatial_expr)` symbolically, but JIT codegen has to evaluate `exp` per quadrature point per residual eval — potentially expensive. + + Mitigation: project (α, φ) onto a scalar mesh variable at the start of each step. They're constant within a step. The JIT then sees a scalar-field reference, not an `exp` to evaluate. ~one extra projection per step. + +3. **Forcing-history projection cost** + + ε̇* needs to be projected into `forcing_star[0]` after each solve. UW3's `SNES_MultiComponent_Projection` (committed in 2026-04 for VE-Stokes' tau projection, see `docs/developer/CHANGELOG.md`) makes this cheap and direct. Memory cost: one extra `SYM_TENSOR` MeshVariable per VE/VEP solver. + +4. **TI-VEP per-component decomposition** + + The TI rank-4 tensor has separate timescales: $\tau_0 = \eta_0/\mu$ for bulk, $\tau_{1,\text{eff}} = \eta_{1,\text{eff}}/\mu$ for fault-tangent. The clean approach is to construct the rank-4 stress tensor with separate $(\alpha_0, \varphi_0)$ for the isotropic part and $(\alpha_1, \varphi_1)$ for the director-aligned correction, matching how `_build_c_tensor` already does separate viscosities. **Validate at Phase B step 5; bug-fix in this session if needed.** + +5. **Asymmetric fine-Δt windows around BC flips** + + Phase B 1D evaluation showed that centred fine windows around BC discontinuities waste their pre-flip half (σ is near peak and barely changes) while the post-flip half does the real work. Production benchmarks should use asymmetric windows (small pre, larger post). Affects the `bench_*_vardt` schedule, not the integrator itself. + +--- + +## Validation gates (achieved — Phase B closed) + +| Test | Baseline | ETD-2 result | Status | +|---|---|---|---| +| `bench_ve_harmonic` | BDF-2 max\|err\| = 1.34e-3 | **3.14e-4** | ✅ **4.3× more accurate** than BDF-2 | +| `bench_ve_square` (const-Δt) | BDF-1 2.83e-2, BDF-2 8.07e-2 | 8.72e-2 | ✅ matches BDF-2 within 10% | +| `bench_vep_square` (Min) | peak\|σ\| = 0.5000 | peak\|σ\| = 0.4899, **0/160 violations** | ✅ saturated under τ_y | +| **`bench_ti_vep_harmonic` order=2 (killer test)** | **BDF-2: 10⁵-10⁹ blow-up on every yield-active combo** | **6/6 PASS, σ ≲ 1.12·τ_y at fault centre** | ✅ **decision gate met** | +| 20 existing VE/VEP regression tests | pass | pass | ✅ no BDF regression | + +### Killer-test detail (`bench_ti_vep_harmonic`) + +Centre-probe metrics (apples-to-apples with BDF-1 production), ETD-2 vs BDF-1: + +| θ | τ_y | ETD-2 \|τ_resolved\| | BDF-1 \|τ_resolved\| | ETD-2 \|σ_xy\| | BDF-1 \|σ_xy\| | +|---|---|---|---|---|---| +| 0° | 0.15 | **1.103·τ_y** | 1.122·τ_y | 1.103·τ_y | 1.122·τ_y | +| +15°| 0.15 | **1.118·τ_y** | 1.143·τ_y | 1.410·τ_y | 1.447·τ_y | +| -15°| 0.15 | **1.120·τ_y** | 1.127·τ_y | 1.408·τ_y | 1.440·τ_y | +| 0° | 0.30 | **0.922·τ_y** | 1.150·τ_y | 0.922·τ_y | 1.150·τ_y | +| +15°| 0.30 | **0.804·τ_y** | 1.139·τ_y | 0.929·τ_y | 1.049·τ_y | +| -15°| 0.30 | **0.803·τ_y** | 1.138·τ_y | 0.929·τ_y | 1.047·τ_y | + +ETD-2 **is tighter than BDF-1 production on every probe**. BDF-2 (the higher-order method ETD-2 replaces) blows up to 10⁵-10⁹ on every yield-active combo (τ_y=0.15) — confirming the structural argument empirically. + +Runner: `docs/developer/design/_exp_integrator_phase_b_killer.py`. + +--- + +## Future work (out of scope for Phase B but relevant) + +- **Backtracking timestepping**: when a step contains an event that the integrator can't capture in one piece (e.g., a steep change in γ̇ or yield-onset), back up and retry with smaller Δt. Logically separate from the integrator choice; both BDF and exp would benefit. Useful for adaptive timestep strategies that don't know flip times a priori. + +- **Higher-order ETDs**: ETD-3, ETD-4 would store 2 or 3 forcing-history slots and use cubic/quartic interpolation in the integral. Not needed unless second-order forcing accuracy proves insufficient (unlikely for typical mantle/lithosphere problems). + +- **Higher-order yield treatment**: the lagged-τ approach is first-order in the nonlinear coupling. For sharp yield onset under variable Δt, a self-consistent τ via SNES sub-iteration may be needed. Bridge from Phase B if observed. + +- **Symbolic τ_eff in non-Maxwell rheologies** (Burgers, Maxwell-Voigt, etc.): the exponential framework generalises to any linear relaxation operator. Each relaxation timescale gets its own (α, φ); the rank-4 contraction picks them up via a matrix exponential of the relaxation tensor. Out of scope, but the architecture leaves the door open. + +--- + +## What we learned — deviations from the original plan + +These notes capture decisions taken during Phase B that diverge from or refine the pre-Phase-B plan above. They should inform Phase C and Phase D scope. + +### 1. The "JIT propagation" task was a red herring + +The plan's Task 1 anticipated a UWexpression-to-JIT propagation issue based on the jury-rig's failure. The actual cause was simpler: the jury-rig subclassed `ViscousFlowModel` which has `requires_stress_history = False`, so the Stokes solver took the viscous branch where `cm.flux` is **never compiled** — it builds the flux from `cm.viscosity` instead. The custom flux containing `_exp_alpha`, `_exp_phi`, etc. was effectively dead code. Once the model declares `requires_stress_history = True` (as the new sibling did), the existing constants-manifest infrastructure handles α, φ propagation correctly with no new plumbing. + +### 2. Predictor-corrector return mapping (the 1D Phase B's yield approach) is wrong for 2D Stokes + +The 1D Phase B evaluator used predictor-corrector return mapping: solve pure VE, then clip σ to satisfy yield. In 2D Stokes that breaks momentum balance — the SNES finds u that satisfies `∇·σ_VE = body force` (no yield), then we clip σ but leave u unchanged, so the velocity field corresponds to the unclipped stress. **In 2D Stokes-VEP, yield must live inside the SNES residual** via the standard viscosity-wrapping pattern (`viscosity = softmin(η, η_pl)`), the same as the production BDF VEP path. Refactored mid-Phase-B (commit `aba93c2`). + +### 3. Lagged-τ aggregation experiments did not tighten yield-surface saturation + +Multiple lagged-τ approaches were tried (scalar `min η_eff` over yield-active nodes; scalar `median η_eff`; per-node spatial α via projected scalar mesh variables) — all gave **worse** σ overshoot than the raw τ_VE baseline. Analysis showed the ETD-2 history term `2η_raw·(φ-α)·ε̇*` uses raw η (not yield-clipped) — a Picard-style approximation — and produces a non-zero floor on σ under harmonic forcing that is insensitive to τ_eff except via the α·σ* scaling. The effect is geometric, not a τ-choice issue. Reverted to raw τ_VE = η/μ (commit `584dea8`). + +The ETD-2 result at parity-with-BDF-1-production — 1.10-1.14·τ_y at the fault centre — reflects the same kind of overshoot BDF-1 itself shows. Tightening past that is a Phase D concern requiring per-component (α₀, φ₀) for the rank-4 TI tensor, **not** a fix on the lagged-τ aggregation. + +### 4. Probe-metric mismatch caused a false alarm + +`max σ_II/τ_y_local` over a fault-zone mask reads larger than `σ_xy at fault centre / τ_y_at_fault` because the Gaussian-weakened τ_y(x) varies sharply across the mask: shoulder nodes have τ_y_local much larger than the centerline value, and σ_II saturates accordingly at those local τ_y, which inflates the ratio when the centerline τ_y_at_fault is used as the denominator. **Use the per-node ratio `max σ_II(x)/τ_y(x)`**, or stick to the centre-probe metric (the one BDF-1 production reports). The killer-test runner now reports both. + +### 5. Architectural collapse landed at the parameter level + +The plan envisaged Phase B with sibling classes (`MaxwellExponentialFlowModel`, TI variant) and Phase D moving integrator state onto the DDt with a strategy parameter. The collapse landed at the **constructor parameter** level instead: `ViscoElasticPlasticFlowModel(unknowns, integrator='etd')` (and the same on TI-VEP). Coefficients still live where the existing infrastructure naturally wants them (`_bdf_c0..c3` on the model, `_exp_coeffs` on the DDt) — the dispatch in `E_eff`, `viscosity`, `_build_c_tensor`, and the uniform `_update_history_*` hooks all branch on `self._integrator`. Sibling classes survive as ~10-line aliases for backwards compatibility (commit `ae79664`). + +### 6. Unit-handling in any new array touchpoints needs explicit care + +A predictor-corrector clip of `psi_star[0].array` via raw numpy initially looked correct in the non-units case (production benches don't use units) but stripped UnitAwareArray wrappers silently. The user flagged this as accumulating tech debt. The audit fix landed in commit `aba93c2`: `forcing_star` allocated `units=None` (ε̇ has different physical dimensions from σ), `update_forcing_history` non-dimensionalises eval results before storing. Future Phase D work touching `.array` should follow the same pattern (see `update_pre_solve` for the canonical example). + +### 7. Empirical range of validity: τ_y / A_∞ ≥ ~0.5; below that, Phase B ETD-2 is strictly worse than BDF-1 production + +A direct test by tightening τ_y from 0.15 to **0.05** on `bench_ti_vep_harmonic` (so τ_y / A_∞ = 0.05/0.27 ≈ 0.19) at RES=32 over 1.5 periods produced a **catastrophic step-by-step runaway** for Phase B ETD-2 even though Newton converged on every step. Apples-to-apples comparison with BDF-1 production on the *same* setup (saved at `output/phase_b_th{0,15}_ty0p05.*` and `output/phase_b_bdf_th+15_ty0p05.npz`): + +| metric (θ=+15°, τ_y=0.05) | **ETD-2** (Phase B) | **BDF-1** (production) | +|---|---|---| +| max σ_II in domain | **17.8** | **1.05** | +| u_y range | ±18 | ±0.032 | +| SNES iter mean / max | 8 / 22 | 1.8 / 4 | +| Wall / step | 5.9 s | 1.7 s | +| Centre \|σ_xy\| peak | 17.8 (356·τ_y) | 0.108 (2.15·τ_y) | +| Diverged SNES steps | 0/120 | 0/120 | + +The catastrophe **is specific to the ETD-2 implementation**, not a problem-class issue: BDF-1 production handles τ_y=0.05 cleanly with bounded σ, faster Newton (mean 1.8 iters), and 3.5× faster wall time per step. + +Time-series comparison, both integrators run on the same RES=32 mesh with matching driver and step size: ``output/exp_integrator_phase_b_bdf_vs_etd.png`` (generated by `_plot_phase_b_bdf_vs_etd.py` from `output/phase_b_{bdf,etd}_th+15_ty0p05.npz`). The ETD-2 trace tracks BDF-1 inside the ±τ_y band for the first half-cycle, then breaks loose at the second yield event and runs away through the second period — peak centre |σ_xy| reaches 1.46 (29·τ_y) and global max |u_y| reaches ~18, while BDF-1 stays at 0.11 and 0.03 respectively. The divergence point is the first deep yield, not a steady accumulation. + +The mechanism is the one items 3 and 5 in this list already identified: the ETD-2 history term ``α·σ* + 2η·(φ-α)·ε̇*`` uses raw η (Picard approximation when yield is active). The analytical-floor σ-magnitude under harmonic forcing is ~A_∞, independent of τ_y. When A_∞ > τ_y, σ* feeds back through α·σ* on each step and grows without bound; the leading viscous term is yield-clipped but has small (1-φ) coefficient at typical Δt, so it can't dominate the runaway history. + +**Newton's "convergence" reports in this regime are physically meaningless** — Newton finds the residual minimum each step, but the time-integration loop diverges. SNES iteration counts are *not* an early-warning signal (they actually *drop* from typical levels because the residual structure becomes degenerate); the warning is in σ_II / u_y magnitudes themselves. + +Practical implications for Phase B as committed: + +* ``integrator='etd'`` with the raw τ_VE = η/μ in `α, φ` works for **τ_y / A_∞ ≥ ~0.5** — at parity with BDF-1 production for accuracy at ratio 0.55, beats BDF-2 by 4.3× at no-yield ``bench_ve_harmonic``. +* Below that ratio (the **typical fault-mechanics regime**): solution diverges silently (no SNES error). Phase B ETD-2 is **strictly worse than BDF-1** — slower, less accurate, unstable. +* Phase B as currently committed should be treated as a structural-argument demo, not a drop-in replacement for the BDF integrators. Production users should keep ``integrator='bdf'`` (the default) until Phase D lands. +* **Phase D (per-component (α₀, φ₀)/(α₁, φ₁) for TI) is blocking, not "future work"**, for any production use of ETD-2 on tight-yield problems. + +The Phase B design-doc note that "lagged-τ doesn't help" applies in this regime too — the failure is structural to the Picard approximation, not a τ-choice issue. + +### 8. The diagnostic that mattered: |σ_∥| (resolved fault shear), not |σ_xy| + +Throughout Phase B and into the early Phase D iterations, the killer-test trajectories used `|σ_xy|` at fault centre as the yield-surface diagnostic. That was wrong. The yield criterion `|σ_∥| ≤ τ_y` lives in the fault frame; `|σ_xy|` is global-frame and includes contributions that the limiter doesn't constrain (off-fault stress, geometric tilts). + +Adding the resolved fault-plane shear `|σ_∥| = √(|σ·n|² - (n·σ·n)²)` as a per-step probe (commits 59ab769 onwards) revealed that: + +* BDF-1 sits **right on** the yield surface (peak `|σ_∥|` = 1.04·τ_y, essentially exact) despite `|σ_xy|` peaking at 2.15·τ_y. +* Lumped Phase B ETD-2 stays at 2.06·τ_y in `|σ_∥|` even though `|σ_xy|` runs away to 29·τ_y — the catastrophe is off-fault, the *fault* is doing fine. +* Phase D's first split implementations (per-quad and Newton-implicit lag) overshot to 4·τ_y *on the fault plane*; this needed fixing. + +Without `|σ_∥|`, Phase D would have been judged on `|σ_xy|` alone — and the cure (explicit-parallel + cap) would have looked like it just lowered the global-frame number without engaging with the actual yield-criterion physics. + +### 9. The structural BDF-vs-ETD slip-rate difference — physics, not numerics + +After Phase D's σ_∥ enforcement reached BDF parity (1.21 vs 1.04·τ_y), `|u_y|` remained 16-21× BDF-1's at τ_y=0.05. The mechanism is a structural difference in how each integrator handles the yielded regime, not a numerical defect: + +* **BDF**: E_eff = ε̇ + σ*/(2μΔt). At Δt=0.05, μ=1, the σ-history prefactor is **10**. When σ_∥ saturates near τ_y, this term *dominates* E_eff_∥ — boundary motion is preferentially absorbed into elastic accumulation rather than slip. The integrator has built-in elastic damping during yield. +* **ETD (Phase D)**: E_eff_∥ = (1-φ_∥)·ε̇ + α_∥/(2η_∥)·σ* + (φ_∥-α_∥)·ε̇*. At yield with α_∥, φ_∥ → 0 (or even with the soft cap clamping them at 0.37, 0.63), the σ-history coefficient is at most O(1). Boundary motion goes into γ̇_∥ at the imposed BC rate — the fault slips freely. + +Both integrators correctly enforce `|σ_∥| ≤ τ_y` (the limiter works). They just distribute the boundary motion differently between elastic and plastic strain. BDF's behaviour is closer to a typical seismic-cycle picture (elastic energy stores and releases episodically); ETD's is closer to steady-flow plasticity (boundary motion drives free slip at yield). Neither is "wrong"; they're modelling different limits of the same constitutive law. + +Implication: when comparing integrators on a tight-yield problem, σ-amplitude is a poor metric (both at τ_y); the meaningful difference is in time-integrated slip per cycle, which depends on the elastic-damping strength and is integrator-specific. + +### 10. Phase D recommendations checklist — what worked, what didn't + +The chatGPT advisor's stabilisation strategy was on the money for the issues we hit: + +| Recommendation | Phase D status | +| --- | --- | +| 1. Lag τ in the exponential — use τⁿ, never τⁿ⁺¹ | **Implemented.** `_eta_par_eff_lagged()` reads forcing_star (previous-step ε̇). Cured the per-quad split's 1-iter trivial-Newton failure mode. | +| 2. Plastic correction *after* VE update (predictor-corrector) | **Rejected.** Tried earlier in Phase B; broke 2D Stokes momentum balance. Yield-in-residual via softmin is the working pattern. | +| 3. Under-relax stress update (ω ~ 0.5) | **Not implemented.** Open follow-up. Would smooth Newton's hop and might tame the slip ratchet (lesson #9) without affecting the yield surface. | +| 4. Cap τ_eff ≥ c·Δt to avoid α_∥ → 0 | **Implemented** as a soft x_par cap `(1-exp(-c·x))/c`. Tunable via `cm.tau_par_cap_factor` (default 1.0). Modestly improves σ_∥ enforcement (1.31 → 1.21·τ_y at τ_y=0.05) but slightly worsens the slip ratchet (0.525 → 0.681) — the inconsistent capping (η_C natural, η_α capped) shrinks the (1-φ_∥)·E term in proportion to the σ*-contribution, so σ_∥ stays controlled but flux balance is more sensitive between yield events. | +| 5. Consistent viscosity in Stokes + constitutive | **Implemented.** Both C_∥ and (α_∥, φ_∥) use the lagged forcing_star-based η. Earlier inconsistency (C_∥ on current η, α_∥ on lagged η) had Newton converge in 1 iter and σ_∥ drift to 4·τ_y. | + +Also-tried, rejected: +* **Raw E (current strain rate) as yield-criterion rate input**: adds explicit u-dependence into η_∥_eff, which propagates into C_∥ and produces a singular GAMG operator at u=0 (start-up zero state). Smooth-floor regularisation didn't fix the SNES 0-iter divergence. Reverted — the parent's E_eff-based criterion is the right shape, just needs the right rate input (forcing_star, lesson above). +* **`σ*/(2ε̇*)` back-derivation for lagged η**: appears intuitive (it's the *effective* viscosity from histories alone) but breaks elastic regime (where σ ≈ μ·γ·dt, not η·ε̇). Produced startup spikes. Replaced by the parent-softmin-on-forcing_star pattern. +* **`sympy.Min` cap on η**: catastrophic 29/120 SNES diverged; non-smooth derivative breaks Newton. Replaced by smooth `(1-exp(-c·x))/c`. + +### 11. Phase E (hybrid) drifts because of shared σ* history — and that's structural + +The Phase E hybrid (`σ = w·σ_BDF + (1-w)·σ_ETD`) was conceptually the cleanest fix for the BDF-vs-ETD mismatch — let each integrator handle its sweet spot. Snapshot-by-snapshot the field structure is BDF-class (no boundary overshoot, fault-band strain-rate localisation, σ_∥ within 8% of τ_y). But the time-trajectory shows `|u_y|` ramping monotonically over cycles, ending at ~3× BDF. + +Mechanism: both branches share a single ``psi_star`` history slot, which is updated to the *blended* σ each step. So inside the fault, the BDF branch's σ* is "previous step's blended σ" — contaminated with ETD's looser-history contribution from the bulk. Over many cycles, that contamination amplifies fault slip slightly each pass. + +The fix would require two independent history fields with parallel updates (BDF history fed only by BDF flux, ETD history fed only by ETD flux, plus the spatial blend at flux time). That's a real DDt refactor, not a one-line fix, and even then there's no guarantee the slow drift fully closes — the underlying physics-mismatch (lesson #9) lives in how each integrator handles the yielded regime, not just in the history bookkeeping. + +The investigation-level lesson: **patches that share history between BDF and ETD branches will leak the missing damping into temporal drift.** Whether it's a per-quad split (Phase D, Newton-implicit), explicit-parallel split (Phase D with cap), or spatial blend (Phase E), the slow drift keeps reappearing in different magnitudes. ETD-as-designed is a beautiful integrator for VE; trying to retrofit it onto deep-yield VEP without rebuilding from the ground up consistently leaves residual non-physical behaviour. + +### 12. (superseded by #13) + +The conclusion in earlier drafts of this section ("for deep-yield fault mechanics BDF-1 is the right integrator; don't retrofit ETD") was correct *for higher-order ETD*. Lesson #13 below shows it's wrong for ETD generally — first-order ETD works fine. + +### 13. The drift was order-driven, not algorithm-driven — ETD-1 ships + +User's structural insight: "all the integrators have this growing instability except the first order one." ETD-1 (first-order ETD with `φ = α`) confirms it empirically — it reproduces BDF-1 essentially exactly on the killer test: + +| metric (θ=+15°, τ_y=0.05) | BDF-1 | ETD-1 | +| --- | --- | --- | +| centre `\|σ_∥\|` peak | 1.04·τ_y | 1.04·τ_y | +| global max `\|u_y\|` | 0.0320 | 0.0320 | +| SNES iters mean | 1.8 | 1.8 | +| diverged | 0/120 | 0/120 | + +Mechanism: BDF-1 and ETD-1 are both **L-stable** (`|R(z)| ≤ 1` on the entire negative-real-part half-plane → every mode is damped). BDF-2 is only A-stable; ETD-2 is exact for the linear ODE so has *zero* numerical dissipation. The plastic yield transitions create effective high-frequency modes (residual structure flips discontinuously when σ crosses τ_y); first-order methods damp them with the same numerical viscosity they apply to everything else, while higher-order methods preserve them and let them grow. + +Same general principle as Crank-Nicolson failing on stiff problems while implicit Euler doesn't. + +This collapses the "ETD doesn't work for fault mechanics" narrative the earlier lessons #7, #9, #11, #12 were converging toward. The actual statement is "*higher-order* ETD doesn't work for fault mechanics," same as higher-order BDF. ETD-1 (single-step, no forcing-history slot, analytical exp factor) is the right shape: BDF-1 stability + ETD's exact treatment of the linear-relaxation part. + +**Production recommendation**: `integrator='etd', order=1` as the default for VEP and TI-VEP. Wall-clock cost ~5% over BDF-1 (one extra `exp` per coefficient update); accuracy is per-iteration the same as BDF-1 (both first-order) but the analytical factor handles the linear-relaxation limit cleanly without the rational-approximation error at large `Δt/τ`. Phase B's ETD-2 (`integrator='etd', order=2`) remains available for users with smooth VE problems who can certify yield is never active — it beats BDF-2 by 4× there. + +The Phase D and Phase E artefacts stay on the branch as instructive failures of the higher-order-ETD idea — useful documentation of what doesn't work and why, but not part of the production API. + +--- + +## Appendix A — Numerical evidence + +### Phase A (1D linear Maxwell, sinusoidal forcing) — DONE + +`_exp_integrator_phase_a.py` solves $\dot\sigma + \sigma/\tau = \mu\dot\gamma$ with $\dot\gamma = \dot\gamma_0 \cos(\omega t)$, $\omega = \pi/2$, $\eta = \mu = \tau = 1$. + +| Δt/τ | Exp max\|err\| | BDF-1 | BDF-2 | +|---|---|---|---| +| 0.01 | 1.1e-5 | 4.5e-3 | 7.4e-5 | +| 0.05 | 2.9e-4 | 2.2e-2 | 1.8e-3 | +| 0.10 | 1.1e-3 | 4.4e-2 | 6.8e-3 | +| 0.50 | 2.8e-2 | 1.9e-1 | 1.1e-1 | +| **1.00** | **1.0e-1** | 3.5e-1 | 3.5e-1 | +| **2.00** | **5.7e-2** | 3.4e-1 | 3.4e-1 | + +Exp shows clean second-order slope at small Δt and stays accurate at Δt ≥ τ where both BDFs collapse to near-zero output. Figure: `exp_integrator_phase_a.png`. + +### Phase B (VEP, large Δt, square wave, variable-Δt) — DONE + +`_exp_integrator_phase_b_eval.py` extends to: + +- **VEP harmonic** ($\omega = \pi/4$, return-mapping yield): both Exp and BDF-1 clip correctly at τ_y; agreement to ~1% at small Δt because yield mechanism dominates over time integrator. + +- **Pure VE at large Δt/τ**: at Δt/τ ≤ 1, Exp 5–12× more accurate; at Δt/τ ≥ 2, both struggle but Exp degrades more gracefully (gives bounded under-shoot vs BDF's wrong-shape output). + +- **Square wave VE/VEP**: exp consistently ~2× more accurate than BDF-1 for VE; the yield+BC-discontinuity error dominates both for VEP at small Δt. + +- **Variable-Δt around BC flips** (correctly schedule, with fine-zone clamp): improvement of 11–19% in max error for both VE and VEP, both Exp and BDF-1. The exp's plateau-period exactness shows clearly as the per-step error drops to near machine precision once the BC discontinuity is well-resolved. + +Figures: `exp_integrator_phase_b_yield.png`, `exp_integrator_phase_b_largedt.png`, `exp_integrator_phase_b_square.png`, `exp_integrator_phase_b_vardt.png`. + +### Phase B UW3 jury-rig — partial (propagation snag identified) + +`_exp_integrator_uw3_jury_rig.py` attempted to wire ETD-2 into UW3 via a custom `MaxwellExpFlowModel(ViscousFlowModel)` subclass. Hit a JIT propagation issue: `cm._exp_alpha.sym = X` per-step updates don't reach the JIT-compiled flux. Minimal incremental test (`_exp_jury_rig_minimal.py`) confirmed the constitutive-model class plumbing works in isolation; the issue is specific to per-step updates of UWexpression coefficients. **First task of Phase B is resolving this**, by replicating the BDF coefficient propagation pattern. + +--- + +## Appendix B — Architecture details + +### What the exponential integrator stores + +| Integrator | psi_star slots | forcing_star slots | Coefficients | +|---|---|---|---| +| BDF-1 | 1 | 0 | c_0, c_1 | +| BDF-2 | 2 | 0 | c_0, c_1, c_2 | +| AM-2 | 1 | 0 | a_0, a_1, a_2 | +| ETD-1 (Lawson) | 1 | 0 | α | +| **ETD-2 (this proposal)** | **1** | **1** | **α, A, B** | +| ETD-3 | 1 | 2 | α, A, B, C | + +The `SemiLagrangian` already maintains parallel `_bdf_coeffs` and `_am_coeffs`. Adding `_exp_coeffs` is the same kind of peer extension. + +### What stays the same vs BDF in the constitutive model + +The factorisation σ = η_eff·γ̇ + (history) is preserved. Yield-mode logic (softmin/min/harmonic) wraps η_eff identically to today. The Stokes weak-form structure is unchanged. What changes: +- Different formula for η_eff_VE: η(1-φ) replaces η Δt/(τ+Δt) +- Different history term: α·σⁿ + 2η(φ-α)·ε̇ⁿ replaces the BDF Σ c_i ψ*_i sum +- New ε̇* storage slot + +### Why this avoids the BDF-2 instability (TI-VEP + spatial yield) + +The instability we documented arises from the c_2·ψ*_{n-1} term in BDF-2's history sum getting autodiff'd into the Jacobian, where it picks up the spatial gradient of η_1_eff (via $\partial\eta_{1,\text{eff}}/\partial\nabla u$), and then gets *directionally amplified* by the rank-4 tensor's $\hat n\otimes\hat n\otimes\hat n\otimes\hat n$ coupling. The amplification compounds across history, exploding |σ| over ~10 t_r. + +Exponential has **no second history term**. There's no c_2·ψ*_1 to amplify. The α·σⁿ contribution is autodiff-trivial (σⁿ is a known mesh variable, treated as constant w.r.t. ∇u). The ε̇ⁿ contribution likewise. The Jacobian's only ∇u-dependent term is the leading 2η_eff(1-φ)·ε̇, which is well-behaved. + +This is why the structural argument carries to TI-VEP via the per-component decomposition (Phase B step 5): each component of the rank-4 tensor gets its own (α, φ, A, B), each with single-history-slot relaxation, no cross-component amplification. diff --git a/docs/developer/design/ND_UNITS_BOUNDARY_CONTRACT.md b/docs/developer/design/ND_UNITS_BOUNDARY_CONTRACT.md new file mode 100644 index 000000000..8b7eb262f --- /dev/null +++ b/docs/developer/design/ND_UNITS_BOUNDARY_CONTRACT.md @@ -0,0 +1,111 @@ +# The non-dimensional ↔ units boundary (contract) + +This page defines **where the units system stops and the non-dimensional (ND) +solver world begins** in Underworld3, for both users and developers. It is the +companion "rules of engagement" to the +[units design](UNITS_SIMPLIFIED_DESIGN_2025-11.md). + +The one sentence to remember: + +> **Everything from the DM downward — PETSc, the solvers, `evaluate` / +> `global_evaluate`, point-location, the JIT residuals — runs in +> non-dimensional (model-unit) space. Units live *above* that line, in the +> user-facing `MeshVariable` API.** + +## The boundary, concretely + +The mesh DM stores **non-dimensional** coordinates (e.g. `0..1000` model units, +*not* `0..1e9` metres). `evaluate` / `global_evaluate` treat plain arrays as +those DM/ND coordinates, and the JIT residual/Jacobian kernels see ND field +values. None of that machinery knows about Pint units. + +Units are a deliberate **duality on the variable API**: + +| dimensional (units side) | non-dimensional (solver/DM side) | +| --- | --- | +| `var.array` — `UnitAwareArray` | `var.data` — plain ND values (what the solver sees) | +| `var.coords` — `UnitAwareArray` | `var.coords_nd` — plain ND DM coordinates | +| `uw.quantity(...)`, Pint quantities | plain floats / numpy arrays | + +The **crossing functions** between the two sides are: + +- `uw.non_dimensionalise(...)` — dimensional → ND (going *down* to the solver) +- `uw.dimensionalise(value, units)` — ND → dimensional (coming *up* to the user) +- the `.data` / `.coords_nd` accessors (already-crossed ND views) + +## The rule for developers + +Inside any **solver-internal** code (anything that builds DM coordinates, +calls `evaluate`/`global_evaluate`, locates points, assembles residuals, or does +coordinate/velocity arithmetic for the DM): + +1. **Work in ND space.** Use `var.coords_nd`, not `var.coords`; use `var.data`, + not `var.array`. +2. **Cross at the boundary, explicitly.** If you start from a dimensional + quantity, call `uw.non_dimensionalise(...)` *before* the value reaches the + DM / `evaluate`. Convert back with `uw.dimensionalise(...)` only when handing + a result back to the user. +3. **Never pass a `UnitAwareArray` into `evaluate` / `global_evaluate` / the + DM.** See the trap below for why this is silently wrong rather than an error. + +For **users**: pass dimensional quantities (`uw.quantity(...)`) or plain +model-unit values; read `.array`/`.coords` for dimensional results and +`.data`/`.coords_nd` for the ND values the solver used. You should not need to +nondimensionalise by hand — that is the library's job at this boundary. + +## The trap (why this is a contract, not just advice) + +`evaluate` / `global_evaluate` **strip the `UnitAwareArray` subclass with +`np.array()` but keep the numeric value.** So if you hand them a *dimensional* +array (`coords` ≈ `1e9` m), they keep `1e9` and locate it against a `0..1000` DM +— **no exception, wrong location, wrong answer.** The unit label is the only +thing that gets dropped; the dimensional magnitude survives and silently +mislocates. + +This is exactly the bug that was fixed in +[#267](https://github.com/underworldcode/underworld3/issues/267): the +semi-Lagrangian trace-back carried dimensional `.coords` and `m/s` velocity into +the ND DM. The crash (`'meter' − 'meter/second'`) was the *lucky* symptom; the +deeper failure was mislocation when units happened to be compatible. + +## The canonical correct pattern (from the #267 fix) + +The semi-Lagrangian trace-back in `systems/ddt.py` is the reference example — +the whole trace-back is computed in ND/DM space regardless of whether the model +carries units: + +```python +# Departure point in the mesh's ND (DM) coordinate space. +# coords_nd == .coords for a non-units model; the ND reduction of the +# dimensional coordinates when units are active — matching what +# evaluate / the DM point-location expect. +coords = np.asarray(self.psi_star[i].coords_nd) + +# Velocity non-dimensionalised to the same space before the arithmetic. +v_nondim = uw.non_dimensionalise(v_at_node_pts, model) # -> plain ND array + +# dt reduced to non-dimensional model-time. +mid_pt = coords - v_nondim * (0.5 * dt_nd) +v_mid = uw.function.global_evaluate(self.V_fn, mid_pt) # plain ND coords in +``` + +What this code does **not** do (and you should not do): reach for `.coords` +(dimensional), keep velocities in `m/s`, or pass a `UnitAwareArray` to +`global_evaluate`. + +## Enforcement (in progress) + +Today the boundary is **convention** — the rule above is correct but not +machine-checked, which is how #267 slipped in. Making `evaluate` / +`global_evaluate` (and the DM coordinate setters) **reject or correctly +non-dimensionalise** a `UnitAwareArray` input — instead of stripping the label +and keeping the value — would turn this contract into a guard rail. That +enforcement work is tracked separately; until it lands, the rule above is on you +to follow in solver-internal code. + +## See also + +- [Units system design](UNITS_SIMPLIFIED_DESIGN_2025-11.md) — the authoritative + units architecture and the `.array` vs `.data` semantics. +- [Why units, not dimensionality](WHY_UNITS_NOT_DIMENSIONALITY.md). +- [Coordinate units technical note](../ai-notes/COORDINATE-UNITS-TECHNICAL-NOTE.md). diff --git a/docs/developer/design/REMESH_FIELD_TRANSFER_DESIGN.md b/docs/developer/design/REMESH_FIELD_TRANSFER_DESIGN.md new file mode 100644 index 000000000..73cf70587 --- /dev/null +++ b/docs/developer/design/REMESH_FIELD_TRANSFER_DESIGN.md @@ -0,0 +1,260 @@ +# Field transfer on mesh adaptation — design + +Status: **design / spec for implementation.** Phase-1 (REMAP) not yet built. +A working **band-aid** is in `ddt.py` (see §7). This doc is the entry point for +the implementation session. + +## 1. Problem + +When an adaptive mover repositions mesh nodes, every field defined on the mesh +must be brought onto the new node layout. Today this is done **by user code** +(the harness hand-remaps `T` after calling the mover), which structurally +cannot transfer variables the user does not know exist — solver history +(`DuDt.psi_star`), projection/Hessian work-vars, RBF proxies, etc. + +The concrete failure (parallel adaptive `AdvDiffusionSLCN`, see memory +`project_slcn_psistar_seam_remap`): a 30–50% temperature spike at np-partition +seams, traced to the SLCN history `psi_star` being re-recorded by a rank-local +`evaluate` of the field **at its own (on-vertex) node coordinates**, which +mis-locates at a process boundary. Root cause is *hidden-variable transfer*, +not the φ-solve / remap of `T` / monotone clamp / implicit solve (all +exonerated by a long isolation; see the memory). + +**Goal:** move field transfer out of user code and into the adapt operation, +with a per-variable policy so hidden variables fail *safe* (transferred +needlessly) rather than *silently wrong* (stale). + +## 2. Transfer-semantics taxonomy — four kinds + +| policy | post-adapt value = | for | +|---|---|---| +| **REMAP** (default) | old field re-sampled at the new node positions | any Eulerian stored quantity; *all* DuDt history | +| **CARRY** | unchanged DOF value (belongs to node/material identity) | genuinely Lagrangian fields (nodes move *with* material) | +| **REINIT** | nothing — recomputed from source before next use | stateless work-vars (gradient/Hessian projections, RBF proxies) | +| **ALE** | CARRY + a lazy `v_mesh` correction (§6) | history of a material derivative (adv-diff, NS, VE), as an opt-in refinement of REMAP | + +**REMAP is the safe universal default** — literally "the previous values are +those at the original mesh points," correct to first order or better for any +Eulerian quantity. ALE is a refinement, never forced. + +### 2a. CRITICAL: DuDt history is REMAP, never REINIT + +`DuDt.psi_star` is **persistent, unrecoverable state**, not a recomputable +work-var. For order ≥ 2 the older terms (`psi_star[1]`, …) are *accumulated +upstream history* — each is the field traced back along the velocity field of +an **earlier** step. Once Stokes overwrites `V` with the new step's velocity +and the older solution is gone, those terms cannot be rebuilt. The +shift-history step (`psi_star[i] = φ·psi_star[i-1] + (1-φ)·psi_star[i]`, +ddt.py ~2035) reads **every** term, so **all** must be correctly transferred +before `update_pre_solve`. + +⇒ history terms get REMAP (or ALE). **Never REINIT.** REINIT is strictly for +variables that hold no memory and are recomputed from scratch each solve. + +The order-1 case *looks* recomputable (the re-record rebuilds `psi_star[0]` +from `u_Field`) — which is why the band-aid (§7) sufficed at θ=1 — but that is +a coincidence of order 1 and must not be generalised. + +## 3. Interface — per-variable policy + operator hook + +Two levels, because ALE/history transfer is not expressible per-variable +(it moves a field *and* its history coherently under one mesh velocity — +"T and Tdot together"): + +1. **`MeshVariable.remesh_policy`** — enum `REMAP` (default) / `CARRY` / + `REINIT` for standalone fields and the common case. The **framework** + stamps this on hidden vars it creates (projections/proxies → `REINIT`); + the **user** only flags their own `CARRY` fields. Default-unflagged ⇒ + REMAP ⇒ a forgotten variable fails safe. +2. **Operator `on_remesh(ctx)` hook** — solvers / `DuDt`s register one and + handle their *own* field+history set coherently (the SLCN `DuDt` decides + REMAP-all-history vs ALE-carry+`v_mesh`). Operator-managed vars are marked + so the generic per-variable pass defers to the hook. + +**The adapt op** (a new `mesh.adapt` / inside `smooth_mesh_interior` / +`OT_adapt` / `follow_metric`) becomes the single owner of the transfer dance +the harness does by hand today: + +``` +snapshot old coords + old field data (for REMAP/ALE vars) +move nodes to new_X (the mover; see §5) +for each registered var: apply remesh_policy + REMAP -> var.data = interp(old field) at new positions # robust, see §4 + CARRY -> leave + REINIT -> leave / mark stale (recomputed on next use) +for each registered operator: operator.on_remesh(ctx) # ALE etc. +``` + +`ctx` (a `RemeshContext`) carries: old coords, new coords, total displacement +`Δx`, a bound interpolator, `dt`, and a scratch slot where operators stash +`v_mesh`. + +### 3a. REMAP is robust by construction + +REMAP evaluates the **old** field at the **new** node positions. Those are +generic *interior* points of the old mesh, so `global_evaluate` locates them +fine — this is exactly the `T`-remap that already works and that FIXED_DEFORM +proved clean (np4-vs-serial 7e-4). It is **not** the degenerate "evaluate a +field at its own vertices" that the band-aid had to dodge (that is the +per-step re-record, a different operation). So the general REMAP does not +inherit the band-aid's on-vertex fragility. + +Implementation: snapshot old field on the old mesh; after the move, do the +proven *deform-back → evaluate-at-new → deform-forward* dance **once for all +REMAP vars together** (not per-var, not per-deform). + +## 4. Authority for the policy + +- `MeshVariable.remesh_policy` default = **REMAP**. +- The **framework** stamps the hidden vars it creates: + `Vector_Projection`/`Hessian`/gradient targets → `REINIT`; swarm proxies → + `REINIT` (re-projected from the swarm — but note §8 staleness gap). +- A `DuDt`/solver stamps its history vars and registers `on_remesh`. +- The **user** flags only their own Lagrangian fields → `CARRY`. + +## 5. Audit: deferring the update for multi-shot movers (OT n_outer≥12) + +Findings (smoothing.py, discretisation_mesh.py): + +- **`_deform_mesh` does NOT touch field data** (discretisation_mesh.py:2001). + It writes coords, calls `nuke_coords_and_rebuild`, rebuilds the `_coords` + view, sets every registered solver `is_setup=False`, invalidates the + evaluation hash + DMInterpolation cache, bumps `_topology_version`, syncs + submeshes. **Field `.data` is untouched.** ⇒ field transfer is *already* + one-shot and external to the mover; there is no per-inner-deform field + remap to eliminate. +- Each mover (`_winslow_elliptic` ~1570, `_winslow_equidistribute` ~1861, + `_winslow_anisotropic` ~2639) calls `_deform_mesh` **once per outer step** + (inside the `for outer in range(n_outer)` loop). So OT pays + `nuke_coords_and_rebuild` **×n_outer**. +- `nuke_coords_and_rebuild` (discretisation_mesh.py:1757) per call: + `clearDS`/`createDS`; `createCoordinateSpace` + `dm_force_coordinate_field`; + **kd-tree rebuild** (`_build_kd_tree_index`); `_get_mesh_sizes` (centroids / + radii / search_lengths); `copyDS`; invalidate normals + face control points; + and **refill the coord cache for every registered variable** + (`for _var in self.vars: self._get_coords_for_var(_var)`, line 1890). +- The mover's inner loop *consumes* the rebuilt state: it calls + `uw.function.evaluate(metric, Dcoords)` (needs the **kd-tree** of the + just-deformed mesh) and `solver.solve()` (needs the **DS**) every outer step. + ⇒ the kd-tree + DS rebuild are **genuinely needed per inner step** and + cannot be naively deferred. + +**So the deferral opportunity is narrower than "defer the rebuild":** + +- The **avoidable waste** is the per-variable coord-cache refill (line 1890): + it refills *all* registered vars (T, V, P, every `psi_star`, …) on every + inner deform, but the mover only needs its **own work-vars** (metric, φ, + gradients). Refilling the user fields' caches n_outer× is pure waste. +- That refill is a **parallel-correctness BUGFIX (#130)**: it was made eager + to avoid a subset-of-ranks collective deadlock in `_get_coords_for_basis` + (lazy rbf refill). So any deferral must keep collective access correct — + defer the *non-mover* vars' refill to a single full refill at sweep exit + (safe, since user fields aren't accessed mid-sweep), and refill only the + mover's work-vars inner-loop. + +**Options for point 2 (ranked):** + +1. **Scope the per-var refill (low risk, real win).** Give + `nuke_coords_and_rebuild` an optional "active vars" set; during a mover + sweep refill only the mover's work-vars; do one full refill at the end. + Saves `(n_outer−1) × n_user_vars` refills. No context manager strictly + needed — the mover already owns its sweep. +2. **Light vs full rebuild split.** Separate "geometry rebuild" (DS + coords, + needed per inner step) from "search/eval rebuild" (kd-tree, centroids, + per-var cache). But the mover's inner `evaluate(metric,…)` needs the + kd-tree, so this only helps if the metric eval is reworked to not need the + kd-tree (e.g. evaluate-at-own-nodes as a data read) — larger change. +3. **`mesh.batch_deform()` context manager.** Suppresses the non-mover-var + refill + defers a single full rebuild of the search/eval state to exit. + Cleanest API but must respect the #130 collective constraint and ensure no + stale kd-tree/centroid read happens mid-sweep (the mover *does* read them, + so "defer the kd-tree" is unsafe — the CM can only defer the per-var cache + + field-side state, i.e. option 1 wrapped in a CM). + +**Recommendation:** option 1 (scoped refill), optionally surfaced as a context +manager later. MA is one-shot so it is unaffected either way. + +## 6. ALE = CARRY + lazy `v_mesh` correction + +ALE leaves the history on its (now-moved) nodes — **CARRY, no re-interpolation, +no interpolation diffusion** — and corrects for the *arbitrary* mesh motion via +`v_mesh = Δx_total/dt` entering the **next** step's characteristic: the SL +trace-back runs along `(v − v_mesh)` so it samples the carried history at the +correct relative-upstream point. The correction is **lazy** (applied at the +next solve, not baked in at adapt) and `v_mesh` is a **one-step pulse** (zero +again until the next adapt). It must use the **total** displacement across the +whole mover sweep (`X_final − X_orig`), not per inner deform. + +- The `v_mesh` correction is exactly what distinguishes ALE from plain CARRY: + pure CARRY is only correct when nodes move *with the material*; a re-meshing + adapt is arbitrary motion, so the correction restores correctness. +- First-order equal to REMAP (built-in cross-check: validate ALE ≈ REMAP on a + smooth case, then trust it on the steep one), but avoids re-interpolating the + *unrecoverable* higher-order history each adapt — the whole point. +- Fits the deferred-update theme: CARRY is the zero-cost thing during the + sweep; the `v_mesh` correction is accumulated and applied once on consumption. + +**ALE is a DDt property, scoped to the advective flavors** (`SemiLagrangian`, +`Lagrangian`), **not** per-solver. The material-derivative + characteristic +logic lives in the DDt; put it there and it works uniformly for adv-diff, +Navier–Stokes, and VE-Stokes-via-`DFDt` with the solver doing nothing special. +The adapt op supplies `v_mesh`. The `Eulerian` DDt has no advection ⇒ REMAP. +A reset/re-mesh adapt (large discontinuous displacement) is not ALE-able ⇒ +REMAP. + +## 7. The band-aid (already landed) and its relationship to the true fix + +`ddt.py` `SemiLagrangian._record_psi_star_from_field_data()` + a guarded call: +under MPI, when `psi_fn` is a single mesh-variable component on this mesh, the +per-step "record current field into `psi_star[0]`" copies the field's nodal +data **directly** instead of evaluating it at its own (on-vertex) coords. +Serial keeps the validated shifted-evaluate path **bit-identical** (verified +6.66e-16); result: parallel adaptive adv-diff 0.137 → 6e-4. + +Relationship to the true fix: +- It is **orthogonal** to REMAP: it fixes the *per-step re-record*, not the + *adapt-time transfer*. **Keep it** — it is the correct parallel-safe + re-record, and it is what later lets the (recomputable, order-1) `psi_star[0]` + avoid a redundant REMAP. It is **order-1 only** (touches no `psi_star[1+]`); + higher order needs REMAP of the full history stack regardless. +- A cleanup later: consider promoting the direct nodal copy to the standard + re-record (un-gate from MPI) after re-validating serial — it is *more* + correct than the shifted-evaluate, not just a band-aid. + +## 8. Validation gaps / cautions to carry into the build + +- **Order-2 untested.** The harness only ran θ=1 / order-1, so higher-order + history transfer is unexercised. Build a 2nd-order adaptive case and verify + the full `psi_star` stack transfers (it is the load-bearing path for §2a). +- **Swarm proxy staleness.** `SwarmVariable._proxy_stale` (swarm.py) is a lazy + flag **not set by `_deform_mesh`**. Under adaptation a proxy `MeshVariable` + may read stale on the new mesh. Proxies should be `REINIT` (re-project from + the swarm) *and* the adapt must trigger `_proxy_stale = True` (or the + re-projection) — otherwise a different silent-staleness bug. +- **Vector/tensor DuDt** (NS / VE flux history) under parallel adaptation is + not covered by the band-aid and must be validated under Phase-1 REMAP. +- **Solver stability is paramount**: any change touching the DS rebuild / + solver assembly path needs the tier-A/B suite + a bit-identical serial probe. + +## 9. Phasing + +1. **Phase 1 — REMAP in the adapt op.** Policy enum + framework stamping + + adapt op owns snapshot/move/transfer (all REMAP, incl. full history stack). + Replaces the harness's manual transfer; makes parallel adaptation correct + for *all* hidden vars and all DDt orders. Keep the band-aid. Add the scoped + per-var refill (§5 option 1). Validate: parallel adapt sweep np4-vs-serial + (target ≤ no-adapt baseline), order-2 case, tier-A serial bit-identical. +2. **Phase 2 — ALE on the advective DDt.** `on_remesh` hook: CARRY history + + stash total-`Δx` `v_mesh` for the next `(v − v_mesh)` trace-back. Validate + against Phase-1 REMAP via the first-order agreement; benefits adv-diff + + VE-Stokes; removes the interpolation-diffusion cost on 12-shot OT. + +## File map + +| file | site | role | +|---|---|---| +| `discretisation/discretisation_mesh.py` | `_deform_mesh` @2001, `nuke_coords_and_rebuild` @1757 (per-var refill @1890), `vars` registry @3082 | coords/cache rebuild; scoped-refill change (§5) | +| `meshing/smoothing.py` | movers @1570/1861/2639 (per-outer `_deform_mesh`); `smooth_mesh_interior` @2683; `OT_adapt`, `follow_metric` | adapt op owns transfer; mover sweep refill scope | +| `systems/ddt.py` | `SemiLagrangian` (history @119, shift @2035, re-record @2085, band-aid `_record_psi_star_from_field_data`); flavors @98/108/119/146 | history policy = REMAP/ALE; `on_remesh` hook | +| `systems/solvers.py` | `AdvDiffusionSLCN`, NS, VE (`DuDt`/`DFDt` @997/1354) | use DDt; nothing solver-specific for ALE | +| `swarm.py` | `_proxy_stale` @309, `_update` @1019/2129 | proxy REINIT + adapt-triggered staleness (§8) | diff --git a/docs/developer/design/SOLVER_UNIFICATION_DESIGN.md b/docs/developer/design/SOLVER_UNIFICATION_DESIGN.md new file mode 100644 index 000000000..2d6f65dcb --- /dev/null +++ b/docs/developer/design/SOLVER_UNIFICATION_DESIGN.md @@ -0,0 +1,155 @@ +# Solver Unification Design + +> Status: **Implemented** (2026-04-28, branch `feature/exp-integrator-investigation`). +> The unification landed alongside Phase B of the exponential integrator — +> see `EXPONENTIAL_VE_INTEGRATOR.md`. ``VE_Stokes`` now emits a runtime +> ``DeprecationWarning`` on construction with a migration template; production +> callsites have been swept; the constitutive model declares its DDt needs via +> ``requires_stress_history`` (existing) plus ``stress_history_ddt_kwargs`` +> (new in Phase B — used by ``integrator='etd'`` to request +> ``with_forcing_history=True`` on the auto-DDt). The lazy-creation path in +> ``Stokes.constitutive_model.setter`` reads both. The text below is preserved +> as the design rationale for future readers. + +## Goal + +Eliminate the need for separate `VE_Stokes` and (future) `VE_NavierStokes` solver +classes. The constitutive model declares what infrastructure it needs; the solver +creates it lazily. + +## Current Architecture + +| Solver | DuDt (velocity) | DFDt (flux/stress) | Constitutive models | +|--------|-----------------|-------------------|-------------------| +| `Stokes` | — | — | Viscous, VP | +| `VE_Stokes` | — | SemiLagrangian (stress history) | VEP | +| `NavierStokes` | SemiLagrangian (velocity) | SemiLagrangian (AM flux) | Viscous, VP | +| `VE_NavierStokes` | does not exist | — | — | + +Problem: user must choose the correct solver class based on the constitutive model. +Using VEP on plain Stokes silently drops stress history (now caught by barrier in PR #95). + +## Proposed Architecture + +Two solver classes: `Stokes` and `NavierStokes`. Each detects whether the +constitutive model requires stress history and creates DFDt infrastructure lazily. +No separate VE variants needed. + +### Constitutive model contract + +```python +class Constitutive_Model: + @property + def requires_stress_history(self): + return False # Viscous, VP + +class ViscoElasticPlasticFlowModel(ViscousFlowModel): + @property + def requires_stress_history(self): + return True # VEP +``` + +### Solver behaviour + +```python +@constitutive_model.setter +def constitutive_model(self, model): + # ... existing setup ... + if model.requires_stress_history and self.Unknowns.DFDt is None: + self._create_stress_history_ddt(order=model.order) +``` + +### Constitutive model is assigned once + +Changing parameters (viscosity, yield stress, modulus) is fine — they flow through +UWexpressions and PetscDS constants[]. Swapping the constitutive model class after +the first solve is not supported (DFDt allocation, JIT structure changes). + +### VE_Stokes becomes a backward-compat alias + +```python +class VE_Stokes(Stokes): + """Deprecated: use Stokes directly with VEP constitutive model.""" + def __init__(self, mesh, order=2, **kwargs): + super().__init__(mesh, **kwargs) + self._create_stress_history_ddt(order=order) +``` + +### solve() hooks + +```python +def solve(self, timestep=None, ...): + if self.Unknowns.DFDt is not None: + if timestep is None: + raise ValueError("timestep required for viscoelastic solve") + self.constitutive_model._update_bdf_coefficients() + self.DFDt.update_pre_solve(timestep, store_result=False) + + # PETSc solve + self._snes_solve(...) + + if self.Unknowns.DFDt is not None: + self._post_solve_stress_history(timestep) +``` + +### tau property + +```python +@property +def tau(self): + if self.Unknowns.DFDt is not None: + return self.DFDt.psi_star[0] # stored actual stress + else: + return self._lazy_tau_projection() # on-demand projection +``` + +## NavierStokes Considerations + +NS already uses DFDt for Adams-Moulton (Crank-Nicolson) stabilisation of the +viscous flux. The AM scheme stores `η·∇u` at previous timesteps for flux averaging: + +$$F^{n+1/2} = \theta \cdot F^{n+1} + (1-\theta) \cdot F^{n*}$$ + +For VE-NS, the DFDt must serve both purposes: +- AM flux averaging for time integration stability +- VE stress history for the Maxwell constitutive law + +### Possible unification + +If DFDt stores the **actual deviatoric stress** (as PR #89 implements for VE_Stokes), +the AM scheme can be reformulated to read from it: + +$$F^{n+1/2} = \theta \cdot \sigma^{n+1} + (1-\theta) \cdot \sigma^{n*}$$ + +where σ^{n*} is the advected stress from `psi_star[0]`. This unifies the two uses: +the DFDt stores actual stress, and both AM stabilisation and VE constitutive law +read from the same history chain. + +### Open questions for VE-NS + +- Is order-1 AM sufficient alongside VE stress history? The VE history already + provides temporal accuracy for the elastic part; AM only needs to stabilise the + viscous/advection part. +- Can the AM flux and VE stress contributions simply be added symbolically in + the F0/F1 expressions? SymPy handles the algebra; the JIT compiles the combined + expression. This might avoid needing a separate DFDt entirely — the NS solver's + existing DFDt carries the AM flux, and the VE stress history is a separate DFDt + created by the constitutive model. +- This needs to be driven by physics requirements (a problem that demands VE-NS), + not implemented speculatively. + +### Recommendation + +Implement VE-NS only after: +1. VEP on Stokes is fully validated (current priority) +2. Solver unification for Stokes is complete and tested +3. NS benchmarks are passing as a baseline +4. A physics problem demands VE-NS + +## Implementation Order + +1. ~~PR #95: Barrier + is_viscoplastic fix~~ (done) +2. Move DFDt creation from VE_Stokes.__init__ to Stokes.constitutive_model setter +3. Move pre/post solve hooks from VE_Stokes.solve() to Stokes.solve() +4. VE_Stokes becomes backward-compat alias +5. Same pattern for NavierStokes (when physics demands it) diff --git a/docs/developer/design/VEP_TWO_STOKES_OPERATOR_SPLIT.md b/docs/developer/design/VEP_TWO_STOKES_OPERATOR_SPLIT.md new file mode 100644 index 000000000..7b49ccf9c --- /dev/null +++ b/docs/developer/design/VEP_TWO_STOKES_OPERATOR_SPLIT.md @@ -0,0 +1,127 @@ +# VEP Two-Stokes Operator Split — Investigation Plan + +> **Status**: planned, not implemented (2026-04-29). New investigation branching off the ETD integrator work (PR #161). Captures architectural context while it's fresh; first session of implementation will likely build the second-stage solver and run a comparison vs ETD-1 + in-residual yield (the production path that ships in PR #161). + +## Motivation + +The exponential-integrator investigation (Phase A–F, see `EXPONENTIAL_VE_INTEGRATOR.md`) eliminated several VE+plasticity strategies and converged on **ETD-1 + yield-in-residual softmin** as the production answer. That works because: + +1. ETD-1 is L-stable (advice §13 in this doc → `EXPONENTIAL_VE_INTEGRATOR.md` lesson #13). +2. The in-residual softmin yield is a coupled Newton solve where σ and u find each other through the residual. + +But the in-residual softmin has known imperfections: +* **Yield surface saturation** is approximate (softmin with finite δ allows σ to drift above τ_y, especially under variable Δt — see project memory `project_vep_variable_dt_yield_violation.md`). +* **Higher-order ETD-2** beats BDF-2 by 4× on smooth VE but blows up under in-residual yield in tight-yield regimes (lesson #11 — fundamental, not patchable). For *fully-VE problems* ETD-2 is shippable; for *VEP*, only ETD-1 is. +* **No clean predictor-corrector path**: the radial-return-after-VE-predictor pattern (Phase F) only worked with ETD-1, and adding it to ETD-2 didn't rescue ETD-2 (lesson: σ-damping in outer Picard isn't sufficient — need to update η_eff between iters too). + +The web-advice doc (`/Users/lmoresi/Downloads/vep_stress_update_full_latex.md`) prescribes the architecture that *is* the standard robust pattern in production geodynamics codes: + +``` +Stokes solve with lagged viscosity → VE exponential predictor → plastic return mapping → damped outer Picard iteration +``` + +What we built in Phase F was the **single-Stokes** version of this: one Stokes solve per Picard iter, σ damped between iters, η_eff fixed at η_VE. That's not the full architecture. The advice (and the user's framing in the conversation) is a **two-Stokes** operator split: + +> **Stage 1**: VE Stokes solve — find v, p, σ_VE assuming pure viscoelastic. +> **Stage 2**: plasticity Stokes solve where σ_VE is *fully explicit* (a known stress source) and viscosity plays the role of the plastic multiplier. + +Adding the second momentum-balanced solve is what's missing. With it: +* **ETD-2 might become viable for VEP+yield** because the second Stokes equilibrates the velocity field with the corrected stress field via a proper momentum solve, not just σ damping. +* **Pointwise plastic correction is exact** for J2 (closed-form radial return). The second Stokes restores momentum balance globally given the corrected stress field. +* **Anisotropic case (TI-VEP)** becomes a clean extension: stage 1 uses TI VE; stage 2 uses J2 (or Drucker–Prager) on the resolved fault-shear with the same momentum-balanced equilibration. + +## Architecture + +### Stage 1 (VE predictor) + +Standard Stokes with the existing `ViscoElasticPlasticFlowModel`: +* `integrator='etd', order=2` (or `order=1`) +* `yield_stress = ∞` (no in-residual yield) +* solves `∇·σ_VE - body_force - ∇p_VE = 0` + +After solve: `psi_star.array` holds σ_VE (the VE trial stress, the predictor's output). + +### Stage 2 (plasticity corrector — the new piece) + +A separate Stokes-like solver with: + +* **Constitutive**: pure viscous, `σ_pl = 2η_pl(x)·ε̇(v_pl)`. The `ViscousFlowModel` works for this; `Parameters.shear_viscosity_0` is set to a meshvar field. +* **Body force**: `−∇·σ_VE` so the total stress satisfies momentum: `∇·(σ_VE + 2η_pl·ε̇(v_pl)) = body_force_external`. + * Computed symbolically from `psi_star.sym` — UW3 supports `mesh.vector.divergence(rank-2 sym)` or equivalent. + * The bodyforce expression goes into `stokes_pl.bodyforce`. +* **Effective plastic viscosity** `η_pl(x)`: + * **Interpretation (a) — coupled solve**: `η_pl` is determined implicitly so that `|σ_total|_eq ≤ σ_y` everywhere with equality where yielded. Solver iterates Newton on this; `η_pl` is a non-linear function of v_pl. This is the rigorous form, equivalent to a yield-aware viscosity in stage 2. + * **Interpretation (b) — explicit**: compute `η_pl` from stage-1 data: `η_pl = σ_y/(2|γ̇_VE|)` where `|σ_VE|_eq > σ_y`, large value otherwise. Stage 2 is a *linear* viscous solve. Outer Picard iterates over (a) or (b) blend until consistent. + +Phase F tested neither (a) nor (b) properly — it iterated σ via single-Stokes Picard with `η_eff = η_VE` fixed, which doesn't add momentum-balanced equilibration. The first session of this investigation should test (b) as the simpler scaffold; (a) requires either a yield-aware constitutive law for `ViscousFlowModel` or Newton-iterating the linear-viscous-with-spatial-η problem. + +After Stage 2: corrected velocity `v` and stress `σ_total = σ_VE + 2η_pl·ε̇(v_pl)` (or `v_total = v_VE + v_pl` depending on framing). + +### Outer iteration + +Wrap the two stages in a Picard loop with η damping (advice §9, ω_η ≈ 0.3) and σ damping (§10, ω_τ ≈ 0.5): + +``` +for k = 1, 2, ...: + Stage 1 with η_eff_k from previous iter → σ_VE^k + Compute η_pl^k from σ_VE^k (interpretation b) or + Stage 2 (linear or nonlinear) with η_pl^k → v_pl^k, p_pl^k + Damp: + σ ← (1-ω_τ)·σ_old + ω_τ·σ_total^k + η ← (1-ω_η)·η_old + ω_η·η_pl^k + Convergence check: ||σ_k - σ_{k-1}|| / ||σ_k|| < tol +``` + +Within a single timestep. After convergence, `psi_star ← σ_total` (or σ_VE — design choice). + +## Implementation challenges in UW3 + +1. **Two Stokes objects on the same mesh.** Each wants its own velocity/pressure meshvar. Currently the codebase has one Stokes per setup; we'd build a separate `Stokes(mesh, velocityField=v_pl, pressureField=p_pl)` and configure its constitutive model independently. + +2. **Spatial η_pl as a meshvar in the constitutive law.** `ViscousFlowModel.Parameters.shear_viscosity_0 = eta_pl_field.sym` should work — model uses the symbolic expression. Need to verify the JIT path handles a meshvar reference correctly (it does for σ_y already). + +3. **Body force = −∇·σ_VE**. `psi_star` is a SYM_TENSOR meshvar. Its symbolic divergence is `sympy.diff(psi_star.sym[i, j], coord_j)` summed over j. The bodyforce expression assembles to a vector. Cost: per-quadrature-point evaluation of three `sympy.diff` expressions on a tensor field — should be cheap. + +4. **What stays in psi_star at end of step.** Three options: + * `σ_total = σ_VE + 2η_pl·ε̇(v_pl)` — full corrected stress. Best for next-step ETD history term `α·σⁿ`. + * `σ_VE` — the VE-only stress. Cleaner separation but loses plasticity history. + * Apply final return-mapping cleanup on `σ_VE + 2η_pl·ε̇(v_pl)` to enforce yield exactly. + +5. **Velocity composition.** Does `v_pl` *replace* v_VE or *add* to it? If stage 2 has body force `−∇·σ_VE`, the `v_pl` from stage 2 is the velocity that, combined with `σ_VE` as "baseline stress", balances momentum. So `v_pl` IS the corrected velocity field at end of step (not v_VE + v_pl). For Lagrangian advection, use `v_pl`. + +6. **Boundary conditions.** Stage 2 inherits the same BCs as stage 1 (kinematic boundary conditions on v_pl). The VE stress σ_VE on the boundary is consistent with v_VE; stage 2 finds v_pl satisfying BCs and the plastic-balanced momentum. + +## Validation plan + +Reuse the Phase F harness (isotropic VEP, localised weak zone, harmonic loading). Compare: + +* **BDF-1 yield-in-residual** — production reference (already validated). +* **ETD-1 + two-Stokes operator split (interpretation b)** — does it match BDF-1? +* **ETD-2 + two-Stokes operator split** — does the second momentum solve rescue ETD-2 from the drift seen in Phase F? *This is the headline test.* + +If the answer is yes for ETD-2: we have a robust path to the higher-accuracy integrator for VEP+yield, and the path to TI-VEP fault mechanics is open. + +If no: the residual drift mechanism is structural beyond two-Stokes equilibration, and ETD-1 + softmin yield-in-residual remains the right answer. + +## Code organisation + +Suggested new files (on a branch off `development` after PR #161 merges): + +``` +docs/developer/design/_phase_g_two_stokes.py # runner with stage 1 + stage 2 + outer Picard +docs/developer/design/_plot_phase_g.py # comparison plot, includes Phase F traces +docs/developer/design/_phase_g_*.trace.txt # per-step traces +docs/developer/design/VEP_TWO_STOKES_OPERATOR_SPLIT.md # this document +``` + +No production-API changes expected unless the architecture proves itself for VEP+yield — in which case the second-stage solver might land as a new helper or a method on `ViscoElasticPlasticFlowModel`. + +## Connecting back + +The user's framing closing the ETD investigation: *"the radial return, correctly computed as a sequence of solves ... offers the potential for a very robust VEP solver"*. That's exactly what this branch tests. Reference points: + +* `EXPONENTIAL_VE_INTEGRATOR.md` lesson #13 — first-order dissipation explanation +* Phase F results — what radial return alone (without two-Stokes) achieves and where it fails +* Web advice `/Users/lmoresi/Downloads/vep_stress_update_full_latex.md` §3, §11, §15 — the canonical predictor-corrector + outer Picard architecture + +The two-Stokes investigation is the bridge between the ETD work and a production-quality VEP+yield solver. diff --git a/docs/developer/design/_exp_integrator_phase_a.py b/docs/developer/design/_exp_integrator_phase_a.py new file mode 100644 index 000000000..9e8e4adee --- /dev/null +++ b/docs/developer/design/_exp_integrator_phase_a.py @@ -0,0 +1,216 @@ +"""Phase A — 1D linear Maxwell exponential integrator validator. + +Engineering form throughout: σ̇ + σ/τ = μ γ̇ (γ̇ engineering shear rate). +Steady-state under constant γ̇ → σ = η γ̇. Compare: + - Exponential integrator (proposed) + - BDF-1 + - BDF-2 (constant Δt) + - Analytical reference + +Forcings: + - sinusoidal: ε̇ = γ̇₀ cos(ωt) → analytical Maxwell phasor + - square-wave: ε̇ = ±γ̇₀ → piecewise exponential + +Sweep Δt/τ from 0.01 to 10. Output: max|err|, RMS, behaviour at large dt. +""" + +import numpy as np +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import os + + +# ── Parameters (η = μ = 1 for clarity) ───────────────────────────── +ETA = 1.0; MU = 1.0; TAU = ETA / MU # relaxation time = 1 +GAMMA_DOT_0 = 1.0 +T_END = 8 * TAU + + +# ── Analytical references ────────────────────────────────────────── + +def maxwell_sin(t, omega, gamma_dot_0): + """σ(t) for ε̇(t) = γ̇₀ cos(ωt), σ(0) = 0.""" + De = omega * TAU + A_inf = ETA * gamma_dot_0 / np.sqrt(1 + De**2) + phi = np.arctan(De) + # σ_ss(t) = A_∞ cos(ωt - φ); transient = -A_∞ cos(-φ) e^(-t/τ) + sigma_ss = A_inf * np.cos(omega * t - phi) + transient = -A_inf * np.cos(-phi) * np.exp(-t / TAU) + return sigma_ss + transient + + +def maxwell_square(t, half_period, gamma_dot_0): + """σ(t) for square-wave γ̇, σ(0) = 0. σ_start_n = value at start + of period n; updated period-by-period by relaxing toward target.""" + sigma_ss = ETA * gamma_dot_0 + out = np.zeros_like(t) + decay_full = np.exp(-half_period / TAU) + n_prev = -1 + sigma_start = 0.0 # σ at start of period 0 + for i, ti in enumerate(t): + n = int(ti // half_period) + # Advance sigma_start to start-of-period-n if we crossed boundaries + while n_prev < n - 1: + n_prev += 1 + sign = 1.0 if n_prev % 2 == 0 else -1.0 + target = sign * sigma_ss + sigma_start = target + (sigma_start - target) * decay_full + n_prev = n + sign = 1.0 if n % 2 == 0 else -1.0 + target = sign * sigma_ss + t_local = ti - n * half_period + out[i] = target + (sigma_start - target) * np.exp(-t_local / TAU) + return out + + +# ── Integrators ──────────────────────────────────────────────────── + +def exp_integrator(gdot_n, gdot_np1, sigma_n, dt): + """One step of σⁿ⁺¹ = α σⁿ + μ(A γ̇ⁿ⁺¹ + B γ̇ⁿ). Engineering form.""" + x = dt / TAU + alpha = np.exp(-x) + phi = (1 - alpha) / x if x > 1e-12 else 1.0 - x/2 + x*x/6 + A = TAU * (1 - phi) + B = TAU * (phi - alpha) + return alpha * sigma_n + MU * (A * gdot_np1 + B * gdot_n) + + +def bdf1_step(gdot_np1, sigma_n, dt): + """Backward Euler: σⁿ⁺¹ = (σⁿ + μΔt γ̇ⁿ⁺¹) / (1 + Δt/τ).""" + return (sigma_n + MU * dt * gdot_np1) / (1 + dt / TAU) + + +def bdf2_step(gdot_np1, sigma_n, sigma_nm1, dt): + """BDF-2 (constant dt): σⁿ⁺¹ (3/(2Δt) + 1/τ) = (4σⁿ - σⁿ⁻¹)/(2Δt) + μ γ̇ⁿ⁺¹""" + lhs = 1.5 / dt + 1.0 / TAU + rhs = (2 * sigma_n - 0.5 * sigma_nm1) / dt + MU * gdot_np1 + return rhs / lhs + + +# ── Run a forcing through each integrator ────────────────────────── + +def run_sinusoidal(omega, dt): + """Return (t, σ_exp, σ_bdf1, σ_bdf2, σ_ana).""" + t = np.arange(0.0, T_END + 1e-12, dt) + eps = GAMMA_DOT_0 * np.cos(omega * t) + + sig_exp = np.zeros_like(t) + sig_b1 = np.zeros_like(t) + sig_b2 = np.zeros_like(t) + + for i in range(1, len(t)): + sig_exp[i] = exp_integrator(eps[i-1], eps[i], sig_exp[i-1], dt) + sig_b1[i] = bdf1_step(eps[i], sig_b1[i-1], dt) + if i == 1: + # BDF-2 startup: do BDF-1 for the very first step + sig_b2[i] = bdf1_step(eps[i], sig_b2[i-1], dt) + else: + sig_b2[i] = bdf2_step(eps[i], sig_b2[i-1], sig_b2[i-2], dt) + + sig_ana = maxwell_sin(t, omega, GAMMA_DOT_0) + return t, sig_exp, sig_b1, sig_b2, sig_ana + + +def run_square(half_period, dt): + t = np.arange(0.0, T_END + 1e-12, dt) + # sign flips at integer multiples of half_period + n_period = (t // half_period).astype(int) + eps_at = GAMMA_DOT_0 * np.where(n_period % 2 == 0, 1.0, -1.0) + # ε̇ at step boundaries: take the value at t (right-continuous) + + sig_exp = np.zeros_like(t) + sig_b1 = np.zeros_like(t) + sig_b2 = np.zeros_like(t) + + for i in range(1, len(t)): + sig_exp[i] = exp_integrator(eps_at[i-1], eps_at[i], sig_exp[i-1], dt) + sig_b1[i] = bdf1_step(eps_at[i], sig_b1[i-1], dt) + if i == 1: + sig_b2[i] = bdf1_step(eps_at[i], sig_b2[i-1], dt) + else: + sig_b2[i] = bdf2_step(eps_at[i], sig_b2[i-1], sig_b2[i-2], dt) + + sig_ana = maxwell_square(t, half_period, GAMMA_DOT_0) + return t, sig_exp, sig_b1, sig_b2, sig_ana + + +def errors(sig, sig_ana): + err = np.abs(sig - sig_ana) + return float(err.max()), float(np.sqrt((err**2).mean())) + + +def main(): + out_dir = os.path.dirname(os.path.abspath(__file__)) + + # Sinusoidal sweep over Δt/τ + omega = np.pi / 2 # period 4τ + dt_ratios = [0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0] + print("\n=== Sinusoidal ε̇(t) = cos(πt/2), T = 8τ, η = μ = τ = 1 ===") + print(f"{'Δt/τ':>6} | {'exp max|err|':>12} {'bdf1 max':>10} {'bdf2 max':>10} | " + f"{'exp rms':>10} {'bdf1 rms':>10} {'bdf2 rms':>10}") + print("-" * 90) + rows = [] + for r in dt_ratios: + dt = r * TAU + if dt > T_END / 4: + continue + t, se, s1, s2, sa = run_sinusoidal(omega, dt) + em = errors(se, sa); e1 = errors(s1, sa); e2 = errors(s2, sa) + print(f"{r:>6.3g} | {em[0]:>12.3e} {e1[0]:>10.3e} {e2[0]:>10.3e} | " + f"{em[1]:>10.3e} {e1[1]:>10.3e} {e2[1]:>10.3e}") + rows.append((r, dt, em[0], e1[0], e2[0], em[1], e1[1], e2[1])) + + # Square-wave (just one dt — focus on flip handling) + half_period = 2 * TAU + print("\n=== Square-wave (half-period = 2τ) ===") + for dt in (0.05, 0.1, 0.2, 0.5): + t, se, s1, s2, sa = run_square(half_period, dt) + em = errors(se, sa); e1 = errors(s1, sa); e2 = errors(s2, sa) + print(f" dt = {dt:>4.2f}: exp max={em[0]:.3e} bdf1 max={e1[0]:.3e} " + f"bdf2 max={e2[0]:.3e}") + + # Plot the dt-sweep convergence + rs = np.array([row[0] for row in rows]) + em_max = np.array([row[2] for row in rows]) + e1_max = np.array([row[3] for row in rows]) + e2_max = np.array([row[4] for row in rows]) + + fig, (ax_l, ax_r) = plt.subplots(1, 2, figsize=(11, 4.5)) + + # Left: convergence + ax_l.loglog(rs, em_max, 'o-', label='Exponential', color='C0') + ax_l.loglog(rs, e1_max, 's-', label='BDF-1', color='C1') + ax_l.loglog(rs, e2_max, '^-', label='BDF-2', color='C2') + # Reference slopes + ax_l.loglog(rs, 1e-3 * rs / rs[0], 'k:', alpha=0.4, label='slope 1') + ax_l.loglog(rs, 1e-4 * (rs / rs[0])**2, 'k--', alpha=0.4, label='slope 2') + ax_l.set_xlabel(r'$\Delta t / \tau$') + ax_l.set_ylabel(r'max $|\sigma_{\rm sim} - \sigma_{\rm ana}|$') + ax_l.set_title('Sinusoidal forcing — dt convergence') + ax_l.grid(True, which='both', alpha=0.3) + ax_l.legend(fontsize=9) + + # Right: trace at large dt (1.0 if we have it) + if 1.0 in rs: + idx = list(rs).index(1.0) + dt = TAU * 1.0 + t, se, s1, s2, sa = run_sinusoidal(omega, dt) + ax_r.plot(t, sa, 'k-', label='analytical', linewidth=1.5) + ax_r.plot(t, se, 'o-', label='Exponential', color='C0', markersize=4) + ax_r.plot(t, s1, 's-', label='BDF-1', color='C1', markersize=4) + ax_r.plot(t, s2, '^-', label='BDF-2', color='C2', markersize=4) + ax_r.set_xlabel(r'$t / \tau$') + ax_r.set_ylabel(r'$\sigma$') + ax_r.set_title(rf'Trace at $\Delta t/\tau = 1$ (= 1/4 period)') + ax_r.grid(True, alpha=0.3) + ax_r.legend(fontsize=9) + + fig.tight_layout() + fig_path = os.path.join(out_dir, "exp_integrator_phase_a.png") + fig.savefig(fig_path, dpi=140) + print(f"\nWrote {fig_path}") + + +if __name__ == "__main__": + main() diff --git a/docs/developer/design/_exp_integrator_phase_b_benches.py b/docs/developer/design/_exp_integrator_phase_b_benches.py new file mode 100644 index 000000000..49d488e34 --- /dev/null +++ b/docs/developer/design/_exp_integrator_phase_b_benches.py @@ -0,0 +1,279 @@ +"""Phase B benchmark suite for MaxwellExponentialFlowModel. + +Runs the four required Phase B benches with the new ETD-2 model and +prints comparison against the BDF baselines from the design doc: + + 1. ve_harmonic — peak-start harmonic, BDF-2 baseline 1.34e-3 + 2. ve_square — square wave, BDF-2 baseline ≈ 0.5e-2 (wider gap) + 3. vep_square (Min) — yield-active square wave, peak |σ| ≤ 1.001·τ_y + 4. ve_square_vardt — variable Δt around BC flips + +A small companion script ``_exp_integrator_phase_b_validate.py`` runs +just bench 1 (the harmonic) — kept separate as the primary smoke test. + +Run:: + + pixi run -e amr-dev python -u docs/developer/design/_exp_integrator_phase_b_benches.py +""" + +from __future__ import annotations + +import time +import sys +import numpy as np +import sympy + +import underworld3 as uw +from underworld3 import VarType +from underworld3.function import expression + + +# ───────────────────────────────────────────────────────────────────── +# Helpers (mirror docs/advanced/benchmarks/_bench_helpers.py) +# ───────────────────────────────────────────────────────────────────── + +DEFAULT_PARAMS = dict( + eta=1.0, mu=1.0, H=1.0, W=2.0, + elementRes=(16, 8), velocity_degree=2, pressure_degree=1, +) + + +def t_relax(p): + return p["eta"] / p["mu"] + + +def maxwell_square_wave(t, eta, mu, gamma_dot_0, half_period): + """Closed-form Maxwell square-wave response, σ(0) = 0.""" + sigma_ss = eta * gamma_dot_0 + tr = eta / mu + sigma = np.zeros_like(t) + sigma_at_t0 = 0.0 + for n in range(int(np.ceil(t.max() / half_period)) + 1): + s_n = 1.0 if n % 2 == 0 else -1.0 + t0 = n * half_period + in_window = (t >= t0 - 1e-12) & (t < t0 + half_period + 1e-12) + sigma[in_window] = ( + s_n * sigma_ss + + (sigma_at_t0 - s_n * sigma_ss) * np.exp(-(t[in_window] - t0) / tr) + ) + sigma_at_t0 = ( + s_n * sigma_ss + + (sigma_at_t0 - s_n * sigma_ss) * np.exp(-half_period / tr) + ) + return sigma + + +def vep_square_wave(t, eta, mu, gamma_dot_0, tau_y, half_period): + """Closed-form yield-clipped square-wave response.""" + sigma_ss = eta * gamma_dot_0 + tr = eta / mu + sigma = np.zeros_like(t) + sigma_at_t0 = 0.0 + for n in range(int(np.ceil(t.max() / half_period)) + 1): + s_n = 1.0 if n % 2 == 0 else -1.0 + t0 = n * half_period + in_window = (t >= t0 - 1e-12) & (t < t0 + half_period + 1e-12) + raw = ( + s_n * sigma_ss + + (sigma_at_t0 - s_n * sigma_ss) * np.exp(-(t[in_window] - t0) / tr) + ) + sigma[in_window] = np.clip(raw, -tau_y, tau_y) + raw_end = ( + s_n * sigma_ss + + (sigma_at_t0 - s_n * sigma_ss) * np.exp(-half_period / tr) + ) + sigma_at_t0 = float(np.clip(raw_end, -tau_y, tau_y)) + return sigma + + +# ───────────────────────────────────────────────────────────────────── +# Builder for an exp-integrator Stokes problem +# ───────────────────────────────────────────────────────────────────── + +def build_stokes_exp(label, params, yield_stress=None, yield_mode="min"): + """Plain Stokes + MaxwellExponentialFlowModel (auto-DDt with forcing_star).""" + p = dict(params) + mesh = uw.meshing.StructuredQuadBox( + elementRes=p["elementRes"], + minCoords=(-p["W"] / 2.0, -p["H"] / 2.0), + maxCoords=(p["W"] / 2.0, p["H"] / 2.0), + ) + v = uw.discretisation.MeshVariable(f"U_{label}", mesh, mesh.dim, degree=p["velocity_degree"]) + pp = uw.discretisation.MeshVariable(f"P_{label}", mesh, 1, degree=p["pressure_degree"]) + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=pp) + stokes.constitutive_model = uw.constitutive_models.MaxwellExponentialFlowModel + cm = stokes.constitutive_model + cm.Parameters.shear_viscosity_0 = p["eta"] + cm.Parameters.shear_modulus = p["mu"] + if yield_stress is not None: + cm.Parameters.yield_stress = yield_stress + cm._yield_mode = yield_mode + cm.Parameters.strainrate_inv_II_min = 1.0e-6 + + V_top = expression(rf"V_{{top,{label}}}", sympy.Float(0.0), "Top V") + stokes.add_dirichlet_bc((V_top, 0.0), "Top") + stokes.add_dirichlet_bc((-V_top, 0.0), "Bottom") + stokes.add_dirichlet_bc((sympy.oo, 0.0), "Left") + stokes.add_dirichlet_bc((sympy.oo, 0.0), "Right") + stokes.tolerance = 1.0e-6 + stokes.petsc_options["snes_force_iteration"] = True + + return mesh, stokes, V_top, p + + +def probe_centre(stokes, c=np.array([[0.0, 0.0]])): + return float(uw.function.evaluate(stokes.tau.sym[0, 1], c).flatten()[0]) + + +# ───────────────────────────────────────────────────────────────────── +# Benchmarks +# ───────────────────────────────────────────────────────────────────── + +def bench_ve_harmonic_exp(): + V0 = 0.5 + OMEGA = np.pi / 2.0 + DT = 0.05 + N_PERIODS = 4 + T_END = N_PERIODS * 2.0 * np.pi / OMEGA + + params = dict(DEFAULT_PARAMS) + mesh, stokes, V_top, params = build_stokes_exp("ve_harm_exp", params) + cm = stokes.constitutive_model + DFDt = stokes.Unknowns.DFDt + + t_r = params["eta"] / params["mu"] + De = OMEGA * t_r + gamma_dot_0 = 2.0 * V0 / params["H"] + A_inf = params["eta"] * gamma_dot_0 / np.sqrt(1.0 + De ** 2) + phi_lag = float(np.arctan(De)) + + n_nodes = DFDt.psi_star[0].array.shape[0] + sigma0 = np.zeros((n_nodes, 2, 2)) + sigma0[:, 0, 1] = A_inf + sigma0[:, 1, 0] = A_inf + DFDt.set_initial_history([sigma0], dt=DT) + + edot0 = gamma_dot_0 / (2.0 * np.sqrt(1.0 + De ** 2)) + f0 = np.zeros((n_nodes, 2, 2)) + f0[:, 0, 1] = edot0 + f0[:, 1, 0] = edot0 + DFDt.forcing_star.array[...] = f0 + + times, sigmas = [], [] + t_cur = 0.0 + t0 = time.time() + while t_cur < T_END - 1e-9: + dt = min(DT, T_END - t_cur) + t_end = t_cur + dt + v_now = V0 * float(np.cos(OMEGA * t_end + phi_lag)) + V_top.sym = sympy.Float(v_now) + cm.Parameters.dt_elastic = dt + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=2) + sigmas.append(probe_centre(stokes)) + times.append(t_end) + t_cur = t_end + times = np.array(times); sigmas = np.array(sigmas) + sigma_ana = A_inf * np.cos(OMEGA * times) + err = np.abs(sigmas - sigma_ana) + return dict( + label="ve_harmonic", times=times, sigma=sigmas, sigma_ana=sigma_ana, + max_err=float(err.max()), rms=float(np.sqrt((err ** 2).mean())), + wall=time.time() - t0, + ) + + +def _square_run(label, yield_stress=None, yield_mode="min"): + V0 = 0.5 + HALF_PERIOD = 2.0 + N_PERIODS = 4 + DT = 0.10 + T_END = N_PERIODS * 2.0 * HALF_PERIOD + + params = dict(DEFAULT_PARAMS) + mesh, stokes, V_top, params = build_stokes_exp( + label, params, yield_stress=yield_stress, yield_mode=yield_mode + ) + cm = stokes.constitutive_model + + times, sigmas = [], [] + t_cur = 0.0 + t0 = time.time() + while t_cur < T_END - 1e-9: + dt = min(DT, T_END - t_cur) + n_half = int((t_cur + 0.5 * dt) / HALF_PERIOD) + sign = 1.0 if n_half % 2 == 0 else -1.0 + v_now = sign * V0 + V_top.sym = sympy.Float(v_now) + cm.Parameters.dt_elastic = dt + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=2) + sigmas.append(probe_centre(stokes)) + t_cur += dt + times.append(t_cur) + times = np.array(times); sigmas = np.array(sigmas) + gamma_dot_0 = 2.0 * V0 / params["H"] + if yield_stress is None: + sigma_ana = maxwell_square_wave(times, params["eta"], params["mu"], gamma_dot_0, HALF_PERIOD) + else: + sigma_ana = vep_square_wave(times, params["eta"], params["mu"], + gamma_dot_0, yield_stress, HALF_PERIOD) + err = np.abs(sigmas - sigma_ana) + return dict( + label=label, times=times, sigma=sigmas, sigma_ana=sigma_ana, + max_err=float(err.max()), rms=float(np.sqrt((err ** 2).mean())), + peak_abs_sigma=float(np.abs(sigmas).max()), + wall=time.time() - t0, + ) + + +def bench_ve_square_exp(): + return _square_run("ve_square_exp") + + +def bench_vep_square_exp(tau_y=0.5, yield_mode="softmin"): + """VEP square-wave benchmark — defaults to softmin yield_mode for SNES robustness. + + Min mode (sharp Newton kink) leads to ``DIVERGED_LINE_SEARCH`` for the + new exp model under this setup; softmin gives a smooth derivative at + the yield surface and converges robustly. + """ + res = _square_run(f"vep_square_exp_{yield_mode}", yield_stress=tau_y, yield_mode=yield_mode) + res["tau_y"] = tau_y + res["yield_mode"] = yield_mode + return res + + +# ───────────────────────────────────────────────────────────────────── +# Driver +# ───────────────────────────────────────────────────────────────────── + +def main(): + runs = [] + for fn, label in [ + (bench_ve_harmonic_exp, "ve_harmonic"), + (bench_ve_square_exp, "ve_square"), + (bench_vep_square_exp, "vep_square_min"), + ]: + print(f"\n=== {label} (ETD-2) ===", flush=True) + try: + res = fn() + print(f" steps={len(res['times'])} wall={res['wall']:.1f}s") + print(f" max|err|={res['max_err']:.4e} rms={res['rms']:.4e}") + if "peak_abs_sigma" in res: + print(f" peak|σ|={res['peak_abs_sigma']:.4f}") + if "tau_y" in res: + over = int((np.abs(res["sigma"]) > 1.001 * res["tau_y"]).sum()) + print(f" τ_y={res['tau_y']:.4f} over_count={over}/{len(res['sigma'])}") + runs.append(res) + except Exception as e: + import traceback; traceback.print_exc() + print(f" FAILED: {type(e).__name__}: {e}") + runs.append(None) + print("\n=== Summary ===") + print("Baselines (BDF-2, from design doc):") + print(" ve_harmonic max|err|=1.34e-3") + print(" ve_square max|err|=~5e-3") + print(" vep_square (Min) peak|σ|≤1.001·τ_y, BDF-2 over_count=0 once snapshot fix landed") + + +if __name__ == "__main__": + main() diff --git a/docs/developer/design/_exp_integrator_phase_b_eval.py b/docs/developer/design/_exp_integrator_phase_b_eval.py new file mode 100644 index 000000000..16b12cc9d --- /dev/null +++ b/docs/developer/design/_exp_integrator_phase_b_eval.py @@ -0,0 +1,500 @@ +"""Phase B evaluation — exponential integrator under VEP, yield-active. + +Two questions to answer numerically before committing to the UW3 +``MaxwellExponentialFlowModel`` design: + + 1. Does the exponential integrator + softmin yield (lagged-τ) handle + a sub-/super-yield harmonic problem cleanly? Compare against BDF-1 + (the current safe choice for fault problems). + + 2. At Δt/τ ≥ 1 — the regime where BDF-1/2 collapse to no-amplitude + output — does the exponential integrator give a physically + meaningful answer? This is the most interesting regime for + mantle/lithosphere coupling where τ can be small. + +Engineering form throughout: σ̇ + σ/τ = μ γ̇. Steady viscous limit +σ → η γ̇. Yield surface: |σ| ≤ τ_y. + +The "VEP" treatment here uses **lagged-τ**: each step uses τ from the +previous step's η_eff (= softmin(η_ve_exp, η_pl)). η_pl is the +Drucker-Prager-style instantaneous limiter. +""" + +import numpy as np +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import os + + +# ── Parameters ───────────────────────────────────────────────────── +ETA = 1.0; MU = 1.0 +TAU_VE = ETA / MU # = 1 +GAMMA_DOT_0 = 1.0 +T_END = 16 * TAU_VE + + +# ── softmin yield (matches UW3's formula at δ=0.1) ───────────────── + +def eta_eff_softmin(eta_ve, eta_pl, delta=0.1): + """η_eff = η_ve / g(f) where f = η_ve/η_pl, + g(f) = 1 + softplus(f-1) - softplus(-1) = 1 + (f-1+sqrt((f-1)²+δ²))/2 - offset + This is a smooth approximation to min(η_ve, η_pl).""" + f = eta_ve / eta_pl + offset = (-1 + np.sqrt(1 + delta**2)) / 2 + g = 1 + (f - 1 + np.sqrt((f - 1)**2 + delta**2)) / 2 - offset + return eta_ve / g + + +def eta_pl_DP(sigma, gamma_dot, tau_y, eps_min=1e-6): + """Drucker-Prager-like plastic viscosity: τ_y = η_pl · γ̇ → η_pl = τ_y/|γ̇|. + Use |γ̇| including a tiny floor to avoid 1/0 at γ̇=0.""" + return tau_y / (abs(gamma_dot) + eps_min) + + +# ── Integrators (engineering Maxwell, optional yield) ────────────── + +def step_exp_VEP(sigma_n, gdot_n, gdot_np1, dt, tau_y=None, + tau_prev=TAU_VE, delta=0.1): + """One step of the exponential integrator with optional yield clip. + + For the prototype: predictor-corrector return mapping. + 1. Predict σ_pred via pure VE exponential update + 2. If |σ_pred| > τ_y: smoothly clip via softmin so |σ| → τ_y + 3. Update τ for next step based on (yielded or not) + """ + x = dt / tau_prev + alpha = np.exp(-x) + phi = (1 - alpha) / x if x > 1e-12 else 1.0 - x/2 + x*x/6 + A = tau_prev * (1 - phi) + B = tau_prev * (phi - alpha) + sigma_pred = alpha * sigma_n + MU * (A * gdot_np1 + B * gdot_n) + if tau_y is None: + return sigma_pred, ETA / MU # pure VE, full elastic τ + # Smooth return-mapping clip via softmin on |σ|/τ_y + # f = |σ_pred|/τ_y. If f<1, no change. If f>1, scale toward τ_y. + f = abs(sigma_pred) / tau_y + if f <= 1.0: + return sigma_pred, ETA / MU # below yield, full elastic relaxation + offset = (-1 + np.sqrt(1 + delta**2)) / 2 + g = 1 + (f - 1 + np.sqrt((f - 1)**2 + delta**2)) / 2 - offset + sigma_clipped = sigma_pred / g + # During yield, effective relaxation time τ = η_pl/μ. + # η_pl ≈ τ_y/|γ̇| (Drucker-Prager). Use γ̇ⁿ⁺¹ for the lagged update. + eta_pl = tau_y / (abs(gdot_np1) + 1e-6) + tau_new = max(eta_pl / MU, 1e-3) # floor to avoid α→1 numerical issues + return sigma_clipped, tau_new + + +def step_bdf1_VEP(sigma_n, gdot_np1, dt, tau_y=None, delta=0.1): + """BDF-1 with softmin return-mapping yield (parallel to step_exp_VEP).""" + sigma_pred = (sigma_n + MU * dt * gdot_np1) / (1 + dt / TAU_VE) + if tau_y is None: + return sigma_pred + f = abs(sigma_pred) / tau_y + if f <= 1.0: + return sigma_pred + offset = (-1 + np.sqrt(1 + delta**2)) / 2 + g = 1 + (f - 1 + np.sqrt((f - 1)**2 + delta**2)) / 2 - offset + return sigma_pred / g + + +# ── Square-wave analyticals and tests ────────────────────────────── + +def maxwell_square_analytical(t, half_period, gamma_dot_0, tau_y=None): + """σ(t) for square-wave γ̇, σ(0) = 0. Optional yield clip at τ_y. + + Within period n: σ(t) = target_n + (σ_start_n - target_n) e^{-(t-n·HP)/τ} + σ_start_(n+1) = target_n + (σ_start_n - target_n) e^{-HP/τ} + + For yielding: clip σ to [-τ_y, +τ_y] post-hoc. This is approximate + (real yielding clamps σ̇=0 once at yield, doesn't blend) but good + enough for cross-checking integrators against the same model. + """ + sigma_ss = ETA * gamma_dot_0 + decay = np.exp(-half_period / TAU_VE) + out = np.zeros_like(t) + n_prev = 0 + sigma_start = 0.0 + for i, ti in enumerate(t): + n = int(ti // half_period) + while n_prev < n: # advance one period at a time + sign_p = 1.0 if n_prev % 2 == 0 else -1.0 + target_p = sign_p * sigma_ss + sigma_start = target_p + (sigma_start - target_p) * decay + n_prev += 1 + sign = 1.0 if n % 2 == 0 else -1.0 + target = sign * sigma_ss + t_local = ti - n * half_period + s = target + (sigma_start - target) * np.exp(-t_local / TAU_VE) + if tau_y is not None and abs(s) > tau_y: + s = np.sign(s) * tau_y + out[i] = s + return out + + +def test_square_VE_VEP(half_period, dt, tau_y=None): + """Constant-dt run. Returns (t, sig_exp, sig_b1, sig_ana).""" + t = np.arange(0.0, T_END + 1e-12, dt) + n_period = (t // half_period).astype(int) + gdot_at = GAMMA_DOT_0 * np.where(n_period % 2 == 0, 1.0, -1.0) + + sig_exp = np.zeros_like(t) + sig_b1 = np.zeros_like(t) + tau_lag = TAU_VE + for i in range(1, len(t)): + sig_exp[i], tau_lag = step_exp_VEP( + sig_exp[i-1], gdot_at[i-1], gdot_at[i], dt, + tau_y=tau_y, tau_prev=tau_lag, + ) + sig_b1[i] = step_bdf1_VEP(sig_b1[i-1], gdot_at[i], dt, tau_y=tau_y) + sig_ana = maxwell_square_analytical(t, half_period, GAMMA_DOT_0, tau_y=tau_y) + return t, sig_exp, sig_b1, sig_ana + + +def test_square_VE_VEP_vardt(half_period, dt_plateau, dt_fine, window, + tau_y=None): + """Variable-dt run: dt_fine inside ±window of every BC flip, + dt_plateau elsewhere. Step boundaries are clamped to the flip + times so no step straddles a discontinuity. Returns (t, sig_exp, + sig_b1, sig_ana, dts).""" + flip_times = [half_period * (k + 1) + for k in range(int(T_END / half_period) - 1)] + + def schedule_dt(t_cur): + for f in flip_times: + if abs(t_cur - f) <= window: + return dt_fine + return dt_plateau + + times_list = [0.0]; dts_list = [] + sig_exp_list = [0.0]; sig_b1_list = [0.0] + tau_lag = TAU_VE + t_cur = 0.0 + + while t_cur < T_END - 1e-12: + dt_step = schedule_dt(t_cur) + # Clamp so we don't straddle the next flip OR step over the + # fine zone preceding it. Without the second clamp, a plateau + # step starting just outside the window can leap clean over + # the entire fine zone, defeating the purpose of having one. + flip_next = next((f for f in flip_times if f > t_cur + 1e-12), T_END) + fine_zone_start = max(0.0, flip_next - window) + if t_cur < fine_zone_start - 1e-12: + # Approaching the fine zone — clamp to land at its start + dt_step = min(dt_step, fine_zone_start - t_cur) + dt_step = min(dt_step, flip_next - t_cur, T_END - t_cur) + t_end = t_cur + dt_step + # Period indexing: int(t // HP) gives the period containing t, + # right-continuous (period flips at exact multiples of HP). + # No fudge: it breaks the case where t_cur lands exactly on a + # flip (clamped step boundaries). + n_period_end = int(t_end // half_period) + # If t_end == HP exactly, we want the discontinuity TO BE inside + # this step (gdot transitions from +1 to -1 across it), matching + # const-dt convention. So at exact flip, treat n_period_end as + # the post-flip period: + if t_end >= flip_next - 1e-12 and t_end <= flip_next + 1e-12 \ + and flip_next < T_END - 1e-12: + n_period_end = int(flip_next // half_period) + sign_np1 = 1.0 if n_period_end % 2 == 0 else -1.0 + + n_period_start = int(t_cur // half_period) + sign_n = 1.0 if n_period_start % 2 == 0 else -1.0 + gdot_n = GAMMA_DOT_0 * sign_n + gdot_np1 = GAMMA_DOT_0 * sign_np1 + + s_exp_new, tau_lag = step_exp_VEP( + sig_exp_list[-1], gdot_n, gdot_np1, dt_step, + tau_y=tau_y, tau_prev=tau_lag, + ) + s_b1_new = step_bdf1_VEP(sig_b1_list[-1], gdot_np1, dt_step, tau_y=tau_y) + + sig_exp_list.append(s_exp_new) + sig_b1_list.append(s_b1_new) + times_list.append(t_end) + dts_list.append(dt_step) + t_cur = t_end + + times = np.array(times_list) + sig_exp = np.array(sig_exp_list) + sig_b1 = np.array(sig_b1_list) + dts = np.array(dts_list) + sig_ana = maxwell_square_analytical(times, half_period, GAMMA_DOT_0, + tau_y=tau_y) + return times, sig_exp, sig_b1, sig_ana, dts + + +# ── Test 1: yield-active sinusoidal forcing ──────────────────────── + +def test_yield_sin(omega, dt, tau_y): + t = np.arange(0.0, T_END + 1e-12, dt) + gdot = GAMMA_DOT_0 * np.cos(omega * t) + + sig_exp = np.zeros_like(t) + sig_b1 = np.zeros_like(t) + tau_lag = TAU_VE # initial relaxation time + for i in range(1, len(t)): + sig_exp[i], tau_lag = step_exp_VEP( + sig_exp[i-1], gdot[i-1], gdot[i], dt, + tau_y=tau_y, tau_prev=tau_lag, + ) + sig_b1[i] = step_bdf1_VEP(sig_b1[i-1], gdot[i], dt, tau_y=tau_y) + return t, sig_exp, sig_b1 + + +# ── Test 2: large-dt regime (Δt = τ, 2τ, 5τ) ─────────────────────── + +def test_largedt_sin(omega, dt): + """Sinusoidal forcing, no yield (pure VE), large dt.""" + t = np.arange(0.0, T_END + 1e-12, dt) + gdot = GAMMA_DOT_0 * np.cos(omega * t) + sig_exp = np.zeros_like(t) + sig_b1 = np.zeros_like(t) + for i in range(1, len(t)): + sig_exp[i], _ = step_exp_VEP(sig_exp[i-1], gdot[i-1], gdot[i], dt) + sig_b1[i] = step_bdf1_VEP(sig_b1[i-1], gdot[i], dt) + De = omega * TAU_VE + A_inf = ETA * GAMMA_DOT_0 / np.sqrt(1 + De**2) + phi = np.arctan(De) + sig_ana = A_inf * (np.cos(omega * t - phi) - np.cos(phi) * np.exp(-t/TAU_VE)) + return t, sig_exp, sig_b1, sig_ana + + +def main(): + out_dir = os.path.dirname(os.path.abspath(__file__)) + + # ── Test 1: yield-active VEP ───────────────────────────────── + omega = np.pi / 4 # period 8τ — generous timestep window + dt = 0.1 * TAU_VE + print(f"\n=== Test 1: VEP harmonic, ω = π/4, dt = {dt} ===") + print(f"{'τ_y':>5} | {'A_∞':>6} {'sub/sup':>8} | " + f"{'Exp peak|σ|':>11} {'BDF-1 peak|σ|':>13} {'ratio':>6}") + for tau_y in (0.10, 0.20, 0.30, 0.50): + t, sig_exp, sig_b1 = test_yield_sin(omega, dt, tau_y) + De = omega * TAU_VE + A_inf = ETA * GAMMA_DOT_0 / np.sqrt(1 + De**2) + regime = "sub" if A_inf <= tau_y else "sup" + peak_e = np.abs(sig_exp).max() + peak_b = np.abs(sig_b1).max() + print(f"{tau_y:>5.2f} | {A_inf:>6.3f} {regime:>8} | " + f"{peak_e:>11.4f} {peak_b:>13.4f} {peak_e/peak_b:>6.3f}") + + # ── Test 2: large dt ───────────────────────────────────────── + print(f"\n=== Test 2: Pure VE harmonic at large Δt/τ ===") + print(f"{'Δt/τ':>5} | {'Exp max|err|':>12} {'Exp peak':>9} | " + f"{'BDF-1 max|err|':>14} {'BDF-1 peak':>10} | {'analytical peak':>15}") + for dt_over_tau in (0.5, 1.0, 2.0, 5.0): + dt = dt_over_tau * TAU_VE + t, sig_exp, sig_b1, sig_ana = test_largedt_sin(omega, dt) + peak_ana = np.abs(sig_ana).max() + peak_e = np.abs(sig_exp).max() + peak_b = np.abs(sig_b1).max() + err_e = np.abs(sig_exp - sig_ana).max() + err_b = np.abs(sig_b1 - sig_ana).max() + print(f"{dt_over_tau:>5.2g} | {err_e:>12.3e} {peak_e:>9.4f} | " + f"{err_b:>14.3e} {peak_b:>10.4f} | {peak_ana:>15.4f}") + + # ── Plot 1: yield-active VEP traces ────────────────────────── + fig, axes = plt.subplots(2, 2, figsize=(11, 7), sharex=True, sharey=True) + omega = np.pi / 4 + dt = 0.1 * TAU_VE + De = omega * TAU_VE + A_inf = ETA * GAMMA_DOT_0 / np.sqrt(1 + De**2) + for ax, tau_y in zip(axes.ravel(), (0.10, 0.20, 0.30, 0.50)): + t, sig_exp, sig_b1 = test_yield_sin(omega, dt, tau_y) + # Reference: pure-VE no-yield analytical + phi = np.arctan(De) + sig_ve = A_inf * (np.cos(omega * t - phi) - np.cos(phi) * np.exp(-t/TAU_VE)) + ax.plot(t, sig_ve, ':', color='0.4', linewidth=1, label='VE (no yield)') + ax.axhline(+tau_y, color='gray', linestyle=':', alpha=0.6, linewidth=1) + ax.axhline(-tau_y, color='gray', linestyle=':', alpha=0.6, linewidth=1) + ax.plot(t, sig_exp, '-', color='C0', linewidth=1.4, label='Exponential') + ax.plot(t, sig_b1, '-', color='C1', linewidth=1.4, label='BDF-1', alpha=0.85) + regime = "sub-yield" if A_inf <= tau_y else "yielding" + ax.set_title(rf'$\tau_y = {tau_y}$ ({regime}; $A_\infty = {A_inf:.3f}$)') + ax.grid(True, alpha=0.3) + axes[0, 0].legend(fontsize=9, loc='upper right') + for ax in axes[1]: + ax.set_xlabel(r'$t/\tau$') + for ax in axes[:, 0]: + ax.set_ylabel(r'$\sigma$') + fig.suptitle("VEP harmonic — Exponential vs BDF-1 (lagged-τ softmin yield, δ=0.1)", + fontsize=12, y=0.995) + fig.tight_layout(rect=[0, 0, 1, 0.97]) + fig.savefig(os.path.join(out_dir, "exp_integrator_phase_b_yield.png"), dpi=140) + print(f"\nWrote phase_b_yield.png") + + # ── Test 3: Square-wave VE and VEP ─────────────────────────── + half_period = 2 * TAU_VE + print(f"\n=== Test 3: Square-wave VE (half-period = 2τ) ===") + print(f"{'dt':>5} | {'Exp max|err|':>12} {'Exp peak':>9} | " + f"{'BDF-1 max|err|':>14} {'BDF-1 peak':>10} | {'Ana peak':>8}") + for dt in (0.05, 0.1, 0.25, 0.5, 1.0): + t, se, s1, sa = test_square_VE_VEP(half_period, dt, tau_y=None) + eme = np.abs(se - sa).max(); em1 = np.abs(s1 - sa).max() + print(f"{dt:>5.2f} | {eme:>12.3e} {np.abs(se).max():>9.4f} | " + f"{em1:>14.3e} {np.abs(s1).max():>10.4f} | {np.abs(sa).max():>8.4f}") + + print(f"\n=== Test 4: Square-wave VEP (half-period = 2τ, τ_y = 0.4) ===") + tau_y = 0.4 + print(f"{'dt':>5} | {'Exp max|err|':>12} {'Exp peak':>9} | " + f"{'BDF-1 max|err|':>14} {'BDF-1 peak':>10} | {'τ_y':>5}") + for dt in (0.05, 0.1, 0.25, 0.5): + t, se, s1, sa = test_square_VE_VEP(half_period, dt, tau_y=tau_y) + eme = np.abs(se - sa).max(); em1 = np.abs(s1 - sa).max() + print(f"{dt:>5.2f} | {eme:>12.3e} {np.abs(se).max():>9.4f} | " + f"{em1:>14.3e} {np.abs(s1).max():>10.4f} | {tau_y:>5.2f}") + + # ── Plot 3: square-wave VE/VEP traces ──────────────────────── + fig, axes = plt.subplots(2, 2, figsize=(11, 7), sharex=True) + half_period = 2 * TAU_VE + for ax, dt, tau_y_plot, title in [ + (axes[0, 0], 0.1, None, "VE Δt=0.1τ"), + (axes[0, 1], 0.5, None, "VE Δt=0.5τ (large)"), + (axes[1, 0], 0.1, 0.4, "VEP Δt=0.1τ, τ_y=0.4"), + (axes[1, 1], 0.5, 0.4, "VEP Δt=0.5τ, τ_y=0.4"), + ]: + t, se, s1, sa = test_square_VE_VEP(half_period, dt, tau_y=tau_y_plot) + ax.plot(t, sa, 'k-', lw=1.5, label='analytical') + ax.plot(t, se, 'o-', color='C0', ms=3, label='Exp', alpha=0.85) + ax.plot(t, s1, 's-', color='C1', ms=3, label='BDF-1', alpha=0.85) + if tau_y_plot is not None: + ax.axhline(+tau_y_plot, color='gray', ls=':', alpha=0.5) + ax.axhline(-tau_y_plot, color='gray', ls=':', alpha=0.5) + ax.set_title(title) + ax.grid(True, alpha=0.3) + axes[0, 0].legend(fontsize=9) + for ax in axes[1]: ax.set_xlabel(r'$t/\tau$') + for ax in axes[:, 0]: ax.set_ylabel(r'$\sigma$') + fig.suptitle("Square-wave forcing: VE & VEP — Exponential vs BDF-1", + fontsize=12, y=0.995) + fig.tight_layout(rect=[0, 0, 1, 0.97]) + fig.savefig(os.path.join(out_dir, "exp_integrator_phase_b_square.png"), dpi=140) + print(f"\nWrote phase_b_square.png") + + # ── Test 5: Variable-dt around BC flips ────────────────────── + print(f"\n=== Test 5: Square-wave with variable dt around BC flips ===") + half_period = 2 * TAU_VE + DT_PLATEAU = 0.25 * TAU_VE # coarse on plateaus + DT_FINE = 0.025 * TAU_VE # 10× finer near flips + WINDOW = 0.2 * TAU_VE # ±0.2τ window around each flip + + print(f" schedule: plateau Δt={DT_PLATEAU}, fine Δt={DT_FINE} " + f"(×{DT_FINE/DT_PLATEAU}), window=±{WINDOW}") + + # VE + t_v, se_v, s1_v, sa_v, dts_v = test_square_VE_VEP_vardt( + half_period, DT_PLATEAU, DT_FINE, WINDOW, tau_y=None, + ) + err_e_v = np.abs(se_v - sa_v).max() + err_1_v = np.abs(s1_v - sa_v).max() + print(f" VE Exp max|err|={err_e_v:.3e} BDF-1 max|err|={err_1_v:.3e}") + + # VEP + t_p, se_p, s1_p, sa_p, dts_p = test_square_VE_VEP_vardt( + half_period, DT_PLATEAU, DT_FINE, WINDOW, tau_y=0.4, + ) + err_e_p = np.abs(se_p - sa_p).max() + err_1_p = np.abs(s1_p - sa_p).max() + print(f" VEP Exp max|err|={err_e_p:.3e} BDF-1 max|err|={err_1_p:.3e}") + + # Comparison: same problems at constant DT_PLATEAU (no fine windows) + t_v_c, se_v_c, s1_v_c, sa_v_c = test_square_VE_VEP( + half_period, DT_PLATEAU, tau_y=None, + ) + t_p_c, se_p_c, s1_p_c, sa_p_c = test_square_VE_VEP( + half_period, DT_PLATEAU, tau_y=0.4, + ) + print(f" VE const Δt={DT_PLATEAU}: Exp max|err|={np.abs(se_v_c-sa_v_c).max():.3e}, " + f"BDF-1 max|err|={np.abs(s1_v_c-sa_v_c).max():.3e}") + print(f" VEP const Δt={DT_PLATEAU}: Exp max|err|={np.abs(se_p_c-sa_p_c).max():.3e}, " + f"BDF-1 max|err|={np.abs(s1_p_c-sa_p_c).max():.3e}") + + # ── Plot 4: variable-dt traces ─────────────────────────────── + fig, axes = plt.subplots(2, 2, figsize=(12, 7), sharex='col') + flip_times = [half_period * (k + 1) + for k in range(int(T_END / half_period) - 1)] + + # Top-left: VE trace + ax = axes[0, 0] + ax.plot(t_v, sa_v, 'k-', lw=1.5, label='analytical') + ax.plot(t_v, se_v, 'o-', color='C0', ms=4, label='Exp', alpha=0.85) + ax.plot(t_v, s1_v, 's-', color='C1', ms=4, label='BDF-1', alpha=0.85) + for f in flip_times: + ax.axvspan(f - WINDOW, f + WINDOW, color='0.85', alpha=0.4, lw=0) + ax.set_title('VE variable Δt (fine windows shaded)') + ax.set_ylabel(r'$\sigma$') + ax.legend(fontsize=9) + ax.grid(True, alpha=0.3) + + # Top-right: dt schedule + ax = axes[0, 1] + # Stair-step: each dt belongs to its step interval + t_steps = t_v[1:] # right edge of each step + ax.step(t_v[1:], dts_v, where='post', color='C2', lw=1.5) + for f in flip_times: + ax.axvspan(f - WINDOW, f + WINDOW, color='0.85', alpha=0.4, lw=0) + ax.set_title('Δt schedule') + ax.set_ylabel(r'$\Delta t$') + ax.grid(True, alpha=0.3) + + # Bottom-left: VEP trace + ax = axes[1, 0] + ax.plot(t_p, sa_p, 'k-', lw=1.5, label='analytical') + ax.plot(t_p, se_p, 'o-', color='C0', ms=4, label='Exp', alpha=0.85) + ax.plot(t_p, s1_p, 's-', color='C1', ms=4, label='BDF-1', alpha=0.85) + ax.axhline(+0.4, color='gray', ls=':', alpha=0.5) + ax.axhline(-0.4, color='gray', ls=':', alpha=0.5) + for f in flip_times: + ax.axvspan(f - WINDOW, f + WINDOW, color='0.85', alpha=0.4, lw=0) + ax.set_title(r'VEP variable Δt ($\tau_y = 0.4$)') + ax.set_xlabel(r'$t/\tau$') + ax.set_ylabel(r'$\sigma$') + ax.grid(True, alpha=0.3) + + # Bottom-right: error vs t for both + ax = axes[1, 1] + err_e_t = np.abs(se_v - sa_v) + err_1_t = np.abs(s1_v - sa_v) + ax.semilogy(t_v, err_e_t + 1e-12, 'o-', color='C0', ms=3, + label='Exp (VE)', alpha=0.7) + ax.semilogy(t_v, err_1_t + 1e-12, 's-', color='C1', ms=3, + label='BDF-1 (VE)', alpha=0.7) + for f in flip_times: + ax.axvspan(f - WINDOW, f + WINDOW, color='0.85', alpha=0.4, lw=0) + ax.set_title('|σ - σ_ana| (VE)') + ax.set_xlabel(r'$t/\tau$') + ax.set_ylabel(r'pointwise error') + ax.legend(fontsize=9) + ax.grid(True, alpha=0.3, which='both') + + fig.suptitle( + f"Variable-Δt square wave — fine Δt={DT_FINE} within ±{WINDOW}τ " + f"of flips, plateau Δt={DT_PLATEAU}", + fontsize=12, y=0.995, + ) + fig.tight_layout(rect=[0, 0, 1, 0.96]) + fig.savefig(os.path.join(out_dir, "exp_integrator_phase_b_vardt.png"), dpi=140) + print(f" Wrote phase_b_vardt.png") + + # ── Plot 2: large-dt traces ────────────────────────────────── + fig, axes = plt.subplots(2, 2, figsize=(11, 7), sharex=False) + for ax, dt_over_tau in zip(axes.ravel(), (0.5, 1.0, 2.0, 5.0)): + dt = dt_over_tau * TAU_VE + t, sig_exp, sig_b1, sig_ana = test_largedt_sin(omega, dt) + ax.plot(t, sig_ana, 'k-', linewidth=1.5, label='analytical') + ax.plot(t, sig_exp, 'o-', color='C0', markersize=3, label='Exp') + ax.plot(t, sig_b1, 's-', color='C1', markersize=3, label='BDF-1', alpha=0.85) + ax.set_title(rf'$\Delta t/\tau = {dt_over_tau}$') + ax.grid(True, alpha=0.3) + axes[0, 0].legend(fontsize=9) + fig.suptitle("Pure VE harmonic at large Δt/τ — Exponential vs BDF-1", + fontsize=12, y=0.995) + fig.tight_layout(rect=[0, 0, 1, 0.97]) + fig.savefig(os.path.join(out_dir, "exp_integrator_phase_b_largedt.png"), dpi=140) + print(f"Wrote phase_b_largedt.png") + + +if __name__ == "__main__": + main() diff --git a/docs/developer/design/_exp_integrator_phase_b_killer.py b/docs/developer/design/_exp_integrator_phase_b_killer.py new file mode 100644 index 000000000..eebf48227 --- /dev/null +++ b/docs/developer/design/_exp_integrator_phase_b_killer.py @@ -0,0 +1,281 @@ +"""Phase B killer test (decision gate): TI-VEP harmonic with spatial yield_stress. + +Mirrors ``docs/advanced/benchmarks/bench_ti_vep_harmonic.py`` but assigns +the new ``TransverseIsotropicMaxwellExponentialFlowModel`` (ETD-2 + +predictor-corrector return mapping) instead of the BDF-2 TI-VEP model. + +Decision gate (from EXPONENTIAL_VE_INTEGRATOR.md §Validation gates): + ``peak |σ_xy| bounded ≲ 1.1·τ_y in fault zone, ≲ A_∞ in bulk for all + 6 (θ, τ_y) combinations.`` + +BDF-2 currently produces 10⁸ blow-up on this setup; ETD-2 should run +cleanly and stay bounded — the empirical proof of the structural +argument that closes Phase B. + +Sweep: θ ∈ {0°, +15°, -15°} × τ_y ∈ {0.15, 0.30}. + +Run:: + + pixi run -e amr-dev python -u docs/developer/design/_exp_integrator_phase_b_killer.py +""" + +from __future__ import annotations + +import os +import time +import numpy as np +import sympy + +import underworld3 as uw +from underworld3.function import expression + + +# --------------------------------------------------------------------------- +# Run-specific parameters (kept aligned with bench_ti_vep_harmonic.py) +# --------------------------------------------------------------------------- + +V0 = 0.5 +OMEGA = np.pi / 2.0 +DT = 0.05 +N_PERIODS = 4 +T_END = N_PERIODS * 2.0 * np.pi / OMEGA + +ETA_0 = 1.0 +ETA_1 = 1.0 +MU = 1.0 +TAU_Y_BULK = 200.0 + +RES = 16 +H = 1.0; W = 1.0 +FAULT_LENGTH = 0.6 +FAULT_WIDTH = 0.06 + +ANGLES_DEG = (0.0, 15.0, -15.0) +TAU_Y_LIST = (0.15, 0.30) + + +# --------------------------------------------------------------------------- +# Build helper +# --------------------------------------------------------------------------- + +def build_ti_exp_stokes(label, theta_deg, tau_y_at_fault): + mesh = uw.meshing.StructuredQuadBox( + elementRes=(RES, RES), + minCoords=(0.0, 0.0), maxCoords=(W, H), + qdegree=3, + ) + v = uw.discretisation.MeshVariable( + f"U_{label}", mesh, 2, degree=2, vtype=uw.VarType.VECTOR, + ) + p = uw.discretisation.MeshVariable( + f"P_{label}", mesh, 1, degree=1, + continuous=True, vtype=uw.VarType.SCALAR, + ) + + theta = np.radians(theta_deg) + cx, cy = 0.5 * W, 0.5 * H + dx = 0.5 * FAULT_LENGTH * np.cos(theta) + dy = 0.5 * FAULT_LENGTH * np.sin(theta) + fault = uw.meshing.Surface( + f"fault_{label}", mesh, + np.array([[cx - dx, cy - dy], [cx + dx, cy + dy]]), + symbol=f"F{label}", + ) + fault.discretize() + + n_x = -np.sin(theta) + n_y = np.cos(theta) + director = sympy.Matrix([n_x, n_y]) + + weakness = fault.influence_function( + width=FAULT_WIDTH, + value_near=1.0 / tau_y_at_fault, + value_far=1.0 / TAU_Y_BULK, + profile="gaussian", + ) + tau_y_field = 1.0 / weakness + + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + stokes.constitutive_model = uw.constitutive_models.TransverseIsotropicMaxwellExponentialFlowModel + cm = stokes.constitutive_model + cm.Parameters.shear_viscosity_0 = ETA_0 + cm.Parameters.shear_viscosity_1 = ETA_1 + cm.Parameters.shear_modulus = MU + cm.Parameters.yield_stress = tau_y_field + cm.Parameters.director = director + cm.Parameters.shear_viscosity_min = ETA_0 * 1.0e-3 + cm.Parameters.strainrate_inv_II_min = 1.0e-6 + cm.yield_mode = "softmin" + + stokes.saddle_preconditioner = 1.0 / cm.K + stokes.tolerance = 1.0e-4 + stokes.petsc_options["ksp_type"] = "fgmres" + stokes.petsc_options["snes_force_iteration"] = True + + V_top = expression(rf"V_{{top,{label}}}", sympy.Float(0.0), "Top BC") + stokes.add_essential_bc(sympy.Matrix([V_top, 0.0]), "Top") + stokes.add_essential_bc(sympy.Matrix([0.0, 0.0]), "Bottom") + stokes.add_essential_bc((sympy.oo, 0.0), "Left") + stokes.add_essential_bc((sympy.oo, 0.0), "Right") + stokes.bodyforce = sympy.Matrix([0.0, 0.0]) + + return mesh, stokes, V_top, np.array([n_x, n_y]) + + +# --------------------------------------------------------------------------- +# Probes +# --------------------------------------------------------------------------- + +def probe_centre_resolved(stokes, n_vec, c=np.array([[0.5, 0.5]])): + """σ_xy and resolved fault-plane shear at fault centre.""" + tau = stokes.tau + dists = np.linalg.norm(tau.coords - c, axis=1) + idx = int(np.argmin(dists)) + s_xx, s_yy, s_xy = tau.data[idx, 0], tau.data[idx, 1], tau.data[idx, 2] + n_x, n_y = n_vec + t_x, t_y = n_y, -n_x + resolved = (s_xx * t_x * n_x + s_xy * (t_x * n_y + t_y * n_x) + + s_yy * t_y * n_y) + return float(s_xy), float(resolved) + + +# --------------------------------------------------------------------------- +# Time-stepping +# --------------------------------------------------------------------------- + +def run_one(theta_deg, tau_y_at_fault, t_end=None): + """Run one (θ, τ_y) combo. ``t_end`` overrides the module-level T_END; + use a fraction of T_END (e.g. ``T_END/4`` = 1 period) for fast + smokes that just check yield enforcement / convergence behaviour.""" + if t_end is None: + t_end = T_END + label = f"ti_exp_th{theta_deg:+.0f}_ty{tau_y_at_fault:.2f}".replace(".", "p") + mesh, stokes, V_top, n_vec = build_ti_exp_stokes(label, theta_deg, tau_y_at_fault) + cm = stokes.constitutive_model + DFDt = stokes.Unknowns.DFDt + + # Per-node τ_y(x) — the SPATIAL yield_stress field evaluated at psi_star + # node coords. Used for the proper yield-surface gate + # ``max_x σ_II(x) / τ_y(x)`` rather than dividing peak σ_II by the + # fault-centerline τ_y (which is misleading because the Gaussian + # influence decays sharply, so points just off centerline have + # τ_y_local much larger than τ_y_at_fault). + ty_field_sym = cm.Parameters.yield_stress.sym + ty_per_node = np.asarray( + uw.function.evaluate(ty_field_sym, DFDt.psi_star[0].coords) + ).flatten() + + # Steady-state amplitude (sub-yield) for the analytical baseline + t_r = ETA_1 / MU + De = OMEGA * t_r + gamma_dot_0 = V0 / H # engineering shear (NOT 2·V0/H — TI bench uses fixed-bottom BC) + A_inf = ETA_1 * gamma_dot_0 / np.sqrt(1.0 + De ** 2) + + times, sxy_centre, sxy_max_global, sigmaII_max_fault = [], [], [], [] + sigmaII_over_ty_max = [] # the proper yield-surface gate: max σ_II(x)/τ_y(x) + times_ana, resolved_centre = [], [] + diverged = 0 + t0 = time.time() + t_cur = 0.0 + n_x, n_y = n_vec + while t_cur < t_end - 1e-9: + dt = min(DT, t_end - t_cur) + t_end_step = t_cur + dt + v_now = V0 * float(np.cos(OMEGA * t_end_step)) + V_top.sym = sympy.Float(v_now) + cm.Parameters.dt_elastic = dt + try: + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=2) + except Exception as exc: + print(f" step at t={t_end_step:.3f}: {exc}", flush=True) + diverged += 1 + break + + # Centre probe (uses tau projection with snapshot) + sxy_c, res_c = probe_centre_resolved(stokes, n_vec) + sxy_centre.append(sxy_c) + resolved_centre.append(res_c) + + # Global stress-array probes (peak |σ_xy| and σ_II in fault zone) + sigma = np.asarray(DFDt.psi_star[0].array) + sxy_max_global.append(float(np.abs(sigma[:, 0, 1]).max())) + sigma_II = np.sqrt(0.5 * (sigma ** 2).sum(axis=(1, 2))) + coords = DFDt.psi_star[0].coords + # fault zone mask: distance from fault line ≤ 3·FAULT_WIDTH + cx, cy = 0.5 * W, 0.5 * H + sd = np.abs((coords[:, 0] - cx) * n_x + (coords[:, 1] - cy) * n_y) + mask = sd < 3.0 * FAULT_WIDTH + sigmaII_max_fault.append(float(sigma_II[mask].max()) if mask.any() else 0.0) + + # Proper yield-surface gate: per-node ratio σ_II(x)/τ_y(x). + # Should be ≤ 1.001 at all nodes if yield is correctly enforced. + ratio = sigma_II / np.maximum(ty_per_node, 1e-30) + sigmaII_over_ty_max.append(float(ratio.max())) + + times.append(t_end_step) + t_cur = t_end_step + + return dict( + theta_deg=theta_deg, + tau_y=tau_y_at_fault, + A_inf=A_inf, + times=np.array(times), + sxy_centre=np.array(sxy_centre), + resolved_centre=np.array(resolved_centre), + sxy_max_global=np.array(sxy_max_global), + sigmaII_max_fault=np.array(sigmaII_max_fault), + sigmaII_over_ty_max=np.array(sigmaII_over_ty_max), + wall=time.time() - t0, + diverged=diverged, + ) + + +def main(): + print(f"[ti_killer] dt={DT} T_end={T_END:.4f} (4 periods)", flush=True) + print(f" bulk τ_y={TAU_Y_BULK} fault τ_y∈{TAU_Y_LIST} θ∈{ANGLES_DEG}", flush=True) + print(f" Decision gate: σ_II_fault ≤ 1.1·τ_y, |σ_xy| ≤ A_∞ in bulk\n", flush=True) + n_pass = 0; n_total = 0 + summary = [] + for ty in TAU_Y_LIST: + for theta in ANGLES_DEG: + n_total += 1 + print(f"--- θ={theta:+.0f}°, fault τ_y={ty:.2f} ---", flush=True) + res = run_one(theta, ty) + print(f" steps={len(res['times'])} wall={res['wall']:.1f}s " + f"diverged={res['diverged']}", flush=True) + if len(res['times']): + sxy_c = float(np.abs(res['sxy_centre']).max()) + tau_res_c = float(np.abs(res['resolved_centre']).max()) + ratio_sxy = sxy_c / ty + ratio_tau = tau_res_c / ty + print(f" centre probes (apples-to-apples with BDF-1 baseline):") + print(f" peak |σ_xy| = {sxy_c:.4f} ({ratio_sxy:.3f}·τ_y)") + print(f" peak |τ_resolved| = {tau_res_c:.4f} ({ratio_tau:.3f}·τ_y)") + print(f" global probes:") + print(f" peak |σ_xy| any node = {float(res['sxy_max_global'].max()):.4f}") + print(f" peak σ_II any node = {float(res['sigmaII_max_fault'].max()):.4f}") + # Decision gate: τ_resolved at centre ≤ 1.20·τ_y + # (BDF-1 production baseline is 1.12-1.15·τ_y on this setup) + ok_yield = ratio_tau < 1.20 + if ok_yield and res['diverged'] == 0: + print(f" PASS") + n_pass += 1 + summary.append((theta, ty, ratio_tau, "PASS")) + else: + print(f" FAIL (centre |τ_resolved|/τ_y = {ratio_tau:.4f}, " + f"diverged={res['diverged']})") + summary.append((theta, ty, ratio_tau, "FAIL")) + else: + print(f" FAIL — no steps completed") + summary.append((theta, ty, float('inf'), "FAIL")) + print() + print(f"\n=== KILLER TEST SUMMARY: {n_pass}/{n_total} PASS ===", flush=True) + print(" metric: peak |τ_resolved| at fault centre / τ_y_at_fault") + print(" BDF-1 production baseline ≈ 1.12-1.15·τ_y (centre)") + print(" BDF-2 (the higher-order method ETD-2 replaces) blows up to 10⁵-10⁹\n") + for theta, ty, ratio, status in summary: + print(f" θ={theta:+.0f}°, τ_y={ty:.2f}: |τ_resolved|/τ_y = {ratio:.4f} [{status}]") + + +if __name__ == "__main__": + main() diff --git a/docs/developer/design/_exp_integrator_phase_b_ti_iso.py b/docs/developer/design/_exp_integrator_phase_b_ti_iso.py new file mode 100644 index 000000000..af0f42ff9 --- /dev/null +++ b/docs/developer/design/_exp_integrator_phase_b_ti_iso.py @@ -0,0 +1,174 @@ +"""Phase B intermediate test: bench_ti_vep_harmonic geometry with the ISOTROPIC +MaxwellExponentialFlowModel + spatial yield_stress field. + +This is NOT the killer test as designed (which uses TransverseIsotropic +rank-4 tensor) — it's a structural sanity check before investing in the +TI extension. Goal: confirm that the predictor-corrector return mapping +on the spatial yield_stress field stays bounded, i.e. σ_II ≤ 1.001·τ_y +everywhere. If yes: the exp framework's structural argument extends to +spatial yield, and TI extension is tensor-bookkeeping. If no: the spatial +yield handling itself needs more work. + +Note: this uses the ``zIC`` (zero IC) variant of the bench, since the +peak-start TI IC requires resolving stress onto the fault tangent — +which is TI-specific. Zero IC + smooth ramp-up is a cleaner test. + +Run:: + + pixi run -e amr-dev python -u docs/developer/design/_exp_integrator_phase_b_ti_iso.py +""" + +import time +import numpy as np +import sympy + +import underworld3 as uw +from underworld3.function import expression + + +# --------------------------------------------------------------------------- +# Parameters (kept aligned with bench_ti_vep_harmonic.py) +# --------------------------------------------------------------------------- + +V0 = 0.5 +OMEGA = np.pi / 2.0 +DT = 0.05 +N_PERIODS = 4 +T_END = N_PERIODS * 2.0 * np.pi / OMEGA + +ETA = 1.0 # use single isotropic viscosity (η₀ in TI nomenclature) +MU = 1.0 +TAU_Y_BULK = 200.0 + +RES = 16 +H = 1.0; W = 1.0 +FAULT_LENGTH = 0.6 +FAULT_WIDTH = 0.06 + +ANGLES_DEG = (0.0,) # start with 0° to keep the iso comparison simple +TAU_Y_LIST = (0.30, 0.15) + + +def build_iso_exp_stokes(label, theta_deg, tau_y_at_fault): + """Plain Stokes + MaxwellExponentialFlowModel + spatial yield_stress field.""" + mesh = uw.meshing.StructuredQuadBox( + elementRes=(RES, RES), + minCoords=(0.0, 0.0), maxCoords=(W, H), + qdegree=3, + ) + v = uw.discretisation.MeshVariable(f"U_{label}", mesh, 2, degree=2) + p = uw.discretisation.MeshVariable(f"P_{label}", mesh, 1, degree=1, continuous=True) + + theta = np.radians(theta_deg) + cx, cy = 0.5 * W, 0.5 * H + dx = 0.5 * FAULT_LENGTH * np.cos(theta) + dy = 0.5 * FAULT_LENGTH * np.sin(theta) + fault = uw.meshing.Surface( + f"fault_{label}", mesh, + np.array([[cx - dx, cy - dy], [cx + dx, cy + dy]]), + symbol=f"F{label}", + ) + fault.discretize() + + weakness = fault.influence_function( + width=FAULT_WIDTH, + value_near=1.0 / tau_y_at_fault, + value_far=1.0 / TAU_Y_BULK, + profile="gaussian", + ) + tau_y_field = 1.0 / weakness + + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + stokes.constitutive_model = uw.constitutive_models.MaxwellExponentialFlowModel + cm = stokes.constitutive_model + cm.Parameters.shear_viscosity_0 = ETA + cm.Parameters.shear_modulus = MU + cm.Parameters.yield_stress = tau_y_field + cm.Parameters.shear_viscosity_min = ETA * 1.0e-3 + cm.Parameters.strainrate_inv_II_min = 1.0e-6 + cm._yield_mode = "softmin" + + stokes.tolerance = 1.0e-4 + stokes.petsc_options["ksp_type"] = "fgmres" + stokes.petsc_options["snes_force_iteration"] = True + + V_top = expression(rf"V_{{top,{label}}}", sympy.Float(0.0), "Top V") + stokes.add_essential_bc(sympy.Matrix([V_top, 0.0]), "Top") + stokes.add_essential_bc(sympy.Matrix([0.0, 0.0]), "Bottom") + stokes.add_essential_bc((sympy.oo, 0.0), "Left") + stokes.add_essential_bc((sympy.oo, 0.0), "Right") + stokes.bodyforce = sympy.Matrix([0.0, 0.0]) + + return mesh, stokes, V_top, tau_y_field + + +def run_iso_zIC(theta_deg, tau_y_at_fault): + label = f"ti_iso_th{theta_deg:+.0f}_ty{tau_y_at_fault:.2f}".replace(".", "p") + mesh, stokes, V_top, ty_field = build_iso_exp_stokes(label, theta_deg, tau_y_at_fault) + cm = stokes.constitutive_model + DFDt = stokes.Unknowns.DFDt + + times, peak_sxy_global, peak_sigmaII_fault = [], [], [] + n_diverged = 0 + t0 = time.time() + t_cur = 0.0 + while t_cur < T_END - 1e-9: + dt = min(DT, T_END - t_cur) + t_end = t_cur + dt + # Forcing: V_top(t) = V0·cos(ωt + φ_lag) — same as bench + v_now = V0 * float(np.cos(OMEGA * t_end)) + V_top.sym = sympy.Float(v_now) + cm.Parameters.dt_elastic = dt + try: + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=2) + except Exception as exc: + print(f" step at t={t_end:.3f}: {exc}", flush=True) + n_diverged += 1 + break + # Probe + sigma = np.asarray(DFDt.psi_star[0].array) + sxy_global = float(np.abs(sigma[:, 0, 1]).max()) + sigma_II = np.sqrt(0.5 * (sigma ** 2).sum(axis=(1, 2))) + coords = DFDt.psi_star[0].coords + # fault-zone mask (within ~3·FAULT_WIDTH of the centerline) + dist = np.abs(coords[:, 1] - 0.5 * H) # 2D, theta=0 simplification + mask = dist < 3.0 * FAULT_WIDTH + sigmaII_fault = float(sigma_II[mask].max()) if mask.any() else 0.0 + peak_sxy_global.append(sxy_global) + peak_sigmaII_fault.append(sigmaII_fault) + times.append(t_end) + t_cur = t_end + return dict( + times=np.array(times), + peak_sxy_global=np.array(peak_sxy_global), + peak_sigmaII_fault=np.array(peak_sigmaII_fault), + wall=time.time() - t0, + n_diverged=n_diverged, + tau_y=tau_y_at_fault, + ) + + +def main(): + print(f"[ti_iso_zIC] dt={DT} T_end={T_END:.4f} (4 periods)", flush=True) + print(f" bulk τ_y={TAU_Y_BULK}, fault τ_y values: {TAU_Y_LIST}\n", flush=True) + for ty in TAU_Y_LIST: + for theta in ANGLES_DEG: + print(f"--- θ={theta:+.0f}°, fault τ_y={ty:.2f} ---", flush=True) + res = run_iso_zIC(theta, ty) + print(f" steps={len(res['times'])} wall={res['wall']:.1f}s " + f"diverged={res['n_diverged']}", flush=True) + sxy = res['peak_sxy_global'] + sii_fault = res['peak_sigmaII_fault'] + if len(sxy): + print(f" peak|σ_xy| (global): {sxy.max():.4f}", flush=True) + print(f" peak σ_II (fault): {sii_fault.max():.4f}", flush=True) + print(f" ratio σ_II_fault/τ_y: {sii_fault.max()/ty:.3f}", flush=True) + if sii_fault.max() < 1.1 * ty: + print(f" PASS (σ_II_fault ≤ 1.1·τ_y)", flush=True) + else: + print(f" FAIL (σ_II_fault = {sii_fault.max():.4f} > 1.1·τ_y = {1.1*ty:.4f})", flush=True) + print() + + +if __name__ == "__main__": + main() diff --git a/docs/developer/design/_exp_integrator_phase_b_validate.py b/docs/developer/design/_exp_integrator_phase_b_validate.py new file mode 100644 index 000000000..b77c54500 --- /dev/null +++ b/docs/developer/design/_exp_integrator_phase_b_validate.py @@ -0,0 +1,144 @@ +"""Phase B validator for MaxwellExponentialFlowModel — VE harmonic. + +Mirrors ``docs/advanced/benchmarks/bench_ve_harmonic.py`` but assigns the +new ``MaxwellExponentialFlowModel`` (ETD-2) instead of the BDF-style VEP +model. Decision gate: max|err| must match or beat BDF-2's 1.34e-3 baseline. + +The peak-start IC plants ``σ⁰ = A_∞·cos(0) = A_∞`` and the matching +``ε̇⁰ = γ̇₀/(2√(1+De²))`` so step 1 starts on the analytical steady cycle +with no homogeneous transient. + +Run:: + + pixi run -e amr-dev python -u docs/developer/design/_exp_integrator_phase_b_validate.py +""" + +import time +import numpy as np +import sympy + +import underworld3 as uw +from underworld3 import VarType +from underworld3.function import expression + + +# Parameters — same as bench_ve_harmonic +ETA = 1.0 +MU = 1.0 +V0 = 0.5 +OMEGA = np.pi / 2.0 +DT = 0.05 +N_PERIODS = 4 +T_END = N_PERIODS * 2.0 * np.pi / OMEGA +H = 1.0 +W = 2.0 + + +def run_exp(): + mesh = uw.meshing.StructuredQuadBox( + elementRes=(16, 8), minCoords=(-W / 2, -H / 2), maxCoords=(W / 2, H / 2) + ) + v = uw.discretisation.MeshVariable("U_exp_b", mesh, 2, degree=2) + p = uw.discretisation.MeshVariable("P_exp_b", mesh, 1, degree=1) + + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + stokes.constitutive_model = uw.constitutive_models.MaxwellExponentialFlowModel + cm = stokes.constitutive_model + cm.Parameters.shear_viscosity_0 = ETA + cm.Parameters.shear_modulus = MU + stokes.tolerance = 1e-7 + stokes.petsc_options["snes_force_iteration"] = True + + # Antisymmetric BCs (matches bench_ve_harmonic) + V_top = expression(r"V_{top}^{exp}", sympy.Float(0.0), "Top BC for exp validator") + stokes.add_essential_bc((V_top, 0.0), "Top") + stokes.add_essential_bc((-V_top, 0.0), "Bottom") + stokes.add_essential_bc((sympy.oo, 0.0), "Left") + stokes.add_essential_bc((sympy.oo, 0.0), "Right") + + t_r = ETA / MU + De = OMEGA * t_r + gamma_dot_0 = 2.0 * V0 / H + A_inf = ETA * gamma_dot_0 / np.sqrt(1.0 + De ** 2) + phi_lag = float(np.arctan(De)) + + DFDt = stokes.Unknowns.DFDt + n_nodes = DFDt.psi_star[0].array.shape[0] + + # Plant σ_xy = A_inf at t=0 (peak-start) + sigma0 = np.zeros((n_nodes, 2, 2)) + sigma0[:, 0, 1] = A_inf + sigma0[:, 1, 0] = A_inf + history = [sigma0] + DFDt.set_initial_history(history, dt=DT) + + # Plant ε̇⁰ = γ̇₀/(2√(1+De²)) (i.e. shear-only) so step 1's history + # term references the analytical ε̇ at t=0, not zero. + edot0 = gamma_dot_0 / (2.0 * np.sqrt(1.0 + De ** 2)) + f0 = np.zeros((n_nodes, 2, 2)) + f0[:, 0, 1] = edot0 + f0[:, 1, 0] = edot0 + DFDt.forcing_star.array[...] = f0 + + times, dts, sigmas, reasons = [], [], [], [] + t_cur = 0.0 + t0_wall = time.time() + while t_cur < T_END - 1e-9: + dt = min(DT, T_END - t_cur) + t_end_step = t_cur + dt + v_now = V0 * float(np.cos(OMEGA * t_end_step + phi_lag)) + V_top.sym = sympy.Float(v_now) + cm.Parameters.dt_elastic = dt + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=2) + + coords = DFDt.psi_star[0].coords + centre = np.array([[0.0, 0.0]]) + idx = int(np.argmin(np.linalg.norm(coords - centre, axis=1))) + sigmas.append(float(DFDt.psi_star[0].array[idx, 0, 1])) + reasons.append(int(stokes.snes.getConvergedReason())) + times.append(t_end_step) + dts.append(dt) + t_cur = t_end_step + + times = np.array(times) + dts = np.array(dts) + sigmas = np.array(sigmas) + reasons = np.array(reasons) + sigma_ana = A_inf * np.cos(OMEGA * times) + err = np.abs(sigmas - sigma_ana) + max_err = float(err.max()) + rms = float(np.sqrt((err ** 2).mean())) + wall = time.time() - t0_wall + diverged = int((reasons < 0).sum()) + return dict( + times=times, + dts=dts, + sigmas=sigmas, + sigma_ana=sigma_ana, + max_err=max_err, + rms=rms, + wall=wall, + diverged=diverged, + A_inf=A_inf, + De=De, + ) + + +def main(): + print(f"[ve_harmonic_exp] dt={DT} T_end={T_END:.4f} (4 periods)", flush=True) + res = run_exp() + print(f" steps={len(res['times'])} A_inf={res['A_inf']:.4f} De={res['De']:.4f}") + print(f" ETD-2 wall={res['wall']:.1f}s max|err|={res['max_err']:.4e} rms={res['rms']:.4e}") + print(f" diverged: {res['diverged']}/{len(res['times'])}") + print(f" baseline (bench_ve_harmonic BDF-2 peak-start): max|err| = 1.34e-3", flush=True) + out = dict( + times=res["times"], dts=res["dts"], + sigma_exp=res["sigmas"], sigma_ana=res["sigma_ana"], + A_inf=res["A_inf"], De=res["De"], + max_err=res["max_err"], rms=res["rms"], + ) + np.savez("output/exp_integrator_phase_b_ve_harmonic.npz", **out) + + +if __name__ == "__main__": + main() diff --git a/docs/developer/design/_exp_integrator_phase_d_split.py b/docs/developer/design/_exp_integrator_phase_d_split.py new file mode 100644 index 000000000..7d7eadf10 --- /dev/null +++ b/docs/developer/design/_exp_integrator_phase_d_split.py @@ -0,0 +1,253 @@ +"""Phase D — 1D cleanroom bench for the per-component ETD-2 scheme. + +Two parallel Maxwell branches with disparate relaxation times — the +exact analogue of the rank-4 TI tensor split into matrix-aligned (η_⊥) +and director-aligned (η_∥) channels: + + σ̇_⊥ + σ_⊥/τ_⊥ = μ ε̇, τ_⊥ = η_⊥ / μ (slow, matrix) + σ̇_∥ + σ_∥/τ_∥ = μ ε̇, τ_∥ = η_∥ / μ (fast, post-yield clamp) + σ_total = σ_⊥ + σ_∥ + +Both branches see the same engineering shear rate ε̇ = γ̇₀ cos(ωt). +The analytical solution is the sum of two independent Maxwell phasor +responses — fully closed-form, no numerical reference needed. + +Three integrators run on the *total* stress: + + 1. Per-component ETD-2 — propose, integrate σ_⊥ and σ_∥ separately + with their own (α_⊥, φ_⊥) and (α_∥, φ_∥), then sum. (Phase D.) + 2. Lumped-effective ETD-2 — Phase B's current shape, one (α, φ) from + τ_eff = (η_⊥ + η_∥) / μ on the total stress. + 3. Lumped-min ETD-2 — single (α, φ) from τ_min = min(τ_⊥, τ_∥); a + prior lagged-τ experiment we already tried. + +τ_∥ = 0.05 (post-yield-clamp regime), τ_⊥ = 1.0, μ = 1, ω = π/2, +Δt swept from 0.005 to 0.5. Headline metric: max-|err|/A_∞_total. + +Run:: + + pixi run -e amr-dev python -u docs/developer/design/_exp_integrator_phase_d_split.py +""" + +import os + +import numpy as np +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt + + +# ── Parameters ───────────────────────────────────────────────────── +MU = 1.0 +ETA_PERP = 1.0 # matrix viscosity (slow) +ETA_PAR = 0.05 # post-yield-clamp director viscosity (fast) +TAU_PERP = ETA_PERP / MU +TAU_PAR = ETA_PAR / MU + +GAMMA_DOT_0 = 1.0 +OMEGA = np.pi / 2.0 +N_PERIODS = 4.0 +T_END = N_PERIODS * 2.0 * np.pi / OMEGA + +OUT_DIR = "output" + + +def maxwell_sin(t, tau): + """σ(t) for ε̇(t) = γ̇₀ cos(ωt), one Maxwell branch with relaxation τ. + Phasor steady state plus decaying transient at σ(0) = 0.""" + De = OMEGA * tau + A_inf = (MU * tau) * GAMMA_DOT_0 / np.sqrt(1 + De ** 2) + phi = np.arctan(De) + sigma_ss = A_inf * np.cos(OMEGA * t - phi) + transient = -A_inf * np.cos(-phi) * np.exp(-t / tau) + return sigma_ss + transient + + +def analytical_total(t): + return maxwell_sin(t, TAU_PERP) + maxwell_sin(t, TAU_PAR) + + +# ── Integrators ──────────────────────────────────────────────────── + + +def _alpha_phi(dt, tau): + x = dt / tau + alpha = np.exp(-x) + if x > 1e-12: + phi = (1 - alpha) / x + else: + phi = 1.0 - x / 2 + x * x / 6 + return alpha, phi + + +def etd2_step(gdot_n, gdot_np1, sigma_n, dt, tau, eta): + """Single Maxwell branch: σⁿ⁺¹ = α σⁿ + μ[A γ̇ⁿ⁺¹ + B γ̇ⁿ].""" + alpha, phi = _alpha_phi(dt, tau) + A = tau * (1 - phi) + B = tau * (phi - alpha) + return alpha * sigma_n + MU * (A * gdot_np1 + B * gdot_n) + + +def run_per_component(dt): + """Per-component scheme: integrate the two branches separately.""" + t = np.arange(0.0, T_END + 1e-12, dt) + eps_dot = GAMMA_DOT_0 * np.cos(OMEGA * t) + sigma_perp = np.zeros_like(t) + sigma_par = np.zeros_like(t) + for i in range(1, len(t)): + sigma_perp[i] = etd2_step( + eps_dot[i - 1], eps_dot[i], sigma_perp[i - 1], dt, TAU_PERP, ETA_PERP + ) + sigma_par[i] = etd2_step( + eps_dot[i - 1], eps_dot[i], sigma_par[i - 1], dt, TAU_PAR, ETA_PAR + ) + return t, sigma_perp + sigma_par, sigma_perp, sigma_par + + +def run_lumped(dt, tau_choice): + """Single-(α, φ) lump applied to the total stress. + + The effective viscosity in the lumped picture is η_⊥ + η_∥ (the + instantaneous viscous stress is the sum of the two branches at + γ̇₀), so the model is σ̇ + σ/τ_choice = (η_⊥ + η_∥) γ̇ / τ_choice + — i.e. μ_eff γ̇ in our shorthand, where μ_eff = (η_⊥ + η_∥)/τ_choice. + """ + t = np.arange(0.0, T_END + 1e-12, dt) + eps_dot = GAMMA_DOT_0 * np.cos(OMEGA * t) + sigma = np.zeros_like(t) + eta_eff = ETA_PERP + ETA_PAR + mu_eff = eta_eff / tau_choice + alpha, phi = _alpha_phi(dt, tau_choice) + A = tau_choice * (1 - phi) + B = tau_choice * (phi - alpha) + for i in range(1, len(t)): + sigma[i] = ( + alpha * sigma[i - 1] + + mu_eff * (A * eps_dot[i] + B * eps_dot[i - 1]) + ) + return t, sigma + + +# ── Main bench ───────────────────────────────────────────────────── + + +def main(): + os.makedirs(OUT_DIR, exist_ok=True) + + # Pick one Δt for the trajectory plot; sweep for the error figure. + dt_show = 0.05 + dt_sweep = np.array([0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5]) + + # --- trajectory at dt_show --- + t_pc, s_pc_total, s_pc_perp, s_pc_par = run_per_component(dt_show) + _, s_lump_eff = run_lumped(dt_show, TAU_PERP + TAU_PAR) # naive sum + _, s_lump_min = run_lumped(dt_show, TAU_PAR) # min-τ + _, s_lump_slow = run_lumped(dt_show, TAU_PERP) # pick-the-slow + + s_ana = analytical_total(t_pc) + s_perp_ana = maxwell_sin(t_pc, TAU_PERP) + s_par_ana = maxwell_sin(t_pc, TAU_PAR) + + A_inf_total = np.max(np.abs(s_ana[len(s_ana) // 2:])) + + # --- err sweep --- + err_pc, err_lump_eff, err_lump_min, err_lump_slow = [], [], [], [] + for dt in dt_sweep: + t, s_pc, _, _ = run_per_component(dt) + _, s_le = run_lumped(dt, TAU_PERP + TAU_PAR) + _, s_lm = run_lumped(dt, TAU_PAR) + _, s_ls = run_lumped(dt, TAU_PERP) + ana = analytical_total(t) + err_pc.append(np.max(np.abs(s_pc - ana)) / A_inf_total) + err_lump_eff.append(np.max(np.abs(s_le - ana)) / A_inf_total) + err_lump_min.append(np.max(np.abs(s_lm - ana)) / A_inf_total) + err_lump_slow.append(np.max(np.abs(s_ls - ana)) / A_inf_total) + + err_pc = np.array(err_pc); err_lump_eff = np.array(err_lump_eff) + err_lump_min = np.array(err_lump_min); err_lump_slow = np.array(err_lump_slow) + + print("Phase D 1D bench — two parallel Maxwell branches", flush=True) + print(f" τ_⊥={TAU_PERP}, τ_∥={TAU_PAR}, η_⊥={ETA_PERP}, η_∥={ETA_PAR}", flush=True) + print(f" ω={OMEGA:.4f}, γ̇₀={GAMMA_DOT_0}, T_END={T_END:.2f}", flush=True) + print(f" A_∞_total ≈ {A_inf_total:.4f}", flush=True) + print(flush=True) + print(f"{'dt':>8s} {'per-comp':>11s} {'lump-eff':>11s} {'lump-min':>11s} {'lump-slow':>11s}", + flush=True) + for i, dt in enumerate(dt_sweep): + print( + f"{dt:8.4f} {err_pc[i]:11.4e} {err_lump_eff[i]:11.4e} " + f"{err_lump_min[i]:11.4e} {err_lump_slow[i]:11.4e}", + flush=True, + ) + + # --- plots --- + fig = plt.figure(figsize=(11, 8.5)) + gs = fig.add_gridspec(2, 2, height_ratios=[1.4, 1.0]) + ax_traj = fig.add_subplot(gs[0, :]) + ax_split = fig.add_subplot(gs[1, 0]) + ax_err = fig.add_subplot(gs[1, 1]) + + # Total trajectories + ax_traj.plot(t_pc, s_ana, "-", color="black", lw=2.0, alpha=0.8, + label=f"analytical (total, A∞={A_inf_total:.3f})") + ax_traj.plot(t_pc, s_pc_total, "--", color="#1f77b4", lw=1.5, + label=f"per-component ETD-2 " + f"(max|err|/A∞={err_pc[np.where(dt_sweep==dt_show)[0][0]]:.2e})") + idx = np.where(dt_sweep == dt_show)[0][0] + ax_traj.plot(t_pc, s_lump_eff, "--", color="#d62728", lw=1.2, + label=f"lumped τ=τ_⊥+τ_∥ " + f"(max|err|/A∞={err_lump_eff[idx]:.2e})") + ax_traj.plot(t_pc, s_lump_slow, ":", color="#9467bd", lw=1.2, + label=f"lumped τ=τ_⊥ " + f"(max|err|/A∞={err_lump_slow[idx]:.2e})") + ax_traj.plot(t_pc, s_lump_min, ":", color="#2ca02c", lw=1.2, + label=f"lumped τ=τ_∥ " + f"(max|err|/A∞={err_lump_min[idx]:.2e})") + ax_traj.set_xlabel("time") + ax_traj.set_ylabel(r"σ_total") + ax_traj.set_title(rf"Total stress — Δt={dt_show}, τ_⊥={TAU_PERP}, τ_∥={TAU_PAR}") + ax_traj.legend(loc="upper right", fontsize=8.5, ncol=1) + ax_traj.grid(alpha=0.3) + + # Per-component split + ax_split.plot(t_pc, s_perp_ana, "-", color="black", lw=1.6, + label=r"σ_⊥ analytical") + ax_split.plot(t_pc, s_pc_perp, "--", color="#1f77b4", lw=1.2, + label=r"σ_⊥ ETD-2") + ax_split.plot(t_pc, s_par_ana, "-", color="#444444", lw=1.6, + label=r"σ_∥ analytical") + ax_split.plot(t_pc, s_pc_par, "--", color="#ff7f0e", lw=1.2, + label=r"σ_∥ ETD-2") + ax_split.set_xlabel("time") + ax_split.set_ylabel(r"branch stress") + ax_split.set_title("Per-component branches resolved separately") + ax_split.legend(loc="upper right", fontsize=8) + ax_split.grid(alpha=0.3) + + # Error sweep (log-log) + ax_err.loglog(dt_sweep, err_pc, "o-", color="#1f77b4", label="per-component") + ax_err.loglog(dt_sweep, err_lump_eff, "s--", color="#d62728", label=r"lumped τ_⊥+τ_∥") + ax_err.loglog(dt_sweep, err_lump_slow, "^:", color="#9467bd", label=r"lumped τ_⊥") + ax_err.loglog(dt_sweep, err_lump_min, "v:", color="#2ca02c", label=r"lumped τ_∥") + ax_err.set_xlabel(r"Δt") + ax_err.set_ylabel(r"max|err|/A∞_total") + ax_err.set_title(r"Error vs Δt (log-log)") + ax_err.legend(loc="lower right", fontsize=8) + ax_err.grid(alpha=0.3, which="both") + + fig.suptitle( + "Phase D — per-component ETD-2 vs lumped variants " + rf"(parallel Maxwell branches, τ_⊥={TAU_PERP}, τ_∥={TAU_PAR})", + y=0.995, fontsize=11, + ) + fig.tight_layout() + + out_png = os.path.join(OUT_DIR, "exp_integrator_phase_d_split.png") + fig.savefig(out_png, dpi=140) + plt.close(fig) + print(f"\n wrote {out_png}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/docs/developer/design/_exp_integrator_uw3_jury_rig.py b/docs/developer/design/_exp_integrator_uw3_jury_rig.py new file mode 100644 index 000000000..f2867c644 --- /dev/null +++ b/docs/developer/design/_exp_integrator_uw3_jury_rig.py @@ -0,0 +1,362 @@ +"""Jury-rigged exponential integrator in UW3 — proof of concept. + +Tests: + 1. Iso VE harmonic on a 2:1 box (matches bench_ve_harmonic geometry). + Decision gate: must reproduce BDF-2's max|err| ≈ 1.34e-3 or beat it. + 2. Iso VEP harmonic with spatial yield_stress field on the 1:1 mesh + that blew up the BDF-2 consistency test. Decision gate: must + stay bounded with reasonable peak |σ| ≤ 1.1·τ_y. + +Architecture (jury-rig, not production): + + * Custom ``MaxwellExpFlowModel`` subclasses ``ViscousFlowModel`` and + overrides ``flux`` to return: + + σ = 2·η_eff·(1-φ)·ε̇ + α·σⁿ + 2·η_eff·(φ-α)·ε̇ⁿ + + where ``α, φ`` are scalar UWexpressions updated per step. + + * Two new MeshVariables (``sigma_n_var``, ``epsdot_n_var``) hold the + two history streams. After each solve we write the new σ and ε̇ + back to these variables via direct nodal evaluation — not L2 + projection. Adequate for this proof-of-concept; production would + use SNES_Tensor_Projection. + + * Yield handling (Test 2): η_eff = softmin(η(1-φ), η_pl), η_pl = + τ_y/(2·|ε̇_inv|). Lagged-τ: τ_eff used in α, φ comes from the + *previous* step's η_eff (Picard-style; full self-consistent + iteration would solve τ↔σ inside the SNES). +""" + +import numpy as np +import sympy +import underworld3 as uw +from underworld3 import VarType +from underworld3.function import expression +from underworld3.constitutive_models import ViscousFlowModel + + +# ───────────────────────────────────────────────────────────────── +# Custom constitutive model +# ───────────────────────────────────────────────────────────────── + +class MaxwellExpFlowModel(ViscousFlowModel): + """Jury-rigged Maxwell with exponential time integration. + + flux = 2·η_eff·(1-φ)·ε̇ + α·σⁿ + 2·η·(φ-α)·ε̇ⁿ + + For VE: η_eff = η. For VEP: η_eff = softmin(η, η_pl) (set externally + on the model's ``yield_stress`` parameter and recomputed per step). + """ + + def __init__(self, unknowns, sigma_n_var, epsdot_n_var, **kwargs): + super().__init__(unknowns, **kwargs) + self._sigma_n = sigma_n_var + self._epsdot_n = epsdot_n_var + # Coefficients updated per timestep. Initialise to non-degenerate + # values so the JIT compile produces an invertible Jacobian — at + # JIT time, the autodiff bakes in the symbolic structure, but the + # FIRST PetscDS constants[] update uses the *current* UWexpression + # values, so we want 2η(1-φ) > 0 even if update_exp_coeffs hasn't + # been called yet. φ = 0 means "fully viscous (no relaxation)"; + # this is the right "neutral" starting point. + self._exp_alpha = expression(r"{\alpha_{\rm exp}}", sympy.Float(0.0), + "exponential α = exp(-Δt/τ)") + self._exp_phi = expression(r"{\varphi_{\rm exp}}", sympy.Float(0.0), + "exponential φ = (1-α)/(Δt/τ)") + # Yield-stress UWexpression — set externally for VEP, leave at oo for VE + self._tau_y = expression(r"{\tau_y}", sympy.oo, "yield stress") + self._strainrate_min = expression(r"{\dot\varepsilon_{\min}}", + sympy.Float(1e-6), + "strain-rate floor for η_pl") + self._softness = 0.1 # softmin δ + + @property + def K(self): + """Stiffness for saddle preconditioner — use raw η.""" + return self.Parameters.shear_viscosity_0 + + def _eta_eff(self): + """Yield-limited viscosity (softmin).""" + eta = self.Parameters.shear_viscosity_0 + ty = self._tau_y + # If τ_y is ∞, no yield + if hasattr(ty, 'sym') and ty.sym is sympy.oo: + return eta + # η_pl = τ_y / (2·|ε̇_inv|). Use current ε̇ (sym(∇u)) + E = self.Unknowns.E + epsII = sympy.sqrt((E**2).trace() / 2) + eta_pl = ty / (2 * (epsII + self._strainrate_min)) + # softmin(eta, eta_pl) + delta = self._softness + f = eta / eta_pl + import math + offset = (-1 + math.sqrt(1 + delta**2)) / 2 + g = 1 + (f - 1 + sympy.sqrt((f - 1)**2 + delta**2)) / 2 - offset + return eta / g + + @property + def flux(self): + """Stress = 2·η_eff·(1-φ)·ε̇ + α·σⁿ + 2·η·(φ-α)·ε̇ⁿ.""" + eta_eff = self._eta_eff() + eta_raw = self.Parameters.shear_viscosity_0 + E = self.Unknowns.E + return (2 * eta_eff * (1 - self._exp_phi) * E + + self._exp_alpha * self._sigma_n.sym + + 2 * eta_raw * (self._exp_phi - self._exp_alpha) * self._epsdot_n.sym) + + +# ───────────────────────────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────────────────────────── + +def update_exp_coeffs(cm, dt, tau_eff): + """Set α, φ on the constitutive model for this step. Uses the + *lagged* τ_eff from the previous step's η_eff.""" + x = float(dt) / float(tau_eff) + if x < 1e-10: + alpha, phi = 1.0, 1.0 + else: + alpha = float(np.exp(-x)) + phi = (1.0 - alpha) / x + cm._exp_alpha.sym = sympy.Float(alpha) + cm._exp_phi.sym = sympy.Float(phi) + + +def project_history(stokes, cm, sigma_n_var, epsdot_n_var, + eta_raw, alpha_val, phi_val): + """After a solve, update σⁿ and ε̇ⁿ history variables. + + Strategy that avoids the Matrix-evaluate-with-derivatives issue: + 1. Evaluate ε̇^{n+1} component-wise (scalars) and write to a + temporary array. + 2. Compute σ^{n+1} purely on numpy .array data using the + exponential update formula (mesh-variable reads, no derivs). + 3. Update both history variables. + """ + # Step 1: project ε̇^{n+1} component-wise + E_sym = stokes.Unknowns.E + coords = epsdot_n_var.coords + e_xx = np.asarray(uw.function.evaluate(E_sym[0, 0], coords)).flatten() + e_xy = np.asarray(uw.function.evaluate(E_sym[0, 1], coords)).flatten() + e_yy = np.asarray(uw.function.evaluate(E_sym[1, 1], coords)).flatten() + new_epsdot = np.zeros_like(epsdot_n_var.array) + new_epsdot[:, 0, 0] = e_xx + new_epsdot[:, 1, 1] = e_yy + new_epsdot[:, 0, 1] = e_xy + new_epsdot[:, 1, 0] = e_xy + + # Step 2: σ^{n+1} = α·σⁿ + 2η(1-φ)·ε̇^{n+1} + 2η(φ-α)·ε̇ⁿ + # All quantities are nodal arrays — pure numpy. + a = alpha_val + p = phi_val + sigma_old = np.array(sigma_n_var.array) # σⁿ (snapshot) + epsdot_old = np.array(epsdot_n_var.array) # ε̇ⁿ (snapshot) + new_sigma = (a * sigma_old + + 2 * eta_raw * (1 - p) * new_epsdot + + 2 * eta_raw * (p - a) * epsdot_old) + + # Step 3: write back + sigma_n_var.array[...] = new_sigma + epsdot_n_var.array[...] = new_epsdot + + +def probe_centre_xy(sigma_n_var, c=np.array([[0.5, 0.5]])): + """Read σ_xy at the domain centre from sigma_n_var.""" + coords = sigma_n_var.coords + idx = int(np.argmin(np.linalg.norm(coords - c, axis=1))) + return float(sigma_n_var.array[idx, 0, 1]) + + +# ───────────────────────────────────────────────────────────────── +# Test 1 — Iso VE harmonic, 2:1 antisymmetric box, peak-start IC +# ───────────────────────────────────────────────────────────────── + +def test_1_iso_VE_harmonic(): + print("\n=== Test 1: iso VE harmonic — exp integrator vs BDF-2 baseline ===", + flush=True) + ETA = 1.0; MU = 1.0 + V0 = 0.5 + OMEGA = np.pi / 2.0 + DT = 0.05 + T_END = 4 * 2 * np.pi / OMEGA # 4 periods + + H = 1.0; W = 2.0 + mesh = uw.meshing.StructuredQuadBox( + elementRes=(16, 8), minCoords=(-W/2, -H/2), maxCoords=(W/2, H/2), + ) + v = uw.discretisation.MeshVariable("U_exp", mesh, 2, degree=2) + p = uw.discretisation.MeshVariable("P_exp", mesh, 1, degree=1) + sigma_n = uw.discretisation.MeshVariable( + "sigma_n", mesh, 2, degree=2, vtype=VarType.SYM_TENSOR, + ) + epsdot_n = uw.discretisation.MeshVariable( + "epsdot_n", mesh, 2, degree=2, vtype=VarType.SYM_TENSOR, + ) + + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + cm = MaxwellExpFlowModel(stokes.Unknowns, sigma_n, epsdot_n) + cm.Parameters.shear_viscosity_0 = ETA + stokes.constitutive_model = cm + stokes.tolerance = 1e-6 + stokes.petsc_options["snes_force_iteration"] = True + + # Antisymmetric BCs (matches bench_ve_harmonic.py) + V_top_expr = expression(r"V_{top}", sympy.Float(0.0), "Top BC") + stokes.add_essential_bc((V_top_expr, 0.0), "Top") + stokes.add_essential_bc((-V_top_expr, 0.0), "Bottom") + stokes.add_essential_bc((sympy.oo, 0.0), "Left") + stokes.add_essential_bc((sympy.oo, 0.0), "Right") + + # Peak-start IC: plant σ_xy = A_inf in sigma_n_var so step 1 starts + # at the steady-state cycle peak under cos(ωt+φ) forcing. + t_r = ETA / MU + De = OMEGA * t_r + gamma_dot_0 = 2.0 * V0 / H + A_inf = ETA * gamma_dot_0 / np.sqrt(1.0 + De**2) + phi_lag = float(np.arctan(De)) + + # Initialize sigma_n with σ_xy = A_inf, others = 0 + sigma_n.array[:, 0, 0] = 0.0 + sigma_n.array[:, 1, 1] = 0.0 + sigma_n.array[:, 0, 1] = A_inf + sigma_n.array[:, 1, 0] = A_inf + # Initialize epsdot_n with ε̇_xy = γ̇₀/2 · cos(-ω·DT + φ_lag) ≈ γ̇₀/2 + # The exact peak-start ε̇ value is small for the harmonic, but use 0 + # initially — the first solve will correct. + epsdot_n.array[...] = 0.0 + + # Pure VE: τ_eff = η/μ = 1 + tau_eff = ETA / MU + + times, sxys, reasons = [], [], [] + t_cur = 0.0 + while t_cur < T_END - 1e-9: + update_exp_coeffs(cm, DT, tau_eff) + t_end = t_cur + DT + v_now = V0 * float(np.cos(OMEGA * t_end + phi_lag)) + V_top_expr.sym = sympy.Float(v_now) + stokes.solve(zero_init_guess=False) + # Pull current α, φ from the model (we just set them via update_exp_coeffs) + a = float(cm._exp_alpha.sym); p = float(cm._exp_phi.sym) + project_history(stokes, cm, sigma_n, epsdot_n, + eta_raw=ETA, alpha_val=a, phi_val=p) + sxys.append(probe_centre_xy(sigma_n, c=np.array([[0.0, 0.0]]))) + reasons.append(int(stokes.snes.getConvergedReason())) + times.append(t_end) + t_cur = t_end + + times = np.array(times); sxys = np.array(sxys) + sigma_ana = A_inf * np.cos(OMEGA * times) + err = np.abs(sxys - sigma_ana) + print(f" steps={len(times)}, peak|σ_xy|={np.abs(sxys).max():.4f}, " + f"max|err|={err.max():.4e}, rms={np.sqrt((err**2).mean()):.4e}", + flush=True) + print(f" diverged: {(np.array(reasons) < 0).sum()}/{len(reasons)}", + flush=True) + print(f" baseline (bench_ve_harmonic BDF-2 peak-start): max|err| = 1.34e-3", + flush=True) + return times, sxys, sigma_ana + + +# ───────────────────────────────────────────────────────────────── +# Test 2 — Iso VEP with spatial yield_stress (the consistency case) +# ───────────────────────────────────────────────────────────────── + +def test_2_iso_VEP_spatial(): + print("\n=== Test 2: iso VEP harmonic w/ spatial τ_y — exp vs BDF-2 (which blew up) ===", + flush=True) + ETA = 1.0; MU = 1.0 + V0 = 0.5 + OMEGA = np.pi / 2.0 + DT = 0.05 + T_END = 16.0 # match the consistency test + TAU_Y_FAULT = 0.30 + TAU_Y_BULK = 200.0 + + mesh = uw.meshing.StructuredQuadBox( + elementRes=(16, 16), minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), + qdegree=3, + ) + v = uw.discretisation.MeshVariable("U_exp2", mesh, 2, degree=2) + p = uw.discretisation.MeshVariable("P_exp2", mesh, 1, degree=1) + sigma_n = uw.discretisation.MeshVariable( + "sigma_n_2", mesh, 2, degree=2, vtype=VarType.SYM_TENSOR, + ) + epsdot_n = uw.discretisation.MeshVariable( + "epsdot_n_2", mesh, 2, degree=2, vtype=VarType.SYM_TENSOR, + ) + + fault = uw.meshing.Surface( + "fault_exp", mesh, + np.array([[0.2, 0.5], [0.8, 0.5]]), + ) + fault.discretize() + weakness = fault.influence_function( + width=0.06, value_near=1.0/TAU_Y_FAULT, value_far=1.0/TAU_Y_BULK, + profile="gaussian", + ) + ty_field = 1.0 / weakness + + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + cm = MaxwellExpFlowModel(stokes.Unknowns, sigma_n, epsdot_n) + cm.Parameters.shear_viscosity_0 = ETA + cm._tau_y.sym = ty_field # spatial yield_stress + stokes.constitutive_model = cm + stokes.tolerance = 1e-6 + stokes.petsc_options["snes_force_iteration"] = True + + V_top_expr = expression(r"V_{top}^{(2)}", sympy.Float(0.0), "Top BC") + stokes.add_essential_bc((V_top_expr, 0.0), "Top") + stokes.add_essential_bc((0.0, 0.0), "Bottom") # asymmetric (matches consistency test) + stokes.add_essential_bc((sympy.oo, 0.0), "Left") + stokes.add_essential_bc((sympy.oo, 0.0), "Right") + + # σ=0 IC + sigma_n.array[...] = 0.0 + epsdot_n.array[...] = 0.0 + + # Lagged τ_eff — start at τ_VE = η/μ = 1 + tau_eff = ETA / MU + + times, sxys, reasons = [], [], [] + t_cur = 0.0 + phi_lag = float(np.arctan(OMEGA)) + n_steps = int(T_END / DT) + for step in range(n_steps): + update_exp_coeffs(cm, DT, tau_eff) + t_end = t_cur + DT + v_now = V0 * float(np.cos(OMEGA * t_end + phi_lag)) + V_top_expr.sym = sympy.Float(v_now) + try: + stokes.solve(zero_init_guess=False, divergence_retries=2) + except Exception as exc: + print(f" step {step+1}: solve failed: {exc}", flush=True) + break + # Pull current α, φ from the model (we just set them via update_exp_coeffs) + a = float(cm._exp_alpha.sym); p = float(cm._exp_phi.sym) + project_history(stokes, cm, sigma_n, epsdot_n, + eta_raw=ETA, alpha_val=a, phi_val=p) + sxys.append(probe_centre_xy(sigma_n, c=np.array([[0.5, 0.5]]))) + reasons.append(int(stokes.snes.getConvergedReason())) + times.append(t_end) + t_cur = t_end + + times = np.array(times); sxys = np.array(sxys) + print(f" steps={len(times)}, peak|σ_xy|={np.abs(sxys).max():.4f}, " + f"diverged: {(np.array(reasons) < 0).sum()}/{len(reasons)}", + flush=True) + print(f" baseline (BDF-2 same setup, consistency test): peak|σ_xy| = 13377 ← BLEW UP", + flush=True) + print(f" expected exp result: bounded |σ_xy| ≲ τ_y = 0.30 (yield-clipped)", + flush=True) + return times, sxys + + +def main(): + test_1_iso_VE_harmonic() + test_2_iso_VEP_spatial() + + +if __name__ == "__main__": + main() diff --git a/docs/developer/design/_exp_jury_rig_minimal.py b/docs/developer/design/_exp_jury_rig_minimal.py new file mode 100644 index 000000000..e5f1be747 --- /dev/null +++ b/docs/developer/design/_exp_jury_rig_minimal.py @@ -0,0 +1,150 @@ +"""Minimal jury-rig: build up the exponential constitutive model term by term. + + Step A: pure Newton fluid (sanity check the custom-class plumbing) + Step B: Newton fluid + constant σⁿ history (additive stress) + Step C: Add α·σⁿ with α<1 (real exp) + Step D: Add ε̇ⁿ history term (full ETD-2) + +If any step diverges, the failing addition is identified. +""" + +import numpy as np +import sympy +import underworld3 as uw +from underworld3 import VarType +from underworld3.function import expression +from underworld3.constitutive_models import ViscousFlowModel + + +ETA = 1.0; MU = 1.0 +V0 = 0.5 +DT = 0.05 +T_END = 0.5 # short — just need a few steps + + +def setup(): + mesh = uw.meshing.StructuredQuadBox( + elementRes=(8, 4), minCoords=(-1, -0.5), maxCoords=(1, 0.5), + ) + v = uw.discretisation.MeshVariable("U_min", mesh, 2, degree=2) + p = uw.discretisation.MeshVariable("P_min", mesh, 1, degree=1) + sigma_n = uw.discretisation.MeshVariable( + "sigma_n_m", mesh, 2, degree=2, vtype=VarType.SYM_TENSOR, + ) + epsdot_n = uw.discretisation.MeshVariable( + "epsdot_n_m", mesh, 2, degree=2, vtype=VarType.SYM_TENSOR, + ) + # Initialise to zero + sigma_n.array[...] = 0.0 + epsdot_n.array[...] = 0.0 + return mesh, v, p, sigma_n, epsdot_n + + +def make_solver(mesh, v, p, custom_flux_fn): + """Build a Stokes solver with a custom-flux constitutive model.""" + + class _Custom(ViscousFlowModel): + @property + def K(self): + return self.Parameters.shear_viscosity_0 + + @property + def flux(self): + return custom_flux_fn(self) + + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + cm = _Custom(stokes.Unknowns) + cm.Parameters.shear_viscosity_0 = ETA + stokes.constitutive_model = cm + stokes.tolerance = 1e-6 + stokes.petsc_options["snes_force_iteration"] = True + + V_top = expression(r"V_t", sympy.Float(0.0), "Top BC") + stokes.add_essential_bc((V_top, 0.0), "Top") + stokes.add_essential_bc((-V_top, 0.0), "Bottom") + stokes.add_essential_bc((sympy.oo, 0.0), "Left") + stokes.add_essential_bc((sympy.oo, 0.0), "Right") + return stokes, cm, V_top + + +def run_a_few_steps(stokes, V_top, label, n_steps=4): + print(f"\n--- {label} ---", flush=True) + diverged = 0 + last_iters = 0 + for step in range(n_steps): + V_top.sym = sympy.Float(V0) + try: + stokes.solve(zero_init_guess=(step == 0)) + except Exception as exc: + print(f" step {step+1}: solve raised: {exc}", flush=True) + diverged += 1 + break + reason = int(stokes.snes.getConvergedReason()) + last_iters = int(stokes.snes.getIterationNumber()) + if reason < 0: + diverged += 1 + print(f" step {step+1}: SNES diverged (reason={reason})", flush=True) + else: + print(f" step {step+1}: converged in {last_iters} its (reason={reason})", + flush=True) + + +def main(): + # Step A: pure Newton fluid + mesh, v, p, sigma_n, epsdot_n = setup() + stokes, cm, V_top = make_solver( + mesh, v, p, + custom_flux_fn=lambda self: 2 * self.Parameters.shear_viscosity_0 * self.Unknowns.E, + ) + run_a_few_steps(stokes, V_top, "A: pure Newton (2η·ε̇)") + + # Step B: Newton + uniform constant σ added (use sigma_n with σ_xy=0.3 baked in) + mesh, v, p, sigma_n, epsdot_n = setup() + sigma_n.array[:, 0, 1] = 0.3 + sigma_n.array[:, 1, 0] = 0.3 + stokes, cm, V_top = make_solver( + mesh, v, p, + custom_flux_fn=lambda self: ( + 2 * self.Parameters.shear_viscosity_0 * self.Unknowns.E + + sigma_n.sym + ), + ) + run_a_few_steps(stokes, V_top, "B: Newton + σⁿ (constant uniform σ_xy=0.3)") + + # Step C: scaled-down viscosity with α·σⁿ history (representative of exp) + mesh, v, p, sigma_n, epsdot_n = setup() + sigma_n.array[:, 0, 1] = 0.3 + sigma_n.array[:, 1, 0] = 0.3 + alpha_expr = expression(r"\alpha", sympy.Float(0.95), "α") + phi_expr = expression(r"\varphi", sympy.Float(0.975), "φ") + stokes, cm, V_top = make_solver( + mesh, v, p, + custom_flux_fn=lambda self: ( + 2 * self.Parameters.shear_viscosity_0 * (1 - phi_expr) * self.Unknowns.E + + alpha_expr * sigma_n.sym + ), + ) + run_a_few_steps(stokes, V_top, "C: 2η(1-φ)·ε̇ + α·σⁿ (φ=0.975, α=0.95)") + + # Step D: full ETD-2 form including ε̇ⁿ history + mesh, v, p, sigma_n, epsdot_n = setup() + sigma_n.array[:, 0, 1] = 0.3 + sigma_n.array[:, 1, 0] = 0.3 + epsdot_n.array[:, 0, 1] = 0.5 + epsdot_n.array[:, 1, 0] = 0.5 + alpha_expr = expression(r"\alpha", sympy.Float(0.95), "α") + phi_expr = expression(r"\varphi", sympy.Float(0.975), "φ") + stokes, cm, V_top = make_solver( + mesh, v, p, + custom_flux_fn=lambda self: ( + 2 * self.Parameters.shear_viscosity_0 * (1 - phi_expr) * self.Unknowns.E + + alpha_expr * sigma_n.sym + + 2 * self.Parameters.shear_viscosity_0 + * (phi_expr - alpha_expr) * epsdot_n.sym + ), + ) + run_a_few_steps(stokes, V_top, "D: full ETD-2 form") + + +if __name__ == "__main__": + main() diff --git a/docs/developer/design/_phase_b_bdf2_at_tight_yield.py b/docs/developer/design/_phase_b_bdf2_at_tight_yield.py new file mode 100644 index 000000000..81bc1a250 --- /dev/null +++ b/docs/developer/design/_phase_b_bdf2_at_tight_yield.py @@ -0,0 +1,238 @@ +"""BDF-2 trajectory at τ_y=0.05, θ=+15° — companion to the BDF-1/ETD/split/hybrid +captures so the user can see the original BDF-2 instability that motivated +the whole ETD investigation. + +Same setup as ``_phase_b_bdf_vs_etd_at_tight_yield.py`` but with ``order=2`` +on the BDF integrator. Saves σ_∥ probe + same trajectory metrics. +""" + +import os +import time + +import numpy as np +import sympy + +import underworld3 as uw +from underworld3 import VarType +from underworld3.function import expression + + +V0 = 0.5 +OMEGA = np.pi / 2.0 +DT = 0.05 +H = 1.0; W = 1.0 +FAULT_LENGTH = 0.6 +FAULT_WIDTH = 0.06 +ETA_0 = 1.0; ETA_1 = 1.0; MU = 1.0 +TAU_Y_BULK = 200.0 +RES = 32 + +OUT_DIR = "output" + + +def run_bdf2(theta_deg, tau_y_at_fault, n_periods=1.5): + label = f"bdf2_th{theta_deg:+.0f}_ty{tau_y_at_fault:.2f}".replace(".", "p") + + mesh = uw.meshing.StructuredQuadBox( + elementRes=(RES, RES), + minCoords=(0.0, 0.0), maxCoords=(W, H), + qdegree=3, + ) + u = uw.discretisation.MeshVariable(f"U_{label}", mesh, 2, degree=2, + vtype=VarType.VECTOR) + p_sol = uw.discretisation.MeshVariable(f"P_{label}", mesh, 1, degree=1, + continuous=True, vtype=VarType.SCALAR) + + theta = np.radians(theta_deg) + cx, cy = 0.5 * W, 0.5 * H + dx = 0.5 * FAULT_LENGTH * np.cos(theta) + dy = 0.5 * FAULT_LENGTH * np.sin(theta) + fault = uw.meshing.Surface( + f"fault_{label}", mesh, + np.array([[cx - dx, cy - dy], [cx + dx, cy + dy]]), + symbol=f"F{label}", + ) + fault.discretize() + + n_x = -np.sin(theta); n_y = np.cos(theta) + director = sympy.Matrix([n_x, n_y]) + weakness = fault.influence_function( + width=FAULT_WIDTH, + value_near=1.0 / tau_y_at_fault, value_far=1.0 / TAU_Y_BULK, + profile="gaussian", + ) + tau_y_field = 1.0 / weakness + + stokes = uw.systems.Stokes(mesh, velocityField=u, pressureField=p_sol) + stokes.constitutive_model = uw.constitutive_models.TransverseIsotropicVEPFlowModel( + stokes.Unknowns, integrator="bdf", order=2, + ) + cm = stokes.constitutive_model + cm.Parameters.shear_viscosity_0 = ETA_0 + cm.Parameters.shear_viscosity_1 = ETA_1 + cm.Parameters.shear_modulus = MU + cm.Parameters.yield_stress = tau_y_field + cm.Parameters.director = director + cm.Parameters.shear_viscosity_min = ETA_0 * 1.0e-3 + cm.Parameters.strainrate_inv_II_min = 1.0e-6 + cm.yield_mode = "softmin" + + stokes.saddle_preconditioner = 1.0 / cm.K + stokes.tolerance = 1.0e-4 + stokes.petsc_options["ksp_type"] = "fgmres" + stokes.petsc_options["snes_force_iteration"] = True + + V_top = expression(rf"V_{{top,{label}}}", sympy.Float(0.0), "Top BC") + stokes.add_essential_bc(sympy.Matrix([V_top, 0.0]), "Top") + stokes.add_essential_bc(sympy.Matrix([0.0, 0.0]), "Bottom") + stokes.add_essential_bc((sympy.oo, 0.0), "Left") + stokes.add_essential_bc((sympy.oo, 0.0), "Right") + stokes.bodyforce = sympy.Matrix([0.0, 0.0]) + + DFDt = stokes.Unknowns.DFDt + + T_END = n_periods * 2.0 * np.pi / OMEGA + iters = []; reasons = [] + sigma_II_max_per_step = [] + u_y_max_per_step = [] + sigma_xy_centre = [] + sigma_par_centre = [] + centre = np.array([[cx, cy]]) + n_x_val = -float(np.sin(theta)) + n_y_val = float(np.cos(theta)) + + t_cur = 0.0 + t0 = time.time() + while t_cur < T_END - 1e-9: + dt = min(DT, T_END - t_cur) + t_end_step = t_cur + dt + v_now = V0 * float(np.cos(OMEGA * t_end_step)) + V_top.sym = sympy.Float(v_now) + cm.Parameters.dt_elastic = dt + try: + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=2) + except Exception as exc: + print(f" step at t={t_end_step:.3f}: solve raised — {exc}", flush=True) + iters.append(-1) + reasons.append(-99) + break + + iters.append(int(stokes.snes.getIterationNumber())) + reasons.append(int(stokes.snes.getConvergedReason())) + + sigma_arr = np.asarray(DFDt.psi_star[0].array) + sigma_II = np.sqrt(0.5 * (sigma_arr ** 2).sum(axis=(1, 2))) + sigma_II_max_per_step.append(float(sigma_II.max())) + u_arr = np.asarray(u.array).reshape(-1, 2) + u_y_max_per_step.append(float(np.abs(u_arr[:, 1]).max())) + sxy_centre = float(uw.function.evaluate(stokes.tau.sym[0, 1], centre).flatten()[0]) + sigma_xy_centre.append(sxy_centre) + sxx_c = float(uw.function.evaluate(stokes.tau.sym[0, 0], centre).flatten()[0]) + syy_c = float(uw.function.evaluate(stokes.tau.sym[1, 1], centre).flatten()[0]) + T_x = sxx_c * n_x_val + sxy_centre * n_y_val + T_y = sxy_centre * n_x_val + syy_c * n_y_val + sig_nn = T_x * n_x_val + T_y * n_y_val + sig_par = float(np.sqrt(max(T_x ** 2 + T_y ** 2 - sig_nn ** 2, 0.0))) + sigma_par_centre.append(sig_par) + + # Per-step running output so a runaway is visible immediately. + step_idx = len(iters) + if step_idx <= 5 or step_idx % 5 == 0: + print( + f" step {step_idx:3d}/120 t={t_end_step:5.3f} " + f"V={v_now:+.3f} iters={iters[-1]:2d} " + f"|σ|_II={sigma_II_max_per_step[-1]:.3e} " + f"|u_y|={u_y_max_per_step[-1]:.3e} " + f"|σ_∥|={sig_par:.3e}", + flush=True, + ) + + # Runaway guard — BDF-2 instability on TI-VEP+spatial yield is + # the documented original-investigation gap. Break and save the + # partial trajectory so we can plot the blow-up. + if sigma_II_max_per_step[-1] > 100.0 or u_y_max_per_step[-1] > 10.0: + print( + f" *** runaway detected at step {step_idx}: " + f"|σ|_II={sigma_II_max_per_step[-1]:.3e}, " + f"|u_y|={u_y_max_per_step[-1]:.3e} — breaking ***", + flush=True, + ) + break + + t_cur = t_end_step + + iters_arr = np.array(iters) + reasons_arr = np.array(reasons) + + print( + f" ran {len(iters)} steps in {time.time()-t0:.1f}s; " + f"BDF-2, order=2, τ_y_fault={tau_y_at_fault}", + flush=True, + ) + if iters_arr.size > 0 and (iters_arr >= 0).any(): + print( + f" SNES iters per step (bdf2): mean={iters_arr[iters_arr>=0].mean():.1f} " + f"median={int(np.median(iters_arr[iters_arr>=0]))} " + f"max={iters_arr[iters_arr>=0].max()} " + f"diverged={int((reasons_arr<0).sum())}/{len(reasons_arr)}", + flush=True, + ) + if sigma_II_max_per_step: + print( + f" max |σ|_II per step: end={sigma_II_max_per_step[-1]:.4f} " + f"global max={max(sigma_II_max_per_step):.4f}", + flush=True, + ) + print( + f" max |u_y| per step: end={u_y_max_per_step[-1]:.4f} " + f"global max={max(u_y_max_per_step):.4f}", + flush=True, + ) + print( + f" centre |σ_xy| time series: " + f"end={abs(sigma_xy_centre[-1]):.4f} " + f"peak={max(abs(s) for s in sigma_xy_centre):.4f} " + f"({max(abs(s) for s in sigma_xy_centre)/tau_y_at_fault:.2f}·τ_y_fault)", + flush=True, + ) + print( + f" centre |σ_∥| (resolved): " + f"end={sigma_par_centre[-1]:.4f} " + f"peak={max(sigma_par_centre):.4f} " + f"({max(sigma_par_centre)/tau_y_at_fault:.2f}·τ_y_fault)", + flush=True, + ) + + out_npz = os.path.join( + OUT_DIR, + f"phase_b_bdf2_th{theta_deg:+.0f}_ty{tau_y_at_fault:.2f}".replace(".", "p") + ".npz", + ) + np.savez( + out_npz, + iters=iters_arr, + reasons=reasons_arr, + sigma_II_max_per_step=np.asarray(sigma_II_max_per_step), + u_y_max_per_step=np.asarray(u_y_max_per_step), + sigma_xy_centre=np.asarray(sigma_xy_centre), + sigma_par_centre=np.asarray(sigma_par_centre), + theta_deg=np.array(theta_deg), + tau_y_at_fault=np.array(tau_y_at_fault), + T_END=np.array(T_END), + n_steps=np.array(len(iters)), + wall_seconds=np.array(time.time() - t0), + ) + print(f" saved → {out_npz}", flush=True) + + +def main(): + os.makedirs(OUT_DIR, exist_ok=True) + cache = os.path.join(OUT_DIR, "phase_b_bdf2_th+15_ty0p05.npz") + if os.path.exists(cache): + print(f"=== BDF-2 cache hit: {cache} — skipping run ===", flush=True) + return + print("=== BDF-2: θ=+15°, τ_y=0.05 ===", flush=True) + run_bdf2(15.0, 0.05, n_periods=1.5) + + +if __name__ == "__main__": + main() diff --git a/docs/developer/design/_phase_b_bdf2_th+15_ty0p05.trace.txt b/docs/developer/design/_phase_b_bdf2_th+15_ty0p05.trace.txt new file mode 100644 index 000000000..1555b41af --- /dev/null +++ b/docs/developer/design/_phase_b_bdf2_th+15_ty0p05.trace.txt @@ -0,0 +1,15 @@ +=== BDF-2: θ=+15°, τ_y=0.05 === +Structured box element resolution 32 32 + step 1/120 t=0.050 V=+0.498 iters= 1 |σ|_II=2.383e-02 |u_y|=1.003e-05 |σ_∥|=2.052e-02 + step 2/120 t=0.100 V=+0.494 iters= 1 |σ|_II=4.634e-02 |u_y|=8.526e-05 |σ_∥|=3.989e-02 + step 3/120 t=0.150 V=+0.486 iters= 3 |σ|_II=7.835e-02 |u_y|=9.166e-03 |σ_∥|=5.149e-02 + step 4/120 t=0.200 V=+0.476 iters= 2 |σ|_II=1.296e-01 |u_y|=2.537e-02 |σ_∥|=5.081e-02 + step 5/120 t=0.250 V=+0.462 iters= 2 |σ|_II=2.099e-01 |u_y|=2.466e-02 |σ_∥|=5.069e-02 + step 10/120 t=0.500 V=+0.354 iters= 2 |σ|_II=6.482e-01 |u_y|=2.372e-02 |σ_∥|=5.034e-02 + step 15/120 t=0.750 V=+0.191 iters= 2 |σ|_II=8.816e-01 |u_y|=2.833e-02 |σ_∥|=5.033e-02 + step 20/120 t=1.000 V=-0.000 iters= 4 |σ|_II=8.373e-01 |u_y|=1.561e-02 |σ_∥|=4.854e-02 + step 25/120 t=1.250 V=-0.191 iters= 1 |σ|_II=6.041e-01 |u_y|=1.319e-02 |σ_∥|=1.914e-02 + step 30/120 t=1.500 V=-0.354 iters= 2 |σ|_II=7.496e-01 |u_y|=3.150e-02 |σ_∥|=3.976e-02 + step 35/120 t=1.750 V=-0.462 iters= 2 |σ|_II=1.872e+00 |u_y|=7.211e-02 |σ_∥|=4.820e-02 + step 40/120 t=2.000 V=-0.500 iters= 4 |σ|_II=1.423e+01 |u_y|=8.612e-01 |σ_∥|=4.884e-02 + step 45/120 t=2.250 V=-0.462 iters=10 |σ|_II=6.761e+01 |u_y|=9.766e+00 |σ_∥|=8.159e-02 diff --git a/docs/developer/design/_phase_b_bdf_vs_etd_at_tight_yield.py b/docs/developer/design/_phase_b_bdf_vs_etd_at_tight_yield.py new file mode 100644 index 000000000..f0d2fef7d --- /dev/null +++ b/docs/developer/design/_phase_b_bdf_vs_etd_at_tight_yield.py @@ -0,0 +1,252 @@ +"""Phase B: BDF-1 vs ETD-2 trajectory comparison at τ_y=0.05. + +The user asked whether the catastrophic ETD-2 runaway at τ_y=0.05 is +specific to ETD-2 or a problem-class issue affecting BDF as well. This +script runs the bench_ti_vep_harmonic geometry at τ_y=0.05 with both +``integrator='bdf'`` (production BDF-1) and ``integrator='etd'`` +(ETD-2 trial) and saves matching time series so we can plot them on +the same axes. + +Running θ=+15° (the more demanding angled case) for 1.5 periods at +RES=32 — same setup as ``output/phase_b_th+15_ty0p05.*``. + +Run:: + + pixi run -e amr-dev python -u docs/developer/design/_phase_b_bdf_vs_etd_at_tight_yield.py +""" + +import os +import sys +import time + +import numpy as np +import sympy + +import underworld3 as uw +from underworld3 import VarType +from underworld3.function import expression + + +# Match the ETD-2 demo parameters +V0 = 0.5 +OMEGA = np.pi / 2.0 +DT = 0.05 +H = 1.0; W = 1.0 +FAULT_LENGTH = 0.6 +FAULT_WIDTH = 0.06 +ETA_0 = 1.0; ETA_1 = 1.0; MU = 1.0 +TAU_Y_BULK = 200.0 +RES = 32 + +OUT_DIR = "output" + + +def run_case(theta_deg, tau_y_at_fault, integrator, n_periods=1.5): + """Run one (integrator, θ, τ_y) trajectory and save a time-series npz. + + integrator: 'bdf' (BDF-1) or 'etd' (ETD-2). + """ + if integrator == "bdf": + order = 1 + elif integrator == "etd": + order = 2 + else: + raise ValueError(f"unknown integrator '{integrator}'") + + label = f"{integrator}_th{theta_deg:+.0f}_ty{tau_y_at_fault:.2f}".replace(".", "p") + + mesh = uw.meshing.StructuredQuadBox( + elementRes=(RES, RES), + minCoords=(0.0, 0.0), maxCoords=(W, H), + qdegree=3, + ) + u = uw.discretisation.MeshVariable(f"U_{label}", mesh, 2, degree=2, + vtype=VarType.VECTOR) + p_sol = uw.discretisation.MeshVariable(f"P_{label}", mesh, 1, degree=1, + continuous=True, vtype=VarType.SCALAR) + + theta = np.radians(theta_deg) + cx, cy = 0.5 * W, 0.5 * H + dx = 0.5 * FAULT_LENGTH * np.cos(theta) + dy = 0.5 * FAULT_LENGTH * np.sin(theta) + fault = uw.meshing.Surface( + f"fault_{label}", mesh, + np.array([[cx - dx, cy - dy], [cx + dx, cy + dy]]), + symbol=f"F{label}", + ) + fault.discretize() + + n_x = -np.sin(theta); n_y = np.cos(theta) + director = sympy.Matrix([n_x, n_y]) + weakness = fault.influence_function( + width=FAULT_WIDTH, + value_near=1.0 / tau_y_at_fault, value_far=1.0 / TAU_Y_BULK, + profile="gaussian", + ) + tau_y_field = 1.0 / weakness + + stokes = uw.systems.Stokes(mesh, velocityField=u, pressureField=p_sol) + stokes.constitutive_model = uw.constitutive_models.TransverseIsotropicVEPFlowModel( + stokes.Unknowns, integrator=integrator, order=order, + ) + cm = stokes.constitutive_model + cm.Parameters.shear_viscosity_0 = ETA_0 + cm.Parameters.shear_viscosity_1 = ETA_1 + cm.Parameters.shear_modulus = MU + cm.Parameters.yield_stress = tau_y_field + cm.Parameters.director = director + cm.Parameters.shear_viscosity_min = ETA_0 * 1.0e-3 + cm.Parameters.strainrate_inv_II_min = 1.0e-6 + cm.yield_mode = "softmin" + + stokes.saddle_preconditioner = 1.0 / cm.K + stokes.tolerance = 1.0e-4 + stokes.petsc_options["ksp_type"] = "fgmres" + stokes.petsc_options["snes_force_iteration"] = True + + V_top = expression(rf"V_{{top,{label}}}", sympy.Float(0.0), "Top BC") + stokes.add_essential_bc(sympy.Matrix([V_top, 0.0]), "Top") + stokes.add_essential_bc(sympy.Matrix([0.0, 0.0]), "Bottom") + stokes.add_essential_bc((sympy.oo, 0.0), "Left") + stokes.add_essential_bc((sympy.oo, 0.0), "Right") + stokes.bodyforce = sympy.Matrix([0.0, 0.0]) + + DFDt = stokes.Unknowns.DFDt + sigma_coords = DFDt.psi_star[0].coords + + T_END = n_periods * 2.0 * np.pi / OMEGA + iters = []; reasons = [] + sigma_II_max_per_step = [] + u_y_max_per_step = [] + sigma_xy_centre = [] # at fault centre, time series + sigma_par_centre = [] # resolved fault-plane shear at fault centre + centre = np.array([[cx, cy]]) + n_x_val = -float(np.sin(theta)) + n_y_val = float(np.cos(theta)) + + t_cur = 0.0 + t0 = time.time() + while t_cur < T_END - 1e-9: + dt = min(DT, T_END - t_cur) + t_end_step = t_cur + dt + v_now = V0 * float(np.cos(OMEGA * t_end_step)) + V_top.sym = sympy.Float(v_now) + cm.Parameters.dt_elastic = dt + try: + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=2) + except Exception as exc: + print(f" step at t={t_end_step:.3f}: solve raised — {exc}", flush=True) + iters.append(-1) + reasons.append(-99) + break + + iters.append(int(stokes.snes.getIterationNumber())) + reasons.append(int(stokes.snes.getConvergedReason())) + + sigma_arr = np.asarray(DFDt.psi_star[0].array) + sigma_II = np.sqrt(0.5 * (sigma_arr ** 2).sum(axis=(1, 2))) + sigma_II_max_per_step.append(float(sigma_II.max())) + u_arr = np.asarray(u.array).reshape(-1, 2) + u_y_max_per_step.append(float(np.abs(u_arr[:, 1]).max())) + sxy_centre = float(uw.function.evaluate(stokes.tau.sym[0, 1], centre).flatten()[0]) + sigma_xy_centre.append(sxy_centre) + # Resolved fault-plane shear |σ_∥| at fault centre. + sxx_c = float(uw.function.evaluate(stokes.tau.sym[0, 0], centre).flatten()[0]) + syy_c = float(uw.function.evaluate(stokes.tau.sym[1, 1], centre).flatten()[0]) + T_x = sxx_c * n_x_val + sxy_centre * n_y_val + T_y = sxy_centre * n_x_val + syy_c * n_y_val + sig_nn = T_x * n_x_val + T_y * n_y_val + sig_par = float(np.sqrt(max(T_x ** 2 + T_y ** 2 - sig_nn ** 2, 0.0))) + sigma_par_centre.append(sig_par) + + t_cur = t_end_step + + iters_arr = np.array(iters) + reasons_arr = np.array(reasons) + + integrator_label = "BDF-1" if integrator == "bdf" else "ETD-2" + print( + f" ran {len(iters)} steps in {time.time()-t0:.1f}s; " + f"{integrator_label}, integrator='{integrator}', τ_y_fault={tau_y_at_fault}", + flush=True, + ) + if iters_arr.size > 0 and (iters_arr >= 0).any(): + print( + f" SNES iters per step ({integrator}): mean={iters_arr[iters_arr>=0].mean():.1f} " + f"median={int(np.median(iters_arr[iters_arr>=0]))} " + f"max={iters_arr[iters_arr>=0].max()} " + f"diverged={int((reasons_arr<0).sum())}/{len(reasons_arr)}", + flush=True, + ) + if sigma_II_max_per_step: + print( + f" max |σ|_II per step: end={sigma_II_max_per_step[-1]:.4f} " + f"global max={max(sigma_II_max_per_step):.4f}", + flush=True, + ) + print( + f" max |u_y| per step: end={u_y_max_per_step[-1]:.4f} " + f"global max={max(u_y_max_per_step):.4f}", + flush=True, + ) + print( + f" centre |σ_xy| time series: " + f"end={abs(sigma_xy_centre[-1]):.4f} " + f"peak={max(abs(s) for s in sigma_xy_centre):.4f} " + f"({max(abs(s) for s in sigma_xy_centre)/tau_y_at_fault:.2f}·τ_y_fault)", + flush=True, + ) + print( + f" centre |σ_∥| (resolved): " + f"end={sigma_par_centre[-1]:.4f} " + f"peak={max(sigma_par_centre):.4f} " + f"({max(sigma_par_centre)/tau_y_at_fault:.2f}·τ_y_fault)", + flush=True, + ) + + # Save the time series so we can replot/compare without rerunning + out_npz = os.path.join( + OUT_DIR, + f"phase_b_{integrator}_th{theta_deg:+.0f}_ty{tau_y_at_fault:.2f}".replace(".", "p") + ".npz", + ) + np.savez( + out_npz, + iters=iters_arr, + reasons=reasons_arr, + sigma_II_max_per_step=np.asarray(sigma_II_max_per_step), + u_y_max_per_step=np.asarray(u_y_max_per_step), + sigma_xy_centre=np.asarray(sigma_xy_centre), + sigma_par_centre=np.asarray(sigma_par_centre), + theta_deg=np.array(theta_deg), + tau_y_at_fault=np.array(tau_y_at_fault), + T_END=np.array(T_END), + n_steps=np.array(len(iters)), + wall_seconds=np.array(time.time() - t0), + ) + print(f" saved → {out_npz}", flush=True) + return out_npz + + +def _cache_path(integrator, theta_deg, tau_y): + return os.path.join( + OUT_DIR, + f"phase_b_{integrator}_th{theta_deg:+.0f}_ty{tau_y:.2f}".replace(".", "p") + ".npz", + ) + + +def main(): + os.makedirs(OUT_DIR, exist_ok=True) + theta_deg = 15.0 + tau_y = 0.05 + + for integrator in ("bdf", "etd"): + cache = _cache_path(integrator, theta_deg, tau_y) + if os.path.exists(cache): + print(f"=== {integrator.upper()} cache hit: {cache} — skipping run ===", flush=True) + continue + print(f"=== {integrator.upper()}: θ={theta_deg:+.0f}°, τ_y={tau_y} ===", flush=True) + run_case(theta_deg, tau_y, integrator, n_periods=1.5) + + +if __name__ == "__main__": + main() diff --git a/docs/developer/design/_phase_b_etd1_at_tight_yield.py b/docs/developer/design/_phase_b_etd1_at_tight_yield.py new file mode 100644 index 000000000..9efa414b1 --- /dev/null +++ b/docs/developer/design/_phase_b_etd1_at_tight_yield.py @@ -0,0 +1,244 @@ +"""ETD (order=1) trajectory at τ_y=0.05, θ=+15°. + +Hypothesis (after the lesson from BDF-2/ETD-2 lumped/split/hybrid): +all higher-order time integrators show some flavour of growing +instability on this tight-yield TI fault problem; only first-order +BDF stays stable. The cause is L-stability / numerical dissipation, +not algorithm specifics. ETD-1 is the first-order ETD analogue: + + σ^{n+1} = α·σ^n + 2η(1-α)·ε̇^{n+1}, α = exp(-Δt/τ) + +Single step, no φ, no ε̇* history. Selected via +``integrator='etd', order=1`` on the constitutive model — implemented +as ETD-2 with φ = α (set in _update_history_coefficients), which +zeroes the (φ-α)·ε̇* term and turns (1-φ)·ε̇ into (1-α)·ε̇. + +Per-step logging every 5 steps + runaway guard, per the project +memory on per-step diagnostics. + +Run:: + + pixi run -e amr-dev python -u docs/developer/design/_phase_b_etd1_at_tight_yield.py +""" + +import os +import time + +import numpy as np +import sympy + +import underworld3 as uw +from underworld3 import VarType +from underworld3.function import expression + + +V0 = 0.5 +OMEGA = np.pi / 2.0 +DT = 0.05 +H = 1.0; W = 1.0 +FAULT_LENGTH = 0.6 +FAULT_WIDTH = 0.06 +ETA_0 = 1.0; ETA_1 = 1.0; MU = 1.0 +TAU_Y_BULK = 200.0 +RES = 32 + +OUT_DIR = "output" + + +def run_etd1(theta_deg, tau_y_at_fault, n_periods=1.5): + label = f"etd1_th{theta_deg:+.0f}_ty{tau_y_at_fault:.2f}".replace(".", "p") + + mesh = uw.meshing.StructuredQuadBox( + elementRes=(RES, RES), + minCoords=(0.0, 0.0), maxCoords=(W, H), + qdegree=3, + ) + u = uw.discretisation.MeshVariable(f"U_{label}", mesh, 2, degree=2, + vtype=VarType.VECTOR) + p_sol = uw.discretisation.MeshVariable(f"P_{label}", mesh, 1, degree=1, + continuous=True, vtype=VarType.SCALAR) + + theta = np.radians(theta_deg) + cx, cy = 0.5 * W, 0.5 * H + dx = 0.5 * FAULT_LENGTH * np.cos(theta) + dy = 0.5 * FAULT_LENGTH * np.sin(theta) + fault = uw.meshing.Surface( + f"fault_{label}", mesh, + np.array([[cx - dx, cy - dy], [cx + dx, cy + dy]]), + symbol=f"F{label}", + ) + fault.discretize() + + n_x = -np.sin(theta); n_y = np.cos(theta) + director = sympy.Matrix([n_x, n_y]) + weakness = fault.influence_function( + width=FAULT_WIDTH, + value_near=1.0 / tau_y_at_fault, value_far=1.0 / TAU_Y_BULK, + profile="gaussian", + ) + tau_y_field = 1.0 / weakness + + stokes = uw.systems.Stokes(mesh, velocityField=u, pressureField=p_sol) + stokes.constitutive_model = uw.constitutive_models.TransverseIsotropicVEPFlowModel( + stokes.Unknowns, integrator="etd", order=1, + ) + cm = stokes.constitutive_model + cm.Parameters.shear_viscosity_0 = ETA_0 + cm.Parameters.shear_viscosity_1 = ETA_1 + cm.Parameters.shear_modulus = MU + cm.Parameters.yield_stress = tau_y_field + cm.Parameters.director = director + cm.Parameters.shear_viscosity_min = ETA_0 * 1.0e-3 + cm.Parameters.strainrate_inv_II_min = 1.0e-6 + cm.yield_mode = "softmin" + + stokes.saddle_preconditioner = 1.0 / cm.K + stokes.tolerance = 1.0e-4 + stokes.petsc_options["ksp_type"] = "fgmres" + stokes.petsc_options["snes_force_iteration"] = True + + V_top = expression(rf"V_{{top,{label}}}", sympy.Float(0.0), "Top BC") + stokes.add_essential_bc(sympy.Matrix([V_top, 0.0]), "Top") + stokes.add_essential_bc(sympy.Matrix([0.0, 0.0]), "Bottom") + stokes.add_essential_bc((sympy.oo, 0.0), "Left") + stokes.add_essential_bc((sympy.oo, 0.0), "Right") + stokes.bodyforce = sympy.Matrix([0.0, 0.0]) + + DFDt = stokes.Unknowns.DFDt + + T_END = n_periods * 2.0 * np.pi / OMEGA + iters = []; reasons = [] + sigma_II_max_per_step = [] + u_y_max_per_step = [] + sigma_xy_centre = [] + sigma_par_centre = [] + centre = np.array([[cx, cy]]) + n_x_val = -float(np.sin(theta)) + n_y_val = float(np.cos(theta)) + + t_cur = 0.0 + t0 = time.time() + while t_cur < T_END - 1e-9: + dt = min(DT, T_END - t_cur) + t_end_step = t_cur + dt + v_now = V0 * float(np.cos(OMEGA * t_end_step)) + V_top.sym = sympy.Float(v_now) + cm.Parameters.dt_elastic = dt + try: + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=2) + except Exception as exc: + print(f" step at t={t_end_step:.3f}: solve raised — {exc}", flush=True) + iters.append(-1) + reasons.append(-99) + break + + iters.append(int(stokes.snes.getIterationNumber())) + reasons.append(int(stokes.snes.getConvergedReason())) + + sigma_arr = np.asarray(DFDt.psi_star[0].array) + sigma_II = np.sqrt(0.5 * (sigma_arr ** 2).sum(axis=(1, 2))) + sigma_II_max_per_step.append(float(sigma_II.max())) + u_arr = np.asarray(u.array).reshape(-1, 2) + u_y_max_per_step.append(float(np.abs(u_arr[:, 1]).max())) + sxy_centre = float(uw.function.evaluate(stokes.tau.sym[0, 1], centre).flatten()[0]) + sigma_xy_centre.append(sxy_centre) + sxx_c = float(uw.function.evaluate(stokes.tau.sym[0, 0], centre).flatten()[0]) + syy_c = float(uw.function.evaluate(stokes.tau.sym[1, 1], centre).flatten()[0]) + T_x = sxx_c * n_x_val + sxy_centre * n_y_val + T_y = sxy_centre * n_x_val + syy_c * n_y_val + sig_nn = T_x * n_x_val + T_y * n_y_val + sig_par = float(np.sqrt(max(T_x ** 2 + T_y ** 2 - sig_nn ** 2, 0.0))) + sigma_par_centre.append(sig_par) + + # Per-step logging + step_idx = len(iters) + if step_idx <= 5 or step_idx % 5 == 0: + print( + f" step {step_idx:3d}/120 t={t_end_step:5.3f} " + f"V={v_now:+.3f} iters={iters[-1]:2d} " + f"|σ|_II={sigma_II_max_per_step[-1]:.3e} " + f"|u_y|={u_y_max_per_step[-1]:.3e} " + f"|σ_∥|={sig_par:.3e}", + flush=True, + ) + + # Runaway guard + if sigma_II_max_per_step[-1] > 100.0 or u_y_max_per_step[-1] > 10.0: + print( + f" *** runaway detected at step {step_idx}: " + f"|σ|_II={sigma_II_max_per_step[-1]:.3e}, " + f"|u_y|={u_y_max_per_step[-1]:.3e} — breaking ***", + flush=True, + ) + break + + t_cur = t_end_step + + iters_arr = np.array(iters) + reasons_arr = np.array(reasons) + + print( + f" ran {len(iters)} steps in {time.time()-t0:.1f}s; " + f"ETD-1, τ_y_fault={tau_y_at_fault}", + flush=True, + ) + if iters_arr.size > 0 and (iters_arr >= 0).any(): + print( + f" SNES iters per step (etd1): mean={iters_arr[iters_arr>=0].mean():.1f} " + f"median={int(np.median(iters_arr[iters_arr>=0]))} " + f"max={iters_arr[iters_arr>=0].max()} " + f"diverged={int((reasons_arr<0).sum())}/{len(reasons_arr)}", + flush=True, + ) + if sigma_II_max_per_step: + print( + f" max |σ|_II per step: end={sigma_II_max_per_step[-1]:.4f} " + f"global max={max(sigma_II_max_per_step):.4f}", + flush=True, + ) + print( + f" max |u_y| per step: end={u_y_max_per_step[-1]:.4f} " + f"global max={max(u_y_max_per_step):.4f}", + flush=True, + ) + print( + f" centre |σ_∥| (resolved): " + f"end={sigma_par_centre[-1]:.4f} " + f"peak={max(sigma_par_centre):.4f} " + f"({max(sigma_par_centre)/tau_y_at_fault:.2f}·τ_y_fault)", + flush=True, + ) + + out_npz = os.path.join( + OUT_DIR, + f"phase_b_etd1_th{theta_deg:+.0f}_ty{tau_y_at_fault:.2f}".replace(".", "p") + ".npz", + ) + np.savez( + out_npz, + iters=iters_arr, + reasons=reasons_arr, + sigma_II_max_per_step=np.asarray(sigma_II_max_per_step), + u_y_max_per_step=np.asarray(u_y_max_per_step), + sigma_xy_centre=np.asarray(sigma_xy_centre), + sigma_par_centre=np.asarray(sigma_par_centre), + theta_deg=np.array(theta_deg), + tau_y_at_fault=np.array(tau_y_at_fault), + T_END=np.array(T_END), + n_steps=np.array(len(iters)), + wall_seconds=np.array(time.time() - t0), + ) + print(f" saved → {out_npz}", flush=True) + + +def main(): + os.makedirs(OUT_DIR, exist_ok=True) + cache = os.path.join(OUT_DIR, "phase_b_etd1_th+15_ty0p05.npz") + if os.path.exists(cache): + print(f"=== ETD-1 cache hit: {cache} — skipping run ===", flush=True) + return + print("=== ETD-1: θ=+15°, τ_y=0.05 ===", flush=True) + run_etd1(15.0, 0.05, n_periods=1.5) + + +if __name__ == "__main__": + main() diff --git a/docs/developer/design/_phase_d_killer_split.py b/docs/developer/design/_phase_d_killer_split.py new file mode 100644 index 000000000..f78d636c4 --- /dev/null +++ b/docs/developer/design/_phase_d_killer_split.py @@ -0,0 +1,228 @@ +"""Phase D: split-history ETD-2 vs Phase B lumped + BDF-1 baseline. + +Same setup as ``_phase_b_bdf_vs_etd_at_tight_yield.py``: bench_ti_vep_harmonic +geometry at θ=+15°, τ_y=0.05, RES=32, 1.5 periods. Compares the new +``TransverseIsotropicVEPSplitFlowModel`` (per-component (α_⊥, φ_⊥)/ +(α_∥, φ_∥)) against the existing BDF-1 trajectory cache. + +Saves the split-ETD trajectory to ``output/phase_b_etd-split_th+15_ty0p05.npz`` +and reports the same metrics as the BDF/lumped-ETD captures. + +Run:: + + pixi run -e amr-dev python -u docs/developer/design/_phase_d_killer_split.py +""" + +import os +import time + +import numpy as np +import sympy + +import underworld3 as uw +from underworld3 import VarType +from underworld3.function import expression + + +V0 = 0.5 +OMEGA = np.pi / 2.0 +DT = 0.05 +H = 1.0; W = 1.0 +FAULT_LENGTH = 0.6 +FAULT_WIDTH = 0.06 +ETA_0 = 1.0; ETA_1 = 1.0; MU = 1.0 +TAU_Y_BULK = 200.0 +RES = 32 + +OUT_DIR = "output" + + +def run_split(theta_deg, tau_y_at_fault, n_periods=1.5): + label = f"etd-split_th{theta_deg:+.0f}_ty{tau_y_at_fault:.2f}".replace(".", "p") + + mesh = uw.meshing.StructuredQuadBox( + elementRes=(RES, RES), + minCoords=(0.0, 0.0), maxCoords=(W, H), + qdegree=3, + ) + u = uw.discretisation.MeshVariable(f"U_{label}", mesh, 2, degree=2, + vtype=VarType.VECTOR) + p_sol = uw.discretisation.MeshVariable(f"P_{label}", mesh, 1, degree=1, + continuous=True, vtype=VarType.SCALAR) + + theta = np.radians(theta_deg) + cx, cy = 0.5 * W, 0.5 * H + dx = 0.5 * FAULT_LENGTH * np.cos(theta) + dy = 0.5 * FAULT_LENGTH * np.sin(theta) + fault = uw.meshing.Surface( + f"fault_{label}", mesh, + np.array([[cx - dx, cy - dy], [cx + dx, cy + dy]]), + symbol=f"F{label}", + ) + fault.discretize() + + n_x = -np.sin(theta); n_y = np.cos(theta) + director = sympy.Matrix([n_x, n_y]) + weakness = fault.influence_function( + width=FAULT_WIDTH, + value_near=1.0 / tau_y_at_fault, value_far=1.0 / TAU_Y_BULK, + profile="gaussian", + ) + tau_y_field = 1.0 / weakness + + stokes = uw.systems.Stokes(mesh, velocityField=u, pressureField=p_sol) + # *** Phase D split-history ETD-2 *** + stokes.constitutive_model = uw.constitutive_models.TransverseIsotropicVEPSplitFlowModel( + stokes.Unknowns, + ) + cm = stokes.constitutive_model + cm.Parameters.shear_viscosity_0 = ETA_0 + cm.Parameters.shear_viscosity_1 = ETA_1 + cm.Parameters.shear_modulus = MU + cm.Parameters.yield_stress = tau_y_field + cm.Parameters.director = director + cm.Parameters.shear_viscosity_min = ETA_0 * 1.0e-3 + cm.Parameters.strainrate_inv_II_min = 1.0e-6 + cm.yield_mode = "softmin" + + stokes.saddle_preconditioner = 1.0 / cm.K + stokes.tolerance = 1.0e-4 + stokes.petsc_options["ksp_type"] = "fgmres" + stokes.petsc_options["snes_force_iteration"] = True + + V_top = expression(rf"V_{{top,{label}}}", sympy.Float(0.0), "Top BC") + stokes.add_essential_bc(sympy.Matrix([V_top, 0.0]), "Top") + stokes.add_essential_bc(sympy.Matrix([0.0, 0.0]), "Bottom") + stokes.add_essential_bc((sympy.oo, 0.0), "Left") + stokes.add_essential_bc((sympy.oo, 0.0), "Right") + stokes.bodyforce = sympy.Matrix([0.0, 0.0]) + + DFDt = stokes.Unknowns.DFDt + + T_END = n_periods * 2.0 * np.pi / OMEGA + iters = []; reasons = [] + sigma_II_max_per_step = [] + u_y_max_per_step = [] + sigma_xy_centre = [] + sigma_par_centre = [] + centre = np.array([[cx, cy]]) + n_x_val = -float(np.sin(theta)) + n_y_val = float(np.cos(theta)) + + t_cur = 0.0 + t0 = time.time() + while t_cur < T_END - 1e-9: + dt = min(DT, T_END - t_cur) + t_end_step = t_cur + dt + v_now = V0 * float(np.cos(OMEGA * t_end_step)) + V_top.sym = sympy.Float(v_now) + cm.Parameters.dt_elastic = dt + try: + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=2) + except Exception as exc: + print(f" step at t={t_end_step:.3f}: solve raised — {exc}", flush=True) + iters.append(-1) + reasons.append(-99) + break + + iters.append(int(stokes.snes.getIterationNumber())) + reasons.append(int(stokes.snes.getConvergedReason())) + + sigma_arr = np.asarray(DFDt.psi_star[0].array) + sigma_II = np.sqrt(0.5 * (sigma_arr ** 2).sum(axis=(1, 2))) + sigma_II_max_per_step.append(float(sigma_II.max())) + u_arr = np.asarray(u.array).reshape(-1, 2) + u_y_max_per_step.append(float(np.abs(u_arr[:, 1]).max())) + sxy_centre = float(uw.function.evaluate(stokes.tau.sym[0, 1], centre).flatten()[0]) + sigma_xy_centre.append(sxy_centre) + sxx_c = float(uw.function.evaluate(stokes.tau.sym[0, 0], centre).flatten()[0]) + syy_c = float(uw.function.evaluate(stokes.tau.sym[1, 1], centre).flatten()[0]) + T_x = sxx_c * n_x_val + sxy_centre * n_y_val + T_y = sxy_centre * n_x_val + syy_c * n_y_val + sig_nn = T_x * n_x_val + T_y * n_y_val + sig_par = float(np.sqrt(max(T_x ** 2 + T_y ** 2 - sig_nn ** 2, 0.0))) + sigma_par_centre.append(sig_par) + + t_cur = t_end_step + + iters_arr = np.array(iters) + reasons_arr = np.array(reasons) + + print( + f" ran {len(iters)} steps in {time.time()-t0:.1f}s; " + f"split-ETD-2, τ_y_fault={tau_y_at_fault}", + flush=True, + ) + if iters_arr.size > 0 and (iters_arr >= 0).any(): + print( + f" SNES iters per step (split): mean={iters_arr[iters_arr>=0].mean():.1f} " + f"median={int(np.median(iters_arr[iters_arr>=0]))} " + f"max={iters_arr[iters_arr>=0].max()} " + f"diverged={int((reasons_arr<0).sum())}/{len(reasons_arr)}", + flush=True, + ) + if sigma_II_max_per_step: + print( + f" max |σ|_II per step: end={sigma_II_max_per_step[-1]:.4f} " + f"global max={max(sigma_II_max_per_step):.4f}", + flush=True, + ) + print( + f" max |u_y| per step: end={u_y_max_per_step[-1]:.4f} " + f"global max={max(u_y_max_per_step):.4f}", + flush=True, + ) + print( + f" centre |σ_xy| time series: " + f"end={abs(sigma_xy_centre[-1]):.4f} " + f"peak={max(abs(s) for s in sigma_xy_centre):.4f} " + f"({max(abs(s) for s in sigma_xy_centre)/tau_y_at_fault:.2f}·τ_y_fault)", + flush=True, + ) + print( + f" centre |σ_∥| (resolved): " + f"end={sigma_par_centre[-1]:.4f} " + f"peak={max(sigma_par_centre):.4f} " + f"({max(sigma_par_centre)/tau_y_at_fault:.2f}·τ_y_fault)", + flush=True, + ) + + out_npz = os.path.join( + OUT_DIR, + f"phase_b_etd-split_th{theta_deg:+.0f}_ty{tau_y_at_fault:.2f}".replace(".", "p") + ".npz", + ) + np.savez( + out_npz, + iters=iters_arr, + reasons=reasons_arr, + sigma_II_max_per_step=np.asarray(sigma_II_max_per_step), + u_y_max_per_step=np.asarray(u_y_max_per_step), + sigma_xy_centre=np.asarray(sigma_xy_centre), + sigma_par_centre=np.asarray(sigma_par_centre), + theta_deg=np.array(theta_deg), + tau_y_at_fault=np.array(tau_y_at_fault), + T_END=np.array(T_END), + n_steps=np.array(len(iters)), + wall_seconds=np.array(time.time() - t0), + ) + print(f" saved → {out_npz}", flush=True) + + +def main(): + os.makedirs(OUT_DIR, exist_ok=True) + cases = [(15.0, 0.05), (15.0, 0.15)] # tight + Phase B working regime + for theta_deg, tau_y in cases: + cache_name = ( + f"phase_b_etd-split_th{theta_deg:+.0f}_ty{tau_y:.2f}".replace(".", "p") + + ".npz" + ) + cache = os.path.join(OUT_DIR, cache_name) + if os.path.exists(cache): + print(f"=== split-ETD-2 cache hit: {cache} — skipping run ===", flush=True) + continue + print(f"=== Phase D split-ETD-2: θ=+{theta_deg:.0f}°, τ_y={tau_y} ===", flush=True) + run_split(theta_deg, tau_y, n_periods=1.5) + + +if __name__ == "__main__": + main() diff --git a/docs/developer/design/_phase_e_killer_hybrid.py b/docs/developer/design/_phase_e_killer_hybrid.py new file mode 100644 index 000000000..6cc9a766a --- /dev/null +++ b/docs/developer/design/_phase_e_killer_hybrid.py @@ -0,0 +1,238 @@ +"""Phase E: hybrid BDF/ETD integrator with spatial fault weight. + +σ(x) = w(x)·σ_BDF + (1-w(x))·σ_ETD + +w(x) = (1/τ_y(x) - 1/τ_y_bulk) / (1/τ_y_fault - 1/τ_y_bulk) ∈ [0, 1] + +Inside the fault zone (where yielding can happen), w → 1 and BDF +takes over (its built-in elastic damping during yield is the right +physics, lesson #9). Outside the fault (where τ_y(x) → τ_y_bulk and +yielding is structurally unreachable), w → 0 and ETD takes over (its +4× accuracy advantage on smooth VE matters in the bulk). + +Same setup as ``_phase_d_killer_split.py`` (θ=+15°, RES=32, τ_y values +{0.05, 0.15}, 1.5 periods). Saves time-series with σ_∥ probe. + +Run:: + + pixi run -e amr-dev python -u docs/developer/design/_phase_e_killer_hybrid.py +""" + +import os +import time + +import numpy as np +import sympy + +import underworld3 as uw +from underworld3 import VarType +from underworld3.function import expression + + +V0 = 0.5 +OMEGA = np.pi / 2.0 +DT = 0.05 +H = 1.0; W = 1.0 +FAULT_LENGTH = 0.6 +FAULT_WIDTH = 0.06 +ETA_0 = 1.0; ETA_1 = 1.0; MU = 1.0 +TAU_Y_BULK = 200.0 +RES = 32 + +OUT_DIR = "output" + + +def run_hybrid(theta_deg, tau_y_at_fault, n_periods=1.5): + label = f"hybrid_th{theta_deg:+.0f}_ty{tau_y_at_fault:.2f}".replace(".", "p") + + mesh = uw.meshing.StructuredQuadBox( + elementRes=(RES, RES), + minCoords=(0.0, 0.0), maxCoords=(W, H), + qdegree=3, + ) + u = uw.discretisation.MeshVariable(f"U_{label}", mesh, 2, degree=2, + vtype=VarType.VECTOR) + p_sol = uw.discretisation.MeshVariable(f"P_{label}", mesh, 1, degree=1, + continuous=True, vtype=VarType.SCALAR) + + theta = np.radians(theta_deg) + cx, cy = 0.5 * W, 0.5 * H + dx = 0.5 * FAULT_LENGTH * np.cos(theta) + dy = 0.5 * FAULT_LENGTH * np.sin(theta) + fault = uw.meshing.Surface( + f"fault_{label}", mesh, + np.array([[cx - dx, cy - dy], [cx + dx, cy + dy]]), + symbol=f"F{label}", + ) + fault.discretize() + + n_x = -np.sin(theta); n_y = np.cos(theta) + director = sympy.Matrix([n_x, n_y]) + weakness = fault.influence_function( + width=FAULT_WIDTH, + value_near=1.0 / tau_y_at_fault, value_far=1.0 / TAU_Y_BULK, + profile="gaussian", + ) + tau_y_field = 1.0 / weakness + + # Fault weight: 0 in bulk (where weakness = 1/τ_y_bulk), + # 1 inside fault (where weakness = 1/τ_y_fault). + weakness_min = 1.0 / TAU_Y_BULK + weakness_max = 1.0 / tau_y_at_fault + fault_weight = (weakness - weakness_min) / (weakness_max - weakness_min) + + stokes = uw.systems.Stokes(mesh, velocityField=u, pressureField=p_sol) + stokes.constitutive_model = uw.constitutive_models.TransverseIsotropicVEPFlowModel( + stokes.Unknowns, integrator="hybrid", fault_weight=fault_weight, + ) + cm = stokes.constitutive_model + cm.Parameters.shear_viscosity_0 = ETA_0 + cm.Parameters.shear_viscosity_1 = ETA_1 + cm.Parameters.shear_modulus = MU + cm.Parameters.yield_stress = tau_y_field + cm.Parameters.director = director + cm.Parameters.shear_viscosity_min = ETA_0 * 1.0e-3 + cm.Parameters.strainrate_inv_II_min = 1.0e-6 + cm.yield_mode = "softmin" + + stokes.saddle_preconditioner = 1.0 / cm.K + stokes.tolerance = 1.0e-4 + stokes.petsc_options["ksp_type"] = "fgmres" + stokes.petsc_options["snes_force_iteration"] = True + + V_top = expression(rf"V_{{top,{label}}}", sympy.Float(0.0), "Top BC") + stokes.add_essential_bc(sympy.Matrix([V_top, 0.0]), "Top") + stokes.add_essential_bc(sympy.Matrix([0.0, 0.0]), "Bottom") + stokes.add_essential_bc((sympy.oo, 0.0), "Left") + stokes.add_essential_bc((sympy.oo, 0.0), "Right") + stokes.bodyforce = sympy.Matrix([0.0, 0.0]) + + DFDt = stokes.Unknowns.DFDt + + T_END = n_periods * 2.0 * np.pi / OMEGA + iters = []; reasons = [] + sigma_II_max_per_step = [] + u_y_max_per_step = [] + sigma_xy_centre = [] + sigma_par_centre = [] + centre = np.array([[cx, cy]]) + n_x_val = -float(np.sin(theta)) + n_y_val = float(np.cos(theta)) + + t_cur = 0.0 + t0 = time.time() + while t_cur < T_END - 1e-9: + dt = min(DT, T_END - t_cur) + t_end_step = t_cur + dt + v_now = V0 * float(np.cos(OMEGA * t_end_step)) + V_top.sym = sympy.Float(v_now) + cm.Parameters.dt_elastic = dt + try: + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=2) + except Exception as exc: + print(f" step at t={t_end_step:.3f}: solve raised — {exc}", flush=True) + iters.append(-1) + reasons.append(-99) + break + + iters.append(int(stokes.snes.getIterationNumber())) + reasons.append(int(stokes.snes.getConvergedReason())) + + sigma_arr = np.asarray(DFDt.psi_star[0].array) + sigma_II = np.sqrt(0.5 * (sigma_arr ** 2).sum(axis=(1, 2))) + sigma_II_max_per_step.append(float(sigma_II.max())) + u_arr = np.asarray(u.array).reshape(-1, 2) + u_y_max_per_step.append(float(np.abs(u_arr[:, 1]).max())) + sxy_centre = float(uw.function.evaluate(stokes.tau.sym[0, 1], centre).flatten()[0]) + sigma_xy_centre.append(sxy_centre) + sxx_c = float(uw.function.evaluate(stokes.tau.sym[0, 0], centre).flatten()[0]) + syy_c = float(uw.function.evaluate(stokes.tau.sym[1, 1], centre).flatten()[0]) + T_x = sxx_c * n_x_val + sxy_centre * n_y_val + T_y = sxy_centre * n_x_val + syy_c * n_y_val + sig_nn = T_x * n_x_val + T_y * n_y_val + sig_par = float(np.sqrt(max(T_x ** 2 + T_y ** 2 - sig_nn ** 2, 0.0))) + sigma_par_centre.append(sig_par) + + t_cur = t_end_step + + iters_arr = np.array(iters) + reasons_arr = np.array(reasons) + + print( + f" ran {len(iters)} steps in {time.time()-t0:.1f}s; " + f"hybrid (BDF/ETD), τ_y_fault={tau_y_at_fault}", + flush=True, + ) + if iters_arr.size > 0 and (iters_arr >= 0).any(): + print( + f" SNES iters per step (hybrid): mean={iters_arr[iters_arr>=0].mean():.1f} " + f"median={int(np.median(iters_arr[iters_arr>=0]))} " + f"max={iters_arr[iters_arr>=0].max()} " + f"diverged={int((reasons_arr<0).sum())}/{len(reasons_arr)}", + flush=True, + ) + if sigma_II_max_per_step: + print( + f" max |σ|_II per step: end={sigma_II_max_per_step[-1]:.4f} " + f"global max={max(sigma_II_max_per_step):.4f}", + flush=True, + ) + print( + f" max |u_y| per step: end={u_y_max_per_step[-1]:.4f} " + f"global max={max(u_y_max_per_step):.4f}", + flush=True, + ) + print( + f" centre |σ_xy| time series: " + f"end={abs(sigma_xy_centre[-1]):.4f} " + f"peak={max(abs(s) for s in sigma_xy_centre):.4f} " + f"({max(abs(s) for s in sigma_xy_centre)/tau_y_at_fault:.2f}·τ_y_fault)", + flush=True, + ) + print( + f" centre |σ_∥| (resolved): " + f"end={sigma_par_centre[-1]:.4f} " + f"peak={max(sigma_par_centre):.4f} " + f"({max(sigma_par_centre)/tau_y_at_fault:.2f}·τ_y_fault)", + flush=True, + ) + + out_npz = os.path.join( + OUT_DIR, + f"phase_b_hybrid_th{theta_deg:+.0f}_ty{tau_y_at_fault:.2f}".replace(".", "p") + ".npz", + ) + np.savez( + out_npz, + iters=iters_arr, + reasons=reasons_arr, + sigma_II_max_per_step=np.asarray(sigma_II_max_per_step), + u_y_max_per_step=np.asarray(u_y_max_per_step), + sigma_xy_centre=np.asarray(sigma_xy_centre), + sigma_par_centre=np.asarray(sigma_par_centre), + theta_deg=np.array(theta_deg), + tau_y_at_fault=np.array(tau_y_at_fault), + T_END=np.array(T_END), + n_steps=np.array(len(iters)), + wall_seconds=np.array(time.time() - t0), + ) + print(f" saved → {out_npz}", flush=True) + + +def main(): + os.makedirs(OUT_DIR, exist_ok=True) + cases = [(15.0, 0.05), (15.0, 0.15)] + for theta_deg, tau_y in cases: + cache_name = ( + f"phase_b_hybrid_th{theta_deg:+.0f}_ty{tau_y:.2f}".replace(".", "p") + + ".npz" + ) + cache = os.path.join(OUT_DIR, cache_name) + if os.path.exists(cache): + print(f"=== hybrid cache hit: {cache} — skipping run ===", flush=True) + continue + print(f"=== Phase E hybrid BDF/ETD: θ=+{theta_deg:.0f}°, τ_y={tau_y} ===", flush=True) + run_hybrid(theta_deg, tau_y, n_periods=1.5) + + +if __name__ == "__main__": + main() diff --git a/docs/developer/design/_phase_f_bdf1_iso.trace.txt b/docs/developer/design/_phase_f_bdf1_iso.trace.txt new file mode 100644 index 000000000..29afd73d7 --- /dev/null +++ b/docs/developer/design/_phase_f_bdf1_iso.trace.txt @@ -0,0 +1,123 @@ +# Phase F predictor-corrector trace: bdf1_iso +# integrator='bdf' order=1 apply_radial_return=False in_residual_yield=True +# columns: step, t, V_top, snes_iters_total, picard_iters, sigma_eq_max, sigma_eq_max_after_correction, u_y_max, yielded_fraction + 1 0.0500 +0.4985 1 1 4.127208e-02 4.127208e-02 9.910768e-06 0.000000 + 2 0.1000 +0.4938 1 1 8.038114e-02 8.038114e-02 1.736993e-04 0.046832 + 3 0.1500 +0.4862 28 1 1.506698e-01 1.506698e-01 9.614702e-03 0.059688 + 4 0.2000 +0.4755 23 1 2.390412e-01 2.390412e-01 1.853175e-02 0.067034 + 5 0.2500 +0.4619 20 1 3.495552e-01 3.495552e-01 2.103261e-02 0.074380 + 6 0.3000 +0.4455 15 1 4.846955e-01 4.846955e-01 2.298590e-02 0.074380 + 7 0.3500 +0.4263 15 1 6.096305e-01 6.096305e-01 2.423472e-02 0.074380 + 8 0.4000 +0.4045 14 1 7.198707e-01 7.198707e-01 2.396950e-02 0.076217 + 9 0.4500 +0.3802 14 1 8.120357e-01 8.120357e-01 2.274448e-02 0.079890 + 10 0.5000 +0.3536 14 1 8.836892e-01 8.836892e-01 2.156902e-02 0.081726 + 11 0.5500 +0.3247 13 1 9.348462e-01 9.348462e-01 2.019526e-02 0.081726 + 12 0.6000 +0.2939 14 1 9.672433e-01 9.672433e-01 1.857488e-02 0.081726 + 13 0.6500 +0.2612 16 1 9.830292e-01 9.830292e-01 1.680594e-02 0.081726 + 14 0.7000 +0.2270 17 1 9.840694e-01 9.840694e-01 1.495069e-02 0.081726 + 15 0.7500 +0.1913 19 1 9.717716e-01 9.717716e-01 1.282147e-02 0.081726 + 16 0.8000 +0.1545 21 1 9.473505e-01 9.473505e-01 1.052256e-02 0.081726 + 17 0.8500 +0.1167 25 1 9.120627e-01 9.120627e-01 9.265141e-03 0.081726 + 18 0.9000 +0.0782 30 1 8.675153e-01 8.675153e-01 8.563905e-03 0.081726 + 19 0.9500 +0.0392 34 1 8.158198e-01 8.158198e-01 7.559992e-03 0.081726 + 20 1.0000 -0.0000 19 1 7.630093e-01 7.630093e-01 6.323271e-03 0.074380 + 21 1.0500 -0.0392 8 1 7.288530e-01 7.288530e-01 5.154964e-03 0.074380 + 22 1.1000 -0.0782 7 1 6.886855e-01 6.886855e-01 4.095200e-03 0.072544 + 23 1.1500 -0.1167 5 1 6.462222e-01 6.462222e-01 3.139607e-03 0.054178 + 24 1.2000 -0.1545 4 1 6.024623e-01 6.024623e-01 2.466041e-03 0.009183 + 25 1.2500 -0.1913 3 1 5.579514e-01 5.579514e-01 2.282205e-03 0.000000 + 26 1.3000 -0.2270 3 1 5.130849e-01 5.130849e-01 2.190943e-03 0.000000 + 27 1.3500 -0.2612 2 1 4.667991e-01 4.667991e-01 2.080320e-03 0.000918 + 28 1.4000 -0.2939 1 1 4.197769e-01 4.197769e-01 1.930754e-03 0.021120 + 29 1.4500 -0.3247 2 1 3.722971e-01 3.722971e-01 1.743052e-03 0.033058 + 30 1.5000 -0.3536 12 1 3.225307e-01 3.225307e-01 3.181995e-03 0.048669 + 31 1.5500 -0.3802 25 1 2.627879e-01 2.627879e-01 9.008150e-03 0.056015 + 32 1.6000 -0.4045 22 1 2.470817e-01 2.470817e-01 1.384321e-02 0.056015 + 33 1.6500 -0.4263 23 1 2.617071e-01 2.617071e-01 1.795609e-02 0.063361 + 34 1.7000 -0.4455 20 1 3.171074e-01 3.171074e-01 2.084389e-02 0.066116 + 35 1.7500 -0.4619 16 1 3.720918e-01 3.720918e-01 2.375286e-02 0.072544 + 36 1.8000 -0.4755 15 1 4.180842e-01 4.180842e-01 2.593457e-02 0.074380 + 37 1.8500 -0.4862 14 1 5.144363e-01 5.144363e-01 2.754916e-02 0.074380 + 38 1.9000 -0.4938 13 1 6.484323e-01 6.484323e-01 3.122790e-02 0.077135 + 39 1.9500 -0.4985 12 1 7.692478e-01 7.692478e-01 3.421500e-02 0.079890 + 40 2.0000 -0.5000 11 1 8.776854e-01 8.776854e-01 3.577072e-02 0.081726 + 41 2.0500 -0.4985 10 1 9.753243e-01 9.753243e-01 3.613523e-02 0.082645 + 42 2.1000 -0.4938 9 1 1.063180e+00 1.063180e+00 3.558671e-02 0.083563 + 43 2.1500 -0.4862 8 1 1.141797e+00 1.141797e+00 3.439847e-02 0.084481 + 44 2.2000 -0.4755 8 1 1.211360e+00 1.211360e+00 3.344108e-02 0.084481 + 45 2.2500 -0.4619 8 1 1.278016e+00 1.278016e+00 3.406503e-02 0.086318 + 46 2.3000 -0.4455 8 1 1.339710e+00 1.339710e+00 3.373125e-02 0.086318 + 47 2.3500 -0.4263 8 1 1.389034e+00 1.389034e+00 3.234658e-02 0.088154 + 48 2.4000 -0.4045 8 1 1.425182e+00 1.425182e+00 3.025302e-02 0.089073 + 49 2.4500 -0.3802 10 1 1.447622e+00 1.447622e+00 2.745848e-02 0.089073 + 50 2.5000 -0.3536 11 1 1.456493e+00 1.456493e+00 2.508182e-02 0.090909 + 51 2.5500 -0.3247 12 1 1.452144e+00 1.452144e+00 2.321883e-02 0.090909 + 52 2.6000 -0.2939 13 1 1.435099e+00 1.435099e+00 2.117630e-02 0.089073 + 53 2.6500 -0.2612 14 1 1.408897e+00 1.408897e+00 1.892825e-02 0.089073 + 54 2.7000 -0.2270 15 1 1.382260e+00 1.382260e+00 1.693459e-02 0.088154 + 55 2.7500 -0.1913 17 1 1.375198e+00 1.375198e+00 1.496980e-02 0.089073 + 56 2.8000 -0.1545 20 1 1.361218e+00 1.361218e+00 1.361402e-02 0.089073 + 57 2.8500 -0.1167 23 1 1.338628e+00 1.338628e+00 1.213324e-02 0.088154 + 58 2.9000 -0.0782 27 1 1.307413e+00 1.307413e+00 1.046934e-02 0.086318 + 59 2.9500 -0.0392 33 1 1.267677e+00 1.267677e+00 8.977656e-03 0.082645 + 60 3.0000 -0.0000 24 1 1.220795e+00 1.220795e+00 7.933386e-03 0.081726 + 61 3.0500 +0.0392 10 1 1.171966e+00 1.171966e+00 6.959463e-03 0.080808 + 62 3.1000 +0.0782 7 1 1.112697e+00 1.112697e+00 5.897244e-03 0.071625 + 63 3.1500 +0.1167 5 1 1.047772e+00 1.047772e+00 4.924303e-03 0.056015 + 64 3.2000 +0.1545 5 1 9.822068e-01 9.822068e-01 4.021409e-03 0.016529 + 65 3.2500 +0.1913 4 1 9.166450e-01 9.166450e-01 3.436732e-03 0.001837 + 66 3.3000 +0.2270 3 1 8.520661e-01 8.520661e-01 3.068158e-03 0.000000 + 67 3.3500 +0.2612 2 1 7.883038e-01 7.883038e-01 2.865541e-03 0.001837 + 68 3.4000 +0.2939 2 1 7.247583e-01 7.247583e-01 2.728022e-03 0.019284 + 69 3.4500 +0.3247 2 1 6.614330e-01 6.614330e-01 2.528061e-03 0.032140 + 70 3.5000 +0.3536 11 1 5.970971e-01 5.970971e-01 3.228570e-03 0.048669 + 71 3.5500 +0.3802 25 1 5.245090e-01 5.245090e-01 8.312550e-03 0.056015 + 72 3.6000 +0.4045 23 1 4.415680e-01 4.415680e-01 1.276039e-02 0.056015 + 73 3.6500 +0.4263 23 1 3.740465e-01 3.740465e-01 1.660880e-02 0.062443 + 74 3.7000 +0.4455 19 1 3.769107e-01 3.769107e-01 1.956283e-02 0.065197 + 75 3.7500 +0.4619 16 1 3.849742e-01 3.849742e-01 2.212255e-02 0.069789 + 76 3.8000 +0.4755 15 1 4.314492e-01 4.314492e-01 2.585272e-02 0.073462 + 77 3.8500 +0.4862 13 1 4.569590e-01 4.569590e-01 2.882846e-02 0.074380 + 78 3.9000 +0.4938 13 1 5.536645e-01 5.536645e-01 3.087855e-02 0.076217 + 79 3.9500 +0.4985 12 1 6.987001e-01 6.987001e-01 3.270765e-02 0.076217 + 80 4.0000 +0.5000 11 1 8.300400e-01 8.300400e-01 3.425436e-02 0.078972 + 81 4.0500 +0.4985 10 1 9.481211e-01 9.481211e-01 3.628425e-02 0.081726 + 82 4.1000 +0.4938 9 1 1.052744e+00 1.052744e+00 3.772184e-02 0.082645 + 83 4.1500 +0.4862 9 1 1.143134e+00 1.143134e+00 3.843999e-02 0.082645 + 84 4.2000 +0.4755 8 1 1.218388e+00 1.218388e+00 3.838852e-02 0.083563 + 85 4.2500 +0.4619 8 1 1.277964e+00 1.277964e+00 3.765011e-02 0.085399 + 86 4.3000 +0.4455 8 1 1.322205e+00 1.322205e+00 3.624003e-02 0.086318 + 87 4.3500 +0.4263 8 1 1.352417e+00 1.352417e+00 3.423680e-02 0.087236 + 88 4.4000 +0.4045 9 1 1.370249e+00 1.370249e+00 3.178397e-02 0.087236 + 89 4.4500 +0.3802 10 1 1.378066e+00 1.378066e+00 2.894993e-02 0.087236 + 90 4.5000 +0.3536 11 1 1.378157e+00 1.378157e+00 2.593349e-02 0.087236 + 91 4.5500 +0.3247 12 1 1.371909e+00 1.371909e+00 2.340301e-02 0.088154 + 92 4.6000 +0.2939 13 1 1.359741e+00 1.359741e+00 2.156856e-02 0.089073 + 93 4.6500 +0.2612 14 1 1.342647e+00 1.342647e+00 1.961159e-02 0.089073 + 94 4.7000 +0.2270 16 1 1.344674e+00 1.344674e+00 1.743032e-02 0.089073 + 95 4.7500 +0.1913 18 1 1.336877e+00 1.336877e+00 1.502347e-02 0.087236 + 96 4.8000 +0.1545 20 1 1.320058e+00 1.320058e+00 1.392546e-02 0.087236 + 97 4.8500 +0.1167 23 1 1.294622e+00 1.294622e+00 1.297610e-02 0.085399 + 98 4.9000 +0.0782 28 1 1.260405e+00 1.260405e+00 1.166777e-02 0.083563 + 99 4.9500 +0.0392 33 1 1.217751e+00 1.217751e+00 1.010702e-02 0.081726 + 100 5.0000 +0.0000 22 1 1.167237e+00 1.167237e+00 8.388419e-03 0.081726 + 101 5.0500 -0.0392 9 1 1.111585e+00 1.111585e+00 7.391111e-03 0.081726 + 102 5.1000 -0.0782 7 1 1.048879e+00 1.048879e+00 5.923332e-03 0.071625 + 103 5.1500 -0.1167 5 1 9.869192e-01 9.869192e-01 4.702023e-03 0.055096 + 104 5.2000 -0.1545 4 1 9.244225e-01 9.244225e-01 3.691150e-03 0.013774 + 105 5.2500 -0.1913 3 1 8.618489e-01 8.618489e-01 3.237655e-03 0.000918 + 106 5.3000 -0.2270 3 1 8.000484e-01 8.000484e-01 3.031333e-03 0.000000 + 107 5.3500 -0.2612 2 1 7.381037e-01 7.381037e-01 2.863970e-03 0.001837 + 108 5.4000 -0.2939 2 1 6.763055e-01 6.763055e-01 2.714261e-03 0.021120 + 109 5.4500 -0.3247 2 1 6.147766e-01 6.147766e-01 2.540707e-03 0.033058 + 110 5.5000 -0.3536 12 1 5.518377e-01 5.518377e-01 3.367459e-03 0.048669 + 111 5.5500 -0.3802 25 1 4.792515e-01 4.792515e-01 8.877937e-03 0.056015 + 112 5.6000 -0.4045 23 1 3.948926e-01 3.948926e-01 1.378900e-02 0.056933 + 113 5.6500 -0.4263 23 1 3.537057e-01 3.537057e-01 1.782485e-02 0.063361 + 114 5.7000 -0.4455 20 1 3.527766e-01 3.527766e-01 2.055281e-02 0.065197 + 115 5.7500 -0.4619 16 1 3.959641e-01 3.959641e-01 2.324016e-02 0.068871 + 116 5.8000 -0.4755 15 1 4.349951e-01 4.349951e-01 2.537614e-02 0.073462 + 117 5.8500 -0.4862 14 1 4.724881e-01 4.724881e-01 2.728828e-02 0.074380 + 118 5.9000 -0.4938 13 1 5.735626e-01 5.735626e-01 3.131495e-02 0.076217 + 119 5.9500 -0.4985 12 1 6.955473e-01 6.955473e-01 3.435077e-02 0.077135 + 120 6.0000 -0.5000 11 1 8.061232e-01 8.061232e-01 3.596882e-02 0.079890 diff --git a/docs/developer/design/_phase_f_etd1_pc1.trace.txt b/docs/developer/design/_phase_f_etd1_pc1.trace.txt new file mode 100644 index 000000000..9c1186c8e --- /dev/null +++ b/docs/developer/design/_phase_f_etd1_pc1.trace.txt @@ -0,0 +1,123 @@ +# Phase F predictor-corrector trace: etd1_pc1 +# integrator='etd' order=1 apply_radial_return=True in_residual_yield=False +# columns: step, t, V_top, snes_iters_total, picard_iters, sigma_eq_max, sigma_eq_max_after_correction, u_y_max, yielded_fraction + 1 0.0500 +0.4985 1 1 4.227008e-02 4.227008e-02 7.408887e-06 0.000000 + 2 0.1000 +0.4938 1 1 8.219118e-02 8.219118e-02 1.875342e-05 0.048669 + 3 0.1500 +0.4862 1 1 1.312133e-01 1.312133e-01 3.499493e-03 0.061524 + 4 0.2000 +0.4755 1 1 1.847484e-01 1.847484e-01 8.601088e-03 0.072544 + 5 0.2500 +0.4619 1 1 2.349787e-01 2.349787e-01 1.262241e-02 0.073462 + 6 0.3000 +0.4455 1 1 2.842488e-01 2.842488e-01 1.517815e-02 0.081726 + 7 0.3500 +0.4263 1 1 3.303034e-01 3.303034e-01 1.652152e-02 0.081726 + 8 0.4000 +0.4045 1 1 3.759653e-01 3.759653e-01 1.709990e-02 0.081726 + 9 0.4500 +0.3802 1 1 4.155843e-01 4.155843e-01 1.717741e-02 0.081726 + 10 0.5000 +0.3536 1 1 4.482948e-01 4.482948e-01 1.675827e-02 0.083563 + 11 0.5500 +0.3247 1 1 4.744306e-01 4.744306e-01 1.604408e-02 0.083563 + 12 0.6000 +0.2939 1 1 4.942698e-01 4.942698e-01 1.508513e-02 0.083563 + 13 0.6500 +0.2612 1 1 5.083255e-01 5.083255e-01 1.444807e-02 0.083563 + 14 0.7000 +0.2270 1 1 5.170730e-01 5.170730e-01 1.364595e-02 0.081726 + 15 0.7500 +0.1913 1 1 5.209158e-01 5.209158e-01 1.265520e-02 0.078972 + 16 0.8000 +0.1545 1 1 5.200673e-01 5.200673e-01 1.151499e-02 0.074380 + 17 0.8500 +0.1167 1 1 5.146865e-01 5.146865e-01 1.027186e-02 0.073462 + 18 0.9000 +0.0782 1 1 5.048434e-01 5.048434e-01 8.898937e-03 0.068871 + 19 0.9500 +0.0392 1 1 4.906089e-01 4.906089e-01 7.427466e-03 0.065197 + 20 1.0000 -0.0000 1 1 4.734378e-01 4.734378e-01 5.897057e-03 0.059688 + 21 1.0500 -0.0392 1 1 4.519180e-01 4.519180e-01 4.329136e-03 0.047750 + 22 1.1000 -0.0782 1 1 4.264055e-01 4.264055e-01 2.576242e-03 0.021120 + 23 1.1500 -0.1167 1 1 3.971031e-01 3.971031e-01 1.405211e-03 0.001837 + 24 1.2000 -0.1545 1 1 3.654972e-01 3.654972e-01 1.189141e-03 0.000000 + 25 1.2500 -0.1913 1 1 3.321918e-01 3.321918e-01 1.065001e-03 0.000000 + 26 1.3000 -0.2270 1 1 2.973628e-01 2.973628e-01 9.223411e-04 0.001837 + 27 1.3500 -0.2612 1 1 2.611811e-01 2.611811e-01 8.630974e-04 0.006428 + 28 1.4000 -0.2939 1 1 2.234569e-01 2.234569e-01 1.135203e-03 0.029385 + 29 1.4500 -0.3247 1 1 1.831775e-01 1.831775e-01 2.125745e-03 0.046832 + 30 1.5000 -0.3536 1 1 1.385713e-01 1.385713e-01 4.241876e-03 0.054178 + 31 1.5500 -0.3802 1 1 1.181696e-01 1.181696e-01 6.389454e-03 0.059688 + 32 1.6000 -0.4045 1 1 1.372726e-01 1.372726e-01 8.507503e-03 0.067952 + 33 1.6500 -0.4263 1 1 1.598838e-01 1.598838e-01 1.072879e-02 0.072544 + 34 1.7000 -0.4455 1 1 1.810420e-01 1.810420e-01 1.306631e-02 0.074380 + 35 1.7500 -0.4619 1 1 2.238122e-01 2.238122e-01 1.512466e-02 0.078053 + 36 1.8000 -0.4755 1 1 2.638722e-01 2.638722e-01 1.687399e-02 0.081726 + 37 1.8500 -0.4862 1 1 3.133549e-01 3.133549e-01 1.829238e-02 0.081726 + 38 1.9000 -0.4938 1 1 3.615901e-01 3.615901e-01 1.939778e-02 0.081726 + 39 1.9500 -0.4985 1 1 4.063672e-01 4.063672e-01 2.028848e-02 0.084481 + 40 2.0000 -0.5000 1 1 4.473514e-01 4.473514e-01 2.105355e-02 0.087236 + 41 2.0500 -0.4985 1 1 4.842762e-01 4.842762e-01 2.163985e-02 0.089073 + 42 2.1000 -0.4938 1 1 5.282834e-01 5.282834e-01 2.193861e-02 0.089073 + 43 2.1500 -0.4862 1 1 5.772664e-01 5.772664e-01 2.206459e-02 0.090909 + 44 2.2000 -0.4755 1 1 6.219866e-01 6.219866e-01 2.213212e-02 0.091827 + 45 2.2500 -0.4619 1 1 6.619491e-01 6.619491e-01 2.224729e-02 0.092746 + 46 2.3000 -0.4455 1 1 6.969564e-01 6.969564e-01 2.214004e-02 0.092746 + 47 2.3500 -0.4263 1 1 7.266837e-01 7.266837e-01 2.194797e-02 0.092746 + 48 2.4000 -0.4045 1 1 7.510734e-01 7.510734e-01 2.152773e-02 0.092746 + 49 2.4500 -0.3802 1 1 7.702274e-01 7.702274e-01 2.089505e-02 0.092746 + 50 2.5000 -0.3536 1 1 7.843171e-01 7.843171e-01 2.006671e-02 0.092746 + 51 2.5500 -0.3247 1 1 7.935401e-01 7.935401e-01 1.905951e-02 0.091827 + 52 2.6000 -0.2939 1 1 7.981186e-01 7.981186e-01 1.788337e-02 0.090909 + 53 2.6500 -0.2612 1 1 7.981725e-01 7.981725e-01 1.685370e-02 0.089073 + 54 2.7000 -0.2270 1 1 7.938056e-01 7.938056e-01 1.579015e-02 0.085399 + 55 2.7500 -0.1913 1 1 7.851243e-01 7.851243e-01 1.454144e-02 0.083563 + 56 2.8000 -0.1545 1 1 7.721168e-01 7.721168e-01 1.317253e-02 0.078053 + 57 2.8500 -0.1167 1 1 7.547879e-01 7.547879e-01 1.169327e-02 0.073462 + 58 2.9000 -0.0782 1 1 7.332337e-01 7.332337e-01 1.027793e-02 0.068871 + 59 2.9500 -0.0392 1 1 7.075753e-01 7.075753e-01 8.729184e-03 0.065197 + 60 3.0000 -0.0000 1 1 6.779846e-01 6.779846e-01 7.188479e-03 0.059688 + 61 3.0500 +0.0392 1 1 6.465370e-01 6.465370e-01 5.643225e-03 0.053260 + 62 3.1000 +0.0782 1 1 6.116895e-01 6.116895e-01 3.843751e-03 0.028466 + 63 3.1500 +0.1167 1 1 5.744340e-01 5.744340e-01 2.409459e-03 0.004591 + 64 3.2000 +0.1545 1 1 5.350145e-01 5.350145e-01 1.969759e-03 0.001837 + 65 3.2500 +0.1913 1 1 4.938750e-01 4.938750e-01 1.888315e-03 0.001837 + 66 3.3000 +0.2270 1 1 4.513645e-01 4.513645e-01 1.724640e-03 0.004591 + 67 3.3500 +0.2612 1 1 4.077729e-01 4.077729e-01 1.635097e-03 0.009183 + 68 3.4000 +0.2939 1 1 3.628311e-01 3.628311e-01 1.949322e-03 0.030303 + 69 3.4500 +0.3247 1 1 3.157002e-01 3.157002e-01 2.924769e-03 0.044995 + 70 3.5000 +0.3536 1 1 2.653372e-01 2.653372e-01 4.546049e-03 0.055096 + 71 3.5500 +0.3802 1 1 2.125265e-01 2.125265e-01 6.607843e-03 0.059688 + 72 3.6000 +0.4045 1 1 1.559260e-01 1.559260e-01 8.480549e-03 0.067034 + 73 3.6500 +0.4263 1 1 1.598838e-01 1.598838e-01 1.051411e-02 0.072544 + 74 3.7000 +0.4455 1 1 1.662188e-01 1.662188e-01 1.269594e-02 0.074380 + 75 3.7500 +0.4619 1 1 1.986397e-01 1.986397e-01 1.494535e-02 0.078053 + 76 3.8000 +0.4755 1 1 2.455196e-01 2.455196e-01 1.685755e-02 0.081726 + 77 3.8500 +0.4862 1 1 2.903839e-01 2.903839e-01 1.850313e-02 0.081726 + 78 3.9000 +0.4938 1 1 3.370835e-01 3.370835e-01 1.973221e-02 0.081726 + 79 3.9500 +0.4985 1 1 3.880890e-01 3.880890e-01 2.074312e-02 0.083563 + 80 4.0000 +0.5000 1 1 4.353984e-01 4.353984e-01 2.153348e-02 0.086318 + 81 4.0500 +0.4985 1 1 4.785444e-01 4.785444e-01 2.212614e-02 0.089073 + 82 4.1000 +0.4938 1 1 5.167683e-01 5.167683e-01 2.247357e-02 0.089073 + 83 4.1500 +0.4862 1 1 5.504927e-01 5.504927e-01 2.259279e-02 0.089073 + 84 4.2000 +0.4755 1 1 5.946387e-01 5.946387e-01 2.243399e-02 0.089073 + 85 4.2500 +0.4619 1 1 6.372919e-01 6.372919e-01 2.205612e-02 0.090909 + 86 4.3000 +0.4455 1 1 6.734048e-01 6.734048e-01 2.166966e-02 0.092746 + 87 4.3500 +0.4263 1 1 7.027984e-01 7.027984e-01 2.121580e-02 0.092746 + 88 4.4000 +0.4045 1 1 7.257176e-01 7.257176e-01 2.071868e-02 0.092746 + 89 4.4500 +0.3802 1 1 7.426603e-01 7.426603e-01 2.025395e-02 0.092746 + 90 4.5000 +0.3536 1 1 7.542223e-01 7.542223e-01 1.969271e-02 0.091827 + 91 4.5500 +0.3247 1 1 7.609657e-01 7.609657e-01 1.900148e-02 0.089991 + 92 4.6000 +0.2939 1 1 7.634485e-01 7.634485e-01 1.813807e-02 0.089991 + 93 4.6500 +0.2612 1 1 7.620670e-01 7.620670e-01 1.713053e-02 0.088154 + 94 4.7000 +0.2270 1 1 7.570430e-01 7.570430e-01 1.594556e-02 0.085399 + 95 4.7500 +0.1913 1 1 7.484863e-01 7.484863e-01 1.460595e-02 0.083563 + 96 4.8000 +0.1545 1 1 7.363491e-01 7.363491e-01 1.314961e-02 0.076217 + 97 4.8500 +0.1167 1 1 7.204547e-01 7.204547e-01 1.171749e-02 0.072544 + 98 4.9000 +0.0782 1 1 7.013981e-01 7.013981e-01 1.021931e-02 0.068871 + 99 4.9500 +0.0392 1 1 6.798032e-01 6.798032e-01 8.675941e-03 0.065197 + 100 5.0000 +0.0000 1 1 6.539379e-01 6.539379e-01 7.059479e-03 0.059688 + 101 5.0500 -0.0392 1 1 6.240594e-01 6.240594e-01 5.518673e-03 0.048669 + 102 5.1000 -0.0782 1 1 5.904925e-01 5.904925e-01 3.551771e-03 0.024793 + 103 5.1500 -0.1167 1 1 5.535297e-01 5.535297e-01 2.128064e-03 0.001837 + 104 5.2000 -0.1545 1 1 5.145025e-01 5.145025e-01 1.647455e-03 0.000000 + 105 5.2500 -0.1913 1 1 4.740482e-01 4.740482e-01 1.587837e-03 0.001837 + 106 5.3000 -0.2270 1 1 4.323105e-01 4.323105e-01 1.406939e-03 0.002755 + 107 5.3500 -0.2612 1 1 3.894801e-01 3.894801e-01 1.331123e-03 0.009183 + 108 5.4000 -0.2939 1 1 3.453496e-01 3.453496e-01 1.591105e-03 0.031221 + 109 5.4500 -0.3247 1 1 2.989171e-01 2.989171e-01 2.527376e-03 0.044995 + 110 5.5000 -0.3536 1 1 2.484559e-01 2.484559e-01 4.543657e-03 0.054178 + 111 5.5500 -0.3802 1 1 1.957117e-01 1.957117e-01 6.666710e-03 0.059688 + 112 5.6000 -0.4045 1 1 1.414415e-01 1.414415e-01 8.680619e-03 0.067034 + 113 5.6500 -0.4263 1 1 1.598838e-01 1.598838e-01 1.084531e-02 0.072544 + 114 5.7000 -0.4455 1 1 1.660375e-01 1.660375e-01 1.310024e-02 0.074380 + 115 5.7500 -0.4619 1 1 1.951370e-01 1.951370e-01 1.524265e-02 0.078053 + 116 5.8000 -0.4755 1 1 2.367414e-01 2.367414e-01 1.697206e-02 0.081726 + 117 5.8500 -0.4862 1 1 2.792419e-01 2.792419e-01 1.839754e-02 0.081726 + 118 5.9000 -0.4938 1 1 3.291106e-01 3.291106e-01 1.942385e-02 0.081726 + 119 5.9500 -0.4985 1 1 3.755431e-01 3.755431e-01 2.029558e-02 0.083563 + 120 6.0000 -0.5000 1 1 4.181972e-01 4.181972e-01 2.101870e-02 0.086318 diff --git a/docs/developer/design/_phase_f_etd1_pc_picard.trace.txt b/docs/developer/design/_phase_f_etd1_pc_picard.trace.txt new file mode 100644 index 000000000..4938e56c8 --- /dev/null +++ b/docs/developer/design/_phase_f_etd1_pc_picard.trace.txt @@ -0,0 +1,123 @@ +# Phase F predictor-corrector trace: etd1_pc_picard +# integrator='etd' order=1 apply_radial_return=True in_residual_yield=False +# columns: step, t, V_top, snes_iters_total, picard_iters, sigma_eq_max, sigma_eq_max_after_correction, u_y_max, yielded_fraction + 1 0.0500 +0.4985 2 6 4.161077e-02 4.161077e-02 5.099459e-11 0.000000 + 2 0.1000 +0.4938 2 6 8.093501e-02 8.093501e-02 1.349445e-05 0.046832 + 3 0.1500 +0.4862 3 6 1.287508e-01 1.287508e-01 3.351302e-03 0.061524 + 4 0.2000 +0.4755 4 6 1.811023e-01 1.811023e-01 8.347638e-03 0.072544 + 5 0.2500 +0.4619 6 6 2.302776e-01 2.302776e-01 1.234744e-02 0.073462 + 6 0.3000 +0.4455 4 6 2.775334e-01 2.775334e-01 1.493068e-02 0.081726 + 7 0.3500 +0.4263 6 6 3.230740e-01 3.230740e-01 1.627572e-02 0.081726 + 8 0.4000 +0.4045 6 6 3.674049e-01 3.674049e-01 1.692438e-02 0.081726 + 9 0.4500 +0.3802 6 6 4.058537e-01 4.058537e-01 1.703386e-02 0.081726 + 10 0.5000 +0.3536 6 6 4.377588e-01 4.377588e-01 1.663614e-02 0.082645 + 11 0.5500 +0.3247 6 6 4.635655e-01 4.635655e-01 1.593578e-02 0.083563 + 12 0.6000 +0.2939 5 5 4.832436e-01 4.832436e-01 1.509389e-02 0.083563 + 13 0.6500 +0.2612 5 5 4.976155e-01 4.976155e-01 1.456039e-02 0.083563 + 14 0.7000 +0.2270 4 4 5.067653e-01 5.067653e-01 1.378263e-02 0.081726 + 15 0.7500 +0.1913 4 4 5.113699e-01 5.113699e-01 1.287287e-02 0.078053 + 16 0.8000 +0.1545 5 5 5.115385e-01 5.115385e-01 1.179448e-02 0.074380 + 17 0.8500 +0.1167 5 5 5.072425e-01 5.072425e-01 1.050574e-02 0.073462 + 18 0.9000 +0.0782 6 6 4.983598e-01 4.983598e-01 9.114029e-03 0.068871 + 19 0.9500 +0.0392 6 6 4.859537e-01 4.859537e-01 7.607633e-03 0.065197 + 20 1.0000 -0.0000 6 6 4.694749e-01 4.694749e-01 6.055486e-03 0.059688 + 21 1.0500 -0.0392 6 6 4.485793e-01 4.485793e-01 4.131973e-03 0.047750 + 22 1.1000 -0.0782 6 6 4.235467e-01 4.235467e-01 2.559961e-03 0.022957 + 23 1.1500 -0.1167 6 6 3.948545e-01 3.948545e-01 1.422857e-03 0.000918 + 24 1.2000 -0.1545 6 6 3.638478e-01 3.638478e-01 1.121558e-03 0.000000 + 25 1.2500 -0.1913 6 6 3.311189e-01 3.311189e-01 1.010840e-03 0.000000 + 26 1.3000 -0.2270 6 6 2.968686e-01 2.968686e-01 8.951009e-04 0.001837 + 27 1.3500 -0.2612 6 6 2.612707e-01 2.612707e-01 8.206035e-04 0.004591 + 28 1.4000 -0.2939 6 6 2.241692e-01 2.241692e-01 1.015615e-03 0.029385 + 29 1.4500 -0.3247 6 6 1.846865e-01 1.846865e-01 1.975358e-03 0.046832 + 30 1.5000 -0.3536 6 6 1.409845e-01 1.409845e-01 4.005596e-03 0.054178 + 31 1.5500 -0.3802 6 6 1.166370e-01 1.166370e-01 6.163564e-03 0.059688 + 32 1.6000 -0.4045 6 6 1.336565e-01 1.336565e-01 8.330606e-03 0.066116 + 33 1.6500 -0.4263 6 6 1.594410e-01 1.594410e-01 1.050882e-02 0.072544 + 34 1.7000 -0.4455 6 6 1.765654e-01 1.765654e-01 1.282975e-02 0.073462 + 35 1.7500 -0.4619 6 6 2.194027e-01 2.194027e-01 1.493627e-02 0.078053 + 36 1.8000 -0.4755 6 6 2.595226e-01 2.595226e-01 1.670650e-02 0.081726 + 37 1.8500 -0.4862 5 6 3.063497e-01 3.063497e-01 1.817514e-02 0.081726 + 38 1.9000 -0.4938 5 6 3.544942e-01 3.544942e-01 1.928309e-02 0.081726 + 39 1.9500 -0.4985 6 6 3.991369e-01 3.991369e-01 2.019328e-02 0.084481 + 40 2.0000 -0.5000 5 6 4.398635e-01 4.398635e-01 2.094383e-02 0.086318 + 41 2.0500 -0.4985 5 6 4.766056e-01 4.766056e-01 2.155494e-02 0.089073 + 42 2.1000 -0.4938 5 6 5.164216e-01 5.164216e-01 2.186644e-02 0.089073 + 43 2.1500 -0.4862 5 6 5.647899e-01 5.647899e-01 2.200411e-02 0.089991 + 44 2.2000 -0.4755 6 6 6.088865e-01 6.088865e-01 2.205423e-02 0.091827 + 45 2.2500 -0.4619 6 6 6.482230e-01 6.482230e-01 2.215879e-02 0.091827 + 46 2.3000 -0.4455 6 6 6.827193e-01 6.827193e-01 2.204627e-02 0.092746 + 47 2.3500 -0.4263 6 6 7.121618e-01 7.121618e-01 2.181352e-02 0.092746 + 48 2.4000 -0.4045 5 5 7.360911e-01 7.360911e-01 2.145828e-02 0.092746 + 49 2.4500 -0.3802 5 5 7.551158e-01 7.551158e-01 2.084470e-02 0.092746 + 50 2.5000 -0.3536 4 4 7.689838e-01 7.689838e-01 2.003559e-02 0.092746 + 51 2.5500 -0.3247 4 4 7.784161e-01 7.784161e-01 1.908171e-02 0.089991 + 52 2.6000 -0.2939 4 4 7.835573e-01 7.835573e-01 1.817218e-02 0.089073 + 53 2.6500 -0.2612 4 4 7.845050e-01 7.845050e-01 1.729180e-02 0.089073 + 54 2.7000 -0.2270 4 4 7.813407e-01 7.813407e-01 1.624477e-02 0.085399 + 55 2.7500 -0.1913 5 5 7.738206e-01 7.738206e-01 1.499546e-02 0.083563 + 56 2.8000 -0.1545 5 5 7.620874e-01 7.620874e-01 1.361492e-02 0.077135 + 57 2.8500 -0.1167 5 5 7.461220e-01 7.461220e-01 1.206496e-02 0.073462 + 58 2.9000 -0.0782 6 6 7.256369e-01 7.256369e-01 1.060674e-02 0.068871 + 59 2.9500 -0.0392 6 6 7.010363e-01 7.010363e-01 9.047866e-03 0.065197 + 60 3.0000 -0.0000 6 6 6.738234e-01 6.738234e-01 7.415888e-03 0.059688 + 61 3.0500 +0.0392 6 6 6.433692e-01 6.433692e-01 5.543277e-03 0.053260 + 62 3.1000 +0.0782 6 6 6.099471e-01 6.099471e-01 3.871836e-03 0.029385 + 63 3.1500 +0.1167 6 6 5.734248e-01 5.734248e-01 2.376721e-03 0.004591 + 64 3.2000 +0.1545 6 6 5.346290e-01 5.346290e-01 1.916660e-03 0.001837 + 65 3.2500 +0.1913 6 6 4.940989e-01 4.940989e-01 1.827304e-03 0.001837 + 66 3.3000 +0.2270 6 6 4.522099e-01 4.522099e-01 1.683525e-03 0.004591 + 67 3.3500 +0.2612 6 6 4.092209e-01 4.092209e-01 1.594976e-03 0.009183 + 68 3.4000 +0.2939 6 6 3.649311e-01 3.649311e-01 1.932707e-03 0.030303 + 69 3.4500 +0.3247 6 6 3.186272e-01 3.186272e-01 2.831886e-03 0.044995 + 70 3.5000 +0.3536 6 6 2.685705e-01 2.685705e-01 4.340578e-03 0.054178 + 71 3.5500 +0.3802 6 6 2.166092e-01 2.166092e-01 6.361989e-03 0.059688 + 72 3.6000 +0.4045 6 6 1.610261e-01 1.610261e-01 8.260349e-03 0.066116 + 73 3.6500 +0.4263 6 6 1.595580e-01 1.595580e-01 1.034355e-02 0.072544 + 74 3.7000 +0.4455 6 6 1.632820e-01 1.632820e-01 1.248723e-02 0.074380 + 75 3.7500 +0.4619 6 6 1.940748e-01 1.940748e-01 1.479202e-02 0.078053 + 76 3.8000 +0.4755 6 6 2.410678e-01 2.410678e-01 1.669484e-02 0.081726 + 77 3.8500 +0.4862 6 6 2.858449e-01 2.858449e-01 1.840059e-02 0.081726 + 78 3.9000 +0.4938 6 6 3.294513e-01 3.294513e-01 1.965186e-02 0.081726 + 79 3.9500 +0.4985 6 6 3.802306e-01 3.802306e-01 2.068451e-02 0.082645 + 80 4.0000 +0.5000 6 6 4.271951e-01 4.271951e-01 2.145156e-02 0.085399 + 81 4.0500 +0.4985 6 6 4.698095e-01 4.698095e-01 2.204894e-02 0.089073 + 82 4.1000 +0.4938 5 6 5.072628e-01 5.072628e-01 2.239363e-02 0.089073 + 83 4.1500 +0.4862 5 6 5.400127e-01 5.400127e-01 2.251380e-02 0.089073 + 84 4.2000 +0.4755 5 6 5.800820e-01 5.800820e-01 2.235144e-02 0.089073 + 85 4.2500 +0.4619 6 6 6.211326e-01 6.211326e-01 2.199080e-02 0.090909 + 86 4.3000 +0.4455 6 6 6.558938e-01 6.558938e-01 2.156490e-02 0.092746 + 87 4.3500 +0.4263 6 6 6.843123e-01 6.843123e-01 2.102370e-02 0.092746 + 88 4.4000 +0.4045 5 5 7.063759e-01 7.063759e-01 2.062574e-02 0.092746 + 89 4.4500 +0.3802 5 5 7.231025e-01 7.231025e-01 2.032786e-02 0.092746 + 90 4.5000 +0.3536 5 5 7.350305e-01 7.350305e-01 1.985498e-02 0.091827 + 91 4.5500 +0.3247 4 4 7.424922e-01 7.424922e-01 1.919538e-02 0.089991 + 92 4.6000 +0.2939 4 4 7.461228e-01 7.461228e-01 1.844436e-02 0.089073 + 93 4.6500 +0.2612 4 4 7.462011e-01 7.462011e-01 1.746419e-02 0.088154 + 94 4.7000 +0.2270 4 4 7.428746e-01 7.428746e-01 1.629555e-02 0.085399 + 95 4.7500 +0.1913 5 5 7.358811e-01 7.358811e-01 1.502104e-02 0.083563 + 96 4.8000 +0.1545 5 5 7.253639e-01 7.253639e-01 1.351238e-02 0.076217 + 97 4.8500 +0.1167 5 5 7.110578e-01 7.110578e-01 1.204830e-02 0.072544 + 98 4.9000 +0.0782 6 6 6.946220e-01 6.946220e-01 1.054086e-02 0.068871 + 99 4.9500 +0.0392 6 6 6.739125e-01 6.739125e-01 8.925269e-03 0.065197 + 100 5.0000 +0.0000 6 6 6.489214e-01 6.489214e-01 7.307332e-03 0.059688 + 101 5.0500 -0.0392 6 6 6.198678e-01 6.198678e-01 5.249013e-03 0.050505 + 102 5.1000 -0.0782 6 6 5.868589e-01 5.868589e-01 3.540912e-03 0.026630 + 103 5.1500 -0.1167 6 6 5.506593e-01 5.506593e-01 2.155882e-03 0.001837 + 104 5.2000 -0.1545 6 6 5.124078e-01 5.124078e-01 1.573846e-03 0.000000 + 105 5.2500 -0.1913 6 6 4.726878e-01 4.726878e-01 1.526428e-03 0.001837 + 106 5.3000 -0.2270 6 6 4.316547e-01 4.316547e-01 1.350858e-03 0.002755 + 107 5.3500 -0.2612 6 6 3.895309e-01 3.895309e-01 1.259554e-03 0.008264 + 108 5.4000 -0.2939 6 6 3.461341e-01 3.461341e-01 1.468956e-03 0.030303 + 109 5.4500 -0.3247 6 6 3.006082e-01 3.006082e-01 2.371470e-03 0.044995 + 110 5.5000 -0.3536 6 6 2.510723e-01 2.510723e-01 4.310133e-03 0.054178 + 111 5.5500 -0.3802 6 6 1.995589e-01 1.995589e-01 6.431268e-03 0.059688 + 112 5.6000 -0.4045 6 6 1.452066e-01 1.452066e-01 8.472646e-03 0.066116 + 113 5.6500 -0.4263 6 6 1.594970e-01 1.594970e-01 1.064486e-02 0.072544 + 114 5.7000 -0.4455 6 6 1.630191e-01 1.630191e-01 1.287049e-02 0.074380 + 115 5.7500 -0.4619 6 6 1.902722e-01 1.902722e-01 1.505430e-02 0.077135 + 116 5.8000 -0.4755 6 6 2.319192e-01 2.319192e-01 1.681173e-02 0.081726 + 117 5.8500 -0.4862 6 6 2.719600e-01 2.719600e-01 1.828151e-02 0.081726 + 118 5.9000 -0.4938 5 6 3.215692e-01 3.215692e-01 1.932832e-02 0.081726 + 119 5.9500 -0.4985 6 6 3.678246e-01 3.678246e-01 2.020076e-02 0.083563 + 120 6.0000 -0.5000 6 6 4.102416e-01 4.102416e-01 2.091711e-02 0.086318 diff --git a/docs/developer/design/_phase_f_etd2_pc1.trace.txt b/docs/developer/design/_phase_f_etd2_pc1.trace.txt new file mode 100644 index 000000000..d4f420707 --- /dev/null +++ b/docs/developer/design/_phase_f_etd2_pc1.trace.txt @@ -0,0 +1,123 @@ +# Phase F predictor-corrector trace: etd2_pc1 +# integrator='etd' order=2 apply_radial_return=True in_residual_yield=False +# columns: step, t, V_top, snes_iters_total, picard_iters, sigma_eq_max, sigma_eq_max_after_correction, u_y_max, yielded_fraction + 1 0.0500 +0.4985 1 1 2.131127e-02 2.131127e-02 9.914503e-06 0.000000 + 2 0.1000 +0.4938 1 1 6.239662e-02 6.239662e-02 2.606727e-05 0.033976 + 3 0.1500 +0.4862 1 1 1.049722e-01 1.049722e-01 1.715476e-03 0.056933 + 4 0.2000 +0.4755 1 1 1.573794e-01 1.573794e-01 1.068129e-02 0.067034 + 5 0.2500 +0.4619 1 1 2.099182e-01 2.099182e-01 1.096408e-02 0.072544 + 6 0.3000 +0.4455 1 1 2.570379e-01 2.570379e-01 1.714362e-02 0.078972 + 7 0.3500 +0.4263 1 1 3.056119e-01 3.056119e-01 1.463398e-02 0.081726 + 8 0.4000 +0.4045 1 1 3.528050e-01 3.528050e-01 1.893268e-02 0.081726 + 9 0.4500 +0.3802 1 1 3.950287e-01 3.950287e-01 1.531541e-02 0.081726 + 10 0.5000 +0.3536 1 1 4.303910e-01 4.303910e-01 1.860162e-02 0.081726 + 11 0.5500 +0.3247 1 1 4.596525e-01 4.596525e-01 1.503352e-02 0.083563 + 12 0.6000 +0.2939 1 1 4.823697e-01 4.823697e-01 1.744548e-02 0.083563 + 13 0.6500 +0.2612 1 1 4.997832e-01 4.997832e-01 1.443183e-02 0.083563 + 14 0.7000 +0.2270 1 1 5.114560e-01 5.114560e-01 1.609903e-02 0.081726 + 15 0.7500 +0.1913 1 1 5.187145e-01 5.187145e-01 1.290046e-02 0.078972 + 16 0.8000 +0.1545 1 1 5.207510e-01 5.207510e-01 1.444387e-02 0.076217 + 17 0.8500 +0.1167 1 1 5.186851e-01 5.186851e-01 1.077858e-02 0.074380 + 18 0.9000 +0.0782 1 1 5.115076e-01 5.115076e-01 1.346888e-02 0.070707 + 19 0.9500 +0.0392 1 1 5.015462e-01 5.015462e-01 9.097274e-03 0.065197 + 20 1.0000 -0.0000 1 1 4.867079e-01 4.867079e-01 1.245325e-02 0.062443 + 21 1.0500 -0.0392 1 1 4.675201e-01 4.675201e-01 1.021491e-02 0.052342 + 22 1.1000 -0.0782 1 1 4.434251e-01 4.434251e-01 1.038060e-02 0.038567 + 23 1.1500 -0.1167 1 1 4.164446e-01 4.164446e-01 1.173302e-02 0.006428 + 24 1.2000 -0.1545 1 1 3.849434e-01 3.849434e-01 1.269477e-02 0.000000 + 25 1.2500 -0.1913 1 1 3.531562e-01 3.531562e-01 1.352314e-02 0.000000 + 26 1.3000 -0.2270 1 1 3.179293e-01 3.179293e-01 1.607292e-02 0.000918 + 27 1.3500 -0.2612 1 1 2.833158e-01 2.833158e-01 1.672074e-02 0.003673 + 28 1.4000 -0.2939 1 1 2.450061e-01 2.450061e-01 1.905884e-02 0.016529 + 29 1.4500 -0.3247 1 1 2.071696e-01 2.071696e-01 2.134301e-02 0.039486 + 30 1.5000 -0.3536 1 1 1.635172e-01 1.635172e-01 2.036617e-02 0.050505 + 31 1.5500 -0.3802 1 1 1.178158e-01 1.178158e-01 2.706291e-02 0.059688 + 32 1.6000 -0.4045 1 1 1.280774e-01 1.280774e-01 2.330717e-02 0.064279 + 33 1.6500 -0.4263 1 1 1.517607e-01 1.517607e-01 3.185378e-02 0.068871 + 34 1.7000 -0.4455 1 1 1.598838e-01 1.598838e-01 2.657179e-02 0.072544 + 35 1.7500 -0.4619 1 1 2.045166e-01 2.045166e-01 3.583165e-02 0.076217 + 36 1.8000 -0.4755 1 1 2.435566e-01 2.435566e-01 2.884058e-02 0.081726 + 37 1.8500 -0.4862 1 1 2.918784e-01 2.918784e-01 3.794113e-02 0.081726 + 38 1.9000 -0.4938 1 1 3.363541e-01 3.363541e-01 2.983185e-02 0.081726 + 39 1.9500 -0.4985 1 1 3.886665e-01 3.886665e-01 3.713012e-02 0.083563 + 40 2.0000 -0.5000 1 1 4.241066e-01 4.241066e-01 2.952963e-02 0.086318 + 41 2.0500 -0.4985 1 1 4.703930e-01 4.703930e-01 3.455771e-02 0.089073 + 42 2.1000 -0.4938 1 1 5.022749e-01 5.022749e-01 2.753084e-02 0.089073 + 43 2.1500 -0.4862 1 1 5.534926e-01 5.534926e-01 3.177891e-02 0.089991 + 44 2.2000 -0.4755 1 1 5.979954e-01 5.979954e-01 2.425708e-02 0.091827 + 45 2.2500 -0.4619 1 1 6.417369e-01 6.417369e-01 3.041661e-02 0.091827 + 46 2.3000 -0.4455 1 1 6.769766e-01 6.769766e-01 2.647030e-02 0.091827 + 47 2.3500 -0.4263 1 1 7.102783e-01 7.102783e-01 3.046915e-02 0.092746 + 48 2.4000 -0.4045 1 1 7.360519e-01 7.360519e-01 2.877922e-02 0.092746 + 49 2.4500 -0.3802 1 1 7.581262e-01 7.581262e-01 2.953757e-02 0.092746 + 50 2.5000 -0.3536 1 1 7.747097e-01 7.747097e-01 3.010213e-02 0.092746 + 51 2.5500 -0.3247 1 1 7.862858e-01 7.862858e-01 2.827722e-02 0.089991 + 52 2.6000 -0.2939 1 1 7.943083e-01 7.943083e-01 3.064881e-02 0.089073 + 53 2.6500 -0.2612 1 1 7.961581e-01 7.961581e-01 2.784177e-02 0.089073 + 54 2.7000 -0.2270 1 1 7.958760e-01 7.958760e-01 3.067474e-02 0.087236 + 55 2.7500 -0.1913 1 1 7.885778e-01 7.885778e-01 2.694955e-02 0.084481 + 56 2.8000 -0.1545 1 1 7.800704e-01 7.800704e-01 3.004956e-02 0.081726 + 57 2.8500 -0.1167 1 1 7.639095e-01 7.639095e-01 2.975656e-02 0.074380 + 58 2.9000 -0.0782 1 1 7.467074e-01 7.467074e-01 2.920571e-02 0.070707 + 59 2.9500 -0.0392 1 1 7.223785e-01 7.223785e-01 3.390057e-02 0.067034 + 60 3.0000 -0.0000 1 1 6.973502e-01 6.973502e-01 3.174960e-02 0.062443 + 61 3.0500 +0.0392 1 1 6.667682e-01 6.667682e-01 3.857958e-02 0.056015 + 62 3.1000 +0.0782 1 1 6.348044e-01 6.348044e-01 3.902196e-02 0.046832 + 63 3.1500 +0.1167 1 1 5.979348e-01 5.979348e-01 4.217937e-02 0.015611 + 64 3.2000 +0.1545 1 1 5.606210e-01 5.606210e-01 4.461844e-02 0.002755 + 65 3.2500 +0.1913 1 1 5.185266e-01 5.185266e-01 4.670081e-02 0.001837 + 66 3.3000 +0.2270 1 1 4.780786e-01 4.780786e-01 5.137915e-02 0.004591 + 67 3.3500 +0.2612 1 1 4.326961e-01 4.326961e-01 5.301539e-02 0.007346 + 68 3.4000 +0.2939 1 1 3.901218e-01 3.901218e-01 5.731251e-02 0.012856 + 69 3.4500 +0.3247 1 1 3.425765e-01 3.425765e-01 5.724200e-02 0.041322 + 70 3.5000 +0.3536 1 1 2.949486e-01 2.949486e-01 6.371193e-02 0.050505 + 71 3.5500 +0.3802 1 1 2.421294e-01 2.421294e-01 5.811950e-02 0.059688 + 72 3.6000 +0.4045 1 1 1.881670e-01 1.881670e-01 6.840521e-02 0.064279 + 73 3.6500 +0.4263 1 1 1.555694e-01 1.555694e-01 5.713230e-02 0.067952 + 74 3.7000 +0.4455 1 1 1.598838e-01 1.598838e-01 7.112147e-02 0.072544 + 75 3.7500 +0.4619 1 1 1.791295e-01 1.791295e-01 5.442180e-02 0.076217 + 76 3.8000 +0.4755 1 1 2.257324e-01 2.257324e-01 7.161664e-02 0.079890 + 77 3.8500 +0.4862 1 1 2.690861e-01 2.690861e-01 5.083165e-02 0.081726 + 78 3.9000 +0.4938 1 1 3.166168e-01 3.166168e-01 7.074444e-02 0.081726 + 79 3.9500 +0.4985 1 1 3.635441e-01 3.635441e-01 4.568633e-02 0.081726 + 80 4.0000 +0.5000 1 1 4.151861e-01 4.151861e-01 6.803058e-02 0.084481 + 81 4.0500 +0.4985 1 1 4.566118e-01 4.566118e-01 4.174742e-02 0.088154 + 82 4.1000 +0.4938 1 1 5.005229e-01 5.005229e-01 6.408209e-02 0.089073 + 83 4.1500 +0.4862 1 1 5.305149e-01 5.305149e-01 4.089872e-02 0.089073 + 84 4.2000 +0.4755 1 1 5.707071e-01 5.707071e-01 5.870990e-02 0.089073 + 85 4.2500 +0.4619 1 1 6.126959e-01 6.126959e-01 3.954730e-02 0.090909 + 86 4.3000 +0.4455 1 1 6.540621e-01 6.540621e-01 5.234383e-02 0.092746 + 87 4.3500 +0.4263 1 1 6.829757e-01 6.829757e-01 3.965990e-02 0.092746 + 88 4.4000 +0.4045 1 1 7.113745e-01 7.113745e-01 4.544573e-02 0.092746 + 89 4.4500 +0.3802 1 1 7.282133e-01 7.282133e-01 3.923397e-02 0.092746 + 90 4.5000 +0.3536 1 1 7.450524e-01 7.450524e-01 4.160950e-02 0.091827 + 91 4.5500 +0.3247 1 1 7.526353e-01 7.526353e-01 3.734183e-02 0.089991 + 92 4.6000 +0.2939 1 1 7.598689e-01 7.598689e-01 4.341982e-02 0.089073 + 93 4.6500 +0.2612 1 1 7.603876e-01 7.603876e-01 3.460950e-02 0.089073 + 94 4.7000 +0.2270 1 1 7.590870e-01 7.590870e-01 5.127460e-02 0.087236 + 95 4.7500 +0.1913 1 1 7.533418e-01 7.533418e-01 4.776523e-02 0.083563 + 96 4.8000 +0.1545 1 1 7.438568e-01 7.438568e-01 6.608833e-02 0.079890 + 97 4.8500 +0.1167 1 1 7.321045e-01 7.321045e-01 6.812981e-02 0.075298 + 98 4.9000 +0.0782 1 1 7.178501e-01 7.178501e-01 8.677898e-02 0.070707 + 99 4.9500 +0.0392 1 1 6.975008e-01 6.975008e-01 9.291052e-02 0.067034 + 100 5.0000 +0.0000 1 1 6.742254e-01 6.742254e-01 1.110457e-01 0.061524 + 101 5.0500 -0.0392 1 1 6.448974e-01 6.448974e-01 1.211323e-01 0.052342 + 102 5.1000 -0.0782 1 1 6.150097e-01 6.150097e-01 1.369753e-01 0.041322 + 103 5.1500 -0.1167 1 1 5.758267e-01 5.758267e-01 1.497804e-01 0.010101 + 104 5.2000 -0.1545 1 1 5.411679e-01 5.411679e-01 1.634744e-01 0.002755 + 105 5.2500 -0.1913 1 1 4.970565e-01 4.970565e-01 1.772986e-01 0.001837 + 106 5.3000 -0.2270 1 1 4.599766e-01 4.599766e-01 1.902496e-01 0.001837 + 107 5.3500 -0.2612 1 1 4.129634e-01 4.129634e-01 2.035024e-01 0.006428 + 108 5.4000 -0.2939 1 1 3.736203e-01 3.736203e-01 2.145889e-01 0.011019 + 109 5.4500 -0.3247 1 1 3.237010e-01 3.237010e-01 2.273457e-01 0.041322 + 110 5.5000 -0.3536 1 1 2.798159e-01 2.798159e-01 2.378508e-01 0.050505 + 111 5.5500 -0.3802 1 1 2.228483e-01 2.228483e-01 2.586419e-01 0.058770 + 112 5.6000 -0.4045 1 1 1.766384e-01 1.766384e-01 2.637201e-01 0.064279 + 113 5.6500 -0.4263 1 1 1.547365e-01 1.547365e-01 2.853110e-01 0.068871 + 114 5.7000 -0.4455 1 1 1.598838e-01 1.598838e-01 2.825352e-01 0.073462 + 115 5.7500 -0.4619 1 1 2.088551e-01 2.088551e-01 3.047250e-01 0.076217 + 116 5.8000 -0.4755 1 1 2.197518e-01 2.197518e-01 2.940441e-01 0.081726 + 117 5.8500 -0.4862 1 1 2.587456e-01 2.587456e-01 3.149978e-01 0.081726 + 118 5.9000 -0.4938 1 1 3.094653e-01 3.094653e-01 2.954414e-01 0.081726 + 119 5.9500 -0.4985 1 1 3.424852e-01 3.424852e-01 3.132496e-01 0.082645 + 120 6.0000 -0.5000 1 1 4.068626e-01 4.068626e-01 2.862348e-01 0.084481 diff --git a/docs/developer/design/_phase_f_etd2_pc_picard.trace.txt b/docs/developer/design/_phase_f_etd2_pc_picard.trace.txt new file mode 100644 index 000000000..4c1aac478 --- /dev/null +++ b/docs/developer/design/_phase_f_etd2_pc_picard.trace.txt @@ -0,0 +1,63 @@ +# Phase F predictor-corrector trace: etd2_pc_picard +# integrator='etd' order=2 apply_radial_return=True in_residual_yield=False +# columns: step, t, V_top, snes_iters_total, picard_iters, sigma_eq_max, sigma_eq_max_after_correction, u_y_max, yielded_fraction + 1 0.0500 +0.4985 6 6 4.128329e-02 4.128329e-02 9.032622e-06 0.000000 + 2 0.1000 +0.4938 6 6 8.062246e-02 8.062246e-02 8.778956e-06 0.046832 + 3 0.1500 +0.4862 6 6 1.282990e-01 1.282990e-01 4.488153e-04 0.061524 + 4 0.2000 +0.4755 6 6 1.806463e-01 1.806463e-01 1.659196e-03 0.071625 + 5 0.2500 +0.4619 6 6 2.299833e-01 2.299833e-01 3.567426e-03 0.073462 + 6 0.3000 +0.4455 6 6 2.763849e-01 2.763849e-01 6.038144e-03 0.081726 + 7 0.3500 +0.4263 6 6 3.231831e-01 3.231831e-01 8.951024e-03 0.081726 + 8 0.4000 +0.4045 6 6 3.676827e-01 3.676827e-01 1.271368e-02 0.081726 + 9 0.4500 +0.3802 6 6 4.063513e-01 4.063513e-01 2.101424e-02 0.081726 + 10 0.5000 +0.3536 6 6 4.385887e-01 4.385887e-01 3.332654e-02 0.082645 + 11 0.5500 +0.3247 6 6 4.648292e-01 4.648292e-01 5.096470e-02 0.083563 + 12 0.6000 +0.2939 6 6 4.853601e-01 4.853601e-01 7.694427e-02 0.083563 + 13 0.6500 +0.2612 6 6 5.006052e-01 5.006052e-01 1.156709e-01 0.083563 + 14 0.7000 +0.2270 6 6 5.111314e-01 5.111314e-01 1.678903e-01 0.081726 + 15 0.7500 +0.1913 6 6 5.168793e-01 5.168793e-01 2.345889e-01 0.078053 + 16 0.8000 +0.1545 6 6 5.186714e-01 5.186714e-01 3.194702e-01 0.075298 + 17 0.8500 +0.1167 6 6 5.165864e-01 5.165864e-01 4.280691e-01 0.068871 + 18 0.9000 +0.0782 6 6 5.234129e-01 5.234129e-01 5.854402e-01 0.059688 + 19 0.9500 +0.0392 6 6 5.867592e-01 5.867592e-01 7.774405e-01 0.053260 + 20 1.0000 -0.0000 6 6 6.367049e-01 6.367049e-01 9.775868e-01 0.041322 + 21 1.0500 -0.0392 6 6 6.757299e-01 6.757299e-01 1.155523e+00 0.028466 + 22 1.1000 -0.0782 6 6 6.955732e-01 6.955732e-01 1.281643e+00 0.011019 + 23 1.1500 -0.1167 6 6 7.025940e-01 7.025940e-01 1.366677e+00 0.000000 + 24 1.2000 -0.1545 6 6 7.152548e-01 7.152548e-01 1.413835e+00 0.000000 + 25 1.2500 -0.1913 6 6 7.402345e-01 7.402345e-01 1.468174e+00 0.001837 + 26 1.3000 -0.2270 6 6 7.759602e-01 7.759602e-01 1.538369e+00 0.004591 + 27 1.3500 -0.2612 6 6 8.198429e-01 8.198429e-01 1.592886e+00 0.008264 + 28 1.4000 -0.2939 6 6 8.827329e-01 8.827329e-01 1.635576e+00 0.025712 + 29 1.4500 -0.3247 6 6 9.305599e-01 9.305599e-01 1.671694e+00 0.036731 + 30 1.5000 -0.3536 6 6 9.176821e-01 9.176821e-01 1.689775e+00 0.045914 + 31 1.5500 -0.3802 6 6 1.097916e+00 1.097916e+00 1.674682e+00 0.048669 + 32 1.6000 -0.4045 6 6 1.214428e+00 1.214428e+00 1.637503e+00 0.049587 + 33 1.6500 -0.4263 6 6 1.339736e+00 1.339736e+00 1.619010e+00 0.054178 + 34 1.7000 -0.4455 6 6 1.569842e+00 1.569842e+00 1.712545e+00 0.053260 + 35 1.7500 -0.4619 6 6 1.768136e+00 1.768136e+00 2.102587e+00 0.061524 + 36 1.8000 -0.4755 6 6 2.032997e+00 2.032997e+00 2.454732e+00 0.062443 + 37 1.8500 -0.4862 6 6 2.176244e+00 2.176244e+00 2.849964e+00 0.061524 + 38 1.9000 -0.4938 6 6 2.602614e+00 2.602614e+00 3.596606e+00 0.059688 + 39 1.9500 -0.4985 6 6 2.625967e+00 2.625967e+00 4.030853e+00 0.049587 + 40 2.0000 -0.5000 6 6 2.560084e+00 2.560084e+00 3.803991e+00 0.049587 + 41 2.0500 -0.4985 6 6 2.421060e+00 2.421060e+00 4.127847e+00 0.032140 + 42 2.1000 -0.4938 6 6 2.439370e+00 2.439370e+00 4.099199e+00 0.034894 + 43 2.1500 -0.4862 6 6 2.701228e+00 2.701228e+00 4.153585e+00 0.032140 + 44 2.2000 -0.4755 6 6 2.955110e+00 2.955110e+00 4.392278e+00 0.021120 + 45 2.2500 -0.4619 6 6 3.186634e+00 3.186634e+00 4.594595e+00 0.011019 + 46 2.3000 -0.4455 6 6 3.102073e+00 3.102073e+00 4.461982e+00 0.015611 + 47 2.3500 -0.4263 6 6 2.950377e+00 2.950377e+00 4.048292e+00 0.015611 + 48 2.4000 -0.4045 6 6 2.916105e+00 2.916105e+00 4.480963e+00 0.015611 + 49 2.4500 -0.3802 6 6 2.552756e+00 2.552756e+00 4.639798e+00 0.012856 + 50 2.5000 -0.3536 6 6 2.372077e+00 2.372077e+00 4.676110e+00 0.013774 + 51 2.5500 -0.3247 6 6 2.319044e+00 2.319044e+00 5.137358e+00 0.012856 + 52 2.6000 -0.2939 6 6 2.363969e+00 2.363969e+00 5.114993e+00 0.013774 + 53 2.6500 -0.2612 6 6 2.210504e+00 2.210504e+00 5.205307e+00 0.012856 + 54 2.7000 -0.2270 6 6 2.292182e+00 2.292182e+00 5.493810e+00 0.016529 + 55 2.7500 -0.1913 6 6 2.216241e+00 2.216241e+00 5.507382e+00 0.020202 + 56 2.8000 -0.1545 6 6 2.484720e+00 2.484720e+00 5.237860e+00 0.013774 + 57 2.8500 -0.1167 6 6 2.584487e+00 2.584487e+00 4.986766e+00 0.018365 + 58 2.9000 -0.0782 6 6 2.634986e+00 2.634986e+00 4.819767e+00 0.010101 + 59 2.9500 -0.0392 6 6 2.773244e+00 2.773244e+00 5.291063e+00 0.012856 + 60 3.0000 -0.0000 6 6 1.821914e+01 1.821914e+01 1.022878e+01 0.000000 diff --git a/docs/developer/design/_phase_f_predictor_corrector.py b/docs/developer/design/_phase_f_predictor_corrector.py new file mode 100644 index 000000000..03225da89 --- /dev/null +++ b/docs/developer/design/_phase_f_predictor_corrector.py @@ -0,0 +1,437 @@ +"""Phase F: ETD-2 VE predictor + J2 radial-return corrector (isotropic). + +Implements the predictor-corrector architecture from the web advice +(``vep_stress_update_full_latex.md`` §15 Stage 4) on the isotropic VEP +case. The TI extension is the end goal but isotropic is a cleaner first +test of the architecture. + +Per-step structure: + 1. Stokes solve with constitutive_model = isotropic Maxwell ETD-2 + (yield_stress = ∞, so no in-residual yield clipping). + 2. Read psi_star (= unclipped VE trial stress). + 3. J2 radial return at each quadrature node: if |σ|_eq > σ_y, scale + σ ← (σ_y/|σ|_eq)·σ. Overwrite psi_star. + 4. The corrected psi_star becomes σⁿ for the next timestep's + history term (α·σⁿ in the ETD-2 update). + +This is "predictor-corrector without outer Picard" — single-shot +correction per timestep. If stability fails, add an outer Picard +loop with stress damping (advice §10, ω_τ ≈ 0.5). + +Comparison: same harmonic shear box geometry as the existing killer +test but isotropic (no fault, no director, uniform σ_y). Compare +trajectory against BDF-1 and ETD-1 baselines run in this script +in the same setup. + +Per-step diagnostics every 5 steps; runaway guard. + +Run:: + + pixi run -e amr-dev python -u docs/developer/design/_phase_f_predictor_corrector.py +""" + +import os +import time + +import numpy as np +import sympy + +import underworld3 as uw +from underworld3 import VarType +from underworld3.function import expression + + +V0 = 0.5 +OMEGA = np.pi / 2.0 +DT = 0.05 +H = 1.0; W = 1.0 +FAULT_LENGTH = 0.6 +FAULT_WIDTH = 0.06 +ETA = 1.0; MU = 1.0 +# Spatially-varying σ_y(x): small in the fault influence zone, large in +# the bulk. Isotropic von Mises elsewhere — no director, no rank-4 +# projector, just the same fault influence-function used to localise +# yielding. Constant σ_y everywhere would fail everywhere (the correct +# solution); the localised weak zone gives partial yielding. +TAU_Y_FAULT = 0.05 # yield stress in the weak zone +TAU_Y_BULK = 200.0 # effectively no yield outside +THETA_DEG = 15.0 # weak-zone tilt (no director use; just the geometry) +RES = 32 + +OUT_DIR = "output" + + +def _build_isotropic_stokes(label, integrator, order, yield_stress_value): + """Common Stokes + isotropic VEP setup with spatial yield-stress + field via the fault influence function (isotropic von Mises; + director NOT used). + + yield_stress_value: pass the spatial sympy expression to apply the + localised yield zone, or sympy.oo to disable yielding entirely + (predictor-corrector path applies J2 return mapping externally). + """ + mesh = uw.meshing.StructuredQuadBox( + elementRes=(RES, RES), + minCoords=(0.0, 0.0), maxCoords=(W, H), + qdegree=3, + ) + # Build the localised weak zone using the fault influence-function + # (same geometry as the killer test). σ_y(x) small near the layer + # axis, large in the bulk. We're NOT using the director — this is + # just a spatial yield-stress field that happens to be drawn from + # the fault helper. + cx, cy = 0.5 * W, 0.5 * H + theta = np.radians(THETA_DEG) + dx_layer = 0.5 * FAULT_LENGTH * np.cos(theta) + dy_layer = 0.5 * FAULT_LENGTH * np.sin(theta) + fault = uw.meshing.Surface( + f"layer_{label}", mesh, + np.array([[cx - dx_layer, cy - dy_layer], + [cx + dx_layer, cy + dy_layer]]), + symbol=f"L{label}", + ) + fault.discretize() + weakness = fault.influence_function( + width=FAULT_WIDTH, + value_near=1.0 / TAU_Y_FAULT, + value_far=1.0 / TAU_Y_BULK, + profile="gaussian", + ) + tau_y_field = 1.0 / weakness + + u = uw.discretisation.MeshVariable(f"U_{label}", mesh, 2, degree=2, + vtype=VarType.VECTOR) + p_sol = uw.discretisation.MeshVariable(f"P_{label}", mesh, 1, degree=1, + continuous=True, vtype=VarType.SCALAR) + + stokes = uw.systems.Stokes(mesh, velocityField=u, pressureField=p_sol) + stokes.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + stokes.Unknowns, integrator=integrator, order=order, + ) + cm = stokes.constitutive_model + cm.Parameters.shear_viscosity_0 = ETA + cm.Parameters.shear_modulus = MU + # Use the spatial yield-stress field by default; sympy.oo overrides + # for the no-yield-in-residual predictor-corrector path. + cm.Parameters.yield_stress = ( + yield_stress_value if yield_stress_value is not None else tau_y_field + ) + cm.Parameters.shear_viscosity_min = ETA * 1.0e-3 + cm.Parameters.strainrate_inv_II_min = 1.0e-6 + cm.yield_mode = "softmin" + + stokes.saddle_preconditioner = 1.0 / cm.K + stokes.tolerance = 1.0e-4 + stokes.petsc_options["ksp_type"] = "fgmres" + stokes.petsc_options["snes_force_iteration"] = True + + V_top = expression(rf"V_{{top,{label}}}", sympy.Float(0.0), "Top BC") + stokes.add_essential_bc(sympy.Matrix([V_top, 0.0]), "Top") + stokes.add_essential_bc(sympy.Matrix([0.0, 0.0]), "Bottom") + stokes.add_essential_bc((sympy.oo, 0.0), "Left") + stokes.add_essential_bc((sympy.oo, 0.0), "Right") + stokes.bodyforce = sympy.Matrix([0.0, 0.0]) + + return dict(mesh=mesh, stokes=stokes, u=u, p=p_sol, V_top=V_top, cm=cm) + + +def _j2_radial_return(sigma_arr, sigma_y): + """Apply J2 radial return at each quadrature node. + + sigma_arr: shape (n_nodes, dim, dim) symmetric tensor field. + Returns the corrected array (in-place not allowed — return new copy + so the caller can decide whether to overwrite). + """ + # Equivalent stress: σ_eq = sqrt(3/2 · σ:σ) for deviatoric σ. + sig_dot_sig = (sigma_arr * sigma_arr).sum(axis=(1, 2)) # σ_ij σ_ij + sigma_eq = np.sqrt(1.5 * sig_dot_sig) + # Avoid divide-by-zero + safe_eq = np.where(sigma_eq > 1.0e-12, sigma_eq, 1.0) + scale = np.where(sigma_eq > sigma_y, sigma_y / safe_eq, 1.0) + return sigma_arr * scale[:, None, None] + + +def run_case(label, integrator, order, n_periods=1.5, + apply_radial_return=False, in_residual_yield=True, + max_picard_iters=1, picard_tol=1.0e-3, omega_tau=0.5): + """Generic runner. + + integrator/order: passed to constitutive model. + apply_radial_return: if True, apply J2 radial return post-solve. + in_residual_yield: if False, set yield_stress=∞ in the model so it + doesn't clip in-residual; only post-solve return + maps. If True, use the spatial yield_stress + field (small near layer, large in bulk). + max_picard_iters: outer Picard within a timestep. With value 1 the + Stokes solve runs once (no equilibration after + correction). With value > 1 we save σ_n at step + start, run Stokes, apply correction, damp σ, and + re-solve with the corrected psi_star until σ + converges. + omega_tau: stress damping coefficient inside the Picard loop + (advice §10; ω_τ ~ 0.5 is the standard value). + """ + # None → use the spatial weak-zone field built inside _build_..; oo + # → disable in-residual yielding (predictor-corrector path). + yield_stress_value = None if in_residual_yield else sympy.oo + obj = _build_isotropic_stokes(label, integrator, order, yield_stress_value) + mesh = obj["mesh"]; stokes = obj["stokes"] + u = obj["u"]; V_top = obj["V_top"]; cm = obj["cm"] + DFDt = stokes.Unknowns.DFDt + + # Per-step trace file — updated each step (flush every line). Lets a + # killed run still leave usable data, and lets the plot script parse + # it without rerunning. Pattern from feedback_per_step_logging.md. + trace_path = os.path.join( + os.path.dirname(__file__), f"_phase_f_{label}.trace.txt" + ) + trace_fh = open(trace_path, "w") + trace_fh.write( + f"# Phase F predictor-corrector trace: {label}\n" + f"# integrator={integrator!r} order={order} " + f"apply_radial_return={apply_radial_return} " + f"in_residual_yield={in_residual_yield}\n" + f"# columns: step, t, V_top, snes_iters_total, picard_iters, " + f"sigma_eq_max, sigma_eq_max_after_correction, u_y_max, yielded_fraction\n" + ) + trace_fh.flush() + + # Evaluate spatial σ_y(x) at psi_star coords ONCE — the yield stress + # field doesn't change in time (just space). Used by the radial + # return corrector AND by the yielded-fraction diagnostic. + sigma_coords = DFDt.psi_star[0].coords + cx, cy = 0.5 * W, 0.5 * H + theta = np.radians(THETA_DEG) + n_x_l = -np.sin(theta); n_y_l = np.cos(theta) + # Re-derive the same Gaussian as in _build (signed-distance to the + # layer axis). Avoids needing to evaluate the cm's yield_stress + # symbolically for every node — which can be expensive. + sd = np.abs((sigma_coords[:, 0] - cx) * n_x_l + + (sigma_coords[:, 1] - cy) * n_y_l) + half_length = 0.5 * FAULT_LENGTH + along = (sigma_coords[:, 0] - cx) * n_y_l - (sigma_coords[:, 1] - cy) * n_x_l + in_extent = np.abs(along) <= half_length + # The fault influence_function uses a Gaussian normal to the layer + # axis, restricted to the layer extent. value_near at sd=0 (and + # within extent), value_far at large sd (or outside extent). + weakness_arr = np.where( + in_extent, + (1.0 / TAU_Y_FAULT) * np.exp(-(sd / FAULT_WIDTH) ** 2) + + (1.0 / TAU_Y_BULK) + * (1.0 - np.exp(-(sd / FAULT_WIDTH) ** 2)), + 1.0 / TAU_Y_BULK * np.ones_like(sd), + ) + sigma_y_at_nodes = 1.0 / weakness_arr + + T_END = n_periods * 2.0 * np.pi / OMEGA + iters = []; reasons = [] + sigma_eq_max_per_step = [] + sigma_eq_centre_per_step = [] + u_y_max_per_step = [] + yielded_fraction_per_step = [] + centre = np.array([[0.5 * W, 0.5 * H]]) + + t_cur = 0.0 + t0 = time.time() + picard_iters_per_step = [] # diagnostic: how many Picard iters used + while t_cur < T_END - 1e-9: + dt = min(DT, T_END - t_cur) + t_end_step = t_cur + dt + v_now = V0 * float(np.cos(OMEGA * t_end_step)) + V_top.sym = sympy.Float(v_now) + cm.Parameters.dt_elastic = dt + + # Save σ_n at start of step — needed if we Picard-iterate within + # the step (psi_star has to read σ_n for E_eff history each + # solve, but the post-solve correction overwrites it). + sigma_n = np.asarray(DFDt.psi_star[0].array).copy() + sigma_iter = sigma_n.copy() # current best estimate of σ_{n+1} + + snes_iters_total = 0 + snes_reason_last = 0 + picard_k_used = max_picard_iters + for picard_k in range(max_picard_iters): + # Restore start-of-step state so model sees σ_n as history + DFDt.psi_star[0].array[...] = sigma_n + try: + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=2) + except Exception as exc: + print(f" step at t={t_end_step:.3f} picard={picard_k}: " + f"solve raised — {exc}", flush=True) + snes_iters_total = -1 + snes_reason_last = -99 + picard_k_used = picard_k + break + snes_iters_total += int(stokes.snes.getIterationNumber()) + snes_reason_last = int(stokes.snes.getConvergedReason()) + + sigma_trial = np.asarray(DFDt.psi_star[0].array).copy() + if apply_radial_return: + sigma_corrected = _j2_radial_return(sigma_trial, sigma_y_at_nodes) + else: + sigma_corrected = sigma_trial + + if max_picard_iters == 1: + # Single-shot mode: just accept the correction + sigma_iter = sigma_corrected + picard_k_used = 1 + break + + # Damp the σ update for outer-Picard convergence (advice §10) + sigma_new = (1.0 - omega_tau) * sigma_iter + omega_tau * sigma_corrected + # Convergence check + denom = max(np.linalg.norm(sigma_new), 1e-12) + diff = np.linalg.norm(sigma_new - sigma_iter) / denom + sigma_iter = sigma_new + if diff < picard_tol: + picard_k_used = picard_k + 1 + break + + # Final accepted state + DFDt.psi_star[0].array[...] = sigma_iter + picard_iters_per_step.append(picard_k_used) + + if snes_iters_total < 0: + iters.append(-1); reasons.append(snes_reason_last) + break + iters.append(snes_iters_total) + reasons.append(snes_reason_last) + + # Diagnostics on the FINAL accepted σ + sig_dot_sig = (sigma_iter * sigma_iter).sum(axis=(1, 2)) + sigma_eq = np.sqrt(1.5 * sig_dot_sig) + sigma_eq_max_per_step.append(float(sigma_eq.max())) + n_yielded = int((sigma_eq > sigma_y_at_nodes * 0.99).sum()) + yielded_fraction_per_step.append(n_yielded / sigma_eq.size) + # σ_eq AFTER correction (same as final state since sigma_iter is corrected) + sigma_eq_centre_per_step.append(float(sigma_eq.max())) + + u_arr = np.asarray(u.array).reshape(-1, 2) + u_y_max_per_step.append(float(np.abs(u_arr[:, 1]).max())) + + step_idx = len(iters) + # Persistent per-step trace — written EVERY step, flushed + trace_fh.write( + f"{step_idx:4d} {t_end_step:7.4f} {v_now:+.4f} " + f"{iters[-1]:3d} {picard_k_used:2d} " + f"{sigma_eq_max_per_step[-1]:.6e} " + f"{sigma_eq_centre_per_step[-1]:.6e} " + f"{u_y_max_per_step[-1]:.6e} " + f"{yielded_fraction_per_step[-1]:.6f}\n" + ) + trace_fh.flush() + if step_idx <= 5 or step_idx % 5 == 0: + picard_str = f" pic={picard_k_used:d}" if max_picard_iters > 1 else "" + print( + f" step {step_idx:3d}/120 t={t_end_step:5.3f} " + f"V={v_now:+.3f} iters={iters[-1]:2d}{picard_str} " + f"|σ|_eq_max={sigma_eq_max_per_step[-1]:.3e} " + f"|u_y|={u_y_max_per_step[-1]:.3e} " + f"yielded={yielded_fraction_per_step[-1]:.2%}", + flush=True, + ) + + if sigma_eq_max_per_step[-1] > 100.0 or u_y_max_per_step[-1] > 10.0: + print(f" *** runaway at step {step_idx} — breaking ***", flush=True) + break + + t_cur = t_end_step + + iters_arr = np.array(iters) + reasons_arr = np.array(reasons) + print( + f" ran {len(iters)} steps in {time.time()-t0:.1f}s; " + f"{label} (integrator={integrator}, order={order}, " + f"radial_return={apply_radial_return}, in_residual_yield={in_residual_yield})", + flush=True, + ) + if iters_arr.size > 0 and (iters_arr >= 0).any(): + print( + f" SNES iters mean={iters_arr[iters_arr>=0].mean():.1f} " + f"max={iters_arr[iters_arr>=0].max()} " + f"diverged={int((reasons_arr<0).sum())}/{len(reasons_arr)}", + flush=True, + ) + if sigma_eq_max_per_step: + print( + f" σ_eq_max: end={sigma_eq_max_per_step[-1]:.4f} " + f"global max={max(sigma_eq_max_per_step):.4f} " + f"({max(sigma_eq_max_per_step)/TAU_Y_FAULT:.2f}·τ_y_fault)", + flush=True, + ) + print( + f" |u_y|_max: end={u_y_max_per_step[-1]:.4f} " + f"global max={max(u_y_max_per_step):.4f}", + flush=True, + ) + print( + f" yielded fraction: end={yielded_fraction_per_step[-1]:.2%} " + f"max={max(yielded_fraction_per_step):.2%}", + flush=True, + ) + + out_npz = os.path.join(OUT_DIR, f"phase_f_{label}.npz") + np.savez( + out_npz, + iters=iters_arr, reasons=reasons_arr, + sigma_eq_max_per_step=np.asarray(sigma_eq_max_per_step), + sigma_eq_centre_per_step=np.asarray(sigma_eq_centre_per_step), + u_y_max_per_step=np.asarray(u_y_max_per_step), + yielded_fraction_per_step=np.asarray(yielded_fraction_per_step), + T_END=np.array(T_END), + n_steps=np.array(len(iters)), + wall_seconds=np.array(time.time() - t0), + ) + print(f" saved → {out_npz}", flush=True) + + +def main(): + os.makedirs(OUT_DIR, exist_ok=True) + + # Each tuple: (label, integrator, order, apply_pc, max_picard_iters) + # apply_pc=True → yield_stress=∞ in model, radial-return correction + # max_picard_iters=1 → single shot; >1 → outer Picard equilibrate + cases = [ + # Baseline: BDF-1 yield-in-residual via softmin (works) + ("bdf1_iso", "bdf", 1, False, 1), + # NOTE: ETD-1 / ETD-2 with yield_stress=spatial-field + + # yield_mode=softmin in the parent ViscoElasticPlasticFlowModel + # currently produces SNES line-search divergence (separate bug, + # filed for follow-up). Skipping those baselines here — the + # predictor-corrector path below sets yield_stress=∞ in the + # model so it never enters that broken in-residual code path. + # Predictor-corrector: single shot + ("etd2_pc1", "etd", 2, True, 1), + ("etd1_pc1", "etd", 1, True, 1), + # Predictor-corrector with outer Picard equilibration (ω_τ=0.5) + ("etd2_pc_picard", "etd", 2, True, 6), + ("etd1_pc_picard", "etd", 1, True, 6), + ] + for label, integrator, order, apply_pc, max_picard in cases: + cache = os.path.join(OUT_DIR, f"phase_f_{label}.npz") + if os.path.exists(cache): + print(f"\n=== {label}: cache hit, skipping ===", flush=True) + continue + if apply_pc: + mode = ("predictor-corrector single-shot" + if max_picard == 1 + else f"predictor-corrector + outer Picard ({max_picard} iters)") + print( + f"\n=== {label}: integrator={integrator!r}, order={order}, " + f"{mode} ===", + flush=True, + ) + run_case(label, integrator, order, apply_radial_return=True, + in_residual_yield=False, max_picard_iters=max_picard) + else: + print( + f"\n=== {label}: integrator={integrator!r}, order={order}, " + f"yield-in-residual (softmin in model) ===", + flush=True, + ) + run_case(label, integrator, order, apply_radial_return=False, + in_residual_yield=True, max_picard_iters=1) + + +if __name__ == "__main__": + main() diff --git a/docs/developer/design/_plot_phase_b_bdf_vs_etd.py b/docs/developer/design/_plot_phase_b_bdf_vs_etd.py new file mode 100644 index 000000000..e74ce3a95 --- /dev/null +++ b/docs/developer/design/_plot_phase_b_bdf_vs_etd.py @@ -0,0 +1,238 @@ +"""Plot BDF-1 vs ETD-2 (lumped) vs split-ETD-2 trajectories at +τ_y=0.05, θ=+15°. + +Loads time series saved by ``_phase_b_bdf_vs_etd_at_tight_yield.py`` +(BDF-1, lumped ETD-2) and ``_phase_d_killer_split.py`` (split ETD-2); +produces a 3-panel figure on shared time axes: + + 1. centre σ_xy(t) + 2. global max |σ|_II(t) + 3. global max |u_y|(t) + +τ_y=0.05 reference lines are drawn on panels 1 and 2. The split +trace is expected to sit inside the lumped runaway and ideally close +to the BDF-1 baseline. + +Run:: + + pixi run -e amr-dev python -u docs/developer/design/_plot_phase_b_bdf_vs_etd.py +""" + +import os + +import numpy as np +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt + + +OMEGA = np.pi / 2.0 +DT = 0.05 +THETA = 15.0 +TAU_Y = 0.05 +OUT_DIR = "output" + + +def _path(integrator): + return os.path.join( + OUT_DIR, + f"phase_b_{integrator}_th{THETA:+.0f}_ty{TAU_Y:.2f}".replace(".", "p") + ".npz", + ) + + +def _load(integrator): + path = _path(integrator) + if not os.path.exists(path): + raise FileNotFoundError( + f"missing trajectory cache {path} — run " + f"_phase_b_bdf_vs_etd_at_tight_yield.py first" + ) + return np.load(path) + + +def _load_bdf2_from_log(log_path=None): + """Parse the per-step BDF-2 trace from the runner's stdout log. + Returns ``(t, sigma_II, u_y, sigma_par)`` numpy arrays of the + sparse sample points (every 5 steps after the first 5) up to the + runaway. None if the log doesn't exist. + """ + if log_path is None: + # Tracked trace lives next to this script (git won't track *.log). + candidates = [ + os.path.join( + os.path.dirname(__file__), + "_phase_b_bdf2_th+15_ty0p05.trace.txt", + ), + os.path.join(OUT_DIR, "phase_b_bdf2_th+15_ty0p05.log"), + ] + log_path = next((p for p in candidates if os.path.exists(p)), None) + if log_path is None: + return None + elif not os.path.exists(log_path): + return None + import re + pat = re.compile( + r"step\s+(\d+)/\d+\s+t=([\d.+\-eE]+)\s+V=[\d.+\-eE]+\s+iters=\s*\d+\s+" + r"\|σ\|_II=([\d.+\-eE]+)\s+\|u_y\|=([\d.+\-eE]+)\s+\|σ_∥\|=([\d.+\-eE]+)" + ) + rows = [] + with open(log_path) as f: + for line in f: + m = pat.search(line) + if m: + rows.append((int(m.group(1)), float(m.group(2)), + float(m.group(3)), float(m.group(4)), + float(m.group(5)))) + if not rows: + return None + rows.sort(key=lambda r: r[0]) + return ( + np.array([r[1] for r in rows]), + np.array([r[2] for r in rows]), + np.array([r[3] for r in rows]), + np.array([r[4] for r in rows]), + ) + + +def main(): + bdf = _load("bdf") + etd = _load("etd") + try: + split = _load("etd-split") + except FileNotFoundError: + split = None + print(" (no split-ETD trajectory cached — skipping)", flush=True) + try: + hybrid = _load("hybrid") + except FileNotFoundError: + hybrid = None + print(" (no hybrid trajectory cached — skipping)", flush=True) + + bdf2 = _load_bdf2_from_log() + if bdf2 is None: + print(" (no BDF-2 log found — skipping)", flush=True) + + try: + etd1 = _load("etd1") + except FileNotFoundError: + etd1 = None + print(" (no ETD-1 trajectory cached — skipping)", flush=True) + + n_bdf = int(bdf["n_steps"]) + n_etd = int(etd["n_steps"]) + t_bdf = (np.arange(n_bdf) + 1) * DT + t_etd = (np.arange(n_etd) + 1) * DT + period = 2.0 * np.pi / OMEGA + if split is not None: + n_split = int(split["n_steps"]) + t_split = (np.arange(n_split) + 1) * DT + if hybrid is not None: + n_hybrid = int(hybrid["n_steps"]) + t_hybrid = (np.arange(n_hybrid) + 1) * DT + if etd1 is not None: + n_etd1 = int(etd1["n_steps"]) + t_etd1 = (np.arange(n_etd1) + 1) * DT + + fig, axes = plt.subplots(3, 1, figsize=(8.5, 9.5), sharex=True) + + # Panel 1 — fault-resolved |σ_∥|(t) at fault centre. + ax = axes[0] + if "sigma_par_centre" in bdf.files: + ax.plot(t_bdf / period, bdf["sigma_par_centre"], "-", color="#1f77b4", + label=f"BDF-1 (peak |σ_∥|={bdf['sigma_par_centre'].max():.3f})") + if "sigma_par_centre" in etd.files: + ax.plot(t_etd / period, etd["sigma_par_centre"], "-", color="#d62728", + label=f"ETD-2 lumped (peak |σ_∥|={etd['sigma_par_centre'].max():.3f})") + if split is not None and "sigma_par_centre" in split.files: + ax.plot(t_split / period, split["sigma_par_centre"], "-", color="#2ca02c", + label=f"ETD-2 split (peak |σ_∥|={split['sigma_par_centre'].max():.3f})") + if hybrid is not None and "sigma_par_centre" in hybrid.files: + ax.plot(t_hybrid / period, hybrid["sigma_par_centre"], "-", color="#9467bd", + label=f"hybrid (peak |σ_∥|={hybrid['sigma_par_centre'].max():.3f})") + if etd1 is not None and "sigma_par_centre" in etd1.files: + ax.plot(t_etd1 / period, etd1["sigma_par_centre"], "-", color="#17becf", lw=2.0, + label=f"ETD-1 (peak |σ_∥|={etd1['sigma_par_centre'].max():.3f}) ★") + if bdf2 is not None: + t_b2, sII_b2, uy_b2, spar_b2 = bdf2 + ax.plot(t_b2 / period, spar_b2, "x-", color="#ff7f0e", + label=f"BDF-2 → blow-up (peak |σ_∥|={spar_b2.max():.3f})") + ax.axhline(TAU_Y, color="#888888", lw=0.8, linestyle="--", + label=rf"$\tau_y={TAU_Y}$") + ax.set_ylabel(r"centre $|\sigma_\parallel|$ (resolved fault shear)") + ax.legend(loc="upper right", fontsize=9) + ax.grid(alpha=0.3) + + # Panel 2 — global max |σ|_II(t) + ax = axes[1] + ax.semilogy(t_bdf / period, np.abs(bdf["sigma_II_max_per_step"]), + "-", color="#1f77b4", + label=f"BDF-1 (peak={bdf['sigma_II_max_per_step'].max():.3f})") + ax.semilogy(t_etd / period, np.abs(etd["sigma_II_max_per_step"]), + "-", color="#d62728", + label=f"ETD-2 lumped (peak={etd['sigma_II_max_per_step'].max():.3f})") + if split is not None: + ax.semilogy(t_split / period, np.abs(split["sigma_II_max_per_step"]), + "-", color="#2ca02c", + label=f"ETD-2 split (peak={split['sigma_II_max_per_step'].max():.3f})") + if hybrid is not None: + ax.semilogy(t_hybrid / period, np.abs(hybrid["sigma_II_max_per_step"]), + "-", color="#9467bd", + label=f"hybrid (peak={hybrid['sigma_II_max_per_step'].max():.3f})") + if etd1 is not None: + ax.semilogy(t_etd1 / period, np.abs(etd1["sigma_II_max_per_step"]), + "-", color="#17becf", lw=2.0, + label=f"ETD-1 (peak={etd1['sigma_II_max_per_step'].max():.3f}) ★") + if bdf2 is not None: + t_b2, sII_b2, uy_b2, spar_b2 = bdf2 + ax.semilogy(t_b2 / period, sII_b2, "x-", color="#ff7f0e", + label=f"BDF-2 → blow-up (last sample={sII_b2[-1]:.2e})") + ax.axhline(TAU_Y, color="#888888", lw=0.8, linestyle="--", + label=rf"$\tau_y={TAU_Y}$") + ax.set_ylabel(r"max $|\sigma|_{II}$ (log)") + ax.legend(loc="upper left", fontsize=9) + ax.grid(alpha=0.3, which="both") + + # Panel 3 — global max |u_y|(t) + ax = axes[2] + ax.semilogy(t_bdf / period, bdf["u_y_max_per_step"], + "-", color="#1f77b4", + label=f"BDF-1 (peak={bdf['u_y_max_per_step'].max():.3f})") + ax.semilogy(t_etd / period, etd["u_y_max_per_step"], + "-", color="#d62728", + label=f"ETD-2 lumped (peak={etd['u_y_max_per_step'].max():.3f})") + if split is not None: + ax.semilogy(t_split / period, split["u_y_max_per_step"], + "-", color="#2ca02c", + label=f"ETD-2 split (peak={split['u_y_max_per_step'].max():.3f})") + if hybrid is not None: + ax.semilogy(t_hybrid / period, hybrid["u_y_max_per_step"], + "-", color="#9467bd", + label=f"hybrid (peak={hybrid['u_y_max_per_step'].max():.3f})") + if etd1 is not None: + ax.semilogy(t_etd1 / period, etd1["u_y_max_per_step"], + "-", color="#17becf", lw=2.0, + label=f"ETD-1 (peak={etd1['u_y_max_per_step'].max():.3f}) ★") + if bdf2 is not None: + t_b2, sII_b2, uy_b2, spar_b2 = bdf2 + ax.semilogy(t_b2 / period, uy_b2, "x-", color="#ff7f0e", + label=f"BDF-2 → blow-up (last sample={uy_b2[-1]:.2e})") + ax.set_ylabel(r"max $|u_y|$ (log)") + ax.set_xlabel(r"time $t / T$ (periods)") + ax.legend(loc="upper left", fontsize=9) + ax.grid(alpha=0.3, which="both") + + fig.suptitle( + rf"BDF-1 vs ETD-2 lumped/split/hybrid at $\theta=+15^\circ$, $\tau_y={TAU_Y}$, RES=32", + y=0.995, + ) + fig.tight_layout() + + out_png = os.path.join(OUT_DIR, "exp_integrator_phase_b_bdf_vs_etd.png") + fig.savefig(out_png, dpi=140) + plt.close(fig) + print(f" wrote {out_png}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/docs/developer/design/_plot_phase_b_fields.py b/docs/developer/design/_plot_phase_b_fields.py new file mode 100644 index 000000000..0f7d7a95a --- /dev/null +++ b/docs/developer/design/_plot_phase_b_fields.py @@ -0,0 +1,248 @@ +"""Phase B field plots — velocity / strain-rate / stress for yield-active cases. + +Runs the bench_ti_vep_harmonic geometry with ETD-2 for one period at one +or more yield-active (θ, τ_y) combinations, captures the full mesh-variable +fields at peak forcing, and plots velocity vectors + strain-rate magnitude ++ stress magnitude with the spatial τ_y(x) yield zone overlaid. + +Run:: + + pixi run -e amr-dev python -u docs/developer/design/_plot_phase_b_fields.py +""" + +import os +import time +import sys + +import numpy as np +import sympy +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +from matplotlib.tri import Triangulation, LinearTriInterpolator + +import underworld3 as uw +from underworld3.function import expression + + +# Add the design dir to path so we can reuse the killer-test build helpers +_DESIGN_DIR = os.path.dirname(os.path.abspath(__file__)) +if _DESIGN_DIR not in sys.path: + sys.path.insert(0, _DESIGN_DIR) +from _exp_integrator_phase_b_killer import build_ti_exp_stokes # noqa: E402 + + +V0 = 0.5 +OMEGA = np.pi / 2.0 +DT = 0.05 +H = 1.0 +W = 1.0 +FAULT_WIDTH = 0.06 + + +def run_capture_at_yield_peak(theta_deg, tau_y_at_fault, n_periods=2): + """Run ``n_periods`` of the harmonic forcing and capture fields at + the step where the yield-zone σ_II reaches its peak — i.e. when yield + is most active in the fault zone. + + Strategy: run forward, after each step record σ_II_max in the + fault-zone mask AND a snapshot of the full state; at the end pick + the step with the largest in-fault σ_II to plot. + """ + label = f"fields_th{theta_deg:+.0f}_ty{tau_y_at_fault:.2f}".replace(".", "p") + mesh, stokes, V_top, n_vec = build_ti_exp_stokes(label, theta_deg, tau_y_at_fault) + cm = stokes.constitutive_model + DFDt = stokes.Unknowns.DFDt + + E_sym = stokes.Unknowns.E + n_x, n_y = n_vec + cx, cy = 0.5 * W, 0.5 * H + sigma_coords = DFDt.psi_star[0].coords + # fault-zone mask: signed distance to fault line ≤ 1.5·FAULT_WIDTH + sd = np.abs((sigma_coords[:, 0] - cx) * n_x + (sigma_coords[:, 1] - cy) * n_y) + fault_mask = sd < 1.5 * FAULT_WIDTH + + # τ_y(x) field — constant in time, evaluate once + ty_field = np.asarray( + uw.function.evaluate(cm.Parameters.yield_stress.sym, sigma_coords) + ).flatten() + + T_END = n_periods * 2.0 * np.pi / OMEGA + snapshots = [] # list of dicts captured each step + snapshot_metrics = [] # [(t, sigma_II_in_fault_max), ...] + t_cur = 0.0 + while t_cur < T_END - 1e-9: + dt = min(DT, T_END - t_cur) + t_end_step = t_cur + dt + v_now = V0 * float(np.cos(OMEGA * t_end_step)) + V_top.sym = sympy.Float(v_now) + cm.Parameters.dt_elastic = dt + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=2) + + sigma_arr = np.asarray(DFDt.psi_star[0].array) + sigma_II = np.sqrt(0.5 * (sigma_arr ** 2).sum(axis=(1, 2))) + # Track σ_II in fault zone, and discount the initial transient + # (first half-period where σ is still ramping from zero) by + # only considering steps after t > T_period. + t_recordable = t_end_step > 2.0 * np.pi / OMEGA + in_fault_max = float(sigma_II[fault_mask].max()) if fault_mask.any() else 0.0 + snapshot_metrics.append((t_end_step, in_fault_max, t_recordable)) + + # Capture every step (cheap) — snapshot for the chosen step at end + # Strain rate (eval is the expensive bit, do it inline) + edot_xx = np.asarray(uw.function.evaluate(E_sym[0, 0], sigma_coords)).flatten() + edot_xy = np.asarray(uw.function.evaluate(E_sym[0, 1], sigma_coords)).flatten() + edot_yy = np.asarray(uw.function.evaluate(E_sym[1, 1], sigma_coords)).flatten() + edot_II = np.sqrt(0.5 * (edot_xx ** 2 + edot_yy ** 2 + 2 * edot_xy ** 2)) + u_arr = np.asarray(stokes.u.array) + snapshots.append(dict( + t=t_end_step, + v_top=v_now, + u_coords=stokes.u.coords.copy(), + u=u_arr.reshape(-1, 2).copy(), + sigma_arr=sigma_arr.copy(), + sigma_II=sigma_II.copy(), + edot_II=edot_II, + edot_xy=edot_xy, + )) + t_cur = t_end_step + + # Pick the step (after the first period) with the biggest in-fault σ_II. + candidates = [(t, m) for (t, m, ok) in snapshot_metrics if ok] + if not candidates: + # Fallback: take the last step + chosen = snapshots[-1] + else: + idx = int(np.argmax([m for (_, m) in candidates])) + # candidates index → snapshot index (count of recordable + initial transient) + first_recordable = next( + i for i, sm in enumerate(snapshot_metrics) if sm[2] + ) + chosen = snapshots[first_recordable + idx] + chosen.update( + theta_deg=theta_deg, + tau_y_at_fault=tau_y_at_fault, + n_vec=n_vec, + sigma_coords=sigma_coords, + ty_field=ty_field, + fault_mask=fault_mask, + T_END=T_END, + ) + chosen["yield_ratio"] = chosen["sigma_II"] / np.maximum(ty_field, 1e-30) + chosen["sigma_xy"] = chosen["sigma_arr"][:, 0, 1] + chosen["sigma_xx"] = chosen["sigma_arr"][:, 0, 0] + chosen["sigma_yy"] = chosen["sigma_arr"][:, 1, 1] + + print( + f" picked step at t={chosen['t']:.3f} (V_top={chosen['v_top']:+.4f}); " + f"max in-fault σ_II = {float(chosen['sigma_II'][fault_mask].max()):.4f} " + f"(τ_y_centre={tau_y_at_fault}, ratio " + f"{float(chosen['sigma_II'][fault_mask].max())/tau_y_at_fault:.3f}·τ_y)", + flush=True, + ) + return chosen + + +def plot_one(snapshot, out_path): + th = snapshot["theta_deg"] + ty_fault = snapshot["tau_y_at_fault"] + n_x, n_y = snapshot["n_vec"] + cx, cy = 0.5 * W, 0.5 * H + + sx, sy = snapshot["sigma_coords"][:, 0], snapshot["sigma_coords"][:, 1] + tri = Triangulation(sx, sy) + + ux, uy = snapshot["u_coords"][:, 0], snapshot["u_coords"][:, 1] + u_x, u_y = snapshot["u"][:, 0], snapshot["u"][:, 1] + + fig, axes = plt.subplots(2, 2, figsize=(13, 11), sharex=True, sharey=True) + + # ---- Top-left: velocity field with fault overlay ------------------ + ax = axes[0, 0] + speed = np.sqrt(u_x ** 2 + u_y ** 2) + ax.tricontourf( + Triangulation(ux, uy), speed, levels=24, cmap="Blues", alpha=0.7, + ) + # Subsample for arrows (~every 2nd node) + sub = slice(None, None, 4) + ax.quiver( + ux[sub], uy[sub], u_x[sub], u_y[sub], + scale=8.0, width=0.0035, color="0.2", alpha=0.85, + ) + _overlay_fault(ax, snapshot) + ax.set_title("velocity field (arrows + |u| heatmap)") + ax.set_aspect("equal") + + # ---- Top-right: |ε̇|_II --------------------------------------------- + ax = axes[0, 1] + cax = ax.tricontourf(tri, snapshot["edot_II"], levels=20, cmap="viridis") + fig.colorbar(cax, ax=ax, fraction=0.040, pad=0.02) + _overlay_fault(ax, snapshot) + ax.set_title(r"$|\dot\varepsilon|_{II}$ (strain-rate 2nd invariant)") + ax.set_aspect("equal") + + # ---- Bottom-left: |σ|_II with τ_y(x) contour ------------------------ + ax = axes[1, 0] + cax = ax.tricontourf(tri, snapshot["sigma_II"], levels=20, cmap="magma") + fig.colorbar(cax, ax=ax, fraction=0.040, pad=0.02) + # Contour the τ_y(x) field at a few values to show the fault + tri_full = Triangulation(sx, sy) + ax.tricontour( + tri_full, snapshot["ty_field"], + levels=[0.5, 1.0, 5.0, 50.0], colors="cyan", linewidths=0.7, alpha=0.7, + ) + _overlay_fault(ax, snapshot, color="white") + ax.set_title(r"$|\sigma|_{II}$ — cyan: $\tau_y(x)$ contours (0.5, 1, 5, 50)") + ax.set_aspect("equal") + + # ---- Bottom-right: yield ratio σ_II / τ_y(x) ------------------------ + ax = axes[1, 1] + ratio = snapshot["yield_ratio"] + levels = np.linspace(0, 1.2, 25) + cax = ax.tricontourf(tri, np.clip(ratio, 0, 1.2), levels=levels, cmap="RdYlGn_r") + fig.colorbar(cax, ax=ax, fraction=0.040, pad=0.02, label=r"$|\sigma|_{II}/\tau_y(x)$") + ax.tricontour(tri, ratio, levels=[1.0], colors="black", linewidths=1.2) + _overlay_fault(ax, snapshot) + ax.set_title(r"yield activation: $|\sigma|_{II}/\tau_y(x)$ (black contour at 1)") + ax.set_aspect("equal") + + fig.suptitle( + f"ETD-2 fields at yield-active step " + f"(t={snapshot['t']:.2f}, V_top={snapshot['v_top']:+.3f}) — " + f"θ={th:+.0f}°, fault τ_y={ty_fault}, " + f"max |σ_II/τ_y| = {ratio.max():.3f}", + fontsize=12, y=0.995, + ) + fig.tight_layout() + fig.savefig(out_path, dpi=130, bbox_inches="tight") + plt.close(fig) + print(f"wrote {out_path}", flush=True) + + +def _overlay_fault(ax, snap, color="red"): + """Draw the fault line and the 1·FAULT_WIDTH band.""" + n_x, n_y = snap["n_vec"] + cx, cy = 0.5 * W, 0.5 * H + # Fault segment endpoints (length 0.6 like FAULT_LENGTH in the bench) + L = 0.6 + t_x, t_y = n_y, -n_x # tangent + p1 = (cx - 0.5 * L * t_x, cy - 0.5 * L * t_y) + p2 = (cx + 0.5 * L * t_x, cy + 0.5 * L * t_y) + ax.plot([p1[0], p2[0]], [p1[1], p2[1]], color=color, lw=1.5, alpha=0.85) + + +def main(): + os.makedirs("output", exist_ok=True) + cases = [(0.0, 0.15), (15.0, 0.15)] + for theta, ty in cases: + print(f"\n=== θ={theta:+.0f}°, τ_y={ty:.2f} ===", flush=True) + t0 = time.time() + snap = run_capture_at_yield_peak(theta, ty, n_periods=2) + print(f" ran in {time.time()-t0:.1f}s, max per-node yield ratio = {snap['yield_ratio'].max():.3f}") + out = f"output/exp_integrator_phase_b_fields_th{theta:+.0f}_ty{ty:.2f}".replace(".", "p") + ".png" + plot_one(snap, out) + + +if __name__ == "__main__": + main() diff --git a/docs/developer/design/_plot_phase_b_pyvista.py b/docs/developer/design/_plot_phase_b_pyvista.py new file mode 100644 index 000000000..8de371f2e --- /dev/null +++ b/docs/developer/design/_plot_phase_b_pyvista.py @@ -0,0 +1,492 @@ +"""Phase B PyVista field plots — high-resolution snapshot at yield-active step. + +Runs the bench_ti_vep_harmonic geometry with ETD-2 at **RES=32** for one +yielding cycle and **checkpoints** the snapshot via ``mesh.write_timestep`` +(HDF5 + XDMF, ParaView-compatible) so we can replot without re-running. +Renders 4-panel PyVista figures using the UW3 ``visualisation`` API. + +Capture-or-load pattern: each case checkpoints to +``output/phase_b_.{mesh, U, sigma, edot_II, ty, sigma_II, yield_ratio}.00000.h5``. +If those files exist, skip the simulation and read back from disk. + +Run:: + + pixi run -e amr-dev python -u docs/developer/design/_plot_phase_b_pyvista.py + +Force re-capture:: + + rm output/phase_b_*.h5 output/phase_b_*.xdmf + pixi run -e amr-dev python -u docs/developer/design/_plot_phase_b_pyvista.py +""" + +import os +import sys +import time + +import numpy as np +import sympy + +import underworld3 as uw +from underworld3 import VarType +from underworld3.function import expression +import underworld3.visualisation as vis + + +# Geometric parameters (kept aligned with the killer test, but at RES=32) +V0 = 0.5 +OMEGA = np.pi / 2.0 +DT = 0.05 +H = 1.0; W = 1.0 +FAULT_LENGTH = 0.6 +FAULT_WIDTH = 0.06 +ETA_0 = 1.0; ETA_1 = 1.0; MU = 1.0 +TAU_Y_BULK = 200.0 +RES = 32 + +OUT_DIR = "output" + + +def _key(theta_deg, tau_y_at_fault): + return f"phase_b_th{theta_deg:+.0f}_ty{tau_y_at_fault:.2f}".replace(".", "p") + + +def _meta_path(key): + return os.path.join(OUT_DIR, key + ".meta.npz") + + +# --------------------------------------------------------------------------- +# Build a fresh model + plotting variables for a given (θ, τ_y). +# Used by both the capture path and the load path so the mesh+var +# discretisation is byte-identical. +# --------------------------------------------------------------------------- + +def build_model(theta_deg, tau_y_at_fault, label_suffix=""): + label = _key(theta_deg, tau_y_at_fault) + label_suffix + + mesh = uw.meshing.StructuredQuadBox( + elementRes=(RES, RES), + minCoords=(0.0, 0.0), maxCoords=(W, H), + qdegree=3, + ) + + # Solver variables (degree=2 / degree=1) + u = uw.discretisation.MeshVariable( + f"U_{label}", mesh, 2, degree=2, vtype=VarType.VECTOR, + ) + p_sol = uw.discretisation.MeshVariable( + f"P_{label}", mesh, 1, degree=1, + continuous=True, vtype=VarType.SCALAR, + ) + + # Fault geometry / spatial yield_stress field + theta = np.radians(theta_deg) + cx, cy = 0.5 * W, 0.5 * H + dx = 0.5 * FAULT_LENGTH * np.cos(theta) + dy = 0.5 * FAULT_LENGTH * np.sin(theta) + fault = uw.meshing.Surface( + f"fault_{label}", mesh, + np.array([[cx - dx, cy - dy], [cx + dx, cy + dy]]), + symbol=f"F{label}", + ) + fault.discretize() + + n_x = -np.sin(theta); n_y = np.cos(theta) + director = sympy.Matrix([n_x, n_y]) + weakness = fault.influence_function( + width=FAULT_WIDTH, + value_near=1.0 / tau_y_at_fault, value_far=1.0 / TAU_Y_BULK, + profile="gaussian", + ) + tau_y_field = 1.0 / weakness + + # Scalar mesh variables for the four post-solve plottable fields. + # Same degree=1 / continuous so they share the canonical mesh nodes. + edot_II_var = uw.discretisation.MeshVariable( + f"edotII_{label}", mesh, 1, degree=1, continuous=True, vtype=VarType.SCALAR, + ) + tau_y_var = uw.discretisation.MeshVariable( + f"tauy_{label}", mesh, 1, degree=1, continuous=True, vtype=VarType.SCALAR, + ) + sigma_II_var = uw.discretisation.MeshVariable( + f"sigmaII_{label}", mesh, 1, degree=1, continuous=True, vtype=VarType.SCALAR, + ) + yield_ratio_var = uw.discretisation.MeshVariable( + f"yieldRatio_{label}", mesh, 1, degree=1, continuous=True, vtype=VarType.SCALAR, + ) + + # Solver (only built when capturing) + stokes = uw.systems.Stokes(mesh, velocityField=u, pressureField=p_sol) + stokes.constitutive_model = uw.constitutive_models.TransverseIsotropicVEPFlowModel( + stokes.Unknowns, integrator="etd", + ) + cm = stokes.constitutive_model + cm.Parameters.shear_viscosity_0 = ETA_0 + cm.Parameters.shear_viscosity_1 = ETA_1 + cm.Parameters.shear_modulus = MU + cm.Parameters.yield_stress = tau_y_field + cm.Parameters.director = director + cm.Parameters.shear_viscosity_min = ETA_0 * 1.0e-3 + cm.Parameters.strainrate_inv_II_min = 1.0e-6 + cm.yield_mode = "softmin" + + stokes.saddle_preconditioner = 1.0 / cm.K + stokes.tolerance = 1.0e-4 + stokes.petsc_options["ksp_type"] = "fgmres" + stokes.petsc_options["snes_force_iteration"] = True + # Iteration monitoring (only printed during the capture phase; load + # path doesn't run the solver). + if os.environ.get("UW_SNES_MONITOR", "0") == "1": + stokes.petsc_options["snes_monitor"] = None + stokes.petsc_options["snes_converged_reason"] = None + stokes.petsc_options["snes_max_it"] = 50 + + V_top = expression(rf"V_{{top,{label}}}", sympy.Float(0.0), "Top BC") + stokes.add_essential_bc(sympy.Matrix([V_top, 0.0]), "Top") + stokes.add_essential_bc(sympy.Matrix([0.0, 0.0]), "Bottom") + stokes.add_essential_bc((sympy.oo, 0.0), "Left") + stokes.add_essential_bc((sympy.oo, 0.0), "Right") + stokes.bodyforce = sympy.Matrix([0.0, 0.0]) + + return dict( + mesh=mesh, stokes=stokes, + u=u, V_top=V_top, + edot_II_var=edot_II_var, tau_y_var=tau_y_var, + sigma_II_var=sigma_II_var, yield_ratio_var=yield_ratio_var, + n_vec=np.array([n_x, n_y]), + ) + + +# --------------------------------------------------------------------------- +# Capture: run the sim, project plottable fields, write_timestep +# --------------------------------------------------------------------------- + +def capture(theta_deg, tau_y_at_fault, n_periods=1.5): + """Run + checkpoint a yield-active snapshot via mesh.write_timestep.""" + obj = build_model(theta_deg, tau_y_at_fault, label_suffix="_cap") + mesh = obj["mesh"]; stokes = obj["stokes"] + u = obj["u"] + V_top = obj["V_top"] + edot_II_var = obj["edot_II_var"] + tau_y_var = obj["tau_y_var"] + sigma_II_var = obj["sigma_II_var"] + yield_ratio_var = obj["yield_ratio_var"] + cm = stokes.constitutive_model + DFDt = stokes.Unknowns.DFDt + + sigma_coords = DFDt.psi_star[0].coords + n_x, n_y = obj["n_vec"]; cx, cy = 0.5 * W, 0.5 * H + sd = np.abs((sigma_coords[:, 0] - cx) * n_x + (sigma_coords[:, 1] - cy) * n_y) + fault_mask = sd < 1.5 * FAULT_WIDTH + E_sym = stokes.Unknowns.E + ty_at_psi_coords = np.asarray( + uw.function.evaluate(cm.Parameters.yield_stress.sym, sigma_coords) + ).flatten() + + T_END = n_periods * 2.0 * np.pi / OMEGA + best = None # (in_fault_max, step_index) + saved = [] # full state history so we can rewind to the chosen step + iters = [] # SNES iteration count per step + reasons = [] # SNES convergence reason per step + t_cur = 0.0 + t0 = time.time() + while t_cur < T_END - 1e-9: + dt = min(DT, T_END - t_cur) + t_end_step = t_cur + dt + v_now = V0 * float(np.cos(OMEGA * t_end_step)) + V_top.sym = sympy.Float(v_now) + cm.Parameters.dt_elastic = dt + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=2) + iters.append(int(stokes.snes.getIterationNumber())) + reasons.append(int(stokes.snes.getConvergedReason())) + + sigma_arr = np.asarray(DFDt.psi_star[0].array) + sigma_II = np.sqrt(0.5 * (sigma_arr ** 2).sum(axis=(1, 2))) + # post-transient window + recordable = t_end_step > 0.5 * 2.0 * np.pi / OMEGA + in_fault_max = float(sigma_II[fault_mask].max()) if fault_mask.any() else 0.0 + if recordable and (best is None or in_fault_max > best[0]): + # Snapshot current state + best = (in_fault_max, len(saved)) + # Snapshot of solver+history state (cheap: arrays) + saved.append(dict( + t=t_end_step, v_top=v_now, + u_arr=np.asarray(u.array).copy(), + sigma_arr=sigma_arr.copy(), + sigma_II=sigma_II.copy(), + )) + t_cur = t_end_step + + if best is None: + # Edge case: didn't reach the post-transient window + best = (saved[-1]["sigma_II"].max(), len(saved) - 1) + chosen = saved[best[1]] + + # Replant chosen state (so subsequent eval calls see the right u for ε̇) + u.array[...] = chosen["u_arr"] + DFDt.psi_star[0].array[...] = chosen["sigma_arr"] + u._sync_lvec_to_gvec() + + # Project the four scalar fields onto the plotting mesh variables. + # Use direct nodal evaluation (degree=1 continuous nodes). + plot_coords = edot_II_var.coords + edot_xx = np.asarray(uw.function.evaluate(E_sym[0, 0], plot_coords)).flatten() + edot_xy = np.asarray(uw.function.evaluate(E_sym[0, 1], plot_coords)).flatten() + edot_yy = np.asarray(uw.function.evaluate(E_sym[1, 1], plot_coords)).flatten() + edot_II_at_plot = np.sqrt(0.5 * (edot_xx ** 2 + edot_yy ** 2 + 2 * edot_xy ** 2)) + edot_II_var.array[:, 0, 0] = edot_II_at_plot + + ty_at_plot = np.asarray( + uw.function.evaluate(cm.Parameters.yield_stress.sym, plot_coords) + ).flatten() + tau_y_var.array[:, 0, 0] = ty_at_plot + + # σ_II at plot nodes — interpolate from psi_star[0] coords via kd-tree. + # psi_star[0] degree = u.degree-1 = 1 → typically same nodes as plot + # mesh, but in general we use uw.function.evaluate on psi_star[0].sym + # for safety. + sigma_sym_II = sympy.sqrt((DFDt.psi_star[0].sym * DFDt.psi_star[0].sym).trace() / 2) + try: + sigma_II_at_plot = np.asarray( + uw.function.evaluate(sigma_sym_II, plot_coords) + ).flatten() + except Exception: + # Fallback — kd-tree interpolation from psi_star coords + from underworld3.kdtree import KDTree + tree = KDTree(np.asarray(sigma_coords)) + sigma_II_at_plot = tree.rbf_interpolator_local( + plot_coords, chosen["sigma_II"][:, None], 4, 2, + ).flatten() + sigma_II_var.array[:, 0, 0] = sigma_II_at_plot + + yield_ratio_var.array[:, 0, 0] = sigma_II_at_plot / np.maximum(ty_at_plot, 1e-30) + + # Write the checkpoint + key = _key(theta_deg, tau_y_at_fault) + os.makedirs(OUT_DIR, exist_ok=True) + mesh.write_timestep( + key, index=0, outputPath=OUT_DIR, + meshVars=[u, edot_II_var, tau_y_var, sigma_II_var, yield_ratio_var], + create_xdmf=True, + ) + # Also the raw stress (rank-2 sym tensor) — psi_star[0] is on the + # solver's DDt, save by writing its underlying mesh-variable + DFDt.psi_star[0].write( + os.path.join(OUT_DIR, key + ".mesh.sigma.00000.h5") + ) + + iters_arr = np.array(iters) + reasons_arr = np.array(reasons) + metadata = dict( + theta_deg=theta_deg, + tau_y_at_fault=tau_y_at_fault, + n_x=float(n_x), n_y=float(n_y), + t=float(chosen["t"]), v_top=float(chosen["v_top"]), + T_END=float(T_END), RES=int(RES), + wall_seconds=float(time.time() - t0), + max_in_fault_sigma_II=float(best[0]), + n_steps=len(saved), + iters=iters_arr, # SNES iteration count per step + reasons=reasons_arr, # SNES convergence reason per step (>0 OK) + ) + np.savez(os.path.join(OUT_DIR, key + ".meta.npz"), **metadata) + + n_diverged = int((reasons_arr < 0).sum()) + print( + f" ran {len(saved)} steps in {metadata['wall_seconds']:.1f}s; " + f"chose t={metadata['t']:.3f}, V_top={metadata['v_top']:+.4f}; " + f"max in-fault σ_II = {metadata['max_in_fault_sigma_II']:.4f} " + f"({metadata['max_in_fault_sigma_II']/tau_y_at_fault:.3f}·τ_y_centre); " + f"checkpointed → {OUT_DIR}/{key}.*", + flush=True, + ) + print( + f" SNES iterations per step: mean={iters_arr.mean():.1f} " + f"median={int(np.median(iters_arr))} max={iters_arr.max()} " + f"diverged_steps={n_diverged}/{len(reasons_arr)}", + flush=True, + ) + + +# --------------------------------------------------------------------------- +# Load: rebuild mesh + variables, read_timestep into them, return them +# --------------------------------------------------------------------------- + +def load_into_fresh_model(theta_deg, tau_y_at_fault): + obj = build_model(theta_deg, tau_y_at_fault, label_suffix="_load") + key = _key(theta_deg, tau_y_at_fault) + # The plotting mesh variables — ``read_timestep`` interpolates from the + # checkpointed coords to the current mesh's coords (kd-tree RBF). + obj["edot_II_var"].read_timestep( + key, obj["edot_II_var"].clean_name.replace("_load", "_cap"), + index=0, outputPath=OUT_DIR, + ) + obj["tau_y_var"].read_timestep( + key, obj["tau_y_var"].clean_name.replace("_load", "_cap"), + index=0, outputPath=OUT_DIR, + ) + obj["sigma_II_var"].read_timestep( + key, obj["sigma_II_var"].clean_name.replace("_load", "_cap"), + index=0, outputPath=OUT_DIR, + ) + obj["yield_ratio_var"].read_timestep( + key, obj["yield_ratio_var"].clean_name.replace("_load", "_cap"), + index=0, outputPath=OUT_DIR, + ) + obj["u"].read_timestep( + key, obj["u"].clean_name.replace("_load", "_cap"), + index=0, outputPath=OUT_DIR, + ) + meta = dict(np.load(os.path.join(OUT_DIR, key + ".meta.npz"))) + obj["meta"] = {k: (v.item() if v.ndim == 0 else v) for k, v in meta.items()} + return obj + + +# --------------------------------------------------------------------------- +# Plot via UW3 visualisation (PyVista) +# --------------------------------------------------------------------------- + +def plot_panels(obj, out_path, off_screen=True): + import pyvista as pv + + pv.global_theme.background = "white" + pv.global_theme.anti_aliasing = "ssaa" + + mesh = obj["mesh"] + u = obj["u"] + sII = obj["sigma_II_var"] + eII = obj["edot_II_var"] + yr = obj["yield_ratio_var"] + ty = obj["tau_y_var"] + meta = obj["meta"] + + # Build PV mesh once + add scalar fields as point_data + pvmesh = vis.mesh_to_pv_mesh(mesh) + pvmesh.point_data["sigma_II"] = vis.scalar_fn_to_pv_points(pvmesh, sII.sym) + pvmesh.point_data["edot_II"] = vis.scalar_fn_to_pv_points(pvmesh, eII.sym) + pvmesh.point_data["yield_ratio"] = np.clip( + vis.scalar_fn_to_pv_points(pvmesh, yr.sym), 0.0, 1.5, + ) + pvmesh.point_data["tau_y"] = vis.scalar_fn_to_pv_points(pvmesh, ty.sym) + + # Velocity arrows from the velocity-degree variable + u_cloud = vis.meshVariable_to_pv_cloud(u) + u_cloud.point_data["u"] = vis.vector_fn_to_pv_points(u_cloud, u.sym) + u_speed = np.linalg.norm(u_cloud.point_data["u"][:, :2], axis=1) + u_cloud.point_data["|u|"] = u_speed + # u_y as a separate scalar — the BC drives a horizontal shear so u_x + # is dominant everywhere; u_y concentrates where the fault forces a + # rotation of the velocity field toward the fault tangent direction. + # Plot u_y as the panel-1 colormap to make that pattern visible. + pvmesh.point_data["u_y"] = vis.scalar_fn_to_pv_points(pvmesh, u.sym[1]) + pvmesh.point_data["|u|"] = vis.scalar_fn_to_pv_points( + pvmesh, sympy.sqrt(u.sym.dot(u.sym)) + ) + + # Fault line for overlay + n_x = float(meta["n_x"]); n_y = float(meta["n_y"]) + cx, cy = 0.5 * W, 0.5 * H + L = FAULT_LENGTH + t_x, t_y = n_y, -n_x + fault_line = pv.Line( + (cx - 0.5 * L * t_x, cy - 0.5 * L * t_y, 0.0), + (cx + 0.5 * L * t_x, cy + 0.5 * L * t_y, 0.0), + ) + + pl = pv.Plotter(off_screen=off_screen, shape=(2, 2), + window_size=(1500, 1400), border=True) + + def _common(p): + p.view_xy() + p.camera.parallel_projection = True + p.add_mesh(fault_line, color="red", line_width=4) + + # Velocity — u_y heatmap (small but reveals fault-induced rotation) + # with full-vector arrows on top. + pl.subplot(0, 0) + uy_max = float(np.max(np.abs(pvmesh.point_data["u_y"]))) + pl.add_mesh( + pvmesh, scalars="u_y", cmap="seismic", + clim=(-uy_max, uy_max), + show_scalar_bar=True, scalar_bar_args={"title": "u_y"}, + ) + sub = max(1, len(u_cloud.points) // 250) + pl.add_arrows(u_cloud.points[::sub], u_cloud.point_data["u"][::sub], + mag=0.35, color="#333333") + pl.add_text( + "velocity: u_y heatmap (+arrows show full u)", + position="upper_edge", font_size=11, color="black", + ) + _common(pl) + + # |ε̇|_II + pl.subplot(0, 1) + pl.add_mesh(pvmesh, scalars="edot_II", cmap="viridis", + show_scalar_bar=True, scalar_bar_args={"title": "|ε̇|_II"}) + pl.add_text("|ε̇|_II", position="upper_edge", font_size=12, color="black") + _common(pl) + + # |σ|_II + pl.subplot(1, 0) + pl.add_mesh(pvmesh, scalars="sigma_II", cmap="magma", + show_scalar_bar=True, scalar_bar_args={"title": "|σ|_II"}) + ty_levels = [meta["tau_y_at_fault"] * f for f in (4.0, 20.0, 100.0)] + contours = pvmesh.contour(isosurfaces=ty_levels, scalars="tau_y") + if contours.n_points > 0: + pl.add_mesh(contours, color="cyan", line_width=1.2) + pl.add_text("|σ|_II — cyan: τ_y(x) contours", + position="upper_edge", font_size=12, color="black") + _common(pl) + + # σ/τ_y ratio with active surface contour + pl.subplot(1, 1) + pl.add_mesh(pvmesh, scalars="yield_ratio", cmap="RdYlGn_r", + clim=(0.0, 1.2), + show_scalar_bar=True, scalar_bar_args={"title": "|σ|_II / τ_y(x)"}) + yc = pvmesh.contour(isosurfaces=[1.0], scalars="yield_ratio") + if yc.n_points > 0: + pl.add_mesh(yc, color="black", line_width=2.0) + pl.add_text("yield activation", + position="upper_edge", font_size=12, color="black") + _common(pl) + + pl.add_text( + f"ETD-2, RES={int(meta['RES'])}, θ={meta['theta_deg']:+.0f}°, " + f"τ_y_fault={meta['tau_y_at_fault']} " + f"(t={meta['t']:.2f}, V_top={meta['v_top']:+.3f})", + position="lower_edge", font_size=10, color="black", + ) + + pl.screenshot(out_path, scale=1.5) + pl.close() + print(f" wrote {out_path}", flush=True) + + +# --------------------------------------------------------------------------- +# Driver +# --------------------------------------------------------------------------- + +def capture_or_load(theta_deg, tau_y_at_fault, n_periods=1.5): + if os.path.exists(_meta_path(_key(theta_deg, tau_y_at_fault))): + print(f" cache hit: {_key(theta_deg, tau_y_at_fault)}.* — skipping run", + flush=True) + else: + print(f" cache miss: running capture", flush=True) + capture(theta_deg, tau_y_at_fault, n_periods=n_periods) + return load_into_fresh_model(theta_deg, tau_y_at_fault) + + +def main(): + cases = [(0.0, 0.15), (15.0, 0.15), (0.0, 0.05), (15.0, 0.05)] + for theta, ty in cases: + print(f"\n=== θ={theta:+.0f}°, τ_y={ty:.2f} ===", flush=True) + obj = capture_or_load(theta, ty, n_periods=1.5) + out = os.path.join( + OUT_DIR, + f"exp_integrator_phase_b_pyvista_th{theta:+.0f}_ty{ty:.2f}".replace(".", "p") + + ".png", + ) + plot_panels(obj, out) + + +if __name__ == "__main__": + main() diff --git a/docs/developer/design/_plot_phase_b_results.py b/docs/developer/design/_plot_phase_b_results.py new file mode 100644 index 000000000..fc1f7ab3a --- /dev/null +++ b/docs/developer/design/_plot_phase_b_results.py @@ -0,0 +1,153 @@ +"""Phase B comparison plots — ETD-2 vs BDF-1 vs BDF-2. + +Produces side-by-side panels reading the saved npz traces: +- ``output/benchmarks/ve_harmonic.npz`` (BDF-1, BDF-2 + analytical) +- ``output/exp_integrator_phase_b_ve_harmonic.npz`` (ETD-2 + analytical) + +Outputs PNGs in ``output/exp_integrator_phase_b_*.png``. + +Run:: + + pixi run -e amr-dev python -u docs/developer/design/_plot_phase_b_results.py +""" + +import os +import numpy as np +import matplotlib + +matplotlib.use("Agg") # non-interactive — write files only +import matplotlib.pyplot as plt + + +C_ANA = "#222222" +C_BDF1 = "#1f77b4" +C_BDF2 = "#d62728" +C_ETD = "#2ca02c" + + +def plot_ve_harmonic(): + bdf = np.load("output/benchmarks/ve_harmonic.npz", allow_pickle=True) + etd = np.load("output/exp_integrator_phase_b_ve_harmonic.npz", allow_pickle=True) + + # Both runs share the same time grid and analytical reference; sanity-check. + t_bdf = bdf["arr_times"] + t_etd = etd["times"] + sigma_ana_bdf = bdf["arr_sigma_ana"] + sigma_ana_etd = etd["sigma_ana"] + sigma_bdf1 = bdf["arr_sigma_bdf1"] + sigma_bdf2 = bdf["arr_sigma_bdf2"] + sigma_etd2 = etd["sigma_exp"] + + assert np.allclose(t_bdf, t_etd), "Time grids differ between runs" + + err_bdf1 = np.abs(sigma_bdf1 - sigma_ana_bdf) + err_bdf2 = np.abs(sigma_bdf2 - sigma_ana_bdf) + err_etd2 = np.abs(sigma_etd2 - sigma_ana_etd) + + A_inf = float(etd["A_inf"]) + + fig, (ax_s, ax_e) = plt.subplots( + 2, 1, figsize=(10.5, 7.0), sharex=True, + gridspec_kw={"height_ratios": [2.0, 1.2]}, + ) + + # σ trace panel + ax_s.plot(t_bdf, sigma_ana_bdf, "-", color=C_ANA, lw=1.5, label="analytical") + ax_s.plot(t_bdf, sigma_bdf1, ".", color=C_BDF1, ms=4, alpha=0.7, + label=f"BDF-1 (max|err|={err_bdf1.max():.2e})") + ax_s.plot(t_bdf, sigma_bdf2, ".", color=C_BDF2, ms=4, alpha=0.7, + label=f"BDF-2 (max|err|={err_bdf2.max():.2e})") + ax_s.plot(t_etd, sigma_etd2, ".", color=C_ETD, ms=4, alpha=0.85, + label=f"ETD-2 (max|err|={err_etd2.max():.2e})") + ax_s.set_ylabel(r"$\sigma_{xy}$ at centre") + ax_s.axhline(0, color="0.7", lw=0.6, zorder=0) + ax_s.axhline(+A_inf, color="0.6", lw=0.6, ls="--", zorder=0) + ax_s.axhline(-A_inf, color="0.6", lw=0.6, ls="--", zorder=0) + ax_s.set_title( + "bench_ve_harmonic (peak-start IC, ω=π/2, dt=0.05) — ETD-2 vs BDF-1, BDF-2" + ) + ax_s.legend(loc="upper right", fontsize=9, framealpha=0.85) + ax_s.grid(True, alpha=0.3) + + # |error| panel (semilog) + ax_e.semilogy(t_bdf, err_bdf1 + 1e-16, "-", color=C_BDF1, lw=0.9, alpha=0.85, label="BDF-1") + ax_e.semilogy(t_bdf, err_bdf2 + 1e-16, "-", color=C_BDF2, lw=0.9, alpha=0.85, label="BDF-2") + ax_e.semilogy(t_etd, err_etd2 + 1e-16, "-", color=C_ETD, lw=1.1, alpha=0.95, label="ETD-2") + ax_e.set_xlabel("t") + ax_e.set_ylabel(r"$|\sigma - \sigma_\mathrm{ana}|$") + ax_e.legend(loc="upper right", fontsize=9, framealpha=0.85) + ax_e.grid(True, alpha=0.3, which="both") + + fig.tight_layout() + out = "output/exp_integrator_phase_b_ve_harmonic.png" + fig.savefig(out, dpi=130, bbox_inches="tight") + plt.close(fig) + print(f"wrote {out}", flush=True) + print(f" BDF-1 max|err|={err_bdf1.max():.4e} rms={np.sqrt((err_bdf1**2).mean()):.4e}") + print(f" BDF-2 max|err|={err_bdf2.max():.4e} rms={np.sqrt((err_bdf2**2).mean()):.4e}") + print(f" ETD-2 max|err|={err_etd2.max():.4e} rms={np.sqrt((err_etd2**2).mean()):.4e}") + + +def plot_killer_summary(): + """Killer-test summary: bar chart of |τ_resolved|/τ_y per (θ, τ_y) for ETD-2 and BDF-1.""" + # Hard-coded from the BDF-1 production npz files (already validated centre probes + # earlier in the session) and the latest ETD-2 sweep. + cases = [ + # (theta_deg, tau_y, etd_tau_res_ratio, bdf1_tau_res_ratio, bdf2_tau_res_ratio_log10) + (0, 0.15, 1.103, 1.122, np.log10(5.689)), + (15, 0.15, 1.118, 1.143, np.log10(2.157e9)), + (-15, 0.15, 1.120, 1.127, np.log10(6.889e7)), + (0, 0.30, 0.922, 1.150, np.log10(9.620)), + (15, 0.30, 0.804, 1.139, np.log10(9.091e9)), + (-15, 0.30, 0.803, 1.138, np.log10(1.859e8)), + ] + labels = [f"θ={c[0]:+}°,\nτ_y={c[1]}" for c in cases] + etd_ratios = [c[2] for c in cases] + bdf1_ratios = [c[3] for c in cases] + bdf2_log10 = [c[4] for c in cases] + x = np.arange(len(cases)) + + fig, (ax_main, ax_bdf2) = plt.subplots( + 2, 1, figsize=(10.5, 7.0), sharex=True, + gridspec_kw={"height_ratios": [2.2, 1.0]}, + ) + + width = 0.36 + ax_main.bar(x - width / 2, bdf1_ratios, width, color=C_BDF1, alpha=0.85, label="BDF-1 (production)") + ax_main.bar(x + width / 2, etd_ratios, width, color=C_ETD, alpha=0.9, label="ETD-2 (this work)") + ax_main.axhline(1.0, color="0.4", lw=0.8, ls="--", zorder=0, label=r"$\tau_y$") + ax_main.axhline(1.20, color="0.7", lw=0.8, ls=":", zorder=0, label=r"gate (1.20·$\tau_y$)") + ax_main.set_ylabel(r"$|\tau_\mathrm{resolved}|$ at fault centre / $\tau_y$") + ax_main.set_title( + "bench_ti_vep_harmonic killer test — ETD-2 vs BDF-1 (centre probe, 6/6 PASS)" + ) + ax_main.legend(loc="upper right", fontsize=9, framealpha=0.85) + ax_main.grid(True, alpha=0.3, axis="y") + ax_main.set_ylim(0.0, 1.4) + + # BDF-2 |σ_xy| log-blow-up panel — BDF-2 is the integrator ETD-2 *replaces* + ax_bdf2.bar(x, bdf2_log10, color=C_BDF2, alpha=0.85, label=r"BDF-2 $\log_{10}|\sigma_{xy}|$ (centre)") + ax_bdf2.axhline(np.log10(1.5), color="0.4", lw=0.8, ls="--", zorder=0, + label=r"$\log_{10}(1.5\cdot\tau_y\sim O(1))$") + ax_bdf2.set_xticks(x) + ax_bdf2.set_xticklabels(labels, fontsize=9) + ax_bdf2.set_ylabel(r"$\log_{10}|\sigma_{xy}|$ at fault centre") + ax_bdf2.legend(loc="upper right", fontsize=9, framealpha=0.85) + ax_bdf2.grid(True, alpha=0.3, axis="y") + ax_bdf2.set_title("BDF-2: blows up to 10⁵–10⁹ on every yield-active combo") + + fig.tight_layout() + out = "output/exp_integrator_phase_b_killer_summary.png" + fig.savefig(out, dpi=130, bbox_inches="tight") + plt.close(fig) + print(f"wrote {out}", flush=True) + + +def main(): + os.makedirs("output", exist_ok=True) + plot_ve_harmonic() + plot_killer_summary() + + +if __name__ == "__main__": + main() diff --git a/docs/developer/design/_plot_phase_d_pyvista_split.py b/docs/developer/design/_plot_phase_d_pyvista_split.py new file mode 100644 index 000000000..d9d135b94 --- /dev/null +++ b/docs/developer/design/_plot_phase_d_pyvista_split.py @@ -0,0 +1,413 @@ +"""Phase D PyVista field plots — split-ETD-2 with explicit-parallel η_∥. + +Identical pattern to ``_plot_phase_b_pyvista.py`` but uses +``TransverseIsotropicVEPSplitFlowModel`` (Phase D split + lag, with the +forcing_star-based η_∥ used for both α_∥/φ_∥ and the C_∥ multiplier). + +Captures the yield-active step from a 1.5-period run at θ=+15°, τ_y=0.05 +(also τ_y=0.15 for the easier baseline) and renders the same 4-panel +PyVista figure (u_y heatmap, |ε̇|_II, |σ|_II, yield_ratio) so we can +compare the field structure directly to the BDF/lumped Phase B plots. + +Run:: + + pixi run -e amr-dev python -u docs/developer/design/_plot_phase_d_pyvista_split.py +""" + +import os +import time + +import numpy as np +import sympy + +import underworld3 as uw +from underworld3 import VarType +from underworld3.function import expression +import underworld3.visualisation as vis + + +V0 = 0.5 +OMEGA = np.pi / 2.0 +DT = 0.05 +H = 1.0; W = 1.0 +FAULT_LENGTH = 0.6 +FAULT_WIDTH = 0.06 +ETA_0 = 1.0; ETA_1 = 1.0; MU = 1.0 +TAU_Y_BULK = 200.0 +RES = 32 + +OUT_DIR = "output" + + +def _key(theta_deg, tau_y_at_fault): + return f"phase_d_split_th{theta_deg:+.0f}_ty{tau_y_at_fault:.2f}".replace(".", "p") + + +def _meta_path(key): + return os.path.join(OUT_DIR, key + ".meta.npz") + + +def build_model(theta_deg, tau_y_at_fault, label_suffix=""): + label = _key(theta_deg, tau_y_at_fault) + label_suffix + + mesh = uw.meshing.StructuredQuadBox( + elementRes=(RES, RES), + minCoords=(0.0, 0.0), maxCoords=(W, H), + qdegree=3, + ) + + u = uw.discretisation.MeshVariable( + f"U_{label}", mesh, 2, degree=2, vtype=VarType.VECTOR, + ) + p_sol = uw.discretisation.MeshVariable( + f"P_{label}", mesh, 1, degree=1, continuous=True, vtype=VarType.SCALAR, + ) + + theta = np.radians(theta_deg) + cx, cy = 0.5 * W, 0.5 * H + dx = 0.5 * FAULT_LENGTH * np.cos(theta) + dy = 0.5 * FAULT_LENGTH * np.sin(theta) + fault = uw.meshing.Surface( + f"fault_{label}", mesh, + np.array([[cx - dx, cy - dy], [cx + dx, cy + dy]]), + symbol=f"F{label}", + ) + fault.discretize() + + n_x = -np.sin(theta); n_y = np.cos(theta) + director = sympy.Matrix([n_x, n_y]) + weakness = fault.influence_function( + width=FAULT_WIDTH, + value_near=1.0 / tau_y_at_fault, value_far=1.0 / TAU_Y_BULK, + profile="gaussian", + ) + tau_y_field = 1.0 / weakness + + edot_II_var = uw.discretisation.MeshVariable( + f"edotII_{label}", mesh, 1, degree=1, continuous=True, vtype=VarType.SCALAR, + ) + tau_y_var = uw.discretisation.MeshVariable( + f"tauy_{label}", mesh, 1, degree=1, continuous=True, vtype=VarType.SCALAR, + ) + sigma_II_var = uw.discretisation.MeshVariable( + f"sigmaII_{label}", mesh, 1, degree=1, continuous=True, vtype=VarType.SCALAR, + ) + yield_ratio_var = uw.discretisation.MeshVariable( + f"yieldRatio_{label}", mesh, 1, degree=1, continuous=True, vtype=VarType.SCALAR, + ) + + stokes = uw.systems.Stokes(mesh, velocityField=u, pressureField=p_sol) + # *** Phase D split-ETD-2 (explicit-parallel) *** + stokes.constitutive_model = uw.constitutive_models.TransverseIsotropicVEPSplitFlowModel( + stokes.Unknowns, + ) + cm = stokes.constitutive_model + cm.Parameters.shear_viscosity_0 = ETA_0 + cm.Parameters.shear_viscosity_1 = ETA_1 + cm.Parameters.shear_modulus = MU + cm.Parameters.yield_stress = tau_y_field + cm.Parameters.director = director + cm.Parameters.shear_viscosity_min = ETA_0 * 1.0e-3 + cm.Parameters.strainrate_inv_II_min = 1.0e-6 + cm.yield_mode = "softmin" + + stokes.saddle_preconditioner = 1.0 / cm.K + stokes.tolerance = 1.0e-4 + stokes.petsc_options["ksp_type"] = "fgmres" + stokes.petsc_options["snes_force_iteration"] = True + + V_top = expression(rf"V_{{top,{label}}}", sympy.Float(0.0), "Top BC") + stokes.add_essential_bc(sympy.Matrix([V_top, 0.0]), "Top") + stokes.add_essential_bc(sympy.Matrix([0.0, 0.0]), "Bottom") + stokes.add_essential_bc((sympy.oo, 0.0), "Left") + stokes.add_essential_bc((sympy.oo, 0.0), "Right") + stokes.bodyforce = sympy.Matrix([0.0, 0.0]) + + return dict( + mesh=mesh, stokes=stokes, + u=u, V_top=V_top, + edot_II_var=edot_II_var, tau_y_var=tau_y_var, + sigma_II_var=sigma_II_var, yield_ratio_var=yield_ratio_var, + n_vec=np.array([n_x, n_y]), + ) + + +def capture(theta_deg, tau_y_at_fault, n_periods=1.5): + obj = build_model(theta_deg, tau_y_at_fault, label_suffix="_cap") + mesh = obj["mesh"]; stokes = obj["stokes"] + u = obj["u"]; V_top = obj["V_top"] + edot_II_var = obj["edot_II_var"] + tau_y_var = obj["tau_y_var"] + sigma_II_var = obj["sigma_II_var"] + yield_ratio_var = obj["yield_ratio_var"] + cm = stokes.constitutive_model + DFDt = stokes.Unknowns.DFDt + + sigma_coords = DFDt.psi_star[0].coords + n_x, n_y = obj["n_vec"]; cx, cy = 0.5 * W, 0.5 * H + sd = np.abs((sigma_coords[:, 0] - cx) * n_x + (sigma_coords[:, 1] - cy) * n_y) + fault_mask = sd < 1.5 * FAULT_WIDTH + E_sym = stokes.Unknowns.E + + T_END = n_periods * 2.0 * np.pi / OMEGA + best = None + saved = [] + iters = []; reasons = [] + t_cur = 0.0 + t0 = time.time() + while t_cur < T_END - 1e-9: + dt = min(DT, T_END - t_cur) + t_end_step = t_cur + dt + v_now = V0 * float(np.cos(OMEGA * t_end_step)) + V_top.sym = sympy.Float(v_now) + cm.Parameters.dt_elastic = dt + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=2) + iters.append(int(stokes.snes.getIterationNumber())) + reasons.append(int(stokes.snes.getConvergedReason())) + + sigma_arr = np.asarray(DFDt.psi_star[0].array) + sigma_II = np.sqrt(0.5 * (sigma_arr ** 2).sum(axis=(1, 2))) + recordable = t_end_step > 0.5 * 2.0 * np.pi / OMEGA + in_fault_max = float(sigma_II[fault_mask].max()) if fault_mask.any() else 0.0 + if recordable and (best is None or in_fault_max > best[0]): + best = (in_fault_max, len(saved)) + saved.append(dict( + t=t_end_step, v_top=v_now, + u_arr=np.asarray(u.array).copy(), + sigma_arr=sigma_arr.copy(), + sigma_II=sigma_II.copy(), + )) + t_cur = t_end_step + + if best is None: + best = (saved[-1]["sigma_II"].max(), len(saved) - 1) + chosen = saved[best[1]] + u.array[...] = chosen["u_arr"] + DFDt.psi_star[0].array[...] = chosen["sigma_arr"] + u._sync_lvec_to_gvec() + + plot_coords = edot_II_var.coords + edot_xx = np.asarray(uw.function.evaluate(E_sym[0, 0], plot_coords)).flatten() + edot_xy = np.asarray(uw.function.evaluate(E_sym[0, 1], plot_coords)).flatten() + edot_yy = np.asarray(uw.function.evaluate(E_sym[1, 1], plot_coords)).flatten() + edot_II_at_plot = np.sqrt(0.5 * (edot_xx ** 2 + edot_yy ** 2 + 2 * edot_xy ** 2)) + edot_II_var.array[:, 0, 0] = edot_II_at_plot + + ty_at_plot = np.asarray( + uw.function.evaluate(cm.Parameters.yield_stress.sym, plot_coords) + ).flatten() + tau_y_var.array[:, 0, 0] = ty_at_plot + + sigma_sym_II = sympy.sqrt( + (DFDt.psi_star[0].sym * DFDt.psi_star[0].sym).trace() / 2 + ) + try: + sigma_II_at_plot = np.asarray( + uw.function.evaluate(sigma_sym_II, plot_coords) + ).flatten() + except Exception: + from underworld3.kdtree import KDTree + tree = KDTree(np.asarray(sigma_coords)) + sigma_II_at_plot = tree.rbf_interpolator_local( + plot_coords, chosen["sigma_II"][:, None], 4, 2, + ).flatten() + sigma_II_var.array[:, 0, 0] = sigma_II_at_plot + + yield_ratio_var.array[:, 0, 0] = sigma_II_at_plot / np.maximum(ty_at_plot, 1e-30) + + key = _key(theta_deg, tau_y_at_fault) + os.makedirs(OUT_DIR, exist_ok=True) + mesh.write_timestep( + key, index=0, outputPath=OUT_DIR, + meshVars=[u, edot_II_var, tau_y_var, sigma_II_var, yield_ratio_var], + create_xdmf=True, + ) + DFDt.psi_star[0].write( + os.path.join(OUT_DIR, key + ".mesh.sigma.00000.h5") + ) + + iters_arr = np.array(iters); reasons_arr = np.array(reasons) + metadata = dict( + theta_deg=theta_deg, + tau_y_at_fault=tau_y_at_fault, + n_x=float(n_x), n_y=float(n_y), + t=float(chosen["t"]), v_top=float(chosen["v_top"]), + T_END=float(T_END), RES=int(RES), + wall_seconds=float(time.time() - t0), + max_in_fault_sigma_II=float(best[0]), + n_steps=len(saved), + iters=iters_arr, reasons=reasons_arr, + ) + np.savez(os.path.join(OUT_DIR, key + ".meta.npz"), **metadata) + n_diverged = int((reasons_arr < 0).sum()) + print( + f" ran {len(saved)} steps in {metadata['wall_seconds']:.1f}s; " + f"chose t={metadata['t']:.3f}, V_top={metadata['v_top']:+.4f}; " + f"max in-fault σ_II = {metadata['max_in_fault_sigma_II']:.4f} " + f"({metadata['max_in_fault_sigma_II']/tau_y_at_fault:.3f}·τ_y_centre); " + f"checkpointed → {OUT_DIR}/{key}.*", + flush=True, + ) + print( + f" SNES iters per step: mean={iters_arr.mean():.1f} " + f"median={int(np.median(iters_arr))} max={iters_arr.max()} " + f"diverged_steps={n_diverged}/{len(reasons_arr)}", + flush=True, + ) + + +def load_into_fresh_model(theta_deg, tau_y_at_fault): + obj = build_model(theta_deg, tau_y_at_fault, label_suffix="_load") + key = _key(theta_deg, tau_y_at_fault) + obj["edot_II_var"].read_timestep( + key, obj["edot_II_var"].clean_name.replace("_load", "_cap"), + index=0, outputPath=OUT_DIR, + ) + obj["tau_y_var"].read_timestep( + key, obj["tau_y_var"].clean_name.replace("_load", "_cap"), + index=0, outputPath=OUT_DIR, + ) + obj["sigma_II_var"].read_timestep( + key, obj["sigma_II_var"].clean_name.replace("_load", "_cap"), + index=0, outputPath=OUT_DIR, + ) + obj["yield_ratio_var"].read_timestep( + key, obj["yield_ratio_var"].clean_name.replace("_load", "_cap"), + index=0, outputPath=OUT_DIR, + ) + obj["u"].read_timestep( + key, obj["u"].clean_name.replace("_load", "_cap"), + index=0, outputPath=OUT_DIR, + ) + meta = dict(np.load(os.path.join(OUT_DIR, key + ".meta.npz"))) + obj["meta"] = {k: (v.item() if v.ndim == 0 else v) for k, v in meta.items()} + return obj + + +def plot_panels(obj, out_path, off_screen=True): + import pyvista as pv + + pv.global_theme.background = "white" + pv.global_theme.anti_aliasing = "ssaa" + + mesh = obj["mesh"] + u = obj["u"] + sII = obj["sigma_II_var"] + eII = obj["edot_II_var"] + yr = obj["yield_ratio_var"] + ty = obj["tau_y_var"] + meta = obj["meta"] + + pvmesh = vis.mesh_to_pv_mesh(mesh) + pvmesh.point_data["sigma_II"] = vis.scalar_fn_to_pv_points(pvmesh, sII.sym) + pvmesh.point_data["edot_II"] = vis.scalar_fn_to_pv_points(pvmesh, eII.sym) + pvmesh.point_data["yield_ratio"] = np.clip( + vis.scalar_fn_to_pv_points(pvmesh, yr.sym), 0.0, 1.5, + ) + pvmesh.point_data["tau_y"] = vis.scalar_fn_to_pv_points(pvmesh, ty.sym) + + u_cloud = vis.meshVariable_to_pv_cloud(u) + u_cloud.point_data["u"] = vis.vector_fn_to_pv_points(u_cloud, u.sym) + u_speed = np.linalg.norm(u_cloud.point_data["u"][:, :2], axis=1) + u_cloud.point_data["|u|"] = u_speed + pvmesh.point_data["u_y"] = vis.scalar_fn_to_pv_points(pvmesh, u.sym[1]) + pvmesh.point_data["|u|"] = vis.scalar_fn_to_pv_points( + pvmesh, sympy.sqrt(u.sym.dot(u.sym)) + ) + + n_x = float(meta["n_x"]); n_y = float(meta["n_y"]) + cx, cy = 0.5 * W, 0.5 * H + L = FAULT_LENGTH + t_x, t_y = n_y, -n_x + fault_line = pv.Line( + (cx - 0.5 * L * t_x, cy - 0.5 * L * t_y, 0.0), + (cx + 0.5 * L * t_x, cy + 0.5 * L * t_y, 0.0), + ) + + pl = pv.Plotter(off_screen=off_screen, shape=(2, 2), + window_size=(1500, 1400), border=True) + + def _common(p): + p.view_xy() + p.camera.parallel_projection = True + p.add_mesh(fault_line, color="red", line_width=4) + + pl.subplot(0, 0) + uy_max = float(np.max(np.abs(pvmesh.point_data["u_y"]))) + pl.add_mesh( + pvmesh, scalars="u_y", cmap="seismic", + clim=(-uy_max, uy_max), + show_scalar_bar=True, scalar_bar_args={"title": "u_y"}, + ) + sub = max(1, len(u_cloud.points) // 250) + pl.add_arrows(u_cloud.points[::sub], u_cloud.point_data["u"][::sub], + mag=0.35, color="#333333") + pl.add_text( + "velocity: u_y heatmap (+arrows show full u)", + position="upper_edge", font_size=11, color="black", + ) + _common(pl) + + pl.subplot(0, 1) + pl.add_mesh(pvmesh, scalars="edot_II", cmap="viridis", + show_scalar_bar=True, scalar_bar_args={"title": "|ε̇|_II"}) + pl.add_text("|ε̇|_II", position="upper_edge", font_size=12, color="black") + _common(pl) + + pl.subplot(1, 0) + pl.add_mesh(pvmesh, scalars="sigma_II", cmap="magma", + show_scalar_bar=True, scalar_bar_args={"title": "|σ|_II"}) + ty_levels = [meta["tau_y_at_fault"] * f for f in (4.0, 20.0, 100.0)] + contours = pvmesh.contour(isosurfaces=ty_levels, scalars="tau_y") + if contours.n_points > 0: + pl.add_mesh(contours, color="cyan", line_width=1.2) + pl.add_text("|σ|_II — cyan: τ_y(x) contours", + position="upper_edge", font_size=12, color="black") + _common(pl) + + pl.subplot(1, 1) + pl.add_mesh(pvmesh, scalars="yield_ratio", cmap="RdYlGn_r", + clim=(0.0, 1.2), + show_scalar_bar=True, scalar_bar_args={"title": "|σ|_II / τ_y(x)"}) + yc = pvmesh.contour(isosurfaces=[1.0], scalars="yield_ratio") + if yc.n_points > 0: + pl.add_mesh(yc, color="black", line_width=2.0) + pl.add_text("yield activation", + position="upper_edge", font_size=12, color="black") + _common(pl) + + pl.add_text( + f"Phase D split-ETD-2, RES={int(meta['RES'])}, " + f"θ={meta['theta_deg']:+.0f}°, τ_y_fault={meta['tau_y_at_fault']} " + f"(t={meta['t']:.2f}, V_top={meta['v_top']:+.3f})", + position="lower_edge", font_size=10, color="black", + ) + + pl.screenshot(out_path, scale=1.5) + pl.close() + print(f" wrote {out_path}", flush=True) + + +def capture_or_load(theta_deg, tau_y_at_fault, n_periods=1.5): + if os.path.exists(_meta_path(_key(theta_deg, tau_y_at_fault))): + print(f" cache hit: {_key(theta_deg, tau_y_at_fault)}.* — skipping run", + flush=True) + else: + print(f" cache miss: running capture", flush=True) + capture(theta_deg, tau_y_at_fault, n_periods=n_periods) + return load_into_fresh_model(theta_deg, tau_y_at_fault) + + +def main(): + cases = [(15.0, 0.05), (15.0, 0.15)] + for theta, ty in cases: + print(f"\n=== θ={theta:+.0f}°, τ_y={ty:.2f} ===", flush=True) + obj = capture_or_load(theta, ty, n_periods=1.5) + out_path = os.path.join( + OUT_DIR, f"exp_integrator_phase_d_pyvista_split_th{theta:+.0f}_ty{ty:.2f}".replace(".", "p") + ".png", + ) + plot_panels(obj, out_path) + + +if __name__ == "__main__": + main() diff --git a/docs/developer/design/_plot_phase_d_uy_diagnosis.py b/docs/developer/design/_plot_phase_d_uy_diagnosis.py new file mode 100644 index 000000000..e39ddd1f5 --- /dev/null +++ b/docs/developer/design/_plot_phase_d_uy_diagnosis.py @@ -0,0 +1,69 @@ +"""Diagnose: is split-ETD's |u_y| growing without bound, or settling? + +Plots BDF-1 / lumped-ETD / split-ETD u_y(t) and σ_∥(t) on shared time +axes — answers whether the 16× higher |u_y| peak in split-ETD is a +stable accumulation matching the elastic-loading/plastic-yielding +cycle, or unbounded drift. +""" + +import os +import numpy as np +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +DT = 0.05 +OMEGA = np.pi / 2.0 +PERIOD = 2.0 * np.pi / OMEGA +TAU_Y = 0.05 +OUT_DIR = "output" + + +def main(): + bdf = np.load(os.path.join(OUT_DIR, "phase_b_bdf_th+15_ty0p05.npz")) + etd = np.load(os.path.join(OUT_DIR, "phase_b_etd_th+15_ty0p05.npz")) + split = np.load(os.path.join(OUT_DIR, "phase_b_etd-split_th+15_ty0p05.npz")) + + t_b = (np.arange(int(bdf["n_steps"])) + 1) * DT / PERIOD + t_e = (np.arange(int(etd["n_steps"])) + 1) * DT / PERIOD + t_s = (np.arange(int(split["n_steps"])) + 1) * DT / PERIOD + + fig, axes = plt.subplots(2, 1, figsize=(9, 7), sharex=True) + + ax = axes[0] + ax.plot(t_b, bdf["u_y_max_per_step"], "-", color="#1f77b4", label="BDF-1") + ax.plot(t_e, etd["u_y_max_per_step"], "-", color="#d62728", + label="ETD lumped", alpha=0.5) + ax.plot(t_s, split["u_y_max_per_step"], "-", color="#2ca02c", + label="split (explicit-parallel)") + ax.set_yscale("log") + ax.set_ylabel(r"max $|u_y|$ (log)") + ax.legend(loc="lower right") + ax.grid(alpha=0.3, which="both") + ax.set_title( + r"split-ETD vs BDF-1 / lumped-ETD: $|u_y|$ and $|\sigma_\parallel|$ " + r"($\tau_y=0.05$, $\theta=+15^\circ$)" + ) + + ax = axes[1] + ax.plot(t_b, bdf["sigma_par_centre"], "-", color="#1f77b4", label="BDF-1") + ax.plot(t_e, etd["sigma_par_centre"], "-", color="#d62728", + label="ETD lumped", alpha=0.5) + ax.plot(t_s, split["sigma_par_centre"], "-", color="#2ca02c", + label="split (explicit-parallel)") + ax.axhline(TAU_Y, color="black", lw=0.7, linestyle="--", + label=rf"$\tau_y={TAU_Y}$") + ax.set_xlabel(r"time $t/T$ (periods)") + ax.set_ylabel(r"centre $|\sigma_\parallel|$") + ax.legend(loc="upper right") + ax.grid(alpha=0.3) + + fig.tight_layout() + out_png = os.path.join(OUT_DIR, "exp_integrator_phase_d_uy_diagnosis.png") + fig.savefig(out_png, dpi=140) + print(f" wrote {out_png}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/docs/developer/design/_plot_phase_e_pyvista_hybrid.py b/docs/developer/design/_plot_phase_e_pyvista_hybrid.py new file mode 100644 index 000000000..ee6cda57b --- /dev/null +++ b/docs/developer/design/_plot_phase_e_pyvista_hybrid.py @@ -0,0 +1,408 @@ +"""Phase E PyVista field plot — hybrid BDF/ETD integrator. + +Same structure as ``_plot_phase_b_pyvista.py`` but uses +``TransverseIsotropicVEPFlowModel(integrator='hybrid', fault_weight=...)``. +Captures the yield-active step at θ=+15°, τ_y ∈ {0.05, 0.15} and +renders the 4-panel field figure (u_y, |ε̇|_II, |σ|_II, yield_ratio). +""" + +import os +import time + +import numpy as np +import sympy + +import underworld3 as uw +from underworld3 import VarType +from underworld3.function import expression +import underworld3.visualisation as vis + + +V0 = 0.5 +OMEGA = np.pi / 2.0 +DT = 0.05 +H = 1.0; W = 1.0 +FAULT_LENGTH = 0.6 +FAULT_WIDTH = 0.06 +ETA_0 = 1.0; ETA_1 = 1.0; MU = 1.0 +TAU_Y_BULK = 200.0 +RES = 32 + +OUT_DIR = "output" + + +def _key(theta_deg, tau_y_at_fault): + return f"phase_e_hybrid_th{theta_deg:+.0f}_ty{tau_y_at_fault:.2f}".replace(".", "p") + + +def _meta_path(key): + return os.path.join(OUT_DIR, key + ".meta.npz") + + +def build_model(theta_deg, tau_y_at_fault, label_suffix=""): + label = _key(theta_deg, tau_y_at_fault) + label_suffix + + mesh = uw.meshing.StructuredQuadBox( + elementRes=(RES, RES), + minCoords=(0.0, 0.0), maxCoords=(W, H), + qdegree=3, + ) + + u = uw.discretisation.MeshVariable( + f"U_{label}", mesh, 2, degree=2, vtype=VarType.VECTOR, + ) + p_sol = uw.discretisation.MeshVariable( + f"P_{label}", mesh, 1, degree=1, continuous=True, vtype=VarType.SCALAR, + ) + + theta = np.radians(theta_deg) + cx, cy = 0.5 * W, 0.5 * H + dx = 0.5 * FAULT_LENGTH * np.cos(theta) + dy = 0.5 * FAULT_LENGTH * np.sin(theta) + fault = uw.meshing.Surface( + f"fault_{label}", mesh, + np.array([[cx - dx, cy - dy], [cx + dx, cy + dy]]), + symbol=f"F{label}", + ) + fault.discretize() + + n_x = -np.sin(theta); n_y = np.cos(theta) + director = sympy.Matrix([n_x, n_y]) + weakness = fault.influence_function( + width=FAULT_WIDTH, + value_near=1.0 / tau_y_at_fault, value_far=1.0 / TAU_Y_BULK, + profile="gaussian", + ) + tau_y_field = 1.0 / weakness + + weakness_min = 1.0 / TAU_Y_BULK + weakness_max = 1.0 / tau_y_at_fault + fault_weight = (weakness - weakness_min) / (weakness_max - weakness_min) + + edot_II_var = uw.discretisation.MeshVariable( + f"edotII_{label}", mesh, 1, degree=1, continuous=True, vtype=VarType.SCALAR, + ) + tau_y_var = uw.discretisation.MeshVariable( + f"tauy_{label}", mesh, 1, degree=1, continuous=True, vtype=VarType.SCALAR, + ) + sigma_II_var = uw.discretisation.MeshVariable( + f"sigmaII_{label}", mesh, 1, degree=1, continuous=True, vtype=VarType.SCALAR, + ) + yield_ratio_var = uw.discretisation.MeshVariable( + f"yieldRatio_{label}", mesh, 1, degree=1, continuous=True, vtype=VarType.SCALAR, + ) + + stokes = uw.systems.Stokes(mesh, velocityField=u, pressureField=p_sol) + stokes.constitutive_model = uw.constitutive_models.TransverseIsotropicVEPFlowModel( + stokes.Unknowns, integrator="hybrid", fault_weight=fault_weight, + ) + cm = stokes.constitutive_model + cm.Parameters.shear_viscosity_0 = ETA_0 + cm.Parameters.shear_viscosity_1 = ETA_1 + cm.Parameters.shear_modulus = MU + cm.Parameters.yield_stress = tau_y_field + cm.Parameters.director = director + cm.Parameters.shear_viscosity_min = ETA_0 * 1.0e-3 + cm.Parameters.strainrate_inv_II_min = 1.0e-6 + cm.yield_mode = "softmin" + + stokes.saddle_preconditioner = 1.0 / cm.K + stokes.tolerance = 1.0e-4 + stokes.petsc_options["ksp_type"] = "fgmres" + stokes.petsc_options["snes_force_iteration"] = True + + V_top = expression(rf"V_{{top,{label}}}", sympy.Float(0.0), "Top BC") + stokes.add_essential_bc(sympy.Matrix([V_top, 0.0]), "Top") + stokes.add_essential_bc(sympy.Matrix([0.0, 0.0]), "Bottom") + stokes.add_essential_bc((sympy.oo, 0.0), "Left") + stokes.add_essential_bc((sympy.oo, 0.0), "Right") + stokes.bodyforce = sympy.Matrix([0.0, 0.0]) + + return dict( + mesh=mesh, stokes=stokes, + u=u, V_top=V_top, + edot_II_var=edot_II_var, tau_y_var=tau_y_var, + sigma_II_var=sigma_II_var, yield_ratio_var=yield_ratio_var, + n_vec=np.array([n_x, n_y]), + ) + + +def capture(theta_deg, tau_y_at_fault, n_periods=1.5): + obj = build_model(theta_deg, tau_y_at_fault, label_suffix="_cap") + mesh = obj["mesh"]; stokes = obj["stokes"] + u = obj["u"]; V_top = obj["V_top"] + edot_II_var = obj["edot_II_var"] + tau_y_var = obj["tau_y_var"] + sigma_II_var = obj["sigma_II_var"] + yield_ratio_var = obj["yield_ratio_var"] + cm = stokes.constitutive_model + DFDt = stokes.Unknowns.DFDt + + sigma_coords = DFDt.psi_star[0].coords + n_x, n_y = obj["n_vec"]; cx, cy = 0.5 * W, 0.5 * H + sd = np.abs((sigma_coords[:, 0] - cx) * n_x + (sigma_coords[:, 1] - cy) * n_y) + fault_mask = sd < 1.5 * FAULT_WIDTH + E_sym = stokes.Unknowns.E + + T_END = n_periods * 2.0 * np.pi / OMEGA + best = None + saved = [] + iters = []; reasons = [] + t_cur = 0.0 + t0 = time.time() + while t_cur < T_END - 1e-9: + dt = min(DT, T_END - t_cur) + t_end_step = t_cur + dt + v_now = V0 * float(np.cos(OMEGA * t_end_step)) + V_top.sym = sympy.Float(v_now) + cm.Parameters.dt_elastic = dt + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=2) + iters.append(int(stokes.snes.getIterationNumber())) + reasons.append(int(stokes.snes.getConvergedReason())) + + sigma_arr = np.asarray(DFDt.psi_star[0].array) + sigma_II = np.sqrt(0.5 * (sigma_arr ** 2).sum(axis=(1, 2))) + recordable = t_end_step > 0.5 * 2.0 * np.pi / OMEGA + in_fault_max = float(sigma_II[fault_mask].max()) if fault_mask.any() else 0.0 + if recordable and (best is None or in_fault_max > best[0]): + best = (in_fault_max, len(saved)) + saved.append(dict( + t=t_end_step, v_top=v_now, + u_arr=np.asarray(u.array).copy(), + sigma_arr=sigma_arr.copy(), + sigma_II=sigma_II.copy(), + )) + t_cur = t_end_step + + if best is None: + best = (saved[-1]["sigma_II"].max(), len(saved) - 1) + chosen = saved[best[1]] + u.array[...] = chosen["u_arr"] + DFDt.psi_star[0].array[...] = chosen["sigma_arr"] + u._sync_lvec_to_gvec() + + plot_coords = edot_II_var.coords + edot_xx = np.asarray(uw.function.evaluate(E_sym[0, 0], plot_coords)).flatten() + edot_xy = np.asarray(uw.function.evaluate(E_sym[0, 1], plot_coords)).flatten() + edot_yy = np.asarray(uw.function.evaluate(E_sym[1, 1], plot_coords)).flatten() + edot_II_at_plot = np.sqrt(0.5 * (edot_xx ** 2 + edot_yy ** 2 + 2 * edot_xy ** 2)) + edot_II_var.array[:, 0, 0] = edot_II_at_plot + + ty_at_plot = np.asarray( + uw.function.evaluate(cm.Parameters.yield_stress.sym, plot_coords) + ).flatten() + tau_y_var.array[:, 0, 0] = ty_at_plot + + sigma_sym_II = sympy.sqrt( + (DFDt.psi_star[0].sym * DFDt.psi_star[0].sym).trace() / 2 + ) + try: + sigma_II_at_plot = np.asarray( + uw.function.evaluate(sigma_sym_II, plot_coords) + ).flatten() + except Exception: + from underworld3.kdtree import KDTree + tree = KDTree(np.asarray(sigma_coords)) + sigma_II_at_plot = tree.rbf_interpolator_local( + plot_coords, chosen["sigma_II"][:, None], 4, 2, + ).flatten() + sigma_II_var.array[:, 0, 0] = sigma_II_at_plot + yield_ratio_var.array[:, 0, 0] = sigma_II_at_plot / np.maximum(ty_at_plot, 1e-30) + + key = _key(theta_deg, tau_y_at_fault) + os.makedirs(OUT_DIR, exist_ok=True) + mesh.write_timestep( + key, index=0, outputPath=OUT_DIR, + meshVars=[u, edot_II_var, tau_y_var, sigma_II_var, yield_ratio_var], + create_xdmf=True, + ) + DFDt.psi_star[0].write( + os.path.join(OUT_DIR, key + ".mesh.sigma.00000.h5") + ) + + iters_arr = np.array(iters); reasons_arr = np.array(reasons) + metadata = dict( + theta_deg=theta_deg, + tau_y_at_fault=tau_y_at_fault, + n_x=float(n_x), n_y=float(n_y), + t=float(chosen["t"]), v_top=float(chosen["v_top"]), + T_END=float(T_END), RES=int(RES), + wall_seconds=float(time.time() - t0), + max_in_fault_sigma_II=float(best[0]), + n_steps=len(saved), + iters=iters_arr, reasons=reasons_arr, + ) + np.savez(os.path.join(OUT_DIR, key + ".meta.npz"), **metadata) + n_diverged = int((reasons_arr < 0).sum()) + print( + f" ran {len(saved)} steps in {metadata['wall_seconds']:.1f}s; " + f"chose t={metadata['t']:.3f}, V_top={metadata['v_top']:+.4f}; " + f"max in-fault σ_II = {metadata['max_in_fault_sigma_II']:.4f} " + f"({metadata['max_in_fault_sigma_II']/tau_y_at_fault:.3f}·τ_y_centre); " + f"checkpointed → {OUT_DIR}/{key}.*", + flush=True, + ) + print( + f" SNES iters per step: mean={iters_arr.mean():.1f} " + f"median={int(np.median(iters_arr))} max={iters_arr.max()} " + f"diverged_steps={n_diverged}/{len(reasons_arr)}", + flush=True, + ) + + +def load_into_fresh_model(theta_deg, tau_y_at_fault): + obj = build_model(theta_deg, tau_y_at_fault, label_suffix="_load") + key = _key(theta_deg, tau_y_at_fault) + obj["edot_II_var"].read_timestep( + key, obj["edot_II_var"].clean_name.replace("_load", "_cap"), + index=0, outputPath=OUT_DIR, + ) + obj["tau_y_var"].read_timestep( + key, obj["tau_y_var"].clean_name.replace("_load", "_cap"), + index=0, outputPath=OUT_DIR, + ) + obj["sigma_II_var"].read_timestep( + key, obj["sigma_II_var"].clean_name.replace("_load", "_cap"), + index=0, outputPath=OUT_DIR, + ) + obj["yield_ratio_var"].read_timestep( + key, obj["yield_ratio_var"].clean_name.replace("_load", "_cap"), + index=0, outputPath=OUT_DIR, + ) + obj["u"].read_timestep( + key, obj["u"].clean_name.replace("_load", "_cap"), + index=0, outputPath=OUT_DIR, + ) + meta = dict(np.load(os.path.join(OUT_DIR, key + ".meta.npz"))) + obj["meta"] = {k: (v.item() if v.ndim == 0 else v) for k, v in meta.items()} + return obj + + +def plot_panels(obj, out_path, off_screen=True): + import pyvista as pv + + pv.global_theme.background = "white" + pv.global_theme.anti_aliasing = "ssaa" + + mesh = obj["mesh"] + u = obj["u"] + sII = obj["sigma_II_var"] + eII = obj["edot_II_var"] + yr = obj["yield_ratio_var"] + ty = obj["tau_y_var"] + meta = obj["meta"] + + pvmesh = vis.mesh_to_pv_mesh(mesh) + pvmesh.point_data["sigma_II"] = vis.scalar_fn_to_pv_points(pvmesh, sII.sym) + pvmesh.point_data["edot_II"] = vis.scalar_fn_to_pv_points(pvmesh, eII.sym) + pvmesh.point_data["yield_ratio"] = np.clip( + vis.scalar_fn_to_pv_points(pvmesh, yr.sym), 0.0, 1.5, + ) + pvmesh.point_data["tau_y"] = vis.scalar_fn_to_pv_points(pvmesh, ty.sym) + + u_cloud = vis.meshVariable_to_pv_cloud(u) + u_cloud.point_data["u"] = vis.vector_fn_to_pv_points(u_cloud, u.sym) + u_speed = np.linalg.norm(u_cloud.point_data["u"][:, :2], axis=1) + u_cloud.point_data["|u|"] = u_speed + pvmesh.point_data["u_y"] = vis.scalar_fn_to_pv_points(pvmesh, u.sym[1]) + pvmesh.point_data["|u|"] = vis.scalar_fn_to_pv_points( + pvmesh, sympy.sqrt(u.sym.dot(u.sym)) + ) + + n_x = float(meta["n_x"]); n_y = float(meta["n_y"]) + cx, cy = 0.5 * W, 0.5 * H + L = FAULT_LENGTH + t_x, t_y = n_y, -n_x + fault_line = pv.Line( + (cx - 0.5 * L * t_x, cy - 0.5 * L * t_y, 0.0), + (cx + 0.5 * L * t_x, cy + 0.5 * L * t_y, 0.0), + ) + + pl = pv.Plotter(off_screen=off_screen, shape=(2, 2), + window_size=(1500, 1400), border=True) + + def _common(p): + p.view_xy() + p.camera.parallel_projection = True + p.add_mesh(fault_line, color="red", line_width=4) + + pl.subplot(0, 0) + uy_max = float(np.max(np.abs(pvmesh.point_data["u_y"]))) + pl.add_mesh( + pvmesh, scalars="u_y", cmap="seismic", + clim=(-uy_max, uy_max), + show_scalar_bar=True, scalar_bar_args={"title": "u_y"}, + ) + sub = max(1, len(u_cloud.points) // 250) + pl.add_arrows(u_cloud.points[::sub], u_cloud.point_data["u"][::sub], + mag=0.35, color="#333333") + pl.add_text( + "velocity: u_y heatmap (+arrows show full u)", + position="upper_edge", font_size=11, color="black", + ) + _common(pl) + + pl.subplot(0, 1) + pl.add_mesh(pvmesh, scalars="edot_II", cmap="viridis", + show_scalar_bar=True, scalar_bar_args={"title": "|ε̇|_II"}) + pl.add_text("|ε̇|_II", position="upper_edge", font_size=12, color="black") + _common(pl) + + pl.subplot(1, 0) + pl.add_mesh(pvmesh, scalars="sigma_II", cmap="magma", + show_scalar_bar=True, scalar_bar_args={"title": "|σ|_II"}) + ty_levels = [meta["tau_y_at_fault"] * f for f in (4.0, 20.0, 100.0)] + contours = pvmesh.contour(isosurfaces=ty_levels, scalars="tau_y") + if contours.n_points > 0: + pl.add_mesh(contours, color="cyan", line_width=1.2) + pl.add_text("|σ|_II — cyan: τ_y(x) contours", + position="upper_edge", font_size=12, color="black") + _common(pl) + + pl.subplot(1, 1) + pl.add_mesh(pvmesh, scalars="yield_ratio", cmap="RdYlGn_r", + clim=(0.0, 1.2), + show_scalar_bar=True, scalar_bar_args={"title": "|σ|_II / τ_y(x)"}) + yc = pvmesh.contour(isosurfaces=[1.0], scalars="yield_ratio") + if yc.n_points > 0: + pl.add_mesh(yc, color="black", line_width=2.0) + pl.add_text("yield activation", + position="upper_edge", font_size=12, color="black") + _common(pl) + + pl.add_text( + f"Phase E hybrid BDF/ETD, RES={int(meta['RES'])}, " + f"θ={meta['theta_deg']:+.0f}°, τ_y_fault={meta['tau_y_at_fault']} " + f"(t={meta['t']:.2f}, V_top={meta['v_top']:+.3f})", + position="lower_edge", font_size=10, color="black", + ) + + pl.screenshot(out_path, scale=1.5) + pl.close() + print(f" wrote {out_path}", flush=True) + + +def capture_or_load(theta_deg, tau_y_at_fault, n_periods=1.5): + if os.path.exists(_meta_path(_key(theta_deg, tau_y_at_fault))): + print(f" cache hit: {_key(theta_deg, tau_y_at_fault)}.* — skipping run", + flush=True) + else: + print(f" cache miss: running capture", flush=True) + capture(theta_deg, tau_y_at_fault, n_periods=n_periods) + return load_into_fresh_model(theta_deg, tau_y_at_fault) + + +def main(): + cases = [(15.0, 0.05), (15.0, 0.15)] + for theta, ty in cases: + print(f"\n=== θ={theta:+.0f}°, τ_y={ty:.2f} ===", flush=True) + obj = capture_or_load(theta, ty, n_periods=1.5) + out_path = os.path.join( + OUT_DIR, + f"exp_integrator_phase_e_pyvista_hybrid_th{theta:+.0f}_ty{ty:.2f}".replace(".", "p") + ".png", + ) + plot_panels(obj, out_path) + + +if __name__ == "__main__": + main() diff --git a/docs/developer/design/_plot_phase_f.py b/docs/developer/design/_plot_phase_f.py new file mode 100644 index 000000000..dcf0d86c6 --- /dev/null +++ b/docs/developer/design/_plot_phase_f.py @@ -0,0 +1,109 @@ +"""Plot Phase F results — predictor-corrector experiments on isotropic VEP. + +Reads the per-step trace files (text format, written each step) so the +plot reproduces from a fresh clone if the npz files are absent. +""" + +import os +import numpy as np +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +OUT_DIR = "output" +TRACE_DIR = "docs/developer/design" +DT = 0.05 +OMEGA = np.pi / 2.0 +PERIOD = 2.0 * np.pi / OMEGA +TAU_Y_FAULT = 0.05 + + +def _load_trace(label): + path = os.path.join(TRACE_DIR, f"_phase_f_{label}.trace.txt") + if not os.path.exists(path): + return None + rows = np.loadtxt(path, comments="#") + if rows.size == 0: + return None + if rows.ndim == 1: + rows = rows.reshape(1, -1) + # Columns: step, t, V_top, snes_iters, picard_iters, sigma_eq_max, + # sigma_eq_max_after_correction, u_y_max, yielded_fraction + return dict( + step=rows[:, 0], + t=rows[:, 1], + V=rows[:, 2], + snes_iters=rows[:, 3], + picard_iters=rows[:, 4], + sigma_eq_max=rows[:, 5], + sigma_eq_max_corrected=rows[:, 6], + u_y_max=rows[:, 7], + yielded_fraction=rows[:, 8], + ) + + +def main(): + cases = [ + ("bdf1_iso", "BDF-1 (yield-in-residual)", "#1f77b4", "-"), + ("etd1_pc1", "ETD-1 + RR single-shot", "#2ca02c", "-"), + ("etd1_pc_picard", "ETD-1 + RR + Picard", "#17becf", "--"), + ("etd2_pc1", "ETD-2 + RR single-shot", "#ff7f0e", "-"), + ("etd2_pc_picard", "ETD-2 + RR + Picard", "#d62728", ":"), + ] + traces = {label: _load_trace(label) for label, _, _, _ in cases} + + fig, axes = plt.subplots(3, 1, figsize=(10, 10), sharex=True) + + # Panel 1 — σ_eq_max (log) + ax = axes[0] + for label, name, color, ls in cases: + tr = traces[label] + if tr is None: + continue + ax.semilogy(tr["t"] / PERIOD, tr["sigma_eq_max"], ls, color=color, + label=f"{name} (peak={tr['sigma_eq_max'].max():.3f})") + ax.axhline(TAU_Y_FAULT, color="#888888", lw=0.7, linestyle="--", + label=rf"$\tau_y^{{fault}}={TAU_Y_FAULT}$") + ax.set_ylabel(r"max $|\sigma|_{eq}$ (log)") + ax.legend(loc="upper left", fontsize=8) + ax.grid(alpha=0.3, which="both") + ax.set_title( + rf"Phase F: predictor-corrector on isotropic VEP, " + rf"localised weak zone (τ_y_fault={TAU_Y_FAULT}, τ_y_bulk=200)" + ) + + # Panel 2 — |u_y|_max (log) + ax = axes[1] + for label, name, color, ls in cases: + tr = traces[label] + if tr is None: + continue + ax.semilogy(tr["t"] / PERIOD, tr["u_y_max"], ls, color=color, + label=f"{name} (peak={tr['u_y_max'].max():.3e})") + ax.set_ylabel(r"max $|u_y|$ (log)") + ax.legend(loc="upper left", fontsize=8) + ax.grid(alpha=0.3, which="both") + + # Panel 3 — yielded fraction + ax = axes[2] + for label, name, color, ls in cases: + tr = traces[label] + if tr is None: + continue + ax.plot(tr["t"] / PERIOD, tr["yielded_fraction"] * 100, ls, color=color, + label=name) + ax.set_ylabel(r"yielded fraction (%)") + ax.set_xlabel(r"time $t / T$ (periods)") + ax.legend(loc="upper left", fontsize=8) + ax.grid(alpha=0.3) + + fig.tight_layout() + out_png = os.path.join(OUT_DIR, "exp_integrator_phase_f_predictor_corrector.png") + fig.savefig(out_png, dpi=140) + plt.close(fig) + print(f" wrote {out_png}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/docs/developer/design/_repro_dminterp_multifield_bug.py b/docs/developer/design/_repro_dminterp_multifield_bug.py new file mode 100644 index 000000000..317dfa011 --- /dev/null +++ b/docs/developer/design/_repro_dminterp_multifield_bug.py @@ -0,0 +1,215 @@ +"""Minimal reproducer for the multi-field / vector-field +`DMInterpolationEvaluate_UW` bug observed during Phase H pyvista +snapshots. + +Three test phases: + + 1. Single-field: mesh + one degree=2 vector ``u``. Assign constants + ``u_x=7``, ``u_y=-3`` and evaluate at u's own DOF nodes. A correct + interpolator returns the assigned constants exactly. + 2. Multi-field, fresh: same mesh + N extra degree=1 scalars assigned + known constants. All variables should round-trip on their own DOFs. + 3. Save+load: write the multi-field state to disk, load into a fresh + mesh with `read_timestep`, then re-evaluate. Tests whether the + symptom severity depends on the load path. + +A correct interpolator round-trips at machine precision on all three +phases. Use this script as a regression gate while fixing the +underlying bug in `_dminterp_wrapper.pyx` / +`MeshVariable.read_timestep`. + +Usage: + pixi run -e amr-dev python -u \ + docs/developer/design/_repro_dminterp_multifield_bug.py +""" + +import numpy as np +import sympy + +import underworld3 as uw +from underworld3 import VarType + + +W = 1.0 +H = 1.0 +RES = 16 + + +def case(n_extra_scalars): + """Build a mesh with one vector (degree=2) + N extra scalars + (degree=1), assign known constants, evaluate at DOF nodes.""" + print(f"\n=== n_extra_scalars = {n_extra_scalars} ===", flush=True) + mesh = uw.meshing.StructuredQuadBox( + elementRes=(RES, RES), + minCoords=(0.0, 0.0), maxCoords=(W, H), + qdegree=3, + ) + + label = f"R{n_extra_scalars}" + u = uw.discretisation.MeshVariable( + f"U_{label}", mesh, 2, degree=2, vtype=VarType.VECTOR, + ) + extras = [] + for k in range(n_extra_scalars): + s = uw.discretisation.MeshVariable( + f"S{k}_{label}", mesh, 1, degree=1, + continuous=True, vtype=VarType.SCALAR, + ) + extras.append(s) + + # Assign distinctive constant values via the array setter + u.array[:, 0, 0] = 7.0 # u_x = 7 at every node + u.array[:, 0, 1] = -3.0 # u_y = -3 at every node + u._sync_lvec_to_gvec() + for k, s in enumerate(extras): + s.array[:, 0, 0] = float(100 + k) # 100, 101, 102, … + s._sync_lvec_to_gvec() + mesh._stale_lvec = True + + # Evaluate u at u's own DOF nodes — should round-trip 7, -3 + u_eval = np.asarray(uw.function.evaluate(u.sym, u.coords)).reshape(-1, 2) + err_u = np.max(np.abs(u_eval - np.array([7.0, -3.0]))) + u_x_max = float(np.max(np.abs(u_eval[:, 0]))) + u_y_max = float(np.max(np.abs(u_eval[:, 1]))) + print(f" u_x: assigned 7.0, eval max|u_x|={u_x_max:.4e}", flush=True) + print(f" u_y: assigned -3.0, eval max|u_y|={u_y_max:.4e}", flush=True) + print(f" u_eval err vs assigned: max={err_u:.4e}", flush=True) + + # Evaluate each scalar at its own DOF nodes + for k, s in enumerate(extras): + expected = float(100 + k) + s_eval = np.asarray(uw.function.evaluate(s.sym, s.coords)).flatten() + err = np.max(np.abs(s_eval - expected)) + print(f" S{k}: assigned {expected}, eval max={s_eval.max():.4e} " + f"min={s_eval.min():.4e} err={err:.4e}", flush=True) + + # Aggregate verdict + bad = err_u > 1e-8 + for k, s in enumerate(extras): + expected = float(100 + k) + s_eval = np.asarray(uw.function.evaluate(s.sym, s.coords)).flatten() + if np.max(np.abs(s_eval - expected)) > 1e-8: + bad = True + return bad + + +def case_load(n_extra_scalars): + """Build, assign, write checkpoint, load into fresh mesh, evaluate. + + Tests whether the read_timestep path makes the bug worse (or + triggers it where the fresh-build path doesn't). + """ + import os + OUT = "output/_repro_dminterp" + os.makedirs(OUT, exist_ok=True) + print(f"\n=== load test, n_extra_scalars = {n_extra_scalars} ===", flush=True) + + # ---- Phase 1: capture (write checkpoint) ---- + mesh_w = uw.meshing.StructuredQuadBox( + elementRes=(RES, RES), + minCoords=(0.0, 0.0), maxCoords=(W, H), + qdegree=3, + ) + label_w = f"W{n_extra_scalars}" + u_w = uw.discretisation.MeshVariable( + f"U_{label_w}", mesh_w, 2, degree=2, vtype=VarType.VECTOR, + ) + extras_w = [] + for k in range(n_extra_scalars): + s = uw.discretisation.MeshVariable( + f"S{k}_{label_w}", mesh_w, 1, degree=1, + continuous=True, vtype=VarType.SCALAR, + ) + extras_w.append(s) + + u_w.array[:, 0, 0] = 7.0 + u_w.array[:, 0, 1] = -3.0 + u_w._sync_lvec_to_gvec() + for k, s in enumerate(extras_w): + s.array[:, 0, 0] = float(100 + k) + s._sync_lvec_to_gvec() + mesh_w._stale_lvec = True + + key = f"repro_n{n_extra_scalars}" + mesh_w.write_timestep( + key, index=0, outputPath=OUT, + meshVars=[u_w] + extras_w, + create_xdmf=False, + ) + + # ---- Phase 2: load into fresh mesh with the same variable layout ---- + mesh_r = uw.meshing.StructuredQuadBox( + elementRes=(RES, RES), + minCoords=(0.0, 0.0), maxCoords=(W, H), + qdegree=3, + ) + label_r = f"R{n_extra_scalars}" + u_r = uw.discretisation.MeshVariable( + f"U_{label_r}", mesh_r, 2, degree=2, vtype=VarType.VECTOR, + ) + extras_r = [] + for k in range(n_extra_scalars): + s = uw.discretisation.MeshVariable( + f"S{k}_{label_r}", mesh_r, 1, degree=1, + continuous=True, vtype=VarType.SCALAR, + ) + extras_r.append(s) + + u_r.read_timestep(key, u_w.clean_name, index=0, outputPath=OUT) + for k, s in enumerate(extras_r): + s.read_timestep(key, extras_w[k].clean_name, index=0, outputPath=OUT) + + # Confirm raw arrays loaded correctly + print(f" u_r.array max|u_x|={float(np.max(np.abs(u_r.array[:, 0, 0]))):.4e}, " + f"max|u_y|={float(np.max(np.abs(u_r.array[:, 0, 1]))):.4e}", + flush=True) + for k, s in enumerate(extras_r): + print(f" S{k}.array max={float(np.max(np.abs(s.array))):.4e}", + flush=True) + + # Now evaluate each + u_eval = np.asarray(uw.function.evaluate(u_r.sym, u_r.coords)) + u_eval = u_eval.reshape(-1, 2) + err_u = np.max(np.abs(u_eval - np.array([7.0, -3.0]))) + print(f" evaluate(u.sym, u.coords): err vs [7,-3] = {err_u:.4e} " + f"(col0 max={u_eval[:,0].max():.4e} col1 max={u_eval[:,1].max():.4e})", + flush=True) + + bad = err_u > 1e-8 + for k, s in enumerate(extras_r): + expected = float(100 + k) + s_eval = np.asarray(uw.function.evaluate(s.sym, s.coords)).flatten() + err = np.max(np.abs(s_eval - expected)) + print(f" evaluate(S{k}.sym, S{k}.coords): err vs {expected} = " + f"{err:.4e} (max={s_eval.max():.4e} min={s_eval.min():.4e})", + flush=True) + if err > 1e-8: + bad = True + return bad + + +def main(): + print("Multi-field DMInterpolationEvaluate reproducer", flush=True) + print("Tests `uw.function.evaluate(var.sym, var.coords)` after " + "assigning known constants.", flush=True) + + fresh_bad = [] + for n in (0, 1, 4): + if case(n): + fresh_bad.append(n) + + print("\n--- with save + load ---", flush=True) + load_bad = [] + for n in (0, 1, 4): + if case_load(n): + load_bad.append(n) + + print() + print(f"Fresh-build bad: {fresh_bad}", flush=True) + print(f"Save+load bad: {load_bad}", flush=True) + if not fresh_bad and not load_bad: + print("All cases pass — bug not reproduced (or fixed).", flush=True) + + +if __name__ == "__main__": + main() diff --git a/docs/developer/design/anisotropic-mmpde-mover.md b/docs/developer/design/anisotropic-mmpde-mover.md new file mode 100644 index 000000000..b6a1a273c --- /dev/null +++ b/docs/developer/design/anisotropic-mmpde-mover.md @@ -0,0 +1,276 @@ +# Anisotropic MMPDE Mesh Mover (variational, Huang–Kamenski) + +**Status:** design + validated numpy prototype (2026-05-30); UW3 port +pending as `smooth_mesh_interior(..., method="mmpde")`. + +## Why this mover exists + +The existing movers cannot produce a **thin refined strip aligned to a +codimension-1 feature** (a fault, an interface): + +- **`"ma"` (Monge–Ampère, `_winslow_elliptic`)** genuinely *clusters*, + but only isotropically. A scalar value-metric peaked on a fault + refines a *disk around the densest part* — the cluster lands at the + metric's centre of gravity, not *on* the line (measured: only + ~60–80 % of refined cells within ~0.75 h of a fault, the rest pulled + toward the middle/root). Right tool for *tangentially-uniform* + features (thermal boundary layers, plumes); wrong *shape* for a fault. +- **`"anisotropic"` (decoupled forward-Winslow, `_winslow_anisotropic`)** + uses a tensor metric but solves a *linear* M-weighted Laplacian for + each physical coordinate independently. That is a **smoother, not a + clusterer** — it reshapes/aligns cells but does not concentrate them + (measured band median-area/global ≈ 0.9 ≈ uniform). It also has no + non-folding guarantee: at fault-grade anisotropy (≳6:1) the map folds + one cell, and the *global* signed-area backtrack then throttles the + whole step to protect it, so the mesh freezes (`scale → 0`) while + reporting "converged" — the paradoxical "at the stability limit but + nothing moves." The inner solve is exact (MUMPS LU, SPD); the failure + is the *formulation/strategy*, not the linear solve. + See `ADAPTIVE_MESHING_DESIGN.md` and `mesh-adaptation-formulation.md`. + +The MMPDE mover fixes this structurally. It is the **variational moving +mesh** method of Huang & Russell, in the direct simplex discretization +of Huang & Kamenski. It generates the physical mesh as the image of a +**fixed computational (reference) mesh** under the *inverse* coordinate +map, minimizing a meshing functional that combines **equidistribution** +and **alignment** to a metric tensor `M`. Because the functional has a +barrier (`G → ∞` as `det 𝕁 → 0`) it is provably **non-folding** (Huang +& Kamenski 2018), and because it is the inverse map of a convex +computational domain it genuinely *clusters and aligns* — a thin strip +*on* the feature line. + +Validated (numpy prototype): refinement **on the fault line** +(on-fault fraction 0.95–0.99 vs 0.6–0.8 for `"ma"`), **0 crushed +cells**, **monotone-convergent, never folds** (`scale = 1` throughout), +generalizes to **multiple / crossing / curved** faults and to an +**evolving (moving) metric**. + +## References + +- W. Huang & L. Kamenski, *A geometric discretization and a simple + implementation for variational mesh generation and adaptation*, + J. Comput. Phys. 301 (2015) 322–337. **doi:10.1016/j.jcp.2015.07.015** + (arXiv:1410.7872). — the discretization and the analytic nodal + velocities implemented here. +- W. Huang & L. Kamenski, *On the mesh nonsingularity of the moving mesh + PDE method*, Math. Comp. 87 (2018) 1887–1911. **doi:10.1090/mcom/3271** + (arXiv:1512.04971). — the non-folding guarantee. +- W. Huang, *Variational mesh adaptation: isotropy and equidistribution*, + J. Comput. Phys. 174 (2001) 903–924. **doi:10.1006/jcph.2001.6878** — + the functional and its `p`, `θ` parameters. +- W. Huang & R. D. Russell, *Adaptive Moving Mesh Methods*, Springer AMS + 174 (2011). **doi:10.1007/978-1-4419-7916-2**. + +## Formulation (general `d`; simplex meshes) + +Per element `K` with local physical vertices `x0, x1, x2` and the +corresponding **fixed computational** vertices `ξ0, ξ1, ξ2`: + +``` +E = [x1-x0, x2-x0] physical edge matrix (columns) +Ehat = [ξ1-ξ0, ξ2-ξ0] computational edge matrix (FIXED reference) +𝕁 = Ehat · E^{-1} Jacobian of the inverse map ξ(x) (eq 17) +r = det 𝕁 = det Ehat / det E +M = M(x_K) SPD metric at the element centroid +S = tr(𝕁 M^{-1} 𝕁^T) +``` + +**Huang's functional** (eq 3; with `d = 2`, `dp/2 = p`): + +``` +G = θ · √det(M) · S^p + (1 - 2θ) · 2^p · r^p · det(M)^{(1-p)/2} +I_h = Σ_K |K| · G_K (eq 6) +``` + +`θ ∈ (0, ½]` balances **alignment** (1st term) vs **equidistribution** +(2nd term); `p ≥ 1`. Coercive/polyconvex (unique minimizer) for +`0 < θ ≤ ½`, `dp ≥ 2`, `p ≥ 1`. + +**Derivatives** (eq 16): + +``` +∂G/∂𝕁 = 2 p θ √det(M) · S^{p-1} · M^{-1} 𝕁^T +∂G/∂r = p (1 - 2θ) 2^p · det(M)^{(1-p)/2} · r^{p-1} +``` + +**Physical-coordinate nodal velocity** (Appendix A, eqs 39–41). The +descent velocity `v_i = −∂I_h/∂x_i` is assembled from per-element local +velocities; for the local non-`0` vertices, + +``` +[v1; v2] = −G E^{-1} + E^{-1} (∂G/∂𝕁) Ehat E^{-1} + (∂G/∂r) r E^{-1} +v0 = −(v1 + v2) +∂I_h/∂x_i = − Σ_{K ∋ i} |K| v^K_{i} +``` + +### The metric-variation term is ESSENTIAL (key gotcha) + +Equations 39–41 as written treat `M = M(x_K)` as moving with the cell +centroid, contributing a **`∂G/∂M : ∂M/∂x`** term. Dropping it +("frozen-M") is *wrong wherever `M` varies sharply* — i.e. **on the +fault**, which is exactly where it matters. Measured: frozen-M gradient +is **65–330 % wrong** vs finite differences for a sharp fault metric, +and the resulting flow does **not cluster** (band ≈ uniform, energy +wanders). Including it restores agreement to **1e-8**. + +``` +∂G/∂M = θ √det(M) [ ½ S^q M^{-1} − q S^{q-1} M^{-1} 𝕁^T 𝕁 M^{-1} ] + + (1-2θ) d^q r^p · (1-p)/2 · det(M)^{(1-p)/2} · M^{-1} (symmetric) +``` +(general `d`, `q = dp/2`; for `d=2`, `q=p`, `d^q=2^p`.) + +assembled per vertex as `∂I_h/∂x_i += Σ_{K∋i} (|K|/(d+1)) · tr(∂G/∂M · +∂_c M)` (the `1/(d+1)` because `∂x_K/∂x_i = 1/(d+1)`), with `∂M/∂x` from +the analytic metric (centroid finite difference is fine). + +**Lesson:** finite-difference-validate any hand-derived mesh gradient +before trusting it. The prototype's `mmpde.py __main__` does exactly +this (const-M and varying-M, all `p/θ`). + +## Time integration (the MMPDE) + +Gradient flow `∂ξ/∂t = −(P/τ) ∂I_h/∂ξ`, rewritten in physical +coordinates (eq 39): + +``` +dx_i/dt = (P_i / τ) Σ_{K ∋ i} |K| v^K_i , P_i = det(M(x_i))^{(p-1)/2} +``` + +`P_i` (eq 24) makes the flow invariant under `M → cM` (scale-free +node concentration). Discretized as **explicit Euler with two +safeguards** (validated): + +1. **Per-node step cap**: limit each node's move to `step_frac · h_i` + (`h_i` = min incident edge). Prevents single-step overshoot + (the boundary-crush mechanism); `step_frac ≈ 0.2`. +2. **Energy line-search backtrack**: accept a step only if it produces + **no fold** *and* **decreases `I_h`** (halve `scale` up to ~20×). + This makes the descent **monotone** — it reaches the true minimizer + instead of oscillating around it (an early non-monotone version + produced run-to-run-variable, over-stated refinement). + +`τ` sets the move scale; with the line-search, `τ` is non-critical +(`τ = 1`). Convergence ≈ a few hundred explicit steps for `cellSize` +0.04; the line-search crawls to small `scale` near the minimum (a +candidate for acceleration in the port). + +## Boundary conditions + +- **Pinned** interior-only boundary: boundary nodes excluded from the + move (`free = ~is_bnd`). Simplest; the ring cannot open to admit a + surface-reaching feature. +- **Tangential slip** (recommended for surface-reaching faults): include + boundary nodes in the move but **remove the outward-normal component** + of their velocity, then snap them back onto the surface (`project`, + e.g. fixed `|r|` for an annulus). `free = (~is_bnd) | slip`. + - Trade-off (measured, surface-reaching fault, `across=100`): slip + lets the ring **open** to admit the fault (finer band at the + surface, 0.30 → 0.26) **but** costs boundary-row angle quality + (min angle 20° → 9°, on-fault 0.88). It is a real knob, **not + free** — localize slip to the fault root and/or temper its strength. + - Use the projected PETSc/`Gamma_P1` normal in UW3 (the generic + boundary normal used for slip BCs), consistent with the existing + `_build_slip_projector`. + +## Behaviour and tuning (validated, numpy prototype) + +| Knob | Effect | +|---|---| +| `across`-ratio of `M` | primary strength; 9 → 100 deepens band 0.79 → 0.44, on-fault → 1.0, 0 crushed. `≳400` over-shoots (refinement drifts off-fault). Sweet spot ~100. | +| `p` (with `θ`) | `p` 1.5 → 3 sharpens the band; pair higher `p` with smaller `θ` (e.g. `θ = 1/6`). | +| node count (base `h`) | sets **absolute** on-fault cell size (≈ linear in `h`): `cellSize` 0.04 → 0.013 gives `h_fault` 0.017 → 0.006. Use to get real resolution; the *ratio* is capped by the fixed budget (~1.5–2× median, the standard r-adapt cap). | +| cumulative reference-reset | re-running with `ref = current mesh` pushes past the single-run cap (band 0.62 → 0.19 over 3 rounds) but **degrades quality** (min angle 24° → 0.9°, crushed cells appear). Use sparingly. | + +**Discriminant:** judge with `n_crushed` (cells with area < 0.02 · +global median) and the metric-aware *radial/tangential* extent — **not** +min-angle, which over-counts legitimate thin anisotropic cells (a +resolved strip looks like "slivers" to an isotropic detector). See +`alignment.py` in the prototype scratch. + +## UW3 port plan (`method="mmpde"`) + +**Architectural rule: PETSc-native and parallel-safe by construction.** +The numpy prototype was a *validation* vehicle only. The element-level +algebra (per-`K` `d×d` matrices `E`, `Ehat`, `𝕁`, and `G`, `∂G/∂𝕁`, +`∂G/∂r`, `∂G/∂M`) is genuinely local and may stay vectorised NumPy over +the rank-local element block — that is not a parallel hazard. Everything +that **couples across vertices or ranks** must go through PETSc `Vec` / +DM operations, never a rank-local `np.add.at` into a global array. The +prototype's `np.add.at` assembly is serial-only and must NOT be ported +as-is. + +1. Add `_winslow_mmpde(mesh, metric, pinned_labels, verbose, **kw)` to + `src/underworld3/meshing/smoothing.py`, **dimension-general (`d = 2` + and `3`) from the start** — the method and all formulas above are + general `d` (paper validates 3D), and UW3 already has the 3D + infrastructure (`_tet_cells`, `_signed_volumes`, and a 3D branch in + `_boundary_vertex_normals` / `_build_slip_projector` for tangent-plane + slip). Use `cdim` everywhere: `(d+1)`-vertex cells, `d×d` edge + matrices / metric, `det`/`inv` via batched `numpy.linalg` (not a + hand-coded `2×2`), signed *volume* via `_tet_cells`/`_signed_volumes` + in 3D. Do **not** raise `NotImplementedError` for 3D the way + `_winslow_anisotropic` does — that 2D-only limitation is what this + mover supersedes. Element terms (eqs 16, 40–41 + `∂G/∂M`, with + `q = d·p/2`) computed per rank-local cell; the **velocity assembly is + a PETSc Vec**, not a numpy array: + - assemble `Σ_{K∋i} |K| v^K_i` into a global `Vec` with `ADD_VALUES` + (DM section / `petsc_dm.localToGlobal(..., ADD_VALUES)`), so cells + straddling a partition boundary correctly contribute to off-rank + vertices. This is the same ghost-summation pattern flagged for the + lumped-V source in `_winslow_elliptic` (whose `np.add.at` is a known + serial-only TODO) — do it right here from the start. + - the `P_i` balancing and the final coordinate update act on the + assembled global Vec, then scatter back to the local (ghosted) + coordinate vector. +2. **All scalar tests/norms are collective reductions** (`uw.mpi.comm` + `allreduce`): + - energy `I_h = Σ_K |K| G_K` — sum over owned cells then `allreduce` + (count each cell once; use the owned-cell mask, not ghosts); + - the line-search predicates — *"min signed area > floor"* and *"I_h + decreased"* — must be **global** (`MIN` / the globally-summed `I_h`), + so every rank takes the same accept/backtrack branch in lockstep + (otherwise ranks desynchronise on `scale`); + - the convergence norm `max|Δx|` — `allreduce(MAX)`. +3. Reuse existing parallel-aware infrastructure: `_tri_cells`, + `_signed_areas`, `_min_incident_edge`, and `_ot_adapt._build_slip_projector` + / `_resolve_slip` for the slip normal (`Gamma_P1`). The slip + projection is per-vertex local (no coupling) once the velocity Vec is + assembled and ghost-updated. `Gamma_P1` is already projected/parallel. +4. **Metric `M` and `∂M/∂x`** via `uw.function.evaluate` at element + centroids (already parallel-aware) — do **not** hand-roll a coordinate + loop. `M` is a `d×d` sympy matrix / `VarType.TENSOR` MeshVariable via + the existing `supplied_D`-style entry routed by `smooth_mesh_interior`. + Eulerian re-eval each step is safe (`M` anchored to fixed feature + geometry). +5. The **fixed computational reference** = mesh coordinates at the first + call, cached as a *ghosted* coordinate Vec on the mesh (like + `_ot_adapt_reference_coords`), so each rank has its halo. For an + *evolving* feature, keep the uniform reference and re-relax each adapt + event (validated serially: tracks a moving fault cleanly). +6. `method_kwargs`: `p` (1.5–2), `theta` (1/3), `tau` (1), `n_steps`, + `step_frac` (0.2), `slip` (bool/mask), `area_floor_frac` (0.01). +7. Cross-link from `ADAPTIVE_MESHING_DESIGN.md` / + `mesh-adaptation-formulation.md`. **Regressions must cover both + dimensions and parallel** (decision 2026-05-30: port 2D+3D, validate + both directly in UW3 — no separate 3D numpy prototype): + - **2D serial** (Tier-A): uniform `M` ⇒ near no-op; single fault ⇒ + on-fault band, 0 crushed. + - **3D serial**: uniform `M` ⇒ near no-op on a tet mesh; a planar + fault ⇒ on-plane refined slab, 0 crushed. 3D is *derived* here but + **not yet numerically validated**, and its decoupled non-folding + margin is tighter than 2D — treat this as a first-class acceptance + test, not an afterthought. + - **`np>=2` in each dimension**: matches the serial result to solver + tolerance (same final coords up to partition-independent reduction + order) — the assembly/ghost path is exactly where serial-only bugs + hide. + +## Open items + +- Acceleration: the line-search takes tiny end-steps near convergence; + an accelerated / semi-implicit step could cut iteration count. Any such + scheme must keep its global-reduction predicates collective (item 2). +- Slip localization: temper/localize slip to the fault root to keep the + finer-at-surface benefit without the global boundary-angle cost. +- Parallel correctness is a **release gate**, not an open item: the port + is not "done" until the `np>=2` regression (item 7) matches serial. diff --git a/docs/developer/design/boundary-slip-strategy.md b/docs/developer/design/boundary-slip-strategy.md new file mode 100644 index 000000000..d49cf2f56 --- /dev/null +++ b/docs/developer/design/boundary-slip-strategy.md @@ -0,0 +1,483 @@ +# Boundary-slip strategy: a mesh-owned tangent-slip + surface-restore contract + +```{note} +**Status:** design proposal (2026-06-06). This document specifies a refactor; +no behaviour change is intended in the first (interface) step. It gates making +the MMPDE mover the production default — see +`docs/developer/design/anisotropic-mmpde-mover.md` and the mover-API +simplification work. +``` + +## Motivation + +Every metric mover that moves *boundary* vertices needs **tangential boundary +slip**: a boundary node may slide *along* the domain surface (so the mesh can +concentrate cells where a feature meets the wall) but must not drift *off* it +(which would change the domain and, on a free-slip Stokes problem, leak +`v·n ≠ 0`). Slip is therefore two operations applied to the moved boundary +coordinates: + +1. **tangent-project** — remove the boundary-normal component of a node's + displacement, so it slides in the tangent plane; +2. **restore-to-surface** — snap the node back onto the true surface, because + tangent-projecting a *finite* step off a *curved* surface leaves it a + sagitta inside the chord; without restoration nodes creep inward over many + iterations. + +Today this logic is **duplicated and geometry-coupled**: + +- The most evolved version lives in `meshing/_ot_adapt.py` as private helpers + (`_resolve_slip`, `_build_slip_projector`, `_slip_normals`, + `_is_radial_coords`, `_boundary_centre`, `_nearest_on_facets_2d/3d`). The + `mmpde` and `ot` movers consume it through `_build_slip_projector`. +- The `spring` and `ma` movers carry their **own inline** per-ring radial snap + (`meshing/smoothing.py`, the `boundary_slip and is_bnd.any()` blocks) — a + special case of the same idea, written before the `_ot_adapt` unification. +- Whether a boundary is "radial" (snap to `|r|`) vs "flat" (no snap needed) is + decided at **run time** by a `CoordinateSystemType` heuristic + (`_is_radial_coords`), and the snap **centre** is recovered every call by a + parallel `allreduce` over boundary coordinates — even though the constructor + *knew* the exact centre and radii. +- Free / deforming surfaces (where the surface position is itself unknown) are + handled by a `dict {label: snap_bool}` opt-out that disables restoration — + a placeholder for a real kinematic restore that does not yet exist. + +This is fragile (four code paths to keep parallel-consistent), guesses geometry +it could be told, and offers no clean seam for the free-surface case. Making +MMPDE the default mover means *every* analytic-boundary mesh must slip +correctly with no per-script `boundary_slip='ring'`/`'box'` fiddling. That +needs a **single, mesh-owned slip contract**. + +## Proposal + +Introduce a **first-class bounding-surface object** that owns a boundary's +geometry, state flags, and the methods to operate on it (tangent-project, +restore, normals). Slip is then a *bounding-surface-level* capability — the +surface knows whether it is radial, planar, free, or generic, and how to restore +a point to itself — and the **mesh is only the orchestrator** of the +cross-surface concerns the movers need (which vertices slip vs pin, including +junction vertices shared by two surfaces, and composing the per-surface +operations into one pass). + +This is the key correction over a mesh-level provider dict: behaviour and state +live on the surface, not in a side table keyed by label. If one surface is +radial and another is free, each carries its own flags and the orchestrator +calls the correct per-surface restore for each. + +```{important} +**`mesh.boundaries` is NOT repurposed.** That `Enum` carries the gmsh-style +boundary *labels* that persist all the way through: gmsh `.msh` → PETSc DMPlex +`DMLabel`s → the mesh hdf5 checkpoint. It is a persistence contract, not a +scratch object — silently turning it into rich objects would risk the +label round-trip. The bounding-surface objects live in a **separate** +collection (`mesh.bounding_surfaces`); each *references* a boundary label by +name but does not replace it. Reusing the `mesh.boundaries` name for the rich +objects would require a deliberate deprecation plan and is out of scope here. +``` + +### Bounding-surface objects + +A `BoundingSurface` binds a boundary *label* (a name in `mesh.boundaries`) to +that surface's geometry, state, and slip methods: + +```python +class BoundingSurface: + label: str # the gmsh/DMPlex boundary label ("Upper", "Lower", ...) + kind: str # "radial" | "plane" | "facet" | "free" + is_free: bool # True ⇒ restore follows the live surface, not a fixed target + # geometry it was told at construction (kind-dependent): + # radial: centre, radius plane: point, normal facet: reference_facets + + def normals(self, coords): ... # outward unit normals on THIS surface (Gamma_P1 restricted) + def tangent_project(self, coords): ... # remove this surface's normal component + def restore(self, coords): ... # snap back onto THIS surface (kind-specific) + def release(self): ... # flip rigid → free (free-surface activation) +``` + +The surface object *is* the restoration capability (the earlier +`TangentSlipProvider` folds into it). `restore` dispatches on `kind`: + +| `kind` | meshes / origin | `restore` | +|--------|-----------------|-----------| +| `radial` | `Annulus`, `SphericalShell`, `CubedSphere`, cylinders | exact `|r|` snap about the **known** centre: `centre + r̂·radius`. Concave-safe (no chord sag). Inner/outer surfaces are separate objects with their own radius. | +| `plane` | `UnstructuredSimplexBox`, `StructuredQuadBox` faces | zero the off-plane coordinate (axis-aligned ⇒ tangent-project already keeps it on the face; supports non-axis-aligned planes too). | +| `facet` | loaded-from-file / internal boundaries with no analytic form | nearest point on the surface's reference facets (the current `_nearest_on_facets_*` fallback). Convex-safe; concave bias is a documented TODO. | +| `free` | free-surface module, OR any surface that has been `release()`-d | follow the **current deformed discrete surface** (generic surface tangent), not a fixed target. `is_free=True`. | + +The object **encapsulates the geometry the constructor already knows**, so the +runtime `_is_radial_coords` guess and the per-call centre `allreduce` disappear: +`Annulus(radiusOuter, radiusInner, centre)` builds a `radial` surface on +`"Upper"` (radius `radiusOuter`) and on `"Lower"` (radius `radiusInner`) +directly. + +### Public API + +The mesh orchestrates the per-surface objects. The low-level contract is the +`(is_pinned, project)` pair the movers already consume, plus an in-place +convenience and a public registration hook (decisions confirmed in review, +2026-06-06): + +```python +# Low-level: the projector tuple the movers expect. is_pinned drives the +# solve's pinned-DOF set; project() does tangent-project + restore per surface. +is_pinned, project = mesh.boundary_slip(slip_spec, reference_coords=X0) +Y = project(Y_moved) + +# In-place convenience (built on the above) for callers that just want coords +# snapped back — e.g. a checkpoint reload, a diagnostic, the free-surface module. +mesh.project_to_slip_surface(coords, slip_spec, reference_coords=X0) + +# Public registration so users can install a custom analytic surface object +# (e.g. an ellipsoid) that the constructors don't know about. +mesh.register_tangent_slip_provider(label, surface) +``` + +`slip_spec` keeps the back-compatible forms already accepted by +`_resolve_slip`: `True`/`"ring"`/`"box"`/`"all"` (all named codim-1 +boundaries), a label name, a list of labels, or a `dict {label: snap_bool}` +(`False` = free surface, slip but do not restore). + +The two primitives are surface-level, exposed at mesh level for convenience +over a label (and reusable by checkpoints, diagnostics, the free-surface +module): + +```python +mesh.tangent_project(coords, labels) # = surface.tangent_project per label +mesh.restore_to_surface(coords, label) # = surface.restore for that label +``` + +`tangent_project` is **geometry-agnostic** — it uses the projected P1 +boundary-normal field (`mesh.Gamma_P1`), which is already smooth and +consistently oriented on curved boundaries where raw face normals are noisy. +`restore` is **geometry-specific** and dispatches on the surface's `kind`. + +### Restoration is a surface-*state* question, not just initial geometry + +A subtlety the surface model must get right (raised in review, 2026-06-06): +**the correct restoration target depends on the surface's current state, not +only on how the mesh was built.** The motivating case is a free surface on a +sphere/annulus. At construction the outer surface is rigid at `|r| = radius`, so +the `radial` restore is exact. But once that surface is `release()`-d to deform +(free-surface dynamics), the analytic radius is *no longer the surface* — +snapping returned nodes to the original `|r|` would actively fight the surface +motion. The restore must fall back to a **generic surface tangent**: keep +returned nodes on the *current* discrete surface (the deformed boundary facets), +not on the frozen analytic shape. + +So each surface object is **state-aware** along two axes: + +1. **Mode (the `kind`/`is_free` flag on the object).** A surface is *rigid* + (snap to an analytic target — `radial`/`plane`) or *free* (follow the current + deformed surface — the generic surface-tangent / `facet`-style restore + against the live boundary, `is_free=True`). `release()` flips a `radial`/ + `plane` surface to `free` **in place on the object** — no side-table to keep + in sync. This is the same generic-surface mechanism as the loaded-from-file + `facet` fallback, reached by a *state transition* rather than by mesh type. +2. **Reference.** A rigid surface restores relative to a captured reference + (`reference_coords` per adapt) for idempotence; a free surface's "reference" + is the *current* surface, re-read each call from live mesh state. + +Implication for the contract: a surface's `restore` must be able to read +**current mesh state** (its live facets / the coordinate field), not just +constants frozen at construction. The analytic kinds are the constant-target +special case; `free`/`facet` are the live-target general case behind the same +method. The free-surface case is therefore a *mode of the same surface object*, +not a parallel code path — and "one surface radial, one free" is just two +objects with different flags, which the orchestrator handles per surface. + +### Mesh-side data model + +`mesh.bounding_surfaces` is a **new** collection of `BoundingSurface` objects, +keyed by boundary label — *separate from* and *additional to* `mesh.boundaries` +(the persistent gmsh/DMPlex label `Enum`, left untouched). The objects ARE the +slip registry — behaviour and state live on them, not in a side table: + +```python +mesh.boundaries # unchanged: gmsh/DMPlex labels (persisted) +mesh.bounding_surfaces["Upper"].kind # "radial" +mesh.bounding_surfaces["Upper"].release() # → "free" (free-surface activation) +``` + +Constructors that know their geometry build `radial`/`plane` surfaces for their +labels. A label with no analytic object (mesh loaded from file, an internal +boundary, third-party mesh) defaults to a `facet` surface built from the current +boundary facets — exactly today's geometry-general path, so nothing regresses. +The surfaces are reconstructed on a checkpoint reload from the stored +construction parameters (a loaded mesh otherwise gets only the `facet` default); +because the labels themselves persist in `mesh.boundaries`/DMPlex, the +surface-to-label binding is always recoverable. +`register_tangent_slip_provider(label, surface)` lets a user install a custom +surface object (e.g. an ellipsoid) the constructors don't know about. + +### Slip-vs-pin classification (unchanged, made canonical) + +A boundary vertex **slips iff it belongs to exactly one slip surface**. +Vertices on a non-slip boundary (count 0), at a **junction** of two slip +surfaces (count ≥2 — a box corner, where the normal is ambiguous), or with a +degenerate/non-finite projected normal are **pinned**. This is the +label-driven rule already in `_build_slip_projector` (it fixed an older +topology classifier that spuriously pinned coarse-but-smooth curved rings); the +refactor promotes it to the one canonical implementation. + +## Why this is the clean-land gate + +- **Mmpde-as-default needs zero per-script slip config.** With surface objects + built at construction, `boundary_slip=True` on any analytic-boundary mesh + "just works" — no `'ring'` vs `'box'` choice, no coordinate-type guess. +- **One parallel-safe code path.** Today four movers must each keep the + owned-vertex projection + halo sync + collective centre consistent; the + refactor leaves exactly one. +- **A real seam for free surfaces.** A surface's `free` mode is where the + kinematic restore lands later, behind the *same* `mesh.boundary_slip` + interface the movers already call — no second integration. + +## Migration plan (interface first, per the branching discipline) + +Following `docs/developer/guides/branching-strategy.md` (extract the interface, +land it on `development`, keep the feature branch to implementation): + +```{note} +**Implementation reality (2026-06-07).** The unified slip projector +(`_build_slip_projector`, `_resolve_slip`, `_gamma_p1_at_vertices`, +`_nearest_on_facets_*`) lives on the mover **feature branch**, not on +`development`. On `development` `_ot_adapt.py` has only the *primitive* helpers +(`_slip_normals`, `_boundary_centre`, `_is_radial_coords`) and the movers use +their older inline slip. So step 1 on `development` is implemented as a +**self-contained, additive** API (it does not depend on the feature-branch +projector), and step 2's bit-identical claim is interpreted as +*machine-precision identical* — the analytic centre differs from the feature +branch's boundary-COM `allreduce` only at round-off. +``` + +1. **Bounding-surface objects + mesh API, additive — no behaviour change** + (lands on `development`): + - Add `mesh.bounding_surfaces`: a **new** collection of `BoundingSurface` + objects keyed by boundary label, with `kind`/`is_free` + the + `normals`/`tangent_project`/`restore`/`release` methods. **Leave + `mesh.boundaries` (the persisted gmsh/DMPlex label `Enum`) untouched.** + - Add `mesh.boundary_slip` (orchestrator), `mesh.tangent_project`, + `mesh.restore_to_surface`, `mesh.project_to_slip_surface`, and + `register_tangent_slip_provider`. + - Build `radial`/`plane` surfaces in the `Annulus`, `SphericalShell`, + `CubedSphere`, and box constructors. **Self-contained**: `radial`/`plane` + `restore` are direct (analytic centre/radius, plane projection); `normals` + reuses the primitive `_slip_normals` (Gamma_P1); a label with **no** + analytic surface is **pinned** (safe default) — `facet` restore is a + follow-up. No dependence on the feature-branch projector. + - Because the `development` movers do not call the new API, step 1 cannot + change any existing trajectory (additive). Validate with **new tests**: + constructors register the right `kind`/geometry; `radial.restore` lands + points on `|r|`, `plane.restore` on the face; `release()` flips to `free`; + `mesh.boundaries` is unchanged. +2. **Movers consume the public API** (on the mover feature branch, which has the + unified projector): replace the `_build_slip_projector` call in `mmpde`/`ot` + and the **inline** radial-snap blocks in `spring`/`ma` with + `mesh.boundary_slip(...)`. Delete the private duplicates. Re-validate the + convection harness (serial + np=5) for *machine-precision-identical* + behaviour (the centre source changes COM → analytic). +3. **Follow-up (separate work)**: the `facet` restore + its concave-bias cure + (mean-preserving / smoothness constraint — the documented TODO) and the + `free`-surface restore mode (`release()` + live-surface follow) for the + free-surface + adaptive-mesh case (cf. `project_freesurface_ale_design`). + +## Invariants the refactor must preserve + +- **Parallel safety.** Projection touches only owned vertices; the caller (the + mover) halo-syncs. Any collective (e.g. a free-surface global reduction) must + run unconditionally on every rank — the analytic surfaces remove the only + current collective (the centre `allreduce`) because the centre is a known + constant. +- **DM-stale safety.** `mesh.Gamma_P1` (the `_projected_normals` MeshVariable) + must exist *before* a mover builds its solver DM (creating that MeshVariable + mid-mover stales the DM handle — see `project_uw3_smoother_footguns`). + Building surface objects / pre-touching `Gamma_P1` at construction makes this + automatic instead of relying on `_resolve_slip`'s pre-touch. +- **Free surfaces** (`surface.is_free`, or a `dict` `False` value) slip without + restoration. +- **Reference surface.** A rigid surface's restore is relative to a fixed + reference (`reference_coords`, captured once per adapt) so repeated projection + is idempotent and does not accumulate drift. +- **Units.** Surfaces store radii/points in the mesh's coordinate units. + +## Decisions (review, 2026-06-06) + +- **Locus.** Slip behaviour + state live on **bounding-surface objects** in a + new `mesh.bounding_surfaces` collection, not a mesh-level provider table and + **not** by repurposing `mesh.boundaries` (the persisted gmsh/DMPlex labels, + left untouched). The mesh orchestrates cross-surface concerns (vertex + slip-vs-pin classification, junctions, composition). +- **API surface.** Low-level `(is_pinned, project)` (the movers' contract) **plus** + an in-place `mesh.project_to_slip_surface(coords, spec)` convenience built on + it. +- **User-registerable surfaces.** Yes — + `mesh.register_tangent_slip_provider(label, surface)` is public, so users can + install a custom surface object (e.g. an ellipsoid). +- **Land plan.** Step 1 (bounding-surface objects + mesh API, internally + delegating to the existing helpers, bit-identical) lands on `development` as + its own PR; the mover-side swap (step 2) stays on the feature branch — clean + API/impl split. + +## Still open (for review) + +1. **Naming.** Method `boundary_slip` (matches the existing kwarg) vs + `slip_project` vs `tangent_slip`; the surface class `BoundingSurface` and its + collection `mesh.bounding_surfaces` (chosen to keep `mesh.boundaries` safe); + the registration method `register_tangent_slip_provider` (now installs a + surface object — reconcile vs `register_bounding_surface`?); the `kind` + values `radial`/`plane`/`facet`/`free`. +2. **Concave non-analytic restore.** Defer the `facet`-kind concave-bias cure to + the follow-up, or block on it? (Radial surfaces take the exact analytic + branch and are immune; the bias only bites a concave *non-analytic* surface, + which no current production case hits.) + +## Roadmap: from boundary slip to a mesh-owned surface contract (2026-06-09) + +The tangent-slip contract above is the first instance of a more general idea: a +mesh keeps **declared surfaces** intact as it redistributes its nodes. This +section records the design we settled on for growing it from "the outer +boundary" to "any surface the mesh must preserve" — driven by the metric movers +(it is squarely *mesh-redistributor* work), with codim-1 **submesh extraction** +as the horizon we steer by rather than a separate effort. None of this is +implemented yet; it is the agreed direction and the constraints it must honour. + +### Principles (load-bearing) + +- **Declaration over topology, never an alternative topology.** DMPlex and its + labels are authoritative. A `BoundingSurface` only *annotates* a label the + mesh already owns ("this label of mine means a radial / plane / free + surface"); it never *defines* topology. There is nothing to keep in sync — + the same discipline that keeps `mesh.boundaries` (the persisted labelling) + untouched, promoted to a rule. *The mesh decides what is important and what + its declared objects represent.* +- **Geometry is per-surface, never per-mesh.** A spherical *regional* mesh is + the decisive case: its caps are `radial` but its great-circle side cuts are + `plane`, and the mesh's `SPHERICAL` `CoordinateSystem` is *wrong* for those + sides. There is no single "mesh geometry" to inherit. Because each label + carries its own `kind`, the heterogeneous case is correct by construction — + **nothing reads the mesh's coordinate frame, only a surface's geometry.** + This one rule disarms the r/θ/φ-on-a-plane trap, the deferred `geographic` + case, and the dimension-drop ambiguity together. +- **A submesh declares its *own* surfaces; it does not inherit the parent's.** + An internal interface becomes a bounding surface of an extracted submesh + because *topologically it now is one* — the submesh, being a mesh, declares + it. The connection is that both meshes annotate the *same persisted label* + (and may reference the same geometry object): **borrow by reference, never + re-home.** Re-deriving a surface's geometry under a dimension/coordinate + change *is* the hard part — that is what stays deferred (geometry + inheritance), and the per-surface reference is the seam that lets us tackle it + later one `kind` at a time without re-plumbing extraction. + +### Geometry-kind ⟂ capabilities + +A surface has a **geometry kind** (`radial`/`plane`/`facet`/`free`) and a set of +**orthogonal capabilities**, declared independently: + +- **`tangent_moving`** — the mover keeps nodes *on* this surface + (`tangent_project + restore`). This is the broad, near-universal requirement: + slip but stay on the surface to *preserve* it. It applies to outer + boundaries, regional edge cuts, **internal interfaces**, and free surfaces + alike. An internal interface *needs* it for the same reason an outer boundary + does, turned inward — adapt the mesh without slip-constraining the interface + and its nodes drift off it, destroying the surface you meant to preserve. +- **`extractable`** — a codim-1 submesh can be filtered from this surface. The + narrower, opt-in capability; desirable but separate from preservation. + +The build priority follows: `tangent_moving` for internal interfaces is the part +with *teeth* (correctness under adaptation); `extractable` is convenience on top. + +**Concrete first extension.** Today the mover's slip gate is `is_bnd` — only +*outer* codim-1 labels are slip-eligible. To preserve an internal interface, its +label must enter the slip set even though those nodes are topologically interior, +and `mesh.boundary_slip` projects them onto the interface's `BoundingSurface` +exactly as it does an outer ring. The per-surface orchestration ("project nodes +on surface X back onto X, pin the junctions") already does the right thing; the +only change is that the eligible-vertex set becomes *"any vertex on a +`tangent_moving` surface"* rather than *"on the outer boundary."* + +### Scope: interfaces yes, faults no + +Bounding surfaces are the named codim-1 surfaces a mesh *declares* as +actual-or-potential boundaries — outer boundaries **and** internal interfaces +(including the free surface, which is just an internal-interface surface that has +been `release()`-d to `free`). A **fault is not** one of these: it is an +internal feature represented its own way (not a subdomain boundary; material is +~continuous across it, with slip), and the registry must not absorb it. Nothing +auto-classifies an internal surface — the interface-mesh constructor declares the +interface as a bounding surface; the fault machinery declares faults its own way. + +### Declaration mechanism + +- **Built-in meshes are the worked example.** The analytic constructors register + at construction via helpers (`register_radial_surfaces`, a `plane` / + internal-interface helper to add); that constructor code is the canonical + template, because the helpers are *also* the public API a user calls by hand + after loading their own gmsh. Keep them ergonomic and obvious. +- **User gmsh is the number→name→geometry sync.** gmsh gives numbers, DMPlex + gives named-but-opaque labels, the geometry lives nowhere until UW3 declares + it. The seam already isolates the hard part: `BoundingSurface` keys off the + **label name**, never the gmsh number, so registration sits *after* the + existing numbers→names mapping (`mesh.boundaries`), on stable names. Helpers to + ease that chain are future work but bolt onto a name-based seam. + +### Persistence (checkpoint roundtrip) + +Surfaces are currently reconstructed only by re-running the constructor — a mesh +*loaded* from a checkpoint gets nothing but the `facet` default. Bounding-surface +metadata must therefore ride in the HDF5 next to the boundary-label metadata, and +reload must rebuild the objects. What is persisted is small and is *annotation, +not topology* (the DMPlex/labels roundtrip by their own mechanism; the surface +info is a sidecar keyed by label name), and it is kind-dependent: + +- `radial` / `plane` — persist the few construction scalars (centre/radius, + point/normal); exact reconstruction. +- `facet` — do not persist; it is derived from the current boundary facets, so + regenerate on load. +- `free` — persist the mode flag (+ reference if any); the geometry is live. + +A submesh roundtrips *its own* declared surfaces, consistent with "each mesh +declares its own." + +### Discoverability + +If the mesh *declares* its surfaces, the declarations must be *inspectable* — +and a checkpoint-loaded mesh must be *equally* self-describing (this is why +persistence matters, not just reconstruction-by-constructor). By examination the +mesh should answer: + +- **What surfaces do I define?** — enumerate `mesh.bounding_surfaces`, with a + human-readable summary of `label · kind · capabilities · geometry`. +- **By capability** — "which are `tangent_moving`? which are `extractable`?" — so + the mover and the submesh extractor each ask the mesh for *their* set instead + of hard-coding label names. +- **How do I access them** — the same objects carry both the operations + (normals/restore) and the access path (slip via `mesh.boundary_slip`, + extraction via `extract_surface(surface)`); discovery and use are one surface. + +### Suggested build order (smallest-first) + +1. **Registration helpers as template code** — mostly exists; add the + `plane` / internal-interface helper and register regional edge cuts as + `plane` (a correctness gap for boundary-slip on regional meshes *today*). +2. **`tangent_moving` for internal interfaces** — generalise the slip gate from + `is_bnd` to "any `tangent_moving` surface." The part with teeth. +3. **HDF5 persistence** of the analytic surface metadata for checkpoint + roundtrip; discoverability falls out of it. +4. **`extractable` + submesh re-declaration** — extraction accepts a surface and + the child re-declares its surviving labels. Geometry inheritance stays parked. +5. **numbers→names→geometry helpers** for hand-rolled gmsh — later. + +## Deferred cases (handle after the simple analytic geometries) + +- **Geographic meshes are an odd case** (flagged in review, 2026-06-06). The + `GEOGRAPHIC` coordinate system mixes a radial (depth) direction with + lon/lat surface coordinates and a non-trivial metric; "tangent to the + surface" and "the projected normal" are not the plain Cartesian operations the + `radial`/`Gamma_P1` path assumes. **Do not** try to cover it in the first cut + — get `Annulus`/`SphericalShell`/box working under the new contract first, then + design a `geographic` surface `kind` against the settled interface. Until then + geographic meshes keep the current `_is_radial_coords` → radial-snap behaviour + via the fallback path (they classify as radial today). +- **Free-surface restoration** (the surface `free` mode and the `release()` + rigid→free transition described above) is the primary follow-up + (cf. `project_freesurface_ale_design`). +``` diff --git a/docs/developer/design/exp_integrator_phase_a.png b/docs/developer/design/exp_integrator_phase_a.png new file mode 100644 index 000000000..cfbbeb16f Binary files /dev/null and b/docs/developer/design/exp_integrator_phase_a.png differ diff --git a/docs/developer/design/exp_integrator_phase_b_largedt.png b/docs/developer/design/exp_integrator_phase_b_largedt.png new file mode 100644 index 000000000..8886782bb Binary files /dev/null and b/docs/developer/design/exp_integrator_phase_b_largedt.png differ diff --git a/docs/developer/design/exp_integrator_phase_b_square.png b/docs/developer/design/exp_integrator_phase_b_square.png new file mode 100644 index 000000000..4fce7217b Binary files /dev/null and b/docs/developer/design/exp_integrator_phase_b_square.png differ diff --git a/docs/developer/design/exp_integrator_phase_b_vardt.png b/docs/developer/design/exp_integrator_phase_b_vardt.png new file mode 100644 index 000000000..ce653943e Binary files /dev/null and b/docs/developer/design/exp_integrator_phase_b_vardt.png differ diff --git a/docs/developer/design/exp_integrator_phase_b_yield.png b/docs/developer/design/exp_integrator_phase_b_yield.png new file mode 100644 index 000000000..dddb3ace3 Binary files /dev/null and b/docs/developer/design/exp_integrator_phase_b_yield.png differ diff --git a/docs/developer/design/fault-refinement-simplification.md b/docs/developer/design/fault-refinement-simplification.md new file mode 100644 index 000000000..808ad0844 --- /dev/null +++ b/docs/developer/design/fault-refinement-simplification.md @@ -0,0 +1,650 @@ +# Fault refinement — the simplification + +```{note} +Design note, 2026-05-28. Captures the convergence after the +feature/elliptic-ma fault-meshing work: one mover, one metric form, one +slip, 2D *and* 3D. The pieces this collapses (the anisotropic tensor +mover and the analytic-Eulerian per-segment machinery) remain present +for the moment but are scheduled for deprecation. +``` + +## The recipe + +```python +import sympy, underworld3 as uw + +rho_T = uw.meshing.metric_density_from_gradient(mesh, T, metric_choice="arc-length") +rho_F = uw.meshing.fault_comb_metric(mesh, faults, cell_size=dx, n_across=N) + +uw.meshing.smooth_mesh_interior( + mesh, method="ma", + metric=[(rho_T, 1.0), (rho_F, w_F)], # composable list (max-on-excess) + boundary_slip=True, # generic topology slip — required + method_kwargs=dict(n_outer=1, n_picard=25)) # single-shot +``` + +One mover (single-shot Monge–Ampère), one metric form (scalar density), one +composition operator (weighted max on the excess), one slip (topology-based +vertex normals). Works in **2D and 3D**, on Cartesian boxes, annulus, +sphere, polyhedra, curved surfaces. + +```{note} +``boundary_slip=True`` is part of the recommended recipe, not optional. For +any feature that **touches the boundary** (a thermal BL that runs full +width, a fault that reaches the wall, …), pinning the boundary effectively +wastes the budget at the edges: the refined band visibly fades as it +approaches the wall. With the generic topology slip enabled, boundary face +nodes slide along the face to cluster where the metric demands them, and +the refinement runs uniformly to the wall (corners stay pinned, box +shape exactly preserved). See ``fault_compose_demo2.py``. +``` + +## Why each piece + +### Single-shot MA and what `n_outer` actually does + +`smooth_mesh_interior(method="ma", n_outer=1)` is the Caffarelli-clean +Monge–Ampère map: one solve, untangled by construction, **composable** +(see below — repeated calls compose correctly toward the equidistribution +fixed point). For most metrics this is also the right default: one +solve gives a clean band, and `n_outer=1` is what `fault_metric(...)` +wraps. + +`n_outer>1` performs `n_outer` outer Picard iterations *within a single +`smooth_mesh_interior` call*, each recomputing the source density on +the current deformed mesh. With the lumped-V projection fix (see the +"Composable iteration" section below), `n_outer>1` is now equivalent +to calling the mover `n_outer` times in sequence — both paths converge +to the same equilibrium. The "patch-aware composition" language in +the original design note was describing the *intent*; the original +implementation didn't reliably deliver it because of the bug fixed in +this update. + +**The honest update**: iterated calls now compose correctly, so the +choice between "call `smooth_mesh_interior` once with `n_outer=k`" +and "call it `k` times with `n_outer=1`" is a stylistic one — same +trajectory, same equilibrium. Use whichever fits the surrounding +code structure. The pre-placement recipe below uses repeated calls +because it varies the *metric width* between calls. + +For composed metrics including a Lagrangian field (gradient(T), a +`Surface.distance`-field comb): keep `n_outer=1` and don't iterate +manually either — the feature would convect each pass and the bands +would smear. The honest path to more refinement there is finer base +mesh or `mesh.adapt`. + +**Don't use `target_side_rho=True`.** It exists in `_winslow_elliptic` +as an experimental option (query ρ at the target position +`x + ∇φ(x)` rather than the source). The Picard fixed-point coupling +is much tighter than the default and the default `n_picard=25` is +typically under-converged (it needs ~100+ iters for moderate-to-strong +demand) — silently producing inconsistent results. Even when fully +converged, it doesn't deliver sharper realised refinement than iterated +source-side. Treat it as an internal experiment, not a user-facing +lever. + +### Scalar comb metric + +`fault_comb_metric(mesh, faults, cell_size=dx, n_across=N)` places narrow +teeth at `d = 0, dx, 2 dx, …` from each fault's distance field. +Equidistribution drops a node row at each tooth → evenly-spaced rows ⇒ a +band of `~ N` roughly-uniform cells across each fault, **with the `d=0` +tooth pinning a row on the fault line** (so close faults centre to +~0.0002 — better than h-adapt with `mesh.adapt`). + +For 2D faults the per-segment min-distance is analytic. For curved or +**3D triangulated** fault surfaces (`FaultSurface.compute_distance_field`, +kdtree-based), the comb is built directly on the precomputed distance +**field** — segment-count-independent JIT cost, and the natural input +for 3D where analytic point-to-triangulated-surface distance is hard. + +### Composable list of metrics + +`smooth_mesh_interior(metric=[(m_i, w_i), …])` composes internally via + +$$\rho_{\text{combined}}(x) = 1 + \max_i\, w_i\,\big(\rho_i(x) - 1\big)$$ + +— "refine wherever any feature demands it," with weights scaling each +feature's demand cleanly. Scalar densities compose by `max` trivially; +metric *tensors* would need Alauzet metric intersection (much more +involved) — another reason scalar-MA is the convergence point. + +### Generic topology-based tangent slip + +`_boundary_vertex_normals(mesh)` computes outward unit normals at each +boundary vertex *geometrically* from the cell coordinates (boundary +facets identified topologically, normals area-weighted averaged). It +classifies each vertex as **face-slip** (all incident facet normals +within ~15° of the average — slides tangentially) or **pinned** +(corners, 3D edges between faces). Works on **any** simplicial mesh. + +This replaces the old `Gamma_P1`-based slip, which evaluated PETSc's +`petsc_n` quadrature symbol at *vertices* (undefined off boundary +quadrature points) — radial mesh classes worked around it by +redefining `Gamma` as the analytic radial unit vector, but Cartesian +got garbage normals and was silently pinned. + +### Dimension-general MA + +`_winslow_elliptic` is now dimension-general (bit-identical at `cdim=2`): + +* **Normalisation `c`** branches on the source's leading term: + `c = 1/⟨b^{-1/2}⟩²` for the 2D convex radical, `c = 1/⟨b^{-1}⟩` for + the 3D simple Picard. Wrong `c` made the source non-zero-mean and the + pure-Neumann φ-Poisson unsolvable (the constant nullspace fixes + *solution* ambiguity, not *RHS* inconsistency) — the actual cause of + the previous 3D failure. + +* **3D source**: `f_src = tr(H_s) + g − det(I+H_s)` + (`H_s` symmetrised), restoring the 2×2 principal-minor terms the old + `(g−1) − det(H)` dropped in 3D. Reduces to the 2D simple-Picard form + exactly. + +* **Tet signed-volume backtrack**: `_tri_cells` returns `None` for tets, + so 3D previously had no anti-tangle guard. Added `_tet_cells` + + `_signed_volumes` and a tet branch in the backtrack. + +Validated on a 3D slab and spherical-shell adapt (refines toward the +feature, 0 inverted tets) and a 3D disk fault (the recipe above). + +## What this collapses + +The following remain in the codebase for the moment but are scheduled for +deprecation once external users have migrated: + +| Component | Replaced by | +|---|---| +| `_winslow_anisotropic` (anisotropic tensor mover) | single-shot MA + comb | +| `fault_metric_tensor` (analytic 2×2 supplied tensor) | `fault_comb_metric` | +| `_winslow_anisotropic.supplied_D` entry point | (no need — comb is scalar) | +| Per-segment analytic min-distance for curved faults | `Surface.distance` / `FaultSurface.compute_distance_field` | +| Ring-projection slip on annulus + geometric box-slip | topology-based generic slip | + +The `fault_metric` facade keeps `method="anisotropic"` and `method="adapt"` +(MMG) for the moment as documented alternatives — the recommended default +is `method="ma"`. + +## Composable iteration: lumped V_T projection + +```{note} +Update 2026-05-28 (late session). Replaces the earlier +`_patch_volumes` source density in `_winslow_elliptic`. Makes +repeated calls to `smooth_mesh_interior(method="ma", ...)` +properly **composable**, which in turn unlocks the +*pre-placement* recipe in the next section. +``` + +### The bug that wasn't documented + +`_winslow_elliptic` solves the convex-branch Picard for the +Caffarelli-Brenier displacement potential. The right-hand side +contains a **source density `V(x)`** representing the current mesh — +in continuous form `V` would be `det(I + ∇²φ_current)`, i.e. the +local Jacobian of the deformed mapping at every point. Per-vertex +discretisation of `V` is what tells the solver "this region is +already partially adapted, don't pull it further." + +The previous code did one of two things: + +```python +if tris is not None and n_outer > 1: + patch = _patch_volumes(...) # Σ_{T ∋ i} |T| / 3 per vertex + patch /= float(np.mean(patch)) +else: + patch = np.ones(n_verts) # assume mesh is uniform +``` + +Both were wrong, in different ways: + +1. **`patch = ones` at `n_outer=1`** (the default) — assumed the input + mesh is uniform regardless of how it actually looked. Calling + `smooth_mesh_interior` a second time from a previously-adapted + mesh produced the same displacement that the first call would + have produced from cold, applied on top of the existing + deformation. Composition broke: every call started from scratch + conceptually, so iterated calls compounded biases instead of + correcting them. This is why the design note above had to + recommend `n_outer=1` "single-shot, don't compose." + +2. **`_patch_volumes` at `n_outer>1`** — returned `Σ_{T ∋ i} |T| / 3`, + which is the **lumped mass diagonal** `M^lumped_ii = ∫ ψ_i dx`, + an *integral* with units of area. The code then used it as a + *density*. On an unstructured Delaunay mesh of equal-area cells + `M^lumped_ii = d_i · |T_0| / 3` (proportional to vertex valence + `d_i = 5..7`), so the equation saw a ~30 % spurious source + non-uniformity from FE bookkeeping, not from any actual mesh + deformation. The conservative behaviour of `n_outer>1` under + the old code was the mover *trying to flatten that valence + noise* and giving up. + +### The fix + +`V(x)` is fundamentally a **cell** quantity: `V_T = |T|` in 2D, +`|Tet|` in 3D. The Caffarelli equidistribution invariant is +*cell-wise*: at equilibrium `ρ_T · |T| = const` over all cells. +The FE-natural projection of this cell field into the P1 +`vol_field` storage that the solver expects is a **lumped L2 +projection**: + +$$V_i = \frac{\sum_{T \ni i} V_T\,|T| / k} + {\sum_{T \ni i} |T| / k} + = \frac{\sum_T |T|^2}{\sum_T |T|}$$ + +(`k = 3` in 2D, `k = 4` in 3D — the per-vertex weight per incident +cell). This is the *area-weighted average of incident cell +volumes*, strictly local, no neighbour mixing, valence-independent +on uniform meshes (`Σ|T|² / Σ|T| = |T_0|` exactly when all `|T|` +are equal regardless of valence). + +It is implemented inline in `_winslow_elliptic` with two +`np.add.at` accumulators (numerator and denominator) and one +division. + +```{note} +An intermediate attempt used the consistent-mass `uw.systems.Projection` +to project `V_T → vol_field`. That introduces an intrinsic L2 +smoothing kernel of ~one element width. Cell-density signals +narrower than the kernel get smoothed into a halo around refined +bands, and the next solve reads the halo as "over-refined" and +*undoes* the refinement — iteration becomes regressive. The +lumped form has zero kernel scale and behaves correctly. +``` + +### What this changes for users + +The mover is now **composable**: each call to +`smooth_mesh_interior(method="ma", ...)` produces a displacement +*from the actual current mesh state* toward the target metric. +Repeated calls iterate the same fixed point, with `|Δo|` decreasing +monotonically. Single-shot remains the recommended **default**; +iterated calls are now safe to use when more refinement is wanted +than a single solve delivers, and — more importantly — when the +*metric itself changes between calls*. That second case is the +pre-placement recipe below. + +```{note} +**TODO (parallel)**: the lumped projection accumulators are +rank-local (`np.add.at`). At MPI partition boundaries, vertices +owned by one rank under-count contributions from cells owned by +neighbouring ranks. Same parallel deficit as the old +`_patch_volumes` had. The fix is to assemble the two numerators +into PETSc Vecs with `ADD_VALUES` so the assembly ghost reduction +sums them correctly. Required before parallel use of the MA mover +on adapted meshes. +``` + +## Pre-placement and redistribution recipe + +```{note} +Recommended when single-shot MA leaves the band off-line — the +classic case is two or more faults closer to each other than the +band width can comfortably resolve from cold. +``` + +### Why single-shot is centroid-biased for close faults + +For two faults at half-separation `a` and a metric built as a +**sum** of per-fault Gaussians, + +$$\rho(x) = 1 + A\,\sum_i \exp(-d_i(x)^2 / w^2)$$ + +the two Gaussians overlap when `w > a√2`. Past that crossover the +sum has a **single maximum at the midpoint** between the faults +rather than two maxima on the faults. The mover faithfully +equidistributes to whatever the metric's actual maximum is, and +ends up clustering nodes at the centroid — not because of any +mover deficiency, but because the metric construction *told it +to*. With `a = 0.030`, `w_crit = 0.030√2 ≈ 0.042`; anything at +or above the crossover puts the metric peak in the gap. + +Starting cold from a uniform mesh and applying any single-call +narrow-`w` solve produces a converged equilibrium where `ρ · V` +is balanced even though many refined cells sit in the gap and not +on the lines — a *degenerate* equidistribution. With the mover +now composable (above), iteration on a fixed metric stays at this +equilibrium; the local minimum of the equidistribution functional +is genuine. + +### The recipe — MAX, wide pre-place, narrow redistribute + +Use a **max** combination of per-fault Gaussians, not a sum: + +$$\rho(x) = 1 + A\,\max_i \exp(-d_i(x)^2 / w^2) + = 1 + A\,\exp(-d_{\min}(x)^2 / w^2)$$ + +Pick the closer fault at every point. The metric is constant +amplitude `A` on any fault, falls off independently to either +side, and **no centroid pile**, however wide `w` is. + +Then a two-stage iterated call: + +```python +# Stage 1 — wide pre-place (a few iters) +for _ in range(n_wide): + rho = max_of_gaussians(mesh, faults, w=w_wide) + smooth_mesh_interior(mesh, method="ma", metric=rho, + boundary_slip=True, + method_kwargs=dict(n_outer=1, n_picard=25)) + +# Stage 2 — narrow redistribute (more iters) +for _ in range(n_narrow): + rho = max_of_gaussians(mesh, faults, w=w_narrow) + smooth_mesh_interior(mesh, method="ma", metric=rho, + boundary_slip=True, + method_kwargs=dict(n_outer=1, n_picard=25)) +``` + +The wide stage pre-clusters cells *around the entire fault +system* without piling them in any specific spot (the MAX +amplitude is flat over the broad neighbourhood). The narrow stage +inherits a mesh that *already has refined cells in the right +neighbourhood* of every fault, and the equidistribution at the +narrow width simply pulls those cells onto the lines. + +### The width-vs-separation knob + +`w_wide` is the single design knob and it scales with the **fault +separation**, not with the mesh resolution: + +| `w_wide / a` (a = half-separation) | Behaviour | +|---|---| +| ≈ 1 (just the gap) | Mild improvement over cold-narrow; still some centroid bias | +| **≈ 4 (≈ 2× full separation)** | **Sweet spot — bands land on lines to ≤ 1/10 cell** | +| ≫ 4 (very wide) | Refinement too diffuse; pre-placement doesn't localize | + +Two-fault test case (gap `2a = 0.060`, target band `w_narrow = 0.015`, +60×60 base mesh): + +| Schedule | `f0` offset | `f1` offset | +|---|---|---| +| Cold → `w=0.015` × 10 | −0.0109 | +0.0103 | +| `w=0.060` × 2 → `w=0.015` × 8 (MAX) | −0.0040 | +0.0021 | +| **`w=0.120` × 4 → `w=0.015` × 8 (MAX)** | **−0.0005** | **−0.0014** | +| `w=0.200` × 4 → `w=0.015` × 8 (MAX) | −0.0069 | +0.0035 | + +`w_wide = 0.120` (`= 2 × 0.060`, i.e. `2 × full separation`) wins: +both bands within `≤ 8 %` of one mesh cell of the actual lines. +The recipe genuinely *places* nodes on the close-paired fault +lines that cold-narrow iteration could not reach. + +### Convergence diagnostic and why `n_picard=25` is the right default + +The equation-natural residual is the **coefficient of variation of +$\rho \cdot V$ over cells**: + +$$\mathrm{cv}(\rho V) = \frac{\mathrm{std}(\rho_T \cdot |T|)} + {\mathrm{mean}(\rho_T \cdot |T|)}$$ + +At equilibrium $\rho \cdot V = K$ constant, so $\mathrm{cv}(\rho V) = 0$. +On a discrete mesh against a continuous metric, the minimum achievable +$\mathrm{cv}$ is non-zero — but the *relative* value across iterations +and schedules cleanly distinguishes which equilibrium the mover settled +into. For the two-fault recipe at gap=0.060, the centroid-local-minimum +sits at $\mathrm{cv} \approx 1.07$, the bands-on-lines equilibrium at +$\mathrm{cv} \approx 0.79$. + +```{important} +Crucial finding: **the inner Picard iteration count is not a "more is +better" knob**. At `n_picard=50` and `n_picard=200` the trajectory +becomes *bit-identical* (inner Picard is fully converged at 50) — but +the recipe **gets stuck in the centroid local minimum and never +escapes**. At `n_picard=25` the inner Picard is mildly under-converged, +and that residual non-equilibrium acts as **numerical annealing**: it +occasionally kicks the system out of shallow local minima into deeper +ones. The bands-on-lines result we report for the two-fault gap=0.060 +case is *only* reachable with `n_picard=25`; tightening to 50+ locks +the centroid-bias floor. + +This is counter to the usual "tighter inner solve is better" intuition +and is the reason `n_picard=25` was chosen as the default in +`smooth_mesh_interior(method="ma", ...)`. **Don't increase it for +"convergence."** +``` + +The geometric `|Δo|` we used in the diagnostic plots is a poor stopping +signal because it reads ≈ 0 immediately when the mover hits the +*centroid* local minimum (locally converged, just to the wrong place). +`cv(ρV)` reads ≈ 1.07 there and only drops to ≈ 0.79 when the recipe +escapes — so it's a much better measure of actual equidistribution +quality. + +A practical stopping rule: + +```python +prev_cv = float("inf") +plateau = 0 +for outer_iter in range(MAX_OUTER): + smooth_mesh_interior(mesh, method="ma", metric=rho_target, + method_kwargs=dict(n_outer=1, n_picard=25)) + cv = cell_cv_of_rho_V(mesh, rho_target) + if abs(prev_cv - cv) < 0.001 * cv: + plateau += 1 + if plateau >= 3 and outer_iter > MIN_OUTER: + break + else: + plateau = 0 + prev_cv = cv +``` + +`MIN_OUTER` should be at least the wide-stage iteration count plus a +few — the system has to be given a chance to escape the wide-stage +local minimum. + +### When this matters + +* Stationary fault-pair problems — geometry once, iterate to + equilibrium, use the resulting mesh as the substrate for the + rest of the simulation. +* Moving-fault problems — the long-term aim. When the fault + positions evolve, redoing the schedule each adaptation step is + expensive. *Open question (next session)*: can the converged + equilibrium for time `t` serve as the wide-pre-placed state for + time `t + Δt`? The mover being composable suggests yes — the + narrow-stage iteration should be sufficient to track small + motion. + +* Faults farther apart than `w_wide` becomes irrelevant: single-shot + with `n_across = 1` (a single Gaussian per fault) is already + centred on the line. The pre-placement recipe is specifically + for the close-paired regime where overlap matters. + +## Update 2026-05-29: smooth-aid, plain Picard, fat band for moving faults + +```{note} +This section supersedes the earlier "n_picard=25 is a feature" and +"Anderson acceleration" framings further up. Those findings were +*directionally* correct (under-converged Picard helps escape local +minima, Anderson does accelerate per-iteration descent) but the +recipe that actually works robustly across geometries — and so +qualifies as a *user-facing default* — turns out to be different. +``` + +### The mover misses one fault without a smooth aid + +The sharpest finding of this update. Cold-start with a single sharp +narrow Gaussian per fault (the previous design), and no wide +pre-pass, the mover **catastrophically misses one of two close +faults** — only the first one gets a refinement band, the second +is uniformly meshed. Adding a low-amplitude wide Gaussian on top of +the sharp narrow one provides a non-trivial $\nabla \rho$ everywhere +in the fault neighbourhood. With the smooth aid, both faults get +their bands; without it, the mover's equidistribution invariant is +satisfied by a one-band solution. + +The recommended target metric is therefore **always** a sum of two +Gaussians: a sharp peak for localisation, a smooth halo for "find +the fault" direction: + +$$\rho(x) = 1 + + A_{\text{sharp}} \cdot \max_i \exp(-d_i(x)^2 / w_{\text{target}}^2) + + A_{\text{smooth}} \cdot \max_i \exp(-d_i(x)^2 / w_{\text{smooth}}^2)$$ + +With $A_{\text{sharp}} \approx 6$, $A_{\text{smooth}} \approx 2$, +$w_{\text{smooth}} \approx 5 \cdot w_{\text{target}}$ as +reasonable defaults. + +### Plain Picard is geometrically equivariant; Anderson isn't + +Anderson acceleration on the outer fixed-point map gives a 3× per-step +speedup *on a fixed geometry* but is **not equivariant under +translation of the fault** — the basin Anderson converges into depends +on the post-phase-1 cell distribution, which depends on fault position. +A uniform translation of all faults that should give a uniformly +translated solution does not, with Anderson: shifted geometry can +land at $\mathrm{cv} \approx 1.08$ where the original geometry lands +at $\mathrm{cv} \approx 0.39$. The deeper basin exists for the shifted +geometry too, Anderson just can't find it. + +**Plain Picard does not have this problem.** On both initial and +shifted geometries it reaches the same basin ($\mathrm{cv} \approx +0.57$), takes 10–15 outer iterations to plateau, and is the +*reliable* default. Anderson is opt-in for speed when the user can +afford to verify it reached a good basin. + +The displacement residual $\|X_{k+1} - X_k\|/(\sqrt N \cdot h)$ is +the natural fixed-point convergence signal — clean monotone descent +under plain Picard, machine zero at the fixed point — and is the +right stopping criterion. `cv(ρV)` is a *quality* measure (lower = +deeper basin) and is useful to compare which basin you landed in but +*not* a convergence test. + +### The right recipe (current state) + +```python +def fault_metric_iterate(mesh, faults, w_target, *, + w_smooth=None, + amp_sharp=6.0, amp_smooth=2.0, + w_wide=None, + n_pre=4, n_combined=16, + tol_disp=1e-4): + """Recommended recipe for two-fault (and multi-fault) refinement. + + Phase 1 — marshalling (wide sharp pre-pass): a wide MAX-of-Gaussians + metric at sharp amplitude pulls cells into concentrated clusters + around each fault. Not a smooth metric; the concentration is + the *point*. + + Phase 2 — localisation (sharp + smooth combined target): the sharp + narrow peak localises onto each line; the smooth halo provides + non-trivial gradient direction everywhere in the fault + neighbourhood (without it, cold-start can miss a fault entirely). + Plain Picard, no Anderson — geometric equivariance > speed. + + Termination: |ΔX|/(√N · h) < tol_disp, OR n_combined iters exhausted. + """ + if w_smooth is None: + w_smooth = 5.0 * w_target + if w_wide is None: + # Heuristic: 2 × estimated fault separation + w_wide = 0.120 # for the canonical test geometry + + # Phase 1 — wide sharp pre-pass + rho_pre = max_of_gaussians(mesh, faults, w_wide, amp=amp_sharp) + for _ in range(n_pre): + smooth_mesh_interior(mesh, method="ma", metric=rho_pre, + method_kwargs=dict(n_outer=1, n_picard=10)) + + # Phase 2 — sharp + smooth combined, plain Picard + rho_target = ( + max_of_gaussians(mesh, faults, w_target, amp=amp_sharp) + + max_of_gaussians(mesh, faults, w_smooth, amp=amp_smooth)) + X_prev = mesh.X.coords.flatten() + for k in range(n_combined): + smooth_mesh_interior(mesh, method="ma", metric=rho_target, + method_kwargs=dict(n_outer=1, n_picard=10)) + X = mesh.X.coords.flatten() + h = median_min_edge(mesh) + disp = np.linalg.norm(X - X_prev) / (np.sqrt(len(X) // 2) * h) + if k > 4 and disp < tol_disp: + break + X_prev = X +``` + +### Moving faults: fat band + deferred re-meshing + +For a fault that moves through several mesh-cell widths per simulation +step, the realistic strategy is **not** to re-mesh every step but to +build a refinement band wide enough to contain the fault over multiple +timesteps, then re-mesh only when the fault is about to exit. Picking + +$$w_{\text{target}} \approx v_{\text{fault}} \cdot \Delta t_{\text{remesh}}$$ + +(where $v_{\text{fault}}$ is estimated fault drift speed per timestep +and $\Delta t_{\text{remesh}}$ is the desired re-mesh interval in +timesteps) gives a fat refined band that the fault stays within for +$\Delta t_{\text{remesh}}$ steps. Trade-off: a 1.8 × $h$ wide band +(instead of sub-element) buys ~3 timesteps of fault motion at the +cost of a slightly more diffuse band on the t=0 fault (offset +~1/2 cell instead of ~1/10 cell). Total cost goes from ~20 mover +calls per timestep to ~7 amortised — a ~3× speedup. + +Warm-starting from the previous timestep's converged mesh does **not** +work as a substitute. The cells inherit the old fault positions and +plain Picard from that state finds a *different, suboptimal* local +fixed point rather than tracking the moving fault. Cold restart per +re-meshing event with plain Picard is the reliable approach. + +### Future: SNES wrap with approximate Jacobian + +The remaining major efficiency lever — left as a follow-up session — +is wrapping the fixed-point map $F(X) = X - \mathrm{mover}(X)$ in +PETSc's `SNES` framework, with either matrix-free JFNK +($J \delta X$ via finite-difference) or an approximate analytic +Jacobian using the per-vertex $\partial V / \partial X$ from the +lumped-L2 projection. Expected gains: quadratic convergence rate +near the fixed point, line search for global robustness, and +standard SNES tooling for convergence tests. The mesh deformation +inside the outer loop is what makes the present Picard slow — folding +it into a Newton step is the natural next move. + +## Honest limits + +* **Budget cap**: `r-adapt` (any mover, including MA) redistributes a *fixed* + set of nodes — `cell_size` in `fault_comb_metric` is a *target*, not a + guarantee. The realised cell sizes are roughly `~1.5–2.5×` finer than the + base mesh per feature. To honour an absolute `cell_size`, use + `mesh.adapt` (MMG) via `fault_metric(method="adapt")` — but that *adds* + nodes (topology changes, disturbing particle workflows). + +* **Composed multi-feature budgets compete**: composing gradient(T) with a + fault sends a fixed budget over two extended demands. Weights tune + *who* wins; the base mesh resolution controls the absolute resolution + each can reach. + +* **Multi-iteration metric convection**: at `n_outer>1` the MA mover + re-queries the target metric on the deformed mesh. Analytic metrics + re-evaluate correctly (Eulerian); a frozen *field* metric (the field + comb) convects and degrades. The recommended single-shot recipe + sidesteps this entirely. + +* **3D MA is the simple Picard, not a convex branch**: it converges + cleanly on gentle metrics (validated on the slab, sphere shell, and + disk fault) but could be fragile on very strong/sharp ones. The 2D + convex-branch (BFO) path stays in place at `cdim=2`. + +## Migration + +For users of the now-deprecated paths: + +* `smooth_mesh_interior(method="anisotropic", supplied_D=M, ...)` → + `smooth_mesh_interior(method="ma", metric=fault_comb_metric(...))` + (or via the list-of-metrics composition). + +* `fault_metric_tensor` → `fault_comb_metric` (or `fault_metric(method="ma", ...)`). + +* Hand-built `sympy.Max(...)` composition → pass `metric=[m1, m2, …]` + to `smooth_mesh_interior`. + +* Custom box-face slip code → just enable `boundary_slip=True`; the + generic slip handles any geometry. + +## References + +* `src/underworld3/meshing/surfaces.py` — `fault_metric_tensor`, + `fault_comb_metric`, `fault_metric`, `compose_metrics`. +* `src/underworld3/meshing/smoothing.py` — `_winslow_elliptic` (now + dimension-general), `smooth_mesh_interior(metric=[...])`. +* `src/underworld3/meshing/_ot_adapt.py` — `_boundary_facets`, + `_boundary_vertex_normals`, generic `_build_slip_projector`. +* `tests/test_0762_fault_metric_tensor.py` — 17 tier-A tests locking + the new layer. diff --git a/docs/developer/design/fmg-checkpoint-hierarchy.md b/docs/developer/design/fmg-checkpoint-hierarchy.md new file mode 100644 index 000000000..67bed838d --- /dev/null +++ b/docs/developer/design/fmg-checkpoint-hierarchy.md @@ -0,0 +1,117 @@ +--- +title: "Persisting the FMG mesh hierarchy across checkpoints" +--- + +# Persisting the geometric-multigrid hierarchy across checkpoints + +## Problem + +A mesh built with `refinement=N` carries a geometric refinement hierarchy in +`mesh.dm_hierarchy`, which the Stokes/scalar/vector solvers use for geometric +**Full Multigrid (FMG)** — the anisotropy-robust preconditioner of choice on +adapted meshes. But the checkpoint load path (`_from_plexh5`) reconstructs only +the single saved DMPlex, so a **reloaded mesh has `dm_hierarchy = [dm]`** (one +level) and FMG silently falls back to GAMG after a restart. This note records the +design that restores the hierarchy on reload, and the experiments that shaped it. + +## Why store the coarse levels (not reconstruct, not refine-from-label) + +Several approaches were prototyped and rejected: + +- **Reconstruct the coarse mesh from a per-node "level" label.** The coarse + *topology* is recoverable bit-exact from labelled nodes (the all-midpoint + "central" fine cells map 1:1 to coarse cells). **But** a coarse DMPlex built + from scratch (`createFromCellList`) does not reproduce the original's internal + cone-orientation / edge ordering, and PETSc's nested multigrid interpolator + needs the **canonical `refine()` numbering**. Splicing a reconstructed coarse + in throws `PETSc has generated inconsistent data` (err 77). +- **Store only the coarsest level + `refine()` back up on reload.** Works for the + hierarchy, but then the rebuilt fine has to be reconciled (numbering + field + data) with the saved deformed mesh — fragile. + +The winning insight: a **loaded** coarse DM preserves the canonical numbering +(`topologyLoad` is faithful), so it can be `setCoarseDM`-linked directly under the +working fine and FMG just works — **no refine, no node-moving, no reconstruction**. +This is exactly the live `clone_dm_hierarchy` pattern with *load* swapped for +*clone*. Validated: `refine(stored L0) == saved fine` bit-exact in 2D and 3D, and a +reloaded hierarchy drives `pc_type=mg` to convergence. + +## On-disk format: a single coarsest sidecar + +PETSc's `HDF5_PETSC` `DMView` writes to fixed top-level groups (`/topology`, +`/geometry`, `/labels`) — it is **not namespaced by DM name**. Writing a second +DMPlex into the same file (PETSc viewer append, *or* an h5py-injected subgroup +that the PETSc reader then ignores) corrupts the file (a reload BUS-errors). So +the hierarchy is stored in **one extra single-DM file** beside the main +checkpoint, holding only the **coarsest** level: + +``` +mymesh.h5 # the working/fine mesh (unchanged, fully compatible) +mymesh.hierarchy.L0.h5 # coarsest level only +``` + +The intermediate coarse levels are not stored — on reload they are rebuilt by +`refine()`-ing the coarsest `N-1` times (they come back canonically numbered, +which is all the co-located nested interpolation needs). The main file's +`metadata` group gains `hierarchy_coarse_levels = N-1` (the refinement depth). +Old checkpoints (attribute absent) and plain meshes (no hierarchy) write no +sidecar and reload exactly as before. + +## Reload and the link-free working `dm` + +On reload the coarse levels are loaded and spliced: +`dm_hierarchy = [L0, …, L_{N-2}, fine]`, linked with `setCoarseDM`. One subtlety: +the mesh's **working `self.dm` must be a link-free clone** of the finest level +(mirroring the `refinement` construction branch). If `self.dm` itself carries a +coarse-DM link, `mesh.update_lvec()`'s `createFieldDecomposition` recurses into +the 0-field coarse levels and fails (`requested fields 1 > DM fields 0`). The +linked hierarchy lives in `dm_hierarchy`; the solver clones it +(`clone_dm_hierarchy`) for its own multigrid setup. + +## Parallel: co-location via the Simple partitioner + +Works in serial **and** parallel through the same reload path. The hazard in +parallel is that the coarse sidecars and the fine reload on **independent +partitions**; linking incompatibly-partitioned levels sends the interpolator into +a cross-rank point-location spin (observed before the fix: np=2, rank 0 at 99% CPU +indefinitely, rank 1 idle). + +The fix needs no custom partition math. The fine carries the **canonical +refinement numbering** — coarse cell `c`'s children are fine cells +`c·numSubcells + r`, laid out contiguously right after `c`. So if the fine *and* +every coarse level are distributed with PETSc's **Simple** partitioner (equal +contiguous splits of `[0, Ncells)`), the fine split at `k·Nf/p` lines up with the +coarse split at `k·Nc/p` (since `Nf = numSubcells·Nc`): **each rank's coarse cells +and their fine children land on the same rank.** The multigrid interpolation is +then rank-local — no cross-partition communication, no hang — and the levels are a +genuine per-rank refinement, so the exact **nested** interpolator applies (the fine +levels are flagged via `DMPlexSetRegularRefinement`). + +Trade-off: hierarchy meshes reload with a Simple (contiguous) partition rather than +the default graph partition. For refinement meshes the canonical ordering is +reasonably coherent, and field reload is coordinate-matched (partition-agnostic), +so correctness is unaffected; partition-quality tuning can come later. Plain +(non-hierarchy) meshes are untouched — they keep the default partitioner. + +## Implementation + +All in `src/underworld3/discretisation/discretisation_mesh.py`: + +- `_hierarchy_sidecar_name()` — sidecar path convention. +- `Mesh.write()` — writes `metadata/hierarchy_coarse_levels` and one sidecar + holding the coarsest level (collective). +- `Mesh.__init__` `.h5` branch — loads the coarsest sidecar and rebuilds the + intermediate coarse levels by `refine()` (serial and parallel), stashing the + list on `self._sidecar_coarse_levels`. +- `Mesh.__init__` hierarchy section — distributes fine + coarse with the Simple + partitioner (co-location), splices them under the working dm, flags the fine + levels as regular refinements, re-establishes the link-free clone. +- `petsc_dm_{set,get}_regular_refinement` in `cython/petsc_discretisation.pyx` — + wraps `DMPlexSetRegularRefinement` (not exposed by petsc4py) so reloaded levels + take the exact nested interpolation path. + +No new user-facing surface: the same `Mesh(file)` reload transparently restores the +hierarchy when the checkpoint has one, and behaves exactly as before when it does +not. + +Tests: `tests/test_0004_checkpoint_fmg_hierarchy.py`. diff --git a/docs/developer/design/in_memory_checkpoint_design.md b/docs/developer/design/in_memory_checkpoint_design.md new file mode 100644 index 000000000..b705efd1c --- /dev/null +++ b/docs/developer/design/in_memory_checkpoint_design.md @@ -0,0 +1,505 @@ +# In-memory checkpoint as a general UW3 capability + +Design note. Spun off from the deformable-surface architectural +discussion (2026-05-11) as a self-contained capability that is bounded, +useful in its own right, and not specifically tied to free-surface code. +The companion note that motivated this is +`docs/developer/design/deformable_surface_metronome_design_note.md` (in +the `feature/exp-integrator-freesurface` worktree). + +## Motivation + +**Primary use case: backtrack past unstable timestepping.** A timestepper +hits an instability or a sudden regime change; the cleanest recovery is +to restore the last known-good state and continue with smaller Δt +(or a different scheme). Today this is done ad-hoc by users where it +happens at all. + +**Secondary uses, all sharing the same primitive:** + +- Multi-stage time integration (RK4 between stages — restore to + start-of-step, deform to next stage configuration, sample rate). The + free-surface integrator-zoo work is the immediate case; the same + primitive applies to any multi-stage scheme. +- Adaptive Δt with error estimate (take a step, estimate per-step + error, restore + retry if too large). +- Predictor-corrector probing (try a predictor, check the corrector + residual, fall back if not converging — relevant to the VEP work). +- Regime-change feeling-out (e.g., elastic predictor → check yield + surface → restore + plastic split if violated). + +All five want one thing: at one moment, capture *enough* state that the +system can be put back at any point afterwards as if nothing had +happened. + +## Reframe: same operation as on-disk checkpoint, different backend + +Rather than build a parallel snapshot mechanism, treat in-memory +snapshot as a *backend variant* of the existing checkpoint code. Two +benefits: + +1. **One source of truth for "what is system state."** Whatever set of + (mesh coords, MV DOFs, swarm positions, swarm-var values, algorithm + history, ...) the checkpoint serialiser captures becomes the + contract for in-memory snapshots too. Adding new state-bearers means + adding them once; both backends pick them up. +2. **The existing checkpoint code gets exercised harder and improved + as a side effect.** In-memory roundtrip is a cheap unit test — + every snapshot/restore cycle exercises the serialise/deserialise + paths, which surfaces gaps and bugs that disk-only checkpointing + exposes only at quarterly-test cadence. + +## Two mesh-variable output paths + +This design note predates the unified timestep writer. The current public API +keeps the same two reload semantics, but exposes new output through +`Mesh.write_timestep(...)`: + +**Coordinate-remap path.** +- Write with `Mesh.write_timestep(...)`. +- Read selected variables with `MeshVariable.read_timestep(...)`. +- Uses `/fields` coordinate/value datasets and coordinate/KDTree remapping. +- Can load data onto a different mesh or MPI decomposition. + +**PETSc-native reload path.** +- Write with `Mesh.write_timestep(..., petsc_reload=True)`. +- Read selected variables with `MeshVariable.read_checkpoint(...)`. +- Uses PETSc DMPlex topology, section, vector, and `PetscSF` metadata. +- Intended for exact same-mesh finite-element vector reload. + +`Mesh.write_checkpoint(...)` is retained as a compatibility wrapper for older +scripts. New code should use `Mesh.write_timestep(..., petsc_reload=True)` for +PETSc-native reload output. + +**For in-memory snapshot, the PETSc-native path is the conceptual model.** +Restore goes back to the same DM, so the resolution/decomposition flexibility +of coordinate remap is unneeded. But the in-memory backend does not use the +HDF5 Viewer — it copies section structure and vector data directly into numpy +arrays. Same conceptual capture, different mechanism. + +## What state must be captured + +Audit-informed inventory: + +**Captured today (in Path A):** +- DM topology and section +- Deformed mesh coordinates +- Mesh-variable global vectors (DOF values) — **including `DDt.psi_star`, + which is itself a mesh variable; its DOF data goes through the + section path automatically** +- Swarm-variable values (via mesh-DM proxy fields) + +**NOT captured today, required for full-state in-memory restore:** + +| state | location | priority | +|---|---|---| +| `DDt._dt_history` (variable-Δt BDF history; list of floats) | `systems/ddt.py:386` | **high** | +| `DDt._history_initialised` (bool) | `systems/ddt.py:383` | **high** | +| `DDt._n_solves_completed` (int) | `systems/ddt.py:384` | **high** | +| Binding `DDt instance ↔ psi_star MV(s)` | implicit in `__init__` | high | +| Simulation time, step counter | not in any object today | high | +| Parameter mutation history | `parameters.py:145` (`_history`) | medium | +| `Swarm._mesh_version` | `swarm.py:2421` | medium | +| Solver iteration counts / convergence history | scattered | low | + +**The DDt example is representative, not exceptional.** Algorithm-internal +scalar/list state on Python objects is the architecturally significant +gap — it lives in solver-adjacent Python objects, not in any PETScSection. +The in-memory snapshot must compose PETScSection state + Python-side +mutable state into a single token. The next section addresses how this +composition should be designed in general — not as a per-class bolt-on, +but as a contract that new algorithm-helper classes follow from day one. + +## General serialisation contract for solver-internal state + +The opportunity. Bolting per-class snapshot hooks onto each algorithm +helper as it appears would produce a half-baked system that silently +misses state whenever a new helper is added without a corresponding hook. +DDt today, the next adaptive-Δt controller tomorrow, the gamma estimator +the day after — the pattern recurs. + +This is the right moment to design a general serialisation contract for +solver-internal state, before it leaks into the user API. New +algorithm-helper classes should declare their state slots from day one; +existing classes (DDt, parameter mutation history, solver convergence +bookkeeping) get retrofitted as they're touched. + +**Three options for the contract.** + +(A) **Declarative slots.** Class declares a class-level list of +attribute names that constitute state. +```python +class DDt: + _state_attrs = ('_dt_history', '_history_initialised', + '_n_solves_completed') +``` +Snapshot copies named attrs; restore writes them back. Stringly-typed; +silent breakage when attribute names drift. Least invasive — works as a +mixin on any existing class. + +(B) **Explicit save/restore methods.** Class implements paired methods. +```python +class DDt: + def _save_state(self) -> dict: ... + def _restore_state(self, d: dict) -> None: ... +``` +Most flexible (transform on save, validate on restore). Most boilerplate; +easy to forget to update when adding a new state attribute. Standard +Python pickling pattern (`__getstate__` / `__setstate__`) is essentially +this option. + +(C) **State as a first-class object.** Computation and state are +separated; the State object is a dataclass / pydantic model that +trivially serialises. +```python +@dataclass +class DDtState: + dt_history: list[float] + history_initialised: bool + n_solves_completed: int + psi_star_var_names: list[str] # binding to MVs lives here too + +class DDt: + def __init__(self, ...): + self.state = DDtState(...) + # All mutations go via self.state. +``` +Most self-documenting; enables side benefits beyond serialisation +(deep-copy, equality testing, repr, schema-versioned migrations). Most +invasive — changes how new solver-internal classes are written. + +**Recommendation.** (C) for new code, (B)-style adapters for the small +number of existing classes that need retrofitting (primarily DDt and +parameter-mutation-history). (A) is rejected because the silent-drift +failure mode is exactly the kind of half-baked outcome we're trying to +avoid by designing this now. + +The decision matters because it sets the pattern for every +algorithm-internal class added over the next few years. The cost of +choosing wrong is that snapshot tokens silently miss state for any +class added without proper instrumentation; the cost of choosing right +is that new solver-internal classes get serialisation, deep-copy, and +equality testing automatically. + +**Side benefits of (C) beyond checkpoint.** +- `solver_a.state == solver_b.state` becomes a meaningful comparison — + useful for regression testing of solver-internal behaviour +- `repr(obj.state)` is automatic and useful for debugging +- snapshot tokens compose trivially: `{id(obj): obj.state.copy()}` +- schema versioning is tractable: a `_schema_version` field on each + State object plus a migration registry handles cross-version + compatibility (relevant for on-disk checkpoints that survive UW3 + upgrades) +- the `Snapshottable` interface becomes a one-liner: "has a `.state` + attribute that is a dataclass." + +**Bindings to PETSc-owned objects.** State objects must NOT hold direct +PETSc Vec / DM / MV handles — only stable identifiers (variable names, +mesh names) that can resolve back to the live object on restore. This +keeps tokens plain-Python and avoids the DM-lifecycle hazards +identified in earlier work. + +## A third backend: on-disk full-state snapshot + +The in-memory backend is one storage option. The same capture/restore +machinery supports a slower-but-persistent **on-disk full-state** +backend with no architectural changes — only the serialisation layer +differs. + +This is **distinct from the existing `write_timestep` on-disk path**, +which is selective (per-variable), designed for visualisation, emits +XDMF for ParaView, and does not restore solver-internal state. Three +backends serving three different needs: + +| dimension | existing `write_timestep` | new on-disk full-state | new in-memory | +|---|---|---|---| +| storage | HDF5 + XDMF, per-variable | HDF5, monolithic | dict of numpy arrays | +| selectivity | user picks variables | always full state | always full state | +| Python-side state | not captured | captured | captured | +| restorability | partial (re-init solvers, lose history) | bit-equivalent | bit-equivalent | +| persistence | survives process exit | survives process exit | intra-run only | +| speed | medium | slow (HDF5 I/O) | fast (RAM copy) | +| typical use | visualisation, restart-with-changes | crash recovery, bisection, debugging | RK staging, backtrack, adaptive Δt | + +The existing `write_timestep` continues to serve its role unchanged. +The new mechanism (in-memory + on-disk-full-state) addresses a +different need: faithful state restore for algorithmic uses where +"approximate restart" would silently corrupt the run. + +**Use cases the on-disk full-state backend opens up:** + +- **Crash recovery for long simulations.** Periodic snapshots written + to disk; on restart, restore from the most recent. No lost work + beyond the snapshot interval. +- **Cross-run resumption.** A simulation that ran to $t = T_1$ can be + picked up at $t = T_1$ in a later session, bit-equivalent — including + DDt history and any other algorithm-internal state. +- **Bisection / branching exploration.** Snapshot at a decision point; + try one parameter path; if unsatisfactory, restore and try another. + Powerful for sensitivity studies on long runs. +- **Debugging captures.** Snapshot at a problem point; examine offline; + iterate without re-running the costly setup. + +**What this adds to the design.** Three implications, all bounded: + +1. **Backend abstraction must serialise to bytes from day one.** The + in-memory backend stores numpy arrays directly; the on-disk backend + serialises them to HDF5. Both go through the same `save_*` / `load_*` + protocol on the backend interface — the abstraction is shaped by + needing both. +2. **Schema versioning becomes critical.** In-memory tokens are + short-lived; on-disk tokens may be loaded by a future UW3 version. + Each `State` dataclass carries a `_schema_version`; a migration + registry handles cross-version restore. (The on-disk + `write_timestep` path has no equivalent need because it doesn't + restore solver state.) +3. **Generation-counter semantics need a cross-process branch.** + Within-process: counter check ensures you don't restore an + invalidated snapshot. Across-process (restoring from disk on a + fresh run): the model is being initialised *from* the snapshot, + not restored *to* a previous state — counters are set to the + snapshot's values, no invalidation check needed. Restore code + distinguishes the two paths. + +## API shape + +Full-state always; backend chosen at save time by passing (or +omitting) a ``file=``. (Final names landed after a phase-5 rename +pass that replaced the draft ``snapshot``/``restore`` verbs — see +the user guide at ``docs/advanced/snapshot-restore.md``.) + +```python +# Same call, different storage — dispatch on whether a file is given. +token = model.save_state() # in-memory (default) +model.save_state(file='step42.snap.h5') # on-disk full-state + +model.load_state(token) # in-memory restore +model.load_state('step42.snap.h5') # on-disk restore + +# Existing per-variable selective on-disk path is unchanged: +mesh.write_timestep('step42.h5', ...) # visualisation; not full-state +``` + +Backends share a single `Snapshot` structure — only the serialisation +layer differs: + +```python +class Snapshot: + section_state: dict[DM, SectionAndVecData] # PETSc state + python_state: dict[ObjectId, ObjectState] # State dataclasses + generations: dict[ObjectId, int] # within-process invalidation + metadata: dict # sim time, step #, schema version +``` + +The in-memory backend stores `section_state` arrays as numpy buffers +directly; the on-disk backend serialises them to HDF5 datasets. The +Python-side `State` dataclasses serialise to HDF5 attributes (small +scalars / short lists) and groups (arrays). + +Tokens are plain Python / numpy — never PETSc Vec or DM handles. +On-disk "tokens" are paths to single HDF5 files. Either way, the +DM-lifecycle hazards identified in earlier work do not apply. + +## Restore semantics for swarms — rebuild, do not refuse + +**Correction (2026-05-11, post-review).** An earlier draft of this +section proposed using a per-swarm `_population_generation` counter as +an *invalidation gate*: if the counter at restore differs from the +counter at capture, raise rather than restore. That design is wrong. +The whole point of the toolkit is to undo state changes — including +particle motion / migration / repopulation between capture and +restore. Refusing on counter mismatch breaks the central use cases (RK +staging, backtrack-on-instability, adaptive Δt retry — all of which +*will* migrate particles between capture and restore). + +**The correct semantics: restore rebuilds the swarm's particle +population from the snapshot.** Specifically: + +1. Clear the swarm's current local particles. +2. Re-add the captured per-rank coordinates via + `add_particles_with_coordinates(saved_local_coords, migrate=False)`. + The mesh partition is deterministic and unchanged within v1 scope + (mesh-version check still applies), so particles that were local at + capture are local at restore — no migration step needed. +3. Write the captured per-particle variable data back into the + newly-added particles, in their captured order. + +The `Swarm._population_generation` counter is still useful as +*informational metadata* — it can flag in logs / metadata what +happened between capture and restore, it can feed cache invalidation +in other consumers, it can power future optimisations (e.g., a fast +in-place restore when the counter happens to match). But it is **not** +a restore gate. + +Mutation sites where the counter is incremented (current line numbers +re-derived per audit; the design's correctness does not depend on the +exact set as long as we over-bump rather than under-bump): + +| file | site | call | +|---|---|---| +| `swarm.py` | end of `populate()` | covers internal `dm.addNPoints()` calls | +| `swarm.py` | `Swarm.migrate()` after `migration_disabled` early-exit | bumps unconditionally; conservative no-op safe | +| `swarm.py` | after `dm.migrate()` in `add_particles_with_coordinates()` | direct PETSc DM call, not via `Swarm.migrate` | +| `swarm.py` | after `addNPoints` in `add_particles_with_global_coordinates()` | catches `migrate=False` callers | +| `swarm.py` | `advection()` remesh path after re-injection `addNPoints` | recycle-mode reinitialisation | + +`Mesh._mesh_version` is a separate counter on the mesh side. In v1 +the mesh-version mismatch *does* refuse restore — see the next +section. + +## Architectural work required + +In rough dependency order: + +1. **Backend abstraction layer.** Extract the "capture" logic from the + output-storage logic used for PETSc-native reload. Introduce a + `CheckpointBackend` protocol (e.g., `save_section`, `save_vector`, + `save_metadata`, `load_section`, `load_vector`, `load_metadata`). + Shaped from day one to support both backends — the in-memory case + is the cheapest test of the abstraction's correctness; the on-disk + case is what locks in the byte-level serialisation contract. The + existing HDF5 path is refactored to fit the protocol; the wedge + is clean because section/vector ops are PETSc abstractions and the + HDF5 coupling lives in the Viewer construction. +2. **Backend implementations.** Two concrete backends: + - `InMemoryBackend` — dict of numpy arrays. Trivial once the + abstraction exists. + - `OnDiskFullStateBackend` — single monolithic HDF5 file. Shares + PETSc-state serialisation with the existing PETSc-native reload path + (already HDF5); adds Python-state serialisation as HDF5 attributes/groups. +3. **Adopt the serialisation contract for new solver-internal code.** + Decision: option (C) — state as first-class dataclass (see "General + serialisation contract" section). Any new algorithm-helper class + added from this point on declares its state as a separate + dataclass; the checkpoint mechanism reads `.state` automatically. + Document the pattern in the developer guide; add a check to CI that + new classes in `systems/` and `solvers/` declare `.state`. +4. **Retrofit existing solver-internal classes** to the contract. + In priority order: `DDt` (`systems/ddt.py`), parameter mutation + history (`parameters.py:145`), any solver convergence-tracking + state (audit pending). Each retrofit is small; total bounded by + the number of classes (probably under ten). +5. **Swarm rebuild on restore + informational + `_population_generation` counter.** Snapshot captures per-rank + particle coordinates and per-variable arrays. Restore clears the + current local population and re-adds the captured particles at + their captured coords (see "Restore semantics for swarms" section + above for the corrected design). The counter is bumped at every + identified mutation site for informational use; it is **not** an + invalidation gate. +6. **Schema versioning + migration registry.** Each `State` dataclass + carries a `_schema_version` integer. A central registry maps + `(class, version)` to migration functions that lift older State + data to the current schema. In-memory restore checks version + equality (any mismatch is a programming error since both sides are + the same process); on-disk restore consults the migration registry + and applies migrations in sequence. Without this infrastructure, + on-disk snapshots break the moment any retrofitted class evolves. +7. **`Model.snapshot()` / `Model.restore()` orchestration.** Walks + registered meshes, swarms, MVs, and any object exposing a `.state` + attribute; composes the token; routes to backend (in-memory or + on-disk) based on whether `path=` is given; restores in safe order; + distinguishes within-process restore (counter validation enforced) + from cross-process restore (counters initialised from snapshot). +8. **Tests.** In-memory roundtrip on standard setups (Stokes benchmark, + swarm-bearing setup, DDt-using setup). On-disk roundtrip on the + same setups. Cross-process restore: write to disk in process A, + load in fresh process B, verify the restored model continues + bit-equivalently from the snapshot point. Verify generation + invalidation raises cleanly; verify `.state == .state` after + roundtrip for retrofitted classes. + +## Scope boundaries (NOT in v1) + +- **Mesh adaptation roundtrip — scheduled for v1.2, not a permanent + limitation.** A snapshot taken before a `mesh.adapt()` event in v1 + refuses restore via the `_mesh_version` check, because the captured + DOF arrays are sized for the pre-adapt section and writing them + in-place into the post-adapt DM would corrupt the run. **v1.2 will + replace the refusal with a mesh-rebuild path**: capture enough + topology / section info to destroy the post-adapt DM and rebuild + the pre-adapt one, then write DOFs into the rebuilt DM. The + principle is the same as the swarm rebuild (capture-the-state, + rebuild-on-restore); the implementation is more invasive because + every MeshVariable / Swarm / solver holds references into the DM + and those wrappers need to re-bind. v1's snapshot **captures the + topology / section info even though v1 restore ignores it**, so the + payload is forward-compatible with v1.2 without a schema bump. +- **Replacing the existing `write_timestep` path.** The selective + per-variable on-disk path continues unchanged. The new full-state + on-disk backend is additive and serves a different need (faithful + restore vs visualisation/restart-with-changes). +- **Cross-rank-count restore.** On-disk full-state snapshots written + on N ranks restore on N ranks. Restoring on a different rank count + requires the existing `write_timestep` path's interpolation + machinery and is out of scope for the full-state backend. +- **Lazy / copy-on-write in-memory tokens.** Always-eager copy for + v1. The use cases that drove this (RK staging, single-step + backtrack) all happen on a per-step cadence where eager copy is + affordable. Lazy/COW is an optimisation for later if someone proves + it matters. + +## Open implementation questions + +1. **Does PETSc expose a clean way to copy section + vector state + directly into numpy without going through a Viewer?** The PETSc + `Vec.getArray()` / `Section.getDof()` low-level APIs should + suffice, but it needs a quick prototype to confirm. Alternative: + `PETSc.Viewer.Type.MEMORY` may exist but support for DM operations + is uncertain. +2. **How does `Model.snapshot()` discover state-bearing objects?** + With the contract decision made (option C — `.state` dataclass), + discovery options narrow to: (a) walk Model's registered solvers + and recurse into anything with a `.state` attr; (b) explicit + registration at construction time. (a) is more ergonomic but + relies on solvers maintaining proper registration with the Model. + (b) is more explicit but requires every state-bearing object to + know about Model. (a) is probably right; verify the existing + solver-Model relationship can support recursive `.state` discovery. +3. **What does `Model.restore(token)` order look like?** Mesh + coords first (since MV DOFs are tied to mesh layout), then MV + DOFs, then swarm positions + migrate, then swarm-var values, then + DDt and Python-side state, then generation counters validated last. + Confirm this order is correct; particularly whether MV restoration + needs the mesh to be at restored coords first. +4. **Memory cost on realistic setups.** For a typical coupled-physics + run (mesh + swarm + several MVs + DDt history), what's the + per-snapshot byte cost? Drives the answer to "can users keep N + snapshots in memory without thinking about it." Probably bounded + by one-Stokes-solve worth of memory, but worth measuring early. + +## Status + +Audit complete. Design pending review. Implementation has not started. +The work is bounded (no open-ended research items in any of the seven +architectural-work items above) and decomposes into commits that can +land sequentially: backend abstraction → InMemoryBackend → Python-side +registration → swarm generation counter → Model orchestration → tests. + +Expected size: around four weeks of careful work, dominated by: +- the backend-abstraction refactor (item 1), which touches existing + checkpoint code other things depend on, and must be shaped to + support both backends from day one; +- the contract retrofit (item 4), mechanically small per class but + needing careful audit so no state is silently missed; +- the on-disk backend (item 2b) and schema versioning (item 6), + together adding around a week beyond the in-memory-only scope but + unlocking the cross-run / crash-recovery / bisection use cases. + +The contract decision (item 3) and the schema-versioning decision +(item 6) are the highest-leverage pieces — they set the pattern for +every algorithm-internal class added over the next few years and the +durability guarantee for every on-disk full-state snapshot. Worth +getting right even if they slow the immediate work, because the +alternative is years of half-baked snapshots that silently miss state +or break across UW3 versions. + +## Cross-references + +- `docs/developer/design/deformable_surface_metronome_design_note.md` + (in `feature/exp-integrator-freesurface` worktree) — the parent + design discussion that motivated spinning this off as a separate + capability. +- `publications/free-surface-paper/integrator_zoo_supplementary.md` — + the empirical work that exposed the need for snapshot/restore as + part of multi-stage time integration. diff --git a/docs/developer/design/lagged-clone-sl-history.md b/docs/developer/design/lagged-clone-sl-history.md new file mode 100644 index 000000000..04ba8bd2d --- /dev/null +++ b/docs/developer/design/lagged-clone-sl-history.md @@ -0,0 +1,324 @@ +# Lagged reference-mesh SL history (and mixed-mesh evaluation) + +**Status:** design / plan (2026-06). Supersedes the ALE `v_mesh`-pulse + CARRY +approximation for the semi-Lagrangian history on a moving mesh. Two-phase plan: +**Phase 1 — mixed-mesh expression evaluation** (the enabling primitive, +implement and prove first); **Phase 2 — lagged rolling-clone SL history** +(the architecture built on Phase 1). + +Related: [REMESH_FIELD_TRANSFER_DESIGN](REMESH_FIELD_TRANSFER_DESIGN.md), +[in_memory_checkpoint_design](in_memory_checkpoint_design.md), +[submesh-solver-architecture](submesh-solver-architecture.md), +[STRESS_EQUILIBRIUM_FREESURFACE](STRESS_EQUILIBRIUM_FREESURFACE.md). + +## Problem + +On a moving mesh (free surface, or adaptation) the semi-Lagrangian update needs +the *old* field at the departure point. At a **receding** free surface +(downwellings) that point lies in the layer the surface just vacated — which the +*new* mesh does not cover. Sampling the new-mesh FE field there extrapolates the +P3 polynomial → ±300 node-level speckles → blow-up (Ra=1e5 annulus, ~step 20). + +The current code approximates "old field on old geometry" *without* keeping a +copy: history vars are `CARRY`'d onto the new mesh and the trace-back subtracts +`v_mesh = Δx/dt` (`ddt.py` `SemiLagrangian.update_pre_solve` / +`_activate_ale_for_traceback`). This is the approximation that fails: + +- The order-1 advected field has its `v_mesh` correction **gated off** at `i=0` + (the band-aid re-record path, `ddt.py:2447`), so its foot lands at the *old* + surface — in the vacated layer. +- The annulus `return_coords_to_bounds` (`meshing/annulus.py:507`) clamps to the + **construction-time circular radius**, not the deformed surface — so receded + feet aren't recognised as outside and aren't clamped. + +Confirmed empirically (`~/+Simulations/fs_convection_goal4/smoke_geomclamp.py`): +a geometry-aware clamp **delays** the blow-up ~2 steps and halves the early +overshoot but does **not** cure it — because the departure *value* is wrong, not +just the geometry. Clamp/monotone band-aids are dead ends; `monotone` does run on +the FE path (`functions_unit_system.py:854`) but fails via neighbour-corruption +feedback once a speckle seeds. + +## Target architecture: lagged rolling-clone + +Keep a **frozen clone of the previous-step mesh** (geometry + the primitive +fields needed for history). The SL trace-back samples *that* clone: +`global_evaluate(history_expr_on_clone, departure_coords)`. The departure point +sits in the receded layer, which the **old** mesh covers — so the sample is a +real interpolated value: no clamp, no extrapolation, no speckle. This is the +*exact* computation the `v_mesh`/CARRY apparatus only approximates. + +### Invariants + +1. **One clone, any scheme order.** Every trace-back is a single-Δt reach from + the current mesh to the previous-step mesh. BDF2 reaches 2Δt as two composed + single-Δt traces across successive steps (`psi_star[1]` is last step's + already-traced `psi_star[0]`). History *depth* lives in the **stack of + fields**, not in a stack of meshes. The clone is a **rolling** one-step-behind + copy, refreshed at the start of each step. +2. **Shared partition.** The clone must share topology *and* the exact parallel + partition / local numbering (PETSc `DMClone`-style: shared DMPlex + `PetscSF`, + **independent** coordinate vector). Then refreshing the clone's geometry each + step is a **local memcpy of coordinates, no global scatter**. (Sampling at + launch points is `global_evaluate` — parallel-routed and partition-agnostic; + shared partition lets it reuse routing.) +3. **The clone stores primitives, not derived tensors.** Store `T` (and `V`, and + stress for VE) at the lagged time; **recompute** gradients / fluxes / stress + at full FE accuracy on the clone. This retires the lossy projected histories + (`DFDt` flux history, the `psi_star_flat` tensor view, the VE stress + projection) and their projection diffusion. +4. **Mesh-homogeneous history; the mixing guard defines the clone set.** Each + history term is an expression evaluated **entirely on the clone**; the only + thing crossing the mesh boundary is a value **array at coordinates**, never a + symbolic expression spanning two DMs. The evaluator's existing + refuse-mixed-mesh guard is the *enforcement*: if a history build trips it, + that names a current-mesh field that must be cloned to the lagged mesh. Time- + splitting (BDF/AM) already separates terms by time level, so "no mixed mesh" + and "no mixed time level in one expression" become the same discipline. +5. **Split the implicit `self.mesh`.** Today `update_pre_solve` assumes one mesh + everywhere (`get_closest_cells`, `_centroids`, `return_coords_to_bounds`, the + `global_evaluate` calls). The refactor makes two roles explicit: + *foot computation* (where did the material come from) on the **current** mesh; + *history sampling* (value/flux there) on the **lagged** clone. Every + evaluation takes its mesh **from the variable/expression**, not a default. +6. **Unifies free-surface and adaptation.** The lagged clone is the SL reference + regardless of *how* nodes moved (free surface, mmpde, OT). Same code path — + the original goal of this work. + +## Phase 1 — mixed-mesh expression evaluation (implement first) + +The enabling primitive, and the efficiency-critical piece. Two implementation +strategies; Phase 1 likely needs both, chosen per term: + +- **(a) Clone-the-primitives** → expression is homogeneous on the clone. Simple + per-eval; costs memory + per-step field copy. Good default for history terms. +- **(b) True mixed-mesh evaluate** → evaluate each meshVariable on *its own* DM + at the shared physical coordinates, then combine the per-mesh arrays pointwise. + No field cloning; the harder/efficiency-critical path. + +### Semantics of (b) + +For an expression `E` whose meshVariable atoms belong to meshes `{M_a, M_b, …}`, +at physical points `X`: value at `x_k` = `E(var_a(x_k|M_a), var_b(x_k|M_b), …)`. +Pointwise; derivatives are local to each variable's own mesh, so they're fine. + +### Mechanism + +1. Partition the (unwrapped) expression's atoms by owning mesh. +2. For each mesh `M_i`, point-locate `X` and interpolate the needed + variable/derivative components → array `A_i`. +3. Substitute the `A_i` into the symbolic combination and evaluate pointwise + (existing numpy/lambdify path). + +### Efficiency (the crux) + +- **Per-mesh location cache.** Point location dominates. Cache per mesh, keyed on + coord-hash (cf. `_dminterpolation_cache`, see + [data-access](../subsystems/data-access.md)); reuse across all history levels + evaluated at the same `X` within one step. +- **Shared-partition fast path.** When `M_b` is a partition-sharing clone of + `M_a`, a point owned by rank *r* on `M_a` is owned by rank *r* on `M_b` — one + routing, two local interpolations; no second scatter. This is *why* the clone + shares the partition. +- **Reuse the locator across the BDF stack** (all `psi_star[i]` share `X`). + +### API + +- Keep refuse-mixed-mesh as the **default** (accidental mixing still caught). +- Add an **explicit opt-in** (e.g. `evaluate(..., allow_mixed_mesh=True)` or a + dedicated `multi_mesh_evaluate`) for intentional history evaluation. +- Caveat to verify up front: `global_evaluate` of **derivative / vector** + expressions on a *non-current* mesh has a known wrinkle on some branches + (`scalar_component` mismatch). The gradient-recompute path depends entirely on + this — confirm/fix it as the first Phase-1 task. + +### Tests + +Correctness vs per-mesh manual eval; the shared-partition fast path +(bit-identical, reduced routing); parallel (np>1); derivative/vector exprs across +meshes. + +## Phase 2 — lagged rolling-clone SL history + +1. **Clone primitive:** `DMClone`-with-shared-partition + independent coords; + rolling refresh (local coord + field copy at step start). Reuse the in-memory + snapshot toolkit if it already provides same-partition state hold. +2. **DDt refactor:** split foot-mesh (current) vs history-mesh (clone); sample + history via Phase-1 evaluation; recompute fluxes/gradients homogeneously on + the clone. +3. **Retire** the `CARRY` + `v_mesh`-pulse + `i=0`-gate machinery for the trace- + back (scope what else still depends on `CARRY` before deleting). +4. **Validate:** the free-surface smoke test reaches approximate equilibrium + (T∈[0,1], no speckle); adaptation regression unchanged; bit-identical on a + non-moving mesh. + +## Open questions / risks + +- Derivative/vector `global_evaluate` on a non-current mesh (Phase-1 task 1). +- What else depends on `CARRY` / the ALE pulse (swarm advection? VE?). +- Memory/perf of the rolling clone at scale; locator rebuild vs reuse. +- VE: the stress history needs lagged `V` (and `∇V`) on the clone — confirm the + clone primitive set covers the constitutive expression's full free-variable + set (the mixing guard enumerates it). + +## Relationship to landed work + +- **PR #246** (capability gate + `Mesh.deform` + `ephemeral_coords` + SL-field + `CARRY` auto-stamp) made coordinate mutation foolproof and proved the failure + is *not* transfer-incoherence — it set up this design. The `CARRY` auto-stamp + is part of the apparatus this design will eventually retire. +- The **big question** this unblocks: SL height field vs full node movement + + mmpde. The lagged-clone approach makes SL correct at a moving boundary; the + comparison is then about cost/robustness, not correctness. + +## Stage 0 — landed (2026-06-18): old-frame reach-back, cost/benefit + +Stage 0 (a) — *ephemeral old-geometry restore on the working mesh* — is +implemented and validated as the production cure for the high-Ra free-surface +SL blow-up. It is the minimal subset of this design: no clone, no mixed-mesh +evaluator. This section records what it costs, what it leaves on the table, and +the recommendation on whether to proceed to the rolling clone (Stage 1 (c)) now. + +### What landed + +`SemiLagrangian.old_frame_traceback` (opt-in, default `False`; forwarded through +`AdvDiffusionSLCN(..., old_frame_traceback=True)` to the **advective `DuDt` +only** — the diffusive `DFDt` keeps stock ALE, validated sufficient at Ra=1e5). +Three hooks in `systems/ddt.py`: + +1. `on_remesh` stashes the pre-move geometry (`_oldframe_X = ctx.old_X`, earliest + since the last solve) **instead of** a `v_mesh` displacement, and leaves + `psi_star` `CARRY`'d. +2. `update_pre_solve` computes the departure foot from the **physical** velocity + (no `v_mesh`; `_ale_active` is `False`), records `psi_star[0]` by **direct + nodal carry** (not a re-evaluate on the deformed mesh — see below), skips the + new-mesh bounds clamp, and samples `psi_star` inside + `with mesh.ephemeral_coords(): mesh._deform_mesh(_oldframe_X); global_evaluate(...)`. +3. The one-step stash is consumed at the end of `update_pre_solve`. + +**De-risked:** a variable's `.data` is bit-identical through a +`_deform_mesh(old) → _deform_mesh(new)` round-trip (`nuke_coords_and_rebuild` +rebuilds the DS / DM / DOF-coordinate caches but never touches the solution +`Vec`), and `_deform_mesh` invalidates `_dminterpolation_cache` + the evaluation +cache — so the ephemeral sample is correct and not stale. The clone (Stage 1) is +therefore **not required** for correctness; it is an efficiency / capability +play. + +**Load-bearing detail:** the first real-API run blew up *like the baseline* +despite old-frame being active, because the standard `store_result` path +**re-records** `psi_star[0]` by evaluating `psi_fn` on the *deformed* mesh at +centroid-shifted nodes — injecting boundary-layer interpolation error that grows +with `h_max` and then rides the old-geometry sample. Recording the history by a +**direct nodal carry** (reusing the parallel `_record_psi_star_from_field_data` +path) restores the prototype's exact behaviour. This is the "store primitives, +not re-derived values" principle of invariant 3, in miniature. + +### Validation (Ra=1e5 annulus, res 20, free surface, CN) + +| run | worst T over 30 steps | +|-----|-----------------------| +| baseline (stock ALE) | `[-311, +333]` — blow-up by ~step 20 | +| old-frame, **SLCN** (BDF1 + CN flux, θ=0.5) | `[0.000, 1.000]` | +| old-frame, **SL-BDF2** (BDF2 + flux at n+1, θ=1) | `[0.000, 1.000]` | + +`h_max` reaches ~0.067 (surface deformed ~2.3 cells), Nu climbs 12→55 — vigorous, +not over-diffused. Old-frame is **order-agnostic** — it changes only *where* the +history is sampled, so both the canonical SLCN and SL-BDF2 schemes hold T∈[0,1]. +(The two scheme knobs must be paired: SLCN = BDF1 time-difference + Crank-Nicolson +flux; SL-BDF2 = BDF2 time-difference + flux implicit at n+1 — a BDF2 stencil with a +CN flux is *not* a consistent 2nd-order scheme. See +[`docs/advanced/semi-lagrangian-time-integration.md`](../../advanced/semi-lagrangian-time-integration.md) +and Bonaventura et al., 2021.) Unit regression: `tests/test_0855_oldframe_sl_traceback.py` +(no-op on static mesh bit-identical; geometry restored + stash consumed; samples +on old geometry not new; bounded on a moving mesh; BDF2) — passes serial and +np=2; the existing `ddt` suite is unchanged (opt-in, default off). + +### Cost of Stage 0 (a) + +Measured on the Ra=1e5 res-20 free-surface loop (`oldframe_overhead_bench.py`): + +- **`adv_diff.solve`: +166 ms/step (+8 %)** over baseline (2033 → 2199 ms/step), + from **+2 `nuke_coords_and_rebuild` per solve** (deform-to-old + restore) for an + order-1 `DuDt`. Order *N* wraps the sample per history level → `2N` rebuilds / + step (a hoist to one ephemeral block per step is an obvious Stage-0 + optimisation, deferred). +- **Hidden churn not in that 8 %:** `_deform_mesh` unconditionally sets + `is_setup = False` on *every* registered solver, so each ephemeral round-trip + also forces the **Stokes** DM/assembly to rebuild on its next solve — even + though the working mesh returns to bit-identical coordinates. This is the real + Stage-0 tax at scale (Stokes assembly ≫ a `nuke`), and it is **entirely + avoidable**: a guard that skips the `is_setup` invalidation when + `ephemeral_coords` restores identical coordinates would remove it without the + clone. Recommended as the first Stage-0 follow-up if the tax bites. + +### What Stage 0 leaves on the table (the Stage-1 (c) clone) + +The rolling clone (DMClone, shared partition, independent coords, refreshed by +local memcpy; sampled via the **mixed-mesh evaluator**, Phase 1) would: + +- **Eliminate both costs above** — the working mesh never moves, so no per-step + `nuke` and no solver-rebuild churn; the clone refresh is a local coord memcpy. +- **Enable old `V` on the clone** → second-order *temporal* reach-back (the + current foot uses only the current velocity). +- **Recompute fluxes / gradients at full FE accuracy** on the clone, retiring the + lossy projected histories (`DFDt`, `psi_star_flat`, VE stress projection). + +Its build cost is the large piece deliberately excluded from Stage 0: the +mixed-mesh `global_evaluate` (per-mesh point location + pointwise combine, +shared-partition fast path) and **verifying derivative/vector `global_evaluate` +on a non-current DM** (the `scalar_component` wrinkle, invariant 5 / Phase-1 task +1). + +### Recommendation + +**Ship Stage 0 (a) now; defer the clone (Stage 1 (c)).** Stage 0 delivers the +correctness cure with ~8 % on the advection solve and zero new infrastructure, +and (Task 2 below) is mesh-agnostic — it covers interior-node adaptation on the +same code path. The clone's payoff is *efficiency and second-order/flux +capability*, not correctness, and it costs the whole mixed-mesh evaluator. Build +it when one of these triggers fires, not before: + +1. The Stokes-rebuild churn is shown to dominate a production run **and** the + cheap `is_setup` guard above does not remove it; or +2. a feature needs old `V` (second-order temporal reach-back) or full-accuracy + flux/stress recompute on a moving mesh (VE on a free surface / adapting mesh); + or +3. the per-step `nuke` cost becomes material at higher order or resolution. + +Until then the v_mesh/`CARRY`/i=0-gate apparatus stays as the default path; old- +frame is the opt-in cure. Retiring that apparatus is gated on unifying the +adaptation path onto old-frame (Task 2) and is tracked separately. + +### Task 2 — old-frame through interior-node adaptation + +_(see `~/+Simulations/fs_convection_goal4/oldframe_adapt_comparison.py`; +no-slip Ra=1e5 res-16 annulus convection, periodic interior adaptation, old-frame +vs the current `v_mesh` path; gentle `refinement=1.3` — aggressive refinement +destabilises the **base** convection regardless of transfer mode, so it is not a +fair test)._ + +Old-frame is mesh-agnostic: `on_remesh` stashes the old geometry for *any* node +move (free surface or mmpde/OT interior adaptation), so the same code path +applies. The comparison (30 steps, 7 adaptation events each): + +| mover | v_mesh worst T | old-frame worst T | final Nu (vm / of) | worst q (vm / of) | +|-------|----------------|-------------------|--------------------|-------------------| +| `follow_metric` (anisotropic) | **[-6.97, +4.27]** | **[-0.51, +1.07]** | 16.4 / 16.0 | 0.778 / 0.783 | +| `OT_adapt` | [-0.014, 1.000] | [0.000, 1.000] | 21.8 / 21.7 | 0.762 / 0.762 | +| `smooth_mesh_interior` | [0, 1] (n_adapts=0) | [0, 1] (n_adapts=0) | 32.3 / 32.1 | 0.826 / 0.826 | + +**Conclusion: old-frame MATCHES or BEATS the current `v_mesh` path with no +regression.** For `follow_metric`'s anisotropic interior motion it is +*substantially* better bounded — `v_mesh` overshoots to `[-7, +4]` (the same +new-mesh-frame fold that fails at the free surface), while old-frame holds +`≈[0, 1]` — at equal Nu and equal-or-better mesh quality. For `OT_adapt` (whose +internal reset + FE-remap already keeps the history clean) both are bounded, old- +frame marginally better. `smooth_mesh_interior` on a uniform mesh is a no-op +(equant cells → no move); the near-identical Nu confirms old-frame does not +perturb a no-adaptation run. + +So old-frame is the **single SL reference for both free surface and adaptation** +(invariant 6), and is the path to eventually retiring the `v_mesh`/`CARRY`/i=0- +gate apparatus. That retirement is held back only by sequencing/benchmarking +discipline (it is the default path today and old-frame is opt-in), not by any +correctness gap — and is tracked as the unification follow-up. + diff --git a/docs/developer/design/ma-newton-cofactor-exploration.md b/docs/developer/design/ma-newton-cofactor-exploration.md new file mode 100644 index 000000000..72acfc89b --- /dev/null +++ b/docs/developer/design/ma-newton-cofactor-exploration.md @@ -0,0 +1,888 @@ +# Monge–Ampère mesh redistribution: Newton/cofactor linearisation + +> **Status**: exploration (Phase 0), `feature/winslow-mesh-smoother`, +> 2026-05-17. Companion to +> `docs/developer/subsystems/mesh-metric-redistribution.md` (the +> shipped BFO-Picard + direct-solver work) and the project memory +> `project-ma-efficiency-direct-solver`. + +## Motivation + +The shipped MA path (`_winslow_elliptic`) is a damped +**Benamou–Froese–Oberman Picard** iteration: each iteration solves a +*constant-coefficient* Poisson `Δφ = √((φxx−φyy)²+4φxy²+4g)−2` with +the recovered Hessian of the previous iterate, ~20–25 iterations, +under-relaxation `ω=0.4`. The constant operator is what made the +factor-once-reuse direct-solver speedup (~10×) possible — but that is +a **serial** expedient (sparse direct factorisation does not scale to +large-3D parallel per-timestep use; this build has only MUMPS + GAMG, +no hypre/SuperLU_DIST). + +A **Newton / quasi-Newton** linearisation is the textbook approach for +smooth MA / mesh redistribution / OT. Linearising +`R(φ)=det(I+D²φ)−g`: + +$$ \operatorname{cof}(I+D^2\varphi_k) : D^2\,\delta\varphi + \;=\; g-\det(I+D^2\varphi_k), \qquad + \varphi_{k+1}=\varphi_k+\lambda\,\delta\varphi $$ + +Using the Jacobi (Piola) identity `∂_i cof(M)_{ij}=0`, the weak form +is the **symmetric variable-coefficient elliptic** problem + +$$ a(\delta\varphi,v)=\int (C_k\nabla\delta\varphi)\cdot\nabla v, + \qquad C_k=\operatorname{cof}(I+D^2\varphi_k), $$ + +with `C_k` SPD iff `φ_k` is convex (Brenier branch). In 2D +`C_k = [[1+φyy, −φxy],[−φxy, 1+φxx]] = det(M_k)·M_k⁻ᵀ`. Only **first +derivatives of the unknown** appear (in the flux `F1=C_k∇δφ`); all +2nd-derivative content is in the *coefficient* `C_k`, read from the +existing recovered-Hessian field (`_hessian_recovery_class`, +first-derivatives-only — UW3-legal). + +It slots into the existing `uw.systems.Poisson` (`SNES_Scalar`): +`F1 = constitutive_model.flux = c·∇u`, so a `DiffusionModel` subclass +with `_c = C_k` *is* the Newton operator; `f = det(I+H_k)−g` is the +source; `constant_nullspace` handles the pure-Neumann singularity +exactly as the BFO path does. Single-field scalar SNES — **not** the +rejected fully-coupled (φ,H) SNES. + +### What it can and cannot change + +- **Cannot** change the fixed-node grading ceiling (≈1.5–1.8× for an + 8–20× target). Same equation, same recovered Hessian ⇒ same fixed + point. The OT ~10× needs *more nodes* (settled — see + `project-ma-recovered-hessian-picard-inadequate`). Newton is **not a + grading lever**; `ma_cost_grading.py` (1.02/1.43/1.71/1.54) is the + regression guard. +- **Can** change convergence: few Newton iterations vs ~20–25 Picard + ⇒ insensitive to per-iteration setup cost (the GAMG-resetup failure + mode), and the per-step operator is SPD variable-coefficient + elliptic ⇒ **AMG-friendly** ⇒ the right structure for the parallel + rework. + +## Phase 0 — residual-contraction quantification + +**Goal**: confirm Newton contracts the MA residual +`r_k = det(I+H_k) − g` in a handful of iterations vs the BFO-Picard's +~20–25, on the canonical res-16 Annulus, *before* any source changes. +Both schemes share the φ field, the recovered-Hessian solver, the `c` +normalisation, `g`, the constant nullspace and pinned BCs — the **only +difference is the inner potential update**. Geometry is held fixed +(no node move) to isolate solver contraction. + +Script: `scripts/ma_newton_phase0.py` (no `src/` changes; uses +`smoothing._hessian_recovery_class`, `_use_direct_solver`, +`_auto_pinned_labels`). + +### Results + +**Run 1 (AMP=8, RES=16) — a methodological finding.** Measuring +contraction of `r_k = det(I+H_k) − g` (H recovered) was the *wrong +yardstick*: it has a large **irreducible floor** that *neither* +scheme reduces — BFO plateaus at `‖r‖≈0.29` (from 0.46), Newton at +`≈0.34`. That floor is precisely the recovered-Hessian +under-estimation of `det(D²φ)` that the project memory identifies as +the root cause of the ≈1.5–1.8× single-solve cap (the FE-MA fixed +point is *self-consistently under-deformed*; `det(I+H_rec)−g` is O(0.3) +even at the exact FE solution). Strong confirmation that **Newton on +the cofactor operator cannot beat the grading ceiling** (same +recovered Hessian, same floor) — exactly as predicted; it is not a +grading lever. + +Consequence for the experiment: a residual-decrease line search on +`‖det(I+H_rec)−g‖` is meaningless here (it rejected almost every +Newton step, collapsing `λ→0.008` and freezing the iteration — *not* +a fair Newton test). The correct Phase-0 question is the +*efficiency* one: **does Newton reach the same fixed-node transport +map in far fewer iterations than BFO's ~20–25?** The valid metric is +the **transport-map increment** `Δ_k = ‖∇φ_k − ∇φ_{k-1}‖∞` (→0 as +the map converges) and the realised `max|∇φ|` / honest grading +(must match BFO — the regression guard). Run 2 uses that, with +fixed damping (no residual-rejection; keep only a `det(I+H)>0` +convexity backtrack). + +**Run 2 (AMP=8, RES=16) — transport-map contraction.** Metric: +`d_k=max|∇φ_k|`, increment `Δ_k=max|∇φ_k−∇φ_{k-1}|`; final honest +`d/n` after one signed-area-backtracked move. Three Newton +convexity-safeguard variants, all vs the shipped BFO-Picard. + +| scheme | converges? | iters (Δ<1e-3·d₀) | final d/n | note | +|---|---|---|---|---| +| **BFO-Picard** (`+√` branch, ω=0.4) | yes | **16** | **1.713** | shipped; reference | +| Newton, residual line-search | no | — (frozen) | — | λ→0.008; the `det(I+H_rec)−g` floor (Run 1) makes the search objective meaningless | +| Newton, `det>0` backtrack only | no | — (stalls) | 1.58 | recovered-H noise breaks convexity under a finite step ⇒ λ→0.002, under-deforms | +| Newton, **PD-projected H** (eps=0.05) | no | — (creeps) | 1.49 | no λ collapse, but `Δ_k` plateaus ≈2e-3 (never contracts), overshoots `max|∇φ|` past BFO, map inverts cells (move scale→0.5) | + +### Verdict — Newton/cofactor is NOT the efficiency/parallel path + +Decisive negative result, consistent with and extending the settled +memory: + +1. **It cannot beat the grading cap** (predicted): same recovered + Hessian ⇒ same `det(I+H_rec)−g` floor (Run 1). Not a grading lever. +2. **It is *less robust* than BFO at the same recovered-Hessian + quality** (new): all three convexity safeguards from the standard + remedy list fail to reach BFO's fixed point — the iteration either + freezes, stalls under-deformed (1.58), or creeps past it into a + cell-inverting state (1.49). BFO reaches d/n 1.713 in 16 iters. +3. **Root cause**: BFO's `Δφ=√((φxx−φyy)²+4φxy²+4g)−2` is not "just a + linearisation" — it is a *closed-form convex-branch solve* that + expresses the new Laplacian via `g` and only the **deviatoric** + part of the recovered Hessian, side-stepping the noisy/ + under-estimated full `det`. The cofactor-Newton operator feeds the + full noisy recovered Hessian into *both* the variable coefficient + `C_k` *and* the residual `det(I+H_k)−g`; at this recovery quality + that is fragile (non-convex repulsion) or, once convexity is + forced by projection, no longer the true MA equation (drifts + instead of contracting). UW3 forbids 2nd derivatives of mesh-var + functions, so a genuinely sharp `D²φ` (which Newton needs) is not + available — the original footgun. Newton would only pay off with a + fundamentally better Hessian / a wide-stencil MA discretisation: + research effort, **no expected grading gain (settled) and now a + demonstrated robustness loss**. Do not pursue. + +### Implication for the parallel requirement + +The validated efficiency lever stays the **factor/setup-once-reuse on +the constant BFO Laplacian** (shipped, ~10× serial via MUMPS). For +**parallel**, port that exact pattern to GAMG (the only AMG in this +build): build the GAMG hierarchy **once per `_winslow_elliptic` call** +(the operator is constant across the ~25 BFO iters) via +`snes_lag_jacobian=-2` / `KSPSetReusePreconditioner` with the constant +near-nullspace already wired, and warm-start the Krylov from the +previous Picard φ. Parallel-scalable, keeps BFO's robust convex-branch +structure, preserves grading. This — not Newton — is the parallel +work item. Script: `scripts/ma_newton_phase0.py`; data +`/tmp/metric_mesh/ma_newton_phase0.npz`. + +## BFO + GAMG-reuse parallel prototype — tested, fragile (2026-05-17) + +Wired as a *selectable* path: `_winslow_elliptic(..., +linear_solver="gamg")` (default stays `"direct"`). +`_use_iterative_solver`: FGMRES + GAMG(SOR smoother) for the elliptic +φ-Poisson — CG was *not* justified there (UW3 DMPlex-FEM assembly + +Neumann/nullspace gives no exact symmetry guarantee, and the SOR +smoother is non-symmetric ⇒ non-SPD preconditioner; FGMRES tolerates +both); CG + Jacobi for the provably-SPD mass systems. +`snes_lag_jacobian=-2` / `snes_lag_preconditioner=-2` so the GAMG +hierarchy is built **once per call** and reused across the ~25 Picard +iters (verified: φ-KSP iter count flat ≈75 once warm), Krylov +warm-started from the previous Picard φ. + +The reuse mechanism works and **grading is bit-for-bit preserved +where it converges**. But the path is **not robust and does not +scale here** (`scripts/ma_solver_scaling.py`, AMP=8, direct = serial +MUMPS): + +| RES | nodes | direct cold/warm | gamg cold/warm | d/n dir/gmg | +|----|------|------------------|----------------|-------------| +| 24 | 1748 | 3.1 / 3.8 s | 27.7 / 27.6 s | 1.712 / **1.007** ⚠ | +| 32 | 3059 | 6.9 / 8.7 s | 7.2 / 15.1 s | 1.722 / 1.722 | +| 48 | 6655 | 11.5 / 23.2 s | 16.3 / **69.2** s | 1.729 / 1.729 | + +- **res-24 fails outright** — `DIVERGED_LINEAR_SOLVE` after 0 iters, + φ≈0, d/n 1.007 (no-op). A *correctness* failure at one resolution + while 32/48 converge: the hallmark of the documented + GAMG-on-pure-Neumann + `constant_nullspace` + warm-resolve + fragility (see the `_attach_constant_nullspace` code comment and + `project-ma-efficiency-direct-solver`). +- Where it converges it is **2–3× slower than direct** and the + **warm≫cold degradation returns** (res-48: gamg warm 69 s vs cold + 16 s) — the precise pathology the direct path *eliminated*. The + gamg/direct ratio is erratic (7.3 / 1.75 / 3.0), **not** shrinking + with N: no scalability signal at feasible 2D sizes. + +### Two challenges that reshaped the verdict + +**(a) "Did you wire the nullspace in?"** Verified at runtime: yes — +on the gamg path `ps.constant_nullspace=True` attaches the constant +`MatNullSpace` to the operator, the near-nullspace *and* the KSP +operator, cold *and* warm. The divergence is **not** a missing/ +unprojected nullspace; the warm KSP runs to `its=10000`, +`reason=-3` (DIVERGED_ITS) — a GAMG *convergence* failure. The +direct path masks this entirely (MUMPS `icntl_24` null-pivot +detection solves the singular system irrespective of the PETSc +nullspace), which is why the iterative path is the first place a +conditioning problem surfaces. + +**(b) "Why P3?"** No good reason — inherited from the original BFO +implementation. Sweeping φ∈{P1,P2,P3} × {direct,gamg} +(`scripts/ma_phi_order.py`): + +| effect | finding | +|---|---| +| grading is set by φ **order**, not the solver | P2 ≡ P3 (≈1.71); **P1 is ~18 % weaker** (≈1.40) — P1 is *not* grading-equivalent, P2 is the floor | +| P3 is a **major GAMG confound** | res-24: P2+gamg converges (its=77, d/n 1.709 ✓) exactly where P3+gamg catastrophically fails (10000 its, d/n 1.007 ✗) | +| P2 does **not** fully cure GAMG | res-32 P2 *warm* still diverges — GAMG remains erratic across (res, cold/warm) even at P2 | + +### Bankable win, independent of the parallel question + +φ=P2 ≡ P3 grading to ~3 dp across AMP 0/2/8/20 on the **direct** +path (1.022/1.434/1.707/1.542 vs the recorded 1.02/1.43/1.71/1.54; +AMP=0 no-op exact; no tangle) at **~2× lower cost** (smaller +matrices — which also *helps* the direct factorisation scale, the +exact opposite of a scaling concern). **`phi_degree` default is now +2.** Canonical `cost_compare.py` at P2: MA cold ≈0.7–0.9 s (vs ~12–18 s +original), grading bit-for-bit. Combined with the factor-once-reuse +work this is ~15–20× over the original GAMG baseline. + +### Verdict & recommendation + +GAMG's failure was *partly* an own-goal (P3) — at P2 it converges in +many more cases — but P2 still leaves it **erratic on the warm +(post-`_deform_mesh`) re-solve**, so it is not a robust parallel +path yet. Combined with: no alternative AMG in this build (hypre/ML +absent), 2D sparse-direct being near-optimal at every feasible size, +and (decisively) the user's accepted position — **MUMPS direct is +fine for now; smaller matrices (P2) only help its scaling.** Keep +`linear_solver="direct"` (MUMPS — itself MPI-parallel) as the +validated path; retain `"gamg"` as experimental/documented-fragile +(do not delete — lag/reuse machinery is correct). A robust iterative +path would still need the pure-Neumann operator de-fragilised +(single Dirichlet pin, not the constant nullspace — ∇φ is unaffected +by the additive constant) and/or hypre, and is **gated behind** +parallel-exact assembly + 3D (the smoother is 2D-triangle-only, +serial-exact-assembly-only — the linear solver is *not* the parallel +bottleneck yet). Scripts: `ma_gamg_vs_direct.py`, +`ma_solver_scaling.py`, `ma_phi_order.py`, `ma_phi2_validate.py`. + +### Spring as the MA initial guess — settled (do not re-run) + +Asked whether seeding MA from the cheap `_winslow_spring` result +helps convergence. This is **settled-rejected** in +`project-ma-recovered-hessian-picard-inadequate`: spring-as-MA- +preconditioner is dead — at full AMP the spring drives a cell to +near-degeneracy and MA's signed-area backtrack *prevents* inversion +but cannot *cure* an already-degenerate start (it freezes); a +mild-spring→MA does converge but is **net slower than MA-only** +(the spring pass costs without cutting MA's ~25 Picard iters enough +to pay for itself). The mechanism is geometric — independent of +φ-order or solver speed — so the conclusion stands, and with MA now +~0.8 s the spring complexity is even less attractive. Not pursued. + +### P1 vs P2 × GAMG, scaling with #triangles (check, 2026-05-17) + +`scripts/ma_p1_gamg_scaling.py`, AMP=8, RES 16→64 (1.5k→22.7k tris): + +- **P1 does not rescue GAMG.** When P1+GAMG converges it is + textbook-good — **18–22 iters, N-independent** (vs P2's + 77→99→103, slowly growing) — confirming P1 is genuinely more + AMG-friendly. *But it still fails erratically*: P1+GAMG diverges + at res-32 (10000 its) and res-64 (r=-4, d/n collapses to 1.021 + no-op). P2+GAMG fails at 16 and 32. Neither order is reliable + across the sweep — the pure-Neumann + warm-resolve breakdown is + **order-independent and resolution-erratic**. Direct (MUMPS) is + `✓` at every (res, order). +- Grading holds at every resolution: P1 ≈1.40 (1.397–1.421), P2 + ≈1.71–1.75 — P1 is ~18 % weaker *everywhere*, not a grading + option regardless of solver. +- **More important side-finding (direct path):** the *warm* cost + scales badly with N. P2-direct warm: 1.3 s (res-16) → 17.8 s + (res-48) → **46.4 s (res-64)**, far above cold (9.5 s at res-64). + The per-call post-`_deform_mesh` rebuild + MUMPS refactorisation + + cache-invalidated `evaluate()` re-interpolation is O(N)-growing + and re-opens a warm≫cold gap at realistic resolution. This — not + the GAMG question — is the next per-timestep-scaling work item + (the res-16 warm≈cold result does not extrapolate). Scripts add + `ma_p1_gamg_scaling.py`. + +### d/n is anisotropy/sliver-blind — rim over-collapse (2026-05-17) + +User flagged the P2 rim cells as far tighter than the nominal 1/3. +`scripts/ma_radial_anisotropy.py` (res-16, AMP=8, vs undeformed): + +| | band-mean radial (rim) | **min radial** | minA/meanA | +|---|---|---|---| +| undeformed | 1.00 | 1.00 | 0.575 | +| P1 | 0.65 | 0.43 | 0.240 | +| P2 | 0.38 (~1/3) | **0.14 (~1/7)** | **0.019** | +| P3 | 0.38 | 0.13 | 0.026 | + +The reported deep/near ≈1.71 is a **per-node mean of all incident +edges** — it averages the collapsed *radial* edges with the +frozen/expanded *tangential* ones (tangential edges actually grow in +the interior; see the figure) and so hides a near-degenerate radial +sliver layer. Band-mean radial ≈0.38× matches the isotropic edge +criterion, but the **thinnest layer is ≈0.14× (~1/7)** and the +smallest cell is ~1/52 of the mean area. + +**Mechanism:** the outer ring is *pinned* (it is the boundary) and +the metric peaks *exactly at* r=R_O — equidistribution demands +maximal density where nodes cannot move, so it jams the next +ring(s) against the fixed wall into one sliver layer, **independent +of AMP**. The isotropic `AMP = 1/s² − 1` design rule is wrong here: +in an annulus all transport is radial (tangential node count +frozen) *and* a boundary-peaked metric against a pinned boundary +over-collapses the wall layer. + +**Consequences:** (1) d/n is fine as a *regression/consistency* +guard but does **not** certify mesh quality near a boundary-peaked +feature — use `minA/meanA` or a radial/tangential split. (2) Levers: +offset the Gaussian peak inward (`r=R_O−k·W`, k≈2–3) so the band +sits where nodes can redistribute on both sides; or cap AMP to a +quality floor (`minA/meanA ≥ 0.1` ⇒ AMP ≲ 3); or design the metric +from the *pinned-boundary 1-D radial OT*, not the isotropic rule. +Fig `/tmp/metric_mesh/ma_radial_profile.png`; script +`ma_radial_anisotropy.py`. + +### Localised features: GAMG is robust + the "snuggle" metric fix (2026-05-17) + +User: nodes should "snuggle up close to the feature"; the rim +example was "too local" (bulk has no metric gradient → doesn't +move). Interior blob (0.78,0), AMP=8, `ma_localised_reach_gamg.py` ++ `ma_heavytail_metric.py`: + +| metric | far/near (resolution) | inward (distant→feature) | minA | GAMG | +|---|---|---|---|---| +| Gaussian W=0.12 | 2.42 | +0.008 | 0.105 | ✓ ~30 it | +| Gaussian W=0.30 | 1.55 | +0.010 | 0.267 | ✓ ~30 it | +| **Lorentzian (core 0.12 + 1/d² tail)** | **2.74** | **+0.025** | 0.089 | ✓ ~31 it | + +- **A wider Gaussian is the WRONG fix.** One Gaussian width sets + *both* the resolution scale and the reach: narrow ⇒ sharp but + isolated pucker (bulk idle); broad ⇒ global motion but the + feature washes out (far/near→1.5). The fix is a **heavy-tailed + (Lorentzian) monitor**: a sharp core (best feature resolution, + far/near 2.74) + a slow `1/d²` tail (∇ρ≠0 everywhere ⇒ distant + nodes migrate IN ~3× more). The whole mesh rakes coherently + toward the feature (`/tmp/metric_mesh/ma_heavytail.png`). Mild + quality cost (minA 0.089 vs 0.105), no tangle. This is the + standard r-adaptation lesson (monitor needs global reach — heavy + tail or post-smoothing — not a narrow bump). +- **GAMG is ROBUST for localised interior cases — revises the + earlier verdict.** Every metric shape × width × resolution + converged in ~27–54 iters, cost competitive with direct, *zero* + failures. The earlier GAMG fragility was **specifically** the + boundary-peaked-metric-against-pinned-boundary pathology (metric + spiking where the operator is pinned/singular). For the realistic + localised-feature use case the parallel GAMG path is viable — + the blanket "GAMG fragile" should be read as "fragile only for a + metric peaked on the pinned boundary". Scripts: + `ma_localised_reach_gamg.py`, `ma_heavytail_metric.py`. + +### Polar metric + boundary slip — settled negative (2026-05-18) + +Tested "define the metric in (r,θ) so it pulls in θ" + boundary +slip. `ma_polar_lorentzian_slip{,_v2}.py`, `ma_lorentzian_slip_final.py`, +interior/near-rim feature, AMP=8 res-24 (compact Cartesian +Lorentzian at an *interior* point gave far/near 2.74 — the +reference): + +| variant | far/near | rim drift | GAMG | +|---|---|---|---| +| polar, chord 2(1−cosΔθ) | 1.38 | — | ✓ | +| polar, true wrapped angle, balanced cores | 1.12 | 1e-16 | ✓ | +| compact Cartesian Lorentzian near rim, slip off | 1.21 | 1e-16 | ✓ | +| …slip on | 1.12 (minA 0.32→0.48) | 3e-16 | ✓ | + +1. **Separable (r,θ) Lorentzian is the wrong shape** — an + anisotropic spoke, not a blob: the chord `2(1−cosΔθ)` saturates + at the antipode (no angular reach); the balanced/true-angle + version is a low-gradient radial ridge the smoother washes out + (far/near≈1.1, ≈ no-op). Use a **compact `|X−P|²` Lorentzian + about the feature point** — it has the correct combined + radial+angular extent and pulls in θ automatically (far/near + 2.74 at an interior point). +2. **Slip works mechanically, is not a concentrator.** Rim radial + drift ~1e-16 (nodes provably stay on the ring); GAMG robust + (~31 it) throughout. But slip ON near a boundary feature + *relaxes* the mesh (far/near 1.21→1.12, minA 0.32→0.48) — it + removes the hard pin so the rim equalises; it does NOT drag rim + nodes tangentially toward θ₀ (rim count near θ₀ 16→18). Slip + buys boundary *quality*, not feature *concentration*. +3. **Boundary-proximal features are choked.** The same compact + Lorentzian gives far/near 2.74 at r₀=0.78 (interior) but only + 1.21 at r₀=0.88 (near rim) — no node room between feature and + pinned wall; slip relaxes rather than fills. Same fixed-node + + pinned-boundary limit, feature side. + +Net: compact Cartesian `|X−P|²` Lorentzian about the feature point +(pulls in θ inherently); keep features with interior room; slip is +safe and good for boundary *quality* but is not the lever for a +tangential pull. Drop the polar-separable formulation. Figures +`/tmp/metric_mesh/ma_polar_slip{,_v2}.png`, `ma_lorentzian_slip.png`. + +### Angular OT target vs anisotropic scalar (2026-05-18) — (2) is a dead end + +User: the metric should exploit the *abundant tangential* node +budget (slide spare angular nodes toward the feature) rather than +the *scarce pinned radial* one. Built (1) the exact 1-D angular OT +as the target for (2) a new opt-in `move_anisotropy=(w_r,w_θ)` +that rescales the realised displacement in the local +radial/tangential frame. Angle-only feature ρ(θ)=1+AMP/(1+(Δθ/Wθ)²), +AMP=8, res-24: + +| | far/near | frac@θ₀ | minA | radial drift | +|---|---|---|---|---| +| undeformed | 1.00 | 0.159 | 0.547 | 0 | +| **(1) exact angular OT [TARGET]** | **2.21** | **0.415** | 0.209 | 1e-16 | +| (2) winslow isotropic | 0.98 | 0.158 | 0.356 | 6.8e-2 | +| (2) winslow tangential-preferred | 0.99 | 0.158 | 0.392 | 7.9e-3 | + +- **(1) is exactly right** — rakes spare angular nodes into the θ₀ + sector (frac 0.16→0.42, far/near 2.2), radius untouched (drift + 1e-16), no tangle. For separable/structured features the explicit + 1-D OT is the correct tool, used *directly*. +- **(2) is a structural dead end.** Scalar BFO on the same metric + produces ≈zero angular concentration (far/near 0.98, frac 0.158 ≈ + uniform) for *any* weighting. `move_anisotropy` works as designed + — it suppresses *spurious radial* drift (6.8e-2→8e-3) — but there + is no angular concentration to preserve: the scalar potential + never generates the coherent tangential transport. Reweighting + can shape transport the solver produces, not manufacture + transport it does not. +- **Root cause = the foundational cap, both directions.** A scalar + equidistribution potential with fixed topology cannot deliver + large coherent *bulk* transport — radial (the ~1.7 cap) *or* + tangential (here). Hoop/fixed-topology stiffness cuts both ways. + +Verdict: "(1) as a target for (2)" *proves (2) cannot reach it*. +Use the explicit 1-D OT directly for separable features +(directional / dimensional-split redistribution); the generalisable +heavy route is a true anisotropic metric-*tensor* adaptation — not +anisotropic diffusivity / move-weighting on the scalar potential. +`move_anisotropy` is kept as an opt-in *quality* knob (suppresses +off-direction drift), not a concentrator. Script +`ma_angular_ot_target.py`; fig `/tmp/metric_mesh/ma_angular_ot.png`. + +### (3) metric-tensor machinery — construction verified (2026-05-18) + +`ma_metric_tensor_viz.py`: scalar density ρ(x) → `M = (1/h0²)[I + +β ĝĝᵀ(|∇ρ|/∇ρ_ref)²]`, eigen-clamped to spacing ∈ [H_MIN,H_MAX] +(≤8:1). Desired-cell ellipses drawn on a clean polar sample grid for +a radial feature ρ(r) and an angular feature ρ(θ). Result is +correct and confirms the design: + +- Radial feature → ellipses **tangentially elongated** (short ⟂ r, + long along the ring); circular where ∇ρ→0 (crest, far field). +- Angular feature → ellipses **radially elongated** (short ⟂ θ, + long in r), concentrated in the θ₀ sector. +- **The eigenframe auto-aligns to r̂ / θ̂ with no (r,θ) frame + specified anywhere** — M was fed only the Cartesian ∇ρ. This is + the resolution of the user's (r,θ) puzzle: scalar density in, + tensor alignment emergent from its gradient; API stays scalar. +- Max anisotropy = the eigen-clamp band (8.3:1), as designed. + +Honest nuance (visible in the figure): a *gradient*-based metric +refines where ρ **changes** (the flanks) and is isotropic at a +smooth peak (∇ρ=0) and far away. Correct for "resolve the feature's +structure"; for small cells at the feature *core* use smoothed +`|∇ρ|` or the Hessian-based `M=|H(ρ)|` (curvature-aligned; needs the +recovered-Hessian path, extra cost). Gradient form is the +first-derivative, UW3-clean first cut. + +Status: the metric *construction* (the ~1-day half) is verified and +cheap. Remaining for (3): the anisotropic **mover** (metric-Winslow +/ M-weighted displacement solve — the medium-effort half), with the +standing caveat that it improves cell alignment/quality, not the +fixed-node-count cap. Fig `/tmp/metric_mesh/ma_metric_tensor.png`. + +--- + +## NEXT-PHASE KICKOFF BRIEF (read this first in a new session) + +**Goal:** build the anisotropic *mover* for approach (3). The metric +*construction* is done & verified (`ma_metric_tensor_viz.py`, +`M = (1/h0²)[I + β ĝĝᵀ(|∇ρ|/ref)²]`, eigen-clamped). What remains is +the solver that moves nodes to satisfy a tensor metric M(x). + +**Read before starting (do NOT re-derive / re-explore):** +- Memory `project-ma-efficiency-direct-solver` — the settled + dead-ends. Do not retry: Newton/cofactor; GAMG on a + boundary-peaked/pinned metric; polar-separable metrics; boundary + slip as a *concentrator*; anisotropic *reweighting of the scalar + BFO* (`move_anisotropy`) as a concentrator. All proven dead. +- This design doc, the "(3) metric-tensor machinery" + the angular- + OT section (why scalar BFO can't do coherent bulk transport — the + fixed-topology cap, both directions). +- `src/underworld3/meshing/smoothing.py`: the cache/lag/MUMPS infra, + `_use_direct_solver` / `_use_iterative_solver`, `linear_solver`, + `phi_degree=2` default, `move_anisotropy` (keep as a quality knob), + and the Phase-0 `_CofDiff` pattern (script + `ma_newton_phase0.py`) — the working example of a variable + *tensor*-coefficient `SNES_Scalar` in UW3 (reuse this for M). + +**Concrete plan:** a metric-Winslow / MMPDE M-weighted displacement +solve — `∇·(M ∇ξ)=0`-type vector system (or the M-weighted Laplace +smooth of the coordinate map), M the gradient-derived tensor field +above, move = the solved displacement, with the existing signed-area +backtrack + `boundary_slip`. Reuse: the tensor-constitutive pattern +(`_CofDiff`-style `DiffusionModel` subclass with `_c = M`), the +factor-once-reuse solver options, the cache. Validate on the SAME +model problems with the SAME honest, anisotropy-aware diagnostics +(`ma_radial_anisotropy.py`: minA + radial/tangential split, NOT +d/n) and against the explicit 1-D OT target (`ma_angular_ot_target.py`, +`ma_analytic_check.py`). + +**Standing caveat (accepted by the user):** (3) improves cell +alignment/quality and removes the slivers/wasted-isotropic-resolution +— it does **not** beat the fixed node-count cap (that needs +`mesh.adapt`). For separable features the explicit 1-D OT (method 1) +stays exact and strictly cheaper; (3) earns its keep only for the +general non-separable case. Gradient-based M refines feature *edges*; +Hessian-based `M=|H(ρ)|` (curvature-aligned, needs the recovered- +Hessian path) is the follow-up if core-resolution is needed. + +**Scope estimate:** ~1–2 weeks to a validated prototype on the +Annulus model problems. New feature branch off +`feature/winslow-mesh-smoother`. Effort is the solver + its +validation arc, not the metric (done). + +--- + +## (3) anisotropic mover — IMPLEMENTED & VALIDATED (2026-05-18) + +Branch `feature/anisotropic-metric-mover` (off +`feature/winslow-mesh-smoother`). `_winslow_anisotropic` in +`smoothing.py`; `smooth_mesh_interior(..., method="anisotropic")`. + +### Formulation (as built) + +Displacement form of the **decoupled direct** M-weighted Laplace +(Winslow) coordinate map. Per physical component `c`: + +$$ \nabla\!\cdot(D\nabla u_c) = -\textstyle\sum_j\partial_j D_{jc}, + \qquad u_c=0 \text{ on the pinned boundary}, $$ + +so `ψ_c = x_c + u_c` solves `∇·(D∇ψ_c)=0`, `ψ=x` on the boundary +(the direct Winslow smoother — clusters nodes where `D` is large). +`D = M` (the verified eigen-clamped `M = (1/h0²)[I + β ĝĝᵀ +(|∇ρ|/gref)²]`). The two components share the *same* tensor +operator `_c = D` via a `_CofDiff`-style `DiffusionModel` +subclass; reuses `_use_direct_solver` (factor-once), the cache, +the signed-area backtrack, `boundary_slip`, `move_anisotropy`. +**Linear** — one solve/component/step, no Picard (cheaper than the +BFO `_winslow_elliptic`). Homogeneous Dirichlet ⇒ non-singular ⇒ +**no `constant_nullspace`**, side-stepping the GAMG-pure-Neumann +fragility entirely. + +### Two formulation findings (do NOT re-derive) + +1. **The metric must be built ONCE and held fixed & Lagrangian** + (like `_winslow_spring`'s rest-lengths/A0). Re-projecting ∇ρ on + the progressively distorted mesh inside the outer loop is a + *positive feedback* — `D` blows up on squashed cells → + catastrophic over-collapse (minA/meanA → 1e-3). With `D` fixed, + the outer loop is a stable damped fixed-point iteration of one + linear operator toward the M-harmonic map. +2. **The decoupled direct Winslow form has no + Rado–Kneser–Choquet non-folding guarantee**, so its stable + regime is bounded by the metric anisotropy/contrast. A single + un-damped elliptic jump folds; under-relaxation (`relax`) + + `n_outer` damped steps is required (the analogue of the BFO + `picard_relax=0.4`). Characterised Pareto frontier + (`scripts/aniso_param_sweep.py`, interior radial feature): `β` + is *not* the binding lever — the **eigen-clamp `aniso_cap`** is. + + | `aniso_cap` | needs | minA/meanA | note | + |---|---|---|---| + | 2 | `relax≈0.1–0.2` | **≈0.47–0.50** | robust default | + | 4 | `relax≈0.05`, `n_outer≳25` | ≈0.35 | sharper, still clean | + | ≳6 | — | ≲0.02 (folds) | needs coupled/inverse Winslow | + + Defaults shipped: `aniso_cap=2`, `relax=0.2`, `n_outer=12`, + `β=200`. AMP=0 is an **exact isotropic no-op** (a scale-aware + `g_eps=1e-9` floor rejects the ~1e-18 projection round-off of a + uniform-ρ zero gradient — without it the noisy `gref` fabricated + O(1) anisotropy). + +### Validation arc (anisotropy-aware: radial/tangential split + +minA/meanA, NOT the anisotropy-blind d/n; grids rendered) + +| problem (res, AMP=8) | metric | (3) minA/meanA | isotropic MA | spring | +|---|---|---|---|---| +| radial @R_O (pathology) | — | **0.240** | 0.019 | 0.177 | +| radial interior r=0.70 | — | **0.466** | 0.182 | 0.253 | +| angular-only (separable) | — | **0.243** | 0.144 | — | +| non-separable blob | — | **0.295** | 0.109 | 0.119 | + +- **(3) is the cleanest method everywhere** — 2.6–12× better + minA/meanA than the isotropic MA, never slivers, linear/cheap + (~3 s res-16, no Picard). +- **Concentration is milder** than MA (radial interior far/near ≈ + MA; non-separable far/near 1.10 vs MA 1.37; angular ≈ uniform). + (3) trades grading *magnitude* for clean anisotropic *cell + alignment* — exactly its intended role. +- **Separable features confirm the settled cap**: angular-only + (3) ≈ uniform concentration (far/near 1.02, frac@θ0 0.160) — it + CANNOT beat the explicit 1-D OT (`ma_angular_ot_target.py` + target far/near 2.21), same fixed-topology limit as the scalar + paths. (3) is for the **non-separable** case + quality, not + separable concentration. +- Figures: `/tmp/metric_mesh/aniso_radial_peak{1p00,0p70}.png`, + `aniso_angular.png`, `aniso_nonsep.png` (the non-separable zoom + is the clearest: MA/spring pull a degenerate slivered knot into + the blob; (3) gives a clean, well-shaped, blob-aligned + densification). + +### Verdict + +A **validated prototype matching the brief**: (3) improves cell +alignment/quality and removes the slivers/wasted isotropic +resolution; it does **not** beat the fixed node-count cap (the +explicit 1-D OT stays exact + cheaper for separable features). +Open follow-ups (out of prototype scope): the **coupled/inverse** +Winslow (RKC-non-folding) to admit `aniso_cap ≳ 6`; Hessian-based +`M=|H(ρ)|` for feature-core resolution; parallel-exact assembly. +Scripts: `aniso_smoke.py`, `aniso_param_sweep.py`, +`aniso_validate_{radial,angular,nonsep}.py`, +`aniso_blob_metric.py` (target-vs-realised), `aniso_convection_demo.py` +(Ra=1e5 → refine on ∇T). + +### Architecture (pipeline & components) + +`_winslow_anisotropic` in `src/underworld3/meshing/smoothing.py`; +reached via `smooth_mesh_interior(mesh, metric=ρ, +method="anisotropic")`. `ρ` is a target *density* (larger ⇒ finer) +— typically a Lagrangian `f(frozen_field.sym)`. + +**Cache build (once per mesh/topology/params key):** + +1. `grho` — projected `∇ρ`: a `Vector_Projection` with + `uw_function = [ρ.diff(Xᵢ)]`, `smoothing=0`. A *first* derivative + of the Lagrangian density only (UW3-legal). +2. `Df` — a `TENSOR` MeshVariable holding the metric tensor; + initialised to the identity. +3. `_TensorDiff(DiffusionModel)` — `_build_c_tensor` sets + `_c = Df.sym` (the `_CofDiff` pattern from `ma_newton_phase0.py`: + a variable tensor-coefficient `SNES_Scalar`). +4. Per coordinate component `c`: a scalar `uw.systems.Poisson` with + that constitutive tensor, source + `f_c = Σⱼ ∂D_{jc}/∂xⱼ`, **homogeneous Dirichlet `u_c=0`** on the + pinned boundary (non-singular → no `constant_nullspace` → no + GAMG-pure-Neumann fragility), wired to `_use_direct_solver` + (MUMPS, factor-once-reuse) or the `_use_iterative_solver` GAMG + path. (`boundary_slip=True` ⇒ pure-Neumann + `constant_nullspace` + + ring-projection instead, as in `_winslow_elliptic`.) + +**Per call:** + +5. **Build `D` ONCE on the undeformed mesh.** `gproj.solve()`; + per node `M = (1/h₀²)[I + β ĝĝᵀ(|∇ρ|/gref)²]`; eigen-decompose; + **clamp eigenvalues** to `[1/h_max², 1/h_min²]` (the `aniso_cap` + band); reassemble → write `Df`. A scale-aware `g_eps=1e-9` floor + makes uniform ρ an exact no-op (rejects the ~1e-18 projection + round-off of a zero gradient). `D` is thereafter **fixed and + Lagrangian** — it rides material points through `_deform_mesh`; + re-projecting it each step is the positive-feedback collapse + (settled). +6. **Damped MMPDE outer loop** (`n_outer` steps): solve the `cdim` + displacement Poissons `∇·(D∇u_c) = −Σⱼ∂ⱼD_{jc}` (so `ψ=x+u` is + the M-harmonic coordinate map); optional `move_anisotropy` + reweight; `step = relax·disp`; **coherent global signed-area + backtrack** (halve the scale until no triangle inverts) + slip + ring-projection; `mesh._deform_mesh`; stop when + `max|Δx| < outer_tol`. + +Reuses `_winslow_elliptic`'s backtrack, `boundary_slip`, +`move_anisotropy`, the solver cache and the MUMPS +factor-once-reuse wiring verbatim. **Linear** — one solve per +component per outer step, no Picard (cheaper than the BFO MA). + +### GAMG parity + cost per step (2026-05-18 — measured) + +`scripts/aniso_cost_and_gamg.py`, interior radial feature, res +16/24/32/48 (1.5k–12.9k tris), `direct` vs `gamg`. Times: **cold** +(fresh mesh — MeshVariable+solver creation + 1st factorisation, +one-off per remesh), **warm** (same mesh object, cache hit — the +genuine per-timestep cost in a dynamic loop), per-outer-step, and +the D-build. + +| res | ntri | warm direct | warm gamg | warm/outer | D-build | minA/meanA | +|----|------|------|------|------|------|------| +| 16 | 1522 | 3.08 s | 3.26 s | 0.25 s | 0.34 s | 0.4657 | +| 24 | 3268 | 6.29 s | 6.30 s | 0.51 s | 0.64 s | 0.4256 | +| 32 | 5814 | 10.94 s | 10.94 s | 0.89 s | 1.11 s | 0.3938 | +| 48 | 12856 | 23.72 s | 23.98 s | 1.94 s | 2.41 s | 0.4452 | + +- **GAMG is robust here — bit-parity with direct** + (`|minA_g−minA_d| ≤ 5e-5` at every resolution). The mover is + **non-singular** (homogeneous Dirichlet, no constant nullspace), + so it does **not** hit the pure-Neumann + warm-resolve fragility + that made the MA `gamg` path erratic. This is the **first** of + the three metric methods with a working parity-preserving + parallel-capable solver path. (At feasible 2D sizes MUMPS is + near-optimal so `gamg` is not *faster* — the point is it *works + and matches*, so the parallel route is real.) +- **cold ≈ warm at every resolution** — no warm-≫-cold + degradation (the MA path's O(N) post-deform rebuild pathology is + absent here; the cache reuses the MeshVariables/solvers, only the + operator is refactorised because `D`+geometry change each call). +- **Cost is ~O(N) (linear in #cells).** warm 3.1→23.7 s for + ntri 1522→12856 (≈7.7× for ≈8.4× cells); per-outer-step and + D-build likewise ~O(N). No superlinear blow-up — the per-step + work is a fixed number of **sparse SPD-ish elliptic solves** + (the part GAMG parallelises with optimal O(N/P) complexity and + good weak scaling) plus embarrassingly-local per-node / + backtrack work. +- **The cost lever is `n_outer`.** Default 12 ⇒ ~12 scalar + elliptic solves of the mesh size. The damped MMPDE converges + (most displacement is in the first few steps; `max|Δx|` decays), + so an `outer_tol` early-exit / a small `n_outer` cuts the warm + cost to ≈ `D-build + 3–5 · warm/outer` (≈1.5–2 s at res-16). The + per-step adaptation is then ≈ *a handful of pressure-solve-class + SPD solves* — genuinely cheap for an r-adaptation scheme (most + need nonlinear solves or global transport; this does not). +- Honest hotspot: the per-node eigen-clamp is a Python loop + (`np.linalg.eigh` per node) — vectorisable to a batched + `eigh` on a stacked `(N,d,d)` array (a cheap win, matters more in + 3D / at scale); currently dominated by the solves anyway. + +**Parallel verdict (the user's hypothesis, now evidenced):** the +per-step cost is `1 ∇ρ projection + a vectorisable eigen-clamp + +n_outer × (cdim non-singular SPD elliptic solves + a local +backtrack)`, all O(N) and GAMG-parallelisable with proven +2D parity. This is one of the few r-adaptation strategies with +**no nonlinear solve and no global transport** — structurally +inexpensive in parallel. (Caveat: the *assembly* — ∇ρ projection / +D-build / backtrack — is still serial-exact; the parallel-exact +cross-rank version is the remaining piece, not the solver.) + +### Solver limitations + +- **2D triangle meshes only** (hard `NotImplementedError`). +- **Decoupled direct Winslow form → no Rado–Kneser–Choquet + non-folding guarantee.** Stable only for modest anisotropy: + `aniso_cap≈2` (robust default), `≈4` with gentler `relax` + more + `n_outer`, **`≳6` folds regardless**. The backtrack prevents + *inversion*, not extreme squashing — a property of the + formulation, not a tuning miss. +- **Fixed node budget** — relative redistribution only; cannot + beat the node-count cap. For *separable* features the explicit + 1-D OT is exact and strictly cheaper. +- **Gradient metric resolves edges/fronts, not cores** — + isotropic-coarse (de-refined) where `∇ρ=0` (a smooth peak). Right + tool for boundary layers / interfaces / fronts; wrong tool for + resolving a smooth peak's centre (→ Hessian metric). +- **Metric is Lagrangian-fixed** (built once). A tensor metric + should co-rotate with large deformation; we don't — fine for + modest moves, not large-strain. +- **Serial-exact assembly only** — the ∇ρ projection / `D` build / + backtrack under-count at rank-partition boundaries (same caveat + as spring/MA). The *solver* is no longer the parallel blocker + (GAMG validated, see the cost section); the cross-rank + parallel-exact assembly is the remaining piece. MUMPS scales to + modest sizes; GAMG is the route beyond. +- **Linear, component-decoupled** — an anisotropic Laplacian + smoother, not the full nonlinear (Jacobian-coupled) Winslow + generator. + +### Corners still unexplored + +- **Solution-accuracy proof.** Validated mesh *quality + alignment* + only — NOT yet that it *helps the PDE* (lower T-discretisation + error / better Nu at fixed node count vs a uniform mesh). That + accuracy/cost study is the real payoff and is untested. +- **Dynamic-adaptive loop.** The demo is static ("20 steps then + refine once", `aniso_convection_demo.py`). Re-refining every N + steps with the metric riding the flow (ALE-style, interacting + with SLCN advection / the free-surface ALE) — the production use + case — is unexplored. +- **Coupled / inverse Winslow** (computational ξ harmonic in + physical space → RKC-non-folding) to safely admit `aniso_cap ≳ 6` + and stronger alignment. The heavy MMPDE (map inversion / + resampling). +- **Hessian metric `M=|H(ρ)|`** (curvature-aligned) for feature- + *core* resolution — reuse the recovered-Hessian path + (`_hessian_recovery_class`; first-derivative L2 recovery, since + UW3 forbids 2nd derivatives of mesh-var functions). +- **A `metric_from_gradient`-style ρ helper** unifying the metric + API across `mesh.adapt` (absolute `h`, MMG re-meshes) and the + mover (relative `ρ`, fixed budget) — discussed, not built. +- **GAMG path — VALIDATED (2026-05-18), see the cost section.** + Bit-parity with direct at res 16–48 (non-singular ⇒ no + pure-Neumann fragility); the parallel-scalable route is real. + *Remaining*: cross-rank **parallel-exact assembly** (the ∇ρ + projection / D-build / backtrack are serial-exact — the solver + is not the blocker), and a true MPI weak-scaling study. +- **3D extensibility — concrete scope.** Already + dimension-general: the metric formula + `M=base[I+β ĝĝᵀ(|∇ρ|/gref)²]`, the eigen-clamp + (`np.linalg.eigh` works for 3×3), the `TENSOR` MeshVariable + (`dim²` comps), the displacement form `∇·(D∇u_c)=−Σⱼ∂ⱼD_{jc}` + over `c=0..cdim−1`, the per-component `Poisson` + `_TensorDiff` + (3×3 `_c`), and the solver wiring — and GAMG (now proven for + this operator) is exactly what makes 3D viable (3D sparse-direct + does not scale). 2D-specific work to remove: the + `cdim!=2` guard; `_tri_cells`/`_signed_areas` → + `_tet_cells`/`_signed_volumes` for the inversion backtrack (the + main piece — a shared limitation with spring/MA); ~5 lines of + the eigen-clamp / `Df.array[:,i,j]` writes generalised to + `cdim`; `boundary_slip`/`move_anisotropy` stay 2D (default + off/None). Modest, well-scoped (~1–2 days) — the solver core is + already dim-general; the careful step is validating the tet + signed-volume backtrack before it lands in the shared smoother. +- **Auto-tuning** `aniso_cap`/`relax`/`n_outer` (largest cap that + keeps `minA/meanA` above a floor — the Pareto frontier is + characterised but not automated). +- **Free-surface / deformed-boundary slip** (polyline projection — + shared open item with spring/MA). + +--- + +## NEXT-PHASE KICKOFF BRIEF — dynamic adaptive convection (read first) + +**Phase just closed (2026-05-18):** the anisotropic mover is a +validated 2D prototype, GAMG-parity, ~O(N), and the **API is +locked in**: + +- `uw.meshing.smooth_mesh_interior(mesh, metric=ρ, + method="anisotropic", method_kwargs=dict(aniso_cap=2.0, + relax=0.2, n_outer=12, linear_solver="direct"))` +- `uw.meshing.metric_density_from_gradient(mesh, field, amp=8.0, + lo_percentile=50, hi_percentile=97)` → the Lagrangian + `ρ = 1+amp·t` density (the relative analogue of + `adaptivity.metric_from_gradient`; cached for per-step use). +- Docs: `docs/advanced/mesh-adaptation.md` (peer to `mesh.adapt`), + `docs/developer/subsystems/mesh-metric-redistribution.md`, + this design note. +- Test harness: `scripts/adaptive_convection_harness.py`. + +**Goal of the next phase:** a *correct* dynamic-adaptive +convection solve — coarse adaptively-snuggled mesh reproducing a +fine uniform reference. The harness already runs the comparison +(Ra=1e5, uniform res-24 reference vs res-16 adaptive, +`Nu(t)`/`vrms(t)` rms error, figure). + +**THE open piece — the node-update / ALE correction.** When the +mover displaces nodes by `Δx` over the step interval `Δt`, the +mesh has velocity `v_mesh = Δx/Δt`. The SLCN advection–diffusion +must transport along the material velocity *relative to the moving +mesh*: `V_fn = v_fluid − v_mesh` for the post-adapt step (ALE), or +T must be conservatively remapped onto the moved nodes. Without it +the pure coordinate move is read as a spurious advection of T. +**Precedent is settled in this codebase:** the free-surface ALE +finding (memory `project_freesurface_ale_design` — a Lagrangian +mesh move needs `V_fn = v − v_mesh` or convection is +non-physically damped, Nu ~57 vs 143). The hook is +`apply_adaptation_correction` in the harness: `--correction none` +is the uncorrected baseline (expected to drift — it *quantifies* +the error the correction must remove); `--correction ale` raises +with the spec. **Acceptance test:** harness `rms ΔNu(adaptive +res-16 vs uniform res-24)` small with the correction, large +without. + +**Other follow-ups, priority order:** +1. ALE correction + harness acceptance (above) — the headline. +2. **3D port** — scoped ~1–2 days. The solver core is already + dimension-general; the 2D-specific work is the tet + signed-volume inversion backtrack (`_tri_cells`/`_signed_areas` + → tet) + dropping the `cdim!=2` guard + ~5 generalised lines. + **The metric stays `1/h²` per principal direction in 3D — it is + NOT `1/h³`** (a Riemannian metric measures *edge length*, which + is 1-D regardless of embedding dimension: `eᵀMe=1` ⇒ eigenvalue + `1/h²`; dimension enters only the complexity integral + `∫√(det M)` via `det M = ∏1/hᵢ²`). For the *mover* the overall + `D` scale is moreover irrelevant (the displacement PDE is + invariant under `D→αD`) — only the anisotropy/contrast ratios + matter, so 3D needs no scaling change at all. +3. Parallel-exact cross-rank assembly + MPI weak-scaling (GAMG + solver path already validated bit-parity). +4. Hessian metric `M=|H(ρ)|` for feature-*core* resolution. +5. `aniso_cap`/`relax`/`n_outer` auto-tuning to a `minA/meanA` + floor. + +**How to resume:** run +`python scripts/adaptive_convection_harness.py --correction none` +for the baseline error, then implement `apply_adaptation_correction` +(`--correction ale`) and re-run to show the gap closes. diff --git a/docs/developer/design/media/adapt_convection_a16x.png b/docs/developer/design/media/adapt_convection_a16x.png new file mode 100644 index 000000000..280588347 Binary files /dev/null and b/docs/developer/design/media/adapt_convection_a16x.png differ diff --git a/docs/developer/design/media/adapt_metric_tensor_construction.png b/docs/developer/design/media/adapt_metric_tensor_construction.png new file mode 100644 index 000000000..263703854 Binary files /dev/null and b/docs/developer/design/media/adapt_metric_tensor_construction.png differ diff --git a/docs/developer/design/media/adapt_nonseparable_validation.png b/docs/developer/design/media/adapt_nonseparable_validation.png new file mode 100644 index 000000000..abed67dec Binary files /dev/null and b/docs/developer/design/media/adapt_nonseparable_validation.png differ diff --git a/docs/developer/design/mesh-adaptation-formulation.md b/docs/developer/design/mesh-adaptation-formulation.md new file mode 100644 index 000000000..25334f095 --- /dev/null +++ b/docs/developer/design/mesh-adaptation-formulation.md @@ -0,0 +1,573 @@ +# Mesh adaptation by metric-driven node redistribution — mathematical formulation + +> **Scope.** This is the self-contained *mathematical* reference for the +> topology-preserving mesh-adaptation family in UW3 +> (`uw.meshing.smooth_mesh_interior`). It derives the three solution +> strategies — **optimal-transport / Monge–Ampère**, the **volumetric +> elastic spring**, and the **anisotropic metric-tensor (Winslow/MMPDE) +> mover** — the gradient-metric construction, the handling of fields +> under mesh motion in time-dependent problems, and the Nusselt +> diagnostic. Operational guidance (when to use which, parameters) is in +> {doc}`/developer/subsystems/mesh-metric-redistribution` and +> {doc}`/advanced/mesh-adaptation`; the dated R&D log is +> `ma-newton-cofactor-exploration.md`. Formulae here are transcribed +> from `src/underworld3/meshing/smoothing.py`. + +## 1. The equidistribution principle + +All three strategies share one goal. Given a strictly-positive +**monitor** (target density) field $\rho(\mathbf x)$ — larger where the +mesh should be finer — find a coordinate map that, **at fixed topology +and fixed node count**, redistributes the interior nodes so the cell +size tracks $\rho$. In $d$ dimensions the design criterion is + +$$ h(\mathbf x)\;\propto\;\rho(\mathbf x)^{-1/d}, $$ + +equivalently the *equidistribution* condition that the monitor mass per +cell be uniform, + +$$ \rho(\mathbf x)\,\bigl|\det \mathbf J\bigr| \;=\; \text{const}, + \qquad \mathbf J=\partial\mathbf x/\partial\boldsymbol\xi, $$ + +with $\boldsymbol\xi$ the (uniform) computational coordinate. Boundary +vertices are pinned (or slide tangentially, {ref}`§6 `), so +the domain $\Omega$ is unchanged — this is *redistribution*, **not** +re-meshing. + +```{important} +**The fixed-node-count cap.** With a fixed number of nodes and fixed +connectivity, the achievable grading is bounded. For an 8–20× +density-contrast target the realisable deep/near edge-length ratio is +only ≈1.5–1.8×; the exact optimal-transport map is ≈10×. Reaching the +latter needs *more nodes* — a topology change (`mesh.adapt` / MMG), not +this smoother. Every fixed-topology local method (graph-Laplacian, +weighted-Laplacian, all Monge–Ampère variants, the elastic spring) +converges to the same ≈1.0×–1.8× band: the cap is intrinsic to +fixed-topology redistribution, not a solver deficiency. The strategies +below differ in *cell shape/alignment quality* and *cost*, not in their +ability to exceed this cap. +``` + +## 2. Strategy A — Optimal transport / Monge–Ampère + +### 2.1 Brenier map and the Monge–Ampère equation + +The $L^2$-optimal map carrying the uniform measure to the target +measure $\propto\rho$ is, by Brenier's theorem, the gradient of a +**convex** potential, $\mathbf x=\nabla\Phi(\boldsymbol\xi)$. +Substituting into the equidistribution condition gives the +**Monge–Ampère equation** + +$$ \rho\bigl(\nabla\Phi\bigr)\,\det\!\bigl(D^2\Phi\bigr)\;=\;c . $$ + +Writing the map as a perturbation of the identity, +$\mathbf x=\boldsymbol\xi+\nabla\varphi$ (so $D^2\Phi=I+D^2\varphi$), +the implementation solves + +$$ \det\!\bigl(I+D^2\varphi\bigr)\;=\;g, + \qquad g \;=\; \frac{c\,\rho_{\mathrm{cur}}}{\rho_{\mathrm{tgt}}}, $$ + +and moves nodes by $\nabla\varphi$. The normalisation constant is +chosen so that a **uniform monitor is an exact no-op**: + +$$ c \;=\; \Bigl\langle\, b^{-1/2}\,\Bigr\rangle^{-2}, + \qquad b=\rho_{\mathrm{tgt}}\,\rho_{\mathrm{cur}} ,$$ + +(`c = 1/mean(1/sqrt(b))**2`), which makes the first Picard iterate +mean-zero so that $\rho_{\mathrm{tgt}}\!=\!\text{const}\Rightarrow +\nabla\varphi\equiv 0$. + +### 2.2 Benamou–Froese–Oberman convex branch (2-D) + +In 2-D, $\det(I+D^2\varphi)=(1+\varphi_{xx})(1+\varphi_{yy})-\varphi_{xy}^2$. +Setting this equal to $g$ and solving the resulting quadratic for the +Laplacian $\Delta\varphi=\varphi_{xx}+\varphi_{yy}$ gives the two +roots; the **convex (Brenier) branch** is the $+\sqrt{\cdot}$ one: + +$$ + \boxed{\;\Delta\varphi \;=\; + \sqrt{(\varphi_{xx}-\varphi_{yy})^2 + 4\,\varphi_{xy}^2 + 4\,g} + \;-\;2\;} +$$ + +(`f_src = sqrt((Hxx-Hyy)**2 + 4*Hxy**2 + 4*g) - 2`). The $+\sqrt{}$ +selects the convex root unconditionally — this is what makes the +iteration stable without an explicit convexity safeguard. It is a +**closed-form convex-branch solve**, not a linearisation: the new +Laplacian is expressed through $g$ and only the *deviatoric* part of +the Hessian, side-stepping the noisy/under-estimated full $\det$. + +### 2.3 Damped Picard, recovered Hessian, the move + +The equation is solved by a **damped Picard iteration**: each iterate +solves a *constant-coefficient* Poisson problem for $\varphi$ with the +above source evaluated at the previous Hessian, then under-relaxes, + +$$ + \varphi \;\leftarrow\; (1-\omega)\,\varphi + \;+\;\omega\,\varphi^{\text{solve}}, \qquad \omega\approx 0.4 ; +$$ + +without the relaxation the recovered Hessian grows unbounded and the +(otherwise well-posed) Neumann solve diverges. The Poisson operator is +**pure-Neumann** (the map's natural BC is $\nabla\varphi\cdot\hat n=0$) +and is closed with a constant nullspace. + +Because UW3 forbids second derivatives of mesh-variable functions, the +Hessian is obtained by a **variationally-consistent first-derivative +recovery** — the SPD mass-matrix system + +$$ + \int H_{ij}\,\tau_{ij}\,dV \;+\; + \int \frac{\partial\varphi}{\partial x_i}\, + \frac{\partial\tau_{ij}}{\partial x_j}\,dV \;=\;0 + \quad\Longrightarrow\quad + H_{ij}\approx\frac{\partial^2\varphi}{\partial x_i\partial x_j}, +$$ + +i.e. the weak form of $\int H_{ij}\tau_{ij}=-\int\partial^2_{ij}\varphi\, +\tau_{ij}$ integrated by parts (boundary term dropped = natural). Only +first derivatives of $\varphi$ appear. + +Nodes are then displaced by $\nabla\varphi$ subject to a **coherent +global signed-area backtrack**: a single scalar step factor is halved +until *no triangle inverts* (orientation of every cell preserved), +guaranteeing a valid mesh. (UW3's `SNES_Poisson` uses $F_0=-f$, so the +source is applied with a sign, `_EQUIDIST_SIGN=-1`, that makes the +validated linear first iterate $\Delta\varphi=(g-1)$ grade nodes toward +high target density.) + +### 2.4 The 1-D exact reference (separable features) + +For a *separable* monitor (e.g. radial $\rho(r)$ on an annulus, or +angular $\rho(\theta)$) the exact equidistribution map is a 1-D +**cumulative-mass inversion**, computable to machine precision with no +FE solve: place node radii $r_k$ so that equal target mass + +$$ +m(r)=\int_{R_i}^{r}\rho(s)\,s\,\mathrm{d}s +$$ + +(the $s\,\mathrm{d}r$ is the 2-D polar area element) lies between +consecutive shells, $r_k=m^{-1}(k/N)$. This is the optimal-transport +map under radial symmetry; it achieves the full ≈10× grading and is +*exact and strictly cheaper than any FE solve* — for separable features +it is the tool of choice. It also serves as the ground-truth target +against which the FE strategies are measured. + +```{note} + +**Why the single FE Monge–Ampère solve caps at ≈1.5–1.8×.** Every +FE-MA-potential variant (linear Picard; recovered-Hessian Picard, +smoothed and variational; BFO convex-branch + damping; outer map +composition) converges to the *same* ≈30 %-of-exact, self-consistent +but under-deformed (non-Brenier / weak-branch) transport map — right +shape and sign, never tangling, but deep/mid nodes move only ~30 % of +the exact distance. This is a property of the FE-MA-potential +*formulation at fixed topology*, not of the linear solver, Hessian +recovery, branch, resolution, or single-vs-composed solves. The coupled +$(\varphi,H)$ Newton SNES solves the same equation ⇒ same ceiling. +Strategy C exists because a *scalar* potential cannot deliver coherent +*anisotropic* bulk transport at fixed topology either. + +``` + +## 3. Strategy B — Volumetric elastic-spring equilibrium + +Decouple **shape** from **size**. Every mesh edge is a linear spring of +*uniform* rest length $\bar L$ (the current mean edge), a pure shape +regulariser that drives cells equant and kills slivers; the *size* +grading lives entirely in a per-cell area target. Minimise the truss +energy + +$$ + E(\mathbf x)\;=\; w_{\text{shape}} + \sum_{e}\Bigl(\tfrac{|\mathbf x_i-\mathbf x_j|-\bar L}{\bar L}\Bigr)^{2} + \;+\; w_{\text{size}} + \sum_{t}\Bigl(\tfrac{A_t-A^0_t}{A^0_t}\Bigr)^{2}, +$$ + +with per-cell target area $A^0_t\propto 1/\rho_{\mathrm{tgt}}$, rescaled +so $\sum A^0_t=\sum a^{\text{init}}_t$ (total area conserved — pure +redistribution). Defaults $w_{\text{shape}}=1,\ w_{\text{size}}=8$; +results are robust to them. Minimised by **Jacobi-preconditioned +nonlinear conjugate gradients** (Polak–Ribière$^+$) with an Armijo line +search that rejects any cell-inverting trial — the tangle guard lives +*inside* the optimiser, so it converges to the true equilibrium rather +than creeping against a per-sweep freeze. Fast (≈0.3 s on a res-16 +annulus), robust, never degenerates; slightly streaky/anisotropic at +sharp interior features. + +## 4. Strategy C — Anisotropic metric-tensor mover (production) + +A scalar equidistribution potential is isotropic and, at fixed +topology, cannot produce coherent anisotropic bulk transport. Strategy +C instead reshapes cells with a **gradient-derived anisotropic metric +tensor** and an M-weighted harmonic (Winslow / MMPDE) coordinate map. + +### 4.1 The gradient-derived metric tensor + +From the scalar density $\rho$, form the *projected* gradient +$\nabla\rho$ (a **first** derivative — UW3-clean; via a +`Vector_Projection`), and at each node build + +$$ +\boxed{\;M \;=\; \frac{1}{h_0^{2}} + \Bigl[\, I \;+\; \beta\,\hat{\mathbf g}\hat{\mathbf g}^{\mathsf T} + \bigl(|\nabla\rho|/\nabla\rho_{\mathrm{ref}}\bigr)^{2}\Bigr],\qquad + \hat{\mathbf g}=\nabla\rho/|\nabla\rho|\;} +$$ + +(`M = base*(I + beta*(gn/gref)**2 * outer(gh,gh))`), then +**eigen-clamp**: $M=\sum_i\lambda_i\mathbf v_i\mathbf v_i^{\mathsf T}$, +clip $\lambda_i\in[\,1/h_{\max}^2,\;1/h_{\min}^2\,]$ with +$h_{\min}=h_0/\sqrt{\texttt{aniso\_cap}}$, +$h_{\max}=h_0$, and reassemble. $h_0$ is the mean edge length; +$\nabla\rho_{\mathrm{ref}}$ the max projected $|\nabla\rho|$. + +The eigenframe **auto-aligns to the feature from the Cartesian +$\nabla\rho$ alone** — no $(r,\theta)$ frame is supplied anywhere +(figure below). A radial feature yields tangentially-elongated cells +(short $\perp\hat{\mathbf r}$, long along the ring); an angular feature +yields radially-elongated cells. Being a **gradient** metric it refines +where $\rho$ *changes* (feature edges/flanks) and is isotropic-coarse at +a smooth peak ($\nabla\rho\to0$) and in the far field — the correct +behaviour for resolving fronts/interfaces; resolving a feature *core* +needs a curvature (Hessian) metric instead. + +#### Single-knob equidistribution (`resolution_ratio`) + +The anisotropic term is positive-semidefinite, so the bare metric is +$M\succeq\tfrac1{h_0^2}I$: it can **only refine** (it keeps just +$\nabla\rho$ and discards $\rho$'s magnitude, so it never asks for a +cell coarser than $h_0$). On a **fixed node budget** that is fatally +one-sided — flat regions cannot release nodes, the globally-steepest +feature scavenges the budget and the interior plumes starve. *This is +structural, not a tuning deficit:* no `aniso_cap`, $\beta$, or +percentile setting frees the budget; they only re-aim one that is +never released. + +The fix makes the isotropic part a genuinely **equidistributed** +density. Evaluate $\rho$ on the (near-uniform, *undeformed*) metric +mesh, form the geometric mean $G=\exp\langle\ln\rho\rangle$, and set + +$$ +\boxed{\;M \;=\; s(\mathbf x)\bigl(I + \;+\; \beta\,\hat{\mathbf g}\hat{\mathbf g}^{\mathsf T} + \bigl(|\nabla\rho|/\nabla\rho_{\mathrm{ref}}\bigr)^{2}\bigr), + \qquad + s(\mathbf x)=\tfrac1{h_0^{2}}\;\frac{\rho(\mathbf x)}{G}\;} +$$ + +eigen-clamped to $\lambda_i\in[\,1/h_{\max}^2,\,1/h_{\min}^2\,]$ with +**$h_{\min}=h_0/R$, $h_{\max}=h_0R$** for the single knob +$R=\texttt{resolution\_ratio}$. Because $\langle\ln s\rangle=\ln +(1/h_0^2)$, the node budget is **centred**: steep regions ($\rho>G$) +refine and flat regions ($\rho 1` (broken on reset path — cached projection + goes stale) +- `boundary_slip` mode string — automatic: uses + `mesh._boundary_tangent_project` if defined, else falls back + to pinned boundaries +- The "reset" itself — caller doesn't see it; just calls + `mesh.OT_adapt(...)` + +## What still needs upstream work + +Two production-readiness gaps remain (per +`project_ot_production_blockers.md`): + +1. **Sphere2D constrained-manifold OT** — the only true manifold + mesh in the table; OT mover needs to constrain *every* node + (not just boundary nodes) to the spherical surface. The + NotImplementedError hook is the API contract; the actual + implementation is research. +2. **Parallel JIT determinism error** — blocks ANY parallel UW3 + run, not specific to OT_adapt. + +## Caller code + +After the API lands, the harness's `_adapt_step` becomes: + +```python +def _adapt_step(): + return mesh.OT_adapt( + T, + refinement=args.refinement, + coarsening=args.coarsening, + grad_smoothing_length=args.grad_smooth_length, + metric_choice=args.metric_choice, + fields_to_remap=[T], + fields_to_zero=[V, P], + verbose=True, + ) +``` + +A user wanting a one-shot adapt (no time loop) writes: + +```python +mesh = uw.meshing.Annulus(radiusOuter=1.0, radiusInner=0.5, + cellSize=1/16, qdegree=3) +T = uw.discretisation.MeshVariable(...) +# ... initialise T somehow ... +mesh.OT_adapt(T, refinement=3.0, fields_to_remap=[T]) +``` + +## Implementation location + +- Method lives on `Mesh` base class in + `src/underworld3/discretisation/discretisation_mesh.py` +- Common implementation in + `src/underworld3/meshing/_ot_adapt.py` (new file), called + from the method +- Per-mesh hooks implemented in each mesh class file in + `src/underworld3/meshing/` +- The existing `_winslow_equidistribute`'s box/ring handling + becomes legacy; new code uses `mesh._boundary_tangent_project` + +## Open questions + +1. Should `fields_to_remap` default to `[field]` (i.e. just remap + the driving field if nothing else specified)? +2. Should the post-adapt FE-remap zero out V,P automatically when + the mesh changes topology? (Probably not — user knows their + physics; explicit `fields_to_zero` is cleaner.) +3. Should there be a class-level constant on the mesh advertising + whether boundary slip is supported, so the caller can check + without try/except? E.g. `mesh.supports_boundary_slip`? +4. Naming: `OT_adapt` (PascalCase to match `CoordinateSystem` + etc.) vs `ot_adapt` (snake_case, matches most UW3 method + conventions)? UW3 codebase mixes both — what's the project + preference? + +## Test plan + +- Unit test: `Annulus.OT_adapt(T)` on a fixed T moves mesh and + preserves T's spatial pattern within FE-remap tolerance +- Regression test: harness using API matches hand-rolled current + version bit-for-bit +- Negative test: `Sphere2D.OT_adapt(T)` raises + NotImplementedError with the expected message (the + constrained-manifold case) +- Resume test: save + restart, call `OT_adapt` — the cache + initialises lazily from the *loaded* mesh's current coords + (which is the deformed state at the snapshot point). For + resume-from-snapshot scenarios the user should + `mesh.OT_adapt_reset_reference(coords=loaded_init_coords)` + with the explicit IC mesh, or otherwise document that resumed + runs use the snapshot's mesh as the "reference" diff --git a/docs/developer/design/parallel-repeated-solve-corruption.md b/docs/developer/design/parallel-repeated-solve-corruption.md new file mode 100644 index 000000000..011d11769 --- /dev/null +++ b/docs/developer/design/parallel-repeated-solve-corruption.md @@ -0,0 +1,320 @@ +# Parallel repeated-FE-solve heap corruption (np ≥ 3) + +**Status:** FIXED — PR #213 (→ `development`), 2026-05-27. Root cause is the +**`_use_direct_solver` +(lagged MUMPS LU)** path the movers wire in, *not* the Poisson solve, the DM, or +singularity. The UW3 **default GMRES+GAMG solver is clean at np=5** (10/10, even +for the singular `constant_nullspace` case). Fix is mover-local + low-risk. +**Branch:** `bugfix/parallel-singular-corruption` → PR #213. + +Headline `np ≥ 3` covers the full failing range; `np=3` is intermittent (mostly +escapes but does crash some runs), `np=4` is reliably bad, `np=5+` is +catastrophic. See "Reproducibility" below for the per-`np` rates measured. + +> ## ROOT CAUSE (supersedes the #96-class framing below) +> Fixed mesh, np=5, 15 looped solves — measured: +> ``` +> nullspace (singular), DEFAULT GMRES+GAMG : clean 10/10 +> dirichlet (non-sing), DEFAULT GMRES+GAMG : clean 10/10 +> nullspace (singular), _use_direct_solver : crash 5/6 (lagged MUMPS LU) +> ``` +> Every repro and every mover wired the Poisson through +> `sm._use_direct_solver` (`smoothing.py:859`): `snes_type=ksponly`, +> **`snes_lag_jacobian=-2` + `snes_lag_preconditioner=-2`** (factor once, reuse +> forever), `pc_type=lu`, `pc_factor_mat_solver_type=mumps`. The lagged-MUMPS +> reuse corrupts the heap over repeated solves at np≥5. The Poisson/DM/nullspace +> are all fine — the **default solver is the proof**. This is why normal UW3 +> parallel runs are healthy. The earlier "general / Dirichlet 25%" rates were all +> measured *with* `_use_direct_solver`. The #96 framing below (DMClone cache) was +> disproved (independent DM still crashes) and is kept only for the record. +> +> **Narrowed (np=5, nullspace):** it is **MUMPS itself, not the lagging** — +> non-lagged MUMPS (`snes_lag_*=1`, refactor every solve) still crashes 7/8, and +> un-lagging only the PC crashes 8/8. So `pc_type=lu` + `pc_factor_mat_solver_type=mumps` +> repeated at np≥5 corrupts; the factorisation-reuse is incidental. +> +> **Fix direction:** the movers must **not use MUMPS in parallel**. Use the +> (clean) iterative GAMG path when `uw.mpi.size > 1`, keep MUMPS (lagged, fast) +> for serial. Concretely: gate in `_use_direct_solver` (fall back to +> `_use_iterative_solver` under MPI) or in the movers' `_wire` (choose by +> `uw.mpi.size`). The singular `constant_nullspace` φ-Poisson is clean AND +> convergent with GAMG (default test 10/10 + all iterations OK), so the docstring's +> "GAMG fragile" note is about convergence quality, not crashes. Verify the full +> movers at np=5 with the oracle (in progress). Whether this is a MUMPS bug in +> this build or a UW3 MUMPS misconfig is left open — irrelevant to the fix. + +--- + +## Symptom + +Underworld3 solvers that **repeatedly `.solve()` in a loop at np ≥ 5** suffer a +**probabilistic heap corruption** — crash (SIGSEGV / SIGBUS / SIGABRT, signal +varies = heap corruption) **or MPI deadlock (hang)**. This blocks the adaptive +mesh-motion movers (`_winslow_equidistribute` / OT and `_winslow_elliptic` / MA +in `meshing/smoothing.py`), which drive Picard loops of cached solvers. + +It is **not** mover-specific. Measured crash/hang rates at **np = 5** (release +arch `petsc-325-uw-openmpi`, MUMPS, 15 looped solves per run): + +| solver | operator | rate | +|--------|----------|------| +| pure-Neumann Poisson (`constant_nullspace=True`) | singular | **100%** (8/8) | +| Poisson with a Dirichlet BC | non-singular | **~25%** (2/8) — *also crashes* | +| `SNES_MultiComponent` Hessian recovery (Nc=4, flux from aux φ) | — | ~50–75% | +| `MultiComponent_Projection` (Nc=4, F1=0 mass-only) | — | **0%** clean | + +**np = 3 mostly escapes; np ≤ 2 escapes entirely** (why this was missed — almost +all prior parallel UW3 work ran at np ≤ 2). Singularity *amplifies* the rate to +100% but is **not** the root cause: a non-singular Dirichlet solve still corrupts +~25%. So this is a **general** repeated-parallel-FE-solve corruption. + +## What it is NOT (ruled out) + +`constant_nullspace` object · MUMPS `ICNTL(24)` · the linear solver (MUMPS **and** +GAMG both) · per-solve nullspace re-attach · the NullSpace comm · FE order (P1/P2) +· `n_components > dim` (disproved — see below) · the IS/SF/DM per-solve leak +(identical in the clean Dirichlet path) · DM field sizes · **bare PETSc** (pure +AIJ/DMDA + KSP/SNES + NullSpace is clean at np=5 — needs UW3's DMPlex+FE C +callbacks). + +### "Nc > dim" was a red herring +Earlier hypothesis (and the prior handoff) blamed `SNES_MultiComponent` with +`n_components > mesh.dim`. **Disproved this session:** `MultiComponent_Projection` +at **Nc=4 > dim=2 is clean** (0% — it has no flux, F1=0), while the Hessian-row +recovery at **Nc=dim=2 still crashes** (it has a flux from an auxiliary field). +So Nc>dim is neither necessary nor the trigger; the discriminator is the +**assembly path** (flux / no-essential-BC), and ultimately the **#96 shared-state +mechanism** below. + +## Root-cause class — NOT the #96 DMClone-shared-cache (disproved 2026-05-27) + +Initial hypothesis (and the prior handoff) was the Issue-#96 mechanism: `DMClone` +shares `mesh.dm`'s `DM_Plex` mutable caches by refcount, so a repeated parallel +assembly corrupts the shared state. **Disproved this session** — see "Isolation +attempts". A fully **independent** solver DM (fresh `DM_Plex`, no `DMClone`, +reloaded from the mesh `.h5` + re-distributed, verified layout-identical to +`mesh.dm`) **still crashes 7/8 at np=5**, same as baseline. So the corruption is +**not** the DMClone-shared `DM_Plex` cache, and the #96 "new DM, no clones" +isolation does **not** transfer to this bug. + +The corruption is **intrinsic to a single UW3 solver doing repeated parallel FE +solves at np ≥ 5** — the OT mover crashes **5/5 alone** (analytic metric, no other +solver instantiated), so it is not about coexisting solvers either. What remains +coupled to the mesh in a single solve (the new suspects): + +1. **The auxiliary vector.** Every `SNES_*.solve()` calls `mesh.update_lvec()` + then `DMSetAuxiliaryVec(solver.dm, mesh.lvec)` — sharing `mesh.lvec` and, on + first build, **mutating `mesh.dm`** (`clearDS` + `createDS` + + `createFieldDecomposition`; `discretisation_mesh.py:1975` `update_lvec`). +2. **The per-solve IS/SF/DM leak.** `-log_view` showed ~92 Index Set, ~74 Star + Forest, ~46 DM objects leaked per 15 solves. The prior handoff dismissed this + as benign "because the clean Dirichlet path leaks identically" — but **Dirichlet + is NOT clean (it crashes ~25%)**, so the accumulating leak is back as a prime + suspect (heap fragmentation/corruption from accumulating un-destroyed PETSc + objects at np ≥ 5). + +A full isolation per the #96 recipe (independent DM **and** independent +fields/aux-data copied via numpy, never touching `mesh.lvec`/`update_lvec`) was +**not** completed — it requires bypassing the aux-vec sourcing inside the compiled +`solve()` path. That, plus the leak, is the next investigation front. + +## The proven fix recipe (from the #96 campaign) + +> Completely separate the solver out: **create a NEW dm (NOT a clone)**, add the +> fields the DM needs, and **COPY the values in from `mesh.dm` via numpy** (not +> shared PETSc objects). Establish that the fully-isolated solver is clean, then +> **back off** (re-share progressively) **until it breaks** — that locates the +> minimal sufficient isolation, which is what ships. + +### Implementation path (designed; not yet built) + +1. **Independent DM, no clone.** The mesh persists its gmsh topology as + `mesh.name + ".h5"`. Reload it per-solver via `_from_plexh5(...)` → a fresh + `DM_Plex` (no refcount sharing with `mesh.dm`). **Must then distribute it**: + the raw reload lands undistributed (all cells on rank 0 — verified by + `probe_reload_layout.py`), so its partition will **not** match `mesh.dm`. +2. **numpy data bridge.** Because the independent DM's parallel layout differs + from `mesh.dm`, map all data (coordinates, aux/coefficient fields, the solution + back) **by coordinate matching via numpy** — not by sharing/copying PETSc Vecs. + This is the substantial, careful, parallel part. +3. **Replicate the DM setup** the assembly needs on the independent DM: + `createCoordinateSpace(degree, …)` + `dm_force_coordinate_field`, boundary + labels (`labelsLoad` + named-boundary patching), FE field + `createDS`. +4. **Gate narrow-first** (a solver/mesh flag): apply only to the mover solvers + first; generalise to `clone_dm_hierarchy` for all solvers only after + tier-A/B benchmarking (Solver Stability is Paramount). +5. **Back off to minimal** using the oracle below: once full isolation is clean, + re-introduce sharing incrementally to find the cheapest sufficient isolation. + +### Isolation attempts already tried (this session) — both FAILED +- **Rebuild each clone's coordinate space in `clone_dm_hierarchy`** + (`createCoordinateSpace` + `dm_force_coordinate_field` on the clone): no rate + reduction. Reverted. +- **Fully independent solver DM (no `DMClone`)**: `clone_dm_hierarchy` returns a + fresh DM reloaded from `mesh.name + ".h5"` + `distribute()` (verified + layout-identical to `mesh.dm` — `probe_reload_distribute.py` shows + `same_order=True` all ranks, so the aux-vec/section coupling is compatible with + no remapping). **Still 7/8 crash at np=5** (baseline 100%). Reverted. ⇒ the + DMClone-shared `DM_Plex` is NOT the culprit; isolating only the DM is + insufficient. (Repro: `repro_ns_isolated.py` with `mesh._isolate_solver_dm`.) + +### Band-aids (insufficient — for the record) +A single-DOF pin or a **near-zero mass term** (`-∇·κ∇φ + εφ = f`, εφ in the **F0 +operator**, never in `ps.f` — that breaks the Jacobian) makes the φ-Poisson +non-singular → drops it from 100% to the ~25% **general** baseline. Still not +production-usable, because the general 25% remains. The mass term is the right +*formulation* for a mesh-motion potential, but it does not fix the corruption. + +### Third change: collective remesh decision (found by the np=4 convection run) +Running the full adaptive convection harness in parallel exposed a **second, +independent** parallel bug: `mesh_metric_mismatch` computed the +mesh↔metric *misalignment* via `np.corrcoef` on **rank-local** cells, so each +rank got a different value (e.g. 0.34/0.91/0.77/0.45) straddling the +`skip_threshold=0.9`. `smooth_mesh_interior` then made the skip/adapt choice +**per rank** → ranks disagreed → the collective mover **deadlocked** (some ranks +entered it, others skipped). Fix: +- `mesh_metric_mismatch`: compute the Pearson `alignment` from **globally + reduced** moment sums (`Σx,Σy,Σxx,Σyy,Σxy,n` allreduced) so every rank agrees. + Serial is bit-identical to `np.corrcoef` (the 1/n normalisation cancels). +- `smooth_mesh_interior`: **OR-reduce** the final decision — *if any rank needs + to remesh, all ranks remesh* (and all skip together otherwise). The mover is + collective, so the decision must be unanimous. +Verified live (np=4, 8 steps): step 2 → unanimous `adapting`, steps 4/6/8 → +unanimous `skipping`, clean exit; the adapted mesh renders valid (no tangling). + +### Fourth change: field remap must use `global_evaluate` (parallel) +After the mover moves nodes, the driving field is FE-remapped by evaluating the +*old* field at the *new* DOF coordinates. This used the **local** +`uw.function.evaluate` — but a DOF that moved near a rank-partition boundary +lands in a **neighbour rank's** subdomain, where a local evaluate returns +stale/garbage (it doesn't raise, so the bad value persists and convection +amplifies it → a *growing T anomaly localised to the partition seams*). Fixed by +using `uw.function.global_evaluate` (serial-identical drop-in, maxdiff 0.0; it +gathers/resolves off-rank points across ranks): +- core `meshing/_ot_adapt.py`: the reference-mesh remap and the adapted-position + remap (affects every `mesh.OT_adapt(..., fields_to_remap=...)` user); +- the demonstrator harness's hand-rolled `T` remap. +Quantitative check (T on r=0.55 ring, np=4, step 8): the fixed run is smoother at +the seams (peak adjacent jump 0.0113 → 0.0091) and recovers the correct hotter +boundary-layer T (0.896 → 0.913). NOTE: the co-located boundary **slivers** are a +*separate* (lower-priority) issue — the anisotropic mover's slip handling at the +partition seams — not the remap. + +## Reproduction & tooling (all under `~/+Simulations/StagnantLid/`) + +- **Repros** (`parallel_corruption_repros/`): `test_ns_loop.py` + (MODE=nullspace|dirichlet — primary), `repro_hessian_loop.py` + (MODE=full|row — secondary), `repro_mc_projection.py` / + `repro_flux_chars.py` (the clean Projection controls), + `repro_screened.py` (mass-term band-aid), `probe_reload_layout.py`. +- **Oracle:** `rate_test.sh` — runs each config N times at np=5 with a per-run + **timeout** (the corruption can deadlock, so a plain loop hangs forever) and + classifies CLEAN / CRASH / HANG. This is the scoring harness for isolation + candidates. `TIMEOUT=100 NP=5 N1=.. N2=.. bash rate_test.sh`. +- **Run env var:** `UW_NO_USAGE_METRICS=1` (silences the telemetry thread). + +### Debug PETSc (built this session) +A minimal `--with-debugging=1 -O0` arch **`petsc-325-uw-openmpi-debug`** is built +(coexists with the release arch; release untouched). Use env **`amr-debug`** +(activation sets the `-debug` arch). **It does NOT reliably reproduce the crash** — +the debug allocator's padding absorbs the overrun (the process completes or dies +silently; `-malloc_debug` guard bytes did not trip). This matches the #96 +experience: the debug build "exits before the SEGV". So **the release arch + the +timeout oracle is the working reproduction**, not the debug build. (The 3.24 +`petsc-4-uw-openmpi-debug` arch is **ABI-incompatible** with the now-3.25.0 shared +source — do not use it. To rebuild a 3.25 debug arch: a minimal manual +`./configure --with-petsc-arch=petsc-325-uw-openmpi-debug --with-debugging=1 +--with-mpi-dir=$CONDA_PREFIX --with-hdf5-dir=$CONDA_PREFIX --download-*=0 +--with-petsc4py=0 --with-pragmatic=0 --with-slepc=0 --with-x=0 COPTFLAGS="-g -O0"` +under `pixi run -e amr-debug`, then `make … all`, then build petsc4py + `pip +install .` under `amr-debug`.) + +## THE FIX (implemented in `meshing/smoothing.py`, 2026-05-27) + +Avoid MUMPS in parallel; keep it for the validated serial speedup. Three changes, +all mover-local: +1. **`_use_direct_solver`** — under `uw.mpi.size > 1`, fall back to + `_use_iterative_solver` (MUMPS-free GMRES+GAMG / CG+Jacobi). Serial keeps the + lagged MUMPS LU (the 10× Picard efficiency lever). `elliptic` is now a param, + forwarded to the fallback. +2. **`_use_iterative_solver`** — the GAMG **coarse** solver was `lu`+`mumps`; + under MPI it now uses `redundant`+`svd` (verified clean + convergent on the + singular pure-Neumann coarse). Serial keeps the MUMPS coarse. +3. **`_wire`** (all three movers) — forwards `elliptic` to `_use_direct_solver` + so the parallel fallback picks GAMG (φ-Poisson) vs CG+Jacobi (mass) correctly. + +### Verification (np=5, timeout oracle; baselines in parentheses) +- OT mover, default `linear_solver="direct"`: **clean 6/6** (was 5/5 crash). +- MA mover (`_winslow_elliptic`, original Nc=4 Hessian recovery): **clean 5/5** (was 5/5). +- Public `mesh.OT_adapt(field)` (full metric-density + mover): **clean 5/5** (was crash). +- φ-Poisson `constant_nullspace`, `_use_direct_solver`: **clean 6/6** (was 5/6). +- Nc=4 Hessian recovery loop (`_use_direct_solver`): **clean 6/6** (was 4/4 crash). +- Serial (np=1, MUMPS): unchanged, DONE. +- **Regression:** tier-A (`level_1 and tier_a`) **177 passed, 6 skipped, 0 failed** + (serial path is bit-identical — the fix only changes the parallel branch). + +### Convergence + correctness (not just crash-free) +All three mover solver types **converge** in parallel (positive KSP reason) and +**match the serial MUMPS answer** to ~1e-10 relative (within `ksp_rtol=1e-7`), +via partition-invariant `uw.maths.Integral` diagnostics: +- φ-Poisson (GAMG, singular `constant_nullspace`): converged 29 its, resnorm + 1.1e-9; ∫|∇φ|² serial 7.354489536e-2 vs parallel 7.354489548e-2 (Δ 1.6e-9). +- ∇φ Vector_Projection (CG+Jacobi): converged 8 its; ∫ serial 3.536143815311 vs + parallel 3.536143815630 (Δ 9e-11). +- Nc=4 Hessian recovery (CG+Jacobi): converged 12 its; ∫ serial 1268.973923874 + vs parallel 1268.973923661 (Δ 1.7e-10). +The full OT mover yields a valid non-tangled adapted mesh in parallel. + +### Second change: parallel-correct `_patch_volumes` (the equidistribution source) +`_patch_volumes` (the per-vertex dual area driving the equidistribution metric) +is exactly the **lumped P1 mass diagonal** `M_ii = ∫N_i dV`. The hand-rolled +local `np.add.at` sum **under-counts shared vertices on rank-partition +boundaries** (it never sums the neighbouring rank's incident triangles), so the +parallel grading was systematically too weak (q_min 0.82→0.150 vs serial +0.82→0.108 — under-refined). Fixed by computing it through the **FE mass matrix** +in parallel (`_lumped_vertex_volumes`): `M·1` row-sums = the lumped diagonal, with +PETSc's `localToGlobal(ADD)` doing the cross-rank reduction. Verified: +serial result **bit-identical** to the numpy version (maxdiff 2.6e-18, ordering +preserved), parallel **conserves total area** (∑lumped = mesh area) and the +parallel grading now tracks serial (q_min→0.093, aspect→24.8 ≈ serial 21.3, +vs the under-refined 0.150/15.3 before). Serial keeps the fast numpy path. +Uses petsc4py's bound `DM.createMassMatrix` + `M·1`; annotated `TODO(petsc4py)` +to switch to the purpose-built `DMCreateMassMatrixLumped` (which returns the +lumped diagonal directly with the cross-rank ADD built in) once petsc4py +binds it (it exists in the 3.25 C API but is not exposed). + +### Fix 2 (Nc>dim guard + Hessian row-restructure) — REVERTED +Premised on the disproved "Nc>dim" theory. The Hessian recovery crashed only +because it too used MUMPS (`_use_direct_solver`); `MultiComponent_Projection` at +Nc=4 with the default (non-MUMPS) solver was always clean. The MUMPS fix makes +the **original** single Nc=4 recovery parallel-safe (CG+Jacobi), so the +row-restructure is unnecessary and the guard would false-positive. Reverted. + +### Open / broader caveat +**MUMPS-in-parallel-over-repeated-solves is unsafe in this build generally** — any +UW3 code setting `pc_factor_mat_solver_type=mumps` (or `pc_type=lu` in parallel) +and re-solving at np≥3 risks the same corruption. This fix covers the movers; +a broader guard/warning (or pinning whether it is a MUMPS bug vs a UW3/PETSc-MUMPS +interface issue — possibly the per-solve IS/SF/DM leak being MUMPS factorisation +objects) is worth a follow-up. The `petsc-custom/build-petsc.sh` worktree-local +`UW_PETSC_DEBUG=1` patch must be reverted before any PR (the debug arch is already +built). + +## Next steps (DM isolation is ruled out — pursue the aux-vec / leak fronts) + +1. **Per-solve leak.** Re-run `-log_view` at np=2 on `test_ns_loop` (both modes) + and locate the un-destroyed IS/SF/DM allocations per solve (in the `solve()` + path and/or `update_lvec`'s `createFieldDecomposition`). Plug them and re-score + with `rate_test.sh`. Test whether the crash rate scales with solve count / + `NREP` (accumulation signature). +2. **Aux-vec / `update_lvec`.** Try a complete isolation that also avoids + `mesh.lvec`: build an independent aux vec with numpy-copied data and bypass + `update_lvec`'s mutation of `mesh.dm` (`clearDS`/`createDS`/`createFieldDecomposition` + every first-build). This needs a hook in the compiled `solve()` aux-vec + sourcing. +3. **ASan.** Because the debug build absorbs the overrun, a definitive pinpoint + likely needs an AddressSanitizer PETSc build (`-fsanitize=address`, fiddly via + Python on macOS — needs the ASan runtime preloaded) run at np ≥ 5. +4. Score every candidate with `rate_test.sh` (CLEAN/CRASH/HANG); benchmark + tier-A/B before any change to the shared solve path (Solver Stability is + Paramount). diff --git a/docs/developer/design/petsc-dmplex-checkpoint-reload-plan.md b/docs/developer/design/petsc-dmplex-checkpoint-reload-plan.md new file mode 100644 index 000000000..2df11c647 --- /dev/null +++ b/docs/developer/design/petsc-dmplex-checkpoint-reload-plan.md @@ -0,0 +1,261 @@ +# PETSc DMPlex Checkpoint Reload Plan + +## Commit And Test Workflow + +Use small, meaningful commits while implementing this work. + +Do `git add` and `git commit` after each coherent feature, fix, or debugging +checkpoint so progress is easy to inspect and bisect. Do not wait until the end +to commit everything as one large change, and do not commit after every tiny +edit. + +Create or update unit tests whenever they are needed to prove behavior or +prevent regressions. Prefer adding a failing regression test before the fix when +the failure mode is already known. + +## Objective + +Provide an exact PETSc DMPlex reload path for UW3 mesh variables. This path is +intended for restart and large-scale postprocessing. It is now exposed through +the standard `write_timestep(..., petsc_reload=True)` output method; legacy +`write_checkpoint()` calls remain supported as a compatibility wrapper. + +Target workflow: + +```python +mesh.write_timestep( + "checkout", + index=0, + outputPath=str(output_dir), + meshVars=[v_soln, p_soln], + create_xdmf=False, + petsc_reload=True, +) +``` + +Default output: + +```text +checkout.mesh.00000.h5 +checkout.mesh.Velocity.00000.h5 +checkout.mesh.Pressure.00000.h5 +``` + +Reload workflow: + +```python +mesh = uw.discretisation.Mesh("checkout.mesh.00000.h5") +v_soln = uw.discretisation.MeshVariable("Velocity", mesh, mesh.dim, degree=2) +p_soln = uw.discretisation.MeshVariable("Pressure", mesh, 1, degree=1) + +v_soln.read_checkpoint("checkout.mesh.Velocity.00000.h5", data_name="Velocity") +p_soln.read_checkpoint("checkout.mesh.Pressure.00000.h5", data_name="Pressure") +``` + +The reload path must not use `KDTree` remapping. It must restore FE data through +PETSc DMPlex topology, section, vector, and `PetscSF` metadata. + +## Output Payloads + +`mesh.write_timestep(...)` is the standard output method. It can write either +or both output payloads. + +| Payload | Writer option | Reload method | Strength | Limitation | +| --- | --- | --- | --- | --- | +| XDMF/remap | `create_xdmf=True` | `MeshVariable.read_timestep(...)` | Writes XDMF and vertex-field data; can map data onto a different mesh | Uses coordinate/KDTree remapping; memory-heavy for large meshes and high MPI counts | +| PETSc reload | `petsc_reload=True` | `MeshVariable.read_checkpoint(...)` | Uses PETSc DMPlex section/vector metadata; avoids KDTree | Requires compatible PETSc mesh metadata | + +For unified output, use `write_timestep(..., create_xdmf=True, +petsc_reload=True)`. For restart-safe, memory-efficient postprocessing without +XDMF, use `write_timestep(..., create_xdmf=False, petsc_reload=True)`. + +## Implemented Design + +### PETSc Reload Writing + +`Mesh.write_timestep(..., petsc_reload=True)` writes PETSc DMPlex reload +metadata into the standard per-variable timestep HDF5 files. The legacy +`Mesh.write_checkpoint(...)` compatibility wrapper also writes PETSc DMPlex +reload metadata, but uses the older checkpoint filename layout. + +The mesh file is named: + +```text +.mesh..h5 +``` + +With `write_timestep(..., petsc_reload=True)`, each mesh variable is written to +its own timestep-layout file: + +```text +.mesh...h5 +``` + +The legacy `write_checkpoint(...)` wrapper writes one file per variable by +default: + +```text +...h5 +``` + +With the legacy `write_checkpoint(..., separate_variable_files=False)` mode, +all variables are written into: + +```text +.checkpoint..h5 +``` + +Per-variable files are the default because they avoid forcing downstream +postprocessing to open and move through one very large combined checkpoint file. +This is useful for large spherical benchmark cases where velocity and pressure +files can already be large individually. + +### Mesh Reload + +When a PETSc DMPlex HDF5 mesh is loaded, UW3 keeps the topology-load `PetscSF` +returned by `DMPlexTopologyLoad(...)`. If UW3 distributes the mesh after load, +the topology-load SF is composed with the redistribution SF. + +This composed SF is stored on the mesh and is the mapping used by +`MeshVariable.read_checkpoint(...)`. + +The mesh DM name is fixed to `uw_mesh` while writing and loading checkpoint +files so PETSc can find the expected topology groups. + +### Variable Reload + +`MeshVariable.read_checkpoint(filename, data_name=None)`: + +- opens the checkpoint HDF5 file in PETSc HDF5 format +- loads the saved DMPlex section for `data_name` +- loads the saved local vector through PETSc's DMPlex local-vector path +- copies values into the target UW3 variable using section offsets +- syncs the UW3 local vector back to its global vector + +The implementation uses a small Cython wrapper around: + +- `DMPlexSectionLoad(...)` +- `DMPlexLocalVectorLoad(...)` + +The wrapper requests only the local-data SF. This avoids failures seen when +the global-data SF path was constructed before the local checkpoint data could +be loaded. + +## PETSc Requirements + +The relevant PETSc DMPlex HDF5 restart sequence is: + +1. Load topology with `DMPlexTopologyLoad(...)`. +2. Keep the `PetscSF` returned by topology load. +3. Load coordinates with `DMPlexCoordinatesLoad(...)`. +4. Load labels with `DMPlexLabelsLoad(...)`. +5. If the mesh is redistributed, compose the topology-load SF with the + redistribution SF. +6. Load the saved section with `DMPlexSectionLoad(...)`. +7. Load the saved vector with the DMPlex vector-load API. +8. Scatter or copy the loaded values into UW3's mesh-variable storage. + +The critical identity requirements are: + +- topology DM name must match the saved topology group +- section DM name must match the saved variable group +- vector name must match the saved vector group +- the SF passed to section load must map saved topology points to current + distributed topology points + +PETSc reference: [DMPlex manual](https://petsc.org/main/manual/dmplex/). + +## Tests + +Current unit coverage is in `tests/test_0003_save_load.py`. + +The checkpoint roundtrip test covers: + +- scalar variable reload +- vector variable reload +- discontinuous variable reload +- combined checkpoint file reload with `separate_variable_files=False` +- per-variable checkpoint file reload with the default + `separate_variable_files=True` + +Required validation before PR: + +```bash +./uw python -m pytest tests/test_0003_save_load.py -q +mpirun -np 2 ./uw python -m pytest \ + tests/test_0003_save_load.py::test_meshvariable_checkpoint_roundtrip -q +``` + +## Spherical Benchmark Validation + +The motivating case is spherical benchmark postprocessing at high MPI counts. +The coordinate-remap `read_timestep()` path can build large KDTree mapping +structures during reload. At `1/128` this used nearly the full 4.5 TB +allocation on Gadi. + +The PETSc reload method avoids KDTree reload and preserves velocity/pressure +metrics to roundoff. Boundary stress metrics require the benchmark to recover +stress consistently after reload. In the spherical benchmark this is handled by +projecting the six deviatoric-stress components and then forming `sigma_rr`. + +### Gadi Evidence + +| Resolution | Method | NCPUs | Walltime | Memory used | Status | +| --- | --- | ---: | ---: | ---: | --- | +| `1/64` | `write_timestep/read_timestep` remap | 144 | `00:03:43` | `211.27 GB` | completed | +| `1/64` | `write_timestep(petsc_reload=True)/read_checkpoint` | 144 | `00:02:41` | `233.67 GB` | completed | +| `1/128` | `write_timestep/read_timestep` remap | 1152 | `00:13:55` | `3.92 TB` | completed near memory limit | +| `1/128` | `write_timestep(petsc_reload=True)/read_checkpoint` | 1152 | `00:03:57` | `1.83 TB` | completed | + +The `1/128` checkpoint reload reduced memory by about `2.09 TB` and walltime by +about `3.5x` for the postprocessing run. + +### Metric Agreement + +`1/128` spherical Thieulot benchmark: + +| Metric | `write_timestep/read_timestep` remap | `write_timestep(petsc_reload=True)/read_checkpoint` | +| --- | ---: | ---: | +| `v_l2_norm` | `1.4319274480265082e-06` | `1.4319274480231255e-06` | +| `p_l2_norm` | `5.985841567394967e-04` | `5.985841567395382e-04` | +| `p_l2_norm_abs` | `1.0566381005355924e-03` | `1.0566381005356654e-03` | +| `sigma_rr_l2_norm_lower` | `1.117914337768646e-03` | `1.1256362820288926e-03` | +| `sigma_rr_l2_norm_upper` | `4.461443231341268e-05` | `3.811141458727819e-05` | +| `u_dot_n_l2_norm_lower_abs` | `2.2509850571644799e-04` | `2.2509850571645164e-04` | +| `u_dot_n_l2_norm_upper_abs` | `5.535239716141496e-05` | `5.535239716141875e-05` | + +Velocity, pressure, and normal-velocity metrics agree to roundoff. The +`sigma_rr` values are close but not bitwise identical because the stress +recovery path changed from the old reload workflow to explicit tau-component +projection after checkpoint reload. + +`1/64` spherical Thieulot benchmark: + +| Metric | `write_timestep/read_timestep` remap | `write_timestep(petsc_reload=True)/read_checkpoint` | +| --- | ---: | ---: | +| `v_l2_norm` | `1.1662200663950889e-05` | `1.1662200663957042e-05` | +| `p_l2_norm` | `2.7573367818459473e-03` | `2.7573367818460497e-03` | +| `sigma_rr_l2_norm_lower` | `4.368560398155481e-03` | `4.381908965541248e-03` | +| `sigma_rr_l2_norm_upper` | `1.6315543718450765e-04` | `1.6047310456195621e-04` | + +## Remaining PR Readiness Items + +- Run the unit checkpoint tests on the clean checkpoint-only branch. +- Add or record one small local benchmark smoke test for reproducibility. +- Decide whether different-rank checkpoint reload is required for the first PR + or should be documented as follow-up validation. +- Keep the final checkpoint PR branch free of unrelated JIT and macOS compiler + commits. + +## Acceptance Criteria + +The checkpoint reload implementation is ready for review when: + +- `write_timestep(..., petsc_reload=True)` writes PETSc DMPlex reload metadata. +- `write_timestep(..., petsc_reload=True)` supports `outputPath`. +- legacy `write_checkpoint()` remains available as a compatibility wrapper. +- `MeshVariable.read_checkpoint(...)` reloads through PETSc metadata, not + coordinate/KDTree remapping. +- scalar, vector, and discontinuous variables roundtrip in tests. +- same-rank MPI reload is validated. +- benchmark evidence shows the large-memory KDTree reload issue is avoided. diff --git a/docs/developer/design/snes-atol-convergence-scale.md b/docs/developer/design/snes-atol-convergence-scale.md new file mode 100644 index 000000000..9146d9935 --- /dev/null +++ b/docs/developer/design/snes-atol-convergence-scale.md @@ -0,0 +1,216 @@ +--- +title: "SNES convergence: set snes_atol to the problem scale" +--- + +# SNES `snes_atol` — guess-independent convergence + +**Status:** design proposal, pending sign-off + benchmarking. +**Scope:** UW3 SNES solver wrapper — internal to the `solve()` +path in `cython/petsc_generic_snes_solvers.pyx` (which already +branches on `zero_init_guess`). No user-facing API. Affects *every* +UW3 SNES solve. +**Origin:** the adaptive-mesh / Stokes warm-start divergence +investigation (2026-05). This note is the root-cause writeup + +proposed fix; the mesh-mover work was unrelated — it merely exposed +this. + +## Summary + +UW3's `tolerance` setter configures `snes_rtol` but **never sets +`snes_atol`**, leaving it at PETSc's default (`~1e-50`). PETSc's +default convergence test then has only one viable criterion: a +**relative tolerance referenced to the residual at the initial +guess**. A warm-started solve whose initial residual is already +small (re-solving a near-solved state — exactly what you *want* to be +cheap) is handed an unreachably tight target and fails +(`DIVERGED_LINE_SEARCH`), while the *same problem* cold-started +converges. The fix is to also set `snes_atol` to the problem's +natural residual scale, so convergence is judged **absolutely +(guess-independent)** — including the desirable "re-solve the +solution ⇒ zero iterations" behaviour. + +## Evidence (PETSc 3.25 source) + +`SNESConvergedDefault` (`src/snes/interface/snesut.c`): + +```c +if (!it) { /* iteration 0 = initial guess */ + snes->ttol = fnorm * snes->rtol; /* rtol target ∝ ‖F(x0)‖ */ + snes->rnorm0 = fnorm; +} +... +} else if (fnorm < snes->abstol && (it || !snes->forceiteration)) { + *reason = SNES_CONVERGED_FNORM_ABS; /* absolute — guess-independent */ +} ... +if (it && !*reason) { + if (fnorm <= snes->ttol) *reason = SNES_CONVERGED_FNORM_RELATIVE; + else if (snorm < snes->stol * xnorm) + *reason = SNES_CONVERGED_SNORM_RELATIVE; /* it>=1 only */ +} +``` + +Key facts, verified in-tree: + +1. `rtol` is **defined** relative to the initial-guess residual + (`ttol = rtol·‖F(x0)‖`, set once at `it==0`). There is **no + option** to reference it to the problem/RHS scale. PETSc has not + changed this. +2. The **absolute** path (`fnorm < snes_atol`) is gated by + `(it || !snes->forceiteration)`, so it is evaluated **even at + `it==0`**. With `snes_atol` set to the problem scale and + `snes_force_iteration` off (UW3's default), re-solving an + already-solved state converges at iteration 0 with **zero Newton + steps** — the intended behaviour. +3. The step-norm path (`snorm < stol·xnorm`) is gated by `it && ...` + — it cannot deliver zero-iteration convergence and is pre-empted + when the line search aborts at the 0→1 transition. + +UW3 (`petsc_generic_snes_solvers.pyx`, `tolerance` setter): + +```python +self.petsc_options["snes_rtol"] = self._tolerance # set +self.petsc_options["ksp_rtol"] = self._tolerance * 1e-1 # set +self.petsc_options["ksp_atol"] = self._tolerance * 1e-6 # set +# snes_atol : NEVER set → PETSc default ~1e-50 → absolute path dead +``` + +So convergence is decided **solely** by `fnorm ≤ rtol·‖F(x0)‖`. + +## Failure mechanism + +For a warm-started solve where the carried-forward guess is close to +the solution, `‖F(x0)‖` is small ⇒ `ttol = rtol·‖F(x0)‖` is a tiny +absolute number, often below what the (relative-tolerance) inner KSP +delivers for the Newton correction. The line search cannot achieve +sufficient decrease toward an unreachable target ⇒ +`DIVERGED_LINE_SEARCH`. Cold-start (`x0 = 0`) gives +`‖F(x0)‖ ≈ ‖RHS‖` (large) ⇒ a sane `ttol` ⇒ converges. This is +guess-relative, not problem-relative — and it means *improving the +guess makes convergence harder*, the opposite of what a solver should +do. + +Observed across the adaptive-convection runs: warm Stokes diverged +repeatedly through violent transients (every step, until the field +calmed), each instance recovering cleanly from a cold restart; +`ksponly`/`basic` line-search "worked" only by bypassing the test; +improving the warm guess (V,P remap) did **not** help — all exactly +as the mechanism predicts. + +## Proposed fix + +**`snes_atol` is internal to the solver and never user-facing.** +There is no new API, no `tolerance_abs` knob — exposing it would +repeat the mistake this whole investigation argued against (robust +defaults, not fragile expert knobs). The solver derives and applies +it automatically, **per solve, conditioned on `zero_init_guess`**: + +``` +if not zero_init_guess: # WARM start + F0 = ‖F(x=0)‖ for the CURRENT operator/RHS # problem scale + saved = snes_atol + snes_atol = snes_rtol * F0 # temporary, guess-independent + # → SNES_CONVERGED_FNORM_ABS + snes_atol = saved # restore +else: # COLD start + # rtol·‖F(x0=0)‖ already = scale +``` + +* **Warm solve:** the guess-relative `ttol = rtol·‖F(x_warm)‖` is + unreachable; the solver instead **computes the problem-scale + target residual and temporarily sets `snes_atol` to it for that + solve only**, then restores. Convergence then takes the absolute, + guess-independent path (`SNES_CONVERGED_FNORM_ABS`, evaluated even + at `it==0`), so re-solving an already-solved state converges in + **zero Newton iterations** — the intended behaviour. +* **Cold solve:** untouched. `‖F(x0=0)‖` *is* the problem scale, so + the existing `rtol` path already targets the right residual; the + cold solve is also the natural place to (re)source the scale. + +**Scale currency (design decision).** The target must be the +**current** problem scale, *recomputed each warm solve* — one extra +function evaluation at `x=0`, negligible against the solve — **not** +a frozen startup `‖F₀‖`. The RHS scale (e.g. `‖buoyancy‖`) varies +substantially through a transient; a frozen scale would be stale +exactly where warm-start divergence bites. `F(0)` remains a valid +scale for nonlinear rheology, so this is a convergence-*criterion* +fix independent of linearity. (The `--stokes-snes-atol-auto` +confirmation harness uses a *frozen* startup scale — a valid proof +of the mechanism, but a simplification; production recomputes.) + +## Impact & risk + +This changes the convergence criterion for **every UW3 SNES solve** +(Stokes, scalar Poisson, projections, advection–diffusion; the +mesh-mover's `ksponly` sub-solves are unaffected — no Newton test). +Per the repository rule *solver stability is paramount — no changes +without benchmarking*: + +* **Cold-started** solves: behaviour ≈ unchanged + (`atol` not applied; `rtol·‖F0_cold‖` already the accuracy floor). +* **Warm-started** solves: spurious divergence *fixed*; + "re-solve the solution ⇒ 0 iterations" now works; accuracy is the + same `rtol·‖F(0)‖` a working cold solve targets — no under-solving. +* The `snes_atol` mutation is **scoped to one solve and restored**, + so it cannot leak across solvers/steps or interact with a user's + own `petsc_options`. +* Benchmark the standard suite (Stokes/Poisson convergence-order, + the `tier_a` set) before merge — it must show unchanged accuracy + and order, only removed spurious warm divergences. + +Recommended landing: internal to the `solve()` path in +`petsc_generic_snes_solvers.pyx` (which already branches on +`zero_init_guess`); no API surface; benchmark suite green; one line +in the solver guide noting the automatic behaviour. + +## Validation + +* Root cause verified against PETSc 3.25 source (above) and the UW3 + `tolerance` setter. +* Confirmation experiment (`scripts/adaptive_saturation.py + --model a16r15a --stokes-snes-atol-auto`, equidist R=1.5, warm, + V,P-remap on, default `newtonls`+`bt`, **no cold-recover**; + `‖F0‖=24.75 ⇒ snes_atol=2.47e-4`): **full settled run** — + warm `STOKES DIVERGED` **24 → 31** (i.e. *no net benefit*, if + anything slightly worse, vs the identical run without the + absolute criterion). *(An earlier step-70 partial read showed a + spurious 24→9 — corrected here: it was a mid-trajectory snapshot + before the later transient windows, not the result.)* + + This is *consistent with* the mechanism, and clarifies the scope: + + * The absolute path (`SNES_CONVERGED_FNORM_ABS` at `it==0`) only + fires when `‖F(x_warm)‖ < snes_atol`. In a **violent-transient + -dominated** run the warm-guess residual is almost always + ≫ `atol` (the field changed substantially per step), so the + absolute path essentially never triggers; SNES proceeds to the + line search, which aborts on the inexact inner Newton step + *before any convergence test is consulted*. The + near-converged-guess class this fix targets is **nearly absent** + in this benchmark, so `snes_atol` provides no net benefit here + and merely perturbs which steps fail (net +7). + * Where it *does* help — and the reason it should still land — is + the regime it is *for*: steady-state continuation, restarts, + lightly-evolving problems, any re-solve of a near-solved state. + There `‖F(x_warm)‖ < atol` genuinely holds and SNES converges + in **zero Newton iterations** instead of failing on an + unreachable guess-relative `ttol`. That is a real, general UW3 + gap (PETSc-source-verified), independent of this benchmark. + This experiment does **not** exhibit that regime, so it neither + confirms nor refutes the fix's value there — it only shows the + fix does not help violent transients (as the mechanism + predicts). + +**Conclusion:** `snes_atol` is a correct, general improvement for +the near-converged-guess regime (justified by the PETSc-source +diagnosis, *not* demonstrated by this transient-dominated run — +which shows no benefit, as expected). It is **not** the cure for +warm-start through a violent transient. That cure is a separate, +*demonstrated* result: an accurate inner Newton solve (`a16r15d`, +MUMPS-LU inner solve, warm, default `bt`, no recover/atol → +**24 → 0** warm `STOKES DIVERGED`) — the inner KSP must deliver an +acceptable step on the graded / stiff-Robin operator, generalised +as a tight inner tolerance / strong PC / direct where affordable +(not "always direct"). Cold-restart-on-divergence is the +operational safety net. The pieces are independent and +complementary; this note covers only the `snes_atol` piece — see +the inner-solve result for the transient cure. diff --git a/docs/developer/design/snesfas-feasibility.md b/docs/developer/design/snesfas-feasibility.md new file mode 100644 index 000000000..2ec6ec713 --- /dev/null +++ b/docs/developer/design/snesfas-feasibility.md @@ -0,0 +1,377 @@ +--- +title: "SNESFAS (nonlinear multigrid) feasibility in Underworld3" +--- + +# SNESFAS feasibility spike + +**Verdict: GO for scalar nonlinear, and GO for nonlinear Stokes with adaptation +(with two engineering follow-ons).** PETSc's `SNESFAS` (Full Approximation Scheme — +nonlinear geometric multigrid) runs through Underworld3's existing DMPlex-FE solver +stack **with no source changes** — it is activated entirely through `petsc_options`. +On a strongly nonlinear scalar problem it is mesh-independent, more robust than +Newton, and faster, in serial and parallel. On **nonlinear (power-law) Stokes** it +holds a nonlinearity-independent 2–3 multigrid cycles where Newton climbs to 30+ +iterations, **including on an adapted (coordinate-deformed) mesh** — the actual +production target. The two remaining pieces before it is production-ready are +scalable saddle-point smoothers (the spike used direct LU per level) and a +per-level pressure nullspace for enclosed/free-slip problems. + +This note records the spike (scalar `SNES_Scalar` / `Poisson`, static meshes) that +established feasibility, and what remains before FAS could be offered as a +production feature or extended to Stokes. + +## Background: why FAS, and the architectural question + +The geometric **FMG** preconditioner landed in #231 gives *linear* multigrid that +is robust to mesh anisotropy. But nonlinear robustness still rests on Newton +(`newtonls`) / Picard, whose convergence basin shrinks as nonlinearity sharpens +(temperature-dependent viscosity, viscoplastic yield, Richards fronts). `SNESFAS` +performs coarse-grid correction on the **nonlinear residual itself**, which is the +classic lever for global robustness. + +The open question was whether UW3 could feed FAS a genuine nonlinear residual on +every coarse level. The FMG note (`docs/advanced/multigrid-preconditioning.md`) +states that UW3 "does not install residual/Jacobian callbacks on the coarse DMs, +so the coarse operators must be formed by Galerkin projection (RAP)". That is true +for the **linear `PCMG`** path — but it turns out **not** to block FAS. + +## Key finding: FAS works with zero code changes + +In `petsc_generic_snes_solvers.pyx` each solver `_build`: + +1. copies the `PetscDS` (which holds the JIT pointwise residual/Jacobian function + pointers) to **every** coarse DM — `self.dm.copyDS(coarse_dm)` in a loop over + `self.dm_hierarchy`; and +2. installs the SNES local-FEM callback on the **fine** DM only — + `UW_DMPlexSetSNESLocalFEM(cdm.dm, ...)`. + +When `snes_type = fas`, PETSc's `SNESSetUp_FAS` walks the coarse-DM chain (already +linked by `setCoarseDM` when the mesh is built with `refinement=N`) and +**propagates the fine DM's `DMSNES` local-FEM callback down to each level** via +`DMCopyDMSNES`. Combined with the already-copied DS, every level can evaluate its +own nonlinear residual and Jacobian. `-snes_view` confirms it: + +``` +type: fas + type is FULL, levels=3, cycles=1 + Not using Galerkin computed coarse grid function evaluation <-- genuine re-discretisation + Coarse grid solver -- level 0: newtonls + LU, rows=445 + Down/Up smoother on level 1: newtonls, rows=1857 + Down/Up smoother on level 2: newtonls (fine) +``` + +So the spike's anticipated "install callbacks on coarse DMs" change was +**unnecessary**. FAS is a `petsc_options`-only capability today. + +## Results (scalar, static mesh) + +Two testbeds on the unit square, P2 elements, mesh built with `refinement`: + +- **Bratu** `-Δu = λ e^u`, `u=0` on `∂Ω` (lower branch exists for `λ < λ_c ≈ 6.808`). +- **Exponential nonlinear diffusion** `-∇·(e^{βu}∇u) = f`, manufactured solution + `u = sin πx · sin πy` so a solution provably exists at every β (a stiff Newton + stressor as β grows). + +Configurations: `newton` (`newtonls`), `fas` (`snes_type=fas`, FULL cycle, +`newtonls` smoothers, LU coarse), `ngmres+fas` (NGMRES outer with FAS as the +nonlinear preconditioner, `-npc_snes_type fas`). + +### Correctness and mesh-independence (Bratu, λ=5) + +| refinement | levels | Newton its | FAS its (F-cycles) | ‖u_FAS − u_Newton‖ | +|---|---|---|---|---| +| 1 | 2 | 4 | **1** | 7e-15 | +| 2 | 3 | 4 | **1** | 2e-15 | +| 3 | 4 | 4 | **1** | 2e-15 | + +FAS converges in a single F-cycle independent of refinement, to the same solution +as Newton. Across `λ = 3…6.8`, `fas` and `ngmres+fas` agree with Newton to +1e-9…1e-15. Both Newton and FAS hit the **same** envelope at the fold +(`λ ≳ 6.85`, where no solution exists) — Bratu's lower branch is reachable by +Newton from a cold start, so Bratu shows FAS *works*, not that it is *more robust*. + +### Robustness advantage (exponential diffusion, MMS) + +| β | Newton | FAS | NGMRES+FAS | notes | +|---|---|---|---|---| +| 1 | 16 its ✓ | **1** ✓ | 1 ✓ | L2 ≈ 2e-7 all | +| 2 | 26 its ✓ | **1** ✓ | 1 ✓ | | +| 3 | **diverges** (line search) | **1 ✓** | **1 ✓** | solution exists — FAS finds it (L2 2.2e-7), Newton cannot | +| 4 | diverges | diverges | diverges | default smoothers insufficient | +| 5 | diverges | 80 (stalls) | 27 (fails) | | + +This is the headline: at β=3 a solution provably exists and **FAS converges in one +cycle where Newton diverges from the cold start**. FAS is not a silver bullet — +β ≥ 4 needs stronger smoothers/continuation than the spike's defaults. + +### Wall-clock (exponential diffusion) + +| β | refinement | Newton | FAS | speed-up | +|---|---|---|---|---| +| 1 | 2 | 1.86 s (16) | 1.10 s (1) | 1.7× | +| 2 | 2 | 2.59 s (26) | 1.14 s (1) | 2.3× | +| 1 | 3 | 6.45 s (16) | 5.35 s (1) | 1.2× | +| 2 | 3 | 10.6 s (26) | 5.56 s (1) | 1.9× | + +FAS is faster as well as more robust when the nonlinearity is strong (Newton needs +many steps). On mild problems where Newton converges in 3–4 steps, FAS's +per-cycle cost makes it merely competitive, not faster. + +### Parallel + +np=2 with a parallel-safe coarse solve (`fas_coarse_pc_type=redundant`, +`fas_coarse_redundant_pc_type=lu`) converges in 1 cycle, `L2=2.06e-7` vs np=1's +`2.07e-7`. The scalar parallel gate is clear. + +### Adaptation — FAS on a coordinate-deformed mesh + +The headline risk in the first cut of this note was that, because the movers +update only the fine DM's coordinates, FAS's coarse(undeformed) → fine(deformed) +inter-grid transfers might be "too weak" to converge. **Tested and refuted for the +scalar case.** A refined mesh (3 levels) was deformed with `follow_metric` +(tanh-front metric) at increasing strength, then the exponential-diffusion MMS was +solved on the deformed mesh: + +| follow R | q_min | coarse moved | fine moved | Newton | FAS | FAS L2 | +|---|---|---|---|---|---|---| +| — (static) | 0.89 | — | — | 26 ✓ | **1** ✓ | 2.1e-7 | +| 1.5 | 0.37 | **0** | 0.21 | 24 ✓ | **2** ✓ | 4.2e-7 | +| 2.0 | 0.32 | **0** | 0.35 | 24 ✓ | **2** ✓ | 6.3e-7 | +| 2.5 | 0.34 | **0** | 0.41 | 25 ✓ | **2** ✓ | 5.7e-7 | + +The mismatch is real ("coarse moved" = 0 confirms coarse DMs keep original +geometry while the fine DM deforms), yet FAS degrades only from 1 to **2 F-cycles** +and never loses correctness (L2 tracks Newton exactly). Pushed harder — into the +β=3 regime where Newton **diverges** on a uniform mesh — FAS on the *deformed* mesh +still converges in 2 cycles and stays correct: + +| β | follow R | q_min | Newton | FAS | FAS L2 | +|---|---|---|---|---|---| +| 3 | 1.5 | 0.37 | **diverges** | **2 ✓** | 4.3e-7 | +| 3 | 2.0 | 0.32 | **diverges** | **2 ✓** | 6.7e-7 | +| 2 | 3.0 | 0.32 | 25 ✓ | 2 ✓ | 5.3e-7 | +| 3 | 3.0 | 0.32 | **diverges** | **2 ✓** | 5.9e-7 | + +Why it works despite the mismatch: PETSc builds the transfers from the parent→child +*refinement* relationship (reference-element shape functions), not from a match of +physical coarse/fine geometry, and FAS only needs the coarse step to be a useful +*correction* — the fine smoother removes whatever the geometrically-imperfect +coarse correction leaves behind. Coarse-coordinate propagation would likely recover +the single-cycle count, but is **not required for convergence** on the scalar case. + +## How to use it today (no code required) + +```python +mesh = uw.meshing.UnstructuredSimplexBox(..., refinement=2) # builds the hierarchy +poisson = uw.systems.Poisson(mesh, u_Field=u) +# ... constitutive model, nonlinear f, BCs ... + +po = poisson.petsc_options +po["snes_type"] = "fas" +po["snes_fas_type"] = "full" # FMG-style F-cycle +po["fas_levels_snes_type"] = "newtonls" # per-level nonlinear smoother +po["fas_levels_snes_max_it"] = 4 +po["fas_levels_snes_linesearch_type"] = "basic" +po["fas_coarse_snes_type"] = "newtonls" # coarse nonlinear solve +po["fas_coarse_ksp_type"] = "preonly" +po["fas_coarse_pc_type"] = "lu" # parallel: "redundant" + redundant_pc_type "lu" +poisson.solve(zero_init_guess=True) + +# Or FAS as a nonlinear preconditioner to a robust outer accelerator: +# po["snes_type"] = "ngmres"; po["npc_snes_type"] = "fas"; po["npc_fas_*"] = ... +``` + +## Stokes (saddle-point) — the production target + +Everything above is scalar. The real goal is **nonlinear Stokes with adaptation**. +The spike carried the result all the way there. + +**Plumbing (linear Stokes).** FAS runs on the velocity–pressure saddle-point system +with no code changes. Using a monolithic LU smoother on each level (newtonls + +`preonly`/`lu`) on a constant-viscosity box with an **open top** (traction-free, so +*no* constant-pressure nullspace — the LU level solves stay non-singular), FAS +reaches the same velocity field as the default fieldsplit+FMG solve (rel.diff 4e-5, +1 cycle). So coarse residual, inter-grid transfers, and coarse correction all work +on a saddle system. The Stokes `solve()` reads `snes_type` from +`self.snes.getType()` (set by `petsc_options` during `_build`) and re-applies it, so +`snes_type=fas` survives on the standard path (`picard=0`, `zero_init_guess=True`). + +**Nonlinear Stokes — the win.** A *smooth* shear-thinning power-law viscosity +`η = η₀ (ε_II/ε_ref)^(1/n − 1)` (n=1 linear, larger n more nonlinear), open top: + +| n | Newton its | FAS cycles | same soln | +|---|---|---|---| +| 1 | 1 | 1 | 4e-6 | +| 2 | 14 | **2** | 2e-4 | +| 3 | 22 | **2** | 3e-4 | +| 4 | 28 | **3** | 3e-4 | +| 5 | 30 | **3** | 2e-4 | + +FAS holds **2–3 cycles while Newton climbs 14 → 30** as the nonlinearity sharpens — +the same nonlinearity-independence seen in the scalar exponential-diffusion case, +now on the saddle-point system, converging to the same solution. + +**Wall-clock — is the complexity worth it?** Yes, even with the un-optimised LU +smoother. FAS (monolithic LU per level) vs Newton (default fieldsplit + FMG): + +| n | refine | levels | Newton | FAS | speed-up | +|---|---|---|---|---|---| +| 3 | 2 | 3 | 15.0 s | 9.1 s | 1.66× | +| 5 | 2 | 3 | 19.9 s | 13.9 s | 1.44× | +| 3 | 3 | 4 | 72.5 s | 38.6 s | 1.88× | +| 5 | 3 | 4 | 95.3 s | 59.9 s | 1.59× | + +FAS is **1.4–1.9× faster** despite paying for a direct solve on every level, and the +gap *widens* with mesh size (1.66→1.88× from ref2→ref3 at n=3) because the cycle +count stays flat while Newton's iteration count does not. + +**But the per-level smoother is the crux, and it is not a `petsc_options` swap.** +`-snes_view` confirms the smoother on *every* level (including the fine, 17505×17505) +is `newtonls`+`preonly`+`lu` — a full direct factorization, ~6 of them per cycle on +the fine grid. That does not scale (2-D sparse LU is ≈ O(N^1.5)). The obvious "fix" +— a fieldsplit-Schur smoother on the levels, LU only on the coarse grid — was tried +and is **9–17× slower**, not faster: + +| n | refine | LU smoother | fieldsplit-Schur smoother | +|---|---|---|---| +| 3 | 2 | 9.0 s | 81 s | +| 3 | 3 | 37.6 s | 657 s | + +Both converge in 2–3 cycles to the same solution, but a Schur fieldsplit is itself +an approximate Stokes *solve*, and as a smoother it is invoked many times (≈ 2 Newton +steps × down+up × per level × per cycle) — so each relaxation does a near-complete +Stokes solve. At these sizes a single direct LU is cheaper. The genuine requirement +is an **inexpensive saddle-point smoother** — Vanka (element/patch block), Braess– +Sarazin, or a distributive/Uzawa relaxation — none of which is a stock PETSc +`petsc_options` choice for a DMPlex FE Stokes operator. **This, not the nullspace, +is the real research/engineering cost of production Stokes FAS.** Until it exists, +LU-smoothed FAS is a correct and (at moderate size) faster method whose cost is +dominated by the fine-grid direct solve. + +**Nonlinear Stokes ON AN ADAPTED MESH — the target.** Power-law Stokes (n=3) with +the mesh deformed by `follow_metric` toward the buoyancy feature (coarse DMs keep +their original geometry — the same mismatch the scalar case shrugged off): + +| follow R | q_min | Newton its | FAS cycles | same soln | +|---|---|---|---|---| +| — (static) | 0.89 | 22 | 2 | 0.0931≈0.0932 | +| 1.5 | 0.31 | 22 | **2** | 2.8e-4 | +| 2.0 | 0.32 | 22 | **3** | 2.7e-4 | +| 2.5 | 0.36 | 23 | **3** | 1.8e-4 | + +FAS keeps its 2–3 cycle convergence on the deformed saddle-point mesh, same answer +every time. **Feasibility for nonlinear-Stokes-with-adaptation is established.** + +**Decomposing the benefit: globalization vs convergence rate.** FAS delivers two +distinct things — (a) it drops the fine grid into Newton's quadratic basin (the +coarse→fine ramp-up / *globalization*), and (b) a genuine nonlinear-multigrid +*convergence rate* via repeated coarse correction. The globalization piece can be +had *cheaply*, reusing the existing Newton + linear-FMG machinery with no saddle +smoother, by **nested iteration / grid sequencing**: solve coarse, interpolate, +warm-start fine. Two ways to get it, and how much it buys: + +- PETSc `-snes_grid_sequence` **does not work** on UW3 meshes via `petsc_options`: + it triggers `DMPlexComputeInterpolatorGeneral` (PETSc err 56) rather than the + pre-chained nested hierarchy that FAS reuses — so, unlike FAS, it is *not* + plug-and-play. +- **Manual nested iteration at the UW3 level** (coarse solve → `uw.function.evaluate` + onto the fine field → warm-started fine solve) *does* work, and drops the fine + Newton count from 22→14 (n=3) and 29→19 (n=5) — but only ~1.1–1.2× wall-clock. + +So globalization alone is a *modest* win: it fixes the initial guess but not the +per-level convergence rate, and the coarse solve still pays the full nonlinear cost. +FAS's larger 1.5–1.9× comes from (b), the multigrid convergence rate — which is +exactly the part that needs the inexpensive saddle smoother. Practical reading: for +*robustness* on hard nonlinear Stokes, Picard warm-up (already in UW3) or manual +nested iteration is cheap and reuses FMG; for *speed*, FAS is the lever but only +once a real saddle smoother exists. + +**Where Stokes FAS does *not* help: non-smooth yielding.** A von Mises viscoplastic +viscosity `η = min(η₀, τ_y/2ε_II)` is hard for *every* solver: once the domain +yields (τ_y ≲ 1 here) Newton, Picard **and** FAS all fail. The `min()` kink is +non-smooth and, worse for FAS, the coarse level yields on a different pattern than +the fine, so the coarse correction is inconsistent. This is the known +regularization/continuation regime, not a FAS-specific failure — the same shape as +the scalar β≥4 ceiling. + +**Two engineering follow-ons before production Stokes FAS:** + +1. **An inexpensive saddle-point smoother (the hard part — feasible, but real work).** + The spike's per-level smoother is a monolithic direct LU — fine for proving the + algorithm (the 2–3 cycle count is the real result), but it does not scale. + Replacements tried, all on power-law n=3 at ref2/ref3: + + | smoother | result | + |---|---| + | monolithic LU (baseline) | converges, 2 cycles, 11 s / 38 s — but LU does not scale | + | fieldsplit-Schur, heavy (gmres 20) | converges but **9–17× slower** (a Schur solve per relaxation) | + | fieldsplit-Schur, light (gmres 1–2) | **zero pivot** → `DIVERGED_INNER` (−7) | + | PCPATCH **Vanka** | **zero pivot** on setup (singular local patches) | + + Every cheap attempt dies on the same rock: **"Zero pivot in LU factorization."** + The pressure block has a zero diagonal, so naive relaxations (SOR / ILU / a plain + sub-LU) divide by zero. That is the defining saddle-point difficulty, and exactly + what Vanka (solve each local velocity+pressure patch as a coupled block) and + Braess–Sarazin (a specific approximate-Schur relaxation) are constructed to avoid. + So the smoother is **feasible — Vanka via PETSc `PCPATCH` is the standard tool and + is used in production for Stokes geometric MG (e.g. Firedrake)** — but wiring it + into UW3 is a real task: correct `PCPATCH` patch construction so the local saddles + are non-singular, plus UW3-side exposure of the DM fields/topology to the patch PC + (analogous to `_setup_block_fieldsplit_options`). It is **not** a `petsc_options` + one-liner, which is what makes it the genuine engineering cost of scalable Stokes + FAS. +2. **Per-level pressure nullspace.** The spike used an open-top problem to avoid the + constant-pressure nullspace. `_attach_stokes_nullspace()` sets the nullspace on + the *outer* matrix only; FAS builds each level's matrix internally. Enclosed / + free-slip problems will need the nullspace registered at the DM/DS level (e.g. + `DMSetNullSpaceConstructor` on the pressure field) so every FAS level inherits + it. This is the one place a small UW3 code change is likely required. + +## Limitations and open questions + +- **Smoother/coarse tuning matters.** Default `newtonls` smoothers handle moderate + nonlinearity; very stiff regimes need stronger smoothers, more pre/post sweeps, + W-cycles, or continuation. This is normal FAS practice, not a UW3 limitation. +- **Coarse geometry under movers — TESTED, benign for scalar.** The movers update + only the fine DM's coordinates; coarse DMs keep their original geometry (verified: + "coarse moved" = 0). This was the headline risk, but on the scalar case FAS only + slows from 1 to 2 F-cycles under deformation strong enough to drop q_min to 0.32, + stays correct, and still converges where Newton diverges (see the adaptation + tables above). Coarse-coordinate propagation down the hierarchy would likely + restore the single-cycle count but is **not a prerequisite for convergence**. Whether + this still holds for the much larger anisotropy of an adapted Stokes problem is + the next thing to confirm. +- **Stokes saddle-point FAS — separate, harder.** FAS smoothers on the + velocity-pressure system need a fieldsplit smoother and a constant-pressure + nullspace on every level. Out of scope here; revisit only after the scalar path + and the coarse-geometry question are settled. + +## Recommendation + +1. **Offer FAS as an opt-in for scalar (and likely vector) nonlinear solves.** It + needs no new code — only a documented option bundle, or a thin + `nonlinear_preconditioner` / `snes_type='fas'` convenience knob mirroring the + FMG `preconditioner` property (default off; FMG/Newton defaults untouched). + The exact deliverable shape is deferred pending these results. +2. **Validate on Richards** (`docs/beginner/tutorials/16/17`), the strongest + real-world scalar FAS target, before committing to an API. +3. **Nonlinear-Stokes-with-adaptation is demonstrated** (2–3 cycles vs Newton's + ~22, on a deformed mesh, same solution; 1.4–1.9× faster with LU smoothers). + Production scalability hinges on an **inexpensive saddle-point smoother** (Vanka / + Braess–Sarazin / distributive) — *not* a stock fieldsplit, which is 9–17× slower + as a smoother. A **per-level pressure nullspace** is the smaller second piece + (enclosed/free-slip). Coarse-coordinate propagation stays an optimisation; the + non-smooth viscoplastic regime is out of scope (hard for all solvers). + +## Reproduction + +Spike scripts (not part of the repo; under `~/+Simulations/snesfas_spike/`): +`bratu_baseline.py`, `fas_probe.py`, `fas_measure.py`, `expdiff_mms.py`, +`adapt_fas.py` (FAS on a `follow_metric`-deformed mesh), and the Stokes set: +`stokes_fas.py` (linear plumbing), `stokes_fas_nl.py` (viscoplastic — hard for +all), `stokes_fas_powerlaw.py` (smooth nonlinear win), `stokes_fas_adapt.py` +(nonlinear Stokes on an adapted mesh). +Worktree `feature/snesfas-spike` off `origin/development` — **no source files +changed** (`git status` on `src/` is clean); the only artifact in-repo is this +note. Regression sanity: `tests/test_1014_stokes_multigrid.py` + +`tests/test_1000_poissonCart.py` → 18 passed. diff --git a/docs/developer/design/snesfas-vanka-feasibility-study.md b/docs/developer/design/snesfas-vanka-feasibility-study.md new file mode 100644 index 000000000..37dee2215 --- /dev/null +++ b/docs/developer/design/snesfas-vanka-feasibility-study.md @@ -0,0 +1,337 @@ +--- +title: "Feasibility study: a scalable saddle-point smoother for Stokes SNESFAS" +--- + +# Feasibility study — scalable saddle smoother for Stokes FAS + +**Bottom line: GO — demonstrated with a working, mesh-independent prototype. +[Revised 2026-06-15 after expert input + experiments — supersedes the earlier +NO-GO.]** Vanka works on UW3's simplex Taylor-Hood Stokes; the earlier failure was +PETSc's *stock* `-pc_patch_construct_type vanka`/`star` heuristic mis-constructing +patches for continuous-P1 simplices, **not** Vanka. With patches built as *the +support of each pressure basis function* (one pressure DOF + its B-coupled velocity +DOFs), driven by **`PCASM` with custom index sets** and exact local LU mini-Stokes +solves, and used as the smoother of a geometric multigrid on the full saddle, the +solver is **mesh-independent: 5 → 5 → 6 outer FGMRES iterations across a 16× growth +in DOFs (1.2k → 19k)**. The one non-obvious ingredient: the smoother must be a +**Krylov (GMRES) smoother** wrapping the Vanka PC — an undamped Richardson smoother +amplifies the additive-Schwarz spectrum and diverges. So the scalable-smoother piece +is **done in prototype**. **Caveat on the payoff:** the iteration count is +mesh-independent, but at moderate **2-D** sizes it is *not* faster in wall-clock than +a sparse direct solve / fieldsplit+FMG (2-D direct solves are cheap); Vanka's +wall-clock advantage is asymptotic in 2-D (~10⁵–10⁶ DOFs) and real in **3-D and +large-parallel** runs, where direct solves scale badly. So this is the tool for +big/3-D/parallel Stokes, not a speed-up for typical 2-D problems. What remains is +productionisation (wrap in FAS — already proven with an LU smoother; per-level +pressure nullspace; parallel patch construction; a UW3 API). Until that lands, FMG + +Newton/Picard/nested ships and LU-smoothed FAS is the moderate-size nonlinear option. + +> The detailed reasoning below that concluded NO-GO was **correct about stock +> `PCPATCH` but wrong about Vanka in general**. Read it as "stock PCPATCH +> constructions are the wrong tool", then jump to *Correction* near the end for the +> working approach and the revised path forward. + +## Objective + +Decide — *before* a major rewrite — whether a production-scalable Stokes FAS is +achievable, by answering whether a cheap saddle-point smoother can be made to work +in UW3. + +## Method + +Staged, cheapest-fatal-risk-first, with the key move of **decoupling the smoother +from FAS**: test each smoother candidate as a plain *linear* preconditioner on a +constant-viscosity UW3 Stokes solve first (a `pc_type` swap), since FAS itself is +already proven. Option sets were lifted from PETSc's own CI-tested example +`src/snes/tutorials/ex62.c`. + +## Findings + +### Stage 0 — the known-good Vanka recipe exists (and my first attempt was mis-configured) + +`ex62.c`'s `2d_q1_p0_gmg_vanka` test gives the authoritative incantation: +`-pc_type patch -pc_patch_partition_of_unity 0 -pc_patch_construct_codim 0 +-pc_patch_construct_type vanka -..._sub_pc_type lu -mg_coarse_pc_type svd`. Note: +**every passing Vanka test in ex62 uses `dm_plex_simplex 0` (quads), Q1–P0 +(discontinuous pressure)** — there is no CI-tested Vanka for simplex Taylor-Hood. + +### Stage 1 — Vanka does not work on UW3's Stokes (the crux, R2) + +PCPATCH Vanka as the outer linear PC on a constant-viscosity UW3 Stokes solve, with +the corrected recipe, fails **uniformly**: + +| variant | result | +|---|---| +| P1-continuous, sub LU | **zero pivot** (singular patch) | +| P0/P1-discontinuous, sub LU | **zero pivot** | +| `+ pc_use_amat` (true saddle Jac, not UW3's Schur Pmat) | **zero pivot** | +| sub `svd` (tolerates singular patches), P1-cont | builds, but **no convergence** (400 its, no progress) | +| sub `svd`, P0-disc | builds, **crawls** to 0.00274 vs 0.00319 ref, 427 its, no convergence | +| **quad** mesh (ex62's Vanka element family), all of the above | **identical** failure pattern | + +So the failure is **not** element-type (simplex vs quad), pressure continuity, or +sub-solver. The patches are genuinely singular (SVD removes the pivot but leaves an +ineffective preconditioner). The obstruction is in how `PCPATCH` constructs/assembles +the local patch operators (and their boundary conditions / local nullspaces) from +UW3's DM/DS — the same interface Firedrake feeds correctly when it runs DMPlex Stokes +Vanka. UW3 confirmed to build a **separate Schur-structured Pmat** (`1/μ` pressure +mass; `petsc_generic_snes_solvers.pyx:4381`), which is part of why the default path +hands Vanka the wrong operator, but `pc_use_amat` did not rescue it — the problem is +deeper than operator selection. + +### Braess–Sarazin hedge — works as a PC, too weak as a smoother + +A *correct* Braess–Sarazin (diagonal velocity block `fieldsplit_velocity_pc_type=jacobi` ++ `selfp` Schur on the true operator via `pc_use_amat`) **converges as an outer PC** +(reason 3, exact reference solution) — options-only, no patches, no zero pivot. But +as a **FAS level smoother it fails** (`DIVERGED_INNER`): it needs ~63 iterations to +converge as a PC, i.e. it is a *weak* relaxation, so a few sweeps per smooth don't +reduce error. A stronger variant (SOR velocity + full Schur) converged at ref2 (5 +cycles) but was **22× slower than LU and failed at ref3** — unreliable and expensive. + +### The smoother-candidate matrix + +| smoother | cheap? | effective? | scalable? | status in UW3 | +|---|---|---|---|---| +| monolithic **LU** | no | yes | no | works; fastest at moderate size; O(N^1.5) wall | +| **Vanka** (PCPATCH) | yes | yes | yes | **blocked** — singular/ineffective patches | +| **Braess–Sarazin** (diag) | yes | no (weak) | yes | works as PC, fails as smoother | +| BS + SOR velocity | ~ | unreliable | ? | 22× slower, fails at ref3 | +| heavy fieldsplit-Schur | no | yes | ~ | 9–17× slower than LU | + +No candidate is simultaneously cheap, effective, and configurable. The only effective +smoothers are expensive (LU, heavy fieldsplit); the only cheap one (BS) is too weak. + +## De-risking step 1 — DONE: simplex Vanka fails in clean PETSc too + +The proposed de-risk was to test Vanka *outside* UW3. Rather than re-write a Stokes +solver in petsc4py, the cleanest reference is PETSc's own `ex62.c` (DMPlex Stokes, +the source of the recipe). Compiled it (this build has **no 2-D simplex generator** — +`--download-triangle` absent — so a gmsh unit-square `.msh` was loaded via +`-dm_plex_filename ... -dm_plex_boundary_label marker`; UW3 itself only gets simplices +through gmsh for the same reason). Results, **pure PETSc, no UW3**: + +| element / construction | result | +|---|---| +| **quad** Q1–P0, `vanka` (the CI-tested recipe) | ✅ converges, 59 KSP its | +| simplex P2–P1, full LU **and** fieldsplit-Schur (controls) | ✅ converge — mesh/BC are correct | +| simplex P2–P1, `vanka` patches (sub lu / svd) | ❌ KSP **stalls at the initial residual** (29.46 flat for 200 its) | +| simplex P2–P0, `vanka` patches | ❌ diverges | +| simplex P2–P1 / P2–P0, `star` (vertex) patches | ❌ diverges | + +So Vanka works on quads and the same binary solves the simplex mesh fine with +standard solvers, but **every patch-smoother construction is ineffective on simplex +Taylor-Hood** — the KSP makes *zero* progress. This is a PETSc/numerical-methods +fact, independent of UW3. (The earlier UW3 zero-pivot was just the first symptom of +the same underlying problem; "fixing UW3's DM/DS↔PCPATCH interface" would **not** have +helped, since clean PETSc fails identically.) + +**Why:** stock `PCPATCH` Vanka/star patches are built for low-order, quad/structured, +discontinuous-pressure elements. Effective patch smoothing for simplex Stokes needs +specialist constructions from the literature — typically a patch-smoothable stable +element (e.g. **Scott–Vogelius on barycentrically-refined/Alfeld meshes**) plus +**macro-element patches** (the Farrell–Mitchell–Wechsung programme). That is a change +of *element and mesh*, not a smoother option. + +## Correction (2026-06-15): custom-IS PCASM Vanka works — the NO-GO was wrong + +External expert input reframed Vanka as *"a specialised overlapping-Schwarz +preconditioner whose patches are defined by the pressure space, not by geometry"*: +loop over pressure DOFs, use the FE sparsity of the divergence block **B** to gather +the coupled velocity DOFs, extract the local mixed matrix, factor it, and run +additive/multiplicative Schwarz — i.e. **`PCASM`/`PCGASM` with custom index sets**, +the patches being the *support of each pressure basis function*. This is standard for +Taylor-Hood, MINI, Scott–Vogelius, variable-viscosity convection, etc. The stock +`PCPATCH` `vanka`/`star` constructs I tested are *not* this — they apply a fixed +topological heuristic that misfires for continuous-P1 simplices. + +Tested directly on UW3's true saddle Jacobian (open-top, no pressure nullspace): + +``` +saddle 1110×1110, fields [velocity, pressure], n_vel=968 n_pres=142 +patches = 142 (one per pressure DOF), size 11–45 (mean 32.5) +PCASM-Vanka (RAS and additive): CONVERGED, 312 fgmres its, rel_err 2.2e-6 +``` + +One-level iteration count vs mesh size — the smoother signature: + +| cellSize | ndof | n_pres | 1-level Vanka its | +|---|---|---|---| +| 0.15 | 555 | 75 | 147 | +| 0.10 | 1110 | 142 | 312 | +| 0.07 | 2479 | 303 | 688 | + +Iterations grow ~with 1/h (no coarse correction) — high-frequency error is removed, +low-frequency is left for the MG coarse grid. That is precisely a multigrid smoother. +So **the failure was the patch *construction*, not Vanka**, and the smoother exists +for UW3's element. + +## The working recipe (prototype: `vanka_mg_WORKING.py`) + +A geometric multigrid on the **full** Stokes saddle, custom-IS Vanka smoother: + +1. **Hierarchy + Galerkin.** PCMG over UW3's `dm_hierarchy` (built by `refinement=N`), + interpolation per level from `DMCreateInterpolation`, coarse operators by Galerkin + `pc_mg_galerkin both` (the FMG path — UW3 has no coarse-DM callbacks). Block-diagonal + velocity/pressure interpolation keeps the coarse operators saddle-structured. +2. **Per-level Vanka smoother.** For each level: from the level's field decomposition + + the operator's row sparsity, build one patch per pressure DOF = {pressure DOF} ∪ + {B-coupled velocity DOFs}; install a `PCASM` (RESTRICT) with those index sets and + exact `sub_pc_type lu` mini-Stokes solves. (Set subdomains after a `PC.reset()` + + re-attach operator, since they must precede `PCSetUp`.) +3. **Krylov smoother — the key.** Wrap the Vanka PC in **`ksp_type gmres`, ~6 its** + per level. Richardson (even damped) diverges because additive Schwarz has spectral + radius > 1; GMRES self-stabilises it. Outer solver is FGMRES (flexible, since the + smoother is now a Krylov method). +4. **Coarse solve:** LU. + +Measured: **5 / 5 / 6 / 5 outer iterations at ndof 1.2k / 4.8k / 19k / 76k** — +mesh-independent. + +### Timing — and the right competitor is FMG, not LU + +A direct (LU) solve is a *strawman* competitor: if a full-scale factorization is +affordable you would just use it directly, not as a smoother — and in 3-D / parallel +that option disappears (the sparse direct solver, MUMPS, scales ~O(N²) in 3-D and is +unreliable at large core counts). The honest competitor is **FMG** (velocity-block +geometric MG inside the Schur fieldsplit, #231). + +Linear 2-D Stokes, same problem (FMG numbers include assembly/JIT — not a clean +linear-solve isolation; Vanka is linear-solve only): + +| | 1.2k | 4.8k | 19k | 76k | iters | +|---|---|---|---|---|---| +| **FMG** total solve | (JIT) | ~1.7s | ~1.8s | ~4.7s | outer Schur = 1 | +| **Vanka-MG** linear solve | 0.02s | 0.13s | 1.1s | 5.7s | 5–6 | +| LU-smoother MG (≈ direct) | 0.01s | 0.07s | 0.54s | ~5s | 1 | + +For **linear 2-D Stokes the three are in the same ballpark** — FMG is mature and +slightly ahead, the LU/direct path is cheap because 2-D fill-in is modest, and +Vanka-MG is competitive but not a winner. So Vanka does **not** earn its keep on easy +(linear, moderate-2-D) problems. + +**Where Vanka-MG wins — and the reason to build it:** + +- **3-D and large-parallel**, where the direct solves that FMG's coarse grid and the + LU paths lean on scale badly / become unreliable, while Vanka's local patch solves + scale ~O(N) and parallelise naturally. (3-D and parallel timing is the obvious next + measurement.) +- **Strongly nonlinear / plastic rheology** — the decisive case. Fieldsplit-Schur+FMG + assumes a good Schur (pressure) approximation, which degrades when the viscosity + varies wildly (viscoplastic yield, thermal runaway). A **full-saddle Vanka-FAS** + smooths the coupled nonlinear system directly, and — the key point — the **coarse + problems are better conditioned** (plasticity localises; on coarse grids the yield + is smeared/milder), so the nonlinear coarse correction is especially effective. + This is exactly the regime where Newton+FMG needs many iterations or stalls + (cf. the viscoplastic results in `snesfas-feasibility.md`, where Newton, Picard + *and* LU-FAS all struggled). + +**Takeaway:** the value proposition is *not* "faster on linear 2-D" — there FMG is +fine. It is **robust, scalable nonlinear Stokes in 3-D / parallel / plasticity**, +where the fieldsplit-Schur assumptions and direct solves break down. + +### First data point — FMG vs FAS on hard benchmarks (`benchmark_fmg_vs_fas.py`) + +FMG (Newton + fieldsplit-Schur + velocity FMG, `saddle_preconditioner=1/η`) vs FAS +(snes_type=fas, LU smoother — *not yet Vanka*), open-top so no nullspace, +refinement=2: + +*SolCx viscosity step (linear), Δη = 1 → 10⁶:* both **converge at every contrast**. +FMG is faster (1–2 outer iterations, 7–40 s) but its time *grows* with the jump +(12 s → 40 s from 10⁴ → 10⁶ as the velocity-block MG degrades); FAS-LU is flat +(~62 s, 2 cycles, robust but slow). So with the right Schur preconditioner FMG +handles the discontinuous-viscosity benchmark well; FAS's edge only shows as the +contrast becomes extreme. + +*Viscoplastic yield (nonlinear), τ_y = 10 → 0.25:* the nonlinear iteration count +favours FAS — at τ_y = 1, **FAS converges in 3 nonlinear cycles where FMG+Newton +needs 9 iterations** (the nonlinear coarse correction at work, as predicted). Below +τ_y ≈ 0.5 *both* fail — the hard-yielding regime is continuation/regularisation +territory for every solver (consistent with `snesfas-feasibility.md`). FAS is slower +in wall-clock here only because it uses the LU smoother; the Vanka smoother is what +would make the 3-vs-9 nonlinear-iteration advantage also a wall-clock win at scale. + +**Reading:** FMG is the right default for linear / moderate problems; FAS's +robustness advantage is real but currently shows as *fewer nonlinear iterations* in +the plastic regime, not yet as wall-clock (LU smoother). Wiring in the Vanka smoother ++ pushing to 3-D / extreme contrast is where the combination should pull clearly +ahead. The very-hard yield regime needs continuation regardless of solver. + +### Three turnkey choices — FMG vs GAMG vs FAS-Vanka (`benchmark_3way.py`) + +With the Vanka smoother actually wired into FAS (custom-IS injection, GMRES Krylov +smoother), the three production options compared on the same problems (open top, +res 16 / refinement 1): + +*SolCx viscosity step (linear):* + +| Δη | FMG | GAMG | FAS-Vanka | +|---|---|---|---| +| 1 | 1 it, 3.9s | 2 it, 4.9s | 1 it, 3.0s | +| 10³ | 1 it, 2.8s | 3 it, 14s | 1 it (gmres-15 smoother) | +| 10⁶ | 2 it, 5.8s | 8 it, **99s** | **FAIL** | + +*Viscoplastic yield (nonlinear):* + +| τ_y | FMG | GAMG | FAS-Vanka | +|---|---|---|---| +| 10 | 1 it | 2 it | 1 it | +| 1 | 10 it | 10 it | **2 it** | +| 0.3 | FAIL | FAIL | FAIL | + +**What this says — and the honest "how much tuning":** + +- **FMG** is the **robust all-rounder and the right default.** Its + `saddle_preconditioner = 1/η` makes the Schur (pressure) approximation + viscosity-robust, so it sails through the 10⁶ contrast (5.8 s) where the others + struggle, and it is fastest almost everywhere. Needs a refinement hierarchy. +- **GAMG** is the **no-hierarchy fallback** — robust but it *cliffs in cost* at high + contrast (99 s at 10⁶, 8 outer iterations). Use when there is no geometric + hierarchy. +- **FAS-Vanka** is the **nonlinear / plasticity specialist.** It crushes the + viscoplastic case (**2 nonlinear iterations vs 10** for the others — the coarse + problem is better conditioned, exactly as expected) and handles moderate viscosity + contrast (10³) once the Krylov smoother is bumped to ~15 inner iterations. But with + *additive* PCASM Vanka it **fails at extreme contrast (10⁶)** no matter how hard you + smooth — that needs a **multiplicative** Vanka (PCGASM / a coloured patch sweep) + and/or viscosity-aware coarsening, which is genuine development, not a flag. + +**Bottom line for "2–3 good choices, not much tuning":** FMG (default) and GAMG +(fallback) are the two robust, low-tuning options today. FAS-Vanka is a strong third +**specifically for strongly nonlinear / plastic problems** (where it beats both on +iteration count) and for 3-D / large-parallel; making it a *robust* turnkey peer of +FMG across all regimes needs the multiplicative-Vanka smoother (the extreme-contrast +gap) plus the productionisation items (per-level nullspace, parallel patches, UW3 +API). The plasticity win, though, is real and in hand now. + +## Path forward (GO — productionise the prototype) + +1. **Wrap in FAS for the nonlinear case.** The smoother is the hard part and it is now + solved; FAS adds the nonlinear coarse correction, already proven to work with an LU + smoother (`snesfas-feasibility.md`). Swap LU → the Vanka GMRES smoother above. +2. **Per-level pressure nullspace** for enclosed / free-slip problems (the prototype + used an open top to avoid it). Register it at the DM/DS level so each level inherits + it. +3. **Parallel patch construction** (PCASM is parallel; build the pressure-support index + sets rank-locally) and a **UW3 API** (a `smoother="vanka"` path that builds the IS + per level and installs the smoother — ≈ the prototype's ~40 lines, generalised). +4. **Open optimisation:** whether `PCPATCH` can be configured to build these exact + pressure-support patches (would make it options-only, no custom-IS code). + +The earlier "research only / different element" conclusion is fully withdrawn: a +mesh-independent Vanka multigrid for UW3's existing simplex Taylor-Hood Stokes is +demonstrated; what remains is engineering. + +## Meanwhile + +`FMG + Picard / nested iteration` remains the production workhorse for nonlinear +Stokes; LU-smoothed FAS is a correct, moderate-size-faster option where nonlinear +robustness matters more than asymptotic scaling. Neither is blocked; both ship today. + +## Reproduction + +`~/+Simulations/snesfas_spike/`: `vanka_stage1.py` (UW3 Stage 1 matrix), `/tmp`-staged +probes `vanka_quad.py`, `braess.py`, `bs_fas.py`, `bs_sor.py`. Clean-PETSc reference: +`petsc-custom/petsc/src/snes/tutorials/ex62.c` (compiled in the `amr-dev` env; gmsh +mesh `/tmp/square.msh` from `/tmp/mksquare.py`; driven with the `vanka`/`star` patch +options above). PETSc `/*TEST*/` block `*_vanka` suffixes are the recipe source. diff --git a/docs/developer/design/solver-strategies-catalogue.md b/docs/developer/design/solver-strategies-catalogue.md new file mode 100644 index 000000000..f23a338f8 --- /dev/null +++ b/docs/developer/design/solver-strategies-catalogue.md @@ -0,0 +1,406 @@ +--- +title: "Solver strategies catalogue" +--- + +# Solver strategies — switches, dials, and when to reach for them + +**Scope:** the index + picking guide for solver knowledge across +**all UW3 PDE families** — Stokes (linear, variable viscosity, +nonlinear / strain-rate-dependent, yield / viscoplastic), Darcy, +Poisson, Navier–Stokes — plus time-integration *order* for +visco-elastic (VE) and visco-elasto-plastic (VEP) problems, +boundary-treatment / pressure-space / parallel-correctness +choices, and the diagnostic tooling that supports them. + +**Status:** working notes / catalogue, ahead of full documentation. +**Consult and contribute as standard PDE work** (see +`memory/feedback_solver_strategies_catalogue.md`): start here when +hitting a solver wall, and add findings back when settled. Each +entry: what it does, the mechanism, the evidence, when to reach +for it, and the caveats. The catalogue is the *aggregation point* +— individual deep findings live in sibling design notes in this +directory, linked from here. + +**Current body of content (2026-05):** the adaptive-mesh + Stokes +warm-start investigation populated the catalogue with its first +batch of entries (V,P remap, `snes_atol`, cold-restart, SNES +line-search variants, GAMG anisotropy tuning, direct inner solve, +`mesh.quality()`, the error-estimator and geometric-MG design +arcs). **Intended growth:** entries for the other PDE families +(Darcy, Poisson, Navier–Stokes), time-order guidance for VE/VEP +(consolidating the existing project-memory findings on BDF +order, yield-coupling, two-Stokes split, dt-yield interactions), +variable-viscosity / viscosity-contrast pressure-space choices, +and viscoplastic flow strategies. Extend as those threads land +or as referenced project memories are touched. + +The investigation's mental model: solver fragility on an adaptive +problem has several *independent* failure classes, each with its +own appropriate cure. Reaching for the wrong cure for a given +failure can give the right answer for the wrong reason and mask +the real cause — so it's worth being explicit about which cure +addresses which class. + +## Failure classes — quick reference + +| symptom | underlying class | indicated cure | +|---|---|---| +| Re-solving a near-solved state fails (`DIVERGED_LINE_SEARCH` from a tiny initial residual) | guess-relative-only convergence (`snes_atol` unset) | **snes_atol** absolute path | +| Warm-start from a stale guess on a just-moved mesh fails | V,P not remapped across the mesh move | **V,P remap** (mirror T) | +| Warm-start fails through a violent transient *despite* a fresh, correct previous solution | inner KSP gives an inexact Newton step that `bt` line search rejects on an anisotropic operator | **accurate inner solve** (best PC for the operator), or **bypass the line search** (l2 / direct) | +| Failures recur in same-mesh bursts after a single failure | corrupted V,P propagates as next warm start | **cold-restart fallback** | +| Adaptation degrades element regularity → AMG aggregation degrades | mesh-quality side of the coupled mesh⇄solver problem | **mesh.quality()** monitoring + a less-aggressive grading dial (the equidist `resolution_ratio` is the user-facing one; legacy `coarsen_cap` / `aniso_cap` are demoted overrides) | +| Refinement bunches even where it isn't needed; can't say "add nodes" | percentile metric is relative-not-absolute | **error-estimator-driven metric** (design arc) | + +## Diagnostics (harness) + +The harness (`scripts/adaptive_saturation.py`) carries the flags +the investigation accumulated; they belong as durable diagnostic +tooling, not just one-off probes: + +* `--snes-debug` — after each adv/Stokes solve, query + `snes.getConvergedReason()` + `getIterationNumber()` and tag + which physics solver diverged + reason code + iter count. + Replaces the solver-anonymous PETSc retry message. **Does not** + set global PETSc viewers (they leak into the mover's `ksponly` + sub-solves and spam phantom `DIVERGED_MAX_IT iterations 0`). +* `--resume-from N` + `--src-tag SRC` — restart from a specific + checkpoint of another model, write outputs under the current + `--model` tag. Enables the *clean-restart probe* pattern: a + reproducible failure window from a known state without re-running + the entire trajectory. +* `--stokes-cold-recover N` — see "cold-restart fallback" below. +* `--no-vp-remap` — A/B disable the V,P remap; see "V,P remap". +* `--stokes-snes-opt {default,basic,l2,tr,ksponly,direct, + gamg-n1,gamg-thr,gamg-noagr,gamg-sor,gamg-full,...}` — selects a + preset bundle of PETSc options on the Stokes solver; see the + SNES-line-search and GAMG sections. +* `--stokes-snes-atol-auto` — captures cold ‖F₀‖ and sets a fixed + `snes_atol`; see "snes_atol" / `snes-atol-convergence-scale.md`. + +The **`mesh.quality()` API** + the `view()` summary line is the +mesh-side diagnostic — shape quality `q = 4√3·A/Σℓ²` (min / +percentiles), max interior angle, aspect ratio, neighbour +size-jump, and the joint "large-AND-stretched" cell count. The +relevant *tail* metrics for FE conditioning are what `minA/meanA` +hides. + +## V,P remap on mesh move + +**Class:** correctness, esp. for *nonlinear* solves where Newton +has a small convergence basin. + +**What:** when the mesh moves, evaluate the previous V,P at the +new DOF coords (the same FE-evaluate-at-new-coords that T already +gets) and write the results onto the new mesh — *do not* leave the +old nodal values on moved nodes. + +**Evidence:** without it, warm-start at adapt steps takes a +spatially-scrambled guess → `DIVERGED_LINE_SEARCH` on adapt steps +specifically (a16r15v cleared all adapt-step failures by remapping; +24 → 24 (no adapt) vs failures concentrated on non-adapt steps). + +**Status:** implemented in the harness adapt functions +(`adapt_local_fe_interp`, `adapt_pristine`). **Production version +belongs in UW3's adaptation/deform path** (gated). The pristine / +local subtlety: V,P live in `X_prev` geometry (not the pristine +X0c geometry that T transfers through) — that asymmetry matters. + +## `snes_atol` — guess-independent convergence + +**Class:** near-converged-guess re-solve (steady-state continuation, +restarts, lightly-evolving). The PETSc default `snes_atol ~ 1e-50` +makes the absolute convergence path effectively dead; UW3 sets +`snes_rtol` but not `snes_atol` ⇒ only the guess-relative +`rtol·‖F(x₀)‖` criterion is live. + +**What:** set `snes_atol` to the problem's natural residual scale +(e.g. `rtol · ‖F(x=0)‖_current`, recomputed per warm solve, +temporarily applied and restored), so `SNES_CONVERGED_FNORM_ABS` +fires at it==0 when the warm guess is already good — zero Newton +iterations. + +**Evidence:** PETSc 3.25 `SNESConvergedDefault` source-verified; +confirmation experiment showed exactly the predicted behaviour +(works for the near-converged class; *did not* fix the violent- +transient class — that's a different mechanism). + +**Status:** design note in `snes-atol-convergence-scale.md`, +gated on sign-off + benchmarking. Internal/automatic, **no user +API** (user never sets `atol` directly). + +## Cold-restart fallback + +**Class:** operational safety net for any divergence that survives +the other fixes (genuine nonlinear divergence from a bad guess +where no line-search config rescues; or the transient case +described next, before the inner-solve fix is in place). + +**What:** on a Stokes `DIVERGED_LINE_SEARCH` (or any negative +reason), discard the (now corrupted) warm V,P and re-solve cold +(`zero_init_guess=True`) on the *same mesh, same T* before +advancing. Warm-first, cold-on-failure — standard robust +nonlinear-solver practice. + +**Evidence:** harness `--stokes-cold-recover N`. a16r15r: +31/31 recoveries succeeded; the run settled cleanly. Important +nuance: in a violent transient, cold-restart fires on *runs* of +consecutive steps (not isolated events) — every step warm-fails +because the previous step's true solution is itself a poor Newton +start for the next step. Cold-restart guarantees correctness, but +in the violent transient regime is *not* cheap (one cold solve +per step in the danger window). + +**Status:** harness flag; production = port to UW3's SNES +solve() path. + +## SNES line search / type variants + +| `--stokes-snes-opt` | mechanism | takeaway | +|---|---|---| +| `default` (`newtonls`+`bt`) | full backtracking | the existing default; brittle to inexact Newton steps | +| `basic` | full step, no backtracking | works on *linear* problems; **removes globalisation → unsafe nonlinear** — diagnostic only | +| `l2` | minimises ‖F‖ along the Newton direction | clean *general* line-search variant (legitimate fallback), but **slow** (extra residual evaluations); fixes the bt-rejection symptom, not the cause | +| `tr` (`newtontr`) | trust region | **hopeless on the Stokes saddle point** (indefinite Jacobian, TR quadratic model ill-posed); 98 fails at *step 1* — do not use | +| `ksponly` | one linear KSP solve, no Newton/line-search | works only because Stokes is *linear* here; **invalid for nonlinear rheology** | +| `direct` | MUMPS LU on the full Stokes Jacobian | exact inner solve; 24→0 warm divergences. **Gold standard at small/2D scale; not feasible at scale** | + +The cleanest pattern (from the GAMG sweep, see below): **none of +these is the production cure.** The principled fix is to make the +*existing* default `newtonls`+`bt` work, by giving it an *accurate +enough Newton step* — i.e., fix the inner KSP/PC, not the outer +line search. + +## GAMG anisotropy tuning + +**Class:** AMG aggregation defaults degrade on anisotropic +operators (stretched / graded cells from adaptive refinement), +producing aggregates that span the weak direction. The inner KSP +under-converges the Newton correction; `bt` line search rejects +the step; SNES reports `DIVERGED_LINE_SEARCH`. + +UW3's default Stokes PC is **GAMG (aggregation AMG)** with +`pc_gamg_type=agg`, `pc_gamg_agg_nsmooths=2` (PETSc default is 1), +`pc_mg_type=additive`. Smoother defaults: Chebyshev + Jacobi. + +**CRITICAL — option scope (corrected 2026-05-20).** UW3 Stokes +nests its GAMG inside the velocity Schur sub-block at prefix +``fieldsplit_velocity_pc_gamg_*`` (see +``cython/petsc_generic_snes_solvers.pyx`` ~L4199-4205). Setting +``pc_gamg_*`` at the bare/global scope ⇒ silent no-op — PETSc +reads the option key at the velocity sub-block prefix and never +inherits from the bare prefix. Verified bit-identical KSP +residuals to default on a static one-shot probe +(``scripts/_sl_preset_verify.py``), and bit-identical warm-fail +signature to default on the dynamic 40-step probe (both gave +4 fails at steps 61-64 with iter counts [4,6,4,1] — +indistinguishable). + +The **earlier GAMG sweep in this catalogue used the WRONG +scope** and therefore "validated" a string of no-op presets +against each other. Re-run with the correct +``fieldsplit_velocity_pc_gamg_*`` prefix gives a very different +table — including one preset that **actively breaks** the +solver: + +**Corrected sweep (restart-from-50 testbed, 40 steps, baseline +4 warm DIVERGED at steps 61–64, all options at the proper +``fieldsplit_velocity_*`` prefix):** + +| `--stokes-snes-opt` | option(s) (at `fieldsplit_velocity_*` prefix) | warm fails | mechanism | +|---|---|---|---| +| `gamg-n1-corr` | `pc_gamg_agg_nsmooths=1` (PETSc default) | **0** ✓ | revert UW3's `=2` override; smoothed aggregates of degree 2 on graded mesh hurt | +| `gamg-thr-corr` | `pc_gamg_threshold=0.02`, `threshold_scale=0.5` | **23** ✗ DANGEROUS | aggressive thresholding prunes the weak-direction connections AMG actually needs on adapted velocity operator — *worse* than default | +| `gamg-noagr-corr` | `pc_gamg_aggressive_coarsening=0` | **0** ✓ | suppress finest-level MIS-2 aggressive coarsening | +| `gamg-sor-corr` | `mg_levels_ksp_type=richardson`, `pc_type=sor`, `ksp_max_it=2` | **0** ✓ | stronger smoother absorbs sub-optimal aggregates | +| `gamg-full-corr` | combined | **0** ✓ | no improvement over single fixes | +| `gamg-noagrsor-corr` | noagr + sor | **0** ✓ | no improvement over either alone | + +**Findings:** + +1. Five of six correct-scope variants close the failure window + independently. They produce indistinguishable wall times + (≈5 min for 40 steps at res-16) → no clear performance winner + on this small problem. Any of them can serve as the + surgical fix. +2. **`gamg-thr-corr` is dangerous** — 23 fails vs 4 baseline. + The threshold+threshold_scale pair at the velocity sub-block + removes structure GAMG needs. Do not use. (Was silently a + no-op at the wrong scope, masking this danger.) +3. The mechanistic story (Cheb+Jac × poor aggregates → + divergence; fix either side and it works) survives — the + evidence base just shrunk to noagr/n1/sor/full/noagrsor. + +**Recommended UW3 default change (corrected):** +``fieldsplit_velocity_pc_gamg_aggressive_coarsening = 0`` on the +Stokes solver. Single integer; surgical; closes the failure +window; preserves Cheb+Jac for HPC parallel scalability. **Note +the scope** — bare ``pc_gamg_aggressive_coarsening = 0`` does +nothing. + +**Verification methodology (mandatory for future GAMG-tuning +claims):** before claiming a tuning helps, verify the option is +actually applied to the GAMG instance it targets. Static probe: +run the SAME problem twice with and without the option, on a +fixed T snapshot, with ``snes_monitor`` and ``ksp_monitor`` +enabled. If the KSP residual values are bit-identical between +the two runs, the option is a no-op (wrong scope) and any +"benefit" elsewhere is illusory. See +``scripts/_sl_preset_verify.py`` for the verification harness. + +**Caveats:** +- The 40-step restart probe is a narrow window (4 failure + opportunities). Closing it does *not* prove a candidate + survives a full settled trajectory or harder problems. +- These tests are on a *simple* PDE (constant-viscosity + Stokes, T-fixed buoyancy). The story may differ with + nonlinear rheology / yield / temperature- and strain-rate- + dependent viscosity. The next stress test is the harder + PDE family, not more aggressive Ra=1e6 of the same simple + problem. + +## Direct inner solve (MUMPS LU) + +**Class:** the gold-standard *demonstration* of the +"accurate-inner-Newton-step → bt accepts λ=1 → robust" mechanism. +At small/2D scale (e.g. res-16 annulus), MUMPS LU on the full +Stokes Jacobian is cheap and exact. + +**What:** `pc_type=lu`, `ksp_type=preonly`, +`pc_factor_mat_solver_type=mumps`, `mat_mumps_icntl_24=1`. + +**Evidence:** a16r15d (warm, default `bt`, no recover) → 0 warm +DIVERGED (vs 24 baseline). The cleanest single-experiment proof +that the failure is inner-step accuracy, not the outer solver +type. + +**Status:** keep as a diagnostic / sanity tool. Generalise as +"solve the inner Newton correction accurately on the adapted +operator" — implemented in production via tight KSP or strong PC +(see GAMG-tuning above), *not* by always-direct. + +## Error-estimator-driven metric (design arc) + +**Class:** the absolute, resolution-aware refinement criterion — +the principled successor to the percentile metric. The +percentile is purely relative (always bunches the top X% of +*whatever* distribution; can't say "this needs more nodes than +redistribution can give"; can't recognise "the uniform mesh is +already fine"). This is the *adaptation analogue* of the missing +`snes_atol`: in both cases the fix is "judge against the problem, +not the distribution." + +**Routes:** +- *(a) Recovery-based (ZZ) — cheap first cut:* recovered ∇u minus + FE ∇u as a per-cell error indicator. Reuses the existing + projected-gradient machinery; no hierarchy needed. +- *(b) Hierarchical / τ two-grid estimator (richer):* leverage + UW3's `dm_hierarchy` for both the error estimator *and* a + **geometric multigrid preconditioner** that sidesteps + AMG-anisotropy entirely. Two birds from one structure. + +**Status:** scoped, not started. To be written up as a design +note (cf. `snes-atol-convergence-scale.md`) before implementation. + +## Geometric MG via `dm_hierarchy` + +**Class:** the alternative to AMG that is *inherently* +anisotropy-robust (the hierarchy is built geometrically, not from +the operator's connection graph). + +**Status:** **Landed.** A mesh built with `refinement >= 1` carries a +`dm_hierarchy`, and the solvers now switch to geometric Full Multigrid +on it automatically. The user-facing control is the `preconditioner` +property (`"auto"` | `"fmg"` | `"gamg"`) on the Stokes / scalar / vector +solvers; `"auto"` (the default) selects FMG when a hierarchy is present +and falls back to GAMG otherwise. + +Implementation: `SolverBaseClass._apply_preconditioner_options()` in +`petsc_generic_snes_solvers.pyx`, invoked from `_build` so `"auto"` +re-resolves against the current mesh (a true remesh collapses the +hierarchy → automatic GAMG fallback). The auto path is deliberately +conservative — it only *adds* geometric MG on top of an untouched +default and never rewrites pc options a solver/user configured directly +(e.g. the tuned GAMG in the OT/φ-Poisson smoother). User guide: +`docs/advanced/multigrid-preconditioning.md`. Tests: +`tests/test_1014_stokes_multigrid.py`. + +Benchmark-validated on a deforming adaptive mesh (annulus res32, R=8, +mode-1, np=5, MMPDE mover, 50 steps): the 3-level hierarchy survives +every step and the inner velocity-block KSP stays flat at ~5 iters under +FMG where GAMG is a volatile ~64-131 (~23×) without cliffing at this +anisotropy; wall-clock gap only ~1.8× (the cold-start Stokes solve, common +to both, dominates the time). The value is predictability/mesh-independence, +not raw speed. Figure + data in `docs/advanced/multigrid-preconditioning.md`. + +Still pairs naturally with the error-estimator design arc — the same +multi-level structure yields both the anisotropy-robust PC and the +absolute error indicator. + +## Mesh-quality / `mesh.quality()` API + +**Class:** the diagnostic on the *mesh* side of the coupled +mesh⇄solver problem. + +**What:** `mesh.quality()` returns per-mesh aggregate + tail +metrics — shape quality `q = 4√3·A/Σℓ²` (min, percentiles, +mean), max interior angle, aspect ratio (max, p99), neighbour +size-jump, joint "large-AND-stretched" count, plus the dimension- +agnostic `vol_min_over_mean`. `mesh.view()` prints a one-line +summary with a hazard flag for `q<0.2` cells. + +**Why it matters here:** bulk `minA/meanA` hid the equidist mover's +poor-cell problem; the tail metrics exposed it. AMG aggregation +degrades on poor cells (the GAMG anisotropy section above) — +mesh-quality monitoring is therefore not aesthetic, it directly +predicts solver robustness. + +## Failure-class → strategy map (the picking guide) + +``` +Symptom First-line cure Backup +-------------------------------- ---------------------- ---------------------- +"Re-solve = no fewer iterations" snes_atol cold-restart +than a fresh solve + +Warm-start fails at adapt step V,P remap cold-restart + +Warm-start fails in violent Accurate inner cold-restart +transient (non-adapt) solve (GAMG tuning / + l2 (slow but safe) + direct at small scale) + +Adaptive metric bunches a smooth (design arc) error- reduce R / use coarsen +solution / can't signal "more estimator metric cap; mesh.quality() +nodes needed" monitors regularity + +AMG diverges on adapted mesh pc_gamg_aggressive_ gamg-thr; gamg-sor + coarsening=0 geometric MG (long-term) +``` + +## Open follow-ups + +- Fresh full-settled validation of `pc_gamg_aggressive_coarsening=0` + alone on a16r15-equivalent (verify 24→0 on the full trajectory, + not just the 40-step probe). +- Combined `gamg-noagrsor` discriminator run (in flight as of the + catalogue's first draft). +- Test the strategies on a harder PDE family (nonlinear / + temperature- or strain-rate-dependent viscosity, yield) — the + current evidence is on simple Stokes only. +- Design notes: error-estimator metric; geometric-MG via + `dm_hierarchy`. +- Port the harness-side fixes (V,P remap, cold-restart) into the + UW3 core (adaptation/deform path + SNES `solve()`). + +## Related artefacts + +- `docs/developer/design/snes-atol-convergence-scale.md` — + full design note for the snes_atol fix. +- `docs/developer/design/mesh-adaptation-formulation.md` — + the equidistribution mover formulation + single-knob + `resolution_ratio` API. +- `scripts/adaptive_saturation.py` — the diagnostic harness + (the flags listed under "Diagnostics" above). +- `scripts/_cellquality.py`, `_dial_quality_compare.py`, + `_pctl_parallel_check.py`, `_equidist_probe.py` — focused + validation / sweep scripts kept for reproducibility. diff --git a/docs/developer/design/submesh-solver-architecture.md b/docs/developer/design/submesh-solver-architecture.md new file mode 100644 index 000000000..70ea7e514 --- /dev/null +++ b/docs/developer/design/submesh-solver-architecture.md @@ -0,0 +1,494 @@ +# Submesh Solver Architecture: Multi-Domain Equation Systems + +## Context + +Underworld3 needs to support solving different equations on different subsets of a mesh while maintaining a unified field representation. Use cases include: + +- **Air/rock**: Stokes on rock only, full mesh for temperature/gravity +- **Surface evolution**: deforming air mesh coupled to rock Stokes +- **Gravity**: Poisson on full domain, density source from rock only +- **Multi-physics**: different equations on different subdomains (Stokes, Darcy, etc.) + +### What we've established (2026-04-05) + +1. **`DMPlexFilter`** extracts a submesh with exact shared nodes. The submesh carries a subpoint IS mapping back to the parent via `getSubpointIS()`. + +2. **PETSc Region DS** (`DMSetRegionDS`) segfaults during assembly — no examples exist in PETSc, likely incomplete infrastructure. Dead end for now. + +3. **Solver `part` parameter** in PETSc boundary assembly (`support[key.part]`) — controls which cell's closure is used for internal boundary integrals. Useful for one-sided boundary assembly but doesn't address the core problem of restricting volume assembly to a subdomain. + +4. **Low-viscosity air layer** with discontinuous pressure works reasonably but the air's incompressibility constraint acts as an unintended physical boundary condition. Not equivalent to solving on rock alone. + +5. **Normalised `Gamma_N`** (merged) — `mesh.Gamma_N` now returns a unit normal. Penalty and Nitsche BCs are mesh-independent. + +## Three Submesh Flavours: Subdomain, Resolution Level, Surface + +A *submesh* in UW3 is any mesh pulled out of a parent mesh that retains a +lineage link (`parent`, registration in `parent._registered_submeshes`) +and supports explicit field transfer back and forth. There are three +flavours, and they share one usage pattern: + +> **get a submesh → build a solver on it → map fields back and forth** + +| | Subdomain | Resolution level | Surface | +|---|---|---|---| +| Constructor | `mesh.extract_region("Inner")` | `coarsened_companion(fine, levels=1)` | `extract_surface(mesh, "Upper")` | +| PETSc mechanism | `DMPlexFilter` (cell subset) | `dm.refine()` nested hierarchy | `DMPlexCreateSubmesh` then `DMPlexFilter(depth, 2)` (strip phantom DAG) | +| dim, cdim | `parent.dim`, `parent.cdim` | `parent.dim`, `parent.cdim` | `parent.dim − 1`, `parent.cdim` | +| Parent ↔ child map | `getSubpointIS()` (point-level) | nested `createInjection` / `createInterpolation` (DOF-level) | `getSubpointIS()` (point-level) | +| Transfer fidelity | exact (shared nodes) | exact (nested FE) | exact (shared nodes) | +| Example | `docs/examples/submesh_investigation/test_region_ds_submesh.py` | `docs/examples/submesh_investigation/example_refined_companion.py` | `docs/examples/submesh_investigation/example_surface_extraction.py` | + +The subdomain and resolution-level examples solve the **same** annulus + +radial-buoyancy Stokes problem so those two flavours are directly +comparable: one solves on a *subdomain* of the annulus, the other on a +*coarser resolution* of the whole annulus, and both map the solution +back to the parent. + +The surface flavour produces a 2-manifold embedded in 3-space +(`dim=2, cdim=3`) and is the natural carrier for *lateral surface +processes*: surface diffusion, surface advection, surface evolution. +Phase 1 (the lineage layer: extract, restrict/prolongate round-trip, +registration with the parent, navigation, `evaluate(expr, surface_pts)`) +lands as the investigation under +`docs/examples/submesh_investigation/surface_submesh_prototype.py`. +Phase 2 — making a solver actually run on the manifold (Laplace– +Beltrami) — is tracked in the *Surface submesh: solver path* section +below. + +### Design contract for the resolution-level flavour: refine-DM mode only + +A resolution level is extractable **only when a genuine nested +refinement hierarchy exists** (the mesh was built with +`refinement >= 1`). The hierarchy is the source of truth; any level can +be pulled out as a standalone solver-ready `uw.Mesh`, exactly as a +subdomain is pulled out with `extract_region`. + +**If there is no refinement relationship the operation is unavailable.** +There is deliberately: + +- **no geometric / `DMPlexComputeInterpolatorGeneral` fallback**, and +- **no KDTree coordinate-matching fallback**. + +`coarsened_companion` raises a clear error on a non-refined mesh rather +than silently degrading to an approximate transfer. + +Transfer between levels uses PETSc's *nested* interpolator/injector +(`plex.c:10328`, `DMPlexComputeInterpolatorNested` / +`DMPlexComputeInjectorFEM`), which is: + +- **exact** — the prolongation is the FE embedding; injection is a pure + scatter of coincident DOFs; +- **parallel-local** — the injector builds per-rank `COMM_SELF` scatters + (`plexfem.c:3739-3741`); `DMRefine` preserves the coarse partition, so + no MPI communication is needed for restrict/prolongate; +- **not dependent on point location** — no grid hashing, no geometry + search. + +```{note} +The nested path triggers only when the fine DM's `getCoarseDM()` *is* +the coarse DM and the regular-refinement flag is set. Independently +cloning two hierarchy levels breaks this and petsc4py exposes no setter +to restore it. The working construction is to build the transfer pair by +**refining a single-field clone of the coarse level** +(`dm_f = dm_c.refine()`): `refine()` itself establishes the linkage and +the flag. See `refined_pair_prototype.py` (`_linked_pair`). +``` + +Empirically (box and annulus, P2 velocity), prolongating a coarse Stokes +solution to the fine mesh and sampling it back recovers the coarse +solution to **O(1e-15)** — machine precision — with the geometric escape +hatch deliberately removed, proving the transfer is genuinely the nested +operator. + +```python +fine = uw.meshing.Annulus(radiusOuter=1.5, radiusInner=0.5, + cellSize=1/16, refinement=2) +coarse = coarsened_companion(fine, levels=1) # parent = fine + +v_c = uw.discretisation.MeshVariable("V", coarse, coarse.dim, degree=2) +stokes = Stokes(coarse, velocityField=v_c, ...) # solve cheaply, coarse +stokes.solve() + +v_f = uw.discretisation.MeshVariable("Vf", fine, fine.dim, degree=2) +prolongate(coarse, v_c, v_f) # exact, fills all fine DOFs +``` + +Status: prototype + both parallel examples + gating tests +(`test_refined_pair_solver.py`) + contract test +(`test_refined_pair_contract.py`) all passing. The investigation under +`docs/examples/submesh_investigation/` is the sign-off artifact; +promotion of `coarsened_companion` into the `Mesh` API proper is a +follow-up. + +### Design contract for the surface flavour: two-stage strip, no fallback + +A surface submesh is extractable **only when the parent carries a +non-empty boundary-face stratum** for the named label. Mirrors the +loud-failure stance of the other two flavours: `extract_surface` raises +on an unknown label, on a label whose value isn't in the parent's live +value set, or on an empty stratum. There is deliberately no geometric +fallback that might silently produce a degenerate mesh. + +The PETSc construction is **two-stage** — both primitives are already +wrapped in UW3's cython layer: + +``` +sub1 = petsc_dm_create_submesh_from_label(parent.dm, label, value, marked_faces=True) +sub2 = petsc_dm_filter_by_label(sub1, "depth", 2) # for parent.dim == 3 +subpoint_is = compose(sub2.getSubpointIS(), sub1.getSubpointIS()) +``` + +Stage 1 (`DMPlexCreateSubmesh`) gives a cd-1 DM with the right cells, +edges and vertices, but PETSc additionally retains an **upward-DAG +phantom stratum**: depth-3 (= parent.dim) points, one per parent +volume cell, celltype 12, included inside each surface cell's downward +closure tuple. This linkage is there so the resulting DM can still be +navigated as "a slice of the parent" (assemble surface integrals from +parent volume cells). For a standalone surface mesh we don't use that +linkage, and the phantom points are actively harmful — every consumer +that does `closure[-n:]` slicing to grab vertices (the kd-tree +builder, centroid pickers, control-point face markers — three copies +of the same idiom in `discretisation_mesh.py` alone) gets sometimes +the right answer and sometimes a phantom point inside the slice. + +Stage 2 (`DMPlexFilter(depth, 2)`) keeps only the cells of `sub1` and +their downward closure (edges, vertices). The result is a **genuine +standalone 2-manifold mesh** with a clean 3-stratum chart +(`depth = 0, 1, 2`; celltypes `[0, 1, 3]`). Cell closures are length 7 +(1 cell + 3 edges + 3 vertices) and `closure[-3:]` reliably returns +vertices. The standard `Mesh._build_kd_tree_index` and downstream +navigation code run on this DM without any cd-1 special-casing. + +The composed subpoint IS gives a direct surface → parent point map +(point IDs in the parent's chart). The properties carry through: + +- `surf.dim = parent.dim − 1`, `surf.cdim = parent.cdim` (2 and 3 for + the sphere case); +- vertex coordinates land on the parent surface to machine precision + (we measured 2.2e-16 on `SphericalShell.Upper`); +- parent boundary labels propagate onto the submesh: the *selecting* + label (e.g. `"Upper"`) survives carrying the surface itself; + sibling boundary labels (`"Lower"`) survive as empty strata which + the submesh constructor filters out. + +**One PETSc footgun, worth knowing about.** Calling +`label.getStratumIS(value)` on a `DMPlexCreateSubmesh` DM for a value +that isn't in `getValueIS()` hard-aborts PETSc (no Python-catchable +exception). The prototype enumerates `surface_dm.getNumLabels()` by +index and checks each label's live value set first. + +**Navigation on the manifold.** For an on-surface query point (the +contract assumption — query points come *from* the manifold), the +centroid kd-tree ranks faces by chord distance, which agrees with +the owning-face ranking to first order on any convex manifold (sphere, +ellipsoid). `get_closest_cells(surface_dof_coords)` returns valid +surface cell IDs and `evaluate(expr, surface_pts)` works to machine +precision. The cell-containment-by-half-space-rule helpers +(`_test_if_points_in_cells_internal`, `_mark_faces_inside_and_out`) +use a 2-D perpendicular construction that doesn't make sense for a +curved cell, so the cd-1 mesh path skips that rejection step and +trusts the centroid kd-tree result. A proper manifold in-cell test +(project the query into the cell's tangent plane, then barycentric) +is Phase 2 work — only needed if we ever support off-surface queries. + +```python +shell = uw.meshing.SphericalShell(radiusOuter=1.0, radiusInner=0.5, + cellSize=0.2) +surface = extract_surface(shell, "Upper") # parent = shell + +# Round-trip a parent scalar via shared-vertex KDTree (1e-10 match) +T_p = uw.discretisation.MeshVariable("T_p", shell, 1, degree=1) +T_s = uw.discretisation.MeshVariable("T_s", surface, 1, degree=1) +surface.restrict(T_p, T_s) # parent -> surface, bit-exact +# … work on the surface … +surface.prolongate(T_s, T_p) # surface -> parent, bit-exact at surface DOFs + +# Symbolic expressions can be evaluated at surface points directly +vals = uw.function.evaluate(T_s.sym[0], surface.X.coords) # to machine precision +``` + +Status (Phase 1): prototype + documented example + +contract test +(`docs/examples/submesh_investigation/{surface_submesh_prototype.py, +example_surface_extraction.py, test_surface_submesh_contract.py}`) all +passing. + +### Surface submesh: solver path (Phase 2) + +Phase 1 produces the *mesh*. Phase 2 makes a solver run on it. UW3's +solver stack was built when `dim == cdim` was implicit; a surface +submesh — `dim = parent.dim − 1`, `cdim = parent.cdim` — exercises +that path from a new angle. + +Concrete deliverable: lateral surface diffusion (Laplace–Beltrami, +`∫_M ∇_M T · ∇_M w dA`) on the upper surface of a `SphericalShell`, +verified against the analytic spherical-harmonic decay +`exp(-l(l+1) κ t / R²)`. Either a `SurfaceDiffusion`/`LaplaceBeltrami` +sibling of `Poisson`, or a flag on `Poisson` — decide after the JIT +audit. The Phase 1 example +(`example_surface_extraction.py`) is the natural site to add this once +the solver path is operational. + +The investigation needs to clear (at least) these knowns: + +1. **JIT pointwise functions on `dim != cdim`.** `petsc_x[i]` indexing + should run to `cdim`; gradient-of-basis indexing to `dim` + (tangent-space). Audit `utilities/_jitextension.py` for any + `range(self.dim)` that should be `range(self.cdim)` (or vice + versa). Phase 1 status: navigation and `evaluate()` work via the + standard volume-mesh code path on the stripped chart — no JIT + change was needed for that. Assembly is the open question. +2. **FE assembly with non-square Jacobian.** PETSc DMPlex computes + element Jacobians from the embedded coordinates and produces the + correct surface metric automatically *when the FE machinery is + exercised with* `dim != cdim`. Confirm this on a trivial bilinear + form before assuming it. +3. **Coordinate-system symbols.** `mesh.X` on the surface submesh is a + 3-vector; `mesh.dim` is 2. Code that iterates `range(mesh.dim)` + over `mesh.X` components silently misses the third — exactly the + kind of cdim/dim conflation the audit needs to catch. +4. **Manifold in-cell test.** `_test_if_points_in_cells_internal` / + `_mark_faces_inside_and_out` use a 2-D perpendicular construction + to place face-relative control points and run the half-space test. + The cd-1 path currently skips that step (centroid kd-tree only). + A proper version projects the query into the cell's tangent plane + then runs barycentric. Only needed if we ever support off-surface + queries; on-surface queries already work via the centroid path. +5. **Surface outward normal.** For intrinsic surface diffusion the + bilinear form is metric-only, no explicit normal needed. For + coupled problems (surface advection driven by a 3D flow), we need + the surface's outward unit normal in 3-space — distinct from + `mesh.Gamma_N` which is the normal to the *boundary* of the mesh + (a closed sphere has none). Possibly a new symbol + (`mesh.surface_normal`); deferred until needed. +6. **Boundary conditions on a closed surface.** `SphericalShell.Upper` + has no boundary curve, so no Dirichlet/Neumann is needed for the + first pass. A partial-surface submesh would add another dim/cdim + layer (BCs on a 1D curve in 3D); out of scope for the first pass. + +## Design Principles + +### 1. Separate meshes, separate variables, explicit copies + +Each mesh has its own MeshVariables. The user decides when data moves between meshes. There are no hidden globals or auto-managed shared fields. + +```python +# Each mesh owns its own variables +v_rock = MeshVariable("v", rock_mesh, ...) +v_full = MeshVariable("v", full_mesh, ...) + +# Solver works on submesh variables directly +stokes = Stokes(rock_mesh, velocityField=v_rock, ...) +stokes.solve() + +# Explicit copy to full mesh when needed (e.g., for visualisation or coupling) +rock_mesh.prolongate(v_rock, v_full) +``` + +### 2. Meshes know their lineage + +Every mesh has a `parent` attribute and a `subpoint_is` mapping. Top-level meshes have `parent=None` and `subpoint_is=None`. Submeshes reference their parent and carry the IS. + +```python +full_mesh.parent # None +full_mesh.subpoint_is # None + +rock_mesh = full_mesh.extract_region("Inner") +rock_mesh.parent # full_mesh +rock_mesh.subpoint_is # IS mapping submesh points -> parent points +``` + +### 3. Restrict/prolongate as mesh operations + +```python +mesh.restrict(var) # parent -> submesh DOFs (no-op if parent is None) +mesh.prolongate(var) # submesh DOFs -> parent (no-op if parent is None) +``` + +Solvers call these uniformly. On a top-level mesh they're no-ops. On a submesh they gather/scatter via the subpoint IS. The solver code doesn't branch. + +### 4. One mesh per expression + +An expression passed to a solver must only contain MeshVariable symbols from that solver's mesh. The JIT compiler evaluates all symbols against one DM's auxiliary vector and one coordinate system — mixing meshes is undefined. + +The user must restrict cross-mesh data before building expressions: + +```python +# T lives on full_mesh, but Stokes is on rock_mesh +rock_mesh.restrict(T_full, T_rock) + +# Expression uses only rock_mesh variables — safe +stokes.bodyforce = rho_rock.sym * alpha * T_rock.sym * gravity +``` + +If meshes are mixed in an expression, detect it (check `var.mesh` for all MeshVariable atoms) and raise an error at solver setup. + +### 5. Boundary mapping is automatic + +When `extract_region("Inner")` creates a submesh, boundaries are remapped: +- Full mesh "Lower" (r=r_inner) → submesh "Lower" +- Full mesh "Internal" (r=r_internal) → submesh outer boundary +- Full mesh "Upper" (r=r_outer) → not present on submesh + +The label names are preserved from the parent (they survive `DMPlexFilter`). The user refers to boundaries by the same names. + +## PETSc Infrastructure Available + +| API | What it does | Status | +|-----|-------------|--------| +| `DMPlexFilter(dm, label, value, ...)` | Extract cells by label → new DMPlex | **Works**, tested | +| `DMPlex.getSubpointIS()` | IS mapping submesh → parent points | Available in petsc4py | +| `DMSetRegionDS(dm, label, fields, ds, dsIn)` | Per-region discrete system | **Segfaults**, no examples | +| `DMGetCellDS(dm, point, &ds, &dsIn)` | Per-cell DS dispatch in assembly | Works but requires Region DS | +| `DMPlexCreateSubmesh(dm, label, value, ...)` | Co-dimension 1 submesh (boundaries) | **Works**, used by `extract_surface` (dim=parent.dim−1, cdim=parent.cdim) | +| `VecScatter` / `PetscSF` | Parallel data transfer | Standard PETSc | + +### PETSc Alternatives Investigated (2026-04-05) + +**DMComposite** — packs multiple DMs into one composite. Tested 2026-04-05. + +- Accepts DMPlex sub-DMs from DMPlexFilter. Scatter/gather works correctly. +- Interface nodes appear in both sub-DMs (102 shared vertices + 102 shared edges confirmed). +- Composite Vec concatenates sub-DM DOFs — interface DOFs are **duplicated**, not shared. Synchronisation after each solve is still required. +- **Verdict**: Designed for **combining** separate problems (fluid + structure), not **subdividing** one mesh. Doesn't simplify our use case — the core challenge (interface DOF ownership, restrict/prolongate) remains the same either way. The direct subpoint IS approach is simpler and more natural. + +**PCFIELDSPLIT with spatial IS** — split by region, not field. + +- `PCFieldSplitSetIS()` accepts arbitrary IS — confirmed no restriction to field-based splits. +- Supports Schur complement strategies between spatial blocks. +- **Problem**: This is a preconditioner, not an assembly strategy. Both blocks still assemble from the same DS. Doesn't let you have different equations per region. +- **Verdict**: Useful for preconditioning variable-viscosity systems, but doesn't solve the core problem. + +**DMCreateDomainDecomposition** — PETSc's native spatial decomposition. + +- `DMCreateDomainDecomposition_Plex()` returns inner/outer IS with configurable overlap. +- `DMCreateDomainDecompositionScatters_Plex()` creates VecScatter for restrict/prolongate. +- **Problem**: Designed for PCASM/PCGASM where the *same* equations are solved on each subdomain. Not for different physics per region. +- **Verdict**: Scatter infrastructure is useful but intent doesn't match multi-physics. + +### Assessment + +None of the PETSc mechanisms directly solve "different equations on different subsets of the same mesh with shared fields." They each address adjacent problems: + +| Mechanism | Different equations? | Shared fields? | Fits? | +|-----------|---------------------|----------------|-------| +| DMComposite | Yes | No (different vector layout) | Partial | +| PCFIELDSPLIT | No (same assembly) | Yes | No | +| DomainDecomp | No (same equations) | Yes | No | +| Region DS | Yes (in theory) | Yes | Segfaults | + +The **DMPlexFilter + subpoint IS + UW3-level restrict/prolongate** approach remains the best fit. PETSc provides the building blocks (mesh filtering, IS mapping, parallel SF), UW3 handles the multi-physics orchestration. + +## Open Questions + +1. **DM lifecycle**: The solver currently clones DMs freely (`clone_dm_hierarchy`). If the submesh also clones, DMs proliferate with no clear ownership. Need a cleanup strategy. + +2. **Mesh adaptation**: If the full mesh adapts (refinement, coarsening, surface deformation), the submesh must be re-extracted and the IS rebuilt. All in-flight MeshVariables need re-projection. How does this interact with the existing `refinement_callback` infrastructure? + +3. **Parallel decomposition**: `DMPlexFilter` builds a new SF for the submesh. If the partition differs from the parent, restrict/prolongate need MPI communication. How expensive is this? Does it matter for the target use cases? + +4. **Coupled solves**: If two solvers on different submeshes need to iterate (e.g., rock Stokes + air transport), the restrict/prolongate happens every outer iteration. Is the data copy overhead acceptable, or do we need shared vectors? + +5. **Pressure space**: Discontinuous pressure (dP1) is required for viscosity contrasts at internal boundaries. Should this be the default for submesh solvers, or should the user choose? + +## Implementation Plan + +### Immediate: `Mesh.extract_region()` + +The minimum viable feature. Everything else follows from existing UW3 patterns. + +```python +rock_mesh = full_mesh.extract_region("Inner") +``` + +Wraps `DMPlexFilter`, returns a new `Mesh` with: +- `parent` reference to the full mesh +- `subpoint_is` from `getSubpointIS()` (stored for future optimisation) +- Boundaries inherited from parent labels (they survive DMPlexFilter) +- Coordinate system inherited from parent + +The extracted mesh is fully independent — users create their own MeshVariables on it, set up solvers normally, and transfer data between parent and submesh via restrict/prolongate: + +```python +# Separate variables on separate meshes +v_rock = MeshVariable("v", rock_mesh, ...) +rho_rock = MeshVariable("rho", rock_mesh, ...) +rho_full = MeshVariable("rho", full_mesh, ...) + +# Transfer density from full mesh to rock submesh +rock_mesh.restrict(rho_full, rho_rock) + +# Stokes on rock submesh — standard solver, nothing special +stokes = Stokes(rock_mesh, velocityField=v_rock, ...) +stokes.add_natural_bc(penalty * Gamma_N.dot(v_rock.sym) * Gamma_N, "Internal") +stokes.solve() + +# Transfer rock velocity back to full mesh +rock_mesh.prolongate(v_rock, v_full) + +# Gravity on full mesh using transferred data +gravity = Poisson(full_mesh, ...) +gravity.solve() +``` + +The restrict/prolongate use the subpoint IS from `DMPlexFilter` — a direct index mapping with exact point correspondence. No kd-tree search, no interpolation, no error. This is the preferred transfer mechanism between parent and submesh. + +For transfer between unrelated meshes (no parent relationship), the existing `uw.function.evaluate(expr, coords)` path still works. + +### Restrict / Prolongate + +```python +rock_mesh.restrict(parent_var, sub_var) # gather parent DOFs at subpoint IS +rock_mesh.prolongate(sub_var, parent_var) # scatter submesh DOFs back to parent +``` + +- No-op when `parent is None` (top-level mesh) +- The subpoint IS maps submesh points → parent points +- Translation from point IS to DOF IS uses the PETSc section (offset lookup per point) +- Exact — same nodes, no interpolation + +### Why not auto-managed globals? + +We considered having MeshVariables live on the parent mesh with solvers auto-restricting/prolongating. This hides data flow, makes the solver more complex, and the user loses track of where data lives. The explicit approach is clearer: each mesh owns its variables, copies are visible. + +### Mesh deformation and adaptation + +Changes to the parent mesh must propagate to submeshes. Two cases: + +**Coordinate deformation** (ALE, surface evolution): Parent node positions change but topology is unchanged. The subpoint IS remains valid — restrict the parent's coordinate Vec to update submesh node positions. The submesh DM's internal geometry (Jacobians, normals, quadrature) must then be rebuilt. + +```python +# After deforming parent mesh coordinates +rock_mesh.sync_coordinates() # restrict parent coords via subpoint IS, rebuild geometry +``` + +This should be automatic: if the submesh detects that its parent's coordinates have changed (version counter on the parent mesh, which we already have via `_mesh_version`), it updates on next access. + +**Topology change** (adaptation, remeshing): The parent mesh gains/loses cells and vertices. The subpoint IS is invalidated — the submesh must be re-extracted from scratch. All submesh MeshVariables need re-projection onto the new submesh (interpolation from old to new via the usual adaptation path). + +```python +# After parent mesh adapts +rock_mesh = full_mesh.extract_region("Inner") # fresh extraction +# Old submesh variables are orphaned — user must re-create and re-project +``` + +This is the expensive case. The parent mesh already has `refinement_callback` infrastructure for post-adaptation fixups. The submesh re-extraction could hook into this: the parent notifies registered submeshes that topology has changed, and they invalidate themselves. + +The parent `Mesh` should track its submeshes (weak references, like the existing `_registered_swarms` pattern) so it can notify them of coordinate or topology changes. + +### Other items + +- **Boundary remapping**: Document which parent labels map to submesh boundaries. DMPlexFilter preserves labels; "Internal" on the parent becomes an exterior boundary on the submesh. +- **DM lifecycle**: Audit clone/destroy patterns, ensure submesh DMs are cleaned up. +- **Parallel**: `DMPlexFilter` builds a new SF. Test in MPI before relying on it. + +## Additional Findings + +### Discontinuous pressure required for viscosity contrasts + +Continuous P1 pressure cannot represent the pressure jump at a viscosity discontinuity (scales with viscosity ratio). With eta_rock/eta_air = 1000, the pressure smears across interface elements and corrupts velocity direction up to 177 degrees. Discontinuous P1 handles each side independently — velocity direction error drops to <5 degrees. + +### Normalised boundary normal (Gamma_N) + +`mesh.Gamma_N` now returns `Gamma / |Gamma|` — a unit normal regardless of element size. The raw `mesh.Gamma` magnitude scales with edge length (2D) / face area (3D). This affects penalty scaling: `penalty * Gamma.dot(v) * Gamma` has effective penalty ~ penalty * h², while `penalty * Gamma_N.dot(v) * Gamma_N` is mesh-independent. Nitsche's `gamma * mu / h` term now has correct 1/h scaling with normalised normals. diff --git a/docs/developer/gadi_singularity/README.md b/docs/developer/gadi_singularity/README.md new file mode 100644 index 000000000..6b579a451 --- /dev/null +++ b/docs/developer/gadi_singularity/README.md @@ -0,0 +1,73 @@ +# Building Underworld3 for Gadi (NCI) + +This directory contains two Containerfiles to build the Underworld3 (UW3) Singularity image for Gadi (nci.org.au). + +Both use Rocky Linux 8.10 to match Gadi's OS for ABI compatibility. + +## Build Order + +Build commands must be run from the top-level `underworld3/` directory (the build context). +Builds targeting Gadi must use `--platform linux/amd64`. + +### 1. Build PETSc layer + +```bash +podman build . \ + --platform linux/amd64 \ + --format docker \ + -t ghcr.io//petsc:3.25.0-ompi \ + -f ./docs/developer/gadi_singularity/petsc.rhel +``` + +### 2. Push PETSc image to registry + +```bash +podman push ghcr.io//petsc:3.25.0-ompi +``` + +### 3. Build Underworld3 + +```bash +podman build . \ + --platform linux/amd64 \ + --format docker \ + --build-arg PETSC_IMAGE=ghcr.io//petsc:3.25.0-ompi \ + --build-arg UW3_BRANCH=development \ + -t ghcr.io//underworld3-gadi:latest \ + -f ./docs/developer/gadi_singularity/underworld3.rhel +``` + +### 4. Push Underworld3 image + +```bash +podman push ghcr.io//underworld3-gadi:latest +``` + +## What Each File Does + +- **petsc.rhel** — Builds PETSc 3.25.0 with full AMR support (petsc4py, slepc4py, mmg, parmmg, etc.) +- **underworld3.rhel** — Builds Underworld3 on top of the PETSc image + +## Running on Gadi + +Pull the image on Gadi (redirect cache to scratch to avoid home quota issues): + +```bash +export SINGULARITY_CACHEDIR=/scratch///.singularity +module load singularity +singularity pull docker://ghcr.io//underworld3-gadi:latest +``` + +Run a script with MPI: + +```bash +module load singularity +module load openmpi/4.1.7 +mpiexec -n singularity exec underworld3-gadi_latest.sif python3 +``` + +## Notes + +- OpenFabrics (mlx5_0) warnings in the job error log are harmless +- PostHog telemetry failures on compute nodes are harmless (no outbound internet) +- The ghcr.io images must be set to **public** for Singularity to pull without authentication diff --git a/docs/developer/gadi_singularity/petsc.rhel b/docs/developer/gadi_singularity/petsc.rhel new file mode 100644 index 000000000..e612826dc --- /dev/null +++ b/docs/developer/gadi_singularity/petsc.rhel @@ -0,0 +1,197 @@ +##################################################################### +# UW3 PETSc container +# Multi stage Containerfile based on UW2 version +# This builds PETSc according to the pixi amr-dev environment +# see https://docs.docker.com/get-started/docker-concepts/building-images/multi-stage-builds/ +# +# Stages: +# 1. 'runtime' +# The runtime environment (packages, permissions, ENV vars.) +# is consistent accross all stages of this Containerfile. +# +# 2. 'builder' +# The builder layer, takes the runtime layer and add compiling / building software +# that is added to /usr/local and /opt/venv +# +# 3. 'final' == runtime + min. builder +# The final image is a composite of the runtime layer and the +# minimal sections of the builder layer's final software stack. +# +# To build use podman from the top level underworld directory. i.e. +# $ podman build . \ +# --platform linux/amd64 \ +# --format docker \ +# -t new_image_name \ +# -f ./docs/developer/gadi_singularity/petsc.rhel +##################################################################### + +# The following are passed in via --build-args +# Must go before the 1st FROM see +# https://docs.docker.com/engine/reference/builder/#understand-how-arg-and-from-interact +ARG PYTHON_VERSION="3.12" +ARG PETSC_VERSION="3.25.0" +ARG BASE_IMAGE="quay.io/rockylinux/rockylinux:8.10" + +# 1. Stage 1: 'runtime' +FROM ${BASE_IMAGE} as runtime +LABEL maintainer="https://github.com/underworldcode/" + +# need to repeat ARGS after every FROM +ARG PYTHON_VERSION + +#### Containerfile ENV vars - for all image stages +ENV LANG=C.UTF-8 +ENV PYVER=${PYTHON_VERSION} + +# gadi-specific settings +ENV OPENBLAS_NUM_THREADS=1 +ENV OMPI_MCA_io=ompio + +# add user jovyan +ENV NB_USER jovyan +ENV NB_HOME /home/$NB_USER +RUN useradd -m -s /bin/bash -N $NB_USER + +RUN yum update -y \ +&& yum install -y \ + bash-completion \ + openssh \ + openblas \ + python${PYVER}-pip \ + python${PYVER}-devel \ + openmpi \ + findutils \ +&& yum clean all \ +&& rm -rf /var/cache/yum + +# add system openmpi to $PATH and $LD_LIBRARY_PATH +ENV PATH=/usr/lib64/openmpi/bin:$PATH +ENV LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH + +ENV PYOPT=/opt/venv +# build and set open permissions on virtual environment +RUN python${PYVER} -m venv $PYOPT \ +&& chmod ugo+rwx $PYOPT + +# define python env vars. +# prepappending on PATH means all pip install will goto the PYOPT +ENV PATH=$PYOPT/bin:$PATH +ENV PYTHONPATH=$PYTHONPATH:$PYOPT/lib/python${PYVER}/site-packages + +# runtime python requirements +RUN python${PYVER} -m pip install wheel \ + "numpy<2" + +# 2. Define the builder layer +FROM runtime as builder + +ARG PETSC_VERSION +ARG PYTHON_VERSION + +RUN yum install -y \ + ca-certificates \ + wget \ + make \ + gcc \ + gcc-gfortran \ + gcc-c++ \ + cmake \ + patch \ + openblas \ + zlib-devel \ + openmpi-devel \ + findutils \ + git \ + flex \ + bison \ +&& yum clean all \ +&& rm -rf /var/cache/yum +# NOTE flex and bison are needed to build + +RUN python${PYVER} -m pip install "cython>=3.1" \ + "setuptools>=75" \ + "meson" \ + "meson-python" \ + "ninja" \ +&& python${PYVER} -m pip install --no-cache-dir --no-binary=mpi4py "mpi4py>=4,<5" +# no-binary for mpi4py to force build against openmpi, rather than mpich (default) + +# copy patches into builder +COPY petsc-custom/patches/scotch-7.0.10-c23-fix.tar.gz /tmp/scotch.tar.gz +COPY petsc-custom/patches/plexfem-internal-boundary-ownership-fix.patch /tmp/ + +# get petsc +RUN mkdir -p /tmp/src +WORKDIR /tmp/src +RUN wget https://web.cels.anl.gov/projects/petsc/download/release-snapshots/petsc-lite-${PETSC_VERSION}.tar.gz --no-check-certificate \ +&& tar -zxf petsc-lite-${PETSC_VERSION}.tar.gz +WORKDIR /tmp/src/petsc-${PETSC_VERSION} + +# apply patch then configure +# patch may already be included in newer PETSc versions - skip gracefully if it doesn't apply +RUN if patch -p1 --dry-run < /tmp/plexfem-internal-boundary-ownership-fix.patch 2>/dev/null; then \ + patch -p1 < /tmp/plexfem-internal-boundary-ownership-fix.patch; \ + echo "plexfem patch applied successfully"; \ + else \ + echo "plexfem patch not applicable (may already be in this PETSc version), skipping"; \ + fi +RUN python${PYVER} ./configure \ + --with-debugging=0 \ + --prefix=/usr/local \ + --with-shared-libraries=1 \ + --with-cxx-dialect=C++11 \ + "--COPTFLAGS=-g -O3" "--CXXOPTFLAGS=-g -O3" "--FOPTFLAGS=-g -O3" \ + --useThreads=0 \ + --with-x=0 \ + --with-pragmatic=1 \ + --with-petsc4py=1 \ + --with-slepc4py=1 \ + --download-eigen=1 \ + --download-metis=1 \ + --download-parmetis=1 \ + --download-mumps=1 \ + --download-scalapack=1 \ + --download-hypre=1 \ + --download-superlu=1 \ + --download-superlu_dist=1 \ + --download-mmg=1 \ + "--download-mmg-cmake-arguments=-DMMG_INSTALL_PRIVATE_HEADERS=ON -DUSE_SCOTCH=OFF" \ + --download-parmmg=1 \ + --download-pragmatic=1 \ + "--download-ptscotch=/tmp/scotch.tar.gz" \ + --download-slepc=1 \ + --download-hdf5=1 \ + --download-fblaslapack=1 \ + --download-zlib=1 \ + --download-ctetgen=1 \ + --download-triangle=1 \ + --with-make-np=2 +RUN make PETSC_DIR=`pwd` PETSC_ARCH=arch-linux-c-opt all +RUN make PETSC_DIR=`pwd` PETSC_ARCH=arch-linux-c-opt install \ + || (echo "=== petsc4py build log ===" && \ + cat arch-linux-c-opt/lib/petsc/conf/petsc4py.build.log 2>/dev/null && \ + echo "=== slepc4py build log ===" && \ + cat arch-linux-c-opt/lib/petsc/conf/slepc4py.build.log 2>/dev/null && \ + exit 1) +RUN rm -rf /usr/local/share/petsc + +# record builder stage packages used +RUN python${PYVER} -m pip freeze > /opt/requirements.txt \ +&& dnf history userinstalled > /opt/packages.txt + +# Stage 3: 'final' +FROM runtime as final + +COPY --from=builder /opt /opt +COPY --from=builder /usr/local /usr/local + +# MUST set PETSc environment variables +ENV PETSC_DIR=/usr/local +ENV PYTHONPATH=$PYTHONPATH:$PETSC_DIR/lib + +# switch to not-root user and workspace +USER $NB_USER +WORKDIR $NB_HOME + +# default command is to run jupyter lab +CMD ["jupyter-lab", "--no-browser", "--ip='0.0.0.0'"] diff --git a/docs/developer/gadi_singularity/underworld3.rhel b/docs/developer/gadi_singularity/underworld3.rhel new file mode 100644 index 000000000..21a712f30 --- /dev/null +++ b/docs/developer/gadi_singularity/underworld3.rhel @@ -0,0 +1,209 @@ +##################################################################### +# Multi stage Containerfile for Underworld 3 +# UW3 container based on UW2 version +# This UW3 container is based on pixi amr-dev environment +# see https://docs.docker.com/get-started/docker-concepts/building-images/multi-stage-builds/ +# +# Stages: +# 1. 'runtime' +# The runtime environment (packages, permissions, ENV vars.) +# is consistent accross all stages of this Containerfile. +# +# 2. 'builder' +# The builder layer, takes the runtime layer and add compiling / building software +# that is added to /usr/local and /opt/venv +# +# 3. 'final' == runtime + min. builder +# The final image is a composite of the runtime layer and the +# minimal sections of the builder layer's final software stack. +# +# To build use podman from the top level underworld didrectory. i.e. +# $ podman build . \ + --platform linux/amd64 \ +# --format docker \ +# --build-arg UW3_BRANCH=xxx \ +# -t new_image_name \ +# -f ./docs/developer/gadi_singularity/underworld3.rhel +##################################################################### + +# The following are passed in via --build-arg +# Must go before the 1st FROM see +# https://docs.docker.com/engine/reference/builder/#understand-how-arg-and-from-interact +ARG PYTHON_VERSION="3.12" +ARG BASE_IMAGE="quay.io/rockylinux/rockylinux:8.10" +ARG PETSC_IMAGE="ghcr.io/jcgraciosa/petsc:3.25.0-ompi" +ARG UW3_BRANCH="development" + +# 'petsc-image' will be used later on in builder stage COPY command +FROM ${PETSC_IMAGE} as petsc-image + +#################### +# Stage 1: 'runtime' +#################### +FROM ${BASE_IMAGE} as runtime +LABEL maintainer="https://github.com/underworldcode/" + +# need to repeat ARGS after every FROM +ARG PYTHON_VERSION + +#### Containerfile ENV vars - for all image stages +ENV LANG=C.UTF-8 +ENV PYVER=${PYTHON_VERSION} + +# add user jovyan +ENV NB_USER jovyan +ENV NB_HOME /home/$NB_USER +RUN useradd -m -s /bin/bash -N $NB_USER + +# runtime packages - [vim, git] are optional +RUN yum update -y \ +&& yum install -y \ + ca-certificates \ + bash-completion \ + openssh \ + openblas \ + openmpi \ + python${PYVER}-pip \ + python${PYVER}-devel \ + zlib \ + mesa-libGL \ + mesa-libGLU \ + mesa-libOSMesa \ + libX11 \ + libXrender \ + libXext \ + libXfixes \ + libXcursor \ + libXinerama \ + libXrandr \ + libXft \ + fontconfig \ +&& yum clean all \ +&& rm -rf /var/cache/yum + +# add system openmpi to $PATH and $LD_LIBRARY_PATH +ENV PATH=/usr/lib64/openmpi/bin:$PATH +ENV LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH + +ENV PYOPT=/opt/venv +# build and set open permissions on virtual environment +RUN python${PYVER} -m venv $PYOPT \ +&& chmod ugo+rwx $PYOPT + +# define python env vars. +# prepappending on PATH means all pip install will goto the PYOPT +ENV PATH=$PYOPT/bin:$PATH +ENV PYTHONPATH=$PYTHONPATH:$PYOPT/lib/python${PYVER}/site-packages +# taken from PETSC images - will be needed in the final image +ENV PETSC_DIR=/usr/local +ENV PYTHONPATH=$PYTHONPATH:$PETSC_DIR/lib + +#################### +# Stage 2: 'builder' +#################### +FROM petsc-image as builder +ARG PYTHON_VERSION +ARG UW3_BRANCH + +# root to install with yum +USER root +RUN yum install -y\ + cmake \ + make \ + git \ + gcc \ + gcc-c++ \ + gcc-gfortran \ + findutils \ + openmpi-devel \ +&& yum clean all \ +&& rm -rf /var/cache/yum +# NOTE: underworld2 does not yum install gcc + +# install python build time and runtime requirements here +RUN python${PYVER} -m pip install --no-cache-dir \ + "setuptools>=75" \ + "cython>=3.1" \ + "scipy>=1.15" \ + "numpy<2" \ + matplotlib \ + pint \ + pytest \ + jupytext \ + sympy \ + "pydantic>=2" \ + pyyaml \ + psutil \ + typing_extensions \ + xxhash \ + trimesh \ + ipython \ + jupyterlab \ + "ipywidgets<9.0.0" \ + jupyter-server-proxy \ + "trame>=2.5.2" \ + "trame-vtk>=2.5.8" \ + "trame-vuetify>=2.3.1" \ + cmocean \ + colorcet \ + imageio \ + imageio-ffmpeg \ + "rich<14" \ + meshio \ + pyvista +# gmsh wheels are only available for x86_64; skip gracefully on aarch64 (local Apple Silicon builds) +RUN pip install --no-cache-dir gmsh pygmsh || echo "gmsh/pygmsh not available on this platform, skipping" + +# H5PY with mpi - match pixi version constraint! +RUN CC=mpicc HDF5_MPI="ON" HDF5_DIR=${PETSC_DIR} \ + python${PYVER} -m pip install \ + --no-binary=h5py \ + --no-cache-dir \ + "h5py>=3.12" + +# separate so if this fails, don't need to reinstall everything again with layer caching +# vtk-osmesa wheels only available for x86_64; skip gracefully on aarch64 (local Apple Silicon builds) +RUN pip install --no-cache-dir --extra-index-url https://wheels.vtk.org vtk-osmesa \ + || echo "vtk-osmesa not available on this platform, skipping" + +# Clone and install uw3 +RUN git clone --depth 1 --branch ${UW3_BRANCH} \ + https://github.com/underworldcode/underworld3.git /tmp/underworld3 +WORKDIR /tmp/underworld3 +RUN pip install --no-build-isolation --no-cache-dir . + +# record 'builder' stage packages used +RUN python${PYVER} -m pip freeze >/opt/requirements.txt \ +&& dnf history userinstalled >/opt/installed.txt + +#################### +# Stage 3: 'final', a combination of 'runtime' and 'builder' stages +#################### + +FROM runtime as final + +COPY --from=builder --chown=$NB_USER:users /opt /opt +COPY --from=builder --chown=$NB_USER:users /usr/local /usr/local + +# must make directory before COPY into it for permissions to work () +# set default viewer to a notebook see https://jupytext.readthedocs.io/en/latest/text-notebooks.html#with-a-double-click +RUN mkdir -p $NB_HOME/workspace $NB_HOME/Underworld3/ \ +&& chown $NB_USER:users -R $NB_HOME \ +&& jupyter-server extension enable --sys-prefix jupyter_server_proxy \ +&& jupytext-config set-default-viewer + +# confirm if these are the ones to put into the container +# copy examples, tests, etc. +COPY --chown=$NB_USER:users ./docs/examples $NB_HOME/Underworld3/examples +COPY --chown=$NB_USER:users ./docs/beginner $NB_HOME/Underworld3/beginner +COPY --chown=$NB_USER:users ./docs/advanced $NB_HOME/Underworld3/advanced + +EXPOSE 8888 +WORKDIR $NB_HOME +USER $NB_USER + +# Declare a volume space +VOLUME $NB_HOME/workspace + +CMD ["jupyter-lab", "--no-browser", "--ip='0.0.0.0'"] + diff --git a/docs/developer/guides/branching-strategy.md b/docs/developer/guides/branching-strategy.md index f4f8f3be0..6d3323612 100644 --- a/docs/developer/guides/branching-strategy.md +++ b/docs/developer/guides/branching-strategy.md @@ -33,6 +33,10 @@ feature/Z ────────────────────┘ ### `main` — stable releases - Receives merges from `development` at release time (quarterly, or when ready). + The quarterly merge brings over the **whole** stable state of `development` — + it is *not* a surgical cherry-pick of one feature. Which features are *announced + as working* is decided by the release notes, not by branch surgery + (see [Maturity-gated promotion](#maturity-gated-promotion) below). - Critical bug fixes are cherry-picked from `development` between releases. - Every merge is tagged: `v3.0.0`, `v3.0.1` (patch), `v3.1.0` (quarterly). - Binder launcher tracks the latest release tag. @@ -118,6 +122,28 @@ Bug found Version numbers are managed by setuptools-scm from git tags — see `version-management.md`. +### Maturity-gated promotion + +The quarterly `development → main` merge brings over everything stable on +`development`. The hard part is not *which commits* move — it is being able to say +"feature X is released and **works**" without claiming the same for half-finished +work that rode along. + +The project solves this with **release notes, not branch surgery**. A declarative +manifest (`docs/release-notes/feature-manifest.yaml`) lists each shippable feature +and the tests that validate it. At release time a gate runs each feature's tests +and sorts it into: + +- **Supported (validated)** — `tier_a`/`tier_b` tests pass; guaranteed. +- **Preview (present, unguaranteed)** — code is on `main` but not guaranteed. + +So the MMPDE mover's code can be on `main` while the release announces only the +validated features as supported — the mover stays "preview" until its validation +is trusted. Run the gate any time with `./uw dev release-check`, and drive the +whole promotion with `./uw dev release`. + +**Full guide**: `release-process.md`. + ## CI Requirements For this strategy to work, CI must be reliable: @@ -155,6 +181,35 @@ one set of dependencies and one (expensive) PETSc compilation. half-finished work. - **Clean PRs**: Each worktree has its own branch, so commits stay focused. +### Branch policy: worktrees are always on a side branch + +**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 — including 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: it creates the +worktree on `/` and resets to `origin/development`, never +checking out `development` itself in the new worktree. + +**Don't break it manually:** + +- Never `git checkout development` (or `main`) inside a worktree +- Never `git worktree add ... development` to put a worktree directly on + `development` +- If you find a worktree on `development` (e.g. from older tooling), branch + off immediately (`git switch -c bugfix/whatever`) before committing + +The default repo checkout is the *only* place that should be on +`development`. Worktrees are *always* on side branches. + ### Lifecycle ```bash diff --git a/docs/developer/guides/hpc-cluster-setup.md b/docs/developer/guides/hpc-cluster-setup.md new file mode 100644 index 000000000..898a1447a --- /dev/null +++ b/docs/developer/guides/hpc-cluster-setup.md @@ -0,0 +1,302 @@ +# HPC Cluster Setup + +This guide covers installing and running Underworld3 on HPC clusters. Install scripts are maintained in the [uw3-hpc-baremetal-install-run](https://github.com/jcgraciosa/uw3-hpc-baremetal-install-run) repository. + +--- + +## Architecture + +All supported clusters use the same architecture: + +``` +pixi hpc env → Python 3.12, sympy, scipy, pint, pydantic, ... (conda-forge, no MPI) +cluster MPI → OpenMPI (spack or module) (cluster MPI) +source build → mpi4py, PETSc+AMR+petsc4py, h5py (linked to cluster MPI) +``` + +**Why source builds?** Anything linked against MPI must use the same MPI as the cluster scheduler. conda-forge bundles its own MPI (MPICH), which is incompatible with Slurm/PBS. Building from source ensures the correct linkage. + +**Why pixi?** Pixi manages the Python environment consistently with local development — same `pixi.toml`, same package versions. The `hpc` environment is pure Python (no MPI packages from conda-forge). + +**PETSc build:** `petsc-custom/build-petsc.sh` auto-detects the cluster from hostname, or can be overridden with `UW_CLUSTER=kaiju|gadi`. Cluster-specific differences (HDF5 source, BLAS, cmake, compiler flags) are handled internally. + +--- + +## Kaiju + +### Hardware + +| Resource | Specification | +|----------|--------------| +| Head node | 1× Intel Xeon Silver 4210R, 40 CPUs @ 2.4 GHz | +| Compute nodes | 8× Intel Xeon Gold 6230R, 104 CPUs @ 2.1 GHz each | +| Shared storage | `/opt/cluster` via NFS | +| Scheduler | Slurm with Munge authentication | +| MPI | Spack `openmpi@4.1.6` | + +### Prerequisites + +Spack must have OpenMPI available: + +```bash +spack find openmpi +# openmpi@4.1.6 +``` + +Pixi must be installed in your user space: + +```bash +pixi --version # check +curl -fsSL https://pixi.sh/install.sh | bash # install if missing +``` + +### Installation + +Copy `kaiju_install_user.sh` (per-user) or `kaiju_install_shared.sh` (admin) from [uw3-hpc-baremetal-install-run](https://github.com/jcgraciosa/uw3-hpc-baremetal-install-run) to a convenient location, edit the variables at the top, then: + +```bash +source kaiju_install_user.sh install +``` + +| Step | Function | Time | +|------|----------|------| +| Install pixi | `setup_pixi` | ~1 min | +| Clone Underworld3 | `clone_uw3` | ~1 min | +| Install pixi hpc env | `install_pixi_env` | ~3 min | +| Build mpi4py | `install_mpi4py` | ~2 min | +| Build PETSc + AMR tools | `install_petsc` | ~1 hour | +| Build h5py | `install_h5py` | ~2 min | +| Install Underworld3 | `install_uw3` | ~2 min | +| Verify | `verify_install` | ~1 min | + +Individual steps can be run after sourcing: + +```bash +source kaiju_install_user.sh +install_petsc # run just one step +``` + +#### What PETSc builds on Kaiju + +- **AMR tools**: mmg, parmmg, pragmatic, eigen, bison +- **Solvers**: mumps, scalapack, slepc +- **Partitioners**: metis, parmetis, ptscotch +- **MPI**: Spack's OpenMPI (`--with-mpi-dir`) +- **HDF5**: downloaded (not in Spack) +- **BLAS/LAPACK**: fblaslapack (no guaranteed system BLAS on Rocky Linux 8) +- **cmake**: downloaded (not in Spack) +- **petsc4py**: built during configure (`--with-petsc4py=1`) + +### Activating the Environment + +Source the install script at the start of every session or job: + +```bash +source kaiju_install_user.sh +``` + +This loads `spack openmpi@4.1.6`, activates the pixi `hpc` environment via `pixi shell-hook`, and sets `PETSC_DIR`, `PETSC_ARCH`, and `PYTHONPATH`. + +> `pixi shell-hook` is used instead of `pixi shell` because it activates the environment in the current shell without spawning a new one — required for Slurm batch jobs. + +### Running with Slurm + +Use `kaiju_slurm_job.sh` from [uw3-hpc-baremetal-install-run](https://github.com/jcgraciosa/uw3-hpc-baremetal-install-run). Edit the variables at the top, then: + +```bash +sbatch kaiju_slurm_job.sh +``` + +`--mpi=pmix` is **required** on Kaiju (Spack has `pmix@5.0.3`): + +```bash +srun --mpi=pmix python3 my_model.py +``` + +Monitor progress: + +```bash +squeue -u $USER +tail -f uw3_.out +``` + +### Shared Installation (Admin) + +Deploys to `/opt/cluster/software/underworld3/` so all users access it via Environment Modules: + +```bash +source kaiju_install_shared.sh install +module load underworld3/development-12Mar26 +``` + +The shared script adds `fix_permissions()` and `install_modulefile()` on top of the per-user steps. The TCL modulefile hardcodes the Spack OpenMPI and pixi env paths — if Spack is rebuilt (hash changes), update `mpi_root` in `modulefiles/underworld3/development.tcl`. + +### Troubleshooting (Kaiju) + +#### `import underworld3` fails on compute nodes + +Source the install script inside the job script (not the login shell) so all paths propagate to compute nodes. The `kaiju_slurm_job.sh` template does this correctly. + +#### PETSc needs rebuilding after Spack module update + +PETSc links against Spack's OpenMPI at build time. If `openmpi@4.1.6` is reinstalled: + +```bash +source kaiju_install_user.sh +rm -rf ~/uw3-installation/underworld3/petsc-custom/petsc +install_petsc +install_h5py +``` + +#### h5py replaces source-built mpi4py + +`pip install h5py` without `--no-deps` silently replaces the source-built mpi4py with a wheel linked to a different MPI. The install script uses `--no-deps` to prevent this. If mpi4py was accidentally replaced: + +```bash +pip install --no-binary :all: --no-cache-dir --force-reinstall "mpi4py>=4,<5" +``` + +#### PARMMG configure failure + +pixi's conda linker requires transitive shared library dependencies to be explicitly linked. `libmmg.so` built with SCOTCH support causes PARMMG's link test to fail. This is fixed in `build-petsc.sh` by building MMG without SCOTCH (`-DUSE_SCOTCH=OFF`). + +--- + +## Gadi + +### Hardware + +| Resource | Specification | +|----------|--------------| +| System | NCI Gadi (CentOS, Lustre filesystem) | +| Compute | Multiple node types (normal, hugemem, gpuvolta) | +| Shared storage | `/g/data` (project quota), `/scratch` (temporary) | +| Scheduler | PBS Pro | +| MPI | Module `openmpi/4.1.7` | + +### Prerequisites + +The following Gadi modules must be available: + +```bash +module load openmpi/4.1.7 hdf5/1.12.2p gmsh/4.13.1 cmake/3.31.6 +``` + +Pixi must be installed: + +```bash +pixi --version # check +curl -fsSL https://pixi.sh/install.sh | bash # install if missing +``` + +> **Inode quota:** Gadi's `/g/data` has strict inode limits. PETSc (which creates many files during build) may need to be built on `/scratch` and symlinked from `/g/data`. The install script handles this if you set `PETSC_DIR` to a `/scratch` path. + +### Installation + +Copy `gadi_install_user.sh` (per-user) or `gadi_install_shared.sh` (admin) from [uw3-hpc-baremetal-install-run](https://github.com/jcgraciosa/uw3-hpc-baremetal-install-run) to a convenient location, edit the variables at the top, then: + +```bash +source gadi_install_shared.sh install +``` + +| Step | Function | Time | +|------|----------|------| +| Install pixi | `setup_pixi` | ~1 min | +| Clone Underworld3 | `clone_uw3` | ~1 min | +| Install pixi hpc env | `install_pixi_env` | ~3 min | +| Build mpi4py | `install_mpi4py` | ~2 min | +| Build PETSc + AMR tools | `install_petsc` | ~1 hour | +| Build h5py | `install_h5py` | ~2 min | +| Install Underworld3 | `install_uw3` | ~2 min | +| Verify | `verify_install` | ~1 min | + +#### What PETSc builds on Gadi + +- **AMR tools**: mmg, parmmg, pragmatic, eigen +- **Solvers**: mumps, scalapack, slepc, superlu, superlu_dist, hypre +- **Partitioners**: metis, parmetis, ptscotch +- **MPI**: Gadi's OpenMPI module (`--with-cc/cxx/fc`) +- **HDF5**: Gadi's `hdf5/1.12.2p` module (`--with-hdf5-dir`) +- **BLAS/LAPACK**: fblaslapack (auto-detection fails due to compiler env manipulation) +- **petsc4py**: built during configure (`--with-petsc4py=1`) + +### Activating the Environment + +Source the install script at the start of every session or job: + +```bash +source gadi_install_shared.sh +``` + +This loads Gadi modules, activates the pixi `hpc` environment via `pixi shell-hook`, and sets `PETSC_DIR`, `PETSC_ARCH`, and `PYTHONPATH`. Gadi's HDF5 lib dir is prepended to `LD_LIBRARY_PATH` to ensure the parallel HDF5 1.12.2p is loaded at runtime (not conda's serial HDF5 1.14). + +### Running with PBS + +Use `gadi_pbs_job.sh` from [uw3-hpc-baremetal-install-run](https://github.com/jcgraciosa/uw3-hpc-baremetal-install-run). Edit the variables at the top, then: + +```bash +qsub gadi_pbs_job.sh +``` + +Monitor progress: + +```bash +qstat -u $USER +tail -f .o* +``` + +### Shared Installation (Admin) + +Deploys to `/g/data/m18/software/uw3-pixi/` so all m18 project members can use it: + +```bash +source gadi_install_shared.sh install +``` + +The install script is then copied to the install directory so users can source it directly: + +```bash +source /g/data/m18/software/uw3-pixi/gadi_install_shared.sh +``` + +### Troubleshooting (Gadi) + +#### h5py undefined symbol: H5E_BADATOM_g + +The pixi `hpc` env ships a serial HDF5 1.14 (transitive conda-forge dependency). If h5py links against it instead of Gadi's parallel HDF5 1.12.2p, this symbol (removed in 1.14) is missing at runtime. The install script fixes this by temporarily hiding conda's HDF5 during the h5py build so meson can only find Gadi's. If you see this error, re-run: + +```bash +source gadi_install_shared.sh +install_h5py +``` + +#### Compiler interference during PETSc build + +The pixi `hpc` env ships a full conda toolchain (`x86_64-conda-linux-gnu-*`) that interferes with Gadi's OpenMPI wrappers. `build-petsc.sh` handles this via `setup_gadi_build_env()`, which unsets conda compiler variables and forces the MPI wrappers to use system compilers (`/usr/bin/gcc`). + +#### Fortran MPI library not found + +Gadi ships compiler-tagged Fortran MPI libraries (`libmpi_usempif08_GNU.so`) rather than the standard untagged names. `build-petsc.sh` creates symlinks in `petsc-custom/mpi-gadi-gnu-libs/` to bridge this. + +#### `import underworld3` fails in PBS job + +Ensure the install script is sourced inside the job script (not just in the login shell). The `gadi_pbs_job.sh` template does this correctly. + +--- + +## Rebuilding Underworld3 after source changes + +```bash +source kaiju_install_user.sh # or gadi_install_shared.sh +cd +git pull +pip install -e . +``` + +--- + +## Related + +- [Development Setup](development-setup.md) — local development with pixi +- [Branching Strategy](branching-strategy.md) — git workflow +- [Parallel Computing](../../advanced/parallel-computing.md) — writing parallel-safe UW3 code diff --git a/docs/developer/guides/memory-diagnostics.md b/docs/developer/guides/memory-diagnostics.md new file mode 100644 index 000000000..8dea38e70 --- /dev/null +++ b/docs/developer/guides/memory-diagnostics.md @@ -0,0 +1,153 @@ +# Memory diagnostics + +Long parallel runs occasionally OOM on HPC even when each step looks small. +The `uw.utilities.memprobe` module gives you a way to "light up" memory +tracking on demand, sample at regular intervals, and pin which subsystem is +growing. + +## Quick start + +```bash +UW_MEMPROBE=1 mpirun -n 16 python my_long_run.py +``` + +With this flag set, the `Stokes.solve()` and `NavierStokes.solve()` paths +emit a one-line growth report each time they're called: + +``` +[memprobe] Stokes.solve: + RSS +0.42 MiB + kdtree: live +1, total_constructed +1 +``` + +A clean run shows mostly zero deltas. A leak shows the same component +growing on every step. + +## What's tracked + +| Signal | Source | Cost | +|---|---|---| +| Process RSS (MiB, current) | `psutil.Process().memory_info().rss`, fallback `/proc/self/statm` (Linux), last resort `resource.ru_maxrss` | free | +| KDTree live count | `uw.kdtree.live_count()` | free | +| KDTree total constructed | `uw.kdtree.total_constructed()` | free | +| Per-class Python instance counts | `gc.get_objects()` walk | slow — gated behind `full=True` | + +The RSS source is **current** RSS where possible — it should drop when memory +is freed, not just rise. The last-resort `resource.ru_maxrss` fallback returns +the **peak** (high-water-mark) RSS instead and never decreases, so on systems +where neither psutil nor `/proc/self/statm` is available you'll see growth but +not recovery; install `psutil` to fix that. + +`KDTree` instances are tracked via Cython class counters in `__cinit__` and +`__dealloc__`. CPython refcounting calls `__dealloc__` promptly when the +refcount hits zero, so the count is accurate for typical use; it can lag if a +KDTree ends up in a reference cycle that only the cyclic garbage collector +can break. Call `gc.collect()` before reading if that matters. + +PETSc-side object and allocation tracking is **not** parsed from Python — +PETSc's own `-log_view` and `-malloc_dump` runtime flags give the same +information more reliably. To enable them from Python: + +```python +from underworld3.utilities import memprobe +memprobe.dump_petsc_leaks_at_finalize() # equivalent to -malloc_dump -objects_dump +``` + +## API + +### Snapshots and diffs + +```python +from underworld3.utilities import memprobe + +before = memprobe.snapshot() +do_work() +after = memprobe.snapshot() +print(memprobe.format_diff("after-work", memprobe.diff(before, after))) +``` + +Add `full=True` to also walk Python-class counts: + +```python +snap = memprobe.snapshot(full=True) +# snap["py_classes"] = {"underworld3.swarm.Swarm": 3, ...} +``` + +### Probe context manager + +```python +with memprobe.probe("step 42"): + advance_one_step() +# On exit, the diff is emitted via `print` (configurable). +``` + +The `emit` keyword takes any callable: `with probe(..., emit=logger.info):` +to route into the logging system, or a rank-aware writer for parallel runs: + +```python +import underworld3 as uw +emit = lambda s: uw.pprint(0, s) # rank-0 only +with memprobe.probe("step 42", emit=emit): + ... +``` + +### Decorator + +```python +@memprobe.instrument("my-hot-loop") +def step(): + ... +``` + +When `memprobe.ENABLED` is `False` (the default) the decorator's wrapper is +a single attribute lookup + branch — sub-microsecond — so it's safe to +leave on hot paths permanently. + +`Stokes.solve()` and `NavierStokes.solve()` are pre-decorated. Add more if +you want them. + +### Runtime toggles + +```python +memprobe.enable() +# ...probed region... +memprobe.disable() +``` + +`UW_MEMPROBE=1` flips it on at import time. + +## Debugging recipes + +### "RSS grows X MiB per step — which component is it?" + +1. Set `UW_MEMPROBE=1` and run for ~20 steps to confirm the per-solve + growth pattern. +2. Add `with memprobe.probe("step N", full=True):` around your step loop. + The `full=True` walks `gc.get_objects()` and lists Python class growth + sorted by absolute change — usually the dominant suspect is on top. +3. If RSS grows but no Python class does, the leak is in PETSc memory or + C extensions. Re-run with `-log_view -malloc_dump` and inspect the PETSc + reports written at finalize. + +### "Are kd-trees being released properly?" + +Check `uw.kdtree.live_count()` directly, or look for the `kdtree: live +N` +line in the diff. KDTrees should drop to zero when their owning object +(typically a `Mesh` or `Swarm`) is destroyed. + +### Parallel runs + +`memprobe` runs on each rank independently. For meaningful aggregate +output, pipe `emit` through a rank filter: + +```python +import underworld3 as uw +def root_only(s): + if uw.mpi.rank == 0: + print(s) + +with memprobe.probe("step", emit=root_only): + ... +``` + +Or compare per-rank snapshots manually for a load-imbalance view. diff --git a/docs/developer/guides/notebook-style-guide.md b/docs/developer/guides/notebook-style-guide.md index 4fae3e3c3..9deddc05c 100644 --- a/docs/developer/guides/notebook-style-guide.md +++ b/docs/developer/guides/notebook-style-guide.md @@ -71,17 +71,21 @@ print("Success!") # Unnecessary import numpy as np ``` -3. **Concept Sections** +3. **Parameters Cell** (see [Parameters and Configuration](#parameters-and-configuration) below) + - Named constants for defaults, then `uw.Params` block + - Markdown cell above explaining CLI override syntax + +4. **Concept Sections** - Markdown header (##) for each major concept - Brief explanation in markdown - Code cells demonstrating the concept - Minimal output cells (let Jupyter display) -4. **Summary** (markdown) +5. **Summary** (markdown) - Key takeaways in bullet points - When to use what -5. **Try It Yourself** (markdown) +6. **Try It Yourself** (markdown) - Optional exercises in code fences - Encourage exploration @@ -110,6 +114,79 @@ mesh.units mesh.view() ``` +## Parameters and Configuration + +Every notebook or example script that accepts tuneable settings should use +`uw.Params`. The standard pattern has two parts: + +1. **Named constants** — plain Python variables holding the default values. + These are the first thing a notebook user sees and edits. +2. **`uw.Params` block** — wraps the constants with units, bounds, + descriptions, and CLI override support. + +### Standard Pattern + +A markdown cell introduces the parameters and shows CLI usage: + +~~~markdown +### Configurable parameters + +Default values are defined as named constants below. From the command +line, override them with PETSc-style flags: + +```bash +python script.py -uw_viscosity "5e20 Pa*s" -uw_cell_size 25km +``` +~~~ + +Followed by the code cell: + +```python +# --- Default values (edit these in a notebook) --- +VISCOSITY = 1e21 # Pa·s – reference viscosity +CELL_SIZE = 50.0 # km – target cell size +DEPTH = 660.0 # km – model depth +MAX_STEPS = 100 # solver iterations + +params = uw.Params( + uw_viscosity = uw.Param(VISCOSITY, units="Pa*s", description="reference viscosity"), + uw_cell_size = uw.Param(CELL_SIZE, units="km", description="target cell size"), + uw_depth = uw.Param(DEPTH, units="km", description="model depth"), + uw_max_steps = MAX_STEPS, +) +``` + +### Why Named Constants + +- **Visibility**: The reader sees the default values at a glance without + having to parse the `uw.Param(...)` wrapper. +- **Editability**: In a notebook, changing a default is a single number + edit at the top of the cell — no need to find it inside a function call. +- **Separation of concerns**: The constants say *what* the defaults are; + the `uw.Params` block says *how* they are validated and overridden. + +### Naming Conventions + +- Named constants: `UPPER_CASE` with a brief inline comment showing + units and purpose. +- Parameter names: `uw_` prefix to avoid PETSc option collisions. +- Add a `description=` string for any parameter that will appear in + `params.cli_help()`. + +### What Not To Do + +```python +# Avoid: inline literals with no named constant +params = uw.Params( + uw_viscosity = uw.Param(1e21, units="Pa*s"), # hard to scan +) + +# Avoid: parameters scattered through the notebook +viscosity = 1e21 # defined in cell 3 +# ... 20 cells later ... +params.uw_viscosity = viscosity # reader has lost context +``` + ## What to Avoid - ❌ Excessive congratulation ("Great job!", "Excellent!") diff --git a/docs/developer/guides/release-process.md b/docs/developer/guides/release-process.md new file mode 100644 index 000000000..8eb7d5249 --- /dev/null +++ b/docs/developer/guides/release-process.md @@ -0,0 +1,193 @@ +# Release Process: Maturity-Gated Quarterly Promotion + +**Status**: Active +**Date**: 2026-06 + +This guide describes how finished work is promoted from `development` to `main` +*with confidence*. It complements `branching-strategy.md` (which covers how work +lands on `development` in the first place) and `version-management.md` (tags and +versioning). + +## Overview — the "we can be sure it works" guarantee + +`development` is a busy integration trunk — at any time it is hundreds of commits +ahead of `main` and carries many features at different stages of maturity. The +goal is **not** to surgically isolate one feature's commits onto `main`. That +fights the grain: a feature like the MMPDE mover depends on the Winslow smoother, +boundary-slip surfaces, FMG infrastructure, and field-transfer — its dependency +closure is most of `development` anyway. + +Instead: + +> `main` accumulates whatever stable work comes over from `development` each +> quarter. The **release notes** — not branch surgery — distinguish features that +> are *validated and guaranteed* from features that are merely *present but not +> guaranteed to work*. + +So the mover's code can be on `main` while the release simply does **not** claim +it works. FMG can be announced as supported while the mover rides along as +preview. This matches how the project actually develops. + +## Maturity definitions + +Every shippable feature is announced at one of three maturities: + +| Maturity | Meaning | Release-notes section | +|----------|---------|-----------------------| +| **supported** | `tier_a`/`tier_b` tests exist and pass on the release candidate. Guaranteed to work. | *Supported (validated)* | +| **preview** | Code is on `main`, but the release does **not** guarantee it works. | *Preview (present, unguaranteed)* | +| **experimental** | Present in the tree, not announced as working. | *Preview* (tagged `experimental`) | + +The announced maturity is `min(claim, tests_maturity)`: + +- An owner may be deliberately **cautious** — claim `preview` even though the + tests pass (this is the mover today). +- The gate may **downgrade** — claim `supported` but a test fails, or there is no + validation suite, so it drops to `preview`/`experimental`. + +The gate **never blocks the `development → main` merge**. The code ships +regardless; only the announcement changes. + +## The feature manifest + +Features are declared in **`docs/release-notes/feature-manifest.yaml`**. Each +entry scopes *which tests belong to a feature*; the existing tier markers scope +*which of those tests are trustworthy*. There is **no per-feature pytest marker** +to maintain. + +```yaml +features: + - key: units-system + title: "Units and Scaling System" + owner: "@lmoresi" + claim: supported # what the owner wants to announce + summary: > + Pint-backed dimensional quantities and unit-aware arithmetic. + validation: + paths: # test files/globs that scope the feature + - tests/test_0700_units_system.py + - tests/test_0710_units_utilities.py + select: "..." # optional pytest -k to narrow + markers: "tier_a or tier_b" # default; ties "supported" to validated tiers + levels: "1,2,3" # optional level filter (cost control) + docs: + - docs/developer/design/UNITS_SIMPLIFIED_DESIGN_2025-11.md +``` + +### Worked example: FMG vs the mover + +- **FMG** claims `supported` but has **no dedicated `tier_a` test** yet. The gate + finds an empty selection and downgrades it to `experimental`. That is the + signal you want: *FMG cannot be announced as supported until it has a + validation suite.* Add a `tier_a` test and the row goes green. +- **The MMPDE mover** has passing `tier_a` tests, but its owner claims `preview` + because it is not yet trusted across production problems. It announces as + `preview` — code on `main`, not guaranteed. This is the canonical case. + +## The validation gate + +`scripts/release_gate.py` reads the manifest and, per feature, builds a pytest +invocation equivalent to: + +```bash +pytest --config-file=tests/pytest.ini \ + -m "(tier_a or tier_b) and (level_1 or level_2 or level_3)" \ + -k "