Skip to content

The analytic solution suite: uw.analytic with the full Velic family, an oracle-free validation contract, and four fixed defects - #571

Merged
lmoresi merged 30 commits into
developmentfrom
feature/analytic-suite
Aug 15, 2026
Merged

The analytic solution suite: uw.analytic with the full Velic family, an oracle-free validation contract, and four fixed defects#571
lmoresi merged 30 commits into
developmentfrom
feature/analytic-suite

Conversation

@lmoresi

Copy link
Copy Markdown
Member

Land the analytic solution suite: the Velic family as uw.analytic, with an oracle-free validation contract

Branched from development, merging the long-stranded bugfix/analytic-module-rename.
That branch name is misleading — it long outgrew a rename. This is the suite
itself, plus a convention audit and four defect fixes found while landing it.

What this adds

A single namespace, uw.analytic, holding nineteen exact solutions behind one
contract:

  • The Velic Stokes familySolA, SolB, SolC, SolCx, SolDA,
    SolDB2d, SolDB3d, SolH, SolKx, SolKz, SolM, SolNL.
  • An elliptical inclusion (Schmid & Podladchikov 2003), derived rather than
    transcribed — the authors publish pressure, deviatoric stress and rotation
    rate but no velocity, so the Muskhelishvili potentials were recovered from the
    fields and the velocity built from those.
  • A cylindrical Stokes case (Kramer et al.) on an annulus.
  • Transport and Richards solutionsPoisson1D, ErfcDiffusion,
    AdvectedFront, TwoLayerDarcy, GardnerSteady, GardnerTransient.

Every one reads the same way:

sol=uw.analytic.SolKz(mesh, B=..., n=3, m=2)
stokes.constitutive_model.Parameters.shear_viscosity_0=sol.fn_viscositystokes.bodyforce=sol.fn_bodyforcesol.apply_boundary_conditions(stokes)
stokes.solve()
rel=sol.error("velocity", stokes.u)

available() and describe() list the family without constructing it.
Published kernels are vendored under analytic/_reference/ and transcribed
into SymPy
rather than compiled, so the solutions are differentiable,
JIT-compilable, and usable as solver input rather than only as point oracles.

SolKz is the immediate motivation: a smooth viscosity variation with a
known solution, which is what makes a resolution-convergence claim checkable.
development currently has only SolCx, whose viscosity is a step.

Validation posture — read this part

Most of the 11k lines are mathematics transcribed from published kernels, where
a sign error is invisible to a build and to any test that compares against the
same transcription. So the question that matters is not "do the tests pass" but
"can the tests fail".

The oracle is a residual that consults nothing.momentum_residual forms
$\nabla\cdot\sigma + \mathbf f$ by symbolically differentiating the solution's
own Cauchy stress and adding its own body force. It never touches the vendored
kernel and never touches the solver. The scalar families have counterparts
(transport_residual, diffusion_residual, richards_residual), and
incompressibility_residual and strainrate_consistency complete the set.

This is not tautological, and the design forbids it becoming so:
AnalyticSolution.set_fields takes the body force as an independently
transcribed input, and refuses a solution that supplies neither a stress nor
a strain rate — the one configuration in which the residual would be true by
construction.

The negative control fires. Flipping the sign of the body force moves the
momentum residual from $10^{-16}$ to between 0.5 and 2.0 for every solution that
has one. That is now asserted, not merely observed
(test_flipping_the_body_force_breaks_the_momentum_balance). EllipticalInclusion
is excluded by name because it is boundary-driven and has no body force to flip;
that exclusion is itself asserted.

Per-solution strength. All nineteen registered solutions are swept by
test_1024_analytic_conformance.py, which iterates available() so a solution
added later is covered the moment it is registered. Two exclusions exist and
both are declared by the solution, not listed by name: symbolic = False (an
external numeric oracle with nothing to differentiate — CylindricalStokes) and
requires = "..." for an uninstalled optional dependency. The sweep asserts
that every exclusion has one of those grounds, so a typo in a class attribute
fails the test rather than quietly shrinking coverage.

Where the validation is weaker, stated plainly.reference_agreement
compares a transcription against the kernel it came from. That tests the
transcription and not the mathematics: if the kernel is wrong, agreement is the
wrong answer. SolM is the worked example — see the errata.

