diff --git a/setup.py b/setup.py index 840c84ab2..890a9f4d3 100644 --- a/setup.py +++ b/setup.py @@ -243,6 +243,8 @@ def configure(): sources=[ "src/underworld3/function/analytic.pyx", "src/underworld3/function/AnalyticSolNL.c", + "src/underworld3/function/AnalyticSolCx.c", + "src/underworld3/function/solCx.c", ], extra_compile_args=extra_compile_args, **conf, diff --git a/src/underworld3/function/AnalyticSolCx.c b/src/underworld3/function/AnalyticSolCx.c new file mode 100644 index 000000000..6d59b1a14 --- /dev/null +++ b/src/underworld3/function/AnalyticSolCx.c @@ -0,0 +1,45 @@ +/*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~* +** ** +** 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. ** +** ** +**~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*/ + +/* Thin UW3 adapters around the verbatim Velic SolCx kernel (solCx.c). + _Velic_solCx fills caller-provided arrays; pass NULL for components we + don't want. The viscosity is the analytic step (the kernel doesn't return + it as a field). */ + +#include "solCx.h" +#include "AnalyticSolCx.h" + +vec2 SolCx_velocity( double eta_A, double eta_B, double x_c, int n, const double x, const double z ) +{ + double pos[2]; + double vel[2]; + vec2 out; + pos[0] = x; + pos[1] = z; + _Velic_solCx( pos, eta_A, eta_B, x_c, n, vel, NULL, NULL, NULL ); + out.x = vel[0]; + out.z = vel[1]; + return out; +} + +double SolCx_pressure( double eta_A, double eta_B, double x_c, int n, const double x, const double z ) +{ + double pos[2]; + double pressure; + pos[0] = x; + pos[1] = z; + _Velic_solCx( pos, eta_A, eta_B, x_c, n, NULL, &pressure, NULL, NULL ); + return pressure; +} + +double SolCx_viscosity( double eta_A, double eta_B, double x_c, int n, const double x, const double z ) +{ + (void) n; (void) z; + return ( x < x_c ) ? eta_A : eta_B; +} diff --git a/src/underworld3/function/AnalyticSolCx.h b/src/underworld3/function/AnalyticSolCx.h new file mode 100644 index 000000000..aafd0c5b3 --- /dev/null +++ b/src/underworld3/function/AnalyticSolCx.h @@ -0,0 +1,31 @@ +/*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~* +** ** +** 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. ** +** ** +**~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*/ + +/* UW3 wrapper for the classic Velic "SolCx" analytic Stokes solution: + isoviscous step in x (eta_A for x=x_c), trigonometric + density forcing f = (0, +cos(pi x) sin(n pi z)) (UW3 momentum-sign + convention; UW2 docs quote -cos) on a unit box with free-slip walls. + The heavy lifting is the verbatim Velic kernel in solCx.c + (_Velic_solCx); these thin adapters expose it in the same vec2/scalar shape + as AnalyticSolNL so analytic.pyx can wrap it identically. */ + +#ifndef __Underworld_Function_AnalyticSolCx_h__ +#define __Underworld_Function_AnalyticSolCx_h__ + +#include "AnalyticSolNL.h" /* for the shared vec2 typedef */ + +/* See AnalyticSolNL.h for the rationale behind __attribute__((const)): + the result depends solely on the arguments, so redundant calls (e.g. the + x and z components evaluated separately) can be collapsed by the compiler. */ + +vec2 SolCx_velocity( double eta_A, double eta_B, double x_c, int n, const double x, const double z ) __attribute__((const)); +double SolCx_pressure( double eta_A, double eta_B, double x_c, int n, const double x, const double z ) __attribute__((const)); +double SolCx_viscosity( double eta_A, double eta_B, double x_c, int n, const double x, const double z ) __attribute__((const)); + +#endif diff --git a/src/underworld3/function/analytic.pyx b/src/underworld3/function/analytic.pyx index 7d8e97c4d..dbc237516 100644 --- a/src/underworld3/function/analytic.pyx +++ b/src/underworld3/function/analytic.pyx @@ -16,6 +16,12 @@ cdef extern from "AnalyticSolNL.h" nogil: vec2 SolNL_bodyforce( double eta0, unsigned n, double r, double x, double y ) double SolNL_viscosity( double eta0, unsigned n, double r, double x, double y ) +cdef extern from "AnalyticSolCx.h" nogil: + # vec2 already declared above (shared typedef via AnalyticSolNL.h) + vec2 SolCx_velocity( double eta_A, double eta_B, double x_c, int n, double x, double z ) + double SolCx_pressure( double eta_A, double eta_B, double x_c, int n, double x, double z ) + double SolCx_viscosity( double eta_A, double eta_B, double x_c, int n, double x, double z ) + class sympy_function_printable(sympy.Function): """ This help function simply does most of the work for c-code printing. @@ -82,4 +88,111 @@ class AnalyticSolNL_viscosity(AnalyticSolNL_base): _printstr = "SolNL_viscosity({})" def _eval_evalf(self,prec): from sympy import sympify - return sympify(SolNL_viscosity( self.args[0],self.args[1],self.args[2],self.args[3],self.args[4] )) \ No newline at end of file + return sympify(SolNL_viscosity( self.args[0],self.args[1],self.args[2],self.args[3],self.args[4] )) + + +# ---------------------------------------------------------------------------- +# SolCx: viscosity step in x (eta_A for x=x_c), trigonometric +# density forcing f = (0, +cos(pi x) sin(n pi z)) on a unit box with free-slip +# walls. The +cos sign matches UW3's momentum convention (UW2 docs quote -cos). +# args: (eta_A, eta_B, x_c, n, x, z) +# ---------------------------------------------------------------------------- +class AnalyticSolCx_base(sympy_function_printable): + nargs = 6 + _header = "AnalyticSolCx.h" + +class AnalyticSolCx_velocity_x(AnalyticSolCx_base): + _printstr = "SolCx_velocity({}).x" + def _eval_evalf(self,prec): + from sympy import sympify + return sympify(SolCx_velocity( self.args[0],self.args[1],self.args[2],self.args[3],self.args[4],self.args[5] ).x) +class AnalyticSolCx_velocity_y(AnalyticSolCx_base): + _printstr = "SolCx_velocity({}).z" + def _eval_evalf(self,prec): + from sympy import sympify + return sympify(SolCx_velocity( self.args[0],self.args[1],self.args[2],self.args[3],self.args[4],self.args[5] ).z) +class AnalyticSolCx_velocity(AnalyticSolCx_base): + nargs = 6 + @classmethod + def eval(cls, *args ): + from sympy.vector import CoordSys3D + N = CoordSys3D("N") + return AnalyticSolCx_velocity_x(*args)*N.i + AnalyticSolCx_velocity_y(*args)*N.j + +class AnalyticSolCx_pressure(AnalyticSolCx_base): + _printstr = "SolCx_pressure({})" + def _eval_evalf(self,prec): + from sympy import sympify + return sympify(SolCx_pressure( self.args[0],self.args[1],self.args[2],self.args[3],self.args[4],self.args[5] )) + +class AnalyticSolCx_viscosity(AnalyticSolCx_base): + _printstr = "SolCx_viscosity({})" + def _eval_evalf(self,prec): + from sympy import sympify + return sympify(SolCx_viscosity( self.args[0],self.args[1],self.args[2],self.args[3],self.args[4],self.args[5] )) + + +class SolCx: + r"""Velic *SolCx* analytic Stokes solution — the canonical free-slip, + discontinuous-viscosity benchmark: a viscosity step :math:`\eta_A` (for + :math:`xx_c`), driven by the density + forcing :math:`f=(0,\cos(\pi x)\sin(n\pi z))` on the unit box with free-slip + walls. Use it to validate a UW3 Stokes solve against the exact solution:: + + sol = uw.function.analytic.SolCx(mesh, eta_A=1.0, eta_B=1.0e6, x_c=0.5, n=1) + stokes.constitutive_model.Parameters.shear_viscosity_0 = sol.fn_viscosity + stokes.bodyforce = sol.fn_bodyforce + # free-slip on all four walls + pressure nullspace, then solve and: + rel = sol.velocity_error(stokes.u) # L2 error vs the analytic + + Notes + ----- + ``fn_velocity`` / ``fn_pressure`` wrap the compiled Velic kernel and are + evaluated point-wise via SymPy ``evalf`` (they are not ``lambdify``-able, so + use :meth:`evaluate_velocity` / :meth:`velocity_error` rather than + ``uw.function.evaluate``). ``fn_bodyforce`` / ``fn_viscosity`` are elementary + and compile through the normal JIT path when assigned to a solver. + + The body-force **sign** matches UW3's momentum convention; the original + Underworld2 documentation quotes the opposite sign. + """ + def __init__(self, mesh, eta_A=1.0, eta_B=1.0e6, x_c=0.5, n=1): + import sympy as _sp + if getattr(mesh, "dim", None) != 2: + raise ValueError("SolCx is a 2D analytic solution; mesh.dim must be 2.") + if not (float(eta_A) > 0.0 and float(eta_B) > 0.0): + raise ValueError("eta_A and eta_B must be positive.") + if not (0.0 <= float(x_c) <= 1.0): + raise ValueError("x_c must lie in [0, 1].") + if int(n) != n or int(n) < 1: + raise ValueError("n (z-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, y = mesh.X + p = (self.eta_A, self.eta_B, self.x_c, self.n) + self.fn_velocity = _sp.Matrix([AnalyticSolCx_velocity_x(*p, x, y), + AnalyticSolCx_velocity_y(*p, x, y)]) + self.fn_pressure = AnalyticSolCx_pressure(*p, x, y) + # tie-break at x==x_c matches the compiled SolCx_viscosity (eta_B for x>=x_c) + self.fn_viscosity = _sp.Piecewise((self.eta_A, x < self.x_c), (self.eta_B, True)) + self.fn_bodyforce = _sp.Matrix([_sp.Integer(0), + _sp.cos(_sp.pi * x) * _sp.sin(self.n * _sp.pi * y)]) + + def evaluate_velocity(self, coords): + """Exact velocity at ``coords`` (N×2 array), via the compiled kernel.""" + import numpy as _np + p = (self.eta_A, self.eta_B, self.x_c, self.n) + out = _np.empty((len(coords), 2)) + for i in range(len(coords)): + xi = float(coords[i, 0]); yi = float(coords[i, 1]) + out[i, 0] = float(AnalyticSolCx_velocity_x(*p, xi, yi).evalf()) + out[i, 1] = float(AnalyticSolCx_velocity_y(*p, xi, yi).evalf()) + return out + + def velocity_error(self, velocity_var): + """Relative L2 error of a velocity MeshVariable against the analytic.""" + import numpy as _np + ua = self.evaluate_velocity(velocity_var.coords) + return float(_np.linalg.norm(velocity_var.data - ua) / _np.linalg.norm(ua)) \ No newline at end of file diff --git a/src/underworld3/function/solCx.c b/src/underworld3/function/solCx.c new file mode 100644 index 000000000..a3f819bb5 --- /dev/null +++ b/src/underworld3/function/solCx.c @@ -0,0 +1,1500 @@ +/*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~* +** ** +** 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 "solCx.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; + int N=4; + + for (i=0;i _eta_B){ + _solCx_A(pos, _eta_A, _eta_B, _x_c, _n, vel, pressure, total_stress, strain_rate); + } + else{ + _solCx_B(pos, _eta_A, _eta_B, _x_c, _n, vel, pressure, total_stress, strain_rate); + } + +} + + +/* ZA >> ZB */ +void _solCx_A( + const double pos[], + double _eta_A, double _eta_B, /* Input parameters: density, viscosity A, viscosity B */ + double _x_c, int _n, /* Input parameters: viscosity jump location, wavenumber in x */ + double vel[], double* pressure, + double total_stress[], double strain_rate[] ) +{ + double Z,u1,u2,u3,u4,u5,u6,ZA,ZB,ZR; + double sum1,sum2,sum3,sum4,sum5,sum6,x,z,xc; + double num1A,num2A,num3A,num4A,num1B,num2B,num3B,num4B,denA,denB; + double _C1A,_C2A,_C3A,_C4A,_C1B,_C2B,_C3B,_C4B,_C1,_C2,_C3,_C4; + int nx; + double kx, kn; + + 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 t42,t43,t44,t45,t46,t47,t48,t49,t50,t51; + double t52,t53,t54,t55,t56,t57,t58,t59,t60,t61; + double t62,t63,t64,t66,t67,t69,t70,t71,t72,t73; + double t74,t76,t77,t78,t79,t80,t81,t82,t83,t84; + double t85,t86,t87,t88,t89,t90,t91,t92,t93,t94; + double t95,t96,t97,t98,t99,t100,t101,t102,t103,t104; + double t105,t106,t107,t108,t109,t110,t112,t113,t114,t115; + double t116,t118,t119,t121,t122,t123,t124,t125,t126,t127; + double t128,t129,t132,t134,t135,t136,t137,t138,t139,t140; + double t141,t142,t143,t144,t146,t147,t148,t149,t150,t152; + double t153,t154,t155,t156,t157,t159,t160,t162,t164,t166; + double t167,t168,t169,t171,t172,t173,t175,t177,t178,t180; + double t181,t183,t184,t185,t187,t188,t189,t190,t192,t193; + double t194,t195,t197,t198,t199,t200,t202,t204,t208,t211; + double t214,t215,t216,t217,t218,t220,t223,t224,t226,t229; + double t230,t232,t233,t236,t237,t239,t243,t248,t252,t256; + double t257,t259,t263,t267,t272,t275,t281,t284,t294,t303; + double t305,t306,t309,t314,t322,t325,t365,t369,t380,t384; + double t392,t414,t456; + + nx=1.0; + kx=nx*M_PI; + kn=_n*M_PI; + + ZA=_eta_A; /* left column viscosity */ + ZB=_eta_B; /* right column viscosity */ + 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; + + ZR=ZB/ZA; + + if (xxc ) { + Z = ZB; + } + else { + Z = ZA; + } + strain_rate[0] = (sum3+sum5)/(2.0*Z); + strain_rate[1] = (sum6+sum5)/(2.0*Z); + strain_rate[2] = (sum4)/(2.0*Z); + } +} + +/* ZB >> ZA */ +void _solCx_B( + const double pos[], + double _eta_A, double _eta_B, /* Input parameters: density, viscosity A, viscosity B */ + double _x_c, int _n, /* Input parameters: viscosity jump location, wavenumber in x */ + double vel[], double* pressure, + double total_stress[], double strain_rate[] ) +{ + double Z,u1,u2,u3,u4,u5,u6,ZA,ZB,ZR; + double sum1,sum2,sum3,sum4,sum5,sum6,x,z,xc; + double num1A,num2A,num3A,num4A,num1B,num2B,num3B,num4B,denA,denB; + double _C1A,_C2A,_C3A,_C4A,_C1B,_C2B,_C3B,_C4B,_C1,_C2,_C3,_C4; + int nx; + double kx, kn; + + 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,t36,t37,t38,t39,t40,t41; + double t42,t43,t44,t45,t46,t47,t48,t49,t50,t51; + double t52,t53,t54,t55,t56,t57,t58,t59,t60,t61; + double t62,t63,t64,t65,t66,t67,t68,t69,t70,t71; + double t72,t73,t74,t75,t76,t78,t79,t81,t83,t84; + double t85,t86,t88,t89,t90,t91,t92,t93,t94,t95; + double t96,t97,t98,t99,t100,t101,t102,t103,t104,t105; + double t106,t107,t108,t109,t110,t113,t114,t115,t116,t117; + double t118,t119,t120,t121,t122,t123,t124,t126,t127,t128; + double t129,t131,t132,t133,t134,t135,t136,t137,t138,t139; + double t140,t141,t142,t143,t144,t145,t146,t147,t148,t149; + double t150,t151,t152,t153,t154,t155,t156,t157,t159,t160; + double t161,t162,t164,t165,t166,t169,t170,t171,t173,t174; + double t177,t178,t180,t181,t183,t184,t185,t186,t187,t188; + double t190,t195,t196,t197,t198,t199,t200,t201,t206,t208; + double t209,t210,t212,t213,t214,t215,t216,t220,t221,t222; + double t224,t226,t227,t230,t232,t235,t237,t239,t240,t241; + double t242,t244,t245,t248,t250,t256,t258,t259,t261,t263; + double t265,t271,t273,t275,t276,t278,t279,t282,t283,t285; + double t288,t293,t295,t296,t298,t299,t300,t301,t303,t314; + double t317,t321,t326,t328,t331,t363,t371,t374,t382,t402; + double t431,t442,t470; + + nx=1.0; + kx=nx*M_PI; + kn=_n*M_PI; + + ZA=_eta_A; /* left column viscosity */ + ZB=_eta_B; /* right column viscosity */ + 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; + + ZR=ZA/ZB; + if (xxc ) { + Z = ZB; + } + else { + Z = ZA; + } + strain_rate[0] = (sum3+sum5)/(2.0*Z); + strain_rate[1] = (sum6+sum5)/(2.0*Z); + strain_rate[2] = (sum4)/(2.0*Z); + } +} diff --git a/src/underworld3/function/solCx.h b/src/underworld3/function/solCx.h new file mode 100644 index 000000000..1e76c942b --- /dev/null +++ b/src/underworld3/function/solCx.h @@ -0,0 +1,38 @@ +/*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~*~* +** ** +** 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 __solCx_h__ +#define __solCx_h__ + +#include +#include +#include +#include + +void _Velic_solCx( + const double pos[], + double _eta_A, double _eta_B, + double _x_c, int _n, + double vel[], double* pressure, + double total_stress[], double strain_rate[] ); + +void _solCx_A( + const double pos[], + double _eta_A, double _eta_B, /* Input parameters: density, viscosity A, viscosity B */ + double _x_c, int _n, /* Input parameters: viscosity jump location, wavenumber in x */ + double vel[], double* pressure, + double total_stress[], double strain_rate[] ); + +void _solCx_B( + const double pos[], + double _eta_A, double _eta_B, /* Input parameters: density, viscosity A, viscosity B */ + double _x_c, int _n, /* Input parameters: viscosity jump location, wavenumber in x */ + double vel[], double* pressure, + double total_stress[], double strain_rate[] ); + +#endif \ No newline at end of file diff --git a/tests/test_1015_analytic_solcx.py b/tests/test_1015_analytic_solcx.py new file mode 100644 index 000000000..b9d623d63 --- /dev/null +++ b/tests/test_1015_analytic_solcx.py @@ -0,0 +1,58 @@ +"""SolCx analytic-solution validation. + +SolCx is the canonical free-slip, discontinuous-viscosity Stokes benchmark +(viscosity step eta_A|eta_B at x_c, density forcing (0, cos(pi x) sin(n pi z))). +Here we (a) check the ported Velic kernel binding returns non-trivial values and +(b) confirm a default-solver UW3 Stokes solve CONVERGES to the exact analytic. + +This guards a subtle trap: on a free-slip (pressure-nullspace) problem a direct +`ksponly + lu` solve mangles the singular saddle and returns a wrong-but-quiet +answer. Comparing against the analytic catches that immediately. + +Run: pixi run python -m pytest tests/test_1015_analytic_solcx.py -v +""" + +import pytest + +pytestmark = [pytest.mark.level_2] + +import numpy as np +import underworld3 as uw +from underworld3.function import analytic as A + + +def _solve_solcx(res, eta_B=1.0e6, x_c=0.5, n=1): + mesh = uw.meshing.StructuredQuadBox( + elementRes=(res, res), minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), qdegree=3 + ) + sol = A.SolCx(mesh, eta_A=1.0, eta_B=eta_B, x_c=x_c, n=n) + + stokes = uw.systems.Stokes(mesh) + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = sol.fn_viscosity + stokes.saddle_preconditioner = 1.0 / sol.fn_viscosity + stokes.bodyforce = sol.fn_bodyforce + # Free-slip on all four walls (normal component pinned, tangential free). + stokes.add_dirichlet_bc((0.0, None), "Left") + stokes.add_dirichlet_bc((0.0, None), "Right") + stokes.add_dirichlet_bc((None, 0.0), "Bottom") + stokes.add_dirichlet_bc((None, 0.0), "Top") + stokes.petsc_use_pressure_nullspace = True # enclosed -> constant-pressure mode + stokes.tolerance = 1.0e-9 + stokes.solve() + return sol.velocity_error(stokes.u) + + +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()) + assert np.isfinite(vy) + assert abs(vy) > 1.0e-8 + + +def test_solcx_stokes_converges_to_analytic(): + """A default-solver Stokes solve converges to the exact SolCx solution.""" + e16 = _solve_solcx(16) + e32 = _solve_solcx(32) + assert e32 < e16, f"no convergence: e16={e16:.2e} e32={e32:.2e}" + assert e32 < 1.0e-3, f"poor match to analytic: e32={e32:.2e}"