diff --git a/docs/api/analytic.md b/docs/api/analytic.md new file mode 100644 index 000000000..77698aadd --- /dev/null +++ b/docs/api/analytic.md @@ -0,0 +1,25 @@ +# Analytic Solutions + +```{eval-rst} +.. automodule:: underworld3.analytic + :members: + :show-inheritance: +``` + +## The contract + +Every solution satisfies the same contract, so a validation run reads the same way +whichever one you use. + +```{eval-rst} +.. automodule:: underworld3.analytic._base + :members: + :show-inheritance: +``` + +## See also + +- {doc}`solvers` — the solvers these solutions validate. +- `docs/developer/subsystems/analytic-solutions.md` — the implementation form, + the validation protocol every transcription must pass, and the provenance of + each vendored reference kernel. diff --git a/docs/api/function.md b/docs/api/function.md index b3a052b37..9ac50e271 100644 --- a/docs/api/function.md +++ b/docs/api/function.md @@ -78,8 +78,5 @@ Factory function for creating UWQuantity objects with units. ## Analytic Functions -```{eval-rst} -.. automodule:: underworld3.function.analytic - :members: - :show-inheritance: -``` +The analytic solutions have moved to {doc}`analytic` — `underworld3.function.analytic` +is a deprecation shim. Use `uw.analytic.SolCx(mesh, ...)`. diff --git a/docs/api/index.md b/docs/api/index.md index ab7ac16fb..a0d9602bf 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -29,6 +29,7 @@ model utilities visualisation adaptivity +analytic ``` ## Quick Links @@ -50,6 +51,9 @@ adaptivity - **{doc}`scaling`** - Units, quantities, and non-dimensionalisation - **{doc}`maths`** - Mathematical operations and integrals +### Validation +- **{doc}`analytic`** - Exact solutions for benchmarking and convergence testing + ### Infrastructure - **{doc}`model`** - Model management and configuration - **{doc}`utilities`** - I/O, mesh import, and helper functions diff --git a/docs/developer/index.md b/docs/developer/index.md index 356a4f74c..c32976e1f 100644 --- a/docs/developer/index.md +++ b/docs/developer/index.md @@ -37,6 +37,7 @@ same topic are reference or historical material subordinate to the governing doc | Data access | [subsystems/data-access.md](subsystems/data-access.md) (internals reference: [NDArray System](UW3_Developers_NDArrays.md)) | | Local scattered-point interpolation | [subsystems/interpolation.md](subsystems/interpolation.md) | | Rotated free-slip & wall-normal datum | [subsystems/rotated-freeslip.md](subsystems/rotated-freeslip.md) | +| Analytic & benchmark solutions | [subsystems/analytic-solutions.md](subsystems/analytic-solutions.md) | | Units | [design/UNITS_SIMPLIFIED_DESIGN_2025-11.md](design/UNITS_SIMPLIFIED_DESIGN_2025-11.md) | | Testing tiers | [TESTING-RELIABILITY-SYSTEM.md](TESTING-RELIABILITY-SYSTEM.md) | | Branching & releases | [guides/branching-strategy.md](guides/branching-strategy.md) | @@ -197,6 +198,7 @@ subsystems/constitutive-models-anisotropy subsystems/swarm-system subsystems/data-access subsystems/interpolation +subsystems/analytic-solutions subsystems/expressions-functions subsystems/containers subsystems/checkpointing-system diff --git a/docs/developer/subsystems/analytic-solutions.md b/docs/developer/subsystems/analytic-solutions.md new file mode 100644 index 000000000..2fe4ae6b4 --- /dev/null +++ b/docs/developer/subsystems/analytic-solutions.md @@ -0,0 +1,817 @@ +# Analytic solutions + +Exact solutions are the code's source of truth. They are the only diagnostic that +can tell you a solver returned a *wrong* answer rather than an unconverged one — +the SolCx port caught a direct `ksponly + lu` solve silently mangling the singular +saddle on a free-slip problem, which no residual norm reported. + +This document governs how they are implemented, how a new one earns trust, and +where each vendored reference kernel came from. + +## Where they live + +`underworld3.analytic` — one namespace, reached as `uw.analytic.(mesh, ...)`. + +```{note} +`uw.function.analytic` is the historic location. It is a *compiled extension +module*, which is why the suite could not grow there: the name is owned by a +`.so`, so no submodules or pure-Python solutions can live under it. +``` + +## One implementation form: SymPy + +**Every analytic solution is a pure SymPy expression on `mesh.X`.** No exceptions, +no tiers. A solution written this way: + +- compiles through the normal JIT path when handed to a solver, so it is C where + speed matters; +- carries its own analytic Jacobian, which a hand-written C kernel cannot supply; +- can be used as a Dirichlet boundary value, which a kernel-backed function cannot; +- evaluates through `uw.function.evaluate` like any other field, vectorised. + +Point evaluation is not in a hot loop — it is tests and error norms — so the SymPy +form costs nothing where it is used, and wins where it matters. + +## Transcribing a reference kernel + +Most of the classical solutions (Velic's, and PETSc's copies of them) exist as +machine-generated C: straight-line single-assignment code, `t125 = 0.4e1*t81*t83 + +...`, several hundred lines per branch. `scripts/maple_c_to_sympy.py` converts +these mechanically. + +**Preserve the grouping term for term. Never `simplify()`.** The Maple grouping is +what keeps `sinh(k)*exp(-k)`-style products numerically stable at large wavenumber +and large viscosity contrast. A re-derivation is a *different* grouping and can +lose eight digits in exactly the regime the benchmark exists to probe. Do not +re-derive what you cannot revalidate. + +The reference kernel is **kept**, not deleted, and stays reachable: + +```python +sol = uw.analytic.SolCx(mesh, ...) # SymPy (default) +ref = uw.analytic.SolCx(mesh, ..., reference=True) # the Velic C, verbatim +``` + +so "is this the transcription or the model?" stays a one-line question. + +## The validation protocol + +Velic's kernels are obsessively careful. The risk sits entirely on our side of the +conversion, and pointwise agreement on a sample is a weak test — it can pass while +the transcription is wrong off-sample, or wrong in its derivatives, which is what +the solver actually consumes. **No transcription lands until all six gates pass.** + +### Gate 1 — two independent oracles, not one + +PETSc maintains its own copy of several Velic solutions, with different call +signatures from Underworld2's: `SolCxSolution` and `SolKxSolution` in +`src/snes/tutorials/ex69.c`. Agreement of UW2-C ↔ PETSc-C ↔ our SymPy is a far +stronger statement than agreement with either alone, and both trees are already +available. For SolKx there is a third: PETSc's `ex75.h` is a 41×41 table of +tabulated reference values at `B=100, kn=km=100π` — an independent fixture in the +hardest wavenumber regime. + +### Gate 2 — adversarial sampling, max error not mean + +Stratified so no region goes unsampled, and deliberately loaded with the hard +places: on and either side of a viscosity interface, on the boundaries, at +corners, and across parameter extremes (viscosity ratios spanning `1e-6` to `1e8`, +wavenumbers 1–8). Report the **maximum** relative error. Threshold `1e-10`. A +failing point gets investigated, not sampled around. + +### Gate 3 — derivatives, against independently derived output + +The solver consumes derivatives of these fields, and a transcription can be +pointwise right yet derivative-wrong if a dropped term happens to vanish on the +sample set. + +The reference kernels return `vel`, `pressure`, `total_stress` and `strain_rate` +as *separately derived* quantities — they do not differentiate the velocity to get +them. So compare SymPy's **symbolic** derivative of the velocity against the +kernel's own strain rate, and the symbolic constitutive stress against its total +stress. That is a genuine independent check rather than a tautology. + +### Gate 4 — the physics residual, which needs no oracle + +Substitute the transcribed fields back into the equations they claim to solve and +confirm the residual vanishes: + +$$\nabla\cdot\mathbf u = 0, \qquad + \nabla\cdot\left(2\eta\,\varepsilon(\mathbf u)\right) - \nabla p + \mathbf f = 0$$ + +using the solution's *own* `fn_viscosity` and `fn_bodyforce`; plus traction +continuity across any interface, and the boundary conditions the solution claims. + +This tests the transcription **as a solution** rather than as a table of numbers, +and it catches what a convergence test structurally cannot. If the transcription +and the solver share the same mistaken convention — a sign, a factor of two — the +solve converges beautifully to the wrong answer. That is not hypothetical: the +original SolCx port hit exactly this, with Underworld2's documentation quoting +$-\cos(\pi x)\sin(n\pi z)$ where UW3's momentum convention needs $+\cos$. Gate 4 +is the guard, because it never consults the solver. + +### Gate 5 — negative control + +Flip the sign of a single coefficient in the transcription and confirm that gates +1–4 all **fail**. A gate that passes a deliberately broken input is measuring +nothing. Do this once per solution, in the test suite, with the perturbation +applied programmatically. + +### Gate 6 — separate transcription error from conditioning + +If SymPy and C disagree near threshold, we need to know whether that is our +transcription or the C's own double-precision cancellation. Evaluate the SymPy +form under `mpmath` at 50 digits and compare against both. This makes "the Maple +grouping is numerically stable" a measured claim rather than an assumption — and +if the high-precision form agrees with the kernel's intent while the +double-precision form does not, that is a concrete instruction to preserve +grouping harder. + +### After the gates: pin it + +Freeze a table of validated values as a test fixture — the pattern PETSc itself +uses with `ex75.h` — so later refactors cannot drift silently. Record the measured +maximum error, the sampling design, and which oracles were used in the solution's +docstring. Not just "validated". + +## Status: SolCx transcribed and validated + +The transcriber is in place and measured. On `solCx.c` — the largest kernel in +the family at 1500 lines — it reads both arrangements and both spatial branches +in **0.25 s**, and the resulting expressions are 2000–7000 operations, well +within what SymPy, `lambdify` and the JIT handle. That measurement is what makes +the all-SymPy form viable for the whole family; it was not obvious in advance. + +Gate 1 and Gate 2 on the `_solCx_A` arrangement, against the published kernel, at +40 stratified points per case: + +| eta_A | eta_B | x_c | n | max relative error | +|---|---|---|---|---| +| 1 | 10 | 0.5 | 1 | 4.3e-15 | +| 1 | 1e3 | 0.5 | 1 | 1.7e-14 | +| 1 | 1e6 | 0.5 | 1 | 1.1e-14 | +| 1 | 1e8 | 0.5 | 1 | 9.3e-15 | +| 1 | 1e6 | 0.5 | 3 | 4.9e-15 | +| 1 | 1e6 | 0.75 | 1 | 4.1e-15 | +| 1e6 | 1 | 0.5 | 1 | 1.5e-14 | +| 1e3 | 1 | 0.25 | 2 | 8.9e-16 | +| 1 | 1e-6 | 0.5 | 1 | 1.5e-14 | + +### What the gates caught, and what turned out to be true + +Three things looked like transcription failures and were not. All three are worth +knowing before transcribing the next kernel. + +**`_solCx_B` is not a second conditioning — it is the mirror.** The published +source dispatches on $\eta_A > \eta_B$, which reads as two arrangements of one +formula chosen for numerical conditioning. Transcribing `_solCx_B` directly gave +an answer wrong by a factor of tens in every regime, while `_solCx_A` matched to +1e-14 — including in the regime where the dispatcher runs `_solCx_B`. Evaluated +exactly at 50 digits the relationship is clean: $B(x,z) = A(1-x, z)$. `_solCx_B` +solves the mirrored problem so the algebra derived for a stiff *left* column can +be reused when the stiff column is on the right, and reflects on the way out. + +So only `_solCx_A` is transcribed and there is no dispatch. That is safe because +the stated reason for the dispatch was checked rather than assumed: the table +above spans ratios from 1e-6 to 1e8 in both directions and the error never leaves +1e-14. **Do not assume a kernel's internal dispatch means what its condition +suggests — evaluate both arms exactly and compare.** + +**The isoviscous case is a genuine limitation, and it is now refused.** The closed +form carries $Z_R - 1$ in several denominators, so $\eta_A = \eta_B$ is a +removable singularity. SymPy cancels it when the expression is evaluated +symbolically at a point — verified to 40 digits against the kernel — but not in +the compiled form, where it survives as $0/0$. Rather than return nonsense, +`SolCx` raises for equal viscosities: it is a viscosity-jump benchmark, and +uniform viscosity is a different solution. Parameters are substituted as exact +`Rational`s regardless, since that is what lets the cancellation happen at all. + +**The first gate run's 1e12 errors were the metric, not the transcription.** A +pointwise relative error divides by the true value, and these fields pass through +zero, so the ratio explodes wherever the solution is small — the values were +close all along. Normalise by the field's magnitude over the sample. Suspect the +metric before the result when every case fails alike. + +### The harness + +`underworld3.analytic._validation` holds the checks, so a new transcription gets +them by calling four functions rather than reinventing them: + +| function | gate | +|---|---| +| `adversarial_points` | 2 — stratified, plus interface, walls and corners | +| `reference_agreement` | 1, 2 — against the kernel, worst case, normalised | +| `incompressibility_residual` | 4 — $\nabla\cdot\mathbf u$, no oracle | +| `momentum_residual` | 4 — $\nabla\cdot\sigma + \mathbf f$, no oracle | +| `strainrate_consistency` | 3 — derivatives, against separately derived output | +| `high_precision_value` | 6 — 50 digits, to separate our error from the kernel's | + +Measured on SolCx at contrast 1e3: incompressibility 3.6e-17, momentum 2.3e-16, +strain rate 8.5e-16 — each in under half a second. + +**Evaluate through `lambdify`, not `uw.function.evaluate`.** The checks +differentiate the fields, and a viscosity-jump solution puts a large `Piecewise` +inside a stress derivative. Routed through the JIT that combination takes so long +to generate and compile that the suite becomes unrunnable — the same blow-up seen +with `add_nitsche_bc` on SolCx. These expressions are pure SymPy in the mesh +coordinates, so `lambdify(..., cse=True)` is both correct and three orders of +magnitude faster. `_validation.sample` does this; note it must first swap the +mesh coordinates for plain symbols, which `lambdify` cannot bind directly. + +Gate 5, the negative control, belongs in each solution's test rather than the +harness: perturb one coefficient and assert the other checks fail. +`test_the_checks_reject_a_broken_transcription` is the pattern — it perturbs the +velocity by a part in a thousand and requires both the comparison and the +oracle-free residual to report it. + +## One interface, so a mistake cannot be local + +Each solution used to assemble its own `fn_*` attributes, and each had its own +test file. That combination let a real error through: SolNL's kernel publishes +the deviatoric stress, it was stored as the total, and its momentum residual was +**1.06** rather than zero. Its test file checked agreement with the kernel and +incompressibility — both passed — and nothing checked the momentum balance. + +Two changes make that class of mistake structural rather than a matter of +remembering. + +**Assembly happens once.** A solution hands its components to +`AnalyticSolution.set_fields`, which applies the conventions. Whether the source +publishes $\sigma$ or $\tau$ is a class-level declaration, +`stress_is_deviatoric`, honoured in exactly one place. A solution can no longer +quietly disagree with its neighbours about what its own stress means. + +**Conformance is checked for every registered solution.** +`tests/test_1024_analytic_conformance.py` iterates over `uw.analytic.available()` +and applies the same checks to all of them: the contract is fully populated, the +metadata is declared, the flow is incompressible, the momentum balance holds, the +stress and strain rate agree, and the boundary conditions configure a solver. A +solution added later is covered the moment it is registered. + +Where a solution differs from the others it says so through the contract rather +than by being exempted: + +| method | default | why a solution overrides it | +|---|---|---| +| `sample_points` | unit box or cube, plus faces and corners | the elliptical inclusion is not box-filling, and the conformal map is singular at its foci — a generic sampler lands on both | +| `boundaries` | box wall labels | a curved geometry has its own | +| `apply_boundary_conditions` | free slip or Dirichlet mixin | — | +| `stress_is_deviatoric` | `False` | the source publishes $\tau$ | + +One consequence worth knowing: `_validation.sample` returns real values, and if +an expression evaluates complex it checks the imaginary part is round-off before +discarding it. The elliptical inclusion is built from complex potentials and is +real-valued without SymPy being able to prove it — but a *genuinely* complex +result would mean the construction is wrong, and silently taking the real part +would hide exactly that. + +## The stress convention is not uniform across the family + +**Check which stress a kernel publishes. Do not read it off the variable name.** + +| solution | what we transcribe is | +|---|---| +| SolA, SolB, SolC, SolCx, SolDA, SolKx | total (Cauchy) $\sigma$ | +| SolKz, SolNL, SolDB2d, SolDB3d | deviatoric $\tau$ (SolKz: see the cut-point note below) | +| SolM | published, but wrong — see below | +| SolA | total, but its $zz$ component is wrong — see below | + +SolB is the clearest case to read: its source writes `u3 = 2.0*Z*kn*ss_z - pp`, +with the pressure subtracted in plain sight. (Do **not** use solA's version of +that line as the exemplar, as this document once did — it is the one that is +missing the viscosity.) + +Two further traps that are not about deviatoric-vs-total: + +- **Component order is not uniform.** Every kernel here writes `[xx, zz, xz]` + except `solKx.c`, which writes **`[xx, xz, zz]`** (:481-491, with the legend + at :456). It has a different provenance from the rest — it is vendored from + PETSc's `ex69.c`, not the Underworld tree. +- **Several kernels label the vertical velocity `u1`** and the horizontal `u2` + — solA (:151), solB (:135), solC (:142), solDA (:910), solKz (:493). solCx + (:1467) and solKx (:456) do not. `solH.c` reverses all three (:173-182). The + transcription maps components explicitly rather than positionally; a swap here + is caught by the momentum residual only because the components have different + functional forms, and would be invisible in a symmetric problem. + +Both are uniform across the family in the two ways that matter for a solve, and +neither is stated in most of the files — both had to be measured by +finite-differencing the kernels' own outputs: the momentum sign is +$\nabla\cdot\sigma + \mathbf f = 0$ with $\mathbf f = -\rho\hat z$ under unit +gravity in $-z$, and pressure is positive in compression. + +### The body force is minus the density + +Most of these kernels negate internally — they write `rho = -sigma*sin*cos` and +force with `+sigma*sin*cos`. SolC does not: it accumulates the density itself, so +the transcription negates. Measured, as always: as summed the momentum residual +is 1.8; negated it is 1.6e-16. + +Worth stating as a rule because the sign is invisible to everything except the +momentum balance. Incompressibility and the free-slip conditions hold either way. + +### One published stress is simply wrong + +SolM's kernel declares its viscosity as $(1 + \cos(r\pi x))\eta_0 + 1$ and then +computes its stress as $2(\eta - 1)\dot\varepsilon$ — the constant part is +missing. The difference from $2(\eta-1)\dot\varepsilon$ is *exactly* zero, so +this is a defect in the source rather than a transcription slip: using the +published stress leaves the momentum residual at **0.21**, while deriving it from +the kernel's own strain rate and viscosity gives **1.7e-16**. + +Everything else SolM publishes — velocity, pressure, strain rate, viscosity, body +force — is mutually consistent. Only the stress output is defective, so the +transcription supplies the strain rate to `set_fields` and lets the stress be +derived. + +This is the case for having a check that consults no reference. Comparing SolM +against its own kernel would have reproduced the error faithfully and reported +agreement. + +SolKz is the trap, though it needs stating more carefully than it was here +originally. `solKz.c` *does* convert to the total stress, and says so — `u6 -= +u5; /* get total stress */` (:490), restated at :527 as `/* sigma = tau - p */`. +The array the C function returns is the total. What is deviatoric is **what our +transcription captures**: the transcriber reads the per-mode straight-line block +and stops at the first accumulation, and `sum5 += ...` (:489) precedes the +conversion (:490), so the per-mode `u6` and `u3` we take are pre-conversion. +`stress_is_deviatoric = True` on `SolKz` is therefore correct, but it describes +the transcription's cut point rather than the kernel's output — and anyone +transcribing afresh from the *returned array* must not set it. + +Getting that wrong leaves the momentum residual at order $|\mathbf f|$ *and* +manufactures a horizontal body force in a benchmark that has none — a large, +structured error that reads like a transcription failure rather than a +convention one. Measured on the transcribed fields: as the deviator (ours) +1.7e-16, as the total 6.0e-1. + +### A published stress that is silently right at the default + +`solA.c:156` computes the $zz$ stress as `u3 = 2.0*kn*ss_z - pp`. The matching +line in `solB.c:140` is `2.0*Z*kn*ss_z - pp`, and solA's own $xx$ stress two +lines later carries the `Z`. The published $\sigma_{zz}$ is short by +$\tau_{zz}(1-Z)/Z$. + +That is **identically zero at $Z = 1$** — the only case the file's own disabled +driver exercised, and the default `eta` in our transcription. Every gate in the +conformance file passed. At `eta=3`, three of them fail: momentum 2.8e-1, +deviator trace 6.7e-1, strain-rate consistency 6.7e-1, where +$|1-3|/3 = 0.667$ exactly. + +The transcription restores the factor, declared per solution as +`_zz_stress_lost_the_viscosity`; the vendored source stays verbatim. + +Note the repair that was **rejected**. Tracelessness gives +$\sigma_{zz} = -\sigma_{xx} - 2p$ straight from the correct $xx$ component, and +it works — but it would make the deviator traceless *by construction* and so +retire one of the three gates that caught the defect. Restoring the missing +factor instead keeps tracelessness an independent statement about the repair. +`test_1028` asserts both halves: that solA's published deviator is not traceless +at $Z=3$, and that solB's is. + +**The general lesson, and the reason `test_1028_analytic_parameter_sweep.py` +exists**: the conformance sweep builds every solution from a mesh alone. That is +right — the defaults are part of the interface — but a coefficient that is unity +by default multiplies a term nothing ever looks at. Any new solution needs an +entry in that file's `SWEEP` table, and the file asserts that every registered +Stokes solution has one. + +## Two test tiers, and how to run the slow one + +The residual gates are not cheap. Every one of them differentiates the +solution's expressions symbolically and runs common-subexpression elimination +over the result, and five solutions produce expressions with tens of thousands +of operations — SolC accumulates over forty modes, SolDA and SolH over several +more, and SolKx and SolKz carry an exponential viscosity. + +Measured: those five cost **565s of the suite's 1010s**; the other eight +together cost about 17s. CI was already within five minutes of its 60-minute cap +before this suite existed, so they cannot ride on every PR. + +| tier | what it covers | cost | where | +|---|---|---|---| +| per-PR | every gate, on every solution that is cheap to validate; one canonical parameter case for SolKx/SolKz | **4m07s** | `tests/test_101[5-9]_analytic_*`, `tests/test_102[0-8]_analytic_*` — matched by the CI batch globs | +| full family | every gate, on every solution, over the whole parameter table | **8m34s** | `tests/analytic_full/` — matched by nothing in CI | + +```bash +# the full family — before a release, and after touching +# underworld3/analytic/ or _validation.py +pixi run -e amr-dev python -m pytest tests/analytic_full/ -v +``` + +**The split is by solutions per run, never by checks per solution.** The +momentum residual, incompressibility, tracelessness, strain-rate consistency and +the body-force negative control all run in both tiers, on every solution that +tier covers. Those are the gates that caught the four errata above, and none of +them is weakened by the split. + +Solutions declare their own side of it — `expensive_to_validate` on the class — +so the two tiers partition the family from one source of truth. Two guards keep +that honest: `test_1024` asserts that every solution it skips is *named* in the +full-family file, and the full-family file asserts its own hand-written list +matches the declarations. + +`tests/analytic_full/` is a **subdirectory** rather than a marked file because +`scripts/test.sh` batches by file glob (`tests/test_101*py`, ...) and not by +marker — a `slow` marker alone would need every batch line to remember to +deselect it, whereas the globs do not recurse. The `level_2` mark on the file +additionally keeps it out of `pytest -m "level_1 and tier_a" tests/`, which does. + +Two cheap signatures tell them apart, and both are worth running on any new +kernel: + +- a deviator is traceless, so its $xx$ and $zz$ entries are exact negatives; +- $\tau = 2\eta\dot\varepsilon$, and the strain rate follows from the velocity — + a *different* output of the same kernel, so the comparison is independent. + +On SolKz the shear component agreed with $2\eta\dot\varepsilon$ to machine +precision while the normal components did not agree with anything, which located +the problem immediately. `test_kernel_publishes_the_deviatoric_stress` keeps +both checks. + +## A solution that is derived rather than transcribed + +`EllipticalInclusion` (Schmid & Podladchikov 2003) is the one case so far where +the published source does **not** contain what we need. The authors' MATLAB gives +pressure, deviatoric stress and the rotation rate; it does not give velocity. So +the Muskhelishvili potentials had to be recovered from the fields, and the +velocity built from those. + +That changes what validation means. There is no kernel to compare velocity +against, so the checks have to come from physics and from internal consistency: + +| check | pairs against | +|---|---| +| $\eta\nabla^2\mathbf v = \nabla p$ | the *published* pressure | +| velocity continuity across the interface | the interior uniform-gradient field | +| far field | the imposed shear, computed independently | +| interior uniformity | the Eshelby property | + +Measured: Stokes residual 1.4e-17, $\nabla\cdot\mathbf v$ 1.7e-16, far field +agreeing to a few parts in $10^6$ (the inclusion's own $1/r^2$ perturbation at +finite distance, not error). + +Two things about this derivation are worth carrying to the next one. + +**What the published data cannot constrain.** A purely imaginary constant in +$\varphi'$ contributes nothing to pressure ($-2\,\mathrm{Re}\,\varphi'$) or to +stress (which involves $\varphi''$). It is a far-field rigid rotation. Reading +the potentials off stress and pressure alone therefore loses the spin entirely, +and an imposed simple shear comes back as pure shear — with the correct strain +magnitude, which is what makes it easy to miss. Its value came from a different +published expression: taken to a circle, the rotation rate collapses to +$-\dot\gamma/2$ for every viscosity ratio. **When reading potentials back out of +fields, ask what the fields are blind to.** + +**Branch cuts are not cosmetic.** Inverting $z = \zeta + 1/\zeta$ as +`sqrt(z**2 - 4)` cuts along a ray and selects the root *inside* the unit circle +for $x < 0$ — the wrong Riemann sheet. The far field then comes out asymmetric, +roughly three times too fast on one side. Written `sqrt(z-2)*sqrt(z+2)` the cut +lies on $[-2, 2]$, the slit the map already has, and $|\zeta| > 1$ everywhere +outside. Sampling only positive $x$ would have missed this, which is why the test +samples both. + +A SymPy consequence: the correct branch defeats `re()`/`im()`, which then survive +into derivatives as an unprintable `Derivative(re(...))`. Building the components +as $(w + \bar w)/2$ and $(w - \bar w)/2i$ avoids them, with `conjugate` obtained +by flipping the sign of `I` — for an expression in real symbols that is exactly +conjugation, and unlike `sympy.conjugate` it distributes through a square root. +The result is real-valued but complex-typed; SymPy cannot prove the imaginary +part vanishes, so callers take `.real`. + +## The family is complete + +All twelve Velic solutions are transcribed, plus the Schmid & Podladchikov +inclusion. Every one is validated by the conformance suite. + +Two lessons from the last three, which I had recorded as too large to attempt: + +**Measure before estimating.** SolDA was written off on a guess about expression +size. Measuring took two minutes: one mode is 0.07 s and about four thousand +operations, putting twenty modes in the same range as SolKz. SolH was written off +on the source's own warning that it is "very expensive" — true of a *compiled* +kernel, which re-sums every mode at every evaluation point, and backwards for a +transcription, where the sum is built once and each mode is the smallest in the +family at ninety operations. Both transcribed and validated on the first attempt. + +**The named obstacle is the cheap one.** For SolDA the blocker I could point at — +chained assignment, `del_rhoB = del_rhoA = del_rho` — was a ten-line fix, while +the one I could only estimate was not an obstacle at all. Same for SolH: the C +ternary and the index-conditioned guards were mechanical additions; +the cost was imagined. + +### What the last three needed from the reader + +| addition | why | +|---|---| +| `CSource.loop_body` | the series solutions accumulate over modes with `+=`, which is not an assignment; the caller evaluates per mode and sums in SymPy | +| chained assignment | `a = b = c;` read as one statement has `b = c` as its *value*, which is not an expression | +| C ternary and `&&`/`\|\|` | SolH guards its zero modes with `(n!=0 \|\| m!=0) ? … : …` | +| `resolve_branches` | guards on loop indices have an answer at transcription time. Left unresolved, `evaluate_block` reads every branch in order and each guarded variable keeps the *last* one — in SolH that silently zeroes two velocity components, which looks plausible rather than broken | + +## The scalar transport family + +The Stokes solutions solve for a velocity-and-pressure pair. The scalar solutions +solve for one field, so they declare `solves = "transport"` and carry a different +set of `fn_*`: + +| | Stokes | transport | +|---|---|---| +| unknowns | `fn_velocity`, `fn_pressure` | `fn_solution` | +| material | `fn_viscosity` | `fn_coefficient` | +| forcing | `fn_bodyforce` | `fn_source` | +| set by | `set_fields(...)` | `set_scalar_field(...)` | +| residual | momentum + incompressibility | `transport_residual` / `diffusion_residual` | + +These were already in the repository — written inline in the tests that used them. +Collecting them gains the residual check, which they never had, and a name to cite. + +| solution | equation | previously | +|---|---|---| +| `Poisson1D` | $\nabla^2 u + f = 0$, three sources | `test_1000_poissonCart.py` | +| `TwoLayerDarcy` | $\nabla\cdot[k(\nabla p + S\hat z)] = 0$ across a permeability jump | `test_1004_DarcyCartesian.py` | +| `ErfcDiffusion` | $\partial_t u = D\nabla^2 u$ | `test_1005_TransientDarcyCartesian.py` | +| `AdvectedFront` | $\partial_t c + u\,\partial_x c = \kappa\,\partial_{xx} c$ | `test_1100_AdvDiffCartesian.py` | + +The transient ones expose time as a symbol, so a solver is checked at whatever +time it actually reached: + +```python +sol = uw.analytic.ErfcDiffusion(mesh, diffusivity=0.5) +exact = sol.fn_solution.subs(sol.t, t_end) +``` + +Starting a comparison from a smooth profile at $t > 0$ rather than from the step +itself is the point of using these: the step is not representable on the mesh, +which is what makes `test_1100`'s current comparison fragile. + +They cannot reuse the Stokes boundary-condition mixins, which apply a *velocity*. +`_Transport` prescribes `fn_solution` on every wall instead. + +### Transient solutions are singular at t = 0, and that changes how you use them + +Every diffusive similarity solution here — `ErfcDiffusion`, `AdvectedFront`, +`GardnerTransient` — is a step with unbounded gradient at $t = 0$. **That state +cannot be represented on any mesh.** Two consequences, and both are enforced in +code rather than left to the reader: + +**You cannot start at $t = 0$.** `sol.at(t)` refuses $t \le 0$ with an +explanation instead of returning the singular profile. Returning it would only +ever produce a benchmark measuring its own projection error. + +**A transient benchmark is a pair of times, never one.** You initialise from the +solution at $t_0$ and compare at $t_1$, and the error depends on *both*. An +error quoted at a single time is not interpretable — a small $t_0$ means +starting from a profile the mesh cannot hold, and the error you then attribute +to the timestepper is mostly initial projection error. + +**And $t_0$ has a floor set by the mesh, not by the solution.** The front has +width $\sim 2\sqrt{Dt}$; asking it to span $n$ elements of size $h$ gives + +$$t_0 \ge \frac{1}{D}\left(\frac{n h}{2}\right)^2$$ + +which is `sol.earliest_resolvable_time(h, elements_across=4)`. It falls as +$h^2$, so refining buys an earlier start quickly. + +```python +t0 = sol.earliest_resolvable_time(mesh.get_min_radius()) +field.array = uw.function.evaluate(sol.at(t0), field.coords) +... # step to t1 +error = sol.error("solution", field) # against sol.at(t1) +``` + +#### What this diagnoses + +`tests/test_1100_AdvDiffCartesian.py` has carried an `xfail` describing itself +as "not a great test", with a note saying it needs "an error-function IC +starting at $t > 0$ with a meaningful transport distance". The floor says +exactly how badly, at its own parameters ($res=24$, $\kappa=1$, $u=1/24$, +$t_0=10^{-4}$, $t_1=2\times10^{-4}$): + +| | | +|---|---| +| earliest resolvable $t_0$ | $3.5\times10^{-3}$ — the test starts **35× too early** | +| front width at its $t_0$ | 0.68 elements, i.e. narrower than one cell | +| transport over the whole run | $4.2\times10^{-6}$ = **0.0001 elements** | + +So it initialises a profile the mesh cannot represent and then advects it by a +ten-thousandth of a cell. It measures neither advection nor diffusion, which is +why it has always been sensitive to which evaluation path `uw.function.evaluate` +happens to take. Reworking it needs a timestep convergence study, not just new +constants — a resolution-consistent setup at $res=24$ still shows 11% error in +five steps, dominated by time discretisation. That is left as follow-up work. + +### The four tests now use them + +The inline copies are gone; each test keeps its assertions and tolerances and +only swaps the expression for the class. Two details worth knowing: + +`TwoLayerDarcy` was generalised to take the column extent and a gravity term +$S$, because the Darcy test is posed on $y \in (-1, 0)$ and runs the case twice, +with and without gravity. The profile is now **derived** from constant flux +rather than transcribed from the test's closed form — so agreement is a check on +both, not a copy of one. It matches to 1.1e-16 in both cases. + +Its arguments are `k_lower` / `k_upper` rather than `k1` / `k2`, because +`test_1004`'s `k1` is the *upper* layer and the opposite reading is silent: it +produces a perfectly smooth wrong answer. + +### The residual has to be the equation the solution actually solves + +`AdvectedFront` reported a residual of 1.44 against 0.00 for everything else. The +solution was right; the check was the wrong equation. `diffusion_residual` tested +$\partial_t u = \nabla\cdot(k\nabla u)$, and an advecting front does not satisfy +that — it satisfies advection-diffusion. + +The failure is worth recording because of how it presents: an order-one residual +next to a column of zeros reads unambiguously as a broken solution, and the +tempting next step is to go looking for the transcription error. The fix was to +include the advection term, which is the general case — a purely diffusive +solution declares no advecting velocity and the term drops out, leaving the other +three at zero exactly as before. The lesson mirrors Gate 4's: a residual only +means something if it is the residual of the right equation, and a check narrower +than the family it is applied to will convict a correct solution. + +## The Richards family + +Unsaturated flow is the one nonlinear scalar equation in the suite: + +$$C(\psi)\,\frac{\partial\psi}{\partial t} + = \nabla\cdot\!\left[K(\psi)\left(\nabla\psi + \hat y\right)\right]$$ + +so it declares `solves = "richards"` and carries `fn_conductivity` and +`fn_capacity` rather than a single coefficient. Gardner's exponential model +$K = K_s e^{\alpha\psi}$ is the case that closes, because $u = e^{\alpha\psi}$ +linearises it *exactly* — under that substitution Richards becomes linear +advection–diffusion in $u$, which is why `GardnerTransient` is an Ogata–Banks +form and shares its shape with `AdvectedFront`. + +| solution | content | +|---|---| +| `GardnerSteady` | constant flux down a column; head is $\ln[(u_0-q^*)e^{-\alpha y} + q^*]/\alpha$ | +| `GardnerTransient` | a wetting front advancing at $V = K_s/\Delta\theta$ | + +Both already existed as NumPy functions in `utilities/retention_curves.py`. Those +functions keep their signatures and now evaluate the same SymPy expression the +classes build, so there is one formula rather than two copies that can drift. + +### Two things this family taught the harness + +**A residual can be degenerate rather than wrong.** The first `richards_residual` +normalised by the flux divergence — which *is* the residual. It reported exactly +`1.00` for a solution that turned out to be exact to the last bit. An order-one +number from a normalised residual is not automatically a failing solution; it can +be a scale that divides the quantity by itself. The fix is to normalise by the +terms that have to *cancel*, kept separately. + +**Not every perturbation is a negative control.** Gate 5 says a check that passes +a broken input is measuring nothing — but scaling $K$ by a constant leaves the +steady residual at zero, and that is correct: it is a genuine symmetry of +$\nabla\cdot[K(\nabla\psi+\hat y)]=0$, not a defect the gate missed. A control has +to break something the solution actually asserts. Three that do: a wrong $\alpha$ +inside $K$ (0.64), a head scaled by 1% (0.0099, tracking the perturbation), and a +conductivity that ignores the head at all (1.0). Both facts are asserted in +`tests/test_1026_analytic_richards.py`, the symmetry included, so neither is left +as a claim in prose. + +### `erfc` is not in NumPy + +`lambdify(..., "numpy")` falls back to the scalar `math.erfc` without complaint; +the failure surfaces much later as `only 0-dimensional arrays can be converted to +Python scalars`, from generated code, at the first array. It went unseen for as +long as it did because differentiating an `erfc` removes it — every earlier +residual differentiated, and only the Richards head keeps one inside a logarithm. +`_validation.sample` now asks for `["scipy", "numpy"]`. + +## Provenance + +Each vendored reference kernel keeps its original copyright header. + +| Source | Licence | Compatible with UW3 (LGPL-3-or-later) | +|---|---|---| +| Underworld2 `Velic_sol*` kernels | LGPL-3 | Yes — same licence | +| PETSc `ex69.c`, `ex13.c`, `ex24.c`, `ex45.c` | BSD-2-Clause | Yes — permissive; retain the notice | +| Schmid & Podladchikov MATLAB (`dwschmid/muskhelishvili`) | BSD-3-Clause | Yes — permissive; retain the notice | +| `assess` (Kramer et al. 2021) | External, optional dependency | Not vendored; wrapped lazily | + +## Optional dependencies + +A solution requiring a package Underworld3 does not depend on is wrapped lazily, +in the style SciPy uses for its optional backends: the import happens at +construction, and its absence raises with an install message rather than breaking +`import underworld3`. `uw.analytic.available()` lists such solutions and marks +them unavailable rather than omitting them — omitting would make the listing +truthful about what constructs and silent about what exists, leaving a user with +no way to discover that a benchmark is one `pip install` away. + +There is one: `CylindricalStokes`, wrapping `assess` (Kramer et al. 2021) for +curved-geometry Stokes. It is declared as the `benchmarks` extra: + +```bash +pip install "underworld3[benchmarks]" +``` + +Four scripts under `docs/examples/` already imported `assess` while nothing +declared it, so on a normal install they failed with a bare +`ModuleNotFoundError`. + +`assess` is also a dev dependency in `pixi.toml`, so the tests actually run +rather than skipping themselves into a green tick. + +### It is an oracle, not a member of the family + +`assess` gives numeric callables, so there is nothing to differentiate — a +Kramer solution can be compared against a solver but **cannot be checked against +the equations it claims to solve**. None of the six gates reaches it. + +What can still be asked is asked by finite differences, which is the same idea +as Gate 4 with a weaker instrument. All four cases give +$|\nabla\cdot\mathbf u|/|\mathbf u| \approx 10^{-9}$ — the difference floor at +$h=10^{-6}$, so machine-level. Free slip gives $\mathbf u\cdot\hat n \sim +10^{-17}$ on both arcs while $|\mathbf u| \sim 10^{-2}$ there, and zero slip +gives $|\mathbf u| \sim 10^{-17}$ on the walls with $10^{-5}$ inside. Each of +those carries its own control: the free-slip wall is demonstrably *slipping*, so +$\mathbf u\cdot\hat n = 0$ is not passing because everything is zero, and the +divergence probe is checked against $\mathbf u = (x, y)$ to confirm it reports 2 +rather than reporting 0 for everything. + +### Testing the absent path once the package is present + +Installing `assess` silently removed the coverage that mattered most: the +missing-dependency path is what a normal install takes, and it was the thing the +previous arrangement got wrong. Skipping it whenever the package is present +means it never runs in CI. + +So absence is **simulated** — `builtins.__import__` and `importlib.util.find_spec` +are monkeypatched — and the fixture has its own negative control asserting that +the simulation actually blocks. Without that, a Python change to import +resolution would let every one of those tests pass by importing the real +package while appearing to cover a path they never touch. + +That is a real gap, not a technicality, so it is declared rather than +described: `symbolic = False`, and the conformance sweep excludes on the +declaration. The sweep then asserts what it excluded and why, so an accidental +exclusion — a mistyped class attribute — fails the suite instead of quietly +shrinking it. + +Two class attributes carry this: + +| attribute | meaning | +|---|---| +| `symbolic` | fields are SymPy on `mesh.X`. False means the residual gates cannot be applied at all | +| `requires` | name of an optional package, or `None` | + +## Adding a new solution + +### The format to supply a new solution in + +If you are deriving a solution rather than porting one, supply it in the form +below and it drops in with no translation step. This is the format to ask +contributors for. + +**Fields as SymPy expressions in the mesh coordinates**, not as callables, +lambdas, or NumPy code. `mesh.X` gives the coordinate symbols; build everything +from those. Expressions route through the JIT, so an analytic viscosity or body +force compiles to C *and* supplies its own Jacobian — neither of which a numeric +callable can do. `CylindricalStokes` is the one exception in the suite and it +pays for it: none of the six gates can reach it. + +**Do not simplify.** Preserve whatever grouping the derivation produced. The +Maple grouping in the Velic kernels is what keeps `sinh(k)*exp(-k)` products +stable at large wavenumber; a re-derived, "tidier" grouping can lose eight +digits. `cse=True` at evaluation time recovers the sharing anyway. + +**State the stress convention explicitly** if the solution has one — deviatoric +or total. It is a declaration (`stress_is_deviatoric`), never inferred, because +the family is not consistent and one kernel writes its deviator into an array +named `total_stress`. Getting it wrong leaves the momentum residual at order +$|\mathbf f|$ and looks like a transcription failure rather than a convention +one. + +**Give the equation the solution solves**, not just the answer. The oracle-free +residual is the strongest check in the suite — it caught four defects that +comparison against the source could not, including a published stress that is +simply wrong. It needs to know what to substitute back into. + +**Say whether it is singular anywhere**, in time or in space. A $t = 0$ +similarity singularity needs `singular_at_origin = True` and a `diffusivity`; +a spatial singularity (the inclusion's foci, SolCx's isoviscous limit) needs a +`sample_points` override so the validation harness does not land on it. + +**Say what parameter ranges it is valid over.** SolKx is only free-slip for +integer wavenumbers; the Gardner solutions require $\psi < 0$. Ranges become +constructor validation, which is where they stop being folklore. + +1. Subclass `AnalyticSolution` and one of the boundary-condition mixins + (`FreeSlipWalls`, `FixedWalls`) — or `_Transport` for a scalar solution. +2. Build the exact fields on `mesh.X` in `__init__`; set `dim`, `reference`, and + the `eqn_*` LaTeX strings that document the *problem*. Set the fields through + `set_fields` (Stokes) or `set_scalar_field` (transport) rather than assigning + `fn_*` directly — that is where the stress convention and the advection term + are applied, and the conformance suite trusts the declaration. +3. Export it from `underworld3/analytic/__init__.py` and register it in + `_SOLUTIONS` — a namespace entry and the registry entry land in the same PR. +4. If it was transcribed from a reference kernel, clear all six gates and pin the + validated values. +5. Add a convergence test: the error must decrease under refinement *and* clear an + absolute floor. diff --git a/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Annulus_Benchmark_Kramer.py b/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Annulus_Benchmark_Kramer.py index 8f1dafcc6..57a234e55 100644 --- a/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Annulus_Benchmark_Kramer.py +++ b/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Annulus_Benchmark_Kramer.py @@ -72,7 +72,12 @@ import numpy as np import sympy import os -import assess +# `assess` (Kramer et al. 2021) is an optional dependency: pip install +# "underworld3[benchmarks]". Imported through the wrapper so a missing install +# reports what to do about it rather than a bare ModuleNotFoundError. +from underworld3.analytic.kramer import require_assess + +assess = require_assess() import h5py from enum import Enum diff --git a/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Cartesian_SolC.py b/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Cartesian_SolC.py index 6e8b19ee5..bed5cf120 100644 --- a/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Cartesian_SolC.py +++ b/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Cartesian_SolC.py @@ -14,38 +14,41 @@ # %% [markdown] """ -# Stokes Benchmark SolCx +# Stokes Benchmark SolC **PHYSICS:** fluid_mechanics **DIFFICULTY:** advanced ## Description -The SolCx benchmark tests the Stokes solver with a sharp viscosity contrast. -A vertical step in viscosity at x = 0.5 creates a challenging test case for -iterative solvers. Compares Dirichlet and natural boundary conditions. +SolC is the *isoviscous* benchmark: a dense column occupying half the box drives +flow through a fluid of uniform viscosity. The difficulty is the discontinuous +forcing, not a material contrast — the pressure has a kink at the column edge +that a discretisation has to resolve. + +(For the sharp **viscosity** contrast, see SolCx. This file was previously +titled and documented as SolCx while solving SolC, which is worth knowing if +you are comparing old output.) ## Key Concepts -- **Viscosity jump**: Step function viscosity contrast (10^6) -- **Benchmark validation**: Comparison with analytical solution (if UW2 available) -- **Natural vs Dirichlet BCs**: Free-slip implemented different ways -- **Piecewise functions**: Using sympy.Piecewise for sharp interfaces -- **Multigrid preconditioning**: Essential for high viscosity contrast +- **Discontinuous body force**: buoyancy steps at x = 0.5, viscosity is uniform +- **Benchmark validation**: against `uw.analytic.SolC`, in-tree and validated +- **Truncated series**: the exact solution is a Fourier sum, `modes` terms of it +- **Free slip on all walls**, with a pressure null space ## Mathematical Formulation -Viscosity step function: -$$\\eta(x) = \\begin{cases} 10^6 & x > 0.5 \\\\ 1 & x \\le 0.5 \\end{cases}$$ - -Buoyancy forcing: -$$f_y = -\\cos(\\pi x) \\sin(2\\pi y)$$ +Uniform viscosity $\\eta = 1$, with buoyancy stepping at the column edge. Both +the forcing and the exact velocity come from `uw.analytic.SolC`, so they cannot +disagree about sign or side — see the note in the validation section below. ## Parameters - `uw_resolution`: Mesh resolution - `uw_refinement`: Mesh refinement level -- `uw_viscosity_contrast`: log10 of viscosity contrast +- `uw_modes`: Fourier modes in the SolC analytic solution +- `uw_viscosity_contrast`: log10 contrast, used by the SolCx section """ # %% [markdown] @@ -87,14 +90,15 @@ params = uw.Params( uw_resolution = 4, # Base mesh resolution uw_refinement = 2, # Mesh refinement levels - uw_viscosity_contrast = 6, # log10 of viscosity contrast uw_use_simplex = 1, # Use simplex mesh (1) or quad (0) uw_penalty = 100, # Stokes penalty parameter + uw_modes = 40, # Fourier modes in the SolC analytic solution + uw_viscosity_contrast = 6, # log10 contrast, for the SolCx section below ) # Derived parameters -eta_ratio = 10 ** params.uw_viscosity_contrast use_simplex = bool(params.uw_use_simplex) +eta_ratio = 10 ** params.uw_viscosity_contrast # %% [markdown] """ @@ -159,20 +163,19 @@ """ # %% -eta_0 = 1 x_c = sympy.Rational(1, 2) -f_0 = 1 + +# The exact solution supplies the forcing as well as the answer. Writing the +# body force out by hand here is how this file came to solve a mirrored, +# sign-flipped problem from the one it compared against: SolC's buoyancy is +# negative on x < x_c, and the Piecewise previously used was +1 on x > x_c. +# Nobody noticed, because the comparison sat behind an `import underworld` that +# always failed. +solC = uw.analytic.SolC(mesh, x_c=x_c, modes=int(params.uw_modes)) stokes.penalty = params.uw_penalty -stokes.bodyforce = sympy.Matrix( - [ - 0, - Piecewise( - (f_0, x > x_c), - (0.0, True), - ), - ] -) +stokes.constitutive_model.Parameters.shear_viscosity_0 = solC.fn_viscosity +stokes.bodyforce = solC.fn_bodyforce # Free-slip boundary conditions (Dirichlet form) stokes.add_dirichlet_bc((sympy.oo, 0.0), "Top") @@ -208,6 +211,25 @@ # %% stokes.solve() +# %% [markdown] +""" +## Validation against the analytic solution + +This has to happen **here**, not at the end of the file. Everything below +reconfigures the same solver for other experiments, and `v` then holds those +answers rather than this one — which is exactly how the old check came to +compare a SolC analytic solution against a SolCx solve with penalty boundary +conditions. +""" + +# %% +# `error` is a global reduction, so this is the same number on any rank count. +solC_velocity_error = solC.error("velocity", v) +solC_pressure_error = solC.error("pressure", p) + +uw.pprint(f"SolC relative velocity error: {solC_velocity_error:.6e}") +uw.pprint(f"SolC relative pressure error: {solC_pressure_error:.6e}") + # %% [markdown] """ ## SolCx Benchmark Configuration @@ -231,7 +253,7 @@ timing.reset() timing.start() stokes.solve(zero_init_guess=True) -timing.print_table(display_fraction=0.999) +timing.print_table() # see #499: display_fraction was removed from the API # Save solution with Dirichlet BCs v0.data[...] = v.data[...] @@ -256,7 +278,7 @@ timing.reset() timing.start() stokes.solve() -timing.print_table(display_fraction=0.999) +timing.print_table() # see #499: display_fraction was removed from the API v1.data[...] = v.data[...] @@ -278,7 +300,7 @@ timing.reset() timing.start() stokes.solve() -timing.print_table(display_fraction=0.999) +timing.print_table() # see #499: display_fraction was removed from the API # %% [markdown] """ @@ -330,32 +352,19 @@ show_scalar_bar=False, ) - pl.show(cpos="xy") - -# %% [markdown] -""" -## Validation Against UW2 (if available) -""" + # Only when there is somewhere to show it. Guarded on mpi.size alone, this + # blocks a script run forever waiting on a window that never opens — which + # is why running this file to completion was not something anyone had done. + if uw.is_notebook: + pl.show(cpos="xy") # %% -try: - import underworld as uw2 - - solC = uw2.function.analytic.SolC() - vel_soln_analytic = solC.fn_velocity.evaluate(mesh.X.coords) - from mpi4py import MPI - from numpy import linalg as LA - - comm = MPI.COMM_WORLD - - num = function.evaluate(v.fn, mesh.X.coords) - if comm.rank == 0: - print(f"Velocity difference norm: {LA.norm(v.data - vel_soln_analytic):.6e}") - comm.barrier() -except ImportError: - import warnings - - warnings.warn("Unable to validate against UW2 analytical solution (UW2 not available).") - -# %% -print(f"SolCx benchmark complete: resolution {n_els}, refinement {refinement}") +uw.pprint( + f"Complete: resolution {n_els}, refinement {refinement}, " + f"modes {int(params.uw_modes)}" +) +uw.pprint(f" SolC velocity error (validated above): {solC_velocity_error:.6e}") +uw.pprint( + " The SolCx and natural-BC solves that follow it are BC experiments, " + "not benchmarks — nothing here compares them against an exact solution." +) diff --git a/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Cartesian_SolNL.py b/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Cartesian_SolNL.py index a37dcd358..c91609341 100644 --- a/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Cartesian_SolNL.py +++ b/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Cartesian_SolNL.py @@ -78,24 +78,19 @@ # %% # NL problem # Create solution functions -from underworld3.function.analytic import ( - AnalyticSolNL_velocity, - AnalyticSolNL_bodyforce, - AnalyticSolNL_viscosity) +from underworld3 import analytic as A x, y = mesh.X -r = mesh.r eta0 = 1.0 n = 1 r0 = 1.5 -params = (eta0, n, r0) -sol_bf_ijk = AnalyticSolNL_bodyforce(*params, *r) -sol_vel_ijk = AnalyticSolNL_velocity(*params, *r) -sol_bf = mesh.vector.to_matrix(sol_bf_ijk) -sol_vel = mesh.vector.to_matrix(sol_vel_ijk) -sol_visc = AnalyticSolNL_viscosity(*params, *r) +sol = A.SolNL(mesh, eta_0=eta0, n=n, r=r0) + +sol_vel = sol.fn_velocity +sol_bf = sol.fn_bodyforce +sol_visc = sol.fn_viscosity # debug - are problems just because there is no analytic solution module on mac # The solNL case is a MMS force term (complicated) designed to produce a specific diff --git a/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Spherical_Benchmark_Kramer.py b/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Spherical_Benchmark_Kramer.py index 6b1a60c88..b6ff0488b 100644 --- a/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Spherical_Benchmark_Kramer.py +++ b/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Spherical_Benchmark_Kramer.py @@ -79,7 +79,12 @@ import numpy as np import sympy import os -import assess +# `assess` (Kramer et al. 2021) is an optional dependency: pip install +# "underworld3[benchmarks]". Imported through the wrapper so a missing install +# reports what to do about it rather than a bare ModuleNotFoundError. +from underworld3.analytic.kramer import require_assess + +assess = require_assess() import h5py import sys diff --git a/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Spherical_Benchmark_Thieulot.py b/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Spherical_Benchmark_Thieulot.py index baec41cdf..4e85e5f18 100644 --- a/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Spherical_Benchmark_Thieulot.py +++ b/docs/examples/fluid_mechanics/advanced/Ex_Stokes_Spherical_Benchmark_Thieulot.py @@ -81,7 +81,12 @@ import numpy as np import sympy as sp import os -import assess +# `assess` (Kramer et al. 2021) is an optional dependency: pip install +# "underworld3[benchmarks]". Imported through the wrapper so a missing install +# reports what to do about it rather than a bare ModuleNotFoundError. +from underworld3.analytic.kramer import require_assess + +assess = require_assess() import h5py import sys from petsc4py import PETSc diff --git a/docs/examples/snesfas_investigation/benchmark_3way.py b/docs/examples/snesfas_investigation/benchmark_3way.py index 1a9625e4f..b4f7aa30e 100644 --- a/docs/examples/snesfas_investigation/benchmark_3way.py +++ b/docs/examples/snesfas_investigation/benchmark_3way.py @@ -19,7 +19,7 @@ import numpy as np import sympy import underworld3 as uw -from underworld3.function import analytic as A +from underworld3 import analytic as A fv = importlib.util.module_from_spec( importlib.util.spec_from_file_location("fv", os.path.join(os.path.dirname(os.path.abspath(__file__)), "fas_vanka.py"))) diff --git a/docs/examples/utilities/advanced/Ex_Stokes_Annulus_Benchmark_Kramer_etal.py b/docs/examples/utilities/advanced/Ex_Stokes_Annulus_Benchmark_Kramer_etal.py index 6a2143f92..2060585ce 100644 --- a/docs/examples/utilities/advanced/Ex_Stokes_Annulus_Benchmark_Kramer_etal.py +++ b/docs/examples/utilities/advanced/Ex_Stokes_Annulus_Benchmark_Kramer_etal.py @@ -44,7 +44,12 @@ import os import matplotlib.pyplot as plt import cmcrameri.cm as cmc -import assess +# `assess` (Kramer et al. 2021) is an optional dependency: pip install +# "underworld3[benchmarks]". Imported through the wrapper so a missing install +# reports what to do about it rather than a bare ModuleNotFoundError. +from underworld3.analytic.kramer import require_assess + +assess = require_assess() # - os.environ["SYMPY_USE_CACHE"] = "no" diff --git a/docs/examples/utilities/advanced/Ex_Stokes_Annulus_Benchmark_Thieulot.py b/docs/examples/utilities/advanced/Ex_Stokes_Annulus_Benchmark_Thieulot.py index 2fc3eb826..23a79a5d1 100644 --- a/docs/examples/utilities/advanced/Ex_Stokes_Annulus_Benchmark_Thieulot.py +++ b/docs/examples/utilities/advanced/Ex_Stokes_Annulus_Benchmark_Thieulot.py @@ -44,7 +44,12 @@ import os import matplotlib.pyplot as plt import cmcrameri.cm as cmc -import assess +# `assess` (Kramer et al. 2021) is an optional dependency: pip install +# "underworld3[benchmarks]". Imported through the wrapper so a missing install +# reports what to do about it rather than a bare ModuleNotFoundError. +from underworld3.analytic.kramer import require_assess + +assess = require_assess() # - os.environ["SYMPY_USE_CACHE"] = "no" diff --git a/pixi.lock b/pixi.lock index fe6915470..8bd07ac7f 100644 --- a/pixi.lock +++ b/pixi.lock @@ -1171,6 +1171,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py312h5253ce2_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - pypi: https://files.pythonhosted.org/packages/e5/70/7b0fd9c1a738f59d3babe2b4212031c34ab7d0fda4ffef15b58a55c5bcea/anthropic-0.76.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/db/56/df7622ef4c96b61a5563a6dd999f10c19ef4da113906a1718f4a0b856a2a/assess-1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/63/22/27f88a5a1c48b833c5893a23b71cf74b5270fac82d88e337ec2c138e8853/gmsh-4.15.0-py2.py3-none-manylinux_2_24_x86_64.whl @@ -1648,6 +1649,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstandard-0.25.0-py312h37e1c23_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - pypi: https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/db/56/df7622ef4c96b61a5563a6dd999f10c19ef4da113906a1718f4a0b856a2a/assess-1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/b0/a5c659d2b5180c037ff2304e459e0c142015de510ac68d4f100770c7d89d/gmsh-4.15.1-py2.py3-none-macosx_12_0_arm64.whl @@ -2183,6 +2185,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py312h5253ce2_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - pypi: https://files.pythonhosted.org/packages/e5/70/7b0fd9c1a738f59d3babe2b4212031c34ab7d0fda4ffef15b58a55c5bcea/anthropic-0.76.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/db/56/df7622ef4c96b61a5563a6dd999f10c19ef4da113906a1718f4a0b856a2a/assess-1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/63/22/27f88a5a1c48b833c5893a23b71cf74b5270fac82d88e337ec2c138e8853/gmsh-4.15.0-py2.py3-none-manylinux_2_24_x86_64.whl @@ -2660,6 +2663,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstandard-0.25.0-py312h37e1c23_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - pypi: https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/db/56/df7622ef4c96b61a5563a6dd999f10c19ef4da113906a1718f4a0b856a2a/assess-1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/b0/a5c659d2b5180c037ff2304e459e0c142015de510ac68d4f100770c7d89d/gmsh-4.15.1-py2.py3-none-macosx_12_0_arm64.whl @@ -3835,6 +3839,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py312h5253ce2_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - pypi: https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/db/56/df7622ef4c96b61a5563a6dd999f10c19ef4da113906a1718f4a0b856a2a/assess-1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/5d/339b995273c25a79bad5144ffb4fe57f4428d9ad2603942c851a66376afd/gmsh-4.15.1-py2.py3-none-manylinux_2_24_x86_64.whl @@ -4313,6 +4318,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstandard-0.25.0-py312h37e1c23_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - pypi: https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/db/56/df7622ef4c96b61a5563a6dd999f10c19ef4da113906a1718f4a0b856a2a/assess-1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/b0/a5c659d2b5180c037ff2304e459e0c142015de510ac68d4f100770c7d89d/gmsh-4.15.1-py2.py3-none-macosx_12_0_arm64.whl @@ -5496,6 +5502,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py312h5253ce2_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - pypi: https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/db/56/df7622ef4c96b61a5563a6dd999f10c19ef4da113906a1718f4a0b856a2a/assess-1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/5d/339b995273c25a79bad5144ffb4fe57f4428d9ad2603942c851a66376afd/gmsh-4.15.1-py2.py3-none-manylinux_2_24_x86_64.whl @@ -5976,6 +5983,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstandard-0.25.0-py312h37e1c23_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - pypi: https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/db/56/df7622ef4c96b61a5563a6dd999f10c19ef4da113906a1718f4a0b856a2a/assess-1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/b0/a5c659d2b5180c037ff2304e459e0c142015de510ac68d4f100770c7d89d/gmsh-4.15.1-py2.py3-none-macosx_12_0_arm64.whl @@ -8103,6 +8111,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py312h5253ce2_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - pypi: https://files.pythonhosted.org/packages/e5/70/7b0fd9c1a738f59d3babe2b4212031c34ab7d0fda4ffef15b58a55c5bcea/anthropic-0.76.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/db/56/df7622ef4c96b61a5563a6dd999f10c19ef4da113906a1718f4a0b856a2a/assess-1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/63/22/27f88a5a1c48b833c5893a23b71cf74b5270fac82d88e337ec2c138e8853/gmsh-4.15.0-py2.py3-none-manylinux_2_24_x86_64.whl @@ -8600,6 +8609,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstandard-0.25.0-py312h37e1c23_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - pypi: https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/db/56/df7622ef4c96b61a5563a6dd999f10c19ef4da113906a1718f4a0b856a2a/assess-1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/b0/a5c659d2b5180c037ff2304e459e0c142015de510ac68d4f100770c7d89d/gmsh-4.15.1-py2.py3-none-macosx_12_0_arm64.whl @@ -10168,6 +10178,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py312h5253ce2_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - pypi: https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/db/56/df7622ef4c96b61a5563a6dd999f10c19ef4da113906a1718f4a0b856a2a/assess-1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/5d/339b995273c25a79bad5144ffb4fe57f4428d9ad2603942c851a66376afd/gmsh-4.15.1-py2.py3-none-manylinux_2_24_x86_64.whl @@ -10667,6 +10678,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstandard-0.25.0-py312h37e1c23_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - pypi: https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/db/56/df7622ef4c96b61a5563a6dd999f10c19ef4da113906a1718f4a0b856a2a/assess-1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/b0/a5c659d2b5180c037ff2304e459e0c142015de510ac68d4f100770c7d89d/gmsh-4.15.1-py2.py3-none-macosx_12_0_arm64.whl @@ -11908,6 +11920,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/linux-64/zstandard-0.25.0-py312h5253ce2_1.conda - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda - pypi: https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/db/56/df7622ef4c96b61a5563a6dd999f10c19ef4da113906a1718f4a0b856a2a/assess-1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/bc/5d/339b995273c25a79bad5144ffb4fe57f4428d9ad2603942c851a66376afd/gmsh-4.15.1-py2.py3-none-manylinux_2_24_x86_64.whl @@ -12408,6 +12421,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstandard-0.25.0-py312h37e1c23_1.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hbf9d68e_6.conda - pypi: https://files.pythonhosted.org/packages/64/ca/218fa25002a332c0aa149ba18ffc0543175998b1f65de63f6d106689a345/anthropic-0.84.0-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/db/56/df7622ef4c96b61a5563a6dd999f10c19ef4da113906a1718f4a0b856a2a/assess-1.4-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/a6/b0/a5c659d2b5180c037ff2304e459e0c142015de510ac68d4f100770c7d89d/gmsh-4.15.1-py2.py3-none-macosx_12_0_arm64.whl @@ -13744,6 +13758,13 @@ packages: - pkg:pypi/arrow?source=hash-mapping size: 113854 timestamp: 1760831179410 +- pypi: https://files.pythonhosted.org/packages/db/56/df7622ef4c96b61a5563a6dd999f10c19ef4da113906a1718f4a0b856a2a/assess-1.4-py3-none-any.whl + name: assess + version: '1.4' + sha256: c676e1c6bc1abd5bac1fe0886fdc2e9450665f31fb2e457be5610ff9c350e372 + requires_dist: + - numpy + - scipy>=1.15.0 - conda: https://conda.anaconda.org/conda-forge/noarch/asttokens-3.0.1-pyhd8ed1ab_0.conda sha256: ee4da0f3fe9d59439798ee399ef3e482791e48784873d546e706d0935f9ff010 md5: 9673a61a297b00016442e022d689faa6 diff --git a/pixi.toml b/pixi.toml index c0eafa97e..10c37245c 100644 --- a/pixi.toml +++ b/pixi.toml @@ -332,6 +332,13 @@ anthropic = "*" sphinx-math-dollar = "*" sphinxcontrib-mermaid = "*" +# Kramer et al. (2021) curved-geometry Stokes solutions, behind +# uw.analytic.CylindricalStokes and the annulus/spherical benchmark examples. +# A dev dependency rather than a runtime one: it is the `benchmarks` extra in +# pyproject.toml for users, and here so that tests/test_1027 actually runs +# instead of skipping itself into a green tick. Pure Python, ~12 kB. +assess = "*" + [feature.dev.tasks] install-claude = "npm install -g @anthropic-ai/claude-code" claude = "claude" diff --git a/pyproject.toml b/pyproject.toml index 9d3f7fee2..f3ebd7c08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,14 @@ classifiers = [ "Topic :: Scientific/Engineering :: Physics", ] +[project.optional-dependencies] +# Curved-geometry Stokes benchmarks. `assess` (Kramer et al. 2021, +# doi:10.5194/gmd-14-1899-2021) backs uw.analytic.CylindricalStokes and the +# annulus/spherical benchmark scripts under docs/examples/. Optional rather than +# required: it is needed only to run those benchmarks, and everything else in +# uw.analytic is self-contained SymPy. +benchmarks = ["assess"] + [project.urls] Homepage = "https://github.com/underworldcode/underworld3" Documentation = "https://underworldcode.github.io/underworld3/" diff --git a/setup.py b/setup.py index bdd0bee0f..8d46fcac3 100644 --- a/setup.py +++ b/setup.py @@ -238,13 +238,17 @@ def configure(): define_macros=[("NPY_NO_DEPRECATED_API", "NPY_1_7_API_VERSION")], **conf, ), + # The published reference kernels. Analytic solutions are delivered as SymPy; + # these are kept as the independent oracle each transcription is validated + # against. The headers must install beside the .so — the JIT adds only the + # module's own directory to its include path. Extension( - "underworld3.function.analytic", + "underworld3.analytic._reference._velic", sources=[ - "src/underworld3/function/analytic.pyx", - "src/underworld3/function/AnalyticSolNL.c", - "src/underworld3/function/AnalyticSolCx.c", - "src/underworld3/function/solCx.c", + "src/underworld3/analytic/_reference/_velic.pyx", + "src/underworld3/analytic/_reference/AnalyticSolNL.c", + "src/underworld3/analytic/_reference/AnalyticSolCx.c", + "src/underworld3/analytic/_reference/solCx.c", ], extra_compile_args=extra_compile_args, **conf, @@ -286,7 +290,13 @@ def configure(): name="underworld3", packages=find_packages(), # Version is derived from git tags via setuptools_scm (configured in pyproject.toml) - package_data={"underworld3": ["*.pxd", "*.h", "function/*.h", "cython/*.pxd"]}, + package_data={ + "underworld3": ["*.pxd", "*.h", "function/*.h", "cython/*.pxd"], + # Its own key: the "underworld3" globs above do not reach a directory + # that is itself a package. Missing these headers fails at solve time, + # when the JIT compiles, not at import. + "underworld3.analytic._reference": ["*.h", "*.hpp", "*.c"], + }, ext_modules=cythonize( extensions, compiler_directives={"language_level": "3"}, # or "2" or "3str" diff --git a/src/underworld3/__init__.py b/src/underworld3/__init__.py index e1d989f66..818ff2339 100644 --- a/src/underworld3/__init__.py +++ b/src/underworld3/__init__.py @@ -208,6 +208,9 @@ def view(): import underworld3.materials import underworld3.checkpoint +# After underworld3.function: the analytic suite still sources SolCx from it. +import underworld3.analytic + from .model import ( Model, create_model, diff --git a/src/underworld3/analytic/__init__.py b/src/underworld3/analytic/__init__.py new file mode 100644 index 000000000..f7a458586 --- /dev/null +++ b/src/underworld3/analytic/__init__.py @@ -0,0 +1,174 @@ +r"""Exact solutions for validating Underworld3 solves. + +Every solution here is a closed form we can compare a numerical answer against — +the code's source of truth for benchmarking and convergence testing. They share +one contract (:class:`AnalyticSolution`), so a validation run reads the same way +whatever the solution:: + + sol = uw.analytic.SolCx(mesh, eta_A=1.0, eta_B=1.0e6) + + stokes.constitutive_model.Parameters.shear_viscosity_0 = sol.fn_viscosity + stokes.bodyforce = sol.fn_bodyforce + sol.apply_boundary_conditions(stokes) + + stokes.solve() + rel = sol.error("velocity", stokes.u) + +:func:`available` lists what is here; :func:`describe` summarises one solution +without constructing it. + +These are worth reaching for whenever a solver change needs evidence. The SolCx +port alone caught a direct ``ksponly + lu`` solve silently mangling the singular +saddle on a free-slip problem — a wrong answer that no residual norm reported. + +See Also +-------- +underworld3.analytic._base : the contract each solution satisfies. +""" + +from ._base import AnalyticSolution, FreeSlipWalls, FixedWalls + +from .inclusion import EllipticalInclusion +from .kramer import CylindricalStokes +from .richards import GardnerSteady, GardnerTransient +from .transport import AdvectedFront, ErfcDiffusion, Poisson1D, TwoLayerDarcy +from .velic import SolA, SolB, SolC, SolCx, SolDA, SolDB2d, SolDB3d, SolH, SolKx, SolKz, SolM, SolNL + +__all__ = [ + "AnalyticSolution", + "FreeSlipWalls", + "FixedWalls", + "AdvectedFront", + "EllipticalInclusion", + "ErfcDiffusion", + "CylindricalStokes", + "GardnerSteady", + "GardnerTransient", + "Poisson1D", + "SolA", + "SolB", + "SolC", + "SolCx", + "SolDA", + "SolDB2d", + "SolDB3d", + "SolH", + "SolKx", + "SolKz", + "SolM", + "SolNL", + "TwoLayerDarcy", + "available", + "describe", + "is_available", +] + +# The solutions this namespace offers. Explicit rather than introspected, so the +# listing stays truthful while solutions are still being migrated onto the +# contract, and so a solution needing an optional dependency can be listed +# without being importable. +_SOLUTIONS = { + "AdvectedFront": AdvectedFront, + "CylindricalStokes": CylindricalStokes, + "EllipticalInclusion": EllipticalInclusion, + "ErfcDiffusion": ErfcDiffusion, + "GardnerSteady": GardnerSteady, + "GardnerTransient": GardnerTransient, + "Poisson1D": Poisson1D, + "SolA": SolA, + "SolB": SolB, + "SolC": SolC, + "SolCx": SolCx, + "SolDA": SolDA, + "SolDB2d": SolDB2d, + "SolDB3d": SolDB3d, + "SolH": SolH, + "SolKx": SolKx, + "SolKz": SolKz, + "SolM": SolM, + "SolNL": SolNL, + "TwoLayerDarcy": TwoLayerDarcy, +} + + +def is_available(name): + """Whether *name* can actually be constructed here and now. + + False only when a solution needs an optional package that is not installed. + Kept separate from :func:`available` because a missing optional dependency + should not make a solution disappear — silently shortening the listing hides + the very thing the user needs to be told. + + Parameters + ---------- + name : str + A name from :func:`available`. + + Returns + ------- + bool + """ + + solution = _SOLUTIONS[name] + requires = getattr(solution, "requires", None) + + if requires is None: + return True + + import importlib.util + + return importlib.util.find_spec(requires) is not None + + +def available(installed_only=False): + """Names of the solutions in this namespace, in alphabetical order. + + Parameters + ---------- + installed_only : bool + Omit solutions whose optional dependency is missing. The default lists + everything; use :func:`is_available` to tell them apart, or + :func:`describe`, which says so in words. + + Returns + ------- + list of str + """ + + names = sorted(_SOLUTIONS) + + if installed_only: + return [name for name in names if is_available(name)] + + return names + + +def describe(name): + """One-line summary of a solution, without constructing it. + + Parameters + ---------- + name : str + A name from :func:`available`. + + Returns + ------- + str + The first line of the solution's docstring. + """ + + try: + solution = _SOLUTIONS[name] + except KeyError: + raise ValueError( + f"no analytic solution named {name!r}; available: " + f"{', '.join(available())}" + ) from None + + docstring = solution.__doc__ or "" + summary = docstring.strip().split("\n")[0] + + if not is_available(name): + return f"{summary} [unavailable: needs the '{solution.requires}' package]" + + return summary diff --git a/src/underworld3/analytic/_base.py b/src/underworld3/analytic/_base.py new file mode 100644 index 000000000..8bd5ee775 --- /dev/null +++ b/src/underworld3/analytic/_base.py @@ -0,0 +1,585 @@ +r"""The contract every Underworld3 analytic solution satisfies. + +An analytic solution is a closed-form answer to a problem Underworld3 can also +solve numerically. It supplies the coefficients of that problem (viscosity, body +force, source), the exact fields it produces (velocity, pressure, stress), and +the boundary conditions under which the two agree — so a validation run is three +lines rather than a bespoke script. + +See Also +-------- +underworld3.analytic : the solution registry and the available solutions. +""" + +import numpy as np +import sympy + +import underworld3 as uw +from underworld3.utilities._api_tools import uw_object + + +class AnalyticSolution(uw_object): + r"""Base class for closed-form solutions used to validate a numerical solve. + + A concrete solution builds its exact fields on ``mesh.X`` in ``__init__`` and + sets the class-level metadata below. Everything else — applying the boundary + conditions, evaluating a field at points, measuring the error of a computed + field — is inherited. + + The canonical validation pattern is:: + + sol = uw.analytic.SolCx(mesh, eta_A=1.0, eta_B=1.0e6) + + stokes.constitutive_model.Parameters.shear_viscosity_0 = sol.fn_viscosity + stokes.bodyforce = sol.fn_bodyforce + sol.apply_boundary_conditions(stokes) + + stokes.solve() + rel = sol.error("velocity", stokes.u) + + Attributes + ---------- + dim : int + Spatial dimension the solution is posed in (2 or 3). + nonlinear : bool + Whether the constitutive law depends on the solution itself. + reference : str + Citation for the solution, and the provenance of this implementation. + eqn_velocity, eqn_pressure, eqn_viscosity, eqn_bodyforce : str + LaTeX for the defining equations, shown by :meth:`view`. These document + the *problem*, not the answer: a reader should be able to tell from + ``eqn_viscosity`` and ``eqn_bodyforce`` alone what is being solved. + fn_velocity : sympy.Matrix + Exact velocity, shape ``(1, dim)``. + fn_pressure : sympy.Expr + Exact pressure. + fn_stress : sympy.Matrix + Exact total (Cauchy) stress, shape ``(dim, dim)``. + fn_strainrate : sympy.Matrix + Exact strain rate, shape ``(dim, dim)``. + fn_viscosity : sympy.Expr + The viscosity the solution is posed with — assign this to the solver. + fn_bodyforce : sympy.Matrix + The body force the solution is posed with, shape ``(1, dim)``. + + Notes + ----- + Solutions are pure SymPy expressions on ``mesh.X``. That is a deliberate + single form: it means they compile through the normal JIT path when handed to + a solver, carry their own analytic Jacobian, can be used as Dirichlet boundary + values, and evaluate through :func:`underworld3.function.evaluate` like any + other field. Solutions transcribed from a reference C kernel keep that kernel + in the tree as an independent oracle; see + ``docs/developer/subsystems/analytic-solutions.md`` for the validation + protocol every transcription must pass. + """ + + dim = None + nonlinear = False + reference = "" + + #: Which equation this solution answers. The suite began as Stokes-only and + #: the field names still read that way, so a solution that answers something + #: else says so here rather than being exempted from the checks: the + #: conformance suite dispatches on this, and applies the residual for the + #: equation the solution actually claims. + solves = "stokes" + + #: Whether the fields are SymPy expressions on ``mesh.X``. True for every + #: solution in the suite proper — that is the one-form rule. A solution that + #: wraps an external *numeric* library says so here, and thereby says that + #: the residual gates cannot be applied to it: there is no expression to + #: differentiate, so it can be compared against a solver but never checked + #: against the equations it claims to solve. Treat those as oracles rather + #: than as validated members of the family. + symbolic = True + + #: Name of an optional package this solution needs, or None. A solution with + #: an unmet requirement is still listed by :func:`available` — it just says + #: it is unavailable, because silently omitting it hides the one fact the + #: user needs. + requires = None + + #: Whether this solution's published stress is the deviator rather than the + #: total. Declared, never inferred — the family is not consistent, and one + #: kernel writes its deviator into an array named ``total_stress``. Getting + #: it wrong leaves the momentum residual at order :math:`|\mathbf f|` and + #: invents a body force, which reads like a transcription failure rather + #: than a convention one. See the table in the subsystem documentation. + stress_is_deviatoric = False + + #: Whether validating this solution is expensive enough to keep out of the + #: per-PR test sweep. + #: + #: The cost is intrinsic and predictable from the mathematics rather than a + #: property of any machine: a solution that accumulates over modes (SolC + #: sums forty) or carries an exponential viscosity (SolKx, SolKz) produces + #: expressions with tens of thousands of operations, and every residual gate + #: differentiates them symbolically and runs common-subexpression + #: elimination over the result. Measured, the five solutions flagged here + #: account for 565s of the suite's 1010s while the other eight together cost + #: about 17s. + #: + #: This changes WHICH SOLUTIONS a given run covers, never which checks are + #: applied to a solution. The full family, gates and all, runs from + #: ``tests/analytic_full/``. + expensive_to_validate = False + + #: Whether the source published BOTH a stress and a strain rate, set by + #: :meth:`set_fields` from what it was actually given. + #: + #: This decides what the conformance check + #: ``sigma + p I == 2 eta edot`` is worth. When only one of the two was + #: supplied, :meth:`set_fields` derived the other from exactly that identity, + #: so the check is structural rather than evidential — it re-reads a + #: derivation. When both were supplied it compares two separately published + #: quantities and is a real check. + #: + #: Recorded here rather than inferred from the source text: a solution that + #: bypasses :meth:`set_fields` altogether keeps the ``False`` default, which + #: is the honest answer for it and the one a source scan gets wrong (it finds + #: the parameter names in this class's own signature). + publishes_both_stress_and_strainrate = False + + eqn_velocity = "" + eqn_pressure = "" + eqn_viscosity = "" + eqn_bodyforce = "" + + # Field name -> attribute holding the exact expression. A solution that adds a + # field (a temperature, a pressure head) extends this so error() and + # evaluate() reach it by name. + _fields = { + "solution": "fn_solution", + "coefficient": "fn_coefficient", + "source": "fn_source", + "velocity": "fn_velocity", + "pressure": "fn_pressure", + "stress": "fn_stress", + "strainrate": "fn_strainrate", + "viscosity": "fn_viscosity", + "bodyforce": "fn_bodyforce", + } + + def __init__(self, mesh): + super().__init__() + + if self.dim is not None and mesh.dim != self.dim: + raise ValueError( + f"{type(self).__name__} is a {self.dim}D solution; " + f"this mesh has dim={mesh.dim}." + ) + + self.mesh = mesh + + @property + def boundaries(self): + """The domain walls this solution is posed on. + + The box labels, since the classical solutions are all posed on the unit + box. A solution in another geometry overrides this with its own labels + (an annulus, for instance, has ``Upper`` and ``Lower``). + """ + + walls = ["Left", "Right", "Bottom", "Top"] + if self.mesh.dim == 3: + walls += ["Front", "Back"] + return walls + + def set_scalar_field(self, solution, coefficient, source, advection=None): + r"""Populate the fields of a scalar solution. + + For the transport family — steady diffusion, Darcy flow, Richards — where + the unknown is one field satisfying + :math:`\nabla\cdot(k\,\nabla u) + f = 0` rather than a velocity and a + pressure. + + Parameters + ---------- + solution : sympy expression + The exact field: temperature, pressure head, hydraulic head. + coefficient : sympy expression + Diffusivity, conductivity or permeability — whatever multiplies the + gradient in this solution's equation. + source : sympy expression + The source term the solution is posed with. + advection : sequence, optional + Velocity carrying the field, if it is transported as well as + diffused. Omitted means no advection, which is the common case; + supplying it is what distinguishes an advection-diffusion solution + from a purely diffusive one, and the residual check needs to know. + """ + + self.fn_solution = sympy.sympify(solution) + self.fn_coefficient = sympy.sympify(coefficient) + self.fn_source = sympy.sympify(source) + self.fn_advection = ( + sympy.Matrix([list(advection)]) + if advection is not None + else sympy.zeros(1, self.dim) + ) + + def set_fields( + self, velocity, pressure, viscosity, bodyforce, stress=None, strainrate=None + ): + r"""Populate the ``fn_*`` attributes from a solution's own components. + + Every solution goes through here, so the conventions are applied in one + place instead of being re-derived each time. In particular + :attr:`stress_is_deviatoric` is honoured here and nowhere else: a + solution declares which stress its source publishes and this converts, + rather than each one remembering to subtract the pressure. + + Parameters + ---------- + velocity, bodyforce : sequence + ``dim`` components each. + pressure, viscosity : sympy expression + stress : sequence of sequences, optional + ``dim x dim``. Read as the deviator or the total according to + :attr:`stress_is_deviatoric`. Derived from the strain rate if + omitted. + strainrate : sequence of sequences, optional + ``dim x dim``. Derived from the stress if omitted. + + Notes + ----- + Stress and strain rate are related by :math:`\sigma = -p\,I + 2\eta + \dot\varepsilon`, so either determines the other once the pressure and + viscosity are known; supplying both is fine and is worth doing when the + source publishes both, because then they can be checked against each + other. + """ + + self.fn_velocity = sympy.Matrix([list(velocity)]) + self.fn_bodyforce = sympy.Matrix([list(bodyforce)]) + self.fn_pressure = sympy.sympify(pressure) + self.fn_viscosity = sympy.sympify(viscosity) + + # What the source actually gave us, not what the class says it gives. + # See the attribute's docstring: this is what tells the conformance + # sweep whether its stress/strain-rate check is evidence or bookkeeping. + self.publishes_both_stress_and_strainrate = ( + stress is not None and strainrate is not None + ) + + identity = sympy.eye(self.dim) + + if stress is not None: + given = sympy.Matrix(stress) + deviator = given if self.stress_is_deviatoric else given + self.fn_pressure * identity + elif strainrate is not None: + deviator = 2 * self.fn_viscosity * sympy.Matrix(strainrate) + else: + raise ValueError("a solution must supply either stress or strainrate") + + self.fn_stress = deviator - self.fn_pressure * identity + self.fn_strainrate = ( + sympy.Matrix(strainrate) + if strainrate is not None + else deviator / (2 * self.fn_viscosity) + ) + + def sample_points(self, count=12): + """Points at which this solution can meaningfully be evaluated. + + The default is the unit box or cube, stratified and loaded with the + boundary and the corners. A solution posed on a different domain — or one + with a singularity somewhere a generic sampler would happily land on — + overrides this. The checks in :mod:`underworld3.analytic._validation` ask + the solution rather than assuming, so adding such a solution does not + require touching them. + + Parameters + ---------- + count : int + Number of interior points. + + Returns + ------- + numpy.ndarray + Shape ``(N, dim)``. + """ + + from ._validation import adversarial_points + + return adversarial_points(count=count, dim=self.dim) + + #: Whether this solution is singular at :math:`t = 0`. True for every + #: diffusive similarity solution in the suite: at the origin the profile is + #: a step and its gradient is unbounded, so the state the solution describes + #: at :math:`t = 0` **cannot be represented on any mesh**. Such a solution + #: must be started from a positive time. See :meth:`at`. + singular_at_origin = False + + #: The diffusive scale that sets how fast the singularity heals — the + #: :math:`D` in :math:`\sqrt{Dt}`. Transient solutions set this so that + #: :meth:`earliest_resolvable_time` can answer in numbers rather than + #: advice. + diffusivity = None + + def at(self, time, field=None): + r"""The solution frozen at *time*. + + Parameters + ---------- + time : float + Must be **strictly positive** if :attr:`singular_at_origin`. + field : str, optional + Which field. Defaults to the solution's primary unknown. + + Returns + ------- + sympy expression + + Raises + ------ + ValueError + If the solution has no time symbol, or if *time* is at or before the + singular origin. + + Notes + ----- + **A transient benchmark is a pair of times, never one.** You initialise + the solver from this solution at :math:`t_0` and compare against it at + :math:`t_1`, and the error you measure depends on *both* — a small + :math:`t_0` means starting from a profile the mesh cannot hold, and the + error you then attribute to the timestepper is mostly initial + projection error. Quoting an error at a single time says nothing unless + the start time is quoted with it. + + :math:`t_0` also has a floor that depends on the mesh, not just on the + solution — see :meth:`earliest_resolvable_time`. + """ + + if getattr(self, "t", None) is None: + raise ValueError( + f"{type(self).__name__} is steady — it has no time symbol, so " + f"there is no time to evaluate it at." + ) + + time = float(time) + + if self.singular_at_origin and time <= 0.0: + raise ValueError( + f"{type(self).__name__} is singular at t = 0: the profile there " + f"is a step with unbounded gradient, which no mesh can " + f"represent. Start from a positive time instead — " + f"`earliest_resolvable_time(h)` will tell you how positive it " + f"has to be for your resolution. Got t = {time}." + ) + + expression = self._exact(field) if field is not None else self.fn_solution + + return expression.subs(self.t, time) + + def earliest_resolvable_time(self, h, elements_across=4): + r"""The earliest start time this mesh can actually represent. + + The diffusive front has width :math:`\sim 2\sqrt{Dt}`. Asking for it to + span *elements_across* cells of size *h* gives + + .. math:: + t \ge \frac{1}{D}\left(\frac{n_{\rm el}\,h}{2}\right)^2 + + Below that the exact profile varies faster than the discretisation can + follow, and a benchmark started there measures interpolation error + rather than anything about the solver. The floor falls as + :math:`h^2`, so refining buys an earlier start quickly. + + Parameters + ---------- + h : float + Element size. ``mesh.get_min_radius()`` is a reasonable source. + elements_across : int + How many elements the front should span. Four is a working + minimum; fewer and the initial condition is visibly stepped. + + Returns + ------- + float + Earliest sensible :math:`t_0`. + """ + + if self.diffusivity is None: + raise ValueError( + f"{type(self).__name__} declares no diffusivity, so there is no " + f"diffusive scale to set a floor from." + ) + + return (float(elements_across) * float(h) / 2.0) ** 2 / float(self.diffusivity) + + def _exact(self, field): + """Resolve a field name — or pass an expression straight through.""" + + if not isinstance(field, str): + return field + + try: + return getattr(self, self._fields[field]) + except KeyError: + available = ", ".join(sorted(self._fields)) + raise ValueError( + f"{type(self).__name__} has no field {field!r}; " + f"available: {available}" + ) from None + + def evaluate(self, field, coords): + """Exact values of ``field`` at ``coords``. + + Parameters + ---------- + field : str or sympy expression + A name from the solution's field set (``"velocity"``, + ``"pressure"``, ...), or any SymPy expression in ``mesh.X``. + coords : numpy.ndarray + Evaluation points, shape ``(N, dim)``. + + Returns + ------- + numpy.ndarray + Shape ``(N, *field_shape)``. + """ + + return uw.function.evaluate(self._exact(field), np.asarray(coords)) + + def error(self, field, meshvar, norm="l2"): + r"""Relative error of a computed field against the exact solution. + + Parameters + ---------- + field : str or sympy expression + The exact field to compare against — see :meth:`evaluate`. + meshvar : MeshVariable + The computed field. + norm : {"l2", "integral"} + ``"l2"`` (default) is the discrete nodal relative :math:`L_2` norm + over the variable's own degrees of freedom. ``"integral"`` is the + continuous :math:`L_2` norm integrated over the mesh, which is the + right choice when comparing across different discretisations. + + Returns + ------- + float + The same value on every rank. + + Notes + ----- + Both norms are global. The nodal norm reduces the squared differences and + the squared exact values across ranks *before* dividing, so it does not + depend on the partition — an earlier rank-local version reported an error + 10–20x larger on whichever rank owned the hardest region, e.g. the SolCx + viscosity jump (issue #370). Degrees of freedom shared on a partition + boundary contribute once per owning rank, a small seam weighting that is + acceptable for a benchmark diagnostic. + """ + + exact = self._exact(field) + + if norm == "integral": + zero = ( + sympy.zeros(*exact.shape) + if isinstance(exact, sympy.Matrix) + else sympy.S.Zero + ) + magnitude = uw.maths.L2_norm(zero, exact, self.mesh) + return float(uw.maths.L2_norm(meshvar.sym, exact, self.mesh) / magnitude) + + if norm != "l2": + raise ValueError(f"norm must be 'l2' or 'integral'; got {norm!r}") + + computed = np.asarray(meshvar.array).reshape(len(meshvar.coords), -1) + exact_values = np.asarray(self.evaluate(exact, meshvar.coords)).reshape( + computed.shape + ) + + difference = computed - exact_values + error_squared = uw.mpi.comm.allreduce(float((difference**2).sum())) + exact_squared = uw.mpi.comm.allreduce(float((exact_values**2).sum())) + + return float(np.sqrt(error_squared / exact_squared)) + + def set_richards_field(self, solution, conductivity, capacity): + r"""Declare an unsaturated-flow solution. + + Richards is neither of the other two: the coefficient depends on the + unknown, and there is a capacity term multiplying the time derivative, + so it gets its own declaration rather than being forced into + :meth:`set_scalar_field`. + + Parameters + ---------- + solution : sympy expression + Pressure head :math:`\psi`. + conductivity : sympy expression + :math:`K(\psi)`, in terms of the solution itself. + capacity : sympy expression + :math:`C(\psi) = \mathrm d\theta/\mathrm d\psi`. + """ + + self.fn_solution = sympy.sympify(solution) + self.fn_conductivity = sympy.sympify(conductivity) + self.fn_capacity = sympy.sympify(capacity) + + def apply_boundary_conditions(self, solver): + """Impose the boundary conditions this solution is posed under.""" + + raise NotImplementedError( + f"{type(self).__name__} does not declare its boundary conditions. " + f"Mix in FreeSlipWalls or FixedWalls, or override this method." + ) + + def _object_viewer(self): + from IPython.display import Markdown, display + + display(Markdown(f"**{type(self).__name__}** — {self.dim}D")) + + if self.reference: + display(Markdown(f"*{self.reference}*")) + + for label, equation in ( + ("velocity", self.eqn_velocity), + ("pressure", self.eqn_pressure), + ("viscosity", self.eqn_viscosity), + ("body force", self.eqn_bodyforce), + ): + if equation: + display(Markdown(rf"{label}: $\displaystyle {equation}$")) + + +class FreeSlipWalls: + r"""Mixin: the solution is posed with free slip on every wall. + + Free slip is imposed as a strong *rotated* constraint + (:math:`\mathbf u\cdot\hat{\mathbf n}=0` to machine precision) rather than by + zeroing a velocity component. On an axis-aligned box the two agree; on a + curved, tilted or adapted boundary only the rotated form is correct, so it is + the one that still holds when a solution is used to validate an adapted mesh. + + The domain is enclosed, so the pressure carries a constant nullspace and the + solver is told to remove it. Leaving that out is the failure this whole suite + exists to catch: a direct solve on the singular saddle returns a quiet, wrong + answer that only an exact solution exposes. + """ + + def apply_boundary_conditions(self, solver): + for boundary in self.boundaries: + solver.add_rotated_freeslip_bc(0.0, boundary) + + solver.petsc_use_pressure_nullspace = True + + +class FixedWalls: + """Mixin: velocity is prescribed on every wall, from the exact solution. + + For solutions driven by their boundaries rather than by a body force — a + far-field shear, say — and for manufactured solutions whose exact velocity is + not tangential to the domain. The domain is again enclosed, so the pressure + nullspace is removed. + """ + + def apply_boundary_conditions(self, solver): + for boundary in self.boundaries: + solver.add_dirichlet_bc(self.fn_velocity, boundary) + + solver.petsc_use_pressure_nullspace = True diff --git a/src/underworld3/function/AnalyticSolCx.c b/src/underworld3/analytic/_reference/AnalyticSolCx.c similarity index 100% rename from src/underworld3/function/AnalyticSolCx.c rename to src/underworld3/analytic/_reference/AnalyticSolCx.c diff --git a/src/underworld3/function/AnalyticSolCx.h b/src/underworld3/analytic/_reference/AnalyticSolCx.h similarity index 100% rename from src/underworld3/function/AnalyticSolCx.h rename to src/underworld3/analytic/_reference/AnalyticSolCx.h diff --git a/src/underworld3/analytic/_reference/AnalyticSolDB2d.hpp b/src/underworld3/analytic/_reference/AnalyticSolDB2d.hpp new file mode 100644 index 000000000..94d62d55f --- /dev/null +++ b/src/underworld3/analytic/_reference/AnalyticSolDB2d.hpp @@ -0,0 +1,123 @@ +/*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~* + ** ** + ** This file forms part of the Underworld geophysics modelling application. ** + ** ** + ** For full license and copyright information, please refer to the LICENSE.md file ** + ** located at the project root, or contact the authors. ** + ** ** + **~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*/ + +/* + SolDB2d from: + + @ARTICLE{2004IJNMF..46..183D, + author = {{Dohrmann}, C.~R. and {Bochev}, P.~B.}, + title = "{A stabilized finite element method for the Stokes problem based on polynomial pressure projections}", + journal = {International Journal for Numerical Methods in Fluids}, + keywords = {Stokes equations, stabilized mixed methods, equal-order interpolation, inf-sup condition}, + year = 2004, + month = sep, + volume = 46, + pages = {183-201}, + doi = {10.1002/fld.752}, + adsurl = {http://adsabs.harvard.edu/abs/2004IJNMF..46..183D}, + adsnote = {Provided by the SAO/NASA Astrophysics Data System} + } + */ + + +#ifndef __Underworld_Function_AnalyticSolDB2d_hpp__ +#define __Underworld_Function_AnalyticSolDB2d_hpp__ + +#include "Analytic.hpp" + +namespace Fn +{ + class SolDB2d: public AnalyticCRTP + { + public: + SolDB2d() + :AnalyticCRTP(this,2) + { + } + virtual ~SolDB2d(){}; + + void bodyforce( const double* in, double* out ) + { + double x=in[0],z=in[1]; + double fx; + double fz; + + fx = -z - 0.1e1 + 0.3e1 * x * x * z * z; + out[0]=fx; + + fz = -0.1e1 + 0.3e1 * x + 0.2e1 * pow(x, 0.3e1) * z; + out[1]=fz; + + }; + void viscosity( const double* in, double* out ) + { + out[0]=1.0; + }; + void pressure(const double* in, double* out ) + { + double x=in[0],z=in[1]; + double p,t2,t4; + + t2 = x * x; + t4 = z * z; + p = x * z + x + z + t2 * x * t4 - 0.4e1 / 0.3e1; + out[0]=p; + + }; + void strainrate( const double* in, double* out ) + { + double x=in[0],z=in[1]; + double exx; + double ezz; + double exz; + + exx = 0.1e1 + 0.2e1 * x - 0.2e1 * z + 0.3e1 * x * x - 0.3e1 * z * z + 0.2e1 * x * z; + out[0]=exx; + + ezz = -0.1e1 - 0.2e1 * x + 0.2e1 * z - 0.3e1 * x * x + 0.3e1 * z * z - 0.2e1 * x * z; + out[1]=ezz; + + exz = -x - 0.6e1 * x * z + x * x / 0.2e1 - z - z * z / 0.2e1; + out[2]=exz; + + }; + void stress( const double* in, double* out ) + { + double x=in[0],z=in[1]; + double txx; + double tzz; + double txz; + + txx = 0.2e1 + 0.4e1 * x - 0.4e1 * z + 0.6e1 * x * x - 0.6e1 * z * z + 0.4e1 * x * z; + out[0]=txx; + + tzz = -0.2e1 - 0.4e1 * x + 0.4e1 * z - 0.6e1 * x * x + 0.6e1 * z * z - 0.4e1 * x * z; + out[1]=tzz; + + txz = -0.2e1 * x - 0.12e2 * x * z + x * x - 0.2e1 * z - z * z; + out[2]=txz; + + }; + void velocity( const double* in, double* out ) + { + double x=in[0],z=in[1]; + double vx; + double vz; + + vx = x + x * x - 0.2e1 * x * z + pow(x, 0.3e1) - 0.3e1 * x * z * z + x * x * z; + out[0]=vx; + + vz = -z - 0.2e1 * x * z + z * z - 0.3e1 * x * x * z + pow(z, 0.3e1) - x * z * z; + out[1]=vz; + + }; + }; +} + +#endif diff --git a/src/underworld3/analytic/_reference/AnalyticSolDB3d.hpp b/src/underworld3/analytic/_reference/AnalyticSolDB3d.hpp new file mode 100644 index 000000000..5c8a2ab01 --- /dev/null +++ b/src/underworld3/analytic/_reference/AnalyticSolDB3d.hpp @@ -0,0 +1,166 @@ +/*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~* + ** ** + ** This file forms part of the Underworld geophysics modelling application. ** + ** ** + ** For full license and copyright information, please refer to the LICENSE.md file ** + ** located at the project root, or contact the authors. ** + ** ** + **~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*/ + +/* + SolDB3d initially from Dohrmann/Bochev with exponential viscosity added by Burstedde. + + Burstedde, C., Stadler, G., Alisic, L., Wilcox, L., Tan, E., Gurnis, M., and Ghattas, O.: Large- + scale adaptive mantle convection simulation, GJI, 192, 889–906, 2013. + + @article{Burstedde01032013, + author = {Burstedde, Carsten and Stadler, Georg and Alisic, Laura and Wilcox, Lucas C. and Tan, Eh and Gurnis, Michael and Ghattas, Omar}, + title = {Large-scale adaptive mantle convection simulation}, + volume = {192}, + number = {3}, + pages = {889-906}, + year = {2013}, + doi = {10.1093/gji/ggs070}, + URL = {http://gji.oxfordjournals.org/content/192/3/889.abstract}, + eprint = {http://gji.oxfordjournals.org/content/192/3/889.full.pdf+html}, + journal = {Geophysical Journal International} + } + + */ + + +#ifndef __Underworld_Function_AnalyticSolDB3d_hpp__ +#define __Underworld_Function_AnalyticSolDB3d_hpp__ + +#include "Analytic.hpp" + +namespace Fn +{ + class SolDB3d: public AnalyticCRTP + { + public: + SolDB3d( double Beta ) + :AnalyticCRTP(this,3), Beta(Beta) + { + } + virtual ~SolDB3d(){}; + double Beta; + + void bodyforce( const double* in, double* out ) + { + double x=in[0],y=in[1],z=in[2]; + double fx; + double fy; + double fz; + + fx = y * z + 0.2e1 * x * pow(y, 0.3e1) * z + 0.2e1 * Beta * (0.1e1 - 0.2e1 * x) * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (0.1e1 + 0.2e1 * x + y + 0.3e1 * x * x * y) - 0.2e1 * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (0.2e1 + 0.6e1 * x * y) + 0.2e1 * Beta * (0.1e1 - 0.2e1 * y) * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (x / 0.2e1 + pow(x, 0.3e1) / 0.2e1 + y / 0.2e1 + x * y * y) - 0.2e1 * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (0.1e1 / 0.2e1 + 0.2e1 * x * y) + 0.2e1 * Beta * (0.1e1 - 0.2e1 * z) * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (-0.3e1 / 0.2e1 * z - 0.5e1 * x * y * z) - 0.2e1 * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (-0.3e1 / 0.2e1 - 0.5e1 * x * y); + out[0]=fx; + + fy = 0.2e1 * Beta * (0.1e1 - 0.2e1 * x) * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (x / 0.2e1 + pow(x, 0.3e1) / 0.2e1 + y / 0.2e1 + x * y * y) - 0.2e1 * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (0.1e1 / 0.2e1 + 0.3e1 / 0.2e1 * x * x + y * y) + x * z + 0.3e1 * x * x * y * y * z + 0.2e1 * Beta * (0.1e1 - 0.2e1 * y) * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (0.1e1 + x + 0.2e1 * y + 0.2e1 * x * x * y) - 0.2e1 * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (0.2e1 + 0.2e1 * x * x) + 0.2e1 * Beta * (0.1e1 - 0.2e1 * z) * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (-0.3e1 / 0.2e1 * z - 0.5e1 / 0.2e1 * x * x * z) - 0.2e1 * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (-0.3e1 / 0.2e1 - 0.5e1 / 0.2e1 * x * x); + out[1]=fy; + + fz = 0.2e1 * Beta * (0.1e1 - 0.2e1 * x) * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (-0.3e1 / 0.2e1 * z - 0.5e1 * x * y * z) + 0.10e2 * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * y * z + 0.2e1 * Beta * (0.1e1 - 0.2e1 * y) * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (-0.3e1 / 0.2e1 * z - 0.5e1 / 0.2e1 * x * x * z) + x * y + x * x * pow(y, 0.3e1) + 0.2e1 * Beta * (0.1e1 - 0.2e1 * z) * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (-0.2e1 - 0.3e1 * x - 0.3e1 * y - 0.5e1 * x * x * y); + out[2]=fz; + + }; + void viscosity( const double* in, double* out ) + { + double x=in[0],y=in[1],z=in[2]; + double eta; + + eta = exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))); + out[0]=eta; + + }; + void pressure(const double* in, double* out ) + { + double x=in[0],y=in[1],z=in[2]; + double p,t3,t4; + + t3 = x * x; + t4 = y * y; + p = x * y * z + t3 * t4 * y * z - 0.5e1 / 0.32e2; + out[0]=p; + + }; + void strainrate( const double* in, double* out ) + { + double x=in[0],y=in[1],z=in[2]; + double exx; + double eyy; + double ezz; + double exy; + double exz; + double eyz; + + exx = 0.1e1 + 0.2e1 * x + y + 0.3e1 * x * x * y; + out[0]=exx; + + eyy = 0.1e1 + x + 0.2e1 * y + 0.2e1 * x * x * y; + out[1]=eyy; + + ezz = -0.2e1 - 0.3e1 * x - 0.3e1 * y - 0.5e1 * x * x * y; + out[2]=ezz; + + exy = x / 0.2e1 + pow(x, 0.3e1) / 0.2e1 + y / 0.2e1 + x * y * y; + out[3]=exy; + + exz = -0.3e1 / 0.2e1 * z - 0.5e1 * x * y * z; + out[4]=exz; + + eyz = -0.3e1 / 0.2e1 * z - 0.5e1 / 0.2e1 * x * x * z; + out[5]=eyz; + + }; + void stress( const double* in, double* out ) + { + double x=in[0],y=in[1],z=in[2]; + double txx; + double tyy; + double tzz; + double txy; + double txz; + double tyz; + + txx = 0.2e1 * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (0.1e1 + 0.2e1 * x + y + 0.3e1 * x * x * y); + out[0]=txx; + + tyy = 0.2e1 * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (0.1e1 + x + 0.2e1 * y + 0.2e1 * x * x * y); + out[1]=tyy; + + tzz = 0.2e1 * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (-0.2e1 - 0.3e1 * x - 0.3e1 * y - 0.5e1 * x * x * y); + out[2]=tzz; + + txy = 0.2e1 * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (x / 0.2e1 + pow(x, 0.3e1) / 0.2e1 + y / 0.2e1 + x * y * y); + out[3]=txy; + + txz = 0.2e1 * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (-0.3e1 / 0.2e1 * z - 0.5e1 * x * y * z); + out[4]=txz; + + tyz = 0.2e1 * exp(0.1e1 - Beta * (x * (0.1e1 - x) + y * (0.1e1 - y) + z * (0.1e1 - z))) * (-0.3e1 / 0.2e1 * z - 0.5e1 / 0.2e1 * x * x * z); + out[5]=tyz; + + }; + void velocity( const double* in, double* out ) + { + double x=in[0],y=in[1],z=in[2]; + double vx; + double vy; + double vz; + + vx = x + x * x + x * y + pow(x, 0.3e1) * y; + out[0]=vx; + + vy = y + x * y + y * y + x * x * y * y; + out[1]=vy; + + vz = -0.2e1 * z - 0.3e1 * x * z - 0.3e1 * y * z - 0.5e1 * x * x * y * z; + out[2]=vz; + + }; + + }; + +} + +#endif diff --git a/src/underworld3/analytic/_reference/AnalyticSolM.hpp b/src/underworld3/analytic/_reference/AnalyticSolM.hpp new file mode 100644 index 000000000..b1e2e73e1 --- /dev/null +++ b/src/underworld3/analytic/_reference/AnalyticSolM.hpp @@ -0,0 +1,100 @@ +/*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~* + ** ** + ** This file forms part of the Underworld geophysics modelling application. ** + ** ** + ** For full license and copyright information, please refer to the LICENSE.md file ** + ** located at the project root, or contact the authors. ** + ** ** + **~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*/ + +#ifndef __Underworld_Function_AnalyticSolM_hpp__ +#define __Underworld_Function_AnalyticSolM_hpp__ + +#include "Analytic.hpp" + +namespace Fn +{ + class SolM: public AnalyticCRTP + { + public: + SolM( double eta0, unsigned m, unsigned n, double r ) + :AnalyticCRTP(this,2), eta0(eta0), m(m), n(n), r(r) + { + } + virtual ~SolM(){}; + double eta0; + unsigned m; + unsigned n; + double r; + double km=m*M_PI; + double kn=n*M_PI; + double kr=r*M_PI; + + void velocity( const double* in, double* out ) + { + out[0] = -sin(km*in[0])*kn*cos(kn*in[1]); + out[1] = cos(km*in[0])*km*sin(kn*in[1]); + }; + void pressure( const double* in, double* out ) + { + double x=in[0],z=in[1]; + double p; + double t2,t4,t5,t6,t7,t8,t10,t11,t15,t16; + double t18,t23,t27,t28,t29,t32,t33,t34,t44; + + t2 = cos(kn * z); + t4 = kr * kr; + t5 = t4 * eta0; + t6 = km * km; + t7 = kr * x; + t8 = cos(t7); + t10 = km * x; + t11 = cos(t10); + t15 = kn * kn; + t16 = t15 * t11; + t18 = t6 * t11; + t23 = t6 * t6; + t27 = t18 * t15; + t28 = t11 * t23; + t29 = eta0 * t8; + t32 = sin(t7); + t33 = t32 * kr; + t34 = sin(t10); + t44 = 0.2e1 * t5 * t6 * t8 * t11 + t16 * t5 + t18 * t5 - t6 * t15 * t11 * eta0 - t23 * eta0 * t11 + t16 * t4 - t27 - t28 - t29 * t27 - t29 * t28 + t33 * eta0 * t34 * t6 * km + t18 * t4 - t33 * eta0 * t34 * t15 * km; + p = t2 * kn * t44 / km / (-t4 + t6); + out[0]=p; + + }; + void stress( const double* in, double* out ) + { + out[0] = -2.*(1.+cos(kr*in[0]))*eta0*cos(km*in[0])*km*cos(kn*in[1])*kn; //txx + out[1] = 2.*(1.+cos(kr*in[0]))*eta0*cos(km*in[0])*km*cos(kn*in[1])*kn; //tzz + out[2] = 2.*(1.+cos(kr*in[0]))*eta0*(1./2*sin(km*in[0])*sin(kn*in[1])*kn*kn-1./2*sin(km*in[0])*km*km*sin(kn*in[1])); //txz + }; + void strainrate( const double* in, double* out ) + { + out[0] = -cos(km*in[0])*km*cos(kn*in[1])*kn; //exx + out[1] = cos(km*in[0])*km*cos(kn*in[1])*kn; //ezz + out[2] = 1./2*sin(km*in[0])*sin(kn*in[1])*kn*kn-1./2*sin(km*in[0])*km*km*sin(kn*in[1]); //exz + }; + void viscosity( const double* in, double* out ) + { + out[0] = (1.+cos(kr*in[0]))*eta0+1.; + }; + void bodyforce( const double* in, double* out ) + { + double x,z; + out[0] = 0.; + + x=in[0]; + z=in[1]; + + out[1] = -sin(kn*z)*eta0*kr*(km-kn)*(km+kn)*(km*km-kr*kr+kn*kn)*sin(km*x)*sin(kr*x)/(-kr+km)/(kr+km) + + ( km*eta0*(km*km*km*km-3*kn*kn*kr*kr+2*km*km*kn*kn-kr*kr*km*km+kn*kn*kn*kn)*cos(kr*x)/(-kr+km)/(kr+km) + + (km*km+kn*kn)*(km*km+kn*kn)*(1+eta0)/km )*cos(km*x)*sin(kn*z); + }; + }; + +} + +#endif diff --git a/src/underworld3/function/AnalyticSolNL.c b/src/underworld3/analytic/_reference/AnalyticSolNL.c similarity index 100% rename from src/underworld3/function/AnalyticSolNL.c rename to src/underworld3/analytic/_reference/AnalyticSolNL.c diff --git a/src/underworld3/function/AnalyticSolNL.h b/src/underworld3/analytic/_reference/AnalyticSolNL.h similarity index 100% rename from src/underworld3/function/AnalyticSolNL.h rename to src/underworld3/analytic/_reference/AnalyticSolNL.h diff --git a/src/underworld3/analytic/_reference/__init__.py b/src/underworld3/analytic/_reference/__init__.py new file mode 100644 index 000000000..9dd6ea228 --- /dev/null +++ b/src/underworld3/analytic/_reference/__init__.py @@ -0,0 +1,20 @@ +r"""Reference implementations of the analytic solutions, as originally published. + +These are the machine-generated C kernels the suite's SymPy solutions were +transcribed from — Velic's, and PETSc's copies of them. They are kept, and +supported, for two reasons: + +1. every transcription is validated against them (see the six gates in + ``docs/developer/subsystems/analytic-solutions.md``), so they must stay + available and unmodified; +2. when a benchmark result looks wrong, "is this the transcription or the + model?" should be a one-line question: + + .. code-block:: python + + sol = uw.analytic.SolCx(mesh, ...) # SymPy + ref = uw.analytic.SolCx(mesh, ..., reference=True) # the C, verbatim + +Reach for :mod:`underworld3.analytic` instead; nothing here is part of the +public API. +""" diff --git a/src/underworld3/function/analytic.pyx b/src/underworld3/analytic/_reference/_velic.pyx similarity index 100% rename from src/underworld3/function/analytic.pyx rename to src/underworld3/analytic/_reference/_velic.pyx diff --git a/src/underworld3/analytic/_reference/solA.c b/src/underworld3/analytic/_reference/solA.c new file mode 100644 index 000000000..6f8422b2d --- /dev/null +++ b/src/underworld3/analytic/_reference/solA.c @@ -0,0 +1,211 @@ +/*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~* +** ** +** This file forms part of the Underworld geophysics modelling application. ** +** ** +** For full license and copyright information, please refer to the LICENSE.md file ** +** located at the project root, or contact the authors. ** +** ** +**~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*/ +#include +#include +#include +#include + +#include "solA.h" + +#if 0 +int main() { + double sigma = 1.0; + double Z = 1.0; + double km = M_PI; + int n = 1; + double pos[2], velocity[2], pressure, Tstress[3], strainRate[3],jp; + int i, j; + + for (i=0;i<101;i++){ + for(j=0;j<101;j++){ + pos[0] = i/100.0; + pos[1] = j/100.0; + + _Velic_solA( + pos, + sigma, + Z, + n, + km, + velocity, + &pressure, + Tstress, + strainRate ); + + printf( + "%.7g %.7g %.7g %.7g %.7g %.7g %.7g %.7g %.7g %.7g %.7g %.7g\n", + pos[0], pos[1], + velocity[0], velocity[1], + pressure, + jp, + strainRate[0], strainRate[1], strainRate[2], + Tstress[0], Tstress[1], Tstress[2] ); + } + } + return 0; +} +#endif + +void _Velic_solA( + const double* pos, + double sigma, + double Z, + int n, + double km, + double* velocity, + double* pressure, + double* Tstress, + double* strainRate ) { + + double u1,u2,u3,u4,pp,txx; + double _C1,_C2,_C3,_C4; + double sum1,sum2,sum3,sum4,sum5,sum6,x,z; + double ss,ss_z,ss_zz,ss_zzz,e_zz,e_xx,e_xz; + double t1,t3,t4,t5,t6,t7,t9,t10,t14,t15; + double t16,t17,t18,t19,t20,t21,t23,t24,t25,t26,t27,t29; + double kn; + + kn = (double)n*M_PI; + x = pos[0]; + z = pos[1]; + + sum1=0.0; + sum2=0.0; + sum3=0.0; + sum4=0.0; + sum5=0.0; + sum6=0.0; +// sum7=0.0; + + t1 = sin(km); + t3 = kn * kn; + t4 = exp(-kn); + t5 = t4 * t4; + t10 = km * km; + t17 = pow(t10 + t3, 0.2e1); + t21 = pow(t4 - 0.1e1, 0.2e1); + t24 = pow(t4 + 0.1e1, 0.2e1); + _C1 = t1 * sigma * (t3 * t5 + t3 - 0.2e1 * kn * t5 + 0.2e1 * kn + t10 * t5 + t10) * t4 / Z / t17 / t21 / t24 / 0.2e1; + + t1 = sin(km); + t3 = kn * kn; + t4 = exp(-kn); + t5 = t4 * t4; + t10 = km * km; + t16 = pow(t10 + t3, 0.2e1); + t20 = pow(t4 - 0.1e1, 0.2e1); + t23 = pow(t4 + 0.1e1, 0.2e1); + _C2 = -t1 * sigma * (t3 * t5 + t3 - 0.2e1 * kn * t5 + 0.2e1 * kn + t10 * t5 + t10) / Z / t16 / t20 / t23 / 0.2e1; + + t1 = sin(km); + t3 = exp(-kn); + t6 = km * km; + t7 = kn * kn; + _C3 = -t1 * sigma * t3 / Z / (t6 + t7) / (t3 - 0.1e1) / (t3 + 0.1e1) / 0.2e1; + + t1 = sin(km); + t5 = km * km; + t6 = kn * kn; + t9 = exp(-kn); + _C4 = -t1 * sigma / Z / (t5 + t6) / (t9 - 0.1e1) / (t9 + 0.1e1) / 0.2e1; + + t4 = exp(-kn * z); + t10 = exp(kn * (z - 0.1e1)); + t14 = sin(km * z); + t15 = km * km; + t16 = kn * kn; + t18 = pow(t15 + t16, 0.2e1); + ss = (_C1 + z * _C3) * t4 + (_C2 + z * _C4) * t10 + kn * sigma * t14 / t18 / Z; + + t6 = exp(-kn * z); + t14 = exp(kn * (z - 0.1e1)); + t18 = cos(km * z); + t20 = km * km; + t21 = kn * kn; + t23 = pow(t20 + t21, 0.2e1); + ss_z = (-(_C1 + z * _C3) * kn + _C3) * t6 + (_C4 + (_C2 + z * _C4) * kn) * t14 + kn * sigma * t18 * km / t23 / Z; + + t3 = kn * kn; + t9 = exp(-kn * z); + t19 = exp(kn * (z - 0.1e1)); + t23 = sin(km * z); + t25 = km * km; + t27 = pow(t25 + t3, 0.2e1); + ss_zz = ((_C1 + z * _C3) * t3 - 0.2e1 * _C3 * kn) * t9 + (0.2e1 * _C4 * kn + (_C2 + z * _C4) * t3) * t19 - kn * sigma * t23 * t25 / t27 / Z; + + t3 = kn * kn; + t4 = t3 * kn; + t10 = exp(-kn * z); + t20 = exp(kn * (z - 0.1e1)); + t24 = cos(km * z); + t26 = km * km; + t29 = pow(t26 + t3, 0.2e1); + ss_zzz = (-(_C1 + z * _C3) * t4 + 0.3e1 * _C3 * t3) * t10 + (0.3e1 * _C4 * t3 + (_C2 + z * _C4) * t4) * t20 - kn * sigma * t24 * t26 * km / t29 / Z; + + /* u1 = Vz, u2 = Vx, u3 = tzz, u4 = tzx, pp = pressure */ + + u1 = kn*ss; + u2 = -ss_z; + pp = Z*(ss_zzz-kn*kn*ss_z)/kn; + u3 = 2.0*kn*ss_z - pp; + u4 = -Z*(ss_zz + kn*kn*ss); + txx = -2.0*Z*kn*ss_z - pp; + + u1 *= cos(kn*x); /* z velocity */ + sum1 += u1; + u2 *= sin(kn*x); /* x velocity */ + sum2 += u2; + u3 *= cos(kn*x); /* zz stress */ + sum3 += u3; + u4 *= sin(kn*x); /* zx stress */ + sum4 += u4; + txx *= cos(kn*x); /* xx stress */ + sum6 += txx; + pp *= cos(kn*x); /* pressure */ + sum5 += pp; + + e_zz = kn*ss_z*cos(kn*x); /* zz rate of strain */ + e_xx = -e_zz; /* xx rate of strain */ + e_xz = -0.5*(ss_zz+kn*kn*ss)*sin(kn*x); /* xz rate of strain */ + +// ss *= sin(kn*x); /* stream function */ + /* density/temp */ +// sum7 += -sin(km*z)*cos(kn*x); +// mag=sqrt(sum1*sum1+sum2*sum2); + + // printf("%0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f\n",x,z,sum1,sum2,sum3,sum4,sum5,sum6,mag,sum7,e_zz,e_xx,e_xz,ss); + + if ( velocity != NULL ) { + velocity[0] = sum2; + velocity[1] = sum1; + } + if( pressure != NULL ) { + *pressure = sum5; + } + if( Tstress != NULL ) { + Tstress[0] = sum6; + Tstress[1] = sum3; + Tstress[2] = sum4; /*2*Z*e_xz;*/ + } + if( strainRate != NULL ) { + strainRate[0] = e_xx; + strainRate[1] = e_zz; + strainRate[2] = e_xz; + } + /* + if ( fabs( sum3 - ( 2*Z*e_zz - sum5 ) ) > 1e-4 ) { + printf("%g is not within tolerance 1e-4\n", sum3 - ( 2*Z*e_zz + sum5 ) ); + assert(0); + } + if( fabs( sum5 - ( -0.5*(sum6 + sum3) ) ) > 1e-4 ) { + printf("error is %g, which is outside tolerance of 1e-4\n", fabs( sum5 - ( -0.5*(sum6 + sum3) )) ); + assert(0); + } + */ +} diff --git a/src/underworld3/analytic/_reference/solB.c b/src/underworld3/analytic/_reference/solB.c new file mode 100644 index 000000000..c64e5313b --- /dev/null +++ b/src/underworld3/analytic/_reference/solB.c @@ -0,0 +1,197 @@ +/*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~* +** ** +** This file forms part of the Underworld geophysics modelling application. ** +** ** +** For full license and copyright information, please refer to the LICENSE.md file ** +** located at the project root, or contact the authors. ** +** ** +**~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*/ +#include +#include +#include +#include + +#include "solB.h" + +#if 0 +int main() { + double sigma = 1.0; + double Z = 1.0; + int n = 1; + double km = 2*M_PI; + double pos[2], velocity[2], pressure, Tstress[3], strainRate[3]; + int i,j; + + for (i=0;i<33;i++){ + for(j=0;j<33;j++){ + pos[0] = i/32.0; + pos[1] = j/32.0; + + _Velic_solB( pos, + sigma, Z, n, km, + velocity, &pressure, Tstress, strainRate ); + + printf("%.7g %.7g %.7g %.7g %.7g %.7g %.7g %.7g %.7g %.7g %.7g\n", + pos[0], pos[1], velocity[0], velocity[1], + pressure, + strainRate[0], strainRate[1], strainRate[2], + Tstress[0], Tstress[1], Tstress[2] ); + } + } + return 0; +} +#endif + +void _Velic_solB( const double* pos, + double sigma, double Z, int n, double km, + double* velocity, double* pressure, double* Tstress, double* strainRate ) { + + double u1,u2,u3,u4,pp,txx; + double _C1,_C2,_C3,_C4; + double sum1,sum2,sum3,sum4,sum5,sum6,sum7,x,z; + double kn; + double ss,ss_z,ss_zz,ss_zzz,e_zz,e_xx,e_xz; + double t1,t2,t3,t4,t5,t6,t7,t8,t9,t10; + double t11,t12,t14,t15,t16,t18,t19,t20,t21,t22; + double t23,t24,t25,t26,t27,t28,t29,t32,t33; + + kn = (double)n*M_PI; + + x = pos[0]; + z = pos[1]; + + sum1=0.0; + sum2=0.0; + sum3=0.0; + sum4=0.0; + sum5=0.0; + sum6=0.0; + sum7=0.0; + + + t1 = exp(km); + t5 = kn * kn; + t6 = exp(-kn); + t7 = t6 * t6; + t12 = km * km; + t22 = pow(-0.1e1 + t6, 0.2e1); + t26 = pow(t6 + 0.1e1, 0.2e1); + t29 = pow(kn - km, 0.2e1); + t33 = pow(kn + km, 0.2e1); + _C1 = sigma * (-0.1e1 + t1) * (t1 + 0.1e1) * (t5 * t7 + t5 + 0.2e1 * kn - 0.2e1 * kn * t7 - t12 * t7 - t12) * t6 / Z / t1 / t22 / t26 / t29 / t33 / 0.4e1; + + t1 = kn * kn; + t2 = exp(-kn); + t3 = t2 * t2; + t8 = km * km; + t11 = exp(km); + t21 = pow(-0.1e1 + t2, 0.2e1); + t25 = pow(t2 + 0.1e1, 0.2e1); + t28 = pow(kn - km, 0.2e1); + t32 = pow(kn + km, 0.2e1); + _C2 = -(t1 * t3 + t1 + 0.2e1 * kn - 0.2e1 * kn * t3 - t8 * t3 - t8) * (t11 + 0.1e1) * (-0.1e1 + t11) * sigma / Z / t11 / t21 / t25 / t28 / t32 / 0.4e1; + + t1 = exp(km); + t5 = exp(-kn); + _C3 = -sigma * (-0.1e1 + t1) * (t1 + 0.1e1) * t5 / Z / t1 / (-0.1e1 + t5) / (t5 + 0.1e1) / (kn - km) / (kn + km) / 0.4e1; + + t1 = exp(km); + t9 = exp(-kn); + _C4 = -(t1 + 0.1e1) * (-0.1e1 + t1) * sigma / Z / t1 / (-0.1e1 + t9) / (t9 + 0.1e1) / (kn - km) / (kn + km) / 0.4e1; + + t4 = exp(-kn * z); + t10 = exp(kn * (z - 0.1e1)); + t14 = sinh(km * z); + t15 = km * km; + t16 = kn * kn; + t18 = pow(t15 - t16, 0.2e1); + ss = (_C1 + z * _C3) * t4 + (_C2 + z * _C4) * t10 + kn * sigma * t14 / t18 / Z; + + t6 = exp(-kn * z); + t14 = exp(kn * (z - 0.1e1)); + t18 = cosh(km * z); + t20 = km * km; + t21 = kn * kn; + t23 = pow(t20 - t21, 0.2e1); + ss_z = (-(_C1 + z * _C3) * kn + _C3) * t6 + (_C4 + (_C2 + z * _C4) * kn) * t14 + kn * sigma * t18 * km / t23 / Z; + + t3 = kn * kn; + t9 = exp(-kn * z); + t19 = exp(kn * (z - 0.1e1)); + t23 = sinh(km * z); + t25 = km * km; + t27 = pow(t25 - t3, 0.2e1); + ss_zz = ((_C1 + z * _C3) * t3 - 0.2e1 * _C3 * kn) * t9 + (0.2e1 * _C4 * kn + (_C2 + z * _C4) * t3) * t19 + kn * sigma * t23 * t25 / t27 / Z; + + t3 = kn * kn; + t4 = t3 * kn; + t10 = exp(-kn * z); + t20 = exp(kn * (z - 0.1e1)); + t24 = cosh(km * z); + t26 = km * km; + t29 = pow(t26 - t3, 0.2e1); + ss_zzz = (-(_C1 + z * _C3) * t4 + 0.3e1 * _C3 * t3) * t10 + (0.3e1 * _C4 * t3 + (_C2 + z * _C4) * t4) * t20 + kn * sigma * t24 * t26 * km / t29 / Z; + + /* u1 = Vz, u2 = Vx, u3 = tzz, u4 = tzx, pp = pressure */ + + u1 = kn*ss; + u2 = -ss_z; + pp = Z*(ss_zzz-kn*kn*ss_z)/kn; + u3 = 2.0*Z*kn*ss_z - pp; + u4 = -Z*(ss_zz + kn*kn*ss); + txx = -2.0*Z*kn*ss_z - pp; + + + u1 *= cos(kn*x); /* z velocity */ + sum1 += u1; + u2 *= sin(kn*x); /* x velocity */ + sum2 += u2; + u3 *= cos(kn*x); /* zz stress */ + sum3 += u3; + u4 *= sin(kn*x); /* zx stress */ + sum4 += u4; + txx *= cos(kn*x); /* xx stress */ + sum6 += txx; + pp *= cos(kn*x); /* pressure */ + sum5 += pp; + + e_zz = kn*ss_z*cos(kn*x); /* zz rate of strain */ + e_xx = -e_zz; /* xx rate of strain */ + e_xz = -0.5*(ss_zz+kn*kn*ss)*sin(kn*x); /* xz rate of strain */ + + ss *= sin(kn*x); /* stream function */ + /* density/temp */ + sum7 += -sigma*sinh(km*z)*cos(kn*x); + + //mag=sqrt(sum1*sum1+sum2*sum2); + // printf("%0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f\n",x,z,sum1,sum2,sum3,sum4,sum5,sum6,mag,sum7,e_zz,e_xx,e_xz,ss); + if ( velocity != NULL ) { + velocity[0] = sum2; + velocity[1] = sum1; + } + if( pressure != NULL ) { + *pressure = sum5; + } + if( Tstress != NULL ) { + Tstress[0] = sum6; + Tstress[1] = sum3; + Tstress[2] = sum4; + } + if( strainRate != NULL ) { + strainRate[0] = e_xx; + strainRate[1] = e_zz; + strainRate[2] = e_xz; + } +// /* Value checks, could be cleaned up if needed. Julian Giordani 2-Oct-2006*/ +// if( fabs( sum5 - ( -0.5*(sum6+sum3) ) ) > 1e-5 ) { +// assert(0); +// } +// if( fabs( sum6 - (2*Z*e_xx - sum5) ) > 1e-5 ) { +// assert(0); +// } +// if( fabs( sum3 - (2*Z*e_zz - sum5) ) > 1e-5 ) { +// assert(0); +// } +} + + diff --git a/src/underworld3/analytic/_reference/solC.c b/src/underworld3/analytic/_reference/solC.c new file mode 100644 index 000000000..c58a38aa2 --- /dev/null +++ b/src/underworld3/analytic/_reference/solC.c @@ -0,0 +1,186 @@ +/*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~* +** ** +** This file forms part of the Underworld geophysics modelling application. ** +** ** +** For full license and copyright information, please refer to the LICENSE.md file ** +** located at the project root, or contact the authors. ** +** ** +**~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*/ +#include +#include +#include +#include +#include "solC.h" + +#if 0 +int main( int argc, char **argv ) +{ + int i,j; + double pos[2], vel[2], pressure, total_stress[3], strain_rate[3]; + double x,z; + + for (i=0;i<101;i++){ + for(j=0;j<101;j++){ + x = i/100.0; + z = j/100.0; + + pos[0] = x; + pos[1] = z; + _Velic_solC( pos, 1.0, 1.0, 0.4, vel, &pressure, total_stress, strain_rate, 200 ); + + printf("t_xz,e_xz look funny \n"); + printf("%0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f \n", + pos[0],pos[1], + vel[0],vel[1], pressure, + total_stress[0], total_stress[1], total_stress[2], + strain_rate[0], strain_rate[1], strain_rate[2] ); + } + printf("\n"); + } + + return 0; +} +#endif + + + +void _Velic_solC( + const double pos[], /* coordinates */ + double _sigma, double _eta, double _x_c, /* problem dependant inputs: density, viscosity, width of dense column */ + double vel[], double* presssure, /* output: velocity, pressure */ + double total_stress[], double strain_rate[], int nmodes ) /* ouput: total stresss, strain rate */ +{ + double Z,u1,u2,u3,u4,u5,u6; + double _C1,_C2,_C3,_C4; + double sum1,sum2,sum3,sum4,sum5,sum6,sum7,x,z; + double sigma,del_rho,k,xc; + int n; + + double t1,t2,t3,t4,t5,t6,t7,t8,t10,t11; + double t12,t14,t16,t21; + + + /* del_rho = sigma for x < xc and 0 for x > xc */ + sigma = _sigma; + Z = _eta; + xc = _x_c; + + x = pos[0]; + z = pos[1]; + + sum1=0.0; + sum2=0.0; + sum3=0.0; + sum4=0.0; + sum5=0.0; + sum6=0.0; + sum7=0.0; + + + + for(n=1;n +#include +#include +#include +#include "solDA.h" + +#if 0 +int main( int argc, char **argv ) +{ + int i,j; + double pos[2], vel[2], pressure, total_stress[3], strain_rate[3]; + double x,z; + + for (i=0;i<101;i++){ + for(j=0;j<101;j++){ + x = i/100.0; + z = j/100.0; + + pos[0] = x; + pos[1] = z; + _Velic_solDA( + pos, + 3.0, + 1.0, 2.0, + 0.8, 0.25, 0.45, + vel, &pressure, total_stress, strain_rate ); + printf("%0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f \n", + pos[0],pos[1], + vel[0],vel[1], pressure, + total_stress[0], total_stress[1], total_stress[2], + strain_rate[0], strain_rate[1], strain_rate[2] ); + } + printf("\n"); + } + + return 0; +} +#endif + + + +void _Velic_solDA( + const double pos[], + double _sigma, /* density */ + double _eta_A, double _eta_B, /* viscosity A, viscosity B */ + double _z_c, double _dx, double _x_0, /* viscosity jump location, width of dense column, centre of dense column */ + double vel[], double* presssure, + double total_stress[], double strain_rate[], int nmodes ) +{ + double Z,ZA,ZB,u1,u2,u3,u4,pp,txx; + double u1a,u2a,u3a,u4a,u1b,u2b,u3b,u4b; + double sum1,sum2,sum3,sum4,sum5,sum6,sum7,x,z; + double sigma,dx; + double del_rhoA,del_rhoB,del_rho; + int n; + double kn; + double _C1A,_C2A,_C3A,_C4A,_C1B,_C2B,_C3B,_C4B; + double x0,rho,zc; + + double t1,t2,t3,t4,t5,t6,t7,t8,t9,t10; + double t11,t12,t13,t14,t15,t16,t17,t18,t19,t20; + double t21,t22,t23,t24,t25,t26,t27,t28,t29,t30; + double t31,t32,t33,t34,t35,t36,t37,t38,t39,t40; + double t41,t42,t43,t44,t45,t46,t47,t48,t49,t50; + double t51,t52,t53,t54,t55,t56,t57,t58,t59,t60; + double t61,t62,t63,t64,t66,t67,t68,t69,t70,t71; + double t72,t73,t74,t75,t76,t77,t78,t79,t80,t81; + double t82,t83,t84,t85,t86,t87,t88,t89,t90,t91; + double t93,t94,t95,t96,t97,t98,t99,t100,t101,t102; + double t103,t104,t105,t106,t107,t108,t109,t110,t111,t112; + double t113,t114,t115,t116,t117,t118,t119,t120,t121,t122; + double t123,t124,t125,t126,t128,t129,t130,t131,t132,t133; + double t134,t135,t136,t137,t138,t139,t140,t141,t142,t143; + double t144,t145,t146,t147,t148,t149,t150,t151,t152,t153; + double t154,t155,t156,t157,t158,t159,t160,t161,t162,t163; + double t164,t165,t166,t167,t168,t169,t170,t171,t172,t173; + double t174,t175,t176,t178,t179,t180,t181,t183,t184,t185; + double t186,t187,t188,t189,t191,t193,t196,t197,t200,t202; + double t203,t206,t207,t210,t211,t215,t217,t218,t219,t221; + double t222,t223,t224,t225,t226,t227,t228,t229,t232,t233; + double t238,t239,t243,t244,t246,t248,t250,t251,t267,t273; + + + + + /*************************************************************************/ + /*************************************************************************/ + /* We have a dense column of density sigma + ranging from x=x0-dx/2 to x= x0+dx/2: */ + x0 = _x_0; + dx = _dx; + sigma = _sigma; /* density parameter */ + ZA = _eta_A; /* viscosity for layer A */ + ZB = _eta_B; /* viscosity for layer B */ + zc = _z_c; + /*************************************************************************/ + /*************************************************************************/ + + x = pos[0]; + z = pos[1]; + + sum1=0.0; + sum2=0.0; + sum3=0.0; + sum4=0.0; + sum5=0.0; + sum6=0.0; + sum7=0.0; + /* convergence is good */ + for(n=1;n 1e-5 ) { +// assert(0); +// } + +} + + diff --git a/src/underworld3/analytic/_reference/solH.c b/src/underworld3/analytic/_reference/solH.c new file mode 100644 index 000000000..2c4280eda --- /dev/null +++ b/src/underworld3/analytic/_reference/solH.c @@ -0,0 +1,223 @@ +/*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~* +** ** +** This file forms part of the Underworld geophysics modelling application. ** +** ** +** For full license and copyright information, please refer to the LICENSE.md file ** +** located at the project root, or contact the authors. ** +** ** +**~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*/ +#include +#include +#include +#include +#include "solH.h" + +#if 0 +int main( int argc, char **argv ) +{ + int i,j; + double pos[3], vel[3], pressure, total_stress[6], strain_rate[6]; + double x,y,z; + + for (i=0;i<101;i++){ + for(j=0;j<101;j++){ + x = i/100.0; + y = j/100.0; + + pos[0] = x; + pos[1] = y; + z = 0.2; + pos[2] = z; + _Velic_solH( + pos, + 1.0, + 1.0, + 0.4,0.6, + vel, &pressure, total_stress, strain_rate, 45 ); + } + } + + return 0; +} +#endif + + + +void _Velic_solH( + const double pos[], + double _sigma, + double _eta, + double _dx, double _dy, + double vel[], double* presssure, + double total_stress[], double strain_rate[], int nmodes ) +{ + + double Z,u1,u2,u3,u4,u5,u6; + double sum1,sum2,sum3,sum4,sum5,sum6,sum7,sum8,sum9,sum10,sum11,x,y,z; + double sigma,dx,dy; + double del_rho; + int n,m; + double L1,kn,km; + double Am,Ap,Bm,Bp,C,D,E; + double pp,txx,tyy,tyx,rho; + + /*************************************************************************/ + + dx = _dx; /* x width of block */ + dy = _dy; /* y width of block */ + sigma = _sigma; /* density of block */ + Z = _eta; /* viscosity */ + z=pos[2]; /* height of 2-d slice in x-y plane to view */ + x = pos[0]; + y = pos[1]; + + sum1=0.0; + sum2=0.0; + sum3=0.0; + sum4=0.0; + sum5=0.0; + sum6=0.0; + sum7=0.0; + sum8=0.0; + sum9=0.0; + sum10=0.0; + sum11=0.0; + + for(n=0;n L2 = %0.7g --> L1 = %0.7g\n",u2,n,m,L2,L1); + u1 *= cos(n*M_PI*x)*cos(m*M_PI*y); + sum1 += u1; + u2 *= cos(n*M_PI*x)*sin(m*M_PI*y); + sum2 += u2; + u3 *= sin(n*M_PI*x)*cos(m*M_PI*y); + sum3 += u3; + u4 *= cos(n*M_PI*x)*cos(m*M_PI*y); + sum4 += u4; + u5 *= cos(n*M_PI*x)*sin(m*M_PI*y); + sum5 += u5; + u6 *= sin(n*M_PI*x)*cos(m*M_PI*y); + sum6 += u6; + + pp *= cos(n*M_PI*x)*cos(m*M_PI*y); + sum7 += pp; /* total pressure */ + sum8 += txx; + sum9 += tyy; + sum10 += tyx; + + rho = del_rho*cos(n*M_PI*x)*cos(m*M_PI*y); + + sum11 += rho; + + }/* n */ + }/* m */ + //mag=sqrt(sum1*sum1+sum2*sum2+sum3*sum3); + + //printf("%0.7g %0.7g %0.7g %0.7g %0.7g %0.7g %0.7g %0.7g %0.7g %0.7g %0.7g %0.7g %0.7g %0.7g\n",x,y,sum1,sum2,sum3,sum4,sum5,sum6,sum7,sum8,sum9,sum10,mag,sum11); + /************************************************************************************ + Run this by doing + _Velic_solH > out + In gnuplot + set pm3d map + splot "out" u 1:2:N + where N = 3-14 + N=3 is z velocity field (sum1) + 4 is y velocity field (sum2) + 5 is x velocity field (sum3) + 6 = zz total stress field (sum4) + 7 = zy total stress field (sum5) + 8 = zx total stress field (sum6) + 9 = total pressure field (sum7) + 10 = xx total stress field (sum8) + 11 = yy total stress field (sum9) + 12 = yx total stress field (sum10) + 13 = velocity magnitude (mag) + 14 = density (mag11) + + if the set pm3d map thing doesn't work then you are probably using a version of + gnuplot that is too old --- seek medical assistance. + ************************************************************************************/ + + + /* Output */ + if( vel != NULL ) { + vel[0] = sum3; + vel[1] = sum2; + vel[2] = sum1; + } + if( presssure != NULL ) { + (*presssure) = sum7; + } + if( total_stress != NULL ) { + /* xx,yy,zz,xy,xz,yz */ + total_stress[0] = sum8; + total_stress[1] = sum9; + total_stress[2] = sum4; + total_stress[3] = sum10; + total_stress[4] = sum6; + total_stress[5] = sum5; + } + if( strain_rate != NULL ) { + strain_rate[0] = (sum8+sum7)/(2.0*Z); + strain_rate[1] = (sum9+sum7)/(2.0*Z); + strain_rate[2] = (sum4+sum7)/(2.0*Z); + strain_rate[3] = (sum10)/(2.0*Z); + strain_rate[4] = (sum6)/(2.0*Z); + strain_rate[5] = (sum5)/(2.0*Z); + } + /* Value checks, could be cleaned up if needed. Julian Giordani 9-Oct-2006*/ + //assert ( fabs( 3.0 * sum7 + sum8+sum9+sum4 ) <= 1e-5 ); + + +} + + diff --git a/src/underworld3/analytic/_reference/solKx.c b/src/underworld3/analytic/_reference/solKx.c new file mode 100644 index 000000000..10f196229 --- /dev/null +++ b/src/underworld3/analytic/_reference/solKx.c @@ -0,0 +1,493 @@ +/* + Velic SolKx: exact Stokes solution for an exponentially varying viscosity. + + Vendored verbatim from PETSc src/snes/tutorials/ex69.c (SolKxSolution), which + carries the following notice. It is transcription source, not built code — + underworld3.analytic.velic reads it to rebuild the solution in SymPy. + + Copyright (c) 1991-2025, UChicago Argonne, LLC and the PETSc Developers and + Contributors. All rights reserved. Redistribution and use in source and binary + forms, with or without modification, are permitted provided that the above + copyright notice and this list of conditions are retained. THIS SOFTWARE IS + PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR + IMPLIED WARRANTIES ARE DISCLAIMED. (BSD-2-Clause; see the PETSc LICENSE.) + + The domain is the unit square with free slip everywhere. The forcing is + fx = 0, fz = sigma*sin(km*z)*cos(kn*x) with km = m*Pi, kn = n*Pi and sigma = 1, + and the viscosity is eta = exp(2*B*x). +*/ + +static PetscErrorCode SolKxSolution(const PetscReal pos[], PetscReal m, PetscInt n, PetscReal B, PetscScalar vel[], PetscScalar *p, PetscScalar s[], PetscScalar gamma[], PetscScalar *mu) +{ + PetscReal sigma = 1.0; + PetscReal Z; + PetscReal u1, u2, u3, u4, u5, u6; + PetscReal sum1, sum2, sum3, sum4, sum5, sum6; + PetscReal kn, km, x, z; + PetscReal _PC1, _PC2, _PC3, _PC4; + PetscReal Rp, UU, VV; + PetscReal a, b, r, _aa, _bb, AA, BB, Rm; + PetscReal num1, num2, num3, num4, den1; + + PetscReal t1, t2, t3, t4, t5, t6, t7, t8, t9, t10; + PetscReal t11, t12, t13, t14, t15, t16, t17, t18, t19, t20; + PetscReal t21, t22, t23, t24, t25, t26, t27, t28, t29, t30; + PetscReal t31, t32, t33, t34, t35, t36, t37, t38, t39, t40; + PetscReal t41, t42, t43, t44, t45, t46, t47, t49, t51, t53; + PetscReal t56, t58, t61, t62, t63, t64, t65, t66, t67, t68; + PetscReal t69, t70, t71, t72, t73, t74, t75, t76, t77, t78; + PetscReal t79, t80, t81, t82, t83, t84, t85, t86, t87, t88; + PetscReal t89, t90, t91, t92, t93, t94, t95, t96, t97, t99; + PetscReal t100, t101, t103, t104, t105, t106, t107, t108, t109, t110; + PetscReal t111, t112, t113, t114, t115, t116, t117, t118, t119, t120; + PetscReal t121, t124, t125, t126, t127, t129, t130, t132, t133, t135; + PetscReal t136, t138, t140, t141, t142, t143, t152, t160, t162; + + PetscFunctionBegin; + /*************************************************************************/ + /*************************************************************************/ + /* rho = -sin(km*z)*cos(kn*x) */ + x = pos[0]; + z = pos[1]; + Z = PetscExpReal(2.0 * B * x); + km = m * PETSC_PI; /* solution valid for km not zero -- should get trivial solution if km=0 */ + kn = (PetscReal)n * PETSC_PI; + /*************************************************************************/ + /*************************************************************************/ + a = B * B + km * km; + b = 2.0 * km * B; + r = PetscSqrtReal(a * a + b * b); + Rp = PetscSqrtReal((r + a) / 2.0); + Rm = PetscSqrtReal((r - a) / 2.0); + UU = Rp - B; + VV = Rp + B; + + sum1 = 0.0; + sum2 = 0.0; + sum3 = 0.0; + sum4 = 0.0; + sum5 = 0.0; + sum6 = 0.0; + /*sum7=0.0;*/ + + /*******************************************/ + /* calculate the constants */ + /*******************************************/ + + t1 = kn * kn; + t4 = km * km; + t5 = t4 + t1; + t6 = t5 * t5; + t8 = pow(km + kn, 0.2e1); + t9 = B * B; + t16 = pow(km - kn, 0.2e1); + _aa = -0.4e1 * B * t1 * sigma * t5 / (t6 + 0.4e1 * t8 * t9) / (t6 + 0.4e1 * t16 * t9); + + t2 = km * km; + t3 = kn * kn; + t5 = pow(t2 + t3, 0.2e1); + t6 = km - kn; + t7 = km + kn; + t9 = B * B; + t13 = t7 * t7; + t19 = t6 * t6; + _bb = sigma * kn * (t5 + 0.4e1 * t6 * t7 * t9) / (t5 + 0.4e1 * t13 * t9) / (t5 + 0.4e1 * t19 * t9); + + AA = _aa; + BB = _bb; + + /*******************************************/ + /* calculate the velocities etc */ + /*******************************************/ + t1 = Rm * Rm; + t2 = B - Rp; + t4 = Rp + B; + t6 = UU * x; + t9 = PetscExpReal(t6 - 0.4e1 * Rp); + t13 = B * B; + t16 = Rp * t1; + t18 = Rp * Rp; + t19 = B * t18; + t20 = t13 * Rp; + t22 = kn * kn; + t24 = B * t1; + t32 = 0.8e1 * t13 * BB * kn * Rp; + t34 = 0.2e1 * Rm; + t35 = PetscCosReal(t34); + t37 = Rp * Rm; + t49 = PetscSinReal(t34); + t63 = PetscExpReal(t6 - 0.2e1 * Rp); + t65 = Rm * t2; + t67 = 0.2e1 * B * kn; + t68 = B * Rm; + t69 = t67 + t68 + t37; + t73 = 0.3e1 * t13; + t75 = 0.2e1 * B * Rp; + t76 = t73 - t75 + t1 - t22 - t18; + t78 = t65 * t76 * BB; + t80 = Rm - kn; + t81 = PetscCosReal(t80); + t83 = -t67 + t68 + t37; + t88 = Rm + kn; + t89 = PetscCosReal(t88); + t92 = t65 * t76 * AA; + t97 = PetscSinReal(t80); + t103 = PetscSinReal(t88); + t108 = PetscExpReal(t6 - 0.3e1 * Rp - B); + t110 = Rm * t4; + t111 = t67 + t68 - t37; + t115 = t73 + t75 + t1 - t22 - t18; + t117 = t110 * t115 * BB; + t120 = -t67 + t68 - t37; + t127 = t110 * t115 * AA; + t140 = PetscExpReal(t6 - Rp - B); + num1 = -0.4e1 * t1 * t2 * t4 * AA * t9 + ((0.2e1 * Rp * (0.3e1 * t13 * B - 0.2e1 * t16 - t19 - 0.2e1 * t20 - B * t22 - t24) * AA - t32) * t35 + (0.2e1 * t37 * (t1 + 0.5e1 * t13 - t22 - t18) * AA - 0.8e1 * B * BB * kn * Rm * Rp) * t49 - 0.2e1 * B * (0.3e1 * t20 - Rp * t22 - t18 * Rp - 0.2e1 * t19 - t16 - 0.2e1 * t24) * AA + t32) * t63 + ((0.2e1 * t65 * t69 * AA + t78) * t81 + (0.2e1 * t65 * t83 * AA - t78) * t89 + (t92 - 0.2e1 * t65 * t69 * BB) * t97 + (t92 + 0.2e1 * t65 * t83 * BB) * t103) * t108 + ((-0.2e1 * t110 * t111 * AA - t117) * t81 + (-0.2e1 * t110 * t120 * AA + t117) * t89 + (-t127 + 0.2e1 * t110 * t111 * BB) * t97 + (-t127 - 0.2e1 * t110 * t120 * BB) * t103) * t140; + + t1 = Rp + B; + t2 = Rm * t1; + t3 = B * B; + t4 = 0.3e1 * t3; + t5 = B * Rp; + t7 = Rm * Rm; + t8 = kn * kn; + t9 = Rp * Rp; + t10 = t4 + 0.2e1 * t5 + t7 - t8 - t9; + t12 = t2 * t10 * AA; + t14 = B * Rm; + t20 = UU * x; + t23 = PetscExpReal(t20 - 0.4e1 * Rp); + t25 = Rp * Rm; + t32 = Rm * kn; + t37 = 0.2e1 * Rm; + t38 = PetscCosReal(t37); + t40 = t3 * B; + t44 = B * t9; + t45 = t3 * Rp; + t53 = t3 * BB; + t58 = PetscSinReal(t37); + t69 = PetscExpReal(t20 - 0.2e1 * Rp); + t72 = 0.3e1 * t40 * Rm; + t73 = t9 * Rp; + t74 = t73 * Rm; + t75 = t7 * Rm; + t76 = B * t75; + t77 = t14 * t8; + t78 = Rp * t75; + t80 = 0.8e1 * t45 * kn; + t81 = t25 * t8; + t83 = 0.5e1 * t45 * Rm; + t84 = t44 * Rm; + t85 = t72 - t74 + t76 - t77 + t78 + t80 - t81 + t83 + t84; + t88 = 0.2e1 * t9 * t3; + t90 = 0.3e1 * t40 * Rp; + t91 = t7 * t3; + t93 = 0.2e1 * t5 * t32; + t94 = t5 * t7; + t95 = t5 * t8; + t96 = B * t73; + t97 = t7 * t9; + t100 = 0.2e1 * t3 * Rm * kn; + t101 = -t88 + t90 - t91 - t93 - t94 - t95 - t96 - t97 - t100; + t105 = Rm - kn; + t106 = PetscCosReal(t105); + t108 = t72 - t80 + t83 + t76 + t84 - t81 - t74 + t78 - t77; + t110 = -t97 - t96 - t88 + t100 + t90 - t95 + t93 - t91 - t94; + t114 = Rm + kn; + t115 = PetscCosReal(t114); + t121 = PetscSinReal(t105); + t127 = PetscSinReal(t114); + t132 = PetscExpReal(t20 - 0.3e1 * Rp - B); + t135 = 0.2e1 * B * kn; + t136 = t135 + t14 - t25; + t142 = -t135 + t14 - t25; + t152 = t2 * t10 * BB; + t162 = PetscExpReal(t20 - Rp - B); + num2 = (0.2e1 * t12 - 0.8e1 * t14 * kn * t1 * BB) * t23 + ((-0.2e1 * t25 * (t7 + 0.5e1 * t3 - t8 - t9) * AA + 0.8e1 * B * BB * t32 * Rp) * t38 + (0.2e1 * Rp * (0.3e1 * t40 - 0.2e1 * Rp * t7 - t44 - 0.2e1 * t45 - B * t8 - B * t7) * AA - 0.8e1 * t53 * kn * Rp) * t58 - 0.2e1 * t14 * (-t8 + t9 + t4 + t7) * AA + 0.8e1 * t53 * t32) * t69 + ((-t85 * AA - 0.2e1 * t101 * BB) * t106 + (-t108 * AA + 0.2e1 * t110 * BB) * t115 + (-0.2e1 * t101 * AA + t85 * BB) * t121 + (-0.2e1 * t110 * AA - t108 * BB) * t127) * t132 + ((t12 - 0.2e1 * t2 * t136 * BB) * t106 + (t12 + 0.2e1 * t2 * t142 * BB) * t115 + (-0.2e1 * t2 * t136 * AA - t152) * t121 + (-0.2e1 * t2 * t142 * AA + t152) * t127) * t162; + + t1 = Rm * Rm; + t2 = B - Rp; + t4 = Rp + B; + t6 = VV * x; + t7 = PetscExpReal(-t6); + t11 = kn * kn; + t13 = B * t1; + t14 = Rp * Rp; + t15 = B * t14; + t16 = B * B; + t17 = t16 * Rp; + t21 = Rp * t1; + t30 = 0.8e1 * t16 * BB * kn * Rp; + t32 = 0.2e1 * Rm; + t33 = PetscCosReal(t32); + t35 = Rp * Rm; + t47 = PetscSinReal(t32); + t61 = PetscExpReal(-t6 - 0.2e1 * Rp); + t63 = Rm * t2; + t65 = 0.2e1 * B * kn; + t66 = B * Rm; + t67 = t65 + t66 + t35; + t71 = 0.3e1 * t16; + t73 = 0.2e1 * B * Rp; + t74 = t71 - t73 + t1 - t11 - t14; + t76 = t63 * t74 * BB; + t78 = Rm - kn; + t79 = PetscCosReal(t78); + t81 = -t65 + t66 + t35; + t86 = Rm + kn; + t87 = PetscCosReal(t86); + t90 = t63 * t74 * AA; + t95 = PetscSinReal(t78); + t101 = PetscSinReal(t86); + t106 = PetscExpReal(-t6 - 0.3e1 * Rp - B); + t108 = Rm * t4; + t109 = t65 + t66 - t35; + t113 = t71 + t73 + t1 - t11 - t14; + t115 = t108 * t113 * BB; + t118 = -t65 + t66 - t35; + t125 = t108 * t113 * AA; + t138 = PetscExpReal(-t6 - Rp - B); + num3 = -0.4e1 * t1 * t2 * t4 * AA * t7 + ((-0.2e1 * Rp * (-B * t11 - t13 - t15 + 0.2e1 * t17 + 0.3e1 * t16 * B + 0.2e1 * t21) * AA + t30) * t33 + (-0.2e1 * t35 * (t1 + 0.5e1 * t16 - t11 - t14) * AA + 0.8e1 * B * BB * kn * Rm * Rp) * t47 + 0.2e1 * B * (0.3e1 * t17 - t21 + 0.2e1 * t15 + 0.2e1 * t13 - Rp * t11 - t14 * Rp) * AA - t30) * t61 + ((-0.2e1 * t63 * t67 * AA - t76) * t79 + (-0.2e1 * t63 * t81 * AA + t76) * t87 + (-t90 + 0.2e1 * t63 * t67 * BB) * t95 + (-t90 - 0.2e1 * t63 * t81 * BB) * t101) * t106 + ((0.2e1 * t108 * t109 * AA + t115) * t79 + (0.2e1 * t108 * t118 * AA - t115) * t87 + (t125 - 0.2e1 * t108 * t109 * BB) * t95 + (t125 + 0.2e1 * t108 * t118 * BB) * t101) * t138; + + t1 = B - Rp; + t2 = Rm * t1; + t3 = B * B; + t4 = 0.3e1 * t3; + t5 = B * Rp; + t7 = Rm * Rm; + t8 = kn * kn; + t9 = Rp * Rp; + t10 = t4 - 0.2e1 * t5 + t7 - t8 - t9; + t12 = t2 * t10 * AA; + t14 = B * Rm; + t20 = VV * x; + t21 = PetscExpReal(-t20); + t23 = Rp * Rm; + t30 = Rm * kn; + t35 = 0.2e1 * Rm; + t36 = PetscCosReal(t35); + t40 = B * t9; + t41 = t3 * Rp; + t43 = t3 * B; + t51 = t3 * BB; + t56 = PetscSinReal(t35); + t67 = PetscExpReal(-t20 - 0.2e1 * Rp); + t70 = 0.2e1 * B * kn; + t71 = t70 + t14 + t23; + t76 = Rm - kn; + t77 = PetscCosReal(t76); + t79 = -t70 + t14 + t23; + t84 = Rm + kn; + t85 = PetscCosReal(t84); + t91 = t2 * t10 * BB; + t93 = PetscSinReal(t76); + t99 = PetscSinReal(t84); + t104 = PetscExpReal(-t20 - 0.3e1 * Rp - B); + t107 = 0.3e1 * t43 * Rm; + t108 = t9 * Rp; + t109 = t108 * Rm; + t110 = t7 * Rm; + t111 = B * t110; + t112 = t14 * t8; + t113 = Rp * t110; + t115 = 0.8e1 * t41 * kn; + t116 = t23 * t8; + t118 = 0.5e1 * t41 * Rm; + t119 = t40 * Rm; + t120 = t107 + t109 + t111 - t112 - t113 - t115 + t116 - t118 + t119; + t124 = 0.2e1 * t3 * Rm * kn; + t125 = t5 * t8; + t126 = B * t108; + t127 = t7 * t9; + t129 = 0.2e1 * t9 * t3; + t130 = t5 * t7; + t132 = 0.3e1 * t43 * Rp; + t133 = t7 * t3; + t135 = 0.2e1 * t5 * t30; + t136 = t124 - t125 - t126 + t127 + t129 - t130 + t132 + t133 - t135; + t141 = t107 + t115 - t118 + t111 + t119 + t116 + t109 - t113 - t112; + t143 = t132 + t129 - t125 + t133 + t127 - t124 - t130 - t126 + t135; + t160 = PetscExpReal(-t20 - Rp - B); + num4 = (0.2e1 * t12 - 0.8e1 * t14 * kn * t1 * BB) * t21 + ((0.2e1 * t23 * (t7 + 0.5e1 * t3 - t8 - t9) * AA - 0.8e1 * B * BB * t30 * Rp) * t36 + (-0.2e1 * Rp * (-B * t8 - B * t7 - t40 + 0.2e1 * t41 + 0.3e1 * t43 + 0.2e1 * Rp * t7) * AA + 0.8e1 * t51 * kn * Rp) * t56 - 0.2e1 * t14 * (-t8 + t9 + t4 + t7) * AA + 0.8e1 * t51 * t30) * t67 + ((t12 - 0.2e1 * t2 * t71 * BB) * t77 + (t12 + 0.2e1 * t2 * t79 * BB) * t85 + (-0.2e1 * t2 * t71 * AA - t91) * t93 + (-0.2e1 * t2 * t79 * AA + t91) * t99) * t104 + ((-t120 * AA + 0.2e1 * t136 * BB) * t77 + (-t141 * AA - 0.2e1 * t143 * BB) * t85 + (0.2e1 * t136 * AA + t120 * BB) * t93 + (0.2e1 * t143 * AA - t141 * BB) * t99) * t160; + + t1 = Rm * Rm; + t2 = Rp * Rp; + t3 = t1 * t2; + t4 = B * B; + t5 = t1 * t4; + t9 = PetscExpReal(-0.4e1 * Rp); + t15 = PetscCosReal(0.2e1 * Rm); + t22 = PetscExpReal(-0.2e1 * Rp); + den1 = (-0.4e1 * t3 + 0.4e1 * t5) * t9 + ((0.8e1 * t1 + 0.8e1 * t4) * t2 * t15 - 0.8e1 * t5 - 0.8e1 * t2 * t4) * t22 - 0.4e1 * t3 + 0.4e1 * t5; + + _PC1 = num1 / den1; + _PC2 = num2 / den1; + _PC3 = num3 / den1; + _PC4 = num4 / den1; + + t1 = Rm * x; + t2 = PetscCosReal(t1); + t4 = PetscSinReal(t1); + t10 = PetscExpReal(-0.2e1 * x * B); + t12 = kn * x; + t13 = PetscCosReal(t12); + t16 = PetscSinReal(t12); + u1 = -km * (_PC1 * t2 + _PC2 * t4 + _PC3 * t2 + _PC4 * t4 + t10 * AA * t13 + t10 * BB * t16); + + t2 = Rm * x; + t3 = PetscCosReal(t2); + t6 = PetscSinReal(t2); + t22 = PetscExpReal(-0.2e1 * x * B); + t23 = B * t22; + t24 = kn * x; + t25 = PetscCosReal(t24); + t29 = PetscSinReal(t24); + u2 = UU * _PC1 * t3 + UU * _PC2 * t6 - _PC1 * t6 * Rm + _PC2 * t3 * Rm - VV * _PC3 * t3 - VV * _PC4 * t6 - _PC3 * t6 * Rm + _PC4 * t3 * Rm - 0.2e1 * t23 * AA * t25 - 0.2e1 * t23 * BB * t29 - t22 * AA * t29 * kn + t22 * BB * t25 * kn; + + t3 = PetscExpReal(0.2e1 * x * B); + t4 = t3 * B; + t8 = km * km; + t9 = t3 * t8; + t11 = 0.3e1 * t9 * Rm; + t12 = Rm * Rm; + t14 = t3 * t12 * Rm; + t15 = UU * UU; + t19 = 0.4e1 * t4 * UU * Rm - t11 - t14 + 0.3e1 * t3 * t15 * Rm; + t20 = Rm * x; + t21 = PetscSinReal(t20); + t27 = 0.2e1 * B * t9; + t33 = 0.2e1 * t4 * t12; + t36 = 0.3e1 * t3 * UU * t12 - t27 - 0.2e1 * t4 * t15 + 0.3e1 * t9 * UU + t33 - t3 * t15 * UU; + t37 = PetscCosReal(t20); + t49 = VV * VV; + t53 = -0.4e1 * t4 * VV * Rm - t11 + 0.3e1 * t3 * t49 * Rm - t14; + t64 = t3 * t49 * VV + t33 - 0.3e1 * t9 * VV - 0.2e1 * t4 * t49 - t27 - 0.3e1 * t3 * VV * t12; + t76 = B * t8; + t80 = kn * kn; + t83 = B * B; + t87 = t80 * kn; + t90 = kn * x; + t91 = PetscSinReal(t90); + t106 = PetscCosReal(t90); + u3 = -((t19 * t21 + t36 * t37) * _PC1 + (t36 * t21 - t19 * t37) * _PC2 + (t53 * t21 + t64 * t37) * _PC3 + (t64 * t21 - t53 * t37) * _PC4 + (-0.3e1 * t8 * AA * kn - 0.8e1 * t76 * BB - 0.4e1 * BB * B * t80 + 0.4e1 * AA * t83 * kn - AA * t87) * t91 + (-0.4e1 * AA * t80 * B - 0.4e1 * t83 * BB * kn + 0.3e1 * t8 * BB * kn - sigma + BB * t87 - 0.8e1 * t76 * AA) * t106) / km; + + t3 = PetscExpReal(0.2e1 * x * B); + t4 = km * km; + t5 = t3 * t4; + t6 = Rm * x; + t7 = PetscCosReal(t6); + t8 = _PC1 * t7; + t10 = PetscSinReal(t6); + t11 = _PC2 * t10; + t13 = _PC3 * t7; + t15 = _PC4 * t10; + t18 = kn * x; + t19 = PetscCosReal(t18); + t22 = PetscSinReal(t18); + t24 = UU * UU; + t25 = t3 * t24; + t28 = t3 * UU; + t38 = Rm * Rm; + t39 = t7 * t38; + t42 = t10 * t38; + t44 = t5 * t8 + t5 * t11 + t5 * t13 + t5 * t15 + t4 * AA * t19 + t4 * BB * t22 + t25 * t8 + t25 * t11 - 0.2e1 * t28 * _PC1 * t10 * Rm + 0.2e1 * t28 * _PC2 * t7 * Rm - t3 * _PC1 * t39 - t3 * _PC2 * t42; + t45 = VV * VV; + t46 = t3 * t45; + t49 = t3 * VV; + t62 = B * B; + t78 = kn * kn; + t82 = t46 * t13 + t46 * t15 + 0.2e1 * t49 * _PC3 * t10 * Rm - 0.2e1 * t49 * _PC4 * t7 * Rm - t3 * _PC3 * t39 - t3 * _PC4 * t42 + 0.4e1 * t62 * AA * t19 + 0.4e1 * t62 * BB * t22 + 0.4e1 * B * AA * t22 * kn - 0.4e1 * B * BB * t19 * kn - AA * t19 * t78 - BB * t22 * t78; + u4 = t44 + t82; + + t3 = PetscExpReal(0.2e1 * x * B); + t4 = t3 * B; + t8 = km * km; + t9 = t3 * t8; + t10 = t9 * Rm; + t11 = Rm * Rm; + t13 = t3 * t11 * Rm; + t14 = UU * UU; + t18 = 0.4e1 * t4 * UU * Rm - t10 - t13 + 0.3e1 * t3 * t14 * Rm; + t19 = Rm * x; + t20 = PetscSinReal(t19); + t26 = 0.2e1 * B * t9; + t31 = 0.2e1 * t4 * t11; + t34 = 0.3e1 * t3 * UU * t11 - t26 - 0.2e1 * t4 * t14 + t9 * UU + t31 - t3 * t14 * UU; + t35 = PetscCosReal(t19); + t47 = VV * VV; + t51 = -0.4e1 * t4 * VV * Rm - t10 + 0.3e1 * t3 * t47 * Rm - t13; + t61 = t3 * t47 * VV + t31 - t9 * VV - 0.2e1 * t4 * t47 - t26 - 0.3e1 * t3 * VV * t11; + t72 = B * t8; + t76 = kn * kn; + t79 = B * B; + t83 = t76 * kn; + t86 = kn * x; + t87 = PetscSinReal(t86); + t101 = PetscCosReal(t86); + u5 = ((t18 * t20 + t34 * t35) * _PC1 + (t34 * t20 - t18 * t35) * _PC2 + (t51 * t20 + t61 * t35) * _PC3 + (t61 * t20 - t51 * t35) * _PC4 + (-t8 * AA * kn - 0.4e1 * t72 * BB - 0.4e1 * BB * B * t76 + 0.4e1 * AA * t79 * kn - AA * t83) * t87 + (-0.4e1 * AA * t76 * B - 0.4e1 * t79 * BB * kn + t8 * BB * kn - sigma + BB * t83 - 0.4e1 * t72 * AA) * t101) / km; + + t3 = PetscExpReal(0.2e1 * x * B); + t4 = UU * UU; + t8 = km * km; + t9 = t3 * t8; + t10 = t9 * Rm; + t11 = Rm * Rm; + t13 = t3 * t11 * Rm; + t14 = t3 * B; + t18 = 0.3e1 * t3 * t4 * Rm + t10 - t13 + 0.4e1 * t14 * UU * Rm; + t19 = Rm * x; + t20 = PetscSinReal(t19); + t28 = 0.2e1 * B * t9; + t33 = 0.2e1 * t14 * t11; + t34 = -0.2e1 * t4 * t14 + 0.3e1 * t3 * UU * t11 - t28 - t3 * t4 * UU - t9 * UU + t33; + t35 = PetscCosReal(t19); + t47 = VV * VV; + t51 = -0.4e1 * t14 * VV * Rm - t13 + t10 + 0.3e1 * t3 * t47 * Rm; + t61 = -0.3e1 * t3 * VV * t11 + t33 + t3 * t47 * VV + t9 * VV - 0.2e1 * t14 * t47 - t28; + t71 = kn * kn; + t74 = B * B; + t80 = t71 * kn; + t83 = kn * x; + t84 = PetscSinReal(t83); + t96 = PetscCosReal(t83); + u6 = -((t18 * t20 + t34 * t35) * _PC1 + (t34 * t20 - t18 * t35) * _PC2 + (t51 * t20 + t61 * t35) * _PC3 + (t61 * t20 - t51 * t35) * _PC4 + (-0.4e1 * BB * B * t71 + 0.4e1 * AA * t74 * kn + t8 * AA * kn - AA * t80) * t84 + (-0.4e1 * AA * t71 * B - t8 * BB * kn - 0.4e1 * t74 * BB * kn - sigma + BB * t80) * t96) / km; + + /* SS = sin(km*z)*(exp(UU*x)*(_PC1*cos(Rm*x)+_PC2*sin(Rm*x)) + exp(-VV*x)*(_PC3*cos(Rm*x)+_PC4*sin(Rm*x)) + exp(-2*x*B)*(AA*cos(kn*x)+BB*sin(kn*x))); */ + + /* u1 = Vx, u2 = Vz, u3 = txx, u4 = tzx, u5 = pressure, u6 = tzz */ + + sum5 += u5 * PetscCosReal(km * z); /* pressure */ + sum6 += u6 * PetscCosReal(km * z); /* zz total stress */ + + u1 *= PetscCosReal(km * z); /* x velocity */ + sum1 += u1; + u2 *= PetscSinReal(km * z); /* z velocity */ + sum2 += u2; + + u3 *= PetscCosReal(km * z); /* xx total stress */ + sum3 += u3; + u4 *= PetscSinReal(km * z); /* zx stress */ + sum4 += u4; + + /* rho = -sigma*sin(km*z)*cos(kn*x); */ /* density */ + /* sum7 += rho; */ + + /* Output */ + if (mu) *mu = Z; + if (vel) { + vel[0] = sum1; + vel[1] = sum2; + } + if (p) (*p) = sum5; + if (s) { + s[0] = sum3; + s[1] = sum4; + s[2] = sum6; + } + if (gamma) { + /* sigma = tau - p, tau = sigma + p, tau[] = 2*eta*gamma[] */ + gamma[0] = (sum3 + sum5) / (2.0 * Z); + gamma[1] = (sum4) / (2.0 * Z); + gamma[2] = (sum6 + sum5) / (2.0 * Z); + } + PetscFunctionReturn(PETSC_SUCCESS); +} diff --git a/src/underworld3/analytic/_reference/solKz.c b/src/underworld3/analytic/_reference/solKz.c new file mode 100644 index 000000000..cff477261 --- /dev/null +++ b/src/underworld3/analytic/_reference/solKz.c @@ -0,0 +1,539 @@ +/*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~* +** ** +** This file forms part of the Underworld geophysics modelling application. ** +** ** +** For full license and copyright information, please refer to the LICENSE.md file ** +** located at the project root, or contact the authors. ** +** ** +**~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*/ +#include "solKz.h" + +#if 0 +int main( int argc, char **argv ) +{ + int i,j; + double pos[2], vel[2], pressure, total_stress[3], strain_rate[3]; + double x,z; + + for (i=0;i<101;i++){ + for(j=0;j<101;j++){ + x = i/100.0; + z = j/100.0; + + pos[0] = x; + pos[1] = z; + _Velic_solKz( + pos, + 1.0, + (double)M_PI, 1, + 2.5, + vel, &pressure, total_stress, strain_rate ); + printf("%0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f \n", + pos[0],pos[1], + vel[0],vel[1], pressure, + total_stress[0], total_stress[1], total_stress[2], + strain_rate[0], strain_rate[1], strain_rate[2] ); + } + printf("\n"); + } + + return 0; +} +#endif + + + +void _Velic_solKz( + const double pos[], + double _sigma, /* density */ + double _km, int _n, /* wavelength in z, wavenumber in x */ + double _B, /* viscosity parameter */ + double vel[], double* presssure, + double total_stress[], double strain_rate[] ) +{ + double Z; + double u1,u2,u3,u4,u5,u6,SS; + double sum1,sum2,sum3,sum4,sum5,sum6,sum7,x,z; + double sigma; + int n; + double kn; + double _C1,_C2,_C3,_C4; + double B, Rp, UU, VV; + double rho,a,b,r,_aa,_bb,AA,BB,Rm,km; + + + double t1,t2,t3,t4,t5,t6,t7,t8,t9,t10; + double t11,t12,t13,t14,t15,t16,t17,t18,t19,t20; + double t21,t22,t23,t24,t25,t26,t27,t28,t29,t31; + double t33,t34,t35,t37,t38,t40,t41,t42,t43,t45; + double t47,t51,t52,t53,t54,t55,t56,t57,t58,t59; + double t60,t61,t62,t64,t65,t66,t67,t68,t69,t70; + double t71,t72,t73,t74,t75,t76,t77,t78,t79,t80; + double t81,t82,t83,t84,t85,t86,t89,t90,t92,t94; + double t96,t97,t98,t99,t100,t101,t103,t104,t105,t106; + double t107,t108,t109,t110,t111,t112,t113,t114,t115,t116; + double t117,t118,t119,t120,t121,t122,t123,t124,t125,t126; + double t127,t130,t131,t132,t134,t135,t141,t144,t147,t148; + double t150,t151,t152,t161,t171; + + + + /*************************************************************************/ + /*************************************************************************/ + /* rho = -sigma*sin(km*z)*cos(kn*x) */ + /* viscosity Z= exp(2*B*z) */ + B = _B; /* viscosity parameter must be non-zero*/ + km = _km; /* solution valid for km not zero -- should get trivial solution if km=0 */ + n = _n; /* solution valid for n not zero */ + sigma = _sigma; + /*************************************************************************/ + /*************************************************************************/ + kn = (double) _n*M_PI; + a = B*B + kn*kn; + b = 2.0*kn*B; + r = sqrt(a*a + b*b); + Rp = sqrt( (r+a)/2.0 ); + Rm = sqrt( (r-a)/2.0 ); + UU = Rp - B; + VV = Rp + B; + + + x = pos[0]; + z = pos[1]; + + sum1=0.0; + sum2=0.0; + sum3=0.0; + sum4=0.0; + sum5=0.0; + sum6=0.0; + sum7=0.0; + + + + + /*******************************************/ + /* calculate the constants */ + /*******************************************/ + + t3 = kn * kn; + t4 = km * km; + t6 = B * B; + t8 = 0.4e1 * t3 * t6; + t10 = 0.4e1 * t4 * t6; + t13 = 0.8e1 * kn * t6 * km; + t14 = t4 * t4; + t16 = 0.2e1 * t3 * t4; + t17 = t3 * t3; + _aa = -0.4e1 * B * km * kn * (t3 + t4) / (t8 + t10 + t13 + t14 + t16 + t17) / (-t13 + t8 + t10 + t14 + t16 + t17); + + t1 = kn * kn; + t2 = t1 * t1; + t3 = B * B; + t5 = 0.4e1 * t1 * t3; + t6 = km * km; + t7 = t6 * t6; + t9 = 0.2e1 * t1 * t6; + t11 = 0.4e1 * t3 * t6; + t16 = 0.8e1 * kn * t3 * km; + _bb = kn * (t2 + t5 + t7 + t9 - t11) / (t5 + t11 + t16 + t7 + t9 + t2) / (-t16 + t5 + t11 + t7 + t9 + t2); + + AA = _aa; + BB = _bb; + + t1 = B * B; + t2 = t1 * Rp; + t4 = Rm * Rm; + t5 = t4 * Rp; + t7 = t4 * B; + t8 = km * km; + t12 = Rp * Rp; + t13 = B * t12; + t21 = 0.8e1 * t1 * km * BB * Rp; + t23 = 0.2e1 * Rm; + t24 = cos(t23); + t26 = Rm * Rp; + t38 = sin(t23); + t51 = exp(-0.2e1 * Rp); + t53 = B + Rp; + t54 = Rm * t53; + t55 = Rm * B; + t57 = 0.2e1 * B * km; + t58 = t55 + t57 - t26; + t62 = 0.3e1 * t1; + t64 = 0.2e1 * Rp * B; + t65 = t62 + t64 + t4 - t8 - t12; + t67 = t54 * t65 * BB; + t69 = Rm - km; + t70 = cos(t69); + t72 = -t57 + t55 - t26; + t77 = Rm + km; + t78 = cos(t77); + t81 = t54 * t65 * AA; + t86 = sin(t77); + t92 = sin(t69); + t96 = exp(-t53); + t98 = B - Rp; + t99 = Rm * t98; + t100 = t55 + t57 + t26; + t104 = t62 - t64 + t4 - t8 - t12; + t106 = t99 * t104 * BB; + t109 = -t57 + t55 + t26; + t116 = t99 * t104 * AA; + t130 = exp(-0.3e1 * Rp - B); + t135 = exp(-0.4e1 * Rp); + t144 = t4 * t1; + t150 = t4 * t12; + _C1 = (((0.2e1 * Rp * (0.2e1 * t2 + 0.2e1 * t5 + t7 + B * t8 - 0.3e1 * t1 * B + t13) * AA + t21) * t24 + (-0.2e1 * t26 * (t4 - t8 - t12 + 0.5e1 * t1) * AA + 0.8e1 * B * BB * km * Rm * Rp) * t38 - 0.2e1 * B * (0.2e1 * t13 + t12 * Rp - 0.3e1 * t2 + t5 + 0.2e1 * t7 + t8 * Rp) * AA - t21) * t51 + ((0.2e1 * t54 * t58 * AA + t67) * t70 + (0.2e1 * t54 * t72 * AA - t67) * t78 + (t81 + 0.2e1 * t54 * t72 * BB) * t86 + (t81 - 0.2e1 * t54 * t58 * BB) * t92) * t96 + ((-0.2e1 * t99 * t100 * AA - t106) * t70 + (-0.2e1 * t99 * t109 * AA + t106) * t78 + (-t116 - 0.2e1 * t99 * t109 * BB) * t86 + (-t116 + 0.2e1 * t99 * t100 * BB) * t92) * t130 + 0.4e1 * t4 * t98 * t53 * AA * t135) / (((-0.8e1 * t4 - 0.8e1 * t1) * t12 * t24 + 0.8e1 * t144 + 0.8e1 * t12 * t1) * t51 + (0.4e1 * t150 - 0.4e1 * t144) * t135 + 0.4e1 * t150 - 0.4e1 * t144); + + t1 = Rm * Rp; + t2 = Rm * Rm; + t3 = km * km; + t4 = Rp * Rp; + t5 = B * B; + t12 = km * Rm; + t17 = 0.2e1 * Rm; + t18 = cos(t17); + t22 = t2 * Rp; + t25 = B * t3; + t26 = t5 * B; + t33 = t5 * km; + t38 = sin(t17); + t40 = Rm * B; + t41 = 0.3e1 * t5; + t51 = exp(-0.2e1 * Rp); + t53 = B + Rp; + t54 = Rm * t53; + t57 = t41 + 0.2e1 * Rp * B + t2 - t3 - t4; + t59 = t54 * t57 * AA; + t60 = B * km; + t61 = 0.2e1 * t60; + t62 = t40 + t61 - t1; + t67 = Rm - km; + t68 = cos(t67); + t70 = -t61 + t40 - t1; + t75 = Rm + km; + t76 = cos(t75); + t82 = t54 * t57 * BB; + t84 = sin(t75); + t90 = sin(t67); + t94 = exp(-t53); + t97 = 0.3e1 * Rm * t26; + t98 = t2 * Rm; + t99 = t98 * B; + t100 = t3 * Rm; + t101 = t100 * Rp; + t103 = Rm * t4 * B; + t104 = t4 * Rp; + t105 = Rm * t104; + t107 = 0.8e1 * t33 * Rp; + t109 = 0.5e1 * t1 * t5; + t110 = t98 * Rp; + t111 = t100 * B; + t112 = t97 + t99 - t101 + t103 - t105 + t107 + t109 + t110 - t111; + t114 = t2 * t4; + t116 = 0.2e1 * t60 * t1; + t117 = t2 * t5; + t119 = 0.3e1 * t26 * Rp; + t120 = t104 * B; + t121 = t4 * t5; + t122 = 0.2e1 * t121; + t123 = t22 * B; + t125 = 0.2e1 * t33 * Rm; + t126 = t25 * Rp; + t127 = t114 + t116 + t117 - t119 + t120 + t122 + t123 + t125 + t126; + t132 = -t107 + t103 - t105 - t101 + t97 - t111 + t110 + t109 + t99; + t134 = t120 - t125 + t123 - t116 + t122 + t117 + t114 + t126 - t119; + t152 = exp(-0.3e1 * Rp - B); + t161 = exp(-0.4e1 * Rp); + _C2 = (((0.2e1 * t1 * (t2 - t3 - t4 + 0.5e1 * t5) * AA - 0.8e1 * B * BB * t12 * Rp) * t18 + (0.2e1 * Rp * (0.2e1 * t5 * Rp + 0.2e1 * t22 + t2 * B + t25 - 0.3e1 * t26 + B * t4) * AA + 0.8e1 * t33 * BB * Rp) * t38 + 0.2e1 * t40 * (t41 + t4 + t2 - t3) * AA - 0.8e1 * t5 * BB * t12) * t51 + ((-t59 + 0.2e1 * t54 * t62 * BB) * t68 + (-t59 - 0.2e1 * t54 * t70 * BB) * t76 + (0.2e1 * t54 * t70 * AA - t82) * t84 + (0.2e1 * t54 * t62 * AA + t82) * t90) * t94 + ((t112 * AA - 0.2e1 * t127 * BB) * t68 + (t132 * AA + 0.2e1 * t134 * BB) * t76 + (-0.2e1 * t134 * AA + t132 * BB) * t84 + (-0.2e1 * t127 * AA - t112 * BB) * t90) * t152 + (-0.2e1 * t59 + 0.8e1 * t40 * km * t53 * BB) * t161) / (((-0.8e1 * t2 - 0.8e1 * t5) * t4 * t18 + 0.8e1 * t117 + 0.8e1 * t121) * t51 + (0.4e1 * t114 - 0.4e1 * t117) * t161 + 0.4e1 * t114 - 0.4e1 * t117); + + t1 = B * B; + t2 = t1 * Rp; + t4 = Rm * Rm; + t5 = t4 * Rp; + t7 = Rp * Rp; + t8 = B * t7; + t11 = km * km; + t13 = t4 * B; + t21 = 0.8e1 * t1 * km * BB * Rp; + t23 = 0.2e1 * Rm; + t24 = cos(t23); + t26 = Rm * Rp; + t38 = sin(t23); + t51 = exp(-0.2e1 * Rp); + t53 = B + Rp; + t54 = Rm * t53; + t55 = Rm * B; + t57 = 0.2e1 * B * km; + t58 = t55 + t57 - t26; + t62 = 0.3e1 * t1; + t64 = 0.2e1 * Rp * B; + t65 = t62 + t64 + t4 - t11 - t7; + t67 = t54 * t65 * BB; + t69 = Rm - km; + t70 = cos(t69); + t72 = -t57 + t55 - t26; + t77 = Rm + km; + t78 = cos(t77); + t81 = t54 * t65 * AA; + t86 = sin(t77); + t92 = sin(t69); + t96 = exp(-t53); + t98 = B - Rp; + t99 = Rm * t98; + t100 = t55 + t57 + t26; + t104 = t62 - t64 + t4 - t11 - t7; + t106 = t99 * t104 * BB; + t109 = -t57 + t55 + t26; + t116 = t99 * t104 * AA; + t130 = exp(-0.3e1 * Rp - B); + t141 = t4 * t1; + t147 = t4 * t7; + t151 = exp(-0.4e1 * Rp); + _C3 = (((-0.2e1 * Rp * (-0.2e1 * t2 - 0.2e1 * t5 + t8 - 0.3e1 * t1 * B + B * t11 + t13) * AA - t21) * t24 + (0.2e1 * t26 * (t4 - t11 - t7 + 0.5e1 * t1) * AA - 0.8e1 * B * BB * km * Rm * Rp) * t38 - 0.2e1 * B * (0.2e1 * t8 + 0.2e1 * t13 + 0.3e1 * t2 - t7 * Rp - t5 - t11 * Rp) * AA + t21) * t51 + ((-0.2e1 * t54 * t58 * AA - t67) * t70 + (-0.2e1 * t54 * t72 * AA + t67) * t78 + (-t81 - 0.2e1 * t54 * t72 * BB) * t86 + (-t81 + 0.2e1 * t54 * t58 * BB) * t92) * t96 + ((0.2e1 * t99 * t100 * AA + t106) * t70 + (0.2e1 * t99 * t109 * AA - t106) * t78 + (t116 + 0.2e1 * t99 * t109 * BB) * t86 + (t116 - 0.2e1 * t99 * t100 * BB) * t92) * t130 + 0.4e1 * t4 * t98 * t53 * AA) / (((-0.8e1 * t4 - 0.8e1 * t1) * t7 * t24 + 0.8e1 * t141 + 0.8e1 * t7 * t1) * t51 + (0.4e1 * t147 - 0.4e1 * t141) * t151 + 0.4e1 * t147 - 0.4e1 * t141); + + t1 = Rm * Rp; + t2 = Rm * Rm; + t3 = km * km; + t4 = Rp * Rp; + t5 = B * B; + t12 = km * Rm; + t17 = 0.2e1 * Rm; + t18 = cos(t17); + t22 = t2 * Rp; + t25 = t5 * B; + t27 = B * t3; + t33 = t5 * km; + t38 = sin(t17); + t40 = Rm * B; + t41 = 0.3e1 * t5; + t51 = exp(-0.2e1 * Rp); + t53 = t2 * Rm; + t54 = t53 * B; + t56 = 0.5e1 * t1 * t5; + t58 = Rm * t4 * B; + t59 = t3 * Rm; + t60 = t59 * Rp; + t62 = 0.8e1 * t33 * Rp; + t64 = 0.3e1 * Rm * t25; + t65 = t53 * Rp; + t66 = t59 * B; + t67 = t4 * Rp; + t68 = Rm * t67; + t69 = t54 - t56 + t58 + t60 - t62 + t64 - t65 - t66 + t68; + t71 = t2 * t4; + t73 = 0.3e1 * t25 * Rp; + t74 = t2 * t5; + t75 = t27 * Rp; + t76 = B * km; + t78 = 0.2e1 * t76 * t1; + t80 = 0.2e1 * t33 * Rm; + t81 = t22 * B; + t82 = t4 * t5; + t83 = 0.2e1 * t82; + t84 = t67 * B; + t85 = t71 + t73 + t74 - t75 - t78 + t80 - t81 + t83 - t84; + t89 = Rm - km; + t90 = cos(t89); + t92 = t60 - t66 - t65 + t58 + t54 - t56 + t62 + t68 + t64; + t94 = t73 + t78 - t81 + t74 - t80 - t84 - t75 + t83 + t71; + t98 = Rm + km; + t99 = cos(t98); + t105 = sin(t98); + t111 = sin(t89); + t115 = exp(-Rp - B); + t117 = B - Rp; + t118 = Rm * t117; + t121 = t41 - 0.2e1 * Rp * B + t2 - t3 - t4; + t123 = t118 * t121 * AA; + t124 = 0.2e1 * t76; + t125 = t40 + t124 + t1; + t131 = -t124 + t40 + t1; + t141 = t118 * t121 * BB; + t152 = exp(-0.3e1 * Rp - B); + t171 = exp(-0.4e1 * Rp); + _C4 = (((-0.2e1 * t1 * (t2 - t3 - t4 + 0.5e1 * t5) * AA + 0.8e1 * B * BB * t12 * Rp) * t18 + (-0.2e1 * Rp * (-0.2e1 * t5 * Rp - 0.2e1 * t22 + t4 * B - 0.3e1 * t25 + t27 + t2 * B) * AA - 0.8e1 * t33 * BB * Rp) * t38 + 0.2e1 * t40 * (t41 + t4 + t2 - t3) * AA - 0.8e1 * t5 * BB * t12) * t51 + ((t69 * AA - 0.2e1 * t85 * BB) * t90 + (t92 * AA + 0.2e1 * t94 * BB) * t99 + (-0.2e1 * t94 * AA + t92 * BB) * t105 + (-0.2e1 * t85 * AA - t69 * BB) * t111) * t115 + ((-t123 + 0.2e1 * t118 * t125 * BB) * t90 + (-t123 - 0.2e1 * t118 * t131 * BB) * t99 + (0.2e1 * t118 * t131 * AA - t141) * t105 + (0.2e1 * t118 * t125 * AA + t141) * t111) * t152 - 0.2e1 * t123 + 0.8e1 * t40 * km * t117 * BB) / (((-0.8e1 * t2 - 0.8e1 * t5) * t4 * t18 + 0.8e1 * t74 + 0.8e1 * t82) * t51 + (0.4e1 * t71 - 0.4e1 * t74) * t171 + 0.4e1 * t71 - 0.4e1 * t74); + + /******************************************************************/ + /******************************************************************/ + + /*******************************************/ + /* calculate the velocities etc */ + /*******************************************/ + + t2 = exp(UU * z); + t3 = Rm * z; + t4 = cos(t3); + t6 = sin(t3); + t11 = exp(-VV * z); + t18 = exp(-0.2e1 * z * B); + t19 = km * z; + t20 = cos(t19); + t22 = sin(t19); + u1 = kn * (t2 * (_C1 * t4 + _C2 * t6) + t11 * (_C3 * t4 + _C4 * t6) + t18 * (AA * t20 + BB * t22)); + + t1 = Rm * z; + t2 = cos(t1); + t4 = sin(t1); + t14 = exp(UU * z); + t26 = exp(-VV * z); + t28 = km * z; + t29 = cos(t28); + t31 = sin(t28); + t43 = exp(-0.2e1 * z * B); + u2 = (-UU * (_C1 * t2 + _C2 * t4) + _C1 * t4 * Rm - _C2 * t2 * Rm) * t14 + (VV * (_C3 * t2 + _C4 * t4) + _C3 * t4 * Rm - _C4 * t2 * Rm) * t26 + (0.2e1 * B * (AA * t29 + BB * t31) + AA * t31 * km - BB * t29 * km) * t43; + + t2 = 0.2e1 * z * B; + t3 = exp(t2); + t4 = t3 * kn; + t5 = Rm * z; + t6 = cos(t5); + t8 = sin(t5); + t18 = exp(UU * z); + t31 = exp(-VV * z); + t34 = km * z; + t35 = cos(t34); + t37 = sin(t34); + t47 = exp(-t2); + u3 = 0.2e1 * t4 * (UU * (_C1 * t6 + _C2 * t8) - _C1 * t8 * Rm + _C2 * t6 * Rm) * t18 + 0.2e1 * t4 * (-VV * (_C3 * t6 + _C4 * t8) - _C3 * t8 * Rm + _C4 * t6 * Rm) * t31 + 0.2e1 * t4 * (-0.2e1 * B * (AA * t35 + BB * t37) - AA * t37 * km + BB * t35 * km) * t47; + + t1 = Rm * Rm; + t3 = UU * UU; + t8 = kn * kn; + t11 = Rm * z; + t12 = sin(t11); + t14 = cos(t11); + t20 = t14 * Rm; + t27 = 0.2e1 * z * B; + t28 = exp(t27); + t31 = exp(UU * z); + t38 = VV * VV; + t54 = exp(-VV * z); + t56 = km * km; + t59 = B * B; + t66 = km * z; + t67 = sin(t66); + t69 = cos(t66); + t83 = exp(-t27); + u4 = ((_C2 * t1 - t3 * _C2 + 0.2e1 * UU * _C1 * Rm - _C2 * t8) * t12 + _C1 * t14 * t1 - t3 * _C1 * t14 - 0.2e1 * UU * _C2 * t20 - t8 * _C1 * t14) * t28 * t31 + ((-0.2e1 * VV * _C3 * Rm + _C4 * t1 - _C4 * t8 - t38 * _C4) * t12 + 0.2e1 * VV * _C4 * t20 + _C3 * t14 * t1 - t8 * _C3 * t14 - t38 * _C3 * t14) * t28 * t54 + ((BB * t56 - t8 * BB - 0.4e1 * t59 * BB - 0.4e1 * B * AA * km) * t67 + AA * t69 * t56 - t8 * AA * t69 - 0.4e1 * t59 * AA * t69 + 0.4e1 * B * BB * t69 * km) * t28 * t83; + + + t1 = Rm * z; + t2 = sin(t1); + t3 = Rm * Rm; + t4 = t3 * Rm; + t5 = t2 * t4; + t6 = UU * UU; + t7 = t6 * UU; + t8 = cos(t1); + t15 = 0.2e1 * B * t8 * t3; + t19 = B * UU; + t20 = t2 * Rm; + t23 = kn * kn; + t24 = B * t23; + t26 = 0.2e1 * t24 * t8; + t27 = t23 * UU; + t29 = B * t6; + t33 = t23 * t2 * Rm; + t35 = 0.1e1 / kn; + t42 = 0.2e1 * B * t2 * t3; + t43 = t8 * t4; + t45 = 0.2e1 * t24 * t2; + t52 = t23 * t8 * Rm; + t53 = t8 * Rm; + t64 = 0.2e1 * z * B; + t65 = exp(t64); + t68 = exp(UU * z); + t70 = B * VV; + t76 = t23 * VV; + t78 = VV * VV; + t79 = t78 * VV; + t84 = B * t78; + t108 = exp(-VV * z); + t111 = km * z; + t112 = sin(t111); + t113 = km * km; + t118 = cos(t111); + t119 = t118 * km; + t121 = B * B; + t123 = t112 * km; + t130 = t113 * km; + t148 = exp(-t64); + u5 = (-(-t5 - t7 * t8 + 0.3e1 * UU * t8 * t3 + t15 + 0.3e1 * t6 * t2 * Rm + 0.4e1 * t19 * t20 - t26 + t27 * t8 - 0.2e1 * t29 * t8 - t33) * t35 * _C1 - (-t7 * t2 + t27 * t2 + t42 + t43 - t45 + 0.3e1 * UU * t2 * t3 - 0.2e1 * t29 * t2 + t52 - 0.4e1 * t19 * t53 - 0.3e1 * t6 * t8 * Rm) * t35 * _C2) * t65 * t68 + (-(t15 - 0.4e1 * t70 * t20 - t33 - 0.3e1 * VV * t8 * t3 - t76 * t8 + t79 * t8 + 0.3e1 * t78 * t2 * Rm - 0.2e1 * t84 * t8 - t26 - t5) * t35 * _C3 - (t52 - 0.3e1 * VV * t2 * t3 + t79 * t2 + 0.4e1 * t70 * t53 - 0.3e1 * t78 * t8 * Rm - 0.2e1 * t84 * t2 + t43 - t76 * t2 + t42 - t45) * t35 * _C4) * t65 * t108 - t65 * (-0.4e1 * B * BB * t112 * t113 + t23 * BB * t119 + 0.4e1 * t121 * AA * t123 - 0.4e1 * t121 * BB * t119 + BB * t118 * t130 - AA * t112 * t130 - 0.4e1 * B * AA * t118 * t113 - t23 * AA * t123 - 0.4e1 * t24 * AA * t118 - 0.4e1 * t24 * BB * t112) * t35 * t148; + + + + t2 = 0.2e1 * z * B; + t3 = exp(t2); + t4 = t3 * kn; + t5 = Rm * z; + t6 = cos(t5); + t8 = sin(t5); + t18 = exp(UU * z); + t31 = exp(-VV * z); + t34 = km * z; + t35 = cos(t34); + t37 = sin(t34); + t47 = exp(-t2); + u6 = -0.2e1 * t4 * (UU * (_C1 * t6 + _C2 * t8) - _C1 * t8 * Rm + _C2 * t6 * Rm) * t18 - 0.2e1 * t4 * (-VV * (_C3 * t6 + _C4 * t8) - _C3 * t8 * Rm + _C4 * t6 * Rm) * t31 - 0.2e1 * t4 * (-0.2e1 * B * (AA * t35 + BB * t37) - AA * t37 * km + BB * t35 * km) * t47; + + + + + /******************************************************************/ + /******************************************************************/ + + + + sum5 += u5*cos(n*M_PI*x); /* pressure */ + u6 -= u5; /* get total stress */ + sum6 += u6*cos(n*M_PI*x); /* xx stress */ + + u1 *= cos(n*M_PI*x); /* z velocity */ + sum1 += u1; + u2 *= sin(n*M_PI*x); /* x velocity */ + sum2 += u2; + u3 -= u5; /* get total stress */ + u3 *= cos(n*M_PI*x); /* zz stress */ + sum3 += u3; + u4 *= sin(n*M_PI*x); /* zx stress */ + sum4 += u4; + + rho = -sigma*sin(km*z)*cos(kn*x); /* density */ + sum7 += rho; + + SS = exp(UU*z)*(_C1*cos(Rm*z)+_C2*sin(Rm*z)) +exp(-VV*z)*(_C3*cos(Rm*z)+_C4*sin(Rm*z)) + exp(-2*z*B)*(AA*cos(km*z)+BB*sin(km*z)); + SS *= sin(kn*x); /* stream function */ + + //mag=sqrt(u1*u1+u2*u2); + /*printf("%0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f %0.7f\n",x,z,sum1,sum2,sum3,sum4,sum5,sum6,mag,sum7,SS);*/ + + + /* Output */ + if( vel != NULL ) { + vel[0] = sum2; + vel[1] = sum1; + } + if( presssure != NULL ) { + (*presssure) = sum5; + } + if( total_stress != NULL ) { + total_stress[0] = sum6; + total_stress[1] = sum3; + total_stress[2] = sum4; + } + if( strain_rate != NULL ) { + /* sigma = tau - p, tau = sigma + p, tau[] = 2*eta*strain_rate[] */ + Z = exp( 2.0 * B * z ); + strain_rate[0] = (sum6+sum5)/(2.0*Z); + strain_rate[1] = (sum3+sum5)/(2.0*Z); + strain_rate[2] = (sum4)/(2.0*Z); + } + /* Value checks, could be cleaned up if needed. Julian Giordani 9-Oct-2006*/ +// if( fabs( sum5 - ( -0.5*(sum6+sum3) ) ) > 1e-5 ) { +// assert(0); +// } +} + + diff --git a/src/underworld3/analytic/_transcribe.py b/src/underworld3/analytic/_transcribe.py new file mode 100644 index 000000000..3b267e14c --- /dev/null +++ b/src/underworld3/analytic/_transcribe.py @@ -0,0 +1,427 @@ +r"""Turn a machine-generated C kernel into SymPy, preserving its grouping. + +The classical analytic solutions were published as Maple output: long runs of +straight-line single assignments, ``t125 = 0.4e1 * t81 * t83 + ...``, with a +branch or two. This module reads that C and rebuilds the same expression tree in +SymPy. + +**The grouping is the point.** These expansions contain products like +:math:`\sinh(k)e^{-k}` that are numerically stable only in the arrangement the +generator produced; a re-derivation is a different arrangement and can lose eight +digits at large wavenumber or large viscosity contrast — exactly the regime the +benchmarks exist to probe. So nothing here simplifies, expands, collects or +reorders: each statement is substituted into the next verbatim, and the resulting +tree evaluates term for term as the C does. + +Transcribing at run time rather than generating a checked-in module is deliberate. +It costs a fraction of a second, and it means the SymPy form cannot drift from the +C it came from — they are the same artefact, not two copies of one. + +Numeric literals become exact :class:`sympy.Rational`\s. Maple writes them as +``0.4e1``, i.e. exact small values, so this loses nothing and lets a solution be +evaluated at arbitrary precision — which is how a transcription error is told +apart from the C's own double-precision cancellation. + +See ``docs/developer/subsystems/analytic-solutions.md`` for the validation every +transcription must pass before it is used. +""" + +import re + +import sympy + +# The only functions these kernels call, in both the plain-C and PETSc spellings +# — the same solutions are published in both forms. +_C_FUNCTIONS = { + "exp": sympy.exp, + "sin": sympy.sin, + "cos": sympy.cos, + "sqrt": sympy.sqrt, + "sinh": sympy.sinh, + "cosh": sympy.cosh, + "tanh": sympy.tanh, + "pow": lambda base, exponent: base**exponent, + "M_PI": sympy.pi, + "PetscExpReal": sympy.exp, + "PetscSinReal": sympy.sin, + "PetscCosReal": sympy.cos, + "PetscSqrtReal": sympy.sqrt, + "PetscPowReal": lambda base, exponent: base**exponent, + "PETSC_PI": sympy.pi, +} + +# C casts, which are juxtaposition in Python and so a syntax error. +_CAST = re.compile( + r"\(\s*(?:PetscReal|PetscScalar|PetscInt|double|float|int|unsigned)\s*\)\s*" +) + +# C identifiers that Python reserves. `in` is the one that actually occurs — +# these kernels take their coordinates as `const double* in`. Renamed in the +# source text and in the caller's environment together, so a caller still writes +# the name the C uses. +_RESERVED = {"in": "_c_in", "lambda": "_c_lambda", "is": "_c_is", "not": "_c_not"} +_RESERVED_PATTERN = re.compile(r"\b(" + "|".join(_RESERVED) + r")\b") + + +def _rewrite_ternary(text): + """Rewrite C conditional expressions as Python ones. + + ``c ? a : b`` becomes ``(a) if (c) else (b)``, and the C logical operators + become their Python spellings. ``!=`` is left alone — the only bare ``!`` + these kernels use is part of it. + + Parenthesised groups are rewritten first, so a conditional nested inside one + is resolved before the enclosing scan runs; the outer scan then only has to + find a ``?`` at depth zero. In practice the conditions are on loop indices, + bound to integers before evaluation, so the whole expression collapses to a + single branch. + """ + + text = text.replace("&&", " and ").replace("||", " or ") + + # Inner groups first. + pieces, index = [], 0 + while index < len(text): + if text[index] == "(": + close = _matching_paren(text, index) + pieces.append("(" + _rewrite_ternary(text[index + 1 : close]) + ")") + index = close + 1 + else: + pieces.append(text[index]) + index += 1 + text = "".join(pieces) + + depth = 0 + for index, character in enumerate(text): + if character == "(": + depth += 1 + elif character == ")": + depth -= 1 + elif character == "?" and depth == 0: + inner = 0 + for offset, following in enumerate(text[index + 1 :]): + if following == "(": + inner += 1 + elif following == ")": + inner -= 1 + elif following == ":" and inner == 0: + colon = index + 1 + offset + return ( + f"(({text[index + 1 : colon]})" + f" if ({text[:index]})" + f" else ({text[colon + 1 :]}))" + ) + break + + return text + + +def _matching_paren(text, opening): + """Index of the ``)`` closing the ``(`` at *opening*.""" + + depth = 0 + for index in range(opening, len(text)): + if text[index] == "(": + depth += 1 + elif text[index] == ")": + depth -= 1 + if depth == 0: + return index + return len(text) - 1 + + +def _rename_reserved(text): + return _RESERVED_PATTERN.sub(lambda m: _RESERVED[m.group(1)], text) + + +_CHAIN = re.compile(r"\b(\w+)\s*=\s*(?=\w+\s*=(?!=))") + + +def _expand_chains(block): + """Give each target of a chained assignment its own statement. + + ``a = b = c;`` assigns c to both. Read as one statement its *value* is + ``b = c``, which is not an expression and raises. Rewriting it as + ``b = c; a = b;`` keeps the order the C has — the rightmost target is bound + first, and the others follow from it. + """ + + out = [] + for statement in block.split(";"): + targets = _CHAIN.findall(statement) + if not targets: + out.append(statement) + continue + + remainder = _CHAIN.sub("", statement) + # Innermost first, so each target is defined before the next uses it. + pieces = [remainder] + previous = remainder.split("=")[0].strip() + for target in reversed(targets): + pieces.append(f" {target} = {previous}") + previous = target + out.append(";".join(pieces)) + + return ";".join(out) + + +def _split_declarators(block): + """Give each declarator in a C declaration its own statement. + + ``double x=in[0], y=in[1], z=in[2];`` is one statement to the reader below, + whose value would run past the first comma and fail to parse. Splitting on + top-level commas that introduce a new ``name =`` turns it into three. + + Only commas outside brackets count, so function arguments and array + subscripts are left alone. + """ + + out = [] + depth = 0 + for index, character in enumerate(block): + if character in "([": + depth += 1 + elif character in ")]": + depth -= 1 + elif character == "," and depth == 0: + if re.match(r"\s*\w+\s*=(?!=)", block[index + 1 :]): + out.append(";") + continue + out.append(character) + + return "".join(out) + +# The assignment target keeps whatever it is written through: a `struct.` prefix +# or an `[index]` suffix. Both occur — these kernels return their results through +# `out.xx = ...` or `out[0] = ...` depending on their vintage. +# +# The prefix is not cosmetic. Without it, `out.x = ...` reads as an assignment to +# `x` and silently overwrites the coordinate symbol; every later statement using x +# then gets the wrong thing, and the result looks plausible rather than broken. +# Without the suffix, `out[0] = ...` matches nothing at all — a loud failure +# rather than a quiet one, but a failure. +_STATEMENT = re.compile(r"((?:\w+\.)?\w+(?:\[\d+\])?)\s*=\s*([^;]+);") +_FLOAT_LITERAL = re.compile(r"\b\d+\.\d*(?:[eE][+-]?\d+)?") + + +def _strip_comments(source): + source = re.sub(r"/\*.*?\*/", " ", source, flags=re.S) + return re.sub(r"//[^\n]*", " ", source) + + +def _matching_brace(source, opening): + """Index just past the ``}`` closing the ``{`` at *opening*.""" + + depth, index = 0, opening + while True: + if source[index] == "{": + depth += 1 + elif source[index] == "}": + depth -= 1 + if depth == 0: + return index + 1 + index += 1 + + +def _as_python(expression): + """Prepare one C expression for evaluation as Python. + + Three rewrites. Float literals become exact ``Rational``\\s — ``0.4e1`` is + the generator's way of writing 4, and reading it as a float would make every + downstream comparison approximate for no reason. The statement is folded onto + one line, since C wraps freely but a wrapped Python expression with indented + continuations is a syntax error. And C casts are dropped: ``(PetscReal)n`` is + juxtaposition in Python, which does not parse. + """ + + expression = _rewrite_ternary( + _rename_reserved(_CAST.sub("", " ".join(expression.split()))) + ) + return _FLOAT_LITERAL.sub(lambda m: f"Rational('{m.group(0)}')", expression) + + +class CSource: + """A vendored C kernel, addressable by function and by block. + + Parameters + ---------- + path : str or pathlib.Path + The ``.c`` file to read. + """ + + def __init__(self, path): + self.path = str(path) + self.text = _strip_comments(open(self.path).read()) + + def function(self, name, returns="void"): + """The body of `` (...)``, braces excluded.""" + + signature = self.text.index(f"{returns} {name}(") + opening = self.text.index("{", self.text.index(")", signature)) + return self.text[opening + 1 : _matching_brace(self.text, opening) - 1] + + @staticmethod + def returned(body): + """The expression a body returns, as text.""" + + marker = body.index("return") + return body[marker + len("return") : body.index(";", marker)] + + @staticmethod + def loop_body(body, header): + """The body of a ``for`` loop, braces excluded. + + The series solutions accumulate over modes. The reader does not + interpret the loop — the caller evaluates this body once per mode with + the index bound, and sums — because the accumulation uses ``+=``, which + is not an assignment this reader recognises, and because the summation + has to happen in SymPy anyway. + """ + + marker = body.index(header) + opening = body.index("{", marker) + return body[opening + 1 : _matching_brace(body, opening) - 1] + + @staticmethod + def branches(body, condition, tail_ends_at=None): + """Split ``if () { A } else { B }`` into ``(A, B, tail)``. + + The tail is what follows the ``else`` block — in these kernels, the shared + arithmetic turning the branch's integration constants into the output + fields. + + Parameters + ---------- + tail_ends_at : str, optional + Cut the tail at the first occurrence of this text. Needed because the + kernels end with an output section that accumulates into ``sum`` variables + with ``+=`` and writes through pointers — statements this reader is not + meant to interpret, and whose operands it has never bound. + """ + + marker = body.index(condition) + opening = body.index("{", marker) + then_end = _matching_brace(body, opening) + then_block = body[opening + 1 : then_end - 1] + + else_opening = body.index("{", body.index("else", then_end - 1)) + else_end = _matching_brace(body, else_opening) + else_block = body[else_opening + 1 : else_end - 1] + + tail = body[else_end:] + if tail_ends_at is not None: + tail = tail[: tail.index(tail_ends_at)] + + return then_block, else_block, tail + + +def resolve_branches(block, bindings): + """Replace ``if``/``else`` whose condition is already decided by its branch. + + The series kernels guard their zero modes with tests on the loop indices — + ``if (n != 0 && m != 0)`` and so on. Those indices are bound to integers + before anything is evaluated, so the condition has an answer at transcription + time and the construct collapses to whichever branch the C would take. + + That matters because :func:`evaluate_block` does not interpret ``if``: it + reads every assignment in order, so an unresolved guard leaves each guarded + variable holding the *last* branch's value. In SolH that silently zeroes two + velocity components, which looks like a plausible solution rather than a + broken one. + + Nested guards resolve outermost first, repeatedly, until none remain. + + Parameters + ---------- + block : str + bindings : dict + Names the condition may use, mapped to Python values — normally the loop + indices as integers. + """ + + namespace = {**_C_FUNCTIONS, "Rational": sympy.Rational} + + while True: + marker = re.search(r"\bif\s*\(", block) + if marker is None: + return block + + condition_start = block.index("(", marker.start()) + condition_end = _matching_paren(block, condition_start) + condition = block[condition_start + 1 : condition_end] + + opening = block.index("{", condition_end) + then_end = _matching_brace(block, opening) + taken = block[opening + 1 : then_end - 1] + after = block[then_end:] + + otherwise = "" + stripped = after.lstrip() + if stripped.startswith("else"): + else_opening = block.index("{", then_end) + else_end = _matching_brace(block, else_opening) + otherwise = block[else_opening + 1 : else_end - 1] + after = block[else_end:] + + chosen = taken if eval( + _as_python(condition), {"__builtins__": {}}, {**namespace, **bindings} + ) else otherwise + + block = block[: marker.start()] + chosen + after + + +def evaluate_expression(text, environment): + """Evaluate a single C expression against names already in scope. + + For kernels that end in ``return ;`` rather than assigning the + result to a variable. + """ + + namespace = {**_C_FUNCTIONS, "Rational": sympy.Rational} + scope = {_RESERVED.get(name, name): value for name, value in environment.items()} + return sympy.sympify( + eval(_as_python(text), {"__builtins__": {}}, {**namespace, **scope}) + ) + + +def evaluate_block(block, environment): + """Substitute a straight-line block of C assignments into SymPy. + + Parameters + ---------- + block : str + C statements of the form ``name = expression;``. Anything else — a + declaration, an ``if``, an array write — is ignored, so a block may be + handed a whole function body and only its assignments are read. + environment : dict + Symbol names already in scope, mapped to SymPy expressions. Not modified. + + Returns + ------- + dict + *environment* extended with every name the block assigns. A name assigned + more than once holds its final value, matching C. + + Notes + ----- + The generated statements are already valid Python once ``pow``, ``exp``, + ``sin`` and ``cos`` are in scope, so they are evaluated directly against a + namespace holding no builtins. The input is a kernel vendored in this + package, not anything a caller supplies. + + Temporaries are substituted as they are read, so the returned expressions are + full trees rather than a chain of definitions. Measured on ``solCx.c``, the + largest is a few thousand operations — the sharing is recovered by common + subexpression elimination when the expression is compiled or lambdified. + """ + + scope = {_RESERVED.get(name, name): value for name, value in environment.items()} + namespace = {**_C_FUNCTIONS, "Rational": sympy.Rational} + + for target, expression in _STATEMENT.findall(_expand_chains(_split_declarators(block))): + value = eval( + _as_python(expression), {"__builtins__": {}}, {**namespace, **scope} + ) + scope[target] = sympy.sympify(value) + + return scope diff --git a/src/underworld3/analytic/_validation.py b/src/underworld3/analytic/_validation.py new file mode 100644 index 000000000..86ff28b4e --- /dev/null +++ b/src/underworld3/analytic/_validation.py @@ -0,0 +1,449 @@ +r"""Checks a transcribed analytic solution must pass before it is trusted. + +The published kernels are careful; the risk is in our conversion of them. These +are the standing checks that catch a conversion error, and they are deliberately +of two kinds: + +**Against the source.** :func:`reference_agreement` compares the transcription +with the kernel it came from, sampled where the solution is hard rather than +uniformly. + +**Against the physics.** :func:`incompressibility_residual` and +:func:`momentum_residual` put the fields back into the equations they claim to +solve. These need no oracle at all, which is what makes them the strongest +checks here: they catch an error that a comparison would miss because the +comparison and the error share an assumption, and they catch the failure a +convergence test structurally cannot — if a transcription and the solver share a +mistaken sign, the solve converges beautifully to the wrong answer. + +:func:`strainrate_consistency` sits between the two. The kernels return velocity +and stress as separately derived quantities, so differentiating one and +comparing against the other is a genuine cross-check rather than a tautology. + +A check is only worth as much as its ability to fail. Every solution's test +should include a negative control — perturb one coefficient of the transcription +and confirm these report it — because a check that passes a deliberately broken +input is measuring nothing. + +See ``docs/developer/subsystems/analytic-solutions.md``. +""" + +import itertools + +import numpy as np +import sympy + +import underworld3 as uw + + +def adversarial_points(x_c=None, count=40, seed=20260802, dim=2): + """Sample points that stress a solution rather than flatter it. + + Stratified over the unit box or cube, then loaded with the places these + solutions are hard: either side of a material interface, the boundary faces, + and the corners. + + Parameters + ---------- + x_c : float, optional + Position of a material interface normal to x, to sample across. + count : int + Number of interior points. + seed : int + Fixed, so a failure is reproducible. + dim : int + 2 or 3. A 3D solution needs 3D points; sampling it on a plane would + leave any error in the third direction unseen. + + Returns + ------- + numpy.ndarray + Shape ``(N, dim)``. + """ + + rng = np.random.default_rng(seed) + points = list(map(tuple, rng.uniform(0.0, 1.0, size=(count, dim)))) + + interior = (0.37,) * (dim - 1) + if x_c is not None: + points += [ + (x_c - 1.0e-9,) + interior, + (x_c + 1.0e-9,) + interior, + (x_c,) + interior, + ] + + # One point on each face, and every corner. + for axis in range(dim): + for value in (0.0, 1.0): + face = [0.31] * dim + face[axis] = value + points.append(tuple(face)) + + points += list(itertools.product((0.0, 1.0), repeat=dim)) + + return np.array(points) + + +def sample(solution, expression, points): + """Evaluate a solution's expression at *points*, without the JIT. + + These expressions are pure SymPy in the mesh coordinates, so they can be + lambdified straight to NumPy. Routing them through + :func:`underworld3.function.evaluate` instead would compile C for each one, + and for a viscosity-jump solution that means a large ``Piecewise`` inside a + stress derivative — a known combination that takes minutes to generate and + build, turning a validation run into something nobody will wait for. + + ``cse=True`` recovers the sharing that back-substitution flattened, so the + generated function is about the size of the original kernel. + """ + + coordinates = tuple(solution.mesh.X) + + # Mesh coordinates are not plain Symbols, and lambdify cannot bind them as + # arguments, so swap in ordinary symbols first. + plain = sympy.symbols(f"_c0:{len(coordinates)}") + expression = sympy.sympify(expression).subs(dict(zip(coordinates, plain))) + + points = np.asarray(points, dtype=float) + values = np.asarray( + # SciPy first: NumPy has no erfc, and lambdify then silently falls back + # to the scalar `math.erfc`, which fails only once an array reaches it. + sympy.lambdify(plain, expression, ["scipy", "numpy"], cse=True)( + *(points[:, i] for i in range(len(coordinates))) + ) + ) + values = np.broadcast_to(values, (len(points),)) + + if np.iscomplexobj(values): + # A solution built from complex potentials is real-valued, but SymPy + # cannot prove it, so the generated code returns complex. Take the real + # part — after checking the imaginary one is round-off, because a + # genuinely complex result would mean the construction is wrong and + # discarding it silently would hide exactly that. + # + # The relative test alone is not enough. These same functions are used on + # residuals, which are meant to vanish: there the real part is round-off + # too, and comparing one round-off with another reports a large + # "imaginary fraction" for a perfectly good result. So an absolute floor + # comes first — an imaginary part at 1e-12 is noise whatever it is being + # compared with. + largest_imaginary = float(np.max(np.abs(values.imag))) + largest_real = float(np.max(np.abs(values.real))) + + if largest_imaginary > 1.0e-12 and largest_imaginary > 1.0e-8 * largest_real: + raise ValueError( + f"expression evaluated complex: imaginary part reaches " + f"{largest_imaginary:.3e} against a real part of " + f"{largest_real:.3e}. It should be real-valued." + ) + + values = values.real + + return np.asarray(values, dtype=float) + + +def _worst_normalised(mine, theirs): + """Largest difference, scaled by the field's own magnitude. + + Not a pointwise relative error: these fields pass through zero, so dividing + by the true value reports a huge error wherever the solution is small and + makes a correct transcription look catastrophic. + """ + + scale = max(float(np.max(np.abs(theirs))), 1.0e-300) + return float(np.max(np.abs(mine - theirs)) / scale) + + +def reference_agreement(solution, fields, points): + """Compare a transcription against the kernel it was transcribed from. + + Parameters + ---------- + solution : AnalyticSolution + The transcription under test. + fields : dict + Name -> ``(sympy expression, callable)``. The callable takes the sample + coordinates and returns the reference value there. + points : numpy.ndarray + Sample coordinates, shape ``(N, 2)``. + + Returns + ------- + dict + Field name -> worst normalised error over the sample. + """ + + worst = {} + for name, (expression, kernel) in fields.items(): + mine = sample(solution, expression, points) + theirs = np.array([float(kernel(x, z)) for x, z in points]) + worst[name] = _worst_normalised(mine, theirs) + + return worst + + +def incompressibility_residual(solution, points): + r""":math:`\max|\nabla\cdot\mathbf u|`, from the transcribed velocity alone. + + Needs no reference. A transcription that has dropped or corrupted a term in + the velocity will generally stop being divergence-free. + """ + + coordinates = solution.mesh.X + velocity = solution.fn_velocity + + # Summed numerically rather than symbolically — see the note in + # `momentum_residual`. Same argument, same round-off. + values = sum( + sample(solution, sympy.diff(velocity[0, i], coordinates[i]), points) + for i in range(solution.mesh.dim) + ) + + return float(np.max(np.abs(values))) + + +def momentum_residual(solution, points): + r"""Largest :math:`|\nabla\cdot\sigma + \mathbf f|`, relative to its terms. + + Uses the solution's own total (Cauchy) stress and body force, so it needs no + reference and does not consult the solver. This is the check that catches a + shared mistaken convention: the original SolCx port had the body-force sign + the opposite way round from Underworld2's documentation, and a residual like + this is what settles which is right. + """ + + coordinates = solution.mesh.X + stress = solution.fn_stress + bodyforce = solution.fn_bodyforce + dim = solution.mesh.dim + + scale = 0.0 + worst = 0.0 + for i in range(dim): + terms = [sympy.diff(stress[i, j], coordinates[j]) for j in range(dim)] + terms.append(bodyforce[0, i]) + + # Sample each term and add the ARRAYS, rather than sampling the symbolic + # sum. The two agree to round-off, and the terms have to be sampled + # individually anyway for the scale below — so the symbolic sum was pure + # extra cost, and a large one: `sample` runs common-subexpression + # elimination, which on the sum of several nearly-cancelling series + # expressions is far more expensive than on any one of them. For SolKz it + # was 14.9s of the gate's 27.5s. + # + # Round-off is the right thing to measure here in any case. The residual + # is a cancellation of O(1) terms down to ~1e-16 relative, and the gate + # sits at 1e-8 — eight orders of margin — while flipping the body force + # sign moves it to order unity. Summing symbolically can cancel to an + # exact zero where summing numerically leaves 1e-16; both are the same + # statement about the mathematics, and the second is the honest one. + sampled = [sample(solution, term, points) for term in terms] + worst = max(worst, float(np.max(np.abs(sum(sampled))))) + + # Scale by the largest term being cancelled, not by the body force. A + # solution driven entirely by its boundary has no body force at all — the + # elliptical inclusion is one — and normalising by it divides by zero. + # The size of the terms is also the right yardstick for a cancellation: + # it says how many digits actually had to cancel. + for values in sampled: + scale = max(scale, float(np.max(np.abs(values)))) + + return worst / max(scale, 1.0e-300) + + +def transport_residual(solution, points): + r"""Largest :math:`|\nabla\cdot(k\nabla u) + f|`, relative to its terms. + + The scalar counterpart of :func:`momentum_residual`, and the same argument + for it: the coefficient, the source and the field all come from the solution + itself, so this consults nothing external and cannot be fooled by a mistake + the solution shares with whatever it might be compared against. + """ + + coordinates = solution.mesh.X + dim = solution.mesh.dim + + flux = [ + solution.fn_coefficient * sympy.diff(solution.fn_solution, coordinates[i]) + for i in range(dim) + ] + terms = [sympy.diff(flux[i], coordinates[i]) for i in range(dim)] + terms.append(solution.fn_source) + + # Summed numerically — see the note in `momentum_residual`. + sampled = [sample(solution, term, points) for term in terms] + worst = float(np.max(np.abs(sum(sampled)))) + scale = max(float(np.max(np.abs(values))) for values in sampled) + + return worst / max(scale, 1.0e-300) + + +def diffusion_residual(solution, points, time): + r"""Transient scalar residual at *time*. + + .. math:: + \partial_t u + \mathbf v\cdot\nabla u - \nabla\cdot(k\nabla u) + + The advection term is included because it is the general case: a purely + diffusive solution declares no advecting velocity and it drops out. Leaving + it off instead reports an order-one residual for a perfectly good + advection-diffusion solution, which reads like a broken solution rather than + a check applied to the wrong equation. + + Needs no reference — every term comes from the solution itself. + """ + + coordinates = solution.mesh.X + dim = solution.mesh.dim + + rate = sympy.diff(solution.fn_solution, solution.t) + carried = sum( + solution.fn_advection[0, i] * sympy.diff(solution.fn_solution, coordinates[i]) + for i in range(dim) + ) + spread = sum( + sympy.diff( + solution.fn_coefficient * sympy.diff(solution.fn_solution, coordinates[i]), + coordinates[i], + ) + for i in range(dim) + ) + + # Combined numerically — see the note in `momentum_residual`. + at_time = {solution.t: time} + values = { + name: sample(solution, term.subs(at_time), points) + for name, term in (("rate", rate), ("carried", carried), ("spread", spread)) + if term != 0 + } + + worst = float( + np.max( + np.abs( + values.get("rate", 0.0) + + values.get("carried", 0.0) + - values.get("spread", 0.0) + ) + ) + ) + scale = max(float(np.max(np.abs(v))) for v in values.values()) + + return worst / max(scale, 1.0e-300) + + +def strainrate_consistency(solution, points): + r"""Compare :math:`\tfrac12(\nabla\mathbf u + \nabla\mathbf u^{T})` with + the solution's own strain rate, scaled by its magnitude. + + Not a tautology: the kernels derive velocity and stress independently, and + the strain rate here comes from the stress and pressure. So this checks two + separately derived quantities against each other, and it exercises the + *derivatives* — which is what a solver consumes, and where a transcription + can be wrong while still matching pointwise. + """ + + coordinates = solution.mesh.X + velocity = solution.fn_velocity + dim = solution.mesh.dim + + worst = 0.0 + scale = 0.0 + for i in range(dim): + for j in range(dim): + from_velocity = ( + sympy.diff(velocity[0, i], coordinates[j]) + + sympy.diff(velocity[0, j], coordinates[i]) + ) / 2 + + # Differenced numerically, not symbolically — see `momentum_residual`. + # The reference has to be sampled anyway for the scale, so forming + # the symbolic difference only added a second, larger expression to + # run CSE over. + mine = sample(solution, from_velocity, points) + reference = sample(solution, solution.fn_strainrate[i, j], points) + + worst = max(worst, float(np.max(np.abs(mine - reference)))) + scale = max(scale, float(np.max(np.abs(reference)))) + + return worst / max(scale, 1.0e-300) + + +def high_precision_value(expression, substitutions, digits=50): + """Evaluate an expression at high precision, to separate two kinds of error. + + When a transcription and its source differ near the agreement threshold, the + difference is either ours or the kernel's own double-precision cancellation. + Evaluating the transcription at 50 digits tells them apart — which is what + makes "the generator's grouping is numerically stable" a measured claim + rather than an assumption. + + Parameters + ---------- + expression : sympy expression + substitutions : dict + Values to substitute. Pass exact numbers (``sympy.Rational``, + ``sympy.Integer``); floats defeat the purpose. + digits : int + """ + + return sympy.N(expression.subs(substitutions), digits) + + +def richards_residual(solution, points, time=None): + r"""Residual of the Richards equation for an unsaturated-flow solution. + + .. math:: + C(\psi)\,\partial_t \psi - \nabla\cdot\!\left[K(\psi)(\nabla\psi + \hat y)\right] + + The :math:`\hat y` is gravity, and leaving it out is the same class of + mistake as leaving advection out of :func:`diffusion_residual` — it turns a + correct solution into an order-one failure. + + *time* is ``None`` for a steady solution, which drops the capacity term. + Needs no oracle: the conductivity and capacity are the solution's own. + """ + + coordinates = solution.mesh.X + dim = solution.mesh.dim + vertical = dim - 1 + + gravity = [0] * dim + gravity[vertical] = 1 + + # The conducted and gravitational parts of the flux divergence are kept + # apart because they are what has to cancel, and so they are the honest + # scale. Normalising by the divergence itself divides the residual by the + # residual, which reports 1.0 for a flawless solution — that is not a + # tolerance failure, it is a meaningless number. + terms = [] + for i in range(dim): + conducted = solution.fn_conductivity * sympy.diff( + solution.fn_solution, coordinates[i] + ) + terms.append(sympy.diff(conducted, coordinates[i])) + if gravity[i]: + terms.append( + sympy.diff(solution.fn_conductivity * gravity[i], coordinates[i]) + ) + + divergence = sum(terms) + + if time is None: + residual = -divergence + at_time = {} + else: + storage = solution.fn_capacity * sympy.diff(solution.fn_solution, solution.t) + terms.append(storage) + residual = storage - divergence + at_time = {solution.t: time} + + terms = [term for term in terms if term != 0] + + worst = float(np.max(np.abs(sample(solution, residual.subs(at_time), points)))) + scale = max( + float(np.max(np.abs(sample(solution, term.subs(at_time), points)))) + for term in terms + ) + + return worst / max(scale, 1.0e-300) diff --git a/src/underworld3/analytic/inclusion.py b/src/underworld3/analytic/inclusion.py new file mode 100644 index 000000000..b21500a16 --- /dev/null +++ b/src/underworld3/analytic/inclusion.py @@ -0,0 +1,446 @@ +r"""A deformable elliptical inclusion in general shear. + +Schmid, D. W. & Podladchikov, Y. Y. (2003), "Analytical solutions for deformable +elliptical inclusions in general shear", *Geophysical Journal International* +**155**(1), 269–288, doi:10.1046/j.1365-246X.2003.02042.x. + +An ellipse of one viscosity sitting in a matrix of another, with a far-field pure +and/or simple shear. It is the natural test for anything that has to represent a +strong material contrast on a curved interface — a weak inclusion, a clast, a +fault-adjacent lens — because: + +* the velocity and pressure are closed form both **inside and outside**, so a + computed field can be compared point by point rather than only through a + summary like "is the interior strain uniform"; +* it is derived for incompressible viscous flow via Muskhelishvili complex + potentials, so nothing has to be translated from an elastic result; +* there is no restriction on the viscosity ratio. + +There is no body force. The flow is driven entirely by the far field, which makes +this a test of how a solver handles the contrast rather than of how it handles +forcing. + +Provenance +---------- +Transcribed from the authors' reference MATLAB, ``ell_dynamix.m`` and +``ell_rot_rate.m`` in ``github.com/dwschmid/muskhelishvili`` (BSD-3-Clause). + +Those scripts give pressure, deviatoric stress and the rotation rate, but **not +the velocity**, which has to be reconstructed from the potentials. They are +recoverable from what is published, and the reading is checked rather than +assumed — see :func:`_potentials`. + +""" + +import functools + +import sympy + +from ._base import AnalyticSolution, FixedWalls + + +def _conjugate(expression): + r"""Complex conjugate of an expression built from real symbols and ``I``. + + :func:`sympy.conjugate` will not distribute through ``sqrt`` of a symbolic + argument — it has a branch cut, so SymPy leaves ``conjugate(sqrt(...))`` + unevaluated, and anything built on it can no longer be differentiated into + printable code. + + Flipping the sign of ``I`` does the same job here and does it structurally: + for a function of real variables assembled from real symbols and ``I``, + :math:`\overline{f(z)} = f(\bar z)`, and the sign flip is exactly that. + Verified against :func:`numpy.conj` on both sides of the branch cut. + """ + + return expression.subs(sympy.I, -sympy.I) + + +@functools.lru_cache(maxsize=None) +def _psi_shape(): + r"""The :math:`\zeta`-dependence of :math:`\psi`, integrated once. + + :math:`\mathrm d\psi/\mathrm d\zeta = \psi'(z)\,\mathrm dz/\mathrm d\zeta` + splits into a far-field part, which integrates by inspection, and + + .. math:: + \int \frac{3\zeta^2 - 1}{\zeta^2 (\zeta^2-1)^2}\,\mathrm d\zeta + + which is a rational function with integer coefficients. Integrating it once + with a bare symbol keeps SymPy in an exact domain; substituting the numeric + constants first puts it in a floating complex polynomial ring, where the + division algorithm cannot detect zero and the integration fails outright. + """ + + zeta = sympy.Symbol("_zeta") + integrand = (3 * zeta**2 - 1) / (zeta**2 * (zeta**2 - 1) ** 2) + + return zeta, sympy.integrate(integrand, zeta) + + +def _shape_ratio(aspect_ratio): + r"""The conformal radius :math:`r_c` for an ellipse of the given aspect ratio. + + The map :math:`z = \zeta + 1/\zeta` takes the circle :math:`|\zeta| = r_c` to + an ellipse with semi-axes :math:`r_c \pm 1/r_c`, so the aspect ratio is + :math:`(r_c^2+1)/(r_c^2-1)`. Inverting that is the relation the reference + MATLAB writes as ``rc = sqrt((t-1)*(t+1))/(t-1)``. + """ + + t = sympy.sympify(aspect_ratio) + return sympy.sqrt((t - 1) * (t + 1)) / (t - 1) + + +def _potentials(zeta, viscosity_ratio, aspect_ratio, alpha, pure_shear, simple_shear): + r"""The Muskhelishvili potentials for the matrix, and the interior constants. + + The published scripts give the fields, not the potentials, so these are read + back out of them: + + * the matrix pressure is :math:`p = -2\,\mathrm{Re}\,\varphi'(z)`, which fixes + :math:`\varphi'(z) = A/(\zeta^2-1)`; + * the matrix stress is :math:`\bar z\,\varphi''(z) + \psi'(z)`, and its first + term must then reproduce :math:`\varphi''` derived from that same + :math:`\varphi'`. + + The second point is a real check rather than a restatement: it is an + independent expression in the source, and it agrees identically. Any error in + reading :math:`\varphi` off the pressure would show up there. + + Returns + ------- + dict + ``phi``, ``phi_prime``, ``psi_prime``, ``psi`` as functions of + :math:`\zeta`, plus the interior pressure and deviatoric stress. + """ + + mc = sympy.sympify(viscosity_ratio) + rc = _shape_ratio(aspect_ratio) + er = sympy.sympify(pure_shear) + gr = sympy.sympify(simple_shear) + + # Far field, as the reference writes it. + BC = (2 * er - sympy.I * gr) * sympy.exp(2 * sympy.I * sympy.sympify(alpha)) + ReBC, ImBC = sympy.re(BC), sympy.im(BC) + + B1 = rc**4 * mc + rc**4 - 1 + mc + B2 = rc**4 * mc + rc**4 - mc + 1 + B3 = rc**4 * mc - mc - rc**4 + 1 + B4 = -(rc**4) * mc - mc - rc**4 + 1 + B5 = rc**8 * mc - mc - rc**8 + 1 + + D = sympy.I * ImBC / B1 - ReBC / B2 + A = -(rc**2) * B3 * D + + # A purely imaginary constant in phi' is invisible to the published data: + # the pressure is -2 Re[phi'] and the stress involves phi'', so neither sees + # it. It is a far-field rigid rotation, and without it the reconstructed flow + # has the right strain but no spin — a simple shear comes out as pure shear. + # + # Its value is fixed by the reference's own rotation rate: taken to a circle + # the expression collapses to -gr/2 for every viscosity ratio, which is the + # statement that a circular inclusion turns with the far field. Vorticity is + # frame invariant, so alpha does not enter, and pure shear contributes none. + far_field_spin = -gr / 2 + + phi_prime = A / (zeta**2 - 1) + sympy.I * far_field_spin + phi = -A / zeta + sympy.I * far_field_spin * (zeta + 1 / zeta) + + psi_prime = -BC - B5 * D * (3 * zeta**2 - 1) / (zeta**2 - 1) ** 3 + + # psi needs integrating in zeta, since d/dz = (d/dzeta)/(dz/dzeta). The + # far-field part integrates by inspection; the rest is the cached shape. + symbol, shape = _psi_shape() + psi = -BC * (zeta + 1 / zeta) - B5 * D * shape.subs(symbol, zeta) + + # Inside the inclusion both are uniform — this is the Eshelby property, and + # it is what makes the interior a sharp test. + interior_pressure = sympy.re( + -sympy.I * mc * B4 / B1 * gr + + 2 * rc**2 * (mc - 1) * (sympy.I * mc * ImBC / B1 - ReBC / B2) + ) + interior_stress = -2 * mc * rc**4 * (sympy.I * ImBC / B1 + ReBC / B2) + + return { + "phi": phi, + "phi_prime": phi_prime, + "psi_prime": psi_prime, + "psi": psi, + "interior_pressure": interior_pressure, + "interior_stress": interior_stress, + "rc": rc, + } + + +class EllipticalInclusion(FixedWalls, AnalyticSolution): + r"""A viscous elliptical inclusion in a matrix under general shear. + + Parameters + ---------- + mesh : Mesh + A 2D mesh. The inclusion is placed at *centre*, so the domain does not + have to be the unit box. + viscosity_ratio : float + Inclusion viscosity over matrix viscosity. Any positive value; a weak + inclusion is a ratio below one. + aspect_ratio : float + Long axis over short axis, strictly greater than one. Use something like + 1.001 for a near-circular inclusion — exactly one is a degenerate + conformal map. + alpha : float + Angle of the far-field flow relative to the inclusion's long axis, which + lies along *x*. + pure_shear, simple_shear : float + Far-field rates. The reference combines them as + :math:`(2\dot\epsilon - i\dot\gamma)e^{2i\alpha}`. + centre : tuple of float + Position of the inclusion centre. + semi_major : float + Physical length of the long semi-axis. + matrix_viscosity : float + Viscosity of the matrix. Stresses scale with it; the velocity does not. + + Notes + ----- + The inclusion **rotates**, so this is an instantaneous solution: it describes + the flow at one moment, not a history. :attr:`rotation_rate` is the angular + velocity, and it is an independent scalar to check a solve against — it comes + from a separate published expression, not from these fields. + + The velocity is expressed through :func:`sympy.re` and :func:`sympy.im` of a + complex potential. That evaluates and differentiates correctly, which is what + the validation needs, but it has not been exercised through the JIT — so + treat using this as a Dirichlet boundary value in a solver as unverified. + """ + + dim = 2 + reference = ( + "Schmid & Podladchikov (2003), Geophys. J. Int. 155(1), 269-288, " + "doi:10.1046/j.1365-246X.2003.02042.x. Transcribed from the authors' " + "reference MATLAB (github.com/dwschmid/muskhelishvili, BSD-3-Clause)." + ) + eqn_viscosity = r"\mu_c \text{ inside},\quad \mu_m \text{ outside}" + eqn_bodyforce = r"\mathbf 0 \quad(\text{driven by the far field})" + + def __init__( + self, + mesh, + viscosity_ratio=1.0e3, + aspect_ratio=2.0, + alpha=0.0, + pure_shear=0.0, + simple_shear=1.0, + centre=(0.0, 0.0), + semi_major=1.0, + matrix_viscosity=1.0, + ): + super().__init__(mesh) + + if float(aspect_ratio) <= 1.0: + raise ValueError( + "aspect_ratio must exceed 1; the conformal map is degenerate at " + "a circle. Use 1.001 for a near-circular inclusion." + ) + if float(viscosity_ratio) <= 0.0 or float(matrix_viscosity) <= 0.0: + raise ValueError("viscosities must be positive.") + + self.viscosity_ratio = float(viscosity_ratio) + self.aspect_ratio = float(aspect_ratio) + self.alpha = float(alpha) + self.pure_shear = float(pure_shear) + self.simple_shear = float(simple_shear) + self.matrix_viscosity = float(matrix_viscosity) + + rc = _shape_ratio(self.aspect_ratio) + + # Build on real-declared symbols and substitute the mesh coordinates at + # the end. Mesh coordinates carry no reality assumption, so SymPy cannot + # push re/im through a conjugate or a square root of them: the split is + # left symbolic, and differentiating it produces a Derivative(re(...)) + # that no code printer can emit. + u, v = sympy.symbols("_u _v", real=True) + + # The reference solution lives on the unit-focal ellipse, whose long + # semi-axis is rc + 1/rc. Lengths scale, so map the physical point in and + # scale the velocity back out; stress and pressure are scale invariant. + self._scale = sympy.sympify(semi_major) / (rc + 1 / rc) + z = (u + sympy.I * v) / self._scale + + # Invert z = zeta + 1/zeta on the branch outside the unit circle, which + # is the one that maps the matrix to |zeta| > rc. + # + # Written as sqrt(z-2) sqrt(z+2) rather than sqrt(z^2-4): the two agree + # in modulus but not in branch. The single square root cuts along a ray, + # so for z left of the origin it selects the root inside the unit circle + # — the wrong sheet — and the far field comes out asymmetric. The split + # form cuts along the segment [-2, 2], which is the slit the map already + # has, and gives |zeta| > 1 everywhere outside it. + zeta = (z + sympy.sqrt(z - 2) * sympy.sqrt(z + 2)) / 2 + + potentials = _potentials( + zeta, + self.viscosity_ratio, + self.aspect_ratio, + self.alpha, + self.pure_shear, + self.simple_shear, + ) + + # 2 mu (v_x + i v_y) = kappa phi - z conj(phi') - conj(psi), and kappa = 1 + # for an incompressible medium (the elastic 3 - 4 nu at nu = 1/2). + outside = ( + potentials["phi"] + - z * _conjugate(potentials["phi_prime"]) + - _conjugate(potentials["psi"]) + ) / 2 + + # Real and imaginary parts without re()/im(), which cannot be pushed + # through the branch cut. These are real-valued but complex-typed: SymPy + # cannot prove the imaginary part vanishes, though it does to roundoff. + outside_x = self._scale * (outside + _conjugate(outside)) / 2 + outside_y = self._scale * (outside - _conjugate(outside)) / (2 * sympy.I) + + # Inside, the velocity gradient is uniform — the Eshelby property, and + # what makes this benchmark sharp. It is fixed by the interior deviatoric + # stress and the rotation rate, both already known. + # + # The reference writes stress as (sigma_yy - sigma_xx)/2 + i sigma_xy, so + # with a traceless deviator tau_yy = Re[T], tau_xx = -Re[T], tau_xy = + # Im[T]. Dividing by 2 mu_c gives the strain rate, and mu_m cancels: the + # interior strain rate depends on the viscosity *ratio* only. + T = potentials["interior_stress"] + rate = 2 * self.viscosity_ratio + exx, eyy = -sympy.re(T) / rate, sympy.re(T) / rate + exy = sympy.im(T) / rate + spin = self.rotation_rate + + inside_x = exx * u + (exy - spin) * v + inside_y = (exy + spin) * u + eyy * v + + x, y = mesh.X + inside = sympy.Abs(zeta) < rc + physical = {u: x - centre[0], v: y - centre[1]} + + self.fn_velocity = sympy.Matrix( + [ + [ + sympy.Piecewise((inside_x, inside), (outside_x, True)).subs(physical), + sympy.Piecewise((inside_y, inside), (outside_y, True)).subs(physical), + ] + ] + ) + # p = -2 Re[phi'], written without re() so it can be differentiated. + matrix_pressure = -( + potentials["phi_prime"] + _conjugate(potentials["phi_prime"]) + ) + # The potentials are normalised to UNIT matrix viscosity. Under a + # rescaling eta -> lambda*eta at fixed boundary velocity, Stokes flow + # leaves the velocity and the strain rate alone and scales the stress + # AND the pressure by lambda. Omitting the factor here left the viscous + # part of sigma scaled and the pressure part unscaled, so + # div(sigma) = 0 failed by 0.63 at matrix_viscosity = 3 while every + # other gate — tracelessness, strain-rate consistency — still passed, + # because those do not couple the two parts. + self.fn_pressure = self.matrix_viscosity * sympy.Piecewise( + (potentials["interior_pressure"], inside), + (matrix_pressure, True), + ).subs(physical) + self.fn_viscosity = sympy.Piecewise( + (self.matrix_viscosity * self.viscosity_ratio, inside), + (self.matrix_viscosity, True), + ).subs(physical) + self.fn_bodyforce = sympy.Matrix([[0, 0]]) + + # Strain rate and stress follow from the velocity and pressure above. + # They are consistent with those rather than independent of them, so they + # are not a check on the reconstruction — the checks are the Stokes + # residual, velocity continuity across the interface, and the far field. + strain = sympy.Matrix( + [ + [ + ( + sympy.diff(self.fn_velocity[0, i], mesh.X[j]) + + sympy.diff(self.fn_velocity[0, j], mesh.X[i]) + ) + / 2 + for j in range(2) + ] + for i in range(2) + ] + ) + self.fn_strainrate = strain + self.fn_stress = ( + 2 * self.fn_viscosity * strain - self.fn_pressure * sympy.eye(2) + ) + + self._potentials = potentials + self._centre = tuple(float(c) for c in centre) + + @property + def semi_axes(self): + """Long and short semi-axes of the inclusion, in physical units.""" + + rc = float(_shape_ratio(self.aspect_ratio)) + scale = float(self._scale) + return scale * (rc + 1 / rc), scale * (rc - 1 / rc) + + def sample_points(self, count=12): + """Points in the matrix, clear of the inclusion and of the map's foci. + + The default unit-box sampler is wrong for this solution twice over: the + inclusion is centred wherever the caller put it rather than filling the + box, and the conformal map has branch points at the foci, where the + fields are singular. A generic sampler lands on both. + + Points are laid on rings outside the inclusion, so they exercise the + matrix solution at a range of distances and azimuths without straddling + the interface — across which the stress is discontinuous, so a residual + evaluated there is meaningless rather than merely inaccurate. + """ + + import numpy as np + + a, b = self.semi_axes + rings = np.linspace(1.6, 4.0, max(count // 4, 2)) + angles = np.linspace(0.13, 2 * np.pi + 0.13, 4, endpoint=False) + + return np.array( + [ + ( + self._centre[0] + factor * a * np.cos(theta), + self._centre[1] + factor * b * np.sin(theta), + ) + for factor in rings + for theta in angles + ] + ) + + @property + def rotation_rate(self): + r"""Angular velocity of the inclusion. + + A scalar oracle independent of the fields above: it comes from the + reference's own closed form (``ell_rot_rate.m``), so comparing a computed + interior vorticity against it is a genuine check rather than a + restatement. + """ + + t = self.aspect_ratio + mc = self.viscosity_ratio + alpha = self.alpha + + return float( + ( + -0.5 + * (t**2 - mc * t**2 + mc - 1) + / (mc * t**2 + mc + 2 * t) + * sympy.cos(2 * alpha) + - 0.5 + ) + * self.simple_shear + - 0.5 + * (2 * mc * t**2 - 2 * t**2 - 2 * mc + 2) + / (mc * t**2 + mc + 2 * t) + * sympy.sin(2 * alpha) + * self.pure_shear + ) diff --git a/src/underworld3/analytic/kramer.py b/src/underworld3/analytic/kramer.py new file mode 100644 index 000000000..620fbe3c5 --- /dev/null +++ b/src/underworld3/analytic/kramer.py @@ -0,0 +1,267 @@ +r"""Curved-geometry Stokes solutions, wrapping the external `assess` library. + +Kramer et al. (2021) give exact Stokes solutions in a cylindrical annulus and a +spherical shell, for smooth and delta-function density anomalies under free-slip +or zero-slip walls. They are the reference for benchmarking a solver on curved +boundaries, which nothing else in this suite covers — every other solution here +is posed on a box. + +Unlike the rest of the suite these are **not SymPy**, and the difference is not +cosmetic. `assess` provides numeric callables, so a Kramer solution can be +compared against a solver but cannot be checked against the equations it claims +to solve: there is nothing to differentiate. It therefore declares +``symbolic = False``, exposes no ``fn_*``, and the conformance suite skips it on +that declaration rather than by name. + +Treat it as an external oracle rather than a validated member of the family. It +is here so the curved-geometry benchmarks stop importing an undeclared +dependency, and so that when the dependency is missing the failure is a sentence +rather than a traceback. + +Installation:: + + pip install "underworld3[benchmarks]" # or: pip install assess + +References +---------- +Kramer, S.C., Davies, D.R. & Wilson, C.R. (2021). Analytical solutions for +mantle flow in cylindrical and spherical shells. *Geoscientific Model +Development* 14, 1899-1919. doi:10.5194/gmd-14-1899-2021 +""" + +import numpy as np + +from ._base import AnalyticSolution + + +_INSTALL_MESSAGE = ( + "The Kramer benchmark solutions need the 'assess' package, which " + "Underworld3 does not depend on.\n" + " pip install \"underworld3[benchmarks]\"\n" + "or pip install assess\n" + "See Kramer, Davies & Wilson (2021), doi:10.5194/gmd-14-1899-2021." +) + + +def require_assess(): + """Import `assess`, or explain how to get it. + + Deferred to construction rather than to module import so that + ``import underworld3`` works without it and + :func:`underworld3.analytic.available` can still list the solution. + + Public because the curved-geometry benchmark scripts under + ``docs/examples/`` call it directly: they use `assess` for cases this + wrapper does not cover, and should still fail with an install message + rather than a bare ``ModuleNotFoundError``. + + Returns + ------- + module + The imported `assess` module. + + Raises + ------ + ImportError + With installation instructions, if `assess` is not present. + """ + + try: + import assess + except ImportError as missing: + raise ImportError(_INSTALL_MESSAGE) from missing + + return assess + + +def assess_available(): + """Whether the optional `assess` dependency can be imported.""" + + try: + require_assess() + except ImportError: + return False + + return True + + +class CylindricalStokes(AnalyticSolution): + r"""Stokes flow in a cylindrical annulus, after Kramer et al. (2021). + + Four cases, chosen by *density* and *boundary*: + + ========== =========== ==================================================== + ``density`` ``boundary`` anomaly + ========== =========== ==================================================== + ``"delta"`` ``"free"`` a delta-function density shell at radius ``r_int`` + ``"delta"`` ``"zero"`` the same, with zero-slip walls + ``"smooth"`` ``"free"`` :math:`r^k` radial dependence, wavenumber ``n`` + ``"smooth"`` ``"zero"`` the same, with zero-slip walls + ========== =========== ==================================================== + + The delta cases are solved as two solutions, above and below the anomaly, and + :meth:`evaluate` selects between them by radius — that discontinuity is the + point of the benchmark, and flattening it would remove what is being tested. + + Parameters + ---------- + mesh : Mesh + A 2D annulus mesh. + n : int + Azimuthal wavenumber. + k : int + Radial wavenumber. Used by the smooth cases only. + r_inner, r_outer : float + Shell radii. + r_int : float + Radius of the delta-function anomaly. Delta cases only. + density : {"delta", "smooth"} + boundary : {"free", "zero"} + + Notes + ----- + Built on the `assess` calls the curved-geometry benchmark scripts in + ``docs/examples/`` already make — ``CylindricalStokesSolution*(n, sign, + Rp=, Rm=, rp=, nu=, g=)`` with ``.velocity_cartesian`` and + ``.pressure_cartesian``. The wrapper's own tests cover the missing-dependency + path; the working path is only as good as those signatures. + """ + + dim = 2 + nonlinear = False + symbolic = False + solves = "stokes" + requires = "assess" + reference = ( + "Kramer, Davies & Wilson (2021), Geosci. Model Dev. 14, 1899-1919, " + "doi:10.5194/gmd-14-1899-2021. Numeric, via the external 'assess' package." + ) + boundaries = ("Upper", "Lower") + + def __init__( + self, + mesh, + n=2, + k=2, + r_inner=0.5, + r_outer=1.0, + r_int=0.8, + density="delta", + boundary="free", + ): + super().__init__(mesh) + + if density not in ("delta", "smooth"): + raise ValueError(f"density must be 'delta' or 'smooth'; got {density!r}") + if boundary not in ("free", "zero"): + raise ValueError(f"boundary must be 'free' or 'zero'; got {boundary!r}") + + assess = require_assess() + + self.n = int(n) + self.k = int(k) + self.r_inner = float(r_inner) + self.r_outer = float(r_outer) + self.r_int = float(r_int) + self.density = density + self.boundary = boundary + + slip = "FreeSlip" if boundary == "free" else "ZeroSlip" + family = getattr( + assess, f"CylindricalStokesSolution{density.capitalize()}{slip}" + ) + + if density == "delta": + shared = dict( + Rp=self.r_outer, Rm=self.r_inner, rp=self.r_int, nu=1.0, g=-1.0 + ) + self._above = family(self.n, +1, **shared) + self._below = family(self.n, -1, **shared) + else: + shared = dict(Rp=self.r_outer, Rm=self.r_inner, nu=1.0, g=1.0) + self._above = family(self.n, self.k, **shared) + self._below = self._above + + def _split(self, coords): + """Indices of the points above and below the anomaly radius.""" + + radius = np.linalg.norm(np.asarray(coords, dtype=float), axis=1) + above = radius >= self.r_int + return above, ~above + + def evaluate(self, field, coords): + """Exact *field* at *coords*, choosing the branch by radius. + + Parameters + ---------- + field : {"velocity", "pressure"} + coords : array, shape (n, 2) + Cartesian coordinates. + + Returns + ------- + ndarray + Shape ``(n, 2)`` for velocity, ``(n,)`` for pressure. + """ + + if field not in ("velocity", "pressure"): + raise ValueError( + f"{type(self).__name__} provides 'velocity' and 'pressure'; " + f"got {field!r}" + ) + + coords = np.asarray(coords, dtype=float) + above, below = self._split(coords) + + width = 2 if field == "velocity" else 1 + values = np.zeros((len(coords), width)) + + for mask, solution in ((above, self._above), (below, self._below)): + if not mask.any(): + continue + call = getattr(solution, f"{field}_cartesian") + values[mask] = np.array([call(point) for point in coords[mask]]).reshape( + -1, width + ) + + return values if width == 2 else values[:, 0] + + def error(self, field, meshvar, norm="l2"): + """Relative error of *meshvar* against the exact *field*. + + The mesh-variable coordinates are used directly, so this is the discrete + :math:`\\ell_2` norm over nodes rather than an integral — `assess` gives + no symbolic form to integrate. + """ + + if norm != "l2": + raise ValueError( + f"{type(self).__name__} has no symbolic form, so only the " + f"discrete 'l2' norm is available; got {norm!r}" + ) + + from underworld3 import mpi + + coords = meshvar.coords + exact = np.atleast_2d(self.evaluate(field, coords).T).T + computed = meshvar.data.reshape(exact.shape) + + local = np.array( + [((computed - exact) ** 2).sum(), (exact**2).sum()], dtype=float + ) + difference, magnitude = mpi.comm.allreduce(local) + + return float(np.sqrt(difference / magnitude)) + + def apply_boundary_conditions(self, solver): + """Free-slip or zero-slip on both walls, as the case declares.""" + + if self.boundary == "zero": + for wall in self.boundaries: + solver.add_dirichlet_bc((0.0, 0.0), wall) + return + + for wall in self.boundaries: + solver.add_rotated_freeslip_bc(0.0, wall) + + solver.petsc_use_pressure_nullspace = True diff --git a/src/underworld3/analytic/richards.py b/src/underworld3/analytic/richards.py new file mode 100644 index 000000000..2556e70f4 --- /dev/null +++ b/src/underworld3/analytic/richards.py @@ -0,0 +1,299 @@ +r"""Exact solutions for unsaturated flow — the Gardner Richards problems. + +Richards is the one nonlinear scalar equation in the suite: + +.. math:: + C(\psi)\,\frac{\partial\psi}{\partial t} + = \nabla\cdot\!\left[K(\psi)\left(\nabla\psi + \hat y\right)\right] + +with the conductivity depending on the unknown head :math:`\psi`. Gardner's +exponential model :math:`K = K_s e^{\alpha\psi}` is the case that admits closed +forms, because the substitution :math:`u = e^{\alpha\psi}` linearises it +*exactly* — not as an approximation. Under that substitution the equation becomes +linear advection–diffusion in :math:`u`, which is why the transient solution here +is an Ogata–Banks form and why it shares a residual check with +:class:`~underworld3.analytic.transport.AdvectedFront`. + +These two solutions already existed in +:mod:`underworld3.utilities.retention_curves` as NumPy functions. Those functions +remain, unchanged in signature, and now delegate here; what they gain is the +residual check and a place in the registry. + +See Also +-------- +underworld3.analytic.transport : the linear scalar solutions. +""" + +import sympy + +from ._base import AnalyticSolution + + +def gardner_steady_saturation(y, psi_0, psi_L, L, alpha): + r"""The linearised variable :math:`u = e^{\alpha\psi}` for steady flow. + + Given as :math:`u` rather than as the head because that is the form the + formula is actually in — :math:`\psi = \ln u/\alpha` is the last step, and + the NumPy wrapper in :mod:`underworld3.utilities.retention_curves` needs to + interpose a floor before taking the log. Building the head here and undoing + it there would be two spellings of one formula, which is how they drift. + """ + + alpha = sympy.sympify(alpha) + u_0 = sympy.exp(alpha * sympy.sympify(psi_0)) + u_L = sympy.exp(alpha * sympy.sympify(psi_L)) + decay = sympy.exp(-alpha * sympy.sympify(L)) + + # Normalised steady flux q/Ks, fixed by the two boundary heads. + flux = (u_L - u_0 * decay) / (1 - decay) + + return (u_0 - flux) * sympy.exp(-alpha * y) + flux, flux + + +def gardner_transient_saturation( + y, t, psi_dry, psi_wet, L, Ks, alpha, theta_r, theta_s +): + r"""The linearised variable :math:`u = e^{\alpha\psi}` for a wetting front. + + The Ogata–Banks solution of :math:`u_t = Du_{zz} - Vu_z` with :math:`z` the + depth below the surface. See :class:`GardnerTransient`. + """ + + alpha = sympy.sympify(alpha) + Ks = sympy.sympify(Ks) + spread = sympy.sympify(theta_s) - sympy.sympify(theta_r) + + D = Ks / (alpha * spread) + V = Ks / spread + depth = sympy.sympify(L) - y + + u_dry = sympy.exp(alpha * sympy.sympify(psi_dry)) + u_wet = sympy.exp(alpha * sympy.sympify(psi_wet)) + + scale = 2 * sympy.sqrt(D * t) + front = ( + sympy.erfc((depth - V * t) / scale) + + sympy.exp(V * depth / D) * sympy.erfc((depth + V * t) / scale) + ) / 2 + + return u_dry + (u_wet - u_dry) * front, D, V + + +class _Gardner(AnalyticSolution): + """Shared Gardner parameters and boundary conditions.""" + + solves = "richards" + dim = 2 + reference = ( + "Gardner (1958), Soil Sci. 85, 228-232; transient form after " + "Ogata & Banks (1961), USGS Prof. Paper 411-A. Previously NumPy " + "functions in underworld3/utilities/retention_curves.py." + ) + + def apply_boundary_conditions(self, solver): + """Prescribe the exact head on every wall.""" + + for boundary in self.boundaries: + solver.add_dirichlet_bc([self.fn_solution], boundary) + + def _gardner_material(self, head): + r"""Conductivity and capacity for a given head expression. + + :math:`K = K_s e^{\alpha\psi}` and + :math:`C = \mathrm d\theta/\mathrm d\psi = \alpha\,\Delta\theta\,e^{\alpha\psi}` + — the unsaturated branches. The saturated branches in + :mod:`~underworld3.utilities.retention_curves` do not apply because + these solutions are posed with :math:`\psi < 0` throughout. + """ + + saturation = sympy.exp(self.alpha * head) + return ( + self.Ks * saturation, + self.alpha * (self.theta_s - self.theta_r) * saturation, + ) + + +class GardnerSteady(_Gardner): + r"""Steady infiltration through a vertical column. + + With gravity, steady Richards reduces to a constant flux, + + .. math:: + K(\psi)\left(\frac{\mathrm d\psi}{\mathrm dy} + 1\right) = q, + + and the Gardner substitution :math:`u = e^{\alpha\psi}` linearises it to give + + .. math:: + \psi(y) = \frac1\alpha\ln\!\left[(u_0 - q^*)e^{-\alpha y} + q^*\right], + \qquad q^* = \frac{u_L - u_0 e^{-\alpha L}}{1 - e^{-\alpha L}}. + + The flux being *constant* is the whole content of the solution, and it is a + sharper test of a Richards solver than the head profile: the head can look + right while the conductivity is being evaluated at the wrong place, and the + flux then drifts down the column. + + Parameters + ---------- + mesh : Mesh + A 2D mesh; the column is the vertical direction. + psi_0, psi_L : float + Head at the bottom and the top. Both should be negative + (unsaturated); the Gardner model is only valid there. + L : float + Column height. + alpha : float + Gardner sorptive number, :math:`1/\mathrm{length}`. + Ks : float + Saturated conductivity. + theta_r, theta_s : float + Residual and saturated water content. + """ + + eqn_solution = ( + r"\frac1\alpha\ln\left[(u_0 - q^*)e^{-\alpha y} + q^*\right]" + ) + + def __init__( + self, + mesh, + psi_0=-1.0, + psi_L=-5.0, + L=1.0, + alpha=1.0, + Ks=1.0, + theta_r=0.05, + theta_s=0.45, + ): + super().__init__(mesh) + + if float(alpha) <= 0.0 or float(Ks) <= 0.0: + raise ValueError("alpha and Ks must be positive.") + if not float(theta_s) > float(theta_r): + raise ValueError("theta_s must exceed theta_r.") + + self.alpha = sympy.Rational(str(alpha)) + self.Ks = sympy.Rational(str(Ks)) + self.theta_r = sympy.Rational(str(theta_r)) + self.theta_s = sympy.Rational(str(theta_s)) + self.L = sympy.Rational(str(L)) + self.psi_0 = float(psi_0) + self.psi_L = float(psi_L) + + y = mesh.X[mesh.dim - 1] + + saturation, self.flux = gardner_steady_saturation( + y, + sympy.Rational(str(psi_0)), + sympy.Rational(str(psi_L)), + self.L, + self.alpha, + ) + head = sympy.log(saturation) / self.alpha + + self.set_richards_field(head, *self._gardner_material(head)) + + +class GardnerTransient(_Gardner): + r"""A wetting front advancing down a dry column. + + The Gardner substitution turns Richards into linear advection–diffusion in + :math:`u = e^{\alpha\psi}`, with + + .. math:: + D = \frac{K_s}{\alpha\,\Delta\theta}, \qquad V = \frac{K_s}{\Delta\theta}, + + and the Ogata–Banks solution for a step applied at the surface gives + + .. math:: + u = u_{\rm dry} + (u_{\rm wet} - u_{\rm dry})\left[ + \tfrac12\mathrm{erfc}\frac{z - Vt}{2\sqrt{Dt}} + + \tfrac12 e^{Vz/D}\,\mathrm{erfc}\frac{z + Vt}{2\sqrt{Dt}}\right] + + with :math:`z = L - y` the depth below the surface. Then + :math:`\psi = \ln u / \alpha`. + + Time is the symbol :attr:`t`, so a solver is checked at whatever time it + reached:: + + exact = sol.fn_solution.subs(sol.t, t_end) + + The solution is semi-infinite, so it satisfies the equation exactly + everywhere but only satisfies the *bottom* boundary condition while the front + has yet to arrive. Compare before then — :attr:`front_depth` says where the + front is. + + Parameters + ---------- + mesh : Mesh + A 2D mesh; the column is the vertical direction. + psi_dry : float + Initial head throughout the column. + psi_wet : float + Head imposed at the surface. Wetter means less negative. + L, alpha, Ks, theta_r, theta_s + As for :class:`GardnerSteady`. + """ + + eqn_solution = ( + r"\frac1\alpha\ln\left[u_{\rm dry} " + r"+ (u_{\rm wet} - u_{\rm dry})H(z,t)\right]" + ) + singular_at_origin = True + + def __init__( + self, + mesh, + psi_dry=-5.0, + psi_wet=-0.5, + L=1.0, + alpha=1.0, + Ks=1.0, + theta_r=0.05, + theta_s=0.45, + ): + super().__init__(mesh) + + if float(alpha) <= 0.0 or float(Ks) <= 0.0: + raise ValueError("alpha and Ks must be positive.") + if not float(theta_s) > float(theta_r): + raise ValueError("theta_s must exceed theta_r.") + if not float(psi_wet) > float(psi_dry): + raise ValueError("psi_wet must be wetter (less negative) than psi_dry.") + + self.alpha = sympy.Rational(str(alpha)) + self.Ks = sympy.Rational(str(Ks)) + self.theta_r = sympy.Rational(str(theta_r)) + self.theta_s = sympy.Rational(str(theta_s)) + self.L = sympy.Rational(str(L)) + self.psi_dry = float(psi_dry) + self.psi_wet = float(psi_wet) + self.t = sympy.Symbol("t", positive=True) + + y = mesh.X[mesh.dim - 1] + + saturation, D, V = gardner_transient_saturation( + y, + self.t, + sympy.Rational(str(psi_dry)), + sympy.Rational(str(psi_wet)), + self.L, + self.Ks, + self.alpha, + self.theta_r, + self.theta_s, + ) + self.diffusivity = float(D) + self.speed = float(V) + + head = sympy.log(saturation) / self.alpha + + self.set_richards_field(head, *self._gardner_material(head)) + + def front_depth(self, time): + """Depth of the wetting front below the surface at *time*. + + The front advects at :math:`V = K_s/\\Delta\\theta`; compare against the + column height to know whether the semi-infinite form still holds. + """ + + return self.speed * float(time) diff --git a/src/underworld3/analytic/transport.py b/src/underworld3/analytic/transport.py new file mode 100644 index 000000000..ce2c0054d --- /dev/null +++ b/src/underworld3/analytic/transport.py @@ -0,0 +1,336 @@ +r"""Exact solutions for the scalar transport problems. + +Diffusion, Darcy flow, Richards flow and Poisson: one unknown field satisfying +:math:`\nabla\cdot(k\nabla u) + f = 0`, rather than the velocity-and-pressure +pair the Velic family solves for. + +These were already in the repository, written inline in the tests that used them +or as helper functions beside the constitutive models. Collected here they gain +what the Stokes solutions have: a residual check that consults nothing external, +a place to be found, and a name to cite. + +See Also +-------- +underworld3.analytic.velic : the Stokes family. +""" + +import sympy + +from ._base import AnalyticSolution + + +class _Transport(AnalyticSolution): + """Shared metadata and boundary conditions for the scalar solutions. + + These prescribe the field itself on the boundary, so they cannot reuse the + Stokes mixins — `FixedWalls` applies a velocity, which a scalar solution does + not have. + """ + + solves = "transport" + dim = 2 + + def apply_boundary_conditions(self, solver): + """Prescribe the exact field on every wall.""" + + for boundary in self.boundaries: + solver.add_dirichlet_bc([self.fn_solution], boundary) + + +class Poisson1D(_Transport): + r"""One-dimensional Poisson, with a source of your choosing. + + Solves :math:`\nabla^2 u + f = 0` on the unit box with :math:`u` varying in + :math:`z` only, for three sources that between them cover the cases worth + separating: + + ============== ========================== ============================== + ``source`` :math:`f` :math:`u` + ============== ========================== ============================== + ``"none"`` :math:`0` :math:`1 - z` + ``"constant"`` :math:`2` :math:`z(1-z)` + ``"sinusoid"`` :math:`\pi^2\sin(\pi z)` :math:`\sin(\pi z)` + ============== ========================== ============================== + + The simplest thing in the suite, and the first thing to run when a scalar + solver is suspect: a linear profile is exact in any sensible discretisation, + a quadratic is exact from second order up, and only the sinusoid actually + tests convergence. A solver that fails the first two has a problem that has + nothing to do with accuracy. + + Parameters + ---------- + mesh : Mesh + A 2D mesh on the unit box. + source : {"none", "constant", "sinusoid"} + """ + + reference = "Standard; previously inline in tests/test_1000_poissonCart.py." + + def __init__(self, mesh, source="sinusoid"): + super().__init__(mesh) + + x, z = mesh.X + exact = { + "none": (1 - z, sympy.Integer(0)), + "constant": (z * (1 - z), sympy.Integer(2)), + "sinusoid": (sympy.sin(sympy.pi * z), sympy.pi**2 * sympy.sin(sympy.pi * z)), + } + if source not in exact: + raise ValueError( + f"source must be one of {sorted(exact)}; got {source!r}" + ) + + self.source = source + solution, forcing = exact[source] + + self.eqn_solution = {"none": r"1 - z", "constant": r"z(1-z)", + "sinusoid": r"\sin(\pi z)"}[source] + self.set_scalar_field(solution=solution, coefficient=1, source=forcing) + + +class ErfcDiffusion(_Transport): + r"""The error-function diffusion profile. + + :math:`h(z, t) = \mathrm{erfc}\!\left(z / 2\sqrt{Dt}\right)`, the response of + a semi-infinite column to a step held at its boundary. + + Time appears as a symbol, :attr:`t`, so a transient solver can be checked at + whatever time it reached. + + .. warning:: + **Singular at** :math:`t = 0`, and so is every diffusive similarity + solution. At the origin the profile is a step with unbounded gradient — + a state no finite element space can hold. Do not start a benchmark + there, and do not quote an error at one time: a transient comparison is + a *pair* of times, and the error depends on the start as much as the + finish. + + ``sol.at(t)`` refuses :math:`t \le 0` outright, and + ``sol.earliest_resolvable_time(h)`` gives the resolution-dependent floor + below which you are measuring interpolation error rather than the + solver:: + + t0 = sol.earliest_resolvable_time(mesh.get_min_radius()) + field.array = uw.function.evaluate(sol.at(t0), field.coords) + ... # step to t1 + error = sol.error("solution", field) # against sol.at(t1) + + Parameters + ---------- + mesh : Mesh + A 2D mesh; the profile varies with :math:`z`. + diffusivity : float + :math:`D`. For a Darcy problem this is conductivity over storativity. + """ + + reference = ( + "Standard; previously inline in " + "tests/test_1005_TransientDarcyCartesian.py." + ) + eqn_solution = r"\mathrm{erfc}\left(z / 2\sqrt{Dt}\right)" + singular_at_origin = True + + def __init__(self, mesh, diffusivity=1.0): + super().__init__(mesh) + + if float(diffusivity) <= 0.0: + raise ValueError("diffusivity must be positive.") + + self.diffusivity = float(diffusivity) + self.t = sympy.Symbol("t", positive=True) + + x, z = mesh.X + profile = sympy.erfc(z / (2 * sympy.sqrt(self.diffusivity * self.t))) + + # Diffusion has no source; the field is driven entirely by its boundary, + # so the steady residual is not the right check here — the transient one + # is du/dt = D lap(u), which `diffusion_residual` verifies. + self.set_scalar_field(profile, coefficient=self.diffusivity, source=0) + + +class AdvectedFront(_Transport): + r"""An advecting, diffusing top hat. + + Two error functions, the exact response of a rectangular pulse between + :math:`x_0` and :math:`x_1` carried at speed :math:`u` while diffusing with + :math:`\kappa`. Time is the symbol :attr:`t`, as for :class:`ErfcDiffusion`. + + This is the solution `tests/test_1100_AdvDiffCartesian.py` needs. Its own + note said the test was fragile because a step initial condition is not + representable on the mesh — which is the singularity below, and the reason + that test has to name a start time as well as an end time. + + .. warning:: + **Singular at** :math:`t = 0`, and so is every diffusive similarity + solution. At the origin the profile is a step with unbounded gradient — + a state no finite element space can hold. Do not start a benchmark + there, and do not quote an error at one time: a transient comparison is + a *pair* of times, and the error depends on the start as much as the + finish. + + ``sol.at(t)`` refuses :math:`t \le 0` outright, and + ``sol.earliest_resolvable_time(h)`` gives the resolution-dependent floor + below which you are measuring interpolation error rather than the + solver:: + + t0 = sol.earliest_resolvable_time(mesh.get_min_radius()) + field.array = uw.function.evaluate(sol.at(t0), field.coords) + ... # step to t1 + error = sol.error("solution", field) # against sol.at(t1) + + Parameters + ---------- + mesh : Mesh + A 2D mesh; the front travels in :math:`x`. + kappa : float + Diffusivity. + speed : float + Advection speed. + x0, x1 : float + Edges of the initial pulse. + """ + + reference = ( + "Ogata & Banks (1961) form; previously inline in " + "tests/test_1100_AdvDiffCartesian.py." + ) + eqn_solution = ( + r"\tfrac12\left[\mathrm{erf}\frac{x_1 - x + ut}{2\sqrt{\kappa t}}" + r" + \mathrm{erf}\frac{x - x_0 - ut}{2\sqrt{\kappa t}}\right]" + ) + singular_at_origin = True + + def __init__(self, mesh, kappa=1.0e-3, speed=1.0, x0=0.1, x1=0.3): + super().__init__(mesh) + + if float(kappa) <= 0.0: + raise ValueError("kappa must be positive.") + + self.kappa = float(kappa) + self.diffusivity = float(kappa) + self.speed = float(speed) + self.x0 = float(x0) + self.x1 = float(x1) + self.t = sympy.Symbol("t", positive=True) + + x, z = mesh.X + spread = 2 * sympy.sqrt(self.kappa * self.t) + drift = self.speed * self.t + profile = ( + sympy.erf((self.x1 - x + drift) / spread) + + sympy.erf((x - self.x0 - drift) / spread) + ) / 2 + + self.set_scalar_field( + profile, coefficient=self.kappa, source=0, advection=(self.speed, 0) + ) + + +class TwoLayerDarcy(_Transport): + r"""Steady Darcy flow through two layers of different permeability. + + A piecewise-linear pressure with a kink at the interface, set by continuity + of flux across it: the steeper gradient is in the less permeable layer. + + The simplest problem with a *coefficient* discontinuity rather than a source + one, which makes it the scalar counterpart of SolCx — and the fastest way to + tell whether a Darcy solver handles a permeability contrast at all. + + With gravity the flux is :math:`q = -k(\partial_z p + S)`; setting + :math:`S = 0` recovers the pressure-driven case. Either way :math:`q` is + constant down the column, and that is what fixes the interface pressure: + + .. math:: + q = \frac{\Delta p - S(z_1 - z_0)}{d_{\rm low}/k_{\rm low} + + d_{\rm up}/k_{\rm up}} + + with :math:`d` the layer thicknesses. The profile is then linear in each + layer with slope :math:`-q/k - S`. + + Parameters + ---------- + mesh : Mesh + A 2D mesh; the layering is in the vertical coordinate. + k_lower, k_upper : float + Permeability below and above the interface. + interface : float + Height of the layer boundary. Must lie strictly inside the column. + pressure_drop : float + Pressure at the bottom, taking the top as zero. + gravity : float + :math:`S` in the flux law. Zero for pressure-driven flow. + bottom, top : float + Extent of the column. Defaults to the unit interval; the Darcy tests + use :math:`(-1, 0)`. + """ + + reference = ( + "Standard; previously inline in tests/test_1004_DarcyCartesian.py." + ) + eqn_solution = r"\text{piecewise linear, kinked at the interface}" + + def __init__( + self, + mesh, + k_lower=1.0, + k_upper=0.1, + interface=0.5, + pressure_drop=1.0, + gravity=0.0, + bottom=0.0, + top=1.0, + ): + super().__init__(mesh) + + if not (float(k_lower) > 0.0 and float(k_upper) > 0.0): + raise ValueError("permeabilities must be positive.") + if not float(bottom) < float(interface) < float(top): + raise ValueError( + f"interface must lie strictly inside ({bottom}, {top}); " + f"got {interface}" + ) + + self.k_lower = float(k_lower) + self.k_upper = float(k_upper) + self.interface = float(interface) + self.pressure_drop = float(pressure_drop) + self.gravity = float(gravity) + self.bottom = float(bottom) + self.top = float(top) + + z = mesh.X[mesh.dim - 1] + + k_low = sympy.Rational(str(k_lower)) + k_up = sympy.Rational(str(k_upper)) + z_int = sympy.Rational(str(interface)) + z_bot = sympy.Rational(str(bottom)) + z_top = sympy.Rational(str(top)) + drop = sympy.Rational(str(pressure_drop)) + S = sympy.Rational(str(gravity)) + + thick_low = z_int - z_bot + thick_up = z_top - z_int + + # Constant flux, from p(top) = 0 and p(bottom) = drop. Derived here + # rather than transcribed, so that agreeing with the form the Darcy test + # carried is a check on both rather than a copy of one. + self.flux = (drop - S * (z_top - z_bot)) / ( + thick_low / k_low + thick_up / k_up + ) + self.at_interface = thick_up * (self.flux / k_up + S) + + slope_low = -self.flux / k_low - S + slope_up = -self.flux / k_up - S + + self.set_scalar_field( + sympy.Piecewise( + (self.at_interface + slope_low * (z - z_int), z < z_int), + (self.at_interface + slope_up * (z - z_int), True), + ), + # Piecewise-constant k makes the gravity term vanish inside each + # layer, so the source is zero away from the interface — where flux + # continuity is imposed by construction rather than by a source. + coefficient=sympy.Piecewise((k_low, z < z_int), (k_up, True)), + source=0, + ) diff --git a/src/underworld3/analytic/velic.py b/src/underworld3/analytic/velic.py new file mode 100644 index 000000000..c97205e19 --- /dev/null +++ b/src/underworld3/analytic/velic.py @@ -0,0 +1,1785 @@ +r"""The Velic family of exact Stokes solutions. + +These are the classical variable-viscosity benchmarks: a known body force and a +known viscosity structure on the unit box, with the exact velocity, pressure and +stress they produce. They are the standard way to show a Stokes solver is right +rather than merely converged. + +Each is transcribed from its published Maple-generated kernel, which stays +vendored in :mod:`underworld3.analytic._reference` as the oracle the +transcription is validated against and as a supported escape hatch +(``reference=True``). + +""" + +import functools +import os + +import sympy + +from ._base import AnalyticSolution, FixedWalls, FreeSlipWalls +from ._transcribe import ( + CSource, + evaluate_block, + evaluate_expression, + resolve_branches, +) + +_REFERENCE_DIR = os.path.join(os.path.dirname(__file__), "_reference") + +# Free symbols of the transcribed kernel, in the kernel's own naming. +_X, _Z = sympy.symbols("x z") +_XC, _KN, _KX = sympy.symbols("xc kn kx") +_ZA, _ZB, _ZR = sympy.symbols("ZA ZB ZR") + +# What the kernel's shared tail leaves behind, and what each means. +_SOLCX_OUTPUTS = { + "velocity_x": "u1", + "velocity_z": "u2", + "stress_xx": "u3", + "stress_zx": "u4", + "pressure": "u5", + "stress_zz": "u6", +} + + +@functools.lru_cache(maxsize=None) +def _solcx_kernel(variant="_solCx_A"): + r"""Transcribe the Velic SolCx kernel into SymPy. + + The published source carries two arrangements, ``_solCx_A`` and + ``_solCx_B``, and dispatches on :math:`\eta_A > \eta_B`. They are not two + conditionings of one formula: evaluated exactly, ``_solCx_B`` is + ``_solCx_A`` reflected, :math:`B(x, z) = A(1-x, z)`. It solves the mirrored + problem so that the algebra derived for a stiff left column can be reused + when the stiff column is on the right, and undoes the reflection on the way + out. + + So only ``_solCx_A`` is transcribed, and no dispatch is needed. That is + safe because the reason for the dispatch — conditioning — was measured + rather than assumed: this arrangement reproduces the published kernel to + 1e-14 over viscosity ratios from 1e-6 to 1e8 in both directions. See + ``docs/developer/subsystems/analytic-solutions.md``. + + The remaining branch is spatial (:math:`x < x_c`) and becomes a + :class:`sympy.Piecewise`. + + Returns + ------- + dict + Field name -> Piecewise expression in the kernel's own symbols. + """ + + source = CSource(os.path.join(_REFERENCE_DIR, "solCx.c")) + body = source.function(variant) + left_block, right_block, tail = CSource.branches( + body, "if (x>> sol = uw.analytic.SolCx(mesh, eta_A=1.0, eta_B=1.0e6) + >>> stokes.constitutive_model.Parameters.shear_viscosity_0 = sol.fn_viscosity + >>> stokes.bodyforce = sol.fn_bodyforce + >>> sol.apply_boundary_conditions(stokes) + >>> stokes.solve() + >>> sol.error("velocity", stokes.u) + + Notes + ----- + The body force is :math:`+\cos(\pi x)\sin(n\pi z)`. Underworld2's + documentation quotes the opposite sign; the sign here is the one consistent + with UW3's momentum convention and with the kernel's own pressure, which is + what the validation checks against. + """ + + dim = 2 + reference = ( + "Velic; see also Duretz et al. (2011). Transcribed from the published " + "kernel vendored at underworld3/analytic/_reference/solCx.c." + ) + eqn_viscosity = r"\eta_A \;(x 0.0 and float(eta_B) > 0.0): + raise ValueError("eta_A and eta_B must be positive.") + if float(eta_A) == float(eta_B): + # The closed form carries (ZR - 1) in several denominators, so equal + # viscosities are a removable singularity. SymPy cancels it when the + # expression is evaluated symbolically at a point, but not in the + # compiled form, so this case would silently return nonsense. A + # uniform-viscosity benchmark is a different solution anyway. + raise ValueError( + "SolCx is a viscosity-jump benchmark and is singular at " + "eta_A == eta_B. Use a uniform-viscosity solution instead." + ) + if not 0.0 <= float(x_c) <= 1.0: + raise ValueError("x_c must lie in [0, 1].") + if int(n) != n or int(n) < 1: + raise ValueError("n (vertical wavenumber) must be a positive integer.") + + self.eta_A = float(eta_A) + self.eta_B = float(eta_B) + self.x_c = float(x_c) + self.n = int(n) + + x, z = mesh.X + + # Exact parameters, not floats. The closed form carries (ZR - 1) in + # several denominators, so at equal viscosities it has a removable + # singularity: substituting exactly lets SymPy cancel it, while + # substituting floats leaves a 0/0 that evaluates to nothing useful. + # Rational() of a float is its exact binary value, so this costs nothing. + eta_A_exact = sympy.Rational(self.eta_A) + eta_B_exact = sympy.Rational(self.eta_B) + + # The kernel's own naming: kx is the horizontal wavenumber of the forcing + # (fixed at pi), kn the vertical one, ZR the viscosity ratio. + values = { + _XC: sympy.Rational(self.x_c), + _KN: self.n * sympy.pi, + _KX: sympy.pi, + _ZA: eta_A_exact, + _ZB: eta_B_exact, + _ZR: eta_B_exact / eta_A_exact, + _X: x, + _Z: z, + } + + kernel = { + field: expression.subs(values) + for field, expression in _solcx_kernel().items() + } + + # The viscosity tie-break at x == x_c matches the kernel's own step, so a + # point exactly on the interface is treated the same way by both. + self.set_fields( + velocity=(kernel["velocity_x"], kernel["velocity_z"]), + pressure=kernel["pressure"], + viscosity=sympy.Piecewise((self.eta_A, x < self.x_c), (self.eta_B, True)), + bodyforce=(0, sympy.cos(sympy.pi * x) * sympy.sin(self.n * sympy.pi * z)), + stress=( + (kernel["stress_xx"], kernel["stress_zx"]), + (kernel["stress_zx"], kernel["stress_zz"]), + ), + ) + + if reference: + self._use_reference_kernel() + + def velocity_error(self, velocity_var): + """Global relative L2 velocity error. Equivalent to ``error("velocity", ...)``.""" + + return self.error("velocity", velocity_var) + + def evaluate_stress(self, coords): + """Exact total (Cauchy) stress at ``coords``, as ``(N, 3)`` columns + :math:`(\\sigma_{xx}, \\sigma_{zz}, \\sigma_{xz})`.""" + + import numpy as np + + components = [self.fn_stress[0, 0], self.fn_stress[1, 1], self.fn_stress[0, 1]] + return np.column_stack( + [np.asarray(self.evaluate(c, coords)).reshape(-1) for c in components] + ) + + def topography_top(self, coords): + """Exact dynamic topography :math:`-\\mathbf n\\cdot\\sigma\\cdot\\mathbf n` + on the top boundary, i.e. :math:`-\\sigma_{zz}`.""" + + import numpy as np + + return -np.asarray(self.evaluate(self.fn_stress[1, 1], coords)).reshape(-1) + + def _use_reference_kernel(self): + """Replace the transcribed fields with the vendored kernel's own. + + Point evaluation only: these are opaque to the JIT, so a solution built + this way cannot be handed to a solver. + """ + + from ._reference import _velic + + x, z = self.mesh.X + parameters = (self.eta_A, self.eta_B, self.x_c, self.n) + + self.fn_velocity = sympy.Matrix( + [ + [ + _velic.AnalyticSolCx_velocity_x(*parameters, x, z), + _velic.AnalyticSolCx_velocity_y(*parameters, x, z), + ] + ] + ) + self.fn_pressure = _velic.AnalyticSolCx_pressure(*parameters, x, z) + self.fn_stress = sympy.Matrix( + [ + [ + _velic.AnalyticSolCx_stress_xx(*parameters, x, z), + _velic.AnalyticSolCx_stress_xy(*parameters, x, z), + ], + [ + _velic.AnalyticSolCx_stress_xy(*parameters, x, z), + _velic.AnalyticSolCx_stress_yy(*parameters, x, z), + ], + ] + ) + + +_ETA0, _N, _R = sympy.symbols("eta0 n r") + + +@functools.lru_cache(maxsize=None) +def _solnl_kernel(): + r"""Transcribe the Velic SolNL kernel into SymPy. + + Six short functions rather than one branching kernel, so each is read on its + own. The tensor entries are written through a struct (``out.xx = ...``), and + the viscosity is returned rather than assigned — hence the two ways of + reading a body here. + + Returns + ------- + dict + Field name -> expression in the kernel's own symbols. + """ + + source = CSource(os.path.join(_REFERENCE_DIR, "AnalyticSolNL.c")) + inputs = {"eta0": _ETA0, "n": _N, "r": _R, "x": _X, "z": _Z} + + def block(name, signature): + return evaluate_block(source.function(name, returns=signature), inputs) + + velocity = block("SolNL_velocity", "vec2") + bodyforce = block("SolNL_bodyforce", "vec2") + stress = block("SolNL_stress", "tensor2") + strainrate = block("SolNL_strainrate", "tensor2") + pressure = block("SolNL_pressure", "double") + + viscosity_body = source.function("SolNL_viscosity", returns="double") + viscosity = evaluate_expression( + CSource.returned(viscosity_body), evaluate_block(viscosity_body, inputs) + ) + + return { + "velocity_x": velocity["out.x"], + "velocity_z": velocity["out.z"], + "bodyforce_x": bodyforce["out.x"], + "bodyforce_z": bodyforce["out.z"], + "pressure": pressure["p"], + "stress_xx": stress["out.xx"], + "stress_zz": stress["out.zz"], + "stress_xz": stress["out.xz"], + "strainrate_xx": strainrate["out.xx"], + "strainrate_zz": strainrate["out.zz"], + "strainrate_xz": strainrate["out.xz"], + "viscosity": viscosity, + } + + +class SolNL(FixedWalls, AnalyticSolution): + r"""Power-law viscous flow — the SolNL nonlinear benchmark. + + A manufactured solution for a shear-thinning fluid: the viscosity depends on + the second invariant of the strain rate the solution itself produces, + + .. math:: + \eta = \eta_0 \left(\dot\varepsilon_{ij}\dot\varepsilon_{ij}\right)^{1/r - 1} + + so it tests a nonlinear solver rather than a linear one. The velocity is + simple — :math:`\mathbf u = (-k\,e^{x}\cos kz,\; e^{x}\sin kz)`, divergence + free by inspection — and the body force is whatever makes it exact. + + Parameters + ---------- + mesh : Mesh + A 2D mesh on the unit box. + eta_0 : float + Viscosity prefactor. + n : int + Vertical wavenumber. + r : float + Power-law exponent. ``r = 1`` is Newtonian; larger is more + shear-thinning. + reference : bool + Evaluate through the vendored kernel instead of the transcription. + + Notes + ----- + The velocity is not tangential to the walls, so this is posed with the exact + velocity prescribed on the boundary rather than free slip. + """ + + dim = 2 + nonlinear = True + stress_is_deviatoric = True + reference = ( + "Velic. Transcribed from the published kernel vendored at " + "underworld3/analytic/_reference/AnalyticSolNL.c." + ) + eqn_velocity = r"(-k e^{x}\cos kz,\; e^{x}\sin kz), \quad k = n\pi" + eqn_viscosity = r"\eta_0 (\dot\varepsilon_{ij}\dot\varepsilon_{ij})^{1/r - 1}" + + def __init__(self, mesh, eta_0=1.0, n=1, r=1.5, reference=False): + super().__init__(mesh) + + if float(eta_0) <= 0.0: + raise ValueError("eta_0 must be positive.") + if int(n) != n or int(n) < 1: + raise ValueError("n (vertical wavenumber) must be a positive integer.") + if float(r) <= 0.0: + raise ValueError("r (power-law exponent) must be positive.") + # The published closed form is singular at alpha = 1/r - 1 = -1/2. The + # pressure's denominator (AnalyticSolNL.c:52, `t44`) vanishes there + # identically — for every wavenumber, so this is a pole of the solution + # and not a bad (r, n) pairing. Refuse it here: left to run it produces + # ComplexInfinity in the pressure, stress and body force, and the first + # symptom is a KeyError from SymPy's code printer, a long way from the + # cause. + if float(r) == 2.0: + raise ValueError( + "r = 2 is a pole of the published SolNL solution: its pressure " + "denominator vanishes identically at alpha = 1/r - 1 = -1/2. " + "Use a nearby exponent (r = 1.9 or 2.1) instead." + ) + + self.eta_0 = float(eta_0) + self.n = int(n) + self.r = float(r) + + x, z = mesh.X + values = { + _ETA0: sympy.Rational(self.eta_0), + _N: self.n, + _R: sympy.Rational(self.r), + _X: x, + _Z: z, + } + kernel = { + field: expression.subs(values) + for field, expression in _solnl_kernel().items() + } + + self.set_fields( + velocity=(kernel["velocity_x"], kernel["velocity_z"]), + pressure=kernel["pressure"], + viscosity=kernel["viscosity"], + bodyforce=(kernel["bodyforce_x"], kernel["bodyforce_z"]), + # Both are published and both are consistent, so both are supplied: + # the conformance check then compares them against each other. + stress=( + (kernel["stress_xx"], kernel["stress_xz"]), + (kernel["stress_xz"], kernel["stress_zz"]), + ), + strainrate=( + (kernel["strainrate_xx"], kernel["strainrate_xz"]), + (kernel["strainrate_xz"], kernel["strainrate_zz"]), + ), + ) + + if reference: + self._use_reference_kernel() + + def _use_reference_kernel(self): + """Point-evaluation only: opaque to the JIT, so no solver can use it.""" + + from ._reference import _velic + + x, z = self.mesh.X + parameters = (self.eta_0, self.n, self.r) + + self.fn_velocity = sympy.Matrix( + [ + [ + _velic.AnalyticSolNL_velocity_x(*parameters, x, z), + _velic.AnalyticSolNL_velocity_y(*parameters, x, z), + ] + ] + ) + self.fn_bodyforce = sympy.Matrix( + [ + [ + _velic.AnalyticSolNL_bodyforce_x(*parameters, x, z), + _velic.AnalyticSolNL_bodyforce_y(*parameters, x, z), + ] + ] + ) + self.fn_viscosity = _velic.AnalyticSolNL_viscosity(*parameters, x, z) + + +_B, _M = sympy.symbols("B m") +_KN = sympy.Symbol("kn_solm") +_KM = sympy.Symbol("km") + +# The kernel leaves the fields in u1..u6, each still to be multiplied by its +# vertical mode. Same convention as SolCx, and the source says so in a comment: +# "u1 = Vx, u2 = Vz, u3 = txx, u4 = tzx, u5 = pressure, u6 = tzz". +_SOLKX_OUTPUTS = { + "velocity_x": ("u1", sympy.cos), + "velocity_z": ("u2", sympy.sin), + "stress_xx": ("u3", sympy.cos), + "stress_zx": ("u4", sympy.sin), + "pressure": ("u5", sympy.cos), + "stress_zz": ("u6", sympy.cos), +} + + +@functools.lru_cache(maxsize=None) +def _solkx_kernel(): + r"""Transcribe the Velic SolKx kernel into SymPy. + + One straight-line block, no branches — the exponential viscosity has no + interface to split on, so unlike SolCx there is no ``Piecewise`` here. + + Returns + ------- + dict + Field name -> expression in the kernel's own symbols. + """ + + source = CSource(os.path.join(_REFERENCE_DIR, "solKx.c")) + body = source.function("SolKxSolution", returns="static PetscErrorCode") + + # Stop at the output section (comments are already stripped, so anchor on + # code): it accumulates with `+=` and writes + # through pointers, neither of which this reader interprets. + body = body[: body.index("if (mu)")] + + # The kernel takes its coordinates from an array, so `pos` is bound to one. + inputs = {"pos": (_X, _Z), "B": _B, "m": _M, "n": _N} + scope = evaluate_block(body, inputs) + + km = _M * sympy.pi + return { + field: scope[symbol] * mode(km * _Z) + for field, (symbol, mode) in _SOLKX_OUTPUTS.items() + } + + +class SolKx(FreeSlipWalls, AnalyticSolution): + r"""Stokes flow with an exponentially varying viscosity — the SolKx benchmark. + + Viscosity :math:`\eta = e^{2Bx}` on the unit box, driven by the density + forcing :math:`\mathbf f = (0,\; \sin(m\pi z)\cos(n\pi x))`, free slip on all + four walls. + + The companion to SolCx: same geometry and forcing, but the viscosity varies + *smoothly* rather than jumping. A solver can do well on one and badly on the + other — a jump tests how the discretisation handles a discontinuity, a + gradient tests whether the operator stays well conditioned as the contrast + builds across every element. Over the unit box the total contrast is + :math:`e^{2B}`, so ``B = 5`` already spans four orders of magnitude. + + Parameters + ---------- + mesh : Mesh + A 2D mesh on the unit box. + B : float + Viscosity exponent. + n : int + Horizontal wavenumber of the forcing. + m : int + Vertical wavenumber. + + Examples + -------- + >>> sol = uw.analytic.SolKx(mesh, B=2.302585, n=3, m=2.0) + >>> stokes.constitutive_model.Parameters.shear_viscosity_0 = sol.fn_viscosity + >>> stokes.bodyforce = sol.fn_bodyforce + >>> sol.apply_boundary_conditions(stokes) + + Notes + ----- + Transcribed from PETSc's copy of the kernel rather than Underworld2's: it is + self-contained, returns every field in one call, and is actively maintained + upstream. + + Validated by the equations rather than against a compiled kernel. The forcing + and boundary conditions are known, and a field set that satisfies Stokes with + them is *the* solution by uniqueness — so the momentum and incompressibility + residuals settle it without needing an oracle. + """ + + dim = 2 + # exponential viscosity e^{2Bx} -> tens of thousands of operations per expression + expensive_to_validate = True + reference = ( + "Velic; transcribed from PETSc src/snes/tutorials/ex69.c (SolKxSolution), " + "vendored at underworld3/analytic/_reference/solKx.c (BSD-2-Clause)." + ) + eqn_viscosity = r"e^{2Bx}" + eqn_bodyforce = r"(0,\; \sin(m \pi z)\cos(n \pi x))" + + def __init__(self, mesh, B=2.302585092994046, n=3, m=2): + super().__init__(mesh) + + if int(n) != n or int(n) < 1: + raise ValueError("n (horizontal wavenumber) must be a positive integer.") + if int(m) != m or int(m) < 1: + # The kernel itself allows non-integral m, and PETSc says so. But the + # vertical velocity carries sin(m pi z), which vanishes at z = 1 only + # for integer m — so a fractional value silently stops satisfying free + # slip on the top wall while still solving the equations, and the + # benchmark quietly becomes a different problem. + raise ValueError( + "m (vertical wavenumber) must be a positive integer: the free-slip " + "condition on the top wall requires sin(m*pi) = 0." + ) + + self.B = float(B) + self.n = int(n) + self.m = float(m) + + x, z = mesh.X + values = { + _B: sympy.Rational(self.B), + _N: self.n, + _M: sympy.Rational(self.m), + _X: x, + _Z: z, + } + kernel = { + field: expression.subs(values) + for field, expression in _solkx_kernel().items() + } + + self.set_fields( + velocity=(kernel["velocity_x"], kernel["velocity_z"]), + pressure=kernel["pressure"], + viscosity=sympy.exp(2 * sympy.Rational(self.B) * x), + bodyforce=( + 0, + sympy.sin(self.m * sympy.pi * z) * sympy.cos(self.n * sympy.pi * x), + ), + stress=( + (kernel["stress_xx"], kernel["stress_zx"]), + (kernel["stress_zx"], kernel["stress_zz"]), + ), + ) + + +_Y = sympy.Symbol("y") +_BETA = sympy.Symbol("Beta") + + +@functools.lru_cache(maxsize=None) +def _soldb_kernel(dim): + r"""Transcribe a Dohrmann–Bochev solution into SymPy. + + Six short methods rather than one kernel, each written as a C++ member of a + header-only class, and each taking its coordinates from an array. + + Returns + ------- + dict + Field name -> expression in the kernel's own symbols. + """ + + names = {2: ("x", "z"), 3: ("x", "y", "z")}[dim] + coordinates = {2: (_X, _Z), 3: (_X, _Y, _Z)}[dim] + + source = CSource( + os.path.join(_REFERENCE_DIR, f"AnalyticSolDB{dim}d.hpp") + ) + inputs = {"in": coordinates, "Beta": _BETA} + + def block(method): + return evaluate_block(source.function(method), inputs) + + velocity = block("velocity") + bodyforce = block("bodyforce") + stress = block("stress") + strainrate = block("strainrate") + pressure = block("pressure") + + fields = { + "pressure": pressure["p"], + # SolDB2d writes its unit viscosity straight into the output array, so + # there is no named variable to read; 3D has one. + "viscosity": block("viscosity")["eta"] if dim == 3 else sympy.Integer(1), + } + for axis, name in enumerate(names): + fields[f"velocity_{name}"] = velocity[f"v{name}"] + fields[f"bodyforce_{name}"] = bodyforce[f"f{name}"] + + for i, a in enumerate(names): + for b in names[i:]: + fields[f"stress_{a}{b}"] = stress[f"t{a}{b}"] + fields[f"strainrate_{a}{b}"] = strainrate[f"e{a}{b}"] + + return fields + + +class _SolDB(FixedWalls, AnalyticSolution): + """Shared assembly for the Dohrmann–Bochev manufactured solutions.""" + + stress_is_deviatoric = True + + def _assemble(self, mesh, values, names): + kernel = { + field: expression.subs(values) + for field, expression in _soldb_kernel(self.dim).items() + } + + def tensor(prefix): + return sympy.Matrix( + [ + [ + kernel[f"{prefix}_{min(a, b)}{max(a, b)}"] + if f"{prefix}_{min(a, b)}{max(a, b)}" in kernel + else kernel[f"{prefix}_{max(a, b)}{min(a, b)}"] + for b in names + ] + for a in names + ] + ) + + self.set_fields( + velocity=[kernel[f"velocity_{n}"] for n in names], + pressure=kernel["pressure"], + viscosity=kernel["viscosity"], + bodyforce=[kernel[f"bodyforce_{n}"] for n in names], + stress=tensor("stress"), + strainrate=tensor("strainrate"), + ) + + +class SolDB2d(_SolDB): + r"""Isoviscous polynomial manufactured solution — Dohrmann & Bochev, 2D. + + Unit viscosity, a polynomial velocity, and whatever body force makes it + exact. There is no discontinuity and no large contrast, which is the point: + it isolates the discretisation from the conditioning, so an error here is an + error in the element or the solve rather than in how a hard coefficient was + handled. + + Parameters + ---------- + mesh : Mesh + A 2D mesh on the unit box. + + Notes + ----- + The velocity is not tangential to the walls, so this is posed with the exact + velocity prescribed on the boundary. + """ + + dim = 2 + reference = ( + "Dohrmann & Bochev (2004), Int. J. Numer. Meth. Fluids 46, 183-201, " + "doi:10.1002/fld.752. Transcribed from the kernel vendored at " + "underworld3/analytic/_reference/AnalyticSolDB2d.hpp." + ) + eqn_viscosity = r"1" + + def __init__(self, mesh): + super().__init__(mesh) + + x, z = mesh.X + self._assemble(mesh, {_X: x, _Z: z}, ("x", "z")) + + +class SolDB3d(_SolDB): + r"""Variable-viscosity manufactured solution in 3D — Burstedde et al. + + Viscosity :math:`\eta = e^{1 - \beta\,[x(1-x) + y(1-y) + z(1-z))]}`, smooth + and peaked in the interior, with a polynomial velocity and a body force + chosen to make the pair exact. + + The only 3D solution in the suite, and the only one whose viscosity varies in + every direction at once — a 2D benchmark cannot catch a term that is wrong + only in the third dimension, and several parts of a Stokes discretisation + (the pressure space, the null space, the tensor assembly) genuinely differ + between 2D and 3D. + + Parameters + ---------- + mesh : Mesh + A 3D mesh on the unit cube. + beta : float + Viscosity exponent. Zero is isoviscous; larger makes the interior + viscosity peak sharper. + + Notes + ----- + The velocity is not tangential to the boundary, so the exact velocity is + prescribed there. + """ + + dim = 3 + reference = ( + "Burstedde et al. (2013), Geophys. J. Int. 192(3), 889-906, " + "doi:10.1093/gji/ggs070. Transcribed from the kernel vendored at " + "underworld3/analytic/_reference/AnalyticSolDB3d.hpp." + ) + eqn_viscosity = r"e^{1 - \beta [x(1-x) + y(1-y) + z(1-z)]}" + + def __init__(self, mesh, beta=4.0): + super().__init__(mesh) + + self.beta = float(beta) + + x, y, z = mesh.X + self._assemble( + mesh, + {_X: x, _Y: y, _Z: z, _BETA: sympy.Rational(self.beta)}, + ("x", "y", "z"), + ) + + +# SolKz transposes SolKx: the viscosity varies with depth, the modes run in x, +# and the kernel's u1 is the *vertical* velocity. Reading it with the SolCx +# convention would silently transpose the whole solution, so the mapping is +# spelled out from the kernel's own output section rather than assumed. +_SOLKZ_OUTPUTS = { + "velocity_x": ("u2", sympy.sin), + "velocity_z": ("u1", sympy.cos), + "stress_xx": ("u6", sympy.cos), + "stress_zz": ("u3", sympy.cos), + "stress_zx": ("u4", sympy.sin), + "pressure": ("u5", sympy.cos), +} + + +@functools.lru_cache(maxsize=None) +def _solkz_kernel(): + """Transcribe the Velic SolKz kernel into SymPy. One straight-line block.""" + + source = CSource(os.path.join(_REFERENCE_DIR, "solKz.c")) + body = source.function("_Velic_solKz") + body = body[: body.index("rho =")] + + inputs = {"pos": (_X, _Z), "_sigma": sympy.Integer(1), "_km": _KM, "_n": _N, "_B": _B} + scope = evaluate_block(body, inputs) + + kn = _N * sympy.pi + return { + field: scope[symbol] * mode(kn * _X) + for field, (symbol, mode) in _SOLKZ_OUTPUTS.items() + } + + +class SolKz(FreeSlipWalls, AnalyticSolution): + r"""Stokes flow with a depth-dependent viscosity — the SolKz benchmark. + + Viscosity :math:`\eta = e^{2Bz}` on the unit box, free slip everywhere, + forced by :math:`\mathbf f = (0,\; \sin(m\pi z)\cos(n\pi x))`. + + The vertical twin of :class:`SolKx`, and not a redundant one. A viscosity + that varies with *depth* stratifies the flow along the direction the buoyancy + acts, so the pressure and the vertical velocity are coupled through the + varying coefficient in a way that a horizontal gradient never produces. It is + also the closer analogue of a real mantle viscosity profile. + + Parameters + ---------- + mesh : Mesh + A 2D mesh on the unit box. + B : float + Viscosity exponent; the contrast across the box is :math:`e^{2B}`. + n : int + Horizontal wavenumber of the forcing. + m : int + Vertical wavenumber. + + Notes + ----- + Validated by the equations rather than against a compiled kernel — the + forcing and boundary conditions are known, so satisfying Stokes with them + identifies the solution uniquely. + """ + + dim = 2 + # exponential viscosity e^{2Bz} -> tens of thousands of operations per expression + expensive_to_validate = True + stress_is_deviatoric = True + reference = ( + "Velic. Transcribed from the published kernel vendored at " + "underworld3/analytic/_reference/solKz.c." + ) + eqn_viscosity = r"e^{2Bz}" + eqn_bodyforce = r"(0,\; \sin(m \pi z)\cos(n \pi x))" + + def __init__(self, mesh, B=2.302585092994046, n=3, m=2): + super().__init__(mesh) + + if int(n) != n or int(n) < 1: + raise ValueError("n (horizontal wavenumber) must be a positive integer.") + if int(m) != m or int(m) < 1: + raise ValueError("m (vertical wavenumber) must be a positive integer.") + + self.B = float(B) + self.n = int(n) + self.m = int(m) + + x, z = mesh.X + values = { + _B: sympy.Rational(self.B), + _N: self.n, + _KM: self.m * sympy.pi, + _X: x, + _Z: z, + } + kernel = { + field: expression.subs(values) + for field, expression in _solkz_kernel().items() + } + + self.fn_velocity = sympy.Matrix( + [[kernel["velocity_x"], kernel["velocity_z"]]] + ) + self.set_fields( + velocity=(kernel["velocity_x"], kernel["velocity_z"]), + pressure=kernel["pressure"], + viscosity=sympy.exp(2 * sympy.Rational(self.B) * z), + bodyforce=( + 0, + sympy.sin(self.m * sympy.pi * z) * sympy.cos(self.n * sympy.pi * x), + ), + stress=( + (kernel["stress_xx"], kernel["stress_zx"]), + (kernel["stress_zx"], kernel["stress_zz"]), + ), + ) + + +_SIGMA = sympy.Symbol("sigma") + +# SolA and SolB share a kernel shape, and the source states it outright: +# "u1 = Vz, u2 = Vx, u3 = tzz, u4 = tzx, pp = pressure", with txx alongside. +# Modes run in x, as in SolKz. The stress is the TOTAL: u3 and txx both carry an +# explicit `- pp`. +_SOLAB_OUTPUTS = { + "velocity_x": ("u2", sympy.sin), + "velocity_z": ("u1", sympy.cos), + "stress_xx": ("txx", sympy.cos), + "stress_zz": ("u3", sympy.cos), + "stress_zx": ("u4", sympy.sin), + "pressure": ("pp", sympy.cos), +} + + +@functools.lru_cache(maxsize=None) +def _solab_kernel(name): + """Transcribe SolA or SolB — one straight-line block, isoviscous.""" + + source = CSource(os.path.join(_REFERENCE_DIR, f"{name}.c")) + body = source.function(f"_Velic_{name}") + body = body[: body.index("e_zz =")] + + inputs = { + "pos": (_X, _Z), + "sigma": _SIGMA, + "Z": _ETA0, + "n": _N, + "km": _KM, + } + scope = evaluate_block(body, inputs) + + kn = _N * sympy.pi + return { + field: scope[symbol] * mode(kn * _X) + for field, (symbol, mode) in _SOLAB_OUTPUTS.items() + } + + +class _SolAB(FreeSlipWalls, AnalyticSolution): + """Shared assembly for the two isoviscous Velic solutions.""" + + dim = 2 + _kernel = None + _vertical = None + + #: Whether this kernel's published zz stress is missing the viscosity that + #: its xx stress carries. True for solA (solA.c:156) and false for solB, + #: whose matching line (solB.c:140) is correct. Declared per solution rather + #: than patched in place: the vendored sources stay verbatim. + _zz_stress_lost_the_viscosity = False + + def __init__(self, mesh, sigma=1.0, eta=1.0, n=3, m=2): + super().__init__(mesh) + + if int(n) != n or int(n) < 1: + raise ValueError("n (horizontal wavenumber) must be a positive integer.") + if float(eta) <= 0.0: + raise ValueError("eta must be positive.") + + self.sigma = float(sigma) + self.eta = float(eta) + self.n = int(n) + self.m = float(m) + + x, z = mesh.X + values = { + _SIGMA: sympy.Rational(self.sigma), + _ETA0: sympy.Rational(self.eta), + _N: self.n, + _KM: sympy.Rational(self.m) * sympy.pi, + _X: x, + _Z: z, + } + kernel = { + field: expression.subs(values) + for field, expression in _solab_kernel(self._kernel).items() + } + + # ERRATUM (solA.c:156). solA's published zz stress is + # `u3 = 2.0*kn*ss_z - pp`, where the matching line in solB.c:140 — and + # solA's OWN xx stress on the next line but one — carry a factor of the + # viscosity Z. The published sigma_zz is therefore short by + # tau_zz*(1-Z)/Z, which is identically zero at Z=1: the only case the + # file's own (disabled) driver exercises, and the default `eta` here. + # Left alone it survives every check made at the default and fails three + # of them at any other viscosity. + # + # Rather than edit a vendored source, the MISSING FACTOR is restored on + # the term that lost it: the published deviator is + # tau_zz = u3 + pp = 2*kn*ss_z, and the correct one is Z times that. + # + # Deriving sigma_zz from sigma_xx instead (tau_zz = -tau_xx) would also + # work and is tempting, but it would make the deviator TRACELESS BY + # CONSTRUCTION and so retire one of the three gates that caught this. + # Repairing the factor keeps tracelessness an independent statement + # about the repair, and it is the one the sweep checks. + if self._zz_stress_lost_the_viscosity: + stress_zz = ( + sympy.Rational(self.eta) * (kernel["stress_zz"] + kernel["pressure"]) + - kernel["pressure"] + ) + else: + stress_zz = kernel["stress_zz"] + + self.set_fields( + velocity=(kernel["velocity_x"], kernel["velocity_z"]), + pressure=kernel["pressure"], + viscosity=sympy.Rational(self.eta), + bodyforce=( + 0, + sympy.Rational(self.sigma) + * self._vertical(sympy.Rational(self.m) * sympy.pi * z) + * sympy.cos(self.n * sympy.pi * x), + ), + stress=( + (kernel["stress_xx"], kernel["stress_zx"]), + (kernel["stress_zx"], stress_zz), + ), + ) + + +class SolA(_SolAB): + r"""Isoviscous Stokes flow with a sinusoidal body force — the SolA benchmark. + + Constant viscosity on the unit box, free slip everywhere, forced by + :math:`\mathbf f = (0,\; \sigma\sin(m\pi z)\cos(n\pi x))`. + + The simplest solution in the suite, and useful precisely for that: it removes + the viscosity structure entirely, so a discrepancy here is in the + discretisation or the solve rather than in how a hard coefficient is handled. + Run it before concluding anything from SolCx or SolKx. + + .. note:: + **Erratum in the published kernel.** ``solA.c:156`` computes the $zz$ + stress without the factor of viscosity that its own $xx$ stress carries + and that ``solB.c:140`` carries — the published $\sigma_{zz}$ is short by + $\tau_{zz}(1-Z)/Z$. That is identically zero at $Z = 1$, which is the + default ``eta`` and the only value the kernel's own driver exercised, so + it is invisible unless you change the viscosity. The transcription + restores the factor; the vendored source is left verbatim. If you compare + our $\sigma_{zz}$ against the published kernel at ``eta != 1`` they will + disagree, and the kernel is the one that is wrong. + + Parameters + ---------- + mesh : Mesh + A 2D mesh on the unit box. + sigma : float + Forcing amplitude. + eta : float + The (constant) viscosity. + n : int + Horizontal wavenumber. + m : float + Vertical wavenumber; need not be an integer for the solution itself. + """ + + _kernel = "solA" + _vertical = staticmethod(sympy.sin) + _zz_stress_lost_the_viscosity = True + reference = ( + "Velic. Transcribed from the published kernel vendored at " + "underworld3/analytic/_reference/solA.c." + ) + eqn_viscosity = r"\eta" + eqn_bodyforce = r"(0,\; \sigma \sin(m \pi z)\cos(n \pi x))" + + +class SolB(_SolAB): + r"""Isoviscous Stokes flow with a hyperbolic body force — the SolB benchmark. + + As :class:`SolA`, but the forcing grows with depth as + :math:`\sinh(m\pi z)` rather than oscillating. The response is concentrated + near one boundary instead of filling the box, which is a different test of + the same machinery: it probes resolution where the solution is steep rather + than accuracy where it is smooth. + + Parameters + ---------- + mesh : Mesh + A 2D mesh on the unit box. + sigma : float + Forcing amplitude. + eta : float + The (constant) viscosity. + n : int + Horizontal wavenumber. + m : float + Vertical decay parameter. Must differ from *n* — the kernel is singular + when they coincide. + """ + + _kernel = "solB" + _vertical = staticmethod(sympy.sinh) + reference = ( + "Velic. Transcribed from the published kernel vendored at " + "underworld3/analytic/_reference/solB.c." + ) + eqn_viscosity = r"\eta" + eqn_bodyforce = r"(0,\; \sigma \sinh(m \pi z)\cos(n \pi x))" + + def __init__(self, mesh, sigma=1.0, eta=1.0, n=3, m=2.0): + if abs(float(n) - float(m)) < 1.0e-5: + raise ValueError( + "SolB is singular when the horizontal and vertical wavenumbers " + "coincide; choose n != m." + ) + super().__init__(mesh, sigma=sigma, eta=eta, n=n, m=m) + + +_KR = sympy.Symbol("kr") + + +@functools.lru_cache(maxsize=None) +def _solm_kernel(): + """Transcribe SolM. Short methods writing straight into the output array.""" + + source = CSource(os.path.join(_REFERENCE_DIR, "AnalyticSolM.hpp")) + + # km, kn and kr are initialised in the class body rather than in any method, + # so they are supplied rather than read. + inputs = {"in": (_X, _Z), "eta0": _ETA0, "km": _KM, "kn": _KN, "kr": _KR} + + def block(method): + return evaluate_block(source.function(method), inputs) + + velocity, bodyforce = block("velocity"), block("bodyforce") + stress, strainrate = block("stress"), block("strainrate") + + return { + "velocity_x": velocity["out[0]"], + "velocity_z": velocity["out[1]"], + "bodyforce_x": bodyforce["out[0]"], + "bodyforce_z": bodyforce["out[1]"], + "pressure": block("pressure")["p"], + "viscosity": block("viscosity")["out[0]"], + "stress_xx": stress["out[0]"], + "stress_zz": stress["out[1]"], + "stress_xz": stress["out[2]"], + "strainrate_xx": strainrate["out[0]"], + "strainrate_zz": strainrate["out[1]"], + "strainrate_xz": strainrate["out[2]"], + } + + +class SolM(FreeSlipWalls, AnalyticSolution): + r"""Stokes flow with a laterally oscillating viscosity — the SolM benchmark. + + Viscosity :math:`\eta = 1 + \eta_0(1 + \cos(r\pi x))` on the unit box, free + slip everywhere, with the body force chosen to make a simple sinusoidal + velocity exact. + + The viscosity here *oscillates* rather than jumping (SolCx) or varying + monotonically (SolKx, SolKz), and its wavelength is independent of the + flow's. That makes it the one solution in the suite where the coefficient + structure and the solution structure can be deliberately mismatched — set + ``r`` incommensurate with ``n`` and every element sees a different viscosity + profile, which is a sharper test of quadrature than a smooth gradient. + + Parameters + ---------- + mesh : Mesh + A 2D mesh on the unit box. + eta_0 : float + Amplitude of the viscosity oscillation. + n : int + Horizontal wavenumber of the flow. + m : int + Vertical wavenumber of the flow. + r : float + Wavenumber of the viscosity oscillation. Need not be an integer, and is + most interesting when it is not commensurate with *n*. + + Notes + ----- + The kernel names its wavenumbers the other way round — its ``m`` multiplies + :math:`x` and its ``n`` multiplies :math:`z`. The parameters here follow the + convention used across this suite, ``n`` horizontal and ``m`` vertical, and + the mapping is done at construction. + """ + + dim = 2 + stress_is_deviatoric = True + reference = ( + "Velic. Transcribed from the published kernel vendored at " + "underworld3/analytic/_reference/AnalyticSolM.hpp." + ) + eqn_velocity = r"(-\sin(n\pi x)\,m\pi\cos(m\pi z),\; \cos(n\pi x)\,n\pi\sin(m\pi z))" + eqn_viscosity = r"1 + \eta_0\,(1 + \cos(r \pi x))" + + def __init__(self, mesh, eta_0=1.0, n=3, m=2, r=4.0): + super().__init__(mesh) + + if int(n) != n or int(n) < 1: + raise ValueError("n (horizontal wavenumber) must be a positive integer.") + if int(m) != m or int(m) < 1: + # Free slip on the horizontal walls needs sin(m pi z) to vanish at + # z = 1, exactly as for SolKx. + raise ValueError("m (vertical wavenumber) must be a positive integer.") + + self.eta_0 = float(eta_0) + self.n = int(n) + self.m = int(m) + self.r = float(r) + + x, z = mesh.X + values = { + _ETA0: sympy.Rational(self.eta_0), + _KM: self.n * sympy.pi, # the kernel's km multiplies x + _KN: self.m * sympy.pi, # and its kn multiplies z + _KR: sympy.Rational(self.r) * sympy.pi, + _X: x, + _Z: z, + } + kernel = { + field: expression.subs(values) + for field, expression in _solm_kernel().items() + } + + self.set_fields( + velocity=(kernel["velocity_x"], kernel["velocity_z"]), + pressure=kernel["pressure"], + viscosity=kernel["viscosity"], + bodyforce=(kernel["bodyforce_x"], kernel["bodyforce_z"]), + # The stress is DERIVED from the strain rate here rather than taken + # from the kernel, because the kernel's is wrong. Its viscosity is + # (1 + cos(kr x)) eta0 + 1, but its stress is 2 (eta - 1) edot: the + # constant part is missing. Measured, not guessed — the difference + # from 2 (eta - 1) edot is exactly zero, and using the published + # stress leaves the momentum residual at 0.21 where deriving it gives + # 1.7e-16. Everything else the kernel publishes is mutually + # consistent; only this output is defective. + strainrate=( + (kernel["strainrate_xx"], kernel["strainrate_xz"]), + (kernel["strainrate_xz"], kernel["strainrate_zz"]), + ), + ) + + +_XC_C = sympy.Symbol("xc_solc") + +# SolC accumulates over modes; the source labels each in the loop tail. Same +# transposed convention as SolA and SolKz — u1 is the vertical velocity. +_SOLC_OUTPUTS = { + "velocity_x": ("u2", sympy.sin), + "velocity_z": ("u1", sympy.cos), + "stress_xx": ("u6", sympy.cos), + "stress_zz": ("u3", sympy.cos), + "stress_zx": ("u4", sympy.sin), + "pressure": ("u5", sympy.cos), +} + + +@functools.lru_cache(maxsize=None) +def _solc_kernel(modes): + r"""Transcribe the Velic SolC kernel, summing its Fourier series. + + Unlike the others this one is a truncated series: a step in density, resolved + as ``modes`` cosine terms. The loop body is evaluated once per mode with the + index bound to an integer, and the results summed in SymPy. + + The series is also why the body force here is the *truncated* step rather + than an exact one. The kernel accumulates the density it actually used, and + that is what the solution solves exactly; comparing against a sharp step + instead would report the truncation error as though it were a defect. + + Returns + ------- + dict + Field name -> expression, including ``bodyforce_z`` for the resolved step. + """ + + source = CSource(os.path.join(_REFERENCE_DIR, "solC.c")) + body = source.function("_Velic_solC") + loop = CSource.loop_body(body, "for(n=1;n tens of thousands of operations per expression + expensive_to_validate = True + reference = ( + "Velic. Transcribed from the published kernel vendored at " + "underworld3/analytic/_reference/solC.c." + ) + eqn_viscosity = r"\eta" + eqn_bodyforce = r"(0,\; \sigma \;\text{for}\; x < x_c,\; 0 \;\text{beyond})" + + def __init__(self, mesh, sigma=1.0, eta=1.0, x_c=0.5, modes=40): + super().__init__(mesh) + + if float(eta) <= 0.0: + raise ValueError("eta must be positive.") + if not 0.0 < float(x_c) < 1.0: + raise ValueError("x_c must lie strictly inside (0, 1).") + if int(modes) != modes or int(modes) < 2: + raise ValueError("modes must be an integer of at least 2.") + + self.sigma = float(sigma) + self.eta = float(eta) + self.x_c = float(x_c) + self.modes = int(modes) + + x, z = mesh.X + values = { + _SIGMA: sympy.Rational(self.sigma), + _ETA0: sympy.Rational(self.eta), + _XC_C: sympy.Rational(self.x_c), + _X: x, + _Z: z, + } + kernel = { + field: expression.subs(values) + for field, expression in _solc_kernel(self.modes).items() + } + + self.set_fields( + velocity=(kernel["velocity_x"], kernel["velocity_z"]), + pressure=kernel["pressure"], + viscosity=sympy.Rational(self.eta), + # The body force is MINUS the density. Every other kernel in this + # family negates internally — they write `rho = -sigma*sin*cos` and + # force with `+sigma*sin*cos` — but SolC accumulates the density + # itself, so the negation happens here. Measured: as summed it leaves + # the momentum residual at 1.8, negated at 1.6e-16. + bodyforce=(0, -kernel["bodyforce_z"]), + stress=( + (kernel["stress_xx"], kernel["stress_zx"]), + (kernel["stress_zx"], kernel["stress_zz"]), + ), + ) + + +_ZC, _DX, _X0 = sympy.symbols("zc dx x0") + +# SolDA uses the transposed convention of SolA and SolC: u1 is the vertical +# velocity and the modes run in x. Its pp and txx already carry their mode. +_SOLDA_MODED = { + "velocity_x": ("u2", sympy.sin), + "velocity_z": ("u1", sympy.cos), + "stress_zz": ("u3", sympy.cos), + "stress_zx": ("u4", sympy.sin), +} + + +@functools.lru_cache(maxsize=None) +def _solda_kernel(modes): + r"""Transcribe the Velic SolDA kernel. + + The hardest of the family to read, and the only one combining every feature + the others have separately: a truncated series (as SolC), a viscosity jump + (as SolCx), and a rectangular forcing. + + Both of its ``if (z < zc)`` blocks branch on the same condition, so each mode + is evaluated along one side and then the other and the two are combined into + a single :class:`sympy.Piecewise` — the SolCx pattern, applied per mode. + + Returns + ------- + dict + Field name -> expression, including ``bodyforce_z`` for the resolved + column. + """ + + source = CSource(os.path.join(_REFERENCE_DIR, "solDA.c")) + body = source.function("_Velic_solDA") + + header = "for(n=1;n tens of thousands of operations per expression + expensive_to_validate = True + reference = ( + "Velic. Transcribed from the published kernel vendored at " + "underworld3/analytic/_reference/solDA.c." + ) + eqn_viscosity = r"\eta_A \;(z 0.0 and float(eta_B) > 0.0): + raise ValueError("eta_A and eta_B must be positive.") + if not 0.0 < float(z_c) < 1.0: + raise ValueError("z_c must lie strictly inside (0, 1).") + if int(modes) != modes or int(modes) < 2: + raise ValueError("modes must be an integer of at least 2.") + + self.sigma = float(sigma) + self.eta_A = float(eta_A) + self.eta_B = float(eta_B) + self.z_c = float(z_c) + self.dx = float(dx) + self.x_0 = float(x_0) + self.modes = int(modes) + + x, z = mesh.X + values = { + _SIGMA: sympy.Rational(self.sigma), + _ZA: sympy.Rational(self.eta_A), + _ZB: sympy.Rational(self.eta_B), + _ZC: sympy.Rational(self.z_c), + _DX: sympy.Rational(self.dx), + _X0: sympy.Rational(self.x_0), + _X: x, + _Z: z, + } + kernel = { + field: expression.subs(values) + for field, expression in _solda_kernel(self.modes).items() + } + + self.set_fields( + velocity=(kernel["velocity_x"], kernel["velocity_z"]), + pressure=kernel["pressure"], + viscosity=sympy.Piecewise( + (sympy.Rational(self.eta_A), z < self.z_c), + (sympy.Rational(self.eta_B), True), + ), + # Minus the density, as for SolC: this kernel accumulates rho itself. + bodyforce=(0, -kernel["bodyforce_z"]), + stress=( + (kernel["stress_xx"], kernel["stress_zx"]), + (kernel["stress_zx"], kernel["stress_zz"]), + ), + ) + + +_DY = sympy.Symbol("dy") + +# SolH's output section, which transposes like SolKz's: vel[0]=sum3, [1]=sum2, +# [2]=sum1, and the stress is laid out xx, yy, zz, xy, xz, yz. +_SOLH_MODED = { + "velocity_z": ("u1", (sympy.cos, sympy.cos)), + "velocity_y": ("u2", (sympy.cos, sympy.sin)), + "velocity_x": ("u3", (sympy.sin, sympy.cos)), + "stress_zz": ("u4", (sympy.cos, sympy.cos)), + "stress_yz": ("u5", (sympy.cos, sympy.sin)), + "stress_xz": ("u6", (sympy.sin, sympy.cos)), +} + + +@functools.lru_cache(maxsize=None) +def _solh_kernel(modes): + r"""Transcribe the Velic SolH kernel — 3D, and a double series. + + Two nested mode loops, so the term count goes as ``modes**2``. The published + kernel warns that SolH is expensive to *evaluate*, which it is: a compiled + version sums every mode at every point. Transcribed it is the opposite — + each mode contributes about ninety operations, the cheapest in the family, + and the sum is built once. + + Its guards (``if (m != 0)`` and friends) are on the loop indices, so they are + resolved per mode rather than becoming ``Piecewise``. + """ + + source = CSource(os.path.join(_REFERENCE_DIR, "solH.c")) + body = source.function("_Velic_solH") + + outer = CSource.loop_body(body, "for(n=0;n tens of thousands of operations per expression + expensive_to_validate = True + reference = ( + "Velic. Transcribed from the published kernel vendored at " + "underworld3/analytic/_reference/solH.c." + ) + eqn_viscosity = r"\eta" + eqn_bodyforce = r"(0,\;0,\; \sigma \;\text{in a block of extent } dx \times dy)" + + def __init__(self, mesh, sigma=1.0, eta=1.0, dx=0.5, dy=0.5, modes=6): + super().__init__(mesh) + + if float(eta) <= 0.0: + raise ValueError("eta must be positive.") + if int(modes) != modes or int(modes) < 2: + raise ValueError("modes must be an integer of at least 2.") + + self.sigma = float(sigma) + self.eta = float(eta) + self.dx = float(dx) + self.dy = float(dy) + self.modes = int(modes) + + x, y, z = mesh.X + values = { + _SIGMA: sympy.Rational(self.sigma), + _ETA0: sympy.Rational(self.eta), + _DX: sympy.Rational(self.dx), + _DY: sympy.Rational(self.dy), + _X: x, + _Y: y, + _Z: z, + } + kernel = { + field: expression.subs(values) + for field, expression in _solh_kernel(self.modes).items() + } + + self.set_fields( + velocity=( + kernel["velocity_x"], + kernel["velocity_y"], + kernel["velocity_z"], + ), + pressure=kernel["pressure"], + viscosity=sympy.Rational(self.eta), + bodyforce=(0, 0, -kernel["bodyforce_z"]), + stress=( + (kernel["stress_xx"], kernel["stress_xy"], kernel["stress_xz"]), + (kernel["stress_xy"], kernel["stress_yy"], kernel["stress_yz"]), + (kernel["stress_xz"], kernel["stress_yz"], kernel["stress_zz"]), + ), + ) diff --git a/src/underworld3/discretisation/discretisation_mesh_variables.py b/src/underworld3/discretisation/discretisation_mesh_variables.py index e4e563c9a..9bbcabbcf 100644 --- a/src/underworld3/discretisation/discretisation_mesh_variables.py +++ b/src/underworld3/discretisation/discretisation_mesh_variables.py @@ -593,7 +593,12 @@ def clone(self, name, varsymbol): MeshVariable New mesh variable with copied structure but independent data. """ - newMeshVariable = MeshVariable( + # Built through the public factory rather than a bare `MeshVariable`, + # which is not a name in this module — the clone therefore came back as + # a NameError for every caller. See issue #498. Going through the + # factory also returns the same enhanced type the caller started with, + # so a clone behaves like its original. + return uw.discretisation.MeshVariable( varname=name, mesh=self.mesh, num_components=self.shape, @@ -603,8 +608,6 @@ def clone(self, name, varsymbol): varsymbol=varsymbol, ) - return newMeshVariable - def pack_raw_data_to_petsc(self, data_array, sync=True): """ Pack data array to PETSc using traditional data shape (-1, num_components). diff --git a/src/underworld3/discretisation/enhanced_variables.py b/src/underworld3/discretisation/enhanced_variables.py index 32cc6f64e..4ff8861ce 100644 --- a/src/underworld3/discretisation/enhanced_variables.py +++ b/src/underworld3/discretisation/enhanced_variables.py @@ -481,16 +481,21 @@ def clone(self, name, varsymbol): ------- EnhancedMeshVariable New variable with copied structure but independent data. + + Notes + ----- + This forwarded no arguments at all until issue #498, so it raised + whichever way it was called: with the two arguments every caller uses, + and without them inside the base. Six shipped examples aborted on it. + + The construction itself lives in ``_BaseMeshVariable.clone``, which + goes through the public ``uw.discretisation.MeshVariable`` factory and + so returns the enhanced type. Delegating keeps a single code path; + duplicating the constructor call here is what let the two halves of + #498 drift apart in the first place. """ - return type(self)( - varname=name, - mesh=self._base_var.mesh, - num_components=self._base_var.shape, - vtype=self._base_var.vtype, - degree=self._base_var.degree, - continuous=self._base_var.continuous, - varsymbol=varsymbol, - ) + + return self._base_var.clone(name, varsymbol) def max(self): """Maximum value of the variable.""" diff --git a/src/underworld3/function/__init__.py b/src/underworld3/function/__init__.py index 2f39ff2c7..59252026a 100644 --- a/src/underworld3/function/__init__.py +++ b/src/underworld3/function/__init__.py @@ -24,8 +24,6 @@ underworld3.discretisation : Mesh and variable classes. underworld3.swarm : Particle swarm evaluation targets. """ -from . import analytic - # Import the _function module to expose it in the namespace (needed by expressions.py) from . import _function from ._function import ( @@ -234,3 +232,23 @@ def derivative(expression, variable, evaluate=True): derivative[i, j] = _derivative_expression(latex, expression, variable[i, j]) return derivative + + +def __getattr__(name): + """Keep ``uw.function.analytic`` reachable by attribute after the move. + + The analytic suite now lives in :mod:`underworld3.analytic`; what remains + here is a deprecation shim. Importing it eagerly would pull the whole + analytic package in as a side effect of ``import underworld3.function``, so + it is resolved on first use instead. + """ + + if name == "analytic": + # import_module, not `from . import analytic`: the latter resolves the + # submodule by calling getattr() on this module, which lands back here. + # import_module also binds the result as an attribute, so this runs once. + import importlib + + return importlib.import_module(f"{__name__}.analytic") + + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/underworld3/function/analytic/__init__.py b/src/underworld3/function/analytic/__init__.py new file mode 100644 index 000000000..6835c6639 --- /dev/null +++ b/src/underworld3/function/analytic/__init__.py @@ -0,0 +1,81 @@ +r"""Deprecated location for the analytic solutions — use :mod:`underworld3.analytic`. + +The suite moved out of ``underworld3.function`` because it outgrew it: it now +carries boundary conditions, error norms and a registry, none of which belong +under "symbolic function evaluation". Everything here resolves to the *same +objects* as the new location, so ``isinstance`` and pickling work across both +paths. + +.. deprecated:: + Import from :mod:`underworld3.analytic` instead:: + + sol = uw.analytic.SolCx(mesh, eta_A=1.0, eta_B=1.0e6) + +Notes +----- +This is a package directory rather than a module file on purpose. The old +``underworld3.function.analytic`` was a compiled extension, and an orphaned +``.so`` left behind by an earlier install would otherwise be imported in place +of this shim — Python's path finder checks for a package directory *before* it +checks for an extension of the same name, so the directory always wins. +""" + +import warnings + +__all__ = [ + "AnalyticSolCx_base", + "AnalyticSolCx_pressure", + "AnalyticSolCx_stress_xx", + "AnalyticSolCx_stress_xy", + "AnalyticSolCx_stress_yy", + "AnalyticSolCx_velocity", + "AnalyticSolCx_velocity_x", + "AnalyticSolCx_velocity_y", + "AnalyticSolCx_viscosity", + "AnalyticSolNL_base", + "AnalyticSolNL_bodyforce", + "AnalyticSolNL_bodyforce_x", + "AnalyticSolNL_bodyforce_y", + "AnalyticSolNL_velocity", + "AnalyticSolNL_velocity_x", + "AnalyticSolNL_velocity_y", + "AnalyticSolNL_viscosity", + "SolCx", + "sympy_function_printable", +] + +# Names are resolved through __getattr__ rather than imported here, so that the +# deprecation warning fires when one is *used*. Ten test files import this +# module at collection time; a warning there is noise the reader cannot act on. +_warned = False + + +def __getattr__(name): + global _warned + + if name not in __all__: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + if not _warned: + warnings.warn( + "underworld3.function.analytic has moved to underworld3.analytic; " + "import from there instead (uw.analytic.SolCx, ...).", + DeprecationWarning, + stacklevel=2, + ) + _warned = True + + if name == "SolCx": + # The one class, not a second copy: resolving it anywhere else would + # break isinstance for objects built through the other path. + from underworld3.analytic import SolCx + + return SolCx + + from underworld3.analytic._reference import _velic + + return getattr(_velic, name) + + +def __dir__(): + return sorted(__all__) diff --git a/src/underworld3/utilities/retention_curves.py b/src/underworld3/utilities/retention_curves.py index af5cc8134..5659439e1 100644 --- a/src/underworld3/utilities/retention_curves.py +++ b/src/underworld3/utilities/retention_curves.py @@ -403,16 +403,27 @@ def gardner_steady_state_psi(y, psi_0, psi_L, L, alpha): ----- This is a *numpy* function (not sympy) intended for comparing numerical solutions against the analytical benchmark. + + The formula lives in :func:`underworld3.analytic.richards. + gardner_steady_saturation` and is evaluated here; the mesh-based + :class:`underworld3.analytic.GardnerSteady` builds the same expression, so + the two cannot drift apart. That class is the better entry point when you + have a mesh — it carries the conductivity and the boundary conditions as + well, and it is covered by the residual check. """ import numpy as np + import sympy + + from underworld3.analytic.richards import gardner_steady_saturation - u_0 = np.exp(alpha * psi_0) - u_L = np.exp(alpha * psi_L) + coordinate = sympy.Symbol("y") + saturation, _ = gardner_steady_saturation(coordinate, psi_0, psi_L, L, alpha) - # Normalised steady-state flux q* = q / Ks - q_star = (u_L - u_0 * np.exp(-alpha * L)) / (1.0 - np.exp(-alpha * L)) + u = sympy.lambdify(coordinate, saturation, ["scipy", "numpy"])( + np.asarray(y, dtype=float) + ) - return (1.0 / alpha) * np.log((u_0 - q_star) * np.exp(-alpha * y) + q_star) + return (1.0 / alpha) * np.log(u) def gardner_transient_psi(y, t, psi_dry, psi_wet, L, Ks, alpha, theta_r, theta_s): @@ -489,6 +500,11 @@ def gardner_transient_psi(y, t, psi_dry, psi_wet, L, Ks, alpha, theta_r, theta_s The semi-infinite approximation is excellent when the wetting front has not yet reached the bottom boundary. + The formula lives in :func:`underworld3.analytic.richards. + gardner_transient_saturation`; :class:`underworld3.analytic. + GardnerTransient` builds the same expression on a mesh and is the better + entry point when you have one. + References ---------- Ogata, A. and Banks, R. B. (1961). A solution of the differential @@ -496,27 +512,18 @@ def gardner_transient_psi(y, t, psi_dry, psi_wet, L, Ks, alpha, theta_r, theta_s *US Geological Survey Professional Paper* 411-A. """ import numpy as np - from scipy.special import erfc + import sympy - delta_theta = theta_s - theta_r - D = Ks / (alpha * delta_theta) - V = Ks / delta_theta + from underworld3.analytic.richards import gardner_transient_saturation - u_dry = np.exp(alpha * psi_dry) - u_wet = np.exp(alpha * psi_wet) - - # Depth from the top (z = 0 at top, z = L at bottom) - z = L - np.asarray(y, dtype=float) - - sqrt_Dt = np.sqrt(D * t) - - # Ogata-Banks solution - H = ( - 0.5 * erfc((z - V * t) / (2.0 * sqrt_Dt)) - + 0.5 * np.exp(V * z / D) * erfc((z + V * t) / (2.0 * sqrt_Dt)) + coordinate, elapsed = sympy.symbols("y t") + saturation, _, _ = gardner_transient_saturation( + coordinate, elapsed, psi_dry, psi_wet, L, Ks, alpha, theta_r, theta_s ) - u = u_dry + (u_wet - u_dry) * H + u = sympy.lambdify((coordinate, elapsed), saturation, ["scipy", "numpy"])( + np.asarray(y, dtype=float), float(t) + ) return (1.0 / alpha) * np.log(np.maximum(u, 1e-30)) diff --git a/tests/analytic_full/test_analytic_full_family.py b/tests/analytic_full/test_analytic_full_family.py new file mode 100644 index 000000000..e83c2daea --- /dev/null +++ b/tests/analytic_full/test_analytic_full_family.py @@ -0,0 +1,275 @@ +r"""The whole analytic family, every gate, every parameter case — the slow tier. + +**Run it with one command:** + +```bash +pixi run -e amr-dev python -m pytest tests/analytic_full/ -v +``` + +Nothing in CI runs this file, and that is deliberate rather than an oversight. +Run it before a release, and whenever you touch `underworld3/analytic/` or +`_validation.py`. + +Why it is separate +------------------ + +The per-PR sweep (`tests/test_1024_analytic_conformance.py` and +`tests/test_1028_analytic_parameter_sweep.py`) covers every solution that is +cheap to validate. Five are not: SolC accumulates over forty modes, SolDA and +SolH over several more, and SolKx and SolKz carry an exponential viscosity. Each +produces expressions with tens of thousands of operations, and every residual +gate differentiates them symbolically and runs common-subexpression elimination +over the result. + +Measured on a quiet machine, those five cost **565s of the analytic suite's +1010s**, against about 17s for the other eight together. CI was already within +five minutes of its 60-minute cap before this suite existed, so they cannot ride +on every PR. + +They declare the fact themselves — `AnalyticSolution.expensive_to_validate` — +so this file and the per-PR files partition the family from one source of truth +rather than from two lists that can drift. `test_1024` asserts that every +solution it skips is named here. + +**The split is by solutions per run, never by checks per solution.** Every gate +that runs in the per-PR tier runs here too, on every solution: the momentum +residual, incompressibility, tracelessness, strain-rate consistency, and the +body-force negative control. Those are the gates that caught SolA's missing +viscosity, SolM's wrong published stress, SolC's density-not-force and the +elliptical inclusion's unscaled pressure, and none of them is weakened here. + +Why file placement rather than a marker +--------------------------------------- + +`scripts/test.sh` batches by **file glob** (`tests/test_101*py`, +`tests/test_102*py`, ...), not by marker, so a `slow` marker alone would not keep +this out of CI — every batch line would have to remember to deselect it. Those +globs do not recurse, so a subdirectory is excluded by construction. The +`level_2` mark below additionally keeps it out of +`pytest -m "level_1 and tier_a" tests/`, which *does* recurse. +""" + +import pytest + +pytestmark = [pytest.mark.level_2, pytest.mark.tier_a, pytest.mark.slow] + +import numpy as np +import sympy +import underworld3 as uw +from underworld3.analytic import _validation + + +ALL_SYMBOLIC = sorted( + name + for name in uw.analytic.available() + if getattr(uw.analytic, name).symbolic and uw.analytic.is_available(name) +) + +STOKES = [n for n in ALL_SYMBOLIC if getattr(uw.analytic, n).solves == "stokes"] +TRANSPORT = [n for n in ALL_SYMBOLIC if getattr(uw.analytic, n).solves == "transport"] +RICHARDS = [n for n in ALL_SYMBOLIC if getattr(uw.analytic, n).solves == "richards"] + +# The five that the per-PR sweep drops. Named explicitly so `test_1024` can +# assert they are covered here, and so that reading this file tells you what it +# is for without having to evaluate a comprehension. +EXPENSIVE = ["SolC", "SolDA", "SolH", "SolKx", "SolKz"] + +# The complete parameter table. The cheap solutions' entries duplicate +# `test_1028`'s on purpose: this file is the full sweep, and it should not depend +# on which half of the family happens to be cheap this month. +SWEEP = { + "SolA": [{"eta": 3.0}, {"eta": 0.25}, {"eta": 3.0, "n": 2, "m": 1.5}], + "SolB": [{"eta": 3.0}, {"eta": 0.25}, {"eta": 3.0, "n": 2, "m": 1.5}], + "SolC": [{"eta": 3.0}, {"eta": 0.25, "x_c": 0.3}], + "SolCx": [{"eta_A": 3.0, "eta_B": 0.1}, {"eta_A": 1.0e4, "eta_B": 1.0, "x_c": 0.3}], + "SolDA": [{"eta_A": 2.0, "eta_B": 0.5}, {"sigma": 3.0, "z_c": 0.4}], + "SolDB2d": [], + "SolDB3d": [{"beta": 1.0}, {"beta": 8.0}], + "SolH": [{"eta": 3.0}, {"eta": 0.25, "dx": 0.3, "dy": 0.4}], + # exactly the cases test_1021 dropped from its per-PR CASES + "SolKx": [{"B": 1.0, "n": 2, "m": 1}, {"B": 4.0, "n": 1, "m": 3}, {"B": 5.0, "n": 2, "m": 2}], + # exactly the cases test_1023 dropped from its per-PR CASES + "SolKz": [{"B": 1.0, "n": 2, "m": 1}, {"B": 4.0, "n": 1, "m": 3}, {"B": 5.0, "n": 2, "m": 2}], + "SolM": [{"eta_0": 3.0}, {"eta_0": 0.5, "n": 2, "m": 1, "r": 3.0}], + "SolNL": [{"eta_0": 2.0}, {"r": 2.5}], + "EllipticalInclusion": [ + {"viscosity_ratio": 10.0, "aspect_ratio": 1.5}, + {"matrix_viscosity": 3.0}, + {"matrix_viscosity": 0.5, "viscosity_ratio": 100.0}, + ], +} + +# Defaults first, then every off-default case. +CASES = [(name, {}) for name in STOKES] + [ + (name, kw) for name in STOKES for kw in SWEEP.get(name, []) +] + + +def test_the_expensive_list_is_accurate(): + """`EXPENSIVE` is a hand-written list; check it against the declarations.""" + + declared = sorted( + n for n in ALL_SYMBOLIC if getattr(uw.analytic, n).expensive_to_validate + ) + assert declared == sorted(EXPENSIVE), ( + f"declared expensive_to_validate = {declared}, but EXPENSIVE says {EXPENSIVE}" + ) + + +def test_this_file_sweeps_the_whole_family(): + """Nothing is dropped from BOTH tiers.""" + + assert set(SWEEP) == set(STOKES), ( + f"unswept: {set(STOKES) - set(SWEEP)}; stale: {set(SWEEP) - set(STOKES)}" + ) + assert len(ALL_SYMBOLIC) >= 19, "the sweep has lost solutions" + + +@pytest.fixture(scope="module") +def meshes(): + return { + 2: uw.meshing.StructuredQuadBox( + elementRes=(4, 4), minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), qdegree=3 + ), + 3: uw.meshing.StructuredQuadBox( + elementRes=(2, 2, 2), + minCoords=(0.0, 0.0, 0.0), + maxCoords=(1.0, 1.0, 1.0), + qdegree=2, + ), + } + + +@pytest.fixture(scope="module") +def cache(): + """One construction per (solution, parameters), shared across the gates. + + Building a series solution substitutes parameters into expressions tens of + thousands of operations long — SolDA takes 12s and SolKz 6s. Four gates run + over every case here, so rebuilding per test would multiply the file's cost + by four for nothing. + """ + + return {} + + +def _built(cache, meshes, name, kw): + key = (name, tuple(sorted(kw.items()))) + if key not in cache: + cls = getattr(uw.analytic, name) + cache[key] = cls(meshes[cls.dim], **kw) + return cache[key] + + +IDS = [f"{n}-{kw}" for n, kw in CASES] + + +@pytest.mark.parametrize("name,kw", CASES, ids=IDS) +def test_momentum_balance(cache, meshes, name, kw): + r""":math:`\nabla\cdot\sigma + \mathbf f = 0`.""" + + sol = _built(cache, meshes, name, kw) + assert _validation.momentum_residual(sol, sol.sample_points(count=8)) < 1.0e-8 + + +@pytest.mark.parametrize("name,kw", CASES, ids=IDS) +def test_incompressible(cache, meshes, name, kw): + sol = _built(cache, meshes, name, kw) + assert _validation.incompressibility_residual(sol, sol.sample_points(count=8)) < 1.0e-8 + + +@pytest.mark.parametrize("name,kw", CASES, ids=IDS) +def test_deviator_is_traceless(cache, meshes, name, kw): + r""":math:`\mathrm{tr}(\sigma + p\mathbf I) = 0`. + + Independent of the body force, which is what made it the gate that localised + the SolA erratum. + """ + + sol = _built(cache, meshes, name, kw) + points = sol.sample_points(count=8) + deviator = sol.fn_stress + sol.fn_pressure * sympy.eye(sol.dim) + + sampled = [ + _validation.sample(sol, deviator[i, i], points) for i in range(sol.dim) + ] + scale = max(float(np.abs(v).max()) for v in sampled) + + assert float(np.abs(sum(sampled)).max()) / max(scale, 1e-300) < 1.0e-8 + + +@pytest.mark.parametrize("name,kw", CASES, ids=IDS) +def test_strain_rate_matches_the_velocity(cache, meshes, name, kw): + r""":math:`\tfrac12(\nabla\mathbf u + \nabla\mathbf u^{T})` vs `fn_strainrate`.""" + + sol = _built(cache, meshes, name, kw) + assert _validation.strainrate_consistency(sol, sol.sample_points(count=8)) < 1.0e-8 + + +FORCED = [n for n in STOKES if n != "EllipticalInclusion"] + + +@pytest.mark.parametrize("name", FORCED) +def test_flipping_the_body_force_breaks_the_momentum_balance(cache, meshes, name): + r"""The momentum gate must REJECT :math:`\nabla\cdot\sigma - \mathbf f = 0`. + + The negative control, run here over the whole family. Without it the momentum + gate is only an assertion that a small number is small. + + EllipticalInclusion is excluded because it is boundary-driven and has no body + force to flip — asserted below rather than skipped silently. + """ + + sol = _built(cache, meshes, name, {}) + points = sol.sample_points(count=8) + + assert _validation.momentum_residual(sol, points) < 1.0e-8 + + keep = sol.fn_bodyforce + sol.fn_bodyforce = sympy.Matrix([[-c for c in keep]]) + try: + flipped = _validation.momentum_residual(sol, points) + finally: + sol.fn_bodyforce = keep + + assert flipped > 1.0e-2, ( + f"{name}: flipping the body force left the momentum residual at " + f"{flipped:.3e} — the gate cannot see the sign it is supposed to certify" + ) + + +def test_the_inclusion_really_has_no_body_force(cache, meshes): + """The stated grounds for excluding it from the negative control above.""" + + sol = _built(cache, meshes, "EllipticalInclusion", {}) + assert all(component == 0 for component in sol.fn_bodyforce) + + +@pytest.mark.parametrize("name", TRANSPORT) +def test_transport_solution_satisfies_its_equation(cache, meshes, name): + """Steady or transient, whichever the solution declares — as in `test_1024`.""" + + sol = _built(cache, meshes, name, {}) + points = sol.sample_points(count=8) + + if getattr(sol, "t", None) is None: + assert _validation.transport_residual(sol, points) < 1.0e-10 + return + + for time in (0.05, 0.2, 0.5): + assert _validation.diffusion_residual(sol, points, time) < 1.0e-10 + + +@pytest.mark.parametrize("name", RICHARDS) +def test_richards_solution_satisfies_its_equation(cache, meshes, name): + r""":math:`C(\psi)\partial_t\psi = \nabla\cdot[K(\psi)(\nabla\psi + \hat y)]`.""" + + sol = _built(cache, meshes, name, {}) + points = sol.sample_points(count=8) + + if getattr(sol, "t", None) is None: + assert _validation.richards_residual(sol, points) < 1.0e-10 + return + + for time in (0.05, 0.2): + assert _validation.richards_residual(sol, points, time) < 1.0e-10 diff --git a/tests/parallel/test_1017_custom_mg_parallel_mpi.py b/tests/parallel/test_1017_custom_mg_parallel_mpi.py index db031f918..9ae6e8d43 100644 --- a/tests/parallel/test_1017_custom_mg_parallel_mpi.py +++ b/tests/parallel/test_1017_custom_mg_parallel_mpi.py @@ -13,7 +13,7 @@ import numpy as np import pytest import underworld3 as uw -from underworld3.function import analytic as A +from underworld3 import analytic as A from underworld3.utilities import custom_mg pytestmark = [pytest.mark.mpi(min_size=2), pytest.mark.timeout(180)] diff --git a/tests/parallel/test_1064_rotated_freeslip_parallel.py b/tests/parallel/test_1064_rotated_freeslip_parallel.py index aeed50d4d..7df40f341 100644 --- a/tests/parallel/test_1064_rotated_freeslip_parallel.py +++ b/tests/parallel/test_1064_rotated_freeslip_parallel.py @@ -54,7 +54,7 @@ import pytest import underworld3 as uw -from underworld3.function import analytic as A +from underworld3 import analytic as A from underworld3.utilities import custom_mg from serial_reference import ( diff --git a/tests/test_0301_meshvariable_clone.py b/tests/test_0301_meshvariable_clone.py new file mode 100644 index 000000000..0a63fb33f --- /dev/null +++ b/tests/test_0301_meshvariable_clone.py @@ -0,0 +1,73 @@ +r"""MeshVariable.clone — issue #498. + +`EnhancedMeshVariable.clone` forwarded no arguments to a base that requires two, +so it raised whichever way it was called. Six shipped examples aborted on that +line and no test touched it. + +Run: pixi run python -m pytest tests/test_0301_meshvariable_clone.py -v +""" + +import pytest + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + +import numpy as np +import underworld3 as uw + + +@pytest.fixture(scope="module") +def mesh(): + return uw.meshing.StructuredQuadBox(elementRes=(4, 4), qdegree=2) + + +@pytest.mark.parametrize("degree", [1, 2]) +@pytest.mark.parametrize("vtype", ["scalar", "vector"]) +def test_clone_copies_the_structure(mesh, degree, vtype): + if vtype == "scalar": + original = uw.discretisation.MeshVariable( + f"s{degree}", mesh, 1, degree=degree + ) + else: + original = uw.discretisation.MeshVariable( + f"v{degree}", mesh, mesh.dim, degree=degree + ) + + copy = original.clone(f"{vtype}{degree}_copy", r"{c}") + + assert copy.shape == original.shape + assert copy.degree == original.degree + assert copy.continuous == original.continuous + assert copy.vtype == original.vtype + assert copy.data.shape == original.data.shape + + +def test_clone_is_independent(mesh): + """A clone shares structure, not storage.""" + + original = uw.discretisation.MeshVariable("orig", mesh, 1, degree=2) + original.data[...] = 3.0 + + copy = original.clone("orig_copy", r"{o_c}") + copy.data[...] = 7.0 + + assert np.allclose(original.data, 3.0), "writing the clone changed the original" + assert np.allclose(copy.data, 7.0) + + +def test_clone_takes_the_name_and_symbol_it_is_given(mesh): + """The two arguments the wrapper used to swallow.""" + + original = uw.discretisation.MeshVariable("named", mesh, 1, degree=1) + copy = original.clone("renamed", r"{q_0}") + + assert copy.name != original.name + assert "renamed" in copy.name + + +def test_clone_requires_both_arguments(mesh): + """It is not an optional-argument API, and should not silently look like one.""" + + original = uw.discretisation.MeshVariable("needsargs", mesh, 1, degree=1) + + with pytest.raises(TypeError): + original.clone() diff --git a/tests/test_0835_sbr_adapt_on_top.py b/tests/test_0835_sbr_adapt_on_top.py index 4308c9d9e..d3e07694e 100644 --- a/tests/test_0835_sbr_adapt_on_top.py +++ b/tests/test_0835_sbr_adapt_on_top.py @@ -25,7 +25,7 @@ import pytest import sympy import underworld3 as uw -from underworld3.function import analytic as A +from underworld3 import analytic as A pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] diff --git a/tests/test_0836_nvb_graded_adapt.py b/tests/test_0836_nvb_graded_adapt.py index 59cf0c81f..7042e36b7 100644 --- a/tests/test_0836_nvb_graded_adapt.py +++ b/tests/test_0836_nvb_graded_adapt.py @@ -25,7 +25,7 @@ import pytest import sympy import underworld3 as uw -from underworld3.function import analytic as A +from underworld3 import analytic as A from _mg_ladder import assert_coarsening_ladder from underworld3.utilities.nvb import NVBMesh diff --git a/tests/test_0839_nvb_parallel_adapt.py b/tests/test_0839_nvb_parallel_adapt.py index 3d19cfd22..31e9a34f9 100644 --- a/tests/test_0839_nvb_parallel_adapt.py +++ b/tests/test_0839_nvb_parallel_adapt.py @@ -22,7 +22,7 @@ import pytest import sympy import underworld3 as uw -from underworld3.function import analytic as A +from underworld3 import analytic as A from petsc4py import PETSc pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] diff --git a/tests/test_1000_poissonCart.py b/tests/test_1000_poissonCart.py index fa2386310..b600fb880 100644 --- a/tests/test_1000_poissonCart.py +++ b/tests/test_1000_poissonCart.py @@ -289,8 +289,11 @@ def test_poisson_sinusoidal_source(): poisson.constitutive_model = uw.constitutive_models.DiffusionModel poisson.constitutive_model.Parameters.diffusivity = 1 - # Symbolic source term: f = π²sin(πy) so that ∇²u = -π²sin(πy) has solution u = sin(πy) - poisson.f = sympy.pi**2 * sympy.sin(sympy.pi * y) + # The source and the solution come from the same object, so they cannot + # disagree: uw.analytic.Poisson1D carries f = pi^2 sin(pi y) alongside + # u = sin(pi y), and is checked against div(k grad u) + f = 0. + exact = uw.analytic.Poisson1D(mesh, source="sinusoid") + poisson.f = exact.fn_source # BCs: u(y=0) = 0, u(y=1) = 0 (consistent with sin(πy)) poisson.add_dirichlet_bc(0.0, "Bottom") @@ -306,7 +309,7 @@ def test_poisson_sinusoidal_source(): # Analytical solution: u(y) = sin(πy) u_numerical = uw.function.evaluate(u.sym[0], sample_points, rbf=False).squeeze() - u_analytical = np.sin(np.pi * sample_y) + u_analytical = uw.function.evaluate(exact.fn_solution, sample_points).squeeze() error = np.sqrt(np.mean((u_numerical - u_analytical) ** 2)) diff --git a/tests/test_1004_DarcyCartesian.py b/tests/test_1004_DarcyCartesian.py index 47991f136..4a566ebd6 100644 --- a/tests/test_1004_DarcyCartesian.py +++ b/tests/test_1004_DarcyCartesian.py @@ -117,20 +117,18 @@ def test_Darcy_boxmesh_G_and_noG(mesh): pressure_interp = uw.function.evaluate(p_soln.sym[0], xy_coords).squeeze() # #### Get analytical solution - La = -1.0 * interfaceY - Lb = 1.0 + interfaceY - dP = max_pressure - - S = 0 - Pa = (dP / Lb - S + k1 / k2 * S) / (1.0 / Lb + k1 / k2 / La) - pressure_analytic_noG = np.piecewise( - ycoords, - [ycoords >= -La, ycoords < -La], - [ - lambda ycoords: -Pa * ycoords / La, - lambda ycoords: Pa + (dP - Pa) * (-ycoords - La) / Lb, - ], + # + # Note k1 is the *upper* layer here (y >= interfaceY), so it is passed as + # k_upper. uw.analytic.TwoLayerDarcy derives the profile from constant flux + # rather than carrying the closed form this test used to write out; the two + # agree to 1e-16 in both gravity cases. + exact_noG = uw.analytic.TwoLayerDarcy( + mesh, k_lower=k2, k_upper=k1, interface=interfaceY, + pressure_drop=max_pressure, gravity=0.0, bottom=minY, top=maxY, ) + pressure_analytic_noG = uw.function.evaluate( + exact_noG.fn_solution, xy_coords + ).squeeze() print(pressure_interp) print(pressure_analytic_noG) @@ -142,16 +140,11 @@ def test_Darcy_boxmesh_G_and_noG(mesh): ## Suggest we re-solve right here for version with G to avoid all the re-definitions - S = 1 - Pa = (dP / Lb - S + k1 / k2 * S) / (1.0 / Lb + k1 / k2 / La) - pressure_analytic = np.piecewise( - ycoords, - [ycoords >= -La, ycoords < -La], - [ - lambda ycoords: -Pa * ycoords / La, - lambda ycoords: Pa + (dP - Pa) * (-ycoords - La) / Lb, - ], + exact = uw.analytic.TwoLayerDarcy( + mesh, k_lower=k2, k_upper=k1, interface=interfaceY, + pressure_drop=max_pressure, gravity=1.0, bottom=minY, top=maxY, ) + pressure_analytic = uw.function.evaluate(exact.fn_solution, xy_coords).squeeze() darcy.constitutive_model.Parameters.s = sympy.Matrix( [0, -1] diff --git a/tests/test_1005_TransientDarcyCartesian.py b/tests/test_1005_TransientDarcyCartesian.py index d7ca122f6..dadd7d55e 100644 --- a/tests/test_1005_TransientDarcyCartesian.py +++ b/tests/test_1005_TransientDarcyCartesian.py @@ -49,12 +49,13 @@ def create_mesh(): ) -# Analytical solution: step-change diffusion in a semi-infinite column -# h(y,t) = erfc(y / (2 sqrt(D t))) where D = K/Ss -# with h(0,t) = 1, h(inf,t) = 0 -y_sym, t_sym = sp.symbols("y t", positive=True) +# Analytical solution: step-change diffusion in a semi-infinite column, +# h(y,t) = erfc(y / (2 sqrt(D t))) with h(0,t) = 1 and h(inf,t) = 0. +# +# This used to be written out here. It is now uw.analytic.ErfcDiffusion, which +# is the same expression checked against du/dt = div(k grad u) — the profile +# below was never verified to solve anything. D_val = K_val / Ss_val -h_analytic = sp.erfc(y_sym / (2 * sp.sqrt(D_val * t_sym))) def test_transient_darcy_diffusion(): @@ -83,9 +84,10 @@ def test_transient_darcy_diffusion(): darcy._v_projector.petsc_options["snes_rtol"] = 1.0e-6 darcy._v_projector.smoothing = 1.0e-6 + exact = uw.analytic.ErfcDiffusion(mesh, diffusivity=D_val) + # Initial condition: analytical profile at t_start - h_init = h_analytic.subs(t_sym, t_start) - h_init_fn = h_init.subs(y_sym, mesh.X[1]) + h_init_fn = exact.fn_solution.subs(exact.t, t_start) h_soln.array = uw.function.evaluate(h_init_fn, h_soln.coords) # Time-step @@ -108,7 +110,7 @@ def test_transient_darcy_diffusion(): h_numerical = uw.function.evaluate(h_soln.sym[0], sample_pts).squeeze() - h_exact_fn = h_analytic.subs(t_sym, t_end).subs(y_sym, mesh.X[1]) + h_exact_fn = exact.fn_solution.subs(exact.t, t_end) h_exact = uw.function.evaluate(h_exact_fn, sample_pts).squeeze() assert np.allclose(h_numerical, h_exact, atol=0.1), ( diff --git a/tests/test_1015_analytic_solcx.py b/tests/test_1015_analytic_solcx.py index 4ba67c102..c1f481f55 100644 --- a/tests/test_1015_analytic_solcx.py +++ b/tests/test_1015_analytic_solcx.py @@ -18,7 +18,14 @@ import numpy as np import underworld3 as uw -from underworld3.function import analytic as A +from underworld3 import analytic as A + +# The low-level AnalyticSolCx_* bindings are the VENDORED KERNEL, not part of +# the `uw.analytic` public surface — `uw.analytic` exposes solutions, not the +# reference functions they were transcribed from. Import them from where they +# actually live rather than through the deprecated `uw.function.analytic` +# shim, which only forwards here anyway. +from underworld3.analytic._reference import _velic as K def _solve_solcx(res, eta_B=1.0e6, x_c=0.5, n=1): @@ -45,7 +52,7 @@ def _solve_solcx(res, eta_B=1.0e6, x_c=0.5, n=1): def test_solcx_analytic_binding(): """The ported SolCx kernel returns non-trivial values.""" - vy = float(A.AnalyticSolCx_velocity_y(1.0, 1.0e6, 0.5, 1, 0.25, 0.5).evalf()) + vy = float(K.AnalyticSolCx_velocity_y(1.0, 1.0e6, 0.5, 1, 0.25, 0.5).evalf()) assert np.isfinite(vy) assert abs(vy) > 1.0e-8 @@ -61,9 +68,9 @@ def test_solcx_stokes_converges_to_analytic(): def test_solcx_stress_binding(): """The exact total-stress kernel binding returns finite, non-trivial values.""" pts = [(0.25, 1.0), (0.7, 0.2), (0.9, 0.6), (0.15, 0.4)] - sxx = [float(A.AnalyticSolCx_stress_xx(1.0, 1.0e6, 0.5, 1, x, y).evalf()) for x, y in pts] - syy = [float(A.AnalyticSolCx_stress_yy(1.0, 1.0e6, 0.5, 1, x, y).evalf()) for x, y in pts] - sxy = [float(A.AnalyticSolCx_stress_xy(1.0, 1.0e6, 0.5, 1, x, y).evalf()) for x, y in pts] + sxx = [float(K.AnalyticSolCx_stress_xx(1.0, 1.0e6, 0.5, 1, x, y).evalf()) for x, y in pts] + syy = [float(K.AnalyticSolCx_stress_yy(1.0, 1.0e6, 0.5, 1, x, y).evalf()) for x, y in pts] + sxy = [float(K.AnalyticSolCx_stress_xy(1.0, 1.0e6, 0.5, 1, x, y).evalf()) for x, y in pts] for s in sxx + syy + sxy: assert np.isfinite(s) # the stress field is non-trivial (the dynamic-topography signal on the top is -syy) @@ -78,9 +85,9 @@ def test_solcx_stress_pressure_consistency(): pressure without assuming any UW3 sign convention.""" pts = [(0.2, 0.3), (0.7, 0.8), (0.5, 0.5), (0.9, 0.1), (0.35, 0.65)] for xi, yi in pts: - sxx = float(A.AnalyticSolCx_stress_xx(1.0, 1.0e6, 0.5, 1, xi, yi).evalf()) - syy = float(A.AnalyticSolCx_stress_yy(1.0, 1.0e6, 0.5, 1, xi, yi).evalf()) - p = float(A.AnalyticSolCx_pressure(1.0, 1.0e6, 0.5, 1, xi, yi).evalf()) + sxx = float(K.AnalyticSolCx_stress_xx(1.0, 1.0e6, 0.5, 1, xi, yi).evalf()) + syy = float(K.AnalyticSolCx_stress_yy(1.0, 1.0e6, 0.5, 1, xi, yi).evalf()) + p = float(K.AnalyticSolCx_pressure(1.0, 1.0e6, 0.5, 1, xi, yi).evalf()) mean_normal = 0.5 * (sxx + syy) assert abs(mean_normal + p) < 1.0e-10, ( f"(sxx+syy)/2 != -p at ({xi},{yi}): {mean_normal:.3e} vs {-p:.3e}" diff --git a/tests/test_1016_analytic_contract.py b/tests/test_1016_analytic_contract.py new file mode 100644 index 000000000..6b714c0bb --- /dev/null +++ b/tests/test_1016_analytic_contract.py @@ -0,0 +1,305 @@ +"""The uw.analytic contract. + +`uw.analytic` is the namespace for exact solutions used to validate a solve. +These tests fix the contract itself — that the base class exposes the fields and +error norms every solution promises, that the boundary-condition mixins configure +a solver rather than returning something the caller has to apply, and that a +solution reached through the new namespace is the *same object* as the one +reached through the old one. + +That last check matters more than it looks: `uw.function.analytic` is a compiled +extension module, and a namespace that re-declared its classes rather than +re-exporting them would silently break `isinstance` for anyone holding an object +built through the other path. + +Run: pixi run python -m pytest tests/test_1016_analytic_contract.py -v +""" + +import pytest + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + +import numpy as np +import sympy +import underworld3 as uw + + +@pytest.fixture +def mesh(): + return uw.meshing.StructuredQuadBox( + elementRes=(8, 8), minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), qdegree=3 + ) + + +class Quadratic(uw.analytic.FixedWalls, uw.analytic.AnalyticSolution): + r"""A manufactured Stokes solution used to exercise the contract. + + Divergence-free by construction: :math:`\mathbf u = (y, -x)` is a rigid + rotation, so any solver-independent check of the contract has an exact answer + that is trivial to verify by hand. + """ + + dim = 2 + eqn_velocity = r"(z, -x)" + eqn_viscosity = r"1" + reference = "Contract test fixture; not a published benchmark." + + def __init__(self, mesh): + super().__init__(mesh) + + x, y = mesh.X + + self.fn_velocity = sympy.Matrix([[y, -x]]) + self.fn_pressure = sympy.sympify(0) + self.fn_viscosity = sympy.sympify(1) + self.fn_bodyforce = sympy.Matrix([[0, 0]]) + self.fn_strainrate = sympy.Matrix([[0, 0], [0, 0]]) + self.fn_stress = sympy.Matrix([[0, 0], [0, 0]]) + + +def test_contract_is_exported(): + """The contract and the mixins are reachable from the package namespace.""" + for name in ("AnalyticSolution", "FreeSlipWalls", "FixedWalls"): + assert name in uw.analytic.__all__ + assert hasattr(uw.analytic, name) + + +def test_solcx_is_the_same_object_in_both_namespaces(): + """uw.analytic re-exports SolCx; it does not redeclare it. + + If the old namespace redeclared its classes rather than resolving to the + new ones, `isinstance` would fail for any object built through the other + path. + """ + from underworld3.function import analytic as legacy + + assert uw.analytic.SolCx is legacy.SolCx + + +def test_legacy_namespace_is_a_package_not_an_extension(): + """The shim must stay a package directory, not a module file. + + `underworld3.function.analytic` used to be a compiled extension. An + orphaned `.so` from an earlier install cannot be removed by `pip uninstall` + if it has fallen out of the wheel RECORD, and it would be imported in place + of the shim — silently restoring the old module and bypassing every + redirect. Python's path finder checks for a package directory *before* an + extension of the same name, which is the only thing that makes the stale + `.so` harmless. Keep this a directory. + """ + from underworld3.function import analytic as legacy + + assert legacy.__file__.endswith("__init__.py") + + +def test_legacy_namespace_warns_on_use(): + """Importing the shim is quiet; using a name from it is not.""" + import importlib + + from underworld3.function import analytic as legacy + + # The warning fires once per process, so reset the latch to observe it. + legacy._warned = False + + with pytest.warns(DeprecationWarning, match="moved to underworld3.analytic"): + legacy.SolCx + + +def test_legacy_namespace_rejects_unknown_names(): + from underworld3.function import analytic as legacy + + with pytest.raises(AttributeError): + legacy.SolNotAThing + + +def test_available_lists_solcx(): + """The registry reports what can actually be constructed.""" + assert "SolCx" in uw.analytic.available() + assert uw.analytic.available() == sorted(uw.analytic.available()) + + +def test_describe_rejects_unknown_names(): + with pytest.raises(ValueError, match="no analytic solution named"): + uw.analytic.describe("SolNotAThing") + + +def test_dimension_is_checked(mesh): + """A 2D solution refuses a 3D mesh rather than producing nonsense.""" + + class ThreeDimensional(Quadratic): + dim = 3 + + with pytest.raises(ValueError, match="3D solution"): + ThreeDimensional(mesh) + + +def test_named_fields_resolve(mesh): + sol = Quadratic(mesh) + + assert sol._exact("velocity") is sol.fn_velocity + assert sol._exact("pressure") is sol.fn_pressure + + # An expression passes straight through, so error() and evaluate() work on + # anything derived from the solution, not only its named fields. + x, y = mesh.X + assert sol._exact(x + y) == x + y + + +def test_unknown_field_names_are_reported(mesh): + sol = Quadratic(mesh) + + with pytest.raises(ValueError, match="no field 'temperature'"): + sol._exact("temperature") + + +def test_evaluate_returns_exact_values(mesh): + """evaluate() is the exact field, at arbitrary points, vectorised.""" + sol = Quadratic(mesh) + + coords = np.array([[0.25, 0.75], [0.5, 0.5], [0.9, 0.1]]) + values = np.asarray(sol.evaluate("velocity", coords)).reshape(3, 2) + + expected = np.column_stack([coords[:, 1], -coords[:, 0]]) + assert np.allclose(values, expected) + + +def test_error_is_zero_for_the_exact_field(mesh): + """A variable carrying the exact solution has zero error.""" + sol = Quadratic(mesh) + + velocity = uw.discretisation.MeshVariable("Uc", mesh, mesh.dim, degree=2) + velocity.array[:, 0, :] = np.asarray( + sol.evaluate("velocity", velocity.coords) + ).reshape(-1, mesh.dim) + + assert sol.error("velocity", velocity) < 1.0e-12 + + +def test_error_scales_with_the_perturbation(mesh): + """A known relative perturbation is reported as that relative error. + + Scaling the computed field by (1 + eps) must give a relative L2 error of + exactly eps — this pins the normalisation, which a zero-error test cannot. + """ + sol = Quadratic(mesh) + + velocity = uw.discretisation.MeshVariable("Up", mesh, mesh.dim, degree=2) + exact = np.asarray(sol.evaluate("velocity", velocity.coords)).reshape( + -1, mesh.dim + ) + + for eps in (1.0e-3, 1.0e-1): + velocity.array[:, 0, :] = exact * (1.0 + eps) + assert np.isclose(sol.error("velocity", velocity), eps, rtol=1.0e-8) + + +def test_error_is_the_same_on_every_rank(mesh): + """The nodal norm is a global reduction, not each rank's own partition. + + The perturbation is deliberately confined to x < 0.5, so under np > 1 the + ranks own very different shares of it. A rank-local norm would therefore + return a different number on each rank — which is exactly the regression this + reduction was written to fix (issue #370: the rank owning the SolCx viscosity + jump reported an error 10-20x its neighbours', so parallel tolerance + assertions depended on the partition). + + A uniform perturbation cannot detect this: it would agree across ranks even + if the norm were rank-local. + """ + sol = Quadratic(mesh) + + velocity = uw.discretisation.MeshVariable("Ug", mesh, mesh.dim, degree=2) + exact = np.asarray(sol.evaluate("velocity", velocity.coords)).reshape( + -1, mesh.dim + ) + + perturbed = exact.copy() + left = velocity.coords[:, 0] < 0.5 + perturbed[left] *= 1.5 + velocity.array[:, 0, :] = perturbed + + error = sol.error("velocity", velocity) + + assert error > 1.0e-3, "perturbation too small to discriminate" + assert len(set(uw.mpi.comm.allgather(error))) == 1 + + +def test_integral_norm_agrees_with_the_nodal_norm(mesh): + """The continuous norm reports the same relative error for a uniform scaling. + + Scaling the field by (1 + eps) scales the error integrand uniformly, so both + norms must return eps. They disagree in general — the integral norm is the + one to use across discretisations — but this case pins both normalisations + against the same known answer. + """ + sol = Quadratic(mesh) + + velocity = uw.discretisation.MeshVariable("Ui", mesh, mesh.dim, degree=2) + exact = np.asarray(sol.evaluate("velocity", velocity.coords)).reshape( + -1, mesh.dim + ) + + eps = 1.0e-2 + velocity.array[:, 0, :] = exact * (1.0 + eps) + + assert np.isclose(sol.error("velocity", velocity, norm="integral"), eps, rtol=1e-4) + + +def test_unknown_norm_is_rejected(mesh): + sol = Quadratic(mesh) + velocity = uw.discretisation.MeshVariable("Un", mesh, mesh.dim, degree=2) + + with pytest.raises(ValueError, match="norm must be"): + sol.error("velocity", velocity, norm="linf") + + +def test_missing_boundary_conditions_are_an_error(mesh): + """A solution that declares no BCs says so, rather than solving something else.""" + + class NoBoundaryConditions(uw.analytic.AnalyticSolution): + dim = 2 + + sol = NoBoundaryConditions(mesh) + stokes = uw.systems.Stokes(mesh) + + with pytest.raises(NotImplementedError, match="does not declare its boundary"): + sol.apply_boundary_conditions(stokes) + + +def test_fixed_walls_configures_the_solver(mesh): + """FixedWalls imposes the exact velocity on every wall and kills the nullspace.""" + sol = Quadratic(mesh) + stokes = uw.systems.Stokes(mesh) + + sol.apply_boundary_conditions(stokes) + + assert stokes.petsc_use_pressure_nullspace + assert {bc.boundary for bc in stokes.essential_bcs} == set(sol.boundaries) + + +def test_free_slip_walls_uses_the_rotated_constraint(mesh): + """FreeSlipWalls imposes u.n = 0 by rotation, not by masking a component. + + Component masking is only equivalent on an axis-aligned box; the rotated form + is what still holds when a solution is used to validate a curved or adapted + mesh. The registration list is private because it has no public reader — this + is the only signal that distinguishes the two paths. + """ + + class FreeSlip(uw.analytic.FreeSlipWalls, Quadratic): + pass + + sol = FreeSlip(mesh) + stokes = uw.systems.Stokes(mesh) + + sol.apply_boundary_conditions(stokes) + + assert stokes.petsc_use_pressure_nullspace + registered = {boundary for boundary, _ in stokes._rotated_freeslip_bcs} + assert registered == set(sol.boundaries) + assert stokes.essential_bcs == [] + + +def test_boundaries_follow_the_mesh_dimension(mesh): + sol = Quadratic(mesh) + assert sol.boundaries == ["Left", "Right", "Bottom", "Top"] diff --git a/tests/test_1017_custom_mg_stokes.py b/tests/test_1017_custom_mg_stokes.py index a3c2f4a72..ac5a08d31 100644 --- a/tests/test_1017_custom_mg_stokes.py +++ b/tests/test_1017_custom_mg_stokes.py @@ -16,7 +16,7 @@ import numpy as np import pytest import underworld3 as uw -from underworld3.function import analytic as A +from underworld3 import analytic as A from underworld3.utilities import custom_mg pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] diff --git a/tests/test_1018_rotated_freeslip.py b/tests/test_1018_rotated_freeslip.py index 53af6ea48..543cb36f8 100644 --- a/tests/test_1018_rotated_freeslip.py +++ b/tests/test_1018_rotated_freeslip.py @@ -8,7 +8,7 @@ import pytest import sympy import underworld3 as uw -from underworld3.function import analytic as A +from underworld3 import analytic as A from underworld3.utilities import custom_mg pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] diff --git a/tests/test_1019_analytic_transcription.py b/tests/test_1019_analytic_transcription.py new file mode 100644 index 000000000..57bdb9938 --- /dev/null +++ b/tests/test_1019_analytic_transcription.py @@ -0,0 +1,286 @@ +"""The SolCx transcription, against the kernel it was transcribed from. + +`uw.analytic.SolCx` is SymPy rebuilt from the published Maple-generated C, which +stays vendored alongside it. These tests are the standing version of the +validation gates: the transcription must reproduce its own source across the +parameter range the benchmark is used over, and it must reproduce it where the +solution is hardest — on the viscosity interface, on the walls, at the corners. + +Two traps are pinned here because both cost time to find: + +- a pointwise relative error is the wrong metric. These fields pass through zero, + so dividing by the true value explodes wherever the solution is small and every + case looks catastrophic. Normalise by the field's magnitude over the sample. +- `_solCx_B` in the published source is not a second conditioning of the same + formula, it is the mirror image, `B(x, z) = A(1 - x, z)`. Only `_solCx_A` is + transcribed. `test_arrangements_are_mirror_images` records why. + +Run: pixi run python -m pytest tests/test_1019_analytic_transcription.py -v +""" + +import pytest + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + +import numpy as np +import sympy +import underworld3 as uw + + +# Ratios spanning both directions, wavenumbers, and an off-centre interface. +REGIMES = [ + (1.0, 10.0, 0.5, 1), + (1.0, 1.0e3, 0.5, 1), + (1.0, 1.0e6, 0.5, 1), + (1.0, 1.0e8, 0.5, 1), + (1.0, 1.0e6, 0.5, 3), + (1.0, 1.0e6, 0.75, 1), + (1.0e6, 1.0, 0.5, 1), + (1.0e3, 1.0, 0.25, 2), + (1.0, 1.0e-6, 0.5, 1), +] + + +@pytest.fixture(scope="module") +def mesh(): + return uw.meshing.StructuredQuadBox( + elementRes=(4, 4), minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), qdegree=3 + ) + + +def _reference_fields(sol, eta_A, eta_B, x_c, n): + """The transcribed fields paired with the kernel they came from.""" + + from underworld3.analytic._reference import _velic + + def at(kernel): + return lambda x, z: kernel(eta_A, eta_B, x_c, n, x, z).evalf() + + return { + "velocity_x": (sol.fn_velocity[0, 0], at(_velic.AnalyticSolCx_velocity_x)), + "velocity_z": (sol.fn_velocity[0, 1], at(_velic.AnalyticSolCx_velocity_y)), + "pressure": (sol.fn_pressure, at(_velic.AnalyticSolCx_pressure)), + "stress_xx": (sol.fn_stress[0, 0], at(_velic.AnalyticSolCx_stress_xx)), + "stress_zz": (sol.fn_stress[1, 1], at(_velic.AnalyticSolCx_stress_yy)), + "stress_zx": (sol.fn_stress[0, 1], at(_velic.AnalyticSolCx_stress_xy)), + } + + +@pytest.mark.parametrize("eta_A,eta_B,x_c,n", REGIMES) +def test_transcription_reproduces_the_reference_kernel(mesh, eta_A, eta_B, x_c, n): + """Agreement with the published kernel, worst case rather than average.""" + + from underworld3.analytic import _validation + + sol = uw.analytic.SolCx(mesh, eta_A=eta_A, eta_B=eta_B, x_c=x_c, n=n) + points = _validation.adversarial_points(x_c=x_c) + + worst = _validation.reference_agreement( + sol, _reference_fields(sol, eta_A, eta_B, x_c, n), points + ) + + for name, error in worst.items(): + assert error < 1.0e-10, f"{name}: max normalised error {error:.2e}" + + +@pytest.mark.parametrize("eta_A,eta_B,x_c,n", REGIMES) +def test_transcription_satisfies_the_equations(mesh, eta_A, eta_B, x_c, n): + """The fields solve the Stokes problem they claim to — no reference involved. + + This is the check a convergence test cannot make. If a transcription and the + solver shared a mistaken convention, the solve would converge neatly to the + wrong answer; this residual never consults the solver. It is also what + settles the body-force sign, where UW2's documentation and UW3's convention + disagree. + """ + + from underworld3.analytic import _validation + + sol = uw.analytic.SolCx(mesh, eta_A=eta_A, eta_B=eta_B, x_c=x_c, n=n) + points = _validation.adversarial_points(x_c=x_c, count=12) + + assert _validation.incompressibility_residual(sol, points) < 1.0e-10 + assert _validation.momentum_residual(sol, points) < 1.0e-10 + + +@pytest.mark.parametrize("eta_A,eta_B,x_c,n", REGIMES) +def test_strain_rate_agrees_with_the_velocity_gradient(mesh, eta_A, eta_B, x_c, n): + """Two independently derived kernel outputs, cross-checked through derivatives. + + A transcription can be right pointwise and wrong in its derivatives — which + is what a solver actually consumes. + """ + + from underworld3.analytic import _validation + + sol = uw.analytic.SolCx(mesh, eta_A=eta_A, eta_B=eta_B, x_c=x_c, n=n) + points = _validation.adversarial_points(x_c=x_c, count=12) + + assert _validation.strainrate_consistency(sol, points) < 1.0e-10 + + +def test_the_checks_reject_a_broken_transcription(mesh): + """Negative control: a check that passes a broken input measures nothing. + + One coefficient of the transcribed velocity is perturbed by a part in a + thousand — small enough to be a plausible transcription slip, large enough + that a working check must see it. Both the comparison against the kernel and + the oracle-free residual have to fail. + """ + + from underworld3.analytic import _validation + + eta_A, eta_B, x_c, n = 1.0, 1.0e3, 0.5, 1 + sol = uw.analytic.SolCx(mesh, eta_A=eta_A, eta_B=eta_B, x_c=x_c, n=n) + points = _validation.adversarial_points(x_c=x_c, count=12) + + # Intact first: if this did not pass, the control below would prove nothing. + intact = _validation.reference_agreement( + sol, _reference_fields(sol, eta_A, eta_B, x_c, n), points + ) + assert max(intact.values()) < 1.0e-10 + assert _validation.incompressibility_residual(sol, points) < 1.0e-10 + + sol.fn_velocity = sympy.Matrix( + [[sol.fn_velocity[0, 0] * sympy.Rational(1001, 1000), sol.fn_velocity[0, 1]]] + ) + + broken = _validation.reference_agreement( + sol, _reference_fields(sol, eta_A, eta_B, x_c, n), points + ) + assert broken["velocity_x"] > 1.0e-6, "comparison did not see the perturbation" + assert ( + _validation.incompressibility_residual(sol, points) > 1.0e-6 + ), "residual did not see the perturbation" + + +def test_arrangements_are_mirror_images(): + r"""`_solCx_B` is `_solCx_A` reflected, not a second conditioning. + + The published source dispatches on eta_A > eta_B, which reads as a + conditioning choice. It is not: the second arrangement solves the mirrored + problem so the algebra for a stiff left column can be reused when the stiff + column is on the right. + + The relation is :math:`A(x, z) = -B(1 - x, z)`. The sign is not a fudge — the + forcing :math:`\cos(\pi x)` is odd about :math:`x = 1/2`, so reflecting the + domain flips the whole solution. + + Only `_solCx_A` is transcribed, so this records the evidence for that + decision — if it ever fails, the dispatch mattered after all and the + reasoning needs revisiting. + """ + + from underworld3.analytic import velic + + values = { + velic._XC: sympy.Rational(1, 2), + velic._KN: sympy.pi, + velic._KX: sympy.pi, + velic._ZA: sympy.Integer(1), + velic._ZB: sympy.Integer(10) ** 6, + velic._ZR: sympy.Integer(10) ** 6, + } + + a = velic._solcx_kernel("_solCx_A")["pressure"].subs(values) + b = velic._solcx_kernel("_solCx_B")["pressure"].subs(values) + + for x, z in ((sympy.Rational(1, 10), sympy.Rational(9, 10)), + (sympy.Rational(1, 4), sympy.Rational(1, 3))): + direct = sympy.N(a.subs({velic._X: x, velic._Z: z}), 30) + mirrored = sympy.N(b.subs({velic._X: 1 - x, velic._Z: z}), 30) + assert abs(direct) > 1.0e-3, "sample point is too near a node to discriminate" + assert abs(direct + mirrored) < 1.0e-25 + + +def test_equal_viscosities_are_refused(mesh): + """A removable singularity the compiled form does not remove is not silently used.""" + + with pytest.raises(ValueError, match="singular at eta_A == eta_B"): + uw.analytic.SolCx(mesh, eta_A=1.0, eta_B=1.0) + + +def test_transcription_is_usable_by_the_solver(mesh): + """The point of the SymPy form: it compiles into a residual. + + The reference kernel cannot do this — it is opaque to the JIT — so this is + what the transcription buys, and it is worth a test of its own. + """ + + sol = uw.analytic.SolCx(mesh, eta_A=1.0, eta_B=1.0e3) + + stokes = uw.systems.Stokes(mesh) + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = sol.fn_viscosity + stokes.bodyforce = sol.fn_bodyforce + + # A Dirichlet value taken straight from the exact velocity: only possible + # because the field is real SymPy rather than a compiled kernel call. + stokes.add_dirichlet_bc(sol.fn_velocity, "Top") + + assert len(stokes.essential_bcs) == 1 + + +# --- SolNL ----------------------------------------------------------------- +# +# A second kernel through the same transcriber, which is the point: it caught a +# reader bug SolCx could not. SolNL writes its results through a struct +# (`out.x = ...`), and a target pattern that ignored the prefix read that as an +# assignment to `x`, silently overwriting the coordinate. The velocity still +# looked like a plausible expression. `test_solnl_velocity_is_the_published_form` +# is the cheap guard: the published velocity is short enough to state outright. + + +def test_solnl_velocity_is_the_published_form(mesh): + r"""The exact velocity is :math:`(-k e^{x}\cos kz,\; e^{x}\sin kz)`. + + Short enough to assert directly, which makes it the fastest possible check + that the reader has not mangled the kernel. + """ + + sol = uw.analytic.SolNL(mesh, eta_0=1.0, n=1, r=1.5) + x, z = mesh.X + k = sympy.pi + + assert sympy.simplify(sol.fn_velocity[0, 0] + k * sympy.exp(x) * sympy.cos(k * z)) == 0 + assert sympy.simplify(sol.fn_velocity[0, 1] - sympy.exp(x) * sympy.sin(k * z)) == 0 + + +@pytest.mark.parametrize("eta_0,n,r", [(1.0, 1, 1.5), (2.0, 2, 1.5), (1.0, 1, 3.0)]) +def test_solnl_reproduces_the_reference_kernel(mesh, eta_0, n, r): + from underworld3.analytic import _validation + from underworld3.analytic._reference import _velic + + sol = uw.analytic.SolNL(mesh, eta_0=eta_0, n=n, r=r) + points = _validation.adversarial_points(count=20) + + def at(kernel): + return lambda x, z: kernel(eta_0, n, r, x, z).evalf() + + fields = { + "velocity_x": (sol.fn_velocity[0, 0], at(_velic.AnalyticSolNL_velocity_x)), + "velocity_z": (sol.fn_velocity[0, 1], at(_velic.AnalyticSolNL_velocity_y)), + "bodyforce_z": (sol.fn_bodyforce[0, 1], at(_velic.AnalyticSolNL_bodyforce_y)), + "viscosity": (sol.fn_viscosity, at(_velic.AnalyticSolNL_viscosity)), + } + + for name, error in _validation.reference_agreement(sol, fields, points).items(): + assert error < 1.0e-10, f"{name}: max normalised error {error:.2e}" + + +def test_solnl_velocity_is_divergence_free(mesh): + """No oracle: the published velocity is solenoidal by construction.""" + + from underworld3.analytic import _validation + + sol = uw.analytic.SolNL(mesh, eta_0=1.0, n=2, r=1.5) + points = _validation.adversarial_points(count=20) + + assert _validation.incompressibility_residual(sol, points) < 1.0e-10 + + +def test_solnl_is_marked_nonlinear(mesh): + """The viscosity depends on the solution's own strain rate.""" + + assert uw.analytic.SolNL(mesh).nonlinear is True + assert uw.analytic.SolCx(mesh, eta_A=1.0, eta_B=10.0).nonlinear is False diff --git a/tests/test_1020_analytic_inclusion.py b/tests/test_1020_analytic_inclusion.py new file mode 100644 index 000000000..e44c9e30c --- /dev/null +++ b/tests/test_1020_analytic_inclusion.py @@ -0,0 +1,202 @@ +r"""Schmid & Podladchikov's elliptical inclusion. + +The authors publish pressure, stress and the rotation rate but not the velocity, +so the velocity here is reconstructed from the Muskhelishvili potentials. That +makes validation the whole story, and it has to come from somewhere other than +the thing being validated. Four independent sources are used: + +- the **Stokes residual** :math:`\eta\nabla^2\mathbf v = \nabla p`, which pairs + the reconstructed velocity against the *published* pressure; +- **velocity continuity** across the inclusion boundary, which pairs the exterior + reconstruction against the interior uniform-gradient field; +- the **far field**, against the imposed shear computed independently here; +- **interior uniformity**, the Eshelby property the solution must have. + +Two subtleties are pinned because each produced a plausible-looking wrong answer: + +- a purely imaginary constant in :math:`\varphi'` is invisible to pressure and + stress, so the published data cannot constrain it. Omitting it gives a flow + with the correct strain and no spin — a simple shear that comes out as pure + shear. `test_far_field_matches_the_imposed_shear` catches that. +- inverting the conformal map with ``sqrt(z**2 - 4)`` picks the wrong sheet left + of the origin. `test_far_field_matches_the_imposed_shear` samples negative x + for exactly that reason. + +Run: pixi run python -m pytest tests/test_1020_analytic_inclusion.py -v +""" + +import pytest + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + +import numpy as np +import sympy +import underworld3 as uw + + +# Weak and strong inclusions, both shear types, both signs of orientation. +CASES = [ + (1.0e3, 2.0, -np.pi / 6, 0.0, 1.0), + (1.0e-2, 3.0, 0.4, 1.0, 0.5), + (1.0e6, 1.5, 0.0, 1.0, 0.0), + (0.1, 4.0, -1.1, 0.5, 1.0), +] + + +@pytest.fixture(scope="module") +def mesh(): + return uw.meshing.StructuredQuadBox( + elementRes=(4, 4), minCoords=(-3.0, -3.0), maxCoords=(3.0, 3.0), qdegree=3 + ) + + +def _real(mesh, expression): + """Lambdify a field that is real-valued but complex-typed. + + The velocity is built from complex potentials, and SymPy cannot prove the + imaginary part vanishes even though it does to roundoff. + """ + + f = sympy.lambdify(tuple(mesh.X), expression, "numpy") + return lambda x, y: complex(f(x, y)).real + + +def _solution(mesh, viscosity_ratio, aspect_ratio, alpha, pure_shear, simple_shear): + return uw.analytic.EllipticalInclusion( + mesh, + viscosity_ratio=viscosity_ratio, + aspect_ratio=aspect_ratio, + alpha=alpha, + pure_shear=pure_shear, + simple_shear=simple_shear, + semi_major=1.0, + ) + + +@pytest.mark.parametrize("mc,t,alpha,er,gr", CASES) +def test_stokes_residual_vanishes_in_the_matrix(mesh, mc, t, alpha, er, gr): + r"""With no body force, :math:`\eta\nabla^2\mathbf v - \nabla p = 0`. + + The pressure is the published closed form and the velocity is the + reconstruction, so this is a cross-check between two different sources, not a + restatement of one. + """ + + sol = _solution(mesh, mc, t, alpha, er, gr) + x, y = mesh.X + vx, vy, p = sol.fn_velocity[0, 0], sol.fn_velocity[0, 1], sol.fn_pressure + + residual_x = _real(mesh, sympy.diff(vx, x, 2) + sympy.diff(vx, y, 2) - sympy.diff(p, x)) + residual_y = _real(mesh, sympy.diff(vy, x, 2) + sympy.diff(vy, y, 2) - sympy.diff(p, y)) + divergence = _real(mesh, sympy.diff(vx, x) + sympy.diff(vy, y)) + + points = [(2.4, 1.9), (-2.2, 1.3), (1.5, -2.6), (-1.1, -2.9), (0.0, 2.8)] + for px, py in points: + assert abs(residual_x(px, py)) < 1.0e-10 + assert abs(residual_y(px, py)) < 1.0e-10 + assert abs(divergence(px, py)) < 1.0e-10 + + +@pytest.mark.parametrize("mc,t,alpha,er,gr", CASES) +def test_velocity_is_continuous_across_the_interface(mesh, mc, t, alpha, er, gr): + """Interior and exterior are different expressions; they must agree on the boundary. + + Nothing in the construction forces this — the interior comes from the + published interior stress and rotation rate, the exterior from the + potentials — so agreement is real evidence. + """ + + sol = _solution(mesh, mc, t, alpha, er, gr) + vx, vy = _real(mesh, sol.fn_velocity[0, 0]), _real(mesh, sol.fn_velocity[0, 1]) + a, b = sol.semi_axes + + step = 1.0e-7 + for theta in np.linspace(0.1, 2 * np.pi - 0.1, 9): + px, py = a * np.cos(theta), b * np.sin(theta) + radius = np.hypot(px, py) + inner = np.array( + [vx(px * (1 - step / radius), py * (1 - step / radius)), + vy(px * (1 - step / radius), py * (1 - step / radius))] + ) + outer = np.array( + [vx(px * (1 + step / radius), py * (1 + step / radius)), + vy(px * (1 + step / radius), py * (1 + step / radius))] + ) + # The tolerance is set by the finite step across the interface, not by + # the solution: the velocity is continuous but its gradient is not. + assert np.linalg.norm(inner - outer) < 1.0e-5 + + +@pytest.mark.parametrize("mc,t,alpha,er,gr", CASES) +def test_far_field_matches_the_imposed_shear(mesh, mc, t, alpha, er, gr): + r"""Far from the inclusion the flow is the shear that drives it. + + The comparison is computed here from :math:`\alpha`, the shear rates and the + Muskhelishvili far field, independently of the solution object. Negative + :math:`x` is sampled deliberately: that is where the conformal map's branch + goes wrong if the square root is written as a single ``sqrt(z**2 - 4)``. + """ + + sol = _solution(mesh, mc, t, alpha, er, gr) + vx, vy = _real(mesh, sol.fn_velocity[0, 0]), _real(mesh, sol.fn_velocity[0, 1]) + + BC = (2 * er - 1j * gr) * np.exp(2j * alpha) + + for px, py in [(300.0, 220.0), (-400.0, 150.0), (0.0, 500.0), (-250.0, -330.0)]: + z = px + 1j * py + expected = np.conj(BC) * np.conj(z) / 2 + 1j * (-gr / 2) * z + got = np.array([vx(px, py), vy(px, py)]) + want = np.array([expected.real, expected.imag]) + + # The inclusion's own perturbation decays as 1/r^2, so at r ~ 500 with a + # unit inclusion a few parts in 1e6 remain. That is physics, not error. + assert np.linalg.norm(got - want) / np.linalg.norm(want) < 1.0e-4 + + +def test_interior_deformation_is_uniform(mesh): + """The Eshelby property: strain rate is constant inside the inclusion.""" + + sol = _solution(mesh, 1.0e3, 2.0, -np.pi / 6, 0.0, 1.0) + x, y = mesh.X + + exx = _real(mesh, sympy.diff(sol.fn_velocity[0, 0], x)) + exy = _real( + mesh, + (sympy.diff(sol.fn_velocity[0, 0], y) + sympy.diff(sol.fn_velocity[0, 1], x)) / 2, + ) + + interior = [(0.1, 0.05), (-0.3, 0.1), (0.5, -0.15), (0.0, 0.0)] + reference = (exx(*interior[0]), exy(*interior[0])) + for px, py in interior[1:]: + assert abs(exx(px, py) - reference[0]) < 1.0e-12 + assert abs(exy(px, py) - reference[1]) < 1.0e-12 + + +def test_a_circular_inclusion_turns_with_the_far_field(mesh): + """A circle rotates at the far-field vorticity whatever its viscosity. + + This is the identity that fixes the otherwise-unconstrained rotation in the + potentials, so it is worth asserting rather than trusting. + """ + + for viscosity_ratio in (1.0e-3, 1.0, 1.0e6): + sol = _solution(mesh, viscosity_ratio, 1.0001, 0.3, 0.7, 1.0) + assert abs(sol.rotation_rate - (-0.5)) < 1.0e-3 + + +def test_a_circle_exactly_is_refused(mesh): + """Aspect ratio one is a degenerate conformal map, not a usable circle.""" + + with pytest.raises(ValueError, match="aspect_ratio must exceed 1"): + _solution(mesh, 10.0, 1.0, 0.0, 0.0, 1.0) + + +def test_geometry_scales_with_semi_major(mesh): + """Lengths scale; the strain rate does not.""" + + small = uw.analytic.EllipticalInclusion(mesh, aspect_ratio=2.0, semi_major=0.5) + large = uw.analytic.EllipticalInclusion(mesh, aspect_ratio=2.0, semi_major=2.0) + + assert np.isclose(small.semi_axes[0], 0.5) + assert np.isclose(large.semi_axes[0], 2.0) + assert np.isclose(small.semi_axes[0] / small.semi_axes[1], 2.0) diff --git a/tests/test_1021_analytic_solkx.py b/tests/test_1021_analytic_solkx.py new file mode 100644 index 000000000..3a4ac62df --- /dev/null +++ b/tests/test_1021_analytic_solkx.py @@ -0,0 +1,179 @@ +r"""SolKx — Stokes flow with an exponentially varying viscosity. + +The companion to SolCx: same box, same forcing shape, but the viscosity varies +*smoothly* as :math:`e^{2Bx}` instead of jumping. The two fail differently. A +jump tests how a discretisation copes with a discontinuity inside an element; a +gradient tests whether the operator stays well conditioned while the contrast +builds across every element in the domain. + +Validated by the equations rather than against a compiled kernel. The forcing and +the boundary conditions are both known, so a field set that satisfies Stokes with +them is *the* solution by uniqueness — the momentum and incompressibility +residuals settle it without an oracle, and free slip is checked directly. + +Run: pixi run python -m pytest tests/test_1021_analytic_solkx.py -v +""" + +import pytest + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + +import numpy as np +import sympy +import underworld3 as uw + + +# B = 2.3026 is a decade of viscosity contrast per unit length, so e^2B ~ 100 +# across the box; B = 5 is four orders. Both wavenumbers, integer and not. +# The canonical case only, on every PR. SolKx declares +# `expensive_to_validate`: an exponential viscosity makes every residual +# here a symbolic differentiation of a very large expression, and the four +# cases together were 88s of the analytic suite's 1010s. +# +# The remaining cases below are not dropped — they run in +# tests/analytic_full/, which sweeps this solution over its parameter table +# with the same residual gates. Every CHECK in this file still runs on every +# PR; what is reduced is how many parameter values it runs on. +CASES = [ + (2.302585092994046, 3, 2), +] + + + +@pytest.fixture(scope="module") +def mesh(): + return uw.meshing.StructuredQuadBox( + elementRes=(4, 4), minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), qdegree=3 + ) + + +# Interior points, and points on each wall for the free-slip check. +INTERIOR = np.array([(0.2, 0.3), (0.7, 0.8), (0.5, 0.5), (0.9, 0.15), (0.05, 0.95)]) +LEFT = np.array([(0.0, t) for t in (0.13, 0.47, 0.82)]) +RIGHT = np.array([(1.0, t) for t in (0.13, 0.47, 0.82)]) +BOTTOM = np.array([(t, 0.0) for t in (0.13, 0.47, 0.82)]) +TOP = np.array([(t, 1.0) for t in (0.13, 0.47, 0.82)]) + + +@pytest.fixture(scope="module") +def cache(): + """One construction per (B, n, m), shared across the gates below. + + Constructing SolKx substitutes parameters into an expression tens of + thousands of operations long. Two gates run over every case, so building + afresh in each doubled the file's cost for nothing. + """ + + return {} + + +def _sol(cache, mesh, B, n, m): + key = (B, n, m) + if key not in cache: + cache[key] = uw.analytic.SolKx(mesh, B=B, n=n, m=m) + return cache[key] + + +def _at(sol, expression, points): + """Values of an expression of the mesh coordinates, over a whole point set. + + Goes through the validation harness, which swaps the mesh coordinates for + plain symbols first — lambdify cannot bind mesh coordinates as arguments. + + Evaluated for all points in one call on purpose: these expressions run to + tens of thousands of operations, and lambdifying one per point turns a + two-minute suite into an unrunnable one. + """ + + from underworld3.analytic import _validation + + return np.abs(_validation.sample(sol, expression, points)) + + +@pytest.mark.parametrize("B,n,m", CASES) +def test_solkx_satisfies_the_stokes_equations(mesh, B, n, m): + r"""":math:`\nabla\cdot\sigma + \mathbf f = 0` and :math:`\nabla\cdot\mathbf v = 0`. + + The residual is normalised by the forcing, so the tolerance means what it + says regardless of how strong the driving is. + """ + + sol = uw.analytic.SolKx(mesh, B=B, n=n, m=m) + x, z = mesh.X + + scale = _at(sol, sol.fn_bodyforce[0, 1], INTERIOR).max() + + residual_x = _at( + sol, sympy.diff(sol.fn_stress[0, 0], x) + sympy.diff(sol.fn_stress[0, 1], z), INTERIOR + ) + residual_z = _at( + sol, + sympy.diff(sol.fn_stress[1, 0], x) + + sympy.diff(sol.fn_stress[1, 1], z) + + sol.fn_bodyforce[0, 1], + INTERIOR, + ) + divergence = _at( + sol, + sympy.diff(sol.fn_velocity[0, 0], x) + sympy.diff(sol.fn_velocity[0, 1], z), + INTERIOR, + ) + + assert residual_x.max() / scale < 1.0e-10 + assert residual_z.max() / scale < 1.0e-10 + assert divergence.max() < 1.0e-10 + + +@pytest.mark.parametrize("B,n,m", CASES) +def test_solkx_is_free_slip_on_every_wall(mesh, B, n, m): + """The solution is posed with free slip, so the normal velocity must vanish. + + Together with the Stokes residual this pins the solution uniquely, which is + what makes an oracle unnecessary here. + """ + + sol = uw.analytic.SolKx(mesh, B=B, n=n, m=m) + vx, vz = sol.fn_velocity[0, 0], sol.fn_velocity[0, 1] + + assert _at(sol, vx, LEFT).max() < 1.0e-10 + assert _at(sol, vx, RIGHT).max() < 1.0e-10 + assert _at(sol, vz, BOTTOM).max() < 1.0e-10 + assert _at(sol, vz, TOP).max() < 1.0e-10 + + +def test_solkx_viscosity_is_the_exponential_it_claims(mesh): + r""":math:`\eta = e^{2Bx}`, and the stress is consistent with it.""" + + B = 2.0 + sol = uw.analytic.SolKx(mesh, B=B, n=2, m=1) + x, z = mesh.X + + where = np.array([(px, 0.5) for px in (0.0, 0.3, 1.0)]) + assert np.allclose(_at(sol, sol.fn_viscosity, where), np.exp(2 * B * where[:, 0])) + + # sigma = -p I + 2 eta edot, so the deviator recovered from the stress and + # the strain rate computed from the velocity must agree. + exz = ( + sympy.diff(sol.fn_velocity[0, 0], z) + sympy.diff(sol.fn_velocity[0, 1], x) + ) / 2 + points = np.array([(0.25, 0.4), (0.6, 0.7), (0.85, 0.2)]) + difference = _at(sol, sol.fn_stress[0, 1] - 2 * sol.fn_viscosity * exz, points) + scale = _at(sol, sol.fn_stress[0, 1], points).max() + + assert difference.max() / scale < 1.0e-10 + + +def test_solkx_rejects_a_fractional_vertical_wavenumber(mesh): + """A fractional m breaks free slip on the top wall while still solving Stokes. + + That is the dangerous kind of wrong — the residual checks would all pass and + the benchmark would quietly be a different problem — so it is refused. + """ + + with pytest.raises(ValueError, match="sin\\(m\\*pi\\) = 0"): + uw.analytic.SolKx(mesh, m=1.5) + + +def test_solkx_is_registered(mesh): + assert "SolKx" in uw.analytic.available() + assert uw.analytic.SolKx(mesh).nonlinear is False diff --git a/tests/test_1022_analytic_soldb.py b/tests/test_1022_analytic_soldb.py new file mode 100644 index 000000000..82fac61e3 --- /dev/null +++ b/tests/test_1022_analytic_soldb.py @@ -0,0 +1,156 @@ +r"""The Dohrmann–Bochev manufactured solutions, in 2D and 3D. + +Polynomial velocities with a body force chosen to make them exact. SolDB2d is +isoviscous; SolDB3d (Burstedde et al.) carries a smooth viscosity peaked in the +interior and is **the suite's only 3D solution** — several parts of a Stokes +discretisation genuinely differ between two and three dimensions (the pressure +space, the null space, the tensor assembly), and a 2D benchmark cannot see a term +that is wrong only in the third. + +These are the easiest solutions here to be sure of. The fields are short enough +that the residuals reduce *symbolically* to zero rather than to something small, +so the assertions are exact rather than tolerance-based — no sampling, no +conditioning question, nothing to argue about. + +Run: pixi run python -m pytest tests/test_1022_analytic_soldb.py -v +""" + +import pytest + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + +import numpy as np +import sympy +import underworld3 as uw + + +@pytest.fixture(scope="module") +def box2d(): + return uw.meshing.StructuredQuadBox( + elementRes=(4, 4), minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), qdegree=3 + ) + + +@pytest.fixture(scope="module") +def box3d(): + return uw.meshing.StructuredQuadBox( + elementRes=(2, 2, 2), + minCoords=(0.0, 0.0, 0.0), + maxCoords=(1.0, 1.0, 1.0), + qdegree=2, + ) + + +def _plain(sol, expression): + """Rewrite an expression over plain symbols before simplifying. + + Mesh coordinates are not ordinary Symbols, and `simplify` will not combine + exponentials written in them — the beta = 0 cases reduce and the others do + not, purely because of the symbol type. Swapping first makes the reduction + the algebraic question it is meant to be. + """ + + plain = sympy.symbols(f"_p0:{sol.dim}", real=True) + return sympy.sympify(expression).subs(dict(zip(sol.mesh.X, plain))) + + +def _divergence(sol): + return sum( + sympy.diff(sol.fn_velocity[0, i], sol.mesh.X[i]) for i in range(sol.dim) + ) + + +def _momentum(sol): + r""":math:`\nabla\cdot\sigma + \mathbf f`, component by component.""" + + return [ + sol.fn_bodyforce[0, i] + + sum(sympy.diff(sol.fn_stress[i, j], sol.mesh.X[j]) for j in range(sol.dim)) + for i in range(sol.dim) + ] + + +def test_soldb2d_is_incompressible(box2d): + sol = uw.analytic.SolDB2d(box2d) + assert sympy.simplify(_plain(sol, _divergence(sol))) == 0 + + +def test_soldb2d_satisfies_the_momentum_balance(box2d): + """Exactly, not approximately — the expressions are small enough to reduce.""" + + sol = uw.analytic.SolDB2d(box2d) + for residual in _momentum(sol): + assert sympy.simplify(_plain(sol, residual)) == 0 + + +def test_soldb2d_is_isoviscous(box2d): + assert uw.analytic.SolDB2d(box2d).fn_viscosity == 1 + + +@pytest.mark.parametrize("beta", [0.0, 4.0, 10.0]) +def test_soldb3d_is_incompressible(box3d, beta): + sol = uw.analytic.SolDB3d(box3d, beta=beta) + assert sympy.simplify(_plain(sol, _divergence(sol))) == 0 + + +@pytest.mark.parametrize("beta", [0.0, 4.0, 10.0]) +def test_soldb3d_satisfies_the_momentum_balance(box3d, beta): + """Holds for every viscosity exponent, including the isoviscous beta = 0.""" + + sol = uw.analytic.SolDB3d(box3d, beta=beta) + for residual in _momentum(sol): + assert sympy.simplify(_plain(sol, residual)) == 0 + + +def test_soldb3d_viscosity_is_the_published_form(box3d): + r""":math:`\eta = e^{1-\beta[x(1-x)+y(1-y)+z(1-z)]}`, peaked in the interior.""" + + beta = 4.0 + sol = uw.analytic.SolDB3d(box3d, beta=beta) + x, y, z = box3d.X + + # Rational, matching what the solution substitutes: an exact 4 and a float + # 4.0 in an exponent are equal but SymPy will not cancel them. + expected = sympy.exp( + 1 - sympy.Rational(beta) * (x * (1 - x) + y * (1 - y) + z * (1 - z)) + ) + assert sympy.simplify(_plain(sol, sol.fn_viscosity - expected)) == 0 + + # Smallest where the bracket is largest, i.e. at the centre of the cube. + eta = sympy.lambdify(tuple(box3d.X), sol.fn_viscosity, "numpy") + assert float(eta(0.5, 0.5, 0.5)) < float(eta(0.1, 0.1, 0.1)) + + +def test_soldb3d_stress_is_consistent_with_the_strain_rate(box3d): + r""":math:`\sigma = -p\,I + 2\eta\dot\varepsilon`. + + The kernel publishes the deviatoric stress and the strain rate separately, so + this checks two of its outputs against each other and pins the convention — + reading the deviator as if it were the total would leave the momentum + residual wrong by exactly :math:`\nabla p`. + """ + + sol = uw.analytic.SolDB3d(box3d, beta=4.0) + + deviator = sol.fn_stress + sol.fn_pressure * sympy.eye(3) + for i in range(3): + for j in range(3): + assert ( + sympy.simplify( + _plain(sol, deviator[i, j] - 2 * sol.fn_viscosity * sol.fn_strainrate[i, j]) + ) + == 0 + ) + + +def test_soldb_are_registered(box2d, box3d): + assert {"SolDB2d", "SolDB3d"} <= set(uw.analytic.available()) + assert uw.analytic.SolDB2d(box2d).dim == 2 + assert uw.analytic.SolDB3d(box3d).dim == 3 + + +def test_soldb3d_refuses_a_2d_mesh(box2d): + """The dimension check in the contract does its job.""" + + with pytest.raises(ValueError, match="3D solution"): + uw.analytic.SolDB3d(box2d) diff --git a/tests/test_1023_analytic_solkz.py b/tests/test_1023_analytic_solkz.py new file mode 100644 index 000000000..c8cacfd72 --- /dev/null +++ b/tests/test_1023_analytic_solkz.py @@ -0,0 +1,176 @@ +r"""SolKz — Stokes flow with a depth-dependent viscosity. + +The vertical twin of SolKx: :math:`\eta = e^{2Bz}` instead of :math:`e^{2Bx}`. +Not a redundant one — a viscosity varying with *depth* stratifies the flow along +the direction the buoyancy acts, coupling pressure and vertical velocity through +the varying coefficient in a way a horizontal gradient never does. It is also the +closer analogue of a real mantle viscosity profile. + +Validated by the equations: the forcing and the boundary conditions are known, so +satisfying Stokes with them identifies the solution uniquely. + +The convention trap this solution carries is pinned by +`test_kernel_publishes_the_deviatoric_stress`. Its kernel writes into an array +named `total_stress`, but the contents are the deviator. Reading the name at face +value leaves the momentum residual at order |f| *and* invents a horizontal body +force in a benchmark that has none. + +Run: pixi run python -m pytest tests/test_1023_analytic_solkz.py -v +""" + +import pytest + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + +import numpy as np +import sympy +import underworld3 as uw + + +# The canonical case only, on every PR. SolKz declares +# `expensive_to_validate`: an exponential viscosity makes every residual +# here a symbolic differentiation of a very large expression, and the four +# cases together were 189s of the analytic suite's 1010s. +# +# The remaining cases below are not dropped — they run in +# tests/analytic_full/, which sweeps this solution over its parameter table +# with the same residual gates. Every CHECK in this file still runs on every +# PR; what is reduced is how many parameter values it runs on. +CASES = [ + (2.302585092994046, 3, 2), +] + + +INTERIOR = np.array([(0.2, 0.3), (0.7, 0.8), (0.5, 0.5), (0.9, 0.15), (0.05, 0.95)]) +WALLS = { + "left": np.array([(0.0, t) for t in (0.13, 0.47, 0.82)]), + "right": np.array([(1.0, t) for t in (0.13, 0.47, 0.82)]), + "bottom": np.array([(t, 0.0) for t in (0.13, 0.47, 0.82)]), + "top": np.array([(t, 1.0) for t in (0.13, 0.47, 0.82)]), +} + + +@pytest.fixture(scope="module") +def mesh(): + return uw.meshing.StructuredQuadBox( + elementRes=(4, 4), minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), qdegree=3 + ) + + +@pytest.fixture(scope="module") +def cache(): + """One construction per (B, n, m), shared across the gates below. + + Constructing SolKz substitutes parameters into an expression tens of + thousands of operations long. Two gates run over every case, so building + afresh in each doubled the file's cost for nothing. + """ + + return {} + + +def _sol(cache, mesh, B, n, m): + key = (B, n, m) + if key not in cache: + cache[key] = uw.analytic.SolKz(mesh, B=B, n=n, m=m) + return cache[key] + + +def _at(sol, expression, points): + """Magnitudes over a whole point set — lambdified once, not once per point.""" + + from underworld3.analytic import _validation + + return np.abs(_validation.sample(sol, expression, points)) + + +@pytest.mark.parametrize("B,n,m", CASES) +def test_solkz_satisfies_the_stokes_equations(cache, mesh, B, n, m): + sol = _sol(cache, mesh, B, n, m) + x, z = mesh.X + + scale = _at(sol, sol.fn_bodyforce[0, 1], INTERIOR).max() + + residual_x = _at( + sol, + sympy.diff(sol.fn_stress[0, 0], x) + sympy.diff(sol.fn_stress[0, 1], z), + INTERIOR, + ) + residual_z = _at( + sol, + sympy.diff(sol.fn_stress[1, 0], x) + + sympy.diff(sol.fn_stress[1, 1], z) + + sol.fn_bodyforce[0, 1], + INTERIOR, + ) + divergence = _at( + sol, + sympy.diff(sol.fn_velocity[0, 0], x) + sympy.diff(sol.fn_velocity[0, 1], z), + INTERIOR, + ) + + assert residual_x.max() / scale < 1.0e-10 + assert residual_z.max() / scale < 1.0e-10 + assert divergence.max() < 1.0e-10 + + +@pytest.mark.parametrize("B,n,m", CASES) +def test_solkz_is_free_slip_on_every_wall(cache, mesh, B, n, m): + sol = _sol(cache, mesh, B, n, m) + vx, vz = sol.fn_velocity[0, 0], sol.fn_velocity[0, 1] + + assert _at(sol, vx, WALLS["left"]).max() < 1.0e-10 + assert _at(sol, vx, WALLS["right"]).max() < 1.0e-10 + assert _at(sol, vz, WALLS["bottom"]).max() < 1.0e-10 + assert _at(sol, vz, WALLS["top"]).max() < 1.0e-10 + + +def test_kernel_publishes_the_deviatoric_stress(mesh): + r"""The kernel's ``total_stress`` array holds :math:`\tau`, not :math:`\sigma`. + + Two independent signatures, both checked here because the array's *name* + says otherwise and following it silently breaks the momentum balance: + + - a deviator is traceless, so its xx and zz entries are exact negatives; + - :math:`\tau = 2\eta\dot\varepsilon`, and the strain rate follows from the + velocity, which is a different output of the same kernel. + """ + + sol = uw.analytic.SolKz(mesh, B=2.0, n=2, m=1) + x, z = mesh.X + + deviator = sol.fn_stress + sol.fn_pressure * sympy.eye(2) + + trace = _at(sol, deviator[0, 0] + deviator[1, 1], INTERIOR) + magnitude = _at(sol, deviator[0, 0], INTERIOR).max() + assert trace.max() / magnitude < 1.0e-10 + + from_velocity = sol.fn_viscosity * ( + sympy.diff(sol.fn_velocity[0, 0], z) + sympy.diff(sol.fn_velocity[0, 1], x) + ) + shear = _at(sol, deviator[0, 1] - from_velocity, INTERIOR) + shear_scale = _at(sol, deviator[0, 1], INTERIOR).max() + assert shear.max() / shear_scale < 1.0e-10 + + +def test_solkz_viscosity_varies_with_depth_not_width(mesh): + """The distinction from SolKx, asserted so a copy-paste error cannot hide.""" + + B = 2.0 + sol = uw.analytic.SolKz(mesh, B=B, n=2, m=1) + eta = sympy.lambdify(tuple(mesh.X), sol.fn_viscosity, "numpy") + + assert np.isclose(float(eta(0.2, 0.7)), np.exp(2 * B * 0.7)) + assert np.isclose(float(eta(0.9, 0.7)), np.exp(2 * B * 0.7)) # x makes no difference + assert float(eta(0.5, 0.1)) < float(eta(0.5, 0.9)) + + +def test_solkz_rejects_fractional_wavenumbers(mesh): + with pytest.raises(ValueError, match="n .*must be a positive integer"): + uw.analytic.SolKz(mesh, n=1.5) + with pytest.raises(ValueError, match="m .*must be a positive integer"): + uw.analytic.SolKz(mesh, m=2.5) + + +def test_solkz_is_registered(): + assert "SolKz" in uw.analytic.available() diff --git a/tests/test_1024_analytic_conformance.py b/tests/test_1024_analytic_conformance.py new file mode 100644 index 000000000..209be181b --- /dev/null +++ b/tests/test_1024_analytic_conformance.py @@ -0,0 +1,340 @@ +r"""Every registered solution, checked the same way. + +The per-solution test files check what is particular to each. This one checks +what is true of all of them, by iterating over `uw.analytic.available()` — so a +solution added later is covered the moment it is registered, and cannot ship +without these checks the way SolNL did. + +That is not hypothetical. SolNL's kernel publishes the deviatoric stress; it was +stored as the total, and its momentum residual was 1.06 rather than zero. Its own +test file checked agreement with the kernel and incompressibility, both of which +passed, and nothing checked the momentum balance. The stress convention is now a +declaration (`stress_is_deviatoric`) applied in one place, and this file makes +the omission impossible to repeat. + +Run: pixi run python -m pytest tests/test_1024_analytic_conformance.py -v +""" + +import pytest + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + +import pathlib + +import numpy as np +import sympy +import underworld3 as uw + + +# Solutions that are SymPy and installable here. The two exclusions are +# declared by the solutions themselves, not listed by name, so a later solution +# in either category is handled without touching this file. +# +# symbolic = False an external numeric oracle; nothing to differentiate, so +# the residual gates cannot be applied to it at all +# requires = "..." an optional dependency that is not installed +# +# The excluded names are asserted below, so an accidental exclusion — a typo in +# a class attribute, say — fails here rather than quietly shrinking the sweep. +ALL_SYMBOLIC = sorted( + name + for name in uw.analytic.available() + if getattr(uw.analytic, name).symbolic and uw.analytic.is_available(name) +) + +# The per-PR sweep drops the five solutions that declare themselves expensive +# (see AnalyticSolution.expensive_to_validate). Measured, they are 565s of the +# suite's 1010s; the rest of the family together is about 17s. +# +# Every gate below still runs on every solution it covers — the reduction is in +# solutions per run, never in checks per solution. tests/analytic_full/ runs the +# whole family, gates and all, and is not matched by any CI batch glob. +SOLUTIONS = [n for n in ALL_SYMBOLIC if not getattr(uw.analytic, n).expensive_to_validate] + +SKIPPED_AS_EXPENSIVE = [n for n in ALL_SYMBOLIC if n not in SOLUTIONS] + + +def test_the_sweep_covers_everything_it_can(): + """What this file skips, and on what declared grounds.""" + + excluded = set(uw.analytic.available()) - set(ALL_SYMBOLIC) + + for name in excluded: + solution = getattr(uw.analytic, name) + assert not solution.symbolic or not uw.analytic.is_available(name), ( + f"{name} is symbolic and available but is not being swept" + ) + + assert len(ALL_SYMBOLIC) >= 19, "the sweep has lost solutions" + + +def test_the_expensive_solutions_are_covered_somewhere(): + """The per-PR sweep drops solutions; something has to still cover them. + + Asserts the full-family file exists and names every solution this file skips, + so dropping one here cannot quietly drop it everywhere. That file is the one + place they run, and nothing in CI runs it — this is the link that keeps it + honest. + """ + + full = pathlib.Path(__file__).parent / "analytic_full" / "test_analytic_full_family.py" + assert full.exists(), f"the full-family sweep is missing: {full}" + + text = full.read_text() + for name in SKIPPED_AS_EXPENSIVE: + assert name in text, ( + f"{name} is skipped here as expensive but is not named in {full.name}" + ) + + +@pytest.fixture(scope="module") +def meshes(): + return { + 2: uw.meshing.StructuredQuadBox( + elementRes=(4, 4), minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), qdegree=3 + ), + 3: uw.meshing.StructuredQuadBox( + elementRes=(2, 2, 2), + minCoords=(0.0, 0.0, 0.0), + maxCoords=(1.0, 1.0, 1.0), + qdegree=2, + ), + } + + +@pytest.fixture(scope="module") +def built(meshes): + """Every registered solution, constructed once. + + Construction substitutes parameters into expressions that run to tens of + thousands of operations for the series solutions, so building afresh in each + test dominates the runtime of the whole file. + + Building them all here also asserts something worth asserting: every solution + must be constructible from a mesh alone. The defaults are part of the + interface, not an afterthought. + """ + + return { + name: getattr(uw.analytic, name)(meshes[getattr(uw.analytic, name).dim]) + for name in SOLUTIONS + } + + +@pytest.mark.parametrize("name", SOLUTIONS) +def test_solution_declares_its_metadata(name): + """dim, a citation, and a stress convention — all stated, none inferred.""" + + solution = getattr(uw.analytic, name) + + assert solution.dim in (2, 3), f"{name} must declare its dimension" + assert solution.reference, f"{name} must cite where it came from" + assert isinstance(solution.stress_is_deviatoric, bool) + assert isinstance(solution.nonlinear, bool) + + +STOKES = [n for n in SOLUTIONS if getattr(uw.analytic, n).solves == "stokes"] +TRANSPORT = [n for n in SOLUTIONS if getattr(uw.analytic, n).solves == "transport"] +RICHARDS = [n for n in SOLUTIONS if getattr(uw.analytic, n).solves == "richards"] + +# Every registered solution belongs to exactly one family, so adding a solution +# with a new `solves` value fails here rather than being quietly untested. +assert set(STOKES) | set(TRANSPORT) | set(RICHARDS) == set(SOLUTIONS) + + +@pytest.mark.parametrize("name", STOKES) +def test_solution_exposes_the_whole_contract(name, built): + sol = built[name] + dim = sol.dim + + assert sol.fn_velocity.shape == (1, dim) + assert sol.fn_bodyforce.shape == (1, dim) + assert sol.fn_stress.shape == (dim, dim) + assert sol.fn_strainrate.shape == (dim, dim) + assert sol.fn_pressure is not None + assert sol.fn_viscosity is not None + + +@pytest.mark.parametrize("name", STOKES) +def test_solution_is_incompressible(name, built): + from underworld3.analytic import _validation + + sol = built[name] + points = sol.sample_points(count=8) + + assert _validation.incompressibility_residual(sol, points) < 1.0e-8 + + +@pytest.mark.parametrize("name", STOKES) +def test_solution_satisfies_the_momentum_balance(name, built): + r""":math:`\nabla\cdot\sigma + \mathbf f = 0`, for every solution. + + The check that catches a stress-convention error, and the one SolNL was + missing. It consults no reference and no solver, so it cannot be fooled by a + mistake shared between the solution and something derived from it. + """ + + from underworld3.analytic import _validation + + sol = built[name] + points = sol.sample_points(count=8) + + assert _validation.momentum_residual(sol, points) < 1.0e-8 + + +@pytest.mark.parametrize("name", STOKES) +def test_strain_rate_matches_the_velocity(name, built): + r""":math:`\tfrac12(\nabla\mathbf u + \nabla\mathbf u^{T})` vs `fn_strainrate`. + + This is the independent one, and it belongs in the family-wide sweep rather + than in a single solution's file. The velocity and the stress are *separate + outputs of the same kernel*, and the strain rate here is derived from the + stress — so the comparison relates two quantities the source derived + separately, and it exercises the derivatives, which is what a solver + consumes and where a transcription can be wrong while still matching + pointwise. + + Contrast `test_stress_and_strain_rate_agree` below, which for most of the + family is structural. + """ + + from underworld3.analytic import _validation + + sol = built[name] + points = sol.sample_points(count=8) + + assert _validation.strainrate_consistency(sol, points) < 1.0e-8 + + +# `set_fields` accepts a stress OR a strain rate and derives the other from +# sigma + p I = 2 eta edot. For a solution that supplied only one of the two, +# the assertion below is exactly that derivation read back, so it is structural +# rather than evidential. These three supply BOTH, so for them it compares two +# separately published quantities and is a real check. +# +# It is kept for the whole family regardless: it is nearly free, and it is what +# would catch `set_fields` itself regressing. +PUBLISH_BOTH = {"SolNL", "SolDB2d", "SolDB3d"} + + +def test_the_list_of_solutions_publishing_both_is_accurate(built): + """PUBLISH_BOTH is a claim about the sources; check it against them. + + A solution that starts supplying both — or stops — silently changes what the + gate below is worth, so the claim is asserted rather than commented. + + The fact is read from `set_fields`, which records what it was handed, rather + than scraped from the source text. Scraping gets it wrong in the one case + that matters: `EllipticalInclusion` never calls `set_fields` at all, and a + walk up the MRO finds `stress=` and `strainrate=` in *this base class's own + signature*. + """ + + for name in STOKES: + both = built[name].publishes_both_stress_and_strainrate + assert both == (name in PUBLISH_BOTH), ( + f"{name}: publishes both = {both}, but PUBLISH_BOTH says " + f"{name in PUBLISH_BOTH}" + ) + + +@pytest.mark.parametrize("name", STOKES) +def test_stress_and_strain_rate_agree(name, built): + r""":math:`\sigma + p\,I = 2\eta\dot\varepsilon`, however each was obtained. + + Some solutions publish both and some derive one from the other; either way + the pair has to be consistent, and a wrong `stress_is_deviatoric` shows up + here as a full pressure's worth of disagreement. + + For the solutions NOT in `PUBLISH_BOTH` this is the identity `set_fields` + used to build the missing quantity, so passing it is structural. See + `test_strain_rate_matches_the_velocity` above for the independent check. + """ + + from underworld3.analytic import _validation + + sol = built[name] + points = sol.sample_points(count=8) + identity = sympy.eye(sol.dim) + + deviator = sol.fn_stress + sol.fn_pressure * identity + scale = max( + np.abs(_validation.sample(sol, deviator[i, j], points)).max() + for i in range(sol.dim) + for j in range(sol.dim) + ) + + for i in range(sol.dim): + for j in range(sol.dim): + difference = deviator[i, j] - 2 * sol.fn_viscosity * sol.fn_strainrate[i, j] + assert np.abs(_validation.sample(sol, difference, points)).max() / scale < 1.0e-8 + + +# The boundary-condition mixins are exercised in test_1016_analytic_contract.py. +# Building a Stokes solver per solution here as well was not worth what it cost: +# it dominated the runtime of this file without checking anything the contract +# tests do not already cover. + + +@pytest.mark.parametrize("name", TRANSPORT) +def test_transport_solution_exposes_its_contract(name, built): + sol = built[name] + + assert sol.fn_solution is not None + assert sol.fn_coefficient is not None + assert sol.fn_source is not None + + +@pytest.mark.parametrize("name", TRANSPORT) +def test_transport_solution_satisfies_its_equation(name, built): + r"""Steady or transient, whichever the solution declares. + + A time symbol means the equation is + :math:`\partial_t u + \mathbf v\cdot\nabla u = \nabla\cdot(k\nabla u)`, and + those are checked at three times rather than skipped. Skipping them here is + what let `AdvectedFront` be judged against the wrong equation for as long as + it was — the whole point of this file is that no registered solution goes + unchecked. + """ + + from underworld3.analytic import _validation + + sol = built[name] + points = sol.sample_points(count=8) + + if getattr(sol, "t", None) is None: + assert _validation.transport_residual(sol, points) < 1.0e-10 + return + + for time in (0.05, 0.2, 0.5): + assert _validation.diffusion_residual(sol, points, time) < 1.0e-10 + + +@pytest.mark.parametrize("name", RICHARDS) +def test_richards_solution_exposes_its_contract(name, built): + sol = built[name] + + assert sol.fn_solution is not None + assert sol.fn_conductivity is not None + assert sol.fn_capacity is not None + + +@pytest.mark.parametrize("name", RICHARDS) +def test_richards_solution_satisfies_its_equation(name, built): + r""":math:`C(\psi)\partial_t\psi = \nabla\cdot[K(\psi)(\nabla\psi + \hat y)]`. + + Nonlinear, and the conductivity is the solution's own — so this consults + nothing external, exactly as the Stokes momentum residual does. + """ + + from underworld3.analytic import _validation + + sol = built[name] + points = sol.sample_points(count=8) + + if getattr(sol, "t", None) is None: + assert _validation.richards_residual(sol, points) < 1.0e-10 + return + + for time in (0.05, 0.2): + assert _validation.richards_residual(sol, points, time) < 1.0e-10 diff --git a/tests/test_1025_analytic_transport.py b/tests/test_1025_analytic_transport.py new file mode 100644 index 000000000..7b0011586 --- /dev/null +++ b/tests/test_1025_analytic_transport.py @@ -0,0 +1,257 @@ +r"""The scalar transport solutions. + +Diffusion, advection–diffusion, Darcy and Poisson — one unknown field rather than +the velocity-and-pressure pair the Velic family solves for. These were already in +the repository, inline in the tests that used them; collected into +`uw.analytic` they gain the same oracle-free residual the Stokes solutions have. + +The steady ones reduce to *exactly* zero, so those assertions are exact. The +transient ones carry a time symbol and are checked at several times against +:math:`\partial_t u + \mathbf v\cdot\nabla u = \nabla\cdot(k\nabla u)`. + +Run: pixi run python -m pytest tests/test_1025_analytic_transport.py -v +""" + +import pytest + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + +import numpy as np +import sympy +import underworld3 as uw + + +@pytest.fixture(scope="module") +def mesh(): + return uw.meshing.StructuredQuadBox( + elementRes=(4, 4), minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), qdegree=3 + ) + + +@pytest.mark.parametrize("source", ["none", "constant", "sinusoid"]) +def test_poisson_satisfies_its_equation(mesh, source): + from underworld3.analytic import _validation + + sol = uw.analytic.Poisson1D(mesh, source=source) + assert _validation.transport_residual(sol, sol.sample_points(count=8)) == 0.0 + + +def test_poisson_profiles_are_what_they_claim(mesh): + """Short enough to assert outright, which is the cheapest possible check.""" + + x, z = mesh.X + + assert sympy.simplify(uw.analytic.Poisson1D(mesh, "none").fn_solution - (1 - z)) == 0 + assert ( + sympy.simplify(uw.analytic.Poisson1D(mesh, "constant").fn_solution - z * (1 - z)) + == 0 + ) + assert ( + sympy.simplify( + uw.analytic.Poisson1D(mesh, "sinusoid").fn_solution - sympy.sin(sympy.pi * z) + ) + == 0 + ) + + +def test_poisson_rejects_an_unknown_source(mesh): + with pytest.raises(ValueError, match="source must be one of"): + uw.analytic.Poisson1D(mesh, source="quartic") + + +def test_two_layer_darcy_satisfies_its_equation(mesh): + r""":math:`\nabla\cdot(k\nabla p) = 0` across a permeability jump.""" + + from underworld3.analytic import _validation + + sol = uw.analytic.TwoLayerDarcy(mesh, k_lower=1.0, k_upper=0.1) + assert _validation.transport_residual(sol, sol.sample_points(count=8)) == 0.0 + + +def test_two_layer_darcy_conserves_flux_across_the_interface(mesh): + r""":math:`k\,\partial p/\partial z` is continuous even though the gradient is not. + + This is the physical content of the solution and the thing a Darcy solver + has to get right, so it is asserted directly rather than inferred from the + residual. + """ + + from underworld3.analytic import _validation + + k1, k2, interface = 1.0, 0.1, 0.5 + sol = uw.analytic.TwoLayerDarcy( + mesh, k_lower=k1, k_upper=k2, interface=interface + ) + x, z = mesh.X + + gradient = sympy.diff(sol.fn_solution, z) + below = np.array([[0.4, interface - 0.05]]) + above = np.array([[0.4, interface + 0.05]]) + + flux_below = k1 * _validation.sample(sol, gradient, below)[0] + flux_above = k2 * _validation.sample(sol, gradient, above)[0] + + assert np.isclose(flux_below, flux_above, rtol=1.0e-10) + # ... and the gradient itself is genuinely discontinuous, so the test above + # is not passing for the trivial reason that nothing changes at all. + assert not np.isclose( + _validation.sample(sol, gradient, below)[0], + _validation.sample(sol, gradient, above)[0], + ) + + +@pytest.mark.parametrize("time", [0.05, 0.2, 0.5]) +def test_erfc_diffusion_satisfies_the_heat_equation(mesh, time): + from underworld3.analytic import _validation + + sol = uw.analytic.ErfcDiffusion(mesh, diffusivity=0.5) + assert _validation.diffusion_residual(sol, sol.sample_points(count=8), time) < 1e-10 + + +@pytest.mark.parametrize("time", [0.05, 0.2, 0.5]) +def test_advected_front_satisfies_advection_diffusion(mesh, time): + r""":math:`\partial_t c + u\,\partial_x c = \kappa\,\partial_{xx} c`. + + The advection term matters: without it this same solution reports a residual + of order one, which looks like a broken solution rather than a check applied + to the wrong equation. + """ + + from underworld3.analytic import _validation + + sol = uw.analytic.AdvectedFront(mesh, kappa=1.0e-2, speed=0.5) + assert _validation.diffusion_residual(sol, sol.sample_points(count=8), time) < 1e-10 + + +def test_advected_front_travels(mesh): + """The pulse is where advection says it should be, and it spreads.""" + + from underworld3.analytic import _validation + + speed, x0, x1 = 0.5, 0.1, 0.3 + sol = uw.analytic.AdvectedFront(mesh, kappa=1.0e-4, speed=speed, x0=x0, x1=x1) + + centre = (x0 + x1) / 2 + for time in (0.2, 0.6): + expected = centre + speed * time + here = np.array([[expected, 0.5]]) + elsewhere = np.array([[expected - 0.3, 0.5]]) + + at_pulse = _validation.sample(sol, sol.fn_solution.subs(sol.t, time), here)[0] + away = _validation.sample(sol, sol.fn_solution.subs(sol.t, time), elsewhere)[0] + + assert at_pulse > 0.9, "pulse is not where advection puts it" + assert away < 0.1, "pulse has not stayed compact" + + +def test_transport_solutions_prescribe_their_own_field(mesh): + """They cannot reuse the Stokes mixins, which apply a velocity.""" + + sol = uw.analytic.Poisson1D(mesh) + + temperature = uw.discretisation.MeshVariable("Tb", mesh, 1, degree=2) + poisson = uw.systems.Poisson(mesh, temperature) + sol.apply_boundary_conditions(poisson) + + assert {bc.boundary for bc in poisson.essential_bcs} == set(sol.boundaries) + + +def test_transport_solutions_are_registered(): + registered = set(uw.analytic.available()) + assert {"Poisson1D", "TwoLayerDarcy", "ErfcDiffusion", "AdvectedFront"} <= registered + + for name in ("Poisson1D", "TwoLayerDarcy", "ErfcDiffusion", "AdvectedFront"): + assert getattr(uw.analytic, name).solves == "transport" + + +# --------------------------------------------------------------------------- +# The t = 0 singularity +# --------------------------------------------------------------------------- + + +TRANSIENT = [ + (uw.analytic.ErfcDiffusion, dict(diffusivity=0.5)), + (uw.analytic.AdvectedFront, dict(kappa=1.0e-2, speed=0.5)), +] + + +@pytest.mark.parametrize("cls, kwargs", TRANSIENT) +def test_transient_solutions_declare_their_singularity(mesh, cls, kwargs): + sol = cls(mesh, **kwargs) + + assert sol.singular_at_origin is True + assert sol.diffusivity is not None and sol.diffusivity > 0 + + +@pytest.mark.parametrize("cls, kwargs", TRANSIENT) +@pytest.mark.parametrize("time", [0.0, -1.0]) +def test_the_singular_origin_is_refused(mesh, cls, kwargs, time): + r"""At :math:`t = 0` the profile is a step with unbounded gradient. + + No finite element space holds that, so returning it would only ever produce + a benchmark measuring its own projection error. The refusal is the point: + the caller has to choose a start time, and be told why. + """ + + sol = cls(mesh, **kwargs) + + with pytest.raises(ValueError, match="singular at t = 0"): + sol.at(time) + + +@pytest.mark.parametrize("cls, kwargs", TRANSIENT) +def test_a_positive_time_is_allowed_and_is_the_right_field(mesh, cls, kwargs): + from underworld3.analytic import _validation + + sol = cls(mesh, **kwargs) + frozen = sol.at(0.3) + + assert sol.t not in frozen.free_symbols + points = sol.sample_points(count=6) + assert np.allclose( + _validation.sample(sol, frozen, points), + _validation.sample(sol, sol.fn_solution.subs(sol.t, 0.3), points), + ) + + +def test_steady_solutions_have_no_time_to_be_evaluated_at(mesh): + with pytest.raises(ValueError, match="steady"): + uw.analytic.Poisson1D(mesh).at(0.5) + + +@pytest.mark.parametrize("cls, kwargs", TRANSIENT) +def test_the_resolvable_floor_scales_as_h_squared_over_D(mesh, cls, kwargs): + r""":math:`t_0 = (n_{\rm el} h / 2)^2 / D`, so refining buys an early start.""" + + sol = cls(mesh, **kwargs) + + coarse = sol.earliest_resolvable_time(0.1) + fine = sol.earliest_resolvable_time(0.05) + + assert np.isclose(coarse / fine, 4.0), "floor must fall as h^2" + assert np.isclose(sol.earliest_resolvable_time(0.1, elements_across=8) / coarse, 4.0) + + +@pytest.mark.parametrize("cls, kwargs", TRANSIENT) +def test_the_floor_really_does_resolve_the_front(mesh, cls, kwargs): + """The number is only worth having if it means what it claims. + + At the returned time the diffusive width should be `elements_across` + elements wide — that is the definition, asserted rather than trusted. + """ + + sol = cls(mesh, **kwargs) + h, across = 0.02, 4 + + t0 = sol.earliest_resolvable_time(h, elements_across=across) + width = 2.0 * np.sqrt(sol.diffusivity * t0) + + assert np.isclose(width / h, across) + + +def test_a_solution_with_no_diffusive_scale_says_so(mesh): + sol = uw.analytic.ErfcDiffusion(mesh) + sol.diffusivity = None + + with pytest.raises(ValueError, match="no diffusivity"): + sol.earliest_resolvable_time(0.01) diff --git a/tests/test_1026_analytic_richards.py b/tests/test_1026_analytic_richards.py new file mode 100644 index 000000000..753aa8e6b --- /dev/null +++ b/tests/test_1026_analytic_richards.py @@ -0,0 +1,272 @@ +r"""The Gardner unsaturated-flow solutions. + +Richards is the one nonlinear scalar equation in the suite: the conductivity +depends on the unknown head. Gardner's :math:`K = K_s e^{\alpha\psi}` is the case +that closes, because :math:`u = e^{\alpha\psi}` linearises it *exactly*. + +These solutions were already in the repository as NumPy functions in +`utilities/retention_curves.py`, where nothing checked them against the equation +they solve. Both are exact here, so the residual assertions are tight. + +Run: pixi run python -m pytest tests/test_1026_analytic_richards.py -v +""" + +import pytest + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + +import numpy as np +import sympy +import underworld3 as uw + + +@pytest.fixture(scope="module") +def mesh(): + return uw.meshing.StructuredQuadBox( + elementRes=(4, 4), minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), qdegree=3 + ) + + +def test_steady_satisfies_richards(mesh): + from underworld3.analytic import _validation + + sol = uw.analytic.GardnerSteady(mesh) + assert _validation.richards_residual(sol, sol.sample_points(count=8)) < 1e-12 + + +@pytest.mark.parametrize("time", [0.05, 0.1, 0.2]) +def test_transient_satisfies_richards(mesh, time): + from underworld3.analytic import _validation + + sol = uw.analytic.GardnerTransient(mesh) + points = sol.sample_points(count=8) + assert _validation.richards_residual(sol, points, time) < 1e-12 + + +@pytest.mark.parametrize( + "break_it, label", + [ + ( + lambda sol: setattr( + sol, + "fn_conductivity", + sol.Ks * sympy.exp(sympy.Rational(11, 10) * sol.alpha * sol.fn_solution), + ), + "wrong alpha in K", + ), + ( + lambda sol: setattr(sol, "fn_conductivity", sympy.sympify(sol.Ks)), + "conductivity independent of head", + ), + ( + lambda sol: setattr( + sol, "fn_solution", sol.fn_solution * sympy.Rational(101, 100) + ), + "head scaled by 1%", + ), + ], +) +def test_the_residual_discriminates(mesh, break_it, label): + """Gate 5: a check that passes a broken input is measuring nothing. + + Note what is *not* used as a control here. Scaling :math:`K` by a constant + leaves the residual at zero — correctly, because that is a genuine symmetry + of the steady equation, not a defect the check missed. A negative control + has to break the relationship between the conductivity and the head, which + is the thing the solution actually asserts. + """ + + from underworld3.analytic import _validation + + sol = uw.analytic.GardnerSteady(mesh) + points = sol.sample_points(count=8) + assert _validation.richards_residual(sol, points) < 1e-12 + + break_it(sol) + assert _validation.richards_residual(sol, points) > 1e-3, label + + +def test_scaling_conductivity_is_a_symmetry_not_a_defect(mesh): + """The counterpart to the control above, asserted rather than assumed.""" + + from underworld3.analytic import _validation + + sol = uw.analytic.GardnerSteady(mesh) + sol.fn_conductivity = 2 * sol.fn_conductivity + assert _validation.richards_residual(sol, sol.sample_points(count=8)) < 1e-12 + + +def test_steady_flux_is_constant_down_the_column(mesh): + r""":math:`K(\psi)(\partial_y\psi + 1)` is the same at every height. + + This is the physical content of the steady solution and a sharper test of a + Richards solver than the head profile: the head can look right while the + conductivity is evaluated at the wrong place, and then the flux drifts. + """ + + from underworld3.analytic import _validation + + sol = uw.analytic.GardnerSteady(mesh) + y = mesh.X[1] + + flux = sol.fn_conductivity * (sympy.diff(sol.fn_solution, y) + 1) + heights = np.column_stack( + [np.full(9, 0.5), np.linspace(0.05, 0.95, 9)] + ) + values = _validation.sample(sol, flux, heights) + + assert np.ptp(values) / np.abs(values).max() < 1e-12 + # and it is the flux the construction reported + assert np.allclose(values, float(sol.Ks) * float(sol.flux), rtol=1e-10) + + +def test_steady_meets_its_boundary_heads(mesh): + from underworld3.analytic import _validation + + psi_0, psi_L = -1.5, -4.0 + sol = uw.analytic.GardnerSteady(mesh, psi_0=psi_0, psi_L=psi_L, L=1.0) + + ends = np.array([[0.5, 0.0], [0.5, 1.0]]) + assert np.allclose( + _validation.sample(sol, sol.fn_solution, ends), [psi_0, psi_L], rtol=1e-10 + ) + + +def _half_saturation_depth(sol, time, alpha, psi_dry, psi_wet): + r"""Depth at which :math:`u = e^{\alpha\psi}` is halfway between its limits. + + Halfway in :math:`u`, not in :math:`\psi`. The two are very different: at + :math:`\alpha=1` with :math:`\psi` running from -5 to -0.5, the midpoint + *head* sits at :math:`H \approx 0.1`, out in the leading tail, and tracking + it measures how far the tail has spread rather than where the front is. + """ + + from underworld3.analytic import _validation + + level = np.log((np.exp(alpha * psi_dry) + np.exp(alpha * psi_wet)) / 2) / alpha + column = np.column_stack([np.full(2000, 0.5), np.linspace(0.0, 1.0, 2000)]) + values = _validation.sample(sol, sol.fn_solution.subs(sol.t, time), column) + + wetted = column[values > level, 1] + return 1.0 - wetted.min() if wetted.size else np.nan + + +def test_transient_front_advances_at_the_advective_speed(mesh): + r"""The front tracks :math:`V = K_s/\Delta\theta` when advection dominates. + + Checked at :math:`\mathrm{Pe} = \alpha L = 20`, where the front is sharp + enough for "where the front is" to mean something. The default parameters + give :math:`\mathrm{Pe} = 1` — see the test below, which is the same + measurement and deliberately does *not* assert the advective speed. + """ + + from underworld3.analytic import _validation + + alpha, psi_dry, psi_wet = 20.0, -0.5, -0.05 + sol = uw.analytic.GardnerTransient( + mesh, psi_dry=psi_dry, psi_wet=psi_wet, alpha=alpha + ) + assert np.isclose(sol.speed, 1.0 / (0.45 - 0.05)) + assert np.isclose(sol.speed / sol.diffusivity, alpha) + + early = _half_saturation_depth(sol, 0.05, alpha, psi_dry, psi_wet) + late = _half_saturation_depth(sol, 0.15, alpha, psi_dry, psi_wet) + + assert late > early, "front did not advance" + assert np.isclose(late - early, sol.speed * 0.10, rtol=0.15) + + surface = np.array([[0.5, 1.0]]) + head = sol.fn_solution.subs(sol.t, 0.05) + assert _validation.sample(sol, head, surface)[0] > psi_dry, "surface not wet" + + +def test_transient_front_is_diffusion_dominated_at_unit_peclet(mesh): + """At Pe = 1 the front outruns advection, because it is mostly spreading. + + Recorded so the tolerance in the test above is not mistaken for a general + claim about where the front is: at the default parameters the same + measurement gives roughly 1.6x the advective distance, and that is the + solution behaving correctly rather than a defect. + """ + + alpha, psi_dry, psi_wet = 1.0, -5.0, -0.5 + sol = uw.analytic.GardnerTransient( + mesh, psi_dry=psi_dry, psi_wet=psi_wet, alpha=alpha + ) + assert np.isclose(sol.speed / sol.diffusivity, alpha) + + early = _half_saturation_depth(sol, 0.05, alpha, psi_dry, psi_wet) + late = _half_saturation_depth(sol, 0.15, alpha, psi_dry, psi_wet) + + assert late > early + assert (late - early) > 1.3 * sol.speed * 0.10 + + +def test_transient_reports_when_the_semi_infinite_form_expires(mesh): + """The solution is only usable while the front is inside the column.""" + + sol = uw.analytic.GardnerTransient(mesh, L=1.0) + + assert sol.front_depth(0.1) < 1.0 + assert sol.front_depth(1.0) > 1.0 + + +@pytest.mark.parametrize( + "kwargs, message", + [ + (dict(alpha=0.0), "alpha and Ks must be positive"), + (dict(Ks=-1.0), "alpha and Ks must be positive"), + (dict(theta_r=0.5, theta_s=0.4), "theta_s must exceed theta_r"), + ], +) +def test_rejects_unphysical_parameters(mesh, kwargs, message): + with pytest.raises(ValueError, match=message): + uw.analytic.GardnerSteady(mesh, **kwargs) + + +def test_transient_requires_the_surface_to_be_wetter(mesh): + with pytest.raises(ValueError, match="psi_wet must be wetter"): + uw.analytic.GardnerTransient(mesh, psi_dry=-0.5, psi_wet=-5.0) + + +def test_retention_curve_wrappers_agree_with_the_solution(mesh): + """The NumPy functions and the mesh classes are one formula, not two. + + `retention_curves` keeps its published signatures; this asserts it did not + keep a second copy of the arithmetic along with them. + """ + + from underworld3.analytic import _validation + from underworld3.utilities import retention_curves as rc + + heights = np.linspace(0.05, 0.95, 11) + points = np.column_stack([np.full(heights.size, 0.5), heights]) + + steady = uw.analytic.GardnerSteady( + mesh, psi_0=-1.0, psi_L=-5.0, L=1.0, alpha=1.0 + ) + assert np.allclose( + _validation.sample(steady, steady.fn_solution, points), + rc.gardner_steady_state_psi(heights, -1.0, -5.0, 1.0, 1.0), + rtol=1e-12, + ) + + transient = uw.analytic.GardnerTransient( + mesh, psi_dry=-5.0, psi_wet=-0.5, L=1.0, Ks=1.0, alpha=1.0, + theta_r=0.05, theta_s=0.45, + ) + assert np.allclose( + _validation.sample(transient, transient.fn_solution.subs(transient.t, 0.1), points), + rc.gardner_transient_psi( + heights, 0.1, -5.0, -0.5, 1.0, 1.0, 1.0, 0.05, 0.45 + ), + rtol=1e-12, + ) + + +def test_richards_solutions_are_registered(): + registered = set(uw.analytic.available()) + assert {"GardnerSteady", "GardnerTransient"} <= registered + + for name in ("GardnerSteady", "GardnerTransient"): + assert getattr(uw.analytic, name).solves == "richards" diff --git a/tests/test_1027_analytic_optional.py b/tests/test_1027_analytic_optional.py new file mode 100644 index 000000000..497335972 --- /dev/null +++ b/tests/test_1027_analytic_optional.py @@ -0,0 +1,327 @@ +r"""Solutions behind an optional dependency. + +`assess` (Kramer et al. 2021) is imported by four curved-geometry benchmark +scripts under `docs/examples/` and declared in no dependency list — so those +scripts fail with a bare `ModuleNotFoundError` on a normal install. It is now an +optional extra with a wrapper that says what to do about it. + +Both paths run everywhere. The present case needs the package, which pixi.toml +now supplies to the dev environments; the missing case is *simulated* rather +than waited for, because once `assess` is installed the path that mattered most +would otherwise never be exercised again — least of all in CI. + +The solution itself is numeric, so none of the six SymPy gates reach it. What +can still be checked is checked by finite differences: incompressibility, and +the boundary condition each case claims. Both carry their own controls. + +Run: pixi run python -m pytest tests/test_1027_analytic_optional.py -v +""" + +import pytest + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + +import importlib.util +import sys + +import numpy as np +import underworld3 as uw + +from underworld3.analytic.kramer import assess_available + +_real_find_spec = importlib.util.find_spec + +needs_assess = pytest.mark.skipif( + not assess_available(), reason="the optional 'assess' package is not installed" +) + + +def test_importing_underworld_does_not_need_the_optional_package(): + """The whole point of a lazy import: absence must cost nothing at import.""" + + import underworld3.analytic.kramer # noqa: F401 + + assert uw.analytic.CylindricalStokes is not None + + +def test_the_solution_is_listed_even_when_it_cannot_be_built(): + """Listed, and marked — not omitted. + + Omitting it would make `available()` truthful about what constructs and + silent about what exists, which leaves a user who wants a curved-geometry + benchmark with no way to discover that one is a `pip install` away. + """ + + assert "CylindricalStokes" in uw.analytic.available() + assert uw.analytic.is_available("CylindricalStokes") == assess_available() + + installed = uw.analytic.available(installed_only=True) + assert ("CylindricalStokes" in installed) == assess_available() + + # The missing case is covered unconditionally by the `without_assess` + # tests below, which simulate absence rather than waiting for it. + + +def test_every_other_solution_is_unconditionally_available(): + for name in uw.analytic.available(): + if name == "CylindricalStokes": + continue + assert uw.analytic.is_available(name), name + assert getattr(uw.analytic, name).requires is None + + +@pytest.fixture +def without_assess(monkeypatch): + """Make `assess` unimportable, whether or not it is installed. + + The missing-dependency path is the one a normal install takes and the one + the previous arrangement got wrong, so it must be tested *everywhere* — not + only on machines that happen to lack the package. `assess` is now a dev + dependency in pixi.toml, so skipping when it is present would mean these + tests never run in CI and the wrapper's whole purpose goes unchecked. + """ + + import builtins + + real_import = builtins.__import__ + + def blocked(name, *args, **kwargs): + if name == "assess" or name.startswith("assess."): + raise ImportError("No module named 'assess'") + return real_import(name, *args, **kwargs) + + monkeypatch.delitem(sys.modules, "assess", raising=False) + monkeypatch.setattr(builtins, "__import__", blocked) + monkeypatch.setattr( + importlib.util, "find_spec", lambda name, *a, **k: None + if name == "assess" + else _real_find_spec(name, *a, **k) + ) + + +def test_the_absence_simulation_actually_blocks(without_assess): + """Negative control for the fixture itself. + + If the monkeypatching silently stopped working — a Python version changing + how imports resolve, say — every test below would pass by importing the real + package, and would look like coverage of a path they never touched. + """ + + from underworld3.analytic import kramer + + assert not uw.analytic.is_available("CylindricalStokes") + with pytest.raises(ImportError): + kramer.require_assess() + + +def test_describe_says_what_is_missing(without_assess): + summary = uw.analytic.describe("CylindricalStokes") + + assert "unavailable" in summary + assert "assess" in summary + + +def test_it_is_dropped_from_the_installed_only_listing(without_assess): + assert "CylindricalStokes" in uw.analytic.available() + assert "CylindricalStokes" not in uw.analytic.available(installed_only=True) + + +def test_constructing_it_explains_rather_than_tracebacks(without_assess): + mesh = uw.meshing.Annulus(radiusOuter=1.0, radiusInner=0.5, cellSize=0.2) + + with pytest.raises(ImportError) as raised: + uw.analytic.CylindricalStokes(mesh) + + message = str(raised.value) + assert "pip install" in message + assert "assess" in message + # and it cites the paper, so the user knows what they are installing + assert "Kramer" in message + + +def test_it_declares_that_the_residual_gates_cannot_reach_it(): + """A numeric oracle is not a validated solution, and must not pass as one. + + Every other solution is SymPy and clears the physics residual. This one + cannot: there is no expression to differentiate. The declaration is what + keeps the conformance sweep from either skipping it silently or reporting a + pass it never earned. + """ + + assert uw.analytic.CylindricalStokes.symbolic is False + assert uw.analytic.CylindricalStokes.requires == "assess" + + for name in uw.analytic.available(): + if name != "CylindricalStokes": + assert getattr(uw.analytic, name).symbolic, name + + +def test_it_rejects_unknown_cases_before_reaching_the_dependency(monkeypatch): + """Argument validation must not be hidden behind the optional import. + + Otherwise a typo in `density=` reports a missing package on machines without + `assess` and a wrong answer on machines with it. + """ + + mesh = uw.meshing.Annulus(radiusOuter=1.0, radiusInner=0.5, cellSize=0.2) + + with pytest.raises(ValueError, match="density must be"): + uw.analytic.CylindricalStokes(mesh, density="gaussian") + + with pytest.raises(ValueError, match="boundary must be"): + uw.analytic.CylindricalStokes(mesh, boundary="sticky") + + +@pytest.fixture(scope="module") +def annulus(): + return uw.meshing.Annulus(radiusOuter=1.0, radiusInner=0.5, cellSize=0.15) + + +def _interior_points(count=60, seed=7, r_inner=0.5, r_outer=1.0): + """Points well inside the shell, away from both walls.""" + + rng = np.random.default_rng(seed) + margin = 0.1 * (r_outer - r_inner) + theta = rng.uniform(0.0, 2 * np.pi, count) + radius = rng.uniform(r_inner + margin, r_outer - margin, count) + + return np.column_stack([radius * np.cos(theta), radius * np.sin(theta)]) + + +def _wall_points(radius, count=40): + theta = np.linspace(0.0, 2 * np.pi, count, endpoint=False) + return np.column_stack([radius * np.cos(theta), radius * np.sin(theta)]) + + +def _divergence(sol, points, step=1e-6): + r"""Numerical :math:`\nabla\cdot\mathbf u` by central differences. + + The suite's other solutions are differentiated symbolically. This one is + numeric, so the only way to ask whether it solves anything is to + finite-difference it — weaker than the SymPy gates, but the same idea, and + much stronger than checking that the values are finite. + """ + + total = np.zeros(len(points)) + + for axis in range(2): + offset = np.zeros(2) + offset[axis] = step + total += ( + sol.evaluate("velocity", points + offset)[:, axis] + - sol.evaluate("velocity", points - offset)[:, axis] + ) / (2 * step) + + return total + + +@needs_assess +@pytest.mark.parametrize("density", ["delta", "smooth"]) +@pytest.mark.parametrize("boundary", ["free", "zero"]) +def test_all_four_cases_construct_and_evaluate(annulus, density, boundary): + sol = uw.analytic.CylindricalStokes( + annulus, n=2, k=2, density=density, boundary=boundary + ) + + coords = annulus.X.coords + velocity = sol.evaluate("velocity", coords) + pressure = sol.evaluate("pressure", coords) + + assert velocity.shape == (len(coords), 2) + assert pressure.shape == (len(coords),) + assert np.isfinite(velocity).all() + assert np.isfinite(pressure).all() + + +@needs_assess +@pytest.mark.parametrize("density", ["delta", "smooth"]) +@pytest.mark.parametrize("boundary", ["free", "zero"]) +def test_the_velocity_is_incompressible(annulus, density, boundary): + sol = uw.analytic.CylindricalStokes( + annulus, n=2, k=2, density=density, boundary=boundary + ) + + points = _interior_points() + scale = np.abs(sol.evaluate("velocity", points)).max() + + assert np.abs(_divergence(sol, points)).max() / scale < 1e-7 + + +@needs_assess +def test_the_divergence_probe_actually_fires(): + """Negative control: the check above must fail on a compressible field. + + Without this, `div = 0` says nothing — a probe that returns zero for + everything would pass all four cases above and look like a validation. + """ + + class Expanding: + """u = (x, y), divergence 2 everywhere.""" + + def evaluate(self, field, coords): + return np.asarray(coords, dtype=float) + + points = _interior_points() + assert np.abs(_divergence(Expanding(), points) - 2.0).max() < 1e-5 + + +@needs_assess +@pytest.mark.parametrize("density", ["delta", "smooth"]) +def test_free_slip_stops_wall_normal_flow_but_not_tangential(annulus, density): + r""":math:`\mathbf u\cdot\hat n = 0` on both arcs, with :math:`|u| \neq 0`. + + The second half is the control. A wall where the whole velocity vanishes + would pass a `u.n = 0` test trivially, and that is the zero-slip case, not + this one. + """ + + sol = uw.analytic.CylindricalStokes( + annulus, n=2, k=2, density=density, boundary="free" + ) + + for radius in (sol.r_inner, sol.r_outer): + wall = _wall_points(radius) + velocity = sol.evaluate("velocity", wall) + normal = wall / radius + + assert np.abs((velocity * normal).sum(axis=1)).max() < 1e-14 + assert np.abs(velocity).max() > 1e-4, "wall is not slipping at all" + + +@needs_assess +@pytest.mark.parametrize("density", ["delta", "smooth"]) +def test_zero_slip_stops_the_wall_entirely(annulus, density): + sol = uw.analytic.CylindricalStokes( + annulus, n=2, k=2, density=density, boundary="zero" + ) + + for radius in (sol.r_inner, sol.r_outer): + velocity = sol.evaluate("velocity", _wall_points(radius)) + assert np.abs(velocity).max() < 1e-14 + + # ... and it is not zero everywhere, which would make the above vacuous + assert np.abs(sol.evaluate("velocity", _interior_points())).max() > 1e-5 + + +@needs_assess +def test_the_delta_case_is_genuinely_two_sided(annulus): + """The branch selection is the benchmark, so it has to actually branch.""" + + sol = uw.analytic.CylindricalStokes(annulus, density="delta", r_int=0.8) + assert sol._above is not sol._below + + radii = np.array([[0.6, 0.0], [0.9, 0.0]]) + above, below = sol._split(radii) + assert below[0] and above[1] + + +@needs_assess +def test_it_rejects_fields_it_does_not_have(annulus): + sol = uw.analytic.CylindricalStokes(annulus) + + with pytest.raises(ValueError, match="velocity"): + sol.evaluate("temperature", annulus.X.coords) + + velocity = uw.discretisation.MeshVariable("Vk", annulus, annulus.dim, degree=2) + with pytest.raises(ValueError, match="no symbolic form"): + sol.error("velocity", velocity, norm="integral") diff --git a/tests/test_1028_analytic_parameter_sweep.py b/tests/test_1028_analytic_parameter_sweep.py new file mode 100644 index 000000000..b2db04023 --- /dev/null +++ b/tests/test_1028_analytic_parameter_sweep.py @@ -0,0 +1,293 @@ +r"""The residual gates, applied AWAY from the default parameters. + +`test_1024_analytic_conformance.py` builds every solution from a mesh alone. +That is deliberate — the defaults are part of the interface — but it means a +coefficient that happens to be unity by default multiplies a term nothing ever +looks at. + +That is not hypothetical either. SolA's published $\sigma_{zz}$ (solA.c:156) is +missing the factor of viscosity that its own $\sigma_{xx}$ carries and that +solB's matching line carries. The shortfall is $\tau_{zz}(1-Z)/Z$, identically +zero at $Z = 1$ — the default `eta`, and the only value the kernel's own +disabled driver ever exercised. Every gate in the conformance file passed. At +`eta=3` three of them fail. + +So: each solution is swept over parameters that are *not* its defaults, and the +oracle-free gates are re-applied. The sweep table is explicit rather than +generated, because the point is to move the coefficients that matter for each +solution — a viscosity contrast, a wavenumber, an interface position — and +which those are is per-solution knowledge. + +Run: pixi run python -m pytest tests/test_1028_analytic_parameter_sweep.py -v +""" + +import pytest + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + +import numpy as np +import sympy +import underworld3 as uw +from underworld3.analytic import _validation + + +# solution -> parameter sets to try IN ADDITION to the defaults. Every entry +# moves at least one coefficient off unity or off its default position. +SWEEP = { + "SolA": [{"eta": 3.0}, {"eta": 0.25}, {"eta": 3.0, "n": 2, "m": 1.5}], + "SolB": [{"eta": 3.0}, {"eta": 0.25}, {"eta": 3.0, "n": 2, "m": 1.5}], + "SolCx": [{"eta_A": 3.0, "eta_B": 0.1}, {"eta_A": 1.0e4, "eta_B": 1.0, "x_c": 0.3}], + "SolM": [{"eta_0": 3.0}, {"eta_0": 0.5, "n": 2, "m": 1, "r": 3.0}], + "SolNL": [{"eta_0": 2.0}, {"r": 2.5}], + "SolDB2d": [], # a manufactured solution with no free parameters + "SolDB3d": [{"beta": 1.0}, {"beta": 8.0}], + "EllipticalInclusion": [ + {"viscosity_ratio": 10.0, "aspect_ratio": 1.5}, + {"matrix_viscosity": 3.0}, + {"matrix_viscosity": 0.5, "viscosity_ratio": 100.0}, + ], +} + +ALL_STOKES = sorted( + name + for name in uw.analytic.available() + if getattr(uw.analytic, name).symbolic + and uw.analytic.is_available(name) + and getattr(uw.analytic, name).solves == "stokes" +) + +# SolC, SolDA, SolH, SolKx and SolKz declare `expensive_to_validate` and are +# swept in tests/analytic_full/ instead — same table, same gates, just not on +# every PR. See that file, and the note on the class attribute, for the cost. +STOKES = [n for n in ALL_STOKES if not getattr(uw.analytic, n).expensive_to_validate] + +CASES = [(name, kw) for name in STOKES for kw in SWEEP.get(name, [])] + + +def test_every_stokes_solution_is_swept(): + """A solution added later must be given a sweep, or fail here. + + SolDB2d is listed with an empty sweep rather than omitted: "this one has no + parameters" is a claim worth recording, and it is different from "nobody got + round to it". + + A solution that declares itself expensive belongs in the full-family table + rather than this one, so it must be absent here and present there — the + conformance file asserts the second half. + """ + + assert set(SWEEP) == set(STOKES), ( + f"unswept: {set(STOKES) - set(SWEEP)}; stale: {set(SWEEP) - set(STOKES)}" + ) + + +@pytest.fixture(scope="module") +def meshes(): + return { + 2: uw.meshing.StructuredQuadBox( + elementRes=(4, 4), minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), qdegree=3 + ), + 3: uw.meshing.StructuredQuadBox( + elementRes=(2, 2, 2), + minCoords=(0.0, 0.0, 0.0), + maxCoords=(1.0, 1.0, 1.0), + qdegree=2, + ), + } + + +@pytest.fixture(scope="module") +def cache(): + """One construction per (solution, parameters), shared across the gates. + + Constructing a series solution substitutes parameters into expressions tens + of thousands of operations long. Three gates run over every case here, so + building afresh in each would treble the cost of the file for nothing — + the same reason the conformance sweep uses a module-scoped fixture. + """ + + return {} + + +def _built(cache, meshes, name, kw): + key = (name, tuple(sorted(kw.items()))) + if key not in cache: + cls = getattr(uw.analytic, name) + cache[key] = cls(meshes[cls.dim], **kw) + return cache[key] + + +@pytest.mark.parametrize("name,kw", CASES, ids=[f"{n}-{kw}" for n, kw in CASES]) +def test_momentum_balance_off_default(cache, meshes, name, kw): + r""":math:`\nabla\cdot\sigma + \mathbf f = 0` away from the defaults.""" + + sol = _built(cache, meshes, name, kw) + assert _validation.momentum_residual(sol, sol.sample_points(count=8)) < 1.0e-8 + + +@pytest.mark.parametrize("name,kw", CASES, ids=[f"{n}-{kw}" for n, kw in CASES]) +def test_deviator_is_traceless_off_default(cache, meshes, name, kw): + r""":math:`\mathrm{tr}(\sigma + p\mathbf I) = 0`. + + Equivalent to the pressure being :math:`-\mathrm{tr}\,\sigma/d`, i.e. to + `fn_stress` being the total stress with pressure positive in compression. + Independent of the body force, which is what made it the gate that localised + the SolA erratum: it fired without any reference to the forcing. + """ + + sol = _built(cache, meshes, name, kw) + points = sol.sample_points(count=8) + deviator = sol.fn_stress + sol.fn_pressure * sympy.eye(sol.dim) + + trace = sum(deviator[i, i] for i in range(sol.dim)) + scale = max( + np.abs(_validation.sample(sol, deviator[i, i], points)).max() + for i in range(sol.dim) + ) + + assert np.abs(_validation.sample(sol, trace, points)).max() / max(scale, 1e-300) < 1.0e-8 + + +@pytest.mark.parametrize("name,kw", CASES, ids=[f"{n}-{kw}" for n, kw in CASES]) +def test_strain_rate_matches_the_velocity_off_default(cache, meshes, name, kw): + r""":math:`\tfrac12(\nabla\mathbf u + \nabla\mathbf u^{T})` vs `fn_strainrate`. + + The genuinely independent pair: the velocity and the stress are separate + outputs of the same kernel, so this is not the identity `set_fields` used to + derive one from the other. + """ + + sol = _built(cache, meshes, name, kw) + assert _validation.strainrate_consistency(sol, sol.sample_points(count=8)) < 1.0e-8 + + +# -------------------------------------------------------------------------- +# Negative controls. A gate that has never been seen to fail is a gate nobody +# has checked can fail. +# -------------------------------------------------------------------------- + + +FORCED = [n for n in STOKES if n != "EllipticalInclusion"] + + +@pytest.mark.parametrize("name", FORCED) +def test_flipping_the_body_force_breaks_the_momentum_balance(cache, meshes, name): + r"""The momentum gate must REJECT :math:`\nabla\cdot\sigma - \mathbf f = 0`. + + This is what pins the sign convention to UW3's own. The solver assembles + ``F0 = -bodyforce`` against ``F1 = stress``, whose strong form is + :math:`\nabla\cdot\sigma + \mathbf f = 0`; if the suite tolerated the other + sign it would not be validating that. + + EllipticalInclusion is excluded because it has no body force to flip — it is + driven entirely by its boundary. That is a property of the problem, so it is + excluded by name here and asserted below rather than skipped silently. + """ + + sol = _built(cache, meshes, name, {}) + points = sol.sample_points(count=8) + + assert _validation.momentum_residual(sol, points) < 1.0e-8 + + keep = sol.fn_bodyforce + sol.fn_bodyforce = sympy.Matrix([[-c for c in keep]]) + try: + flipped = _validation.momentum_residual(sol, points) + finally: + sol.fn_bodyforce = keep + + assert flipped > 1.0e-2, ( + f"{name}: flipping the body force left the momentum residual at " + f"{flipped:.3e} — the gate cannot see the sign it is supposed to certify" + ) + + +def test_the_inclusion_really_has_no_body_force(cache, meshes): + """The stated grounds for excluding it from the negative control above.""" + + sol = _built(cache, meshes, "EllipticalInclusion", {}) + assert all(component == 0 for component in sol.fn_bodyforce) + + +@pytest.mark.parametrize("kernel_name,repaired", [("solA", True), ("solB", False)]) +def test_only_solA_is_missing_the_viscosity_in_its_zz_stress(meshes, kernel_name, repaired): + r"""The erratum is real, and it is confined to solA. + + Read the two kernels directly and test the deviator they publish for + tracelessness at a viscosity that is not one. solB passes as published; + solA does not, and passes only after the missing factor of $Z$ is restored. + + Asserting BOTH halves is the point. That solA needs the repair is one claim; + that solB does not is the control which says the repair is a correction to a + defect rather than a convention we imposed on the family. + """ + + from underworld3.analytic import velic + + mesh = meshes[2] + eta = 3.0 + sol = uw.analytic.SolB(mesh, eta=eta) # only a carrier for `sample` + points = sol.sample_points(count=8) + + kernel = velic._solab_kernel(kernel_name) + values = { + velic._SIGMA: sympy.Rational(1.0), + velic._ETA0: sympy.Rational(eta), + velic._N: 3, + velic._KM: sympy.Rational(2) * sympy.pi, + velic._X: mesh.X[0], + velic._Z: mesh.X[1], + } + published = {k: v.subs(values) for k, v in kernel.items()} + + tau_xx = published["stress_xx"] + published["pressure"] + tau_zz = published["stress_zz"] + published["pressure"] + + scale = np.abs(_validation.sample(sol, tau_xx, points)).max() + as_published = np.abs(_validation.sample(sol, tau_xx + tau_zz, points)).max() / scale + + if repaired: + assert as_published > 1.0e-2, ( + f"{kernel_name}: expected the published deviator to be non-traceless " + f"at eta={eta}, got {as_published:.3e} — has the vendored kernel changed?" + ) + restored = np.abs( + _validation.sample(sol, tau_xx + sympy.Rational(eta) * tau_zz, points) + ).max() / scale + assert restored < 1.0e-10 + else: + assert as_published < 1.0e-10 + + +def test_solNL_refuses_its_pole(): + """r = 2 makes the published pressure's denominator vanish identically.""" + + mesh = uw.meshing.StructuredQuadBox( + elementRes=(4, 4), minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), qdegree=3 + ) + + with pytest.raises(ValueError, match="pole"): + uw.analytic.SolNL(mesh, r=2.0) + + +def test_the_inclusion_honours_its_matrix_viscosity(cache, meshes): + r"""Rescaling $\eta$ at fixed boundary velocity scales $\sigma$ AND $p$. + + The velocity and strain rate are invariant; the stress and the pressure both + carry the factor. Scaling only the viscous part leaves $\nabla\cdot\sigma = 0$ + broken while tracelessness and strain-rate consistency still pass, which is + why this needs asserting directly. + """ + + one = _built(cache, meshes, "EllipticalInclusion", {}) + three = _built(cache, meshes, "EllipticalInclusion", {"matrix_viscosity": 3.0}) + points = one.sample_points(count=8) + + for i in range(2): + a = _validation.sample(one, one.fn_velocity[0, i], points) + b = _validation.sample(three, three.fn_velocity[0, i], points) + assert np.abs(a - b).max() / max(np.abs(a).max(), 1e-300) < 1.0e-10 + + a = _validation.sample(one, one.fn_pressure, points) + b = _validation.sample(three, three.fn_pressure, points) + assert np.abs(b - 3.0 * a).max() / max(np.abs(b).max(), 1e-300) < 1.0e-10 diff --git a/tests/test_1062_constrained_solcx.py b/tests/test_1062_constrained_solcx.py index 3079bb4df..72d798e42 100644 --- a/tests/test_1062_constrained_solcx.py +++ b/tests/test_1062_constrained_solcx.py @@ -13,7 +13,7 @@ import numpy as np import sympy import underworld3 as uw -from underworld3.function import analytic as A +from underworld3 import analytic as A ETA_A, ETA_B, XC, NZ, RES = 1.0, 1.0e6, 0.5, 1, 32 diff --git a/tests/test_1100_AdvDiffCartesian.py b/tests/test_1100_AdvDiffCartesian.py index 831aad8aa..7be331e16 100644 --- a/tests/test_1100_AdvDiffCartesian.py +++ b/tests/test_1100_AdvDiffCartesian.py @@ -78,15 +78,12 @@ def create_mesh(mesh_type): # - # ### setup analytical function - -# + -u, t, x, x0, x1 = sp.symbols("u, t, x, x0, x1") - - -U_a_x = ( - sp.erf((x1 - x + (u * t)) / (2 * sp.sqrt(kappa * t))) - + sp.erf((-x0 + x - (u * t)) / (2 * sp.sqrt(kappa * t))) -) / 2 +# +# The two-erf advecting top hat used to be written out here. It is now +# uw.analytic.AdvectedFront, which is the same expression checked against +# du/dt + v.grad(u) = kappa lap(u) — the version here was never verified to +# solve anything, and its own residual is what caught that the check needed the +# advection term at all. # %% @@ -154,7 +151,10 @@ def test_advDiff_boxmesh(mesh_type): # v.array[:, 0, 0] = -1*v.coords[:,1] v.array[:, 0, 0] = velocity - U_start = U_a_x.subs({u: velocity, t: t_start, x: mesh.X[0], x0: 0.4, x1: 0.6}) + exact = uw.analytic.AdvectedFront( + mesh, kappa=kappa, speed=velocity, x0=0.4, x1=0.6 + ) + U_start = exact.fn_solution.subs(exact.t, t_start) T.array = uw.function.evaluate(U_start, T.coords) @@ -180,7 +180,7 @@ def test_advDiff_boxmesh(mesh_type): ### compare UW and 1D numerical solution T_UW = uw.function.evaluate(T.sym[0], sample_points).squeeze() - U_end = U_a_x.subs({u: velocity, t: t_end, x: mesh.X[0], x0: 0.4, x1: 0.6}) + U_end = exact.fn_solution.subs(exact.t, t_end) T_analytical = uw.function.evaluate(U_end, sample_points).squeeze() ### moderate atol due to evaluating onto points