One gate was partly true by construction, and is now labelled.
test_stress_and_strain_rate_agree asserts $\sigma + p\mathbf I = 2\eta\dot\varepsilon$.
For a solution that supplied only one of stress or strain rate, that is exactly
the identity set_fields used to derive the other — structural, not evidential.
Only SolNL, SolDB2d and SolDB3d supply both. The gate is kept (it would
catch set_fields regressing) but the docstring now says what it is worth, the
list of solutions publishing both is asserted against the sources rather than
commented, and the genuinely independent check — strainrate_consistency, which
compares $\tfrac12(\nabla\mathbf u + \nabla\mathbf u^T)$ against the strain rate
derived from the stress, two separate kernel outputs — has been promoted from a
single solution's file into the family-wide sweep.

The convention audit

Louis asked for the sign and stress conventions to be unified before this lands,
on the timing argument that nothing depends on them yet. The audit's finding is
that the exposed conventions are already uniform
— measured, not asserted — and
that the non-uniformity is entirely in the published sources, where it is
absorbed at a single declared boundary. The full table, the per-kernel
provenance, and the errata are in analytic_errata.md, written as prose for the
technical note. Summary:

UW3's own conventions win, and the suite already matches them:

quantityUW3where fixedsuite agrees
total stress$\sigma = \tau - p\mathbf I$SNES_Stokes.stressset_fields builds exactly this
pressurepositive in compressionsamemeasured: $\mathrm{tr},\sigma + d,p = 0$ to $10^{-15}$, all solutions
momentum$\nabla\cdot\sigma + \mathbf f = 0$F0 = -bodyforce, F1 = stressmomentum_residual forms precisely this
$\sigma_{nn}$along the domain's outward normalboundary_normal_traction (post-#561)see below

The sources are not uniform: four of thirteen publish the deviator, the rest
the total; component order varies (solKx.c uses [xx, xz, zz] where every
sibling uses [xx, zz, xz]); and several label the vertical velocity u1. All
of that is absorbed by the stress_is_deviatoric declaration and an explicit
component mapping, honoured in one place. Two things are uniform across every
source and neither is documented in most of them — both had to be measured by
finite-differencing the kernels' own outputs: the momentum sign, and pressure
positive in compression.

Errata (each recorded with its citation, not silently conformed)

  1. SolA's published $\sigma_{zz}$ is missing a factor of $Z$ (solA.c:156) —
    new, and it was live in this suite. See below.
  2. SolM's published stress is wrong (AnalyticSolM.hpp) — viscosity is
    $(1+\cos)\eta_0 + 1$, stress is computed as $2(\eta-1)\dot\varepsilon$.
    Published stress → residual 0.2117; derived from the kernel's own strain
    rate → 1.7e-16. Re-verified independently while landing this: the
    published deviator differs from $2\eta\dot\varepsilon$ by 0.333 relative and
    from $2(\eta-1)\dot\varepsilon$ by exactly zero, which is what makes it a
    defect in the source rather than a slip in transcription.
  3. SolC publishes a density, not a body force (solC.c) — as summed 1.848,
    negated 2.5e-16.
  4. SolKz's deviator/total boundary — this one corrects a statement of our
    own
    . The subsystem doc said solKz.c writes its deviator into an array named
    total_stress. As a claim about the C function that is wrong: it converts
    explicitly (u6 -= u5; /* get total stress */) and says so. What is true is a
    claim about the transcription's cut point — the transcriber stops at the
    first mode accumulation, which precedes the conversion, so what we capture is
    the deviator. stress_is_deviatoric = True is correct but describes the cut,
    not the kernel. Corrected in the errata.

Four defects fixed, all found by the oracle

Three were found by a single question: do the gates still hold away from the
default parameters?
The conformance sweep builds every solution from a mesh
alone, which is right — the defaults are part of the interface — but it means a
coefficient that is unity by default multiplies a term nothing ever looks at.

1. SolA is wrong for any viscosity other than 1 (the significant one)

