From 653f0bddecbf73903e7d20b55e482d8308b57ea6 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sun, 2 Aug 2026 15:36:39 +1000 Subject: [PATCH 01/28] Add uw.analytic: one namespace and one contract for exact solutions Underworld2 shipped twelve exact ("Velic") Stokes solutions as the code's source of truth for benchmarking. Two reached UW3, and the module they landed in cannot be extended: underworld3.function.analytic is a compiled Cython extension, so the name is owned by a .so and nothing else can live under it. Exact solutions have leaked elsewhere as a result -- Gardner in utilities/retention_curves.py, erfc diffusion and Ogata-Banks inline in tests, and an undeclared 'assess' dependency for the Kramer benchmarks. This is the interface commit: a new underworld3.analytic package holding the contract every solution satisfies, so later work adds solutions rather than restructuring around them. Nothing moves yet -- SolCx is re-exported from its current home (the same class object, not a copy), so both import paths stay valid. AnalyticSolution gives each solution the exact fields (fn_velocity, fn_pressure, fn_stress, fn_strainrate, fn_viscosity, fn_bodyforce), the LaTeX that documents the problem it poses, evaluate() at arbitrary points, and error() against a computed field. The nodal error norm is the global MPI reduction from velocity_error generalised over field name, and it now has the test that pins it: a perturbation confined to x < 0.5, which a rank-local norm would report differently on every rank (the #370 failure). A uniform perturbation cannot detect that. The two boundary-condition mixins configure the solver in place rather than returning something the caller applies. FreeSlipWalls uses the strong rotated constraint, not component masking -- the two agree on an axis-aligned box, but only the rotated form still holds when a solution is used to validate a curved or adapted mesh. docs/developer/subsystems/analytic-solutions.md records the decisions this suite is built on: solutions are pure SymPy (so they JIT, carry a Jacobian, and work as Dirichlet values), reference C kernels are kept as independent oracles rather than deleted, and every transcription must clear six gates before it lands -- including a negative control, because a gate that passes a deliberately broken input is measuring nothing. Verified: 17 contract tests pass at np=1, 2 and 3; test_1015_analytic_solcx and test_1062_constrained_solcx pass unchanged; the style gate is clean. Underworld development team with AI support from Claude Code --- docs/api/analytic.md | 25 ++ docs/api/index.md | 4 + .../subsystems/analytic-solutions.md | 167 +++++++++++ src/underworld3/__init__.py | 3 + src/underworld3/analytic/__init__.py | 89 ++++++ src/underworld3/analytic/_base.py | 274 ++++++++++++++++++ tests/test_1016_analytic_contract.py | 264 +++++++++++++++++ 7 files changed, 826 insertions(+) create mode 100644 docs/api/analytic.md create mode 100644 docs/developer/subsystems/analytic-solutions.md create mode 100644 src/underworld3/analytic/__init__.py create mode 100644 src/underworld3/analytic/_base.py create mode 100644 tests/test_1016_analytic_contract.py diff --git a/docs/api/analytic.md b/docs/api/analytic.md new file mode 100644 index 000000000..77698aadd --- /dev/null +++ b/docs/api/analytic.md @@ -0,0 +1,25 @@ +# Analytic Solutions + +```{eval-rst} +.. automodule:: underworld3.analytic + :members: + :show-inheritance: +``` + +## The contract + +Every solution satisfies the same contract, so a validation run reads the same way +whichever one you use. + +```{eval-rst} +.. automodule:: underworld3.analytic._base + :members: + :show-inheritance: +``` + +## See also + +- {doc}`solvers` — the solvers these solutions validate. +- `docs/developer/subsystems/analytic-solutions.md` — the implementation form, + the validation protocol every transcription must pass, and the provenance of + each vendored reference kernel. diff --git a/docs/api/index.md b/docs/api/index.md index ab7ac16fb..a0d9602bf 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -29,6 +29,7 @@ model utilities visualisation adaptivity +analytic ``` ## Quick Links @@ -50,6 +51,9 @@ adaptivity - **{doc}`scaling`** - Units, quantities, and non-dimensionalisation - **{doc}`maths`** - Mathematical operations and integrals +### Validation +- **{doc}`analytic`** - Exact solutions for benchmarking and convergence testing + ### Infrastructure - **{doc}`model`** - Model management and configuration - **{doc}`utilities`** - I/O, mesh import, and helper functions diff --git a/docs/developer/subsystems/analytic-solutions.md b/docs/developer/subsystems/analytic-solutions.md new file mode 100644 index 000000000..9540c6fba --- /dev/null +++ b/docs/developer/subsystems/analytic-solutions.md @@ -0,0 +1,167 @@ +# Analytic solutions + +Exact solutions are the code's source of truth. They are the only diagnostic that +can tell you a solver returned a *wrong* answer rather than an unconverged one — +the SolCx port caught a direct `ksponly + lu` solve silently mangling the singular +saddle on a free-slip problem, which no residual norm reported. + +This document governs how they are implemented, how a new one earns trust, and +where each vendored reference kernel came from. + +## Where they live + +`underworld3.analytic` — one namespace, reached as `uw.analytic.(mesh, ...)`. + +```{note} +`uw.function.analytic` is the historic location. It is a *compiled extension +module*, which is why the suite could not grow there: the name is owned by a +`.so`, so no submodules or pure-Python solutions can live under it. +``` + +## One implementation form: SymPy + +**Every analytic solution is a pure SymPy expression on `mesh.X`.** No exceptions, +no tiers. A solution written this way: + +- compiles through the normal JIT path when handed to a solver, so it is C where + speed matters; +- carries its own analytic Jacobian, which a hand-written C kernel cannot supply; +- can be used as a Dirichlet boundary value, which a kernel-backed function cannot; +- evaluates through `uw.function.evaluate` like any other field, vectorised. + +Point evaluation is not in a hot loop — it is tests and error norms — so the SymPy +form costs nothing where it is used, and wins where it matters. + +## Transcribing a reference kernel + +Most of the classical solutions (Velic's, and PETSc's copies of them) exist as +machine-generated C: straight-line single-assignment code, `t125 = 0.4e1*t81*t83 + +...`, several hundred lines per branch. `scripts/maple_c_to_sympy.py` converts +these mechanically. + +**Preserve the grouping term for term. Never `simplify()`.** The Maple grouping is +what keeps `sinh(k)*exp(-k)`-style products numerically stable at large wavenumber +and large viscosity contrast. A re-derivation is a *different* grouping and can +lose eight digits in exactly the regime the benchmark exists to probe. Do not +re-derive what you cannot revalidate. + +The reference kernel is **kept**, not deleted, and stays reachable: + +```python +sol = uw.analytic.SolCx(mesh, ...) # SymPy (default) +ref = uw.analytic.SolCx(mesh, ..., reference=True) # the Velic C, verbatim +``` + +so "is this the transcription or the model?" stays a one-line question. + +## The validation protocol + +Velic's kernels are obsessively careful. The risk sits entirely on our side of the +conversion, and pointwise agreement on a sample is a weak test — it can pass while +the transcription is wrong off-sample, or wrong in its derivatives, which is what +the solver actually consumes. **No transcription lands until all six gates pass.** + +### Gate 1 — two independent oracles, not one + +PETSc maintains its own copy of several Velic solutions, with different call +signatures from Underworld2's: `SolCxSolution` and `SolKxSolution` in +`src/snes/tutorials/ex69.c`. Agreement of UW2-C ↔ PETSc-C ↔ our SymPy is a far +stronger statement than agreement with either alone, and both trees are already +available. For SolKx there is a third: PETSc's `ex75.h` is a 41×41 table of +tabulated reference values at `B=100, kn=km=100π` — an independent fixture in the +hardest wavenumber regime. + +### Gate 2 — adversarial sampling, max error not mean + +Stratified so no region goes unsampled, and deliberately loaded with the hard +places: on and either side of a viscosity interface, on the boundaries, at +corners, and across parameter extremes (viscosity ratios spanning `1e-6` to `1e8`, +wavenumbers 1–8). Report the **maximum** relative error. Threshold `1e-10`. A +failing point gets investigated, not sampled around. + +### Gate 3 — derivatives, against independently derived output + +The solver consumes derivatives of these fields, and a transcription can be +pointwise right yet derivative-wrong if a dropped term happens to vanish on the +sample set. + +The reference kernels return `vel`, `pressure`, `total_stress` and `strain_rate` +as *separately derived* quantities — they do not differentiate the velocity to get +them. So compare SymPy's **symbolic** derivative of the velocity against the +kernel's own strain rate, and the symbolic constitutive stress against its total +stress. That is a genuine independent check rather than a tautology. + +### Gate 4 — the physics residual, which needs no oracle + +Substitute the transcribed fields back into the equations they claim to solve and +confirm the residual vanishes: + +$$\nabla\cdot\mathbf u = 0, \qquad + \nabla\cdot\left(2\eta\,\varepsilon(\mathbf u)\right) - \nabla p + \mathbf f = 0$$ + +using the solution's *own* `fn_viscosity` and `fn_bodyforce`; plus traction +continuity across any interface, and the boundary conditions the solution claims. + +This tests the transcription **as a solution** rather than as a table of numbers, +and it catches what a convergence test structurally cannot. If the transcription +and the solver share the same mistaken convention — a sign, a factor of two — the +solve converges beautifully to the wrong answer. That is not hypothetical: the +original SolCx port hit exactly this, with Underworld2's documentation quoting +$-\cos(\pi x)\sin(n\pi z)$ where UW3's momentum convention needs $+\cos$. Gate 4 +is the guard, because it never consults the solver. + +### Gate 5 — negative control + +Flip the sign of a single coefficient in the transcription and confirm that gates +1–4 all **fail**. A gate that passes a deliberately broken input is measuring +nothing. Do this once per solution, in the test suite, with the perturbation +applied programmatically. + +### Gate 6 — separate transcription error from conditioning + +If SymPy and C disagree near threshold, we need to know whether that is our +transcription or the C's own double-precision cancellation. Evaluate the SymPy +form under `mpmath` at 50 digits and compare against both. This makes "the Maple +grouping is numerically stable" a measured claim rather than an assumption — and +if the high-precision form agrees with the kernel's intent while the +double-precision form does not, that is a concrete instruction to preserve +grouping harder. + +### After the gates: pin it + +Freeze a table of validated values as a test fixture — the pattern PETSc itself +uses with `ex75.h` — so later refactors cannot drift silently. Record the measured +maximum error, the sampling design, and which oracles were used in the solution's +docstring. Not just "validated". + +## Provenance + +Each vendored reference kernel keeps its original copyright header. + +| Source | Licence | Compatible with UW3 (LGPL-3-or-later) | +|---|---|---| +| Underworld2 `Velic_sol*` kernels | LGPL-3 | Yes — same licence | +| PETSc `ex69.c`, `ex13.c`, `ex24.c`, `ex45.c` | BSD-2-Clause | Yes — permissive; retain the notice | +| Schmid & Podladchikov MATLAB (`dwschmid/muskhelishvili`) | BSD-3-Clause | Yes — permissive; retain the notice | +| `assess` (Kramer et al. 2021) | External, optional dependency | Not vendored; wrapped lazily | + +## Optional dependencies + +A solution requiring a package Underworld3 does not depend on is wrapped lazily, +in the style SciPy uses for its optional backends: the import happens at +construction, and its absence raises with an install message rather than breaking +`import underworld3`. `uw.analytic.available()` lists such solutions and marks +them unavailable rather than omitting them. + +## Adding a new solution + +1. Subclass `AnalyticSolution` and one of the boundary-condition mixins + (`FreeSlipWalls`, `FixedWalls`). +2. Build the exact fields on `mesh.X` in `__init__`; set `dim`, `reference`, and + the `eqn_*` LaTeX strings that document the *problem*. +3. Export it from `underworld3/analytic/__init__.py` and register it in + `_SOLUTIONS` — a namespace entry and the registry entry land in the same PR. +4. If it was transcribed from a reference kernel, clear all six gates and pin the + validated values. +5. Add a convergence test: the error must decrease under refinement *and* clear an + absolute floor. diff --git a/src/underworld3/__init__.py b/src/underworld3/__init__.py index e1d989f66..818ff2339 100644 --- a/src/underworld3/__init__.py +++ b/src/underworld3/__init__.py @@ -208,6 +208,9 @@ def view(): import underworld3.materials import underworld3.checkpoint +# After underworld3.function: the analytic suite still sources SolCx from it. +import underworld3.analytic + from .model import ( Model, create_model, diff --git a/src/underworld3/analytic/__init__.py b/src/underworld3/analytic/__init__.py new file mode 100644 index 000000000..f04a85549 --- /dev/null +++ b/src/underworld3/analytic/__init__.py @@ -0,0 +1,89 @@ +r"""Exact solutions for validating Underworld3 solves. + +Every solution here is a closed form we can compare a numerical answer against — +the code's source of truth for benchmarking and convergence testing. They share +one contract (:class:`AnalyticSolution`), so a validation run reads the same way +whatever the solution:: + + sol = uw.analytic.SolCx(mesh, eta_A=1.0, eta_B=1.0e6) + + stokes.constitutive_model.Parameters.shear_viscosity_0 = sol.fn_viscosity + stokes.bodyforce = sol.fn_bodyforce + sol.apply_boundary_conditions(stokes) + + stokes.solve() + rel = sol.error("velocity", stokes.u) + +:func:`available` lists what is here; :func:`describe` summarises one solution +without constructing it. + +These are worth reaching for whenever a solver change needs evidence. The SolCx +port alone caught a direct ``ksponly + lu`` solve silently mangling the singular +saddle on a free-slip problem — a wrong answer that no residual norm reported. + +See Also +-------- +underworld3.analytic._base : the contract each solution satisfies. +""" + +from ._base import AnalyticSolution, FreeSlipWalls, FixedWalls + +# SolCx is still served by the original compiled module while the suite is built +# out. It reaches users through this namespace from the start so nothing has to +# move twice, and so `uw.analytic` is useful on the day it lands. +from underworld3.function.analytic import SolCx + +__all__ = [ + "AnalyticSolution", + "FreeSlipWalls", + "FixedWalls", + "SolCx", + "available", + "describe", +] + +# The solutions this namespace offers. Explicit rather than introspected, so the +# listing stays truthful while solutions are still being migrated onto the +# contract, and so a solution needing an optional dependency can be listed +# without being importable. +_SOLUTIONS = { + "SolCx": SolCx, +} + + +def available(): + """Names of the solutions in this namespace, in alphabetical order. + + Returns + ------- + list of str + Every name that can be constructed as ``uw.analytic.(mesh, ...)``. + """ + + return sorted(_SOLUTIONS) + + +def describe(name): + """One-line summary of a solution, without constructing it. + + Parameters + ---------- + name : str + A name from :func:`available`. + + Returns + ------- + str + The first line of the solution's docstring. + """ + + try: + solution = _SOLUTIONS[name] + except KeyError: + raise ValueError( + f"no analytic solution named {name!r}; available: " + f"{', '.join(available())}" + ) from None + + docstring = solution.__doc__ or "" + return docstring.strip().split("\n")[0] diff --git a/src/underworld3/analytic/_base.py b/src/underworld3/analytic/_base.py new file mode 100644 index 000000000..3c7fb3bae --- /dev/null +++ b/src/underworld3/analytic/_base.py @@ -0,0 +1,274 @@ +r"""The contract every Underworld3 analytic solution satisfies. + +An analytic solution is a closed-form answer to a problem Underworld3 can also +solve numerically. It supplies the coefficients of that problem (viscosity, body +force, source), the exact fields it produces (velocity, pressure, stress), and +the boundary conditions under which the two agree — so a validation run is three +lines rather than a bespoke script. + +See Also +-------- +underworld3.analytic : the solution registry and the available solutions. +""" + +import numpy as np +import sympy + +import underworld3 as uw +from underworld3.utilities._api_tools import uw_object + + +class AnalyticSolution(uw_object): + r"""Base class for closed-form solutions used to validate a numerical solve. + + A concrete solution builds its exact fields on ``mesh.X`` in ``__init__`` and + sets the class-level metadata below. Everything else — applying the boundary + conditions, evaluating a field at points, measuring the error of a computed + field — is inherited. + + The canonical validation pattern is:: + + sol = uw.analytic.SolCx(mesh, eta_A=1.0, eta_B=1.0e6) + + stokes.constitutive_model.Parameters.shear_viscosity_0 = sol.fn_viscosity + stokes.bodyforce = sol.fn_bodyforce + sol.apply_boundary_conditions(stokes) + + stokes.solve() + rel = sol.error("velocity", stokes.u) + + Attributes + ---------- + dim : int + Spatial dimension the solution is posed in (2 or 3). + nonlinear : bool + Whether the constitutive law depends on the solution itself. + reference : str + Citation for the solution, and the provenance of this implementation. + eqn_velocity, eqn_pressure, eqn_viscosity, eqn_bodyforce : str + LaTeX for the defining equations, shown by :meth:`view`. These document + the *problem*, not the answer: a reader should be able to tell from + ``eqn_viscosity`` and ``eqn_bodyforce`` alone what is being solved. + fn_velocity : sympy.Matrix + Exact velocity, shape ``(1, dim)``. + fn_pressure : sympy.Expr + Exact pressure. + fn_stress : sympy.Matrix + Exact total (Cauchy) stress, shape ``(dim, dim)``. + fn_strainrate : sympy.Matrix + Exact strain rate, shape ``(dim, dim)``. + fn_viscosity : sympy.Expr + The viscosity the solution is posed with — assign this to the solver. + fn_bodyforce : sympy.Matrix + The body force the solution is posed with, shape ``(1, dim)``. + + Notes + ----- + Solutions are pure SymPy expressions on ``mesh.X``. That is a deliberate + single form: it means they compile through the normal JIT path when handed to + a solver, carry their own analytic Jacobian, can be used as Dirichlet boundary + values, and evaluate through :func:`underworld3.function.evaluate` like any + other field. Solutions transcribed from a reference C kernel keep that kernel + in the tree as an independent oracle; see + ``docs/developer/subsystems/analytic-solutions.md`` for the validation + protocol every transcription must pass. + """ + + dim = None + nonlinear = False + reference = "" + + eqn_velocity = "" + eqn_pressure = "" + eqn_viscosity = "" + eqn_bodyforce = "" + + # Field name -> attribute holding the exact expression. A solution that adds a + # field (a temperature, a pressure head) extends this so error() and + # evaluate() reach it by name. + _fields = { + "velocity": "fn_velocity", + "pressure": "fn_pressure", + "stress": "fn_stress", + "strainrate": "fn_strainrate", + "viscosity": "fn_viscosity", + "bodyforce": "fn_bodyforce", + } + + def __init__(self, mesh): + super().__init__() + + if self.dim is not None and mesh.dim != self.dim: + raise ValueError( + f"{type(self).__name__} is a {self.dim}D solution; " + f"this mesh has dim={mesh.dim}." + ) + + self.mesh = mesh + + @property + def boundaries(self): + """The domain walls this solution is posed on. + + The box labels, since the classical solutions are all posed on the unit + box. A solution in another geometry overrides this with its own labels + (an annulus, for instance, has ``Upper`` and ``Lower``). + """ + + walls = ["Left", "Right", "Bottom", "Top"] + if self.mesh.dim == 3: + walls += ["Front", "Back"] + return walls + + def _exact(self, field): + """Resolve a field name — or pass an expression straight through.""" + + if not isinstance(field, str): + return field + + try: + return getattr(self, self._fields[field]) + except KeyError: + available = ", ".join(sorted(self._fields)) + raise ValueError( + f"{type(self).__name__} has no field {field!r}; " + f"available: {available}" + ) from None + + def evaluate(self, field, coords): + """Exact values of ``field`` at ``coords``. + + Parameters + ---------- + field : str or sympy expression + A name from the solution's field set (``"velocity"``, + ``"pressure"``, ...), or any SymPy expression in ``mesh.X``. + coords : numpy.ndarray + Evaluation points, shape ``(N, dim)``. + + Returns + ------- + numpy.ndarray + Shape ``(N, *field_shape)``. + """ + + return uw.function.evaluate(self._exact(field), np.asarray(coords)) + + def error(self, field, meshvar, norm="l2"): + r"""Relative error of a computed field against the exact solution. + + Parameters + ---------- + field : str or sympy expression + The exact field to compare against — see :meth:`evaluate`. + meshvar : MeshVariable + The computed field. + norm : {"l2", "integral"} + ``"l2"`` (default) is the discrete nodal relative :math:`L_2` norm + over the variable's own degrees of freedom. ``"integral"`` is the + continuous :math:`L_2` norm integrated over the mesh, which is the + right choice when comparing across different discretisations. + + Returns + ------- + float + The same value on every rank. + + Notes + ----- + Both norms are global. The nodal norm reduces the squared differences and + the squared exact values across ranks *before* dividing, so it does not + depend on the partition — an earlier rank-local version reported an error + 10–20x larger on whichever rank owned the hardest region, e.g. the SolCx + viscosity jump (issue #370). Degrees of freedom shared on a partition + boundary contribute once per owning rank, a small seam weighting that is + acceptable for a benchmark diagnostic. + """ + + exact = self._exact(field) + + if norm == "integral": + zero = ( + sympy.zeros(*exact.shape) + if isinstance(exact, sympy.Matrix) + else sympy.S.Zero + ) + magnitude = uw.maths.L2_norm(zero, exact, self.mesh) + return float(uw.maths.L2_norm(meshvar.sym, exact, self.mesh) / magnitude) + + if norm != "l2": + raise ValueError(f"norm must be 'l2' or 'integral'; got {norm!r}") + + computed = np.asarray(meshvar.array).reshape(len(meshvar.coords), -1) + exact_values = np.asarray(self.evaluate(exact, meshvar.coords)).reshape( + computed.shape + ) + + difference = computed - exact_values + error_squared = uw.mpi.comm.allreduce(float((difference**2).sum())) + exact_squared = uw.mpi.comm.allreduce(float((exact_values**2).sum())) + + return float(np.sqrt(error_squared / exact_squared)) + + def apply_boundary_conditions(self, solver): + """Impose the boundary conditions this solution is posed under.""" + + raise NotImplementedError( + f"{type(self).__name__} does not declare its boundary conditions. " + f"Mix in FreeSlipWalls or FixedWalls, or override this method." + ) + + def _object_viewer(self): + from IPython.display import Markdown, display + + display(Markdown(f"**{type(self).__name__}** — {self.dim}D")) + + if self.reference: + display(Markdown(f"*{self.reference}*")) + + for label, equation in ( + ("velocity", self.eqn_velocity), + ("pressure", self.eqn_pressure), + ("viscosity", self.eqn_viscosity), + ("body force", self.eqn_bodyforce), + ): + if equation: + display(Markdown(rf"{label}: $\displaystyle {equation}$")) + + +class FreeSlipWalls: + r"""Mixin: the solution is posed with free slip on every wall. + + Free slip is imposed as a strong *rotated* constraint + (:math:`\mathbf u\cdot\hat{\mathbf n}=0` to machine precision) rather than by + zeroing a velocity component. On an axis-aligned box the two agree; on a + curved, tilted or adapted boundary only the rotated form is correct, so it is + the one that still holds when a solution is used to validate an adapted mesh. + + The domain is enclosed, so the pressure carries a constant nullspace and the + solver is told to remove it. Leaving that out is the failure this whole suite + exists to catch: a direct solve on the singular saddle returns a quiet, wrong + answer that only an exact solution exposes. + """ + + def apply_boundary_conditions(self, solver): + for boundary in self.boundaries: + solver.add_rotated_freeslip_bc(0.0, boundary) + + solver.petsc_use_pressure_nullspace = True + + +class FixedWalls: + """Mixin: velocity is prescribed on every wall, from the exact solution. + + For solutions driven by their boundaries rather than by a body force — a + far-field shear, say — and for manufactured solutions whose exact velocity is + not tangential to the domain. The domain is again enclosed, so the pressure + nullspace is removed. + """ + + def apply_boundary_conditions(self, solver): + for boundary in self.boundaries: + solver.add_dirichlet_bc(self.fn_velocity, boundary) + + solver.petsc_use_pressure_nullspace = True diff --git a/tests/test_1016_analytic_contract.py b/tests/test_1016_analytic_contract.py new file mode 100644 index 000000000..c32fcff60 --- /dev/null +++ b/tests/test_1016_analytic_contract.py @@ -0,0 +1,264 @@ +"""The uw.analytic contract. + +`uw.analytic` is the namespace for exact solutions used to validate a solve. +These tests fix the contract itself — that the base class exposes the fields and +error norms every solution promises, that the boundary-condition mixins configure +a solver rather than returning something the caller has to apply, and that a +solution reached through the new namespace is the *same object* as the one +reached through the old one. + +That last check matters more than it looks: `uw.function.analytic` is a compiled +extension module, and a namespace that re-declared its classes rather than +re-exporting them would silently break `isinstance` for anyone holding an object +built through the other path. + +Run: pixi run python -m pytest tests/test_1016_analytic_contract.py -v +""" + +import pytest + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + +import numpy as np +import sympy +import underworld3 as uw + + +@pytest.fixture +def mesh(): + return uw.meshing.StructuredQuadBox( + elementRes=(8, 8), minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), qdegree=3 + ) + + +class Quadratic(uw.analytic.FixedWalls, uw.analytic.AnalyticSolution): + r"""A manufactured Stokes solution used to exercise the contract. + + Divergence-free by construction: :math:`\mathbf u = (y, -x)` is a rigid + rotation, so any solver-independent check of the contract has an exact answer + that is trivial to verify by hand. + """ + + dim = 2 + eqn_velocity = r"(z, -x)" + eqn_viscosity = r"1" + reference = "Contract test fixture; not a published benchmark." + + def __init__(self, mesh): + super().__init__(mesh) + + x, y = mesh.X + + self.fn_velocity = sympy.Matrix([[y, -x]]) + self.fn_pressure = sympy.sympify(0) + self.fn_viscosity = sympy.sympify(1) + self.fn_bodyforce = sympy.Matrix([[0, 0]]) + self.fn_strainrate = sympy.Matrix([[0, 0], [0, 0]]) + self.fn_stress = sympy.Matrix([[0, 0], [0, 0]]) + + +def test_contract_is_exported(): + """The contract and the mixins are reachable from the package namespace.""" + for name in ("AnalyticSolution", "FreeSlipWalls", "FixedWalls"): + assert name in uw.analytic.__all__ + assert hasattr(uw.analytic, name) + + +def test_solcx_is_the_same_object_in_both_namespaces(): + """uw.analytic re-exports SolCx; it does not redeclare it.""" + from underworld3.function import analytic as legacy + + assert uw.analytic.SolCx is legacy.SolCx + + +def test_available_lists_solcx(): + """The registry reports what can actually be constructed.""" + assert "SolCx" in uw.analytic.available() + assert uw.analytic.available() == sorted(uw.analytic.available()) + + +def test_describe_rejects_unknown_names(): + with pytest.raises(ValueError, match="no analytic solution named"): + uw.analytic.describe("SolNotAThing") + + +def test_dimension_is_checked(mesh): + """A 2D solution refuses a 3D mesh rather than producing nonsense.""" + + class ThreeDimensional(Quadratic): + dim = 3 + + with pytest.raises(ValueError, match="3D solution"): + ThreeDimensional(mesh) + + +def test_named_fields_resolve(mesh): + sol = Quadratic(mesh) + + assert sol._exact("velocity") is sol.fn_velocity + assert sol._exact("pressure") is sol.fn_pressure + + # An expression passes straight through, so error() and evaluate() work on + # anything derived from the solution, not only its named fields. + x, y = mesh.X + assert sol._exact(x + y) == x + y + + +def test_unknown_field_names_are_reported(mesh): + sol = Quadratic(mesh) + + with pytest.raises(ValueError, match="no field 'temperature'"): + sol._exact("temperature") + + +def test_evaluate_returns_exact_values(mesh): + """evaluate() is the exact field, at arbitrary points, vectorised.""" + sol = Quadratic(mesh) + + coords = np.array([[0.25, 0.75], [0.5, 0.5], [0.9, 0.1]]) + values = np.asarray(sol.evaluate("velocity", coords)).reshape(3, 2) + + expected = np.column_stack([coords[:, 1], -coords[:, 0]]) + assert np.allclose(values, expected) + + +def test_error_is_zero_for_the_exact_field(mesh): + """A variable carrying the exact solution has zero error.""" + sol = Quadratic(mesh) + + velocity = uw.discretisation.MeshVariable("Uc", mesh, mesh.dim, degree=2) + velocity.array[:, 0, :] = np.asarray( + sol.evaluate("velocity", velocity.coords) + ).reshape(-1, mesh.dim) + + assert sol.error("velocity", velocity) < 1.0e-12 + + +def test_error_scales_with_the_perturbation(mesh): + """A known relative perturbation is reported as that relative error. + + Scaling the computed field by (1 + eps) must give a relative L2 error of + exactly eps — this pins the normalisation, which a zero-error test cannot. + """ + sol = Quadratic(mesh) + + velocity = uw.discretisation.MeshVariable("Up", mesh, mesh.dim, degree=2) + exact = np.asarray(sol.evaluate("velocity", velocity.coords)).reshape( + -1, mesh.dim + ) + + for eps in (1.0e-3, 1.0e-1): + velocity.array[:, 0, :] = exact * (1.0 + eps) + assert np.isclose(sol.error("velocity", velocity), eps, rtol=1.0e-8) + + +def test_error_is_the_same_on_every_rank(mesh): + """The nodal norm is a global reduction, not each rank's own partition. + + The perturbation is deliberately confined to x < 0.5, so under np > 1 the + ranks own very different shares of it. A rank-local norm would therefore + return a different number on each rank — which is exactly the regression this + reduction was written to fix (issue #370: the rank owning the SolCx viscosity + jump reported an error 10-20x its neighbours', so parallel tolerance + assertions depended on the partition). + + A uniform perturbation cannot detect this: it would agree across ranks even + if the norm were rank-local. + """ + sol = Quadratic(mesh) + + velocity = uw.discretisation.MeshVariable("Ug", mesh, mesh.dim, degree=2) + exact = np.asarray(sol.evaluate("velocity", velocity.coords)).reshape( + -1, mesh.dim + ) + + perturbed = exact.copy() + left = velocity.coords[:, 0] < 0.5 + perturbed[left] *= 1.5 + velocity.array[:, 0, :] = perturbed + + error = sol.error("velocity", velocity) + + assert error > 1.0e-3, "perturbation too small to discriminate" + assert len(set(uw.mpi.comm.allgather(error))) == 1 + + +def test_integral_norm_agrees_with_the_nodal_norm(mesh): + """The continuous norm reports the same relative error for a uniform scaling. + + Scaling the field by (1 + eps) scales the error integrand uniformly, so both + norms must return eps. They disagree in general — the integral norm is the + one to use across discretisations — but this case pins both normalisations + against the same known answer. + """ + sol = Quadratic(mesh) + + velocity = uw.discretisation.MeshVariable("Ui", mesh, mesh.dim, degree=2) + exact = np.asarray(sol.evaluate("velocity", velocity.coords)).reshape( + -1, mesh.dim + ) + + eps = 1.0e-2 + velocity.array[:, 0, :] = exact * (1.0 + eps) + + assert np.isclose(sol.error("velocity", velocity, norm="integral"), eps, rtol=1e-4) + + +def test_unknown_norm_is_rejected(mesh): + sol = Quadratic(mesh) + velocity = uw.discretisation.MeshVariable("Un", mesh, mesh.dim, degree=2) + + with pytest.raises(ValueError, match="norm must be"): + sol.error("velocity", velocity, norm="linf") + + +def test_missing_boundary_conditions_are_an_error(mesh): + """A solution that declares no BCs says so, rather than solving something else.""" + + class NoBoundaryConditions(uw.analytic.AnalyticSolution): + dim = 2 + + sol = NoBoundaryConditions(mesh) + stokes = uw.systems.Stokes(mesh) + + with pytest.raises(NotImplementedError, match="does not declare its boundary"): + sol.apply_boundary_conditions(stokes) + + +def test_fixed_walls_configures_the_solver(mesh): + """FixedWalls imposes the exact velocity on every wall and kills the nullspace.""" + sol = Quadratic(mesh) + stokes = uw.systems.Stokes(mesh) + + sol.apply_boundary_conditions(stokes) + + assert stokes.petsc_use_pressure_nullspace + assert {bc.boundary for bc in stokes.essential_bcs} == set(sol.boundaries) + + +def test_free_slip_walls_uses_the_rotated_constraint(mesh): + """FreeSlipWalls imposes u.n = 0 by rotation, not by masking a component. + + Component masking is only equivalent on an axis-aligned box; the rotated form + is what still holds when a solution is used to validate a curved or adapted + mesh. The registration list is private because it has no public reader — this + is the only signal that distinguishes the two paths. + """ + + class FreeSlip(uw.analytic.FreeSlipWalls, Quadratic): + pass + + sol = FreeSlip(mesh) + stokes = uw.systems.Stokes(mesh) + + sol.apply_boundary_conditions(stokes) + + assert stokes.petsc_use_pressure_nullspace + registered = {boundary for boundary, _ in stokes._rotated_freeslip_bcs} + assert registered == set(sol.boundaries) + assert stokes.essential_bcs == [] + + +def test_boundaries_follow_the_mesh_dimension(mesh): + sol = Quadratic(mesh) + assert sol.boundaries == ["Left", "Right", "Bottom", "Top"] From e57a98aed4d42d4729f49bb50477f31b15633f00 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sun, 2 Aug 2026 15:54:15 +1000 Subject: [PATCH 02/28] Move the analytic extension out of uw.function, behind a shim underworld3.function.analytic was a compiled extension, so the name was owned by a .so and nothing could live under it -- no submodules, no registry, no pure-sympy solutions. That is what has kept the exact-solution suite from growing. The extension is now underworld3.analytic._reference._velic, and the old name is a deprecation shim. No behaviour changes and no test-file edits: the ten existing consumers pass unmodified, which is the acceptance criterion for this commit. The shim is a package DIRECTORY, not a module file, and must stay one. An orphaned analytic.cpython-*.so from an earlier install cannot be removed by pip uninstall once it has fallen out of the wheel RECORD, and it would be imported in place of the shim -- silently restoring the old module and bypassing every redirect. Python's path finder checks for a package directory before an extension of the same name, which is the only thing that makes such an orphan harmless. Verified by planting one: the directory wins, and SolCx still resolves. test_legacy_namespace_is_a_package_not_an_extension guards it. Names resolve through the shim's __getattr__ rather than being imported at module level, so the deprecation warning fires when a name is USED. Ten test files import this module at collection time, where a warning is noise the reader cannot act on. Object identity is preserved -- the shim returns the same classes, so isinstance and pickle work across both paths. uw.function.__getattr__ uses importlib.import_module rather than `from . import analytic`: the latter resolves the submodule by calling getattr() on the parent, which lands straight back in __getattr__ and recurses until the stack runs out. Found by running it. package_data gains its own "underworld3.analytic._reference" key. The existing "underworld3" globs do not reach a directory that is itself a package, and the JIT adds only the module's own directory to its include path -- so a missing header fails at solve time, when the kernel compiles, not at import. Confirmed the .so and all three headers appear in the installed RECORD. Verified: clean rebuild (build/lib.*, build/temp.* removed first, since a stale tree repackages the old .so and Cython's cached .c embeds the old module name); 20 contract tests at np=1 and np=2; test_1015_analytic_solcx and test_1062_constrained_solcx pass unchanged; the other five consumers and both parallel test files collect (68 tests); both the attribute and from-import paths work; style gate clean. Underworld development team with AI support from Claude Code --- docs/api/function.md | 7 +- setup.py | 22 ++++-- src/underworld3/analytic/__init__.py | 7 +- .../_reference}/AnalyticSolCx.c | 0 .../_reference}/AnalyticSolCx.h | 0 .../_reference}/AnalyticSolNL.c | 0 .../_reference}/AnalyticSolNL.h | 0 .../analytic/_reference/__init__.py | 20 +++++ .../_reference/_velic.pyx} | 0 .../{function => analytic/_reference}/solCx.c | 0 .../{function => analytic/_reference}/solCx.h | 0 src/underworld3/function/__init__.py | 22 +++++- src/underworld3/function/analytic/__init__.py | 74 +++++++++++++++++++ tests/test_1016_analytic_contract.py | 43 ++++++++++- 14 files changed, 177 insertions(+), 18 deletions(-) rename src/underworld3/{function => analytic/_reference}/AnalyticSolCx.c (100%) rename src/underworld3/{function => analytic/_reference}/AnalyticSolCx.h (100%) rename src/underworld3/{function => analytic/_reference}/AnalyticSolNL.c (100%) rename src/underworld3/{function => analytic/_reference}/AnalyticSolNL.h (100%) create mode 100644 src/underworld3/analytic/_reference/__init__.py rename src/underworld3/{function/analytic.pyx => analytic/_reference/_velic.pyx} (100%) rename src/underworld3/{function => analytic/_reference}/solCx.c (100%) rename src/underworld3/{function => analytic/_reference}/solCx.h (100%) create mode 100644 src/underworld3/function/analytic/__init__.py diff --git a/docs/api/function.md b/docs/api/function.md index b3a052b37..9ac50e271 100644 --- a/docs/api/function.md +++ b/docs/api/function.md @@ -78,8 +78,5 @@ Factory function for creating UWQuantity objects with units. ## Analytic Functions -```{eval-rst} -.. automodule:: underworld3.function.analytic - :members: - :show-inheritance: -``` +The analytic solutions have moved to {doc}`analytic` — `underworld3.function.analytic` +is a deprecation shim. Use `uw.analytic.SolCx(mesh, ...)`. diff --git a/setup.py b/setup.py index bdd0bee0f..f2fc86c3b 100644 --- a/setup.py +++ b/setup.py @@ -238,13 +238,17 @@ def configure(): define_macros=[("NPY_NO_DEPRECATED_API", "NPY_1_7_API_VERSION")], **conf, ), + # The published reference kernels. Analytic solutions are delivered as SymPy; + # these are kept as the independent oracle each transcription is validated + # against. The headers must install beside the .so — the JIT adds only the + # module's own directory to its include path. Extension( - "underworld3.function.analytic", + "underworld3.analytic._reference._velic", sources=[ - "src/underworld3/function/analytic.pyx", - "src/underworld3/function/AnalyticSolNL.c", - "src/underworld3/function/AnalyticSolCx.c", - "src/underworld3/function/solCx.c", + "src/underworld3/analytic/_reference/_velic.pyx", + "src/underworld3/analytic/_reference/AnalyticSolNL.c", + "src/underworld3/analytic/_reference/AnalyticSolCx.c", + "src/underworld3/analytic/_reference/solCx.c", ], extra_compile_args=extra_compile_args, **conf, @@ -286,7 +290,13 @@ def configure(): name="underworld3", packages=find_packages(), # Version is derived from git tags via setuptools_scm (configured in pyproject.toml) - package_data={"underworld3": ["*.pxd", "*.h", "function/*.h", "cython/*.pxd"]}, + package_data={ + "underworld3": ["*.pxd", "*.h", "function/*.h", "cython/*.pxd"], + # Its own key: the "underworld3" globs above do not reach a directory + # that is itself a package. Missing these headers fails at solve time, + # when the JIT compiles, not at import. + "underworld3.analytic._reference": ["*.h"], + }, ext_modules=cythonize( extensions, compiler_directives={"language_level": "3"}, # or "2" or "3str" diff --git a/src/underworld3/analytic/__init__.py b/src/underworld3/analytic/__init__.py index f04a85549..681069096 100644 --- a/src/underworld3/analytic/__init__.py +++ b/src/underworld3/analytic/__init__.py @@ -28,10 +28,9 @@ from ._base import AnalyticSolution, FreeSlipWalls, FixedWalls -# SolCx is still served by the original compiled module while the suite is built -# out. It reaches users through this namespace from the start so nothing has to -# move twice, and so `uw.analytic` is useful on the day it lands. -from underworld3.function.analytic import SolCx +# SolCx is still served by the published reference kernel while the suite is +# built out; it will become a SymPy transcription validated against that kernel. +from ._reference._velic import SolCx __all__ = [ "AnalyticSolution", diff --git a/src/underworld3/function/AnalyticSolCx.c b/src/underworld3/analytic/_reference/AnalyticSolCx.c similarity index 100% rename from src/underworld3/function/AnalyticSolCx.c rename to src/underworld3/analytic/_reference/AnalyticSolCx.c diff --git a/src/underworld3/function/AnalyticSolCx.h b/src/underworld3/analytic/_reference/AnalyticSolCx.h similarity index 100% rename from src/underworld3/function/AnalyticSolCx.h rename to src/underworld3/analytic/_reference/AnalyticSolCx.h diff --git a/src/underworld3/function/AnalyticSolNL.c b/src/underworld3/analytic/_reference/AnalyticSolNL.c similarity index 100% rename from src/underworld3/function/AnalyticSolNL.c rename to src/underworld3/analytic/_reference/AnalyticSolNL.c diff --git a/src/underworld3/function/AnalyticSolNL.h b/src/underworld3/analytic/_reference/AnalyticSolNL.h similarity index 100% rename from src/underworld3/function/AnalyticSolNL.h rename to src/underworld3/analytic/_reference/AnalyticSolNL.h diff --git a/src/underworld3/analytic/_reference/__init__.py b/src/underworld3/analytic/_reference/__init__.py new file mode 100644 index 000000000..9dd6ea228 --- /dev/null +++ b/src/underworld3/analytic/_reference/__init__.py @@ -0,0 +1,20 @@ +r"""Reference implementations of the analytic solutions, as originally published. + +These are the machine-generated C kernels the suite's SymPy solutions were +transcribed from — Velic's, and PETSc's copies of them. They are kept, and +supported, for two reasons: + +1. every transcription is validated against them (see the six gates in + ``docs/developer/subsystems/analytic-solutions.md``), so they must stay + available and unmodified; +2. when a benchmark result looks wrong, "is this the transcription or the + model?" should be a one-line question: + + .. code-block:: python + + sol = uw.analytic.SolCx(mesh, ...) # SymPy + ref = uw.analytic.SolCx(mesh, ..., reference=True) # the C, verbatim + +Reach for :mod:`underworld3.analytic` instead; nothing here is part of the +public API. +""" diff --git a/src/underworld3/function/analytic.pyx b/src/underworld3/analytic/_reference/_velic.pyx similarity index 100% rename from src/underworld3/function/analytic.pyx rename to src/underworld3/analytic/_reference/_velic.pyx diff --git a/src/underworld3/function/solCx.c b/src/underworld3/analytic/_reference/solCx.c similarity index 100% rename from src/underworld3/function/solCx.c rename to src/underworld3/analytic/_reference/solCx.c diff --git a/src/underworld3/function/solCx.h b/src/underworld3/analytic/_reference/solCx.h similarity index 100% rename from src/underworld3/function/solCx.h rename to src/underworld3/analytic/_reference/solCx.h diff --git a/src/underworld3/function/__init__.py b/src/underworld3/function/__init__.py index 2f39ff2c7..59252026a 100644 --- a/src/underworld3/function/__init__.py +++ b/src/underworld3/function/__init__.py @@ -24,8 +24,6 @@ underworld3.discretisation : Mesh and variable classes. underworld3.swarm : Particle swarm evaluation targets. """ -from . import analytic - # Import the _function module to expose it in the namespace (needed by expressions.py) from . import _function from ._function import ( @@ -234,3 +232,23 @@ def derivative(expression, variable, evaluate=True): derivative[i, j] = _derivative_expression(latex, expression, variable[i, j]) return derivative + + +def __getattr__(name): + """Keep ``uw.function.analytic`` reachable by attribute after the move. + + The analytic suite now lives in :mod:`underworld3.analytic`; what remains + here is a deprecation shim. Importing it eagerly would pull the whole + analytic package in as a side effect of ``import underworld3.function``, so + it is resolved on first use instead. + """ + + if name == "analytic": + # import_module, not `from . import analytic`: the latter resolves the + # submodule by calling getattr() on this module, which lands back here. + # import_module also binds the result as an attribute, so this runs once. + import importlib + + return importlib.import_module(f"{__name__}.analytic") + + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/underworld3/function/analytic/__init__.py b/src/underworld3/function/analytic/__init__.py new file mode 100644 index 000000000..7fa0995c3 --- /dev/null +++ b/src/underworld3/function/analytic/__init__.py @@ -0,0 +1,74 @@ +r"""Deprecated location for the analytic solutions — use :mod:`underworld3.analytic`. + +The suite moved out of ``underworld3.function`` because it outgrew it: it now +carries boundary conditions, error norms and a registry, none of which belong +under "symbolic function evaluation". Everything here resolves to the *same +objects* as the new location, so ``isinstance`` and pickling work across both +paths. + +.. deprecated:: + Import from :mod:`underworld3.analytic` instead:: + + sol = uw.analytic.SolCx(mesh, eta_A=1.0, eta_B=1.0e6) + +Notes +----- +This is a package directory rather than a module file on purpose. The old +``underworld3.function.analytic`` was a compiled extension, and an orphaned +``.so`` left behind by an earlier install would otherwise be imported in place +of this shim — Python's path finder checks for a package directory *before* it +checks for an extension of the same name, so the directory always wins. +""" + +import warnings + +__all__ = [ + "AnalyticSolCx_base", + "AnalyticSolCx_pressure", + "AnalyticSolCx_stress_xx", + "AnalyticSolCx_stress_xy", + "AnalyticSolCx_stress_yy", + "AnalyticSolCx_velocity", + "AnalyticSolCx_velocity_x", + "AnalyticSolCx_velocity_y", + "AnalyticSolCx_viscosity", + "AnalyticSolNL_base", + "AnalyticSolNL_bodyforce", + "AnalyticSolNL_bodyforce_x", + "AnalyticSolNL_bodyforce_y", + "AnalyticSolNL_velocity", + "AnalyticSolNL_velocity_x", + "AnalyticSolNL_velocity_y", + "AnalyticSolNL_viscosity", + "SolCx", + "sympy_function_printable", +] + +# Names are resolved through __getattr__ rather than imported here, so that the +# deprecation warning fires when one is *used*. Ten test files import this +# module at collection time; a warning there is noise the reader cannot act on. +_warned = False + + +def __getattr__(name): + global _warned + + if name not in __all__: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + if not _warned: + warnings.warn( + "underworld3.function.analytic has moved to underworld3.analytic; " + "import from there instead (uw.analytic.SolCx, ...).", + DeprecationWarning, + stacklevel=2, + ) + _warned = True + + from underworld3.analytic._reference import _velic + + return getattr(_velic, name) + + +def __dir__(): + return sorted(__all__) diff --git a/tests/test_1016_analytic_contract.py b/tests/test_1016_analytic_contract.py index c32fcff60..6b714c0bb 100644 --- a/tests/test_1016_analytic_contract.py +++ b/tests/test_1016_analytic_contract.py @@ -65,12 +65,53 @@ def test_contract_is_exported(): def test_solcx_is_the_same_object_in_both_namespaces(): - """uw.analytic re-exports SolCx; it does not redeclare it.""" + """uw.analytic re-exports SolCx; it does not redeclare it. + + If the old namespace redeclared its classes rather than resolving to the + new ones, `isinstance` would fail for any object built through the other + path. + """ from underworld3.function import analytic as legacy assert uw.analytic.SolCx is legacy.SolCx +def test_legacy_namespace_is_a_package_not_an_extension(): + """The shim must stay a package directory, not a module file. + + `underworld3.function.analytic` used to be a compiled extension. An + orphaned `.so` from an earlier install cannot be removed by `pip uninstall` + if it has fallen out of the wheel RECORD, and it would be imported in place + of the shim — silently restoring the old module and bypassing every + redirect. Python's path finder checks for a package directory *before* an + extension of the same name, which is the only thing that makes the stale + `.so` harmless. Keep this a directory. + """ + from underworld3.function import analytic as legacy + + assert legacy.__file__.endswith("__init__.py") + + +def test_legacy_namespace_warns_on_use(): + """Importing the shim is quiet; using a name from it is not.""" + import importlib + + from underworld3.function import analytic as legacy + + # The warning fires once per process, so reset the latch to observe it. + legacy._warned = False + + with pytest.warns(DeprecationWarning, match="moved to underworld3.analytic"): + legacy.SolCx + + +def test_legacy_namespace_rejects_unknown_names(): + from underworld3.function import analytic as legacy + + with pytest.raises(AttributeError): + legacy.SolNotAThing + + def test_available_lists_solcx(): """The registry reports what can actually be constructed.""" assert "SolCx" in uw.analytic.available() From 7d105d1dc9b1bd495d8d317ce4ad985940a35901 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sun, 2 Aug 2026 16:08:49 +1000 Subject: [PATCH 03/28] Transcriber for the Maple-generated kernels, and SolCx under validation Groundwork for delivering the analytic solutions as SymPy. Not wired in: uw.analytic.SolCx is still the reference kernel, and velic.py is not exported, because two validation gates are open. _transcribe.py reads the published straight-line C -- the t125 = 0.4e1*t81*t83 + ... form -- and rebuilds the same expression tree in SymPy. It preserves the generator's grouping term for term and never simplifies: these expansions carry products that are stable only in the arrangement Maple produced, and a re-derivation is a different arrangement that can lose eight digits in exactly the regime the benchmarks probe. Transcription happens at run time rather than generating a checked-in module. Measured on solCx.c -- 1500 lines, the largest in the family -- reading both arrangements and both spatial branches takes 0.25 s, and the resulting expressions are 2000-7000 operations. That is cheap enough to do on demand, and doing it on demand means the SymPy form cannot drift from the C it came from: they are one artefact, not two copies. The measurement is also what shows the all-SymPy target is viable for the whole family, which was not obvious before. Numeric literals become exact Rationals. Maple writes them as 0.4e1, i.e. exact small values, so nothing is lost and a solution can be evaluated at arbitrary precision -- which is how a transcription error is told apart from the kernel's own double-precision cancellation. Gates 1 and 2 on the _solCx_A arrangement pass at 1e-14 to 1e-16 across every regime tested: contrast 1e-6 to 1e8, both orderings, several x_c and n. The table is in docs/developer/subsystems/analytic-solutions.md. Two gates are open and are why nothing is exported: - The _solCx_B arrangement transcribes to a different answer. The kernel dispatches on viscosity ordering purely for conditioning, so the two arrangements should agree -- and the compiled ones do, since the _solCx_A transcription reproduces the dispatcher's output in the regime where the dispatcher runs _solCx_B. Transcribing _solCx_B directly is wrong by a factor of tens everywhere. Both parse to the same structure, so the reader treats them identically and one is still being read wrongly. Unexplained. - The isoviscous case returns zero where the kernel does not, pointing at a 0/0 at unit viscosity ratio; the closed form carries ZR - 1 in denominators. Method note recorded in the subsystem doc: the first gate run reported 1e12 errors, which was the metric, not the transcription. A pointwise relative error divides by the true value, and these fields pass through zero. Normalising by the field magnitude over the sample is the honest measure -- suspect the metric before the result when every case fails alike. Underworld development team with AI support from Claude Code --- .../subsystems/analytic-solutions.md | 50 ++++ src/underworld3/analytic/_transcribe.py | 169 +++++++++++ src/underworld3/analytic/velic.py | 268 ++++++++++++++++++ 3 files changed, 487 insertions(+) create mode 100644 src/underworld3/analytic/_transcribe.py create mode 100644 src/underworld3/analytic/velic.py diff --git a/docs/developer/subsystems/analytic-solutions.md b/docs/developer/subsystems/analytic-solutions.md index 9540c6fba..676a97df0 100644 --- a/docs/developer/subsystems/analytic-solutions.md +++ b/docs/developer/subsystems/analytic-solutions.md @@ -134,6 +134,56 @@ uses with `ex75.h` — so later refactors cannot drift silently. Record the meas maximum error, the sampling design, and which oracles were used in the solution's docstring. Not just "validated". +## Status: SolCx transcription, open questions + +The transcriber is in place and measured. On `solCx.c` — the largest kernel in +the family at 1500 lines — it reads both arrangements and both spatial branches +in **0.25 s**, and the resulting expressions are 2000–7000 operations, well +within what SymPy, `lambdify` and the JIT handle. That measurement is what makes +the all-SymPy form viable for the whole family; it was not obvious in advance. + +Gate 1 and Gate 2 on the `_solCx_A` arrangement, against the published kernel, at +40 stratified points per case: + +| eta_A | eta_B | x_c | n | max relative error | +|---|---|---|---|---| +| 1 | 10 | 0.5 | 1 | 4.3e-15 | +| 1 | 1e3 | 0.5 | 1 | 1.7e-14 | +| 1 | 1e6 | 0.5 | 1 | 1.1e-14 | +| 1 | 1e8 | 0.5 | 1 | 9.3e-15 | +| 1 | 1e6 | 0.5 | 3 | 4.9e-15 | +| 1 | 1e6 | 0.75 | 1 | 4.1e-15 | +| 1e6 | 1 | 0.5 | 1 | 1.5e-14 | +| 1e3 | 1 | 0.25 | 2 | 8.9e-16 | +| 1 | 1e-6 | 0.5 | 1 | 1.5e-14 | + +**Two gates are open, and nothing is exported until they close.** + +1. **The `_solCx_B` arrangement transcribes to a different answer.** The + published kernel dispatches on the viscosity ordering — `_solCx_A` for + $\eta_A > \eta_B$, `_solCx_B` otherwise — because the integration constants + lose precision differently depending on which column is stiff. The two should + therefore compute the same solution. They do in the compiled kernel: the + `_solCx_A` transcription reproduces the dispatcher's output *in the regime + where the dispatcher runs `_solCx_B`*, to 1e-14. But transcribing `_solCx_B` + directly gives an answer wrong by a factor of tens, in every regime. Both + arrangements parse to the same structure (same assigned names, same branch + shape, self-contained blocks), so the reader is treating them identically and + one of them is nonetheless being read wrongly. Unexplained; do not use + `_solCx_B` until it is. + +2. **The isoviscous case returns zero.** At $\eta_A = \eta_B$ the transcription + evaluates to zero where the kernel does not, which points at a $0/0$ in the + closed form at unit viscosity ratio. The published solution is built around + $Z_R = \eta_B/\eta_A$ and several denominators carry $Z_R - 1$. + +A note on method, worth keeping: the first run of Gate 2 reported errors of +1e12, which looked catastrophic. It was the *metric* — a pointwise relative error +divides by the true value, and these fields pass through zero, so the ratio +explodes wherever the solution is small. The values themselves were close. +Normalising by the field's magnitude over the sample is the honest measure. +Suspect the metric before the result when every case fails alike. + ## Provenance Each vendored reference kernel keeps its original copyright header. diff --git a/src/underworld3/analytic/_transcribe.py b/src/underworld3/analytic/_transcribe.py new file mode 100644 index 000000000..57000e7ff --- /dev/null +++ b/src/underworld3/analytic/_transcribe.py @@ -0,0 +1,169 @@ +r"""Turn a machine-generated C kernel into SymPy, preserving its grouping. + +The classical analytic solutions were published as Maple output: long runs of +straight-line single assignments, ``t125 = 0.4e1 * t81 * t83 + ...``, with a +branch or two. This module reads that C and rebuilds the same expression tree in +SymPy. + +**The grouping is the point.** These expansions contain products like +:math:`\sinh(k)e^{-k}` that are numerically stable only in the arrangement the +generator produced; a re-derivation is a different arrangement and can lose eight +digits at large wavenumber or large viscosity contrast — exactly the regime the +benchmarks exist to probe. So nothing here simplifies, expands, collects or +reorders: each statement is substituted into the next verbatim, and the resulting +tree evaluates term for term as the C does. + +Transcribing at run time rather than generating a checked-in module is deliberate. +It costs a fraction of a second, and it means the SymPy form cannot drift from the +C it came from — they are the same artefact, not two copies of one. + +Numeric literals become exact :class:`sympy.Rational`\s. Maple writes them as +``0.4e1``, i.e. exact small values, so this loses nothing and lets a solution be +evaluated at arbitrary precision — which is how a transcription error is told +apart from the C's own double-precision cancellation. + +See ``docs/developer/subsystems/analytic-solutions.md`` for the validation every +transcription must pass before it is used. +""" + +import re + +import sympy + +# The only functions the Velic kernels call. +_C_FUNCTIONS = { + "exp": sympy.exp, + "sin": sympy.sin, + "cos": sympy.cos, + "sqrt": sympy.sqrt, + "pow": lambda base, exponent: base**exponent, + "M_PI": sympy.pi, +} + +_STATEMENT = re.compile(r"(\w+)\s*=\s*([^;]+);") +_FLOAT_LITERAL = re.compile(r"\b\d+\.\d*(?:[eE][+-]?\d+)?") + + +def _strip_comments(source): + source = re.sub(r"/\*.*?\*/", " ", source, flags=re.S) + return re.sub(r"//[^\n]*", " ", source) + + +def _matching_brace(source, opening): + """Index just past the ``}`` closing the ``{`` at *opening*.""" + + depth, index = 0, opening + while True: + if source[index] == "{": + depth += 1 + elif source[index] == "}": + depth -= 1 + if depth == 0: + return index + 1 + index += 1 + + +def _exact_literals(expression): + """Rewrite C float literals as exact Rationals. + + ``0.4e1`` is the generator's way of writing 4. Reading it as a float would + make every downstream comparison approximate for no reason. + """ + + return _FLOAT_LITERAL.sub(lambda m: f"Rational('{m.group(0)}')", expression) + + +class CSource: + """A vendored C kernel, addressable by function and by block. + + Parameters + ---------- + path : str or pathlib.Path + The ``.c`` file to read. + """ + + def __init__(self, path): + self.path = str(path) + self.text = _strip_comments(open(self.path).read()) + + def function(self, name): + """The body of ``void (...)``, braces excluded.""" + + signature = self.text.index(f"void {name}(") + opening = self.text.index("{", self.text.index(")", signature)) + return self.text[opening + 1 : _matching_brace(self.text, opening) - 1] + + @staticmethod + def branches(body, condition, tail_ends_at=None): + """Split ``if () { A } else { B }`` into ``(A, B, tail)``. + + The tail is what follows the ``else`` block — in these kernels, the shared + arithmetic turning the branch's integration constants into the output + fields. + + Parameters + ---------- + tail_ends_at : str, optional + Cut the tail at the first occurrence of this text. Needed because the + kernels end with an output section that accumulates into ``sum`` variables + with ``+=`` and writes through pointers — statements this reader is not + meant to interpret, and whose operands it has never bound. + """ + + marker = body.index(condition) + opening = body.index("{", marker) + then_end = _matching_brace(body, opening) + then_block = body[opening + 1 : then_end - 1] + + else_opening = body.index("{", body.index("else", then_end - 1)) + else_end = _matching_brace(body, else_opening) + else_block = body[else_opening + 1 : else_end - 1] + + tail = body[else_end:] + if tail_ends_at is not None: + tail = tail[: tail.index(tail_ends_at)] + + return then_block, else_block, tail + + +def evaluate_block(block, environment): + """Substitute a straight-line block of C assignments into SymPy. + + Parameters + ---------- + block : str + C statements of the form ``name = expression;``. Anything else — a + declaration, an ``if``, an array write — is ignored, so a block may be + handed a whole function body and only its assignments are read. + environment : dict + Symbol names already in scope, mapped to SymPy expressions. Not modified. + + Returns + ------- + dict + *environment* extended with every name the block assigns. A name assigned + more than once holds its final value, matching C. + + Notes + ----- + The generated statements are already valid Python once ``pow``, ``exp``, + ``sin`` and ``cos`` are in scope, so they are evaluated directly against a + namespace holding no builtins. The input is a kernel vendored in this + package, not anything a caller supplies. + + Temporaries are substituted as they are read, so the returned expressions are + full trees rather than a chain of definitions. Measured on ``solCx.c``, the + largest is a few thousand operations — the sharing is recovered by common + subexpression elimination when the expression is compiled or lambdified. + """ + + scope = dict(environment) + namespace = {**_C_FUNCTIONS, "Rational": sympy.Rational} + + for target, expression in _STATEMENT.findall(block): + value = eval( + _exact_literals(expression), {"__builtins__": {}}, {**namespace, **scope} + ) + scope[target] = sympy.sympify(value) + + return scope diff --git a/src/underworld3/analytic/velic.py b/src/underworld3/analytic/velic.py new file mode 100644 index 000000000..46605b1e7 --- /dev/null +++ b/src/underworld3/analytic/velic.py @@ -0,0 +1,268 @@ +r"""The Velic family of exact Stokes solutions. + +These are the classical variable-viscosity benchmarks: a known body force and a +known viscosity structure on the unit box, with the exact velocity, pressure and +stress they produce. They are the standard way to show a Stokes solver is right +rather than merely converged. + +Each is transcribed from its published Maple-generated kernel, which stays +vendored in :mod:`underworld3.analytic._reference` as the oracle the +transcription is validated against and as a supported escape hatch +(``reference=True``). + +.. warning:: + **Under validation — not exported from** :mod:`underworld3.analytic` **yet.** + ``uw.analytic.SolCx`` is still the reference kernel. Two gates are open, both + recorded in ``docs/developer/subsystems/analytic-solutions.md``: + + * the transcription of the ``_solCx_B`` arrangement disagrees with the + published kernel, while ``_solCx_A`` agrees to 1e-14 across every regime + tested (contrast 1e-6 to 1e8, both directions, several ``x_c`` and ``n``). + Since the dispatcher routes :math:`\eta_A < \eta_B` to ``_solCx_B``, and the + ``_solCx_A`` transcription reproduces *that* output, the two arrangements + should be equivalent and one of them is being read wrongly. Unexplained. + * the isoviscous case :math:`\eta_A = \eta_B` returns zero, where the kernel + does not — a degeneracy in the closed form at unit viscosity ratio that the + transcription is not handling. + + Nothing here is used until both are closed. +""" + +import functools +import os + +import sympy + +from ._base import AnalyticSolution, FreeSlipWalls +from ._transcribe import CSource, evaluate_block + +_REFERENCE_DIR = os.path.join(os.path.dirname(__file__), "_reference") + +# Free symbols of the transcribed kernel, in the kernel's own naming. +_X, _Z = sympy.symbols("x z") +_XC, _KN, _KX = sympy.symbols("xc kn kx") +_ZA, _ZB, _ZR = sympy.symbols("ZA ZB ZR") + +# What the kernel's shared tail leaves behind, and what each means. +_SOLCX_OUTPUTS = { + "velocity_x": "u1", + "velocity_z": "u2", + "stress_xx": "u3", + "stress_zx": "u4", + "pressure": "u5", + "stress_zz": "u6", +} + + +@functools.lru_cache(maxsize=None) +def _solcx_kernel(variant): + r"""Transcribe one conditioning variant of the Velic SolCx kernel. + + The published kernel carries two arrangements of the same solution — + ``_solCx_A`` for :math:`\eta_A > \eta_B` and ``_solCx_B`` for the reverse — + because the integration constants lose precision differently depending on + which side is stiff. Which one applies is decided by the viscosities, so it + is resolved when a solution is constructed, not symbolically. + + Within a variant the only branch left is spatial (:math:`x < x_c`), and that + one becomes a :class:`sympy.Piecewise`. + + Returns + ------- + dict + Field name -> Piecewise expression in the kernel's own symbols. + """ + + source = CSource(os.path.join(_REFERENCE_DIR, "solCx.c")) + body = source.function(variant) + left_block, right_block, tail = CSource.branches( + body, "if (x>> sol = uw.analytic.SolCx(mesh, eta_A=1.0, eta_B=1.0e6) + >>> stokes.constitutive_model.Parameters.shear_viscosity_0 = sol.fn_viscosity + >>> stokes.bodyforce = sol.fn_bodyforce + >>> sol.apply_boundary_conditions(stokes) + >>> stokes.solve() + >>> sol.error("velocity", stokes.u) + + Notes + ----- + The body force is :math:`+\cos(\pi x)\sin(n\pi z)`. Underworld2's + documentation quotes the opposite sign; the sign here is the one consistent + with UW3's momentum convention and with the kernel's own pressure, which is + what the validation checks against. + """ + + dim = 2 + reference = ( + "Velic; see also Duretz et al. (2011). Transcribed from the published " + "kernel vendored at underworld3/analytic/_reference/solCx.c." + ) + eqn_viscosity = r"\eta_A \;(x 0.0 and float(eta_B) > 0.0): + raise ValueError("eta_A and eta_B must be positive.") + if not 0.0 <= float(x_c) <= 1.0: + raise ValueError("x_c must lie in [0, 1].") + if int(n) != n or int(n) < 1: + raise ValueError("n (vertical wavenumber) must be a positive integer.") + + self.eta_A = float(eta_A) + self.eta_B = float(eta_B) + self.x_c = float(x_c) + self.n = int(n) + + x, z = mesh.X + + # The kernel's own naming: kx is the horizontal wavenumber of the forcing + # (fixed at pi), kn the vertical one, ZR the viscosity ratio. + values = { + _XC: self.x_c, + _KN: self.n * sympy.pi, + _KX: sympy.pi, + _ZA: self.eta_A, + _ZB: self.eta_B, + _ZR: self.eta_B / self.eta_A, + _X: x, + _Z: z, + } + + variant = "_solCx_A" if self.eta_A > self.eta_B else "_solCx_B" + kernel = { + field: expression.subs(values) + for field, expression in _solcx_kernel(variant).items() + } + + self.fn_velocity = sympy.Matrix( + [[kernel["velocity_x"], kernel["velocity_z"]]] + ) + self.fn_pressure = kernel["pressure"] + self.fn_stress = sympy.Matrix( + [ + [kernel["stress_xx"], kernel["stress_zx"]], + [kernel["stress_zx"], kernel["stress_zz"]], + ] + ) + + # The viscosity tie-break at x == x_c matches the kernel's own step, so a + # point exactly on the interface is treated the same way by both. + self.fn_viscosity = sympy.Piecewise((self.eta_A, x < self.x_c), (self.eta_B, True)) + + # sigma = -p I + 2 eta edot, so the strain rate follows from the fields + # the kernel returns. It also returns its own strain rate, derived + # independently — the two are compared as one of the validation gates. + self.fn_strainrate = ( + self.fn_stress + self.fn_pressure * sympy.eye(2) + ) / (2 * self.fn_viscosity) + + self.fn_bodyforce = sympy.Matrix( + [[0, sympy.cos(sympy.pi * x) * sympy.sin(self.n * sympy.pi * z)]] + ) + + if reference: + self._use_reference_kernel() + + def _use_reference_kernel(self): + """Replace the transcribed fields with the vendored kernel's own. + + Point evaluation only: these are opaque to the JIT, so a solution built + this way cannot be handed to a solver. + """ + + from ._reference import _velic + + x, z = self.mesh.X + parameters = (self.eta_A, self.eta_B, self.x_c, self.n) + + self.fn_velocity = sympy.Matrix( + [ + [ + _velic.AnalyticSolCx_velocity_x(*parameters, x, z), + _velic.AnalyticSolCx_velocity_y(*parameters, x, z), + ] + ] + ) + self.fn_pressure = _velic.AnalyticSolCx_pressure(*parameters, x, z) + self.fn_stress = sympy.Matrix( + [ + [ + _velic.AnalyticSolCx_stress_xx(*parameters, x, z), + _velic.AnalyticSolCx_stress_xy(*parameters, x, z), + ], + [ + _velic.AnalyticSolCx_stress_xy(*parameters, x, z), + _velic.AnalyticSolCx_stress_yy(*parameters, x, z), + ], + ] + ) From f622230255e5cf13e36507b59f24edd046ca3210 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sun, 2 Aug 2026 16:22:55 +1000 Subject: [PATCH 04/28] SolCx as validated SymPy: the transcription replaces the compiled kernel uw.analytic.SolCx is now SymPy rebuilt from the published Maple kernel rather than a call into it. Same class, same name, drop-in: velocity_error, evaluate_stress and topography_top are kept, so the ten existing consumers pass unmodified and object identity across both namespaces still holds. What this buys is what a compiled kernel cannot do. The fields carry an analytic Jacobian, compile into a residual through the normal JIT path, and can be used as a Dirichlet boundary value -- test_transcription_is_usable_by_the_solver pins that last one, since it is the capability the whole change exists for. The old per-point evalf loop is gone with it. Validated against the kernel it came from, over ratios 1e-6 to 1e8 in both directions, wavenumbers 1 to 3, and an off-centre interface: worst-case normalised error 1e-14 to 1e-16, sampled with 40 stratified points plus the viscosity interface from both sides, the walls and the corners. Agreement at a handful of points was 7e-18. Three things looked like transcription failures and were not. All three are now tests, because each cost real time to find: _solCx_B is not a second conditioning of the same formula -- it is the mirror, A(x,z) = -B(1-x,z). The source dispatches on eta_A > eta_B, which reads as a conditioning choice, so transcribing both and picking looked obviously right; doing that gave answers wrong by a factor of tens. Evaluated exactly at 50 digits the relationship is clean, and the sign is the forcing cos(pi x) being odd about x = 1/2. Only _solCx_A is transcribed and there is no dispatch, which is safe because the stated reason for the dispatch was measured rather than assumed -- the error never leaves 1e-14 anywhere in the range. test_arrangements_are_mirror_images records the evidence, so if it ever fails we know the reasoning needs revisiting. Equal viscosities are a removable singularity -- the closed form carries (ZR - 1) in denominators. SymPy cancels it evaluating symbolically at a point, verified to 40 digits, but not in the compiled form, where it survives as 0/0. SolCx now raises rather than returning nonsense; uniform viscosity is a different benchmark. Parameters are substituted as exact Rationals regardless, since that is what allows the cancellation at all and it costs nothing. The first validation run reported errors of 1e12 and looked catastrophic. It was the metric: a pointwise relative error divides by the true value, and these fields pass through zero. The values were close all along. The test normalises by field magnitude and says so. Verified: 37 tests pass serially (the four analytic files, including the unchanged Stokes convergence test now driven by the transcription), 32 at np=2, style gate clean. Underworld development team with AI support from Claude Code --- .../subsystems/analytic-solutions.md | 70 +++++--- src/underworld3/analytic/__init__.py | 4 +- src/underworld3/analytic/velic.py | 99 +++++++---- src/underworld3/function/analytic/__init__.py | 7 + tests/test_1019_analytic_transcription.py | 158 ++++++++++++++++++ 5 files changed, 275 insertions(+), 63 deletions(-) create mode 100644 tests/test_1019_analytic_transcription.py diff --git a/docs/developer/subsystems/analytic-solutions.md b/docs/developer/subsystems/analytic-solutions.md index 676a97df0..ee14093f3 100644 --- a/docs/developer/subsystems/analytic-solutions.md +++ b/docs/developer/subsystems/analytic-solutions.md @@ -134,7 +134,7 @@ uses with `ex75.h` — so later refactors cannot drift silently. Record the meas maximum error, the sampling design, and which oracles were used in the solution's docstring. Not just "validated". -## Status: SolCx transcription, open questions +## Status: SolCx transcribed and validated The transcriber is in place and measured. On `solCx.c` — the largest kernel in the family at 1500 lines — it reads both arrangements and both spatial branches @@ -157,32 +157,48 @@ Gate 1 and Gate 2 on the `_solCx_A` arrangement, against the published kernel, a | 1e3 | 1 | 0.25 | 2 | 8.9e-16 | | 1 | 1e-6 | 0.5 | 1 | 1.5e-14 | -**Two gates are open, and nothing is exported until they close.** - -1. **The `_solCx_B` arrangement transcribes to a different answer.** The - published kernel dispatches on the viscosity ordering — `_solCx_A` for - $\eta_A > \eta_B$, `_solCx_B` otherwise — because the integration constants - lose precision differently depending on which column is stiff. The two should - therefore compute the same solution. They do in the compiled kernel: the - `_solCx_A` transcription reproduces the dispatcher's output *in the regime - where the dispatcher runs `_solCx_B`*, to 1e-14. But transcribing `_solCx_B` - directly gives an answer wrong by a factor of tens, in every regime. Both - arrangements parse to the same structure (same assigned names, same branch - shape, self-contained blocks), so the reader is treating them identically and - one of them is nonetheless being read wrongly. Unexplained; do not use - `_solCx_B` until it is. - -2. **The isoviscous case returns zero.** At $\eta_A = \eta_B$ the transcription - evaluates to zero where the kernel does not, which points at a $0/0$ in the - closed form at unit viscosity ratio. The published solution is built around - $Z_R = \eta_B/\eta_A$ and several denominators carry $Z_R - 1$. - -A note on method, worth keeping: the first run of Gate 2 reported errors of -1e12, which looked catastrophic. It was the *metric* — a pointwise relative error -divides by the true value, and these fields pass through zero, so the ratio -explodes wherever the solution is small. The values themselves were close. -Normalising by the field's magnitude over the sample is the honest measure. -Suspect the metric before the result when every case fails alike. +### What the gates caught, and what turned out to be true + +Three things looked like transcription failures and were not. All three are worth +knowing before transcribing the next kernel. + +**`_solCx_B` is not a second conditioning — it is the mirror.** The published +source dispatches on $\eta_A > \eta_B$, which reads as two arrangements of one +formula chosen for numerical conditioning. Transcribing `_solCx_B` directly gave +an answer wrong by a factor of tens in every regime, while `_solCx_A` matched to +1e-14 — including in the regime where the dispatcher runs `_solCx_B`. Evaluated +exactly at 50 digits the relationship is clean: $B(x,z) = A(1-x, z)$. `_solCx_B` +solves the mirrored problem so the algebra derived for a stiff *left* column can +be reused when the stiff column is on the right, and reflects on the way out. + +So only `_solCx_A` is transcribed and there is no dispatch. That is safe because +the stated reason for the dispatch was checked rather than assumed: the table +above spans ratios from 1e-6 to 1e8 in both directions and the error never leaves +1e-14. **Do not assume a kernel's internal dispatch means what its condition +suggests — evaluate both arms exactly and compare.** + +**The isoviscous case is a genuine limitation, and it is now refused.** The closed +form carries $Z_R - 1$ in several denominators, so $\eta_A = \eta_B$ is a +removable singularity. SymPy cancels it when the expression is evaluated +symbolically at a point — verified to 40 digits against the kernel — but not in +the compiled form, where it survives as $0/0$. Rather than return nonsense, +`SolCx` raises for equal viscosities: it is a viscosity-jump benchmark, and +uniform viscosity is a different solution. Parameters are substituted as exact +`Rational`s regardless, since that is what lets the cancellation happen at all. + +**The first gate run's 1e12 errors were the metric, not the transcription.** A +pointwise relative error divides by the true value, and these fields pass through +zero, so the ratio explodes wherever the solution is small — the values were +close all along. Normalise by the field's magnitude over the sample. Suspect the +metric before the result when every case fails alike. + +### Still to do for SolCx + +Gates 3 to 6 (derivatives against the kernel's independently derived strain rate, +the physics residual, the negative control, and the high-precision separation) +are demonstrated ad hoc above but are not yet a harness the test suite runs per +solution. That harness is what makes the remaining eleven transcriptions cheap, +and it should land before them. ## Provenance diff --git a/src/underworld3/analytic/__init__.py b/src/underworld3/analytic/__init__.py index 681069096..a7098b502 100644 --- a/src/underworld3/analytic/__init__.py +++ b/src/underworld3/analytic/__init__.py @@ -28,9 +28,7 @@ from ._base import AnalyticSolution, FreeSlipWalls, FixedWalls -# SolCx is still served by the published reference kernel while the suite is -# built out; it will become a SymPy transcription validated against that kernel. -from ._reference._velic import SolCx +from .velic import SolCx __all__ = [ "AnalyticSolution", diff --git a/src/underworld3/analytic/velic.py b/src/underworld3/analytic/velic.py index 46605b1e7..f42abed44 100644 --- a/src/underworld3/analytic/velic.py +++ b/src/underworld3/analytic/velic.py @@ -10,22 +10,6 @@ transcription is validated against and as a supported escape hatch (``reference=True``). -.. warning:: - **Under validation — not exported from** :mod:`underworld3.analytic` **yet.** - ``uw.analytic.SolCx`` is still the reference kernel. Two gates are open, both - recorded in ``docs/developer/subsystems/analytic-solutions.md``: - - * the transcription of the ``_solCx_B`` arrangement disagrees with the - published kernel, while ``_solCx_A`` agrees to 1e-14 across every regime - tested (contrast 1e-6 to 1e8, both directions, several ``x_c`` and ``n``). - Since the dispatcher routes :math:`\eta_A < \eta_B` to ``_solCx_B``, and the - ``_solCx_A`` transcription reproduces *that* output, the two arrangements - should be equivalent and one of them is being read wrongly. Unexplained. - * the isoviscous case :math:`\eta_A = \eta_B` returns zero, where the kernel - does not — a degeneracy in the closed form at unit viscosity ratio that the - transcription is not handling. - - Nothing here is used until both are closed. """ import functools @@ -55,17 +39,25 @@ @functools.lru_cache(maxsize=None) -def _solcx_kernel(variant): - r"""Transcribe one conditioning variant of the Velic SolCx kernel. - - The published kernel carries two arrangements of the same solution — - ``_solCx_A`` for :math:`\eta_A > \eta_B` and ``_solCx_B`` for the reverse — - because the integration constants lose precision differently depending on - which side is stiff. Which one applies is decided by the viscosities, so it - is resolved when a solution is constructed, not symbolically. - - Within a variant the only branch left is spatial (:math:`x < x_c`), and that - one becomes a :class:`sympy.Piecewise`. +def _solcx_kernel(variant="_solCx_A"): + r"""Transcribe the Velic SolCx kernel into SymPy. + + The published source carries two arrangements, ``_solCx_A`` and + ``_solCx_B``, and dispatches on :math:`\eta_A > \eta_B`. They are not two + conditionings of one formula: evaluated exactly, ``_solCx_B`` is + ``_solCx_A`` reflected, :math:`B(x, z) = A(1-x, z)`. It solves the mirrored + problem so that the algebra derived for a stiff left column can be reused + when the stiff column is on the right, and undoes the reflection on the way + out. + + So only ``_solCx_A`` is transcribed, and no dispatch is needed. That is + safe because the reason for the dispatch — conditioning — was measured + rather than assumed: this arrangement reproduces the published kernel to + 1e-14 over viscosity ratios from 1e-6 to 1e8 in both directions. See + ``docs/developer/subsystems/analytic-solutions.md``. + + The remaining branch is spatial (:math:`x < x_c`) and becomes a + :class:`sympy.Piecewise`. Returns ------- @@ -173,6 +165,16 @@ def __init__(self, mesh, eta_A=1.0, eta_B=1.0e6, x_c=0.5, n=1, reference=False): if not (float(eta_A) > 0.0 and float(eta_B) > 0.0): raise ValueError("eta_A and eta_B must be positive.") + if float(eta_A) == float(eta_B): + # The closed form carries (ZR - 1) in several denominators, so equal + # viscosities are a removable singularity. SymPy cancels it when the + # expression is evaluated symbolically at a point, but not in the + # compiled form, so this case would silently return nonsense. A + # uniform-viscosity benchmark is a different solution anyway. + raise ValueError( + "SolCx is a viscosity-jump benchmark and is singular at " + "eta_A == eta_B. Use a uniform-viscosity solution instead." + ) if not 0.0 <= float(x_c) <= 1.0: raise ValueError("x_c must lie in [0, 1].") if int(n) != n or int(n) < 1: @@ -185,23 +187,30 @@ def __init__(self, mesh, eta_A=1.0, eta_B=1.0e6, x_c=0.5, n=1, reference=False): x, z = mesh.X + # Exact parameters, not floats. The closed form carries (ZR - 1) in + # several denominators, so at equal viscosities it has a removable + # singularity: substituting exactly lets SymPy cancel it, while + # substituting floats leaves a 0/0 that evaluates to nothing useful. + # Rational() of a float is its exact binary value, so this costs nothing. + eta_A_exact = sympy.Rational(self.eta_A) + eta_B_exact = sympy.Rational(self.eta_B) + # The kernel's own naming: kx is the horizontal wavenumber of the forcing # (fixed at pi), kn the vertical one, ZR the viscosity ratio. values = { - _XC: self.x_c, + _XC: sympy.Rational(self.x_c), _KN: self.n * sympy.pi, _KX: sympy.pi, - _ZA: self.eta_A, - _ZB: self.eta_B, - _ZR: self.eta_B / self.eta_A, + _ZA: eta_A_exact, + _ZB: eta_B_exact, + _ZR: eta_B_exact / eta_A_exact, _X: x, _Z: z, } - variant = "_solCx_A" if self.eta_A > self.eta_B else "_solCx_B" kernel = { field: expression.subs(values) - for field, expression in _solcx_kernel(variant).items() + for field, expression in _solcx_kernel().items() } self.fn_velocity = sympy.Matrix( @@ -233,6 +242,30 @@ def __init__(self, mesh, eta_A=1.0, eta_B=1.0e6, x_c=0.5, n=1, reference=False): if reference: self._use_reference_kernel() + def velocity_error(self, velocity_var): + """Global relative L2 velocity error. Equivalent to ``error("velocity", ...)``.""" + + return self.error("velocity", velocity_var) + + def evaluate_stress(self, coords): + """Exact total (Cauchy) stress at ``coords``, as ``(N, 3)`` columns + :math:`(\\sigma_{xx}, \\sigma_{zz}, \\sigma_{xz})`.""" + + import numpy as np + + components = [self.fn_stress[0, 0], self.fn_stress[1, 1], self.fn_stress[0, 1]] + return np.column_stack( + [np.asarray(self.evaluate(c, coords)).reshape(-1) for c in components] + ) + + def topography_top(self, coords): + """Exact dynamic topography :math:`-\\mathbf n\\cdot\\sigma\\cdot\\mathbf n` + on the top boundary, i.e. :math:`-\\sigma_{zz}`.""" + + import numpy as np + + return -np.asarray(self.evaluate(self.fn_stress[1, 1], coords)).reshape(-1) + def _use_reference_kernel(self): """Replace the transcribed fields with the vendored kernel's own. diff --git a/src/underworld3/function/analytic/__init__.py b/src/underworld3/function/analytic/__init__.py index 7fa0995c3..6835c6639 100644 --- a/src/underworld3/function/analytic/__init__.py +++ b/src/underworld3/function/analytic/__init__.py @@ -65,6 +65,13 @@ def __getattr__(name): ) _warned = True + if name == "SolCx": + # The one class, not a second copy: resolving it anywhere else would + # break isinstance for objects built through the other path. + from underworld3.analytic import SolCx + + return SolCx + from underworld3.analytic._reference import _velic return getattr(_velic, name) diff --git a/tests/test_1019_analytic_transcription.py b/tests/test_1019_analytic_transcription.py new file mode 100644 index 000000000..9d0bb4559 --- /dev/null +++ b/tests/test_1019_analytic_transcription.py @@ -0,0 +1,158 @@ +"""The SolCx transcription, against the kernel it was transcribed from. + +`uw.analytic.SolCx` is SymPy rebuilt from the published Maple-generated C, which +stays vendored alongside it. These tests are the standing version of the +validation gates: the transcription must reproduce its own source across the +parameter range the benchmark is used over, and it must reproduce it where the +solution is hardest — on the viscosity interface, on the walls, at the corners. + +Two traps are pinned here because both cost time to find: + +- a pointwise relative error is the wrong metric. These fields pass through zero, + so dividing by the true value explodes wherever the solution is small and every + case looks catastrophic. Normalise by the field's magnitude over the sample. +- `_solCx_B` in the published source is not a second conditioning of the same + formula, it is the mirror image, `B(x, z) = A(1 - x, z)`. Only `_solCx_A` is + transcribed. `test_arrangements_are_mirror_images` records why. + +Run: pixi run python -m pytest tests/test_1019_analytic_transcription.py -v +""" + +import pytest + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + +import numpy as np +import sympy +import underworld3 as uw + + +# Ratios spanning both directions, wavenumbers, and an off-centre interface. +REGIMES = [ + (1.0, 10.0, 0.5, 1), + (1.0, 1.0e3, 0.5, 1), + (1.0, 1.0e6, 0.5, 1), + (1.0, 1.0e8, 0.5, 1), + (1.0, 1.0e6, 0.5, 3), + (1.0, 1.0e6, 0.75, 1), + (1.0e6, 1.0, 0.5, 1), + (1.0e3, 1.0, 0.25, 2), + (1.0, 1.0e-6, 0.5, 1), +] + + +@pytest.fixture(scope="module") +def mesh(): + return uw.meshing.StructuredQuadBox( + elementRes=(4, 4), minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), qdegree=3 + ) + + +def _sample_points(x_c): + """Stratified, and loaded with the places the solution is hard.""" + + rng = np.random.default_rng(20260802) + points = list(map(tuple, rng.uniform(0.0, 1.0, size=(40, 2)))) + points += [(x_c - 1.0e-9, 0.37), (x_c + 1.0e-9, 0.37), (x_c, 0.37)] # interface + points += [(0.0, 0.5), (1.0, 0.5), (0.31, 0.0), (0.31, 1.0)] # walls + points += [(0.0, 0.0), (1.0, 1.0), (0.0, 1.0), (1.0, 0.0)] # corners + return np.array(points) + + +@pytest.mark.parametrize("eta_A,eta_B,x_c,n", REGIMES) +def test_transcription_reproduces_the_reference_kernel(mesh, eta_A, eta_B, x_c, n): + """Gate 1 and 2: agreement with the published kernel, worst case not average.""" + + from underworld3.analytic._reference import _velic + + sol = uw.analytic.SolCx(mesh, eta_A=eta_A, eta_B=eta_B, x_c=x_c, n=n) + points = _sample_points(x_c) + + fields = { + "velocity_x": (sol.fn_velocity[0, 0], _velic.AnalyticSolCx_velocity_x), + "velocity_z": (sol.fn_velocity[0, 1], _velic.AnalyticSolCx_velocity_y), + "pressure": (sol.fn_pressure, _velic.AnalyticSolCx_pressure), + "stress_xx": (sol.fn_stress[0, 0], _velic.AnalyticSolCx_stress_xx), + "stress_zz": (sol.fn_stress[1, 1], _velic.AnalyticSolCx_stress_yy), + "stress_zx": (sol.fn_stress[0, 1], _velic.AnalyticSolCx_stress_xy), + } + + for name, (expression, kernel) in fields.items(): + mine = np.asarray(sol.evaluate(expression, points)).reshape(-1) + theirs = np.array( + [float(kernel(eta_A, eta_B, x_c, n, x, z).evalf()) for x, z in points] + ) + + # Normalised by the field's magnitude, not pointwise: these fields cross + # zero, and a pointwise ratio would report a huge error for a tiny one. + scale = max(np.max(np.abs(theirs)), 1.0e-300) + worst = np.max(np.abs(mine - theirs)) / scale + + assert worst < 1.0e-10, f"{name}: max relative error {worst:.2e}" + + +def test_arrangements_are_mirror_images(): + r"""`_solCx_B` is `_solCx_A` reflected, not a second conditioning. + + The published source dispatches on eta_A > eta_B, which reads as a + conditioning choice. It is not: the second arrangement solves the mirrored + problem so the algebra for a stiff left column can be reused when the stiff + column is on the right. + + The relation is :math:`A(x, z) = -B(1 - x, z)`. The sign is not a fudge — the + forcing :math:`\cos(\pi x)` is odd about :math:`x = 1/2`, so reflecting the + domain flips the whole solution. + + Only `_solCx_A` is transcribed, so this records the evidence for that + decision — if it ever fails, the dispatch mattered after all and the + reasoning needs revisiting. + """ + + from underworld3.analytic import velic + + values = { + velic._XC: sympy.Rational(1, 2), + velic._KN: sympy.pi, + velic._KX: sympy.pi, + velic._ZA: sympy.Integer(1), + velic._ZB: sympy.Integer(10) ** 6, + velic._ZR: sympy.Integer(10) ** 6, + } + + a = velic._solcx_kernel("_solCx_A")["pressure"].subs(values) + b = velic._solcx_kernel("_solCx_B")["pressure"].subs(values) + + for x, z in ((sympy.Rational(1, 10), sympy.Rational(9, 10)), + (sympy.Rational(1, 4), sympy.Rational(1, 3))): + direct = sympy.N(a.subs({velic._X: x, velic._Z: z}), 30) + mirrored = sympy.N(b.subs({velic._X: 1 - x, velic._Z: z}), 30) + assert abs(direct) > 1.0e-3, "sample point is too near a node to discriminate" + assert abs(direct + mirrored) < 1.0e-25 + + +def test_equal_viscosities_are_refused(mesh): + """A removable singularity the compiled form does not remove is not silently used.""" + + with pytest.raises(ValueError, match="singular at eta_A == eta_B"): + uw.analytic.SolCx(mesh, eta_A=1.0, eta_B=1.0) + + +def test_transcription_is_usable_by_the_solver(mesh): + """The point of the SymPy form: it compiles into a residual. + + The reference kernel cannot do this — it is opaque to the JIT — so this is + what the transcription buys, and it is worth a test of its own. + """ + + sol = uw.analytic.SolCx(mesh, eta_A=1.0, eta_B=1.0e3) + + stokes = uw.systems.Stokes(mesh) + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = sol.fn_viscosity + stokes.bodyforce = sol.fn_bodyforce + + # A Dirichlet value taken straight from the exact velocity: only possible + # because the field is real SymPy rather than a compiled kernel call. + stokes.add_dirichlet_bc(sol.fn_velocity, "Top") + + assert len(stokes.essential_bcs) == 1 From e46e297a881050ebe4a344a76d78f8723439bc8f Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sun, 2 Aug 2026 16:41:55 +1000 Subject: [PATCH 05/28] Validation harness: the checks a transcription must pass, as reusable code The gates were demonstrated ad hoc while transcribing SolCx. This makes them underworld3.analytic._validation, so the remaining eleven solutions get them by calling four functions rather than reinventing them, and so a regression in any of them is a test failure rather than something nobody re-runs. Two of the checks need no reference at all, and those are the strongest here. incompressibility_residual and momentum_residual put the fields back into the equations they claim to solve: div(u) and div(sigma) + f, using the solution's own stress and body force. They catch the failure a convergence test structurally cannot -- if a transcription and the solver share a mistaken convention the solve converges neatly to the wrong answer -- and they are what settles the body-force sign, where UW2's documentation and UW3's convention disagree. Measured on SolCx at contrast 1e3: 3.6e-17 and 2.3e-16. strainrate_consistency sits between comparison and physics. The kernels derive velocity and stress separately, so differentiating one and checking it against the other is a real cross-check, and it exercises the derivatives -- which is what a solver consumes and where a transcription can be wrong while still matching pointwise. 8.5e-16. Gate 5, the negative control, stays in each solution's test rather than the harness, because what counts as a plausible slip is solution-specific. test_the_checks_reject_a_broken_transcription perturbs one velocity coefficient by a part in a thousand and requires both the comparison and the oracle-free residual to report it. Without it the other checks are unfalsified: a check that passes a deliberately broken input is measuring nothing. The harness evaluates through lambdify rather than uw.function.evaluate, and that is not an optimisation. These checks differentiate the fields, and a viscosity-jump solution puts a large Piecewise inside a stress derivative; the JIT path took so long to generate and compile that a three-regime run did not finish in 45 minutes -- the same blow-up already recorded for add_nitsche_bc on SolCx. Lambdified, each check is under half a second. The expressions are pure SymPy in the mesh coordinates so this is exact, not an approximation; the one subtlety is that mesh coordinates cannot be bound as lambdify arguments and must be swapped for plain symbols first. All nine regimes now run every check: 31 tests in 2m22s, worst case 1e-10. Underworld development team with AI support from Claude Code --- .../subsystems/analytic-solutions.md | 38 ++- src/underworld3/analytic/_validation.py | 240 ++++++++++++++++++ tests/test_1019_analytic_transcription.py | 123 ++++++--- 3 files changed, 364 insertions(+), 37 deletions(-) create mode 100644 src/underworld3/analytic/_validation.py diff --git a/docs/developer/subsystems/analytic-solutions.md b/docs/developer/subsystems/analytic-solutions.md index ee14093f3..fcbdb8ae6 100644 --- a/docs/developer/subsystems/analytic-solutions.md +++ b/docs/developer/subsystems/analytic-solutions.md @@ -192,13 +192,37 @@ zero, so the ratio explodes wherever the solution is small — the values were close all along. Normalise by the field's magnitude over the sample. Suspect the metric before the result when every case fails alike. -### Still to do for SolCx - -Gates 3 to 6 (derivatives against the kernel's independently derived strain rate, -the physics residual, the negative control, and the high-precision separation) -are demonstrated ad hoc above but are not yet a harness the test suite runs per -solution. That harness is what makes the remaining eleven transcriptions cheap, -and it should land before them. +### The harness + +`underworld3.analytic._validation` holds the checks, so a new transcription gets +them by calling four functions rather than reinventing them: + +| function | gate | +|---|---| +| `adversarial_points` | 2 — stratified, plus interface, walls and corners | +| `reference_agreement` | 1, 2 — against the kernel, worst case, normalised | +| `incompressibility_residual` | 4 — $\nabla\cdot\mathbf u$, no oracle | +| `momentum_residual` | 4 — $\nabla\cdot\sigma + \mathbf f$, no oracle | +| `strainrate_consistency` | 3 — derivatives, against separately derived output | +| `high_precision_value` | 6 — 50 digits, to separate our error from the kernel's | + +Measured on SolCx at contrast 1e3: incompressibility 3.6e-17, momentum 2.3e-16, +strain rate 8.5e-16 — each in under half a second. + +**Evaluate through `lambdify`, not `uw.function.evaluate`.** The checks +differentiate the fields, and a viscosity-jump solution puts a large `Piecewise` +inside a stress derivative. Routed through the JIT that combination takes so long +to generate and compile that the suite becomes unrunnable — the same blow-up seen +with `add_nitsche_bc` on SolCx. These expressions are pure SymPy in the mesh +coordinates, so `lambdify(..., cse=True)` is both correct and three orders of +magnitude faster. `_validation.sample` does this; note it must first swap the +mesh coordinates for plain symbols, which `lambdify` cannot bind directly. + +Gate 5, the negative control, belongs in each solution's test rather than the +harness: perturb one coefficient and assert the other checks fail. +`test_the_checks_reject_a_broken_transcription` is the pattern — it perturbs the +velocity by a part in a thousand and requires both the comparison and the +oracle-free residual to report it. ## Provenance diff --git a/src/underworld3/analytic/_validation.py b/src/underworld3/analytic/_validation.py new file mode 100644 index 000000000..7df6749b1 --- /dev/null +++ b/src/underworld3/analytic/_validation.py @@ -0,0 +1,240 @@ +r"""Checks a transcribed analytic solution must pass before it is trusted. + +The published kernels are careful; the risk is in our conversion of them. These +are the standing checks that catch a conversion error, and they are deliberately +of two kinds: + +**Against the source.** :func:`reference_agreement` compares the transcription +with the kernel it came from, sampled where the solution is hard rather than +uniformly. + +**Against the physics.** :func:`incompressibility_residual` and +:func:`momentum_residual` put the fields back into the equations they claim to +solve. These need no oracle at all, which is what makes them the strongest +checks here: they catch an error that a comparison would miss because the +comparison and the error share an assumption, and they catch the failure a +convergence test structurally cannot — if a transcription and the solver share a +mistaken sign, the solve converges beautifully to the wrong answer. + +:func:`strainrate_consistency` sits between the two. The kernels return velocity +and stress as separately derived quantities, so differentiating one and +comparing against the other is a genuine cross-check rather than a tautology. + +A check is only worth as much as its ability to fail. Every solution's test +should include a negative control — perturb one coefficient of the transcription +and confirm these report it — because a check that passes a deliberately broken +input is measuring nothing. + +See ``docs/developer/subsystems/analytic-solutions.md``. +""" + +import numpy as np +import sympy + +import underworld3 as uw + + +def adversarial_points(x_c=None, count=40, seed=20260802): + """Sample points that stress a solution rather than flatter it. + + Stratified over the unit box, then loaded with the places these solutions are + hard: either side of a material interface, the walls, and the corners. + + Parameters + ---------- + x_c : float, optional + Position of a vertical material interface to sample across. + count : int + Number of interior points. + seed : int + Fixed, so a failure is reproducible. + + Returns + ------- + numpy.ndarray + Shape ``(N, 2)``. + """ + + rng = np.random.default_rng(seed) + points = list(map(tuple, rng.uniform(0.0, 1.0, size=(count, 2)))) + + if x_c is not None: + points += [(x_c - 1.0e-9, 0.37), (x_c + 1.0e-9, 0.37), (x_c, 0.37)] + + points += [(0.0, 0.5), (1.0, 0.5), (0.31, 0.0), (0.31, 1.0)] + points += [(0.0, 0.0), (1.0, 1.0), (0.0, 1.0), (1.0, 0.0)] + + return np.array(points) + + +def sample(solution, expression, points): + """Evaluate a solution's expression at *points*, without the JIT. + + These expressions are pure SymPy in the mesh coordinates, so they can be + lambdified straight to NumPy. Routing them through + :func:`underworld3.function.evaluate` instead would compile C for each one, + and for a viscosity-jump solution that means a large ``Piecewise`` inside a + stress derivative — a known combination that takes minutes to generate and + build, turning a validation run into something nobody will wait for. + + ``cse=True`` recovers the sharing that back-substitution flattened, so the + generated function is about the size of the original kernel. + """ + + coordinates = tuple(solution.mesh.X) + + # Mesh coordinates are not plain Symbols, and lambdify cannot bind them as + # arguments, so swap in ordinary symbols first. + plain = sympy.symbols(f"_c0:{len(coordinates)}") + expression = sympy.sympify(expression).subs(dict(zip(coordinates, plain))) + + points = np.asarray(points, dtype=float) + values = sympy.lambdify(plain, expression, "numpy", cse=True)( + *(points[:, i] for i in range(len(coordinates))) + ) + + return np.broadcast_to(np.asarray(values, dtype=float), (len(points),)) + + +def _worst_normalised(mine, theirs): + """Largest difference, scaled by the field's own magnitude. + + Not a pointwise relative error: these fields pass through zero, so dividing + by the true value reports a huge error wherever the solution is small and + makes a correct transcription look catastrophic. + """ + + scale = max(float(np.max(np.abs(theirs))), 1.0e-300) + return float(np.max(np.abs(mine - theirs)) / scale) + + +def reference_agreement(solution, fields, points): + """Compare a transcription against the kernel it was transcribed from. + + Parameters + ---------- + solution : AnalyticSolution + The transcription under test. + fields : dict + Name -> ``(sympy expression, callable)``. The callable takes the sample + coordinates and returns the reference value there. + points : numpy.ndarray + Sample coordinates, shape ``(N, 2)``. + + Returns + ------- + dict + Field name -> worst normalised error over the sample. + """ + + worst = {} + for name, (expression, kernel) in fields.items(): + mine = sample(solution, expression, points) + theirs = np.array([float(kernel(x, z)) for x, z in points]) + worst[name] = _worst_normalised(mine, theirs) + + return worst + + +def incompressibility_residual(solution, points): + r""":math:`\max|\nabla\cdot\mathbf u|`, from the transcribed velocity alone. + + Needs no reference. A transcription that has dropped or corrupted a term in + the velocity will generally stop being divergence-free. + """ + + coordinates = solution.mesh.X + velocity = solution.fn_velocity + + divergence = sum( + sympy.diff(velocity[0, i], coordinates[i]) for i in range(solution.mesh.dim) + ) + values = sample(solution, divergence, points) + + return float(np.max(np.abs(values))) + + +def momentum_residual(solution, points): + r"""Largest :math:`|\nabla\cdot\sigma + \mathbf f|`, scaled by the forcing. + + Uses the solution's own total (Cauchy) stress and body force, so it needs no + reference and does not consult the solver. This is the check that catches a + shared mistaken convention: the original SolCx port had the body-force sign + the opposite way round from Underworld2's documentation, and a residual like + this is what settles which is right. + """ + + coordinates = solution.mesh.X + stress = solution.fn_stress + bodyforce = solution.fn_bodyforce + dim = solution.mesh.dim + + scale = 0.0 + worst = 0.0 + for i in range(dim): + residual = bodyforce[0, i] + sum( + sympy.diff(stress[i, j], coordinates[j]) for j in range(dim) + ) + worst = max( + worst, + float(np.max(np.abs(sample(solution, residual, points)))), + ) + forcing = sample(solution, bodyforce[0, i], points) + scale = max(scale, float(np.max(np.abs(forcing)))) + + return worst / max(scale, 1.0e-300) + + +def strainrate_consistency(solution, points): + r"""Compare :math:`\tfrac12(\nabla\mathbf u + \nabla\mathbf u^{T})` with + the solution's own strain rate, scaled by its magnitude. + + Not a tautology: the kernels derive velocity and stress independently, and + the strain rate here comes from the stress and pressure. So this checks two + separately derived quantities against each other, and it exercises the + *derivatives* — which is what a solver consumes, and where a transcription + can be wrong while still matching pointwise. + """ + + coordinates = solution.mesh.X + velocity = solution.fn_velocity + dim = solution.mesh.dim + + worst = 0.0 + scale = 0.0 + for i in range(dim): + for j in range(dim): + from_velocity = ( + sympy.diff(velocity[0, i], coordinates[j]) + + sympy.diff(velocity[0, j], coordinates[i]) + ) / 2 + difference = from_velocity - solution.fn_strainrate[i, j] + + values = sample(solution, difference, points) + reference = sample(solution, solution.fn_strainrate[i, j], points) + + worst = max(worst, float(np.max(np.abs(values)))) + scale = max(scale, float(np.max(np.abs(reference)))) + + return worst / max(scale, 1.0e-300) + + +def high_precision_value(expression, substitutions, digits=50): + """Evaluate an expression at high precision, to separate two kinds of error. + + When a transcription and its source differ near the agreement threshold, the + difference is either ours or the kernel's own double-precision cancellation. + Evaluating the transcription at 50 digits tells them apart — which is what + makes "the generator's grouping is numerically stable" a measured claim + rather than an assumption. + + Parameters + ---------- + expression : sympy expression + substitutions : dict + Values to substitute. Pass exact numbers (``sympy.Rational``, + ``sympy.Integer``); floats defeat the purpose. + digits : int + """ + + return sympy.N(expression.subs(substitutions), digits) diff --git a/tests/test_1019_analytic_transcription.py b/tests/test_1019_analytic_transcription.py index 9d0bb4559..598a04261 100644 --- a/tests/test_1019_analytic_transcription.py +++ b/tests/test_1019_analytic_transcription.py @@ -48,47 +48,110 @@ def mesh(): ) -def _sample_points(x_c): - """Stratified, and loaded with the places the solution is hard.""" +def _reference_fields(sol, eta_A, eta_B, x_c, n): + """The transcribed fields paired with the kernel they came from.""" - rng = np.random.default_rng(20260802) - points = list(map(tuple, rng.uniform(0.0, 1.0, size=(40, 2)))) - points += [(x_c - 1.0e-9, 0.37), (x_c + 1.0e-9, 0.37), (x_c, 0.37)] # interface - points += [(0.0, 0.5), (1.0, 0.5), (0.31, 0.0), (0.31, 1.0)] # walls - points += [(0.0, 0.0), (1.0, 1.0), (0.0, 1.0), (1.0, 0.0)] # corners - return np.array(points) + from underworld3.analytic._reference import _velic + + def at(kernel): + return lambda x, z: kernel(eta_A, eta_B, x_c, n, x, z).evalf() + + return { + "velocity_x": (sol.fn_velocity[0, 0], at(_velic.AnalyticSolCx_velocity_x)), + "velocity_z": (sol.fn_velocity[0, 1], at(_velic.AnalyticSolCx_velocity_y)), + "pressure": (sol.fn_pressure, at(_velic.AnalyticSolCx_pressure)), + "stress_xx": (sol.fn_stress[0, 0], at(_velic.AnalyticSolCx_stress_xx)), + "stress_zz": (sol.fn_stress[1, 1], at(_velic.AnalyticSolCx_stress_yy)), + "stress_zx": (sol.fn_stress[0, 1], at(_velic.AnalyticSolCx_stress_xy)), + } @pytest.mark.parametrize("eta_A,eta_B,x_c,n", REGIMES) def test_transcription_reproduces_the_reference_kernel(mesh, eta_A, eta_B, x_c, n): - """Gate 1 and 2: agreement with the published kernel, worst case not average.""" + """Agreement with the published kernel, worst case rather than average.""" - from underworld3.analytic._reference import _velic + from underworld3.analytic import _validation sol = uw.analytic.SolCx(mesh, eta_A=eta_A, eta_B=eta_B, x_c=x_c, n=n) - points = _sample_points(x_c) - - fields = { - "velocity_x": (sol.fn_velocity[0, 0], _velic.AnalyticSolCx_velocity_x), - "velocity_z": (sol.fn_velocity[0, 1], _velic.AnalyticSolCx_velocity_y), - "pressure": (sol.fn_pressure, _velic.AnalyticSolCx_pressure), - "stress_xx": (sol.fn_stress[0, 0], _velic.AnalyticSolCx_stress_xx), - "stress_zz": (sol.fn_stress[1, 1], _velic.AnalyticSolCx_stress_yy), - "stress_zx": (sol.fn_stress[0, 1], _velic.AnalyticSolCx_stress_xy), - } + points = _validation.adversarial_points(x_c=x_c) + + worst = _validation.reference_agreement( + sol, _reference_fields(sol, eta_A, eta_B, x_c, n), points + ) + + for name, error in worst.items(): + assert error < 1.0e-10, f"{name}: max normalised error {error:.2e}" + + +@pytest.mark.parametrize("eta_A,eta_B,x_c,n", REGIMES) +def test_transcription_satisfies_the_equations(mesh, eta_A, eta_B, x_c, n): + """The fields solve the Stokes problem they claim to — no reference involved. + + This is the check a convergence test cannot make. If a transcription and the + solver shared a mistaken convention, the solve would converge neatly to the + wrong answer; this residual never consults the solver. It is also what + settles the body-force sign, where UW2's documentation and UW3's convention + disagree. + """ + + from underworld3.analytic import _validation + + sol = uw.analytic.SolCx(mesh, eta_A=eta_A, eta_B=eta_B, x_c=x_c, n=n) + points = _validation.adversarial_points(x_c=x_c, count=12) + + assert _validation.incompressibility_residual(sol, points) < 1.0e-10 + assert _validation.momentum_residual(sol, points) < 1.0e-10 + - for name, (expression, kernel) in fields.items(): - mine = np.asarray(sol.evaluate(expression, points)).reshape(-1) - theirs = np.array( - [float(kernel(eta_A, eta_B, x_c, n, x, z).evalf()) for x, z in points] - ) +@pytest.mark.parametrize("eta_A,eta_B,x_c,n", REGIMES) +def test_strain_rate_agrees_with_the_velocity_gradient(mesh, eta_A, eta_B, x_c, n): + """Two independently derived kernel outputs, cross-checked through derivatives. + + A transcription can be right pointwise and wrong in its derivatives — which + is what a solver actually consumes. + """ + + from underworld3.analytic import _validation + + sol = uw.analytic.SolCx(mesh, eta_A=eta_A, eta_B=eta_B, x_c=x_c, n=n) + points = _validation.adversarial_points(x_c=x_c, count=12) + + assert _validation.strainrate_consistency(sol, points) < 1.0e-10 + + +def test_the_checks_reject_a_broken_transcription(mesh): + """Negative control: a check that passes a broken input measures nothing. - # Normalised by the field's magnitude, not pointwise: these fields cross - # zero, and a pointwise ratio would report a huge error for a tiny one. - scale = max(np.max(np.abs(theirs)), 1.0e-300) - worst = np.max(np.abs(mine - theirs)) / scale + One coefficient of the transcribed velocity is perturbed by a part in a + thousand — small enough to be a plausible transcription slip, large enough + that a working check must see it. Both the comparison against the kernel and + the oracle-free residual have to fail. + """ + + from underworld3.analytic import _validation - assert worst < 1.0e-10, f"{name}: max relative error {worst:.2e}" + eta_A, eta_B, x_c, n = 1.0, 1.0e3, 0.5, 1 + sol = uw.analytic.SolCx(mesh, eta_A=eta_A, eta_B=eta_B, x_c=x_c, n=n) + points = _validation.adversarial_points(x_c=x_c, count=12) + + # Intact first: if this did not pass, the control below would prove nothing. + intact = _validation.reference_agreement( + sol, _reference_fields(sol, eta_A, eta_B, x_c, n), points + ) + assert max(intact.values()) < 1.0e-10 + assert _validation.incompressibility_residual(sol, points) < 1.0e-10 + + sol.fn_velocity = sympy.Matrix( + [[sol.fn_velocity[0, 0] * sympy.Rational(1001, 1000), sol.fn_velocity[0, 1]]] + ) + + broken = _validation.reference_agreement( + sol, _reference_fields(sol, eta_A, eta_B, x_c, n), points + ) + assert broken["velocity_x"] > 1.0e-6, "comparison did not see the perturbation" + assert ( + _validation.incompressibility_residual(sol, points) > 1.0e-6 + ), "residual did not see the perturbation" def test_arrangements_are_mirror_images(): From 3583423e47e93108533d3fc2bffc453416cde29c Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sun, 2 Aug 2026 17:03:23 +1000 Subject: [PATCH 06/28] SolNL transcribed, and the reader bug a second kernel exposed SolNL had no convenience class, and three of its six published entry points -- pressure, stress, strain rate -- were compiled but never reachable from Python. It is now a transcribed SymPy solution on the contract, with all six fields, and it is the first nonlinear one: the viscosity depends on the second invariant of the strain rate the solution itself produces, so it exercises a nonlinear solver rather than a linear one. Putting a second kernel through the transcriber was the point, and it found two defects SolCx could not have. The reader took the last identifier before `=` as the assignment target. SolNL writes its results through a struct, `out.x = ...`, which that rule reads as an assignment to `x` -- silently rebinding the coordinate. Every later statement using x then got the velocity component instead. The result was not a crash or an obvious mess: fn_velocity came out as exp(velocity_x)*sin(pi n z), a perfectly plausible-looking expression that happens to be wrong. Targets now keep any struct prefix. SolCx was unaffected because it writes through arrays that the tail truncation already excluded, which is exactly why one validated transcription is not evidence the reader is correct. C statements also wrap freely across lines, and a wrapped Python expression with indented continuations is a syntax error. SolCx's statements happened to be single-line. Expressions are now folded before evaluation. Two smaller additions the kernel needed: functions whose result is returned rather than assigned (evaluate_expression, CSource.returned), and non-void signatures (CSource.function(..., returns=)). Validated the same way as SolCx -- agreement with the published kernel at 1e-12 or better across three parameter sets, and divergence-free with no oracle. Plus one check worth having because it is cheap: the published velocity is short enough to assert outright, which catches a mangled read instantly. Verified: 37 transcription tests in 2m25s; the analytic consumers pass unmodified (25 tests); style gate clean; uw.analytic.available() reports both. Underworld development team with AI support from Claude Code --- src/underworld3/analytic/__init__.py | 4 +- src/underworld3/analytic/_transcribe.py | 46 ++++-- src/underworld3/analytic/velic.py | 175 +++++++++++++++++++++- tests/test_1019_analytic_transcription.py | 65 ++++++++ 4 files changed, 278 insertions(+), 12 deletions(-) diff --git a/src/underworld3/analytic/__init__.py b/src/underworld3/analytic/__init__.py index a7098b502..58bf0dd80 100644 --- a/src/underworld3/analytic/__init__.py +++ b/src/underworld3/analytic/__init__.py @@ -28,13 +28,14 @@ from ._base import AnalyticSolution, FreeSlipWalls, FixedWalls -from .velic import SolCx +from .velic import SolCx, SolNL __all__ = [ "AnalyticSolution", "FreeSlipWalls", "FixedWalls", "SolCx", + "SolNL", "available", "describe", ] @@ -45,6 +46,7 @@ # without being importable. _SOLUTIONS = { "SolCx": SolCx, + "SolNL": SolNL, } diff --git a/src/underworld3/analytic/_transcribe.py b/src/underworld3/analytic/_transcribe.py index 57000e7ff..ac6ee7ca4 100644 --- a/src/underworld3/analytic/_transcribe.py +++ b/src/underworld3/analytic/_transcribe.py @@ -40,7 +40,11 @@ "M_PI": sympy.pi, } -_STATEMENT = re.compile(r"(\w+)\s*=\s*([^;]+);") +# The assignment target keeps any `struct.` prefix. Without it, `out.x = ...` +# reads as an assignment to `x` and silently overwrites the coordinate symbol — +# every later statement referring to x then gets the wrong thing, and the result +# looks plausible rather than broken. +_STATEMENT = re.compile(r"((?:\w+\.)?\w+)\s*=\s*([^;]+);") _FLOAT_LITERAL = re.compile(r"\b\d+\.\d*(?:[eE][+-]?\d+)?") @@ -63,13 +67,17 @@ def _matching_brace(source, opening): index += 1 -def _exact_literals(expression): - """Rewrite C float literals as exact Rationals. +def _as_python(expression): + """Prepare one C expression for evaluation as Python. - ``0.4e1`` is the generator's way of writing 4. Reading it as a float would - make every downstream comparison approximate for no reason. + Two rewrites. Float literals become exact ``Rational``\\s — ``0.4e1`` is the + generator's way of writing 4, and reading it as a float would make every + downstream comparison approximate for no reason. And the statement is folded + onto one line: C statements wrap freely, but a wrapped Python expression with + indented continuations is a syntax error. """ + expression = " ".join(expression.split()) return _FLOAT_LITERAL.sub(lambda m: f"Rational('{m.group(0)}')", expression) @@ -86,13 +94,20 @@ def __init__(self, path): self.path = str(path) self.text = _strip_comments(open(self.path).read()) - def function(self, name): - """The body of ``void (...)``, braces excluded.""" + def function(self, name, returns="void"): + """The body of `` (...)``, braces excluded.""" - signature = self.text.index(f"void {name}(") + signature = self.text.index(f"{returns} {name}(") opening = self.text.index("{", self.text.index(")", signature)) return self.text[opening + 1 : _matching_brace(self.text, opening) - 1] + @staticmethod + def returned(body): + """The expression a body returns, as text.""" + + marker = body.index("return") + return body[marker + len("return") : body.index(";", marker)] + @staticmethod def branches(body, condition, tail_ends_at=None): """Split ``if () { A } else { B }`` into ``(A, B, tail)``. @@ -126,6 +141,19 @@ def branches(body, condition, tail_ends_at=None): return then_block, else_block, tail +def evaluate_expression(text, environment): + """Evaluate a single C expression against names already in scope. + + For kernels that end in ``return ;`` rather than assigning the + result to a variable. + """ + + namespace = {**_C_FUNCTIONS, "Rational": sympy.Rational} + return sympy.sympify( + eval(_as_python(text), {"__builtins__": {}}, {**namespace, **environment}) + ) + + def evaluate_block(block, environment): """Substitute a straight-line block of C assignments into SymPy. @@ -162,7 +190,7 @@ def evaluate_block(block, environment): for target, expression in _STATEMENT.findall(block): value = eval( - _exact_literals(expression), {"__builtins__": {}}, {**namespace, **scope} + _as_python(expression), {"__builtins__": {}}, {**namespace, **scope} ) scope[target] = sympy.sympify(value) diff --git a/src/underworld3/analytic/velic.py b/src/underworld3/analytic/velic.py index f42abed44..306ef52fc 100644 --- a/src/underworld3/analytic/velic.py +++ b/src/underworld3/analytic/velic.py @@ -17,8 +17,8 @@ import sympy -from ._base import AnalyticSolution, FreeSlipWalls -from ._transcribe import CSource, evaluate_block +from ._base import AnalyticSolution, FixedWalls, FreeSlipWalls +from ._transcribe import CSource, evaluate_block, evaluate_expression _REFERENCE_DIR = os.path.join(os.path.dirname(__file__), "_reference") @@ -299,3 +299,174 @@ def _use_reference_kernel(self): ], ] ) + + +_ETA0, _N, _R = sympy.symbols("eta0 n r") + + +@functools.lru_cache(maxsize=None) +def _solnl_kernel(): + r"""Transcribe the Velic SolNL kernel into SymPy. + + Six short functions rather than one branching kernel, so each is read on its + own. The tensor entries are written through a struct (``out.xx = ...``), and + the viscosity is returned rather than assigned — hence the two ways of + reading a body here. + + Returns + ------- + dict + Field name -> expression in the kernel's own symbols. + """ + + source = CSource(os.path.join(_REFERENCE_DIR, "AnalyticSolNL.c")) + inputs = {"eta0": _ETA0, "n": _N, "r": _R, "x": _X, "z": _Z} + + def block(name, signature): + return evaluate_block(source.function(name, returns=signature), inputs) + + velocity = block("SolNL_velocity", "vec2") + bodyforce = block("SolNL_bodyforce", "vec2") + stress = block("SolNL_stress", "tensor2") + strainrate = block("SolNL_strainrate", "tensor2") + pressure = block("SolNL_pressure", "double") + + viscosity_body = source.function("SolNL_viscosity", returns="double") + viscosity = evaluate_expression( + CSource.returned(viscosity_body), evaluate_block(viscosity_body, inputs) + ) + + return { + "velocity_x": velocity["out.x"], + "velocity_z": velocity["out.z"], + "bodyforce_x": bodyforce["out.x"], + "bodyforce_z": bodyforce["out.z"], + "pressure": pressure["p"], + "stress_xx": stress["out.xx"], + "stress_zz": stress["out.zz"], + "stress_xz": stress["out.xz"], + "strainrate_xx": strainrate["out.xx"], + "strainrate_zz": strainrate["out.zz"], + "strainrate_xz": strainrate["out.xz"], + "viscosity": viscosity, + } + + +class SolNL(FixedWalls, AnalyticSolution): + r"""Power-law viscous flow — the SolNL nonlinear benchmark. + + A manufactured solution for a shear-thinning fluid: the viscosity depends on + the second invariant of the strain rate the solution itself produces, + + .. math:: + \eta = \eta_0 \left(\dot\varepsilon_{ij}\dot\varepsilon_{ij}\right)^{1/r - 1} + + so it tests a nonlinear solver rather than a linear one. The velocity is + simple — :math:`\mathbf u = (-k\,e^{x}\cos kz,\; e^{x}\sin kz)`, divergence + free by inspection — and the body force is whatever makes it exact. + + Parameters + ---------- + mesh : Mesh + A 2D mesh on the unit box. + eta_0 : float + Viscosity prefactor. + n : int + Vertical wavenumber. + r : float + Power-law exponent. ``r = 1`` is Newtonian; larger is more + shear-thinning. + reference : bool + Evaluate through the vendored kernel instead of the transcription. + + Notes + ----- + The velocity is not tangential to the walls, so this is posed with the exact + velocity prescribed on the boundary rather than free slip. + """ + + dim = 2 + nonlinear = True + reference = ( + "Velic. Transcribed from the published kernel vendored at " + "underworld3/analytic/_reference/AnalyticSolNL.c." + ) + eqn_velocity = r"(-k e^{x}\cos kz,\; e^{x}\sin kz), \quad k = n\pi" + eqn_viscosity = r"\eta_0 (\dot\varepsilon_{ij}\dot\varepsilon_{ij})^{1/r - 1}" + + def __init__(self, mesh, eta_0=1.0, n=1, r=1.5, reference=False): + super().__init__(mesh) + + if float(eta_0) <= 0.0: + raise ValueError("eta_0 must be positive.") + if int(n) != n or int(n) < 1: + raise ValueError("n (vertical wavenumber) must be a positive integer.") + if float(r) <= 0.0: + raise ValueError("r (power-law exponent) must be positive.") + + self.eta_0 = float(eta_0) + self.n = int(n) + self.r = float(r) + + x, z = mesh.X + values = { + _ETA0: sympy.Rational(self.eta_0), + _N: self.n, + _R: sympy.Rational(self.r), + _X: x, + _Z: z, + } + kernel = { + field: expression.subs(values) + for field, expression in _solnl_kernel().items() + } + + self.fn_velocity = sympy.Matrix( + [[kernel["velocity_x"], kernel["velocity_z"]]] + ) + self.fn_pressure = kernel["pressure"] + self.fn_viscosity = kernel["viscosity"] + self.fn_bodyforce = sympy.Matrix( + [[kernel["bodyforce_x"], kernel["bodyforce_z"]]] + ) + self.fn_stress = sympy.Matrix( + [ + [kernel["stress_xx"], kernel["stress_xz"]], + [kernel["stress_xz"], kernel["stress_zz"]], + ] + ) + self.fn_strainrate = sympy.Matrix( + [ + [kernel["strainrate_xx"], kernel["strainrate_xz"]], + [kernel["strainrate_xz"], kernel["strainrate_zz"]], + ] + ) + + if reference: + self._use_reference_kernel() + + def _use_reference_kernel(self): + """Point-evaluation only: opaque to the JIT, so no solver can use it.""" + + from ._reference import _velic + + x, z = self.mesh.X + parameters = (self.eta_0, self.n, self.r) + + self.fn_velocity = sympy.Matrix( + [ + [ + _velic.AnalyticSolNL_velocity_x(*parameters, x, z), + _velic.AnalyticSolNL_velocity_y(*parameters, x, z), + ] + ] + ) + self.fn_bodyforce = sympy.Matrix( + [ + [ + _velic.AnalyticSolNL_bodyforce_x(*parameters, x, z), + _velic.AnalyticSolNL_bodyforce_y(*parameters, x, z), + ] + ] + ) + self.fn_viscosity = _velic.AnalyticSolNL_viscosity(*parameters, x, z) diff --git a/tests/test_1019_analytic_transcription.py b/tests/test_1019_analytic_transcription.py index 598a04261..57bdb9938 100644 --- a/tests/test_1019_analytic_transcription.py +++ b/tests/test_1019_analytic_transcription.py @@ -219,3 +219,68 @@ def test_transcription_is_usable_by_the_solver(mesh): stokes.add_dirichlet_bc(sol.fn_velocity, "Top") assert len(stokes.essential_bcs) == 1 + + +# --- SolNL ----------------------------------------------------------------- +# +# A second kernel through the same transcriber, which is the point: it caught a +# reader bug SolCx could not. SolNL writes its results through a struct +# (`out.x = ...`), and a target pattern that ignored the prefix read that as an +# assignment to `x`, silently overwriting the coordinate. The velocity still +# looked like a plausible expression. `test_solnl_velocity_is_the_published_form` +# is the cheap guard: the published velocity is short enough to state outright. + + +def test_solnl_velocity_is_the_published_form(mesh): + r"""The exact velocity is :math:`(-k e^{x}\cos kz,\; e^{x}\sin kz)`. + + Short enough to assert directly, which makes it the fastest possible check + that the reader has not mangled the kernel. + """ + + sol = uw.analytic.SolNL(mesh, eta_0=1.0, n=1, r=1.5) + x, z = mesh.X + k = sympy.pi + + assert sympy.simplify(sol.fn_velocity[0, 0] + k * sympy.exp(x) * sympy.cos(k * z)) == 0 + assert sympy.simplify(sol.fn_velocity[0, 1] - sympy.exp(x) * sympy.sin(k * z)) == 0 + + +@pytest.mark.parametrize("eta_0,n,r", [(1.0, 1, 1.5), (2.0, 2, 1.5), (1.0, 1, 3.0)]) +def test_solnl_reproduces_the_reference_kernel(mesh, eta_0, n, r): + from underworld3.analytic import _validation + from underworld3.analytic._reference import _velic + + sol = uw.analytic.SolNL(mesh, eta_0=eta_0, n=n, r=r) + points = _validation.adversarial_points(count=20) + + def at(kernel): + return lambda x, z: kernel(eta_0, n, r, x, z).evalf() + + fields = { + "velocity_x": (sol.fn_velocity[0, 0], at(_velic.AnalyticSolNL_velocity_x)), + "velocity_z": (sol.fn_velocity[0, 1], at(_velic.AnalyticSolNL_velocity_y)), + "bodyforce_z": (sol.fn_bodyforce[0, 1], at(_velic.AnalyticSolNL_bodyforce_y)), + "viscosity": (sol.fn_viscosity, at(_velic.AnalyticSolNL_viscosity)), + } + + for name, error in _validation.reference_agreement(sol, fields, points).items(): + assert error < 1.0e-10, f"{name}: max normalised error {error:.2e}" + + +def test_solnl_velocity_is_divergence_free(mesh): + """No oracle: the published velocity is solenoidal by construction.""" + + from underworld3.analytic import _validation + + sol = uw.analytic.SolNL(mesh, eta_0=1.0, n=2, r=1.5) + points = _validation.adversarial_points(count=20) + + assert _validation.incompressibility_residual(sol, points) < 1.0e-10 + + +def test_solnl_is_marked_nonlinear(mesh): + """The viscosity depends on the solution's own strain rate.""" + + assert uw.analytic.SolNL(mesh).nonlinear is True + assert uw.analytic.SolCx(mesh, eta_A=1.0, eta_B=10.0).nonlinear is False From 32275a937a8cfd1a1fcb78ab1915e97bfb44da0e Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sun, 2 Aug 2026 17:15:17 +1000 Subject: [PATCH 07/28] Schmid & Podladchikov inclusion: potentials derived and verified, not yet exported Groundwork for the elliptical-inclusion benchmark (GJI 155, 269-288). The physics is settled; the representation is not, so nothing is exported and uw.analytic is unchanged. The authors' reference MATLAB publishes pressure, deviatoric stress and the rotation rate but not the velocity, so the Muskhelishvili potentials have to be recovered from what is there. phi comes from the matrix pressure via p = -2 Re[phi'(z)], giving phi'(z) = A/(zeta^2 - 1). That reading is then checked against something independent: the stress expression contains a term that must equal phi''(z) derived from the same phi'. It does, identically -- sympy.simplify of the difference is exactly zero. psi' is the remaining bracket, and psi'(inf) = -BC, the constant far field it should be. The reconstructed velocity is divergence-free to 2e-14 in the matrix, which is the first real evidence the reconstruction is right rather than merely plausible. Two representation problems remain, both recorded in the module docstring. Inverting z = zeta + 1/zeta as sqrt(z**2 - 4) cuts along a ray, so left of the origin it selects the root inside the unit circle -- the wrong sheet -- and the far field comes out asymmetric, about three times the imposed shear at (-50, 20). Writing it sqrt(z-2)*sqrt(z+2) cuts along the segment [-2, 2], which is the slit the map already has, and is correct. But SymPy will not then push re/im through it, and differentiating gives an unevaluated Derivative(re(...)) no code printer can emit. Building the components as (w + conj w)/2 and (w - conj w)/2i sidesteps re/im entirely; untried. The interior velocity is also still missing -- pressure and viscosity are Piecewise across the boundary but the velocity is not, so it is wrong inside. The interior is a uniform velocity gradient, fixed by the interior deviatoric stress and the rotation rate, both already computed here. Both are finishable, and the validation for them is already in place: the momentum and incompressibility residuals need no oracle, so they will confirm or refute the result directly, with the published pressure, interface pressure and rotation rate as three further independent checks. Two SymPy traps found on the way, noted in the code because they cost time. Integrating psi with the numeric constants already substituted puts SymPy in a floating complex polynomial ring where the division algorithm cannot detect zero and integration fails outright; integrating the zeta-shape once with a bare symbol keeps it exact. And the complex expression must be built on real-declared symbols with the mesh coordinates substituted at the end -- mesh coordinates carry no reality assumption, so re/im cannot be distributed through them. Verified: 57 analytic tests still pass, style gate clean, uw.analytic.available() unchanged at SolCx and SolNL. Underworld development team with AI support from Claude Code --- src/underworld3/analytic/inclusion.py | 358 ++++++++++++++++++++++++++ 1 file changed, 358 insertions(+) create mode 100644 src/underworld3/analytic/inclusion.py diff --git a/src/underworld3/analytic/inclusion.py b/src/underworld3/analytic/inclusion.py new file mode 100644 index 000000000..bc968a5f4 --- /dev/null +++ b/src/underworld3/analytic/inclusion.py @@ -0,0 +1,358 @@ +r"""A deformable elliptical inclusion in general shear. + +Schmid, D. W. & Podladchikov, Y. Y. (2003), "Analytical solutions for deformable +elliptical inclusions in general shear", *Geophysical Journal International* +**155**(1), 269–288, doi:10.1046/j.1365-246X.2003.02042.x. + +An ellipse of one viscosity sitting in a matrix of another, with a far-field pure +and/or simple shear. It is the natural test for anything that has to represent a +strong material contrast on a curved interface — a weak inclusion, a clast, a +fault-adjacent lens — because: + +* the velocity and pressure are closed form both **inside and outside**, so a + computed field can be compared point by point rather than only through a + summary like "is the interior strain uniform"; +* it is derived for incompressible viscous flow via Muskhelishvili complex + potentials, so nothing has to be translated from an elastic result; +* there is no restriction on the viscosity ratio. + +There is no body force. The flow is driven entirely by the far field, which makes +this a test of how a solver handles the contrast rather than of how it handles +forcing. + +Provenance +---------- +Transcribed from the authors' reference MATLAB, ``ell_dynamix.m`` and +``ell_rot_rate.m`` in ``github.com/dwschmid/muskhelishvili`` (BSD-3-Clause). + +Those scripts give pressure, deviatoric stress and the rotation rate, but **not +the velocity**, which has to be reconstructed from the potentials. They are +recoverable from what is published, and the reading is checked rather than +assumed — see :func:`_potentials`. + +.. warning:: + **Incomplete — not exported from** :mod:`underworld3.analytic`. + + The physics is settled. The potentials were read out of the published fields + and verified independently (the :math:`\varphi` taken from the pressure + reproduces the :math:`\varphi''` appearing in the stress *identically*), and + the reconstructed velocity is divergence-free to 2e-14 in the matrix. + + What is not settled is how to represent it. Two things remain: + + 1. **Branch and representation conflict.** Inverting :math:`z = \zeta + + 1/\zeta` as ``sqrt(z**2 - 4)`` cuts along a ray, so left of the origin it + picks the root *inside* the unit circle and the far field comes out + asymmetric — measured at ``(-50, 20)`` the speed was about three times + what a unit shear gives. Writing it ``sqrt(z-2)*sqrt(z+2)`` cuts along the + segment :math:`[-2,2]`, which is the slit the map already has, and is the + correct branch; but SymPy will then not push :func:`~sympy.re` and + :func:`~sympy.im` through it, so differentiating the velocity yields an + unevaluated ``Derivative(re(...))`` that no code printer can emit. + Expressing the components as :math:`(w + \bar w)/2` and + :math:`(w - \bar w)/2i` avoids ``re``/``im`` entirely and should + differentiate cleanly, at the cost of complex-typed expressions that + evaluate to real values — untried. + + 2. **The interior velocity is missing.** Pressure and viscosity are + ``Piecewise`` on the inclusion boundary, but the velocity currently + evaluates the matrix expression everywhere, so it is wrong inside. The + interior field is a uniform velocity gradient (the Eshelby property that + makes this benchmark sharp), fixed by the interior deviatoric stress and + :attr:`rotation_rate`, both already available here. + + Once both are done the validation is already built: the momentum and + incompressibility residuals in :mod:`underworld3.analytic._validation` need + no oracle, so they will confirm or refute the reconstruction directly, and + the published pressure, interface pressure and rotation rate give three more + independent checks. +""" + +import functools + +import sympy + +from ._base import AnalyticSolution, FixedWalls + + +@functools.lru_cache(maxsize=None) +def _psi_shape(): + r"""The :math:`\zeta`-dependence of :math:`\psi`, integrated once. + + :math:`\mathrm d\psi/\mathrm d\zeta = \psi'(z)\,\mathrm dz/\mathrm d\zeta` + splits into a far-field part, which integrates by inspection, and + + .. math:: + \int \frac{3\zeta^2 - 1}{\zeta^2 (\zeta^2-1)^2}\,\mathrm d\zeta + + which is a rational function with integer coefficients. Integrating it once + with a bare symbol keeps SymPy in an exact domain; substituting the numeric + constants first puts it in a floating complex polynomial ring, where the + division algorithm cannot detect zero and the integration fails outright. + """ + + zeta = sympy.Symbol("_zeta") + integrand = (3 * zeta**2 - 1) / (zeta**2 * (zeta**2 - 1) ** 2) + + return zeta, sympy.integrate(integrand, zeta) + + +def _shape_ratio(aspect_ratio): + r"""The conformal radius :math:`r_c` for an ellipse of the given aspect ratio. + + The map :math:`z = \zeta + 1/\zeta` takes the circle :math:`|\zeta| = r_c` to + an ellipse with semi-axes :math:`r_c \pm 1/r_c`, so the aspect ratio is + :math:`(r_c^2+1)/(r_c^2-1)`. Inverting that is the relation the reference + MATLAB writes as ``rc = sqrt((t-1)*(t+1))/(t-1)``. + """ + + t = sympy.sympify(aspect_ratio) + return sympy.sqrt((t - 1) * (t + 1)) / (t - 1) + + +def _potentials(zeta, viscosity_ratio, aspect_ratio, alpha, pure_shear, simple_shear): + r"""The Muskhelishvili potentials for the matrix, and the interior constants. + + The published scripts give the fields, not the potentials, so these are read + back out of them: + + * the matrix pressure is :math:`p = -2\,\mathrm{Re}\,\varphi'(z)`, which fixes + :math:`\varphi'(z) = A/(\zeta^2-1)`; + * the matrix stress is :math:`\bar z\,\varphi''(z) + \psi'(z)`, and its first + term must then reproduce :math:`\varphi''` derived from that same + :math:`\varphi'`. + + The second point is a real check rather than a restatement: it is an + independent expression in the source, and it agrees identically. Any error in + reading :math:`\varphi` off the pressure would show up there. + + Returns + ------- + dict + ``phi``, ``phi_prime``, ``psi_prime``, ``psi`` as functions of + :math:`\zeta`, plus the interior pressure and deviatoric stress. + """ + + mc = sympy.sympify(viscosity_ratio) + rc = _shape_ratio(aspect_ratio) + er = sympy.sympify(pure_shear) + gr = sympy.sympify(simple_shear) + + # Far field, as the reference writes it. + BC = (2 * er - sympy.I * gr) * sympy.exp(2 * sympy.I * sympy.sympify(alpha)) + ReBC, ImBC = sympy.re(BC), sympy.im(BC) + + B1 = rc**4 * mc + rc**4 - 1 + mc + B2 = rc**4 * mc + rc**4 - mc + 1 + B3 = rc**4 * mc - mc - rc**4 + 1 + B4 = -(rc**4) * mc - mc - rc**4 + 1 + B5 = rc**8 * mc - mc - rc**8 + 1 + + D = sympy.I * ImBC / B1 - ReBC / B2 + A = -(rc**2) * B3 * D + + phi_prime = A / (zeta**2 - 1) + phi = -A / zeta + + psi_prime = -BC - B5 * D * (3 * zeta**2 - 1) / (zeta**2 - 1) ** 3 + + # psi needs integrating in zeta, since d/dz = (d/dzeta)/(dz/dzeta). The + # far-field part integrates by inspection; the rest is the cached shape. + symbol, shape = _psi_shape() + psi = -BC * (zeta + 1 / zeta) - B5 * D * shape.subs(symbol, zeta) + + # Inside the inclusion both are uniform — this is the Eshelby property, and + # it is what makes the interior a sharp test. + interior_pressure = sympy.re( + -sympy.I * mc * B4 / B1 * gr + + 2 * rc**2 * (mc - 1) * (sympy.I * mc * ImBC / B1 - ReBC / B2) + ) + interior_stress = -2 * mc * rc**4 * (sympy.I * ImBC / B1 + ReBC / B2) + + return { + "phi": phi, + "phi_prime": phi_prime, + "psi_prime": psi_prime, + "psi": psi, + "interior_pressure": interior_pressure, + "interior_stress": interior_stress, + "rc": rc, + } + + +class EllipticalInclusion(FixedWalls, AnalyticSolution): + r"""A viscous elliptical inclusion in a matrix under general shear. + + Parameters + ---------- + mesh : Mesh + A 2D mesh. The inclusion is placed at *centre*, so the domain does not + have to be the unit box. + viscosity_ratio : float + Inclusion viscosity over matrix viscosity. Any positive value; a weak + inclusion is a ratio below one. + aspect_ratio : float + Long axis over short axis, strictly greater than one. Use something like + 1.001 for a near-circular inclusion — exactly one is a degenerate + conformal map. + alpha : float + Angle of the far-field flow relative to the inclusion's long axis, which + lies along *x*. + pure_shear, simple_shear : float + Far-field rates. The reference combines them as + :math:`(2\dot\epsilon - i\dot\gamma)e^{2i\alpha}`. + centre : tuple of float + Position of the inclusion centre. + semi_major : float + Physical length of the long semi-axis. + matrix_viscosity : float + Viscosity of the matrix. Stresses scale with it; the velocity does not. + + Notes + ----- + The inclusion **rotates**, so this is an instantaneous solution: it describes + the flow at one moment, not a history. :attr:`rotation_rate` is the angular + velocity, and it is an independent scalar to check a solve against — it comes + from a separate published expression, not from these fields. + + The velocity is expressed through :func:`sympy.re` and :func:`sympy.im` of a + complex potential. That evaluates and differentiates correctly, which is what + the validation needs, but it has not been exercised through the JIT — so + treat using this as a Dirichlet boundary value in a solver as unverified. + """ + + dim = 2 + reference = ( + "Schmid & Podladchikov (2003), Geophys. J. Int. 155(1), 269-288, " + "doi:10.1046/j.1365-246X.2003.02042.x. Transcribed from the authors' " + "reference MATLAB (github.com/dwschmid/muskhelishvili, BSD-3-Clause)." + ) + eqn_viscosity = r"\mu_c \text{ inside},\quad \mu_m \text{ outside}" + eqn_bodyforce = r"\mathbf 0 \quad(\text{driven by the far field})" + + def __init__( + self, + mesh, + viscosity_ratio=1.0e3, + aspect_ratio=2.0, + alpha=0.0, + pure_shear=0.0, + simple_shear=1.0, + centre=(0.0, 0.0), + semi_major=1.0, + matrix_viscosity=1.0, + ): + super().__init__(mesh) + + if float(aspect_ratio) <= 1.0: + raise ValueError( + "aspect_ratio must exceed 1; the conformal map is degenerate at " + "a circle. Use 1.001 for a near-circular inclusion." + ) + if float(viscosity_ratio) <= 0.0 or float(matrix_viscosity) <= 0.0: + raise ValueError("viscosities must be positive.") + + self.viscosity_ratio = float(viscosity_ratio) + self.aspect_ratio = float(aspect_ratio) + self.alpha = float(alpha) + self.pure_shear = float(pure_shear) + self.simple_shear = float(simple_shear) + self.matrix_viscosity = float(matrix_viscosity) + + rc = _shape_ratio(self.aspect_ratio) + + # Build on real-declared symbols and substitute the mesh coordinates at + # the end. Mesh coordinates carry no reality assumption, so SymPy cannot + # push re/im through a conjugate or a square root of them: the split is + # left symbolic, and differentiating it produces a Derivative(re(...)) + # that no code printer can emit. + u, v = sympy.symbols("_u _v", real=True) + + # The reference solution lives on the unit-focal ellipse, whose long + # semi-axis is rc + 1/rc. Lengths scale, so map the physical point in and + # scale the velocity back out; stress and pressure are scale invariant. + self._scale = sympy.sympify(semi_major) / (rc + 1 / rc) + z = (u + sympy.I * v) / self._scale + + # Invert z = zeta + 1/zeta on the branch outside the unit circle, which + # is the one that maps the matrix to |zeta| > rc. + # + # Written as sqrt(z-2) sqrt(z+2) rather than sqrt(z^2-4): the two agree + # in modulus but not in branch. The single square root cuts along a ray, + # so for z left of the origin it selects the root inside the unit circle + # — the wrong sheet — and the far field comes out asymmetric. The split + # form cuts along the segment [-2, 2], which is the slit the map already + # has, and gives |zeta| > 1 everywhere outside it. + zeta = (z + sympy.sqrt(z - 2) * sympy.sqrt(z + 2)) / 2 + + potentials = _potentials( + zeta, + self.viscosity_ratio, + self.aspect_ratio, + self.alpha, + self.pure_shear, + self.simple_shear, + ) + + # 2 mu (v_x + i v_y) = kappa phi - z conj(phi') - conj(psi), and kappa = 1 + # for an incompressible medium (the elastic 3 - 4 nu at nu = 1/2). + velocity = ( + potentials["phi"] + - z * sympy.conjugate(potentials["phi_prime"]) + - sympy.conjugate(potentials["psi"]) + ) / 2 + + x, y = mesh.X + physical = {u: x - centre[0], v: y - centre[1]} + + inside = sympy.Abs(zeta) < rc + + self.fn_velocity = self._scale * sympy.Matrix( + [ + [ + sympy.re(velocity).subs(physical), + sympy.im(velocity).subs(physical), + ] + ] + ) + self.fn_pressure = sympy.Piecewise( + (potentials["interior_pressure"], inside), + (-2 * sympy.re(potentials["phi_prime"]), True), + ).subs(physical) + self.fn_viscosity = sympy.Piecewise( + (self.matrix_viscosity * self.viscosity_ratio, inside), + (self.matrix_viscosity, True), + ).subs(physical) + self.fn_bodyforce = sympy.Matrix([[0, 0]]) + + self._potentials = potentials + + @property + def rotation_rate(self): + r"""Angular velocity of the inclusion. + + A scalar oracle independent of the fields above: it comes from the + reference's own closed form (``ell_rot_rate.m``), so comparing a computed + interior vorticity against it is a genuine check rather than a + restatement. + """ + + t = self.aspect_ratio + mc = self.viscosity_ratio + alpha = self.alpha + + return float( + ( + -0.5 + * (t**2 - mc * t**2 + mc - 1) + / (mc * t**2 + mc + 2 * t) + * sympy.cos(2 * alpha) + - 0.5 + ) + * self.simple_shear + - 0.5 + * (2 * mc * t**2 - 2 * t**2 - 2 * mc + 2) + / (mc * t**2 + mc + 2 * t) + * sympy.sin(2 * alpha) + * self.pure_shear + ) From c464a459379e050a05876ad1b5b91406e6d8d7d7 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sun, 2 Aug 2026 19:40:13 +1000 Subject: [PATCH 08/28] Schmid & Podladchikov elliptical inclusion, validated uw.analytic.EllipticalInclusion: a viscous ellipse in a matrix under far-field general shear, with closed-form velocity and pressure inside and outside and no restriction on the viscosity ratio. No body force -- the flow is driven entirely by the far field, so it tests how a solver handles a strong contrast on a curved interface rather than how it handles forcing. This one is derived, not transcribed. The authors' MATLAB publishes pressure, stress and the rotation rate but not velocity, so the Muskhelishvili potentials had to be recovered from the fields and the velocity built from those. With no kernel to compare velocity against, the validation is physics and internal consistency: Stokes residual eta lap(v) - grad(p) 1.4e-17 (v reconstructed, p published) incompressibility 1.7e-16 velocity continuity across the interface 1e-5 at a 1e-7 step far field vs the imposed shear few parts in 1e6 (the 1/r^2 tail) interior strain rate uniform 1e-12 The first three are cross-checks between things derived separately, not restatements: the pressure is the published closed form, and the interior field comes from the published interior stress and rotation rate while the exterior comes from the potentials. Two traps, both of which produced a plausible wrong answer rather than an obvious one. A purely imaginary constant in phi' is invisible to the published data -- pressure is -2 Re[phi'] and stress involves phi'' -- but it is a far-field rigid rotation. Omitting it gives a flow with exactly the right strain and no spin, so an imposed simple shear comes back as pure shear at the correct magnitude. Its value came from a different published expression: taken to a circle the rotation rate collapses to -gr/2 for every viscosity ratio. When reading potentials back out of fields, ask what the fields are blind to. Inverting z = zeta + 1/zeta as sqrt(z**2 - 4) cuts along a ray and picks the root inside the unit circle for x < 0 -- the wrong sheet -- making the far field asymmetric, about three times too fast on one side. sqrt(z-2)*sqrt(z+2) cuts on [-2, 2], the slit the map already has. The test samples negative x deliberately; positive-only sampling would have missed it. That correct branch then defeats SymPy's re()/im(), which survive into derivatives as an unprintable Derivative(re(...)). The components are built as (w + conj w)/2 and (w - conj w)/2i instead, with conjugation done by flipping the sign of I -- for an expression in real symbols that is exactly conjugation, and unlike sympy.conjugate it distributes through a square root. Verified against numpy.conj on both sides of the cut. Verified: 16 inclusion tests in 16s; 78 analytic tests overall; style gate clean; uw.analytic.available() now lists EllipticalInclusion, SolCx, SolNL. Underworld development team with AI support from Claude Code --- .../subsystems/analytic-solutions.md | 50 +++++ src/underworld3/analytic/__init__.py | 3 + src/underworld3/analytic/inclusion.py | 142 ++++++++---- tests/test_1020_analytic_inclusion.py | 202 ++++++++++++++++++ 4 files changed, 350 insertions(+), 47 deletions(-) create mode 100644 tests/test_1020_analytic_inclusion.py diff --git a/docs/developer/subsystems/analytic-solutions.md b/docs/developer/subsystems/analytic-solutions.md index fcbdb8ae6..d8b3cd7b3 100644 --- a/docs/developer/subsystems/analytic-solutions.md +++ b/docs/developer/subsystems/analytic-solutions.md @@ -224,6 +224,56 @@ harness: perturb one coefficient and assert the other checks fail. velocity by a part in a thousand and requires both the comparison and the oracle-free residual to report it. +## A solution that is derived rather than transcribed + +`EllipticalInclusion` (Schmid & Podladchikov 2003) is the one case so far where +the published source does **not** contain what we need. The authors' MATLAB gives +pressure, deviatoric stress and the rotation rate; it does not give velocity. So +the Muskhelishvili potentials had to be recovered from the fields, and the +velocity built from those. + +That changes what validation means. There is no kernel to compare velocity +against, so the checks have to come from physics and from internal consistency: + +| check | pairs against | +|---|---| +| $\eta\nabla^2\mathbf v = \nabla p$ | the *published* pressure | +| velocity continuity across the interface | the interior uniform-gradient field | +| far field | the imposed shear, computed independently | +| interior uniformity | the Eshelby property | + +Measured: Stokes residual 1.4e-17, $\nabla\cdot\mathbf v$ 1.7e-16, far field +agreeing to a few parts in $10^6$ (the inclusion's own $1/r^2$ perturbation at +finite distance, not error). + +Two things about this derivation are worth carrying to the next one. + +**What the published data cannot constrain.** A purely imaginary constant in +$\varphi'$ contributes nothing to pressure ($-2\,\mathrm{Re}\,\varphi'$) or to +stress (which involves $\varphi''$). It is a far-field rigid rotation. Reading +the potentials off stress and pressure alone therefore loses the spin entirely, +and an imposed simple shear comes back as pure shear — with the correct strain +magnitude, which is what makes it easy to miss. Its value came from a different +published expression: taken to a circle, the rotation rate collapses to +$-\dot\gamma/2$ for every viscosity ratio. **When reading potentials back out of +fields, ask what the fields are blind to.** + +**Branch cuts are not cosmetic.** Inverting $z = \zeta + 1/\zeta$ as +`sqrt(z**2 - 4)` cuts along a ray and selects the root *inside* the unit circle +for $x < 0$ — the wrong Riemann sheet. The far field then comes out asymmetric, +roughly three times too fast on one side. Written `sqrt(z-2)*sqrt(z+2)` the cut +lies on $[-2, 2]$, the slit the map already has, and $|\zeta| > 1$ everywhere +outside. Sampling only positive $x$ would have missed this, which is why the test +samples both. + +A SymPy consequence: the correct branch defeats `re()`/`im()`, which then survive +into derivatives as an unprintable `Derivative(re(...))`. Building the components +as $(w + \bar w)/2$ and $(w - \bar w)/2i$ avoids them, with `conjugate` obtained +by flipping the sign of `I` — for an expression in real symbols that is exactly +conjugation, and unlike `sympy.conjugate` it distributes through a square root. +The result is real-valued but complex-typed; SymPy cannot prove the imaginary +part vanishes, so callers take `.real`. + ## Provenance Each vendored reference kernel keeps its original copyright header. diff --git a/src/underworld3/analytic/__init__.py b/src/underworld3/analytic/__init__.py index 58bf0dd80..4c442ccbc 100644 --- a/src/underworld3/analytic/__init__.py +++ b/src/underworld3/analytic/__init__.py @@ -28,12 +28,14 @@ from ._base import AnalyticSolution, FreeSlipWalls, FixedWalls +from .inclusion import EllipticalInclusion from .velic import SolCx, SolNL __all__ = [ "AnalyticSolution", "FreeSlipWalls", "FixedWalls", + "EllipticalInclusion", "SolCx", "SolNL", "available", @@ -45,6 +47,7 @@ # contract, and so a solution needing an optional dependency can be listed # without being importable. _SOLUTIONS = { + "EllipticalInclusion": EllipticalInclusion, "SolCx": SolCx, "SolNL": SolNL, } diff --git a/src/underworld3/analytic/inclusion.py b/src/underworld3/analytic/inclusion.py index bc968a5f4..60156096e 100644 --- a/src/underworld3/analytic/inclusion.py +++ b/src/underworld3/analytic/inclusion.py @@ -30,42 +30,6 @@ recoverable from what is published, and the reading is checked rather than assumed — see :func:`_potentials`. -.. warning:: - **Incomplete — not exported from** :mod:`underworld3.analytic`. - - The physics is settled. The potentials were read out of the published fields - and verified independently (the :math:`\varphi` taken from the pressure - reproduces the :math:`\varphi''` appearing in the stress *identically*), and - the reconstructed velocity is divergence-free to 2e-14 in the matrix. - - What is not settled is how to represent it. Two things remain: - - 1. **Branch and representation conflict.** Inverting :math:`z = \zeta + - 1/\zeta` as ``sqrt(z**2 - 4)`` cuts along a ray, so left of the origin it - picks the root *inside* the unit circle and the far field comes out - asymmetric — measured at ``(-50, 20)`` the speed was about three times - what a unit shear gives. Writing it ``sqrt(z-2)*sqrt(z+2)`` cuts along the - segment :math:`[-2,2]`, which is the slit the map already has, and is the - correct branch; but SymPy will then not push :func:`~sympy.re` and - :func:`~sympy.im` through it, so differentiating the velocity yields an - unevaluated ``Derivative(re(...))`` that no code printer can emit. - Expressing the components as :math:`(w + \bar w)/2` and - :math:`(w - \bar w)/2i` avoids ``re``/``im`` entirely and should - differentiate cleanly, at the cost of complex-typed expressions that - evaluate to real values — untried. - - 2. **The interior velocity is missing.** Pressure and viscosity are - ``Piecewise`` on the inclusion boundary, but the velocity currently - evaluates the matrix expression everywhere, so it is wrong inside. The - interior field is a uniform velocity gradient (the Eshelby property that - makes this benchmark sharp), fixed by the interior deviatoric stress and - :attr:`rotation_rate`, both already available here. - - Once both are done the validation is already built: the momentum and - incompressibility residuals in :mod:`underworld3.analytic._validation` need - no oracle, so they will confirm or refute the reconstruction directly, and - the published pressure, interface pressure and rotation rate give three more - independent checks. """ import functools @@ -75,6 +39,23 @@ from ._base import AnalyticSolution, FixedWalls +def _conjugate(expression): + r"""Complex conjugate of an expression built from real symbols and ``I``. + + :func:`sympy.conjugate` will not distribute through ``sqrt`` of a symbolic + argument — it has a branch cut, so SymPy leaves ``conjugate(sqrt(...))`` + unevaluated, and anything built on it can no longer be differentiated into + printable code. + + Flipping the sign of ``I`` does the same job here and does it structurally: + for a function of real variables assembled from real symbols and ``I``, + :math:`\overline{f(z)} = f(\bar z)`, and the sign flip is exactly that. + Verified against :func:`numpy.conj` on both sides of the branch cut. + """ + + return expression.subs(sympy.I, -sympy.I) + + @functools.lru_cache(maxsize=None) def _psi_shape(): r"""The :math:`\zeta`-dependence of :math:`\psi`, integrated once. @@ -151,8 +132,19 @@ def _potentials(zeta, viscosity_ratio, aspect_ratio, alpha, pure_shear, simple_s D = sympy.I * ImBC / B1 - ReBC / B2 A = -(rc**2) * B3 * D - phi_prime = A / (zeta**2 - 1) - phi = -A / zeta + # A purely imaginary constant in phi' is invisible to the published data: + # the pressure is -2 Re[phi'] and the stress involves phi'', so neither sees + # it. It is a far-field rigid rotation, and without it the reconstructed flow + # has the right strain but no spin — a simple shear comes out as pure shear. + # + # Its value is fixed by the reference's own rotation rate: taken to a circle + # the expression collapses to -gr/2 for every viscosity ratio, which is the + # statement that a circular inclusion turns with the far field. Vorticity is + # frame invariant, so alpha does not enter, and pure shear contributes none. + far_field_spin = -gr / 2 + + phi_prime = A / (zeta**2 - 1) + sympy.I * far_field_spin + phi = -A / zeta + sympy.I * far_field_spin * (zeta + 1 / zeta) psi_prime = -BC - B5 * D * (3 * zeta**2 - 1) / (zeta**2 - 1) ** 3 @@ -296,28 +288,54 @@ def __init__( # 2 mu (v_x + i v_y) = kappa phi - z conj(phi') - conj(psi), and kappa = 1 # for an incompressible medium (the elastic 3 - 4 nu at nu = 1/2). - velocity = ( + outside = ( potentials["phi"] - - z * sympy.conjugate(potentials["phi_prime"]) - - sympy.conjugate(potentials["psi"]) + - z * _conjugate(potentials["phi_prime"]) + - _conjugate(potentials["psi"]) ) / 2 - x, y = mesh.X - physical = {u: x - centre[0], v: y - centre[1]} + # Real and imaginary parts without re()/im(), which cannot be pushed + # through the branch cut. These are real-valued but complex-typed: SymPy + # cannot prove the imaginary part vanishes, though it does to roundoff. + outside_x = self._scale * (outside + _conjugate(outside)) / 2 + outside_y = self._scale * (outside - _conjugate(outside)) / (2 * sympy.I) + # Inside, the velocity gradient is uniform — the Eshelby property, and + # what makes this benchmark sharp. It is fixed by the interior deviatoric + # stress and the rotation rate, both already known. + # + # The reference writes stress as (sigma_yy - sigma_xx)/2 + i sigma_xy, so + # with a traceless deviator tau_yy = Re[T], tau_xx = -Re[T], tau_xy = + # Im[T]. Dividing by 2 mu_c gives the strain rate, and mu_m cancels: the + # interior strain rate depends on the viscosity *ratio* only. + T = potentials["interior_stress"] + rate = 2 * self.viscosity_ratio + exx, eyy = -sympy.re(T) / rate, sympy.re(T) / rate + exy = sympy.im(T) / rate + spin = self.rotation_rate + + inside_x = exx * u + (exy - spin) * v + inside_y = (exy + spin) * u + eyy * v + + x, y = mesh.X inside = sympy.Abs(zeta) < rc + physical = {u: x - centre[0], v: y - centre[1]} - self.fn_velocity = self._scale * sympy.Matrix( + self.fn_velocity = sympy.Matrix( [ [ - sympy.re(velocity).subs(physical), - sympy.im(velocity).subs(physical), + sympy.Piecewise((inside_x, inside), (outside_x, True)).subs(physical), + sympy.Piecewise((inside_y, inside), (outside_y, True)).subs(physical), ] ] ) + # p = -2 Re[phi'], written without re() so it can be differentiated. + matrix_pressure = -( + potentials["phi_prime"] + _conjugate(potentials["phi_prime"]) + ) self.fn_pressure = sympy.Piecewise( (potentials["interior_pressure"], inside), - (-2 * sympy.re(potentials["phi_prime"]), True), + (matrix_pressure, True), ).subs(physical) self.fn_viscosity = sympy.Piecewise( (self.matrix_viscosity * self.viscosity_ratio, inside), @@ -325,8 +343,38 @@ def __init__( ).subs(physical) self.fn_bodyforce = sympy.Matrix([[0, 0]]) + # Strain rate and stress follow from the velocity and pressure above. + # They are consistent with those rather than independent of them, so they + # are not a check on the reconstruction — the checks are the Stokes + # residual, velocity continuity across the interface, and the far field. + strain = sympy.Matrix( + [ + [ + ( + sympy.diff(self.fn_velocity[0, i], mesh.X[j]) + + sympy.diff(self.fn_velocity[0, j], mesh.X[i]) + ) + / 2 + for j in range(2) + ] + for i in range(2) + ] + ) + self.fn_strainrate = strain + self.fn_stress = ( + 2 * self.fn_viscosity * strain - self.fn_pressure * sympy.eye(2) + ) + self._potentials = potentials + @property + def semi_axes(self): + """Long and short semi-axes of the inclusion, in physical units.""" + + rc = float(_shape_ratio(self.aspect_ratio)) + scale = float(self._scale) + return scale * (rc + 1 / rc), scale * (rc - 1 / rc) + @property def rotation_rate(self): r"""Angular velocity of the inclusion. diff --git a/tests/test_1020_analytic_inclusion.py b/tests/test_1020_analytic_inclusion.py new file mode 100644 index 000000000..e44c9e30c --- /dev/null +++ b/tests/test_1020_analytic_inclusion.py @@ -0,0 +1,202 @@ +r"""Schmid & Podladchikov's elliptical inclusion. + +The authors publish pressure, stress and the rotation rate but not the velocity, +so the velocity here is reconstructed from the Muskhelishvili potentials. That +makes validation the whole story, and it has to come from somewhere other than +the thing being validated. Four independent sources are used: + +- the **Stokes residual** :math:`\eta\nabla^2\mathbf v = \nabla p`, which pairs + the reconstructed velocity against the *published* pressure; +- **velocity continuity** across the inclusion boundary, which pairs the exterior + reconstruction against the interior uniform-gradient field; +- the **far field**, against the imposed shear computed independently here; +- **interior uniformity**, the Eshelby property the solution must have. + +Two subtleties are pinned because each produced a plausible-looking wrong answer: + +- a purely imaginary constant in :math:`\varphi'` is invisible to pressure and + stress, so the published data cannot constrain it. Omitting it gives a flow + with the correct strain and no spin — a simple shear that comes out as pure + shear. `test_far_field_matches_the_imposed_shear` catches that. +- inverting the conformal map with ``sqrt(z**2 - 4)`` picks the wrong sheet left + of the origin. `test_far_field_matches_the_imposed_shear` samples negative x + for exactly that reason. + +Run: pixi run python -m pytest tests/test_1020_analytic_inclusion.py -v +""" + +import pytest + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + +import numpy as np +import sympy +import underworld3 as uw + + +# Weak and strong inclusions, both shear types, both signs of orientation. +CASES = [ + (1.0e3, 2.0, -np.pi / 6, 0.0, 1.0), + (1.0e-2, 3.0, 0.4, 1.0, 0.5), + (1.0e6, 1.5, 0.0, 1.0, 0.0), + (0.1, 4.0, -1.1, 0.5, 1.0), +] + + +@pytest.fixture(scope="module") +def mesh(): + return uw.meshing.StructuredQuadBox( + elementRes=(4, 4), minCoords=(-3.0, -3.0), maxCoords=(3.0, 3.0), qdegree=3 + ) + + +def _real(mesh, expression): + """Lambdify a field that is real-valued but complex-typed. + + The velocity is built from complex potentials, and SymPy cannot prove the + imaginary part vanishes even though it does to roundoff. + """ + + f = sympy.lambdify(tuple(mesh.X), expression, "numpy") + return lambda x, y: complex(f(x, y)).real + + +def _solution(mesh, viscosity_ratio, aspect_ratio, alpha, pure_shear, simple_shear): + return uw.analytic.EllipticalInclusion( + mesh, + viscosity_ratio=viscosity_ratio, + aspect_ratio=aspect_ratio, + alpha=alpha, + pure_shear=pure_shear, + simple_shear=simple_shear, + semi_major=1.0, + ) + + +@pytest.mark.parametrize("mc,t,alpha,er,gr", CASES) +def test_stokes_residual_vanishes_in_the_matrix(mesh, mc, t, alpha, er, gr): + r"""With no body force, :math:`\eta\nabla^2\mathbf v - \nabla p = 0`. + + The pressure is the published closed form and the velocity is the + reconstruction, so this is a cross-check between two different sources, not a + restatement of one. + """ + + sol = _solution(mesh, mc, t, alpha, er, gr) + x, y = mesh.X + vx, vy, p = sol.fn_velocity[0, 0], sol.fn_velocity[0, 1], sol.fn_pressure + + residual_x = _real(mesh, sympy.diff(vx, x, 2) + sympy.diff(vx, y, 2) - sympy.diff(p, x)) + residual_y = _real(mesh, sympy.diff(vy, x, 2) + sympy.diff(vy, y, 2) - sympy.diff(p, y)) + divergence = _real(mesh, sympy.diff(vx, x) + sympy.diff(vy, y)) + + points = [(2.4, 1.9), (-2.2, 1.3), (1.5, -2.6), (-1.1, -2.9), (0.0, 2.8)] + for px, py in points: + assert abs(residual_x(px, py)) < 1.0e-10 + assert abs(residual_y(px, py)) < 1.0e-10 + assert abs(divergence(px, py)) < 1.0e-10 + + +@pytest.mark.parametrize("mc,t,alpha,er,gr", CASES) +def test_velocity_is_continuous_across_the_interface(mesh, mc, t, alpha, er, gr): + """Interior and exterior are different expressions; they must agree on the boundary. + + Nothing in the construction forces this — the interior comes from the + published interior stress and rotation rate, the exterior from the + potentials — so agreement is real evidence. + """ + + sol = _solution(mesh, mc, t, alpha, er, gr) + vx, vy = _real(mesh, sol.fn_velocity[0, 0]), _real(mesh, sol.fn_velocity[0, 1]) + a, b = sol.semi_axes + + step = 1.0e-7 + for theta in np.linspace(0.1, 2 * np.pi - 0.1, 9): + px, py = a * np.cos(theta), b * np.sin(theta) + radius = np.hypot(px, py) + inner = np.array( + [vx(px * (1 - step / radius), py * (1 - step / radius)), + vy(px * (1 - step / radius), py * (1 - step / radius))] + ) + outer = np.array( + [vx(px * (1 + step / radius), py * (1 + step / radius)), + vy(px * (1 + step / radius), py * (1 + step / radius))] + ) + # The tolerance is set by the finite step across the interface, not by + # the solution: the velocity is continuous but its gradient is not. + assert np.linalg.norm(inner - outer) < 1.0e-5 + + +@pytest.mark.parametrize("mc,t,alpha,er,gr", CASES) +def test_far_field_matches_the_imposed_shear(mesh, mc, t, alpha, er, gr): + r"""Far from the inclusion the flow is the shear that drives it. + + The comparison is computed here from :math:`\alpha`, the shear rates and the + Muskhelishvili far field, independently of the solution object. Negative + :math:`x` is sampled deliberately: that is where the conformal map's branch + goes wrong if the square root is written as a single ``sqrt(z**2 - 4)``. + """ + + sol = _solution(mesh, mc, t, alpha, er, gr) + vx, vy = _real(mesh, sol.fn_velocity[0, 0]), _real(mesh, sol.fn_velocity[0, 1]) + + BC = (2 * er - 1j * gr) * np.exp(2j * alpha) + + for px, py in [(300.0, 220.0), (-400.0, 150.0), (0.0, 500.0), (-250.0, -330.0)]: + z = px + 1j * py + expected = np.conj(BC) * np.conj(z) / 2 + 1j * (-gr / 2) * z + got = np.array([vx(px, py), vy(px, py)]) + want = np.array([expected.real, expected.imag]) + + # The inclusion's own perturbation decays as 1/r^2, so at r ~ 500 with a + # unit inclusion a few parts in 1e6 remain. That is physics, not error. + assert np.linalg.norm(got - want) / np.linalg.norm(want) < 1.0e-4 + + +def test_interior_deformation_is_uniform(mesh): + """The Eshelby property: strain rate is constant inside the inclusion.""" + + sol = _solution(mesh, 1.0e3, 2.0, -np.pi / 6, 0.0, 1.0) + x, y = mesh.X + + exx = _real(mesh, sympy.diff(sol.fn_velocity[0, 0], x)) + exy = _real( + mesh, + (sympy.diff(sol.fn_velocity[0, 0], y) + sympy.diff(sol.fn_velocity[0, 1], x)) / 2, + ) + + interior = [(0.1, 0.05), (-0.3, 0.1), (0.5, -0.15), (0.0, 0.0)] + reference = (exx(*interior[0]), exy(*interior[0])) + for px, py in interior[1:]: + assert abs(exx(px, py) - reference[0]) < 1.0e-12 + assert abs(exy(px, py) - reference[1]) < 1.0e-12 + + +def test_a_circular_inclusion_turns_with_the_far_field(mesh): + """A circle rotates at the far-field vorticity whatever its viscosity. + + This is the identity that fixes the otherwise-unconstrained rotation in the + potentials, so it is worth asserting rather than trusting. + """ + + for viscosity_ratio in (1.0e-3, 1.0, 1.0e6): + sol = _solution(mesh, viscosity_ratio, 1.0001, 0.3, 0.7, 1.0) + assert abs(sol.rotation_rate - (-0.5)) < 1.0e-3 + + +def test_a_circle_exactly_is_refused(mesh): + """Aspect ratio one is a degenerate conformal map, not a usable circle.""" + + with pytest.raises(ValueError, match="aspect_ratio must exceed 1"): + _solution(mesh, 10.0, 1.0, 0.0, 0.0, 1.0) + + +def test_geometry_scales_with_semi_major(mesh): + """Lengths scale; the strain rate does not.""" + + small = uw.analytic.EllipticalInclusion(mesh, aspect_ratio=2.0, semi_major=0.5) + large = uw.analytic.EllipticalInclusion(mesh, aspect_ratio=2.0, semi_major=2.0) + + assert np.isclose(small.semi_axes[0], 0.5) + assert np.isclose(large.semi_axes[0], 2.0) + assert np.isclose(small.semi_axes[0] / small.semi_axes[1], 2.0) From 38d07796130cf61481f345f2d309bfa6171c8357 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Mon, 3 Aug 2026 11:06:37 +1000 Subject: [PATCH 09/28] SolKx: exponentially varying viscosity, validated by the equations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uw.analytic.SolKx — Stokes flow with eta = exp(2Bx) on the unit box, free slip everywhere, forced by (0, sin(m pi z) cos(n pi x)). The companion to SolCx: same geometry and forcing shape, but the viscosity varies smoothly instead of jumping, and the two fail differently. A jump tests how a discretisation copes with a discontinuity inside an element; a gradient tests whether the operator stays conditioned while the contrast builds across every element. Over the unit box the total contrast is exp(2B), so B = 5 already spans four orders. Transcribed from PETSc's copy of the kernel rather than Underworld2's: it is self-contained, returns every field in one call, and is maintained upstream. The source text is vendored at analytic/_reference/solKx.c with its BSD-2 notice, as transcription input rather than built code, and package_data now ships the .c alongside the headers. Validated without an oracle, and that is a deliberate choice rather than a shortcut. The forcing and the boundary conditions are both known, so by uniqueness a field set satisfying Stokes with them IS the solution: |div(sigma) + f| / |f| 2.5e-16 |div(v)| 4.3e-19 |v.n| on all four walls 1.4e-19 The tests found a real footgun. PETSc notes that the kernel admits non-integral m, and the first draft passed that through. But the vertical velocity carries sin(m pi z), which vanishes at z = 1 only for integer m -- so a fractional value still solves the equations while silently ceasing to satisfy free slip on the top wall, and the benchmark quietly becomes a different problem. Every residual check would still pass. m is now required to be a positive integer, with the reason in the error, and the test asserts the refusal. Three small transcriber additions, all mechanical: the PETSc spellings of the maths functions (PetscExpReal and friends) alongside the plain-C ones, C cast stripping since (PetscReal)n is juxtaposition in Python, and array-valued inputs so a kernel that reads its coordinates from pos[] can be bound. One note for the next transcription: these expressions run to tens of thousands of operations, so lambdify once per expression over the whole point set, not once per point. Doing it per point turned a two-minute suite into one that did not finish. Verified: 11 SolKx tests in 2m09s; 77 analytic tests elsewhere still pass; style gate clean; uw.analytic.available() now lists EllipticalInclusion, SolCx, SolKx, SolNL. Underworld development team with AI support from Claude Code --- setup.py | 2 +- src/underworld3/analytic/__init__.py | 4 +- src/underworld3/analytic/_reference/solKx.c | 493 ++++++++++++++++++++ src/underworld3/analytic/_transcribe.py | 27 +- src/underworld3/analytic/velic.py | 152 ++++++ tests/test_1021_analytic_solkx.py | 153 ++++++ 6 files changed, 822 insertions(+), 9 deletions(-) create mode 100644 src/underworld3/analytic/_reference/solKx.c create mode 100644 tests/test_1021_analytic_solkx.py diff --git a/setup.py b/setup.py index f2fc86c3b..0a73a539f 100644 --- a/setup.py +++ b/setup.py @@ -295,7 +295,7 @@ def configure(): # Its own key: the "underworld3" globs above do not reach a directory # that is itself a package. Missing these headers fails at solve time, # when the JIT compiles, not at import. - "underworld3.analytic._reference": ["*.h"], + "underworld3.analytic._reference": ["*.h", "*.c"], }, ext_modules=cythonize( extensions, diff --git a/src/underworld3/analytic/__init__.py b/src/underworld3/analytic/__init__.py index 4c442ccbc..62f8397dc 100644 --- a/src/underworld3/analytic/__init__.py +++ b/src/underworld3/analytic/__init__.py @@ -29,7 +29,7 @@ from ._base import AnalyticSolution, FreeSlipWalls, FixedWalls from .inclusion import EllipticalInclusion -from .velic import SolCx, SolNL +from .velic import SolCx, SolKx, SolNL __all__ = [ "AnalyticSolution", @@ -37,6 +37,7 @@ "FixedWalls", "EllipticalInclusion", "SolCx", + "SolKx", "SolNL", "available", "describe", @@ -49,6 +50,7 @@ _SOLUTIONS = { "EllipticalInclusion": EllipticalInclusion, "SolCx": SolCx, + "SolKx": SolKx, "SolNL": SolNL, } diff --git a/src/underworld3/analytic/_reference/solKx.c b/src/underworld3/analytic/_reference/solKx.c new file mode 100644 index 000000000..10f196229 --- /dev/null +++ b/src/underworld3/analytic/_reference/solKx.c @@ -0,0 +1,493 @@ +/* + Velic SolKx: exact Stokes solution for an exponentially varying viscosity. + + Vendored verbatim from PETSc src/snes/tutorials/ex69.c (SolKxSolution), which + carries the following notice. It is transcription source, not built code — + underworld3.analytic.velic reads it to rebuild the solution in SymPy. + + Copyright (c) 1991-2025, UChicago Argonne, LLC and the PETSc Developers and + Contributors. All rights reserved. Redistribution and use in source and binary + forms, with or without modification, are permitted provided that the above + copyright notice and this list of conditions are retained. THIS SOFTWARE IS + PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR + IMPLIED WARRANTIES ARE DISCLAIMED. (BSD-2-Clause; see the PETSc LICENSE.) + + The domain is the unit square with free slip everywhere. The forcing is + fx = 0, fz = sigma*sin(km*z)*cos(kn*x) with km = m*Pi, kn = n*Pi and sigma = 1, + and the viscosity is eta = exp(2*B*x). +*/ + +static PetscErrorCode SolKxSolution(const PetscReal pos[], PetscReal m, PetscInt n, PetscReal B, PetscScalar vel[], PetscScalar *p, PetscScalar s[], PetscScalar gamma[], PetscScalar *mu) +{ + PetscReal sigma = 1.0; + PetscReal Z; + PetscReal u1, u2, u3, u4, u5, u6; + PetscReal sum1, sum2, sum3, sum4, sum5, sum6; + PetscReal kn, km, x, z; + PetscReal _PC1, _PC2, _PC3, _PC4; + PetscReal Rp, UU, VV; + PetscReal a, b, r, _aa, _bb, AA, BB, Rm; + PetscReal num1, num2, num3, num4, den1; + + PetscReal t1, t2, t3, t4, t5, t6, t7, t8, t9, t10; + PetscReal t11, t12, t13, t14, t15, t16, t17, t18, t19, t20; + PetscReal t21, t22, t23, t24, t25, t26, t27, t28, t29, t30; + PetscReal t31, t32, t33, t34, t35, t36, t37, t38, t39, t40; + PetscReal t41, t42, t43, t44, t45, t46, t47, t49, t51, t53; + PetscReal t56, t58, t61, t62, t63, t64, t65, t66, t67, t68; + PetscReal t69, t70, t71, t72, t73, t74, t75, t76, t77, t78; + PetscReal t79, t80, t81, t82, t83, t84, t85, t86, t87, t88; + PetscReal t89, t90, t91, t92, t93, t94, t95, t96, t97, t99; + PetscReal t100, t101, t103, t104, t105, t106, t107, t108, t109, t110; + PetscReal t111, t112, t113, t114, t115, t116, t117, t118, t119, t120; + PetscReal t121, t124, t125, t126, t127, t129, t130, t132, t133, t135; + PetscReal t136, t138, t140, t141, t142, t143, t152, t160, t162; + + PetscFunctionBegin; + /*************************************************************************/ + /*************************************************************************/ + /* rho = -sin(km*z)*cos(kn*x) */ + x = pos[0]; + z = pos[1]; + Z = PetscExpReal(2.0 * B * x); + km = m * PETSC_PI; /* solution valid for km not zero -- should get trivial solution if km=0 */ + kn = (PetscReal)n * PETSC_PI; + /*************************************************************************/ + /*************************************************************************/ + a = B * B + km * km; + b = 2.0 * km * B; + r = PetscSqrtReal(a * a + b * b); + Rp = PetscSqrtReal((r + a) / 2.0); + Rm = PetscSqrtReal((r - a) / 2.0); + UU = Rp - B; + VV = Rp + B; + + sum1 = 0.0; + sum2 = 0.0; + sum3 = 0.0; + sum4 = 0.0; + sum5 = 0.0; + sum6 = 0.0; + /*sum7=0.0;*/ + + /*******************************************/ + /* calculate the constants */ + /*******************************************/ + + t1 = kn * kn; + t4 = km * km; + t5 = t4 + t1; + t6 = t5 * t5; + t8 = pow(km + kn, 0.2e1); + t9 = B * B; + t16 = pow(km - kn, 0.2e1); + _aa = -0.4e1 * B * t1 * sigma * t5 / (t6 + 0.4e1 * t8 * t9) / (t6 + 0.4e1 * t16 * t9); + + t2 = km * km; + t3 = kn * kn; + t5 = pow(t2 + t3, 0.2e1); + t6 = km - kn; + t7 = km + kn; + t9 = B * B; + t13 = t7 * t7; + t19 = t6 * t6; + _bb = sigma * kn * (t5 + 0.4e1 * t6 * t7 * t9) / (t5 + 0.4e1 * t13 * t9) / (t5 + 0.4e1 * t19 * t9); + + AA = _aa; + BB = _bb; + + /*******************************************/ + /* calculate the velocities etc */ + /*******************************************/ + t1 = Rm * Rm; + t2 = B - Rp; + t4 = Rp + B; + t6 = UU * x; + t9 = PetscExpReal(t6 - 0.4e1 * Rp); + t13 = B * B; + t16 = Rp * t1; + t18 = Rp * Rp; + t19 = B * t18; + t20 = t13 * Rp; + t22 = kn * kn; + t24 = B * t1; + t32 = 0.8e1 * t13 * BB * kn * Rp; + t34 = 0.2e1 * Rm; + t35 = PetscCosReal(t34); + t37 = Rp * Rm; + t49 = PetscSinReal(t34); + t63 = PetscExpReal(t6 - 0.2e1 * Rp); + t65 = Rm * t2; + t67 = 0.2e1 * B * kn; + t68 = B * Rm; + t69 = t67 + t68 + t37; + t73 = 0.3e1 * t13; + t75 = 0.2e1 * B * Rp; + t76 = t73 - t75 + t1 - t22 - t18; + t78 = t65 * t76 * BB; + t80 = Rm - kn; + t81 = PetscCosReal(t80); + t83 = -t67 + t68 + t37; + t88 = Rm + kn; + t89 = PetscCosReal(t88); + t92 = t65 * t76 * AA; + t97 = PetscSinReal(t80); + t103 = PetscSinReal(t88); + t108 = PetscExpReal(t6 - 0.3e1 * Rp - B); + t110 = Rm * t4; + t111 = t67 + t68 - t37; + t115 = t73 + t75 + t1 - t22 - t18; + t117 = t110 * t115 * BB; + t120 = -t67 + t68 - t37; + t127 = t110 * t115 * AA; + t140 = PetscExpReal(t6 - Rp - B); + num1 = -0.4e1 * t1 * t2 * t4 * AA * t9 + ((0.2e1 * Rp * (0.3e1 * t13 * B - 0.2e1 * t16 - t19 - 0.2e1 * t20 - B * t22 - t24) * AA - t32) * t35 + (0.2e1 * t37 * (t1 + 0.5e1 * t13 - t22 - t18) * AA - 0.8e1 * B * BB * kn * Rm * Rp) * t49 - 0.2e1 * B * (0.3e1 * t20 - Rp * t22 - t18 * Rp - 0.2e1 * t19 - t16 - 0.2e1 * t24) * AA + t32) * t63 + ((0.2e1 * t65 * t69 * AA + t78) * t81 + (0.2e1 * t65 * t83 * AA - t78) * t89 + (t92 - 0.2e1 * t65 * t69 * BB) * t97 + (t92 + 0.2e1 * t65 * t83 * BB) * t103) * t108 + ((-0.2e1 * t110 * t111 * AA - t117) * t81 + (-0.2e1 * t110 * t120 * AA + t117) * t89 + (-t127 + 0.2e1 * t110 * t111 * BB) * t97 + (-t127 - 0.2e1 * t110 * t120 * BB) * t103) * t140; + + t1 = Rp + B; + t2 = Rm * t1; + t3 = B * B; + t4 = 0.3e1 * t3; + t5 = B * Rp; + t7 = Rm * Rm; + t8 = kn * kn; + t9 = Rp * Rp; + t10 = t4 + 0.2e1 * t5 + t7 - t8 - t9; + t12 = t2 * t10 * AA; + t14 = B * Rm; + t20 = UU * x; + t23 = PetscExpReal(t20 - 0.4e1 * Rp); + t25 = Rp * Rm; + t32 = Rm * kn; + t37 = 0.2e1 * Rm; + t38 = PetscCosReal(t37); + t40 = t3 * B; + t44 = B * t9; + t45 = t3 * Rp; + t53 = t3 * BB; + t58 = PetscSinReal(t37); + t69 = PetscExpReal(t20 - 0.2e1 * Rp); + t72 = 0.3e1 * t40 * Rm; + t73 = t9 * Rp; + t74 = t73 * Rm; + t75 = t7 * Rm; + t76 = B * t75; + t77 = t14 * t8; + t78 = Rp * t75; + t80 = 0.8e1 * t45 * kn; + t81 = t25 * t8; + t83 = 0.5e1 * t45 * Rm; + t84 = t44 * Rm; + t85 = t72 - t74 + t76 - t77 + t78 + t80 - t81 + t83 + t84; + t88 = 0.2e1 * t9 * t3; + t90 = 0.3e1 * t40 * Rp; + t91 = t7 * t3; + t93 = 0.2e1 * t5 * t32; + t94 = t5 * t7; + t95 = t5 * t8; + t96 = B * t73; + t97 = t7 * t9; + t100 = 0.2e1 * t3 * Rm * kn; + t101 = -t88 + t90 - t91 - t93 - t94 - t95 - t96 - t97 - t100; + t105 = Rm - kn; + t106 = PetscCosReal(t105); + t108 = t72 - t80 + t83 + t76 + t84 - t81 - t74 + t78 - t77; + t110 = -t97 - t96 - t88 + t100 + t90 - t95 + t93 - t91 - t94; + t114 = Rm + kn; + t115 = PetscCosReal(t114); + t121 = PetscSinReal(t105); + t127 = PetscSinReal(t114); + t132 = PetscExpReal(t20 - 0.3e1 * Rp - B); + t135 = 0.2e1 * B * kn; + t136 = t135 + t14 - t25; + t142 = -t135 + t14 - t25; + t152 = t2 * t10 * BB; + t162 = PetscExpReal(t20 - Rp - B); + num2 = (0.2e1 * t12 - 0.8e1 * t14 * kn * t1 * BB) * t23 + ((-0.2e1 * t25 * (t7 + 0.5e1 * t3 - t8 - t9) * AA + 0.8e1 * B * BB * t32 * Rp) * t38 + (0.2e1 * Rp * (0.3e1 * t40 - 0.2e1 * Rp * t7 - t44 - 0.2e1 * t45 - B * t8 - B * t7) * AA - 0.8e1 * t53 * kn * Rp) * t58 - 0.2e1 * t14 * (-t8 + t9 + t4 + t7) * AA + 0.8e1 * t53 * t32) * t69 + ((-t85 * AA - 0.2e1 * t101 * BB) * t106 + (-t108 * AA + 0.2e1 * t110 * BB) * t115 + (-0.2e1 * t101 * AA + t85 * BB) * t121 + (-0.2e1 * t110 * AA - t108 * BB) * t127) * t132 + ((t12 - 0.2e1 * t2 * t136 * BB) * t106 + (t12 + 0.2e1 * t2 * t142 * BB) * t115 + (-0.2e1 * t2 * t136 * AA - t152) * t121 + (-0.2e1 * t2 * t142 * AA + t152) * t127) * t162; + + t1 = Rm * Rm; + t2 = B - Rp; + t4 = Rp + B; + t6 = VV * x; + t7 = PetscExpReal(-t6); + t11 = kn * kn; + t13 = B * t1; + t14 = Rp * Rp; + t15 = B * t14; + t16 = B * B; + t17 = t16 * Rp; + t21 = Rp * t1; + t30 = 0.8e1 * t16 * BB * kn * Rp; + t32 = 0.2e1 * Rm; + t33 = PetscCosReal(t32); + t35 = Rp * Rm; + t47 = PetscSinReal(t32); + t61 = PetscExpReal(-t6 - 0.2e1 * Rp); + t63 = Rm * t2; + t65 = 0.2e1 * B * kn; + t66 = B * Rm; + t67 = t65 + t66 + t35; + t71 = 0.3e1 * t16; + t73 = 0.2e1 * B * Rp; + t74 = t71 - t73 + t1 - t11 - t14; + t76 = t63 * t74 * BB; + t78 = Rm - kn; + t79 = PetscCosReal(t78); + t81 = -t65 + t66 + t35; + t86 = Rm + kn; + t87 = PetscCosReal(t86); + t90 = t63 * t74 * AA; + t95 = PetscSinReal(t78); + t101 = PetscSinReal(t86); + t106 = PetscExpReal(-t6 - 0.3e1 * Rp - B); + t108 = Rm * t4; + t109 = t65 + t66 - t35; + t113 = t71 + t73 + t1 - t11 - t14; + t115 = t108 * t113 * BB; + t118 = -t65 + t66 - t35; + t125 = t108 * t113 * AA; + t138 = PetscExpReal(-t6 - Rp - B); + num3 = -0.4e1 * t1 * t2 * t4 * AA * t7 + ((-0.2e1 * Rp * (-B * t11 - t13 - t15 + 0.2e1 * t17 + 0.3e1 * t16 * B + 0.2e1 * t21) * AA + t30) * t33 + (-0.2e1 * t35 * (t1 + 0.5e1 * t16 - t11 - t14) * AA + 0.8e1 * B * BB * kn * Rm * Rp) * t47 + 0.2e1 * B * (0.3e1 * t17 - t21 + 0.2e1 * t15 + 0.2e1 * t13 - Rp * t11 - t14 * Rp) * AA - t30) * t61 + ((-0.2e1 * t63 * t67 * AA - t76) * t79 + (-0.2e1 * t63 * t81 * AA + t76) * t87 + (-t90 + 0.2e1 * t63 * t67 * BB) * t95 + (-t90 - 0.2e1 * t63 * t81 * BB) * t101) * t106 + ((0.2e1 * t108 * t109 * AA + t115) * t79 + (0.2e1 * t108 * t118 * AA - t115) * t87 + (t125 - 0.2e1 * t108 * t109 * BB) * t95 + (t125 + 0.2e1 * t108 * t118 * BB) * t101) * t138; + + t1 = B - Rp; + t2 = Rm * t1; + t3 = B * B; + t4 = 0.3e1 * t3; + t5 = B * Rp; + t7 = Rm * Rm; + t8 = kn * kn; + t9 = Rp * Rp; + t10 = t4 - 0.2e1 * t5 + t7 - t8 - t9; + t12 = t2 * t10 * AA; + t14 = B * Rm; + t20 = VV * x; + t21 = PetscExpReal(-t20); + t23 = Rp * Rm; + t30 = Rm * kn; + t35 = 0.2e1 * Rm; + t36 = PetscCosReal(t35); + t40 = B * t9; + t41 = t3 * Rp; + t43 = t3 * B; + t51 = t3 * BB; + t56 = PetscSinReal(t35); + t67 = PetscExpReal(-t20 - 0.2e1 * Rp); + t70 = 0.2e1 * B * kn; + t71 = t70 + t14 + t23; + t76 = Rm - kn; + t77 = PetscCosReal(t76); + t79 = -t70 + t14 + t23; + t84 = Rm + kn; + t85 = PetscCosReal(t84); + t91 = t2 * t10 * BB; + t93 = PetscSinReal(t76); + t99 = PetscSinReal(t84); + t104 = PetscExpReal(-t20 - 0.3e1 * Rp - B); + t107 = 0.3e1 * t43 * Rm; + t108 = t9 * Rp; + t109 = t108 * Rm; + t110 = t7 * Rm; + t111 = B * t110; + t112 = t14 * t8; + t113 = Rp * t110; + t115 = 0.8e1 * t41 * kn; + t116 = t23 * t8; + t118 = 0.5e1 * t41 * Rm; + t119 = t40 * Rm; + t120 = t107 + t109 + t111 - t112 - t113 - t115 + t116 - t118 + t119; + t124 = 0.2e1 * t3 * Rm * kn; + t125 = t5 * t8; + t126 = B * t108; + t127 = t7 * t9; + t129 = 0.2e1 * t9 * t3; + t130 = t5 * t7; + t132 = 0.3e1 * t43 * Rp; + t133 = t7 * t3; + t135 = 0.2e1 * t5 * t30; + t136 = t124 - t125 - t126 + t127 + t129 - t130 + t132 + t133 - t135; + t141 = t107 + t115 - t118 + t111 + t119 + t116 + t109 - t113 - t112; + t143 = t132 + t129 - t125 + t133 + t127 - t124 - t130 - t126 + t135; + t160 = PetscExpReal(-t20 - Rp - B); + num4 = (0.2e1 * t12 - 0.8e1 * t14 * kn * t1 * BB) * t21 + ((0.2e1 * t23 * (t7 + 0.5e1 * t3 - t8 - t9) * AA - 0.8e1 * B * BB * t30 * Rp) * t36 + (-0.2e1 * Rp * (-B * t8 - B * t7 - t40 + 0.2e1 * t41 + 0.3e1 * t43 + 0.2e1 * Rp * t7) * AA + 0.8e1 * t51 * kn * Rp) * t56 - 0.2e1 * t14 * (-t8 + t9 + t4 + t7) * AA + 0.8e1 * t51 * t30) * t67 + ((t12 - 0.2e1 * t2 * t71 * BB) * t77 + (t12 + 0.2e1 * t2 * t79 * BB) * t85 + (-0.2e1 * t2 * t71 * AA - t91) * t93 + (-0.2e1 * t2 * t79 * AA + t91) * t99) * t104 + ((-t120 * AA + 0.2e1 * t136 * BB) * t77 + (-t141 * AA - 0.2e1 * t143 * BB) * t85 + (0.2e1 * t136 * AA + t120 * BB) * t93 + (0.2e1 * t143 * AA - t141 * BB) * t99) * t160; + + t1 = Rm * Rm; + t2 = Rp * Rp; + t3 = t1 * t2; + t4 = B * B; + t5 = t1 * t4; + t9 = PetscExpReal(-0.4e1 * Rp); + t15 = PetscCosReal(0.2e1 * Rm); + t22 = PetscExpReal(-0.2e1 * Rp); + den1 = (-0.4e1 * t3 + 0.4e1 * t5) * t9 + ((0.8e1 * t1 + 0.8e1 * t4) * t2 * t15 - 0.8e1 * t5 - 0.8e1 * t2 * t4) * t22 - 0.4e1 * t3 + 0.4e1 * t5; + + _PC1 = num1 / den1; + _PC2 = num2 / den1; + _PC3 = num3 / den1; + _PC4 = num4 / den1; + + t1 = Rm * x; + t2 = PetscCosReal(t1); + t4 = PetscSinReal(t1); + t10 = PetscExpReal(-0.2e1 * x * B); + t12 = kn * x; + t13 = PetscCosReal(t12); + t16 = PetscSinReal(t12); + u1 = -km * (_PC1 * t2 + _PC2 * t4 + _PC3 * t2 + _PC4 * t4 + t10 * AA * t13 + t10 * BB * t16); + + t2 = Rm * x; + t3 = PetscCosReal(t2); + t6 = PetscSinReal(t2); + t22 = PetscExpReal(-0.2e1 * x * B); + t23 = B * t22; + t24 = kn * x; + t25 = PetscCosReal(t24); + t29 = PetscSinReal(t24); + u2 = UU * _PC1 * t3 + UU * _PC2 * t6 - _PC1 * t6 * Rm + _PC2 * t3 * Rm - VV * _PC3 * t3 - VV * _PC4 * t6 - _PC3 * t6 * Rm + _PC4 * t3 * Rm - 0.2e1 * t23 * AA * t25 - 0.2e1 * t23 * BB * t29 - t22 * AA * t29 * kn + t22 * BB * t25 * kn; + + t3 = PetscExpReal(0.2e1 * x * B); + t4 = t3 * B; + t8 = km * km; + t9 = t3 * t8; + t11 = 0.3e1 * t9 * Rm; + t12 = Rm * Rm; + t14 = t3 * t12 * Rm; + t15 = UU * UU; + t19 = 0.4e1 * t4 * UU * Rm - t11 - t14 + 0.3e1 * t3 * t15 * Rm; + t20 = Rm * x; + t21 = PetscSinReal(t20); + t27 = 0.2e1 * B * t9; + t33 = 0.2e1 * t4 * t12; + t36 = 0.3e1 * t3 * UU * t12 - t27 - 0.2e1 * t4 * t15 + 0.3e1 * t9 * UU + t33 - t3 * t15 * UU; + t37 = PetscCosReal(t20); + t49 = VV * VV; + t53 = -0.4e1 * t4 * VV * Rm - t11 + 0.3e1 * t3 * t49 * Rm - t14; + t64 = t3 * t49 * VV + t33 - 0.3e1 * t9 * VV - 0.2e1 * t4 * t49 - t27 - 0.3e1 * t3 * VV * t12; + t76 = B * t8; + t80 = kn * kn; + t83 = B * B; + t87 = t80 * kn; + t90 = kn * x; + t91 = PetscSinReal(t90); + t106 = PetscCosReal(t90); + u3 = -((t19 * t21 + t36 * t37) * _PC1 + (t36 * t21 - t19 * t37) * _PC2 + (t53 * t21 + t64 * t37) * _PC3 + (t64 * t21 - t53 * t37) * _PC4 + (-0.3e1 * t8 * AA * kn - 0.8e1 * t76 * BB - 0.4e1 * BB * B * t80 + 0.4e1 * AA * t83 * kn - AA * t87) * t91 + (-0.4e1 * AA * t80 * B - 0.4e1 * t83 * BB * kn + 0.3e1 * t8 * BB * kn - sigma + BB * t87 - 0.8e1 * t76 * AA) * t106) / km; + + t3 = PetscExpReal(0.2e1 * x * B); + t4 = km * km; + t5 = t3 * t4; + t6 = Rm * x; + t7 = PetscCosReal(t6); + t8 = _PC1 * t7; + t10 = PetscSinReal(t6); + t11 = _PC2 * t10; + t13 = _PC3 * t7; + t15 = _PC4 * t10; + t18 = kn * x; + t19 = PetscCosReal(t18); + t22 = PetscSinReal(t18); + t24 = UU * UU; + t25 = t3 * t24; + t28 = t3 * UU; + t38 = Rm * Rm; + t39 = t7 * t38; + t42 = t10 * t38; + t44 = t5 * t8 + t5 * t11 + t5 * t13 + t5 * t15 + t4 * AA * t19 + t4 * BB * t22 + t25 * t8 + t25 * t11 - 0.2e1 * t28 * _PC1 * t10 * Rm + 0.2e1 * t28 * _PC2 * t7 * Rm - t3 * _PC1 * t39 - t3 * _PC2 * t42; + t45 = VV * VV; + t46 = t3 * t45; + t49 = t3 * VV; + t62 = B * B; + t78 = kn * kn; + t82 = t46 * t13 + t46 * t15 + 0.2e1 * t49 * _PC3 * t10 * Rm - 0.2e1 * t49 * _PC4 * t7 * Rm - t3 * _PC3 * t39 - t3 * _PC4 * t42 + 0.4e1 * t62 * AA * t19 + 0.4e1 * t62 * BB * t22 + 0.4e1 * B * AA * t22 * kn - 0.4e1 * B * BB * t19 * kn - AA * t19 * t78 - BB * t22 * t78; + u4 = t44 + t82; + + t3 = PetscExpReal(0.2e1 * x * B); + t4 = t3 * B; + t8 = km * km; + t9 = t3 * t8; + t10 = t9 * Rm; + t11 = Rm * Rm; + t13 = t3 * t11 * Rm; + t14 = UU * UU; + t18 = 0.4e1 * t4 * UU * Rm - t10 - t13 + 0.3e1 * t3 * t14 * Rm; + t19 = Rm * x; + t20 = PetscSinReal(t19); + t26 = 0.2e1 * B * t9; + t31 = 0.2e1 * t4 * t11; + t34 = 0.3e1 * t3 * UU * t11 - t26 - 0.2e1 * t4 * t14 + t9 * UU + t31 - t3 * t14 * UU; + t35 = PetscCosReal(t19); + t47 = VV * VV; + t51 = -0.4e1 * t4 * VV * Rm - t10 + 0.3e1 * t3 * t47 * Rm - t13; + t61 = t3 * t47 * VV + t31 - t9 * VV - 0.2e1 * t4 * t47 - t26 - 0.3e1 * t3 * VV * t11; + t72 = B * t8; + t76 = kn * kn; + t79 = B * B; + t83 = t76 * kn; + t86 = kn * x; + t87 = PetscSinReal(t86); + t101 = PetscCosReal(t86); + u5 = ((t18 * t20 + t34 * t35) * _PC1 + (t34 * t20 - t18 * t35) * _PC2 + (t51 * t20 + t61 * t35) * _PC3 + (t61 * t20 - t51 * t35) * _PC4 + (-t8 * AA * kn - 0.4e1 * t72 * BB - 0.4e1 * BB * B * t76 + 0.4e1 * AA * t79 * kn - AA * t83) * t87 + (-0.4e1 * AA * t76 * B - 0.4e1 * t79 * BB * kn + t8 * BB * kn - sigma + BB * t83 - 0.4e1 * t72 * AA) * t101) / km; + + t3 = PetscExpReal(0.2e1 * x * B); + t4 = UU * UU; + t8 = km * km; + t9 = t3 * t8; + t10 = t9 * Rm; + t11 = Rm * Rm; + t13 = t3 * t11 * Rm; + t14 = t3 * B; + t18 = 0.3e1 * t3 * t4 * Rm + t10 - t13 + 0.4e1 * t14 * UU * Rm; + t19 = Rm * x; + t20 = PetscSinReal(t19); + t28 = 0.2e1 * B * t9; + t33 = 0.2e1 * t14 * t11; + t34 = -0.2e1 * t4 * t14 + 0.3e1 * t3 * UU * t11 - t28 - t3 * t4 * UU - t9 * UU + t33; + t35 = PetscCosReal(t19); + t47 = VV * VV; + t51 = -0.4e1 * t14 * VV * Rm - t13 + t10 + 0.3e1 * t3 * t47 * Rm; + t61 = -0.3e1 * t3 * VV * t11 + t33 + t3 * t47 * VV + t9 * VV - 0.2e1 * t14 * t47 - t28; + t71 = kn * kn; + t74 = B * B; + t80 = t71 * kn; + t83 = kn * x; + t84 = PetscSinReal(t83); + t96 = PetscCosReal(t83); + u6 = -((t18 * t20 + t34 * t35) * _PC1 + (t34 * t20 - t18 * t35) * _PC2 + (t51 * t20 + t61 * t35) * _PC3 + (t61 * t20 - t51 * t35) * _PC4 + (-0.4e1 * BB * B * t71 + 0.4e1 * AA * t74 * kn + t8 * AA * kn - AA * t80) * t84 + (-0.4e1 * AA * t71 * B - t8 * BB * kn - 0.4e1 * t74 * BB * kn - sigma + BB * t80) * t96) / km; + + /* SS = sin(km*z)*(exp(UU*x)*(_PC1*cos(Rm*x)+_PC2*sin(Rm*x)) + exp(-VV*x)*(_PC3*cos(Rm*x)+_PC4*sin(Rm*x)) + exp(-2*x*B)*(AA*cos(kn*x)+BB*sin(kn*x))); */ + + /* u1 = Vx, u2 = Vz, u3 = txx, u4 = tzx, u5 = pressure, u6 = tzz */ + + sum5 += u5 * PetscCosReal(km * z); /* pressure */ + sum6 += u6 * PetscCosReal(km * z); /* zz total stress */ + + u1 *= PetscCosReal(km * z); /* x velocity */ + sum1 += u1; + u2 *= PetscSinReal(km * z); /* z velocity */ + sum2 += u2; + + u3 *= PetscCosReal(km * z); /* xx total stress */ + sum3 += u3; + u4 *= PetscSinReal(km * z); /* zx stress */ + sum4 += u4; + + /* rho = -sigma*sin(km*z)*cos(kn*x); */ /* density */ + /* sum7 += rho; */ + + /* Output */ + if (mu) *mu = Z; + if (vel) { + vel[0] = sum1; + vel[1] = sum2; + } + if (p) (*p) = sum5; + if (s) { + s[0] = sum3; + s[1] = sum4; + s[2] = sum6; + } + if (gamma) { + /* sigma = tau - p, tau = sigma + p, tau[] = 2*eta*gamma[] */ + gamma[0] = (sum3 + sum5) / (2.0 * Z); + gamma[1] = (sum4) / (2.0 * Z); + gamma[2] = (sum6 + sum5) / (2.0 * Z); + } + PetscFunctionReturn(PETSC_SUCCESS); +} diff --git a/src/underworld3/analytic/_transcribe.py b/src/underworld3/analytic/_transcribe.py index ac6ee7ca4..759fe666e 100644 --- a/src/underworld3/analytic/_transcribe.py +++ b/src/underworld3/analytic/_transcribe.py @@ -30,7 +30,8 @@ import sympy -# The only functions the Velic kernels call. +# The only functions these kernels call, in both the plain-C and PETSc spellings +# — the same solutions are published in both forms. _C_FUNCTIONS = { "exp": sympy.exp, "sin": sympy.sin, @@ -38,8 +39,19 @@ "sqrt": sympy.sqrt, "pow": lambda base, exponent: base**exponent, "M_PI": sympy.pi, + "PetscExpReal": sympy.exp, + "PetscSinReal": sympy.sin, + "PetscCosReal": sympy.cos, + "PetscSqrtReal": sympy.sqrt, + "PetscPowReal": lambda base, exponent: base**exponent, + "PETSC_PI": sympy.pi, } +# C casts, which are juxtaposition in Python and so a syntax error. +_CAST = re.compile( + r"\(\s*(?:PetscReal|PetscScalar|PetscInt|double|float|int|unsigned)\s*\)\s*" +) + # The assignment target keeps any `struct.` prefix. Without it, `out.x = ...` # reads as an assignment to `x` and silently overwrites the coordinate symbol — # every later statement referring to x then gets the wrong thing, and the result @@ -70,14 +82,15 @@ def _matching_brace(source, opening): def _as_python(expression): """Prepare one C expression for evaluation as Python. - Two rewrites. Float literals become exact ``Rational``\\s — ``0.4e1`` is the - generator's way of writing 4, and reading it as a float would make every - downstream comparison approximate for no reason. And the statement is folded - onto one line: C statements wrap freely, but a wrapped Python expression with - indented continuations is a syntax error. + Three rewrites. Float literals become exact ``Rational``\\s — ``0.4e1`` is + the generator's way of writing 4, and reading it as a float would make every + downstream comparison approximate for no reason. The statement is folded onto + one line, since C wraps freely but a wrapped Python expression with indented + continuations is a syntax error. And C casts are dropped: ``(PetscReal)n`` is + juxtaposition in Python, which does not parse. """ - expression = " ".join(expression.split()) + expression = _CAST.sub("", " ".join(expression.split())) return _FLOAT_LITERAL.sub(lambda m: f"Rational('{m.group(0)}')", expression) diff --git a/src/underworld3/analytic/velic.py b/src/underworld3/analytic/velic.py index 306ef52fc..eda95d5e2 100644 --- a/src/underworld3/analytic/velic.py +++ b/src/underworld3/analytic/velic.py @@ -470,3 +470,155 @@ def _use_reference_kernel(self): ] ) self.fn_viscosity = _velic.AnalyticSolNL_viscosity(*parameters, x, z) + + +_B, _M = sympy.symbols("B m") + +# The kernel leaves the fields in u1..u6, each still to be multiplied by its +# vertical mode. Same convention as SolCx, and the source says so in a comment: +# "u1 = Vx, u2 = Vz, u3 = txx, u4 = tzx, u5 = pressure, u6 = tzz". +_SOLKX_OUTPUTS = { + "velocity_x": ("u1", sympy.cos), + "velocity_z": ("u2", sympy.sin), + "stress_xx": ("u3", sympy.cos), + "stress_zx": ("u4", sympy.sin), + "pressure": ("u5", sympy.cos), + "stress_zz": ("u6", sympy.cos), +} + + +@functools.lru_cache(maxsize=None) +def _solkx_kernel(): + r"""Transcribe the Velic SolKx kernel into SymPy. + + One straight-line block, no branches — the exponential viscosity has no + interface to split on, so unlike SolCx there is no ``Piecewise`` here. + + Returns + ------- + dict + Field name -> expression in the kernel's own symbols. + """ + + source = CSource(os.path.join(_REFERENCE_DIR, "solKx.c")) + body = source.function("SolKxSolution", returns="static PetscErrorCode") + + # Stop at the output section (comments are already stripped, so anchor on + # code): it accumulates with `+=` and writes + # through pointers, neither of which this reader interprets. + body = body[: body.index("if (mu)")] + + # The kernel takes its coordinates from an array, so `pos` is bound to one. + inputs = {"pos": (_X, _Z), "B": _B, "m": _M, "n": _N} + scope = evaluate_block(body, inputs) + + km = _M * sympy.pi + return { + field: scope[symbol] * mode(km * _Z) + for field, (symbol, mode) in _SOLKX_OUTPUTS.items() + } + + +class SolKx(FreeSlipWalls, AnalyticSolution): + r"""Stokes flow with an exponentially varying viscosity — the SolKx benchmark. + + Viscosity :math:`\eta = e^{2Bx}` on the unit box, driven by the density + forcing :math:`\mathbf f = (0,\; \sin(m\pi z)\cos(n\pi x))`, free slip on all + four walls. + + The companion to SolCx: same geometry and forcing, but the viscosity varies + *smoothly* rather than jumping. A solver can do well on one and badly on the + other — a jump tests how the discretisation handles a discontinuity, a + gradient tests whether the operator stays well conditioned as the contrast + builds across every element. Over the unit box the total contrast is + :math:`e^{2B}`, so ``B = 5`` already spans four orders of magnitude. + + Parameters + ---------- + mesh : Mesh + A 2D mesh on the unit box. + B : float + Viscosity exponent. + n : int + Horizontal wavenumber of the forcing. + m : int + Vertical wavenumber. + + Examples + -------- + >>> sol = uw.analytic.SolKx(mesh, B=2.302585, n=3, m=2.0) + >>> stokes.constitutive_model.Parameters.shear_viscosity_0 = sol.fn_viscosity + >>> stokes.bodyforce = sol.fn_bodyforce + >>> sol.apply_boundary_conditions(stokes) + + Notes + ----- + Transcribed from PETSc's copy of the kernel rather than Underworld2's: it is + self-contained, returns every field in one call, and is actively maintained + upstream. + + Validated by the equations rather than against a compiled kernel. The forcing + and boundary conditions are known, and a field set that satisfies Stokes with + them is *the* solution by uniqueness — so the momentum and incompressibility + residuals settle it without needing an oracle. + """ + + dim = 2 + reference = ( + "Velic; transcribed from PETSc src/snes/tutorials/ex69.c (SolKxSolution), " + "vendored at underworld3/analytic/_reference/solKx.c (BSD-2-Clause)." + ) + eqn_viscosity = r"e^{2Bx}" + eqn_bodyforce = r"(0,\; \sin(m \pi z)\cos(n \pi x))" + + def __init__(self, mesh, B=2.302585092994046, n=3, m=2): + super().__init__(mesh) + + if int(n) != n or int(n) < 1: + raise ValueError("n (horizontal wavenumber) must be a positive integer.") + if int(m) != m or int(m) < 1: + # The kernel itself allows non-integral m, and PETSc says so. But the + # vertical velocity carries sin(m pi z), which vanishes at z = 1 only + # for integer m — so a fractional value silently stops satisfying free + # slip on the top wall while still solving the equations, and the + # benchmark quietly becomes a different problem. + raise ValueError( + "m (vertical wavenumber) must be a positive integer: the free-slip " + "condition on the top wall requires sin(m*pi) = 0." + ) + + self.B = float(B) + self.n = int(n) + self.m = float(m) + + x, z = mesh.X + values = { + _B: sympy.Rational(self.B), + _N: self.n, + _M: sympy.Rational(self.m), + _X: x, + _Z: z, + } + kernel = { + field: expression.subs(values) + for field, expression in _solkx_kernel().items() + } + + self.fn_velocity = sympy.Matrix( + [[kernel["velocity_x"], kernel["velocity_z"]]] + ) + self.fn_pressure = kernel["pressure"] + self.fn_stress = sympy.Matrix( + [ + [kernel["stress_xx"], kernel["stress_zx"]], + [kernel["stress_zx"], kernel["stress_zz"]], + ] + ) + self.fn_viscosity = sympy.exp(2 * sympy.Rational(self.B) * x) + self.fn_bodyforce = sympy.Matrix( + [[0, sympy.sin(sympy.Rational(self.m) * sympy.pi * z) + * sympy.cos(self.n * sympy.pi * x)]] + ) + self.fn_strainrate = ( + self.fn_stress + self.fn_pressure * sympy.eye(2) + ) / (2 * self.fn_viscosity) diff --git a/tests/test_1021_analytic_solkx.py b/tests/test_1021_analytic_solkx.py new file mode 100644 index 000000000..31eeaabc0 --- /dev/null +++ b/tests/test_1021_analytic_solkx.py @@ -0,0 +1,153 @@ +r"""SolKx — Stokes flow with an exponentially varying viscosity. + +The companion to SolCx: same box, same forcing shape, but the viscosity varies +*smoothly* as :math:`e^{2Bx}` instead of jumping. The two fail differently. A +jump tests how a discretisation copes with a discontinuity inside an element; a +gradient tests whether the operator stays well conditioned while the contrast +builds across every element in the domain. + +Validated by the equations rather than against a compiled kernel. The forcing and +the boundary conditions are both known, so a field set that satisfies Stokes with +them is *the* solution by uniqueness — the momentum and incompressibility +residuals settle it without an oracle, and free slip is checked directly. + +Run: pixi run python -m pytest tests/test_1021_analytic_solkx.py -v +""" + +import pytest + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + +import numpy as np +import sympy +import underworld3 as uw + + +# B = 2.3026 is a decade of viscosity contrast per unit length, so e^2B ~ 100 +# across the box; B = 5 is four orders. Both wavenumbers, integer and not. +CASES = [ + (2.302585092994046, 3, 2), + (2.302585092994046, 1, 1), + (5.0, 2, 3), + (1.0, 4, 2), +] + + +@pytest.fixture(scope="module") +def mesh(): + return uw.meshing.StructuredQuadBox( + elementRes=(4, 4), minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), qdegree=3 + ) + + +# Interior points, and points on each wall for the free-slip check. +INTERIOR = np.array([(0.2, 0.3), (0.7, 0.8), (0.5, 0.5), (0.9, 0.15), (0.05, 0.95)]) +LEFT = np.array([(0.0, t) for t in (0.13, 0.47, 0.82)]) +RIGHT = np.array([(1.0, t) for t in (0.13, 0.47, 0.82)]) +BOTTOM = np.array([(t, 0.0) for t in (0.13, 0.47, 0.82)]) +TOP = np.array([(t, 1.0) for t in (0.13, 0.47, 0.82)]) + + +def _at(sol, expression, points): + """Values of an expression of the mesh coordinates, over a whole point set. + + Goes through the validation harness, which swaps the mesh coordinates for + plain symbols first — lambdify cannot bind mesh coordinates as arguments. + + Evaluated for all points in one call on purpose: these expressions run to + tens of thousands of operations, and lambdifying one per point turns a + two-minute suite into an unrunnable one. + """ + + from underworld3.analytic import _validation + + return np.abs(_validation.sample(sol, expression, points)) + + +@pytest.mark.parametrize("B,n,m", CASES) +def test_solkx_satisfies_the_stokes_equations(mesh, B, n, m): + r"""":math:`\nabla\cdot\sigma + \mathbf f = 0` and :math:`\nabla\cdot\mathbf v = 0`. + + The residual is normalised by the forcing, so the tolerance means what it + says regardless of how strong the driving is. + """ + + sol = uw.analytic.SolKx(mesh, B=B, n=n, m=m) + x, z = mesh.X + + scale = _at(sol, sol.fn_bodyforce[0, 1], INTERIOR).max() + + residual_x = _at( + sol, sympy.diff(sol.fn_stress[0, 0], x) + sympy.diff(sol.fn_stress[0, 1], z), INTERIOR + ) + residual_z = _at( + sol, + sympy.diff(sol.fn_stress[1, 0], x) + + sympy.diff(sol.fn_stress[1, 1], z) + + sol.fn_bodyforce[0, 1], + INTERIOR, + ) + divergence = _at( + sol, + sympy.diff(sol.fn_velocity[0, 0], x) + sympy.diff(sol.fn_velocity[0, 1], z), + INTERIOR, + ) + + assert residual_x.max() / scale < 1.0e-10 + assert residual_z.max() / scale < 1.0e-10 + assert divergence.max() < 1.0e-10 + + +@pytest.mark.parametrize("B,n,m", CASES) +def test_solkx_is_free_slip_on_every_wall(mesh, B, n, m): + """The solution is posed with free slip, so the normal velocity must vanish. + + Together with the Stokes residual this pins the solution uniquely, which is + what makes an oracle unnecessary here. + """ + + sol = uw.analytic.SolKx(mesh, B=B, n=n, m=m) + vx, vz = sol.fn_velocity[0, 0], sol.fn_velocity[0, 1] + + assert _at(sol, vx, LEFT).max() < 1.0e-10 + assert _at(sol, vx, RIGHT).max() < 1.0e-10 + assert _at(sol, vz, BOTTOM).max() < 1.0e-10 + assert _at(sol, vz, TOP).max() < 1.0e-10 + + +def test_solkx_viscosity_is_the_exponential_it_claims(mesh): + r""":math:`\eta = e^{2Bx}`, and the stress is consistent with it.""" + + B = 2.0 + sol = uw.analytic.SolKx(mesh, B=B, n=2, m=1) + x, z = mesh.X + + where = np.array([(px, 0.5) for px in (0.0, 0.3, 1.0)]) + assert np.allclose(_at(sol, sol.fn_viscosity, where), np.exp(2 * B * where[:, 0])) + + # sigma = -p I + 2 eta edot, so the deviator recovered from the stress and + # the strain rate computed from the velocity must agree. + exz = ( + sympy.diff(sol.fn_velocity[0, 0], z) + sympy.diff(sol.fn_velocity[0, 1], x) + ) / 2 + points = np.array([(0.25, 0.4), (0.6, 0.7), (0.85, 0.2)]) + difference = _at(sol, sol.fn_stress[0, 1] - 2 * sol.fn_viscosity * exz, points) + scale = _at(sol, sol.fn_stress[0, 1], points).max() + + assert difference.max() / scale < 1.0e-10 + + +def test_solkx_rejects_a_fractional_vertical_wavenumber(mesh): + """A fractional m breaks free slip on the top wall while still solving Stokes. + + That is the dangerous kind of wrong — the residual checks would all pass and + the benchmark would quietly be a different problem — so it is refused. + """ + + with pytest.raises(ValueError, match="sin\\(m\\*pi\\) = 0"): + uw.analytic.SolKx(mesh, m=1.5) + + +def test_solkx_is_registered(mesh): + assert "SolKx" in uw.analytic.available() + assert uw.analytic.SolKx(mesh).nonlinear is False From a2130b17d2d2c25b7d36d9be4c591eadba7635f8 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Mon, 3 Aug 2026 11:35:02 +1000 Subject: [PATCH 10/28] SolDB2d and SolDB3d: polynomial manufactured solutions, and the first 3D one Two Dohrmann-Bochev solutions transcribed from Underworld2's headers. SolDB2d is isoviscous; SolDB3d (Burstedde et al. 2013) carries a smooth viscosity peaked in the interior, exp(1 - beta[x(1-x)+y(1-y)+z(1-z)]), and is the suite's first 3D solution. That 3D gap mattered. Several parts of a Stokes discretisation genuinely differ between two and three dimensions -- the pressure space, the null space, the tensor assembly -- and no 2D benchmark can see a term that is wrong only in the third. SolDB3d also varies its viscosity in every direction at once, which none of the others do. These are the easiest solutions here to be sure of. The fields are short enough that div(v) and div(sigma) + f reduce SYMBOLICALLY to zero rather than to something small, so the tests assert exact equality: no sampling, no tolerance, no conditioning question. One convention trap, now pinned by a test. Unlike SolCx and SolKx, these kernels publish the DEVIATORIC stress rather than the total, so the pressure has to go back in as sigma = tau - p I. Reading the deviator as the total would leave the momentum residual wrong by exactly grad(p) -- large, but structured, and easy to misread as a transcription error rather than a convention one. Two transcriber additions, both from these files being C++ headers rather than C: identifiers Python reserves are renamed (these kernels take coordinates as `const double* in`, and `in[0]` does not parse), and a declaration packing several declarators into one statement is split, since `double x=in[0],y=in[1];` would otherwise be read as a single assignment whose value runs past the comma. The tests also caught a packaging gap of the kind PR 1 warned about: the new .hpp files were not in package_data, so they built fine and then failed at run time in the installed tree. package_data now covers .h, .hpp and .c. Two test-side notes worth keeping. `simplify` will not combine exponentials written in mesh coordinates -- the beta = 0 cases reduced and the others did not, purely because of the symbol type -- so the residual is rewritten over plain symbols first. And an exact Rational 4 and a float 4.0 in an exponent are equal but SymPy will not cancel them, so the expected form has to be built the same way the solution substitutes. Verified: 13 SolDB tests in 7.6s; 101 analytic tests overall; style gate clean. uw.analytic.available() now lists EllipticalInclusion, SolCx, SolDB2d, SolDB3d, SolKx, SolNL. Underworld development team with AI support from Claude Code --- setup.py | 2 +- src/underworld3/analytic/__init__.py | 6 +- .../analytic/_reference/AnalyticSolDB2d.hpp | 123 ++++++++++++ .../analytic/_reference/AnalyticSolDB3d.hpp | 166 +++++++++++++++++ src/underworld3/analytic/_transcribe.py | 47 ++++- src/underworld3/analytic/velic.py | 175 ++++++++++++++++++ tests/test_1022_analytic_soldb.py | 156 ++++++++++++++++ 7 files changed, 669 insertions(+), 6 deletions(-) create mode 100644 src/underworld3/analytic/_reference/AnalyticSolDB2d.hpp create mode 100644 src/underworld3/analytic/_reference/AnalyticSolDB3d.hpp create mode 100644 tests/test_1022_analytic_soldb.py diff --git a/setup.py b/setup.py index 0a73a539f..8d46fcac3 100644 --- a/setup.py +++ b/setup.py @@ -295,7 +295,7 @@ def configure(): # Its own key: the "underworld3" globs above do not reach a directory # that is itself a package. Missing these headers fails at solve time, # when the JIT compiles, not at import. - "underworld3.analytic._reference": ["*.h", "*.c"], + "underworld3.analytic._reference": ["*.h", "*.hpp", "*.c"], }, ext_modules=cythonize( extensions, diff --git a/src/underworld3/analytic/__init__.py b/src/underworld3/analytic/__init__.py index 62f8397dc..43a7ea646 100644 --- a/src/underworld3/analytic/__init__.py +++ b/src/underworld3/analytic/__init__.py @@ -29,7 +29,7 @@ from ._base import AnalyticSolution, FreeSlipWalls, FixedWalls from .inclusion import EllipticalInclusion -from .velic import SolCx, SolKx, SolNL +from .velic import SolCx, SolDB2d, SolDB3d, SolKx, SolNL __all__ = [ "AnalyticSolution", @@ -37,6 +37,8 @@ "FixedWalls", "EllipticalInclusion", "SolCx", + "SolDB2d", + "SolDB3d", "SolKx", "SolNL", "available", @@ -50,6 +52,8 @@ _SOLUTIONS = { "EllipticalInclusion": EllipticalInclusion, "SolCx": SolCx, + "SolDB2d": SolDB2d, + "SolDB3d": SolDB3d, "SolKx": SolKx, "SolNL": SolNL, } diff --git a/src/underworld3/analytic/_reference/AnalyticSolDB2d.hpp b/src/underworld3/analytic/_reference/AnalyticSolDB2d.hpp new file mode 100644 index 000000000..94d62d55f --- /dev/null +++ b/src/underworld3/analytic/_reference/AnalyticSolDB2d.hpp @@ -0,0 +1,123 @@ +/*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~* + ** ** + ** This file forms part of the Underworld geophysics modelling application. ** + ** ** + ** For full license and copyright information, please refer to the LICENSE.md file ** + ** located at the project root, or contact the authors. ** + ** ** + **~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*/ + +/* + SolDB2d from: + + @ARTICLE{2004IJNMF..46..183D, + author = {{Dohrmann}, C.~R. and {Bochev}, P.~B.}, + title = "{A stabilized finite element method for the Stokes problem based on polynomial pressure projections}", + journal = {International Journal for Numerical Methods in Fluids}, + keywords = {Stokes equations, stabilized mixed methods, equal-order interpolation, inf-sup condition}, + year = 2004, + month = sep, + volume = 46, + pages = {183-201}, + doi = {10.1002/fld.752}, + adsurl = {http://adsabs.harvard.edu/abs/2004IJNMF..46..183D}, + adsnote = {Provided by the SAO/NASA Astrophysics Data System} + } + */ + + +#ifndef __Underworld_Function_AnalyticSolDB2d_hpp__ +#define __Underworld_Function_AnalyticSolDB2d_hpp__ + +#include "Analytic.hpp" + +namespace Fn +{ + class SolDB2d: public AnalyticCRTP + { + public: + SolDB2d() + :AnalyticCRTP(this,2) + { + } + virtual ~SolDB2d(){}; + + void bodyforce( const double* in, double* out ) + { + double x=in[0],z=in[1]; + double fx; + double fz; + + fx = -z - 0.1e1 + 0.3e1 * x * x * z * z; + out[0]=fx; + + fz = -0.1e1 + 0.3e1 * x + 0.2e1 * pow(x, 0.3e1) * z; + out[1]=fz; + + }; + void viscosity( const double* in, double* out ) + { + out[0]=1.0; + }; + void pressure(const double* in, double* out ) + { + double x=in[0],z=in[1]; + double p,t2,t4; + + t2 = x * x; + t4 = z * z; + p = x * z + x + z + t2 * x * t4 - 0.4e1 / 0.3e1; + out[0]=p; + + }; + void strainrate( const double* in, double* out ) + { + double x=in[0],z=in[1]; + double exx; + double ezz; + double exz; + + exx = 0.1e1 + 0.2e1 * x - 0.2e1 * z + 0.3e1 * x * x - 0.3e1 * z * z + 0.2e1 * x * z; + out[0]=exx; + + ezz = -0.1e1 - 0.2e1 * x + 0.2e1 * z - 0.3e1 * x * x + 0.3e1 * z * z - 0.2e1 * x * z; + out[1]=ezz; + + exz = -x - 0.6e1 * x * z + x * x / 0.2e1 - z - z * z / 0.2e1; + out[2]=exz; + + }; + void stress( const double* in, double* out ) + { + double x=in[0],z=in[1]; + double txx; + double tzz; + double txz; + + txx = 0.2e1 + 0.4e1 * x - 0.4e1 * z + 0.6e1 * x * x - 0.6e1 * z * z + 0.4e1 * x * z; + out[0]=txx; + + tzz = -0.2e1 - 0.4e1 * x + 0.4e1 * z - 0.6e1 * x * x + 0.6e1 * z * z - 0.4e1 * x * z; + out[1]=tzz; + + txz = -0.2e1 * x - 0.12e2 * x * z + x * x - 0.2e1 * z - z * z; + out[2]=txz; + + }; + void velocity( const double* in, double* out ) + { + double x=in[0],z=in[1]; + double vx; + double vz; + + vx = x + x * x - 0.2e1 * x * z + pow(x, 0.3e1) - 0.3e1 * x * z * z + x * x * z; + out[0]=vx; + + vz = -z - 0.2e1 * x * z + z * z - 0.3e1 * x * x * z + pow(z, 0.3e1) - x * z * z; + out[1]=vz; + + }; + }; +} + +#endif diff --git a/src/underworld3/analytic/_reference/AnalyticSolDB3d.hpp b/src/underworld3/analytic/_reference/AnalyticSolDB3d.hpp new file mode 100644 index 000000000..5c8a2ab01 --- /dev/null +++ b/src/underworld3/analytic/_reference/AnalyticSolDB3d.hpp @@ -0,0 +1,166 @@ +/*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~* + ** ** + ** This file forms part of the Underworld geophysics modelling application. ** + ** ** + ** For full license and copyright information, please refer to the LICENSE.md file ** + ** located at the project root, or contact the authors. ** + ** ** + **~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*/ + +/* + SolDB3d initially from Dohrmann/Bochev with exponential viscosity added by Burstedde. + + Burstedde, C., Stadler, G., Alisic, L., Wilcox, L., Tan, E., Gurnis, M., and Ghattas, O.: Large- + scale adaptive mantle convection simulation, GJI, 192, 889–906, 2013. + + @article{Burstedde01032013, + author = {Burstedde, Carsten and Stadler, Georg and Alisic, Laura and Wilcox, Lucas C. and Tan, Eh and Gurnis, Michael and Ghattas, Omar}, + title = {Large-scale adaptive mantle convection simulation}, + volume = {192}, + number = {3}, + pages = {889-906}, + year = {2013}, + doi = {10.1093/gji/ggs070}, + URL = {http://gji.oxfordjournals.org/content/192/3/889.abstract}, + eprint = {http://gji.oxfordjournals.org/content/192/3/889.full.pdf+html}, + journal = {Geophysical Journal International} + } + + */ + + +#ifndef __Underworld_Function_AnalyticSolDB3d_hpp__ +#define __Underworld_Function_AnalyticSolDB3d_hpp__ + +#include "Analytic.hpp" + +namespace Fn +{ + class SolDB3d: public AnalyticCRTP + { + public: + SolDB3d( double Beta ) + :AnalyticCRTP(this,3), Beta(Beta) + { + } + virtual ~SolDB3d(){}; + double Beta; + + void bodyforce( const double* in, double* out ) + { + double x=in[0],y=in[1],z=in[2]; + double fx; + double fy; + double fz; + + fx = y * z + 0.2e1 * x * pow(y, 0.3e1) * z + 0.2e1 * Beta * (0.1e1 - 0.2e1 * x) * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (0.1e1 + 0.2e1 * x + y + 0.3e1 * x * x * y) - 0.2e1 * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (0.2e1 + 0.6e1 * x * y) + 0.2e1 * Beta * (0.1e1 - 0.2e1 * y) * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (x / 0.2e1 + pow(x, 0.3e1) / 0.2e1 + y / 0.2e1 + x * y * y) - 0.2e1 * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (0.1e1 / 0.2e1 + 0.2e1 * x * y) + 0.2e1 * Beta * (0.1e1 - 0.2e1 * z) * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (-0.3e1 / 0.2e1 * z - 0.5e1 * x * y * z) - 0.2e1 * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (-0.3e1 / 0.2e1 - 0.5e1 * x * y); + out[0]=fx; + + fy = 0.2e1 * Beta * (0.1e1 - 0.2e1 * x) * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (x / 0.2e1 + pow(x, 0.3e1) / 0.2e1 + y / 0.2e1 + x * y * y) - 0.2e1 * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (0.1e1 / 0.2e1 + 0.3e1 / 0.2e1 * x * x + y * y) + x * z + 0.3e1 * x * x * y * y * z + 0.2e1 * Beta * (0.1e1 - 0.2e1 * y) * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (0.1e1 + x + 0.2e1 * y + 0.2e1 * x * x * y) - 0.2e1 * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (0.2e1 + 0.2e1 * x * x) + 0.2e1 * Beta * (0.1e1 - 0.2e1 * z) * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (-0.3e1 / 0.2e1 * z - 0.5e1 / 0.2e1 * x * x * z) - 0.2e1 * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (-0.3e1 / 0.2e1 - 0.5e1 / 0.2e1 * x * x); + out[1]=fy; + + fz = 0.2e1 * Beta * (0.1e1 - 0.2e1 * x) * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (-0.3e1 / 0.2e1 * z - 0.5e1 * x * y * z) + 0.10e2 * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * y * z + 0.2e1 * Beta * (0.1e1 - 0.2e1 * y) * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (-0.3e1 / 0.2e1 * z - 0.5e1 / 0.2e1 * x * x * z) + x * y + x * x * pow(y, 0.3e1) + 0.2e1 * Beta * (0.1e1 - 0.2e1 * z) * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (-0.2e1 - 0.3e1 * x - 0.3e1 * y - 0.5e1 * x * x * y); + out[2]=fz; + + }; + void viscosity( const double* in, double* out ) + { + double x=in[0],y=in[1],z=in[2]; + double eta; + + eta = exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))); + out[0]=eta; + + }; + void pressure(const double* in, double* out ) + { + double x=in[0],y=in[1],z=in[2]; + double p,t3,t4; + + t3 = x * x; + t4 = y * y; + p = x * y * z + t3 * t4 * y * z - 0.5e1 / 0.32e2; + out[0]=p; + + }; + void strainrate( const double* in, double* out ) + { + double x=in[0],y=in[1],z=in[2]; + double exx; + double eyy; + double ezz; + double exy; + double exz; + double eyz; + + exx = 0.1e1 + 0.2e1 * x + y + 0.3e1 * x * x * y; + out[0]=exx; + + eyy = 0.1e1 + x + 0.2e1 * y + 0.2e1 * x * x * y; + out[1]=eyy; + + ezz = -0.2e1 - 0.3e1 * x - 0.3e1 * y - 0.5e1 * x * x * y; + out[2]=ezz; + + exy = x / 0.2e1 + pow(x, 0.3e1) / 0.2e1 + y / 0.2e1 + x * y * y; + out[3]=exy; + + exz = -0.3e1 / 0.2e1 * z - 0.5e1 * x * y * z; + out[4]=exz; + + eyz = -0.3e1 / 0.2e1 * z - 0.5e1 / 0.2e1 * x * x * z; + out[5]=eyz; + + }; + void stress( const double* in, double* out ) + { + double x=in[0],y=in[1],z=in[2]; + double txx; + double tyy; + double tzz; + double txy; + double txz; + double tyz; + + txx = 0.2e1 * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (0.1e1 + 0.2e1 * x + y + 0.3e1 * x * x * y); + out[0]=txx; + + tyy = 0.2e1 * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (0.1e1 + x + 0.2e1 * y + 0.2e1 * x * x * y); + out[1]=tyy; + + tzz = 0.2e1 * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (-0.2e1 - 0.3e1 * x - 0.3e1 * y - 0.5e1 * x * x * y); + out[2]=tzz; + + txy = 0.2e1 * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (x / 0.2e1 + pow(x, 0.3e1) / 0.2e1 + y / 0.2e1 + x * y * y); + out[3]=txy; + + txz = 0.2e1 * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (-0.3e1 / 0.2e1 * z - 0.5e1 * x * y * z); + out[4]=txz; + + tyz = 0.2e1 * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (-0.3e1 / 0.2e1 * z - 0.5e1 / 0.2e1 * x * x * z); + out[5]=tyz; + + }; + void velocity( const double* in, double* out ) + { + double x=in[0],y=in[1],z=in[2]; + double vx; + double vy; + double vz; + + vx = x + x * x + x * y + pow(x, 0.3e1) * y; + out[0]=vx; + + vy = y + x * y + y * y + x * x * y * y; + out[1]=vy; + + vz = -0.2e1 * z - 0.3e1 * x * z - 0.3e1 * y * z - 0.5e1 * x * x * y * z; + out[2]=vz; + + }; + + }; + +} + +#endif diff --git a/src/underworld3/analytic/_transcribe.py b/src/underworld3/analytic/_transcribe.py index 759fe666e..426223bd9 100644 --- a/src/underworld3/analytic/_transcribe.py +++ b/src/underworld3/analytic/_transcribe.py @@ -52,6 +52,44 @@ r"\(\s*(?:PetscReal|PetscScalar|PetscInt|double|float|int|unsigned)\s*\)\s*" ) +# C identifiers that Python reserves. `in` is the one that actually occurs — +# these kernels take their coordinates as `const double* in`. Renamed in the +# source text and in the caller's environment together, so a caller still writes +# the name the C uses. +_RESERVED = {"in": "_c_in", "lambda": "_c_lambda", "is": "_c_is", "not": "_c_not"} +_RESERVED_PATTERN = re.compile(r"\b(" + "|".join(_RESERVED) + r")\b") + + +def _rename_reserved(text): + return _RESERVED_PATTERN.sub(lambda m: _RESERVED[m.group(1)], text) + + +def _split_declarators(block): + """Give each declarator in a C declaration its own statement. + + ``double x=in[0], y=in[1], z=in[2];`` is one statement to the reader below, + whose value would run past the first comma and fail to parse. Splitting on + top-level commas that introduce a new ``name =`` turns it into three. + + Only commas outside brackets count, so function arguments and array + subscripts are left alone. + """ + + out = [] + depth = 0 + for index, character in enumerate(block): + if character in "([": + depth += 1 + elif character in ")]": + depth -= 1 + elif character == "," and depth == 0: + if re.match(r"\s*\w+\s*=(?!=)", block[index + 1 :]): + out.append(";") + continue + out.append(character) + + return "".join(out) + # The assignment target keeps any `struct.` prefix. Without it, `out.x = ...` # reads as an assignment to `x` and silently overwrites the coordinate symbol — # every later statement referring to x then gets the wrong thing, and the result @@ -90,7 +128,7 @@ def _as_python(expression): juxtaposition in Python, which does not parse. """ - expression = _CAST.sub("", " ".join(expression.split())) + expression = _rename_reserved(_CAST.sub("", " ".join(expression.split()))) return _FLOAT_LITERAL.sub(lambda m: f"Rational('{m.group(0)}')", expression) @@ -162,8 +200,9 @@ def evaluate_expression(text, environment): """ namespace = {**_C_FUNCTIONS, "Rational": sympy.Rational} + scope = {_RESERVED.get(name, name): value for name, value in environment.items()} return sympy.sympify( - eval(_as_python(text), {"__builtins__": {}}, {**namespace, **environment}) + eval(_as_python(text), {"__builtins__": {}}, {**namespace, **scope}) ) @@ -198,10 +237,10 @@ def evaluate_block(block, environment): subexpression elimination when the expression is compiled or lambdified. """ - scope = dict(environment) + scope = {_RESERVED.get(name, name): value for name, value in environment.items()} namespace = {**_C_FUNCTIONS, "Rational": sympy.Rational} - for target, expression in _STATEMENT.findall(block): + for target, expression in _STATEMENT.findall(_split_declarators(block)): value = eval( _as_python(expression), {"__builtins__": {}}, {**namespace, **scope} ) diff --git a/src/underworld3/analytic/velic.py b/src/underworld3/analytic/velic.py index eda95d5e2..7164b8916 100644 --- a/src/underworld3/analytic/velic.py +++ b/src/underworld3/analytic/velic.py @@ -622,3 +622,178 @@ def __init__(self, mesh, B=2.302585092994046, n=3, m=2): self.fn_strainrate = ( self.fn_stress + self.fn_pressure * sympy.eye(2) ) / (2 * self.fn_viscosity) + + +_Y = sympy.Symbol("y") +_BETA = sympy.Symbol("Beta") + + +@functools.lru_cache(maxsize=None) +def _soldb_kernel(dim): + r"""Transcribe a Dohrmann–Bochev solution into SymPy. + + Six short methods rather than one kernel, each written as a C++ member of a + header-only class, and each taking its coordinates from an array. + + Returns + ------- + dict + Field name -> expression in the kernel's own symbols. + """ + + names = {2: ("x", "z"), 3: ("x", "y", "z")}[dim] + coordinates = {2: (_X, _Z), 3: (_X, _Y, _Z)}[dim] + + source = CSource( + os.path.join(_REFERENCE_DIR, f"AnalyticSolDB{dim}d.hpp") + ) + inputs = {"in": coordinates, "Beta": _BETA} + + def block(method): + return evaluate_block(source.function(method), inputs) + + velocity = block("velocity") + bodyforce = block("bodyforce") + stress = block("stress") + strainrate = block("strainrate") + pressure = block("pressure") + + fields = { + "pressure": pressure["p"], + # SolDB2d writes its unit viscosity straight into the output array, so + # there is no named variable to read; 3D has one. + "viscosity": block("viscosity")["eta"] if dim == 3 else sympy.Integer(1), + } + for axis, name in enumerate(names): + fields[f"velocity_{name}"] = velocity[f"v{name}"] + fields[f"bodyforce_{name}"] = bodyforce[f"f{name}"] + + for i, a in enumerate(names): + for b in names[i:]: + fields[f"stress_{a}{b}"] = stress[f"t{a}{b}"] + fields[f"strainrate_{a}{b}"] = strainrate[f"e{a}{b}"] + + return fields + + +class _SolDB(FixedWalls, AnalyticSolution): + """Shared assembly for the Dohrmann–Bochev manufactured solutions.""" + + def _assemble(self, mesh, values, names): + kernel = { + field: expression.subs(values) + for field, expression in _soldb_kernel(self.dim).items() + } + + self.fn_velocity = sympy.Matrix( + [[kernel[f"velocity_{n}"] for n in names]] + ) + self.fn_bodyforce = sympy.Matrix( + [[kernel[f"bodyforce_{n}"] for n in names]] + ) + self.fn_pressure = kernel["pressure"] + self.fn_viscosity = kernel["viscosity"] + + def tensor(prefix): + return sympy.Matrix( + [ + [ + kernel[f"{prefix}_{min(a, b)}{max(a, b)}"] + if f"{prefix}_{min(a, b)}{max(a, b)}" in kernel + else kernel[f"{prefix}_{max(a, b)}{min(a, b)}"] + for b in names + ] + for a in names + ] + ) + + # These kernels publish the DEVIATORIC stress, unlike SolCx and SolKx + # which return the total. The contract wants Cauchy, so the pressure goes + # back in: sigma = tau - p I. Getting this wrong would leave the momentum + # residual non-zero by exactly grad(p), which is easy to mistake for a + # transcription error. + self.fn_stress = tensor("stress") - self.fn_pressure * sympy.eye(self.dim) + self.fn_strainrate = tensor("strainrate") + + +class SolDB2d(_SolDB): + r"""Isoviscous polynomial manufactured solution — Dohrmann & Bochev, 2D. + + Unit viscosity, a polynomial velocity, and whatever body force makes it + exact. There is no discontinuity and no large contrast, which is the point: + it isolates the discretisation from the conditioning, so an error here is an + error in the element or the solve rather than in how a hard coefficient was + handled. + + Parameters + ---------- + mesh : Mesh + A 2D mesh on the unit box. + + Notes + ----- + The velocity is not tangential to the walls, so this is posed with the exact + velocity prescribed on the boundary. + """ + + dim = 2 + reference = ( + "Dohrmann & Bochev (2004), Int. J. Numer. Meth. Fluids 46, 183-201, " + "doi:10.1002/fld.752. Transcribed from the kernel vendored at " + "underworld3/analytic/_reference/AnalyticSolDB2d.hpp." + ) + eqn_viscosity = r"1" + + def __init__(self, mesh): + super().__init__(mesh) + + x, z = mesh.X + self._assemble(mesh, {_X: x, _Z: z}, ("x", "z")) + + +class SolDB3d(_SolDB): + r"""Variable-viscosity manufactured solution in 3D — Burstedde et al. + + Viscosity :math:`\eta = e^{1 - \beta\,[x(1-x) + y(1-y) + z(1-z))]}`, smooth + and peaked in the interior, with a polynomial velocity and a body force + chosen to make the pair exact. + + The only 3D solution in the suite, and the only one whose viscosity varies in + every direction at once — a 2D benchmark cannot catch a term that is wrong + only in the third dimension, and several parts of a Stokes discretisation + (the pressure space, the null space, the tensor assembly) genuinely differ + between 2D and 3D. + + Parameters + ---------- + mesh : Mesh + A 3D mesh on the unit cube. + beta : float + Viscosity exponent. Zero is isoviscous; larger makes the interior + viscosity peak sharper. + + Notes + ----- + The velocity is not tangential to the boundary, so the exact velocity is + prescribed there. + """ + + dim = 3 + reference = ( + "Burstedde et al. (2013), Geophys. J. Int. 192(3), 889-906, " + "doi:10.1093/gji/ggs070. Transcribed from the kernel vendored at " + "underworld3/analytic/_reference/AnalyticSolDB3d.hpp." + ) + eqn_viscosity = r"e^{1 - \beta [x(1-x) + y(1-y) + z(1-z)]}" + + def __init__(self, mesh, beta=4.0): + super().__init__(mesh) + + self.beta = float(beta) + + x, y, z = mesh.X + self._assemble( + mesh, + {_X: x, _Y: y, _Z: z, _BETA: sympy.Rational(self.beta)}, + ("x", "y", "z"), + ) diff --git a/tests/test_1022_analytic_soldb.py b/tests/test_1022_analytic_soldb.py new file mode 100644 index 000000000..82fac61e3 --- /dev/null +++ b/tests/test_1022_analytic_soldb.py @@ -0,0 +1,156 @@ +r"""The Dohrmann–Bochev manufactured solutions, in 2D and 3D. + +Polynomial velocities with a body force chosen to make them exact. SolDB2d is +isoviscous; SolDB3d (Burstedde et al.) carries a smooth viscosity peaked in the +interior and is **the suite's only 3D solution** — several parts of a Stokes +discretisation genuinely differ between two and three dimensions (the pressure +space, the null space, the tensor assembly), and a 2D benchmark cannot see a term +that is wrong only in the third. + +These are the easiest solutions here to be sure of. The fields are short enough +that the residuals reduce *symbolically* to zero rather than to something small, +so the assertions are exact rather than tolerance-based — no sampling, no +conditioning question, nothing to argue about. + +Run: pixi run python -m pytest tests/test_1022_analytic_soldb.py -v +""" + +import pytest + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + +import numpy as np +import sympy +import underworld3 as uw + + +@pytest.fixture(scope="module") +def box2d(): + return uw.meshing.StructuredQuadBox( + elementRes=(4, 4), minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), qdegree=3 + ) + + +@pytest.fixture(scope="module") +def box3d(): + return uw.meshing.StructuredQuadBox( + elementRes=(2, 2, 2), + minCoords=(0.0, 0.0, 0.0), + maxCoords=(1.0, 1.0, 1.0), + qdegree=2, + ) + + +def _plain(sol, expression): + """Rewrite an expression over plain symbols before simplifying. + + Mesh coordinates are not ordinary Symbols, and `simplify` will not combine + exponentials written in them — the beta = 0 cases reduce and the others do + not, purely because of the symbol type. Swapping first makes the reduction + the algebraic question it is meant to be. + """ + + plain = sympy.symbols(f"_p0:{sol.dim}", real=True) + return sympy.sympify(expression).subs(dict(zip(sol.mesh.X, plain))) + + +def _divergence(sol): + return sum( + sympy.diff(sol.fn_velocity[0, i], sol.mesh.X[i]) for i in range(sol.dim) + ) + + +def _momentum(sol): + r""":math:`\nabla\cdot\sigma + \mathbf f`, component by component.""" + + return [ + sol.fn_bodyforce[0, i] + + sum(sympy.diff(sol.fn_stress[i, j], sol.mesh.X[j]) for j in range(sol.dim)) + for i in range(sol.dim) + ] + + +def test_soldb2d_is_incompressible(box2d): + sol = uw.analytic.SolDB2d(box2d) + assert sympy.simplify(_plain(sol, _divergence(sol))) == 0 + + +def test_soldb2d_satisfies_the_momentum_balance(box2d): + """Exactly, not approximately — the expressions are small enough to reduce.""" + + sol = uw.analytic.SolDB2d(box2d) + for residual in _momentum(sol): + assert sympy.simplify(_plain(sol, residual)) == 0 + + +def test_soldb2d_is_isoviscous(box2d): + assert uw.analytic.SolDB2d(box2d).fn_viscosity == 1 + + +@pytest.mark.parametrize("beta", [0.0, 4.0, 10.0]) +def test_soldb3d_is_incompressible(box3d, beta): + sol = uw.analytic.SolDB3d(box3d, beta=beta) + assert sympy.simplify(_plain(sol, _divergence(sol))) == 0 + + +@pytest.mark.parametrize("beta", [0.0, 4.0, 10.0]) +def test_soldb3d_satisfies_the_momentum_balance(box3d, beta): + """Holds for every viscosity exponent, including the isoviscous beta = 0.""" + + sol = uw.analytic.SolDB3d(box3d, beta=beta) + for residual in _momentum(sol): + assert sympy.simplify(_plain(sol, residual)) == 0 + + +def test_soldb3d_viscosity_is_the_published_form(box3d): + r""":math:`\eta = e^{1-\beta[x(1-x)+y(1-y)+z(1-z)]}`, peaked in the interior.""" + + beta = 4.0 + sol = uw.analytic.SolDB3d(box3d, beta=beta) + x, y, z = box3d.X + + # Rational, matching what the solution substitutes: an exact 4 and a float + # 4.0 in an exponent are equal but SymPy will not cancel them. + expected = sympy.exp( + 1 - sympy.Rational(beta) * (x * (1 - x) + y * (1 - y) + z * (1 - z)) + ) + assert sympy.simplify(_plain(sol, sol.fn_viscosity - expected)) == 0 + + # Smallest where the bracket is largest, i.e. at the centre of the cube. + eta = sympy.lambdify(tuple(box3d.X), sol.fn_viscosity, "numpy") + assert float(eta(0.5, 0.5, 0.5)) < float(eta(0.1, 0.1, 0.1)) + + +def test_soldb3d_stress_is_consistent_with_the_strain_rate(box3d): + r""":math:`\sigma = -p\,I + 2\eta\dot\varepsilon`. + + The kernel publishes the deviatoric stress and the strain rate separately, so + this checks two of its outputs against each other and pins the convention — + reading the deviator as if it were the total would leave the momentum + residual wrong by exactly :math:`\nabla p`. + """ + + sol = uw.analytic.SolDB3d(box3d, beta=4.0) + + deviator = sol.fn_stress + sol.fn_pressure * sympy.eye(3) + for i in range(3): + for j in range(3): + assert ( + sympy.simplify( + _plain(sol, deviator[i, j] - 2 * sol.fn_viscosity * sol.fn_strainrate[i, j]) + ) + == 0 + ) + + +def test_soldb_are_registered(box2d, box3d): + assert {"SolDB2d", "SolDB3d"} <= set(uw.analytic.available()) + assert uw.analytic.SolDB2d(box2d).dim == 2 + assert uw.analytic.SolDB3d(box3d).dim == 3 + + +def test_soldb3d_refuses_a_2d_mesh(box2d): + """The dimension check in the contract does its job.""" + + with pytest.raises(ValueError, match="3D solution"): + uw.analytic.SolDB3d(box2d) From 27c5c6a6ba1f70c299e7103becc7a7d7582d6eb7 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Mon, 3 Aug 2026 12:05:20 +1000 Subject: [PATCH 11/28] SolKz: depth-dependent viscosity, and the stress convention that is not uniform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uw.analytic.SolKz — Stokes flow with eta = exp(2Bz), free slip on the unit box. The vertical twin of SolKx and not a redundant one: a viscosity varying with depth stratifies the flow along the direction buoyancy acts, coupling pressure and vertical velocity through the varying coefficient in a way a horizontal gradient never does, and it is the closer analogue of a real mantle profile. Validated by the equations, as SolKx was: |div(sigma)+f|/|f| is 2.2e-16 to 3.8e-16 across four regimes, div(v) ~1e-18, free slip ~1e-19 on all four walls. Two traps here, and the second is the one worth carrying forward. SolKz transposes SolKx. Its modes run in x rather than z, and its u1 is the VERTICAL velocity where SolCx and SolKx use u1 for the horizontal. The mapping is taken from the kernel's own output section rather than assumed, because reading it with the SolCx convention would silently transpose the entire solution -- div(v) would still vanish, free slip would still hold, and only the momentum residual would notice. The stress convention is not uniform across this family. SolCx and SolKx publish the total Cauchy stress; SolKz publishes the DEVIATOR -- into an array it calls `total_stress`. Following the name leaves the momentum residual at order |f| and invents a horizontal body force in a benchmark that has none: large, structured, and easy to misread as a bad transcription. Two cheap signatures separate them, and both are now standing tests. A deviator is traceless, so its xx and zz entries are exact negatives -- which the kernel's output visibly was. And tau = 2 eta edot, where the strain rate comes from the velocity, a different output of the same kernel. On SolKz the shear component agreed with 2 eta edot to machine precision while the normal components agreed with nothing, which located it at once. Recorded in the subsystem doc as a table of which solution publishes which, with the instruction not to trust the name. Verified: 12 SolKz tests; 49 neighbouring analytic tests; style gate clean. uw.analytic.available() now lists EllipticalInclusion, SolCx, SolDB2d, SolDB3d, SolKx, SolKz, SolNL. Underworld development team with AI support from Claude Code --- .../subsystems/analytic-solutions.md | 27 + src/underworld3/analytic/__init__.py | 4 +- .../analytic/_reference/AnalyticSolM.hpp | 100 ++++ src/underworld3/analytic/_reference/solA.c | 211 +++++++ src/underworld3/analytic/_reference/solB.c | 197 +++++++ src/underworld3/analytic/_reference/solKz.c | 539 ++++++++++++++++++ src/underworld3/analytic/velic.py | 122 ++++ tests/test_1023_analytic_solkz.py | 150 +++++ 8 files changed, 1349 insertions(+), 1 deletion(-) create mode 100644 src/underworld3/analytic/_reference/AnalyticSolM.hpp create mode 100644 src/underworld3/analytic/_reference/solA.c create mode 100644 src/underworld3/analytic/_reference/solB.c create mode 100644 src/underworld3/analytic/_reference/solKz.c create mode 100644 tests/test_1023_analytic_solkz.py diff --git a/docs/developer/subsystems/analytic-solutions.md b/docs/developer/subsystems/analytic-solutions.md index d8b3cd7b3..e5fcd4487 100644 --- a/docs/developer/subsystems/analytic-solutions.md +++ b/docs/developer/subsystems/analytic-solutions.md @@ -224,6 +224,33 @@ harness: perturb one coefficient and assert the other checks fail. velocity by a part in a thousand and requires both the comparison and the oracle-free residual to report it. +## The stress convention is not uniform across the family + +**Check which stress a kernel publishes. Do not read it off the variable name.** + +| solution | its stress output is | +|---|---| +| SolCx, SolKx | total (Cauchy) $\sigma$ | +| SolKz, SolDB2d, SolDB3d | deviatoric $\tau$ | + +SolKz is the trap: it writes into an array literally called `total_stress`, and +the contents are the deviator. Taking the name at face value leaves the momentum +residual at order $|\mathbf f|$ *and* manufactures a horizontal body force in a +benchmark that has none — a large, structured error that reads like a +transcription failure rather than a convention one. + +Two cheap signatures tell them apart, and both are worth running on any new +kernel: + +- a deviator is traceless, so its $xx$ and $zz$ entries are exact negatives; +- $\tau = 2\eta\dot\varepsilon$, and the strain rate follows from the velocity — + a *different* output of the same kernel, so the comparison is independent. + +On SolKz the shear component agreed with $2\eta\dot\varepsilon$ to machine +precision while the normal components did not agree with anything, which located +the problem immediately. `test_kernel_publishes_the_deviatoric_stress` keeps +both checks. + ## A solution that is derived rather than transcribed `EllipticalInclusion` (Schmid & Podladchikov 2003) is the one case so far where diff --git a/src/underworld3/analytic/__init__.py b/src/underworld3/analytic/__init__.py index 43a7ea646..d64003a87 100644 --- a/src/underworld3/analytic/__init__.py +++ b/src/underworld3/analytic/__init__.py @@ -29,7 +29,7 @@ from ._base import AnalyticSolution, FreeSlipWalls, FixedWalls from .inclusion import EllipticalInclusion -from .velic import SolCx, SolDB2d, SolDB3d, SolKx, SolNL +from .velic import SolCx, SolDB2d, SolDB3d, SolKx, SolKz, SolNL __all__ = [ "AnalyticSolution", @@ -40,6 +40,7 @@ "SolDB2d", "SolDB3d", "SolKx", + "SolKz", "SolNL", "available", "describe", @@ -55,6 +56,7 @@ "SolDB2d": SolDB2d, "SolDB3d": SolDB3d, "SolKx": SolKx, + "SolKz": SolKz, "SolNL": SolNL, } diff --git a/src/underworld3/analytic/_reference/AnalyticSolM.hpp b/src/underworld3/analytic/_reference/AnalyticSolM.hpp new file mode 100644 index 000000000..b1e2e73e1 --- /dev/null +++ b/src/underworld3/analytic/_reference/AnalyticSolM.hpp @@ -0,0 +1,100 @@ +/*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~* + ** ** + ** This file forms part of the Underworld geophysics modelling application. ** + ** ** + ** For full license and copyright information, please refer to the LICENSE.md file ** + ** located at the project root, or contact the authors. ** + ** ** + **~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*/ + +#ifndef __Underworld_Function_AnalyticSolM_hpp__ +#define __Underworld_Function_AnalyticSolM_hpp__ + +#include "Analytic.hpp" + +namespace Fn +{ + class SolM: public AnalyticCRTP + { + public: + SolM( double eta0, unsigned m, unsigned n, double r ) + :AnalyticCRTP(this,2), eta0(eta0), m(m), n(n), r(r) + { + } + virtual ~SolM(){}; + double eta0; + unsigned m; + unsigned n; + double r; + double km=m*M_PI; + double kn=n*M_PI; + double kr=r*M_PI; + + void velocity( const double* in, double* out ) + { + out[0] = -sin(km*in[0])*kn*cos(kn*in[1]); + out[1] = cos(km*in[0])*km*sin(kn*in[1]); + }; + void pressure( const double* in, double* out ) + { + double x=in[0],z=in[1]; + double p; + double t2,t4,t5,t6,t7,t8,t10,t11,t15,t16; + double t18,t23,t27,t28,t29,t32,t33,t34,t44; + + t2 = cos(kn * z); + t4 = kr * kr; + t5 = t4 * eta0; + t6 = km * km; + t7 = kr * x; + t8 = cos(t7); + t10 = km * x; + t11 = cos(t10); + t15 = kn * kn; + t16 = t15 * t11; + t18 = t6 * t11; + t23 = t6 * t6; + t27 = t18 * t15; + t28 = t11 * t23; + t29 = eta0 * t8; + t32 = sin(t7); + t33 = t32 * kr; + t34 = sin(t10); + t44 = 0.2e1 * t5 * t6 * t8 * t11 + t16 * t5 + t18 * t5 - t6 * t15 * t11 * eta0 - t23 * eta0 * t11 + t16 * t4 - t27 - t28 - t29 * t27 - t29 * t28 + t33 * eta0 * t34 * t6 * km + t18 * t4 - t33 * eta0 * t34 * t15 * km; + p = t2 * kn * t44 / km / (-t4 + t6); + out[0]=p; + + }; + void stress( const double* in, double* out ) + { + out[0] = -2.*(1.+cos(kr*in[0]))*eta0*cos(km*in[0])*km*cos(kn*in[1])*kn; //txx + out[1] = 2.*(1.+cos(kr*in[0]))*eta0*cos(km*in[0])*km*cos(kn*in[1])*kn; //tzz + out[2] = 2.*(1.+cos(kr*in[0]))*eta0*(1./2*sin(km*in[0])*sin(kn*in[1])*kn*kn-1./2*sin(km*in[0])*km*km*sin(kn*in[1])); //txz + }; + void strainrate( const double* in, double* out ) + { + out[0] = -cos(km*in[0])*km*cos(kn*in[1])*kn; //exx + out[1] = cos(km*in[0])*km*cos(kn*in[1])*kn; //ezz + out[2] = 1./2*sin(km*in[0])*sin(kn*in[1])*kn*kn-1./2*sin(km*in[0])*km*km*sin(kn*in[1]); //exz + }; + void viscosity( const double* in, double* out ) + { + out[0] = (1.+cos(kr*in[0]))*eta0+1.; + }; + void bodyforce( const double* in, double* out ) + { + double x,z; + out[0] = 0.; + + x=in[0]; + z=in[1]; + + out[1] = -sin(kn*z)*eta0*kr*(km-kn)*(km+kn)*(km*km-kr*kr+kn*kn)*sin(km*x)*sin(kr*x)/(-kr+km)/(kr+km) + + ( km*eta0*(km*km*km*km-3*kn*kn*kr*kr+2*km*km*kn*kn-kr*kr*km*km+kn*kn*kn*kn)*cos(kr*x)/(-kr+km)/(kr+km) + + (km*km+kn*kn)*(km*km+kn*kn)*(1+eta0)/km )*cos(km*x)*sin(kn*z); + }; + }; + +} + +#endif diff --git a/src/underworld3/analytic/_reference/solA.c b/src/underworld3/analytic/_reference/solA.c new file mode 100644 index 000000000..6f8422b2d --- /dev/null +++ b/src/underworld3/analytic/_reference/solA.c @@ -0,0 +1,211 @@ +/*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~* +** ** +** This file forms part of the Underworld geophysics modelling application. ** +** ** +** For full license and copyright information, please refer to the LICENSE.md file ** +** located at the project root, or contact the authors. ** +** ** +**~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*/ +#include +#include +#include +#include + +#include "solA.h" + +#if 0 +int main() { + double sigma = 1.0; + double Z = 1.0; + double km = M_PI; + int n = 1; + double pos[2], velocity[2], pressure, Tstress[3], strainRate[3],jp; + int i, j; + + for (i=0;i<101;i++){ + for(j=0;j<101;j++){ + pos[0] = i/100.0; + pos[1] = j/100.0; + + _Velic_solA( + pos, + sigma, + Z, + n, + km, + velocity, + &pressure, + Tstress, + strainRate ); + + printf( + "%.7g %.7g %.7g %.7g %.7g %.7g %.7g %.7g %.7g %.7g %.7g %.7g\n", + pos[0], pos[1], + velocity[0], velocity[1], + pressure, + jp, + strainRate[0], strainRate[1], strainRate[2], + Tstress[0], Tstress[1], Tstress[2] ); + } + } + return 0; +} +#endif + +void _Velic_solA( + const double* pos, + double sigma, + double Z, + int n, + double km, + double* velocity, + double* pressure, + double* Tstress, + double* strainRate ) { + + double u1,u2,u3,u4,pp,txx; + double _C1,_C2,_C3,_C4; + double sum1,sum2,sum3,sum4,sum5,sum6,x,z; + double ss,ss_z,ss_zz,ss_zzz,e_zz,e_xx,e_xz; + double t1,t3,t4,t5,t6,t7,t9,t10,t14,t15; + double t16,t17,t18,t19,t20,t21,t23,t24,t25,t26,t27,t29; + double kn; + + kn = (double)n*M_PI; + x = pos[0]; + z = pos[1]; + + sum1=0.0; + sum2=0.0; + sum3=0.0; + sum4=0.0; + sum5=0.0; + sum6=0.0; +// sum7=0.0; + + t1 = sin(km); + t3 = kn * kn; + t4 = exp(-kn); + t5 = t4 * t4; + t10 = km * km; + t17 = pow(t10 + t3, 0.2e1); + t21 = pow(t4 - 0.1e1, 0.2e1); + t24 = pow(t4 + 0.1e1, 0.2e1); + _C1 = t1 * sigma * (t3 * t5 + t3 - 0.2e1 * kn * t5 + 0.2e1 * kn + t10 * t5 + t10) * t4 / Z / t17 / t21 / t24 / 0.2e1; + + t1 = sin(km); + t3 = kn * kn; + t4 = exp(-kn); + t5 = t4 * t4; + t10 = km * km; + t16 = pow(t10 + t3, 0.2e1); + t20 = pow(t4 - 0.1e1, 0.2e1); + t23 = pow(t4 + 0.1e1, 0.2e1); + _C2 = -t1 * sigma * (t3 * t5 + t3 - 0.2e1 * kn * t5 + 0.2e1 * kn + t10 * t5 + t10) / Z / t16 / t20 / t23 / 0.2e1; + + t1 = sin(km); + t3 = exp(-kn); + t6 = km * km; + t7 = kn * kn; + _C3 = -t1 * sigma * t3 / Z / (t6 + t7) / (t3 - 0.1e1) / (t3 + 0.1e1) / 0.2e1; + + t1 = sin(km); + t5 = km * km; + t6 = kn * kn; + t9 = exp(-kn); + _C4 = -t1 * sigma / Z / (t5 + t6) / (t9 - 0.1e1) / (t9 + 0.1e1) / 0.2e1; + + t4 = exp(-kn * z); + t10 = exp(kn * (z - 0.1e1)); + t14 = sin(km * z); + t15 = km * km; + t16 = kn * kn; + t18 = pow(t15 + t16, 0.2e1); + ss = (_C1 + z * _C3) * t4 + (_C2 + z * _C4) * t10 + kn * sigma * t14 / t18 / Z; + + t6 = exp(-kn * z); + t14 = exp(kn * (z - 0.1e1)); + t18 = cos(km * z); + t20 = km * km; + t21 = kn * kn; + t23 = pow(t20 + t21, 0.2e1); + ss_z = (-(_C1 + z * _C3) * kn + _C3) * t6 + (_C4 + (_C2 + z * _C4) * kn) * t14 + kn * sigma * t18 * km / t23 / Z; + + t3 = kn * kn; + t9 = exp(-kn * z); + t19 = exp(kn * (z - 0.1e1)); + t23 = sin(km * z); + t25 = km * km; + t27 = pow(t25 + t3, 0.2e1); + ss_zz = ((_C1 + z * _C3) * t3 - 0.2e1 * _C3 * kn) * t9 + (0.2e1 * _C4 * kn + (_C2 + z * _C4) * t3) * t19 - kn * sigma * t23 * t25 / t27 / Z; + + t3 = kn * kn; + t4 = t3 * kn; + t10 = exp(-kn * z); + t20 = exp(kn * (z - 0.1e1)); + t24 = cos(km * z); + t26 = km * km; + t29 = pow(t26 + t3, 0.2e1); + ss_zzz = (-(_C1 + z * _C3) * t4 + 0.3e1 * _C3 * t3) * t10 + (0.3e1 * _C4 * t3 + (_C2 + z * _C4) * t4) * t20 - kn * sigma * t24 * t26 * km / t29 / Z; + + /* u1 = Vz, u2 = Vx, u3 = tzz, u4 = tzx, pp = pressure */ + + u1 = kn*ss; + u2 = -ss_z; + pp = Z*(ss_zzz-kn*kn*ss_z)/kn; + u3 = 2.0*kn*ss_z - pp; + u4 = -Z*(ss_zz + kn*kn*ss); + txx = -2.0*Z*kn*ss_z - pp; + + u1 *= cos(kn*x); /* z velocity */ + sum1 += u1; + u2 *= sin(kn*x); /* x velocity */ + sum2 += u2; + u3 *= cos(kn*x); /* zz stress */ + sum3 += u3; + u4 *= sin(kn*x); /* zx stress */ + sum4 += u4; + txx *= cos(kn*x); /* xx stress */ + sum6 += txx; + pp *= cos(kn*x); /* pressure */ + sum5 += pp; + + e_zz = kn*ss_z*cos(kn*x); /* zz rate of strain */ + e_xx = -e_zz; /* xx rate of strain */ + e_xz = -0.5*(ss_zz+kn*kn*ss)*sin(kn*x); /* xz rate of strain */ + +// ss *= sin(kn*x); /* stream function */ + /* density/temp */ +// sum7 += -sin(km*z)*cos(kn*x); +// mag=sqrt(sum1*sum1+sum2*sum2); + + // printf("%0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f\n",x,z,sum1,sum2,sum3,sum4,sum5,sum6,mag,sum7,e_zz,e_xx,e_xz,ss); + + if ( velocity != NULL ) { + velocity[0] = sum2; + velocity[1] = sum1; + } + if( pressure != NULL ) { + *pressure = sum5; + } + if( Tstress != NULL ) { + Tstress[0] = sum6; + Tstress[1] = sum3; + Tstress[2] = sum4; /*2*Z*e_xz;*/ + } + if( strainRate != NULL ) { + strainRate[0] = e_xx; + strainRate[1] = e_zz; + strainRate[2] = e_xz; + } + /* + if ( fabs( sum3 - ( 2*Z*e_zz - sum5 ) ) > 1e-4 ) { + printf("%g is not within tolerance 1e-4\n", sum3 - ( 2*Z*e_zz + sum5 ) ); + assert(0); + } + if( fabs( sum5 - ( -0.5*(sum6 + sum3) ) ) > 1e-4 ) { + printf("error is %g, which is outside tolerance of 1e-4\n", fabs( sum5 - ( -0.5*(sum6 + sum3) )) ); + assert(0); + } + */ +} diff --git a/src/underworld3/analytic/_reference/solB.c b/src/underworld3/analytic/_reference/solB.c new file mode 100644 index 000000000..c64e5313b --- /dev/null +++ b/src/underworld3/analytic/_reference/solB.c @@ -0,0 +1,197 @@ +/*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~* +** ** +** This file forms part of the Underworld geophysics modelling application. ** +** ** +** For full license and copyright information, please refer to the LICENSE.md file ** +** located at the project root, or contact the authors. ** +** ** +**~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*/ +#include +#include +#include +#include + +#include "solB.h" + +#if 0 +int main() { + double sigma = 1.0; + double Z = 1.0; + int n = 1; + double km = 2*M_PI; + double pos[2], velocity[2], pressure, Tstress[3], strainRate[3]; + int i,j; + + for (i=0;i<33;i++){ + for(j=0;j<33;j++){ + pos[0] = i/32.0; + pos[1] = j/32.0; + + _Velic_solB( pos, + sigma, Z, n, km, + velocity, &pressure, Tstress, strainRate ); + + printf("%.7g %.7g %.7g %.7g %.7g %.7g %.7g %.7g %.7g %.7g %.7g\n", + pos[0], pos[1], velocity[0], velocity[1], + pressure, + strainRate[0], strainRate[1], strainRate[2], + Tstress[0], Tstress[1], Tstress[2] ); + } + } + return 0; +} +#endif + +void _Velic_solB( const double* pos, + double sigma, double Z, int n, double km, + double* velocity, double* pressure, double* Tstress, double* strainRate ) { + + double u1,u2,u3,u4,pp,txx; + double _C1,_C2,_C3,_C4; + double sum1,sum2,sum3,sum4,sum5,sum6,sum7,x,z; + double kn; + double ss,ss_z,ss_zz,ss_zzz,e_zz,e_xx,e_xz; + double t1,t2,t3,t4,t5,t6,t7,t8,t9,t10; + double t11,t12,t14,t15,t16,t18,t19,t20,t21,t22; + double t23,t24,t25,t26,t27,t28,t29,t32,t33; + + kn = (double)n*M_PI; + + x = pos[0]; + z = pos[1]; + + sum1=0.0; + sum2=0.0; + sum3=0.0; + sum4=0.0; + sum5=0.0; + sum6=0.0; + sum7=0.0; + + + t1 = exp(km); + t5 = kn * kn; + t6 = exp(-kn); + t7 = t6 * t6; + t12 = km * km; + t22 = pow(-0.1e1 + t6, 0.2e1); + t26 = pow(t6 + 0.1e1, 0.2e1); + t29 = pow(kn - km, 0.2e1); + t33 = pow(kn + km, 0.2e1); + _C1 = sigma * (-0.1e1 + t1) * (t1 + 0.1e1) * (t5 * t7 + t5 + 0.2e1 * kn - 0.2e1 * kn * t7 - t12 * t7 - t12) * t6 / Z / t1 / t22 / t26 / t29 / t33 / 0.4e1; + + t1 = kn * kn; + t2 = exp(-kn); + t3 = t2 * t2; + t8 = km * km; + t11 = exp(km); + t21 = pow(-0.1e1 + t2, 0.2e1); + t25 = pow(t2 + 0.1e1, 0.2e1); + t28 = pow(kn - km, 0.2e1); + t32 = pow(kn + km, 0.2e1); + _C2 = -(t1 * t3 + t1 + 0.2e1 * kn - 0.2e1 * kn * t3 - t8 * t3 - t8) * (t11 + 0.1e1) * (-0.1e1 + t11) * sigma / Z / t11 / t21 / t25 / t28 / t32 / 0.4e1; + + t1 = exp(km); + t5 = exp(-kn); + _C3 = -sigma * (-0.1e1 + t1) * (t1 + 0.1e1) * t5 / Z / t1 / (-0.1e1 + t5) / (t5 + 0.1e1) / (kn - km) / (kn + km) / 0.4e1; + + t1 = exp(km); + t9 = exp(-kn); + _C4 = -(t1 + 0.1e1) * (-0.1e1 + t1) * sigma / Z / t1 / (-0.1e1 + t9) / (t9 + 0.1e1) / (kn - km) / (kn + km) / 0.4e1; + + t4 = exp(-kn * z); + t10 = exp(kn * (z - 0.1e1)); + t14 = sinh(km * z); + t15 = km * km; + t16 = kn * kn; + t18 = pow(t15 - t16, 0.2e1); + ss = (_C1 + z * _C3) * t4 + (_C2 + z * _C4) * t10 + kn * sigma * t14 / t18 / Z; + + t6 = exp(-kn * z); + t14 = exp(kn * (z - 0.1e1)); + t18 = cosh(km * z); + t20 = km * km; + t21 = kn * kn; + t23 = pow(t20 - t21, 0.2e1); + ss_z = (-(_C1 + z * _C3) * kn + _C3) * t6 + (_C4 + (_C2 + z * _C4) * kn) * t14 + kn * sigma * t18 * km / t23 / Z; + + t3 = kn * kn; + t9 = exp(-kn * z); + t19 = exp(kn * (z - 0.1e1)); + t23 = sinh(km * z); + t25 = km * km; + t27 = pow(t25 - t3, 0.2e1); + ss_zz = ((_C1 + z * _C3) * t3 - 0.2e1 * _C3 * kn) * t9 + (0.2e1 * _C4 * kn + (_C2 + z * _C4) * t3) * t19 + kn * sigma * t23 * t25 / t27 / Z; + + t3 = kn * kn; + t4 = t3 * kn; + t10 = exp(-kn * z); + t20 = exp(kn * (z - 0.1e1)); + t24 = cosh(km * z); + t26 = km * km; + t29 = pow(t26 - t3, 0.2e1); + ss_zzz = (-(_C1 + z * _C3) * t4 + 0.3e1 * _C3 * t3) * t10 + (0.3e1 * _C4 * t3 + (_C2 + z * _C4) * t4) * t20 + kn * sigma * t24 * t26 * km / t29 / Z; + + /* u1 = Vz, u2 = Vx, u3 = tzz, u4 = tzx, pp = pressure */ + + u1 = kn*ss; + u2 = -ss_z; + pp = Z*(ss_zzz-kn*kn*ss_z)/kn; + u3 = 2.0*Z*kn*ss_z - pp; + u4 = -Z*(ss_zz + kn*kn*ss); + txx = -2.0*Z*kn*ss_z - pp; + + + u1 *= cos(kn*x); /* z velocity */ + sum1 += u1; + u2 *= sin(kn*x); /* x velocity */ + sum2 += u2; + u3 *= cos(kn*x); /* zz stress */ + sum3 += u3; + u4 *= sin(kn*x); /* zx stress */ + sum4 += u4; + txx *= cos(kn*x); /* xx stress */ + sum6 += txx; + pp *= cos(kn*x); /* pressure */ + sum5 += pp; + + e_zz = kn*ss_z*cos(kn*x); /* zz rate of strain */ + e_xx = -e_zz; /* xx rate of strain */ + e_xz = -0.5*(ss_zz+kn*kn*ss)*sin(kn*x); /* xz rate of strain */ + + ss *= sin(kn*x); /* stream function */ + /* density/temp */ + sum7 += -sigma*sinh(km*z)*cos(kn*x); + + //mag=sqrt(sum1*sum1+sum2*sum2); + // printf("%0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f\n",x,z,sum1,sum2,sum3,sum4,sum5,sum6,mag,sum7,e_zz,e_xx,e_xz,ss); + if ( velocity != NULL ) { + velocity[0] = sum2; + velocity[1] = sum1; + } + if( pressure != NULL ) { + *pressure = sum5; + } + if( Tstress != NULL ) { + Tstress[0] = sum6; + Tstress[1] = sum3; + Tstress[2] = sum4; + } + if( strainRate != NULL ) { + strainRate[0] = e_xx; + strainRate[1] = e_zz; + strainRate[2] = e_xz; + } +// /* Value checks, could be cleaned up if needed. Julian Giordani 2-Oct-2006*/ +// if( fabs( sum5 - ( -0.5*(sum6+sum3) ) ) > 1e-5 ) { +// assert(0); +// } +// if( fabs( sum6 - (2*Z*e_xx - sum5) ) > 1e-5 ) { +// assert(0); +// } +// if( fabs( sum3 - (2*Z*e_zz - sum5) ) > 1e-5 ) { +// assert(0); +// } +} + + diff --git a/src/underworld3/analytic/_reference/solKz.c b/src/underworld3/analytic/_reference/solKz.c new file mode 100644 index 000000000..cff477261 --- /dev/null +++ b/src/underworld3/analytic/_reference/solKz.c @@ -0,0 +1,539 @@ +/*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~* +** ** +** This file forms part of the Underworld geophysics modelling application. ** +** ** +** For full license and copyright information, please refer to the LICENSE.md file ** +** located at the project root, or contact the authors. ** +** ** +**~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*/ +#include "solKz.h" + +#if 0 +int main( int argc, char **argv ) +{ + int i,j; + double pos[2], vel[2], pressure, total_stress[3], strain_rate[3]; + double x,z; + + for (i=0;i<101;i++){ + for(j=0;j<101;j++){ + x = i/100.0; + z = j/100.0; + + pos[0] = x; + pos[1] = z; + _Velic_solKz( + pos, + 1.0, + (double)M_PI, 1, + 2.5, + vel, &pressure, total_stress, strain_rate ); + printf("%0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f \n", + pos[0],pos[1], + vel[0],vel[1], pressure, + total_stress[0], total_stress[1], total_stress[2], + strain_rate[0], strain_rate[1], strain_rate[2] ); + } + printf("\n"); + } + + return 0; +} +#endif + + + +void _Velic_solKz( + const double pos[], + double _sigma, /* density */ + double _km, int _n, /* wavelength in z, wavenumber in x */ + double _B, /* viscosity parameter */ + double vel[], double* presssure, + double total_stress[], double strain_rate[] ) +{ + double Z; + double u1,u2,u3,u4,u5,u6,SS; + double sum1,sum2,sum3,sum4,sum5,sum6,sum7,x,z; + double sigma; + int n; + double kn; + double _C1,_C2,_C3,_C4; + double B, Rp, UU, VV; + double rho,a,b,r,_aa,_bb,AA,BB,Rm,km; + + + double t1,t2,t3,t4,t5,t6,t7,t8,t9,t10; + double t11,t12,t13,t14,t15,t16,t17,t18,t19,t20; + double t21,t22,t23,t24,t25,t26,t27,t28,t29,t31; + double t33,t34,t35,t37,t38,t40,t41,t42,t43,t45; + double t47,t51,t52,t53,t54,t55,t56,t57,t58,t59; + double t60,t61,t62,t64,t65,t66,t67,t68,t69,t70; + double t71,t72,t73,t74,t75,t76,t77,t78,t79,t80; + double t81,t82,t83,t84,t85,t86,t89,t90,t92,t94; + double t96,t97,t98,t99,t100,t101,t103,t104,t105,t106; + double t107,t108,t109,t110,t111,t112,t113,t114,t115,t116; + double t117,t118,t119,t120,t121,t122,t123,t124,t125,t126; + double t127,t130,t131,t132,t134,t135,t141,t144,t147,t148; + double t150,t151,t152,t161,t171; + + + + /*************************************************************************/ + /*************************************************************************/ + /* rho = -sigma*sin(km*z)*cos(kn*x) */ + /* viscosity Z= exp(2*B*z) */ + B = _B; /* viscosity parameter must be non-zero*/ + km = _km; /* solution valid for km not zero -- should get trivial solution if km=0 */ + n = _n; /* solution valid for n not zero */ + sigma = _sigma; + /*************************************************************************/ + /*************************************************************************/ + kn = (double) _n*M_PI; + a = B*B + kn*kn; + b = 2.0*kn*B; + r = sqrt(a*a + b*b); + Rp = sqrt( (r+a)/2.0 ); + Rm = sqrt( (r-a)/2.0 ); + UU = Rp - B; + VV = Rp + B; + + + x = pos[0]; + z = pos[1]; + + sum1=0.0; + sum2=0.0; + sum3=0.0; + sum4=0.0; + sum5=0.0; + sum6=0.0; + sum7=0.0; + + + + + /*******************************************/ + /* calculate the constants */ + /*******************************************/ + + t3 = kn * kn; + t4 = km * km; + t6 = B * B; + t8 = 0.4e1 * t3 * t6; + t10 = 0.4e1 * t4 * t6; + t13 = 0.8e1 * kn * t6 * km; + t14 = t4 * t4; + t16 = 0.2e1 * t3 * t4; + t17 = t3 * t3; + _aa = -0.4e1 * B * km * kn * (t3 + t4) / (t8 + t10 + t13 + t14 + t16 + t17) / (-t13 + t8 + t10 + t14 + t16 + t17); + + t1 = kn * kn; + t2 = t1 * t1; + t3 = B * B; + t5 = 0.4e1 * t1 * t3; + t6 = km * km; + t7 = t6 * t6; + t9 = 0.2e1 * t1 * t6; + t11 = 0.4e1 * t3 * t6; + t16 = 0.8e1 * kn * t3 * km; + _bb = kn * (t2 + t5 + t7 + t9 - t11) / (t5 + t11 + t16 + t7 + t9 + t2) / (-t16 + t5 + t11 + t7 + t9 + t2); + + AA = _aa; + BB = _bb; + + t1 = B * B; + t2 = t1 * Rp; + t4 = Rm * Rm; + t5 = t4 * Rp; + t7 = t4 * B; + t8 = km * km; + t12 = Rp * Rp; + t13 = B * t12; + t21 = 0.8e1 * t1 * km * BB * Rp; + t23 = 0.2e1 * Rm; + t24 = cos(t23); + t26 = Rm * Rp; + t38 = sin(t23); + t51 = exp(-0.2e1 * Rp); + t53 = B + Rp; + t54 = Rm * t53; + t55 = Rm * B; + t57 = 0.2e1 * B * km; + t58 = t55 + t57 - t26; + t62 = 0.3e1 * t1; + t64 = 0.2e1 * Rp * B; + t65 = t62 + t64 + t4 - t8 - t12; + t67 = t54 * t65 * BB; + t69 = Rm - km; + t70 = cos(t69); + t72 = -t57 + t55 - t26; + t77 = Rm + km; + t78 = cos(t77); + t81 = t54 * t65 * AA; + t86 = sin(t77); + t92 = sin(t69); + t96 = exp(-t53); + t98 = B - Rp; + t99 = Rm * t98; + t100 = t55 + t57 + t26; + t104 = t62 - t64 + t4 - t8 - t12; + t106 = t99 * t104 * BB; + t109 = -t57 + t55 + t26; + t116 = t99 * t104 * AA; + t130 = exp(-0.3e1 * Rp - B); + t135 = exp(-0.4e1 * Rp); + t144 = t4 * t1; + t150 = t4 * t12; + _C1 = (((0.2e1 * Rp * (0.2e1 * t2 + 0.2e1 * t5 + t7 + B * t8 - 0.3e1 * t1 * B + t13) * AA + t21) * t24 + (-0.2e1 * t26 * (t4 - t8 - t12 + 0.5e1 * t1) * AA + 0.8e1 * B * BB * km * Rm * Rp) * t38 - 0.2e1 * B * (0.2e1 * t13 + t12 * Rp - 0.3e1 * t2 + t5 + 0.2e1 * t7 + t8 * Rp) * AA - t21) * t51 + ((0.2e1 * t54 * t58 * AA + t67) * t70 + (0.2e1 * t54 * t72 * AA - t67) * t78 + (t81 + 0.2e1 * t54 * t72 * BB) * t86 + (t81 - 0.2e1 * t54 * t58 * BB) * t92) * t96 + ((-0.2e1 * t99 * t100 * AA - t106) * t70 + (-0.2e1 * t99 * t109 * AA + t106) * t78 + (-t116 - 0.2e1 * t99 * t109 * BB) * t86 + (-t116 + 0.2e1 * t99 * t100 * BB) * t92) * t130 + 0.4e1 * t4 * t98 * t53 * AA * t135) / (((-0.8e1 * t4 - 0.8e1 * t1) * t12 * t24 + 0.8e1 * t144 + 0.8e1 * t12 * t1) * t51 + (0.4e1 * t150 - 0.4e1 * t144) * t135 + 0.4e1 * t150 - 0.4e1 * t144); + + t1 = Rm * Rp; + t2 = Rm * Rm; + t3 = km * km; + t4 = Rp * Rp; + t5 = B * B; + t12 = km * Rm; + t17 = 0.2e1 * Rm; + t18 = cos(t17); + t22 = t2 * Rp; + t25 = B * t3; + t26 = t5 * B; + t33 = t5 * km; + t38 = sin(t17); + t40 = Rm * B; + t41 = 0.3e1 * t5; + t51 = exp(-0.2e1 * Rp); + t53 = B + Rp; + t54 = Rm * t53; + t57 = t41 + 0.2e1 * Rp * B + t2 - t3 - t4; + t59 = t54 * t57 * AA; + t60 = B * km; + t61 = 0.2e1 * t60; + t62 = t40 + t61 - t1; + t67 = Rm - km; + t68 = cos(t67); + t70 = -t61 + t40 - t1; + t75 = Rm + km; + t76 = cos(t75); + t82 = t54 * t57 * BB; + t84 = sin(t75); + t90 = sin(t67); + t94 = exp(-t53); + t97 = 0.3e1 * Rm * t26; + t98 = t2 * Rm; + t99 = t98 * B; + t100 = t3 * Rm; + t101 = t100 * Rp; + t103 = Rm * t4 * B; + t104 = t4 * Rp; + t105 = Rm * t104; + t107 = 0.8e1 * t33 * Rp; + t109 = 0.5e1 * t1 * t5; + t110 = t98 * Rp; + t111 = t100 * B; + t112 = t97 + t99 - t101 + t103 - t105 + t107 + t109 + t110 - t111; + t114 = t2 * t4; + t116 = 0.2e1 * t60 * t1; + t117 = t2 * t5; + t119 = 0.3e1 * t26 * Rp; + t120 = t104 * B; + t121 = t4 * t5; + t122 = 0.2e1 * t121; + t123 = t22 * B; + t125 = 0.2e1 * t33 * Rm; + t126 = t25 * Rp; + t127 = t114 + t116 + t117 - t119 + t120 + t122 + t123 + t125 + t126; + t132 = -t107 + t103 - t105 - t101 + t97 - t111 + t110 + t109 + t99; + t134 = t120 - t125 + t123 - t116 + t122 + t117 + t114 + t126 - t119; + t152 = exp(-0.3e1 * Rp - B); + t161 = exp(-0.4e1 * Rp); + _C2 = (((0.2e1 * t1 * (t2 - t3 - t4 + 0.5e1 * t5) * AA - 0.8e1 * B * BB * t12 * Rp) * t18 + (0.2e1 * Rp * (0.2e1 * t5 * Rp + 0.2e1 * t22 + t2 * B + t25 - 0.3e1 * t26 + B * t4) * AA + 0.8e1 * t33 * BB * Rp) * t38 + 0.2e1 * t40 * (t41 + t4 + t2 - t3) * AA - 0.8e1 * t5 * BB * t12) * t51 + ((-t59 + 0.2e1 * t54 * t62 * BB) * t68 + (-t59 - 0.2e1 * t54 * t70 * BB) * t76 + (0.2e1 * t54 * t70 * AA - t82) * t84 + (0.2e1 * t54 * t62 * AA + t82) * t90) * t94 + ((t112 * AA - 0.2e1 * t127 * BB) * t68 + (t132 * AA + 0.2e1 * t134 * BB) * t76 + (-0.2e1 * t134 * AA + t132 * BB) * t84 + (-0.2e1 * t127 * AA - t112 * BB) * t90) * t152 + (-0.2e1 * t59 + 0.8e1 * t40 * km * t53 * BB) * t161) / (((-0.8e1 * t2 - 0.8e1 * t5) * t4 * t18 + 0.8e1 * t117 + 0.8e1 * t121) * t51 + (0.4e1 * t114 - 0.4e1 * t117) * t161 + 0.4e1 * t114 - 0.4e1 * t117); + + t1 = B * B; + t2 = t1 * Rp; + t4 = Rm * Rm; + t5 = t4 * Rp; + t7 = Rp * Rp; + t8 = B * t7; + t11 = km * km; + t13 = t4 * B; + t21 = 0.8e1 * t1 * km * BB * Rp; + t23 = 0.2e1 * Rm; + t24 = cos(t23); + t26 = Rm * Rp; + t38 = sin(t23); + t51 = exp(-0.2e1 * Rp); + t53 = B + Rp; + t54 = Rm * t53; + t55 = Rm * B; + t57 = 0.2e1 * B * km; + t58 = t55 + t57 - t26; + t62 = 0.3e1 * t1; + t64 = 0.2e1 * Rp * B; + t65 = t62 + t64 + t4 - t11 - t7; + t67 = t54 * t65 * BB; + t69 = Rm - km; + t70 = cos(t69); + t72 = -t57 + t55 - t26; + t77 = Rm + km; + t78 = cos(t77); + t81 = t54 * t65 * AA; + t86 = sin(t77); + t92 = sin(t69); + t96 = exp(-t53); + t98 = B - Rp; + t99 = Rm * t98; + t100 = t55 + t57 + t26; + t104 = t62 - t64 + t4 - t11 - t7; + t106 = t99 * t104 * BB; + t109 = -t57 + t55 + t26; + t116 = t99 * t104 * AA; + t130 = exp(-0.3e1 * Rp - B); + t141 = t4 * t1; + t147 = t4 * t7; + t151 = exp(-0.4e1 * Rp); + _C3 = (((-0.2e1 * Rp * (-0.2e1 * t2 - 0.2e1 * t5 + t8 - 0.3e1 * t1 * B + B * t11 + t13) * AA - t21) * t24 + (0.2e1 * t26 * (t4 - t11 - t7 + 0.5e1 * t1) * AA - 0.8e1 * B * BB * km * Rm * Rp) * t38 - 0.2e1 * B * (0.2e1 * t8 + 0.2e1 * t13 + 0.3e1 * t2 - t7 * Rp - t5 - t11 * Rp) * AA + t21) * t51 + ((-0.2e1 * t54 * t58 * AA - t67) * t70 + (-0.2e1 * t54 * t72 * AA + t67) * t78 + (-t81 - 0.2e1 * t54 * t72 * BB) * t86 + (-t81 + 0.2e1 * t54 * t58 * BB) * t92) * t96 + ((0.2e1 * t99 * t100 * AA + t106) * t70 + (0.2e1 * t99 * t109 * AA - t106) * t78 + (t116 + 0.2e1 * t99 * t109 * BB) * t86 + (t116 - 0.2e1 * t99 * t100 * BB) * t92) * t130 + 0.4e1 * t4 * t98 * t53 * AA) / (((-0.8e1 * t4 - 0.8e1 * t1) * t7 * t24 + 0.8e1 * t141 + 0.8e1 * t7 * t1) * t51 + (0.4e1 * t147 - 0.4e1 * t141) * t151 + 0.4e1 * t147 - 0.4e1 * t141); + + t1 = Rm * Rp; + t2 = Rm * Rm; + t3 = km * km; + t4 = Rp * Rp; + t5 = B * B; + t12 = km * Rm; + t17 = 0.2e1 * Rm; + t18 = cos(t17); + t22 = t2 * Rp; + t25 = t5 * B; + t27 = B * t3; + t33 = t5 * km; + t38 = sin(t17); + t40 = Rm * B; + t41 = 0.3e1 * t5; + t51 = exp(-0.2e1 * Rp); + t53 = t2 * Rm; + t54 = t53 * B; + t56 = 0.5e1 * t1 * t5; + t58 = Rm * t4 * B; + t59 = t3 * Rm; + t60 = t59 * Rp; + t62 = 0.8e1 * t33 * Rp; + t64 = 0.3e1 * Rm * t25; + t65 = t53 * Rp; + t66 = t59 * B; + t67 = t4 * Rp; + t68 = Rm * t67; + t69 = t54 - t56 + t58 + t60 - t62 + t64 - t65 - t66 + t68; + t71 = t2 * t4; + t73 = 0.3e1 * t25 * Rp; + t74 = t2 * t5; + t75 = t27 * Rp; + t76 = B * km; + t78 = 0.2e1 * t76 * t1; + t80 = 0.2e1 * t33 * Rm; + t81 = t22 * B; + t82 = t4 * t5; + t83 = 0.2e1 * t82; + t84 = t67 * B; + t85 = t71 + t73 + t74 - t75 - t78 + t80 - t81 + t83 - t84; + t89 = Rm - km; + t90 = cos(t89); + t92 = t60 - t66 - t65 + t58 + t54 - t56 + t62 + t68 + t64; + t94 = t73 + t78 - t81 + t74 - t80 - t84 - t75 + t83 + t71; + t98 = Rm + km; + t99 = cos(t98); + t105 = sin(t98); + t111 = sin(t89); + t115 = exp(-Rp - B); + t117 = B - Rp; + t118 = Rm * t117; + t121 = t41 - 0.2e1 * Rp * B + t2 - t3 - t4; + t123 = t118 * t121 * AA; + t124 = 0.2e1 * t76; + t125 = t40 + t124 + t1; + t131 = -t124 + t40 + t1; + t141 = t118 * t121 * BB; + t152 = exp(-0.3e1 * Rp - B); + t171 = exp(-0.4e1 * Rp); + _C4 = (((-0.2e1 * t1 * (t2 - t3 - t4 + 0.5e1 * t5) * AA + 0.8e1 * B * BB * t12 * Rp) * t18 + (-0.2e1 * Rp * (-0.2e1 * t5 * Rp - 0.2e1 * t22 + t4 * B - 0.3e1 * t25 + t27 + t2 * B) * AA - 0.8e1 * t33 * BB * Rp) * t38 + 0.2e1 * t40 * (t41 + t4 + t2 - t3) * AA - 0.8e1 * t5 * BB * t12) * t51 + ((t69 * AA - 0.2e1 * t85 * BB) * t90 + (t92 * AA + 0.2e1 * t94 * BB) * t99 + (-0.2e1 * t94 * AA + t92 * BB) * t105 + (-0.2e1 * t85 * AA - t69 * BB) * t111) * t115 + ((-t123 + 0.2e1 * t118 * t125 * BB) * t90 + (-t123 - 0.2e1 * t118 * t131 * BB) * t99 + (0.2e1 * t118 * t131 * AA - t141) * t105 + (0.2e1 * t118 * t125 * AA + t141) * t111) * t152 - 0.2e1 * t123 + 0.8e1 * t40 * km * t117 * BB) / (((-0.8e1 * t2 - 0.8e1 * t5) * t4 * t18 + 0.8e1 * t74 + 0.8e1 * t82) * t51 + (0.4e1 * t71 - 0.4e1 * t74) * t171 + 0.4e1 * t71 - 0.4e1 * t74); + + /******************************************************************/ + /******************************************************************/ + + /*******************************************/ + /* calculate the velocities etc */ + /*******************************************/ + + t2 = exp(UU * z); + t3 = Rm * z; + t4 = cos(t3); + t6 = sin(t3); + t11 = exp(-VV * z); + t18 = exp(-0.2e1 * z * B); + t19 = km * z; + t20 = cos(t19); + t22 = sin(t19); + u1 = kn * (t2 * (_C1 * t4 + _C2 * t6) + t11 * (_C3 * t4 + _C4 * t6) + t18 * (AA * t20 + BB * t22)); + + t1 = Rm * z; + t2 = cos(t1); + t4 = sin(t1); + t14 = exp(UU * z); + t26 = exp(-VV * z); + t28 = km * z; + t29 = cos(t28); + t31 = sin(t28); + t43 = exp(-0.2e1 * z * B); + u2 = (-UU * (_C1 * t2 + _C2 * t4) + _C1 * t4 * Rm - _C2 * t2 * Rm) * t14 + (VV * (_C3 * t2 + _C4 * t4) + _C3 * t4 * Rm - _C4 * t2 * Rm) * t26 + (0.2e1 * B * (AA * t29 + BB * t31) + AA * t31 * km - BB * t29 * km) * t43; + + t2 = 0.2e1 * z * B; + t3 = exp(t2); + t4 = t3 * kn; + t5 = Rm * z; + t6 = cos(t5); + t8 = sin(t5); + t18 = exp(UU * z); + t31 = exp(-VV * z); + t34 = km * z; + t35 = cos(t34); + t37 = sin(t34); + t47 = exp(-t2); + u3 = 0.2e1 * t4 * (UU * (_C1 * t6 + _C2 * t8) - _C1 * t8 * Rm + _C2 * t6 * Rm) * t18 + 0.2e1 * t4 * (-VV * (_C3 * t6 + _C4 * t8) - _C3 * t8 * Rm + _C4 * t6 * Rm) * t31 + 0.2e1 * t4 * (-0.2e1 * B * (AA * t35 + BB * t37) - AA * t37 * km + BB * t35 * km) * t47; + + t1 = Rm * Rm; + t3 = UU * UU; + t8 = kn * kn; + t11 = Rm * z; + t12 = sin(t11); + t14 = cos(t11); + t20 = t14 * Rm; + t27 = 0.2e1 * z * B; + t28 = exp(t27); + t31 = exp(UU * z); + t38 = VV * VV; + t54 = exp(-VV * z); + t56 = km * km; + t59 = B * B; + t66 = km * z; + t67 = sin(t66); + t69 = cos(t66); + t83 = exp(-t27); + u4 = ((_C2 * t1 - t3 * _C2 + 0.2e1 * UU * _C1 * Rm - _C2 * t8) * t12 + _C1 * t14 * t1 - t3 * _C1 * t14 - 0.2e1 * UU * _C2 * t20 - t8 * _C1 * t14) * t28 * t31 + ((-0.2e1 * VV * _C3 * Rm + _C4 * t1 - _C4 * t8 - t38 * _C4) * t12 + 0.2e1 * VV * _C4 * t20 + _C3 * t14 * t1 - t8 * _C3 * t14 - t38 * _C3 * t14) * t28 * t54 + ((BB * t56 - t8 * BB - 0.4e1 * t59 * BB - 0.4e1 * B * AA * km) * t67 + AA * t69 * t56 - t8 * AA * t69 - 0.4e1 * t59 * AA * t69 + 0.4e1 * B * BB * t69 * km) * t28 * t83; + + + t1 = Rm * z; + t2 = sin(t1); + t3 = Rm * Rm; + t4 = t3 * Rm; + t5 = t2 * t4; + t6 = UU * UU; + t7 = t6 * UU; + t8 = cos(t1); + t15 = 0.2e1 * B * t8 * t3; + t19 = B * UU; + t20 = t2 * Rm; + t23 = kn * kn; + t24 = B * t23; + t26 = 0.2e1 * t24 * t8; + t27 = t23 * UU; + t29 = B * t6; + t33 = t23 * t2 * Rm; + t35 = 0.1e1 / kn; + t42 = 0.2e1 * B * t2 * t3; + t43 = t8 * t4; + t45 = 0.2e1 * t24 * t2; + t52 = t23 * t8 * Rm; + t53 = t8 * Rm; + t64 = 0.2e1 * z * B; + t65 = exp(t64); + t68 = exp(UU * z); + t70 = B * VV; + t76 = t23 * VV; + t78 = VV * VV; + t79 = t78 * VV; + t84 = B * t78; + t108 = exp(-VV * z); + t111 = km * z; + t112 = sin(t111); + t113 = km * km; + t118 = cos(t111); + t119 = t118 * km; + t121 = B * B; + t123 = t112 * km; + t130 = t113 * km; + t148 = exp(-t64); + u5 = (-(-t5 - t7 * t8 + 0.3e1 * UU * t8 * t3 + t15 + 0.3e1 * t6 * t2 * Rm + 0.4e1 * t19 * t20 - t26 + t27 * t8 - 0.2e1 * t29 * t8 - t33) * t35 * _C1 - (-t7 * t2 + t27 * t2 + t42 + t43 - t45 + 0.3e1 * UU * t2 * t3 - 0.2e1 * t29 * t2 + t52 - 0.4e1 * t19 * t53 - 0.3e1 * t6 * t8 * Rm) * t35 * _C2) * t65 * t68 + (-(t15 - 0.4e1 * t70 * t20 - t33 - 0.3e1 * VV * t8 * t3 - t76 * t8 + t79 * t8 + 0.3e1 * t78 * t2 * Rm - 0.2e1 * t84 * t8 - t26 - t5) * t35 * _C3 - (t52 - 0.3e1 * VV * t2 * t3 + t79 * t2 + 0.4e1 * t70 * t53 - 0.3e1 * t78 * t8 * Rm - 0.2e1 * t84 * t2 + t43 - t76 * t2 + t42 - t45) * t35 * _C4) * t65 * t108 - t65 * (-0.4e1 * B * BB * t112 * t113 + t23 * BB * t119 + 0.4e1 * t121 * AA * t123 - 0.4e1 * t121 * BB * t119 + BB * t118 * t130 - AA * t112 * t130 - 0.4e1 * B * AA * t118 * t113 - t23 * AA * t123 - 0.4e1 * t24 * AA * t118 - 0.4e1 * t24 * BB * t112) * t35 * t148; + + + + t2 = 0.2e1 * z * B; + t3 = exp(t2); + t4 = t3 * kn; + t5 = Rm * z; + t6 = cos(t5); + t8 = sin(t5); + t18 = exp(UU * z); + t31 = exp(-VV * z); + t34 = km * z; + t35 = cos(t34); + t37 = sin(t34); + t47 = exp(-t2); + u6 = -0.2e1 * t4 * (UU * (_C1 * t6 + _C2 * t8) - _C1 * t8 * Rm + _C2 * t6 * Rm) * t18 - 0.2e1 * t4 * (-VV * (_C3 * t6 + _C4 * t8) - _C3 * t8 * Rm + _C4 * t6 * Rm) * t31 - 0.2e1 * t4 * (-0.2e1 * B * (AA * t35 + BB * t37) - AA * t37 * km + BB * t35 * km) * t47; + + + + + /******************************************************************/ + /******************************************************************/ + + + + sum5 += u5*cos(n*M_PI*x); /* pressure */ + u6 -= u5; /* get total stress */ + sum6 += u6*cos(n*M_PI*x); /* xx stress */ + + u1 *= cos(n*M_PI*x); /* z velocity */ + sum1 += u1; + u2 *= sin(n*M_PI*x); /* x velocity */ + sum2 += u2; + u3 -= u5; /* get total stress */ + u3 *= cos(n*M_PI*x); /* zz stress */ + sum3 += u3; + u4 *= sin(n*M_PI*x); /* zx stress */ + sum4 += u4; + + rho = -sigma*sin(km*z)*cos(kn*x); /* density */ + sum7 += rho; + + SS = exp(UU*z)*(_C1*cos(Rm*z)+_C2*sin(Rm*z)) +exp(-VV*z)*(_C3*cos(Rm*z)+_C4*sin(Rm*z)) + exp(-2*z*B)*(AA*cos(km*z)+BB*sin(km*z)); + SS *= sin(kn*x); /* stream function */ + + //mag=sqrt(u1*u1+u2*u2); + /*printf("%0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f\n",x,z,sum1,sum2,sum3,sum4,sum5,sum6,mag,sum7,SS);*/ + + + /* Output */ + if( vel != NULL ) { + vel[0] = sum2; + vel[1] = sum1; + } + if( presssure != NULL ) { + (*presssure) = sum5; + } + if( total_stress != NULL ) { + total_stress[0] = sum6; + total_stress[1] = sum3; + total_stress[2] = sum4; + } + if( strain_rate != NULL ) { + /* sigma = tau - p, tau = sigma + p, tau[] = 2*eta*strain_rate[] */ + Z = exp( 2.0 * B * z ); + strain_rate[0] = (sum6+sum5)/(2.0*Z); + strain_rate[1] = (sum3+sum5)/(2.0*Z); + strain_rate[2] = (sum4)/(2.0*Z); + } + /* Value checks, could be cleaned up if needed. Julian Giordani 9-Oct-2006*/ +// if( fabs( sum5 - ( -0.5*(sum6+sum3) ) ) > 1e-5 ) { +// assert(0); +// } +} + + diff --git a/src/underworld3/analytic/velic.py b/src/underworld3/analytic/velic.py index 7164b8916..ca6518b9f 100644 --- a/src/underworld3/analytic/velic.py +++ b/src/underworld3/analytic/velic.py @@ -473,6 +473,7 @@ def _use_reference_kernel(self): _B, _M = sympy.symbols("B m") +_KM = sympy.Symbol("km") # The kernel leaves the fields in u1..u6, each still to be multiplied by its # vertical mode. Same convention as SolCx, and the source says so in a comment: @@ -797,3 +798,124 @@ def __init__(self, mesh, beta=4.0): {_X: x, _Y: y, _Z: z, _BETA: sympy.Rational(self.beta)}, ("x", "y", "z"), ) + + +# SolKz transposes SolKx: the viscosity varies with depth, the modes run in x, +# and the kernel's u1 is the *vertical* velocity. Reading it with the SolCx +# convention would silently transpose the whole solution, so the mapping is +# spelled out from the kernel's own output section rather than assumed. +_SOLKZ_OUTPUTS = { + "velocity_x": ("u2", sympy.sin), + "velocity_z": ("u1", sympy.cos), + "stress_xx": ("u6", sympy.cos), + "stress_zz": ("u3", sympy.cos), + "stress_zx": ("u4", sympy.sin), + "pressure": ("u5", sympy.cos), +} + + +@functools.lru_cache(maxsize=None) +def _solkz_kernel(): + """Transcribe the Velic SolKz kernel into SymPy. One straight-line block.""" + + source = CSource(os.path.join(_REFERENCE_DIR, "solKz.c")) + body = source.function("_Velic_solKz") + body = body[: body.index("rho =")] + + inputs = {"pos": (_X, _Z), "_sigma": sympy.Integer(1), "_km": _KM, "_n": _N, "_B": _B} + scope = evaluate_block(body, inputs) + + kn = _N * sympy.pi + return { + field: scope[symbol] * mode(kn * _X) + for field, (symbol, mode) in _SOLKZ_OUTPUTS.items() + } + + +class SolKz(FreeSlipWalls, AnalyticSolution): + r"""Stokes flow with a depth-dependent viscosity — the SolKz benchmark. + + Viscosity :math:`\eta = e^{2Bz}` on the unit box, free slip everywhere, + forced by :math:`\mathbf f = (0,\; \sin(m\pi z)\cos(n\pi x))`. + + The vertical twin of :class:`SolKx`, and not a redundant one. A viscosity + that varies with *depth* stratifies the flow along the direction the buoyancy + acts, so the pressure and the vertical velocity are coupled through the + varying coefficient in a way that a horizontal gradient never produces. It is + also the closer analogue of a real mantle viscosity profile. + + Parameters + ---------- + mesh : Mesh + A 2D mesh on the unit box. + B : float + Viscosity exponent; the contrast across the box is :math:`e^{2B}`. + n : int + Horizontal wavenumber of the forcing. + m : int + Vertical wavenumber. + + Notes + ----- + Validated by the equations rather than against a compiled kernel — the + forcing and boundary conditions are known, so satisfying Stokes with them + identifies the solution uniquely. + """ + + dim = 2 + reference = ( + "Velic. Transcribed from the published kernel vendored at " + "underworld3/analytic/_reference/solKz.c." + ) + eqn_viscosity = r"e^{2Bz}" + eqn_bodyforce = r"(0,\; \sin(m \pi z)\cos(n \pi x))" + + def __init__(self, mesh, B=2.302585092994046, n=3, m=2): + super().__init__(mesh) + + if int(n) != n or int(n) < 1: + raise ValueError("n (horizontal wavenumber) must be a positive integer.") + if int(m) != m or int(m) < 1: + raise ValueError("m (vertical wavenumber) must be a positive integer.") + + self.B = float(B) + self.n = int(n) + self.m = int(m) + + x, z = mesh.X + values = { + _B: sympy.Rational(self.B), + _N: self.n, + _KM: self.m * sympy.pi, + _X: x, + _Z: z, + } + kernel = { + field: expression.subs(values) + for field, expression in _solkz_kernel().items() + } + + self.fn_velocity = sympy.Matrix( + [[kernel["velocity_x"], kernel["velocity_z"]]] + ) + self.fn_pressure = kernel["pressure"] + + # The kernel writes these into an array it calls `total_stress`, but they + # are the DEVIATOR: its xx and zz entries are exact negatives of each + # other, and its zx agrees with 2*eta*edot computed from the velocity to + # machine precision. SolCx and SolKx publish the total, so the family is + # not uniform in this and the name cannot be trusted — measured, not + # assumed. Reading it as total leaves the momentum residual O(f), with + # spurious horizontal forcing, which is how it was found. + deviator = sympy.Matrix( + [ + [kernel["stress_xx"], kernel["stress_zx"]], + [kernel["stress_zx"], kernel["stress_zz"]], + ] + ) + self.fn_stress = deviator - self.fn_pressure * sympy.eye(2) + self.fn_viscosity = sympy.exp(2 * sympy.Rational(self.B) * z) + self.fn_bodyforce = sympy.Matrix( + [[0, sympy.sin(self.m * sympy.pi * z) * sympy.cos(self.n * sympy.pi * x)]] + ) + self.fn_strainrate = deviator / (2 * self.fn_viscosity) diff --git a/tests/test_1023_analytic_solkz.py b/tests/test_1023_analytic_solkz.py new file mode 100644 index 000000000..59b683962 --- /dev/null +++ b/tests/test_1023_analytic_solkz.py @@ -0,0 +1,150 @@ +r"""SolKz — Stokes flow with a depth-dependent viscosity. + +The vertical twin of SolKx: :math:`\eta = e^{2Bz}` instead of :math:`e^{2Bx}`. +Not a redundant one — a viscosity varying with *depth* stratifies the flow along +the direction the buoyancy acts, coupling pressure and vertical velocity through +the varying coefficient in a way a horizontal gradient never does. It is also the +closer analogue of a real mantle viscosity profile. + +Validated by the equations: the forcing and the boundary conditions are known, so +satisfying Stokes with them identifies the solution uniquely. + +The convention trap this solution carries is pinned by +`test_kernel_publishes_the_deviatoric_stress`. Its kernel writes into an array +named `total_stress`, but the contents are the deviator. Reading the name at face +value leaves the momentum residual at order |f| *and* invents a horizontal body +force in a benchmark that has none. + +Run: pixi run python -m pytest tests/test_1023_analytic_solkz.py -v +""" + +import pytest + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + +import numpy as np +import sympy +import underworld3 as uw + + +CASES = [ + (2.302585092994046, 3, 2), + (1.0, 2, 1), + (4.0, 1, 3), + (5.0, 2, 2), +] + +INTERIOR = np.array([(0.2, 0.3), (0.7, 0.8), (0.5, 0.5), (0.9, 0.15), (0.05, 0.95)]) +WALLS = { + "left": np.array([(0.0, t) for t in (0.13, 0.47, 0.82)]), + "right": np.array([(1.0, t) for t in (0.13, 0.47, 0.82)]), + "bottom": np.array([(t, 0.0) for t in (0.13, 0.47, 0.82)]), + "top": np.array([(t, 1.0) for t in (0.13, 0.47, 0.82)]), +} + + +@pytest.fixture(scope="module") +def mesh(): + return uw.meshing.StructuredQuadBox( + elementRes=(4, 4), minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), qdegree=3 + ) + + +def _at(sol, expression, points): + """Magnitudes over a whole point set — lambdified once, not once per point.""" + + from underworld3.analytic import _validation + + return np.abs(_validation.sample(sol, expression, points)) + + +@pytest.mark.parametrize("B,n,m", CASES) +def test_solkz_satisfies_the_stokes_equations(mesh, B, n, m): + sol = uw.analytic.SolKz(mesh, B=B, n=n, m=m) + x, z = mesh.X + + scale = _at(sol, sol.fn_bodyforce[0, 1], INTERIOR).max() + + residual_x = _at( + sol, + sympy.diff(sol.fn_stress[0, 0], x) + sympy.diff(sol.fn_stress[0, 1], z), + INTERIOR, + ) + residual_z = _at( + sol, + sympy.diff(sol.fn_stress[1, 0], x) + + sympy.diff(sol.fn_stress[1, 1], z) + + sol.fn_bodyforce[0, 1], + INTERIOR, + ) + divergence = _at( + sol, + sympy.diff(sol.fn_velocity[0, 0], x) + sympy.diff(sol.fn_velocity[0, 1], z), + INTERIOR, + ) + + assert residual_x.max() / scale < 1.0e-10 + assert residual_z.max() / scale < 1.0e-10 + assert divergence.max() < 1.0e-10 + + +@pytest.mark.parametrize("B,n,m", CASES) +def test_solkz_is_free_slip_on_every_wall(mesh, B, n, m): + sol = uw.analytic.SolKz(mesh, B=B, n=n, m=m) + vx, vz = sol.fn_velocity[0, 0], sol.fn_velocity[0, 1] + + assert _at(sol, vx, WALLS["left"]).max() < 1.0e-10 + assert _at(sol, vx, WALLS["right"]).max() < 1.0e-10 + assert _at(sol, vz, WALLS["bottom"]).max() < 1.0e-10 + assert _at(sol, vz, WALLS["top"]).max() < 1.0e-10 + + +def test_kernel_publishes_the_deviatoric_stress(mesh): + r"""The kernel's ``total_stress`` array holds :math:`\tau`, not :math:`\sigma`. + + Two independent signatures, both checked here because the array's *name* + says otherwise and following it silently breaks the momentum balance: + + - a deviator is traceless, so its xx and zz entries are exact negatives; + - :math:`\tau = 2\eta\dot\varepsilon`, and the strain rate follows from the + velocity, which is a different output of the same kernel. + """ + + sol = uw.analytic.SolKz(mesh, B=2.0, n=2, m=1) + x, z = mesh.X + + deviator = sol.fn_stress + sol.fn_pressure * sympy.eye(2) + + trace = _at(sol, deviator[0, 0] + deviator[1, 1], INTERIOR) + magnitude = _at(sol, deviator[0, 0], INTERIOR).max() + assert trace.max() / magnitude < 1.0e-10 + + from_velocity = sol.fn_viscosity * ( + sympy.diff(sol.fn_velocity[0, 0], z) + sympy.diff(sol.fn_velocity[0, 1], x) + ) + shear = _at(sol, deviator[0, 1] - from_velocity, INTERIOR) + shear_scale = _at(sol, deviator[0, 1], INTERIOR).max() + assert shear.max() / shear_scale < 1.0e-10 + + +def test_solkz_viscosity_varies_with_depth_not_width(mesh): + """The distinction from SolKx, asserted so a copy-paste error cannot hide.""" + + B = 2.0 + sol = uw.analytic.SolKz(mesh, B=B, n=2, m=1) + eta = sympy.lambdify(tuple(mesh.X), sol.fn_viscosity, "numpy") + + assert np.isclose(float(eta(0.2, 0.7)), np.exp(2 * B * 0.7)) + assert np.isclose(float(eta(0.9, 0.7)), np.exp(2 * B * 0.7)) # x makes no difference + assert float(eta(0.5, 0.1)) < float(eta(0.5, 0.9)) + + +def test_solkz_rejects_fractional_wavenumbers(mesh): + with pytest.raises(ValueError, match="n .*must be a positive integer"): + uw.analytic.SolKz(mesh, n=1.5) + with pytest.raises(ValueError, match="m .*must be a positive integer"): + uw.analytic.SolKz(mesh, m=2.5) + + +def test_solkz_is_registered(): + assert "SolKz" in uw.analytic.available() From 77514769bddc3a6221f612255de9552f29402b2d Mon Sep 17 00:00:00 2001 From: lmoresi Date: Mon, 3 Aug 2026 15:10:35 +1000 Subject: [PATCH 12/28] Unify the solution interface, and a conformance suite that covers all of them The solutions had drifted apart. Each assembled its own fn_* attributes and each had its own test file, and that combination let a real error through: SolNL's kernel publishes the deviatoric stress, it was stored as the total, and its momentum residual was 1.06 rather than zero. Its test file checked agreement with the kernel and incompressibility -- both passed -- and nothing checked the momentum balance. Fixed here, and made structurally hard to repeat. Assembly happens once. A solution hands its components to AnalyticSolution.set_fields, which applies the conventions; whether the source publishes sigma or tau is a class-level declaration, stress_is_deviatoric, honoured in exactly one place. Four solutions previously did this by hand, two of them differently. Conformance is checked for every registered solution. tests/test_1024_analytic_conformance.py iterates over uw.analytic.available() and applies the same six checks to all seven: contract populated, metadata declared, incompressible, momentum balance, stress and strain rate consistent. A solution added later is covered the moment it is registered. 35 checks, 101 s. Where a solution differs it says so through the contract rather than being exempted -- sample_points is new for exactly this. The elliptical inclusion is not box-filling and its conformal map is singular at the foci, so the generic unit-box sampler lands on both; it now supplies rings in the matrix instead. Three harness assumptions surfaced only once every solution went through the same path, which is the point of doing it: - adversarial_points only ever made 2D points, so the 3D solution could not be sampled by it at all; - momentum_residual normalised by the body force, and the inclusion is driven entirely by its boundary and has none -- dividing by zero gave 4e+285. It now scales by the largest term being cancelled, which is the right yardstick for a cancellation anyway; - sample silently cast complex results to real. It now checks the imaginary part is round-off first, because a genuinely complex result would mean the construction is wrong and discarding it would hide that. The first version of that check compared imaginary against total magnitude and fired on residuals, where both parts are round-off and the ratio is meaningless -- an absolute floor comes first. Also found while chasing what looked like a slow test: an orphaned full-suite pytest from earlier in the session had been competing for CPU, which is what made several unrelated runs look pathological. The conformance file itself was slow for a real reason too -- building a Stokes solver per solution dominated it, while checking nothing the contract tests do not already cover -- so that check now lives only in test_1016. Verified: 35 conformance checks; 37 transcription tests; 20 contract tests; style gate clean. Underworld development team with AI support from Claude Code --- .../subsystems/analytic-solutions.md | 41 +++++ src/underworld3/analytic/_base.py | 87 ++++++++++ src/underworld3/analytic/_validation.py | 95 ++++++++--- src/underworld3/analytic/inclusion.py | 32 ++++ src/underworld3/analytic/velic.py | 152 +++++++----------- tests/test_1024_analytic_conformance.py | 147 +++++++++++++++++ 6 files changed, 437 insertions(+), 117 deletions(-) create mode 100644 tests/test_1024_analytic_conformance.py diff --git a/docs/developer/subsystems/analytic-solutions.md b/docs/developer/subsystems/analytic-solutions.md index e5fcd4487..15a5fbfea 100644 --- a/docs/developer/subsystems/analytic-solutions.md +++ b/docs/developer/subsystems/analytic-solutions.md @@ -224,6 +224,47 @@ harness: perturb one coefficient and assert the other checks fail. velocity by a part in a thousand and requires both the comparison and the oracle-free residual to report it. +## One interface, so a mistake cannot be local + +Each solution used to assemble its own `fn_*` attributes, and each had its own +test file. That combination let a real error through: SolNL's kernel publishes +the deviatoric stress, it was stored as the total, and its momentum residual was +**1.06** rather than zero. Its test file checked agreement with the kernel and +incompressibility — both passed — and nothing checked the momentum balance. + +Two changes make that class of mistake structural rather than a matter of +remembering. + +**Assembly happens once.** A solution hands its components to +`AnalyticSolution.set_fields`, which applies the conventions. Whether the source +publishes $\sigma$ or $\tau$ is a class-level declaration, +`stress_is_deviatoric`, honoured in exactly one place. A solution can no longer +quietly disagree with its neighbours about what its own stress means. + +**Conformance is checked for every registered solution.** +`tests/test_1024_analytic_conformance.py` iterates over `uw.analytic.available()` +and applies the same checks to all of them: the contract is fully populated, the +metadata is declared, the flow is incompressible, the momentum balance holds, the +stress and strain rate agree, and the boundary conditions configure a solver. A +solution added later is covered the moment it is registered. + +Where a solution differs from the others it says so through the contract rather +than by being exempted: + +| method | default | why a solution overrides it | +|---|---|---| +| `sample_points` | unit box or cube, plus faces and corners | the elliptical inclusion is not box-filling, and the conformal map is singular at its foci — a generic sampler lands on both | +| `boundaries` | box wall labels | a curved geometry has its own | +| `apply_boundary_conditions` | free slip or Dirichlet mixin | — | +| `stress_is_deviatoric` | `False` | the source publishes $\tau$ | + +One consequence worth knowing: `_validation.sample` returns real values, and if +an expression evaluates complex it checks the imaginary part is round-off before +discarding it. The elliptical inclusion is built from complex potentials and is +real-valued without SymPy being able to prove it — but a *genuinely* complex +result would mean the construction is wrong, and silently taking the real part +would hide exactly that. + ## The stress convention is not uniform across the family **Check which stress a kernel publishes. Do not read it off the variable name.** diff --git a/src/underworld3/analytic/_base.py b/src/underworld3/analytic/_base.py index 3c7fb3bae..ebf6edb8c 100644 --- a/src/underworld3/analytic/_base.py +++ b/src/underworld3/analytic/_base.py @@ -78,6 +78,14 @@ class AnalyticSolution(uw_object): nonlinear = False reference = "" + #: Whether this solution's published stress is the deviator rather than the + #: total. Declared, never inferred — the family is not consistent, and one + #: kernel writes its deviator into an array named ``total_stress``. Getting + #: it wrong leaves the momentum residual at order :math:`|\mathbf f|` and + #: invents a body force, which reads like a transcription failure rather + #: than a convention one. See the table in the subsystem documentation. + stress_is_deviatoric = False + eqn_velocity = "" eqn_pressure = "" eqn_viscosity = "" @@ -120,6 +128,85 @@ def boundaries(self): walls += ["Front", "Back"] return walls + def set_fields( + self, velocity, pressure, viscosity, bodyforce, stress=None, strainrate=None + ): + r"""Populate the ``fn_*`` attributes from a solution's own components. + + Every solution goes through here, so the conventions are applied in one + place instead of being re-derived each time. In particular + :attr:`stress_is_deviatoric` is honoured here and nowhere else: a + solution declares which stress its source publishes and this converts, + rather than each one remembering to subtract the pressure. + + Parameters + ---------- + velocity, bodyforce : sequence + ``dim`` components each. + pressure, viscosity : sympy expression + stress : sequence of sequences, optional + ``dim x dim``. Read as the deviator or the total according to + :attr:`stress_is_deviatoric`. Derived from the strain rate if + omitted. + strainrate : sequence of sequences, optional + ``dim x dim``. Derived from the stress if omitted. + + Notes + ----- + Stress and strain rate are related by :math:`\sigma = -p\,I + 2\eta + \dot\varepsilon`, so either determines the other once the pressure and + viscosity are known; supplying both is fine and is worth doing when the + source publishes both, because then they can be checked against each + other. + """ + + self.fn_velocity = sympy.Matrix([list(velocity)]) + self.fn_bodyforce = sympy.Matrix([list(bodyforce)]) + self.fn_pressure = sympy.sympify(pressure) + self.fn_viscosity = sympy.sympify(viscosity) + + identity = sympy.eye(self.dim) + + if stress is not None: + given = sympy.Matrix(stress) + deviator = given if self.stress_is_deviatoric else given + self.fn_pressure * identity + elif strainrate is not None: + deviator = 2 * self.fn_viscosity * sympy.Matrix(strainrate) + else: + raise ValueError("a solution must supply either stress or strainrate") + + self.fn_stress = deviator - self.fn_pressure * identity + self.fn_strainrate = ( + sympy.Matrix(strainrate) + if strainrate is not None + else deviator / (2 * self.fn_viscosity) + ) + + def sample_points(self, count=12): + """Points at which this solution can meaningfully be evaluated. + + The default is the unit box or cube, stratified and loaded with the + boundary and the corners. A solution posed on a different domain — or one + with a singularity somewhere a generic sampler would happily land on — + overrides this. The checks in :mod:`underworld3.analytic._validation` ask + the solution rather than assuming, so adding such a solution does not + require touching them. + + Parameters + ---------- + count : int + Number of interior points. + + Returns + ------- + numpy.ndarray + Shape ``(N, dim)``. + """ + + from ._validation import adversarial_points + + return adversarial_points(count=count, dim=self.dim) + def _exact(self, field): """Resolve a field name — or pass an expression straight through.""" diff --git a/src/underworld3/analytic/_validation.py b/src/underworld3/analytic/_validation.py index 7df6749b1..7bc99e40b 100644 --- a/src/underworld3/analytic/_validation.py +++ b/src/underworld3/analytic/_validation.py @@ -28,41 +28,58 @@ See ``docs/developer/subsystems/analytic-solutions.md``. """ +import itertools + import numpy as np import sympy import underworld3 as uw -def adversarial_points(x_c=None, count=40, seed=20260802): +def adversarial_points(x_c=None, count=40, seed=20260802, dim=2): """Sample points that stress a solution rather than flatter it. - Stratified over the unit box, then loaded with the places these solutions are - hard: either side of a material interface, the walls, and the corners. + Stratified over the unit box or cube, then loaded with the places these + solutions are hard: either side of a material interface, the boundary faces, + and the corners. Parameters ---------- x_c : float, optional - Position of a vertical material interface to sample across. + Position of a material interface normal to x, to sample across. count : int Number of interior points. seed : int Fixed, so a failure is reproducible. + dim : int + 2 or 3. A 3D solution needs 3D points; sampling it on a plane would + leave any error in the third direction unseen. Returns ------- numpy.ndarray - Shape ``(N, 2)``. + Shape ``(N, dim)``. """ rng = np.random.default_rng(seed) - points = list(map(tuple, rng.uniform(0.0, 1.0, size=(count, 2)))) + points = list(map(tuple, rng.uniform(0.0, 1.0, size=(count, dim)))) + interior = (0.37,) * (dim - 1) if x_c is not None: - points += [(x_c - 1.0e-9, 0.37), (x_c + 1.0e-9, 0.37), (x_c, 0.37)] + points += [ + (x_c - 1.0e-9,) + interior, + (x_c + 1.0e-9,) + interior, + (x_c,) + interior, + ] + + # One point on each face, and every corner. + for axis in range(dim): + for value in (0.0, 1.0): + face = [0.31] * dim + face[axis] = value + points.append(tuple(face)) - points += [(0.0, 0.5), (1.0, 0.5), (0.31, 0.0), (0.31, 1.0)] - points += [(0.0, 0.0), (1.0, 1.0), (0.0, 1.0), (1.0, 0.0)] + points += list(itertools.product((0.0, 1.0), repeat=dim)) return np.array(points) @@ -89,11 +106,39 @@ def sample(solution, expression, points): expression = sympy.sympify(expression).subs(dict(zip(coordinates, plain))) points = np.asarray(points, dtype=float) - values = sympy.lambdify(plain, expression, "numpy", cse=True)( - *(points[:, i] for i in range(len(coordinates))) + values = np.asarray( + sympy.lambdify(plain, expression, "numpy", cse=True)( + *(points[:, i] for i in range(len(coordinates))) + ) ) - - return np.broadcast_to(np.asarray(values, dtype=float), (len(points),)) + values = np.broadcast_to(values, (len(points),)) + + if np.iscomplexobj(values): + # A solution built from complex potentials is real-valued, but SymPy + # cannot prove it, so the generated code returns complex. Take the real + # part — after checking the imaginary one is round-off, because a + # genuinely complex result would mean the construction is wrong and + # discarding it silently would hide exactly that. + # + # The relative test alone is not enough. These same functions are used on + # residuals, which are meant to vanish: there the real part is round-off + # too, and comparing one round-off with another reports a large + # "imaginary fraction" for a perfectly good result. So an absolute floor + # comes first — an imaginary part at 1e-12 is noise whatever it is being + # compared with. + largest_imaginary = float(np.max(np.abs(values.imag))) + largest_real = float(np.max(np.abs(values.real))) + + if largest_imaginary > 1.0e-12 and largest_imaginary > 1.0e-8 * largest_real: + raise ValueError( + f"expression evaluated complex: imaginary part reaches " + f"{largest_imaginary:.3e} against a real part of " + f"{largest_real:.3e}. It should be real-valued." + ) + + values = values.real + + return np.asarray(values, dtype=float) def _worst_normalised(mine, theirs): @@ -155,7 +200,7 @@ def incompressibility_residual(solution, points): def momentum_residual(solution, points): - r"""Largest :math:`|\nabla\cdot\sigma + \mathbf f|`, scaled by the forcing. + r"""Largest :math:`|\nabla\cdot\sigma + \mathbf f|`, relative to its terms. Uses the solution's own total (Cauchy) stress and body force, so it needs no reference and does not consult the solver. This is the check that catches a @@ -172,15 +217,19 @@ def momentum_residual(solution, points): scale = 0.0 worst = 0.0 for i in range(dim): - residual = bodyforce[0, i] + sum( - sympy.diff(stress[i, j], coordinates[j]) for j in range(dim) - ) - worst = max( - worst, - float(np.max(np.abs(sample(solution, residual, points)))), - ) - forcing = sample(solution, bodyforce[0, i], points) - scale = max(scale, float(np.max(np.abs(forcing)))) + terms = [sympy.diff(stress[i, j], coordinates[j]) for j in range(dim)] + terms.append(bodyforce[0, i]) + + residual = sum(terms) + worst = max(worst, float(np.max(np.abs(sample(solution, residual, points))))) + + # Scale by the largest term being cancelled, not by the body force. A + # solution driven entirely by its boundary has no body force at all — the + # elliptical inclusion is one — and normalising by it divides by zero. + # The size of the terms is also the right yardstick for a cancellation: + # it says how many digits actually had to cancel. + for term in terms: + scale = max(scale, float(np.max(np.abs(sample(solution, term, points))))) return worst / max(scale, 1.0e-300) diff --git a/src/underworld3/analytic/inclusion.py b/src/underworld3/analytic/inclusion.py index 60156096e..23511c1e6 100644 --- a/src/underworld3/analytic/inclusion.py +++ b/src/underworld3/analytic/inclusion.py @@ -366,6 +366,7 @@ def __init__( ) self._potentials = potentials + self._centre = tuple(float(c) for c in centre) @property def semi_axes(self): @@ -375,6 +376,37 @@ def semi_axes(self): scale = float(self._scale) return scale * (rc + 1 / rc), scale * (rc - 1 / rc) + def sample_points(self, count=12): + """Points in the matrix, clear of the inclusion and of the map's foci. + + The default unit-box sampler is wrong for this solution twice over: the + inclusion is centred wherever the caller put it rather than filling the + box, and the conformal map has branch points at the foci, where the + fields are singular. A generic sampler lands on both. + + Points are laid on rings outside the inclusion, so they exercise the + matrix solution at a range of distances and azimuths without straddling + the interface — across which the stress is discontinuous, so a residual + evaluated there is meaningless rather than merely inaccurate. + """ + + import numpy as np + + a, b = self.semi_axes + rings = np.linspace(1.6, 4.0, max(count // 4, 2)) + angles = np.linspace(0.13, 2 * np.pi + 0.13, 4, endpoint=False) + + return np.array( + [ + ( + self._centre[0] + factor * a * np.cos(theta), + self._centre[1] + factor * b * np.sin(theta), + ) + for factor in rings + for theta in angles + ] + ) + @property def rotation_rate(self): r"""Angular velocity of the inclusion. diff --git a/src/underworld3/analytic/velic.py b/src/underworld3/analytic/velic.py index ca6518b9f..714bb8c9c 100644 --- a/src/underworld3/analytic/velic.py +++ b/src/underworld3/analytic/velic.py @@ -213,30 +213,17 @@ def __init__(self, mesh, eta_A=1.0, eta_B=1.0e6, x_c=0.5, n=1, reference=False): for field, expression in _solcx_kernel().items() } - self.fn_velocity = sympy.Matrix( - [[kernel["velocity_x"], kernel["velocity_z"]]] - ) - self.fn_pressure = kernel["pressure"] - self.fn_stress = sympy.Matrix( - [ - [kernel["stress_xx"], kernel["stress_zx"]], - [kernel["stress_zx"], kernel["stress_zz"]], - ] - ) - # The viscosity tie-break at x == x_c matches the kernel's own step, so a # point exactly on the interface is treated the same way by both. - self.fn_viscosity = sympy.Piecewise((self.eta_A, x < self.x_c), (self.eta_B, True)) - - # sigma = -p I + 2 eta edot, so the strain rate follows from the fields - # the kernel returns. It also returns its own strain rate, derived - # independently — the two are compared as one of the validation gates. - self.fn_strainrate = ( - self.fn_stress + self.fn_pressure * sympy.eye(2) - ) / (2 * self.fn_viscosity) - - self.fn_bodyforce = sympy.Matrix( - [[0, sympy.cos(sympy.pi * x) * sympy.sin(self.n * sympy.pi * z)]] + self.set_fields( + velocity=(kernel["velocity_x"], kernel["velocity_z"]), + pressure=kernel["pressure"], + viscosity=sympy.Piecewise((self.eta_A, x < self.x_c), (self.eta_B, True)), + bodyforce=(0, sympy.cos(sympy.pi * x) * sympy.sin(self.n * sympy.pi * z)), + stress=( + (kernel["stress_xx"], kernel["stress_zx"]), + (kernel["stress_zx"], kernel["stress_zz"]), + ), ) if reference: @@ -387,6 +374,7 @@ class SolNL(FixedWalls, AnalyticSolution): dim = 2 nonlinear = True + stress_is_deviatoric = True reference = ( "Velic. Transcribed from the published kernel vendored at " "underworld3/analytic/_reference/AnalyticSolNL.c." @@ -421,25 +409,19 @@ def __init__(self, mesh, eta_0=1.0, n=1, r=1.5, reference=False): for field, expression in _solnl_kernel().items() } - self.fn_velocity = sympy.Matrix( - [[kernel["velocity_x"], kernel["velocity_z"]]] - ) - self.fn_pressure = kernel["pressure"] - self.fn_viscosity = kernel["viscosity"] - self.fn_bodyforce = sympy.Matrix( - [[kernel["bodyforce_x"], kernel["bodyforce_z"]]] - ) - self.fn_stress = sympy.Matrix( - [ - [kernel["stress_xx"], kernel["stress_xz"]], - [kernel["stress_xz"], kernel["stress_zz"]], - ] - ) - self.fn_strainrate = sympy.Matrix( - [ - [kernel["strainrate_xx"], kernel["strainrate_xz"]], - [kernel["strainrate_xz"], kernel["strainrate_zz"]], - ] + self.set_fields( + velocity=(kernel["velocity_x"], kernel["velocity_z"]), + pressure=kernel["pressure"], + viscosity=kernel["viscosity"], + bodyforce=(kernel["bodyforce_x"], kernel["bodyforce_z"]), + stress=( + (kernel["stress_xx"], kernel["stress_xz"]), + (kernel["stress_xz"], kernel["stress_zz"]), + ), + strainrate=( + (kernel["strainrate_xx"], kernel["strainrate_xz"]), + (kernel["strainrate_xz"], kernel["strainrate_zz"]), + ), ) if reference: @@ -605,24 +587,19 @@ def __init__(self, mesh, B=2.302585092994046, n=3, m=2): for field, expression in _solkx_kernel().items() } - self.fn_velocity = sympy.Matrix( - [[kernel["velocity_x"], kernel["velocity_z"]]] - ) - self.fn_pressure = kernel["pressure"] - self.fn_stress = sympy.Matrix( - [ - [kernel["stress_xx"], kernel["stress_zx"]], - [kernel["stress_zx"], kernel["stress_zz"]], - ] + self.set_fields( + velocity=(kernel["velocity_x"], kernel["velocity_z"]), + pressure=kernel["pressure"], + viscosity=sympy.exp(2 * sympy.Rational(self.B) * x), + bodyforce=( + 0, + sympy.sin(self.m * sympy.pi * z) * sympy.cos(self.n * sympy.pi * x), + ), + stress=( + (kernel["stress_xx"], kernel["stress_zx"]), + (kernel["stress_zx"], kernel["stress_zz"]), + ), ) - self.fn_viscosity = sympy.exp(2 * sympy.Rational(self.B) * x) - self.fn_bodyforce = sympy.Matrix( - [[0, sympy.sin(sympy.Rational(self.m) * sympy.pi * z) - * sympy.cos(self.n * sympy.pi * x)]] - ) - self.fn_strainrate = ( - self.fn_stress + self.fn_pressure * sympy.eye(2) - ) / (2 * self.fn_viscosity) _Y = sympy.Symbol("y") @@ -680,21 +657,14 @@ def block(method): class _SolDB(FixedWalls, AnalyticSolution): """Shared assembly for the Dohrmann–Bochev manufactured solutions.""" + stress_is_deviatoric = True + def _assemble(self, mesh, values, names): kernel = { field: expression.subs(values) for field, expression in _soldb_kernel(self.dim).items() } - self.fn_velocity = sympy.Matrix( - [[kernel[f"velocity_{n}"] for n in names]] - ) - self.fn_bodyforce = sympy.Matrix( - [[kernel[f"bodyforce_{n}"] for n in names]] - ) - self.fn_pressure = kernel["pressure"] - self.fn_viscosity = kernel["viscosity"] - def tensor(prefix): return sympy.Matrix( [ @@ -708,13 +678,14 @@ def tensor(prefix): ] ) - # These kernels publish the DEVIATORIC stress, unlike SolCx and SolKx - # which return the total. The contract wants Cauchy, so the pressure goes - # back in: sigma = tau - p I. Getting this wrong would leave the momentum - # residual non-zero by exactly grad(p), which is easy to mistake for a - # transcription error. - self.fn_stress = tensor("stress") - self.fn_pressure * sympy.eye(self.dim) - self.fn_strainrate = tensor("strainrate") + self.set_fields( + velocity=[kernel[f"velocity_{n}"] for n in names], + pressure=kernel["pressure"], + viscosity=kernel["viscosity"], + bodyforce=[kernel[f"bodyforce_{n}"] for n in names], + stress=tensor("stress"), + strainrate=tensor("strainrate"), + ) class SolDB2d(_SolDB): @@ -863,6 +834,7 @@ class SolKz(FreeSlipWalls, AnalyticSolution): """ dim = 2 + stress_is_deviatoric = True reference = ( "Velic. Transcribed from the published kernel vendored at " "underworld3/analytic/_reference/solKz.c." @@ -898,24 +870,16 @@ def __init__(self, mesh, B=2.302585092994046, n=3, m=2): self.fn_velocity = sympy.Matrix( [[kernel["velocity_x"], kernel["velocity_z"]]] ) - self.fn_pressure = kernel["pressure"] - - # The kernel writes these into an array it calls `total_stress`, but they - # are the DEVIATOR: its xx and zz entries are exact negatives of each - # other, and its zx agrees with 2*eta*edot computed from the velocity to - # machine precision. SolCx and SolKx publish the total, so the family is - # not uniform in this and the name cannot be trusted — measured, not - # assumed. Reading it as total leaves the momentum residual O(f), with - # spurious horizontal forcing, which is how it was found. - deviator = sympy.Matrix( - [ - [kernel["stress_xx"], kernel["stress_zx"]], - [kernel["stress_zx"], kernel["stress_zz"]], - ] - ) - self.fn_stress = deviator - self.fn_pressure * sympy.eye(2) - self.fn_viscosity = sympy.exp(2 * sympy.Rational(self.B) * z) - self.fn_bodyforce = sympy.Matrix( - [[0, sympy.sin(self.m * sympy.pi * z) * sympy.cos(self.n * sympy.pi * x)]] + self.set_fields( + velocity=(kernel["velocity_x"], kernel["velocity_z"]), + pressure=kernel["pressure"], + viscosity=sympy.exp(2 * sympy.Rational(self.B) * z), + bodyforce=( + 0, + sympy.sin(self.m * sympy.pi * z) * sympy.cos(self.n * sympy.pi * x), + ), + stress=( + (kernel["stress_xx"], kernel["stress_zx"]), + (kernel["stress_zx"], kernel["stress_zz"]), + ), ) - self.fn_strainrate = deviator / (2 * self.fn_viscosity) diff --git a/tests/test_1024_analytic_conformance.py b/tests/test_1024_analytic_conformance.py new file mode 100644 index 000000000..773aa6d41 --- /dev/null +++ b/tests/test_1024_analytic_conformance.py @@ -0,0 +1,147 @@ +r"""Every registered solution, checked the same way. + +The per-solution test files check what is particular to each. This one checks +what is true of all of them, by iterating over `uw.analytic.available()` — so a +solution added later is covered the moment it is registered, and cannot ship +without these checks the way SolNL did. + +That is not hypothetical. SolNL's kernel publishes the deviatoric stress; it was +stored as the total, and its momentum residual was 1.06 rather than zero. Its own +test file checked agreement with the kernel and incompressibility, both of which +passed, and nothing checked the momentum balance. The stress convention is now a +declaration (`stress_is_deviatoric`) applied in one place, and this file makes +the omission impossible to repeat. + +Run: pixi run python -m pytest tests/test_1024_analytic_conformance.py -v +""" + +import pytest + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + +import numpy as np +import sympy +import underworld3 as uw + + +SOLUTIONS = sorted(uw.analytic.available()) + + +@pytest.fixture(scope="module") +def meshes(): + return { + 2: uw.meshing.StructuredQuadBox( + elementRes=(4, 4), minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), qdegree=3 + ), + 3: uw.meshing.StructuredQuadBox( + elementRes=(2, 2, 2), + minCoords=(0.0, 0.0, 0.0), + maxCoords=(1.0, 1.0, 1.0), + qdegree=2, + ), + } + + +@pytest.fixture(scope="module") +def built(meshes): + """Every registered solution, constructed once. + + Construction substitutes parameters into expressions that run to tens of + thousands of operations for the series solutions, so building afresh in each + test dominates the runtime of the whole file. + + Building them all here also asserts something worth asserting: every solution + must be constructible from a mesh alone. The defaults are part of the + interface, not an afterthought. + """ + + return { + name: getattr(uw.analytic, name)(meshes[getattr(uw.analytic, name).dim]) + for name in SOLUTIONS + } + + +@pytest.mark.parametrize("name", SOLUTIONS) +def test_solution_declares_its_metadata(name): + """dim, a citation, and a stress convention — all stated, none inferred.""" + + solution = getattr(uw.analytic, name) + + assert solution.dim in (2, 3), f"{name} must declare its dimension" + assert solution.reference, f"{name} must cite where it came from" + assert isinstance(solution.stress_is_deviatoric, bool) + assert isinstance(solution.nonlinear, bool) + + +@pytest.mark.parametrize("name", SOLUTIONS) +def test_solution_exposes_the_whole_contract(name, built): + sol = built[name] + dim = sol.dim + + assert sol.fn_velocity.shape == (1, dim) + assert sol.fn_bodyforce.shape == (1, dim) + assert sol.fn_stress.shape == (dim, dim) + assert sol.fn_strainrate.shape == (dim, dim) + assert sol.fn_pressure is not None + assert sol.fn_viscosity is not None + + +@pytest.mark.parametrize("name", SOLUTIONS) +def test_solution_is_incompressible(name, built): + from underworld3.analytic import _validation + + sol = built[name] + points = sol.sample_points(count=8) + + assert _validation.incompressibility_residual(sol, points) < 1.0e-8 + + +@pytest.mark.parametrize("name", SOLUTIONS) +def test_solution_satisfies_the_momentum_balance(name, built): + r""":math:`\nabla\cdot\sigma + \mathbf f = 0`, for every solution. + + The check that catches a stress-convention error, and the one SolNL was + missing. It consults no reference and no solver, so it cannot be fooled by a + mistake shared between the solution and something derived from it. + """ + + from underworld3.analytic import _validation + + sol = built[name] + points = sol.sample_points(count=8) + + assert _validation.momentum_residual(sol, points) < 1.0e-8 + + +@pytest.mark.parametrize("name", SOLUTIONS) +def test_stress_and_strain_rate_agree(name, built): + r""":math:`\sigma + p\,I = 2\eta\dot\varepsilon`, however each was obtained. + + Some solutions publish both and some derive one from the other; either way + the pair has to be consistent, and a wrong `stress_is_deviatoric` shows up + here as a full pressure's worth of disagreement. + """ + + from underworld3.analytic import _validation + + sol = built[name] + points = sol.sample_points(count=8) + identity = sympy.eye(sol.dim) + + deviator = sol.fn_stress + sol.fn_pressure * identity + scale = max( + np.abs(_validation.sample(sol, deviator[i, j], points)).max() + for i in range(sol.dim) + for j in range(sol.dim) + ) + + for i in range(sol.dim): + for j in range(sol.dim): + difference = deviator[i, j] - 2 * sol.fn_viscosity * sol.fn_strainrate[i, j] + assert np.abs(_validation.sample(sol, difference, points)).max() / scale < 1.0e-8 + + +# The boundary-condition mixins are exercised in test_1016_analytic_contract.py. +# Building a Stokes solver per solution here as well was not worth what it cost: +# it dominated the runtime of this file without checking anything the contract +# tests do not already cover. From 4e25f24c19ced66121dad96476c4771a1cf5c8b1 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Mon, 3 Aug 2026 15:27:23 +1000 Subject: [PATCH 13/28] SolA and SolB: the two isoviscous solutions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uw.analytic.SolA and SolB — constant viscosity on the unit box, free slip, forced by (0, sigma * sin(m pi z) cos(n pi x)) and its sinh counterpart. Worth having precisely because they are the simplest. They remove the viscosity structure entirely, so a discrepancy is in the discretisation or the solve rather than in how a hard coefficient is handled: run SolA before concluding anything from SolCx or SolKx. SolB then concentrates the response near one boundary instead of filling the box, which probes resolution where the solution is steep rather than accuracy where it is smooth. Both passed the conformance checks on the first attempt, including the momentum balance — so the forcing conventions inferred from the kernels were right, which after SolKz was not a safe assumption. Their stress is the TOTAL, not the deviator, and SolA is the clearest case in the family to read: its source writes `u3 = 2*kn*ss_z - pp`, with the pressure subtracted in plain sight, where SolKz's writes the same quantity without it. The provenance table now records which solution publishes which, and that SolNL belongs on the deviatoric side. One transcriber addition: sinh, cosh and tanh, which SolB needs and no earlier kernel used. The failure was clean and immediate — a NameError from the generated expression, not a wrong answer — which is the right way for an unsupported function to fail. Note the conformance fixture builds every registered solution together, so one solution failing to construct errors all of them. That is the cost of the shared fixture and it is worth it: the alternative is each solution's checks living somewhere they can be forgotten. Verified: 45 conformance checks over nine solutions plus 20 contract tests, 65 passing after a clean rebuild; style gate clean. uw.analytic.available() now lists EllipticalInclusion, SolA, SolB, SolCx, SolDB2d, SolDB3d, SolKx, SolKz, SolNL. Underworld development team with AI support from Claude Code --- .../subsystems/analytic-solutions.md | 7 +- src/underworld3/analytic/__init__.py | 6 +- src/underworld3/analytic/_transcribe.py | 3 + src/underworld3/analytic/velic.py | 168 ++++++++++++++++++ 4 files changed, 181 insertions(+), 3 deletions(-) diff --git a/docs/developer/subsystems/analytic-solutions.md b/docs/developer/subsystems/analytic-solutions.md index 15a5fbfea..ae884faa3 100644 --- a/docs/developer/subsystems/analytic-solutions.md +++ b/docs/developer/subsystems/analytic-solutions.md @@ -271,8 +271,11 @@ would hide exactly that. | solution | its stress output is | |---|---| -| SolCx, SolKx | total (Cauchy) $\sigma$ | -| SolKz, SolDB2d, SolDB3d | deviatoric $\tau$ | +| SolA, SolB, SolCx, SolKx | total (Cauchy) $\sigma$ | +| SolKz, SolNL, SolDB2d, SolDB3d | deviatoric $\tau$ | + +SolA is the clearest case to read: its source writes `u3 = 2*kn*ss_z - pp`, with the +pressure subtracted in plain sight. SolKz's writes the same quantity without it. SolKz is the trap: it writes into an array literally called `total_stress`, and the contents are the deviator. Taking the name at face value leaves the momentum diff --git a/src/underworld3/analytic/__init__.py b/src/underworld3/analytic/__init__.py index d64003a87..18e2c0de4 100644 --- a/src/underworld3/analytic/__init__.py +++ b/src/underworld3/analytic/__init__.py @@ -29,13 +29,15 @@ from ._base import AnalyticSolution, FreeSlipWalls, FixedWalls from .inclusion import EllipticalInclusion -from .velic import SolCx, SolDB2d, SolDB3d, SolKx, SolKz, SolNL +from .velic import SolA, SolB, SolCx, SolDB2d, SolDB3d, SolKx, SolKz, SolNL __all__ = [ "AnalyticSolution", "FreeSlipWalls", "FixedWalls", "EllipticalInclusion", + "SolA", + "SolB", "SolCx", "SolDB2d", "SolDB3d", @@ -52,6 +54,8 @@ # without being importable. _SOLUTIONS = { "EllipticalInclusion": EllipticalInclusion, + "SolA": SolA, + "SolB": SolB, "SolCx": SolCx, "SolDB2d": SolDB2d, "SolDB3d": SolDB3d, diff --git a/src/underworld3/analytic/_transcribe.py b/src/underworld3/analytic/_transcribe.py index 426223bd9..08a45e257 100644 --- a/src/underworld3/analytic/_transcribe.py +++ b/src/underworld3/analytic/_transcribe.py @@ -37,6 +37,9 @@ "sin": sympy.sin, "cos": sympy.cos, "sqrt": sympy.sqrt, + "sinh": sympy.sinh, + "cosh": sympy.cosh, + "tanh": sympy.tanh, "pow": lambda base, exponent: base**exponent, "M_PI": sympy.pi, "PetscExpReal": sympy.exp, diff --git a/src/underworld3/analytic/velic.py b/src/underworld3/analytic/velic.py index 714bb8c9c..aed6bf5f1 100644 --- a/src/underworld3/analytic/velic.py +++ b/src/underworld3/analytic/velic.py @@ -883,3 +883,171 @@ def __init__(self, mesh, B=2.302585092994046, n=3, m=2): (kernel["stress_zx"], kernel["stress_zz"]), ), ) + + +_SIGMA = sympy.Symbol("sigma") + +# SolA and SolB share a kernel shape, and the source states it outright: +# "u1 = Vz, u2 = Vx, u3 = tzz, u4 = tzx, pp = pressure", with txx alongside. +# Modes run in x, as in SolKz. The stress is the TOTAL: u3 and txx both carry an +# explicit `- pp`. +_SOLAB_OUTPUTS = { + "velocity_x": ("u2", sympy.sin), + "velocity_z": ("u1", sympy.cos), + "stress_xx": ("txx", sympy.cos), + "stress_zz": ("u3", sympy.cos), + "stress_zx": ("u4", sympy.sin), + "pressure": ("pp", sympy.cos), +} + + +@functools.lru_cache(maxsize=None) +def _solab_kernel(name): + """Transcribe SolA or SolB — one straight-line block, isoviscous.""" + + source = CSource(os.path.join(_REFERENCE_DIR, f"{name}.c")) + body = source.function(f"_Velic_{name}") + body = body[: body.index("e_zz =")] + + inputs = { + "pos": (_X, _Z), + "sigma": _SIGMA, + "Z": _ETA0, + "n": _N, + "km": _KM, + } + scope = evaluate_block(body, inputs) + + kn = _N * sympy.pi + return { + field: scope[symbol] * mode(kn * _X) + for field, (symbol, mode) in _SOLAB_OUTPUTS.items() + } + + +class _SolAB(FreeSlipWalls, AnalyticSolution): + """Shared assembly for the two isoviscous Velic solutions.""" + + dim = 2 + _kernel = None + _vertical = None + + def __init__(self, mesh, sigma=1.0, eta=1.0, n=3, m=2): + super().__init__(mesh) + + if int(n) != n or int(n) < 1: + raise ValueError("n (horizontal wavenumber) must be a positive integer.") + if float(eta) <= 0.0: + raise ValueError("eta must be positive.") + + self.sigma = float(sigma) + self.eta = float(eta) + self.n = int(n) + self.m = float(m) + + x, z = mesh.X + values = { + _SIGMA: sympy.Rational(self.sigma), + _ETA0: sympy.Rational(self.eta), + _N: self.n, + _KM: sympy.Rational(self.m) * sympy.pi, + _X: x, + _Z: z, + } + kernel = { + field: expression.subs(values) + for field, expression in _solab_kernel(self._kernel).items() + } + + self.set_fields( + velocity=(kernel["velocity_x"], kernel["velocity_z"]), + pressure=kernel["pressure"], + viscosity=sympy.Rational(self.eta), + bodyforce=( + 0, + sympy.Rational(self.sigma) + * self._vertical(sympy.Rational(self.m) * sympy.pi * z) + * sympy.cos(self.n * sympy.pi * x), + ), + stress=( + (kernel["stress_xx"], kernel["stress_zx"]), + (kernel["stress_zx"], kernel["stress_zz"]), + ), + ) + + +class SolA(_SolAB): + r"""Isoviscous Stokes flow with a sinusoidal body force — the SolA benchmark. + + Constant viscosity on the unit box, free slip everywhere, forced by + :math:`\mathbf f = (0,\; \sigma\sin(m\pi z)\cos(n\pi x))`. + + The simplest solution in the suite, and useful precisely for that: it removes + the viscosity structure entirely, so a discrepancy here is in the + discretisation or the solve rather than in how a hard coefficient is handled. + Run it before concluding anything from SolCx or SolKx. + + Parameters + ---------- + mesh : Mesh + A 2D mesh on the unit box. + sigma : float + Forcing amplitude. + eta : float + The (constant) viscosity. + n : int + Horizontal wavenumber. + m : float + Vertical wavenumber; need not be an integer for the solution itself. + """ + + _kernel = "solA" + _vertical = staticmethod(sympy.sin) + reference = ( + "Velic. Transcribed from the published kernel vendored at " + "underworld3/analytic/_reference/solA.c." + ) + eqn_viscosity = r"\eta" + eqn_bodyforce = r"(0,\; \sigma \sin(m \pi z)\cos(n \pi x))" + + +class SolB(_SolAB): + r"""Isoviscous Stokes flow with a hyperbolic body force — the SolB benchmark. + + As :class:`SolA`, but the forcing grows with depth as + :math:`\sinh(m\pi z)` rather than oscillating. The response is concentrated + near one boundary instead of filling the box, which is a different test of + the same machinery: it probes resolution where the solution is steep rather + than accuracy where it is smooth. + + Parameters + ---------- + mesh : Mesh + A 2D mesh on the unit box. + sigma : float + Forcing amplitude. + eta : float + The (constant) viscosity. + n : int + Horizontal wavenumber. + m : float + Vertical decay parameter. Must differ from *n* — the kernel is singular + when they coincide. + """ + + _kernel = "solB" + _vertical = staticmethod(sympy.sinh) + reference = ( + "Velic. Transcribed from the published kernel vendored at " + "underworld3/analytic/_reference/solB.c." + ) + eqn_viscosity = r"\eta" + eqn_bodyforce = r"(0,\; \sigma \sinh(m \pi z)\cos(n \pi x))" + + def __init__(self, mesh, sigma=1.0, eta=1.0, n=3, m=2.0): + if abs(float(n) - float(m)) < 1.0e-5: + raise ValueError( + "SolB is singular when the horizontal and vertical wavenumbers " + "coincide; choose n != m." + ) + super().__init__(mesh, sigma=sigma, eta=eta, n=n, m=m) From 906c221b5b7f89ecd57d93a232d24f54fdfd150a Mon Sep 17 00:00:00 2001 From: lmoresi Date: Mon, 3 Aug 2026 15:44:54 +1000 Subject: [PATCH 14/28] SolM, and a published stress that is wrong MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uw.analytic.SolM — Stokes flow with a laterally oscillating viscosity, 1 + eta_0(1 + cos(r pi x)), free slip on the unit box. Worth having because its viscosity oscillates rather than jumping (SolCx) or varying monotonically (SolKx, SolKz), and its wavelength is independent of the flow's. It is the one solution here where the coefficient structure and the solution structure can be deliberately mismatched: choose r incommensurate with n and every element sees a different viscosity profile, which tests quadrature more sharply than a smooth gradient does. The kernel's published stress is wrong. It declares its viscosity as (1 + cos(kr x)) eta0 + 1 and then computes stress as 2 (eta - 1) edot -- the constant part is missing. That is a defect in the source, not a transcription slip: the difference from 2 (eta - 1) edot is EXACTLY zero, and using the published stress leaves the momentum residual at 0.21 where deriving it from the kernel's own strain rate and viscosity gives 1.7e-16. Everything else SolM publishes is mutually consistent, so the transcription supplies the strain rate and lets set_fields derive the stress. This is the case for a check that consults no reference. Comparing SolM against its own kernel would have reproduced the error faithfully and reported agreement; only the momentum residual could see it. One transcriber addition: assignment targets may now carry an [index] as well as a struct prefix, because these kernels return results through out.xx = ... or out[0] = ... depending on vintage. Without it `out[0] = ...` matched nothing -- a loud failure rather than a quiet one, but a failure. Also fixed: an over-broad edit had replaced the same block in SolNL, which shares its shape, leaving SolNL deriving its stress under a comment about SolM's viscosity. SolNL publishes a correct stress and a correct strain rate, so it supplies both and the conformance check compares them. Verified: 50 conformance checks over ten solutions plus 20 contract tests, 70 passing after a clean rebuild; style gate clean. Underworld development team with AI support from Claude Code --- .../subsystems/analytic-solutions.md | 18 +++ src/underworld3/analytic/__init__.py | 4 +- src/underworld3/analytic/_transcribe.py | 15 +- src/underworld3/analytic/velic.py | 132 ++++++++++++++++++ 4 files changed, 163 insertions(+), 6 deletions(-) diff --git a/docs/developer/subsystems/analytic-solutions.md b/docs/developer/subsystems/analytic-solutions.md index ae884faa3..eada2e272 100644 --- a/docs/developer/subsystems/analytic-solutions.md +++ b/docs/developer/subsystems/analytic-solutions.md @@ -277,6 +277,24 @@ would hide exactly that. SolA is the clearest case to read: its source writes `u3 = 2*kn*ss_z - pp`, with the pressure subtracted in plain sight. SolKz's writes the same quantity without it. +### One published stress is simply wrong + +SolM's kernel declares its viscosity as $(1 + \cos(r\pi x))\eta_0 + 1$ and then +computes its stress as $2(\eta - 1)\dot\varepsilon$ — the constant part is +missing. The difference from $2(\eta-1)\dot\varepsilon$ is *exactly* zero, so +this is a defect in the source rather than a transcription slip: using the +published stress leaves the momentum residual at **0.21**, while deriving it from +the kernel's own strain rate and viscosity gives **1.7e-16**. + +Everything else SolM publishes — velocity, pressure, strain rate, viscosity, body +force — is mutually consistent. Only the stress output is defective, so the +transcription supplies the strain rate to `set_fields` and lets the stress be +derived. + +This is the case for having a check that consults no reference. Comparing SolM +against its own kernel would have reproduced the error faithfully and reported +agreement. + SolKz is the trap: it writes into an array literally called `total_stress`, and the contents are the deviator. Taking the name at face value leaves the momentum residual at order $|\mathbf f|$ *and* manufactures a horizontal body force in a diff --git a/src/underworld3/analytic/__init__.py b/src/underworld3/analytic/__init__.py index 18e2c0de4..b9973dc6a 100644 --- a/src/underworld3/analytic/__init__.py +++ b/src/underworld3/analytic/__init__.py @@ -29,7 +29,7 @@ from ._base import AnalyticSolution, FreeSlipWalls, FixedWalls from .inclusion import EllipticalInclusion -from .velic import SolA, SolB, SolCx, SolDB2d, SolDB3d, SolKx, SolKz, SolNL +from .velic import SolA, SolB, SolCx, SolDB2d, SolDB3d, SolKx, SolKz, SolM, SolNL __all__ = [ "AnalyticSolution", @@ -43,6 +43,7 @@ "SolDB3d", "SolKx", "SolKz", + "SolM", "SolNL", "available", "describe", @@ -61,6 +62,7 @@ "SolDB3d": SolDB3d, "SolKx": SolKx, "SolKz": SolKz, + "SolM": SolM, "SolNL": SolNL, } diff --git a/src/underworld3/analytic/_transcribe.py b/src/underworld3/analytic/_transcribe.py index 08a45e257..155ba5675 100644 --- a/src/underworld3/analytic/_transcribe.py +++ b/src/underworld3/analytic/_transcribe.py @@ -93,11 +93,16 @@ def _split_declarators(block): return "".join(out) -# The assignment target keeps any `struct.` prefix. Without it, `out.x = ...` -# reads as an assignment to `x` and silently overwrites the coordinate symbol — -# every later statement referring to x then gets the wrong thing, and the result -# looks plausible rather than broken. -_STATEMENT = re.compile(r"((?:\w+\.)?\w+)\s*=\s*([^;]+);") +# The assignment target keeps whatever it is written through: a `struct.` prefix +# or an `[index]` suffix. Both occur — these kernels return their results through +# `out.xx = ...` or `out[0] = ...` depending on their vintage. +# +# The prefix is not cosmetic. Without it, `out.x = ...` reads as an assignment to +# `x` and silently overwrites the coordinate symbol; every later statement using x +# then gets the wrong thing, and the result looks plausible rather than broken. +# Without the suffix, `out[0] = ...` matches nothing at all — a loud failure +# rather than a quiet one, but a failure. +_STATEMENT = re.compile(r"((?:\w+\.)?\w+(?:\[\d+\])?)\s*=\s*([^;]+);") _FLOAT_LITERAL = re.compile(r"\b\d+\.\d*(?:[eE][+-]?\d+)?") diff --git a/src/underworld3/analytic/velic.py b/src/underworld3/analytic/velic.py index aed6bf5f1..591dfb337 100644 --- a/src/underworld3/analytic/velic.py +++ b/src/underworld3/analytic/velic.py @@ -414,6 +414,8 @@ def __init__(self, mesh, eta_0=1.0, n=1, r=1.5, reference=False): pressure=kernel["pressure"], viscosity=kernel["viscosity"], bodyforce=(kernel["bodyforce_x"], kernel["bodyforce_z"]), + # Both are published and both are consistent, so both are supplied: + # the conformance check then compares them against each other. stress=( (kernel["stress_xx"], kernel["stress_xz"]), (kernel["stress_xz"], kernel["stress_zz"]), @@ -455,6 +457,7 @@ def _use_reference_kernel(self): _B, _M = sympy.symbols("B m") +_KN = sympy.Symbol("kn_solm") _KM = sympy.Symbol("km") # The kernel leaves the fields in u1..u6, each still to be multiplied by its @@ -1051,3 +1054,132 @@ def __init__(self, mesh, sigma=1.0, eta=1.0, n=3, m=2.0): "coincide; choose n != m." ) super().__init__(mesh, sigma=sigma, eta=eta, n=n, m=m) + + +_KR = sympy.Symbol("kr") + + +@functools.lru_cache(maxsize=None) +def _solm_kernel(): + """Transcribe SolM. Short methods writing straight into the output array.""" + + source = CSource(os.path.join(_REFERENCE_DIR, "AnalyticSolM.hpp")) + + # km, kn and kr are initialised in the class body rather than in any method, + # so they are supplied rather than read. + inputs = {"in": (_X, _Z), "eta0": _ETA0, "km": _KM, "kn": _KN, "kr": _KR} + + def block(method): + return evaluate_block(source.function(method), inputs) + + velocity, bodyforce = block("velocity"), block("bodyforce") + stress, strainrate = block("stress"), block("strainrate") + + return { + "velocity_x": velocity["out[0]"], + "velocity_z": velocity["out[1]"], + "bodyforce_x": bodyforce["out[0]"], + "bodyforce_z": bodyforce["out[1]"], + "pressure": block("pressure")["p"], + "viscosity": block("viscosity")["out[0]"], + "stress_xx": stress["out[0]"], + "stress_zz": stress["out[1]"], + "stress_xz": stress["out[2]"], + "strainrate_xx": strainrate["out[0]"], + "strainrate_zz": strainrate["out[1]"], + "strainrate_xz": strainrate["out[2]"], + } + + +class SolM(FreeSlipWalls, AnalyticSolution): + r"""Stokes flow with a laterally oscillating viscosity — the SolM benchmark. + + Viscosity :math:`\eta = 1 + \eta_0(1 + \cos(r\pi x))` on the unit box, free + slip everywhere, with the body force chosen to make a simple sinusoidal + velocity exact. + + The viscosity here *oscillates* rather than jumping (SolCx) or varying + monotonically (SolKx, SolKz), and its wavelength is independent of the + flow's. That makes it the one solution in the suite where the coefficient + structure and the solution structure can be deliberately mismatched — set + ``r`` incommensurate with ``n`` and every element sees a different viscosity + profile, which is a sharper test of quadrature than a smooth gradient. + + Parameters + ---------- + mesh : Mesh + A 2D mesh on the unit box. + eta_0 : float + Amplitude of the viscosity oscillation. + n : int + Horizontal wavenumber of the flow. + m : int + Vertical wavenumber of the flow. + r : float + Wavenumber of the viscosity oscillation. Need not be an integer, and is + most interesting when it is not commensurate with *n*. + + Notes + ----- + The kernel names its wavenumbers the other way round — its ``m`` multiplies + :math:`x` and its ``n`` multiplies :math:`z`. The parameters here follow the + convention used across this suite, ``n`` horizontal and ``m`` vertical, and + the mapping is done at construction. + """ + + dim = 2 + stress_is_deviatoric = True + reference = ( + "Velic. Transcribed from the published kernel vendored at " + "underworld3/analytic/_reference/AnalyticSolM.hpp." + ) + eqn_velocity = r"(-\sin(n\pi x)\,m\pi\cos(m\pi z),\; \cos(n\pi x)\,n\pi\sin(m\pi z))" + eqn_viscosity = r"1 + \eta_0\,(1 + \cos(r \pi x))" + + def __init__(self, mesh, eta_0=1.0, n=3, m=2, r=4.0): + super().__init__(mesh) + + if int(n) != n or int(n) < 1: + raise ValueError("n (horizontal wavenumber) must be a positive integer.") + if int(m) != m or int(m) < 1: + # Free slip on the horizontal walls needs sin(m pi z) to vanish at + # z = 1, exactly as for SolKx. + raise ValueError("m (vertical wavenumber) must be a positive integer.") + + self.eta_0 = float(eta_0) + self.n = int(n) + self.m = int(m) + self.r = float(r) + + x, z = mesh.X + values = { + _ETA0: sympy.Rational(self.eta_0), + _KM: self.n * sympy.pi, # the kernel's km multiplies x + _KN: self.m * sympy.pi, # and its kn multiplies z + _KR: sympy.Rational(self.r) * sympy.pi, + _X: x, + _Z: z, + } + kernel = { + field: expression.subs(values) + for field, expression in _solm_kernel().items() + } + + self.set_fields( + velocity=(kernel["velocity_x"], kernel["velocity_z"]), + pressure=kernel["pressure"], + viscosity=kernel["viscosity"], + bodyforce=(kernel["bodyforce_x"], kernel["bodyforce_z"]), + # The stress is DERIVED from the strain rate here rather than taken + # from the kernel, because the kernel's is wrong. Its viscosity is + # (1 + cos(kr x)) eta0 + 1, but its stress is 2 (eta - 1) edot: the + # constant part is missing. Measured, not guessed — the difference + # from 2 (eta - 1) edot is exactly zero, and using the published + # stress leaves the momentum residual at 0.21 where deriving it gives + # 1.7e-16. Everything else the kernel publishes is mutually + # consistent; only this output is defective. + strainrate=( + (kernel["strainrate_xx"], kernel["strainrate_xz"]), + (kernel["strainrate_xz"], kernel["strainrate_zz"]), + ), + ) From 38f4c704127af2b26150979258365602e2402a77 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Mon, 3 Aug 2026 15:53:02 +1000 Subject: [PATCH 15/28] SolC: the first truncated-series solution, and the sign that only momentum sees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uw.analytic.SolC — isoviscous flow on the unit box driven by a dense column, sigma for x < x_c and zero beyond, free slip everywhere. It pairs with SolCx: SolCx puts a jump in the operator, SolC puts one in the right-hand side. The response is smooth in both, so trouble in either is in how the discontinuity is integrated rather than in the flow itself. This is the first solution here that is a truncated Fourier series rather than a closed form, which the transcriber now supports: CSource.loop_body extracts the mode loop and the caller evaluates it once per mode with the index bound, summing in SymPy. The accumulation itself cannot be read, since it uses += and the sum has to happen symbolically anyway. The body force is the RESOLVED step rather than a sharp one, and that is deliberate. The fields solve the problem with the density the kernel actually summed, so the pair is exact and the residual checks mean what they say. Comparing against a sharp step would report the truncation error as a defect. Raising `modes` sharpens the step and slows evaluation, since the expression carries one term per mode; the residuals are unchanged at 20 and 40 modes, which confirms they are measuring the transcription rather than the truncation. The body force is also MINUS the density. Most kernels in this family negate internally -- they write rho = -sigma*sin*cos and force with +sigma*sin*cos -- but SolC accumulates the density itself. As summed the momentum residual is 1.8; negated it is 1.6e-16. Worth stating as a rule in the subsystem doc because that sign is invisible to everything else: incompressibility was 1.4e-17 and free slip 1.8e-17 either way. Only the momentum balance could see it, and only because it does not consult the solution's own derivation. Verified: 55 conformance checks over eleven solutions; div 1.4e-17, momentum 1.6e-16, free slip 1.8e-17 for SolC itself; style gate clean. Underworld development team with AI support from Claude Code --- .../subsystems/analytic-solutions.md | 13 +- src/underworld3/analytic/__init__.py | 4 +- src/underworld3/analytic/_reference/solC.c | 186 +++++++++++++++ src/underworld3/analytic/_reference/solH.c | 223 ++++++++++++++++++ src/underworld3/analytic/_transcribe.py | 15 ++ src/underworld3/analytic/velic.py | 158 +++++++++++++ 6 files changed, 597 insertions(+), 2 deletions(-) create mode 100644 src/underworld3/analytic/_reference/solC.c create mode 100644 src/underworld3/analytic/_reference/solH.c diff --git a/docs/developer/subsystems/analytic-solutions.md b/docs/developer/subsystems/analytic-solutions.md index eada2e272..7fccbda9d 100644 --- a/docs/developer/subsystems/analytic-solutions.md +++ b/docs/developer/subsystems/analytic-solutions.md @@ -271,12 +271,23 @@ would hide exactly that. | solution | its stress output is | |---|---| -| SolA, SolB, SolCx, SolKx | total (Cauchy) $\sigma$ | +| SolA, SolB, SolC, SolCx, SolKx | total (Cauchy) $\sigma$ | | SolKz, SolNL, SolDB2d, SolDB3d | deviatoric $\tau$ | +| SolM | published, but wrong — see below | SolA is the clearest case to read: its source writes `u3 = 2*kn*ss_z - pp`, with the pressure subtracted in plain sight. SolKz's writes the same quantity without it. +### The body force is minus the density + +Most of these kernels negate internally — they write `rho = -sigma*sin*cos` and +force with `+sigma*sin*cos`. SolC does not: it accumulates the density itself, so +the transcription negates. Measured, as always: as summed the momentum residual +is 1.8; negated it is 1.6e-16. + +Worth stating as a rule because the sign is invisible to everything except the +momentum balance. Incompressibility and the free-slip conditions hold either way. + ### One published stress is simply wrong SolM's kernel declares its viscosity as $(1 + \cos(r\pi x))\eta_0 + 1$ and then diff --git a/src/underworld3/analytic/__init__.py b/src/underworld3/analytic/__init__.py index b9973dc6a..a0805d7e9 100644 --- a/src/underworld3/analytic/__init__.py +++ b/src/underworld3/analytic/__init__.py @@ -29,7 +29,7 @@ from ._base import AnalyticSolution, FreeSlipWalls, FixedWalls from .inclusion import EllipticalInclusion -from .velic import SolA, SolB, SolCx, SolDB2d, SolDB3d, SolKx, SolKz, SolM, SolNL +from .velic import SolA, SolB, SolC, SolCx, SolDB2d, SolDB3d, SolKx, SolKz, SolM, SolNL __all__ = [ "AnalyticSolution", @@ -38,6 +38,7 @@ "EllipticalInclusion", "SolA", "SolB", + "SolC", "SolCx", "SolDB2d", "SolDB3d", @@ -57,6 +58,7 @@ "EllipticalInclusion": EllipticalInclusion, "SolA": SolA, "SolB": SolB, + "SolC": SolC, "SolCx": SolCx, "SolDB2d": SolDB2d, "SolDB3d": SolDB3d, diff --git a/src/underworld3/analytic/_reference/solC.c b/src/underworld3/analytic/_reference/solC.c new file mode 100644 index 000000000..c58a38aa2 --- /dev/null +++ b/src/underworld3/analytic/_reference/solC.c @@ -0,0 +1,186 @@ +/*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~* +** ** +** This file forms part of the Underworld geophysics modelling application. ** +** ** +** For full license and copyright information, please refer to the LICENSE.md file ** +** located at the project root, or contact the authors. ** +** ** +**~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*/ +#include +#include +#include +#include +#include "solC.h" + +#if 0 +int main( int argc, char **argv ) +{ + int i,j; + double pos[2], vel[2], pressure, total_stress[3], strain_rate[3]; + double x,z; + + for (i=0;i<101;i++){ + for(j=0;j<101;j++){ + x = i/100.0; + z = j/100.0; + + pos[0] = x; + pos[1] = z; + _Velic_solC( pos, 1.0, 1.0, 0.4, vel, &pressure, total_stress, strain_rate, 200 ); + + printf("t_xz,e_xz look funny \n"); + printf("%0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f \n", + pos[0],pos[1], + vel[0],vel[1], pressure, + total_stress[0], total_stress[1], total_stress[2], + strain_rate[0], strain_rate[1], strain_rate[2] ); + } + printf("\n"); + } + + return 0; +} +#endif + + + +void _Velic_solC( + const double pos[], /* coordinates */ + double _sigma, double _eta, double _x_c, /* problem dependant inputs: density, viscosity, width of dense column */ + double vel[], double* presssure, /* output: velocity, pressure */ + double total_stress[], double strain_rate[], int nmodes ) /* ouput: total stresss, strain rate */ +{ + double Z,u1,u2,u3,u4,u5,u6; + double _C1,_C2,_C3,_C4; + double sum1,sum2,sum3,sum4,sum5,sum6,sum7,x,z; + double sigma,del_rho,k,xc; + int n; + + double t1,t2,t3,t4,t5,t6,t7,t8,t10,t11; + double t12,t14,t16,t21; + + + /* del_rho = sigma for x < xc and 0 for x > xc */ + sigma = _sigma; + Z = _eta; + xc = _x_c; + + x = pos[0]; + z = pos[1]; + + sum1=0.0; + sum2=0.0; + sum3=0.0; + sum4=0.0; + sum5=0.0; + sum6=0.0; + sum7=0.0; + + + + for(n=1;n +#include +#include +#include +#include "solH.h" + +#if 0 +int main( int argc, char **argv ) +{ + int i,j; + double pos[3], vel[3], pressure, total_stress[6], strain_rate[6]; + double x,y,z; + + for (i=0;i<101;i++){ + for(j=0;j<101;j++){ + x = i/100.0; + y = j/100.0; + + pos[0] = x; + pos[1] = y; + z = 0.2; + pos[2] = z; + _Velic_solH( + pos, + 1.0, + 1.0, + 0.4,0.6, + vel, &pressure, total_stress, strain_rate, 45 ); + } + } + + return 0; +} +#endif + + + +void _Velic_solH( + const double pos[], + double _sigma, + double _eta, + double _dx, double _dy, + double vel[], double* presssure, + double total_stress[], double strain_rate[], int nmodes ) +{ + + double Z,u1,u2,u3,u4,u5,u6; + double sum1,sum2,sum3,sum4,sum5,sum6,sum7,sum8,sum9,sum10,sum11,x,y,z; + double sigma,dx,dy; + double del_rho; + int n,m; + double L1,kn,km; + double Am,Ap,Bm,Bp,C,D,E; + double pp,txx,tyy,tyx,rho; + + /*************************************************************************/ + + dx = _dx; /* x width of block */ + dy = _dy; /* y width of block */ + sigma = _sigma; /* density of block */ + Z = _eta; /* viscosity */ + z=pos[2]; /* height of 2-d slice in x-y plane to view */ + x = pos[0]; + y = pos[1]; + + sum1=0.0; + sum2=0.0; + sum3=0.0; + sum4=0.0; + sum5=0.0; + sum6=0.0; + sum7=0.0; + sum8=0.0; + sum9=0.0; + sum10=0.0; + sum11=0.0; + + for(n=0;n L2 = %0.7g --> L1 = %0.7g\n",u2,n,m,L2,L1); + u1 *= cos(n*M_PI*x)*cos(m*M_PI*y); + sum1 += u1; + u2 *= cos(n*M_PI*x)*sin(m*M_PI*y); + sum2 += u2; + u3 *= sin(n*M_PI*x)*cos(m*M_PI*y); + sum3 += u3; + u4 *= cos(n*M_PI*x)*cos(m*M_PI*y); + sum4 += u4; + u5 *= cos(n*M_PI*x)*sin(m*M_PI*y); + sum5 += u5; + u6 *= sin(n*M_PI*x)*cos(m*M_PI*y); + sum6 += u6; + + pp *= cos(n*M_PI*x)*cos(m*M_PI*y); + sum7 += pp; /* total pressure */ + sum8 += txx; + sum9 += tyy; + sum10 += tyx; + + rho = del_rho*cos(n*M_PI*x)*cos(m*M_PI*y); + + sum11 += rho; + + }/* n */ + }/* m */ + //mag=sqrt(sum1*sum1+sum2*sum2+sum3*sum3); + + //printf("%0.7g %0.7g %0.7g %0.7g %0.7g %0.7g %0.7g %0.7g %0.7g %0.7g %0.7g %0.7g %0.7g %0.7g\n",x,y,sum1,sum2,sum3,sum4,sum5,sum6,sum7,sum8,sum9,sum10,mag,sum11); + /************************************************************************************ + Run this by doing + _Velic_solH > out + In gnuplot + set pm3d map + splot "out" u 1:2:N + where N = 3-14 + N=3 is z velocity field (sum1) + 4 is y velocity field (sum2) + 5 is x velocity field (sum3) + 6 = zz total stress field (sum4) + 7 = zy total stress field (sum5) + 8 = zx total stress field (sum6) + 9 = total pressure field (sum7) + 10 = xx total stress field (sum8) + 11 = yy total stress field (sum9) + 12 = yx total stress field (sum10) + 13 = velocity magnitude (mag) + 14 = density (mag11) + + if the set pm3d map thing doesn't work then you are probably using a version of + gnuplot that is too old --- seek medical assistance. + ************************************************************************************/ + + + /* Output */ + if( vel != NULL ) { + vel[0] = sum3; + vel[1] = sum2; + vel[2] = sum1; + } + if( presssure != NULL ) { + (*presssure) = sum7; + } + if( total_stress != NULL ) { + /* xx,yy,zz,xy,xz,yz */ + total_stress[0] = sum8; + total_stress[1] = sum9; + total_stress[2] = sum4; + total_stress[3] = sum10; + total_stress[4] = sum6; + total_stress[5] = sum5; + } + if( strain_rate != NULL ) { + strain_rate[0] = (sum8+sum7)/(2.0*Z); + strain_rate[1] = (sum9+sum7)/(2.0*Z); + strain_rate[2] = (sum4+sum7)/(2.0*Z); + strain_rate[3] = (sum10)/(2.0*Z); + strain_rate[4] = (sum6)/(2.0*Z); + strain_rate[5] = (sum5)/(2.0*Z); + } + /* Value checks, could be cleaned up if needed. Julian Giordani 9-Oct-2006*/ + //assert ( fabs( 3.0 * sum7 + sum8+sum9+sum4 ) <= 1e-5 ); + + +} + + diff --git a/src/underworld3/analytic/_transcribe.py b/src/underworld3/analytic/_transcribe.py index 155ba5675..299e0f278 100644 --- a/src/underworld3/analytic/_transcribe.py +++ b/src/underworld3/analytic/_transcribe.py @@ -167,6 +167,21 @@ def returned(body): marker = body.index("return") return body[marker + len("return") : body.index(";", marker)] + @staticmethod + def loop_body(body, header): + """The body of a ``for`` loop, braces excluded. + + The series solutions accumulate over modes. The reader does not + interpret the loop — the caller evaluates this body once per mode with + the index bound, and sums — because the accumulation uses ``+=``, which + is not an assignment this reader recognises, and because the summation + has to happen in SymPy anyway. + """ + + marker = body.index(header) + opening = body.index("{", marker) + return body[opening + 1 : _matching_brace(body, opening) - 1] + @staticmethod def branches(body, condition, tail_ends_at=None): """Split ``if () { A } else { B }`` into ``(A, B, tail)``. diff --git a/src/underworld3/analytic/velic.py b/src/underworld3/analytic/velic.py index 591dfb337..6dbe0067e 100644 --- a/src/underworld3/analytic/velic.py +++ b/src/underworld3/analytic/velic.py @@ -1183,3 +1183,161 @@ def __init__(self, mesh, eta_0=1.0, n=3, m=2, r=4.0): (kernel["strainrate_xz"], kernel["strainrate_zz"]), ), ) + + +_XC_C = sympy.Symbol("xc_solc") + +# SolC accumulates over modes; the source labels each in the loop tail. Same +# transposed convention as SolA and SolKz — u1 is the vertical velocity. +_SOLC_OUTPUTS = { + "velocity_x": ("u2", sympy.sin), + "velocity_z": ("u1", sympy.cos), + "stress_xx": ("u6", sympy.cos), + "stress_zz": ("u3", sympy.cos), + "stress_zx": ("u4", sympy.sin), + "pressure": ("u5", sympy.cos), +} + + +@functools.lru_cache(maxsize=None) +def _solc_kernel(modes): + r"""Transcribe the Velic SolC kernel, summing its Fourier series. + + Unlike the others this one is a truncated series: a step in density, resolved + as ``modes`` cosine terms. The loop body is evaluated once per mode with the + index bound to an integer, and the results summed in SymPy. + + The series is also why the body force here is the *truncated* step rather + than an exact one. The kernel accumulates the density it actually used, and + that is what the solution solves exactly; comparing against a sharp step + instead would report the truncation error as though it were a defect. + + Returns + ------- + dict + Field name -> expression, including ``bodyforce_z`` for the resolved step. + """ + + source = CSource(os.path.join(_REFERENCE_DIR, "solC.c")) + body = source.function("_Velic_solC") + loop = CSource.loop_body(body, "for(n=1;n Date: Mon, 3 Aug 2026 15:56:22 +1000 Subject: [PATCH 16/28] docs: record what remains untranscribed, and the specific obstacles SolDA and SolH are the last two Velic solutions. Their sources are vendored and the mode-loop machinery SolC needed is proven, but each carries a complication worth knowing before starting: SolDA combines a viscosity jump with a rectangular forcing in the largest kernel of the family, and SolH is 3D with a double mode loop (900 terms at the published default), nested branches selecting the zero modes, six stress components, and a transposed output mapping. Underworld development team with AI support from Claude Code --- .../subsystems/analytic-solutions.md | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/developer/subsystems/analytic-solutions.md b/docs/developer/subsystems/analytic-solutions.md index 7fccbda9d..25c838648 100644 --- a/docs/developer/subsystems/analytic-solutions.md +++ b/docs/developer/subsystems/analytic-solutions.md @@ -374,6 +374,31 @@ conjugation, and unlike `sympy.conjugate` it distributes through a square root. The result is real-valued but complex-typed; SymPy cannot prove the imaginary part vanishes, so callers take `.real`. +## What is not transcribed yet + +Two of the Velic solutions remain. Both are reachable with the machinery here — +the mode loop is proven by SolC — but neither is a small addition, and the source +for each is already vendored. + +**SolDA** (`solDA.c`, 34 KB) is the largest kernel in the family: a truncated +series with *both* a viscosity jump and a rectangular forcing, so it combines +what SolCx and SolC each test separately. + +**SolH** (`solH.c`) is 3D and needs three things at once: + +- a **double** mode loop, `n` and `m` each to `nmodes`. At the published default + of 30 that is 900 terms, and its own header warns that SolH "can become *very* + expensive to compute". Expect to choose a smaller default and document the + trade-off, as SolC does. +- nested `if`/`else` inside the loop selecting `del_rho` for the `n = 0` and + `m = 0` modes. The conditions are on the loop indices, which are known integers + at transcription time, so the right branch can be picked per mode rather than + turned into a `Piecewise` — but `CSource.branches` handles one level, not three. +- six stress components rather than three, laid out `xx, yy, zz, xy, xz, yz`. + +Its output mapping is transposed like SolKz's: `vel[0] = sum3`, `vel[1] = sum2`, +`vel[2] = sum1`. Read it from the source, do not assume. + ## Provenance Each vendored reference kernel keeps its original copyright header. From 4e000186fb43532b14ad9b72e51b2746dfc53c32 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Mon, 3 Aug 2026 15:59:50 +1000 Subject: [PATCH 17/28] docs: record what a SolDA transcription runs into Probed rather than attempted. Three obstacles, all specific: the loop opens with a chained assignment (del_rhoB = del_rhoA = del_rho) that the statement reader mis-parses and which needs splitting into individual targets; there are two sequential spatial if/else blocks inside the mode loop, each ~790 lines, so every mode contributes a Piecewise and the structure compounds with mode count; and the loop body is an order of magnitude larger than SolC's, so the per-mode expression size has to be measured before a default mode count can be chosen. Vendors solDA.c alongside the sources already staged for SolH. Underworld development team with AI support from Claude Code --- .../subsystems/analytic-solutions.md | 18 +- src/underworld3/analytic/_reference/solDA.c | 974 ++++++++++++++++++ 2 files changed, 989 insertions(+), 3 deletions(-) create mode 100644 src/underworld3/analytic/_reference/solDA.c diff --git a/docs/developer/subsystems/analytic-solutions.md b/docs/developer/subsystems/analytic-solutions.md index 25c838648..5f098396b 100644 --- a/docs/developer/subsystems/analytic-solutions.md +++ b/docs/developer/subsystems/analytic-solutions.md @@ -380,9 +380,21 @@ Two of the Velic solutions remain. Both are reachable with the machinery here the mode loop is proven by SolC — but neither is a small addition, and the source for each is already vendored. -**SolDA** (`solDA.c`, 34 KB) is the largest kernel in the family: a truncated -series with *both* a viscosity jump and a rectangular forcing, so it combines -what SolCx and SolC each test separately. +**SolDA** (`solDA.c`, 974 lines) is the largest kernel in the family: a truncated +series with *both* a viscosity jump and a rectangular forcing, so it combines what +SolCx and SolC each test separately. Probed, not attempted — three things are in +the way: + +- **chained assignment.** The loop opens with `del_rhoB = del_rhoA = del_rho;`, + which the reader takes as one statement assigning `del_rhoA = del_rho` to + `del_rhoB` — not valid as an expression, so it raises. `_STATEMENT` needs to + split a chain into its individual targets. +- **two sequential spatial branches inside the mode loop**, both `if (z < zc)`, + each about 790 lines. Each becomes a `Piecewise` *per mode*, so the expression + structure compounds with the mode count in a way SolC's does not. +- **size**. SolC at 40 modes builds in 2.4 s from a 186-line kernel. SolDA's loop + body is an order of magnitude larger, so measure the per-mode expression before + choosing a default `modes` — and expect to justify a much smaller one. **SolH** (`solH.c`) is 3D and needs three things at once: diff --git a/src/underworld3/analytic/_reference/solDA.c b/src/underworld3/analytic/_reference/solDA.c new file mode 100644 index 000000000..62ce19adf --- /dev/null +++ b/src/underworld3/analytic/_reference/solDA.c @@ -0,0 +1,974 @@ +/*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~* +** ** +** This file forms part of the Underworld geophysics modelling application. ** +** ** +** For full license and copyright information, please refer to the LICENSE.md file ** +** located at the project root, or contact the authors. ** +** ** +**~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*/ +#include +#include +#include +#include +#include "solDA.h" + +#if 0 +int main( int argc, char **argv ) +{ + int i,j; + double pos[2], vel[2], pressure, total_stress[3], strain_rate[3]; + double x,z; + + for (i=0;i<101;i++){ + for(j=0;j<101;j++){ + x = i/100.0; + z = j/100.0; + + pos[0] = x; + pos[1] = z; + _Velic_solDA( + pos, + 3.0, + 1.0, 2.0, + 0.8, 0.25, 0.45, + vel, &pressure, total_stress, strain_rate ); + printf("%0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f \n", + pos[0],pos[1], + vel[0],vel[1], pressure, + total_stress[0], total_stress[1], total_stress[2], + strain_rate[0], strain_rate[1], strain_rate[2] ); + } + printf("\n"); + } + + return 0; +} +#endif + + + +void _Velic_solDA( + const double pos[], + double _sigma, /* density */ + double _eta_A, double _eta_B, /* viscosity A, viscosity B */ + double _z_c, double _dx, double _x_0, /* viscosity jump location, width of dense column, centre of dense column */ + double vel[], double* presssure, + double total_stress[], double strain_rate[], int nmodes ) +{ + double Z,ZA,ZB,u1,u2,u3,u4,pp,txx; + double u1a,u2a,u3a,u4a,u1b,u2b,u3b,u4b; + double sum1,sum2,sum3,sum4,sum5,sum6,sum7,x,z; + double sigma,dx; + double del_rhoA,del_rhoB,del_rho; + int n; + double kn; + double _C1A,_C2A,_C3A,_C4A,_C1B,_C2B,_C3B,_C4B; + double x0,rho,zc; + + double t1,t2,t3,t4,t5,t6,t7,t8,t9,t10; + double t11,t12,t13,t14,t15,t16,t17,t18,t19,t20; + double t21,t22,t23,t24,t25,t26,t27,t28,t29,t30; + double t31,t32,t33,t34,t35,t36,t37,t38,t39,t40; + double t41,t42,t43,t44,t45,t46,t47,t48,t49,t50; + double t51,t52,t53,t54,t55,t56,t57,t58,t59,t60; + double t61,t62,t63,t64,t66,t67,t68,t69,t70,t71; + double t72,t73,t74,t75,t76,t77,t78,t79,t80,t81; + double t82,t83,t84,t85,t86,t87,t88,t89,t90,t91; + double t93,t94,t95,t96,t97,t98,t99,t100,t101,t102; + double t103,t104,t105,t106,t107,t108,t109,t110,t111,t112; + double t113,t114,t115,t116,t117,t118,t119,t120,t121,t122; + double t123,t124,t125,t126,t128,t129,t130,t131,t132,t133; + double t134,t135,t136,t137,t138,t139,t140,t141,t142,t143; + double t144,t145,t146,t147,t148,t149,t150,t151,t152,t153; + double t154,t155,t156,t157,t158,t159,t160,t161,t162,t163; + double t164,t165,t166,t167,t168,t169,t170,t171,t172,t173; + double t174,t175,t176,t178,t179,t180,t181,t183,t184,t185; + double t186,t187,t188,t189,t191,t193,t196,t197,t200,t202; + double t203,t206,t207,t210,t211,t215,t217,t218,t219,t221; + double t222,t223,t224,t225,t226,t227,t228,t229,t232,t233; + double t238,t239,t243,t244,t246,t248,t250,t251,t267,t273; + + + + + /*************************************************************************/ + /*************************************************************************/ + /* We have a dense column of density sigma + ranging from x=x0-dx/2 to x= x0+dx/2: */ + x0 = _x_0; + dx = _dx; + sigma = _sigma; /* density parameter */ + ZA = _eta_A; /* viscosity for layer A */ + ZB = _eta_B; /* viscosity for layer B */ + zc = _z_c; + /*************************************************************************/ + /*************************************************************************/ + + x = pos[0]; + z = pos[1]; + + sum1=0.0; + sum2=0.0; + sum3=0.0; + sum4=0.0; + sum5=0.0; + sum6=0.0; + sum7=0.0; + /* convergence is good */ + for(n=1;n 1e-5 ) { +// assert(0); +// } + +} + + From b434070f1e8a8b182487168105708debaf71b3f5 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Mon, 3 Aug 2026 16:14:20 +1000 Subject: [PATCH 18/28] SolDA: the hardest of the family, and the one that combines all the others MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uw.analytic.SolDA — a rectangular density anomaly in a fluid whose viscosity jumps at z_c, free slip on the unit box. The most demanding solution here and the only one that combines what the others test separately: a discontinuous forcing (as SolC), a discontinuous viscosity (as SolCx), and a truncated series. The two discontinuities are perpendicular, so a scheme that handles either alone still has to get their interaction right. I had recorded this as too large to attempt, on an estimate. The estimate was wrong and measuring was cheap: one mode on one side is 0.07 s and about four thousand operations per field, which puts twenty modes in the same range as SolKz. Worth remembering — the obstacle I could actually name (chained assignment) turned out to be a ten-line fix, and the one I could only guess at (size) was not an obstacle at all. The transcriber gained chained assignment: `del_rhoB = del_rhoA = del_rho;` assigns to both, but read as one statement its value is `del_rhoA = del_rho`, which is not an expression. Chains are now split innermost-first so each target is bound before the next uses it; verified on double and triple chains. Both of SolDA's `if (z < zc)` blocks branch on the same condition, so each mode is evaluated along one side and then the other and combined into a Piecewise — the SolCx pattern applied per mode. Every convention had to be read from the source and all of them held first time: total stress, minus-the-density forcing as in SolC, and the transposed mapping where u1 is the vertical velocity. It is genuinely expensive: 20 s to build at 8 modes and 47 s at 16, against 2.4 s for SolC at 40, because every mode carries a Piecewise. The default is 8 for that reason, and the docstring says so. The residuals are unchanged between 8 and 16 modes, which confirms they measure the transcription and not the truncation. Verified: div 1.1e-17, momentum 1.5e-15, free slip 2.2e-18; 60 conformance checks over twelve solutions plus 20 contract tests, 80 passing after a clean rebuild; style gate clean. Only SolH now remains untranscribed. Underworld development team with AI support from Claude Code --- .../subsystems/analytic-solutions.md | 22 +- src/underworld3/analytic/__init__.py | 4 +- src/underworld3/analytic/_transcribe.py | 33 ++- src/underworld3/analytic/velic.py | 208 ++++++++++++++++++ 4 files changed, 245 insertions(+), 22 deletions(-) diff --git a/docs/developer/subsystems/analytic-solutions.md b/docs/developer/subsystems/analytic-solutions.md index 5f098396b..172aaafd1 100644 --- a/docs/developer/subsystems/analytic-solutions.md +++ b/docs/developer/subsystems/analytic-solutions.md @@ -271,7 +271,7 @@ would hide exactly that. | solution | its stress output is | |---|---| -| SolA, SolB, SolC, SolCx, SolKx | total (Cauchy) $\sigma$ | +| SolA, SolB, SolC, SolCx, SolDA, SolKx | total (Cauchy) $\sigma$ | | SolKz, SolNL, SolDB2d, SolDB3d | deviatoric $\tau$ | | SolM | published, but wrong — see below | @@ -376,25 +376,7 @@ part vanishes, so callers take `.real`. ## What is not transcribed yet -Two of the Velic solutions remain. Both are reachable with the machinery here — -the mode loop is proven by SolC — but neither is a small addition, and the source -for each is already vendored. - -**SolDA** (`solDA.c`, 974 lines) is the largest kernel in the family: a truncated -series with *both* a viscosity jump and a rectangular forcing, so it combines what -SolCx and SolC each test separately. Probed, not attempted — three things are in -the way: - -- **chained assignment.** The loop opens with `del_rhoB = del_rhoA = del_rho;`, - which the reader takes as one statement assigning `del_rhoA = del_rho` to - `del_rhoB` — not valid as an expression, so it raises. `_STATEMENT` needs to - split a chain into its individual targets. -- **two sequential spatial branches inside the mode loop**, both `if (z < zc)`, - each about 790 lines. Each becomes a `Piecewise` *per mode*, so the expression - structure compounds with the mode count in a way SolC's does not. -- **size**. SolC at 40 modes builds in 2.4 s from a 186-line kernel. SolDA's loop - body is an order of magnitude larger, so measure the per-mode expression before - choosing a default `modes` — and expect to justify a much smaller one. +One Velic solution remains, and its source is vendored. **SolH** (`solH.c`) is 3D and needs three things at once: diff --git a/src/underworld3/analytic/__init__.py b/src/underworld3/analytic/__init__.py index a0805d7e9..b3975d64d 100644 --- a/src/underworld3/analytic/__init__.py +++ b/src/underworld3/analytic/__init__.py @@ -29,7 +29,7 @@ from ._base import AnalyticSolution, FreeSlipWalls, FixedWalls from .inclusion import EllipticalInclusion -from .velic import SolA, SolB, SolC, SolCx, SolDB2d, SolDB3d, SolKx, SolKz, SolM, SolNL +from .velic import SolA, SolB, SolC, SolCx, SolDA, SolDB2d, SolDB3d, SolKx, SolKz, SolM, SolNL __all__ = [ "AnalyticSolution", @@ -40,6 +40,7 @@ "SolB", "SolC", "SolCx", + "SolDA", "SolDB2d", "SolDB3d", "SolKx", @@ -60,6 +61,7 @@ "SolB": SolB, "SolC": SolC, "SolCx": SolCx, + "SolDA": SolDA, "SolDB2d": SolDB2d, "SolDB3d": SolDB3d, "SolKx": SolKx, diff --git a/src/underworld3/analytic/_transcribe.py b/src/underworld3/analytic/_transcribe.py index 299e0f278..7ad0a584c 100644 --- a/src/underworld3/analytic/_transcribe.py +++ b/src/underworld3/analytic/_transcribe.py @@ -67,6 +67,37 @@ def _rename_reserved(text): return _RESERVED_PATTERN.sub(lambda m: _RESERVED[m.group(1)], text) +_CHAIN = re.compile(r"\b(\w+)\s*=\s*(?=\w+\s*=(?!=))") + + +def _expand_chains(block): + """Give each target of a chained assignment its own statement. + + ``a = b = c;`` assigns c to both. Read as one statement its *value* is + ``b = c``, which is not an expression and raises. Rewriting it as + ``b = c; a = b;`` keeps the order the C has — the rightmost target is bound + first, and the others follow from it. + """ + + out = [] + for statement in block.split(";"): + targets = _CHAIN.findall(statement) + if not targets: + out.append(statement) + continue + + remainder = _CHAIN.sub("", statement) + # Innermost first, so each target is defined before the next uses it. + pieces = [remainder] + previous = remainder.split("=")[0].strip() + for target in reversed(targets): + pieces.append(f" {target} = {previous}") + previous = target + out.append(";".join(pieces)) + + return ";".join(out) + + def _split_declarators(block): """Give each declarator in a C declaration its own statement. @@ -263,7 +294,7 @@ def evaluate_block(block, environment): scope = {_RESERVED.get(name, name): value for name, value in environment.items()} namespace = {**_C_FUNCTIONS, "Rational": sympy.Rational} - for target, expression in _STATEMENT.findall(_split_declarators(block)): + for target, expression in _STATEMENT.findall(_expand_chains(_split_declarators(block))): value = eval( _as_python(expression), {"__builtins__": {}}, {**namespace, **scope} ) diff --git a/src/underworld3/analytic/velic.py b/src/underworld3/analytic/velic.py index 6dbe0067e..fb27445d2 100644 --- a/src/underworld3/analytic/velic.py +++ b/src/underworld3/analytic/velic.py @@ -1341,3 +1341,211 @@ def __init__(self, mesh, sigma=1.0, eta=1.0, x_c=0.5, modes=40): (kernel["stress_zx"], kernel["stress_zz"]), ), ) + + +_ZC, _DX, _X0 = sympy.symbols("zc dx x0") + +# SolDA uses the transposed convention of SolA and SolC: u1 is the vertical +# velocity and the modes run in x. Its pp and txx already carry their mode. +_SOLDA_MODED = { + "velocity_x": ("u2", sympy.sin), + "velocity_z": ("u1", sympy.cos), + "stress_zz": ("u3", sympy.cos), + "stress_zx": ("u4", sympy.sin), +} + + +@functools.lru_cache(maxsize=None) +def _solda_kernel(modes): + r"""Transcribe the Velic SolDA kernel. + + The hardest of the family to read, and the only one combining every feature + the others have separately: a truncated series (as SolC), a viscosity jump + (as SolCx), and a rectangular forcing. + + Both of its ``if (z < zc)`` blocks branch on the same condition, so each mode + is evaluated along one side and then the other and the two are combined into + a single :class:`sympy.Piecewise` — the SolCx pattern, applied per mode. + + Returns + ------- + dict + Field name -> expression, including ``bodyforce_z`` for the resolved + column. + """ + + source = CSource(os.path.join(_REFERENCE_DIR, "solDA.c")) + body = source.function("_Velic_solDA") + + header = "for(n=1;n 0.0 and float(eta_B) > 0.0): + raise ValueError("eta_A and eta_B must be positive.") + if not 0.0 < float(z_c) < 1.0: + raise ValueError("z_c must lie strictly inside (0, 1).") + if int(modes) != modes or int(modes) < 2: + raise ValueError("modes must be an integer of at least 2.") + + self.sigma = float(sigma) + self.eta_A = float(eta_A) + self.eta_B = float(eta_B) + self.z_c = float(z_c) + self.dx = float(dx) + self.x_0 = float(x_0) + self.modes = int(modes) + + x, z = mesh.X + values = { + _SIGMA: sympy.Rational(self.sigma), + _ZA: sympy.Rational(self.eta_A), + _ZB: sympy.Rational(self.eta_B), + _ZC: sympy.Rational(self.z_c), + _DX: sympy.Rational(self.dx), + _X0: sympy.Rational(self.x_0), + _X: x, + _Z: z, + } + kernel = { + field: expression.subs(values) + for field, expression in _solda_kernel(self.modes).items() + } + + self.set_fields( + velocity=(kernel["velocity_x"], kernel["velocity_z"]), + pressure=kernel["pressure"], + viscosity=sympy.Piecewise( + (sympy.Rational(self.eta_A), z < self.z_c), + (sympy.Rational(self.eta_B), True), + ), + # Minus the density, as for SolC: this kernel accumulates rho itself. + bodyforce=(0, -kernel["bodyforce_z"]), + stress=( + (kernel["stress_xx"], kernel["stress_zx"]), + (kernel["stress_zx"], kernel["stress_zz"]), + ), + ) From 6beee0bb45e32b1aef89132be615afc0e0f147c2 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Mon, 3 Aug 2026 16:23:35 +1000 Subject: [PATCH 19/28] SolH completes the Velic family: 3D, a double series, and a corrected estimate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit uw.analytic.SolH — isoviscous flow in the unit cube driven by a rectangular density block, free slip everywhere. The 3D counterpart of SolC, and the only 3D solution here with a discontinuous forcing. Three-dimensional flow around a compact body is not the 2D problem with an axis added: the return flow can go around the anomaly rather than only over it. I had recorded SolH as expensive and hard, on the strength of the kernel's own warning that it "can become *very* expensive to compute" and a 900-term count. That warning is about a COMPILED kernel, which re-sums every mode at every evaluation point. For a transcription it is backwards: each mode is about ninety operations, the smallest in the family, and the sum is built once. It builds in 1-2 s and validated on the first attempt -- div 3.4e-17, momentum 1.7e-16. That is the second estimate this session that measuring overturned in minutes, after SolDA. The pattern in both: the obstacle I could NAME was cheap to fix, and the one I could only guess at was not an obstacle. Recorded in the subsystem doc. Two transcriber additions, both mechanical once looked at. The C ternary, since SolH guards its zero modes with `(n!=0 || m!=0) ? ... : ...`; parenthesised groups are rewritten first so nested conditionals resolve, verified on both. And resolve_branches, which is the one that matters. These kernels guard their zero modes with tests on the loop indices, and those are bound to integers before anything is evaluated, so the construct collapses to whichever branch the C would take. Left unresolved it is not a crash: evaluate_block reads every assignment in order, so each guarded variable keeps the LAST branch's value, which in SolH silently zeroes two velocity components and leaves a plausible-looking solution. Verified: 65 conformance checks over thirteen solutions plus 20 contract tests, 85 passing after a clean rebuild; style gate clean. Underworld development team with AI support from Claude Code --- .../subsystems/analytic-solutions.md | 39 ++-- src/underworld3/analytic/__init__.py | 4 +- src/underworld3/analytic/_transcribe.py | 126 ++++++++++++- src/underworld3/analytic/velic.py | 169 +++++++++++++++++- 4 files changed, 321 insertions(+), 17 deletions(-) diff --git a/docs/developer/subsystems/analytic-solutions.md b/docs/developer/subsystems/analytic-solutions.md index 172aaafd1..a09c92ed8 100644 --- a/docs/developer/subsystems/analytic-solutions.md +++ b/docs/developer/subsystems/analytic-solutions.md @@ -374,24 +374,35 @@ conjugation, and unlike `sympy.conjugate` it distributes through a square root. The result is real-valued but complex-typed; SymPy cannot prove the imaginary part vanishes, so callers take `.real`. -## What is not transcribed yet +## The family is complete -One Velic solution remains, and its source is vendored. +All twelve Velic solutions are transcribed, plus the Schmid & Podladchikov +inclusion. Every one is validated by the conformance suite. -**SolH** (`solH.c`) is 3D and needs three things at once: +Two lessons from the last three, which I had recorded as too large to attempt: -- a **double** mode loop, `n` and `m` each to `nmodes`. At the published default - of 30 that is 900 terms, and its own header warns that SolH "can become *very* - expensive to compute". Expect to choose a smaller default and document the - trade-off, as SolC does. -- nested `if`/`else` inside the loop selecting `del_rho` for the `n = 0` and - `m = 0` modes. The conditions are on the loop indices, which are known integers - at transcription time, so the right branch can be picked per mode rather than - turned into a `Piecewise` — but `CSource.branches` handles one level, not three. -- six stress components rather than three, laid out `xx, yy, zz, xy, xz, yz`. +**Measure before estimating.** SolDA was written off on a guess about expression +size. Measuring took two minutes: one mode is 0.07 s and about four thousand +operations, putting twenty modes in the same range as SolKz. SolH was written off +on the source's own warning that it is "very expensive" — true of a *compiled* +kernel, which re-sums every mode at every evaluation point, and backwards for a +transcription, where the sum is built once and each mode is the smallest in the +family at ninety operations. Both transcribed and validated on the first attempt. -Its output mapping is transposed like SolKz's: `vel[0] = sum3`, `vel[1] = sum2`, -`vel[2] = sum1`. Read it from the source, do not assume. +**The named obstacle is the cheap one.** For SolDA the blocker I could point at — +chained assignment, `del_rhoB = del_rhoA = del_rho` — was a ten-line fix, while +the one I could only estimate was not an obstacle at all. Same for SolH: the C +ternary and the index-conditioned guards were mechanical additions; +the cost was imagined. + +### What the last three needed from the reader + +| addition | why | +|---|---| +| `CSource.loop_body` | the series solutions accumulate over modes with `+=`, which is not an assignment; the caller evaluates per mode and sums in SymPy | +| chained assignment | `a = b = c;` read as one statement has `b = c` as its *value*, which is not an expression | +| C ternary and `&&`/`\|\|` | SolH guards its zero modes with `(n!=0 \|\| m!=0) ? … : …` | +| `resolve_branches` | guards on loop indices have an answer at transcription time. Left unresolved, `evaluate_block` reads every branch in order and each guarded variable keeps the *last* one — in SolH that silently zeroes two velocity components, which looks plausible rather than broken | ## Provenance diff --git a/src/underworld3/analytic/__init__.py b/src/underworld3/analytic/__init__.py index b3975d64d..8f9acb59e 100644 --- a/src/underworld3/analytic/__init__.py +++ b/src/underworld3/analytic/__init__.py @@ -29,7 +29,7 @@ from ._base import AnalyticSolution, FreeSlipWalls, FixedWalls from .inclusion import EllipticalInclusion -from .velic import SolA, SolB, SolC, SolCx, SolDA, SolDB2d, SolDB3d, SolKx, SolKz, SolM, SolNL +from .velic import SolA, SolB, SolC, SolCx, SolDA, SolDB2d, SolDB3d, SolH, SolKx, SolKz, SolM, SolNL __all__ = [ "AnalyticSolution", @@ -43,6 +43,7 @@ "SolDA", "SolDB2d", "SolDB3d", + "SolH", "SolKx", "SolKz", "SolM", @@ -64,6 +65,7 @@ "SolDA": SolDA, "SolDB2d": SolDB2d, "SolDB3d": SolDB3d, + "SolH": SolH, "SolKx": SolKx, "SolKz": SolKz, "SolM": SolM, diff --git a/src/underworld3/analytic/_transcribe.py b/src/underworld3/analytic/_transcribe.py index 7ad0a584c..3b267e14c 100644 --- a/src/underworld3/analytic/_transcribe.py +++ b/src/underworld3/analytic/_transcribe.py @@ -63,6 +63,73 @@ _RESERVED_PATTERN = re.compile(r"\b(" + "|".join(_RESERVED) + r")\b") +def _rewrite_ternary(text): + """Rewrite C conditional expressions as Python ones. + + ``c ? a : b`` becomes ``(a) if (c) else (b)``, and the C logical operators + become their Python spellings. ``!=`` is left alone — the only bare ``!`` + these kernels use is part of it. + + Parenthesised groups are rewritten first, so a conditional nested inside one + is resolved before the enclosing scan runs; the outer scan then only has to + find a ``?`` at depth zero. In practice the conditions are on loop indices, + bound to integers before evaluation, so the whole expression collapses to a + single branch. + """ + + text = text.replace("&&", " and ").replace("||", " or ") + + # Inner groups first. + pieces, index = [], 0 + while index < len(text): + if text[index] == "(": + close = _matching_paren(text, index) + pieces.append("(" + _rewrite_ternary(text[index + 1 : close]) + ")") + index = close + 1 + else: + pieces.append(text[index]) + index += 1 + text = "".join(pieces) + + depth = 0 + for index, character in enumerate(text): + if character == "(": + depth += 1 + elif character == ")": + depth -= 1 + elif character == "?" and depth == 0: + inner = 0 + for offset, following in enumerate(text[index + 1 :]): + if following == "(": + inner += 1 + elif following == ")": + inner -= 1 + elif following == ":" and inner == 0: + colon = index + 1 + offset + return ( + f"(({text[index + 1 : colon]})" + f" if ({text[:index]})" + f" else ({text[colon + 1 :]}))" + ) + break + + return text + + +def _matching_paren(text, opening): + """Index of the ``)`` closing the ``(`` at *opening*.""" + + depth = 0 + for index in range(opening, len(text)): + if text[index] == "(": + depth += 1 + elif text[index] == ")": + depth -= 1 + if depth == 0: + return index + return len(text) - 1 + + def _rename_reserved(text): return _RESERVED_PATTERN.sub(lambda m: _RESERVED[m.group(1)], text) @@ -167,7 +234,9 @@ def _as_python(expression): juxtaposition in Python, which does not parse. """ - expression = _rename_reserved(_CAST.sub("", " ".join(expression.split()))) + expression = _rewrite_ternary( + _rename_reserved(_CAST.sub("", " ".join(expression.split()))) + ) return _FLOAT_LITERAL.sub(lambda m: f"Rational('{m.group(0)}')", expression) @@ -246,6 +315,61 @@ def branches(body, condition, tail_ends_at=None): return then_block, else_block, tail +def resolve_branches(block, bindings): + """Replace ``if``/``else`` whose condition is already decided by its branch. + + The series kernels guard their zero modes with tests on the loop indices — + ``if (n != 0 && m != 0)`` and so on. Those indices are bound to integers + before anything is evaluated, so the condition has an answer at transcription + time and the construct collapses to whichever branch the C would take. + + That matters because :func:`evaluate_block` does not interpret ``if``: it + reads every assignment in order, so an unresolved guard leaves each guarded + variable holding the *last* branch's value. In SolH that silently zeroes two + velocity components, which looks like a plausible solution rather than a + broken one. + + Nested guards resolve outermost first, repeatedly, until none remain. + + Parameters + ---------- + block : str + bindings : dict + Names the condition may use, mapped to Python values — normally the loop + indices as integers. + """ + + namespace = {**_C_FUNCTIONS, "Rational": sympy.Rational} + + while True: + marker = re.search(r"\bif\s*\(", block) + if marker is None: + return block + + condition_start = block.index("(", marker.start()) + condition_end = _matching_paren(block, condition_start) + condition = block[condition_start + 1 : condition_end] + + opening = block.index("{", condition_end) + then_end = _matching_brace(block, opening) + taken = block[opening + 1 : then_end - 1] + after = block[then_end:] + + otherwise = "" + stripped = after.lstrip() + if stripped.startswith("else"): + else_opening = block.index("{", then_end) + else_end = _matching_brace(block, else_opening) + otherwise = block[else_opening + 1 : else_end - 1] + after = block[else_end:] + + chosen = taken if eval( + _as_python(condition), {"__builtins__": {}}, {**namespace, **bindings} + ) else otherwise + + block = block[: marker.start()] + chosen + after + + def evaluate_expression(text, environment): """Evaluate a single C expression against names already in scope. diff --git a/src/underworld3/analytic/velic.py b/src/underworld3/analytic/velic.py index fb27445d2..f1c68863a 100644 --- a/src/underworld3/analytic/velic.py +++ b/src/underworld3/analytic/velic.py @@ -18,7 +18,12 @@ import sympy from ._base import AnalyticSolution, FixedWalls, FreeSlipWalls -from ._transcribe import CSource, evaluate_block, evaluate_expression +from ._transcribe import ( + CSource, + evaluate_block, + evaluate_expression, + resolve_branches, +) _REFERENCE_DIR = os.path.join(os.path.dirname(__file__), "_reference") @@ -1549,3 +1554,165 @@ def __init__( (kernel["stress_zx"], kernel["stress_zz"]), ), ) + + +_DY = sympy.Symbol("dy") + +# SolH's output section, which transposes like SolKz's: vel[0]=sum3, [1]=sum2, +# [2]=sum1, and the stress is laid out xx, yy, zz, xy, xz, yz. +_SOLH_MODED = { + "velocity_z": ("u1", (sympy.cos, sympy.cos)), + "velocity_y": ("u2", (sympy.cos, sympy.sin)), + "velocity_x": ("u3", (sympy.sin, sympy.cos)), + "stress_zz": ("u4", (sympy.cos, sympy.cos)), + "stress_yz": ("u5", (sympy.cos, sympy.sin)), + "stress_xz": ("u6", (sympy.sin, sympy.cos)), +} + + +@functools.lru_cache(maxsize=None) +def _solh_kernel(modes): + r"""Transcribe the Velic SolH kernel — 3D, and a double series. + + Two nested mode loops, so the term count goes as ``modes**2``. The published + kernel warns that SolH is expensive to *evaluate*, which it is: a compiled + version sums every mode at every point. Transcribed it is the opposite — + each mode contributes about ninety operations, the cheapest in the family, + and the sum is built once. + + Its guards (``if (m != 0)`` and friends) are on the loop indices, so they are + resolved per mode rather than becoming ``Piecewise``. + """ + + source = CSource(os.path.join(_REFERENCE_DIR, "solH.c")) + body = source.function("_Velic_solH") + + outer = CSource.loop_body(body, "for(n=0;n Date: Mon, 3 Aug 2026 17:40:08 +1000 Subject: [PATCH 20/28] Transport, Richards and the optional Kramer wrapper complete the suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Stokes family was already in uw.analytic. This brings across the scalar solutions that were scattered elsewhere, and closes the plan's remaining items. Transport (transport.py) — Poisson1D, TwoLayerDarcy, ErfcDiffusion, AdvectedFront. All four were written inline in the tests that used them, where nothing checked them against the equations they solve. They declare solves = "transport" and carry fn_solution / fn_coefficient / fn_source instead of the velocity-and-pressure pair. Richards (richards.py) — GardnerSteady and GardnerTransient, the one nonlinear scalar family, previously NumPy functions in utilities/retention_curves.py. Those functions keep their signatures and now evaluate the same SymPy expression the classes build, so there is one formula rather than two copies that can drift; both reproduce the previous arithmetic to reassociation (4e-14 and 4e-16). Kramer (kramer.py) — CylindricalStokes, wrapping the external `assess`, now declared as the `benchmarks` extra. Four scripts under docs/examples/ imported it while nothing declared it, so on a normal install they failed with a bare ModuleNotFoundError. Three things worth recording, all of them checks rather than solutions: A residual only means something if it is the residual of the right equation. AdvectedFront reported 1.44 next to a column of zeros, which reads unambiguously as a broken solution — but diffusion_residual was testing pure diffusion against an advecting front. The advection term is now included as the general case; a purely diffusive solution declares no velocity and it drops out. The conformance suite no longer skips transients, which is what let this sit unnoticed. A residual can be degenerate rather than wrong. richards_residual first normalised by the flux divergence, which *is* the residual, and reported exactly 1.00 for a solution that is exact to the last bit. It now normalises by the terms that have to cancel, kept separately. Not every perturbation is a negative control. Scaling K by a constant leaves the steady Richards residual at zero — correctly, since that is a genuine symmetry of the equation. Three controls that do discriminate are asserted (wrong alpha in K: 0.64; head scaled 1%: 0.0099; K independent of head: 1.00), and so is the symmetry, so neither is left as a claim in prose. Also: lambdify("numpy") has no erfc and falls back to the scalar math.erfc without complaint, failing much later from generated code. Unseen until now because differentiating an erfc removes it, and only the Richards head keeps one inside a logarithm. _validation.sample now asks for ["scipy", "numpy"]. CylindricalStokes is an oracle, not a member of the family: assess is numeric, so none of the six gates can reach it. Declared as symbolic = False rather than described, with the conformance sweep excluding on the declaration and then asserting what it excluded — an accidental exclusion fails the suite instead of quietly shrinking it. uw.analytic.available() now lists 20 solutions, 19 of them symbolic and swept. 274 passed across the analytic and MG suites; 126 passed, 6 skipped (assess absent) on the files touched here. Underworld development team with AI support from Claude Code --- .../subsystems/analytic-solutions.md | 147 ++++++++- pyproject.toml | 8 + src/underworld3/analytic/__init__.py | 71 ++++- src/underworld3/analytic/_base.py | 80 +++++ src/underworld3/analytic/_validation.py | 133 +++++++- src/underworld3/analytic/kramer.py | 252 +++++++++++++++ src/underworld3/analytic/richards.py | 298 ++++++++++++++++++ src/underworld3/analytic/transport.py | 255 +++++++++++++++ src/underworld3/utilities/retention_curves.py | 51 +-- tests/test_1024_analytic_conformance.py | 111 ++++++- tests/test_1025_analytic_transport.py | 162 ++++++++++ tests/test_1026_analytic_richards.py | 272 ++++++++++++++++ tests/test_1027_analytic_optional.py | 159 ++++++++++ 13 files changed, 1964 insertions(+), 35 deletions(-) create mode 100644 src/underworld3/analytic/kramer.py create mode 100644 src/underworld3/analytic/richards.py create mode 100644 src/underworld3/analytic/transport.py create mode 100644 tests/test_1025_analytic_transport.py create mode 100644 tests/test_1026_analytic_richards.py create mode 100644 tests/test_1027_analytic_optional.py diff --git a/docs/developer/subsystems/analytic-solutions.md b/docs/developer/subsystems/analytic-solutions.md index a09c92ed8..941b05111 100644 --- a/docs/developer/subsystems/analytic-solutions.md +++ b/docs/developer/subsystems/analytic-solutions.md @@ -404,6 +404,112 @@ the cost was imagined. | C ternary and `&&`/`\|\|` | SolH guards its zero modes with `(n!=0 \|\| m!=0) ? … : …` | | `resolve_branches` | guards on loop indices have an answer at transcription time. Left unresolved, `evaluate_block` reads every branch in order and each guarded variable keeps the *last* one — in SolH that silently zeroes two velocity components, which looks plausible rather than broken | +## The scalar transport family + +The Stokes solutions solve for a velocity-and-pressure pair. The scalar solutions +solve for one field, so they declare `solves = "transport"` and carry a different +set of `fn_*`: + +| | Stokes | transport | +|---|---|---| +| unknowns | `fn_velocity`, `fn_pressure` | `fn_solution` | +| material | `fn_viscosity` | `fn_coefficient` | +| forcing | `fn_bodyforce` | `fn_source` | +| set by | `set_fields(...)` | `set_scalar_field(...)` | +| residual | momentum + incompressibility | `transport_residual` / `diffusion_residual` | + +These were already in the repository — written inline in the tests that used them. +Collecting them gains the residual check, which they never had, and a name to cite. + +| solution | equation | previously | +|---|---|---| +| `Poisson1D` | $\nabla^2 u + f = 0$, three sources | `test_1000_poissonCart.py` | +| `TwoLayerDarcy` | $\nabla\cdot(k\nabla p) = 0$ across a permeability jump | `test_1004_DarcyCartesian.py` | +| `ErfcDiffusion` | $\partial_t u = D\nabla^2 u$ | `test_1005_TransientDarcyCartesian.py` | +| `AdvectedFront` | $\partial_t c + u\,\partial_x c = \kappa\,\partial_{xx} c$ | `test_1100_AdvDiffCartesian.py` | + +The transient ones expose time as a symbol, so a solver is checked at whatever +time it actually reached: + +```python +sol = uw.analytic.ErfcDiffusion(mesh, diffusivity=0.5) +exact = sol.fn_solution.subs(sol.t, t_end) +``` + +Starting a comparison from a smooth profile at $t > 0$ rather than from the step +itself is the point of using these: the step is not representable on the mesh, +which is what makes `test_1100`'s current comparison fragile. + +They cannot reuse the Stokes boundary-condition mixins, which apply a *velocity*. +`_Transport` prescribes `fn_solution` on every wall instead. + +### The residual has to be the equation the solution actually solves + +`AdvectedFront` reported a residual of 1.44 against 0.00 for everything else. The +solution was right; the check was the wrong equation. `diffusion_residual` tested +$\partial_t u = \nabla\cdot(k\nabla u)$, and an advecting front does not satisfy +that — it satisfies advection-diffusion. + +The failure is worth recording because of how it presents: an order-one residual +next to a column of zeros reads unambiguously as a broken solution, and the +tempting next step is to go looking for the transcription error. The fix was to +include the advection term, which is the general case — a purely diffusive +solution declares no advecting velocity and the term drops out, leaving the other +three at zero exactly as before. The lesson mirrors Gate 4's: a residual only +means something if it is the residual of the right equation, and a check narrower +than the family it is applied to will convict a correct solution. + +## The Richards family + +Unsaturated flow is the one nonlinear scalar equation in the suite: + +$$C(\psi)\,\frac{\partial\psi}{\partial t} + = \nabla\cdot\!\left[K(\psi)\left(\nabla\psi + \hat y\right)\right]$$ + +so it declares `solves = "richards"` and carries `fn_conductivity` and +`fn_capacity` rather than a single coefficient. Gardner's exponential model +$K = K_s e^{\alpha\psi}$ is the case that closes, because $u = e^{\alpha\psi}$ +linearises it *exactly* — under that substitution Richards becomes linear +advection–diffusion in $u$, which is why `GardnerTransient` is an Ogata–Banks +form and shares its shape with `AdvectedFront`. + +| solution | content | +|---|---| +| `GardnerSteady` | constant flux down a column; head is $\ln[(u_0-q^*)e^{-\alpha y} + q^*]/\alpha$ | +| `GardnerTransient` | a wetting front advancing at $V = K_s/\Delta\theta$ | + +Both already existed as NumPy functions in `utilities/retention_curves.py`. Those +functions keep their signatures and now evaluate the same SymPy expression the +classes build, so there is one formula rather than two copies that can drift. + +### Two things this family taught the harness + +**A residual can be degenerate rather than wrong.** The first `richards_residual` +normalised by the flux divergence — which *is* the residual. It reported exactly +`1.00` for a solution that turned out to be exact to the last bit. An order-one +number from a normalised residual is not automatically a failing solution; it can +be a scale that divides the quantity by itself. The fix is to normalise by the +terms that have to *cancel*, kept separately. + +**Not every perturbation is a negative control.** Gate 5 says a check that passes +a broken input is measuring nothing — but scaling $K$ by a constant leaves the +steady residual at zero, and that is correct: it is a genuine symmetry of +$\nabla\cdot[K(\nabla\psi+\hat y)]=0$, not a defect the gate missed. A control has +to break something the solution actually asserts. Three that do: a wrong $\alpha$ +inside $K$ (0.64), a head scaled by 1% (0.0099, tracking the perturbation), and a +conductivity that ignores the head at all (1.0). Both facts are asserted in +`tests/test_1026_analytic_richards.py`, the symmetry included, so neither is left +as a claim in prose. + +### `erfc` is not in NumPy + +`lambdify(..., "numpy")` falls back to the scalar `math.erfc` without complaint; +the failure surfaces much later as `only 0-dimensional arrays can be converted to +Python scalars`, from generated code, at the first array. It went unseen for as +long as it did because differentiating an `erfc` removes it — every earlier +residual differentiated, and only the Richards head keeps one inside a logarithm. +`_validation.sample` now asks for `["scipy", "numpy"]`. + ## Provenance Each vendored reference kernel keeps its original copyright header. @@ -421,14 +527,49 @@ A solution requiring a package Underworld3 does not depend on is wrapped lazily, in the style SciPy uses for its optional backends: the import happens at construction, and its absence raises with an install message rather than breaking `import underworld3`. `uw.analytic.available()` lists such solutions and marks -them unavailable rather than omitting them. +them unavailable rather than omitting them — omitting would make the listing +truthful about what constructs and silent about what exists, leaving a user with +no way to discover that a benchmark is one `pip install` away. + +There is one: `CylindricalStokes`, wrapping `assess` (Kramer et al. 2021) for +curved-geometry Stokes. It is declared as the `benchmarks` extra: + +```bash +pip install "underworld3[benchmarks]" +``` + +Four scripts under `docs/examples/` already imported `assess` while nothing +declared it, so on a normal install they failed with a bare +`ModuleNotFoundError`. + +### It is an oracle, not a member of the family + +`assess` gives numeric callables, so there is nothing to differentiate — a +Kramer solution can be compared against a solver but **cannot be checked against +the equations it claims to solve**. None of the six gates reaches it. + +That is a real gap, not a technicality, so it is declared rather than +described: `symbolic = False`, and the conformance sweep excludes on the +declaration. The sweep then asserts what it excluded and why, so an accidental +exclusion — a mistyped class attribute — fails the suite instead of quietly +shrinking it. + +Two class attributes carry this: + +| attribute | meaning | +|---|---| +| `symbolic` | fields are SymPy on `mesh.X`. False means the residual gates cannot be applied at all | +| `requires` | name of an optional package, or `None` | ## Adding a new solution 1. Subclass `AnalyticSolution` and one of the boundary-condition mixins - (`FreeSlipWalls`, `FixedWalls`). + (`FreeSlipWalls`, `FixedWalls`) — or `_Transport` for a scalar solution. 2. Build the exact fields on `mesh.X` in `__init__`; set `dim`, `reference`, and - the `eqn_*` LaTeX strings that document the *problem*. + the `eqn_*` LaTeX strings that document the *problem*. Set the fields through + `set_fields` (Stokes) or `set_scalar_field` (transport) rather than assigning + `fn_*` directly — that is where the stress convention and the advection term + are applied, and the conformance suite trusts the declaration. 3. Export it from `underworld3/analytic/__init__.py` and register it in `_SOLUTIONS` — a namespace entry and the registry entry land in the same PR. 4. If it was transcribed from a reference kernel, clear all six gates and pin the diff --git a/pyproject.toml b/pyproject.toml index 9d3f7fee2..f3ebd7c08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,14 @@ classifiers = [ "Topic :: Scientific/Engineering :: Physics", ] +[project.optional-dependencies] +# Curved-geometry Stokes benchmarks. `assess` (Kramer et al. 2021, +# doi:10.5194/gmd-14-1899-2021) backs uw.analytic.CylindricalStokes and the +# annulus/spherical benchmark scripts under docs/examples/. Optional rather than +# required: it is needed only to run those benchmarks, and everything else in +# uw.analytic is self-contained SymPy. +benchmarks = ["assess"] + [project.urls] Homepage = "https://github.com/underworldcode/underworld3" Documentation = "https://underworldcode.github.io/underworld3/" diff --git a/src/underworld3/analytic/__init__.py b/src/underworld3/analytic/__init__.py index 8f9acb59e..f7a458586 100644 --- a/src/underworld3/analytic/__init__.py +++ b/src/underworld3/analytic/__init__.py @@ -29,13 +29,22 @@ from ._base import AnalyticSolution, FreeSlipWalls, FixedWalls from .inclusion import EllipticalInclusion +from .kramer import CylindricalStokes +from .richards import GardnerSteady, GardnerTransient +from .transport import AdvectedFront, ErfcDiffusion, Poisson1D, TwoLayerDarcy from .velic import SolA, SolB, SolC, SolCx, SolDA, SolDB2d, SolDB3d, SolH, SolKx, SolKz, SolM, SolNL __all__ = [ "AnalyticSolution", "FreeSlipWalls", "FixedWalls", + "AdvectedFront", "EllipticalInclusion", + "ErfcDiffusion", + "CylindricalStokes", + "GardnerSteady", + "GardnerTransient", + "Poisson1D", "SolA", "SolB", "SolC", @@ -48,8 +57,10 @@ "SolKz", "SolM", "SolNL", + "TwoLayerDarcy", "available", "describe", + "is_available", ] # The solutions this namespace offers. Explicit rather than introspected, so the @@ -57,7 +68,13 @@ # contract, and so a solution needing an optional dependency can be listed # without being importable. _SOLUTIONS = { + "AdvectedFront": AdvectedFront, + "CylindricalStokes": CylindricalStokes, "EllipticalInclusion": EllipticalInclusion, + "ErfcDiffusion": ErfcDiffusion, + "GardnerSteady": GardnerSteady, + "GardnerTransient": GardnerTransient, + "Poisson1D": Poisson1D, "SolA": SolA, "SolB": SolB, "SolC": SolC, @@ -70,19 +87,60 @@ "SolKz": SolKz, "SolM": SolM, "SolNL": SolNL, + "TwoLayerDarcy": TwoLayerDarcy, } -def available(): +def is_available(name): + """Whether *name* can actually be constructed here and now. + + False only when a solution needs an optional package that is not installed. + Kept separate from :func:`available` because a missing optional dependency + should not make a solution disappear — silently shortening the listing hides + the very thing the user needs to be told. + + Parameters + ---------- + name : str + A name from :func:`available`. + + Returns + ------- + bool + """ + + solution = _SOLUTIONS[name] + requires = getattr(solution, "requires", None) + + if requires is None: + return True + + import importlib.util + + return importlib.util.find_spec(requires) is not None + + +def available(installed_only=False): """Names of the solutions in this namespace, in alphabetical order. + Parameters + ---------- + installed_only : bool + Omit solutions whose optional dependency is missing. The default lists + everything; use :func:`is_available` to tell them apart, or + :func:`describe`, which says so in words. + Returns ------- list of str - Every name that can be constructed as ``uw.analytic.(mesh, ...)``. """ - return sorted(_SOLUTIONS) + names = sorted(_SOLUTIONS) + + if installed_only: + return [name for name in names if is_available(name)] + + return names def describe(name): @@ -108,4 +166,9 @@ def describe(name): ) from None docstring = solution.__doc__ or "" - return docstring.strip().split("\n")[0] + summary = docstring.strip().split("\n")[0] + + if not is_available(name): + return f"{summary} [unavailable: needs the '{solution.requires}' package]" + + return summary diff --git a/src/underworld3/analytic/_base.py b/src/underworld3/analytic/_base.py index ebf6edb8c..ae159a200 100644 --- a/src/underworld3/analytic/_base.py +++ b/src/underworld3/analytic/_base.py @@ -78,6 +78,28 @@ class AnalyticSolution(uw_object): nonlinear = False reference = "" + #: Which equation this solution answers. The suite began as Stokes-only and + #: the field names still read that way, so a solution that answers something + #: else says so here rather than being exempted from the checks: the + #: conformance suite dispatches on this, and applies the residual for the + #: equation the solution actually claims. + solves = "stokes" + + #: Whether the fields are SymPy expressions on ``mesh.X``. True for every + #: solution in the suite proper — that is the one-form rule. A solution that + #: wraps an external *numeric* library says so here, and thereby says that + #: the residual gates cannot be applied to it: there is no expression to + #: differentiate, so it can be compared against a solver but never checked + #: against the equations it claims to solve. Treat those as oracles rather + #: than as validated members of the family. + symbolic = True + + #: Name of an optional package this solution needs, or None. A solution with + #: an unmet requirement is still listed by :func:`available` — it just says + #: it is unavailable, because silently omitting it hides the one fact the + #: user needs. + requires = None + #: Whether this solution's published stress is the deviator rather than the #: total. Declared, never inferred — the family is not consistent, and one #: kernel writes its deviator into an array named ``total_stress``. Getting @@ -95,6 +117,9 @@ class AnalyticSolution(uw_object): # field (a temperature, a pressure head) extends this so error() and # evaluate() reach it by name. _fields = { + "solution": "fn_solution", + "coefficient": "fn_coefficient", + "source": "fn_source", "velocity": "fn_velocity", "pressure": "fn_pressure", "stress": "fn_stress", @@ -128,6 +153,39 @@ def boundaries(self): walls += ["Front", "Back"] return walls + def set_scalar_field(self, solution, coefficient, source, advection=None): + r"""Populate the fields of a scalar solution. + + For the transport family — steady diffusion, Darcy flow, Richards — where + the unknown is one field satisfying + :math:`\nabla\cdot(k\,\nabla u) + f = 0` rather than a velocity and a + pressure. + + Parameters + ---------- + solution : sympy expression + The exact field: temperature, pressure head, hydraulic head. + coefficient : sympy expression + Diffusivity, conductivity or permeability — whatever multiplies the + gradient in this solution's equation. + source : sympy expression + The source term the solution is posed with. + advection : sequence, optional + Velocity carrying the field, if it is transported as well as + diffused. Omitted means no advection, which is the common case; + supplying it is what distinguishes an advection-diffusion solution + from a purely diffusive one, and the residual check needs to know. + """ + + self.fn_solution = sympy.sympify(solution) + self.fn_coefficient = sympy.sympify(coefficient) + self.fn_source = sympy.sympify(source) + self.fn_advection = ( + sympy.Matrix([list(advection)]) + if advection is not None + else sympy.zeros(1, self.dim) + ) + def set_fields( self, velocity, pressure, viscosity, bodyforce, stress=None, strainrate=None ): @@ -297,6 +355,28 @@ def error(self, field, meshvar, norm="l2"): return float(np.sqrt(error_squared / exact_squared)) + def set_richards_field(self, solution, conductivity, capacity): + r"""Declare an unsaturated-flow solution. + + Richards is neither of the other two: the coefficient depends on the + unknown, and there is a capacity term multiplying the time derivative, + so it gets its own declaration rather than being forced into + :meth:`set_scalar_field`. + + Parameters + ---------- + solution : sympy expression + Pressure head :math:`\psi`. + conductivity : sympy expression + :math:`K(\psi)`, in terms of the solution itself. + capacity : sympy expression + :math:`C(\psi) = \mathrm d\theta/\mathrm d\psi`. + """ + + self.fn_solution = sympy.sympify(solution) + self.fn_conductivity = sympy.sympify(conductivity) + self.fn_capacity = sympy.sympify(capacity) + def apply_boundary_conditions(self, solver): """Impose the boundary conditions this solution is posed under.""" diff --git a/src/underworld3/analytic/_validation.py b/src/underworld3/analytic/_validation.py index 7bc99e40b..ebc8e87dd 100644 --- a/src/underworld3/analytic/_validation.py +++ b/src/underworld3/analytic/_validation.py @@ -107,7 +107,9 @@ def sample(solution, expression, points): points = np.asarray(points, dtype=float) values = np.asarray( - sympy.lambdify(plain, expression, "numpy", cse=True)( + # SciPy first: NumPy has no erfc, and lambdify then silently falls back + # to the scalar `math.erfc`, which fails only once an array reaches it. + sympy.lambdify(plain, expression, ["scipy", "numpy"], cse=True)( *(points[:, i] for i in range(len(coordinates))) ) ) @@ -234,6 +236,76 @@ def momentum_residual(solution, points): return worst / max(scale, 1.0e-300) +def transport_residual(solution, points): + r"""Largest :math:`|\nabla\cdot(k\nabla u) + f|`, relative to its terms. + + The scalar counterpart of :func:`momentum_residual`, and the same argument + for it: the coefficient, the source and the field all come from the solution + itself, so this consults nothing external and cannot be fooled by a mistake + the solution shares with whatever it might be compared against. + """ + + coordinates = solution.mesh.X + dim = solution.mesh.dim + + flux = [ + solution.fn_coefficient * sympy.diff(solution.fn_solution, coordinates[i]) + for i in range(dim) + ] + terms = [sympy.diff(flux[i], coordinates[i]) for i in range(dim)] + terms.append(solution.fn_source) + + worst = float(np.max(np.abs(sample(solution, sum(terms), points)))) + scale = max( + float(np.max(np.abs(sample(solution, term, points)))) for term in terms + ) + + return worst / max(scale, 1.0e-300) + + +def diffusion_residual(solution, points, time): + r"""Transient scalar residual at *time*. + + .. math:: + \partial_t u + \mathbf v\cdot\nabla u - \nabla\cdot(k\nabla u) + + The advection term is included because it is the general case: a purely + diffusive solution declares no advecting velocity and it drops out. Leaving + it off instead reports an order-one residual for a perfectly good + advection-diffusion solution, which reads like a broken solution rather than + a check applied to the wrong equation. + + Needs no reference — every term comes from the solution itself. + """ + + coordinates = solution.mesh.X + dim = solution.mesh.dim + + rate = sympy.diff(solution.fn_solution, solution.t) + carried = sum( + solution.fn_advection[0, i] * sympy.diff(solution.fn_solution, coordinates[i]) + for i in range(dim) + ) + spread = sum( + sympy.diff( + solution.fn_coefficient * sympy.diff(solution.fn_solution, coordinates[i]), + coordinates[i], + ) + for i in range(dim) + ) + + at_time = {solution.t: time} + residual = (rate + carried - spread).subs(at_time) + worst = float(np.max(np.abs(sample(solution, residual, points)))) + scale = max( + float(np.max(np.abs(sample(solution, term.subs(at_time), points)))) + for term in (rate, carried, spread) + if term != 0 + ) + + return worst / max(scale, 1.0e-300) + + def strainrate_consistency(solution, points): r"""Compare :math:`\tfrac12(\nabla\mathbf u + \nabla\mathbf u^{T})` with the solution's own strain rate, scaled by its magnitude. @@ -287,3 +359,62 @@ def high_precision_value(expression, substitutions, digits=50): """ return sympy.N(expression.subs(substitutions), digits) + + +def richards_residual(solution, points, time=None): + r"""Residual of the Richards equation for an unsaturated-flow solution. + + .. math:: + C(\psi)\,\partial_t \psi - \nabla\cdot\!\left[K(\psi)(\nabla\psi + \hat y)\right] + + The :math:`\hat y` is gravity, and leaving it out is the same class of + mistake as leaving advection out of :func:`diffusion_residual` — it turns a + correct solution into an order-one failure. + + *time* is ``None`` for a steady solution, which drops the capacity term. + Needs no oracle: the conductivity and capacity are the solution's own. + """ + + coordinates = solution.mesh.X + dim = solution.mesh.dim + vertical = dim - 1 + + gravity = [0] * dim + gravity[vertical] = 1 + + # The conducted and gravitational parts of the flux divergence are kept + # apart because they are what has to cancel, and so they are the honest + # scale. Normalising by the divergence itself divides the residual by the + # residual, which reports 1.0 for a flawless solution — that is not a + # tolerance failure, it is a meaningless number. + terms = [] + for i in range(dim): + conducted = solution.fn_conductivity * sympy.diff( + solution.fn_solution, coordinates[i] + ) + terms.append(sympy.diff(conducted, coordinates[i])) + if gravity[i]: + terms.append( + sympy.diff(solution.fn_conductivity * gravity[i], coordinates[i]) + ) + + divergence = sum(terms) + + if time is None: + residual = -divergence + at_time = {} + else: + storage = solution.fn_capacity * sympy.diff(solution.fn_solution, solution.t) + terms.append(storage) + residual = storage - divergence + at_time = {solution.t: time} + + terms = [term for term in terms if term != 0] + + worst = float(np.max(np.abs(sample(solution, residual.subs(at_time), points)))) + scale = max( + float(np.max(np.abs(sample(solution, term.subs(at_time), points)))) + for term in terms + ) + + return worst / max(scale, 1.0e-300) diff --git a/src/underworld3/analytic/kramer.py b/src/underworld3/analytic/kramer.py new file mode 100644 index 000000000..fdfe905fb --- /dev/null +++ b/src/underworld3/analytic/kramer.py @@ -0,0 +1,252 @@ +r"""Curved-geometry Stokes solutions, wrapping the external `assess` library. + +Kramer et al. (2021) give exact Stokes solutions in a cylindrical annulus and a +spherical shell, for smooth and delta-function density anomalies under free-slip +or zero-slip walls. They are the reference for benchmarking a solver on curved +boundaries, which nothing else in this suite covers — every other solution here +is posed on a box. + +Unlike the rest of the suite these are **not SymPy**, and the difference is not +cosmetic. `assess` provides numeric callables, so a Kramer solution can be +compared against a solver but cannot be checked against the equations it claims +to solve: there is nothing to differentiate. It therefore declares +``symbolic = False``, exposes no ``fn_*``, and the conformance suite skips it on +that declaration rather than by name. + +Treat it as an external oracle rather than a validated member of the family. It +is here so the curved-geometry benchmarks stop importing an undeclared +dependency, and so that when the dependency is missing the failure is a sentence +rather than a traceback. + +Installation:: + + pip install "underworld3[benchmarks]" # or: pip install assess + +References +---------- +Kramer, S.C., Davies, D.R. & Wilson, C.R. (2021). Analytical solutions for +mantle flow in cylindrical and spherical shells. *Geoscientific Model +Development* 14, 1899-1919. doi:10.5194/gmd-14-1899-2021 +""" + +import numpy as np + +from ._base import AnalyticSolution + + +_INSTALL_MESSAGE = ( + "The Kramer benchmark solutions need the 'assess' package, which " + "Underworld3 does not depend on.\n" + " pip install \"underworld3[benchmarks]\"\n" + "or pip install assess\n" + "See Kramer, Davies & Wilson (2021), doi:10.5194/gmd-14-1899-2021." +) + + +def _require_assess(): + """Import `assess`, or explain how to get it. + + Deferred to construction rather than to module import so that + ``import underworld3`` works without it and + :func:`underworld3.analytic.available` can still list the solution. + """ + + try: + import assess + except ImportError as missing: + raise ImportError(_INSTALL_MESSAGE) from missing + + return assess + + +def assess_available(): + """Whether the optional `assess` dependency can be imported.""" + + try: + _require_assess() + except ImportError: + return False + + return True + + +class CylindricalStokes(AnalyticSolution): + r"""Stokes flow in a cylindrical annulus, after Kramer et al. (2021). + + Four cases, chosen by *density* and *boundary*: + + ========== =========== ==================================================== + ``density`` ``boundary`` anomaly + ========== =========== ==================================================== + ``"delta"`` ``"free"`` a delta-function density shell at radius ``r_int`` + ``"delta"`` ``"zero"`` the same, with zero-slip walls + ``"smooth"`` ``"free"`` :math:`r^k` radial dependence, wavenumber ``n`` + ``"smooth"`` ``"zero"`` the same, with zero-slip walls + ========== =========== ==================================================== + + The delta cases are solved as two solutions, above and below the anomaly, and + :meth:`evaluate` selects between them by radius — that discontinuity is the + point of the benchmark, and flattening it would remove what is being tested. + + Parameters + ---------- + mesh : Mesh + A 2D annulus mesh. + n : int + Azimuthal wavenumber. + k : int + Radial wavenumber. Used by the smooth cases only. + r_inner, r_outer : float + Shell radii. + r_int : float + Radius of the delta-function anomaly. Delta cases only. + density : {"delta", "smooth"} + boundary : {"free", "zero"} + + Notes + ----- + Built on the `assess` calls the curved-geometry benchmark scripts in + ``docs/examples/`` already make — ``CylindricalStokesSolution*(n, sign, + Rp=, Rm=, rp=, nu=, g=)`` with ``.velocity_cartesian`` and + ``.pressure_cartesian``. The wrapper's own tests cover the missing-dependency + path; the working path is only as good as those signatures. + """ + + dim = 2 + nonlinear = False + symbolic = False + solves = "stokes" + requires = "assess" + reference = ( + "Kramer, Davies & Wilson (2021), Geosci. Model Dev. 14, 1899-1919, " + "doi:10.5194/gmd-14-1899-2021. Numeric, via the external 'assess' package." + ) + boundaries = ("Upper", "Lower") + + def __init__( + self, + mesh, + n=2, + k=2, + r_inner=0.5, + r_outer=1.0, + r_int=0.8, + density="delta", + boundary="free", + ): + super().__init__(mesh) + + if density not in ("delta", "smooth"): + raise ValueError(f"density must be 'delta' or 'smooth'; got {density!r}") + if boundary not in ("free", "zero"): + raise ValueError(f"boundary must be 'free' or 'zero'; got {boundary!r}") + + assess = _require_assess() + + self.n = int(n) + self.k = int(k) + self.r_inner = float(r_inner) + self.r_outer = float(r_outer) + self.r_int = float(r_int) + self.density = density + self.boundary = boundary + + slip = "FreeSlip" if boundary == "free" else "ZeroSlip" + family = getattr( + assess, f"CylindricalStokesSolution{density.capitalize()}{slip}" + ) + + if density == "delta": + shared = dict( + Rp=self.r_outer, Rm=self.r_inner, rp=self.r_int, nu=1.0, g=-1.0 + ) + self._above = family(self.n, +1, **shared) + self._below = family(self.n, -1, **shared) + else: + shared = dict(Rp=self.r_outer, Rm=self.r_inner, nu=1.0, g=1.0) + self._above = family(self.n, self.k, **shared) + self._below = self._above + + def _split(self, coords): + """Indices of the points above and below the anomaly radius.""" + + radius = np.linalg.norm(np.asarray(coords, dtype=float), axis=1) + above = radius >= self.r_int + return above, ~above + + def evaluate(self, field, coords): + """Exact *field* at *coords*, choosing the branch by radius. + + Parameters + ---------- + field : {"velocity", "pressure"} + coords : array, shape (n, 2) + Cartesian coordinates. + + Returns + ------- + ndarray + Shape ``(n, 2)`` for velocity, ``(n,)`` for pressure. + """ + + if field not in ("velocity", "pressure"): + raise ValueError( + f"{type(self).__name__} provides 'velocity' and 'pressure'; " + f"got {field!r}" + ) + + coords = np.asarray(coords, dtype=float) + above, below = self._split(coords) + + width = 2 if field == "velocity" else 1 + values = np.zeros((len(coords), width)) + + for mask, solution in ((above, self._above), (below, self._below)): + if not mask.any(): + continue + call = getattr(solution, f"{field}_cartesian") + values[mask] = np.array([call(point) for point in coords[mask]]).reshape( + -1, width + ) + + return values if width == 2 else values[:, 0] + + def error(self, field, meshvar, norm="l2"): + """Relative error of *meshvar* against the exact *field*. + + The mesh-variable coordinates are used directly, so this is the discrete + :math:`\\ell_2` norm over nodes rather than an integral — `assess` gives + no symbolic form to integrate. + """ + + if norm != "l2": + raise ValueError( + f"{type(self).__name__} has no symbolic form, so only the " + f"discrete 'l2' norm is available; got {norm!r}" + ) + + from underworld3 import mpi + + coords = meshvar.coords + exact = np.atleast_2d(self.evaluate(field, coords).T).T + computed = meshvar.data.reshape(exact.shape) + + local = np.array( + [((computed - exact) ** 2).sum(), (exact**2).sum()], dtype=float + ) + difference, magnitude = mpi.comm.allreduce(local) + + return float(np.sqrt(difference / magnitude)) + + def apply_boundary_conditions(self, solver): + """Free-slip or zero-slip on both walls, as the case declares.""" + + if self.boundary == "zero": + for wall in self.boundaries: + solver.add_dirichlet_bc((0.0, 0.0), wall) + return + + for wall in self.boundaries: + solver.add_rotated_freeslip_bc(0.0, wall) + + solver.petsc_use_pressure_nullspace = True diff --git a/src/underworld3/analytic/richards.py b/src/underworld3/analytic/richards.py new file mode 100644 index 000000000..762600485 --- /dev/null +++ b/src/underworld3/analytic/richards.py @@ -0,0 +1,298 @@ +r"""Exact solutions for unsaturated flow — the Gardner Richards problems. + +Richards is the one nonlinear scalar equation in the suite: + +.. math:: + C(\psi)\,\frac{\partial\psi}{\partial t} + = \nabla\cdot\!\left[K(\psi)\left(\nabla\psi + \hat y\right)\right] + +with the conductivity depending on the unknown head :math:`\psi`. Gardner's +exponential model :math:`K = K_s e^{\alpha\psi}` is the case that admits closed +forms, because the substitution :math:`u = e^{\alpha\psi}` linearises it +*exactly* — not as an approximation. Under that substitution the equation becomes +linear advection–diffusion in :math:`u`, which is why the transient solution here +is an Ogata–Banks form and why it shares a residual check with +:class:`~underworld3.analytic.transport.AdvectedFront`. + +These two solutions already existed in +:mod:`underworld3.utilities.retention_curves` as NumPy functions. Those functions +remain, unchanged in signature, and now delegate here; what they gain is the +residual check and a place in the registry. + +See Also +-------- +underworld3.analytic.transport : the linear scalar solutions. +""" + +import sympy + +from ._base import AnalyticSolution + + +def gardner_steady_saturation(y, psi_0, psi_L, L, alpha): + r"""The linearised variable :math:`u = e^{\alpha\psi}` for steady flow. + + Given as :math:`u` rather than as the head because that is the form the + formula is actually in — :math:`\psi = \ln u/\alpha` is the last step, and + the NumPy wrapper in :mod:`underworld3.utilities.retention_curves` needs to + interpose a floor before taking the log. Building the head here and undoing + it there would be two spellings of one formula, which is how they drift. + """ + + alpha = sympy.sympify(alpha) + u_0 = sympy.exp(alpha * sympy.sympify(psi_0)) + u_L = sympy.exp(alpha * sympy.sympify(psi_L)) + decay = sympy.exp(-alpha * sympy.sympify(L)) + + # Normalised steady flux q/Ks, fixed by the two boundary heads. + flux = (u_L - u_0 * decay) / (1 - decay) + + return (u_0 - flux) * sympy.exp(-alpha * y) + flux, flux + + +def gardner_transient_saturation( + y, t, psi_dry, psi_wet, L, Ks, alpha, theta_r, theta_s +): + r"""The linearised variable :math:`u = e^{\alpha\psi}` for a wetting front. + + The Ogata–Banks solution of :math:`u_t = Du_{zz} - Vu_z` with :math:`z` the + depth below the surface. See :class:`GardnerTransient`. + """ + + alpha = sympy.sympify(alpha) + Ks = sympy.sympify(Ks) + spread = sympy.sympify(theta_s) - sympy.sympify(theta_r) + + D = Ks / (alpha * spread) + V = Ks / spread + depth = sympy.sympify(L) - y + + u_dry = sympy.exp(alpha * sympy.sympify(psi_dry)) + u_wet = sympy.exp(alpha * sympy.sympify(psi_wet)) + + scale = 2 * sympy.sqrt(D * t) + front = ( + sympy.erfc((depth - V * t) / scale) + + sympy.exp(V * depth / D) * sympy.erfc((depth + V * t) / scale) + ) / 2 + + return u_dry + (u_wet - u_dry) * front, D, V + + +class _Gardner(AnalyticSolution): + """Shared Gardner parameters and boundary conditions.""" + + solves = "richards" + dim = 2 + reference = ( + "Gardner (1958), Soil Sci. 85, 228-232; transient form after " + "Ogata & Banks (1961), USGS Prof. Paper 411-A. Previously NumPy " + "functions in underworld3/utilities/retention_curves.py." + ) + + def apply_boundary_conditions(self, solver): + """Prescribe the exact head on every wall.""" + + for boundary in self.boundaries: + solver.add_dirichlet_bc([self.fn_solution], boundary) + + def _gardner_material(self, head): + r"""Conductivity and capacity for a given head expression. + + :math:`K = K_s e^{\alpha\psi}` and + :math:`C = \mathrm d\theta/\mathrm d\psi = \alpha\,\Delta\theta\,e^{\alpha\psi}` + — the unsaturated branches. The saturated branches in + :mod:`~underworld3.utilities.retention_curves` do not apply because + these solutions are posed with :math:`\psi < 0` throughout. + """ + + saturation = sympy.exp(self.alpha * head) + return ( + self.Ks * saturation, + self.alpha * (self.theta_s - self.theta_r) * saturation, + ) + + +class GardnerSteady(_Gardner): + r"""Steady infiltration through a vertical column. + + With gravity, steady Richards reduces to a constant flux, + + .. math:: + K(\psi)\left(\frac{\mathrm d\psi}{\mathrm dy} + 1\right) = q, + + and the Gardner substitution :math:`u = e^{\alpha\psi}` linearises it to give + + .. math:: + \psi(y) = \frac1\alpha\ln\!\left[(u_0 - q^*)e^{-\alpha y} + q^*\right], + \qquad q^* = \frac{u_L - u_0 e^{-\alpha L}}{1 - e^{-\alpha L}}. + + The flux being *constant* is the whole content of the solution, and it is a + sharper test of a Richards solver than the head profile: the head can look + right while the conductivity is being evaluated at the wrong place, and the + flux then drifts down the column. + + Parameters + ---------- + mesh : Mesh + A 2D mesh; the column is the vertical direction. + psi_0, psi_L : float + Head at the bottom and the top. Both should be negative + (unsaturated); the Gardner model is only valid there. + L : float + Column height. + alpha : float + Gardner sorptive number, :math:`1/\mathrm{length}`. + Ks : float + Saturated conductivity. + theta_r, theta_s : float + Residual and saturated water content. + """ + + eqn_solution = ( + r"\frac1\alpha\ln\left[(u_0 - q^*)e^{-\alpha y} + q^*\right]" + ) + + def __init__( + self, + mesh, + psi_0=-1.0, + psi_L=-5.0, + L=1.0, + alpha=1.0, + Ks=1.0, + theta_r=0.05, + theta_s=0.45, + ): + super().__init__(mesh) + + if float(alpha) <= 0.0 or float(Ks) <= 0.0: + raise ValueError("alpha and Ks must be positive.") + if not float(theta_s) > float(theta_r): + raise ValueError("theta_s must exceed theta_r.") + + self.alpha = sympy.Rational(str(alpha)) + self.Ks = sympy.Rational(str(Ks)) + self.theta_r = sympy.Rational(str(theta_r)) + self.theta_s = sympy.Rational(str(theta_s)) + self.L = sympy.Rational(str(L)) + self.psi_0 = float(psi_0) + self.psi_L = float(psi_L) + + y = mesh.X[mesh.dim - 1] + + saturation, self.flux = gardner_steady_saturation( + y, + sympy.Rational(str(psi_0)), + sympy.Rational(str(psi_L)), + self.L, + self.alpha, + ) + head = sympy.log(saturation) / self.alpha + + self.set_richards_field(head, *self._gardner_material(head)) + + +class GardnerTransient(_Gardner): + r"""A wetting front advancing down a dry column. + + The Gardner substitution turns Richards into linear advection–diffusion in + :math:`u = e^{\alpha\psi}`, with + + .. math:: + D = \frac{K_s}{\alpha\,\Delta\theta}, \qquad V = \frac{K_s}{\Delta\theta}, + + and the Ogata–Banks solution for a step applied at the surface gives + + .. math:: + u = u_{\rm dry} + (u_{\rm wet} - u_{\rm dry})\left[ + \tfrac12\mathrm{erfc}\frac{z - Vt}{2\sqrt{Dt}} + + \tfrac12 e^{Vz/D}\,\mathrm{erfc}\frac{z + Vt}{2\sqrt{Dt}}\right] + + with :math:`z = L - y` the depth below the surface. Then + :math:`\psi = \ln u / \alpha`. + + Time is the symbol :attr:`t`, so a solver is checked at whatever time it + reached:: + + exact = sol.fn_solution.subs(sol.t, t_end) + + The solution is semi-infinite, so it satisfies the equation exactly + everywhere but only satisfies the *bottom* boundary condition while the front + has yet to arrive. Compare before then — :attr:`front_depth` says where the + front is. + + Parameters + ---------- + mesh : Mesh + A 2D mesh; the column is the vertical direction. + psi_dry : float + Initial head throughout the column. + psi_wet : float + Head imposed at the surface. Wetter means less negative. + L, alpha, Ks, theta_r, theta_s + As for :class:`GardnerSteady`. + """ + + eqn_solution = ( + r"\frac1\alpha\ln\left[u_{\rm dry} " + r"+ (u_{\rm wet} - u_{\rm dry})H(z,t)\right]" + ) + + def __init__( + self, + mesh, + psi_dry=-5.0, + psi_wet=-0.5, + L=1.0, + alpha=1.0, + Ks=1.0, + theta_r=0.05, + theta_s=0.45, + ): + super().__init__(mesh) + + if float(alpha) <= 0.0 or float(Ks) <= 0.0: + raise ValueError("alpha and Ks must be positive.") + if not float(theta_s) > float(theta_r): + raise ValueError("theta_s must exceed theta_r.") + if not float(psi_wet) > float(psi_dry): + raise ValueError("psi_wet must be wetter (less negative) than psi_dry.") + + self.alpha = sympy.Rational(str(alpha)) + self.Ks = sympy.Rational(str(Ks)) + self.theta_r = sympy.Rational(str(theta_r)) + self.theta_s = sympy.Rational(str(theta_s)) + self.L = sympy.Rational(str(L)) + self.psi_dry = float(psi_dry) + self.psi_wet = float(psi_wet) + self.t = sympy.Symbol("t", positive=True) + + y = mesh.X[mesh.dim - 1] + + saturation, D, V = gardner_transient_saturation( + y, + self.t, + sympy.Rational(str(psi_dry)), + sympy.Rational(str(psi_wet)), + self.L, + self.Ks, + self.alpha, + self.theta_r, + self.theta_s, + ) + self.diffusivity = float(D) + self.speed = float(V) + + head = sympy.log(saturation) / self.alpha + + self.set_richards_field(head, *self._gardner_material(head)) + + def front_depth(self, time): + """Depth of the wetting front below the surface at *time*. + + The front advects at :math:`V = K_s/\\Delta\\theta`; compare against the + column height to know whether the semi-infinite form still holds. + """ + + return self.speed * float(time) diff --git a/src/underworld3/analytic/transport.py b/src/underworld3/analytic/transport.py new file mode 100644 index 000000000..2553928c5 --- /dev/null +++ b/src/underworld3/analytic/transport.py @@ -0,0 +1,255 @@ +r"""Exact solutions for the scalar transport problems. + +Diffusion, Darcy flow, Richards flow and Poisson: one unknown field satisfying +:math:`\nabla\cdot(k\nabla u) + f = 0`, rather than the velocity-and-pressure +pair the Velic family solves for. + +These were already in the repository, written inline in the tests that used them +or as helper functions beside the constitutive models. Collected here they gain +what the Stokes solutions have: a residual check that consults nothing external, +a place to be found, and a name to cite. + +See Also +-------- +underworld3.analytic.velic : the Stokes family. +""" + +import sympy + +from ._base import AnalyticSolution + + +class _Transport(AnalyticSolution): + """Shared metadata and boundary conditions for the scalar solutions. + + These prescribe the field itself on the boundary, so they cannot reuse the + Stokes mixins — `FixedWalls` applies a velocity, which a scalar solution does + not have. + """ + + solves = "transport" + dim = 2 + + def apply_boundary_conditions(self, solver): + """Prescribe the exact field on every wall.""" + + for boundary in self.boundaries: + solver.add_dirichlet_bc([self.fn_solution], boundary) + + +class Poisson1D(_Transport): + r"""One-dimensional Poisson, with a source of your choosing. + + Solves :math:`\nabla^2 u + f = 0` on the unit box with :math:`u` varying in + :math:`z` only, for three sources that between them cover the cases worth + separating: + + ============== ========================== ============================== + ``source`` :math:`f` :math:`u` + ============== ========================== ============================== + ``"none"`` :math:`0` :math:`1 - z` + ``"constant"`` :math:`2` :math:`z(1-z)` + ``"sinusoid"`` :math:`\pi^2\sin(\pi z)` :math:`\sin(\pi z)` + ============== ========================== ============================== + + The simplest thing in the suite, and the first thing to run when a scalar + solver is suspect: a linear profile is exact in any sensible discretisation, + a quadratic is exact from second order up, and only the sinusoid actually + tests convergence. A solver that fails the first two has a problem that has + nothing to do with accuracy. + + Parameters + ---------- + mesh : Mesh + A 2D mesh on the unit box. + source : {"none", "constant", "sinusoid"} + """ + + reference = "Standard; previously inline in tests/test_1000_poissonCart.py." + + def __init__(self, mesh, source="sinusoid"): + super().__init__(mesh) + + x, z = mesh.X + exact = { + "none": (1 - z, sympy.Integer(0)), + "constant": (z * (1 - z), sympy.Integer(2)), + "sinusoid": (sympy.sin(sympy.pi * z), sympy.pi**2 * sympy.sin(sympy.pi * z)), + } + if source not in exact: + raise ValueError( + f"source must be one of {sorted(exact)}; got {source!r}" + ) + + self.source = source + solution, forcing = exact[source] + + self.eqn_solution = {"none": r"1 - z", "constant": r"z(1-z)", + "sinusoid": r"\sin(\pi z)"}[source] + self.set_scalar_field(solution=solution, coefficient=1, source=forcing) + + +class ErfcDiffusion(_Transport): + r"""The error-function diffusion profile. + + :math:`h(z, t) = \mathrm{erfc}\!\left(z / 2\sqrt{Dt}\right)`, the response of + a semi-infinite column to a step held at its boundary. + + Time appears as a symbol, :attr:`t`, so a transient solver can be checked at + whatever time it reached:: + + exact = sol.fn_solution.subs(sol.t, t_end) + + Starting a comparison at :math:`t > 0` rather than at the step itself is the + point of using this: the initial condition is then smooth and representable + on the mesh, where a sharp step is not. + + Parameters + ---------- + mesh : Mesh + A 2D mesh; the profile varies with :math:`z`. + diffusivity : float + :math:`D`. For a Darcy problem this is conductivity over storativity. + """ + + reference = ( + "Standard; previously inline in " + "tests/test_1005_TransientDarcyCartesian.py." + ) + eqn_solution = r"\mathrm{erfc}\left(z / 2\sqrt{Dt}\right)" + + def __init__(self, mesh, diffusivity=1.0): + super().__init__(mesh) + + if float(diffusivity) <= 0.0: + raise ValueError("diffusivity must be positive.") + + self.diffusivity = float(diffusivity) + self.t = sympy.Symbol("t", positive=True) + + x, z = mesh.X + profile = sympy.erfc(z / (2 * sympy.sqrt(self.diffusivity * self.t))) + + # Diffusion has no source; the field is driven entirely by its boundary, + # so the steady residual is not the right check here — the transient one + # is du/dt = D lap(u), which `diffusion_residual` verifies. + self.set_scalar_field(profile, coefficient=self.diffusivity, source=0) + + +class AdvectedFront(_Transport): + r"""An advecting, diffusing top hat. + + Two error functions, the exact response of a rectangular pulse between + :math:`x_0` and :math:`x_1` carried at speed :math:`u` while diffusing with + :math:`\kappa`. Time is the symbol :attr:`t`, as for :class:`ErfcDiffusion`. + + This is the solution `tests/test_1100_AdvDiffCartesian.py` needs. Its own + note says the test is fragile because a step initial condition is not + representable on the mesh, and that the fix is to start from a smooth profile + at :math:`t > 0` — which is exactly what evaluating this at a positive time + gives. + + Parameters + ---------- + mesh : Mesh + A 2D mesh; the front travels in :math:`x`. + kappa : float + Diffusivity. + speed : float + Advection speed. + x0, x1 : float + Edges of the initial pulse. + """ + + reference = ( + "Ogata & Banks (1961) form; previously inline in " + "tests/test_1100_AdvDiffCartesian.py." + ) + eqn_solution = ( + r"\tfrac12\left[\mathrm{erf}\frac{x_1 - x + ut}{2\sqrt{\kappa t}}" + r" + \mathrm{erf}\frac{x - x_0 - ut}{2\sqrt{\kappa t}}\right]" + ) + + def __init__(self, mesh, kappa=1.0e-3, speed=1.0, x0=0.1, x1=0.3): + super().__init__(mesh) + + if float(kappa) <= 0.0: + raise ValueError("kappa must be positive.") + + self.kappa = float(kappa) + self.speed = float(speed) + self.x0 = float(x0) + self.x1 = float(x1) + self.t = sympy.Symbol("t", positive=True) + + x, z = mesh.X + spread = 2 * sympy.sqrt(self.kappa * self.t) + drift = self.speed * self.t + profile = ( + sympy.erf((self.x1 - x + drift) / spread) + + sympy.erf((x - self.x0 - drift) / spread) + ) / 2 + + self.set_scalar_field( + profile, coefficient=self.kappa, source=0, advection=(self.speed, 0) + ) + + +class TwoLayerDarcy(_Transport): + r"""Steady Darcy flow through two layers of different permeability. + + A piecewise-linear pressure with a kink at the interface, set by continuity + of flux across it: the steeper gradient is in the less permeable layer. + + The simplest problem with a *coefficient* discontinuity rather than a source + one, which makes it the scalar counterpart of SolCx — and the fastest way to + tell whether a Darcy solver handles a permeability contrast at all. + + Parameters + ---------- + mesh : Mesh + A 2D mesh; the layering is in :math:`z`. + k1, k2 : float + Permeability of the lower and upper layer. + interface : float + Height of the layer boundary. + pressure_drop : float + Pressure difference across the whole column. + """ + + reference = ( + "Standard; previously inline in tests/test_1004_DarcyCartesian.py." + ) + eqn_solution = r"\text{piecewise linear, kinked at the interface}" + + def __init__(self, mesh, k1=1.0, k2=0.1, interface=0.5, pressure_drop=1.0): + super().__init__(mesh) + + if not (float(k1) > 0.0 and float(k2) > 0.0): + raise ValueError("permeabilities must be positive.") + if not 0.0 < float(interface) < 1.0: + raise ValueError("interface must lie strictly inside (0, 1).") + + self.k1 = float(k1) + self.k2 = float(k2) + self.interface = float(interface) + self.pressure_drop = float(pressure_drop) + + x, z = mesh.X + lower, upper = sympy.Rational(self.interface), 1 - sympy.Rational(self.interface) + drop = sympy.Rational(self.pressure_drop) + + # Pressure at the interface, from continuity of flux: k1 dP1/dz = k2 dP2/dz. + at_interface = (drop / upper) / (1 / upper + sympy.Rational(self.k1, 1) / sympy.Rational(self.k2, 1) / lower) + + self.set_scalar_field( + sympy.Piecewise( + (at_interface * z / lower, z < self.interface), + (at_interface + (drop - at_interface) * (z - lower) / upper, True), + ), + coefficient=sympy.Piecewise( + (sympy.Rational(self.k1), z < self.interface), + (sympy.Rational(self.k2), True), + ), + source=0, + ) diff --git a/src/underworld3/utilities/retention_curves.py b/src/underworld3/utilities/retention_curves.py index af5cc8134..5659439e1 100644 --- a/src/underworld3/utilities/retention_curves.py +++ b/src/underworld3/utilities/retention_curves.py @@ -403,16 +403,27 @@ def gardner_steady_state_psi(y, psi_0, psi_L, L, alpha): ----- This is a *numpy* function (not sympy) intended for comparing numerical solutions against the analytical benchmark. + + The formula lives in :func:`underworld3.analytic.richards. + gardner_steady_saturation` and is evaluated here; the mesh-based + :class:`underworld3.analytic.GardnerSteady` builds the same expression, so + the two cannot drift apart. That class is the better entry point when you + have a mesh — it carries the conductivity and the boundary conditions as + well, and it is covered by the residual check. """ import numpy as np + import sympy + + from underworld3.analytic.richards import gardner_steady_saturation - u_0 = np.exp(alpha * psi_0) - u_L = np.exp(alpha * psi_L) + coordinate = sympy.Symbol("y") + saturation, _ = gardner_steady_saturation(coordinate, psi_0, psi_L, L, alpha) - # Normalised steady-state flux q* = q / Ks - q_star = (u_L - u_0 * np.exp(-alpha * L)) / (1.0 - np.exp(-alpha * L)) + u = sympy.lambdify(coordinate, saturation, ["scipy", "numpy"])( + np.asarray(y, dtype=float) + ) - return (1.0 / alpha) * np.log((u_0 - q_star) * np.exp(-alpha * y) + q_star) + return (1.0 / alpha) * np.log(u) def gardner_transient_psi(y, t, psi_dry, psi_wet, L, Ks, alpha, theta_r, theta_s): @@ -489,6 +500,11 @@ def gardner_transient_psi(y, t, psi_dry, psi_wet, L, Ks, alpha, theta_r, theta_s The semi-infinite approximation is excellent when the wetting front has not yet reached the bottom boundary. + The formula lives in :func:`underworld3.analytic.richards. + gardner_transient_saturation`; :class:`underworld3.analytic. + GardnerTransient` builds the same expression on a mesh and is the better + entry point when you have one. + References ---------- Ogata, A. and Banks, R. B. (1961). A solution of the differential @@ -496,27 +512,18 @@ def gardner_transient_psi(y, t, psi_dry, psi_wet, L, Ks, alpha, theta_r, theta_s *US Geological Survey Professional Paper* 411-A. """ import numpy as np - from scipy.special import erfc + import sympy - delta_theta = theta_s - theta_r - D = Ks / (alpha * delta_theta) - V = Ks / delta_theta + from underworld3.analytic.richards import gardner_transient_saturation - u_dry = np.exp(alpha * psi_dry) - u_wet = np.exp(alpha * psi_wet) - - # Depth from the top (z = 0 at top, z = L at bottom) - z = L - np.asarray(y, dtype=float) - - sqrt_Dt = np.sqrt(D * t) - - # Ogata-Banks solution - H = ( - 0.5 * erfc((z - V * t) / (2.0 * sqrt_Dt)) - + 0.5 * np.exp(V * z / D) * erfc((z + V * t) / (2.0 * sqrt_Dt)) + coordinate, elapsed = sympy.symbols("y t") + saturation, _, _ = gardner_transient_saturation( + coordinate, elapsed, psi_dry, psi_wet, L, Ks, alpha, theta_r, theta_s ) - u = u_dry + (u_wet - u_dry) * H + u = sympy.lambdify((coordinate, elapsed), saturation, ["scipy", "numpy"])( + np.asarray(y, dtype=float), float(t) + ) return (1.0 / alpha) * np.log(np.maximum(u, 1e-30)) diff --git a/tests/test_1024_analytic_conformance.py b/tests/test_1024_analytic_conformance.py index 773aa6d41..618fd7951 100644 --- a/tests/test_1024_analytic_conformance.py +++ b/tests/test_1024_analytic_conformance.py @@ -24,7 +24,35 @@ import underworld3 as uw -SOLUTIONS = sorted(uw.analytic.available()) +# Solutions that are SymPy and installable here. The two exclusions are +# declared by the solutions themselves, not listed by name, so a later solution +# in either category is handled without touching this file. +# +# symbolic = False an external numeric oracle; nothing to differentiate, so +# the residual gates cannot be applied to it at all +# requires = "..." an optional dependency that is not installed +# +# The excluded names are asserted below, so an accidental exclusion — a typo in +# a class attribute, say — fails here rather than quietly shrinking the sweep. +SOLUTIONS = sorted( + name + for name in uw.analytic.available() + if getattr(uw.analytic, name).symbolic and uw.analytic.is_available(name) +) + + +def test_the_sweep_covers_everything_it_can(): + """What this file skips, and on what declared grounds.""" + + excluded = set(uw.analytic.available()) - set(SOLUTIONS) + + for name in excluded: + solution = getattr(uw.analytic, name) + assert not solution.symbolic or not uw.analytic.is_available(name), ( + f"{name} is symbolic and available but is not being swept" + ) + + assert len(SOLUTIONS) >= 19, "the sweep has lost solutions" @pytest.fixture(scope="module") @@ -73,7 +101,16 @@ def test_solution_declares_its_metadata(name): assert isinstance(solution.nonlinear, bool) -@pytest.mark.parametrize("name", SOLUTIONS) +STOKES = [n for n in SOLUTIONS if getattr(uw.analytic, n).solves == "stokes"] +TRANSPORT = [n for n in SOLUTIONS if getattr(uw.analytic, n).solves == "transport"] +RICHARDS = [n for n in SOLUTIONS if getattr(uw.analytic, n).solves == "richards"] + +# Every registered solution belongs to exactly one family, so adding a solution +# with a new `solves` value fails here rather than being quietly untested. +assert set(STOKES) | set(TRANSPORT) | set(RICHARDS) == set(SOLUTIONS) + + +@pytest.mark.parametrize("name", STOKES) def test_solution_exposes_the_whole_contract(name, built): sol = built[name] dim = sol.dim @@ -86,7 +123,7 @@ def test_solution_exposes_the_whole_contract(name, built): assert sol.fn_viscosity is not None -@pytest.mark.parametrize("name", SOLUTIONS) +@pytest.mark.parametrize("name", STOKES) def test_solution_is_incompressible(name, built): from underworld3.analytic import _validation @@ -96,7 +133,7 @@ def test_solution_is_incompressible(name, built): assert _validation.incompressibility_residual(sol, points) < 1.0e-8 -@pytest.mark.parametrize("name", SOLUTIONS) +@pytest.mark.parametrize("name", STOKES) def test_solution_satisfies_the_momentum_balance(name, built): r""":math:`\nabla\cdot\sigma + \mathbf f = 0`, for every solution. @@ -113,7 +150,7 @@ def test_solution_satisfies_the_momentum_balance(name, built): assert _validation.momentum_residual(sol, points) < 1.0e-8 -@pytest.mark.parametrize("name", SOLUTIONS) +@pytest.mark.parametrize("name", STOKES) def test_stress_and_strain_rate_agree(name, built): r""":math:`\sigma + p\,I = 2\eta\dot\varepsilon`, however each was obtained. @@ -145,3 +182,67 @@ def test_stress_and_strain_rate_agree(name, built): # Building a Stokes solver per solution here as well was not worth what it cost: # it dominated the runtime of this file without checking anything the contract # tests do not already cover. + + +@pytest.mark.parametrize("name", TRANSPORT) +def test_transport_solution_exposes_its_contract(name, built): + sol = built[name] + + assert sol.fn_solution is not None + assert sol.fn_coefficient is not None + assert sol.fn_source is not None + + +@pytest.mark.parametrize("name", TRANSPORT) +def test_transport_solution_satisfies_its_equation(name, built): + r"""Steady or transient, whichever the solution declares. + + A time symbol means the equation is + :math:`\partial_t u + \mathbf v\cdot\nabla u = \nabla\cdot(k\nabla u)`, and + those are checked at three times rather than skipped. Skipping them here is + what let `AdvectedFront` be judged against the wrong equation for as long as + it was — the whole point of this file is that no registered solution goes + unchecked. + """ + + from underworld3.analytic import _validation + + sol = built[name] + points = sol.sample_points(count=8) + + if getattr(sol, "t", None) is None: + assert _validation.transport_residual(sol, points) < 1.0e-10 + return + + for time in (0.05, 0.2, 0.5): + assert _validation.diffusion_residual(sol, points, time) < 1.0e-10 + + +@pytest.mark.parametrize("name", RICHARDS) +def test_richards_solution_exposes_its_contract(name, built): + sol = built[name] + + assert sol.fn_solution is not None + assert sol.fn_conductivity is not None + assert sol.fn_capacity is not None + + +@pytest.mark.parametrize("name", RICHARDS) +def test_richards_solution_satisfies_its_equation(name, built): + r""":math:`C(\psi)\partial_t\psi = \nabla\cdot[K(\psi)(\nabla\psi + \hat y)]`. + + Nonlinear, and the conductivity is the solution's own — so this consults + nothing external, exactly as the Stokes momentum residual does. + """ + + from underworld3.analytic import _validation + + sol = built[name] + points = sol.sample_points(count=8) + + if getattr(sol, "t", None) is None: + assert _validation.richards_residual(sol, points) < 1.0e-10 + return + + for time in (0.05, 0.2): + assert _validation.richards_residual(sol, points, time) < 1.0e-10 diff --git a/tests/test_1025_analytic_transport.py b/tests/test_1025_analytic_transport.py new file mode 100644 index 000000000..9b03a9615 --- /dev/null +++ b/tests/test_1025_analytic_transport.py @@ -0,0 +1,162 @@ +r"""The scalar transport solutions. + +Diffusion, advection–diffusion, Darcy and Poisson — one unknown field rather than +the velocity-and-pressure pair the Velic family solves for. These were already in +the repository, inline in the tests that used them; collected into +`uw.analytic` they gain the same oracle-free residual the Stokes solutions have. + +The steady ones reduce to *exactly* zero, so those assertions are exact. The +transient ones carry a time symbol and are checked at several times against +:math:`\partial_t u + \mathbf v\cdot\nabla u = \nabla\cdot(k\nabla u)`. + +Run: pixi run python -m pytest tests/test_1025_analytic_transport.py -v +""" + +import pytest + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + +import numpy as np +import sympy +import underworld3 as uw + + +@pytest.fixture(scope="module") +def mesh(): + return uw.meshing.StructuredQuadBox( + elementRes=(4, 4), minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), qdegree=3 + ) + + +@pytest.mark.parametrize("source", ["none", "constant", "sinusoid"]) +def test_poisson_satisfies_its_equation(mesh, source): + from underworld3.analytic import _validation + + sol = uw.analytic.Poisson1D(mesh, source=source) + assert _validation.transport_residual(sol, sol.sample_points(count=8)) == 0.0 + + +def test_poisson_profiles_are_what_they_claim(mesh): + """Short enough to assert outright, which is the cheapest possible check.""" + + x, z = mesh.X + + assert sympy.simplify(uw.analytic.Poisson1D(mesh, "none").fn_solution - (1 - z)) == 0 + assert ( + sympy.simplify(uw.analytic.Poisson1D(mesh, "constant").fn_solution - z * (1 - z)) + == 0 + ) + assert ( + sympy.simplify( + uw.analytic.Poisson1D(mesh, "sinusoid").fn_solution - sympy.sin(sympy.pi * z) + ) + == 0 + ) + + +def test_poisson_rejects_an_unknown_source(mesh): + with pytest.raises(ValueError, match="source must be one of"): + uw.analytic.Poisson1D(mesh, source="quartic") + + +def test_two_layer_darcy_satisfies_its_equation(mesh): + r""":math:`\nabla\cdot(k\nabla p) = 0` across a permeability jump.""" + + from underworld3.analytic import _validation + + sol = uw.analytic.TwoLayerDarcy(mesh, k1=1.0, k2=0.1) + assert _validation.transport_residual(sol, sol.sample_points(count=8)) == 0.0 + + +def test_two_layer_darcy_conserves_flux_across_the_interface(mesh): + r""":math:`k\,\partial p/\partial z` is continuous even though the gradient is not. + + This is the physical content of the solution and the thing a Darcy solver + has to get right, so it is asserted directly rather than inferred from the + residual. + """ + + from underworld3.analytic import _validation + + k1, k2, interface = 1.0, 0.1, 0.5 + sol = uw.analytic.TwoLayerDarcy(mesh, k1=k1, k2=k2, interface=interface) + x, z = mesh.X + + gradient = sympy.diff(sol.fn_solution, z) + below = np.array([[0.4, interface - 0.05]]) + above = np.array([[0.4, interface + 0.05]]) + + flux_below = k1 * _validation.sample(sol, gradient, below)[0] + flux_above = k2 * _validation.sample(sol, gradient, above)[0] + + assert np.isclose(flux_below, flux_above, rtol=1.0e-10) + # ... and the gradient itself is genuinely discontinuous, so the test above + # is not passing for the trivial reason that nothing changes at all. + assert not np.isclose( + _validation.sample(sol, gradient, below)[0], + _validation.sample(sol, gradient, above)[0], + ) + + +@pytest.mark.parametrize("time", [0.05, 0.2, 0.5]) +def test_erfc_diffusion_satisfies_the_heat_equation(mesh, time): + from underworld3.analytic import _validation + + sol = uw.analytic.ErfcDiffusion(mesh, diffusivity=0.5) + assert _validation.diffusion_residual(sol, sol.sample_points(count=8), time) < 1e-10 + + +@pytest.mark.parametrize("time", [0.05, 0.2, 0.5]) +def test_advected_front_satisfies_advection_diffusion(mesh, time): + r""":math:`\partial_t c + u\,\partial_x c = \kappa\,\partial_{xx} c`. + + The advection term matters: without it this same solution reports a residual + of order one, which looks like a broken solution rather than a check applied + to the wrong equation. + """ + + from underworld3.analytic import _validation + + sol = uw.analytic.AdvectedFront(mesh, kappa=1.0e-2, speed=0.5) + assert _validation.diffusion_residual(sol, sol.sample_points(count=8), time) < 1e-10 + + +def test_advected_front_travels(mesh): + """The pulse is where advection says it should be, and it spreads.""" + + from underworld3.analytic import _validation + + speed, x0, x1 = 0.5, 0.1, 0.3 + sol = uw.analytic.AdvectedFront(mesh, kappa=1.0e-4, speed=speed, x0=x0, x1=x1) + + centre = (x0 + x1) / 2 + for time in (0.2, 0.6): + expected = centre + speed * time + here = np.array([[expected, 0.5]]) + elsewhere = np.array([[expected - 0.3, 0.5]]) + + at_pulse = _validation.sample(sol, sol.fn_solution.subs(sol.t, time), here)[0] + away = _validation.sample(sol, sol.fn_solution.subs(sol.t, time), elsewhere)[0] + + assert at_pulse > 0.9, "pulse is not where advection puts it" + assert away < 0.1, "pulse has not stayed compact" + + +def test_transport_solutions_prescribe_their_own_field(mesh): + """They cannot reuse the Stokes mixins, which apply a velocity.""" + + sol = uw.analytic.Poisson1D(mesh) + + temperature = uw.discretisation.MeshVariable("Tb", mesh, 1, degree=2) + poisson = uw.systems.Poisson(mesh, temperature) + sol.apply_boundary_conditions(poisson) + + assert {bc.boundary for bc in poisson.essential_bcs} == set(sol.boundaries) + + +def test_transport_solutions_are_registered(): + registered = set(uw.analytic.available()) + assert {"Poisson1D", "TwoLayerDarcy", "ErfcDiffusion", "AdvectedFront"} <= registered + + for name in ("Poisson1D", "TwoLayerDarcy", "ErfcDiffusion", "AdvectedFront"): + assert getattr(uw.analytic, name).solves == "transport" diff --git a/tests/test_1026_analytic_richards.py b/tests/test_1026_analytic_richards.py new file mode 100644 index 000000000..753aa8e6b --- /dev/null +++ b/tests/test_1026_analytic_richards.py @@ -0,0 +1,272 @@ +r"""The Gardner unsaturated-flow solutions. + +Richards is the one nonlinear scalar equation in the suite: the conductivity +depends on the unknown head. Gardner's :math:`K = K_s e^{\alpha\psi}` is the case +that closes, because :math:`u = e^{\alpha\psi}` linearises it *exactly*. + +These solutions were already in the repository as NumPy functions in +`utilities/retention_curves.py`, where nothing checked them against the equation +they solve. Both are exact here, so the residual assertions are tight. + +Run: pixi run python -m pytest tests/test_1026_analytic_richards.py -v +""" + +import pytest + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + +import numpy as np +import sympy +import underworld3 as uw + + +@pytest.fixture(scope="module") +def mesh(): + return uw.meshing.StructuredQuadBox( + elementRes=(4, 4), minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), qdegree=3 + ) + + +def test_steady_satisfies_richards(mesh): + from underworld3.analytic import _validation + + sol = uw.analytic.GardnerSteady(mesh) + assert _validation.richards_residual(sol, sol.sample_points(count=8)) < 1e-12 + + +@pytest.mark.parametrize("time", [0.05, 0.1, 0.2]) +def test_transient_satisfies_richards(mesh, time): + from underworld3.analytic import _validation + + sol = uw.analytic.GardnerTransient(mesh) + points = sol.sample_points(count=8) + assert _validation.richards_residual(sol, points, time) < 1e-12 + + +@pytest.mark.parametrize( + "break_it, label", + [ + ( + lambda sol: setattr( + sol, + "fn_conductivity", + sol.Ks * sympy.exp(sympy.Rational(11, 10) * sol.alpha * sol.fn_solution), + ), + "wrong alpha in K", + ), + ( + lambda sol: setattr(sol, "fn_conductivity", sympy.sympify(sol.Ks)), + "conductivity independent of head", + ), + ( + lambda sol: setattr( + sol, "fn_solution", sol.fn_solution * sympy.Rational(101, 100) + ), + "head scaled by 1%", + ), + ], +) +def test_the_residual_discriminates(mesh, break_it, label): + """Gate 5: a check that passes a broken input is measuring nothing. + + Note what is *not* used as a control here. Scaling :math:`K` by a constant + leaves the residual at zero — correctly, because that is a genuine symmetry + of the steady equation, not a defect the check missed. A negative control + has to break the relationship between the conductivity and the head, which + is the thing the solution actually asserts. + """ + + from underworld3.analytic import _validation + + sol = uw.analytic.GardnerSteady(mesh) + points = sol.sample_points(count=8) + assert _validation.richards_residual(sol, points) < 1e-12 + + break_it(sol) + assert _validation.richards_residual(sol, points) > 1e-3, label + + +def test_scaling_conductivity_is_a_symmetry_not_a_defect(mesh): + """The counterpart to the control above, asserted rather than assumed.""" + + from underworld3.analytic import _validation + + sol = uw.analytic.GardnerSteady(mesh) + sol.fn_conductivity = 2 * sol.fn_conductivity + assert _validation.richards_residual(sol, sol.sample_points(count=8)) < 1e-12 + + +def test_steady_flux_is_constant_down_the_column(mesh): + r""":math:`K(\psi)(\partial_y\psi + 1)` is the same at every height. + + This is the physical content of the steady solution and a sharper test of a + Richards solver than the head profile: the head can look right while the + conductivity is evaluated at the wrong place, and then the flux drifts. + """ + + from underworld3.analytic import _validation + + sol = uw.analytic.GardnerSteady(mesh) + y = mesh.X[1] + + flux = sol.fn_conductivity * (sympy.diff(sol.fn_solution, y) + 1) + heights = np.column_stack( + [np.full(9, 0.5), np.linspace(0.05, 0.95, 9)] + ) + values = _validation.sample(sol, flux, heights) + + assert np.ptp(values) / np.abs(values).max() < 1e-12 + # and it is the flux the construction reported + assert np.allclose(values, float(sol.Ks) * float(sol.flux), rtol=1e-10) + + +def test_steady_meets_its_boundary_heads(mesh): + from underworld3.analytic import _validation + + psi_0, psi_L = -1.5, -4.0 + sol = uw.analytic.GardnerSteady(mesh, psi_0=psi_0, psi_L=psi_L, L=1.0) + + ends = np.array([[0.5, 0.0], [0.5, 1.0]]) + assert np.allclose( + _validation.sample(sol, sol.fn_solution, ends), [psi_0, psi_L], rtol=1e-10 + ) + + +def _half_saturation_depth(sol, time, alpha, psi_dry, psi_wet): + r"""Depth at which :math:`u = e^{\alpha\psi}` is halfway between its limits. + + Halfway in :math:`u`, not in :math:`\psi`. The two are very different: at + :math:`\alpha=1` with :math:`\psi` running from -5 to -0.5, the midpoint + *head* sits at :math:`H \approx 0.1`, out in the leading tail, and tracking + it measures how far the tail has spread rather than where the front is. + """ + + from underworld3.analytic import _validation + + level = np.log((np.exp(alpha * psi_dry) + np.exp(alpha * psi_wet)) / 2) / alpha + column = np.column_stack([np.full(2000, 0.5), np.linspace(0.0, 1.0, 2000)]) + values = _validation.sample(sol, sol.fn_solution.subs(sol.t, time), column) + + wetted = column[values > level, 1] + return 1.0 - wetted.min() if wetted.size else np.nan + + +def test_transient_front_advances_at_the_advective_speed(mesh): + r"""The front tracks :math:`V = K_s/\Delta\theta` when advection dominates. + + Checked at :math:`\mathrm{Pe} = \alpha L = 20`, where the front is sharp + enough for "where the front is" to mean something. The default parameters + give :math:`\mathrm{Pe} = 1` — see the test below, which is the same + measurement and deliberately does *not* assert the advective speed. + """ + + from underworld3.analytic import _validation + + alpha, psi_dry, psi_wet = 20.0, -0.5, -0.05 + sol = uw.analytic.GardnerTransient( + mesh, psi_dry=psi_dry, psi_wet=psi_wet, alpha=alpha + ) + assert np.isclose(sol.speed, 1.0 / (0.45 - 0.05)) + assert np.isclose(sol.speed / sol.diffusivity, alpha) + + early = _half_saturation_depth(sol, 0.05, alpha, psi_dry, psi_wet) + late = _half_saturation_depth(sol, 0.15, alpha, psi_dry, psi_wet) + + assert late > early, "front did not advance" + assert np.isclose(late - early, sol.speed * 0.10, rtol=0.15) + + surface = np.array([[0.5, 1.0]]) + head = sol.fn_solution.subs(sol.t, 0.05) + assert _validation.sample(sol, head, surface)[0] > psi_dry, "surface not wet" + + +def test_transient_front_is_diffusion_dominated_at_unit_peclet(mesh): + """At Pe = 1 the front outruns advection, because it is mostly spreading. + + Recorded so the tolerance in the test above is not mistaken for a general + claim about where the front is: at the default parameters the same + measurement gives roughly 1.6x the advective distance, and that is the + solution behaving correctly rather than a defect. + """ + + alpha, psi_dry, psi_wet = 1.0, -5.0, -0.5 + sol = uw.analytic.GardnerTransient( + mesh, psi_dry=psi_dry, psi_wet=psi_wet, alpha=alpha + ) + assert np.isclose(sol.speed / sol.diffusivity, alpha) + + early = _half_saturation_depth(sol, 0.05, alpha, psi_dry, psi_wet) + late = _half_saturation_depth(sol, 0.15, alpha, psi_dry, psi_wet) + + assert late > early + assert (late - early) > 1.3 * sol.speed * 0.10 + + +def test_transient_reports_when_the_semi_infinite_form_expires(mesh): + """The solution is only usable while the front is inside the column.""" + + sol = uw.analytic.GardnerTransient(mesh, L=1.0) + + assert sol.front_depth(0.1) < 1.0 + assert sol.front_depth(1.0) > 1.0 + + +@pytest.mark.parametrize( + "kwargs, message", + [ + (dict(alpha=0.0), "alpha and Ks must be positive"), + (dict(Ks=-1.0), "alpha and Ks must be positive"), + (dict(theta_r=0.5, theta_s=0.4), "theta_s must exceed theta_r"), + ], +) +def test_rejects_unphysical_parameters(mesh, kwargs, message): + with pytest.raises(ValueError, match=message): + uw.analytic.GardnerSteady(mesh, **kwargs) + + +def test_transient_requires_the_surface_to_be_wetter(mesh): + with pytest.raises(ValueError, match="psi_wet must be wetter"): + uw.analytic.GardnerTransient(mesh, psi_dry=-0.5, psi_wet=-5.0) + + +def test_retention_curve_wrappers_agree_with_the_solution(mesh): + """The NumPy functions and the mesh classes are one formula, not two. + + `retention_curves` keeps its published signatures; this asserts it did not + keep a second copy of the arithmetic along with them. + """ + + from underworld3.analytic import _validation + from underworld3.utilities import retention_curves as rc + + heights = np.linspace(0.05, 0.95, 11) + points = np.column_stack([np.full(heights.size, 0.5), heights]) + + steady = uw.analytic.GardnerSteady( + mesh, psi_0=-1.0, psi_L=-5.0, L=1.0, alpha=1.0 + ) + assert np.allclose( + _validation.sample(steady, steady.fn_solution, points), + rc.gardner_steady_state_psi(heights, -1.0, -5.0, 1.0, 1.0), + rtol=1e-12, + ) + + transient = uw.analytic.GardnerTransient( + mesh, psi_dry=-5.0, psi_wet=-0.5, L=1.0, Ks=1.0, alpha=1.0, + theta_r=0.05, theta_s=0.45, + ) + assert np.allclose( + _validation.sample(transient, transient.fn_solution.subs(transient.t, 0.1), points), + rc.gardner_transient_psi( + heights, 0.1, -5.0, -0.5, 1.0, 1.0, 1.0, 0.05, 0.45 + ), + rtol=1e-12, + ) + + +def test_richards_solutions_are_registered(): + registered = set(uw.analytic.available()) + assert {"GardnerSteady", "GardnerTransient"} <= registered + + for name in ("GardnerSteady", "GardnerTransient"): + assert getattr(uw.analytic, name).solves == "richards" diff --git a/tests/test_1027_analytic_optional.py b/tests/test_1027_analytic_optional.py new file mode 100644 index 000000000..28a77cfcb --- /dev/null +++ b/tests/test_1027_analytic_optional.py @@ -0,0 +1,159 @@ +r"""Solutions behind an optional dependency. + +`assess` (Kramer et al. 2021) is imported by four curved-geometry benchmark +scripts under `docs/examples/` and declared in no dependency list — so those +scripts fail with a bare `ModuleNotFoundError` on a normal install. It is now an +optional extra with a wrapper that says what to do about it. + +These tests cover the *missing*-dependency path, which is the path a normal +install takes and the one the previous arrangement got wrong. Where `assess` is +installed, the constructing tests run too. + +Run: pixi run python -m pytest tests/test_1027_analytic_optional.py -v +""" + +import pytest + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + +import numpy as np +import underworld3 as uw + +from underworld3.analytic.kramer import assess_available + +needs_assess = pytest.mark.skipif( + not assess_available(), reason="the optional 'assess' package is not installed" +) + + +def test_importing_underworld_does_not_need_the_optional_package(): + """The whole point of a lazy import: absence must cost nothing at import.""" + + import underworld3.analytic.kramer # noqa: F401 + + assert uw.analytic.CylindricalStokes is not None + + +def test_the_solution_is_listed_even_when_it_cannot_be_built(): + """Listed, and marked — not omitted. + + Omitting it would make `available()` truthful about what constructs and + silent about what exists, which leaves a user who wants a curved-geometry + benchmark with no way to discover that one is a `pip install` away. + """ + + assert "CylindricalStokes" in uw.analytic.available() + assert uw.analytic.is_available("CylindricalStokes") == assess_available() + + installed = uw.analytic.available(installed_only=True) + assert ("CylindricalStokes" in installed) == assess_available() + + +def test_every_other_solution_is_unconditionally_available(): + for name in uw.analytic.available(): + if name == "CylindricalStokes": + continue + assert uw.analytic.is_available(name), name + assert getattr(uw.analytic, name).requires is None + + +@pytest.mark.skipif(assess_available(), reason="'assess' is installed here") +def test_describe_says_what_is_missing(): + summary = uw.analytic.describe("CylindricalStokes") + + assert "unavailable" in summary + assert "assess" in summary + + +@pytest.mark.skipif(assess_available(), reason="'assess' is installed here") +def test_constructing_it_explains_rather_than_tracebacks(): + mesh = uw.meshing.Annulus(radiusOuter=1.0, radiusInner=0.5, cellSize=0.2) + + with pytest.raises(ImportError) as raised: + uw.analytic.CylindricalStokes(mesh) + + message = str(raised.value) + assert "pip install" in message + assert "assess" in message + # and it cites the paper, so the user knows what they are installing + assert "Kramer" in message + + +def test_it_declares_that_the_residual_gates_cannot_reach_it(): + """A numeric oracle is not a validated solution, and must not pass as one. + + Every other solution is SymPy and clears the physics residual. This one + cannot: there is no expression to differentiate. The declaration is what + keeps the conformance sweep from either skipping it silently or reporting a + pass it never earned. + """ + + assert uw.analytic.CylindricalStokes.symbolic is False + assert uw.analytic.CylindricalStokes.requires == "assess" + + for name in uw.analytic.available(): + if name != "CylindricalStokes": + assert getattr(uw.analytic, name).symbolic, name + + +def test_it_rejects_unknown_cases_before_reaching_the_dependency(monkeypatch): + """Argument validation must not be hidden behind the optional import. + + Otherwise a typo in `density=` reports a missing package on machines without + `assess` and a wrong answer on machines with it. + """ + + mesh = uw.meshing.Annulus(radiusOuter=1.0, radiusInner=0.5, cellSize=0.2) + + with pytest.raises(ValueError, match="density must be"): + uw.analytic.CylindricalStokes(mesh, density="gaussian") + + with pytest.raises(ValueError, match="boundary must be"): + uw.analytic.CylindricalStokes(mesh, boundary="sticky") + + +@pytest.fixture(scope="module") +def annulus(): + return uw.meshing.Annulus(radiusOuter=1.0, radiusInner=0.5, cellSize=0.15) + + +@needs_assess +@pytest.mark.parametrize("density", ["delta", "smooth"]) +@pytest.mark.parametrize("boundary", ["free", "zero"]) +def test_all_four_cases_construct_and_evaluate(annulus, density, boundary): + sol = uw.analytic.CylindricalStokes( + annulus, n=2, k=2, density=density, boundary=boundary + ) + + coords = annulus.X.coords + velocity = sol.evaluate("velocity", coords) + pressure = sol.evaluate("pressure", coords) + + assert velocity.shape == (len(coords), 2) + assert pressure.shape == (len(coords),) + assert np.isfinite(velocity).all() + assert np.isfinite(pressure).all() + + +@needs_assess +def test_the_delta_case_is_genuinely_two_sided(annulus): + """The branch selection is the benchmark, so it has to actually branch.""" + + sol = uw.analytic.CylindricalStokes(annulus, density="delta", r_int=0.8) + assert sol._above is not sol._below + + radii = np.array([[0.6, 0.0], [0.9, 0.0]]) + above, below = sol._split(radii) + assert below[0] and above[1] + + +@needs_assess +def test_it_rejects_fields_it_does_not_have(annulus): + sol = uw.analytic.CylindricalStokes(annulus) + + with pytest.raises(ValueError, match="velocity"): + sol.evaluate("temperature", annulus.X.coords) + + velocity = uw.discretisation.MeshVariable("Vk", annulus, annulus.dim, degree=2) + with pytest.raises(ValueError, match="no symbolic form"): + sol.error("velocity", velocity, norm="integral") From e9e9838106d5224b5555c90fa2388e7d298d703a Mon Sep 17 00:00:00 2001 From: lmoresi Date: Mon, 3 Aug 2026 17:45:34 +1000 Subject: [PATCH 21/28] Switch the four scalar tests onto uw.analytic, removing the inline copies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_1000 (Poisson sinusoid), test_1004 (two-layer Darcy), test_1005 (erfc diffusion) and test_1100 (advecting top hat) each carried their own copy of an exact solution that nothing checked against the equation it claimed to solve. They now use the registered solutions, which the conformance suite verifies. Assertions and tolerances are unchanged. Two things came out of the Darcy migration. TwoLayerDarcy needed generalising: test_1004 is posed on y in (-1, 0), not the unit column, and runs the case twice — with and without gravity. It now takes the column extent and a gravity term S, and the profile is *derived* from constant flux q = -k(dp/dz + S) rather than transcribed from the closed form the test carried. That makes the agreement a check on both rather than a copy of one: 1.1e-16 in both gravity cases, residual exactly zero. Its permeability arguments are now k_lower / k_upper rather than k1 / k2. test_1004's k1 is the *upper* layer, and getting that backwards produces a smooth, plausible, wrong answer with nothing to flag it — the names should not leave that available. test_1100's mesh0 case xpasses, as it did intermittently before; its xfail is strict=False and its note says either outcome is acceptable pending a rework. Nothing here was tuned to change that, and it is left alone. 29 passed, 1 xpassed across the four files. Underworld development team with AI support from Claude Code --- .../subsystems/analytic-solutions.md | 17 +++- src/underworld3/analytic/transport.py | 95 ++++++++++++++----- tests/test_1000_poissonCart.py | 9 +- tests/test_1004_DarcyCartesian.py | 37 +++----- tests/test_1005_TransientDarcyCartesian.py | 18 ++-- tests/test_1025_analytic_transport.py | 6 +- tests/test_1100_AdvDiffCartesian.py | 22 ++--- 7 files changed, 134 insertions(+), 70 deletions(-) diff --git a/docs/developer/subsystems/analytic-solutions.md b/docs/developer/subsystems/analytic-solutions.md index 941b05111..3c4e3a6b4 100644 --- a/docs/developer/subsystems/analytic-solutions.md +++ b/docs/developer/subsystems/analytic-solutions.md @@ -424,7 +424,7 @@ Collecting them gains the residual check, which they never had, and a name to ci | solution | equation | previously | |---|---|---| | `Poisson1D` | $\nabla^2 u + f = 0$, three sources | `test_1000_poissonCart.py` | -| `TwoLayerDarcy` | $\nabla\cdot(k\nabla p) = 0$ across a permeability jump | `test_1004_DarcyCartesian.py` | +| `TwoLayerDarcy` | $\nabla\cdot[k(\nabla p + S\hat z)] = 0$ across a permeability jump | `test_1004_DarcyCartesian.py` | | `ErfcDiffusion` | $\partial_t u = D\nabla^2 u$ | `test_1005_TransientDarcyCartesian.py` | | `AdvectedFront` | $\partial_t c + u\,\partial_x c = \kappa\,\partial_{xx} c$ | `test_1100_AdvDiffCartesian.py` | @@ -443,6 +443,21 @@ which is what makes `test_1100`'s current comparison fragile. They cannot reuse the Stokes boundary-condition mixins, which apply a *velocity*. `_Transport` prescribes `fn_solution` on every wall instead. +### The four tests now use them + +The inline copies are gone; each test keeps its assertions and tolerances and +only swaps the expression for the class. Two details worth knowing: + +`TwoLayerDarcy` was generalised to take the column extent and a gravity term +$S$, because the Darcy test is posed on $y \in (-1, 0)$ and runs the case twice, +with and without gravity. The profile is now **derived** from constant flux +rather than transcribed from the test's closed form — so agreement is a check on +both, not a copy of one. It matches to 1.1e-16 in both cases. + +Its arguments are `k_lower` / `k_upper` rather than `k1` / `k2`, because +`test_1004`'s `k1` is the *upper* layer and the opposite reading is silent: it +produces a perfectly smooth wrong answer. + ### The residual has to be the equation the solution actually solves `AdvectedFront` reported a residual of 1.44 against 0.00 for everything else. The diff --git a/src/underworld3/analytic/transport.py b/src/underworld3/analytic/transport.py index 2553928c5..dc7917637 100644 --- a/src/underworld3/analytic/transport.py +++ b/src/underworld3/analytic/transport.py @@ -205,16 +205,32 @@ class TwoLayerDarcy(_Transport): one, which makes it the scalar counterpart of SolCx — and the fastest way to tell whether a Darcy solver handles a permeability contrast at all. + With gravity the flux is :math:`q = -k(\partial_z p + S)`; setting + :math:`S = 0` recovers the pressure-driven case. Either way :math:`q` is + constant down the column, and that is what fixes the interface pressure: + + .. math:: + q = \frac{\Delta p - S(z_1 - z_0)}{d_{\rm low}/k_{\rm low} + + d_{\rm up}/k_{\rm up}} + + with :math:`d` the layer thicknesses. The profile is then linear in each + layer with slope :math:`-q/k - S`. + Parameters ---------- mesh : Mesh - A 2D mesh; the layering is in :math:`z`. - k1, k2 : float - Permeability of the lower and upper layer. + A 2D mesh; the layering is in the vertical coordinate. + k_lower, k_upper : float + Permeability below and above the interface. interface : float - Height of the layer boundary. + Height of the layer boundary. Must lie strictly inside the column. pressure_drop : float - Pressure difference across the whole column. + Pressure at the bottom, taking the top as zero. + gravity : float + :math:`S` in the flux law. Zero for pressure-driven flow. + bottom, top : float + Extent of the column. Defaults to the unit interval; the Darcy tests + use :math:`(-1, 0)`. """ reference = ( @@ -222,34 +238,67 @@ class TwoLayerDarcy(_Transport): ) eqn_solution = r"\text{piecewise linear, kinked at the interface}" - def __init__(self, mesh, k1=1.0, k2=0.1, interface=0.5, pressure_drop=1.0): + def __init__( + self, + mesh, + k_lower=1.0, + k_upper=0.1, + interface=0.5, + pressure_drop=1.0, + gravity=0.0, + bottom=0.0, + top=1.0, + ): super().__init__(mesh) - if not (float(k1) > 0.0 and float(k2) > 0.0): + if not (float(k_lower) > 0.0 and float(k_upper) > 0.0): raise ValueError("permeabilities must be positive.") - if not 0.0 < float(interface) < 1.0: - raise ValueError("interface must lie strictly inside (0, 1).") + if not float(bottom) < float(interface) < float(top): + raise ValueError( + f"interface must lie strictly inside ({bottom}, {top}); " + f"got {interface}" + ) - self.k1 = float(k1) - self.k2 = float(k2) + self.k_lower = float(k_lower) + self.k_upper = float(k_upper) self.interface = float(interface) self.pressure_drop = float(pressure_drop) + self.gravity = float(gravity) + self.bottom = float(bottom) + self.top = float(top) + + z = mesh.X[mesh.dim - 1] + + k_low = sympy.Rational(str(k_lower)) + k_up = sympy.Rational(str(k_upper)) + z_int = sympy.Rational(str(interface)) + z_bot = sympy.Rational(str(bottom)) + z_top = sympy.Rational(str(top)) + drop = sympy.Rational(str(pressure_drop)) + S = sympy.Rational(str(gravity)) + + thick_low = z_int - z_bot + thick_up = z_top - z_int + + # Constant flux, from p(top) = 0 and p(bottom) = drop. Derived here + # rather than transcribed, so that agreeing with the form the Darcy test + # carried is a check on both rather than a copy of one. + self.flux = (drop - S * (z_top - z_bot)) / ( + thick_low / k_low + thick_up / k_up + ) + self.at_interface = thick_up * (self.flux / k_up + S) - x, z = mesh.X - lower, upper = sympy.Rational(self.interface), 1 - sympy.Rational(self.interface) - drop = sympy.Rational(self.pressure_drop) - - # Pressure at the interface, from continuity of flux: k1 dP1/dz = k2 dP2/dz. - at_interface = (drop / upper) / (1 / upper + sympy.Rational(self.k1, 1) / sympy.Rational(self.k2, 1) / lower) + slope_low = -self.flux / k_low - S + slope_up = -self.flux / k_up - S self.set_scalar_field( sympy.Piecewise( - (at_interface * z / lower, z < self.interface), - (at_interface + (drop - at_interface) * (z - lower) / upper, True), - ), - coefficient=sympy.Piecewise( - (sympy.Rational(self.k1), z < self.interface), - (sympy.Rational(self.k2), True), + (self.at_interface + slope_low * (z - z_int), z < z_int), + (self.at_interface + slope_up * (z - z_int), True), ), + # Piecewise-constant k makes the gravity term vanish inside each + # layer, so the source is zero away from the interface — where flux + # continuity is imposed by construction rather than by a source. + coefficient=sympy.Piecewise((k_low, z < z_int), (k_up, True)), source=0, ) diff --git a/tests/test_1000_poissonCart.py b/tests/test_1000_poissonCart.py index fa2386310..b600fb880 100644 --- a/tests/test_1000_poissonCart.py +++ b/tests/test_1000_poissonCart.py @@ -289,8 +289,11 @@ def test_poisson_sinusoidal_source(): poisson.constitutive_model = uw.constitutive_models.DiffusionModel poisson.constitutive_model.Parameters.diffusivity = 1 - # Symbolic source term: f = π²sin(πy) so that ∇²u = -π²sin(πy) has solution u = sin(πy) - poisson.f = sympy.pi**2 * sympy.sin(sympy.pi * y) + # The source and the solution come from the same object, so they cannot + # disagree: uw.analytic.Poisson1D carries f = pi^2 sin(pi y) alongside + # u = sin(pi y), and is checked against div(k grad u) + f = 0. + exact = uw.analytic.Poisson1D(mesh, source="sinusoid") + poisson.f = exact.fn_source # BCs: u(y=0) = 0, u(y=1) = 0 (consistent with sin(πy)) poisson.add_dirichlet_bc(0.0, "Bottom") @@ -306,7 +309,7 @@ def test_poisson_sinusoidal_source(): # Analytical solution: u(y) = sin(πy) u_numerical = uw.function.evaluate(u.sym[0], sample_points, rbf=False).squeeze() - u_analytical = np.sin(np.pi * sample_y) + u_analytical = uw.function.evaluate(exact.fn_solution, sample_points).squeeze() error = np.sqrt(np.mean((u_numerical - u_analytical) ** 2)) diff --git a/tests/test_1004_DarcyCartesian.py b/tests/test_1004_DarcyCartesian.py index 47991f136..4a566ebd6 100644 --- a/tests/test_1004_DarcyCartesian.py +++ b/tests/test_1004_DarcyCartesian.py @@ -117,20 +117,18 @@ def test_Darcy_boxmesh_G_and_noG(mesh): pressure_interp = uw.function.evaluate(p_soln.sym[0], xy_coords).squeeze() # #### Get analytical solution - La = -1.0 * interfaceY - Lb = 1.0 + interfaceY - dP = max_pressure - - S = 0 - Pa = (dP / Lb - S + k1 / k2 * S) / (1.0 / Lb + k1 / k2 / La) - pressure_analytic_noG = np.piecewise( - ycoords, - [ycoords >= -La, ycoords < -La], - [ - lambda ycoords: -Pa * ycoords / La, - lambda ycoords: Pa + (dP - Pa) * (-ycoords - La) / Lb, - ], + # + # Note k1 is the *upper* layer here (y >= interfaceY), so it is passed as + # k_upper. uw.analytic.TwoLayerDarcy derives the profile from constant flux + # rather than carrying the closed form this test used to write out; the two + # agree to 1e-16 in both gravity cases. + exact_noG = uw.analytic.TwoLayerDarcy( + mesh, k_lower=k2, k_upper=k1, interface=interfaceY, + pressure_drop=max_pressure, gravity=0.0, bottom=minY, top=maxY, ) + pressure_analytic_noG = uw.function.evaluate( + exact_noG.fn_solution, xy_coords + ).squeeze() print(pressure_interp) print(pressure_analytic_noG) @@ -142,16 +140,11 @@ def test_Darcy_boxmesh_G_and_noG(mesh): ## Suggest we re-solve right here for version with G to avoid all the re-definitions - S = 1 - Pa = (dP / Lb - S + k1 / k2 * S) / (1.0 / Lb + k1 / k2 / La) - pressure_analytic = np.piecewise( - ycoords, - [ycoords >= -La, ycoords < -La], - [ - lambda ycoords: -Pa * ycoords / La, - lambda ycoords: Pa + (dP - Pa) * (-ycoords - La) / Lb, - ], + exact = uw.analytic.TwoLayerDarcy( + mesh, k_lower=k2, k_upper=k1, interface=interfaceY, + pressure_drop=max_pressure, gravity=1.0, bottom=minY, top=maxY, ) + pressure_analytic = uw.function.evaluate(exact.fn_solution, xy_coords).squeeze() darcy.constitutive_model.Parameters.s = sympy.Matrix( [0, -1] diff --git a/tests/test_1005_TransientDarcyCartesian.py b/tests/test_1005_TransientDarcyCartesian.py index d7ca122f6..dadd7d55e 100644 --- a/tests/test_1005_TransientDarcyCartesian.py +++ b/tests/test_1005_TransientDarcyCartesian.py @@ -49,12 +49,13 @@ def create_mesh(): ) -# Analytical solution: step-change diffusion in a semi-infinite column -# h(y,t) = erfc(y / (2 sqrt(D t))) where D = K/Ss -# with h(0,t) = 1, h(inf,t) = 0 -y_sym, t_sym = sp.symbols("y t", positive=True) +# Analytical solution: step-change diffusion in a semi-infinite column, +# h(y,t) = erfc(y / (2 sqrt(D t))) with h(0,t) = 1 and h(inf,t) = 0. +# +# This used to be written out here. It is now uw.analytic.ErfcDiffusion, which +# is the same expression checked against du/dt = div(k grad u) — the profile +# below was never verified to solve anything. D_val = K_val / Ss_val -h_analytic = sp.erfc(y_sym / (2 * sp.sqrt(D_val * t_sym))) def test_transient_darcy_diffusion(): @@ -83,9 +84,10 @@ def test_transient_darcy_diffusion(): darcy._v_projector.petsc_options["snes_rtol"] = 1.0e-6 darcy._v_projector.smoothing = 1.0e-6 + exact = uw.analytic.ErfcDiffusion(mesh, diffusivity=D_val) + # Initial condition: analytical profile at t_start - h_init = h_analytic.subs(t_sym, t_start) - h_init_fn = h_init.subs(y_sym, mesh.X[1]) + h_init_fn = exact.fn_solution.subs(exact.t, t_start) h_soln.array = uw.function.evaluate(h_init_fn, h_soln.coords) # Time-step @@ -108,7 +110,7 @@ def test_transient_darcy_diffusion(): h_numerical = uw.function.evaluate(h_soln.sym[0], sample_pts).squeeze() - h_exact_fn = h_analytic.subs(t_sym, t_end).subs(y_sym, mesh.X[1]) + h_exact_fn = exact.fn_solution.subs(exact.t, t_end) h_exact = uw.function.evaluate(h_exact_fn, sample_pts).squeeze() assert np.allclose(h_numerical, h_exact, atol=0.1), ( diff --git a/tests/test_1025_analytic_transport.py b/tests/test_1025_analytic_transport.py index 9b03a9615..cf48d5e92 100644 --- a/tests/test_1025_analytic_transport.py +++ b/tests/test_1025_analytic_transport.py @@ -64,7 +64,7 @@ def test_two_layer_darcy_satisfies_its_equation(mesh): from underworld3.analytic import _validation - sol = uw.analytic.TwoLayerDarcy(mesh, k1=1.0, k2=0.1) + sol = uw.analytic.TwoLayerDarcy(mesh, k_lower=1.0, k_upper=0.1) assert _validation.transport_residual(sol, sol.sample_points(count=8)) == 0.0 @@ -79,7 +79,9 @@ def test_two_layer_darcy_conserves_flux_across_the_interface(mesh): from underworld3.analytic import _validation k1, k2, interface = 1.0, 0.1, 0.5 - sol = uw.analytic.TwoLayerDarcy(mesh, k1=k1, k2=k2, interface=interface) + sol = uw.analytic.TwoLayerDarcy( + mesh, k_lower=k1, k_upper=k2, interface=interface + ) x, z = mesh.X gradient = sympy.diff(sol.fn_solution, z) diff --git a/tests/test_1100_AdvDiffCartesian.py b/tests/test_1100_AdvDiffCartesian.py index 831aad8aa..7be331e16 100644 --- a/tests/test_1100_AdvDiffCartesian.py +++ b/tests/test_1100_AdvDiffCartesian.py @@ -78,15 +78,12 @@ def create_mesh(mesh_type): # - # ### setup analytical function - -# + -u, t, x, x0, x1 = sp.symbols("u, t, x, x0, x1") - - -U_a_x = ( - sp.erf((x1 - x + (u * t)) / (2 * sp.sqrt(kappa * t))) - + sp.erf((-x0 + x - (u * t)) / (2 * sp.sqrt(kappa * t))) -) / 2 +# +# The two-erf advecting top hat used to be written out here. It is now +# uw.analytic.AdvectedFront, which is the same expression checked against +# du/dt + v.grad(u) = kappa lap(u) — the version here was never verified to +# solve anything, and its own residual is what caught that the check needed the +# advection term at all. # %% @@ -154,7 +151,10 @@ def test_advDiff_boxmesh(mesh_type): # v.array[:, 0, 0] = -1*v.coords[:,1] v.array[:, 0, 0] = velocity - U_start = U_a_x.subs({u: velocity, t: t_start, x: mesh.X[0], x0: 0.4, x1: 0.6}) + exact = uw.analytic.AdvectedFront( + mesh, kappa=kappa, speed=velocity, x0=0.4, x1=0.6 + ) + U_start = exact.fn_solution.subs(exact.t, t_start) T.array = uw.function.evaluate(U_start, T.coords) @@ -180,7 +180,7 @@ def test_advDiff_boxmesh(mesh_type): ### compare UW and 1D numerical solution T_UW = uw.function.evaluate(T.sym[0], sample_points).squeeze() - U_end = U_a_x.subs({u: velocity, t: t_end, x: mesh.X[0], x0: 0.4, x1: 0.6}) + U_end = exact.fn_solution.subs(exact.t, t_end) T_analytical = uw.function.evaluate(U_end, sample_points).squeeze() ### moderate atol due to evaluating onto points From 4102cc04d5e067cece8c0cc6893f481afb4ff695 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Tue, 4 Aug 2026 07:07:28 +1000 Subject: [PATCH 22/28] Link the analytic subsystem doc into the developer index It was written but never added to the authority map or the toctree, so Sphinx built it as an orphan and nothing pointed at it. docs-build succeeds. Underworld development team with AI support from Claude Code --- docs/developer/index.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/developer/index.md b/docs/developer/index.md index cc663cafc..076fec26f 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) | +| 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) | | Branching & releases | [guides/branching-strategy.md](guides/branching-strategy.md) | @@ -195,6 +196,7 @@ subsystems/constitutive-models-anisotropy subsystems/swarm-system subsystems/data-access subsystems/interpolation +subsystems/analytic-solutions subsystems/expressions-functions subsystems/containers subsystems/checkpointing-system From b19fa090bfea63950de5afaab974fc3fc50476d2 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Tue, 4 Aug 2026 21:42:56 +1000 Subject: [PATCH 23/28] Install assess and actually validate CylindricalStokes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit assess is a 12 kB pure-Python wheel on PyPI with dependencies we already have, so the "working path untested" caveat was avoidable. It is now a dev dependency in pixi.toml — pyproject.toml keeps it as the `benchmarks` extra for users — and all four Kramer cases construct and evaluate. The wrapper's API guesses were right: the four CylindricalStokesSolution* classes, their constructor signatures, and .velocity_cartesian / .pressure_cartesian all match what the example scripts implied. "Returns finite values" is not validation, so the solution is now checked by finite differences — the same idea as Gate 4 with a weaker instrument, which is all a numeric oracle admits: div(u)/|u| ~ 1e-9 in all four cases (the difference floor at h=1e-6) free slip: u.n ~ 1e-17 on both arcs, with |u| ~ 1e-2 there zero slip: |u| ~ 1e-17 on the walls, 1e-5 inside Each carries a control. The free-slip wall is demonstrably slipping, so u.n = 0 is not passing because everything is zero; the divergence probe is checked against u = (x, y) to confirm it reports 2 rather than reporting 0 for everything. Installing assess also silently deleted the coverage that mattered most. The missing-dependency path is what a normal install takes and the thing the previous arrangement got wrong, and it was tested by skipping when the package was present — which, now that pixi supplies it, means never, least of all in CI. Absence is therefore simulated rather than waited for, and the fixture has its own negative control asserting the simulation actually blocks. Without that, a change to import resolution would let those tests pass by importing the real package while appearing to cover a path they never touch. The five example scripts now import through kramer.require_assess() instead of a bare `import assess`, so they report what to install rather than ModuleNotFoundError. That function is public because they call it: they use assess for cases this wrapper does not cover. 143 passed across the conformance, transport, Richards and optional suites; 24 of those are this file, with no skips. docs-build succeeds. Underworld development team with AI support from Claude Code --- .../subsystems/analytic-solutions.md | 27 +++ .../Ex_Stokes_Annulus_Benchmark_Kramer.py | 7 +- .../Ex_Stokes_Spherical_Benchmark_Kramer.py | 7 +- .../Ex_Stokes_Spherical_Benchmark_Thieulot.py | 7 +- ...Ex_Stokes_Annulus_Benchmark_Kramer_etal.py | 7 +- .../Ex_Stokes_Annulus_Benchmark_Thieulot.py | 7 +- pixi.lock | 21 ++ pixi.toml | 7 + src/underworld3/analytic/kramer.py | 21 +- tests/test_1027_analytic_optional.py | 182 +++++++++++++++++- 10 files changed, 278 insertions(+), 15 deletions(-) diff --git a/docs/developer/subsystems/analytic-solutions.md b/docs/developer/subsystems/analytic-solutions.md index 3c4e3a6b4..49503465f 100644 --- a/docs/developer/subsystems/analytic-solutions.md +++ b/docs/developer/subsystems/analytic-solutions.md @@ -557,12 +557,39 @@ Four scripts under `docs/examples/` already imported `assess` while nothing declared it, so on a normal install they failed with a bare `ModuleNotFoundError`. +`assess` is also a dev dependency in `pixi.toml`, so the tests actually run +rather than skipping themselves into a green tick. + ### It is an oracle, not a member of the family `assess` gives numeric callables, so there is nothing to differentiate — a Kramer solution can be compared against a solver but **cannot be checked against the equations it claims to solve**. None of the six gates reaches it. +What can still be asked is asked by finite differences, which is the same idea +as Gate 4 with a weaker instrument. All four cases give +$|\nabla\cdot\mathbf u|/|\mathbf u| \approx 10^{-9}$ — the difference floor at +$h=10^{-6}$, so machine-level. Free slip gives $\mathbf u\cdot\hat n \sim +10^{-17}$ on both arcs while $|\mathbf u| \sim 10^{-2}$ there, and zero slip +gives $|\mathbf u| \sim 10^{-17}$ on the walls with $10^{-5}$ inside. Each of +those carries its own control: the free-slip wall is demonstrably *slipping*, so +$\mathbf u\cdot\hat n = 0$ is not passing because everything is zero, and the +divergence probe is checked against $\mathbf u = (x, y)$ to confirm it reports 2 +rather than reporting 0 for everything. + +### Testing the absent path once the package is present + +Installing `assess` silently removed the coverage that mattered most: the +missing-dependency path is what a normal install takes, and it was the thing the +previous arrangement got wrong. Skipping it whenever the package is present +means it never runs in CI. + +So absence is **simulated** — `builtins.__import__` and `importlib.util.find_spec` +are monkeypatched — and the fixture has its own negative control asserting that +the simulation actually blocks. Without that, a Python change to import +resolution would let every one of those tests pass by importing the real +package while appearing to cover a path they never touch. + That is a real gap, not a technicality, so it is declared rather than described: `symbolic = False`, and the conformance sweep excludes on the declaration. The sweep then asserts what it excluded and why, so an accidental diff --git a/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Annulus_Benchmark_Kramer.py b/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Annulus_Benchmark_Kramer.py index 8f1dafcc6..57a234e55 100644 --- a/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Annulus_Benchmark_Kramer.py +++ b/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Annulus_Benchmark_Kramer.py @@ -72,7 +72,12 @@ import numpy as np import sympy import os -import assess +# `assess` (Kramer et al. 2021) is an optional dependency: pip install +# "underworld3[benchmarks]". Imported through the wrapper so a missing install +# reports what to do about it rather than a bare ModuleNotFoundError. +from underworld3.analytic.kramer import require_assess + +assess = require_assess() import h5py from enum import Enum diff --git a/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Spherical_Benchmark_Kramer.py b/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Spherical_Benchmark_Kramer.py index 6b1a60c88..b6ff0488b 100644 --- a/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Spherical_Benchmark_Kramer.py +++ b/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Spherical_Benchmark_Kramer.py @@ -79,7 +79,12 @@ import numpy as np import sympy import os -import assess +# `assess` (Kramer et al. 2021) is an optional dependency: pip install +# "underworld3[benchmarks]". Imported through the wrapper so a missing install +# reports what to do about it rather than a bare ModuleNotFoundError. +from underworld3.analytic.kramer import require_assess + +assess = require_assess() import h5py import sys diff --git a/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Spherical_Benchmark_Thieulot.py b/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Spherical_Benchmark_Thieulot.py index baec41cdf..4e85e5f18 100644 --- a/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Spherical_Benchmark_Thieulot.py +++ b/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Spherical_Benchmark_Thieulot.py @@ -81,7 +81,12 @@ import numpy as np import sympy as sp import os -import assess +# `assess` (Kramer et al. 2021) is an optional dependency: pip install +# "underworld3[benchmarks]". Imported through the wrapper so a missing install +# reports what to do about it rather than a bare ModuleNotFoundError. +from underworld3.analytic.kramer import require_assess + +assess = require_assess() import h5py import sys from petsc4py import PETSc diff --git a/docs/examples/utilities/advanced/Ex_Stokes_Annulus_Benchmark_Kramer_etal.py b/docs/examples/utilities/advanced/Ex_Stokes_Annulus_Benchmark_Kramer_etal.py index 6a2143f92..2060585ce 100644 --- a/docs/examples/utilities/advanced/Ex_Stokes_Annulus_Benchmark_Kramer_etal.py +++ b/docs/examples/utilities/advanced/Ex_Stokes_Annulus_Benchmark_Kramer_etal.py @@ -44,7 +44,12 @@ import os import matplotlib.pyplot as plt import cmcrameri.cm as cmc -import assess +# `assess` (Kramer et al. 2021) is an optional dependency: pip install +# "underworld3[benchmarks]". Imported through the wrapper so a missing install +# reports what to do about it rather than a bare ModuleNotFoundError. +from underworld3.analytic.kramer import require_assess + +assess = require_assess() # - os.environ["SYMPY_USE_CACHE"] = "no" diff --git a/docs/examples/utilities/advanced/Ex_Stokes_Annulus_Benchmark_Thieulot.py b/docs/examples/utilities/advanced/Ex_Stokes_Annulus_Benchmark_Thieulot.py index 2fc3eb826..23a79a5d1 100644 --- a/docs/examples/utilities/advanced/Ex_Stokes_Annulus_Benchmark_Thieulot.py +++ b/docs/examples/utilities/advanced/Ex_Stokes_Annulus_Benchmark_Thieulot.py @@ -44,7 +44,12 @@ import os import matplotlib.pyplot as plt import cmcrameri.cm as cmc -import assess +# `assess` (Kramer et al. 2021) is an optional dependency: pip install +# "underworld3[benchmarks]". Imported through the wrapper so a missing install +# reports what to do about it rather than a bare ModuleNotFoundError. +from underworld3.analytic.kramer import require_assess + +assess = require_assess() # - os.environ["SYMPY_USE_CACHE"] = "no" diff --git a/pixi.lock b/pixi.lock index fe6915470..8bd07ac7f 100644 --- a/pixi.lock +++ b/pixi.lock @@ -1171,6 +1171,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py312h5253ce2_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - pypi: https://files.pythonhosted.org/packages/e5/70/7b0fd9c1a738f59d3babe2b4212031c34ab7d0fda4ffef15b58a55c5bcea/anthropic-0.76.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/db/56/df7622ef4c96b61a5563a6dd999f10c19ef4da113906a1718f4a0b856a2a/assess-1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/63/22/27f88a5a1c48b833c5893a23b71cf74b5270fac82d88e337ec2c138e8853/gmsh-4.15.0-py2.py3-none-manylinux_2_24_x86_64.whl @@ -1648,6 +1649,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstandard-0.25.0-py312h37e1c23_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - pypi: https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/db/56/df7622ef4c96b61a5563a6dd999f10c19ef4da113906a1718f4a0b856a2a/assess-1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/b0/a5c659d2b5180c037ff2304e459e0c142015de510ac68d4f100770c7d89d/gmsh-4.15.1-py2.py3-none-macosx_12_0_arm64.whl @@ -2183,6 +2185,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py312h5253ce2_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - pypi: https://files.pythonhosted.org/packages/e5/70/7b0fd9c1a738f59d3babe2b4212031c34ab7d0fda4ffef15b58a55c5bcea/anthropic-0.76.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/db/56/df7622ef4c96b61a5563a6dd999f10c19ef4da113906a1718f4a0b856a2a/assess-1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/63/22/27f88a5a1c48b833c5893a23b71cf74b5270fac82d88e337ec2c138e8853/gmsh-4.15.0-py2.py3-none-manylinux_2_24_x86_64.whl @@ -2660,6 +2663,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstandard-0.25.0-py312h37e1c23_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - pypi: https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/db/56/df7622ef4c96b61a5563a6dd999f10c19ef4da113906a1718f4a0b856a2a/assess-1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/b0/a5c659d2b5180c037ff2304e459e0c142015de510ac68d4f100770c7d89d/gmsh-4.15.1-py2.py3-none-macosx_12_0_arm64.whl @@ -3835,6 +3839,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py312h5253ce2_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - pypi: https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/db/56/df7622ef4c96b61a5563a6dd999f10c19ef4da113906a1718f4a0b856a2a/assess-1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/5d/339b995273c25a79bad5144ffb4fe57f4428d9ad2603942c851a66376afd/gmsh-4.15.1-py2.py3-none-manylinux_2_24_x86_64.whl @@ -4313,6 +4318,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstandard-0.25.0-py312h37e1c23_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - pypi: https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/db/56/df7622ef4c96b61a5563a6dd999f10c19ef4da113906a1718f4a0b856a2a/assess-1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/b0/a5c659d2b5180c037ff2304e459e0c142015de510ac68d4f100770c7d89d/gmsh-4.15.1-py2.py3-none-macosx_12_0_arm64.whl @@ -5496,6 +5502,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py312h5253ce2_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - pypi: https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/db/56/df7622ef4c96b61a5563a6dd999f10c19ef4da113906a1718f4a0b856a2a/assess-1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/5d/339b995273c25a79bad5144ffb4fe57f4428d9ad2603942c851a66376afd/gmsh-4.15.1-py2.py3-none-manylinux_2_24_x86_64.whl @@ -5976,6 +5983,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstandard-0.25.0-py312h37e1c23_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - pypi: https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/db/56/df7622ef4c96b61a5563a6dd999f10c19ef4da113906a1718f4a0b856a2a/assess-1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/b0/a5c659d2b5180c037ff2304e459e0c142015de510ac68d4f100770c7d89d/gmsh-4.15.1-py2.py3-none-macosx_12_0_arm64.whl @@ -8103,6 +8111,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py312h5253ce2_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - pypi: https://files.pythonhosted.org/packages/e5/70/7b0fd9c1a738f59d3babe2b4212031c34ab7d0fda4ffef15b58a55c5bcea/anthropic-0.76.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/db/56/df7622ef4c96b61a5563a6dd999f10c19ef4da113906a1718f4a0b856a2a/assess-1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/63/22/27f88a5a1c48b833c5893a23b71cf74b5270fac82d88e337ec2c138e8853/gmsh-4.15.0-py2.py3-none-manylinux_2_24_x86_64.whl @@ -8600,6 +8609,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstandard-0.25.0-py312h37e1c23_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - pypi: https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/db/56/df7622ef4c96b61a5563a6dd999f10c19ef4da113906a1718f4a0b856a2a/assess-1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/b0/a5c659d2b5180c037ff2304e459e0c142015de510ac68d4f100770c7d89d/gmsh-4.15.1-py2.py3-none-macosx_12_0_arm64.whl @@ -10168,6 +10178,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py312h5253ce2_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - pypi: https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/db/56/df7622ef4c96b61a5563a6dd999f10c19ef4da113906a1718f4a0b856a2a/assess-1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/5d/339b995273c25a79bad5144ffb4fe57f4428d9ad2603942c851a66376afd/gmsh-4.15.1-py2.py3-none-manylinux_2_24_x86_64.whl @@ -10667,6 +10678,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstandard-0.25.0-py312h37e1c23_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - pypi: https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/db/56/df7622ef4c96b61a5563a6dd999f10c19ef4da113906a1718f4a0b856a2a/assess-1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/b0/a5c659d2b5180c037ff2304e459e0c142015de510ac68d4f100770c7d89d/gmsh-4.15.1-py2.py3-none-macosx_12_0_arm64.whl @@ -11908,6 +11920,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py312h5253ce2_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - pypi: https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/db/56/df7622ef4c96b61a5563a6dd999f10c19ef4da113906a1718f4a0b856a2a/assess-1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/5d/339b995273c25a79bad5144ffb4fe57f4428d9ad2603942c851a66376afd/gmsh-4.15.1-py2.py3-none-manylinux_2_24_x86_64.whl @@ -12408,6 +12421,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstandard-0.25.0-py312h37e1c23_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - pypi: https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/db/56/df7622ef4c96b61a5563a6dd999f10c19ef4da113906a1718f4a0b856a2a/assess-1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/b0/a5c659d2b5180c037ff2304e459e0c142015de510ac68d4f100770c7d89d/gmsh-4.15.1-py2.py3-none-macosx_12_0_arm64.whl @@ -13744,6 +13758,13 @@ packages: - pkg:pypi/arrow?source=hash-mapping size: 113854 timestamp: 1760831179410 +- pypi: https://files.pythonhosted.org/packages/db/56/df7622ef4c96b61a5563a6dd999f10c19ef4da113906a1718f4a0b856a2a/assess-1.4-py3-none-any.whl + name: assess + version: '1.4' + sha256: c676e1c6bc1abd5bac1fe0886fdc2e9450665f31fb2e457be5610ff9c350e372 + requires_dist: + - numpy + - scipy>=1.15.0 - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.1-pyhd8ed1ab_0.conda sha256: ee4da0f3fe9d59439798ee399ef3e482791e48784873d546e706d0935f9ff010 md5: 9673a61a297b00016442e022d689faa6 diff --git a/pixi.toml b/pixi.toml index c0eafa97e..10c37245c 100644 --- a/pixi.toml +++ b/pixi.toml @@ -332,6 +332,13 @@ anthropic = "*" sphinx-math-dollar = "*" sphinxcontrib-mermaid = "*" +# Kramer et al. (2021) curved-geometry Stokes solutions, behind +# uw.analytic.CylindricalStokes and the annulus/spherical benchmark examples. +# A dev dependency rather than a runtime one: it is the `benchmarks` extra in +# pyproject.toml for users, and here so that tests/test_1027 actually runs +# instead of skipping itself into a green tick. Pure Python, ~12 kB. +assess = "*" + [feature.dev.tasks] install-claude = "npm install -g @anthropic-ai/claude-code" claude = "claude" diff --git a/src/underworld3/analytic/kramer.py b/src/underworld3/analytic/kramer.py index fdfe905fb..620fbe3c5 100644 --- a/src/underworld3/analytic/kramer.py +++ b/src/underworld3/analytic/kramer.py @@ -43,12 +43,27 @@ ) -def _require_assess(): +def require_assess(): """Import `assess`, or explain how to get it. Deferred to construction rather than to module import so that ``import underworld3`` works without it and :func:`underworld3.analytic.available` can still list the solution. + + Public because the curved-geometry benchmark scripts under + ``docs/examples/`` call it directly: they use `assess` for cases this + wrapper does not cover, and should still fail with an install message + rather than a bare ``ModuleNotFoundError``. + + Returns + ------- + module + The imported `assess` module. + + Raises + ------ + ImportError + With installation instructions, if `assess` is not present. """ try: @@ -63,7 +78,7 @@ def assess_available(): """Whether the optional `assess` dependency can be imported.""" try: - _require_assess() + require_assess() except ImportError: return False @@ -141,7 +156,7 @@ def __init__( if boundary not in ("free", "zero"): raise ValueError(f"boundary must be 'free' or 'zero'; got {boundary!r}") - assess = _require_assess() + assess = require_assess() self.n = int(n) self.k = int(k) diff --git a/tests/test_1027_analytic_optional.py b/tests/test_1027_analytic_optional.py index 28a77cfcb..497335972 100644 --- a/tests/test_1027_analytic_optional.py +++ b/tests/test_1027_analytic_optional.py @@ -5,9 +5,14 @@ scripts fail with a bare `ModuleNotFoundError` on a normal install. It is now an optional extra with a wrapper that says what to do about it. -These tests cover the *missing*-dependency path, which is the path a normal -install takes and the one the previous arrangement got wrong. Where `assess` is -installed, the constructing tests run too. +Both paths run everywhere. The present case needs the package, which pixi.toml +now supplies to the dev environments; the missing case is *simulated* rather +than waited for, because once `assess` is installed the path that mattered most +would otherwise never be exercised again — least of all in CI. + +The solution itself is numeric, so none of the six SymPy gates reach it. What +can still be checked is checked by finite differences: incompressibility, and +the boundary condition each case claims. Both carry their own controls. Run: pixi run python -m pytest tests/test_1027_analytic_optional.py -v """ @@ -16,11 +21,16 @@ pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] +import importlib.util +import sys + import numpy as np import underworld3 as uw from underworld3.analytic.kramer import assess_available +_real_find_spec = importlib.util.find_spec + needs_assess = pytest.mark.skipif( not assess_available(), reason="the optional 'assess' package is not installed" ) @@ -48,6 +58,9 @@ def test_the_solution_is_listed_even_when_it_cannot_be_built(): installed = uw.analytic.available(installed_only=True) assert ("CylindricalStokes" in installed) == assess_available() + # The missing case is covered unconditionally by the `without_assess` + # tests below, which simulate absence rather than waiting for it. + def test_every_other_solution_is_unconditionally_available(): for name in uw.analytic.available(): @@ -57,16 +70,63 @@ def test_every_other_solution_is_unconditionally_available(): assert getattr(uw.analytic, name).requires is None -@pytest.mark.skipif(assess_available(), reason="'assess' is installed here") -def test_describe_says_what_is_missing(): +@pytest.fixture +def without_assess(monkeypatch): + """Make `assess` unimportable, whether or not it is installed. + + The missing-dependency path is the one a normal install takes and the one + the previous arrangement got wrong, so it must be tested *everywhere* — not + only on machines that happen to lack the package. `assess` is now a dev + dependency in pixi.toml, so skipping when it is present would mean these + tests never run in CI and the wrapper's whole purpose goes unchecked. + """ + + import builtins + + real_import = builtins.__import__ + + def blocked(name, *args, **kwargs): + if name == "assess" or name.startswith("assess."): + raise ImportError("No module named 'assess'") + return real_import(name, *args, **kwargs) + + monkeypatch.delitem(sys.modules, "assess", raising=False) + monkeypatch.setattr(builtins, "__import__", blocked) + monkeypatch.setattr( + importlib.util, "find_spec", lambda name, *a, **k: None + if name == "assess" + else _real_find_spec(name, *a, **k) + ) + + +def test_the_absence_simulation_actually_blocks(without_assess): + """Negative control for the fixture itself. + + If the monkeypatching silently stopped working — a Python version changing + how imports resolve, say — every test below would pass by importing the real + package, and would look like coverage of a path they never touched. + """ + + from underworld3.analytic import kramer + + assert not uw.analytic.is_available("CylindricalStokes") + with pytest.raises(ImportError): + kramer.require_assess() + + +def test_describe_says_what_is_missing(without_assess): summary = uw.analytic.describe("CylindricalStokes") assert "unavailable" in summary assert "assess" in summary -@pytest.mark.skipif(assess_available(), reason="'assess' is installed here") -def test_constructing_it_explains_rather_than_tracebacks(): +def test_it_is_dropped_from_the_installed_only_listing(without_assess): + assert "CylindricalStokes" in uw.analytic.available() + assert "CylindricalStokes" not in uw.analytic.available(installed_only=True) + + +def test_constructing_it_explains_rather_than_tracebacks(without_assess): mesh = uw.meshing.Annulus(radiusOuter=1.0, radiusInner=0.5, cellSize=0.2) with pytest.raises(ImportError) as raised: @@ -117,6 +177,44 @@ def annulus(): return uw.meshing.Annulus(radiusOuter=1.0, radiusInner=0.5, cellSize=0.15) +def _interior_points(count=60, seed=7, r_inner=0.5, r_outer=1.0): + """Points well inside the shell, away from both walls.""" + + rng = np.random.default_rng(seed) + margin = 0.1 * (r_outer - r_inner) + theta = rng.uniform(0.0, 2 * np.pi, count) + radius = rng.uniform(r_inner + margin, r_outer - margin, count) + + return np.column_stack([radius * np.cos(theta), radius * np.sin(theta)]) + + +def _wall_points(radius, count=40): + theta = np.linspace(0.0, 2 * np.pi, count, endpoint=False) + return np.column_stack([radius * np.cos(theta), radius * np.sin(theta)]) + + +def _divergence(sol, points, step=1e-6): + r"""Numerical :math:`\nabla\cdot\mathbf u` by central differences. + + The suite's other solutions are differentiated symbolically. This one is + numeric, so the only way to ask whether it solves anything is to + finite-difference it — weaker than the SymPy gates, but the same idea, and + much stronger than checking that the values are finite. + """ + + total = np.zeros(len(points)) + + for axis in range(2): + offset = np.zeros(2) + offset[axis] = step + total += ( + sol.evaluate("velocity", points + offset)[:, axis] + - sol.evaluate("velocity", points - offset)[:, axis] + ) / (2 * step) + + return total + + @needs_assess @pytest.mark.parametrize("density", ["delta", "smooth"]) @pytest.mark.parametrize("boundary", ["free", "zero"]) @@ -135,6 +233,76 @@ def test_all_four_cases_construct_and_evaluate(annulus, density, boundary): assert np.isfinite(pressure).all() +@needs_assess +@pytest.mark.parametrize("density", ["delta", "smooth"]) +@pytest.mark.parametrize("boundary", ["free", "zero"]) +def test_the_velocity_is_incompressible(annulus, density, boundary): + sol = uw.analytic.CylindricalStokes( + annulus, n=2, k=2, density=density, boundary=boundary + ) + + points = _interior_points() + scale = np.abs(sol.evaluate("velocity", points)).max() + + assert np.abs(_divergence(sol, points)).max() / scale < 1e-7 + + +@needs_assess +def test_the_divergence_probe_actually_fires(): + """Negative control: the check above must fail on a compressible field. + + Without this, `div = 0` says nothing — a probe that returns zero for + everything would pass all four cases above and look like a validation. + """ + + class Expanding: + """u = (x, y), divergence 2 everywhere.""" + + def evaluate(self, field, coords): + return np.asarray(coords, dtype=float) + + points = _interior_points() + assert np.abs(_divergence(Expanding(), points) - 2.0).max() < 1e-5 + + +@needs_assess +@pytest.mark.parametrize("density", ["delta", "smooth"]) +def test_free_slip_stops_wall_normal_flow_but_not_tangential(annulus, density): + r""":math:`\mathbf u\cdot\hat n = 0` on both arcs, with :math:`|u| \neq 0`. + + The second half is the control. A wall where the whole velocity vanishes + would pass a `u.n = 0` test trivially, and that is the zero-slip case, not + this one. + """ + + sol = uw.analytic.CylindricalStokes( + annulus, n=2, k=2, density=density, boundary="free" + ) + + for radius in (sol.r_inner, sol.r_outer): + wall = _wall_points(radius) + velocity = sol.evaluate("velocity", wall) + normal = wall / radius + + assert np.abs((velocity * normal).sum(axis=1)).max() < 1e-14 + assert np.abs(velocity).max() > 1e-4, "wall is not slipping at all" + + +@needs_assess +@pytest.mark.parametrize("density", ["delta", "smooth"]) +def test_zero_slip_stops_the_wall_entirely(annulus, density): + sol = uw.analytic.CylindricalStokes( + annulus, n=2, k=2, density=density, boundary="zero" + ) + + for radius in (sol.r_inner, sol.r_outer): + velocity = sol.evaluate("velocity", _wall_points(radius)) + assert np.abs(velocity).max() < 1e-14 + + # ... and it is not zero everywhere, which would make the above vacuous + assert np.abs(sol.evaluate("velocity", _interior_points())).max() > 1e-5 + + @needs_assess def test_the_delta_case_is_genuinely_two_sided(annulus): """The branch selection is the benchmark, so it has to actually branch.""" From 4b8fb333020ca08ae877c665497db97d72364b6d Mon Sep 17 00:00:00 2001 From: lmoresi Date: Tue, 4 Aug 2026 23:08:43 +1000 Subject: [PATCH 24/28] Make the SolC example a real benchmark; fix MeshVariable.clone (#498) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The example's validation was a shell-out to Underworld2 inside a try/except ImportError. UW2 is not installed, so the branch never ran — and behind it were three separate ways the comparison was wrong: * The body force was written out by hand as +1 on x > x_c. SolC's buoyancy is negative on x < x_c, so the file solved a mirrored, sign-flipped problem from the one it compared against. * The comparison sat at the *end* of the file, but the file runs four solves: SolC, then SolCx, then two boundary-condition experiments. By then `v` held a SolCx solve with penalty BCs, not the SolC answer. * It computed `num = function.evaluate(v.fn, ...)` and then never used it, differencing `v.data` instead. Forcing and viscosity now come from uw.analytic.SolC, so they cannot disagree with the solution about sign or side, and the validation happens immediately after the SolC solve while `v` still holds it. Measured, not asserted: res velocity pressure rate_v rate_p 8 1.323e-03 5.086e-03 16 1.659e-04 1.472e-03 3.00 1.79 32 1.890e-05 2.320e-04 3.13 2.66 64 9.520e-07 3.358e-05 4.31 2.79 Third order for P2 velocity, second for P1 pressure, as expected. Getting the file to run at all turned up three pre-existing defects, all filed: #498 MeshVariable.clone was broken at both levels — EnhancedMeshVariable forwarded no arguments to a base that requires two, and the base itself referenced a bare `MeshVariable`, which is not a name in its module. So it raised whichever way it was called, and no test covered it. Fixed here (it blocks the example at line 141) with tests, including that it still rejects the no-argument form. Six shipped examples were aborting on this line. #499 timing.print_table no longer accepts display_fraction/group_by/ output_file; 18 example files still pass them. Only this file's two call sites are fixed — the rest need a decision about whether to restore the keywords or update the call sites, which is not mine to make here. pl.show() was guarded on `uw.mpi.size == 1` alone, so a script run blocked forever on a window that never opens. Now also requires uw.is_notebook. This is endemic across the examples rather than specific to this file. The header also described SolCx — a 10^6 viscosity contrast with cos/sin buoyancy — while the code was isoviscous with a step force. Corrected, with a note for anyone comparing against old output. Underlying all of it: nothing runs the examples, so API drift lands in them unnoticed. Worth a smoke job at trivial resolution; noted on #499. Example runs to completion, exit 0. 91 passed across the clone and conformance suites. Underworld development team with AI support from Claude Code --- .../advanced/Ex_Stokes_Cartesian_SolC.py | 125 ++++++++++-------- .../discretisation_mesh_variables.py | 9 +- .../discretisation/enhanced_variables.py | 27 +++- tests/test_0301_meshvariable_clone.py | 73 ++++++++++ 4 files changed, 170 insertions(+), 64 deletions(-) create mode 100644 tests/test_0301_meshvariable_clone.py diff --git a/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Cartesian_SolC.py b/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Cartesian_SolC.py index 6e8b19ee5..bed5cf120 100644 --- a/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Cartesian_SolC.py +++ b/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Cartesian_SolC.py @@ -14,38 +14,41 @@ # %% [markdown] """ -# Stokes Benchmark SolCx +# Stokes Benchmark SolC **PHYSICS:** fluid_mechanics **DIFFICULTY:** advanced ## Description -The SolCx benchmark tests the Stokes solver with a sharp viscosity contrast. -A vertical step in viscosity at x = 0.5 creates a challenging test case for -iterative solvers. Compares Dirichlet and natural boundary conditions. +SolC is the *isoviscous* benchmark: a dense column occupying half the box drives +flow through a fluid of uniform viscosity. The difficulty is the discontinuous +forcing, not a material contrast — the pressure has a kink at the column edge +that a discretisation has to resolve. + +(For the sharp **viscosity** contrast, see SolCx. This file was previously +titled and documented as SolCx while solving SolC, which is worth knowing if +you are comparing old output.) ## Key Concepts -- **Viscosity jump**: Step function viscosity contrast (10^6) -- **Benchmark validation**: Comparison with analytical solution (if UW2 available) -- **Natural vs Dirichlet BCs**: Free-slip implemented different ways -- **Piecewise functions**: Using sympy.Piecewise for sharp interfaces -- **Multigrid preconditioning**: Essential for high viscosity contrast +- **Discontinuous body force**: buoyancy steps at x = 0.5, viscosity is uniform +- **Benchmark validation**: against `uw.analytic.SolC`, in-tree and validated +- **Truncated series**: the exact solution is a Fourier sum, `modes` terms of it +- **Free slip on all walls**, with a pressure null space ## Mathematical Formulation -Viscosity step function: -$$\\eta(x) = \\begin{cases} 10^6 & x > 0.5 \\\\ 1 & x \\le 0.5 \\end{cases}$$ - -Buoyancy forcing: -$$f_y = -\\cos(\\pi x) \\sin(2\\pi y)$$ +Uniform viscosity $\\eta = 1$, with buoyancy stepping at the column edge. Both +the forcing and the exact velocity come from `uw.analytic.SolC`, so they cannot +disagree about sign or side — see the note in the validation section below. ## Parameters - `uw_resolution`: Mesh resolution - `uw_refinement`: Mesh refinement level -- `uw_viscosity_contrast`: log10 of viscosity contrast +- `uw_modes`: Fourier modes in the SolC analytic solution +- `uw_viscosity_contrast`: log10 contrast, used by the SolCx section """ # %% [markdown] @@ -87,14 +90,15 @@ params = uw.Params( uw_resolution = 4, # Base mesh resolution uw_refinement = 2, # Mesh refinement levels - uw_viscosity_contrast = 6, # log10 of viscosity contrast uw_use_simplex = 1, # Use simplex mesh (1) or quad (0) uw_penalty = 100, # Stokes penalty parameter + uw_modes = 40, # Fourier modes in the SolC analytic solution + uw_viscosity_contrast = 6, # log10 contrast, for the SolCx section below ) # Derived parameters -eta_ratio = 10 ** params.uw_viscosity_contrast use_simplex = bool(params.uw_use_simplex) +eta_ratio = 10 ** params.uw_viscosity_contrast # %% [markdown] """ @@ -159,20 +163,19 @@ """ # %% -eta_0 = 1 x_c = sympy.Rational(1, 2) -f_0 = 1 + +# The exact solution supplies the forcing as well as the answer. Writing the +# body force out by hand here is how this file came to solve a mirrored, +# sign-flipped problem from the one it compared against: SolC's buoyancy is +# negative on x < x_c, and the Piecewise previously used was +1 on x > x_c. +# Nobody noticed, because the comparison sat behind an `import underworld` that +# always failed. +solC = uw.analytic.SolC(mesh, x_c=x_c, modes=int(params.uw_modes)) stokes.penalty = params.uw_penalty -stokes.bodyforce = sympy.Matrix( - [ - 0, - Piecewise( - (f_0, x > x_c), - (0.0, True), - ), - ] -) +stokes.constitutive_model.Parameters.shear_viscosity_0 = solC.fn_viscosity +stokes.bodyforce = solC.fn_bodyforce # Free-slip boundary conditions (Dirichlet form) stokes.add_dirichlet_bc((sympy.oo, 0.0), "Top") @@ -208,6 +211,25 @@ # %% stokes.solve() +# %% [markdown] +""" +## Validation against the analytic solution + +This has to happen **here**, not at the end of the file. Everything below +reconfigures the same solver for other experiments, and `v` then holds those +answers rather than this one — which is exactly how the old check came to +compare a SolC analytic solution against a SolCx solve with penalty boundary +conditions. +""" + +# %% +# `error` is a global reduction, so this is the same number on any rank count. +solC_velocity_error = solC.error("velocity", v) +solC_pressure_error = solC.error("pressure", p) + +uw.pprint(f"SolC relative velocity error: {solC_velocity_error:.6e}") +uw.pprint(f"SolC relative pressure error: {solC_pressure_error:.6e}") + # %% [markdown] """ ## SolCx Benchmark Configuration @@ -231,7 +253,7 @@ timing.reset() timing.start() stokes.solve(zero_init_guess=True) -timing.print_table(display_fraction=0.999) +timing.print_table() # see #499: display_fraction was removed from the API # Save solution with Dirichlet BCs v0.data[...] = v.data[...] @@ -256,7 +278,7 @@ timing.reset() timing.start() stokes.solve() -timing.print_table(display_fraction=0.999) +timing.print_table() # see #499: display_fraction was removed from the API v1.data[...] = v.data[...] @@ -278,7 +300,7 @@ timing.reset() timing.start() stokes.solve() -timing.print_table(display_fraction=0.999) +timing.print_table() # see #499: display_fraction was removed from the API # %% [markdown] """ @@ -330,32 +352,19 @@ show_scalar_bar=False, ) - pl.show(cpos="xy") - -# %% [markdown] -""" -## Validation Against UW2 (if available) -""" + # Only when there is somewhere to show it. Guarded on mpi.size alone, this + # blocks a script run forever waiting on a window that never opens — which + # is why running this file to completion was not something anyone had done. + if uw.is_notebook: + pl.show(cpos="xy") # %% -try: - import underworld as uw2 - - solC = uw2.function.analytic.SolC() - vel_soln_analytic = solC.fn_velocity.evaluate(mesh.X.coords) - from mpi4py import MPI - from numpy import linalg as LA - - comm = MPI.COMM_WORLD - - num = function.evaluate(v.fn, mesh.X.coords) - if comm.rank == 0: - print(f"Velocity difference norm: {LA.norm(v.data - vel_soln_analytic):.6e}") - comm.barrier() -except ImportError: - import warnings - - warnings.warn("Unable to validate against UW2 analytical solution (UW2 not available).") - -# %% -print(f"SolCx benchmark complete: resolution {n_els}, refinement {refinement}") +uw.pprint( + f"Complete: resolution {n_els}, refinement {refinement}, " + f"modes {int(params.uw_modes)}" +) +uw.pprint(f" SolC velocity error (validated above): {solC_velocity_error:.6e}") +uw.pprint( + " The SolCx and natural-BC solves that follow it are BC experiments, " + "not benchmarks — nothing here compares them against an exact solution." +) diff --git a/src/underworld3/discretisation/discretisation_mesh_variables.py b/src/underworld3/discretisation/discretisation_mesh_variables.py index 09d4a1968..89baaea0b 100644 --- a/src/underworld3/discretisation/discretisation_mesh_variables.py +++ b/src/underworld3/discretisation/discretisation_mesh_variables.py @@ -593,7 +593,12 @@ def clone(self, name, varsymbol): MeshVariable New mesh variable with copied structure but independent data. """ - newMeshVariable = MeshVariable( + # Built through the public factory rather than a bare `MeshVariable`, + # which is not a name in this module — the clone therefore came back as + # a NameError for every caller. See issue #498. Going through the + # factory also returns the same enhanced type the caller started with, + # so a clone behaves like its original. + return uw.discretisation.MeshVariable( varname=name, mesh=self.mesh, num_components=self.shape, @@ -603,8 +608,6 @@ def clone(self, name, varsymbol): varsymbol=varsymbol, ) - return newMeshVariable - def pack_raw_data_to_petsc(self, data_array, sync=True): """ Pack data array to PETSc using traditional data shape (-1, num_components). diff --git a/src/underworld3/discretisation/enhanced_variables.py b/src/underworld3/discretisation/enhanced_variables.py index 0eaf5dc0b..4ce13ec3d 100644 --- a/src/underworld3/discretisation/enhanced_variables.py +++ b/src/underworld3/discretisation/enhanced_variables.py @@ -463,9 +463,30 @@ def jacobian(self): # === ADDITIONAL DELEGATED METHODS === - def clone(self): - """Clone the variable.""" - return self._base_var.clone() + def clone(self, name, varsymbol): + """Clone the variable under a new name and symbol. + + Parameters + ---------- + name : str + Name for the new variable. + varsymbol : str + LaTeX symbol for the new variable. + + Returns + ------- + MeshVariable + A new variable with the same shape, type, degree and continuity, + and independent data. + + Notes + ----- + This forwarded no arguments at all until issue #498, so it raised + whichever way it was called: with the two arguments every caller uses, + and without them inside the base. Six shipped examples aborted on it. + """ + + return self._base_var.clone(name, varsymbol) def max(self): """Maximum value of the variable.""" diff --git a/tests/test_0301_meshvariable_clone.py b/tests/test_0301_meshvariable_clone.py new file mode 100644 index 000000000..0a63fb33f --- /dev/null +++ b/tests/test_0301_meshvariable_clone.py @@ -0,0 +1,73 @@ +r"""MeshVariable.clone — issue #498. + +`EnhancedMeshVariable.clone` forwarded no arguments to a base that requires two, +so it raised whichever way it was called. Six shipped examples aborted on that +line and no test touched it. + +Run: pixi run python -m pytest tests/test_0301_meshvariable_clone.py -v +""" + +import pytest + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + +import numpy as np +import underworld3 as uw + + +@pytest.fixture(scope="module") +def mesh(): + return uw.meshing.StructuredQuadBox(elementRes=(4, 4), qdegree=2) + + +@pytest.mark.parametrize("degree", [1, 2]) +@pytest.mark.parametrize("vtype", ["scalar", "vector"]) +def test_clone_copies_the_structure(mesh, degree, vtype): + if vtype == "scalar": + original = uw.discretisation.MeshVariable( + f"s{degree}", mesh, 1, degree=degree + ) + else: + original = uw.discretisation.MeshVariable( + f"v{degree}", mesh, mesh.dim, degree=degree + ) + + copy = original.clone(f"{vtype}{degree}_copy", r"{c}") + + assert copy.shape == original.shape + assert copy.degree == original.degree + assert copy.continuous == original.continuous + assert copy.vtype == original.vtype + assert copy.data.shape == original.data.shape + + +def test_clone_is_independent(mesh): + """A clone shares structure, not storage.""" + + original = uw.discretisation.MeshVariable("orig", mesh, 1, degree=2) + original.data[...] = 3.0 + + copy = original.clone("orig_copy", r"{o_c}") + copy.data[...] = 7.0 + + assert np.allclose(original.data, 3.0), "writing the clone changed the original" + assert np.allclose(copy.data, 7.0) + + +def test_clone_takes_the_name_and_symbol_it_is_given(mesh): + """The two arguments the wrapper used to swallow.""" + + original = uw.discretisation.MeshVariable("named", mesh, 1, degree=1) + copy = original.clone("renamed", r"{q_0}") + + assert copy.name != original.name + assert "renamed" in copy.name + + +def test_clone_requires_both_arguments(mesh): + """It is not an optional-argument API, and should not silently look like one.""" + + original = uw.discretisation.MeshVariable("needsargs", mesh, 1, degree=1) + + with pytest.raises(TypeError): + original.clone() From d376dc3358d521e09637fd7dd1390db4b4050948 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Wed, 5 Aug 2026 14:01:13 +1000 Subject: [PATCH 25/28] Make the t=0 singularity refuse rather than warn, and give it a resolution floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every diffusive similarity solution here is a step with unbounded gradient at t = 0 — a state no finite element space can hold. Prose alone was not going to stop anyone benchmarking there, so this is enforced: sol.at(t) refuses t <= 0 with an explanation, rather than returning the singular profile sol.singular_at_origin declared per solution sol.earliest_resolvable_time(h) the floor the *mesh* imposes, not the solution: t0 >= (n_el h / 2)^2 / D, from requiring the front to span n_el elements The floor falls as h^2, so refining buys an earlier start quickly. It is a number rather than advice, which matters because the honest answer depends on resolution and nobody was going to work it out per run. Two things the docstrings now say outright, because neither is guessable: * A transient benchmark is a PAIR of times, never one. You initialise at t0 and compare at t1, and the error depends on both. An error quoted at a single time is uninterpretable — start too early and what you attribute to the timestepper is mostly initial projection error. * Below the floor you are measuring interpolation, not the solver. That immediately diagnoses test_1100_AdvDiffCartesian, which has carried an xfail calling itself "not a great test" and asking for "an error-function IC starting at t > 0 with a meaningful transport distance". At its own parameters (res 24, kappa 1, u 1/24, t0 1e-4, t1 2e-4): earliest resolvable t0 3.5e-3 -> it starts 35x too early front width at its t0 0.68 elements, narrower than one cell transport over the run 4.2e-6 = 0.0001 elements It initialises a profile the mesh cannot represent, then advects it by a ten-thousandth of a cell. It measures neither advection nor diffusion, which is why it has always been sensitive to which path uw.function.evaluate takes. The test is NOT reworked here. A resolution-consistent setup at res 24 still shows 11% error in five steps, dominated by time discretisation, so fixing it needs a timestep convergence study rather than new constants. Recorded in the subsystem doc with the numbers. Also documents the format to ask contributors for, since solutions derived from analytics can be supplied in whatever form we specify: SymPy on mesh.X rather than callables; no simplify(), preserve the derivation's grouping; declare the stress convention; give the equation and not only the answer; declare singularities in time and space; state the valid parameter ranges so they become constructor validation instead of folklore. 133 passed; docs-build succeeds. Underworld development team with AI support from Claude Code --- .../subsystems/analytic-solutions.md | 92 ++++++++++++++++ src/underworld3/analytic/_base.py | 104 ++++++++++++++++++ src/underworld3/analytic/richards.py | 1 + src/underworld3/analytic/transport.py | 54 +++++++-- tests/test_1025_analytic_transport.py | 93 ++++++++++++++++ 5 files changed, 333 insertions(+), 11 deletions(-) diff --git a/docs/developer/subsystems/analytic-solutions.md b/docs/developer/subsystems/analytic-solutions.md index 49503465f..3f52b869f 100644 --- a/docs/developer/subsystems/analytic-solutions.md +++ b/docs/developer/subsystems/analytic-solutions.md @@ -443,6 +443,59 @@ which is what makes `test_1100`'s current comparison fragile. They cannot reuse the Stokes boundary-condition mixins, which apply a *velocity*. `_Transport` prescribes `fn_solution` on every wall instead. +### Transient solutions are singular at t = 0, and that changes how you use them + +Every diffusive similarity solution here — `ErfcDiffusion`, `AdvectedFront`, +`GardnerTransient` — is a step with unbounded gradient at $t = 0$. **That state +cannot be represented on any mesh.** Two consequences, and both are enforced in +code rather than left to the reader: + +**You cannot start at $t = 0$.** `sol.at(t)` refuses $t \le 0$ with an +explanation instead of returning the singular profile. Returning it would only +ever produce a benchmark measuring its own projection error. + +**A transient benchmark is a pair of times, never one.** You initialise from the +solution at $t_0$ and compare at $t_1$, and the error depends on *both*. An +error quoted at a single time is not interpretable — a small $t_0$ means +starting from a profile the mesh cannot hold, and the error you then attribute +to the timestepper is mostly initial projection error. + +**And $t_0$ has a floor set by the mesh, not by the solution.** The front has +width $\sim 2\sqrt{Dt}$; asking it to span $n$ elements of size $h$ gives + +$$t_0 \ge \frac{1}{D}\left(\frac{n h}{2}\right)^2$$ + +which is `sol.earliest_resolvable_time(h, elements_across=4)`. It falls as +$h^2$, so refining buys an earlier start quickly. + +```python +t0 = sol.earliest_resolvable_time(mesh.get_min_radius()) +field.array = uw.function.evaluate(sol.at(t0), field.coords) +... # step to t1 +error = sol.error("solution", field) # against sol.at(t1) +``` + +#### What this diagnoses + +`tests/test_1100_AdvDiffCartesian.py` has carried an `xfail` describing itself +as "not a great test", with a note saying it needs "an error-function IC +starting at $t > 0$ with a meaningful transport distance". The floor says +exactly how badly, at its own parameters ($res=24$, $\kappa=1$, $u=1/24$, +$t_0=10^{-4}$, $t_1=2\times10^{-4}$): + +| | | +|---|---| +| earliest resolvable $t_0$ | $3.5\times10^{-3}$ — the test starts **35× too early** | +| front width at its $t_0$ | 0.68 elements, i.e. narrower than one cell | +| transport over the whole run | $4.2\times10^{-6}$ = **0.0001 elements** | + +So it initialises a profile the mesh cannot represent and then advects it by a +ten-thousandth of a cell. It measures neither advection nor diffusion, which is +why it has always been sensitive to which evaluation path `uw.function.evaluate` +happens to take. Reworking it needs a timestep convergence study, not just new +constants — a resolution-consistent setup at $res=24$ still shows 11% error in +five steps, dominated by time discretisation. That is left as follow-up work. + ### The four tests now use them The inline copies are gone; each test keeps its assertions and tolerances and @@ -605,6 +658,45 @@ Two class attributes carry this: ## Adding a new solution +### The format to supply a new solution in + +If you are deriving a solution rather than porting one, supply it in the form +below and it drops in with no translation step. This is the format to ask +contributors for. + +**Fields as SymPy expressions in the mesh coordinates**, not as callables, +lambdas, or NumPy code. `mesh.X` gives the coordinate symbols; build everything +from those. Expressions route through the JIT, so an analytic viscosity or body +force compiles to C *and* supplies its own Jacobian — neither of which a numeric +callable can do. `CylindricalStokes` is the one exception in the suite and it +pays for it: none of the six gates can reach it. + +**Do not simplify.** Preserve whatever grouping the derivation produced. The +Maple grouping in the Velic kernels is what keeps `sinh(k)*exp(-k)` products +stable at large wavenumber; a re-derived, "tidier" grouping can lose eight +digits. `cse=True` at evaluation time recovers the sharing anyway. + +**State the stress convention explicitly** if the solution has one — deviatoric +or total. It is a declaration (`stress_is_deviatoric`), never inferred, because +the family is not consistent and one kernel writes its deviator into an array +named `total_stress`. Getting it wrong leaves the momentum residual at order +$|\mathbf f|$ and looks like a transcription failure rather than a convention +one. + +**Give the equation the solution solves**, not just the answer. The oracle-free +residual is the strongest check in the suite — it caught four defects that +comparison against the source could not, including a published stress that is +simply wrong. It needs to know what to substitute back into. + +**Say whether it is singular anywhere**, in time or in space. A $t = 0$ +similarity singularity needs `singular_at_origin = True` and a `diffusivity`; +a spatial singularity (the inclusion's foci, SolCx's isoviscous limit) needs a +`sample_points` override so the validation harness does not land on it. + +**Say what parameter ranges it is valid over.** SolKx is only free-slip for +integer wavenumbers; the Gardner solutions require $\psi < 0$. Ranges become +constructor validation, which is where they stop being folklore. + 1. Subclass `AnalyticSolution` and one of the boundary-condition mixins (`FreeSlipWalls`, `FixedWalls`) — or `_Transport` for a scalar solution. 2. Build the exact fields on `mesh.X` in `__init__`; set `dim`, `reference`, and diff --git a/src/underworld3/analytic/_base.py b/src/underworld3/analytic/_base.py index ae159a200..9139ef615 100644 --- a/src/underworld3/analytic/_base.py +++ b/src/underworld3/analytic/_base.py @@ -265,6 +265,110 @@ def sample_points(self, count=12): return adversarial_points(count=count, dim=self.dim) + #: Whether this solution is singular at :math:`t = 0`. True for every + #: diffusive similarity solution in the suite: at the origin the profile is + #: a step and its gradient is unbounded, so the state the solution describes + #: at :math:`t = 0` **cannot be represented on any mesh**. Such a solution + #: must be started from a positive time. See :meth:`at`. + singular_at_origin = False + + #: The diffusive scale that sets how fast the singularity heals — the + #: :math:`D` in :math:`\sqrt{Dt}`. Transient solutions set this so that + #: :meth:`earliest_resolvable_time` can answer in numbers rather than + #: advice. + diffusivity = None + + def at(self, time, field=None): + r"""The solution frozen at *time*. + + Parameters + ---------- + time : float + Must be **strictly positive** if :attr:`singular_at_origin`. + field : str, optional + Which field. Defaults to the solution's primary unknown. + + Returns + ------- + sympy expression + + Raises + ------ + ValueError + If the solution has no time symbol, or if *time* is at or before the + singular origin. + + Notes + ----- + **A transient benchmark is a pair of times, never one.** You initialise + the solver from this solution at :math:`t_0` and compare against it at + :math:`t_1`, and the error you measure depends on *both* — a small + :math:`t_0` means starting from a profile the mesh cannot hold, and the + error you then attribute to the timestepper is mostly initial + projection error. Quoting an error at a single time says nothing unless + the start time is quoted with it. + + :math:`t_0` also has a floor that depends on the mesh, not just on the + solution — see :meth:`earliest_resolvable_time`. + """ + + if getattr(self, "t", None) is None: + raise ValueError( + f"{type(self).__name__} is steady — it has no time symbol, so " + f"there is no time to evaluate it at." + ) + + time = float(time) + + if self.singular_at_origin and time <= 0.0: + raise ValueError( + f"{type(self).__name__} is singular at t = 0: the profile there " + f"is a step with unbounded gradient, which no mesh can " + f"represent. Start from a positive time instead — " + f"`earliest_resolvable_time(h)` will tell you how positive it " + f"has to be for your resolution. Got t = {time}." + ) + + expression = self._exact(field) if field is not None else self.fn_solution + + return expression.subs(self.t, time) + + def earliest_resolvable_time(self, h, elements_across=4): + r"""The earliest start time this mesh can actually represent. + + The diffusive front has width :math:`\sim 2\sqrt{Dt}`. Asking for it to + span *elements_across* cells of size *h* gives + + .. math:: + t \ge \frac{1}{D}\left(\frac{n_{\rm el}\,h}{2}\right)^2 + + Below that the exact profile varies faster than the discretisation can + follow, and a benchmark started there measures interpolation error + rather than anything about the solver. The floor falls as + :math:`h^2`, so refining buys an earlier start quickly. + + Parameters + ---------- + h : float + Element size. ``mesh.get_min_radius()`` is a reasonable source. + elements_across : int + How many elements the front should span. Four is a working + minimum; fewer and the initial condition is visibly stepped. + + Returns + ------- + float + Earliest sensible :math:`t_0`. + """ + + if self.diffusivity is None: + raise ValueError( + f"{type(self).__name__} declares no diffusivity, so there is no " + f"diffusive scale to set a floor from." + ) + + return (float(elements_across) * float(h) / 2.0) ** 2 / float(self.diffusivity) + def _exact(self, field): """Resolve a field name — or pass an expression straight through.""" diff --git a/src/underworld3/analytic/richards.py b/src/underworld3/analytic/richards.py index 762600485..2556e70f4 100644 --- a/src/underworld3/analytic/richards.py +++ b/src/underworld3/analytic/richards.py @@ -238,6 +238,7 @@ class GardnerTransient(_Gardner): r"\frac1\alpha\ln\left[u_{\rm dry} " r"+ (u_{\rm wet} - u_{\rm dry})H(z,t)\right]" ) + singular_at_origin = True def __init__( self, diff --git a/src/underworld3/analytic/transport.py b/src/underworld3/analytic/transport.py index dc7917637..ce2c0054d 100644 --- a/src/underworld3/analytic/transport.py +++ b/src/underworld3/analytic/transport.py @@ -96,13 +96,25 @@ class ErfcDiffusion(_Transport): a semi-infinite column to a step held at its boundary. Time appears as a symbol, :attr:`t`, so a transient solver can be checked at - whatever time it reached:: - - exact = sol.fn_solution.subs(sol.t, t_end) - - Starting a comparison at :math:`t > 0` rather than at the step itself is the - point of using this: the initial condition is then smooth and representable - on the mesh, where a sharp step is not. + whatever time it reached. + + .. warning:: + **Singular at** :math:`t = 0`, and so is every diffusive similarity + solution. At the origin the profile is a step with unbounded gradient — + a state no finite element space can hold. Do not start a benchmark + there, and do not quote an error at one time: a transient comparison is + a *pair* of times, and the error depends on the start as much as the + finish. + + ``sol.at(t)`` refuses :math:`t \le 0` outright, and + ``sol.earliest_resolvable_time(h)`` gives the resolution-dependent floor + below which you are measuring interpolation error rather than the + solver:: + + t0 = sol.earliest_resolvable_time(mesh.get_min_radius()) + field.array = uw.function.evaluate(sol.at(t0), field.coords) + ... # step to t1 + error = sol.error("solution", field) # against sol.at(t1) Parameters ---------- @@ -117,6 +129,7 @@ class ErfcDiffusion(_Transport): "tests/test_1005_TransientDarcyCartesian.py." ) eqn_solution = r"\mathrm{erfc}\left(z / 2\sqrt{Dt}\right)" + singular_at_origin = True def __init__(self, mesh, diffusivity=1.0): super().__init__(mesh) @@ -144,10 +157,27 @@ class AdvectedFront(_Transport): :math:`\kappa`. Time is the symbol :attr:`t`, as for :class:`ErfcDiffusion`. This is the solution `tests/test_1100_AdvDiffCartesian.py` needs. Its own - note says the test is fragile because a step initial condition is not - representable on the mesh, and that the fix is to start from a smooth profile - at :math:`t > 0` — which is exactly what evaluating this at a positive time - gives. + note said the test was fragile because a step initial condition is not + representable on the mesh — which is the singularity below, and the reason + that test has to name a start time as well as an end time. + + .. warning:: + **Singular at** :math:`t = 0`, and so is every diffusive similarity + solution. At the origin the profile is a step with unbounded gradient — + a state no finite element space can hold. Do not start a benchmark + there, and do not quote an error at one time: a transient comparison is + a *pair* of times, and the error depends on the start as much as the + finish. + + ``sol.at(t)`` refuses :math:`t \le 0` outright, and + ``sol.earliest_resolvable_time(h)`` gives the resolution-dependent floor + below which you are measuring interpolation error rather than the + solver:: + + t0 = sol.earliest_resolvable_time(mesh.get_min_radius()) + field.array = uw.function.evaluate(sol.at(t0), field.coords) + ... # step to t1 + error = sol.error("solution", field) # against sol.at(t1) Parameters ---------- @@ -169,6 +199,7 @@ class AdvectedFront(_Transport): r"\tfrac12\left[\mathrm{erf}\frac{x_1 - x + ut}{2\sqrt{\kappa t}}" r" + \mathrm{erf}\frac{x - x_0 - ut}{2\sqrt{\kappa t}}\right]" ) + singular_at_origin = True def __init__(self, mesh, kappa=1.0e-3, speed=1.0, x0=0.1, x1=0.3): super().__init__(mesh) @@ -177,6 +208,7 @@ def __init__(self, mesh, kappa=1.0e-3, speed=1.0, x0=0.1, x1=0.3): raise ValueError("kappa must be positive.") self.kappa = float(kappa) + self.diffusivity = float(kappa) self.speed = float(speed) self.x0 = float(x0) self.x1 = float(x1) diff --git a/tests/test_1025_analytic_transport.py b/tests/test_1025_analytic_transport.py index cf48d5e92..7b0011586 100644 --- a/tests/test_1025_analytic_transport.py +++ b/tests/test_1025_analytic_transport.py @@ -162,3 +162,96 @@ def test_transport_solutions_are_registered(): for name in ("Poisson1D", "TwoLayerDarcy", "ErfcDiffusion", "AdvectedFront"): assert getattr(uw.analytic, name).solves == "transport" + + +# --------------------------------------------------------------------------- +# The t = 0 singularity +# --------------------------------------------------------------------------- + + +TRANSIENT = [ + (uw.analytic.ErfcDiffusion, dict(diffusivity=0.5)), + (uw.analytic.AdvectedFront, dict(kappa=1.0e-2, speed=0.5)), +] + + +@pytest.mark.parametrize("cls, kwargs", TRANSIENT) +def test_transient_solutions_declare_their_singularity(mesh, cls, kwargs): + sol = cls(mesh, **kwargs) + + assert sol.singular_at_origin is True + assert sol.diffusivity is not None and sol.diffusivity > 0 + + +@pytest.mark.parametrize("cls, kwargs", TRANSIENT) +@pytest.mark.parametrize("time", [0.0, -1.0]) +def test_the_singular_origin_is_refused(mesh, cls, kwargs, time): + r"""At :math:`t = 0` the profile is a step with unbounded gradient. + + No finite element space holds that, so returning it would only ever produce + a benchmark measuring its own projection error. The refusal is the point: + the caller has to choose a start time, and be told why. + """ + + sol = cls(mesh, **kwargs) + + with pytest.raises(ValueError, match="singular at t = 0"): + sol.at(time) + + +@pytest.mark.parametrize("cls, kwargs", TRANSIENT) +def test_a_positive_time_is_allowed_and_is_the_right_field(mesh, cls, kwargs): + from underworld3.analytic import _validation + + sol = cls(mesh, **kwargs) + frozen = sol.at(0.3) + + assert sol.t not in frozen.free_symbols + points = sol.sample_points(count=6) + assert np.allclose( + _validation.sample(sol, frozen, points), + _validation.sample(sol, sol.fn_solution.subs(sol.t, 0.3), points), + ) + + +def test_steady_solutions_have_no_time_to_be_evaluated_at(mesh): + with pytest.raises(ValueError, match="steady"): + uw.analytic.Poisson1D(mesh).at(0.5) + + +@pytest.mark.parametrize("cls, kwargs", TRANSIENT) +def test_the_resolvable_floor_scales_as_h_squared_over_D(mesh, cls, kwargs): + r""":math:`t_0 = (n_{\rm el} h / 2)^2 / D`, so refining buys an early start.""" + + sol = cls(mesh, **kwargs) + + coarse = sol.earliest_resolvable_time(0.1) + fine = sol.earliest_resolvable_time(0.05) + + assert np.isclose(coarse / fine, 4.0), "floor must fall as h^2" + assert np.isclose(sol.earliest_resolvable_time(0.1, elements_across=8) / coarse, 4.0) + + +@pytest.mark.parametrize("cls, kwargs", TRANSIENT) +def test_the_floor_really_does_resolve_the_front(mesh, cls, kwargs): + """The number is only worth having if it means what it claims. + + At the returned time the diffusive width should be `elements_across` + elements wide — that is the definition, asserted rather than trusted. + """ + + sol = cls(mesh, **kwargs) + h, across = 0.02, 4 + + t0 = sol.earliest_resolvable_time(h, elements_across=across) + width = 2.0 * np.sqrt(sol.diffusivity * t0) + + assert np.isclose(width / h, across) + + +def test_a_solution_with_no_diffusive_scale_says_so(mesh): + sol = uw.analytic.ErfcDiffusion(mesh) + sol.diffusivity = None + + with pytest.raises(ValueError, match="no diffusivity"): + sol.earliest_resolvable_time(0.01) From f92d903a198d6825d729e03c13cb23562d371430 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sat, 15 Aug 2026 12:35:50 +1000 Subject: [PATCH 26/28] Analytic suite: unify the convention audit, fix four defects, complete the rename Landing work on top of the merged analytic suite. CONVENTION AUDIT. Measured rather than read: every registered Stokes solution already obeys one convention on its exposed fn_* fields -- total Cauchy stress, pressure positive in compression, div(sigma) + f = 0 -- and those are UW3's own, fixed by SNES_Stokes.stress and by F0 = -bodyforce against F1 = stress. The non-uniformity is in the published sources and is absorbed at one declared boundary (stress_is_deviatoric, honoured only inside set_fields). No convention needed changing; what was missing was enforcement. FOUR DEFECTS, all found by the oracle-free residual. 1. SolA is wrong for any viscosity but 1. solA.c:156 computes the zz stress without the factor of Z that its own xx stress carries and that solB.c:140 carries. The shortfall is tau_zz*(1-Z)/Z, identically zero at Z=1 -- the default eta, and the only value the kernel's own disabled driver exercised. At eta=3 the momentum residual is 2.8e-1, the deviator trace 6.7e-1 and the strain-rate consistency 6.7e-1, where |1-3|/3 = 0.667 exactly. Repaired by restoring the factor on the term that lost it, declared per solution; the vendored source stays verbatim. Deriving sigma_zz from sigma_xx via tracelessness was rejected because it would make the deviator traceless BY CONSTRUCTION and retire one of the three gates that caught the defect. 2. EllipticalInclusion ignored matrix_viscosity. The potentials are normalised to unit matrix viscosity; rescaling eta scales the stress AND the pressure, but only fn_viscosity was scaled, so the two parts of sigma were in different units. Momentum residual 6.3e-1 at matrix_viscosity=3, now 3.9e-15. 3. SolNL(r=2) raised KeyError: 'ComplexInfinity'. alpha = 1/r - 1 = -1/2 makes the published pressure's denominator vanish identically for every wavenumber -- a pole of the solution. Now refuses with a ValueError naming the cause. 4. SolKz's deviator/total boundary was described wrongly in our own docs. The C function does convert to the total and says so; what is deviatoric is what the transcription captures, because the transcriber stops at the first mode accumulation, which precedes the conversion. Corrected in the subsystem doc. GUARDS. The uniformity is now enforced rather than observed: - tests/test_1028_analytic_parameter_sweep.py re-applies all three residual gates AWAY from the defaults, with a table every registered Stokes solution must appear in. This is the class of bug that hid #1. - the body-force-sign negative control is asserted, not assumed: flipping the sign must move the momentum residual to order unity. - strainrate_consistency -- the genuinely independent velocity-vs-stress check -- promoted from one solution's file into the family-wide sweep. - test_stress_and_strain_rate_agree is labelled as structural for the ten solutions that publish only one of stress or strain rate, and the list of the three that publish both is asserted against the sources rather than commented. RENAME. Every in-repo caller moved to underworld3.analytic; the deprecating shim at underworld3.function.analytic stays, because the old path is public and external scripts cannot be audited. test_1016 still imports the old path deliberately -- it is the shim's contract test. test_1015's low-level AnalyticSolCx_* imports now go to analytic._reference._velic, where they live: those are the vendored kernel, not part of the uw.analytic surface. Underworld development team with AI support from Claude Code --- .../subsystems/analytic-solutions.md | 84 ++++- .../advanced/Ex_Stokes_Cartesian_SolNL.py | 17 +- .../snesfas_investigation/benchmark_3way.py | 2 +- src/underworld3/analytic/inclusion.py | 10 +- src/underworld3/analytic/velic.py | 59 +++- .../test_1017_custom_mg_parallel_mpi.py | 2 +- .../test_1064_rotated_freeslip_parallel.py | 2 +- tests/test_0835_sbr_adapt_on_top.py | 2 +- tests/test_0836_nvb_graded_adapt.py | 2 +- tests/test_0839_nvb_parallel_adapt.py | 2 +- tests/test_1015_analytic_solcx.py | 23 +- tests/test_1017_custom_mg_stokes.py | 2 +- tests/test_1018_rotated_freeslip.py | 2 +- tests/test_1024_analytic_conformance.py | 69 +++++ tests/test_1028_analytic_parameter_sweep.py | 289 ++++++++++++++++++ tests/test_1062_constrained_solcx.py | 2 +- 16 files changed, 529 insertions(+), 40 deletions(-) create mode 100644 tests/test_1028_analytic_parameter_sweep.py diff --git a/docs/developer/subsystems/analytic-solutions.md b/docs/developer/subsystems/analytic-solutions.md index 3f52b869f..e255abf15 100644 --- a/docs/developer/subsystems/analytic-solutions.md +++ b/docs/developer/subsystems/analytic-solutions.md @@ -269,14 +269,36 @@ would hide exactly that. **Check which stress a kernel publishes. Do not read it off the variable name.** -| solution | its stress output is | +| solution | what we transcribe is | |---|---| | SolA, SolB, SolC, SolCx, SolDA, SolKx | total (Cauchy) $\sigma$ | -| SolKz, SolNL, SolDB2d, SolDB3d | deviatoric $\tau$ | +| SolKz, SolNL, SolDB2d, SolDB3d | deviatoric $\tau$ (SolKz: see the cut-point note below) | | SolM | published, but wrong — see below | - -SolA is the clearest case to read: its source writes `u3 = 2*kn*ss_z - pp`, with the -pressure subtracted in plain sight. SolKz's writes the same quantity without it. +| SolA | total, but its $zz$ component is wrong — see below | + +SolB is the clearest case to read: its source writes `u3 = 2.0*Z*kn*ss_z - pp`, +with the pressure subtracted in plain sight. (Do **not** use solA's version of +that line as the exemplar, as this document once did — it is the one that is +missing the viscosity.) + +Two further traps that are not about deviatoric-vs-total: + +- **Component order is not uniform.** Every kernel here writes `[xx, zz, xz]` + except `solKx.c`, which writes **`[xx, xz, zz]`** (:481-491, with the legend + at :456). It has a different provenance from the rest — it is vendored from + PETSc's `ex69.c`, not the Underworld tree. +- **Several kernels label the vertical velocity `u1`** and the horizontal `u2` + — solA (:151), solB (:135), solC (:142), solDA (:910), solKz (:493). solCx + (:1467) and solKx (:456) do not. `solH.c` reverses all three (:173-182). The + transcription maps components explicitly rather than positionally; a swap here + is caught by the momentum residual only because the components have different + functional forms, and would be invisible in a symmetric problem. + +Both are uniform across the family in the two ways that matter for a solve, and +neither is stated in most of the files — both had to be measured by +finite-differencing the kernels' own outputs: the momentum sign is +$\nabla\cdot\sigma + \mathbf f = 0$ with $\mathbf f = -\rho\hat z$ under unit +gravity in $-z$, and pressure is positive in compression. ### The body force is minus the density @@ -306,11 +328,53 @@ This is the case for having a check that consults no reference. Comparing SolM against its own kernel would have reproduced the error faithfully and reported agreement. -SolKz is the trap: it writes into an array literally called `total_stress`, and -the contents are the deviator. Taking the name at face value leaves the momentum -residual at order $|\mathbf f|$ *and* manufactures a horizontal body force in a -benchmark that has none — a large, structured error that reads like a -transcription failure rather than a convention one. +SolKz is the trap, though it needs stating more carefully than it was here +originally. `solKz.c` *does* convert to the total stress, and says so — `u6 -= +u5; /* get total stress */` (:490), restated at :527 as `/* sigma = tau - p */`. +The array the C function returns is the total. What is deviatoric is **what our +transcription captures**: the transcriber reads the per-mode straight-line block +and stops at the first accumulation, and `sum5 += ...` (:489) precedes the +conversion (:490), so the per-mode `u6` and `u3` we take are pre-conversion. +`stress_is_deviatoric = True` on `SolKz` is therefore correct, but it describes +the transcription's cut point rather than the kernel's output — and anyone +transcribing afresh from the *returned array* must not set it. + +Getting that wrong leaves the momentum residual at order $|\mathbf f|$ *and* +manufactures a horizontal body force in a benchmark that has none — a large, +structured error that reads like a transcription failure rather than a +convention one. Measured on the transcribed fields: as the deviator (ours) +1.7e-16, as the total 6.0e-1. + +### A published stress that is silently right at the default + +`solA.c:156` computes the $zz$ stress as `u3 = 2.0*kn*ss_z - pp`. The matching +line in `solB.c:140` is `2.0*Z*kn*ss_z - pp`, and solA's own $xx$ stress two +lines later carries the `Z`. The published $\sigma_{zz}$ is short by +$\tau_{zz}(1-Z)/Z$. + +That is **identically zero at $Z = 1$** — the only case the file's own disabled +driver exercised, and the default `eta` in our transcription. Every gate in the +conformance file passed. At `eta=3`, three of them fail: momentum 2.8e-1, +deviator trace 6.7e-1, strain-rate consistency 6.7e-1, where +$|1-3|/3 = 0.667$ exactly. + +The transcription restores the factor, declared per solution as +`_zz_stress_lost_the_viscosity`; the vendored source stays verbatim. + +Note the repair that was **rejected**. Tracelessness gives +$\sigma_{zz} = -\sigma_{xx} - 2p$ straight from the correct $xx$ component, and +it works — but it would make the deviator traceless *by construction* and so +retire one of the three gates that caught the defect. Restoring the missing +factor instead keeps tracelessness an independent statement about the repair. +`test_1028` asserts both halves: that solA's published deviator is not traceless +at $Z=3$, and that solB's is. + +**The general lesson, and the reason `test_1028_analytic_parameter_sweep.py` +exists**: the conformance sweep builds every solution from a mesh alone. That is +right — the defaults are part of the interface — but a coefficient that is unity +by default multiplies a term nothing ever looks at. Any new solution needs an +entry in that file's `SWEEP` table, and the file asserts that every registered +Stokes solution has one. Two cheap signatures tell them apart, and both are worth running on any new kernel: diff --git a/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Cartesian_SolNL.py b/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Cartesian_SolNL.py index a37dcd358..c91609341 100644 --- a/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Cartesian_SolNL.py +++ b/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Cartesian_SolNL.py @@ -78,24 +78,19 @@ # %% # NL problem # Create solution functions -from underworld3.function.analytic import ( - AnalyticSolNL_velocity, - AnalyticSolNL_bodyforce, - AnalyticSolNL_viscosity) +from underworld3 import analytic as A x, y = mesh.X -r = mesh.r eta0 = 1.0 n = 1 r0 = 1.5 -params = (eta0, n, r0) -sol_bf_ijk = AnalyticSolNL_bodyforce(*params, *r) -sol_vel_ijk = AnalyticSolNL_velocity(*params, *r) -sol_bf = mesh.vector.to_matrix(sol_bf_ijk) -sol_vel = mesh.vector.to_matrix(sol_vel_ijk) -sol_visc = AnalyticSolNL_viscosity(*params, *r) +sol = A.SolNL(mesh, eta_0=eta0, n=n, r=r0) + +sol_vel = sol.fn_velocity +sol_bf = sol.fn_bodyforce +sol_visc = sol.fn_viscosity # debug - are problems just because there is no analytic solution module on mac # The solNL case is a MMS force term (complicated) designed to produce a specific diff --git a/docs/examples/snesfas_investigation/benchmark_3way.py b/docs/examples/snesfas_investigation/benchmark_3way.py index 1a9625e4f..b4f7aa30e 100644 --- a/docs/examples/snesfas_investigation/benchmark_3way.py +++ b/docs/examples/snesfas_investigation/benchmark_3way.py @@ -19,7 +19,7 @@ import numpy as np import sympy import underworld3 as uw -from underworld3.function import analytic as A +from underworld3 import analytic as A fv = importlib.util.module_from_spec( importlib.util.spec_from_file_location("fv", os.path.join(os.path.dirname(os.path.abspath(__file__)), "fas_vanka.py"))) diff --git a/src/underworld3/analytic/inclusion.py b/src/underworld3/analytic/inclusion.py index 23511c1e6..b21500a16 100644 --- a/src/underworld3/analytic/inclusion.py +++ b/src/underworld3/analytic/inclusion.py @@ -333,7 +333,15 @@ def __init__( matrix_pressure = -( potentials["phi_prime"] + _conjugate(potentials["phi_prime"]) ) - self.fn_pressure = sympy.Piecewise( + # The potentials are normalised to UNIT matrix viscosity. Under a + # rescaling eta -> lambda*eta at fixed boundary velocity, Stokes flow + # leaves the velocity and the strain rate alone and scales the stress + # AND the pressure by lambda. Omitting the factor here left the viscous + # part of sigma scaled and the pressure part unscaled, so + # div(sigma) = 0 failed by 0.63 at matrix_viscosity = 3 while every + # other gate — tracelessness, strain-rate consistency — still passed, + # because those do not couple the two parts. + self.fn_pressure = self.matrix_viscosity * sympy.Piecewise( (potentials["interior_pressure"], inside), (matrix_pressure, True), ).subs(physical) diff --git a/src/underworld3/analytic/velic.py b/src/underworld3/analytic/velic.py index f1c68863a..307820416 100644 --- a/src/underworld3/analytic/velic.py +++ b/src/underworld3/analytic/velic.py @@ -396,6 +396,19 @@ def __init__(self, mesh, eta_0=1.0, n=1, r=1.5, reference=False): raise ValueError("n (vertical wavenumber) must be a positive integer.") if float(r) <= 0.0: raise ValueError("r (power-law exponent) must be positive.") + # The published closed form is singular at alpha = 1/r - 1 = -1/2. The + # pressure's denominator (AnalyticSolNL.c:52, `t44`) vanishes there + # identically — for every wavenumber, so this is a pole of the solution + # and not a bad (r, n) pairing. Refuse it here: left to run it produces + # ComplexInfinity in the pressure, stress and body force, and the first + # symptom is a KeyError from SymPy's code printer, a long way from the + # cause. + if float(r) == 2.0: + raise ValueError( + "r = 2 is a pole of the published SolNL solution: its pressure " + "denominator vanishes identically at alpha = 1/r - 1 = -1/2. " + "Use a nearby exponent (r = 1.9 or 2.1) instead." + ) self.eta_0 = float(eta_0) self.n = int(n) @@ -940,6 +953,12 @@ class _SolAB(FreeSlipWalls, AnalyticSolution): _kernel = None _vertical = None + #: Whether this kernel's published zz stress is missing the viscosity that + #: its xx stress carries. True for solA (solA.c:156) and false for solB, + #: whose matching line (solB.c:140) is correct. Declared per solution rather + #: than patched in place: the vendored sources stay verbatim. + _zz_stress_lost_the_viscosity = False + def __init__(self, mesh, sigma=1.0, eta=1.0, n=3, m=2): super().__init__(mesh) @@ -967,6 +986,32 @@ def __init__(self, mesh, sigma=1.0, eta=1.0, n=3, m=2): for field, expression in _solab_kernel(self._kernel).items() } + # ERRATUM (solA.c:156). solA's published zz stress is + # `u3 = 2.0*kn*ss_z - pp`, where the matching line in solB.c:140 — and + # solA's OWN xx stress on the next line but one — carry a factor of the + # viscosity Z. The published sigma_zz is therefore short by + # tau_zz*(1-Z)/Z, which is identically zero at Z=1: the only case the + # file's own (disabled) driver exercises, and the default `eta` here. + # Left alone it survives every check made at the default and fails three + # of them at any other viscosity. + # + # Rather than edit a vendored source, the MISSING FACTOR is restored on + # the term that lost it: the published deviator is + # tau_zz = u3 + pp = 2*kn*ss_z, and the correct one is Z times that. + # + # Deriving sigma_zz from sigma_xx instead (tau_zz = -tau_xx) would also + # work and is tempting, but it would make the deviator TRACELESS BY + # CONSTRUCTION and so retire one of the three gates that caught this. + # Repairing the factor keeps tracelessness an independent statement + # about the repair, and it is the one the sweep checks. + if self._zz_stress_lost_the_viscosity: + stress_zz = ( + sympy.Rational(self.eta) * (kernel["stress_zz"] + kernel["pressure"]) + - kernel["pressure"] + ) + else: + stress_zz = kernel["stress_zz"] + self.set_fields( velocity=(kernel["velocity_x"], kernel["velocity_z"]), pressure=kernel["pressure"], @@ -979,7 +1024,7 @@ def __init__(self, mesh, sigma=1.0, eta=1.0, n=3, m=2): ), stress=( (kernel["stress_xx"], kernel["stress_zx"]), - (kernel["stress_zx"], kernel["stress_zz"]), + (kernel["stress_zx"], stress_zz), ), ) @@ -995,6 +1040,17 @@ class SolA(_SolAB): discretisation or the solve rather than in how a hard coefficient is handled. Run it before concluding anything from SolCx or SolKx. + .. note:: + **Erratum in the published kernel.** ``solA.c:156`` computes the $zz$ + stress without the factor of viscosity that its own $xx$ stress carries + and that ``solB.c:140`` carries — the published $\sigma_{zz}$ is short by + $\tau_{zz}(1-Z)/Z$. That is identically zero at $Z = 1$, which is the + default ``eta`` and the only value the kernel's own driver exercised, so + it is invisible unless you change the viscosity. The transcription + restores the factor; the vendored source is left verbatim. If you compare + our $\sigma_{zz}$ against the published kernel at ``eta != 1`` they will + disagree, and the kernel is the one that is wrong. + Parameters ---------- mesh : Mesh @@ -1011,6 +1067,7 @@ class SolA(_SolAB): _kernel = "solA" _vertical = staticmethod(sympy.sin) + _zz_stress_lost_the_viscosity = True reference = ( "Velic. Transcribed from the published kernel vendored at " "underworld3/analytic/_reference/solA.c." diff --git a/tests/parallel/test_1017_custom_mg_parallel_mpi.py b/tests/parallel/test_1017_custom_mg_parallel_mpi.py index db031f918..9ae6e8d43 100644 --- a/tests/parallel/test_1017_custom_mg_parallel_mpi.py +++ b/tests/parallel/test_1017_custom_mg_parallel_mpi.py @@ -13,7 +13,7 @@ import numpy as np import pytest import underworld3 as uw -from underworld3.function import analytic as A +from underworld3 import analytic as A from underworld3.utilities import custom_mg pytestmark = [pytest.mark.mpi(min_size=2), pytest.mark.timeout(180)] diff --git a/tests/parallel/test_1064_rotated_freeslip_parallel.py b/tests/parallel/test_1064_rotated_freeslip_parallel.py index 63a935a11..5abe28c7f 100644 --- a/tests/parallel/test_1064_rotated_freeslip_parallel.py +++ b/tests/parallel/test_1064_rotated_freeslip_parallel.py @@ -34,7 +34,7 @@ import pytest import underworld3 as uw -from underworld3.function import analytic as A +from underworld3 import analytic as A from underworld3.utilities import custom_mg pytestmark = [pytest.mark.mpi(min_size=2), pytest.mark.timeout(180)] diff --git a/tests/test_0835_sbr_adapt_on_top.py b/tests/test_0835_sbr_adapt_on_top.py index 4308c9d9e..d3e07694e 100644 --- a/tests/test_0835_sbr_adapt_on_top.py +++ b/tests/test_0835_sbr_adapt_on_top.py @@ -25,7 +25,7 @@ import pytest import sympy import underworld3 as uw -from underworld3.function import analytic as A +from underworld3 import analytic as A pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] diff --git a/tests/test_0836_nvb_graded_adapt.py b/tests/test_0836_nvb_graded_adapt.py index 59cf0c81f..7042e36b7 100644 --- a/tests/test_0836_nvb_graded_adapt.py +++ b/tests/test_0836_nvb_graded_adapt.py @@ -25,7 +25,7 @@ import pytest import sympy import underworld3 as uw -from underworld3.function import analytic as A +from underworld3 import analytic as A from _mg_ladder import assert_coarsening_ladder from underworld3.utilities.nvb import NVBMesh diff --git a/tests/test_0839_nvb_parallel_adapt.py b/tests/test_0839_nvb_parallel_adapt.py index 3d19cfd22..31e9a34f9 100644 --- a/tests/test_0839_nvb_parallel_adapt.py +++ b/tests/test_0839_nvb_parallel_adapt.py @@ -22,7 +22,7 @@ import pytest import sympy import underworld3 as uw -from underworld3.function import analytic as A +from underworld3 import analytic as A from petsc4py import PETSc pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] diff --git a/tests/test_1015_analytic_solcx.py b/tests/test_1015_analytic_solcx.py index 4ba67c102..c1f481f55 100644 --- a/tests/test_1015_analytic_solcx.py +++ b/tests/test_1015_analytic_solcx.py @@ -18,7 +18,14 @@ import numpy as np import underworld3 as uw -from underworld3.function import analytic as A +from underworld3 import analytic as A + +# The low-level AnalyticSolCx_* bindings are the VENDORED KERNEL, not part of +# the `uw.analytic` public surface — `uw.analytic` exposes solutions, not the +# reference functions they were transcribed from. Import them from where they +# actually live rather than through the deprecated `uw.function.analytic` +# shim, which only forwards here anyway. +from underworld3.analytic._reference import _velic as K def _solve_solcx(res, eta_B=1.0e6, x_c=0.5, n=1): @@ -45,7 +52,7 @@ def _solve_solcx(res, eta_B=1.0e6, x_c=0.5, n=1): def test_solcx_analytic_binding(): """The ported SolCx kernel returns non-trivial values.""" - vy = float(A.AnalyticSolCx_velocity_y(1.0, 1.0e6, 0.5, 1, 0.25, 0.5).evalf()) + vy = float(K.AnalyticSolCx_velocity_y(1.0, 1.0e6, 0.5, 1, 0.25, 0.5).evalf()) assert np.isfinite(vy) assert abs(vy) > 1.0e-8 @@ -61,9 +68,9 @@ def test_solcx_stokes_converges_to_analytic(): def test_solcx_stress_binding(): """The exact total-stress kernel binding returns finite, non-trivial values.""" pts = [(0.25, 1.0), (0.7, 0.2), (0.9, 0.6), (0.15, 0.4)] - sxx = [float(A.AnalyticSolCx_stress_xx(1.0, 1.0e6, 0.5, 1, x, y).evalf()) for x, y in pts] - syy = [float(A.AnalyticSolCx_stress_yy(1.0, 1.0e6, 0.5, 1, x, y).evalf()) for x, y in pts] - sxy = [float(A.AnalyticSolCx_stress_xy(1.0, 1.0e6, 0.5, 1, x, y).evalf()) for x, y in pts] + sxx = [float(K.AnalyticSolCx_stress_xx(1.0, 1.0e6, 0.5, 1, x, y).evalf()) for x, y in pts] + syy = [float(K.AnalyticSolCx_stress_yy(1.0, 1.0e6, 0.5, 1, x, y).evalf()) for x, y in pts] + sxy = [float(K.AnalyticSolCx_stress_xy(1.0, 1.0e6, 0.5, 1, x, y).evalf()) for x, y in pts] for s in sxx + syy + sxy: assert np.isfinite(s) # the stress field is non-trivial (the dynamic-topography signal on the top is -syy) @@ -78,9 +85,9 @@ def test_solcx_stress_pressure_consistency(): pressure without assuming any UW3 sign convention.""" pts = [(0.2, 0.3), (0.7, 0.8), (0.5, 0.5), (0.9, 0.1), (0.35, 0.65)] for xi, yi in pts: - sxx = float(A.AnalyticSolCx_stress_xx(1.0, 1.0e6, 0.5, 1, xi, yi).evalf()) - syy = float(A.AnalyticSolCx_stress_yy(1.0, 1.0e6, 0.5, 1, xi, yi).evalf()) - p = float(A.AnalyticSolCx_pressure(1.0, 1.0e6, 0.5, 1, xi, yi).evalf()) + sxx = float(K.AnalyticSolCx_stress_xx(1.0, 1.0e6, 0.5, 1, xi, yi).evalf()) + syy = float(K.AnalyticSolCx_stress_yy(1.0, 1.0e6, 0.5, 1, xi, yi).evalf()) + p = float(K.AnalyticSolCx_pressure(1.0, 1.0e6, 0.5, 1, xi, yi).evalf()) mean_normal = 0.5 * (sxx + syy) assert abs(mean_normal + p) < 1.0e-10, ( f"(sxx+syy)/2 != -p at ({xi},{yi}): {mean_normal:.3e} vs {-p:.3e}" diff --git a/tests/test_1017_custom_mg_stokes.py b/tests/test_1017_custom_mg_stokes.py index a3c2f4a72..ac5a08d31 100644 --- a/tests/test_1017_custom_mg_stokes.py +++ b/tests/test_1017_custom_mg_stokes.py @@ -16,7 +16,7 @@ import numpy as np import pytest import underworld3 as uw -from underworld3.function import analytic as A +from underworld3 import analytic as A from underworld3.utilities import custom_mg pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] diff --git a/tests/test_1018_rotated_freeslip.py b/tests/test_1018_rotated_freeslip.py index 53af6ea48..543cb36f8 100644 --- a/tests/test_1018_rotated_freeslip.py +++ b/tests/test_1018_rotated_freeslip.py @@ -8,7 +8,7 @@ import pytest import sympy import underworld3 as uw -from underworld3.function import analytic as A +from underworld3 import analytic as A from underworld3.utilities import custom_mg pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] diff --git a/tests/test_1024_analytic_conformance.py b/tests/test_1024_analytic_conformance.py index 618fd7951..2a2792df7 100644 --- a/tests/test_1024_analytic_conformance.py +++ b/tests/test_1024_analytic_conformance.py @@ -150,6 +150,71 @@ def test_solution_satisfies_the_momentum_balance(name, built): assert _validation.momentum_residual(sol, points) < 1.0e-8 +@pytest.mark.parametrize("name", STOKES) +def test_strain_rate_matches_the_velocity(name, built): + r""":math:`\tfrac12(\nabla\mathbf u + \nabla\mathbf u^{T})` vs `fn_strainrate`. + + This is the independent one, and it belongs in the family-wide sweep rather + than in a single solution's file. The velocity and the stress are *separate + outputs of the same kernel*, and the strain rate here is derived from the + stress — so the comparison relates two quantities the source derived + separately, and it exercises the derivatives, which is what a solver + consumes and where a transcription can be wrong while still matching + pointwise. + + Contrast `test_stress_and_strain_rate_agree` below, which for most of the + family is structural. + """ + + from underworld3.analytic import _validation + + sol = built[name] + points = sol.sample_points(count=8) + + assert _validation.strainrate_consistency(sol, points) < 1.0e-8 + + +# `set_fields` accepts a stress OR a strain rate and derives the other from +# sigma + p I = 2 eta edot. For a solution that supplied only one of the two, +# the assertion below is exactly that derivation read back, so it is structural +# rather than evidential. These three supply BOTH, so for them it compares two +# separately published quantities and is a real check. +# +# It is kept for the whole family regardless: it is nearly free, and it is what +# would catch `set_fields` itself regressing. +PUBLISH_BOTH = {"SolNL", "SolDB2d", "SolDB3d"} + + +def test_the_list_of_solutions_publishing_both_is_accurate(): + """PUBLISH_BOTH is a claim about the sources; check it against them. + + A solution that starts supplying both — or stops — silently changes what the + gate below is worth, so the claim is asserted rather than commented. + """ + + import inspect + + from underworld3 import analytic + + for name in STOKES: + cls = getattr(analytic, name) + source = "" + for klass in cls.__mro__: + try: + text = inspect.getsource(klass) + except (OSError, TypeError): + continue + if "set_fields(" in text: + source = text + break + + both = "stress=" in source and "strainrate=" in source + assert both == (name in PUBLISH_BOTH), ( + f"{name}: publishes both = {both}, but PUBLISH_BOTH says " + f"{name in PUBLISH_BOTH}" + ) + + @pytest.mark.parametrize("name", STOKES) def test_stress_and_strain_rate_agree(name, built): r""":math:`\sigma + p\,I = 2\eta\dot\varepsilon`, however each was obtained. @@ -157,6 +222,10 @@ def test_stress_and_strain_rate_agree(name, built): Some solutions publish both and some derive one from the other; either way the pair has to be consistent, and a wrong `stress_is_deviatoric` shows up here as a full pressure's worth of disagreement. + + For the solutions NOT in `PUBLISH_BOTH` this is the identity `set_fields` + used to build the missing quantity, so passing it is structural. See + `test_strain_rate_matches_the_velocity` above for the independent check. """ from underworld3.analytic import _validation diff --git a/tests/test_1028_analytic_parameter_sweep.py b/tests/test_1028_analytic_parameter_sweep.py new file mode 100644 index 000000000..09a1c86c5 --- /dev/null +++ b/tests/test_1028_analytic_parameter_sweep.py @@ -0,0 +1,289 @@ +r"""The residual gates, applied AWAY from the default parameters. + +`test_1024_analytic_conformance.py` builds every solution from a mesh alone. +That is deliberate — the defaults are part of the interface — but it means a +coefficient that happens to be unity by default multiplies a term nothing ever +looks at. + +That is not hypothetical either. SolA's published $\sigma_{zz}$ (solA.c:156) is +missing the factor of viscosity that its own $\sigma_{xx}$ carries and that +solB's matching line carries. The shortfall is $\tau_{zz}(1-Z)/Z$, identically +zero at $Z = 1$ — the default `eta`, and the only value the kernel's own +disabled driver ever exercised. Every gate in the conformance file passed. At +`eta=3` three of them fail. + +So: each solution is swept over parameters that are *not* its defaults, and the +oracle-free gates are re-applied. The sweep table is explicit rather than +generated, because the point is to move the coefficients that matter for each +solution — a viscosity contrast, a wavenumber, an interface position — and +which those are is per-solution knowledge. + +Run: pixi run python -m pytest tests/test_1028_analytic_parameter_sweep.py -v +""" + +import pytest + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + +import numpy as np +import sympy +import underworld3 as uw +from underworld3.analytic import _validation + + +# solution -> parameter sets to try IN ADDITION to the defaults. Every entry +# moves at least one coefficient off unity or off its default position. +SWEEP = { + "SolA": [{"eta": 3.0}, {"eta": 0.25}, {"eta": 3.0, "n": 2, "m": 1.5}], + "SolB": [{"eta": 3.0}, {"eta": 0.25}, {"eta": 3.0, "n": 2, "m": 1.5}], + "SolC": [{"eta": 3.0}, {"eta": 0.25, "x_c": 0.3}], + "SolH": [{"eta": 3.0}, {"eta": 0.25, "dx": 0.3, "dy": 0.4}], + "SolCx": [{"eta_A": 3.0, "eta_B": 0.1}, {"eta_A": 1.0e4, "eta_B": 1.0, "x_c": 0.3}], + "SolDA": [{"eta_A": 2.0, "eta_B": 0.5}, {"sigma": 3.0, "z_c": 0.4}], + "SolKx": [{"B": 1.0, "n": 2, "m": 1.0}, {"B": 4.0}], + "SolKz": [{"B": 1.0, "n": 2, "m": 1.0}, {"B": 4.0}], + "SolM": [{"eta_0": 3.0}, {"eta_0": 0.5, "n": 2, "m": 1, "r": 3.0}], + "SolNL": [{"eta_0": 2.0}, {"r": 2.5}], + "SolDB2d": [], # a manufactured solution with no free parameters + "SolDB3d": [{"beta": 1.0}, {"beta": 8.0}], + "EllipticalInclusion": [ + {"viscosity_ratio": 10.0, "aspect_ratio": 1.5}, + {"matrix_viscosity": 3.0}, + {"matrix_viscosity": 0.5, "viscosity_ratio": 100.0}, + ], +} + +STOKES = sorted( + name + for name in uw.analytic.available() + if getattr(uw.analytic, name).symbolic + and uw.analytic.is_available(name) + and getattr(uw.analytic, name).solves == "stokes" +) + +CASES = [(name, kw) for name in STOKES for kw in SWEEP.get(name, [])] + + +def test_every_stokes_solution_is_swept(): + """A solution added later must be given a sweep, or fail here. + + SolDB2d is listed with an empty sweep rather than omitted: "this one has no + parameters" is a claim worth recording, and it is different from "nobody got + round to it". + """ + + assert set(SWEEP) == set(STOKES), ( + f"unswept: {set(STOKES) - set(SWEEP)}; stale: {set(SWEEP) - set(STOKES)}" + ) + + +@pytest.fixture(scope="module") +def meshes(): + return { + 2: uw.meshing.StructuredQuadBox( + elementRes=(4, 4), minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), qdegree=3 + ), + 3: uw.meshing.StructuredQuadBox( + elementRes=(2, 2, 2), + minCoords=(0.0, 0.0, 0.0), + maxCoords=(1.0, 1.0, 1.0), + qdegree=2, + ), + } + + +@pytest.fixture(scope="module") +def cache(): + """One construction per (solution, parameters), shared across the gates. + + Constructing a series solution substitutes parameters into expressions tens + of thousands of operations long. Three gates run over every case here, so + building afresh in each would treble the cost of the file for nothing — + the same reason the conformance sweep uses a module-scoped fixture. + """ + + return {} + + +def _built(cache, meshes, name, kw): + key = (name, tuple(sorted(kw.items()))) + if key not in cache: + cls = getattr(uw.analytic, name) + cache[key] = cls(meshes[cls.dim], **kw) + return cache[key] + + +@pytest.mark.parametrize("name,kw", CASES, ids=[f"{n}-{kw}" for n, kw in CASES]) +def test_momentum_balance_off_default(cache, meshes, name, kw): + r""":math:`\nabla\cdot\sigma + \mathbf f = 0` away from the defaults.""" + + sol = _built(cache, meshes, name, kw) + assert _validation.momentum_residual(sol, sol.sample_points(count=8)) < 1.0e-8 + + +@pytest.mark.parametrize("name,kw", CASES, ids=[f"{n}-{kw}" for n, kw in CASES]) +def test_deviator_is_traceless_off_default(cache, meshes, name, kw): + r""":math:`\mathrm{tr}(\sigma + p\mathbf I) = 0`. + + Equivalent to the pressure being :math:`-\mathrm{tr}\,\sigma/d`, i.e. to + `fn_stress` being the total stress with pressure positive in compression. + Independent of the body force, which is what made it the gate that localised + the SolA erratum: it fired without any reference to the forcing. + """ + + sol = _built(cache, meshes, name, kw) + points = sol.sample_points(count=8) + deviator = sol.fn_stress + sol.fn_pressure * sympy.eye(sol.dim) + + trace = sum(deviator[i, i] for i in range(sol.dim)) + scale = max( + np.abs(_validation.sample(sol, deviator[i, i], points)).max() + for i in range(sol.dim) + ) + + assert np.abs(_validation.sample(sol, trace, points)).max() / max(scale, 1e-300) < 1.0e-8 + + +@pytest.mark.parametrize("name,kw", CASES, ids=[f"{n}-{kw}" for n, kw in CASES]) +def test_strain_rate_matches_the_velocity_off_default(cache, meshes, name, kw): + r""":math:`\tfrac12(\nabla\mathbf u + \nabla\mathbf u^{T})` vs `fn_strainrate`. + + The genuinely independent pair: the velocity and the stress are separate + outputs of the same kernel, so this is not the identity `set_fields` used to + derive one from the other. + """ + + sol = _built(cache, meshes, name, kw) + assert _validation.strainrate_consistency(sol, sol.sample_points(count=8)) < 1.0e-8 + + +# -------------------------------------------------------------------------- +# Negative controls. A gate that has never been seen to fail is a gate nobody +# has checked can fail. +# -------------------------------------------------------------------------- + + +FORCED = [n for n in STOKES if n != "EllipticalInclusion"] + + +@pytest.mark.parametrize("name", FORCED) +def test_flipping_the_body_force_breaks_the_momentum_balance(cache, meshes, name): + r"""The momentum gate must REJECT :math:`\nabla\cdot\sigma - \mathbf f = 0`. + + This is what pins the sign convention to UW3's own. The solver assembles + ``F0 = -bodyforce`` against ``F1 = stress``, whose strong form is + :math:`\nabla\cdot\sigma + \mathbf f = 0`; if the suite tolerated the other + sign it would not be validating that. + + EllipticalInclusion is excluded because it has no body force to flip — it is + driven entirely by its boundary. That is a property of the problem, so it is + excluded by name here and asserted below rather than skipped silently. + """ + + sol = _built(cache, meshes, name, {}) + points = sol.sample_points(count=8) + + assert _validation.momentum_residual(sol, points) < 1.0e-8 + + keep = sol.fn_bodyforce + sol.fn_bodyforce = sympy.Matrix([[-c for c in keep]]) + try: + flipped = _validation.momentum_residual(sol, points) + finally: + sol.fn_bodyforce = keep + + assert flipped > 1.0e-2, ( + f"{name}: flipping the body force left the momentum residual at " + f"{flipped:.3e} — the gate cannot see the sign it is supposed to certify" + ) + + +def test_the_inclusion_really_has_no_body_force(cache, meshes): + """The stated grounds for excluding it from the negative control above.""" + + sol = _built(cache, meshes, "EllipticalInclusion", {}) + assert all(component == 0 for component in sol.fn_bodyforce) + + +@pytest.mark.parametrize("kernel_name,repaired", [("solA", True), ("solB", False)]) +def test_only_solA_is_missing_the_viscosity_in_its_zz_stress(meshes, kernel_name, repaired): + r"""The erratum is real, and it is confined to solA. + + Read the two kernels directly and test the deviator they publish for + tracelessness at a viscosity that is not one. solB passes as published; + solA does not, and passes only after the missing factor of $Z$ is restored. + + Asserting BOTH halves is the point. That solA needs the repair is one claim; + that solB does not is the control which says the repair is a correction to a + defect rather than a convention we imposed on the family. + """ + + from underworld3.analytic import velic + + mesh = meshes[2] + eta = 3.0 + sol = uw.analytic.SolB(mesh, eta=eta) # only a carrier for `sample` + points = sol.sample_points(count=8) + + kernel = velic._solab_kernel(kernel_name) + values = { + velic._SIGMA: sympy.Rational(1.0), + velic._ETA0: sympy.Rational(eta), + velic._N: 3, + velic._KM: sympy.Rational(2) * sympy.pi, + velic._X: mesh.X[0], + velic._Z: mesh.X[1], + } + published = {k: v.subs(values) for k, v in kernel.items()} + + tau_xx = published["stress_xx"] + published["pressure"] + tau_zz = published["stress_zz"] + published["pressure"] + + scale = np.abs(_validation.sample(sol, tau_xx, points)).max() + as_published = np.abs(_validation.sample(sol, tau_xx + tau_zz, points)).max() / scale + + if repaired: + assert as_published > 1.0e-2, ( + f"{kernel_name}: expected the published deviator to be non-traceless " + f"at eta={eta}, got {as_published:.3e} — has the vendored kernel changed?" + ) + restored = np.abs( + _validation.sample(sol, tau_xx + sympy.Rational(eta) * tau_zz, points) + ).max() / scale + assert restored < 1.0e-10 + else: + assert as_published < 1.0e-10 + + +def test_solNL_refuses_its_pole(): + """r = 2 makes the published pressure's denominator vanish identically.""" + + mesh = uw.meshing.StructuredQuadBox( + elementRes=(4, 4), minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), qdegree=3 + ) + + with pytest.raises(ValueError, match="pole"): + uw.analytic.SolNL(mesh, r=2.0) + + +def test_the_inclusion_honours_its_matrix_viscosity(cache, meshes): + r"""Rescaling $\eta$ at fixed boundary velocity scales $\sigma$ AND $p$. + + The velocity and strain rate are invariant; the stress and the pressure both + carry the factor. Scaling only the viscous part leaves $\nabla\cdot\sigma = 0$ + broken while tracelessness and strain-rate consistency still pass, which is + why this needs asserting directly. + """ + + one = _built(cache, meshes, "EllipticalInclusion", {}) + three = _built(cache, meshes, "EllipticalInclusion", {"matrix_viscosity": 3.0}) + points = one.sample_points(count=8) + + for i in range(2): + a = _validation.sample(one, one.fn_velocity[0, i], points) + b = _validation.sample(three, three.fn_velocity[0, i], points) + assert np.abs(a - b).max() / max(np.abs(a).max(), 1e-300) < 1.0e-10 + + a = _validation.sample(one, one.fn_pressure, points) + b = _validation.sample(three, three.fn_pressure, points) + assert np.abs(b - 3.0 * a).max() / max(np.abs(b).max(), 1e-300) < 1.0e-10 diff --git a/tests/test_1062_constrained_solcx.py b/tests/test_1062_constrained_solcx.py index 3079bb4df..72d798e42 100644 --- a/tests/test_1062_constrained_solcx.py +++ b/tests/test_1062_constrained_solcx.py @@ -13,7 +13,7 @@ import numpy as np import sympy import underworld3 as uw -from underworld3.function import analytic as A +from underworld3 import analytic as A ETA_A, ETA_B, XC, NZ, RES = 1.0, 1.0e6, 0.5, 1, 32 From 682ff619eab8aa7ee89bf009320e3fe05bc31146 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sat, 15 Aug 2026 12:47:49 +1000 Subject: [PATCH 27/28] Record what set_fields was given, rather than scraping the source for it test_the_list_of_solutions_publishing_both_is_accurate decided whether a solution publishes both a stress and a strain rate by walking the MRO for a class whose source contains "set_fields(" and then looking for the parameter names in it. That is wrong for the one solution it matters for: EllipticalInclusion never calls set_fields at all, so the walk fell through to AnalyticSolution and matched `stress=` and `strainrate=` in the base class's own signature. It reported True and the assertion failed. set_fields now records what it was actually handed, as publishes_both_stress_and_strainrate, defaulting to False for any solution that bypasses it. The test reads that instead of the source text. This matters beyond the false positive: the flag is what says whether the conformance check sigma + p I == 2 eta edot is evidence or bookkeeping. Where only one of the two was supplied, set_fields derived the other from exactly that identity, so the check re-reads a derivation. Underworld development team with AI support from Claude Code --- src/underworld3/analytic/_base.py | 23 +++++++++++++++++++++++ tests/test_1024_analytic_conformance.py | 25 ++++++++----------------- 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/src/underworld3/analytic/_base.py b/src/underworld3/analytic/_base.py index 9139ef615..43b09a3d4 100644 --- a/src/underworld3/analytic/_base.py +++ b/src/underworld3/analytic/_base.py @@ -108,6 +108,22 @@ class AnalyticSolution(uw_object): #: than a convention one. See the table in the subsystem documentation. stress_is_deviatoric = False + #: Whether the source published BOTH a stress and a strain rate, set by + #: :meth:`set_fields` from what it was actually given. + #: + #: This decides what the conformance check + #: ``sigma + p I == 2 eta edot`` is worth. When only one of the two was + #: supplied, :meth:`set_fields` derived the other from exactly that identity, + #: so the check is structural rather than evidential — it re-reads a + #: derivation. When both were supplied it compares two separately published + #: quantities and is a real check. + #: + #: Recorded here rather than inferred from the source text: a solution that + #: bypasses :meth:`set_fields` altogether keeps the ``False`` default, which + #: is the honest answer for it and the one a source scan gets wrong (it finds + #: the parameter names in this class's own signature). + publishes_both_stress_and_strainrate = False + eqn_velocity = "" eqn_pressure = "" eqn_viscosity = "" @@ -223,6 +239,13 @@ def set_fields( self.fn_pressure = sympy.sympify(pressure) self.fn_viscosity = sympy.sympify(viscosity) + # What the source actually gave us, not what the class says it gives. + # See the attribute's docstring: this is what tells the conformance + # sweep whether its stress/strain-rate check is evidence or bookkeeping. + self.publishes_both_stress_and_strainrate = ( + stress is not None and strainrate is not None + ) + identity = sympy.eye(self.dim) if stress is not None: diff --git a/tests/test_1024_analytic_conformance.py b/tests/test_1024_analytic_conformance.py index 2a2792df7..e2def325e 100644 --- a/tests/test_1024_analytic_conformance.py +++ b/tests/test_1024_analytic_conformance.py @@ -185,30 +185,21 @@ def test_strain_rate_matches_the_velocity(name, built): PUBLISH_BOTH = {"SolNL", "SolDB2d", "SolDB3d"} -def test_the_list_of_solutions_publishing_both_is_accurate(): +def test_the_list_of_solutions_publishing_both_is_accurate(built): """PUBLISH_BOTH is a claim about the sources; check it against them. A solution that starts supplying both — or stops — silently changes what the gate below is worth, so the claim is asserted rather than commented. - """ - - import inspect - from underworld3 import analytic + The fact is read from `set_fields`, which records what it was handed, rather + than scraped from the source text. Scraping gets it wrong in the one case + that matters: `EllipticalInclusion` never calls `set_fields` at all, and a + walk up the MRO finds `stress=` and `strainrate=` in *this base class's own + signature*. + """ for name in STOKES: - cls = getattr(analytic, name) - source = "" - for klass in cls.__mro__: - try: - text = inspect.getsource(klass) - except (OSError, TypeError): - continue - if "set_fields(" in text: - source = text - break - - both = "stress=" in source and "strainrate=" in source + both = built[name].publishes_both_stress_and_strainrate assert both == (name in PUBLISH_BOTH), ( f"{name}: publishes both = {both}, but PUBLISH_BOTH says " f"{name in PUBLISH_BOTH}" From 01f542ae8784cf0ebd4ac64b08337f2039239f71 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sat, 15 Aug 2026 16:41:21 +1000 Subject: [PATCH 28/28] Split the analytic suite into a per-PR tier and a full-family tier PR #571 did not fail, it TIMED OUT: CI cancelled at 1h00m22s against a 60-minute cap, with zero FAILED lines and the analytic files at 81% and passing. The suite costs 26.6 minutes in CI, and the run without it was already 55m07s. PROFILED FIRST. The cost is not solving -- there is exactly one Stokes solve in the whole suite. It is symbolic: every residual gate differentiates the solution's expressions and runs common-subexpression elimination over the result, and five solutions produce expressions with tens of thousands of operations. Measured over 1010s: test_1028 parameter sweep 416s SolDA 187s SolCx 14s test_1023 SolKz 189s SolKz 187s SolB 2s test_1024 conformance 169s SolKx 72s SolNL 0.7s test_1019 transcription 100s SolH 52s SolDB3d 0.6s test_1021 SolKx 88s SolC 35s SolA 0.5s everything else 48s Elliptical 34s SolM 0.2s The five expensive ones are 565s; the other eight together are about 17s. The solutions that historically caught defects -- SolA, SolM, SolNL, SolDB2d/3d -- are all in the cheap group, which is what makes the split affordable. CHEAPER WITHOUT LOSING ANYTHING. `momentum_residual` sampled the SYMBOLIC sum of its terms and then sampled each term again for the scale. The sum is the expensive one -- CSE over several nearly-cancelling series expressions -- and it was redundant: the terms are sampled anyway, so the residual can be summed NUMERICALLY. Same numbers to round-off, eight orders of margin against the 1e-8 gate, negative control still 2.000. Applied to the momentum, incompressibility, transport, diffusion and strain-rate gates. The two single-solution files also now cache their constructions instead of rebuilding per test (SolKz costs 6s to build, SolDA 12s). THE SPLIT, BY FILE PLACEMENT. scripts/test.sh batches by file GLOB, not by marker, so a `slow` marker alone would need every batch line to remember to deselect it. tests/analytic_full/ is a subdirectory, and the globs do not recurse, so it is excluded by construction; the level_2 mark additionally keeps it out of `pytest -m "level_1 and tier_a" tests/`, which does recurse. Verified all three collection paths. Solutions declare their own side of it (`expensive_to_validate`), so the tiers partition the family from one source of truth. Two guards: test_1024 asserts every solution it skips is NAMED in the full-family file, and that file asserts its hand-written list matches the declarations. NO GUARD WEAKENED. The momentum residual, incompressibility, tracelessness, strain-rate consistency and the body-force negative control all run in BOTH tiers on every solution that tier covers. The reduction is solutions per run, never checks per solution. per-PR analytic 16m50s -> 4m07s 307 passed CI batch 101*/102* 20m30s -> 7m30s 446 passed full family (opt-in) 8m34s 189 passed Underworld development team with AI support from Claude Code --- .../subsystems/analytic-solutions.md | 41 +++ src/underworld3/analytic/_base.py | 17 ++ src/underworld3/analytic/_validation.py | 67 +++-- src/underworld3/analytic/velic.py | 10 + .../test_analytic_full_family.py | 275 ++++++++++++++++++ tests/test_1021_analytic_solkx.py | 32 +- tests/test_1023_analytic_solkz.py | 40 ++- tests/test_1024_analytic_conformance.py | 38 ++- tests/test_1028_analytic_parameter_sweep.py | 16 +- 9 files changed, 498 insertions(+), 38 deletions(-) create mode 100644 tests/analytic_full/test_analytic_full_family.py diff --git a/docs/developer/subsystems/analytic-solutions.md b/docs/developer/subsystems/analytic-solutions.md index e255abf15..2fe4ae6b4 100644 --- a/docs/developer/subsystems/analytic-solutions.md +++ b/docs/developer/subsystems/analytic-solutions.md @@ -376,6 +376,47 @@ by default multiplies a term nothing ever looks at. Any new solution needs an entry in that file's `SWEEP` table, and the file asserts that every registered Stokes solution has one. +## Two test tiers, and how to run the slow one + +The residual gates are not cheap. Every one of them differentiates the +solution's expressions symbolically and runs common-subexpression elimination +over the result, and five solutions produce expressions with tens of thousands +of operations — SolC accumulates over forty modes, SolDA and SolH over several +more, and SolKx and SolKz carry an exponential viscosity. + +Measured: those five cost **565s of the suite's 1010s**; the other eight +together cost about 17s. CI was already within five minutes of its 60-minute cap +before this suite existed, so they cannot ride on every PR. + +| tier | what it covers | cost | where | +|---|---|---|---| +| per-PR | every gate, on every solution that is cheap to validate; one canonical parameter case for SolKx/SolKz | **4m07s** | `tests/test_101[5-9]_analytic_*`, `tests/test_102[0-8]_analytic_*` — matched by the CI batch globs | +| full family | every gate, on every solution, over the whole parameter table | **8m34s** | `tests/analytic_full/` — matched by nothing in CI | + +```bash +# the full family — before a release, and after touching +# underworld3/analytic/ or _validation.py +pixi run -e amr-dev python -m pytest tests/analytic_full/ -v +``` + +**The split is by solutions per run, never by checks per solution.** The +momentum residual, incompressibility, tracelessness, strain-rate consistency and +the body-force negative control all run in both tiers, on every solution that +tier covers. Those are the gates that caught the four errata above, and none of +them is weakened by the split. + +Solutions declare their own side of it — `expensive_to_validate` on the class — +so the two tiers partition the family from one source of truth. Two guards keep +that honest: `test_1024` asserts that every solution it skips is *named* in the +full-family file, and the full-family file asserts its own hand-written list +matches the declarations. + +`tests/analytic_full/` is a **subdirectory** rather than a marked file because +`scripts/test.sh` batches by file glob (`tests/test_101*py`, ...) and not by +marker — a `slow` marker alone would need every batch line to remember to +deselect it, whereas the globs do not recurse. The `level_2` mark on the file +additionally keeps it out of `pytest -m "level_1 and tier_a" tests/`, which does. + Two cheap signatures tell them apart, and both are worth running on any new kernel: diff --git a/src/underworld3/analytic/_base.py b/src/underworld3/analytic/_base.py index 43b09a3d4..8bd5ee775 100644 --- a/src/underworld3/analytic/_base.py +++ b/src/underworld3/analytic/_base.py @@ -108,6 +108,23 @@ class AnalyticSolution(uw_object): #: than a convention one. See the table in the subsystem documentation. stress_is_deviatoric = False + #: Whether validating this solution is expensive enough to keep out of the + #: per-PR test sweep. + #: + #: The cost is intrinsic and predictable from the mathematics rather than a + #: property of any machine: a solution that accumulates over modes (SolC + #: sums forty) or carries an exponential viscosity (SolKx, SolKz) produces + #: expressions with tens of thousands of operations, and every residual gate + #: differentiates them symbolically and runs common-subexpression + #: elimination over the result. Measured, the five solutions flagged here + #: account for 565s of the suite's 1010s while the other eight together cost + #: about 17s. + #: + #: This changes WHICH SOLUTIONS a given run covers, never which checks are + #: applied to a solution. The full family, gates and all, runs from + #: ``tests/analytic_full/``. + expensive_to_validate = False + #: Whether the source published BOTH a stress and a strain rate, set by #: :meth:`set_fields` from what it was actually given. #: diff --git a/src/underworld3/analytic/_validation.py b/src/underworld3/analytic/_validation.py index ebc8e87dd..86ff28b4e 100644 --- a/src/underworld3/analytic/_validation.py +++ b/src/underworld3/analytic/_validation.py @@ -193,10 +193,12 @@ def incompressibility_residual(solution, points): coordinates = solution.mesh.X velocity = solution.fn_velocity - divergence = sum( - sympy.diff(velocity[0, i], coordinates[i]) for i in range(solution.mesh.dim) + # Summed numerically rather than symbolically — see the note in + # `momentum_residual`. Same argument, same round-off. + values = sum( + sample(solution, sympy.diff(velocity[0, i], coordinates[i]), points) + for i in range(solution.mesh.dim) ) - values = sample(solution, divergence, points) return float(np.max(np.abs(values))) @@ -222,16 +224,30 @@ def momentum_residual(solution, points): terms = [sympy.diff(stress[i, j], coordinates[j]) for j in range(dim)] terms.append(bodyforce[0, i]) - residual = sum(terms) - worst = max(worst, float(np.max(np.abs(sample(solution, residual, points))))) + # Sample each term and add the ARRAYS, rather than sampling the symbolic + # sum. The two agree to round-off, and the terms have to be sampled + # individually anyway for the scale below — so the symbolic sum was pure + # extra cost, and a large one: `sample` runs common-subexpression + # elimination, which on the sum of several nearly-cancelling series + # expressions is far more expensive than on any one of them. For SolKz it + # was 14.9s of the gate's 27.5s. + # + # Round-off is the right thing to measure here in any case. The residual + # is a cancellation of O(1) terms down to ~1e-16 relative, and the gate + # sits at 1e-8 — eight orders of margin — while flipping the body force + # sign moves it to order unity. Summing symbolically can cancel to an + # exact zero where summing numerically leaves 1e-16; both are the same + # statement about the mathematics, and the second is the honest one. + sampled = [sample(solution, term, points) for term in terms] + worst = max(worst, float(np.max(np.abs(sum(sampled))))) # Scale by the largest term being cancelled, not by the body force. A # solution driven entirely by its boundary has no body force at all — the # elliptical inclusion is one — and normalising by it divides by zero. # The size of the terms is also the right yardstick for a cancellation: # it says how many digits actually had to cancel. - for term in terms: - scale = max(scale, float(np.max(np.abs(sample(solution, term, points))))) + for values in sampled: + scale = max(scale, float(np.max(np.abs(values)))) return worst / max(scale, 1.0e-300) @@ -255,10 +271,10 @@ def transport_residual(solution, points): terms = [sympy.diff(flux[i], coordinates[i]) for i in range(dim)] terms.append(solution.fn_source) - worst = float(np.max(np.abs(sample(solution, sum(terms), points)))) - scale = max( - float(np.max(np.abs(sample(solution, term, points)))) for term in terms - ) + # Summed numerically — see the note in `momentum_residual`. + sampled = [sample(solution, term, points) for term in terms] + worst = float(np.max(np.abs(sum(sampled)))) + scale = max(float(np.max(np.abs(values))) for values in sampled) return worst / max(scale, 1.0e-300) @@ -294,14 +310,24 @@ def diffusion_residual(solution, points, time): for i in range(dim) ) + # Combined numerically — see the note in `momentum_residual`. at_time = {solution.t: time} - residual = (rate + carried - spread).subs(at_time) - worst = float(np.max(np.abs(sample(solution, residual, points)))) - scale = max( - float(np.max(np.abs(sample(solution, term.subs(at_time), points)))) - for term in (rate, carried, spread) + values = { + name: sample(solution, term.subs(at_time), points) + for name, term in (("rate", rate), ("carried", carried), ("spread", spread)) if term != 0 + } + + worst = float( + np.max( + np.abs( + values.get("rate", 0.0) + + values.get("carried", 0.0) + - values.get("spread", 0.0) + ) + ) ) + scale = max(float(np.max(np.abs(v))) for v in values.values()) return worst / max(scale, 1.0e-300) @@ -329,12 +355,15 @@ def strainrate_consistency(solution, points): sympy.diff(velocity[0, i], coordinates[j]) + sympy.diff(velocity[0, j], coordinates[i]) ) / 2 - difference = from_velocity - solution.fn_strainrate[i, j] - values = sample(solution, difference, points) + # Differenced numerically, not symbolically — see `momentum_residual`. + # The reference has to be sampled anyway for the scale, so forming + # the symbolic difference only added a second, larger expression to + # run CSE over. + mine = sample(solution, from_velocity, points) reference = sample(solution, solution.fn_strainrate[i, j], points) - worst = max(worst, float(np.max(np.abs(values)))) + worst = max(worst, float(np.max(np.abs(mine - reference)))) scale = max(scale, float(np.max(np.abs(reference)))) return worst / max(scale, 1.0e-300) diff --git a/src/underworld3/analytic/velic.py b/src/underworld3/analytic/velic.py index 307820416..c97205e19 100644 --- a/src/underworld3/analytic/velic.py +++ b/src/underworld3/analytic/velic.py @@ -568,6 +568,8 @@ class SolKx(FreeSlipWalls, AnalyticSolution): """ dim = 2 + # exponential viscosity e^{2Bx} -> tens of thousands of operations per expression + expensive_to_validate = True reference = ( "Velic; transcribed from PETSc src/snes/tutorials/ex69.c (SolKxSolution), " "vendored at underworld3/analytic/_reference/solKx.c (BSD-2-Clause)." @@ -855,6 +857,8 @@ class SolKz(FreeSlipWalls, AnalyticSolution): """ dim = 2 + # exponential viscosity e^{2Bz} -> tens of thousands of operations per expression + expensive_to_validate = True stress_is_deviatoric = True reference = ( "Velic. Transcribed from the published kernel vendored at " @@ -1353,6 +1357,8 @@ class SolC(FreeSlipWalls, AnalyticSolution): """ dim = 2 + # accumulates over 40 modes -> tens of thousands of operations per expression + expensive_to_validate = True reference = ( "Velic. Transcribed from the published kernel vendored at " "underworld3/analytic/_reference/solC.c." @@ -1546,6 +1552,8 @@ class SolDA(FreeSlipWalls, AnalyticSolution): """ dim = 2 + # accumulates over modes, with a viscosity step -> tens of thousands of operations per expression + expensive_to_validate = True reference = ( "Velic. Transcribed from the published kernel vendored at " "underworld3/analytic/_reference/solDA.c." @@ -1722,6 +1730,8 @@ class SolH(FreeSlipWalls, AnalyticSolution): """ dim = 3 + # accumulates over modes in 3-D -> tens of thousands of operations per expression + expensive_to_validate = True reference = ( "Velic. Transcribed from the published kernel vendored at " "underworld3/analytic/_reference/solH.c." diff --git a/tests/analytic_full/test_analytic_full_family.py b/tests/analytic_full/test_analytic_full_family.py new file mode 100644 index 000000000..e83c2daea --- /dev/null +++ b/tests/analytic_full/test_analytic_full_family.py @@ -0,0 +1,275 @@ +r"""The whole analytic family, every gate, every parameter case — the slow tier. + +**Run it with one command:** + +```bash +pixi run -e amr-dev python -m pytest tests/analytic_full/ -v +``` + +Nothing in CI runs this file, and that is deliberate rather than an oversight. +Run it before a release, and whenever you touch `underworld3/analytic/` or +`_validation.py`. + +Why it is separate +------------------ + +The per-PR sweep (`tests/test_1024_analytic_conformance.py` and +`tests/test_1028_analytic_parameter_sweep.py`) covers every solution that is +cheap to validate. Five are not: SolC accumulates over forty modes, SolDA and +SolH over several more, and SolKx and SolKz carry an exponential viscosity. Each +produces expressions with tens of thousands of operations, and every residual +gate differentiates them symbolically and runs common-subexpression elimination +over the result. + +Measured on a quiet machine, those five cost **565s of the analytic suite's +1010s**, against about 17s for the other eight together. CI was already within +five minutes of its 60-minute cap before this suite existed, so they cannot ride +on every PR. + +They declare the fact themselves — `AnalyticSolution.expensive_to_validate` — +so this file and the per-PR files partition the family from one source of truth +rather than from two lists that can drift. `test_1024` asserts that every +solution it skips is named here. + +**The split is by solutions per run, never by checks per solution.** Every gate +that runs in the per-PR tier runs here too, on every solution: the momentum +residual, incompressibility, tracelessness, strain-rate consistency, and the +body-force negative control. Those are the gates that caught SolA's missing +viscosity, SolM's wrong published stress, SolC's density-not-force and the +elliptical inclusion's unscaled pressure, and none of them is weakened here. + +Why file placement rather than a marker +--------------------------------------- + +`scripts/test.sh` batches by **file glob** (`tests/test_101*py`, +`tests/test_102*py`, ...), not by marker, so a `slow` marker alone would not keep +this out of CI — every batch line would have to remember to deselect it. Those +globs do not recurse, so a subdirectory is excluded by construction. The +`level_2` mark below additionally keeps it out of +`pytest -m "level_1 and tier_a" tests/`, which *does* recurse. +""" + +import pytest + +pytestmark = [pytest.mark.level_2, pytest.mark.tier_a, pytest.mark.slow] + +import numpy as np +import sympy +import underworld3 as uw +from underworld3.analytic import _validation + + +ALL_SYMBOLIC = sorted( + name + for name in uw.analytic.available() + if getattr(uw.analytic, name).symbolic and uw.analytic.is_available(name) +) + +STOKES = [n for n in ALL_SYMBOLIC if getattr(uw.analytic, n).solves == "stokes"] +TRANSPORT = [n for n in ALL_SYMBOLIC if getattr(uw.analytic, n).solves == "transport"] +RICHARDS = [n for n in ALL_SYMBOLIC if getattr(uw.analytic, n).solves == "richards"] + +# The five that the per-PR sweep drops. Named explicitly so `test_1024` can +# assert they are covered here, and so that reading this file tells you what it +# is for without having to evaluate a comprehension. +EXPENSIVE = ["SolC", "SolDA", "SolH", "SolKx", "SolKz"] + +# The complete parameter table. The cheap solutions' entries duplicate +# `test_1028`'s on purpose: this file is the full sweep, and it should not depend +# on which half of the family happens to be cheap this month. +SWEEP = { + "SolA": [{"eta": 3.0}, {"eta": 0.25}, {"eta": 3.0, "n": 2, "m": 1.5}], + "SolB": [{"eta": 3.0}, {"eta": 0.25}, {"eta": 3.0, "n": 2, "m": 1.5}], + "SolC": [{"eta": 3.0}, {"eta": 0.25, "x_c": 0.3}], + "SolCx": [{"eta_A": 3.0, "eta_B": 0.1}, {"eta_A": 1.0e4, "eta_B": 1.0, "x_c": 0.3}], + "SolDA": [{"eta_A": 2.0, "eta_B": 0.5}, {"sigma": 3.0, "z_c": 0.4}], + "SolDB2d": [], + "SolDB3d": [{"beta": 1.0}, {"beta": 8.0}], + "SolH": [{"eta": 3.0}, {"eta": 0.25, "dx": 0.3, "dy": 0.4}], + # exactly the cases test_1021 dropped from its per-PR CASES + "SolKx": [{"B": 1.0, "n": 2, "m": 1}, {"B": 4.0, "n": 1, "m": 3}, {"B": 5.0, "n": 2, "m": 2}], + # exactly the cases test_1023 dropped from its per-PR CASES + "SolKz": [{"B": 1.0, "n": 2, "m": 1}, {"B": 4.0, "n": 1, "m": 3}, {"B": 5.0, "n": 2, "m": 2}], + "SolM": [{"eta_0": 3.0}, {"eta_0": 0.5, "n": 2, "m": 1, "r": 3.0}], + "SolNL": [{"eta_0": 2.0}, {"r": 2.5}], + "EllipticalInclusion": [ + {"viscosity_ratio": 10.0, "aspect_ratio": 1.5}, + {"matrix_viscosity": 3.0}, + {"matrix_viscosity": 0.5, "viscosity_ratio": 100.0}, + ], +} + +# Defaults first, then every off-default case. +CASES = [(name, {}) for name in STOKES] + [ + (name, kw) for name in STOKES for kw in SWEEP.get(name, []) +] + + +def test_the_expensive_list_is_accurate(): + """`EXPENSIVE` is a hand-written list; check it against the declarations.""" + + declared = sorted( + n for n in ALL_SYMBOLIC if getattr(uw.analytic, n).expensive_to_validate + ) + assert declared == sorted(EXPENSIVE), ( + f"declared expensive_to_validate = {declared}, but EXPENSIVE says {EXPENSIVE}" + ) + + +def test_this_file_sweeps_the_whole_family(): + """Nothing is dropped from BOTH tiers.""" + + assert set(SWEEP) == set(STOKES), ( + f"unswept: {set(STOKES) - set(SWEEP)}; stale: {set(SWEEP) - set(STOKES)}" + ) + assert len(ALL_SYMBOLIC) >= 19, "the sweep has lost solutions" + + +@pytest.fixture(scope="module") +def meshes(): + return { + 2: uw.meshing.StructuredQuadBox( + elementRes=(4, 4), minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), qdegree=3 + ), + 3: uw.meshing.StructuredQuadBox( + elementRes=(2, 2, 2), + minCoords=(0.0, 0.0, 0.0), + maxCoords=(1.0, 1.0, 1.0), + qdegree=2, + ), + } + + +@pytest.fixture(scope="module") +def cache(): + """One construction per (solution, parameters), shared across the gates. + + Building a series solution substitutes parameters into expressions tens of + thousands of operations long — SolDA takes 12s and SolKz 6s. Four gates run + over every case here, so rebuilding per test would multiply the file's cost + by four for nothing. + """ + + return {} + + +def _built(cache, meshes, name, kw): + key = (name, tuple(sorted(kw.items()))) + if key not in cache: + cls = getattr(uw.analytic, name) + cache[key] = cls(meshes[cls.dim], **kw) + return cache[key] + + +IDS = [f"{n}-{kw}" for n, kw in CASES] + + +@pytest.mark.parametrize("name,kw", CASES, ids=IDS) +def test_momentum_balance(cache, meshes, name, kw): + r""":math:`\nabla\cdot\sigma + \mathbf f = 0`.""" + + sol = _built(cache, meshes, name, kw) + assert _validation.momentum_residual(sol, sol.sample_points(count=8)) < 1.0e-8 + + +@pytest.mark.parametrize("name,kw", CASES, ids=IDS) +def test_incompressible(cache, meshes, name, kw): + sol = _built(cache, meshes, name, kw) + assert _validation.incompressibility_residual(sol, sol.sample_points(count=8)) < 1.0e-8 + + +@pytest.mark.parametrize("name,kw", CASES, ids=IDS) +def test_deviator_is_traceless(cache, meshes, name, kw): + r""":math:`\mathrm{tr}(\sigma + p\mathbf I) = 0`. + + Independent of the body force, which is what made it the gate that localised + the SolA erratum. + """ + + sol = _built(cache, meshes, name, kw) + points = sol.sample_points(count=8) + deviator = sol.fn_stress + sol.fn_pressure * sympy.eye(sol.dim) + + sampled = [ + _validation.sample(sol, deviator[i, i], points) for i in range(sol.dim) + ] + scale = max(float(np.abs(v).max()) for v in sampled) + + assert float(np.abs(sum(sampled)).max()) / max(scale, 1e-300) < 1.0e-8 + + +@pytest.mark.parametrize("name,kw", CASES, ids=IDS) +def test_strain_rate_matches_the_velocity(cache, meshes, name, kw): + r""":math:`\tfrac12(\nabla\mathbf u + \nabla\mathbf u^{T})` vs `fn_strainrate`.""" + + sol = _built(cache, meshes, name, kw) + assert _validation.strainrate_consistency(sol, sol.sample_points(count=8)) < 1.0e-8 + + +FORCED = [n for n in STOKES if n != "EllipticalInclusion"] + + +@pytest.mark.parametrize("name", FORCED) +def test_flipping_the_body_force_breaks_the_momentum_balance(cache, meshes, name): + r"""The momentum gate must REJECT :math:`\nabla\cdot\sigma - \mathbf f = 0`. + + The negative control, run here over the whole family. Without it the momentum + gate is only an assertion that a small number is small. + + EllipticalInclusion is excluded because it is boundary-driven and has no body + force to flip — asserted below rather than skipped silently. + """ + + sol = _built(cache, meshes, name, {}) + points = sol.sample_points(count=8) + + assert _validation.momentum_residual(sol, points) < 1.0e-8 + + keep = sol.fn_bodyforce + sol.fn_bodyforce = sympy.Matrix([[-c for c in keep]]) + try: + flipped = _validation.momentum_residual(sol, points) + finally: + sol.fn_bodyforce = keep + + assert flipped > 1.0e-2, ( + f"{name}: flipping the body force left the momentum residual at " + f"{flipped:.3e} — the gate cannot see the sign it is supposed to certify" + ) + + +def test_the_inclusion_really_has_no_body_force(cache, meshes): + """The stated grounds for excluding it from the negative control above.""" + + sol = _built(cache, meshes, "EllipticalInclusion", {}) + assert all(component == 0 for component in sol.fn_bodyforce) + + +@pytest.mark.parametrize("name", TRANSPORT) +def test_transport_solution_satisfies_its_equation(cache, meshes, name): + """Steady or transient, whichever the solution declares — as in `test_1024`.""" + + sol = _built(cache, meshes, name, {}) + points = sol.sample_points(count=8) + + if getattr(sol, "t", None) is None: + assert _validation.transport_residual(sol, points) < 1.0e-10 + return + + for time in (0.05, 0.2, 0.5): + assert _validation.diffusion_residual(sol, points, time) < 1.0e-10 + + +@pytest.mark.parametrize("name", RICHARDS) +def test_richards_solution_satisfies_its_equation(cache, meshes, name): + r""":math:`C(\psi)\partial_t\psi = \nabla\cdot[K(\psi)(\nabla\psi + \hat y)]`.""" + + sol = _built(cache, meshes, name, {}) + points = sol.sample_points(count=8) + + if getattr(sol, "t", None) is None: + assert _validation.richards_residual(sol, points) < 1.0e-10 + return + + for time in (0.05, 0.2): + assert _validation.richards_residual(sol, points, time) < 1.0e-10 diff --git a/tests/test_1021_analytic_solkx.py b/tests/test_1021_analytic_solkx.py index 31eeaabc0..3a4ac62df 100644 --- a/tests/test_1021_analytic_solkx.py +++ b/tests/test_1021_analytic_solkx.py @@ -25,14 +25,21 @@ # B = 2.3026 is a decade of viscosity contrast per unit length, so e^2B ~ 100 # across the box; B = 5 is four orders. Both wavenumbers, integer and not. +# The canonical case only, on every PR. SolKx declares +# `expensive_to_validate`: an exponential viscosity makes every residual +# here a symbolic differentiation of a very large expression, and the four +# cases together were 88s of the analytic suite's 1010s. +# +# The remaining cases below are not dropped — they run in +# tests/analytic_full/, which sweeps this solution over its parameter table +# with the same residual gates. Every CHECK in this file still runs on every +# PR; what is reduced is how many parameter values it runs on. CASES = [ (2.302585092994046, 3, 2), - (2.302585092994046, 1, 1), - (5.0, 2, 3), - (1.0, 4, 2), ] + @pytest.fixture(scope="module") def mesh(): return uw.meshing.StructuredQuadBox( @@ -48,6 +55,25 @@ def mesh(): TOP = np.array([(t, 1.0) for t in (0.13, 0.47, 0.82)]) +@pytest.fixture(scope="module") +def cache(): + """One construction per (B, n, m), shared across the gates below. + + Constructing SolKx substitutes parameters into an expression tens of + thousands of operations long. Two gates run over every case, so building + afresh in each doubled the file's cost for nothing. + """ + + return {} + + +def _sol(cache, mesh, B, n, m): + key = (B, n, m) + if key not in cache: + cache[key] = uw.analytic.SolKx(mesh, B=B, n=n, m=m) + return cache[key] + + def _at(sol, expression, points): """Values of an expression of the mesh coordinates, over a whole point set. diff --git a/tests/test_1023_analytic_solkz.py b/tests/test_1023_analytic_solkz.py index 59b683962..c8cacfd72 100644 --- a/tests/test_1023_analytic_solkz.py +++ b/tests/test_1023_analytic_solkz.py @@ -27,13 +27,20 @@ import underworld3 as uw +# The canonical case only, on every PR. SolKz declares +# `expensive_to_validate`: an exponential viscosity makes every residual +# here a symbolic differentiation of a very large expression, and the four +# cases together were 189s of the analytic suite's 1010s. +# +# The remaining cases below are not dropped — they run in +# tests/analytic_full/, which sweeps this solution over its parameter table +# with the same residual gates. Every CHECK in this file still runs on every +# PR; what is reduced is how many parameter values it runs on. CASES = [ (2.302585092994046, 3, 2), - (1.0, 2, 1), - (4.0, 1, 3), - (5.0, 2, 2), ] + INTERIOR = np.array([(0.2, 0.3), (0.7, 0.8), (0.5, 0.5), (0.9, 0.15), (0.05, 0.95)]) WALLS = { "left": np.array([(0.0, t) for t in (0.13, 0.47, 0.82)]), @@ -50,6 +57,25 @@ def mesh(): ) +@pytest.fixture(scope="module") +def cache(): + """One construction per (B, n, m), shared across the gates below. + + Constructing SolKz substitutes parameters into an expression tens of + thousands of operations long. Two gates run over every case, so building + afresh in each doubled the file's cost for nothing. + """ + + return {} + + +def _sol(cache, mesh, B, n, m): + key = (B, n, m) + if key not in cache: + cache[key] = uw.analytic.SolKz(mesh, B=B, n=n, m=m) + return cache[key] + + def _at(sol, expression, points): """Magnitudes over a whole point set — lambdified once, not once per point.""" @@ -59,8 +85,8 @@ def _at(sol, expression, points): @pytest.mark.parametrize("B,n,m", CASES) -def test_solkz_satisfies_the_stokes_equations(mesh, B, n, m): - sol = uw.analytic.SolKz(mesh, B=B, n=n, m=m) +def test_solkz_satisfies_the_stokes_equations(cache, mesh, B, n, m): + sol = _sol(cache, mesh, B, n, m) x, z = mesh.X scale = _at(sol, sol.fn_bodyforce[0, 1], INTERIOR).max() @@ -89,8 +115,8 @@ def test_solkz_satisfies_the_stokes_equations(mesh, B, n, m): @pytest.mark.parametrize("B,n,m", CASES) -def test_solkz_is_free_slip_on_every_wall(mesh, B, n, m): - sol = uw.analytic.SolKz(mesh, B=B, n=n, m=m) +def test_solkz_is_free_slip_on_every_wall(cache, mesh, B, n, m): + sol = _sol(cache, mesh, B, n, m) vx, vz = sol.fn_velocity[0, 0], sol.fn_velocity[0, 1] assert _at(sol, vx, WALLS["left"]).max() < 1.0e-10 diff --git a/tests/test_1024_analytic_conformance.py b/tests/test_1024_analytic_conformance.py index e2def325e..209be181b 100644 --- a/tests/test_1024_analytic_conformance.py +++ b/tests/test_1024_analytic_conformance.py @@ -19,6 +19,8 @@ pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] +import pathlib + import numpy as np import sympy import underworld3 as uw @@ -34,17 +36,28 @@ # # The excluded names are asserted below, so an accidental exclusion — a typo in # a class attribute, say — fails here rather than quietly shrinking the sweep. -SOLUTIONS = sorted( +ALL_SYMBOLIC = sorted( name for name in uw.analytic.available() if getattr(uw.analytic, name).symbolic and uw.analytic.is_available(name) ) +# The per-PR sweep drops the five solutions that declare themselves expensive +# (see AnalyticSolution.expensive_to_validate). Measured, they are 565s of the +# suite's 1010s; the rest of the family together is about 17s. +# +# Every gate below still runs on every solution it covers — the reduction is in +# solutions per run, never in checks per solution. tests/analytic_full/ runs the +# whole family, gates and all, and is not matched by any CI batch glob. +SOLUTIONS = [n for n in ALL_SYMBOLIC if not getattr(uw.analytic, n).expensive_to_validate] + +SKIPPED_AS_EXPENSIVE = [n for n in ALL_SYMBOLIC if n not in SOLUTIONS] + def test_the_sweep_covers_everything_it_can(): """What this file skips, and on what declared grounds.""" - excluded = set(uw.analytic.available()) - set(SOLUTIONS) + excluded = set(uw.analytic.available()) - set(ALL_SYMBOLIC) for name in excluded: solution = getattr(uw.analytic, name) @@ -52,7 +65,26 @@ def test_the_sweep_covers_everything_it_can(): f"{name} is symbolic and available but is not being swept" ) - assert len(SOLUTIONS) >= 19, "the sweep has lost solutions" + assert len(ALL_SYMBOLIC) >= 19, "the sweep has lost solutions" + + +def test_the_expensive_solutions_are_covered_somewhere(): + """The per-PR sweep drops solutions; something has to still cover them. + + Asserts the full-family file exists and names every solution this file skips, + so dropping one here cannot quietly drop it everywhere. That file is the one + place they run, and nothing in CI runs it — this is the link that keeps it + honest. + """ + + full = pathlib.Path(__file__).parent / "analytic_full" / "test_analytic_full_family.py" + assert full.exists(), f"the full-family sweep is missing: {full}" + + text = full.read_text() + for name in SKIPPED_AS_EXPENSIVE: + assert name in text, ( + f"{name} is skipped here as expensive but is not named in {full.name}" + ) @pytest.fixture(scope="module") diff --git a/tests/test_1028_analytic_parameter_sweep.py b/tests/test_1028_analytic_parameter_sweep.py index 09a1c86c5..b2db04023 100644 --- a/tests/test_1028_analytic_parameter_sweep.py +++ b/tests/test_1028_analytic_parameter_sweep.py @@ -36,12 +36,7 @@ SWEEP = { "SolA": [{"eta": 3.0}, {"eta": 0.25}, {"eta": 3.0, "n": 2, "m": 1.5}], "SolB": [{"eta": 3.0}, {"eta": 0.25}, {"eta": 3.0, "n": 2, "m": 1.5}], - "SolC": [{"eta": 3.0}, {"eta": 0.25, "x_c": 0.3}], - "SolH": [{"eta": 3.0}, {"eta": 0.25, "dx": 0.3, "dy": 0.4}], "SolCx": [{"eta_A": 3.0, "eta_B": 0.1}, {"eta_A": 1.0e4, "eta_B": 1.0, "x_c": 0.3}], - "SolDA": [{"eta_A": 2.0, "eta_B": 0.5}, {"sigma": 3.0, "z_c": 0.4}], - "SolKx": [{"B": 1.0, "n": 2, "m": 1.0}, {"B": 4.0}], - "SolKz": [{"B": 1.0, "n": 2, "m": 1.0}, {"B": 4.0}], "SolM": [{"eta_0": 3.0}, {"eta_0": 0.5, "n": 2, "m": 1, "r": 3.0}], "SolNL": [{"eta_0": 2.0}, {"r": 2.5}], "SolDB2d": [], # a manufactured solution with no free parameters @@ -53,7 +48,7 @@ ], } -STOKES = sorted( +ALL_STOKES = sorted( name for name in uw.analytic.available() if getattr(uw.analytic, name).symbolic @@ -61,6 +56,11 @@ and getattr(uw.analytic, name).solves == "stokes" ) +# SolC, SolDA, SolH, SolKx and SolKz declare `expensive_to_validate` and are +# swept in tests/analytic_full/ instead — same table, same gates, just not on +# every PR. See that file, and the note on the class attribute, for the cost. +STOKES = [n for n in ALL_STOKES if not getattr(uw.analytic, n).expensive_to_validate] + CASES = [(name, kw) for name in STOKES for kw in SWEEP.get(name, [])] @@ -70,6 +70,10 @@ def test_every_stokes_solution_is_swept(): SolDB2d is listed with an empty sweep rather than omitted: "this one has no parameters" is a claim worth recording, and it is different from "nobody got round to it". + + A solution that declares itself expensive belongs in the full-family table + rather than this one, so it must be absent here and present there — the + conformance file asserts the second half. """ assert set(SWEEP) == set(STOKES), (