From aeb0293247a4e66ab8f6ca6b67deb8a71efa3c09 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Thu, 8 Oct 2026 17:20:54 +1100 Subject: [PATCH 01/57] Vector solves on a manifold: size the FE and the vector calculus by cdim SNES_Vector built its finite element with mesh.dim components while the unknown MeshVariable, the residual and the Jacobian blocks were already sized by the embedding dimension, so on a SphericalManifold (dim 2, cdim 3) the field and the FE disagreed and the solve failed on a Vec-size mismatch. The FE, the PC block size and the default unknown now use cdim. The same component count applies in maths/vector_calculus (a tangential field on a manifold is still a cdim-component row) and in the JIT's truncation of sympy.vector objects. On a volume mesh cdim == dim and nothing changes. Measured: a thin-sheet manufactured solution (toroidal l=2) on a unit SphericalManifold at h=0.2, P2, relative nodal L2 error 4e-3. Underworld development team with AI support from Claude Code --- .../cython/petsc_generic_snes_solvers.pyx | 16 +++++++++++----- src/underworld3/maths/vector_calculus.py | 4 +++- src/underworld3/utilities/_jitextension.py | 4 ++-- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/underworld3/cython/petsc_generic_snes_solvers.pyx b/src/underworld3/cython/petsc_generic_snes_solvers.pyx index 0e73b97b9..e1a9ae447 100644 --- a/src/underworld3/cython/petsc_generic_snes_solvers.pyx +++ b/src/underworld3/cython/petsc_generic_snes_solvers.pyx @@ -4717,9 +4717,11 @@ class SNES_Vector(SolverBaseClass): self.petsc_options["snes_type"] = "newtonls" self.petsc_options["ksp_rtol"] = 1.0e-3 self._push_managed_option("ksp_type", "gmres") - # A vector unknown: mesh.dim components per node, which GAMG has to be - # told. See multigrid_options._gamg_settings. - self._pc_block_size = self.mesh.dim + # A vector unknown: one component per EMBEDDING dimension (cdim) at each + # node, which GAMG has to be told. On a volume mesh cdim == dim; on a + # manifold (dim < cdim) the vector lives in the embedding space. See + # multigrid_options._gamg_settings. + self._pc_block_size = self.mesh.cdim for key, value in multigrid_options.gamg_bundle( block_size=self._pc_block_size).settings.items(): self._push_managed_option(key, value) @@ -4741,7 +4743,7 @@ class SNES_Vector(SolverBaseClass): # here we only auto-create the default unknown when none was supplied) if u_Field is None: self.Unknowns.u = uw.discretisation.MeshVariable( mesh=mesh, - num_components=mesh.dim, varname="Uv{}".format(SNES_Vector._obj_count), + num_components=mesh.cdim, varname="Uv{}".format(SNES_Vector._obj_count), vtype=uw.VarType.VECTOR, degree=degree ) @@ -5033,7 +5035,11 @@ class SNES_Vector(SolverBaseClass): options.setValue("private_{}_u_petscdualspace_lagrange_continuity".format(self.petsc_options_prefix), self.u.continuous) options.setValue("private_{}_u_petscdualspace_lagrange_node_endpoints".format(self.petsc_options_prefix), False) - self.petsc_fe_u = PETSc.FE().createDefault(mesh.dim, mesh.dim, mesh.isSimplex, mesh.qdegree, "private_{}_u_".format(self.petsc_options_prefix), PETSc.COMM_SELF) + # The FE carries cdim components: the unknown MeshVariable is sized by the + # embedding dimension, and the residual / Jacobian blocks below are too. + # (A dim-component FE against a cdim-component variable was the manifold + # vector-FE gap: a Vec-size mismatch on SphericalManifold.) + self.petsc_fe_u = PETSc.FE().createDefault(mesh.dim, mesh.cdim, mesh.isSimplex, mesh.qdegree, "private_{}_u_".format(self.petsc_options_prefix), PETSc.COMM_SELF) self.petsc_fe_u_id = self.dm.getNumFields() self.dm.setField( self.petsc_fe_u_id, self.petsc_fe_u ) self.petsc_fe_u.setName("_vector_unknown_") diff --git a/src/underworld3/maths/vector_calculus.py b/src/underworld3/maths/vector_calculus.py index 2c65dc3c5..0ad10000d 100644 --- a/src/underworld3/maths/vector_calculus.py +++ b/src/underworld3/maths/vector_calculus.py @@ -62,7 +62,9 @@ class mesh_vector_calculus: def __init__(self, mesh): self.mesh = mesh - self.dim = self.mesh.dim + # Vectors carry one component per EMBEDDING dimension: on a manifold + # (dim < cdim) a tangential field is still a cdim-component row. + self.dim = self.mesh.cdim def cross(self, vector1, vector2): r""" diff --git a/src/underworld3/utilities/_jitextension.py b/src/underworld3/utilities/_jitextension.py index 1fd8d7788..9ba28bfd9 100644 --- a/src/underworld3/utilities/_jitextension.py +++ b/src/underworld3/utilities/_jitextension.py @@ -1426,9 +1426,9 @@ def _handle_UnevaluatedExpr(expr): ) if isinstance(fn, sympy.vector.Vector): - fn = fn.to_matrix(mesh.N)[0 : mesh.dim, 0] + fn = fn.to_matrix(mesh.N)[0 : mesh.cdim, 0] elif isinstance(fn, sympy.vector.Dyadic): - fn = fn.to_matrix(mesh.N)[0 : mesh.dim, 0 : mesh.dim] + fn = fn.to_matrix(mesh.N)[0 : mesh.cdim, 0 : mesh.cdim] else: fn = sympy.Matrix([fn]) From 27e85eb485b5fe139ed7ca3af9f3a012e991fd76 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Thu, 8 Oct 2026 17:20:54 +1100 Subject: [PATCH 02/57] ThinSheet: a vertically averaged plane-stress sheet solver (flat, viscous) uw.systems.ThinSheet solves -div_s(L sigma) + c u = f for the sheet velocity with the plane-stress closure sigma = tau + tr(tau) P, which eliminates the pressure: a vector problem on SNES_Vector, not a saddle point. Any deviatoric constitutive law applies; the solver's inner _Unknowns hands the model the in-plane strain rate P sym(grad u) P through Unknowns.E (P = I on a flat mesh), and ViscousFlowModel.grad_u now reads Unknowns.E as the VEP model already did (identical on a volume mesh). Tests (closed forms): uniaxial stretch with free edges gives the Trouton stress 3 eta edot and lateral contraction edot/2; a rigid motion carries no stress; a uniform load against basal drag gives u = f/c; a manufactured solution converges at third order with P2. Design: docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md. Underworld development team with AI support from Claude Code --- .../design/THIN_SHEET_VISCOELASTIC_DESIGN.md | 95 ++++++ src/underworld3/constitutive_models.py | 10 +- src/underworld3/systems/__init__.py | 1 + src/underworld3/systems/thin_sheet.py | 273 ++++++++++++++++++ tests/test_1075_thin_sheet.py | 133 +++++++++ 5 files changed, 505 insertions(+), 7 deletions(-) create mode 100644 docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md create mode 100644 src/underworld3/systems/thin_sheet.py create mode 100644 tests/test_1075_thin_sheet.py diff --git a/docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md b/docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md new file mode 100644 index 000000000..78b3364ff --- /dev/null +++ b/docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md @@ -0,0 +1,95 @@ +# Thin viscoelastic sheet with faults — flat and on the sphere + +**Status**: In progress (started 2026-10-08). Branch `feature/thin-sheet`. + +## Goal + +A vertically averaged (thin-sheet) model of the lithosphere carrying faults, on a +flat 2-D mesh and on the surface of a sphere, with a Maxwell viscoelastic +rheology and a viscous substrate. Headline result: the loading, slip and +postseismic relaxation of a strike-slip fault in a viscoelastic sheet, flat first +and then on the sphere. + +## The sheet + +With thickness $L$, tangential velocity $\mathbf u$ and the plane-stress closure +$\sigma_{nn} = 0$ on the dynamic stress, the pressure of an incompressible +material is eliminated: $p = \tau_{nn} = -\operatorname{tr}_s\boldsymbol\tau$, so + +$$ +\boldsymbol\sigma = \boldsymbol\tau + \operatorname{tr}(\boldsymbol\tau)\,\mathbf P, +\qquad \mathbf P = \mathbf I - \hat{\mathbf n}\hat{\mathbf n}^T, +\qquad +-\nabla_s\cdot(L\boldsymbol\sigma) + c\,\mathbf u = \mathbf f . +$$ + +$\boldsymbol\tau$ is any deviatoric constitutive law of the in-plane strain rate +$\dot{\boldsymbol\varepsilon}_s = \mathbf P\,\mathrm{sym}(\nabla_s\mathbf u)\,\mathbf P$ +— Newtonian, Maxwell (the stress history is the in-plane deviatoric stress; +$\tau_{nn}$ follows from the trace), or yielding. $c$ is a basal drag +($\eta_a/h_a$ for a viscous substrate); $\mathbf f$ a force per unit area. + +Consequences used as hard baselines: + +- uniaxial stretch with free lateral edges: $\sigma_{xx} = 3\eta\dot\varepsilon$ (Trouton); +- rigid motion: $\boldsymbol\sigma = 0$ exactly; +- Maxwell uniaxial loading: $\sigma = 3\eta\dot\varepsilon\,(1 - e^{-t\mu/\eta})$; +- a fault in a thin sheet: Barr & Houseman (1996) Appendix (A10)–(A13) — the + plane-strain (A1)–(A9) solution is already in `underworld3.analytic`; the + thin-sheet one is to be added beside it; +- stress diffusion with basal drag after imposed slip (Elsasser; Lehner, Li & + Rice 1981). + +Not in the first round (recorded so they are not forgotten): thickness +evolution $\dot L = -L\,\nabla_s\cdot\mathbf u$ with the gravitational potential +energy gradient (England & McKenzie's Argand number); yield in the sheet, whose +invariant must include $\tau_{nn} = -\operatorname{tr}\boldsymbol\tau$ (the +plane-strain code omits it). + +## Implementation + +`uw.systems.ThinSheet` (`systems/thin_sheet.py`) is an `SNES_Vector`: there is +no pressure unknown, so no saddle point. Its inner `_Unknowns` hands the +constitutive model the projected strain rate through `Unknowns.E` (the VEP model +already reads it; `ViscousFlowModel.grad_u` now does too — identical on a volume +mesh). Templates: `F1 = L (τ + tr τ P)`, `F0 = -f + c u`. + +### On the sphere + +The velocity is carried in the embedding space (3 components on a +`SphericalManifold`, `dim=2`, `cdim=3`). PETSc already supplies the surface +gradient (pseudo-inverse Jacobian) in the derivative index; the outer projection +in $\dot{\boldsymbol\varepsilon}_s$ supplies the curvature term +$-\mathbf u\cdot\nabla_s\hat{\mathbf n}$. Tangency $\mathbf u\cdot\hat{\mathbf n}=0$ +is imposed STRONGLY at every node through the per-node rotation of +`utilities/rotated_bc.py` (the same machinery as rotated free-slip and the +split-node fault constraint, so faults compose with it). A penalty was used only +to probe feasibility and is not an API. + +Vector solves on a manifold needed four `dim → cdim` corrections (the manifold +vector-FE gap): the `SNES_Vector` FE component count, its PC block size and +default unknown, `maths/vector_calculus` (component count), and the +`sympy.vector` truncation in `_jitextension`. + +### Faults on the sphere (Phase 5) + +The split-node pipeline is topological and survives; what has to generalise is +geometry: great-circle crossings in `line_cut`, cell orientation by the outward +normal in `reconnect`, the contact frame $\{\hat\nu = \hat r\times\hat t,\ \hat t,\ +\hat r\}$ in `fault_contact` (PETSc's FVM edge normal is wrong for `cdim=3`), and +the `mesh.dim` strides in `rotated_bc`, whose rigid-rotation gauge must offer the +three Killing fields. `mesh.adapt` (NVB) runs on the manifold already; refined +vertices need snapping back to the sphere. + +## Phases + +0. Feasibility probes (sheet operator on S² with penalty and with the strong + rotation; NVB adapt on S²; splitting an arc) — scratch, not committed. +1. Flat viscous `ThinSheet` + tests (`tests/test_1075_thin_sheet.py`). +2. Viscoelastic sheet (Maxwell; small-strain non-advected history for fault + problems; Elsasser check). +3. Faults in the flat sheet (rotated/fault BCs hoisted off the saddle class; a + vector-only branch of the rotated solve; BH96 thin-sheet solution). +4. Sheet on the sphere with strong tangency and the Killing null space. +5. Faults on the sphere. +6. Results and examples. diff --git a/src/underworld3/constitutive_models.py b/src/underworld3/constitutive_models.py index 4beb479d8..f8138f54a 100644 --- a/src/underworld3/constitutive_models.py +++ b/src/underworld3/constitutive_models.py @@ -975,13 +975,9 @@ def grad_u(self): \dot{\varepsilon}_{ij} = \frac{1}{2}\left(\frac{\partial u_i}{\partial x_j} + \frac{\partial u_j}{\partial x_i}\right) """ - mesh = self.Unknowns.u.mesh - - return mesh.vector.strain_tensor(self.Unknowns.u.sym) - - # ddu = self.Unknowns.u.sym.jacobian(mesh.CoordinateSystem.N) - # edot = (ddu + ddu.T) / 2 - # return edot + # The solver owns the kinematics: Unknowns.E is sym(grad u) on a volume + # mesh and the in-plane (projected) strain rate on a thin sheet. + return sympy.Matrix(self.Unknowns.E) @property def plastic_fraction(self): diff --git a/src/underworld3/systems/__init__.py b/src/underworld3/systems/__init__.py index 5ba37b93a..5f2b5f039 100644 --- a/src/underworld3/systems/__init__.py +++ b/src/underworld3/systems/__init__.py @@ -54,6 +54,7 @@ from .solvers import SNES_Poisson as Poisson from .solvers import SNES_Darcy as SteadyStateDarcy from .solvers import SNES_Stokes as Stokes +from .thin_sheet import SNES_ThinSheet as ThinSheet from .solvers import SNES_Stokes_Constrained as Stokes_Constrained from .solvers import SNES_VE_Stokes as VE_Stokes from .solvers import SNES_Projection as Projection diff --git a/src/underworld3/systems/thin_sheet.py b/src/underworld3/systems/thin_sheet.py new file mode 100644 index 000000000..9ad91f3e8 --- /dev/null +++ b/src/underworld3/systems/thin_sheet.py @@ -0,0 +1,273 @@ +r"""Thin-sheet solver: a vertically averaged (plane-stress) viscous or +viscoelastic sheet, flat or on a surface embedded in three dimensions. + +The sheet carries a tangential velocity :math:`\mathbf{u}` and a thickness +:math:`L`. Vertical averaging with the plane-stress closure +:math:`\sigma_{nn} = 0` on the dynamic stress eliminates the pressure: +with :math:`\boldsymbol{\tau}` the deviatoric stress of an incompressible +material, :math:`p = \tau_{nn} = -\operatorname{tr}_s \boldsymbol{\tau}`, so the +in-plane stress is + +.. math:: + + \boldsymbol{\sigma} = \boldsymbol{\tau} + + \operatorname{tr}(\boldsymbol{\tau})\,\mathbf{P}, + \qquad \mathbf{P} = \mathbf{I} - \hat{\mathbf{n}}\hat{\mathbf{n}}^T, + +(England & McKenzie 1982). Any deviatoric constitutive law applies unchanged, +a Maxwell viscoelastic one included; for a Newtonian sheet +:math:`\boldsymbol{\sigma} = 2\eta\,(\dot{\boldsymbol\varepsilon}_s ++ \operatorname{tr}\dot{\boldsymbol\varepsilon}_s\,\mathbf{P})`, whose uniaxial +limit is the Trouton viscosity :math:`3\eta`. The momentum balance solved is + +.. math:: + + -\nabla_s \cdot \left( L\,\boldsymbol{\sigma} \right) + c\,\mathbf{u} + = \mathbf{f}, + +with :math:`c` a basal drag coefficient (:math:`\eta_a/h_a` for a viscous +substrate) and :math:`\mathbf{f}` a force per unit area. There is no pressure +unknown, so this is a vector problem, not a saddle point. + +On a flat mesh :math:`\mathbf{P} = \mathbf{I}` and the velocity has two +components. On a manifold (``mesh.dim < mesh.cdim``, e.g. +:class:`~underworld3.meshing.SphericalManifold`) the velocity is carried in the +embedding space with :math:`\mathbf{P}` built from the surface normal; the +in-plane strain rate is :math:`\dot{\boldsymbol\varepsilon}_s = \mathbf{P}\, +\operatorname{sym}(\nabla_s \mathbf{u})\,\mathbf{P}`, the outer projection +supplying the curvature term :math:`-\mathbf{u}\cdot\nabla_s\hat{\mathbf{n}}`. +""" + +from typing import Optional, Union + +import sympy + +import underworld3 as uw +import underworld3.timing as timing +from underworld3.coordinates import CoordinateSystemType +from underworld3.function import expression +from underworld3.systems import SNES_Vector +from underworld3.utilities._api_tools import Template + +from .solvers import _ConstitutiveModelStateMixin +from .ddt import SemiLagrangian as SemiLagrangian_DDt +from .ddt import Lagrangian as Lagrangian_DDt + + +def _nondimensional_row(value, cdim): + """A ``(1, cdim)`` sympy row from a sequence whose entries may be + dimensional ``UWQuantity`` values (converted to the solver's non-dimensional + scale) or plain numbers / expressions.""" + # TODO(DESIGN): SNES_Stokes.bodyforce repeats this conversion inline; share it. + row = [] + for item in value: + if isinstance(item, uw.function.quantities.UWQuantity): + nd = uw.non_dimensionalise(item) + nd = nd.magnitude if hasattr(nd, "magnitude") else nd + if hasattr(nd, "shape") and tuple(nd.shape) == (1, 1): + nd = nd[0, 0] + row.append(nd) + else: + row.append(item) + return sympy.Matrix(row).reshape(1, cdim) + + +def _sheet_normal(mesh): + r"""Unit normal :math:`\hat{\mathbf{n}}` of the sheet as a ``(1, cdim)`` + row, or ``None`` on a flat (volume) mesh.""" + if mesh.dim == mesh.cdim: + return None + if mesh.CoordinateSystem.coordinate_type is CoordinateSystemType.SPHERICAL: + return sympy.Matrix(mesh.CoordinateSystem.unit_e_0).reshape(1, mesh.cdim) + raise NotImplementedError( + "ThinSheet needs the surface normal of the sheet: only a flat mesh " + "or a spherical manifold (CoordinateSystemType.SPHERICAL) is supported.") + + +def _tangent_projector(mesh): + r""":math:`\mathbf{P} = \mathbf{I} - \hat{\mathbf{n}}\hat{\mathbf{n}}^T` + (the identity on a flat mesh), ``(cdim, cdim)``.""" + n = _sheet_normal(mesh) + if n is None: + return sympy.eye(mesh.cdim) + return sympy.eye(mesh.cdim) - n.T * n + + +class SNES_ThinSheet(_ConstitutiveModelStateMixin, SNES_Vector): + r"""Vertically averaged thin sheet (plane stress), viscous or viscoelastic. + + Solves + + .. math:: + + -\nabla_s \cdot \left( L\,\boldsymbol{\sigma} \right) + c\,\mathbf{u} + = \mathbf{f}, + \qquad + \boldsymbol{\sigma} = \boldsymbol{\tau} + + \operatorname{tr}(\boldsymbol{\tau})\,\mathbf{P}, + + for the sheet velocity :math:`\mathbf{u}`, with :math:`\boldsymbol{\tau}` + the deviatoric stress of the constitutive model evaluated on the in-plane + strain rate :math:`\dot{\boldsymbol\varepsilon}_s`. See the module + docstring for the closure. + + Parameters + ---------- + mesh : Mesh + A flat mesh (``dim == cdim``) or a spherical manifold. + velocityField : MeshVariable, optional + Sheet velocity, ``cdim`` components. Created if not supplied. + degree : int, default 2 + Polynomial degree of the velocity space. + verbose : bool, default False + DuDt, DFDt : optional + Time-derivative managers; a viscoelastic constitutive model creates + its own stress history when none is supplied. + + Examples + -------- + A Newtonian sheet stretched along :math:`x` with free lateral edges + carries :math:`\sigma_{xx} = 3\eta\dot\varepsilon` (the Trouton ratio): + + >>> sheet = uw.systems.ThinSheet(mesh) + >>> sheet.constitutive_model = uw.constitutive_models.ViscousFlowModel + >>> sheet.constitutive_model.Parameters.shear_viscosity_0 = 1 + >>> sheet.add_essential_bc([-1, None], "Left") + >>> sheet.add_essential_bc([1, None], "Right") + >>> sheet.solve() + """ + + class _Unknowns(SNES_Vector._Unknowns): + """The sheet's kinematics: the strain rate handed to the constitutive + model is the in-plane one, :math:`\mathbf{P}\,\mathrm{sym}(\nabla_s + \mathbf{u})\,\mathbf{P}`.""" + + @SNES_Vector._Unknowns.u.setter + def u(inner_self, new_u): + SNES_Vector._Unknowns.u.fset(inner_self, new_u) + if new_u is not None: + P = _tangent_projector(new_u.mesh) + inner_self._E = P * inner_self._E * P + inner_self._Einv2 = sympy.sqrt( + (sympy.Matrix(inner_self._E) ** 2).trace() / 2) + + _solver_terms = ( + ("thickness", "sheet thickness L multiplying the in-plane stress"), + ("bodyforce", "force per unit area; F0 is its negative"), + ("basal_drag", "drag coefficient c on the sheet velocity (eta_a / h_a)"), + ) + + @timing.routine_timer_decorator + def __init__( + self, + mesh: uw.discretisation.Mesh, + velocityField: Optional[uw.discretisation.MeshVariable] = None, + degree: Optional[int] = 2, + verbose: Optional[bool] = False, + DuDt: Union[SemiLagrangian_DDt, Lagrangian_DDt] = None, + DFDt: Union[SemiLagrangian_DDt, Lagrangian_DDt] = None, + ): + super().__init__(mesh, velocityField, degree, verbose, DuDt=DuDt, DFDt=DFDt) + + self._P = _tangent_projector(mesh) + self._thickness = expression(R"L", 1, "Sheet thickness") + self._basal_drag = expression(R"c_b", 0, "Basal drag coefficient") + self._bodyforce = expression( + Rf"\mathbf{{f}}_0\left( {self.Unknowns.u.symbol} \right)", + sympy.Matrix([[0] * self.mesh.cdim]), + "Thin-sheet force per unit area: f_0(u)", + ) + self._needs_function_rewire = True + self._constitutive_model = None + + # ------------------------------------------------------------------ + # geometry + # ------------------------------------------------------------------ + + @property + def tangent_projector(self): + r""":math:`\mathbf{P} = \mathbf{I} - \hat{\mathbf{n}}\hat{\mathbf{n}}^T`; + the identity on a flat mesh.""" + return self._P + + # ------------------------------------------------------------------ + # parameters + # ------------------------------------------------------------------ + + @property + def thickness(self): + r"""Sheet thickness :math:`L` (a constant or an expression).""" + return self._thickness + + @thickness.setter + def thickness(self, value): + self._needs_function_rewire = True + self._thickness.sym = value + + @property + def basal_drag(self): + r"""Basal drag coefficient :math:`c` in :math:`c\,\mathbf{u}`; for a + viscous substrate of viscosity :math:`\eta_a` and thickness + :math:`h_a`, :math:`c = \eta_a / h_a`.""" + return self._basal_drag + + @basal_drag.setter + def basal_drag(self, value): + self._needs_function_rewire = True + self._basal_drag.sym = value + + @property + def bodyforce(self): + r"""Force per unit area :math:`\mathbf{f}` on the right-hand side of + the momentum balance.""" + return self._bodyforce + + @bodyforce.setter + def bodyforce(self, value): + self._needs_function_rewire = True + if isinstance(value, uw.function.expressions.UWexpression): + self._bodyforce.sym = value.sym + else: + self._bodyforce.sym = _nondimensional_row(value, self.mesh.cdim) + + # ------------------------------------------------------------------ + # kinematics and stress + # ------------------------------------------------------------------ + + @property + def strainrate(self): + r"""In-plane strain rate :math:`\dot{\boldsymbol\varepsilon}_s = + \mathbf{P}\,\mathrm{sym}(\nabla_s \mathbf{u})\,\mathbf{P}`.""" + return sympy.Matrix(self.Unknowns.E) + + @property + def stress_deviator(self): + r"""In-plane deviatoric stress :math:`\boldsymbol{\tau}` from the + constitutive model.""" + return sympy.Matrix(self.constitutive_model.flux) + + @property + def stress(self): + r"""In-plane sheet stress :math:`\boldsymbol{\sigma} = \boldsymbol{\tau} + + \operatorname{tr}(\boldsymbol{\tau})\,\mathbf{P}` (plane-stress + closure, :math:`p = -\operatorname{tr}\boldsymbol{\tau}`).""" + tau = self.stress_deviator + return tau + tau.trace() * self._P + + # ------------------------------------------------------------------ + # PETSc residual templates + # ------------------------------------------------------------------ + + F0 = Template( + r"\mathbf{f}_0\left( \mathbf{u} \right)", + lambda self: -self.bodyforce.sym + self.basal_drag.sym * self.u.sym, + r"""Pointwise term: basal drag :math:`c\,\mathbf{u}` less the force per + unit area :math:`\mathbf{f}`.""", + ) + + F1 = Template( + r"\mathbf{F}_1\left( \mathbf{u} \right)", + lambda self: self.thickness.sym * self.stress, + r"""Flux term: the depth-integrated in-plane stress :math:`L\, + \boldsymbol{\sigma}`.""", + ) diff --git a/tests/test_1075_thin_sheet.py b/tests/test_1075_thin_sheet.py new file mode 100644 index 000000000..fc6eec72c --- /dev/null +++ b/tests/test_1075_thin_sheet.py @@ -0,0 +1,133 @@ +"""Thin viscous sheet (plane stress) on a flat mesh: closed-form checks. + +The sheet closure is :math:`\\sigma = \\tau + \\mathrm{tr}(\\tau)\\,\\mathbf{I}` +with :math:`\\tau = 2\\eta\\dot\\varepsilon`. Every test here has an exact +answer the discrete solution must reproduce to solver tolerance, except the +manufactured-solution test, whose contract is the convergence order. +""" + +import numpy as np +import pytest +import sympy + +import underworld3 as uw + +pytestmark = [pytest.mark.level_1] + + +def _box(cell_size=0.1): + return uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=cell_size, qdegree=3) + + +def _sheet(mesh, eta=1): + sheet = uw.systems.ThinSheet(mesh) + sheet.constitutive_model = uw.constitutive_models.ViscousFlowModel + sheet.constitutive_model.Parameters.shear_viscosity_0 = eta + sheet.tolerance = 1.0e-10 + return sheet + + +def _probe_points(n=7): + s = np.linspace(0.15, 0.85, n) + return np.array([[a, b] for a in s for b in s]) + + +@pytest.mark.tier_b +def test_trouton_uniaxial_stretch(): + """Uniaxial stretch with free lateral edges: the lateral contraction is + half the stretch and the axial stress is the Trouton value 3 eta edot.""" + mesh = _box() + x, y = mesh.X + edot = sympy.Rational(1, 2) + eta = 2 + sheet = _sheet(mesh, eta=eta) + # exact solution u = (edot x, -edot (y - 1/2) / 2): prescribe it on the + # loaded ends, leave the lateral edges traction-free + u_y = -edot * (y - sympy.Rational(1, 2)) / 2 + sheet.add_essential_bc([0, u_y], "Left") + sheet.add_essential_bc([edot, u_y], "Right") + sheet.solve() + + pts = _probe_points() + u = uw.function.evaluate(sheet.u.sym, pts) + exact = np.column_stack([edot * pts[:, 0], -edot * (pts[:, 1] - 0.5) / 2]) + assert np.abs(u.reshape(-1, 2) - exact).max() < 1.0e-8 + + # the stress is a derivative of the P2 field sampled at arbitrary points: + # point-evaluation noise sits ~1/h above the velocity's 1e-8 + sigma = uw.function.evaluate(sheet.stress, pts).reshape(-1, 2, 2) + trouton = 3 * eta * float(edot) + assert np.abs(sigma[:, 0, 0] - trouton).max() < 1.0e-6 * trouton + assert np.abs(sigma[:, 1, 1]).max() < 1.0e-6 * trouton + assert np.abs(sigma[:, 0, 1]).max() < 1.0e-6 * trouton + + +@pytest.mark.tier_b +def test_rigid_motion_carries_no_stress(): + """A rigid translation plus rotation prescribed on the whole boundary is + reproduced inside, and the sheet stress vanishes.""" + mesh = _box() + x, y = mesh.X + omega, cx, cy = sympy.Rational(3, 10), sympy.Rational(1, 5), sympy.Rational(-1, 7) + rigid = [cx - omega * y, cy + omega * x] + sheet = _sheet(mesh) + for boundary in ("Left", "Right", "Top", "Bottom"): + sheet.add_essential_bc(rigid, boundary) + sheet.solve() + + pts = _probe_points() + u = uw.function.evaluate(sheet.u.sym, pts).reshape(-1, 2) + exact = np.column_stack([float(cx) - float(omega) * pts[:, 1], + float(cy) + float(omega) * pts[:, 0]]) + assert np.abs(u - exact).max() < 1.0e-8 + sigma = uw.function.evaluate(sheet.stress, pts) + assert np.abs(sigma).max() < 1.0e-6 + + +@pytest.mark.tier_b +def test_basal_drag_balances_a_uniform_load(): + """With no kinematic boundary condition, a uniform force per unit area + against a basal drag c gives the uniform velocity u = f / c exactly.""" + mesh = _box() + sheet = _sheet(mesh) + c = sympy.Rational(5, 2) + f = (sympy.Rational(3, 4), sympy.Rational(-1, 3)) + sheet.basal_drag = c + sheet.bodyforce = f + sheet.solve() + + pts = _probe_points() + u = uw.function.evaluate(sheet.u.sym, pts).reshape(-1, 2) + exact = np.array([float(f[0] / c), float(f[1] / c)]) + assert np.abs(u - exact).max() < 1.0e-8 + + +@pytest.mark.tier_b +def test_manufactured_solution_converges_at_third_order(): + """P2 velocity on a smooth manufactured solution: the L2 error of the + sheet momentum balance with basal drag converges at order 3 in h.""" + errors = {} + for h in (0.2, 0.1): + mesh = _box(cell_size=h) + x, y = mesh.X + eta = sympy.Rational(3, 2) + c = sympy.Integer(2) + u_e = sympy.Matrix([[sympy.sin(sympy.pi * x) * sympy.cos(sympy.pi * y), + sympy.cos(sympy.pi * x) * sympy.sin(sympy.pi * y) / 3]]) + G = u_e.jacobian(sympy.Matrix([x, y])) + E = (G + G.T) / 2 + sigma = 2 * eta * (E + E.trace() * sympy.eye(2)) + div_sigma = sympy.Matrix([[sum(sigma[i, j].diff(v) for j, v in enumerate((x, y))) + for i in range(2)]]) + sheet = _sheet(mesh, eta=eta) + sheet.basal_drag = c + sheet.bodyforce = list(-div_sigma + c * u_e) + for boundary in ("Left", "Right", "Top", "Bottom"): + sheet.add_essential_bc(list(u_e), boundary) + sheet.solve() + err2 = uw.maths.Integral(mesh, ((sheet.u.sym - u_e) * (sheet.u.sym - u_e).T)[0, 0]).evaluate() + errors[h] = np.sqrt(err2) + + order = np.log(errors[0.2] / errors[0.1]) / np.log(2.0) + assert order > 2.6, f"convergence order {order:.2f}, errors {errors}" From 722709668852936fcb14dd7ae5de0bdfc8454548 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Thu, 8 Oct 2026 17:35:11 +1100 Subject: [PATCH 03/57] ThinSheet carries a Maxwell stress history; the history code is shared with Stokes The viscoelastic stress history (its transport choice, the three per-step hooks, the history factory and the `tau` read) moves from SNES_Stokes into _StressHistoryMixin, a pure move apart from sizing the stored tensor by the embedding dimension (identical on a volume mesh) and a no-op DEVSS hook that Stokes overrides. ThinSheet.solve(timestep=) runs the same sequence around its momentum solve, so any constitutive model with a stress history works in the sheet unchanged. Tests (closed forms): the exponential integrator reproduces the Maxwell uniaxial response 3 eta edot (1 - exp(-t mu/eta)) at every step; backward Euler reproduces its own recursion; Elsasser stress diffusion into an elastic sheet over a viscous substrate (D = mu L / c) approaches the erfc front at first order in the timestep. Stokes regression (1050/1051/1052/ 0070) unchanged. scripts/sessions/thin_sheet_elsasser.py draws the profiles. Underworld development team with AI support from Claude Code --- scripts/sessions/thin_sheet_elsasser.py | 82 +++ src/underworld3/systems/solvers.py | 604 +++++++++++---------- src/underworld3/systems/thin_sheet.py | 48 +- tests/test_1076_thin_sheet_viscoelastic.py | 106 ++++ 4 files changed, 545 insertions(+), 295 deletions(-) create mode 100644 scripts/sessions/thin_sheet_elsasser.py create mode 100644 tests/test_1076_thin_sheet_viscoelastic.py diff --git a/scripts/sessions/thin_sheet_elsasser.py b/scripts/sessions/thin_sheet_elsasser.py new file mode 100644 index 000000000..27b2ecfe1 --- /dev/null +++ b/scripts/sessions/thin_sheet_elsasser.py @@ -0,0 +1,82 @@ +"""Elsasser stress diffusion in a viscoelastic thin sheet over a viscous substrate. + +An elastic plate (shear modulus mu, thickness L, Maxwell time much longer than +the run) rests on a viscous channel of drag coefficient c = eta_a / h_a. A +step in the velocity of its left edge diffuses into the plate: + + v_t = D v_xx, D = mu L / c, v(x, t) = V0 erfc(x / sqrt(4 D t)). + +The figure compares the ThinSheet profiles with the erfc solution at several +times. Run from the worktree environment; the figure goes to -uw_output_dir. +""" +import os + +import numpy as np +from scipy.special import erfc + +import underworld3 as uw + +params = uw.Params( + uw_output_dir=os.path.expanduser("~/+Simulations/thin_sheet_faults/figures"), + uw_dt=0.0125, + uw_cell_size=0.1, + uw_length=4.0, + uw_times="0.1,0.25,0.5,1.0", +) + +mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(params.uw_length, 1.0), + cellSize=params.uw_cell_size, qdegree=3) +sheet = uw.systems.ThinSheet(mesh) +sheet.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + sheet.Unknowns, order=1, integrator="etd") +sheet.constitutive_model.Parameters.shear_viscosity_0 = 1.0e6 # t_r = 1e6 >> t +sheet.constitutive_model.Parameters.shear_modulus = 1.0 +sheet.basal_drag = 1 # D = mu L / c = 1 +sheet.tolerance = 1.0e-10 +sheet.add_essential_bc([0, 1.0], "Left") +sheet.add_essential_bc([0, 0], "Right") +sheet.add_essential_bc([0, None], "Top") +sheet.add_essential_bc([0, None], "Bottom") + +xs = np.linspace(0.0, 3.0, 121) +pts = np.column_stack([xs, np.full_like(xs, 0.5)]) +targets = [float(t) for t in params.uw_times.split(",")] +profiles = {} +t, step = 0.0, 0 +while targets: + sheet.solve(timestep=params.uw_dt, zero_init_guess=False) + t += params.uw_dt + step += 1 + if abs(t - targets[0]) < 0.5 * params.uw_dt: + v = uw.function.evaluate(sheet.u.sym, pts).reshape(-1, 2)[:, 1] + profiles[targets.pop(0)] = v + uw.pprint(f"t={t:.4f} step {step}: max |v - erfc| = " + f"{np.abs(v - erfc(xs / np.sqrt(4.0 * t))).max():.3e}", flush=True) + +if uw.mpi.rank == 0: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + os.makedirs(params.uw_output_dir, exist_ok=True) + fig, ax = plt.subplots(figsize=(7.0, 4.2)) + blues = plt.get_cmap("Blues") + for k, (tt, v) in enumerate(sorted(profiles.items())): + colour = blues(0.4 + 0.6 * k / max(len(profiles) - 1, 1)) + ax.plot(xs, erfc(xs / np.sqrt(4.0 * tt)), color="0.25", lw=1.0, ls="--", + label="erfc solution" if k == 0 else None) + ax.plot(xs, v, color=colour, lw=2.0, label=f"t = {tt:g}") + ax.annotate(f"t = {tt:g}", (np.sqrt(4.0 * tt) * 0.9, erfc(0.9) + 0.02), + color=colour, fontsize=9) + ax.set_xlabel("distance from the driven edge x / sqrt(D)") + ax.set_ylabel("sheet velocity v / V0") + ax.set_xlim(0, 3.0); ax.set_ylim(0, 1.02) + ax.spines[["top", "right"]].set_visible(False) + ax.grid(color="0.9", lw=0.6) + ax.legend(frameon=False, fontsize=9) + ax.set_title("Elsasser stress diffusion: viscoelastic thin sheet over a viscous substrate", + fontsize=10, loc="left") + out = os.path.join(params.uw_output_dir, "elsasser-thin-sheet.png") + fig.savefig(out, dpi=160, bbox_inches="tight") + uw.pprint(f"wrote {out}", flush=True) diff --git a/src/underworld3/systems/solvers.py b/src/underworld3/systems/solvers.py index aa0d6a5df..db6f85b68 100644 --- a/src/underworld3/systems/solvers.py +++ b/src/underworld3/systems/solvers.py @@ -492,6 +492,300 @@ def constitutive_model_is_setup(self): CM_is_setup = constitutive_model_is_setup +class _StressHistoryMixin: + """The viscoelastic stress history, shared by every solver whose + constitutive model carries one (Stokes, the thin sheet). + + A model with ``requires_stress_history`` gets a history manager + (``Unknowns.DFDt``) from :meth:`_create_stress_history_ddt`; a solve then + runs :meth:`_stress_history_prepare` before the build, + :meth:`_stress_history_advance` once per step before the momentum solve, + and :meth:`_stress_history_post_solve` after it. The DEVSS hook is a no-op + here; the Stokes solver, which carries the split, overrides it. + """ + + def _devss_refresh(self, verbose=False): + """No DEVSS split on this solver.""" + return + + @property + def stress_transport(self) -> str: + """How a viscoelastic stress history is carried: ``"semi_lagrangian"`` + (default), ``"integration_point"``, ``"forward"``, ``"lagrangian"`` or + ``"eulerian"``. + + ``"forward"`` carries the stress from a fixed set of launch points inside + the cells (the integration points), one forward trajectory a step, and + fits the arrivals per cell; the constitutive flux is read at the launch + points through a continuous P1 projection. It holds the Maxwell + start-up below Courant one where the integration-point history rings + (see :class:`~underworld3.systems.ddt.ForwardSemiLagrangian`). + + The semi-Lagrangian history traces the stress back along characteristics + and stores it on a nodal field, which the assembler then interpolates to + the integration points: two interpolations a step. ``"integration_point"`` + traces back to the integration points themselves and holds the history + there, so it carries one evaluation error and needs no projection. The + Eulerian one transports the stress on the grid with the same + streamline-upwind stabilisation the Eulerian solvers use, and gives the + same answer on any partition. ``"lagrangian"`` carries the stress on a + swarm of material points the solver creates and advects, reading the + constitutive flux at the particles each step and never projecting it + back to the mesh: no numerical diffusion of the history, at the cost of + the swarm (see :class:`~underworld3.systems.ddt.Lagrangian`). The default + is ``"semi_lagrangian"``. Set + it before the constitutive model is assigned: assigning the model + creates the history, and the choice cannot change after that. + """ + return getattr(self, "_stress_transport", "semi_lagrangian") + + @stress_transport.setter + def stress_transport(self, value): + value = str(value) + if value not in ("semi_lagrangian", "integration_point", "forward", + "lagrangian", "eulerian"): + raise ValueError( + "stress_transport must be 'semi_lagrangian', 'integration_point', " + f"'forward', 'lagrangian' or 'eulerian', not {value!r}.") + if self.Unknowns.DFDt is not None: + raise RuntimeError( + "the stress history already exists: set stress_transport before the " + "constitutive model that asks for one.") + self._stress_transport = value + + + def _stress_history_prepare(self, timestep, _force_setup=False): + """Set the elastic timestep and the flags a rebuild depends on. + + Runs BEFORE the solver is built. The effective order of the stress + history ramps over the opening steps, and when it changes the compiled + functions must be rewired; setting that flag after the build has already + decided whether to set up leaves the managed multigrid block asking a + preconditioner for sub-solvers it has not created (#727). + """ + # dt_elastic must always equal the solve timestep. The constitutive + # model's VE formulas (eta_eff, stress history terms) all reference + # Parameters.dt_elastic. If it differs from the actual timestep, + # the stress computation is inconsistent with the time integration. + self.constitutive_model.Parameters.dt_elastic = timestep + # The integrator coefficients must be current BEFORE the history is + # first carried: a trace-back history initialises its first level from + # the constitutive flux of the velocity it finds, and with the + # exponential integrator that flux read the viscous-limit coefficients + # (alpha = phi = 0) until the update that used to follow the carry + # (#740). The BDF coefficients are refreshed again after the carry, as + # before, since they read the step history the carry updates. + self.constitutive_model._update_history_coefficients() + + if _force_setup: + self._needs_function_rewire = True + + # Re-setup when effective_order changes (DDt history ramp-up) + _current_eff_order = self.constitutive_model.effective_order + if _current_eff_order != self._prev_effective_order: + self._needs_function_rewire = True + self.constitutive_model._solver_is_setup = False + self._prev_effective_order = _current_eff_order + + if not self.constitutive_model._solver_is_setup: + self._needs_function_rewire = True + self.DFDt.psi_fn = _history_psi_fn(self.constitutive_model) + # D starts from the velocity as it is now, so the DEVSS pair + # cancels on the first step as it does on every later one. + self._devss_refresh() + + def _stress_history_advance(self, timestep, verbose=False, evalf=False): + """Carry the stress history to where the momentum solve will read it. + + Runs AFTER the solver is built, and once per step: a solver that takes + several passes over the momentum equation (the Navier-Stokes one, with + its Picard corrections) must not advance the history once per pass. + """ + if uw.mpi.rank == 0 and verbose: + print("Stokes solver - carry the stress history", flush=True) + + self.DFDt.update_pre_solve(timestep, verbose=verbose, evalf=evalf, + store_result=False) + # Uniform pre-solve coefficient hook: VEP delegates to + # _update_bdf_coefficients(); MaxwellExponentialFlowModel updates + # α, φ on the DDt via _update_exp_coefficients(). No isinstance + # checks at the solver layer. + self.constitutive_model._update_history_coefficients() + + def _stress_history_post_solve(self, timestep, verbose=False, evalf=False): + """Commit the stress the solve produced and shift the history levels.""" + # The history manager places the new stress in level 0 and shifts the + # levels. A particle-carried history does that itself in its post-solve, + # by evaluating the new stress at its own particles. + if not self.DFDt.commits_flux_in_post_solve: + self.DFDt.commit_flux_to_history(_history_record(self.constitutive_model), verbose=verbose) + + self.DFDt.update_post_solve(timestep, verbose=verbose, evalf=evalf) + self._devss_refresh(verbose=verbose) + + # Uniform post-solve hook for any extra integrator-state storage. + # VEP: no-op. ETD-2 / MaxwellExponentialFlowModel: refresh + # forcing_star with current ε̇^{n+1} so the next step's history + # term has access to ε̇ⁿ. + self.constitutive_model._update_history_post_solve() + + self.is_setup = True + self.constitutive_model._solver_is_setup = True + + def _create_stress_history_ddt(self, order=2): + """Create DFDt for stress history tracking (VE/VEP models). + + Called automatically when a constitutive model with + ``requires_stress_history = True`` is assigned. Can also be called + explicitly to pre-create the DFDt with a specific order. + :attr:`stress_transport` chooses which flavour carries it. + + Constitutive models can inject extra SemiLagrangian kwargs via the + ``stress_history_ddt_kwargs`` property — used e.g. by + ``MaxwellExponentialFlowModel`` to set ``with_forcing_history=True``. + """ + if self.Unknowns.DFDt is not None: + return # already created + + self._order = order + # Constitutive model may request extra SemiLagrangian kwargs (e.g. + # with_forcing_history for ETD-2 integration). + cm = getattr(self, "constitutive_model", None) + ddt_kwargs = {} + if cm is not None: + ddt_kwargs = dict(getattr(cm, "stress_history_ddt_kwargs", {})) + + common = dict( + vtype=uw.VarType.SYM_TENSOR, + degree=self.u.degree - 1, + continuous=True, + varsymbol=rf"{{F[ {self.u.symbol} ] }}", + verbose=self.verbose, + bcs=None, + order=order, + smoothing=0.0001, + # the history carries a stress (the flux handed over at construction + # is a zero placeholder, so it cannot say so itself), or the + # dimensionless log-conformation of one + units=uw.units.Pa if getattr(cm, "_stress_history", "stress") == "stress" else None, + ) + if self.stress_transport == "integration_point": + unsupported = set(ddt_kwargs) - {"with_forcing_history"} + if unsupported: + raise NotImplementedError( + f"{type(cm).__name__} asks its stress history for " + f"{sorted(unsupported)}, which the integration-point flavour " + "does not provide; use stress_transport='semi_lagrangian'.") + self.Unknowns.DFDt = uw.systems.ddt.IntegrationPointSemiLagrangian( + self.mesh, + sympy.Matrix.zeros(self.mesh.cdim, self.mesh.cdim), + self.u.sym, + **ddt_kwargs, + **{k: v for k, v in common.items() if k != "smoothing"}, + ) + elif self.stress_transport == "forward": + if ddt_kwargs: + raise NotImplementedError( + f"{type(cm).__name__} asks its stress history for " + f"{sorted(ddt_kwargs)}, which the forward flavour does not provide; " + "use stress_transport='semi_lagrangian' for it.") + self.Unknowns.DFDt = uw.systems.ddt.ForwardSemiLagrangian( + self.mesh, + sympy.Matrix.zeros(self.mesh.cdim, self.mesh.cdim), + self.u.sym, + vtype=common["vtype"], varsymbol=common["varsymbol"], order=order, + units=common["units"], + ) + elif self.stress_transport == "lagrangian": + if ddt_kwargs: + raise NotImplementedError( + f"{type(cm).__name__} asks its stress history for " + f"{sorted(ddt_kwargs)}, which the particle Lagrangian flavour does " + "not provide; use stress_transport='semi_lagrangian' for it.") + # Order 1 BDF only for now: the particle flavour has no exponential + # coefficients (it is built with_exp=False), and order 2 is not yet + # validated. Refuse cleanly rather than crash inside the first solve. + if getattr(cm, "_integrator", "bdf") != "bdf": + raise NotImplementedError( + "the particle Lagrangian stress history supports the BDF " + "integrator only; use stress_transport='semi_lagrangian' for the " + "exponential one.") + if order > 1: + raise NotImplementedError( + "the particle Lagrangian stress history is first order for now; " + "use stress_transport='semi_lagrangian' for order 2.") + # The solver owns the swarm: Lagrangian creates and populates it, and + # carries the stress on it. Lagrangian_Swarm (a user-supplied swarm) + # stays available by passing DFDt= to the constructor. + # A cells proxy (discontinuous, reconstructed from the particles in + # each cell) is what the particle history is validated on + # (test_0070); the continuous nodal store the mesh flavours use is + # not the right target for a swarm-carried field. + lag_common = {k: v for k, v in common.items() if k != "continuous"} + self.Unknowns.DFDt = uw.systems.ddt.Lagrangian( + self.mesh, + sympy.Matrix.zeros(self.mesh.cdim, self.mesh.cdim), + self.u.sym, + continuous=False, + proxy_location="cells", + **lag_common, + ) + elif self.stress_transport == "eulerian": + if ddt_kwargs: + raise NotImplementedError( + f"{type(cm).__name__} asks its stress history for " + f"{sorted(ddt_kwargs)}, which only the semi-Lagrangian flavour " + "provides; use stress_transport='semi_lagrangian' for it.") + self.Unknowns.DFDt = uw.systems.ddt.EulerianSUPG( + self.mesh, + sympy.Matrix.zeros(self.mesh.cdim, self.mesh.cdim), + self.u.sym, + transport_on_update=True, + **common, + ) + else: + self.Unknowns.DFDt = uw.systems.ddt.SemiLagrangian( + self.mesh, + sympy.Matrix.zeros(self.mesh.cdim, self.mesh.cdim), + self.u.sym, + **ddt_kwargs, + **common, + ) + # Stress flux = 2·viscosity·E_eff references psi_star[0] in E_eff's + # history term — without snapshot substitution the projection of + # flux→psi_star[0] becomes implicit in psi_star[0] and Min-mode at + # yield admits the wrong fixed point under timestep change. + self.Unknowns.DFDt.enable_source_snapshot() + # the history stores the model's encoding of a stress; an inflow datum + # is given as a stress and stored through the same encoding + self.Unknowns.DFDt._encode = getattr(cm, "encode_history", None) + + @property + def tau(self): + r"""Deviatoric stress from the most recent solve. + + When stress history is active (VEP), returns ``psi_star[0]`` which + contains the actual projected stress; a log-conformation history is + decoded into a projected stress variable. Otherwise falls through to + the base class lazy projection. + """ + if self.Unknowns.DFDt is not None: + cm = self.constitutive_model + if getattr(cm, "_stress_history", "stress") == "stress": + return self.DFDt.psi_star[0] + if getattr(self, "_tau_decoded", None) is None: + self._tau_decoded = uw.discretisation.MeshVariable( + "tau_decoded", self.mesh, (self.mesh.cdim, self.mesh.cdim), + vtype=uw.VarType.SYM_TENSOR, degree=self.DFDt.psi_star[0].degree, + continuous=True, units=uw.units.Pa if uw.get_default_model().has_units() else None) + self._tau_decode = uw.systems.Tensor_Projection(self.mesh, self._tau_decoded) + self._tau_decode.smoothing = 0.0 + self._tau_decode.uw_function = cm._carried_stress_sym(0) + self._tau_decode.solve() + return self._tau_decoded + return super().tau + + class SNES_Poisson(_ConstitutiveModelStateMixin, SNES_Scalar): r"""Poisson equation solver. @@ -1373,7 +1667,7 @@ def _penalty_value(penalty_expression): return float("nan") # symbolic: not a number, so not zero -class SNES_Stokes(_ConstitutiveModelStateMixin, SNES_Stokes_SaddlePt): +class SNES_Stokes(_StressHistoryMixin, _ConstitutiveModelStateMixin, SNES_Stokes_SaddlePt): r""" Stokes equation solver for incompressible viscous flow. @@ -1548,68 +1842,23 @@ def set_jacobian_F1_source(self, F1_source, linesearch="cp"): Parameters ---------- F1_source : sympy.Matrix or None - Alternative expression of the same shape as ``F1.sym``. - linesearch : str or None, default ``"cp"`` - SNES linesearch type to install when ``F1_source`` is set. - Defaults to ``"cp"`` (critical-point) because inexact-Newton - steps don't reliably reduce the residual norm and PETSc's - default ``bt`` (backtracking) consequently rejects useful - steps with ``DIVERGED_LINE_SEARCH``. ``cp`` accepts the - predicted step at the optimum of the local linearisation and - converges cleanly on the same problems where ``bt`` flails. - Set to ``None`` to leave the linesearch type untouched (e.g. - if you've already configured one via ``petsc_options``). - Has no effect when ``F1_source is None``. - """ - self._F1_jacobian_source = F1_source - self._needs_function_rewire = True - if F1_source is not None and linesearch is not None: - self.petsc_options["snes_linesearch_type"] = linesearch - - @property - def stress_transport(self) -> str: - """How a viscoelastic stress history is carried: ``"semi_lagrangian"`` - (default), ``"integration_point"``, ``"forward"``, ``"lagrangian"`` or - ``"eulerian"``. - - ``"forward"`` carries the stress from a fixed set of launch points inside - the cells (the integration points), one forward trajectory a step, and - fits the arrivals per cell; the constitutive flux is read at the launch - points through a continuous P1 projection. It holds the Maxwell - start-up below Courant one where the integration-point history rings - (see :class:`~underworld3.systems.ddt.ForwardSemiLagrangian`). - - The semi-Lagrangian history traces the stress back along characteristics - and stores it on a nodal field, which the assembler then interpolates to - the integration points: two interpolations a step. ``"integration_point"`` - traces back to the integration points themselves and holds the history - there, so it carries one evaluation error and needs no projection. The - Eulerian one transports the stress on the grid with the same - streamline-upwind stabilisation the Eulerian solvers use, and gives the - same answer on any partition. ``"lagrangian"`` carries the stress on a - swarm of material points the solver creates and advects, reading the - constitutive flux at the particles each step and never projecting it - back to the mesh: no numerical diffusion of the history, at the cost of - the swarm (see :class:`~underworld3.systems.ddt.Lagrangian`). The default - is ``"semi_lagrangian"``. Set - it before the constitutive model is assigned: assigning the model - creates the history, and the choice cannot change after that. + Alternative expression of the same shape as ``F1.sym``. + linesearch : str or None, default ``"cp"`` + SNES linesearch type to install when ``F1_source`` is set. + Defaults to ``"cp"`` (critical-point) because inexact-Newton + steps don't reliably reduce the residual norm and PETSc's + default ``bt`` (backtracking) consequently rejects useful + steps with ``DIVERGED_LINE_SEARCH``. ``cp`` accepts the + predicted step at the optimum of the local linearisation and + converges cleanly on the same problems where ``bt`` flails. + Set to ``None`` to leave the linesearch type untouched (e.g. + if you've already configured one via ``petsc_options``). + Has no effect when ``F1_source is None``. """ - return getattr(self, "_stress_transport", "semi_lagrangian") - - @stress_transport.setter - def stress_transport(self, value): - value = str(value) - if value not in ("semi_lagrangian", "integration_point", "forward", - "lagrangian", "eulerian"): - raise ValueError( - "stress_transport must be 'semi_lagrangian', 'integration_point', " - f"'forward', 'lagrangian' or 'eulerian', not {value!r}.") - if self.Unknowns.DFDt is not None: - raise RuntimeError( - "the stress history already exists: set stress_transport before the " - "constitutive model that asks for one.") - self._stress_transport = value + self._F1_jacobian_source = F1_source + self._needs_function_rewire = True + if F1_source is not None and linesearch is not None: + self.petsc_options["snes_linesearch_type"] = linesearch # ----- DEVSS: stabilising a discontinuous elastic stress ----- @@ -1699,212 +1948,6 @@ def _devss_refresh(self, verbose=False): if i != j: self._devss_D.array[:, j, i] = values - def _stress_history_prepare(self, timestep, _force_setup=False): - """Set the elastic timestep and the flags a rebuild depends on. - - Runs BEFORE the solver is built. The effective order of the stress - history ramps over the opening steps, and when it changes the compiled - functions must be rewired; setting that flag after the build has already - decided whether to set up leaves the managed multigrid block asking a - preconditioner for sub-solvers it has not created (#727). - """ - # dt_elastic must always equal the solve timestep. The constitutive - # model's VE formulas (eta_eff, stress history terms) all reference - # Parameters.dt_elastic. If it differs from the actual timestep, - # the stress computation is inconsistent with the time integration. - self.constitutive_model.Parameters.dt_elastic = timestep - # The integrator coefficients must be current BEFORE the history is - # first carried: a trace-back history initialises its first level from - # the constitutive flux of the velocity it finds, and with the - # exponential integrator that flux read the viscous-limit coefficients - # (alpha = phi = 0) until the update that used to follow the carry - # (#740). The BDF coefficients are refreshed again after the carry, as - # before, since they read the step history the carry updates. - self.constitutive_model._update_history_coefficients() - - if _force_setup: - self._needs_function_rewire = True - - # Re-setup when effective_order changes (DDt history ramp-up) - _current_eff_order = self.constitutive_model.effective_order - if _current_eff_order != self._prev_effective_order: - self._needs_function_rewire = True - self.constitutive_model._solver_is_setup = False - self._prev_effective_order = _current_eff_order - - if not self.constitutive_model._solver_is_setup: - self._needs_function_rewire = True - self.DFDt.psi_fn = _history_psi_fn(self.constitutive_model) - # D starts from the velocity as it is now, so the DEVSS pair - # cancels on the first step as it does on every later one. - self._devss_refresh() - - def _stress_history_advance(self, timestep, verbose=False, evalf=False): - """Carry the stress history to where the momentum solve will read it. - - Runs AFTER the solver is built, and once per step: a solver that takes - several passes over the momentum equation (the Navier-Stokes one, with - its Picard corrections) must not advance the history once per pass. - """ - if uw.mpi.rank == 0 and verbose: - print("Stokes solver - carry the stress history", flush=True) - - self.DFDt.update_pre_solve(timestep, verbose=verbose, evalf=evalf, - store_result=False) - # Uniform pre-solve coefficient hook: VEP delegates to - # _update_bdf_coefficients(); MaxwellExponentialFlowModel updates - # α, φ on the DDt via _update_exp_coefficients(). No isinstance - # checks at the solver layer. - self.constitutive_model._update_history_coefficients() - - def _stress_history_post_solve(self, timestep, verbose=False, evalf=False): - """Commit the stress the solve produced and shift the history levels.""" - # The history manager places the new stress in level 0 and shifts the - # levels. A particle-carried history does that itself in its post-solve, - # by evaluating the new stress at its own particles. - if not self.DFDt.commits_flux_in_post_solve: - self.DFDt.commit_flux_to_history(_history_record(self.constitutive_model), verbose=verbose) - - self.DFDt.update_post_solve(timestep, verbose=verbose, evalf=evalf) - self._devss_refresh(verbose=verbose) - - # Uniform post-solve hook for any extra integrator-state storage. - # VEP: no-op. ETD-2 / MaxwellExponentialFlowModel: refresh - # forcing_star with current ε̇^{n+1} so the next step's history - # term has access to ε̇ⁿ. - self.constitutive_model._update_history_post_solve() - - self.is_setup = True - self.constitutive_model._solver_is_setup = True - - def _create_stress_history_ddt(self, order=2): - """Create DFDt for stress history tracking (VE/VEP models). - - Called automatically when a constitutive model with - ``requires_stress_history = True`` is assigned. Can also be called - explicitly to pre-create the DFDt with a specific order. - :attr:`stress_transport` chooses which flavour carries it. - - Constitutive models can inject extra SemiLagrangian kwargs via the - ``stress_history_ddt_kwargs`` property — used e.g. by - ``MaxwellExponentialFlowModel`` to set ``with_forcing_history=True``. - """ - if self.Unknowns.DFDt is not None: - return # already created - - self._order = order - # Constitutive model may request extra SemiLagrangian kwargs (e.g. - # with_forcing_history for ETD-2 integration). - cm = getattr(self, "constitutive_model", None) - ddt_kwargs = {} - if cm is not None: - ddt_kwargs = dict(getattr(cm, "stress_history_ddt_kwargs", {})) - - common = dict( - vtype=uw.VarType.SYM_TENSOR, - degree=self.u.degree - 1, - continuous=True, - varsymbol=rf"{{F[ {self.u.symbol} ] }}", - verbose=self.verbose, - bcs=None, - order=order, - smoothing=0.0001, - # the history carries a stress (the flux handed over at construction - # is a zero placeholder, so it cannot say so itself), or the - # dimensionless log-conformation of one - units=uw.units.Pa if getattr(cm, "_stress_history", "stress") == "stress" else None, - ) - if self.stress_transport == "integration_point": - unsupported = set(ddt_kwargs) - {"with_forcing_history"} - if unsupported: - raise NotImplementedError( - f"{type(cm).__name__} asks its stress history for " - f"{sorted(unsupported)}, which the integration-point flavour " - "does not provide; use stress_transport='semi_lagrangian'.") - self.Unknowns.DFDt = uw.systems.ddt.IntegrationPointSemiLagrangian( - self.mesh, - sympy.Matrix.zeros(self.mesh.dim, self.mesh.dim), - self.u.sym, - **ddt_kwargs, - **{k: v for k, v in common.items() if k != "smoothing"}, - ) - elif self.stress_transport == "forward": - if ddt_kwargs: - raise NotImplementedError( - f"{type(cm).__name__} asks its stress history for " - f"{sorted(ddt_kwargs)}, which the forward flavour does not provide; " - "use stress_transport='semi_lagrangian' for it.") - self.Unknowns.DFDt = uw.systems.ddt.ForwardSemiLagrangian( - self.mesh, - sympy.Matrix.zeros(self.mesh.dim, self.mesh.dim), - self.u.sym, - vtype=common["vtype"], varsymbol=common["varsymbol"], order=order, - units=common["units"], - ) - elif self.stress_transport == "lagrangian": - if ddt_kwargs: - raise NotImplementedError( - f"{type(cm).__name__} asks its stress history for " - f"{sorted(ddt_kwargs)}, which the particle Lagrangian flavour does " - "not provide; use stress_transport='semi_lagrangian' for it.") - # Order 1 BDF only for now: the particle flavour has no exponential - # coefficients (it is built with_exp=False), and order 2 is not yet - # validated. Refuse cleanly rather than crash inside the first solve. - if getattr(cm, "_integrator", "bdf") != "bdf": - raise NotImplementedError( - "the particle Lagrangian stress history supports the BDF " - "integrator only; use stress_transport='semi_lagrangian' for the " - "exponential one.") - if order > 1: - raise NotImplementedError( - "the particle Lagrangian stress history is first order for now; " - "use stress_transport='semi_lagrangian' for order 2.") - # The solver owns the swarm: Lagrangian creates and populates it, and - # carries the stress on it. Lagrangian_Swarm (a user-supplied swarm) - # stays available by passing DFDt= to the constructor. - # A cells proxy (discontinuous, reconstructed from the particles in - # each cell) is what the particle history is validated on - # (test_0070); the continuous nodal store the mesh flavours use is - # not the right target for a swarm-carried field. - lag_common = {k: v for k, v in common.items() if k != "continuous"} - self.Unknowns.DFDt = uw.systems.ddt.Lagrangian( - self.mesh, - sympy.Matrix.zeros(self.mesh.dim, self.mesh.dim), - self.u.sym, - continuous=False, - proxy_location="cells", - **lag_common, - ) - elif self.stress_transport == "eulerian": - if ddt_kwargs: - raise NotImplementedError( - f"{type(cm).__name__} asks its stress history for " - f"{sorted(ddt_kwargs)}, which only the semi-Lagrangian flavour " - "provides; use stress_transport='semi_lagrangian' for it.") - self.Unknowns.DFDt = uw.systems.ddt.EulerianSUPG( - self.mesh, - sympy.Matrix.zeros(self.mesh.dim, self.mesh.dim), - self.u.sym, - transport_on_update=True, - **common, - ) - else: - self.Unknowns.DFDt = uw.systems.ddt.SemiLagrangian( - self.mesh, - sympy.Matrix.zeros(self.mesh.dim, self.mesh.dim), - self.u.sym, - **ddt_kwargs, - **common, - ) - # Stress flux = 2·viscosity·E_eff references psi_star[0] in E_eff's - # history term — without snapshot substitution the projection of - # flux→psi_star[0] becomes implicit in psi_star[0] and Min-mode at - # yield admits the wrong fixed point under timestep change. - self.Unknowns.DFDt.enable_source_snapshot() - # the history stores the model's encoding of a stress; an inflow datum - # is given as a stress and stored through the same encoding - self.Unknowns.DFDt._encode = getattr(cm, "encode_history", None) - @timing.routine_timer_decorator @memprobe.instrument("Stokes.solve") def solve( @@ -2115,31 +2158,6 @@ def solve( ("penalty", "augmented-Lagrangian grad-div penalty (0 = off)"), ) - @property - def tau(self): - r"""Deviatoric stress from the most recent solve. - - When stress history is active (VEP), returns ``psi_star[0]`` which - contains the actual projected stress; a log-conformation history is - decoded into a projected stress variable. Otherwise falls through to - the base class lazy projection. - """ - if self.Unknowns.DFDt is not None: - cm = self.constitutive_model - if getattr(cm, "_stress_history", "stress") == "stress": - return self.DFDt.psi_star[0] - if getattr(self, "_tau_decoded", None) is None: - self._tau_decoded = uw.discretisation.MeshVariable( - "tau_decoded", self.mesh, (self.mesh.dim, self.mesh.dim), - vtype=uw.VarType.SYM_TENSOR, degree=self.DFDt.psi_star[0].degree, - continuous=True, units=uw.units.Pa if uw.get_default_model().has_units() else None) - self._tau_decode = uw.systems.Tensor_Projection(self.mesh, self._tau_decoded) - self._tau_decode.smoothing = 0.0 - self._tau_decode.uw_function = cm._carried_stress_sym(0) - self._tau_decode.solve() - return self._tau_decoded - return super().tau - # ========================================================================= # PETSc Residual Templates # These define the weak form terms assembled by PETSc's finite element system. diff --git a/src/underworld3/systems/thin_sheet.py b/src/underworld3/systems/thin_sheet.py index 9ad91f3e8..a46c1ec58 100644 --- a/src/underworld3/systems/thin_sheet.py +++ b/src/underworld3/systems/thin_sheet.py @@ -49,7 +49,7 @@ from underworld3.systems import SNES_Vector from underworld3.utilities._api_tools import Template -from .solvers import _ConstitutiveModelStateMixin +from .solvers import _ConstitutiveModelStateMixin, _StressHistoryMixin from .ddt import SemiLagrangian as SemiLagrangian_DDt from .ddt import Lagrangian as Lagrangian_DDt @@ -93,7 +93,7 @@ def _tangent_projector(mesh): return sympy.eye(mesh.cdim) - n.T * n -class SNES_ThinSheet(_ConstitutiveModelStateMixin, SNES_Vector): +class SNES_ThinSheet(_StressHistoryMixin, _ConstitutiveModelStateMixin, SNES_Vector): r"""Vertically averaged thin sheet (plane stress), viscous or viscoelastic. Solves @@ -179,6 +179,50 @@ def __init__( ) self._needs_function_rewire = True self._constitutive_model = None + # Last-seen effective order of the stress history (it ramps over the + # opening steps); a change rewires the pointwise functions. + self._prev_effective_order = None + + @timing.routine_timer_decorator + def solve( + self, + zero_init_guess: bool = None, + timestep: float = None, + _force_setup: bool = False, + verbose: bool = False, + evalf: bool = False, + divergence_retries: int = 0, + ): + """Solve the sheet momentum balance; with a viscoelastic constitutive + model, carry the stress history across the step first. + + Parameters + ---------- + timestep : float + Required when the constitutive model carries a stress history: the + elastic timestep of the Maxwell element. + zero_init_guess, verbose, divergence_retries + As for :meth:`SNES_Vector.solve`. + evalf : bool, default False + Numerical evaluation during the history carry. + """ + if self.Unknowns.DFDt is None: + super().solve(zero_init_guess, _force_setup=_force_setup, verbose=verbose, + divergence_retries=divergence_retries) + return + if timestep is None: + raise ValueError( + "timestep is required for a viscoelastic sheet: call " + "sheet.solve(timestep=dt)") + self._stress_history_prepare(timestep, _force_setup=_force_setup) + if not self.is_setup: + self._setup_pointwise_functions(verbose) + self._setup_discretisation(verbose) + self._setup_solver(verbose) + self._stress_history_advance(timestep, verbose=verbose, evalf=evalf) + super().solve(zero_init_guess, _force_setup=_force_setup, verbose=verbose, + divergence_retries=divergence_retries) + self._stress_history_post_solve(timestep, verbose=verbose, evalf=evalf) # ------------------------------------------------------------------ # geometry diff --git a/tests/test_1076_thin_sheet_viscoelastic.py b/tests/test_1076_thin_sheet_viscoelastic.py new file mode 100644 index 000000000..1adddbdbf --- /dev/null +++ b/tests/test_1076_thin_sheet_viscoelastic.py @@ -0,0 +1,106 @@ +"""Maxwell viscoelastic thin sheet on a flat mesh: closed-form checks. + +Uniaxial loading at constant strain rate with free lateral edges relaxes +exactly as sigma_xx = 3 eta edot (1 - exp(-t mu / eta)): the Trouton stress +with the Maxwell time. The exponential integrator reproduces that exactly at +every step (the forcing is constant over a step); backward Euler reproduces +its own recursion exactly. Stress diffusion into an elastic sheet over a +viscous substrate (Elsasser) follows v = V0 erfc(x / sqrt(4 D t)) with +D = mu L / c, approached at first order in the timestep. +""" + +import numpy as np +import pytest +import sympy +from scipy.special import erfc + +import underworld3 as uw + +pytestmark = [pytest.mark.level_2] + +ETA, MU, EDOT = 2.0, 4.0, 0.5 # t_r = eta / mu = 0.5 + + +def _uniaxial(integrator, n_steps, dt_over_tr): + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.2, qdegree=3) + x, y = mesh.X + sheet = uw.systems.ThinSheet(mesh) + sheet.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + sheet.Unknowns, order=1, integrator=integrator) + sheet.constitutive_model.Parameters.shear_viscosity_0 = ETA + sheet.constitutive_model.Parameters.shear_modulus = MU + sheet.tolerance = 1.0e-10 + edot = sympy.Rational(1, 2) + u_y = -edot * (y - sympy.Rational(1, 2)) / 2 + sheet.add_essential_bc([0, u_y], "Left") + sheet.add_essential_bc([edot, u_y], "Right") + + t_r = ETA / MU + dt = dt_over_tr * t_r + centre = np.array([[0.5, 0.5]]) + times, sigma_xx = [], [] + for step in range(1, n_steps + 1): + sheet.solve(timestep=dt, zero_init_guess=False) + tau = uw.function.evaluate(sheet.tau.sym, centre).reshape(2, 2) + # plane stress: sigma = tau + tr(tau) I + sigma_xx.append(2.0 * tau[0, 0] + tau[1, 1]) + times.append(step * dt) + return np.array(times), np.array(sigma_xx), dt + + +@pytest.mark.tier_b +def test_exponential_integrator_matches_the_maxwell_response_exactly(): + """ETD-1 integrates the relaxation analytically, so at constant strain + rate it lands on 3 eta edot (1 - exp(-t/t_r)) at every step.""" + times, sigma, _ = _uniaxial("etd", n_steps=8, dt_over_tr=0.5) + exact = 3.0 * ETA * EDOT * (1.0 - np.exp(-times * MU / ETA)) + assert np.abs(sigma - exact).max() < 1.0e-6 * 3.0 * ETA * EDOT + + +@pytest.mark.tier_b +def test_backward_euler_follows_its_own_recursion_exactly(): + """BDF-1 on the Maxwell element: sigma_n = (sigma_{n-1} + 3 mu dt edot) / (1 + dt/t_r).""" + times, sigma, dt = _uniaxial("bdf", n_steps=8, dt_over_tr=0.5) + recursion, s = [], 0.0 + for _ in times: + s = (s + 3.0 * MU * dt * EDOT) / (1.0 + dt * MU / ETA) + recursion.append(s) + assert np.abs(sigma - np.array(recursion)).max() < 1.0e-6 * 3.0 * ETA * EDOT + + +def _elsasser(dt, t_end, V0=1.0, length=4.0, cell=0.1): + """Anti-plane shear v(x, t) in an elastic sheet (t_r >> t_end) over a + viscous substrate: v_t = D v_xx with D = mu L / c.""" + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(length, 1.0), cellSize=cell, qdegree=3) + sheet = uw.systems.ThinSheet(mesh) + sheet.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + sheet.Unknowns, order=1, integrator="etd") + sheet.constitutive_model.Parameters.shear_viscosity_0 = 1.0e6 # t_r = 1e6 >> t_end + sheet.constitutive_model.Parameters.shear_modulus = 1.0 + sheet.basal_drag = 1 # D = mu L / c = 1 + sheet.tolerance = 1.0e-10 + sheet.add_essential_bc([0, V0], "Left") + sheet.add_essential_bc([0, 0], "Right") + sheet.add_essential_bc([0, None], "Top") + sheet.add_essential_bc([0, None], "Bottom") + n_steps = int(round(t_end / dt)) + for _ in range(n_steps): + sheet.solve(timestep=dt, zero_init_guess=False) + xs = np.linspace(0.05, 2.5, 30) + pts = np.column_stack([xs, np.full_like(xs, 0.5)]) + v = uw.function.evaluate(sheet.u.sym, pts).reshape(-1, 2)[:, 1] + exact = V0 * erfc(xs / np.sqrt(4.0 * 1.0 * t_end)) + return np.abs(v - exact).max() + + +@pytest.mark.tier_b +def test_elsasser_stress_diffusion_converges_in_the_timestep(): + """The erfc front is approached at first order in dt (backward Euler in + time), and is within 5% of V0 at dt = 0.025.""" + err_coarse = _elsasser(dt=0.05, t_end=0.5) + err_fine = _elsasser(dt=0.025, t_end=0.5) + order = np.log(err_coarse / err_fine) / np.log(2.0) + assert err_fine < 0.05, f"error {err_fine:.3e} against the erfc front" + assert order > 0.8, f"timestep convergence order {order:.2f} ({err_coarse:.3e} -> {err_fine:.3e})" From 79fcce0612d048fcb7110f2010d6fc9ec81d1f9f Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Thu, 8 Oct 2026 17:48:01 +1100 Subject: [PATCH 04/57] Rotated constraints and split-node faults on any vector solver The rotated strong-constraint machinery (add_rotated_freeslip_bc, add_fault_bc, the constraint-mechanism guard, the reaction recoveries and the solve dispatch) lived on the saddle-point class. It moves to SolverBaseClass as _solve_rotated_if_registered, called by the Stokes solve and now by SNES_Vector.solve, so a thin sheet takes a split-node fault the way Stokes does. SNES_Vector registers its one field under the name the rotated path addresses it by; rotated_bc gates its pressure sites on the presence of a pressure field and solves the rotated operator of a vector solver with one KSP (custom-prolongation FMG when a hierarchy exists, else the shared GAMG bundle at the solver's block size). Tests (thin viscous sheet, plane-stress elasticity with nu = 1/2): a frictionless fault along or across a uniaxial stretch carries no shear traction and leaves the exact solution untouched (nodal 1e-9); a viscous interface approaches the welded sheet at first order in 1/eta_f; a frictionless fault under remote shear slips as the Griffith mode-II crack, (4 tau / E') sqrt(a^2 - x^2) with E' = 3 eta, within 8% (2 x 2 box, a = 0.2, h = 1/25). Stokes regression on the moved code: 22 rotated/fault tests and 17 viscoelastic tests unchanged. Underworld development team with AI support from Claude Code --- .../cython/petsc_generic_snes_solvers.pyx | 1460 +++++++++-------- src/underworld3/utilities/rotated_bc.py | 110 +- tests/test_1077_thin_sheet_faults.py | 130 ++ 3 files changed, 978 insertions(+), 722 deletions(-) create mode 100644 tests/test_1077_thin_sheet_faults.py diff --git a/src/underworld3/cython/petsc_generic_snes_solvers.pyx b/src/underworld3/cython/petsc_generic_snes_solvers.pyx index e1a9ae447..ecd187d49 100644 --- a/src/underworld3/cython/petsc_generic_snes_solvers.pyx +++ b/src/underworld3/cython/petsc_generic_snes_solvers.pyx @@ -212,6 +212,25 @@ class SolverBaseClass(uw_object): self._velocity_is = None self._pressure_is = None self._subdict = {} + # Rotated strong free-slip BCs: [(boundary, normal), ...]. Registered via + # add_rotated_freeslip_bc; when non-empty, solve() delegates to + # underworld3.utilities.rotated_bc (per-node DOF rotation + strong v_n = u_n + # + reaction = sigma_nn). Empty by default → the solve path is unchanged. + # _rotated_freeslip_datum maps boundary → prescribed wall-normal datum + # (the non-zero `conds` of add_rotated_freeslip_bc; absent ⇒ u.n = 0). + self._rotated_freeslip_bcs = [] + self._rotated_freeslip_datum = {} + self._rotated_freeslip_info = None + # Cross-solve rotated workspace (rotated_bc cache, issue #417): + # populated/keyed/invalidated entirely inside solve_rotated_freeslip; + # torn down here by _reset_rotated_solver_cache on any DM rebuild. + self._rotated_linear_cache = None + # Split-fault interface conditions (add_fault_bc): fault names whose + # coincident DOF pairs carry a contact (the laws themselves live in + # _fault_interface_laws, set lazily by utilities/fault_contact.py). + # Non-empty => solve() takes the rotated path, where fault_contact + # supplies the pair blocks and the interface operator. + self._fault_contact_faults = [] # Per-Newton-iteration callbacks (PETSc SNESSetUpdate). Empty by default # -> no hook is installed and the solve path is unchanged. See @@ -3712,140 +3731,541 @@ class SolverBaseClass(uw_object): ## Specific to dimensionality -class SNES_Scalar(SolverBaseClass): - r""" - General scalar equation solver using PETSc SNES. - - Solves the scalar conservation problem for unknown :math:`u`: - - .. math:: - - \nabla \cdot \mathbf{F}(u, \nabla u, \dot{u}, \nabla\dot{u}) - - f(u, \nabla u, \dot{u}, \nabla\dot{u}) = 0 - - where :math:`f` is a source term, :math:`\mathbf{F}` is a flux term relating - :math:`u` to its gradients :math:`\nabla u`, and :math:`\dot{u}` is the - Lagrangian time derivative. - - The unknown :math:`u` is a scalar mesh variable, and :math:`f`, :math:`\mathbf{F}` - are arbitrary sympy expressions of mesh coordinate variables. + def _solve_rotated_if_registered(self, zero_init_guess, verbose, picard, time): + """Run the rotated strong-constraint solve when a rotated free-slip + boundary or a split-fault contact is registered, and return True; return + False when neither is, so the caller continues on the standard SNES path. - This class is the base layer for building solvers that translate physical - conservation laws into this general mathematical form. + Shared by the saddle-point (Stokes) and vector (thin sheet) solvers: the + rotation, the Newton loop and the reaction recovery live in + ``utilities/rotated_bc.py`` and address the velocity field by name. + """ + cdef DM _time_dm + if not (self._rotated_freeslip_bcs or self._fault_contact_faults): + return False + # Rotated strong free-slip: delegate to the rotated_bc module (per-node DOF + # rotation + strong v_n=0 + reaction=sigma_nn). Handles the whole assemble/ + # solve/rotate-back/gauge-removal; stashes info for boundary_normal_traction. + # Run the same pre-solve preamble as the standard path so the pointwise + # functions see the DM time, the auxiliary vector, and updated constants + # (needed for problems whose coefficients live in auxiliary fields). This + # must precede the nonlinearity probe below so the trial assemblies see + # the correct coefficients. + if time is not None: + t_nd = self._nondimensional_time(time) + _time_dm = self.dm + UW_DMSetTime(_time_dm.dm, t_nd) + self.mesh.update_lvec() + self.dm.setAuxiliaryVec(self.mesh.lvec, None) + self._update_constants(record=True) - Parameters - ---------- - mesh : underworld3.discretisation.Mesh - The computational mesh. - u_Field : MeshVariable, optional - Pre-existing scalar field variable. If None, creates a new variable. - degree : int, default=2 - Polynomial degree for finite element discretization. - verbose : bool, default=False - Enable verbose solver output (monitors convergence). - DuDt : SemiLagrangian or Lagrangian, optional - Time derivative handler for the unknown (advection-diffusion problems). - DFDt : SemiLagrangian or Lagrangian, optional - Time derivative handler for flux (viscoelastic problems). + # guard() refuses rotated free-slip, but the BC can be added AFTER arming. + # Re-check here: this path never reaches the instrumentation, so an armed + # guard would be silently inert -- the exact state guard() exists to refuse. + if (getattr(self, "_instrumentation", None) is not None + and self._instrumentation.armed): + raise NotImplementedError( + "a wall-clock guard is armed but this solve takes the rotated " + "free-slip path, which runs its own Krylov loop outside self.snes " + "and cannot be bounded by it. Call unguard() first." + ) + # ONE path for linear and nonlinear models: the manual outer + # Newton/Picard loop that rotates F(u), J(u) and the strong + # v_n = u_n constraint every iteration. It honours zero_init_guess + # (warm start), the Picard warmup count, and the consistent_jacobian + # tangent (Picard / Newton / continuation); a linear model converges + # after its first increment, so no up-front nonlinearity probe is + # paid (the loop self-terminates; _residual_is_nonlinear survives as + # a lazy guard inside the picard+pure-Newton corner). + from underworld3.utilities.rotated_bc import solve_rotated_freeslip + self._rotated_freeslip_info = solve_rotated_freeslip( + self, self._rotated_freeslip_bcs, verbose=verbose, + zero_init_guess=zero_init_guess, picard=picard, + # An explicit `time=` reaches the kernels through petsc_t on + # the DM — invisible to every state counter and constant + # value, so it must veto the cached-operator fast path. + force_operator_refresh=time is not None) + # This path solves via ksp.solve on the rotated operator (not self.snes), + # so give it a report from the rotated result rather than leaving a stale one. + _rotated_report = self._capture_rotated_report(self._rotated_freeslip_info) + # The warm-start flag must follow the rotated solve's own verdict — the SNES + # was never run, so the generic reader would latch a stale reason. + self._record_convergence_status(converged=_rotated_report.converged) + return True - Attributes - ---------- - u : MeshVariable - The scalar unknown being solved for. - F0 : UWexpression - Source/force term :math:`f`. - F1 : UWexpression - Flux term :math:`\mathbf{F}`. - constitutive_model : Constitutive_Model - Material model defining flux-gradient relationship. - tolerance : float - Solver convergence tolerance. + def _reject_mixed_constraint_mechanisms(self, adding): + """Refuse a rotated constraint and a block constraint on one solver (#464). - Examples - -------- - >>> import underworld3 as uw - >>> mesh = uw.meshing.StructuredQuadBox(elementRes=(16, 16)) - >>> poisson = uw.systems.Poisson(mesh) - >>> poisson.constitutive_model = uw.constitutive_models.DiffusionModel - >>> poisson.constitutive_model.Parameters.diffusivity = 1.0 - >>> poisson.f = 1.0 # Source term - >>> poisson.add_dirichlet_bc(0.0, "Bottom") - >>> poisson.solve() + The rotated driver builds its own index-set fieldsplit over exactly two + fields (``rotated_bc._solve_rotated_iterative``), and ``build_rotation`` + addresses velocity and pressure by field number. A block constraint + registers a multiplier field of its own, and those DOFs are in neither + index set — the preconditioner would then be built over a strict subset + of the operator's rows, with nothing said about it. - See Also - -------- - SNES_Vector : For vector-valued equations. - SNES_Stokes_SaddlePt : For coupled velocity-pressure (Stokes) problems. - """ + The two are alternative ways to impose the same wall-normal condition, + so asking for both is a configuration error rather than a case to + support. Supporting it would need a third split for the multipliers and + a ``build_rotation`` that knows about them. - @timing.routine_timer_decorator - def __init__(self, - mesh : uw.discretisation.Mesh, - u_Field : uw.discretisation.MeshVariable = None, - degree: int = 2, - verbose = False, - DuDt : Union[uw.systems.ddt.SemiLagrangian, uw.systems.ddt.Lagrangian] = None, - DFDt : Union[uw.systems.ddt.SemiLagrangian, uw.systems.ddt.Lagrangian] = None, - ): + Parameters + ---------- + adding : str + Name of the method being called, so the message can say which + mechanism is already in place and which one was refused. + """ - super().__init__(mesh) + mechanisms = self._constraint_mechanisms() + rotated = mechanisms["rotated_freeslip"] + mechanisms["fault_contact"] + multipliers = mechanisms["multipliers"] - ## Keep track + if adding == "solve": + # The dispatch reads both lists, so it can only report the pair. + if not (rotated and multipliers): + return + raise RuntimeError( + f"solve(): this solver carries {len(rotated)} rotated " + f"(free-slip or fault contact) and {len(multipliers)} " + f"block-constraint boundary condition(s). The rotated solve " + f"splits velocity and pressure by field number and the " + f"multiplier fields lie outside that split, so the " + f"preconditioner would cover only part of the operator. Both " + f"impose the same wall-normal condition — use one of them " + f"(issue #464)." + ) - ## Todo: some validity checking on the size / type of u_Field supplied - if u_Field is None: - # A scalar unknown has one component. (MeshVariable already forced - # this: vtype=SCALAR overrides num_components, so the old - # num_components=mesh.dim here was ignored, never over-allocated — - # issue #367.) - self.Unknowns.u = uw.discretisation.MeshVariable( mesh=mesh, num_components=1, - varname="Us{}".format(SNES_Scalar._obj_count), - vtype=uw.VarType.SCALAR, degree=degree, ) + if adding == "add_constraint_bc": + if not rotated: + return + present, refused = len(rotated), "a block constraint" + present_kind = "rotated (free-slip or fault contact)" else: - self.Unknowns.u = u_Field - self.Unknowns.DuDt = DuDt - self.Unknowns.DFDt = DFDt - - self.verbose = verbose - self._tolerance = 1.0e-4 - - # Here we can set some defaults for this set of KSP / SNES solvers - - ## FAST as possible for simple problems: - ## MG, - - # ROBUST and general GAMG, heavy-duty solvers in the suite + if not multipliers: + return + present, refused = len(multipliers), "a rotated constraint" + present_kind = "block-constraint multiplier" - # Participate in the auto FMG/GAMG switch (see the `preconditioner` - # property). The pc/mg keys below are the GAMG default; - # _apply_preconditioner_options() upgrades this block to geometric FMG - # at build time when the mesh carries a refinement hierarchy, and - # otherwise leaves these defaults in place. - self._pc_option_prefix = "" + raise RuntimeError( + f"{adding}(): this solver already carries {present} " + f"{present_kind} boundary condition(s), so it cannot also take " + f"{refused}. The rotated solve splits velocity and pressure by " + f"field number and a block constraint adds a multiplier field " + f"outside that split, so the preconditioner would cover only part " + f"of the operator. Both impose the same wall-normal condition — " + f"use one of them (issue #464)." + ) - self.petsc_options["snes_type"] = "newtonls" - self._push_managed_option("ksp_type", "gmres") - for key, value in multigrid_options.gamg_bundle().settings.items(): - self._push_managed_option(key, value) + def add_rotated_freeslip_bc(self, conds=None, boundary=None, normal=None): + r"""Add STRONG free-slip (:math:`\mathbf{u}\cdot\hat{\mathbf n}=0`) by rotating + the boundary velocity DOFs into a per-node (normal, tangential) frame and + imposing the rotated normal component as an exact Dirichlet constraint. - self.petsc_options["snes_rtol"] = 1.0e-4 + Unlike Nitsche/penalty free-slip (weak, leaks :math:`\mathcal O(10^{-3})`), + this enforces zero wall-normal flow to machine precision, and the constraint + **reaction** is the consistent boundary normal traction + :math:`\sigma_{nn}` (see :meth:`boundary_normal_traction`) with no + augmented-Lagrangian splitting. Correct on deformed / tilted / curved + boundaries because the normal is taken per node. - if self.verbose == True: - self.petsc_options["ksp_monitor"] = None - self.petsc_options["snes_converged_reason"] = None - self.petsc_options["snes_monitor_short"] = None - else: - self.petsc_options.delValue("ksp_monitor") - self.petsc_options.delValue("snes_monitor") - self.petsc_options.delValue("snes_monitor_short") - self.petsc_options.delValue("snes_converged_reason") + Parameters + ---------- + conds : float, scalar sympy expression, or None, optional + Prescribed wall-normal velocity datum :math:`\tilde u_n` (the SCALAR + component along the outward normal), in the canonical value-first BC + order (Style Charter, API conventions). Zero (or ``None``) is pure + free-slip :math:`\mathbf{u}\cdot\hat{\mathbf n}=0`. A non-zero value + — a number, a sympy expression of ``mesh.X``, or a scalar field read + such as ``h_dot.sym[0]`` — is imposed strongly (machine precision) as + :math:`\mathbf{u}\cdot\hat{\mathbf n}=\tilde u_n`, evaluated at the + boundary nodes at each ``solve()``. On an enclosed boundary the + datum must be discretely flux-free (:math:`\oint \tilde u_n = 0`) + for incompressibility. A corner/edge node shared between rotated + boundaries has no single normal and stays at the free-slip pinning + (datum ignored there). Vector/matrix values are rejected. + boundary : str + Boundary label to constrain. + normal : None or sympy 1×dim Matrix or array, optional + Per-node outward normal source. ``None`` (the default, and normally + the right choice) uses the geometric facet normal, measure-weighted + so that it is consistent with the straight-facet boundary integral + the assembler evaluates. A sympy ``1×dim`` matrix supplies an + analytic normal (``X/|X|`` on a spherical cap, a constant on a + planar face): exact for the TRUE surface, but the assembler still + integrates over the facets, so it keeps a consistency error that + grows with facet non-uniformity. Use it when the constraint must + follow the geometry rather than the mesh. A constant array is also + accepted. See ``docs/developer/subsystems/rotated-freeslip.md``. - self.dm = None + Notes + ----- + A node shared by several rotated-free-slip boundaries (a box corner, a 3D + edge) is constrained on the whole span of its accumulated normals: a 3D + face frees two tangential directions, a 3D edge frees one (the edge + tangent), a corner is fully pinned. Registering delegates the solve to + :mod:`underworld3.utilities.rotated_bc`. + The legacy boundary-first call ``add_rotated_freeslip_bc(boundary, + normal)`` is detected conservatively (the first positional argument is + a string — the datum is never a string) and shimmed with one + DeprecationWarning: the string becomes ``boundary`` and a second + positional argument, if present, becomes ``normal``. + """ + self._reject_mixed_constraint_mechanisms("add_rotated_freeslip_bc") - self.essential_bcs = [] - self.natural_bcs = [] - self.bcs = self.essential_bcs + if isinstance(conds, str): + # legacy boundary-first call: (boundary[, normal]) + if boundary is not None: + if normal is not None: + raise TypeError( + "add_rotated_freeslip_bc() received 'normal' twice " + "(positionally, legacy order, and as a keyword)") + normal = boundary + boundary = conds + conds = None + import warnings + warnings.warn( + "add_rotated_freeslip_bc(boundary, normal) is deprecated; " + "use add_rotated_freeslip_bc(conds, boundary, normal=...) " + "with conds=0 (or boundary=... by keyword)", + DeprecationWarning, stacklevel=2) + if not isinstance(boundary, str): + raise TypeError( + f"add_rotated_freeslip_bc() requires a boundary label string; " + f"got {type(boundary).__name__}") + if conds is not None: + if isinstance(conds, (sympy.MatrixBase, list, tuple, np.ndarray)): + raise TypeError( + "add_rotated_freeslip_bc(conds=...) takes the SCALAR " + "wall-normal datum u.n; got a vector/matrix value") + # Value comparison, not sympy's structural ==: Float(0.0) != Integer(0) + # structurally, but both are zero data (the free-slip member of the + # family). is_zero is True only when sympy can PROVE zero, so a + # symbolic datum (field read, expression) is correctly kept. + if sympy.sympify(conds).is_zero is not True: + self._rotated_freeslip_datum[boundary] = conds + self._rotated_freeslip_bcs.append((boundary, normal)) + self.is_setup = False + return + + def add_fault_bc(self, conds=0, boundary=None, normal=None): + r"""Interface condition on a split-node fault (value-first). + + ``boundary`` names a fault split by ``Mesh.add_fault`` (or + ``fault_split.split_fault``); the mesh carries its coincident DOF + pairing. The no-opening constraint :math:`[\mathbf v]\cdot\hat n = 0` + is always imposed strongly; ``conds`` sets the tangential law: + + * ``conds = 0`` — frictionless (perfectly slippery): zero shear + traction, the slip emerges (the stress-driven crack). + * ``conds`` > 0 — viscous interface :math:`\tau = \eta_f V` with + ``conds`` = :math:`\eta_f` (viscosity per unit length; the + zero-thickness limit of a band is :math:`\eta_f = \eta_{band}/w`, + where :math:`\eta_{band}` is the band's OWN weak-zone viscosity — + not the background's — and :math:`w` its width). ``conds`` large + removes the fault (only the JUMP is penalised — nothing becomes + rigid). + + ``normal`` optionally supplies the fault's smooth unit normal — + a sympy ``1×dim`` Matrix in ``mesh.X`` (same conventions as + :meth:`add_rotated_freeslip_bc`), the string ``"trace"`` (2-D: the + smoothed normal is built from the fault's own stored polyline — + the right choice for digitized traces with no analytic formula), + or a constant ``(dim,)`` array. Use it whenever the fault trace is + a SAMPLED SMOOTH CURVE: the default per-node normal averages the + adjacent facet normals, which zig-zags at the sampling kinks, and + the no-opening constraint then forbids smooth slip past each kink — + slip notches and normal-traction sawteeth that GROW under mesh + refinement. The smooth normal restores the smooth curve's + mechanics on the same polyline mesh. On a straight fault the + default is already exact, and a deliberately KINKED fault should + NOT be smoothed — there the kink response is the physics. + + The solve then takes the rotated strong-constraint path + (``utilities/rotated_bc.py`` with the pair blocks of + ``utilities/fault_contact.py``); ``guard()`` / + ``estimate_difficulty()`` are unavailable, as for rotated free-slip. + Slip and leak per coincident pair afterwards: + ``fault_contact.fault_slip(solver, boundary, + solver._rotated_freeslip_info)``. + """ + from underworld3.utilities import fault_contact + + self._reject_mixed_constraint_mechanisms("add_fault_bc") + + if not isinstance(boundary, str): + raise TypeError( + f"add_fault_bc() requires the fault's boundary name string; " + f"got {type(boundary).__name__}") + eta_f = float(conds) + if eta_f == 0.0: + fault_contact.add_frictionless_fault_bc(self, boundary, + normal=normal) + else: + fault_contact.add_viscous_fault_bc(self, eta_f, boundary, + normal=normal) + self.is_setup = False + return + + def _residual_is_nonlinear(self, tol=1e-8): + r"""True if the Stokes residual is nonlinear in the unknowns :math:`(v, p)` + — i.e. the assembled Jacobian depends on the solution, so Newton / Picard + iteration is required. + + Detected by a NUMERICAL probe: assemble the Jacobian at two distinct + velocity states and compare. A symbolic test on ``F1.sym`` cannot see the + nonlinearity — the effective viscosity's strain-rate (velocity-gradient) + dependence is carried as a JIT-substituted *placeholder* symbol + (``\dot\varepsilon_{II}``) in the flux, decoupled from the gradient + ``L`` in the symbolic form, so it only becomes visible once the operator + is assembled at a concrete iterate. Constant- or temperature-dependent + viscosity ⇒ ``J`` independent of ``v`` ⇒ the two assemblies are + bit-identical ⇒ linear. + + Used as a LAZY guard in the rotated-free-slip loop's picard + pure-Newton + corner (a Picard warmup is meaningless for a linear residual and impossible + for pure Newton) — the loop itself needs no up-front probe, it + self-terminates. The caller must have run the pre-solve preamble + (auxiliary vector + constants) so the assembly sees the correct + coefficients. + """ + snes = self.snes + dm = self.dm + snes.setUp() + J = snes.getJacobian()[0] + U1 = dm.getGlobalVec(); U2 = dm.getGlobalVec() + # two distinct smooth, bounded states with non-zero velocity gradients + # (a linear operator gives the SAME J for both; only a solution-dependent + # viscosity makes them differ). Ownership-relative index keeps it + # partition-independent enough for the norm comparison. + rs, re = U1.getOwnershipRange() + idx = np.arange(rs, re, dtype=float) + # write IN PLACE into each vec's PETSc-owned array (getArray returns a writable + # view). setArray with a fresh numpy temporary on a POOLED getGlobalVec is risky: + # its storage lifetime/pool reuse is not guaranteed for the later computeJacobian. + a1 = U1.getArray(); a1[:] = 0.1 * np.sin(0.7 * idx + 0.3) + a2 = U2.getArray(); a2[:] = 0.1 * np.sin(1.3 * idx + 1.1) + J1 = J.copy(); J2 = J.copy() + try: + snes.computeJacobian(U1, J1) + snes.computeJacobian(U2, J2) + J2.axpy(-1.0, J1) # J2 <- J(U2) - J(U1) + rel = J2.norm() / (J1.norm() + 1e-300) + finally: + dm.restoreGlobalVec(U1); dm.restoreGlobalVec(U2) + J1.destroy(); J2.destroy() + return rel > tol + + def boundary_normal_traction(self, boundary, mass="auto"): + r"""Return the boundary normal traction :math:`\sigma_{nn}` on a + rotated-free-slip ``boundary`` as the constraint reaction from the last + solve — the smooth, bounded quantity used for dynamic topography + (:math:`h_\infty=-(\sigma_{nn}-\overline{\sigma_{nn}})/\rho g`). Requires a + prior :meth:`add_rotated_freeslip_bc` on ``boundary`` and a completed + :meth:`solve`. + + ``mass="auto"`` (default) uses lumped recovery for 2D traces and 3D P1 + triangles, and MIDPOINT-RECONSTRUCTED recovery for 3D P2 triangles (the + consistent solve, keeping its superconvergent midpoints, with vertices rebuilt + from them). ``"p1"`` selects the simpler P1-projected recovery; explicit + ``"lumped"`` and ``"consistent"`` remain available where mathematically valid; + ``"consistent"`` is pointwise-exact on a P2 trace in exact arithmetic but + its zero vertex row sums amplify any load perturbation at VERTICES by O(1), + independently of h (#404, measured in #633). Three-dimensional recovery + currently supports triangular P1/P2 traces only (#637). + + .. warning:: + On CURVED boundaries, P2 vertex values of :math:`\sigma_{nn}` converge + only slowly (the vertex basis has zero surface mean, so vertex reactions + carry only the O(h) facet-geometry error); edge-midpoint values are + superconvergent. Pointwise consumers on curved boundaries should use + midpoint or integral/fitted quantities (issue #414).""" + if self._rotated_freeslip_info is None: + raise RuntimeError( + "boundary_normal_traction requires a completed rotated-free-slip solve.") + from underworld3.utilities.rotated_bc import boundary_normal_traction as _bnt + return _bnt(self, boundary, self._rotated_freeslip_info, mass=mass) + + def dynamic_topography(self, boundary, field, buoyancy_scale=1.0, mass="auto"): + r"""Write the dynamic topography + :math:`h = -(\sigma_{nn}-\overline{\sigma_{nn}})/(\Delta\rho\,g)` on a + rotated-free-slip ``boundary`` onto a scalar MeshVariable ``field``, from the + constraint reaction of the last solve. This is the hand-off to the free-surface + machinery — the 3-number topography integrator drives node motion from a surface + field, so create a scalar ``field`` (P1 recommended, continuous) up front and + pass it here after each :meth:`solve`; its boundary nodes are filled and the + interior left untouched. + + ``buoyancy_scale`` is :math:`\Delta\rho\,g` (traction → length). + ``mass="auto"`` selects lumped recovery where valid and + midpoint-reconstructed recovery for 3D P2 triangles. Requires a prior + :meth:`add_rotated_freeslip_bc` on ``boundary`` and a completed :meth:`solve`. + + .. warning:: + On CURVED boundaries (annulus/spherical free surfaces), the P2 VERTEX + values written into ``field`` converge only slowly; edge-midpoint values + are superconvergent. Downstream pointwise use of curved-boundary + topography should rely on midpoint/fitted quantities (issue #414).""" + if self._rotated_freeslip_info is None: + raise RuntimeError( + "dynamic_topography requires a completed rotated-free-slip solve.") + from underworld3.utilities.rotated_bc import dynamic_topography_field as _dtf + return _dtf(self, boundary, self._rotated_freeslip_info, field, + buoyancy_scale=buoyancy_scale, mass=mass) + + def boundary_normal_traction_integral(self, boundary, fn, remove_mean=True): + r"""Return the boundary integral of ``sigma_nn * fn`` directly from the + rotated-free-slip constraint reaction. + + With ``remove_mean=True`` (default), the constant normal-traction gauge + is removed before projection. Unlike pointwise + :meth:`boundary_normal_traction`, this weak projection does not recover + nodal traction values or gather a global boundary mesh. It is therefore + suitable for harmonic and integral diagnostics on curved P2 boundaries, + whose recovered vertex values converge slowly (issue #414). + """ + if self._rotated_freeslip_info is None: + raise RuntimeError( + "boundary_normal_traction_integral requires a completed " + "rotated-free-slip solve.") + from underworld3.utilities.rotated_bc import boundary_normal_traction_integral as _bnti + return _bnti(self, boundary, self._rotated_freeslip_info, fn, + remove_mean=remove_mean) + + +class SNES_Scalar(SolverBaseClass): + r""" + General scalar equation solver using PETSc SNES. + + Solves the scalar conservation problem for unknown :math:`u`: + + .. math:: + + \nabla \cdot \mathbf{F}(u, \nabla u, \dot{u}, \nabla\dot{u}) + - f(u, \nabla u, \dot{u}, \nabla\dot{u}) = 0 + + where :math:`f` is a source term, :math:`\mathbf{F}` is a flux term relating + :math:`u` to its gradients :math:`\nabla u`, and :math:`\dot{u}` is the + Lagrangian time derivative. + + The unknown :math:`u` is a scalar mesh variable, and :math:`f`, :math:`\mathbf{F}` + are arbitrary sympy expressions of mesh coordinate variables. + + This class is the base layer for building solvers that translate physical + conservation laws into this general mathematical form. + + Parameters + ---------- + mesh : underworld3.discretisation.Mesh + The computational mesh. + u_Field : MeshVariable, optional + Pre-existing scalar field variable. If None, creates a new variable. + degree : int, default=2 + Polynomial degree for finite element discretization. + verbose : bool, default=False + Enable verbose solver output (monitors convergence). + DuDt : SemiLagrangian or Lagrangian, optional + Time derivative handler for the unknown (advection-diffusion problems). + DFDt : SemiLagrangian or Lagrangian, optional + Time derivative handler for flux (viscoelastic problems). + + Attributes + ---------- + u : MeshVariable + The scalar unknown being solved for. + F0 : UWexpression + Source/force term :math:`f`. + F1 : UWexpression + Flux term :math:`\mathbf{F}`. + constitutive_model : Constitutive_Model + Material model defining flux-gradient relationship. + tolerance : float + Solver convergence tolerance. + + Examples + -------- + >>> import underworld3 as uw + >>> mesh = uw.meshing.StructuredQuadBox(elementRes=(16, 16)) + >>> poisson = uw.systems.Poisson(mesh) + >>> poisson.constitutive_model = uw.constitutive_models.DiffusionModel + >>> poisson.constitutive_model.Parameters.diffusivity = 1.0 + >>> poisson.f = 1.0 # Source term + >>> poisson.add_dirichlet_bc(0.0, "Bottom") + >>> poisson.solve() + + See Also + -------- + SNES_Vector : For vector-valued equations. + SNES_Stokes_SaddlePt : For coupled velocity-pressure (Stokes) problems. + """ + + @timing.routine_timer_decorator + def __init__(self, + mesh : uw.discretisation.Mesh, + u_Field : uw.discretisation.MeshVariable = None, + degree: int = 2, + verbose = False, + DuDt : Union[uw.systems.ddt.SemiLagrangian, uw.systems.ddt.Lagrangian] = None, + DFDt : Union[uw.systems.ddt.SemiLagrangian, uw.systems.ddt.Lagrangian] = None, + ): + + super().__init__(mesh) + + ## Keep track + + ## Todo: some validity checking on the size / type of u_Field supplied + if u_Field is None: + # A scalar unknown has one component. (MeshVariable already forced + # this: vtype=SCALAR overrides num_components, so the old + # num_components=mesh.dim here was ignored, never over-allocated — + # issue #367.) + self.Unknowns.u = uw.discretisation.MeshVariable( mesh=mesh, num_components=1, + varname="Us{}".format(SNES_Scalar._obj_count), + vtype=uw.VarType.SCALAR, degree=degree, ) + else: + self.Unknowns.u = u_Field + self.Unknowns.DuDt = DuDt + self.Unknowns.DFDt = DFDt + + self.verbose = verbose + self._tolerance = 1.0e-4 + + # Here we can set some defaults for this set of KSP / SNES solvers + + ## FAST as possible for simple problems: + ## MG, + + # ROBUST and general GAMG, heavy-duty solvers in the suite + + # Participate in the auto FMG/GAMG switch (see the `preconditioner` + # property). The pc/mg keys below are the GAMG default; + # _apply_preconditioner_options() upgrades this block to geometric FMG + # at build time when the mesh carries a refinement hierarchy, and + # otherwise leaves these defaults in place. + self._pc_option_prefix = "" + + self.petsc_options["snes_type"] = "newtonls" + self._push_managed_option("ksp_type", "gmres") + for key, value in multigrid_options.gamg_bundle().settings.items(): + self._push_managed_option(key, value) + + self.petsc_options["snes_rtol"] = 1.0e-4 + + if self.verbose == True: + self.petsc_options["ksp_monitor"] = None + self.petsc_options["snes_converged_reason"] = None + self.petsc_options["snes_monitor_short"] = None + else: + self.petsc_options.delValue("ksp_monitor") + self.petsc_options.delValue("snes_monitor") + self.petsc_options.delValue("snes_monitor_short") + self.petsc_options.delValue("snes_converged_reason") + + self.dm = None + + + self.essential_bcs = [] + self.natural_bcs = [] + self.bcs = self.essential_bcs self.boundary_conditions = False self.verbose = verbose @@ -5126,6 +5546,15 @@ class SNES_Vector(SolverBaseClass): self.essential_bcs[index] = self.essential_bcs[index]._replace(PETScID=bc, boundary_label_val=value) + # The single field, registered AFTER the boundaries are on the DM (the + # decomposition finalises the DM, and DMAddBoundary refuses afterwards), + # under the name the rotated solve and the copy-back address it by — + # the saddle solver names its fields the same way. The index set is the + # whole vector; the sub-DM carries its local/global scatter. + names, isets, dms = self.dm.createFieldDecomposition() + self.fields = {"velocity": self.u} + self._subdict = {"velocity": (isets[0], dms[0])} + for coarse_dm in self.dm_hierarchy: self.dm.copyFields(coarse_dm) self.dm.copyDS(coarse_dm) @@ -5569,6 +5998,8 @@ class SNES_Vector(SolverBaseClass): zero_init_guess = self._resolve_zero_init_guess(zero_init_guess) self._build(verbose, debug, debug_name) + if self._solve_rotated_if_registered(zero_init_guess, verbose, 0, None): + return gvec = self.dm.getGlobalVec() @@ -6395,592 +6826,235 @@ class SNES_Stokes_SaddlePt(SolverBaseClass): super().__init__(owning_solver) inner_self._p = None - inner_self._DpDt = None - inner_self._DFpDt = None - - return - - @property - def p(inner_self): - return inner_self._p - - @p.setter - def p(inner_self, new_p): - inner_self._p = new_p - inner_self._owning_solver.is_setup = False - return - - - @timing.routine_timer_decorator - def __init__(self, - mesh : underworld3.discretisation.Mesh, - velocityField : Optional[underworld3.discretisation.MeshVariable] = None, - pressureField : Optional[underworld3.discretisation.MeshVariable] = None, - degree : Optional[int] = 2, - p_continuous : Optional[bool] = True, - verbose : Optional[bool] =False, - DuDt : Union[uw.systems.ddt.SemiLagrangian, uw.systems.ddt.Lagrangian] = None, - DFDt : Union[uw.systems.ddt.SemiLagrangian, uw.systems.ddt.Lagrangian] = None, - ): - - - super().__init__(mesh) - - self.verbose = verbose - self.dm = None - - self.Unknowns.u = velocityField - self.Unknowns.p = pressureField - self.Unknowns.DuDt = DuDt - self.Unknowns.DFDt = DFDt - - # Optional saddle-point Lagrange multiplier fields (block-constrained - # Stokes). Each entry is a full-domain scalar MeshVariable registered - # as an extra DM field (id 2, 3, ...), grouped with pressure into the - # Schur split. EMPTY for ordinary Stokes — every multiplier-aware code - # path below is guarded by `if self._multipliers:` so that with no - # multiplier the emitted DS is bit-identical to the 2-field solver. - # _multiplier_screening[k] is the interior screening coefficient - # (eps M de-singularises the interior h block); see - # docs/developer/design/CONSTRAINED_FREESLIP_MULTIPLIER.md. - self._multipliers = [] - self._multiplier_screening = [] - self._block_constraint_bcs = [] - # Rotated strong free-slip BCs: [(boundary, normal), ...]. Registered via - # add_rotated_freeslip_bc; when non-empty, solve() delegates to - # underworld3.utilities.rotated_bc (per-node DOF rotation + strong v_n = u_n - # + reaction = sigma_nn). Empty by default → the solve path is unchanged. - # _rotated_freeslip_datum maps boundary → prescribed wall-normal datum - # (the non-zero `conds` of add_rotated_freeslip_bc; absent ⇒ u.n = 0). - self._rotated_freeslip_bcs = [] - self._rotated_freeslip_datum = {} - self._rotated_freeslip_info = None - # Cross-solve rotated workspace (rotated_bc cache, issue #417): - # populated/keyed/invalidated entirely inside solve_rotated_freeslip; - # torn down here by _reset_rotated_solver_cache on any DM rebuild. - self._rotated_linear_cache = None - # Split-fault interface conditions (add_fault_bc): fault names whose - # coincident DOF pairs carry a contact (the laws themselves live in - # _fault_interface_laws, set lazily by utilities/fault_contact.py). - # Non-empty => solve() takes the rotated path, where fault_contact - # supplies the pair blocks and the interface operator. - self._fault_contact_faults = [] - # Give the Lagrange-multiplier (lambda) block its own viscosity-scaled - # Schur preconditioner. The constraint Schur complement S_lambda = C A^-1 C^T - # scales as 1/mu (since A ~ mu K), exactly like the pressure Schur S_p ~ mu^-1 M_p - # and independent of the augmentation r. When True, the lambda block's - # PRECONDITIONER mass (Pmat only) uses 1/mu (= saddle_preconditioner) while the - # true operator (Amat) (lambda,lambda) block stays = eps (exact Newton). This is - # the boundary-trace analog of _pp_G0 = 1/mu on pressure, and decouples - # convergence from r so r can shrink -> cleaner recovered lambda (= topography). - # When on, the lambda block reuses pressure's already-compiled _pp_G0 = 1/mu - # for its preconditioner block (same scaling, no extra JIT term). On uniform mu - # (fieldsplit) it is bit-identical; on moderate contrast it cracks the wall and - # makes the augmentation optional. Default OFF (opt-in): a monolithic lu solve - # factorizes the Pmat (pc_use_amat is a no-op here), so this Pmat term is NOT - # inert for direct solves — flipping the default on regresses the direct-lu - # constraint enforcement. See the design note - # docs/developer/design/CONSTRAINED_FREESLIP_MULTIPLIER.md (the warning on the - # coupled vs sub-block preconditioner placement). - self._multiplier_schur_pc = False - # Pin interior (off-boundary) multiplier DOFs to 0 so the solved [p,h] - # block carries only the boundary trace (~√ndof instead of ~ndof/3 DOFs); - # the boundary trace is the only physical part. Done by constraining the - # interior h DOFs directly in the fine local PetscSection - # (_constrain_interior_multipliers_in_section) — lossless (correct - # constraint-boundary closure) and refined/FMG-safe (no DMAddBoundary - # ordering restriction). Default ON: it is both a correctness-neutral DOF - # reduction AND a conditioning win — leaving the interior h DOFs in place - # keeps the near-singular ε-screened interior block in the solved system, - # which an iterative ([p,h] Schur) solve handles poorly. See that method's - # docstring for why disabling it is ill-conditioned, not "more accurate". - self._reduce_interior_multiplier = True - - self._degree = degree - - ## Any problem with U,P, just define our own - if velocityField == None or pressureField == None: - - # Note, ensure names are unique for each solver type - i = SNES_Stokes_SaddlePt._obj_count - self.Unknowns.u = uw.discretisation.MeshVariable(f"V{i}", self.mesh, self.mesh.dim, degree=degree, varsymbol=rf"{{\mathbf{{u}}^{{[{i}]}} }}" ) - self.Unknowns.p = uw.discretisation.MeshVariable(f"P{i}", self.mesh, 1, degree=degree-1, continuous=p_continuous, varsymbol=rf"{{\mathbf{{p}}^{{[{i}]}} }}") - - if self.verbose and uw.mpi.rank == 0: - print(f"SNES Saddle Point Solver {self.instance_number}: creating new mesh variables for unknowns") - - # I expect the following to break for anyone who wants to name their solver _stokes__ etc etc (LM) - - # options = PETSc.Options() - # options["dm_adaptor"]= "pragmatic" - # Here we can set some defaults for this set of KSP / SNES solvers - - if self.verbose == True: - self.petsc_options["ksp_monitor"] = None - self.petsc_options["snes_converged_reason"] = None - self.petsc_options["snes_monitor_short"] = None - else: - self.petsc_options.delValue("ksp_monitor") - self.petsc_options.delValue("snes_monitor") - self.petsc_options.delValue("snes_monitor_short") - self.petsc_options.delValue("snes_converged_reason") - - self._tolerance = 1.0e-4 - self._strategy = "default" - - # Owned-option latch state (see _resolve_owned_option): the value THIS - # solver last pushed per key, and any user value latched per key. - # Ownership is RECORDED, never inferred — the same doctrine as - # _managed_pc_options. - self._owned_option_pushes = {} - self._owned_option_user = {} - - # Participate in the auto FMG/GAMG switch on the velocity fieldsplit - # block (see the `preconditioner` property). The velocity pc/mg keys - # set below are the GAMG default; _apply_preconditioner_options() - # upgrades the velocity block to geometric FMG at build time when the - # mesh carries a refinement hierarchy, and otherwise leaves them. - self._pc_option_prefix = "fieldsplit_velocity_" - - self.petsc_options["snes_rtol"] = self._tolerance - self.petsc_options["snes_ksp_ew"] = None - self.petsc_options["snes_ksp_ew_version"] = 3 - - # The OUTER Krylov must be FLEXIBLE: both sub-blocks below are - # themselves Krylov solves run to a tolerance, so the operator the - # outer method applies differs from one outer iteration to the next - # and plain GMRES's residual recurrence does not hold — the - # velocity-block FGMRES reasoning (#147), one level up. PETSc's - # default is `gmres`; invisible on easy problems (isoviscous SolKz - # converges in two outer iterations), ruinous on hard ones: 14,400 - # inner iterations / ~540 s on an 85k-cell contrast problem for both - # velocity preconditioners (#624), and on the Spiegelman notch at - # refinement 3, 983 velocity iterations per step and - # DIVERGED_LINEAR_SOLVE against 58 for fgmres — raising the velocity - # cap changes nothing (byte-identical residuals), so the failure is - # inconsistency, not iteration count (#576). Managed, so an explicit - # user ksp_type still wins. - self._push_managed_option("ksp_type", "fgmres") - - self.petsc_options["pc_type"] = "fieldsplit" - self.petsc_options["pc_fieldsplit_type"] = "schur" - self.petsc_options["pc_fieldsplit_schur_fact_type"] = "full" # diag is an alternative (quick/dirty) - self.petsc_options["pc_fieldsplit_schur_precondition"] = "a11" # despite what the docs say for saddle points - - self.petsc_options["pc_fieldsplit_diag_use_amat"] = None - self.petsc_options["pc_fieldsplit_off_diag_use_amat"] = None - # self.petsc_options["pc_use_amat"] = None # Using this puts more pressure on the inner solve - - p_name = "pressure" # pressureField.clean_name - v_name = "velocity" # velocityField.clean_name - - # Pressure subsolve — flexible GMRES + GASM. (FGMRES is right- - # preconditioned by construction; no need to set pc_side explicitly.) - self.petsc_options[f"fieldsplit_{p_name}_ksp_type"] = "fgmres" - self.petsc_options[f"fieldsplit_{p_name}_ksp_max_it"] = 200 - self.petsc_options[f"fieldsplit_{p_name}_ksp_rtol"] = self._tolerance - self.petsc_options[f"fieldsplit_{p_name}_pc_type"] = "gasm" - # self.petsc_options[f"fieldsplit_{p_name}_pc_gasm_type"] = "basic" - - ## may be more robust but usually slower - # self.petsc_options[f"fieldsplit_{p_name}_ksp_type"] = "fgmres" - # self.petsc_options[f"fieldsplit_{p_name}_ksp_rtol"] = self._tolerance * 0.1 - # self.petsc_options[f"fieldsplit_{p_name}_pc_type"] = "gamg" - # self.petsc_options[f"fieldsplit_{p_name}_pc_gamg_type"] = "agg" - # self.petsc_options[f"fieldsplit_{p_name}_pc_gamg_repartition"] = True - - # Velocity subsolve — flexible GMRES + GAMG. - # - # FGMRES (not CG/FCG) is the default to remain robust under non-stationary - # preconditioning and weakly-indefinite coarse operators. The mg_levels - # KSP is bounded but variable-iteration (mg_levels_ksp_converged_maxits) - # so the GAMG application is non-linear; CG/FCG's residual recurrence - # cannot accommodate this, and at scale (large parallel partitions, sharp - # internal sources, free-slip Nitsche) PETSc reports DIVERGED_PC_FAILED - # / indefinite matrix or GMRES residual-recursion mismatch. See issue - # #147 for the spherical-Kramer benchmark on Gadi that drove this change; - # gthyagi's validated FGMRES configuration completed at np=144, - # cellsize=1/32 where CG/FCG and standard GMRES both failed. - self.petsc_options[f"fieldsplit_{v_name}_ksp_type"] = "fgmres" - self.petsc_options[f"fieldsplit_{v_name}_ksp_max_it"] = 200 - self.petsc_options[f"fieldsplit_{v_name}_ksp_rtol"] = self._tolerance * 0.1 - # The velocity field carries mesh.dim components per node; say so, or - # GAMG aggregates scalars. See multigrid_options._gamg_settings. - self._pc_block_size = self.mesh.dim - for key, value in multigrid_options.gamg_bundle( - block_size=self._pc_block_size).settings.items(): - self._push_managed_option(f"fieldsplit_{v_name}_{key}", value) - - # Create this dict - self.fields = {} - self.fields[p_name] = self.p - self.fields[v_name] = self.u - - # Some other setup - - self.mesh._equation_systems_register.append(self) - self._rebuild_after_mesh_update = self._build - - self.essential_bcs = [] - - self.natural_bcs = [] - self.bcs = self.essential_bcs - self.boundary_conditions = False - self._saddle_preconditioner = None - self._petsc_use_pressure_nullspace = False - self._petsc_velocity_nullspace_basis = () - self._stokes_nullspace = None - self._stokes_nullspace_basis = () - self._velocity_rotation_nullspace = None + inner_self._DpDt = None + inner_self._DFpDt = None - # Construct strainrate tensor for future usage. - # Grab gradients, and let's switch out to sympy.Matrix notation - # immediately as it is probably cleaner for this. - N = mesh.N + return - ## sympy.Matrix - gradient tensors - self._G = self.p.sym.jacobian(self.mesh.CoordinateSystem.N) + @property + def p(inner_self): + return inner_self._p - # this attrib records if we need to re-setup - self.is_setup = False + @p.setter + def p(inner_self, new_p): + inner_self._p = new_p + inner_self._owning_solver.is_setup = False + return - def _reject_mixed_constraint_mechanisms(self, adding): - """Refuse a rotated constraint and a block constraint on one solver (#464). - The rotated driver builds its own index-set fieldsplit over exactly two - fields (``rotated_bc._solve_rotated_iterative``), and ``build_rotation`` - addresses velocity and pressure by field number. A block constraint - registers a multiplier field of its own, and those DOFs are in neither - index set — the preconditioner would then be built over a strict subset - of the operator's rows, with nothing said about it. + @timing.routine_timer_decorator + def __init__(self, + mesh : underworld3.discretisation.Mesh, + velocityField : Optional[underworld3.discretisation.MeshVariable] = None, + pressureField : Optional[underworld3.discretisation.MeshVariable] = None, + degree : Optional[int] = 2, + p_continuous : Optional[bool] = True, + verbose : Optional[bool] =False, + DuDt : Union[uw.systems.ddt.SemiLagrangian, uw.systems.ddt.Lagrangian] = None, + DFDt : Union[uw.systems.ddt.SemiLagrangian, uw.systems.ddt.Lagrangian] = None, + ): - The two are alternative ways to impose the same wall-normal condition, - so asking for both is a configuration error rather than a case to - support. Supporting it would need a third split for the multipliers and - a ``build_rotation`` that knows about them. - Parameters - ---------- - adding : str - Name of the method being called, so the message can say which - mechanism is already in place and which one was refused. - """ + super().__init__(mesh) - mechanisms = self._constraint_mechanisms() - rotated = mechanisms["rotated_freeslip"] + mechanisms["fault_contact"] - multipliers = mechanisms["multipliers"] + self.verbose = verbose + self.dm = None - if adding == "solve": - # The dispatch reads both lists, so it can only report the pair. - if not (rotated and multipliers): - return - raise RuntimeError( - f"solve(): this solver carries {len(rotated)} rotated " - f"(free-slip or fault contact) and {len(multipliers)} " - f"block-constraint boundary condition(s). The rotated solve " - f"splits velocity and pressure by field number and the " - f"multiplier fields lie outside that split, so the " - f"preconditioner would cover only part of the operator. Both " - f"impose the same wall-normal condition — use one of them " - f"(issue #464)." - ) + self.Unknowns.u = velocityField + self.Unknowns.p = pressureField + self.Unknowns.DuDt = DuDt + self.Unknowns.DFDt = DFDt - if adding == "add_constraint_bc": - if not rotated: - return - present, refused = len(rotated), "a block constraint" - present_kind = "rotated (free-slip or fault contact)" - else: - if not multipliers: - return - present, refused = len(multipliers), "a rotated constraint" - present_kind = "block-constraint multiplier" + # Optional saddle-point Lagrange multiplier fields (block-constrained + # Stokes). Each entry is a full-domain scalar MeshVariable registered + # as an extra DM field (id 2, 3, ...), grouped with pressure into the + # Schur split. EMPTY for ordinary Stokes — every multiplier-aware code + # path below is guarded by `if self._multipliers:` so that with no + # multiplier the emitted DS is bit-identical to the 2-field solver. + # _multiplier_screening[k] is the interior screening coefficient + # (eps M de-singularises the interior h block); see + # docs/developer/design/CONSTRAINED_FREESLIP_MULTIPLIER.md. + self._multipliers = [] + self._multiplier_screening = [] + self._block_constraint_bcs = [] + # Give the Lagrange-multiplier (lambda) block its own viscosity-scaled + # Schur preconditioner. The constraint Schur complement S_lambda = C A^-1 C^T + # scales as 1/mu (since A ~ mu K), exactly like the pressure Schur S_p ~ mu^-1 M_p + # and independent of the augmentation r. When True, the lambda block's + # PRECONDITIONER mass (Pmat only) uses 1/mu (= saddle_preconditioner) while the + # true operator (Amat) (lambda,lambda) block stays = eps (exact Newton). This is + # the boundary-trace analog of _pp_G0 = 1/mu on pressure, and decouples + # convergence from r so r can shrink -> cleaner recovered lambda (= topography). + # When on, the lambda block reuses pressure's already-compiled _pp_G0 = 1/mu + # for its preconditioner block (same scaling, no extra JIT term). On uniform mu + # (fieldsplit) it is bit-identical; on moderate contrast it cracks the wall and + # makes the augmentation optional. Default OFF (opt-in): a monolithic lu solve + # factorizes the Pmat (pc_use_amat is a no-op here), so this Pmat term is NOT + # inert for direct solves — flipping the default on regresses the direct-lu + # constraint enforcement. See the design note + # docs/developer/design/CONSTRAINED_FREESLIP_MULTIPLIER.md (the warning on the + # coupled vs sub-block preconditioner placement). + self._multiplier_schur_pc = False + # Pin interior (off-boundary) multiplier DOFs to 0 so the solved [p,h] + # block carries only the boundary trace (~√ndof instead of ~ndof/3 DOFs); + # the boundary trace is the only physical part. Done by constraining the + # interior h DOFs directly in the fine local PetscSection + # (_constrain_interior_multipliers_in_section) — lossless (correct + # constraint-boundary closure) and refined/FMG-safe (no DMAddBoundary + # ordering restriction). Default ON: it is both a correctness-neutral DOF + # reduction AND a conditioning win — leaving the interior h DOFs in place + # keeps the near-singular ε-screened interior block in the solved system, + # which an iterative ([p,h] Schur) solve handles poorly. See that method's + # docstring for why disabling it is ill-conditioned, not "more accurate". + self._reduce_interior_multiplier = True - raise RuntimeError( - f"{adding}(): this solver already carries {present} " - f"{present_kind} boundary condition(s), so it cannot also take " - f"{refused}. The rotated solve splits velocity and pressure by " - f"field number and a block constraint adds a multiplier field " - f"outside that split, so the preconditioner would cover only part " - f"of the operator. Both impose the same wall-normal condition — " - f"use one of them (issue #464)." - ) + self._degree = degree - def add_rotated_freeslip_bc(self, conds=None, boundary=None, normal=None): - r"""Add STRONG free-slip (:math:`\mathbf{u}\cdot\hat{\mathbf n}=0`) by rotating - the boundary velocity DOFs into a per-node (normal, tangential) frame and - imposing the rotated normal component as an exact Dirichlet constraint. + ## Any problem with U,P, just define our own + if velocityField == None or pressureField == None: - Unlike Nitsche/penalty free-slip (weak, leaks :math:`\mathcal O(10^{-3})`), - this enforces zero wall-normal flow to machine precision, and the constraint - **reaction** is the consistent boundary normal traction - :math:`\sigma_{nn}` (see :meth:`boundary_normal_traction`) with no - augmented-Lagrangian splitting. Correct on deformed / tilted / curved - boundaries because the normal is taken per node. + # Note, ensure names are unique for each solver type + i = SNES_Stokes_SaddlePt._obj_count + self.Unknowns.u = uw.discretisation.MeshVariable(f"V{i}", self.mesh, self.mesh.dim, degree=degree, varsymbol=rf"{{\mathbf{{u}}^{{[{i}]}} }}" ) + self.Unknowns.p = uw.discretisation.MeshVariable(f"P{i}", self.mesh, 1, degree=degree-1, continuous=p_continuous, varsymbol=rf"{{\mathbf{{p}}^{{[{i}]}} }}") - Parameters - ---------- - conds : float, scalar sympy expression, or None, optional - Prescribed wall-normal velocity datum :math:`\tilde u_n` (the SCALAR - component along the outward normal), in the canonical value-first BC - order (Style Charter, API conventions). Zero (or ``None``) is pure - free-slip :math:`\mathbf{u}\cdot\hat{\mathbf n}=0`. A non-zero value - — a number, a sympy expression of ``mesh.X``, or a scalar field read - such as ``h_dot.sym[0]`` — is imposed strongly (machine precision) as - :math:`\mathbf{u}\cdot\hat{\mathbf n}=\tilde u_n`, evaluated at the - boundary nodes at each ``solve()``. On an enclosed boundary the - datum must be discretely flux-free (:math:`\oint \tilde u_n = 0`) - for incompressibility. A corner/edge node shared between rotated - boundaries has no single normal and stays at the free-slip pinning - (datum ignored there). Vector/matrix values are rejected. - boundary : str - Boundary label to constrain. - normal : None or sympy 1×dim Matrix or array, optional - Per-node outward normal source. ``None`` (the default, and normally - the right choice) uses the geometric facet normal, measure-weighted - so that it is consistent with the straight-facet boundary integral - the assembler evaluates. A sympy ``1×dim`` matrix supplies an - analytic normal (``X/|X|`` on a spherical cap, a constant on a - planar face): exact for the TRUE surface, but the assembler still - integrates over the facets, so it keeps a consistency error that - grows with facet non-uniformity. Use it when the constraint must - follow the geometry rather than the mesh. A constant array is also - accepted. See ``docs/developer/subsystems/rotated-freeslip.md``. + if self.verbose and uw.mpi.rank == 0: + print(f"SNES Saddle Point Solver {self.instance_number}: creating new mesh variables for unknowns") - Notes - ----- - A node shared by several rotated-free-slip boundaries (a box corner, a 3D - edge) is constrained on the whole span of its accumulated normals: a 3D - face frees two tangential directions, a 3D edge frees one (the edge - tangent), a corner is fully pinned. Registering delegates the solve to - :mod:`underworld3.utilities.rotated_bc`. + # I expect the following to break for anyone who wants to name their solver _stokes__ etc etc (LM) - The legacy boundary-first call ``add_rotated_freeslip_bc(boundary, - normal)`` is detected conservatively (the first positional argument is - a string — the datum is never a string) and shimmed with one - DeprecationWarning: the string becomes ``boundary`` and a second - positional argument, if present, becomes ``normal``. - """ - self._reject_mixed_constraint_mechanisms("add_rotated_freeslip_bc") + # options = PETSc.Options() + # options["dm_adaptor"]= "pragmatic" + # Here we can set some defaults for this set of KSP / SNES solvers - if isinstance(conds, str): - # legacy boundary-first call: (boundary[, normal]) - if boundary is not None: - if normal is not None: - raise TypeError( - "add_rotated_freeslip_bc() received 'normal' twice " - "(positionally, legacy order, and as a keyword)") - normal = boundary - boundary = conds - conds = None - import warnings - warnings.warn( - "add_rotated_freeslip_bc(boundary, normal) is deprecated; " - "use add_rotated_freeslip_bc(conds, boundary, normal=...) " - "with conds=0 (or boundary=... by keyword)", - DeprecationWarning, stacklevel=2) - if not isinstance(boundary, str): - raise TypeError( - f"add_rotated_freeslip_bc() requires a boundary label string; " - f"got {type(boundary).__name__}") - if conds is not None: - if isinstance(conds, (sympy.MatrixBase, list, tuple, np.ndarray)): - raise TypeError( - "add_rotated_freeslip_bc(conds=...) takes the SCALAR " - "wall-normal datum u.n; got a vector/matrix value") - # Value comparison, not sympy's structural ==: Float(0.0) != Integer(0) - # structurally, but both are zero data (the free-slip member of the - # family). is_zero is True only when sympy can PROVE zero, so a - # symbolic datum (field read, expression) is correctly kept. - if sympy.sympify(conds).is_zero is not True: - self._rotated_freeslip_datum[boundary] = conds - self._rotated_freeslip_bcs.append((boundary, normal)) - self.is_setup = False - return + if self.verbose == True: + self.petsc_options["ksp_monitor"] = None + self.petsc_options["snes_converged_reason"] = None + self.petsc_options["snes_monitor_short"] = None + else: + self.petsc_options.delValue("ksp_monitor") + self.petsc_options.delValue("snes_monitor") + self.petsc_options.delValue("snes_monitor_short") + self.petsc_options.delValue("snes_converged_reason") - def add_fault_bc(self, conds=0, boundary=None, normal=None): - r"""Interface condition on a split-node fault (value-first). + self._tolerance = 1.0e-4 + self._strategy = "default" - ``boundary`` names a fault split by ``Mesh.add_fault`` (or - ``fault_split.split_fault``); the mesh carries its coincident DOF - pairing. The no-opening constraint :math:`[\mathbf v]\cdot\hat n = 0` - is always imposed strongly; ``conds`` sets the tangential law: + # Owned-option latch state (see _resolve_owned_option): the value THIS + # solver last pushed per key, and any user value latched per key. + # Ownership is RECORDED, never inferred — the same doctrine as + # _managed_pc_options. + self._owned_option_pushes = {} + self._owned_option_user = {} + + # Participate in the auto FMG/GAMG switch on the velocity fieldsplit + # block (see the `preconditioner` property). The velocity pc/mg keys + # set below are the GAMG default; _apply_preconditioner_options() + # upgrades the velocity block to geometric FMG at build time when the + # mesh carries a refinement hierarchy, and otherwise leaves them. + self._pc_option_prefix = "fieldsplit_velocity_" - * ``conds = 0`` — frictionless (perfectly slippery): zero shear - traction, the slip emerges (the stress-driven crack). - * ``conds`` > 0 — viscous interface :math:`\tau = \eta_f V` with - ``conds`` = :math:`\eta_f` (viscosity per unit length; the - zero-thickness limit of a band is :math:`\eta_f = \eta_{band}/w`, - where :math:`\eta_{band}` is the band's OWN weak-zone viscosity — - not the background's — and :math:`w` its width). ``conds`` large - removes the fault (only the JUMP is penalised — nothing becomes - rigid). + self.petsc_options["snes_rtol"] = self._tolerance + self.petsc_options["snes_ksp_ew"] = None + self.petsc_options["snes_ksp_ew_version"] = 3 - ``normal`` optionally supplies the fault's smooth unit normal — - a sympy ``1×dim`` Matrix in ``mesh.X`` (same conventions as - :meth:`add_rotated_freeslip_bc`), the string ``"trace"`` (2-D: the - smoothed normal is built from the fault's own stored polyline — - the right choice for digitized traces with no analytic formula), - or a constant ``(dim,)`` array. Use it whenever the fault trace is - a SAMPLED SMOOTH CURVE: the default per-node normal averages the - adjacent facet normals, which zig-zags at the sampling kinks, and - the no-opening constraint then forbids smooth slip past each kink — - slip notches and normal-traction sawteeth that GROW under mesh - refinement. The smooth normal restores the smooth curve's - mechanics on the same polyline mesh. On a straight fault the - default is already exact, and a deliberately KINKED fault should - NOT be smoothed — there the kink response is the physics. + # The OUTER Krylov must be FLEXIBLE: both sub-blocks below are + # themselves Krylov solves run to a tolerance, so the operator the + # outer method applies differs from one outer iteration to the next + # and plain GMRES's residual recurrence does not hold — the + # velocity-block FGMRES reasoning (#147), one level up. PETSc's + # default is `gmres`; invisible on easy problems (isoviscous SolKz + # converges in two outer iterations), ruinous on hard ones: 14,400 + # inner iterations / ~540 s on an 85k-cell contrast problem for both + # velocity preconditioners (#624), and on the Spiegelman notch at + # refinement 3, 983 velocity iterations per step and + # DIVERGED_LINEAR_SOLVE against 58 for fgmres — raising the velocity + # cap changes nothing (byte-identical residuals), so the failure is + # inconsistency, not iteration count (#576). Managed, so an explicit + # user ksp_type still wins. + self._push_managed_option("ksp_type", "fgmres") - The solve then takes the rotated strong-constraint path - (``utilities/rotated_bc.py`` with the pair blocks of - ``utilities/fault_contact.py``); ``guard()`` / - ``estimate_difficulty()`` are unavailable, as for rotated free-slip. - Slip and leak per coincident pair afterwards: - ``fault_contact.fault_slip(solver, boundary, - solver._rotated_freeslip_info)``. - """ - from underworld3.utilities import fault_contact + self.petsc_options["pc_type"] = "fieldsplit" + self.petsc_options["pc_fieldsplit_type"] = "schur" + self.petsc_options["pc_fieldsplit_schur_fact_type"] = "full" # diag is an alternative (quick/dirty) + self.petsc_options["pc_fieldsplit_schur_precondition"] = "a11" # despite what the docs say for saddle points - self._reject_mixed_constraint_mechanisms("add_fault_bc") + self.petsc_options["pc_fieldsplit_diag_use_amat"] = None + self.petsc_options["pc_fieldsplit_off_diag_use_amat"] = None + # self.petsc_options["pc_use_amat"] = None # Using this puts more pressure on the inner solve - if not isinstance(boundary, str): - raise TypeError( - f"add_fault_bc() requires the fault's boundary name string; " - f"got {type(boundary).__name__}") - eta_f = float(conds) - if eta_f == 0.0: - fault_contact.add_frictionless_fault_bc(self, boundary, - normal=normal) - else: - fault_contact.add_viscous_fault_bc(self, eta_f, boundary, - normal=normal) - self.is_setup = False - return + p_name = "pressure" # pressureField.clean_name + v_name = "velocity" # velocityField.clean_name - def _residual_is_nonlinear(self, tol=1e-8): - r"""True if the Stokes residual is nonlinear in the unknowns :math:`(v, p)` - — i.e. the assembled Jacobian depends on the solution, so Newton / Picard - iteration is required. + # Pressure subsolve — flexible GMRES + GASM. (FGMRES is right- + # preconditioned by construction; no need to set pc_side explicitly.) + self.petsc_options[f"fieldsplit_{p_name}_ksp_type"] = "fgmres" + self.petsc_options[f"fieldsplit_{p_name}_ksp_max_it"] = 200 + self.petsc_options[f"fieldsplit_{p_name}_ksp_rtol"] = self._tolerance + self.petsc_options[f"fieldsplit_{p_name}_pc_type"] = "gasm" + # self.petsc_options[f"fieldsplit_{p_name}_pc_gasm_type"] = "basic" - Detected by a NUMERICAL probe: assemble the Jacobian at two distinct - velocity states and compare. A symbolic test on ``F1.sym`` cannot see the - nonlinearity — the effective viscosity's strain-rate (velocity-gradient) - dependence is carried as a JIT-substituted *placeholder* symbol - (``\dot\varepsilon_{II}``) in the flux, decoupled from the gradient - ``L`` in the symbolic form, so it only becomes visible once the operator - is assembled at a concrete iterate. Constant- or temperature-dependent - viscosity ⇒ ``J`` independent of ``v`` ⇒ the two assemblies are - bit-identical ⇒ linear. + ## may be more robust but usually slower + # self.petsc_options[f"fieldsplit_{p_name}_ksp_type"] = "fgmres" + # self.petsc_options[f"fieldsplit_{p_name}_ksp_rtol"] = self._tolerance * 0.1 + # self.petsc_options[f"fieldsplit_{p_name}_pc_type"] = "gamg" + # self.petsc_options[f"fieldsplit_{p_name}_pc_gamg_type"] = "agg" + # self.petsc_options[f"fieldsplit_{p_name}_pc_gamg_repartition"] = True - Used as a LAZY guard in the rotated-free-slip loop's picard + pure-Newton - corner (a Picard warmup is meaningless for a linear residual and impossible - for pure Newton) — the loop itself needs no up-front probe, it - self-terminates. The caller must have run the pre-solve preamble - (auxiliary vector + constants) so the assembly sees the correct - coefficients. - """ - snes = self.snes - dm = self.dm - snes.setUp() - J = snes.getJacobian()[0] - U1 = dm.getGlobalVec(); U2 = dm.getGlobalVec() - # two distinct smooth, bounded states with non-zero velocity gradients - # (a linear operator gives the SAME J for both; only a solution-dependent - # viscosity makes them differ). Ownership-relative index keeps it - # partition-independent enough for the norm comparison. - rs, re = U1.getOwnershipRange() - idx = np.arange(rs, re, dtype=float) - # write IN PLACE into each vec's PETSc-owned array (getArray returns a writable - # view). setArray with a fresh numpy temporary on a POOLED getGlobalVec is risky: - # its storage lifetime/pool reuse is not guaranteed for the later computeJacobian. - a1 = U1.getArray(); a1[:] = 0.1 * np.sin(0.7 * idx + 0.3) - a2 = U2.getArray(); a2[:] = 0.1 * np.sin(1.3 * idx + 1.1) - J1 = J.copy(); J2 = J.copy() - try: - snes.computeJacobian(U1, J1) - snes.computeJacobian(U2, J2) - J2.axpy(-1.0, J1) # J2 <- J(U2) - J(U1) - rel = J2.norm() / (J1.norm() + 1e-300) - finally: - dm.restoreGlobalVec(U1); dm.restoreGlobalVec(U2) - J1.destroy(); J2.destroy() - return rel > tol + # Velocity subsolve — flexible GMRES + GAMG. + # + # FGMRES (not CG/FCG) is the default to remain robust under non-stationary + # preconditioning and weakly-indefinite coarse operators. The mg_levels + # KSP is bounded but variable-iteration (mg_levels_ksp_converged_maxits) + # so the GAMG application is non-linear; CG/FCG's residual recurrence + # cannot accommodate this, and at scale (large parallel partitions, sharp + # internal sources, free-slip Nitsche) PETSc reports DIVERGED_PC_FAILED + # / indefinite matrix or GMRES residual-recursion mismatch. See issue + # #147 for the spherical-Kramer benchmark on Gadi that drove this change; + # gthyagi's validated FGMRES configuration completed at np=144, + # cellsize=1/32 where CG/FCG and standard GMRES both failed. + self.petsc_options[f"fieldsplit_{v_name}_ksp_type"] = "fgmres" + self.petsc_options[f"fieldsplit_{v_name}_ksp_max_it"] = 200 + self.petsc_options[f"fieldsplit_{v_name}_ksp_rtol"] = self._tolerance * 0.1 + # The velocity field carries mesh.dim components per node; say so, or + # GAMG aggregates scalars. See multigrid_options._gamg_settings. + self._pc_block_size = self.mesh.dim + for key, value in multigrid_options.gamg_bundle( + block_size=self._pc_block_size).settings.items(): + self._push_managed_option(f"fieldsplit_{v_name}_{key}", value) - def boundary_normal_traction(self, boundary, mass="auto"): - r"""Return the boundary normal traction :math:`\sigma_{nn}` on a - rotated-free-slip ``boundary`` as the constraint reaction from the last - solve — the smooth, bounded quantity used for dynamic topography - (:math:`h_\infty=-(\sigma_{nn}-\overline{\sigma_{nn}})/\rho g`). Requires a - prior :meth:`add_rotated_freeslip_bc` on ``boundary`` and a completed - :meth:`solve`. + # Create this dict + self.fields = {} + self.fields[p_name] = self.p + self.fields[v_name] = self.u - ``mass="auto"`` (default) uses lumped recovery for 2D traces and 3D P1 - triangles, and MIDPOINT-RECONSTRUCTED recovery for 3D P2 triangles (the - consistent solve, keeping its superconvergent midpoints, with vertices rebuilt - from them). ``"p1"`` selects the simpler P1-projected recovery; explicit - ``"lumped"`` and ``"consistent"`` remain available where mathematically valid; - ``"consistent"`` is pointwise-exact on a P2 trace in exact arithmetic but - its zero vertex row sums amplify any load perturbation at VERTICES by O(1), - independently of h (#404, measured in #633). Three-dimensional recovery - currently supports triangular P1/P2 traces only (#637). + # Some other setup - .. warning:: - On CURVED boundaries, P2 vertex values of :math:`\sigma_{nn}` converge - only slowly (the vertex basis has zero surface mean, so vertex reactions - carry only the O(h) facet-geometry error); edge-midpoint values are - superconvergent. Pointwise consumers on curved boundaries should use - midpoint or integral/fitted quantities (issue #414).""" - if self._rotated_freeslip_info is None: - raise RuntimeError( - "boundary_normal_traction requires a completed rotated-free-slip solve.") - from underworld3.utilities.rotated_bc import boundary_normal_traction as _bnt - return _bnt(self, boundary, self._rotated_freeslip_info, mass=mass) + self.mesh._equation_systems_register.append(self) + self._rebuild_after_mesh_update = self._build - def dynamic_topography(self, boundary, field, buoyancy_scale=1.0, mass="auto"): - r"""Write the dynamic topography - :math:`h = -(\sigma_{nn}-\overline{\sigma_{nn}})/(\Delta\rho\,g)` on a - rotated-free-slip ``boundary`` onto a scalar MeshVariable ``field``, from the - constraint reaction of the last solve. This is the hand-off to the free-surface - machinery — the 3-number topography integrator drives node motion from a surface - field, so create a scalar ``field`` (P1 recommended, continuous) up front and - pass it here after each :meth:`solve`; its boundary nodes are filled and the - interior left untouched. + self.essential_bcs = [] - ``buoyancy_scale`` is :math:`\Delta\rho\,g` (traction → length). - ``mass="auto"`` selects lumped recovery where valid and - midpoint-reconstructed recovery for 3D P2 triangles. Requires a prior - :meth:`add_rotated_freeslip_bc` on ``boundary`` and a completed :meth:`solve`. + self.natural_bcs = [] + self.bcs = self.essential_bcs + self.boundary_conditions = False + self._saddle_preconditioner = None + self._petsc_use_pressure_nullspace = False + self._petsc_velocity_nullspace_basis = () + self._stokes_nullspace = None + self._stokes_nullspace_basis = () + self._velocity_rotation_nullspace = None - .. warning:: - On CURVED boundaries (annulus/spherical free surfaces), the P2 VERTEX - values written into ``field`` converge only slowly; edge-midpoint values - are superconvergent. Downstream pointwise use of curved-boundary - topography should rely on midpoint/fitted quantities (issue #414).""" - if self._rotated_freeslip_info is None: - raise RuntimeError( - "dynamic_topography requires a completed rotated-free-slip solve.") - from underworld3.utilities.rotated_bc import dynamic_topography_field as _dtf - return _dtf(self, boundary, self._rotated_freeslip_info, field, - buoyancy_scale=buoyancy_scale, mass=mass) + # Construct strainrate tensor for future usage. + # Grab gradients, and let's switch out to sympy.Matrix notation + # immediately as it is probably cleaner for this. + N = mesh.N - def boundary_normal_traction_integral(self, boundary, fn, remove_mean=True): - r"""Return the boundary integral of ``sigma_nn * fn`` directly from the - rotated-free-slip constraint reaction. + ## sympy.Matrix - gradient tensors + self._G = self.p.sym.jacobian(self.mesh.CoordinateSystem.N) - With ``remove_mean=True`` (default), the constant normal-traction gauge - is removed before projection. Unlike pointwise - :meth:`boundary_normal_traction`, this weak projection does not recover - nodal traction values or gather a global boundary mesh. It is therefore - suitable for harmonic and integral diagnostics on curved P2 boundaries, - whose recovered vertex values converge slowly (issue #414). - """ - if self._rotated_freeslip_info is None: - raise RuntimeError( - "boundary_normal_traction_integral requires a completed " - "rotated-free-slip solve.") - from underworld3.utilities.rotated_bc import boundary_normal_traction_integral as _bnti - return _bnti(self, boundary, self._rotated_freeslip_info, fn, - remove_mean=remove_mean) + # this attrib records if we need to re-setup + self.is_setup = False def add_nitsche_bc(self, conds=None, boundary=None, direction=None, normal=None, gamma=12.5, theta=1, mask=None, local_h=True, g=None): @@ -10033,55 +10107,7 @@ class SNES_Stokes_SaddlePt(SolverBaseClass): # Non-dimensionalise if the scaling system is active. cdef DM _time_dm_stokes - # Rotated strong free-slip: delegate to the rotated_bc module (per-node DOF - # rotation + strong v_n=0 + reaction=sigma_nn). Handles the whole assemble/ - # solve/rotate-back/gauge-removal; stashes info for boundary_normal_traction. - if self._rotated_freeslip_bcs or self._fault_contact_faults: - # Run the same pre-solve preamble as the standard path so the pointwise - # functions see the DM time, the auxiliary vector, and updated constants - # (needed for problems whose coefficients live in auxiliary fields). This - # must precede the nonlinearity probe below so the trial assemblies see - # the correct coefficients. - if time is not None: - t_nd = self._nondimensional_time(time) - _time_dm_stokes = self.dm - UW_DMSetTime(_time_dm_stokes.dm, t_nd) - self.mesh.update_lvec() - self.dm.setAuxiliaryVec(self.mesh.lvec, None) - self._update_constants(record=True) - - # guard() refuses rotated free-slip, but the BC can be added AFTER arming. - # Re-check here: this path never reaches the instrumentation, so an armed - # guard would be silently inert -- the exact state guard() exists to refuse. - if (getattr(self, "_instrumentation", None) is not None - and self._instrumentation.armed): - raise NotImplementedError( - "a wall-clock guard is armed but this solve takes the rotated " - "free-slip path, which runs its own Krylov loop outside self.snes " - "and cannot be bounded by it. Call unguard() first." - ) - # ONE path for linear and nonlinear models: the manual outer - # Newton/Picard loop that rotates F(u), J(u) and the strong - # v_n = u_n constraint every iteration. It honours zero_init_guess - # (warm start), the Picard warmup count, and the consistent_jacobian - # tangent (Picard / Newton / continuation); a linear model converges - # after its first increment, so no up-front nonlinearity probe is - # paid (the loop self-terminates; _residual_is_nonlinear survives as - # a lazy guard inside the picard+pure-Newton corner). - from underworld3.utilities.rotated_bc import solve_rotated_freeslip - self._rotated_freeslip_info = solve_rotated_freeslip( - self, self._rotated_freeslip_bcs, verbose=verbose, - zero_init_guess=zero_init_guess, picard=picard, - # An explicit `time=` reaches the kernels through petsc_t on - # the DM — invisible to every state counter and constant - # value, so it must veto the cached-operator fast path. - force_operator_refresh=time is not None) - # This path solves via ksp.solve on the rotated operator (not self.snes), - # so give it a report from the rotated result rather than leaving a stale one. - _rotated_report = self._capture_rotated_report(self._rotated_freeslip_info) - # The warm-start flag must follow the rotated solve's own verdict — the SNES - # was never run, so the generic reader would latch a stale reason. - self._record_convergence_status(converged=_rotated_report.converged) + if self._solve_rotated_if_registered(zero_init_guess, verbose, picard, time): return if time is not None: diff --git a/src/underworld3/utilities/rotated_bc.py b/src/underworld3/utilities/rotated_bc.py index 81ccd4571..c4472a766 100644 --- a/src/underworld3/utilities/rotated_bc.py +++ b/src/underworld3/utilities/rotated_bc.py @@ -1094,7 +1094,7 @@ def solve_rotated_freeslip(solver, boundaries, remove_rotation_gauge=True, # defined as unreachable. Measured before this was hoisted: the velocity # residual reached 6e-12 at the first increment and the remaining |F̂| was # the pinned DOF alone, bit-identical for eight further no-op iterations. - lu_pin = _naive_pressure_pin(dm) if use_lu else None + lu_pin = _naive_pressure_pin(dm) if (use_lu and "pressure" in solver._subdict) else None interface_laws = bool(getattr(solver, "_fault_interface_laws", {})) # ---- cross-solve workspace cache (issue #417; see the block comment @@ -1229,7 +1229,11 @@ def solve_rotated_freeslip(solver, boundaries, remove_rotation_gauge=True, _impose_normal_constraint(u, Q, Qt, normal_rows, datum_map) J, Jp = snes.getJacobian()[:2] - pres_is = solver._subdict["pressure"][0] + # A vector solver (the thin sheet) has the velocity alone: no pressure + # block, no Schur complement, no pressure gauge. Every pressure site below + # is gated on this. + has_pressure = "pressure" in solver._subdict + pres_is = solver._subdict["pressure"][0] if has_pressure else None Fc = dm.createGlobalVec() reaction = dm.createGlobalVec() # the final (un-zeroed) Cartesian residual @@ -1261,7 +1265,7 @@ def solve_rotated_freeslip(solver, boundaries, remove_rotation_gauge=True, # stalls against the constant offset). This mirrors PETSc's own projected # residual for singular systems. The Cartesian reaction stash is UNPROJECTED # (σ_nn reads velocity rows only). - use_pnull = bool(getattr(solver, "_petsc_use_pressure_nullspace", False)) + use_pnull = has_pressure and bool(getattr(solver, "_petsc_use_pressure_nullspace", False)) # L2 norm of the LAST projected-out gauge component (|mean|·√N over the # pressure rows) — kept observable so an INCOMPATIBLE datum cannot hide # behind the projection (see the guard after the Newton loop). @@ -1397,7 +1401,7 @@ def rotated_residual(uvec, keep_cartesian=False): Aop = Ahat if not use_lu: if ctx is None: - Mp = _pressure_mass_schur_pmat(solver) + Mp = _pressure_mass_schur_pmat(solver) if has_pressure else None nsp = _rotated_nullspace(solver, Q, normal_rows) # J assembled above elif assemble and Mp is not None: Jp.createSubMatrix(pres_is, pres_is, submat=Mp) # viscosity may be u-dependent @@ -1440,11 +1444,15 @@ def rotated_residual(uvec, keep_cartesian=False): # parity with the iterative path's exact v_n cleanup (LU is exact only # to factorisation round-off at the decoupled constraint rows) _zero_rows_local(dhat, normal_rows) - else: + elif has_pressure: dhat, last_reason, ctx = _solve_rotated_iterative( solver, Aop, bhat, Q, Qt, normal_rows, custom_Pl=custom_Pl, nsp=nsp, Mp=Mp, verbose=False, ctx=ctx, refresh_operator=assemble) + else: + dhat, last_reason, ctx = _solve_rotated_vector( + solver, Aop, bhat, normal_rows, + custom_Pl=custom_Pl, nsp=nsp, ctx=ctx, refresh_operator=assemble) lin_its.append(ctx["ksp"].getIterationNumber()) vel_its_last.append(ctx.get("vel_its_last")) pres_its_last.append(ctx.get("pres_its_last")) @@ -1978,6 +1986,98 @@ def _solve_rotated_iterative(solver, Ahat, bhat, Q, Qt, normal_rows, verbose=Fal return Uhat, ksp.getConvergedReason(), ctx +def _solve_rotated_vector(solver, Ahat, bhat, normal_rows, custom_Pl=None, nsp=None, + ctx=None, refresh_operator=True): + """Solve the rotated operator of a single-field (vector) solver — the thin + sheet — with one KSP on the whole matrix: geometric FMG on the custom + prolongation when the solver has a hierarchy, else the shared GAMG bundle + with the solver's block size. The saddle-point counterpart is + ``_solve_rotated_iterative``; this one has no Schur complement and no + pressure gauge. Same ``(Uhat, reason, ctx)`` contract and the same context + reuse across Newton iterations.""" + from underworld3.utilities import custom_mg, multigrid_options + dm = solver.dm + if ctx is None: + if nsp is not None: + Ahat.setNullSpace(nsp) + Ahat.setTransposeNullSpace(nsp) + global _ROT_SOLVE_COUNT + _ROT_SOLVE_COUNT += 1 + pfx = f"rotvec{_ROT_SOLVE_COUNT}_" + opts = PETSc.Options() + tol = float(solver.tolerance) + cfg = {"ksp_type": "fgmres", "ksp_rtol": str(tol), "ksp_max_it": "500"} + if custom_Pl is None: + cfg.update(multigrid_options.gamg_bundle( + block_size=getattr(solver, "_pc_block_size", 1)).settings) + for k, v in cfg.items(): + opts[pfx + k] = v + try: + ksp = PETSc.KSP().create(comm=dm.comm) + ksp.setOptionsPrefix(pfx) + ksp.setOperators(Ahat) + pc = ksp.getPC() + ksp.setFromOptions() + if custom_Pl is not None: + # coarse="svd" only when verified rotation modes make the + # Galerkin-coarsened operator singular (see the saddle route). + _coarse = ("svd" if getattr(solver, "_rotated_velocity_null_modes", 0) + else "redundant") + _hier = (getattr(solver, "_custom_mg", None) or {}).get("hierarchy") + _fac_keys = custom_mg._configure_pcmg( + pc, custom_Pl, coarse=_coarse, + smoother=solver._mg_smoother_variant, + patch_rows=getattr(_hier, "level_patch_rows", None)) + pc.setUp() + for _l in range(1, pc.getMGLevels()): + if _fac_keys and any(k.startswith(f"mg_levels_{_l}_") + for k in _fac_keys): + pc.getMGSmoother(_l).setUp() + vpfx = pc.getOptionsPrefix() or "" + for key in multigrid_options.geometric_mg_bundle( + coarse=_coarse, + smoother=solver._mg_smoother_variant).settings: + opts.delValue(vpfx + key) + for key in _fac_keys or (): + opts.delValue(vpfx + key) + else: + pc.setUp() + finally: + for k in cfg: + # an option PETSc already consumed and dropped is not an error + try: + opts.delValue(pfx + k) + except Exception: + pass + ctx = {"ksp": ksp, "pc": pc, "Mp": None, "nsp": nsp, "cns": None, + "custom_Pl": custom_Pl, "pfx": pfx, + "velocity_pc": "custom-FMG" if custom_Pl is not None else "GAMG", + "schur_pre": "none"} + if custom_Pl is None: + solver._record_pc_fallback( + "rotated.velocity_pc", + requested="custom-P geometric MG on the rotated operator", + installed="GAMG", + reason="unavailable", + detail="no multigrid hierarchy (set_custom_fmg or a mesh-owned " + "adapt tail) is available to the rotated path") + else: + ksp = ctx["ksp"] + nsp = ctx["nsp"] + if refresh_operator: + ksp.setOperators(Ahat) + if nsp is not None: + nsp.remove(bhat) + Uhat = Ahat.createVecRight() + Uhat.set(0.0) + ksp.solve(bhat, Uhat) + _warn_if_ksp_diverged(ksp, kind="rotated vector") + # exact at the decoupled constraint rows, independent of the iterative tolerance + _zero_rows_local(Uhat, normal_rows) + ctx["velocity_pc_type"] = ksp.getPC().getType() + return Uhat, ksp.getConvergedReason(), ctx + + def _rotated_nullspace(solver, Q, normal_rows): """Coupled Stokes null space in the rotated frame: constant pressure, plus every rigid rotation Q·mode that is a genuine null space of the constraints — one diff --git a/tests/test_1077_thin_sheet_faults.py b/tests/test_1077_thin_sheet_faults.py new file mode 100644 index 000000000..afabba4a4 --- /dev/null +++ b/tests/test_1077_thin_sheet_faults.py @@ -0,0 +1,130 @@ +"""Split-node faults in a thin viscous sheet: closed-form checks. + +The thin viscous sheet is plane-stress elasticity with nu = 1/2 (velocity for +displacement, eta for G), so its effective modulus is E' = 2 eta (1 + nu) = +3 eta — the Trouton value — and the crack solutions of plane-stress elasticity +carry over. A frictionless fault is the mode-II crack whose faces stay in +contact: zero shear traction, continuous normal velocity and normal traction. +""" + +import numpy as np +import pytest +import sympy + +import underworld3 as uw +from underworld3.utilities.fault_contact import fault_pair_jumps, fault_slip + +pytestmark = [pytest.mark.level_2] + + +def _box(cell_size): + return uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=cell_size, qdegree=3) + + +def _sheet(mesh, eta=1): + sheet = uw.systems.ThinSheet(mesh) + sheet.constitutive_model = uw.constitutive_models.ViscousFlowModel + sheet.constitutive_model.Parameters.shear_viscosity_0 = eta + sheet.tolerance = 1.0e-10 + return sheet + + +def _probe_points(n=8): + # an even count keeps the grid off the fault line y = 0.5 (and x = 0.5), + # where a point evaluation on coincident split nodes is ambiguous + s = np.linspace(0.1, 0.9, n) + return np.array([[a, b] for a in s for b in s]) + + +@pytest.mark.tier_b +@pytest.mark.parametrize("fault", [ + np.array([[0.3, 0.5], [0.7, 0.5]]), # along the stretch + np.array([[0.5, 0.3], [0.5, 0.7]]), # across it +]) +def test_a_fault_free_of_shear_traction_changes_nothing(fault): + """Under uniaxial stretch the principal axes are x and y, so a frictionless + fault along either carries no shear traction: the faulted solution is the + unfaulted one (u = (edot x, -edot (y - 1/2) / 2)) and the slip is zero.""" + split = _box(0.1).add_fault(("Flt", fault)) + x, y = split.X + edot = sympy.Rational(1, 2) + u_y = -edot * (y - sympy.Rational(1, 2)) / 2 + sheet = _sheet(split, eta=2) + sheet.add_essential_bc([0, u_y], "Left") + sheet.add_essential_bc([edot, u_y], "Right") + sheet.add_fault_bc(0, "Flt") + sheet.solve() + info = sheet._rotated_freeslip_info + assert info["converged"] + + # The exact field is linear, so the P2 nodal values carry it exactly. + # TODO(BUG): uw.function.evaluate mislocates points within ~h/2 of the slit + # of a split mesh (coincident nodes) and returns O(1e-3) errors there while + # the nodal solution is exact to 1e-9 (measured 2026-10-08, cellSize 0.1). + c = sheet.u.coords + u = sheet.u.array[:, 0, :] + exact = np.column_stack([0.5 * c[:, 0], -0.5 * (c[:, 1] - 0.5) / 2]) + assert np.abs(u - exact).max() < 1.0e-8 + _s, V, leak = fault_slip(sheet, "Flt", info) + assert np.abs(V).max() < 1.0e-8 + assert np.abs(leak).max() < 1.0e-13 + + +def _remote_shear(split, eta_f): + """Remote simple shear u = (y, 0) (tau_inf = eta * 1) on every boundary with + an interior fault carrying the viscous law tau = eta_f V (0 = frictionless).""" + x, y = split.X + sheet = _sheet(split, eta=1) + for boundary in ("Left", "Right", "Top", "Bottom"): + sheet.add_essential_bc([y, 0], boundary) + sheet.add_fault_bc(eta_f, "Flt") + sheet.solve() + info = sheet._rotated_freeslip_info + assert info["converged"] + return sheet, info + + +@pytest.mark.tier_b +def test_a_viscous_fault_approaches_the_welded_sheet_at_first_order(): + """tau = eta_f V penalises the slip: the slip, and with it the deviation + from the unfaulted solution (u = (y, 0) exactly), is first order in + 1 / eta_f.""" + split = _box(0.1).add_fault(("Flt", np.array([[0.3, 0.5], [0.7, 0.5]]))) + pts = _probe_points() + exact = np.column_stack([pts[:, 1], np.zeros(len(pts))]) + slip, deviation = {}, {} + for eta_f in (1.0e2, 1.0e3): + sheet, info = _remote_shear(split, eta_f) + _s, V, _leak = fault_slip(sheet, "Flt", info) + slip[eta_f] = np.abs(V).max() + u = uw.function.evaluate(sheet.u.sym, pts).reshape(-1, 2) + deviation[eta_f] = np.abs(u - exact).max() + order = np.log(slip[1.0e2] / slip[1.0e3]) / np.log(10.0) + assert order > 0.9, f"slip order in 1/eta_f {order:.2f}: {slip}" + assert deviation[1.0e3] < deviation[1.0e2] + + +@pytest.mark.tier_b +def test_frictionless_fault_slips_as_the_griffith_crack(): + """A frictionless fault of half-length a under remote shear tau slips as + the mode-II crack of plane-stress elasticity, delta u = (4 tau / E') + sqrt(a^2 - x^2) with E' = 3 eta. Infinite-plane solution: the box is + 2 x 2 against a = 0.2 (walls at 5 a) and the P2 tip resolution is h = 1/25, + which the 8% margins cover (measured 2026-10, recorded in the commit that + added this test).""" + a = 0.2 + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(-1.0, -1.0), maxCoords=(1.0, 1.0), cellSize=1.0 / 25, qdegree=3) + split = mesh.add_fault(("Flt", np.array([[-a, 0.0], [a, 0.0]]))) + sheet, info = _remote_shear(split, 0) + coords, jumps, normals = fault_pair_jumps(sheet, "Flt", info) + tangents = np.column_stack([-normals[:, 1], normals[:, 0]]) + V = np.einsum("ij,ij->i", jumps, tangents) + leak = np.einsum("ij,ij->i", jumps, normals) + assert np.abs(leak).max() < 1.0e-13 + ellipse = (4.0 / 3.0) * np.sqrt(np.maximum(a**2 - coords[:, 0] ** 2, 0.0)) + peak = 4.0 * a / 3.0 + assert abs(np.abs(V).max() - peak) < 0.08 * peak, f"peak slip {np.abs(V).max():.4f} vs {peak:.4f}" + rms = np.sqrt(np.mean((np.abs(V) - ellipse) ** 2)) + assert rms < 0.08 * peak, f"rms profile error {rms:.4f} vs peak {peak:.4f}" From 45688229b69f7532434d0565b650313073f630cc Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Thu, 8 Oct 2026 18:08:50 +1100 Subject: [PATCH 05/57] ThinSheet on the surface of a sphere: strong tangency, facet-normal projector A sheet on a SphericalManifold carries its velocity in the embedding space. Tangency u.n = 0 is a rotated strong constraint at every node (rotated_bc's new SHEET_TANGENCY spec, enumerated over all velocity nodes with the analytic surface normal), so the solve takes the same path as rotated free-slip and the split-node fault pairs. rotated_bc's component and coordinate strides are the embedding dimension (identical on a volume mesh) and the rigid-rotation gauge offers the three Killing fields when the embedding dimension is three. The in-plane projector uses the cell's facet normal, not the analytic one: the assembler differentiates along the flat facets, and with the analytic normal a rigid rotation kept a spurious strain of order h (0.7% of the velocity at h = 0.25). With the facet normal the rotation is a discrete solution (residual 2e-14) and is returned to 2e-10. SphericalManifold cleared nothing after setting dm_plex_gmsh_spacedim, so every later gmsh mesh in the process loaded with three coordinates (a 2-D box after a sphere failed on a 3:2 vector-length mismatch); the option is now cleared once consumed. Tests: rigid rotation exact against basal drag (Killing field); toroidal manufactured solution at second order with tangency at machine precision. Underworld development team with AI support from Claude Code --- .../design/THIN_SHEET_VISCOELASTIC_DESIGN.md | 27 +++-- src/underworld3/meshing/spherical.py | 12 ++- src/underworld3/systems/thin_sheet.py | 45 ++++++-- src/underworld3/utilities/rotated_bc.py | 102 +++++++++++++----- tests/test_1078_thin_sheet_sphere.py | 102 ++++++++++++++++++ 5 files changed, 245 insertions(+), 43 deletions(-) create mode 100644 tests/test_1078_thin_sheet_sphere.py diff --git a/docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md b/docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md index 78b3364ff..7819a00a3 100644 --- a/docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md +++ b/docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md @@ -58,13 +58,26 @@ mesh). Templates: `F1 = L (τ + tr τ P)`, `F0 = -f + c u`. The velocity is carried in the embedding space (3 components on a `SphericalManifold`, `dim=2`, `cdim=3`). PETSc already supplies the surface -gradient (pseudo-inverse Jacobian) in the derivative index; the outer projection -in $\dot{\boldsymbol\varepsilon}_s$ supplies the curvature term -$-\mathbf u\cdot\nabla_s\hat{\mathbf n}$. Tangency $\mathbf u\cdot\hat{\mathbf n}=0$ -is imposed STRONGLY at every node through the per-node rotation of -`utilities/rotated_bc.py` (the same machinery as rotated free-slip and the -split-node fault constraint, so faults compose with it). A penalty was used only -to probe feasibility and is not an API. +gradient along the flat facets in the derivative index; the outer projection in +$\dot{\boldsymbol\varepsilon}_s$ supplies the curvature term +$-\mathbf u\cdot\nabla_s\hat{\mathbf n}$. Two normals are involved and they +are deliberately different: + +- the **projector** uses the facet's own normal (a cell-wise constant field + built once per mesh). The gradient is taken along the flat facet, so a rigid + rotation — a Killing field, strain-free on the sphere — is strain-free in the + discrete form only with the facet normal; with the analytic normal it keeps a + spurious strain of order $h$ (measured 0.7% of the velocity at $h = 0.25$); +- the **tangency constraint** $\mathbf u\cdot\hat{\mathbf n}=0$ uses the + analytic normal of the surface, imposed strongly at every node through the + per-node rotation of `utilities/rotated_bc.py` (`SHEET_TANGENCY`, the same + machinery as rotated free-slip and the split-node fault constraint, so faults + compose with it). The nodes lie on the sphere, so a rigid rotation is exactly + admissible and is reproduced to solver tolerance. + +A penalty was used only to probe feasibility and is not an API. The toroidal +manufactured solution converges at second order (the flat-facet geometry limit +for P2) with tangency at machine precision. Vector solves on a manifold needed four `dim → cdim` corrections (the manifold vector-FE gap): the `SNES_Vector` FE component count, its PC block size and diff --git a/src/underworld3/meshing/spherical.py b/src/underworld3/meshing/spherical.py index 1e2a7618c..4355ddcbb 100644 --- a/src/underworld3/meshing/spherical.py +++ b/src/underworld3/meshing/spherical.py @@ -427,10 +427,16 @@ class boundaries(Enum): # h5 round-trip, which currently loses the explicit coordinate # dimension), then hand the DM to Mesh() which accepts either a # filename or a DMPlex object. + # The option is global: left in place it would load every LATER gmsh + # mesh in this process with three coordinates (a 2-D box after a sphere + # failed on a 3:2 vector-length mismatch), so it is cleared once consumed. PETSc.Options().setValue("dm_plex_gmsh_spacedim", 3) - surface_dm = PETSc.DMPlex().createFromFile( - uw_filename, interpolate=True, comm=PETSc.COMM_WORLD, - ) + try: + surface_dm = PETSc.DMPlex().createFromFile( + uw_filename, interpolate=True, comm=PETSc.COMM_WORLD, + ) + finally: + PETSc.Options().delValue("dm_plex_gmsh_spacedim") surface_dm.setName("uw_mesh") surface_dm.markBoundaryFaces("All_Boundaries", 1001) assert surface_dm.getCoordinateDim() == 3, ( diff --git a/src/underworld3/systems/thin_sheet.py b/src/underworld3/systems/thin_sheet.py index a46c1ec58..f771fe1b7 100644 --- a/src/underworld3/systems/thin_sheet.py +++ b/src/underworld3/systems/thin_sheet.py @@ -84,12 +84,36 @@ def _sheet_normal(mesh): "or a spherical manifold (CoordinateSystemType.SPHERICAL) is supported.") +def _facet_normal_field(mesh): + r"""Unit normal of each (flat) cell of a manifold mesh as a cell-wise + constant vector field, built once per mesh. + + The strain rate the assembler sees is the gradient along the flat facets, + so the in-plane projector has to use the facet's own normal: with the + analytic normal of the true surface a rigid rotation (a Killing field, + zero strain on the sphere) keeps a spurious strain of order :math:`h` + (measured: 0.7% of the velocity at h = 0.25). The sign of the normal does + not enter :math:`\mathbf{n}\mathbf{n}^T`. + """ + field = getattr(mesh, "_sheet_facet_normal", None) + if field is None: + import numpy as np + field = uw.discretisation.MeshVariable( + "sheet_facet_normal", mesh, mesh.cdim, degree=0, continuous=False) + c_start, c_end = mesh.dm.getHeightStratum(0) + normals = np.array([mesh.dm.computeCellGeometryFVM(c)[2] for c in range(c_start, c_end)]) + normals /= np.linalg.norm(normals, axis=1)[:, None] + field.array[:, 0, :] = normals + mesh._sheet_facet_normal = field + return field + + def _tangent_projector(mesh): - r""":math:`\mathbf{P} = \mathbf{I} - \hat{\mathbf{n}}\hat{\mathbf{n}}^T` - (the identity on a flat mesh), ``(cdim, cdim)``.""" - n = _sheet_normal(mesh) - if n is None: + r""":math:`\mathbf{P} = \mathbf{I} - \mathbf{n}\mathbf{n}^T` with the + facet normal of the cell (the identity on a flat mesh), ``(cdim, cdim)``.""" + if mesh.dim == mesh.cdim: return sympy.eye(mesh.cdim) + n = sympy.Matrix(_facet_normal_field(mesh).sym).reshape(1, mesh.cdim) return sympy.eye(mesh.cdim) - n.T * n @@ -179,6 +203,14 @@ def __init__( ) self._needs_function_rewire = True self._constitutive_model = None + # On a manifold the velocity is carried in the embedding space; its + # tangency u . n = 0 is a strong rotated constraint at every node (the + # same per-node rotation as rotated free-slip and the split-node fault + # pairs, so faults compose with it). Registering it routes solve() + # through the rotated path. + if mesh.dim < mesh.cdim: + from underworld3.utilities.rotated_bc import SHEET_TANGENCY + self._rotated_freeslip_bcs.append((SHEET_TANGENCY, _sheet_normal(mesh))) # Last-seen effective order of the stress history (it ramps over the # opening steps); a change rewires the pointwise functions. self._prev_effective_order = None @@ -230,8 +262,9 @@ def solve( @property def tangent_projector(self): - r""":math:`\mathbf{P} = \mathbf{I} - \hat{\mathbf{n}}\hat{\mathbf{n}}^T`; - the identity on a flat mesh.""" + r""":math:`\mathbf{P} = \mathbf{I} - \mathbf{n}\mathbf{n}^T` with the + cell's facet normal; the identity on a flat mesh. The tangency + constraint at the nodes uses the analytic normal of the surface.""" return self._P # ------------------------------------------------------------------ diff --git a/src/underworld3/utilities/rotated_bc.py b/src/underworld3/utilities/rotated_bc.py index c4472a766..995463863 100644 --- a/src/underworld3/utilities/rotated_bc.py +++ b/src/underworld3/utilities/rotated_bc.py @@ -83,6 +83,12 @@ def _warn_if_ksp_diverged(ksp, kind): # PETSc section field ids of the Stokes saddle unknowns (fixed by the solver's # DM field registration order: velocity first, pressure second). _VELOCITY_FIELD = 0 + +#: Boundary spec name for the tangency constraint of a sheet on a manifold: +#: ``(SHEET_TANGENCY, normal)`` in ``solver._rotated_freeslip_bcs`` constrains +#: ``u . n = 0`` at EVERY velocity node, with the analytic surface normal, so a +#: velocity carried in the embedding space stays in the tangent plane. +SHEET_TANGENCY = "__sheet_tangency__" _PRESSURE_FIELD = 1 @@ -104,6 +110,62 @@ def _point_coord(dm, dim, cvec, csec, v0, v1, q): return np.mean([cvec[csec.getOffset(v) // dim] for v in verts], axis=0) +def _normal_source(solver, normal, boundary): + """``(sym_fn, const_normal)`` for an analytic / constant normal spec: a + lambdified numpy callable of ``mesh.X`` for a sympy normal (unwrapped first, + so a curvilinear ``unit_e_0`` reduces to mesh coordinates), or the constant + vector; ``(None, None)`` for the geometric facet normal.""" + cdim = solver.mesh.cdim + if normal is None: + return None, None + if isinstance(normal, sympy.Matrix): + from underworld3.function.expressions import unwrap + comps = [sympy.sympify(unwrap(normal[0, k], keep_constants=False, + return_self=False)) + for k in range(cdim)] + stray = set().union(*[c.free_symbols for c in comps]) \ + - set(solver.mesh.X) + if stray: + raise ValueError( + f"analytic normal for boundary '{boundary}' contains " + f"symbols {sorted(map(str, stray))} that are not mesh " + "coordinates — express it in mesh.X (e.g. radial X/|X|).") + return sympy.lambdify(list(solver.mesh.X), comps, "numpy"), None + return None, np.asarray(normal, dtype=float).ravel() + + +def _manifold_velocity_nodes(solver, normal): + """Every DMPlex point carrying velocity DOFs, each with the analytic unit + normal of the surface at its coordinate — the sheet-tangency constraint of + a velocity carried in the embedding space of a manifold mesh. The normal is + a function of the node coordinate, so every rank computes the same value + and nothing is reduced.""" + if normal is None: + raise ValueError( + "the sheet-tangency constraint needs the analytic surface normal " + "(e.g. mesh.CoordinateSystem.unit_e_0 on a sphere); a manifold has " + "no facet normal to fall back on.") + dm = solver.dm + cdim = solver.mesh.cdim + csec = dm.getCoordinateSection() + cvec = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, cdim) + v0, v1 = dm.getDepthStratum(0) + lsec = dm.getLocalSection() + sym_fn, const_normal = _normal_source(solver, normal, SHEET_TANGENCY) + pStart, pEnd = lsec.getChart() + nodes = [] + for q in range(pStart, pEnd): + if lsec.getFieldDof(q, _VELOCITY_FIELD) <= 0: + continue + if sym_fn is not None: + ne = np.asarray(sym_fn(*_point_coord(dm, cdim, cvec, csec, v0, v1, q)), + dtype=float).ravel() + else: + ne = const_normal.copy() + nodes.append((q, ne / (np.linalg.norm(ne) + 1e-30))) + return nodes + + def _boundary_velocity_nodes(solver, boundary, normal=None): """DMPlex points carrying velocity DOFs on `boundary`, each with an outward unit normal. Dimension-general (2D edges, 3D faces). @@ -176,7 +238,7 @@ def _boundary_velocity_nodes(solver, boundary, normal=None): only rescale it (and by a rank-count-dependent factor). """ dm = solver.dm - dim = solver.mesh.dim + dim = solver.mesh.cdim csec = dm.getCoordinateSection() cvec = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, dim) v0, v1 = dm.getDepthStratum(0) @@ -203,24 +265,7 @@ def coord(q): # whose UWexpressions hide the Cartesian coordinates behind ``r`` etc.) reduces # to pure mesh.X before lambdify — otherwise the generated function references # the curvilinear symbol by bare name and dies with a NameError at call time. - sym_fn = None - const_normal = None - if normal is not None: - if isinstance(normal, sympy.Matrix): - from underworld3.function.expressions import unwrap - comps = [sympy.sympify(unwrap(normal[0, k], keep_constants=False, - return_self=False)) - for k in range(dim)] - stray = set().union(*[c.free_symbols for c in comps]) \ - - set(solver.mesh.X) - if stray: - raise ValueError( - f"analytic normal for boundary '{boundary}' contains " - f"symbols {sorted(map(str, stray))} that are not mesh " - "coordinates — express it in mesh.X (e.g. radial X/|X|).") - sym_fn = sympy.lambdify(list(solver.mesh.X), comps, "numpy") - else: - const_normal = np.asarray(normal, dtype=float).ravel() + sym_fn, const_normal = _normal_source(solver, normal, boundary) nacc = {} # velocity node → Σ_f measure · n̂_f for f in facets: @@ -285,7 +330,7 @@ def _sum_facet_normals_across_ranks(solver, contribs): COLLECTIVE — a rank owning no facet of this boundary still takes part. """ dm = solver.dm - dim = solver.mesh.dim + dim = solver.mesh.cdim if dm.comm.getSize() == 1: return contribs @@ -381,7 +426,7 @@ def build_rotation(solver, boundaries, datum_specs=None): dm = solver.dm lsec = dm.getLocalSection() l2g = dm.getLGMap() - dim = solver.mesh.dim + dim = solver.mesh.cdim # gather all normals per velocity node across the boundaries (and, per node, the # datum of the boundary it came from — the surface's ũ_n for a prescribed-normal @@ -391,7 +436,9 @@ def build_rotation(solver, boundaries, datum_specs=None): for spec in boundaries: name, normal = _boundary_spec(spec) dspec = None if datum_specs is None else datum_specs.get(name) - for q, nrm in _boundary_velocity_nodes(solver, name, normal=normal): + nodes = (_manifold_velocity_nodes(solver, normal) if name == SHEET_TANGENCY + else _boundary_velocity_nodes(solver, name, normal=normal)) + for q, nrm in nodes: node_normals.setdefault(q, []).append(nrm) if dspec is not None: node_dspec.setdefault(q, dspec) @@ -2268,7 +2315,7 @@ def boundary_normal_traction(solver, boundary, solve_result, mass="auto"): triangular P1/P2 traces are supported in 3D. """ dm = solver.dm - dim = solver.mesh.dim + dim = solver.mesh.cdim # Cartesian nodal reaction r_c = F(u) at the converged state, stashed by the # solve driver (the final ``computeFunction`` residual; for a linear residual # this equals the assembled A·u−b exactly). @@ -2326,7 +2373,7 @@ def boundary_normal_traction_integral(solver, boundary, solve_result, fn, fn = sympy.sympify(fn) dm = solver.dm comm = dm.comm.tompi4py() - dim = solver.mesh.dim + dim = solver.mesh.cdim rc = solve_result["reaction"] rstart, rend = rc.getOwnershipRange() rcl = dm.getLocalVec() @@ -2399,7 +2446,7 @@ def dynamic_topography_field(solver, boundary, solve_result, field, safe: the recovery is partition-independent and the write is local (each rank fills its own boundary nodes, matched by coordinate to the recovery output). """ - dim = solver.mesh.dim + dim = solver.mesh.cdim xs, sig = boundary_normal_traction(solver, boundary, solve_result, mass=mass) # σ_nn is already mean-removed (the ρg·h gauge); topography h = -σ_nn / (Δρ g). def key(c): @@ -2413,7 +2460,8 @@ def _rigid_rotation_modes(solver): """The Cartesian rigid-body rotation mode(s) as composite GLOBAL vectors (velocity DOFs only, zero pressure): ``[(-y,x)]`` in 2D, ``[e_k×r]`` for k=x,y,z in 3D — a spherical shell with free-slip on both boundaries has all - THREE. Parallel-safe: built via localToGlobal on the velocity sub-DM, so + THREE, and so does a sheet on the surface of a sphere (embedding dimension + three), whose tangency constraint every Killing field satisfies. Parallel-safe: built via localToGlobal on the velocity sub-DM, so shared nodes are handled by PETSc, not double-counted. The vectors are borrowed from the DM pool — every one must go back via ``dm.restoreGlobalVec``. COLLECTIVE: all ranks build the same mode list.""" @@ -2421,7 +2469,7 @@ def _rigid_rotation_modes(solver): v = solver.Unknowns.u c = v.coords saved = v.data.copy() - if solver.mesh.dim == 2: + if solver.mesh.cdim == 2: fields = [np.column_stack([-c[:, 1], c[:, 0]])] else: x, y, z = c[:, 0], c[:, 1], c[:, 2] diff --git a/tests/test_1078_thin_sheet_sphere.py b/tests/test_1078_thin_sheet_sphere.py new file mode 100644 index 000000000..f75736251 --- /dev/null +++ b/tests/test_1078_thin_sheet_sphere.py @@ -0,0 +1,102 @@ +"""Thin sheet on the surface of a sphere: the velocity lives in the embedding +space with a strong tangency constraint at every node. + +Closed forms: a rigid rotation is a Killing field of the sphere (zero surface +strain rate), so a force c (omega x X) against basal drag c returns exactly +u = omega x X; a toroidal manufactured solution of the sheet operator +converges at second order (flat-facet geometry) with machine-exact tangency. +""" + +import numpy as np +import pytest +import sympy + +import underworld3 as uw + +pytestmark = [pytest.mark.level_2] + + +def _sphere(cell_size, degree=2): + return uw.meshing.SphericalManifold(radius=1.0, cellSize=cell_size, degree=1, + qdegree=2 * degree + 1) + + +def _sheet(sphere, eta=1): + sheet = uw.systems.ThinSheet(sphere) + sheet.constitutive_model = uw.constitutive_models.ViscousFlowModel + sheet.constitutive_model.Parameters.shear_viscosity_0 = eta + sheet.tolerance = 1.0e-9 + return sheet + + +def _leak(sheet): + c = sheet.u.coords + u = sheet.u.array[:, 0, :] + rhat = c / np.linalg.norm(c, axis=1)[:, None] + return np.abs((u * rhat).sum(axis=1)).max() + + +@pytest.mark.tier_b +def test_rigid_rotation_is_a_killing_field(): + """u = omega x X has no surface strain rate: with basal drag c and the + force c (omega x X) the sheet returns the rotation exactly (P2 carries the + linear field) and the tangency constraint holds to machine precision.""" + sphere = _sphere(0.25) + X = sphere.X + x, y, z = X[0], X[1], X[2] + omega = sympy.Matrix([[sympy.Rational(1, 3), sympy.Rational(-1, 2), sympy.Rational(2, 7)]]) + rot = sympy.Matrix([[omega[1] * z - omega[2] * y, + omega[2] * x - omega[0] * z, + omega[0] * y - omega[1] * x]]) + sheet = _sheet(sphere) + sheet.basal_drag = 2 + sheet.bodyforce = list(2 * rot) + sheet.solve() + assert sheet._rotated_freeslip_info["converged"] + c = sheet.u.coords + exact = np.cross(np.array([1 / 3, -1 / 2, 2 / 7]), c) + assert np.abs(sheet.u.array[:, 0, :] - exact).max() < 1.0e-7 + assert _leak(sheet) < 1.0e-13 + + +def _toroidal_mms(sphere, eta, alpha): + """Manufactured toroidal l=2 field u_e = rhat x grad(z^2) = (2yz, -2xz, 0) + and the force that makes it the sheet solution with basal drag alpha.""" + X = sphere.X + x, y, z = X[0], X[1], X[2] + r = sympy.sqrt(x**2 + y**2 + z**2) + rhat = sympy.Matrix([[x, y, z]]) / r + P = sympy.eye(3) - rhat.T * rhat + u_e = sympy.Matrix([[2 * y * z, -2 * x * z, 0]]) + G = u_e.jacobian(sympy.Matrix([x, y, z])) * P + E = P * ((G + G.T) / 2) * P + sigma = 2 * eta * (E + E.trace() * P) + Xs = [x, y, z] + divs = sympy.Matrix([[sum(sigma[i, j].diff(Xs[k]) * P[k, j] + for j in range(3) for k in range(3)) for i in range(3)]]) + return u_e, (-divs + alpha * u_e).subs(r, 1) + + +@pytest.mark.tier_b +def test_manufactured_toroidal_solution_converges_at_second_order(): + """Relative nodal L2 error of the tangential velocity at h = 0.2 and 0.1: + second order (the flat-facet geometry limit for P2 on a curved surface), + tangency exact. Measured 2026-10-08: 6.9e-3 -> 1.8e-3.""" + eta, alpha = sympy.Integer(1), sympy.Integer(1) + errors = {} + for h in (0.2, 0.1): + sphere = _sphere(h) + u_e, force = _toroidal_mms(sphere, eta, alpha) + sheet = _sheet(sphere, eta=eta) + sheet.basal_drag = alpha + sheet.bodyforce = list(force) + sheet.solve() + assert sheet._rotated_freeslip_info["converged"] + assert _leak(sheet) < 1.0e-13 + c = sheet.u.coords + exact = np.column_stack([2 * c[:, 1] * c[:, 2], -2 * c[:, 0] * c[:, 2], np.zeros(len(c))]) + err = sheet.u.array[:, 0, :] - exact + errors[h] = np.sqrt((err**2).sum()) / np.sqrt((exact**2).sum()) + order = np.log(errors[0.2] / errors[0.1]) / np.log(2.0) + assert errors[0.1] < 3.0e-3, errors + assert order > 1.7, f"order {order:.2f}: {errors}" From 12dab0403e99278cc2d5ec4af0362f906f378627 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Thu, 8 Oct 2026 18:32:47 +1100 Subject: [PATCH 06/57] Split-node faults on the surface of a sphere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fault polyline given with three coordinates is read as great-circle arcs about the origin: line_cut's chord primitives become segment objects (a chord in the plane, an arc on the sphere) with the same cut algorithm on top; the containing-cell test, the tip placement and the signed cell area take their surface-mesh forms (the area signed against the outward direction, the mesh's winding convention read off the majority). reconnect orients a surface cell by its normal against the centroid, and the chain orientation lives in the embedding dimension. The contact frame of a coincident pair on a surface mesh is the in-surface conormal of the fault edge (PETSc's FVM normal is undefined for an edge in three dimensions), the trace tangent, and the surface normal — orthonormal- ised, because the conormal is perpendicular to the facet while the surface normal at the node is the analytic one: an O(h) misfit made the pair rotation non-orthogonal and released a normal mode the sheet has no stiffness against (a Killing field came back 5e-2 off, the same from LU). The pair block pins the jump along the conormal and both sides' tangency (the jump and the mean along the surface normal); the slip row stays free; a paired node leaves the sheet-tangency wall set. Strides in fault_contact are the embedding dimension and the interface assembler has one slip component on a surface. Tests: a rigid rotation (zero surface strain, so no traction on the fault) is reproduced exactly across a frictionless arc with zero slip; a short arc under the shear of the toroidal flow slips as the plane-stress Griffith crack within 12% at a/R = 0.25. The 2-D fault suite (line cut, split, contact, API, surfaces, placement, sheet faults) is unchanged: 110 passed. Underworld development team with AI support from Claude Code --- src/underworld3/utilities/fault_contact.py | 94 ++++++++-- src/underworld3/utilities/fault_split.py | 2 +- src/underworld3/utilities/line_cut.py | 191 ++++++++++++++------ src/underworld3/utilities/reconnect.py | 11 ++ src/underworld3/utilities/rotated_bc.py | 27 ++- tests/test_1079_thin_sheet_sphere_faults.py | 108 +++++++++++ 6 files changed, 366 insertions(+), 67 deletions(-) create mode 100644 tests/test_1079_thin_sheet_sphere_faults.py diff --git a/src/underworld3/utilities/fault_contact.py b/src/underworld3/utilities/fault_contact.py index 73814c394..c647390ad 100644 --- a/src/underworld3/utilities/fault_contact.py +++ b/src/underworld3/utilities/fault_contact.py @@ -156,7 +156,7 @@ def _compile_normal_spec(spec, mesh, boundary): result need not be unit length — the caller normalises — but must be nonzero at every fault node. """ - dim = mesh.dim + dim = mesh.cdim if isinstance(spec, str): if spec == "trace": if dim != 2: @@ -513,6 +513,64 @@ def solve_with_fault(solver, verbose=False, zero_init_guess=True, picard=0): return info +def _manifold_edge_conormal(dm, f): + """Length and unit conormal of a fault edge of a surface mesh: in the + tangent plane of the edge's single support cell, perpendicular to the + edge, pointing away from that cell (the Plus side's cell after the + split, so Plus -> Minus like the volume-mesh facet normal). PETSc's FVM + facet normal is not defined for an edge in three dimensions.""" + cdim = dm.getCoordinateDim() + csec = dm.getCoordinateSection() + cvec = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, cdim) + v0, v1 = dm.getDepthStratum(0) + P, Q = (cvec[csec.getOffset(int(v)) // cdim] for v in dm.getCone(f)) + cell = int(dm.getSupport(f)[0]) + verts = [int(p) for p in dm.getTransitiveClosure(cell)[0] if v0 <= p < v1] + Xc = np.array([cvec[csec.getOffset(v) // cdim] for v in verts]) + n_cell = np.cross(Xc[1] - Xc[0], Xc[2] - Xc[0]) + nu = np.cross(n_cell, Q - P) + nu = nu / (np.linalg.norm(nu) + 1e-30) + if (Xc.mean(axis=0) - 0.5 * (P + Q)) @ nu > 0.0: + nu = -nu + return float(np.linalg.norm(Q - P)), nu + + +def _pair_frame(solver, q, nrm): + """The rotation frame of a coincident pair at point ``q``: the fault + normal first, then the slip direction(s). On a volume mesh that is + ``[n] + _tangent_frame(n)``; on a surface mesh the fault normal is the + in-surface conormal, the slip direction is the trace tangent and the + third vector is the surface normal, whose jump AND mean rows the + tangency constraint pins (see ``rotated_bc.build_rotation``). The SINGLE + authority for the pair frame: the rotated pair blocks and the interface + assembler's slip rows must agree row for row.""" + mesh = solver.mesh + if mesh.dim == mesh.cdim: + return [np.asarray(nrm, dtype=float)] + _tangent_frame(nrm) + from underworld3.utilities.rotated_bc import _normal_source, _point_coord + dm = solver.dm + cdim = mesh.cdim + csec = dm.getCoordinateSection() + cvec = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, cdim) + v0, v1 = dm.getDepthStratum(0) + sym_fn, const = _normal_source(solver, mesh.CoordinateSystem.unit_e_0, "fault") + X = _point_coord(dm, cdim, cvec, csec, v0, v1, int(q)) + r = np.asarray(sym_fn(*X), dtype=float).ravel() if sym_fn is not None else const + r = r / (np.linalg.norm(r) + 1e-30) + # The conormal is perpendicular to the FACET normal, the surface normal is + # the analytic one at the node: they differ by O(h), and a frame that is + # not orthonormal makes the pair rotation non-orthogonal — the pinned row + # along r then leaks into the free row along nu and releases a normal + # mode the sheet has no stiffness against (measured: a Killing field + # returned 5e-2 off near the fault, the same from LU). Orthogonalise. + nu = np.asarray(nrm, dtype=float) + nu = nu - (nu @ r) * r + nu = nu / (np.linalg.norm(nu) + 1e-30) + t = np.cross(r, nu) + t = t / (np.linalg.norm(t) + 1e-30) + return [nu, t, r] + + def _fault_pair_nodes(solver, boundary): """The velocity-carrying coincident pairs of a split fault, with normals. @@ -536,7 +594,7 @@ def _fault_pair_nodes(solver, boundary): """ mesh = solver.mesh dm = solver.dm - dim = mesh.dim + dim = mesh.cdim lsec = dm.getLocalSection() fS, fE = dm.getHeightStratum(1) @@ -573,8 +631,12 @@ def _fault_pair_nodes(solver, boundary): # verbatim copy of those six lines, and copies of them drifting apart is what # produced #560 and then #564. nacc = {} + manifold = mesh.dim < mesh.cdim for f in facets: - vol, ne, _exterior = facet_measure_and_normal(dm, f, orient="support0") + if manifold: + vol, ne = _manifold_edge_conormal(dm, f) + else: + vol, ne, _exterior = facet_measure_and_normal(dm, f, orient="support0") for q in (int(c) for c in dm.getTransitiveClosure(f)[0]): if lsec.getFieldDof(q, _VELOCITY_FIELD) > 0: nacc[q] = nacc.get(q, np.zeros(dim)) + float(vol) * ne @@ -682,8 +744,11 @@ class _InterfaceAssembler: def __init__(self, solver, include=()): dm = solver.dm - dim = solver.mesh.dim - ncomp = dim - 1 # slip components per node + dim = solver.mesh.cdim + # slip components per node: one along the trace on a surface mesh + # (the third frame vector is the pinned surface normal), dim - 1 on + # a volume mesh + ncomp = 1 if solver.mesh.dim < dim else dim - 1 lsec = dm.getLocalSection() l2g = dm.getLGMap() csec = dm.getCoordinateSection() @@ -740,7 +805,7 @@ def node_index(q): for a in range(ncomp)]) lo_p.append(lsec.getFieldOffset(q, _VELOCITY_FIELD)) lo_m.append(lo) - tans.append(_tangent_frame(nrm)) + tans.append(_pair_frame(solver, q, nrm)[1:1 + ncomp]) nrms.append(nrm) return index_of[q] @@ -756,7 +821,7 @@ def node_index(q): .getStratumIS(value).getIndices()): if not (fS <= f < fE): continue - if dim == 2: + if solver.mesh.dim == 2: va, vb = (int(q) for q in dm.getCone(f)) L = float(np.linalg.norm( cvec[csec.getOffset(va) // dim] @@ -1039,7 +1104,7 @@ def fault_normal_traction(solver, boundary, solve_result): sig = assembler.nodal_normal_traction(solver, solve_result["reaction"]) dm = solver.dm - dim = solver.mesh.dim + dim = solver.mesh.cdim csec = dm.getCoordinateSection() cvec = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, dim) v0, v1 = dm.getDepthStratum(0) @@ -1083,7 +1148,7 @@ def fault_pair_jumps(solver, boundary, solve_result, gather=False): comm = solver.mesh.dm.comm.tompi4py() if comm.size == 1: return coords, jumps, normals - dim = solver.mesh.dim + dim = solver.mesh.cdim parts = comm.allgather((np.asarray(coords, dtype=float).reshape(-1, dim), np.asarray(jumps, dtype=float).reshape(-1, dim), np.asarray(normals, dtype=float).reshape(-1, dim))) @@ -1093,7 +1158,7 @@ def fault_pair_jumps(solver, boundary, solve_result, gather=False): def _fault_pair_jumps_local(solver, boundary, solve_result): """The rank-local half of :func:`fault_pair_jumps`.""" dm = solver.dm - dim = solver.mesh.dim + dim = solver.mesh.cdim lsec = dm.getLocalSection() l2g = dm.getLGMap() U = solve_result["U"] @@ -1144,7 +1209,14 @@ def fault_slip(solver, boundary, solve_result): raise NotImplementedError( "fault_slip's arc-length profile is 2-D; use fault_pair_jumps " "for a 3-D patch.") - tangents = np.column_stack([-normals[:, 1], normals[:, 0]]) + if coords.shape[1] == 3: + # a surface mesh: the trace tangent is the surface normal (radial on + # the sphere) crossed with the in-surface conormal + rhat = coords / np.linalg.norm(coords, axis=1)[:, None] + tangents = np.cross(rhat, normals) + tangents /= np.linalg.norm(tangents, axis=1)[:, None] + else: + tangents = np.column_stack([-normals[:, 1], normals[:, 0]]) # Along-fault coordinate from the mean tangent, origin at the trailing end. tbar = tangents.mean(axis=0) tbar /= np.linalg.norm(tbar) + 1e-30 diff --git a/src/underworld3/utilities/fault_split.py b/src/underworld3/utilities/fault_split.py index 514f58e23..d652a0cbe 100644 --- a/src/underworld3/utilities/fault_split.py +++ b/src/underworld3/utilities/fault_split.py @@ -183,7 +183,7 @@ def _fault_chain(dm, fault_edges, X, vS, shared, pStart, orientation=None): # side is the LEFT of the directed line the USER drew, not of the # coordinate-order tip rule. if orientation is not None and len(chain) >= 2: - d = np.asarray(orientation, dtype=float).ravel()[:2] + d = np.asarray(orientation, dtype=float).ravel()[:X.shape[1]] if float((X[chain[-1] - vS] - X[chain[0] - vS]) @ d) < 0.0: chain.reverse() return None, chain diff --git a/src/underworld3/utilities/line_cut.py b/src/underworld3/utilities/line_cut.py index 8e27293b8..515baf5ee 100644 --- a/src/underworld3/utilities/line_cut.py +++ b/src/underworld3/utilities/line_cut.py @@ -139,13 +139,76 @@ def _global_extent(dm): return float((-box[cdim:] - box[:cdim]).max()) +class _Chord: + """A straight segment A -> B of a planar polyline.""" + + def __init__(self, A, B): + self.A, self.B = A, B + self.d = B - A + self.dd = float(self.d @ self.d) + self.nrm = np.array([-self.d[1], self.d[0]]) + + def foot(self, X): + u = np.clip(((X - self.A) @ self.d) / self.dd, 0.0, 1.0) + return self.A + u[:, None] * self.d + + def distance(self, X): + return np.linalg.norm(X - self.foot(X), axis=1) + + def side(self, X): + """Zero on the line, opposite signs either side.""" + return (X - self.A) @ self.nrm + + def within(self, F): + """Points of the line between A and B, not on its infinite extension: + a polyline is a chain of finite segments.""" + u = ((F - self.A) @ self.d) / self.dd + return (u >= 0.0) & (u <= 1.0) + + +class _GreatCircleArc: + """The short great-circle arc A -> B about the origin: a segment of a + polyline on a sphere. A polyline given with three coordinates is read this + way — the surface meshes this module cuts are spheres centred on the + origin (``SphericalManifold``). The plane through A, B and the origin plays + the part of the chord's line; a point is on the arc when it lies in that + plane between the end points.""" + + def __init__(self, A, B): + self.A, self.B = A, B + m = np.cross(A, B) + self.m = m / np.linalg.norm(m) + + def within(self, F): + return ((np.cross(self.A, F) @ self.m) >= 0.0) & ((np.cross(F, self.B) @ self.m) >= 0.0) + + def foot(self, X): + # drop the out-of-plane component and keep the point's own radius, so + # a vertex moved onto the arc stays on its sphere + Fp = X - np.outer(X @ self.m, self.m) + radius = np.linalg.norm(X, axis=1) / np.maximum(np.linalg.norm(Fp, axis=1), 1e-300) + Fp = Fp * radius[:, None] + inside = self.within(Fp) + nearer_A = np.linalg.norm(X - self.A, axis=1) <= np.linalg.norm(X - self.B, axis=1) + end = np.where(nearer_A[:, None], self.A, self.B) + return np.where(inside[:, None], Fp, end) + + def distance(self, X): + return np.linalg.norm(X - self.foot(X), axis=1) + + def side(self, X): + return X @ self.m + + def _segments(lines): - """Every (A, B) segment of every polyline.""" + """Every segment of every polyline: chords of a planar polyline, great-circle + arcs of one given on a sphere (three coordinates).""" for pts in lines: - pts = np.asarray(pts, dtype=float)[:, :2] + pts = np.asarray(pts, dtype=float) + kind = _GreatCircleArc if pts.shape[1] == 3 else _Chord for A, B in zip(pts[:-1], pts[1:]): if np.any(B != A): - yield A, B + yield kind(A, B) def _distance_to_lines(X, lines): @@ -155,10 +218,8 @@ def _distance_to_lines(X, lines): attracting vertices beyond its own end. """ best = np.full(len(X), np.inf) - for A, B in _segments(lines): - d = B - A - u = np.clip(((X - A) @ d) / (d @ d), 0.0, 1.0) - best = np.minimum(best, np.linalg.norm(X - (A + u[:, None] * d), axis=1)) + for seg in _segments(lines): + best = np.minimum(best, seg.distance(X)) return best @@ -166,10 +227,8 @@ def _project_onto_lines(X, lines): """The nearest point of the polylines to each point.""" best = np.full(len(X), np.inf) out = X.copy() - for A, B in _segments(lines): - d = B - A - u = np.clip(((X - A) @ d) / (d @ d), 0.0, 1.0) - foot = A + u[:, None] * d + for seg in _segments(lines): + foot = seg.foot(X) dist = np.linalg.norm(X - foot, axis=1) closer = dist < best best = np.where(closer, dist, best) @@ -191,23 +250,17 @@ def _crossing_parameters(X, ends, lines, on_line): """ P, Q = X[ends[:, 0]], X[ends[:, 1]] touches = on_line[ends[:, 0]] | on_line[ends[:, 1]] - t = np.full(len(P), np.nan) multiply_crossed = np.zeros(len(P), dtype=bool) - for A, B in _segments(lines): - d = B - A - nrm = np.array([-d[1], d[0]]) - sP, sQ = (P - A) @ nrm, (Q - A) @ nrm + for seg in _segments(lines): + sP, sQ = seg.side(P), seg.side(Q) straddles = (sP * sQ < 0.0) & ~touches - with np.errstate(invalid="ignore", divide="ignore"): tk = np.where(straddles, sP / (sP - sQ), np.nan) - # The crossing must lie between A and B, not merely on the infinite line - # through them: a polyline is a chain of finite segments. - foot = P + tk[:, None] * (Q - P) - u = ((foot - A) @ d) / (d @ d) - hit = straddles & (u >= 0.0) & (u <= 1.0) - + # The crossing must lie between the segment's ends, not merely on the + # infinite line (or great circle) through them. + foot = P + np.where(straddles, tk, 0.0)[:, None] * (Q - P) + hit = straddles & seg.within(foot) multiply_crossed |= hit & np.isfinite(t) t = np.where(hit, tk, t) return t, np.flatnonzero(multiply_crossed) @@ -401,21 +454,38 @@ def _minimal_face_vertices(X, cell_verts, point): any size — which embedded meshes need, their cell sizes differing by an order of magnitude either side of a zone skin. """ - A = X[cell_verts[:, 0], :2] - B = X[cell_verts[:, 1], :2] - C = X[cell_verts[:, 2], :2] - v0, v1, v2 = C - A, B - A, np.asarray(point, dtype=float)[:2] - A - den = v0[:, 0] * v1[:, 1] - v1[:, 0] * v0[:, 1] - # A degenerate cell has no interior to be inside of; it cannot hold the - # point and must not divide. - alive = np.abs(den) > 0.0 - den = np.where(alive, den, 1.0) - lc = (v2[:, 0] * v1[:, 1] - v1[:, 0] * v2[:, 1]) / den - lb = (v0[:, 0] * v2[:, 1] - v2[:, 0] * v0[:, 1]) / den - la = 1.0 - lb - lc - + A = X[cell_verts[:, 0]] + B = X[cell_verts[:, 1]] + C = X[cell_verts[:, 2]] + point = np.asarray(point, dtype=float) tol = 1e-9 - inside = np.flatnonzero(alive & (la >= -tol) & (lb >= -tol) & (lc >= -tol)) + if X.shape[1] == 2: + v0, v1, v2 = C - A, B - A, point[:2] - A + den = v0[:, 0] * v1[:, 1] - v1[:, 0] * v0[:, 1] + alive = np.abs(den) > 0.0 + den = np.where(alive, den, 1.0) + lc = (v2[:, 0] * v1[:, 1] - v1[:, 0] * v2[:, 1]) / den + lb = (v0[:, 0] * v2[:, 1] - v2[:, 0] * v0[:, 1]) / den + la = 1.0 - lb - lc + inside = np.flatnonzero(alive & (la >= -tol) & (lb >= -tol) & (lc >= -tol)) + else: + # a surface cell in three dimensions: barycentric coordinates of the + # point's projection onto the cell's plane, the point no further from + # that plane than the cell's own size + e1, e2, v = B - A, C - A, point - A + g11, g12, g22 = (e1 * e1).sum(1), (e1 * e2).sum(1), (e2 * e2).sum(1) + r1, r2 = (v * e1).sum(1), (v * e2).sum(1) + den = g11 * g22 - g12 * g12 + alive = den > 0.0 + den = np.where(alive, den, 1.0) + lb = (r1 * g22 - r2 * g12) / den + lc = (r2 * g11 - r1 * g12) / den + la = 1.0 - lb - lc + n = np.cross(e1, e2) + area2 = np.maximum(np.linalg.norm(n, axis=1), 1e-300) + height = np.abs((v * n).sum(1)) / area2 + near = height < 0.5 * np.sqrt(area2) + inside = np.flatnonzero(alive & near & (la >= -tol) & (lb >= -tol) & (lc >= -tol)) if not len(inside): return np.empty(0, dtype=np.int64) # One containing cell settles it; the first keeps the answer independent @@ -439,12 +509,28 @@ def _cell_quality(X, cell_verts): P = X[cell_verts] e = np.stack([P[:, 2] - P[:, 1], P[:, 0] - P[:, 2], P[:, 1] - P[:, 0]], axis=1) - twice_area = ((P[:, 1, 0] - P[:, 0, 0]) * (P[:, 2, 1] - P[:, 0, 1]) - - (P[:, 1, 1] - P[:, 0, 1]) * (P[:, 2, 0] - P[:, 0, 0])) + twice_area = _signed_twice_areas(P) return 2.0 * np.sqrt(3.0) * twice_area / np.maximum( (e ** 2).sum(axis=(1, 2)), np.finfo(float).tiny) +def _signed_twice_areas(P): + """Twice the signed area of each triangle ``P[:, 0:3]``: the planar cross + product in two dimensions; on a surface mesh in three, the cell normal + against the outward direction (the centroid's, the surface being a sphere + about the origin). A mesh is wound one way throughout, so the convention + is read off the majority and an inverted cell is one against it.""" + if P.shape[2] == 2: + return ((P[:, 1, 0] - P[:, 0, 0]) * (P[:, 2, 1] - P[:, 0, 1]) + - (P[:, 1, 1] - P[:, 0, 1]) * (P[:, 2, 0] - P[:, 0, 0])) + n = np.cross(P[:, 1] - P[:, 0], P[:, 2] - P[:, 0]) + c = P.sum(axis=1) + signed = (n * c).sum(axis=1) / np.maximum(np.linalg.norm(c, axis=1), 1e-300) + if (signed < 0.0).sum() > 0.5 * len(signed): + signed = -signed + return signed + + def _cell_edge_counts(dm, crossed_edges, on_line_vertices): """Per cell: how many of its edges are crossed, how many corners are on a line.""" cS, cE = dm.getHeightStratum(0) @@ -546,15 +632,15 @@ def cell_areas(dm): X = _coords(dm) vS, _vE = dm.getDepthStratum(0) cS, cE = dm.getHeightStratum(0) - out = np.empty(cE - cS) + rings = [] for c in range(cS, cE): ring = [] for e, o in zip(dm.getCone(c), dm.getConeOrientation(c)): a, b = (int(v) - vS for v in dm.getCone(e)) ring.append(a if o >= 0 else b) - p, q, r = X[ring[0]], X[ring[1]], X[ring[2]] - out[c - cS] = 0.5 * ((q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0])) - return out + rings.append(ring[:3]) + P = X[np.asarray(rings, dtype=np.int64)] if rings else np.empty((0, 3, X.shape[1])) + return 0.5 * _signed_twice_areas(P) def min_angles(dm): @@ -872,7 +958,8 @@ def pull_vertex_onto(dm, targets): dm : PETSc.DMPlex **Not modified.** The pull is returned as a new mesh. targets : array_like - An ``(N, 2)`` array of points, or one point. + An ``(N, cdim)`` array of points, or one point — three coordinates on + a sphere surface mesh. Returns ------- @@ -906,7 +993,8 @@ def pull_vertex_onto(dm, targets): scale = _global_extent(dm) cell_verts = _cell_vertices(dm) - for t in np.atleast_2d(np.asarray(targets, dtype=float))[:, :2]: + cdim = X.shape[1] + for t in np.atleast_2d(np.asarray(targets, dtype=float))[:, :cdim]: # Candidates are the vertices of the smallest face CONTAINING the # target, not every vertex in the mesh. The nearest vertex overall can # belong to a neighbouring cell, and moving it leaves the containing @@ -919,7 +1007,7 @@ def pull_vertex_onto(dm, targets): face = _minimal_face_vertices(X, cell_verts, t) held = len(face) > 0 candidates = face if held else np.arange(len(X)) - d = np.linalg.norm(X[candidates, :2] - t, axis=1) + d = np.linalg.norm(X[candidates] - t, axis=1) # Reduced as (outside, distance, x, y) so both decisions ride one # reduction: tuples compare lexicographically, so MIN prefers ANY rank # that holds a containing cell over every rank that does not, then the @@ -933,16 +1021,15 @@ def pull_vertex_onto(dm, targets): # containment rule, kept because a target off the mesh still has to # land somewhere. local = ((0 if held else 1, float(d.min()), - *X[candidates[int(d.argmin())], :2]) - if d.size else (1, np.inf, np.inf, np.inf)) - _outside, _dist, tx, ty = uw.mpi.comm.allreduce(local, op=MPI.MIN) + *X[candidates[int(d.argmin())]]) + if d.size else (1, np.inf, *([np.inf] * cdim))) + reduced = uw.mpi.comm.allreduce(local, op=MPI.MIN) # Move it by POSITION, not by index: the chosen vertex may be a ghost # here and an owned point there, and both copies have to end up in the # same place without a star-forest exchange. - hit = np.flatnonzero(np.linalg.norm(X[:, :2] - np.array([tx, ty]), - axis=1) < 1e-12 * scale) - arr[hit, :2] = t + hit = np.flatnonzero(np.linalg.norm(X - np.array(reduced[2:]), axis=1) < 1e-12 * scale) + arr[hit] = t out = dm.clone() _set_coordinates(out, np.arange(len(arr)), arr) diff --git a/src/underworld3/utilities/reconnect.py b/src/underworld3/utilities/reconnect.py index e5986f872..7ab7809d5 100644 --- a/src/underworld3/utilities/reconnect.py +++ b/src/underworld3/utilities/reconnect.py @@ -228,6 +228,17 @@ def _orient2d(pa, pb, pc): Returns ``_UNCERTAIN`` when the filter cannot resolve the sign. """ + if len(pa) == 3: + # A triangle of a surface mesh in three dimensions, anticlockwise as + # seen from outside: the outward direction is the centroid's, the + # surface being a sphere about the origin. + n = np.cross(np.asarray(pa) - np.asarray(pc), np.asarray(pb) - np.asarray(pc)) + c = np.asarray(pa) + np.asarray(pb) + np.asarray(pc) + det = float(n @ c) + bound = float(np.linalg.norm(n) * np.linalg.norm(c)) + if det == 0.0 or abs(det) < _ORIENT_BOUND * bound: + return _UNCERTAIN + return 1 if det > 0.0 else -1 acx, acy = pa[0] - pc[0], pa[1] - pc[1] bcx, bcy = pb[0] - pc[0], pb[1] - pc[1] left, right = acx * bcy, acy * bcx diff --git a/src/underworld3/utilities/rotated_bc.py b/src/underworld3/utilities/rotated_bc.py index 995463863..894e43c34 100644 --- a/src/underworld3/utilities/rotated_bc.py +++ b/src/underworld3/utilities/rotated_bc.py @@ -433,6 +433,7 @@ def build_rotation(solver, boundaries, datum_specs=None): # solve; None for free-slip). node_normals = {} node_dspec = {} + node_specs = {} for spec in boundaries: name, normal = _boundary_spec(spec) dspec = None if datum_specs is None else datum_specs.get(name) @@ -440,12 +441,26 @@ def build_rotation(solver, boundaries, datum_specs=None): else _boundary_velocity_nodes(solver, name, normal=normal)) for q, nrm in nodes: node_normals.setdefault(q, []).append(nrm) + node_specs.setdefault(q, set()).add(name) if dspec is not None: node_dspec.setdefault(q, dspec) # Split-fault contact pairs (fault_contact.add_frictionless_fault_bc): # their 2·dim mean/jump blocks widen the widest row of Q. fault_names = getattr(solver, "_fault_contact_faults", []) + # On a surface mesh every node carries the sheet-tangency constraint; a + # paired fault node takes it through its PAIR block instead (the mean and + # jump rows along the surface normal), so it leaves the wall-block set. + manifold = solver.mesh.dim < solver.mesh.cdim + pair_nodes_by_fault = {} + if fault_names and manifold: + from underworld3.utilities import fault_contact + for fname in fault_names: + pair_nodes_by_fault[fname] = fault_contact._fault_pair_nodes(solver, fname) + for q_plus, q_minus, _nrm in pair_nodes_by_fault[fname]: + for q in (q_plus, q_minus): + if q in node_normals and node_specs[q] <= {SHEET_TANGENCY}: + del node_normals[q] # Distributed Q with the assembled operator's ROW layout. Q is identity except a # per-node dim×dim orthonormal block; because a node's dim velocity components @@ -557,8 +572,8 @@ def build_rotation(solver, boundaries, datum_specs=None): problem = None try: for fname in fault_names: - for q_plus, q_minus, nrm in fault_contact._fault_pair_nodes( - solver, fname): + for q_plus, q_minus, nrm in (pair_nodes_by_fault.get(fname) + or fault_contact._fault_pair_nodes(solver, fname)): if q_plus in node_normals or q_minus in node_normals: raise ValueError( f"fault {fname!r} shares a node with a rotated " @@ -570,7 +585,7 @@ def build_rotation(solver, boundaries, datum_specs=None): # specific in-plane directions carry no physics: both # slip rows are free (frictionless) or enter through # |V| (laws). - frame = [nrm] + fault_contact._tangent_frame(nrm) + frame = fault_contact._pair_frame(solver, q_plus, nrm) gp, gm = [], [] for q, rows in ((q_plus, gp), (q_minus, gm)): lo = lsec.getFieldOffset(q, _VELOCITY_FIELD) @@ -599,6 +614,12 @@ def build_rotation(solver, boundaries, datum_specs=None): Q.setValue(row, gm[j], sgn * s2 * float(e[j])) normal_rows.append(gm[0]) # [v]·n̂ = 0, datum 0 + if manifold: + # both sides tangent to the surface: the jump and the + # mean along the surface normal (the frame's last + # vector) are pinned; the slip row stays free + normal_rows.append(gm[2]) + normal_rows.append(gp[2]) except (RuntimeError, ValueError) as err: problem = str(err) fault_contact._collective_raise(problem) diff --git a/tests/test_1079_thin_sheet_sphere_faults.py b/tests/test_1079_thin_sheet_sphere_faults.py new file mode 100644 index 000000000..bd13c90d1 --- /dev/null +++ b/tests/test_1079_thin_sheet_sphere_faults.py @@ -0,0 +1,108 @@ +"""Split-node faults in a thin sheet on the surface of a sphere. + +The fault is a great-circle arc cut into the SphericalManifold; its coincident +pairs carry the no-opening constraint along the in-surface conormal and the +tangency of both sides, and slip along the trace. A rigid rotation is a Killing +field (zero surface strain), so it loads a frictionless fault with no traction +at all: the faulted solution must be the rotation exactly, with zero slip. A +short arc under the shear of the toroidal flow slips as the Griffith mode-II +crack of the plane-stress sheet, E' = 3 eta, up to the curvature of the sphere. +""" + +import numpy as np +import pytest +import sympy + +import underworld3 as uw +from underworld3.utilities.fault_contact import fault_pair_jumps, fault_slip + +pytestmark = [pytest.mark.level_2] + + +def _equator_arc(half_angle, n=9): + phi = np.linspace(-half_angle, half_angle, n) + return np.column_stack([np.cos(phi), np.sin(phi), np.zeros_like(phi)]) + + +def _faulted_sphere(cell_size, half_angle): + sphere = uw.meshing.SphericalManifold(radius=1.0, cellSize=cell_size, degree=1, qdegree=5) + return sphere.add_fault(("Flt", _equator_arc(half_angle))) + + +def _sheet(split, eta=1): + sheet = uw.systems.ThinSheet(split) + sheet.constitutive_model = uw.constitutive_models.ViscousFlowModel + sheet.constitutive_model.Parameters.shear_viscosity_0 = eta + sheet.tolerance = 1.0e-9 + return sheet + + +@pytest.mark.tier_b +def test_a_killing_field_leaves_the_fault_untouched(): + """u = omega x X has no surface strain, so the frictionless fault sees no + traction: the solution is the rotation exactly, the slip and the opening + are zero, and every node (both sides of the fault included) stays tangent.""" + split = _faulted_sphere(0.2, np.pi / 6) + X = split.X + x, y, z = X[0], X[1], X[2] + omega = (sympy.Rational(1, 3), sympy.Rational(-1, 2), sympy.Rational(2, 7)) + rot = sympy.Matrix([[omega[1] * z - omega[2] * y, + omega[2] * x - omega[0] * z, + omega[0] * y - omega[1] * x]]) + sheet = _sheet(split) + sheet.basal_drag = 2 + sheet.bodyforce = list(2 * rot) + sheet.add_fault_bc(0, "Flt") + sheet.solve() + info = sheet._rotated_freeslip_info + assert info["converged"] + c = sheet.u.coords + exact = np.cross(np.array([1 / 3, -1 / 2, 2 / 7]), c) + assert np.abs(sheet.u.array[:, 0, :] - exact).max() < 1.0e-7 + rhat = c / np.linalg.norm(c, axis=1)[:, None] + assert np.abs((sheet.u.array[:, 0, :] * rhat).sum(axis=1)).max() < 1.0e-13 + _s, V, leak = fault_slip(sheet, "Flt", info) + assert np.abs(V).max() < 1.0e-7 + assert np.abs(leak).max() < 1.0e-13 + + +@pytest.mark.tier_b +def test_short_arc_slips_as_the_griffith_crack(): + """The toroidal field u = rhat x grad(z^2) shears the equator with + E_yz = -x: at the arc's centre (1, 0, 0) the fault-parallel shear traction + is tau = 2 eta. A frictionless arc of half-length a slips as the plane-stress + mode-II crack, (4 tau / 3 eta) sqrt(a^2 - s^2), with the curvature of the + sphere (a / R = 0.25) and the basal screening inside the 12% margin + (measured 2026-10-09, recorded in the commit that added this test).""" + a = 0.25 + split = _faulted_sphere(0.05, a) + X = split.X + x, y, z = X[0], X[1], X[2] + r = sympy.sqrt(x**2 + y**2 + z**2) + rhat = sympy.Matrix([[x, y, z]]) / r + P = sympy.eye(3) - rhat.T * rhat + u_e = sympy.Matrix([[2 * y * z, -2 * x * z, 0]]) + G = u_e.jacobian(sympy.Matrix([x, y, z])) * P + E = P * ((G + G.T) / 2) * P + sigma = 2 * (E + E.trace() * P) + Xs = [x, y, z] + divs = sympy.Matrix([[sum(sigma[i, j].diff(Xs[k]) * P[k, j] + for j in range(3) for k in range(3)) for i in range(3)]]) + sheet = _sheet(split, eta=1) + sheet.basal_drag = 1 + sheet.bodyforce = list((-divs + u_e).subs(r, 1)) + sheet.add_fault_bc(0, "Flt") + sheet.solve() + info = sheet._rotated_freeslip_info + assert info["converged"] + coords, jumps, normals = fault_pair_jumps(sheet, "Flt", info) + leak = np.einsum("ij,ij->i", jumps, normals) + assert np.abs(leak).max() < 1.0e-13 + s_arc = np.arctan2(coords[:, 1], coords[:, 0]) # arc length on the unit sphere + V = np.linalg.norm(jumps, axis=1) + tau = 2.0 + ellipse = (4.0 * tau / 3.0) * np.sqrt(np.maximum(a**2 - s_arc**2, 0.0)) + peak = 4.0 * tau * a / 3.0 + assert abs(V.max() - peak) < 0.12 * peak, f"peak slip {V.max():.4f} vs {peak:.4f}" + rms = np.sqrt(np.mean((V - ellipse) ** 2)) + assert rms < 0.12 * peak, f"rms profile error {rms:.4f} vs peak {peak:.4f}" From 8f9191fc4a2b8935f3d0cec6790fad06841198f6 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Thu, 8 Oct 2026 18:53:07 +1100 Subject: [PATCH 07/57] A Maxwell sheet on the sphere: tensor histories sized by the embedding dimension A stress history on a surface mesh is a 3x3 tensor. The symmetric-tensor data layout and Voigt list of a MeshVariable, the history projection's component count, the Eulerian history's velocity check, the velocity-trace variables and the objective-rate term were all sized by the topological dimension; they now use the embedding dimension (identical on a volume mesh). ThinSheet carries its history on the grid on a manifold (the Eulerian streamline-upwind transport): the semi-Lagrangian trace-back leaves the faceted surface and is not validated there. Test: with the grid history a rigid rotation stays the exact solution of the Maxwell sheet on the sphere step after step (3e-10), with the carried stress at round-off and tangency at machine precision. Flat viscoelastic sheet and Stokes shear-box tests unchanged. Underworld development team with AI support from Claude Code --- src/underworld3/constitutive_models.py | 3 +- .../discretisation_mesh_variables.py | 14 ++++++---- src/underworld3/systems/ddt.py | 14 ++++++---- src/underworld3/systems/thin_sheet.py | 6 ++++ tests/test_1078_thin_sheet_sphere.py | 28 +++++++++++++++++++ 5 files changed, 52 insertions(+), 13 deletions(-) diff --git a/src/underworld3/constitutive_models.py b/src/underworld3/constitutive_models.py index f8138f54a..a3d390674 100644 --- a/src/underworld3/constitutive_models.py +++ b/src/underworld3/constitutive_models.py @@ -2400,7 +2400,8 @@ def _objective_term(self, sigma): ``"none"``, :math:`L\sigma + \sigma L^T` for ``"upper_convected"``, :math:`W\sigma - \sigma W` with :math:`W = (L - L^T)/2` for ``"jaumann"``. Symmetric whenever ``sigma`` is.""" - dim = self.Unknowns.u.mesh.dim + # the carried stress has one component per embedding dimension + dim = self.Unknowns.u.mesh.cdim if self._objective_rate == "none": return sympy.zeros(dim, dim) sigma = sympy.Matrix(sigma) diff --git a/src/underworld3/discretisation/discretisation_mesh_variables.py b/src/underworld3/discretisation/discretisation_mesh_variables.py index f2a106c03..98f679a55 100644 --- a/src/underworld3/discretisation/discretisation_mesh_variables.py +++ b/src/underworld3/discretisation/discretisation_mesh_variables.py @@ -1787,7 +1787,8 @@ def sym_1d(self) -> sympy.Basic: if self.vtype != uw.VarType.SYM_TENSOR: return self._sym.reshape(1, len(self._sym)) else: - if self.mesh.dim == 2: + # Voigt order of a tensor with one index per EMBEDDING dimension + if self.mesh.cdim == 2: return sympy.Matrix( [ self._sym[0, 0], @@ -1808,22 +1809,23 @@ def sym_1d(self) -> sympy.Basic: ).T def _data_layout(self, i, j=None): - # mapping + # mapping; vectors and tensors carry one component per EMBEDDING + # dimension (cdim), which is what sizes the layout on a manifold mesh if self.vtype == uw.VarType.SCALAR: return 0 if self.vtype == uw.VarType.VECTOR: if i < 0 or j < 0: - return self.mesh.dim + return self.mesh.cdim else: if j is None: return i elif i == 0: return j else: - raise IndexError(f"Vectors have shape {self.mesh.dim} or {(1, self.mesh.dim)} ") + raise IndexError(f"Vectors have shape {self.mesh.cdim} or {(1, self.mesh.cdim)} ") if self.vtype == uw.VarType.TENSOR: - if self.mesh.dim == 2: + if self.mesh.cdim == 2: if i < 0 or j < 0: return 4 else: @@ -1835,7 +1837,7 @@ def _data_layout(self, i, j=None): return ((0, 1, 2), (3, 4, 5), (6, 7, 8))[i][j] if self.vtype == uw.VarType.SYM_TENSOR: - if self.mesh.dim == 2: + if self.mesh.cdim == 2: if i < 0 or j < 0: return 3 else: diff --git a/src/underworld3/systems/ddt.py b/src/underworld3/systems/ddt.py index 428fe0fd5..1a8117d33 100644 --- a/src/underworld3/systems/ddt.py +++ b/src/underworld3/systems/ddt.py @@ -1809,7 +1809,9 @@ def _setup_projections(self): self.mesh, self.psi_star[0], verbose=False ) elif self.vtype == uw.VarType.SYM_TENSOR or self.vtype == uw.VarType.TENSOR: - dim = self.mesh.dim + # a tensor history has one index per EMBEDDING dimension (a 3x3 + # stress on a sheet in three-space) + dim = self.mesh.cdim if self.vtype == uw.VarType.SYM_TENSOR: Nc = math.comb(dim + 1, 2) # 3 in 2D, 6 in 3D self._psi_star_indep_indices = [ @@ -2190,7 +2192,7 @@ def V_fn(self): @V_fn.setter def V_fn(self, value): - self._V_fn = None if value is None else _as_row_vector(value, self.mesh.dim) + self._V_fn = None if value is None else _as_row_vector(value, self.mesh.cdim) @property def integrator(self) -> str: @@ -2200,7 +2202,7 @@ def advecting_velocity(self, level: int = 0): """The velocity carrying the unknown at ``states()[level]``.""" if level == 0 or not self.V_fn_history: return self.V_fn - return _as_row_vector(self.V_fn_history[level - 1], self.mesh.dim) + return _as_row_vector(self.V_fn_history[level - 1], self.mesh.cdim) @property def tau_shape(self) -> str: @@ -2530,7 +2532,7 @@ def velocity_units(self): def _new_level(self, k): snap = uw.discretisation.MeshVariable( - f"vtrace_{self.instance_number}_n{k}", self.mesh, self.mesh.dim, + f"vtrace_{self.instance_number}_n{k}", self.mesh, self.mesh.cdim, degree=self.velocity_degree(), continuous=True, varsymbol=rf"{{ V^{{ (n-{k}) }}_{{ [{self.instance_number}] }} }}", units=self.velocity_units(), @@ -2556,7 +2558,7 @@ def level_valid(self, k): def _evaluate_into(self, level): vals = uw.function.evaluate(self.V_matrix(), np.asarray(level["var"].coords_nd)) vals = _to_nondim_ndarray(vals, units=self.velocity_units()) - level["var"].data[...] = np.asarray(vals).reshape(-1, self.mesh.dim) + level["var"].data[...] = np.asarray(vals).reshape(-1, self.mesh.cdim) def initialise_levels(self, n): """Every cached level ``1 .. n-1`` <- ``V_fn`` now (a history starting @@ -2611,7 +2613,7 @@ def velocity_at(self, expr, coords, use_global=False, evalf=False, v_at_pts = np.asarray(v_result) v_at_pts = v_at_pts[:, 0, :] if v_at_pts.ndim == 3 else v_at_pts out = _to_nondim_ndarray(v_at_pts, units=uw.get_units(self.V_fn)) - return np.asarray(out).reshape(coords.shape[0], self.mesh.dim) + return np.asarray(out).reshape(coords.shape[0], self.mesh.cdim) # -- the steps and the trace ----------------------------------------------- diff --git a/src/underworld3/systems/thin_sheet.py b/src/underworld3/systems/thin_sheet.py index f771fe1b7..d0052877c 100644 --- a/src/underworld3/systems/thin_sheet.py +++ b/src/underworld3/systems/thin_sheet.py @@ -211,6 +211,12 @@ def __init__( if mesh.dim < mesh.cdim: from underworld3.utilities.rotated_bc import SHEET_TANGENCY self._rotated_freeslip_bcs.append((SHEET_TANGENCY, _sheet_normal(mesh))) + # A stress history on a surface mesh is carried on the grid (the + # streamline-upwind transport the Eulerian solvers use): the + # semi-Lagrangian trace-back leaves the faceted surface and is not + # validated there. Measured: a Killing field stays exact (3e-10) + # over the steps with the grid history. + self._stress_transport = "eulerian" # Last-seen effective order of the stress history (it ramps over the # opening steps); a change rewires the pointwise functions. self._prev_effective_order = None diff --git a/tests/test_1078_thin_sheet_sphere.py b/tests/test_1078_thin_sheet_sphere.py index f75736251..d1cfd8950 100644 --- a/tests/test_1078_thin_sheet_sphere.py +++ b/tests/test_1078_thin_sheet_sphere.py @@ -100,3 +100,31 @@ def test_manufactured_toroidal_solution_converges_at_second_order(): order = np.log(errors[0.2] / errors[0.1]) / np.log(2.0) assert errors[0.1] < 3.0e-3, errors assert order > 1.7, f"order {order:.2f}: {errors}" + + +@pytest.mark.tier_b +def test_a_maxwell_sheet_keeps_a_rigid_rotation_stress_free(): + """With the grid-carried stress history, the Killing field stays the exact + solution step after step and the carried stress stays at round-off.""" + sphere = _sphere(0.25) + X = sphere.X + x, y, z = X[0], X[1], X[2] + omega = sympy.Matrix([[sympy.Rational(1, 3), sympy.Rational(-1, 2), sympy.Rational(2, 7)]]) + rot = sympy.Matrix([[omega[1] * z - omega[2] * y, + omega[2] * x - omega[0] * z, + omega[0] * y - omega[1] * x]]) + sheet = uw.systems.ThinSheet(sphere) + sheet.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + sheet.Unknowns, order=1, integrator="etd") + sheet.constitutive_model.Parameters.shear_viscosity_0 = 10 + sheet.constitutive_model.Parameters.shear_modulus = 1 + sheet.basal_drag = 2 + sheet.bodyforce = list(2 * rot) + sheet.tolerance = 1.0e-9 + c = sheet.u.coords + exact = np.cross(np.array([1 / 3, -1 / 2, 2 / 7]), c) + for _ in range(4): + sheet.solve(timestep=0.1, zero_init_guess=False) + assert np.abs(sheet.u.array[:, 0, :] - exact).max() < 1.0e-7 + assert np.abs(np.asarray(sheet.tau.array)).max() < 1.0e-7 + assert _leak(sheet) < 1.0e-13 From d1ea5189e95ed4ba425e5f58e0f226913da8d40b Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Thu, 8 Oct 2026 18:59:24 +1100 Subject: [PATCH 08/57] Interface laws on a surface-mesh fault: the trace quadrature follows the facet topology The interface assembler chose its quadrature by the component count, which became the embedding dimension on a surface mesh and so picked the triangle rule for edge facets (six nodes against three). It now follows the facet topology; the component count still sizes the strides. Test: a viscous interface law on a great-circle arc under the toroidal shear reduces the slip at first order in 1/eta_f. Underworld development team with AI support from Claude Code --- src/underworld3/utilities/fault_contact.py | 7 +++-- tests/test_1079_thin_sheet_sphere_faults.py | 35 +++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/underworld3/utilities/fault_contact.py b/src/underworld3/utilities/fault_contact.py index c647390ad..67c07671c 100644 --- a/src/underworld3/utilities/fault_contact.py +++ b/src/underworld3/utilities/fault_contact.py @@ -856,8 +856,11 @@ def node_index(q): self._lo_m = np.asarray(lo_m, dtype=np.int64) self._tan = np.asarray(tans, dtype=float).reshape(-1, ncomp, dim) self._nrm = np.asarray(nrms, dtype=float).reshape(-1, dim) - self._NQ = _NQ if dim == 2 else _NQ3 - self._WQ = _WQ if dim == 2 else _WQ3 + # the trace quadrature follows the facet TOPOLOGY (an edge on a 2-D + # or surface mesh, a triangle in 3-D); the strides above follow the + # component count + self._NQ = _NQ if solver.mesh.dim == 2 else _NQ3 + self._WQ = _WQ if solver.mesh.dim == 2 else _WQ3 self._laws = laws_of self._facets = facets self._dim = dim diff --git a/tests/test_1079_thin_sheet_sphere_faults.py b/tests/test_1079_thin_sheet_sphere_faults.py index bd13c90d1..53d42c74b 100644 --- a/tests/test_1079_thin_sheet_sphere_faults.py +++ b/tests/test_1079_thin_sheet_sphere_faults.py @@ -106,3 +106,38 @@ def test_short_arc_slips_as_the_griffith_crack(): assert abs(V.max() - peak) < 0.12 * peak, f"peak slip {V.max():.4f} vs {peak:.4f}" rms = np.sqrt(np.mean((V - ellipse) ** 2)) assert rms < 0.12 * peak, f"rms profile error {rms:.4f} vs peak {peak:.4f}" + + +@pytest.mark.tier_b +def test_a_viscous_interface_law_penalises_the_slip_on_the_sphere(): + """tau = eta_f V on the arc under the toroidal shear: the interface law is + assembled on the edge facets of the surface mesh (one slip component along + the trace), and the slip falls at first order in 1 / eta_f.""" + a = 0.25 + split = _faulted_sphere(0.1, a) + X = split.X + x, y, z = X[0], X[1], X[2] + r = sympy.sqrt(x**2 + y**2 + z**2) + rhat = sympy.Matrix([[x, y, z]]) / r + P = sympy.eye(3) - rhat.T * rhat + u_e = sympy.Matrix([[2 * y * z, -2 * x * z, 0]]) + G = u_e.jacobian(sympy.Matrix([x, y, z])) * P + E = P * ((G + G.T) / 2) * P + sigma = 2 * (E + E.trace() * P) + Xs = [x, y, z] + divs = sympy.Matrix([[sum(sigma[i, j].diff(Xs[k]) * P[k, j] + for j in range(3) for k in range(3)) for i in range(3)]]) + slip = {} + for eta_f in (1.0e1, 1.0e2): + sheet = _sheet(split, eta=1) + sheet.basal_drag = 1 + sheet.bodyforce = list((-divs + u_e).subs(r, 1)) + sheet.add_fault_bc(eta_f, "Flt") + sheet.solve() + info = sheet._rotated_freeslip_info + assert info["converged"] + _s, V, leak = fault_slip(sheet, "Flt", info) + assert np.abs(leak).max() < 1.0e-13 + slip[eta_f] = np.abs(V).max() + order = np.log(slip[1.0e1] / slip[1.0e2]) / np.log(10.0) + assert order > 0.8, f"slip order in 1/eta_f {order:.2f}: {slip}" From c99d5cfbf09e8c0827df2af35c71bc892670b0cb Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Thu, 8 Oct 2026 19:05:26 +1100 Subject: [PATCH 09/57] A manifold sheet carries its stress history on the grid and says so ThinSheet.stress_transport refuses anything but the Eulerian (streamline- upwind) history on a manifold mesh, where the semi-Lagrangian trace-back leaves the faceted surface; the default is already the grid history. Test: the refusal, and that the default reads "eulerian". Underworld development team with AI support from Claude Code --- src/underworld3/systems/thin_sheet.py | 19 +++++++++++++++++++ tests/test_1078_thin_sheet_sphere.py | 11 +++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/underworld3/systems/thin_sheet.py b/src/underworld3/systems/thin_sheet.py index d0052877c..77c2ac2e9 100644 --- a/src/underworld3/systems/thin_sheet.py +++ b/src/underworld3/systems/thin_sheet.py @@ -262,6 +262,25 @@ def solve( divergence_retries=divergence_retries) self._stress_history_post_solve(timestep, verbose=verbose, evalf=evalf) + @property + def stress_transport(self) -> str: + """How a viscoelastic stress history is carried (see + :attr:`SNES_Stokes.stress_transport`). On a manifold the history is + carried on the grid (``"eulerian"``, the streamline-upwind transport): + the semi-Lagrangian trace-back leaves the faceted surface and is not + supported there.""" + return super().stress_transport + + @stress_transport.setter + def stress_transport(self, value): + if self.mesh.dim < self.mesh.cdim and str(value) != "eulerian": + raise ValueError( + f"stress_transport={value!r} is not available on a manifold " + "mesh: the thin sheet carries its history on the grid " + "('eulerian'); the semi-Lagrangian trace-back leaves the " + "faceted surface.") + _StressHistoryMixin.stress_transport.fset(self, value) + # ------------------------------------------------------------------ # geometry # ------------------------------------------------------------------ diff --git a/tests/test_1078_thin_sheet_sphere.py b/tests/test_1078_thin_sheet_sphere.py index d1cfd8950..e8a1ca05a 100644 --- a/tests/test_1078_thin_sheet_sphere.py +++ b/tests/test_1078_thin_sheet_sphere.py @@ -128,3 +128,14 @@ def test_a_maxwell_sheet_keeps_a_rigid_rotation_stress_free(): assert np.abs(sheet.u.array[:, 0, :] - exact).max() < 1.0e-7 assert np.abs(np.asarray(sheet.tau.array)).max() < 1.0e-7 assert _leak(sheet) < 1.0e-13 + + +@pytest.mark.tier_b +def test_a_manifold_sheet_refuses_a_semi_lagrangian_history(): + """The history is carried on the grid on a manifold; asking for the + trace-back is refused before any history exists.""" + sheet = uw.systems.ThinSheet(_sphere(0.4)) + assert sheet.stress_transport == "eulerian" + with pytest.raises(ValueError, match="manifold"): + sheet.stress_transport = "semi_lagrangian" + sheet.stress_transport = "eulerian" From 9caabb31f9b0c78417e83019279253761d99f284 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Thu, 8 Oct 2026 19:08:36 +1100 Subject: [PATCH 10/57] Document the thin sheet: equations, the two normals on a sphere, faults, histories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A subsystem page for uw.systems.ThinSheet — the plane-stress closure, the closed forms the tests pin, the facet-normal projector against the analytic tangency normal on a sphere, great-circle faults and their pair frame, the grid-carried history on a manifold, and the known limits — linked from the developer index. The design record notes the facet-normal finding. Underworld development team with AI support from Claude Code --- docs/developer/index.md | 2 + docs/developer/subsystems/thin-sheet.md | 93 +++++++++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 docs/developer/subsystems/thin-sheet.md diff --git a/docs/developer/index.md b/docs/developer/index.md index 3424240c2..1fdaca500 100644 --- a/docs/developer/index.md +++ b/docs/developer/index.md @@ -37,6 +37,7 @@ same topic are reference or historical material subordinate to the governing doc | Data access | [subsystems/data-access.md](subsystems/data-access.md) (internals reference: [NDArray System](UW3_Developers_NDArrays.md)) | | Local scattered-point interpolation | [subsystems/interpolation.md](subsystems/interpolation.md) | | Rotated free-slip & wall-normal datum | [subsystems/rotated-freeslip.md](subsystems/rotated-freeslip.md) | +| Thin sheet (plane stress, sphere, faults) | [subsystems/thin-sheet.md](subsystems/thin-sheet.md) | | Analytic & benchmark solutions | [subsystems/analytic-solutions.md](subsystems/analytic-solutions.md) | | Units | [design/UNITS_SIMPLIFIED_DESIGN_2025-11.md](design/UNITS_SIMPLIFIED_DESIGN_2025-11.md) | | Testing tiers | [TESTING-RELIABILITY-SYSTEM.md](TESTING-RELIABILITY-SYSTEM.md) | @@ -228,6 +229,7 @@ subsystems/integration-point-variables subsystems/solvers subsystems/boundary-stress-and-projection-postprocessing subsystems/rotated-freeslip +subsystems/thin-sheet subsystems/petsc-jacobian-layout subsystems/constitutive-models subsystems/constitutive-models-theory diff --git a/docs/developer/subsystems/thin-sheet.md b/docs/developer/subsystems/thin-sheet.md new file mode 100644 index 000000000..749883558 --- /dev/null +++ b/docs/developer/subsystems/thin-sheet.md @@ -0,0 +1,93 @@ +# Thin sheet: `uw.systems.ThinSheet` + +A vertically averaged (plane-stress) sheet solver for the lithosphere, on a flat +mesh or on the surface of a sphere, with any deviatoric constitutive model — +Newtonian, Maxwell viscoelastic, or yielding — and the split-node fault machinery. + +Design record: `docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md`. +Tests: `tests/test_1075`–`test_1079`. Session drivers: `scripts/sessions/thin_sheet_*.py`. + +## The equations + +With thickness $L$, tangential velocity $\mathbf u$ and the plane-stress closure +$\sigma_{nn} = 0$ on the dynamic stress, the pressure of an incompressible material is +$p = \tau_{nn} = -\operatorname{tr}\boldsymbol\tau$ and + +$$ +-\nabla_s\cdot\big(L\,\boldsymbol\sigma\big) + c\,\mathbf u = \mathbf f, +\qquad +\boldsymbol\sigma = \boldsymbol\tau + \operatorname{tr}(\boldsymbol\tau)\,\mathbf P, +\qquad +\dot{\boldsymbol\varepsilon}_s = \mathbf P\,\mathrm{sym}(\nabla_s\mathbf u)\,\mathbf P . +$$ + +$\boldsymbol\tau$ is the deviatoric stress the constitutive model returns for the +in-plane strain rate $\dot{\boldsymbol\varepsilon}_s$; $c$ is a basal drag +coefficient ($\eta_a/h_a$ for a viscous channel); $\mathbf f$ a force per unit +area, which for a sheet dragged by a moving substrate is $c\,\mathbf u_s$. There is +no pressure unknown: the solver is an `SNES_Vector`. + +Closed forms the tests pin: uniaxial stretch with free edges gives the Trouton +stress $3\eta\dot\varepsilon$; a rigid motion carries no stress; the Maxwell +uniaxial response is $3\eta\dot\varepsilon(1-e^{-t\mu/\eta})$; Elsasser stress +diffusion into an elastic sheet over a viscous substrate has $D = \mu L/c$; a +frictionless fault under remote shear slips as the plane-stress Griffith crack +with $E' = 3\eta$. + +```python +sheet = uw.systems.ThinSheet(mesh) +sheet.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + sheet.Unknowns, order=1, integrator="etd") +sheet.constitutive_model.Parameters.shear_viscosity_0 = eta +sheet.constitutive_model.Parameters.shear_modulus = mu +sheet.thickness = L +sheet.basal_drag = c +sheet.bodyforce = list(c * u_substrate) +sheet.add_fault_bc(0, "Flt") # after mesh.add_fault(("Flt", points)) +sheet.solve(timestep=dt) +``` + +## On a sphere + +The velocity on a `SphericalManifold` (`dim=2`, `cdim=3`) is carried in the +embedding space. Two normals are involved, and they are different on purpose: + +- the **in-plane projector** uses the cell's facet normal (a cell-wise constant + field built once per mesh): the assembler differentiates along the flat facets, + and only with the facet normal is a rigid rotation — a Killing field — + strain-free in the discrete form. With the analytic normal it keeps a spurious + strain of order $h$; +- the **tangency constraint** $\mathbf u\cdot\hat{\mathbf n} = 0$ uses the analytic + surface normal at every node, imposed strongly through the per-node rotation of + `utilities/rotated_bc.py` (`SHEET_TANGENCY`), so a rigid rotation is exactly + admissible. Faults compose with it because the split-node pair blocks use the + same machinery. + +The rigid-rotation gauge offers the three Killing fields; with basal drag they are +not null modes and are rejected by the operator test, as they should be. + +A stress history on a manifold is carried on the grid (`stress_transport = +"eulerian"`, the streamline-upwind transport the Eulerian solvers use); the +semi-Lagrangian trace-back is refused there. + +## Faults on a sphere + +`mesh.add_fault(("name", points))` with three-coordinate points reads the polyline +as great-circle arcs about the origin. The coincident pair at a fault node carries +the frame $\{\hat\nu, \hat t, \hat r\}$ — the in-surface conormal of the fault edge +(orthogonalised against the node's surface normal), the trace tangent, the surface +normal — and pins the jump along $\hat\nu$ (no opening) and both sides' tangency +(the jump and the mean along $\hat r$); the slip row stays free, or carries the +interface law. Diagnostics (`fault_slip`, `fault_pair_jumps`, +`fault_normal_traction`) read the same frame. + +Known limits: faults may not reach the mesh boundary (2-D rule); on a split mesh +`uw.function.evaluate` within about $h/2$ of the slit is unreliable — read nodal or +pair data there; `mesh.adapt` runs on a manifold but does not yet snap the refined +vertices back to the sphere. + +## Round 2 (not yet) + +Thickness evolution with the gravitational potential energy term (England & +McKenzie's Argand number) — on the sphere with the Eulerian advection–diffusion — +and yield in the sheet with the invariant that includes $\tau_{nn}$. From aff1518534c41d828ecdd05d06d2ef6a2745e24f Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Thu, 8 Oct 2026 20:28:34 +1100 Subject: [PATCH 11/57] Reloadable checkpoints on a manifold mesh: coordinates in the embedding dimension write_timestep(petsc_reload=True) reshaped the coordinate vector by the topological dimension and failed on a surface mesh (a reshape ValueError, seen in the sphere fault-cycle run); it now uses the embedding dimension (identical on a volume mesh). Test: a vector field on a SphericalManifold round-trips exactly through the in-place reload. Two further manifold defects found while testing, marked TODO(BUG) and not fixed here: the default DMPlex-migration reload segfaults inside PETSc on a manifold, and the XDMF writer labels triangles in three-space as hexahedra. Underworld development team with AI support from Claude Code --- .../discretisation/discretisation_mesh.py | 4 ++++ .../discretisation_mesh_variables.py | 4 ++++ src/underworld3/function/field_projection.py | 3 ++- tests/test_1078_thin_sheet_sphere.py | 19 +++++++++++++++++++ 4 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/underworld3/discretisation/discretisation_mesh.py b/src/underworld3/discretisation/discretisation_mesh.py index e51cd1d89..e1e47eced 100644 --- a/src/underworld3/discretisation/discretisation_mesh.py +++ b/src/underworld3/discretisation/discretisation_mesh.py @@ -9866,6 +9866,10 @@ def checkpoint_xdmf( topology_type = "Quadrilateral" geomType = "XY" else: + # TODO(BUG): a manifold mesh (triangles in three-space, e.g. + # SphericalManifold) lands here with numCorners == 3 and is written as + # Hexahedron; it should be Triangle with XYZ geometry. Found 2026-10-09 + # (thin-sheet work), not fixed — out of that scope. if numCorners == 4: topology_type = "Tetrahedron" elif numCorners == 8: diff --git a/src/underworld3/discretisation/discretisation_mesh_variables.py b/src/underworld3/discretisation/discretisation_mesh_variables.py index 98f679a55..6b68966e6 100644 --- a/src/underworld3/discretisation/discretisation_mesh_variables.py +++ b/src/underworld3/discretisation/discretisation_mesh_variables.py @@ -1620,6 +1620,10 @@ def read_checkpoint( if self._lvec is None: self._set_vec(available=True) + # TODO(BUG): on a manifold mesh (dim < cdim, e.g. SphericalManifold) the + # default DMPlex-migration path below segfaults inside PETSc; the + # in-place same_layout=True path round-trips exactly. Found 2026-10-09 + # (thin-sheet work, test_1078 uses same_layout=True), not fixed. if same_layout: import h5py diff --git a/src/underworld3/function/field_projection.py b/src/underworld3/function/field_projection.py index 91a01b2cc..c4d2c3299 100644 --- a/src/underworld3/function/field_projection.py +++ b/src/underworld3/function/field_projection.py @@ -316,7 +316,8 @@ def write_coordinates_to_viewer( Dataset name (default ``coordinates``). """ coord_gvec = mesh.dm.getCoordinates() - coords = coord_gvec.array.reshape(-1, mesh.dim).copy() + # one coordinate per EMBEDDING dimension (a surface mesh in three-space) + coords = coord_gvec.array.reshape(-1, mesh.cdim).copy() _write_vec_to_group(viewer, coords, name, group, PETSc.COMM_WORLD) diff --git a/tests/test_1078_thin_sheet_sphere.py b/tests/test_1078_thin_sheet_sphere.py index e8a1ca05a..6c4588581 100644 --- a/tests/test_1078_thin_sheet_sphere.py +++ b/tests/test_1078_thin_sheet_sphere.py @@ -139,3 +139,22 @@ def test_a_manifold_sheet_refuses_a_semi_lagrangian_history(): with pytest.raises(ValueError, match="manifold"): sheet.stress_transport = "semi_lagrangian" sheet.stress_transport = "eulerian" + + +@pytest.mark.tier_b +def test_reloadable_checkpoint_round_trips_on_a_manifold(tmp_path): + """write_timestep(petsc_reload=True) writes the coordinates with one + component per embedding dimension; it used to reshape them by the + topological dimension and fail on a surface mesh. The written vector + reloads exactly.""" + sphere = _sphere(0.3) + v = uw.discretisation.MeshVariable("v", sphere, 3, degree=2) + v.array[:, 0, :] = v.coords + sphere.write_timestep("manifold", 0, outputPath=str(tmp_path), meshVars=[v], + petsc_reload=True) + w = uw.discretisation.MeshVariable("w", sphere, 3, degree=2) + # the in-place reload (same mesh object); the DMPlex-migration path + # segfaults on a manifold mesh (TODO(BUG) in read_checkpoint) + w.read_checkpoint(str(tmp_path / "manifold.mesh.v.00000.h5"), data_name="v", + same_layout=True) + assert np.abs(w.array[:, 0, :] - w.coords).max() < 1.0e-12 From b071b1c458a0ab9a563a6dd4034cae6863e63c5f Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Thu, 8 Oct 2026 20:28:34 +1100 Subject: [PATCH 12/57] Fault-cycle driver for the thin sheet: Delta CFF against a welded reference, uplift rate scripts/sessions/thin_sheet_fault_cycle.py runs a Coulomb fault in a Maxwell sheet dragged by a shearing substrate, flat or on the sphere. Snapshots show the Coulomb failure change on fault-parallel receivers against a reference sheet with the fault welded, on the same split mesh and solved alongside (a reference on the unsplit mesh differed by interpolation at the cut's moved vertices), and the implied isostatic uplift rate from the thinning rate. The fault traction and slip rate are recorded every step; checkpoints are reloadable. Underworld development team with AI support from Claude Code --- scripts/sessions/thin_sheet_fault_cycle.py | 246 +++++++++++++++++++++ 1 file changed, 246 insertions(+) create mode 100644 scripts/sessions/thin_sheet_fault_cycle.py diff --git a/scripts/sessions/thin_sheet_fault_cycle.py b/scripts/sessions/thin_sheet_fault_cycle.py new file mode 100644 index 000000000..744cbd4e5 --- /dev/null +++ b/scripts/sessions/thin_sheet_fault_cycle.py @@ -0,0 +1,246 @@ +"""A Coulomb fault in a Maxwell thin sheet dragged by a shearing substrate — +on a flat sheet or on the surface of a sphere. + +The sheet (shear modulus mu, Maxwell time eta/mu) rests on a viscous channel +of drag coefficient c whose far side moves at a prescribed substrate velocity +u_s: the force per unit area on the sheet is c (u_s - u), so ``basal_drag = c`` +and ``bodyforce = c u_s``. On the flat sheet u_s is the simple shear (y, 0); +on the sphere it is the toroidal flow rhat x grad(z^2), whose shear is largest +on the equator. The sheet is dragged towards the substrate motion and its +stress rises as eta gamma_dot (1 - exp(-t mu/eta)) towards the viscous value; +an interior fault with regularised Coulomb friction (strength mu_f sigma_n) +holds until the shear traction on it reaches strength, then slides, and the +stress drop it imposes diffuses into the sheet with D = mu L / c. + +Snapshots show the Coulomb failure change on receiver faults parallel to the +fault, Delta CFF = Delta tau + mu_f Delta sigma_n (shear signed in the loading +sense, tension positive), against a REFERENCE STATE: the same sheet with the fault +welded, on the same mesh and at the same time, solved alongside it. Red is moved towards failure, blue +is the stress shadow. The second panel is the implied surface uplift rate. The +fault traction and slip rate are recorded against time as the run goes +(history.npz is rewritten at every step, so a run that stops early still has its +record); checkpoints are written beside the snapshots. +""" +import os + +import numpy as np +import sympy + +import underworld3 as uw +from underworld3.utilities.fault_contact import add_coulomb_fault_bc, fault_pair_jumps + +params = uw.Params( + uw_geometry="flat", # flat | sphere + uw_output_dir=os.path.expanduser("~/+Simulations/thin_sheet_faults/cycle_v2"), + uw_cell_size=0.05, + uw_dt=0.02, + uw_t_end=3.0, + uw_mu_f=0.6, # friction coefficient + uw_sigma_n=1.0, # prescribed effective normal stress + uw_V0=1.0e-4, # regularisation velocity of the Coulomb law + uw_eta=1.0, # Maxwell viscosity: t_r = eta / mu = 1 + uw_shear_modulus=1.0, + uw_basal_drag=1.0, # D = mu L / c = 1 + uw_half_length=0.5, # flat: half-length; sphere: half-angle of the equatorial arc + uw_snapshots="0.5,1.0,1.5,3.0", + uw_isostatic_factor=0.15, # 1 - rho_c / rho_m: surface uplift per unit thickening (Airy) + uw_weld_viscosity=1.0e6, # interface viscosity that welds the reference sheet's fault +) +out = os.path.join(params.uw_output_dir, params.uw_geometry) +os.makedirs(out, exist_ok=True) +a = params.uw_half_length + +if params.uw_geometry == "flat": + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(-2.0, -1.0), maxCoords=(2.0, 1.0), cellSize=params.uw_cell_size, qdegree=3) + fault_pts = np.array([[-a, 0.0], [a, 0.0]]) +else: + mesh = uw.meshing.SphericalManifold(radius=1.0, cellSize=params.uw_cell_size, degree=1, qdegree=5) + phi = np.linspace(-a, a, 9) + fault_pts = np.column_stack([np.cos(phi), np.sin(phi), np.zeros_like(phi)]) +split = mesh.add_fault(("Flt", fault_pts)) +c = sympy.nsimplify(params.uw_basal_drag, rational=True) + + +def substrate_velocity(m): + """The substrate motion in mesh ``m``'s coordinates: simple shear (y, 0) on + the flat sheet, the toroidal flow rhat x grad(z^2) on the sphere (its shear + peaks on the equator).""" + if params.uw_geometry == "flat": + x, y = m.X + return sympy.Matrix([[y, 0]]) + x, y, z = m.X[0], m.X[1], m.X[2] + return sympy.Matrix([[2 * y * z, -2 * x * z, 0]]) + + +def dragged_sheet(m): + """A Maxwell sheet on mesh ``m`` dragged by the substrate.""" + s = uw.systems.ThinSheet(m) + s.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + s.Unknowns, order=1, integrator="etd") + s.constitutive_model.Parameters.shear_viscosity_0 = params.uw_eta + s.constitutive_model.Parameters.shear_modulus = params.uw_shear_modulus + s.basal_drag = c + s.bodyforce = list(c * substrate_velocity(m)) + s.tolerance = 1.0e-8 + return s + + +sheet = dragged_sheet(split) +add_coulomb_fault_bc(sheet, params.uw_mu_f, "Flt", sigma_n=params.uw_sigma_n, V0=params.uw_V0) +# The reference state for Delta CFF: the same sheet on the SAME split mesh with +# the fault welded (a stiff viscous interface, slip ~ tau / eta_f). Same nodes, +# same discretisation, so Delta CFF is the fault's effect alone — a reference on +# the unsplit mesh differed by interpolation at the cut's moved vertices (1e-4 +# speckle before any slip, worse at the free edges). +reference = dragged_sheet(split) +reference.add_fault_bc(params.uw_weld_viscosity, "Flt") +strength = params.uw_mu_f * params.uw_sigma_n + +# The sheet thins at -L div_s u; under Airy isostasy the surface rises at +# (1 - rho_c / rho_m) times the thickening rate. The divergence of the P2 +# velocity is cell-discontinuous, so it is projected onto a continuous P1 field +# for the picture (the slit's coincident nodes keep their own values). +thinning = uw.discretisation.MeshVariable("thinning_rate", split, 1, degree=1) +thinning_projection = uw.systems.Projection(split, thinning) +thinning_projection.uw_function = -sheet.thickness.sym * split.vector.divergence(sheet.u.sym) +thinning_projection.smoothing = 0.0 + + +def receiver_frame(coords): + """Unit fault-parallel direction t and in-surface fault normal n at each + node, for receivers parallel to the fault: the strike (x) on the flat sheet; + small circles parallel to the equatorial fault on the sphere (t east, + n north).""" + if coords.shape[1] == 2: + t = np.tile([1.0, 0.0], (len(coords), 1)) + n = np.tile([0.0, 1.0], (len(coords), 1)) + return t, n + rho = np.maximum(np.hypot(coords[:, 0], coords[:, 1]), 1e-12) + t = np.column_stack([-coords[:, 1] / rho, coords[:, 0] / rho, np.zeros(len(coords))]) + rhat = coords / np.linalg.norm(coords, axis=1)[:, None] + n = np.cross(rhat, t) + return t, n + + +def sheet_stress(coords, tau): + """sigma = tau + tr(tau) P at each node (plane-stress closure).""" + if coords.shape[1] == 2: + P = np.broadcast_to(np.eye(2), tau.shape) + else: + rhat = coords / np.linalg.norm(coords, axis=1)[:, None] + P = np.eye(3)[None] - rhat[:, :, None] * rhat[:, None, :] + return tau + np.trace(tau, axis1=1, axis2=2)[:, None, None] * P + + +def resolved(coords, tau): + """(shear t.sigma.n, normal n.sigma.n) on the receiver planes, tension positive.""" + t, n = receiver_frame(coords) + sigma = sheet_stress(coords, tau) + return np.einsum("ni,nij,nj->n", t, sigma, n), np.einsum("ni,nij,nj->n", n, sigma, n) + + +def render(k, t, tau_field, uplift_field, slip_rate_max): + """Two panels: Delta CFF on fault-parallel receivers against the unfaulted + reference (``tau_field``), and the implied surface uplift rate (Airy + isostasy on the thinning rate).""" + import pyvista as pv + import underworld3.visualisation as vis + pv.OFF_SCREEN = True + trace = pv.lines_from_points(np.column_stack([fault_pts, np.zeros(len(fault_pts))]) + if fault_pts.shape[1] == 2 else fault_pts * 1.001) + height = 900 if params.uw_geometry == "sphere" else 720 + pl = pv.Plotter(off_screen=True, shape=(1, 2), window_size=(2400, height), border=False) + for col, (name, field, title) in enumerate((("dcff", tau_field, "Delta CFF"), + ("uplift", uplift_field, "uplift rate"))): + pl.subplot(0, col) + pvm = vis.mesh_to_pv_mesh(split) + pvm.point_data[name] = field + pl.set_background("white") + vmax = max(float(np.percentile(np.abs(field), 99)), 1e-9) + pl.add_mesh(pvm, scalars=name, cmap="RdBu_r", clim=(-vmax, vmax), show_edges=False, + lighting=False, scalar_bar_args={"title": title, "color": "black"}) + pl.add_mesh(trace, color="black", line_width=4, lighting=False) + pl.add_text(f"t = {t:.2f} Delta CFF (mu = {params.uw_mu_f}) vs welded " + f"max slip rate {slip_rate_max:.3f}" + if col == 0 else "uplift rate = (1 - rho_c/rho_m) (-L div u)", + position="upper_left", font_size=11, color="black") + if params.uw_geometry == "flat": + pl.view_xy() + pl.camera.parallel_projection = True + pl.camera.parallel_scale = 1.05 + else: + pl.camera_position = [(3.2, -1.2, 1.4), (0.0, 0.0, 0.0), (0.0, 0.0, 1.0)] + pl.screenshot(os.path.join(out, f"tau_{k:02d}.png")) + pl.close() + + +snapshots = [float(s) for s in params.uw_snapshots.split(",")] +history, t, step, k = [], 0.0, 0, 0 +nodes_on_fault = None +while t < params.uw_t_end - 0.5 * params.uw_dt: + sheet.solve(timestep=params.uw_dt, zero_init_guess=False) + reference.solve(timestep=params.uw_dt, zero_init_guess=False) + t += params.uw_dt + step += 1 + info = sheet._rotated_freeslip_info + coords, jumps, normals = fault_pair_jumps(sheet, "Flt", info) + slip = np.linalg.norm(jumps - normals * np.einsum("ij,ij->i", jumps, normals)[:, None], axis=1) + leak = np.abs(np.einsum("ij,ij->i", jumps, normals)).max() if len(jumps) else 0.0 + tau_coords = sheet.tau.coords + tau = np.asarray(sheet.tau.array) + if nodes_on_fault is None: + if params.uw_geometry == "flat": + nodes_on_fault = (np.abs(tau_coords[:, 1]) < 1e-9) & (np.abs(tau_coords[:, 0]) < 0.5 * a) + # on strike well beyond the tip and away from the traction-free + # edges (the stress falls over the drag length sqrt(eta / c) there) + far = np.argmin(np.hypot(tau_coords[:, 0] - 1.6, tau_coords[:, 1])) + else: + phi_n = np.arctan2(tau_coords[:, 1], tau_coords[:, 0]) + nodes_on_fault = (np.abs(tau_coords[:, 2]) < 1e-9) & (np.abs(phi_n) < 0.5 * a) + far = np.argmin(np.linalg.norm(tau_coords - np.array([np.cos(np.pi), np.sin(np.pi), 0.0]), axis=1)) + shear, normal = resolved(tau_coords, tau) + history.append((t, shear[nodes_on_fault].mean(), shear[far], slip.max() if len(slip) else 0.0, + slip.mean() if len(slip) else 0.0, info["nonlinear_iterations"])) + uw.pprint(f"t={t:.3f} tau_fault={history[-1][1]:+.4f} tau_far={history[-1][2]:+.4f} " + f"slip_max={history[-1][3]:.3e} leak={leak:.1e} newton={info['nonlinear_iterations']}", flush=True) + if uw.mpi.rank == 0: + np.savez(os.path.join(out, "history.npz"), history=np.array(history), strength=strength) + if snapshots and abs(t - snapshots[0]) < 0.5 * params.uw_dt: + snapshots.pop(0) + # petsc_reload: a split mesh duplicates the fault nodes, and only the + # PETSc-native payload lets a later read tell the two sides apart (#640) + thinning_projection.solve() + split.write_timestep("cycle", k, outputPath=out, meshVars=[sheet.u, sheet.tau, thinning], + petsc_reload=True) + shear_ref, normal_ref = resolved(tau_coords, np.asarray(reference.tau.array)) + loading = np.sign(np.mean(shear_ref[nodes_on_fault])) or 1.0 + dcff = loading * (shear - shear_ref) + params.uw_mu_f * (normal - normal_ref) + if uw.mpi.rank == 0: + uplift = params.uw_isostatic_factor * np.asarray(thinning.array[:, 0, 0]) + render(k, t, dcff, uplift, history[-1][3]) + k += 1 + +if uw.mpi.rank == 0: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + h = np.array(history) + fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(7.0, 5.6), sharex=True) + ax1.plot(h[:, 0], h[:, 1], color="#1f5fa8", lw=2.0, label="shear traction on the fault") + ax1.plot(h[:, 0], h[:, 2], color="#1f5fa8", lw=1.2, ls=":", label="shear stress far from the fault") + ax1.axhline(strength, color="0.25", lw=1.0, ls="--", label="Coulomb strength") + ax1.set_ylabel("shear stress") + ax1.legend(frameon=False, fontsize=9) + ax2.plot(h[:, 0], h[:, 3], color="#c0392b", lw=2.0, label="max slip rate") + ax2.plot(h[:, 0], h[:, 4], color="#c0392b", lw=1.2, ls=":", label="mean slip rate") + ax2.set_xlabel("time t / t_r") + ax2.set_ylabel("slip rate") + ax2.legend(frameon=False, fontsize=9) + for ax in (ax1, ax2): + ax.spines[["top", "right"]].set_visible(False) + ax.grid(color="0.9", lw=0.6) + fig.suptitle(f"Coulomb fault in a Maxwell sheet dragged by a shearing substrate ({params.uw_geometry})", + fontsize=10, x=0.12, ha="left") + fig.savefig(os.path.join(out, "fault-cycle-history.png"), dpi=160, bbox_inches="tight") + uw.pprint(f"wrote figures and checkpoints to {out}", flush=True) From 7daa7a7f226ed6fa5e86bc0954c92d303c956f6c Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Fri, 9 Oct 2026 08:38:02 +1100 Subject: [PATCH 13/57] State exactly what the manifold reload defect is The TODO(BUG) on read_checkpoint now records what was observed against the current reload design (#674, #640): the reconstructed-mesh path segfaults on a manifold when called on the mesh that wrote the file, where a volume mesh round-trips exactly through the same call; the in-place path is exact on the manifold; and the path's intended use, a mesh rebuilt from its checkpoint, cannot yet be tried on a manifold because the h5 mesh round-trip drops the coordinate dimension first. Underworld development team with AI support from Claude Code --- .../discretisation/discretisation_mesh_variables.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/underworld3/discretisation/discretisation_mesh_variables.py b/src/underworld3/discretisation/discretisation_mesh_variables.py index 6b68966e6..0b7c771c8 100644 --- a/src/underworld3/discretisation/discretisation_mesh_variables.py +++ b/src/underworld3/discretisation/discretisation_mesh_variables.py @@ -1621,9 +1621,13 @@ def read_checkpoint( self._set_vec(available=True) # TODO(BUG): on a manifold mesh (dim < cdim, e.g. SphericalManifold) the - # default DMPlex-migration path below segfaults inside PETSc; the - # in-place same_layout=True path round-trips exactly. Found 2026-10-09 - # (thin-sheet work, test_1078 uses same_layout=True), not fixed. + # default (reconstructed-mesh, migration-SF) path below segfaults inside + # PETSc when called on the mesh object that wrote the file; the same + # call on a volume mesh round-trips exactly, and same_layout=True + # round-trips exactly on the manifold. The path's intended use — a mesh + # reconstructed from its checkpoint — is untested on a manifold: the h5 + # mesh round-trip drops the coordinate dimension first (see the + # SphericalManifold factory). Found 2026-10-09 (thin-sheet work), not fixed. if same_layout: import h5py From ed74e5498895a8e3bfc7d88cc2e7656317125de1 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Fri, 9 Oct 2026 13:32:41 +1100 Subject: [PATCH 14/57] ThinSheet carries its stress history on the grid by default; the trace-back reads across a fault With a sliding Coulomb fault in a Maxwell sheet, the semi-Lagrangian history produced a period-2-step oscillation of the near-tip slip rate (sign reversal at 77% of steps, amplitude doubling at half the timestep, unchanged by a 1e-11 solver tolerance) and patchy stress along the trace. Its trace-back samples the history by point evaluation, which within about h/2 of a split-node slit can read the other side of the fault. The grid history (Eulerian SUPG) removes both: reversals at 2% of steps, no amplitude, the same yield time and mean slip rate, and a sixth of the Newton work. ThinSheet now uses it by default on every mesh; a flat mesh may still choose another. TODO(BUG) on SemiLagrangian.update_pre_solve: a Stokes viscoelastic model with a split fault uses that history by default. Thin-sheet tests 1075-1079: 19 passed with the new default. Underworld development team with AI support from Claude Code --- docs/developer/subsystems/thin-sheet.md | 10 +++++++--- src/underworld3/systems/ddt.py | 8 ++++++++ src/underworld3/systems/thin_sheet.py | 22 ++++++++++++---------- 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/docs/developer/subsystems/thin-sheet.md b/docs/developer/subsystems/thin-sheet.md index 749883558..24c5ac470 100644 --- a/docs/developer/subsystems/thin-sheet.md +++ b/docs/developer/subsystems/thin-sheet.md @@ -66,9 +66,13 @@ embedding space. Two normals are involved, and they are different on purpose: The rigid-rotation gauge offers the three Killing fields; with basal drag they are not null modes and are rejected by the operator test, as they should be. -A stress history on a manifold is carried on the grid (`stress_transport = -"eulerian"`, the streamline-upwind transport the Eulerian solvers use); the -semi-Lagrangian trace-back is refused there. +The stress history is carried on the grid (`stress_transport = "eulerian"`, the +streamline-upwind transport the Eulerian solvers use) by default. On a manifold it +is the only choice; the semi-Lagrangian trace-back is refused there. On a flat +mesh it can be chosen, but next to a split-node fault its point evaluation reads +across the slit: with a sliding Coulomb fault it produced a period-2-step +oscillation of the near-tip slip rate and patchy stress along the trace, both of +which the grid history removes, at a sixth of the Newton work. ## Faults on a sphere diff --git a/src/underworld3/systems/ddt.py b/src/underworld3/systems/ddt.py index 1a8117d33..0a44798c3 100644 --- a/src/underworld3/systems/ddt.py +++ b/src/underworld3/systems/ddt.py @@ -3815,6 +3815,14 @@ def _sample_history_at_departure( # here as dead code — see git history for the sketch if the # `preserve_moments` option is ever implemented. + # TODO(BUG): on a mesh with a split-node fault the trace-back samples the + # history with a point evaluation, which is unreliable within about h/2 of + # the slit and can read the other side of the fault. Measured 2026-10-09 on + # a Maxwell thin sheet with a sliding Coulomb fault: a period-2-step + # oscillation of the near-tip slip rate (amplitude doubling at half dt, + # unchanged by a 1e-11 solver tolerance) and patchy stress along the trace, + # both gone with the grid (Eulerian SUPG) history. A Stokes viscoelastic + # model with a split fault uses this history by default and is exposed too. def update_pre_solve( self, dt: float, diff --git a/src/underworld3/systems/thin_sheet.py b/src/underworld3/systems/thin_sheet.py index 77c2ac2e9..e373bde47 100644 --- a/src/underworld3/systems/thin_sheet.py +++ b/src/underworld3/systems/thin_sheet.py @@ -211,12 +211,13 @@ def __init__( if mesh.dim < mesh.cdim: from underworld3.utilities.rotated_bc import SHEET_TANGENCY self._rotated_freeslip_bcs.append((SHEET_TANGENCY, _sheet_normal(mesh))) - # A stress history on a surface mesh is carried on the grid (the - # streamline-upwind transport the Eulerian solvers use): the - # semi-Lagrangian trace-back leaves the faceted surface and is not - # validated there. Measured: a Killing field stays exact (3e-10) - # over the steps with the grid history. - self._stress_transport = "eulerian" + # The stress history is carried on the grid (the streamline-upwind + # transport the Eulerian solvers use). On a surface mesh the + # semi-Lagrangian trace-back leaves the faceted surface; next to a + # split-node fault its point evaluation reads across the slit (measured: + # a period-2-step slip-rate oscillation that the grid history removes, + # at a sixth of the Newton work). + self._stress_transport = "eulerian" # Last-seen effective order of the stress history (it ramps over the # opening steps); a change rewires the pointwise functions. self._prev_effective_order = None @@ -265,10 +266,11 @@ def solve( @property def stress_transport(self) -> str: """How a viscoelastic stress history is carried (see - :attr:`SNES_Stokes.stress_transport`). On a manifold the history is - carried on the grid (``"eulerian"``, the streamline-upwind transport): - the semi-Lagrangian trace-back leaves the faceted surface and is not - supported there.""" + :attr:`SNES_Stokes.stress_transport`). The default is the grid + (``"eulerian"``, the streamline-upwind transport). On a manifold it is + the only one: the semi-Lagrangian trace-back leaves the faceted surface. + On a flat mesh the others may be chosen, but next to a split-node fault + the trace-back reads across the slit.""" return super().stress_transport @stress_transport.setter From 89ac67b01ce4ccf88585f123e3fb2d8dab3f8d6b Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Fri, 9 Oct 2026 13:32:41 +1100 Subject: [PATCH 15/57] Fault-cycle driver: tolerance, viscous friction term, history transport and order options -uw_tolerance, -uw_eta_v (a viscous term eta_v V added to the Coulomb law, registered through fault_contact's private _register_law with a TODO(FEATURE) for a public route), -uw_transport and -uw_order / -uw_integrator: the options the sawtooth diagnosis used. Underworld development team with AI support from Claude Code --- scripts/sessions/thin_sheet_fault_cycle.py | 25 +++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/scripts/sessions/thin_sheet_fault_cycle.py b/scripts/sessions/thin_sheet_fault_cycle.py index 744cbd4e5..eda30d892 100644 --- a/scripts/sessions/thin_sheet_fault_cycle.py +++ b/scripts/sessions/thin_sheet_fault_cycle.py @@ -27,6 +27,7 @@ import sympy import underworld3 as uw +from underworld3.utilities import fault_contact from underworld3.utilities.fault_contact import add_coulomb_fault_bc, fault_pair_jumps params = uw.Params( @@ -45,6 +46,11 @@ uw_snapshots="0.5,1.0,1.5,3.0", uw_isostatic_factor=0.15, # 1 - rho_c / rho_m: surface uplift per unit thickening (Airy) uw_weld_viscosity=1.0e6, # interface viscosity that welds the reference sheet's fault + uw_tolerance=1.0e-8, # sheet solver tolerance + uw_eta_v=0.0, # viscous term eta_v V added to the Coulomb law (0 = pure Coulomb) + uw_transport="", # stress-history transport; "" = the solver's default + uw_order=1, # Maxwell time-integration order + uw_integrator="etd", # etd (exponential) | bdf ) out = os.path.join(params.uw_output_dir, params.uw_geometry) os.makedirs(out, exist_ok=True) @@ -76,18 +82,31 @@ def substrate_velocity(m): def dragged_sheet(m): """A Maxwell sheet on mesh ``m`` dragged by the substrate.""" s = uw.systems.ThinSheet(m) + if params.uw_transport: + s.stress_transport = params.uw_transport # before the model creates the history s.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( - s.Unknowns, order=1, integrator="etd") + s.Unknowns, order=params.uw_order, integrator=params.uw_integrator) s.constitutive_model.Parameters.shear_viscosity_0 = params.uw_eta s.constitutive_model.Parameters.shear_modulus = params.uw_shear_modulus s.basal_drag = c s.bodyforce = list(c * substrate_velocity(m)) - s.tolerance = 1.0e-8 + s.tolerance = params.uw_tolerance return s sheet = dragged_sheet(split) -add_coulomb_fault_bc(sheet, params.uw_mu_f, "Flt", sigma_n=params.uw_sigma_n, V0=params.uw_V0) +if params.uw_eta_v > 0.0: + # Saturated regularised Coulomb is nearly flat in V (dtau/dV ~ mu sigma_n + # V0 / V^2), which leaves the slip rate of a sliding fault barely determined + # by the per-step problem; a viscous term eta_v V gives it a slope. + # TODO(FEATURE): no public way to register a composite SymbolicFaultLaw; + # this uses fault_contact's private _register_law. + fault_contact.add_frictionless_fault_bc(sheet, "Flt") + coulomb = fault_contact.CoulombFaultLaw(params.uw_mu_f, params.uw_sigma_n, params.uw_V0) + fault_contact._register_law(sheet, "Flt", fault_contact.SymbolicFaultLaw( + coulomb.expr + params.uw_eta_v * fault_contact.slip_rate)) +else: + add_coulomb_fault_bc(sheet, params.uw_mu_f, "Flt", sigma_n=params.uw_sigma_n, V0=params.uw_V0) # The reference state for Delta CFF: the same sheet on the SAME split mesh with # the fault welded (a stiff viscous interface, slip ~ tau / eta_f). Same nodes, # same discretisation, so Delta CFF is the fault's effect alone — a reference on From a783ee947b5b29e290ef8d83d8870bca4f760527 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Fri, 9 Oct 2026 14:42:25 +1100 Subject: [PATCH 16/57] SUPG transport on a surface mesh uses every velocity component The Eulerian SUPG history (its advection term, its stabilisation scale and its transport-step residual) and the Eulerian advection-diffusion solver (its velocity check and its diffusive flux) summed or sized over the topological dimension. On a sphere surface mesh the velocity and the surface gradient have three components, so the z term of the advection was dropped; they now run over the embedding dimension (identical on a volume mesh). Tests on a SphericalManifold, a rigid rotation about the x axis carrying z to -y in a quarter turn: through the grid stress history (relative error 1.126 before the fix, 2.27e-3 after) and through uw.systems.AdvDiffusion (a shape error before the fix, 2.27e-3 after). The stress-transport, SUPG advection API and thin-sheet suites pass (61). The Eulerian Navier-Stokes solver has the same topological sizing in several places; it is not used on a surface mesh here and is left as it is. Underworld development team with AI support from Claude Code --- .../systems/advection_diffusion_eulerian.py | 8 +-- src/underworld3/systems/ddt.py | 9 ++-- tests/test_1078_thin_sheet_sphere.py | 52 +++++++++++++++++++ 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/underworld3/systems/advection_diffusion_eulerian.py b/src/underworld3/systems/advection_diffusion_eulerian.py index 8f7424fe4..8b92e54d6 100644 --- a/src/underworld3/systems/advection_diffusion_eulerian.py +++ b/src/underworld3/systems/advection_diffusion_eulerian.py @@ -476,7 +476,8 @@ def V_fn(self): @V_fn.setter def V_fn(self, value): - self.DuDt.V_fn = _as_row_vector(value, self.mesh.dim) + # one velocity component per EMBEDDING dimension (three on a surface mesh) + self.DuDt.V_fn = _as_row_vector(value, self.mesh.cdim) self.is_setup = False @property @@ -519,8 +520,9 @@ def tau_weights(self, values): # ------------------------------------------------------------------ def _diffusive_flux(self): - r"""``(1, dim)`` flux :math:`\sum_k w_k\,\nabla\phi^{(k)}\cdot\kappa` from the constitutive tensor.""" - dim = self.mesh.dim + r"""``(1, cdim)`` flux :math:`\sum_k w_k\,\nabla\phi^{(k)}\cdot\kappa` from the constitutive tensor.""" + # the gradient has one component per EMBEDDING dimension + dim = self.mesh.cdim c = self.constitutive_model.c total = sympy.zeros(1, dim) for w, phi in zip(self.DuDt.spatial_weights(), self.DuDt.states()): diff --git a/src/underworld3/systems/ddt.py b/src/underworld3/systems/ddt.py index 0a44798c3..4cb196ada 100644 --- a/src/underworld3/systems/ddt.py +++ b/src/underworld3/systems/ddt.py @@ -2235,7 +2235,8 @@ def tau_weights(self, values): def _convective(self, a, psi): r"""``(a . grad) psi`` entry by entry, a matrix of ``psi``'s shape.""" - dim = self.mesh.dim + # velocity components: one per EMBEDDING dimension (three on a surface mesh) + dim = self.mesh.cdim grad = self.mesh.vector.gradient def entry(r, c): @@ -2255,7 +2256,8 @@ def advection(self): def tau(self): r"""The stabilisation parameter :math:`\tau` (times the weights).""" - dim = self.mesh.dim + # velocity components: one per EMBEDDING dimension (three on a surface mesh) + dim = self.mesh.cdim a = self.advecting_velocity(0) a_mag2 = sum(a[0, i] ** 2 for i in range(dim)) h = self.mesh.cell_size() @@ -2348,7 +2350,8 @@ def _transport_residual(self, solver): The theta rule at :attr:`transport_theta`, Crank-Nicolson by default. """ - dim = self.mesh.dim + # velocity components: one per EMBEDDING dimension (three on a surface mesh) + dim = self.mesh.cdim S, S_old = solver.u.sym, self._transport_old.sym gradient = self.mesh.vector.gradient a = self.advecting_velocity(0) diff --git a/tests/test_1078_thin_sheet_sphere.py b/tests/test_1078_thin_sheet_sphere.py index 6c4588581..b446f4275 100644 --- a/tests/test_1078_thin_sheet_sphere.py +++ b/tests/test_1078_thin_sheet_sphere.py @@ -158,3 +158,55 @@ def test_reloadable_checkpoint_round_trips_on_a_manifold(tmp_path): w.read_checkpoint(str(tmp_path / "manifold.mesh.v.00000.h5"), data_name="v", same_layout=True) assert np.abs(w.array[:, 0, :] - w.coords).max() < 1.0e-12 + + +@pytest.mark.tier_b +def test_the_grid_history_carries_a_tensor_round_the_sphere(): + """The grid (Eulerian SUPG) history advects each component as a scalar. + A rigid rotation about the x axis, u = (0, -z, y), carries a component + planted as z to -y after a quarter turn; the rotation has a z velocity, so a + transport that sums only the first mesh.dim velocity components (as it + once did on a surface mesh) leaves the field where it started (measured + 1.126 then). Measured 2026-10-09, h = 0.15, 40 steps: 2.27e-3.""" + sphere = _sphere(0.15) + X = sphere.X + x, y, z = X[0], X[1], X[2] + stress = uw.discretisation.MeshVariable( + "S_turn", sphere, vtype=uw.VarType.SYM_TENSOR, degree=2) + manager = uw.systems.ddt.EulerianSUPG( + sphere, stress, sympy.Matrix([[0, -z, y]]), vtype=uw.VarType.SYM_TENSOR, + degree=2, continuous=True, order=1, transport_on_update=True) + c = stress.coords + stress.array[:, 0, 0] = c[:, 2] + manager.initialise_history() + dt, steps = np.pi / 2 / 40, 40 # a quarter turn + for _ in range(steps): + manager.update_pre_solve(dt) + stress.array[...] = manager.psi_star[0].array[...] + carried = np.asarray(manager.psi_star[0].array[:, 0, 0]) + turned = -c[:, 1] + error = np.sqrt(((carried - turned) ** 2).sum() / (turned ** 2).sum()) + assert error < 5.0e-3, f"relative error {error:.2e} after a quarter turn" + + +@pytest.mark.tier_b +def test_supg_advection_turns_a_scalar_round_the_sphere(): + """The Eulerian SUPG advection-diffusion solver on a surface mesh: a rigid + rotation about the x axis, u = (0, -z, y), carries T = z to T = -y after a + quarter turn (no diffusion). The velocity has three components and the + surface gradient three entries; the solver must use all of them. + Measured 2026-10-09, h = 0.15, 40 steps: 2.27e-3.""" + sphere = _sphere(0.15) + X = sphere.X + x, y, z = X[0], X[1], X[2] + T = uw.discretisation.MeshVariable("T_turn", sphere, 1, degree=2) + T.array[:, 0, 0] = T.coords[:, 2] + adv = uw.systems.AdvDiffusion(sphere, T, sympy.Matrix([[0, -z, y]])) + adv.constitutive_model = uw.constitutive_models.DiffusionModel + adv.constitutive_model.Parameters.diffusivity = 0 + dt, steps = np.pi / 2 / 40, 40 # a quarter turn + for _ in range(steps): + adv.solve(timestep=dt) + turned = -T.coords[:, 1] + error = np.sqrt(((T.array[:, 0, 0] - turned) ** 2).sum() / (turned ** 2).sum()) + assert error < 5.0e-3, f"relative error {error:.2e} after a quarter turn" From d8b6f6a10cb23975c397a796fb09c506336b8cf9 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Fri, 9 Oct 2026 14:42:41 +1100 Subject: [PATCH 17/57] Mark the Eulerian Navier-Stokes solver's topological-dimension sizing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TODO(BUG): its velocity-shaped fields, SUPG terms and speed estimates are sized by mesh.dim, which on a surface mesh drops a velocity component — the defect just fixed in EulerianSUPG and the Eulerian AdvDiffusion. Left unfixed; the solver is not used on a surface mesh yet. Underworld development team with AI support from Claude Code --- src/underworld3/systems/navier_stokes_eulerian.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/underworld3/systems/navier_stokes_eulerian.py b/src/underworld3/systems/navier_stokes_eulerian.py index f480564d9..d3eed7646 100644 --- a/src/underworld3/systems/navier_stokes_eulerian.py +++ b/src/underworld3/systems/navier_stokes_eulerian.py @@ -218,6 +218,11 @@ def __init__( # solve: the extrapolation, or the latest Picard iterate) and the # level n-1 the extrapolation needs beyond what the history holds. u = self.Unknowns.u + # TODO(BUG): this solver sizes its velocity-shaped fields and loops by + # mesh.dim (here, and in the SUPG terms and speed estimates below). On a + # surface mesh the velocity has mesh.cdim components; the same sizing was + # fixed in EulerianSUPG and the Eulerian AdvDiffusion (2026-10-09, + # thin-sheet work). Not fixed here: not used on a surface mesh yet. self._a_var = uw.discretisation.MeshVariable( f"a_NSSUPG_{tag}", self.mesh, self.mesh.dim, degree=u.degree, continuous=u.continuous, varsymbol=rf"\mathbf{{a}}_{{{tag}}}") From 8828e9273334c884c0808bb77a7da18a35cebc85 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Sat, 10 Oct 2026 05:11:52 +1100 Subject: [PATCH 18/57] A Maxwell sheet on a sphere keeps its carried stress in the tangent plane The history is transported component by component in the embedding space, so a tensor carried along the curved surface keeps its Cartesian components while the tangent plane under it turns. It acquired normal components (31% of the stress by t = 3, measured on an unfaulted sheet driven by a divergence-free toroidal flow), which the plane-stress closure reads as a trace and so as a pressure. On a manifold the sheet now projects every stored level onto the tangent plane, P sigma* P with the analytic normal at each node, after the transport and after the commit: tangent to round-off. That sheet still thickens at both poles (uplift rate 0.15). This is the advection of the stress, a large-strain effect quadratic in the flow at a Weissenberg number of about two: with the transport switched off it falls to 6e-6, and a Jaumann rate does not change it. The test records this and asserts the tangency. Underworld development team with AI support from Claude Code --- src/underworld3/systems/thin_sheet.py | 30 +++++++++++++++++++++++++- tests/test_1078_thin_sheet_sphere.py | 31 +++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/underworld3/systems/thin_sheet.py b/src/underworld3/systems/thin_sheet.py index e373bde47..ece8e2615 100644 --- a/src/underworld3/systems/thin_sheet.py +++ b/src/underworld3/systems/thin_sheet.py @@ -40,6 +40,7 @@ from typing import Optional, Union +import numpy as np import sympy import underworld3 as uw @@ -97,7 +98,6 @@ def _facet_normal_field(mesh): """ field = getattr(mesh, "_sheet_facet_normal", None) if field is None: - import numpy as np field = uw.discretisation.MeshVariable( "sheet_facet_normal", mesh, mesh.cdim, degree=0, continuous=False) c_start, c_end = mesh.dm.getHeightStratum(0) @@ -259,9 +259,37 @@ def solve( self._setup_discretisation(verbose) self._setup_solver(verbose) self._stress_history_advance(timestep, verbose=verbose, evalf=evalf) + self._keep_history_tangent() super().solve(zero_init_guess, _force_setup=_force_setup, verbose=verbose, divergence_retries=divergence_retries) self._stress_history_post_solve(timestep, verbose=verbose, evalf=evalf) + self._keep_history_tangent() + + def _keep_history_tangent(self): + r"""On a manifold, project every stored history level onto the tangent + plane, :math:`\boldsymbol\sigma^* \leftarrow \mathbf{P}\boldsymbol\sigma^*\mathbf{P}`, + with the analytic surface normal at each history node. + + The history is transported component by component in the embedding + space, so a tensor carried along the curved surface keeps its Cartesian + components while the tangent plane under it turns: it acquires normal + components, which the plane-stress closure reads as a trace and so as a + spurious pressure. Measured without this, an unfaulted Maxwell sheet + driven by a divergence-free flow carried normal components up to 31% of + the stress by t = 3 and thickened at both poles (an uplift rate of 0.15 + against 1.7e-4 for the same sheet solved viscously). The projection + removes the normal part; the in-plane orientation is still carried + without rotation, as on a flat mesh.""" + if self.mesh.dim == self.mesh.cdim: + return + for level in self.Unknowns.DFDt.psi_star: + coords = level.coords + rhat = coords / np.linalg.norm(coords, axis=1)[:, None] + P = np.eye(self.mesh.cdim)[None] - rhat[:, :, None] * rhat[:, None, :] + projected = P @ np.asarray(level.array) @ P + # symmetric to round-off; a symmetric tensor variable stores one + # value per off-diagonal pair and refuses two that differ + level.array[...] = 0.5 * (projected + projected.transpose(0, 2, 1)) @property def stress_transport(self) -> str: diff --git a/tests/test_1078_thin_sheet_sphere.py b/tests/test_1078_thin_sheet_sphere.py index b446f4275..e1c13b40b 100644 --- a/tests/test_1078_thin_sheet_sphere.py +++ b/tests/test_1078_thin_sheet_sphere.py @@ -210,3 +210,34 @@ def test_supg_advection_turns_a_scalar_round_the_sphere(): turned = -T.coords[:, 1] error = np.sqrt(((T.array[:, 0, 0] - turned) ** 2).sum() / (turned ** 2).sum()) assert error < 5.0e-3, f"relative error {error:.2e} after a quarter turn" + + +@pytest.mark.tier_b +def test_a_maxwell_sheet_keeps_its_stress_tangent(): + """The carried stress of a Maxwell sheet on the sphere stays tangent to the + surface. Transported component by component in the embedding space it drifted + out of the tangent plane (normal part 31% of the stress by t = 3, measured + 2026-10-09, h = 0.12, dt = 0.1) and the plane-stress closure read the normal + part as a pressure. Driven by the divergence-free toroidal flow + rhat x grad(z^2), the sheet still thickens at both poles (an uplift rate of + 0.15) with the history tangent: that is the advection of the stress, a + large-strain effect quadratic in the flow (Weissenberg number ~2 here) — with + the transport switched off it is 6e-6.""" + sphere = _sphere(0.12) + X = sphere.X + x, y, z = X[0], X[1], X[2] + sheet = uw.systems.ThinSheet(sphere) + sheet.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + sheet.Unknowns, order=1, integrator="etd") + sheet.constitutive_model.Parameters.shear_viscosity_0 = 1 + sheet.constitutive_model.Parameters.shear_modulus = 1 + sheet.basal_drag = 4 + sheet.bodyforce = list(4 * sympy.Matrix([[2 * y * z, -2 * x * z, 0]])) + sheet.tolerance = 1.0e-9 + for _ in range(12): + sheet.solve(timestep=0.1, zero_init_guess=False) + tau = np.asarray(sheet.tau.array) + c = sheet.tau.coords + rhat = c / np.linalg.norm(c, axis=1)[:, None] + normal = np.linalg.norm(np.einsum("nij,nj->ni", tau, rhat), axis=1) + assert normal.max() < 1.0e-12 * np.abs(tau).max() From 978b5407a4a4aa8e529f82a19a4de87ca580c3ca Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Mon, 12 Oct 2026 09:58:35 +1100 Subject: [PATCH 19/57] WIP: objective_order, surface spin, frozen carried stress (tests pending) Work in progress, set aside to merge development: the objective step as a truncated expansion of exp(dt K) sigma exp(dt K^T) or the Cayley rotation (objective_order), the spin of a sheet element on a curved surface, the carried stress frozen in the Picard tangent, the history projected only after the commit (placement under measurement), and the design note on one model for thin and thick sheets. Tests and measurements follow. Underworld development team with AI support from Claude Code --- .../design/THIN_SHEET_VISCOELASTIC_DESIGN.md | 21 +++ src/underworld3/constitutive_models.py | 143 ++++++++++++++++-- src/underworld3/systems/thin_sheet.py | 51 ++++++- 3 files changed, 198 insertions(+), 17 deletions(-) diff --git a/docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md b/docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md index 7819a00a3..a6513c218 100644 --- a/docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md +++ b/docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md @@ -106,3 +106,24 @@ vertices need snapping back to the sphere. 4. Sheet on the sphere with strong tangency and the Killing null space. 5. Faults on the sphere. 6. Results and examples. + +## One model, thin or thick + +Principle (maintainer, 2026-10-10): the same physics is the same model script. A model +must move between the thin sheet (`ThinSheet` on a surface mesh) and a thick sheet +(`Stokes` on a thin 3-D mesh) by changing the mesh and the solver class, nothing else. +The solver owns the kinematics, the closure and the geometry; the model owns the +rheology. What is already shared: the constitutive models, the fault interface API, the +boundary-condition methods, the stress history and `solve(timestep=)`. + +Still to align: + +| | thin sheet | thick sheet | aligned form | +|---|---|---|---| +| construction | `ThinSheet(mesh)` | `Stokes(mesh, v, p)` | same signature; the sheet exposes `p = -tr(tau)` as a diagnostic | +| forcing | per unit area | per unit volume | per unit volume; the sheet multiplies by its thickness | +| basal coupling | `basal_drag` | traction on the base | one method, `add_basal_drag(c, boundary)` | +| fault geometry | `add_fault` with a trace | `add_conforming_sheet` | `add_fault` with a surface trace extrudes it through a thin 3-D mesh | +| stress | in-plane sigma | 3-D sigma | one resolved-traction / Delta CFF helper | +| nonlinear invariants | in-plane only (wrong where the sheet thins) | 3-D | the sheet supplies the invariant with eps_nn = -tr(eps_s) | +| topography | implied isostatic rate | free surface | one diagnostic name | diff --git a/src/underworld3/constitutive_models.py b/src/underworld3/constitutive_models.py index a3d390674..af69bf272 100644 --- a/src/underworld3/constitutive_models.py +++ b/src/underworld3/constitutive_models.py @@ -1781,7 +1781,8 @@ class ViscoElasticPlasticFlowModel(ViscousFlowModel): def __init__(self, unknowns, order=1, integrator: str = "bdf", material_name: str = None, objective_rate: str = "none", - stress_history: str = "stress", convected_step: str = None): + stress_history: str = "stress", convected_step: str = None, + objective_order=None): """Construct a viscoelastic-plastic flow model. Parameters @@ -1838,6 +1839,27 @@ def __init__(self, unknowns, order=1, integrator: str = "bdf", stretching of the relaxation target is completed the same way). Default ``"linear"``, or ``"deformation"`` with the log-conformation history, which requires it. + objective_order : int or "cayley", optional + How much of the step that carries the stress is taken, with + ``convected_step="linear"``. Over one step the convected stress is + :math:`M\sigma^*M^T` with :math:`M = e^{\Delta t K}` (:math:`K = W` + for the Jaumann rate, :math:`L` for the upper-convected); order + :math:`q` keeps the terms of total order up to :math:`q` in + :math:`\Delta t`, + + .. math:: + \sum_{i+j \le q} \frac{\Delta t^{i+j}}{i!\,j!}\, + K^i\sigma^*(K^T)^j , + + so ``1`` is :math:`\sigma^* + \Delta t\,(K\sigma^* + \sigma^*K^T)`, + the explicit source, and each order adds the next terms. + ``"cayley"`` (Jaumann only) rotates by + :math:`R = (I - \Delta tW/2)^{-1}(I + \Delta tW/2)`: exactly + orthogonal, so a rigid rotation carries the stress rigidly at any + step, with an angle :math:`2\arctan(\omega\Delta t/2)` against + :math:`\omega\Delta t`. The step acts on the newest history level: + orders above one, and ``"cayley"``, need a first-order history. + Default ``"cayley"`` with the Jaumann rate, ``1`` otherwise. """ if integrator not in ("bdf", "etd"): raise ValueError( @@ -1867,6 +1889,17 @@ def __init__(self, unknowns, order=1, integrator: str = "bdf", convected_step = "deformation" if stress_history == "log_conformation" else "linear" if convected_step not in ("linear", "deformation"): raise ValueError(f"convected_step must be 'linear' or 'deformation', got {convected_step!r}") + if objective_order is None: + objective_order = "cayley" if objective_rate == "jaumann" else 1 + if objective_order != "cayley" and not (isinstance(objective_order, int) and objective_order >= 1): + raise ValueError("objective_order must be a positive integer (the order in dt of the " + f"step that carries the stress) or 'cayley', got {objective_order!r}") + if objective_order == "cayley" and objective_rate != "jaumann": + raise ValueError("objective_order='cayley' is the orthogonal rotation by the spin: it " + "needs objective_rate='jaumann'") + if convected_step == "deformation" and objective_order != 1: + raise ValueError("convected_step='deformation' takes its own positive-definite step; " + "objective_order applies to convected_step='linear'") if stress_history == "log_conformation" and convected_step != "deformation": raise ValueError("the log-conformation history needs convected_step='deformation': the " "linear step can hand it an indefinite conformation, which has no logarithm") @@ -1879,6 +1912,7 @@ def __init__(self, unknowns, order=1, integrator: str = "bdf", "logarithm and exponential; 3-D is not implemented") self._stress_history = stress_history self._convected_step = convected_step + self._objective_order = objective_order # Store material_name before creating expressions (needed by create_unique_symbol) self._material_name = material_name @@ -2269,6 +2303,10 @@ def _check_order_supported(self, order): if order != 1 and self._convected_step == "deformation": raise NotImplementedError("convected_step='deformation' and the log-conformation history " "are first order only") + if order != 1 and self._objective_rate != "none" and self._objective_order != 1: + raise NotImplementedError(f"objective_order={self._objective_order!r} carries the newest " + "history level only: it needs a first-order history (use " + "objective_order=1 with order 2)") def _carried_stress_sym(self, level=0): r"""The carried stress :math:`\sigma^*` of history level ``level``: the @@ -2355,13 +2393,22 @@ def E_eff(self): else: edot_star = sympy.zeros(*E.shape) eta_raw = self.Parameters.shear_viscosity_0 - E_eff = ( - (1 - phi) * E - + (alpha / (2 * eta_raw)) * sigma_star - + (phi - alpha) * edot_star - # objective rate: the flux gains alpha * dt * S(sigma*) - + alpha * self.Parameters.dt_elastic * self._objective_term(sigma_star) / (2 * eta_raw) - ) + if self._objective_rate != "none" and self._objective_order != 1: + # the stress carried through the step at the chosen order + # replaces sigma* + dt S(sigma*) + E_eff = ( + (1 - phi) * E + + (alpha / (2 * eta_raw)) * self._carried_through_step(sigma_star) + + (phi - alpha) * edot_star + ) + else: + E_eff = ( + (1 - phi) * E + + (alpha / (2 * eta_raw)) * sigma_star + + (phi - alpha) * edot_star + # objective rate: the flux gains alpha * dt * S(sigma*) + + alpha * self.Parameters.dt_elastic * self._objective_term(sigma_star) / (2 * eta_raw) + ) if self._convected_step == "deformation": # The relaxation target is stretched during the step too. The # exponential step carries that as (1-a) I + b (L + L^T) with @@ -2382,6 +2429,12 @@ def E_eff(self): # BDF default mu_dt = self.Parameters.dt_elastic * self.Parameters.shear_modulus bdf_cs = [self._bdf_c1, self._bdf_c2, self._bdf_c3] + if self._objective_rate != "none" and self._objective_order != 1: + # first-order history (checked): the newest level carried through + # the step at the chosen order replaces sigma* + dt S(sigma*) + E += -bdf_cs[0] * self._carried_through_step(self._carried_stress_sym(0)) / (2 * mu_dt) + self._E_eff.sym = E + return self._E_eff for i in range(DDt.order): E += -bdf_cs[i] * self._carried_stress_sym(i) / (2 * mu_dt) # objective rate on the newest level, explicit and first order: the @@ -2393,13 +2446,74 @@ def E_eff(self): self._E_eff.sym = E return self._E_eff + def _carried_through_step(self, sigma): + r"""The stress carried through one step at ``objective_order``: + :math:`\sum_{i+j\le q} \Delta t^{i+j} K^i\sigma(K^T)^j/(i!\,j!)`, the + expansion of :math:`e^{\Delta tK}\sigma\,e^{\Delta tK^T}` to total order + :math:`q` (:math:`K = W` Jaumann, :math:`L` upper-convected), or for + ``"cayley"`` :math:`R\sigma R^T` with + :math:`R = (I - A)^{-1}(I + A) = I + 2(A + A^2)/(1 + \theta^2)`, + :math:`A = \Delta t\,W/2`, :math:`\theta^2 = -\mathrm{tr}(A^2)/2` + (closed form for a skew :math:`A`, in 2-D and 3-D).""" + dim = self.Unknowns.u.mesh.cdim + dt = self.Parameters.dt_elastic + sigma = sympy.Matrix(sigma) + if self._objective_rate == "jaumann": + K = sympy.Matrix(self.Unknowns.W) + else: + K = sympy.Matrix(self.Unknowns.u.sym).jacobian(self.Unknowns.u.mesh.X) + if self._objective_order == "cayley": + A = dt * K / 2 + A2 = A * A + theta2 = -A2.trace() / 2 + R = sympy.eye(dim) + 2 * (A + A2) / (1 + theta2) + carried = R * sigma * R.T + else: + q = self._objective_order + powers = [sympy.eye(dim)] + for _ in range(q): + powers.append(powers[-1] * dt * K) + carried = sympy.zeros(dim, dim) + for i in range(q + 1): + for j in range(q + 1 - i): + carried += powers[i] * sigma * powers[j].T / (sympy.factorial(i) * sympy.factorial(j)) + return self._frozen_carried_stress(carried) + + def _frozen_carried_stress(self, carried): + r"""The carried stress as one wrapped atom per independent entry. + + The same freezing contract as the effective viscosity: the residual + compiles the exact entries, while the default (Picard) tangent sees each + atom as a constant, and only the Newton tangent (``consistent_jacobian``) + differentiates through it. Differentiating the Cayley rotation or a + high-order series directly — a rational function of all the velocity + gradients, in every entry — took tens of minutes per setup on a sphere + (measured 2026-10-11); its velocity dependence is O(dt), which the frozen + tangent omits at no cost to the converged answer.""" + dim = carried.shape[0] + atoms = getattr(self, "_carried_step_atoms", None) + if atoms is None or atoms.shape != (dim, dim): + atoms = sympy.zeros(dim, dim) + for i in range(dim): + for j in range(i, dim): + atoms[i, j] = expression( + rf"{{\sigma^{{\circ}}_{{{i}{j}}}}}", 0, + "carried stress after the objective step (frozen in the Picard tangent)") + atoms[j, i] = atoms[i, j] + self._carried_step_atoms = atoms + for i in range(dim): + for j in range(i, dim): + atoms[i, j].sym = (carried[i, j] + carried[j, i]) / 2 + return atoms + def _objective_term(self, sigma): r"""The stretching / rotation source of the chosen objective rate, :math:`S(\sigma)`, on the carried stress with the current velocity gradient :math:`L_{ij} = \partial u_i / \partial x_j`: zero for ``"none"``, :math:`L\sigma + \sigma L^T` for ``"upper_convected"``, - :math:`W\sigma - \sigma W` with :math:`W = (L - L^T)/2` for - ``"jaumann"``. Symmetric whenever ``sigma`` is.""" + :math:`W\sigma - \sigma W` with the solver's spin :math:`W` + (:math:`(L - L^T)/2` on a volume mesh) for ``"jaumann"``. Symmetric + whenever ``sigma`` is.""" # the carried stress has one component per embedding dimension dim = self.Unknowns.u.mesh.cdim if self._objective_rate == "none": @@ -2415,7 +2529,11 @@ def _objective_term(self, sigma): G = self.Parameters.shear_modulus return L * sigma + sigma * L.T + dt * (L * sigma * L.T + G * L * L.T) return L * sigma + sigma * L.T - W = (L - L.T) / 2 + # The spin is the solver's: on a volume mesh it is (L - L^T)/2 of the + # gradient above, identically; a sheet on a curved surface supplies the + # spin of a material element including the turning of its normal, which + # the surface gradient alone does not carry. + W = sympy.Matrix(self.Unknowns.W) return W * sigma - sigma * W # ----- Health of the carried stress (#768) ----- @@ -2468,7 +2586,8 @@ def max_elastic_timestep(self, safety: float = 0.3) -> float: for any timestep, so this limit does not apply) or with no flow; a velocity that is not finite raises rather than returning a silent ``nan``. """ - if self._objective_rate == "none" or self._convected_step == "deformation": + if (self._objective_rate == "none" or self._convected_step == "deformation" + or self._objective_order == "cayley"): return float("inf") E = sympy.Matrix(self.Unknowns.E) rate = sympy.sqrt(2 * (E.T * E).trace()) diff --git a/src/underworld3/systems/thin_sheet.py b/src/underworld3/systems/thin_sheet.py index ece8e2615..a561a13c2 100644 --- a/src/underworld3/systems/thin_sheet.py +++ b/src/underworld3/systems/thin_sheet.py @@ -117,6 +117,33 @@ def _tangent_projector(mesh): return sympy.eye(mesh.cdim) - n.T * n +def _surface_spin(mesh, L): + r"""Spin of a material element of a sheet on a curved surface, + + .. math:: + + \mathbf{W} = \mathrm{skew}(\mathbf{P}\mathbf{L}\mathbf{P}) + + \dot{\mathbf{n}}\mathbf{n}^T - \mathbf{n}\dot{\mathbf{n}}^T, + \qquad \dot{\mathbf{n}} = -\mathbf{L}^T\mathbf{n}, + + with :math:`\mathbf{n}` the cell's facet normal, :math:`\mathbf{P}` its + projector and :math:`\mathbf{L}` the surface velocity gradient the + assembler supplies (projected on the derivative index only). The first term + is the rotation within the surface; the second turns the normal, which for + a tangential flow rotates at :math:`-\mathbf{L}^T\mathbf{n}` because + :math:`\nabla_s(\mathbf{u}\cdot\mathbf{n}) = 0`. For a rigid rotation + :math:`\mathbf{u} = \boldsymbol\omega\times\mathbf{X}` (exact in the P2 + space) this is the rotation tensor :math:`\boldsymbol\Omega` itself, so a + Jaumann-rate stress is carried rigidly; :math:`\mathrm{skew}(\mathbf{L})` + alone misses the turning of the normal.""" + n = sympy.Matrix(_facet_normal_field(mesh).sym).reshape(mesh.cdim, 1) + P = sympy.eye(mesh.cdim) - n * n.T + L = sympy.Matrix(L) + PLP = P * L * P + ndot = -L.T * n + return (PLP - PLP.T) / 2 + ndot * n.T - n * ndot.T + + class SNES_ThinSheet(_StressHistoryMixin, _ConstitutiveModelStateMixin, SNES_Vector): r"""Vertically averaged thin sheet (plane stress), viscous or viscoelastic. @@ -174,6 +201,8 @@ def u(inner_self, new_u): inner_self._E = P * inner_self._E * P inner_self._Einv2 = sympy.sqrt( (sympy.Matrix(inner_self._E) ** 2).trace() / 2) + if new_u.mesh.dim < new_u.mesh.cdim: + inner_self._W = _surface_spin(new_u.mesh, inner_self._L) _solver_terms = ( ("thickness", "sheet thickness L multiplying the in-plane stress"), @@ -253,13 +282,18 @@ def solve( raise ValueError( "timestep is required for a viscoelastic sheet: call " "sheet.solve(timestep=dt)") + if (self.mesh.dim < self.mesh.cdim + and getattr(self.constitutive_model, "_objective_rate", "none") == "upper_convected"): + raise NotImplementedError( + "the upper-convected rate needs the full velocity gradient of a " + "material element, which a sheet on a curved surface does not yet " + "supply; use objective_rate='jaumann' (the spin is supplied) or 'none'.") self._stress_history_prepare(timestep, _force_setup=_force_setup) if not self.is_setup: self._setup_pointwise_functions(verbose) self._setup_discretisation(verbose) self._setup_solver(verbose) self._stress_history_advance(timestep, verbose=verbose, evalf=evalf) - self._keep_history_tangent() super().solve(zero_init_guess, _force_setup=_force_setup, verbose=verbose, divergence_retries=divergence_retries) self._stress_history_post_solve(timestep, verbose=verbose, evalf=evalf) @@ -276,10 +310,17 @@ def _keep_history_tangent(self): components, which the plane-stress closure reads as a trace and so as a spurious pressure. Measured without this, an unfaulted Maxwell sheet driven by a divergence-free flow carried normal components up to 31% of - the stress by t = 3 and thickened at both poles (an uplift rate of 0.15 - against 1.7e-4 for the same sheet solved viscously). The projection - removes the normal part; the in-plane orientation is still carried - without rotation, as on a flat mesh.""" + the stress by t = 3. The projection removes the normal part left after + the rotation. + + It is applied to the COMMITTED stress, after the solve, never between the + transport and the solve. After the transport the history at a node still + carries the components it had upstream, tangent to the upstream plane; + the objective rotation inside the solve (the spin includes the turning of + the normal) turns them into the node's own plane. Projecting first throws + those components away before the rotation can use them: measured on a + rigid quarter-turn of the sphere, a first-order error (4.0e-2 at 40 steps, + 1.9e-2 at 80) where the flat sheet carries the stress to 5e-4.""" if self.mesh.dim == self.mesh.cdim: return for level in self.Unknowns.DFDt.psi_star: From 9391503f3464784f426eeb507c7ff21a5b0e0079 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Mon, 12 Oct 2026 13:35:47 +1100 Subject: [PATCH 20/57] Graph JIT route: sympy.vector objects keep every embedding component The graph route converted sympy.vector Vector and Dyadic objects with the topological dimension, which drops the third component on a surface mesh in three-space. The expanded route was corrected the same way earlier (aeb02932); the graph route, merged since, carried the old slice. Underworld development team with AI support from Claude Code --- src/underworld3/utilities/_jit_graph.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/underworld3/utilities/_jit_graph.py b/src/underworld3/utilities/_jit_graph.py index 585246894..516b22d96 100644 --- a/src/underworld3/utilities/_jit_graph.py +++ b/src/underworld3/utilities/_jit_graph.py @@ -434,9 +434,10 @@ def lower_callbacks(fns, mesh): fn = fn.doit() fn = graph.lower(fn) if isinstance(fn, sympy.vector.Vector): - fn = fn.to_matrix(mesh.N)[0:mesh.dim, 0] + # one component per EMBEDDING dimension (three on a surface mesh in 3-D) + fn = fn.to_matrix(mesh.N)[0:mesh.cdim, 0] elif isinstance(fn, sympy.vector.Dyadic): - fn = fn.to_matrix(mesh.N)[0:mesh.dim, 0:mesh.dim] + fn = fn.to_matrix(mesh.N)[0:mesh.cdim, 0:mesh.cdim] else: fn = sympy.Matrix([fn]) out.append(fn) From bf0e0aa37bdcf4a3b85875597678d41bc635dc0b Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Mon, 12 Oct 2026 13:35:47 +1100 Subject: [PATCH 21/57] The objective step: chosen order, Cayley rotation, frozen in Picard, linearised in Newton Completes 978b5407. A viscoelastic model's carried stress is taken through the step at objective_order: q truncates exp(dt K) sigma* exp(dt K^T) at total order q in dt (K = W Jaumann, L upper-convected; q = 1 is the existing explicit source and stays the default for upper-convected), or "cayley" rotates it orthogonally (the Jaumann default). The rotation reads the solver's spin, which on a curved sheet includes the turning of the normal. The carried stress is frozen in the Picard tangent: differentiating the Cayley rotation symbolically took tens of minutes per setup. A model now declares tangent_surrogates, differentiable stand-ins for its frozen atoms (here the first-order step); in Newton mode the solver swaps them into its own flux before differentiating, so a solver's closure stays in the tangent. Measured on the sphere, 4 steps: Picard 9 -> 14 iterations a step, rising; Newton with the stand-in 5, steady, 31 s against 40-51 s. Measured rigid quarter-turn on the sphere (h = 0.15, 40 steps): the history projected onto the tangent plane only after the commit, Cayley 8.4e-3, independent of dt (8.5e-3 at 80 steps) and 2.5e-3 at h = 0.075 (order 1.77 in space); projecting before the solve as well cost 4.0e-2. Tests (test_1080): the order's defaults and refusals; the Cayley step is the orthogonal rotation of the stress; a rigid quarter-turn of a flat sheet carries the stress to 2.9e-3; the Newton tangent converges to the frozen tangent's stress. Regression across stress transport, log-conformation, VE stability, Newton cold start, yield, the JIT, rotated constraints, faults and the thin-sheet suites: 157 passed. Underworld development team with AI support from Claude Code --- src/underworld3/constitutive_models.py | 26 ++++ .../cython/petsc_generic_snes_solvers.pyx | 9 +- tests/test_1080_objective_step.py | 123 ++++++++++++++++++ 3 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 tests/test_1080_objective_step.py diff --git a/src/underworld3/constitutive_models.py b/src/underworld3/constitutive_models.py index 7d6c5b857..7c1027acb 100644 --- a/src/underworld3/constitutive_models.py +++ b/src/underworld3/constitutive_models.py @@ -2462,6 +2462,9 @@ def _carried_through_step(self, sigma): K = sympy.Matrix(self.Unknowns.W) else: K = sympy.Matrix(self.Unknowns.u.sym).jacobian(self.Unknowns.u.mesh.X) + # the first-order step, kept as the Newton tangent's stand-in for the + # frozen carried stress (see tangent_surrogates) + self._carried_step_linear = sigma + dt * (K * sigma + sigma * K.T) if self._objective_order == "cayley": A = dt * K / 2 A2 = A * A @@ -2479,6 +2482,29 @@ def _carried_through_step(self, sigma): carried += powers[i] * sigma * powers[j].T / (sympy.factorial(i) * sympy.factorial(j)) return self._frozen_carried_stress(carried) + @property + def tangent_surrogates(self): + r"""Frozen atoms of this model's flux and the differentiable stand-in + the Newton tangent uses for each, ``{atom: expression}``; empty when + nothing is frozen. + + The carried stress after an objective step above first order is frozen + (see :meth:`_frozen_carried_stress`): differentiating the Cayley rotation + or a long series directly is prohibitively expensive. Its first-order + form :math:`\sigma^* + \Delta t\,(K\sigma^* + \sigma^*K^T)` carries the + same leading dependence on the velocity gradient and is cheap to + differentiate, so the Newton tangent (``consistent_jacobian``) uses it in + place of each frozen entry. The residual keeps the exact step; the solver + applies the substitution to its own flux, so a solver's closure (the + thin sheet's plane-stress trace term) stays in the tangent.""" + atoms = getattr(self, "_carried_step_atoms", None) + linear = getattr(self, "_carried_step_linear", None) + if atoms is None or linear is None or self._objective_order == 1: + return {} + dim = atoms.shape[0] + return {atoms[i, j]: (linear[i, j] + linear[j, i]) / 2 + for i in range(dim) for j in range(i, dim)} + def _frozen_carried_stress(self, carried): r"""The carried stress as one wrapped atom per independent entry. diff --git a/src/underworld3/cython/petsc_generic_snes_solvers.pyx b/src/underworld3/cython/petsc_generic_snes_solvers.pyx index 4f84e4412..53d95105d 100644 --- a/src/underworld3/cython/petsc_generic_snes_solvers.pyx +++ b/src/underworld3/cython/petsc_generic_snes_solvers.pyx @@ -583,7 +583,14 @@ class SolverBaseClass(uw_object): if not mode: return expr if newton_expr is None: - newton_expr = _jacobian_unwrap(expr, route=self._jit_route_in_use()) + # Atoms the constitutive model freezes because they are too costly to + # differentiate are swapped for its differentiable stand-ins first + # (tangent_surrogates); applied to the solver's own flux, so the + # solver's closure terms stay in the tangent. + surrogates = getattr(getattr(self, "_constitutive_model", None), + "tangent_surrogates", None) or {} + source = expr.xreplace(surrogates) if surrogates else expr + newton_expr = _jacobian_unwrap(source, route=self._jit_route_in_use()) if mode == "continuation": a = self._get_newton_alpha() if isinstance(expr, sympy.MatrixBase): diff --git a/tests/test_1080_objective_step.py b/tests/test_1080_objective_step.py new file mode 100644 index 000000000..ef0e92aaf --- /dev/null +++ b/tests/test_1080_objective_step.py @@ -0,0 +1,123 @@ +"""The objective step of the viscoelastic model: how the carried stress is taken +through one step (objective_order), on a flat thin sheet. + +A rigid rotation must carry a stress rigidly: sigma(X) = R sigma1(R^T X) R^T for +a quarter turn R. That is exact for any objective rate, so the rigid carry is a +hard baseline for the transport and the rotation together. +""" + +import numpy as np +import pytest +import sympy + +import underworld3 as uw + +pytestmark = [pytest.mark.level_2] + + +def _box(cell_size=0.5): + return uw.meshing.UnstructuredSimplexBox( + minCoords=(-1.0, -1.0), maxCoords=(1.0, 1.0), cellSize=cell_size, qdegree=3) + + +def _model(rate, order=None, history_order=1, convected_step=None): + sheet = uw.systems.ThinSheet(_box()) + return uw.constitutive_models.ViscoElasticPlasticFlowModel( + sheet.Unknowns, order=history_order, integrator="etd", objective_rate=rate, + objective_order=order, convected_step=convected_step) + + +@pytest.mark.tier_b +def test_the_objective_order_defaults_and_refusals(): + """Cayley for the Jaumann rate, first order otherwise; Cayley needs the + Jaumann rate; the deformation step takes no order; an order above one needs + a first-order history.""" + assert _model("jaumann")._objective_order == "cayley" + assert _model("upper_convected")._objective_order == 1 + assert _model("none")._objective_order == 1 + with pytest.raises(ValueError, match="cayley"): + _model("upper_convected", order="cayley") + with pytest.raises(ValueError, match="positive integer"): + _model("jaumann", order=0) + with pytest.raises(ValueError, match="deformation"): + _model("upper_convected", order=2, convected_step="deformation") + with pytest.raises(NotImplementedError, match="first-order history"): + _model("jaumann", order=2, history_order=2) + + +@pytest.mark.tier_b +def test_the_cayley_step_is_the_orthogonal_rotation_of_the_stress(): + """At a spin W = [[0, -w], [w, 0]] the Cayley step carries sigma to + R sigma R^T with R = (I - dt W/2)^{-1}(I + dt W/2), an exact rotation.""" + from underworld3.function.expressions import unwrap + model = _model("jaumann") + model.Parameters.dt_elastic = 0.1 + sigma = sympy.Matrix([[1, 2], [2, -3]]) + carried = model._carried_through_step(sigma) + L = sympy.Matrix(model.Unknowns.L) + w = 0.7 + spin = {L[0, 0]: 0, L[0, 1]: -w, L[1, 0]: w, L[1, 1]: 0} + got = np.array([[float(unwrap(carried[i, j].sym, keep_constants=False).xreplace(spin)) + for j in range(2)] for i in range(2)]) + A = 0.1 * np.array([[0.0, -w], [w, 0.0]]) / 2 + R = np.linalg.solve(np.eye(2) - A, np.eye(2) + A) + assert np.abs(R @ R.T - np.eye(2)).max() < 1.0e-14 + assert np.abs(got - R @ np.array([[1.0, 2.0], [2.0, -3.0]]) @ R.T).max() < 1.0e-13 + + +def _rigid_carry(steps, tangent=False, cell_size=0.1): + """Build an elastic stress on a flat sheet, then turn the sheet rigidly a + quarter turn about z through a stiff basal drag; return the carried stress + and the rigidly carried phase-one stress, within r < 0.6.""" + mesh = _box(cell_size) + x, y = mesh.X + sheet = uw.systems.ThinSheet(mesh) + sheet.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + sheet.Unknowns, order=1, integrator="etd", objective_rate="jaumann") + sheet.constitutive_model.Parameters.shear_viscosity_0 = 1.0e6 # elastic over the run + sheet.constitutive_model.Parameters.shear_modulus = 1 + sheet.tolerance = 1.0e-10 + sheet.consistent_jacobian = tangent + sheet.basal_drag = 1 + sheet.bodyforce = list(sympy.Matrix([[sympy.sin(2 * y) + x * y, sympy.cos(x) * y]])) + for _ in range(5): + sheet.solve(timestep=0.1, zero_init_guess=False) + sigma1 = uw.discretisation.MeshVariable( + f"sigma1_{steps}_{int(tangent)}", mesh, vtype=uw.VarType.SYM_TENSOR, degree=1) + sigma1.array[...] = np.asarray(sheet.tau.array) + c = 1.0e4 + sheet.basal_drag = c + sheet.bodyforce = list(c * sympy.Matrix([[-y, x]])) + # the history is advected with the previous step's velocity: start the + # turn with that velocity already the rotation + sheet.u.array[:, 0, :] = np.column_stack([-sheet.u.coords[:, 1], sheet.u.coords[:, 0]]) + angle = np.pi / 2 + for _ in range(steps): + sheet.solve(timestep=angle / steps, zero_init_guess=False) + R = np.array([[np.cos(angle), -np.sin(angle)], [np.sin(angle), np.cos(angle)]]) + pts = sheet.tau.coords + inside = np.hypot(pts[:, 0], pts[:, 1]) < 0.6 + got = np.asarray(sheet.tau.array)[inside] + s1 = np.asarray(uw.function.evaluate(sigma1.sym, pts[inside] @ R)).reshape(-1, 2, 2) + return got, np.einsum("ij,njk,lk->nil", R, s1, R) + + +@pytest.mark.tier_b +def test_a_rigid_rotation_carries_the_stress_rigidly(): + """The Cayley step and the grid transport together carry a stress through a + rigid quarter turn: relative error 2.94e-3 measured 2026-10-12 (h = 0.1, + 40 steps; the error is spatial: 3.97e-3 at 80 steps, from the transport's + upwinding). Without the rotation the components ride unrotated (O(1)).""" + got, expected = _rigid_carry(40) + error = np.linalg.norm(got - expected) / np.linalg.norm(expected) + assert error < 6.0e-3, f"relative error {error:.3e}" + + +@pytest.mark.tier_b +def test_the_newton_tangent_converges_to_the_same_stress(): + """The Newton tangent replaces the frozen carried stress by its first-order + form (tangent_surrogates); the residual is unchanged, so the converged + stress is the frozen-tangent one to solver tolerance.""" + picard, _ = _rigid_carry(8, tangent=False, cell_size=0.2) + newton, _ = _rigid_carry(8, tangent=True, cell_size=0.2) + assert np.abs(newton - picard).max() < 1.0e-7 * np.abs(picard).max() From 1daebf024cbce08d485fcdec6cc7bf73faac6b55 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Mon, 12 Oct 2026 14:30:42 +1100 Subject: [PATCH 22/57] Mark the flux_jacobian composition defect in the Newton tangent TODO(BUG): a model's flux_jacobian replaces the whole F1 in the Newton tangent, so with one set the Stokes uu tangent loses the augmented-Lagrangian penalty and the DEVSS term, which both depend on the velocity gradient; a thin sheet's closure would be lost the same way. To be unified with the thin/thick alignment (solvers apply their own closure to the model's flux_jacobian; tangent_surrogates then retires). Underworld development team with AI support from Claude Code --- src/underworld3/cython/petsc_generic_snes_solvers.pyx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/underworld3/cython/petsc_generic_snes_solvers.pyx b/src/underworld3/cython/petsc_generic_snes_solvers.pyx index 53d95105d..b8637a25e 100644 --- a/src/underworld3/cython/petsc_generic_snes_solvers.pyx +++ b/src/underworld3/cython/petsc_generic_snes_solvers.pyx @@ -618,6 +618,15 @@ class SolverBaseClass(uw_object): model whose flux has a non-smooth yield kink provide a physically motivated smooth tangent without changing the residual. """ + # TODO(BUG): the model's flux_jacobian REPLACES the whole F1 in the Newton + # tangent. Stokes' F1 is stress(flux) + DEVSS, and stress carries the + # augmented-Lagrangian penalty lambda K (div u) I, so with a flux_jacobian + # set the uu tangent silently loses the penalty and DEVSS terms (both + # depend on the velocity gradient); a thin sheet's closure tr(tau) P would + # be lost the same way. The composing form: each solver builds F1 from the + # model flux through one function and applies it to flux_jacobian too. + # Found 2026-10-12 (thin-sheet work); tangent_surrogates works around it + # for frozen atoms. Not fixed here. # Default (Picard) path never needs the Newton flux — short-circuit so # the (potentially expensive) model.flux_jacobian is not even evaluated, # keeping the default assembly allocation-free and bit-identical. From 3fff3cdb0f762e5dc3394df50610eb6cc94106c9 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Mon, 12 Oct 2026 14:44:19 +1100 Subject: [PATCH 23/57] A rigid rotation of the sphere carries a Maxwell sheet's stress rigidly (slow test) The objective step on a curved sheet end to end: grid transport of the Cartesian components, the spin with the turning of the normal, the Cayley rotation, and the tangent-plane projection after the rotation. A quarter turn about x in 20 steps at h = 0.15 carries the stress to 9.4e-3 (measured; 8.4e-3 at 40 steps, 8.5e-3 at 80, 2.5e-3 at h = 0.075), tangent to round-off. level_3: 13 minutes. Underworld development team with AI support from Claude Code --- tests/test_1081_objective_step_sphere.py | 58 ++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 tests/test_1081_objective_step_sphere.py diff --git a/tests/test_1081_objective_step_sphere.py b/tests/test_1081_objective_step_sphere.py new file mode 100644 index 000000000..2d38e40f2 --- /dev/null +++ b/tests/test_1081_objective_step_sphere.py @@ -0,0 +1,58 @@ +"""The objective step on a curved sheet: a rigid rotation of the sphere carries +a tangent stress rigidly, sigma(X) = R sigma1(R^T X) R^T. + +This exercises everything a curved surface adds: the grid transport of the +Cartesian components, the spin of a material element including the turning +of its normal, the Cayley rotation through the step, and the tangent-plane +projection of the committed history (after the rotation, never before it). +""" + +import numpy as np +import pytest +import sympy + +import underworld3 as uw + +pytestmark = [pytest.mark.level_3] + + +@pytest.mark.tier_b +def test_a_rigid_rotation_of_the_sphere_carries_the_stress_rigidly(): + """A quarter turn about x in 20 steps, h = 0.15. Measured 2026-10-12: + relative error 9.4e-3 (8.4e-3 at 40 steps, 8.5e-3 at 80: the error is + spatial; 2.5e-3 at h = 0.075, order 1.77). Before the history was projected + only after the rotation it was 4.0e-2, first order in dt; without an + objective rate the stress is not rotated at all (error O(1)).""" + sphere = uw.meshing.SphericalManifold(radius=1.0, cellSize=0.15, degree=1, qdegree=5) + x, y, z = sphere.X[0], sphere.X[1], sphere.X[2] + sheet = uw.systems.ThinSheet(sphere) + sheet.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + sheet.Unknowns, order=1, integrator="etd", objective_rate="jaumann") + sheet.constitutive_model.Parameters.shear_viscosity_0 = 1.0e6 # elastic over the run + sheet.constitutive_model.Parameters.shear_modulus = 1 + sheet.tolerance = 1.0e-10 + # phase 1: an elastic stress from the toroidal drag rhat x grad(z^2) + sheet.basal_drag = 1 + sheet.bodyforce = list(sympy.Matrix([[2 * y * z, -2 * x * z, 0]])) + for _ in range(5): + sheet.solve(timestep=0.1, zero_init_guess=False) + sigma1 = uw.discretisation.MeshVariable("sigma1", sphere, vtype=uw.VarType.SYM_TENSOR, degree=1) + sigma1.array[...] = np.asarray(sheet.tau.array) + # phase 2: a rigid rotation about x at unit rate through a stiff drag, the + # velocity set to it first (the history is advected with the previous step's) + c = 1.0e4 + sheet.basal_drag = c + sheet.bodyforce = list(c * sympy.Matrix([[0, -z, y]])) + sheet.u.array[:, 0, :] = np.cross(np.array([1.0, 0.0, 0.0]), sheet.u.coords) + angle, steps = np.pi / 2, 20 + for _ in range(steps): + sheet.solve(timestep=angle / steps, zero_init_guess=False) + R = np.array([[1, 0, 0], [0, np.cos(angle), -np.sin(angle)], [0, np.sin(angle), np.cos(angle)]]) + pts = sheet.tau.coords + s1 = np.asarray(uw.function.evaluate(sigma1.sym, pts @ R)).reshape(-1, 3, 3) + expected = np.einsum("ij,njk,lk->nil", R, s1, R) + got = np.asarray(sheet.tau.array) + error = np.linalg.norm(got - expected) / np.linalg.norm(expected) + assert error < 1.9e-2, f"relative error {error:.3e}" + rhat = pts / np.linalg.norm(pts, axis=1)[:, None] + assert np.abs(np.einsum("nij,nj->ni", got, rhat)).max() < 1.0e-12 * np.abs(got).max() From a55eb62a5fda1611379297014e33fd2d59eff4b5 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Mon, 12 Oct 2026 21:10:41 +1100 Subject: [PATCH 24/57] One model, thin or thick: the solver owns its closure, invariants and forcing The Newton tangent flux. A constitutive model's flux_jacobian is a deviatoric stress, like its flux; each solver now builds its momentum flux through one function (_flux_closure: Stokes and Navier-Stokes add the mechanical pressure with the penalty and DEVSS, the thin sheet the plane-stress trace term times the thickness) and applies it to the tangent flux too. Before, the tangent flux replaced the whole F1 (a Stokes Newton solve lost its pressure, penalty and DEVSS terms and diverged), and on vector and scalar solvers it was silently ignored: a 2-D Array was flattened with list(), which yields rows, and the error swallowed by a bare except. tangent_surrogates becomes the model's private _tangent_surrogates. Invariants. Every second invariant a constitutive model forms goes through Unknowns.second_invariant (the plain contraction by default, the same expression as before); the thin sheet adds the out-of-plane component M_nn = -tr M, so a plastic sheet in uniaxial stretch yields at the von Mises sqrt(3) tau_y instead of sqrt(18/5) tau_y. The sheet's API follows Stokes: ThinSheet(mesh, v, p, degree, p_continuous), with p = -tr(tau) as a diagnostic refreshed after each solve; bodyforce per unit volume (the sheet integrates it through its thickness); add_basal_drag(c, boundary, reference_velocity) and add_basal_traction on both solvers (natural conditions on Stokes). uw.maths.tensor gains resolved_traction and coulomb_failure_stress on any solver's stress. SNES_Vector copies its fields to the coarse levels before the field decomposition, which recurses into them (PETSc error 62 on any refined mesh since the decomposition was added for the rotated solves). Tests: test_1082 (tangent flux keeps the closure, and is live), test_1075 (forcing per unit volume with drag and traction, the pressure diagnostic, the von Mises yield, a refined mesh); test_108* batched in scripts/test.sh. Underworld development team with AI support from Claude Code --- scripts/test.sh | 3 + src/underworld3/constitutive_models.py | 29 +++- .../cython/petsc_generic_snes_solvers.pyx | 93 +++++++---- src/underworld3/maths/tensors.py | 51 ++++++ .../systems/navier_stokes_eulerian.py | 25 ++- src/underworld3/systems/solvers.py | 48 +++++- src/underworld3/systems/thin_sheet.py | 146 ++++++++++++++++-- tests/test_1075_thin_sheet.py | 88 ++++++++++- tests/test_1080_objective_step.py | 2 +- tests/test_1082_flux_jacobian_closure.py | 80 ++++++++++ 10 files changed, 496 insertions(+), 69 deletions(-) create mode 100644 tests/test_1082_flux_jacobian_closure.py diff --git a/scripts/test.sh b/scripts/test.sh index b6caaedda..61091a0a7 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -193,6 +193,9 @@ if [ $PARALLEL_ONLY -eq 0 ]; then # hardened, fast, and exactly what CI exists to run. test_1064 is the slow one # and it is excluded by WHAT IT IS (tier_c) below, not by its number. "${PYTEST[@]}" tests/test_106*py tests/test_107*py || status=1 + # test_108*: thin sheet (objective step, the Newton tangent's closure, thin vs + # thick, the quadratic benchmark); the slow ones are level_3 and marked so. + "${PYTEST[@]}" tests/test_108*py || status=1 # # test_1072 used to be pulled forward out of test_107* by name, because that # band was not batched and it is the ONLY guard on the 3-D free-surface sign diff --git a/src/underworld3/constitutive_models.py b/src/underworld3/constitutive_models.py index 7c1027acb..0b19ddd4b 100644 --- a/src/underworld3/constitutive_models.py +++ b/src/underworld3/constitutive_models.py @@ -446,6 +446,19 @@ def Unknowns(self, unknowns): self._solver_is_setup = False return + def _second_invariant(self, M): + r"""Second invariant of a deviatoric tensor of this model (a strain + rate, a stress), as the solver's kinematics define it. + + The solver supplies it (``Unknowns.second_invariant``) because only the + solver knows what its tensors leave out: the thin sheet's in-plane + tensors stand for 3-D ones with :math:`M_{nn} = -\operatorname{tr}M`. + Otherwise the plain contraction :math:`\sqrt{M_{ij}M_{ij}/2}`.""" + invariant = getattr(self.Unknowns, "second_invariant", None) + if invariant is None: + return sympy.sqrt((M**2).trace() / 2) + return invariant(M) + @property def K(self): r"""Primary constitutive property (viscosity, diffusivity, etc.). @@ -1505,7 +1518,7 @@ def __init__(self, unknowns, material_name: str = None): self._strainrate_inv_II = expression( r"\dot\varepsilon_{II}", - sympy.sqrt((self.grad_u**2).trace() / 2), + self._second_invariant(self.grad_u), "Strain rate 2nd Invariant", ) @@ -1714,7 +1727,7 @@ def plastic_correction(self) -> float: # The yield criterion in this case is assumed to be a bound on the second invariant of the stress - stress_II = sympy.sqrt((stress**2).trace() / 2) + stress_II = self._second_invariant(stress) correction = parameters.yield_stress / stress_II @@ -2463,7 +2476,7 @@ def _carried_through_step(self, sigma): else: K = sympy.Matrix(self.Unknowns.u.sym).jacobian(self.Unknowns.u.mesh.X) # the first-order step, kept as the Newton tangent's stand-in for the - # frozen carried stress (see tangent_surrogates) + # frozen carried stress (see _tangent_surrogates) self._carried_step_linear = sigma + dt * (K * sigma + sigma * K.T) if self._objective_order == "cayley": A = dt * K / 2 @@ -2483,7 +2496,7 @@ def _carried_through_step(self, sigma): return self._frozen_carried_stress(carried) @property - def tangent_surrogates(self): + def _tangent_surrogates(self): r"""Frozen atoms of this model's flux and the differentiable stand-in the Newton tangent uses for each, ``{atom: expression}``; empty when nothing is frozen. @@ -2687,7 +2700,7 @@ def conformation_min_eigenvalue(self): def E_eff_inv_II(self): r"""Second invariant of effective strain rate: :math:`\dot{\varepsilon}_{II} = \sqrt{\frac{1}{2}\dot{\varepsilon}_{ij}\dot{\varepsilon}_{ij}}`.""" E_eff = self.E_eff.sym - self._E_eff_inv_II.sym = sympy.sqrt((E_eff**2).trace() / 2) + self._E_eff_inv_II.sym = self._second_invariant(E_eff) return self._E_eff_inv_II @@ -2803,7 +2816,7 @@ def _plastic_effective_viscosity(self): strainrate_inv_II = expression( R"{\dot\varepsilon_{II}'}", - sympy.sqrt((Edot**2).trace() / 2), + self._second_invariant(Edot), "Strain rate 2nd Invariant including elastic strain rate term", ) @@ -2842,7 +2855,7 @@ def plastic_correction(self): stress = self.stress_projection() # The yield criterion in this case is assumed to be a bound on the second invariant of the stress - stress_inv_II = sympy.sqrt((stress**2).trace() / 2) + stress_inv_II = self._second_invariant(stress) correction = parameters.yield_stress / stress_inv_II return correction @@ -4177,7 +4190,7 @@ def E_eff(self): def E_eff_inv_II(self): r"""Second invariant of effective strain rate.""" E_eff = self.E_eff.sym - self._E_eff_inv_II.sym = sympy.sqrt((E_eff**2).trace() / 2) + self._E_eff_inv_II.sym = self._second_invariant(E_eff) return self._E_eff_inv_II @property diff --git a/src/underworld3/cython/petsc_generic_snes_solvers.pyx b/src/underworld3/cython/petsc_generic_snes_solvers.pyx index b8637a25e..87c813337 100644 --- a/src/underworld3/cython/petsc_generic_snes_solvers.pyx +++ b/src/underworld3/cython/petsc_generic_snes_solvers.pyx @@ -585,10 +585,10 @@ class SolverBaseClass(uw_object): if newton_expr is None: # Atoms the constitutive model freezes because they are too costly to # differentiate are swapped for its differentiable stand-ins first - # (tangent_surrogates); applied to the solver's own flux, so the - # solver's closure terms stay in the tangent. + # (its private _tangent_surrogates); applied to the solver's own + # flux, so the solver's closure terms stay in the tangent. surrogates = getattr(getattr(self, "_constitutive_model", None), - "tangent_surrogates", None) or {} + "_tangent_surrogates", None) or {} source = expr.xreplace(surrogates) if surrogates else expr newton_expr = _jacobian_unwrap(source, route=self._jit_route_in_use()) if mode == "continuation": @@ -611,22 +611,23 @@ class SolverBaseClass(uw_object): def _newton_flux(self, exact_F1): """Newton (consistent) flux for the bulk ``F1`` Jacobian source. - Returns the constitutive model's own smooth tangent law - (``constitutive_model.flux_jacobian``) when it supplies one — matched to - the container/shape of ``exact_F1`` — otherwise ``None`` so that - :meth:`_jacobian_source` falls back to the exact flux unwrapped. Lets a - model whose flux has a non-smooth yield kink provide a physically - motivated smooth tangent without changing the residual. - """ - # TODO(BUG): the model's flux_jacobian REPLACES the whole F1 in the Newton - # tangent. Stokes' F1 is stress(flux) + DEVSS, and stress carries the - # augmented-Lagrangian penalty lambda K (div u) I, so with a flux_jacobian - # set the uu tangent silently loses the penalty and DEVSS terms (both - # depend on the velocity gradient); a thin sheet's closure tr(tau) P would - # be lost the same way. The composing form: each solver builds F1 from the - # model flux through one function and applies it to flux_jacobian too. - # Found 2026-10-12 (thin-sheet work); tangent_surrogates works around it - # for frozen atoms. Not fixed here. + When the constitutive model supplies a tangent flux + (``constitutive_model.flux_jacobian``), it is a DEVIATORIC stress, like + the model's ``flux``: the solver applies its own closure to it + (``_flux_closure`` — Stokes: less the mechanical pressure with the + penalty, plus DEVSS; the thin sheet: the plane-stress trace term times + the thickness), so the tangent differentiates the same momentum flux as + the residual with only the model's part exchanged. A solver without a + closure uses the model flux as its F1, and the tangent flux stands in for + it directly. Returns ``None`` when there is no tangent flux, so that + :meth:`_jacobian_source` differentiates the exact flux. + + Before 2026-10-12 the tangent flux replaced the WHOLE F1 (Stokes lost the + pressure, penalty and DEVSS terms from its tangent), and on vector and + scalar solvers it was silently ignored: a 2-D Array was flattened with + ``list()`` (which yields rows) and the error swallowed. See + ``tests/test_1082_flux_jacobian_closure.py``. + """ # Default (Picard) path never needs the Newton flux — short-circuit so # the (potentially expensive) model.flux_jacobian is not even evaluated, # keeping the default assembly allocation-free and bit-identical. @@ -636,16 +637,27 @@ class SolverBaseClass(uw_object): smooth = getattr(cm, "flux_jacobian", None) if cm is not None else None if smooth is None: return None - try: - sm = sympy.Array(smooth) - ex = sympy.Array(exact_F1) - if sm.shape != ex.shape: - sm = sm.reshape(*ex.shape) - if isinstance(exact_F1, sympy.MatrixBase): - return sympy.Matrix(exact_F1.shape[0], exact_F1.shape[1], list(sm)) - return sm - except Exception: - return None + closure = getattr(self, "_flux_closure", None) + if closure is not None: + smooth = closure(sympy.Matrix(smooth)) + n_exact = int(np.prod(sympy.Array(exact_F1).shape)) + flat = list(sympy.Array(smooth).reshape(n_exact)) \ + if int(np.prod(sympy.Array(smooth).shape)) == n_exact else None + if flat is None: + raise ValueError( + f"constitutive_model.flux_jacobian has shape " + f"{sympy.Array(smooth).shape}; this solver's flux has shape " + f"{sympy.Array(exact_F1).shape}") + shape = sympy.Array(exact_F1).shape + if isinstance(exact_F1, sympy.MatrixBase): + composed = sympy.Matrix(shape[0], shape[1], flat) + else: + composed = sympy.Array(flat, shape) + surrogates = getattr(cm, "_tangent_surrogates", None) or {} + if surrogates: + composed = composed.xreplace(surrogates) + # differentiated as the exact flux would be: named quantities unwrapped + return _jacobian_unwrap(composed, route=self._jit_route_in_use()) def _get_newton_alpha(self): """Lazily construct the Picard->Newton continuation parameter. @@ -1482,6 +1494,17 @@ class SolverBaseClass(uw_object): """Second invariant of strain rate tensor.""" return inner_self._Einv2 + def second_invariant(inner_self, M): + r"""Second invariant :math:`\sqrt{M_{ij} M_{ij} / 2}` of a deviatoric + tensor built on these kinematics (a strain rate, a stress). + + Constitutive models take every invariant through here, so a solver + whose tensors are a reduced representation of the 3-D ones supplies + the missing components: the thin sheet adds the out-of-plane + component :math:`M_{nn} = -\operatorname{tr} M` that incompressibility + implies. The default is the plain contraction.""" + return sympy.sqrt((M**2).trace() / 2) + @property def CoordinateSystem(inner_self): """Coordinate system of the underlying mesh.""" @@ -5624,6 +5647,14 @@ class SNES_Vector(SolverBaseClass): self.essential_bcs[index] = self.essential_bcs[index]._replace(PETScID=bc, boundary_label_val=value) + # The coarse levels take the fields first: the decomposition below builds + # a sub-DM, which recurses into the coarse DMs (measured: PETSc error 62, + # "number of requested fields 1 greater than number of DM fields 0", on + # any mesh with refinement levels). + for coarse_dm in self.dm_hierarchy: + self.dm.copyFields(coarse_dm) + self.dm.copyDS(coarse_dm) + # The single field, registered AFTER the boundaries are on the DM (the # decomposition finalises the DM, and DMAddBoundary refuses afterwards), # under the name the rotated solve and the copy-back address it by — @@ -5633,10 +5664,6 @@ class SNES_Vector(SolverBaseClass): self.fields = {"velocity": self.u} self._subdict = {"velocity": (isets[0], dms[0])} - for coarse_dm in self.dm_hierarchy: - self.dm.copyFields(coarse_dm) - self.dm.copyDS(coarse_dm) - self.is_setup = False return diff --git a/src/underworld3/maths/tensors.py b/src/underworld3/maths/tensors.py index 68fa4f909..5db4b10d8 100644 --- a/src/underworld3/maths/tensors.py +++ b/src/underworld3/maths/tensors.py @@ -395,3 +395,54 @@ def rank2_inner_product(A, B): return sympy.tensorcontraction( sympy.tensorcontraction(sympy.tensorproduct(A, B), (1, 3)), (0, 1) ) + + +def resolved_traction(stress, normal, direction): + r"""Normal and shear traction of a stress on a plane. + + .. math:: + + \sigma_n = \hat{\mathbf{n}}\cdot\boldsymbol\sigma\cdot\hat{\mathbf{n}}, + \qquad + \tau_s = \hat{\mathbf{s}}\cdot\boldsymbol\sigma\cdot\hat{\mathbf{n}}, + + with :math:`\hat{\mathbf{n}}` the plane's unit normal and + :math:`\hat{\mathbf{s}}` a unit direction in the plane (the slip direction + of a receiver fault). Tension is positive, as in the solvers' ``stress``. + + Written on any solver's ``stress``, so the same line resolves a thin + sheet's in-plane stress (normal and direction in the sheet) and a thick + sheet's 3-D stress. + + Parameters + ---------- + stress : sympy.Matrix + A ``(d, d)`` stress, e.g. ``solver.stress``. + normal, direction : sequence or sympy.Matrix + ``d`` components each; normalised here. + + Returns + ------- + (sympy.Expr, sympy.Expr) + ``(sigma_n, tau_s)``. + """ + sigma = sympy.Matrix(stress) + d = sigma.shape[0] + n = sympy.Matrix(list(normal)).reshape(d, 1) + s = sympy.Matrix(list(direction)).reshape(d, 1) + n = n / sympy.sqrt((n.T * n)[0, 0]) + s = s / sympy.sqrt((s.T * s)[0, 0]) + return (n.T * sigma * n)[0, 0], (s.T * sigma * n)[0, 0] + + +def coulomb_failure_stress(stress, normal, direction, friction): + r"""Coulomb failure stress on a receiver plane, + :math:`\mathrm{CFS} = \tau_s + \mu\,\sigma_n` (tension positive, shear + signed along ``direction``): a rise moves the receiver towards failure. + + The change against a reference state, :math:`\Delta\mathrm{CFS}`, is the + difference of two of these (the same receivers, two stresses). See + :func:`resolved_traction` for the arguments; ``friction`` is :math:`\mu`. + """ + sigma_n, tau_s = resolved_traction(stress, normal, direction) + return tau_s + friction * sigma_n diff --git a/src/underworld3/systems/navier_stokes_eulerian.py b/src/underworld3/systems/navier_stokes_eulerian.py index d3eed7646..f3d8799c9 100644 --- a/src/underworld3/systems/navier_stokes_eulerian.py +++ b/src/underworld3/systems/navier_stokes_eulerian.py @@ -413,9 +413,12 @@ def _viscous_stress(self, u_row): eta = self.constitutive_model.K return 2 * eta * sympy.Matrix(self.mesh.vector.strain_tensor(u_row)) - def _viscous_flux(self): + def _viscous_flux(self, tau_new=None): r"""The flux of the time scheme: the new stress, blended with the stored levels. + ``tau_new`` replaces the constitutive model's stress at the new level (the + Newton tangent passes the model's tangent flux through here). + The theta rule weights the flux across time levels, so it needs the stress at the stored levels as well as the new one. For a viscous fluid that is rebuilt from the stored velocity, :math:`2\eta\dot\varepsilon(\mathbf{u}^{n})`. @@ -428,7 +431,8 @@ def _viscous_flux(self): """ states = self.DuDt.states() weights = self.DuDt.spatial_weights() - total = weights[0] * self.stress_deviator + total = weights[0] * (self.stress_deviator if tau_new is None + else sympy.Matrix(tau_new)) stress_history = self.Unknowns.DFDt for level, (w, u_k) in enumerate(zip(weights[1:], states[1:])): if w == 0: @@ -470,18 +474,25 @@ def F0(self): @property def F1(self): """Pointwise flux: weighted viscous stress, mechanical pressure, SUPG term.""" - dim = self.mesh.dim - mechanical_pressure = ( - self.p.sym[0] - self.penalty * self.constitutive_model.K * self.div_u) F1 = public_expression( r"\mathbf{F}_1\left( \mathbf{u} \right)", - self._viscous_flux() + self._devss_flux() - sympy.eye(dim) * mechanical_pressure - + self._stabilisation_flux(), + self._flux_closure(self.stress_deviator), "Navier-Stokes SUPG: viscous flux of the time scheme, pressure, tau R (x) a", ) self._u_f1 = F1 return F1 + def _flux_closure(self, tau): + """The momentum flux built on the new-level deviatoric stress ``tau``: + the time scheme's viscous flux, DEVSS, the mechanical pressure and the + SUPG term. ``F1`` applies it to the constitutive model's flux, the Newton + tangent to the model's tangent flux (``flux_jacobian``).""" + dim = self.mesh.dim + mechanical_pressure = ( + self.p.sym[0] - self.penalty * self.constitutive_model.K * self.div_u) + return (self._viscous_flux(tau) + self._devss_flux() + - sympy.eye(dim) * mechanical_pressure + self._stabilisation_flux()) + # ------------------------------------------------------------------ # Timestep and solve # ------------------------------------------------------------------ diff --git a/src/underworld3/systems/solvers.py b/src/underworld3/systems/solvers.py index db6f85b68..b437b5322 100644 --- a/src/underworld3/systems/solvers.py +++ b/src/underworld3/systems/solvers.py @@ -1860,6 +1860,38 @@ def set_jacobian_F1_source(self, F1_source, linesearch="cp"): if F1_source is not None and linesearch is not None: self.petsc_options["snes_linesearch_type"] = linesearch + def add_basal_drag(self, coefficient, boundary, reference_velocity=None): + r"""Couple the model to what lies beneath ``boundary`` by a drag + :math:`-c\,(\mathbf{u} - \mathbf{u}_\mathrm{ref})` per unit area: a + natural condition :math:`c\,(\mathbf{u} - \mathbf{u}_\mathrm{ref})`. + + The thick form of ``ThinSheet.add_basal_drag``: a thin sheet modelled as + a thin 3-D layer takes the same line. ``reference_velocity`` is the + velocity of the substrate (``None``: a fixed base); for a viscous + substrate of viscosity :math:`\eta_a` and thickness :math:`h_a`, + :math:`c = \eta_a / h_a`. The drag acts on every component, so the base + also resists normal motion, which the sheet never has.""" + u = self.Unknowns.u.sym + dim = self.mesh.dim + if reference_velocity is None: + reference = sympy.zeros(1, dim) + elif isinstance(reference_velocity, uw.discretisation.MeshVariable): + reference = sympy.Matrix(reference_velocity.sym).reshape(1, dim) + else: + reference = sympy.Matrix(list(reference_velocity)).reshape(1, dim) + self.add_natural_bc(coefficient * (u - reference), boundary) + + def add_basal_traction(self, traction, boundary): + r"""Apply a traction :math:`\mathbf{t}` (force per unit area, exerted on + the model) on ``boundary``; the thick form of + ``ThinSheet.add_basal_traction``.""" + dim = self.mesh.dim + if isinstance(traction, uw.discretisation.MeshVariable): + row = sympy.Matrix(traction.sym).reshape(1, dim) + else: + row = sympy.Matrix(list(traction)).reshape(1, dim) + self.add_natural_bc(-row, boundary) + # ----- DEVSS: stabilising a discontinuous elastic stress ----- @property @@ -2176,7 +2208,7 @@ def solve( F1 = Template( r"\mathbf{F}_1\left( \mathbf{u} \right)", - lambda self: self.stress + self._devss_flux(), + lambda self: self._flux_closure(self.stress_deviator), r"""Velocity equation flux/stress term (pointwise). The $\mathbf{F}_1$ tensor represents the stress response of the fluid, @@ -2357,11 +2389,23 @@ def stress(self): stress_deviator : Deviatoric (traceless) part, penalty-free. penalty : The augmentation, and its effect on the recovered pressure. """ + return self._stress_from_deviator(self.stress_deviator) + + def _stress_from_deviator(self, tau): + """The total stress built on a deviatoric stress ``tau``: less the + mechanical pressure, which carries the grad-div penalty.""" mechanical_pressure = ( self.p.sym[0] - self.penalty * self.constitutive_model.K * self.div_u ) - return self.stress_deviator - sympy.eye(self.mesh.dim) * mechanical_pressure + return sympy.Matrix(tau) - sympy.eye(self.mesh.dim) * mechanical_pressure + + def _flux_closure(self, tau): + """The momentum flux built on a deviatoric stress: the total stress + plus the DEVSS term. ``F1`` is this applied to the constitutive model's + flux; the Newton tangent applies it to the model's tangent flux + (``flux_jacobian``), so those terms stay in the tangent.""" + return self._stress_from_deviator(tau) + self._devss_flux() @property def stress_1d(self): diff --git a/src/underworld3/systems/thin_sheet.py b/src/underworld3/systems/thin_sheet.py index a561a13c2..0cacbfa4a 100644 --- a/src/underworld3/systems/thin_sheet.py +++ b/src/underworld3/systems/thin_sheet.py @@ -22,12 +22,16 @@ .. math:: - -\nabla_s \cdot \left( L\,\boldsymbol{\sigma} \right) + c\,\mathbf{u} - = \mathbf{f}, + -\nabla_s \cdot \left( L\,\boldsymbol{\sigma} \right) + + c\,(\mathbf{u} - \mathbf{u}_\mathrm{ref}) + = L\,\mathbf{f} + \mathbf{t}_b, with :math:`c` a basal drag coefficient (:math:`\eta_a/h_a` for a viscous -substrate) and :math:`\mathbf{f}` a force per unit area. There is no pressure -unknown, so this is a vector problem, not a saddle point. +substrate) against a reference velocity, :math:`\mathbf{f}` a body force per +unit volume (as for ``Stokes``) and :math:`\mathbf{t}_b` a basal traction. There +is no pressure unknown, so this is a vector problem, not a saddle point; the +plane-stress pressure :math:`-\operatorname{tr}\boldsymbol\tau` is available as +the diagnostic ``p``. On a flat mesh :math:`\mathbf{P} = \mathbf{I}` and the velocity has two components. On a manifold (``mesh.dim < mesh.cdim``, e.g. @@ -151,8 +155,9 @@ class SNES_ThinSheet(_StressHistoryMixin, _ConstitutiveModelStateMixin, SNES_Vec .. math:: - -\nabla_s \cdot \left( L\,\boldsymbol{\sigma} \right) + c\,\mathbf{u} - = \mathbf{f}, + -\nabla_s \cdot \left( L\,\boldsymbol{\sigma} \right) + + c\,(\mathbf{u} - \mathbf{u}_\mathrm{ref}) + = L\,\mathbf{f} + \mathbf{t}_b, \qquad \boldsymbol{\sigma} = \boldsymbol{\tau} + \operatorname{tr}(\boldsymbol{\tau})\,\mathbf{P}, @@ -168,8 +173,16 @@ class SNES_ThinSheet(_StressHistoryMixin, _ConstitutiveModelStateMixin, SNES_Vec A flat mesh (``dim == cdim``) or a spherical manifold. velocityField : MeshVariable, optional Sheet velocity, ``cdim`` components. Created if not supplied. + pressureField : MeshVariable, optional + Receives the plane-stress pressure :math:`p = -\operatorname{tr}\boldsymbol\tau` + after each solve (a diagnostic; the sheet has no pressure unknown). + Created on first access of :attr:`p` if not supplied. The signature is + Stokes', so a model script moves between a thin sheet and a thick one + (``Stokes`` on a thin 3-D mesh) by changing the mesh and the class. degree : int, default 2 Polynomial degree of the velocity space. + p_continuous : bool, default True + Continuity of the pressure diagnostic (degree ``degree - 1``). verbose : bool, default False DuDt, DFDt : optional Time-derivative managers; a viscoelastic constitutive model creates @@ -199,14 +212,26 @@ def u(inner_self, new_u): if new_u is not None: P = _tangent_projector(new_u.mesh) inner_self._E = P * inner_self._E * P - inner_self._Einv2 = sympy.sqrt( - (sympy.Matrix(inner_self._E) ** 2).trace() / 2) + inner_self._Einv2 = inner_self.second_invariant(sympy.Matrix(inner_self._E)) if new_u.mesh.dim < new_u.mesh.cdim: inner_self._W = _surface_spin(new_u.mesh, inner_self._L) + def second_invariant(inner_self, M): + r"""Second invariant of the 3-D deviatoric tensor that an in-plane + tensor :math:`M` stands for: incompressibility puts + :math:`M_{nn} = -\operatorname{tr} M` on the sheet normal, so + + .. math:: M_{II} = \sqrt{\tfrac12\left(M_{ij}M_{ij} + + (\operatorname{tr} M)^2\right)}. + + Without the normal component a plane-stress uniaxial stretch yields + at :math:`\sqrt{18/5}\,\tau_y` instead of the von Mises + :math:`\sqrt{3}\,\tau_y` (the sheet thins where it stretches).""" + return sympy.sqrt(((M**2).trace() + M.trace() ** 2) / 2) + _solver_terms = ( ("thickness", "sheet thickness L multiplying the in-plane stress"), - ("bodyforce", "force per unit area; F0 is its negative"), + ("bodyforce", "body force per unit volume; F0 carries -L f"), ("basal_drag", "drag coefficient c on the sheet velocity (eta_a / h_a)"), ) @@ -215,7 +240,9 @@ def __init__( self, mesh: uw.discretisation.Mesh, velocityField: Optional[uw.discretisation.MeshVariable] = None, + pressureField: Optional[uw.discretisation.MeshVariable] = None, degree: Optional[int] = 2, + p_continuous: Optional[bool] = True, verbose: Optional[bool] = False, DuDt: Union[SemiLagrangian_DDt, Lagrangian_DDt] = None, DFDt: Union[SemiLagrangian_DDt, Lagrangian_DDt] = None, @@ -228,8 +255,19 @@ def __init__( self._bodyforce = expression( Rf"\mathbf{{f}}_0\left( {self.Unknowns.u.symbol} \right)", sympy.Matrix([[0] * self.mesh.cdim]), - "Thin-sheet force per unit area: f_0(u)", + "Thin-sheet body force per unit volume: f_0(u)", ) + # basal coupling added by add_basal_drag / add_basal_traction: a list of + # (1, cdim) rows entering F0 as residual terms (a drag c (u - u_ref), + # a traction with its sign reversed) + self._basal_terms = [] + # The pressure is not an unknown of the sheet; p = -tr(tau) is recovered + # after each solve into a diagnostic field, created on demand so a script + # written for Stokes (which reads solver.p) runs unchanged. + self._p_degree = max(degree - 1, 1) + self._p_continuous = p_continuous + self._p_field = pressureField + self._p_projection = None self._needs_function_rewire = True self._constitutive_model = None # On a manifold the velocity is carried in the embedding space; its @@ -277,6 +315,7 @@ def solve( if self.Unknowns.DFDt is None: super().solve(zero_init_guess, _force_setup=_force_setup, verbose=verbose, divergence_retries=divergence_retries) + self._refresh_pressure(verbose) return if timestep is None: raise ValueError( @@ -298,6 +337,7 @@ def solve( divergence_retries=divergence_retries) self._stress_history_post_solve(timestep, verbose=verbose, evalf=evalf) self._keep_history_tangent() + self._refresh_pressure(verbose) def _keep_history_tangent(self): r"""On a manifold, project every stored history level onto the tangent @@ -389,10 +429,74 @@ def basal_drag(self, value): self._needs_function_rewire = True self._basal_drag.sym = value + def add_basal_drag(self, coefficient, boundary=None, reference_velocity=None): + r"""Couple the sheet to its base by a drag + :math:`-c\,(\mathbf{u} - \mathbf{u}_\mathrm{ref})` per unit area. + + ``reference_velocity`` is the velocity of what lies beneath (a viscous + substrate's flow, a mantle convection model's surface velocity, plate + motions); ``None`` drags against a fixed base. For a viscous substrate + of viscosity :math:`\eta_a` and thickness :math:`h_a`, + :math:`c = \eta_a / h_a`. Calls accumulate. + + ``boundary`` names the base of a thick sheet (``Stokes`` on a thin 3-D + mesh applies the same drag there as a natural condition). The thin sheet + is its own base, so it is accepted and not used — the same model line + serves both.""" + self._needs_function_rewire = True + u = self.Unknowns.u.sym + if reference_velocity is None: + reference = sympy.zeros(1, self.mesh.cdim) + elif isinstance(reference_velocity, uw.discretisation.MeshVariable): + reference = sympy.Matrix(reference_velocity.sym).reshape(1, self.mesh.cdim) + else: + reference = _nondimensional_row(reference_velocity, self.mesh.cdim) + self._basal_terms.append(coefficient * (u - reference)) + + def add_basal_traction(self, traction, boundary=None): + r"""Apply a traction :math:`\mathbf{t}` (force per unit area, exerted + on the sheet) at its base — the shear stress of a convection model + resolved at the base of the lithosphere, say. Calls accumulate. + ``boundary`` as in :meth:`add_basal_drag`.""" + self._needs_function_rewire = True + if isinstance(traction, uw.discretisation.MeshVariable): + row = sympy.Matrix(traction.sym).reshape(1, self.mesh.cdim) + else: + row = _nondimensional_row(traction, self.mesh.cdim) + self._basal_terms.append(-row) + + def _basal_residual(self): + total = self.basal_drag.sym * self.Unknowns.u.sym + for term in self._basal_terms: + total = total + term + return total + + @property + def p(self): + r"""Plane-stress pressure :math:`p = -\operatorname{tr}\boldsymbol\tau` + (the out-of-plane deviatoric stress), refreshed after each solve. A + diagnostic: the sheet has no pressure unknown.""" + if self._p_field is None: + self._p_field = uw.discretisation.MeshVariable( + f"p_sheet_{self.instance_number}", self.mesh, 1, + degree=self._p_degree, continuous=self._p_continuous) + return self._p_field + + def _refresh_pressure(self, verbose=False): + if self._p_field is None: + return + if self._p_projection is None: + self._p_projection = uw.systems.Projection(self.mesh, self._p_field) + self._p_projection.smoothing = 0.0 + self._p_projection.uw_function = -sympy.Matrix(self.stress_deviator).trace() + self._p_projection.solve(verbose=verbose) + @property def bodyforce(self): - r"""Force per unit area :math:`\mathbf{f}` on the right-hand side of - the momentum balance.""" + r"""Body force per unit VOLUME :math:`\mathbf{f}`, as for ``Stokes``; + the sheet integrates it through its thickness, :math:`L\mathbf{f}`. + Forcing that acts on the base of the sheet (per unit area) belongs in + :meth:`add_basal_traction` or :meth:`add_basal_drag`.""" return self._bodyforce @bodyforce.setter @@ -427,20 +531,30 @@ def stress(self): tau = self.stress_deviator return tau + tau.trace() * self._P + def _flux_closure(self, tau): + r"""The momentum flux built on a deviatoric stress, + :math:`L(\boldsymbol\tau + \operatorname{tr}(\boldsymbol\tau)\mathbf{P})`. + ``F1`` is this applied to the constitutive model's flux; the Newton + tangent applies it to the model's tangent flux (``flux_jacobian``), so + the plane-stress closure stays in the tangent.""" + tau = sympy.Matrix(tau) + return self.thickness.sym * (tau + tau.trace() * self._P) + # ------------------------------------------------------------------ # PETSc residual templates # ------------------------------------------------------------------ F0 = Template( r"\mathbf{f}_0\left( \mathbf{u} \right)", - lambda self: -self.bodyforce.sym + self.basal_drag.sym * self.u.sym, - r"""Pointwise term: basal drag :math:`c\,\mathbf{u}` less the force per - unit area :math:`\mathbf{f}`.""", + lambda self: -self.thickness.sym * self.bodyforce.sym + self._basal_residual(), + r"""Pointwise term: the basal coupling (drag + :math:`c\,(\mathbf{u} - \mathbf{u}_\mathrm{ref})`, less any basal + traction) less the depth-integrated body force :math:`L\mathbf{f}`.""", ) F1 = Template( r"\mathbf{F}_1\left( \mathbf{u} \right)", - lambda self: self.thickness.sym * self.stress, + lambda self: self._flux_closure(self.stress_deviator), r"""Flux term: the depth-integrated in-plane stress :math:`L\, \boldsymbol{\sigma}`.""", ) diff --git a/tests/test_1075_thin_sheet.py b/tests/test_1075_thin_sheet.py index fc6eec72c..7fd3dd91c 100644 --- a/tests/test_1075_thin_sheet.py +++ b/tests/test_1075_thin_sheet.py @@ -87,8 +87,8 @@ def test_rigid_motion_carries_no_stress(): @pytest.mark.tier_b def test_basal_drag_balances_a_uniform_load(): - """With no kinematic boundary condition, a uniform force per unit area - against a basal drag c gives the uniform velocity u = f / c exactly.""" + """With no kinematic boundary condition, a uniform body force against a + basal drag c gives the uniform velocity u = L f / c exactly (L = 1).""" mesh = _box() sheet = _sheet(mesh) c = sympy.Rational(5, 2) @@ -131,3 +131,87 @@ def test_manufactured_solution_converges_at_third_order(): order = np.log(errors[0.2] / errors[0.1]) / np.log(2.0) assert order > 2.6, f"convergence order {order:.2f}, errors {errors}" + + +@pytest.mark.tier_b +def test_basal_coupling_and_body_force_per_unit_volume(): + """The aligned forcing: a body force per unit VOLUME (integrated through + the thickness L, as Stokes integrates it through a thick layer), a drag + against a moving base and a basal traction. Uniform, so exactly + u = u_ref + (L f + t) / c.""" + mesh = _box() + sheet = _sheet(mesh) + L, c = sympy.Integer(2), sympy.Rational(5, 2) + f = (sympy.Rational(3, 4), sympy.Rational(-1, 3)) + t = (sympy.Rational(1, 5), sympy.Rational(1, 2)) + u_ref = (sympy.Rational(-1, 2), sympy.Rational(1, 7)) + sheet.thickness = L + sheet.bodyforce = f + sheet.add_basal_drag(c, "Bottom", reference_velocity=u_ref) + sheet.add_basal_traction(t, "Bottom") + sheet.solve() + + u = np.asarray(sheet.u.array).reshape(-1, 2) + exact = np.array([float(u_ref[i] + (L * f[i] + t[i]) / c) for i in range(2)]) + assert np.abs(u - exact).max() < 1.0e-8 + + +@pytest.mark.tier_b +def test_pressure_diagnostic_is_minus_the_trace(): + """Uniaxial stretch: the plane-stress pressure p = -tr(tau) = -eta edot, + recovered into the diagnostic field ``sheet.p`` after the solve.""" + mesh = _box() + x, y = mesh.X + edot, eta = sympy.Rational(1, 2), 2 + sheet = _sheet(mesh, eta=eta) + u_y = -edot * (y - sympy.Rational(1, 2)) / 2 + sheet.add_essential_bc([0, u_y], "Left") + sheet.add_essential_bc([edot, u_y], "Right") + p = sheet.p # created before the solve + sheet.solve() + assert np.abs(np.asarray(p.array) + eta * float(edot)).max() < 1.0e-6 + + +@pytest.mark.tier_b +def test_plastic_sheet_yields_at_the_von_mises_stress(): + """A perfectly plastic sheet (viscosity far above tau_y / edot) in + uniaxial stretch carries sigma_xx = sqrt(3) tau_y, the von Mises uniaxial + yield stress: the invariant includes the out-of-plane strain rate + eps_nn = -tr(eps_s) (with the in-plane invariant alone the sheet yields at + sqrt(18/5) tau_y = 1.897 tau_y).""" + mesh = _box() + x, y = mesh.X + edot, tau_y = sympy.Rational(1, 2), 1 + sheet = uw.systems.ThinSheet(mesh) + sheet.constitutive_model = uw.constitutive_models.ViscoPlasticFlowModel + sheet.constitutive_model.Parameters.shear_viscosity_0 = 1.0e4 + sheet.constitutive_model.Parameters.yield_stress = tau_y + sheet.tolerance = 1.0e-10 + u_y = -edot * (y - sympy.Rational(1, 2)) / 2 + sheet.add_essential_bc([0, u_y], "Left") + sheet.add_essential_bc([edot, u_y], "Right") + # start from the uniform stretch: the plastic viscosity is singular at rest + sheet.u.array[:, 0, 0] = float(edot) * sheet.u.coords[:, 0] + sheet.u.array[:, 0, 1] = -float(edot) * (sheet.u.coords[:, 1] - 0.5) / 2 + sheet.solve(zero_init_guess=False) + + sigma = uw.function.evaluate(sheet.stress, _probe_points()).reshape(-1, 2, 2) + assert np.abs(sigma[:, 0, 0] - np.sqrt(3.0) * tau_y).max() < 1.0e-3 + assert np.abs(sigma[:, 1, 1]).max() < 1.0e-3 + + +@pytest.mark.tier_b +def test_sheet_solves_on_a_refined_mesh_with_a_hierarchy(): + """On a mesh with refinement levels the field decomposition (which the + rotated solves address the field by) recurses into the coarse levels; it + must run after the fields are copied there. Before, it raised PETSc error + 62 ("number of requested fields 1 greater than number of DM fields 0").""" + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.25, refinement=1, qdegree=3) + sheet = _sheet(mesh) + c = sympy.Rational(5, 2) + sheet.basal_drag = c + sheet.bodyforce = (1, 0) + sheet.solve() + u = np.asarray(sheet.u.array).reshape(-1, 2) + assert np.abs(u - np.array([1 / 2.5, 0.0])).max() < 1.0e-8 diff --git a/tests/test_1080_objective_step.py b/tests/test_1080_objective_step.py index ef0e92aaf..e2ead8fd6 100644 --- a/tests/test_1080_objective_step.py +++ b/tests/test_1080_objective_step.py @@ -116,7 +116,7 @@ def test_a_rigid_rotation_carries_the_stress_rigidly(): @pytest.mark.tier_b def test_the_newton_tangent_converges_to_the_same_stress(): """The Newton tangent replaces the frozen carried stress by its first-order - form (tangent_surrogates); the residual is unchanged, so the converged + form (the private _tangent_surrogates); the residual is unchanged, so the converged stress is the frozen-tangent one to solver tolerance.""" picard, _ = _rigid_carry(8, tangent=False, cell_size=0.2) newton, _ = _rigid_carry(8, tangent=True, cell_size=0.2) diff --git a/tests/test_1082_flux_jacobian_closure.py b/tests/test_1082_flux_jacobian_closure.py new file mode 100644 index 000000000..d6a509f19 --- /dev/null +++ b/tests/test_1082_flux_jacobian_closure.py @@ -0,0 +1,80 @@ +"""A constitutive model's tangent flux (``flux_jacobian``) is composed with the +solver's own closure, never substituted for the whole momentum flux. + +The model supplies the deviatoric stress; the solver turns it into its momentum +flux (Stokes: less the mechanical pressure, with the augmented-Lagrangian +penalty, plus DEVSS; the thin sheet: the plane-stress trace term times the +thickness). The Newton tangent has to see the same closure applied to the +model's tangent flux. Contract used here: on a LINEAR problem, a tangent flux +equal to the flux leaves Newton exactly as fast as no tangent flux at all — +the tangent is exact either way. When the tangent flux replaced the whole +flux, the penalty, DEVSS and pressure terms (Stokes) fell out of the tangent +and the linear solve diverged; on a vector solver (the sheet) the tangent flux +was silently ignored altogether (a flattening error swallowed by a bare +except), so the companion check is that a WRONG tangent flux (twice the flux) +does slow Newton down: the hook is live. +""" + +import pytest +import sympy + +import underworld3 as uw + +pytestmark = [pytest.mark.level_1] + + +def _newton_iterations(build, tangent_scale=None): + solver = build() + solver.consistent_jacobian = True + if tangent_scale is not None: + solver.constitutive_model.flux_jacobian = ( + tangent_scale * sympy.Matrix(solver.constitutive_model.flux)) + solver.solve() + return solver.snes.getIterationNumber(), solver + + +def _stokes(): + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.25, qdegree=3) + x, y = mesh.X + stokes = uw.systems.Stokes(mesh) + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = 1 + stokes.penalty = 10 + stokes.bodyforce = sympy.Matrix([[0, sympy.sin(sympy.pi * x)]]) + for wall in ("Left", "Right", "Top", "Bottom"): + stokes.add_essential_bc([0, 0], wall) + stokes.tolerance = 1.0e-8 + stokes.petsc_options["ksp_rtol"] = 1.0e-12 + return stokes + + +def _sheet(): + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.25, qdegree=3) + x, y = mesh.X + sheet = uw.systems.ThinSheet(mesh) + sheet.constitutive_model = uw.constitutive_models.ViscousFlowModel + sheet.constitutive_model.Parameters.shear_viscosity_0 = 1 + sheet.thickness = 2 + sheet.basal_drag = 1 + sheet.bodyforce = sympy.Matrix([[sympy.sin(sympy.pi * y), x * y]]) + sheet.add_essential_bc([0, 0], "Left") + sheet.tolerance = 1.0e-8 + sheet.petsc_options["ksp_rtol"] = 1.0e-12 + return sheet + + +@pytest.mark.tier_b +@pytest.mark.parametrize("build", [_stokes, _sheet], ids=["stokes", "thin_sheet"]) +def test_tangent_flux_keeps_the_solver_closure(build): + exact, _ = _newton_iterations(build) + composed, solver = _newton_iterations(build, tangent_scale=1) + assert solver.snes.getConvergedReason() > 0 + assert composed == exact, ( + f"Newton took {composed} iterations with a tangent flux equal to the " + f"flux, {exact} without one: the tangent lost the solver's closure") + wrong, _ = _newton_iterations(build, tangent_scale=2) + assert wrong > exact, ( + f"a tangent flux of twice the flux left Newton at {wrong} iterations " + f"({exact} exact): the tangent flux is not being used") From dc0d998d95dcfb4b4932986b75bbca29514b39ab Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Mon, 12 Oct 2026 21:10:48 +1100 Subject: [PATCH 25/57] The quadratic stress benchmark: a corotational Maxwell sheet thins at second order An unfaulted Maxwell sheet on the unit sphere dragged by the toroidal force 4A rhat x grad_s(z^2): the first-order flow is divergence-free, and the sheet thins only through the Jaumann rate turning shear stress into normal stress differences (the Weissenberg effect). The steady second-order perturbation, derived in sympy (scripts/sessions/thin_sheet_quadratic_closed_form.py): -div_s u / A^2 = -3 (65 z^4 - 126 z^2 + 29) / 533 = (24/91) P2(z) - (24/287) P4(z), with no P0 and no odd degrees. ETD-1 relaxes the corotational term with the effective time lambda' = alpha dt / (1 - alpha) in steady state, and the thinning is linear in lambda, so the discrete steady state is the closed form times lambda'/lambda. Measured at A = 0.1: P2 within 0.65 % (h 0.2, dt 0.2), 0.18 % (h 0.12, dt 0.1), 0.09 % (h 0.08); P4 converges at second order in h (14 %, 5.6 %, 2.75 %); the dt dependence is entirely lambda'. test_1084 (level 3, 7 min): h 0.2, dt 0.2, 20 steps; P2 to 2 %, P4 to 25 %, P0 and the odd degrees below 2e-3 of P2. Underworld development team with AI support from Claude Code --- .../thin_sheet_quadratic_closed_form.py | 94 +++++++++++++++++++ tests/test_1084_quadratic_stress_sphere.py | 75 +++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 scripts/sessions/thin_sheet_quadratic_closed_form.py create mode 100644 tests/test_1084_quadratic_stress_sphere.py diff --git a/scripts/sessions/thin_sheet_quadratic_closed_form.py b/scripts/sessions/thin_sheet_quadratic_closed_form.py new file mode 100644 index 000000000..8cb998c48 --- /dev/null +++ b/scripts/sessions/thin_sheet_quadratic_closed_form.py @@ -0,0 +1,94 @@ +"""Second-order perturbation of the steady corotational Maxwell sheet on the unit +sphere driven by f = 4A rhat x grad_s(z^2) — the closed form behind +tests/test_1084_quadratic_stress_sphere.py. + +eta = mu = L = 1, c = 4 (lambda = eta / mu = 1). Everything in embedding +(Cartesian) components, fields extended as polynomials, derivatives projected; the +problem is axisymmetric, so it is reduced on the meridian y = 0. + +Steady first order: tau_1 = 2 eta E(u_1), u_1 = a rhat x grad_s(z^2), a = 1/2. +Second order: tau_2 = 2 eta E(u_2) - lambda P[(u_1 . grad) tau_1 - W_1 tau_1 ++ tau_1 W_1] P, the plane-stress closure and the membrane balance solved for +u_2 = grad_s Phi + rhat x grad_s Psi with Phi, Psi polynomials in z. + +Prints (2026-10-12): a = 1/2; -div_s u_2 = -3 (65 z^4 - 126 z^2 + 29) / 533, +i.e. P2 = 24/91, P4 = -24/287, all other degrees zero. Runs in a few minutes: + + pixi run -e dev python scripts/sessions/thin_sheet_quadratic_closed_form.py +""" +import sympy as sp +x, y, z = sp.symbols("x y z", real=True) +X = sp.Matrix([x, y, z]) +eta, mu, c, L = 1, 1, 4, 1 +lam = sp.Rational(eta, mu) +I3 = sp.eye(3) +P = I3 - X * X.T # on r = 1 +S = sp.Symbol("S", positive=True) # sin(theta); the problem is axisymmetric, so the +def onS(e): # meridian y = 0, x = S = sqrt(1 - z^2) is enough + e = sp.expand(sp.sympify(e).subs({y: 0, x: S})) + p = sp.Poly(e, S) + out = 0 + for (k,), cf in p.terms(): + out += cf * (1 - z**2)**(k // 2) * S**(k % 2) + return sp.expand(out) +def grad_s_vec(u): # (grad_s u)_ij = d_k u_i P_kj + J = u.jacobian(X) + return J * P +def grad_s_scalar(f): + return P * sp.Matrix([sp.diff(f, v) for v in X]) +def div_s_tensor(S): # (div_s S)_i = d_k S_ij P_jk + out = sp.zeros(3, 1) + for i in range(3): + out[i] = sum(sp.diff(S[i, j], X[k]) * P[j, k] for j in range(3) for k in range(3)) + return out +def rcross(v): + return X.cross(v) +def E_of(u): + G = grad_s_vec(u) + return P * (G + G.T) / 2 * P +def W_of(u): + G = grad_s_vec(u) + return P * (G - G.T) / 2 * P +def membrane(u, Q=None): + """-P div_s(L sigma) + c u with sigma = tau + tr(tau) P, tau = 2 eta E(u) - Q.""" + tau = 2 * eta * E_of(u) + if Q is not None: + tau = tau - Q + sig = tau + tau.trace() * P + return -P * div_s_tensor(L * sig) + c * u + +# first order (per unit A) +a = sp.Symbol("a") +f1 = 4 * rcross(grad_s_scalar(z**2)) +u1 = a * rcross(grad_s_scalar(z**2)) +res1 = (membrane(u1) - f1).applyfunc(onS); print("first order built", flush=True) +asol = sp.solve([e for e in res1 if e != 0][0], a)[0] +u1 = u1.subs(a, asol) +print("a =", asol, " residual:", (membrane(u1) - f1).applyfunc(onS).T) +tau1 = 2 * eta * E_of(u1) +W1 = W_of(u1) +adv = sp.zeros(3, 3) +for i in range(3): + for j in range(3): + adv[i, j] = sum(u1[k] * sp.diff(tau1[i, j], X[k]) for k in range(3)) +Q = lam * P * (adv - W1 * tau1 + tau1 * W1) * P +# second order: u2 = grad_s Phi + rhat x grad_s Psi, Phi,Psi polynomials in z +cp = sp.symbols("p0:7"); cq = sp.symbols("q0:7") +Phi = sum(cp[k] * z**k for k in range(7)); Psi = sum(cq[k] * z**k for k in range(7)) +u2 = grad_s_scalar(Phi) + rcross(grad_s_scalar(Psi)) +res2 = (membrane(u2, Q)).applyfunc(onS) +eqs = [] +for e in res2: + eqs += sp.Poly(e, S, z).coeffs() +sol = sp.solve(eqs, list(cp[1:]) + list(cq[1:]), dict=True) +print("solutions:", sol) +s = sol[0] +Phi2 = Phi.subs(s).subs({k: 0 for k in cp + cq}) +u2s = u2.subs(s).subs({k: 0 for k in cp + cq}) +thin = onS(-sum(sp.diff(u2s[i], X[k]) * P[i, k] for i in range(3) for k in range(3))) +assert thin.free_symbols <= {z} +print("thinning rate / A^2 =", sp.factor(thin)) +Pl = [sp.legendre(l, z) for l in range(6)] +for l in range(6): + cl = sp.integrate(thin * Pl[l], (z, -1, 1)) / sp.integrate(Pl[l]**2, (z, -1, 1)) + print(f"P{l}: {sp.nsimplify(cl)} = {float(cl):.6f}") diff --git a/tests/test_1084_quadratic_stress_sphere.py b/tests/test_1084_quadratic_stress_sphere.py new file mode 100644 index 000000000..f06d5c2bc --- /dev/null +++ b/tests/test_1084_quadratic_stress_sphere.py @@ -0,0 +1,75 @@ +"""The quadratic (normal-stress) benchmark of a corotational Maxwell sheet on +the sphere: a divergence-free flow thins the sheet at second order. + +An unfaulted Maxwell sheet (eta = mu = L = 1) on the unit sphere, dragged +(c = 4) by the toroidal force 4 A rhat x grad_s(z^2). At first order the flow is +the zonal toroidal field u_1 = (A/2) rhat x grad_s(z^2), divergence-free: the +sheet neither thins nor thickens. At second order the corotational (Jaumann) +rate turns the shear stress into normal stress differences — the Weissenberg +effect of a Maxwell fluid — whose divergence drives a poloidal flow, and the +sheet thins. The steady second-order perturbation (sympy, derived 2026-10-12; +scripts/sessions/thin_sheet_quadratic_closed_form.py) gives + + -div_s u / A^2 = -3 (65 z^4 - 126 z^2 + 29) / 533 + = (24/91) P_2(z) - (24/287) P_4(z), + +with no P_0 (the sheet's mean thickness is conserved) and no odd degrees (the +forcing is symmetric about the equator). The linear (A^1) problem carries no +thinning at all, so the benchmark isolates the objective rate. + +ETD-1 carries the relaxation of the corotational term with an effective time +lambda' = alpha dt / (1 - alpha), alpha = exp(-dt / lambda), in steady state +(lambda' = 0.903 lambda at dt = 0.2), and the second-order thinning is linear in +lambda, so the discrete steady state is the closed form times lambda' / lambda. + +Measured (A = 0.1, steady by t = 4), P_2 / A^2 against (24/91) lambda': +h = 0.2, dt = 0.2: -0.65 %; h = 0.12, dt = 0.1: -0.18 %. P_4 / A^2 against +-(24/287) lambda': +14 % at h = 0.2, +5.6 % at h = 0.12 (second order in h). +""" + +import numpy as np +import pytest +import sympy + +import underworld3 as uw + +pytestmark = [pytest.mark.level_3] + + +@pytest.mark.tier_b +def test_quadratic_thinning_matches_the_second_order_closed_form(): + A, h, dt, steps = 0.1, 0.2, 0.2, 20 + sphere = uw.meshing.SphericalManifold(radius=1.0, cellSize=h, degree=1, qdegree=5) + x, y, z = sphere.X[0], sphere.X[1], sphere.X[2] + sheet = uw.systems.ThinSheet(sphere) + sheet.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + sheet.Unknowns, order=1, integrator="etd", objective_rate="jaumann") + sheet.constitutive_model.Parameters.shear_viscosity_0 = 1 + sheet.constitutive_model.Parameters.shear_modulus = 1 + sheet.add_basal_drag(4, "Base") + sheet.bodyforce = list(4 * sympy.nsimplify(A) * sympy.Matrix([[2 * y * z, -2 * x * z, 0]])) + sheet.tolerance = 1.0e-9 + sheet.consistent_jacobian = True + for _ in range(steps): + sheet.solve(timestep=dt, zero_init_guess=False) + + thinning = uw.discretisation.MeshVariable("thinning", sphere, 1, degree=1) + projection = uw.systems.Projection(sphere, thinning) + projection.uw_function = -sphere.vector.divergence(sheet.u.sym) + projection.smoothing = 0.0 + projection.solve() + coefficients = [] + for l in range(5): + P_l = sympy.legendre(l, z) + num = uw.maths.Integral(sphere, thinning.sym[0, 0] * P_l).evaluate() + den = uw.maths.Integral(sphere, P_l * P_l).evaluate() + coefficients.append(num / den / A**2) + c0, c1, c2, c3, c4 = coefficients + + alpha = np.exp(-dt) + lam = alpha * dt / (1 - alpha) + p2, p4 = 24 / 91 * lam, -24 / 287 * lam + assert abs(c2 - p2) < 0.02 * abs(p2), f"P2/A^2 {c2:.5f}, closed form {p2:.5f}" + assert abs(c4 - p4) < 0.25 * abs(p4), f"P4/A^2 {c4:.5f}, closed form {p4:.5f}" + for name, c in (("P0", c0), ("P1", c1), ("P3", c3)): + assert abs(c) < 2.0e-3 * abs(c2), f"{name}/A^2 {c:.3e} against P2 {c2:.3e}" From 937481bd878798fdaf3299ef0e96c2e59094763f Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Mon, 12 Oct 2026 21:34:24 +1100 Subject: [PATCH 26/57] The thick twin of a sheet mesh: mesh.extrude, faults and all uw.meshing.extrude_sheet / Mesh.extrude(L, layers) build a layer of tetrahedra centred on a sheet mesh (flat, or radially from a spherical manifold). The sheet's boundaries become the layer's walls under the same names, its faces are Base and Surface, and a split-node fault of the sheet becomes a vertical fault through the whole layer: the slit is duplicated at every level, so the fault is open at the surface and the base, its tips are shared vertical edges, and the side labels and minus-to-plus pairing are rebuilt by joining mapped vertex tuples. (The 3-D splitter cannot make this fault: it keeps a patch rim welded and needs about nine cells of depth.) Each prism is cut into three tetrahedra by the vertex order, with a fault's minus copy taking its plus copy's place, so both sides triangulate alike. Cells are oriented as PETSc expects; with the other sign every volume integral flipped against the facet ones (the volume came out negative and a basal drag pushed). An unfaulted layer gets a geometric-multigrid tail that coarsens through the thickness first and then laterally through the sheet's own levels. A faulted layer gets none yet: custom multigrid on the 3-D rotated pair operator fails on its first application (marked TODO(BUG)); algebraic multigrid converges. So one model function runs thin or thick by changing the mesh and the solver class. test_1083: the layer's geometry and pairing; a dragged plate, thin against thick, mid-plane velocity within 0.72 %; the same plate with a frictionless fault, 0.53 % away from the slit and the slip within 1.6 % (L = 0.04, c L / eta = 0.04). Serial only for now. Underworld development team with AI support from Claude Code --- .../design/THIN_SHEET_VISCOELASTIC_DESIGN.md | 33 +- docs/developer/subsystems/thin-sheet.md | 64 +++- .../discretisation/discretisation_mesh.py | 16 + src/underworld3/meshing/__init__.py | 1 + src/underworld3/meshing/extrude.py | 304 ++++++++++++++++++ tests/test_1083_thin_thick_alignment.py | 148 +++++++++ 6 files changed, 547 insertions(+), 19 deletions(-) create mode 100644 src/underworld3/meshing/extrude.py create mode 100644 tests/test_1083_thin_thick_alignment.py diff --git a/docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md b/docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md index a6513c218..1d914f8a9 100644 --- a/docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md +++ b/docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md @@ -116,14 +116,25 @@ The solver owns the kinematics, the closure and the geometry; the model owns the rheology. What is already shared: the constitutive models, the fault interface API, the boundary-condition methods, the stress history and `solve(timestep=)`. -Still to align: - -| | thin sheet | thick sheet | aligned form | -|---|---|---|---| -| construction | `ThinSheet(mesh)` | `Stokes(mesh, v, p)` | same signature; the sheet exposes `p = -tr(tau)` as a diagnostic | -| forcing | per unit area | per unit volume | per unit volume; the sheet multiplies by its thickness | -| basal coupling | `basal_drag` | traction on the base | one method, `add_basal_drag(c, boundary)` | -| fault geometry | `add_fault` with a trace | `add_conforming_sheet` | `add_fault` with a surface trace extrudes it through a thin 3-D mesh | -| stress | in-plane sigma | 3-D sigma | one resolved-traction / Delta CFF helper | -| nonlinear invariants | in-plane only (wrong where the sheet thins) | 3-D | the sheet supplies the invariant with eps_nn = -tr(eps_s) | -| topography | implied isostatic rate | free surface | one diagnostic name | +State of the alignment (2026-10-12): + +| | thin sheet | thick sheet | aligned form | state | +|---|---|---|---|---| +| construction | `ThinSheet(mesh, v, p, degree, p_continuous)` | `Stokes(mesh, v, p, ...)` | same signature; the sheet's `p` is the diagnostic $-\operatorname{tr}\tau$, refreshed after each solve | done | +| mesh | the sheet mesh | `mesh.extrude(L, layers)` | the thick twin keeps the sheet's boundaries as walls (same names) and adds `Base` / `Surface` | done (serial); MG tail coarsens through the thickness first | +| forcing | body force per unit volume, times $L$ | per unit volume | `bodyforce` means the same on both | done | +| basal coupling | `add_basal_drag(c, "Base", reference_velocity=)`, `add_basal_traction(t, "Base")` | the same, as natural conditions on `Base` | one line serves both; the sheet ignores the boundary name | done | +| fault geometry | `mesh.add_fault` | extruded with the mesh: a vertical fault through the layer, split at surface and base, tips shared | `add_fault_bc` on either | done flat (test_1083: velocity 0.5 %, slip 1.6 %); sphere implemented, untested; faulted layer has no MG tail yet (TODO(BUG)) | +| stress | `solver.stress` (in-plane, tension positive) | `solver.stress` (3-D) | `uw.maths.tensor.resolved_traction` / `coulomb_failure_stress` on `solver.stress` | done | +| nonlinear invariants | `Unknowns.second_invariant` adds $M_{nn} = -\operatorname{tr}M$ | plain contraction | every model invariant goes through the solver | done | +| Newton tangent | `_flux_closure` | `_flux_closure` | the model's `flux_jacobian` is composed with the solver's closure (it used to replace the whole flux, and was ignored on vector solvers) | done | +| topography | implied isostatic rate | free surface | with thickness transport (round 2) | open | + +Mesh-pairing notes. A thin 3-D fault cannot come from the 3-D splitter: it keeps +the patch rim welded (the tip rule one dimension up), so a fault through a layer +would be locked at the surface and the base, and it needs about nine cells of +depth. Extruding the already-split sheet avoids both: the slit is duplicated at +every level, so the fault is open where it meets the faces, and the two sides are +cut into tetrahedra identically (the minus copy of a vertex takes its plus copy's +place in the vertex order), so the pairing is rebuilt by joining mapped vertex +tuples. diff --git a/docs/developer/subsystems/thin-sheet.md b/docs/developer/subsystems/thin-sheet.md index 24c5ac470..524d93635 100644 --- a/docs/developer/subsystems/thin-sheet.md +++ b/docs/developer/subsystems/thin-sheet.md @@ -5,7 +5,7 @@ mesh or on the surface of a sphere, with any deviatoric constitutive model — Newtonian, Maxwell viscoelastic, or yielding — and the split-node fault machinery. Design record: `docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md`. -Tests: `tests/test_1075`–`test_1079`. Session drivers: `scripts/sessions/thin_sheet_*.py`. +Tests: `tests/test_1075`–`test_1084`. Session drivers: `scripts/sessions/thin_sheet_*.py`. ## The equations @@ -14,7 +14,8 @@ $\sigma_{nn} = 0$ on the dynamic stress, the pressure of an incompressible mater $p = \tau_{nn} = -\operatorname{tr}\boldsymbol\tau$ and $$ --\nabla_s\cdot\big(L\,\boldsymbol\sigma\big) + c\,\mathbf u = \mathbf f, +-\nabla_s\cdot\big(L\,\boldsymbol\sigma\big) + c\,(\mathbf u - \mathbf u_\mathrm{ref}) += L\,\mathbf f + \mathbf t_b, \qquad \boldsymbol\sigma = \boldsymbol\tau + \operatorname{tr}(\boldsymbol\tau)\,\mathbf P, \qquad @@ -23,9 +24,13 @@ $$ $\boldsymbol\tau$ is the deviatoric stress the constitutive model returns for the in-plane strain rate $\dot{\boldsymbol\varepsilon}_s$; $c$ is a basal drag -coefficient ($\eta_a/h_a$ for a viscous channel); $\mathbf f$ a force per unit -area, which for a sheet dragged by a moving substrate is $c\,\mathbf u_s$. There is -no pressure unknown: the solver is an `SNES_Vector`. +coefficient ($\eta_a/h_a$ for a viscous channel) against the velocity of what lies +beneath; $\mathbf f$ a body force per unit **volume** (as for `Stokes`) and +$\mathbf t_b$ a basal traction. There is no pressure unknown: the solver is an +`SNES_Vector`, and $p = -\operatorname{tr}\boldsymbol\tau$ is available as the +diagnostic `sheet.p`. Every invariant a constitutive model forms includes the +out-of-plane component $M_{nn} = -\operatorname{tr}M$ (`Unknowns.second_invariant`), +so a yielding sheet yields at the von Mises stress. Closed forms the tests pin: uniaxial stretch with free edges gives the Trouton stress $3\eta\dot\varepsilon$; a rigid motion carries no stress; the Maxwell @@ -41,12 +46,47 @@ sheet.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( sheet.constitutive_model.Parameters.shear_viscosity_0 = eta sheet.constitutive_model.Parameters.shear_modulus = mu sheet.thickness = L -sheet.basal_drag = c -sheet.bodyforce = list(c * u_substrate) +sheet.add_basal_drag(c, "Base", reference_velocity=u_substrate) sheet.add_fault_bc(0, "Flt") # after mesh.add_fault(("Flt", points)) sheet.solve(timestep=dt) ``` +## One model, thin or thick + +The same model function runs as a thin sheet and as a thick one — `Stokes` on a +thin 3-D layer — by changing the mesh and the solver class: + +```python +def model(mesh, Solver): + solver = Solver(mesh) + solver.constitutive_model = uw.constitutive_models.ViscousFlowModel + solver.constitutive_model.Parameters.shear_viscosity_0 = eta + solver.add_basal_drag(c, "Base", reference_velocity=u_base) + solver.add_fault_bc(0, "Flt") + return solver + +sheet = model(sheet_mesh, uw.systems.ThinSheet) # sheet.thickness = L +layer = model(sheet_mesh.extrude(L, layers=2), uw.systems.Stokes) +``` + +`mesh.extrude(L, layers)` (`uw.meshing.extrude_sheet`) builds the layer from the +sheet mesh: the sheet's boundaries become its walls under the same names, its faces +are `Base` and `Surface`, and every split-node fault of the sheet becomes a vertical +fault through the whole layer — open at the surface and the base, its tips shared +vertical edges — with the side labels and the pairing rebuilt, so +`Stokes.add_fault_bc` works on it. The thin sheet accepts the boundary name of +`add_basal_drag` / `add_basal_traction` and ignores it (it is its own base). +`uw.maths.tensor.resolved_traction` and `coulomb_failure_stress` act on either +solver's `stress`. A constitutive model's tangent flux (`flux_jacobian`) is +composed with each solver's own closure (`_flux_closure`). + +Measured (`test_1083`, $L = 0.04$, $cL/\eta = 0.04$): the layer's mid-plane +velocity matches the sheet's to 0.7 % unfaulted and 0.5 % faulted, the slip to +1.6 %. Limits: serial only; a faulted layer gets no geometric-multigrid tail yet +(the rotated 3-D pair operator fails under custom multigrid, marked TODO(BUG) in +`meshing/extrude.py`), so it solves with algebraic multigrid; the radial extrusion of a spherical +manifold is implemented but not yet tested. + ## On a sphere The velocity on a `SphericalManifold` (`dim=2`, `cdim=3`) is carried in the @@ -90,8 +130,16 @@ Known limits: faults may not reach the mesh boundary (2-D rule); on a split mesh pair data there; `mesh.adapt` runs on a manifold but does not yet snap the refined vertices back to the sphere. +## The quadratic benchmark + +On the sphere a divergence-free toroidal drag thins a corotational Maxwell sheet at +second order (the Weissenberg effect): $-\nabla_s\cdot\mathbf u/A^2 = +(24/91)P_2 - (24/287)P_4$ in steady state, scaled by ETD-1's effective relaxation +time $\alpha\Delta t/(1-\alpha)$. `test_1084`; the closed form is derived by +`scripts/sessions/thin_sheet_quadratic_closed_form.py`. + ## Round 2 (not yet) Thickness evolution with the gravitational potential energy term (England & McKenzie's Argand number) — on the sphere with the Eulerian advection–diffusion — -and yield in the sheet with the invariant that includes $\tau_{nn}$. +and topography from it. diff --git a/src/underworld3/discretisation/discretisation_mesh.py b/src/underworld3/discretisation/discretisation_mesh.py index e1e47eced..a6859d13e 100644 --- a/src/underworld3/discretisation/discretisation_mesh.py +++ b/src/underworld3/discretisation/discretisation_mesh.py @@ -8270,6 +8270,22 @@ def add_conforming_sheet(self, points, triangles, name, *, return self._adopt_cut_child(cut_dm, boundaries, info, mg_coarsening_ratio, verbose) + def extrude(self, thickness, layers=2, base="Base", surface="Surface", + qdegree=None, verbose=False): + r"""The thick twin of this sheet mesh: a layer of tetrahedra + ``thickness`` deep, centred on the sheet. + + The sheet's boundaries become the layer's walls under the same names, + its two faces are ``base`` and ``surface``, and every split-node fault + of the sheet becomes a vertical fault through the whole layer, with its + side labels and pairing. A model written for ``ThinSheet`` on this mesh + runs as ``Stokes`` on the result by changing the mesh and the solver + class. See :func:`underworld3.meshing.extrude_sheet`. + """ + from underworld3.meshing.extrude import extrude_sheet + return extrude_sheet(self, thickness, layers=layers, base=base, + surface=surface, qdegree=qdegree, verbose=verbose) + def add_fault(self, faults, verbose=False): """Cut AND split one or more faults; return the split mesh. diff --git a/src/underworld3/meshing/__init__.py b/src/underworld3/meshing/__init__.py index 69582f8fa..4e0d95ee5 100644 --- a/src/underworld3/meshing/__init__.py +++ b/src/underworld3/meshing/__init__.py @@ -78,6 +78,7 @@ register_plane_surfaces, register_box_face_surfaces, ) +from .extrude import extrude_sheet # Make all functions available at module level for backward compatibility __all__ = [ diff --git a/src/underworld3/meshing/extrude.py b/src/underworld3/meshing/extrude.py new file mode 100644 index 000000000..ca252b6b7 --- /dev/null +++ b/src/underworld3/meshing/extrude.py @@ -0,0 +1,304 @@ +r"""The thick twin of a sheet mesh: extrude a surface mesh through a thickness. + +A thin sheet (``ThinSheet`` on a surface mesh) and a thick one (``Stokes`` on a +thin 3-D layer) are the same model when they share everything but the solver +class and the mesh. :func:`extrude_sheet` makes the second mesh from the first, +so the two carry the same lateral boundaries under the same names and the same +faults: + +* every in-plane boundary of the sheet becomes the vertical wall it bounds, under + the same name (a model's ``add_essential_bc(..., "Left")`` means the same + thing on both); +* the two faces of the layer are added as ``Base`` and ``Surface`` (the drag and + traction of ``add_basal_drag`` / ``add_basal_traction`` go on ``Base``); +* a split-node fault of the sheet becomes a vertical fault through the whole + layer. Its slit is extruded with it, so the fault is split at the surface and + at the base, as a fault through a plate is, and its tips become shared + vertical edges — the tip rule of the sheet one dimension up. The fault's + side labels and the minus-to-plus point pairing are rebuilt for the layer, so + ``Stokes.add_fault_bc`` works on it as ``ThinSheet.add_fault_bc`` does on the + sheet. + +The layer is centred on the sheet: on a flat sheet it spans +:math:`z \in [-L/2, L/2]`, on a spherical manifold of radius :math:`R` the shell +:math:`r \in [R - L/2, R + L/2]` (each node moved along its own radius). + +Each prism of the layer is cut into three tetrahedra by the global vertex order, +which makes the cut of every shared quadrilateral face the same from both +sides. The two copies of a fault vertex take the SAME place in that order (their +original's), so the two sides of a fault are triangulated identically and their +faces coincide pair by pair. +""" + +from enum import Enum + +import numpy as np +from petsc4py import PETSc + +import underworld3 as uw + + +def _sheet_topology(mesh): + """Vertex coordinates, triangles (as vertex indices) and the point-to-index + map of a 2-D (or surface) simplex mesh.""" + dm = mesh.dm + v_start, v_end = dm.getDepthStratum(0) + c_start, c_end = dm.getHeightStratum(0) + cdim = dm.getCoordinateDim() + coords = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, cdim).copy() + triangles = np.empty((c_end - c_start, 3), dtype=np.int64) + for c in range(c_start, c_end): + closure = dm.getTransitiveClosure(c)[0] + verts = [int(p) - v_start for p in closure if v_start <= p < v_end] + if len(verts) != 3: + raise ValueError("extrude_sheet needs a triangle mesh") + triangles[c - c_start] = verts + return coords, triangles, v_start + + +def _edge_vertices(dm, edge, v_start): + cone = dm.getCone(edge) + return int(cone[0]) - v_start, int(cone[1]) - v_start + + +def extrude_sheet(mesh, thickness, layers=2, base="Base", surface="Surface", + qdegree=None, verbose=False, with_tail=True): + r"""Extrude a sheet mesh through ``thickness`` into a layer of tetrahedra. + + Parameters + ---------- + mesh : Mesh + A 2-D triangle mesh, or a triangulated spherical manifold, possibly + carrying split-node faults (``mesh.add_fault``). + thickness : float + Layer thickness :math:`L`, centred on the sheet. + layers : int, default 2 + Elements through the thickness. + base, surface : str + Names of the two faces of the layer. + qdegree : int, optional + Quadrature degree of the new mesh; the sheet's by default. + with_tail : bool, default True + Give the layer a geometric-multigrid tail: the sheet with the layer + count halved down to one, then the sheet's own coarse levels at one + layer (coarsened through the thickness first, see the note in the code). + + Returns + ------- + Mesh + The layer, with the sheet's boundaries as walls, ``base`` and + ``surface``, and the sheet's faults as vertical faults with their pairing. + """ + from underworld3.coordinates import CoordinateSystemType + + if uw.mpi.size > 1: + # TODO(DESIGN): build from the gathered sheet on one rank and distribute; + # the fault pairing then has to migrate with the distribution, as + # fault_split's redistribution does for a split. + raise NotImplementedError("extrude_sheet runs in serial for now") + if layers < 1: + raise ValueError("layers must be at least 1") + + dm2 = mesh.dm + coords, triangles, v_start = _sheet_topology(mesh) + nv = len(coords) + spherical = mesh.dim < mesh.cdim + if spherical and (mesh.CoordinateSystem.coordinate_type + is not CoordinateSystemType.SPHERICAL): + raise NotImplementedError("extrude_sheet: only flat sheets and spherical manifolds") + + # The order that cuts each prism: a fault's minus copy of a vertex takes its + # plus copy's place, so both sides cut their prisms alike. + key = np.arange(nv) + pairs2 = getattr(mesh, "_fault_point_pairs", {}) + for pairs in pairs2.values(): + for q_minus, q_plus in pairs.items(): + if v_start <= q_minus < v_start + nv: + key[q_minus - v_start] = q_plus - v_start + + # vertices, layer by layer + offsets = -0.5 * thickness + thickness * np.arange(layers + 1) / layers + if spherical: + radius = np.linalg.norm(coords, axis=1) + rhat = coords / radius[:, None] + xyz = np.concatenate([rhat * (radius + s)[:, None] for s in offsets]) + else: + xyz = np.concatenate([np.column_stack([coords, np.full(nv, s)]) for s in offsets]) + + # tetrahedra, three per prism + cells = [] + for k in range(layers): + lo, hi = k * nv, (k + 1) * nv + for tri in triangles: + a, b, c = sorted(tri, key=lambda v: key[v]) + A, B, C = lo + a, lo + b, lo + c + A1, B1, C1 = hi + a, hi + b, hi + c + cells += [[A, B, C, A1], [B, C, A1, B1], [C, A1, B1, C1]] + cells = np.array(cells, dtype=np.int32) + # PETSc's orientation: the first three vertices see the fourth on the side + # their right-hand normal points AWAY from (a negative triple product). With + # the other sign every volume integral changes sign against the facet ones — + # measured: the domain volume came out -0.1 and a basal drag pushed. + p = xyz[cells] + vol = np.einsum("ij,ij->i", np.cross(p[:, 1] - p[:, 0], p[:, 2] - p[:, 0]), p[:, 3] - p[:, 0]) + flip = vol > 0 + cells[flip, 2], cells[flip, 3] = cells[flip, 3].copy(), cells[flip, 2].copy() + + dm = PETSc.DMPlex().createFromCellList(3, cells, xyz, interpolate=True, + comm=PETSc.COMM_SELF) + v3_start, _ = dm.getDepthStratum(0) + + def vertex(k, i): + return v3_start + k * nv + i + + def face(verts): + join = dm.getFullJoin(verts) + if len(join) != 1: + raise RuntimeError(f"extrude_sheet internal: no face on vertices {verts}") + return int(join[0]) + + def quad_faces(a, b): + """The two triangles of each layer's quad over the sheet edge (a, b).""" + if key[a] > key[b]: + a, b = b, a + out = [] + for k in range(layers): + A, B, A1, B1 = vertex(k, a), vertex(k, b), vertex(k + 1, a), vertex(k + 1, b) + out += [face([A, B, A1]), face([B, A1, B1])] + return out + + def label_with_closure(name, value, points): + dm.createLabel(name) + for q in points: + for r in dm.getTransitiveClosure(q)[0]: + dm.setLabelValue(name, int(r), value) + + def label_faces(name, value, points): + dm.createLabel(name) + for q in points: + dm.setLabelValue(name, int(q), value) + + # the sheet's boundaries -> walls (fault labels: faces only, as on the sheet) + members = {} + fault_side_names = set() + for name in pairs2: + fault_side_names |= {name, f"{name}Plus", f"{name}Minus"} + e_start, e_end = dm2.getDepthStratum(1) + for b in mesh.boundaries: + if b.name == "All_Boundaries": + continue + members[b.name] = b.value + lab = dm2.getLabel(b.name) + if not lab or lab.getStratumSize(b.value) == 0: + continue + edges = [int(q) for q in lab.getStratumIS(b.value).getIndices() if e_start <= q < e_end] + faces3 = [f for e in edges for f in quad_faces(*_edge_vertices(dm2, e, v_start))] + if b.name in fault_side_names: + label_faces(b.name, b.value, faces3) + else: + label_with_closure(b.name, b.value, faces3) + + taken = set(members.values()) + for name in (base, surface): + value = max([v for v in taken if v < 666] + [0]) + 1 + while value in taken: + value += 1 + members[name] = value + taken.add(value) + for name, k in ((base, 0), (surface, layers)): + label_with_closure(name, members[name], + [face([vertex(k, i) for i in tri]) for tri in triangles]) + + # the fault pairing: every point on the minus side whose vertices include a + # duplicated one, paired with the point on the same plus-copy vertices + pairs3 = {} + for name, pairs in pairs2.items(): + vmap = {} + for q_minus, q_plus in pairs.items(): + if v_start <= q_minus < v_start + nv: + for k in range(layers + 1): + vmap[vertex(k, q_minus - v_start)] = vertex(k, q_plus - v_start) + value = mesh.boundaries[f"{name}Minus"].value + lab = dm.getLabel(f"{name}Minus") + minus_faces = lab.getStratumIS(value).getIndices() + found = {} + for f in minus_faces: + for q in dm.getTransitiveClosure(int(f))[0]: + q = int(q) + if q in found: + continue + if v3_start <= q < v3_start + nv * (layers + 1): + verts = [q] + else: + verts = [int(r) for r in dm.getTransitiveClosure(q)[0] + if v3_start <= r < v3_start + nv * (layers + 1)] + if not any(v in vmap for v in verts): + continue + mapped = [vmap.get(v, v) for v in verts] + found[q] = mapped[0] if len(mapped) == 1 else face(mapped) + pairs3[name] = found + + # every exterior face, as the sheet's All_Boundaries; a split fault's faces + # have one supporting cell each, so they are marked too and taken out again + dm.markBoundaryFaces("All_Boundaries", 1001) + all_label = dm.getLabel("All_Boundaries") + for name in pairs2: + lab = dm.getLabel(name) + value = mesh.boundaries[name].value + if lab and lab.getStratumSize(value): + for q in lab.getStratumIS(value).getIndices(): + all_label.clearValue(int(q), 1001) + + boundaries = Enum("boundaries", members) + thick = uw.discretisation.Mesh( + dm, simplex=True, + coordinate_system_type=mesh.CoordinateSystem.coordinate_type, + qdegree=qdegree if qdegree is not None else mesh.qdegree, + boundaries=boundaries, verbose=verbose) + if pairs3: + p_start, p_end = thick.dm.getChart() + if (p_start, p_end) != dm.getChart(): + raise RuntimeError("extrude_sheet internal: the mesh renumbered its points") + thick._fault_point_pairs = pairs3 + if getattr(mesh, "_fault_traces", None): + thick._fault_traces = dict(mesh._fault_traces) + thick._sheet_extrusion = {"sheet": mesh, "thickness": thickness, "layers": layers, + "base": base, "surface": surface} + + # The multigrid tail. A thin layer couples strongly through its thickness + # (the vertical spacing is the smallest), and point smoothers only smooth + # along the strong coupling, so the layer is coarsened through its thickness + # FIRST — the fine sheet with the layer count halved down to one — and only + # then laterally, through the sheet's own coarse levels at one layer. The + # coarse end of the hierarchy is then essentially the thin sheet. Without a + # tail the solve falls back to algebraic multigrid. The coarse levels do not + # carry the faults (the fine level alone is cut). + # TODO(BUG): with a split fault the layer's solve goes through the rotated + # pair constraints, and custom geometric multigrid on that 3-D rotated + # operator fails on its first application (DIVERGED_PC_FAILED, a NaN + # residual) with any tail tried — barycentric or RBF transfers, with or + # without the through-thickness levels — where algebraic multigrid + # converges. The 2-D rotated pairs take an inherited tail without trouble. + # Until that is found, a faulted layer is built without a tail. + # Found 2026-10-12; see the thin-sheet plan file. + if with_tail and not pairs2: + if len(getattr(mesh, "dm_hierarchy", []) or []) > 1: + sheet_levels = list(mesh._coarse_level_meshes())[:-1] + else: + sheet_levels = [] + vertical = [] + n = layers // 2 + while n >= 1: + vertical.append(extrude_sheet(mesh, thickness, layers=n, base=base, + surface=surface, qdegree=qdegree, + with_tail=False)) + n //= 2 + lateral = [extrude_sheet(level, thickness, layers=1, base=base, surface=surface, + qdegree=qdegree, with_tail=False) + for level in sheet_levels] + tail = lateral + list(reversed(vertical)) + if tail: + thick._custom_mg_coarse_meshes = tail + thick._custom_mg_builder = getattr(mesh, "_custom_mg_builder", "barycentric") + thick._custom_mg_fac_zone = None + return thick diff --git a/tests/test_1083_thin_thick_alignment.py b/tests/test_1083_thin_thick_alignment.py new file mode 100644 index 000000000..57b5ac427 --- /dev/null +++ b/tests/test_1083_thin_thick_alignment.py @@ -0,0 +1,148 @@ +"""One model, thin or thick: the same model function runs as a thin sheet +(``ThinSheet`` on a surface mesh) and as a thick one (``Stokes`` on the extruded +layer, ``mesh.extrude``), changing only the mesh and the solver class. + +The thin sheet is the depth-averaged, plane-stress limit of the layer, so the +two agree to the thin-sheet error: O(c L / eta) for the shear the basal drag +drives through the layer and O((L / ell)^2) for the vertical averaging, with +ell the drag coupling length sqrt(eta L / c). Here L = 0.04, eta = 1, c = 1: +ell = 0.2, c L / eta = 0.04. Measured 2026-10-12 (h = 0.05, two layers): the +mid-plane velocity of the layer differs from the sheet's by 0.72 % unfaulted and +0.53 % faulted (away from the slit), the slip along the fault by 1.6 %. +""" + +import numpy as np +import pytest +import sympy + +import underworld3 as uw + +pytestmark = [pytest.mark.level_2] + +L, ETA, C = 0.04, 1, 1 + + +def _sheet_mesh(faulted): + # refinement=1: the sheet's multigrid level is extruded with it, so the + # layer's Stokes solve gets geometric multigrid (h = 0.05 on the fine level) + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.1, refinement=1, qdegree=3) + if faulted: + mesh = mesh.add_fault([("Flt", np.array([[0.25, 0.5], [0.75, 0.5]]))]) + return mesh + + +def model(mesh, Solver): + """The model, written once: a viscous plate dragged by a base moving in + simple shear, free at its edges, optionally cut by a frictionless fault.""" + solver = Solver(mesh) + solver.constitutive_model = uw.constitutive_models.ViscousFlowModel + solver.constitutive_model.Parameters.shear_viscosity_0 = ETA + y = mesh.X[1] + base_velocity = [y - sympy.Rational(1, 2), 0] + [0] * (mesh.cdim - 2) + solver.add_basal_drag(C, "Base", reference_velocity=base_velocity) + if "Flt" in [b.name for b in mesh.boundaries]: + solver.add_fault_bc(0, "Flt") + # the contract is the thin-sheet error (percent), not solver round-off + solver.tolerance = 1.0e-5 + return solver + + +def _run(faulted): + sheet_mesh = _sheet_mesh(faulted) + sheet = model(sheet_mesh, uw.systems.ThinSheet) + sheet.thickness = L + sheet.solve() + layer = model(sheet_mesh.extrude(L, layers=2), uw.systems.Stokes) + layer.solve() + return sheet, layer + + +def _mid_plane(layer, xy): + """The layer's in-plane velocity on its mid-plane, at the sheet's nodes.""" + pts = np.column_stack([xy, np.zeros(len(xy))]) + return np.asarray(uw.function.evaluate(layer.u.sym, pts)).reshape(-1, 3)[:, :2] + + +@pytest.mark.tier_b +def test_extruded_layer_geometry_and_pairing(): + """The layer: positive volume (area x L, every cell oriented as PETSc + expects — inverted cells flip every volume integral against the facet ones, + and a basal drag pushed), the sheet's walls under their names plus Base and + Surface, and the fault pairing coincident and open at the surface and the + base (paired vertices at both faces).""" + sheet = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.1, qdegree=3) + split = sheet.add_fault([("Flt", np.array([[0.3, 0.5], [0.7, 0.5]]))]) + layer = split.extrude(L, layers=2) + uw.discretisation.MeshVariable("probe", layer, 1, degree=1) # Integral needs a field + assert abs(uw.maths.Integral(layer, 1).evaluate() - L) < 1.0e-12 + names = {b.name for b in layer.boundaries} + assert {"Left", "Right", "Top", "Bottom", "Base", "Surface", + "Flt", "FltPlus", "FltMinus"} <= names + + dm = layer.dm + v_start, v_end = dm.getDepthStratum(0) + coords = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, 3) + + def centroid(q): + verts = [int(r) - v_start for r in dm.getTransitiveClosure(q)[0] + if v_start <= r < v_end] + return coords[verts].mean(axis=0) + + pairs = layer._fault_point_pairs["Flt"] + assert len(pairs) > 0 + gap = max(np.linalg.norm(centroid(a) - centroid(b)) for a, b in pairs.items()) + assert gap < 1.0e-12 + paired_vertex_z = {round(float(coords[q - v_start, 2]), 12) + for q in pairs if v_start <= q < v_end} + assert round(-L / 2, 12) in paired_vertex_z and round(L / 2, 12) in paired_vertex_z + + +@pytest.mark.tier_b +def test_dragged_plate_thin_and_thick_agree(): + sheet, layer = _run(faulted=False) + xy = sheet.u.coords + u_sheet = np.asarray(sheet.u.array).reshape(-1, 2) + u_layer = _mid_plane(layer, xy) + error = np.linalg.norm(u_layer - u_sheet) / np.linalg.norm(u_sheet) + assert error < 2.0e-2, f"relative difference {error:.3e}" + + +@pytest.mark.tier_b +def test_faulted_plate_thin_and_thick_agree(): + """The same plate cut by a frictionless fault: on the layer the fault is the + sheet's, extruded through the thickness (open at the surface and base, + shared at its tip columns), with ``Stokes.add_fault_bc``. Compared away from + the slit (point evaluation within a cell of a slit reads across it); the + fault must slip, or the comparison is vacuous.""" + sheet, layer = _run(faulted=True) + xy = sheet.u.coords + u_sheet = np.asarray(sheet.u.array).reshape(-1, 2) + away = np.abs(xy[:, 1] - 0.5) > 0.06 + u_layer = _mid_plane(layer, xy[away]) + error = np.linalg.norm(u_layer - u_sheet[away]) / np.linalg.norm(u_sheet[away]) + assert error < 2.0e-2, f"relative difference {error:.3e}" + + # the slip: coincident nodes on the slit carry two velocities each; their + # spread in the fault-parallel component is the slip + s_sheet = np.asarray(sheet.u.array).reshape(-1, 2) + on_fault = (np.abs(xy[:, 1] - 0.5) < 1.0e-10) & (np.abs(xy[:, 0] - 0.5) < 0.2) + assert on_fault.sum() >= 4 + # coincident nodes: two velocities per position, their spread is the slip + positions = np.round(xy[on_fault, 0], 10) + spread = [np.ptp(s_sheet[on_fault][positions == p, 0]) for p in np.unique(positions)] + assert max(spread) > 1.0e-2, f"the sheet's fault does not slip ({max(spread):.2e})" + xyz = layer.u.coords + layer_on_fault = ((np.abs(xyz[:, 1] - 0.5) < 1.0e-10) & (np.abs(xyz[:, 0] - 0.5) < 0.2) + & (np.abs(xyz[:, 2]) < 1.0e-10)) + u_l = np.asarray(layer.u.array).reshape(-1, 3)[layer_on_fault] + pos_l = np.round(xyz[layer_on_fault, 0], 10) + spread_l = {p: np.ptp(u_l[pos_l == p, 0]) for p in np.unique(pos_l)} + spread_s = {p: np.ptp(s_sheet[on_fault][positions == p, 0]) for p in np.unique(positions)} + common = sorted(set(spread_l) & set(spread_s)) + assert len(common) >= 4 + slip_l = np.array([spread_l[p] for p in common]) + slip_s = np.array([spread_s[p] for p in common]) + assert np.linalg.norm(slip_l - slip_s) < 0.05 * np.linalg.norm(slip_s), ( + f"slip thick {slip_l} thin {slip_s}") From e2a2ece7bd2c61c4443b7e524d568b46af4df5b1 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Mon, 12 Oct 2026 21:41:47 +1100 Subject: [PATCH 27/57] The thick twin on the sphere: a radially extruded shell against the sheet The same model, a sheet on the unit-sphere manifold and the shell extruded radially from it, dragged by a toroidal base: the difference is first order in L, as the thin-sheet limit predicts (the base has 1 - L/R of the mid-surface's area, and the drag-driven shear c L / eta scales with L too): 3.32 % at L = 0.04, 1.35 % at L = 0.02. In test_1083. extrude_sheet carries over only the sheet's labels that mark facets (a manifold labels the whole surface "Surface", which the layer's own surface face then collided with), and refuses a real wall whose name the base or the surface would take. Underworld development team with AI support from Claude Code --- .../design/THIN_SHEET_VISCOELASTIC_DESIGN.md | 2 +- docs/developer/subsystems/thin-sheet.md | 5 +-- src/underworld3/meshing/extrude.py | 11 ++++++- tests/test_1083_thin_thick_alignment.py | 32 +++++++++++++++++++ 4 files changed, 46 insertions(+), 4 deletions(-) diff --git a/docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md b/docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md index 1d914f8a9..8963c6d83 100644 --- a/docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md +++ b/docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md @@ -124,7 +124,7 @@ State of the alignment (2026-10-12): | mesh | the sheet mesh | `mesh.extrude(L, layers)` | the thick twin keeps the sheet's boundaries as walls (same names) and adds `Base` / `Surface` | done (serial); MG tail coarsens through the thickness first | | forcing | body force per unit volume, times $L$ | per unit volume | `bodyforce` means the same on both | done | | basal coupling | `add_basal_drag(c, "Base", reference_velocity=)`, `add_basal_traction(t, "Base")` | the same, as natural conditions on `Base` | one line serves both; the sheet ignores the boundary name | done | -| fault geometry | `mesh.add_fault` | extruded with the mesh: a vertical fault through the layer, split at surface and base, tips shared | `add_fault_bc` on either | done flat (test_1083: velocity 0.5 %, slip 1.6 %); sphere implemented, untested; faulted layer has no MG tail yet (TODO(BUG)) | +| fault geometry | `mesh.add_fault` | extruded with the mesh: a vertical fault through the layer, split at surface and base, tips shared | `add_fault_bc` on either | done flat (test_1083: velocity 0.5 %, slip 1.6 %); sphere: first order in L, 1.35 % at L = 0.02; faulted layer has no MG tail yet (TODO(BUG)) | | stress | `solver.stress` (in-plane, tension positive) | `solver.stress` (3-D) | `uw.maths.tensor.resolved_traction` / `coulomb_failure_stress` on `solver.stress` | done | | nonlinear invariants | `Unknowns.second_invariant` adds $M_{nn} = -\operatorname{tr}M$ | plain contraction | every model invariant goes through the solver | done | | Newton tangent | `_flux_closure` | `_flux_closure` | the model's `flux_jacobian` is composed with the solver's closure (it used to replace the whole flux, and was ignored on vector solvers) | done | diff --git a/docs/developer/subsystems/thin-sheet.md b/docs/developer/subsystems/thin-sheet.md index 524d93635..df0a02750 100644 --- a/docs/developer/subsystems/thin-sheet.md +++ b/docs/developer/subsystems/thin-sheet.md @@ -84,8 +84,9 @@ Measured (`test_1083`, $L = 0.04$, $cL/\eta = 0.04$): the layer's mid-plane velocity matches the sheet's to 0.7 % unfaulted and 0.5 % faulted, the slip to 1.6 %. Limits: serial only; a faulted layer gets no geometric-multigrid tail yet (the rotated 3-D pair operator fails under custom multigrid, marked TODO(BUG) in -`meshing/extrude.py`), so it solves with algebraic multigrid; the radial extrusion of a spherical -manifold is implemented but not yet tested. +`meshing/extrude.py`), so it solves with algebraic multigrid. On the sphere (radial extrusion) the +difference is first order in $L$ — the curvature the sheet neglects and the +drag-driven shear: 3.3 % at $L = 0.04$, 1.35 % at $L = 0.02$. ## On a sphere diff --git a/src/underworld3/meshing/extrude.py b/src/underworld3/meshing/extrude.py index ca252b6b7..46529ab3a 100644 --- a/src/underworld3/meshing/extrude.py +++ b/src/underworld3/meshing/extrude.py @@ -187,11 +187,20 @@ def label_faces(name, value, points): for b in mesh.boundaries: if b.name == "All_Boundaries": continue - members[b.name] = b.value lab = dm2.getLabel(b.name) if not lab or lab.getStratumSize(b.value) == 0: continue edges = [int(q) for q in lab.getStratumIS(b.value).getIndices() if e_start <= q < e_end] + if not edges: + # not a boundary of the sheet's area (a manifold labels the whole + # surface, for one): nothing to stand up as a wall + continue + if b.name in (base, surface): + raise ValueError( + f"the sheet has a boundary called {b.name!r}, which the layer's " + f"{'base' if b.name == base else 'surface'} would take; pass " + "another name for it") + members[b.name] = b.value faces3 = [f for e in edges for f in quad_faces(*_edge_vertices(dm2, e, v_start))] if b.name in fault_side_names: label_faces(b.name, b.value, faces3) diff --git a/tests/test_1083_thin_thick_alignment.py b/tests/test_1083_thin_thick_alignment.py index 57b5ac427..05a55d0d1 100644 --- a/tests/test_1083_thin_thick_alignment.py +++ b/tests/test_1083_thin_thick_alignment.py @@ -146,3 +146,35 @@ def test_faulted_plate_thin_and_thick_agree(): slip_s = np.array([spread_s[p] for p in common]) assert np.linalg.norm(slip_l - slip_s) < 0.05 * np.linalg.norm(slip_s), ( f"slip thick {slip_l} thin {slip_s}") + + +@pytest.mark.tier_b +def test_dragged_shell_thin_and_thick_agree_on_the_sphere(): + """The same model on the sphere: a sheet on the unit-sphere manifold and the + shell extruded radially from it, dragged by a base in the toroidal flow + rhat x grad(z^2). The difference is first order in L — the curvature the + thin sheet neglects (the base has 1 - L/R of the mid-surface's area) and the + drag-driven shear c L / eta both scale with L. Measured 2026-10-12 + (h = 0.15, two layers): 3.32 % at L = 0.04, 1.35 % at L = 0.02.""" + sphere = uw.meshing.SphericalManifold(radius=1.0, cellSize=0.15, degree=1, qdegree=3) + + def shell_model(mesh, Solver): + solver = Solver(mesh) + solver.constitutive_model = uw.constitutive_models.ViscousFlowModel + solver.constitutive_model.Parameters.shear_viscosity_0 = 1 + x, y, z = mesh.X + solver.add_basal_drag(1, "Base", reference_velocity=[2 * y * z, -2 * x * z, 0]) + solver.tolerance = 1.0e-5 + return solver + + thickness = 0.02 + sheet = shell_model(sphere, uw.systems.ThinSheet) + sheet.thickness = thickness + sheet.solve() + shell = shell_model(sphere.extrude(thickness, layers=2), uw.systems.Stokes) + shell.solve() + pts = sheet.u.coords + u_sheet = np.asarray(sheet.u.array).reshape(-1, 3) + u_shell = np.asarray(uw.function.evaluate(shell.u.sym, pts)).reshape(-1, 3) + error = np.linalg.norm(u_shell - u_sheet) / np.linalg.norm(u_sheet) + assert error < 2.5e-2, f"relative difference {error:.3e}" From 7cf3decf1a6845fb28b45aee35ba33d6f875ddd2 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Tue, 13 Oct 2026 04:16:47 +1100 Subject: [PATCH 28/57] A faulted layer gets geometric multigrid: coarse levels from the unsplit sheet The layer's multigrid tail was extruded from the split sheet itself when the split mesh owned no tail (a plain refined base hands none down), so the one-layer coarse level carried the slit: coincident nodes, which the barycentric and RBF transfer builders cannot place, and the first cycle returned NaN (DIVERGED_PC_FAILED). It was first read as a failure of geometric multigrid on the rotated 3-D pair operator and the faulted layer was left on algebraic multigrid. The coarse levels now come from the split sheet's ancestors: the conforming cut it was split from (same nodes, no slit) for the through-thickness levels, the first ancestor with a refinement hierarchy for the lateral ones. Measured: the faulted layer's velocity block takes 14 iterations per solve against about 180 under algebraic multigrid. test_1083's faulted case asserts the layer runs custom FMG and converges. Underworld development team with AI support from Claude Code --- .../design/THIN_SHEET_VISCOELASTIC_DESIGN.md | 2 +- docs/developer/subsystems/thin-sheet.md | 7 +- src/underworld3/meshing/extrude.py | 69 ++++++++++++------- tests/test_1083_thin_thick_alignment.py | 5 ++ 4 files changed, 55 insertions(+), 28 deletions(-) diff --git a/docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md b/docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md index 8963c6d83..a9e0d7745 100644 --- a/docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md +++ b/docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md @@ -124,7 +124,7 @@ State of the alignment (2026-10-12): | mesh | the sheet mesh | `mesh.extrude(L, layers)` | the thick twin keeps the sheet's boundaries as walls (same names) and adds `Base` / `Surface` | done (serial); MG tail coarsens through the thickness first | | forcing | body force per unit volume, times $L$ | per unit volume | `bodyforce` means the same on both | done | | basal coupling | `add_basal_drag(c, "Base", reference_velocity=)`, `add_basal_traction(t, "Base")` | the same, as natural conditions on `Base` | one line serves both; the sheet ignores the boundary name | done | -| fault geometry | `mesh.add_fault` | extruded with the mesh: a vertical fault through the layer, split at surface and base, tips shared | `add_fault_bc` on either | done flat (test_1083: velocity 0.5 %, slip 1.6 %); sphere: first order in L, 1.35 % at L = 0.02; faulted layer has no MG tail yet (TODO(BUG)) | +| fault geometry | `mesh.add_fault` | extruded with the mesh: a vertical fault through the layer, split at surface and base, tips shared | `add_fault_bc` on either | done flat (test_1083: velocity 0.5 %, slip 1.6 %); sphere: first order in L, 1.35 % at L = 0.02; the multigrid tail uses fault-free coarse levels | | stress | `solver.stress` (in-plane, tension positive) | `solver.stress` (3-D) | `uw.maths.tensor.resolved_traction` / `coulomb_failure_stress` on `solver.stress` | done | | nonlinear invariants | `Unknowns.second_invariant` adds $M_{nn} = -\operatorname{tr}M$ | plain contraction | every model invariant goes through the solver | done | | Newton tangent | `_flux_closure` | `_flux_closure` | the model's `flux_jacobian` is composed with the solver's closure (it used to replace the whole flux, and was ignored on vector solvers) | done | diff --git a/docs/developer/subsystems/thin-sheet.md b/docs/developer/subsystems/thin-sheet.md index df0a02750..7e219e06b 100644 --- a/docs/developer/subsystems/thin-sheet.md +++ b/docs/developer/subsystems/thin-sheet.md @@ -82,9 +82,10 @@ composed with each solver's own closure (`_flux_closure`). Measured (`test_1083`, $L = 0.04$, $cL/\eta = 0.04$): the layer's mid-plane velocity matches the sheet's to 0.7 % unfaulted and 0.5 % faulted, the slip to -1.6 %. Limits: serial only; a faulted layer gets no geometric-multigrid tail yet -(the rotated 3-D pair operator fails under custom multigrid, marked TODO(BUG) in -`meshing/extrude.py`), so it solves with algebraic multigrid. On the sphere (radial extrusion) the +1.6 %. Limits: serial only. The layer's multigrid tail coarsens through the +thickness first, then laterally through the sheet's own levels, and every coarse +level is fault-free (taken from the split sheet's ancestors: a coarse level with a +slit has coincident nodes, which the transfer builders cannot place). On the sphere (radial extrusion) the difference is first order in $L$ — the curvature the sheet neglects and the drag-driven shear: 3.3 % at $L = 0.04$, 1.35 % at $L = 0.02$. diff --git a/src/underworld3/meshing/extrude.py b/src/underworld3/meshing/extrude.py index 46529ab3a..10b0f87af 100644 --- a/src/underworld3/meshing/extrude.py +++ b/src/underworld3/meshing/extrude.py @@ -282,32 +282,53 @@ def label_faces(name, value, points): # coarse end of the hierarchy is then essentially the thin sheet. Without a # tail the solve falls back to algebraic multigrid. The coarse levels do not # carry the faults (the fine level alone is cut). - # TODO(BUG): with a split fault the layer's solve goes through the rotated - # pair constraints, and custom geometric multigrid on that 3-D rotated - # operator fails on its first application (DIVERGED_PC_FAILED, a NaN - # residual) with any tail tried — barycentric or RBF transfers, with or - # without the through-thickness levels — where algebraic multigrid - # converges. The 2-D rotated pairs take an inherited tail without trouble. - # Until that is found, a faulted layer is built without a tail. - # Found 2026-10-12; see the thin-sheet plan file. - if with_tail and not pairs2: - if len(getattr(mesh, "dm_hierarchy", []) or []) > 1: - sheet_levels = list(mesh._coarse_level_meshes())[:-1] - else: - sheet_levels = [] - vertical = [] - n = layers // 2 - while n >= 1: - vertical.append(extrude_sheet(mesh, thickness, layers=n, base=base, - surface=surface, qdegree=qdegree, - with_tail=False)) - n //= 2 - lateral = [extrude_sheet(level, thickness, layers=1, base=base, surface=surface, - qdegree=qdegree, with_tail=False) - for level in sheet_levels] - tail = lateral + list(reversed(vertical)) + if with_tail: + tail = _extruded_tail(mesh, thickness, layers, base, surface, qdegree) if tail: thick._custom_mg_coarse_meshes = tail thick._custom_mg_builder = getattr(mesh, "_custom_mg_builder", "barycentric") thick._custom_mg_fac_zone = None return thick + + +def _extruded_tail(mesh, thickness, layers, base, surface, qdegree): + """The coarse levels of the layer, coarsest first (see the note in + :func:`extrude_sheet`): the sheet with the layer count halved down to one, + then the sheet's own coarse levels at one layer. + + Every coarse level must be free of the faults. The transfer builders locate + fine nodes among the coarse ones, and a split level has coincident nodes along + its slit: the transfers degenerate and the multigrid returns NaN on its first + application (measured; this was first misread as a failure of geometric + multigrid on the rotated 3-D pair operator — on fault-free levels the faulted + layer's velocity block takes 14 iterations where algebraic multigrid takes + about 180). So the levels come from the split sheet's ancestors: the conforming + cut it was split from (same nodes, no slit) for the through-thickness levels, + and the first ancestor with a refinement hierarchy for the lateral ones. + """ + def ancestors(m): + while m is not None: + yield m + m = getattr(m, "parent", None) + + unsplit = next((m for m in ancestors(mesh) + if not getattr(m, "_fault_point_pairs", None)), None) + if unsplit is None: + return [] + own = getattr(mesh, "_custom_mg_coarse_meshes", None) + if own is not None: + sheet_levels = [m for m in own if not getattr(m, "_fault_point_pairs", None)] + else: + refined = next((m for m in ancestors(unsplit) + if len(getattr(m, "dm_hierarchy", []) or []) > 1), None) + sheet_levels = list(refined._coarse_level_meshes())[:-1] if refined is not None else [] + vertical = [] + n = layers // 2 + while n >= 1: + vertical.append(extrude_sheet(unsplit, thickness, layers=n, base=base, + surface=surface, qdegree=qdegree, with_tail=False)) + n //= 2 + lateral = [extrude_sheet(level, thickness, layers=1, base=base, surface=surface, + qdegree=qdegree, with_tail=False) + for level in sheet_levels] + return lateral + list(reversed(vertical)) diff --git a/tests/test_1083_thin_thick_alignment.py b/tests/test_1083_thin_thick_alignment.py index 05a55d0d1..08c031f29 100644 --- a/tests/test_1083_thin_thick_alignment.py +++ b/tests/test_1083_thin_thick_alignment.py @@ -117,6 +117,11 @@ def test_faulted_plate_thin_and_thick_agree(): the slit (point evaluation within a cell of a slit reads across it); the fault must slip, or the comparison is vacuous.""" sheet, layer = _run(faulted=True) + # the layer's velocity block is geometric multigrid over fault-free coarse + # levels; a split coarse level (coincident nodes on the slit) made the + # transfers degenerate and the first cycle returned NaN + info = layer._rotated_freeslip_info + assert info["velocity_pc"] == "custom-FMG" and info["converged"], info xy = sheet.u.coords u_sheet = np.asarray(sheet.u.array).reshape(-1, 2) away = np.abs(xy[:, 1] - 0.5) > 0.06 From 4d219f701e3b4148679f539cdbbe8581a8798889 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Tue, 13 Oct 2026 10:22:01 +1100 Subject: [PATCH 29/57] build_rotation sets the SNES up instead of reading a null Jacobian build_rotation takes the operator's row layout from the solver's Jacobian. _build() leaves the SNES set up only on some paths: with essential conditions registered it is, without them (a drag-only layer) the matrix is never created, and build_rotation dereferenced a null Mat - a segmentation fault. The solver's own rotated solve sets the SNES up first, so production solves were unaffected; a direct call on a built solver was not. Now it sets the SNES up when the Jacobian does not exist yet. test_1085 (crashed with SIGSEGV before the guard). Underworld development team with AI support from Claude Code --- src/underworld3/utilities/rotated_bc.py | 7 ++++++ tests/test_1085_build_rotation_setup.py | 31 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 tests/test_1085_build_rotation_setup.py diff --git a/src/underworld3/utilities/rotated_bc.py b/src/underworld3/utilities/rotated_bc.py index 894e43c34..d15605a67 100644 --- a/src/underworld3/utilities/rotated_bc.py +++ b/src/underworld3/utilities/rotated_bc.py @@ -470,7 +470,14 @@ def build_rotation(solver, boundaries, datum_specs=None): # rows fall outside [rstart, rend)). A fault PAIR block spans two points, but # both are rank-local (a seam-touching fault is redistributed onto one rank # before the split), so the diagonal-portion argument carries over unchanged. + # The row layout comes from the solver's Jacobian. _build() leaves the SNES + # set up only on some paths (with essential conditions registered it is; + # without them the matrix is never created), so set it up here rather than + # read a null matrix — that was a segmentation fault (test_1085). A = solver.snes.getJacobian()[0] + if A is None or A.handle == 0: + solver.snes.setUp() + A = solver.snes.getJacobian()[0] rstart, rend = A.getOwnershipRange() nloc = rend - rstart N = A.getSize()[0] diff --git a/tests/test_1085_build_rotation_setup.py b/tests/test_1085_build_rotation_setup.py new file mode 100644 index 000000000..856d30dc0 --- /dev/null +++ b/tests/test_1085_build_rotation_setup.py @@ -0,0 +1,31 @@ +"""``build_rotation`` reads the solver's Jacobian for the operator's row layout. +``_build()`` sets the SNES up only on some paths (with essential boundary +conditions registered it ends up set up; without them the Jacobian is never +created), so ``build_rotation`` must set the SNES up itself rather than +dereference a null matrix. Before the guard: a segmentation fault (found +2026-10-13 building a rotation for a drag-only faulted layer).""" + +import numpy as np +import pytest + +import underworld3 as uw + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_b] + + +def test_build_rotation_on_a_built_solver_without_essential_bcs(): + from underworld3.utilities.rotated_bc import build_rotation + + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.1, qdegree=2) + split = mesh.add_fault([("Flt", np.array([[0.25, 0.5], [0.75, 0.5]]))]) + stokes = uw.systems.Stokes(split) + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = 1 + stokes.add_basal_drag(1, "Bottom") # natural condition only + stokes.add_fault_bc(0, "Flt") + stokes._build() + Q, Qt, normal_rows, _ = build_rotation(stokes, stokes._rotated_freeslip_bcs) + n = stokes.snes.getJacobian()[0].getSize()[0] + assert Q.getSize() == (n, n) + assert len(normal_rows) > 0 From 07447c02ca2ff47bb78ea103ef8fba168c7534e3 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Tue, 13 Oct 2026 10:47:03 +1100 Subject: [PATCH 30/57] Technical note: multigrid hierarchies for split-node faults Four alternatives for the levels under a split fault on the add-on hierarchy (background never cut): A finest-only split (today), B2 TI weak band rediscretised on the add-on levels, C every add-on level split, D every add-on level split over a broad TI zone on the background. Measured on a faulted box (frictionless, viscous, Coulomb) to five add-on levels: C's velocity iterations fall from 10 to 5 while A's climb from 9 to 32; D matches C at depth and is worse on shallow hierarchies; B2 is worse than A. Split levels need a side-aware, cell-located transfer and a per-level pair frame; both are in the exploration drivers (scripts/sessions/fault_mg/), not yet in the library. Underworld development team with AI support from Claude Code --- .../design/FAULT_MULTIGRID_HIERARCHIES.md | 122 +++++++++++ docs/developer/index.md | 1 + scripts/sessions/fault_mg/cellP.py | 84 ++++++++ scripts/sessions/fault_mg/stage4_thick.py | 82 ++++++++ scripts/sessions/fault_mg/stage5_variants.py | 194 ++++++++++++++++++ 5 files changed, 483 insertions(+) create mode 100644 docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md create mode 100644 scripts/sessions/fault_mg/cellP.py create mode 100644 scripts/sessions/fault_mg/stage4_thick.py create mode 100644 scripts/sessions/fault_mg/stage5_variants.py diff --git a/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md b/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md new file mode 100644 index 000000000..cf51307a4 --- /dev/null +++ b/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md @@ -0,0 +1,122 @@ +# Multigrid hierarchies for split-node faults + +Technical note, 2026-10-13. Exploration, not yet in the library. Drivers: +`scripts/sessions/fault_mg/`. + +## The question + +A split-node fault lives on the finest mesh. The geometric-multigrid levels below it +come from the add-on hierarchy: the static background mesh, which is never cut, and +the add-on levels refined from it toward the fault. How the fault is represented on +those coarser levels decides whether the velocity solve stays mesh-independent as the +fault is refined. + +## Alternatives + +| | finest | add-on levels below it | background levels | +|---|---|---|---| +| **A** | split | welded, Galerkin | welded, Galerkin | +| **B2** | split | welded, TI weak band, rediscretised | Galerkin from the TI levels | +| **C** | split | split, Galerkin | welded, Galerkin | +| **D** | split | split, Galerkin | broad TI weak zone, rediscretised | + +A is the current add-on behaviour (`mesh.adapt` then `add_fault`). + +TI band: a Gaussian in the distance to the trace, half-width equal to the level's +near-fault cell size, director along the fault normal, $\eta_1 = \eta^{1-f}\eta_w^{f}$ +with $\eta_w = 0.1\,\eta$ for a frictionless fault and $\eta_w = \eta_f w$ (the +compliance of the interface) for a viscous one. + +Split levels (C, D) need two things the current hierarchy does not provide. + +**A side-aware transfer.** For each fine node, one fine cell containing it is taken, and +that cell's centroid is located in the coarse mesh. A centroid is never on the fault, so +the coarse cell is on the node's own side. The coarse cell's Lagrange interpolant is +then evaluated at the node (dual-basis identity $W = BM^{-1}$, as in +`custom_mg.nested_refine_pair_prolongation`), so a node on the slit interpolates from +its own side. The node-cloud builders cannot do this: they triangulate the coarse +nodes, and coincident slit nodes make the transfer degenerate. + +**A per-level pair frame.** Each split level carries its own pair rotation (mean and +jump), and its opening (normal-jump) unknowns are removed: +$\hat P_l = Q_{l+1} P_l Q_l^{T}$, restricted to admissible unknowns. Coarse unknowns +that no fine unknown reaches are dropped too. Near the tips of non-nested levels there +are a few, and they are null modes of the Galerkin coarse operator. + +## Test problem + +- Box $[0,1]^2$ with simple shear on the walls; incompressible Stokes, $\eta = 1$. +- A straight fault, $(0.30, 0.45)$ to $(0.70, 0.55)$, frictionless or viscous ($\eta_f = 1$). +- Background: `UnstructuredSimplexBox(cellSize=0.1, refinement=1)` (levels $h = 0.1, 0.05$). +- Add-on level $j$: `base.adapt` to near-fault $h = 0.05/2^j$, built independently from the + background for each $j$, so the levels are conforming but not nested; split by + `add_fault` where the alternative asks. +- $k$ add-on levels in total, the finest at $h = 0.05/2^k$. +- Rotated fieldsplit solve: velocity block custom FMG, pressure Schur 1/μ mass. +- The measure is velocity-block iterations per solve. + +## Results + +Frictionless fault: + +| $k$ | near-fault $h$ | A | B2 | C | D | +|---|---|---|---|---|---| +| 1 | 0.025 | 9 | 10 | 10 | 14 | +| 2 | 0.0125 | 13 | 14 | 7 | 9 | +| 3 | 0.00625 | 18 | 22 | 7 | 6 | +| 4 | 0.0031 | 23 | 30 | 6 | 6 | +| 5 | 0.0016 | 32 | 46 | 5 | 5 | + +Viscous fault: + +| $k$ | A | B2 | C | D | +|---|---|---|---|---| +| 1 | 9 | 9 | 9 | 17 | +| 2 | 12 | 19 | 7 | 9 | +| 3 | 17 | 53 | 6 | 8 | +| 4 | 22 | 200 (cap) | 6 | 7 | +| 5 | 30 | — | 5 | 6 | + +The peak slip agrees to five figures across all alternatives at each $k$, so only the +solver path changes. + +Coulomb fault ($\mu = 0.6$, $\sigma_n = 0.8$; four Newton steps in every case), +velocity iterations per Newton step: + +| $k$ | A | C | +|---|---|---| +| 1 | 5, 15, 17, 17 | 5, 15, 17, 17 | +| 3 | 9, 21, 24, 24 | 4, 14, 15, 16 | +| 5 | 15, 38, 45, 50 | 3, 13, 15, 15 | + +Supporting measurements: + +- **A single TI level.** One TI level directly below the split finest level, with + Galerkin below it, is worse than A at every band strength. At $k = 4$ and a band + half-width of half a cell, A takes 27; the band at 0.1, 0.03 and 0.01 takes 29, 46 + and 73. With a half-width of a full cell the band stalls. +- **Transfer alone.** C's levels left welded but with the side-aware transfer behave + like A (35 iterations at $k = 5$). The gain comes from splitting the levels. +- **3-D extruded layer (preliminary).** Only the through-thickness levels split, the + lateral levels welded: 11 to 16 iterations as $h$ halves, against 14 to 25 for A. + +## What works best + +**C: split every add-on level and leave the background welded.** The iteration count +falls slightly with refinement, for frictionless, viscous and Coulomb faults alike. +It needs nothing rediscretised and no TI parameters. + +D matches C at depth but is worse on shallow hierarchies, and it adds a rediscretised +operator and band parameters for no gain. B2 is worse than A, and badly so for the +viscous fault. A smeared band on a coarse level is a continuous shear that the split +fine operator rejects, so the coarse space has to contain the discontinuity itself. + +## Not yet done + +- Building the add-on levels from one `adapt` call (its NVB generations, each split) + instead of $k$ independent ones. +- The 3-D layer with the lateral levels split too. +- Fault networks, and faults near a boundary. +- Parallel. +- Moving the transfer and the per-level frames into `custom_mg` as a mesh-owned tail + built by `add_fault`. diff --git a/docs/developer/index.md b/docs/developer/index.md index 968297f7c..b03f6e696 100644 --- a/docs/developer/index.md +++ b/docs/developer/index.md @@ -189,6 +189,7 @@ design/mesh-adaptation-formulation design/LAYER2_SBR_ADAPT_ON_TOP design/NVB_GRADED_ADAPT design/MULTIGRID_MINIMAL_CONTROL_2026-07 +design/FAULT_MULTIGRID_HIERARCHIES design/solver-wall-clock-guard design/ARCHITECTURE_ANALYSIS design/MATHEMATICAL_MIXIN_DESIGN diff --git a/scripts/sessions/fault_mg/cellP.py b/scripts/sessions/fault_mg/cellP.py new file mode 100644 index 000000000..58952644d --- /dev/null +++ b/scripts/sessions/fault_mg/cellP.py @@ -0,0 +1,84 @@ +"""Cell-located, side-aware Lagrange prolongation between (possibly split, non-nested) meshes. + +For each fine node: one fine cell containing it -> that cell's CENTROID located in the coarse +mesh (a centroid is never on a fault, so the coarse cell is on the node's own side) -> the coarse +cell's interpolant evaluated at the node by the dual-basis identity W = B M^-1 (as in +custom_mg.nested_refine_pair_prolongation). Exact for nested pairs; for non-nested pairs it +reproduces the coarse polynomial of the located cell (global polynomials of the degree exactly). +""" +import itertools +import numpy as np +import scipy.sparse as sp +from scipy.spatial import cKDTree + + +def _cell_vertices(mesh): + dm = mesh.dm + dim = dm.getDimension() + cS, cE = dm.getHeightStratum(0) + vS, vE = dm.getDepthStratum(0) + vxy = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, dm.getCoordinateDim()) + cv = np.empty((cE - cS, dim + 1), dtype=np.int64) + for c in range(cS, cE): + cv[c - cS] = [q - vS for q in dm.getTransitiveClosure(c)[0] if vS <= q < vE] + return vxy, cv + + +def _barycentric(P, verts): + """barycentric coords of points P (n,d) in simplices verts (n,d+1,d).""" + A = np.transpose(verts[:, 1:] - verts[:, :1], (0, 2, 1)) + lam = np.linalg.solve(A, (P - verts[:, 0])[..., None])[..., 0] + return np.concatenate([1 - lam.sum(axis=1, keepdims=True), lam], axis=1) + + +def locate_cells(points, mesh, k=16): + vxy, cv = _cell_vertices(mesh) + cent = vxy[cv].mean(axis=1) + tree = cKDTree(cent) + _, cand = tree.query(points, k=min(k, len(cent))) + best = np.full(len(points), -1) + bestmin = np.full(len(points), -np.inf) + for j in range(cand.shape[1]): + c = cand[:, j] + b = _barycentric(points, vxy[cv[c]]) + m = b.min(axis=1) + better = m > bestmin + best[better] = c[better] + bestmin[better] = m[better] + return best, bestmin + + +def cell_located_prolongation(coarse, fine, degree=2, continuous=True): + cn = coarse._cell_node_indices(degree, continuous) + fn = fine._cell_node_indices(degree, continuous) + Xc = np.asarray(coarse._get_coords_for_basis(degree, continuous)) + Xf = np.asarray(fine._get_coords_for_basis(degree, continuous)) + dim = coarse.dm.getDimension() + vxy_c, cv_c = _cell_vertices(coarse) + vxy_f, cv_f = _cell_vertices(fine) + owner = np.full(Xf.shape[0], -1, dtype=np.int64) + for kk in range(fn.shape[0]): + owner[fn[kk]] = kk + assert (owner >= 0).all() + fcent = vxy_f[cv_f].mean(axis=1) + parent_cell, inside = locate_cells(fcent, coarse) + parent_of = parent_cell[owner] + E = np.asarray([e for e in itertools.product(range(degree + 1), repeat=dim) if sum(e) <= degree]) + vander = lambda L: np.prod(L[:, None, :] ** E[None, :, :], axis=2) + order = np.argsort(parent_of, kind="stable") + runs = np.flatnonzero(np.diff(parent_of[order])) + 1 + rows, cols, vals = [], [], [] + for grp in np.split(order, runs): + c = int(parent_of[grp[0]]) + verts = vxy_c[cv_c[c]] + Ainv = np.linalg.inv((verts[1:] - verts[0]).T) + Lc = (Xc[cn[c]] - verts[0]) @ Ainv.T + Lf = (Xf[grp] - verts[0]) @ Ainv.T + W = np.linalg.solve(vander(Lc).T, vander(Lf).T).T + for ti, t in enumerate(grp): + keep = np.flatnonzero(np.abs(W[ti]) > 1e-13) + rows.extend([int(t)] * keep.size) + cols.extend(int(cn[c][j]) for j in keep) + vals.extend(float(W[ti, j]) for j in keep) + P = sp.csr_matrix((vals, (rows, cols)), shape=(Xf.shape[0], Xc.shape[0])) + return P, inside.min() diff --git a/scripts/sessions/fault_mg/stage4_thick.py b/scripts/sessions/fault_mg/stage4_thick.py new file mode 100644 index 000000000..be70f9805 --- /dev/null +++ b/scripts/sessions/fault_mg/stage4_thick.py @@ -0,0 +1,82 @@ +"""Stage 4: case C on the 3-D extruded faulted layer. + +Today's extrude tail: lateral levels (unsplit sheet levels, 1 layer) + through-thickness levels +extruded from the UNSPLIT cut sheet. Variant C: the through-thickness levels are extruded from the +SPLIT sheet (they keep the fault), cell-located side-aware transfers, per-level pair rotation. + +python stage4_thick.py +""" +import os, sys, time +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "..", "tests")) +import numpy as np, scipy.sparse as sp, underworld3 as uw +from petsc4py import PETSc +from underworld3.utilities import custom_mg, rotated_bc +from underworld3.meshing.extrude import extrude_sheet +from cellP import cell_located_prolongation +import test_1083_thin_thick_alignment as T +ref, layers, variant = int(sys.argv[1]), int(sys.argv[2]), sys.argv[3] +sheet = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), cellSize=0.1, refinement=ref, qdegree=3) +split = sheet.add_fault([("Flt", np.array([[0.25, 0.5], [0.75, 0.5]]))]) +layer_mesh = extrude_sheet(split, T.L, layers=layers) # stock tail attached +layer = T.model(layer_mesh, uw.systems.Stokes) + +def to_scipy(M): + ai, aj, av = M.getValuesCSR() + return sp.csr_matrix((av, aj, ai), shape=M.getSize()) + +def to_petsc(S): + S = S.tocsr(); S.sort_indices() + return PETSc.Mat().createAIJ(size=S.shape, csr=(S.indptr.astype(PETSc.IntType), S.indices.astype(PETSc.IntType), S.data), + comm=PETSc.COMM_SELF) + +if variant == "split": + lateral = [extrude_sheet(lev, T.L, layers=1, with_tail=False) for lev in list(sheet._coarse_level_meshes())[:-1]] + vertical = [] + n = layers // 2 + while n >= 1: + vertical.append(extrude_sheet(split, T.L, layers=n, with_tail=False)); n //= 2 + levels = lateral + list(reversed(vertical)) + rot = [] + for m in levels: + if getattr(m, "_fault_point_pairs", None): + s = T.model(m, uw.systems.Stokes); s._build(); s.snes.setUp() + Q, Qt, nrows, _ = rotated_bc.build_rotation(s, s._rotated_freeslip_bcs) + vis = s._subdict["velocity"][0]; vidx = np.asarray(vis.getIndices()) + g2b = {int(g): i for i, g in enumerate(vidx)} + rot.append((to_scipy(Q.createSubMatrix(vis, vis)), sorted({g2b[g] for g in nrows if g in g2b}))) + else: + rot.append(None) + + def my_Pl(solver, Q, normal_rows): + vis = solver._subdict["velocity"][0]; vidx = np.asarray(vis.getIndices()) + g2b = {int(g): i for i, g in enumerate(vidx)} + Qf = to_scipy(Q.createSubMatrix(vis, vis)); nf = sorted({g2b[g] for g in normal_rows if g in g2b}) + meshes = levels + [solver.mesh] + rmaps = [custom_mg._coarse_reduced_map(solver, m, 0)[0] for m in levels] + [custom_mg._reduced_map(solver.dm, 0)[0]] + rots = rot + [(Qf, nf)] + keep = [np.setdiff1d(np.arange(len(rm)), r[1]) if r is not None else np.arange(len(rm)) for r, rm in zip(rots, rmaps)] + Pl = [] + for l in range(len(meshes) - 1): + Pn, inside = cell_located_prolongation(meshes[l], meshes[l + 1]) + P = custom_mg._reduced_from_node_transfer(Pn, rmaps[l], rmaps[l + 1], 3).tocsr() + if rots[l + 1] is not None: P = rots[l + 1][0] @ P + if rots[l] is not None: P = P @ rots[l][0].T + P = P[:, keep[l]] + if l + 1 == len(meshes) - 1: + P = P.tolil(); P[nf, :] = 0; P = P.tocsr() + else: + P = P[keep[l + 1], :] + P.eliminate_zeros() + Pl.append(to_petsc(P)) + if getattr(solver, "_custom_mg", None) is None: + solver._custom_mg = {"mode": "hierarchy", "hierarchy": None, "verbose": False} + return Pl + rotated_bc._build_rotated_custom_Pl = my_Pl + +layer.solve() +t0 = time.time(); layer.solve(zero_init_guess=True); wall = time.time() - t0 +info = layer._rotated_freeslip_info +print(f"RESULT thick ref={ref} h={0.1/2**ref:.4f} layers={layers} variant={variant} pc={info['velocity_pc']} outer={info['ksp_its']} " + f"vel_its={info['vel_its_last']} pres_its={info['pres_its_last']} conv={info['converged']} " + f"umax={np.abs(np.asarray(layer.u.array)).max():.6f} wall={wall:.1f}s", flush=True) diff --git a/scripts/sessions/fault_mg/stage5_variants.py b/scripts/sessions/fault_mg/stage5_variants.py new file mode 100644 index 000000000..983f6c2fd --- /dev/null +++ b/scripts/sessions/fault_mg/stage5_variants.py @@ -0,0 +1,194 @@ +"""Fault multigrid hierarchies, four alternatives through one driver (2-D add-on Stokes, box in +simple shear, split fault). The background mesh (static base, levels L0 h=0.1, L1 h=0.05) is never +cut; add-on level j (j = 1..k) is base.adapt(h_near = 0.05 / 2^j), split by add_fault where the +variant asks. Transfers are cell-located Lagrange prolongations (cellP.py), side-aware on split +levels; every split level carries its own pair rotation with the opening DOFs removed. + + A : finest split; add-on levels welded; everything Galerkin + B2 : finest split; add-on levels welded with a TI band, REDISCRETISED; background Galerkin + C : all add-on levels split; everything Galerkin + D : all add-on levels split (Galerkin); background levels rediscretised with a broad TI zone + stock: today's hierarchy (mesh.adapt + add_fault, existing transfers) — the A reference + +python stage5_variants.py [weak=0.1] [wfac=1.0] + TI band: Gaussian in distance to the trace, half-width wfac * (level's near-fault h); + eta_weak = weak*eta (frictionless) or eta_f*w (viscous); geometric blend; director = fault normal. +""" +import os, sys, time +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import numpy as np, scipy.sparse as sp, sympy, underworld3 as uw +from petsc4py import PETSc +from underworld3.utilities import custom_mg, rotated_bc +from cellP import cell_located_prolongation +k, law, variant = int(sys.argv[1]), sys.argv[2], sys.argv[3] +weak = float(sys.argv[4]) if len(sys.argv) > 4 else 0.1 +wfac = float(sys.argv[5]) if len(sys.argv) > 5 else 1.0 +TIP_A, TIP_B = np.array([0.30, 0.45]), np.array([0.70, 0.55]) +FAULT = [("Flt", np.vstack([TIP_A, TIP_B]))] +ETA, ETA_F = 1.0, 1.0 +base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), cellSize=0.1, refinement=1, qdegree=2) +pts3 = np.column_stack([np.linspace(TIP_A[0], TIP_B[0], 9), np.linspace(TIP_A[1], TIP_B[1], 9), np.zeros(9)]) +surf = uw.meshing.Surface("g", base, pts3); surf.discretize() +h_base = 0.05 +AB = TIP_B - TIP_A +NRM = np.array([-AB[1], AB[0]]) / np.linalg.norm(AB) + +def addon(j, split): + ch = base.adapt(surf.refinement_metric_function(h_near=h_base / 2**j, h_far=h_base, width=0.08, profile="linear"), + max_levels=j) + return ch.add_fault(FAULT) if split else ch + +def ti_params(m, h_level): + xc, yc = m.X + A_, B_ = sympy.Matrix(TIP_A.tolist()), sympy.Matrix(TIP_B.tolist()); ab = B_ - A_ + t = ((xc - A_[0]) * ab[0] + (yc - A_[1]) * ab[1]) / ab.dot(ab) + tc = sympy.Min(1, sympy.Max(0, t)) + d = sympy.sqrt((xc - A_[0] - tc * ab[0])**2 + (yc - A_[1] - tc * ab[1])**2) + w = wfac * h_level + f = sympy.exp(-(d / w)**2) + eta_w = weak * ETA if law == "frictionless" else ETA_F * w + return {"eta1": ETA**(1 - f) * eta_w**f, "director": sympy.Matrix([[NRM[0], NRM[1]]])} + +def stokes_on(m, fault=True, ti=None): + x, y = m.X + s = uw.systems.Stokes(m) + if ti is None: + s.constitutive_model = uw.constitutive_models.ViscousFlowModel + s.constitutive_model.Parameters.shear_viscosity_0 = ETA + else: + s.constitutive_model = uw.constitutive_models.TransverseIsotropicFlowModel + s.constitutive_model.Parameters.shear_viscosity_0 = ETA + s.constitutive_model.Parameters.shear_viscosity_1 = ti["eta1"] + s.constitutive_model.Parameters.director = ti["director"] + s.tolerance = 1e-6 + s.petsc_use_pressure_nullspace = True + for side in ("Top", "Bottom", "Left", "Right"): + s.add_dirichlet_bc((y - 0.5, 0.0), side) + if fault: + s.add_fault_bc(0 if law == "frictionless" else ETA_F, "Flt") + return s + +def to_scipy(M): + ai, aj, av = M.getValuesCSR() + return sp.csr_matrix((av, aj, ai), shape=M.getSize()) + +def to_petsc(S): + S = S.tocsr(); S.sort_indices() + return PETSc.Mat().createAIJ(size=S.shape, csr=(S.indptr.astype(PETSc.IntType), S.indices.astype(PETSc.IntType), S.data), + comm=PETSc.COMM_SELF) + +finest = addon(k, True) +stokes = stokes_on(finest) + +if variant != "stock": + background = list(base._coarse_level_meshes()) # L0, L1 + split_addons = variant in ("C", "D", "Cm") + addons = [addon(j, split_addons) for j in range(1, k)] + levels = background + addons + h_levels = [0.1, 0.05] + [h_base / 2**j for j in range(1, k)] + # which coarse levels get a rediscretised TI operator + if variant == "B2": + ti_levels = set(range(2, len(levels))) # every add-on level below the finest + elif variant == "D": + ti_levels = {0, 1} # the background levels + else: + ti_levels = set() + rot, redisc = [], {} + for li, m in enumerate(levels): + if getattr(m, "_fault_point_pairs", None): + s = stokes_on(m); s._build() + Q, Qt, nrows, _ = rotated_bc.build_rotation(s, s._rotated_freeslip_bcs) + vis = s._subdict["velocity"][0]; vidx = np.asarray(vis.getIndices()) + g2b = {int(g): i for i, g in enumerate(vidx)} + rot.append((to_scipy(Q.createSubMatrix(vis, vis)), sorted({g2b[g] for g in nrows if g in g2b}))) + else: + rot.append(None) + if li in ti_levels: + s = stokes_on(m, fault=False, ti=ti_params(m, h_levels[li])) + s.penalty = float(stokes.penalty.sym) if hasattr(stokes.penalty, "sym") else stokes.penalty + s._build() + X = s.dm.getGlobalVec(); X.set(0.0) + J, Jp = s.snes.getJacobian()[:2] + s.snes.computeJacobian(X, J, Jp) + vis = s._subdict["velocity"][0] + redisc[li] = J.createSubMatrix(vis, vis) + + state = {} + def my_Pl(solver, Q, normal_rows): + vis = solver._subdict["velocity"][0]; vidx = np.asarray(vis.getIndices()) + g2b = {int(g): i for i, g in enumerate(vidx)} + Qf = to_scipy(Q.createSubMatrix(vis, vis)); nf = sorted({g2b[g] for g in normal_rows if g in g2b}) + meshes = levels + [solver.mesh] + rmaps = [custom_mg._coarse_reduced_map(solver, m, 0)[0] for m in levels] + [custom_mg._reduced_map(solver.dm, 0)[0]] + rots = rot + [(Qf, nf)] + keep = [np.setdiff1d(np.arange(len(rm)), r[1]) if r is not None else np.arange(len(rm)) for r, rm in zip(rots, rmaps)] + # Built from the finest level down, so each level's admissible set is final + # before the level below is prolongated into it. A coarse unknown that no fine + # unknown reaches (a pair DOF near a tip of non-nested split levels) is dropped: + # it is a null mode of the Galerkin coarse operator (a zero diagonal under SOR). + nlev_ = len(meshes) + Pl = [None] * (nlev_ - 1) + for l in range(nlev_ - 2, -1, -1): + Pn, inside = cell_located_prolongation(meshes[l], meshes[l + 1]) + P = custom_mg._reduced_from_node_transfer(Pn, rmaps[l], rmaps[l + 1], 2).tocsr() + if rots[l + 1] is not None: P = rots[l + 1][0] @ P + if rots[l] is not None: P = P @ rots[l][0].T + if l + 1 == nlev_ - 1: + P = P.tolil(); P[nf, :] = 0; P = P.tocsr() + else: + P = P[keep[l + 1], :] + P = P[:, keep[l]] + P.eliminate_zeros() + reached = np.asarray(abs(P).sum(axis=0)).ravel() > 0 + dropped = int((~reached).sum()) + if dropped: + print(f"DEBUGP level {l}: dropped {dropped} unreached coarse DOFs", flush=True) + keep[l] = keep[l][reached] + P = P[:, np.flatnonzero(reached)] + Pl[l] = to_petsc(P) + if getattr(solver, "_custom_mg", None) is None: + solver._custom_mg = {"mode": "hierarchy", "hierarchy": None, "verbose": False} + state["keep"] = keep + return Pl + rotated_bc._build_rotated_custom_Pl = my_Pl + + if redisc or variant == "Cm": + orig = custom_mg._configure_pcmg + def with_redisc(pc, Pl, *a, **kw): + out = orig(pc, Pl, *a, **kw) + nlev = len(Pl) + 1 + ops = [None] * nlev + ops[nlev - 1] = pc.getOperators()[0] + for l in range(nlev - 2, -1, -1): + if l in redisc: # rediscretised level, on its admissible set + kl = state["keep"][l] + if len(kl) == redisc[l].getSize()[0]: + ops[l] = redisc[l] + else: + isk = PETSc.IS().createGeneral(np.asarray(kl, dtype=PETSc.IntType), comm=PETSc.COMM_SELF) + ops[l] = redisc[l].createSubMatrix(isk, isk) + else: + ops[l] = ops[l + 1].PtAP(Pl[l]) + for l in range(nlev): + d = ops[l].getDiagonal().getArray() + print(f"DEBUG level {l} size {ops[l].getSize()} type {ops[l].getType()} norm {ops[l].norm():.3e} " + f"diag min {np.abs(d).min():.3e} nan {int(np.isnan(d).sum())} P {Pl[l].getSize() if l < nlev - 1 else '-'}", flush=True) + PETSc.Options()[(pc.getOptionsPrefix() or "") + "pc_mg_galerkin"] = "none" + pc.setFromOptions() + for l in range(nlev - 1): + kk = pc.getMGCoarseSolve() if l == 0 else pc.getMGSmoother(l) + kk.setOperators(ops[l], ops[l]) + return out + custom_mg._configure_pcmg = with_redisc + +opts = PETSc.Options() +for i in range(1, 6): + opts[f"rotfs{i}_fieldsplit_vel_ksp_converged_reason"] = None +stokes.solve() +t0 = time.time(); stokes.solve(zero_init_guess=True); wall = time.time() - t0 +info = stokes._rotated_freeslip_info +from underworld3.utilities.fault_contact import fault_slip +s_, V, leak = fault_slip(stokes, "Flt", info) +print(f"RESULT V k={k} law={law} variant={variant} weak={weak} wfac={wfac} h_near={h_base / 2**k:.5f} " + f"pc={info['velocity_pc']} vel_its={info['vel_its_last']} pres_its={info['pres_its_last']} conv={info['converged']} " + f"peak_slip={np.abs(V).max():.5f} wall={wall:.1f}s", flush=True) From 497baff458437878b4175adf545b72b2b9b88865 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Tue, 13 Oct 2026 10:51:27 +1100 Subject: [PATCH 31/57] Fault multigrid note: the last viscous B2 case (k = 5) sits at the iteration cap Underworld development team with AI support from Claude Code --- docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md b/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md index cf51307a4..58ed49178 100644 --- a/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md +++ b/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md @@ -75,7 +75,7 @@ Viscous fault: | 2 | 12 | 19 | 7 | 9 | | 3 | 17 | 53 | 6 | 8 | | 4 | 22 | 200 (cap) | 6 | 7 | -| 5 | 30 | — | 5 | 6 | +| 5 | 30 | 200 (cap; stopped at 1 h) | 5 | 6 | The peak slip agrees to five figures across all alternatives at each $k$, so only the solver path changes. From 80f308ae24cf7d3e0cbc3fce7d215679e8f9cf04 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Tue, 13 Oct 2026 12:06:06 +1100 Subject: [PATCH 32/57] Fault multigrid note: a moving fault, and the cost of each stage A fault translated across the box in 8 steps, the add-on part re-adapted from the unchanged background at every step. Per step at five add-on levels: A 143 s, C 46 s, D 79 s, GAMG 134 s; meshing and fault surgery are a few seconds of that, the solve the rest. Peak slip identical across the strategies at every position. The moving fault found a C failure at one position: coarse unknowns near a tip reached only through tiny extrapolated weights on non-nested levels gave a near-zero Galerkin diagonal. Weakly reached coarse unknowns are now dropped with the unreached ones (a stand-in for nesting). Underworld development team with AI support from Claude Code --- .../design/FAULT_MULTIGRID_HIERARCHIES.md | 39 +++- scripts/sessions/fault_mg/stage5_variants.py | 3 +- scripts/sessions/fault_mg/stage6_moving.py | 205 ++++++++++++++++++ scripts/sessions/fault_mg/summarise_moving.py | 30 +++ 4 files changed, 272 insertions(+), 5 deletions(-) create mode 100644 scripts/sessions/fault_mg/stage6_moving.py create mode 100644 scripts/sessions/fault_mg/summarise_moving.py diff --git a/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md b/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md index 58ed49178..12eb88e01 100644 --- a/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md +++ b/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md @@ -40,8 +40,11 @@ nodes, and coincident slit nodes make the transfer degenerate. **A per-level pair frame.** Each split level carries its own pair rotation (mean and jump), and its opening (normal-jump) unknowns are removed: $\hat P_l = Q_{l+1} P_l Q_l^{T}$, restricted to admissible unknowns. Coarse unknowns -that no fine unknown reaches are dropped too. Near the tips of non-nested levels there -are a few, and they are null modes of the Galerkin coarse operator. +that no fine unknown reaches, or reaches only weakly (column norm below 5% of the +median), are dropped too. Near the tips of non-nested levels there are a few, reached +only through tiny or extrapolated weights; left in, they are null or near-null modes of +the Galerkin coarse operator. The threshold stands in for nesting: with the split levels +built from one `adapt` call's generations it should not be needed. ## Test problem @@ -100,14 +103,42 @@ Supporting measurements: - **3-D extruded layer (preliminary).** Only the through-thickness levels split, the lateral levels welded: 11 to 16 iterations as $h$ halves, against 14 to 25 for A. +## A moving fault + +The same box and fault, translated in 8 steps from $x = 0.3$ to $x = 0.7$. The +background is built once; each step re-adapts the add-on part from it with one +`mesh.adapt` call, splits the finest level and, for C and D, splits each intermediate +level of that same call. The table gives the mean seconds per step. The runs shared a +busy machine, so absolute times are good to a factor of 1.5 to 2; the ratios between +strategies and the iteration counts are not affected. + +$k = 5$ (seven levels, near-fault $h = 0.0016$): + +| | adapt | split finest | split levels | level frames | background TI | solve | total | velocity its | +|---|---|---|---|---|---|---|---|---| +| A | 3.1 | 1.9 | – | – | – | 137 | 143 | 30–34 | +| C | 2.7 | 1.7 | 2.8 | 1.0 | – | 38 | 46 | 5–6 | +| D | 4.7 | 2.9 | 5.1 | 1.8 | 2.4 | 61 | 79 | 4–5 | +| GAMG | 3.8 | 2.4 | – | – | – | 127 | 134 | 200 (cap) | + +At $k = 3$ the totals are 27 (A), 15 (C), 17 (D) and 28 (GAMG) seconds per step. The +solve includes building the transfers: 2 s for A, 3.4 s for C. + +The peak slip is identical across A, C and D at every position. Level frames come from +throwaway solvers in the drivers (`build_rotation`); they could be computed from the +pair geometry directly. + ## What works best **C: split every add-on level and leave the background welded.** The iteration count falls slightly with refinement, for frictionless, viscous and Coulomb faults alike. -It needs nothing rediscretised and no TI parameters. +It needs nothing rediscretised and no TI parameters. With a moving fault its extra +surgery and transfers cost about 5 s per step against a solve that is three times +cheaper than A's at $k = 5$. D matches C at depth but is worse on shallow hierarchies, and it adds a rediscretised -operator and band parameters for no gain. B2 is worse than A, and badly so for the +background operator to re-assemble at every step and band parameters to choose for +every problem, for no gain. B2 is worse than A, and badly so for the viscous fault. A smeared band on a coarse level is a continuous shear that the split fine operator rejects, so the coarse space has to contain the discontinuity itself. diff --git a/scripts/sessions/fault_mg/stage5_variants.py b/scripts/sessions/fault_mg/stage5_variants.py index 983f6c2fd..9f0849ca1 100644 --- a/scripts/sessions/fault_mg/stage5_variants.py +++ b/scripts/sessions/fault_mg/stage5_variants.py @@ -139,7 +139,8 @@ def my_Pl(solver, Q, normal_rows): P = P[keep[l + 1], :] P = P[:, keep[l]] P.eliminate_zeros() - reached = np.asarray(abs(P).sum(axis=0)).ravel() > 0 + cn = np.sqrt(np.asarray(P.multiply(P).sum(axis=0)).ravel()) + reached = cn > 0.05 * np.median(cn[cn > 0]) dropped = int((~reached).sum()) if dropped: print(f"DEBUGP level {l}: dropped {dropped} unreached coarse DOFs", flush=True) diff --git a/scripts/sessions/fault_mg/stage6_moving.py b/scripts/sessions/fault_mg/stage6_moving.py new file mode 100644 index 000000000..f517e259f --- /dev/null +++ b/scripts/sessions/fault_mg/stage6_moving.py @@ -0,0 +1,205 @@ +"""Moving fault on a static background: per-step cost of add-on meshing, fault surgery, +hierarchy building and solving, for strategies A / C / D / gamg. + +Box in simple shear, frictionless straight fault of fixed length and orientation whose centre +migrates x = 0.3 -> 0.7 in `steps` steps. The background (UnstructuredSimplexBox refinement=1) is +built ONCE and never changes; every step re-adapts the add-on part from it (one mesh.adapt call), +splits the finest level, and (C, D) splits each intermediate NVB level of that same adapt call. + +python stage6_moving.py [steps=8] +""" +import os, sys, time, json +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import numpy as np, scipy.sparse as sp, sympy, underworld3 as uw +from petsc4py import PETSc +from underworld3.utilities import custom_mg, rotated_bc +from cellP import cell_located_prolongation +strategy, k = sys.argv[1], int(sys.argv[2]) +steps = int(sys.argv[3]) if len(sys.argv) > 3 else 8 +HALF = np.array([0.20, 0.05]) # half-vector of the fault (length 0.41) +ETA, WEAK = 1.0, 0.1 +h_base = 0.05 +T = {} +def tic(name): + T.setdefault(name, 0.0); T["_t0"] = time.perf_counter(); T["_n"] = name +def toc(): + T[T["_n"]] += time.perf_counter() - T["_t0"] + +t_bg = time.perf_counter() +base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), cellSize=0.1, refinement=1, qdegree=2) +background = list(base._coarse_level_meshes()) +t_bg = time.perf_counter() - t_bg + +def to_scipy(M): + ai, aj, av = M.getValuesCSR(); return sp.csr_matrix((av, aj, ai), shape=M.getSize()) +def to_petsc(S): + S = S.tocsr(); S.sort_indices() + return PETSc.Mat().createAIJ(size=S.shape, csr=(S.indptr.astype(PETSc.IntType), S.indices.astype(PETSc.IntType), S.data), + comm=PETSc.COMM_SELF) + +def stokes_on(m, fault=True, ti=None): + x, y = m.X + s = uw.systems.Stokes(m) + if ti is None: + s.constitutive_model = uw.constitutive_models.ViscousFlowModel + s.constitutive_model.Parameters.shear_viscosity_0 = ETA + else: + s.constitutive_model = uw.constitutive_models.TransverseIsotropicFlowModel + s.constitutive_model.Parameters.shear_viscosity_0 = ETA + s.constitutive_model.Parameters.shear_viscosity_1 = ti["eta1"] + s.constitutive_model.Parameters.director = ti["director"] + s.tolerance = 1e-6 + s.petsc_use_pressure_nullspace = True + for side in ("Top", "Bottom", "Left", "Right"): + s.add_dirichlet_bc((y - 0.5, 0.0), side) + if fault: + s.add_fault_bc(0, "Flt") + return s + +def ti_params(m, A, B, h_level): + xc, yc = m.X + A_, B_ = sympy.Matrix(A.tolist()), sympy.Matrix(B.tolist()); ab = B_ - A_ + t = ((xc - A_[0]) * ab[0] + (yc - A_[1]) * ab[1]) / ab.dot(ab) + tc = sympy.Min(1, sympy.Max(0, t)) + d = sympy.sqrt((xc - A_[0] - tc * ab[0])**2 + (yc - A_[1] - tc * ab[1])**2) + f = sympy.exp(-(d / h_level)**2) + nrm = np.array([-(B - A)[1], (B - A)[0]]); nrm /= np.linalg.norm(nrm) + return {"eta1": ETA**(1 - f) * (WEAK * ETA)**f, "director": sympy.Matrix([[nrm[0], nrm[1]]])} + +state = {} +NT = {} +def timed(name, fn): + def wrapper(*a, **kw): + t0 = time.perf_counter() + try: + return fn(*a, **kw) + finally: + NT[name] = NT.get(name, 0.0) + time.perf_counter() - t0 + return wrapper +custom_mg.build_transfers = timed("transfers", custom_mg.build_transfers) +rotated_bc.build_rotation = timed("rotation", rotated_bc.build_rotation) +def my_Pl(solver, Q, normal_rows): + levels, rot = state["levels"], state["rot"] + vis = solver._subdict["velocity"][0]; vidx = np.asarray(vis.getIndices()) + g2b = {int(g): i for i, g in enumerate(vidx)} + Qf = to_scipy(Q.createSubMatrix(vis, vis)); nf = sorted({g2b[g] for g in normal_rows if g in g2b}) + meshes = levels + [solver.mesh] + rmaps = [custom_mg._coarse_reduced_map(solver, m, 0)[0] for m in levels] + [custom_mg._reduced_map(solver.dm, 0)[0]] + rots = rot + [(Qf, nf)] + keep = [np.setdiff1d(np.arange(len(rm)), r[1]) if r is not None else np.arange(len(rm)) for r, rm in zip(rots, rmaps)] + nlev = len(meshes); Pl = [None] * (nlev - 1) + for l in range(nlev - 2, -1, -1): + Pn, _ = cell_located_prolongation(meshes[l], meshes[l + 1]) + P = custom_mg._reduced_from_node_transfer(Pn, rmaps[l], rmaps[l + 1], 2).tocsr() + if rots[l + 1] is not None: P = rots[l + 1][0] @ P + if rots[l] is not None: P = P @ rots[l][0].T + if l + 1 == nlev - 1: + P = P.tolil(); P[nf, :] = 0; P = P.tocsr() + else: + P = P[keep[l + 1], :] + P = P[:, keep[l]]; P.eliminate_zeros() + # a coarse unknown reached only weakly (tiny or extrapolated weights near a tip of + # non-nested split levels) gives a near-zero Galerkin diagonal: drop it with the + # unreached ones (column 2-norm below 5% of the median column) + cn = np.sqrt(np.asarray(P.multiply(P).sum(axis=0)).ravel()) + reached = cn > 0.05 * np.median(cn[cn > 0]) + if not reached.all(): + keep[l] = keep[l][reached]; P = P[:, np.flatnonzero(reached)] + Pl[l] = to_petsc(P) + state["keep"] = keep + if getattr(solver, "_custom_mg", None) is None: + solver._custom_mg = {"mode": "hierarchy", "hierarchy": None, "verbose": False} + return Pl + +orig_configure = custom_mg._configure_pcmg +def with_redisc(pc, Pl, *a, **kw): + out = orig_configure(pc, Pl, *a, **kw) + redisc = state.get("redisc") or {} + if not redisc: + return out + nlev = len(Pl) + 1 + ops = [None] * nlev + ops[nlev - 1] = pc.getOperators()[0] + for l in range(nlev - 2, -1, -1): + if l in redisc: + kl = state["keep"][l] + ops[l] = redisc[l] if len(kl) == redisc[l].getSize()[0] else redisc[l].createSubMatrix( + *(2 * [PETSc.IS().createGeneral(np.asarray(kl, dtype=PETSc.IntType), comm=PETSc.COMM_SELF)])) + else: + ops[l] = ops[l + 1].PtAP(Pl[l]) + PETSc.Options()[(pc.getOptionsPrefix() or "") + "pc_mg_galerkin"] = "none" + pc.setFromOptions() + for l in range(nlev - 1): + (pc.getMGCoarseSolve() if l == 0 else pc.getMGSmoother(l)).setOperators(ops[l], ops[l]) + return out +if strategy in ("C", "D"): + rotated_bc._build_rotated_custom_Pl = timed("transfers", my_Pl) + custom_mg._configure_pcmg = timed("mg_setup", with_redisc) + +records = [] +centres = np.linspace(0.3, 0.7, steps) +for step, cx in enumerate(centres): + for key in [k_ for k_ in T if not k_.startswith("_")]: + T[key] = 0.0 + A_ = np.array([cx, 0.5]) - HALF; B_ = np.array([cx, 0.5]) + HALF + trace = np.vstack([A_, B_]) + tic("addon_adapt") + pts3 = np.column_stack([np.linspace(A_[0], B_[0], 9), np.linspace(A_[1], B_[1], 9), np.zeros(9)]) + surf = uw.meshing.Surface(f"g{step}", base, pts3); surf.discretize() + child = base.adapt(surf.refinement_metric_function(h_near=h_base / 2**k, h_far=h_base, width=0.08, profile="linear"), + max_levels=k) + toc() + tic("split_finest") + finest = child.add_fault([("Flt", trace)]) + toc() + tail = list(child._custom_mg_coarse_meshes) + inter = tail[len(background):] + if strategy in ("C", "D"): + tic("split_levels") + split_inter = [lev.add_fault([("Flt", trace)]) for lev in inter] + toc() + state["levels"] = background + split_inter + tic("level_frames") + rot = [None] * len(background) + for m in split_inter: + s = stokes_on(m); s._build(); s.snes.setUp() + Q, Qt, nrows, _ = rotated_bc.build_rotation(s, s._rotated_freeslip_bcs) + vis = s._subdict["velocity"][0]; vidx = np.asarray(vis.getIndices()) + g2b = {int(g): i for i, g in enumerate(vidx)} + rot.append((to_scipy(Q.createSubMatrix(vis, vis)), sorted({g2b[g] for g in nrows if g in g2b}))) + state["rot"] = rot + toc() + state["redisc"] = {} + if strategy == "D": + tic("background_TI") + for li, (m, hl) in enumerate(zip(background, (0.1, 0.05))): + s = stokes_on(m, fault=False, ti=ti_params(m, A_, B_, hl)); s._build(); s.snes.setUp() + X = s.dm.getGlobalVec(); X.set(0.0) + J, Jp = s.snes.getJacobian()[:2]; s.snes.computeJacobian(X, J, Jp) + state["redisc"][li] = J.createSubMatrix(*(2 * [s._subdict["velocity"][0]])) + toc() + tic("solver_build") + stokes = stokes_on(finest) + if strategy == "gamg": + stokes.preconditioner = "gamg" + toc() + tic("jit_build") + stokes._build() + toc() + NT.clear() + tic("first_solve") # assembly, hierarchy transfers, MG setup, Krylov + stokes.solve() + toc() + info = stokes._rotated_freeslip_info + from underworld3.utilities.fault_contact import fault_slip + s_, V, leak = fault_slip(stokes, "Flt", info) + rec = {"step": step, "cx": float(cx), "cells": int(finest.dm.getHeightStratum(0)[1]), + "levels": len(tail) + 1, "pc": info["velocity_pc"], "vel_its": info["vel_its_last"], + "pres_its": info["pres_its_last"], "conv": bool(info["converged"]), "peak_slip": float(np.abs(V).max()), + "times": {kk: round(v, 3) for kk, v in T.items() if not kk.startswith("_")}, + "inside_solve": {kk: round(v, 3) for kk, v in NT.items()}} + records.append(rec) + print("STEP", json.dumps(rec), flush=True) +out = os.path.expanduser(f"~/+Simulations/fault_mg_exploration/moving_{strategy}_k{k}.json") +json.dump({"strategy": strategy, "k": k, "background_build": t_bg, "records": records}, open(out, "w"), indent=1) +print("DONE", out, flush=True) diff --git a/scripts/sessions/fault_mg/summarise_moving.py b/scripts/sessions/fault_mg/summarise_moving.py new file mode 100644 index 000000000..db80a53e6 --- /dev/null +++ b/scripts/sessions/fault_mg/summarise_moving.py @@ -0,0 +1,30 @@ +import json, glob, os +import numpy as np +D = os.path.expanduser("~/+Simulations/fault_mg_exploration") # where stage6_moving.py writes +runs = {} +for f in sorted(glob.glob(f"{D}/moving_*_k*.json")): + d = json.load(open(f)); runs[(d["strategy"], d["k"])] = d +phases = ["addon_adapt", "split_finest", "split_levels", "level_frames", "background_TI", "solver_build", "jit_build", "first_solve"] +for k in sorted({k for _, k in runs}): + print(f"\n=== k = {k} (mean seconds per step over {len(next(iter(runs.values()))['records'])} steps) ===") + hdr = f"{'':6s}" + "".join(f"{p[:12]:>13s}" for p in phases) + f"{'transfers':>11s}{'TOTAL':>9s}{'vel its':>11s}{'levels':>8s}" + print(hdr) + for s in ("A", "C", "D", "gamg"): + if (s, k) not in runs: continue + R = runs[(s, k)]["records"] + row = f"{s:6s}" + tot = 0.0 + for p in phases: + v = np.mean([r["times"].get(p, 0.0) for r in R]); tot += v + row += f"{v:13.2f}" + tr = np.mean([r.get("inside_solve", {}).get("transfers", 0.0) for r in R]) + its = [r["vel_its"][-1] for r in R] + lev = [r["levels"] for r in R] + conv = all(r["conv"] for r in R) + row += f"{tr:11.2f}{tot:9.2f}{min(its):>6d}-{max(its):<4d}{min(lev):>4d}-{max(lev)}" + ("" if conv else " NOT CONVERGED") + print(row) + # correctness: peak slip per step, all strategies + print("peak slip per step:") + for s in ("A", "C", "D", "gamg"): + if (s, k) in runs: + print(f" {s:5s}", " ".join(f"{r['peak_slip']:.5f}" for r in runs[(s, k)]["records"])) From c3d556a31c9dd9c3e1cbaae4eaa87bc5c8bbd4e8 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Tue, 13 Oct 2026 13:09:54 +1100 Subject: [PATCH 33/57] Rotated fault solve in parallel: zero the opening rows by global row _build_rotated_custom_Pl forms Q_v P for the finest multigrid transfer and zeroes the rows carrying the constrained (opening) component of a split fault. The row positions came from vel_is.getIndices(), which holds this rank's entries only, and were passed to zeroRows as global rows. In serial the two coincide; at np=2 the wrong rows were zeroed, 232 coarse unknowns lost every fine image, and PETSc's Galerkin product failed on empty rows (MatDiagonalSet: new nonzero caused a malloc). The positions are now offset by the rank's first owned row. test_1086 (tests/parallel): every opening row of the rotated fine transfer is zero and no coarse column is empty; failed at np=2 before (78 opening rows left non-zero). Two further parallel defects on this path remain open, recorded in the fault-multigrid note: adapt's multigrid tail differs between serial and parallel, and the cross-partition transfer to a split finest level (which add_fault does not co-partition with its tail) leaves hundreds of orphan coarse unknowns whose injection repair duplicates columns and makes the Galerkin coarse operators singular. Underworld development team with AI support from Claude Code --- src/underworld3/utilities/rotated_bc.py | 10 ++- .../test_1086_rotated_fault_transfer_rows.py | 68 +++++++++++++++++++ 2 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 tests/parallel/test_1086_rotated_fault_transfer_rows.py diff --git a/src/underworld3/utilities/rotated_bc.py b/src/underworld3/utilities/rotated_bc.py index d15605a67..40f5f8bb9 100644 --- a/src/underworld3/utilities/rotated_bc.py +++ b/src/underworld3/utilities/rotated_bc.py @@ -1702,10 +1702,16 @@ def _build_rotated_custom_Pl(solver, Q, normal_rows): return None vel_is = solver._subdict["velocity"][0] vis = np.asarray(vel_is.getIndices()) - g2blk = {int(g): k for k, g in enumerate(vis)} Qv = Q.createSubMatrix(vel_is, vel_is) - nrows_blk = sorted({g2blk[g] for g in normal_rows if g in g2blk}) Pfine = Qv.matMult(Ps[-1]) + # zeroRows takes GLOBAL rows of the velocity block. getIndices() holds this + # rank's entries only, so a position in it is a LOCAL row: offset it by the + # rank's first owned row. (Without the offset the wrong rows were zeroed at + # np > 1 — 232 coarse unknowns lost every fine image and the Galerkin product + # failed on empty rows; serial, where local and global coincide, was fine.) + rstart = Pfine.getOwnershipRange()[0] + g2blk = {int(g): rstart + k for k, g in enumerate(vis)} + nrows_blk = sorted({g2blk[g] for g in normal_rows if g in g2blk}) Pfine.zeroRows(nrows_blk, diag=0.0) # Remember an opportunistic mesh-owned pickup, so a later solve on this solver # (rotated or not) reuses the hierarchy instead of re-resolving it. diff --git a/tests/parallel/test_1086_rotated_fault_transfer_rows.py b/tests/parallel/test_1086_rotated_fault_transfer_rows.py new file mode 100644 index 000000000..1e0074669 --- /dev/null +++ b/tests/parallel/test_1086_rotated_fault_transfer_rows.py @@ -0,0 +1,68 @@ +"""The rotated solve's fine transfer zeroes the opening rows of a split fault by +GLOBAL row of the velocity block. + +``_build_rotated_custom_Pl`` forms Q_v P and zeroes the rows that carry the +constrained (opening) component. It took the row positions from +``vel_is.getIndices()`` — this rank's entries only — and passed them to +``zeroRows`` as global rows. Serial, where the two coincide, was right; at np=2 +the wrong rows were zeroed, 232 coarse unknowns lost every fine image, and the +Galerkin product failed on empty rows (found 2026-10-13). Contract, at any rank +count: every opening row is zero and no coarse column is empty. + + mpirun -n 2 python -m pytest --with-mpi tests/parallel/test_1086_rotated_fault_transfer_rows.py +""" + +import numpy as np +import pytest + +import underworld3 as uw + +pytestmark = [pytest.mark.level_2, pytest.mark.tier_b, pytest.mark.timeout(900)] + + +def test_rotated_fine_transfer_zeroes_opening_rows_by_global_row(): + from underworld3.utilities import rotated_bc + + tip_a, tip_b = np.array([0.30, 0.45]), np.array([0.70, 0.55]) + base = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.1, refinement=1, qdegree=2) + pts = np.column_stack([np.linspace(tip_a[0], tip_b[0], 9), + np.linspace(tip_a[1], tip_b[1], 9), np.zeros(9)]) + guide = uw.meshing.Surface("guide", base, pts) + guide.discretize() + child = base.adapt(guide.refinement_metric_function( + h_near=0.0125, h_far=0.05, width=0.08, profile="linear"), max_levels=2) + mesh = child.add_fault([("Flt", np.vstack([tip_a, tip_b]))]) + + x, y = mesh.X + stokes = uw.systems.Stokes(mesh) + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = 1 + for side in ("Top", "Bottom", "Left", "Right"): + stokes.add_dirichlet_bc((y - 0.5, 0.0), side) + stokes.add_fault_bc(0, "Flt") + stokes._build() + Q, Qt, normal_rows, _ = rotated_bc.build_rotation(stokes, stokes._rotated_freeslip_bcs) + Pl = rotated_bc._build_rotated_custom_Pl(stokes, Q, normal_rows) + assert Pl is not None, "no multigrid hierarchy on the add-on mesh" + P = Pl[-1] + + # the opening rows, as global rows of the velocity block + vel_is = stokes._subdict["velocity"][0] + rstart = P.getOwnershipRange()[0] + position = {int(g): rstart + k for k, g in enumerate(vel_is.getIndices())} + opening = [position[g] for g in normal_rows if g in position] + ai, aj, av = P.getValuesCSR() + nonzero_in_opening = sum( + int(np.any(av[ai[r - rstart]:ai[r - rstart + 1]] != 0.0)) for r in opening) + assert uw.mpi.comm.allreduce(nonzero_in_opening) == 0 + + ones = P.createVecLeft() + ones.set(1.0) + column_mass = P.createVecRight() + absP = P.copy() + absP.setValuesCSR(ai, aj, np.abs(av)) + absP.assemble() + absP.multTranspose(ones, column_mass) + empty = int(np.sum(column_mass.getArray() == 0.0)) + assert uw.mpi.comm.allreduce(empty) == 0, "coarse unknowns with no fine image" From 76b78f6ff90408f32ff17c40d0013945ab679ce5 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Tue, 13 Oct 2026 13:10:16 +1100 Subject: [PATCH 34/57] Fault multigrid note: the clamped side-aware transfer, and the state of the parallel path Underworld development team with AI support from Claude Code --- .../design/FAULT_MULTIGRID_HIERARCHIES.md | 40 +++++++++++++++---- scripts/sessions/fault_mg/cellP.py | 10 +++++ scripts/sessions/fault_mg/stage6_moving.py | 4 +- 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md b/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md index 12eb88e01..6da3636ba 100644 --- a/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md +++ b/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md @@ -35,16 +35,19 @@ the coarse cell is on the node's own side. The coarse cell's Lagrange interpolan then evaluated at the node (dual-basis identity $W = BM^{-1}$, as in `custom_mg.nested_refine_pair_prolongation`), so a node on the slit interpolates from its own side. The node-cloud builders cannot do this: they triangulate the coarse -nodes, and coincident slit nodes make the transfer degenerate. +nodes, and coincident slit nodes make the transfer degenerate. The levels are not +nested near the trace, because each cut pulls vertices onto it, so a fine node can lie +slightly outside its located coarse cell. The evaluation point is therefore clamped +into the cell (its barycentric coordinates clipped and renormalised). Without the +clamp, extrapolated weights reached 5.4 near a tip, two coarse unknowns were reached +only through them, and the Galerkin operator had a near-zero diagonal that made SOR +return NaN. With it, every weight is at most 1. **A per-level pair frame.** Each split level carries its own pair rotation (mean and jump), and its opening (normal-jump) unknowns are removed: $\hat P_l = Q_{l+1} P_l Q_l^{T}$, restricted to admissible unknowns. Coarse unknowns -that no fine unknown reaches, or reaches only weakly (column norm below 5% of the -median), are dropped too. Near the tips of non-nested levels there are a few, reached -only through tiny or extrapolated weights; left in, they are null or near-null modes of -the Galerkin coarse operator. The threshold stands in for nesting: with the split levels -built from one `adapt` call's generations it should not be needed. +that no fine unknown reaches at all (a few near the tips) are dropped too; they are null +modes of the Galerkin coarse operator. ## Test problem @@ -122,12 +125,33 @@ $k = 5$ (seven levels, near-fault $h = 0.0016$): | GAMG | 3.8 | 2.4 | – | – | – | 127 | 134 | 200 (cap) | At $k = 3$ the totals are 27 (A), 15 (C), 17 (D) and 28 (GAMG) seconds per step. The -solve includes building the transfers: 2 s for A, 3.4 s for C. +solve includes building the transfers: 2 s for A, 3.4 s for C. With the clamped +transfer, C takes 5 to 6 velocity iterations at every position at $k = 3$ and 4 to 8 at +$k = 5$. The peak slip is identical across A, C and D at every position. Level frames come from throwaway solvers in the drivers (`build_rotation`); they could be computed from the pair geometry directly. +## Parallel + +A, the existing path, had three defects at np > 1: + +1. **Fixed** (`test_1086`). The rotated solve zeroed the opening rows of its fine + transfer by local position instead of global row. +2. **Open.** `mesh.adapt` keeps a different intermediate level for the multigrid tail in + parallel than in serial (1358 against 1520 cells; the finest mesh is the same). +3. **Open.** `add_fault` gathers the fault's cell star onto one rank, so the split finest + level is not co-partitioned with its tail. The cross-partition transfer to it then + left 618 coarse unknowns with no fine image. Their nearest-node injection duplicated + 512 columns, the Galerkin coarse operators became singular, and PETSc's inode SOR + returned NaN. With inodes off it converges (34 iterations at np = 2 against 18 + serial), but the coarse operators are still singular. + +For C, the plan is to co-locate each add-on level with the finest *before* splitting it, +so that every split level holds its fault star on the same rank, and to build the +side-aware transfer rank-locally on those co-located levels. + ## What works best **C: split every add-on level and leave the background welded.** The iteration count @@ -148,6 +172,6 @@ fine operator rejects, so the coarse space has to contain the discontinuity itse instead of $k$ independent ones. - The 3-D layer with the lateral levels split too. - Fault networks, and faults near a boundary. -- Parallel. +- Parallel: see above. - Moving the transfer and the per-level frames into `custom_mg` as a mesh-owned tail built by `add_fault`. diff --git a/scripts/sessions/fault_mg/cellP.py b/scripts/sessions/fault_mg/cellP.py index 58952644d..5bd5f4440 100644 --- a/scripts/sessions/fault_mg/cellP.py +++ b/scripts/sessions/fault_mg/cellP.py @@ -10,6 +10,8 @@ import numpy as np import scipy.sparse as sp from scipy.spatial import cKDTree +import os +CLAMP = os.environ.get("CELLP_CLAMP", "1") == "1" def _cell_vertices(mesh): @@ -74,6 +76,14 @@ def cell_located_prolongation(coarse, fine, degree=2, continuous=True): Ainv = np.linalg.inv((verts[1:] - verts[0]).T) Lc = (Xc[cn[c]] - verts[0]) @ Ainv.T Lf = (Xf[grp] - verts[0]) @ Ainv.T + if CLAMP: + # evaluate at the nearest point of the located cell: a fine node a little + # outside it (non-nested levels near a pulled tip) is not extrapolated, so + # the weights stay bounded (the Lagrange basis inside the simplex) + b = np.column_stack([1 - Lf.sum(axis=1), Lf]) + if (b < 0).any(): + b = np.clip(b, 0, None); b /= b.sum(axis=1, keepdims=True) + Lf = b[:, 1:] W = np.linalg.solve(vander(Lc).T, vander(Lf).T).T for ti, t in enumerate(grp): keep = np.flatnonzero(np.abs(W[ti]) > 1e-13) diff --git a/scripts/sessions/fault_mg/stage6_moving.py b/scripts/sessions/fault_mg/stage6_moving.py index f517e259f..cc74d1492 100644 --- a/scripts/sessions/fault_mg/stage6_moving.py +++ b/scripts/sessions/fault_mg/stage6_moving.py @@ -102,7 +102,7 @@ def my_Pl(solver, Q, normal_rows): # non-nested split levels) gives a near-zero Galerkin diagonal: drop it with the # unreached ones (column 2-norm below 5% of the median column) cn = np.sqrt(np.asarray(P.multiply(P).sum(axis=0)).ravel()) - reached = cn > 0.05 * np.median(cn[cn > 0]) + reached = cn > 0 if not reached.all(): keep[l] = keep[l][reached]; P = P[:, np.flatnonzero(reached)] Pl[l] = to_petsc(P) @@ -200,6 +200,6 @@ def with_redisc(pc, Pl, *a, **kw): "inside_solve": {kk: round(v, 3) for kk, v in NT.items()}} records.append(rec) print("STEP", json.dumps(rec), flush=True) -out = os.path.expanduser(f"~/+Simulations/fault_mg_exploration/moving_{strategy}_k{k}.json") +out = os.path.expanduser(f"~/+Simulations/fault_mg_exploration/moving_{strategy}_k{k}{os.environ.get('TAG', '')}.json") json.dump({"strategy": strategy, "k": k, "background_build": t_bg, "records": records}, open(out, "w"), indent=1) print("DONE", out, flush=True) From ab8003ef11373c775b8db003c5f04bbe0c1e093f Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Tue, 13 Oct 2026 15:11:22 +1100 Subject: [PATCH 35/57] Split-level fault multigrid in the library: 2-D, 3-D and parallel mesh.add_fault carries a split-node fault through every add-on level of the inherited multigrid tail (the background is never cut), so the coarse spaces contain the discontinuity and the velocity iterations fall with refinement: 6, 5, 4 at k = 2, 3, 4 add-on levels against 13, 18, 23 with the welded tail, the slip identical to six figures. split_levels=False keeps the welded tail. utilities/fault_mg.py: the side-aware cell-located Lagrange transfer (fine cell centroid located in the coarse mesh, dual-basis weights, barycentric clamp; batched; serial CSR and an MPIAIJ builder that locates rank-locally and asks the other ranks for the nodes it cannot), the per-level pair rotation built from the level's own pairing, and the composition that removes each level's opening rows and drops unreached coarse unknowns instead of repairing them by injection (which duplicated columns and made the Galerkin operators singular in parallel). Every pair touching a split level takes this transfer, so the welded tail under a split finest now runs at np > 1 too (it returned NaN before). fault_split: 3-D sheet faults through add_fault (placed and split, placement options threaded through for bisection-refined meshes), the fault recipe recorded for replay on the levels, and a pairing that rides redistributions as a pair of DM labels: the migration star forest PETSc returns for an initial distribution does not index the stratified numbering. meshing/extrude: the layer's tail extrudes the split sheet and its split levels, and the extrusion runs in parallel (built on one rank, distributed, fault stars made rank-interior, pairing collected): 261 of 261 pairs coincident at np = 2, the faulted plate solving with its slip unchanged. Tests: test_1087 (tail structure, iterations against the welded tail, viscous fault, moving fault, the transfer's side awareness and exactness) and tests/parallel/test_1089 (np = 2 and 4; the thick twin extruded with its pairing). Subsystem page docs/developer/subsystems/fault-split-multigrid.md. Underworld development team with AI support from Claude Code --- .../design/FAULT_MULTIGRID_HIERARCHIES.md | 50 +- docs/developer/guides/adapt-on-top-faults.md | 9 + docs/developer/index.md | 2 + .../subsystems/fault-split-multigrid.md | 121 ++++ scripts/sessions/fault_mg/library_c.py | 103 +++ scripts/sessions/fault_mg/library_c_3d.py | 97 +++ scripts/sessions/fault_mg/thick_parallel.py | 69 ++ .../discretisation/discretisation_mesh.py | 53 +- src/underworld3/meshing/extrude.py | 233 ++++--- src/underworld3/utilities/custom_mg.py | 47 +- src/underworld3/utilities/fault_contact.py | 63 +- src/underworld3/utilities/fault_mg.py | 627 ++++++++++++++++++ src/underworld3/utilities/fault_split.py | 211 +++++- src/underworld3/utilities/rotated_bc.py | 10 + ...est_1089_fault_split_multigrid_parallel.py | 117 ++++ tests/test_1087_fault_split_multigrid.py | 168 +++++ 16 files changed, 1818 insertions(+), 162 deletions(-) create mode 100644 docs/developer/subsystems/fault-split-multigrid.md create mode 100644 scripts/sessions/fault_mg/library_c.py create mode 100644 scripts/sessions/fault_mg/library_c_3d.py create mode 100644 scripts/sessions/fault_mg/thick_parallel.py create mode 100644 src/underworld3/utilities/fault_mg.py create mode 100644 tests/parallel/test_1089_fault_split_multigrid_parallel.py create mode 100644 tests/test_1087_fault_split_multigrid.py diff --git a/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md b/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md index 6da3636ba..a51c3a82a 100644 --- a/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md +++ b/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md @@ -1,7 +1,10 @@ # Multigrid hierarchies for split-node faults -Technical note, 2026-10-13. Exploration, not yet in the library. Drivers: -`scripts/sessions/fault_mg/`. +Technical note, 2026-10-13. The exploration that chose the hierarchy; the library +implementation (C, below) is governed by +[subsystems/fault-split-multigrid.md](../subsystems/fault-split-multigrid.md). +Exploration drivers: `scripts/sessions/fault_mg/` (`stage*.py` monkey-patch the +solver; `library_c*.py` and `thick_parallel.py` go through the library). ## The question @@ -139,18 +142,21 @@ A, the existing path, had three defects at np > 1: 1. **Fixed** (`test_1086`). The rotated solve zeroed the opening rows of its fine transfer by local position instead of global row. -2. **Open.** `mesh.adapt` keeps a different intermediate level for the multigrid tail in - parallel than in serial (1358 against 1520 cells; the finest mesh is the same). -3. **Open.** `add_fault` gathers the fault's cell star onto one rank, so the split finest - level is not co-partitioned with its tail. The cross-partition transfer to it then - left 618 coarse unknowns with no fine image. Their nearest-node injection duplicated - 512 columns, the Galerkin coarse operators became singular, and PETSc's inode SOR - returned NaN. With inodes off it converges (34 iterations at np = 2 against 18 - serial), but the coarse operators are still singular. - -For C, the plan is to co-locate each add-on level with the finest *before* splitting it, -so that every split level holds its fault star on the same rank, and to build the -side-aware transfer rank-locally on those co-located levels. +2. **Open, now harmless.** `mesh.adapt` keeps a different intermediate level for the + multigrid tail in parallel than in serial (1358 against 1520 cells; the finest mesh + is the same). The levels need not match: the transfer locates nodes. +3. **Fixed** (`test_1089`). `add_fault` gathers the fault's cell star onto one rank, so + the split finest level is not co-partitioned with its tail. The node-cloud transfer + left 618 coarse unknowns with no fine image, their nearest-node injection + duplicated 512 columns, the Galerkin coarse operators became singular, and PETSc's + inode SOR returned NaN. Every pair touching a split level now takes the + cell-located transfer, which asks the other ranks for the nodes it cannot locate + locally, and unreached unknowns are dropped rather than repaired: A converges at + np = 2 (15 iterations against 13 serial), C in 6 at np = 2 and 7 at np = 4 (k = 2). + +The pairing rides every redistribution as a pair of DM labels. The migration star +forest PETSc returns for an initial distribution does not index the stratified point +numbering (three quarters of the points mapped to the wrong depth), so it is not used. ## What works best @@ -166,12 +172,12 @@ every problem, for no gain. B2 is worse than A, and badly so for the viscous fault. A smeared band on a coarse level is a continuous shear that the split fine operator rejects, so the coarse space has to contain the discontinuity itself. -## Not yet done +## State (library, 2026-10-13) -- Building the add-on levels from one `adapt` call (its NVB generations, each split) - instead of $k$ independent ones. -- The 3-D layer with the lateral levels split too. -- Fault networks, and faults near a boundary. -- Parallel: see above. -- Moving the transfer and the per-level frames into `custom_mg` as a mesh-owned tail - built by `add_fault`. +In the library: the add-on levels of one `adapt` call, each split by `add_fault`; the +cell-located transfer (serial and parallel) and the per-level frames in +`utilities/fault_mg.py`, installed through `custom_mg` and the rotated solve; the 3-D +layer with every level split, extruded in parallel; 3-D sheet faults on add-on meshes. +Not yet: fault networks through the levels (one `add_fault` call with several faults +replays them all, untested), faults near a boundary, co-locating the levels with the +finest as an optimisation. diff --git a/docs/developer/guides/adapt-on-top-faults.md b/docs/developer/guides/adapt-on-top-faults.md index 43b130479..a2260be08 100644 --- a/docs/developer/guides/adapt-on-top-faults.md +++ b/docs/developer/guides/adapt-on-top-faults.md @@ -247,6 +247,15 @@ near-optimal for TI — do **not** hand-roll a "TI-aware Schur"; measured, the d `1/η₀` beats every alternative (the weak TI mode is fault-parallel shear, which is volume-preserving, so pressure sees η₀). +**Split-node faults on an add-on mesh.** `child.add_fault(...)` carries the fault +through every add-on level of the tail (the background is never cut), so the coarse +spaces contain the discontinuity and the velocity iterations FALL with refinement +(6, 5, 4 at k = 2, 3, 4 against 13, 18, 23 with the welded levels; slip identical). +This works in serial and in parallel (the pairing rides every redistribution as +labels) and for the extruded thick twin. `split_levels=False` keeps the welded +tail. Governing doc: +[`subsystems/fault-split-multigrid.md`](../subsystems/fault-split-multigrid.md). + --- ## Moving fault + re-adaptation + field transfer diff --git a/docs/developer/index.md b/docs/developer/index.md index b03f6e696..f9eb5df10 100644 --- a/docs/developer/index.md +++ b/docs/developer/index.md @@ -38,6 +38,7 @@ same topic are reference or historical material subordinate to the governing doc | Local scattered-point interpolation | [subsystems/interpolation.md](subsystems/interpolation.md) | | Rotated free-slip & wall-normal datum | [subsystems/rotated-freeslip.md](subsystems/rotated-freeslip.md) | | Thin sheet (plane stress, sphere, faults) | [subsystems/thin-sheet.md](subsystems/thin-sheet.md) | +| Fault multigrid (split-level hierarchy, parallel pairing) | [subsystems/fault-split-multigrid.md](subsystems/fault-split-multigrid.md) | | Analytic & benchmark solutions | [subsystems/analytic-solutions.md](subsystems/analytic-solutions.md) | | Units | [design/UNITS_SIMPLIFIED_DESIGN_2025-11.md](design/UNITS_SIMPLIFIED_DESIGN_2025-11.md) | | Testing tiers | [TESTING-RELIABILITY-SYSTEM.md](TESTING-RELIABILITY-SYSTEM.md) | @@ -232,6 +233,7 @@ subsystems/solvers subsystems/boundary-stress-and-projection-postprocessing subsystems/rotated-freeslip subsystems/thin-sheet +subsystems/fault-split-multigrid subsystems/petsc-jacobian-layout subsystems/constitutive-models subsystems/constitutive-models-theory diff --git a/docs/developer/subsystems/fault-split-multigrid.md b/docs/developer/subsystems/fault-split-multigrid.md new file mode 100644 index 000000000..a1ab63512 --- /dev/null +++ b/docs/developer/subsystems/fault-split-multigrid.md @@ -0,0 +1,121 @@ +# Split-level multigrid for split-node faults + +Governing document for the fault multigrid hierarchy: `utilities/fault_mg.py`, the +split-pair branches of `utilities/custom_mg.py` and `utilities/rotated_bc.py`, the +level replay in `Mesh.add_fault`, and the fault-carrying tail of `meshing/extrude.py`. +The exploration that chose this design is +[FAULT_MULTIGRID_HIERARCHIES](../design/FAULT_MULTIGRID_HIERARCHIES.md). + +## What it is + +A split-node fault lives on the finest mesh. Its multigrid levels come from the add-on +hierarchy: the static background, which is never cut, and the add-on levels refined +from it toward the fault. `mesh.add_fault` now carries the fault through **every +add-on level** of the inherited tail, cut and split exactly as the finest is, so the +coarse spaces contain the discontinuity. The background levels are the base's own +wraps, untouched (`_mg_static`). + +```python +child = base.adapt(metric, max_levels=k) # add-on levels toward the fault +mesh = child.add_fault([("Flt", trace)]) # finest AND every add-on level split +stokes = uw.systems.Stokes(mesh) +stokes.add_fault_bc(0, "Flt") +stokes.solve() # geometric multigrid, no setup +mesh = child.add_fault([("Flt", trace)], split_levels=False) # the welded tail +``` + +In 3-D a fault is a triangulated sheet (`("Flt", points, triangles)` or a +`FaultSurface`), placed with the sheet placement and split; the levels are replayed +the same way. The extruded thick twin of a faulted sheet (`mesh.extrude`) extrudes the +split sheet and its split levels, so the layer's hierarchy is the sheet's one dimension +up. + +## The three pieces + +**Side-aware transfer** (`cell_located_prolongation`, `cell_located_transfer_parallel`). +For each fine node one fine cell containing it is taken, and that cell's *centroid* is +located in the coarse mesh. A centroid is never on the fault, so the coarse cell is on +the node's own side; a node on the slit interpolates from its own side. The coarse +cell's Lagrange interpolant is evaluated at the node by the dual-basis identity +$W = BM^{-1}$ (as the nested native-pair transfer does), batched over cells. The levels +are conforming but not nested near the trace, so a fine node can lie slightly outside +its located cell: the evaluation point is clamped into the cell (barycentric +coordinates clipped and renormalised), which keeps every weight within the Lagrange +basis on the simplex. Every pair that touches a split level takes this transfer; the +node-cloud builders triangulate the coarse nodes and degenerate on a slit. + +**Per-level pair frame** (`level_pair_rotation`). Each split level carries its own pair +rotation $Q_l$ (mean and jump in the fault frame, the same construction as +`build_rotation`, from the level's own pairing and facet normals), built on the +solver's discretisation copied onto the level DM. The rotated transfer is +$\hat P_l = Q_{l+1} P_l Q_l^{T}$ with the level's opening rows removed (and, on a +surface mesh, the jump and mean along the surface normal). + +**Unreached unknowns dropped** (`rotated_split_transfers`, `restrict_unreached`). A +coarse unknown no fine unknown reaches (a few near the tips) is a null mode of the +Galerkin coarse operator. It is removed from the level, fine to coarse, never repaired +by injection: the injection duplicated columns and made the coarse operators singular +in parallel. + +## Parallel + +Every split level is redistributed so its fault star is rank-interior (the split's own +rule), so the pair blocks are rank-local on every level. The transfer locates each +owned fine node in the *local* coarse mesh (owned plus ghost cells); the nodes whose +coarse cell lives elsewhere are all-gathered as query points, answered by the rank +holding the cell with global column indices, and the asker keeps the best answer. The +levels need not be co-partitioned. + +A coincident pairing rides a redistribution as a pair of DM labels +(`fault_split.stamp_point_pairs` / `collect_point_pairs`): one value per pair on its +Minus and Plus points. The migration star forest PETSc returns for an *initial* +distribution does not index the stratified numbering (measured: three quarters of the +points mapped to the wrong depth), so the forest is not used. `extrude_sheet` builds the +layer on one rank, distributes it with the labels on, redistributes the fault stars +rank-interior and reads the pairing back; a balanced partition readily puts the whole +Minus side on one rank and the whole Plus side on another, since the slit disconnects +them. + +## Measured (box in simple shear, straight fault, 2026-10-13) + +Velocity iterations per solve, split levels (C) against the welded tail (A): + +| add-on levels $k$ | near-fault $h$ | C | A | +|---|---|---|---| +| 2 | 0.0125 | 6 | 13 | +| 3 | 0.00625 | 5 | 18 | +| 4 | 0.0031 | 4 | 23 | + +Viscous fault at $k = 3$: 5 against 17. The peak slip agrees to six figures. In +parallel, C at $k = 2, 3$: 6, 5 iterations at np = 2 and 7, 6 at np = 4; A runs at np = 2 +too (15), where before it returned NaN. + +A moving fault at $k = 4$ (three positions, the add-on levels rebuilt and re-split at +each): 4 to 5 iterations in serial, 7 at np = 8, the slip equal to six figures. Per +step at np = 8: adapt 0.8 to 1.3 s, cut and split of every level 1.5 to 2.1 s, solve +11 to 14 s (serial: 2.7, 4.5 and 26 to 30 s). In parallel `adapt` keeps one fewer +intermediate level (a known difference in its subsampling), which the transfer does +not mind. + +The faulted thick plate of `test_1083` (two layers, 6024 tetrahedra, 261 pairs): +11 velocity iterations in serial and 13 at np = 2, slip 0.19356 on both; with one +add-on level in the sheet (7524 tetrahedra, 681 pairs): 16 in serial and 20 at +np = 2, slip 0.19382 on both. The thin layer's iteration count still grows with +refinement; the through-thickness coupling and the pressure Schur complement on a +thin layer are their own question, outside this hierarchy. + +## Tests + +`tests/test_1087_fault_split_multigrid.py` (tail structure, iterations falling with +refinement against the welded tail, viscous fault, a moving fault, the transfer's side +awareness and quadratic exactness) and +`tests/parallel/test_1089_fault_split_multigrid_parallel.py` (np = 2 and 4: split and +welded tails converge with the serial slip; the thick twin extruded with its pairing). + +## Not yet done + +- The 3-D sheet placement refuses an NVB-refined child (its volume guard fires), so the + general 3-D path is exercised only on meshes the placement accepts. +- Co-locating the add-on levels with the finest in parallel is an optimisation the + query path makes unnecessary for correctness; not measured at scale. +- The one-rank build of `extrude_sheet` bounds the layer size by one rank's memory. diff --git a/scripts/sessions/fault_mg/library_c.py b/scripts/sessions/fault_mg/library_c.py new file mode 100644 index 000000000..b2d1c7b62 --- /dev/null +++ b/scripts/sessions/fault_mg/library_c.py @@ -0,0 +1,103 @@ +"""Split-level fault multigrid through the LIBRARY path (no monkeypatching). + +Box in simple shear with a straight frictionless (or viscous / Coulomb) fault on an +add-on mesh of k levels. `mesh.add_fault` splits the add-on levels by default +(strategy C); `split_levels=False` keeps the welded tail (A). Reports the velocity +iterations, the peak slip and the stage timings. Runs in serial or under mpirun. + +python library_c.py [positions=1] [law=frictionless|viscous|coulomb] +""" +import json +import os +import sys +import time + +import numpy as np +import underworld3 as uw + +strategy, k = sys.argv[1], int(sys.argv[2]) +positions = int(sys.argv[3]) if len(sys.argv) > 3 else 1 +law = sys.argv[4] if len(sys.argv) > 4 else "frictionless" +HALF = np.array([0.20, 0.05]) +h_base = 0.05 +T = {} + + +def tic(name): + T["_t0"], T["_n"] = time.perf_counter(), name + + +def toc(): + T[T["_n"]] = T.get(T["_n"], 0.0) + time.perf_counter() - T["_t0"] + + +base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), + cellSize=0.1, refinement=1, qdegree=2) + + +def stokes_on(m): + x, y = m.X + s = uw.systems.Stokes(m) + s.constitutive_model = uw.constitutive_models.ViscousFlowModel + s.constitutive_model.Parameters.shear_viscosity_0 = 1.0 + s.tolerance = 1e-6 + s.petsc_use_pressure_nullspace = True + for side in ("Top", "Bottom", "Left", "Right"): + s.add_dirichlet_bc((y - 0.5, 0.0), side) + if law == "frictionless": + s.add_fault_bc(0, "Flt") + elif law == "viscous": + s.add_fault_bc(1.0, "Flt") + else: + from underworld3.utilities.fault_contact import add_coulomb_fault_bc + add_coulomb_fault_bc(s, 0.6, "Flt", sigma_n=0.8) + return s + + +records = [] +for step, cx in enumerate(np.linspace(0.3, 0.7, positions) if positions > 1 else [0.5]): + A_ = np.array([cx, 0.5]) - HALF + B_ = np.array([cx, 0.5]) + HALF + trace = np.vstack([A_, B_]) + tic("adapt") + pts3 = np.column_stack([np.linspace(A_[0], B_[0], 9), np.linspace(A_[1], B_[1], 9), + np.zeros(9)]) + surf = uw.meshing.Surface(f"g{step}", base, pts3) + surf.discretize() + child = base.adapt(surf.refinement_metric_function(h_near=h_base / 2**k, h_far=h_base, + width=0.08, profile="linear"), + max_levels=k) + toc() + tic("add_fault") + finest = child.add_fault([("Flt", trace)], split_levels=(strategy == "C")) + toc() + tail = finest._custom_mg_coarse_meshes + nsplit = sum(1 for m in tail if getattr(m, "_fault_point_pairs", None)) + tic("solver_build") + stokes = stokes_on(finest) + stokes._build() + toc() + tic("solve") + stokes.solve() + toc() + info = stokes._rotated_freeslip_info + from underworld3.utilities.fault_contact import fault_slip + s_, V, leak = fault_slip(stokes, "Flt", info) + peak = float(uw.mpi.comm.allreduce(float(np.abs(V).max()) if len(V) else 0.0, op=max)) + rec = {"strategy": strategy, "k": k, "law": law, "step": step, "cx": float(cx), + "np": uw.mpi.size, + "cells": int(uw.mpi.comm.allreduce(finest.dm.getHeightStratum(0)[1])), + "levels": len(tail) + 1, "split_levels": nsplit, "pc": info["velocity_pc"], + "vel_its": info["vel_its_last"], "pres_its": info["pres_its_last"], + "newton": info.get("nonlinear_iterations"), + "conv": bool(info["converged"]), "peak_slip": peak, + "times": {kk: round(v, 2) for kk, v in T.items() if not kk.startswith("_")}} + T.clear() + records.append(rec) + uw.pprint("STEP " + json.dumps(rec)) +out = os.path.expanduser( + f"~/+Simulations/fault_split_mg/lib_{strategy}_k{k}_{law}_np{uw.mpi.size}.json") +if uw.mpi.rank == 0: + os.makedirs(os.path.dirname(out), exist_ok=True) + json.dump(records, open(out, "w"), indent=1) +uw.pprint("DONE " + out) diff --git a/scripts/sessions/fault_mg/library_c_3d.py b/scripts/sessions/fault_mg/library_c_3d.py new file mode 100644 index 000000000..60a18df7f --- /dev/null +++ b/scripts/sessions/fault_mg/library_c_3d.py @@ -0,0 +1,97 @@ +"""Split-level fault multigrid in 3-D through the library: a planar fault patch in a +box in simple shear, on an add-on mesh of k levels. + +python library_c_3d.py +""" +import json +import os +import sys +import time + +import numpy as np +import underworld3 as uw + +strategy, k = sys.argv[1], int(sys.argv[2]) +h_base = 0.075 +T = {} + + +def tic(name): + T["_t0"], T["_n"] = time.perf_counter(), name + + +def toc(): + T[T["_n"]] = T.get(T["_n"], 0.0) + time.perf_counter() - T["_t0"] + + +base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0, 0), maxCoords=(1, 1, 1), + cellSize=2 * h_base, refinement=1, qdegree=2) +# the patch: x = 0.5, y and z in [0.3, 0.7], a 7 x 7 grid of points (five cells +# across at the base spacing of 0.075; the split needs about four, and the +# placement's cavity must clear the walls). Each quad takes the +# diagonal that leaves no triangle with all three vertices on the rim. +n = 7 +yy, zz = np.meshgrid(np.linspace(0.3, 0.7, n), np.linspace(0.3, 0.7, n), indexing="ij") +pts = np.column_stack([np.full(n * n, 0.5), yy.ravel(), zz.ravel()]) +rim = lambda i, j: i in (0, n - 1) or j in (0, n - 1) +tris = [] +for i in range(n - 1): + for j in range(n - 1): + a, b, c, d = i * n + j, (i + 1) * n + j, (i + 1) * n + j + 1, i * n + j + 1 + ra, rb, rc, rd = rim(i, j), rim(i + 1, j), rim(i + 1, j + 1), rim(i, j + 1) + if (ra and rb and rc) or (ra and rc and rd): + tris += [[a, b, d], [b, c, d]] + else: + tris += [[a, b, c], [a, c, d]] +tris = np.array(tris) + + +def metric(X): + d_plane = np.abs(X[:, 0] - 0.5) + d_rect = np.maximum(0.0, np.maximum(np.abs(X[:, 1] - 0.5) - 0.2, + np.abs(X[:, 2] - 0.5) - 0.2)) + d = np.sqrt(d_plane**2 + d_rect**2) + h_near, h_far, width = h_base / 2**k, h_base, 0.15 + h = np.minimum(h_far, h_near + (h_far - h_near) * d / width) + return 1.0 / h**2 + + +tic("adapt") +child = base.adapt(metric, max_levels=k) +toc() +tic("add_fault") +finest = child.add_fault([("Flt", pts, tris)], split_levels=(strategy == "C"), clearance=0.9) +toc() +tail = finest._custom_mg_coarse_meshes +nsplit = sum(1 for m in tail if getattr(m, "_fault_point_pairs", None)) +x, y, z = finest.X +s = uw.systems.Stokes(finest) +s.constitutive_model = uw.constitutive_models.ViscousFlowModel +s.constitutive_model.Parameters.shear_viscosity_0 = 1.0 +s.tolerance = 1e-6 +s.petsc_use_pressure_nullspace = True +for side in ("Top", "Bottom", "Left", "Right", "Front", "Back"): + s.add_dirichlet_bc((y - 0.5, 0.0, 0.0), side) +s.add_fault_bc(0, "Flt") +tic("solver_build") +s._build() +toc() +tic("solve") +s.solve() +toc() +info = s._rotated_freeslip_info +from underworld3.utilities.fault_contact import fault_pair_jumps +_c, jumps, normals = fault_pair_jumps(s, "Flt", info, gather=True) +tangential = jumps - np.einsum("ij,ij->i", jumps, normals)[:, None] * normals +peak = float(np.linalg.norm(tangential, axis=1).max()) if len(jumps) else 0.0 +rec = {"strategy": strategy, "k": k, "np": uw.mpi.size, + "cells": int(uw.mpi.comm.allreduce(finest.dm.getHeightStratum(0)[1])), + "levels": len(tail) + 1, "split_levels": nsplit, "pc": info["velocity_pc"], + "vel_its": info["vel_its_last"], "pres_its": info["pres_its_last"], + "conv": bool(info["converged"]), "peak_slip": peak, + "times": {kk: round(v, 2) for kk, v in T.items() if not kk.startswith("_")}} +uw.pprint("STEP " + json.dumps(rec)) +out = os.path.expanduser( + f"~/+Simulations/fault_split_mg/lib3d_{strategy}_k{k}_np{uw.mpi.size}.json") +if uw.mpi.rank == 0: + json.dump(rec, open(out, "w"), indent=1) diff --git a/scripts/sessions/fault_mg/thick_parallel.py b/scripts/sessions/fault_mg/thick_parallel.py new file mode 100644 index 000000000..64dd7e720 --- /dev/null +++ b/scripts/sessions/fault_mg/thick_parallel.py @@ -0,0 +1,69 @@ +"""The faulted thick plate (test_1083's model) through the parallel extrusion: +pair count, multigrid levels, velocity iterations and peak slip at any np. + +mpirun -np N python thick_parallel.py [k] (k add-on levels; 0 = plain refinement) +""" +import json +import os +import sys +import time + +import numpy as np +import sympy +import underworld3 as uw + +k = int(sys.argv[1]) if len(sys.argv) > 1 else 0 +L, ETA, C = 0.04, 1, 1 +t0 = time.perf_counter() +base = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.1, refinement=1, qdegree=3) +trace = np.array([[0.25, 0.5], [0.75, 0.5]]) +mesh = base +if k: + pts3 = np.column_stack([np.linspace(0.25, 0.75, 9), np.full(9, 0.5), np.zeros(9)]) + surf = uw.meshing.Surface("g", base, pts3) + surf.discretize() + mesh = base.adapt(surf.refinement_metric_function(h_near=0.05 / 2**k, h_far=0.05, + width=0.08, profile="linear"), + max_levels=k) +sheet_mesh = mesh.add_fault([("Flt", trace)]) +t_sheet = time.perf_counter() - t0 +t0 = time.perf_counter() +layer_mesh = sheet_mesh.extrude(L, layers=2) +t_extrude = time.perf_counter() - t0 +n_pairs = int(uw.mpi.comm.allreduce(sum(len(p) for p in layer_mesh._fault_point_pairs.values()))) +tail = layer_mesh._custom_mg_coarse_meshes or [] +n_split = sum(1 for m in tail if getattr(m, "_fault_point_pairs", None)) + + +def model(mesh, Solver): + solver = Solver(mesh) + solver.constitutive_model = uw.constitutive_models.ViscousFlowModel + solver.constitutive_model.Parameters.shear_viscosity_0 = ETA + y = mesh.X[1] + base_velocity = [y - sympy.Rational(1, 2), 0] + [0] * (mesh.cdim - 2) + solver.add_basal_drag(C, "Base", reference_velocity=base_velocity) + solver.add_fault_bc(0, "Flt") + solver.tolerance = 1.0e-5 + return solver + + +layer = model(layer_mesh, uw.systems.Stokes) +t0 = time.perf_counter() +layer.solve() +t_solve = time.perf_counter() - t0 +info = layer._rotated_freeslip_info +from underworld3.utilities.fault_contact import fault_pair_jumps +_c, jumps, normals = fault_pair_jumps(layer, "Flt", info, gather=True) +tangential = jumps - np.einsum("ij,ij->i", jumps, normals)[:, None] * normals +peak = float(np.linalg.norm(tangential, axis=1).max()) if len(jumps) else 0.0 +leak = float(np.abs(np.einsum("ij,ij->i", jumps, normals)).max()) if len(jumps) else 0.0 +cells = int(uw.mpi.comm.allreduce(layer_mesh.dm.getHeightStratum(0)[1])) +rec = {"np": uw.mpi.size, "k": k, "cells": cells, "pairs": n_pairs, "levels": len(tail) + 1, + "split_levels": n_split, "pc": info["velocity_pc"], "vel_its": info["vel_its_last"], + "pres_its": info["pres_its_last"], "conv": bool(info["converged"]), "peak_slip": peak, "leak": leak, + "t_sheet": round(t_sheet, 2), "t_extrude": round(t_extrude, 2), "t_solve": round(t_solve, 2)} +uw.pprint("STEP " + json.dumps(rec)) +out = os.path.expanduser(f"~/+Simulations/fault_split_mg/thick_k{k}_np{uw.mpi.size}.json") +if uw.mpi.rank == 0: + json.dump(rec, open(out, "w"), indent=1) diff --git a/src/underworld3/discretisation/discretisation_mesh.py b/src/underworld3/discretisation/discretisation_mesh.py index a6859d13e..903db66b0 100644 --- a/src/underworld3/discretisation/discretisation_mesh.py +++ b/src/underworld3/discretisation/discretisation_mesh.py @@ -7343,6 +7343,8 @@ def _coarse_level_meshes(self): token = object() for k, w in enumerate(cached): w._refine_slot = (token, k) + # the static background: never cut by a fault (add-on principle) + w._mg_static = True self._refine_slot = (token, len(cached) - 1) self._coarse_level_meshes_cache = cached return cached @@ -8286,7 +8288,7 @@ def extrude(self, thickness, layers=2, base="Base", surface="Surface", return extrude_sheet(self, thickness, layers=layers, base=base, surface=surface, qdegree=qdegree, verbose=verbose) - def add_fault(self, faults, verbose=False): + def add_fault(self, faults, verbose=False, split_levels=True, **place_kwargs): """Cut AND split one or more faults; return the split mesh. The split-node fault pipeline in one call: each fault becomes a @@ -8310,23 +8312,50 @@ def add_fault(self, faults, verbose=False): :meth:`add_conforming_surface`); solvers take their algebraic-multigrid defaults. - Implementation and design: ``underworld3.utilities.fault_split`` - and ``docs/developer/design/FAULT_CONTACT_DEPLOYMENT_2026-08.md``. + In 3-D a fault is a triangulated sheet: a ``FaultSurface`` or a + ``(name, points, triangles)`` triple, placed as + :meth:`add_conforming_sheet` places it and split; ``place_kwargs`` + are the placement's options (``clearance`` about 0.9 on a + bisection-refined mesh, where the default cavity cannot be closed). + + ``split_levels`` (default True) carries the fault through the ADD-ON + levels of the inherited multigrid tail — every level refined from the + static background toward the fault is cut and split the same way, so + the coarse spaces contain the discontinuity and the velocity solve + stays mesh-independent as the fault is refined (the split-level + multigrid of ``utilities.fault_mg``; measured 10 -> 5 velocity + iterations over four halvings against 9 -> 32 with welded levels). + The background levels are never cut. ``False`` keeps the welded tail. + + Implementation and design: ``underworld3.utilities.fault_split``, + ``utilities.fault_mg`` and + ``docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md``. """ from underworld3.utilities.fault_split import add_fault - child = add_fault(self, faults, verbose=verbose) + child = add_fault(self, faults, verbose=verbose, **place_kwargs) # The split mesh INHERITS a mesh-owned geometric-MG tail: a cut # re-represents the same grid with the surface conformed (finer only # by the duplicated vertices), so the parent's coarse levels serve - # unchanged with the cut mesh as the finest level — the coarse - # levels do not need the fault (#620/#629). Without this every - # solver on a split mesh fell to GAMG unless it called - # set_custom_fmg by hand. The FAC zone is NOT inherited: a split - # fault needs no patch (the keying ruling). + # with the cut mesh as the finest level. The static background + # levels serve unchanged; the add-on levels are split too (above). + # Without this every solver on a split mesh fell to GAMG unless it + # called set_custom_fmg by hand. The FAC zone is NOT inherited: a + # split fault needs no patch (the keying ruling). own_tail = getattr(self, "_custom_mg_coarse_meshes", None) if (own_tail is not None and getattr(child, "_custom_mg_coarse_meshes", None) is None): - child._custom_mg_coarse_meshes = list(own_tail) + tail = list(own_tail) + if split_levels: + from underworld3.utilities.fault_mg import ( + addon_level_indices, split_tail_levels) + idx = addon_level_indices(tail) + if idx: + split = split_tail_levels([tail[k] for k in idx], + child._fault_specs, + verbose=verbose) + for k, m in zip(idx, split): + tail[k] = m + child._custom_mg_coarse_meshes = tail child._custom_mg_builder = getattr(self, "_custom_mg_builder", "barycentric") child._custom_mg_fac_zone = None @@ -9316,7 +9345,9 @@ def _relax_generation(engine_obj, carry, rcarry): # the finest base level of the MG tail must carry the SAME moved # geometry the child was refined from; coarser levels keep their # original coordinates (custom-P transfers accept non-nested pairs) - coarse_tail = coarse_tail[:-1] + [self._wrap_coarse_level(base_finest)] + _moved_wrap = self._wrap_coarse_level(base_finest) + _moved_wrap._mg_static = True + coarse_tail = coarse_tail[:-1] + [_moved_wrap] child._custom_mg_coarse_meshes = coarse_tail + intermediate child._custom_mg_builder = builder diff --git a/src/underworld3/meshing/extrude.py b/src/underworld3/meshing/extrude.py index 10b0f87af..13672b6c7 100644 --- a/src/underworld3/meshing/extrude.py +++ b/src/underworld3/meshing/extrude.py @@ -28,20 +28,28 @@ class and the mesh. :func:`extrude_sheet` makes the second mesh from the first, sides. The two copies of a fault vertex take the SAME place in that order (their original's), so the two sides of a fault are triangulated identically and their faces coincide pair by pair. + +In parallel the sheet is gathered onto one rank, extruded there, and the layer +distributed; the fault pairing rides each redistribution as a pair of labels +(:func:`fault_split.stamp_point_pairs`) and the layer is finally redistributed +so every fault's cell star is rank-interior, the rule the split contact solve +relies on. The one-rank build bounds the layer size by one rank's +memory; a rank-local extrusion is the follow-up. """ from enum import Enum import numpy as np +from mpi4py import MPI from petsc4py import PETSc import underworld3 as uw -def _sheet_topology(mesh): +def _sheet_topology(dm): """Vertex coordinates, triangles (as vertex indices) and the point-to-index - map of a 2-D (or surface) simplex mesh.""" - dm = mesh.dm + map of a 2-D (or surface) simplex DM; empty arrays on a rank holding no + cells.""" v_start, v_end = dm.getDepthStratum(0) c_start, c_end = dm.getHeightStratum(0) cdim = dm.getCoordinateDim() @@ -61,56 +69,28 @@ def _edge_vertices(dm, edge, v_start): return int(cone[0]) - v_start, int(cone[1]) - v_start -def extrude_sheet(mesh, thickness, layers=2, base="Base", surface="Surface", - qdegree=None, verbose=False, with_tail=True): - r"""Extrude a sheet mesh through ``thickness`` into a layer of tetrahedra. - - Parameters - ---------- - mesh : Mesh - A 2-D triangle mesh, or a triangulated spherical manifold, possibly - carrying split-node faults (``mesh.add_fault``). - thickness : float - Layer thickness :math:`L`, centred on the sheet. - layers : int, default 2 - Elements through the thickness. - base, surface : str - Names of the two faces of the layer. - qdegree : int, optional - Quadrature degree of the new mesh; the sheet's by default. - with_tail : bool, default True - Give the layer a geometric-multigrid tail: the sheet with the layer - count halved down to one, then the sheet's own coarse levels at one - layer (coarsened through the thickness first, see the note in the code). - - Returns - ------- - Mesh - The layer, with the sheet's boundaries as walls, ``base`` and - ``surface``, and the sheet's faults as vertical faults with their pairing. - """ - from underworld3.coordinates import CoordinateSystemType +def _gather_sheet(dm, pairs): + """Everything onto rank 0: ``(dm, pairs)`` in the gathered numbering.""" + from underworld3.utilities.fault_split import redistribute_by_assignment + cS, cE = dm.getHeightStratum(0) + gathered, pairs, _dropped = redistribute_by_assignment( + dm, np.zeros(cE - cS, dtype=np.int32), pairs=pairs) + return gathered, pairs or {} - if uw.mpi.size > 1: - # TODO(DESIGN): build from the gathered sheet on one rank and distribute; - # the fault pairing then has to migrate with the distribution, as - # fault_split's redistribution does for a split. - raise NotImplementedError("extrude_sheet runs in serial for now") - if layers < 1: - raise ValueError("layers must be at least 1") - dm2 = mesh.dm - coords, triangles, v_start = _sheet_topology(mesh) +def _extrude_dm(dm2, boundaries, pairs2, thickness, layers, base, surface, + spherical, comm): + """Extrude the sheet DM ``dm2`` (all of it on this rank; empty on the other + ranks in parallel) into a layer DM on ``comm``: ``(dm, members, pairs3)``. + The boundary name/value table ``members`` is decided collectively, so the + layer's boundaries are the same on every rank.""" + m4 = comm.tompi4py() + coords, triangles, v_start = _sheet_topology(dm2) nv = len(coords) - spherical = mesh.dim < mesh.cdim - if spherical and (mesh.CoordinateSystem.coordinate_type - is not CoordinateSystemType.SPHERICAL): - raise NotImplementedError("extrude_sheet: only flat sheets and spherical manifolds") # The order that cuts each prism: a fault's minus copy of a vertex takes its # plus copy's place, so both sides cut their prisms alike. key = np.arange(nv) - pairs2 = getattr(mesh, "_fault_point_pairs", {}) for pairs in pairs2.values(): for q_minus, q_plus in pairs.items(): if v_start <= q_minus < v_start + nv: @@ -120,7 +100,7 @@ def extrude_sheet(mesh, thickness, layers=2, base="Base", surface="Surface", offsets = -0.5 * thickness + thickness * np.arange(layers + 1) / layers if spherical: radius = np.linalg.norm(coords, axis=1) - rhat = coords / radius[:, None] + rhat = coords / radius[:, None] if nv else coords xyz = np.concatenate([rhat * (radius + s)[:, None] for s in offsets]) else: xyz = np.concatenate([np.column_stack([coords, np.full(nv, s)]) for s in offsets]) @@ -134,18 +114,21 @@ def extrude_sheet(mesh, thickness, layers=2, base="Base", surface="Surface", A, B, C = lo + a, lo + b, lo + c A1, B1, C1 = hi + a, hi + b, hi + c cells += [[A, B, C, A1], [B, C, A1, B1], [C, A1, B1, C1]] - cells = np.array(cells, dtype=np.int32) + cells = np.array(cells, dtype=np.int32).reshape(-1, 4) # PETSc's orientation: the first three vertices see the fourth on the side # their right-hand normal points AWAY from (a negative triple product). With # the other sign every volume integral changes sign against the facet ones — # measured: the domain volume came out -0.1 and a basal drag pushed. - p = xyz[cells] - vol = np.einsum("ij,ij->i", np.cross(p[:, 1] - p[:, 0], p[:, 2] - p[:, 0]), p[:, 3] - p[:, 0]) - flip = vol > 0 - cells[flip, 2], cells[flip, 3] = cells[flip, 3].copy(), cells[flip, 2].copy() - - dm = PETSc.DMPlex().createFromCellList(3, cells, xyz, interpolate=True, - comm=PETSc.COMM_SELF) + if len(cells): + p = xyz[cells] + vol = np.einsum("ij,ij->i", np.cross(p[:, 1] - p[:, 0], p[:, 2] - p[:, 0]), + p[:, 3] - p[:, 0]) + flip = vol > 0 + cells[flip, 2], cells[flip, 3] = cells[flip, 3].copy(), cells[flip, 2].copy() + + # the cell list on rank 0 only (the other ranks hand in nothing) + dm = PETSc.DMPlex().createFromCellList(3, cells, xyz.reshape(-1, 3), + interpolate=True, comm=comm) v3_start, _ = dm.getDepthStratum(0) def vertex(k, i): @@ -184,16 +167,18 @@ def label_faces(name, value, points): for name in pairs2: fault_side_names |= {name, f"{name}Plus", f"{name}Minus"} e_start, e_end = dm2.getDepthStratum(1) - for b in mesh.boundaries: + for b in boundaries: if b.name == "All_Boundaries": continue lab = dm2.getLabel(b.name) - if not lab or lab.getStratumSize(b.value) == 0: - continue - edges = [int(q) for q in lab.getStratumIS(b.value).getIndices() if e_start <= q < e_end] - if not edges: - # not a boundary of the sheet's area (a manifold labels the whole - # surface, for one): nothing to stand up as a wall + edges = [] + if lab and lab.getStratumSize(b.value) > 0: + edges = [int(q) for q in lab.getStratumIS(b.value).getIndices() + if e_start <= q < e_end] + # a boundary of the sheet's AREA has edges (somewhere); a manifold + # labels the whole surface, for one, and has nothing to stand up as a + # wall. Decided collectively: the table must agree on every rank. + if not m4.allreduce(bool(edges), op=MPI.LOR): continue if b.name in (base, surface): raise ValueError( @@ -227,9 +212,10 @@ def label_faces(name, value, points): if v_start <= q_minus < v_start + nv: for k in range(layers + 1): vmap[vertex(k, q_minus - v_start)] = vertex(k, q_plus - v_start) - value = mesh.boundaries[f"{name}Minus"].value + value = boundaries[f"{name}Minus"].value lab = dm.getLabel(f"{name}Minus") - minus_faces = lab.getStratumIS(value).getIndices() + minus_faces = (lab.getStratumIS(value).getIndices() + if lab and lab.getStratumSize(value) > 0 else []) found = {} for f in minus_faces: for q in dm.getTransitiveClosure(int(f))[0]: @@ -253,10 +239,82 @@ def label_faces(name, value, points): all_label = dm.getLabel("All_Boundaries") for name in pairs2: lab = dm.getLabel(name) - value = mesh.boundaries[name].value + value = boundaries[name].value if lab and lab.getStratumSize(value): for q in lab.getStratumIS(value).getIndices(): all_label.clearValue(int(q), 1001) + return dm, members, pairs3 + + +def extrude_sheet(mesh, thickness, layers=2, base="Base", surface="Surface", + qdegree=None, verbose=False, with_tail=True): + r"""Extrude a sheet mesh through ``thickness`` into a layer of tetrahedra. + + Parameters + ---------- + mesh : Mesh + A 2-D triangle mesh, or a triangulated spherical manifold, possibly + carrying split-node faults (``mesh.add_fault``). + thickness : float + Layer thickness :math:`L`, centred on the sheet. + layers : int, default 2 + Elements through the thickness. + base, surface : str + Names of the two faces of the layer. + qdegree : int, optional + Quadrature degree of the new mesh; the sheet's by default. + with_tail : bool, default True + Give the layer a geometric-multigrid tail: the sheet with the layer + count halved down to one, then the sheet's own coarse levels at one + layer (coarsened through the thickness first, see the note in the code). + + Returns + ------- + Mesh + The layer, with the sheet's boundaries as walls, ``base`` and + ``surface``, and the sheet's faults as vertical faults with their pairing. + """ + from underworld3.coordinates import CoordinateSystemType + from underworld3.utilities.fault_split import ( + _fault_labels_touch_seam, _redistribute_fault_interior, + collect_point_pairs, stamp_point_pairs) + + if layers < 1: + raise ValueError("layers must be at least 1") + spherical = mesh.dim < mesh.cdim + if spherical and (mesh.CoordinateSystem.coordinate_type + is not CoordinateSystemType.SPHERICAL): + raise NotImplementedError("extrude_sheet: only flat sheets and spherical manifolds") + + comm = mesh.dm.comm + parallel = comm.getSize() > 1 + pairs2 = dict(getattr(mesh, "_fault_point_pairs", {}) or {}) + dm2 = mesh.dm + if parallel: + dm2, pairs2 = _gather_sheet(dm2, pairs2) + + dm, members, pairs3 = _extrude_dm(dm2, mesh.boundaries, pairs2, thickness, layers, + base, surface, spherical, comm) + if parallel: + # the layer lives on rank 0: distribute it, carry the pairing through, + # and make every fault's cell star rank-interior (the split's rule) + # The slit disconnects the two sides, so the balanced partition + # readily puts the whole Minus side on one rank and the whole Plus + # side on another (measured at np = 2: 261 of 261 pairs apart). The + # pair labels stay on through BOTH moves and are read once the stars + # are rank-interior. + names = stamp_point_pairs(dm, pairs3) + dm.distribute() + if names: + labels = [(f"{name}{side}", members[f"{name}{side}"]) + for name in names for side in ("Plus", "Minus")] + if _fault_labels_touch_seam(dm, labels): + dm = _redistribute_fault_interior(dm, labels, verbose=verbose) + pairs3, dropped = collect_point_pairs(dm, names) + if dropped: + raise RuntimeError( + f"extrude_sheet: {dropped} fault pair(s) have no rank holding " + "both sides after the fault-interior redistribution") boundaries = Enum("boundaries", members) thick = uw.discretisation.Mesh( @@ -280,8 +338,8 @@ def label_faces(name, value, points): # FIRST — the fine sheet with the layer count halved down to one — and only # then laterally, through the sheet's own coarse levels at one layer. The # coarse end of the hierarchy is then essentially the thin sheet. Without a - # tail the solve falls back to algebraic multigrid. The coarse levels do not - # carry the faults (the fine level alone is cut). + # tail the solve falls back to algebraic multigrid. The faults go with the + # levels (see _extruded_tail). if with_tail: tail = _extruded_tail(mesh, thickness, layers, base, surface, qdegree) if tail: @@ -296,39 +354,40 @@ def _extruded_tail(mesh, thickness, layers, base, surface, qdegree): :func:`extrude_sheet`): the sheet with the layer count halved down to one, then the sheet's own coarse levels at one layer. - Every coarse level must be free of the faults. The transfer builders locate - fine nodes among the coarse ones, and a split level has coincident nodes along - its slit: the transfers degenerate and the multigrid returns NaN on its first - application (measured; this was first misread as a failure of geometric - multigrid on the rotated 3-D pair operator — on fault-free levels the faulted - layer's velocity block takes 14 iterations where algebraic multigrid takes - about 180). So the levels come from the split sheet's ancestors: the conforming - cut it was split from (same nodes, no slit) for the through-thickness levels, - and the first ancestor with a refinement hierarchy for the lateral ones. + The faults go with the levels. The through-thickness levels are extrusions + of the sheet itself, slit and pairing included, and a split level of the + sheet's own tail (the add-on levels ``add_fault`` splits) is extruded split + too, so the layer's hierarchy is the split-level multigrid of the sheet one + dimension up (``utilities.fault_mg``: the cell-located transfer reads the + cells of a split level, where the node-cloud builders degenerated on its + coincident nodes and returned NaN — the earlier reason for keeping every + level fault-free). The background levels stay as they are. Each extruded + level keeps its sheet level's static flag. """ def ancestors(m): while m is not None: yield m m = getattr(m, "parent", None) - unsplit = next((m for m in ancestors(mesh) - if not getattr(m, "_fault_point_pairs", None)), None) - if unsplit is None: - return [] own = getattr(mesh, "_custom_mg_coarse_meshes", None) if own is not None: - sheet_levels = [m for m in own if not getattr(m, "_fault_point_pairs", None)] + sheet_levels = list(own) else: - refined = next((m for m in ancestors(unsplit) + refined = next((m for m in ancestors(mesh) if len(getattr(m, "dm_hierarchy", []) or []) > 1), None) - sheet_levels = list(refined._coarse_level_meshes())[:-1] if refined is not None else [] + if refined is None: + return [] + sheet_levels = list(refined._coarse_level_meshes())[:-1] vertical = [] n = layers // 2 while n >= 1: - vertical.append(extrude_sheet(unsplit, thickness, layers=n, base=base, + vertical.append(extrude_sheet(mesh, thickness, layers=n, base=base, surface=surface, qdegree=qdegree, with_tail=False)) n //= 2 - lateral = [extrude_sheet(level, thickness, layers=1, base=base, surface=surface, - qdegree=qdegree, with_tail=False) - for level in sheet_levels] + lateral = [] + for level in sheet_levels: + thick_level = extrude_sheet(level, thickness, layers=1, base=base, surface=surface, + qdegree=qdegree, with_tail=False) + thick_level._mg_static = getattr(level, "_mg_static", False) + lateral.append(thick_level) return lateral + list(reversed(vertical)) diff --git a/src/underworld3/utilities/custom_mg.py b/src/underworld3/utilities/custom_mg.py index 4c57b1ec0..fb8d070a0 100644 --- a/src/underworld3/utilities/custom_mg.py +++ b/src/underworld3/utilities/custom_mg.py @@ -1290,6 +1290,11 @@ def __init__(self, level_meshes, builder="barycentric", field_id=None, self.cross_partition = cross_partition self.fac_zone = self._validated_fac_zone(fac_zone) self.transfers = None + # Split-level multigrid (utilities.fault_mg): a COARSE level carrying a + # split-node fault takes the side-aware cell-located transfer, and its + # unreached unknowns are dropped at install rather than repaired. + from underworld3.utilities.fault_mg import is_split_level + self.has_split_levels = any(is_split_level(m) for m in self.level_meshes) def _validated_fac_zone(self, fac_zone): """Normalise ``fac_zone`` to None or a list of boolean cell masks. @@ -1433,7 +1438,34 @@ def build(self, solver): # None, which _configure_pcmg reads as whole-level smoothing. self.level_patch_rows = [None] * nlev comm = solver.dm.comm + from underworld3.utilities import fault_mg for l in range(1, nlev): + # Split-level multigrid: every pair touching a split level takes + # the cell-located transfer. A split COARSE level defeats the + # node-cloud builders (they triangulate the coarse nodes, and + # coincident slit nodes make that degenerate); the transfer reads + # the coarse CELLS instead and keeps each side's nodes on their + # own side. Its unreached coarse unknowns (a few near the tips) + # are dropped at install time — never repaired by injection, which + # duplicated columns and made the Galerkin operators singular in + # parallel (the welded tail under a split finest: 426 orphans at + # np = 2, then NaN from the inode SOR). + if (fault_mg.is_split_level(self.level_meshes[l - 1]) + or fault_mg.is_split_level(self.level_meshes[l])): + if parallel: + P = fault_mg.cell_located_transfer_parallel( + self.level_meshes[l - 1], self.level_meshes[l], degree, + continuous, coords[l - 1], coords[l], maps[l - 1], + maps[l], nc, comm) + else: + Pn = fault_mg.cell_located_prolongation( + self.level_meshes[l - 1], self.level_meshes[l], degree, + continuous, coarse_coords=coords[l - 1], + fine_coords=coords[l]) + P = _to_petsc_aij( + _reduced_from_node_transfer(Pn, maps[l - 1], maps[l], nc)) + Ps.append(P) + continue if parallel: args = (coords[l - 1], coords[l], maps[l - 1], maps[l], nc, self.builder, comm) @@ -1603,8 +1635,15 @@ def _assert_finest_matches_operator(solver, finest_map, parallel): def install(self, solver, verbose=False): if self.transfers is None: raise RuntimeError("call build() before install()") - _install_transfers(solver, self.transfers, verbose=verbose, - patch_rows=getattr(self, "level_patch_rows", None)) + Ps = self.transfers + patch_rows = getattr(self, "level_patch_rows", None) + if self.has_split_levels: + # the restriction renumbers the coarse levels, so the recorded + # FAC patch rows no longer apply (a split fault needs no patch) + from underworld3.utilities.fault_mg import restrict_unreached + Ps = restrict_unreached(Ps, solver.dm.comm) + patch_rows = None + _install_transfers(solver, Ps, verbose=verbose, patch_rows=patch_rows) # Record what is live on this solver's PC, so a repeat solve can # skip the re-install (see _pcmg_still_installed). solver._custom_mg_live = {"h": id(self), @@ -2085,10 +2124,12 @@ def _colocate_level(coarse_mesh, fine_mesh): sizes = np.bincount(assign, minlength=comm.size).astype(np.int32) part.setShellPartition(comm.size, sizes=sizes, points=order) work.distribute() - return uw.discretisation.Mesh( + moved = uw.discretisation.Mesh( work, simplex=coarse_mesh.dm.isSimplex(), qdegree=coarse_mesh.qdegree, coordinate_system_type=coarse_mesh.CoordinateSystem.coordinate_type, boundaries=coarse_mesh.boundaries, verbose=False) + moved._mg_static = getattr(coarse_mesh, "_mg_static", False) + return moved def adopt_hierarchy(mesh, base_mesh, fac_zone=None, builder=None): diff --git a/src/underworld3/utilities/fault_contact.py b/src/underworld3/utilities/fault_contact.py index 67c07671c..4bb74877a 100644 --- a/src/underworld3/utilities/fault_contact.py +++ b/src/underworld3/utilities/fault_contact.py @@ -535,27 +535,50 @@ def _manifold_edge_conormal(dm, f): return float(np.linalg.norm(Q - P)), nu -def _pair_frame(solver, q, nrm): +def _radial_fn(mesh): + """A numpy callable of the mesh coordinates giving the surface normal of a + manifold mesh — its radial unit vector ``unit_e_0``, unwrapped to mesh + coordinates exactly as ``rotated_bc._normal_source`` does for an analytic + normal. Build it once per mesh, not per pair.""" + from underworld3.function.expressions import unwrap + normal = mesh.CoordinateSystem.unit_e_0 + comps = [sympy.sympify(unwrap(normal[0, k], keep_constants=False, + return_self=False)) + for k in range(mesh.cdim)] + return sympy.lambdify(list(mesh.X), comps, "numpy") + + +def _pair_frame(solver, q, nrm, radial=None): + """The rotation frame of a coincident pair at point ``q`` of the SOLVER's + mesh — see :func:`_pair_frame_on`, the mesh-level form a coarse level of + the split-level multigrid uses too.""" + mesh = solver.mesh + if mesh.dim < mesh.cdim and radial is None: + radial = _radial_fn(mesh) + return _pair_frame_on(mesh, solver.dm, q, nrm, radial) + + +def _pair_frame_on(mesh, dm, q, nrm, radial=None): """The rotation frame of a coincident pair at point ``q``: the fault normal first, then the slip direction(s). On a volume mesh that is ``[n] + _tangent_frame(n)``; on a surface mesh the fault normal is the in-surface conormal, the slip direction is the trace tangent and the third vector is the surface normal, whose jump AND mean rows the tangency constraint pins (see ``rotated_bc.build_rotation``). The SINGLE - authority for the pair frame: the rotated pair blocks and the interface - assembler's slip rows must agree row for row.""" - mesh = solver.mesh + authority for the pair frame: the rotated pair blocks, the interface + assembler's slip rows and the coarse levels' pair blocks must agree row + for row. ``radial`` is :func:`_radial_fn` of the mesh (surface meshes).""" if mesh.dim == mesh.cdim: return [np.asarray(nrm, dtype=float)] + _tangent_frame(nrm) - from underworld3.utilities.rotated_bc import _normal_source, _point_coord - dm = solver.dm + from underworld3.utilities.rotated_bc import _point_coord cdim = mesh.cdim csec = dm.getCoordinateSection() cvec = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, cdim) v0, v1 = dm.getDepthStratum(0) - sym_fn, const = _normal_source(solver, mesh.CoordinateSystem.unit_e_0, "fault") + if radial is None: + radial = _radial_fn(mesh) X = _point_coord(dm, cdim, cvec, csec, v0, v1, int(q)) - r = np.asarray(sym_fn(*X), dtype=float).ravel() if sym_fn is not None else const + r = np.asarray(radial(*X), dtype=float).ravel() r = r / (np.linalg.norm(r) + 1e-30) # The conormal is perpendicular to the FACET normal, the surface normal is # the analytic one at the node: they differ by O(h), and a frame that is @@ -572,6 +595,17 @@ def _pair_frame(solver, q, nrm): def _fault_pair_nodes(solver, boundary): + """The velocity-carrying coincident pairs of a split fault of the + SOLVER's mesh, with normals — :func:`_fault_pair_nodes_on` with the + solver's analytic-normal override for this fault, if one is registered.""" + return _fault_pair_nodes_on(solver.mesh, solver.dm, + solver.dm.getLocalSection(), boundary, + field=_VELOCITY_FIELD, + override=_compiled_normal_override(solver, boundary)) + + +def _fault_pair_nodes_on(mesh, dm, lsec, boundary, field=_VELOCITY_FIELD, + override=None): """The velocity-carrying coincident pairs of a split fault, with normals. Returns ``[(q_plus, q_minus, n_hat), ...]`` over this rank's pairs, where @@ -591,11 +625,13 @@ def _fault_pair_nodes(solver, boundary): rotated pair blocks, the interface-law rows, and every diagnostic (slip, leak, normal traction) all read the frame from here, so the override is consistent everywhere by construction. + + ``mesh`` is any mesh carrying the pairing (the solver's, or a coarse + level of the split-level multigrid), ``dm`` a DM over it that carries the + velocity field ``field`` in its local section ``lsec``, and ``override`` + the compiled analytic normal (``None`` for the facet-derived one). """ - mesh = solver.mesh - dm = solver.dm dim = mesh.cdim - lsec = dm.getLocalSection() fS, fE = dm.getHeightStratum(1) pairs = mesh._fault_point_pairs[boundary] @@ -638,10 +674,9 @@ def _fault_pair_nodes(solver, boundary): else: vol, ne, _exterior = facet_measure_and_normal(dm, f, orient="support0") for q in (int(c) for c in dm.getTransitiveClosure(f)[0]): - if lsec.getFieldDof(q, _VELOCITY_FIELD) > 0: + if lsec.getFieldDof(q, field) > 0: nacc[q] = nacc.get(q, np.zeros(dim)) + float(vol) * ne - override = _compiled_normal_override(solver, boundary) if override is not None: from underworld3.utilities.rotated_bc import _point_coord csec = dm.getCoordinateSection() @@ -650,7 +685,7 @@ def _fault_pair_nodes(solver, boundary): out = [] for q_minus, q_plus in pairs.items(): - if lsec.getFieldDof(int(q_plus), _VELOCITY_FIELD) <= 0: + if lsec.getFieldDof(int(q_plus), field) <= 0: continue acc = nacc.get(int(q_plus)) if acc is None: diff --git a/src/underworld3/utilities/fault_mg.py b/src/underworld3/utilities/fault_mg.py new file mode 100644 index 000000000..e1260c0e8 --- /dev/null +++ b/src/underworld3/utilities/fault_mg.py @@ -0,0 +1,627 @@ +r"""Split-level geometric multigrid for split-node faults. + +A split-node fault lives on the finest mesh. The coarse levels under it come from +the add-on hierarchy: the static background, which is never cut, and the add-on +levels refined from it toward the fault. This module carries the fault through +the add-on levels — every add-on level is split the same way as the finest — so +the coarse spaces contain the discontinuity and the velocity solve stays +mesh-independent as the fault is refined (iterations fall from 10 to 5 over four +halvings, against 9 to 32 with welded levels; see +``docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md``). + +Three pieces make a split level usable as a multigrid level: + +* **A side-aware transfer.** For each fine node one fine cell containing it is + taken and that cell's CENTROID is located in the coarse mesh. A centroid is + never on the fault, so the coarse cell is on the node's own side, and a node on + the slit interpolates from its own side. The coarse cell's Lagrange interpolant + is evaluated at the node by the dual-basis identity :math:`W = BM^{-1}` (as in + :func:`custom_mg.nested_refine_pair_prolongation`). The levels are conforming + but not nested near the trace (each cut pulls vertices onto it), so a fine node + can lie a little outside its located coarse cell: the evaluation point is + clamped into the cell (barycentric coordinates clipped and renormalised), which + keeps every weight bounded by the Lagrange basis inside the simplex. +* **A per-level pair frame.** Each split level carries its own pair rotation + :math:`Q_l` (mean and jump in the fault frame) and its opening rows are removed: + :math:`\hat P_l = Q_{l+1} P_l Q_l^T` restricted to the admissible unknowns. +* **Unreached coarse unknowns dropped.** A coarse unknown no fine unknown reaches + (a few near the tips) is a null mode of the Galerkin coarse operator; it is + removed from the level rather than repaired by injection. + +Parallel: each split level is redistributed so its fault star is rank-interior +(the split's own rule), so pair blocks are rank-local on every level. The transfer +locates each owned fine node in the LOCAL coarse mesh (owned plus ghost cells); +the few nodes whose coarse cell lives elsewhere are gathered as query points and +answered by the rank that holds the cell, with global column indices. +""" + +import itertools + +import numpy as np +import scipy.sparse as sp +from petsc4py import PETSc +from scipy.spatial import cKDTree + +import underworld3 as uw + +# Barycentric slack for "inside": a fine node this far outside (in reference +# coordinates) still counts as located in the cell; beyond it the clamp acts. +_INSIDE_TOL = 1e-8 +# Chunk size for the batched weight evaluation (bounds the (n, nb, nb) temporaries). +_CHUNK = 1 << 16 + + +# --------------------------------------------------------------------------- # +# Geometry +# --------------------------------------------------------------------------- # +def is_split_level(mesh): + """Does this level carry a split-node fault (a coincident DOF pairing)?""" + return bool(getattr(mesh, "_fault_point_pairs", None)) + + +def _cell_vertex_table(mesh): + """Vertex coordinates ``(n_v, cdim)`` and the ``(n_cells, dim + 1)`` table of + vertex rows per cell — the P1 node table, which the mesh caches.""" + vxy = np.asarray(mesh._get_coords_for_basis(1, True)) + cv = np.asarray(mesh._cell_node_indices(1, True)) + return vxy, cv + + +def _barycentric(P, V): + """Barycentric coordinates of points ``P (n, cdim)`` in the simplices + ``V (n, dim + 1, cdim)``. For an embedded surface (``cdim > dim``) the point is + projected onto the simplex plane first (normal equations).""" + A = V[:, 1:] - V[:, :1] # (n, dim, cdim) + b = P - V[:, 0] # (n, cdim) + dim, cdim = A.shape[1], A.shape[2] + if dim == cdim: + lam = np.linalg.solve(np.transpose(A, (0, 2, 1)), b[..., None])[..., 0] + else: + G = np.einsum("nik,njk->nij", A, A) + rhs = np.einsum("nik,nk->ni", A, b) + lam = np.linalg.solve(G, rhs[..., None])[..., 0] + return np.concatenate([1.0 - lam.sum(axis=1, keepdims=True), lam], axis=1) + + +def locate_in_cells(points, vxy, cv, k=16): + """Locate ``points`` among the simplices ``cv`` (vertex rows into ``vxy``). + + Returns ``(cell, lam)``: the best candidate cell per point (the one whose + minimum barycentric coordinate is largest among the ``k`` nearest by centroid) + and its barycentric coordinates there. A point is INSIDE its cell when + ``lam.min() >= -_INSIDE_TOL``; otherwise the caller clamps or asks another rank. + Empty ``cv`` returns cells of -1.""" + n = points.shape[0] + dim1 = cv.shape[1] + best = np.full(n, -1, dtype=np.int64) + lam_best = np.full((n, dim1), -np.inf) + if n == 0 or cv.shape[0] == 0: + return best, lam_best + cent = vxy[cv].mean(axis=1) + kk = min(k, cv.shape[0]) + _, cand = cKDTree(cent).query(points, k=kk) + cand = np.asarray(cand).reshape(n, kk) + bestmin = np.full(n, -np.inf) + for j in range(kk): + c = cand[:, j] + lam = _barycentric(points, vxy[cv[c]]) + m = lam.min(axis=1) + better = m > bestmin + best[better] = c[better] + lam_best[better] = lam[better] + bestmin[better] = m[better] + return best, lam_best + + +def _clamp(lam): + """Clip barycentric coordinates into the simplex and renormalise — the + nearest point of the cell in reference coordinates for a node a little + outside it (non-nested levels near a pulled tip). Every weight then stays + bounded by the Lagrange basis inside the simplex.""" + out = lam.copy() + bad = (out < 0).any(axis=1) + if bad.any(): + b = np.clip(out[bad], 0.0, None) + out[bad] = b / b.sum(axis=1, keepdims=True) + return out + + +def _monomial_exponents(degree, dim): + return np.asarray([e for e in itertools.product(range(degree + 1), repeat=dim) + if sum(e) <= degree]) + + +def _vander(L, E): + """Monomials ``E`` of the pulled-back coordinates ``L (n, dim)``: ``(n, nb)``.""" + return np.prod(L[:, None, :] ** E[None, :, :], axis=2) + + +def _pullback(X, V): + """Reference coordinates of points ``X (n, cdim)`` in simplices ``V (n, dim + 1, + cdim)`` — the barycentric vector without its first entry.""" + return _barycentric(X, V)[:, 1:] + + +def _node_weights(Xf, cells, lam, Xc, cn, vxy_c, cv_c, degree): + """Weights of the fine nodes ``Xf`` on the nodes of their located coarse + ``cells`` (barycentric ``lam`` there, clamped): ``(n, nb)``. + + Batched dual-basis evaluation: per distinct coarse cell the inverse nodal + Vandermonde ``M^{-1}`` (nodes pulled back through the cell's own affine map), + then ``W = B M^{-1}`` row by row in chunks.""" + dim = cv_c.shape[1] - 1 + E = _monomial_exponents(degree, dim) + nb = E.shape[0] + if cn.shape[1] != nb: + raise RuntimeError( + f"fault_mg: {cn.shape[1]} nodes per cell against {nb} monomials " + f"of degree {degree} in {dim}-D") + used, inv = np.unique(cells, return_inverse=True) + Vc = vxy_c[cv_c[used]] # (nu, dim+1, cdim) + # coarse nodes of each used cell, pulled back through that cell's map + Xn = Xc[cn[used]] # (nu, nb, cdim) + Lc = _pullback(Xn.reshape(-1, Xn.shape[-1]), + np.repeat(Vc, nb, axis=0)).reshape(len(used), nb, dim) + M = _vander(Lc.reshape(-1, dim), E).reshape(len(used), nb, nb) + Minv = np.linalg.inv(M) + Lf = _clamp(lam)[:, 1:] # (n, dim) + W = np.empty((Xf.shape[0], nb)) + for s in range(0, Xf.shape[0], _CHUNK): + e = min(s + _CHUNK, Xf.shape[0]) + B = _vander(Lf[s:e], E) # (m, nb) + W[s:e] = np.einsum("tm,tmk->tk", B, Minv[inv[s:e]]) + return W + + +def _fine_cell_of_node(fn, n_nodes): + """One fine cell containing each fine node (the last cell listing it). A + node on the slit is listed only by cells on its own side.""" + owner = np.full(n_nodes, -1, dtype=np.int64) + for k in range(fn.shape[0]): + owner[fn[k]] = k + return owner + + +# --------------------------------------------------------------------------- # +# Serial transfer +# --------------------------------------------------------------------------- # +def cell_located_prolongation(coarse, fine, degree, continuous=True, + coarse_coords=None, fine_coords=None): + """Side-aware, cell-located node prolongation ``(n_fine_nodes, n_coarse_nodes)`` + as scipy CSR (serial). Exact for nested pairs; on non-nested pairs it + reproduces the located coarse cell's polynomial, with the evaluation point + clamped into the cell.""" + cn = np.asarray(coarse._cell_node_indices(degree, continuous)) + fn = np.asarray(fine._cell_node_indices(degree, continuous)) + Xc = (np.asarray(coarse_coords) if coarse_coords is not None + else np.asarray(coarse._get_coords_for_basis(degree, continuous))) + Xf = (np.asarray(fine_coords) if fine_coords is not None + else np.asarray(fine._get_coords_for_basis(degree, continuous))) + vxy_c, cv_c = _cell_vertex_table(coarse) + vxy_f, cv_f = _cell_vertex_table(fine) + owner = _fine_cell_of_node(fn, Xf.shape[0]) + if (owner < 0).any(): + raise RuntimeError("fault_mg: a fine node belongs to no cell") + fcent = vxy_f[cv_f].mean(axis=1) + pcell, _lam_c = locate_in_cells(fcent, vxy_c, cv_c) + cells = pcell[owner] + lam = _barycentric(Xf, vxy_c[cv_c[cells]]) + W = _node_weights(Xf, cells, lam, Xc, cn, vxy_c, cv_c, degree) + nb = W.shape[1] + rows = np.repeat(np.arange(Xf.shape[0]), nb) + cols = cn[cells].ravel() + vals = W.ravel() + keep = np.abs(vals) > 1e-13 + P = sp.csr_matrix((vals[keep], (rows[keep], cols[keep])), + shape=(Xf.shape[0], Xc.shape[0])) + P.sum_duplicates() + return P + + +# --------------------------------------------------------------------------- # +# Parallel transfer +# --------------------------------------------------------------------------- # +def cell_located_transfer_parallel(coarse, fine, degree, continuous, + coarse_coords, fine_coords, coarse_layout, + fine_layout, ncomp, comm): + """The same transfer as :func:`cell_located_prolongation`, reduced->reduced, + as an MPIAIJ matrix. + + Every rank locates its OWNED fine rows in its LOCAL coarse mesh (owned plus + ghost cells, whose nodes carry the owner's global index through the layout). + A fine node whose coarse cell is not local is a MISS: the misses are + all-gathered as (centroid, node position) queries, every rank answers the + ones it finds in its own cells, and the asker takes the best answer (the + largest minimum barycentric coordinate). A query nobody finds — a node outside + every coarse cell, as at a curved boundary — keeps the asker's own clamped + best. ``coarse_layout`` / ``fine_layout`` are :class:`custom_mg.LevelLayout`. + """ + m4 = comm.tompi4py() + cn = np.asarray(coarse._cell_node_indices(degree, continuous)) + fn = np.asarray(fine._cell_node_indices(degree, continuous)) + Xc = np.asarray(coarse_coords) + Xf = np.asarray(fine_coords) + vxy_c, cv_c = _cell_vertex_table(coarse) + vxy_f, cv_f = _cell_vertex_table(fine) + l2g_c = np.asarray(coarse_layout.l2g, dtype=np.int64).reshape(-1, ncomp) + l2g_f = np.asarray(fine_layout.l2g, dtype=np.int64).reshape(-1, ncomp) + fstart, fend = fine_layout.rstart, fine_layout.rend + cstart, cend = coarse_layout.rstart, coarse_layout.rend + n_f = Xf.shape[0] + nb = cn.shape[1] + + owner = _fine_cell_of_node(fn, n_f) + if (owner < 0).any(): + raise RuntimeError("fault_mg: a fine node belongs to no cell") + # the fine nodes with at least one owned row (ghost nodes are another rank's) + mine = np.flatnonzero(((l2g_f >= fstart) & (l2g_f < fend)).any(axis=1)) + fcent = vxy_f[cv_f].mean(axis=1) + pcell, lam_c = (locate_in_cells(fcent[owner[mine]], vxy_c, cv_c) + if cv_c.shape[0] else + (np.full(mine.size, -1), np.full((mine.size, cv_c.shape[1]), -np.inf))) + inside = lam_c.min(axis=1) >= -_INSIDE_TOL + + # --- local hits ------------------------------------------------------- # + hit = mine[inside] + cells = pcell[inside] + gcols = np.empty((0, nb, ncomp), dtype=np.int64) + W = np.empty((0, nb)) + if hit.size: + lam = _barycentric(Xf[hit], vxy_c[cv_c[cells]]) + W = _node_weights(Xf[hit], cells, lam, Xc, cn, vxy_c, cv_c, degree) + gcols = l2g_c[cn[cells]] # (nh, nb, ncomp) + hit_nodes, hit_W, hit_cols = [hit], [W], [gcols] + + # --- misses: ask the other ranks -------------------------------------- # + miss = mine[~inside] + q_cent = fcent[owner[miss]] + q_node = Xf[miss] + all_cent = m4.allgather(q_cent) + all_node = m4.allgather(q_node) + counts = np.array([a.shape[0] for a in all_cent]) + total = int(counts.sum()) + if total: + Qc = (np.vstack(all_cent) if total else + np.zeros((0, Xf.shape[1]))) + Qn = np.vstack(all_node) + offs = np.concatenate([[0], np.cumsum(counts)]) + if cv_c.shape[0]: + qcell, qlam = locate_in_cells(Qc, vxy_c, cv_c) + qin = qlam.min(axis=1) >= -_INSIDE_TOL + else: + qcell = np.full(total, -1) + qlam = np.full((total, cv_c.shape[1]), -np.inf) + qin = np.zeros(total, dtype=bool) + found = np.flatnonzero(qin) + if found.size: + lamn = _barycentric(Qn[found], vxy_c[cv_c[qcell[found]]]) + Wq = _node_weights(Qn[found], qcell[found], lamn, Xc, cn, + vxy_c, cv_c, degree) + colsq = l2g_c[cn[qcell[found]]] + score = qlam[found].min(axis=1) + else: + Wq = np.zeros((0, nb)) + colsq = np.zeros((0, nb, ncomp), dtype=np.int64) + score = np.zeros(0) + # answers travel back to every rank; each keeps those for its own queries + ans_idx = m4.allgather(found) + ans_W = m4.allgather(Wq) + ans_cols = m4.allgather(colsq) + ans_score = m4.allgather(score) + lo, hi = offs[m4.rank], offs[m4.rank + 1] + best_score = np.full(miss.size, -np.inf) + best_W = np.zeros((miss.size, nb)) + best_cols = np.full((miss.size, nb, ncomp), -1, dtype=np.int64) + answered = np.zeros(miss.size, dtype=bool) + for idx, Wa, ca, sa in zip(ans_idx, ans_W, ans_cols, ans_score): + sel = (idx >= lo) & (idx < hi) + if not sel.any(): + continue + local = idx[sel] - lo + better = sa[sel] > best_score[local] + local = local[better] + best_score[local] = sa[sel][better] + best_W[local] = Wa[sel][better] + best_cols[local] = ca[sel][better] + answered[local] = True + # nobody found it: this rank's own clamped best (if it has coarse cells) + rest = np.flatnonzero(~answered) + if rest.size and cv_c.shape[0]: + cells_r = pcell[~inside][rest] + lam_r = _barycentric(Xf[miss[rest]], vxy_c[cv_c[cells_r]]) + best_W[rest] = _node_weights(Xf[miss[rest]], cells_r, lam_r, Xc, cn, + vxy_c, cv_c, degree) + best_cols[rest] = l2g_c[cn[cells_r]] + answered[rest] = True + if not answered.all(): + raise RuntimeError( + "fault_mg: a fine node could not be located in any coarse cell " + "on any rank") + hit_nodes.append(miss) + hit_W.append(best_W) + hit_cols.append(best_cols) + + nodes = np.concatenate(hit_nodes) + Wall = np.vstack(hit_W) + Call = np.concatenate(hit_cols) if len(hit_cols) > 1 else hit_cols[0] + + # --- assemble owned rows ---------------------------------------------- # + nloc_f = fend - fstart + nloc_c = cend - cstart + rows_l, cols_l, vals_l = [], [], [] + for c in range(ncomp): + grow = l2g_f[nodes, c] + own = (grow >= fstart) & (grow < fend) + if not own.any(): + continue + gr = np.repeat(grow[own], nb) + gc = Call[own][:, :, c].ravel() + gv = Wall[own].ravel() + keep = (gc >= 0) & (np.abs(gv) > 1e-13) + rows_l.append(gr[keep] - fstart) + cols_l.append(gc[keep]) + vals_l.append(gv[keep]) + if rows_l: + rows_l = np.concatenate(rows_l) + cols_l = np.concatenate(cols_l) + vals_l = np.concatenate(vals_l) + else: + rows_l = np.zeros(0, dtype=np.int64) + cols_l = np.zeros(0, dtype=np.int64) + vals_l = np.zeros(0) + loc = sp.csr_matrix((vals_l, (rows_l, cols_l)), + shape=(nloc_f, int(m4.allreduce(nloc_c)))) + loc.sum_duplicates() + loc.sort_indices() + P = PETSc.Mat().createAIJ( + size=((nloc_f, None), (nloc_c, None)), + csr=(loc.indptr.astype(PETSc.IntType), loc.indices.astype(PETSc.IntType), + loc.data), comm=comm) + P.assemble() + return P + + +# --------------------------------------------------------------------------- # +# Splitting the add-on levels +# --------------------------------------------------------------------------- # +def split_tail_levels(levels, fault_specs, verbose=False): + """Replay the finest mesh's faults on each add-on level; return the split + levels (same order). ``fault_specs`` is the list ``fault_split.add_fault`` + records on its child: ``("line", name, polyline)`` in 2-D, ``("sheet", name, + points, triangles)`` in 3-D. Each level is cut and split exactly as the + finest was, so its slit lies on the same geometry and the centroid rule of + the transfer assigns sides consistently across levels. A level carries no + tail of its own.""" + from underworld3.utilities.fault_split import add_fault + + entries = [tuple(spec[1:4]) for spec in fault_specs] + kwargs = {} + for spec in fault_specs: + if spec[0] == "sheet" and len(spec) > 4: + kwargs.update(spec[4]) + out = [] + for level in levels: + split = (add_fault(level, entries, verbose=verbose, **kwargs) + if entries else level) + split._custom_mg_coarse_meshes = None + out.append(split) + return out + + +# --------------------------------------------------------------------------- # +# Per-level pair rotation +# --------------------------------------------------------------------------- # +def level_pair_rotation(level, solver, field_id): + """The pair rotation of a split coarse level in the level's reduced + velocity layout: ``(Q, Qt, normal_rows, layout)`` with ``Q`` an AIJ matrix + (identity except one orthogonal ``2 dim`` block per coincident pair, mean rows + on the Plus point and jump rows on the Minus point — the same construction as + ``rotated_bc.build_rotation``) and ``normal_rows`` the global rows constrained + out of the level (the opening jump; on a surface mesh also the jump and mean + along the surface normal). The level's DOF layout is derived from the solver's + discretisation copied onto the level DM (no throwaway solver). Not cached: + the layout depends on the solver's essential conditions, and the rotated + solve already keeps the composed prolongations across its Newton steps.""" + from underworld3.utilities import custom_mg, fault_contact + + cdm = custom_mg._clone_dm_with_solver_discretisation(solver, level) + layout = custom_mg._level_dof_layout(cdm, field_id) + sub = custom_mg._field_subdm(cdm, field_id) + lsec_sub = sub.getLocalSection() + lsec = cdm.getLocalSection() + dim = level.cdim + manifold = level.dim < level.cdim + l2g = np.asarray(layout.l2g, dtype=np.int64) + rstart, rend = layout.rstart, layout.rend + nloc = rend - rstart + comm = cdm.getComm() + N = int(comm.tompi4py().allreduce(nloc)) + + # identity rows, replaced block by block + rows = [np.arange(rstart, rend)] + cols = [np.arange(rstart, rend)] + vals = [np.ones(nloc)] + is_block_row = np.zeros(nloc, dtype=bool) + normal_rows = [] + s2 = 1.0 / np.sqrt(2.0) + radial = fault_contact._radial_fn(level) if manifold else None + problem = None + try: + for fname in level._fault_point_pairs: + pairs = fault_contact._fault_pair_nodes_on( + level, cdm, lsec, fname, field=field_id or 0) + for q_plus, q_minus, nrm in pairs: + frame = fault_contact._pair_frame_on(level, cdm, q_plus, nrm, + radial) + op, om = lsec_sub.getOffset(q_plus), lsec_sub.getOffset(q_minus) + gp = [int(l2g[op + c]) for c in range(dim)] + gm = [int(l2g[om + c]) for c in range(dim)] + if any(g < 0 for g in gp + gm): + continue + owned_p = rstart <= gp[0] < rend + owned_m = rstart <= gm[0] < rend + if owned_p != owned_m: + raise RuntimeError( + "fault_mg: a coincident pair straddles ranks on a " + "coarse level — the split's redistribution should " + "make this impossible.") + if not owned_p: + continue + is_block_row[np.array(gp + gm) - rstart] = True + for i, e in enumerate(frame): + for row, sgn in ((gp[i], 1.0), (gm[i], -1.0)): + rows.append(np.full(2 * dim, row)) + cols.append(np.array(gp + gm)) + vals.append(np.concatenate([s2 * e, sgn * s2 * e])) + normal_rows.append(gm[0]) + if manifold: + normal_rows.append(gm[2]) + normal_rows.append(gp[2]) + except (RuntimeError, ValueError) as err: + problem = str(err) + fault_contact._collective_raise(problem) + + rows = np.concatenate(rows) + cols = np.concatenate(cols) + vals = np.concatenate(vals) + # drop the identity entry of every block row (the first nloc entries) + keep = np.ones(len(rows), dtype=bool) + keep[:nloc] = ~is_block_row + loc = sp.csr_matrix((vals[keep], (rows[keep] - rstart, cols[keep])), + shape=(nloc, N)) + loc.sum_duplicates() + loc.sort_indices() + Q = PETSc.Mat().createAIJ( + size=((nloc, None), (nloc, None)), + csr=(loc.indptr.astype(PETSc.IntType), loc.indices.astype(PETSc.IntType), + loc.data), comm=comm) + Q.assemble() + Qt = Q.transpose(PETSc.Mat()) + return Q, Qt, np.array(sorted(set(normal_rows)), dtype=np.int64), layout + + +# --------------------------------------------------------------------------- # +# Static / add-on levels +# --------------------------------------------------------------------------- # +def addon_level_indices(tail): + """Indices of the ADD-ON levels of a mesh-owned tail — every level that is + not a static background wrap (``_mg_static``). These are the levels a fault + is carried through; the background is never cut.""" + return [k for k, m in enumerate(tail) if not getattr(m, "_mg_static", False)] + + +def restrict_unreached(Ps, comm): + """The prolongation list with every coarse unknown no fine unknown reaches + dropped, fine to coarse, each level's rows restricted to the set the level + above kept. The unrotated twin of :func:`rotated_split_transfers`, for the + standard (non-rotated) install of a hierarchy with split levels.""" + nlev = len(Ps) + 1 + kept = [None] * nlev + out = [None] * (nlev - 1) + for l in range(nlev - 2, -1, -1): + T = Ps[l] + if kept[l + 1] is not None: + T = T.createSubMatrix( + _owned_is(kept[l + 1], comm), + _owned_is(np.arange(*T.getOwnershipRangeColumn()), comm)) + n2 = _column_norms_sq(T) + reached = np.asarray(n2.array) > 0.0 + cols = np.arange(*T.getOwnershipRangeColumn()) + if comm.tompi4py().allreduce(int((~reached).sum())): + T = T.createSubMatrix( + _owned_is(np.arange(*T.getOwnershipRange()), comm), + _owned_is(cols[reached], comm)) + kept[l] = cols[reached] + out[l] = T + return out + + +# --------------------------------------------------------------------------- # +# Composition for the rotated solve +# --------------------------------------------------------------------------- # +def _owned_is(indices, comm): + return PETSc.IS().createGeneral(np.asarray(indices, dtype=PETSc.IntType), + comm=comm) + + +def _column_norms_sq(P): + """Squared 2-norm of every column of ``P``, in the column layout (a Vec).""" + v = P.createVecRight() + v.set(0.0) + _indptr, indices, data = P.getValuesCSR() + if indices.size: + v.setValues(indices.astype(PETSc.IntType), data * data, + addv=PETSc.InsertMode.ADD_VALUES) + v.assemblyBegin() + v.assemblyEnd() + return v + + +def rotated_split_transfers(solver, hierarchy, Ps, Q, normal_rows): + r"""The rotated prolongation list for a hierarchy with split levels. + + Level by level from the finest down: :math:`\hat P_l = Q_{l+1} P_l Q_l^T` + with the finest's opening rows zeroed (its size is the operator's) and every + coarse level restricted to its admissible unknowns — the level's own opening + rows removed, then the unknowns no fine unknown reaches dropped. Welded levels + (the background) have no rotation; their columns are still restricted to the + reached set. Returns the list ``[P_hat_0, ..., P_hat_{n-2}]`` and records the + kept index sets on the hierarchy (``hierarchy.level_kept``).""" + from underworld3.utilities import custom_mg + + levels = hierarchy.level_meshes + nlev = len(levels) + comm = solver.dm.comm + vel_is = solver._subdict["velocity"][0] + vis = np.asarray(vel_is.getIndices()) + Qv = Q.createSubMatrix(vel_is, vel_is) + rstart = Qv.getOwnershipRange()[0] + g2blk = {int(g): rstart + k for k, g in enumerate(vis)} + fine_normal = sorted({g2blk[g] for g in normal_rows if g in g2blk}) + + rot = [None] * nlev + for l in range(nlev - 1): + if is_split_level(levels[l]): + rot[l] = level_pair_rotation(levels[l], solver, hierarchy.field_id) + + kept = [None] * nlev # owned kept global indices per coarse level + out = [None] * (nlev - 1) + for l in range(nlev - 2, -1, -1): + T = Ps[l] + if l + 1 == nlev - 1: + T = Qv.matMult(T) + elif rot[l + 1] is not None: + T = rot[l + 1][0].matMult(T) + if rot[l] is not None: + T = T.matMult(rot[l][1]) + if l + 1 == nlev - 1: + T.zeroRows(fine_normal, diag=0.0) + row_is = None + else: + row_is = _owned_is(kept[l + 1], comm) + # admissible columns at level l: owned, minus the opening rows + cs, ce = T.getOwnershipRangeColumn() + adm = np.arange(cs, ce) + if rot[l] is not None and rot[l][2].size: + adm = np.setdiff1d(adm, rot[l][2]) + col_is = _owned_is(adm, comm) + T = T.createSubMatrix(row_is, col_is) if row_is is not None \ + else T.createSubMatrix(_owned_is(np.arange(*T.getOwnershipRange()), comm), + col_is) + # drop the coarse unknowns no fine unknown reaches + n2 = _column_norms_sq(T) + reached = np.asarray(n2.array) > 0.0 + n_drop = int(comm.tompi4py().allreduce(int((~reached).sum()))) + if n_drop: + adm = adm[reached] + T = T.createSubMatrix( + _owned_is(np.arange(*T.getOwnershipRange()), comm), + _owned_is(np.arange(*T.getOwnershipRangeColumn())[reached], comm)) + kept[l] = adm + out[l] = T + hierarchy.level_kept = kept + # The restriction renumbers every coarse level, so any FAC patch rows the + # builder recorded (keyed to the unrestricted layouts) no longer apply; a + # split fault needs no patch in any case (the keying ruling). + hierarchy.level_patch_rows = [None] * nlev + return out diff --git a/src/underworld3/utilities/fault_split.py b/src/underworld3/utilities/fault_split.py index d652a0cbe..3bfb0c97c 100644 --- a/src/underworld3/utilities/fault_split.py +++ b/src/underworld3/utilities/fault_split.py @@ -1044,7 +1044,7 @@ def replica_new(t): return new, point_map, clone_map -def add_fault(mesh, faults, verbose=False): +def add_fault(mesh, faults, verbose=False, **place_kwargs): """Cut AND split one or more faults into a mesh; return the split Mesh. The one-call form of the split-node pipeline: for each fault, the tips @@ -1069,6 +1069,11 @@ def add_fault(mesh, faults, verbose=False): sizes), the J0 pattern of the deployment design. verbose : bool, optional Report each cut and split. + **place_kwargs + 3-D only: options of :func:`place_surface.place_sheet` (``clearance``, + ``size``, ``setback``). A bisection-refined mesh needs ``clearance`` + of about 0.9 (the default 0.6 leaves a cavity the fill cannot close + on it; the placement's volume guard refuses). Returns ------- @@ -1084,9 +1089,15 @@ def add_fault(mesh, faults, verbose=False): pull_vertex_onto) if isinstance(faults, (Surface, tuple)) or ( - hasattr(faults, "name") and hasattr(faults, "control_points")): + hasattr(faults, "name") and hasattr(faults, "control_points")) or ( + hasattr(faults, "name") and hasattr(faults, "triangles")): faults = [faults] + if mesh.dim == 3: + return _add_fault_sheets(mesh, faults, verbose=verbose, **place_kwargs) + if place_kwargs: + raise TypeError(f"add_fault: {sorted(place_kwargs)} apply to 3-D sheets only") + segments = [] for entry in faults: if isinstance(entry, tuple) and len(entry) == 2 \ @@ -1167,6 +1178,10 @@ def add_fault(mesh, faults, verbose=False): # same thing in 2-D as it does for a 3-D FaultSurface out._fault_traces = {name: np.array(poly, dtype=float) for name, poly in segments} + # the recipe, for replaying the same faults on the add-on multigrid + # levels (utilities.fault_mg.split_tail_levels) + out._fault_specs = [("line", name, np.array(poly, dtype=float)) + for name, poly in segments] surfaces = {entry.name: entry for entry in faults if hasattr(entry, "control_points") and hasattr(entry, "name")} @@ -1175,6 +1190,56 @@ def add_fault(mesh, faults, verbose=False): return out +def _add_fault_sheets(mesh, faults, verbose=False, **place_kwargs): + """The 3-D form of :func:`add_fault`: each fault is a triangulated sheet + (a ``FaultSurface`` or a ``(name, points, triangles)`` triple), placed into + the mesh with :func:`place_surface.place_sheet` so its triangles become + interior faces, then every sheet is split (:func:`split_faults`, which + redistributes the network's cell stars rank-interior first). Like the 2-D + cut, the placed mesh carries NO multigrid tail of its own: the caller + (``Mesh.add_fault``) hands the parent's tail down, with its add-on levels + split the same way.""" + from underworld3.discretisation import Mesh + from underworld3.utilities.place_surface import place_sheet + + sheets = [] + for entry in faults: + if isinstance(entry, tuple) and len(entry) == 3 \ + and isinstance(entry[0], str): + name, points, triangles = entry + else: + name = entry.name + if not getattr(entry, "is_triangulated", True): + entry.triangulate() + points, triangles = entry.points, entry.triangles + sheets.append((name, np.asarray(points, dtype=float), + np.asarray(triangles, dtype=np.int64))) + + cut = mesh + for name, points, triangles in sheets: + boundaries = cut._boundaries_with(name) + cut_dm, info = place_sheet(cut.dm, points, triangles, label=name, + label_value=boundaries[name].value, + verbose=verbose, **place_kwargs) + child = Mesh(cut_dm, simplex=cut.dm.isSimplex(), + coordinate_system_type=cut.CoordinateSystem.coordinate_type, + qdegree=cut.qdegree, boundaries=boundaries, verbose=False) + child.parent = cut + child._relationship_kind = "refinement" + child._refine_dofs_coincide = False + child.regions = cut.regions + child._parent_mesh_version = cut._mesh_version + child._surface_info = info + cut._registered_children.add(child) + cut = child + out = split_faults(cut, [name for name, _p, _t in sheets], verbose=verbose) + out._fault_specs = [("sheet", name, points, triangles, dict(place_kwargs)) + for name, points, triangles in sheets] + out._fault_surfaces = {entry.name: entry for entry in faults + if hasattr(entry, "name") and hasattr(entry, "triangles")} + return out + + def _boundaries_with_sides(mesh, name): """The mesh's boundary enum extended with ``Plus`` / ``Minus``. @@ -1238,7 +1303,7 @@ def _fault_labels_touch_seam(dm, labels): return bool(uw.mpi.comm.allreduce(touch, op=MPI.LOR)) -def _redistribute_fault_interior(dm, labels, verbose=False): +def _redistribute_fault_interior(dm, labels, verbose=False, pairs=None): """Redistribute a cut mesh so each fault's cell star is rank-interior. The default partition's balance cuts are ATTRACTED to the locally @@ -1256,18 +1321,20 @@ def _redistribute_fault_interior(dm, labels, verbose=False): ``(name, value)`` pairs — a NETWORK is redistributed in one move, keyed on the union of its faults, which is what lets every split that follows run without migrating any prior pairing. Returns a - NEW dm; the input is untouched. + NEW dm; the input is untouched. With ``pairs`` (a mesh's + ``_fault_point_pairs``) the pairing is carried through the move and + ``(dm, pairs, n_dropped)`` comes back — see + :func:`redistribute_by_assignment`. """ comm = dm.getComm().tompi4py() if comm.size == 1: - return dm + return (dm, pairs, 0) if pairs is not None else dm - work = dm.clone() - cS, cE = work.getHeightStratum(0) - fS, fE = work.getHeightStratum(1) - vS, vE = work.getDepthStratum(0) - _pS, pEnd = work.getChart() - sf = work.getPointSF() + cS, cE = dm.getHeightStratum(0) + fS, fE = dm.getHeightStratum(1) + vS, vE = dm.getDepthStratum(0) + _pS, pEnd = dm.getChart() + sf = dm.getPointSF() def propagate(mark): # global OR of a chart-length mark across the point SF: remote @@ -1288,7 +1355,7 @@ def star_of_marked(mark): cells = set() for v in range(vS, vE): if mark[v]: - for q in work.getTransitiveClosure(v, useCone=False)[0]: + for q in dm.getTransitiveClosure(v, useCone=False)[0]: if cS <= int(q) < cE: cells.add(int(q)) return cells @@ -1296,12 +1363,12 @@ def star_of_marked(mark): # fault vertices (of every fault in the batch), marked globally mark = np.zeros(pEnd, dtype=np.int32) for name, value in labels: - if work.hasLabel(name) and \ - work.getLabel(name).getStratumSize(int(value)) > 0: - for f in work.getLabel(name).getStratumIS( + if dm.hasLabel(name) and \ + dm.getLabel(name).getStratumSize(int(value)) > 0: + for f in dm.getLabel(name).getStratumIS( int(value)).getIndices(): if fS <= int(f) < fE: - for q in work.getTransitiveClosure(int(f))[0]: + for q in dm.getTransitiveClosure(int(f))[0]: if vS <= int(q) < vE: mark[int(q)] = 1 mark = propagate(mark) @@ -1315,7 +1382,7 @@ def star_of_marked(mark): # incident cells all touch a marked-closure vertex). mark2 = np.zeros(pEnd, dtype=np.int32) for c in star: - for q in work.getTransitiveClosure(c)[0]: + for q in dm.getTransitiveClosure(c)[0]: if vS <= int(q) < vE: mark2[int(q)] = 1 mark2 = propagate(mark2) @@ -1332,20 +1399,112 @@ def star_of_marked(mark): assign = np.full(n_local, comm.rank, dtype=np.int32) for c in star: assign[c - cS] = target - order = np.argsort(assign, kind="stable").astype(np.int32) - sizes = np.bincount(assign, minlength=comm.size).astype(np.int32) - - part = work.getPartitioner() - part.setType(PETSc.Partitioner.Type.SHELL) - part.setShellPartition(comm.size, sizes=sizes, points=order) - work.distribute() + work, new_pairs, dropped = redistribute_by_assignment(dm, assign, pairs=pairs) if verbose: names = sorted(name for name, _value in labels) uw.pprint(f"[fault_split] {names}: star of " f"{int(counts.sum())} cells gathered onto rank " f"{target}; local cells now " f"{work.getHeightStratum(0)[1]}") - return work + return (work, new_pairs, dropped) if pairs is not None else work + + +def _pair_label_names(name): + return f"_pair_minus_{name}", f"_pair_plus_{name}" + + +def stamp_point_pairs(dm, pairs_by_fault): + """Write a coincident-point pairing into two labels per fault (one value + per pair, on its Minus point and on its Plus point) so a redistribution + carries it: labels ride every point migration, where the migration star + forest PETSc returns for an INITIAL distribution does not index the + stratified numbering (measured: three quarters of the points mapped to the + wrong depth). The ids are globally unique (each rank's pairs offset by the + ranks before it). Returns the fault names, for :func:`collect_point_pairs`. + Collective.""" + comm = dm.getComm().tompi4py() + names = sorted(set().union(*comm.allgather(set(pairs_by_fault)))) + for name in names: + pairs = pairs_by_fault.get(name, {}) + counts = comm.allgather(len(pairs)) + offset = int(sum(counts[:comm.rank])) + minus, plus = _pair_label_names(name) + for lab in (minus, plus): + if dm.hasLabel(lab): + dm.removeLabel(lab) + dm.createLabel(lab) + for k, (qm, qp) in enumerate(pairs.items()): + dm.setLabelValue(minus, int(qm), offset + k + 1) + dm.setLabelValue(plus, int(qp), offset + k + 1) + return names + + +def collect_point_pairs(dm, names, remove=True): + """Read back a pairing :func:`stamp_point_pairs` wrote, after a + redistribution: ``(pairs_by_fault, n_dropped)``. A rank keeps every pair + whose two points it holds; ``n_dropped`` counts, over all ranks, the pairs + no single rank holds both sides of — zero once the fault stars are + rank-interior (the split's rule). The labels are removed unless asked + otherwise. Collective.""" + comm = dm.getComm().tompi4py() + out = {} + n_dropped = 0 + for name in names: + minus, plus = _pair_label_names(name) + + def points_by_value(lab_name): + table = {} + if not dm.hasLabel(lab_name): + return table + lab = dm.getLabel(lab_name) + vis = lab.getValueIS() + values = vis.getIndices() if vis is not None else [] + for v in values: + v = int(v) + if lab.getStratumSize(v) > 0: + table[v] = int(lab.getStratumIS(v).getIndices()[0]) + return table + + tm, tp = points_by_value(minus), points_by_value(plus) + common = set(tm) & set(tp) + out[name] = {tm[v]: tp[v] for v in common} + seen = set(tm) | set(tp) + all_seen = set().union(*comm.allgather(seen)) + all_common = set().union(*comm.allgather(common)) + n_dropped += len(all_seen - all_common) + if remove: + for lab in (minus, plus): + if dm.hasLabel(lab): + dm.removeLabel(lab) + return out, n_dropped + + +def redistribute_by_assignment(dm, assign, pairs=None): + """Redistribute ``dm`` so local cell ``k`` goes to rank ``assign[k]``. + Returns ``(new_dm, new_pairs, n_dropped)``: the pairing ``pairs`` (a + mesh's ``_fault_point_pairs``, or None) carried through the move with + :func:`stamp_point_pairs` / :func:`collect_point_pairs`. Nothing moves when + every cell is already where it is asked to be (the input comes back). The + shell-partitioner idiom the fault-interior move and the level co-location + both use.""" + comm = dm.getComm().tompi4py() + assign = np.asarray(assign, dtype=np.int32) + if comm.size == 1 or not comm.allreduce(int((assign != comm.rank).sum())): + return dm, pairs, 0 + work = dm.clone() + names = stamp_point_pairs(work, pairs) if pairs else [] + part = work.getPartitioner() + part.setType(PETSc.Partitioner.Type.SHELL) + order = np.argsort(assign, kind="stable").astype(np.int32) + sizes = np.bincount(assign, minlength=comm.size).astype(np.int32) + part.setShellPartition(comm.size, sizes=sizes, points=order) + work.distribute() + new_pairs, dropped = (collect_point_pairs(work, names) if pairs + else (None, 0)) + for lab in [l for n in names for l in _pair_label_names(n)]: + if dm.hasLabel(lab): + dm.removeLabel(lab) + return work, new_pairs, dropped def split_faults(mesh, names, verbose=False): @@ -1495,6 +1654,8 @@ def split_fault(mesh, name, orientation=None, verbose=False): child._fault_traces = dict(mesh._fault_traces) if getattr(mesh, "_fault_surfaces", None): child._fault_surfaces = dict(mesh._fault_surfaces) + if getattr(mesh, "_fault_specs", None): + child._fault_specs = list(mesh._fault_specs) child.regions = mesh.regions child._parent_mesh_version = mesh._mesh_version # The Minus->Plus point pairing in the SPLIT mesh's numbering, for every diff --git a/src/underworld3/utilities/rotated_bc.py b/src/underworld3/utilities/rotated_bc.py index 40f5f8bb9..921562718 100644 --- a/src/underworld3/utilities/rotated_bc.py +++ b/src/underworld3/utilities/rotated_bc.py @@ -1700,6 +1700,16 @@ def _build_rotated_custom_Pl(solver, Q, normal_rows): h, Ps = resolved if resolved is not None else (None, None) if h is None or Ps is None: return None + if getattr(h, "has_split_levels", False): + # Split-level multigrid: the coarse levels carry the fault too, so each + # takes its own pair rotation and loses its opening rows, and the + # unknowns no fine unknown reaches are dropped (utilities.fault_mg). + from underworld3.utilities.fault_mg import rotated_split_transfers + Pl = rotated_split_transfers(solver, h, Ps, Q, normal_rows) + if getattr(solver, "_custom_mg", None) is None: + solver._custom_mg = {"mode": "hierarchy", "hierarchy": h, + "verbose": False} + return Pl vel_is = solver._subdict["velocity"][0] vis = np.asarray(vel_is.getIndices()) Qv = Q.createSubMatrix(vel_is, vel_is) diff --git a/tests/parallel/test_1089_fault_split_multigrid_parallel.py b/tests/parallel/test_1089_fault_split_multigrid_parallel.py new file mode 100644 index 000000000..e1fdf55e8 --- /dev/null +++ b/tests/parallel/test_1089_fault_split_multigrid_parallel.py @@ -0,0 +1,117 @@ +"""Split-level fault multigrid at any rank count. + +The welded tail under a split finest did not run at np > 1 (found 2026-10-13): +the split gathers the fault's cell star onto one rank, the node-cloud transfer +left hundreds of coarse unknowns with no fine image, their injection repair +duplicated columns, the Galerkin coarse operators went singular and the inode +SOR returned NaN. The cell-located transfer locates every owned fine node in +the local coarse mesh and asks the other ranks for the rest, and unreached +coarse unknowns are dropped, never repaired. Contract: the split-level +hierarchy (and the welded one) converges at np = 2 and 4 in as few velocity +iterations as in serial (6 at k = 2; serial 6, np = 4 measured 7), with the +serial slip. The thick twin of the faulted sheet is extruded in parallel with +its pairing complete and rank-local. + + mpirun -n 2 python -m pytest --with-mpi tests/parallel/test_1089_fault_split_multigrid_parallel.py + mpirun -n 4 python -m pytest --with-mpi tests/parallel/test_1089_fault_split_multigrid_parallel.py +""" + +import numpy as np +import pytest +import sympy + +import underworld3 as uw + +pytestmark = [pytest.mark.mpi(min_size=2), pytest.mark.level_2, pytest.mark.tier_b, + pytest.mark.timeout(1800)] + +TIP_A, TIP_B = np.array([0.30, 0.45]), np.array([0.70, 0.55]) +SERIAL_PEAK_SLIP = 0.159581 # k = 2, frictionless, solve tolerance 1e-6 + + +def _faulted_box(k, split_levels=True): + base = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.1, refinement=1, + qdegree=2) + pts = np.column_stack([np.linspace(TIP_A[0], TIP_B[0], 9), + np.linspace(TIP_A[1], TIP_B[1], 9), np.zeros(9)]) + guide = uw.meshing.Surface("guide", base, pts) + guide.discretize() + child = base.adapt(guide.refinement_metric_function( + h_near=0.05 / 2**k, h_far=0.05, width=0.08, profile="linear"), max_levels=k) + return child.add_fault([("Flt", np.vstack([TIP_A, TIP_B]))], + split_levels=split_levels) + + +def _solve(mesh): + x, y = mesh.X + stokes = uw.systems.Stokes(mesh) + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = 1 + stokes.tolerance = 1.0e-6 + stokes.petsc_use_pressure_nullspace = True + for side in ("Top", "Bottom", "Left", "Right"): + stokes.add_dirichlet_bc((y - 0.5, 0.0), side) + stokes.add_fault_bc(0, "Flt") + stokes.solve() + info = stokes._rotated_freeslip_info + from underworld3.utilities.fault_contact import fault_slip + _s, V, _leak = fault_slip(stokes, "Flt", info) + local = float(np.abs(V).max()) if len(V) else 0.0 + return info, float(uw.mpi.comm.allreduce(local, op=max)) + + +def test_split_levels_converge_like_serial(): + info, slip = _solve(_faulted_box(2)) + assert info["velocity_pc"] == "custom-FMG" and info["converged"], info + assert info["vel_its_last"][-1] <= 9, info["vel_its_last"] + assert abs(slip - SERIAL_PEAK_SLIP) < 1.0e-4 * SERIAL_PEAK_SLIP, slip + + +def test_the_welded_tail_runs_in_parallel_too(): + info, slip = _solve(_faulted_box(2, split_levels=False)) + assert info["velocity_pc"] == "custom-FMG" and info["converged"], info + assert abs(slip - SERIAL_PEAK_SLIP) < 1.0e-4 * SERIAL_PEAK_SLIP, slip + + +def test_the_thick_twin_is_extruded_with_its_pairing(): + """The faulted plate of test_1083, extruded at np > 1: every pair of the + serial layer is present on exactly one rank with its two points coincident, + the layer's levels are split, and the plate solves with geometric + multigrid.""" + base = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.1, refinement=1, + qdegree=3) + sheet = base.add_fault([("Flt", np.array([[0.25, 0.5], [0.75, 0.5]]))]) + layer = sheet.extrude(0.04, layers=2) + pairs = layer._fault_point_pairs["Flt"] + n_pairs = uw.mpi.comm.allreduce(len(pairs)) + assert n_pairs == 261, n_pairs # the serial layer's count + dm = layer.dm + cs, cv = dm.getCoordinateSection(), dm.getCoordinatesLocal().array + vS, vE = dm.getDepthStratum(0) + + def centroid(q): + verts = [int(v) for v in dm.getTransitiveClosure(int(q))[0] if vS <= v < vE] + return np.array([cv[cs.getOffset(v):cs.getOffset(v) + 3] for v in verts]).mean(axis=0) + + gap = max([np.linalg.norm(centroid(a) - centroid(b)) for a, b in pairs.items()] or [0.0]) + assert uw.mpi.comm.allreduce(gap, op=max) < 1.0e-12 + tail = layer._custom_mg_coarse_meshes + assert tail and any(getattr(m, "_fault_point_pairs", None) for m in tail) + + stokes = uw.systems.Stokes(layer) + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = 1 + y = layer.X[1] + stokes.add_basal_drag(1, "Base", reference_velocity=[y - sympy.Rational(1, 2), 0, 0]) + stokes.add_fault_bc(0, "Flt") + stokes.tolerance = 1.0e-5 + stokes.solve() + info = stokes._rotated_freeslip_info + assert info["velocity_pc"] == "custom-FMG" and info["converged"], info + from underworld3.utilities.fault_contact import fault_pair_jumps + _c, jumps, normals = fault_pair_jumps(stokes, "Flt", info, gather=True) + tangential = jumps - np.einsum("ij,ij->i", jumps, normals)[:, None] * normals + assert np.linalg.norm(tangential, axis=1).max() > 1.0e-2 + assert np.abs(np.einsum("ij,ij->i", jumps, normals)).max() < 1.0e-8 diff --git a/tests/test_1087_fault_split_multigrid.py b/tests/test_1087_fault_split_multigrid.py new file mode 100644 index 000000000..7be7c68e6 --- /dev/null +++ b/tests/test_1087_fault_split_multigrid.py @@ -0,0 +1,168 @@ +"""Split-level multigrid for split-node faults (``utilities.fault_mg``). + +``mesh.add_fault`` on an add-on mesh carries the fault through every add-on +level of the inherited multigrid tail and leaves the static background +untouched. The coarse spaces then contain the discontinuity, and the velocity +solve stays mesh-independent as the fault is refined — measured on this box +(2026-10-13): 6, 5 and 4 velocity iterations at k = 2, 3, 4 add-on levels +against 13, 18 and 23 with the welded tail, the peak slip identical to six +figures. Design note: ``docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md``. +""" + +import numpy as np +import pytest + +import underworld3 as uw + +pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] + +TIP_A, TIP_B = np.array([0.30, 0.45]), np.array([0.70, 0.55]) +H_BASE = 0.05 + + +@pytest.fixture(scope="module") +def base(): + return uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.1, refinement=1, + qdegree=2) + + +def _adapted(base, k, centre=(0.5, 0.5)): + """The add-on mesh of ``k`` levels toward the fault, and the fault trace.""" + half = (TIP_B - TIP_A) / 2 + a, b = np.asarray(centre) - half, np.asarray(centre) + half + pts = np.column_stack([np.linspace(a[0], b[0], 9), np.linspace(a[1], b[1], 9), + np.zeros(9)]) + guide = uw.meshing.Surface(f"guide_{k}_{centre[0]:.2f}", base, pts) + guide.discretize() + child = base.adapt(guide.refinement_metric_function( + h_near=H_BASE / 2**k, h_far=H_BASE, width=0.08, profile="linear"), + max_levels=k) + return child, np.vstack([a, b]) + + +def _solve(mesh, law=0): + x, y = mesh.X + stokes = uw.systems.Stokes(mesh) + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = 1 + stokes.tolerance = 1.0e-6 + stokes.petsc_use_pressure_nullspace = True + for side in ("Top", "Bottom", "Left", "Right"): + stokes.add_dirichlet_bc((y - 0.5, 0.0), side) + stokes.add_fault_bc(law, "Flt") + stokes.solve() + info = stokes._rotated_freeslip_info + from underworld3.utilities.fault_contact import fault_slip + _s, V, _leak = fault_slip(stokes, "Flt", info) + return info, float(np.abs(V).max()) + + +def test_the_add_on_levels_are_split_and_the_background_is_not(base): + from underworld3.utilities.fault_mg import is_split_level + + child, trace = _adapted(base, 2) + mesh = child.add_fault([("Flt", trace)]) + tail = mesh._custom_mg_coarse_meshes + background = base._coarse_level_meshes() + assert len(tail) == len(background) + 1 + # the static background levels are the base's own wraps, untouched + for level, wrap in zip(tail, background): + assert level is wrap and not is_split_level(level) + # every add-on level and the finest carry the fault's pairing + assert all(is_split_level(level) for level in tail[len(background):]) + assert is_split_level(mesh) + # the welded tail is still available + welded = child.add_fault([("Flt", trace)], split_levels=False) + assert not any(is_split_level(level) for level in welded._custom_mg_coarse_meshes) + + +def test_iterations_fall_with_refinement_and_the_slip_is_unchanged(base): + its, slips = {}, {} + for k in (2, 3): + child, trace = _adapted(base, k) + info, slip = _solve(child.add_fault([("Flt", trace)])) + assert info["velocity_pc"] == "custom-FMG" and info["converged"], info + its[k] = info["vel_its_last"][-1] + slips[k] = slip + # measured 6 and 5; the welded tail takes 13 and 18 and grows with k + assert its[2] <= 8 and its[3] <= 8, its + assert its[3] <= its[2] + 1, its + child, trace = _adapted(base, 3) + info_w, slip_w = _solve(child.add_fault([("Flt", trace)], split_levels=False)) + assert info_w["vel_its_last"][-1] >= its[3] + 6, (info_w["vel_its_last"], its) + assert abs(slip_w - slips[3]) < 1.0e-4 * slip_w, (slip_w, slips[3]) + + +def test_a_viscous_fault_solves_in_as_few_iterations(base): + child, trace = _adapted(base, 3) + info, slip = _solve(child.add_fault([("Flt", trace)]), law=1.0) + assert info["converged"] and info["vel_its_last"][-1] <= 8, info["vel_its_last"] + assert slip > 0.1 + + +def test_a_moving_fault_re_splits_the_levels_at_every_position(base): + for cx in (0.35, 0.5, 0.65): + child, trace = _adapted(base, 2, centre=(cx, 0.5)) + mesh = child.add_fault([("Flt", trace)]) + info, slip = _solve(mesh) + assert info["converged"] and info["vel_its_last"][-1] <= 8, (cx, info["vel_its_last"]) + assert slip > 0.1, (cx, slip) + + +def test_the_cell_located_transfer_keeps_each_side_on_its_own_side(base): + """A coarse field equal to +1 on one side of the fault and -1 on the other + prolongs to the same values on the fine level, at the slit nodes included: a + node on the slit interpolates from its own side only. The side of a node is + that of a cell listing it — a slit node is listed only by its own side's + cells — so no pairing orientation is assumed. Nodes within two coarse cells of + a tip are left out (the tip vertex is shared by both sides; measured, the + disagreement reaches 0.033 from a tip on this hierarchy). And a quadratic + field is reproduced exactly between the split levels.""" + from underworld3.utilities.fault_mg import cell_located_prolongation + + child, trace = _adapted(base, 2) + mesh = child.add_fault([("Flt", trace)]) + coarse = mesh._custom_mg_coarse_meshes[-1] + assert getattr(coarse, "_fault_point_pairs", None) + degree, continuous = 2, True + P = cell_located_prolongation(coarse, mesh, degree, continuous) + Xc = np.asarray(coarse._get_coords_for_basis(degree, continuous)) + Xf = np.asarray(mesh._get_coords_for_basis(degree, continuous)) + assert P.shape == (Xf.shape[0], Xc.shape[0]) + # partition of unity, bounded weights (the clamp) + assert np.abs(np.asarray(P.sum(axis=1)).ravel() - 1.0).max() < 1.0e-10 + assert np.abs(P.data).max() <= 1.0 + 1.0e-10 + + a, b = trace + n = np.array([-(b - a)[1], (b - a)[0]]) + n /= np.linalg.norm(n) + + def side_by_cell(m, X): + cn = np.asarray(m._cell_node_indices(degree, continuous)) + cen = X[cn].mean(axis=1) # P2 node centroid = cell centroid + side = np.zeros(X.shape[0]) + for k in range(cn.shape[0]): + side[cn[k]] = np.sign((cen[k] - a) @ n) + return side + + fc = side_by_cell(coarse, Xc) + ff = P @ fc + expected = side_by_cell(mesh, Xf) + away_from_tips = np.minimum(np.linalg.norm(Xf - a, axis=1), + np.linalg.norm(Xf - b, axis=1)) > 0.05 + on_slit = np.abs((Xf - a) @ n) < 1.0e-10 + assert (on_slit & away_from_tips).sum() > 20 + assert np.abs(ff - expected)[away_from_tips].max() < 1.0e-10 + + # the levels are nested away from the trace (one adapt call's bisections), + # so a quadratic is reproduced exactly there; within a coarse cell or two of + # the trace each cut pulls vertices onto it, the levels are not nested, and + # the clamp bounds the weights at the cost of exactness (measured: 0.018 at + # worst, 3e-4 at 0.03 from the trace, 3e-14 beyond 0.04) + q = lambda X: (1.0 + 2.0 * X[:, 0] - X[:, 1] + 3.0 * X[:, 0]**2 - X[:, 0] * X[:, 1] + + 0.5 * X[:, 1]**2) + err = np.abs(P @ q(Xc) - q(Xf)) + off_trace = np.abs((Xf - a) @ n) > 0.05 + assert err[off_trace].max() < 1.0e-9 + assert err.max() < 0.05 From 348fbb5a0ef135869b9a458c77123f7e6219f113 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Tue, 13 Oct 2026 16:05:26 +1100 Subject: [PATCH 36/57] Session bootstrap: check open and merged PRs before any investigation Repeated work has come from investigating without looking at what is in flight: the fault multigrid session of 2026-10-13 re-diagnosed parallel fault defects that PR #672, open for weeks, had already fixed, and then paid a seven-file merge. The bootstrap now requires fetching development and reading the open and recently merged PRs on the subsystem before an investigation, a branch or a design decision, naming them in the opening report, basing the work on current development, and raising a related PR that has sat open for more than about a week as a finding at the start. The branching guide says the same for feature branches. Underworld development team with AI support from Claude Code --- CLAUDE.md | 18 ++++++++++++++++++ docs/developer/guides/branching-strategy.md | 9 +++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 30cd06d59..95432ad1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,24 @@ fenced block for display, LaTeX not unicode). Private cross-project context, when a session has it, still arrives through `UW_AI_TOOLS_PATH` (colon-separated directories of `.md` files; set via `./uw setup`). +**Pending work, before anything else.** Repeated work has come from investigating +without looking at what is already in flight. Before an investigation, a new branch +or a design decision, and again when a related PR merges mid-session: + +```bash +git fetch origin development +git log --oneline --merges -20 origin/development # what merged recently +``` + +plus the open PRs (GitHub MCP `list_pull_requests`, or `gh pr list --state open`). Read +every open or recently merged PR that touches the subsystem before writing a line, name +them in the opening report, and base the work on current development (branch from it, +or merge it into the base branch first). A related PR open for more than about a week +is itself a finding: say so at the start and ask whether to merge it, build on it, or +wait. Do not rely on the maintainer noticing the overlap. (Case: the fault multigrid +session of 2026-10-13 re-diagnosed parallel fault defects that PR #672, open for +weeks, had already fixed, then paid a seven-file merge.) + **Inline TODOs** mark the *place*; the roadmap item or issue tracks the *work*: ```python diff --git a/docs/developer/guides/branching-strategy.md b/docs/developer/guides/branching-strategy.md index 6d3323612..eb3e1c332 100644 --- a/docs/developer/guides/branching-strategy.md +++ b/docs/developer/guides/branching-strategy.md @@ -51,9 +51,14 @@ feature/Z ────────────────────┘ ### `feature/*` — long-lived feature work -- Branch from `development`. PR back to `development` when ready. +- Branch from `development`, **current** `development`: fetch first, and read the open + and recently merged PRs that touch the subsystem before starting (the session + bootstrap in `CLAUDE.md` spells out the check). A branch cut from a stale base or + from another feature branch re-does work that is already in a PR. +- PR back to `development` when ready. - Reviewed by human + Copilot. CI runs on the PR. -- Periodically incorporate changes from `development` (merge or rebase, developer's choice). +- Incorporate `development` whenever a related PR merges, not only periodically + (merge or rebase, developer's choice). - No direct API changes — see next section. ### Retired: `uw3-release-candidate` From 40349a6435347a25bd378c73547dcd7b0c64ff01 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Tue, 13 Oct 2026 16:50:30 +1100 Subject: [PATCH 37/57] Co-locate the split levels' fault stars with the finest's; parallel load measurement Each level's split gathers its own fault star onto the rank holding most of it, which differs from level to level, so a transfer between stars on different ranks located most of the fault band remotely (measured: 48 % of the finest nodes at np = 2, 58 % at np = 8). After the replay every level's star is moved onto the rank holding the finest's star, the pairing carried as labels; the misses fall to the band's rim (2 to 6 %). The parallel transfer records its hit rate per level on the hierarchy. scripts/sessions/fault_mg: parallel_load.py (owned cells and star per rank on every level, misses, stage times), and the figure scripts for the wiki page and the technical note (strategy schematics, the transfer rule, the pipeline, the rendered hierarchy, iterations against refinement, the moving-fault cost, the parallel load). Underworld development team with AI support from Claude Code --- .../sessions/fault_mg/fig_fmg_behaviour.py | 103 ++++++++++ .../sessions/fault_mg/fig_hierarchy_render.py | 85 ++++++++ .../sessions/fault_mg/fig_parallel_load.py | 104 ++++++++++ scripts/sessions/fault_mg/fig_schematics.py | 187 ++++++++++++++++++ scripts/sessions/fault_mg/parallel_load.py | 142 +++++++++++++ .../discretisation/discretisation_mesh.py | 9 + src/underworld3/utilities/custom_mg.py | 3 + src/underworld3/utilities/fault_mg.py | 94 +++++++++ 8 files changed, 727 insertions(+) create mode 100644 scripts/sessions/fault_mg/fig_fmg_behaviour.py create mode 100644 scripts/sessions/fault_mg/fig_hierarchy_render.py create mode 100644 scripts/sessions/fault_mg/fig_parallel_load.py create mode 100644 scripts/sessions/fault_mg/fig_schematics.py create mode 100644 scripts/sessions/fault_mg/parallel_load.py diff --git a/scripts/sessions/fault_mg/fig_fmg_behaviour.py b/scripts/sessions/fault_mg/fig_fmg_behaviour.py new file mode 100644 index 000000000..14b66d2af --- /dev/null +++ b/scripts/sessions/fault_mg/fig_fmg_behaviour.py @@ -0,0 +1,103 @@ +"""FMG behaviour plots from the measured tables (exploration note and the library +runs of 2026-10-13): iterations against refinement for the four strategies, the +Coulomb Newton sequence, and the moving-fault cost breakdown. + +python fig_fmg_behaviour.py +""" +import os +import sys + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np + +out = sys.argv[1] if len(sys.argv) > 1 else "." +os.makedirs(out, exist_ok=True) +k = np.array([1, 2, 3, 4, 5]) +h = 0.05 / 2.0**k +# exploration (prototype drivers, FAULT_MULTIGRID_HIERARCHIES.md) +fric = {"A": [9, 13, 18, 23, 32], "B2": [10, 14, 22, 30, 46], "C": [10, 7, 7, 6, 5], + "D": [14, 9, 6, 6, 5]} +visc = {"A": [9, 12, 17, 22, 30], "B2": [9, 19, 53, 200, 200], "C": [9, 7, 6, 6, 5], + "D": [17, 9, 8, 7, 6]} +# library path (tests/test_1087, scripts/sessions/fault_mg/library_c.py), serial +lib = {"C": {2: 6, 3: 5, 4: 4}, "A": {2: 13, 3: 18, 4: 23}} +col = {"A": "#2c3e50", "B2": "#7f8c8d", "C": "#c0392b", "D": "#e67e22"} + +fig, axes = plt.subplots(1, 3, figsize=(14, 4.3)) +for ax, data, title in ((axes[0], fric, "frictionless fault"), (axes[1], visc, "viscous fault")): + for name, its in data.items(): + its = np.array(its, dtype=float) + capped = its >= 200 + ax.plot(h, np.where(capped, np.nan, its), "o-", color=col[name], label=name) + if capped.any(): + ax.plot(h[capped], np.full(capped.sum(), 60), "x", color=col[name], ms=9, mew=2) + ax.text(h[capped][0], 64, "cap (200)", fontsize=7.5, color=col[name], ha="center") + if title.startswith("friction"): + for name in ("A", "C"): + kk = np.array(sorted(lib[name])) + ax.plot(0.05 / 2.0**kk, [lib[name][q] for q in kk], "s", mfc="none", ms=9, + color=col[name], label=f"{name} (library)") + ax.set_xscale("log") + ax.invert_xaxis() + ax.set_xlabel("near-fault cell size h") + ax.set_ylabel("velocity iterations per solve") + ax.set_title(title, loc="left", fontsize=10) + ax.set_ylim(0, 70) + ax.grid(alpha=0.3) + ax.legend(fontsize=8) +ax = axes[2] +steps = np.arange(1, 5) +coul = {"A k=1": [5, 15, 17, 17], "C k=1": [5, 15, 17, 17], "A k=3": [9, 21, 24, 24], + "C k=3": [4, 14, 15, 16], "A k=5": [15, 38, 45, 50], "C k=5": [3, 13, 15, 15]} +for name, its in coul.items(): + ax.plot(steps, its, "o-" if name.startswith("C") else "s--", + color=col[name[0]], alpha={1: 0.4, 3: 0.7, 5: 1.0}[int(name[-1])], label=name) +ax.set_xlabel("Newton step") +ax.set_ylabel("velocity iterations") +ax.set_title("Coulomb fault, per Newton step", loc="left", fontsize=10) +ax.set_xticks(steps) +ax.grid(alpha=0.3) +ax.legend(fontsize=8, ncol=2) +fig.suptitle("Iterations of the velocity block (FMG F-cycle preconditioner, FGMRES) as the fault is " + "refined: box in simple shear, straight fault", fontsize=10) +fig.tight_layout() +fig.savefig(os.path.join(out, "fmg_iterations_vs_h.png"), dpi=160) +plt.close(fig) + +# moving fault, k = 5, seconds per step (exploration), and k = 4 library at np 1 / 8 +fig, axes = plt.subplots(1, 2, figsize=(11, 4.2)) +ax = axes[0] +stages = ["adapt", "split finest", "split levels", "level frames", "background TI", "solve"] +rows = {"A": [3.1, 1.9, 0, 0, 0, 137], "C": [2.7, 1.7, 2.8, 1.0, 0, 38], + "D": [4.7, 2.9, 5.1, 1.8, 2.4, 61], "GAMG": [3.8, 2.4, 0, 0, 0, 127]} +cmap = plt.get_cmap("tab20c") +bottom = np.zeros(len(rows)) +names = list(rows) +for i, st in enumerate(stages): + vals = np.array([rows[n][i] for n in names]) + ax.bar(names, vals, bottom=bottom, color=cmap(i * 2), label=st) + bottom += vals +for j, n in enumerate(names): + ax.text(j, bottom[j] + 2, f"{bottom[j]:.0f} s", ha="center", fontsize=8.5) +ax.set_ylabel("seconds per step (prototype drivers, k = 5, 1 rank)") +ax.set_title("moving fault: cost per step by stage", loc="left", fontsize=10) +ax.legend(fontsize=8) +ax = axes[1] +lib_mov = {"1 rank": {"adapt": 2.9, "cut + split all levels": 4.6, "solver build": 0.5, "solve": 28.6}, + "8 ranks": {"adapt": 1.0, "cut + split all levels": 1.8, "solver build": 0.45, "solve": 12.4}} +bottom = np.zeros(2) +for i, st in enumerate(lib_mov["1 rank"]): + vals = np.array([lib_mov[n][st] for n in lib_mov]) + ax.bar(list(lib_mov), vals, bottom=bottom, color=cmap(i * 2 + 1), label=st) + bottom += vals +for j, n in enumerate(lib_mov): + ax.text(j, bottom[j] + 0.5, f"{bottom[j]:.0f} s", ha="center", fontsize=8.5) +ax.set_ylabel("seconds per step (library, C, k = 4)") +ax.set_title("library path, mean of three positions", loc="left", fontsize=10) +ax.legend(fontsize=8) +fig.tight_layout() +fig.savefig(os.path.join(out, "moving_fault_cost.png"), dpi=160) +plt.close(fig) +print("ok") diff --git a/scripts/sessions/fault_mg/fig_hierarchy_render.py b/scripts/sessions/fault_mg/fig_hierarchy_render.py new file mode 100644 index 000000000..3f2b68034 --- /dev/null +++ b/scripts/sessions/fault_mg/fig_hierarchy_render.py @@ -0,0 +1,85 @@ +"""Render the actual split-level hierarchy near the fault: one panel per level, +coarsest to finest, mesh edges with the Plus and Minus facets in two colours +(the slit opened by a small offset so the two copies are visible). + +python fig_hierarchy_render.py +""" +import sys + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.collections import LineCollection + +import underworld3 as uw + +k = int(sys.argv[1]) +out = sys.argv[2] +A_, B_ = np.array([0.30, 0.45]), np.array([0.70, 0.55]) +base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), cellSize=0.1, + refinement=1, qdegree=2) +pts3 = np.column_stack([np.linspace(A_[0], B_[0], 9), np.linspace(A_[1], B_[1], 9), np.zeros(9)]) +surf = uw.meshing.Surface("g", base, pts3) +surf.discretize() +child = base.adapt(surf.refinement_metric_function(h_near=0.05 / 2**k, h_far=0.05, width=0.08, + profile="linear"), max_levels=k) +mesh = child.add_fault([("Flt", np.vstack([A_, B_]))]) +levels = list(mesh._custom_mg_coarse_meshes) + [mesh] + + +def edges_of(m, offset): + """all edges as segments; fault-side facets returned separately, shifted + by ``offset`` along the fault normal so the two copies separate""" + dm = m.dm + eS, eE = dm.getDepthStratum(1) + vS, vE = dm.getDepthStratum(0) + X = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, 2) + n = np.array([-(B_ - A_)[1], (B_ - A_)[0]]) + n /= np.linalg.norm(n) + sides = {} + for side, sgn in (("Plus", 1.0), ("Minus", -1.0)): + name = f"Flt{side}" + if name in [b.name for b in m.boundaries]: + lab = dm.getLabel(name) + value = m.boundaries[name].value + if lab and lab.getStratumSize(value) > 0: + sides[side] = (set(int(e) for e in lab.getStratumIS(value).getIndices() + if eS <= int(e) < eE), sgn) + segs, fsegs = [], {"Plus": [], "Minus": []} + for e in range(eS, eE): + a, b = (int(q) - vS for q in dm.getCone(e)) + seg = np.array([X[a], X[b]]) + placed = False + for side, (es, sgn) in sides.items(): + if e in es: + fsegs[side].append(seg + sgn * offset * n) + placed = True + if not placed: + segs.append(seg) + return segs, fsegs + + +fig, axes = plt.subplots(1, len(levels), figsize=(3.2 * len(levels), 3.6)) +for ax, m, li in zip(np.atleast_1d(axes), levels, range(len(levels))): + segs, fsegs = edges_of(m, offset=0.004) + ax.add_collection(LineCollection(segs, colors="#777", linewidths=0.5)) + for side, col in (("Plus", "#c0392b"), ("Minus", "#1a5276")): + if fsegs[side]: + ax.add_collection(LineCollection(fsegs[side], colors=col, linewidths=1.8)) + static = getattr(m, "_mg_static", False) + split = bool(getattr(m, "_fault_point_pairs", None)) + cells = m.dm.getHeightStratum(0)[1] + kind = "background" if static else ("finest" if m is mesh else "add-on") + ax.set_title(f"level {li}: {kind}\n{cells} cells, {'split' if split else 'welded'}", + fontsize=9) + ax.set_xlim(0.22, 0.78) + ax.set_ylim(0.32, 0.68) + ax.set_aspect("equal") + ax.set_xticks([]) + ax.set_yticks([]) +fig.suptitle(f"Split-level hierarchy (C), k = {k}: red Plus and blue Minus facets drawn " + "0.004 apart; the background is never cut", fontsize=10) +fig.tight_layout() +fig.savefig(out, dpi=170) +print("wrote", out) diff --git a/scripts/sessions/fault_mg/fig_parallel_load.py b/scripts/sessions/fault_mg/fig_parallel_load.py new file mode 100644 index 000000000..412225a73 --- /dev/null +++ b/scripts/sessions/fault_mg/fig_parallel_load.py @@ -0,0 +1,104 @@ +"""Parallel behaviour plots from parallel_load.py's records. + +python fig_parallel_load.py (reads ~/+Simulations/fault_split_mg/load_k_np*.json) +""" +import glob +import json +import os +import sys + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np + +k = int(sys.argv[1]) +out = sys.argv[2] +os.makedirs(out, exist_ok=True) +runs = {} +for f in glob.glob(os.path.expanduser(f"~/+Simulations/fault_split_mg/load_k{k}_np*.json")): + rec = json.load(open(f))[0] + runs[rec["np"]] = rec +nps = sorted(runs) +print("ranks:", nps) + +# 1. owned cells per rank on every level, for the largest two rank counts +big = [n for n in nps if n > 1][-2:] +fig, axes = plt.subplots(1, len(big), figsize=(6.2 * len(big), 4.4), squeeze=False) +for ax, n in zip(axes[0], big): + rec = runs[n] + load = rec["load"] + nlev = len(load) + width = 0.8 / n + for r in range(n): + own = [lv["owned_cells"][r] for lv in load] + star = [lv["fault_star"][r] for lv in load] + x = np.arange(nlev) + (r - (n - 1) / 2) * width + ax.bar(x, own, width, color=plt.get_cmap("tab10")(r), label=f"rank {r}") + ax.bar(x, star, width, color="none", edgecolor="k", hatch="///", lw=0.5, + label="fault star" if r == 0 else None) + ideal = [sum(lv["owned_cells"]) / n for lv in load] + ax.plot(np.arange(nlev), ideal, "k_", ms=14, label="perfect balance") + labels = [("bg" if lv["static"] else ("finest" if i == nlev - 1 else "add-on")) + + ("\nsplit" if lv["split"] else "") for i, lv in enumerate(load)] + ax.set_xticks(np.arange(nlev)) + ax.set_xticklabels(labels, fontsize=8) + ax.set_ylabel("owned cells") + ax.set_title(f"{n} ranks, k = {k}: cells per rank on each level", loc="left", fontsize=10) + ax.legend(fontsize=7.5, ncol=2) +fig.tight_layout() +fig.savefig(os.path.join(out, f"parallel_load_k{k}.png"), dpi=160) +plt.close(fig) + +# 2. imbalance (max / mean owned cells) per level vs ranks; misses; iterations; times +fig, axes = plt.subplots(1, 3, figsize=(14, 4.2)) +ax = axes[0] +for n in nps: + if n == 1: + continue + load = runs[n]["load"] + imb = [max(lv["owned_cells"]) / (sum(lv["owned_cells"]) / n) for lv in load] + ax.plot(range(len(load)), imb, "o-", label=f"{n} ranks") + child = runs[n]["load_unsplit_child"] + ax.plot([len(load) - 1], [max(child) / (sum(child) / n)], "k_", ms=12) +ax.axhline(1.0, color="k", lw=0.6) +ax.set_xlabel("level (0 = coarsest background)") +ax.set_ylabel("max / mean owned cells") +ax.set_title("imbalance per level (dash: unsplit adapt child)", loc="left", fontsize=10) +ax.legend(fontsize=8) +ax.grid(alpha=0.3) +ax = axes[1] +for n in nps: + st = runs[n].get("transfer_stats") or [] + xs, ys = [], [] + for li, s in enumerate(st): + if s: + xs.append(li) + ys.append(100.0 * s["misses"] / max(s["owned_nodes"], 1)) + if xs: + ax.plot(xs, ys, "o-", label=f"{n} ranks") +ax.set_xlabel("level (fine side of the transfer)") +ax.set_ylabel("fine nodes not located locally (%)") +ax.set_title("transfer misses answered by other ranks", loc="left", fontsize=10) +ax.legend(fontsize=8) +ax.grid(alpha=0.3) +ax = axes[2] +stages = ["adapt", "split_all_levels", "solver_build", "transfers", "solve_total"] +labels = ["adapt", "cut + split all levels", "solver build", "transfer build (in solve)", + "solve total"] +for st, lab in zip(stages, labels): + ax.plot(nps, [runs[n]["times"].get(st, np.nan) for n in nps], "o-", label=lab) +ax.set_xscale("log", base=2) +ax.set_xticks(nps) +ax.set_xticklabels([str(n) for n in nps]) +ax.set_xlabel("ranks") +ax.set_ylabel("seconds") +its = [runs[n]["vel_its"][-1] for n in nps] +ax.set_title("stage times; velocity its " + ", ".join(f"{n}:{i}" for n, i in zip(nps, its)), + loc="left", fontsize=9) +ax.legend(fontsize=8) +ax.grid(alpha=0.3) +fig.tight_layout() +fig.savefig(os.path.join(out, f"parallel_behaviour_k{k}.png"), dpi=160) +plt.close(fig) +print("ok") diff --git a/scripts/sessions/fault_mg/fig_schematics.py b/scripts/sessions/fault_mg/fig_schematics.py new file mode 100644 index 000000000..ed111fb61 --- /dev/null +++ b/scripts/sessions/fault_mg/fig_schematics.py @@ -0,0 +1,187 @@ +"""Schematic figures for the fault multigrid write-up (wiki and technical note). + +1. hierarchy_strategies.png — the four hierarchies A, B2, C, D as level stacks +2. transfer_rule.png — the side-aware cell-located transfer and the clamp +3. parallel_pipeline.png — what moves where in parallel (split, levels, extrusion) + +python fig_schematics.py +""" +import os +import sys + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.patches import FancyBboxPatch, Polygon + +out = sys.argv[1] if len(sys.argv) > 1 else "." +os.makedirs(out, exist_ok=True) +BG, ADD, FINE = "#dfe6ee", "#f6e3b4", "#f2c2b0" +SPLIT, TI, WELD = "#c0392b", "#7f8c8d", "#2c3e50" + + +def level_box(ax, y, kind, label, fault, width=3.6, x0=0.2): + """one multigrid level: a box, a fault symbol, a label""" + face = {"bg": BG, "add": ADD, "fine": FINE}[kind] + ax.add_patch(FancyBboxPatch((x0, y), width, 0.72, boxstyle="round,pad=0.02", + fc=face, ec="#555", lw=0.8)) + xc = x0 + width * 0.62 + if fault == "split": + ax.plot([xc - 0.9, xc + 0.9], [y + 0.40, y + 0.40], color=SPLIT, lw=2.2) + ax.plot([xc - 0.9, xc + 0.9], [y + 0.30, y + 0.30], color=SPLIT, lw=2.2) + ax.plot([xc - 0.9, xc - 0.9], [y + 0.30, y + 0.40], color=SPLIT, lw=1.0) + ax.plot([xc + 0.9, xc + 0.9], [y + 0.30, y + 0.40], color=SPLIT, lw=1.0) + elif fault == "ti": + ax.add_patch(Polygon([[xc - 0.95, y + 0.18], [xc + 0.95, y + 0.18], + [xc + 0.95, y + 0.54], [xc - 0.95, y + 0.54]], + closed=True, fc=TI, alpha=0.35, ec="none")) + ax.plot([xc - 0.9, xc + 0.9], [y + 0.36, y + 0.36], color=TI, lw=1.2, ls="--") + else: + ax.plot([xc - 0.9, xc + 0.9], [y + 0.36, y + 0.36], color=WELD, lw=1.0, ls=":") + ax.text(x0 + 0.12, y + 0.36, label, va="center", ha="left", fontsize=8.5) + + +def strategies(): + cols = [ + ("A welded tail", ["split", "weld", "weld", "weld", "weld"]), + ("B2 TI band on the add-on levels", ["split", "ti", "ti", "weld", "weld"]), + ("C split add-on levels", ["split", "split", "split", "weld", "weld"]), + ("D C + TI background", ["split", "split", "split", "ti", "ti"]), + ] + names = ["finest (add-on)", "add-on 2", "add-on 1", "background 1", "background 0"] + kinds = ["fine", "add", "add", "bg", "bg"] + fig, axes = plt.subplots(1, 4, figsize=(13.5, 4.6)) + for ax, (title, faults) in zip(axes, cols): + for i, (nm, kd, f) in enumerate(zip(names, kinds, faults)): + level_box(ax, 4.0 - i * 0.95, kd, nm, f) + ax.set_xlim(0, 4.0) + ax.set_ylim(-0.2, 5.0) + ax.set_title(title, fontsize=10.5, loc="left") + ax.axis("off") + # legend + ax = axes[0] + ax.plot([], [], color=SPLIT, lw=2.2, label="split slit (two coincident node sets)") + ax.plot([], [], color=TI, lw=1.2, ls="--", label="transversely isotropic weak band") + ax.plot([], [], color=WELD, lw=1.0, ls=":", label="welded (no fault)") + fig.legend(loc="lower center", ncol=3, fontsize=9, frameon=False, + bbox_to_anchor=(0.5, -0.01)) + fig.text(0.01, 0.97, "The background levels (grey) are the static base hierarchy and are " + "never cut; the add-on levels (yellow) are refined from it toward the fault.", + fontsize=9, va="top") + fig.tight_layout(rect=(0, 0.05, 1, 0.95)) + fig.savefig(os.path.join(out, "hierarchy_strategies.png"), dpi=160) + plt.close(fig) + + +def transfer_rule(): + fig, axes = plt.subplots(1, 2, figsize=(11, 4.6)) + # left: coarse split cells, a fine node on the slit, its own-side cell centroid + ax = axes[0] + # fault along y = 0 (slit drawn as a gap); coarse triangles above and below + up = [np.array([[0, 0], [2, 0], [1, 1.6]]), np.array([[2, 0], [4, 0], [3, 1.6]]), + np.array([[1, 1.6], [2, 0], [3, 1.6]])] + dn = [np.array([[0, 0], [2, 0], [1, -1.6]]), np.array([[2, 0], [4, 0], [3, -1.6]]), + np.array([[1, -1.6], [2, 0], [3, -1.6]])] + gap = 0.08 + for t in up: + ax.add_patch(Polygon(t + [0, gap], closed=True, fc="#eaf2fb", ec="#444", lw=0.9)) + for t in dn: + ax.add_patch(Polygon(t - [0, gap], closed=True, fc="#fdf2e9", ec="#444", lw=0.9)) + ax.plot([0, 4], [gap, gap], color=SPLIT, lw=2.2) + ax.plot([0, 4], [-gap, -gap], color=SPLIT, lw=2.2) + # a fine cell on the upper side touching the slit, its centroid + ft = np.array([[1.4, gap], [2.0, gap], [1.7, 0.55]]) + ax.add_patch(Polygon(ft, closed=True, fc="none", ec="#1a5276", lw=1.6, ls="-")) + c = ft.mean(axis=0) + ax.plot(*c, "o", color="#1a5276", ms=6) + ax.plot(1.4, gap, "s", color="#1a5276", ms=7) + ax.annotate("fine node on the slit\n(upper copy)", (1.4, gap), (0.2, 0.9), fontsize=8.5, + arrowprops=dict(arrowstyle="->", lw=0.8)) + ax.annotate("fine cell's centroid:\nnever on the fault,\nlocates the coarse cell\n" + "on the node's own side", c, (2.6, 1.0), fontsize=8.5, + arrowprops=dict(arrowstyle="->", lw=0.8)) + ax.text(2.0, -1.0, "the lower copy of the same node\nis located from a lower-side cell", + ha="center", fontsize=8.5, color="#7b3f00") + ax.set_xlim(-0.2, 4.4) + ax.set_ylim(-1.9, 2.0) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title("side-aware location", loc="left", fontsize=10.5) + # right: the clamp + ax = axes[1] + T = np.array([[0.3, 0.2], [3.2, 0.4], [1.6, 2.3]]) + ax.add_patch(Polygon(T, closed=True, fc="#eef5ee", ec="#444", lw=1.0)) + nodes = np.vstack([T, (T[[0, 1]]).mean(0), (T[[1, 2]]).mean(0), (T[[2, 0]]).mean(0)]) + ax.plot(nodes[:, 0], nodes[:, 1], "o", color="#2e7d32", ms=5) + p_out = np.array([3.0, 1.55]) + # nearest point of the triangle edge (1,2) + a, b = T[1], T[2] + t = np.clip(((p_out - a) @ (b - a)) / ((b - a) @ (b - a)), 0, 1) + q = a + t * (b - a) + ax.plot(*p_out, "s", color="#1a5276", ms=7) + ax.plot(*q, "x", color=SPLIT, ms=9, mew=2) + ax.annotate("", q, p_out, arrowprops=dict(arrowstyle="->", color=SPLIT, lw=1.2)) + ax.text(3.05, 1.65, "fine node a little outside\nits located coarse cell\n" + "(levels not nested near the trace)", fontsize=8.5, va="bottom") + ax.text(1.0, -0.35, "evaluate the coarse Lagrange interpolant at the clamped point:\n" + r"barycentric coordinates clipped to $[0,1]$ and renormalised; every weight stays" + "\nwithin the basis on the simplex (no extrapolation, no 5.4 weights at a tip)", + fontsize=8.5, va="top") + ax.text(0.9, 1.9, "P2 nodes of the\ncoarse cell", fontsize=8.5, color="#2e7d32") + ax.set_xlim(-0.2, 5.4) + ax.set_ylim(-1.3, 2.7) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title("the clamp", loc="left", fontsize=10.5) + fig.suptitle(r"The transfer: $W = B\,M^{-1}$ — the coarse cell's own nodes determine its " + "interpolant; no node-cloud triangulation (which degenerates on a slit)", + fontsize=10) + fig.tight_layout() + fig.savefig(os.path.join(out, "transfer_rule.png"), dpi=160) + plt.close(fig) + + +def parallel_pipeline(): + fig, ax = plt.subplots(figsize=(12.5, 5.2)) + ax.axis("off") + boxes = [ + (0.3, 3.4, "base mesh\n(distributed, static)", BG), + (3.2, 3.4, "adapt: add-on levels\n(in place, co-partitioned\nwith the base)", ADD), + (6.1, 3.4, "cut + split, finest\n(fault star gathered to\none rank, per region)", FINE), + (6.1, 1.4, "cut + split, each add-on level\n(its own star gathered;\npairing rank-local)", + ADD), + (9.0, 3.4, "transfers\n(rank-local location; misses\nasked of the other ranks)", "#e8daef"), + (9.0, 1.4, "per-level pair frames\n(rank-local pair blocks)", "#e8daef"), + (3.2, 1.4, "extrude (thick twin)\n(built on one rank, distributed,\nstars re-gathered, " + "pairing as labels)", "#d5f5e3"), + ] + for x, y, txt, fc in boxes: + ax.add_patch(FancyBboxPatch((x, y), 2.6, 1.3, boxstyle="round,pad=0.03", fc=fc, + ec="#555", lw=0.8)) + ax.text(x + 1.3, y + 0.65, txt, ha="center", va="center", fontsize=8.5) + def arrow(x0, y0, x1, y1): + ax.annotate("", (x1, y1), (x0, y0), arrowprops=dict(arrowstyle="->", lw=1.0)) + arrow(2.9, 4.05, 3.2, 4.05) + arrow(5.8, 4.05, 6.1, 4.05) + arrow(5.8, 3.6, 6.1, 2.3) + arrow(8.7, 4.05, 9.0, 4.05) + arrow(8.7, 2.05, 9.0, 2.05) + arrow(8.7, 2.3, 9.0, 3.6) + arrow(7.4, 3.4, 5.8, 2.7) + ax.text(0.3, 0.55, "Load: everything stays load-balanced except the fault stars (a layer " + "about two cells thick around each fault, on every level),\nwhich sit on the rank " + "holding most of the star. No communication inside the contact solve; the " + "imbalance is bounded by the stars, not by the refined band.", + fontsize=9, va="top") + ax.set_xlim(0, 12.2) + ax.set_ylim(-0.2, 5.0) + fig.tight_layout() + fig.savefig(os.path.join(out, "parallel_pipeline.png"), dpi=160) + plt.close(fig) + + +strategies() +transfer_rule() +parallel_pipeline() +print("figures in", out) diff --git a/scripts/sessions/fault_mg/parallel_load.py b/scripts/sessions/fault_mg/parallel_load.py new file mode 100644 index 000000000..9684ef0fc --- /dev/null +++ b/scripts/sessions/fault_mg/parallel_load.py @@ -0,0 +1,142 @@ +"""Where the work sits in parallel: per-rank cells on every level of the split-level +hierarchy, the fault star's share, the transfer's rank-local hit rate, and the +time of each stage, at any rank count. + +mpirun -np N python parallel_load.py [positions=1] [base_cell=0.1] +""" +import json +import os +import sys +import time + +import numpy as np +import underworld3 as uw +from mpi4py import MPI + +k = int(sys.argv[1]) +positions = int(sys.argv[2]) if len(sys.argv) > 2 else 1 +base_cell = float(sys.argv[3]) if len(sys.argv) > 3 else 0.1 +comm = uw.mpi.comm +HALF = np.array([0.20, 0.05]) +h_base = base_cell / 2 + + +def owned_cells(mesh): + dm = mesh.dm + cS, cE = dm.getHeightStratum(0) + try: + _n, ileaf, _r = dm.getPointSF().getGraph() + ghost = set(int(q) for q in ileaf if cS <= q < cE) + except (ValueError, TypeError): + ghost = set() + return cE - cS - len(ghost) + + +def fault_star_cells(mesh, name): + """cells incident to a vertex of the fault's Plus or Minus facets (local).""" + dm = mesh.dm + cS, cE = dm.getHeightStratum(0) + fS, fE = dm.getHeightStratum(1) + vS, vE = dm.getDepthStratum(0) + verts = set() + for side in ("Plus", "Minus"): + lab = dm.getLabel(f"{name}{side}") + value = mesh.boundaries[f"{name}{side}"].value + if lab and lab.getStratumSize(value) > 0: + for f in lab.getStratumIS(value).getIndices(): + if fS <= int(f) < fE: + verts.update(int(q) for q in dm.getTransitiveClosure(int(f))[0] + if vS <= int(q) < vE) + cells = set() + for v in verts: + cells.update(int(q) for q in dm.getTransitiveClosure(v, useCone=False)[0] + if cS <= int(q) < cE) + return len(cells) + + +base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), + cellSize=base_cell, refinement=1, qdegree=2) +records = [] +centres = np.linspace(0.3, 0.7, positions) if positions > 1 else [0.5] +for step, cx in enumerate(centres): + T = {} + A_ = np.array([cx, 0.5]) - HALF + B_ = np.array([cx, 0.5]) + HALF + trace = np.vstack([A_, B_]) + t0 = time.perf_counter() + pts3 = np.column_stack([np.linspace(A_[0], B_[0], 9), np.linspace(A_[1], B_[1], 9), + np.zeros(9)]) + surf = uw.meshing.Surface(f"g{step}", base, pts3) + surf.discretize() + child = base.adapt(surf.refinement_metric_function(h_near=h_base / 2**k, h_far=h_base, + width=0.08, profile="linear"), + max_levels=k) + T["adapt"] = time.perf_counter() - t0 + t0 = time.perf_counter() + unsplit_finest = child.add_fault([("Flt", trace)], split_levels=False) + T["split_finest_only"] = time.perf_counter() - t0 + t0 = time.perf_counter() + finest = child.add_fault([("Flt", trace)]) + T["split_all_levels"] = time.perf_counter() - t0 + tail = finest._custom_mg_coarse_meshes + levels = list(tail) + [finest] + load = [] + for li, m in enumerate(levels): + own = comm.allgather(owned_cells(m)) + split = bool(getattr(m, "_fault_point_pairs", None)) + star = comm.allgather(fault_star_cells(m, "Flt") if split else 0) + load.append({"level": li, "split": split, "owned_cells": own, "fault_star": star, + "static": bool(getattr(m, "_mg_static", False))}) + # the unsplit adapt child's balance, for comparison + load_child = comm.allgather(owned_cells(child)) + + x, y = finest.X + s = uw.systems.Stokes(finest) + s.constitutive_model = uw.constitutive_models.ViscousFlowModel + s.constitutive_model.Parameters.shear_viscosity_0 = 1.0 + s.tolerance = 1e-6 + s.petsc_use_pressure_nullspace = True + for side in ("Top", "Bottom", "Left", "Right"): + s.add_dirichlet_bc((y - 0.5, 0.0), side) + s.add_fault_bc(0, "Flt") + t0 = time.perf_counter() + s._build() + T["solver_build"] = time.perf_counter() - t0 + from underworld3.utilities import custom_mg, rotated_bc + _orig_build = custom_mg.CustomMGHierarchy.build + _orig_pl = rotated_bc._build_rotated_custom_Pl + + def timed_build(self, solver): + t = time.perf_counter() + out = _orig_build(self, solver) + T["transfers"] = T.get("transfers", 0.0) + time.perf_counter() - t + T["_stats"] = self.level_transfer_stats + return out + + def timed_pl(solver, Q, normal_rows): + t = time.perf_counter() + out = _orig_pl(solver, Q, normal_rows) + T["prolongations_total"] = T.get("prolongations_total", 0.0) + time.perf_counter() - t + return out + custom_mg.CustomMGHierarchy.build = timed_build + rotated_bc._build_rotated_custom_Pl = timed_pl + t0 = time.perf_counter() + s.solve() + T["solve_total"] = time.perf_counter() - t0 + custom_mg.CustomMGHierarchy.build = _orig_build + rotated_bc._build_rotated_custom_Pl = _orig_pl + info = s._rotated_freeslip_info + stats = T.pop("_stats", None) + rec = {"np": comm.size, "k": k, "step": step, "cx": float(cx), + "cells_total": int(sum(load[-1]["owned_cells"])), + "levels": len(levels), "pc": info["velocity_pc"], "vel_its": info["vel_its_last"], + "converged": bool(info["converged"]), "load": load, "load_unsplit_child": load_child, + "transfer_stats": stats, + "times": {kk: round(v, 2) for kk, v in T.items()}} + records.append(rec) + uw.pprint("STEP " + json.dumps({kk: v for kk, v in rec.items() if kk != "load"})) +tag = "" if base_cell == 0.1 else f"_b{base_cell}" +out = os.path.expanduser(f"~/+Simulations/fault_split_mg/load_k{k}{tag}_np{comm.size}.json") +if comm.rank == 0: + json.dump(records, open(out, "w"), indent=1) +uw.pprint("DONE " + out) diff --git a/src/underworld3/discretisation/discretisation_mesh.py b/src/underworld3/discretisation/discretisation_mesh.py index 728e4eea9..74467aa3c 100644 --- a/src/underworld3/discretisation/discretisation_mesh.py +++ b/src/underworld3/discretisation/discretisation_mesh.py @@ -8390,6 +8390,15 @@ def add_fault(self, faults, verbose=False, cut=True, exclude=None, split = split_tail_levels([tail[k] for k in idx], child._fault_specs, verbose=verbose) + if self.dm.getComm().getSize() > 1: + # each level's star onto the rank holding the finest's, + # so the transfers locate the band rank-locally + from underworld3.utilities.fault_mg import ( + colocate_split_level, fault_star_owner) + target = fault_star_owner(child) + split = [colocate_split_level(m, target) + if getattr(m, "_fault_point_pairs", None) else m + for m in split] for k, m in zip(idx, split): tail[k] = m child._custom_mg_coarse_meshes = tail diff --git a/src/underworld3/utilities/custom_mg.py b/src/underworld3/utilities/custom_mg.py index 226b5f924..e6f9192ab 100644 --- a/src/underworld3/utilities/custom_mg.py +++ b/src/underworld3/utilities/custom_mg.py @@ -1586,6 +1586,8 @@ def build(self, solver): # patch is read off its own transfer ``Ps[l-1]``, in the level # operator's row numbering (global rows in parallel, #670). self.level_patch_rows = [None] * nlev + # per level, the parallel cell-located transfer's hit-rate record + self.level_transfer_stats = [None] * nlev comm = solver.dm.comm from underworld3.utilities import fault_mg for l in range(1, nlev): @@ -1609,6 +1611,7 @@ def build(self, solver): self.level_meshes[l - 1], self.level_meshes[l], degree, continuous, coords[l - 1], coords[l], maps[l - 1], maps[l], nc, comm) + self.level_transfer_stats[l] = fault_mg.last_transfer_stats else: Pn = fault_mg.cell_located_prolongation( self.level_meshes[l - 1], self.level_meshes[l], degree, diff --git a/src/underworld3/utilities/fault_mg.py b/src/underworld3/utilities/fault_mg.py index a96e78659..3395ea5c6 100644 --- a/src/underworld3/utilities/fault_mg.py +++ b/src/underworld3/utilities/fault_mg.py @@ -49,6 +49,9 @@ _INSIDE_TOL = 1e-8 # Chunk size for the batched weight evaluation (bounds the (n, nb, nb) temporaries). _CHUNK = 1 << 16 +# The last parallel transfer's hit-rate record (see cell_located_transfer_parallel); +# CustomMGHierarchy.build copies it per level into ``level_transfer_stats``. +last_transfer_stats = None # --------------------------------------------------------------------------- # @@ -274,6 +277,14 @@ def cell_located_transfer_parallel(coarse, fine, degree, continuous, # --- misses: ask the other ranks -------------------------------------- # miss = mine[~inside] + # a record of the rank-local hit rate, for the hierarchy's report + global last_transfer_stats + last_transfer_stats = { + "owned_nodes": int(m4.allreduce(int(mine.size))), + "misses": int(m4.allreduce(int(miss.size))), + "local_coarse_cells": m4.allgather(int(cv_c.shape[0])), + "local_fine_cells": m4.allgather(int(cv_f.shape[0])), + } q_cent = fcent[owner[miss]] q_node = Xf[miss] all_cent = m4.allgather(q_cent) @@ -431,6 +442,89 @@ def split_tail_levels(levels, fault_specs, verbose=False): return out +def _fault_star_cells(mesh): + """The local cells of every split fault's star on ``mesh``: cells incident to a + vertex of a Plus or Minus facet, plus one growth layer (the same two-layer + rule as the split's own redistribution, so a moved star stays + rank-interior). Returns local cell indices (``cS``-based).""" + dm = mesh.dm + cS, cE = dm.getHeightStratum(0) + fS, fE = dm.getHeightStratum(1) + vS, vE = dm.getDepthStratum(0) + names = [b.name for b in mesh.boundaries] + verts = set() + for name in getattr(mesh, "_fault_point_pairs", {}): + for side in ("Plus", "Minus"): + lab_name = f"{name}{side}" + if lab_name not in names: + continue + lab = dm.getLabel(lab_name) + value = mesh.boundaries[lab_name].value + if lab and lab.getStratumSize(value) > 0: + for f in lab.getStratumIS(value).getIndices(): + if fS <= int(f) < fE: + verts.update(int(q) for q in dm.getTransitiveClosure(int(f))[0] + if vS <= int(q) < vE) + + def star(vs): + cells = set() + for v in vs: + cells.update(int(q) for q in dm.getTransitiveClosure(v, useCone=False)[0] + if cS <= int(q) < cE) + return cells + + cells = star(verts) + ring = set() + for c in cells: + ring.update(int(q) for q in dm.getTransitiveClosure(c)[0] if vS <= int(q) < vE) + cells |= star(ring) + return np.array(sorted(c - cS for c in cells), dtype=np.int64) + + +def fault_star_owner(mesh): + """The rank holding most of the split faults' star on ``mesh`` (collective).""" + comm = mesh.dm.getComm().tompi4py() + counts = np.asarray(comm.allgather(int(_fault_star_cells(mesh).size))) + return int(np.argmax(counts)) + + +def colocate_split_level(level, target): + """Move a split level's fault star (two layers, as the split gathers it) onto + rank ``target`` — the rank holding the FINEST level's star — carrying the + pairing through as labels. The split gathers each level's star onto the + rank already holding most of it, which differs from level to level, and a + transfer between stars on different ranks locates most of the fault-band + nodes remotely (measured: 48 % of the finest nodes at np = 2, 58 % at + np = 8). With the stars on one rank the misses are the band's rim only. + Returns ``level`` itself when nothing moves. Collective.""" + from underworld3.utilities.fault_split import redistribute_by_assignment + + comm = level.dm.getComm().tompi4py() + if comm.size == 1: + return level + cS, cE = level.dm.getHeightStratum(0) + assign = np.full(cE - cS, comm.rank, dtype=np.int32) + assign[_fault_star_cells(level)] = target + dm, pairs, dropped = redistribute_by_assignment( + level.dm, assign, pairs=dict(level._fault_point_pairs)) + if dm is level.dm: + return level + if dropped: + raise RuntimeError( + f"fault_mg: {dropped} pair(s) lost co-locating a level's fault star") + moved = uw.discretisation.Mesh( + dm, simplex=level.dm.isSimplex(), qdegree=level.qdegree, + coordinate_system_type=level.CoordinateSystem.coordinate_type, + boundaries=level.boundaries, verbose=False) + moved._fault_point_pairs = pairs + for attr in ("_fault_traces", "_fault_specs", "_fault_surfaces", "_fault_seam_normals"): + if getattr(level, attr, None) is not None: + setattr(moved, attr, getattr(level, attr)) + moved._custom_mg_coarse_meshes = None + moved._mg_static = False + return moved + + # --------------------------------------------------------------------------- # # Per-level pair rotation # --------------------------------------------------------------------------- # From 49a3fa4fad59fa3c365f38583670457b0702a7a8 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Tue, 13 Oct 2026 17:00:25 +1100 Subject: [PATCH 38/57] Fault multigrid figures: the two embeddings, and a tag for the larger load case Underworld development team with AI support from Claude Code --- .../sessions/fault_mg/fig_parallel_load.py | 11 ++-- scripts/sessions/fault_mg/fig_schematics.py | 51 +++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/scripts/sessions/fault_mg/fig_parallel_load.py b/scripts/sessions/fault_mg/fig_parallel_load.py index 412225a73..d62092124 100644 --- a/scripts/sessions/fault_mg/fig_parallel_load.py +++ b/scripts/sessions/fault_mg/fig_parallel_load.py @@ -1,6 +1,6 @@ """Parallel behaviour plots from parallel_load.py's records. -python fig_parallel_load.py (reads ~/+Simulations/fault_split_mg/load_k_np*.json) +python fig_parallel_load.py [tag] (reads ~/+Simulations/fault_split_mg/load_k_np*.json) """ import glob import json @@ -14,9 +14,10 @@ k = int(sys.argv[1]) out = sys.argv[2] +tag = sys.argv[3] if len(sys.argv) > 3 else "" os.makedirs(out, exist_ok=True) runs = {} -for f in glob.glob(os.path.expanduser(f"~/+Simulations/fault_split_mg/load_k{k}_np*.json")): +for f in glob.glob(os.path.expanduser(f"~/+Simulations/fault_split_mg/load_k{k}{tag}_np*.json")): rec = json.load(open(f))[0] runs[rec["np"]] = rec nps = sorted(runs) @@ -44,10 +45,10 @@ ax.set_xticks(np.arange(nlev)) ax.set_xticklabels(labels, fontsize=8) ax.set_ylabel("owned cells") - ax.set_title(f"{n} ranks, k = {k}: cells per rank on each level", loc="left", fontsize=10) + ax.set_title(f"{n} ranks, k = {k}{tag}: cells per rank on each level", loc="left", fontsize=10) ax.legend(fontsize=7.5, ncol=2) fig.tight_layout() -fig.savefig(os.path.join(out, f"parallel_load_k{k}.png"), dpi=160) +fig.savefig(os.path.join(out, f"parallel_load_k{k}{tag}.png"), dpi=160) plt.close(fig) # 2. imbalance (max / mean owned cells) per level vs ranks; misses; iterations; times @@ -99,6 +100,6 @@ ax.legend(fontsize=8) ax.grid(alpha=0.3) fig.tight_layout() -fig.savefig(os.path.join(out, f"parallel_behaviour_k{k}.png"), dpi=160) +fig.savefig(os.path.join(out, f"parallel_behaviour_k{k}{tag}.png"), dpi=160) plt.close(fig) print("ok") diff --git a/scripts/sessions/fault_mg/fig_schematics.py b/scripts/sessions/fault_mg/fig_schematics.py index ed111fb61..a4fd73319 100644 --- a/scripts/sessions/fault_mg/fig_schematics.py +++ b/scripts/sessions/fault_mg/fig_schematics.py @@ -185,3 +185,54 @@ def arrow(x0, y0, x1, y1): transfer_rule() parallel_pipeline() print("figures in", out) + + +def embedding_routes(): + """The two embeddings of a fault and what each does for multigrid: the + cut (where C lives) and the placed ladder band (welded tail + FAC patch), + and the proposed meeting point: a 2:1 sub-sampled ladder split on every + level.""" + fig, axes = plt.subplots(1, 3, figsize=(15, 5.2)) + names = ["finest", "add-on 2", "add-on 1", "background 1", "background 0"] + kinds = ["fine", "add", "add", "bg", "bg"] + + def column(ax, title, faults, bands, notes): + for i, (nm, kd, f, b) in enumerate(zip(names, kinds, faults, bands)): + y = 4.0 - i * 0.95 + level_box(ax, y, kd, nm, f) + if b: + xc = 0.2 + 3.6 * 0.62 + ax.add_patch(Polygon([[xc - 1.05, y + 0.12], [xc + 1.05, y + 0.12], + [xc + 1.05, y + 0.60], [xc - 1.05, y + 0.60]], + closed=True, fc="none", ec="#2e7d32", lw=1.3, ls="-")) + ax.set_xlim(0, 4.0) + ax.set_ylim(-1.3, 5.0) + ax.set_title(title, fontsize=10.5, loc="left") + ax.text(0.2, -0.25, notes, fontsize=8.5, va="top") + ax.axis("off") + + column(axes[0], "the cut (C, in the library)", + ["split", "split", "split", "weld", "weld"], [False] * 5, + "adapt refines toward the trace; the trace is\ncut into the existing edges " + "(vertices pulled onto it)\nand split, on the finest AND on every add-on\nlevel. " + "Whole-level smoothing. Welded background.") + column(axes[1], "the ladder band (#629 / #672, in the library)", + ["split", "weld", "weld", "weld", "weld"], [True, False, False, False, False], + "a structured band (rails + exact spine, green)\nis placed into the finest by " + "carve-and-fill;\nthe spine is split without cutting (cut=False).\nWelded tail; " + "the finest smoother gains an ASM\npatch (LU blocks) over the band's cells.") + column(axes[2], "proposed: C on the ladder", + ["split", "split", "split", "weld", "weld"], [True, True, True, False, False], + "the ladder sub-sampled 2:1 shares every spine\nvertex between levels: placed " + "and split on each\nadd-on level, the slits NEST (no clamp needed).\nThe FAC patch " + "can stay on the finest. Not built;\nthe no-mid measurements had welded mids only.") + axes[0].plot([], [], color=SPLIT, lw=2.2, label="split slit") + axes[0].plot([], [], color="#2e7d32", lw=1.3, label="placed ladder band (cell label)") + axes[0].plot([], [], color=WELD, lw=1.0, ls=":", label="welded") + fig.legend(loc="lower center", ncol=3, fontsize=9, frameon=False, bbox_to_anchor=(0.5, -0.01)) + fig.tight_layout(rect=(0, 0.05, 1, 1)) + fig.savefig(os.path.join(out, "embedding_routes.png"), dpi=160) + plt.close(fig) + + +embedding_routes() From 03d2d4ebe4d7785adf684532224530b91c5a5c1e Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Tue, 13 Oct 2026 17:26:33 +1100 Subject: [PATCH 39/57] Drop unreached coarse unknowns on every hierarchy; the injection repair is retired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The nearest-node injection that repaired a coarse unknown with no fine image put a weight-1 entry on a fine row that already carried one: two identical columns, a singular Galerkin coarse operator. Measured on the composed ladder rig (test_1022's stack with two mid ribbons): four orphans on the welded-to-finest transfer, NaN on the first cycle, with a split finest and with an unsplit TI finest alike; and 512 duplicated columns at np = 2 on the welded tail under a split fault. Every custom-P hierarchy now drops such unknowns at install (restrict_unreached), on the standard and the rotated path; the coarse levels' FAC patch rows are cleared by the renumbering, the finest's kept. The repair and its zero-column asserts are removed. scripts/sessions/fault_mg/ladder_c.py: the ladder measured — the mid ribbons split, welded or absent at two depths, and the TI realisation on the same nested ladders (recorded on the wiki, Fault multigrid). Serial: 77 tests pass across the custom-P, adapt, ribbon and fault files; parallel: the fault, rotated-row, seam-split and single-field FMG tests at np = 2. Underworld development team with AI support from Claude Code --- .../subsystems/fault-split-multigrid.md | 9 +- scripts/sessions/fault_mg/ladder_c.py | 166 +++++++++++++++++ src/underworld3/utilities/custom_mg.py | 169 ++---------------- src/underworld3/utilities/rotated_bc.py | 8 + 4 files changed, 199 insertions(+), 153 deletions(-) create mode 100644 scripts/sessions/fault_mg/ladder_c.py diff --git a/docs/developer/subsystems/fault-split-multigrid.md b/docs/developer/subsystems/fault-split-multigrid.md index 7ff2cb7e3..747ac5e6c 100644 --- a/docs/developer/subsystems/fault-split-multigrid.md +++ b/docs/developer/subsystems/fault-split-multigrid.md @@ -54,8 +54,13 @@ surface mesh, the jump and mean along the surface normal). **Unreached unknowns dropped** (`rotated_split_transfers`, `restrict_unreached`). A coarse unknown no fine unknown reaches (a few near the tips) is a null mode of the Galerkin coarse operator. It is removed from the level, fine to coarse, never repaired -by injection: the injection duplicated columns and made the coarse operators singular -in parallel. +by injection. This is now the rule on every custom-P hierarchy, split or not +(`CustomMGHierarchy.drop_orphans`): the nearest-node injection put a weight-1 entry +on a fine row that already carried one, which is two identical columns and a singular +coarse operator. Measured: NaN on a two-mid placed-ladder chain in serial (four +orphans, with and without a split finest) and 512 duplicated columns at np = 2. The +coarse levels' FAC patch rows do not survive the renumbering and are cleared; the +finest's are unchanged. ## Parallel diff --git a/scripts/sessions/fault_mg/ladder_c.py b/scripts/sessions/fault_mg/ladder_c.py new file mode 100644 index 000000000..b43c05cba --- /dev/null +++ b/scripts/sessions/fault_mg/ladder_c.py @@ -0,0 +1,166 @@ +"""C on the ladder: the composed ribbon rig of test_1022 with the mid ribbon(s) +SPLIT, against the recorded welded-mid and no-mid tails, at two depths; and the +TI realisation on the same nested ladders. + +The finest level is a placed transfinite ribbon (spacing S) with the spine cut +and split (or, for TI, left whole with the band's footprint painted +transversely isotropic). The mid levels are the same ribbon at 2:1 and 4:1 +spacing, welded or split on the same spine vertices. Base: L0 + L1. + +python ladder_c.py +""" +import json +import os +import sys +import time + +import numpy as np +import sympy +import underworld3 as uw +from underworld3.utilities import fault_contact +from underworld3.utilities.custom_mg import set_custom_fmg +from underworld3.utilities.place_surface import place_thin_volume + +out = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/+Simulations/fault_split_mg") +os.makedirs(out, exist_ok=True) +H_BG = 1.0 / 8 +WIDTH = 0.06 +TRACE = np.array([[0.15, 0.42], [0.85, 0.58]]) +TIP = 0.04 # the cut stops this far inside the band, every level +FAULT = "Main" +a, b = TRACE[0], TRACE[-1] +t_hat = (b - a) / np.linalg.norm(b - a) +L = float(np.linalg.norm(b - a)) +n_hat = np.array([-t_hat[1], t_hat[0]]) + + +def banded(base, size): + for clearance in (0.55, 0.7, 0.9): + try: + dm, _info = place_thin_volume( + base.dm, [TRACE], width=WIDTH, label="Zone", label_value=31, + size=size, clearance=clearance, assembly="fuse", mesher="ladder") + return uw.discretisation.Mesh( + dm, simplex=True, qdegree=2, boundaries=base.boundaries, + coordinate_system_type=base.CoordinateSystem.coordinate_type) + except RuntimeError: + continue + raise RuntimeError(f"band build failed at size {size}") + + +def spine_line(size): + """the cut, sampled at the ladder's own rung vertices, tips TIP inside""" + n_along = max(2, int(round(L / size))) + step = L / n_along + k0 = int(round(TIP / step)) + return a + (np.arange(k0, n_along - k0 + 1) * step)[:, None] * t_hat + + +def split_ribbon(mesh, size): + return mesh.add_fault((FAULT, spine_line(size)), split_levels=False) + + +def contact_stokes(mesh, tag): + x, y = mesh.X + v = uw.discretisation.MeshVariable(f"v{tag}", mesh, 2, degree=2) + p = uw.discretisation.MeshVariable(f"p{tag}", mesh, 1, degree=1, continuous=True) + s = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + s.constitutive_model = uw.constitutive_models.ViscousFlowModel + s.constitutive_model.Parameters.shear_viscosity_0 = 1.0 + s.bodyforce = [0.0, 0.0] + for wall in ("Bottom", "Top", "Left", "Right"): + s.add_dirichlet_bc((y - 0.5, 0.0), wall) + s.add_fault_bc(0, boundary=FAULT) + s.petsc_use_pressure_nullspace = True + s.tolerance = 1e-5 + s.strategy = "robust" + return s + + +def ti_stokes(mesh, tag, contrast=1e-3): + x, y = mesh.X + v = uw.discretisation.MeshVariable(f"v{tag}", mesh, 2, degree=2) + p = uw.discretisation.MeshVariable(f"p{tag}", mesh, 1, degree=1, continuous=True) + zone = uw.discretisation.MeshVariable(f"zone{tag}", mesh, 1, degree=0, continuous=False) + mask = mesh.cells_labelled("Zone", 31) + zone.array[:, 0, 0] = mask.astype(float) + s = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + s.constitutive_model = uw.constitutive_models.TransverseIsotropicFlowModel + s.constitutive_model.Parameters.shear_viscosity_0 = 1.0 + s.constitutive_model.Parameters.shear_viscosity_1 = 1.0 - (1.0 - contrast) * zone.sym[0] + s.constitutive_model.Parameters.director = sympy.Matrix([[float(n_hat[0]), float(n_hat[1])]]) + s.bodyforce = [0.0, 0.0] + for wall in ("Bottom", "Top", "Left", "Right"): + s.add_dirichlet_bc((y - 0.5, 0.0), wall) + s.petsc_use_pressure_nullspace = True + s.tolerance = 1e-5 + s.strategy = "robust" + return s, mask + + +def chain_density(velpc): + out = [] + for l in range(velpc.getMGLevels()): + A = velpc.getMGSmoother(l).getOperators()[0] + out.append(round(A.getInfo()["nz_used"] / max(A.getSize()[0], 1), 1)) + return out + + +records = [] +base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), cellSize=H_BG, + regular=False, qdegree=2, refinement=1) +L01 = base._coarse_level_meshes()[:-1] + [base] +ribbons = {} +for size in (0.04, 0.02, 0.01, 0.005): + t0 = time.perf_counter() + ribbons[size] = banded(base, size) + print(f"ribbon {size}: {ribbons[size].dm.getHeightStratum(0)[1]} cells " + f"({time.perf_counter() - t0:.1f} s)", flush=True) +split = {size: split_ribbon(m, size) for size, m in ribbons.items()} + +arms = [] +for s_fine, mids in ((0.01, [0.02]), (0.005, [0.02, 0.01])): + arms.append((f"fine {s_fine}: no mid", s_fine, [], "contact")) + arms.append((f"fine {s_fine}: welded mid {mids}", s_fine, [ribbons[m] for m in mids], "contact")) + arms.append((f"fine {s_fine}: split mid {mids}", s_fine, [split[m] for m in mids], "contact")) +for s_fine, mids in ((0.01, [0.02]), (0.005, [0.02, 0.01])): + arms.append((f"TI fine {s_fine}: no mid", s_fine, [], "ti")) + arms.append((f"TI fine {s_fine}: welded mid {mids}", s_fine, [ribbons[m] for m in mids], "ti")) + +for k, (name, s_fine, mids, kind) in enumerate(arms): + tail = L01 + mids + t0 = time.perf_counter() + if kind == "contact": + mesh = split[s_fine] + stokes = contact_stokes(mesh, f"c{k}") + set_custom_fmg(stokes, tail, field_id=0) + info = fault_contact.solve_with_fault(stokes) + ctx = (getattr(stokes, "_rotated_linear_cache", None) or {})["ctx"] + velpc = ctx["ksp"].getPC().getFieldSplitSubKSP()[0].getPC() + _c, jumps, normals = fault_contact.fault_pair_jumps(stokes, FAULT, stokes._rotated_freeslip_info) + jn = np.einsum("ij,ij->i", jumps, normals) + rec = {"arm": name, "kind": kind, "vel_its": ctx.get("vel_its_last"), + "pres_its": ctx.get("pres_its_last"), "converged": bool(info.get("converged")), + "velocity_pc": velpc.getType(), "nnz_per_row": chain_density(velpc), + "slip": float(np.linalg.norm(jumps - jn[:, None] * normals, axis=1).max()), + "leak": float(np.abs(jn).max()), + "levels": len(tail) + 1, "cells": int(mesh.dm.getHeightStratum(0)[1]), + "t_solve": round(time.perf_counter() - t0, 1)} + else: + mesh = ribbons[s_fine] + stokes, mask = ti_stokes(mesh, f"t{k}") + set_custom_fmg(stokes, tail, field_id=0, fac_zone=mask) + stokes.solve() + ksp = stokes.snes.getKSP() + velksp = ksp.getPC().getFieldSplitSubKSP()[0] + velpc = velksp.getPC() + # iterations of the velocity sub-solve on the last outer application + rec = {"arm": name, "kind": kind, "vel_its": velksp.getIterationNumber(), + "outer_its": ksp.getIterationNumber(), "velocity_pc": velpc.getType(), + "nnz_per_row": chain_density(velpc) if velpc.getType() == "mg" else None, + "levels": len(tail) + 1, "cells": int(mesh.dm.getHeightStratum(0)[1]), + "t_solve": round(time.perf_counter() - t0, 1)} + records.append(rec) + print("ARM", json.dumps(rec), flush=True) +json.dump(records, open(os.path.join(out, "ladder_c.json"), "w"), indent=1) +print("DONE") diff --git a/src/underworld3/utilities/custom_mg.py b/src/underworld3/utilities/custom_mg.py index e6f9192ab..e99ca6b19 100644 --- a/src/underworld3/utilities/custom_mg.py +++ b/src/underworld3/utilities/custom_mg.py @@ -982,127 +982,6 @@ def _count_zero_columns_parallel(P, comm): return nzero -def _repair_zero_columns_parallel(P, coords_f, fine_layout, coords_u, cols_u, - ncomp, comm): - """Give each unreached coarse DOF a nearest-fine-DOF entry (parallel). - - The counterpart of :func:`_repair_zero_columns_serial` for the - cross-partition build: a coarse DOF no fine node reaches (the fine - mesh gathered onto a surgery rank, a placed band, a relaxed child) - leaves an empty column and a singular Galerkin coarse operator. Each - rank reads the empty columns it OWNS off P^T.1, the orphan list is - all-gathered (small), every rank offers its nearest OWNED fine DOF - of the same component, and the rank holding the nearest one sets the - injection entry. Returns ``(P, n_repaired)``; ``P`` is re-assembled. - """ - m4 = comm.tompi4py() - ones_f = P.createVecLeft() - ones_f.set(1.0) - colsum = P.createVecRight() - P.multTranspose(ones_f, colsum) - cstart, _cend = colsum.getOwnershipRange() - zero_local = np.flatnonzero(colsum.array == 0.0) + cstart - ones_f.destroy() - colsum.destroy() - zero = np.concatenate(m4.allgather(zero_local.astype(np.int64))) - if zero.size == 0: - return P, 0 - # global column -> (coarse node coordinate, component) via the gathered cloud - where = {int(cols_u[n, c]): (n, c) - for n in range(cols_u.shape[0]) for c in range(ncomp) - if cols_u[n, c] >= 0} - from scipy.spatial import cKDTree - l2g_f, fstart, fend = fine_layout.l2g, fine_layout.rstart, fine_layout.rend - tree = cKDTree(coords_f) if coords_f.shape[0] else None - offers = [] # (distance, global fine row) - for col in zero.tolist(): - node_c, comp = where[int(col)] - best = (np.inf, -1) - if tree is not None: - k = min(8, coords_f.shape[0]) - d, idx = tree.query(coords_u[node_c], k=k) - for dd, i in zip(np.atleast_1d(d), np.atleast_1d(idx)): - grow = int(l2g_f[int(i) * ncomp + comp]) - if fstart <= grow < fend: # an OWNED fine row - best = (float(dd), grow) - break - offers.append(best) - # the nearest offer across ranks wins each orphan - dist = np.array([o[0] for o in offers]) - rows = np.array([o[1] for o in offers], dtype=np.int64) - all_dist = np.vstack(m4.allgather(dist)) - all_rows = np.vstack(m4.allgather(rows)) - winner = np.argmin(all_dist, axis=0) - for j, col in enumerate(zero.tolist()): - if winner[j] == m4.rank and np.isfinite(all_dist[winner[j], j]): - P.setValues([int(all_rows[winner[j], j])], [int(col)], [1.0], - addv=PETSc.InsertMode.INSERT_VALUES) - P.assemble() - return P, int(zero.size) - - -def _assert_no_zero_columns_parallel(P, comm): - """Parallel zero-column guard: a coarse DOF with no fine image -> singular - Galerkin coarse operator.""" - nzero = _count_zero_columns_parallel(P, comm) - if nzero: - raise RuntimeError( - f"parallel transfer has {nzero} zero columns (coarse DOFs with no fine " - f"image) — BC-per-level reduction failed; coarse operator would be singular.") - - -def _assert_no_zero_columns_serial(P_csr, level): - """Serial zero-column guard — same physics as the parallel guard above: a - coarse DOF with no fine image makes the Galerkin coarse operator singular.""" - nzero = int((np.asarray((P_csr != 0).sum(axis=0)).ravel() == 0).sum()) - if nzero: - raise RuntimeError( - f"transfer {level - 1}->{level} has {nzero} zero columns (coarse DOFs " - f"with no fine image) — BC-per-level reduction failed; coarse " - f"operator would be singular.") - - -def _repair_zero_columns_serial(P_csr, coords_c, coords_f, map_c, map_f, - nc, level): - """Give each unreached coarse DOF a nearest-fine-DOF entry (serial). - - The barycentric builder has LOCAL support, so on NON-NESTED level pairs - — two independently PLACED meshes (#626), a relaxed child (#424) — a - coarse DOF can lose every fine image and its Galerkin column goes - singular. The dense-RBF rescue fixes that globally at a performance - cliff; when only a handful of columns are empty, a surgical injection - entry (weight 1 at the nearest fine DOF of the same component) makes - the RAP nonsingular at zero cost. These are preconditioner transfers, - not the discretisation — a few injected rows cost iterations at worst, - never correctness. Returns the (possibly repaired) matrix and the - repair count; a column it cannot repair is left empty for the guard - to refuse loudly. - """ - colsum = np.asarray((P_csr != 0).sum(axis=0)).ravel() - zero = np.flatnonzero(colsum == 0) - if not len(zero): - return P_csr, 0 - from scipy.spatial import cKDTree - - n_full_f = coords_f.shape[0] * nc - inv_f = -np.ones(n_full_f, dtype=np.int64) - inv_f[np.asarray(map_f, dtype=np.int64)] = np.arange(len(map_f)) - tree = cKDTree(coords_f) - P = P_csr.tolil() - repaired = 0 - for j in zero: - full_c = int(map_c[j]) - node_c, comp = divmod(full_c, nc) - k = min(8, coords_f.shape[0]) - for i in np.atleast_1d(tree.query(coords_c[node_c], k=k)[1]): - full_f = int(i) * nc + comp - if inv_f[full_f] >= 0: - P[int(inv_f[full_f]), int(j)] = 1.0 - repaired += 1 - break - return P.tocsr(), repaired - - def _configure_pcmg(pc, Ps, coarse="redundant", smoother="robust", owned=None, ksp=None, patch_rows=None): """Reconfigure ``pc`` as a fresh PCMG (FMG F-cycle) driven by the supplied @@ -1445,6 +1324,15 @@ def __init__(self, level_meshes, builder="barycentric", field_id=None, # unreached unknowns are dropped at install rather than repaired. from underworld3.utilities.fault_mg import is_split_level self.has_split_levels = any(is_split_level(m) for m in self.level_meshes[:-1]) + # A coarse unknown no fine unknown reaches (a non-nested level: a + # cut, a placed band, a relaxed child) is DROPPED at install, never + # repaired by nearest-node injection. The injection put a weight-1 + # entry on a fine row that already carried one, two identical + # columns, a singular Galerkin coarse operator: NaN on a two-mid + # ladder chain (serial, 4 orphans, split and unsplit finest alike) + # and 512 duplicated columns at np = 2. Kept as an attribute so the + # install paths read one rule. + self.drop_orphans = True def _validated_fac_zone(self, fac_zone): """Normalise ``fac_zone`` to None or a list of boolean cell masks. @@ -1655,21 +1543,8 @@ def build(self, solver): f"without a fine image where the " f"co-partitioned one left {n_zero}; kept " f"the co-partitioned transfer (#671).") - # the same orphan repair the serial path has: a coarse DOF no - # fine node reaches gets its nearest fine DOF as an injection - if _count_zero_columns_parallel(P, comm) > 0: - coords_u, cols_u = _gather_coarse_cloud( - coords[l - 1], maps[l - 1], nc, comm) - P, n_rep = _repair_zero_columns_parallel( - P, coords[l], maps[l], coords_u, cols_u, nc, comm) - if n_rep: - import warnings - warnings.warn( - f"custom_mg: parallel transfer {l - 1}->{l} had " - f"{n_rep} coarse DOF(s) with no fine image " - f"(non-nested levels); repaired by " - f"nearest-fine-DOF injection.") - _assert_no_zero_columns_parallel(P, comm) + # an orphan coarse DOF (no fine image) is dropped at install + # (restrict_unreached), never injected — see drop_orphans # FAC in parallel (#670): the same blocks as the serial branch # below, in GLOBAL row numbering — the layout's ghost-resolved # l2g puts a masked cell's off-rank nodes into this rank's @@ -1761,17 +1636,8 @@ def build(self, solver): else: Pr = _reduced_transfer(coords[l - 1], coords[l], maps[l - 1], maps[l], nc, self.builder) - Pr, n_rep = _repair_zero_columns_serial( - Pr, coords[l - 1], coords[l], maps[l - 1], maps[l], - nc, l) - if n_rep: - import warnings - warnings.warn( - f"custom_mg: transfer {l - 1}->{l} had {n_rep} coarse " - f"DOF(s) with no fine image (non-nested levels); " - f"repaired by nearest-fine-DOF injection. Costs " - f"iterations at worst, never correctness.") - _assert_no_zero_columns_serial(Pr, l) + # an orphan coarse DOF (no fine image) is dropped at install + # (restrict_unreached), never injected — see drop_orphans # TODO(MEASURE): A/B affordance for the #629 campaign, like # the nested-native flag above; remove when settled. if not os.environ.get("UW_CUSTOM_MG_DISABLE_FAC"): @@ -1883,12 +1749,13 @@ def install(self, solver, verbose=False): raise RuntimeError("call build() before install()") Ps = self.transfers patch_rows = getattr(self, "level_patch_rows", None) - if self.has_split_levels: - # the restriction renumbers the coarse levels, so the recorded - # FAC patch rows no longer apply (a split fault needs no patch) + if self.drop_orphans: + # the restriction renumbers the COARSE levels, so their recorded + # FAC patch rows no longer apply; the finest's rows are unchanged from underworld3.utilities.fault_mg import restrict_unreached Ps = restrict_unreached(Ps, solver.dm.comm) - patch_rows = None + if patch_rows is not None: + patch_rows = [None] * (len(patch_rows) - 1) + [patch_rows[-1]] _install_transfers(solver, Ps, verbose=verbose, patch_rows=patch_rows) # Record what is live on this solver's PC, so a repeat solve can # skip the re-install (see _pcmg_still_installed). diff --git a/src/underworld3/utilities/rotated_bc.py b/src/underworld3/utilities/rotated_bc.py index 2fc89a05f..8a03e2e25 100644 --- a/src/underworld3/utilities/rotated_bc.py +++ b/src/underworld3/utilities/rotated_bc.py @@ -1716,6 +1716,14 @@ def _build_rotated_custom_Pl(solver, Q, normal_rows): solver._custom_mg = {"mode": "hierarchy", "hierarchy": h, "verbose": False} return Pl + if getattr(h, "drop_orphans", False): + # unreached coarse unknowns are dropped, never injected; the + # finest's layout (and its FAC patch rows) is untouched + from underworld3.utilities.fault_mg import restrict_unreached + Ps = restrict_unreached(Ps, solver.dm.comm) + rows = getattr(h, "level_patch_rows", None) + if rows is not None: + h.level_patch_rows = [None] * (len(rows) - 1) + [rows[-1]] vel_is = solver._subdict["velocity"][0] vis = np.asarray(vel_is.getIndices()) g2blk = {int(g): k for k, g in enumerate(vis)} From 76ae644e33076541ed1561ce4902d9d0bf8c243d Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Wed, 14 Oct 2026 02:46:58 +1100 Subject: [PATCH 40/57] Fault multigrid figures: the ladder hierarchy with a realisation switch, the real ladder levels, and the parallel imbalance decomposed by level Underworld development team with AI support from Claude Code --- .../sessions/fault_mg/fig_ladder_render.py | 127 ++++++++++++++++++ .../fault_mg/fig_load_decomposition.py | 81 +++++++++++ scripts/sessions/fault_mg/fig_schematics.py | 71 +++++++++- 3 files changed, 277 insertions(+), 2 deletions(-) create mode 100644 scripts/sessions/fault_mg/fig_ladder_render.py create mode 100644 scripts/sessions/fault_mg/fig_load_decomposition.py diff --git a/scripts/sessions/fault_mg/fig_ladder_render.py b/scripts/sessions/fault_mg/fig_ladder_render.py new file mode 100644 index 000000000..bd8402bb8 --- /dev/null +++ b/scripts/sessions/fault_mg/fig_ladder_render.py @@ -0,0 +1,127 @@ +"""Render the nested ladder hierarchy of ladder_c.py near the band: the two +background levels and the placed ribbons at spacing 0.04, 0.02, 0.01, each +spine split (Plus red, Minus blue, drawn apart), band cells shaded. + +python fig_ladder_render.py +""" +import sys + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.collections import LineCollection, PolyCollection + +import underworld3 as uw +from underworld3.utilities.place_surface import place_thin_volume + +out = sys.argv[1] +H_BG = 1.0 / 8 +WIDTH = 0.06 +TRACE = np.array([[0.15, 0.42], [0.85, 0.58]]) +TIP = 0.04 +FAULT = "Main" +a, b = TRACE[0], TRACE[-1] +t_hat = (b - a) / np.linalg.norm(b - a) +L = float(np.linalg.norm(b - a)) +n_hat = np.array([-t_hat[1], t_hat[0]]) + + +def banded(base, size): + for clearance in (0.55, 0.7, 0.9): + try: + dm, _info = place_thin_volume( + base.dm, [TRACE], width=WIDTH, label="Zone", label_value=31, + size=size, clearance=clearance, assembly="fuse", mesher="ladder") + return uw.discretisation.Mesh( + dm, simplex=True, qdegree=2, boundaries=base.boundaries, + coordinate_system_type=base.CoordinateSystem.coordinate_type) + except RuntimeError: + continue + raise RuntimeError(f"band build failed at size {size}") + + +def spine_line(size): + n_along = max(2, int(round(L / size))) + step = L / n_along + k0 = int(round(TIP / step)) + return a + (np.arange(k0, n_along - k0 + 1) * step)[:, None] * t_hat + + +def edges_of(m, offset): + dm = m.dm + eS, eE = dm.getDepthStratum(1) + vS, vE = dm.getDepthStratum(0) + X = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, 2) + sides = {} + for side, sgn in (("Plus", 1.0), ("Minus", -1.0)): + name = f"{FAULT}{side}" + if name in [bd.name for bd in m.boundaries]: + lab = dm.getLabel(name) + value = m.boundaries[name].value + if lab and lab.getStratumSize(value) > 0: + sides[side] = (set(int(e) for e in lab.getStratumIS(value).getIndices() + if eS <= int(e) < eE), sgn) + segs, fsegs = [], {"Plus": [], "Minus": []} + for e in range(eS, eE): + p, q = (int(r) - vS for r in dm.getCone(e)) + seg = np.array([X[p], X[q]]) + placed = False + for side, (es, sgn) in sides.items(): + if e in es: + fsegs[side].append(seg + sgn * offset * n_hat) + placed = True + if not placed: + segs.append(seg) + return segs, fsegs + + +def band_polys(m): + dm = m.dm + cS, cE = dm.getHeightStratum(0) + vS, vE = dm.getDepthStratum(0) + X = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, 2) + try: + mask = m.cells_labelled("Zone", 31) + except Exception: + return [] + polys = [] + for c in np.nonzero(mask)[0]: + cl = dm.getTransitiveClosure(int(c) + cS)[0] + vs = [int(p) - vS for p in cl if vS <= p < vE] + polys.append(X[vs]) + return polys + + +base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), cellSize=H_BG, + regular=False, qdegree=2, refinement=1) +levels = base._coarse_level_meshes()[:-1] + [base] +titles = ["background 0", "background 1"] +for size in (0.04, 0.02, 0.01): + m = banded(base, size).add_fault((FAULT, spine_line(size)), split_levels=False) + levels.append(m) + titles.append(f"ladder {size}, split") + +fig, axes = plt.subplots(1, len(levels), figsize=(3.3 * len(levels), 3.5)) +for ax, m, t in zip(axes, levels, titles): + polys = band_polys(m) + if polys: + ax.add_collection(PolyCollection(polys, facecolors="#dff0d8", edgecolors="none")) + segs, fsegs = edges_of(m, offset=0.003) + ax.add_collection(LineCollection(segs, colors="#777", linewidths=0.5)) + for side, col in (("Plus", "#c0392b"), ("Minus", "#1a5276")): + if fsegs[side]: + ax.add_collection(LineCollection(fsegs[side], colors=col, linewidths=1.6)) + cells = m.dm.getHeightStratum(0)[1] + ax.set_title(f"{t}\n{cells} cells", fontsize=9) + ax.set_xlim(0.3, 0.7) + ax.set_ylim(0.37, 0.63) + ax.set_aspect("equal") + ax.set_xticks([]) + ax.set_yticks([]) +fig.suptitle("The nested ladder hierarchy (test_1022 rig): base levels welded and untouched; " + "the band (green) placed at 0.04, 0.02, 0.01 and its spine split on each", + fontsize=10) +fig.tight_layout() +fig.savefig(out, dpi=170) +print("wrote", out) diff --git a/scripts/sessions/fault_mg/fig_load_decomposition.py b/scripts/sessions/fault_mg/fig_load_decomposition.py new file mode 100644 index 000000000..66b2f313c --- /dev/null +++ b/scripts/sessions/fault_mg/fig_load_decomposition.py @@ -0,0 +1,81 @@ +"""Where the parallel imbalance comes from: the base levels, the adapt child +(partition inherited from the base), and the gathered fault star, from +parallel_load.py's records. + +python fig_load_decomposition.py [tag=_b0.025] [k=3] +""" +import json +import os +import sys + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np + +out = sys.argv[1] +tag = sys.argv[2] if len(sys.argv) > 2 else "_b0.025" +k = int(sys.argv[3]) if len(sys.argv) > 3 else 3 +os.makedirs(out, exist_ok=True) +runs = {} +for n in (4, 8, 16): + f = os.path.expanduser(f"~/+Simulations/fault_split_mg/load_k{k}{tag}_np{n}.json") + if os.path.exists(f): + runs[n] = json.load(open(f))[0] +nps = sorted(runs) + + +def imb(cells, n): + return max(cells) / (sum(cells) / n) + + +fig, axes = plt.subplots(1, 2, figsize=(13, 4.4), gridspec_kw={"width_ratios": [1, 1.5]}) +ax = axes[0] +labels = ["base, coarsest", "base, finest", "adapt child\n(no fault)", "split finest\n(child + star)"] +cols = ["#9bb0c4", "#6d8aa6", "#e8c872", "#c0392b"] +w = 0.8 / len(labels) +for j, (lab, col) in enumerate(zip(labels, cols)): + ys = [] + for n in nps: + rec = runs[n] + load = rec["load"] + base = [lv for lv in load if lv["static"]] + vals = [imb(base[0]["owned_cells"], n), imb(base[-1]["owned_cells"], n), + imb(rec["load_unsplit_child"], n), imb(load[-1]["owned_cells"], n)] + ys.append(vals[j]) + x = np.arange(len(nps)) + (j - (len(labels) - 1) / 2) * w + ax.bar(x, ys, w, color=col, label=lab) + for xi, yi in zip(x, ys): + ax.text(xi, yi + 0.05, f"{yi:.1f}", ha="center", va="bottom", fontsize=7.5) +ax.axhline(1.0, color="k", lw=0.6) +ax.set_xticks(np.arange(len(nps))) +ax.set_xticklabels([f"{n} ranks" for n in nps]) +ax.set_ylabel("max / mean owned cells") +ax.set_title("the base is balanced; the child inherits the base's partition", loc="left", fontsize=10) +ax.legend(fontsize=8, loc="upper left") + +ax = axes[1] +n = nps[-1] +rec = runs[n] +load = rec["load"] +base_f = np.array([lv for lv in load if lv["static"]][-1]["owned_cells"]) +child = np.array(rec["load_unsplit_child"]) +fin = np.array(load[-1]["owned_cells"]) +star = np.array(load[-1]["fault_star"]) +x = np.arange(n) +w = 0.27 +ax.bar(x - w, base_f, w, color="#6d8aa6", label="base, finest level") +ax.bar(x, child, w, color="#e8c872", label="adapt child, no fault") +ax.bar(x + w, fin, w, color="#f2c2b0", edgecolor="#c0392b", label="split finest") +ax.bar(x + w, star, w, color="none", edgecolor="k", hatch="///", lw=0.5, label="of which fault star") +ax.axhline(fin.sum() / n, color="#c0392b", lw=0.8, ls="--", label="perfect balance, finest") +ax.axhline(base_f.sum() / n, color="#6d8aa6", lw=0.8, ls="--") +ax.set_xticks(x) +ax.set_xlabel("rank") +ax.set_ylabel("owned cells") +ax.set_title(f"{n} ranks: cells per rank on the base's finest level, the adapt child and the split finest", + loc="left", fontsize=10) +ax.legend(fontsize=8, ncol=2) +fig.tight_layout() +fig.savefig(os.path.join(out, f"load_decomposition_k{k}{tag}.png"), dpi=160) +print("ok", nps) diff --git a/scripts/sessions/fault_mg/fig_schematics.py b/scripts/sessions/fault_mg/fig_schematics.py index a4fd73319..8b0fc3160 100644 --- a/scripts/sessions/fault_mg/fig_schematics.py +++ b/scripts/sessions/fault_mg/fig_schematics.py @@ -3,6 +3,8 @@ 1. hierarchy_strategies.png — the four hierarchies A, B2, C, D as level stacks 2. transfer_rule.png — the side-aware cell-located transfer and the clamp 3. parallel_pipeline.png — what moves where in parallel (split, levels, extrusion) +4. embedding_routes.png — the cut, the placed ladder band, and the ladder as the hierarchy +5. ladder_hierarchy.png — one nested ladder hierarchy with a realisation switch (split or TI) python fig_schematics.py """ @@ -221,11 +223,11 @@ def column(ax, title, faults, bands, notes): "a structured band (rails + exact spine, green)\nis placed into the finest by " "carve-and-fill;\nthe spine is split without cutting (cut=False).\nWelded tail; " "the finest smoother gains an ASM\npatch (LU blocks) over the band's cells.") - column(axes[2], "proposed: C on the ladder", + column(axes[2], "the ladder as the hierarchy (measured 2026-10-13)", ["split", "split", "split", "weld", "weld"], [True, True, True, False, False], "the ladder sub-sampled 2:1 shares every spine\nvertex between levels: placed " "and split on each\nadd-on level, the slits NEST (no clamp needed).\nThe FAC patch " - "can stay on the finest. Not built;\nthe no-mid measurements had welded mids only.") + "stays on the finest. Assembled by\nhand (ladder_c.py); the automatic replay is next.") axes[0].plot([], [], color=SPLIT, lw=2.2, label="split slit") axes[0].plot([], [], color="#2e7d32", lw=1.3, label="placed ladder band (cell label)") axes[0].plot([], [], color=WELD, lw=1.0, ls=":", label="welded") @@ -236,3 +238,68 @@ def column(ax, title, faults, bands, notes): embedding_routes() + + +def ladder_hierarchy(): + """One nested ladder hierarchy with a realisation switch: what the library + builds today for a placed band, and the two realisations measured on the + test_1022 rig (ladder_c.py), split spine (C) or TI band on every ladder level.""" + fig, axes = plt.subplots(1, 3, figsize=(15, 5.4)) + names = ["finest ladder, spacing S", "ladder, 2S", "ladder, 4S", "background 1", "background 0"] + kinds = ["fine", "add", "add", "bg", "bg"] + + def column(ax, title, faults, bands, present, notes): + for i, (nm, kd, f, b, p) in enumerate(zip(names, kinds, faults, bands, present)): + y = 4.0 - i * 0.95 + if not p: + ax.add_patch(FancyBboxPatch((0.2, y), 3.6, 0.72, boxstyle="round,pad=0.02", + fc="none", ec="#bbb", lw=0.8, ls="--")) + ax.text(0.32, y + 0.36, nm + " (not built)", va="center", ha="left", + fontsize=8.5, color="#999") + continue + level_box(ax, y, kd, nm, f) + if b: + xc = 0.2 + 3.6 * 0.62 + ax.add_patch(Polygon([[xc - 1.05, y + 0.12], [xc + 1.05, y + 0.12], + [xc + 1.05, y + 0.60], [xc - 1.05, y + 0.60]], + closed=True, fc="none", ec="#2e7d32", lw=1.3)) + ax.set_xlim(0, 4.0) + ax.set_ylim(-1.5, 5.0) + ax.set_title(title, fontsize=10.5, loc="left") + ax.text(0.2, -0.25, notes, fontsize=8.5, va="top") + ax.axis("off") + + column(axes[0], "today: FaultNetwork.build(width=w)", + ["split", "weld", "weld", "weld", "weld"], [True, False, False, False, False], + [True, False, False, True, True], + "the band placed on the finest only; welded tail\n[background 0, 1]; the finest " + "smoother carries an\nASM patch over the band. Contact: 6 its at S = 0.01,\n9 at " + "S = 0.005. TI: 3 and 6.") + column(axes[1], "split realisation (C on the ladder)", + ["split", "split", "split", "weld", "weld"], [True, True, True, False, False], + [True] * 5, + "the same band at 2S and 4S, each spine split on the\nvertices it shares with " + "the finer one (slits nest).\nContact: 7 and 9 its, slip identical. The mids are\n" + "not needed for convergence here; they cost a fatter\nlevel (45 nnz/row against 33).") + column(axes[2], "TI realisation (band at every level)", + ["ti", "ti", "ti", "weld", "weld"], [True, True, True, False, False], + [True] * 5, + "the same nested ladders, the band's footprint painted\nTI at each level's own " + "resolution, nothing split.\nTI: 2 and 3 its against 3 and 6 with no mid:\nthe mids " + "halve the count at depth.") + axes[0].plot([], [], color=SPLIT, lw=2.2, label="split spine") + axes[0].plot([], [], color=TI, lw=1.2, ls="--", label="TI band") + axes[0].plot([], [], color="#2e7d32", lw=1.3, label="placed ladder band (cell label)") + axes[0].plot([], [], color=WELD, lw=1.0, ls=":", label="welded") + fig.legend(loc="lower center", ncol=4, fontsize=9, frameon=False, bbox_to_anchor=(0.5, -0.01)) + fig.text(0.01, 0.99, "One hierarchy, two realisations. The background (grey) is the base's own " + "levels: never cut, never rebalanced. The ladder levels (yellow) are the band sub-sampled\n" + "2:1, so every coarser spine vertex is a finer one and the levels nest. Measured on the " + "test_1022 rig, base h = 1/8, band width 0.06 (ladder_c.py).", + fontsize=8.8, va="top") + fig.tight_layout(rect=(0, 0.05, 1, 0.93)) + fig.savefig(os.path.join(out, "ladder_hierarchy.png"), dpi=160) + plt.close(fig) + + +ladder_hierarchy() From 9f9b2475b875610b1c21bff70837d00a525582d0 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Wed, 14 Oct 2026 03:23:01 +1100 Subject: [PATCH 41/57] Ladder depth experiments: the single finest ladder against its tails, contact and TI, and the finer base Underworld development team with AI support from Claude Code --- scripts/sessions/fault_mg/fig_ladder_depth.py | 82 +++++++++ scripts/sessions/fault_mg/ladder_base.py | 163 +++++++++++++++++ scripts/sessions/fault_mg/ladder_depth.py | 165 ++++++++++++++++++ .../sessions/fault_mg/ladder_depth_split.py | 161 +++++++++++++++++ .../sessions/fault_mg/ladder_depth_split4.py | 161 +++++++++++++++++ 5 files changed, 732 insertions(+) create mode 100644 scripts/sessions/fault_mg/fig_ladder_depth.py create mode 100644 scripts/sessions/fault_mg/ladder_base.py create mode 100644 scripts/sessions/fault_mg/ladder_depth.py create mode 100644 scripts/sessions/fault_mg/ladder_depth_split.py create mode 100644 scripts/sessions/fault_mg/ladder_depth_split4.py diff --git a/scripts/sessions/fault_mg/fig_ladder_depth.py b/scripts/sessions/fault_mg/fig_ladder_depth.py new file mode 100644 index 000000000..90eac731a --- /dev/null +++ b/scripts/sessions/fault_mg/fig_ladder_depth.py @@ -0,0 +1,82 @@ +"""Iterations of the single-level ladder and its tails as the band deepens: +from ladder_c.json, ladder_depth.json, ladder_depth_split*.json and +ladder_base_ref2.json (test_1022 rig). + +python fig_ladder_depth.py +""" +import json +import os +import sys + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +out = sys.argv[1] +D = os.path.expanduser("~/+Simulations/fault_split_mg") +recs = [] +for f in ("ladder_c.json", "ladder_depth.json", "ladder_depth_split.json", + "ladder_depth_split4.json", "ladder_base_ref2.json"): + recs += json.load(open(os.path.join(D, f))) +by = {r["arm"]: r["vel_its"] for r in recs} + +fig, axes = plt.subplots(1, 2, figsize=(12, 4.3)) +# left: contact +ax = axes[0] +ax.plot([0.01, 0.005, 0.0025], [by["fine 0.01: no mid"], by["fine 0.005: no mid"], + by["fine 0.0025: no mid"]], "o-", color="#c0392b", + label="single ladder on base h = 1/16 (no mid)") +ax.plot([0.005, 0.0025], [by["ref 2 fine 0.005: no mid"], by["ref 2 fine 0.0025: no mid"]], + "s-", color="#1a5276", label="single ladder on base h = 1/32 (no mid)") +ax.plot([0.0025] * 3, [by["fine 0.0025: welded mid [0.005]"], + by["fine 0.0025: welded mid [0.01, 0.005]"], + by["fine 0.0025: welded mid [0.02, 0.01, 0.005]"]], + "v", color="#7f8c8d", ms=9, label="h = 1/16 + 1, 2, 3 welded mids") +ax.plot([0.0025] * 4, [by["fine 0.0025: split mid [0.005]"], + by["fine 0.0025: split mid [0.01, 0.005]"], + by["fine 0.0025: split mid [0.02, 0.01, 0.005]"], + by["fine 0.0025: split mid [0.04, 0.02, 0.01, 0.005]"]], + "^", color="#e67e22", ms=9, mfc="none", label="h = 1/16 + 1, 2, 3, 4 split mids") +ax.annotate("3 and 4 split mids\n(chain from 0.02)", (0.0025, 13), (0.0045, 15), fontsize=8, + arrowprops=dict(arrowstyle="-", color="#e67e22")) +ax.annotate("1, 2 split or any welded", (0.0025, 21), (0.0045, 22), fontsize=8, + arrowprops=dict(arrowstyle="-", color="#7f8c8d")) +ax.set_xscale("log", base=2) +ax.invert_xaxis() +ax.set_xticks([0.01, 0.005, 0.0025]) +ax.set_xticklabels(["0.01\n(6 | 3)", "0.005\n(12 | 6)", "0.0025\n(25 | 12)"]) +ax.set_xlabel("ladder spacing (ratio to base finest: h = 1/16 | h = 1/32)") +ax.set_ylabel("velocity iterations") +ax.set_title("split-node contact on the ladder", loc="left", fontsize=10) +ax.legend(fontsize=7.5) +ax.grid(alpha=0.3) +# right: TI +ax = axes[1] +ax.plot([0.01, 0.005, 0.0025], [by["TI fine 0.01: no mid"], by["TI fine 0.005: no mid"], + by["TI fine 0.0025: no mid"]], "o-", color="#c0392b", + label="single ladder on base h = 1/16 (no mid)") +ax.plot([0.005, 0.0025], [by["TI ref 2 fine 0.005: no mid"], by["TI ref 2 fine 0.0025: no mid"]], + "s-", color="#1a5276", label="single ladder on base h = 1/32 (no mid)") +ax.plot([0.01, 0.005], [by["TI fine 0.01: welded mid [0.02]"], + by["TI fine 0.005: welded mid [0.02, 0.01]"]], "v--", color="#7f8c8d", + label="h = 1/16 + all nested welded mids") +ax.plot([0.0025] * 3, [by["TI fine 0.0025: welded mid [0.005]"], + by["TI fine 0.0025: welded mid [0.01, 0.005]"], + by["TI fine 0.0025: welded mid [0.02, 0.01, 0.005]"]], + "v", color="#7f8c8d", ms=9) +ax.annotate("1, 2, 3 welded mids", (0.0025, 5), (0.0045, 3.2), fontsize=8, + arrowprops=dict(arrowstyle="-", color="#7f8c8d")) +ax.set_xscale("log", base=2) +ax.invert_xaxis() +ax.set_xticks([0.01, 0.005, 0.0025]) +ax.set_xticklabels(["0.01\n(6 | 3)", "0.005\n(12 | 6)", "0.0025\n(25 | 12)"]) +ax.set_xlabel("ladder spacing (ratio to base finest: h = 1/16 | h = 1/32)") +ax.set_title("TI band on the ladder (contrast 1e-3)", loc="left", fontsize=10) +ax.legend(fontsize=7.5) +ax.grid(alpha=0.3) +ax.set_ylim(0, 10) +fig.suptitle("A single finest ladder over the welded base, and what the tail buys as the band deepens", + fontsize=10) +fig.tight_layout() +fig.savefig(out, dpi=160) +print("wrote", out) diff --git a/scripts/sessions/fault_mg/ladder_base.py b/scripts/sessions/fault_mg/ladder_base.py new file mode 100644 index 000000000..912cf4dae --- /dev/null +++ b/scripts/sessions/fault_mg/ladder_base.py @@ -0,0 +1,163 @@ +"""The same finest ladder on a finer base: the test_1022 rig at spacing 0.005 and +0.0025 with the base refined times (finest base cell 1/8/2^ref), +no mids, contact and TI. Separates the band-to-base rim ratio from the tail. + +The finest level is a placed transfinite ribbon (spacing S) with the spine cut +and split (or, for TI, left whole with the band's footprint painted +transversely isotropic). The mid levels are the same ribbon at 2:1 and 4:1 +spacing, welded or split on the same spine vertices. Base: L0 + L1. + +python ladder_base.py +""" +import json +import os +import sys +import time + +import numpy as np +import sympy +import underworld3 as uw +from underworld3.utilities import fault_contact +from underworld3.utilities.custom_mg import set_custom_fmg +from underworld3.utilities.place_surface import place_thin_volume + +out = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/+Simulations/fault_split_mg") +REF = int(sys.argv[2]) if len(sys.argv) > 2 else 2 +os.makedirs(out, exist_ok=True) +H_BG = 1.0 / 8 +WIDTH = 0.06 +TRACE = np.array([[0.15, 0.42], [0.85, 0.58]]) +TIP = 0.04 # the cut stops this far inside the band, every level +FAULT = "Main" +a, b = TRACE[0], TRACE[-1] +t_hat = (b - a) / np.linalg.norm(b - a) +L = float(np.linalg.norm(b - a)) +n_hat = np.array([-t_hat[1], t_hat[0]]) + + +def banded(base, size): + for clearance in (0.55, 0.7, 0.9): + try: + dm, _info = place_thin_volume( + base.dm, [TRACE], width=WIDTH, label="Zone", label_value=31, + size=size, clearance=clearance, assembly="fuse", mesher="ladder") + return uw.discretisation.Mesh( + dm, simplex=True, qdegree=2, boundaries=base.boundaries, + coordinate_system_type=base.CoordinateSystem.coordinate_type) + except RuntimeError: + continue + raise RuntimeError(f"band build failed at size {size}") + + +def spine_line(size): + """the cut, sampled at the ladder's own rung vertices, tips TIP inside""" + n_along = max(2, int(round(L / size))) + step = L / n_along + k0 = int(round(TIP / step)) + return a + (np.arange(k0, n_along - k0 + 1) * step)[:, None] * t_hat + + +def split_ribbon(mesh, size): + return mesh.add_fault((FAULT, spine_line(size)), split_levels=False) + + +def contact_stokes(mesh, tag): + x, y = mesh.X + v = uw.discretisation.MeshVariable(f"v{tag}", mesh, 2, degree=2) + p = uw.discretisation.MeshVariable(f"p{tag}", mesh, 1, degree=1, continuous=True) + s = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + s.constitutive_model = uw.constitutive_models.ViscousFlowModel + s.constitutive_model.Parameters.shear_viscosity_0 = 1.0 + s.bodyforce = [0.0, 0.0] + for wall in ("Bottom", "Top", "Left", "Right"): + s.add_dirichlet_bc((y - 0.5, 0.0), wall) + s.add_fault_bc(0, boundary=FAULT) + s.petsc_use_pressure_nullspace = True + s.tolerance = 1e-5 + s.strategy = "robust" + return s + + +def ti_stokes(mesh, tag, contrast=1e-3): + x, y = mesh.X + v = uw.discretisation.MeshVariable(f"v{tag}", mesh, 2, degree=2) + p = uw.discretisation.MeshVariable(f"p{tag}", mesh, 1, degree=1, continuous=True) + zone = uw.discretisation.MeshVariable(f"zone{tag}", mesh, 1, degree=0, continuous=False) + mask = mesh.cells_labelled("Zone", 31) + zone.array[:, 0, 0] = mask.astype(float) + s = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + s.constitutive_model = uw.constitutive_models.TransverseIsotropicFlowModel + s.constitutive_model.Parameters.shear_viscosity_0 = 1.0 + s.constitutive_model.Parameters.shear_viscosity_1 = 1.0 - (1.0 - contrast) * zone.sym[0] + s.constitutive_model.Parameters.director = sympy.Matrix([[float(n_hat[0]), float(n_hat[1])]]) + s.bodyforce = [0.0, 0.0] + for wall in ("Bottom", "Top", "Left", "Right"): + s.add_dirichlet_bc((y - 0.5, 0.0), wall) + s.petsc_use_pressure_nullspace = True + s.tolerance = 1e-5 + s.strategy = "robust" + return s, mask + + +def chain_density(velpc): + out = [] + for l in range(velpc.getMGLevels()): + A = velpc.getMGSmoother(l).getOperators()[0] + out.append(round(A.getInfo()["nz_used"] / max(A.getSize()[0], 1), 1)) + return out + + +records = [] +base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), cellSize=H_BG, + regular=False, qdegree=2, refinement=REF) +L01 = base._coarse_level_meshes()[:-1] + [base] +ribbons = {} +for size in (0.005, 0.0025): + t0 = time.perf_counter() + ribbons[size] = banded(base, size) + print(f"ribbon {size}: {ribbons[size].dm.getHeightStratum(0)[1]} cells " + f"({time.perf_counter() - t0:.1f} s)", flush=True) +split = {size: split_ribbon(m, size) for size, m in ribbons.items()} + +arms = [] +for s_fine in (0.005, 0.0025): + arms.append((f"ref {REF} fine {s_fine}: no mid", s_fine, [], "contact")) + arms.append((f"TI ref {REF} fine {s_fine}: no mid", s_fine, [], "ti")) + +for k, (name, s_fine, mids, kind) in enumerate(arms): + tail = L01 + mids + t0 = time.perf_counter() + if kind == "contact": + mesh = split[s_fine] + stokes = contact_stokes(mesh, f"c{k}") + set_custom_fmg(stokes, tail, field_id=0) + info = fault_contact.solve_with_fault(stokes) + ctx = (getattr(stokes, "_rotated_linear_cache", None) or {})["ctx"] + velpc = ctx["ksp"].getPC().getFieldSplitSubKSP()[0].getPC() + _c, jumps, normals = fault_contact.fault_pair_jumps(stokes, FAULT, stokes._rotated_freeslip_info) + jn = np.einsum("ij,ij->i", jumps, normals) + rec = {"arm": name, "kind": kind, "vel_its": ctx.get("vel_its_last"), + "pres_its": ctx.get("pres_its_last"), "converged": bool(info.get("converged")), + "velocity_pc": velpc.getType(), "nnz_per_row": chain_density(velpc), + "slip": float(np.linalg.norm(jumps - jn[:, None] * normals, axis=1).max()), + "leak": float(np.abs(jn).max()), + "levels": len(tail) + 1, "cells": int(mesh.dm.getHeightStratum(0)[1]), + "t_solve": round(time.perf_counter() - t0, 1)} + else: + mesh = ribbons[s_fine] + stokes, mask = ti_stokes(mesh, f"t{k}") + set_custom_fmg(stokes, tail, field_id=0, fac_zone=mask) + stokes.solve() + ksp = stokes.snes.getKSP() + velksp = ksp.getPC().getFieldSplitSubKSP()[0] + velpc = velksp.getPC() + # iterations of the velocity sub-solve on the last outer application + rec = {"arm": name, "kind": kind, "vel_its": velksp.getIterationNumber(), + "outer_its": ksp.getIterationNumber(), "velocity_pc": velpc.getType(), + "nnz_per_row": chain_density(velpc) if velpc.getType() == "mg" else None, + "levels": len(tail) + 1, "cells": int(mesh.dm.getHeightStratum(0)[1]), + "t_solve": round(time.perf_counter() - t0, 1)} + records.append(rec) + print("ARM", json.dumps(rec), flush=True) +json.dump(records, open(os.path.join(out, f"ladder_base_ref{REF}.json"), "w"), indent=1) +print("DONE") diff --git a/scripts/sessions/fault_mg/ladder_depth.py b/scripts/sessions/fault_mg/ladder_depth.py new file mode 100644 index 000000000..6eddb7d8c --- /dev/null +++ b/scripts/sessions/fault_mg/ladder_depth.py @@ -0,0 +1,165 @@ +"""How far the welded base tail carries a single finest ladder: the test_1022 rig +at spacing 0.005 and 0.0025 (ratio 12 and 24 to the base finest) with no mid, +and at 0.0025 with one, two and three nested welded mids; contact and TI. + +The finest level is a placed transfinite ribbon (spacing S) with the spine cut +and split (or, for TI, left whole with the band's footprint painted +transversely isotropic). The mid levels are the same ribbon at 2:1 and 4:1 +spacing, welded or split on the same spine vertices. Base: L0 + L1. + +python ladder_c.py +""" +import json +import os +import sys +import time + +import numpy as np +import sympy +import underworld3 as uw +from underworld3.utilities import fault_contact +from underworld3.utilities.custom_mg import set_custom_fmg +from underworld3.utilities.place_surface import place_thin_volume + +out = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/+Simulations/fault_split_mg") +os.makedirs(out, exist_ok=True) +H_BG = 1.0 / 8 +WIDTH = 0.06 +TRACE = np.array([[0.15, 0.42], [0.85, 0.58]]) +TIP = 0.04 # the cut stops this far inside the band, every level +FAULT = "Main" +a, b = TRACE[0], TRACE[-1] +t_hat = (b - a) / np.linalg.norm(b - a) +L = float(np.linalg.norm(b - a)) +n_hat = np.array([-t_hat[1], t_hat[0]]) + + +def banded(base, size): + for clearance in (0.55, 0.7, 0.9): + try: + dm, _info = place_thin_volume( + base.dm, [TRACE], width=WIDTH, label="Zone", label_value=31, + size=size, clearance=clearance, assembly="fuse", mesher="ladder") + return uw.discretisation.Mesh( + dm, simplex=True, qdegree=2, boundaries=base.boundaries, + coordinate_system_type=base.CoordinateSystem.coordinate_type) + except RuntimeError: + continue + raise RuntimeError(f"band build failed at size {size}") + + +def spine_line(size): + """the cut, sampled at the ladder's own rung vertices, tips TIP inside""" + n_along = max(2, int(round(L / size))) + step = L / n_along + k0 = int(round(TIP / step)) + return a + (np.arange(k0, n_along - k0 + 1) * step)[:, None] * t_hat + + +def split_ribbon(mesh, size): + return mesh.add_fault((FAULT, spine_line(size)), split_levels=False) + + +def contact_stokes(mesh, tag): + x, y = mesh.X + v = uw.discretisation.MeshVariable(f"v{tag}", mesh, 2, degree=2) + p = uw.discretisation.MeshVariable(f"p{tag}", mesh, 1, degree=1, continuous=True) + s = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + s.constitutive_model = uw.constitutive_models.ViscousFlowModel + s.constitutive_model.Parameters.shear_viscosity_0 = 1.0 + s.bodyforce = [0.0, 0.0] + for wall in ("Bottom", "Top", "Left", "Right"): + s.add_dirichlet_bc((y - 0.5, 0.0), wall) + s.add_fault_bc(0, boundary=FAULT) + s.petsc_use_pressure_nullspace = True + s.tolerance = 1e-5 + s.strategy = "robust" + return s + + +def ti_stokes(mesh, tag, contrast=1e-3): + x, y = mesh.X + v = uw.discretisation.MeshVariable(f"v{tag}", mesh, 2, degree=2) + p = uw.discretisation.MeshVariable(f"p{tag}", mesh, 1, degree=1, continuous=True) + zone = uw.discretisation.MeshVariable(f"zone{tag}", mesh, 1, degree=0, continuous=False) + mask = mesh.cells_labelled("Zone", 31) + zone.array[:, 0, 0] = mask.astype(float) + s = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + s.constitutive_model = uw.constitutive_models.TransverseIsotropicFlowModel + s.constitutive_model.Parameters.shear_viscosity_0 = 1.0 + s.constitutive_model.Parameters.shear_viscosity_1 = 1.0 - (1.0 - contrast) * zone.sym[0] + s.constitutive_model.Parameters.director = sympy.Matrix([[float(n_hat[0]), float(n_hat[1])]]) + s.bodyforce = [0.0, 0.0] + for wall in ("Bottom", "Top", "Left", "Right"): + s.add_dirichlet_bc((y - 0.5, 0.0), wall) + s.petsc_use_pressure_nullspace = True + s.tolerance = 1e-5 + s.strategy = "robust" + return s, mask + + +def chain_density(velpc): + out = [] + for l in range(velpc.getMGLevels()): + A = velpc.getMGSmoother(l).getOperators()[0] + out.append(round(A.getInfo()["nz_used"] / max(A.getSize()[0], 1), 1)) + return out + + +records = [] +base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), cellSize=H_BG, + regular=False, qdegree=2, refinement=1) +L01 = base._coarse_level_meshes()[:-1] + [base] +ribbons = {} +for size in (0.02, 0.01, 0.005, 0.0025): + t0 = time.perf_counter() + ribbons[size] = banded(base, size) + print(f"ribbon {size}: {ribbons[size].dm.getHeightStratum(0)[1]} cells " + f"({time.perf_counter() - t0:.1f} s)", flush=True) +split = {size: split_ribbon(ribbons[size], size) for size in (0.005, 0.0025)} + +arms = [] +for s_fine in (0.005, 0.0025): + arms.append((f"fine {s_fine}: no mid", s_fine, [], "contact")) + arms.append((f"TI fine {s_fine}: no mid", s_fine, [], "ti")) +for mids in ([0.005], [0.01, 0.005], [0.02, 0.01, 0.005]): + arms.append((f"fine 0.0025: welded mid {mids}", 0.0025, [ribbons[m] for m in mids], "contact")) + arms.append((f"TI fine 0.0025: welded mid {mids}", 0.0025, [ribbons[m] for m in mids], "ti")) + +for k, (name, s_fine, mids, kind) in enumerate(arms): + tail = L01 + mids + t0 = time.perf_counter() + if kind == "contact": + mesh = split[s_fine] + stokes = contact_stokes(mesh, f"c{k}") + set_custom_fmg(stokes, tail, field_id=0) + info = fault_contact.solve_with_fault(stokes) + ctx = (getattr(stokes, "_rotated_linear_cache", None) or {})["ctx"] + velpc = ctx["ksp"].getPC().getFieldSplitSubKSP()[0].getPC() + _c, jumps, normals = fault_contact.fault_pair_jumps(stokes, FAULT, stokes._rotated_freeslip_info) + jn = np.einsum("ij,ij->i", jumps, normals) + rec = {"arm": name, "kind": kind, "vel_its": ctx.get("vel_its_last"), + "pres_its": ctx.get("pres_its_last"), "converged": bool(info.get("converged")), + "velocity_pc": velpc.getType(), "nnz_per_row": chain_density(velpc), + "slip": float(np.linalg.norm(jumps - jn[:, None] * normals, axis=1).max()), + "leak": float(np.abs(jn).max()), + "levels": len(tail) + 1, "cells": int(mesh.dm.getHeightStratum(0)[1]), + "t_solve": round(time.perf_counter() - t0, 1)} + else: + mesh = ribbons[s_fine] + stokes, mask = ti_stokes(mesh, f"t{k}") + set_custom_fmg(stokes, tail, field_id=0, fac_zone=mask) + stokes.solve() + ksp = stokes.snes.getKSP() + velksp = ksp.getPC().getFieldSplitSubKSP()[0] + velpc = velksp.getPC() + # iterations of the velocity sub-solve on the last outer application + rec = {"arm": name, "kind": kind, "vel_its": velksp.getIterationNumber(), + "outer_its": ksp.getIterationNumber(), "velocity_pc": velpc.getType(), + "nnz_per_row": chain_density(velpc) if velpc.getType() == "mg" else None, + "levels": len(tail) + 1, "cells": int(mesh.dm.getHeightStratum(0)[1]), + "t_solve": round(time.perf_counter() - t0, 1)} + records.append(rec) + print("ARM", json.dumps(rec), flush=True) +json.dump(records, open(os.path.join(out, "ladder_depth.json"), "w"), indent=1) +print("DONE") diff --git a/scripts/sessions/fault_mg/ladder_depth_split.py b/scripts/sessions/fault_mg/ladder_depth_split.py new file mode 100644 index 000000000..31ea7d7fc --- /dev/null +++ b/scripts/sessions/fault_mg/ladder_depth_split.py @@ -0,0 +1,161 @@ +"""The split mids at ratio 24: the test_1022 rig at spacing 0.0025 with one, two +and three nested SPLIT mids (C on the ladder), contact only. Companion to +ladder_depth.py, which has the welded mids. + +The finest level is a placed transfinite ribbon (spacing S) with the spine cut +and split (or, for TI, left whole with the band's footprint painted +transversely isotropic). The mid levels are the same ribbon at 2:1 and 4:1 +spacing, welded or split on the same spine vertices. Base: L0 + L1. + +python ladder_c.py +""" +import json +import os +import sys +import time + +import numpy as np +import sympy +import underworld3 as uw +from underworld3.utilities import fault_contact +from underworld3.utilities.custom_mg import set_custom_fmg +from underworld3.utilities.place_surface import place_thin_volume + +out = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/+Simulations/fault_split_mg") +os.makedirs(out, exist_ok=True) +H_BG = 1.0 / 8 +WIDTH = 0.06 +TRACE = np.array([[0.15, 0.42], [0.85, 0.58]]) +TIP = 0.04 # the cut stops this far inside the band, every level +FAULT = "Main" +a, b = TRACE[0], TRACE[-1] +t_hat = (b - a) / np.linalg.norm(b - a) +L = float(np.linalg.norm(b - a)) +n_hat = np.array([-t_hat[1], t_hat[0]]) + + +def banded(base, size): + for clearance in (0.55, 0.7, 0.9): + try: + dm, _info = place_thin_volume( + base.dm, [TRACE], width=WIDTH, label="Zone", label_value=31, + size=size, clearance=clearance, assembly="fuse", mesher="ladder") + return uw.discretisation.Mesh( + dm, simplex=True, qdegree=2, boundaries=base.boundaries, + coordinate_system_type=base.CoordinateSystem.coordinate_type) + except RuntimeError: + continue + raise RuntimeError(f"band build failed at size {size}") + + +def spine_line(size): + """the cut, sampled at the ladder's own rung vertices, tips TIP inside""" + n_along = max(2, int(round(L / size))) + step = L / n_along + k0 = int(round(TIP / step)) + return a + (np.arange(k0, n_along - k0 + 1) * step)[:, None] * t_hat + + +def split_ribbon(mesh, size): + return mesh.add_fault((FAULT, spine_line(size)), split_levels=False) + + +def contact_stokes(mesh, tag): + x, y = mesh.X + v = uw.discretisation.MeshVariable(f"v{tag}", mesh, 2, degree=2) + p = uw.discretisation.MeshVariable(f"p{tag}", mesh, 1, degree=1, continuous=True) + s = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + s.constitutive_model = uw.constitutive_models.ViscousFlowModel + s.constitutive_model.Parameters.shear_viscosity_0 = 1.0 + s.bodyforce = [0.0, 0.0] + for wall in ("Bottom", "Top", "Left", "Right"): + s.add_dirichlet_bc((y - 0.5, 0.0), wall) + s.add_fault_bc(0, boundary=FAULT) + s.petsc_use_pressure_nullspace = True + s.tolerance = 1e-5 + s.strategy = "robust" + return s + + +def ti_stokes(mesh, tag, contrast=1e-3): + x, y = mesh.X + v = uw.discretisation.MeshVariable(f"v{tag}", mesh, 2, degree=2) + p = uw.discretisation.MeshVariable(f"p{tag}", mesh, 1, degree=1, continuous=True) + zone = uw.discretisation.MeshVariable(f"zone{tag}", mesh, 1, degree=0, continuous=False) + mask = mesh.cells_labelled("Zone", 31) + zone.array[:, 0, 0] = mask.astype(float) + s = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + s.constitutive_model = uw.constitutive_models.TransverseIsotropicFlowModel + s.constitutive_model.Parameters.shear_viscosity_0 = 1.0 + s.constitutive_model.Parameters.shear_viscosity_1 = 1.0 - (1.0 - contrast) * zone.sym[0] + s.constitutive_model.Parameters.director = sympy.Matrix([[float(n_hat[0]), float(n_hat[1])]]) + s.bodyforce = [0.0, 0.0] + for wall in ("Bottom", "Top", "Left", "Right"): + s.add_dirichlet_bc((y - 0.5, 0.0), wall) + s.petsc_use_pressure_nullspace = True + s.tolerance = 1e-5 + s.strategy = "robust" + return s, mask + + +def chain_density(velpc): + out = [] + for l in range(velpc.getMGLevels()): + A = velpc.getMGSmoother(l).getOperators()[0] + out.append(round(A.getInfo()["nz_used"] / max(A.getSize()[0], 1), 1)) + return out + + +records = [] +base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), cellSize=H_BG, + regular=False, qdegree=2, refinement=1) +L01 = base._coarse_level_meshes()[:-1] + [base] +ribbons = {} +for size in (0.02, 0.01, 0.005, 0.0025): + t0 = time.perf_counter() + ribbons[size] = banded(base, size) + print(f"ribbon {size}: {ribbons[size].dm.getHeightStratum(0)[1]} cells " + f"({time.perf_counter() - t0:.1f} s)", flush=True) +split = {size: split_ribbon(m, size) for size, m in ribbons.items()} + +arms = [] +for mids in ([0.005], [0.01, 0.005], [0.02, 0.01, 0.005]): + arms.append((f"fine 0.0025: split mid {mids}", 0.0025, [split[m] for m in mids], "contact")) + +for k, (name, s_fine, mids, kind) in enumerate(arms): + tail = L01 + mids + t0 = time.perf_counter() + if kind == "contact": + mesh = split[s_fine] + stokes = contact_stokes(mesh, f"c{k}") + set_custom_fmg(stokes, tail, field_id=0) + info = fault_contact.solve_with_fault(stokes) + ctx = (getattr(stokes, "_rotated_linear_cache", None) or {})["ctx"] + velpc = ctx["ksp"].getPC().getFieldSplitSubKSP()[0].getPC() + _c, jumps, normals = fault_contact.fault_pair_jumps(stokes, FAULT, stokes._rotated_freeslip_info) + jn = np.einsum("ij,ij->i", jumps, normals) + rec = {"arm": name, "kind": kind, "vel_its": ctx.get("vel_its_last"), + "pres_its": ctx.get("pres_its_last"), "converged": bool(info.get("converged")), + "velocity_pc": velpc.getType(), "nnz_per_row": chain_density(velpc), + "slip": float(np.linalg.norm(jumps - jn[:, None] * normals, axis=1).max()), + "leak": float(np.abs(jn).max()), + "levels": len(tail) + 1, "cells": int(mesh.dm.getHeightStratum(0)[1]), + "t_solve": round(time.perf_counter() - t0, 1)} + else: + mesh = ribbons[s_fine] + stokes, mask = ti_stokes(mesh, f"t{k}") + set_custom_fmg(stokes, tail, field_id=0, fac_zone=mask) + stokes.solve() + ksp = stokes.snes.getKSP() + velksp = ksp.getPC().getFieldSplitSubKSP()[0] + velpc = velksp.getPC() + # iterations of the velocity sub-solve on the last outer application + rec = {"arm": name, "kind": kind, "vel_its": velksp.getIterationNumber(), + "outer_its": ksp.getIterationNumber(), "velocity_pc": velpc.getType(), + "nnz_per_row": chain_density(velpc) if velpc.getType() == "mg" else None, + "levels": len(tail) + 1, "cells": int(mesh.dm.getHeightStratum(0)[1]), + "t_solve": round(time.perf_counter() - t0, 1)} + records.append(rec) + print("ARM", json.dumps(rec), flush=True) +json.dump(records, open(os.path.join(out, "ladder_depth_split.json"), "w"), indent=1) +print("DONE") diff --git a/scripts/sessions/fault_mg/ladder_depth_split4.py b/scripts/sessions/fault_mg/ladder_depth_split4.py new file mode 100644 index 000000000..5d38fb74f --- /dev/null +++ b/scripts/sessions/fault_mg/ladder_depth_split4.py @@ -0,0 +1,161 @@ +"""C on the ladder with the split chain starting at the base spacing: the +test_1022 rig at 0.0025 with four nested SPLIT mids 0.04, 0.02, 0.01, 0.005, +contact only. Companion to ladder_depth_split.py. + +The finest level is a placed transfinite ribbon (spacing S) with the spine cut +and split (or, for TI, left whole with the band's footprint painted +transversely isotropic). The mid levels are the same ribbon at 2:1 and 4:1 +spacing, welded or split on the same spine vertices. Base: L0 + L1. + +python ladder_c.py +""" +import json +import os +import sys +import time + +import numpy as np +import sympy +import underworld3 as uw +from underworld3.utilities import fault_contact +from underworld3.utilities.custom_mg import set_custom_fmg +from underworld3.utilities.place_surface import place_thin_volume + +out = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/+Simulations/fault_split_mg") +os.makedirs(out, exist_ok=True) +H_BG = 1.0 / 8 +WIDTH = 0.06 +TRACE = np.array([[0.15, 0.42], [0.85, 0.58]]) +TIP = 0.04 # the cut stops this far inside the band, every level +FAULT = "Main" +a, b = TRACE[0], TRACE[-1] +t_hat = (b - a) / np.linalg.norm(b - a) +L = float(np.linalg.norm(b - a)) +n_hat = np.array([-t_hat[1], t_hat[0]]) + + +def banded(base, size): + for clearance in (0.55, 0.7, 0.9): + try: + dm, _info = place_thin_volume( + base.dm, [TRACE], width=WIDTH, label="Zone", label_value=31, + size=size, clearance=clearance, assembly="fuse", mesher="ladder") + return uw.discretisation.Mesh( + dm, simplex=True, qdegree=2, boundaries=base.boundaries, + coordinate_system_type=base.CoordinateSystem.coordinate_type) + except RuntimeError: + continue + raise RuntimeError(f"band build failed at size {size}") + + +def spine_line(size): + """the cut, sampled at the ladder's own rung vertices, tips TIP inside""" + n_along = max(2, int(round(L / size))) + step = L / n_along + k0 = int(round(TIP / step)) + return a + (np.arange(k0, n_along - k0 + 1) * step)[:, None] * t_hat + + +def split_ribbon(mesh, size): + return mesh.add_fault((FAULT, spine_line(size)), split_levels=False) + + +def contact_stokes(mesh, tag): + x, y = mesh.X + v = uw.discretisation.MeshVariable(f"v{tag}", mesh, 2, degree=2) + p = uw.discretisation.MeshVariable(f"p{tag}", mesh, 1, degree=1, continuous=True) + s = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + s.constitutive_model = uw.constitutive_models.ViscousFlowModel + s.constitutive_model.Parameters.shear_viscosity_0 = 1.0 + s.bodyforce = [0.0, 0.0] + for wall in ("Bottom", "Top", "Left", "Right"): + s.add_dirichlet_bc((y - 0.5, 0.0), wall) + s.add_fault_bc(0, boundary=FAULT) + s.petsc_use_pressure_nullspace = True + s.tolerance = 1e-5 + s.strategy = "robust" + return s + + +def ti_stokes(mesh, tag, contrast=1e-3): + x, y = mesh.X + v = uw.discretisation.MeshVariable(f"v{tag}", mesh, 2, degree=2) + p = uw.discretisation.MeshVariable(f"p{tag}", mesh, 1, degree=1, continuous=True) + zone = uw.discretisation.MeshVariable(f"zone{tag}", mesh, 1, degree=0, continuous=False) + mask = mesh.cells_labelled("Zone", 31) + zone.array[:, 0, 0] = mask.astype(float) + s = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + s.constitutive_model = uw.constitutive_models.TransverseIsotropicFlowModel + s.constitutive_model.Parameters.shear_viscosity_0 = 1.0 + s.constitutive_model.Parameters.shear_viscosity_1 = 1.0 - (1.0 - contrast) * zone.sym[0] + s.constitutive_model.Parameters.director = sympy.Matrix([[float(n_hat[0]), float(n_hat[1])]]) + s.bodyforce = [0.0, 0.0] + for wall in ("Bottom", "Top", "Left", "Right"): + s.add_dirichlet_bc((y - 0.5, 0.0), wall) + s.petsc_use_pressure_nullspace = True + s.tolerance = 1e-5 + s.strategy = "robust" + return s, mask + + +def chain_density(velpc): + out = [] + for l in range(velpc.getMGLevels()): + A = velpc.getMGSmoother(l).getOperators()[0] + out.append(round(A.getInfo()["nz_used"] / max(A.getSize()[0], 1), 1)) + return out + + +records = [] +base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), cellSize=H_BG, + regular=False, qdegree=2, refinement=1) +L01 = base._coarse_level_meshes()[:-1] + [base] +ribbons = {} +for size in (0.04, 0.02, 0.01, 0.005, 0.0025): + t0 = time.perf_counter() + ribbons[size] = banded(base, size) + print(f"ribbon {size}: {ribbons[size].dm.getHeightStratum(0)[1]} cells " + f"({time.perf_counter() - t0:.1f} s)", flush=True) +split = {size: split_ribbon(m, size) for size, m in ribbons.items()} + +arms = [] +for mids in ([0.04, 0.02, 0.01, 0.005],): + arms.append((f"fine 0.0025: split mid {mids}", 0.0025, [split[m] for m in mids], "contact")) + +for k, (name, s_fine, mids, kind) in enumerate(arms): + tail = L01 + mids + t0 = time.perf_counter() + if kind == "contact": + mesh = split[s_fine] + stokes = contact_stokes(mesh, f"c{k}") + set_custom_fmg(stokes, tail, field_id=0) + info = fault_contact.solve_with_fault(stokes) + ctx = (getattr(stokes, "_rotated_linear_cache", None) or {})["ctx"] + velpc = ctx["ksp"].getPC().getFieldSplitSubKSP()[0].getPC() + _c, jumps, normals = fault_contact.fault_pair_jumps(stokes, FAULT, stokes._rotated_freeslip_info) + jn = np.einsum("ij,ij->i", jumps, normals) + rec = {"arm": name, "kind": kind, "vel_its": ctx.get("vel_its_last"), + "pres_its": ctx.get("pres_its_last"), "converged": bool(info.get("converged")), + "velocity_pc": velpc.getType(), "nnz_per_row": chain_density(velpc), + "slip": float(np.linalg.norm(jumps - jn[:, None] * normals, axis=1).max()), + "leak": float(np.abs(jn).max()), + "levels": len(tail) + 1, "cells": int(mesh.dm.getHeightStratum(0)[1]), + "t_solve": round(time.perf_counter() - t0, 1)} + else: + mesh = ribbons[s_fine] + stokes, mask = ti_stokes(mesh, f"t{k}") + set_custom_fmg(stokes, tail, field_id=0, fac_zone=mask) + stokes.solve() + ksp = stokes.snes.getKSP() + velksp = ksp.getPC().getFieldSplitSubKSP()[0] + velpc = velksp.getPC() + # iterations of the velocity sub-solve on the last outer application + rec = {"arm": name, "kind": kind, "vel_its": velksp.getIterationNumber(), + "outer_its": ksp.getIterationNumber(), "velocity_pc": velpc.getType(), + "nnz_per_row": chain_density(velpc) if velpc.getType() == "mg" else None, + "levels": len(tail) + 1, "cells": int(mesh.dm.getHeightStratum(0)[1]), + "t_solve": round(time.perf_counter() - t0, 1)} + records.append(rec) + print("ARM", json.dumps(rec), flush=True) +json.dump(records, open(os.path.join(out, "ladder_depth_split4.json"), "w"), indent=1) +print("DONE") From f42ca30eca4d6eb6e2637f360326c1827f104d42 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Wed, 14 Oct 2026 06:09:35 +1100 Subject: [PATCH 42/57] Faults on the annulus: the ladder outcrops, the coarse levels take the wall rotation Placement on a curved wall (utilities/place_surface.py): - the domain tool winds every boundary loop counter-clockwise; a hole loop wound against the exterior was added as area and the ribbon met nothing (annulus at cell sizes 0.04 and 0.08, "meshed to no triangles"); - the 2-D outcrop frame groups the wall's chords into smooth walls (the 3-D rule) and the splice keeps the curve's own vertices, so a dipping band's clearance cavity is not refused as contact away from the outcrop and the cap does not chord across the sagitta the area guard refuses; - the structured ladder outcrops: rungs inside the domain to one rung short of the wall, closed by a small unstructured cap clipped against the domain. The spine keeps its rung vertices for the split; the cap is the TI bridge's home. Custom multigrid under rotated free-slip (utilities/fault_mg.py, rotated_bc.py): - every coarse level takes the wall rotation on its own nodes (level_wall_rotation, composed with a split level's pair rotation by level_rotation) and loses its normal rows. Cartesian coarse levels under a rotated finest kept wall-normal unknowns the fine operator reaches only through vanishing sines; the inode SOR smoother's nodal block inversion met a zero pivot and the velocity solve failed before its first iteration; - the orphan rule's reach test is relative (UNREACHED_REL) and runs after the finest's constrained rows are zeroed; - the finest's FAC patch rows survive the per-level restriction. tests/test_1091_place_outcrop_curved_wall.py covers the placer on the annulus. scripts/sessions/fault_mg/zg95_{probe,annulus,profile}.py: the Zhong & Gurnis (1995) analogue (annulus, temperature-dependent lid, dipping trench that moves with the overriding plate, topography and surface velocity), its probe and its profile (meshing 5 s against a 60 s solve, the solve being Schur Krylov work). Underworld development team with AI support from Claude Code --- .../conforming-surfaces-and-fault-zones.md | 21 + .../subsystems/fault-split-multigrid.md | 34 ++ docs/developer/subsystems/rotated-freeslip.md | 8 + scripts/sessions/fault_mg/zg95_annulus.py | 366 ++++++++++++++++ scripts/sessions/fault_mg/zg95_probe.py | 397 ++++++++++++++++++ scripts/sessions/fault_mg/zg95_profile.py | 120 ++++++ src/underworld3/utilities/fault_mg.py | 194 ++++++++- src/underworld3/utilities/place_surface.py | 299 +++++++++++-- src/underworld3/utilities/rotated_bc.py | 37 +- tests/test_1091_place_outcrop_curved_wall.py | 123 ++++++ 10 files changed, 1532 insertions(+), 67 deletions(-) create mode 100644 scripts/sessions/fault_mg/zg95_annulus.py create mode 100644 scripts/sessions/fault_mg/zg95_probe.py create mode 100644 scripts/sessions/fault_mg/zg95_profile.py create mode 100644 tests/test_1091_place_outcrop_curved_wall.py diff --git a/docs/developer/subsystems/conforming-surfaces-and-fault-zones.md b/docs/developer/subsystems/conforming-surfaces-and-fault-zones.md index ac76dd8e6..8bc12808c 100644 --- a/docs/developer/subsystems/conforming-surfaces-and-fault-zones.md +++ b/docs/developer/subsystems/conforming-surfaces-and-fault-zones.md @@ -437,6 +437,27 @@ Refusals, stated: one flat wall per object (box-edge outcrops and multi-wall contact refuse); curved or deformed tops refuse (the cap's manifold-prep slot); removal of an outcropping object is not yet wired. +### The ladder through the wall + +The structured ladder (`mesher="ladder"`: rungs on the spine's samples, one element +either side, nothing snapped) used to be refused wherever it protruded past the +domain, in either realisation. It now outcrops: the ladder runs on the sample rows +inside the domain to one rung short of the wall, and a small unstructured cap, +clipped against the domain polygon exactly as the default band is, closes it to +the outcrop (`_ladder_outcrop_assembly_2d`). The spine keeps its rung vertices up +to the last rung, so a split walks them; the cap's cells are where a weak layer +bridges a split that must stop blind of the surface. On the ribbon placer give the +polyline that reaches past the wall as the `spines` entry and the samples at least +three spacings inside as the trace (a trace nearer the wall meets a wall vertex in +the end-vertex pull). Two defects on curved walls were fixed with it: the domain +tool now winds every boundary loop counter-clockwise (a hole loop wound against the +exterior was added as area, so an annulus at some cell sizes met nothing), and the +2-D outcrop frame groups the wall's chords into smooth walls as the 3-D rule does, +keeping the curve's own vertices through the splice (a dipping band's clearance +cavity spans a few chords beside the one it crosses, and chording across the kept +vertices lost the sagitta the area guard refuses). +`tests/test_1091_place_outcrop_curved_wall.py`. + ## Limitations * **An essential boundary condition on the surface is not sound** under the geometric multigrid hierarchy. The coarse levels do not carry the surface, so diff --git a/docs/developer/subsystems/fault-split-multigrid.md b/docs/developer/subsystems/fault-split-multigrid.md index 747ac5e6c..b6fad6dd2 100644 --- a/docs/developer/subsystems/fault-split-multigrid.md +++ b/docs/developer/subsystems/fault-split-multigrid.md @@ -62,6 +62,34 @@ orphans, with and without a split finest) and 512 duplicated columns at np = 2. coarse levels' FAC patch rows do not survive the renumbering and are cleared; the finest's are unchanged. +## Rotated walls on every level + +A hierarchy under rotated free-slip walls (an annulus, a spherical shell) rotates +its coarse levels too (`level_wall_rotation`, composed with the pair rotation of a +split level by `level_rotation`; `_build_rotated_custom_Pl` routes any hierarchy +with rotated walls through `rotated_split_transfers`). Each coarse level takes the +same per-node frame as the finest, built on the level's own wall nodes from the +solver's boundary specifications (the analytic normal evaluated at the level's +nodes, or the level's own measure-weighted facet normals), and its normal rows are +removed. Before this the coarse levels were Cartesian and only the finest's normal +rows were zeroed in the transfer. A coarse wall node's normal Cartesian component +was then reached only through the fine tangential rows, weighted by the sine of the +angle between the two nodes' normals: a Galerkin nodal block of 1e-3 against 2.5 at +a node near an axis, 1e-20 on the axis. The inode SOR smoother inverts those 2x2 +blocks and reported a zero pivot, and the velocity solve failed before its first +iteration (measured on the annulus of the Zhong & Gurnis analogue; a box with +Dirichlet walls never showed it because an axis-aligned wall's sines are exactly +zero). Two further rules came out of the same case: + +- the reach test of the orphan rule is relative (`UNREACHED_REL`, 1e-8 of the + level's largest column, squared norms) and runs after the finest's constrained + rows are zeroed, not before; +- the free-slip gauge mode must verify for the SVD coarse solve to be selected, + which on a faceted circle needs the exact radial normal + (`normal=mesh.CoordinateSystem.unit_e_0`); and that SVD needs a small coarsest + level (14k unknowns took over 30 minutes, 900 is instant), so a base with three + halvings rather than one. + ## Parallel Every split level is redistributed so its fault star is rank-interior (the split's own @@ -118,6 +146,9 @@ split 3-D level. ## Tests +`tests/test_1091_place_outcrop_curved_wall.py` (the placer on an annulus: loop +orientation, the dipping band through the curved wall, the outcropping ladder on +both placement routes) and `tests/test_1087_fault_split_multigrid.py` (tail structure, iterations falling with refinement against the welded tail, viscous fault, a moving fault, the transfer's side awareness and quadratic exactness) and @@ -126,6 +157,9 @@ welded tails converge with the serial slip; the thick twin extruded with its pai ## Not yet done +- A regression test for the coarse-level wall rotation itself (a rotated free-slip + annulus with a placed band and the custom tail), beyond the example scripts + (`scripts/sessions/fault_mg/zg95_probe.py`, `zg95_annulus.py`). - The 3-D sheet placement on an NVB-refined child needs `clearance` of about 0.9 at one add-on level (its volume guard fires at the default 0.6) and refuses the two-level child at every clearance and sheet size tried (volume change 4e-6 to diff --git a/docs/developer/subsystems/rotated-freeslip.md b/docs/developer/subsystems/rotated-freeslip.md index 668947b73..b63536c49 100644 --- a/docs/developer/subsystems/rotated-freeslip.md +++ b/docs/developer/subsystems/rotated-freeslip.md @@ -200,6 +200,14 @@ Galerkin-coarsened rotated block inherits the rigid-rotation null space where `redundant`/LU hits a zero pivot. See [the solvers subsystem doc](solvers.md) for the bundle itself. +The coarse levels are rotated as well (`fault_mg.level_wall_rotation`, since +2026-10-14): each takes the per-node wall frame on its own nodes and loses its +normal rows, so the coarse space is exactly the constrained motion. A Cartesian +coarse level under a rotated finest keeps wall-normal unknowns the fine operator +reaches only through vanishing sines, and the smoother's nodal block inversion +meets a zero pivot there. Details in +[the split-level multigrid doc](fault-split-multigrid.md). + ### The velocity sub-KSP must converge, not just apply The velocity block is preconditioned by multigrid, but the KSP *around* that diff --git a/scripts/sessions/fault_mg/zg95_annulus.py b/scripts/sessions/fault_mg/zg95_annulus.py new file mode 100644 index 000000000..3208a3ac2 --- /dev/null +++ b/scripts/sessions/fault_mg/zg95_annulus.py @@ -0,0 +1,366 @@ +"""A Zhong & Gurnis (1995) analogue on the annulus: temperature-dependent +convection with a dipping trench fault that moves with the overriding plate, +surface velocity and dynamic topography read every step. + +The base annulus (static, balanced, never cut) carries the temperature and +the advection-diffusion. The add-on finest is the base's finest with the +trench placed as the structured ladder (one element either side of the +spine, outcropping through its cap); it is rebuilt whenever the trench has +moved by a rung. Stokes is solved on the add-on with rotated free-slip on +both walls (exact radial normal), the spine split and in frictionless +contact (``--arm split``, the ladder's cap and last rungs painted TI to +bridge to the surface) or the whole band painted TI (``--arm ti``), and +custom FMG on the base's own levels. Velocity goes back to the base by +evaluation for the semi-Lagrangian step. + +python zg95_annulus.py --out ~/+Simulations/zg95/run1 --steps 50 [--arm split|ti] +""" +import argparse +import gc +import json +import os +import time + +import numpy as np +import sympy +import underworld3 as uw +from scipy.special import erf +from mpi4py import MPI +from underworld3.utilities.custom_mg import set_custom_fmg +from underworld3.utilities.place_surface import place_fault_ribbon_2d + +comm = uw.mpi.comm + + +def gmax(x): + return float(comm.allreduce(float(x), op=MPI.MAX)) + + +def gmin(x): + return float(comm.allreduce(float(x), op=MPI.MIN)) + + +def gather_arrays(*arrs): + """Concatenate rank-local 1-D arrays onto every rank (small surface data).""" + out = [] + for a in arrs: + parts = comm.allgather(np.asarray(a)) + out.append(np.concatenate(parts) if parts else np.asarray(a)) + return out + +ap = argparse.ArgumentParser() +ap.add_argument("--out", default=os.path.expanduser("~/+Simulations/zg95/run1")) +ap.add_argument("--steps", type=int, default=50) +ap.add_argument("--spinup", type=int, default=0, help="steps on the base without the fault first") +ap.add_argument("--ra", type=float, default=1e6) +ap.add_argument("--deta", type=float, default=1e3) +ap.add_argument("--eta_w", type=float, default=1e-3, help="TI weak-plane viscosity ratio") +ap.add_argument("--H", type=float, default=0.16, help="coarsest base cell") +ap.add_argument("--ref", type=int, default=3, help="base halvings (finest base cell H / 2**ref)") +ap.add_argument("--S", type=float, default=0.005, help="ladder spacing") +ap.add_argument("--dip", type=float, default=45.0) +ap.add_argument("--depth", type=float, default=0.15) +ap.add_argument("--arm", choices=["split", "ti"], default="split") +ap.add_argument("--fac", type=int, default=0, help="band patch smoother on the finest") +ap.add_argument("--dt_mult", type=float, default=2.0) +ap.add_argument("--dt_max", type=float, default=1e-3) +ap.add_argument("--restart", type=int, default=-1, help="restart T from checkpoint index in --out (skips the spin-up)") +ap.add_argument("--ckpt_every", type=int, default=25) +ap.add_argument("--fig_every", type=int, default=5) +ap.add_argument("--theta0", type=float, default=0.0, help="initial trench position (rad)") +ap.add_argument("--migrate", type=int, default=1, help="advect the trench with the overriding plate") +args = ap.parse_args() +os.makedirs(args.out, exist_ok=True) +RI, RO = 0.55, 1.0 +S, W = args.S, 2.0 * args.S +DIP = np.deg2rad(args.dip) +rank0 = uw.mpi.rank == 0 + + +def log(*a): + if rank0: + print(*a, flush=True) + + +# ----------------------------------------------------------------------------- base +base = uw.meshing.Annulus(radiusOuter=RO, radiusInner=RI, cellSize=args.H, refinement=args.ref, + qdegree=2) +tail = base._coarse_level_meshes()[:-1] + [base] +T_b = uw.discretisation.MeshVariable("T_b", base, 1, degree=2) +V_b = uw.discretisation.MeshVariable("V_b", base, 2, degree=2) +xb = np.asarray(T_b.coords) +rb = np.hypot(xb[:, 0], xb[:, 1]) +thb = np.arctan2(xb[:, 1], xb[:, 0]) +z = (RO - rb) / (RO - RI) +T_int = 0.6 +T0 = (T_int * erf(z / 0.1) + (1.0 - T_int) * (1.0 - erf((1.0 - z) / 0.1)) + - 0.04 * np.cos(3 * thb) * np.sin(np.pi * z)) # downwelling under the trench at theta 0 +T_b.array[:, 0, 0] = np.clip(T0, 0.0, 1.0) +if args.restart >= 0: + T_b.read_timestep("zg95", "T_b", args.restart, outputPath=args.out) + log(f"restarted T from checkpoint {args.restart}") +log(f"base: {base.dm.getHeightStratum(0)[1]} cells, {len(tail)} levels; finest cell {args.H / 2**args.ref}") + +adv = uw.systems.AdvDiffusionSLCN(base, u_Field=T_b, V_fn=V_b.sym, order=1, monotone_mode="clamp") +adv.constitutive_model = uw.constitutive_models.DiffusionModel +adv.constitutive_model.Parameters.diffusivity = 1.0 +adv.f = 0.0 +adv.add_dirichlet_bc(0.0, "Upper") +adv.add_dirichlet_bc(1.0, "Lower") +unit_r_b = base.CoordinateSystem.unit_e_0 + + +def eta_of(Tsym): + return sympy.exp(sympy.log(args.deta) * (1 - Tsym)) # 1 at T = 1, deta at T = 0 + + +def clamp_radii(x): + r = np.hypot(x[:, 0], x[:, 1]) + f = np.clip(r, RI + 1e-10, RO - 1e-10) / np.maximum(r, 1e-14) + return x * f[:, None] + + +# ----------------------------------------------------------------------------- add-on +class AddOn: + """The trench at angle theta_t placed into a copy of the base's finest, with + its Stokes solver.""" + + def __init__(self, theta_t, tag): + t0 = time.perf_counter() + t_hat = np.array([np.cos(theta_t), np.sin(theta_t)]) + s_hat = np.array([-np.sin(theta_t), np.cos(theta_t)]) + d_hat = -np.cos(DIP) * t_hat + np.sin(DIP) * s_hat # down-dip toward +theta + self.n_hat = np.array([-d_hat[1], d_hat[0]]) + top = t_hat * (RO + 0.02) + L = (args.depth + 0.02) / np.cos(DIP) + n_s = int(round(L / S)) + spine = np.array([top + d_hat * (L * i / n_s) for i in range(n_s + 1)]) + r_sp = np.hypot(spine[:, 0], spine[:, 1]) + self.trace = spine[r_sp <= RO - 3.0 * S] + self.theta_t = theta_t + self.mesh, self.info = place_fault_ribbon_2d( + base, [("Trench", self.trace)], W, spines=[("Trench", spine)], margin_rings=1, + clearance=0.7, split=(args.arm == "split"), mesher="ladder") + mesh = self.mesh + self.band = np.asarray(self.info["band"], dtype=bool) + self.T = uw.discretisation.MeshVariable(f"T_{tag}", mesh, 1, degree=2) + self.v = uw.discretisation.MeshVariable(f"U_{tag}", mesh, 2, degree=2) + self.p = uw.discretisation.MeshVariable(f"P_{tag}", mesh, 1, degree=1, continuous=True) + self.h = uw.discretisation.MeshVariable(f"h_{tag}", mesh, 1, degree=1) + self.zone = uw.discretisation.MeshVariable(f"zone_{tag}", mesh, 1, degree=0, continuous=False) + dm = mesh.dm + vS, vE = dm.getDepthStratum(0) + cS, cE = dm.getHeightStratum(0) + X = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, 2) + if args.arm == "split": + pairs = (getattr(mesh, "_fault_point_pairs", None) or {}).get("Trench", {}) + pp = np.array([int(q) for q in pairs] + [int(q) for q in pairs.values()], dtype=np.int64) + pv = pp[(pp >= vS) & (pp < vE)] - vS + self.r_tip = gmax(np.hypot(X[pv, 0], X[pv, 1]).max() if pv.size else 0.0) + cent = np.array([X[[int(q) - vS for q in dm.getTransitiveClosure(c)[0] if vS <= q < vE]].mean(axis=0) + for c in range(cS, cE)]) + self.bridge = self.band & (np.hypot(cent[:, 0], cent[:, 1]) > self.r_tip - 0.5 * W) + else: + self.r_tip = None + self.bridge = self.band.copy() + self.zone.array[:, 0, 0] = self.bridge.astype(float) + s = uw.systems.Stokes(mesh, velocityField=self.v, pressureField=self.p) + eta0 = eta_of(self.T.sym[0]) + s.constitutive_model = uw.constitutive_models.TransverseIsotropicFlowModel + s.constitutive_model.Parameters.shear_viscosity_0 = eta0 + s.constitutive_model.Parameters.shear_viscosity_1 = eta0 * (1 - (1 - args.eta_w) * self.zone.sym[0]) + s.constitutive_model.Parameters.director = sympy.Matrix([[float(self.n_hat[0]), float(self.n_hat[1])]]) + unit_r = mesh.CoordinateSystem.unit_e_0 + s.bodyforce = args.ra * self.T.sym[0] * unit_r + s.add_rotated_freeslip_bc(0, "Upper", normal=unit_r) + s.add_rotated_freeslip_bc(0, "Lower", normal=unit_r) + if args.arm == "split": + s.add_fault_bc(0, boundary="Trench") + s.petsc_use_pressure_nullspace = True + s.tolerance = 1e-5 + set_custom_fmg(s, tail, field_id=0, fac_zone=self.band if args.fac else None) + self.stokes = s + self.t_build = time.perf_counter() - t0 + log(f" add-on {tag}: {mesh.dm.getHeightStratum(0)[1]} cells, band {int(self.band.sum())}, " + f"bridge {int(self.bridge.sum())}, trench at {np.degrees(theta_t):.3f} deg, built in {self.t_build:.1f} s") + + def solve(self): + self.T.array[:, 0, 0] = np.asarray(uw.function.global_evaluate( + T_b.sym[0], clamp_radii(np.asarray(self.T.coords)))).ravel() + t0 = time.perf_counter() + self.stokes.solve() + info = self.stokes._rotated_freeslip_info + self.t_solve = time.perf_counter() - t0 + self.stokes.dynamic_topography("Upper", self.h, buoyancy_scale=args.ra) + rec = {"vel_its": info.get("vel_its_last"), "pres_its": info.get("pres_its_last"), + "converged": bool(info.get("converged")), "t_solve": round(self.t_solve, 1)} + if args.arm == "split": + from underworld3.utilities.fault_contact import fault_slip + _s, Vs, leak = fault_slip(self.stokes, "Trench", info) + rec["slip_max"] = gmax(np.abs(Vs).max() if len(Vs) else 0.0) + rec["leak"] = gmax(np.abs(leak).max() if len(leak) else 0.0) + return rec + + def surface(self): + vc = np.asarray(self.v.coords) + on = np.hypot(vc[:, 0], vc[:, 1]) > RO - 1e-6 + th = np.arctan2(vc[on, 1], vc[on, 0]) + vx = self.v.array[on, 0, :] + vt = -vx[:, 0] * np.sin(th) + vx[:, 1] * np.cos(th) + hc = np.asarray(self.h.coords) + hon = np.hypot(hc[:, 0], hc[:, 1]) > RO - 1e-6 + thh = np.arctan2(hc[hon, 1], hc[hon, 0]) + th, vt, thh, hh = gather_arrays(th, vt, thh, self.h.array[hon, 0, 0]) + o = np.argsort(th) + oh = np.argsort(thh) + return th[o], vt[o], thh[oh], hh[oh] + + +# ----------------------------------------------------------------------------- base-only Stokes (spin-up) +def base_stokes(tag): + v = uw.discretisation.MeshVariable(f"Ub_{tag}", base, 2, degree=2) + p = uw.discretisation.MeshVariable(f"Pb_{tag}", base, 1, degree=1, continuous=True) + s = uw.systems.Stokes(base, velocityField=v, pressureField=p) + s.constitutive_model = uw.constitutive_models.ViscousFlowModel + s.constitutive_model.Parameters.shear_viscosity_0 = eta_of(T_b.sym[0]) + s.bodyforce = args.ra * T_b.sym[0] * unit_r_b + s.add_rotated_freeslip_bc(0, "Upper", normal=unit_r_b) + s.add_rotated_freeslip_bc(0, "Lower", normal=unit_r_b) + s.petsc_use_pressure_nullspace = True + s.tolerance = 1e-5 + set_custom_fmg(s, tail[:-1], field_id=0) + return s, v + + +# ----------------------------------------------------------------------------- figures +def figure(step, t, addon, rec): + surf = addon.surface() if addon is not None else None # collective gather + if not rank0: + return + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + import matplotlib.tri as mtri + from matplotlib.collections import LineCollection + fig, axes = plt.subplots(1, 3, figsize=(18, 5.6), gridspec_kw={"width_ratios": [1.2, 1, 1]}) + ax = axes[0] + if uw.mpi.size == 1: + dm = base.dm + vS, vE = dm.getDepthStratum(0) + cS, cE = dm.getHeightStratum(0) + X = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, 2) + cells = np.array([[int(q) - vS for q in dm.getTransitiveClosure(c)[0] if vS <= q < vE] for c in range(cS, cE)]) + Tv = uw.function.evaluate(T_b.sym[0], clamp_radii(X)).ravel() + tc = ax.tripcolor(mtri.Triangulation(X[:, 0], X[:, 1], cells), Tv, shading="gouraud", cmap="RdBu_r", + vmin=0, vmax=1) + fig.colorbar(tc, ax=ax, shrink=0.8) + if addon is not None: + tr = addon.trace + ax.plot(tr[:, 0], tr[:, 1], color="k", lw=1.5) + ax.set_aspect("equal"); ax.set_xticks([]); ax.set_yticks([]) + ax.set_title(f"step {step}, t = {t:.5f}: T on the base (serial render); trench (black)", fontsize=9, loc="left") + if addon is not None: + th, vt, thh, hh = surf + ax = axes[1] + ax.plot(np.degrees(th), vt, lw=1) + ax.axvline(np.degrees(addon.theta_t), color="#c0392b", lw=0.8, ls="--") + ax.set_xlabel("theta (deg)"); ax.set_ylabel("surface tangential velocity") + ax.set_title(f"surface velocity; velocity its {rec.get('vel_its')}, slip {rec.get('slip_max', 0):.2f}", + fontsize=9, loc="left") + ax.grid(alpha=0.3) + ax = axes[2] + ax.plot(np.degrees(thh), hh, lw=1) + ax.axvline(np.degrees(addon.theta_t), color="#c0392b", lw=0.8, ls="--") + ax.set_xlabel("theta (deg)"); ax.set_ylabel("dynamic topography (-(s_nn - mean)/Ra)") + ax.set_title("topography from the free-slip reaction", fontsize=9, loc="left") + ax.grid(alpha=0.3) + fig.tight_layout() + fig.savefig(os.path.join(args.out, f"step_{step:04d}.png"), dpi=130) + plt.close(fig) + + +# ----------------------------------------------------------------------------- run +records = [] +t = 0.0 +step = 0 + + +def checkpoint(step, t): + base.write_timestep("zg95", meshUpdates=False, meshVars=[T_b, V_b], index=step, outputPath=args.out) + if rank0: + json.dump({"step": step, "t": t, "theta_t": float(globals().get("theta_t", args.theta0))}, + open(os.path.join(args.out, f"state_{step:04d}.json"), "w")) + json.dump(records, open(os.path.join(args.out, "records.json"), "w"), indent=1) + + +if args.spinup > 0 and args.restart < 0: + s_b, v_b = base_stokes("spin") + for k in range(args.spinup): + t0 = time.perf_counter() + s_b.solve() + info = s_b._rotated_freeslip_info + V_b.array[...] = v_b.array + dt = min(args.dt_mult * float(adv.estimate_dt()), args.dt_max) + adv.solve(timestep=dt) + t += dt + vmax = gmax(np.linalg.norm(V_b.array[:, 0, :], axis=1).max() if V_b.array.shape[0] else 0.0) + rec = {"step": step, "phase": "spinup", "t": t, "dt": dt, "vel_its": info.get("vel_its_last"), + "vmax": vmax, "t_step": round(time.perf_counter() - t0, 1)} + records.append(rec) + log("SPIN", json.dumps(rec)) + if step % args.fig_every == 0: + figure(step, t, None, rec) + if step % args.ckpt_every == 0 or k == args.spinup - 1: + checkpoint(step, t) + step += 1 + del s_b, v_b + gc.collect() +if args.restart >= 0: + st = json.load(open(os.path.join(args.out, f"state_{args.restart:04d}.json"))) + t, step = float(st["t"]), int(st["step"]) + 1 + args.theta0 = float(st.get("theta_t", args.theta0)) + +theta_t = args.theta0 +addon = None +n_built = 0 +for k in range(args.steps): + t0 = time.perf_counter() + if addon is None or abs(theta_t - addon.theta_t) * RO > S: + if addon is not None: + del addon + gc.collect() + addon = AddOn(theta_t, f"a{n_built}") + n_built += 1 + rec = addon.solve() + th, vt, thh, hh = addon.surface() + # the trench moves with the overriding plate: the surface just on the +theta side + j = int(np.argmin(np.abs(th - (addon.theta_t + 0.03)))) + v_trench = float(vt[j]) + # velocity back onto the base for the advection step + Vb = np.asarray(uw.function.global_evaluate(addon.v.sym, clamp_radii(np.asarray(V_b.coords)))).reshape(-1, 2) + V_b.array[:, 0, :] = Vb + dt = min(args.dt_mult * float(adv.estimate_dt()), args.dt_max) + adv.solve(timestep=dt) + t += dt + if args.migrate: + theta_t += v_trench * dt / RO + vmax = gmax(np.linalg.norm(V_b.array[:, 0, :], axis=1).max() if V_b.array.shape[0] else 0.0) + rec.update({"step": step, "phase": "fault", "t": t, "dt": dt, "vmax": vmax, "v_trench": v_trench, + "theta_t": float(theta_t), "theta_built": float(addon.theta_t), "n_built": n_built, + "topo_min": float(hh.min()), "topo_max": float(hh.max()), + "np": uw.mpi.size, + "t_build": round(addon.t_build, 1), "t_step": round(time.perf_counter() - t0, 1)}) + records.append(rec) + log("STEP", json.dumps(rec)) + if step % args.fig_every == 0 or k == args.steps - 1: + figure(step, t, addon, rec) + if rank0: + json.dump(records, open(os.path.join(args.out, "records.json"), "w"), indent=1) + np.savez(os.path.join(args.out, f"surface_{step:04d}.npz"), theta=th, vt=vt, theta_h=thh, h=hh, + trace=addon.trace) + if step % args.ckpt_every == 0 or k == args.steps - 1: + checkpoint(step, t) + step += 1 +if rank0: + json.dump(records, open(os.path.join(args.out, "records.json"), "w"), indent=1) +log("DONE") diff --git a/scripts/sessions/fault_mg/zg95_probe.py b/scripts/sessions/fault_mg/zg95_probe.py new file mode 100644 index 000000000..4cb87b0a0 --- /dev/null +++ b/scripts/sessions/fault_mg/zg95_probe.py @@ -0,0 +1,397 @@ +"""Probe the pieces of the Zhong & Gurnis (1995) analogue on a small annulus: +a dipping trace running past the outer wall placed as a ladder band on the +base's finest, spine split blind below the surface, a TI bridge painted on the +band's top, rotated free-slip on both walls, temperature-dependent viscosity +from a base-mesh T evaluated onto the add-on, custom FMG with the base tail, +topography and surface velocity read back. + +python zg95_probe.py +""" +import os +import sys +import time + +import numpy as np +import sympy +import underworld3 as uw +from underworld3.utilities.custom_mg import set_custom_fmg + +out = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/+Simulations/zg95") +os.makedirs(out, exist_ok=True) +RI, RO = 0.55, 1.0 +H = 0.16 # coarsest base cell; REF halvings below it +REF = 3 # finest base cell H / 2**REF = 0.02; the coarsest annulus + # stays small enough for the SVD coarse solve the free-slip + # gauge needs (14k unknowns at H = 0.08 took over 30 min) +DIP = np.deg2rad(45.0) +DEPTH = 0.15 # fault reaches this far below the surface +RA, DETA = 1e6, 1e3 + +t0 = time.perf_counter() +base = uw.meshing.Annulus(radiusOuter=RO, radiusInner=RI, cellSize=H, refinement=REF, qdegree=2) +print(f"base: {base.dm.getHeightStratum(0)[1]} cells, levels {len(base._coarse_level_meshes())}", + flush=True) +tail = base._coarse_level_meshes()[:-1] + [base] +S = 0.005 + +# temperature on the BASE: conductive profile + a lid, perturbed +T_b = uw.discretisation.MeshVariable("T_b", base, 1, degree=2) +r_b, th_b = base.CoordinateSystem.R +xb = np.asarray(T_b.coords) +rr = np.hypot(xb[:, 0], xb[:, 1]) +tt = np.arctan2(xb[:, 1], xb[:, 0]) +depth = (RO - rr) / (RO - RI) +T_b.array[:, 0, 0] = np.clip(1.0 - np.exp(-depth / 0.15) * 0.0 - depth * 0.0, 0, 1) # placeholder +T_b.array[:, 0, 0] = 0.5 * (1 - np.cos(np.pi * np.minimum(depth / 0.3, 1.0))) * 0.5 + 0.5 * np.minimum(depth / 0.3, 1.0) +T_b.array[:, 0, 0] = np.clip(T_b.array[:, 0, 0] + 0.05 * np.cos(3 * tt) * np.sin(np.pi * depth), 0, 1) + +# the band: the structured ladder (one element either side of the spine, width 2S) +# along a line from past the outer wall down at DIP toward +theta, sampled at S; +# the spine is split on its own rung vertices for the samples at least three +# spacings inside the surface; the ladder's outcrop cap carries the band to the wall +from underworld3.utilities.place_surface import place_fault_ribbon_2d +W = 2.0 * S +th0 = 0.0 +t_hat = np.array([np.cos(th0), np.sin(th0)]) # outward radial at th0 +s_hat = np.array([-np.sin(th0), np.cos(th0)]) # +theta direction +d_hat = -np.cos(DIP) * t_hat + np.sin(DIP) * s_hat # down-dip, toward +theta +n_hat = np.array([-d_hat[1], d_hat[0]]) # fault normal +top = t_hat * (RO + 0.02) +L_line = (DEPTH + 0.02) / np.cos(DIP) +n_s = int(round(L_line / S)) +spine = np.array([top + d_hat * (L_line * i / n_s) for i in range(n_s + 1)]) +r_sp = np.hypot(spine[:, 0], spine[:, 1]) +trace = spine[r_sp <= RO - 3.0 * S] +t1 = time.perf_counter() +mesh, rinfo = place_fault_ribbon_2d(base, [("Trench", trace)], W, spines=[("Trench", spine)], + margin_rings=1, clearance=0.7, split=True, mesher="ladder") +print(f"finest: {mesh.dm.getHeightStratum(0)[1]} cells, placed + split {time.perf_counter() - t1:.1f} s, " + f"pairs {len(mesh._fault_point_pairs['Trench'])}, band {int(rinfo['band'].sum())} cells, " + f"tail levels {len(mesh._custom_mg_coarse_meshes)}", flush=True) +print("boundaries:", [b.name for b in mesh.boundaries], flush=True) +foot = np.asarray(rinfo["band"], dtype=bool) +BAND_LABEL, BAND_VALUE = "Band", int(mesh.boundaries["Band"].value) if "Band" in [b.name for b in mesh.boundaries] else None + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +from matplotlib.collections import LineCollection, PolyCollection + + +def mesh_art(m, fault, offset, n_hat): + dm = m.dm + eS, eE = dm.getDepthStratum(1) + vS_, vE_ = dm.getDepthStratum(0) + cS_, cE_ = dm.getHeightStratum(0) + Xm = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, 2) + sides = {} + for side, sgn in (("Plus", 1.0), ("Minus", -1.0)): + name = f"{fault}{side}" + if name in [bd.name for bd in m.boundaries]: + lab = dm.getLabel(name) + value = m.boundaries[name].value + if lab and lab.getStratumSize(value) > 0: + sides[side] = (set(int(e) for e in lab.getStratumIS(value).getIndices() + if eS <= int(e) < eE), sgn) + segs, fsegs = [], {"Plus": [], "Minus": []} + for e in range(eS, eE): + a_, b_ = (int(q) - vS_ for q in dm.getCone(e)) + seg = np.array([Xm[a_], Xm[b_]]) + placed = False + for side, (es, sgn) in sides.items(): + if e in es: + fsegs[side].append(seg + sgn * offset * n_hat) + placed = True + if not placed: + segs.append(seg) + polys = [] + try: + mask = foot + for c in np.nonzero(mask)[0]: + cl = dm.getTransitiveClosure(int(c) + cS_)[0] + polys.append(Xm[[int(q) - vS_ for q in cl if vS_ <= q < vE_]]) + except Exception: + pass + return segs, fsegs, polys + + +segs, fsegs, polys = mesh_art(mesh, "Trench", 0.0015, n_hat) +fig, axes = plt.subplots(1, 2, figsize=(13, 6.2), gridspec_kw={"width_ratios": [1, 1]}) +for ax, (x0, x1, y0, y1), lw in ((axes[0], (-1.05, 1.05, -1.05, 1.05), 0.25), + (axes[1], (0.80, 1.02, -0.06, 0.20), 0.5)): + if polys: + ax.add_collection(PolyCollection(polys, facecolors="#dff0d8", edgecolors="none")) + ax.add_collection(LineCollection(segs, colors="#777", linewidths=lw)) + for side, col in (("Plus", "#c0392b"), ("Minus", "#1a5276")): + if fsegs[side]: + ax.add_collection(LineCollection(fsegs[side], colors=col, linewidths=1.6)) + ax.set_xlim(x0, x1) + ax.set_ylim(y0, y1) + ax.set_aspect("equal") + ax.set_xticks([]) + ax.set_yticks([]) +axes[0].set_title(f"the add-on finest: base annulus h = {H / 2**REF} ({REF} halvings of {H}) with the band placed, " + f"{mesh.dm.getHeightStratum(0)[1]} cells", fontsize=9, loc="left") +axes[1].set_title(f"the trench: ladder width {W}, spacing {S}, outcropping through the cap; spine split (red Plus, blue Minus) " + f"to three spacings below the surface", fontsize=9, loc="left") +fig.tight_layout() +fig.savefig(os.path.join(out, "probe_mesh.png"), dpi=170) +plt.close(fig) +print("wrote probe_mesh.png", flush=True) + +# T on the add-on, by evaluation from the base +T_f = uw.discretisation.MeshVariable("T_f", mesh, 1, degree=2) +xf = np.asarray(T_f.coords) +rf = np.hypot(xf[:, 0], xf[:, 1]) +xq = xf * np.minimum(1.0, (RO - 1e-10) / np.maximum(rf, 1e-12))[:, None] +xq = xq * np.maximum(1.0, (RI + 1e-10) / np.maximum(np.hypot(xq[:, 0], xq[:, 1]), 1e-12))[:, None] +t2 = time.perf_counter() +T_f.array[:, 0, 0] = uw.function.evaluate(T_b.sym[0], xq).ravel() +print(f"T transfer {time.perf_counter() - t2:.1f} s, range {T_f.array.min():.3f} {T_f.array.max():.3f}", + flush=True) + +# Stokes on the add-on +v = uw.discretisation.MeshVariable("U", mesh, 2, degree=2) +p = uw.discretisation.MeshVariable("P", mesh, 1, degree=1, continuous=True) +stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) +unit_r = mesh.CoordinateSystem.unit_e_0 +stokes.bodyforce = RA * T_f.sym[0] * unit_r +eta_T = sympy.exp(sympy.log(DETA) * (1 - T_f.sym[0])) # 1 at T=1, DETA at T=0 +if os.environ.get("ZG95_CONSTETA"): + eta_T = sympy.Integer(1) + print("constant viscosity", flush=True) +# TI bridge: band cells whose centroid lies above the split's top tip +pairs = mesh._fault_point_pairs +if "Trench" in pairs: + pairs = pairs["Trench"] +print("pairs:", type(pairs).__name__, len(pairs), flush=True) +vS, vE = mesh.dm.getDepthStratum(0) +X = np.asarray(mesh.dm.getCoordinatesLocal().array).reshape(-1, 2) +if isinstance(pairs, dict): + pair_pts = np.array([int(k) for k in pairs] + [int(q) for q in pairs.values()]) +else: + pair_pts = np.asarray(pairs, dtype=int).ravel() +pv = pair_pts[(pair_pts >= vS) & (pair_pts < vE)] - vS +r_tip = np.hypot(X[pv, 0], X[pv, 1]).max() +print(f"split top tip at r = {r_tip:.4f} (surface {RO})", flush=True) +cS, cE = mesh.dm.getHeightStratum(0) +cent = np.array([X[[int(q) - vS for q in mesh.dm.getTransitiveClosure(c)[0] if vS <= q < vE]].mean(axis=0) + for c in range(cS, cE)]) +bridge = foot & (np.hypot(cent[:, 0], cent[:, 1]) > r_tip - 0.5 * W) +zone = uw.discretisation.MeshVariable("zone", mesh, 1, degree=0, continuous=False) +zone.array[:, 0, 0] = bridge.astype(float) +print(f"footprint cells {int(foot.sum())}, bridge cells {int(bridge.sum())}", flush=True) +if os.environ.get("ZG95_NOTI"): + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = eta_T + print("no TI bridge (isotropic)", flush=True) +else: + stokes.constitutive_model = uw.constitutive_models.TransverseIsotropicFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = eta_T + stokes.constitutive_model.Parameters.shear_viscosity_1 = eta_T * (1 - 0.999 * zone.sym[0]) + stokes.constitutive_model.Parameters.director = sympy.Matrix([[float(n_hat[0]), float(n_hat[1])]]) +if os.environ.get("ZG95_DIRICHLET"): + stokes.add_dirichlet_bc((0.0, 0.0), "Upper") + stokes.add_dirichlet_bc((0.0, 0.0), "Lower") + print("no-slip walls (Dirichlet)", flush=True) +else: + # the exact radial normal: with facet-averaged normals on an irregular + # polygon the rigid rotation is not exactly tangential, the gauge mode is + # rejected, and the coarse Galerkin operator is nearly singular for LU + stokes.add_rotated_freeslip_bc(0, "Upper", normal=unit_r) + stokes.add_rotated_freeslip_bc(0, "Lower", normal=unit_r) +if os.environ.get("ZG95_NOFAULT"): + print("no fault bc (welded)", flush=True) +else: + stokes.add_fault_bc(0, boundary="Trench") +stokes.petsc_use_pressure_nullspace = True +stokes.tolerance = 1e-5 +print(f"T_f nan {int(np.isnan(T_f.array).sum())}, zone nan {int(np.isnan(zone.array).sum())}", flush=True) +if os.environ.get("ZG95_REL"): + from underworld3.utilities import fault_mg + fault_mg.UNREACHED_REL = float(os.environ["ZG95_REL"]) + print("unreached threshold", fault_mg.UNREACHED_REL, flush=True) +if not os.environ.get("ZG95_NOFMG"): + set_custom_fmg(stokes, tail, field_id=0, + fac_zone=None if os.environ.get("ZG95_NOFAC") else foot) + print("FMG with" + ("out" if os.environ.get("ZG95_NOFAC") else "") + " band patch", flush=True) +else: + print("FMG disabled (GAMG)", flush=True) +t3 = time.perf_counter() +if os.environ.get("ZG95_TRAP"): + from petsc4py import PETSc + for pre in ("rotfs1_fieldsplit_vel_mg_levels_", "rotfs1_fieldsplit_vel_mg_levels_3_", + "rotfs1_fieldsplit_vel_", "rotfs1_"): + PETSc.Options()[pre + "ksp_error_if_not_converged"] = True + print("trapping KSP failures", flush=True) +stokes.solve() +info = stokes._rotated_freeslip_info +try: + ctx = (getattr(stokes, "_rotated_linear_cache", None) or {})["ctx"] + velpc = ctx["ksp"].getPC().getFieldSplitSubKSP()[0].getPC() + print(f" null modes verified: {getattr(stokes, '_rotated_velocity_null_modes', None)}, " + f"gauge removed: {info.get('rotation_gauge_removed')}, nsp: {ctx.get('nsp') is not None}, " + f"coarse pc: {velpc.getMGCoarseSolve().getPC().getType()}", flush=True) + velksp = ctx["ksp"].getPC().getFieldSplitSubKSP()[0] + print(f" prefixes: outer {ctx['ksp'].getOptionsPrefix()!r} velocity {velksp.getOptionsPrefix()!r}", flush=True) + if os.environ.get("ZG95_PCAPPLY"): + from petsc4py import PETSc + A_vv = velpc.getOperators()[0] + xx, yy = A_vv.createVecs() + xx.setRandom() + try: + velpc.apply(xx, yy) + print(f" whole velocity PC apply: |y| {yy.norm():.3e}", flush=True) + except Exception as e: + print(" whole velocity PC apply raised:", str(e)[:200], flush=True) + for l in range(velpc.getMGLevels()): + sm = velpc.getMGSmoother(l) + A = sm.getOperators()[0] + Pm = sm.getOperators()[1] + dA = A.getDiagonal().array; dP = Pm.getDiagonal().array + print(f" level {l}: Amat is Pmat {A.handle == Pm.handle}; zero diag A {int((dA == 0).sum())} P {int((dP == 0).sum())}; " + f"|dP| min {np.abs(dP).min():.3e}; nan P {int(np.isnan(dP).sum())} types {A.getType()}/{Pm.getType()}", flush=True) + b, x = A.createVecs() + b.setRandom(); x.set(0.0) + try: + sm.solve(b, x) + print(f" level {l} smoother ({sm.getType()}/{sm.getPC().getType()}): |x| {x.norm():.3e} its {sm.getIterationNumber()} reason {sm.getConvergedReason()}", flush=True) + except Exception as e: + print(f" level {l} smoother raised:", str(e)[:200], flush=True) + if l > 0: + P = velpc.getMGInterpolation(l) + xc = P.createVecRight(); xf = P.createVecLeft() + xc.setRandom() + P.mult(xc, xf) + print(f" P_{l} apply |xf| {xf.norm():.3e}; A_{l} apply |Ax| {A.createVecs()[0].norm():.3e}", flush=True) + cs = velpc.getMGCoarseSolve() + A0 = cs.getOperators()[0] + b, x = A0.createVecs(); b.setRandom(); x.set(0.0) + try: + cs.solve(b, x) + print(f" coarse solve ({cs.getType()}/{cs.getPC().getType()}): |x| {x.norm():.3e} reason {cs.getConvergedReason()}", flush=True) + except Exception as e: + print(" coarse solve raised:", str(e)[:300], flush=True) + for l in range(velpc.getMGLevels()): + A = velpc.getMGSmoother(l).getOperators()[0] + d = A.getDiagonal().array + line = f" level {l}: n {A.getSize()[0]} |A| {A.norm():.3e} diag min|.| {np.abs(d).min():.3e} nan {int(np.isnan(d).sum())}" + if l > 0: + P = velpc.getMGInterpolation(l) + Pa = P.getValuesCSR()[2] + line += f" | P {P.getSize()} nnz {len(Pa)} max {np.abs(Pa).max():.3e} nan {int(np.isnan(Pa).sum())} inf {int(np.isinf(Pa).sum())}" + print(line, flush=True) + if l > 0: + bad = np.flatnonzero(np.abs(d) < 1e-8) + print(f" tiny-diagonal unknowns on level {l}: {bad[:10]} ({len(bad)})", flush=True) + if len(bad): + ia, ja, va = P.getValuesCSR() + for b in bad[:4]: + rows = np.flatnonzero(np.diff(ia))[np.isin(ja[ia[np.flatnonzero(np.diff(ia))]], [b])] if False else None + hit = np.flatnonzero(ja == b) + rr = np.searchsorted(ia, hit, side="right") - 1 + print(f" col {b}: {len(hit)} entries, weights {va[hit][:8]}, fine rows {rr[:8]}", flush=True) + # coarse row b of the Galerkin operator: its own entries + cols_b, vals_b = A.getRow(int(b)) + print(f" A row {b}: {len(cols_b)} entries, max |.| {np.abs(vals_b).max() if len(vals_b) else 0:.3e}", flush=True) + # the coarse levels' own layout: which node is unknown b? + if l == 1 and len(bad): + h_ = getattr(stokes, "_custom_mg_hierarchy", None) + print(" hierarchy object:", type(h_).__name__ if h_ is not None else None, + [k for k in dir(h_) if "layout" in k or "reduced" in k or "map" in k][:12] if h_ is not None else "", flush=True) +except Exception as e: + import traceback; traceback.print_exc() + print("hierarchy inspection failed:", e, flush=True) +print(f"solve {time.perf_counter() - t3:.1f} s: pc {info.get('velocity_pc')}, vel its {info.get('vel_its_last')}, " + f"pres its {info.get('pres_its_last')}, converged {info.get('converged')}", flush=True) +from underworld3.utilities.fault_contact import fault_slip +_s, Vslip, leak = fault_slip(stokes, "Trench", info) +print(f"slip max {np.abs(Vslip).max():.4f}, leak {np.abs(leak).max():.2e}", flush=True) + +h = uw.discretisation.MeshVariable("topo", mesh, 1, degree=1) +stokes.dynamic_topography("Upper", h, buoyancy_scale=RA) +xs = np.asarray(h.coords) +on = np.hypot(xs[:, 0], xs[:, 1]) > RO - 1e-6 +print(f"topography on {on.sum()} nodes, range {h.array[on].min():.3e} {h.array[on].max():.3e}", flush=True) +vmag = np.linalg.norm(v.array[:, 0, :], axis=1) +print(f"vmax {vmag.max():.3f}; total {time.perf_counter() - t0:.1f} s", flush=True) +np.savez(os.path.join(out, "probe.npz"), topo_x=xs[on], topo=h.array[on, 0, 0]) +# solution figure: T with velocity arrows, surface velocity and topography against theta +import matplotlib.tri as mtri +Tx = np.asarray(T_f.coords) +tri_cells = np.array([[int(q) - vS for q in mesh.dm.getTransitiveClosure(c)[0] if vS <= q < vE] + for c in range(cS, cE)]) +fig, axes = plt.subplots(1, 3, figsize=(17, 5.2), gridspec_kw={"width_ratios": [1.2, 1, 1]}) +ax = axes[0] +Tv = uw.function.evaluate(T_f.sym[0], X * (1 - 1e-12)).ravel() +tc = ax.tripcolor(mtri.Triangulation(X[:, 0], X[:, 1], tri_cells), Tv, shading="gouraud", + cmap="RdBu_r", vmin=0, vmax=1) +vx = v.array[:, 0, :] +vc = np.asarray(v.coords) +pick = np.arange(0, len(vc), max(1, len(vc) // 900)) +ax.quiver(vc[pick, 0], vc[pick, 1], vx[pick, 0], vx[pick, 1], color="k", scale=None, width=0.0025) +for side, col in (("Plus", "#c0392b"), ("Minus", "#1a5276")): + if fsegs[side]: + ax.add_collection(LineCollection(fsegs[side], colors=col, linewidths=1.2)) +ax.set_aspect("equal"); ax.set_xticks([]); ax.set_yticks([]) +ax.set_title(f"T (base field evaluated onto the add-on) and velocity; vmax {vmag.max():.1f}", fontsize=9, loc="left") +fig.colorbar(tc, ax=ax, shrink=0.8) +ax = axes[1] +surf = np.hypot(vc[:, 0], vc[:, 1]) > RO - 1e-6 +th_s = np.arctan2(vc[surf, 1], vc[surf, 0]) +vt = -vx[surf, 0] * np.sin(th_s) + vx[surf, 1] * np.cos(th_s) +o = np.argsort(th_s) +ax.plot(np.degrees(th_s[o]), vt[o], "-", lw=1) +ax.axvline(0, color="#c0392b", lw=0.8, ls="--") +ax.set_xlabel("theta (deg); trench at 0, dipping toward +theta"); ax.set_ylabel("surface tangential velocity") +ax.grid(alpha=0.3); ax.set_title("surface velocity", fontsize=9, loc="left") +ax = axes[2] +th_t = np.arctan2(xs[on, 1], xs[on, 0]); o = np.argsort(th_t) +ax.plot(np.degrees(th_t[o]), h.array[on, 0, 0][o], "-", lw=1) +ax.axvline(0, color="#c0392b", lw=0.8, ls="--") +ax.set_xlabel("theta (deg)"); ax.set_ylabel("dynamic topography (-(s_nn - mean) / Ra)") +ax.grid(alpha=0.3); ax.set_title("topography from the free-slip reaction", fontsize=9, loc="left") +fig.tight_layout() +fig.savefig(os.path.join(out, "probe_solution.png"), dpi=150) +print("wrote probe_solution.png", flush=True) + +# mesh + solution: the fault and its cut in the elements +from scipy.spatial import cKDTree +tri_obj = mtri.Triangulation(X[:, 0], X[:, 1], tri_cells) +bridge_polys = [X[tri_cells[c]] for c in np.nonzero(bridge)[0]] +_d, _k = cKDTree(vc).query(X) +vmag_v = vmag[_k] # |v| at the mesh vertices +tip_xy = trace[0] # the sample nearest the surface +fig, axes = plt.subplots(1, 3, figsize=(19, 6.4), gridspec_kw={"width_ratios": [1.15, 1, 1]}) +ax = axes[0] +ax.tripcolor(tri_obj, Tv, shading="gouraud", cmap="RdBu_r", vmin=0, vmax=1, alpha=0.85) +ax.add_collection(LineCollection(segs, colors="#555", linewidths=0.18)) +for side, col in (("Plus", "#c0392b"), ("Minus", "#1a5276")): + if fsegs[side]: + ax.add_collection(LineCollection(fsegs[side], colors=col, linewidths=1.4)) +ax.set_aspect("equal"); ax.set_xticks([]); ax.set_yticks([]) +ax.set_xlim(-1.03, 1.03); ax.set_ylim(-1.03, 1.03) +ax.set_title(f"T on the add-on finest ({mesh.dm.getHeightStratum(0)[1]} cells) with the mesh and the trench", + fontsize=9, loc="left") +for ax, win, lw, off, title in ( + (axes[1], (0.84, 1.01, -0.04, 0.17), 0.45, 0.0015, + "the trench: |v| under the mesh; band (green rim), TI bridge (orange rim), split spine (red Plus, blue Minus)"), + (axes[2], (tip_xy[0] - 0.03, tip_xy[0] + 0.03, tip_xy[1] - 0.03, tip_xy[1] + 0.03), 0.7, 0.0008, + f"the upper tip: the cut stops at r = {r_tip:.3f} inside the band; the bridge carries it to the outcrop")): + tc = ax.tripcolor(tri_obj, vmag_v, shading="gouraud", cmap="viridis", vmin=0, vmax=np.percentile(vmag_v, 99)) + if polys: + ax.add_collection(PolyCollection(polys, facecolors="none", edgecolors="#2e7d32", linewidths=0.6)) + if bridge_polys: + ax.add_collection(PolyCollection(bridge_polys, facecolors="none", edgecolors="#e67e22", linewidths=0.9)) + ax.add_collection(LineCollection(segs, colors="#ddd", linewidths=lw)) + for side, col in (("Plus", "#c0392b"), ("Minus", "#1a5276")): + if fsegs[side]: + ax.add_collection(LineCollection(fsegs[side], colors=col, linewidths=2.0)) + ax.set_xlim(win[0], win[1]); ax.set_ylim(win[2], win[3]) + ax.set_aspect("equal"); ax.set_xticks([]); ax.set_yticks([]) + ax.set_title(title, fontsize=8.5, loc="left") +fig.colorbar(tc, ax=axes[2], shrink=0.7, label="|v|") +fig.tight_layout() +fig.savefig(os.path.join(out, "probe_mesh_solution.png"), dpi=160) +print("wrote probe_mesh_solution.png", flush=True) +print("DONE") diff --git a/scripts/sessions/fault_mg/zg95_profile.py b/scripts/sessions/fault_mg/zg95_profile.py new file mode 100644 index 000000000..42cbfa3f6 --- /dev/null +++ b/scripts/sessions/fault_mg/zg95_profile.py @@ -0,0 +1,120 @@ +"""Where a step's time goes: the meshing (placement, split, solver creation) +against the solve, and inside the solve the assembly, the hierarchy set-up +and the Krylov work. One add-on of the Zhong & Gurnis analogue, three solves: +the first (everything built), a repeat with nothing changed, and a repeat +after the temperature changed (operator refreshed, transfers kept). + +python zg95_profile.py +""" +import os +import sys +import time + +import numpy as np +import sympy +from petsc4py import PETSc + +out = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/+Simulations/zg95") +os.makedirs(out, exist_ok=True) +PETSc.Options()["log_view"] = f":{os.path.join(out, 'profile_log_view.txt')}" +PETSc.Log.begin() +import underworld3 as uw +from scipy.special import erf +from underworld3.utilities.custom_mg import set_custom_fmg +from underworld3.utilities.place_surface import place_fault_ribbon_2d + +RI, RO = 0.55, 1.0 +S, W = 0.005, 0.01 +DIP, DEPTH, RA, DETA, ETA_W = np.deg2rad(45.0), 0.15, 1e6, 1e3, 1e-3 +T = {} + + +def tic(k): + T["_k"], T["_t"] = k, time.perf_counter() + + +def toc(): + T[T["_k"]] = T.get(T["_k"], 0.0) + time.perf_counter() - T["_t"] + + +tic("base mesh + levels") +base = uw.meshing.Annulus(radiusOuter=RO, radiusInner=RI, cellSize=0.16, refinement=3, qdegree=2) +tail = base._coarse_level_meshes()[:-1] + [base] +toc() +T_b = uw.discretisation.MeshVariable("T_b", base, 1, degree=2) +xb = np.asarray(T_b.coords) +z = (RO - np.hypot(xb[:, 0], xb[:, 1])) / (RO - RI) +th = np.arctan2(xb[:, 1], xb[:, 0]) +T_b.array[:, 0, 0] = np.clip(0.6 * erf(z / 0.1) + 0.4 * (1 - erf((1 - z) / 0.1)) - 0.04 * np.cos(3 * th) * np.sin(np.pi * z), 0, 1) + +t_hat, s_hat = np.array([1.0, 0.0]), np.array([0.0, 1.0]) +d_hat = -np.cos(DIP) * t_hat + np.sin(DIP) * s_hat +n_hat = np.array([-d_hat[1], d_hat[0]]) +top = t_hat * (RO + 0.02) +L = (DEPTH + 0.02) / np.cos(DIP) +n_s = int(round(L / S)) +spine = np.array([top + d_hat * (L * i / n_s) for i in range(n_s + 1)]) +trace = spine[np.hypot(spine[:, 0], spine[:, 1]) <= RO - 3 * S] + +stage = PETSc.Log.Stage("placement+split") +stage.push() +tic("placement + split (ladder, cap, cut)") +mesh, rinfo = place_fault_ribbon_2d(base, [("Trench", trace)], W, spines=[("Trench", spine)], margin_rings=1, + clearance=0.7, split=True, mesher="ladder") +toc() +stage.pop() +band = np.asarray(rinfo["band"], dtype=bool) + +tic("variables + solver object") +T_f = uw.discretisation.MeshVariable("T_f", mesh, 1, degree=2) +v = uw.discretisation.MeshVariable("U", mesh, 2, degree=2) +p = uw.discretisation.MeshVariable("P", mesh, 1, degree=1, continuous=True) +zone = uw.discretisation.MeshVariable("zone", mesh, 1, degree=0, continuous=False) +dm = mesh.dm +vS, vE = dm.getDepthStratum(0) +cS, cE = dm.getHeightStratum(0) +X = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, 2) +pairs = mesh._fault_point_pairs["Trench"] +pp = np.array([int(q) for q in pairs] + [int(q) for q in pairs.values()]) +pv = pp[(pp >= vS) & (pp < vE)] - vS +r_tip = float(np.hypot(X[pv, 0], X[pv, 1]).max()) +cent = np.array([X[[int(q) - vS for q in dm.getTransitiveClosure(c)[0] if vS <= q < vE]].mean(axis=0) for c in range(cS, cE)]) +zone.array[:, 0, 0] = (band & (np.hypot(cent[:, 0], cent[:, 1]) > r_tip - 0.5 * W)).astype(float) +s = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) +eta0 = sympy.exp(sympy.log(DETA) * (1 - T_f.sym[0])) +s.constitutive_model = uw.constitutive_models.TransverseIsotropicFlowModel +s.constitutive_model.Parameters.shear_viscosity_0 = eta0 +s.constitutive_model.Parameters.shear_viscosity_1 = eta0 * (1 - (1 - ETA_W) * zone.sym[0]) +s.constitutive_model.Parameters.director = sympy.Matrix([[float(n_hat[0]), float(n_hat[1])]]) +unit_r = mesh.CoordinateSystem.unit_e_0 +s.bodyforce = RA * T_f.sym[0] * unit_r +s.add_rotated_freeslip_bc(0, "Upper", normal=unit_r) +s.add_rotated_freeslip_bc(0, "Lower", normal=unit_r) +s.add_fault_bc(0, boundary="Trench") +s.petsc_use_pressure_nullspace = True +s.tolerance = 1e-5 +set_custom_fmg(s, tail, field_id=0) +toc() + + +def clamp(x): + r = np.hypot(x[:, 0], x[:, 1]) + return x * (np.clip(r, RI + 1e-10, RO - 1e-10) / np.maximum(r, 1e-14))[:, None] + + +tic("T transfer") +T_f.array[:, 0, 0] = uw.function.evaluate(T_b.sym[0], clamp(np.asarray(T_f.coords))).ravel() +toc() +for name in ("solve 1 (first: hierarchy built)", "solve 2 (nothing changed)", "solve 3 (T changed)"): + if name.startswith("solve 3"): + T_f.array[:, 0, 0] = np.clip(T_f.array[:, 0, 0] * 0.98 + 0.01, 0, 1) + stage = PETSc.Log.Stage(name.split(" (")[0].replace(" ", "_")) + stage.push() + tic(name) + s.solve() + toc() + stage.pop() + info = s._rotated_freeslip_info + print(f"{name}: {T[name]:.1f} s, velocity its {info.get('vel_its_last')}, pressure its {info.get('pres_its_last')}", flush=True) +print("TIMES", {k: round(v_, 2) for k, v_ in T.items() if not k.startswith("_")}, flush=True) +print("DONE", flush=True) diff --git a/src/underworld3/utilities/fault_mg.py b/src/underworld3/utilities/fault_mg.py index 3395ea5c6..1a0c77ad4 100644 --- a/src/underworld3/utilities/fault_mg.py +++ b/src/underworld3/utilities/fault_mg.py @@ -38,6 +38,8 @@ import itertools import numpy as np +from types import SimpleNamespace +from mpi4py import MPI import scipy.sparse as sp from petsc4py import PETSc from scipy.spatial import cKDTree @@ -528,7 +530,7 @@ def colocate_split_level(level, target): # --------------------------------------------------------------------------- # # Per-level pair rotation # --------------------------------------------------------------------------- # -def level_pair_rotation(level, solver, field_id): +def level_pair_rotation(level, solver, field_id, cdm=None, layout=None): """The pair rotation of a split coarse level in the level's reduced velocity layout: ``(Q, Qt, normal_rows, layout)`` with ``Q`` an AIJ matrix (identity except one orthogonal ``2 dim`` block per coincident pair, mean rows @@ -541,8 +543,10 @@ def level_pair_rotation(level, solver, field_id): solve already keeps the composed prolongations across its Newton steps.""" from underworld3.utilities import custom_mg, fault_contact - cdm = custom_mg._clone_dm_with_solver_discretisation(solver, level) - layout = custom_mg._level_dof_layout(cdm, field_id) + if cdm is None: + cdm = custom_mg._clone_dm_with_solver_discretisation(solver, level) + if layout is None: + layout = custom_mg._level_dof_layout(cdm, field_id) sub = custom_mg._field_subdm(cdm, field_id) lsec_sub = sub.getLocalSection() lsec = cdm.getLocalSection() @@ -617,6 +621,152 @@ def level_pair_rotation(level, solver, field_id): return Q, Qt, np.array(sorted(set(normal_rows)), dtype=np.int64), layout +class _LevelMeshProxy: + """The level mesh wearing the solver mesh's coordinate symbols, so the + solver's analytic wall normals (expressions in ``solver.mesh.X``) evaluate + on the level's nodes through the finest-level helpers unchanged.""" + + def __init__(self, level, X): + self._level = level + self.X = X + + def __getattr__(self, name): + return getattr(self._level, name) + + +def level_wall_rotation(level, solver, field_id, cdm=None, layout=None): + """The rotated free-slip wall rotation of a coarse level in the level's + reduced velocity layout: ``(Q, Qt, normal_rows, layout)`` with one + orthonormal ``dim x dim`` block per velocity node on each of the solver's + rotated boundaries (the same frame rule as ``rotated_bc.build_rotation``, + on the level's own nodes and normals) and ``normal_rows`` the rows of the + rotated normal components, which the level constrains out. ``None`` when + the solver has no rotated walls. + + Why the coarse levels need it: with Cartesian coarse levels under a + rotated finest whose normal rows take no coarse correction, a coarse wall + node's normal Cartesian component is reached only through the fine + tangential rows, weighted by the sine of the angle between the two + nodes' normals. Its Galerkin block is then nearly singular (measured + 1e-3 against 2.5 on an annulus at a node near an axis), the inode SOR + smoother's 2x2 block inversion reports a zero pivot, and the velocity + solve fails before its first iteration. Rotating the level and removing + the normal rows gives the coarse space exactly the constrained motion. + """ + from underworld3.utilities import custom_mg, rotated_bc + + specs = list(getattr(solver, "_rotated_freeslip_bcs", None) or []) + if not specs: + return None + if cdm is None: + cdm = custom_mg._clone_dm_with_solver_discretisation(solver, level) + if layout is None: + layout = custom_mg._level_dof_layout(cdm, field_id) + sub = custom_mg._field_subdm(cdm, field_id) + lsec_sub = sub.getLocalSection() + dim = level.cdim + manifold = level.dim < level.cdim + proxy = SimpleNamespace(dm=cdm, mesh=_LevelMeshProxy(level, solver.mesh.X), + snes=None) + + node_normals = {} + for spec in specs: + name, normal = rotated_bc._boundary_spec(spec) + if name == rotated_bc.SHEET_TANGENCY: + nodes = rotated_bc._manifold_velocity_nodes(proxy, normal) + else: + nodes = rotated_bc._boundary_velocity_nodes(proxy, name, normal=normal) + for q, nrm in nodes: + node_normals.setdefault(q, []).append(nrm) + if manifold and is_split_level(level): + # a paired node takes the sheet tangency through its pair block + for pairs in level._fault_point_pairs.values(): + for q_plus, q_minus in (pairs.items() if isinstance(pairs, dict) + else pairs): + node_normals.pop(int(q_plus), None) + node_normals.pop(int(q_minus), None) + + l2g = np.asarray(layout.l2g, dtype=np.int64) + rstart, rend = layout.rstart, layout.rend + nloc = rend - rstart + comm = cdm.getComm() + N = int(comm.tompi4py().allreduce(nloc)) + rows = [np.arange(rstart, rend)] + cols = [np.arange(rstart, rend)] + vals = [np.ones(nloc)] + is_block_row = np.zeros(nloc, dtype=bool) + normal_rows = [] + for q, nrms in node_normals.items(): + off = lsec_sub.getOffset(q) + g = [int(l2g[off + c]) for c in range(dim)] + if any(gi < 0 for gi in g): + # a component pinned by an essential condition on the level: the + # wall meets an essential wall there; left Cartesian, as the + # finest's partially constrained nodes are handled separately + continue + if not (rstart <= g[0] < rend): + continue + M = np.array(nrms, dtype=float) + _u, sv, Vt = np.linalg.svd(M) + r = int((sv > 1e-8 * (sv[0] if sv.size else 1.0)).sum()) + is_block_row[np.array(g) - rstart] = True + for i in range(dim): + rows.append(np.full(dim, g[i])) + cols.append(np.array(g)) + vals.append(np.asarray(Vt[i], dtype=float)) + normal_rows.extend(g[:r]) + rows = np.concatenate(rows) + cols = np.concatenate(cols) + vals = np.concatenate(vals) + keep = np.ones(len(rows), dtype=bool) + keep[:nloc] = ~is_block_row + loc = sp.csr_matrix((vals[keep], (rows[keep] - rstart, cols[keep])), + shape=(nloc, N)) + loc.sum_duplicates() + loc.sort_indices() + Q = PETSc.Mat().createAIJ( + size=((nloc, None), (nloc, None)), + csr=(loc.indptr.astype(PETSc.IntType), loc.indices.astype(PETSc.IntType), + loc.data), comm=comm) + Q.assemble() + Qt = Q.transpose(PETSc.Mat()) + return Q, Qt, np.array(sorted(set(normal_rows)), dtype=np.int64), layout + + +def level_rotation(level, solver, field_id): + """The full rotation of a coarse level: its fault pairs (a split level) + and its rotated walls, composed. The two act on disjoint node blocks (a + paired node is never a wall node on a volume mesh; on a surface mesh the + paired nodes are removed from the wall set), so the product is a single + orthogonal matrix. Returns ``(Q, Qt, normal_rows)`` or ``None`` when the + level has neither.""" + from underworld3.utilities import custom_mg + + split = is_split_level(level) + walls = bool(getattr(solver, "_rotated_freeslip_bcs", None)) + if not split and not walls: + return None + cdm = custom_mg._clone_dm_with_solver_discretisation(solver, level) + layout = custom_mg._level_dof_layout(cdm, field_id) + parts = [] + if split: + parts.append(level_pair_rotation(level, solver, field_id, cdm=cdm, layout=layout)) + if walls: + w = level_wall_rotation(level, solver, field_id, cdm=cdm, layout=layout) + if w is not None: + parts.append(w) + if not parts: + return None + if len(parts) == 1: + Q, Qt, nr, _lay = parts[0] + return Q, Qt, nr + Q = parts[0][0].matMult(parts[1][0]) + Qt = Q.transpose(PETSc.Mat()) + nr = np.array(sorted(set(parts[0][2].tolist()) | set(parts[1][2].tolist())), + dtype=np.int64) + return Q, Qt, nr + + # --------------------------------------------------------------------------- # # Static / add-on levels # --------------------------------------------------------------------------- # @@ -627,11 +777,30 @@ def addon_level_indices(tail): return [k for k, m in enumerate(tail) if not getattr(m, "_mg_static", False)] +# A coarse unknown whose transfer column carries less than this fraction of +# the level's largest column (squared 2-norms) is UNREACHED: its Galerkin +# diagonal is that fraction of the fine operator's, and SOR divides by it. +# Exactly-zero columns are the orphans of a split tip; the near-zero ones +# arise under rotation, where a coarse wall node on an axis has its normal +# Cartesian component reached only through fine tangential rows weighted by +# the sine of a vanishing angle (measured 1e-10 on an annulus, NaN at the +# first sweep). +UNREACHED_REL = 1e-8 + + +def _reached_columns(T, rel=None): + rel = UNREACHED_REL if rel is None else rel + n2 = np.asarray(_column_norms_sq(T).array) + big = T.comm.tompi4py().allreduce(float(n2.max()) if n2.size else 0.0, op=MPI.MAX) + return n2 > rel * big + + def restrict_unreached(Ps, comm): """The prolongation list with every coarse unknown no fine unknown reaches dropped, fine to coarse, each level's rows restricted to the set the level above kept. The unrotated twin of :func:`rotated_split_transfers`, for the - standard (non-rotated) install of a hierarchy with split levels.""" + standard (non-rotated) install of a hierarchy with split levels. "Reaches" + is relative (``UNREACHED_REL``), so a vanishing column counts as none.""" nlev = len(Ps) + 1 kept = [None] * nlev out = [None] * (nlev - 1) @@ -641,8 +810,7 @@ def restrict_unreached(Ps, comm): T = T.createSubMatrix( _owned_is(kept[l + 1], comm), _owned_is(np.arange(*T.getOwnershipRangeColumn()), comm)) - n2 = _column_norms_sq(T) - reached = np.asarray(n2.array) > 0.0 + reached = _reached_columns(T) cols = np.arange(*T.getOwnershipRangeColumn()) if comm.tompi4py().allreduce(int((~reached).sum())): T = T.createSubMatrix( @@ -698,8 +866,7 @@ def rotated_split_transfers(solver, hierarchy, Ps, Q, normal_rows): rot = [None] * nlev for l in range(nlev - 1): - if is_split_level(levels[l]): - rot[l] = level_pair_rotation(levels[l], solver, hierarchy.field_id) + rot[l] = level_rotation(levels[l], solver, hierarchy.field_id) kept = [None] * nlev # owned kept global indices per coarse level out = [None] * (nlev - 1) @@ -726,8 +893,7 @@ def rotated_split_transfers(solver, hierarchy, Ps, Q, normal_rows): else T.createSubMatrix(_owned_is(np.arange(*T.getOwnershipRange()), comm), col_is) # drop the coarse unknowns no fine unknown reaches - n2 = _column_norms_sq(T) - reached = np.asarray(n2.array) > 0.0 + reached = _reached_columns(T) n_drop = int(comm.tompi4py().allreduce(int((~reached).sum()))) if n_drop: adm = adm[reached] @@ -738,7 +904,9 @@ def rotated_split_transfers(solver, hierarchy, Ps, Q, normal_rows): out[l] = T hierarchy.level_kept = kept # The restriction renumbers every coarse level, so any FAC patch rows the - # builder recorded (keyed to the unrestricted layouts) no longer apply; a - # split fault needs no patch in any case (the keying ruling). - hierarchy.level_patch_rows = [None] * nlev + # builder recorded for them (keyed to the unrestricted layouts) no longer + # apply; the finest's layout is untouched and its patch rows stay. + rows = getattr(hierarchy, "level_patch_rows", None) + hierarchy.level_patch_rows = ([None] * (nlev - 1) + [rows[-1]] + if rows is not None else [None] * nlev) return out diff --git a/src/underworld3/utilities/place_surface.py b/src/underworld3/utilities/place_surface.py index 508eb6e68..becd3a07e 100644 --- a/src/underworld3/utilities/place_surface.py +++ b/src/underworld3/utilities/place_surface.py @@ -3596,7 +3596,16 @@ def area(P): order = sorted(range(len(loops)), key=lambda k: -abs(area(loops[k]))) rings = [] for k in order: - P = loops[k] + P = np.asarray(loops[k], dtype=float) + # Every loop counter-clockwise. The walk returns each loop in + # whichever direction it started, and OCC reads a hole loop wound + # against the exterior as ADDED area: on an annulus whose inner loop + # came out clockwise the tool had the area of the full disc plus the + # hole, and a boolean against it met nothing (measured: tool area + # 3.93 against 2.36 for cell sizes 0.04 and 0.08, correct for 0.05 + # and 0.06, which happened to wind both loops the same way). + if area(P) < 0.0: + P = P[::-1] pts = [occ.addPoint(x, y, 0.0) for x, y in P] lines = [occ.addLine(pts[i], pts[(i + 1) % len(pts)]) for i in range(len(pts))] @@ -4372,6 +4381,13 @@ def find(v): "multiply-outcropping zone is not built.") +# Adjacent boundary facets whose normals differ by less than this are the +# same SMOOTH WALL: the faceting of a curved boundary (angle -> 0 under +# refinement) groups into one wall, while a box-like corner (90 degrees) +# separates two. The outcrop bowl may open anywhere on a band-touched wall. +_WALL_DIHEDRAL_COS = np.cos(np.deg2rad(45.0)) + + def _outcrop_frame_2d(comp_loops, asm_pts, band_pairs, X, on_wall): """Which mesh boundary vertices an outcrop may DELETE, and which ring vertices count as boundary contact AWAY from the outcrop. @@ -4389,18 +4405,48 @@ def _outcrop_frame_2d(comp_loops, asm_pts, band_pairs, X, on_wall): """ band_nodes = sorted({int(v) for pair in band_pairs for v in pair}) B = asm_pts[band_nodes] + # A compressed loop's segments group into SMOOTH WALLS across + # low-angle turns (the 2-D reading of ``_boundary_walls``): the + # faceting of a curved boundary is one wall, a box corner separates + # two. A band-touched segment opens its whole wall, and only a sharp + # corner is the domain's shape. Before this, every chord of an annulus + # was its own wall, so the clearance cavity of a dipping band, which + # spans a few chords beside the one the band crosses, was refused as + # contact away from the outcrop. seg_pts, seg_edges, corners = [], [], [] for L in comp_loops: - corners.append(L) + L = np.asarray(L, dtype=float) n = len(L) + e_in = L - np.roll(L, 1, axis=0) + e_out = np.roll(L, -1, axis=0) - L + e_in = e_in / np.linalg.norm(e_in, axis=1)[:, None] + e_out = e_out / np.linalg.norm(e_out, axis=1)[:, None] + sharp = np.einsum("ij,ij->i", e_in, e_out) < _WALL_DIHEDRAL_COS + corners.append(L) + # walls: runs of segments between sharp corners (segment i runs + # from vertex i to i+1; a wall break sits at a sharp vertex) + wall_of = np.zeros(n, dtype=np.int64) + w = 0 + for i in range(n): + if i > 0 and sharp[i]: + w += 1 + wall_of[i] = w + if not sharp.any(): + wall_of[:] = 0 + elif not sharp[0]: + wall_of[wall_of == w] = 0 # the run wraps around vertex 0 + touched = set() for i in range(n): A, C = L[i], L[(i + 1) % n] e = C - A u = np.clip(((B - A) @ e) / float(e @ e), 0.0, 1.0) d = np.linalg.norm(B - (A + u[:, None] * e), axis=1) if (d < 1e-9).any(): + touched.add(int(wall_of[i])) + for i in range(n): + if int(wall_of[i]) in touched: seg_edges.append((len(seg_pts), len(seg_pts) + 1)) - seg_pts += [A, C] + seg_pts += [L[i], L[(i + 1) % n]] if not seg_edges: raise RuntimeError( "the outcrop band lies on no boundary segment; the clip and " @@ -4412,17 +4458,13 @@ def _outcrop_frame_2d(comp_loops, asm_pts, band_pairs, X, on_wall): for c in np.vstack(corners): is_corner |= np.linalg.norm(X - c, axis=1) < 1e-9 near_touched = d_seg < 1e-9 + # A compressed vertex off the band is never deleted: on a straight wall + # none exist, on a curved wall every vertex is one, and keeping them is + # what keeps the domain's area (the splice below carries them). deletable = on_wall & near_touched & (~is_corner | (d_band < 1e-9)) return deletable, near_touched -# Adjacent boundary facets whose normals differ by less than this are the -# same SMOOTH WALL: the faceting of a curved boundary (angle -> 0 under -# refinement) groups into one wall, while a box-like corner (90 degrees) -# separates two. The outcrop bowl may open anywhere on a band-touched wall. -_WALL_DIHEDRAL_COS = np.cos(np.deg2rad(45.0)) - - def _boundary_walls(dom_verts, dom_tris, region): """Group coplanar regions into SMOOTH WALLS across low-angle creases. @@ -5386,21 +5428,161 @@ def _ladder_curved_assembly_2d(P, width, size, reach=None): return pts, tris, float(np.abs(twice).sum() / 2.0) +def _inside_domain_2d(pts, domain): + """Which of ``pts`` lie inside the domain: in its largest-area loop and + in none of the others (the holes).""" + from matplotlib.path import Path + + def area(P): + return 0.5 * float(P[:, 0] @ np.roll(P[:, 1], -1) + - P[:, 1] @ np.roll(P[:, 0], -1)) + + loops = [np.asarray(L, dtype=float) for L in domain] + order = sorted(range(len(loops)), key=lambda k: -abs(area(loops[k]))) + inside = Path(loops[order[0]]).contains_points(pts) + for k in order[1:]: + inside &= ~Path(loops[k]).contains_points(pts) + return inside + + def _ladder_domain_gate_2d(pts, domain): - """Refuse a structured band that protrudes past the domain bbox.""" + """Refuse a structured band that protrudes past the domain.""" if domain is None: return - lo = np.min([np.min(np.asarray(Lp, dtype=float), axis=0) - for Lp in domain], axis=0) - hi = np.max([np.max(np.asarray(Lp, dtype=float), axis=0) - for Lp in domain], axis=0) - if (pts < lo - 1e-12).any() or (pts > hi + 1e-12).any(): + if not _inside_domain_2d(np.asarray(pts, dtype=float), domain).all(): raise ValueError( "the ladder band protrudes past the domain; it is structured " "and cannot be clipped. Shorten the trace or use the default " "band (which clips).") +def _ladder_outcrop_assembly_2d(S, R, width, size, domain): + """The ladder that OUTCROPS: the structured band on the samples inside + the domain, stopping one rung short of the wall, closed by a small + unstructured CAP between its last rung and the wall, clipped against + the domain polygon exactly as the default band is. + + The spine keeps its rung vertices (a split walks them, nothing snaps) + up to the last rung; the cap carries the band to the wall and its + clipped edge is the outcrop band. The cap's cells are where a weak + layer bridges a split that must stop blind of the surface (the trench + of a convection model), so their being unstructured costs nothing the + split needs. Either end may protrude; a sample row counts as inside + only when its spine and both rail points are. Returns + ``(points, triangles, area)`` with the cap's nodes merged onto the + ladder's rung vertices. + """ + import gmsh + + S = np.asarray(S, dtype=float) + R = np.asarray(R, dtype=float) + rows = np.stack([S + 0.5 * width * R, S, S - 0.5 * width * R], axis=1) + inside = _inside_domain_2d(rows.reshape(-1, 2), domain).reshape(-1, 3).all(axis=1) + if inside.all(): + return _ladder_curved_assembly_2d(S, width, size, reach=R) + idx = np.flatnonzero(inside) + if idx.size < 2: + raise ValueError( + "the ladder band has fewer than two rungs inside the domain") + # the inside run must be contiguous (a band leaving and re-entering + # the domain is two bands) + if idx[-1] - idx[0] + 1 != idx.size: + raise ValueError( + "the ladder band leaves the domain and re-enters it; place it " + "as two bands") + k0, k1 = int(idx[0]), int(idx[-1]) + caps = [] # (row index, outward dir) + if k1 < len(S) - 1: + caps.append((k1, S[k1] - S[k1 - 1])) + if k0 > 0: + caps.append((k0, S[k0] - S[k0 + 1])) + pts_l, tris_l, area_l = _ladder_curved_assembly_2d( + S[k0:k1 + 1], width, size, reach=R[k0:k1 + 1]) + + comp = [_compress_collinear_loop(np.asarray(L, dtype=float)) for L in domain] + pts_all = [pts_l] + tris_all = [tris_l] + area = float(area_l) + n_off = len(pts_l) + gmsh.initialize() + gmsh.option.setNumber("General.Terminal", 0) + try: + for k, t in caps: + t = t / np.linalg.norm(t) + rung = rows[k] # rail+, spine, rail- + # reach past the wall by the band's own scale; the clip trims it + ext = 3.0 * size + width + gmsh.model.add(f"uw_ladder_cap_{k}") + occ = gmsh.model.occ + corners = [rung[0], rung[1], rung[2], rung[2] + ext * t, + rung[0] + ext * t] + ptags = [occ.addPoint(q[0], q[1], 0.0) for q in corners] + ltags = [occ.addLine(ptags[i], ptags[(i + 1) % 5]) for i in range(5)] + face = occ.addPlaneSurface([occ.addCurveLoop(ltags)]) + occ.synchronize() + tool = _occ_domain_2d(occ, comp) + occ.intersect([(2, face)], [(2, tool)]) + occ.synchronize() + faces = gmsh.model.getEntities(2) + if not faces: + raise ValueError( + "the ladder's outcrop cap meets no part of the domain") + area += sum(occ.getMass(2, f) for _d, f in faces) + # the two rung segments stay single edges: their nodes are the + # ladder's rung vertices and nothing may be inserted on them + for _d, c in gmsh.model.getEntities(1): + ends = gmsh.model.getBoundary([(1, c)], oriented=False) + xy = [np.asarray(gmsh.model.getValue(0, e[1], []))[:2] for e in ends] + if len(xy) == 2 and all( + min(np.linalg.norm(q - r) for r in rung) < 1e-9 for q in xy): + gmsh.model.mesh.setTransfiniteCurve(c, 2) + gmsh.option.setNumber("Mesh.MeshSizeMin", 0.7 * size) + gmsh.option.setNumber("Mesh.MeshSizeMax", 1.3 * size) + gmsh.model.mesh.generate(2) + tags, xyz, _ = gmsh.model.mesh.getNodes() + xy = np.asarray(xyz).reshape(-1, 3)[:, :2] + xy = _snap_to_boundary_2d(xy, comp) + renum = {int(tg): i for i, tg in enumerate(tags)} + tris = [] + for _d, f in faces: + et, _ei, en = gmsh.model.mesh.getElements(2, f) + for ty, nodes in zip(et, en): + if ty == 2: + tris.append(np.array([renum[int(x)] for x in nodes], + dtype=np.int64).reshape(-1, 3)) + if not tris: + raise RuntimeError("the ladder's outcrop cap meshed to no triangles") + tris = np.vstack(tris) + # merge the cap's rung nodes onto the ladder's + ladder_rung = [k - k0, (k - k0) + (k1 - k0 + 1), (k - k0) + 2 * (k1 - k0 + 1)] + remap = np.arange(len(xy)) + n_off + keep = np.ones(len(xy), dtype=bool) + for r_xy, r_id in zip(rung, ladder_rung): + hit = np.flatnonzero(np.linalg.norm(xy - r_xy, axis=1) < 1e-9) + if hit.size != 1: + raise RuntimeError( + "the ladder's outcrop cap does not carry its rung " + "vertices as nodes") + remap[hit[0]] = r_id + keep[hit[0]] = False + new_ids = np.cumsum(keep) - 1 + n_off + remap[keep] = new_ids[keep] + pts_all.append(xy[keep]) + tris_all.append(remap[tris]) + n_off += int(keep.sum()) + gmsh.model.remove() + finally: + gmsh.finalize() + pts = np.vstack(pts_all) + tris = np.vstack(tris_all) + Q = pts[tris] + twice = ((Q[:, 1, 0] - Q[:, 0, 0]) * (Q[:, 2, 1] - Q[:, 0, 1]) + - (Q[:, 1, 1] - Q[:, 0, 1]) * (Q[:, 2, 0] - Q[:, 0, 0])) + flip = twice < 0.0 + tris[flip] = tris[flip][:, [0, 2, 1]] + return pts, tris, float(area) + + def _occ_ladder_assembly_2d(polylines, width, size, assembly="fuse", domain=None): """The cut-ready band: transfinite, THREE nodes across, spine included. @@ -5441,10 +5623,9 @@ def _occ_ladder_assembly_2d(polylines, width, size, assembly="fuse", # rung positions and mitred reach vectors from ONE parametrisation # (a coarser level passes S[::2], reach[::2]). Used verbatim. S, R = (np.asarray(q, dtype=float) for q in p0) - pts_c, tris_c, area_c = _ladder_curved_assembly_2d( - S, width, size, reach=R) - _ladder_domain_gate_2d(pts_c, domain) - return pts_c, tris_c, area_c + if domain is not None: + return _ladder_outcrop_assembly_2d(S, R, width, size, domain) + return _ladder_curved_assembly_2d(S, width, size, reach=R) P = np.asarray(p0, dtype=float)[:, :2] a, b = P[0], P[-1] t = b - a @@ -5459,21 +5640,26 @@ def _occ_ladder_assembly_2d(polylines, width, size, assembly="fuse", # same three-rail structure, mitred. The straight path below # stays on gmsh transfinite so the recorded composed benchmark # remains bit-identical. - pts_c, tris_c, area_c = _ladder_curved_assembly_2d(P, width, size) - _ladder_domain_gate_2d(pts_c, domain) - return pts_c, tris_c, area_c + if domain is not None: + seg = np.linalg.norm(np.diff(P, axis=0), axis=1) + arc = np.concatenate([[0.0], np.cumsum(seg)]) + n_s = max(2, int(round(float(arc[-1]) / size))) + s_ = np.linspace(0.0, float(arc[-1]), n_s + 1) + S = np.column_stack([np.interp(s_, arc, P[:, 0]), + np.interp(s_, arc, P[:, 1])]) + return _ladder_outcrop_assembly_2d(S, _mitred_reach_2d(S), width, + size, domain) + return _ladder_curved_assembly_2d(P, width, size) corners = np.array([a + 0.5 * width * n, b + 0.5 * width * n, b - 0.5 * width * n, a - 0.5 * width * n]) - if domain is not None: - lo = np.min([np.min(np.asarray(Lp, dtype=float), axis=0) - for Lp in domain], axis=0) - hi = np.max([np.max(np.asarray(Lp, dtype=float), axis=0) - for Lp in domain], axis=0) - if (corners < lo - 1e-12).any() or (corners > hi + 1e-12).any(): - raise ValueError( - "the ladder band protrudes past the domain; it is meshed " - "transfinite and cannot be clipped. Shorten the trace or " - "use the default band (which clips).") + if domain is not None and not _inside_domain_2d(corners, domain).all(): + # a straight band reaching the wall: the numpy ladder with the + # outcrop cap (the gmsh transfinite path below stays for the + # interior band, bit-identical to the recorded benchmark) + n_s = max(2, int(round(L / size))) + S = a[None, :] + np.linspace(0.0, L, n_s + 1)[:, None] * t[None, :] + return _ladder_outcrop_assembly_2d(S, np.tile(n, (len(S), 1)), width, + size, domain) n_along = max(2, int(round(L / size))) gmsh.initialize() @@ -5766,7 +5952,8 @@ def _arc_project(polyline, p): return best_d, best_s -def _outcrop_ring_splice(ring, X, boundary_pairs, chain_ids, chain_ends): +def _outcrop_ring_splice(ring, X, boundary_pairs, chain_ids, chain_ends, + keep=None): """Replace the cavity ring's boundary span with the ribbon's interior chain. @@ -5787,6 +5974,13 @@ def _outcrop_ring_splice(ring, X, boundary_pairs, chain_ids, chain_ends): ``(spliced_ring, removed_wall_pairs)``, the removed pairs as old vertex rows for the wall-label discovery. A second boundary run refuses — the carve spilled onto the boundary away from the outcrop. + + ``keep`` (a vertex mask) names span vertices that SURVIVE: on a curved + wall the boundary's own vertices beside the band are protected (each + is a corner of the compressed boundary), and the cap must run through + them rather than chord across — the chord loses the sagitta, and the + area guard refuses. Only the span between the last kept vertex before + the band and the first after it is removed. """ n = len(ring) wall_edge = [frozenset((ring[i], ring[(i + 1) % n])) in boundary_pairs @@ -5820,7 +6014,25 @@ def _outcrop_ring_splice(ring, X, boundary_pairs, chain_ids, chain_ends): # its corner_l-side end first — the loop's orientation is preserved. tail = [ring[(i0 + k + j) % n] for j in range(n - k + 1)] seq = chain_ids if ends[0][1] <= ends[1][1] else chain_ids[::-1] - return tail + list(seq), removed + s_lo, s_hi = sorted(e[1] for e in ends) + if keep is None: + return tail + list(seq), removed + # arc parameter of every span vertex, in the span's own polyline + span_ids = [ring[(i0 + j) % n] for j in range(k + 1)] + s_v = [_arc_project(span, X[v])[1] for v in span_ids] + before = [(v, sv) for v, sv in zip(span_ids[1:-1], s_v[1:-1]) + if sv < s_lo - 1e-12 and keep[v]] + after = [(v, sv) for v, sv in zip(span_ids[1:-1], s_v[1:-1]) + if sv > s_hi + 1e-12 and keep[v]] + before.sort(key=lambda t: t[1]) + after.sort(key=lambda t: t[1]) + # the removed wall pairs: a span edge that loses a vertex, or whose arc + # overlaps the band's clipped edge between the corners + removed = [(span_ids[j], span_ids[j + 1]) for j in range(k) + if not (keep[span_ids[j]] and keep[span_ids[j + 1]]) + or (s_v[j] < s_hi + 1e-12 and s_v[j + 1] > s_lo - 1e-12)] + return (tail + [v for v, _sv in before] + list(seq) + + [v for v, _sv in after]), removed def _ring_growing(cells, drop, held_mask): @@ -7297,7 +7509,8 @@ def _place_thin_volume_2d(dm, polylines, width, label, label_value, chain_ids = [len(X) + int(v) for v in chain_k] chain_ends = (asm_pts[chain_k[0]], asm_pts[chain_k[-1]]) ring, removed_wall = _outcrop_ring_splice( - ring, X, boundary_pairs, chain_ids, chain_ends) + ring, X, boundary_pairs, chain_ids, chain_ends, + keep=~victim) off_band = [v for v in ring if v < len(X) and on_wall[v] and not near_touched[v]] if off_band: @@ -7356,14 +7569,18 @@ def _place_thin_volume_2d(dm, polylines, width, label, label_value, span_labels.append(pairs_p) span_segments.append((X[int(a)].copy(), X[int(b)].copy())) - # The splice ends: the surviving corner vertices and the - # chain ends they meet — the chain is the ring's tail, in - # the orientation the splice chose. + # The splice ends: the surviving corner vertices (the ends + # of the removed span) and the chain ends they meet, in + # the orientation the splice chose. The chain is the ring's + # one run of assembly rows; on a curved wall the splice + # keeps the wall's own vertices after it, so the run is + # located, not assumed to be the tail. + chain_pos = [i for i, v in enumerate(ring) if v >= len(X)] outcrop = (span_labels, span_segments, int(removed_wall[0][0]), int(removed_wall[-1][1]), - int(ring[-len(chain_k)]) - len(X), - int(ring[-1]) - len(X)) + int(ring[chain_pos[0]]) - len(X), + int(ring[chain_pos[-1]]) - len(X)) kept_all = np.zeros(len(asm_tris), dtype=bool) ring_band = [] diff --git a/src/underworld3/utilities/rotated_bc.py b/src/underworld3/utilities/rotated_bc.py index 8a03e2e25..6e08f7ef3 100644 --- a/src/underworld3/utilities/rotated_bc.py +++ b/src/underworld3/utilities/rotated_bc.py @@ -1706,24 +1706,22 @@ def _build_rotated_custom_Pl(solver, Q, normal_rows): h, Ps = resolved if resolved is not None else (None, None) if h is None or Ps is None: return None - if getattr(h, "has_split_levels", False): - # Split-level multigrid: the coarse levels carry the fault too, so each - # takes its own pair rotation and loses its opening rows, and the - # unknowns no fine unknown reaches are dropped (utilities.fault_mg). + walls = bool(getattr(solver, "_rotated_freeslip_bcs", None)) + if getattr(h, "has_split_levels", False) or \ + (walls and getattr(h, "drop_orphans", False)): + # Per-level rotation: a split coarse level takes its own pair + # rotation and loses its opening rows; under rotated free-slip walls + # every coarse level takes the wall rotation and loses its normal + # rows (a Cartesian coarse level under a rotated finest keeps wall + # unknowns the fine operator barely reaches, and the smoother's + # nodal block inversion meets a zero pivot); the unknowns no fine + # unknown reaches are then dropped (utilities.fault_mg). from underworld3.utilities.fault_mg import rotated_split_transfers Pl = rotated_split_transfers(solver, h, Ps, Q, normal_rows) if getattr(solver, "_custom_mg", None) is None: solver._custom_mg = {"mode": "hierarchy", "hierarchy": h, "verbose": False} return Pl - if getattr(h, "drop_orphans", False): - # unreached coarse unknowns are dropped, never injected; the - # finest's layout (and its FAC patch rows) is untouched - from underworld3.utilities.fault_mg import restrict_unreached - Ps = restrict_unreached(Ps, solver.dm.comm) - rows = getattr(h, "level_patch_rows", None) - if rows is not None: - h.level_patch_rows = [None] * (len(rows) - 1) + [rows[-1]] vel_is = solver._subdict["velocity"][0] vis = np.asarray(vel_is.getIndices()) g2blk = {int(g): k for k, g in enumerate(vis)} @@ -1744,11 +1742,24 @@ def _build_rotated_custom_Pl(solver, Q, normal_rows): nrows_blk = sorted({rstart + g2blk[g] for g in normal_rows if g in g2blk}) Pfine.zeroRows(nrows_blk, diag=0.0) + Pl = list(Ps[:-1]) + [Pfine] + if getattr(h, "drop_orphans", False): + # Unreached coarse unknowns are dropped, never injected, AFTER the + # finest's constrained rows are zeroed: a coarse wall node's normal + # component is reached only through those rows (or, on an axis, + # through tangential rows weighted by a vanishing sine), and kept it + # is a zero Galerkin diagonal. The finest's layout (and its FAC + # patch rows) is untouched. + from underworld3.utilities.fault_mg import restrict_unreached + Pl = restrict_unreached(Pl, solver.dm.comm) + rows = getattr(h, "level_patch_rows", None) + if rows is not None: + h.level_patch_rows = [None] * (len(rows) - 1) + [rows[-1]] # Remember an opportunistic mesh-owned pickup, so a later solve on this solver # (rotated or not) reuses the hierarchy instead of re-resolving it. if getattr(solver, "_custom_mg", None) is None: solver._custom_mg = {"mode": "hierarchy", "hierarchy": h, "verbose": False} - return list(Ps[:-1]) + [Pfine] + return Pl def _pressure_mass_schur_pmat(solver): diff --git a/tests/test_1091_place_outcrop_curved_wall.py b/tests/test_1091_place_outcrop_curved_wall.py new file mode 100644 index 000000000..818aada2c --- /dev/null +++ b/tests/test_1091_place_outcrop_curved_wall.py @@ -0,0 +1,123 @@ +"""A ribbon placed into an annulus: every cell size, and a DIPPING band that +outcrops through the curved wall (the Zhong & Gurnis trench, 2026-10-14). + +Two placer defects, each with the probe that found it: + +* the domain tool's boundary loops arrived with whichever winding the walk + started on; a hole loop wound against the exterior was ADDED by OCC, the + tool had the area of the full disc plus the hole, and the ribbon's + intersection with it was empty ("meshed to no triangles") — for cell sizes + 0.04 and 0.08 but not 0.05 or 0.06, which happened to wind both loops the + same way; +* the 2-D outcrop frame read every chord of the faceted wall as its own + wall, so a dipping band's clearance cavity, spanning a few chords beside + the one it crosses, was refused as contact "away from the outcrop"; and + the splice then chorded across the wall's own vertices, losing the sagitta + the area guard refuses. The frame now groups chords into smooth walls + (the 3-D rule) and the splice keeps the surviving wall vertices. +""" +import numpy as np +import pytest + +import underworld3 as uw +from underworld3.utilities.place_surface import place_thin_volume + +pytestmark = [pytest.mark.level_2, pytest.mark.tier_b, + pytest.mark.skipif(uw.mpi.size > 1, reason="serial suite")] + + +def _census(new, zone_value): + fS, fE = new.getHeightStratum(1) + lower, upper, trace = (new.getLabel(n) for n in ("Lower", "Upper", "Zone_trace")) + n_trace = n_bare = 0 + for f in range(fS, fE): + if len(new.getSupport(f)) != 1: + continue + if lower.getValue(f) < 0 and upper.getValue(f) < 0: + n_bare += 1 + if trace.getValue(f) == zone_value: + n_trace += 1 + return n_trace, n_bare + + +def _dipping(theta, top_r, length, dip=np.deg2rad(45.0)): + t_hat = np.array([np.cos(theta), np.sin(theta)]) + s_hat = np.array([-np.sin(theta), np.cos(theta)]) + d_hat = -np.cos(dip) * t_hat + np.sin(dip) * s_hat + return np.array([t_hat * top_r, t_hat * top_r + d_hat * length]) + + +@pytest.mark.parametrize("cell_size", [0.04, 0.05, 0.06]) +def test_an_interior_ribbon_places_at_every_cell_size(cell_size): + from underworld3.utilities.line_cut import cell_areas + + base = uw.meshing.Annulus(radiusOuter=1.0, radiusInner=0.5, cellSize=cell_size, qdegree=2) + zv = base._boundaries_with("Zone")["Zone"].value + ray = np.array([np.cos(0.4), np.sin(0.4)]) + before = float(cell_areas(base.dm).sum()) + new, info = place_thin_volume(base.dm, [np.array([0.62 * ray, 0.88 * ray])], + width=0.03, label="Zone", label_value=zv) + assert info["n_zone_cells"] > 0 + assert float(cell_areas(new).sum()) == pytest.approx(before, rel=1e-12) + + +def test_a_dipping_band_outcrops_through_the_curved_wall(): + from underworld3.utilities.line_cut import cell_areas + + base = uw.meshing.Annulus(radiusOuter=1.0, radiusInner=0.55, cellSize=0.04, + refinement=1, qdegree=2) + zv = base._boundaries_with("Zone")["Zone"].value + before = float(cell_areas(base.dm).sum()) + new, info = place_thin_volume(base.dm, [_dipping(0.0, 1.03, 0.25)], width=0.02, + label="Zone", label_value=zv, size=0.005, clearance=0.7) + n_trace, n_bare = _census(new, zv) + assert info["n_trace_facets"] > 0 and n_trace == info["n_trace_facets"], \ + "the dipping band left no outcrop trace on the wall" + assert n_bare == 0, "the relabel left boundary edges without a wall label" + assert float(cell_areas(new).sum()) == pytest.approx(before, rel=1e-9), \ + "the splice chorded across the wall's vertices" + assert float(cell_areas(new).min()) > 0.0 + + +def test_the_structured_ladder_outcrops_through_a_cap(): + """The ladder (rungs on the spine, one element either side) used to be + refused at a wall outright. It now runs to one rung short of the wall + and a clipped unstructured cap closes it to the outcrop, so a TI band + reaches the surface and a split spine stops on its own rung vertices + below it. Both placement routes: the thin volume, and the ribbon placer + with a trace shorter than its spine.""" + from underworld3.utilities.line_cut import cell_areas + from underworld3.utilities.place_surface import place_fault_ribbon_2d + + S = 0.005 + base = uw.meshing.Annulus(radiusOuter=1.0, radiusInner=0.55, cellSize=0.16, + refinement=3, qdegree=2) + zv = base._boundaries_with("Zone")["Zone"].value + dip = np.deg2rad(45.0) + d_hat = np.array([-np.cos(dip), np.sin(dip)]) + top = np.array([1.02, 0.0]) + n = int(round(0.2 / S)) + spine = np.array([top + d_hat * (0.2 * i / n) for i in range(n + 1)]) + before = float(cell_areas(base.dm).sum()) + new, info = place_thin_volume(base.dm, [spine], width=2 * S, label="Zone", + label_value=zv, size=S, clearance=0.7, mesher="ladder") + n_trace, n_bare = _census(new, zv) + assert info["n_trace_facets"] > 0 and n_trace == info["n_trace_facets"] + assert n_bare == 0 + assert float(cell_areas(new).sum()) == pytest.approx(before, rel=1e-9) + assert float(cell_areas(new).min()) > 0.0 + + r = np.hypot(spine[:, 0], spine[:, 1]) + trace = spine[r <= 1.0 - 3 * S] + mesh, rinfo = place_fault_ribbon_2d(base, [("Trench", trace)], 2 * S, + spines=[("Trench", spine)], margin_rings=1, + clearance=0.7, split=True, mesher="ladder") + pairs = mesh._fault_point_pairs["Trench"] + # interior vertices plus the edges between them (P2 carries edge points) + assert len(pairs) == 2 * len(trace) - 3, "the spine is split on its own rung vertices" + X = np.asarray(mesh.dm.getCoordinatesLocal().array).reshape(-1, 2) + vS, vE = mesh.dm.getDepthStratum(0) + pp = np.array([int(q) for q in pairs] + [int(q) for q in pairs.values()]) + pv = pp[(pp >= vS) & (pp < vE)] - vS # the vertex points of the pairing + assert np.hypot(X[pv, 0], X[pv, 1]).max() < 1.0 - 2 * S, "no pair node near the wall" + assert len(mesh._custom_mg_coarse_meshes) == 4, "the base tail is adopted" From 8052c3aa8df9eb8041fa16e74a21d83848fad59a Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Wed, 14 Oct 2026 06:35:05 +1100 Subject: [PATCH 43/57] zg95: checkpoint and restart in the driver; offline checkpoint renderer Underworld development team with AI support from Claude Code --- scripts/sessions/fault_mg/zg95_render.py | 56 ++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 scripts/sessions/fault_mg/zg95_render.py diff --git a/scripts/sessions/fault_mg/zg95_render.py b/scripts/sessions/fault_mg/zg95_render.py new file mode 100644 index 000000000..24e0f0832 --- /dev/null +++ b/scripts/sessions/fault_mg/zg95_render.py @@ -0,0 +1,56 @@ +"""Render a zg95 checkpoint offline: T on the base from the global h5 (P2 cells +subdivided into four), with the trench and the surface profiles of that step +when the driver wrote them. + +python zg95_render.py [out.png] +""" +import glob +import json +import os +import sys + +import h5py +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import matplotlib.tri as mtri +import numpy as np + +run, idx = sys.argv[1], int(sys.argv[2]) +out = sys.argv[3] if len(sys.argv) > 3 else os.path.join(run, f"render_{idx:04d}.png") +with h5py.File(os.path.join(run, f"zg95.mesh.T_b.{idx:05d}.h5")) as h: + T = np.asarray(h["fields/T_b"]).ravel() + X = np.asarray(h["fields/coordinates"]) + C = np.asarray(h["fields/cells"]).astype(int) +# P2 triangle node order: 3 vertices then 3 edge midpoints (opposite order per PETSc: +# midpoints follow the vertex order of the edges 01, 12, 20); subdivide into 4 +v0, v1, v2, m01, m12, m20 = (C[:, k] for k in range(6)) +tris = np.vstack([np.column_stack([v0, m01, m20]), np.column_stack([m01, v1, m12]), + np.column_stack([m20, m12, v2]), np.column_stack([m01, m12, m20])]) +state = {} +sf = os.path.join(run, f"state_{idx:04d}.json") +if os.path.exists(sf): + state = json.load(open(sf)) +surf = os.path.join(run, f"surface_{idx:04d}.npz") +has_surf = os.path.exists(surf) +fig, axes = plt.subplots(1, 3 if has_surf else 1, figsize=(18 if has_surf else 7, 5.8), squeeze=False) +ax = axes[0, 0] +tc = ax.tripcolor(mtri.Triangulation(X[:, 0], X[:, 1], tris), T, shading="gouraud", cmap="RdBu_r", vmin=0, vmax=1) +if has_surf: + tr = np.load(surf)["trace"] + ax.plot(tr[:, 0], tr[:, 1], color="k", lw=1.5) +ax.set_aspect("equal"); ax.set_xticks([]); ax.set_yticks([]) +ax.set_title(f"step {idx}, t = {state.get('t', float('nan')):.4f}: T on the base", fontsize=9, loc="left") +fig.colorbar(tc, ax=ax, shrink=0.8) +if has_surf: + d = np.load(surf) + th0 = np.degrees(state.get("theta_t", 0.0)) + ax = axes[0, 1] + ax.plot(np.degrees(d["theta"]), d["vt"], lw=1); ax.axvline(th0, color="#c0392b", lw=0.8, ls="--") + ax.set_xlabel("theta (deg)"); ax.set_ylabel("surface tangential velocity"); ax.grid(alpha=0.3) + ax = axes[0, 2] + ax.plot(np.degrees(d["theta_h"]), d["h"], lw=1); ax.axvline(th0, color="#c0392b", lw=0.8, ls="--") + ax.set_xlabel("theta (deg)"); ax.set_ylabel("dynamic topography"); ax.grid(alpha=0.3) +fig.tight_layout() +fig.savefig(out, dpi=130) +print("wrote", out) From e611f9ff8ee446472b06788372411328059985f6 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Wed, 14 Oct 2026 07:30:13 +1100 Subject: [PATCH 44/57] Rotated custom multigrid: the level operators drop inode grouping On the developed temperature field of the annulus run at four ranks the inode SOR's 2x2 block inversion met a zero pivot on the base's two finest levels with every scalar diagonal healthy; without the grouping the same solve converged in nine iterations to the two-rank answer. A rotated wall node keeps one tangential row beside the two-row nodes. The finest operator is untouched. The driver gains a level inspection (ZG95_INSPECT) and the parallel reductions. Underworld development team with AI support from Claude Code --- .../subsystems/fault-split-multigrid.md | 9 ++++++++ scripts/sessions/fault_mg/zg95_annulus.py | 22 +++++++++++++++++++ src/underworld3/utilities/rotated_bc.py | 14 ++++++++++++ 3 files changed, 45 insertions(+) diff --git a/docs/developer/subsystems/fault-split-multigrid.md b/docs/developer/subsystems/fault-split-multigrid.md index b6fad6dd2..cb8041b1d 100644 --- a/docs/developer/subsystems/fault-split-multigrid.md +++ b/docs/developer/subsystems/fault-split-multigrid.md @@ -84,6 +84,15 @@ zero). Two further rules came out of the same case: - the reach test of the orphan rule is relative (`UNREACHED_REL`, 1e-8 of the level's largest column, squared norms) and runs after the finest's constrained rows are zeroed, not before; +- the Galerkin operators of the rotated coarse levels do not use PETSc's inode + block grouping (`MAT_USE_INODES` off, set after the velocity PC's set-up; the + finest operator is untouched). A rotated wall node keeps one tangential row + beside the two-row nodes, and on the developed temperature field of the + annulus run at four ranks the inode SOR's 2x2 block inversion met a zero pivot + on the base's two finest levels with every scalar diagonal healthy (minimum + 2.6 against a maximum of 3e4); without the grouping the same solve converged + in 9 iterations to the two-rank answer. Which rows the grouping pairs there is + not yet identified (a row-level inspection is in the driver's history); - the free-slip gauge mode must verify for the SVD coarse solve to be selected, which on a faceted circle needs the exact radial normal (`normal=mesh.CoordinateSystem.unit_e_0`); and that SVD needs a small coarsest diff --git a/scripts/sessions/fault_mg/zg95_annulus.py b/scripts/sessions/fault_mg/zg95_annulus.py index 3208a3ac2..1913a6631 100644 --- a/scripts/sessions/fault_mg/zg95_annulus.py +++ b/scripts/sessions/fault_mg/zg95_annulus.py @@ -71,6 +71,9 @@ def gather_arrays(*arrs): ap.add_argument("--migrate", type=int, default=1, help="advect the trench with the overriding plate") args = ap.parse_args() os.makedirs(args.out, exist_ok=True) +if os.environ.get("ZG95_NOINODE"): + from petsc4py import PETSc + PETSc.Options()["mat_no_inode"] = True RI, RO = 0.55, 1.0 S, W = args.S, 2.0 * args.S DIP = np.deg2rad(args.dip) @@ -187,10 +190,29 @@ def __init__(self, theta_t, tag): def solve(self): self.T.array[:, 0, 0] = np.asarray(uw.function.global_evaluate( T_b.sym[0], clamp_radii(np.asarray(self.T.coords)))).ravel() + n_nan = int(comm.allreduce(int(np.isnan(self.T.array).sum()), op=MPI.SUM)) + tmin, tmax = gmin(np.nanmin(self.T.array) if self.T.array.shape[0] else 1.0), gmax(np.nanmax(self.T.array) if self.T.array.shape[0] else 0.0) + log(f" T on the add-on: nan {n_nan}, range {tmin:.3f} {tmax:.3f}") t0 = time.perf_counter() self.stokes.solve() info = self.stokes._rotated_freeslip_info self.t_solve = time.perf_counter() - t0 + if os.environ.get("ZG95_INSPECT"): + try: + ctx = (getattr(self.stokes, "_rotated_linear_cache", None) or {})["ctx"] + velpc = ctx["ksp"].getPC().getFieldSplitSubKSP()[0].getPC() + for l in range(velpc.getMGLevels()): + sm = velpc.getMGSmoother(l) + A = sm.getOperators()[0] + d = A.getDiagonal().array + dmin = gmin(np.abs(d).min() if d.size else np.inf) + dmax = gmax(np.abs(d).max() if d.size else 0.0) + b, x = A.createVecs(); b.setRandom(); x.set(0.0) + sm.solve(b, x) + log(f" level {l}: n {A.getSize()[0]} diag min {dmin:.3e} max {dmax:.3e} " + f"smoother |x| {x.norm():.3e} reason {sm.getConvergedReason()}") + except Exception as e: + log(" inspection failed:", e) self.stokes.dynamic_topography("Upper", self.h, buoyancy_scale=args.ra) rec = {"vel_its": info.get("vel_its_last"), "pres_its": info.get("pres_its_last"), "converged": bool(info.get("converged")), "t_solve": round(self.t_solve, 1)} diff --git a/src/underworld3/utilities/rotated_bc.py b/src/underworld3/utilities/rotated_bc.py index 6e08f7ef3..fd2dbc788 100644 --- a/src/underworld3/utilities/rotated_bc.py +++ b/src/underworld3/utilities/rotated_bc.py @@ -1718,6 +1718,7 @@ def _build_rotated_custom_Pl(solver, Q, normal_rows): # unknown reaches are then dropped (utilities.fault_mg). from underworld3.utilities.fault_mg import rotated_split_transfers Pl = rotated_split_transfers(solver, h, Ps, Q, normal_rows) + solver._custom_mg_rotated_levels = True if getattr(solver, "_custom_mg", None) is None: solver._custom_mg = {"mode": "hierarchy", "hierarchy": h, "verbose": False} @@ -2003,6 +2004,19 @@ def _solve_rotated_iterative(solver, Ahat, bhat, Q, Qt, normal_rows, verbose=Fal smoother=solver._mg_smoother_variant, patch_rows=getattr(_hier, "level_patch_rows", None)) vel_pc.setUp() + if getattr(solver, "_custom_mg_rotated_levels", False): + # Rotated coarse levels: a wall node keeps one tangential + # row beside the two-row nodes, and the inode SOR's 2x2 + # block inversion met a zero pivot on the Galerkin + # operators of a developed temperature field (four + # ranks on the Zhong & Gurnis annulus, 2026-10-14) with + # every scalar diagonal healthy; without the inode + # grouping the same solve converged in 9 iterations to + # the two-rank answer. The finest operator is untouched. + for _l in range(vel_pc.getMGLevels() - 1): + _A = vel_pc.getMGSmoother(_l).getOperators()[0] + _A.setOption(PETSc.Mat.Option.USE_INODES, False) + _A.assemble() # Force the FAC levels' smoother setup NOW: PCASM creates its # sub-KSP (which reads mg_levels__sub_* from the DB) lazily # at first application, which on this path is AFTER the key From cb1f714c63651821fa45a43d90f33e575d65a021 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Wed, 14 Oct 2026 08:26:26 +1100 Subject: [PATCH 45/57] zg95: yielding, internal heating, flow and yield diagnostics in the driver; the jigsaw renderer Underworld development team with AI support from Claude Code --- scripts/sessions/fault_mg/zg95_annulus.py | 133 ++++++++++++++++++++-- scripts/sessions/fault_mg/zg95_jigsaw.py | 82 +++++++++++++ 2 files changed, 204 insertions(+), 11 deletions(-) create mode 100644 scripts/sessions/fault_mg/zg95_jigsaw.py diff --git a/scripts/sessions/fault_mg/zg95_annulus.py b/scripts/sessions/fault_mg/zg95_annulus.py index 1913a6631..dd852ba67 100644 --- a/scripts/sessions/fault_mg/zg95_annulus.py +++ b/scripts/sessions/fault_mg/zg95_annulus.py @@ -69,6 +69,11 @@ def gather_arrays(*arrs): ap.add_argument("--fig_every", type=int, default=5) ap.add_argument("--theta0", type=float, default=0.0, help="initial trench position (rad)") ap.add_argument("--migrate", type=int, default=1, help="advect the trench with the overriding plate") +ap.add_argument("--heating", type=float, default=0.0, help="internal heating rate (source term)") +ap.add_argument("--yield0", type=float, default=0.0, help="yield stress at the surface (0: no yielding)") +ap.add_argument("--yield_depth", type=float, default=0.0, help="yield stress gradient with depth") +ap.add_argument("--eta_min", type=float, default=1.0, help="viscosity floor under yielding") +ap.add_argument("--T_int", type=float, default=0.6, help="initial interior temperature") args = ap.parse_args() os.makedirs(args.out, exist_ok=True) if os.environ.get("ZG95_NOINODE"): @@ -91,11 +96,12 @@ def log(*a): tail = base._coarse_level_meshes()[:-1] + [base] T_b = uw.discretisation.MeshVariable("T_b", base, 1, degree=2) V_b = uw.discretisation.MeshVariable("V_b", base, 2, degree=2) +Y_b = uw.discretisation.MeshVariable("Y_b", base, 1, degree=1) # eta_eff / eta(T): 1 unyielded xb = np.asarray(T_b.coords) rb = np.hypot(xb[:, 0], xb[:, 1]) thb = np.arctan2(xb[:, 1], xb[:, 0]) z = (RO - rb) / (RO - RI) -T_int = 0.6 +T_int = args.T_int T0 = (T_int * erf(z / 0.1) + (1.0 - T_int) * (1.0 - erf((1.0 - z) / 0.1)) - 0.04 * np.cos(3 * thb) * np.sin(np.pi * z)) # downwelling under the trench at theta 0 T_b.array[:, 0, 0] = np.clip(T0, 0.0, 1.0) @@ -107,7 +113,7 @@ def log(*a): adv = uw.systems.AdvDiffusionSLCN(base, u_Field=T_b, V_fn=V_b.sym, order=1, monotone_mode="clamp") adv.constitutive_model = uw.constitutive_models.DiffusionModel adv.constitutive_model.Parameters.diffusivity = 1.0 -adv.f = 0.0 +adv.f = args.heating adv.add_dirichlet_bc(0.0, "Upper") adv.add_dirichlet_bc(1.0, "Lower") unit_r_b = base.CoordinateSystem.unit_e_0 @@ -117,6 +123,39 @@ def eta_of(Tsym): return sympy.exp(sympy.log(args.deta) * (1 - Tsym)) # 1 at T = 1, deta at T = 0 +def yield_stress_on(mesh): + """tau_y = yield0 + yield_depth * depth, depth measured from the outer wall""" + x, y = mesh.X + depth = RO - sympy.sqrt(x ** 2 + y ** 2) + return args.yield0 + args.yield_depth * depth + + +def set_rheology(solver, mesh, Tsym, zone_sym=None): + """Temperature-dependent viscosity, with yielding when ``--yield0`` is set. + ``zone_sym`` (0/1 per cell) marks the weak bridge: under the TI model it is the + weak plane's footprint; under yielding (the TI model has no yield stress + without elasticity) it is an isotropic weak zone, viscosity and yield stress + both reduced by ``--eta_w``.""" + eta0 = eta_of(Tsym) + weak = 1 if zone_sym is None else (1 - (1 - args.eta_w) * zone_sym) + if args.yield0 > 0: + solver.constitutive_model = uw.constitutive_models.ViscoPlasticFlowModel + P = solver.constitutive_model.Parameters + P.shear_viscosity_0 = eta0 * weak + P.shear_viscosity_min = args.eta_min + P.yield_stress = yield_stress_on(mesh) * weak + return + if zone_sym is None: + solver.constitutive_model = uw.constitutive_models.ViscousFlowModel + solver.constitutive_model.Parameters.shear_viscosity_0 = eta0 + return + solver.constitutive_model = uw.constitutive_models.TransverseIsotropicFlowModel + P = solver.constitutive_model.Parameters + P.shear_viscosity_0 = eta0 + P.shear_viscosity_1 = eta0 * weak + return P # the caller sets the director + + def clamp_radii(x): r = np.hypot(x[:, 0], x[:, 1]) f = np.clip(r, RI + 1e-10, RO - 1e-10) / np.maximum(r, 1e-14) @@ -168,11 +207,9 @@ def __init__(self, theta_t, tag): self.bridge = self.band.copy() self.zone.array[:, 0, 0] = self.bridge.astype(float) s = uw.systems.Stokes(mesh, velocityField=self.v, pressureField=self.p) - eta0 = eta_of(self.T.sym[0]) - s.constitutive_model = uw.constitutive_models.TransverseIsotropicFlowModel - s.constitutive_model.Parameters.shear_viscosity_0 = eta0 - s.constitutive_model.Parameters.shear_viscosity_1 = eta0 * (1 - (1 - args.eta_w) * self.zone.sym[0]) - s.constitutive_model.Parameters.director = sympy.Matrix([[float(self.n_hat[0]), float(self.n_hat[1])]]) + P = set_rheology(s, mesh, self.T.sym[0], self.zone.sym[0]) + if P is not None: + P.director = sympy.Matrix([[float(self.n_hat[0]), float(self.n_hat[1])]]) unit_r = mesh.CoordinateSystem.unit_e_0 s.bodyforce = args.ra * self.T.sym[0] * unit_r s.add_rotated_freeslip_bc(0, "Upper", normal=unit_r) @@ -243,8 +280,7 @@ def base_stokes(tag): v = uw.discretisation.MeshVariable(f"Ub_{tag}", base, 2, degree=2) p = uw.discretisation.MeshVariable(f"Pb_{tag}", base, 1, degree=1, continuous=True) s = uw.systems.Stokes(base, velocityField=v, pressureField=p) - s.constitutive_model = uw.constitutive_models.ViscousFlowModel - s.constitutive_model.Parameters.shear_viscosity_0 = eta_of(T_b.sym[0]) + set_rheology(s, base, T_b.sym[0]) s.bodyforce = args.ra * T_b.sym[0] * unit_r_b s.add_rotated_freeslip_bc(0, "Upper", normal=unit_r_b) s.add_rotated_freeslip_bc(0, "Lower", normal=unit_r_b) @@ -307,8 +343,71 @@ def figure(step, t, addon, rec): step = 0 +_vb_coords = np.asarray(V_b.coords) +_vb_r = np.hypot(_vb_coords[:, 0], _vb_coords[:, 1]) +_vb_surf = _vb_r > RO - 1e-6 +_tb_coords = np.asarray(T_b.coords) +_tb_depth = RO - np.hypot(_tb_coords[:, 0], _tb_coords[:, 1]) + + +def yielded_fraction(solver, Tsym, coords, depth): + """Fraction of nodes (all, and in the lid: depth < 0.15) where the effective + viscosity is below 0.9 of the temperature-dependent one, i.e. the plastic + branch is active. Collective; NaN when the model has no yield stress.""" + if args.yield0 <= 0 or solver is None: + return float("nan"), float("nan") + try: + cm = solver.constitutive_model + ratio = np.asarray(uw.function.evaluate(cm._plastic_eff_viscosity.sym / eta_of(Tsym), + clamp_radii(coords))).ravel() + except Exception as e: + log(" yielded fraction unavailable:", str(e)[:120]) + return float("nan"), float("nan") + yielded = ratio < 0.9 + lid = depth < 0.15 + try: + if coords.shape[0] == _tb_coords.shape[0] and np.allclose(coords[:8], _tb_coords[:8]): + # the base: keep the indicator at the P1 nodes for the checkpoint + from scipy.spatial import cKDTree + _d, k = cKDTree(coords).query(np.asarray(Y_b.coords)) + Y_b.array[:, 0, 0] = ratio[k] + except Exception: + pass + n_all = comm.allreduce(int(yielded.size), op=MPI.SUM) + n_lid = comm.allreduce(int(lid.sum()), op=MPI.SUM) + y_all = comm.allreduce(int(yielded.sum()), op=MPI.SUM) + y_lid = comm.allreduce(int((yielded & lid).sum()), op=MPI.SUM) + return y_all / max(n_all, 1), y_lid / max(n_lid, 1) + + +def flow_diagnostics(): + """vrms over the base nodes, the surface rms, their ratio (mobility), and the + lid depth: where the horizontally averaged T first reaches 0.8 of its mid-depth + value. Node averages, not integrals; collective.""" + v2 = np.einsum("ij,ij->i", V_b.array[:, 0, :], V_b.array[:, 0, :]) if V_b.array.shape[0] else np.zeros(0) + n_all = comm.allreduce(int(v2.size), op=MPI.SUM) + n_s = comm.allreduce(int(_vb_surf.sum()), op=MPI.SUM) + vrms = np.sqrt(comm.allreduce(float(v2.sum()), op=MPI.SUM) / max(n_all, 1)) + vsurf = np.sqrt(comm.allreduce(float(v2[_vb_surf].sum()), op=MPI.SUM) / max(n_s, 1)) + nb = 45 + edges = np.linspace(0.0, RO - RI, nb + 1) + Tv = T_b.array[:, 0, 0] if T_b.array.shape[0] else np.zeros(0) + idx = np.clip(np.digitize(_tb_depth, edges) - 1, 0, nb - 1) + sums = np.bincount(idx, weights=Tv, minlength=nb) + cnts = np.bincount(idx, minlength=nb).astype(float) + sums = comm.allreduce(sums, op=MPI.SUM) + cnts = comm.allreduce(cnts, op=MPI.SUM) + prof = sums / np.maximum(cnts, 1) + mid = prof[nb // 2] + hit = np.flatnonzero(prof >= 0.8 * mid) + lid = float(edges[hit[0]] + 0.5 * (edges[1] - edges[0])) if hit.size else float("nan") + return {"vrms": float(vrms), "vsurf_rms": float(vsurf), + "mobility": float(vsurf / max(vrms, 1e-30)), "lid_depth": lid, + "T_mid": float(mid)}, prof + + def checkpoint(step, t): - base.write_timestep("zg95", meshUpdates=False, meshVars=[T_b, V_b], index=step, outputPath=args.out) + base.write_timestep("zg95", meshUpdates=False, meshVars=[T_b, V_b, Y_b], index=step, outputPath=args.out) if rank0: json.dump({"step": step, "t": t, "theta_t": float(globals().get("theta_t", args.theta0))}, open(os.path.join(args.out, f"state_{step:04d}.json"), "w")) @@ -326,8 +425,14 @@ def checkpoint(step, t): adv.solve(timestep=dt) t += dt vmax = gmax(np.linalg.norm(V_b.array[:, 0, :], axis=1).max() if V_b.array.shape[0] else 0.0) + diag, prof = flow_diagnostics() + y_all, y_lid = yielded_fraction(s_b, T_b.sym[0], _tb_coords, _tb_depth) rec = {"step": step, "phase": "spinup", "t": t, "dt": dt, "vel_its": info.get("vel_its_last"), - "vmax": vmax, "t_step": round(time.perf_counter() - t0, 1)} + "newton": info.get("nonlinear_iterations"), "vmax": vmax, + "t_step": round(time.perf_counter() - t0, 1), "yield_frac": y_all, "yield_frac_lid": y_lid, + **diag} + if rank0 and step % args.fig_every == 0: + np.save(os.path.join(args.out, f"Tprofile_{step:04d}.npy"), prof) records.append(rec) log("SPIN", json.dumps(rec)) if step % args.fig_every == 0: @@ -367,6 +472,12 @@ def checkpoint(step, t): if args.migrate: theta_t += v_trench * dt / RO vmax = gmax(np.linalg.norm(V_b.array[:, 0, :], axis=1).max() if V_b.array.shape[0] else 0.0) + diag, prof = flow_diagnostics() + rec.update(diag) + xa = np.asarray(addon.T.coords) + y_all, y_lid = yielded_fraction(addon.stokes, addon.T.sym[0], xa, RO - np.hypot(xa[:, 0], xa[:, 1])) + rec.update({"newton": addon.stokes._rotated_freeslip_info.get("nonlinear_iterations"), + "yield_frac": y_all, "yield_frac_lid": y_lid}) rec.update({"step": step, "phase": "fault", "t": t, "dt": dt, "vmax": vmax, "v_trench": v_trench, "theta_t": float(theta_t), "theta_built": float(addon.theta_t), "n_built": n_built, "topo_min": float(hh.min()), "topo_max": float(hh.max()), diff --git a/scripts/sessions/fault_mg/zg95_jigsaw.py b/scripts/sessions/fault_mg/zg95_jigsaw.py new file mode 100644 index 000000000..dba8cf92d --- /dev/null +++ b/scripts/sessions/fault_mg/zg95_jigsaw.py @@ -0,0 +1,82 @@ +"""The pieces side by side: for each (label, run dir, checkpoint index) a row with +T on the base, the surface tangential velocity from the checkpointed velocity, +and the horizontally averaged T profile, titled with the step's mobility and +yielded fraction from the run's records. + +python zg95_jigsaw.py label=dir:index [label=dir:index ...] +""" +import json +import os +import sys + +import h5py +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import matplotlib.tri as mtri +import numpy as np + +out = sys.argv[1] +items = [] +for a in sys.argv[2:]: + label, rest = a.split("=", 1) + d, idx = rest.rsplit(":", 1) + items.append((label, os.path.expanduser(d), int(idx))) +RO, RI = 1.0, 0.55 +fig, axes = plt.subplots(len(items), 3, figsize=(16, 4.3 * len(items)), squeeze=False, + gridspec_kw={"width_ratios": [1.1, 1, 0.8]}) +for row, (label, d, idx) in enumerate(items): + with h5py.File(os.path.join(d, f"zg95.mesh.T_b.{idx:05d}.h5")) as h: + T = np.asarray(h["fields/T_b"]).ravel() + X = np.asarray(h["fields/coordinates"]) + C = np.asarray(h["fields/cells"]).astype(int) + vfile = os.path.join(d, f"zg95.mesh.V_b.{idx:05d}.h5") + V = None + if os.path.exists(vfile): + with h5py.File(vfile) as h: + V = np.asarray(h["fields/V_b"]).reshape(-1, 2) + XV = np.asarray(h["fields/coordinates"]) + v0, v1, v2, m01, m12, m20 = (C[:, k] for k in range(6)) + tris = np.vstack([np.column_stack([v0, m01, m20]), np.column_stack([m01, v1, m12]), + np.column_stack([m20, m12, v2]), np.column_stack([m01, m12, m20])]) + rec = {} + rf = os.path.join(d, "records.json") + if os.path.exists(rf): + recs = [r for r in json.load(open(rf)) if r.get("step") == idx] + rec = recs[0] if recs else {} + t = rec.get("t", float("nan")) + ax = axes[row, 0] + tc = ax.tripcolor(mtri.Triangulation(X[:, 0], X[:, 1], tris), T, shading="gouraud", cmap="RdBu_r", vmin=0, vmax=1) + ax.set_aspect("equal"); ax.set_xticks([]); ax.set_yticks([]) + title = f"{label}: step {idx}, t = {t:.3f}" + if "mobility" in rec: + title += f"; mobility {rec['mobility']:.2f}, lid {rec.get('lid_depth', float('nan')):.3f}" + if rec.get("yield_frac_lid") is not None and not (isinstance(rec.get("yield_frac_lid"), float) and np.isnan(rec["yield_frac_lid"])): + title += f", yielded in lid {100 * rec['yield_frac_lid']:.0f}%" + ax.set_title(title, fontsize=9, loc="left") + if row == 0: + fig.colorbar(tc, ax=ax, shrink=0.8) + ax = axes[row, 1] + if V is not None: + r = np.hypot(XV[:, 0], XV[:, 1]) + on = r > RO - 1e-6 + th = np.arctan2(XV[on, 1], XV[on, 0]) + vt = -V[on, 0] * np.sin(th) + V[on, 1] * np.cos(th) + o = np.argsort(th) + ax.plot(np.degrees(th[o]), vt[o], lw=1) + ax.axhline(0, color="k", lw=0.5) + ax.set_xlabel("theta (deg)"); ax.set_ylabel("surface tangential velocity"); ax.grid(alpha=0.3) + ax.set_title("surface velocity", fontsize=9, loc="left") + ax = axes[row, 2] + depth = RO - np.hypot(X[:, 0], X[:, 1]) + nb = 45 + edges = np.linspace(0, RO - RI, nb + 1) + k = np.clip(np.digitize(depth, edges) - 1, 0, nb - 1) + prof = np.bincount(k, weights=T, minlength=nb) / np.maximum(np.bincount(k, minlength=nb), 1) + ax.plot(prof, 0.5 * (edges[1:] + edges[:-1]), lw=1.2) + ax.invert_yaxis(); ax.set_xlim(0, 1) + ax.set_xlabel("mean T"); ax.set_ylabel("depth"); ax.grid(alpha=0.3) + ax.set_title("horizontally averaged T", fontsize=9, loc="left") +fig.tight_layout() +fig.savefig(out, dpi=120) +print("wrote", out) From ca4133182b1e2c750501c1cfb620c55eb16de1ec Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Wed, 14 Oct 2026 09:04:28 +1100 Subject: [PATCH 46/57] zg95: surface velocity above the unrolled annulus with streamlines Underworld development team with AI support from Claude Code --- scripts/sessions/fault_mg/zg95_stream.py | 124 +++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 scripts/sessions/fault_mg/zg95_stream.py diff --git a/scripts/sessions/fault_mg/zg95_stream.py b/scripts/sessions/fault_mg/zg95_stream.py new file mode 100644 index 000000000..f17010143 --- /dev/null +++ b/scripts/sessions/fault_mg/zg95_stream.py @@ -0,0 +1,124 @@ +"""Surface lateral velocity above the flow: from a zg95 checkpoint, the surface +tangential velocity against theta, aligned above the unrolled annulus (theta, +depth) with temperature and streamlines, and the annulus itself with streamlines +beside it. The trench is drawn when the step wrote its surface file. + +python zg95_stream.py [out.png] +""" +import json +import os +import sys + +import h5py +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import matplotlib.tri as mtri +import numpy as np +from scipy.interpolate import LinearNDInterpolator + +run, idx = sys.argv[1], int(sys.argv[2]) +out = sys.argv[3] if len(sys.argv) > 3 else os.path.join(run, f"stream_{idx:04d}.png") +RO, RI = 1.0, 0.55 +with h5py.File(os.path.join(run, f"zg95.mesh.T_b.{idx:05d}.h5")) as h: + T = np.asarray(h["fields/T_b"]).ravel() + X = np.asarray(h["fields/coordinates"]) + C = np.asarray(h["fields/cells"]).astype(int) +with h5py.File(os.path.join(run, f"zg95.mesh.V_b.{idx:05d}.h5")) as h: + V = np.asarray(h["fields/V_b"]).reshape(-1, 2) + XV = np.asarray(h["fields/coordinates"]) +state = {} +sf = os.path.join(run, f"state_{idx:04d}.json") +if os.path.exists(sf): + state = json.load(open(sf)) +rec = {} +rf = os.path.join(run, "records.json") +if os.path.exists(rf): + rr = [r for r in json.load(open(rf)) if r.get("step") == idx] + rec = rr[0] if rr else {} +trace = None +surf = os.path.join(run, f"surface_{idx:04d}.npz") +if os.path.exists(surf): + trace = np.load(surf)["trace"] + +r = np.hypot(X[:, 0], X[:, 1]) +th = np.arctan2(X[:, 1], X[:, 0]) +rV = np.hypot(XV[:, 0], XV[:, 1]) +thV = np.arctan2(XV[:, 1], XV[:, 0]) +vt = -V[:, 0] * np.sin(thV) + V[:, 1] * np.cos(thV) +vr = V[:, 0] * np.cos(thV) + V[:, 1] * np.sin(thV) + +# unrolled grid (theta in degrees, depth), periodic in theta: pad the nodes by one period +def padded(th_, vals, *more): + th3 = np.concatenate([th_ - 2 * np.pi, th_, th_ + 2 * np.pi]) + return th3, [np.concatenate([v, v, v]) for v in (vals, *more)] +th3, (T3, r3) = padded(th, T, r) +th3V, (vt3, vr3, rV3) = padded(thV, vt, vr, rV) +nth, nd = 720, 120 +thg = np.linspace(-np.pi, np.pi, nth) +dg = np.linspace(0.0, RO - RI, nd) +TH, D = np.meshgrid(thg, dg) +Rg = RO - D +Tg = LinearNDInterpolator(np.column_stack([th3, r3]), T3)(TH, Rg) +vtg = LinearNDInterpolator(np.column_stack([th3V, rV3]), vt3)(TH, Rg) +vrg = LinearNDInterpolator(np.column_stack([th3V, rV3]), vr3)(TH, Rg) +# streamlines in the unrolled plane: d(theta_deg)/dt = v_t / r (in degrees), d(depth)/dt = -v_r +U = np.degrees(vtg / Rg) +W = -vrg + +# annulus grid for the second streamline view +ng = 500 +xg = np.linspace(-RO, RO, ng) +XG, YG = np.meshgrid(xg, xg) +RG = np.hypot(XG, YG) +inside = (RG > RI + 1e-3) & (RG < RO - 1e-3) +vx_i = LinearNDInterpolator(XV, V[:, 0])(XG, YG) +vy_i = LinearNDInterpolator(XV, V[:, 1])(XG, YG) +vx_i[~inside] = np.nan +vy_i[~inside] = np.nan + +o = np.argsort(thV[rV > RO - 1e-6]) +th_s = thV[rV > RO - 1e-6][o] +vt_s = vt[rV > RO - 1e-6][o] + +fig = plt.figure(figsize=(19, 7.2)) +gs = fig.add_gridspec(2, 2, width_ratios=[1.7, 1], height_ratios=[1, 2.2], hspace=0.08, wspace=0.12) +ax0 = fig.add_subplot(gs[0, 0]) +ax1 = fig.add_subplot(gs[1, 0], sharex=ax0) +ax2 = fig.add_subplot(gs[:, 1]) +ax0.plot(np.degrees(th_s), vt_s, lw=1.2) +ax0.axhline(0, color="k", lw=0.5) +if trace is not None: + tt = np.degrees(np.arctan2(trace[:, 1], trace[:, 0])) + ax0.axvline(tt[0], color="#c0392b", lw=0.8, ls="--") +ax0.set_ylabel("surface tangential velocity") +ax0.grid(alpha=0.3) +plt.setp(ax0.get_xticklabels(), visible=False) +t = state.get("t", float("nan")) +title = f"{os.path.basename(os.path.normpath(run))}: step {idx}, t = {t:.4f}" +for k, nm in (("mobility", "mobility"), ("lid_depth", "lid"), ("yield_frac_lid", "yielded in lid")): + if rec.get(k) is not None and not (isinstance(rec[k], float) and np.isnan(rec[k])): + title += f"; {nm} {rec[k]:.2f}" +ax0.set_title(title, fontsize=10, loc="left") +pc = ax1.pcolormesh(np.degrees(TH), D, Tg, cmap="RdBu_r", vmin=0, vmax=1, shading="auto") +ax1.streamplot(np.degrees(thg), dg, U, W, density=(2.2, 0.9), color="k", linewidth=0.6, arrowsize=0.8) +if trace is not None: + ax1.plot(np.degrees(np.arctan2(trace[:, 1], trace[:, 0])), RO - np.hypot(trace[:, 0], trace[:, 1]), + color="#c0392b", lw=2) +ax1.set_ylim(RO - RI, 0) +ax1.set_xlim(-180, 180) +ax1.set_xlabel("theta (deg)") +ax1.set_ylabel("depth") +fig.colorbar(pc, ax=ax1, orientation="horizontal", pad=0.14, shrink=0.5, label="T") +v0, v1, v2, m01, m12, m20 = (C[:, k] for k in range(6)) +tris = np.vstack([np.column_stack([v0, m01, m20]), np.column_stack([m01, v1, m12]), + np.column_stack([m20, m12, v2]), np.column_stack([m01, m12, m20])]) +ax2.tripcolor(mtri.Triangulation(X[:, 0], X[:, 1], tris), T, shading="gouraud", cmap="RdBu_r", vmin=0, vmax=1) +ax2.streamplot(xg, xg, vx_i, vy_i, density=1.6, color="k", linewidth=0.5, arrowsize=0.7) +if trace is not None: + ax2.plot(trace[:, 0], trace[:, 1], color="#c0392b", lw=2) +ax2.set_aspect("equal"); ax2.set_xticks([]); ax2.set_yticks([]) +ax2.set_xlim(-1.02, 1.02); ax2.set_ylim(-1.02, 1.02) +ax2.set_title("streamlines on the annulus", fontsize=10, loc="left") +fig.savefig(out, dpi=120, bbox_inches="tight") +print("wrote", out) From ab551044c44a7a2d6c75acd3b05da1b56a6ca06b Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Wed, 14 Oct 2026 09:21:58 +1100 Subject: [PATCH 47/57] zg95: Eulerian SUPG transport by default, semi-Lagrangian as the option Underworld development team with AI support from Claude Code --- scripts/sessions/fault_mg/zg95_annulus.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/scripts/sessions/fault_mg/zg95_annulus.py b/scripts/sessions/fault_mg/zg95_annulus.py index dd852ba67..155903370 100644 --- a/scripts/sessions/fault_mg/zg95_annulus.py +++ b/scripts/sessions/fault_mg/zg95_annulus.py @@ -74,6 +74,8 @@ def gather_arrays(*arrs): ap.add_argument("--yield_depth", type=float, default=0.0, help="yield stress gradient with depth") ap.add_argument("--eta_min", type=float, default=1.0, help="viscosity floor under yielding") ap.add_argument("--T_int", type=float, default=0.6, help="initial interior temperature") +ap.add_argument("--advect", choices=["supg", "slcn"], default="supg", + help="temperature transport: Eulerian SUPG (its own step estimate) or semi-Lagrangian (dt_mult x Courant)") args = ap.parse_args() os.makedirs(args.out, exist_ok=True) if os.environ.get("ZG95_NOINODE"): @@ -110,7 +112,10 @@ def log(*a): log(f"restarted T from checkpoint {args.restart}") log(f"base: {base.dm.getHeightStratum(0)[1]} cells, {len(tail)} levels; finest cell {args.H / 2**args.ref}") -adv = uw.systems.AdvDiffusionSLCN(base, u_Field=T_b, V_fn=V_b.sym, order=1, monotone_mode="clamp") +if args.advect == "supg": + adv = uw.systems.AdvDiffusion(base, T_b, V_b.sym, order=1) +else: + adv = uw.systems.AdvDiffusionSLCN(base, u_Field=T_b, V_fn=V_b.sym, order=1, monotone_mode="clamp") adv.constitutive_model = uw.constitutive_models.DiffusionModel adv.constitutive_model.Parameters.diffusivity = 1.0 adv.f = args.heating @@ -156,6 +161,13 @@ def set_rheology(solver, mesh, Tsym, zone_sym=None): return P # the caller sets the director +def next_dt(): + """SUPG: the solver's own estimate, capped; semi-Lagrangian: dt_mult times the + Courant estimate, capped (it is not Courant-limited).""" + est = float(adv.estimate_dt()) + return min(est if args.advect == "supg" else args.dt_mult * est, args.dt_max) + + def clamp_radii(x): r = np.hypot(x[:, 0], x[:, 1]) f = np.clip(r, RI + 1e-10, RO - 1e-10) / np.maximum(r, 1e-14) @@ -421,7 +433,7 @@ def checkpoint(step, t): s_b.solve() info = s_b._rotated_freeslip_info V_b.array[...] = v_b.array - dt = min(args.dt_mult * float(adv.estimate_dt()), args.dt_max) + dt = next_dt() adv.solve(timestep=dt) t += dt vmax = gmax(np.linalg.norm(V_b.array[:, 0, :], axis=1).max() if V_b.array.shape[0] else 0.0) @@ -466,7 +478,7 @@ def checkpoint(step, t): # velocity back onto the base for the advection step Vb = np.asarray(uw.function.global_evaluate(addon.v.sym, clamp_radii(np.asarray(V_b.coords)))).reshape(-1, 2) V_b.array[:, 0, :] = Vb - dt = min(args.dt_mult * float(adv.estimate_dt()), args.dt_max) + dt = next_dt() adv.solve(timestep=dt) t += dt if args.migrate: From 44ad106e6130ba59a9eb233520c00f494e3ec3c9 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Wed, 14 Oct 2026 09:35:34 +1100 Subject: [PATCH 48/57] zg95: the step multiplier applies to SUPG too (implicit, not mesh-limited) Underworld development team with AI support from Claude Code --- scripts/sessions/fault_mg/zg95_annulus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sessions/fault_mg/zg95_annulus.py b/scripts/sessions/fault_mg/zg95_annulus.py index 155903370..b871ddcf7 100644 --- a/scripts/sessions/fault_mg/zg95_annulus.py +++ b/scripts/sessions/fault_mg/zg95_annulus.py @@ -165,7 +165,7 @@ def next_dt(): """SUPG: the solver's own estimate, capped; semi-Lagrangian: dt_mult times the Courant estimate, capped (it is not Courant-limited).""" est = float(adv.estimate_dt()) - return min(est if args.advect == "supg" else args.dt_mult * est, args.dt_max) + return min(args.dt_mult * est, args.dt_max) # implicit SUPG tolerates large steps too def clamp_radii(x): From bc46853eb33db217ed4457597b8ffdb931a2c894 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Wed, 14 Oct 2026 10:09:20 +1100 Subject: [PATCH 49/57] zg95: viscosity reference temperature option Underworld development team with AI support from Claude Code --- scripts/sessions/fault_mg/zg95_annulus.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/sessions/fault_mg/zg95_annulus.py b/scripts/sessions/fault_mg/zg95_annulus.py index b871ddcf7..f1065a196 100644 --- a/scripts/sessions/fault_mg/zg95_annulus.py +++ b/scripts/sessions/fault_mg/zg95_annulus.py @@ -74,6 +74,7 @@ def gather_arrays(*arrs): ap.add_argument("--yield_depth", type=float, default=0.0, help="yield stress gradient with depth") ap.add_argument("--eta_min", type=float, default=1.0, help="viscosity floor under yielding") ap.add_argument("--T_int", type=float, default=0.6, help="initial interior temperature") +ap.add_argument("--eta_ref", type=float, default=1.0, help="temperature at which the viscosity is 1 (1: the base; T_int: the interior)") ap.add_argument("--advect", choices=["supg", "slcn"], default="supg", help="temperature transport: Eulerian SUPG (its own step estimate) or semi-Lagrangian (dt_mult x Courant)") args = ap.parse_args() @@ -125,7 +126,7 @@ def log(*a): def eta_of(Tsym): - return sympy.exp(sympy.log(args.deta) * (1 - Tsym)) # 1 at T = 1, deta at T = 0 + return sympy.exp(sympy.log(args.deta) * (args.eta_ref - Tsym)) # 1 at T = eta_ref, deta at eta_ref - 1 def yield_stress_on(mesh): From 8c2d3cae3d52a0b7cc2070a3381fbfe621cf5367 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Wed, 14 Oct 2026 10:35:23 +1100 Subject: [PATCH 50/57] zg95: Picard warm-up for the yielding solves Underworld development team with AI support from Claude Code --- scripts/sessions/fault_mg/zg95_annulus.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/sessions/fault_mg/zg95_annulus.py b/scripts/sessions/fault_mg/zg95_annulus.py index f1065a196..a780680e0 100644 --- a/scripts/sessions/fault_mg/zg95_annulus.py +++ b/scripts/sessions/fault_mg/zg95_annulus.py @@ -74,6 +74,7 @@ def gather_arrays(*arrs): ap.add_argument("--yield_depth", type=float, default=0.0, help="yield stress gradient with depth") ap.add_argument("--eta_min", type=float, default=1.0, help="viscosity floor under yielding") ap.add_argument("--T_int", type=float, default=0.6, help="initial interior temperature") +ap.add_argument("--picard", type=int, default=3, help="Picard warm-up iterations before Newton in the rotated nonlinear loop") ap.add_argument("--eta_ref", type=float, default=1.0, help="temperature at which the viscosity is 1 (1: the base; T_int: the interior)") ap.add_argument("--advect", choices=["supg", "slcn"], default="supg", help="temperature transport: Eulerian SUPG (its own step estimate) or semi-Lagrangian (dt_mult x Courant)") @@ -244,7 +245,7 @@ def solve(self): tmin, tmax = gmin(np.nanmin(self.T.array) if self.T.array.shape[0] else 1.0), gmax(np.nanmax(self.T.array) if self.T.array.shape[0] else 0.0) log(f" T on the add-on: nan {n_nan}, range {tmin:.3f} {tmax:.3f}") t0 = time.perf_counter() - self.stokes.solve() + self.stokes.solve(picard=args.picard if args.yield0 > 0 else 0) info = self.stokes._rotated_freeslip_info self.t_solve = time.perf_counter() - t0 if os.environ.get("ZG95_INSPECT"): @@ -431,7 +432,7 @@ def checkpoint(step, t): s_b, v_b = base_stokes("spin") for k in range(args.spinup): t0 = time.perf_counter() - s_b.solve() + s_b.solve(picard=args.picard if args.yield0 > 0 else 0) info = s_b._rotated_freeslip_info V_b.array[...] = v_b.array dt = next_dt() From 72427349b8bd4f7066929aa343da32240f8bce50 Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Wed, 14 Oct 2026 11:01:26 +1100 Subject: [PATCH 51/57] zg95: solve tolerance and yield softness options Underworld development team with AI support from Claude Code --- scripts/sessions/fault_mg/zg95_annulus.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/scripts/sessions/fault_mg/zg95_annulus.py b/scripts/sessions/fault_mg/zg95_annulus.py index a780680e0..2d7ca84bb 100644 --- a/scripts/sessions/fault_mg/zg95_annulus.py +++ b/scripts/sessions/fault_mg/zg95_annulus.py @@ -74,6 +74,8 @@ def gather_arrays(*arrs): ap.add_argument("--yield_depth", type=float, default=0.0, help="yield stress gradient with depth") ap.add_argument("--eta_min", type=float, default=1.0, help="viscosity floor under yielding") ap.add_argument("--T_int", type=float, default=0.6, help="initial interior temperature") +ap.add_argument("--tol", type=float, default=1e-5, help="Stokes solve tolerance (loosen for a spin-up)") +ap.add_argument("--yield_soft", type=float, default=0.1, help="yield-corner softness delta of the smooth yield law") ap.add_argument("--picard", type=int, default=3, help="Picard warm-up iterations before Newton in the rotated nonlinear loop") ap.add_argument("--eta_ref", type=float, default=1.0, help="temperature at which the viscosity is 1 (1: the base; T_int: the interior)") ap.add_argument("--advect", choices=["supg", "slcn"], default="supg", @@ -147,6 +149,14 @@ def set_rheology(solver, mesh, Tsym, zone_sym=None): weak = 1 if zone_sym is None else (1 - (1 - args.eta_w) * zone_sym) if args.yield0 > 0: solver.constitutive_model = uw.constitutive_models.ViscoPlasticFlowModel + cm = solver.constitutive_model + for attr in ("yield_softness", "_yield_softness"): + if hasattr(cm, attr): + try: + setattr(cm, attr, float(args.yield_soft)) + break + except Exception: + continue P = solver.constitutive_model.Parameters P.shear_viscosity_0 = eta0 * weak P.shear_viscosity_min = args.eta_min @@ -231,7 +241,7 @@ def __init__(self, theta_t, tag): if args.arm == "split": s.add_fault_bc(0, boundary="Trench") s.petsc_use_pressure_nullspace = True - s.tolerance = 1e-5 + s.tolerance = args.tol set_custom_fmg(s, tail, field_id=0, fac_zone=self.band if args.fac else None) self.stokes = s self.t_build = time.perf_counter() - t0 @@ -299,7 +309,7 @@ def base_stokes(tag): s.add_rotated_freeslip_bc(0, "Upper", normal=unit_r_b) s.add_rotated_freeslip_bc(0, "Lower", normal=unit_r_b) s.petsc_use_pressure_nullspace = True - s.tolerance = 1e-5 + s.tolerance = args.tol set_custom_fmg(s, tail[:-1], field_id=0) return s, v From 488004dfa13fd784b1f38e8d9942043b43a0981e Mon Sep 17 00:00:00 2001 From: Louis Moresi Date: Thu, 15 Oct 2026 07:13:32 +1100 Subject: [PATCH 52/57] zg95: parallel-safe diagnostics, warm start across rebuilds, moving trench, hand-over note Driver: the yield indicator is evaluated on the solve mesh into a P1 variable and transferred to the base collectively (a rank-local "is this the base" test hung the 8-rank fault phase); failures in the diagnostic are decided on every rank; the figure's temperature panel is a collective polar-grid evaluation instead of a serial-only branch; the add-on build line reports global counts; each rebuilt add-on warm-starts from the previous velocity and pressure; the trench follows the median overriding-plate velocity over a window beyond the fault; --verbose, --newton_switch, --trench_off/--trench_span options; the surface and trace are saved at checkpoints too. Renderer: the trace falls back to the nearest earlier surface file; the unrolled section has a vertical exaggeration of 2. CAMPAIGN.md records the runs, results, solver state and open items for the session taking the work over. Underworld development team with AI support from Claude Code --- scripts/sessions/fault_mg/CAMPAIGN.md | 63 +++++++++++ scripts/sessions/fault_mg/zg95_annulus.py | 132 ++++++++++++++-------- scripts/sessions/fault_mg/zg95_stream.py | 16 ++- 3 files changed, 163 insertions(+), 48 deletions(-) create mode 100644 scripts/sessions/fault_mg/CAMPAIGN.md diff --git a/scripts/sessions/fault_mg/CAMPAIGN.md b/scripts/sessions/fault_mg/CAMPAIGN.md new file mode 100644 index 000000000..5ee3e994c --- /dev/null +++ b/scripts/sessions/fault_mg/CAMPAIGN.md @@ -0,0 +1,63 @@ +# ZG95 fault convection campaign: hand-over (2026-10-15) + +Driver `zg95_annulus.py` (annulus, Rayleigh-scaled yielding lid, SUPG transport, +rotated free-slip, split-node fault on the add-on hierarchy), renderer +`zg95_stream.py` (surface velocity over streamlines; red fault trace; vertical +exaggeration 2 of the unrolled section). Outputs live on hyperion under +`~/+Simulations/zg95//` (checkpoints every 25 steps: `zg95.mesh.*.h5`, +`state_NNNN.json`, `records.json`, `surface_NNNN.npz` with the trench trace, +`stream_NNNN.png`, `step_NNNN.png`). Frames are on the wiki page `Fault-multigrid`. + +## Physics (Moresi & Solomatov 1998 reference case) +Ra0 = 1e7 on the basal viscosity (`--eta_ref 1`), contrast 3e4, yield 1e5 + 1e7 z +(tau1/Ra0 = 1, mobile per their Fig 6), internal heating 7, interior from 0.8, +base H 0.16 with 2 halvings (finest 0.04), P2 velocity. + + --H 0.16 --ref 2 --ra 1e7 --eta_ref 1.0 --advect supg --heating 7 --deta 3e4 + --yield0 1e5 --yield_depth 1e7 --T_int 0.8 --dt_mult 3 --dt_max 1e-3 + --picard 2 --yield_soft 0.2 --fig_every 50 --ckpt_every 50 + +## Runs +- `conv_ms98` (control, 1500 spin-up steps, DONE): steady six-cell mobile lid from + step ~100: mobility 0.5-0.7, lid 0.105, a third of the lid yielding, T_mid 0.70, + plateau surface speeds ~80, 27-35 s/step at 8 ranks (tol 1e-3, Newton 6-8). +- `conv_ms98_step` (restart from ckpt 100 with `--deta 1e5 --yield0 2e5`, DONE): + sluggish distributed-deformation regime, lid 0.15, mobility 0.2-0.4, no overturn. +- `fault_ms98_split` (fault phase from ckpt 150, 300 steps, trench velocity sampled + 1.7 deg into the hanging wall -> trench effectively frozen, DONE): localised slip + (85-120), stationary sliver, one-sided dipping slab (t 0.12-0.165), slab break-off + (0.177), overturn (0.183, overriding lid at 250-300), ridge jump to the trench + (0.19+). Lid yielding mean 0.40 vs control 0.33. Montage: `sequence.png`. +- `fault_ms98_move` (same, trench follows the overriding plate: median surface + velocity over 0.1-0.3 rad beyond the fault, `--trench_off/--trench_span`; + RUNNING on hyperion, 8 ranks, ~2 min/step): the slab follows the advancing + fault at ~12 deg lag, still symmetric at t 0.093. + +Fault phase launch (from a control checkpoint N copied into the run directory): + + cp conv_ms98/*000NN* conv_ms98/zg95.mesh.00000.h5 conv_ms98/state_0NN.json conv_ms98/records.json / + mpirun -np 8 python zg95_annulus.py --out --restart N --spinup 0 --steps 300 --arm split \ + --tol 1e-2 --newton_switch 1e-2 --fig_every 10 --ckpt_every 25 --verbose + +## Solver state (the item to fix before production) +On the faulted add-on the pressure (Schur) solve hits `fieldsplit_pres_ksp_max_it` += 200 once the nonlinear residual falls below ~1e-2 of its start, under the Newton +tangent AND under Picard (42 Picard iterations at tol 1e-3, cap from iteration 14). +The base mesh never shows it. Each pressure iteration nests a full velocity solve, +so a capped solve costs ~3400 MG cycles (~10 min). Hypotheses: the nested Schur +stalls on the inner-solve noise floor once the outer residual is small; or the +yielded viscosity on the faulted add-on breaks the 1/eta Schur preconditioner. +Probe: one step at np 2 from ckpt 150 with the pressure KSP monitor and converged +reason per outer iteration; compare base vs add-on; check the inner velocity rtol +against the pressure rtol (0.033 x tol). The fault runs above use tol 1e-2, +Picard-only (`--newton_switch 1e-2`) to stay below the cap. Warm start across +add-on rebuilds (`AddOn.warm_start`) halves the step cost. + +## Other open items +- Dynamic topography sign: peaks over the downwellings, lows over upwellings in the + step figures; check the reaction sign convention before quantitative use. +- Parallel-safety lessons applied in the driver (keep them): mesh-variable writes + are collective, decide on every rank; cross-mesh evaluation of an expression with + velocity gradients fails, evaluate on the owning mesh into a P1 variable and + transfer that; build-line counts are global reductions. +- Wiki page `Fault-multigrid` carries the frames; keep it current. diff --git a/scripts/sessions/fault_mg/zg95_annulus.py b/scripts/sessions/fault_mg/zg95_annulus.py index 2d7ca84bb..466e8b502 100644 --- a/scripts/sessions/fault_mg/zg95_annulus.py +++ b/scripts/sessions/fault_mg/zg95_annulus.py @@ -68,6 +68,8 @@ def gather_arrays(*arrs): ap.add_argument("--ckpt_every", type=int, default=25) ap.add_argument("--fig_every", type=int, default=5) ap.add_argument("--theta0", type=float, default=0.0, help="initial trench position (rad)") +ap.add_argument("--trench_off", type=float, default=0.1, help="trench-velocity window: offset beyond the fault (rad)") +ap.add_argument("--trench_span", type=float, default=0.2, help="trench-velocity window: width (rad)") ap.add_argument("--migrate", type=int, default=1, help="advect the trench with the overriding plate") ap.add_argument("--heating", type=float, default=0.0, help="internal heating rate (source term)") ap.add_argument("--yield0", type=float, default=0.0, help="yield stress at the surface (0: no yielding)") @@ -76,6 +78,8 @@ def gather_arrays(*arrs): ap.add_argument("--T_int", type=float, default=0.6, help="initial interior temperature") ap.add_argument("--tol", type=float, default=1e-5, help="Stokes solve tolerance (loosen for a spin-up)") ap.add_argument("--yield_soft", type=float, default=0.1, help="yield-corner softness delta of the smooth yield law") +ap.add_argument("--verbose", action="store_true", help="print every nonlinear iteration of the rotated solve") +ap.add_argument("--newton_switch", type=float, default=1e-2, help="relative residual at which the rotated loop goes from Picard to Newton") ap.add_argument("--picard", type=int, default=3, help="Picard warm-up iterations before Newton in the rotated nonlinear loop") ap.add_argument("--eta_ref", type=float, default=1.0, help="temperature at which the viscosity is 1 (1: the base; T_int: the interior)") ap.add_argument("--advect", choices=["supg", "slcn"], default="supg", @@ -213,6 +217,7 @@ def __init__(self, theta_t, tag): self.v = uw.discretisation.MeshVariable(f"U_{tag}", mesh, 2, degree=2) self.p = uw.discretisation.MeshVariable(f"P_{tag}", mesh, 1, degree=1, continuous=True) self.h = uw.discretisation.MeshVariable(f"h_{tag}", mesh, 1, degree=1) + self.yv = uw.discretisation.MeshVariable(f"Y_{tag}", mesh, 1, degree=1) # yield indicator self.zone = uw.discretisation.MeshVariable(f"zone_{tag}", mesh, 1, degree=0, continuous=False) dm = mesh.dm vS, vE = dm.getDepthStratum(0) @@ -242,11 +247,25 @@ def __init__(self, theta_t, tag): s.add_fault_bc(0, boundary="Trench") s.petsc_use_pressure_nullspace = True s.tolerance = args.tol + s.newton_switch_rtol = args.newton_switch set_custom_fmg(s, tail, field_id=0, fac_zone=self.band if args.fac else None) self.stokes = s self.t_build = time.perf_counter() - t0 - log(f" add-on {tag}: {mesh.dm.getHeightStratum(0)[1]} cells, band {int(self.band.sum())}, " - f"bridge {int(self.bridge.sum())}, trench at {np.degrees(theta_t):.3f} deg, built in {self.t_build:.1f} s") + n_cells = comm.allreduce(int(mesh.dm.getHeightStratum(0)[1]), op=MPI.SUM) + n_band = comm.allreduce(int(self.band.sum()), op=MPI.SUM) + n_bridge = comm.allreduce(int(self.bridge.sum()), op=MPI.SUM) + log(f" add-on {tag}: {n_cells} cells, band {n_band}, bridge {n_bridge} (global), " + f"trench at {np.degrees(theta_t):.3f} deg, built in {self.t_build:.1f} s") + + def warm_start(self, prev): + """Initial guess from the previous add-on's velocity and pressure (collective: + every rank evaluates and writes). Each step rebuilds the add-on, so without + this every solve is a cold start: 20 Picard iterations before Newton.""" + self.v.array[:, 0, :] = np.asarray(uw.function.global_evaluate( + prev.v.sym, clamp_radii(np.asarray(self.v.coords)))).reshape(-1, 2) + self.p.array[:, 0, 0] = np.asarray(uw.function.global_evaluate( + prev.p.sym, clamp_radii(np.asarray(self.p.coords)))).ravel() + self.warm = True def solve(self): self.T.array[:, 0, 0] = np.asarray(uw.function.global_evaluate( @@ -255,7 +274,9 @@ def solve(self): tmin, tmax = gmin(np.nanmin(self.T.array) if self.T.array.shape[0] else 1.0), gmax(np.nanmax(self.T.array) if self.T.array.shape[0] else 0.0) log(f" T on the add-on: nan {n_nan}, range {tmin:.3f} {tmax:.3f}") t0 = time.perf_counter() - self.stokes.solve(picard=args.picard if args.yield0 > 0 else 0) + self.stokes.solve(picard=args.picard if args.yield0 > 0 else 0, verbose=args.verbose, + zero_init_guess=not getattr(self, "warm", False)) + self.warm = True # the next solve on this add-on warms from its own solution info = self.stokes._rotated_freeslip_info self.t_solve = time.perf_counter() - t0 if os.environ.get("ZG95_INSPECT"): @@ -310,13 +331,22 @@ def base_stokes(tag): s.add_rotated_freeslip_bc(0, "Lower", normal=unit_r_b) s.petsc_use_pressure_nullspace = True s.tolerance = args.tol + s.newton_switch_rtol = args.newton_switch set_custom_fmg(s, tail[:-1], field_id=0) return s, v # ----------------------------------------------------------------------------- figures +_fig_th = np.linspace(-np.pi, np.pi, 361) +_fig_r = np.linspace(RI, RO, 61) +_fig_xy = np.column_stack([(np.cos(_fig_th)[:, None] * _fig_r[None, :]).ravel(), + (np.sin(_fig_th)[:, None] * _fig_r[None, :]).ravel()]) + + def figure(step, t, addon, rec): surf = addon.surface() if addon is not None else None # collective gather + # T on a polar grid: collective on every rank, drawn on rank 0 (no serial-only panel) + Tg = np.asarray(uw.function.global_evaluate(T_b.sym[0], clamp_radii(_fig_xy))).ravel() if not rank0: return import matplotlib @@ -326,21 +356,16 @@ def figure(step, t, addon, rec): from matplotlib.collections import LineCollection fig, axes = plt.subplots(1, 3, figsize=(18, 5.6), gridspec_kw={"width_ratios": [1.2, 1, 1]}) ax = axes[0] - if uw.mpi.size == 1: - dm = base.dm - vS, vE = dm.getDepthStratum(0) - cS, cE = dm.getHeightStratum(0) - X = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, 2) - cells = np.array([[int(q) - vS for q in dm.getTransitiveClosure(c)[0] if vS <= q < vE] for c in range(cS, cE)]) - Tv = uw.function.evaluate(T_b.sym[0], clamp_radii(X)).ravel() - tc = ax.tripcolor(mtri.Triangulation(X[:, 0], X[:, 1], cells), Tv, shading="gouraud", cmap="RdBu_r", - vmin=0, vmax=1) - fig.colorbar(tc, ax=ax, shrink=0.8) + Xg = _fig_xy[:, 0].reshape(len(_fig_th), len(_fig_r)) + Yg = _fig_xy[:, 1].reshape(len(_fig_th), len(_fig_r)) + tc = ax.pcolormesh(Xg, Yg, Tg.reshape(len(_fig_th), len(_fig_r)), shading="gouraud", cmap="RdBu_r", + vmin=0, vmax=1) + fig.colorbar(tc, ax=ax, shrink=0.8) if addon is not None: tr = addon.trace ax.plot(tr[:, 0], tr[:, 1], color="k", lw=1.5) ax.set_aspect("equal"); ax.set_xticks([]); ax.set_yticks([]) - ax.set_title(f"step {step}, t = {t:.5f}: T on the base (serial render); trench (black)", fontsize=9, loc="left") + ax.set_title(f"step {step}, t = {t:.5f}: T on the base; trench (black)", fontsize=9, loc="left") if addon is not None: th, vt, thh, hh = surf ax = axes[1] @@ -374,29 +399,35 @@ def figure(step, t, addon, rec): _tb_depth = RO - np.hypot(_tb_coords[:, 0], _tb_coords[:, 1]) -def yielded_fraction(solver, Tsym, coords, depth): - """Fraction of nodes (all, and in the lid: depth < 0.15) where the effective - viscosity is below 0.9 of the temperature-dependent one, i.e. the plastic - branch is active. Collective; NaN when the model has no yield stress.""" +def yielded_fraction(solver, Tsym, yvar): + """Fraction of P1 nodes (all, and in the lid: depth < 0.15) where the effective + viscosity is below 0.9 of the temperature-dependent one, i.e. the plastic branch + is active. ``yvar`` is a P1 variable on the solver's mesh that receives the + ratio; the base's ``Y_b`` is then filled from it by a collective cross-mesh + evaluation of that plain variable (the ratio expression itself holds velocity + gradients, which the cross-mesh evaluator cannot take). Collective on every + rank; NaN when the model has no yield stress.""" if args.yield0 <= 0 or solver is None: return float("nan"), float("nan") + coords = np.asarray(yvar.coords) + depth = RO - np.hypot(coords[:, 0], coords[:, 1]) + ratio, err = None, "" try: cm = solver.constitutive_model ratio = np.asarray(uw.function.evaluate(cm._plastic_eff_viscosity.sym / eta_of(Tsym), clamp_radii(coords))).ravel() except Exception as e: - log(" yielded fraction unavailable:", str(e)[:120]) + err = str(e)[:120] + # a failure on any rank is a failure on all: every rank must take the same path + if comm.allreduce(int(ratio is None), op=MPI.SUM): + log(" yielded fraction unavailable:", err) return float("nan"), float("nan") + yvar.array[:, 0, 0] = ratio # collective write + if yvar is not Y_b: + Y_b.array[:, 0, 0] = np.asarray(uw.function.global_evaluate( + yvar.sym[0], clamp_radii(np.asarray(Y_b.coords)))).ravel() yielded = ratio < 0.9 lid = depth < 0.15 - try: - if coords.shape[0] == _tb_coords.shape[0] and np.allclose(coords[:8], _tb_coords[:8]): - # the base: keep the indicator at the P1 nodes for the checkpoint - from scipy.spatial import cKDTree - _d, k = cKDTree(coords).query(np.asarray(Y_b.coords)) - Y_b.array[:, 0, 0] = ratio[k] - except Exception: - pass n_all = comm.allreduce(int(yielded.size), op=MPI.SUM) n_lid = comm.allreduce(int(lid.sum()), op=MPI.SUM) y_all = comm.allreduce(int(yielded.sum()), op=MPI.SUM) @@ -438,7 +469,17 @@ def checkpoint(step, t): json.dump(records, open(os.path.join(args.out, "records.json"), "w"), indent=1) -if args.spinup > 0 and args.restart < 0: +if args.restart >= 0: + # a restart continues the record and the clock; with --spinup it carries the + # base-only run on (the parameters may change: the paper's step in viscosity + # contrast and yield stress at equilibrium), with --steps the fault phase starts + st = json.load(open(os.path.join(args.out, f"state_{args.restart:04d}.json"))) + t, step = float(st["t"]), int(st["step"]) + 1 + args.theta0 = float(st.get("theta_t", args.theta0)) + rp = os.path.join(args.out, "records.json") + if os.path.exists(rp): + records = [r for r in json.load(open(rp)) if r.get("step", 0) <= args.restart] +if args.spinup > 0: s_b, v_b = base_stokes("spin") for k in range(args.spinup): t0 = time.perf_counter() @@ -450,7 +491,7 @@ def checkpoint(step, t): t += dt vmax = gmax(np.linalg.norm(V_b.array[:, 0, :], axis=1).max() if V_b.array.shape[0] else 0.0) diag, prof = flow_diagnostics() - y_all, y_lid = yielded_fraction(s_b, T_b.sym[0], _tb_coords, _tb_depth) + y_all, y_lid = yielded_fraction(s_b, T_b.sym[0], Y_b) rec = {"step": step, "phase": "spinup", "t": t, "dt": dt, "vel_its": info.get("vel_its_last"), "newton": info.get("nonlinear_iterations"), "vmax": vmax, "t_step": round(time.perf_counter() - t0, 1), "yield_frac": y_all, "yield_frac_lid": y_lid, @@ -466,27 +507,26 @@ def checkpoint(step, t): step += 1 del s_b, v_b gc.collect() -if args.restart >= 0: - st = json.load(open(os.path.join(args.out, f"state_{args.restart:04d}.json"))) - t, step = float(st["t"]), int(st["step"]) + 1 - args.theta0 = float(st.get("theta_t", args.theta0)) - theta_t = args.theta0 addon = None n_built = 0 for k in range(args.steps): t0 = time.perf_counter() if addon is None or abs(theta_t - addon.theta_t) * RO > S: - if addon is not None: - del addon - gc.collect() - addon = AddOn(theta_t, f"a{n_built}") + prev, addon = addon, AddOn(theta_t, f"a{n_built}") n_built += 1 + if prev is not None: + addon.warm_start(prev) + del prev + gc.collect() rec = addon.solve() th, vt, thh, hh = addon.surface() - # the trench moves with the overriding plate: the surface just on the +theta side - j = int(np.argmin(np.abs(th - (addon.theta_t + 0.03)))) - v_trench = float(vt[j]) + # the trench moves with the overriding plate: the median surface velocity over a + # window beyond the fault's hanging wall (the sliver next to the fault goes + # stationary once the fault takes the slip; sampling it froze the trench) + dth = (th - addon.theta_t + np.pi) % (2 * np.pi) - np.pi + win = (dth > args.trench_off) & (dth < args.trench_off + args.trench_span) + v_trench = float(np.median(vt[win])) if win.any() else float(vt[int(np.argmin(np.abs(dth - args.trench_off)))]) # velocity back onto the base for the advection step Vb = np.asarray(uw.function.global_evaluate(addon.v.sym, clamp_radii(np.asarray(V_b.coords)))).reshape(-1, 2) V_b.array[:, 0, :] = Vb @@ -498,8 +538,7 @@ def checkpoint(step, t): vmax = gmax(np.linalg.norm(V_b.array[:, 0, :], axis=1).max() if V_b.array.shape[0] else 0.0) diag, prof = flow_diagnostics() rec.update(diag) - xa = np.asarray(addon.T.coords) - y_all, y_lid = yielded_fraction(addon.stokes, addon.T.sym[0], xa, RO - np.hypot(xa[:, 0], xa[:, 1])) + y_all, y_lid = yielded_fraction(addon.stokes, addon.T.sym[0], addon.yv) rec.update({"newton": addon.stokes._rotated_freeslip_info.get("nonlinear_iterations"), "yield_frac": y_all, "yield_frac_lid": y_lid}) rec.update({"step": step, "phase": "fault", "t": t, "dt": dt, "vmax": vmax, "v_trench": v_trench, @@ -513,8 +552,11 @@ def checkpoint(step, t): figure(step, t, addon, rec) if rank0: json.dump(records, open(os.path.join(args.out, "records.json"), "w"), indent=1) - np.savez(os.path.join(args.out, f"surface_{step:04d}.npz"), theta=th, vt=vt, theta_h=thh, h=hh, - trace=addon.trace) + if rank0 and (step % args.fig_every == 0 or step % args.ckpt_every == 0 or k == args.steps - 1): + # the surface and the trench trace ride every figure AND every checkpoint, so the + # checkpoint renders can draw the fault + np.savez(os.path.join(args.out, f"surface_{step:04d}.npz"), theta=th, vt=vt, theta_h=thh, h=hh, + trace=addon.trace) if step % args.ckpt_every == 0 or k == args.steps - 1: checkpoint(step, t) step += 1 diff --git a/scripts/sessions/fault_mg/zg95_stream.py b/scripts/sessions/fault_mg/zg95_stream.py index f17010143..d41649259 100644 --- a/scripts/sessions/fault_mg/zg95_stream.py +++ b/scripts/sessions/fault_mg/zg95_stream.py @@ -38,6 +38,13 @@ rec = rr[0] if rr else {} trace = None surf = os.path.join(run, f"surface_{idx:04d}.npz") +if not os.path.exists(surf): + # a checkpoint without its own surface file: the nearest earlier one (the trench + # moves about half a degree per step, so a few steps old is still the fault's place) + import glob + cands = sorted(glob.glob(os.path.join(run, "surface_*.npz"))) + cands = [c for c in cands if int(os.path.basename(c)[8:12]) <= idx] + surf = cands[-1] if cands else surf if os.path.exists(surf): trace = np.load(surf)["trace"] @@ -81,8 +88,10 @@ def padded(th_, vals, *more): th_s = thV[rV > RO - 1e-6][o] vt_s = vt[rV > RO - 1e-6][o] -fig = plt.figure(figsize=(19, 7.2)) -gs = fig.add_gridspec(2, 2, width_ratios=[1.7, 1], height_ratios=[1, 2.2], hspace=0.08, wspace=0.12) +VEXAG = 2.0 # vertical exaggeration of the unrolled section (1 = true aspect at mid-depth) +_deg_per_len = 360.0 / (np.pi * (RO + RI)) # degrees of arc per length unit at mid-radius +fig = plt.figure(figsize=(19, 5.6)) +gs = fig.add_gridspec(2, 2, width_ratios=[1.7, 1], height_ratios=[1, 1.15], hspace=0.08, wspace=0.12) ax0 = fig.add_subplot(gs[0, 0]) ax1 = fig.add_subplot(gs[1, 0], sharex=ax0) ax2 = fig.add_subplot(gs[:, 1]) @@ -107,9 +116,10 @@ def padded(th_, vals, *more): color="#c0392b", lw=2) ax1.set_ylim(RO - RI, 0) ax1.set_xlim(-180, 180) +ax1.set_aspect(VEXAG * _deg_per_len, adjustable="box", anchor="N") ax1.set_xlabel("theta (deg)") ax1.set_ylabel("depth") -fig.colorbar(pc, ax=ax1, orientation="horizontal", pad=0.14, shrink=0.5, label="T") +fig.colorbar(pc, ax=ax1, orientation="horizontal", pad=0.42, shrink=0.4, aspect=40, label="T") v0, v1, v2, m01, m12, m20 = (C[:, k] for k in range(6)) tris = np.vstack([np.column_stack([v0, m01, m20]), np.column_stack([m01, v1, m12]), np.column_stack([m20, m12, v2]), np.column_stack([m01, m12, m20])]) From af2e14b778ffdc4e17f8ebe6eba03a1076120054 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Wed, 28 Oct 2026 18:16:40 +1100 Subject: [PATCH 53/57] Fault-split multigrid review: collective verdicts, sheet fixes, test reach Library: - split_tail_levels exchanges each level's verdict, so every rank stops the replay at the same level; a rank-local failure of the split hung the ranks that went on (new test in tests/parallel/test_1089: hangs before, passes after). - build_rotation's manifold pairing pre-pass raises collectively, like the pair-block assembly after it; an empty pair list no longer recomputes. - custom_mg.install keeps the FAC patch rows of every coarse level that restrict_unreached left whole; only renumbered levels lose theirs. - ThinSheet: L and c_b come from the solvers' unique-name factory (a user's "L" was reset to 1 by building a sheet, and two sheets shared a thickness); the plane-stress pressure is read from the committed stress after a viscoelastic solve, not from the shifted history, and p is filled on first access after a solve; a transversely isotropic model is refused on a manifold (its strain rate is not projected). - objective_order defaults to 1 for every solver again (Louis, 2026-10-28); ThinSheet selects "cayley" for a first-order Jaumann history when no order was given. Stokes VE results and order=2 + jaumann are as on development. - add_basal_drag / add_basal_traction take their value as conds (Charter section 6), and Stokes' versions non-dimensionalise the reference velocity and traction as the sheet's do (_nondimensional_row moved to solvers.py). - extrude_sheet is in meshing.__all__. Tests: test_1091 is reached (test_109* glob; check_test_coverage failed); the welded-beats-split iteration assertion in test_1087 is gone (Charter section 8), the slip agreement stays; test_1081 and test_1084 (13 and 7 min) are tier C, reported not gated; test_1083's thin-against-thick agreements are tier C; test_1086 runs both tails at np >= 2 and no longer asserts a property true by construction. Docs: thin-sheet.md's limits paragraph (parallel extrusion, split tails) and the co-location and ghost-cell descriptions corrected. Underworld development team with AI support from Claude Code --- .../subsystems/fault-split-multigrid.md | 7 +- docs/developer/subsystems/thin-sheet.md | 13 ++- scripts/test.sh | 6 +- src/underworld3/constitutive_models.py | 8 +- src/underworld3/meshing/__init__.py | 2 + src/underworld3/systems/solvers.py | 39 ++++++--- src/underworld3/systems/thin_sheet.py | 79 +++++++++++-------- src/underworld3/utilities/custom_mg.py | 14 +++- src/underworld3/utilities/fault_mg.py | 15 +++- src/underworld3/utilities/rotated_bc.py | 16 +++- .../test_1086_rotated_fault_transfer_rows.py | 25 +++--- ...est_1089_fault_split_multigrid_parallel.py | 25 ++++++ tests/test_1081_objective_step_sphere.py | 4 +- tests/test_1083_thin_thick_alignment.py | 24 +++--- tests/test_1084_quadratic_stress_sphere.py | 4 +- tests/test_1087_fault_split_multigrid.py | 7 +- 16 files changed, 199 insertions(+), 89 deletions(-) diff --git a/docs/developer/subsystems/fault-split-multigrid.md b/docs/developer/subsystems/fault-split-multigrid.md index cb8041b1d..759ee5fd2 100644 --- a/docs/developer/subsystems/fault-split-multigrid.md +++ b/docs/developer/subsystems/fault-split-multigrid.md @@ -181,6 +181,9 @@ welded tails converge with the serial slip; the thick twin extruded with its pai welded. A network whose junction ligament is one fine cell wide closes at the next coarser spacing (the two labels meet), so such a network gets split levels only where the ligament survives — on `test_0866`'s kissing Y with one add-on level, none. -- Co-locating the add-on levels with the finest in parallel is an optimisation the - query path makes unnecessary for correctness; not measured at scale. +- In parallel each split level's fault star is moved onto the rank holding the + finest level's star (`fault_mg.colocate_split_level`), so a transfer between + levels locates most fault-band nodes locally (without it, 48 % of the finest + nodes were located remotely at np = 2, 58 % at np = 8); the remote queries + that remain are the band's rim. Not measured at scale. - The one-rank build of `extrude_sheet` bounds the layer size by one rank's memory. diff --git a/docs/developer/subsystems/thin-sheet.md b/docs/developer/subsystems/thin-sheet.md index 7e219e06b..ac9664a72 100644 --- a/docs/developer/subsystems/thin-sheet.md +++ b/docs/developer/subsystems/thin-sheet.md @@ -3,6 +3,8 @@ A vertically averaged (plane-stress) sheet solver for the lithosphere, on a flat mesh or on the surface of a sphere, with any deviatoric constitutive model — Newtonian, Maxwell viscoelastic, or yielding — and the split-node fault machinery. +A transversely isotropic model is refused on the sphere (its strain rate is not +projected onto the surface). Design record: `docs/developer/design/THIN_SHEET_VISCOELASTIC_DESIGN.md`. Tests: `tests/test_1075`–`test_1084`. Session drivers: `scripts/sessions/thin_sheet_*.py`. @@ -82,10 +84,13 @@ composed with each solver's own closure (`_flux_closure`). Measured (`test_1083`, $L = 0.04$, $cL/\eta = 0.04$): the layer's mid-plane velocity matches the sheet's to 0.7 % unfaulted and 0.5 % faulted, the slip to -1.6 %. Limits: serial only. The layer's multigrid tail coarsens through the -thickness first, then laterally through the sheet's own levels, and every coarse -level is fault-free (taken from the split sheet's ancestors: a coarse level with a -slit has coincident nodes, which the transfer builders cannot place). On the sphere (radial extrusion) the +1.6 %. In parallel the sheet is gathered onto one rank, extruded there, and +the layer distributed with its fault pairing, every fault's cell star +rank-interior; the one-rank build bounds the layer by one rank's memory. The +layer's multigrid tail coarsens through the thickness first, then laterally +through the sheet's own levels; a split level of the sheet's tail is extruded +split, so the layer carries the split-level multigrid of the sheet one dimension +up, and the background levels stay welded. On the sphere (radial extrusion) the difference is first order in $L$ — the curvature the sheet neglects and the drag-driven shear: 3.3 % at $L = 0.04$, 1.35 % at $L = 0.02$. diff --git a/scripts/test.sh b/scripts/test.sh index 61091a0a7..0ff629486 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -194,8 +194,10 @@ if [ $PARALLEL_ONLY -eq 0 ]; then # and it is excluded by WHAT IT IS (tier_c) below, not by its number. "${PYTEST[@]}" tests/test_106*py tests/test_107*py || status=1 # test_108*: thin sheet (objective step, the Newton tangent's closure, thin vs - # thick, the quadratic benchmark); the slow ones are level_3 and marked so. - "${PYTEST[@]}" tests/test_108*py || status=1 + # thick, the quadratic benchmark); the two slow sphere tests (1081, 1084) are + # tier C, so the "not tier_c" filter leaves them out. test_109*: the fault + # placement and split-level multigrid tests that follow. + "${PYTEST[@]}" tests/test_108*py tests/test_109*py || status=1 # # test_1072 used to be pulled forward out of test_107* by name, because that # band was not batched and it is the ONLY guard on the 3-D free-surface sign diff --git a/src/underworld3/constitutive_models.py b/src/underworld3/constitutive_models.py index 5e764d295..3ec515937 100644 --- a/src/underworld3/constitutive_models.py +++ b/src/underworld3/constitutive_models.py @@ -2170,7 +2170,8 @@ def __init__(self, unknowns, order=1, integrator: str = "bdf", step, with an angle :math:`2\arctan(\omega\Delta t/2)` against :math:`\omega\Delta t`. The step acts on the newest history level: orders above one, and ``"cayley"``, need a first-order history. - Default ``"cayley"`` with the Jaumann rate, ``1`` otherwise. + Default ``1``; a thin sheet takes ``"cayley"`` with the Jaumann + rate and a first-order history unless an order is given here. """ if integrator not in ("bdf", "etd"): raise ValueError( @@ -2200,8 +2201,11 @@ def __init__(self, unknowns, order=1, integrator: str = "bdf", convected_step = "deformation" if stress_history == "log_conformation" else "linear" if convected_step not in ("linear", "deformation"): raise ValueError(f"convected_step must be 'linear' or 'deformation', got {convected_step!r}") + # left to the solver when not given: 1, the explicit source, unless the + # solver selects otherwise (a thin sheet takes "cayley") + self._objective_order_given = objective_order is not None if objective_order is None: - objective_order = "cayley" if objective_rate == "jaumann" else 1 + objective_order = 1 if objective_order != "cayley" and not (isinstance(objective_order, int) and objective_order >= 1): raise ValueError("objective_order must be a positive integer (the order in dt of the " f"step that carries the stress) or 'cayley', got {objective_order!r}") diff --git a/src/underworld3/meshing/__init__.py b/src/underworld3/meshing/__init__.py index 4e0d95ee5..9b9403a1f 100644 --- a/src/underworld3/meshing/__init__.py +++ b/src/underworld3/meshing/__init__.py @@ -134,4 +134,6 @@ "register_radial_surfaces", "register_plane_surfaces", "register_box_face_surfaces", + # A thin sheet extruded into a layer (faults carried through) + "extrude_sheet", ] diff --git a/src/underworld3/systems/solvers.py b/src/underworld3/systems/solvers.py index 8f2813b31..fcd7fe7fb 100644 --- a/src/underworld3/systems/solvers.py +++ b/src/underworld3/systems/solvers.py @@ -86,6 +86,24 @@ def expression(*args, **kwargs): return public_expression(*args, _unique_name_generation=True, **kwargs) +def _nondimensional_row(value, cdim): + """A ``(1, cdim)`` sympy row from a sequence whose entries may be + dimensional ``UWQuantity`` values (converted to the solver's non-dimensional + scale) or plain numbers / expressions.""" + # TODO(DESIGN): SNES_Stokes.bodyforce repeats this conversion inline; share it. + row = [] + for item in value: + if isinstance(item, uw.function.quantities.UWQuantity): + nd = uw.non_dimensionalise(item) + nd = nd.magnitude if hasattr(nd, "magnitude") else nd + if hasattr(nd, "shape") and tuple(nd.shape) == (1, 1): + nd = nd[0, 0] + row.append(nd) + else: + row.append(item) + return sympy.Matrix(row).reshape(1, cdim) + + def _history_record(constitutive_model): """What a stress history stores: the model's memory part of the flux when it separates one out (a solvent viscosity is rebuilt each step), else the @@ -1859,7 +1877,7 @@ def set_jacobian_F1_source(self, F1_source, linesearch="cp"): if F1_source is not None and linesearch is not None: self.petsc_options["snes_linesearch_type"] = linesearch - def add_basal_drag(self, coefficient, boundary, reference_velocity=None): + def add_basal_drag(self, conds, boundary, reference_velocity=None): r"""Couple the model to what lies beneath ``boundary`` by a drag :math:`-c\,(\mathbf{u} - \mathbf{u}_\mathrm{ref})` per unit area: a natural condition :math:`c\,(\mathbf{u} - \mathbf{u}_\mathrm{ref})`. @@ -1869,7 +1887,8 @@ def add_basal_drag(self, coefficient, boundary, reference_velocity=None): velocity of the substrate (``None``: a fixed base); for a viscous substrate of viscosity :math:`\eta_a` and thickness :math:`h_a`, :math:`c = \eta_a / h_a`. The drag acts on every component, so the base - also resists normal motion, which the sheet never has.""" + also resists normal motion, which the sheet never has. ``conds`` is the + drag coefficient :math:`c`.""" u = self.Unknowns.u.sym dim = self.mesh.dim if reference_velocity is None: @@ -1877,18 +1896,18 @@ def add_basal_drag(self, coefficient, boundary, reference_velocity=None): elif isinstance(reference_velocity, uw.discretisation.MeshVariable): reference = sympy.Matrix(reference_velocity.sym).reshape(1, dim) else: - reference = sympy.Matrix(list(reference_velocity)).reshape(1, dim) - self.add_natural_bc(coefficient * (u - reference), boundary) + reference = _nondimensional_row(reference_velocity, dim) + self.add_natural_bc(conds * (u - reference), boundary) - def add_basal_traction(self, traction, boundary): - r"""Apply a traction :math:`\mathbf{t}` (force per unit area, exerted on - the model) on ``boundary``; the thick form of + def add_basal_traction(self, conds, boundary): + r"""Apply a traction :math:`\mathbf{t}` (``conds``, force per unit area, + exerted on the model) on ``boundary``; the thick form of ``ThinSheet.add_basal_traction``.""" dim = self.mesh.dim - if isinstance(traction, uw.discretisation.MeshVariable): - row = sympy.Matrix(traction.sym).reshape(1, dim) + if isinstance(conds, uw.discretisation.MeshVariable): + row = sympy.Matrix(conds.sym).reshape(1, dim) else: - row = sympy.Matrix(list(traction)).reshape(1, dim) + row = _nondimensional_row(conds, dim) self.add_natural_bc(-row, boundary) # ----- DEVSS: stabilising a discontinuous elastic stress ----- diff --git a/src/underworld3/systems/thin_sheet.py b/src/underworld3/systems/thin_sheet.py index 0cacbfa4a..11c7996b6 100644 --- a/src/underworld3/systems/thin_sheet.py +++ b/src/underworld3/systems/thin_sheet.py @@ -15,7 +15,8 @@ \qquad \mathbf{P} = \mathbf{I} - \hat{\mathbf{n}}\hat{\mathbf{n}}^T, (England & McKenzie 1982). Any deviatoric constitutive law applies unchanged, -a Maxwell viscoelastic one included; for a Newtonian sheet +a Maxwell viscoelastic one included (a transversely isotropic one on a flat +sheet only); for a Newtonian sheet :math:`\boldsymbol{\sigma} = 2\eta\,(\dot{\boldsymbol\varepsilon}_s + \operatorname{tr}\dot{\boldsymbol\varepsilon}_s\,\mathbf{P})`, whose uniaxial limit is the Trouton viscosity :math:`3\eta`. The momentum balance solved is @@ -50,33 +51,17 @@ import underworld3 as uw import underworld3.timing as timing from underworld3.coordinates import CoordinateSystemType -from underworld3.function import expression from underworld3.systems import SNES_Vector from underworld3.utilities._api_tools import Template -from .solvers import _ConstitutiveModelStateMixin, _StressHistoryMixin +# the solvers' factory: a sheet's L and c_b are its own, never a user's +# same-named expression and never another sheet's +from .solvers import (_ConstitutiveModelStateMixin, _StressHistoryMixin, _nondimensional_row, + expression) from .ddt import SemiLagrangian as SemiLagrangian_DDt from .ddt import Lagrangian as Lagrangian_DDt -def _nondimensional_row(value, cdim): - """A ``(1, cdim)`` sympy row from a sequence whose entries may be - dimensional ``UWQuantity`` values (converted to the solver's non-dimensional - scale) or plain numbers / expressions.""" - # TODO(DESIGN): SNES_Stokes.bodyforce repeats this conversion inline; share it. - row = [] - for item in value: - if isinstance(item, uw.function.quantities.UWQuantity): - nd = uw.non_dimensionalise(item) - nd = nd.magnitude if hasattr(nd, "magnitude") else nd - if hasattr(nd, "shape") and tuple(nd.shape) == (1, 1): - nd = nd[0, 0] - row.append(nd) - else: - row.append(item) - return sympy.Matrix(row).reshape(1, cdim) - - def _sheet_normal(mesh): r"""Unit normal :math:`\hat{\mathbf{n}}` of the sheet as a ``(1, cdim)`` row, or ``None`` on a flat (volume) mesh.""" @@ -372,6 +357,30 @@ def _keep_history_tangent(self): # value per off-diagonal pair and refuses two that differ level.array[...] = 0.5 * (projected + projected.transpose(0, 2, 1)) + @property + def constitutive_model(self): + """Constitutive model of the sheet (see + :attr:`SNES_Stokes.constitutive_model`). A Jaumann stress history with a + first-order step is carried by the Cayley rotation unless the model was + given an ``objective_order``: exactly orthogonal, so a rigid rotation of + the sheet carries its stress rigidly at any step.""" + return SNES_Vector.constitutive_model.fget(self) + + @constitutive_model.setter + def constitutive_model(self, model_or_class): + SNES_Vector.constitutive_model.fset(self, model_or_class) + cm = self._constitutive_model + if (self.mesh.dim < self.mesh.cdim + and isinstance(cm, uw.constitutive_models.TransverseIsotropicFlowModel)): + # its strain rate is the full symmetric gradient, not the sheet's + # projected one: a rigid rotation of the sphere would strain it + raise NotImplementedError( + "a transversely isotropic model on a manifold sheet: its strain " + "rate is not projected onto the surface; use it on a flat sheet") + if (getattr(cm, "_objective_rate", "none") == "jaumann" + and not cm._objective_order_given and cm.order == 1): + cm._objective_order = "cayley" + @property def stress_transport(self) -> str: """How a viscoelastic stress history is carried (see @@ -429,7 +438,7 @@ def basal_drag(self, value): self._needs_function_rewire = True self._basal_drag.sym = value - def add_basal_drag(self, coefficient, boundary=None, reference_velocity=None): + def add_basal_drag(self, conds, boundary=None, reference_velocity=None): r"""Couple the sheet to its base by a drag :math:`-c\,(\mathbf{u} - \mathbf{u}_\mathrm{ref})` per unit area. @@ -437,7 +446,7 @@ def add_basal_drag(self, coefficient, boundary=None, reference_velocity=None): substrate's flow, a mantle convection model's surface velocity, plate motions); ``None`` drags against a fixed base. For a viscous substrate of viscosity :math:`\eta_a` and thickness :math:`h_a`, - :math:`c = \eta_a / h_a`. Calls accumulate. + :math:`c = \eta_a / h_a`; ``conds`` is :math:`c`. Calls accumulate. ``boundary`` names the base of a thick sheet (``Stokes`` on a thin 3-D mesh applies the same drag there as a natural condition). The thin sheet @@ -451,18 +460,18 @@ def add_basal_drag(self, coefficient, boundary=None, reference_velocity=None): reference = sympy.Matrix(reference_velocity.sym).reshape(1, self.mesh.cdim) else: reference = _nondimensional_row(reference_velocity, self.mesh.cdim) - self._basal_terms.append(coefficient * (u - reference)) + self._basal_terms.append(conds * (u - reference)) - def add_basal_traction(self, traction, boundary=None): - r"""Apply a traction :math:`\mathbf{t}` (force per unit area, exerted - on the sheet) at its base — the shear stress of a convection model + def add_basal_traction(self, conds, boundary=None): + r"""Apply a traction :math:`\mathbf{t}` (``conds``, force per unit area, + exerted on the sheet) at its base — the shear stress of a convection model resolved at the base of the lithosphere, say. Calls accumulate. ``boundary`` as in :meth:`add_basal_drag`.""" self._needs_function_rewire = True - if isinstance(traction, uw.discretisation.MeshVariable): - row = sympy.Matrix(traction.sym).reshape(1, self.mesh.cdim) + if isinstance(conds, uw.discretisation.MeshVariable): + row = sympy.Matrix(conds.sym).reshape(1, self.mesh.cdim) else: - row = _nondimensional_row(traction, self.mesh.cdim) + row = _nondimensional_row(conds, self.mesh.cdim) self._basal_terms.append(-row) def _basal_residual(self): @@ -480,6 +489,10 @@ def p(self): self._p_field = uw.discretisation.MeshVariable( f"p_sheet_{self.instance_number}", self.mesh, 1, degree=self._p_degree, continuous=self._p_continuous) + # asked for after a solve: filled from that solve, not left at zero + # until the next one + if self.is_setup: + self._refresh_pressure() return self._p_field def _refresh_pressure(self, verbose=False): @@ -488,7 +501,11 @@ def _refresh_pressure(self, verbose=False): if self._p_projection is None: self._p_projection = uw.systems.Projection(self.mesh, self._p_field) self._p_projection.smoothing = 0.0 - self._p_projection.uw_function = -sympy.Matrix(self.stress_deviator).trace() + # After a viscoelastic solve the newest history level is the stress just + # committed; the flux reads the history and so, by now, the shifted one. + stress = (self.tau.sym if self.Unknowns.DFDt is not None + else self.stress_deviator) + self._p_projection.uw_function = -sympy.Matrix(stress).trace() self._p_projection.solve(verbose=verbose) @property diff --git a/src/underworld3/utilities/custom_mg.py b/src/underworld3/utilities/custom_mg.py index 4263b3d79..79928544a 100644 --- a/src/underworld3/utilities/custom_mg.py +++ b/src/underworld3/utilities/custom_mg.py @@ -1751,12 +1751,18 @@ def install(self, solver, verbose=False): Ps = self.transfers patch_rows = getattr(self, "level_patch_rows", None) if self.drop_orphans: - # the restriction renumbers the COARSE levels, so their recorded - # FAC patch rows no longer apply; the finest's rows are unchanged + # a coarse level the restriction renumbered (it dropped unreached + # columns) loses its recorded FAC patch rows; a level it left whole, + # and the finest, keep theirs from underworld3.utilities.fault_mg import restrict_unreached - Ps = restrict_unreached(Ps, solver.dm.comm) + restricted = restrict_unreached(Ps, solver.dm.comm) if patch_rows is not None: - patch_rows = [None] * (len(patch_rows) - 1) + [patch_rows[-1]] + patch_rows = [ + None if level < len(Ps) + and restricted[level].getSize()[1] != Ps[level].getSize()[1] + else rows + for level, rows in enumerate(patch_rows)] + Ps = restricted _install_transfers(solver, Ps, verbose=verbose, patch_rows=patch_rows) # Record what is live on this solver's PC, so a repeat solve can # skip the re-install (see _pcmg_still_installed). diff --git a/src/underworld3/utilities/fault_mg.py b/src/underworld3/utilities/fault_mg.py index 1a0c77ad4..762e54b73 100644 --- a/src/underworld3/utilities/fault_mg.py +++ b/src/underworld3/utilities/fault_mg.py @@ -30,9 +30,9 @@ Parallel: each split level is redistributed so its fault star is rank-interior (the split's own rule), so pair blocks are rank-local on every level. The transfer -locates each owned fine node in the LOCAL coarse mesh (owned plus ghost cells); -the few nodes whose coarse cell lives elsewhere are gathered as query points and -answered by the rank that holds the cell, with global column indices. +locates each owned fine node in the rank's own coarse cells (the solver DMs carry +no overlap); the nodes whose coarse cell lives elsewhere are gathered as query +points and answered by the rank that holds the cell, with global column indices. """ import itertools @@ -418,6 +418,7 @@ def split_tail_levels(levels, fault_specs, verbose=False): """ import warnings + from underworld3.utilities.fault_contact import _collective_raise from underworld3.utilities.fault_split import add_fault entries = [tuple(spec[1:4]) for spec in fault_specs] @@ -430,9 +431,17 @@ def split_tail_levels(levels, fault_specs, verbose=False): return out for k in range(len(levels) - 1, -1, -1): level = levels[k] + problem = None try: split = add_fault(level, entries, verbose=verbose, **kwargs) except (ValueError, RuntimeError, NotImplementedError) as exc: + problem = str(exc) + # the split's refusals are collective, its internal failures may not + # be: every rank stops at the same level, or the ranks that went on + # wait in the next level's split for the one that stopped + try: + _collective_raise(problem) + except RuntimeError as exc: warnings.warn( f"fault_mg: the fault(s) could not be carried onto add-on " f"level {k} of {len(levels)} ({exc}); that level and the " diff --git a/src/underworld3/utilities/rotated_bc.py b/src/underworld3/utilities/rotated_bc.py index 28c3616ca..0a8213c2b 100644 --- a/src/underworld3/utilities/rotated_bc.py +++ b/src/underworld3/utilities/rotated_bc.py @@ -455,8 +455,17 @@ def build_rotation(solver, boundaries, datum_specs=None): pair_nodes_by_fault = {} if fault_names and manifold: from underworld3.utilities import fault_contact + # the pairing fails rank-locally (a label mismatch, a vanishing analytic + # normal); the verdict is exchanged before any rank goes on to the + # collective Q assembly, as in the pair-block assembly below + problem = None + try: + for fname in fault_names: + pair_nodes_by_fault[fname] = fault_contact._fault_pair_nodes(solver, fname) + except (RuntimeError, ValueError) as err: + problem = str(err) + fault_contact._collective_raise(problem) for fname in fault_names: - pair_nodes_by_fault[fname] = fault_contact._fault_pair_nodes(solver, fname) for q_plus, q_minus, _nrm in pair_nodes_by_fault[fname]: for q in (q_plus, q_minus): if q in node_normals and node_specs[q] <= {SHEET_TANGENCY}: @@ -579,8 +588,9 @@ def build_rotation(solver, boundaries, datum_specs=None): problem = None try: for fname in fault_names: - for q_plus, q_minus, nrm in (pair_nodes_by_fault.get(fname) - or fault_contact._fault_pair_nodes(solver, fname)): + pairs = (pair_nodes_by_fault[fname] if fname in pair_nodes_by_fault + else fault_contact._fault_pair_nodes(solver, fname)) + for q_plus, q_minus, nrm in pairs: if q_plus in node_normals or q_minus in node_normals: raise ValueError( f"fault {fname!r} shares a node with a rotated " diff --git a/tests/parallel/test_1086_rotated_fault_transfer_rows.py b/tests/parallel/test_1086_rotated_fault_transfer_rows.py index 1e0074669..6ee912818 100644 --- a/tests/parallel/test_1086_rotated_fault_transfer_rows.py +++ b/tests/parallel/test_1086_rotated_fault_transfer_rows.py @@ -7,7 +7,11 @@ ``zeroRows`` as global rows. Serial, where the two coincide, was right; at np=2 the wrong rows were zeroed, 232 coarse unknowns lost every fine image, and the Galerkin product failed on empty rows (found 2026-10-13). Contract, at any rank -count: every opening row is zero and no coarse column is empty. +count: every opening row is zero. Since the add-on levels are split by default +the split tail builds its transfers in ``fault_mg``, so the test runs the welded +tail (``split_levels=False``, the code it was written for) and the split one. +Both drop the coarse columns no fine unknown reaches, so "no empty column" is +true by construction and is not asserted. mpirun -n 2 python -m pytest --with-mpi tests/parallel/test_1086_rotated_fault_transfer_rows.py """ @@ -17,10 +21,12 @@ import underworld3 as uw -pytestmark = [pytest.mark.level_2, pytest.mark.tier_b, pytest.mark.timeout(900)] +pytestmark = [pytest.mark.mpi(min_size=2), pytest.mark.level_2, pytest.mark.tier_b, + pytest.mark.timeout(900)] -def test_rotated_fine_transfer_zeroes_opening_rows_by_global_row(): +@pytest.mark.parametrize("split_levels", [False, True]) +def test_rotated_fine_transfer_zeroes_opening_rows_by_global_row(split_levels): from underworld3.utilities import rotated_bc tip_a, tip_b = np.array([0.30, 0.45]), np.array([0.70, 0.55]) @@ -32,7 +38,7 @@ def test_rotated_fine_transfer_zeroes_opening_rows_by_global_row(): guide.discretize() child = base.adapt(guide.refinement_metric_function( h_near=0.0125, h_far=0.05, width=0.08, profile="linear"), max_levels=2) - mesh = child.add_fault([("Flt", np.vstack([tip_a, tip_b]))]) + mesh = child.add_fault([("Flt", np.vstack([tip_a, tip_b]))], split_levels=split_levels) x, y = mesh.X stokes = uw.systems.Stokes(mesh) @@ -55,14 +61,5 @@ def test_rotated_fine_transfer_zeroes_opening_rows_by_global_row(): ai, aj, av = P.getValuesCSR() nonzero_in_opening = sum( int(np.any(av[ai[r - rstart]:ai[r - rstart + 1]] != 0.0)) for r in opening) + assert uw.mpi.comm.allreduce(len(opening)) > 0 assert uw.mpi.comm.allreduce(nonzero_in_opening) == 0 - - ones = P.createVecLeft() - ones.set(1.0) - column_mass = P.createVecRight() - absP = P.copy() - absP.setValuesCSR(ai, aj, np.abs(av)) - absP.assemble() - absP.multTranspose(ones, column_mass) - empty = int(np.sum(column_mass.getArray() == 0.0)) - assert uw.mpi.comm.allreduce(empty) == 0, "coarse unknowns with no fine image" diff --git a/tests/parallel/test_1089_fault_split_multigrid_parallel.py b/tests/parallel/test_1089_fault_split_multigrid_parallel.py index e1fdf55e8..e538a1522 100644 --- a/tests/parallel/test_1089_fault_split_multigrid_parallel.py +++ b/tests/parallel/test_1089_fault_split_multigrid_parallel.py @@ -115,3 +115,28 @@ def centroid(q): tangential = jumps - np.einsum("ij,ij->i", jumps, normals)[:, None] * normals assert np.linalg.norm(tangential, axis=1).max() > 1.0e-2 assert np.abs(np.einsum("ij,ij->i", jumps, normals)).max() < 1.0e-8 + + +def test_a_failure_on_one_rank_stops_the_replay_on_every_rank(monkeypatch): + """An internal failure of the split on one rank (a rank-local raise, not one + of the split's collective refusals) stopped the replay of the fault on that + rank alone, and the others went on into the next level's split and waited + for it. The verdict is exchanged per level: every rank keeps the same split + levels, and the levels from the failure down stay welded.""" + import underworld3.utilities.fault_split as fault_split + original, calls = fault_split.add_fault, [0] + + def fails_on_one_rank_at_the_second_add_on_level(mesh, *args, **kwargs): + calls[0] += 1 # 1: the finest; 2, 3: the add-on levels + if calls[0] == 3 and uw.mpi.rank == 1: + raise RuntimeError("an internal failure on one rank") + return original(mesh, *args, **kwargs) + + monkeypatch.setattr(fault_split, "add_fault", fails_on_one_rank_at_the_second_add_on_level) + with pytest.warns(UserWarning, match="could not be carried"): + mesh = _faulted_box(3) + split = [bool(getattr(m, "_fault_point_pairs", None)) + for m in mesh._custom_mg_coarse_meshes] + everywhere = uw.mpi.comm.allgather(split) + assert all(s == everywhere[0] for s in everywhere), everywhere + assert sum(split) == 1, split diff --git a/tests/test_1081_objective_step_sphere.py b/tests/test_1081_objective_step_sphere.py index 2d38e40f2..a2270c3cb 100644 --- a/tests/test_1081_objective_step_sphere.py +++ b/tests/test_1081_objective_step_sphere.py @@ -16,7 +16,9 @@ pytestmark = [pytest.mark.level_3] -@pytest.mark.tier_b +# tier C for its cost (about 13 min): reported, not gated, as test_1061 and +# test_1064 are; it was shown to fail on its bug and would be tier B otherwise +@pytest.mark.tier_c def test_a_rigid_rotation_of_the_sphere_carries_the_stress_rigidly(): """A quarter turn about x in 20 steps, h = 0.15. Measured 2026-10-12: relative error 9.4e-3 (8.4e-3 at 40 steps, 8.5e-3 at 80: the error is diff --git a/tests/test_1083_thin_thick_alignment.py b/tests/test_1083_thin_thick_alignment.py index 08c031f29..211e9abb7 100644 --- a/tests/test_1083_thin_thick_alignment.py +++ b/tests/test_1083_thin_thick_alignment.py @@ -32,7 +32,7 @@ def _sheet_mesh(faulted): return mesh -def model(mesh, Solver): +def _solver_on(mesh, Solver): """The model, written once: a viscous plate dragged by a base moving in simple shear, free at its edges, optionally cut by a frictionless fault.""" solver = Solver(mesh) @@ -50,10 +50,10 @@ def model(mesh, Solver): def _run(faulted): sheet_mesh = _sheet_mesh(faulted) - sheet = model(sheet_mesh, uw.systems.ThinSheet) + sheet = _solver_on(sheet_mesh, uw.systems.ThinSheet) sheet.thickness = L sheet.solve() - layer = model(sheet_mesh.extrude(L, layers=2), uw.systems.Stokes) + layer = _solver_on(sheet_mesh.extrude(L, layers=2), uw.systems.Stokes) layer.solve() return sheet, layer @@ -99,7 +99,9 @@ def centroid(q): assert round(-L / 2, 12) in paired_vertex_z and round(L / 2, 12) in paired_vertex_z -@pytest.mark.tier_b +# tier C: one model against another at one thickness, a characterisation; +# the contract is agreement to first order in L +@pytest.mark.tier_c def test_dragged_plate_thin_and_thick_agree(): sheet, layer = _run(faulted=False) xy = sheet.u.coords @@ -109,7 +111,9 @@ def test_dragged_plate_thin_and_thick_agree(): assert error < 2.0e-2, f"relative difference {error:.3e}" -@pytest.mark.tier_b +# tier C: one model against another at one thickness, a characterisation; +# the contract is agreement to first order in L +@pytest.mark.tier_c def test_faulted_plate_thin_and_thick_agree(): """The same plate cut by a frictionless fault: on the layer the fault is the sheet's, extruded through the thickness (open at the surface and base, @@ -153,7 +157,9 @@ def test_faulted_plate_thin_and_thick_agree(): f"slip thick {slip_l} thin {slip_s}") -@pytest.mark.tier_b +# tier C: one model against another at one thickness, a characterisation; +# the contract is agreement to first order in L +@pytest.mark.tier_c def test_dragged_shell_thin_and_thick_agree_on_the_sphere(): """The same model on the sphere: a sheet on the unit-sphere manifold and the shell extruded radially from it, dragged by a base in the toroidal flow @@ -163,7 +169,7 @@ def test_dragged_shell_thin_and_thick_agree_on_the_sphere(): (h = 0.15, two layers): 3.32 % at L = 0.04, 1.35 % at L = 0.02.""" sphere = uw.meshing.SphericalManifold(radius=1.0, cellSize=0.15, degree=1, qdegree=3) - def shell_model(mesh, Solver): + def _shell_solver_on(mesh, Solver): solver = Solver(mesh) solver.constitutive_model = uw.constitutive_models.ViscousFlowModel solver.constitutive_model.Parameters.shear_viscosity_0 = 1 @@ -173,10 +179,10 @@ def shell_model(mesh, Solver): return solver thickness = 0.02 - sheet = shell_model(sphere, uw.systems.ThinSheet) + sheet = _shell_solver_on(sphere, uw.systems.ThinSheet) sheet.thickness = thickness sheet.solve() - shell = shell_model(sphere.extrude(thickness, layers=2), uw.systems.Stokes) + shell = _shell_solver_on(sphere.extrude(thickness, layers=2), uw.systems.Stokes) shell.solve() pts = sheet.u.coords u_sheet = np.asarray(sheet.u.array).reshape(-1, 3) diff --git a/tests/test_1084_quadratic_stress_sphere.py b/tests/test_1084_quadratic_stress_sphere.py index f06d5c2bc..6ac236e55 100644 --- a/tests/test_1084_quadratic_stress_sphere.py +++ b/tests/test_1084_quadratic_stress_sphere.py @@ -36,7 +36,9 @@ pytestmark = [pytest.mark.level_3] -@pytest.mark.tier_b +# tier C for its cost (about 7 min): reported, not gated, as test_1061 and +# test_1064 are; it was shown to fail on its bug and would be tier B otherwise +@pytest.mark.tier_c def test_quadratic_thinning_matches_the_second_order_closed_form(): A, h, dt, steps = 0.1, 0.2, 0.2, 20 sphere = uw.meshing.SphericalManifold(radius=1.0, cellSize=h, degree=1, qdegree=5) diff --git a/tests/test_1087_fault_split_multigrid.py b/tests/test_1087_fault_split_multigrid.py index 7be7c68e6..7ce98ad79 100644 --- a/tests/test_1087_fault_split_multigrid.py +++ b/tests/test_1087_fault_split_multigrid.py @@ -85,12 +85,13 @@ def test_iterations_fall_with_refinement_and_the_slip_is_unchanged(base): assert info["velocity_pc"] == "custom-FMG" and info["converged"], info its[k] = info["vel_its_last"][-1] slips[k] = slip - # measured 6 and 5; the welded tail takes 13 and 18 and grows with k + # measured 5 and 5 (2026-10-28); the welded tail takes 13 at k = 3 and + # grows with k, so the bound tells the split levels from the welded tail assert its[2] <= 8 and its[3] <= 8, its assert its[3] <= its[2] + 1, its + # the welded tail solves the same problem: the same slip child, trace = _adapted(base, 3) - info_w, slip_w = _solve(child.add_fault([("Flt", trace)], split_levels=False)) - assert info_w["vel_its_last"][-1] >= its[3] + 6, (info_w["vel_its_last"], its) + _info_w, slip_w = _solve(child.add_fault([("Flt", trace)], split_levels=False)) assert abs(slip_w - slips[3]) < 1.0e-4 * slip_w, (slip_w, slips[3]) From d6266e4038fdb6c03cee60666e404d34197f5ef0 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Wed, 28 Oct 2026 18:17:42 +1100 Subject: [PATCH 54/57] Fault-split multigrid: the four new TODO(BUG) markers name their issues (#869-#872) Underworld development team with AI support from Claude Code --- src/underworld3/discretisation/discretisation_mesh.py | 2 +- src/underworld3/discretisation/discretisation_mesh_variables.py | 1 + src/underworld3/systems/ddt.py | 1 + src/underworld3/systems/navier_stokes_eulerian.py | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/underworld3/discretisation/discretisation_mesh.py b/src/underworld3/discretisation/discretisation_mesh.py index 74467aa3c..94f95499b 100644 --- a/src/underworld3/discretisation/discretisation_mesh.py +++ b/src/underworld3/discretisation/discretisation_mesh.py @@ -10111,7 +10111,7 @@ def units_information(units, indent): # TODO(BUG): a manifold mesh (triangles in three-space, e.g. # SphericalManifold) lands here with numCorners == 3 and is written as # Hexahedron; it should be Triangle with XYZ geometry. Found 2026-10-09 - # (thin-sheet work), not fixed — out of that scope. + # (thin-sheet work), not fixed — out of that scope. See issue #869. if numCorners == 4: topology_type = "Tetrahedron" elif numCorners == 8: diff --git a/src/underworld3/discretisation/discretisation_mesh_variables.py b/src/underworld3/discretisation/discretisation_mesh_variables.py index e1c06a1bb..1b8622567 100644 --- a/src/underworld3/discretisation/discretisation_mesh_variables.py +++ b/src/underworld3/discretisation/discretisation_mesh_variables.py @@ -1967,6 +1967,7 @@ def read_checkpoint( # reconstructed from its checkpoint — is untested on a manifold: the h5 # mesh round-trip drops the coordinate dimension first (see the # SphericalManifold factory). Found 2026-10-09 (thin-sheet work), not fixed. + # See issue #870. import h5py if uw.mpi.rank == 0: diff --git a/src/underworld3/systems/ddt.py b/src/underworld3/systems/ddt.py index 185cc52ce..dd4c65d4a 100644 --- a/src/underworld3/systems/ddt.py +++ b/src/underworld3/systems/ddt.py @@ -3906,6 +3906,7 @@ def _sample_history_at_departure( # unchanged by a 1e-11 solver tolerance) and patchy stress along the trace, # both gone with the grid (Eulerian SUPG) history. A Stokes viscoelastic # model with a split fault uses this history by default and is exposed too. + # See issue #871. def update_pre_solve( self, dt: float, diff --git a/src/underworld3/systems/navier_stokes_eulerian.py b/src/underworld3/systems/navier_stokes_eulerian.py index 693fcfb21..1144cbfe9 100644 --- a/src/underworld3/systems/navier_stokes_eulerian.py +++ b/src/underworld3/systems/navier_stokes_eulerian.py @@ -242,6 +242,7 @@ def __init__( # surface mesh the velocity has mesh.cdim components; the same sizing was # fixed in EulerianSUPG and the Eulerian AdvDiffusion (2026-10-09, # thin-sheet work). Not fixed here: not used on a surface mesh yet. + # See issue #872. self._a_var = uw.discretisation.MeshVariable( f"a_NSSUPG_{tag}", self.mesh, self.mesh.dim, degree=u.degree, continuous=u.continuous, varsymbol=rf"\mathbf{{a}}_{{{tag}}}") From 1e9dcf004343848162034a3a5b85cc7bfbfa6323 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Wed, 28 Oct 2026 18:37:50 +1100 Subject: [PATCH 55/57] Fault-split multigrid: tests follow the objective_order default; magnitudes in test_1075 - test_1080: the model's default objective_order is 1 for every rate (Louis, 2026-10-28); the default test now also checks that a ThinSheet selects the Cayley step for a first-order Jaumann history given no order, and keeps a given order; the Cayley test asks for the step by name. - test_1075: the von Mises sheet test compares magnitudes. On development a viscoplastic stress evaluated in a model without units comes back tagged pascal (issue #881), and the comparison with a plain number raised. - extrude: the boundary-value sentinels (666, 1001) are named where the base and surface values are chosen. Underworld development team with AI support from Claude Code --- src/underworld3/meshing/extrude.py | 2 ++ tests/test_1075_thin_sheet.py | 4 +++- tests/test_1080_objective_step.py | 20 +++++++++++++++----- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/underworld3/meshing/extrude.py b/src/underworld3/meshing/extrude.py index 13672b6c7..218931438 100644 --- a/src/underworld3/meshing/extrude.py +++ b/src/underworld3/meshing/extrude.py @@ -194,6 +194,8 @@ def label_faces(name, value, points): taken = set(members.values()) for name in (base, surface): + # the next value above the ordinary boundaries, below the sentinels + # Null_Boundary (666) and All_Boundaries (1001), as fault_split numbers them value = max([v for v in taken if v < 666] + [0]) + 1 while value in taken: value += 1 diff --git a/tests/test_1075_thin_sheet.py b/tests/test_1075_thin_sheet.py index 7fd3dd91c..5db5e2d93 100644 --- a/tests/test_1075_thin_sheet.py +++ b/tests/test_1075_thin_sheet.py @@ -195,7 +195,9 @@ def test_plastic_sheet_yields_at_the_von_mises_stress(): sheet.u.array[:, 0, 1] = -float(edot) * (sheet.u.coords[:, 1] - 0.5) / 2 sheet.solve(zero_init_guess=False) - sigma = uw.function.evaluate(sheet.stress, _probe_points()).reshape(-1, 2, 2) + # magnitudes: in a model without units the yield stress's declared unit + # still tags the evaluated stress (issue #881) + sigma = np.asarray(uw.function.evaluate(sheet.stress, _probe_points())).reshape(-1, 2, 2) assert np.abs(sigma[:, 0, 0] - np.sqrt(3.0) * tau_y).max() < 1.0e-3 assert np.abs(sigma[:, 1, 1]).max() < 1.0e-3 diff --git a/tests/test_1080_objective_step.py b/tests/test_1080_objective_step.py index e2ead8fd6..221226280 100644 --- a/tests/test_1080_objective_step.py +++ b/tests/test_1080_objective_step.py @@ -29,10 +29,20 @@ def _model(rate, order=None, history_order=1, convected_step=None): @pytest.mark.tier_b def test_the_objective_order_defaults_and_refusals(): - """Cayley for the Jaumann rate, first order otherwise; Cayley needs the - Jaumann rate; the deformation step takes no order; an order above one needs - a first-order history.""" - assert _model("jaumann")._objective_order == "cayley" + """First order by default for every rate, so a Stokes model is as it was + (Louis, 2026-10-28); a thin sheet takes the Cayley step for a first-order + Jaumann history given no order; Cayley needs the Jaumann rate; the + deformation step takes no order; an order above one needs a first-order + history.""" + assert _model("jaumann")._objective_order == 1 + sheet = uw.systems.ThinSheet(_box()) + sheet.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + sheet.Unknowns, order=1, integrator="etd", objective_rate="jaumann") + assert sheet.constitutive_model._objective_order == "cayley" + sheet.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( + sheet.Unknowns, order=1, integrator="etd", objective_rate="jaumann", + objective_order=1) + assert sheet.constitutive_model._objective_order == 1 assert _model("upper_convected")._objective_order == 1 assert _model("none")._objective_order == 1 with pytest.raises(ValueError, match="cayley"): @@ -50,7 +60,7 @@ def test_the_cayley_step_is_the_orthogonal_rotation_of_the_stress(): """At a spin W = [[0, -w], [w, 0]] the Cayley step carries sigma to R sigma R^T with R = (I - dt W/2)^{-1}(I + dt W/2), an exact rotation.""" from underworld3.function.expressions import unwrap - model = _model("jaumann") + model = _model("jaumann", order="cayley") model.Parameters.dt_elastic = 0.1 sigma = sympy.Matrix([[1, 2], [2, -3]]) carried = model._carried_through_step(sigma) From 52ac78205c4b29945b4936ca20476840876b25f0 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Wed, 28 Oct 2026 18:44:34 +1100 Subject: [PATCH 56/57] Fault-split multigrid, review 2: rotated adjoints refused where unrouted; sheet fixes - The merge with development took #846's removal of the base refusal of a rotated constraint; only the Stokes saddle point routes a rotated adjoint (solve_rotated_adjoint), and the vector solvers' adjoint_solve transposes the unrotated operator. A manifold ThinSheet (its tangency is a rotated constraint) recorded "supported" and adjoint_solve ignored the constraint. Solvers declare _adjoint_routes_rotated_constraints (True on the saddle point); the base refuses otherwise. test_0018 gains the manifold sheet. - ThinSheet refuses a transversely isotropic model on a manifold before it is installed (a caught refusal left the model in place), including a composite's constituents. - The sheet's plane-stress pressure adds the solvent's Newtonian stress (the history carries the Maxwell element only). - test_1089's replay test injects its rank-local failure after the split's last collective, the case the verdict exchange handles; injected before, the split itself hangs (issue #874), which no exchange after it can rescue. Passes with the exchange; with each rank deciding alone it hangs at np 2. Underworld development team with AI support from Claude Code --- .../cython/petsc_generic_snes_solvers.pyx | 21 ++++++++-- src/underworld3/systems/thin_sheet.py | 38 ++++++++++++++----- src/underworld3/utilities/fault_mg.py | 7 ++-- ...est_1089_fault_split_multigrid_parallel.py | 20 ++++++---- tests/test_0018_adjoint_support_record.py | 25 ++++++++++++ 5 files changed, 87 insertions(+), 24 deletions(-) diff --git a/src/underworld3/cython/petsc_generic_snes_solvers.pyx b/src/underworld3/cython/petsc_generic_snes_solvers.pyx index bd39220de..4ca3545d4 100644 --- a/src/underworld3/cython/petsc_generic_snes_solvers.pyx +++ b/src/underworld3/cython/petsc_generic_snes_solvers.pyx @@ -363,6 +363,10 @@ class SolverBaseClass(uw_object): This class is not intended to be used directly """ + # whether adjoint_solve takes the transpose of a rotated operator + _adjoint_routes_rotated_constraints = False + + def __init__(self, mesh): super().__init__() @@ -2061,9 +2065,16 @@ class SolverBaseClass(uw_object): f"{len(contact)} fault contact(s): the rotated operator " f"carries an additive interface tangent, reassembled at every " f"iterate, whose transpose is not routed into the adjoint") - # Rotated FREE-SLIP does admit one — the transpose of the rotated - # operator, taken in the boundary frame. The solver that owns that path - # says so in its own verdict; nothing is refused here. + # Rotated FREE-SLIP admits one — the transpose of the rotated operator, + # taken in the boundary frame — where the solver routes its adjoint + # through it (the Stokes saddle point, rotated_bc.solve_rotated_adjoint). + # Elsewhere adjoint_solve would transpose the unrotated operator. + rotated = mechanisms["rotated_freeslip"] + if rotated and not self._adjoint_routes_rotated_constraints: + return (False, + f"{len(rotated)} rotated constraint(s): this solver's " + f"adjoint_solve transposes the unrotated operator, and the " + f"rotated transpose is routed only for the Stokes saddle point") if not self.consistent_jacobian and not self._residual_is_linear_in_unknown(): return (True, "implicit residual: Jacobian transpose for the state, symbolic " @@ -8619,6 +8630,10 @@ class SNES_Stokes_SaddlePt(SolverBaseClass): SNES_Vector : For vector-valued equations without constraints. """ + # adjoint_solve routes a rotated solve through solve_rotated_adjoint + _adjoint_routes_rotated_constraints = True + + class _Unknowns(SolverBaseClass._Unknowns): '''Extend the unknowns with the constraint parameter''' diff --git a/src/underworld3/systems/thin_sheet.py b/src/underworld3/systems/thin_sheet.py index 11c7996b6..81cf5e973 100644 --- a/src/underworld3/systems/thin_sheet.py +++ b/src/underworld3/systems/thin_sheet.py @@ -62,6 +62,16 @@ from .ddt import Lagrangian as Lagrangian_DDt +def _is_transversely_isotropic(model_or_class): + """Whether a constitutive model (a class, an instance, or a composite's + constituents) is transversely isotropic.""" + ti = uw.constitutive_models.TransverseIsotropicFlowModel + if isinstance(model_or_class, type): + return issubclass(model_or_class, ti) + parts = getattr(model_or_class, "_constitutive_models", None) or [model_or_class] + return any(isinstance(part, ti) for part in parts) + + def _sheet_normal(mesh): r"""Unit normal :math:`\hat{\mathbf{n}}` of the sheet as a ``(1, cdim)`` row, or ``None`` on a flat (volume) mesh.""" @@ -368,15 +378,15 @@ def constitutive_model(self): @constitutive_model.setter def constitutive_model(self, model_or_class): - SNES_Vector.constitutive_model.fset(self, model_or_class) - cm = self._constitutive_model - if (self.mesh.dim < self.mesh.cdim - and isinstance(cm, uw.constitutive_models.TransverseIsotropicFlowModel)): - # its strain rate is the full symmetric gradient, not the sheet's - # projected one: a rigid rotation of the sphere would strain it + # refused before it is installed: its strain rate is the full symmetric + # gradient, not the sheet's projected one, so a rigid rotation of the + # sphere would strain it + if self.mesh.dim < self.mesh.cdim and _is_transversely_isotropic(model_or_class): raise NotImplementedError( "a transversely isotropic model on a manifold sheet: its strain " "rate is not projected onto the surface; use it on a flat sheet") + SNES_Vector.constitutive_model.fset(self, model_or_class) + cm = self._constitutive_model if (getattr(cm, "_objective_rate", "none") == "jaumann" and not cm._objective_order_given and cm.order == 1): cm._objective_order = "cayley" @@ -501,10 +511,18 @@ def _refresh_pressure(self, verbose=False): if self._p_projection is None: self._p_projection = uw.systems.Projection(self.mesh, self._p_field) self._p_projection.smoothing = 0.0 - # After a viscoelastic solve the newest history level is the stress just - # committed; the flux reads the history and so, by now, the shifted one. - stress = (self.tau.sym if self.Unknowns.DFDt is not None - else self.stress_deviator) + # After a viscoelastic solve the newest history level is the Maxwell + # stress just committed (the flux reads the history and so, by now, the + # shifted one); the solvent's Newtonian part, which the history does not + # carry, is the flux less the history flux (the history terms cancel). + if self.Unknowns.DFDt is not None: + cm = self.constitutive_model + stress = sympy.Matrix(self.tau.sym) + history_flux = getattr(cm, "history_flux", None) + if history_flux is not None: + stress = stress + sympy.Matrix(cm.flux) - sympy.Matrix(history_flux) + else: + stress = self.stress_deviator self._p_projection.uw_function = -sympy.Matrix(stress).trace() self._p_projection.solve(verbose=verbose) diff --git a/src/underworld3/utilities/fault_mg.py b/src/underworld3/utilities/fault_mg.py index 762e54b73..a9d0a0abd 100644 --- a/src/underworld3/utilities/fault_mg.py +++ b/src/underworld3/utilities/fault_mg.py @@ -436,9 +436,10 @@ def split_tail_levels(levels, fault_specs, verbose=False): split = add_fault(level, entries, verbose=verbose, **kwargs) except (ValueError, RuntimeError, NotImplementedError) as exc: problem = str(exc) - # the split's refusals are collective, its internal failures may not - # be: every rank stops at the same level, or the ranks that went on - # wait in the next level's split for the one that stopped + # the split's refusals are collective; a failure on one rank after its + # last collective is not, and every rank must stop at the same level or + # the ranks hold different hierarchies (a rank-local raise inside the + # split, before its own collectives, still hangs there: issue #874) try: _collective_raise(problem) except RuntimeError as exc: diff --git a/tests/parallel/test_1089_fault_split_multigrid_parallel.py b/tests/parallel/test_1089_fault_split_multigrid_parallel.py index e538a1522..6bc6cb665 100644 --- a/tests/parallel/test_1089_fault_split_multigrid_parallel.py +++ b/tests/parallel/test_1089_fault_split_multigrid_parallel.py @@ -118,21 +118,25 @@ def centroid(q): def test_a_failure_on_one_rank_stops_the_replay_on_every_rank(monkeypatch): - """An internal failure of the split on one rank (a rank-local raise, not one - of the split's collective refusals) stopped the replay of the fault on that - rank alone, and the others went on into the next level's split and waited - for it. The verdict is exchanged per level: every rank keeps the same split - levels, and the levels from the failure down stay welded.""" + """A failure of the split on one rank after its last collective (a final + consistency check, rank-local) stopped the replay of the fault on that rank + alone: the ranks kept different split levels and the next collective on the + hierarchy met mismatched partners. The verdict is exchanged per level, so + every rank keeps the same split levels and the levels from the failure down + stay welded. (A rank-local raise BEFORE the split's own collectives still + hangs inside the split; issue #874.)""" import underworld3.utilities.fault_split as fault_split original, calls = fault_split.add_fault, [0] - def fails_on_one_rank_at_the_second_add_on_level(mesh, *args, **kwargs): + def fails_on_one_rank_after_the_second_add_on_split(mesh, *args, **kwargs): calls[0] += 1 # 1: the finest; 2, 3: the add-on levels + split = original(mesh, *args, **kwargs) if calls[0] == 3 and uw.mpi.rank == 1: raise RuntimeError("an internal failure on one rank") - return original(mesh, *args, **kwargs) + return split - monkeypatch.setattr(fault_split, "add_fault", fails_on_one_rank_at_the_second_add_on_level) + monkeypatch.setattr(fault_split, "add_fault", + fails_on_one_rank_after_the_second_add_on_split) with pytest.warns(UserWarning, match="could not be carried"): mesh = _faulted_box(3) split = [bool(getattr(m, "_fault_point_pairs", None)) diff --git a/tests/test_0018_adjoint_support_record.py b/tests/test_0018_adjoint_support_record.py index dd3bd1e91..5838456c4 100644 --- a/tests/test_0018_adjoint_support_record.py +++ b/tests/test_0018_adjoint_support_record.py @@ -117,6 +117,31 @@ def test_a_rotated_constraint_is_supported_on_the_rotated_operator(mesh): assert "null space" in verdict["reason"] +@pytest.mark.tier_b +def test_a_rotated_constraint_without_a_rotated_adjoint_is_refused(): + """A manifold sheet carries its tangency as a rotated constraint, and its + adjoint_solve (the vector solvers') transposes the unrotated operator: the + answer would ignore the constraint. Only the Stokes saddle point routes a + rotated adjoint, so this solve is refused, naming the rotated constraint.""" + uw, model = _fresh_model() + sphere = uw.meshing.SphericalManifold(radius=1.0, cellSize=0.4, degree=1, qdegree=3) + x, y, z = sphere.X + sheet = uw.systems.ThinSheet(sphere) + sheet.constitutive_model = uw.constitutive_models.ViscousFlowModel + sheet.constitutive_model.Parameters.shear_viscosity_0 = 1 + sheet.add_basal_drag(1, reference_velocity=[2 * y * z, -2 * x * z, 0]) + model.tracker.time, model.tracker.step = 0.0, 0 + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + with model.step(0.1): + sheet.solve() + verdict = _adjoint_of(model, "solve") + assert verdict["supported"] is False + assert "rotated constraint" in verdict["reason"] + with pytest.raises(RuntimeError, match="refuses an adjoint"): + sheet.adjoint_solve(sheet.dm.getGlobalVec()) + + def test_an_unconverged_solve_is_refused_after_the_fact(mesh): """The structural verdict is written before the solve. A solve that then diverged is linearised about a state it never reached — the outcome must From 3ecbf6e7d960e1c9255c52ba3f676a0b92f10c25 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Thu, 29 Oct 2026 08:37:20 +1100 Subject: [PATCH 57/57] Fault-split multigrid: session drivers and figure scripts leave the repository Louis, 2026-10-28: library, tests and docs in underworld3; campaign drivers outside it. scripts/sessions/fault_mg/ (28 files: the ZG95 annulus drivers, the exploration stages, UWTN 2026-022's figure scripts) and the three scripts/sessions/thin_sheet_*.py go; they are kept with the campaign. The design note, the subsystem doc and test_1084 no longer point at them. Underworld development team with AI support from Claude Code --- .../design/FAULT_MULTIGRID_HIERARCHIES.md | 5 +- .../subsystems/fault-split-multigrid.md | 4 +- docs/developer/subsystems/thin-sheet.md | 4 +- scripts/sessions/fault_mg/CAMPAIGN.md | 63 -- scripts/sessions/fault_mg/cellP.py | 94 --- .../sessions/fault_mg/fig_fmg_behaviour.py | 103 ---- .../sessions/fault_mg/fig_hierarchy_render.py | 85 --- scripts/sessions/fault_mg/fig_ladder_depth.py | 82 --- .../sessions/fault_mg/fig_ladder_render.py | 127 ---- .../fault_mg/fig_load_decomposition.py | 81 --- .../sessions/fault_mg/fig_parallel_load.py | 105 ---- scripts/sessions/fault_mg/fig_schematics.py | 305 ---------- scripts/sessions/fault_mg/ladder_base.py | 163 ----- scripts/sessions/fault_mg/ladder_c.py | 166 ----- scripts/sessions/fault_mg/ladder_depth.py | 165 ----- .../sessions/fault_mg/ladder_depth_split.py | 161 ----- .../sessions/fault_mg/ladder_depth_split4.py | 161 ----- scripts/sessions/fault_mg/library_c.py | 103 ---- scripts/sessions/fault_mg/library_c_3d.py | 97 --- scripts/sessions/fault_mg/parallel_load.py | 142 ----- scripts/sessions/fault_mg/stage4_thick.py | 82 --- scripts/sessions/fault_mg/stage5_variants.py | 195 ------ scripts/sessions/fault_mg/stage6_moving.py | 205 ------- scripts/sessions/fault_mg/summarise_moving.py | 30 - scripts/sessions/fault_mg/thick_parallel.py | 69 --- scripts/sessions/fault_mg/zg95_annulus.py | 565 ------------------ scripts/sessions/fault_mg/zg95_jigsaw.py | 82 --- scripts/sessions/fault_mg/zg95_probe.py | 397 ------------ scripts/sessions/fault_mg/zg95_profile.py | 120 ---- scripts/sessions/fault_mg/zg95_render.py | 56 -- scripts/sessions/fault_mg/zg95_stream.py | 134 ----- scripts/sessions/thin_sheet_elsasser.py | 82 --- scripts/sessions/thin_sheet_fault_cycle.py | 265 -------- .../thin_sheet_quadratic_closed_form.py | 94 --- tests/test_1084_quadratic_stress_sphere.py | 2 +- 35 files changed, 8 insertions(+), 4586 deletions(-) delete mode 100644 scripts/sessions/fault_mg/CAMPAIGN.md delete mode 100644 scripts/sessions/fault_mg/cellP.py delete mode 100644 scripts/sessions/fault_mg/fig_fmg_behaviour.py delete mode 100644 scripts/sessions/fault_mg/fig_hierarchy_render.py delete mode 100644 scripts/sessions/fault_mg/fig_ladder_depth.py delete mode 100644 scripts/sessions/fault_mg/fig_ladder_render.py delete mode 100644 scripts/sessions/fault_mg/fig_load_decomposition.py delete mode 100644 scripts/sessions/fault_mg/fig_parallel_load.py delete mode 100644 scripts/sessions/fault_mg/fig_schematics.py delete mode 100644 scripts/sessions/fault_mg/ladder_base.py delete mode 100644 scripts/sessions/fault_mg/ladder_c.py delete mode 100644 scripts/sessions/fault_mg/ladder_depth.py delete mode 100644 scripts/sessions/fault_mg/ladder_depth_split.py delete mode 100644 scripts/sessions/fault_mg/ladder_depth_split4.py delete mode 100644 scripts/sessions/fault_mg/library_c.py delete mode 100644 scripts/sessions/fault_mg/library_c_3d.py delete mode 100644 scripts/sessions/fault_mg/parallel_load.py delete mode 100644 scripts/sessions/fault_mg/stage4_thick.py delete mode 100644 scripts/sessions/fault_mg/stage5_variants.py delete mode 100644 scripts/sessions/fault_mg/stage6_moving.py delete mode 100644 scripts/sessions/fault_mg/summarise_moving.py delete mode 100644 scripts/sessions/fault_mg/thick_parallel.py delete mode 100644 scripts/sessions/fault_mg/zg95_annulus.py delete mode 100644 scripts/sessions/fault_mg/zg95_jigsaw.py delete mode 100644 scripts/sessions/fault_mg/zg95_probe.py delete mode 100644 scripts/sessions/fault_mg/zg95_profile.py delete mode 100644 scripts/sessions/fault_mg/zg95_render.py delete mode 100644 scripts/sessions/fault_mg/zg95_stream.py delete mode 100644 scripts/sessions/thin_sheet_elsasser.py delete mode 100644 scripts/sessions/thin_sheet_fault_cycle.py delete mode 100644 scripts/sessions/thin_sheet_quadratic_closed_form.py diff --git a/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md b/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md index 24ac76f5b..dd36ac4c6 100644 --- a/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md +++ b/docs/developer/design/FAULT_MULTIGRID_HIERARCHIES.md @@ -3,8 +3,9 @@ Technical note, 2026-10-13. The exploration that chose the hierarchy; the library implementation (C, below) is governed by [subsystems/fault-split-multigrid.md](../subsystems/fault-split-multigrid.md). -Exploration drivers: `scripts/sessions/fault_mg/` (`stage*.py` monkey-patch the -solver; `library_c*.py` and `thick_parallel.py` go through the library). +The exploration drivers are kept with the campaign, outside this repository +(`stage*.py` monkey-patch the solver; `library_c*.py` and `thick_parallel.py` go +through the library). ## The question diff --git a/docs/developer/subsystems/fault-split-multigrid.md b/docs/developer/subsystems/fault-split-multigrid.md index 759ee5fd2..6a0207c38 100644 --- a/docs/developer/subsystems/fault-split-multigrid.md +++ b/docs/developer/subsystems/fault-split-multigrid.md @@ -167,8 +167,8 @@ welded tails converge with the serial slip; the thick twin extruded with its pai ## Not yet done - A regression test for the coarse-level wall rotation itself (a rotated free-slip - annulus with a placed band and the custom tail), beyond the example scripts - (`scripts/sessions/fault_mg/zg95_probe.py`, `zg95_annulus.py`). + annulus with a placed band and the custom tail), beyond the campaign's annulus + drivers. - The 3-D sheet placement on an NVB-refined child needs `clearance` of about 0.9 at one add-on level (its volume guard fires at the default 0.6) and refuses the two-level child at every clearance and sheet size tried (volume change 4e-6 to diff --git a/docs/developer/subsystems/thin-sheet.md b/docs/developer/subsystems/thin-sheet.md index ac9664a72..4cdf1f4e7 100644 --- a/docs/developer/subsystems/thin-sheet.md +++ b/docs/developer/subsystems/thin-sheet.md @@ -142,8 +142,8 @@ vertices back to the sphere. On the sphere a divergence-free toroidal drag thins a corotational Maxwell sheet at second order (the Weissenberg effect): $-\nabla_s\cdot\mathbf u/A^2 = (24/91)P_2 - (24/287)P_4$ in steady state, scaled by ETD-1's effective relaxation -time $\alpha\Delta t/(1-\alpha)$. `test_1084`; the closed form is derived by -`scripts/sessions/thin_sheet_quadratic_closed_form.py`. +time $\alpha\Delta t/(1-\alpha)$. `test_1084`; the closed form is derived with +sympy (the derivation is kept with the campaign, outside this repository). ## Round 2 (not yet) diff --git a/scripts/sessions/fault_mg/CAMPAIGN.md b/scripts/sessions/fault_mg/CAMPAIGN.md deleted file mode 100644 index 5ee3e994c..000000000 --- a/scripts/sessions/fault_mg/CAMPAIGN.md +++ /dev/null @@ -1,63 +0,0 @@ -# ZG95 fault convection campaign: hand-over (2026-10-15) - -Driver `zg95_annulus.py` (annulus, Rayleigh-scaled yielding lid, SUPG transport, -rotated free-slip, split-node fault on the add-on hierarchy), renderer -`zg95_stream.py` (surface velocity over streamlines; red fault trace; vertical -exaggeration 2 of the unrolled section). Outputs live on hyperion under -`~/+Simulations/zg95//` (checkpoints every 25 steps: `zg95.mesh.*.h5`, -`state_NNNN.json`, `records.json`, `surface_NNNN.npz` with the trench trace, -`stream_NNNN.png`, `step_NNNN.png`). Frames are on the wiki page `Fault-multigrid`. - -## Physics (Moresi & Solomatov 1998 reference case) -Ra0 = 1e7 on the basal viscosity (`--eta_ref 1`), contrast 3e4, yield 1e5 + 1e7 z -(tau1/Ra0 = 1, mobile per their Fig 6), internal heating 7, interior from 0.8, -base H 0.16 with 2 halvings (finest 0.04), P2 velocity. - - --H 0.16 --ref 2 --ra 1e7 --eta_ref 1.0 --advect supg --heating 7 --deta 3e4 - --yield0 1e5 --yield_depth 1e7 --T_int 0.8 --dt_mult 3 --dt_max 1e-3 - --picard 2 --yield_soft 0.2 --fig_every 50 --ckpt_every 50 - -## Runs -- `conv_ms98` (control, 1500 spin-up steps, DONE): steady six-cell mobile lid from - step ~100: mobility 0.5-0.7, lid 0.105, a third of the lid yielding, T_mid 0.70, - plateau surface speeds ~80, 27-35 s/step at 8 ranks (tol 1e-3, Newton 6-8). -- `conv_ms98_step` (restart from ckpt 100 with `--deta 1e5 --yield0 2e5`, DONE): - sluggish distributed-deformation regime, lid 0.15, mobility 0.2-0.4, no overturn. -- `fault_ms98_split` (fault phase from ckpt 150, 300 steps, trench velocity sampled - 1.7 deg into the hanging wall -> trench effectively frozen, DONE): localised slip - (85-120), stationary sliver, one-sided dipping slab (t 0.12-0.165), slab break-off - (0.177), overturn (0.183, overriding lid at 250-300), ridge jump to the trench - (0.19+). Lid yielding mean 0.40 vs control 0.33. Montage: `sequence.png`. -- `fault_ms98_move` (same, trench follows the overriding plate: median surface - velocity over 0.1-0.3 rad beyond the fault, `--trench_off/--trench_span`; - RUNNING on hyperion, 8 ranks, ~2 min/step): the slab follows the advancing - fault at ~12 deg lag, still symmetric at t 0.093. - -Fault phase launch (from a control checkpoint N copied into the run directory): - - cp conv_ms98/*000NN* conv_ms98/zg95.mesh.00000.h5 conv_ms98/state_0NN.json conv_ms98/records.json / - mpirun -np 8 python zg95_annulus.py --out --restart N --spinup 0 --steps 300 --arm split \ - --tol 1e-2 --newton_switch 1e-2 --fig_every 10 --ckpt_every 25 --verbose - -## Solver state (the item to fix before production) -On the faulted add-on the pressure (Schur) solve hits `fieldsplit_pres_ksp_max_it` -= 200 once the nonlinear residual falls below ~1e-2 of its start, under the Newton -tangent AND under Picard (42 Picard iterations at tol 1e-3, cap from iteration 14). -The base mesh never shows it. Each pressure iteration nests a full velocity solve, -so a capped solve costs ~3400 MG cycles (~10 min). Hypotheses: the nested Schur -stalls on the inner-solve noise floor once the outer residual is small; or the -yielded viscosity on the faulted add-on breaks the 1/eta Schur preconditioner. -Probe: one step at np 2 from ckpt 150 with the pressure KSP monitor and converged -reason per outer iteration; compare base vs add-on; check the inner velocity rtol -against the pressure rtol (0.033 x tol). The fault runs above use tol 1e-2, -Picard-only (`--newton_switch 1e-2`) to stay below the cap. Warm start across -add-on rebuilds (`AddOn.warm_start`) halves the step cost. - -## Other open items -- Dynamic topography sign: peaks over the downwellings, lows over upwellings in the - step figures; check the reaction sign convention before quantitative use. -- Parallel-safety lessons applied in the driver (keep them): mesh-variable writes - are collective, decide on every rank; cross-mesh evaluation of an expression with - velocity gradients fails, evaluate on the owning mesh into a P1 variable and - transfer that; build-line counts are global reductions. -- Wiki page `Fault-multigrid` carries the frames; keep it current. diff --git a/scripts/sessions/fault_mg/cellP.py b/scripts/sessions/fault_mg/cellP.py deleted file mode 100644 index 5bd5f4440..000000000 --- a/scripts/sessions/fault_mg/cellP.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Cell-located, side-aware Lagrange prolongation between (possibly split, non-nested) meshes. - -For each fine node: one fine cell containing it -> that cell's CENTROID located in the coarse -mesh (a centroid is never on a fault, so the coarse cell is on the node's own side) -> the coarse -cell's interpolant evaluated at the node by the dual-basis identity W = B M^-1 (as in -custom_mg.nested_refine_pair_prolongation). Exact for nested pairs; for non-nested pairs it -reproduces the coarse polynomial of the located cell (global polynomials of the degree exactly). -""" -import itertools -import numpy as np -import scipy.sparse as sp -from scipy.spatial import cKDTree -import os -CLAMP = os.environ.get("CELLP_CLAMP", "1") == "1" - - -def _cell_vertices(mesh): - dm = mesh.dm - dim = dm.getDimension() - cS, cE = dm.getHeightStratum(0) - vS, vE = dm.getDepthStratum(0) - vxy = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, dm.getCoordinateDim()) - cv = np.empty((cE - cS, dim + 1), dtype=np.int64) - for c in range(cS, cE): - cv[c - cS] = [q - vS for q in dm.getTransitiveClosure(c)[0] if vS <= q < vE] - return vxy, cv - - -def _barycentric(P, verts): - """barycentric coords of points P (n,d) in simplices verts (n,d+1,d).""" - A = np.transpose(verts[:, 1:] - verts[:, :1], (0, 2, 1)) - lam = np.linalg.solve(A, (P - verts[:, 0])[..., None])[..., 0] - return np.concatenate([1 - lam.sum(axis=1, keepdims=True), lam], axis=1) - - -def locate_cells(points, mesh, k=16): - vxy, cv = _cell_vertices(mesh) - cent = vxy[cv].mean(axis=1) - tree = cKDTree(cent) - _, cand = tree.query(points, k=min(k, len(cent))) - best = np.full(len(points), -1) - bestmin = np.full(len(points), -np.inf) - for j in range(cand.shape[1]): - c = cand[:, j] - b = _barycentric(points, vxy[cv[c]]) - m = b.min(axis=1) - better = m > bestmin - best[better] = c[better] - bestmin[better] = m[better] - return best, bestmin - - -def cell_located_prolongation(coarse, fine, degree=2, continuous=True): - cn = coarse._cell_node_indices(degree, continuous) - fn = fine._cell_node_indices(degree, continuous) - Xc = np.asarray(coarse._get_coords_for_basis(degree, continuous)) - Xf = np.asarray(fine._get_coords_for_basis(degree, continuous)) - dim = coarse.dm.getDimension() - vxy_c, cv_c = _cell_vertices(coarse) - vxy_f, cv_f = _cell_vertices(fine) - owner = np.full(Xf.shape[0], -1, dtype=np.int64) - for kk in range(fn.shape[0]): - owner[fn[kk]] = kk - assert (owner >= 0).all() - fcent = vxy_f[cv_f].mean(axis=1) - parent_cell, inside = locate_cells(fcent, coarse) - parent_of = parent_cell[owner] - E = np.asarray([e for e in itertools.product(range(degree + 1), repeat=dim) if sum(e) <= degree]) - vander = lambda L: np.prod(L[:, None, :] ** E[None, :, :], axis=2) - order = np.argsort(parent_of, kind="stable") - runs = np.flatnonzero(np.diff(parent_of[order])) + 1 - rows, cols, vals = [], [], [] - for grp in np.split(order, runs): - c = int(parent_of[grp[0]]) - verts = vxy_c[cv_c[c]] - Ainv = np.linalg.inv((verts[1:] - verts[0]).T) - Lc = (Xc[cn[c]] - verts[0]) @ Ainv.T - Lf = (Xf[grp] - verts[0]) @ Ainv.T - if CLAMP: - # evaluate at the nearest point of the located cell: a fine node a little - # outside it (non-nested levels near a pulled tip) is not extrapolated, so - # the weights stay bounded (the Lagrange basis inside the simplex) - b = np.column_stack([1 - Lf.sum(axis=1), Lf]) - if (b < 0).any(): - b = np.clip(b, 0, None); b /= b.sum(axis=1, keepdims=True) - Lf = b[:, 1:] - W = np.linalg.solve(vander(Lc).T, vander(Lf).T).T - for ti, t in enumerate(grp): - keep = np.flatnonzero(np.abs(W[ti]) > 1e-13) - rows.extend([int(t)] * keep.size) - cols.extend(int(cn[c][j]) for j in keep) - vals.extend(float(W[ti, j]) for j in keep) - P = sp.csr_matrix((vals, (rows, cols)), shape=(Xf.shape[0], Xc.shape[0])) - return P, inside.min() diff --git a/scripts/sessions/fault_mg/fig_fmg_behaviour.py b/scripts/sessions/fault_mg/fig_fmg_behaviour.py deleted file mode 100644 index 14b66d2af..000000000 --- a/scripts/sessions/fault_mg/fig_fmg_behaviour.py +++ /dev/null @@ -1,103 +0,0 @@ -"""FMG behaviour plots from the measured tables (exploration note and the library -runs of 2026-10-13): iterations against refinement for the four strategies, the -Coulomb Newton sequence, and the moving-fault cost breakdown. - -python fig_fmg_behaviour.py -""" -import os -import sys - -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt -import numpy as np - -out = sys.argv[1] if len(sys.argv) > 1 else "." -os.makedirs(out, exist_ok=True) -k = np.array([1, 2, 3, 4, 5]) -h = 0.05 / 2.0**k -# exploration (prototype drivers, FAULT_MULTIGRID_HIERARCHIES.md) -fric = {"A": [9, 13, 18, 23, 32], "B2": [10, 14, 22, 30, 46], "C": [10, 7, 7, 6, 5], - "D": [14, 9, 6, 6, 5]} -visc = {"A": [9, 12, 17, 22, 30], "B2": [9, 19, 53, 200, 200], "C": [9, 7, 6, 6, 5], - "D": [17, 9, 8, 7, 6]} -# library path (tests/test_1087, scripts/sessions/fault_mg/library_c.py), serial -lib = {"C": {2: 6, 3: 5, 4: 4}, "A": {2: 13, 3: 18, 4: 23}} -col = {"A": "#2c3e50", "B2": "#7f8c8d", "C": "#c0392b", "D": "#e67e22"} - -fig, axes = plt.subplots(1, 3, figsize=(14, 4.3)) -for ax, data, title in ((axes[0], fric, "frictionless fault"), (axes[1], visc, "viscous fault")): - for name, its in data.items(): - its = np.array(its, dtype=float) - capped = its >= 200 - ax.plot(h, np.where(capped, np.nan, its), "o-", color=col[name], label=name) - if capped.any(): - ax.plot(h[capped], np.full(capped.sum(), 60), "x", color=col[name], ms=9, mew=2) - ax.text(h[capped][0], 64, "cap (200)", fontsize=7.5, color=col[name], ha="center") - if title.startswith("friction"): - for name in ("A", "C"): - kk = np.array(sorted(lib[name])) - ax.plot(0.05 / 2.0**kk, [lib[name][q] for q in kk], "s", mfc="none", ms=9, - color=col[name], label=f"{name} (library)") - ax.set_xscale("log") - ax.invert_xaxis() - ax.set_xlabel("near-fault cell size h") - ax.set_ylabel("velocity iterations per solve") - ax.set_title(title, loc="left", fontsize=10) - ax.set_ylim(0, 70) - ax.grid(alpha=0.3) - ax.legend(fontsize=8) -ax = axes[2] -steps = np.arange(1, 5) -coul = {"A k=1": [5, 15, 17, 17], "C k=1": [5, 15, 17, 17], "A k=3": [9, 21, 24, 24], - "C k=3": [4, 14, 15, 16], "A k=5": [15, 38, 45, 50], "C k=5": [3, 13, 15, 15]} -for name, its in coul.items(): - ax.plot(steps, its, "o-" if name.startswith("C") else "s--", - color=col[name[0]], alpha={1: 0.4, 3: 0.7, 5: 1.0}[int(name[-1])], label=name) -ax.set_xlabel("Newton step") -ax.set_ylabel("velocity iterations") -ax.set_title("Coulomb fault, per Newton step", loc="left", fontsize=10) -ax.set_xticks(steps) -ax.grid(alpha=0.3) -ax.legend(fontsize=8, ncol=2) -fig.suptitle("Iterations of the velocity block (FMG F-cycle preconditioner, FGMRES) as the fault is " - "refined: box in simple shear, straight fault", fontsize=10) -fig.tight_layout() -fig.savefig(os.path.join(out, "fmg_iterations_vs_h.png"), dpi=160) -plt.close(fig) - -# moving fault, k = 5, seconds per step (exploration), and k = 4 library at np 1 / 8 -fig, axes = plt.subplots(1, 2, figsize=(11, 4.2)) -ax = axes[0] -stages = ["adapt", "split finest", "split levels", "level frames", "background TI", "solve"] -rows = {"A": [3.1, 1.9, 0, 0, 0, 137], "C": [2.7, 1.7, 2.8, 1.0, 0, 38], - "D": [4.7, 2.9, 5.1, 1.8, 2.4, 61], "GAMG": [3.8, 2.4, 0, 0, 0, 127]} -cmap = plt.get_cmap("tab20c") -bottom = np.zeros(len(rows)) -names = list(rows) -for i, st in enumerate(stages): - vals = np.array([rows[n][i] for n in names]) - ax.bar(names, vals, bottom=bottom, color=cmap(i * 2), label=st) - bottom += vals -for j, n in enumerate(names): - ax.text(j, bottom[j] + 2, f"{bottom[j]:.0f} s", ha="center", fontsize=8.5) -ax.set_ylabel("seconds per step (prototype drivers, k = 5, 1 rank)") -ax.set_title("moving fault: cost per step by stage", loc="left", fontsize=10) -ax.legend(fontsize=8) -ax = axes[1] -lib_mov = {"1 rank": {"adapt": 2.9, "cut + split all levels": 4.6, "solver build": 0.5, "solve": 28.6}, - "8 ranks": {"adapt": 1.0, "cut + split all levels": 1.8, "solver build": 0.45, "solve": 12.4}} -bottom = np.zeros(2) -for i, st in enumerate(lib_mov["1 rank"]): - vals = np.array([lib_mov[n][st] for n in lib_mov]) - ax.bar(list(lib_mov), vals, bottom=bottom, color=cmap(i * 2 + 1), label=st) - bottom += vals -for j, n in enumerate(lib_mov): - ax.text(j, bottom[j] + 0.5, f"{bottom[j]:.0f} s", ha="center", fontsize=8.5) -ax.set_ylabel("seconds per step (library, C, k = 4)") -ax.set_title("library path, mean of three positions", loc="left", fontsize=10) -ax.legend(fontsize=8) -fig.tight_layout() -fig.savefig(os.path.join(out, "moving_fault_cost.png"), dpi=160) -plt.close(fig) -print("ok") diff --git a/scripts/sessions/fault_mg/fig_hierarchy_render.py b/scripts/sessions/fault_mg/fig_hierarchy_render.py deleted file mode 100644 index 3f2b68034..000000000 --- a/scripts/sessions/fault_mg/fig_hierarchy_render.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Render the actual split-level hierarchy near the fault: one panel per level, -coarsest to finest, mesh edges with the Plus and Minus facets in two colours -(the slit opened by a small offset so the two copies are visible). - -python fig_hierarchy_render.py -""" -import sys - -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt -import numpy as np -from matplotlib.collections import LineCollection - -import underworld3 as uw - -k = int(sys.argv[1]) -out = sys.argv[2] -A_, B_ = np.array([0.30, 0.45]), np.array([0.70, 0.55]) -base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), cellSize=0.1, - refinement=1, qdegree=2) -pts3 = np.column_stack([np.linspace(A_[0], B_[0], 9), np.linspace(A_[1], B_[1], 9), np.zeros(9)]) -surf = uw.meshing.Surface("g", base, pts3) -surf.discretize() -child = base.adapt(surf.refinement_metric_function(h_near=0.05 / 2**k, h_far=0.05, width=0.08, - profile="linear"), max_levels=k) -mesh = child.add_fault([("Flt", np.vstack([A_, B_]))]) -levels = list(mesh._custom_mg_coarse_meshes) + [mesh] - - -def edges_of(m, offset): - """all edges as segments; fault-side facets returned separately, shifted - by ``offset`` along the fault normal so the two copies separate""" - dm = m.dm - eS, eE = dm.getDepthStratum(1) - vS, vE = dm.getDepthStratum(0) - X = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, 2) - n = np.array([-(B_ - A_)[1], (B_ - A_)[0]]) - n /= np.linalg.norm(n) - sides = {} - for side, sgn in (("Plus", 1.0), ("Minus", -1.0)): - name = f"Flt{side}" - if name in [b.name for b in m.boundaries]: - lab = dm.getLabel(name) - value = m.boundaries[name].value - if lab and lab.getStratumSize(value) > 0: - sides[side] = (set(int(e) for e in lab.getStratumIS(value).getIndices() - if eS <= int(e) < eE), sgn) - segs, fsegs = [], {"Plus": [], "Minus": []} - for e in range(eS, eE): - a, b = (int(q) - vS for q in dm.getCone(e)) - seg = np.array([X[a], X[b]]) - placed = False - for side, (es, sgn) in sides.items(): - if e in es: - fsegs[side].append(seg + sgn * offset * n) - placed = True - if not placed: - segs.append(seg) - return segs, fsegs - - -fig, axes = plt.subplots(1, len(levels), figsize=(3.2 * len(levels), 3.6)) -for ax, m, li in zip(np.atleast_1d(axes), levels, range(len(levels))): - segs, fsegs = edges_of(m, offset=0.004) - ax.add_collection(LineCollection(segs, colors="#777", linewidths=0.5)) - for side, col in (("Plus", "#c0392b"), ("Minus", "#1a5276")): - if fsegs[side]: - ax.add_collection(LineCollection(fsegs[side], colors=col, linewidths=1.8)) - static = getattr(m, "_mg_static", False) - split = bool(getattr(m, "_fault_point_pairs", None)) - cells = m.dm.getHeightStratum(0)[1] - kind = "background" if static else ("finest" if m is mesh else "add-on") - ax.set_title(f"level {li}: {kind}\n{cells} cells, {'split' if split else 'welded'}", - fontsize=9) - ax.set_xlim(0.22, 0.78) - ax.set_ylim(0.32, 0.68) - ax.set_aspect("equal") - ax.set_xticks([]) - ax.set_yticks([]) -fig.suptitle(f"Split-level hierarchy (C), k = {k}: red Plus and blue Minus facets drawn " - "0.004 apart; the background is never cut", fontsize=10) -fig.tight_layout() -fig.savefig(out, dpi=170) -print("wrote", out) diff --git a/scripts/sessions/fault_mg/fig_ladder_depth.py b/scripts/sessions/fault_mg/fig_ladder_depth.py deleted file mode 100644 index 90eac731a..000000000 --- a/scripts/sessions/fault_mg/fig_ladder_depth.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Iterations of the single-level ladder and its tails as the band deepens: -from ladder_c.json, ladder_depth.json, ladder_depth_split*.json and -ladder_base_ref2.json (test_1022 rig). - -python fig_ladder_depth.py -""" -import json -import os -import sys - -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt - -out = sys.argv[1] -D = os.path.expanduser("~/+Simulations/fault_split_mg") -recs = [] -for f in ("ladder_c.json", "ladder_depth.json", "ladder_depth_split.json", - "ladder_depth_split4.json", "ladder_base_ref2.json"): - recs += json.load(open(os.path.join(D, f))) -by = {r["arm"]: r["vel_its"] for r in recs} - -fig, axes = plt.subplots(1, 2, figsize=(12, 4.3)) -# left: contact -ax = axes[0] -ax.plot([0.01, 0.005, 0.0025], [by["fine 0.01: no mid"], by["fine 0.005: no mid"], - by["fine 0.0025: no mid"]], "o-", color="#c0392b", - label="single ladder on base h = 1/16 (no mid)") -ax.plot([0.005, 0.0025], [by["ref 2 fine 0.005: no mid"], by["ref 2 fine 0.0025: no mid"]], - "s-", color="#1a5276", label="single ladder on base h = 1/32 (no mid)") -ax.plot([0.0025] * 3, [by["fine 0.0025: welded mid [0.005]"], - by["fine 0.0025: welded mid [0.01, 0.005]"], - by["fine 0.0025: welded mid [0.02, 0.01, 0.005]"]], - "v", color="#7f8c8d", ms=9, label="h = 1/16 + 1, 2, 3 welded mids") -ax.plot([0.0025] * 4, [by["fine 0.0025: split mid [0.005]"], - by["fine 0.0025: split mid [0.01, 0.005]"], - by["fine 0.0025: split mid [0.02, 0.01, 0.005]"], - by["fine 0.0025: split mid [0.04, 0.02, 0.01, 0.005]"]], - "^", color="#e67e22", ms=9, mfc="none", label="h = 1/16 + 1, 2, 3, 4 split mids") -ax.annotate("3 and 4 split mids\n(chain from 0.02)", (0.0025, 13), (0.0045, 15), fontsize=8, - arrowprops=dict(arrowstyle="-", color="#e67e22")) -ax.annotate("1, 2 split or any welded", (0.0025, 21), (0.0045, 22), fontsize=8, - arrowprops=dict(arrowstyle="-", color="#7f8c8d")) -ax.set_xscale("log", base=2) -ax.invert_xaxis() -ax.set_xticks([0.01, 0.005, 0.0025]) -ax.set_xticklabels(["0.01\n(6 | 3)", "0.005\n(12 | 6)", "0.0025\n(25 | 12)"]) -ax.set_xlabel("ladder spacing (ratio to base finest: h = 1/16 | h = 1/32)") -ax.set_ylabel("velocity iterations") -ax.set_title("split-node contact on the ladder", loc="left", fontsize=10) -ax.legend(fontsize=7.5) -ax.grid(alpha=0.3) -# right: TI -ax = axes[1] -ax.plot([0.01, 0.005, 0.0025], [by["TI fine 0.01: no mid"], by["TI fine 0.005: no mid"], - by["TI fine 0.0025: no mid"]], "o-", color="#c0392b", - label="single ladder on base h = 1/16 (no mid)") -ax.plot([0.005, 0.0025], [by["TI ref 2 fine 0.005: no mid"], by["TI ref 2 fine 0.0025: no mid"]], - "s-", color="#1a5276", label="single ladder on base h = 1/32 (no mid)") -ax.plot([0.01, 0.005], [by["TI fine 0.01: welded mid [0.02]"], - by["TI fine 0.005: welded mid [0.02, 0.01]"]], "v--", color="#7f8c8d", - label="h = 1/16 + all nested welded mids") -ax.plot([0.0025] * 3, [by["TI fine 0.0025: welded mid [0.005]"], - by["TI fine 0.0025: welded mid [0.01, 0.005]"], - by["TI fine 0.0025: welded mid [0.02, 0.01, 0.005]"]], - "v", color="#7f8c8d", ms=9) -ax.annotate("1, 2, 3 welded mids", (0.0025, 5), (0.0045, 3.2), fontsize=8, - arrowprops=dict(arrowstyle="-", color="#7f8c8d")) -ax.set_xscale("log", base=2) -ax.invert_xaxis() -ax.set_xticks([0.01, 0.005, 0.0025]) -ax.set_xticklabels(["0.01\n(6 | 3)", "0.005\n(12 | 6)", "0.0025\n(25 | 12)"]) -ax.set_xlabel("ladder spacing (ratio to base finest: h = 1/16 | h = 1/32)") -ax.set_title("TI band on the ladder (contrast 1e-3)", loc="left", fontsize=10) -ax.legend(fontsize=7.5) -ax.grid(alpha=0.3) -ax.set_ylim(0, 10) -fig.suptitle("A single finest ladder over the welded base, and what the tail buys as the band deepens", - fontsize=10) -fig.tight_layout() -fig.savefig(out, dpi=160) -print("wrote", out) diff --git a/scripts/sessions/fault_mg/fig_ladder_render.py b/scripts/sessions/fault_mg/fig_ladder_render.py deleted file mode 100644 index bd8402bb8..000000000 --- a/scripts/sessions/fault_mg/fig_ladder_render.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Render the nested ladder hierarchy of ladder_c.py near the band: the two -background levels and the placed ribbons at spacing 0.04, 0.02, 0.01, each -spine split (Plus red, Minus blue, drawn apart), band cells shaded. - -python fig_ladder_render.py -""" -import sys - -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt -import numpy as np -from matplotlib.collections import LineCollection, PolyCollection - -import underworld3 as uw -from underworld3.utilities.place_surface import place_thin_volume - -out = sys.argv[1] -H_BG = 1.0 / 8 -WIDTH = 0.06 -TRACE = np.array([[0.15, 0.42], [0.85, 0.58]]) -TIP = 0.04 -FAULT = "Main" -a, b = TRACE[0], TRACE[-1] -t_hat = (b - a) / np.linalg.norm(b - a) -L = float(np.linalg.norm(b - a)) -n_hat = np.array([-t_hat[1], t_hat[0]]) - - -def banded(base, size): - for clearance in (0.55, 0.7, 0.9): - try: - dm, _info = place_thin_volume( - base.dm, [TRACE], width=WIDTH, label="Zone", label_value=31, - size=size, clearance=clearance, assembly="fuse", mesher="ladder") - return uw.discretisation.Mesh( - dm, simplex=True, qdegree=2, boundaries=base.boundaries, - coordinate_system_type=base.CoordinateSystem.coordinate_type) - except RuntimeError: - continue - raise RuntimeError(f"band build failed at size {size}") - - -def spine_line(size): - n_along = max(2, int(round(L / size))) - step = L / n_along - k0 = int(round(TIP / step)) - return a + (np.arange(k0, n_along - k0 + 1) * step)[:, None] * t_hat - - -def edges_of(m, offset): - dm = m.dm - eS, eE = dm.getDepthStratum(1) - vS, vE = dm.getDepthStratum(0) - X = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, 2) - sides = {} - for side, sgn in (("Plus", 1.0), ("Minus", -1.0)): - name = f"{FAULT}{side}" - if name in [bd.name for bd in m.boundaries]: - lab = dm.getLabel(name) - value = m.boundaries[name].value - if lab and lab.getStratumSize(value) > 0: - sides[side] = (set(int(e) for e in lab.getStratumIS(value).getIndices() - if eS <= int(e) < eE), sgn) - segs, fsegs = [], {"Plus": [], "Minus": []} - for e in range(eS, eE): - p, q = (int(r) - vS for r in dm.getCone(e)) - seg = np.array([X[p], X[q]]) - placed = False - for side, (es, sgn) in sides.items(): - if e in es: - fsegs[side].append(seg + sgn * offset * n_hat) - placed = True - if not placed: - segs.append(seg) - return segs, fsegs - - -def band_polys(m): - dm = m.dm - cS, cE = dm.getHeightStratum(0) - vS, vE = dm.getDepthStratum(0) - X = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, 2) - try: - mask = m.cells_labelled("Zone", 31) - except Exception: - return [] - polys = [] - for c in np.nonzero(mask)[0]: - cl = dm.getTransitiveClosure(int(c) + cS)[0] - vs = [int(p) - vS for p in cl if vS <= p < vE] - polys.append(X[vs]) - return polys - - -base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), cellSize=H_BG, - regular=False, qdegree=2, refinement=1) -levels = base._coarse_level_meshes()[:-1] + [base] -titles = ["background 0", "background 1"] -for size in (0.04, 0.02, 0.01): - m = banded(base, size).add_fault((FAULT, spine_line(size)), split_levels=False) - levels.append(m) - titles.append(f"ladder {size}, split") - -fig, axes = plt.subplots(1, len(levels), figsize=(3.3 * len(levels), 3.5)) -for ax, m, t in zip(axes, levels, titles): - polys = band_polys(m) - if polys: - ax.add_collection(PolyCollection(polys, facecolors="#dff0d8", edgecolors="none")) - segs, fsegs = edges_of(m, offset=0.003) - ax.add_collection(LineCollection(segs, colors="#777", linewidths=0.5)) - for side, col in (("Plus", "#c0392b"), ("Minus", "#1a5276")): - if fsegs[side]: - ax.add_collection(LineCollection(fsegs[side], colors=col, linewidths=1.6)) - cells = m.dm.getHeightStratum(0)[1] - ax.set_title(f"{t}\n{cells} cells", fontsize=9) - ax.set_xlim(0.3, 0.7) - ax.set_ylim(0.37, 0.63) - ax.set_aspect("equal") - ax.set_xticks([]) - ax.set_yticks([]) -fig.suptitle("The nested ladder hierarchy (test_1022 rig): base levels welded and untouched; " - "the band (green) placed at 0.04, 0.02, 0.01 and its spine split on each", - fontsize=10) -fig.tight_layout() -fig.savefig(out, dpi=170) -print("wrote", out) diff --git a/scripts/sessions/fault_mg/fig_load_decomposition.py b/scripts/sessions/fault_mg/fig_load_decomposition.py deleted file mode 100644 index 66b2f313c..000000000 --- a/scripts/sessions/fault_mg/fig_load_decomposition.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Where the parallel imbalance comes from: the base levels, the adapt child -(partition inherited from the base), and the gathered fault star, from -parallel_load.py's records. - -python fig_load_decomposition.py [tag=_b0.025] [k=3] -""" -import json -import os -import sys - -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt -import numpy as np - -out = sys.argv[1] -tag = sys.argv[2] if len(sys.argv) > 2 else "_b0.025" -k = int(sys.argv[3]) if len(sys.argv) > 3 else 3 -os.makedirs(out, exist_ok=True) -runs = {} -for n in (4, 8, 16): - f = os.path.expanduser(f"~/+Simulations/fault_split_mg/load_k{k}{tag}_np{n}.json") - if os.path.exists(f): - runs[n] = json.load(open(f))[0] -nps = sorted(runs) - - -def imb(cells, n): - return max(cells) / (sum(cells) / n) - - -fig, axes = plt.subplots(1, 2, figsize=(13, 4.4), gridspec_kw={"width_ratios": [1, 1.5]}) -ax = axes[0] -labels = ["base, coarsest", "base, finest", "adapt child\n(no fault)", "split finest\n(child + star)"] -cols = ["#9bb0c4", "#6d8aa6", "#e8c872", "#c0392b"] -w = 0.8 / len(labels) -for j, (lab, col) in enumerate(zip(labels, cols)): - ys = [] - for n in nps: - rec = runs[n] - load = rec["load"] - base = [lv for lv in load if lv["static"]] - vals = [imb(base[0]["owned_cells"], n), imb(base[-1]["owned_cells"], n), - imb(rec["load_unsplit_child"], n), imb(load[-1]["owned_cells"], n)] - ys.append(vals[j]) - x = np.arange(len(nps)) + (j - (len(labels) - 1) / 2) * w - ax.bar(x, ys, w, color=col, label=lab) - for xi, yi in zip(x, ys): - ax.text(xi, yi + 0.05, f"{yi:.1f}", ha="center", va="bottom", fontsize=7.5) -ax.axhline(1.0, color="k", lw=0.6) -ax.set_xticks(np.arange(len(nps))) -ax.set_xticklabels([f"{n} ranks" for n in nps]) -ax.set_ylabel("max / mean owned cells") -ax.set_title("the base is balanced; the child inherits the base's partition", loc="left", fontsize=10) -ax.legend(fontsize=8, loc="upper left") - -ax = axes[1] -n = nps[-1] -rec = runs[n] -load = rec["load"] -base_f = np.array([lv for lv in load if lv["static"]][-1]["owned_cells"]) -child = np.array(rec["load_unsplit_child"]) -fin = np.array(load[-1]["owned_cells"]) -star = np.array(load[-1]["fault_star"]) -x = np.arange(n) -w = 0.27 -ax.bar(x - w, base_f, w, color="#6d8aa6", label="base, finest level") -ax.bar(x, child, w, color="#e8c872", label="adapt child, no fault") -ax.bar(x + w, fin, w, color="#f2c2b0", edgecolor="#c0392b", label="split finest") -ax.bar(x + w, star, w, color="none", edgecolor="k", hatch="///", lw=0.5, label="of which fault star") -ax.axhline(fin.sum() / n, color="#c0392b", lw=0.8, ls="--", label="perfect balance, finest") -ax.axhline(base_f.sum() / n, color="#6d8aa6", lw=0.8, ls="--") -ax.set_xticks(x) -ax.set_xlabel("rank") -ax.set_ylabel("owned cells") -ax.set_title(f"{n} ranks: cells per rank on the base's finest level, the adapt child and the split finest", - loc="left", fontsize=10) -ax.legend(fontsize=8, ncol=2) -fig.tight_layout() -fig.savefig(os.path.join(out, f"load_decomposition_k{k}{tag}.png"), dpi=160) -print("ok", nps) diff --git a/scripts/sessions/fault_mg/fig_parallel_load.py b/scripts/sessions/fault_mg/fig_parallel_load.py deleted file mode 100644 index d62092124..000000000 --- a/scripts/sessions/fault_mg/fig_parallel_load.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Parallel behaviour plots from parallel_load.py's records. - -python fig_parallel_load.py [tag] (reads ~/+Simulations/fault_split_mg/load_k_np*.json) -""" -import glob -import json -import os -import sys - -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt -import numpy as np - -k = int(sys.argv[1]) -out = sys.argv[2] -tag = sys.argv[3] if len(sys.argv) > 3 else "" -os.makedirs(out, exist_ok=True) -runs = {} -for f in glob.glob(os.path.expanduser(f"~/+Simulations/fault_split_mg/load_k{k}{tag}_np*.json")): - rec = json.load(open(f))[0] - runs[rec["np"]] = rec -nps = sorted(runs) -print("ranks:", nps) - -# 1. owned cells per rank on every level, for the largest two rank counts -big = [n for n in nps if n > 1][-2:] -fig, axes = plt.subplots(1, len(big), figsize=(6.2 * len(big), 4.4), squeeze=False) -for ax, n in zip(axes[0], big): - rec = runs[n] - load = rec["load"] - nlev = len(load) - width = 0.8 / n - for r in range(n): - own = [lv["owned_cells"][r] for lv in load] - star = [lv["fault_star"][r] for lv in load] - x = np.arange(nlev) + (r - (n - 1) / 2) * width - ax.bar(x, own, width, color=plt.get_cmap("tab10")(r), label=f"rank {r}") - ax.bar(x, star, width, color="none", edgecolor="k", hatch="///", lw=0.5, - label="fault star" if r == 0 else None) - ideal = [sum(lv["owned_cells"]) / n for lv in load] - ax.plot(np.arange(nlev), ideal, "k_", ms=14, label="perfect balance") - labels = [("bg" if lv["static"] else ("finest" if i == nlev - 1 else "add-on")) - + ("\nsplit" if lv["split"] else "") for i, lv in enumerate(load)] - ax.set_xticks(np.arange(nlev)) - ax.set_xticklabels(labels, fontsize=8) - ax.set_ylabel("owned cells") - ax.set_title(f"{n} ranks, k = {k}{tag}: cells per rank on each level", loc="left", fontsize=10) - ax.legend(fontsize=7.5, ncol=2) -fig.tight_layout() -fig.savefig(os.path.join(out, f"parallel_load_k{k}{tag}.png"), dpi=160) -plt.close(fig) - -# 2. imbalance (max / mean owned cells) per level vs ranks; misses; iterations; times -fig, axes = plt.subplots(1, 3, figsize=(14, 4.2)) -ax = axes[0] -for n in nps: - if n == 1: - continue - load = runs[n]["load"] - imb = [max(lv["owned_cells"]) / (sum(lv["owned_cells"]) / n) for lv in load] - ax.plot(range(len(load)), imb, "o-", label=f"{n} ranks") - child = runs[n]["load_unsplit_child"] - ax.plot([len(load) - 1], [max(child) / (sum(child) / n)], "k_", ms=12) -ax.axhline(1.0, color="k", lw=0.6) -ax.set_xlabel("level (0 = coarsest background)") -ax.set_ylabel("max / mean owned cells") -ax.set_title("imbalance per level (dash: unsplit adapt child)", loc="left", fontsize=10) -ax.legend(fontsize=8) -ax.grid(alpha=0.3) -ax = axes[1] -for n in nps: - st = runs[n].get("transfer_stats") or [] - xs, ys = [], [] - for li, s in enumerate(st): - if s: - xs.append(li) - ys.append(100.0 * s["misses"] / max(s["owned_nodes"], 1)) - if xs: - ax.plot(xs, ys, "o-", label=f"{n} ranks") -ax.set_xlabel("level (fine side of the transfer)") -ax.set_ylabel("fine nodes not located locally (%)") -ax.set_title("transfer misses answered by other ranks", loc="left", fontsize=10) -ax.legend(fontsize=8) -ax.grid(alpha=0.3) -ax = axes[2] -stages = ["adapt", "split_all_levels", "solver_build", "transfers", "solve_total"] -labels = ["adapt", "cut + split all levels", "solver build", "transfer build (in solve)", - "solve total"] -for st, lab in zip(stages, labels): - ax.plot(nps, [runs[n]["times"].get(st, np.nan) for n in nps], "o-", label=lab) -ax.set_xscale("log", base=2) -ax.set_xticks(nps) -ax.set_xticklabels([str(n) for n in nps]) -ax.set_xlabel("ranks") -ax.set_ylabel("seconds") -its = [runs[n]["vel_its"][-1] for n in nps] -ax.set_title("stage times; velocity its " + ", ".join(f"{n}:{i}" for n, i in zip(nps, its)), - loc="left", fontsize=9) -ax.legend(fontsize=8) -ax.grid(alpha=0.3) -fig.tight_layout() -fig.savefig(os.path.join(out, f"parallel_behaviour_k{k}{tag}.png"), dpi=160) -plt.close(fig) -print("ok") diff --git a/scripts/sessions/fault_mg/fig_schematics.py b/scripts/sessions/fault_mg/fig_schematics.py deleted file mode 100644 index 8b0fc3160..000000000 --- a/scripts/sessions/fault_mg/fig_schematics.py +++ /dev/null @@ -1,305 +0,0 @@ -"""Schematic figures for the fault multigrid write-up (wiki and technical note). - -1. hierarchy_strategies.png — the four hierarchies A, B2, C, D as level stacks -2. transfer_rule.png — the side-aware cell-located transfer and the clamp -3. parallel_pipeline.png — what moves where in parallel (split, levels, extrusion) -4. embedding_routes.png — the cut, the placed ladder band, and the ladder as the hierarchy -5. ladder_hierarchy.png — one nested ladder hierarchy with a realisation switch (split or TI) - -python fig_schematics.py -""" -import os -import sys - -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt -import numpy as np -from matplotlib.patches import FancyBboxPatch, Polygon - -out = sys.argv[1] if len(sys.argv) > 1 else "." -os.makedirs(out, exist_ok=True) -BG, ADD, FINE = "#dfe6ee", "#f6e3b4", "#f2c2b0" -SPLIT, TI, WELD = "#c0392b", "#7f8c8d", "#2c3e50" - - -def level_box(ax, y, kind, label, fault, width=3.6, x0=0.2): - """one multigrid level: a box, a fault symbol, a label""" - face = {"bg": BG, "add": ADD, "fine": FINE}[kind] - ax.add_patch(FancyBboxPatch((x0, y), width, 0.72, boxstyle="round,pad=0.02", - fc=face, ec="#555", lw=0.8)) - xc = x0 + width * 0.62 - if fault == "split": - ax.plot([xc - 0.9, xc + 0.9], [y + 0.40, y + 0.40], color=SPLIT, lw=2.2) - ax.plot([xc - 0.9, xc + 0.9], [y + 0.30, y + 0.30], color=SPLIT, lw=2.2) - ax.plot([xc - 0.9, xc - 0.9], [y + 0.30, y + 0.40], color=SPLIT, lw=1.0) - ax.plot([xc + 0.9, xc + 0.9], [y + 0.30, y + 0.40], color=SPLIT, lw=1.0) - elif fault == "ti": - ax.add_patch(Polygon([[xc - 0.95, y + 0.18], [xc + 0.95, y + 0.18], - [xc + 0.95, y + 0.54], [xc - 0.95, y + 0.54]], - closed=True, fc=TI, alpha=0.35, ec="none")) - ax.plot([xc - 0.9, xc + 0.9], [y + 0.36, y + 0.36], color=TI, lw=1.2, ls="--") - else: - ax.plot([xc - 0.9, xc + 0.9], [y + 0.36, y + 0.36], color=WELD, lw=1.0, ls=":") - ax.text(x0 + 0.12, y + 0.36, label, va="center", ha="left", fontsize=8.5) - - -def strategies(): - cols = [ - ("A welded tail", ["split", "weld", "weld", "weld", "weld"]), - ("B2 TI band on the add-on levels", ["split", "ti", "ti", "weld", "weld"]), - ("C split add-on levels", ["split", "split", "split", "weld", "weld"]), - ("D C + TI background", ["split", "split", "split", "ti", "ti"]), - ] - names = ["finest (add-on)", "add-on 2", "add-on 1", "background 1", "background 0"] - kinds = ["fine", "add", "add", "bg", "bg"] - fig, axes = plt.subplots(1, 4, figsize=(13.5, 4.6)) - for ax, (title, faults) in zip(axes, cols): - for i, (nm, kd, f) in enumerate(zip(names, kinds, faults)): - level_box(ax, 4.0 - i * 0.95, kd, nm, f) - ax.set_xlim(0, 4.0) - ax.set_ylim(-0.2, 5.0) - ax.set_title(title, fontsize=10.5, loc="left") - ax.axis("off") - # legend - ax = axes[0] - ax.plot([], [], color=SPLIT, lw=2.2, label="split slit (two coincident node sets)") - ax.plot([], [], color=TI, lw=1.2, ls="--", label="transversely isotropic weak band") - ax.plot([], [], color=WELD, lw=1.0, ls=":", label="welded (no fault)") - fig.legend(loc="lower center", ncol=3, fontsize=9, frameon=False, - bbox_to_anchor=(0.5, -0.01)) - fig.text(0.01, 0.97, "The background levels (grey) are the static base hierarchy and are " - "never cut; the add-on levels (yellow) are refined from it toward the fault.", - fontsize=9, va="top") - fig.tight_layout(rect=(0, 0.05, 1, 0.95)) - fig.savefig(os.path.join(out, "hierarchy_strategies.png"), dpi=160) - plt.close(fig) - - -def transfer_rule(): - fig, axes = plt.subplots(1, 2, figsize=(11, 4.6)) - # left: coarse split cells, a fine node on the slit, its own-side cell centroid - ax = axes[0] - # fault along y = 0 (slit drawn as a gap); coarse triangles above and below - up = [np.array([[0, 0], [2, 0], [1, 1.6]]), np.array([[2, 0], [4, 0], [3, 1.6]]), - np.array([[1, 1.6], [2, 0], [3, 1.6]])] - dn = [np.array([[0, 0], [2, 0], [1, -1.6]]), np.array([[2, 0], [4, 0], [3, -1.6]]), - np.array([[1, -1.6], [2, 0], [3, -1.6]])] - gap = 0.08 - for t in up: - ax.add_patch(Polygon(t + [0, gap], closed=True, fc="#eaf2fb", ec="#444", lw=0.9)) - for t in dn: - ax.add_patch(Polygon(t - [0, gap], closed=True, fc="#fdf2e9", ec="#444", lw=0.9)) - ax.plot([0, 4], [gap, gap], color=SPLIT, lw=2.2) - ax.plot([0, 4], [-gap, -gap], color=SPLIT, lw=2.2) - # a fine cell on the upper side touching the slit, its centroid - ft = np.array([[1.4, gap], [2.0, gap], [1.7, 0.55]]) - ax.add_patch(Polygon(ft, closed=True, fc="none", ec="#1a5276", lw=1.6, ls="-")) - c = ft.mean(axis=0) - ax.plot(*c, "o", color="#1a5276", ms=6) - ax.plot(1.4, gap, "s", color="#1a5276", ms=7) - ax.annotate("fine node on the slit\n(upper copy)", (1.4, gap), (0.2, 0.9), fontsize=8.5, - arrowprops=dict(arrowstyle="->", lw=0.8)) - ax.annotate("fine cell's centroid:\nnever on the fault,\nlocates the coarse cell\n" - "on the node's own side", c, (2.6, 1.0), fontsize=8.5, - arrowprops=dict(arrowstyle="->", lw=0.8)) - ax.text(2.0, -1.0, "the lower copy of the same node\nis located from a lower-side cell", - ha="center", fontsize=8.5, color="#7b3f00") - ax.set_xlim(-0.2, 4.4) - ax.set_ylim(-1.9, 2.0) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title("side-aware location", loc="left", fontsize=10.5) - # right: the clamp - ax = axes[1] - T = np.array([[0.3, 0.2], [3.2, 0.4], [1.6, 2.3]]) - ax.add_patch(Polygon(T, closed=True, fc="#eef5ee", ec="#444", lw=1.0)) - nodes = np.vstack([T, (T[[0, 1]]).mean(0), (T[[1, 2]]).mean(0), (T[[2, 0]]).mean(0)]) - ax.plot(nodes[:, 0], nodes[:, 1], "o", color="#2e7d32", ms=5) - p_out = np.array([3.0, 1.55]) - # nearest point of the triangle edge (1,2) - a, b = T[1], T[2] - t = np.clip(((p_out - a) @ (b - a)) / ((b - a) @ (b - a)), 0, 1) - q = a + t * (b - a) - ax.plot(*p_out, "s", color="#1a5276", ms=7) - ax.plot(*q, "x", color=SPLIT, ms=9, mew=2) - ax.annotate("", q, p_out, arrowprops=dict(arrowstyle="->", color=SPLIT, lw=1.2)) - ax.text(3.05, 1.65, "fine node a little outside\nits located coarse cell\n" - "(levels not nested near the trace)", fontsize=8.5, va="bottom") - ax.text(1.0, -0.35, "evaluate the coarse Lagrange interpolant at the clamped point:\n" - r"barycentric coordinates clipped to $[0,1]$ and renormalised; every weight stays" - "\nwithin the basis on the simplex (no extrapolation, no 5.4 weights at a tip)", - fontsize=8.5, va="top") - ax.text(0.9, 1.9, "P2 nodes of the\ncoarse cell", fontsize=8.5, color="#2e7d32") - ax.set_xlim(-0.2, 5.4) - ax.set_ylim(-1.3, 2.7) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title("the clamp", loc="left", fontsize=10.5) - fig.suptitle(r"The transfer: $W = B\,M^{-1}$ — the coarse cell's own nodes determine its " - "interpolant; no node-cloud triangulation (which degenerates on a slit)", - fontsize=10) - fig.tight_layout() - fig.savefig(os.path.join(out, "transfer_rule.png"), dpi=160) - plt.close(fig) - - -def parallel_pipeline(): - fig, ax = plt.subplots(figsize=(12.5, 5.2)) - ax.axis("off") - boxes = [ - (0.3, 3.4, "base mesh\n(distributed, static)", BG), - (3.2, 3.4, "adapt: add-on levels\n(in place, co-partitioned\nwith the base)", ADD), - (6.1, 3.4, "cut + split, finest\n(fault star gathered to\none rank, per region)", FINE), - (6.1, 1.4, "cut + split, each add-on level\n(its own star gathered;\npairing rank-local)", - ADD), - (9.0, 3.4, "transfers\n(rank-local location; misses\nasked of the other ranks)", "#e8daef"), - (9.0, 1.4, "per-level pair frames\n(rank-local pair blocks)", "#e8daef"), - (3.2, 1.4, "extrude (thick twin)\n(built on one rank, distributed,\nstars re-gathered, " - "pairing as labels)", "#d5f5e3"), - ] - for x, y, txt, fc in boxes: - ax.add_patch(FancyBboxPatch((x, y), 2.6, 1.3, boxstyle="round,pad=0.03", fc=fc, - ec="#555", lw=0.8)) - ax.text(x + 1.3, y + 0.65, txt, ha="center", va="center", fontsize=8.5) - def arrow(x0, y0, x1, y1): - ax.annotate("", (x1, y1), (x0, y0), arrowprops=dict(arrowstyle="->", lw=1.0)) - arrow(2.9, 4.05, 3.2, 4.05) - arrow(5.8, 4.05, 6.1, 4.05) - arrow(5.8, 3.6, 6.1, 2.3) - arrow(8.7, 4.05, 9.0, 4.05) - arrow(8.7, 2.05, 9.0, 2.05) - arrow(8.7, 2.3, 9.0, 3.6) - arrow(7.4, 3.4, 5.8, 2.7) - ax.text(0.3, 0.55, "Load: everything stays load-balanced except the fault stars (a layer " - "about two cells thick around each fault, on every level),\nwhich sit on the rank " - "holding most of the star. No communication inside the contact solve; the " - "imbalance is bounded by the stars, not by the refined band.", - fontsize=9, va="top") - ax.set_xlim(0, 12.2) - ax.set_ylim(-0.2, 5.0) - fig.tight_layout() - fig.savefig(os.path.join(out, "parallel_pipeline.png"), dpi=160) - plt.close(fig) - - -strategies() -transfer_rule() -parallel_pipeline() -print("figures in", out) - - -def embedding_routes(): - """The two embeddings of a fault and what each does for multigrid: the - cut (where C lives) and the placed ladder band (welded tail + FAC patch), - and the proposed meeting point: a 2:1 sub-sampled ladder split on every - level.""" - fig, axes = plt.subplots(1, 3, figsize=(15, 5.2)) - names = ["finest", "add-on 2", "add-on 1", "background 1", "background 0"] - kinds = ["fine", "add", "add", "bg", "bg"] - - def column(ax, title, faults, bands, notes): - for i, (nm, kd, f, b) in enumerate(zip(names, kinds, faults, bands)): - y = 4.0 - i * 0.95 - level_box(ax, y, kd, nm, f) - if b: - xc = 0.2 + 3.6 * 0.62 - ax.add_patch(Polygon([[xc - 1.05, y + 0.12], [xc + 1.05, y + 0.12], - [xc + 1.05, y + 0.60], [xc - 1.05, y + 0.60]], - closed=True, fc="none", ec="#2e7d32", lw=1.3, ls="-")) - ax.set_xlim(0, 4.0) - ax.set_ylim(-1.3, 5.0) - ax.set_title(title, fontsize=10.5, loc="left") - ax.text(0.2, -0.25, notes, fontsize=8.5, va="top") - ax.axis("off") - - column(axes[0], "the cut (C, in the library)", - ["split", "split", "split", "weld", "weld"], [False] * 5, - "adapt refines toward the trace; the trace is\ncut into the existing edges " - "(vertices pulled onto it)\nand split, on the finest AND on every add-on\nlevel. " - "Whole-level smoothing. Welded background.") - column(axes[1], "the ladder band (#629 / #672, in the library)", - ["split", "weld", "weld", "weld", "weld"], [True, False, False, False, False], - "a structured band (rails + exact spine, green)\nis placed into the finest by " - "carve-and-fill;\nthe spine is split without cutting (cut=False).\nWelded tail; " - "the finest smoother gains an ASM\npatch (LU blocks) over the band's cells.") - column(axes[2], "the ladder as the hierarchy (measured 2026-10-13)", - ["split", "split", "split", "weld", "weld"], [True, True, True, False, False], - "the ladder sub-sampled 2:1 shares every spine\nvertex between levels: placed " - "and split on each\nadd-on level, the slits NEST (no clamp needed).\nThe FAC patch " - "stays on the finest. Assembled by\nhand (ladder_c.py); the automatic replay is next.") - axes[0].plot([], [], color=SPLIT, lw=2.2, label="split slit") - axes[0].plot([], [], color="#2e7d32", lw=1.3, label="placed ladder band (cell label)") - axes[0].plot([], [], color=WELD, lw=1.0, ls=":", label="welded") - fig.legend(loc="lower center", ncol=3, fontsize=9, frameon=False, bbox_to_anchor=(0.5, -0.01)) - fig.tight_layout(rect=(0, 0.05, 1, 1)) - fig.savefig(os.path.join(out, "embedding_routes.png"), dpi=160) - plt.close(fig) - - -embedding_routes() - - -def ladder_hierarchy(): - """One nested ladder hierarchy with a realisation switch: what the library - builds today for a placed band, and the two realisations measured on the - test_1022 rig (ladder_c.py), split spine (C) or TI band on every ladder level.""" - fig, axes = plt.subplots(1, 3, figsize=(15, 5.4)) - names = ["finest ladder, spacing S", "ladder, 2S", "ladder, 4S", "background 1", "background 0"] - kinds = ["fine", "add", "add", "bg", "bg"] - - def column(ax, title, faults, bands, present, notes): - for i, (nm, kd, f, b, p) in enumerate(zip(names, kinds, faults, bands, present)): - y = 4.0 - i * 0.95 - if not p: - ax.add_patch(FancyBboxPatch((0.2, y), 3.6, 0.72, boxstyle="round,pad=0.02", - fc="none", ec="#bbb", lw=0.8, ls="--")) - ax.text(0.32, y + 0.36, nm + " (not built)", va="center", ha="left", - fontsize=8.5, color="#999") - continue - level_box(ax, y, kd, nm, f) - if b: - xc = 0.2 + 3.6 * 0.62 - ax.add_patch(Polygon([[xc - 1.05, y + 0.12], [xc + 1.05, y + 0.12], - [xc + 1.05, y + 0.60], [xc - 1.05, y + 0.60]], - closed=True, fc="none", ec="#2e7d32", lw=1.3)) - ax.set_xlim(0, 4.0) - ax.set_ylim(-1.5, 5.0) - ax.set_title(title, fontsize=10.5, loc="left") - ax.text(0.2, -0.25, notes, fontsize=8.5, va="top") - ax.axis("off") - - column(axes[0], "today: FaultNetwork.build(width=w)", - ["split", "weld", "weld", "weld", "weld"], [True, False, False, False, False], - [True, False, False, True, True], - "the band placed on the finest only; welded tail\n[background 0, 1]; the finest " - "smoother carries an\nASM patch over the band. Contact: 6 its at S = 0.01,\n9 at " - "S = 0.005. TI: 3 and 6.") - column(axes[1], "split realisation (C on the ladder)", - ["split", "split", "split", "weld", "weld"], [True, True, True, False, False], - [True] * 5, - "the same band at 2S and 4S, each spine split on the\nvertices it shares with " - "the finer one (slits nest).\nContact: 7 and 9 its, slip identical. The mids are\n" - "not needed for convergence here; they cost a fatter\nlevel (45 nnz/row against 33).") - column(axes[2], "TI realisation (band at every level)", - ["ti", "ti", "ti", "weld", "weld"], [True, True, True, False, False], - [True] * 5, - "the same nested ladders, the band's footprint painted\nTI at each level's own " - "resolution, nothing split.\nTI: 2 and 3 its against 3 and 6 with no mid:\nthe mids " - "halve the count at depth.") - axes[0].plot([], [], color=SPLIT, lw=2.2, label="split spine") - axes[0].plot([], [], color=TI, lw=1.2, ls="--", label="TI band") - axes[0].plot([], [], color="#2e7d32", lw=1.3, label="placed ladder band (cell label)") - axes[0].plot([], [], color=WELD, lw=1.0, ls=":", label="welded") - fig.legend(loc="lower center", ncol=4, fontsize=9, frameon=False, bbox_to_anchor=(0.5, -0.01)) - fig.text(0.01, 0.99, "One hierarchy, two realisations. The background (grey) is the base's own " - "levels: never cut, never rebalanced. The ladder levels (yellow) are the band sub-sampled\n" - "2:1, so every coarser spine vertex is a finer one and the levels nest. Measured on the " - "test_1022 rig, base h = 1/8, band width 0.06 (ladder_c.py).", - fontsize=8.8, va="top") - fig.tight_layout(rect=(0, 0.05, 1, 0.93)) - fig.savefig(os.path.join(out, "ladder_hierarchy.png"), dpi=160) - plt.close(fig) - - -ladder_hierarchy() diff --git a/scripts/sessions/fault_mg/ladder_base.py b/scripts/sessions/fault_mg/ladder_base.py deleted file mode 100644 index 912cf4dae..000000000 --- a/scripts/sessions/fault_mg/ladder_base.py +++ /dev/null @@ -1,163 +0,0 @@ -"""The same finest ladder on a finer base: the test_1022 rig at spacing 0.005 and -0.0025 with the base refined times (finest base cell 1/8/2^ref), -no mids, contact and TI. Separates the band-to-base rim ratio from the tail. - -The finest level is a placed transfinite ribbon (spacing S) with the spine cut -and split (or, for TI, left whole with the band's footprint painted -transversely isotropic). The mid levels are the same ribbon at 2:1 and 4:1 -spacing, welded or split on the same spine vertices. Base: L0 + L1. - -python ladder_base.py -""" -import json -import os -import sys -import time - -import numpy as np -import sympy -import underworld3 as uw -from underworld3.utilities import fault_contact -from underworld3.utilities.custom_mg import set_custom_fmg -from underworld3.utilities.place_surface import place_thin_volume - -out = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/+Simulations/fault_split_mg") -REF = int(sys.argv[2]) if len(sys.argv) > 2 else 2 -os.makedirs(out, exist_ok=True) -H_BG = 1.0 / 8 -WIDTH = 0.06 -TRACE = np.array([[0.15, 0.42], [0.85, 0.58]]) -TIP = 0.04 # the cut stops this far inside the band, every level -FAULT = "Main" -a, b = TRACE[0], TRACE[-1] -t_hat = (b - a) / np.linalg.norm(b - a) -L = float(np.linalg.norm(b - a)) -n_hat = np.array([-t_hat[1], t_hat[0]]) - - -def banded(base, size): - for clearance in (0.55, 0.7, 0.9): - try: - dm, _info = place_thin_volume( - base.dm, [TRACE], width=WIDTH, label="Zone", label_value=31, - size=size, clearance=clearance, assembly="fuse", mesher="ladder") - return uw.discretisation.Mesh( - dm, simplex=True, qdegree=2, boundaries=base.boundaries, - coordinate_system_type=base.CoordinateSystem.coordinate_type) - except RuntimeError: - continue - raise RuntimeError(f"band build failed at size {size}") - - -def spine_line(size): - """the cut, sampled at the ladder's own rung vertices, tips TIP inside""" - n_along = max(2, int(round(L / size))) - step = L / n_along - k0 = int(round(TIP / step)) - return a + (np.arange(k0, n_along - k0 + 1) * step)[:, None] * t_hat - - -def split_ribbon(mesh, size): - return mesh.add_fault((FAULT, spine_line(size)), split_levels=False) - - -def contact_stokes(mesh, tag): - x, y = mesh.X - v = uw.discretisation.MeshVariable(f"v{tag}", mesh, 2, degree=2) - p = uw.discretisation.MeshVariable(f"p{tag}", mesh, 1, degree=1, continuous=True) - s = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) - s.constitutive_model = uw.constitutive_models.ViscousFlowModel - s.constitutive_model.Parameters.shear_viscosity_0 = 1.0 - s.bodyforce = [0.0, 0.0] - for wall in ("Bottom", "Top", "Left", "Right"): - s.add_dirichlet_bc((y - 0.5, 0.0), wall) - s.add_fault_bc(0, boundary=FAULT) - s.petsc_use_pressure_nullspace = True - s.tolerance = 1e-5 - s.strategy = "robust" - return s - - -def ti_stokes(mesh, tag, contrast=1e-3): - x, y = mesh.X - v = uw.discretisation.MeshVariable(f"v{tag}", mesh, 2, degree=2) - p = uw.discretisation.MeshVariable(f"p{tag}", mesh, 1, degree=1, continuous=True) - zone = uw.discretisation.MeshVariable(f"zone{tag}", mesh, 1, degree=0, continuous=False) - mask = mesh.cells_labelled("Zone", 31) - zone.array[:, 0, 0] = mask.astype(float) - s = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) - s.constitutive_model = uw.constitutive_models.TransverseIsotropicFlowModel - s.constitutive_model.Parameters.shear_viscosity_0 = 1.0 - s.constitutive_model.Parameters.shear_viscosity_1 = 1.0 - (1.0 - contrast) * zone.sym[0] - s.constitutive_model.Parameters.director = sympy.Matrix([[float(n_hat[0]), float(n_hat[1])]]) - s.bodyforce = [0.0, 0.0] - for wall in ("Bottom", "Top", "Left", "Right"): - s.add_dirichlet_bc((y - 0.5, 0.0), wall) - s.petsc_use_pressure_nullspace = True - s.tolerance = 1e-5 - s.strategy = "robust" - return s, mask - - -def chain_density(velpc): - out = [] - for l in range(velpc.getMGLevels()): - A = velpc.getMGSmoother(l).getOperators()[0] - out.append(round(A.getInfo()["nz_used"] / max(A.getSize()[0], 1), 1)) - return out - - -records = [] -base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), cellSize=H_BG, - regular=False, qdegree=2, refinement=REF) -L01 = base._coarse_level_meshes()[:-1] + [base] -ribbons = {} -for size in (0.005, 0.0025): - t0 = time.perf_counter() - ribbons[size] = banded(base, size) - print(f"ribbon {size}: {ribbons[size].dm.getHeightStratum(0)[1]} cells " - f"({time.perf_counter() - t0:.1f} s)", flush=True) -split = {size: split_ribbon(m, size) for size, m in ribbons.items()} - -arms = [] -for s_fine in (0.005, 0.0025): - arms.append((f"ref {REF} fine {s_fine}: no mid", s_fine, [], "contact")) - arms.append((f"TI ref {REF} fine {s_fine}: no mid", s_fine, [], "ti")) - -for k, (name, s_fine, mids, kind) in enumerate(arms): - tail = L01 + mids - t0 = time.perf_counter() - if kind == "contact": - mesh = split[s_fine] - stokes = contact_stokes(mesh, f"c{k}") - set_custom_fmg(stokes, tail, field_id=0) - info = fault_contact.solve_with_fault(stokes) - ctx = (getattr(stokes, "_rotated_linear_cache", None) or {})["ctx"] - velpc = ctx["ksp"].getPC().getFieldSplitSubKSP()[0].getPC() - _c, jumps, normals = fault_contact.fault_pair_jumps(stokes, FAULT, stokes._rotated_freeslip_info) - jn = np.einsum("ij,ij->i", jumps, normals) - rec = {"arm": name, "kind": kind, "vel_its": ctx.get("vel_its_last"), - "pres_its": ctx.get("pres_its_last"), "converged": bool(info.get("converged")), - "velocity_pc": velpc.getType(), "nnz_per_row": chain_density(velpc), - "slip": float(np.linalg.norm(jumps - jn[:, None] * normals, axis=1).max()), - "leak": float(np.abs(jn).max()), - "levels": len(tail) + 1, "cells": int(mesh.dm.getHeightStratum(0)[1]), - "t_solve": round(time.perf_counter() - t0, 1)} - else: - mesh = ribbons[s_fine] - stokes, mask = ti_stokes(mesh, f"t{k}") - set_custom_fmg(stokes, tail, field_id=0, fac_zone=mask) - stokes.solve() - ksp = stokes.snes.getKSP() - velksp = ksp.getPC().getFieldSplitSubKSP()[0] - velpc = velksp.getPC() - # iterations of the velocity sub-solve on the last outer application - rec = {"arm": name, "kind": kind, "vel_its": velksp.getIterationNumber(), - "outer_its": ksp.getIterationNumber(), "velocity_pc": velpc.getType(), - "nnz_per_row": chain_density(velpc) if velpc.getType() == "mg" else None, - "levels": len(tail) + 1, "cells": int(mesh.dm.getHeightStratum(0)[1]), - "t_solve": round(time.perf_counter() - t0, 1)} - records.append(rec) - print("ARM", json.dumps(rec), flush=True) -json.dump(records, open(os.path.join(out, f"ladder_base_ref{REF}.json"), "w"), indent=1) -print("DONE") diff --git a/scripts/sessions/fault_mg/ladder_c.py b/scripts/sessions/fault_mg/ladder_c.py deleted file mode 100644 index b43c05cba..000000000 --- a/scripts/sessions/fault_mg/ladder_c.py +++ /dev/null @@ -1,166 +0,0 @@ -"""C on the ladder: the composed ribbon rig of test_1022 with the mid ribbon(s) -SPLIT, against the recorded welded-mid and no-mid tails, at two depths; and the -TI realisation on the same nested ladders. - -The finest level is a placed transfinite ribbon (spacing S) with the spine cut -and split (or, for TI, left whole with the band's footprint painted -transversely isotropic). The mid levels are the same ribbon at 2:1 and 4:1 -spacing, welded or split on the same spine vertices. Base: L0 + L1. - -python ladder_c.py -""" -import json -import os -import sys -import time - -import numpy as np -import sympy -import underworld3 as uw -from underworld3.utilities import fault_contact -from underworld3.utilities.custom_mg import set_custom_fmg -from underworld3.utilities.place_surface import place_thin_volume - -out = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/+Simulations/fault_split_mg") -os.makedirs(out, exist_ok=True) -H_BG = 1.0 / 8 -WIDTH = 0.06 -TRACE = np.array([[0.15, 0.42], [0.85, 0.58]]) -TIP = 0.04 # the cut stops this far inside the band, every level -FAULT = "Main" -a, b = TRACE[0], TRACE[-1] -t_hat = (b - a) / np.linalg.norm(b - a) -L = float(np.linalg.norm(b - a)) -n_hat = np.array([-t_hat[1], t_hat[0]]) - - -def banded(base, size): - for clearance in (0.55, 0.7, 0.9): - try: - dm, _info = place_thin_volume( - base.dm, [TRACE], width=WIDTH, label="Zone", label_value=31, - size=size, clearance=clearance, assembly="fuse", mesher="ladder") - return uw.discretisation.Mesh( - dm, simplex=True, qdegree=2, boundaries=base.boundaries, - coordinate_system_type=base.CoordinateSystem.coordinate_type) - except RuntimeError: - continue - raise RuntimeError(f"band build failed at size {size}") - - -def spine_line(size): - """the cut, sampled at the ladder's own rung vertices, tips TIP inside""" - n_along = max(2, int(round(L / size))) - step = L / n_along - k0 = int(round(TIP / step)) - return a + (np.arange(k0, n_along - k0 + 1) * step)[:, None] * t_hat - - -def split_ribbon(mesh, size): - return mesh.add_fault((FAULT, spine_line(size)), split_levels=False) - - -def contact_stokes(mesh, tag): - x, y = mesh.X - v = uw.discretisation.MeshVariable(f"v{tag}", mesh, 2, degree=2) - p = uw.discretisation.MeshVariable(f"p{tag}", mesh, 1, degree=1, continuous=True) - s = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) - s.constitutive_model = uw.constitutive_models.ViscousFlowModel - s.constitutive_model.Parameters.shear_viscosity_0 = 1.0 - s.bodyforce = [0.0, 0.0] - for wall in ("Bottom", "Top", "Left", "Right"): - s.add_dirichlet_bc((y - 0.5, 0.0), wall) - s.add_fault_bc(0, boundary=FAULT) - s.petsc_use_pressure_nullspace = True - s.tolerance = 1e-5 - s.strategy = "robust" - return s - - -def ti_stokes(mesh, tag, contrast=1e-3): - x, y = mesh.X - v = uw.discretisation.MeshVariable(f"v{tag}", mesh, 2, degree=2) - p = uw.discretisation.MeshVariable(f"p{tag}", mesh, 1, degree=1, continuous=True) - zone = uw.discretisation.MeshVariable(f"zone{tag}", mesh, 1, degree=0, continuous=False) - mask = mesh.cells_labelled("Zone", 31) - zone.array[:, 0, 0] = mask.astype(float) - s = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) - s.constitutive_model = uw.constitutive_models.TransverseIsotropicFlowModel - s.constitutive_model.Parameters.shear_viscosity_0 = 1.0 - s.constitutive_model.Parameters.shear_viscosity_1 = 1.0 - (1.0 - contrast) * zone.sym[0] - s.constitutive_model.Parameters.director = sympy.Matrix([[float(n_hat[0]), float(n_hat[1])]]) - s.bodyforce = [0.0, 0.0] - for wall in ("Bottom", "Top", "Left", "Right"): - s.add_dirichlet_bc((y - 0.5, 0.0), wall) - s.petsc_use_pressure_nullspace = True - s.tolerance = 1e-5 - s.strategy = "robust" - return s, mask - - -def chain_density(velpc): - out = [] - for l in range(velpc.getMGLevels()): - A = velpc.getMGSmoother(l).getOperators()[0] - out.append(round(A.getInfo()["nz_used"] / max(A.getSize()[0], 1), 1)) - return out - - -records = [] -base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), cellSize=H_BG, - regular=False, qdegree=2, refinement=1) -L01 = base._coarse_level_meshes()[:-1] + [base] -ribbons = {} -for size in (0.04, 0.02, 0.01, 0.005): - t0 = time.perf_counter() - ribbons[size] = banded(base, size) - print(f"ribbon {size}: {ribbons[size].dm.getHeightStratum(0)[1]} cells " - f"({time.perf_counter() - t0:.1f} s)", flush=True) -split = {size: split_ribbon(m, size) for size, m in ribbons.items()} - -arms = [] -for s_fine, mids in ((0.01, [0.02]), (0.005, [0.02, 0.01])): - arms.append((f"fine {s_fine}: no mid", s_fine, [], "contact")) - arms.append((f"fine {s_fine}: welded mid {mids}", s_fine, [ribbons[m] for m in mids], "contact")) - arms.append((f"fine {s_fine}: split mid {mids}", s_fine, [split[m] for m in mids], "contact")) -for s_fine, mids in ((0.01, [0.02]), (0.005, [0.02, 0.01])): - arms.append((f"TI fine {s_fine}: no mid", s_fine, [], "ti")) - arms.append((f"TI fine {s_fine}: welded mid {mids}", s_fine, [ribbons[m] for m in mids], "ti")) - -for k, (name, s_fine, mids, kind) in enumerate(arms): - tail = L01 + mids - t0 = time.perf_counter() - if kind == "contact": - mesh = split[s_fine] - stokes = contact_stokes(mesh, f"c{k}") - set_custom_fmg(stokes, tail, field_id=0) - info = fault_contact.solve_with_fault(stokes) - ctx = (getattr(stokes, "_rotated_linear_cache", None) or {})["ctx"] - velpc = ctx["ksp"].getPC().getFieldSplitSubKSP()[0].getPC() - _c, jumps, normals = fault_contact.fault_pair_jumps(stokes, FAULT, stokes._rotated_freeslip_info) - jn = np.einsum("ij,ij->i", jumps, normals) - rec = {"arm": name, "kind": kind, "vel_its": ctx.get("vel_its_last"), - "pres_its": ctx.get("pres_its_last"), "converged": bool(info.get("converged")), - "velocity_pc": velpc.getType(), "nnz_per_row": chain_density(velpc), - "slip": float(np.linalg.norm(jumps - jn[:, None] * normals, axis=1).max()), - "leak": float(np.abs(jn).max()), - "levels": len(tail) + 1, "cells": int(mesh.dm.getHeightStratum(0)[1]), - "t_solve": round(time.perf_counter() - t0, 1)} - else: - mesh = ribbons[s_fine] - stokes, mask = ti_stokes(mesh, f"t{k}") - set_custom_fmg(stokes, tail, field_id=0, fac_zone=mask) - stokes.solve() - ksp = stokes.snes.getKSP() - velksp = ksp.getPC().getFieldSplitSubKSP()[0] - velpc = velksp.getPC() - # iterations of the velocity sub-solve on the last outer application - rec = {"arm": name, "kind": kind, "vel_its": velksp.getIterationNumber(), - "outer_its": ksp.getIterationNumber(), "velocity_pc": velpc.getType(), - "nnz_per_row": chain_density(velpc) if velpc.getType() == "mg" else None, - "levels": len(tail) + 1, "cells": int(mesh.dm.getHeightStratum(0)[1]), - "t_solve": round(time.perf_counter() - t0, 1)} - records.append(rec) - print("ARM", json.dumps(rec), flush=True) -json.dump(records, open(os.path.join(out, "ladder_c.json"), "w"), indent=1) -print("DONE") diff --git a/scripts/sessions/fault_mg/ladder_depth.py b/scripts/sessions/fault_mg/ladder_depth.py deleted file mode 100644 index 6eddb7d8c..000000000 --- a/scripts/sessions/fault_mg/ladder_depth.py +++ /dev/null @@ -1,165 +0,0 @@ -"""How far the welded base tail carries a single finest ladder: the test_1022 rig -at spacing 0.005 and 0.0025 (ratio 12 and 24 to the base finest) with no mid, -and at 0.0025 with one, two and three nested welded mids; contact and TI. - -The finest level is a placed transfinite ribbon (spacing S) with the spine cut -and split (or, for TI, left whole with the band's footprint painted -transversely isotropic). The mid levels are the same ribbon at 2:1 and 4:1 -spacing, welded or split on the same spine vertices. Base: L0 + L1. - -python ladder_c.py -""" -import json -import os -import sys -import time - -import numpy as np -import sympy -import underworld3 as uw -from underworld3.utilities import fault_contact -from underworld3.utilities.custom_mg import set_custom_fmg -from underworld3.utilities.place_surface import place_thin_volume - -out = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/+Simulations/fault_split_mg") -os.makedirs(out, exist_ok=True) -H_BG = 1.0 / 8 -WIDTH = 0.06 -TRACE = np.array([[0.15, 0.42], [0.85, 0.58]]) -TIP = 0.04 # the cut stops this far inside the band, every level -FAULT = "Main" -a, b = TRACE[0], TRACE[-1] -t_hat = (b - a) / np.linalg.norm(b - a) -L = float(np.linalg.norm(b - a)) -n_hat = np.array([-t_hat[1], t_hat[0]]) - - -def banded(base, size): - for clearance in (0.55, 0.7, 0.9): - try: - dm, _info = place_thin_volume( - base.dm, [TRACE], width=WIDTH, label="Zone", label_value=31, - size=size, clearance=clearance, assembly="fuse", mesher="ladder") - return uw.discretisation.Mesh( - dm, simplex=True, qdegree=2, boundaries=base.boundaries, - coordinate_system_type=base.CoordinateSystem.coordinate_type) - except RuntimeError: - continue - raise RuntimeError(f"band build failed at size {size}") - - -def spine_line(size): - """the cut, sampled at the ladder's own rung vertices, tips TIP inside""" - n_along = max(2, int(round(L / size))) - step = L / n_along - k0 = int(round(TIP / step)) - return a + (np.arange(k0, n_along - k0 + 1) * step)[:, None] * t_hat - - -def split_ribbon(mesh, size): - return mesh.add_fault((FAULT, spine_line(size)), split_levels=False) - - -def contact_stokes(mesh, tag): - x, y = mesh.X - v = uw.discretisation.MeshVariable(f"v{tag}", mesh, 2, degree=2) - p = uw.discretisation.MeshVariable(f"p{tag}", mesh, 1, degree=1, continuous=True) - s = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) - s.constitutive_model = uw.constitutive_models.ViscousFlowModel - s.constitutive_model.Parameters.shear_viscosity_0 = 1.0 - s.bodyforce = [0.0, 0.0] - for wall in ("Bottom", "Top", "Left", "Right"): - s.add_dirichlet_bc((y - 0.5, 0.0), wall) - s.add_fault_bc(0, boundary=FAULT) - s.petsc_use_pressure_nullspace = True - s.tolerance = 1e-5 - s.strategy = "robust" - return s - - -def ti_stokes(mesh, tag, contrast=1e-3): - x, y = mesh.X - v = uw.discretisation.MeshVariable(f"v{tag}", mesh, 2, degree=2) - p = uw.discretisation.MeshVariable(f"p{tag}", mesh, 1, degree=1, continuous=True) - zone = uw.discretisation.MeshVariable(f"zone{tag}", mesh, 1, degree=0, continuous=False) - mask = mesh.cells_labelled("Zone", 31) - zone.array[:, 0, 0] = mask.astype(float) - s = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) - s.constitutive_model = uw.constitutive_models.TransverseIsotropicFlowModel - s.constitutive_model.Parameters.shear_viscosity_0 = 1.0 - s.constitutive_model.Parameters.shear_viscosity_1 = 1.0 - (1.0 - contrast) * zone.sym[0] - s.constitutive_model.Parameters.director = sympy.Matrix([[float(n_hat[0]), float(n_hat[1])]]) - s.bodyforce = [0.0, 0.0] - for wall in ("Bottom", "Top", "Left", "Right"): - s.add_dirichlet_bc((y - 0.5, 0.0), wall) - s.petsc_use_pressure_nullspace = True - s.tolerance = 1e-5 - s.strategy = "robust" - return s, mask - - -def chain_density(velpc): - out = [] - for l in range(velpc.getMGLevels()): - A = velpc.getMGSmoother(l).getOperators()[0] - out.append(round(A.getInfo()["nz_used"] / max(A.getSize()[0], 1), 1)) - return out - - -records = [] -base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), cellSize=H_BG, - regular=False, qdegree=2, refinement=1) -L01 = base._coarse_level_meshes()[:-1] + [base] -ribbons = {} -for size in (0.02, 0.01, 0.005, 0.0025): - t0 = time.perf_counter() - ribbons[size] = banded(base, size) - print(f"ribbon {size}: {ribbons[size].dm.getHeightStratum(0)[1]} cells " - f"({time.perf_counter() - t0:.1f} s)", flush=True) -split = {size: split_ribbon(ribbons[size], size) for size in (0.005, 0.0025)} - -arms = [] -for s_fine in (0.005, 0.0025): - arms.append((f"fine {s_fine}: no mid", s_fine, [], "contact")) - arms.append((f"TI fine {s_fine}: no mid", s_fine, [], "ti")) -for mids in ([0.005], [0.01, 0.005], [0.02, 0.01, 0.005]): - arms.append((f"fine 0.0025: welded mid {mids}", 0.0025, [ribbons[m] for m in mids], "contact")) - arms.append((f"TI fine 0.0025: welded mid {mids}", 0.0025, [ribbons[m] for m in mids], "ti")) - -for k, (name, s_fine, mids, kind) in enumerate(arms): - tail = L01 + mids - t0 = time.perf_counter() - if kind == "contact": - mesh = split[s_fine] - stokes = contact_stokes(mesh, f"c{k}") - set_custom_fmg(stokes, tail, field_id=0) - info = fault_contact.solve_with_fault(stokes) - ctx = (getattr(stokes, "_rotated_linear_cache", None) or {})["ctx"] - velpc = ctx["ksp"].getPC().getFieldSplitSubKSP()[0].getPC() - _c, jumps, normals = fault_contact.fault_pair_jumps(stokes, FAULT, stokes._rotated_freeslip_info) - jn = np.einsum("ij,ij->i", jumps, normals) - rec = {"arm": name, "kind": kind, "vel_its": ctx.get("vel_its_last"), - "pres_its": ctx.get("pres_its_last"), "converged": bool(info.get("converged")), - "velocity_pc": velpc.getType(), "nnz_per_row": chain_density(velpc), - "slip": float(np.linalg.norm(jumps - jn[:, None] * normals, axis=1).max()), - "leak": float(np.abs(jn).max()), - "levels": len(tail) + 1, "cells": int(mesh.dm.getHeightStratum(0)[1]), - "t_solve": round(time.perf_counter() - t0, 1)} - else: - mesh = ribbons[s_fine] - stokes, mask = ti_stokes(mesh, f"t{k}") - set_custom_fmg(stokes, tail, field_id=0, fac_zone=mask) - stokes.solve() - ksp = stokes.snes.getKSP() - velksp = ksp.getPC().getFieldSplitSubKSP()[0] - velpc = velksp.getPC() - # iterations of the velocity sub-solve on the last outer application - rec = {"arm": name, "kind": kind, "vel_its": velksp.getIterationNumber(), - "outer_its": ksp.getIterationNumber(), "velocity_pc": velpc.getType(), - "nnz_per_row": chain_density(velpc) if velpc.getType() == "mg" else None, - "levels": len(tail) + 1, "cells": int(mesh.dm.getHeightStratum(0)[1]), - "t_solve": round(time.perf_counter() - t0, 1)} - records.append(rec) - print("ARM", json.dumps(rec), flush=True) -json.dump(records, open(os.path.join(out, "ladder_depth.json"), "w"), indent=1) -print("DONE") diff --git a/scripts/sessions/fault_mg/ladder_depth_split.py b/scripts/sessions/fault_mg/ladder_depth_split.py deleted file mode 100644 index 31ea7d7fc..000000000 --- a/scripts/sessions/fault_mg/ladder_depth_split.py +++ /dev/null @@ -1,161 +0,0 @@ -"""The split mids at ratio 24: the test_1022 rig at spacing 0.0025 with one, two -and three nested SPLIT mids (C on the ladder), contact only. Companion to -ladder_depth.py, which has the welded mids. - -The finest level is a placed transfinite ribbon (spacing S) with the spine cut -and split (or, for TI, left whole with the band's footprint painted -transversely isotropic). The mid levels are the same ribbon at 2:1 and 4:1 -spacing, welded or split on the same spine vertices. Base: L0 + L1. - -python ladder_c.py -""" -import json -import os -import sys -import time - -import numpy as np -import sympy -import underworld3 as uw -from underworld3.utilities import fault_contact -from underworld3.utilities.custom_mg import set_custom_fmg -from underworld3.utilities.place_surface import place_thin_volume - -out = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/+Simulations/fault_split_mg") -os.makedirs(out, exist_ok=True) -H_BG = 1.0 / 8 -WIDTH = 0.06 -TRACE = np.array([[0.15, 0.42], [0.85, 0.58]]) -TIP = 0.04 # the cut stops this far inside the band, every level -FAULT = "Main" -a, b = TRACE[0], TRACE[-1] -t_hat = (b - a) / np.linalg.norm(b - a) -L = float(np.linalg.norm(b - a)) -n_hat = np.array([-t_hat[1], t_hat[0]]) - - -def banded(base, size): - for clearance in (0.55, 0.7, 0.9): - try: - dm, _info = place_thin_volume( - base.dm, [TRACE], width=WIDTH, label="Zone", label_value=31, - size=size, clearance=clearance, assembly="fuse", mesher="ladder") - return uw.discretisation.Mesh( - dm, simplex=True, qdegree=2, boundaries=base.boundaries, - coordinate_system_type=base.CoordinateSystem.coordinate_type) - except RuntimeError: - continue - raise RuntimeError(f"band build failed at size {size}") - - -def spine_line(size): - """the cut, sampled at the ladder's own rung vertices, tips TIP inside""" - n_along = max(2, int(round(L / size))) - step = L / n_along - k0 = int(round(TIP / step)) - return a + (np.arange(k0, n_along - k0 + 1) * step)[:, None] * t_hat - - -def split_ribbon(mesh, size): - return mesh.add_fault((FAULT, spine_line(size)), split_levels=False) - - -def contact_stokes(mesh, tag): - x, y = mesh.X - v = uw.discretisation.MeshVariable(f"v{tag}", mesh, 2, degree=2) - p = uw.discretisation.MeshVariable(f"p{tag}", mesh, 1, degree=1, continuous=True) - s = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) - s.constitutive_model = uw.constitutive_models.ViscousFlowModel - s.constitutive_model.Parameters.shear_viscosity_0 = 1.0 - s.bodyforce = [0.0, 0.0] - for wall in ("Bottom", "Top", "Left", "Right"): - s.add_dirichlet_bc((y - 0.5, 0.0), wall) - s.add_fault_bc(0, boundary=FAULT) - s.petsc_use_pressure_nullspace = True - s.tolerance = 1e-5 - s.strategy = "robust" - return s - - -def ti_stokes(mesh, tag, contrast=1e-3): - x, y = mesh.X - v = uw.discretisation.MeshVariable(f"v{tag}", mesh, 2, degree=2) - p = uw.discretisation.MeshVariable(f"p{tag}", mesh, 1, degree=1, continuous=True) - zone = uw.discretisation.MeshVariable(f"zone{tag}", mesh, 1, degree=0, continuous=False) - mask = mesh.cells_labelled("Zone", 31) - zone.array[:, 0, 0] = mask.astype(float) - s = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) - s.constitutive_model = uw.constitutive_models.TransverseIsotropicFlowModel - s.constitutive_model.Parameters.shear_viscosity_0 = 1.0 - s.constitutive_model.Parameters.shear_viscosity_1 = 1.0 - (1.0 - contrast) * zone.sym[0] - s.constitutive_model.Parameters.director = sympy.Matrix([[float(n_hat[0]), float(n_hat[1])]]) - s.bodyforce = [0.0, 0.0] - for wall in ("Bottom", "Top", "Left", "Right"): - s.add_dirichlet_bc((y - 0.5, 0.0), wall) - s.petsc_use_pressure_nullspace = True - s.tolerance = 1e-5 - s.strategy = "robust" - return s, mask - - -def chain_density(velpc): - out = [] - for l in range(velpc.getMGLevels()): - A = velpc.getMGSmoother(l).getOperators()[0] - out.append(round(A.getInfo()["nz_used"] / max(A.getSize()[0], 1), 1)) - return out - - -records = [] -base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), cellSize=H_BG, - regular=False, qdegree=2, refinement=1) -L01 = base._coarse_level_meshes()[:-1] + [base] -ribbons = {} -for size in (0.02, 0.01, 0.005, 0.0025): - t0 = time.perf_counter() - ribbons[size] = banded(base, size) - print(f"ribbon {size}: {ribbons[size].dm.getHeightStratum(0)[1]} cells " - f"({time.perf_counter() - t0:.1f} s)", flush=True) -split = {size: split_ribbon(m, size) for size, m in ribbons.items()} - -arms = [] -for mids in ([0.005], [0.01, 0.005], [0.02, 0.01, 0.005]): - arms.append((f"fine 0.0025: split mid {mids}", 0.0025, [split[m] for m in mids], "contact")) - -for k, (name, s_fine, mids, kind) in enumerate(arms): - tail = L01 + mids - t0 = time.perf_counter() - if kind == "contact": - mesh = split[s_fine] - stokes = contact_stokes(mesh, f"c{k}") - set_custom_fmg(stokes, tail, field_id=0) - info = fault_contact.solve_with_fault(stokes) - ctx = (getattr(stokes, "_rotated_linear_cache", None) or {})["ctx"] - velpc = ctx["ksp"].getPC().getFieldSplitSubKSP()[0].getPC() - _c, jumps, normals = fault_contact.fault_pair_jumps(stokes, FAULT, stokes._rotated_freeslip_info) - jn = np.einsum("ij,ij->i", jumps, normals) - rec = {"arm": name, "kind": kind, "vel_its": ctx.get("vel_its_last"), - "pres_its": ctx.get("pres_its_last"), "converged": bool(info.get("converged")), - "velocity_pc": velpc.getType(), "nnz_per_row": chain_density(velpc), - "slip": float(np.linalg.norm(jumps - jn[:, None] * normals, axis=1).max()), - "leak": float(np.abs(jn).max()), - "levels": len(tail) + 1, "cells": int(mesh.dm.getHeightStratum(0)[1]), - "t_solve": round(time.perf_counter() - t0, 1)} - else: - mesh = ribbons[s_fine] - stokes, mask = ti_stokes(mesh, f"t{k}") - set_custom_fmg(stokes, tail, field_id=0, fac_zone=mask) - stokes.solve() - ksp = stokes.snes.getKSP() - velksp = ksp.getPC().getFieldSplitSubKSP()[0] - velpc = velksp.getPC() - # iterations of the velocity sub-solve on the last outer application - rec = {"arm": name, "kind": kind, "vel_its": velksp.getIterationNumber(), - "outer_its": ksp.getIterationNumber(), "velocity_pc": velpc.getType(), - "nnz_per_row": chain_density(velpc) if velpc.getType() == "mg" else None, - "levels": len(tail) + 1, "cells": int(mesh.dm.getHeightStratum(0)[1]), - "t_solve": round(time.perf_counter() - t0, 1)} - records.append(rec) - print("ARM", json.dumps(rec), flush=True) -json.dump(records, open(os.path.join(out, "ladder_depth_split.json"), "w"), indent=1) -print("DONE") diff --git a/scripts/sessions/fault_mg/ladder_depth_split4.py b/scripts/sessions/fault_mg/ladder_depth_split4.py deleted file mode 100644 index 5d38fb74f..000000000 --- a/scripts/sessions/fault_mg/ladder_depth_split4.py +++ /dev/null @@ -1,161 +0,0 @@ -"""C on the ladder with the split chain starting at the base spacing: the -test_1022 rig at 0.0025 with four nested SPLIT mids 0.04, 0.02, 0.01, 0.005, -contact only. Companion to ladder_depth_split.py. - -The finest level is a placed transfinite ribbon (spacing S) with the spine cut -and split (or, for TI, left whole with the band's footprint painted -transversely isotropic). The mid levels are the same ribbon at 2:1 and 4:1 -spacing, welded or split on the same spine vertices. Base: L0 + L1. - -python ladder_c.py -""" -import json -import os -import sys -import time - -import numpy as np -import sympy -import underworld3 as uw -from underworld3.utilities import fault_contact -from underworld3.utilities.custom_mg import set_custom_fmg -from underworld3.utilities.place_surface import place_thin_volume - -out = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/+Simulations/fault_split_mg") -os.makedirs(out, exist_ok=True) -H_BG = 1.0 / 8 -WIDTH = 0.06 -TRACE = np.array([[0.15, 0.42], [0.85, 0.58]]) -TIP = 0.04 # the cut stops this far inside the band, every level -FAULT = "Main" -a, b = TRACE[0], TRACE[-1] -t_hat = (b - a) / np.linalg.norm(b - a) -L = float(np.linalg.norm(b - a)) -n_hat = np.array([-t_hat[1], t_hat[0]]) - - -def banded(base, size): - for clearance in (0.55, 0.7, 0.9): - try: - dm, _info = place_thin_volume( - base.dm, [TRACE], width=WIDTH, label="Zone", label_value=31, - size=size, clearance=clearance, assembly="fuse", mesher="ladder") - return uw.discretisation.Mesh( - dm, simplex=True, qdegree=2, boundaries=base.boundaries, - coordinate_system_type=base.CoordinateSystem.coordinate_type) - except RuntimeError: - continue - raise RuntimeError(f"band build failed at size {size}") - - -def spine_line(size): - """the cut, sampled at the ladder's own rung vertices, tips TIP inside""" - n_along = max(2, int(round(L / size))) - step = L / n_along - k0 = int(round(TIP / step)) - return a + (np.arange(k0, n_along - k0 + 1) * step)[:, None] * t_hat - - -def split_ribbon(mesh, size): - return mesh.add_fault((FAULT, spine_line(size)), split_levels=False) - - -def contact_stokes(mesh, tag): - x, y = mesh.X - v = uw.discretisation.MeshVariable(f"v{tag}", mesh, 2, degree=2) - p = uw.discretisation.MeshVariable(f"p{tag}", mesh, 1, degree=1, continuous=True) - s = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) - s.constitutive_model = uw.constitutive_models.ViscousFlowModel - s.constitutive_model.Parameters.shear_viscosity_0 = 1.0 - s.bodyforce = [0.0, 0.0] - for wall in ("Bottom", "Top", "Left", "Right"): - s.add_dirichlet_bc((y - 0.5, 0.0), wall) - s.add_fault_bc(0, boundary=FAULT) - s.petsc_use_pressure_nullspace = True - s.tolerance = 1e-5 - s.strategy = "robust" - return s - - -def ti_stokes(mesh, tag, contrast=1e-3): - x, y = mesh.X - v = uw.discretisation.MeshVariable(f"v{tag}", mesh, 2, degree=2) - p = uw.discretisation.MeshVariable(f"p{tag}", mesh, 1, degree=1, continuous=True) - zone = uw.discretisation.MeshVariable(f"zone{tag}", mesh, 1, degree=0, continuous=False) - mask = mesh.cells_labelled("Zone", 31) - zone.array[:, 0, 0] = mask.astype(float) - s = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) - s.constitutive_model = uw.constitutive_models.TransverseIsotropicFlowModel - s.constitutive_model.Parameters.shear_viscosity_0 = 1.0 - s.constitutive_model.Parameters.shear_viscosity_1 = 1.0 - (1.0 - contrast) * zone.sym[0] - s.constitutive_model.Parameters.director = sympy.Matrix([[float(n_hat[0]), float(n_hat[1])]]) - s.bodyforce = [0.0, 0.0] - for wall in ("Bottom", "Top", "Left", "Right"): - s.add_dirichlet_bc((y - 0.5, 0.0), wall) - s.petsc_use_pressure_nullspace = True - s.tolerance = 1e-5 - s.strategy = "robust" - return s, mask - - -def chain_density(velpc): - out = [] - for l in range(velpc.getMGLevels()): - A = velpc.getMGSmoother(l).getOperators()[0] - out.append(round(A.getInfo()["nz_used"] / max(A.getSize()[0], 1), 1)) - return out - - -records = [] -base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), cellSize=H_BG, - regular=False, qdegree=2, refinement=1) -L01 = base._coarse_level_meshes()[:-1] + [base] -ribbons = {} -for size in (0.04, 0.02, 0.01, 0.005, 0.0025): - t0 = time.perf_counter() - ribbons[size] = banded(base, size) - print(f"ribbon {size}: {ribbons[size].dm.getHeightStratum(0)[1]} cells " - f"({time.perf_counter() - t0:.1f} s)", flush=True) -split = {size: split_ribbon(m, size) for size, m in ribbons.items()} - -arms = [] -for mids in ([0.04, 0.02, 0.01, 0.005],): - arms.append((f"fine 0.0025: split mid {mids}", 0.0025, [split[m] for m in mids], "contact")) - -for k, (name, s_fine, mids, kind) in enumerate(arms): - tail = L01 + mids - t0 = time.perf_counter() - if kind == "contact": - mesh = split[s_fine] - stokes = contact_stokes(mesh, f"c{k}") - set_custom_fmg(stokes, tail, field_id=0) - info = fault_contact.solve_with_fault(stokes) - ctx = (getattr(stokes, "_rotated_linear_cache", None) or {})["ctx"] - velpc = ctx["ksp"].getPC().getFieldSplitSubKSP()[0].getPC() - _c, jumps, normals = fault_contact.fault_pair_jumps(stokes, FAULT, stokes._rotated_freeslip_info) - jn = np.einsum("ij,ij->i", jumps, normals) - rec = {"arm": name, "kind": kind, "vel_its": ctx.get("vel_its_last"), - "pres_its": ctx.get("pres_its_last"), "converged": bool(info.get("converged")), - "velocity_pc": velpc.getType(), "nnz_per_row": chain_density(velpc), - "slip": float(np.linalg.norm(jumps - jn[:, None] * normals, axis=1).max()), - "leak": float(np.abs(jn).max()), - "levels": len(tail) + 1, "cells": int(mesh.dm.getHeightStratum(0)[1]), - "t_solve": round(time.perf_counter() - t0, 1)} - else: - mesh = ribbons[s_fine] - stokes, mask = ti_stokes(mesh, f"t{k}") - set_custom_fmg(stokes, tail, field_id=0, fac_zone=mask) - stokes.solve() - ksp = stokes.snes.getKSP() - velksp = ksp.getPC().getFieldSplitSubKSP()[0] - velpc = velksp.getPC() - # iterations of the velocity sub-solve on the last outer application - rec = {"arm": name, "kind": kind, "vel_its": velksp.getIterationNumber(), - "outer_its": ksp.getIterationNumber(), "velocity_pc": velpc.getType(), - "nnz_per_row": chain_density(velpc) if velpc.getType() == "mg" else None, - "levels": len(tail) + 1, "cells": int(mesh.dm.getHeightStratum(0)[1]), - "t_solve": round(time.perf_counter() - t0, 1)} - records.append(rec) - print("ARM", json.dumps(rec), flush=True) -json.dump(records, open(os.path.join(out, "ladder_depth_split4.json"), "w"), indent=1) -print("DONE") diff --git a/scripts/sessions/fault_mg/library_c.py b/scripts/sessions/fault_mg/library_c.py deleted file mode 100644 index b2d1c7b62..000000000 --- a/scripts/sessions/fault_mg/library_c.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Split-level fault multigrid through the LIBRARY path (no monkeypatching). - -Box in simple shear with a straight frictionless (or viscous / Coulomb) fault on an -add-on mesh of k levels. `mesh.add_fault` splits the add-on levels by default -(strategy C); `split_levels=False` keeps the welded tail (A). Reports the velocity -iterations, the peak slip and the stage timings. Runs in serial or under mpirun. - -python library_c.py [positions=1] [law=frictionless|viscous|coulomb] -""" -import json -import os -import sys -import time - -import numpy as np -import underworld3 as uw - -strategy, k = sys.argv[1], int(sys.argv[2]) -positions = int(sys.argv[3]) if len(sys.argv) > 3 else 1 -law = sys.argv[4] if len(sys.argv) > 4 else "frictionless" -HALF = np.array([0.20, 0.05]) -h_base = 0.05 -T = {} - - -def tic(name): - T["_t0"], T["_n"] = time.perf_counter(), name - - -def toc(): - T[T["_n"]] = T.get(T["_n"], 0.0) + time.perf_counter() - T["_t0"] - - -base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), - cellSize=0.1, refinement=1, qdegree=2) - - -def stokes_on(m): - x, y = m.X - s = uw.systems.Stokes(m) - s.constitutive_model = uw.constitutive_models.ViscousFlowModel - s.constitutive_model.Parameters.shear_viscosity_0 = 1.0 - s.tolerance = 1e-6 - s.petsc_use_pressure_nullspace = True - for side in ("Top", "Bottom", "Left", "Right"): - s.add_dirichlet_bc((y - 0.5, 0.0), side) - if law == "frictionless": - s.add_fault_bc(0, "Flt") - elif law == "viscous": - s.add_fault_bc(1.0, "Flt") - else: - from underworld3.utilities.fault_contact import add_coulomb_fault_bc - add_coulomb_fault_bc(s, 0.6, "Flt", sigma_n=0.8) - return s - - -records = [] -for step, cx in enumerate(np.linspace(0.3, 0.7, positions) if positions > 1 else [0.5]): - A_ = np.array([cx, 0.5]) - HALF - B_ = np.array([cx, 0.5]) + HALF - trace = np.vstack([A_, B_]) - tic("adapt") - pts3 = np.column_stack([np.linspace(A_[0], B_[0], 9), np.linspace(A_[1], B_[1], 9), - np.zeros(9)]) - surf = uw.meshing.Surface(f"g{step}", base, pts3) - surf.discretize() - child = base.adapt(surf.refinement_metric_function(h_near=h_base / 2**k, h_far=h_base, - width=0.08, profile="linear"), - max_levels=k) - toc() - tic("add_fault") - finest = child.add_fault([("Flt", trace)], split_levels=(strategy == "C")) - toc() - tail = finest._custom_mg_coarse_meshes - nsplit = sum(1 for m in tail if getattr(m, "_fault_point_pairs", None)) - tic("solver_build") - stokes = stokes_on(finest) - stokes._build() - toc() - tic("solve") - stokes.solve() - toc() - info = stokes._rotated_freeslip_info - from underworld3.utilities.fault_contact import fault_slip - s_, V, leak = fault_slip(stokes, "Flt", info) - peak = float(uw.mpi.comm.allreduce(float(np.abs(V).max()) if len(V) else 0.0, op=max)) - rec = {"strategy": strategy, "k": k, "law": law, "step": step, "cx": float(cx), - "np": uw.mpi.size, - "cells": int(uw.mpi.comm.allreduce(finest.dm.getHeightStratum(0)[1])), - "levels": len(tail) + 1, "split_levels": nsplit, "pc": info["velocity_pc"], - "vel_its": info["vel_its_last"], "pres_its": info["pres_its_last"], - "newton": info.get("nonlinear_iterations"), - "conv": bool(info["converged"]), "peak_slip": peak, - "times": {kk: round(v, 2) for kk, v in T.items() if not kk.startswith("_")}} - T.clear() - records.append(rec) - uw.pprint("STEP " + json.dumps(rec)) -out = os.path.expanduser( - f"~/+Simulations/fault_split_mg/lib_{strategy}_k{k}_{law}_np{uw.mpi.size}.json") -if uw.mpi.rank == 0: - os.makedirs(os.path.dirname(out), exist_ok=True) - json.dump(records, open(out, "w"), indent=1) -uw.pprint("DONE " + out) diff --git a/scripts/sessions/fault_mg/library_c_3d.py b/scripts/sessions/fault_mg/library_c_3d.py deleted file mode 100644 index 60a18df7f..000000000 --- a/scripts/sessions/fault_mg/library_c_3d.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Split-level fault multigrid in 3-D through the library: a planar fault patch in a -box in simple shear, on an add-on mesh of k levels. - -python library_c_3d.py -""" -import json -import os -import sys -import time - -import numpy as np -import underworld3 as uw - -strategy, k = sys.argv[1], int(sys.argv[2]) -h_base = 0.075 -T = {} - - -def tic(name): - T["_t0"], T["_n"] = time.perf_counter(), name - - -def toc(): - T[T["_n"]] = T.get(T["_n"], 0.0) + time.perf_counter() - T["_t0"] - - -base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0, 0), maxCoords=(1, 1, 1), - cellSize=2 * h_base, refinement=1, qdegree=2) -# the patch: x = 0.5, y and z in [0.3, 0.7], a 7 x 7 grid of points (five cells -# across at the base spacing of 0.075; the split needs about four, and the -# placement's cavity must clear the walls). Each quad takes the -# diagonal that leaves no triangle with all three vertices on the rim. -n = 7 -yy, zz = np.meshgrid(np.linspace(0.3, 0.7, n), np.linspace(0.3, 0.7, n), indexing="ij") -pts = np.column_stack([np.full(n * n, 0.5), yy.ravel(), zz.ravel()]) -rim = lambda i, j: i in (0, n - 1) or j in (0, n - 1) -tris = [] -for i in range(n - 1): - for j in range(n - 1): - a, b, c, d = i * n + j, (i + 1) * n + j, (i + 1) * n + j + 1, i * n + j + 1 - ra, rb, rc, rd = rim(i, j), rim(i + 1, j), rim(i + 1, j + 1), rim(i, j + 1) - if (ra and rb and rc) or (ra and rc and rd): - tris += [[a, b, d], [b, c, d]] - else: - tris += [[a, b, c], [a, c, d]] -tris = np.array(tris) - - -def metric(X): - d_plane = np.abs(X[:, 0] - 0.5) - d_rect = np.maximum(0.0, np.maximum(np.abs(X[:, 1] - 0.5) - 0.2, - np.abs(X[:, 2] - 0.5) - 0.2)) - d = np.sqrt(d_plane**2 + d_rect**2) - h_near, h_far, width = h_base / 2**k, h_base, 0.15 - h = np.minimum(h_far, h_near + (h_far - h_near) * d / width) - return 1.0 / h**2 - - -tic("adapt") -child = base.adapt(metric, max_levels=k) -toc() -tic("add_fault") -finest = child.add_fault([("Flt", pts, tris)], split_levels=(strategy == "C"), clearance=0.9) -toc() -tail = finest._custom_mg_coarse_meshes -nsplit = sum(1 for m in tail if getattr(m, "_fault_point_pairs", None)) -x, y, z = finest.X -s = uw.systems.Stokes(finest) -s.constitutive_model = uw.constitutive_models.ViscousFlowModel -s.constitutive_model.Parameters.shear_viscosity_0 = 1.0 -s.tolerance = 1e-6 -s.petsc_use_pressure_nullspace = True -for side in ("Top", "Bottom", "Left", "Right", "Front", "Back"): - s.add_dirichlet_bc((y - 0.5, 0.0, 0.0), side) -s.add_fault_bc(0, "Flt") -tic("solver_build") -s._build() -toc() -tic("solve") -s.solve() -toc() -info = s._rotated_freeslip_info -from underworld3.utilities.fault_contact import fault_pair_jumps -_c, jumps, normals = fault_pair_jumps(s, "Flt", info, gather=True) -tangential = jumps - np.einsum("ij,ij->i", jumps, normals)[:, None] * normals -peak = float(np.linalg.norm(tangential, axis=1).max()) if len(jumps) else 0.0 -rec = {"strategy": strategy, "k": k, "np": uw.mpi.size, - "cells": int(uw.mpi.comm.allreduce(finest.dm.getHeightStratum(0)[1])), - "levels": len(tail) + 1, "split_levels": nsplit, "pc": info["velocity_pc"], - "vel_its": info["vel_its_last"], "pres_its": info["pres_its_last"], - "conv": bool(info["converged"]), "peak_slip": peak, - "times": {kk: round(v, 2) for kk, v in T.items() if not kk.startswith("_")}} -uw.pprint("STEP " + json.dumps(rec)) -out = os.path.expanduser( - f"~/+Simulations/fault_split_mg/lib3d_{strategy}_k{k}_np{uw.mpi.size}.json") -if uw.mpi.rank == 0: - json.dump(rec, open(out, "w"), indent=1) diff --git a/scripts/sessions/fault_mg/parallel_load.py b/scripts/sessions/fault_mg/parallel_load.py deleted file mode 100644 index 9684ef0fc..000000000 --- a/scripts/sessions/fault_mg/parallel_load.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Where the work sits in parallel: per-rank cells on every level of the split-level -hierarchy, the fault star's share, the transfer's rank-local hit rate, and the -time of each stage, at any rank count. - -mpirun -np N python parallel_load.py [positions=1] [base_cell=0.1] -""" -import json -import os -import sys -import time - -import numpy as np -import underworld3 as uw -from mpi4py import MPI - -k = int(sys.argv[1]) -positions = int(sys.argv[2]) if len(sys.argv) > 2 else 1 -base_cell = float(sys.argv[3]) if len(sys.argv) > 3 else 0.1 -comm = uw.mpi.comm -HALF = np.array([0.20, 0.05]) -h_base = base_cell / 2 - - -def owned_cells(mesh): - dm = mesh.dm - cS, cE = dm.getHeightStratum(0) - try: - _n, ileaf, _r = dm.getPointSF().getGraph() - ghost = set(int(q) for q in ileaf if cS <= q < cE) - except (ValueError, TypeError): - ghost = set() - return cE - cS - len(ghost) - - -def fault_star_cells(mesh, name): - """cells incident to a vertex of the fault's Plus or Minus facets (local).""" - dm = mesh.dm - cS, cE = dm.getHeightStratum(0) - fS, fE = dm.getHeightStratum(1) - vS, vE = dm.getDepthStratum(0) - verts = set() - for side in ("Plus", "Minus"): - lab = dm.getLabel(f"{name}{side}") - value = mesh.boundaries[f"{name}{side}"].value - if lab and lab.getStratumSize(value) > 0: - for f in lab.getStratumIS(value).getIndices(): - if fS <= int(f) < fE: - verts.update(int(q) for q in dm.getTransitiveClosure(int(f))[0] - if vS <= int(q) < vE) - cells = set() - for v in verts: - cells.update(int(q) for q in dm.getTransitiveClosure(v, useCone=False)[0] - if cS <= int(q) < cE) - return len(cells) - - -base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), - cellSize=base_cell, refinement=1, qdegree=2) -records = [] -centres = np.linspace(0.3, 0.7, positions) if positions > 1 else [0.5] -for step, cx in enumerate(centres): - T = {} - A_ = np.array([cx, 0.5]) - HALF - B_ = np.array([cx, 0.5]) + HALF - trace = np.vstack([A_, B_]) - t0 = time.perf_counter() - pts3 = np.column_stack([np.linspace(A_[0], B_[0], 9), np.linspace(A_[1], B_[1], 9), - np.zeros(9)]) - surf = uw.meshing.Surface(f"g{step}", base, pts3) - surf.discretize() - child = base.adapt(surf.refinement_metric_function(h_near=h_base / 2**k, h_far=h_base, - width=0.08, profile="linear"), - max_levels=k) - T["adapt"] = time.perf_counter() - t0 - t0 = time.perf_counter() - unsplit_finest = child.add_fault([("Flt", trace)], split_levels=False) - T["split_finest_only"] = time.perf_counter() - t0 - t0 = time.perf_counter() - finest = child.add_fault([("Flt", trace)]) - T["split_all_levels"] = time.perf_counter() - t0 - tail = finest._custom_mg_coarse_meshes - levels = list(tail) + [finest] - load = [] - for li, m in enumerate(levels): - own = comm.allgather(owned_cells(m)) - split = bool(getattr(m, "_fault_point_pairs", None)) - star = comm.allgather(fault_star_cells(m, "Flt") if split else 0) - load.append({"level": li, "split": split, "owned_cells": own, "fault_star": star, - "static": bool(getattr(m, "_mg_static", False))}) - # the unsplit adapt child's balance, for comparison - load_child = comm.allgather(owned_cells(child)) - - x, y = finest.X - s = uw.systems.Stokes(finest) - s.constitutive_model = uw.constitutive_models.ViscousFlowModel - s.constitutive_model.Parameters.shear_viscosity_0 = 1.0 - s.tolerance = 1e-6 - s.petsc_use_pressure_nullspace = True - for side in ("Top", "Bottom", "Left", "Right"): - s.add_dirichlet_bc((y - 0.5, 0.0), side) - s.add_fault_bc(0, "Flt") - t0 = time.perf_counter() - s._build() - T["solver_build"] = time.perf_counter() - t0 - from underworld3.utilities import custom_mg, rotated_bc - _orig_build = custom_mg.CustomMGHierarchy.build - _orig_pl = rotated_bc._build_rotated_custom_Pl - - def timed_build(self, solver): - t = time.perf_counter() - out = _orig_build(self, solver) - T["transfers"] = T.get("transfers", 0.0) + time.perf_counter() - t - T["_stats"] = self.level_transfer_stats - return out - - def timed_pl(solver, Q, normal_rows): - t = time.perf_counter() - out = _orig_pl(solver, Q, normal_rows) - T["prolongations_total"] = T.get("prolongations_total", 0.0) + time.perf_counter() - t - return out - custom_mg.CustomMGHierarchy.build = timed_build - rotated_bc._build_rotated_custom_Pl = timed_pl - t0 = time.perf_counter() - s.solve() - T["solve_total"] = time.perf_counter() - t0 - custom_mg.CustomMGHierarchy.build = _orig_build - rotated_bc._build_rotated_custom_Pl = _orig_pl - info = s._rotated_freeslip_info - stats = T.pop("_stats", None) - rec = {"np": comm.size, "k": k, "step": step, "cx": float(cx), - "cells_total": int(sum(load[-1]["owned_cells"])), - "levels": len(levels), "pc": info["velocity_pc"], "vel_its": info["vel_its_last"], - "converged": bool(info["converged"]), "load": load, "load_unsplit_child": load_child, - "transfer_stats": stats, - "times": {kk: round(v, 2) for kk, v in T.items()}} - records.append(rec) - uw.pprint("STEP " + json.dumps({kk: v for kk, v in rec.items() if kk != "load"})) -tag = "" if base_cell == 0.1 else f"_b{base_cell}" -out = os.path.expanduser(f"~/+Simulations/fault_split_mg/load_k{k}{tag}_np{comm.size}.json") -if comm.rank == 0: - json.dump(records, open(out, "w"), indent=1) -uw.pprint("DONE " + out) diff --git a/scripts/sessions/fault_mg/stage4_thick.py b/scripts/sessions/fault_mg/stage4_thick.py deleted file mode 100644 index be70f9805..000000000 --- a/scripts/sessions/fault_mg/stage4_thick.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Stage 4: case C on the 3-D extruded faulted layer. - -Today's extrude tail: lateral levels (unsplit sheet levels, 1 layer) + through-thickness levels -extruded from the UNSPLIT cut sheet. Variant C: the through-thickness levels are extruded from the -SPLIT sheet (they keep the fault), cell-located side-aware transfers, per-level pair rotation. - -python stage4_thick.py -""" -import os, sys, time -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "..", "tests")) -import numpy as np, scipy.sparse as sp, underworld3 as uw -from petsc4py import PETSc -from underworld3.utilities import custom_mg, rotated_bc -from underworld3.meshing.extrude import extrude_sheet -from cellP import cell_located_prolongation -import test_1083_thin_thick_alignment as T -ref, layers, variant = int(sys.argv[1]), int(sys.argv[2]), sys.argv[3] -sheet = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), cellSize=0.1, refinement=ref, qdegree=3) -split = sheet.add_fault([("Flt", np.array([[0.25, 0.5], [0.75, 0.5]]))]) -layer_mesh = extrude_sheet(split, T.L, layers=layers) # stock tail attached -layer = T.model(layer_mesh, uw.systems.Stokes) - -def to_scipy(M): - ai, aj, av = M.getValuesCSR() - return sp.csr_matrix((av, aj, ai), shape=M.getSize()) - -def to_petsc(S): - S = S.tocsr(); S.sort_indices() - return PETSc.Mat().createAIJ(size=S.shape, csr=(S.indptr.astype(PETSc.IntType), S.indices.astype(PETSc.IntType), S.data), - comm=PETSc.COMM_SELF) - -if variant == "split": - lateral = [extrude_sheet(lev, T.L, layers=1, with_tail=False) for lev in list(sheet._coarse_level_meshes())[:-1]] - vertical = [] - n = layers // 2 - while n >= 1: - vertical.append(extrude_sheet(split, T.L, layers=n, with_tail=False)); n //= 2 - levels = lateral + list(reversed(vertical)) - rot = [] - for m in levels: - if getattr(m, "_fault_point_pairs", None): - s = T.model(m, uw.systems.Stokes); s._build(); s.snes.setUp() - Q, Qt, nrows, _ = rotated_bc.build_rotation(s, s._rotated_freeslip_bcs) - vis = s._subdict["velocity"][0]; vidx = np.asarray(vis.getIndices()) - g2b = {int(g): i for i, g in enumerate(vidx)} - rot.append((to_scipy(Q.createSubMatrix(vis, vis)), sorted({g2b[g] for g in nrows if g in g2b}))) - else: - rot.append(None) - - def my_Pl(solver, Q, normal_rows): - vis = solver._subdict["velocity"][0]; vidx = np.asarray(vis.getIndices()) - g2b = {int(g): i for i, g in enumerate(vidx)} - Qf = to_scipy(Q.createSubMatrix(vis, vis)); nf = sorted({g2b[g] for g in normal_rows if g in g2b}) - meshes = levels + [solver.mesh] - rmaps = [custom_mg._coarse_reduced_map(solver, m, 0)[0] for m in levels] + [custom_mg._reduced_map(solver.dm, 0)[0]] - rots = rot + [(Qf, nf)] - keep = [np.setdiff1d(np.arange(len(rm)), r[1]) if r is not None else np.arange(len(rm)) for r, rm in zip(rots, rmaps)] - Pl = [] - for l in range(len(meshes) - 1): - Pn, inside = cell_located_prolongation(meshes[l], meshes[l + 1]) - P = custom_mg._reduced_from_node_transfer(Pn, rmaps[l], rmaps[l + 1], 3).tocsr() - if rots[l + 1] is not None: P = rots[l + 1][0] @ P - if rots[l] is not None: P = P @ rots[l][0].T - P = P[:, keep[l]] - if l + 1 == len(meshes) - 1: - P = P.tolil(); P[nf, :] = 0; P = P.tocsr() - else: - P = P[keep[l + 1], :] - P.eliminate_zeros() - Pl.append(to_petsc(P)) - if getattr(solver, "_custom_mg", None) is None: - solver._custom_mg = {"mode": "hierarchy", "hierarchy": None, "verbose": False} - return Pl - rotated_bc._build_rotated_custom_Pl = my_Pl - -layer.solve() -t0 = time.time(); layer.solve(zero_init_guess=True); wall = time.time() - t0 -info = layer._rotated_freeslip_info -print(f"RESULT thick ref={ref} h={0.1/2**ref:.4f} layers={layers} variant={variant} pc={info['velocity_pc']} outer={info['ksp_its']} " - f"vel_its={info['vel_its_last']} pres_its={info['pres_its_last']} conv={info['converged']} " - f"umax={np.abs(np.asarray(layer.u.array)).max():.6f} wall={wall:.1f}s", flush=True) diff --git a/scripts/sessions/fault_mg/stage5_variants.py b/scripts/sessions/fault_mg/stage5_variants.py deleted file mode 100644 index 9f0849ca1..000000000 --- a/scripts/sessions/fault_mg/stage5_variants.py +++ /dev/null @@ -1,195 +0,0 @@ -"""Fault multigrid hierarchies, four alternatives through one driver (2-D add-on Stokes, box in -simple shear, split fault). The background mesh (static base, levels L0 h=0.1, L1 h=0.05) is never -cut; add-on level j (j = 1..k) is base.adapt(h_near = 0.05 / 2^j), split by add_fault where the -variant asks. Transfers are cell-located Lagrange prolongations (cellP.py), side-aware on split -levels; every split level carries its own pair rotation with the opening DOFs removed. - - A : finest split; add-on levels welded; everything Galerkin - B2 : finest split; add-on levels welded with a TI band, REDISCRETISED; background Galerkin - C : all add-on levels split; everything Galerkin - D : all add-on levels split (Galerkin); background levels rediscretised with a broad TI zone - stock: today's hierarchy (mesh.adapt + add_fault, existing transfers) — the A reference - -python stage5_variants.py [weak=0.1] [wfac=1.0] - TI band: Gaussian in distance to the trace, half-width wfac * (level's near-fault h); - eta_weak = weak*eta (frictionless) or eta_f*w (viscous); geometric blend; director = fault normal. -""" -import os, sys, time -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import numpy as np, scipy.sparse as sp, sympy, underworld3 as uw -from petsc4py import PETSc -from underworld3.utilities import custom_mg, rotated_bc -from cellP import cell_located_prolongation -k, law, variant = int(sys.argv[1]), sys.argv[2], sys.argv[3] -weak = float(sys.argv[4]) if len(sys.argv) > 4 else 0.1 -wfac = float(sys.argv[5]) if len(sys.argv) > 5 else 1.0 -TIP_A, TIP_B = np.array([0.30, 0.45]), np.array([0.70, 0.55]) -FAULT = [("Flt", np.vstack([TIP_A, TIP_B]))] -ETA, ETA_F = 1.0, 1.0 -base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), cellSize=0.1, refinement=1, qdegree=2) -pts3 = np.column_stack([np.linspace(TIP_A[0], TIP_B[0], 9), np.linspace(TIP_A[1], TIP_B[1], 9), np.zeros(9)]) -surf = uw.meshing.Surface("g", base, pts3); surf.discretize() -h_base = 0.05 -AB = TIP_B - TIP_A -NRM = np.array([-AB[1], AB[0]]) / np.linalg.norm(AB) - -def addon(j, split): - ch = base.adapt(surf.refinement_metric_function(h_near=h_base / 2**j, h_far=h_base, width=0.08, profile="linear"), - max_levels=j) - return ch.add_fault(FAULT) if split else ch - -def ti_params(m, h_level): - xc, yc = m.X - A_, B_ = sympy.Matrix(TIP_A.tolist()), sympy.Matrix(TIP_B.tolist()); ab = B_ - A_ - t = ((xc - A_[0]) * ab[0] + (yc - A_[1]) * ab[1]) / ab.dot(ab) - tc = sympy.Min(1, sympy.Max(0, t)) - d = sympy.sqrt((xc - A_[0] - tc * ab[0])**2 + (yc - A_[1] - tc * ab[1])**2) - w = wfac * h_level - f = sympy.exp(-(d / w)**2) - eta_w = weak * ETA if law == "frictionless" else ETA_F * w - return {"eta1": ETA**(1 - f) * eta_w**f, "director": sympy.Matrix([[NRM[0], NRM[1]]])} - -def stokes_on(m, fault=True, ti=None): - x, y = m.X - s = uw.systems.Stokes(m) - if ti is None: - s.constitutive_model = uw.constitutive_models.ViscousFlowModel - s.constitutive_model.Parameters.shear_viscosity_0 = ETA - else: - s.constitutive_model = uw.constitutive_models.TransverseIsotropicFlowModel - s.constitutive_model.Parameters.shear_viscosity_0 = ETA - s.constitutive_model.Parameters.shear_viscosity_1 = ti["eta1"] - s.constitutive_model.Parameters.director = ti["director"] - s.tolerance = 1e-6 - s.petsc_use_pressure_nullspace = True - for side in ("Top", "Bottom", "Left", "Right"): - s.add_dirichlet_bc((y - 0.5, 0.0), side) - if fault: - s.add_fault_bc(0 if law == "frictionless" else ETA_F, "Flt") - return s - -def to_scipy(M): - ai, aj, av = M.getValuesCSR() - return sp.csr_matrix((av, aj, ai), shape=M.getSize()) - -def to_petsc(S): - S = S.tocsr(); S.sort_indices() - return PETSc.Mat().createAIJ(size=S.shape, csr=(S.indptr.astype(PETSc.IntType), S.indices.astype(PETSc.IntType), S.data), - comm=PETSc.COMM_SELF) - -finest = addon(k, True) -stokes = stokes_on(finest) - -if variant != "stock": - background = list(base._coarse_level_meshes()) # L0, L1 - split_addons = variant in ("C", "D", "Cm") - addons = [addon(j, split_addons) for j in range(1, k)] - levels = background + addons - h_levels = [0.1, 0.05] + [h_base / 2**j for j in range(1, k)] - # which coarse levels get a rediscretised TI operator - if variant == "B2": - ti_levels = set(range(2, len(levels))) # every add-on level below the finest - elif variant == "D": - ti_levels = {0, 1} # the background levels - else: - ti_levels = set() - rot, redisc = [], {} - for li, m in enumerate(levels): - if getattr(m, "_fault_point_pairs", None): - s = stokes_on(m); s._build() - Q, Qt, nrows, _ = rotated_bc.build_rotation(s, s._rotated_freeslip_bcs) - vis = s._subdict["velocity"][0]; vidx = np.asarray(vis.getIndices()) - g2b = {int(g): i for i, g in enumerate(vidx)} - rot.append((to_scipy(Q.createSubMatrix(vis, vis)), sorted({g2b[g] for g in nrows if g in g2b}))) - else: - rot.append(None) - if li in ti_levels: - s = stokes_on(m, fault=False, ti=ti_params(m, h_levels[li])) - s.penalty = float(stokes.penalty.sym) if hasattr(stokes.penalty, "sym") else stokes.penalty - s._build() - X = s.dm.getGlobalVec(); X.set(0.0) - J, Jp = s.snes.getJacobian()[:2] - s.snes.computeJacobian(X, J, Jp) - vis = s._subdict["velocity"][0] - redisc[li] = J.createSubMatrix(vis, vis) - - state = {} - def my_Pl(solver, Q, normal_rows): - vis = solver._subdict["velocity"][0]; vidx = np.asarray(vis.getIndices()) - g2b = {int(g): i for i, g in enumerate(vidx)} - Qf = to_scipy(Q.createSubMatrix(vis, vis)); nf = sorted({g2b[g] for g in normal_rows if g in g2b}) - meshes = levels + [solver.mesh] - rmaps = [custom_mg._coarse_reduced_map(solver, m, 0)[0] for m in levels] + [custom_mg._reduced_map(solver.dm, 0)[0]] - rots = rot + [(Qf, nf)] - keep = [np.setdiff1d(np.arange(len(rm)), r[1]) if r is not None else np.arange(len(rm)) for r, rm in zip(rots, rmaps)] - # Built from the finest level down, so each level's admissible set is final - # before the level below is prolongated into it. A coarse unknown that no fine - # unknown reaches (a pair DOF near a tip of non-nested split levels) is dropped: - # it is a null mode of the Galerkin coarse operator (a zero diagonal under SOR). - nlev_ = len(meshes) - Pl = [None] * (nlev_ - 1) - for l in range(nlev_ - 2, -1, -1): - Pn, inside = cell_located_prolongation(meshes[l], meshes[l + 1]) - P = custom_mg._reduced_from_node_transfer(Pn, rmaps[l], rmaps[l + 1], 2).tocsr() - if rots[l + 1] is not None: P = rots[l + 1][0] @ P - if rots[l] is not None: P = P @ rots[l][0].T - if l + 1 == nlev_ - 1: - P = P.tolil(); P[nf, :] = 0; P = P.tocsr() - else: - P = P[keep[l + 1], :] - P = P[:, keep[l]] - P.eliminate_zeros() - cn = np.sqrt(np.asarray(P.multiply(P).sum(axis=0)).ravel()) - reached = cn > 0.05 * np.median(cn[cn > 0]) - dropped = int((~reached).sum()) - if dropped: - print(f"DEBUGP level {l}: dropped {dropped} unreached coarse DOFs", flush=True) - keep[l] = keep[l][reached] - P = P[:, np.flatnonzero(reached)] - Pl[l] = to_petsc(P) - if getattr(solver, "_custom_mg", None) is None: - solver._custom_mg = {"mode": "hierarchy", "hierarchy": None, "verbose": False} - state["keep"] = keep - return Pl - rotated_bc._build_rotated_custom_Pl = my_Pl - - if redisc or variant == "Cm": - orig = custom_mg._configure_pcmg - def with_redisc(pc, Pl, *a, **kw): - out = orig(pc, Pl, *a, **kw) - nlev = len(Pl) + 1 - ops = [None] * nlev - ops[nlev - 1] = pc.getOperators()[0] - for l in range(nlev - 2, -1, -1): - if l in redisc: # rediscretised level, on its admissible set - kl = state["keep"][l] - if len(kl) == redisc[l].getSize()[0]: - ops[l] = redisc[l] - else: - isk = PETSc.IS().createGeneral(np.asarray(kl, dtype=PETSc.IntType), comm=PETSc.COMM_SELF) - ops[l] = redisc[l].createSubMatrix(isk, isk) - else: - ops[l] = ops[l + 1].PtAP(Pl[l]) - for l in range(nlev): - d = ops[l].getDiagonal().getArray() - print(f"DEBUG level {l} size {ops[l].getSize()} type {ops[l].getType()} norm {ops[l].norm():.3e} " - f"diag min {np.abs(d).min():.3e} nan {int(np.isnan(d).sum())} P {Pl[l].getSize() if l < nlev - 1 else '-'}", flush=True) - PETSc.Options()[(pc.getOptionsPrefix() or "") + "pc_mg_galerkin"] = "none" - pc.setFromOptions() - for l in range(nlev - 1): - kk = pc.getMGCoarseSolve() if l == 0 else pc.getMGSmoother(l) - kk.setOperators(ops[l], ops[l]) - return out - custom_mg._configure_pcmg = with_redisc - -opts = PETSc.Options() -for i in range(1, 6): - opts[f"rotfs{i}_fieldsplit_vel_ksp_converged_reason"] = None -stokes.solve() -t0 = time.time(); stokes.solve(zero_init_guess=True); wall = time.time() - t0 -info = stokes._rotated_freeslip_info -from underworld3.utilities.fault_contact import fault_slip -s_, V, leak = fault_slip(stokes, "Flt", info) -print(f"RESULT V k={k} law={law} variant={variant} weak={weak} wfac={wfac} h_near={h_base / 2**k:.5f} " - f"pc={info['velocity_pc']} vel_its={info['vel_its_last']} pres_its={info['pres_its_last']} conv={info['converged']} " - f"peak_slip={np.abs(V).max():.5f} wall={wall:.1f}s", flush=True) diff --git a/scripts/sessions/fault_mg/stage6_moving.py b/scripts/sessions/fault_mg/stage6_moving.py deleted file mode 100644 index cc74d1492..000000000 --- a/scripts/sessions/fault_mg/stage6_moving.py +++ /dev/null @@ -1,205 +0,0 @@ -"""Moving fault on a static background: per-step cost of add-on meshing, fault surgery, -hierarchy building and solving, for strategies A / C / D / gamg. - -Box in simple shear, frictionless straight fault of fixed length and orientation whose centre -migrates x = 0.3 -> 0.7 in `steps` steps. The background (UnstructuredSimplexBox refinement=1) is -built ONCE and never changes; every step re-adapts the add-on part from it (one mesh.adapt call), -splits the finest level, and (C, D) splits each intermediate NVB level of that same adapt call. - -python stage6_moving.py [steps=8] -""" -import os, sys, time, json -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) -import numpy as np, scipy.sparse as sp, sympy, underworld3 as uw -from petsc4py import PETSc -from underworld3.utilities import custom_mg, rotated_bc -from cellP import cell_located_prolongation -strategy, k = sys.argv[1], int(sys.argv[2]) -steps = int(sys.argv[3]) if len(sys.argv) > 3 else 8 -HALF = np.array([0.20, 0.05]) # half-vector of the fault (length 0.41) -ETA, WEAK = 1.0, 0.1 -h_base = 0.05 -T = {} -def tic(name): - T.setdefault(name, 0.0); T["_t0"] = time.perf_counter(); T["_n"] = name -def toc(): - T[T["_n"]] += time.perf_counter() - T["_t0"] - -t_bg = time.perf_counter() -base = uw.meshing.UnstructuredSimplexBox(minCoords=(0, 0), maxCoords=(1, 1), cellSize=0.1, refinement=1, qdegree=2) -background = list(base._coarse_level_meshes()) -t_bg = time.perf_counter() - t_bg - -def to_scipy(M): - ai, aj, av = M.getValuesCSR(); return sp.csr_matrix((av, aj, ai), shape=M.getSize()) -def to_petsc(S): - S = S.tocsr(); S.sort_indices() - return PETSc.Mat().createAIJ(size=S.shape, csr=(S.indptr.astype(PETSc.IntType), S.indices.astype(PETSc.IntType), S.data), - comm=PETSc.COMM_SELF) - -def stokes_on(m, fault=True, ti=None): - x, y = m.X - s = uw.systems.Stokes(m) - if ti is None: - s.constitutive_model = uw.constitutive_models.ViscousFlowModel - s.constitutive_model.Parameters.shear_viscosity_0 = ETA - else: - s.constitutive_model = uw.constitutive_models.TransverseIsotropicFlowModel - s.constitutive_model.Parameters.shear_viscosity_0 = ETA - s.constitutive_model.Parameters.shear_viscosity_1 = ti["eta1"] - s.constitutive_model.Parameters.director = ti["director"] - s.tolerance = 1e-6 - s.petsc_use_pressure_nullspace = True - for side in ("Top", "Bottom", "Left", "Right"): - s.add_dirichlet_bc((y - 0.5, 0.0), side) - if fault: - s.add_fault_bc(0, "Flt") - return s - -def ti_params(m, A, B, h_level): - xc, yc = m.X - A_, B_ = sympy.Matrix(A.tolist()), sympy.Matrix(B.tolist()); ab = B_ - A_ - t = ((xc - A_[0]) * ab[0] + (yc - A_[1]) * ab[1]) / ab.dot(ab) - tc = sympy.Min(1, sympy.Max(0, t)) - d = sympy.sqrt((xc - A_[0] - tc * ab[0])**2 + (yc - A_[1] - tc * ab[1])**2) - f = sympy.exp(-(d / h_level)**2) - nrm = np.array([-(B - A)[1], (B - A)[0]]); nrm /= np.linalg.norm(nrm) - return {"eta1": ETA**(1 - f) * (WEAK * ETA)**f, "director": sympy.Matrix([[nrm[0], nrm[1]]])} - -state = {} -NT = {} -def timed(name, fn): - def wrapper(*a, **kw): - t0 = time.perf_counter() - try: - return fn(*a, **kw) - finally: - NT[name] = NT.get(name, 0.0) + time.perf_counter() - t0 - return wrapper -custom_mg.build_transfers = timed("transfers", custom_mg.build_transfers) -rotated_bc.build_rotation = timed("rotation", rotated_bc.build_rotation) -def my_Pl(solver, Q, normal_rows): - levels, rot = state["levels"], state["rot"] - vis = solver._subdict["velocity"][0]; vidx = np.asarray(vis.getIndices()) - g2b = {int(g): i for i, g in enumerate(vidx)} - Qf = to_scipy(Q.createSubMatrix(vis, vis)); nf = sorted({g2b[g] for g in normal_rows if g in g2b}) - meshes = levels + [solver.mesh] - rmaps = [custom_mg._coarse_reduced_map(solver, m, 0)[0] for m in levels] + [custom_mg._reduced_map(solver.dm, 0)[0]] - rots = rot + [(Qf, nf)] - keep = [np.setdiff1d(np.arange(len(rm)), r[1]) if r is not None else np.arange(len(rm)) for r, rm in zip(rots, rmaps)] - nlev = len(meshes); Pl = [None] * (nlev - 1) - for l in range(nlev - 2, -1, -1): - Pn, _ = cell_located_prolongation(meshes[l], meshes[l + 1]) - P = custom_mg._reduced_from_node_transfer(Pn, rmaps[l], rmaps[l + 1], 2).tocsr() - if rots[l + 1] is not None: P = rots[l + 1][0] @ P - if rots[l] is not None: P = P @ rots[l][0].T - if l + 1 == nlev - 1: - P = P.tolil(); P[nf, :] = 0; P = P.tocsr() - else: - P = P[keep[l + 1], :] - P = P[:, keep[l]]; P.eliminate_zeros() - # a coarse unknown reached only weakly (tiny or extrapolated weights near a tip of - # non-nested split levels) gives a near-zero Galerkin diagonal: drop it with the - # unreached ones (column 2-norm below 5% of the median column) - cn = np.sqrt(np.asarray(P.multiply(P).sum(axis=0)).ravel()) - reached = cn > 0 - if not reached.all(): - keep[l] = keep[l][reached]; P = P[:, np.flatnonzero(reached)] - Pl[l] = to_petsc(P) - state["keep"] = keep - if getattr(solver, "_custom_mg", None) is None: - solver._custom_mg = {"mode": "hierarchy", "hierarchy": None, "verbose": False} - return Pl - -orig_configure = custom_mg._configure_pcmg -def with_redisc(pc, Pl, *a, **kw): - out = orig_configure(pc, Pl, *a, **kw) - redisc = state.get("redisc") or {} - if not redisc: - return out - nlev = len(Pl) + 1 - ops = [None] * nlev - ops[nlev - 1] = pc.getOperators()[0] - for l in range(nlev - 2, -1, -1): - if l in redisc: - kl = state["keep"][l] - ops[l] = redisc[l] if len(kl) == redisc[l].getSize()[0] else redisc[l].createSubMatrix( - *(2 * [PETSc.IS().createGeneral(np.asarray(kl, dtype=PETSc.IntType), comm=PETSc.COMM_SELF)])) - else: - ops[l] = ops[l + 1].PtAP(Pl[l]) - PETSc.Options()[(pc.getOptionsPrefix() or "") + "pc_mg_galerkin"] = "none" - pc.setFromOptions() - for l in range(nlev - 1): - (pc.getMGCoarseSolve() if l == 0 else pc.getMGSmoother(l)).setOperators(ops[l], ops[l]) - return out -if strategy in ("C", "D"): - rotated_bc._build_rotated_custom_Pl = timed("transfers", my_Pl) - custom_mg._configure_pcmg = timed("mg_setup", with_redisc) - -records = [] -centres = np.linspace(0.3, 0.7, steps) -for step, cx in enumerate(centres): - for key in [k_ for k_ in T if not k_.startswith("_")]: - T[key] = 0.0 - A_ = np.array([cx, 0.5]) - HALF; B_ = np.array([cx, 0.5]) + HALF - trace = np.vstack([A_, B_]) - tic("addon_adapt") - pts3 = np.column_stack([np.linspace(A_[0], B_[0], 9), np.linspace(A_[1], B_[1], 9), np.zeros(9)]) - surf = uw.meshing.Surface(f"g{step}", base, pts3); surf.discretize() - child = base.adapt(surf.refinement_metric_function(h_near=h_base / 2**k, h_far=h_base, width=0.08, profile="linear"), - max_levels=k) - toc() - tic("split_finest") - finest = child.add_fault([("Flt", trace)]) - toc() - tail = list(child._custom_mg_coarse_meshes) - inter = tail[len(background):] - if strategy in ("C", "D"): - tic("split_levels") - split_inter = [lev.add_fault([("Flt", trace)]) for lev in inter] - toc() - state["levels"] = background + split_inter - tic("level_frames") - rot = [None] * len(background) - for m in split_inter: - s = stokes_on(m); s._build(); s.snes.setUp() - Q, Qt, nrows, _ = rotated_bc.build_rotation(s, s._rotated_freeslip_bcs) - vis = s._subdict["velocity"][0]; vidx = np.asarray(vis.getIndices()) - g2b = {int(g): i for i, g in enumerate(vidx)} - rot.append((to_scipy(Q.createSubMatrix(vis, vis)), sorted({g2b[g] for g in nrows if g in g2b}))) - state["rot"] = rot - toc() - state["redisc"] = {} - if strategy == "D": - tic("background_TI") - for li, (m, hl) in enumerate(zip(background, (0.1, 0.05))): - s = stokes_on(m, fault=False, ti=ti_params(m, A_, B_, hl)); s._build(); s.snes.setUp() - X = s.dm.getGlobalVec(); X.set(0.0) - J, Jp = s.snes.getJacobian()[:2]; s.snes.computeJacobian(X, J, Jp) - state["redisc"][li] = J.createSubMatrix(*(2 * [s._subdict["velocity"][0]])) - toc() - tic("solver_build") - stokes = stokes_on(finest) - if strategy == "gamg": - stokes.preconditioner = "gamg" - toc() - tic("jit_build") - stokes._build() - toc() - NT.clear() - tic("first_solve") # assembly, hierarchy transfers, MG setup, Krylov - stokes.solve() - toc() - info = stokes._rotated_freeslip_info - from underworld3.utilities.fault_contact import fault_slip - s_, V, leak = fault_slip(stokes, "Flt", info) - rec = {"step": step, "cx": float(cx), "cells": int(finest.dm.getHeightStratum(0)[1]), - "levels": len(tail) + 1, "pc": info["velocity_pc"], "vel_its": info["vel_its_last"], - "pres_its": info["pres_its_last"], "conv": bool(info["converged"]), "peak_slip": float(np.abs(V).max()), - "times": {kk: round(v, 3) for kk, v in T.items() if not kk.startswith("_")}, - "inside_solve": {kk: round(v, 3) for kk, v in NT.items()}} - records.append(rec) - print("STEP", json.dumps(rec), flush=True) -out = os.path.expanduser(f"~/+Simulations/fault_mg_exploration/moving_{strategy}_k{k}{os.environ.get('TAG', '')}.json") -json.dump({"strategy": strategy, "k": k, "background_build": t_bg, "records": records}, open(out, "w"), indent=1) -print("DONE", out, flush=True) diff --git a/scripts/sessions/fault_mg/summarise_moving.py b/scripts/sessions/fault_mg/summarise_moving.py deleted file mode 100644 index db80a53e6..000000000 --- a/scripts/sessions/fault_mg/summarise_moving.py +++ /dev/null @@ -1,30 +0,0 @@ -import json, glob, os -import numpy as np -D = os.path.expanduser("~/+Simulations/fault_mg_exploration") # where stage6_moving.py writes -runs = {} -for f in sorted(glob.glob(f"{D}/moving_*_k*.json")): - d = json.load(open(f)); runs[(d["strategy"], d["k"])] = d -phases = ["addon_adapt", "split_finest", "split_levels", "level_frames", "background_TI", "solver_build", "jit_build", "first_solve"] -for k in sorted({k for _, k in runs}): - print(f"\n=== k = {k} (mean seconds per step over {len(next(iter(runs.values()))['records'])} steps) ===") - hdr = f"{'':6s}" + "".join(f"{p[:12]:>13s}" for p in phases) + f"{'transfers':>11s}{'TOTAL':>9s}{'vel its':>11s}{'levels':>8s}" - print(hdr) - for s in ("A", "C", "D", "gamg"): - if (s, k) not in runs: continue - R = runs[(s, k)]["records"] - row = f"{s:6s}" - tot = 0.0 - for p in phases: - v = np.mean([r["times"].get(p, 0.0) for r in R]); tot += v - row += f"{v:13.2f}" - tr = np.mean([r.get("inside_solve", {}).get("transfers", 0.0) for r in R]) - its = [r["vel_its"][-1] for r in R] - lev = [r["levels"] for r in R] - conv = all(r["conv"] for r in R) - row += f"{tr:11.2f}{tot:9.2f}{min(its):>6d}-{max(its):<4d}{min(lev):>4d}-{max(lev)}" + ("" if conv else " NOT CONVERGED") - print(row) - # correctness: peak slip per step, all strategies - print("peak slip per step:") - for s in ("A", "C", "D", "gamg"): - if (s, k) in runs: - print(f" {s:5s}", " ".join(f"{r['peak_slip']:.5f}" for r in runs[(s, k)]["records"])) diff --git a/scripts/sessions/fault_mg/thick_parallel.py b/scripts/sessions/fault_mg/thick_parallel.py deleted file mode 100644 index 64dd7e720..000000000 --- a/scripts/sessions/fault_mg/thick_parallel.py +++ /dev/null @@ -1,69 +0,0 @@ -"""The faulted thick plate (test_1083's model) through the parallel extrusion: -pair count, multigrid levels, velocity iterations and peak slip at any np. - -mpirun -np N python thick_parallel.py [k] (k add-on levels; 0 = plain refinement) -""" -import json -import os -import sys -import time - -import numpy as np -import sympy -import underworld3 as uw - -k = int(sys.argv[1]) if len(sys.argv) > 1 else 0 -L, ETA, C = 0.04, 1, 1 -t0 = time.perf_counter() -base = uw.meshing.UnstructuredSimplexBox( - minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.1, refinement=1, qdegree=3) -trace = np.array([[0.25, 0.5], [0.75, 0.5]]) -mesh = base -if k: - pts3 = np.column_stack([np.linspace(0.25, 0.75, 9), np.full(9, 0.5), np.zeros(9)]) - surf = uw.meshing.Surface("g", base, pts3) - surf.discretize() - mesh = base.adapt(surf.refinement_metric_function(h_near=0.05 / 2**k, h_far=0.05, - width=0.08, profile="linear"), - max_levels=k) -sheet_mesh = mesh.add_fault([("Flt", trace)]) -t_sheet = time.perf_counter() - t0 -t0 = time.perf_counter() -layer_mesh = sheet_mesh.extrude(L, layers=2) -t_extrude = time.perf_counter() - t0 -n_pairs = int(uw.mpi.comm.allreduce(sum(len(p) for p in layer_mesh._fault_point_pairs.values()))) -tail = layer_mesh._custom_mg_coarse_meshes or [] -n_split = sum(1 for m in tail if getattr(m, "_fault_point_pairs", None)) - - -def model(mesh, Solver): - solver = Solver(mesh) - solver.constitutive_model = uw.constitutive_models.ViscousFlowModel - solver.constitutive_model.Parameters.shear_viscosity_0 = ETA - y = mesh.X[1] - base_velocity = [y - sympy.Rational(1, 2), 0] + [0] * (mesh.cdim - 2) - solver.add_basal_drag(C, "Base", reference_velocity=base_velocity) - solver.add_fault_bc(0, "Flt") - solver.tolerance = 1.0e-5 - return solver - - -layer = model(layer_mesh, uw.systems.Stokes) -t0 = time.perf_counter() -layer.solve() -t_solve = time.perf_counter() - t0 -info = layer._rotated_freeslip_info -from underworld3.utilities.fault_contact import fault_pair_jumps -_c, jumps, normals = fault_pair_jumps(layer, "Flt", info, gather=True) -tangential = jumps - np.einsum("ij,ij->i", jumps, normals)[:, None] * normals -peak = float(np.linalg.norm(tangential, axis=1).max()) if len(jumps) else 0.0 -leak = float(np.abs(np.einsum("ij,ij->i", jumps, normals)).max()) if len(jumps) else 0.0 -cells = int(uw.mpi.comm.allreduce(layer_mesh.dm.getHeightStratum(0)[1])) -rec = {"np": uw.mpi.size, "k": k, "cells": cells, "pairs": n_pairs, "levels": len(tail) + 1, - "split_levels": n_split, "pc": info["velocity_pc"], "vel_its": info["vel_its_last"], - "pres_its": info["pres_its_last"], "conv": bool(info["converged"]), "peak_slip": peak, "leak": leak, - "t_sheet": round(t_sheet, 2), "t_extrude": round(t_extrude, 2), "t_solve": round(t_solve, 2)} -uw.pprint("STEP " + json.dumps(rec)) -out = os.path.expanduser(f"~/+Simulations/fault_split_mg/thick_k{k}_np{uw.mpi.size}.json") -if uw.mpi.rank == 0: - json.dump(rec, open(out, "w"), indent=1) diff --git a/scripts/sessions/fault_mg/zg95_annulus.py b/scripts/sessions/fault_mg/zg95_annulus.py deleted file mode 100644 index 466e8b502..000000000 --- a/scripts/sessions/fault_mg/zg95_annulus.py +++ /dev/null @@ -1,565 +0,0 @@ -"""A Zhong & Gurnis (1995) analogue on the annulus: temperature-dependent -convection with a dipping trench fault that moves with the overriding plate, -surface velocity and dynamic topography read every step. - -The base annulus (static, balanced, never cut) carries the temperature and -the advection-diffusion. The add-on finest is the base's finest with the -trench placed as the structured ladder (one element either side of the -spine, outcropping through its cap); it is rebuilt whenever the trench has -moved by a rung. Stokes is solved on the add-on with rotated free-slip on -both walls (exact radial normal), the spine split and in frictionless -contact (``--arm split``, the ladder's cap and last rungs painted TI to -bridge to the surface) or the whole band painted TI (``--arm ti``), and -custom FMG on the base's own levels. Velocity goes back to the base by -evaluation for the semi-Lagrangian step. - -python zg95_annulus.py --out ~/+Simulations/zg95/run1 --steps 50 [--arm split|ti] -""" -import argparse -import gc -import json -import os -import time - -import numpy as np -import sympy -import underworld3 as uw -from scipy.special import erf -from mpi4py import MPI -from underworld3.utilities.custom_mg import set_custom_fmg -from underworld3.utilities.place_surface import place_fault_ribbon_2d - -comm = uw.mpi.comm - - -def gmax(x): - return float(comm.allreduce(float(x), op=MPI.MAX)) - - -def gmin(x): - return float(comm.allreduce(float(x), op=MPI.MIN)) - - -def gather_arrays(*arrs): - """Concatenate rank-local 1-D arrays onto every rank (small surface data).""" - out = [] - for a in arrs: - parts = comm.allgather(np.asarray(a)) - out.append(np.concatenate(parts) if parts else np.asarray(a)) - return out - -ap = argparse.ArgumentParser() -ap.add_argument("--out", default=os.path.expanduser("~/+Simulations/zg95/run1")) -ap.add_argument("--steps", type=int, default=50) -ap.add_argument("--spinup", type=int, default=0, help="steps on the base without the fault first") -ap.add_argument("--ra", type=float, default=1e6) -ap.add_argument("--deta", type=float, default=1e3) -ap.add_argument("--eta_w", type=float, default=1e-3, help="TI weak-plane viscosity ratio") -ap.add_argument("--H", type=float, default=0.16, help="coarsest base cell") -ap.add_argument("--ref", type=int, default=3, help="base halvings (finest base cell H / 2**ref)") -ap.add_argument("--S", type=float, default=0.005, help="ladder spacing") -ap.add_argument("--dip", type=float, default=45.0) -ap.add_argument("--depth", type=float, default=0.15) -ap.add_argument("--arm", choices=["split", "ti"], default="split") -ap.add_argument("--fac", type=int, default=0, help="band patch smoother on the finest") -ap.add_argument("--dt_mult", type=float, default=2.0) -ap.add_argument("--dt_max", type=float, default=1e-3) -ap.add_argument("--restart", type=int, default=-1, help="restart T from checkpoint index in --out (skips the spin-up)") -ap.add_argument("--ckpt_every", type=int, default=25) -ap.add_argument("--fig_every", type=int, default=5) -ap.add_argument("--theta0", type=float, default=0.0, help="initial trench position (rad)") -ap.add_argument("--trench_off", type=float, default=0.1, help="trench-velocity window: offset beyond the fault (rad)") -ap.add_argument("--trench_span", type=float, default=0.2, help="trench-velocity window: width (rad)") -ap.add_argument("--migrate", type=int, default=1, help="advect the trench with the overriding plate") -ap.add_argument("--heating", type=float, default=0.0, help="internal heating rate (source term)") -ap.add_argument("--yield0", type=float, default=0.0, help="yield stress at the surface (0: no yielding)") -ap.add_argument("--yield_depth", type=float, default=0.0, help="yield stress gradient with depth") -ap.add_argument("--eta_min", type=float, default=1.0, help="viscosity floor under yielding") -ap.add_argument("--T_int", type=float, default=0.6, help="initial interior temperature") -ap.add_argument("--tol", type=float, default=1e-5, help="Stokes solve tolerance (loosen for a spin-up)") -ap.add_argument("--yield_soft", type=float, default=0.1, help="yield-corner softness delta of the smooth yield law") -ap.add_argument("--verbose", action="store_true", help="print every nonlinear iteration of the rotated solve") -ap.add_argument("--newton_switch", type=float, default=1e-2, help="relative residual at which the rotated loop goes from Picard to Newton") -ap.add_argument("--picard", type=int, default=3, help="Picard warm-up iterations before Newton in the rotated nonlinear loop") -ap.add_argument("--eta_ref", type=float, default=1.0, help="temperature at which the viscosity is 1 (1: the base; T_int: the interior)") -ap.add_argument("--advect", choices=["supg", "slcn"], default="supg", - help="temperature transport: Eulerian SUPG (its own step estimate) or semi-Lagrangian (dt_mult x Courant)") -args = ap.parse_args() -os.makedirs(args.out, exist_ok=True) -if os.environ.get("ZG95_NOINODE"): - from petsc4py import PETSc - PETSc.Options()["mat_no_inode"] = True -RI, RO = 0.55, 1.0 -S, W = args.S, 2.0 * args.S -DIP = np.deg2rad(args.dip) -rank0 = uw.mpi.rank == 0 - - -def log(*a): - if rank0: - print(*a, flush=True) - - -# ----------------------------------------------------------------------------- base -base = uw.meshing.Annulus(radiusOuter=RO, radiusInner=RI, cellSize=args.H, refinement=args.ref, - qdegree=2) -tail = base._coarse_level_meshes()[:-1] + [base] -T_b = uw.discretisation.MeshVariable("T_b", base, 1, degree=2) -V_b = uw.discretisation.MeshVariable("V_b", base, 2, degree=2) -Y_b = uw.discretisation.MeshVariable("Y_b", base, 1, degree=1) # eta_eff / eta(T): 1 unyielded -xb = np.asarray(T_b.coords) -rb = np.hypot(xb[:, 0], xb[:, 1]) -thb = np.arctan2(xb[:, 1], xb[:, 0]) -z = (RO - rb) / (RO - RI) -T_int = args.T_int -T0 = (T_int * erf(z / 0.1) + (1.0 - T_int) * (1.0 - erf((1.0 - z) / 0.1)) - - 0.04 * np.cos(3 * thb) * np.sin(np.pi * z)) # downwelling under the trench at theta 0 -T_b.array[:, 0, 0] = np.clip(T0, 0.0, 1.0) -if args.restart >= 0: - T_b.read_timestep("zg95", "T_b", args.restart, outputPath=args.out) - log(f"restarted T from checkpoint {args.restart}") -log(f"base: {base.dm.getHeightStratum(0)[1]} cells, {len(tail)} levels; finest cell {args.H / 2**args.ref}") - -if args.advect == "supg": - adv = uw.systems.AdvDiffusion(base, T_b, V_b.sym, order=1) -else: - adv = uw.systems.AdvDiffusionSLCN(base, u_Field=T_b, V_fn=V_b.sym, order=1, monotone_mode="clamp") -adv.constitutive_model = uw.constitutive_models.DiffusionModel -adv.constitutive_model.Parameters.diffusivity = 1.0 -adv.f = args.heating -adv.add_dirichlet_bc(0.0, "Upper") -adv.add_dirichlet_bc(1.0, "Lower") -unit_r_b = base.CoordinateSystem.unit_e_0 - - -def eta_of(Tsym): - return sympy.exp(sympy.log(args.deta) * (args.eta_ref - Tsym)) # 1 at T = eta_ref, deta at eta_ref - 1 - - -def yield_stress_on(mesh): - """tau_y = yield0 + yield_depth * depth, depth measured from the outer wall""" - x, y = mesh.X - depth = RO - sympy.sqrt(x ** 2 + y ** 2) - return args.yield0 + args.yield_depth * depth - - -def set_rheology(solver, mesh, Tsym, zone_sym=None): - """Temperature-dependent viscosity, with yielding when ``--yield0`` is set. - ``zone_sym`` (0/1 per cell) marks the weak bridge: under the TI model it is the - weak plane's footprint; under yielding (the TI model has no yield stress - without elasticity) it is an isotropic weak zone, viscosity and yield stress - both reduced by ``--eta_w``.""" - eta0 = eta_of(Tsym) - weak = 1 if zone_sym is None else (1 - (1 - args.eta_w) * zone_sym) - if args.yield0 > 0: - solver.constitutive_model = uw.constitutive_models.ViscoPlasticFlowModel - cm = solver.constitutive_model - for attr in ("yield_softness", "_yield_softness"): - if hasattr(cm, attr): - try: - setattr(cm, attr, float(args.yield_soft)) - break - except Exception: - continue - P = solver.constitutive_model.Parameters - P.shear_viscosity_0 = eta0 * weak - P.shear_viscosity_min = args.eta_min - P.yield_stress = yield_stress_on(mesh) * weak - return - if zone_sym is None: - solver.constitutive_model = uw.constitutive_models.ViscousFlowModel - solver.constitutive_model.Parameters.shear_viscosity_0 = eta0 - return - solver.constitutive_model = uw.constitutive_models.TransverseIsotropicFlowModel - P = solver.constitutive_model.Parameters - P.shear_viscosity_0 = eta0 - P.shear_viscosity_1 = eta0 * weak - return P # the caller sets the director - - -def next_dt(): - """SUPG: the solver's own estimate, capped; semi-Lagrangian: dt_mult times the - Courant estimate, capped (it is not Courant-limited).""" - est = float(adv.estimate_dt()) - return min(args.dt_mult * est, args.dt_max) # implicit SUPG tolerates large steps too - - -def clamp_radii(x): - r = np.hypot(x[:, 0], x[:, 1]) - f = np.clip(r, RI + 1e-10, RO - 1e-10) / np.maximum(r, 1e-14) - return x * f[:, None] - - -# ----------------------------------------------------------------------------- add-on -class AddOn: - """The trench at angle theta_t placed into a copy of the base's finest, with - its Stokes solver.""" - - def __init__(self, theta_t, tag): - t0 = time.perf_counter() - t_hat = np.array([np.cos(theta_t), np.sin(theta_t)]) - s_hat = np.array([-np.sin(theta_t), np.cos(theta_t)]) - d_hat = -np.cos(DIP) * t_hat + np.sin(DIP) * s_hat # down-dip toward +theta - self.n_hat = np.array([-d_hat[1], d_hat[0]]) - top = t_hat * (RO + 0.02) - L = (args.depth + 0.02) / np.cos(DIP) - n_s = int(round(L / S)) - spine = np.array([top + d_hat * (L * i / n_s) for i in range(n_s + 1)]) - r_sp = np.hypot(spine[:, 0], spine[:, 1]) - self.trace = spine[r_sp <= RO - 3.0 * S] - self.theta_t = theta_t - self.mesh, self.info = place_fault_ribbon_2d( - base, [("Trench", self.trace)], W, spines=[("Trench", spine)], margin_rings=1, - clearance=0.7, split=(args.arm == "split"), mesher="ladder") - mesh = self.mesh - self.band = np.asarray(self.info["band"], dtype=bool) - self.T = uw.discretisation.MeshVariable(f"T_{tag}", mesh, 1, degree=2) - self.v = uw.discretisation.MeshVariable(f"U_{tag}", mesh, 2, degree=2) - self.p = uw.discretisation.MeshVariable(f"P_{tag}", mesh, 1, degree=1, continuous=True) - self.h = uw.discretisation.MeshVariable(f"h_{tag}", mesh, 1, degree=1) - self.yv = uw.discretisation.MeshVariable(f"Y_{tag}", mesh, 1, degree=1) # yield indicator - self.zone = uw.discretisation.MeshVariable(f"zone_{tag}", mesh, 1, degree=0, continuous=False) - dm = mesh.dm - vS, vE = dm.getDepthStratum(0) - cS, cE = dm.getHeightStratum(0) - X = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, 2) - if args.arm == "split": - pairs = (getattr(mesh, "_fault_point_pairs", None) or {}).get("Trench", {}) - pp = np.array([int(q) for q in pairs] + [int(q) for q in pairs.values()], dtype=np.int64) - pv = pp[(pp >= vS) & (pp < vE)] - vS - self.r_tip = gmax(np.hypot(X[pv, 0], X[pv, 1]).max() if pv.size else 0.0) - cent = np.array([X[[int(q) - vS for q in dm.getTransitiveClosure(c)[0] if vS <= q < vE]].mean(axis=0) - for c in range(cS, cE)]) - self.bridge = self.band & (np.hypot(cent[:, 0], cent[:, 1]) > self.r_tip - 0.5 * W) - else: - self.r_tip = None - self.bridge = self.band.copy() - self.zone.array[:, 0, 0] = self.bridge.astype(float) - s = uw.systems.Stokes(mesh, velocityField=self.v, pressureField=self.p) - P = set_rheology(s, mesh, self.T.sym[0], self.zone.sym[0]) - if P is not None: - P.director = sympy.Matrix([[float(self.n_hat[0]), float(self.n_hat[1])]]) - unit_r = mesh.CoordinateSystem.unit_e_0 - s.bodyforce = args.ra * self.T.sym[0] * unit_r - s.add_rotated_freeslip_bc(0, "Upper", normal=unit_r) - s.add_rotated_freeslip_bc(0, "Lower", normal=unit_r) - if args.arm == "split": - s.add_fault_bc(0, boundary="Trench") - s.petsc_use_pressure_nullspace = True - s.tolerance = args.tol - s.newton_switch_rtol = args.newton_switch - set_custom_fmg(s, tail, field_id=0, fac_zone=self.band if args.fac else None) - self.stokes = s - self.t_build = time.perf_counter() - t0 - n_cells = comm.allreduce(int(mesh.dm.getHeightStratum(0)[1]), op=MPI.SUM) - n_band = comm.allreduce(int(self.band.sum()), op=MPI.SUM) - n_bridge = comm.allreduce(int(self.bridge.sum()), op=MPI.SUM) - log(f" add-on {tag}: {n_cells} cells, band {n_band}, bridge {n_bridge} (global), " - f"trench at {np.degrees(theta_t):.3f} deg, built in {self.t_build:.1f} s") - - def warm_start(self, prev): - """Initial guess from the previous add-on's velocity and pressure (collective: - every rank evaluates and writes). Each step rebuilds the add-on, so without - this every solve is a cold start: 20 Picard iterations before Newton.""" - self.v.array[:, 0, :] = np.asarray(uw.function.global_evaluate( - prev.v.sym, clamp_radii(np.asarray(self.v.coords)))).reshape(-1, 2) - self.p.array[:, 0, 0] = np.asarray(uw.function.global_evaluate( - prev.p.sym, clamp_radii(np.asarray(self.p.coords)))).ravel() - self.warm = True - - def solve(self): - self.T.array[:, 0, 0] = np.asarray(uw.function.global_evaluate( - T_b.sym[0], clamp_radii(np.asarray(self.T.coords)))).ravel() - n_nan = int(comm.allreduce(int(np.isnan(self.T.array).sum()), op=MPI.SUM)) - tmin, tmax = gmin(np.nanmin(self.T.array) if self.T.array.shape[0] else 1.0), gmax(np.nanmax(self.T.array) if self.T.array.shape[0] else 0.0) - log(f" T on the add-on: nan {n_nan}, range {tmin:.3f} {tmax:.3f}") - t0 = time.perf_counter() - self.stokes.solve(picard=args.picard if args.yield0 > 0 else 0, verbose=args.verbose, - zero_init_guess=not getattr(self, "warm", False)) - self.warm = True # the next solve on this add-on warms from its own solution - info = self.stokes._rotated_freeslip_info - self.t_solve = time.perf_counter() - t0 - if os.environ.get("ZG95_INSPECT"): - try: - ctx = (getattr(self.stokes, "_rotated_linear_cache", None) or {})["ctx"] - velpc = ctx["ksp"].getPC().getFieldSplitSubKSP()[0].getPC() - for l in range(velpc.getMGLevels()): - sm = velpc.getMGSmoother(l) - A = sm.getOperators()[0] - d = A.getDiagonal().array - dmin = gmin(np.abs(d).min() if d.size else np.inf) - dmax = gmax(np.abs(d).max() if d.size else 0.0) - b, x = A.createVecs(); b.setRandom(); x.set(0.0) - sm.solve(b, x) - log(f" level {l}: n {A.getSize()[0]} diag min {dmin:.3e} max {dmax:.3e} " - f"smoother |x| {x.norm():.3e} reason {sm.getConvergedReason()}") - except Exception as e: - log(" inspection failed:", e) - self.stokes.dynamic_topography("Upper", self.h, buoyancy_scale=args.ra) - rec = {"vel_its": info.get("vel_its_last"), "pres_its": info.get("pres_its_last"), - "converged": bool(info.get("converged")), "t_solve": round(self.t_solve, 1)} - if args.arm == "split": - from underworld3.utilities.fault_contact import fault_slip - _s, Vs, leak = fault_slip(self.stokes, "Trench", info) - rec["slip_max"] = gmax(np.abs(Vs).max() if len(Vs) else 0.0) - rec["leak"] = gmax(np.abs(leak).max() if len(leak) else 0.0) - return rec - - def surface(self): - vc = np.asarray(self.v.coords) - on = np.hypot(vc[:, 0], vc[:, 1]) > RO - 1e-6 - th = np.arctan2(vc[on, 1], vc[on, 0]) - vx = self.v.array[on, 0, :] - vt = -vx[:, 0] * np.sin(th) + vx[:, 1] * np.cos(th) - hc = np.asarray(self.h.coords) - hon = np.hypot(hc[:, 0], hc[:, 1]) > RO - 1e-6 - thh = np.arctan2(hc[hon, 1], hc[hon, 0]) - th, vt, thh, hh = gather_arrays(th, vt, thh, self.h.array[hon, 0, 0]) - o = np.argsort(th) - oh = np.argsort(thh) - return th[o], vt[o], thh[oh], hh[oh] - - -# ----------------------------------------------------------------------------- base-only Stokes (spin-up) -def base_stokes(tag): - v = uw.discretisation.MeshVariable(f"Ub_{tag}", base, 2, degree=2) - p = uw.discretisation.MeshVariable(f"Pb_{tag}", base, 1, degree=1, continuous=True) - s = uw.systems.Stokes(base, velocityField=v, pressureField=p) - set_rheology(s, base, T_b.sym[0]) - s.bodyforce = args.ra * T_b.sym[0] * unit_r_b - s.add_rotated_freeslip_bc(0, "Upper", normal=unit_r_b) - s.add_rotated_freeslip_bc(0, "Lower", normal=unit_r_b) - s.petsc_use_pressure_nullspace = True - s.tolerance = args.tol - s.newton_switch_rtol = args.newton_switch - set_custom_fmg(s, tail[:-1], field_id=0) - return s, v - - -# ----------------------------------------------------------------------------- figures -_fig_th = np.linspace(-np.pi, np.pi, 361) -_fig_r = np.linspace(RI, RO, 61) -_fig_xy = np.column_stack([(np.cos(_fig_th)[:, None] * _fig_r[None, :]).ravel(), - (np.sin(_fig_th)[:, None] * _fig_r[None, :]).ravel()]) - - -def figure(step, t, addon, rec): - surf = addon.surface() if addon is not None else None # collective gather - # T on a polar grid: collective on every rank, drawn on rank 0 (no serial-only panel) - Tg = np.asarray(uw.function.global_evaluate(T_b.sym[0], clamp_radii(_fig_xy))).ravel() - if not rank0: - return - import matplotlib - matplotlib.use("Agg") - import matplotlib.pyplot as plt - import matplotlib.tri as mtri - from matplotlib.collections import LineCollection - fig, axes = plt.subplots(1, 3, figsize=(18, 5.6), gridspec_kw={"width_ratios": [1.2, 1, 1]}) - ax = axes[0] - Xg = _fig_xy[:, 0].reshape(len(_fig_th), len(_fig_r)) - Yg = _fig_xy[:, 1].reshape(len(_fig_th), len(_fig_r)) - tc = ax.pcolormesh(Xg, Yg, Tg.reshape(len(_fig_th), len(_fig_r)), shading="gouraud", cmap="RdBu_r", - vmin=0, vmax=1) - fig.colorbar(tc, ax=ax, shrink=0.8) - if addon is not None: - tr = addon.trace - ax.plot(tr[:, 0], tr[:, 1], color="k", lw=1.5) - ax.set_aspect("equal"); ax.set_xticks([]); ax.set_yticks([]) - ax.set_title(f"step {step}, t = {t:.5f}: T on the base; trench (black)", fontsize=9, loc="left") - if addon is not None: - th, vt, thh, hh = surf - ax = axes[1] - ax.plot(np.degrees(th), vt, lw=1) - ax.axvline(np.degrees(addon.theta_t), color="#c0392b", lw=0.8, ls="--") - ax.set_xlabel("theta (deg)"); ax.set_ylabel("surface tangential velocity") - ax.set_title(f"surface velocity; velocity its {rec.get('vel_its')}, slip {rec.get('slip_max', 0):.2f}", - fontsize=9, loc="left") - ax.grid(alpha=0.3) - ax = axes[2] - ax.plot(np.degrees(thh), hh, lw=1) - ax.axvline(np.degrees(addon.theta_t), color="#c0392b", lw=0.8, ls="--") - ax.set_xlabel("theta (deg)"); ax.set_ylabel("dynamic topography (-(s_nn - mean)/Ra)") - ax.set_title("topography from the free-slip reaction", fontsize=9, loc="left") - ax.grid(alpha=0.3) - fig.tight_layout() - fig.savefig(os.path.join(args.out, f"step_{step:04d}.png"), dpi=130) - plt.close(fig) - - -# ----------------------------------------------------------------------------- run -records = [] -t = 0.0 -step = 0 - - -_vb_coords = np.asarray(V_b.coords) -_vb_r = np.hypot(_vb_coords[:, 0], _vb_coords[:, 1]) -_vb_surf = _vb_r > RO - 1e-6 -_tb_coords = np.asarray(T_b.coords) -_tb_depth = RO - np.hypot(_tb_coords[:, 0], _tb_coords[:, 1]) - - -def yielded_fraction(solver, Tsym, yvar): - """Fraction of P1 nodes (all, and in the lid: depth < 0.15) where the effective - viscosity is below 0.9 of the temperature-dependent one, i.e. the plastic branch - is active. ``yvar`` is a P1 variable on the solver's mesh that receives the - ratio; the base's ``Y_b`` is then filled from it by a collective cross-mesh - evaluation of that plain variable (the ratio expression itself holds velocity - gradients, which the cross-mesh evaluator cannot take). Collective on every - rank; NaN when the model has no yield stress.""" - if args.yield0 <= 0 or solver is None: - return float("nan"), float("nan") - coords = np.asarray(yvar.coords) - depth = RO - np.hypot(coords[:, 0], coords[:, 1]) - ratio, err = None, "" - try: - cm = solver.constitutive_model - ratio = np.asarray(uw.function.evaluate(cm._plastic_eff_viscosity.sym / eta_of(Tsym), - clamp_radii(coords))).ravel() - except Exception as e: - err = str(e)[:120] - # a failure on any rank is a failure on all: every rank must take the same path - if comm.allreduce(int(ratio is None), op=MPI.SUM): - log(" yielded fraction unavailable:", err) - return float("nan"), float("nan") - yvar.array[:, 0, 0] = ratio # collective write - if yvar is not Y_b: - Y_b.array[:, 0, 0] = np.asarray(uw.function.global_evaluate( - yvar.sym[0], clamp_radii(np.asarray(Y_b.coords)))).ravel() - yielded = ratio < 0.9 - lid = depth < 0.15 - n_all = comm.allreduce(int(yielded.size), op=MPI.SUM) - n_lid = comm.allreduce(int(lid.sum()), op=MPI.SUM) - y_all = comm.allreduce(int(yielded.sum()), op=MPI.SUM) - y_lid = comm.allreduce(int((yielded & lid).sum()), op=MPI.SUM) - return y_all / max(n_all, 1), y_lid / max(n_lid, 1) - - -def flow_diagnostics(): - """vrms over the base nodes, the surface rms, their ratio (mobility), and the - lid depth: where the horizontally averaged T first reaches 0.8 of its mid-depth - value. Node averages, not integrals; collective.""" - v2 = np.einsum("ij,ij->i", V_b.array[:, 0, :], V_b.array[:, 0, :]) if V_b.array.shape[0] else np.zeros(0) - n_all = comm.allreduce(int(v2.size), op=MPI.SUM) - n_s = comm.allreduce(int(_vb_surf.sum()), op=MPI.SUM) - vrms = np.sqrt(comm.allreduce(float(v2.sum()), op=MPI.SUM) / max(n_all, 1)) - vsurf = np.sqrt(comm.allreduce(float(v2[_vb_surf].sum()), op=MPI.SUM) / max(n_s, 1)) - nb = 45 - edges = np.linspace(0.0, RO - RI, nb + 1) - Tv = T_b.array[:, 0, 0] if T_b.array.shape[0] else np.zeros(0) - idx = np.clip(np.digitize(_tb_depth, edges) - 1, 0, nb - 1) - sums = np.bincount(idx, weights=Tv, minlength=nb) - cnts = np.bincount(idx, minlength=nb).astype(float) - sums = comm.allreduce(sums, op=MPI.SUM) - cnts = comm.allreduce(cnts, op=MPI.SUM) - prof = sums / np.maximum(cnts, 1) - mid = prof[nb // 2] - hit = np.flatnonzero(prof >= 0.8 * mid) - lid = float(edges[hit[0]] + 0.5 * (edges[1] - edges[0])) if hit.size else float("nan") - return {"vrms": float(vrms), "vsurf_rms": float(vsurf), - "mobility": float(vsurf / max(vrms, 1e-30)), "lid_depth": lid, - "T_mid": float(mid)}, prof - - -def checkpoint(step, t): - base.write_timestep("zg95", meshUpdates=False, meshVars=[T_b, V_b, Y_b], index=step, outputPath=args.out) - if rank0: - json.dump({"step": step, "t": t, "theta_t": float(globals().get("theta_t", args.theta0))}, - open(os.path.join(args.out, f"state_{step:04d}.json"), "w")) - json.dump(records, open(os.path.join(args.out, "records.json"), "w"), indent=1) - - -if args.restart >= 0: - # a restart continues the record and the clock; with --spinup it carries the - # base-only run on (the parameters may change: the paper's step in viscosity - # contrast and yield stress at equilibrium), with --steps the fault phase starts - st = json.load(open(os.path.join(args.out, f"state_{args.restart:04d}.json"))) - t, step = float(st["t"]), int(st["step"]) + 1 - args.theta0 = float(st.get("theta_t", args.theta0)) - rp = os.path.join(args.out, "records.json") - if os.path.exists(rp): - records = [r for r in json.load(open(rp)) if r.get("step", 0) <= args.restart] -if args.spinup > 0: - s_b, v_b = base_stokes("spin") - for k in range(args.spinup): - t0 = time.perf_counter() - s_b.solve(picard=args.picard if args.yield0 > 0 else 0) - info = s_b._rotated_freeslip_info - V_b.array[...] = v_b.array - dt = next_dt() - adv.solve(timestep=dt) - t += dt - vmax = gmax(np.linalg.norm(V_b.array[:, 0, :], axis=1).max() if V_b.array.shape[0] else 0.0) - diag, prof = flow_diagnostics() - y_all, y_lid = yielded_fraction(s_b, T_b.sym[0], Y_b) - rec = {"step": step, "phase": "spinup", "t": t, "dt": dt, "vel_its": info.get("vel_its_last"), - "newton": info.get("nonlinear_iterations"), "vmax": vmax, - "t_step": round(time.perf_counter() - t0, 1), "yield_frac": y_all, "yield_frac_lid": y_lid, - **diag} - if rank0 and step % args.fig_every == 0: - np.save(os.path.join(args.out, f"Tprofile_{step:04d}.npy"), prof) - records.append(rec) - log("SPIN", json.dumps(rec)) - if step % args.fig_every == 0: - figure(step, t, None, rec) - if step % args.ckpt_every == 0 or k == args.spinup - 1: - checkpoint(step, t) - step += 1 - del s_b, v_b - gc.collect() -theta_t = args.theta0 -addon = None -n_built = 0 -for k in range(args.steps): - t0 = time.perf_counter() - if addon is None or abs(theta_t - addon.theta_t) * RO > S: - prev, addon = addon, AddOn(theta_t, f"a{n_built}") - n_built += 1 - if prev is not None: - addon.warm_start(prev) - del prev - gc.collect() - rec = addon.solve() - th, vt, thh, hh = addon.surface() - # the trench moves with the overriding plate: the median surface velocity over a - # window beyond the fault's hanging wall (the sliver next to the fault goes - # stationary once the fault takes the slip; sampling it froze the trench) - dth = (th - addon.theta_t + np.pi) % (2 * np.pi) - np.pi - win = (dth > args.trench_off) & (dth < args.trench_off + args.trench_span) - v_trench = float(np.median(vt[win])) if win.any() else float(vt[int(np.argmin(np.abs(dth - args.trench_off)))]) - # velocity back onto the base for the advection step - Vb = np.asarray(uw.function.global_evaluate(addon.v.sym, clamp_radii(np.asarray(V_b.coords)))).reshape(-1, 2) - V_b.array[:, 0, :] = Vb - dt = next_dt() - adv.solve(timestep=dt) - t += dt - if args.migrate: - theta_t += v_trench * dt / RO - vmax = gmax(np.linalg.norm(V_b.array[:, 0, :], axis=1).max() if V_b.array.shape[0] else 0.0) - diag, prof = flow_diagnostics() - rec.update(diag) - y_all, y_lid = yielded_fraction(addon.stokes, addon.T.sym[0], addon.yv) - rec.update({"newton": addon.stokes._rotated_freeslip_info.get("nonlinear_iterations"), - "yield_frac": y_all, "yield_frac_lid": y_lid}) - rec.update({"step": step, "phase": "fault", "t": t, "dt": dt, "vmax": vmax, "v_trench": v_trench, - "theta_t": float(theta_t), "theta_built": float(addon.theta_t), "n_built": n_built, - "topo_min": float(hh.min()), "topo_max": float(hh.max()), - "np": uw.mpi.size, - "t_build": round(addon.t_build, 1), "t_step": round(time.perf_counter() - t0, 1)}) - records.append(rec) - log("STEP", json.dumps(rec)) - if step % args.fig_every == 0 or k == args.steps - 1: - figure(step, t, addon, rec) - if rank0: - json.dump(records, open(os.path.join(args.out, "records.json"), "w"), indent=1) - if rank0 and (step % args.fig_every == 0 or step % args.ckpt_every == 0 or k == args.steps - 1): - # the surface and the trench trace ride every figure AND every checkpoint, so the - # checkpoint renders can draw the fault - np.savez(os.path.join(args.out, f"surface_{step:04d}.npz"), theta=th, vt=vt, theta_h=thh, h=hh, - trace=addon.trace) - if step % args.ckpt_every == 0 or k == args.steps - 1: - checkpoint(step, t) - step += 1 -if rank0: - json.dump(records, open(os.path.join(args.out, "records.json"), "w"), indent=1) -log("DONE") diff --git a/scripts/sessions/fault_mg/zg95_jigsaw.py b/scripts/sessions/fault_mg/zg95_jigsaw.py deleted file mode 100644 index dba8cf92d..000000000 --- a/scripts/sessions/fault_mg/zg95_jigsaw.py +++ /dev/null @@ -1,82 +0,0 @@ -"""The pieces side by side: for each (label, run dir, checkpoint index) a row with -T on the base, the surface tangential velocity from the checkpointed velocity, -and the horizontally averaged T profile, titled with the step's mobility and -yielded fraction from the run's records. - -python zg95_jigsaw.py label=dir:index [label=dir:index ...] -""" -import json -import os -import sys - -import h5py -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt -import matplotlib.tri as mtri -import numpy as np - -out = sys.argv[1] -items = [] -for a in sys.argv[2:]: - label, rest = a.split("=", 1) - d, idx = rest.rsplit(":", 1) - items.append((label, os.path.expanduser(d), int(idx))) -RO, RI = 1.0, 0.55 -fig, axes = plt.subplots(len(items), 3, figsize=(16, 4.3 * len(items)), squeeze=False, - gridspec_kw={"width_ratios": [1.1, 1, 0.8]}) -for row, (label, d, idx) in enumerate(items): - with h5py.File(os.path.join(d, f"zg95.mesh.T_b.{idx:05d}.h5")) as h: - T = np.asarray(h["fields/T_b"]).ravel() - X = np.asarray(h["fields/coordinates"]) - C = np.asarray(h["fields/cells"]).astype(int) - vfile = os.path.join(d, f"zg95.mesh.V_b.{idx:05d}.h5") - V = None - if os.path.exists(vfile): - with h5py.File(vfile) as h: - V = np.asarray(h["fields/V_b"]).reshape(-1, 2) - XV = np.asarray(h["fields/coordinates"]) - v0, v1, v2, m01, m12, m20 = (C[:, k] for k in range(6)) - tris = np.vstack([np.column_stack([v0, m01, m20]), np.column_stack([m01, v1, m12]), - np.column_stack([m20, m12, v2]), np.column_stack([m01, m12, m20])]) - rec = {} - rf = os.path.join(d, "records.json") - if os.path.exists(rf): - recs = [r for r in json.load(open(rf)) if r.get("step") == idx] - rec = recs[0] if recs else {} - t = rec.get("t", float("nan")) - ax = axes[row, 0] - tc = ax.tripcolor(mtri.Triangulation(X[:, 0], X[:, 1], tris), T, shading="gouraud", cmap="RdBu_r", vmin=0, vmax=1) - ax.set_aspect("equal"); ax.set_xticks([]); ax.set_yticks([]) - title = f"{label}: step {idx}, t = {t:.3f}" - if "mobility" in rec: - title += f"; mobility {rec['mobility']:.2f}, lid {rec.get('lid_depth', float('nan')):.3f}" - if rec.get("yield_frac_lid") is not None and not (isinstance(rec.get("yield_frac_lid"), float) and np.isnan(rec["yield_frac_lid"])): - title += f", yielded in lid {100 * rec['yield_frac_lid']:.0f}%" - ax.set_title(title, fontsize=9, loc="left") - if row == 0: - fig.colorbar(tc, ax=ax, shrink=0.8) - ax = axes[row, 1] - if V is not None: - r = np.hypot(XV[:, 0], XV[:, 1]) - on = r > RO - 1e-6 - th = np.arctan2(XV[on, 1], XV[on, 0]) - vt = -V[on, 0] * np.sin(th) + V[on, 1] * np.cos(th) - o = np.argsort(th) - ax.plot(np.degrees(th[o]), vt[o], lw=1) - ax.axhline(0, color="k", lw=0.5) - ax.set_xlabel("theta (deg)"); ax.set_ylabel("surface tangential velocity"); ax.grid(alpha=0.3) - ax.set_title("surface velocity", fontsize=9, loc="left") - ax = axes[row, 2] - depth = RO - np.hypot(X[:, 0], X[:, 1]) - nb = 45 - edges = np.linspace(0, RO - RI, nb + 1) - k = np.clip(np.digitize(depth, edges) - 1, 0, nb - 1) - prof = np.bincount(k, weights=T, minlength=nb) / np.maximum(np.bincount(k, minlength=nb), 1) - ax.plot(prof, 0.5 * (edges[1:] + edges[:-1]), lw=1.2) - ax.invert_yaxis(); ax.set_xlim(0, 1) - ax.set_xlabel("mean T"); ax.set_ylabel("depth"); ax.grid(alpha=0.3) - ax.set_title("horizontally averaged T", fontsize=9, loc="left") -fig.tight_layout() -fig.savefig(out, dpi=120) -print("wrote", out) diff --git a/scripts/sessions/fault_mg/zg95_probe.py b/scripts/sessions/fault_mg/zg95_probe.py deleted file mode 100644 index 4cb87b0a0..000000000 --- a/scripts/sessions/fault_mg/zg95_probe.py +++ /dev/null @@ -1,397 +0,0 @@ -"""Probe the pieces of the Zhong & Gurnis (1995) analogue on a small annulus: -a dipping trace running past the outer wall placed as a ladder band on the -base's finest, spine split blind below the surface, a TI bridge painted on the -band's top, rotated free-slip on both walls, temperature-dependent viscosity -from a base-mesh T evaluated onto the add-on, custom FMG with the base tail, -topography and surface velocity read back. - -python zg95_probe.py -""" -import os -import sys -import time - -import numpy as np -import sympy -import underworld3 as uw -from underworld3.utilities.custom_mg import set_custom_fmg - -out = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/+Simulations/zg95") -os.makedirs(out, exist_ok=True) -RI, RO = 0.55, 1.0 -H = 0.16 # coarsest base cell; REF halvings below it -REF = 3 # finest base cell H / 2**REF = 0.02; the coarsest annulus - # stays small enough for the SVD coarse solve the free-slip - # gauge needs (14k unknowns at H = 0.08 took over 30 min) -DIP = np.deg2rad(45.0) -DEPTH = 0.15 # fault reaches this far below the surface -RA, DETA = 1e6, 1e3 - -t0 = time.perf_counter() -base = uw.meshing.Annulus(radiusOuter=RO, radiusInner=RI, cellSize=H, refinement=REF, qdegree=2) -print(f"base: {base.dm.getHeightStratum(0)[1]} cells, levels {len(base._coarse_level_meshes())}", - flush=True) -tail = base._coarse_level_meshes()[:-1] + [base] -S = 0.005 - -# temperature on the BASE: conductive profile + a lid, perturbed -T_b = uw.discretisation.MeshVariable("T_b", base, 1, degree=2) -r_b, th_b = base.CoordinateSystem.R -xb = np.asarray(T_b.coords) -rr = np.hypot(xb[:, 0], xb[:, 1]) -tt = np.arctan2(xb[:, 1], xb[:, 0]) -depth = (RO - rr) / (RO - RI) -T_b.array[:, 0, 0] = np.clip(1.0 - np.exp(-depth / 0.15) * 0.0 - depth * 0.0, 0, 1) # placeholder -T_b.array[:, 0, 0] = 0.5 * (1 - np.cos(np.pi * np.minimum(depth / 0.3, 1.0))) * 0.5 + 0.5 * np.minimum(depth / 0.3, 1.0) -T_b.array[:, 0, 0] = np.clip(T_b.array[:, 0, 0] + 0.05 * np.cos(3 * tt) * np.sin(np.pi * depth), 0, 1) - -# the band: the structured ladder (one element either side of the spine, width 2S) -# along a line from past the outer wall down at DIP toward +theta, sampled at S; -# the spine is split on its own rung vertices for the samples at least three -# spacings inside the surface; the ladder's outcrop cap carries the band to the wall -from underworld3.utilities.place_surface import place_fault_ribbon_2d -W = 2.0 * S -th0 = 0.0 -t_hat = np.array([np.cos(th0), np.sin(th0)]) # outward radial at th0 -s_hat = np.array([-np.sin(th0), np.cos(th0)]) # +theta direction -d_hat = -np.cos(DIP) * t_hat + np.sin(DIP) * s_hat # down-dip, toward +theta -n_hat = np.array([-d_hat[1], d_hat[0]]) # fault normal -top = t_hat * (RO + 0.02) -L_line = (DEPTH + 0.02) / np.cos(DIP) -n_s = int(round(L_line / S)) -spine = np.array([top + d_hat * (L_line * i / n_s) for i in range(n_s + 1)]) -r_sp = np.hypot(spine[:, 0], spine[:, 1]) -trace = spine[r_sp <= RO - 3.0 * S] -t1 = time.perf_counter() -mesh, rinfo = place_fault_ribbon_2d(base, [("Trench", trace)], W, spines=[("Trench", spine)], - margin_rings=1, clearance=0.7, split=True, mesher="ladder") -print(f"finest: {mesh.dm.getHeightStratum(0)[1]} cells, placed + split {time.perf_counter() - t1:.1f} s, " - f"pairs {len(mesh._fault_point_pairs['Trench'])}, band {int(rinfo['band'].sum())} cells, " - f"tail levels {len(mesh._custom_mg_coarse_meshes)}", flush=True) -print("boundaries:", [b.name for b in mesh.boundaries], flush=True) -foot = np.asarray(rinfo["band"], dtype=bool) -BAND_LABEL, BAND_VALUE = "Band", int(mesh.boundaries["Band"].value) if "Band" in [b.name for b in mesh.boundaries] else None - -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt -from matplotlib.collections import LineCollection, PolyCollection - - -def mesh_art(m, fault, offset, n_hat): - dm = m.dm - eS, eE = dm.getDepthStratum(1) - vS_, vE_ = dm.getDepthStratum(0) - cS_, cE_ = dm.getHeightStratum(0) - Xm = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, 2) - sides = {} - for side, sgn in (("Plus", 1.0), ("Minus", -1.0)): - name = f"{fault}{side}" - if name in [bd.name for bd in m.boundaries]: - lab = dm.getLabel(name) - value = m.boundaries[name].value - if lab and lab.getStratumSize(value) > 0: - sides[side] = (set(int(e) for e in lab.getStratumIS(value).getIndices() - if eS <= int(e) < eE), sgn) - segs, fsegs = [], {"Plus": [], "Minus": []} - for e in range(eS, eE): - a_, b_ = (int(q) - vS_ for q in dm.getCone(e)) - seg = np.array([Xm[a_], Xm[b_]]) - placed = False - for side, (es, sgn) in sides.items(): - if e in es: - fsegs[side].append(seg + sgn * offset * n_hat) - placed = True - if not placed: - segs.append(seg) - polys = [] - try: - mask = foot - for c in np.nonzero(mask)[0]: - cl = dm.getTransitiveClosure(int(c) + cS_)[0] - polys.append(Xm[[int(q) - vS_ for q in cl if vS_ <= q < vE_]]) - except Exception: - pass - return segs, fsegs, polys - - -segs, fsegs, polys = mesh_art(mesh, "Trench", 0.0015, n_hat) -fig, axes = plt.subplots(1, 2, figsize=(13, 6.2), gridspec_kw={"width_ratios": [1, 1]}) -for ax, (x0, x1, y0, y1), lw in ((axes[0], (-1.05, 1.05, -1.05, 1.05), 0.25), - (axes[1], (0.80, 1.02, -0.06, 0.20), 0.5)): - if polys: - ax.add_collection(PolyCollection(polys, facecolors="#dff0d8", edgecolors="none")) - ax.add_collection(LineCollection(segs, colors="#777", linewidths=lw)) - for side, col in (("Plus", "#c0392b"), ("Minus", "#1a5276")): - if fsegs[side]: - ax.add_collection(LineCollection(fsegs[side], colors=col, linewidths=1.6)) - ax.set_xlim(x0, x1) - ax.set_ylim(y0, y1) - ax.set_aspect("equal") - ax.set_xticks([]) - ax.set_yticks([]) -axes[0].set_title(f"the add-on finest: base annulus h = {H / 2**REF} ({REF} halvings of {H}) with the band placed, " - f"{mesh.dm.getHeightStratum(0)[1]} cells", fontsize=9, loc="left") -axes[1].set_title(f"the trench: ladder width {W}, spacing {S}, outcropping through the cap; spine split (red Plus, blue Minus) " - f"to three spacings below the surface", fontsize=9, loc="left") -fig.tight_layout() -fig.savefig(os.path.join(out, "probe_mesh.png"), dpi=170) -plt.close(fig) -print("wrote probe_mesh.png", flush=True) - -# T on the add-on, by evaluation from the base -T_f = uw.discretisation.MeshVariable("T_f", mesh, 1, degree=2) -xf = np.asarray(T_f.coords) -rf = np.hypot(xf[:, 0], xf[:, 1]) -xq = xf * np.minimum(1.0, (RO - 1e-10) / np.maximum(rf, 1e-12))[:, None] -xq = xq * np.maximum(1.0, (RI + 1e-10) / np.maximum(np.hypot(xq[:, 0], xq[:, 1]), 1e-12))[:, None] -t2 = time.perf_counter() -T_f.array[:, 0, 0] = uw.function.evaluate(T_b.sym[0], xq).ravel() -print(f"T transfer {time.perf_counter() - t2:.1f} s, range {T_f.array.min():.3f} {T_f.array.max():.3f}", - flush=True) - -# Stokes on the add-on -v = uw.discretisation.MeshVariable("U", mesh, 2, degree=2) -p = uw.discretisation.MeshVariable("P", mesh, 1, degree=1, continuous=True) -stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) -unit_r = mesh.CoordinateSystem.unit_e_0 -stokes.bodyforce = RA * T_f.sym[0] * unit_r -eta_T = sympy.exp(sympy.log(DETA) * (1 - T_f.sym[0])) # 1 at T=1, DETA at T=0 -if os.environ.get("ZG95_CONSTETA"): - eta_T = sympy.Integer(1) - print("constant viscosity", flush=True) -# TI bridge: band cells whose centroid lies above the split's top tip -pairs = mesh._fault_point_pairs -if "Trench" in pairs: - pairs = pairs["Trench"] -print("pairs:", type(pairs).__name__, len(pairs), flush=True) -vS, vE = mesh.dm.getDepthStratum(0) -X = np.asarray(mesh.dm.getCoordinatesLocal().array).reshape(-1, 2) -if isinstance(pairs, dict): - pair_pts = np.array([int(k) for k in pairs] + [int(q) for q in pairs.values()]) -else: - pair_pts = np.asarray(pairs, dtype=int).ravel() -pv = pair_pts[(pair_pts >= vS) & (pair_pts < vE)] - vS -r_tip = np.hypot(X[pv, 0], X[pv, 1]).max() -print(f"split top tip at r = {r_tip:.4f} (surface {RO})", flush=True) -cS, cE = mesh.dm.getHeightStratum(0) -cent = np.array([X[[int(q) - vS for q in mesh.dm.getTransitiveClosure(c)[0] if vS <= q < vE]].mean(axis=0) - for c in range(cS, cE)]) -bridge = foot & (np.hypot(cent[:, 0], cent[:, 1]) > r_tip - 0.5 * W) -zone = uw.discretisation.MeshVariable("zone", mesh, 1, degree=0, continuous=False) -zone.array[:, 0, 0] = bridge.astype(float) -print(f"footprint cells {int(foot.sum())}, bridge cells {int(bridge.sum())}", flush=True) -if os.environ.get("ZG95_NOTI"): - stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel - stokes.constitutive_model.Parameters.shear_viscosity_0 = eta_T - print("no TI bridge (isotropic)", flush=True) -else: - stokes.constitutive_model = uw.constitutive_models.TransverseIsotropicFlowModel - stokes.constitutive_model.Parameters.shear_viscosity_0 = eta_T - stokes.constitutive_model.Parameters.shear_viscosity_1 = eta_T * (1 - 0.999 * zone.sym[0]) - stokes.constitutive_model.Parameters.director = sympy.Matrix([[float(n_hat[0]), float(n_hat[1])]]) -if os.environ.get("ZG95_DIRICHLET"): - stokes.add_dirichlet_bc((0.0, 0.0), "Upper") - stokes.add_dirichlet_bc((0.0, 0.0), "Lower") - print("no-slip walls (Dirichlet)", flush=True) -else: - # the exact radial normal: with facet-averaged normals on an irregular - # polygon the rigid rotation is not exactly tangential, the gauge mode is - # rejected, and the coarse Galerkin operator is nearly singular for LU - stokes.add_rotated_freeslip_bc(0, "Upper", normal=unit_r) - stokes.add_rotated_freeslip_bc(0, "Lower", normal=unit_r) -if os.environ.get("ZG95_NOFAULT"): - print("no fault bc (welded)", flush=True) -else: - stokes.add_fault_bc(0, boundary="Trench") -stokes.petsc_use_pressure_nullspace = True -stokes.tolerance = 1e-5 -print(f"T_f nan {int(np.isnan(T_f.array).sum())}, zone nan {int(np.isnan(zone.array).sum())}", flush=True) -if os.environ.get("ZG95_REL"): - from underworld3.utilities import fault_mg - fault_mg.UNREACHED_REL = float(os.environ["ZG95_REL"]) - print("unreached threshold", fault_mg.UNREACHED_REL, flush=True) -if not os.environ.get("ZG95_NOFMG"): - set_custom_fmg(stokes, tail, field_id=0, - fac_zone=None if os.environ.get("ZG95_NOFAC") else foot) - print("FMG with" + ("out" if os.environ.get("ZG95_NOFAC") else "") + " band patch", flush=True) -else: - print("FMG disabled (GAMG)", flush=True) -t3 = time.perf_counter() -if os.environ.get("ZG95_TRAP"): - from petsc4py import PETSc - for pre in ("rotfs1_fieldsplit_vel_mg_levels_", "rotfs1_fieldsplit_vel_mg_levels_3_", - "rotfs1_fieldsplit_vel_", "rotfs1_"): - PETSc.Options()[pre + "ksp_error_if_not_converged"] = True - print("trapping KSP failures", flush=True) -stokes.solve() -info = stokes._rotated_freeslip_info -try: - ctx = (getattr(stokes, "_rotated_linear_cache", None) or {})["ctx"] - velpc = ctx["ksp"].getPC().getFieldSplitSubKSP()[0].getPC() - print(f" null modes verified: {getattr(stokes, '_rotated_velocity_null_modes', None)}, " - f"gauge removed: {info.get('rotation_gauge_removed')}, nsp: {ctx.get('nsp') is not None}, " - f"coarse pc: {velpc.getMGCoarseSolve().getPC().getType()}", flush=True) - velksp = ctx["ksp"].getPC().getFieldSplitSubKSP()[0] - print(f" prefixes: outer {ctx['ksp'].getOptionsPrefix()!r} velocity {velksp.getOptionsPrefix()!r}", flush=True) - if os.environ.get("ZG95_PCAPPLY"): - from petsc4py import PETSc - A_vv = velpc.getOperators()[0] - xx, yy = A_vv.createVecs() - xx.setRandom() - try: - velpc.apply(xx, yy) - print(f" whole velocity PC apply: |y| {yy.norm():.3e}", flush=True) - except Exception as e: - print(" whole velocity PC apply raised:", str(e)[:200], flush=True) - for l in range(velpc.getMGLevels()): - sm = velpc.getMGSmoother(l) - A = sm.getOperators()[0] - Pm = sm.getOperators()[1] - dA = A.getDiagonal().array; dP = Pm.getDiagonal().array - print(f" level {l}: Amat is Pmat {A.handle == Pm.handle}; zero diag A {int((dA == 0).sum())} P {int((dP == 0).sum())}; " - f"|dP| min {np.abs(dP).min():.3e}; nan P {int(np.isnan(dP).sum())} types {A.getType()}/{Pm.getType()}", flush=True) - b, x = A.createVecs() - b.setRandom(); x.set(0.0) - try: - sm.solve(b, x) - print(f" level {l} smoother ({sm.getType()}/{sm.getPC().getType()}): |x| {x.norm():.3e} its {sm.getIterationNumber()} reason {sm.getConvergedReason()}", flush=True) - except Exception as e: - print(f" level {l} smoother raised:", str(e)[:200], flush=True) - if l > 0: - P = velpc.getMGInterpolation(l) - xc = P.createVecRight(); xf = P.createVecLeft() - xc.setRandom() - P.mult(xc, xf) - print(f" P_{l} apply |xf| {xf.norm():.3e}; A_{l} apply |Ax| {A.createVecs()[0].norm():.3e}", flush=True) - cs = velpc.getMGCoarseSolve() - A0 = cs.getOperators()[0] - b, x = A0.createVecs(); b.setRandom(); x.set(0.0) - try: - cs.solve(b, x) - print(f" coarse solve ({cs.getType()}/{cs.getPC().getType()}): |x| {x.norm():.3e} reason {cs.getConvergedReason()}", flush=True) - except Exception as e: - print(" coarse solve raised:", str(e)[:300], flush=True) - for l in range(velpc.getMGLevels()): - A = velpc.getMGSmoother(l).getOperators()[0] - d = A.getDiagonal().array - line = f" level {l}: n {A.getSize()[0]} |A| {A.norm():.3e} diag min|.| {np.abs(d).min():.3e} nan {int(np.isnan(d).sum())}" - if l > 0: - P = velpc.getMGInterpolation(l) - Pa = P.getValuesCSR()[2] - line += f" | P {P.getSize()} nnz {len(Pa)} max {np.abs(Pa).max():.3e} nan {int(np.isnan(Pa).sum())} inf {int(np.isinf(Pa).sum())}" - print(line, flush=True) - if l > 0: - bad = np.flatnonzero(np.abs(d) < 1e-8) - print(f" tiny-diagonal unknowns on level {l}: {bad[:10]} ({len(bad)})", flush=True) - if len(bad): - ia, ja, va = P.getValuesCSR() - for b in bad[:4]: - rows = np.flatnonzero(np.diff(ia))[np.isin(ja[ia[np.flatnonzero(np.diff(ia))]], [b])] if False else None - hit = np.flatnonzero(ja == b) - rr = np.searchsorted(ia, hit, side="right") - 1 - print(f" col {b}: {len(hit)} entries, weights {va[hit][:8]}, fine rows {rr[:8]}", flush=True) - # coarse row b of the Galerkin operator: its own entries - cols_b, vals_b = A.getRow(int(b)) - print(f" A row {b}: {len(cols_b)} entries, max |.| {np.abs(vals_b).max() if len(vals_b) else 0:.3e}", flush=True) - # the coarse levels' own layout: which node is unknown b? - if l == 1 and len(bad): - h_ = getattr(stokes, "_custom_mg_hierarchy", None) - print(" hierarchy object:", type(h_).__name__ if h_ is not None else None, - [k for k in dir(h_) if "layout" in k or "reduced" in k or "map" in k][:12] if h_ is not None else "", flush=True) -except Exception as e: - import traceback; traceback.print_exc() - print("hierarchy inspection failed:", e, flush=True) -print(f"solve {time.perf_counter() - t3:.1f} s: pc {info.get('velocity_pc')}, vel its {info.get('vel_its_last')}, " - f"pres its {info.get('pres_its_last')}, converged {info.get('converged')}", flush=True) -from underworld3.utilities.fault_contact import fault_slip -_s, Vslip, leak = fault_slip(stokes, "Trench", info) -print(f"slip max {np.abs(Vslip).max():.4f}, leak {np.abs(leak).max():.2e}", flush=True) - -h = uw.discretisation.MeshVariable("topo", mesh, 1, degree=1) -stokes.dynamic_topography("Upper", h, buoyancy_scale=RA) -xs = np.asarray(h.coords) -on = np.hypot(xs[:, 0], xs[:, 1]) > RO - 1e-6 -print(f"topography on {on.sum()} nodes, range {h.array[on].min():.3e} {h.array[on].max():.3e}", flush=True) -vmag = np.linalg.norm(v.array[:, 0, :], axis=1) -print(f"vmax {vmag.max():.3f}; total {time.perf_counter() - t0:.1f} s", flush=True) -np.savez(os.path.join(out, "probe.npz"), topo_x=xs[on], topo=h.array[on, 0, 0]) -# solution figure: T with velocity arrows, surface velocity and topography against theta -import matplotlib.tri as mtri -Tx = np.asarray(T_f.coords) -tri_cells = np.array([[int(q) - vS for q in mesh.dm.getTransitiveClosure(c)[0] if vS <= q < vE] - for c in range(cS, cE)]) -fig, axes = plt.subplots(1, 3, figsize=(17, 5.2), gridspec_kw={"width_ratios": [1.2, 1, 1]}) -ax = axes[0] -Tv = uw.function.evaluate(T_f.sym[0], X * (1 - 1e-12)).ravel() -tc = ax.tripcolor(mtri.Triangulation(X[:, 0], X[:, 1], tri_cells), Tv, shading="gouraud", - cmap="RdBu_r", vmin=0, vmax=1) -vx = v.array[:, 0, :] -vc = np.asarray(v.coords) -pick = np.arange(0, len(vc), max(1, len(vc) // 900)) -ax.quiver(vc[pick, 0], vc[pick, 1], vx[pick, 0], vx[pick, 1], color="k", scale=None, width=0.0025) -for side, col in (("Plus", "#c0392b"), ("Minus", "#1a5276")): - if fsegs[side]: - ax.add_collection(LineCollection(fsegs[side], colors=col, linewidths=1.2)) -ax.set_aspect("equal"); ax.set_xticks([]); ax.set_yticks([]) -ax.set_title(f"T (base field evaluated onto the add-on) and velocity; vmax {vmag.max():.1f}", fontsize=9, loc="left") -fig.colorbar(tc, ax=ax, shrink=0.8) -ax = axes[1] -surf = np.hypot(vc[:, 0], vc[:, 1]) > RO - 1e-6 -th_s = np.arctan2(vc[surf, 1], vc[surf, 0]) -vt = -vx[surf, 0] * np.sin(th_s) + vx[surf, 1] * np.cos(th_s) -o = np.argsort(th_s) -ax.plot(np.degrees(th_s[o]), vt[o], "-", lw=1) -ax.axvline(0, color="#c0392b", lw=0.8, ls="--") -ax.set_xlabel("theta (deg); trench at 0, dipping toward +theta"); ax.set_ylabel("surface tangential velocity") -ax.grid(alpha=0.3); ax.set_title("surface velocity", fontsize=9, loc="left") -ax = axes[2] -th_t = np.arctan2(xs[on, 1], xs[on, 0]); o = np.argsort(th_t) -ax.plot(np.degrees(th_t[o]), h.array[on, 0, 0][o], "-", lw=1) -ax.axvline(0, color="#c0392b", lw=0.8, ls="--") -ax.set_xlabel("theta (deg)"); ax.set_ylabel("dynamic topography (-(s_nn - mean) / Ra)") -ax.grid(alpha=0.3); ax.set_title("topography from the free-slip reaction", fontsize=9, loc="left") -fig.tight_layout() -fig.savefig(os.path.join(out, "probe_solution.png"), dpi=150) -print("wrote probe_solution.png", flush=True) - -# mesh + solution: the fault and its cut in the elements -from scipy.spatial import cKDTree -tri_obj = mtri.Triangulation(X[:, 0], X[:, 1], tri_cells) -bridge_polys = [X[tri_cells[c]] for c in np.nonzero(bridge)[0]] -_d, _k = cKDTree(vc).query(X) -vmag_v = vmag[_k] # |v| at the mesh vertices -tip_xy = trace[0] # the sample nearest the surface -fig, axes = plt.subplots(1, 3, figsize=(19, 6.4), gridspec_kw={"width_ratios": [1.15, 1, 1]}) -ax = axes[0] -ax.tripcolor(tri_obj, Tv, shading="gouraud", cmap="RdBu_r", vmin=0, vmax=1, alpha=0.85) -ax.add_collection(LineCollection(segs, colors="#555", linewidths=0.18)) -for side, col in (("Plus", "#c0392b"), ("Minus", "#1a5276")): - if fsegs[side]: - ax.add_collection(LineCollection(fsegs[side], colors=col, linewidths=1.4)) -ax.set_aspect("equal"); ax.set_xticks([]); ax.set_yticks([]) -ax.set_xlim(-1.03, 1.03); ax.set_ylim(-1.03, 1.03) -ax.set_title(f"T on the add-on finest ({mesh.dm.getHeightStratum(0)[1]} cells) with the mesh and the trench", - fontsize=9, loc="left") -for ax, win, lw, off, title in ( - (axes[1], (0.84, 1.01, -0.04, 0.17), 0.45, 0.0015, - "the trench: |v| under the mesh; band (green rim), TI bridge (orange rim), split spine (red Plus, blue Minus)"), - (axes[2], (tip_xy[0] - 0.03, tip_xy[0] + 0.03, tip_xy[1] - 0.03, tip_xy[1] + 0.03), 0.7, 0.0008, - f"the upper tip: the cut stops at r = {r_tip:.3f} inside the band; the bridge carries it to the outcrop")): - tc = ax.tripcolor(tri_obj, vmag_v, shading="gouraud", cmap="viridis", vmin=0, vmax=np.percentile(vmag_v, 99)) - if polys: - ax.add_collection(PolyCollection(polys, facecolors="none", edgecolors="#2e7d32", linewidths=0.6)) - if bridge_polys: - ax.add_collection(PolyCollection(bridge_polys, facecolors="none", edgecolors="#e67e22", linewidths=0.9)) - ax.add_collection(LineCollection(segs, colors="#ddd", linewidths=lw)) - for side, col in (("Plus", "#c0392b"), ("Minus", "#1a5276")): - if fsegs[side]: - ax.add_collection(LineCollection(fsegs[side], colors=col, linewidths=2.0)) - ax.set_xlim(win[0], win[1]); ax.set_ylim(win[2], win[3]) - ax.set_aspect("equal"); ax.set_xticks([]); ax.set_yticks([]) - ax.set_title(title, fontsize=8.5, loc="left") -fig.colorbar(tc, ax=axes[2], shrink=0.7, label="|v|") -fig.tight_layout() -fig.savefig(os.path.join(out, "probe_mesh_solution.png"), dpi=160) -print("wrote probe_mesh_solution.png", flush=True) -print("DONE") diff --git a/scripts/sessions/fault_mg/zg95_profile.py b/scripts/sessions/fault_mg/zg95_profile.py deleted file mode 100644 index 42cbfa3f6..000000000 --- a/scripts/sessions/fault_mg/zg95_profile.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Where a step's time goes: the meshing (placement, split, solver creation) -against the solve, and inside the solve the assembly, the hierarchy set-up -and the Krylov work. One add-on of the Zhong & Gurnis analogue, three solves: -the first (everything built), a repeat with nothing changed, and a repeat -after the temperature changed (operator refreshed, transfers kept). - -python zg95_profile.py -""" -import os -import sys -import time - -import numpy as np -import sympy -from petsc4py import PETSc - -out = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/+Simulations/zg95") -os.makedirs(out, exist_ok=True) -PETSc.Options()["log_view"] = f":{os.path.join(out, 'profile_log_view.txt')}" -PETSc.Log.begin() -import underworld3 as uw -from scipy.special import erf -from underworld3.utilities.custom_mg import set_custom_fmg -from underworld3.utilities.place_surface import place_fault_ribbon_2d - -RI, RO = 0.55, 1.0 -S, W = 0.005, 0.01 -DIP, DEPTH, RA, DETA, ETA_W = np.deg2rad(45.0), 0.15, 1e6, 1e3, 1e-3 -T = {} - - -def tic(k): - T["_k"], T["_t"] = k, time.perf_counter() - - -def toc(): - T[T["_k"]] = T.get(T["_k"], 0.0) + time.perf_counter() - T["_t"] - - -tic("base mesh + levels") -base = uw.meshing.Annulus(radiusOuter=RO, radiusInner=RI, cellSize=0.16, refinement=3, qdegree=2) -tail = base._coarse_level_meshes()[:-1] + [base] -toc() -T_b = uw.discretisation.MeshVariable("T_b", base, 1, degree=2) -xb = np.asarray(T_b.coords) -z = (RO - np.hypot(xb[:, 0], xb[:, 1])) / (RO - RI) -th = np.arctan2(xb[:, 1], xb[:, 0]) -T_b.array[:, 0, 0] = np.clip(0.6 * erf(z / 0.1) + 0.4 * (1 - erf((1 - z) / 0.1)) - 0.04 * np.cos(3 * th) * np.sin(np.pi * z), 0, 1) - -t_hat, s_hat = np.array([1.0, 0.0]), np.array([0.0, 1.0]) -d_hat = -np.cos(DIP) * t_hat + np.sin(DIP) * s_hat -n_hat = np.array([-d_hat[1], d_hat[0]]) -top = t_hat * (RO + 0.02) -L = (DEPTH + 0.02) / np.cos(DIP) -n_s = int(round(L / S)) -spine = np.array([top + d_hat * (L * i / n_s) for i in range(n_s + 1)]) -trace = spine[np.hypot(spine[:, 0], spine[:, 1]) <= RO - 3 * S] - -stage = PETSc.Log.Stage("placement+split") -stage.push() -tic("placement + split (ladder, cap, cut)") -mesh, rinfo = place_fault_ribbon_2d(base, [("Trench", trace)], W, spines=[("Trench", spine)], margin_rings=1, - clearance=0.7, split=True, mesher="ladder") -toc() -stage.pop() -band = np.asarray(rinfo["band"], dtype=bool) - -tic("variables + solver object") -T_f = uw.discretisation.MeshVariable("T_f", mesh, 1, degree=2) -v = uw.discretisation.MeshVariable("U", mesh, 2, degree=2) -p = uw.discretisation.MeshVariable("P", mesh, 1, degree=1, continuous=True) -zone = uw.discretisation.MeshVariable("zone", mesh, 1, degree=0, continuous=False) -dm = mesh.dm -vS, vE = dm.getDepthStratum(0) -cS, cE = dm.getHeightStratum(0) -X = np.asarray(dm.getCoordinatesLocal().array).reshape(-1, 2) -pairs = mesh._fault_point_pairs["Trench"] -pp = np.array([int(q) for q in pairs] + [int(q) for q in pairs.values()]) -pv = pp[(pp >= vS) & (pp < vE)] - vS -r_tip = float(np.hypot(X[pv, 0], X[pv, 1]).max()) -cent = np.array([X[[int(q) - vS for q in dm.getTransitiveClosure(c)[0] if vS <= q < vE]].mean(axis=0) for c in range(cS, cE)]) -zone.array[:, 0, 0] = (band & (np.hypot(cent[:, 0], cent[:, 1]) > r_tip - 0.5 * W)).astype(float) -s = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) -eta0 = sympy.exp(sympy.log(DETA) * (1 - T_f.sym[0])) -s.constitutive_model = uw.constitutive_models.TransverseIsotropicFlowModel -s.constitutive_model.Parameters.shear_viscosity_0 = eta0 -s.constitutive_model.Parameters.shear_viscosity_1 = eta0 * (1 - (1 - ETA_W) * zone.sym[0]) -s.constitutive_model.Parameters.director = sympy.Matrix([[float(n_hat[0]), float(n_hat[1])]]) -unit_r = mesh.CoordinateSystem.unit_e_0 -s.bodyforce = RA * T_f.sym[0] * unit_r -s.add_rotated_freeslip_bc(0, "Upper", normal=unit_r) -s.add_rotated_freeslip_bc(0, "Lower", normal=unit_r) -s.add_fault_bc(0, boundary="Trench") -s.petsc_use_pressure_nullspace = True -s.tolerance = 1e-5 -set_custom_fmg(s, tail, field_id=0) -toc() - - -def clamp(x): - r = np.hypot(x[:, 0], x[:, 1]) - return x * (np.clip(r, RI + 1e-10, RO - 1e-10) / np.maximum(r, 1e-14))[:, None] - - -tic("T transfer") -T_f.array[:, 0, 0] = uw.function.evaluate(T_b.sym[0], clamp(np.asarray(T_f.coords))).ravel() -toc() -for name in ("solve 1 (first: hierarchy built)", "solve 2 (nothing changed)", "solve 3 (T changed)"): - if name.startswith("solve 3"): - T_f.array[:, 0, 0] = np.clip(T_f.array[:, 0, 0] * 0.98 + 0.01, 0, 1) - stage = PETSc.Log.Stage(name.split(" (")[0].replace(" ", "_")) - stage.push() - tic(name) - s.solve() - toc() - stage.pop() - info = s._rotated_freeslip_info - print(f"{name}: {T[name]:.1f} s, velocity its {info.get('vel_its_last')}, pressure its {info.get('pres_its_last')}", flush=True) -print("TIMES", {k: round(v_, 2) for k, v_ in T.items() if not k.startswith("_")}, flush=True) -print("DONE", flush=True) diff --git a/scripts/sessions/fault_mg/zg95_render.py b/scripts/sessions/fault_mg/zg95_render.py deleted file mode 100644 index 24e0f0832..000000000 --- a/scripts/sessions/fault_mg/zg95_render.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Render a zg95 checkpoint offline: T on the base from the global h5 (P2 cells -subdivided into four), with the trench and the surface profiles of that step -when the driver wrote them. - -python zg95_render.py [out.png] -""" -import glob -import json -import os -import sys - -import h5py -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt -import matplotlib.tri as mtri -import numpy as np - -run, idx = sys.argv[1], int(sys.argv[2]) -out = sys.argv[3] if len(sys.argv) > 3 else os.path.join(run, f"render_{idx:04d}.png") -with h5py.File(os.path.join(run, f"zg95.mesh.T_b.{idx:05d}.h5")) as h: - T = np.asarray(h["fields/T_b"]).ravel() - X = np.asarray(h["fields/coordinates"]) - C = np.asarray(h["fields/cells"]).astype(int) -# P2 triangle node order: 3 vertices then 3 edge midpoints (opposite order per PETSc: -# midpoints follow the vertex order of the edges 01, 12, 20); subdivide into 4 -v0, v1, v2, m01, m12, m20 = (C[:, k] for k in range(6)) -tris = np.vstack([np.column_stack([v0, m01, m20]), np.column_stack([m01, v1, m12]), - np.column_stack([m20, m12, v2]), np.column_stack([m01, m12, m20])]) -state = {} -sf = os.path.join(run, f"state_{idx:04d}.json") -if os.path.exists(sf): - state = json.load(open(sf)) -surf = os.path.join(run, f"surface_{idx:04d}.npz") -has_surf = os.path.exists(surf) -fig, axes = plt.subplots(1, 3 if has_surf else 1, figsize=(18 if has_surf else 7, 5.8), squeeze=False) -ax = axes[0, 0] -tc = ax.tripcolor(mtri.Triangulation(X[:, 0], X[:, 1], tris), T, shading="gouraud", cmap="RdBu_r", vmin=0, vmax=1) -if has_surf: - tr = np.load(surf)["trace"] - ax.plot(tr[:, 0], tr[:, 1], color="k", lw=1.5) -ax.set_aspect("equal"); ax.set_xticks([]); ax.set_yticks([]) -ax.set_title(f"step {idx}, t = {state.get('t', float('nan')):.4f}: T on the base", fontsize=9, loc="left") -fig.colorbar(tc, ax=ax, shrink=0.8) -if has_surf: - d = np.load(surf) - th0 = np.degrees(state.get("theta_t", 0.0)) - ax = axes[0, 1] - ax.plot(np.degrees(d["theta"]), d["vt"], lw=1); ax.axvline(th0, color="#c0392b", lw=0.8, ls="--") - ax.set_xlabel("theta (deg)"); ax.set_ylabel("surface tangential velocity"); ax.grid(alpha=0.3) - ax = axes[0, 2] - ax.plot(np.degrees(d["theta_h"]), d["h"], lw=1); ax.axvline(th0, color="#c0392b", lw=0.8, ls="--") - ax.set_xlabel("theta (deg)"); ax.set_ylabel("dynamic topography"); ax.grid(alpha=0.3) -fig.tight_layout() -fig.savefig(out, dpi=130) -print("wrote", out) diff --git a/scripts/sessions/fault_mg/zg95_stream.py b/scripts/sessions/fault_mg/zg95_stream.py deleted file mode 100644 index d41649259..000000000 --- a/scripts/sessions/fault_mg/zg95_stream.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Surface lateral velocity above the flow: from a zg95 checkpoint, the surface -tangential velocity against theta, aligned above the unrolled annulus (theta, -depth) with temperature and streamlines, and the annulus itself with streamlines -beside it. The trench is drawn when the step wrote its surface file. - -python zg95_stream.py [out.png] -""" -import json -import os -import sys - -import h5py -import matplotlib -matplotlib.use("Agg") -import matplotlib.pyplot as plt -import matplotlib.tri as mtri -import numpy as np -from scipy.interpolate import LinearNDInterpolator - -run, idx = sys.argv[1], int(sys.argv[2]) -out = sys.argv[3] if len(sys.argv) > 3 else os.path.join(run, f"stream_{idx:04d}.png") -RO, RI = 1.0, 0.55 -with h5py.File(os.path.join(run, f"zg95.mesh.T_b.{idx:05d}.h5")) as h: - T = np.asarray(h["fields/T_b"]).ravel() - X = np.asarray(h["fields/coordinates"]) - C = np.asarray(h["fields/cells"]).astype(int) -with h5py.File(os.path.join(run, f"zg95.mesh.V_b.{idx:05d}.h5")) as h: - V = np.asarray(h["fields/V_b"]).reshape(-1, 2) - XV = np.asarray(h["fields/coordinates"]) -state = {} -sf = os.path.join(run, f"state_{idx:04d}.json") -if os.path.exists(sf): - state = json.load(open(sf)) -rec = {} -rf = os.path.join(run, "records.json") -if os.path.exists(rf): - rr = [r for r in json.load(open(rf)) if r.get("step") == idx] - rec = rr[0] if rr else {} -trace = None -surf = os.path.join(run, f"surface_{idx:04d}.npz") -if not os.path.exists(surf): - # a checkpoint without its own surface file: the nearest earlier one (the trench - # moves about half a degree per step, so a few steps old is still the fault's place) - import glob - cands = sorted(glob.glob(os.path.join(run, "surface_*.npz"))) - cands = [c for c in cands if int(os.path.basename(c)[8:12]) <= idx] - surf = cands[-1] if cands else surf -if os.path.exists(surf): - trace = np.load(surf)["trace"] - -r = np.hypot(X[:, 0], X[:, 1]) -th = np.arctan2(X[:, 1], X[:, 0]) -rV = np.hypot(XV[:, 0], XV[:, 1]) -thV = np.arctan2(XV[:, 1], XV[:, 0]) -vt = -V[:, 0] * np.sin(thV) + V[:, 1] * np.cos(thV) -vr = V[:, 0] * np.cos(thV) + V[:, 1] * np.sin(thV) - -# unrolled grid (theta in degrees, depth), periodic in theta: pad the nodes by one period -def padded(th_, vals, *more): - th3 = np.concatenate([th_ - 2 * np.pi, th_, th_ + 2 * np.pi]) - return th3, [np.concatenate([v, v, v]) for v in (vals, *more)] -th3, (T3, r3) = padded(th, T, r) -th3V, (vt3, vr3, rV3) = padded(thV, vt, vr, rV) -nth, nd = 720, 120 -thg = np.linspace(-np.pi, np.pi, nth) -dg = np.linspace(0.0, RO - RI, nd) -TH, D = np.meshgrid(thg, dg) -Rg = RO - D -Tg = LinearNDInterpolator(np.column_stack([th3, r3]), T3)(TH, Rg) -vtg = LinearNDInterpolator(np.column_stack([th3V, rV3]), vt3)(TH, Rg) -vrg = LinearNDInterpolator(np.column_stack([th3V, rV3]), vr3)(TH, Rg) -# streamlines in the unrolled plane: d(theta_deg)/dt = v_t / r (in degrees), d(depth)/dt = -v_r -U = np.degrees(vtg / Rg) -W = -vrg - -# annulus grid for the second streamline view -ng = 500 -xg = np.linspace(-RO, RO, ng) -XG, YG = np.meshgrid(xg, xg) -RG = np.hypot(XG, YG) -inside = (RG > RI + 1e-3) & (RG < RO - 1e-3) -vx_i = LinearNDInterpolator(XV, V[:, 0])(XG, YG) -vy_i = LinearNDInterpolator(XV, V[:, 1])(XG, YG) -vx_i[~inside] = np.nan -vy_i[~inside] = np.nan - -o = np.argsort(thV[rV > RO - 1e-6]) -th_s = thV[rV > RO - 1e-6][o] -vt_s = vt[rV > RO - 1e-6][o] - -VEXAG = 2.0 # vertical exaggeration of the unrolled section (1 = true aspect at mid-depth) -_deg_per_len = 360.0 / (np.pi * (RO + RI)) # degrees of arc per length unit at mid-radius -fig = plt.figure(figsize=(19, 5.6)) -gs = fig.add_gridspec(2, 2, width_ratios=[1.7, 1], height_ratios=[1, 1.15], hspace=0.08, wspace=0.12) -ax0 = fig.add_subplot(gs[0, 0]) -ax1 = fig.add_subplot(gs[1, 0], sharex=ax0) -ax2 = fig.add_subplot(gs[:, 1]) -ax0.plot(np.degrees(th_s), vt_s, lw=1.2) -ax0.axhline(0, color="k", lw=0.5) -if trace is not None: - tt = np.degrees(np.arctan2(trace[:, 1], trace[:, 0])) - ax0.axvline(tt[0], color="#c0392b", lw=0.8, ls="--") -ax0.set_ylabel("surface tangential velocity") -ax0.grid(alpha=0.3) -plt.setp(ax0.get_xticklabels(), visible=False) -t = state.get("t", float("nan")) -title = f"{os.path.basename(os.path.normpath(run))}: step {idx}, t = {t:.4f}" -for k, nm in (("mobility", "mobility"), ("lid_depth", "lid"), ("yield_frac_lid", "yielded in lid")): - if rec.get(k) is not None and not (isinstance(rec[k], float) and np.isnan(rec[k])): - title += f"; {nm} {rec[k]:.2f}" -ax0.set_title(title, fontsize=10, loc="left") -pc = ax1.pcolormesh(np.degrees(TH), D, Tg, cmap="RdBu_r", vmin=0, vmax=1, shading="auto") -ax1.streamplot(np.degrees(thg), dg, U, W, density=(2.2, 0.9), color="k", linewidth=0.6, arrowsize=0.8) -if trace is not None: - ax1.plot(np.degrees(np.arctan2(trace[:, 1], trace[:, 0])), RO - np.hypot(trace[:, 0], trace[:, 1]), - color="#c0392b", lw=2) -ax1.set_ylim(RO - RI, 0) -ax1.set_xlim(-180, 180) -ax1.set_aspect(VEXAG * _deg_per_len, adjustable="box", anchor="N") -ax1.set_xlabel("theta (deg)") -ax1.set_ylabel("depth") -fig.colorbar(pc, ax=ax1, orientation="horizontal", pad=0.42, shrink=0.4, aspect=40, label="T") -v0, v1, v2, m01, m12, m20 = (C[:, k] for k in range(6)) -tris = np.vstack([np.column_stack([v0, m01, m20]), np.column_stack([m01, v1, m12]), - np.column_stack([m20, m12, v2]), np.column_stack([m01, m12, m20])]) -ax2.tripcolor(mtri.Triangulation(X[:, 0], X[:, 1], tris), T, shading="gouraud", cmap="RdBu_r", vmin=0, vmax=1) -ax2.streamplot(xg, xg, vx_i, vy_i, density=1.6, color="k", linewidth=0.5, arrowsize=0.7) -if trace is not None: - ax2.plot(trace[:, 0], trace[:, 1], color="#c0392b", lw=2) -ax2.set_aspect("equal"); ax2.set_xticks([]); ax2.set_yticks([]) -ax2.set_xlim(-1.02, 1.02); ax2.set_ylim(-1.02, 1.02) -ax2.set_title("streamlines on the annulus", fontsize=10, loc="left") -fig.savefig(out, dpi=120, bbox_inches="tight") -print("wrote", out) diff --git a/scripts/sessions/thin_sheet_elsasser.py b/scripts/sessions/thin_sheet_elsasser.py deleted file mode 100644 index 27b2ecfe1..000000000 --- a/scripts/sessions/thin_sheet_elsasser.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Elsasser stress diffusion in a viscoelastic thin sheet over a viscous substrate. - -An elastic plate (shear modulus mu, thickness L, Maxwell time much longer than -the run) rests on a viscous channel of drag coefficient c = eta_a / h_a. A -step in the velocity of its left edge diffuses into the plate: - - v_t = D v_xx, D = mu L / c, v(x, t) = V0 erfc(x / sqrt(4 D t)). - -The figure compares the ThinSheet profiles with the erfc solution at several -times. Run from the worktree environment; the figure goes to -uw_output_dir. -""" -import os - -import numpy as np -from scipy.special import erfc - -import underworld3 as uw - -params = uw.Params( - uw_output_dir=os.path.expanduser("~/+Simulations/thin_sheet_faults/figures"), - uw_dt=0.0125, - uw_cell_size=0.1, - uw_length=4.0, - uw_times="0.1,0.25,0.5,1.0", -) - -mesh = uw.meshing.UnstructuredSimplexBox( - minCoords=(0.0, 0.0), maxCoords=(params.uw_length, 1.0), - cellSize=params.uw_cell_size, qdegree=3) -sheet = uw.systems.ThinSheet(mesh) -sheet.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( - sheet.Unknowns, order=1, integrator="etd") -sheet.constitutive_model.Parameters.shear_viscosity_0 = 1.0e6 # t_r = 1e6 >> t -sheet.constitutive_model.Parameters.shear_modulus = 1.0 -sheet.basal_drag = 1 # D = mu L / c = 1 -sheet.tolerance = 1.0e-10 -sheet.add_essential_bc([0, 1.0], "Left") -sheet.add_essential_bc([0, 0], "Right") -sheet.add_essential_bc([0, None], "Top") -sheet.add_essential_bc([0, None], "Bottom") - -xs = np.linspace(0.0, 3.0, 121) -pts = np.column_stack([xs, np.full_like(xs, 0.5)]) -targets = [float(t) for t in params.uw_times.split(",")] -profiles = {} -t, step = 0.0, 0 -while targets: - sheet.solve(timestep=params.uw_dt, zero_init_guess=False) - t += params.uw_dt - step += 1 - if abs(t - targets[0]) < 0.5 * params.uw_dt: - v = uw.function.evaluate(sheet.u.sym, pts).reshape(-1, 2)[:, 1] - profiles[targets.pop(0)] = v - uw.pprint(f"t={t:.4f} step {step}: max |v - erfc| = " - f"{np.abs(v - erfc(xs / np.sqrt(4.0 * t))).max():.3e}", flush=True) - -if uw.mpi.rank == 0: - import matplotlib - matplotlib.use("Agg") - import matplotlib.pyplot as plt - - os.makedirs(params.uw_output_dir, exist_ok=True) - fig, ax = plt.subplots(figsize=(7.0, 4.2)) - blues = plt.get_cmap("Blues") - for k, (tt, v) in enumerate(sorted(profiles.items())): - colour = blues(0.4 + 0.6 * k / max(len(profiles) - 1, 1)) - ax.plot(xs, erfc(xs / np.sqrt(4.0 * tt)), color="0.25", lw=1.0, ls="--", - label="erfc solution" if k == 0 else None) - ax.plot(xs, v, color=colour, lw=2.0, label=f"t = {tt:g}") - ax.annotate(f"t = {tt:g}", (np.sqrt(4.0 * tt) * 0.9, erfc(0.9) + 0.02), - color=colour, fontsize=9) - ax.set_xlabel("distance from the driven edge x / sqrt(D)") - ax.set_ylabel("sheet velocity v / V0") - ax.set_xlim(0, 3.0); ax.set_ylim(0, 1.02) - ax.spines[["top", "right"]].set_visible(False) - ax.grid(color="0.9", lw=0.6) - ax.legend(frameon=False, fontsize=9) - ax.set_title("Elsasser stress diffusion: viscoelastic thin sheet over a viscous substrate", - fontsize=10, loc="left") - out = os.path.join(params.uw_output_dir, "elsasser-thin-sheet.png") - fig.savefig(out, dpi=160, bbox_inches="tight") - uw.pprint(f"wrote {out}", flush=True) diff --git a/scripts/sessions/thin_sheet_fault_cycle.py b/scripts/sessions/thin_sheet_fault_cycle.py deleted file mode 100644 index eda30d892..000000000 --- a/scripts/sessions/thin_sheet_fault_cycle.py +++ /dev/null @@ -1,265 +0,0 @@ -"""A Coulomb fault in a Maxwell thin sheet dragged by a shearing substrate — -on a flat sheet or on the surface of a sphere. - -The sheet (shear modulus mu, Maxwell time eta/mu) rests on a viscous channel -of drag coefficient c whose far side moves at a prescribed substrate velocity -u_s: the force per unit area on the sheet is c (u_s - u), so ``basal_drag = c`` -and ``bodyforce = c u_s``. On the flat sheet u_s is the simple shear (y, 0); -on the sphere it is the toroidal flow rhat x grad(z^2), whose shear is largest -on the equator. The sheet is dragged towards the substrate motion and its -stress rises as eta gamma_dot (1 - exp(-t mu/eta)) towards the viscous value; -an interior fault with regularised Coulomb friction (strength mu_f sigma_n) -holds until the shear traction on it reaches strength, then slides, and the -stress drop it imposes diffuses into the sheet with D = mu L / c. - -Snapshots show the Coulomb failure change on receiver faults parallel to the -fault, Delta CFF = Delta tau + mu_f Delta sigma_n (shear signed in the loading -sense, tension positive), against a REFERENCE STATE: the same sheet with the fault -welded, on the same mesh and at the same time, solved alongside it. Red is moved towards failure, blue -is the stress shadow. The second panel is the implied surface uplift rate. The -fault traction and slip rate are recorded against time as the run goes -(history.npz is rewritten at every step, so a run that stops early still has its -record); checkpoints are written beside the snapshots. -""" -import os - -import numpy as np -import sympy - -import underworld3 as uw -from underworld3.utilities import fault_contact -from underworld3.utilities.fault_contact import add_coulomb_fault_bc, fault_pair_jumps - -params = uw.Params( - uw_geometry="flat", # flat | sphere - uw_output_dir=os.path.expanduser("~/+Simulations/thin_sheet_faults/cycle_v2"), - uw_cell_size=0.05, - uw_dt=0.02, - uw_t_end=3.0, - uw_mu_f=0.6, # friction coefficient - uw_sigma_n=1.0, # prescribed effective normal stress - uw_V0=1.0e-4, # regularisation velocity of the Coulomb law - uw_eta=1.0, # Maxwell viscosity: t_r = eta / mu = 1 - uw_shear_modulus=1.0, - uw_basal_drag=1.0, # D = mu L / c = 1 - uw_half_length=0.5, # flat: half-length; sphere: half-angle of the equatorial arc - uw_snapshots="0.5,1.0,1.5,3.0", - uw_isostatic_factor=0.15, # 1 - rho_c / rho_m: surface uplift per unit thickening (Airy) - uw_weld_viscosity=1.0e6, # interface viscosity that welds the reference sheet's fault - uw_tolerance=1.0e-8, # sheet solver tolerance - uw_eta_v=0.0, # viscous term eta_v V added to the Coulomb law (0 = pure Coulomb) - uw_transport="", # stress-history transport; "" = the solver's default - uw_order=1, # Maxwell time-integration order - uw_integrator="etd", # etd (exponential) | bdf -) -out = os.path.join(params.uw_output_dir, params.uw_geometry) -os.makedirs(out, exist_ok=True) -a = params.uw_half_length - -if params.uw_geometry == "flat": - mesh = uw.meshing.UnstructuredSimplexBox( - minCoords=(-2.0, -1.0), maxCoords=(2.0, 1.0), cellSize=params.uw_cell_size, qdegree=3) - fault_pts = np.array([[-a, 0.0], [a, 0.0]]) -else: - mesh = uw.meshing.SphericalManifold(radius=1.0, cellSize=params.uw_cell_size, degree=1, qdegree=5) - phi = np.linspace(-a, a, 9) - fault_pts = np.column_stack([np.cos(phi), np.sin(phi), np.zeros_like(phi)]) -split = mesh.add_fault(("Flt", fault_pts)) -c = sympy.nsimplify(params.uw_basal_drag, rational=True) - - -def substrate_velocity(m): - """The substrate motion in mesh ``m``'s coordinates: simple shear (y, 0) on - the flat sheet, the toroidal flow rhat x grad(z^2) on the sphere (its shear - peaks on the equator).""" - if params.uw_geometry == "flat": - x, y = m.X - return sympy.Matrix([[y, 0]]) - x, y, z = m.X[0], m.X[1], m.X[2] - return sympy.Matrix([[2 * y * z, -2 * x * z, 0]]) - - -def dragged_sheet(m): - """A Maxwell sheet on mesh ``m`` dragged by the substrate.""" - s = uw.systems.ThinSheet(m) - if params.uw_transport: - s.stress_transport = params.uw_transport # before the model creates the history - s.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel( - s.Unknowns, order=params.uw_order, integrator=params.uw_integrator) - s.constitutive_model.Parameters.shear_viscosity_0 = params.uw_eta - s.constitutive_model.Parameters.shear_modulus = params.uw_shear_modulus - s.basal_drag = c - s.bodyforce = list(c * substrate_velocity(m)) - s.tolerance = params.uw_tolerance - return s - - -sheet = dragged_sheet(split) -if params.uw_eta_v > 0.0: - # Saturated regularised Coulomb is nearly flat in V (dtau/dV ~ mu sigma_n - # V0 / V^2), which leaves the slip rate of a sliding fault barely determined - # by the per-step problem; a viscous term eta_v V gives it a slope. - # TODO(FEATURE): no public way to register a composite SymbolicFaultLaw; - # this uses fault_contact's private _register_law. - fault_contact.add_frictionless_fault_bc(sheet, "Flt") - coulomb = fault_contact.CoulombFaultLaw(params.uw_mu_f, params.uw_sigma_n, params.uw_V0) - fault_contact._register_law(sheet, "Flt", fault_contact.SymbolicFaultLaw( - coulomb.expr + params.uw_eta_v * fault_contact.slip_rate)) -else: - add_coulomb_fault_bc(sheet, params.uw_mu_f, "Flt", sigma_n=params.uw_sigma_n, V0=params.uw_V0) -# The reference state for Delta CFF: the same sheet on the SAME split mesh with -# the fault welded (a stiff viscous interface, slip ~ tau / eta_f). Same nodes, -# same discretisation, so Delta CFF is the fault's effect alone — a reference on -# the unsplit mesh differed by interpolation at the cut's moved vertices (1e-4 -# speckle before any slip, worse at the free edges). -reference = dragged_sheet(split) -reference.add_fault_bc(params.uw_weld_viscosity, "Flt") -strength = params.uw_mu_f * params.uw_sigma_n - -# The sheet thins at -L div_s u; under Airy isostasy the surface rises at -# (1 - rho_c / rho_m) times the thickening rate. The divergence of the P2 -# velocity is cell-discontinuous, so it is projected onto a continuous P1 field -# for the picture (the slit's coincident nodes keep their own values). -thinning = uw.discretisation.MeshVariable("thinning_rate", split, 1, degree=1) -thinning_projection = uw.systems.Projection(split, thinning) -thinning_projection.uw_function = -sheet.thickness.sym * split.vector.divergence(sheet.u.sym) -thinning_projection.smoothing = 0.0 - - -def receiver_frame(coords): - """Unit fault-parallel direction t and in-surface fault normal n at each - node, for receivers parallel to the fault: the strike (x) on the flat sheet; - small circles parallel to the equatorial fault on the sphere (t east, - n north).""" - if coords.shape[1] == 2: - t = np.tile([1.0, 0.0], (len(coords), 1)) - n = np.tile([0.0, 1.0], (len(coords), 1)) - return t, n - rho = np.maximum(np.hypot(coords[:, 0], coords[:, 1]), 1e-12) - t = np.column_stack([-coords[:, 1] / rho, coords[:, 0] / rho, np.zeros(len(coords))]) - rhat = coords / np.linalg.norm(coords, axis=1)[:, None] - n = np.cross(rhat, t) - return t, n - - -def sheet_stress(coords, tau): - """sigma = tau + tr(tau) P at each node (plane-stress closure).""" - if coords.shape[1] == 2: - P = np.broadcast_to(np.eye(2), tau.shape) - else: - rhat = coords / np.linalg.norm(coords, axis=1)[:, None] - P = np.eye(3)[None] - rhat[:, :, None] * rhat[:, None, :] - return tau + np.trace(tau, axis1=1, axis2=2)[:, None, None] * P - - -def resolved(coords, tau): - """(shear t.sigma.n, normal n.sigma.n) on the receiver planes, tension positive.""" - t, n = receiver_frame(coords) - sigma = sheet_stress(coords, tau) - return np.einsum("ni,nij,nj->n", t, sigma, n), np.einsum("ni,nij,nj->n", n, sigma, n) - - -def render(k, t, tau_field, uplift_field, slip_rate_max): - """Two panels: Delta CFF on fault-parallel receivers against the unfaulted - reference (``tau_field``), and the implied surface uplift rate (Airy - isostasy on the thinning rate).""" - import pyvista as pv - import underworld3.visualisation as vis - pv.OFF_SCREEN = True - trace = pv.lines_from_points(np.column_stack([fault_pts, np.zeros(len(fault_pts))]) - if fault_pts.shape[1] == 2 else fault_pts * 1.001) - height = 900 if params.uw_geometry == "sphere" else 720 - pl = pv.Plotter(off_screen=True, shape=(1, 2), window_size=(2400, height), border=False) - for col, (name, field, title) in enumerate((("dcff", tau_field, "Delta CFF"), - ("uplift", uplift_field, "uplift rate"))): - pl.subplot(0, col) - pvm = vis.mesh_to_pv_mesh(split) - pvm.point_data[name] = field - pl.set_background("white") - vmax = max(float(np.percentile(np.abs(field), 99)), 1e-9) - pl.add_mesh(pvm, scalars=name, cmap="RdBu_r", clim=(-vmax, vmax), show_edges=False, - lighting=False, scalar_bar_args={"title": title, "color": "black"}) - pl.add_mesh(trace, color="black", line_width=4, lighting=False) - pl.add_text(f"t = {t:.2f} Delta CFF (mu = {params.uw_mu_f}) vs welded " - f"max slip rate {slip_rate_max:.3f}" - if col == 0 else "uplift rate = (1 - rho_c/rho_m) (-L div u)", - position="upper_left", font_size=11, color="black") - if params.uw_geometry == "flat": - pl.view_xy() - pl.camera.parallel_projection = True - pl.camera.parallel_scale = 1.05 - else: - pl.camera_position = [(3.2, -1.2, 1.4), (0.0, 0.0, 0.0), (0.0, 0.0, 1.0)] - pl.screenshot(os.path.join(out, f"tau_{k:02d}.png")) - pl.close() - - -snapshots = [float(s) for s in params.uw_snapshots.split(",")] -history, t, step, k = [], 0.0, 0, 0 -nodes_on_fault = None -while t < params.uw_t_end - 0.5 * params.uw_dt: - sheet.solve(timestep=params.uw_dt, zero_init_guess=False) - reference.solve(timestep=params.uw_dt, zero_init_guess=False) - t += params.uw_dt - step += 1 - info = sheet._rotated_freeslip_info - coords, jumps, normals = fault_pair_jumps(sheet, "Flt", info) - slip = np.linalg.norm(jumps - normals * np.einsum("ij,ij->i", jumps, normals)[:, None], axis=1) - leak = np.abs(np.einsum("ij,ij->i", jumps, normals)).max() if len(jumps) else 0.0 - tau_coords = sheet.tau.coords - tau = np.asarray(sheet.tau.array) - if nodes_on_fault is None: - if params.uw_geometry == "flat": - nodes_on_fault = (np.abs(tau_coords[:, 1]) < 1e-9) & (np.abs(tau_coords[:, 0]) < 0.5 * a) - # on strike well beyond the tip and away from the traction-free - # edges (the stress falls over the drag length sqrt(eta / c) there) - far = np.argmin(np.hypot(tau_coords[:, 0] - 1.6, tau_coords[:, 1])) - else: - phi_n = np.arctan2(tau_coords[:, 1], tau_coords[:, 0]) - nodes_on_fault = (np.abs(tau_coords[:, 2]) < 1e-9) & (np.abs(phi_n) < 0.5 * a) - far = np.argmin(np.linalg.norm(tau_coords - np.array([np.cos(np.pi), np.sin(np.pi), 0.0]), axis=1)) - shear, normal = resolved(tau_coords, tau) - history.append((t, shear[nodes_on_fault].mean(), shear[far], slip.max() if len(slip) else 0.0, - slip.mean() if len(slip) else 0.0, info["nonlinear_iterations"])) - uw.pprint(f"t={t:.3f} tau_fault={history[-1][1]:+.4f} tau_far={history[-1][2]:+.4f} " - f"slip_max={history[-1][3]:.3e} leak={leak:.1e} newton={info['nonlinear_iterations']}", flush=True) - if uw.mpi.rank == 0: - np.savez(os.path.join(out, "history.npz"), history=np.array(history), strength=strength) - if snapshots and abs(t - snapshots[0]) < 0.5 * params.uw_dt: - snapshots.pop(0) - # petsc_reload: a split mesh duplicates the fault nodes, and only the - # PETSc-native payload lets a later read tell the two sides apart (#640) - thinning_projection.solve() - split.write_timestep("cycle", k, outputPath=out, meshVars=[sheet.u, sheet.tau, thinning], - petsc_reload=True) - shear_ref, normal_ref = resolved(tau_coords, np.asarray(reference.tau.array)) - loading = np.sign(np.mean(shear_ref[nodes_on_fault])) or 1.0 - dcff = loading * (shear - shear_ref) + params.uw_mu_f * (normal - normal_ref) - if uw.mpi.rank == 0: - uplift = params.uw_isostatic_factor * np.asarray(thinning.array[:, 0, 0]) - render(k, t, dcff, uplift, history[-1][3]) - k += 1 - -if uw.mpi.rank == 0: - import matplotlib - matplotlib.use("Agg") - import matplotlib.pyplot as plt - h = np.array(history) - fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(7.0, 5.6), sharex=True) - ax1.plot(h[:, 0], h[:, 1], color="#1f5fa8", lw=2.0, label="shear traction on the fault") - ax1.plot(h[:, 0], h[:, 2], color="#1f5fa8", lw=1.2, ls=":", label="shear stress far from the fault") - ax1.axhline(strength, color="0.25", lw=1.0, ls="--", label="Coulomb strength") - ax1.set_ylabel("shear stress") - ax1.legend(frameon=False, fontsize=9) - ax2.plot(h[:, 0], h[:, 3], color="#c0392b", lw=2.0, label="max slip rate") - ax2.plot(h[:, 0], h[:, 4], color="#c0392b", lw=1.2, ls=":", label="mean slip rate") - ax2.set_xlabel("time t / t_r") - ax2.set_ylabel("slip rate") - ax2.legend(frameon=False, fontsize=9) - for ax in (ax1, ax2): - ax.spines[["top", "right"]].set_visible(False) - ax.grid(color="0.9", lw=0.6) - fig.suptitle(f"Coulomb fault in a Maxwell sheet dragged by a shearing substrate ({params.uw_geometry})", - fontsize=10, x=0.12, ha="left") - fig.savefig(os.path.join(out, "fault-cycle-history.png"), dpi=160, bbox_inches="tight") - uw.pprint(f"wrote figures and checkpoints to {out}", flush=True) diff --git a/scripts/sessions/thin_sheet_quadratic_closed_form.py b/scripts/sessions/thin_sheet_quadratic_closed_form.py deleted file mode 100644 index 8cb998c48..000000000 --- a/scripts/sessions/thin_sheet_quadratic_closed_form.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Second-order perturbation of the steady corotational Maxwell sheet on the unit -sphere driven by f = 4A rhat x grad_s(z^2) — the closed form behind -tests/test_1084_quadratic_stress_sphere.py. - -eta = mu = L = 1, c = 4 (lambda = eta / mu = 1). Everything in embedding -(Cartesian) components, fields extended as polynomials, derivatives projected; the -problem is axisymmetric, so it is reduced on the meridian y = 0. - -Steady first order: tau_1 = 2 eta E(u_1), u_1 = a rhat x grad_s(z^2), a = 1/2. -Second order: tau_2 = 2 eta E(u_2) - lambda P[(u_1 . grad) tau_1 - W_1 tau_1 -+ tau_1 W_1] P, the plane-stress closure and the membrane balance solved for -u_2 = grad_s Phi + rhat x grad_s Psi with Phi, Psi polynomials in z. - -Prints (2026-10-12): a = 1/2; -div_s u_2 = -3 (65 z^4 - 126 z^2 + 29) / 533, -i.e. P2 = 24/91, P4 = -24/287, all other degrees zero. Runs in a few minutes: - - pixi run -e dev python scripts/sessions/thin_sheet_quadratic_closed_form.py -""" -import sympy as sp -x, y, z = sp.symbols("x y z", real=True) -X = sp.Matrix([x, y, z]) -eta, mu, c, L = 1, 1, 4, 1 -lam = sp.Rational(eta, mu) -I3 = sp.eye(3) -P = I3 - X * X.T # on r = 1 -S = sp.Symbol("S", positive=True) # sin(theta); the problem is axisymmetric, so the -def onS(e): # meridian y = 0, x = S = sqrt(1 - z^2) is enough - e = sp.expand(sp.sympify(e).subs({y: 0, x: S})) - p = sp.Poly(e, S) - out = 0 - for (k,), cf in p.terms(): - out += cf * (1 - z**2)**(k // 2) * S**(k % 2) - return sp.expand(out) -def grad_s_vec(u): # (grad_s u)_ij = d_k u_i P_kj - J = u.jacobian(X) - return J * P -def grad_s_scalar(f): - return P * sp.Matrix([sp.diff(f, v) for v in X]) -def div_s_tensor(S): # (div_s S)_i = d_k S_ij P_jk - out = sp.zeros(3, 1) - for i in range(3): - out[i] = sum(sp.diff(S[i, j], X[k]) * P[j, k] for j in range(3) for k in range(3)) - return out -def rcross(v): - return X.cross(v) -def E_of(u): - G = grad_s_vec(u) - return P * (G + G.T) / 2 * P -def W_of(u): - G = grad_s_vec(u) - return P * (G - G.T) / 2 * P -def membrane(u, Q=None): - """-P div_s(L sigma) + c u with sigma = tau + tr(tau) P, tau = 2 eta E(u) - Q.""" - tau = 2 * eta * E_of(u) - if Q is not None: - tau = tau - Q - sig = tau + tau.trace() * P - return -P * div_s_tensor(L * sig) + c * u - -# first order (per unit A) -a = sp.Symbol("a") -f1 = 4 * rcross(grad_s_scalar(z**2)) -u1 = a * rcross(grad_s_scalar(z**2)) -res1 = (membrane(u1) - f1).applyfunc(onS); print("first order built", flush=True) -asol = sp.solve([e for e in res1 if e != 0][0], a)[0] -u1 = u1.subs(a, asol) -print("a =", asol, " residual:", (membrane(u1) - f1).applyfunc(onS).T) -tau1 = 2 * eta * E_of(u1) -W1 = W_of(u1) -adv = sp.zeros(3, 3) -for i in range(3): - for j in range(3): - adv[i, j] = sum(u1[k] * sp.diff(tau1[i, j], X[k]) for k in range(3)) -Q = lam * P * (adv - W1 * tau1 + tau1 * W1) * P -# second order: u2 = grad_s Phi + rhat x grad_s Psi, Phi,Psi polynomials in z -cp = sp.symbols("p0:7"); cq = sp.symbols("q0:7") -Phi = sum(cp[k] * z**k for k in range(7)); Psi = sum(cq[k] * z**k for k in range(7)) -u2 = grad_s_scalar(Phi) + rcross(grad_s_scalar(Psi)) -res2 = (membrane(u2, Q)).applyfunc(onS) -eqs = [] -for e in res2: - eqs += sp.Poly(e, S, z).coeffs() -sol = sp.solve(eqs, list(cp[1:]) + list(cq[1:]), dict=True) -print("solutions:", sol) -s = sol[0] -Phi2 = Phi.subs(s).subs({k: 0 for k in cp + cq}) -u2s = u2.subs(s).subs({k: 0 for k in cp + cq}) -thin = onS(-sum(sp.diff(u2s[i], X[k]) * P[i, k] for i in range(3) for k in range(3))) -assert thin.free_symbols <= {z} -print("thinning rate / A^2 =", sp.factor(thin)) -Pl = [sp.legendre(l, z) for l in range(6)] -for l in range(6): - cl = sp.integrate(thin * Pl[l], (z, -1, 1)) / sp.integrate(Pl[l]**2, (z, -1, 1)) - print(f"P{l}: {sp.nsimplify(cl)} = {float(cl):.6f}") diff --git a/tests/test_1084_quadratic_stress_sphere.py b/tests/test_1084_quadratic_stress_sphere.py index 6ac236e55..852fc09e9 100644 --- a/tests/test_1084_quadratic_stress_sphere.py +++ b/tests/test_1084_quadratic_stress_sphere.py @@ -8,7 +8,7 @@ rate turns the shear stress into normal stress differences — the Weissenberg effect of a Maxwell fluid — whose divergence drives a poloidal flow, and the sheet thins. The steady second-order perturbation (sympy, derived 2026-10-12; -scripts/sessions/thin_sheet_quadratic_closed_form.py) gives +the derivation is kept with the campaign, outside this repository) gives -div_s u / A^2 = -3 (65 z^4 - 126 z^2 + 29) / 533 = (24/91) P_2(z) - (24/287) P_4(z),