solA.c:156 computes the $zz$ stress as u3 = 2.0*kn*ss_z - pp. The matching
line in solB.c:140 reads 2.0*Z*kn*ss_z - pp, and solA's own$xx$ stress on
the next line but one carries the Z. The shortfall is $\tau_{zz}(1-Z)/Z$,
identically zero at $Z=1$ — the only case the kernel's own disabled driver
exercised, and the default eta in our transcription.

etamomentumdeviator trace$\dot\varepsilon$ vs $\nabla\mathbf u$
1.0000
3.02.8e-16.7e-16.7e-1
0.256.4e-13.0e+07.5e-1

$|1-3|/3 = 0.667$ and $|1-0.25|/0.25 = 3.0$ — the predicted $(1-Z)/Z$ exactly.
Three independent gates, all silent at the default.

Repaired by restoring the missing factor on the term that lost it, declared per
solution (_zz_stress_lost_the_viscosity), leaving the vendored source verbatim.
Deriving $\sigma_{zz}$ from $\sigma_{xx}$ via tracelessness would also work and
was rejected: it would make the deviator traceless by construction and
silently retire one of the three gates that caught the defect. The test asserts
both halves — solA's published deviator is not traceless at $Z=3$, solB's is —
so the control says this is a correction to a defect, not a convention imposed
on the family.

2. EllipticalInclusion ignores matrix_viscosity

At matrix_viscosity = 3.0 the momentum residual is 0.632 while tracelessness
and strain-rate consistency stay at machine precision — the combination that
localises it. The potentials are normalised to unit matrix viscosity; under
$\eta\to\lambda\eta$ at fixed boundary velocity the stress and the pressure
both scale by $\lambda$. fn_viscosity was scaled and fn_pressure was not, so
the two parts of $\sigma$ were in different units — invisible to any gate that
looks at only one of them. Now 3.9e-15.

Worth noting why it escaped: this is the only solution that assigns fn_stress,
fn_pressure and fn_strainrate directly instead of going through
set_fields, which is the one place the stress/pressure relationship is applied.

3. SolNL(r=2.0) raised KeyError: 'ComplexInfinity'

$r=2$ gives $\alpha = 1/r - 1 = -1/2$, at which the published pressure's
denominator (AnalyticSolNL.c:52) vanishes identically — for every
wavenumber, so it is a pole of the solution rather than a bad $(r,n)$ pairing.
It now raises a ValueError naming the cause; previously it produced
ComplexInfinity in three fields and surfaced as a KeyError from SymPy's code
printer, a long way from the cause.

4. MeshVariable.clone is still a NameError on development

Unrelated to the analytic suite, but it is in the diff and it is the one merge
conflict. developmentfixed#498 via the quickfix batch (#532) with
newMeshVariable = MeshVariable(...) inside
discretisation_mesh_variables.py — where the only class defined is
_BaseMeshVariable and MeshVariableis not a name. The branch's version
uses uw.discretisation.MeshVariable(...), which works; the merge took it. The
EnhancedMeshVariable half now delegates to the base rather than duplicating
the constructor call, since duplicating it is what let the two halves of #498
drift apart.

The rename, and the shim

underworld3.function.analyticunderworld3.analytic. The suite outgrew
function: it now carries boundary conditions, error norms and a registry, none
of which belong under "symbolic function evaluation".

Decision: move every caller AND keep a deprecating shim. The old path is a
public namespace and users' scripts live outside this repo, so a hard break is
not something we can audit. The shim:

  • resolves names through __getattr__, so the DeprecationWarning fires when a
    name is used, not when the module is imported — ten test files import it at
    collection time and a warning there is noise the reader cannot act on;
  • returns the same objects, not copies, so isinstance and pickling work
    across both paths;
  • is a package directory rather than a module file on purpose: the old
    function.analytic was a compiled extension, and an orphaned .so from an
    earlier install would otherwise be imported in preference to the shim. Python
    checks for a package directory before an extension of the same name, so the
    directory always wins.

test_1016_analytic_contract.py tests the shim, including that orphaned-.so
case, and is therefore the one file that still imports the old path deliberately.

Callers moved: tests/test_1015, test_1017, test_1018, test_0835,
test_0836, test_0839, test_1062, tests/parallel/test_1017,
tests/parallel/test_1064, docs/examples/snesfas_investigation/benchmark_3way.py,
and docs/examples/.../Ex_Stokes_Cartesian_SolNL.py (rewritten to use
uw.analytic.SolNL rather than three low-level kernel bindings). Docs at
docs/api/function.md, docs/api/analytic.md and docs/api/index.md match.

One nuance: test_1015 uses the low-level AnalyticSolCx_* bindings, which are
the vendored kernel and deliberately not part of the uw.analytic public
surface — that namespace exposes solutions, not the functions they were
transcribed from. Those imports now go to
underworld3.analytic._reference._velic, where they actually live, rather than
through the shim.

Drift: 152 commits

The merge itself was one conflict (above). Everything else was checked by
running, not by reading:

Nothing in the suite needed fixing for the drift. The defects fixed here are
older than the drift and were found by the audit, not caused by it.

Test evidence

All runs in the analytic-suite worktree against its own pixi environment
(amr-dev), sequentially, on a machine also running other sessions.

The suite's own teststest_101[5-9]_analytic* and test_102[0-8]_analytic*:

382 passed, 1 failed in 1063.60s (0:17:43)

The one failure was a guard added in this PR, and it was a genuine false
positive in the guard rather than in the code — see "Record what set_fields was
given" below. After the fix, the two affected tests pass (14 passed, 84 deselected in 65.07s). Every other test in the suite passed on the first run
after the merge, before any of the fixes here: 270 passed across the eleven
pre-existing analytic files, which is the answer to "did a fortnight of API
change break it" — it did not.

The tier-A gatepytest -m "level_1 and tier_a" tests/:

1021 passed, 26 skipped, 1554 deselected, 1 xfailed, 78 warnings in 1491.45s (0:24:51)

All 26 skips are need --with-mpi option to run plus two self-documenting
point-locator skips; the xfail is the tracked 1-manifold evaluate limitation.
Nothing related to this change.

scripts/test.sh — the full serial suite, end to end, because the tier-A
gate and what CI actually runs are not the same set:

129 passed test_00[0-4]*
43 passed test_005[1-9] test_006[0-1]
168 passed test_01*
27 passed test_02*
532 passed, 5 skipped, 9 xfailed, 1 xpassed test_05* test_07*
605 passed, 11 skipped, 11 xfailed test_08* (units)
30 passed test_100[0-9] (Poisson/Darcy)
522 passed 20:30 test_101* test_102* <- this PR
83 passed 05:59 test_105*
5 passed, 1 xpassed test_1100*
4 passed test_1110* test_1120*
3 passed test_1450*
74 passed, 1 skipped named (JIT, docstrings, projections)
-----
2225 passed, 17 skipped, 20 xfailed, 2 xpassed, 0 failed
✅ All tests passed!
SCRIPT_EXIT=0

The test_101*/test_102* batch is the one this PR changes most, and it is also
the batch that previously matched no CI glob (fixed under issue #504). It now
carries the whole analytic suite plus the new parameter sweep, at 20:30.

scripts/test.sh --parallel-only --p 2 — run because this PR edits two files
under tests/parallel/:

38 passed, 1 skipped in 3.42s
20 passed, 2 xfailed, 5 xpassed in 68.05s
0 FAILED / 0 ERROR
✅ All tests passed!
SCRIPT_EXIT=0

The xfail/xpass are the pre-existing #564 free-slip partition-dependence family
(test_1063, test_1064, test_1066), each carrying its own long explanation
in the test file recording that it is not caused by #560/#561 and that it passes
locally on macOS/arm64 (hence strict=False). Unrelated to this PR; noted here
so the counts are not read as new.

The off-default parameter sweep, run directly as a probe before and after
the fixes — this is the measurement that found three of the four defects:

beforeafter
SolA eta=3.0momentum 2.84e-1, trace 6.67e-1, edot 6.67e-10, 0, 0
SolA eta=0.25momentum 6.35e-1, trace 3.00e+0, edot 7.50e-10, 0, 0
SolA eta=3, n=2, m=1.5momentum 2.86e-14.44e-16
EllipticalInclusion matrix_viscosity=3.0momentum 6.32e-13.91e-15
SolNL r=2.0KeyError: 'ComplexInfinity'ValueError naming the pole

38 other configurations across 13 solutions passed both before and after, so the
fixes are localised and did not perturb anything else.

One thing worth flagging separately

pytest -m "level_1 and tier_a"with no path argument — the exact command in
CLAUDE.md under "Quick validation" — dies silently: exit 1, zero bytes of
output, no traceback. Adding tests/ fixes it (pytest otherwise collects from
the repo root, including .pixi/ and petsc-custom/). This is pre-existing and
unrelated to this PR, but it is the "silent pytest death" signature and it means
the documented quick-validation command has been reporting failure without
saying why. Worth its own issue.

Not done

  • CylindricalStokes remains symbolic = False — it is an external numeric
    oracle, so the residual gates cannot apply to it. That is declared by the
    class and asserted by the sweep, not silently skipped.
  • The parameter sweep moves coefficients; it does not sweep meshes or
    resolutions. Convergence-rate testing is a separate concern.
  • docs/docstrings/review_queue.md still lists the removed
    src/underworld3/function/analytic.pyx. It is generated by
    scripts/docstring_sweep.py and should be regenerated rather than hand-edited.

Underworld development team with AI support from Claude Code

lmoresi added 28 commits August 2, 2026 15:36
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
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
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
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
… 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
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
… 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
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
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
… 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
…ot uniform
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
… 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
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
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
…entum sees
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
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
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
…thers
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
… estimate
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
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
…pies
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
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
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
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
…ution floor
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
…nto feature/analytic-suite
# Conflicts:
#	src/underworld3/discretisation/enhanced_variables.py
…e 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
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
CopilotAI lite review requested due to automatic review settings August 15, 2026 04:08

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request lands the new underworld3.analytic (uw.analytic) namespace as a unified, contract-driven suite of analytic/benchmark solutions (Velic Stokes family plus scalar transport and Richards cases), including a deprecating compatibility shim for the legacy underworld3.function.analytic path. It also updates existing tests/examples to use the new API, vendors/relocates the reference kernels behind underworld3.analytic._reference, and includes a fix plus regression test for MeshVariable.clone.

Changes:

  • Introduces underworld3.analytic with registry/contract, adds new analytic solution modules (transport, Richards, Kramer wrapper), and relocates the compiled reference-kernel extension to underworld3.analytic._reference._velic.
  • Updates tests and example scripts to use uw.analytic (and the legacy shim where explicitly required), adding new validation-focused test suites for transport and Richards.
  • Fixes MeshVariable.clone / EnhancedMeshVariable.clone behavior and adds a dedicated regression test.

Reviewed changes

Copilot reviewed 67 out of 75 changed files in this pull request and generated 2 comments.

Show a summary per file
FileDescription
tests/test_1100_AdvDiffCartesian.pySwitches handwritten advected-front expression to uw.analytic.AdvectedFront.
tests/test_1062_constrained_solcx.pyUpdates imports from legacy analytic namespace to underworld3.analytic.
tests/test_1026_analytic_richards.pyAdds Richards (Gardner) analytic conformance/negative-control tests.
tests/test_1025_analytic_transport.pyAdds transport analytic conformance/negative-control tests (Poisson/Darcy/diffusion/advection-diffusion).
tests/test_1023_analytic_solkz.pyAdds SolKz analytic validation tests (viscosity varies with depth).
tests/test_1022_analytic_soldb.pyAdds SolDB2d/SolDB3d manufactured-solution tests (symbolic simplifications).
tests/test_1021_analytic_solkx.pyAdds SolKx analytic validation tests (smooth viscosity contrast).
tests/test_1020_analytic_inclusion.pyAdds EllipticalInclusion validation tests (multiple independent checks).
tests/test_1018_rotated_freeslip.pyUpdates analytic imports to new namespace.
tests/test_1017_custom_mg_stokes.pyUpdates analytic imports to new namespace.
tests/test_1016_analytic_contract.pyAdds contract + legacy-shim behavior tests for uw.analytic.
tests/test_1015_analytic_solcx.pyUpdates to uw.analytic and imports low-level reference bindings from _reference._velic.
tests/test_1005_TransientDarcyCartesian.pySwitches transient diffusion profile to uw.analytic.ErfcDiffusion.
tests/test_1004_DarcyCartesian.pySwitches two-layer Darcy analytic profile to uw.analytic.TwoLayerDarcy.
tests/test_1000_poissonCart.pySwitches sinusoidal Poisson source/solution to uw.analytic.Poisson1D.
tests/test_0839_nvb_parallel_adapt.pyUpdates analytic imports to new namespace.
tests/test_0836_nvb_graded_adapt.pyUpdates analytic imports to new namespace.
tests/test_0835_sbr_adapt_on_top.pyUpdates analytic imports to new namespace.
tests/test_0301_meshvariable_clone.pyAdds regression tests for MeshVariable.clone behavior (issue #498).
tests/parallel/test_1064_rotated_freeslip_parallel.pyUpdates analytic imports to new namespace (MPI test).
tests/parallel/test_1017_custom_mg_parallel_mpi.pyUpdates analytic imports to new namespace (MPI test).
src/underworld3/utilities/retention_curves.pyRefactors Gardner retention helpers to delegate to underworld3.analytic.richards.
src/underworld3/function/analytic/init.pyAdds deprecation shim package for legacy underworld3.function.analytic.
src/underworld3/function/init.pyAdds lazy __getattr__ to keep uw.function.analytic reachable without eager import.
src/underworld3/discretisation/enhanced_variables.pyUpdates EnhancedMeshVariable.clone to delegate to base clone implementation.
src/underworld3/discretisation/discretisation_mesh_variables.pyFixes MeshVariable.clone NameError by calling the public factory.
src/underworld3/analytic/richards.pyAdds Gardner steady/transient Richards solutions and shared helpers.
src/underworld3/analytic/kramer.pyAdds Kramer/assess-backed curved-geometry Stokes wrapper solution(s).
src/underworld3/analytic/_reference/solH.cVendors reference kernel source into new _reference package location.
src/underworld3/analytic/_reference/solCx.hVendors reference kernel header into new _reference package location.
src/underworld3/analytic/_reference/solC.cVendors reference kernel source into new _reference package location.
src/underworld3/analytic/_reference/solB.cVendors reference kernel source into new _reference package location.
src/underworld3/analytic/_reference/solA.cVendors reference kernel source into new _reference package location.
src/underworld3/analytic/_reference/AnalyticSolNL.hVendors reference header into new _reference package location.
src/underworld3/analytic/_reference/AnalyticSolM.hppVendors reference header into new _reference package location.
src/underworld3/analytic/_reference/AnalyticSolDB2d.hppVendors reference header into new _reference package location.
src/underworld3/analytic/_reference/AnalyticSolCx.hAdds UW3 adapter header for SolCx reference kernel in new location.
src/underworld3/analytic/_reference/AnalyticSolCx.cAdds UW3 adapter C implementation for SolCx reference kernel in new location.
src/underworld3/analytic/_reference/init.pyDocuments _reference purpose (vendored kernels; not public API).
src/underworld3/analytic/init.pyAdds public uw.analytic namespace exports plus registry (available, describe, is_available).
src/underworld3/init.pyImports underworld3.analytic into the top-level package namespace.
setup.pyRenames compiled extension target to underworld3.analytic._reference._velic and adds package data for reference headers/sources.
pyproject.tomlAdds optional dependency group benchmarks = ["assess"].
pixi.tomlAdds assess to dev dependencies for running benchmark-related tests/examples.
docs/examples/utilities/advanced/Ex_Stokes_Annulus_Benchmark_Thieulot.pyImports assess via require_assess() to provide a clearer missing-dependency message.
docs/examples/utilities/advanced/Ex_Stokes_Annulus_Benchmark_Kramer_etal.pyImports assess via require_assess() to provide a clearer missing-dependency message.
docs/examples/fluid_mechanics/advanced/Ex_Stokes_Spherical_Benchmark_Thieulot.pyImports assess via require_assess() to provide a clearer missing-dependency message.
docs/examples/fluid_mechanics/advanced/Ex_Stokes_Spherical_Benchmark_Kramer.pyImports assess via require_assess() to provide a clearer missing-dependency message.
docs/examples/fluid_mechanics/advanced/Ex_Stokes_Cartesian_SolNL.pySwitches SolNL example to use uw.analytic.SolNL instead of low-level bindings.
docs/examples/fluid_mechanics/advanced/Ex_Stokes_Cartesian_SolC.pyReworks SolC example to use uw.analytic.SolC, fixes validation placement, updates narrative.
docs/examples/fluid_mechanics/advanced/Ex_Stokes_Annulus_Benchmark_Kramer.pyImports assess via require_assess() to provide a clearer missing-dependency message.
docs/examples/snesfas_investigation/benchmark_3way.pyUpdates analytic imports to new namespace.
docs/developer/index.mdAdds analytic-solutions subsystem doc to the developer index/toctree.
docs/api/index.mdAdds analytic to API docs index.
docs/api/function.mdUpdates function docs to point analytic solutions at the new analytic docs page.
docs/api/analytic.mdAdds API docs page for underworld3.analytic and its contract.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +419 to +424
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)
)
Comment on lines +259 to +262
if self.boundary == "zero":
for wall in self.boundaries:
solver.add_dirichlet_bc((0.0, 0.0), wall)
return
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
@lmoresi

Copy link
Copy Markdown
MemberAuthor

The CI result was a 60-minute job timeout, not a failure — zero FAILED lines, analytic files at 81% and passing. Measured from the log, the suite cost 26.6 minutes against a run (#568, same day, without it) that already took 55m07s. Fixed in 01f542ae.

The profile overturned the obvious hypothesis. There is exactly one Stokes solve in the entire suite; the cost is symbolic — every residual gate differentiates the solution's expressions and runs CSE over the result. Five solutions are 565s of 1010s (SolDA 187, SolKz 187, SolKx 72, SolH 52, SolC 35); the other eight total ~17s. SolC sums forty modes; SolKx/SolKz carry exponential viscosities.

A real redundancy, removed: momentum_residual sampled the symbolic sum of its terms and then sampled each term again for the scale — but the terms are sampled anyway, so the residual can sum numerically for free. Same numbers to round-off, eight orders of margin against the 1e-8 gate, negative control still exactly 2.000. Applied to the momentum, incompressibility, transport, diffusion and strain-rate gates. test_1021/test_1023 were also rebuilding their solutions per test (SolKz 6s, SolDA 12s per build) — now cached.

Split by file placement, not markerscripts/test.sh globs by file, so a slow marker alone would need every batch line to remember to deselect it. tests/analytic_full/ is a subdirectory the globs do not recurse into, so it is excluded by construction, and the level_2 mark additionally keeps it out of pytest -m "level_1 and tier_a" tests/, which does recurse. All three collection paths verified: 0, 0, 189.

Solutions declare their own tier (expensive_to_validate on the class), so the split has one source of truth, with two anti-drift guards: test_1024 asserts every solution it skips is named in the full-family file, and that file asserts its own list matches the declarations.

No guard weakened. Momentum residual, incompressibility, tracelessness, strainrate_consistency and the body-force negative control run in both tiers, on every solution that tier covers — fewer solutions per run, never fewer checks per solution. Critically, every solution that caught a defect in this PR (SolA, SolM, SolNL, SolDB2d/3d) is in the cheap group. SolKx/SolKz keep one canonical parameter case per-PR; the three cases moved out are picked up exactly in the full sweep's table.

beforeafter
analytic files16m50s4m07s (307 passed)
CI batch test_101*+test_102*20m30s7m30s (446 passed)
full family (opt-in)8m34s (189 passed, exit 0)

Scaling by the observed CI/local factor (26.6 min CI vs 16m50s local, ≈1.6×), the suite's CI cost should land near 6.5 minutes. Running both tiers now costs less than the old single tier did.

Full family, one documented command (in the module docstring and docs/developer/subsystems/analytic-solutions.md):

pixi run -e amr-dev python -m pytest tests/analytic_full/ -v

Underworld development team with AI support from Claude Code

@lmoresi
lmoresi merged commit 657aa35 into developmentAug 15, 2026
2 checks passed
@lmoresi
lmoresi deleted the feature/analytic-suite branch August 15, 2026 09:33
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@lmoresi