From bab0e12535622828da5523258d576edd55f2b1ea Mon Sep 17 00:00:00 2001 From: nl Date: Thu, 27 Aug 2026 15:51:22 +1000 Subject: [PATCH 01/20] update --- src/underworld3/systems/__init__.py | 2 + src/underworld3/systems/solver_supg.py | 829 +++++++++++++++++++++++++ 2 files changed, 831 insertions(+) create mode 100644 src/underworld3/systems/solver_supg.py diff --git a/src/underworld3/systems/__init__.py b/src/underworld3/systems/__init__.py index ea6823ab5..7e63fab82 100644 --- a/src/underworld3/systems/__init__.py +++ b/src/underworld3/systems/__init__.py @@ -91,3 +91,5 @@ # δ-continuation driver for hard viscoplastic (Drucker–Prager) yield from .yield_continuation import yield_continuation, YieldHomotopyControl from .solve_report import SolveReport + +from .solver_supg import SNES_AdvectionDiffusion_SUPG as AdvDiffusionSUPG diff --git a/src/underworld3/systems/solver_supg.py b/src/underworld3/systems/solver_supg.py new file mode 100644 index 000000000..4270fb315 --- /dev/null +++ b/src/underworld3/systems/solver_supg.py @@ -0,0 +1,829 @@ +import sympy +from sympy import sympify +import numpy as np +import warnings + +from typing import Optional, Callable, Union + +import underworld3 as uw +from underworld3.systems import SNES_Scalar, SNES_Vector, SNES_Stokes_SaddlePt +from underworld3.cython.generic_solvers import SNES_MultiComponent +from underworld3 import VarType +import underworld3.timing as timing +from underworld3.utilities import memprobe +from underworld3.utilities._api_tools import ( + uw_object, + SymbolicProperty, + Parameter, + Template, + ExpressionProperty, +) + +from underworld3.function import expression as public_expression + +# estimate_dt() below needs two module-level helpers that live alongside +# SNES_AdvectionDiffusion (the SLCN solver) in underworld3/systems/solvers.py. +# They are prefixed with an underscore (module-private by convention) but +# not otherwise protected, so a direct import works fine. +from underworld3.systems.solvers import ( + _global_max_diffusivity, + _centroid_velocities_nd, +) + +def _as_row_vector(V_fn, dim): + r"""Coerce a velocity expression into a ``(1, dim)`` sympy row-vector + Matrix matching the mesh's dimensionality, or raise a CLEAR error at + construction time instead of the cryptic sympy ``IndexError`` that + would otherwise surface much later, deep inside a compiled residual + lambda (``u[0, i]`` for ``i in range(dim)`` on a too-narrow matrix -- + e.g. a scalar/1-component velocity on a 2-D mesh). + + A common source of this mismatch: a "1D" test built on a thin 2-D + strip mesh (``mesh.dim == 2``) with a genuinely 1-component velocity + field/expression -- UW3 doesn't have a bare 1-D mesh type for this, + so the velocity still needs an explicit second (zero) component, + e.g. ``sympy.Matrix([[vx, 0]])``, not a plain scalar ``vx``. + """ + if isinstance(V_fn, sympy.MatrixBase): + if V_fn.shape == (1, dim): + return V_fn + if V_fn.shape == (dim, 1): + # Common transpose slip (column vector instead of row). + return V_fn.T + raise ValueError( + f"V_fn has shape {V_fn.shape}, but the mesh is {dim}-D -- " + f"expected a (1, {dim}) row vector (e.g. `v.sym` from a " + f"{dim}-component vector MeshVariable). If this is meant to " + f"be a 1-D-in-x flow on a thin {dim}-D mesh, pass an explicit " + f"{dim}-component vector, e.g. `sympy.Matrix([[vx, 0]])` for " + f"dim=2, not a bare scalar or a mismatched-shape Matrix." + ) + # Plain scalar (python number or bare sympy scalar expression, not + # wrapped in a Matrix at all). + if dim == 1: + return sympy.Matrix([[V_fn]]) + raise ValueError( + f"V_fn is a scalar, but the mesh is {dim}-D -- expected a " + f"(1, {dim}) row vector. If this is meant to be a 1-D-in-x flow " + f"on a thin {dim}-D mesh, pass e.g. `sympy.Matrix([[V_fn, 0]])` " + f"(dim=2) rather than the bare scalar `V_fn`." + ) + + +class SNES_AdvectionDiffusion_SUPG(SNES_Scalar): + r"""Advection-diffusion equation solver using Crank-Nicolson time integration + + streamline-upwind Petrov-Galerkin (SUPG, Brooks & Hughes 1982) stabilisation: + + .. math:: + \frac{\partial \phi}{\partial t} + \mathbf{u}\cdot\nabla\phi + - \nabla\cdot(\kappa\nabla\phi) = 0, + + Diffusivity :math:`\kappa` defaults to ``0.0`` -- pure advection, + identical to the original single-purpose advection-only version of + this class. Set :attr:`diffusivity` (a plain number or a + symbolic/field expression) to solve advection-diffusion instead; no + other API changes are needed and :meth:`solve` is unchanged either way. + + Weak form + --------- + Writing the ADVECTION-ONLY strong-form residual at the + Crank-Nicolson-averaged state + + .. math:: + R = \frac{\phi^{n+1}-\phi^{n}}{\Delta t} + + \mathbf{u}\cdot\nabla\phi_{CN}, + \qquad + \phi_{CN} = \theta\,\phi^{n+1} + (1-\theta)\,\phi^{n} + \quad(\theta=0.5 \Rightarrow \text{Crank-Nicolson}), + + the SUPG-perturbed test function :math:`w + \tau\,\mathbf{u}\cdot\nabla w` + gives the weak form actually assembled here (UW3's residual convention + :math:`\int_\Omega (w F_0 + \nabla w \cdot \mathbf{F}_1)\,d\Omega = 0`): + + .. math:: + F_0 = R, + \qquad + \mathbf{F}_1 = \kappa\,\nabla\phi_{CN} + \tau\,R\,\mathbf{u}. + + The diffusive term enters ONLY through :math:`\mathbf{F}_1`, as a + standard consistent Galerkin flux -- exactly how ``SNES_Poisson`` + builds :math:`\kappa\nabla u` -- never as a literal second derivative + inside :math:`F_0` (which the pointwise-residual PETSc API cannot + express directly; :math:`\int_\Omega w\,[-\nabla\cdot(\kappa\nabla\phi)] + \,d\Omega = \int_\Omega \nabla w\cdot\kappa\nabla\phi\,d\Omega` after + integration by parts, with the boundary term dropped -- i.e. a natural + zero-flux/Neumann condition on any boundary without an explicit + Dirichlet BC). Diffusion is elliptic/self-adjoint and does not need + Petrov-Galerkin stabilisation, so :math:`R` -- and hence the SUPG term + :math:`\tau R\mathbf{u}` -- stays advection-only regardless of + :math:`\kappa`; this is standard SUPG practice, not a simplification. + + :math:`\tau` is the Tezduyar-style inverse-norm stabilisation + parameter, safely regularised for :math:`\Delta t\to0`, + :math:`|\mathbf u|\to0` AND (now) :math:`\kappa\to0`: + + .. math:: + \tau = \left[ \left(\frac{2}{\Delta t}\right)^2 + + \left(\frac{2|\mathbf u|}{h}\right)^2 + + \left(\frac{4\kappa}{h^2}\right)^2 + \right]^{-1/2}, + + with :math:`h` = ``mesh.cell_size()`` (UW3's per-cell characteristic + length). The extra diffusive term keeps :math:`\tau` (and hence the + SUPG correction) from over-stabilising a diffusion-dominated (low + element-Peclet-number) problem, where diffusion's own ellipticity + already provides stability; it vanishes identically at + :math:`\kappa=0`, recovering the pure-advection :math:`\tau` unchanged. + + A separate ``phi_old`` MeshVariable carries :math:`\phi^{n}`, updated + by :meth:`solve` before each call -- there is no ``DuDt``/ + ``SemiLagrangian``/``Lagrangian`` time-derivative handler involved + anywhere in this class. + + Parameters + ---------- + mesh : Mesh + The computational mesh. + u_Field : MeshVariable + Scalar field :math:`\phi` being advected (and, optionally, + diffused). Must be continuous (shared vertex/edge DOFs) -- SUPG + assembles a continuous-Galerkin weak form. + V_fn : MeshVariable.sym or sympy expression + Advecting velocity :math:`\mathbf{u}`. + theta : float, optional + Crank-Nicolson blend (default 0.5); 1.0 is backward-Euler. + diffusivity : float or sympy expression, optional + Diffusivity :math:`\kappa` (default ``0.0`` -- pure advection). A + plain number is captured as a literal inside the compiled F0/F1 + kernels, exactly like ``dt`` -- re-assign :attr:`diffusivity` to + change it later (this forces a rebuild, see the setter). A + symbolic/field expression (e.g. another MeshVariable's ``.sym``) + already updates its own live value with no rebuild needed. + discontinuity_capturing : bool, optional + Add a crosswind discontinuity-capturing (DC) term to F1 (default + ``False``, i.e. plain streamline-only SUPG). Pure SUPG has no + mechanism to damp oscillations ACROSS a steep, under-resolved + front -- this appears as trailing Gibbs-like ringing behind a + translating front, REGARDLESS of diffusivity (it happens even + at diffusivity=0). Turn this on if you see that. See + :meth:`_dc_flux` for the full rationale/formula. + dc_coefficient : float, optional + Discontinuity-capturing strength :math:`C_{dc}` (default + ``1.0``); only matters when ``discontinuity_capturing=True``. + dc_streamwise_weight : float, optional + How much of the ALONG-FLOW component of :math:`\nabla\phi` the + DC flux includes, in ``[0, 1]`` (default ``0.0``). ``0.0`` is + pure crosswind (textbook Hughes-Mallet -- correct for genuinely + multi-D fronts, where the streamline SUPG term already handles + the along-flow direction, but goes essentially INERT for a front + varying only along the flow, e.g. 1D-in-x on a thin 2D strip + mesh with no y-variation). ``1.0`` is the full gradient, which + DOES engage on such a front but now double-counts diffusion in + the same direction SUPG already stabilises -- expect visible + peak-amplitude loss alongside the ripple suppression. Intermediate + values (e.g. ``0.2-0.5``) trade between the two: sweep this + (and/or ``dc_coefficient``) to find the smallest combination that + still suppresses ringing without eating into the peak. + verbose : bool, optional + Enable verbose SNES output. + + Examples + -------- + Pure advection (identical behaviour to the original single-purpose + class this generalises): + + >>> adv = SNES_AdvectionDiffusion_SUPG(mesh, phi, v.sym) + >>> adv.solve(timestep=1e-3) + + Advection-diffusion: + + >>> adv = SNES_AdvectionDiffusion_SUPG(mesh, phi, v.sym, diffusivity=1.0e-4) + >>> adv.solve(timestep=1e-3) + >>> adv.diffusivity = 2.0e-4 # change later; rebuilds automatically + >>> adv.solve(timestep=1e-3) + + Steep-front advection with trailing-ripple suppression: + + >>> adv = SNES_AdvectionDiffusion_SUPG(mesh, phi, v.sym, + ... discontinuity_capturing=True) + >>> adv.solve(timestep=1e-3) + """ + + @timing.routine_timer_decorator + def __init__( + self, + mesh: uw.discretisation.Mesh, + u_Field: uw.discretisation.MeshVariable, + V_fn, + theta: float = 0.5, + diffusivity=0.0, + discontinuity_capturing: bool = False, + dc_coefficient: float = 1.0, + dc_streamwise_weight: float = 0.0, + verbose: bool = False, + ): + if not u_Field.continuous: + raise ValueError( + "`u_Field` must be a CONTINUOUS MeshVariable -- SUPGAdvection " + "assembles a continuous-Galerkin weak form and needs shared " + "vertex/edge DOFs across cells." + ) + + super().__init__(mesh, u_Field, degree=u_Field.degree, verbose=verbose) + + self._constitutive_model = uw.constitutive_models.Constitutive_Model(self.Unknowns) + + if isinstance(V_fn, uw.discretisation.MeshVariable): + V_fn = V_fn.sym + self._V_fn = _as_row_vector(V_fn, mesh.dim) + self.theta_cn = float(theta) + + self.phi_old = uw.discretisation.MeshVariable( + rf"\phi^{{n}}_{{{id(self)}}}", + mesh, 1, degree=u_Field.degree, continuous=u_Field.continuous, + ) + self.phi_old.data[:, 0] = u_Field.data[:, 0] + + self._dt_value = 1.0 + self._last_dt = None + + self._diffusivity = diffusivity + self._last_diffusivity = None + + # Discontinuity-capturing (DC) term: OFF by default, so nothing + # changes for existing callers. See _dc_flux() for the rationale + # -- pure streamline SUPG has no crosswind damping, so a steep, + # under-resolved front can ring (Gibbs-like oscillations) even + # with diffusivity=0. This is additive to F1, not a separate + # code path: discontinuity_capturing=False makes _dc_flux() + # symbolically zero, exactly like diffusivity=0 recovers pure + # advection through the SAME F1 expression rather than a branch. + self._discontinuity_capturing = bool(discontinuity_capturing) + self._dc_coefficient = float(dc_coefficient) + # dc_streamwise_weight=0.0 (default) restricts the DC flux to the + # component of grad(phi) ORTHOGONAL to the flow, on the reasoning + # that tau*R*u already handles the along-flow direction -- this + # is the textbook Hughes-Mallet formulation and is the right + # choice for genuinely multi-D fronts. But it goes essentially + # INERT for a problem where phi varies (near-)only ALONG the + # flow direction (e.g. a 1D-in-x front on a thin 2D strip mesh, + # with no y-variation): there, grad(phi) is already ~parallel to + # u, so the crosswind component ~0 and DC contributes nothing, + # regardless of dc_coefficient. Set this closer to 1.0 to include + # more of the along-flow component (accepting some double- + # counting with the streamline term in exchange for DC actually + # engaging) -- a continuous knob rather than an all-or-nothing + # switch, since dc_streamwise_weight=1.0 (full gradient) visibly + # eats into peak amplitude alongside suppressing ripples. + self._dc_streamwise_weight = float(np.clip(dc_streamwise_weight, 0.0, 1.0)) + + self.petsc_options["snes_rtol"] = 1.0e-8 + self.petsc_options["snes_max_it"] = 20 + + # KSP/PC defaults for a genuinely non-symmetric operator: unlike + # SLCN (whose SemiLagrangian trace-back leaves a much more + # diffusion/Poisson-like, closer-to-SPD system after each step), + # this class solves the FULL SUPG-stabilised convection-diffusion + # operator directly every step -- at high element Peclet number + # (advection-dominated) that operator is strongly non-symmetric + # and non-normal. + # + # PETSc's bare defaults (GMRES, restart=30) commonly STAGNATE on + # exactly this kind of operator: it can land back in essentially + # the same Krylov subspace every 30 iterations, producing a + # residual that's bit-identical for thousands of iterations + # rather than slowly decaying -- easy to mistake for "just needs + # more iterations" when it actually needs a bigger subspace + # and/or a preconditioner that respects the non-symmetry. GAMG + # (algebraic multigrid) is the wrong FAMILY here for the same + # reason: its classical smoothed-aggregation coarsening and + # default Chebyshev/SOR smoothers assume a near-SPD operator + # (elasticity/Poisson) and silently misbehave on this one rather + # than failing loudly. + # + # ASM+ILU with a larger GMRES restart is the standard robust + # choice for non-symmetric SUPG systems at small-to-moderate + # scale; RCM reordering measurably helps ILU's fill-in quality on + # convection-dominated operators specifically. None of this is + # exact (unlike direct LU, which IS exact and fine to use instead + # while your problem stays small/test-scale -- just won't scale + # to a production-size mesh). All overridable after construction, + # e.g. `adv_diff.petsc_options.setValue('pc_type', 'lu')`. + self.petsc_options["ksp_type"] = "gmres" + self.petsc_options["ksp_gmres_restart"] = 200 + self.petsc_options["pc_type"] = "asm" + self.petsc_options["sub_pc_type"] = "ilu" + self.petsc_options["sub_pc_factor_mat_ordering_type"] = "rcm" + + @property + def diffusivity(self): + r"""Diffusivity :math:`\kappa`. ``0.0`` (default) is pure + advection. Re-assigning a genuinely different value forces a + kernel rebuild on the next :meth:`solve` -- see the class + docstring.""" + return self._diffusivity + + @diffusivity.setter + def diffusivity(self, value): + # Same reasoning as the `dt`-change guard in solve(): a plain + # number is captured as a literal inside the compiled F0/F1 + # kernels (via _tau() and F1's sympy expression), so a genuine + # VALUE change needs those kernels re-evaluated and rebuilt. A + # symbolic/field expression (e.g. a MeshVariable.sym) already + # updates its own live value with no rebuild -- the isclose() + # check below can't meaningfully compare those, so it + # conservatively treats a non-numeric value as "changed". + try: + unchanged = np.isclose( + float(self._diffusivity), float(value), rtol=1e-12, atol=1e-15) + except (TypeError, ValueError): + unchanged = False + self._diffusivity = value + if not unchanged: + self.is_setup = False + + @property + def discontinuity_capturing(self): + """Whether the crosswind discontinuity-capturing (DC) term is + added to F1 -- see :meth:`_dc_flux`. ``False`` (default) is + plain streamline-only SUPG, unchanged from before this feature + existed. Re-assigning forces a kernel rebuild.""" + return self._discontinuity_capturing + + @discontinuity_capturing.setter + def discontinuity_capturing(self, value): + value = bool(value) + if value != self._discontinuity_capturing: + self._discontinuity_capturing = value + self.is_setup = False + + @property + def dc_coefficient(self): + r"""Discontinuity-capturing coefficient :math:`C_{dc}` (default + ``1.0``) -- only matters when :attr:`discontinuity_capturing` is + True. Larger values damp front-adjacent ringing more aggressively + but smear the front more; there's no universal "correct" value, + it's a per-problem tuning knob (literature values commonly range + ~0.5-2.0). Re-assigning forces a kernel rebuild.""" + return self._dc_coefficient + + @dc_coefficient.setter + def dc_coefficient(self, value): + try: + unchanged = np.isclose( + float(self._dc_coefficient), float(value), rtol=1e-12, atol=1e-15) + except (TypeError, ValueError): + unchanged = False + self._dc_coefficient = float(value) + if not unchanged: + self.is_setup = False + + @property + def dc_streamwise_weight(self): + """How much of the along-flow component of grad(phi) the DC + flux includes, in [0, 1] (default 0.0 -- pure crosswind, the + textbook choice, but inert for a front varying only along the + flow -- see the constructor docstring). 1.0 is the full gradient + (double-counts with the streamline SUPG term; expect peak- + amplitude loss). Sweep this alongside dc_coefficient to find the + smallest combination that suppresses ringing without eating + into the peak. Re-assigning forces a kernel rebuild.""" + return self._dc_streamwise_weight + + @dc_streamwise_weight.setter + def dc_streamwise_weight(self, value): + value = float(np.clip(value, 0.0, 1.0)) + try: + unchanged = np.isclose( + self._dc_streamwise_weight, value, rtol=1e-12, atol=1e-15) + except (TypeError, ValueError): + unchanged = False + self._dc_streamwise_weight = value + if not unchanged: + self.is_setup = False + + def _sync_diffusivity_from_constitutive_model(self): + """Compatibility bridge for the SLCN-solver idiom + ``adv_diff.constitutive_model = uw.constitutive_models.DiffusionModel; + adv_diff.constitutive_model.Parameters.diffusivity = X``. + + F0/F1 on THIS class are hand-written and never read + ``constitutive_model.flux``/``.K`` (see class docstring) -- the + ``constitutive_model`` attribute otherwise exists only as a + non-None placeholder the base class expects. Without this + bridge, that (very natural, SLCN-idiomatic) assignment is a + SILENT no-op: no error, but the residual keeps using whatever + :attr:`diffusivity` already was (0.0/pure-advection by default), + which is a dangerous trap -- it can produce a well-posed-looking + script that's actually solving the wrong PDE, or (as with + Dirichlet BCs on both ends of a would-be diffusive problem) an + ill-posed one that fails opaquely deep inside the linear solve. + + Best-effort: swallows anything unexpected about the constitutive + model's shape (it's a bridge for an API this class doesn't own), + and only overrides :attr:`diffusivity` when it finds a genuinely + different value to adopt. + """ + cm = getattr(self, "_constitutive_model", None) + if cm is None: + return + try: + cm_kappa = cm.Parameters.diffusivity + except AttributeError: + return + if cm_kappa is None: + return + # Unwrap a UWexpression-like Parameter to its underlying symbol/value. + cm_kappa_val = getattr(cm_kappa, "sym", cm_kappa) + try: + unchanged = np.isclose( + float(self._diffusivity), float(cm_kappa_val), + rtol=1e-12, atol=1e-15) + except (TypeError, ValueError): + unchanged = False + if not unchanged: + warnings.warn( + "SNES_AdvectionDiffusion_SUPG: adopting diffusivity=" + f"{cm_kappa_val} from constitutive_model.Parameters.diffusivity " + "(this class's F0/F1 don't read the constitutive model " + "directly -- set `adv_diff.diffusivity = ...` instead to " + "avoid relying on this compatibility bridge).", + stacklevel=2, + ) + self.diffusivity = cm_kappa_val + + def _tau(self): + """Tezduyar-style advection-diffusion SUPG parameter. + The diffusive term vanishes identically at + diffusivity=0, recovering the pure-advection tau unchanged.""" + dim = self.mesh.dim + u = self._V_fn + u_mag2 = sum(u[0, i] ** 2 for i in range(dim)) + h = self.mesh.cell_size() + inv_dt_term = (2.0 / self._dt_value) ** 2 + inv_h_term = (2.0 * sympy.sqrt(u_mag2) / h) ** 2 + inv_diff_term = (4.0 * self._diffusivity / h**2) ** 2 + return 1.0 / sympy.sqrt(inv_dt_term + inv_h_term + inv_diff_term + 1.0e-30) + + def _phi_cn(self): + """Crank-Nicolson blended state phi_CN = theta*phi + (1-theta)*phi_old.""" + phi = self.u.sym[0, 0] + phi_old = self.phi_old.sym[0, 0] + return self.theta_cn * phi + (1.0 - self.theta_cn) * phi_old + + def _grad_phi_cn(self): + """(1, dim) row matrix grad(phi_CN), shared by the SUPG residual + and the diffusive flux so both see the SAME Crank-Nicolson state.""" + return self.mesh.vector.gradient(self._phi_cn()) + + def _strong_residual(self): + """ADVECTION-ONLY strong residual R = (phi-phi_old)/dt + + u.grad(phi_CN). This is what SUPG stabilises (F1's tau*R*u term) + and is ALSO the complete F0 Galerkin term: diffusion contributes + nothing here by construction (see class docstring) -- it enters + only as a consistent Galerkin flux in F1, so this residual is + identical whether diffusivity is zero or not.""" + dim = self.mesh.dim + grad_cn = self._grad_phi_cn() + u = self._V_fn + advective = sum(u[0, i] * grad_cn[0, i] for i in range(dim)) + phi = self.u.sym[0, 0] + phi_old = self.phi_old.sym[0, 0] + return (phi - phi_old) / self._dt_value + advective + + def _dc_flux(self): + r"""Discontinuity-capturing (DC) flux, Hughes & Mallet (1986) / + Codina (1993) style. Zero (a (1, dim) zero row) unless + :attr:`discontinuity_capturing` is True -- additive to F1 + exactly like the diffusive term, no separate code path. + + Pure streamline SUPG (the `tau*R*u` term) only damps + oscillations ALONG the flow direction; it has no mechanism to + damp them CROSSWIND. On a steep, under-resolved front this shows + up as Gibbs-like ringing trailing the front -- and crucially, + this happens regardless of physical diffusivity (it appears even + at diffusivity=0, pure advection): it's a property of streamline + SUPG's stabilisation, not an interaction with the diffusive + term. + + The fix adds isotropic-looking but effectively CROSSWIND-ONLY + artificial diffusion (the along-flow component is subtracted + out, since tau*R*u already handles that direction -- adding it + again here would double up the streamline diffusion): + + .. math:: + \nu_{dc} = C_{dc}\,h\,\frac{|R^{n}|}{\|\nabla\phi^{n}\|}, + \qquad + \mathbf{F}_{1,dc} = \nu_{dc}\, + \left(\nabla\phi_{CN} - (\nabla\phi_{CN}\cdot\hat{\mathbf u})\,\hat{\mathbf u}\right) + + Note the coefficient :math:`\nu_{dc}` uses the KNOWN, previous- + timestep state (:math:`R^n`, :math:`\nabla\phi^n`, both from + ``phi_old``) -- see the note below on why -- while it multiplies + the CURRENT (unknown) :math:`\nabla\phi_{CN}`. :math:`R^n` is + the advective part of the strong residual evaluated at + ``phi_old`` alone, so :math:`\nu_{dc}` is automatically near-zero + away from steep fronts (where :math:`\nabla\phi^n` is already + small or the flow is locally well-resolved) and only activates + where genuinely needed -- it doesn't add diffusion uniformly + across the domain. + Both the residual-magnitude and gradient-magnitude in the ratio + are regularised (``+1e-30`` inside the sqrt) against division by + a vanishing gradient in smooth regions. + + Crucially, :math:`\nu_{dc}` (the coefficient) is evaluated from + ``phi_old`` ONLY -- never the unknown :math:`\phi^{n+1}` -- and + is applied multiplying :math:`\nabla\phi_{CN}` (linear in the + unknown). A first version of this used the CURRENT strong + residual/gradient for :math:`\nu_{dc}` too, which makes the + whole term genuinely nonlinear in :math:`\phi`: a + :math:`|R|/\|\nabla\phi\|` ratio evaluated at the unknown is a + well-known source of Newton stagnation (the SNES residual + locking onto an exact plateau, ``DIVERGED_LINE_SEARCH`` / + ``DIVERGED_MAX_IT``) rather than a clean single-iteration linear + solve. Freezing :math:`\nu_{dc}` at ``phi_old`` (a standard + lagged/Picard treatment for shock-capturing terms, e.g. Codina + 1993) keeps the whole solve LINEAR -- one Newton iteration, like + the rest of this class -- at the cost of a one-timestep-lagged + coefficient, which is a good trade since :math:`\phi` doesn't + move far in a single step. + """ + dim = self.mesh.dim + if not self._discontinuity_capturing: + return sympy.zeros(1, dim) + + u = self._V_fn + u_mag2 = sum(u[0, i] ** 2 for i in range(dim)) + u_mag = sympy.sqrt(u_mag2 + 1.0e-30) + u_hat = u / u_mag + + # --- nu_dc computed from phi_old ONLY (known, not the SNES + # unknown) -- see docstring above. --------------------------- + grad_old = self.mesh.vector.gradient(self.phi_old.sym[0, 0]) + advective_old = sum(u[0, i] * grad_old[0, i] for i in range(dim)) + grad_norm_old = sympy.sqrt( + sum(grad_old[0, i] ** 2 for i in range(dim)) + 1.0e-30) + # sympy.Abs() would be mathematically correct but sympy can + # rewrite it via re()/im() (real/imaginary part) when it hasn't + # been told the argument is real -- UW3's C99 JIT printer + # doesn't support those. sqrt(x**2 + eps) is a standard + # regularised abs() that sidesteps Abs/re/im entirely, and is + # smooth (differentiable) at 0, which is preferable for + # Newton's method anyway. + abs_R_old = sympy.sqrt(advective_old ** 2 + 1.0e-30) + + h = self.mesh.cell_size() + nu_dc = self._dc_coefficient * h * abs_R_old / grad_norm_old + + # --- applied to the CURRENT (unknown) CN gradient -- blended + # between crosswind-only (weight=0, default, textbook + # Hughes-Mallet) and the full gradient (weight=1, needed for a + # front that varies only along the flow direction, where the + # crosswind component is ~0 and weight=0 would be inert -- see + # dc_streamwise_weight's docstring). Either way this stays + # LINEAR in phi: nu_dc above is now just a known field and + # grad(phi_CN) is a linear (gradient) operator on the unknown. + grad_cn = self._grad_phi_cn() + grad_cn_along_mag = sum(grad_cn[0, i] * u_hat[0, i] for i in range(dim)) + grad_cn_along = grad_cn_along_mag * u_hat + grad_cn_cross = grad_cn - grad_cn_along + grad_cn_target = grad_cn_cross + self._dc_streamwise_weight * grad_cn_along + + return nu_dc * grad_cn_target + + F0 = Template( + r"f_0(\phi)", + lambda self: sympy.Matrix([[self._strong_residual()]]), + "Galerkin (w-tested) part of the residual -- the advection-only " + "strong residual R itself. Diffusion never appears here (it's a " + "flux term, see F1); this term is IDENTICAL whether diffusivity " + "is zero or not.", + ) + F1 = Template( + r"\mathbf{F}_1(\phi)", + lambda self: ( + self._diffusivity * self._grad_phi_cn() + + self._tau() * self._strong_residual() * self._V_fn + + self._dc_flux() + ), + r"Consistent Galerkin diffusive flux kappa*grad(phi_CN) (zero at " + r"diffusivity=0), the SUPG-stabilised advective flux tau*R*u " + r"(\nabla w$-tested), plus the crosswind discontinuity-capturing " + r"flux (zero unless discontinuity_capturing=True).", + ) + + # ------------------------------------------------------------------ + @timing.routine_timer_decorator + def estimate_dt(self, direction_aware: bool = False, percentile: float = 0.0): + r""" + Estimate an appropriate timestep for the advection-diffusion solver. + + Ported from ``SNES_AdvectionDiffusion.estimate_dt`` (the SLCN + solver) -- see that docstring for the full rationale. The only + difference is where :math:`\kappa` comes from: SLCN reads it off + a constitutive model (``self.constitutive_model.K``), whereas + this class carries a plain :attr:`diffusivity` attribute (float + or symbolic/field expression), used directly below. + + Unlike SLCN, this is an EXPLICIT-in-structure SUPG scheme, not + unconditionally stable -- the returned :math:`\delta t` is not + just a *convenience* estimate here, it is closer to a genuine + stability/accuracy requirement for the advective part; see the + ``percentile`` note below for how much margin different + reductions give you. + + This is an implicit (per-step SNES) solver so the returned + :math:`\delta t` is the minimum of: + + - :math:`\delta t_{\textrm{diff}}`: typical time for diffusion across an element + - :math:`\delta t_{\textrm{adv}}`: typical element-crossing time for a fluid parcel + + Parameters + ---------- + direction_aware : bool, default False + If True, the advective dt uses the per-cell extent + *along the local velocity direction* — `h_eff_c = + max_i(s_i) - min_i(s_i)` where `s_i = (x_i - + centroid) · v̂` over the cell vertices. This is the + distance material actually traverses through the cell + per unit ``|v|``, and is **always ≥ the isotropic + mesh._radii estimate**, by 1.5–3× for equant cells + (geometric factor) and up to ~10× for cells that the + mover has stretched along the flow direction. On + adapted meshes the gain is substantial; on uniform + meshes it's the geometric factor only. Off by + default to preserve historical behaviour; safe to + enable everywhere once validated. + percentile : float, default 0.0 + How the per-element timesteps are reduced to one global + value. ``0`` (the default) takes the strict global + MINIMUM — a single cell sets the limit. A value ``> 0`` + takes that global percentile of the per-element dt + instead (``50`` = median), so a few anisotropic sliver + cells (velocity *across* a thin cell) cannot collapse + the timestep. Unlike SLCN, this solver is NOT + unconditionally stable, so a nonzero ``percentile`` here + trades a guaranteed margin for a less conservative dt -- + validate against ``percentile=0`` before trusting it in + production. + + Returns + ------- + pint.Quantity or float + The recommended timestep with physical time units if a model + with reference scales is available, otherwise nondimensional. + """ + + ### required modules + from mpi4py import MPI + + comm = uw.mpi.comm + + # See _sync_diffusivity_from_constitutive_model()'s docstring: + # picks up `constitutive_model.Parameters.diffusivity` if that's + # how the caller set it (the SLCN idiom), so the estimate matches + # what solve() will actually use. + self._sync_diffusivity_from_constitutive_model() + + ## global max diffusivity. SLCN reads this off a constitutive + ## model's unified .K property; this class has no constitutive + ## model wired up for diffusion (see class docstring) -- its + ## diffusivity lives directly on self._diffusivity (float or a + ## symbolic/field expression), which _global_max_diffusivity + ## accepts exactly the same way it accepts self.constitutive_model.K. + diffusivity_glob = _global_max_diffusivity( + self._diffusivity, self.mesh) + + ### velocity values at element centroids (nondimensional) + vel = _centroid_velocities_nd(self._V_fn, self.mesh) + + # Get per-element velocity magnitudes + vel_magnitudes = np.linalg.norm(vel, axis=1) + + # Get per-element radii (characteristic element size) + element_radii = self.mesh._radii + + ## estimate dt of adv and diff components using per-element approach + ## dt_adv_i = h_i / |v_i| for advection + ## dt_diff_i = h_i^2 / κ for diffusion (using global κ for now) + + # Reduce per-element dt to one global value. Default (percentile=0) = + # strict global MINIMUM — one cell sets the limit. percentile>0 takes the + # Nth global percentile (50 = median) of the per-element dt instead, so a + # few anisotropic SLIVER cells (velocity ACROSS a thin cell) don't collapse + # dt -- see the percentile note above: SLCN is unconditionally stable so + # this trade-off is free there, it is NOT free here. + def _reduce_dt(per_elem): + fin = per_elem[np.isfinite(per_elem)] if len(per_elem) else per_elem + if percentile and percentile > 0: + gathered = comm.allgather(np.ascontiguousarray(fin, dtype=float)) + allv = (np.concatenate([a for a in gathered if a.size]) + if any(a.size for a in gathered) else np.empty(0)) + return float(np.percentile(allv, percentile)) if allv.size else np.inf + loc = float(np.min(fin)) if len(fin) else np.inf + return comm.allreduce(loc, op=MPI.MIN) + + # Per-element diffusive timestep (all elements use same diffusivity) + if diffusivity_glob > 0: + dt_diff_per_element = (element_radii ** 2) / diffusivity_glob + else: + dt_diff_per_element = np.array([np.inf]) + + # Per-element advective timestep — either isotropic + # (mesh._radii / |v|) or direction-aware (v-aligned cell + # extent / |v|). + if direction_aware: + # Per-cell vertex indices (triangle / tet). + from underworld3.meshing.smoothing import _tri_cells + tris = _tri_cells(self.mesh.dm) + if tris is None: + # Fall back to isotropic for non-triangle meshes. + h_per_element = element_radii + else: + coords = np.asarray(self.mesh.X.coords) + centroids = coords[tris].mean(axis=1) + # v-hat per cell (use centroid v we already have) + vhat = np.where( + vel_magnitudes[:, None] > 0, + vel / np.maximum(vel_magnitudes[:, None], + 1.0e-30), + 0.0) + D = coords[tris] - centroids[:, None, :] + # Signed projections along v̂ per cell vertex + s = np.einsum('cvd,cd->cv', D, vhat) + h_per_element = s.max(axis=1) - s.min(axis=1) + # Sanity-floor — for zero-velocity cells s=0 + # ⇒ h_eff=0 ⇒ dt_adv=inf via the where below + h_per_element = np.maximum( + h_per_element, 0.0) + else: + h_per_element = element_radii + + with np.errstate(divide='ignore', invalid='ignore'): + dt_adv_per_element = np.where( + vel_magnitudes > 0, + h_per_element / vel_magnitudes, + np.inf + ) + # Global reduction — strict min (percentile=0) or Nth percentile (median). + min_dt_diff_glob = _reduce_dt(dt_diff_per_element) + min_dt_adv_glob = _reduce_dt(dt_adv_per_element) + + # Store for user inspection + self.dt_adv = min_dt_adv_glob if not np.isinf(min_dt_adv_glob) else 0.0 + self.dt_diff = min_dt_diff_glob if not np.isinf(min_dt_diff_glob) else 0.0 + + # Take overall minimum (respecting infinity for zero velocity/diffusivity cases) + dt_estimate = min(min_dt_diff_glob, min_dt_adv_glob) + + # If both are infinite (no velocity and no diffusivity), return infinity + if np.isinf(dt_estimate): + return np.inf + + # Dimensionalise the result to physical time + try: + return uw.dimensionalise(np.squeeze(dt_estimate), {'[time]': 1}) + except Exception: + # Fallback: return plain nondimensional number + return np.squeeze(dt_estimate) + + # ------------------------------------------------------------------ + def solve(self, *, timestep: float = None, zero_init_guess: bool = False, **kwargs) -> None: + """Advance phi by one Crank-Nicolson step of size `timestep`. Updates + phi_old from the current field *before* solving, then performs one + implicit weak-form SNES solve for phi^{n+1} (warm-started from the + current field unless zero_init_guess=True). Identical for pure + advection (diffusivity=0, the default) and advection-diffusion + (diffusivity != 0) -- there is no separate code path. + + `timestep` (matching SNES_AdvectionDiffusion's/SLCN's calling + convention, `.solve(timestep=dt)`) is keyword-only DELIBERATELY: + an earlier version of this class named the parameter `dt` and took + it positionally, and a caller written against SLCN's + `solve(zero_init_guess, timestep, ...)` order that passed a plain + `.solve(dt)` positional call silently landed `dt` in + `zero_init_guess` instead, leaving `timestep` at its default and + producing a `None`-propagation crash two calls deeper (see + ``ddt.py``'s `_trace_departure_points`, `0.5 * dt_for_calc` with + `dt_for_calc=None`) instead of a clear error at the call site + itself. Keyword-only trades that silent mis-binding for an + immediate, loud `TypeError` if a caller ever gets this wrong again. + """ + if timestep is None: + raise ValueError( + "SNES_AdvectionDiffusion_SUPG.solve() requires `timestep` " + "(e.g. `adv_diff.solve(timestep=dt)`) -- there is no default." + ) + self._sync_diffusivity_from_constitutive_model() + dt = float(timestep) + self.phi_old.data[:, 0] = self.u.data[:, 0] + if dt != self._last_dt: + # dt is captured as a plain float inside the F0/F1 lambdas, so + # a genuine change in dt needs the residual re-evaluated (and + # hence the DS/JIT kernels rebuilt) -- but only THEN, not on + # every call with an unchanged dt, which would force a needless + # rebuild every single timestep. + self._dt_value = dt + self._last_dt = dt + self.is_setup = False + super().solve(zero_init_guess=zero_init_guess, **kwargs) \ No newline at end of file From 151b2b8941df35414f4a2f480a6e2a51aef5c1f6 Mon Sep 17 00:00:00 2001 From: nl Date: Thu, 27 Aug 2026 15:54:37 +1000 Subject: [PATCH 02/20] update supg solver --- src/underworld3/systems/level_set_SLCN.py | 939 ++++++++++++++++++ src/underworld3/systems/level_set_SUPG.py | 745 ++++++++++++++ .../Ex_AdvectionDiffusion_1dBlock_slcn.py | 252 +++++ ..._AdvectionDiffusion_1dBlock_supg_dcterm.py | 225 +++++ .../test_tem/LeVeque_swirling_supg_vs_slcn.py | 343 +++++++ 5 files changed, 2504 insertions(+) create mode 100644 src/underworld3/systems/level_set_SLCN.py create mode 100644 src/underworld3/systems/level_set_SUPG.py create mode 100644 src/underworld3/systems/test_tem/Ex_AdvectionDiffusion_1dBlock_slcn.py create mode 100644 src/underworld3/systems/test_tem/Ex_AdvectionDiffusion_1dBlock_supg_dcterm.py create mode 100644 src/underworld3/systems/test_tem/LeVeque_swirling_supg_vs_slcn.py diff --git a/src/underworld3/systems/level_set_SLCN.py b/src/underworld3/systems/level_set_SLCN.py new file mode 100644 index 000000000..b19b07d1d --- /dev/null +++ b/src/underworld3/systems/level_set_SLCN.py @@ -0,0 +1,939 @@ +import numpy as np +from typing import Optional +import warnings +import sympy + +from petsc4py import PETSc # NOTE: added -- _apply_boundary_neumann uses + # PETSc.COMM_WORLD but this file had no + # module-level PETSc import at all (a latent + # NameError-on-call bug in the previous + # version, unrelated to the solver rewrite). + +import underworld3 as uw +from underworld3 import discretisation, systems +from underworld3.utilities._api_tools import Template +from typing import Optional + +from shapely import geometry as sl +from shapely import prepare as _shapely_prepare + +def initialise_psi( + psi, + epsilon, + signed_distance: np.ndarray | None = None, + interface_geometry: str | None = None, + interface: sl.LineString | sl.Polygon | None = None, + interface_coordinates = None, + boundary_coordinates = None, +) -> None: + """ + Fill a UW3 MeshVariable *psi* with conservative level-set (CLS) values: + + psi = ( 1 + tanh( phi / (2 * epsilon) ) ) / 2 + + where *phi* is the signed-distance function (positive on the '1-side'). + + Parameters + ---------- + psi : uw.discretisation.MeshVariable + Target level-set field. + epsilon : float or ndarray, shape (n_nodes,) + Interface thickness. Use ``interface_thickness()`` to compute it. + + Keyword-only + ------------ + signed_distance : ndarray, shape (n_nodes,), optional + Pre-computed signed distances. If supplied, all geometry arguments + are ignored and the CLS field is written immediately. + + interface_geometry : {'curve', 'polygon', 'circle', 'shapely'} + How the interface is described (ignored when *signed_distance* is given). + + interface : shapely.LineString or shapely.Polygon, optional + Required when ``interface_geometry='shapely'``. + + interface_coordinates : list of (x, y) or ((cx, cy), radius) + Vertex list for 'curve'/'polygon', or (centre, radius) for 'circle'. + + boundary_coordinates : list of (x, y), optional + Extra boundary points used to close an open interface into a polygon + that defines the '1-side'. + + Notes + ----- + * ``psi → 1`` inside the interface (positive signed distance) + * ``psi = 0.5`` on the interface + * ``psi → 0`` outside the interface (negative signed distance) + + References + ---------- + Parameswaran & Mandal (2023), Eur. J. Mech.-B/Fluids, 98, 40-63. + g-ADOPT ``assign_level_set_values``: + https://github.com/g-adopt/g-adopt/blob/main/gadopt/level_set_tools.py + """ + + if signed_distance is not None: + psi.data[:, 0] = _tanh_profile(signed_distance, epsilon) + return + + if interface_geometry is None: + raise ValueError( + "Provide either 'signed_distance' or 'interface_geometry'." + ) + + if interface_coordinates is None and interface_geometry != "shapely": + raise ValueError( + "'interface_coordinates' is required when " + f"interface_geometry='{interface_geometry}'." + ) + + points = psi.coords # shape (n_nodes, dim) + signed_distance = _signed_distance_from_geometry( + interface_geometry, + interface, + interface_coordinates, + boundary_coordinates, + points,) + epsilon_data = epsilon.data[:,0] + psi.data[:, 0] = _tanh_profile(signed_distance, epsilon_data) + + +def interface_thickness( + mesh: uw.discretisation.Mesh, + phi: uw.discretisation.MeshVariable, + *, + scale: float = 0.35, + use_min_edge_length: bool = False, +) -> uw.discretisation.MeshVariable: + """Compute a spatially-varying interface thickness ε on the same mesh and + degree as *phi*, returned as a scalar ``MeshVariable``. + """ + if use_min_edge_length and mesh.qdegree > 1: + raise ValueError( + "use_min_edge_length=True is only valid for straight-edged meshes " + "(qdegree=1)." + ) + + from scipy.spatial import cKDTree + + dm = mesh.dm + dim = mesh.dim + c_start, c_end = dm.getHeightStratum(0) # cell range in the DMPlex + n_cells = c_end - c_start + cell_epsilon = np.empty(n_cells, dtype=float) + cell_centroids = np.empty((n_cells, dim), dtype=float) + + if not use_min_edge_length: + scale_factor = scale / np.sqrt(dim) + for i, cell in enumerate(range(c_start, c_end)): + vol, centroid, _ = dm.computeCellGeometryFVM(cell) + cell_epsilon[i] = scale_factor * float(np.asarray(vol).ravel()[0]) ** (1.0 / dim) + cell_centroids[i, :] = np.asarray(centroid).ravel()[:dim] + else: + v_start, v_end = dm.getDepthStratum(0) + coords = mesh.data # (n_vertices, dim) + for i, cell in enumerate(range(c_start, c_end)): + closure, _ = dm.getTransitiveClosure(cell) + verts = [p for p in closure if v_start <= p < v_end] + v_coords = coords[[p - v_start for p in verts]] + # minimum pairwise edge length + min_edge = np.inf + for a in range(len(v_coords)): + for b in range(a + 1, len(v_coords)): + d = np.linalg.norm(v_coords[a] - v_coords[b]) + if d < min_edge: + min_edge = d + cell_epsilon[i] = scale * min_edge + cell_centroids[i, :] = v_coords.mean(axis=0) + + epsilon_var = uw.discretisation.MeshVariable( + r"\epsilon", mesh, 1, degree=phi.degree,continuous=phi.continuous + ) + node_coords = phi.coords # (n_nodes, dim) + tree = cKDTree(cell_centroids) + _, nearest = tree.query(node_coords) # nearest[i] = cell index for node i + + epsilon_var.data[:, 0] = cell_epsilon[nearest] + return epsilon_var + + +def _sgn_dist_closed(interface: sl.Polygon, points: np.ndarray) -> np.ndarray: + """Signed distance: positive inside, negative outside a closed polygon.""" + _shapely_prepare(interface) + boundary = interface.boundary + sgn = np.where( + [interface.contains(sl.Point(p)) for p in points], 1.0, -1.0 + ) + dist = np.array([boundary.distance(sl.Point(p)) for p in points]) + return sgn * dist + +def _sgn_dist_open( + interface: sl.LineString, + enclosed_side: sl.Polygon, + points: np.ndarray, +) -> np.ndarray: + """Signed distance w.r.t. an open interface; sign from enclosed polygon.""" + _shapely_prepare(enclosed_side) + sgn = np.where( + [enclosed_side.intersects(sl.Point(p)) for p in points], 1.0, -1.0 + ) + dist = np.array([interface.distance(sl.Point(p)) for p in points]) + return sgn * dist + +def _tanh_profile(phi: np.ndarray, epsilon: float | np.ndarray) -> np.ndarray: + """CLS tanh profile: (1 + tanh(phi / 2ε)) / 2""" + return (1.0 + np.tanh(np.asarray(phi) / (2.0 * np.asarray(epsilon)))) / 2.0 + +def _signed_distance_from_geometry( + interface_geometry: str, + interface, + interface_coordinates, + boundary_coordinates, + points: np.ndarray, +) -> np.ndarray: + """Dispatch to the correct signed-distance routine based on geometry type.""" + + match interface_geometry: + + case "curve": + itf = sl.LineString(interface_coordinates) + if itf.is_closed: + _require_no_boundary(boundary_coordinates, "closed curve") + return _sgn_dist_closed(sl.Polygon(itf), points) + else: + _require_boundary(boundary_coordinates, "open curve") + enclosed = sl.Polygon( + np.vstack((interface_coordinates, boundary_coordinates)) + ) + return _sgn_dist_open(itf, enclosed, points) + + case "polygon": + if boundary_coordinates is None: + return _sgn_dist_closed(sl.Polygon(interface_coordinates), points) + else: + itf = sl.LineString(interface_coordinates) + enclosed = sl.Polygon( + np.vstack((interface_coordinates, boundary_coordinates)) + ) + return _sgn_dist_open(itf, enclosed, points) + + case "shapely": + if interface is None: + raise ValueError( + "'interface' must be provided when interface_geometry='shapely'." + ) + if isinstance(interface, sl.Polygon): + return _sgn_dist_closed(interface, points) + else: # LineString + _require_boundary(boundary_coordinates, "shapely LineString") + enclosed = sl.Polygon( + np.vstack((interface.coords, boundary_coordinates)) + ) + return _sgn_dist_open(interface, enclosed, points) + + case _: + raise ValueError( + f"Unknown interface_geometry='{interface_geometry}'. " + "Choose from: 'curve', 'polygon', 'shapely'." + ) + +def _require_boundary(boundary_coordinates, context: str) -> None: + if boundary_coordinates is None: + raise ValueError( + f"'boundary_coordinates' must be supplied for an {context}." + ) + +def _require_no_boundary(boundary_coordinates, context: str) -> None: + if boundary_coordinates is not None: + raise ValueError( + f"'boundary_coordinates' must not be provided for a {context}." + ) + +def _allreduce_min(value: float) -> float: + """Global MPI min via PETSc.COMM_WORLD (works in serial and parallel).""" + from petsc4py import PETSc + from mpi4py import MPI + return PETSc.COMM_WORLD.tompi4py().allreduce(float(value), op=MPI.MIN) + +def _allreduce_max(value: float) -> float: + """Global MPI max via PETSc.COMM_WORLD (works in serial and parallel).""" + from petsc4py import PETSc + from mpi4py import MPI + return PETSc.COMM_WORLD.tompi4py().allreduce(float(value), op=MPI.MAX) + +# ============================================================================= +# Reinitialisation gradient: 2nd-order ENO + Godunov upwinding +# (Osher & Shu, 1991; Jiang & Peng, 2000; Sussman, Smereka & Osher, 1994) +# ============================================================================= +def _snap_to_grid_indices(vals: np.ndarray, rtol: float): + """Map coordinates that should form an evenly-spaced 1D grid (possibly + with small floating-point noise between nominally-equal values) onto + integer grid indices, robustly. + + Deliberately NOT a sequential/chain tolerance-merge (compare each + sorted value only to the previous *accepted* one): that construction + is fragile by design -- A within tol of B and B within tol of C does + not imply A is within tol of C, so a slow "creep" across several + values can chain-merge a run that should have been split into + distinct grid lines, or (as observed in practice, on a real Q2 mesh + where shared-vertex DOF coordinates are computed via different + elements' local coordinate maps and needn't be bit-identical) merge + inconsistently depending on value order, breaking the index mapping. + + Instead: estimate the true grid spacing from the smallest gap between + sorted *unique* values that's clearly not just noise (bigger than + ``rtol`` times the value range), then snap every value to the nearest + integer multiple of that spacing from a single fixed reference (the + minimum value) -- every value is compared against one global + reference, not against its neighbours in a chain, so there's no + order-dependent failure mode. + + Returns (indices, spacing, origin). + """ + uniq = np.unique(vals) + if uniq.size == 1: + return np.zeros(vals.shape, dtype=int), 1.0, float(uniq[0]) + + span = uniq[-1] - uniq[0] + noise_floor = rtol * max(span, 1.0) + gaps = np.diff(uniq) + significant = gaps[gaps > noise_floor] + if significant.size == 0: + raise RuntimeError( + "_snap_to_grid_indices: no gap between distinct coordinate " + f"values exceeds the noise floor ({noise_floor:.3e}, from " + f"rtol={rtol:.1e} x span={span:.3e}) -- either every point is " + "genuinely coincident, or rtol needs loosening for this mesh's " + "coordinate scale." + ) + spacing = float(np.min(significant)) + origin = float(uniq[0]) + + indices = np.round((vals - origin) / spacing).astype(int) + return indices, spacing, origin + + +class _StructuredGrid: + """Maps a continuous Lagrange field's DOFs on a structured + (Cartesian-topology) quad mesh to a regular ``(ny, nx)`` array of + nodes, so classical Cartesian finite-difference schemes -- here, + 2nd-order ENO -- can be applied via plain array indexing instead of + unstructured per-cell stencils. + + Built once from ``var.coords`` via :func:`_snap_to_grid_indices` + (snap-to-nearest-multiple-of-estimated-spacing from a fixed + reference, robust to floating-point noise between nominally-equal + shared-vertex coordinates -- see that function's docstring for why a + naive sequential tolerance merge is NOT used here), agnostic to + whatever internal DOF ordering UW3 happens to use, as long as the + node set genuinely forms a regular grid (true for any Lagrange degree + on ``uw.meshing.StructuredQuadBox``; raises ``RuntimeError`` rather + than a silently-wrong mapping if it does not, e.g. on an unstructured + or simplex mesh). + + **Serial only.** ``var.coords`` is the *rank-local* DOF set; a + parallel-consistent version would need an allgather of coordinates and + a halo exchange for the two ghost points ENO needs at every + partition boundary. Not implemented here -- run this on a single rank, + or extend this class before trusting it in parallel. + """ + + def __init__(self, var: uw.discretisation.MeshVariable, rtol: float = 1.0e-6): + coords = np.asarray(var.coords) + if coords.shape[1] != 2: + raise NotImplementedError("_StructuredGrid currently implements 2D only.") + x, y = coords[:, 0], coords[:, 1] + + ix, self.dx, self._x0 = _snap_to_grid_indices(x, rtol) + iy, self.dy, self._y0 = _snap_to_grid_indices(y, rtol) + + self.nx = int(ix.max()) + 1 + self.ny = int(iy.max()) + 1 + if self.nx * self.ny != coords.shape[0]: + raise RuntimeError( + f"_StructuredGrid: {coords.shape[0]} DOFs do not factor into " + f"a {self.ny} x {self.nx} regular grid ({self.ny * self.nx} " + "expected) -- this mesh/field does not have a genuinely " + "structured (Cartesian-topology) node layout, or `rtol` " + "needs adjusting for its coordinate noise/spacing scale " + f"(dx={self.dx:.3e}, dy={self.dy:.3e} were the estimated " + "spacings)." + ) + + flat_idx = iy * self.nx + ix + if np.unique(flat_idx).size != coords.shape[0]: + raise RuntimeError( + "_StructuredGrid: DOF-to-grid-index mapping is not one-to-one " + f"even though nx*ny matched (nx={self.nx}, ny={self.ny}, " + f"dx={self.dx:.3e}, dy={self.dy:.3e}) -- try loosening/" + "tightening `rtol` for this mesh's actual coordinate noise " + "scale." + ) + + self._i, self._j = ix, iy + + def to_grid(self, flat_array: np.ndarray) -> np.ndarray: + grid = np.empty((self.ny, self.nx)) + grid[self._j, self._i] = flat_array + return grid + + def to_dofs(self, grid_array: np.ndarray) -> np.ndarray: + return grid_array[self._j, self._i] + + +def _minmod(a: np.ndarray, b: np.ndarray) -> np.ndarray: + """Standard two-argument minmod: same sign as both, magnitude the + smaller of the two -- zero if they disagree in sign.""" + same_sign = np.sign(a) == np.sign(b) + return np.where(same_sign, np.sign(a) * np.minimum(np.abs(a), np.abs(b)), 0.0) + + +def _eno2_one_sided(f: np.ndarray, h: float, axis: int): + """Second-order ENO one-sided derivatives (Osher & Shu, 1991) of a + regular-grid array ``f`` along ``axis``, spacing ``h``. + + Classic HJ-ENO2 construction: start from the first-order one-sided + (upwind) difference, then correct it with the smaller-magnitude + (same-sign) of the two neighbouring second-difference estimates -- + i.e. pick whichever of the two candidate quadratic stencils is + smoother, so the reconstruction doesn't differ across a kink in the + field. Returns ``(D_minus, D_plus)``, each the same shape as ``f``. + + The two ghost points ENO needs on each side of the domain are filled + by constant (edge-value) extrapolation -- a simple, standard choice + that approximates a zero-gradient/Neumann boundary, consistent with + this module's existing ``_apply_boundary_neumann`` treatment + elsewhere. + """ + pad_width = [(0, 0)] * f.ndim + pad_width[axis] = (2, 2) + fp = np.pad(f, pad_width, mode="edge") + + n = f.shape[axis] + + def shift(k): + sl_ = [slice(None)] * f.ndim + sl_[axis] = slice(2 + k, 2 + k + n) + return fp[tuple(sl_)] + + fm2, fm1, f0, fp1, fp2 = shift(-2), shift(-1), shift(0), shift(1), shift(2) + + D1_mh = (f0 - fm1) / h # D_{i-1/2} + D1_ph = (fp1 - f0) / h # D_{i+1/2} + D1_m3h = (fm1 - fm2) / h # D_{i-3/2} + D1_p3h = (fp2 - fp1) / h # D_{i+3/2} + + D2_im1 = (D1_mh - D1_m3h) / h + D2_i = (D1_ph - D1_mh) / h + D2_ip1 = (D1_p3h - D1_ph) / h + + D_minus = D1_mh + (h / 2.0) * _minmod(D2_im1, D2_i) + D_plus = D1_ph - (h / 2.0) * _minmod(D2_i, D2_ip1) + return D_minus, D_plus + + +def _grad_magnitude_eno2(phi: np.ndarray, phi0_sign: np.ndarray, dx: float, dy: float) -> np.ndarray: + """``|grad phi|`` via 2nd-order ENO one-sided differences (Osher & Shu, + 1991; Jiang & Peng, 2000) combined with Godunov's upwind selection + based on the sign of the *frozen* reference field ``phi0_sign`` + (Sussman, Smereka & Osher, 1994) -- the standard, stable numerical + Hamiltonian for the reinitialisation equation's gradient term. + + ``phi0_sign`` should be ``sign(phi0 - 0.5)`` for a CLS field in + [0, 1] (interface at 0.5), computed *once* at the start of a + reinitialisation call and held fixed through all of its pseudo-time + stages -- re-deriving the sign from the evolving field at every stage + would let the upwind choice itself drift as the profile sharpens, + which is exactly the kind of inconsistency Godunov upwinding is meant + to avoid. + """ + Dxm, Dxp = _eno2_one_sided(phi, dx, axis=1) # x varies along columns + Dym, Dyp = _eno2_one_sided(phi, dy, axis=0) # y varies along rows + + pos = phi0_sign > 0 + ax = np.where(pos, np.maximum(Dxm, 0.0), np.minimum(Dxm, 0.0)) + bx = np.where(pos, np.minimum(Dxp, 0.0), np.maximum(Dxp, 0.0)) + ay = np.where(pos, np.maximum(Dym, 0.0), np.minimum(Dym, 0.0)) + by = np.where(pos, np.minimum(Dyp, 0.0), np.maximum(Dyp, 0.0)) + + gx2 = np.maximum(ax ** 2, bx ** 2) + gy2 = np.maximum(ay ** 2, by ** 2) + return np.sqrt(gx2 + gy2) + + +class LevelSetSolver: + """Conservative level-set advection + reinitialisation solver for UW3. + + Advection: Crank-Nicolson + SUPG (Brooks & Hughes, 1982), via + :class:`SUPGAdvection` -- a hand-built weak-form solver on UW3's + generic ``SNES_Scalar`` scaffolding, NOT ``AdvDiffusionSLCN``/ + ``SemiLagrangian``. + + Reinitialisation: Eq. (17) of Parameswaran & Mandal (2023), + + d(phi)/d(tau) = -phi(1-phi)(1-2phi) + eps(1-2phi)|grad phi|, + + integrated with the three-stage SSP-RK3 ("TVD Runge-Kutta") scheme of + Gottlieb & Shu (1998) -- unchanged from the previous version of this + file, since that was already the scheme requested. ``|grad phi|`` is + now computed via 2nd-order ENO + Godunov upwinding (see + :func:`_grad_magnitude_eno2`) on a regular node grid, NOT + ``uw.systems.Projection``. This currently restricts ``LevelSetSolver`` + to a structured (Cartesian-topology) mesh, e.g. + ``uw.meshing.StructuredQuadBox`` -- :class:`_StructuredGrid` raises + ``RuntimeError`` rather than silently mis-mapping on anything else. + + Parameters + ---------- + level_set : MeshVariable + Scalar ``MeshVariable`` (degree >= 1, continuous) that holds the + CLS field phi. Its mesh is used for all sub-solvers. + velocity : MeshVariable.sym or sympy expression + Velocity field used for advection. Typically the `.sym` of a + Stokes velocity ``MeshVariable``. + epsilon : MeshVariable + Interface thickness field ε (see ``interface_thickness()``). + reini_dt : float, optional + Pseudo-time step for reinitialisation (default 0.5 x eps). + reini_steps : int, optional + Number of pseudo-time steps per reinitialisation call (default 5). + reini_frequency : int or None, optional + How many advection steps between reinitialisation passes. + ``None`` uses the automatic strategy (see `_default_frequency`). + theta : float, optional + Time-integration parameter for the advection solver (default 0.5, + Crank-Nicolson; 1.0 would be backward-Euler). + adv_solver_opts : dict, optional + Extra PETSc options forwarded to the advection solver. + adv_solver_bc : sequence of str, optional + Mesh boundary labels (e.g. ``["Left","Right","Top","Bottom"]``) to + apply the zero-normal-gradient Neumann correction to after every + advection/reinitialisation pass (see `_apply_boundary_neumann`). + + Usage example + ------------- + >>> import underworld3 as uw, sympy + >>> mesh = uw.meshing.StructuredQuadBox(elementRes=(32, 32)) + >>> phi = uw.discretisation.MeshVariable("phi", mesh, 1, degree=2, continuous=True) + >>> v_sol = uw.discretisation.MeshVariable("v", mesh, 2, degree=2) + >>> eps = interface_thickness(mesh, phi) + >>> ls = LevelSetSolver(phi, velocity=v_sol.sym, epsilon=eps) + >>> for step in range(100): + ... ls.solve(dt=1e-3) # advect (+ reinitialise if due) + """ + + def __init__( + self, + level_set: discretisation.MeshVariable, + *, + velocity, + epsilon, + reini_dt: Optional[float] = None, + reini_steps: int = 5, + reini_frequency: Optional[int] = None, + theta: float = 0.5, + adv_solver_opts: Optional[dict] = None, + adv_solver_bc: Optional[dict] = None, + conserve_mass: bool = True, + mass_correction_tol: float = 1.0e-10, + mass_correction_max_iter: int = 40, + ) -> None: + if level_set.num_components != 1: + raise ValueError("`level_set` must be a scalar MeshVariable.") + if not level_set.continuous: + raise ValueError( + "`level_set` must be a CONTINUOUS MeshVariable -- " + "SUPGAdvection assembles a continuous-Galerkin weak form " + "and needs shared vertex/edge DOFs across cells." + ) + + self.phi = level_set + self.mesh = level_set.mesh + self.velocity = velocity + self.epsilon = epsilon + self.reini_dt = float(reini_dt) if reini_dt is not None else 0.5 * float(epsilon.data[:, 0].min()) + self.reini_steps = int(reini_steps) + self.step = 0 # counts physical advection steps taken + + # ---- Advection solver (SLCN, zero diffusivity) -------------------- + + self._comp_ddt = uw.systems.ddt.SemiLagrangian( + self.mesh, self.phi.sym, self.velocity, + vtype=uw.VarType.SCALAR, degree=self.phi.degree, continuous=self.phi.continuous, + varsymbol="cphi", bcs=[], order=1, smoothing=0.0, + monotone_mode="clamp", theta=0.5, old_frame_traceback=True, + ) + + self._adv_solver = systems.AdvDiffusionSLCN( + self.mesh, + u_Field=self.phi, + V_fn=self.velocity,order=1, DuDt=self._comp_ddt, + ) + # Zero diffusivity → pure advection + self._adv_solver.constitutive_model = uw.constitutive_models.DiffusionModel + self._adv_solver.constitutive_model.Parameters.diffusivity = 0. + self._adv_solver.tolerance = 1.0e-4 + self._adv_solver_bc = adv_solver_bc + + # ---- Reinitialisation: ENO2/Godunov gradient on a regular grid ---- + self._grid = _StructuredGrid(self.phi) + self._phi0_sign_grid = None # frozen sign(phi0-0.5), set in reinitialise() + + # ---- Reinitialisation frequency ----------------------------------- + if reini_frequency is None: + self._reini_frequency = self._default_frequency() + else: + self._reini_frequency = int(reini_frequency) + + # ---- Global mass correction (Zhang, Zou & Greaves 2010) ----------- + # Neither the reinitialisation equation (Eq. 17 is contour- + # preserving, not mass-preserving) nor a raw clamp() (an + # unweighted np.clip -- adds mass wherever it zeros an undershoot, + # removes it wherever it caps an overshoot; if that's not + # symmetric, e.g. from mild cross-wind oscillation SUPG alone + # doesn't fully suppress, the imbalance accumulates every step) + # come with any conservation guarantee. Advection itself does, in + # theory, for a divergence-free/boundary-vanishing velocity field + # (see SUPGAdvection's docstring), so this corrects for the other + # two rather than second-guessing the advection solve. + self.conserve_mass = conserve_mass + self._mass_correction_tol = float(mass_correction_tol) + self._mass_correction_max_iter = int(mass_correction_max_iter) + self._target_volume = self.interface_volume() if conserve_mass else None + + + # ------------------------------------------------------------------ + # Public interface + # ------------------------------------------------------------------ + + def solve(self, dt: float, *, reinitialise: bool = True) -> None: + self._adv_solver.solve(timestep=dt) + if self._adv_solver_bc: + self._apply_boundary_neumann(labels=self._adv_solver_bc) + self.step += 1 + + if reinitialise and (self.step % self._reini_frequency == 0): + self.reinitialise() + if self._adv_solver_bc: + self._apply_boundary_neumann(labels=self._adv_solver_bc) + + if self.conserve_mass: + self._correct_mass(self._target_volume) + + def reinitialise(self) -> None: + """Run `reini_steps` pseudo-time steps of CLS reinitialisation. + + Each step integrates Eq. (17) of Parameswaran & Mandal (2023), + + ∂φ/∂τₙ = θ [ −φ(1−φ)(1−2φ) + ε(1−2φ)|∇φ| ] + + using the three-stage SSP-RK3 scheme the paper validates all of its + results with (their Eq. 28) -- unchanged. |∇φ| is now computed by + 2nd-order ENO + Godunov upwinding (Osher & Shu, 1991; Jiang & Peng, + 2000; Sussman, Smereka & Osher, 1994) on the regular node grid + rather than an L2 projection; its upwind sign reference + sign(phi-0.5) is frozen HERE, once, before the pseudo-time loop. + Both RHS terms share the factor (1−2φ), so φ = 0.5 (the interface) + is a fixed point -- reinitialisation sharpens the profile without + moving the 0.5-contour. + """ + phi0_grid = self._grid.to_grid(self.phi.data[:, 0]) + self._phi0_sign_grid = np.sign(phi0_grid - 0.5) + + for _ in range(self.reini_steps): + self._reini_ssprk3_step(self.reini_dt) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _rhs(self, phi_values: np.ndarray) -> np.ndarray: + """Evaluate the RHS of Eq. (17), L(φ), at a given nodal φ array. + + Reshapes `phi_values` onto the regular node grid, computes + |grad phi| there via ENO2 + Godunov upwinding against the frozen + sign reference from `reinitialise()`, then reshapes back and + combines the sharpening and balancing terms nodally: + + sharpening = −φ(1−φ)(1−2φ) balance = ε(1−2φ)|∇φ| + """ + phi_grid = self._grid.to_grid(phi_values) + gmag_grid = _grad_magnitude_eno2( + phi_grid, self._phi0_sign_grid, self._grid.dx, self._grid.dy + ) + grad = self._grid.to_dofs(gmag_grid) + eps = self.epsilon.data[:, 0] + + sharpening = -phi_values * (1 - phi_values) * (1 - 2 * phi_values) + balance = eps * (1 - 2 * phi_values) * grad + return sharpening + balance # theta = 1 + + def _reini_ssprk3_step(self, dtau: float) -> None: + """One SSP-RK3 pseudo-time step of Eq. (17) (Eq. 28, Parameswaran & + Mandal 2023): + + φ⁽¹⁾ = φⁿ + Δτ L(φⁿ) + φ⁽²⁾ = ¾φⁿ + ¼φ⁽¹⁾ + ¼Δτ L(φ⁽¹⁾) + φⁿ⁺¹ = ⅓φⁿ + ⅔φ⁽²⁾ + ⅔Δτ L(φ⁽²⁾) + + ``self.phi.data`` holds φⁿ on entry and φⁿ⁺¹ on exit. + """ + psi0 = self.phi.data[:, 0].copy() + + L0 = self._rhs(psi0) + psi1 = psi0 + dtau * L0 + + L1 = self._rhs(psi1) + psi2 = 0.75 * psi0 + 0.25 * psi1 + 0.25 * dtau * L1 + + L2 = self._rhs(psi2) + psi_new = (1.0 / 3.0) * psi0 + (2.0 / 3.0) * psi2 + (2.0 / 3.0) * dtau * L2 + + self.phi.data[:, 0] = psi_new + + def _default_frequency(self) -> int: + """Automatic reinitialisation frequency. + + reinitialise every step up to a reference cell size, then scale down as the mesh refines. + Falls back to 1 for non-Cartesian or unusual meshes. + """ + try: + coords = self.mesh.data + max_c = np.array([_allreduce_max(coords[:, i].max()) + for i in range(coords.shape[1])]) + min_c = np.array([_allreduce_min(coords[:, i].min()) + for i in range(coords.shape[1])]) + domain_size = float(np.sqrt(np.sum((max_c - min_c) ** 2))) + return max(1, round(4.9e-3 * domain_size / self.epsilon.data.min() - 0.25)) + except Exception: + warnings.warn( + "Could not compute domain size for reinitialisation frequency; " + "defaulting to every step.", stacklevel=2 + ) + return 1 + + def _apply_boundary_neumann(self, labels=("Left", "Right", "Top", "Bottom")) -> None: + """Enforce zero-normal-gradient at the given mesh boundaries by copying + the adjacent interior row/column of nodes onto the wall nodes. + """ + from mpi4py import MPI + comm = PETSc.COMM_WORLD.tompi4py() + + coords = self.phi.coords + n_local = coords.shape[0] + + axis_for_label = {"Left": 0, "Right": 0, "Top": 1, "Bottom": 1} + reduce_for_label = { + "Left": _allreduce_min, "Right": _allreduce_max, + "Top": _allreduce_max, "Bottom": _allreduce_min, + } + is_min_side = {"Left": True, "Right": False, "Top": False, "Bottom": True} + + for label in labels: + axis = axis_for_label[label] + tang = 1 - axis + + # global wall coordinate -- +/-inf on an empty rank so it can't + # corrupt the min/max reduction + if n_local: + local_extreme = (coords[:, axis].min() if is_min_side[label] + else coords[:, axis].max()) + else: + local_extreme = np.inf if is_min_side[label] else -np.inf + wall_val = reduce_for_label[label](float(local_extreme)) + + # global interior-column coordinate + local_axis_vals = np.unique(coords[:, axis]) if n_local else np.empty(0) + all_axis_vals = np.unique(np.concatenate(comm.allgather(local_axis_vals))) + if all_axis_vals.size < 2: + continue # degenerate mesh extent in this direction + ordered = all_axis_vals[np.argsort(np.abs(all_axis_vals - wall_val))] + inner_val = ordered[1] # nearest distinct coordinate to the wall + + # this rank's contribution to the global interior-column lookup table + if n_local: + inner_idx = np.where(np.isclose(coords[:, axis], inner_val, atol=1e-8))[0] + else: + inner_idx = np.empty(0, dtype=int) + local_pairs = (np.column_stack((coords[inner_idx, tang], self.phi.data[inner_idx, 0])) + if len(inner_idx) else np.empty((0, 2))) + + gathered = [p for p in comm.allgather(local_pairs) if p.shape[0] > 0] + if not gathered: + continue + global_pairs = np.vstack(gathered) + + # de-duplicate shared/ghost dofs reported by more than one rank + order = np.argsort(global_pairs[:, 0]) + global_pairs = global_pairs[order] + uniq = np.concatenate(([True], np.diff(global_pairs[:, 0]) > 1e-10)) + global_pairs = global_pairs[uniq] + + # this rank's own wall dofs (may be empty on this rank) + wall_idx = (np.where(np.isclose(coords[:, axis], wall_val, atol=1e-8))[0] + if n_local else np.empty(0, dtype=int)) + if len(wall_idx) == 0: + continue # this rank owns no nodes on this wall + + # nearest-neighbour lookup against the global table + wall_tang = coords[wall_idx, tang] + pos = np.clip(np.searchsorted(global_pairs[:, 0], wall_tang), 1, len(global_pairs) - 1) + left_err = np.abs(wall_tang - global_pairs[pos - 1, 0]) + right_err = np.abs(global_pairs[pos, 0] - wall_tang) + nearest = np.where(right_err < left_err, pos, pos - 1) + err = np.minimum(left_err, right_err) + + good = err <= 1e-6 + if not np.all(good): + warnings.warn( + f"[rank {comm.rank}] Neumann BC on '{label}': " + f"{np.count_nonzero(~good)} wall node(s) had no matching " + f"interior-column coordinate within tolerance (max err " + f"{err.max():.3e}); those left unchanged.", + stacklevel=2, + ) + + self.phi.data[wall_idx[good], 0] = global_pairs[nearest[good], 1] + # ------------------------------------------------------------------ + # Diagnostics + # ------------------------------------------------------------------ + + @property + def reini_frequency(self) -> int: + """Reinitialisation frequency (advection steps between calls).""" + return self._reini_frequency + + def interface_volume(self) -> float: + """Return ∫φ dΩ (approximate enclosed volume for mass-conservation checks).""" + integ = uw.maths.Integral(self.mesh, self.phi.sym[0, 0]) + return integ.evaluate() + + def clamp(self, lo: float = 0.0, hi: float = 1.0) -> None: + """Clamp φ values to [lo, hi] in place (post-advection safeguard). + + This is a raw, unweighted np.clip -- NOT mass-conservative on its + own (see `_correct_mass` and the `conserve_mass` constructor + option, which is what actually keeps `interface_volume()` from + drifting over many steps; calling this on top of a mass-corrected + `solve()` is a harmless no-op, since the state is already inside + [lo, hi] by then). + """ + self.phi.data[:, 0] = np.clip(self.phi.data[:, 0], lo, hi) + + def _correct_mass(self, target: float, lo: float = 0.0, hi: float = 1.0) -> None: + """Global mass correction (Zhang, Zou & Greaves 2010): find a + single uniform additive shift `delta` such that + + INT_Omega clip(phi + delta, lo, hi) dOmega == target, + + and leave `self.phi.data` in that clipped, shifted state. + + The map `delta -> resulting volume` is monotone non-decreasing + (increasing delta can only raise or hold every clipped nodal + value, never lower one), so a plain bisection is guaranteed to + converge -- no Newton/derivative needed, and no assumption about + how oscillatory or well-behaved the *current* field is beyond + that monotonicity, which holds unconditionally for a clip. + + This does not know or care *why* the volume drifted (reinit, + clamp asymmetry, or anything else); it is a final, cheap + (a handful of `interface_volume()` evaluations, not a new SNES + solve) correction applied once per `solve()` call. + """ + data0 = self.phi.data[:, 0].copy() + + def vol_for_shift(delta: float) -> float: + self.phi.data[:, 0] = np.clip(data0 + delta, lo, hi) + return self.interface_volume() + + v0 = vol_for_shift(0.0) + if abs(v0 - target) < self._mass_correction_tol: + return # already within tolerance; state from delta=0 stands + + span = hi - lo + if v0 < target: + lo_d, hi_d = 0.0, max(span * 1.0e-3, 1.0e-8) + tries = 0 + while vol_for_shift(hi_d) < target and tries < 30: + hi_d *= 2.0 + tries += 1 + else: + lo_d, hi_d = -max(span * 1.0e-3, 1.0e-8), 0.0 + tries = 0 + while vol_for_shift(lo_d) > target and tries < 30: + lo_d *= 2.0 + tries += 1 + + if not (vol_for_shift(lo_d) <= target <= vol_for_shift(hi_d)): + warnings.warn( + "_correct_mass: could not bracket the target volume " + f"({target:.6g}) within delta in [{lo_d:.3g}, {hi_d:.3g}] " + "-- leaving the field at its widest attempted shift rather " + "than an unbracketed (unreliable) bisection result. This " + "usually means the whole field is already pinned at lo or " + "hi, with nothing left to shift.", + stacklevel=2, + ) + return + + mid = 0.0 + for _ in range(self._mass_correction_max_iter): + mid = 0.5 * (lo_d + hi_d) + vmid = vol_for_shift(mid) + if abs(vmid - target) < self._mass_correction_tol: + break + if vmid < target: + lo_d = mid + else: + hi_d = mid + vol_for_shift(mid) # leave self.phi.data at the converged shift + + +def material_property_field( + level_set: sympy.Expr | list[sympy.Expr], + field_values: list[float], + interface: str, +) -> sympy.Expr: + """Generates sympy algebra describing a physical property across the domain. + Args: + level_set: + A sympy expression for the level set (typically `mesh_variable.sym[0, 0]`), + or a list thereof + field_values: + A list of physical-property values specific to each material + interface: + A string specifying how property transitions between materials are calculated + Returns: + Sympy algebra representing the physical property throughout the domain + """ + impl_interface = ["sharp", "sharp_adjoint", "arithmetic", "geometric", "harmonic"] + if interface not in impl_interface: + raise ValueError(f"Interface must be one of {impl_interface}") + + level_set = level_set.copy() if isinstance(level_set, list) else [level_set] + field_values = field_values.copy() + + result = None + while level_set: + ls = sympy.Max(sympy.Min(level_set.pop(), 1), 0) + + # Deepest (last) level set: pull both surrounding field values at once. + # Otherwise: pull one field value and combine with the running result. + field_value = field_values.pop() + other_side = field_values.pop() if not level_set else result + + match interface: + case "sharp": + result = sympy.Piecewise((field_value, ls > sympy.Rational(1, 2)), (other_side, True)) + case "sharp_adjoint": + ls_shift = ls - sympy.Rational(1, 2) + heaviside = (ls_shift + sympy.Abs(ls_shift)) / 2 / ls_shift + + result = field_value * heaviside + other_side * (1 - heaviside) + case "arithmetic": + result = field_value * ls + other_side * (1 - ls) + case "geometric": + result = field_value**ls * other_side ** (1 - ls) + case "harmonic": + result = 1 / (ls / field_value + (1 - ls) / other_side) + return result diff --git a/src/underworld3/systems/level_set_SUPG.py b/src/underworld3/systems/level_set_SUPG.py new file mode 100644 index 000000000..3e0a1a7c0 --- /dev/null +++ b/src/underworld3/systems/level_set_SUPG.py @@ -0,0 +1,745 @@ +import numpy as np +from typing import Optional +import warnings +import sympy + +from petsc4py import PETSc # NOTE: added -- _apply_boundary_neumann uses + # PETSc.COMM_WORLD but this file had no + # module-level PETSc import at all (a latent + # NameError-on-call bug in the previous + # version, unrelated to the solver rewrite). + +import underworld3 as uw +from underworld3 import discretisation, systems +from underworld3.utilities._api_tools import Template +from typing import Optional + +from shapely import geometry as sl +from shapely import prepare as _shapely_prepare + +from underworld3.systems import AdvDiffusionSUPG + +def initialise_psi( + psi, + epsilon, + signed_distance: np.ndarray | None = None, + interface_geometry: str | None = None, + interface: sl.LineString | sl.Polygon | None = None, + interface_coordinates = None, + boundary_coordinates = None, +) -> None: + """ + Fill a UW3 MeshVariable *psi* with conservative level-set (CLS) values: + + psi = ( 1 + tanh( phi / (2 * epsilon) ) ) / 2 + + where *phi* is the signed-distance function (positive on the '1-side'). + + Parameters + ---------- + psi : uw.discretisation.MeshVariable + Target level-set field. + epsilon : float or ndarray, shape (n_nodes,) + Interface thickness. Use ``interface_thickness()`` to compute it. + + Keyword-only + ------------ + signed_distance : ndarray, shape (n_nodes,), optional + Pre-computed signed distances. If supplied, all geometry arguments + are ignored and the CLS field is written immediately. + + interface_geometry : {'curve', 'polygon', 'circle', 'shapely'} + How the interface is described (ignored when *signed_distance* is given). + + interface : shapely.LineString or shapely.Polygon, optional + Required when ``interface_geometry='shapely'``. + + interface_coordinates : list of (x, y) or ((cx, cy), radius) + Vertex list for 'curve'/'polygon', or (centre, radius) for 'circle'. + + boundary_coordinates : list of (x, y), optional + Extra boundary points used to close an open interface into a polygon + that defines the '1-side'. + + Notes + ----- + * ``psi → 1`` inside the interface (positive signed distance) + * ``psi = 0.5`` on the interface + * ``psi → 0`` outside the interface (negative signed distance) + + References + ---------- + Parameswaran & Mandal (2023), Eur. J. Mech.-B/Fluids, 98, 40-63. + g-ADOPT ``assign_level_set_values``: + https://github.com/g-adopt/g-adopt/blob/main/gadopt/level_set_tools.py + """ + + if signed_distance is not None: + psi.data[:, 0] = _tanh_profile(signed_distance, epsilon) + return + + if interface_geometry is None: + raise ValueError( + "Provide either 'signed_distance' or 'interface_geometry'." + ) + + if interface_coordinates is None and interface_geometry != "shapely": + raise ValueError( + "'interface_coordinates' is required when " + f"interface_geometry='{interface_geometry}'." + ) + + points = psi.coords # shape (n_nodes, dim) + signed_distance = _signed_distance_from_geometry( + interface_geometry, + interface, + interface_coordinates, + boundary_coordinates, + points,) + epsilon_data = epsilon.data[:,0] + psi.data[:, 0] = _tanh_profile(signed_distance, epsilon_data) + + +def interface_thickness( + mesh: uw.discretisation.Mesh, + phi: uw.discretisation.MeshVariable, + *, + scale: float = 0.35, + use_min_edge_length: bool = False, +) -> uw.discretisation.MeshVariable: + """Compute a spatially-varying interface thickness ε on the same mesh and + degree as *phi*, returned as a scalar ``MeshVariable``. + """ + if use_min_edge_length and mesh.qdegree > 1: + raise ValueError( + "use_min_edge_length=True is only valid for straight-edged meshes " + "(qdegree=1)." + ) + + from scipy.spatial import cKDTree + + dm = mesh.dm + dim = mesh.dim + c_start, c_end = dm.getHeightStratum(0) # cell range in the DMPlex + n_cells = c_end - c_start + cell_epsilon = np.empty(n_cells, dtype=float) + cell_centroids = np.empty((n_cells, dim), dtype=float) + + if not use_min_edge_length: + scale_factor = scale / np.sqrt(dim) + for i, cell in enumerate(range(c_start, c_end)): + vol, centroid, _ = dm.computeCellGeometryFVM(cell) + cell_epsilon[i] = scale_factor * float(np.asarray(vol).ravel()[0]) ** (1.0 / dim) + cell_centroids[i, :] = np.asarray(centroid).ravel()[:dim] + else: + v_start, v_end = dm.getDepthStratum(0) + coords = mesh.data # (n_vertices, dim) + for i, cell in enumerate(range(c_start, c_end)): + closure, _ = dm.getTransitiveClosure(cell) + verts = [p for p in closure if v_start <= p < v_end] + v_coords = coords[[p - v_start for p in verts]] + # minimum pairwise edge length + min_edge = np.inf + for a in range(len(v_coords)): + for b in range(a + 1, len(v_coords)): + d = np.linalg.norm(v_coords[a] - v_coords[b]) + if d < min_edge: + min_edge = d + cell_epsilon[i] = scale * min_edge + cell_centroids[i, :] = v_coords.mean(axis=0) + + epsilon_var = uw.discretisation.MeshVariable( + r"\epsilon", mesh, 1, degree=phi.degree,continuous=phi.continuous + ) + node_coords = phi.coords # (n_nodes, dim) + tree = cKDTree(cell_centroids) + _, nearest = tree.query(node_coords) # nearest[i] = cell index for node i + + epsilon_var.data[:, 0] = cell_epsilon[nearest] + return epsilon_var + + +def _sgn_dist_closed(interface: sl.Polygon, points: np.ndarray) -> np.ndarray: + """Signed distance: positive inside, negative outside a closed polygon.""" + _shapely_prepare(interface) + boundary = interface.boundary + sgn = np.where( + [interface.contains(sl.Point(p)) for p in points], 1.0, -1.0 + ) + dist = np.array([boundary.distance(sl.Point(p)) for p in points]) + return sgn * dist + +def _sgn_dist_open( + interface: sl.LineString, + enclosed_side: sl.Polygon, + points: np.ndarray, +) -> np.ndarray: + """Signed distance w.r.t. an open interface; sign from enclosed polygon.""" + _shapely_prepare(enclosed_side) + sgn = np.where( + [enclosed_side.intersects(sl.Point(p)) for p in points], 1.0, -1.0 + ) + dist = np.array([interface.distance(sl.Point(p)) for p in points]) + return sgn * dist + +def _tanh_profile(phi: np.ndarray, epsilon: float | np.ndarray) -> np.ndarray: + """CLS tanh profile: (1 + tanh(phi / 2ε)) / 2""" + return (1.0 + np.tanh(np.asarray(phi) / (2.0 * np.asarray(epsilon)))) / 2.0 + +def _signed_distance_from_geometry( + interface_geometry: str, + interface, + interface_coordinates, + boundary_coordinates, + points: np.ndarray, +) -> np.ndarray: + """Dispatch to the correct signed-distance routine based on geometry type.""" + + match interface_geometry: + + case "curve": + itf = sl.LineString(interface_coordinates) + if itf.is_closed: + _require_no_boundary(boundary_coordinates, "closed curve") + return _sgn_dist_closed(sl.Polygon(itf), points) + else: + _require_boundary(boundary_coordinates, "open curve") + enclosed = sl.Polygon( + np.vstack((interface_coordinates, boundary_coordinates)) + ) + return _sgn_dist_open(itf, enclosed, points) + + case "polygon": + if boundary_coordinates is None: + return _sgn_dist_closed(sl.Polygon(interface_coordinates), points) + else: + itf = sl.LineString(interface_coordinates) + enclosed = sl.Polygon( + np.vstack((interface_coordinates, boundary_coordinates)) + ) + return _sgn_dist_open(itf, enclosed, points) + + case "shapely": + if interface is None: + raise ValueError( + "'interface' must be provided when interface_geometry='shapely'." + ) + if isinstance(interface, sl.Polygon): + return _sgn_dist_closed(interface, points) + else: # LineString + _require_boundary(boundary_coordinates, "shapely LineString") + enclosed = sl.Polygon( + np.vstack((interface.coords, boundary_coordinates)) + ) + return _sgn_dist_open(interface, enclosed, points) + + case _: + raise ValueError( + f"Unknown interface_geometry='{interface_geometry}'. " + "Choose from: 'curve', 'polygon', 'shapely'." + ) + +def _require_boundary(boundary_coordinates, context: str) -> None: + if boundary_coordinates is None: + raise ValueError( + f"'boundary_coordinates' must be supplied for an {context}." + ) + +def _require_no_boundary(boundary_coordinates, context: str) -> None: + if boundary_coordinates is not None: + raise ValueError( + f"'boundary_coordinates' must not be provided for a {context}." + ) + +def _allreduce_min(value: float) -> float: + """Global MPI min via PETSc.COMM_WORLD (works in serial and parallel).""" + from petsc4py import PETSc + from mpi4py import MPI + return PETSc.COMM_WORLD.tompi4py().allreduce(float(value), op=MPI.MIN) + +def _allreduce_max(value: float) -> float: + """Global MPI max via PETSc.COMM_WORLD (works in serial and parallel).""" + from petsc4py import PETSc + from mpi4py import MPI + return PETSc.COMM_WORLD.tompi4py().allreduce(float(value), op=MPI.MAX) + + +class LevelSetSolver: + """Conservative level-set advection + reinitialisation solver for UW3. + + Advection: Crank-Nicolson + SUPG (Brooks & Hughes, 1982), via + :class:`SUPGAdvection` -- a hand-built weak-form solver on UW3's + generic ``SNES_Scalar`` scaffolding, NOT ``AdvDiffusionSLCN``/ + ``SemiLagrangian``. + + Reinitialisation: Eq. (17) of Parameswaran & Mandal (2023), + + d(phi)/d(tau) = -phi(1-phi)(1-2phi) + eps(1-2phi)|grad phi|, + + integrated with the three-stage SSP-RK3 ("TVD Runge-Kutta") scheme of + Gottlieb & Shu (1998) -- unchanged from the previous version of this + file, since that was already the scheme requested. ``|grad phi|`` is + now computed via 2nd-order ENO + Godunov upwinding (see + :func:`_grad_magnitude_eno2`) on a regular node grid, NOT + ``uw.systems.Projection``. This currently restricts ``LevelSetSolver`` + to a structured (Cartesian-topology) mesh, e.g. + ``uw.meshing.StructuredQuadBox`` -- :class:`_StructuredGrid` raises + ``RuntimeError`` rather than silently mis-mapping on anything else. + + Parameters + ---------- + level_set : MeshVariable + Scalar ``MeshVariable`` (degree >= 1, continuous) that holds the + CLS field phi. Its mesh is used for all sub-solvers. + velocity : MeshVariable.sym or sympy expression + Velocity field used for advection. Typically the `.sym` of a + Stokes velocity ``MeshVariable``. + epsilon : MeshVariable + Interface thickness field ε (see ``interface_thickness()``). + reini_dt : float, optional + Pseudo-time step for reinitialisation (default 0.5 x eps). + reini_steps : int, optional + Number of pseudo-time steps per reinitialisation call (default 5). + reini_frequency : int or None, optional + How many advection steps between reinitialisation passes. + ``None`` uses the automatic strategy (see `_default_frequency`). + theta : float, optional + Time-integration parameter for the advection solver (default 0.5, + Crank-Nicolson; 1.0 would be backward-Euler). + adv_solver_opts : dict, optional + Extra PETSc options forwarded to the advection solver. + adv_solver_bc : sequence of str, optional + Mesh boundary labels (e.g. ``["Left","Right","Top","Bottom"]``) to + apply the zero-normal-gradient Neumann correction to after every + advection/reinitialisation pass (see `_apply_boundary_neumann`). + + Usage example + ------------- + >>> import underworld3 as uw, sympy + >>> mesh = uw.meshing.StructuredQuadBox(elementRes=(32, 32)) + >>> phi = uw.discretisation.MeshVariable("phi", mesh, 1, degree=2, continuous=True) + >>> v_sol = uw.discretisation.MeshVariable("v", mesh, 2, degree=2) + >>> eps = interface_thickness(mesh, phi) + >>> ls = LevelSetSolver(phi, velocity=v_sol.sym, epsilon=eps) + >>> for step in range(100): + ... ls.solve(dt=1e-3) # advect (+ reinitialise if due) + """ + + def __init__( + self, + level_set: discretisation.MeshVariable, + *, + velocity, + epsilon, + reini_dt: Optional[float] = None, + reini_steps: int = 5, + reini_frequency: Optional[int] = None, + theta: float = 0.5, + adv_solver_opts: Optional[dict] = None, + adv_solver_bc: Optional[dict] = None, + conserve_mass: bool = True, + mass_correction_tol: float = 1.0e-10, + mass_correction_max_iter: int = 40, + ) -> None: + if level_set.num_components != 1: + raise ValueError("`level_set` must be a scalar MeshVariable.") + if not level_set.continuous: + raise ValueError( + "`level_set` must be a CONTINUOUS MeshVariable -- " + "SUPGAdvection assembles a continuous-Galerkin weak form " + "and needs shared vertex/edge DOFs across cells." + ) + + self.phi = level_set + self.mesh = level_set.mesh + self.velocity = velocity + self.epsilon = epsilon + self.reini_dt = float(reini_dt) if reini_dt is not None else 0.5 * float(epsilon.data[:, 0].min()) + self.reini_steps = int(reini_steps) + self.step = 0 # counts physical advection steps taken + + # ---- Advection solver: Crank-Nicolson + SUPG ---------------------- + self._adv_solver = AdvDiffusionSUPG(self.mesh, self.phi, self.velocity, theta=theta) + self._adv_solver_bc = adv_solver_bc + + if adv_solver_opts: + for k, v in adv_solver_opts.items(): + self._adv_solver.petsc_options[k] = v + + # no use ---- Reinitialisation: ENO2/Godunov gradient on a regular grid ---- + #self._grid = _StructuredGrid(self.phi) + #self._phi0_sign_grid = None # frozen sign(phi0-0.5), set in reinitialise() + + grad_s = self.mesh.vector.gradient(self.phi.sym) + self._grad_mag = sympy.sqrt(sum(g**2 for g in grad_s)) + + # ---- Gradient vector field ∇φ ------------------------------------- + self.phi_grad = discretisation.MeshVariable( + r"|\nabla\phi|", + self.mesh, + 1, + degree= self.phi.degree,continuous = self.phi.continuous + ) + self._grad_projector = systems.Projection(self.mesh, self.phi_grad,degree= self.phi.degree) + self._grad_projector.uw_function = self._grad_mag + + # ---- Reinitialisation frequency ----------------------------------- + if reini_frequency is None: + self._reini_frequency = self._default_frequency() + else: + self._reini_frequency = int(reini_frequency) + + # ---- Global mass correction (Zhang, Zou & Greaves 2010) ----------- + # Neither the reinitialisation equation (Eq. 17 is contour- + # preserving, not mass-preserving) nor a raw clamp() (an + # unweighted np.clip -- adds mass wherever it zeros an undershoot, + # removes it wherever it caps an overshoot; if that's not + # symmetric, e.g. from mild cross-wind oscillation SUPG alone + # doesn't fully suppress, the imbalance accumulates every step) + # come with any conservation guarantee. Advection itself does, in + # theory, for a divergence-free/boundary-vanishing velocity field + # (see SUPGAdvection's docstring), so this corrects for the other + # two rather than second-guessing the advection solve. + self.conserve_mass = conserve_mass + self._mass_correction_tol = float(mass_correction_tol) + self._mass_correction_max_iter = int(mass_correction_max_iter) + self._target_volume = self.interface_volume() if conserve_mass else None + + + # ------------------------------------------------------------------ + # Public interface + # ------------------------------------------------------------------ + + def solve(self, dt: float, *, reinitialise: bool = True) -> None: + self._adv_solver.solve(dt) + if self._adv_solver_bc: + self._apply_boundary_neumann(labels=self._adv_solver_bc) + self.step += 1 + + if reinitialise and (self.step % self._reini_frequency == 0): + self.reinitialise() + if self._adv_solver_bc: + self._apply_boundary_neumann(labels=self._adv_solver_bc) + + if self.conserve_mass: + self._correct_mass(self._target_volume) + + def reinitialise(self) -> None: + """Run `reini_steps` pseudo-time steps of CLS reinitialisation. + + Each step integrates Eq. (17) of Parameswaran & Mandal (2023), + + ∂φ/∂τₙ = θ [ −φ(1−φ)(1−2φ) + ε(1−2φ)|∇φ| ] + + using the three-stage SSP-RK3 scheme the paper validates all of its + results with (their Eq. 28) -- unchanged. |∇φ| is now computed by + 2nd-order ENO + Godunov upwinding (Osher & Shu, 1991; Jiang & Peng, + 2000; Sussman, Smereka & Osher, 1994) on the regular node grid + rather than an L2 projection; its upwind sign reference + sign(phi-0.5) is frozen HERE, once, before the pseudo-time loop. + Both RHS terms share the factor (1−2φ), so φ = 0.5 (the interface) + is a fixed point -- reinitialisation sharpens the profile without + moving the 0.5-contour. + """ + for _ in range(self.reini_steps): + self._reini_ssprk3_step(self.reini_dt) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _update_gradient(self) -> None: + """L2-project |∇φ| onto ``phi_grad`` from the *current* φ data. + """ + self._grad_projector.uw_function = self._grad_mag + self._grad_projector.solve() + + def _rhs(self, phi_values: np.ndarray) -> np.ndarray: + """Evaluate the RHS of Eq. (17), L(φ), at a given nodal φ array. + + Writes ``phi_values`` into ``self.phi`` first (so the gradient + projector, built from ``self.phi.sym``, sees the correct SSP-RK3 + stage value), then projects |∇φ| and combines the sharpening and + balancing terms nodally: + + sharpening = −φ(1−φ)(1−2φ) balance = ε(1−2φ)|∇φ| + """ + self.phi.data[:, 0] = phi_values + self._update_gradient() + grad = self.phi_grad.data[:, 0] + eps = self.epsilon.data[:, 0] + + sharpening = -phi_values * (1 - phi_values) * (1 - 2 * phi_values) + balance = eps * (1 - 2 * phi_values) * grad + return sharpening + balance # theta = 1 + + def _reini_ssprk3_step(self, dtau: float) -> None: + """One SSP-RK3 pseudo-time step of Eq. (17) (Eq. 28, Parameswaran & + Mandal 2023): + + φ⁽¹⁾ = φⁿ + Δτ L(φⁿ) + φ⁽²⁾ = ¾φⁿ + ¼φ⁽¹⁾ + ¼Δτ L(φ⁽¹⁾) + φⁿ⁺¹ = ⅓φⁿ + ⅔φ⁽²⁾ + ⅔Δτ L(φ⁽²⁾) + + ``self.phi.data`` holds φⁿ on entry and φⁿ⁺¹ on exit. + """ + psi0 = self.phi.data[:, 0].copy() + + L0 = self._rhs(psi0) + psi1 = psi0 + dtau * L0 + + L1 = self._rhs(psi1) + psi2 = 0.75 * psi0 + 0.25 * psi1 + 0.25 * dtau * L1 + + L2 = self._rhs(psi2) + psi_new = (1.0 / 3.0) * psi0 + (2.0 / 3.0) * psi2 + (2.0 / 3.0) * dtau * L2 + + self.phi.data[:, 0] = psi_new + + + def _default_frequency(self) -> int: + """Automatic reinitialisation frequency. + + reinitialise every step up to a reference cell size, then scale down as the mesh refines. + Falls back to 1 for non-Cartesian or unusual meshes. + """ + try: + coords = self.mesh.data + max_c = np.array([_allreduce_max(coords[:, i].max()) + for i in range(coords.shape[1])]) + min_c = np.array([_allreduce_min(coords[:, i].min()) + for i in range(coords.shape[1])]) + domain_size = float(np.sqrt(np.sum((max_c - min_c) ** 2))) + return max(1, round(4.9e-3 * domain_size / self.epsilon.data.min() - 0.25)) + except Exception: + warnings.warn( + "Could not compute domain size for reinitialisation frequency; " + "defaulting to every step.", stacklevel=2 + ) + return 1 + + def _apply_boundary_neumann(self, labels=("Left", "Right", "Top", "Bottom")) -> None: + """Enforce zero-normal-gradient at the given mesh boundaries by copying + the adjacent interior row/column of nodes onto the wall nodes. + """ + from mpi4py import MPI + comm = PETSc.COMM_WORLD.tompi4py() + + coords = self.phi.coords + n_local = coords.shape[0] + + axis_for_label = {"Left": 0, "Right": 0, "Top": 1, "Bottom": 1} + reduce_for_label = { + "Left": _allreduce_min, "Right": _allreduce_max, + "Top": _allreduce_max, "Bottom": _allreduce_min, + } + is_min_side = {"Left": True, "Right": False, "Top": False, "Bottom": True} + + for label in labels: + axis = axis_for_label[label] + tang = 1 - axis + + # global wall coordinate -- +/-inf on an empty rank so it can't + # corrupt the min/max reduction + if n_local: + local_extreme = (coords[:, axis].min() if is_min_side[label] + else coords[:, axis].max()) + else: + local_extreme = np.inf if is_min_side[label] else -np.inf + wall_val = reduce_for_label[label](float(local_extreme)) + + # global interior-column coordinate + local_axis_vals = np.unique(coords[:, axis]) if n_local else np.empty(0) + all_axis_vals = np.unique(np.concatenate(comm.allgather(local_axis_vals))) + if all_axis_vals.size < 2: + continue # degenerate mesh extent in this direction + ordered = all_axis_vals[np.argsort(np.abs(all_axis_vals - wall_val))] + inner_val = ordered[1] # nearest distinct coordinate to the wall + + # this rank's contribution to the global interior-column lookup table + if n_local: + inner_idx = np.where(np.isclose(coords[:, axis], inner_val, atol=1e-8))[0] + else: + inner_idx = np.empty(0, dtype=int) + local_pairs = (np.column_stack((coords[inner_idx, tang], self.phi.data[inner_idx, 0])) + if len(inner_idx) else np.empty((0, 2))) + + gathered = [p for p in comm.allgather(local_pairs) if p.shape[0] > 0] + if not gathered: + continue + global_pairs = np.vstack(gathered) + + # de-duplicate shared/ghost dofs reported by more than one rank + order = np.argsort(global_pairs[:, 0]) + global_pairs = global_pairs[order] + uniq = np.concatenate(([True], np.diff(global_pairs[:, 0]) > 1e-10)) + global_pairs = global_pairs[uniq] + + # this rank's own wall dofs (may be empty on this rank) + wall_idx = (np.where(np.isclose(coords[:, axis], wall_val, atol=1e-8))[0] + if n_local else np.empty(0, dtype=int)) + if len(wall_idx) == 0: + continue # this rank owns no nodes on this wall + + # nearest-neighbour lookup against the global table + wall_tang = coords[wall_idx, tang] + pos = np.clip(np.searchsorted(global_pairs[:, 0], wall_tang), 1, len(global_pairs) - 1) + left_err = np.abs(wall_tang - global_pairs[pos - 1, 0]) + right_err = np.abs(global_pairs[pos, 0] - wall_tang) + nearest = np.where(right_err < left_err, pos, pos - 1) + err = np.minimum(left_err, right_err) + + good = err <= 1e-6 + if not np.all(good): + warnings.warn( + f"[rank {comm.rank}] Neumann BC on '{label}': " + f"{np.count_nonzero(~good)} wall node(s) had no matching " + f"interior-column coordinate within tolerance (max err " + f"{err.max():.3e}); those left unchanged.", + stacklevel=2, + ) + + self.phi.data[wall_idx[good], 0] = global_pairs[nearest[good], 1] + # ------------------------------------------------------------------ + # Diagnostics + # ------------------------------------------------------------------ + + @property + def reini_frequency(self) -> int: + """Reinitialisation frequency (advection steps between calls).""" + return self._reini_frequency + + def interface_volume(self) -> float: + """Return ∫φ dΩ (approximate enclosed volume for mass-conservation checks).""" + integ = uw.maths.Integral(self.mesh, self.phi.sym[0, 0]) + return integ.evaluate() + + def clamp(self, lo: float = 0.0, hi: float = 1.0) -> None: + """Clamp φ values to [lo, hi] in place (post-advection safeguard). + + This is a raw, unweighted np.clip -- NOT mass-conservative on its + own (see `_correct_mass` and the `conserve_mass` constructor + option, which is what actually keeps `interface_volume()` from + drifting over many steps; calling this on top of a mass-corrected + `solve()` is a harmless no-op, since the state is already inside + [lo, hi] by then). + """ + self.phi.data[:, 0] = np.clip(self.phi.data[:, 0], lo, hi) + + def _correct_mass(self, target: float, lo: float = 0.0, hi: float = 1.0) -> None: + """Global mass correction (Zhang, Zou & Greaves 2010): find a + single uniform additive shift `delta` such that + + INT_Omega clip(phi + delta, lo, hi) dOmega == target, + + and leave `self.phi.data` in that clipped, shifted state. + + The map `delta -> resulting volume` is monotone non-decreasing + (increasing delta can only raise or hold every clipped nodal + value, never lower one), so a plain bisection is guaranteed to + converge -- no Newton/derivative needed, and no assumption about + how oscillatory or well-behaved the *current* field is beyond + that monotonicity, which holds unconditionally for a clip. + + This does not know or care *why* the volume drifted (reinit, + clamp asymmetry, or anything else); it is a final, cheap + (a handful of `interface_volume()` evaluations, not a new SNES + solve) correction applied once per `solve()` call. + """ + data0 = self.phi.data[:, 0].copy() + + def vol_for_shift(delta: float) -> float: + self.phi.data[:, 0] = np.clip(data0 + delta, lo, hi) + return self.interface_volume() + + v0 = vol_for_shift(0.0) + if abs(v0 - target) < self._mass_correction_tol: + return # already within tolerance; state from delta=0 stands + + span = hi - lo + if v0 < target: + lo_d, hi_d = 0.0, max(span * 1.0e-3, 1.0e-8) + tries = 0 + while vol_for_shift(hi_d) < target and tries < 30: + hi_d *= 2.0 + tries += 1 + else: + lo_d, hi_d = -max(span * 1.0e-3, 1.0e-8), 0.0 + tries = 0 + while vol_for_shift(lo_d) > target and tries < 30: + lo_d *= 2.0 + tries += 1 + + if not (vol_for_shift(lo_d) <= target <= vol_for_shift(hi_d)): + warnings.warn( + "_correct_mass: could not bracket the target volume " + f"({target:.6g}) within delta in [{lo_d:.3g}, {hi_d:.3g}] " + "-- leaving the field at its widest attempted shift rather " + "than an unbracketed (unreliable) bisection result. This " + "usually means the whole field is already pinned at lo or " + "hi, with nothing left to shift.", + stacklevel=2, + ) + return + + mid = 0.0 + for _ in range(self._mass_correction_max_iter): + mid = 0.5 * (lo_d + hi_d) + vmid = vol_for_shift(mid) + if abs(vmid - target) < self._mass_correction_tol: + break + if vmid < target: + lo_d = mid + else: + hi_d = mid + vol_for_shift(mid) # leave self.phi.data at the converged shift + + +def material_property_field( + level_set: sympy.Expr | list[sympy.Expr], + field_values: list[float], + interface: str, +) -> sympy.Expr: + """Generates sympy algebra describing a physical property across the domain. + Args: + level_set: + A sympy expression for the level set (typically `mesh_variable.sym[0, 0]`), + or a list thereof + field_values: + A list of physical-property values specific to each material + interface: + A string specifying how property transitions between materials are calculated + Returns: + Sympy algebra representing the physical property throughout the domain + """ + impl_interface = ["sharp", "sharp_adjoint", "arithmetic", "geometric", "harmonic"] + if interface not in impl_interface: + raise ValueError(f"Interface must be one of {impl_interface}") + + level_set = level_set.copy() if isinstance(level_set, list) else [level_set] + field_values = field_values.copy() + + result = None + while level_set: + ls = sympy.Max(sympy.Min(level_set.pop(), 1), 0) + + # Deepest (last) level set: pull both surrounding field values at once. + # Otherwise: pull one field value and combine with the running result. + field_value = field_values.pop() + other_side = field_values.pop() if not level_set else result + + match interface: + case "sharp": + result = sympy.Piecewise((field_value, ls > sympy.Rational(1, 2)), (other_side, True)) + case "sharp_adjoint": + ls_shift = ls - sympy.Rational(1, 2) + heaviside = (ls_shift + sympy.Abs(ls_shift)) / 2 / ls_shift + + result = field_value * heaviside + other_side * (1 - heaviside) + case "arithmetic": + result = field_value * ls + other_side * (1 - ls) + case "geometric": + result = field_value**ls * other_side ** (1 - ls) + case "harmonic": + result = 1 / (ls / field_value + (1 - ls) / other_side) + return result diff --git a/src/underworld3/systems/test_tem/Ex_AdvectionDiffusion_1dBlock_slcn.py b/src/underworld3/systems/test_tem/Ex_AdvectionDiffusion_1dBlock_slcn.py new file mode 100644 index 000000000..b34089851 --- /dev/null +++ b/src/underworld3/systems/test_tem/Ex_AdvectionDiffusion_1dBlock_slcn.py @@ -0,0 +1,252 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: light +# format_version: '1.5' +# jupytext_version: 1.16.1 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# # Advection-diffusion (1d / cross mesh) +# +# - Using the adv_diff solver. +# - Advection of the rectangular pulse vertically as it also diffuses. The velocity is 0.05 and has a diffusivity value of 1, 0.1 or 0.01 +# - Benchmark comparison between 1D analytical solution and 2D UW numerical model. +# +# ![](Figures/AdvectionTestFigure.png) +# +# *Figure: typical results from this test. Quad mesh v. unstructured triangles with equivalent +# resolution. $\kappa=1$, $\mathbf{v}=(1000,0)$, $t_0 = 0.0001$, $\delta t = 0.0003$. The error looks significantly larger with triangles but you can see that it is dominated by a relatively small* phase error *where the speed of propagation is slightly different from the analytic case.* +# +# +# ## How to test advection or diffusion only +# - Set velocity to 0 to test diffusion only. +# - Set diffusivity (k) to 0 to test advection only. +# +# +# ## Analytic solution +# +# $$ +# T(x,t) = +# \frac{\operatorname{erf}{\left(\frac{- \mathrm{x} + v \left(t + {t_0}\right) + \frac{{\delta}}{2} + {x_0}}{2 \sqrt{\kappa \left(t + {t_0}\right)}} \right)}}{2} + \frac{\operatorname{erf}{\left(\frac{\mathrm{x} - v \left(t + {t_0}\right) + \frac{{\delta}}{2} - {x_0}}{2 \sqrt{\kappa \left(t + {t_0}\right)}} \right)}}{2} +# $$ +# +# Where $x,y$ describe the coordinate frame, $v$ is the horizontal velocity that advects the temperature, $\delta$ is the width of the temperature anomaly, $x_0$ is the initial midpoint of the temperature anomaly. $\kappa$ is the thermal diffusivity, $t_0$ is the time at which we turn on the horizontal velocity. +# +# Note: this solution is derived from the diffusion of a step which is applied to the leading and trailing edges of the block. The solution is valid while the diffusion fronts from each interface remain independent of each other. (This is ill-defined from the problem, but the most obvious test is to look a the time that the block temperature drops below 1 to the tolerance of the solver). +# + +import nest_asyncio +nest_asyncio.apply() + +import underworld3 as uw +import numpy as np +import sympy +import math +import os + +from scipy import special + +if uw.mpi.size == 1: + import matplotlib.pyplot as plt + +import underworld3.systems.level_set_SLCN as ls_slcn +import underworld3.systems.level_set_SUPG as ls_supg + +import sys + +init_t = 0.0001 +dt = 0.0006 +velocity = 1000. +centre = 0.1 +width = 0.2 + + +### min and max temps +tmin = 0. # temp min +tmax = 1.0 # temp max + +# I think we should get into the habit of doing this consistently with the PETSc interface + +res = uw.options.getReal("model_resolution", default=16) +kappa = uw.options.getInt("kappa", default=1) +Tdegree = uw.options.getInt("Tdeg", default=3) +Vdegree = uw.options.getInt("Vdeg", default=2) +simplex = uw.options.getBool("simplex", default=True) + + + +# Tdegree = int(sys.argv[1]) +# Vdegree = int(sys.argv[2]) +# kappa = float(sys.argv[3]) # 1, 0.1, 0.01 # diffusive constant +# res = int(sys.argv[4]) +# simplex = sys.argv[5].lower() + + +outputPath = f'./output/1dblock_adv_diff_slcn/' + +if uw.mpi.rank == 0: + # checking if the directory + if not os.path.exists(outputPath): + os.makedirs(outputPath) +# - + + +xmin, xmax = 0, 1 +ymin, ymax = 0, 0.2 + + +if simplex == True: + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(xmin, ymin), maxCoords=(xmax, ymax), cellSize=(ymax-ymin)/res, regular=False, qdegree=max(Tdegree, Vdegree) ) +else: + mesh = uw.meshing.StructuredQuadBox( + elementRes=(int(res)*5, int(res)), minCoords=(xmin, ymin), maxCoords=(xmax, ymax), qdegree=max(Tdegree, Vdegree), + ) + + +x,y = mesh.X + +x0 = sympy.symbols(r"{x_0}") +t0 = sympy.symbols(r"{t_0}") +delta = sympy.symbols(r"{\delta}") +ks = sympy.symbols(r"\kappa") +ts = sympy.symbols("t") +vs = sympy.symbols("v") + +Ts = ( sympy.erf( (x0 + delta/2 - x+(vs*(ts+t0))) / (2*sympy.sqrt(ks*(ts+t0)))) + sympy.erf( (-x0 + delta/2 + x-((ts+t0)*vs)) / (2*sympy.sqrt(ks*(ts+t0)))) ) / 2 +Ts + + +def build_analytic_fn_at_t(time): + fn = Ts.subs({vs:velocity, ts:time, ks:kappa, delta:width, x0:centre, t0:init_t}) + return fn + +Ts0 = build_analytic_fn_at_t(time=0.0) +TsVKT = build_analytic_fn_at_t(time=dt) + +# + +# Create the mesh var +T = uw.discretisation.MeshVariable("T", mesh, 1, degree=Tdegree) + +# This is the velocity field + +v = sympy.Matrix([velocity, 0]) +# - + +# #### Create the advDiff solver + +adv_diff = uw.systems.AdvDiffusionSLCN( + mesh, + u_Field=T, + V_fn=v, +) + + +adv_diff.constitutive_model = uw.constitutive_models.DiffusionModel +adv_diff.constitutive_model.Parameters.diffusivity = 1.0 + +adv_diff.constitutive_model.Parameters.diffusivity.value + +adv_diff.add_dirichlet_bc(tmin, "Left") +adv_diff.add_dirichlet_bc(tmin, "Right") + + +print(adv_diff.estimate_dt()) +steps = 10 + + +with mesh.access(T): + T.data[:,0] = uw.function.evaluate(Ts0, T.coords)[:,0,0] + +step = 0 +model_time = 0.0 + +adv_diff.petsc_options["snes_monitor_short"] = None + + +for step in range(0, steps): + mesh.write_timestep("mesh", meshUpdates=False, meshVars=[T], + outputPath=outputPath, index=step) + adv_diff.solve(timestep=dt/steps, zero_init_guess=False) + model_time += dt/steps + print(f"Timestep: {step}/{steps}, model time {model_time}") + +if uw.mpi.size == 1: + + import pyvista as pv + import underworld3.visualisation as vis + + pvmesh = vis.mesh_to_pv_mesh(mesh) + pvmesh.point_data["V"] = vis.vector_fn_to_pv_points(pvmesh, sympy.Matrix([velocity, 0]).T) + pvmesh.point_data["T"] = vis.scalar_fn_to_pv_points(pvmesh, T.sym) + pvmesh.point_data["Ta"] = vis.scalar_fn_to_pv_points(pvmesh, Ts0) + pvmesh.point_data["dT"] = pvmesh.point_data["T"] - pvmesh.point_data["Ta"] + + T_points = vis.meshVariable_to_pv_cloud(T) + T_points.point_data["T"] = vis.scalar_fn_to_pv_points(T_points, T.sym) + T_points.point_data["Ta"] = vis.scalar_fn_to_pv_points(T_points, TsVKT) + T_points.point_data["T0"] = vis.scalar_fn_to_pv_points(T_points, Ts0) + T_points.point_data["Tp"] = (T_points.point_data["T0"] + T_points.point_data["Ta"])/2 + T_points.point_data["dT"] = T_points.point_data["T"] - T_points.point_data["Ta"] + + pvmesh2 = vis.mesh_to_pv_mesh(mesh) + pvmesh2.point_data["T"] = vis.scalar_fn_to_pv_points(pvmesh2, T.sym) + pvmesh2.point_data["T0"] = vis.scalar_fn_to_pv_points(pvmesh2, Ts0) + pvmesh2.points[:,1] += 0.3 + + pvmesh3 = vis.mesh_to_pv_mesh(mesh) + pvmesh3.point_data["Ta"] = vis.scalar_fn_to_pv_points(pvmesh2, TsVKT) + pvmesh3.points[:,1] -= 0.3 + + + pl = pv.Plotter() + + pl.add_mesh( + pvmesh2, + cmap="coolwarm", + edge_color="Black", + show_edges=True, + scalars="T0", + use_transparency=False, + show_scalar_bar=False, + opacity=1, + ) + + + pl.add_mesh( + + pvmesh3, + cmap="coolwarm", + edge_color="Black", + show_edges=True, + scalars="Ta", + use_transparency=False, + show_scalar_bar=False, + opacity=1, + ) + + pl.add_points(T_points, color="White", + scalars="dT", cmap="coolwarm", + point_size=5.0, opacity=0.5) + + + pl.add_arrows(pvmesh.points, pvmesh.point_data["V"], mag=0.00003, opacity=0.5, show_scalar_bar=False) + + # pl.add_points(pdata) + + pl.show(cpos="xy",screenshot=outputPath+"output.png") + + + # return vsol + +T_points.point_data["dT"].max() + +adv_diff + +adv_diff.F1 \ No newline at end of file diff --git a/src/underworld3/systems/test_tem/Ex_AdvectionDiffusion_1dBlock_supg_dcterm.py b/src/underworld3/systems/test_tem/Ex_AdvectionDiffusion_1dBlock_supg_dcterm.py new file mode 100644 index 000000000..cbe59d4fd --- /dev/null +++ b/src/underworld3/systems/test_tem/Ex_AdvectionDiffusion_1dBlock_supg_dcterm.py @@ -0,0 +1,225 @@ +# --- +# jupyter: +# jupytext: +# text_representation: +# extension: .py +# format_name: light +# format_version: '1.5' +# jupytext_version: 1.16.1 +# kernelspec: +# display_name: Python 3 (ipykernel) +# language: python +# name: python3 +# --- + +# # Advection-diffusion (1d / cross mesh) +# +# - Using the adv_diff solver. +# - Advection of the rectangular pulse vertically as it also diffuses. The velocity is 0.05 and has a diffusivity value of 1, 0.1 or 0.01 +# - Benchmark comparison between 1D analytical solution and 2D UW numerical model. +# +# ![](Figures/AdvectionTestFigure.png) +# +# *Figure: typical results from this test. Quad mesh v. unstructured triangles with equivalent +# resolution. $\kappa=1$, $\mathbf{v}=(1000,0)$, $t_0 = 0.0001$, $\delta t = 0.0003$. The error looks significantly larger with triangles but you can see that it is dominated by a relatively small* phase error *where the speed of propagation is slightly different from the analytic case.* +# +# +# ## How to test advection or diffusion only +# - Set velocity to 0 to test diffusion only. +# - Set diffusivity (k) to 0 to test advection only. +# +# +# ## Analytic solution +# +# $$ +# T(x,t) = +# \frac{\operatorname{erf}{\left(\frac{- \mathrm{x} + v \left(t + {t_0}\right) + \frac{{\delta}}{2} + {x_0}}{2 \sqrt{\kappa \left(t + {t_0}\right)}} \right)}}{2} + \frac{\operatorname{erf}{\left(\frac{\mathrm{x} - v \left(t + {t_0}\right) + \frac{{\delta}}{2} - {x_0}}{2 \sqrt{\kappa \left(t + {t_0}\right)}} \right)}}{2} +# $$ +# +# Where $x,y$ describe the coordinate frame, $v$ is the horizontal velocity that advects the temperature, $\delta$ is the width of the temperature anomaly, $x_0$ is the initial midpoint of the temperature anomaly. $\kappa$ is the thermal diffusivity, $t_0$ is the time at which we turn on the horizontal velocity. +# +# Note: this solution is derived from the diffusion of a step which is applied to the leading and trailing edges of the block. The solution is valid while the diffusion fronts from each interface remain independent of each other. (This is ill-defined from the problem, but the most obvious test is to look a the time that the block temperature drops below 1 to the tolerance of the solver). +# + +import nest_asyncio +nest_asyncio.apply() + +import underworld3 as uw +import numpy as np +import sympy +import math +import os + +from scipy import special + +if uw.mpi.size == 1: + import matplotlib.pyplot as plt + +import sys + +init_t = 0.0001 +dt = 0.0006 +velocity = 1000. +centre = 0.1 +width = 0.2 + + +tmin = 0. # temp min +tmax = 1.0 # temp max + +res = uw.options.getReal("model_resolution", default=16) +kappa = uw.options.getInt("kappa", default=1) +Tdegree = uw.options.getInt("Tdeg", default=3) +Vdegree = uw.options.getInt("Vdeg", default=2) +simplex = uw.options.getBool("simplex", default=True) + +outputPath = f'./output/1dblock_adv_diff_supg_dcterm/' + +if uw.mpi.rank == 0: + # checking if the directory + if not os.path.exists(outputPath): + os.makedirs(outputPath) + + +xmin, xmax = 0, 1 +ymin, ymax = 0, 0.2 + +## Quads +if simplex == True: + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(xmin, ymin), maxCoords=(xmax, ymax), cellSize=(ymax-ymin)/res, regular=False, qdegree=max(Tdegree, Vdegree) ) +else: + mesh = uw.meshing.StructuredQuadBox( + elementRes=(int(res)*5, int(res)), minCoords=(xmin, ymin), maxCoords=(xmax, ymax), qdegree=max(Tdegree, Vdegree), + ) + +x,y = mesh.X + +x0 = sympy.symbols(r"{x_0}") +t0 = sympy.symbols(r"{t_0}") +delta = sympy.symbols(r"{\delta}") +ks = sympy.symbols(r"\kappa") +ts = sympy.symbols("t") +vs = sympy.symbols("v") + +Ts = ( sympy.erf( (x0 + delta/2 - x+(vs*(ts+t0))) / (2*sympy.sqrt(ks*(ts+t0)))) + sympy.erf( (-x0 + delta/2 + x-((ts+t0)*vs)) / (2*sympy.sqrt(ks*(ts+t0)))) ) / 2 + +def build_analytic_fn_at_t(time): + fn = Ts.subs({vs:velocity, ts:time, ks:kappa, delta:width, x0:centre, t0:init_t}) + return fn + +Ts0 = build_analytic_fn_at_t(time=0.0) +TsVKT = build_analytic_fn_at_t(time=dt) + + +T = uw.discretisation.MeshVariable("T", mesh, 1, degree=Tdegree) + +v = sympy.Matrix([velocity, 0]) + + +from underworld3.systems import AdvDiffusionSUPG +adv_diff = AdvDiffusionSUPG( + mesh, u_Field=T, V_fn=v, + diffusivity=0.0, + discontinuity_capturing=True, + dc_coefficient=0.2, + dc_streamwise_weight=0.3, # was: dc_crosswind_only=False (i.e. weight=1.0) +) + +adv_diff.add_dirichlet_bc(tmin, "Left") +adv_diff.add_dirichlet_bc(tmin, "Right") + +print(adv_diff.estimate_dt()) +steps = int(dt // (12*adv_diff.estimate_dt())) + + +with mesh.access(T): + T.data[:,0] = uw.function.evaluate(Ts0, T.coords)[:,0,0] + +step = 0 +model_time = 0.0 + +adv_diff.petsc_options["snes_monitor_short"] = None + + +for step in range(0, steps): + mesh.write_timestep("mesh", meshUpdates=False, meshVars=[T], + outputPath=outputPath, index=step) + adv_diff.solve(timestep=dt/steps, zero_init_guess=False) + model_time += dt/steps + print(f"Timestep: {step}/{steps}, model time {model_time}") + + +if uw.mpi.size == 1: + + import pyvista as pv + import underworld3.visualisation as vis + + pvmesh = vis.mesh_to_pv_mesh(mesh) + pvmesh.point_data["V"] = vis.vector_fn_to_pv_points(pvmesh, sympy.Matrix([velocity, 0]).T) + pvmesh.point_data["T"] = vis.scalar_fn_to_pv_points(pvmesh, T.sym) + pvmesh.point_data["Ta"] = vis.scalar_fn_to_pv_points(pvmesh, Ts0) + pvmesh.point_data["dT"] = pvmesh.point_data["T"] - pvmesh.point_data["Ta"] + + T_points = vis.meshVariable_to_pv_cloud(T) + T_points.point_data["T"] = vis.scalar_fn_to_pv_points(T_points, T.sym) + T_points.point_data["Ta"] = vis.scalar_fn_to_pv_points(T_points, TsVKT) + T_points.point_data["T0"] = vis.scalar_fn_to_pv_points(T_points, Ts0) + T_points.point_data["Tp"] = (T_points.point_data["T0"] + T_points.point_data["Ta"])/2 + T_points.point_data["dT"] = T_points.point_data["T"] - T_points.point_data["Ta"] + + pvmesh2 = vis.mesh_to_pv_mesh(mesh) + pvmesh2.point_data["T"] = vis.scalar_fn_to_pv_points(pvmesh2, T.sym) + pvmesh2.point_data["T0"] = vis.scalar_fn_to_pv_points(pvmesh2, Ts0) + pvmesh2.points[:,1] += 0.3 + + pvmesh3 = vis.mesh_to_pv_mesh(mesh) + pvmesh3.point_data["Ta"] = vis.scalar_fn_to_pv_points(pvmesh2, TsVKT) + pvmesh3.points[:,1] -= 0.3 + + + pl = pv.Plotter() + + pl.add_mesh( + pvmesh2, + cmap="coolwarm", + edge_color="Black", + show_edges=True, + scalars="T0", + use_transparency=False, + show_scalar_bar=False, + opacity=1, + ) + + + pl.add_mesh( + + pvmesh3, + cmap="coolwarm", + edge_color="Black", + show_edges=True, + scalars="Ta", + use_transparency=False, + show_scalar_bar=False, + opacity=1, + ) + + pl.add_points(T_points, color="White", + scalars="dT", cmap="coolwarm", + point_size=5.0, opacity=0.5) + + + pl.add_arrows(pvmesh.points, pvmesh.point_data["V"], mag=0.00003, opacity=0.5, show_scalar_bar=False) + + # pl.add_points(pdata) + + pl.show(cpos="xy",screenshot=outputPath+"output.png") + + + # return vsol + +T_points.point_data["dT"].max() + +adv_diff + +adv_diff.F1 \ No newline at end of file diff --git a/src/underworld3/systems/test_tem/LeVeque_swirling_supg_vs_slcn.py b/src/underworld3/systems/test_tem/LeVeque_swirling_supg_vs_slcn.py new file mode 100644 index 000000000..cef3ff54c --- /dev/null +++ b/src/underworld3/systems/test_tem/LeVeque_swirling_supg_vs_slcn.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python +# coding: utf-8 +""" +LeVeque (1996) swirling deformation-flow benchmark +==================================================== + +Runs the SAME conservative level-set (CLS) advection problem through TWO +independent solvers on the SAME mesh, under the SAME analytic velocity +field, and compares them head-to-head: + + * ``level_set_SUPG.LevelSetSolver`` -- Crank-Nicolson + SUPG + (Brooks & Hughes 1982), a hand-built implicit weak-form solve on + ``uw.systems.SNES_Scalar`` (see ``SUPGAdvection``). + * ``level_set_SLCN.LevelSetSolver`` -- UW3's canned + ``AdvDiffusionSLCN`` (i.e. ``SNES_AdvectionDiffusion`` + + ``SemiLagrangian``), the "old"/built-in solver. + +Benchmark +--------- +LeVeque, R.J. (1996), "High-resolution conservative algorithms for +advection in incompressible flow," SIAM J. Numer. Anal. 33(2):627-665, +introduced the "swirling deformation flow" velocity field derived from +the stream function + + psi(x,y,t) = (1/pi) sin^2(pi*x) sin^2(pi*y) cos(pi*t/T), + +giving + + u = -dpsi/dy = -sin^2(pi*x) sin(2*pi*y) cos(pi*t/T) + v = dpsi/dx = sin^2(pi*y) sin(2*pi*x) cos(pi*t/T). + +This is the SAME formula used in the earlier ``SingleVortex_*`` scripts +(it is the standard "single vortex" test of Bell, Colella & Glaz (1989) +/ Enright et al. (2002), who use exactly this LeVeque stream function +with T=8 -- the two names refer to the same benchmark in the level-set +literature). The cos(pi*t/T) modulation makes the flow time-reversing: +the swirl runs "forward" for t in [0, T/2), stretching/spiralling the +interface into thin filaments, then EXACTLY reverses, so at t=T the +interface should return to its initial shape and position. That +round-trip is what makes this such a discriminating test -- any +irreversible numerical diffusion (interpolation smoothing in a +semi-Lagrangian trace-back, or over-diffusive stabilisation) shows up +directly as a FAILURE to recover the sharp initial shape, not just as a +transient blur that self-heals. + +Diagnostics recorded for each solver, at every save interval: + + * ``interface_volume`` -- ∫phi dΩ (mass-conservation drift). + * shape (L2) error -- sqrt(∫(phi - phi_0)^2 dΩ), phi_0 the + FROZEN initial field; large mid-run + (filaments under-resolved / no longer + matching phi_0's position) but should + return close to its t=0 value (~0) at + t=T if the round-trip is well resolved. + * wall-clock time per `solve(dt)` call (advection + reinitialisation + + mass correction together, i.e. the full per-step user-facing cost). + +Output: a comparison plot (volume drift, shape error, cumulative +wall-clock, all vs model time) plus periodic XDMF/HDF5 checkpoints for +each solver in separate folders for Paraview inspection, and a short +printed summary table at t=T. + +Usage +----- + python LeVeque_swirling_supg_vs_slcn.py [--xres 64] [--T 8.0] [--severity] + +``--severity`` is a shortcut for a shorter reversal period (T=2, the +value LeVeque's own paper favours) which reverses BEFORE the filaments +have thinned as much -- a gentler round-trip, useful as a quick sanity +check before committing to the full T=8 filament-resolution stress test. +""" + +import argparse +import os +import sys +import time +from datetime import datetime + +import numpy as np +import matplotlib.pyplot as plt +import sympy +from mpi4py import MPI + +import underworld3 as uw + +import underworld3.systems.level_set_SLCN as ls_slcn +import underworld3.systems.level_set_SUPG as ls_supg + + +# ============================================================================= +# CLI / problem setup +# ============================================================================= + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument("--xres", type=int, default=64, help="mesh resolution (square)") +parser.add_argument("--T", type=float, default=8.0, + help="reversal period T (LeVeque's own paper uses 2; " + "Enright et al. 2002 use 8 for a much more severe " + "filament-stretching stress test -- default here)") +parser.add_argument("--severity", action="store_true", + help="shortcut for --T 2.0 (gentler round-trip)") +parser.add_argument("--save-dtime", type=float, default=None, + help="model-time interval between diagnostics/checkpoints " + "(default: T/64)") +parser.add_argument("--outdir", type=str, default="op_LeVeque_swirling_supg_vs_slcn/") +args = parser.parse_args() + +xmin, xmax = 0.0, 1.0 +ymin, ymax = 0.0, 1.0 +xres = yres = args.xres + +T_reversal = 2.0 if args.severity else args.T +dt_set = 0.5 / xres # same CFL-based dt convention as SingleVortex_* +save_dtime = args.save_dtime if args.save_dtime is not None else T_reversal / 64.0 +save_every = max(1, int(np.round(save_dtime / dt_set))) +max_steps = int(np.round(save_every * (T_reversal / save_dtime))) + 1 + +outputPath = args.outdir +if uw.mpi.rank == 0: + for sub in ("supg", "slcn"): + p = os.path.join(outputPath, sub) + os.makedirs(p, exist_ok=True) + for f in os.listdir(p): + os.remove(os.path.join(p, f)) + print(f"LeVeque swirling deformation flow: xres={xres}, T={T_reversal}, " + f"dt={dt_set:.5g}, max_steps={max_steps}, save_every={save_every}") + + +# ============================================================================= +# Mesh + shared velocity field (identical for both solvers -> a fair, purely +# solver-attributable comparison -- neither solver ever sees a different u) +# ============================================================================= + +mesh = uw.meshing.StructuredQuadBox( + elementRes=(xres, yres), minCoords=(xmin, ymin), maxCoords=(xmax, ymax)) + +v = uw.discretisation.MeshVariable("V", mesh, mesh.dim, degree=2, continuous=True) +timeField = uw.discretisation.MeshVariable("time", mesh, 1, degree=1) + +x, y = mesh.N.x, mesh.N.y + + +def make_velocity_expr(t_val: float): + """LeVeque (1996) swirling deformation-flow velocity at model time t_val, + from stream function psi = (1/pi) sin^2(pi x) sin^2(pi y) cos(pi t/T).""" + stream = (1 / sympy.pi) * sympy.sin(sympy.pi * x) ** 2 * sympy.sin(sympy.pi * y) ** 2 + u_ = -sympy.diff(stream, y) + v_ = sympy.diff(stream, x) + modulation = sympy.cos(sympy.pi * t_val / T_reversal) + return sympy.Matrix([[u_ * modulation, v_ * modulation]]) + + +def update_velocity(t_val: float): + v_expr = make_velocity_expr(t_val) + with mesh.access(v): + v.data[:, 0] = uw.function.evaluate(v_expr[0, 0], v.coords)[:, 0, 0] + v.data[:, 1] = uw.function.evaluate(v_expr[0, 1], v.coords)[:, 0, 0] + + +# ============================================================================= +# Two independent level-set fields, SAME initial geometry, one per solver +# ============================================================================= + +radius = 0.15 +centre = [0.5, 0.75] +num_points = 91 +angles = np.linspace(0, 2 * np.pi, num_points) +x0 = radius * np.cos(angles) + centre[0] +y0 = radius * np.sin(angles) + centre[1] +interface_coords = np.ascontiguousarray(np.array([x0, y0]).T) +polygon = np.vstack((interface_coords, interface_coords[0, :])) + +psi_supg = uw.discretisation.MeshVariable(r"\psi_{supg}", mesh, 1, degree=2, continuous=True) +psi_slcn = uw.discretisation.MeshVariable(r"\psi_{slcn}", mesh, 1, degree=2, continuous=True) + +eps_supg = ls_supg.interface_thickness(mesh, psi_supg, scale=0.35) +eps_slcn = ls_slcn.interface_thickness(mesh, psi_slcn, scale=0.35) + +ls_supg.initialise_psi(psi_supg, eps_supg, interface_geometry="polygon", + interface_coordinates=polygon) +ls_slcn.initialise_psi(psi_slcn, eps_slcn, interface_geometry="polygon", + interface_coordinates=polygon) + +# Frozen t=0 snapshots for the round-trip shape-error metric. +psi0_supg = uw.discretisation.MeshVariable(r"\psi^0_{supg}", mesh, 1, degree=2, continuous=True) +psi0_slcn = uw.discretisation.MeshVariable(r"\psi^0_{slcn}", mesh, 1, degree=2, continuous=True) +with mesh.access(psi0_supg, psi0_slcn): + psi0_supg.data[:, 0] = psi_supg.data[:, 0] + psi0_slcn.data[:, 0] = psi_slcn.data[:, 0] + +solver_supg = ls_supg.LevelSetSolver( + psi_supg, velocity=v.sym, epsilon=eps_supg, reini_steps=1, reini_frequency=5) +solver_slcn = ls_slcn.LevelSetSolver( + psi_slcn, velocity=v.sym, epsilon=eps_slcn, reini_steps=1, reini_frequency=5) + +initial_area = np.pi * radius ** 2 + + +def shape_error(psi, psi0): + """sqrt(integral((psi - psi0)^2) dOmega) -- 0 for a perfect round-trip.""" + integ = uw.maths.Integral(mesh, (psi.sym[0, 0] - psi0.sym[0, 0]) ** 2) + return float(np.sqrt(max(integ.evaluate(), 0.0))) + + +# ============================================================================= +# Time loop -- both solvers advanced by the SAME dt, under the SAME v, at +# the SAME model times, so any divergence between them is attributable +# purely to the advection scheme. +# ============================================================================= + +history = { + "t": [], + "vol_supg": [], "vol_slcn": [], + "err_supg": [], "err_slcn": [], + "cumtime_supg": [], "cumtime_slcn": [], +} +walltime_supg = 0.0 +walltime_slcn = 0.0 + +step, time_now, dt = 0, 0.0, 0.0 + +while step < max_steps: + if uw.mpi.rank == 0: + msg = (f"Step: {step:5d} Model Time: {time_now:7.4f} dt: {dt:7.4f} " + f"({datetime.now().strftime('%Y-%m-%d %H:%M:%S')})\n") + sys.stdout.write(msg) + sys.stdout.flush() + + update_velocity(time_now) + + if step % save_every == 0: + vol_supg = solver_supg.interface_volume() + vol_slcn = solver_slcn.interface_volume() + err_supg = shape_error(psi_supg, psi0_supg) + err_slcn = shape_error(psi_slcn, psi0_slcn) + + history["t"].append(time_now) + history["vol_supg"].append(vol_supg) + history["vol_slcn"].append(vol_slcn) + history["err_supg"].append(err_supg) + history["err_slcn"].append(err_slcn) + history["cumtime_supg"].append(walltime_supg) + history["cumtime_slcn"].append(walltime_slcn) + + if uw.mpi.rank == 0: + print(f" SUPG: volume={vol_supg:.6f} " + f"(drift={100*(vol_supg-initial_area)/initial_area:+.3f}%) " + f"shape_err={err_supg:.5e} cum_wall={walltime_supg:.2f}s") + print(f" SLCN: volume={vol_slcn:.6f} " + f"(drift={100*(vol_slcn-initial_area)/initial_area:+.3f}%) " + f"shape_err={err_slcn:.5e} cum_wall={walltime_slcn:.2f}s") + + timeField.data[:, 0] = time_now + mesh.write_timestep("mesh", meshUpdates=False, meshVars=[v, psi_supg, timeField], + outputPath=os.path.join(outputPath, "supg"), index=step) + mesh.write_timestep("mesh", meshUpdates=False, meshVars=[v, psi_slcn, timeField], + outputPath=os.path.join(outputPath, "slcn"), index=step) + + dt = dt_set + + t0 = time.perf_counter() + solver_supg.solve(dt=dt) + dt_wall = time.perf_counter() - t0 + walltime_supg += (dt_wall if uw.mpi.comm.size == 1 + else uw.mpi.comm.allreduce(dt_wall, op=MPI.MAX)) + + t0 = time.perf_counter() + solver_slcn.solve(dt=dt) + dt_wall = time.perf_counter() - t0 + walltime_slcn += (dt_wall if uw.mpi.comm.size == 1 + else uw.mpi.comm.allreduce(dt_wall, op=MPI.MAX)) + + step += 1 + time_now += dt + + +# ============================================================================= +# Final round-trip summary (flow has returned to t=0 configuration) +# ============================================================================= + +final_vol_supg = solver_supg.interface_volume() +final_vol_slcn = solver_slcn.interface_volume() +final_err_supg = shape_error(psi_supg, psi0_supg) +final_err_slcn = shape_error(psi_slcn, psi0_slcn) + +if uw.mpi.rank == 0: + print("\n" + "=" * 70) + print(f"LeVeque swirling deformation flow -- round-trip summary at t={time_now:.4f}") + print("=" * 70) + print(f"{'':14s}{'volume drift %':>16s}{'shape L2 error':>18s}{'total wall (s)':>18s}") + print(f"{'SUPG':14s}{100*(final_vol_supg-initial_area)/initial_area:16.4f}" + f"{final_err_supg:18.5e}{walltime_supg:18.2f}") + print(f"{'SLCN (old)':14s}{100*(final_vol_slcn-initial_area)/initial_area:16.4f}" + f"{final_err_slcn:18.5e}{walltime_slcn:18.2f}") + print("=" * 70) + print("Lower shape L2 error at t=T = better round-trip shape recovery " + "(less irreversible numerical diffusion). Lower |volume drift| = " + "better mass conservation. Lower total wall time = faster.") + + +# ============================================================================= +# Comparison plot +# ============================================================================= + +if uw.mpi.rank == 0: + t_arr = np.array(history["t"]) + fig, axes = plt.subplots(1, 3, figsize=(15, 4.2)) + + ax = axes[0] + ax.plot(t_arr, 100 * (np.array(history["vol_supg"]) - initial_area) / initial_area, + label="SUPG", lw=2) + ax.plot(t_arr, 100 * (np.array(history["vol_slcn"]) - initial_area) / initial_area, + label="SLCN (old)", lw=2, ls="--") + ax.axhline(0, color="k", lw=0.5) + ax.set_xlabel("model time") + ax.set_ylabel("volume drift (%)") + ax.set_title("Mass conservation") + ax.legend() + + ax = axes[1] + ax.semilogy(t_arr, np.maximum(history["err_supg"], 1e-16), label="SUPG", lw=2) + ax.semilogy(t_arr, np.maximum(history["err_slcn"], 1e-16), label="SLCN (old)", + lw=2, ls="--") + ax.axvline(T_reversal / 2, color="gray", lw=0.7, ls=":", label="flow reversal (T/2)") + ax.set_xlabel("model time") + ax.set_ylabel(r"shape error $\|\phi-\phi_0\|_2$") + ax.set_title("Round-trip shape recovery\n(should dip back down near t=T)") + ax.legend(fontsize=8) + + ax = axes[2] + ax.plot(t_arr, history["cumtime_supg"], label="SUPG", lw=2) + ax.plot(t_arr, history["cumtime_slcn"], label="SLCN (old)", lw=2, ls="--") + ax.set_xlabel("model time") + ax.set_ylabel("cumulative wall time (s)") + ax.set_title("Performance") + ax.legend() + + fig.suptitle(f"LeVeque (1996) swirling deformation flow -- SUPG vs SLCN " + f"(xres={xres}, T={T_reversal})") + fig.tight_layout() + fig_path = os.path.join(outputPath, "supg_vs_slcn_comparison.png") + fig.savefig(fig_path, dpi=150) + print(f"\nComparison plot written to {fig_path}") From f7d1a216492756281ada84373f9ca2eaefb2dde0 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Wed, 2 Sep 2026 17:29:58 -0700 Subject: [PATCH 03/20] Expose the BDF and Adams-Moulton coefficient symbols on the DDt managers A solver that assembles its own weighted sum of history terms (an Eulerian scheme applying a multistep rule to a spatial operator) needs the constants-routed coefficient expressions, not just their current values. Read-only accessors; no behaviour change. Underworld development team with AI support from Claude Code Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL --- src/underworld3/systems/ddt.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/underworld3/systems/ddt.py b/src/underworld3/systems/ddt.py index e198aad57..a37533cbe 100644 --- a/src/underworld3/systems/ddt.py +++ b/src/underworld3/systems/ddt.py @@ -666,6 +666,29 @@ def bdf_coefficients(self): """Current BDF coefficients [c0, c1, ...] accounting for variable timesteps.""" return _bdf_coefficients(self.effective_order, self._dt, self._dt_history) + @property + def bdf_coefficient_expressions(self): + r"""The BDF coefficient symbols :math:`[c_0, c_1, \dots]` as UWexpressions. + + For a solver that assembles its own weighted sum of history terms + (an Eulerian scheme applying the multistep rule to a spatial + operator, say). The symbols are routed through PETSc's + ``constants[]`` array, so their values follow ``effective_order`` + and the timestep without a recompile; ``bdf_coefficients`` gives + the current values. + """ + return list(self._bdf_coeffs) + + @property + def am_coefficient_expressions(self): + r"""The Adams-Moulton coefficient symbols :math:`[a_0, a_1, \dots]` as UWexpressions. + + :math:`a_0` weights the new state, :math:`a_k` the history slot + ``psi_star[k-1]``. Same constants-routing as + :attr:`bdf_coefficient_expressions`. + """ + return list(self._am_coeffs) + def _history_syms(self): """History terms as sympy expressions for the weighted sums. From e84dea98e0c6d4aaeac3810526ea075c92923ee7 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Wed, 2 Sep 2026 17:29:58 -0700 Subject: [PATCH 04/20] Pack and index auxiliary fields by DM field, not by position in mesh.vars A MeshVariable that is dropped and garbage-collected (the default Model holds the only strong reference; uw.reset_default_model() releases it, and the statistics helpers delete temporaries deliberately) leaves its PETSc field in the DM. Mesh.update_lvec zipped mesh.vars.values() against the field decomposition by position, and the JIT's petsc_a[] offsets were a running count over the live variables, so every later variable was packed into, and read from, the wrong slots. Measured: a P0 cell-size field landing in a P2 slot as garbage, NaN residuals in one run and a subtly wrong answer in the next, depending on when the collector ran. update_lvec now packs by field name and zeroes an orphaned field; the JIT reads component offsets from the DM's own field list and patches each variable from its field_id. Regression test: 2 of its 3 checks fail without the fix. Underworld development team with AI support from Claude Code Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL --- .../discretisation/discretisation_mesh.py | 20 +++- src/underworld3/utilities/_jitextension.py | 34 +++++- ...st_1058_dropped_meshvariable_aux_layout.py | 113 ++++++++++++++++++ 3 files changed, 159 insertions(+), 8 deletions(-) create mode 100644 tests/test_1058_dropped_meshvariable_aux_layout.py diff --git a/src/underworld3/discretisation/discretisation_mesh.py b/src/underworld3/discretisation/discretisation_mesh.py index d3c67611b..c0e65cf82 100644 --- a/src/underworld3/discretisation/discretisation_mesh.py +++ b/src/underworld3/discretisation/discretisation_mesh.py @@ -3896,13 +3896,21 @@ def update_lvec(self, swarm_sync=True): # The field decomposition seems to fail if coarse DMs are present names, isets, dms = self.dm.createFieldDecomposition() - # traverse subdms, taking user generated data in the subdm - # local vec, pushing it into a global sub vec - for var, subiset, subdm in zip(self.vars.values(), isets, dms): - # var.vec lazily creates the PETSc local vector on first access - lvec = var.vec + # Traverse the DM's fields BY NAME. `self.vars` holds its + # variables weakly, so a dropped-and-collected variable leaves + # a field behind in the DM; a positional zip would then pack + # every later variable into the wrong field (measured: the + # cell-size field landing in a P2 slot as garbage, NaN + # residuals in a solver that reads it). An orphaned field is + # zeroed so nothing stale can reach a kernel. + for name, subiset, subdm in zip(names, isets, dms): + var = self.vars.get(name) subvec = a_global.getSubVector(subiset) - subdm.localToGlobal(lvec, subvec, addv=False) + if var is None: + subvec.set(0.0) + else: + # var.vec lazily creates the PETSc local vector on first access + subdm.localToGlobal(var.vec, subvec, addv=False) a_global.restoreSubVector(subiset, subvec) for iset in isets: diff --git a/src/underworld3/utilities/_jitextension.py b/src/underworld3/utilities/_jitextension.py index 01e1a0582..21d190348 100644 --- a/src/underworld3/utilities/_jitextension.py +++ b/src/underworld3/utilities/_jitextension.py @@ -773,6 +773,24 @@ def getext( @timing.routine_timer_decorator +def _aux_component_offsets(mesh): + """Component offset of every field of the mesh DM, keyed by field id. + + Read from the DM itself, not from ``mesh.vars``: a MeshVariable that + was dropped and collected leaves its PETSc field in the DM (a DMPlex + cannot shed a field), and PETSc lays the auxiliary arrays out over + ALL fields in field order. The offsets therefore have to count the + orphaned fields too. + """ + offsets = {} + total = 0 + for field_id in range(mesh.dm.getNumFields()): + fe, _label = mesh.dm.getField(field_id) + offsets[field_id] = total + total += fe.getNumComponents() + return offsets + + def generate_c_source( name, mesh: underworld3.discretisation.Mesh, @@ -822,7 +840,7 @@ def generate_c_source( count_bd_residual_sig, count_bd_jacobian_sig = callbacks.counts # `_ccode` patching - def ccode_patch_fns(varlist, prefix_str): + def ccode_patch_fns(varlist, prefix_str, component_offsets=None): """ This function patches uw functions with the necessary ccode routines for the code printing. @@ -848,11 +866,22 @@ def ccode_patch_fns(varlist, prefix_str): ordered according to their `field_id`. prefix_str: str The string prefix to write. + component_offsets: dict, optional + Component offset of every field in the DM, by ``field_id`` + (see ``_aux_component_offsets``). When given, each variable + is patched from ITS OWN field's offset instead of a running + count over ``varlist``: a field whose Python variable has + been dropped stays in the DM and still occupies its slots, + so a running count would shift every later variable onto + the wrong data. """ u_i = 0 # variable increment u_x_i = 0 # variable gradient increment lambdafunc = lambda self, printer: self._ccodestr for var in varlist: + if component_offsets is not None: + u_i = component_offsets[var.field_id] + u_x_i = u_i * mesh.cdim if var.vtype == VarType.SCALAR: # monkey patch this guy into the function type(var.fn)._ccodestr = f"{prefix_str}[{u_i}]" @@ -898,7 +927,8 @@ def ccode_patch_fns(varlist, prefix_str): # is important, as the secondary call will overwrite # those patched in the first call. - ccode_patch_fns(_stable_sorted(mesh.vars.values()), "petsc_a") + ccode_patch_fns(_stable_sorted(mesh.vars.values()), "petsc_a", + component_offsets=_aux_component_offsets(mesh)) ccode_patch_fns(primary_field_list, "petsc_u") # Also patch `BaseScalar` types. Nothing fancy - patch the overall type, diff --git a/tests/test_1058_dropped_meshvariable_aux_layout.py b/tests/test_1058_dropped_meshvariable_aux_layout.py new file mode 100644 index 000000000..cdc9d3e6e --- /dev/null +++ b/tests/test_1058_dropped_meshvariable_aux_layout.py @@ -0,0 +1,113 @@ +"""A dropped MeshVariable must not corrupt the auxiliary data of later solves. + +`mesh.vars` holds variables weakly, but a DMPlex cannot shed a field: a +variable that is dropped and garbage-collected leaves its PETSc field in +the DM. Two places used to assume the registry and the DM field list line +up by position: + +- `Mesh.update_lvec` zipped `mesh.vars.values()` against the DM's field + decomposition, so every later variable was packed into the wrong field + (the orphan's slot) and its own slot stayed at whatever it held; +- the JIT's `petsc_a[]` offsets were a running count over the live + variables, skipping the orphan's components. + +Measured before the fix: a cell-size (P0) field landing in a P2 slot as +garbage, NaN residuals (`DIVERGED_FUNCTION_NANORINF`) in one run and a +subtly wrong answer in the next, depending on when the collector ran. The +default Model holds the only strong reference to a variable (the mesh +outlives the model it was created under), so `uw.reset_default_model()`, +which the test suite runs between tests, releases every variable a script +no longer names; the variable-statistics +helpers also delete temporaries from the registry on purpose. The orphan is +an ordinary state, not a misuse. + +Run: pixi run python -m pytest tests/test_1058_dropped_meshvariable_aux_layout.py -v +""" +import gc + +import numpy as np +import pytest +import sympy + +import underworld3 as uw + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + + +def _mesh(): + return uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.25, qdegree=3) + + +def _poisson_with_field_coefficient(mesh, tag): + """A Poisson solve whose answer depends on an auxiliary field (the + diffusivity is a MeshVariable), so mis-packed aux data changes it.""" + x, y = mesh.X + kappa = uw.discretisation.MeshVariable(f"kappa_{tag}", mesh, 1, degree=1) + kappa.array[:, 0, 0] = uw.function.evaluate(1.0 + 4.0 * x * y, kappa.coords).reshape(-1) + u = uw.discretisation.MeshVariable(f"u_{tag}", mesh, 1, degree=2) + poisson = uw.systems.Poisson(mesh, u) + poisson.constitutive_model = uw.constitutive_models.DiffusionModel + poisson.constitutive_model.Parameters.diffusivity = kappa.sym[0] + poisson.f = 1.0 + for b in ("Left", "Right", "Top", "Bottom"): + poisson.add_dirichlet_bc(0.0, b) + poisson.solve() + return np.array(u.array), kappa, u + + +def test_dropped_variable_leaves_an_orphaned_field(): + """The premise: dropping a variable does not shrink the DM.""" + mesh = _mesh() + n_fields = mesh.dm.getNumFields() + # The mesh keeps the model it was created under alive; a variable + # registers with the CURRENT default model, so a reset before and + # after creating it is what releases it (the suite's per-test reset). + uw.reset_default_model() + uw.discretisation.MeshVariable("temporary", mesh, 2, degree=2) + uw.reset_default_model() + gc.collect() + assert "temporary" not in mesh.vars + assert mesh.dm.getNumFields() == n_fields + 1 + + +def test_solve_after_a_dropped_variable_matches_a_clean_mesh(): + reference, _k, _u = _poisson_with_field_coefficient(_mesh(), "ref") + + mesh = _mesh() + uw.reset_default_model() + uw.discretisation.MeshVariable("dropped_vector", mesh, 2, degree=2) + uw.discretisation.MeshVariable("dropped_scalar", mesh, 1, degree=1) + uw.reset_default_model() + gc.collect() + assert mesh.dm.getNumFields() > len(mesh.vars) + + answer, _k, _u = _poisson_with_field_coefficient(mesh, "orphan") + assert np.allclose(answer, reference, rtol=0, atol=1e-10) + + +def test_packed_aux_vector_lands_in_the_named_fields(): + mesh = _mesh() + uw.reset_default_model() + uw.discretisation.MeshVariable("dropped", mesh, 2, degree=1) + uw.reset_default_model() + gc.collect() + assert "dropped" not in mesh.vars + x, y = mesh.X + a = uw.discretisation.MeshVariable("a_live", mesh, 1, degree=1) + a.array[:, 0, 0] = uw.function.evaluate(x + 2 * y, a.coords).reshape(-1) + + mesh.update_lvec() + names, isets, _dms = mesh.dm.createFieldDecomposition() + g = mesh.dm.getGlobalVec() + mesh.dm.localToGlobal(mesh.lvec, g) + packed = {} + for name, iset in zip(names, isets): + sub = g.getSubVector(iset) + packed[name] = (sub.min()[1], sub.max()[1]) + g.restoreSubVector(iset, sub) + mesh.dm.restoreGlobalVec(g) + + assert packed["dropped"] == (0.0, 0.0) + lo, hi = packed["a_live"] + assert lo == pytest.approx(0.0) and hi == pytest.approx(3.0) From ccfc23bbb6abdd633e3263c96959ca3c880468a2 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Wed, 2 Sep 2026 17:29:58 -0700 Subject: [PATCH 05/20] Add the RotatingGaussian transport oracle; fix the integral-norm error for scalar variables A Gaussian carried round the origin by rigid rotation while diffusing is exact at every time (rotation commutes with the Laplacian), so a transport scheme's error can be measured directly and the round trip after one revolution is an absolute check. AnalyticSolution.error(norm='integral') added a 1x1 Matrix symbol to a scalar expression and had never been exercised on a scalar variable. Underworld development team with AI support from Claude Code Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL --- src/underworld3/analytic/__init__.py | 4 +- src/underworld3/analytic/_base.py | 8 ++- src/underworld3/analytic/transport.py | 86 +++++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 2 deletions(-) diff --git a/src/underworld3/analytic/__init__.py b/src/underworld3/analytic/__init__.py index 6ccd3eb7d..f1f4c2102 100644 --- a/src/underworld3/analytic/__init__.py +++ b/src/underworld3/analytic/__init__.py @@ -37,7 +37,7 @@ from .inclusion import EllipticalInclusion from .kramer import CylindricalStokes from .richards import GardnerSteady, GardnerTransient -from .transport import AdvectedFront, ErfcDiffusion, Poisson1D, TwoLayerDarcy +from .transport import AdvectedFront, ErfcDiffusion, Poisson1D, RotatingGaussian, TwoLayerDarcy from .velic import ( SolA, SolB, @@ -67,6 +67,7 @@ "GardnerSteady", "GardnerTransient", "Poisson1D", + "RotatingGaussian", "SolA", "SolB", "SolC", @@ -100,6 +101,7 @@ "GardnerSteady": GardnerSteady, "GardnerTransient": GardnerTransient, "Poisson1D": Poisson1D, + "RotatingGaussian": RotatingGaussian, "SolA": SolA, "SolB": SolB, "SolC": SolC, diff --git a/src/underworld3/analytic/_base.py b/src/underworld3/analytic/_base.py index fbec7ec33..9a54e4b08 100644 --- a/src/underworld3/analytic/_base.py +++ b/src/underworld3/analytic/_base.py @@ -483,7 +483,13 @@ def error(self, field, meshvar, norm="l2"): 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) + computed = meshvar.sym + if (isinstance(computed, sympy.MatrixBase) and computed.shape == (1, 1) + and not isinstance(exact, sympy.MatrixBase)): + # A scalar variable's symbol is a 1x1 Matrix; the exact + # scalar is not. Compare like with like. + computed = computed[0] + return float(uw.maths.L2_norm(computed, exact, self.mesh) / magnitude) if norm != "l2": raise ValueError(f"norm must be 'l2' or 'integral'; got {norm!r}") diff --git a/src/underworld3/analytic/transport.py b/src/underworld3/analytic/transport.py index 153044409..dc6872503 100644 --- a/src/underworld3/analytic/transport.py +++ b/src/underworld3/analytic/transport.py @@ -231,6 +231,92 @@ def __init__(self, mesh, kappa=1.0e-3, speed=1.0, x0=0.1, x1=0.3): ) +class RotatingGaussian(_Transport): + r"""A Gaussian carried round the origin by rigid rotation while it diffuses. + + The velocity :math:`\mathbf{u} = \omega(-y, x)` is solenoidal and rigid, so + it commutes with the Laplacian: the exact field is the free-space diffusing + Gaussian with its centre following the rotation, + + .. math:: + \phi(\mathbf{x}, t) = \frac{\sigma^2}{\sigma^2 + 2\kappa t} + \exp\!\left(-\frac{|\mathbf{x} - \mathbf{c}(t)|^2} + {2(\sigma^2 + 2\kappa t)}\right), + \qquad + \mathbf{c}(t) = R\,(\cos(\omega t + \varphi_0),\ \sin(\omega t + \varphi_0)). + + The transport test with a known answer at every time: after one + revolution, :math:`t = 2\pi/\omega`, a pure-advection field must return + to its initial state, so the round-trip error is an absolute measure and + the quarter-turn errors give the growth in between. With + :math:`\kappa = 0` the solution is regular at :math:`t = 0` and a + benchmark may start there. + + The domain is whatever mesh is supplied; the solution is exact on the + plane, so the walls should sit where the field is negligible (a few + :math:`\sigma` from the orbit) and carry :math:`\phi = 0`. + + Parameters + ---------- + mesh : Mesh + A 2D mesh containing the orbit. + sigma : float + Standard deviation of the initial Gaussian. + centre_radius : float + Orbit radius :math:`R`. + omega : float + Angular velocity; the period is :math:`2\pi/\omega`. + diffusivity : float + :math:`\kappa \ge 0`; zero is pure advection. + phase : float + Initial angular position :math:`\varphi_0` of the centre. + """ + + reference = ( + "Rigid rotation of a diffusing Gaussian; classical (e.g. the rotating " + "cone/Gaussian tests of Zalesak 1979 and LeVeque 1996, here in closed form)." + ) + eqn_solution = ( + r"\frac{\sigma^2}{\sigma^2 + 2\kappa t}" + r"\exp\left(-\frac{|\mathbf{x}-\mathbf{c}(t)|^2}{2(\sigma^2+2\kappa t)}\right)" + ) + singular_at_origin = False + + def __init__(self, mesh, sigma=0.12, centre_radius=0.5, omega=1.0, + diffusivity=0.0, phase=0.0): + super().__init__(mesh) + + if float(sigma) <= 0.0: + raise ValueError("sigma must be positive.") + if float(diffusivity) < 0.0: + raise ValueError("diffusivity must not be negative.") + + self.sigma = float(sigma) + self.centre_radius = float(centre_radius) + self.omega = float(omega) + self.diffusivity = float(diffusivity) + self.kappa = float(diffusivity) + self.phase = float(phase) + self.t = sympy.Symbol("t", positive=True) + + x, y = mesh.X + angle = self.omega * self.t + self.phase + cx = self.centre_radius * sympy.cos(angle) + cy = self.centre_radius * sympy.sin(angle) + variance = self.sigma ** 2 + 2 * self.kappa * self.t + profile = (self.sigma ** 2 / variance) * sympy.exp( + -((x - cx) ** 2 + (y - cy) ** 2) / (2 * variance)) + + self.set_scalar_field( + profile, coefficient=self.kappa, source=0, + advection=(-self.omega * y, self.omega * x)) + + @property + def period(self): + r"""Time of one revolution, :math:`2\pi/\omega`.""" + return 2.0 * sympy.pi.evalf() / self.omega + + class TwoLayerDarcy(_Transport): r"""Steady Darcy flow through two layers of different permeability. From 6d8e2b752528aa98d9c3392b641ef5674ecb1f64 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Wed, 2 Sep 2026 17:29:58 -0700 Subject: [PATCH 06/20] Extract the per-element timestep estimate shared by the advection-diffusion solvers The cell-crossing / diffusion-time reduction (isotropic or direction-aware, minimum or percentile) becomes a module-level helper so the Eulerian solver can call it rather than carrying a copy. SLCN behaviour unchanged. Underworld development team with AI support from Claude Code Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL --- src/underworld3/systems/solvers.py | 204 +++++++++++++++-------------- 1 file changed, 105 insertions(+), 99 deletions(-) diff --git a/src/underworld3/systems/solvers.py b/src/underworld3/systems/solvers.py index e8b82d845..2044c8571 100644 --- a/src/underworld3/systems/solvers.py +++ b/src/underworld3/systems/solvers.py @@ -321,6 +321,104 @@ def _centroid_velocities_nd(V_fn, mesh, basis=None, ensure_2d=True): return vel +def _advective_diffusive_dt(constitutive_K, V_fn, mesh, direction_aware=False, + percentile=0.0): + r"""Per-element resolution timestep, reduced to one global value. + + The minimum over cells of the advective crossing time :math:`h/|v|` and + the diffusive time :math:`h^2/\kappa`, nondimensional. Shared by the + semi-Lagrangian and the Eulerian advection-diffusion solvers: for both + it is a *resolution* estimate, not a stability limit. The semi-Lagrangian + scheme is unconditionally stable and the implicit Eulerian scheme is + stable at any cell Courant number; what bounds either one is accuracy + on the feature being transported, which the mesh cannot know. + + Parameters + ---------- + constitutive_K : sympy expression or number + Diffusivity (the constitutive model's unified ``K``). + V_fn : sympy Matrix + Advecting velocity, evaluated at cell centroids. + mesh : Mesh + direction_aware : bool, default False + Use the per-cell extent along the local velocity instead of the + isotropic radius (triangles only; falls back otherwise). + percentile : float, default 0.0 + ``0`` takes the strict global minimum; ``> 0`` takes that global + percentile of the per-element timesteps, so a few sliver cells + cannot collapse the estimate. + + Returns + ------- + (dt, dt_adv, dt_diff) : floats + The estimate and its two components; ``inf`` where a component does + not apply (zero velocity, zero diffusivity). + """ + from mpi4py import MPI + + comm = uw.mpi.comm + + diffusivity_glob = _global_max_diffusivity(constitutive_K, mesh) + vel = _centroid_velocities_nd(V_fn, mesh) + vel_magnitudes = np.linalg.norm(vel, axis=1) + element_radii = mesh._radii + + def _reduce_dt(per_elem): + fin = per_elem[np.isfinite(per_elem)] if len(per_elem) else per_elem + if percentile and percentile > 0: + gathered = comm.allgather(np.ascontiguousarray(fin, dtype=float)) + allv = (np.concatenate([a for a in gathered if a.size]) + if any(a.size for a in gathered) else np.empty(0)) + return float(np.percentile(allv, percentile)) if allv.size else np.inf + loc = float(np.min(fin)) if len(fin) else np.inf + return comm.allreduce(loc, op=MPI.MIN) + + if diffusivity_glob > 0: + dt_diff_per_element = (element_radii ** 2) / diffusivity_glob + else: + dt_diff_per_element = np.array([np.inf]) + + if direction_aware: + from underworld3.meshing.smoothing import _tri_cells + tris = _tri_cells(mesh.dm) + if tris is None: + h_per_element = element_radii + else: + coords = np.asarray(mesh.X.coords) + centroids = coords[tris].mean(axis=1) + vhat = np.where( + vel_magnitudes[:, None] > 0, + vel / np.maximum(vel_magnitudes[:, None], 1.0e-30), + 0.0) + D = coords[tris] - centroids[:, None, :] + # Signed projections of the cell vertices along v-hat: the + # extent material actually traverses through the cell. + s = np.einsum('cvd,cd->cv', D, vhat) + h_per_element = np.maximum(s.max(axis=1) - s.min(axis=1), 0.0) + else: + h_per_element = element_radii + + with np.errstate(divide='ignore', invalid='ignore'): + dt_adv_per_element = np.where( + vel_magnitudes > 0, h_per_element / vel_magnitudes, np.inf) + + dt_diff = _reduce_dt(dt_diff_per_element) + dt_adv = _reduce_dt(dt_adv_per_element) + return min(dt_diff, dt_adv), dt_adv, dt_diff + + +def _dimensionalise_dt(dt_estimate): + """Return a timestep estimate with physical time units when a model with + reference scales is active, otherwise as a plain nondimensional scalar.""" + try: + return uw.dimensionalise(np.squeeze(dt_estimate), {'[time]': 1}) + except Exception: + # Sanctioned fallback: no active scaling model. _as_scalar because + # np.squeeze promotes a Python float to a 0-d array, which is not a + # number any caller expects (see _apply_unit_aware_scaling). + return _as_scalar(np.squeeze(dt_estimate)) + + def _invalidate_solution_cache(u): """Drop the cached data view of a just-solved variable. @@ -4302,111 +4400,19 @@ def estimate_dt(self, direction_aware: bool = False, percentile: float = 0.0): with reference scales is available, otherwise nondimensional. """ - ### required modules - from mpi4py import MPI - - comm = uw.mpi.comm - - ## global max diffusivity (unified .K property: diffusivity for - ## diffusion models) - diffusivity_glob = _global_max_diffusivity( - self.constitutive_model.K, self.mesh) - - ### velocity values at element centroids (nondimensional) - vel = _centroid_velocities_nd(self.V_fn, self.mesh) - - # Get per-element velocity magnitudes - vel_magnitudes = np.linalg.norm(vel, axis=1) - - # Get per-element radii (characteristic element size) - element_radii = self.mesh._radii - - ## estimate dt of adv and diff components using per-element approach - ## dt_adv_i = h_i / |v_i| for advection - ## dt_diff_i = h_i^2 / κ for diffusion (using global κ for now) - - # Reduce per-element dt to one global value. Default (percentile=0) = - # strict global MINIMUM — one cell sets the limit. percentile>0 takes the - # Nth global percentile (50 = median) of the per-element dt instead, so a - # few anisotropic SLIVER cells (velocity ACROSS a thin cell) don't collapse - # dt. SLCN is unconditionally stable, and ``direction_aware`` already - # credits cells stretched ALONG the flow — together they give the - # orientation-aware + sliver-robust timestep. - def _reduce_dt(per_elem): - fin = per_elem[np.isfinite(per_elem)] if len(per_elem) else per_elem - if percentile and percentile > 0: - gathered = comm.allgather(np.ascontiguousarray(fin, dtype=float)) - allv = (np.concatenate([a for a in gathered if a.size]) - if any(a.size for a in gathered) else np.empty(0)) - return float(np.percentile(allv, percentile)) if allv.size else np.inf - loc = float(np.min(fin)) if len(fin) else np.inf - return comm.allreduce(loc, op=MPI.MIN) - - # Per-element diffusive timestep (all elements use same diffusivity) - if diffusivity_glob > 0: - dt_diff_per_element = (element_radii ** 2) / diffusivity_glob - else: - dt_diff_per_element = np.array([np.inf]) - - # Per-element advective timestep — either isotropic - # (mesh._radii / |v|) or direction-aware (v-aligned cell - # extent / |v|). - if direction_aware: - # Per-cell vertex indices (triangle / tet). - from underworld3.meshing.smoothing import _tri_cells - tris = _tri_cells(self.mesh.dm) - if tris is None: - # Fall back to isotropic for non-triangle meshes. - h_per_element = element_radii - else: - coords = np.asarray(self.mesh.X.coords) - centroids = coords[tris].mean(axis=1) - # v-hat per cell (use centroid v we already have) - vhat = np.where( - vel_magnitudes[:, None] > 0, - vel / np.maximum(vel_magnitudes[:, None], - 1.0e-30), - 0.0) - D = coords[tris] - centroids[:, None, :] - # Signed projections along v̂ per cell vertex - s = np.einsum('cvd,cd->cv', D, vhat) - h_per_element = s.max(axis=1) - s.min(axis=1) - # Sanity-floor — for zero-velocity cells s=0 - # ⇒ h_eff=0 ⇒ dt_adv=inf via the where below - h_per_element = np.maximum( - h_per_element, 0.0) - else: - h_per_element = element_radii - - with np.errstate(divide='ignore', invalid='ignore'): - dt_adv_per_element = np.where( - vel_magnitudes > 0, - h_per_element / vel_magnitudes, - np.inf - ) - # Global reduction — strict min (percentile=0) or Nth percentile (median). - min_dt_diff_glob = _reduce_dt(dt_diff_per_element) - min_dt_adv_glob = _reduce_dt(dt_adv_per_element) + dt_estimate, dt_adv, dt_diff = _advective_diffusive_dt( + self.constitutive_model.K, self.V_fn, self.mesh, + direction_aware=direction_aware, percentile=percentile) # Store for user inspection - self.dt_adv = min_dt_adv_glob if not np.isinf(min_dt_adv_glob) else 0.0 - self.dt_diff = min_dt_diff_glob if not np.isinf(min_dt_diff_glob) else 0.0 + self.dt_adv = dt_adv if not np.isinf(dt_adv) else 0.0 + self.dt_diff = dt_diff if not np.isinf(dt_diff) else 0.0 - # Take overall minimum (respecting infinity for zero velocity/diffusivity cases) - dt_estimate = min(min_dt_diff_glob, min_dt_adv_glob) - - # If both are infinite (no velocity and no diffusivity), return infinity + # Both infinite (no velocity and no diffusivity): nothing to bound if np.isinf(dt_estimate): return np.inf - # Dimensionalise the result to physical time - try: - return uw.dimensionalise(np.squeeze(dt_estimate), {'[time]': 1}) - except Exception: - # Fallback: return plain nondimensional number. _as_scalar because - # np.squeeze promotes a Python float to a 0-d array, which is not - # a number any caller expects (see _apply_unit_aware_scaling). - return _as_scalar(np.squeeze(dt_estimate)) + return _dimensionalise_dt(dt_estimate) @timing.routine_timer_decorator def solve( From 9c8a125b5e24a55a3c299ada0b2fef765c716c27 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Wed, 2 Sep 2026 17:29:58 -0700 Subject: [PATCH 07/20] Skip the mesh-owned multigrid pickup for a solver that owns its preconditioner A solver with no managed option block (_pc_option_prefix is None) sets its own PC; installing the adapt child's PCMG hierarchy on it segfaulted inside PETSc (additive-Schwarz PC, PCMG calls). The gate now treats that state as the explicit choice it is, alongside preconditioner='gamg' and the user override latch. Underworld development team with AI support from Claude Code Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL --- src/underworld3/utilities/custom_mg.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/underworld3/utilities/custom_mg.py b/src/underworld3/utilities/custom_mg.py index e463e31c8..4c57b1ec0 100644 --- a/src/underworld3/utilities/custom_mg.py +++ b/src/underworld3/utilities/custom_mg.py @@ -1783,8 +1783,13 @@ def build_transfers(solver, field_id=None): # `return` here is what turned the gate into a TypeError at the call site # when this hunk migrated from auto_inject_custom_mg (which returns nothing) # during the #488 x #471 merge. + # A solver with no managed option block (`_pc_option_prefix is None`) + # owns its PC outright, so the pickup would install a PCMG hierarchy + # on a PC of another type (measured: SEGV in _configure_pcmg with an + # additive-Schwarz PC on an adapt child). if (getattr(solver, "_preconditioner", "auto") == "gamg" - or getattr(solver, "_pc_user_override", False)): + or getattr(solver, "_pc_user_override", False) + or getattr(solver, "_pc_option_prefix", "") is None): return None, None level_tail = list(coarse) builder = getattr(solver.mesh, "_custom_mg_builder", "barycentric") From b68653069fda5acbd909df3be4676878febd155a Mon Sep 17 00:00:00 2001 From: lmoresi Date: Wed, 2 Sep 2026 17:29:58 -0700 Subject: [PATCH 08/20] Eulerian advection-diffusion with SUPG: BDF and Adams-Moulton orders from the symbolic history uw.systems.AdvDiffusionSUPG(mesh, T, V_fn, order=N, integrator='bdf'|'am') assembles the implicit weak form from the Eulerian DDt history: the BDF stencil or the Adams-Moulton weights on the advective and diffusive terms at every stored time level, plus the SUPG flux tau R u with the strong residual of the same scheme. Timestep, multistep coefficients and the tau weights are runtime constants of the compiled kernels, so a change of dt costs nothing (the issue #657 prototype recompiled on every change). Diffusivity comes from the constitutive model like every scalar solver. Measured on the rotating Gaussian: stable at any cell Courant number, error set by u dt against the feature width (dt^2 for the second-order schemes), unchanged to three digits by a band refined to h/9 at local Courant 13; Crank-Nicolson reproduces the prototype's numbers to four digits. Tests: API and no-recompile contract, temporal convergence (slopes 0.8/0.9 for BDF1, 1.9 for BDF2), band invariance, round trip, np=2 = serial. Underworld development team with AI support from Claude Code Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL --- .../Ex_AdvectionDiffusionSUPG_RotationTest.py | 171 ++++++ src/underworld3/systems/__init__.py | 3 + .../systems/advection_diffusion_eulerian.py | 489 ++++++++++++++++++ .../test_1077_advdiff_supg_parallel.py | 49 ++ tests/test_1055_advdiff_supg_api.py | 165 ++++++ ...est_1100_advdiff_supg_rotating_gaussian.py | 121 +++++ 6 files changed, 998 insertions(+) create mode 100644 docs/examples/convection/advanced/Ex_AdvectionDiffusionSUPG_RotationTest.py create mode 100644 src/underworld3/systems/advection_diffusion_eulerian.py create mode 100644 tests/parallel/test_1077_advdiff_supg_parallel.py create mode 100644 tests/test_1055_advdiff_supg_api.py create mode 100644 tests/test_1100_advdiff_supg_rotating_gaussian.py diff --git a/docs/examples/convection/advanced/Ex_AdvectionDiffusionSUPG_RotationTest.py b/docs/examples/convection/advanced/Ex_AdvectionDiffusionSUPG_RotationTest.py new file mode 100644 index 000000000..d5a0db03e --- /dev/null +++ b/docs/examples/convection/advanced/Ex_AdvectionDiffusionSUPG_RotationTest.py @@ -0,0 +1,171 @@ +# --- +# jupyter: +# jupytext: +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% [markdown] +""" +# Eulerian SUPG Advection-Diffusion Rotation Test + +**PHYSICS:** convection +**DIFFICULTY:** advanced + +## Description + +A Gaussian anomaly carried round the origin by rigid rotation, solved with +the fully implicit Eulerian solver `uw.systems.AdvDiffusionSUPG`. The exact +solution is known at every time (`uw.analytic.RotatingGaussian`), so the +error is measured directly rather than inferred from a picture. + +The scheme is stable at any cell Courant number; what limits the timestep +is how far the anomaly moves per step relative to its own width. Try +`-uw_courant 4` to see the accuracy fall off as `dt**2` while the solve +stays perfectly stable, and `-uw_order 2` to see the second-order scheme. + +## Key Concepts + +- **Implicit Eulerian transport**: no trace-back, no departure points; the + timestep is a runtime constant of the compiled kernels. +- **SUPG stabilisation**: the streamline-upwind test-function perturbation + written as a flux, so PETSc needs no modified test space. +- **Multistep order**: `order=1, 2, 3` with `integrator="bdf"` or `"am"`. + +## Parameters + +- `uw_res`: cells across the box +- `uw_courant`: timestep as a multiple of the cell-crossing time +- `uw_order`, `uw_integrator`, `uw_theta`: the time scheme +- `uw_diffusivity`: thermal diffusivity (0 is pure advection) +""" + +# %% +import numpy as np +import sympy +import underworld3 as uw + +# %% [markdown] +""" +## Configurable Parameters + +Override from the command line: + +```bash +python Ex_AdvectionDiffusionSUPG_RotationTest.py -uw_courant 4 -uw_order 2 +``` +""" + +# %% +params = uw.Params( + uw_res=32, + uw_courant=1.0, + uw_order=2, + uw_integrator="bdf", + uw_theta=1.0, + uw_diffusivity=0.0, + uw_sigma=0.12, +) + +# %% [markdown] +""" +## Mesh, exact solution and the transported field +""" + +# %% +mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(-1.0, -1.0), maxCoords=(1.0, 1.0), cellSize=2.0 / params.uw_res, qdegree=3) +x, y = mesh.X + +exact = uw.analytic.RotatingGaussian( + mesh, sigma=params.uw_sigma, centre_radius=0.5, omega=1.0, + diffusivity=params.uw_diffusivity) + +T = uw.discretisation.MeshVariable("T", mesh, 1, degree=2) +T.array[:, 0, 0] = uw.function.evaluate(exact.at(0.0), T.coords).reshape(-1) + +# Rigid rotation about the origin, one revolution in 2 pi +velocity = sympy.Matrix([[-y, x]]) + +# %% [markdown] +""" +## The solver + +Diffusivity is set on the constitutive model, as for every scalar solver. The +walls carry T = 0, which is exact to rounding a few sigma from the orbit. +""" + +# %% +adv_diff = uw.systems.AdvDiffusionSUPG( + mesh, T, velocity, order=params.uw_order, + integrator=params.uw_integrator, theta=params.uw_theta) +adv_diff.constitutive_model.Parameters.diffusivity = params.uw_diffusivity +for boundary in ("Left", "Right", "Top", "Bottom"): + adv_diff.add_dirichlet_bc(0.0, boundary) + +# %% [markdown] +""" +## Time loop + +`estimate_dt` returns the cell-crossing time. It is a resolution guide, not a +stability limit, so the timestep is a chosen multiple of it. For a multistep +scheme the exact history is planted so the first step already runs at full +order. +""" + +# %% +period = float(exact.period) +dt_cell = float(adv_diff.estimate_dt()) +n_steps = int(np.ceil(period / (params.uw_courant * dt_cell))) +dt = period / n_steps + +if params.uw_order > 1: + history = [uw.function.evaluate(exact.at(-k * dt), T.coords).reshape(-1, 1, 1) + for k in range(params.uw_order)] + adv_diff.DuDt.set_initial_history(history, dt=dt) + +t = 0.0 +for step in range(n_steps): + adv_diff.solve(timestep=dt) + t += dt + if step % max(1, n_steps // 4) == 0 or step == n_steps - 1: + err = exact.error(exact.at(t), T, norm="integral") + uw.pprint(f"step {step:4d} t = {t:6.3f} relative L2 error = {err:.3e}") + +# %% [markdown] +""" +## Result + +After one revolution the field should match its initial state. At a Courant +number of one half the round-trip error is below one per cent on this mesh; +it grows as `dt**2` from there. +""" + +# %% +round_trip = exact.error(exact.at(t), T, norm="integral") +uw.pprint(f"round-trip relative L2 error: {round_trip:.3e} " + f"(min {float(T.array.min()):.3f}, max {float(T.array.max()):.3f})") + +# %% +if uw.mpi.size == 1: + import pyvista as pv + import underworld3.visualisation as vis + + pvmesh = vis.mesh_to_pv_mesh(mesh) + pvmesh.point_data["T"] = vis.scalar_fn_to_pv_points(pvmesh, T.sym) + pvmesh.point_data["T_exact"] = vis.scalar_fn_to_pv_points(pvmesh, exact.at(t)) + pvmesh.point_data["error"] = pvmesh.point_data["T"] - pvmesh.point_data["T_exact"] + + pl = pv.Plotter(window_size=(900, 450), shape=(1, 2)) + pl.subplot(0, 0) + pl.add_mesh(pvmesh, scalars="T", cmap="RdBu_r", clim=(0, 1), show_edges=False) + pl.subplot(0, 1) + pl.add_mesh(pvmesh, scalars="error", cmap="RdBu_r", show_edges=False) + pl.show(cpos="xy") diff --git a/src/underworld3/systems/__init__.py b/src/underworld3/systems/__init__.py index ea6823ab5..ed7ee58fc 100644 --- a/src/underworld3/systems/__init__.py +++ b/src/underworld3/systems/__init__.py @@ -19,6 +19,8 @@ L2 projection of fields onto mesh variables. AdvDiffusion : class Advection-diffusion with semi-Lagrangian transport. +AdvDiffusionSUPG : class + Advection-diffusion, implicit Eulerian with SUPG stabilisation. NavierStokes : class Navier-Stokes equations with inertia. Diffusion : class @@ -64,6 +66,7 @@ # These are now implemented the same way using the ddt module from .solvers import SNES_AdvectionDiffusion as AdvDiffusionSLCN from .solvers import SNES_AdvectionDiffusion as AdvDiffusion +from .advection_diffusion_eulerian import SNES_AdvectionDiffusion_SUPG as AdvDiffusionSUPG # import diffusion-only solver from .solvers import SNES_Diffusion as Diffusion diff --git a/src/underworld3/systems/advection_diffusion_eulerian.py b/src/underworld3/systems/advection_diffusion_eulerian.py new file mode 100644 index 000000000..2dc77ab2f --- /dev/null +++ b/src/underworld3/systems/advection_diffusion_eulerian.py @@ -0,0 +1,489 @@ +r"""Fully implicit Eulerian advection-diffusion with SUPG stabilisation. + +The scalar transport equation + +.. math:: + \frac{\partial \phi}{\partial t} + \mathbf{u}\cdot\nabla\phi + - \nabla\cdot(\kappa\nabla\phi) = f + +discretised on the mesh with a linear multistep rule in time and a +streamline-upwind Petrov-Galerkin (SUPG) term in space. Every time level +is a mesh variable held by an :class:`~underworld3.systems.ddt.Eulerian` +history manager, so the scheme's order is a construction argument and the +timestep and multistep coefficients are runtime constants of the compiled +kernels: neither changes the generated code. + +The companion of :class:`~underworld3.systems.solvers.SNES_AdvectionDiffusion` +(semi-Lagrangian). The Eulerian scheme is stable at any cell Courant number +and its accuracy is set by how far the transported feature moves in one +step; the semi-Lagrangian scheme's accuracy is set by how far a characteristic +turns in one step. See ``docs/developer/design/eulerian-supg-transport.md``. +""" + +import numpy as np +import sympy +from typing import Optional + +import underworld3 as uw +import underworld3.timing as timing +from underworld3.systems import SNES_Scalar +from underworld3.utilities._api_tools import Template +from underworld3.function import expression as public_expression +from underworld3.systems.ddt import Eulerian as Eulerian_DDt +from underworld3.systems.solvers import ( + _advective_diffusive_dt, + _dimensionalise_dt, + _invalidate_solution_cache, + _nondimensionalise_timestep, +) + + +def _as_row_vector(V_fn, dim): + """Coerce a velocity expression to a ``(1, dim)`` sympy row Matrix.""" + if isinstance(V_fn, uw.discretisation.MeshVariable): + V_fn = V_fn.sym + if isinstance(V_fn, sympy.MatrixBase): + if V_fn.shape == (1, dim): + return V_fn + if V_fn.shape == (dim, 1): + return V_fn.T + raise ValueError( + f"V_fn has shape {V_fn.shape} but the mesh is {dim}-D; expected a " + f"(1, {dim}) row vector such as `v.sym` of a vector MeshVariable." + ) + raise ValueError( + f"V_fn must be a (1, {dim}) sympy Matrix or a vector MeshVariable, " + f"not {type(V_fn).__name__}." + ) + + +class SNES_AdvectionDiffusion_SUPG(SNES_Scalar): + r"""Eulerian advection-diffusion solver, implicit in time, SUPG in space. + + .. math:: + \frac{\partial \phi}{\partial t} + \mathbf{u}\cdot\nabla\phi + - \nabla\cdot(\kappa\nabla\phi) = f + + Two families of time integration are built from the same stored history + :math:`\phi^{n}, \phi^{n-1}, \dots` (real mesh variables, so their + gradients are available inside the kernels): + + ``integrator="bdf"`` (backward differentiation, order 1-3) + + .. math:: + \frac{1}{\Delta t}\sum_{k=0}^{N} c_k\,\phi^{n+1-k} + + \mathbf{u}\cdot\nabla\phi^{n+1} + - \nabla\cdot(\kappa\nabla\phi^{n+1}) = f + + ``integrator="am"`` (Adams-Moulton, order 1-3; ``theta`` at order 1) + + .. math:: + \frac{\phi^{n+1}-\phi^{n}}{\Delta t} + + \sum_{k=0}^{N} a_k\left[\mathbf{u}\cdot\nabla\phi^{n+1-k} + - \nabla\cdot(\kappa\nabla\phi^{n+1-k})\right] = f + + Order 1 with ``theta=1`` is backward Euler in both families; + ``integrator="am", order=1, theta=0.5`` is Crank-Nicolson. The BDF + coefficients :math:`c_k` and Adams-Moulton weights :math:`a_k` are the + ones the :class:`~underworld3.systems.ddt.Eulerian` manager maintains; + both ramp from first order over the opening steps unless a history is + planted with ``solver.DuDt.set_initial_history``. A BDF3 request falls + back to variable-step BDF2 whenever consecutive timesteps differ by more + than 5%. + + **Weak form.** With the strong residual of the chosen scheme + :math:`R(\phi)` (time derivative and advection; see below) the residual + assembled through PETSc's pointwise interface is + + .. math:: + f_0 = R(\phi), \qquad + \mathbf{f}_1 = \sum_k w_k\,\kappa\nabla\phi^{n+1-k} + + \tau\,R(\phi)\,\mathbf{u}, + + where :math:`w_k` are the weights of the spatial operator (:math:`w_0 = 1` + for BDF, :math:`w_k = a_k` for Adams-Moulton). The SUPG contribution is + the Petrov-Galerkin test-function perturbation + :math:`\tau\,\mathbf{u}\cdot\nabla w` written as a flux against + :math:`\nabla w`, so PETSc needs no modified test space. The strong + residual carries no diffusion term because the pointwise kernels see + first derivatives only; for linear elements that term vanishes + identically, for higher orders it is the usual inconsistency of SUPG + without a Laplacian reconstruction. + + **Stabilisation parameter.** + + .. math:: + \tau = \left[\left(\frac{2 c_0}{\Delta t}\right)^2 + + \left(\frac{2|\mathbf{u}|}{h}\right)^2 + + \left(\frac{4\kappa}{h^2}\right)^2\right]^{-1/2} + + with :math:`h` the local cell size (``mesh.cell_size()``, the + :math:`\mathrm{volume}^{1/d}` equivalent radius) and :math:`c_0` the + leading multistep coefficient. The three weights are runtime constants + (``tau_weights``) and ``supg_weight`` scales the whole term, so a + Galerkin baseline needs no rebuild. + + **What limits the timestep.** Nothing, for stability. The implicit + scheme is stable at any cell Courant number, including on cells refined + for a Stokes problem that the scalar does not need. Accuracy is set by + how far the transported feature moves per step relative to its own + width: the error grows as :math:`(\mathbf{u}\Delta t)^2` for the + second-order schemes, and the transient term of :math:`\tau` cannot + hide that. :meth:`estimate_dt` returns the cell-crossing time as a + resolution guide only. + + Parameters + ---------- + mesh : Mesh + u_Field : MeshVariable + Continuous scalar field :math:`\phi`. + V_fn : MeshVariable or sympy Matrix + Advecting velocity, ``(1, dim)``. + order : int, default 1 + Order of the time integration, 1 to 3. + integrator : {"bdf", "am"}, default "bdf" + theta : float, default 1.0 + Adams-Moulton blend at order 1 only (0.5 is Crank-Nicolson). Must be + 1.0 for BDF and for Adams-Moulton above order 1. + verbose : bool, default False + DuDt : Eulerian, optional + A pre-built history manager (order at least ``order``, no ``V_fn``). + + Notes + ----- + The diffusivity is set through the constitutive model, as for the other + scalar solvers; the solver starts with a + :class:`~underworld3.constitutive_models.DiffusionModel` at + :math:`\kappa = 0` (pure advection):: + + adv = uw.systems.AdvDiffusionSUPG(mesh, T, v.sym, order=2) + adv.constitutive_model.Parameters.diffusivity = 1.0e-3 + adv.add_dirichlet_bc(0.0, "Left") + adv.solve(timestep=dt) + + The linear system is nonsymmetric, so the preconditioner defaults are + GMRES with an additive-Schwarz ILU preconditioner rather than the + algebraic multigrid the symmetric scalar solvers use. + """ + + _INTEGRATORS = ("bdf", "am") + + @timing.routine_timer_decorator + def __init__( + self, + mesh: uw.discretisation.Mesh, + u_Field: uw.discretisation.MeshVariable, + V_fn, + order: int = 1, + integrator: str = "bdf", + theta: float = 1.0, + verbose: bool = False, + DuDt: Optional[Eulerian_DDt] = None, + ): + if not u_Field.continuous: + raise ValueError( + "u_Field must be a continuous MeshVariable: the SUPG weak form " + "is continuous Galerkin." + ) + if integrator not in self._INTEGRATORS: + raise ValueError( + f"integrator must be one of {self._INTEGRATORS}, not {integrator!r}." + ) + order = int(order) + if order not in (1, 2, 3): + raise ValueError(f"order must be 1, 2 or 3, not {order}.") + theta = float(theta) + if theta != 1.0 and not (integrator == "am" and order == 1): + raise ValueError( + "theta applies to integrator='am' at order 1 only " + "(0.5 is Crank-Nicolson); higher orders and BDF take theta=1." + ) + + super().__init__(mesh, u_Field, u_Field.degree, verbose, DuDt=DuDt, DFDt=None) + + self.f = sympy.Matrix.zeros(1, 1) + self._integrator = integrator + self._time_order = order + self._theta = theta + self._V_fn = _as_row_vector(V_fn, mesh.dim) + + tag = self.instance_number + self._delta_t = public_expression( + rf"\Delta t_{{{tag}}}", 1.0, "Eulerian advection-diffusion timestep") + self._last_timestep = None + + # SUPG on/off and the three tau weights are runtime constants: the + # compiled kernels read them from PETSc's constants[] array. + self._supg_weight = public_expression( + rf"w^{{\mathrm{{SUPG}}}}_{{{tag}}}", 1.0, "SUPG term weight (0 = Galerkin)") + self._tau_weights = [ + public_expression(rf"C^{{\tau}}_{{t,{tag}}}", 2.0, "tau transient weight"), + public_expression(rf"C^{{\tau}}_{{u,{tag}}}", 2.0, "tau advective weight"), + public_expression(rf"C^{{\tau}}_{{\kappa,{tag}}}", 4.0, "tau diffusive weight"), + ] + + if DuDt is None: + self.Unknowns.DuDt = Eulerian_DDt( + self.mesh, + u_Field, + vtype=uw.VarType.SCALAR, + degree=u_Field.degree, + continuous=u_Field.continuous, + V_fn=None, + theta=theta, + varsymbol=u_Field.symbol, + verbose=verbose, + bcs=self.essential_bcs, + order=order, + smoothing=0.0, + ) + else: + if DuDt.order < order: + raise ValueError( + f"DuDt supplied is order {DuDt.order} but order {order} was requested." + ) + if getattr(DuDt, "V_fn", None) is not None: + raise ValueError( + "DuDt must be built with V_fn=None: advection is assembled " + "implicitly by this solver, not as an explicit history correction." + ) + self.Unknowns.DuDt = DuDt + + # Diffusivity lives on the constitutive model, as for every scalar + # solver; kappa = 0 until the user sets it. + self.constitutive_model = uw.constitutive_models.DiffusionModel + self.constitutive_model.Parameters.diffusivity = 0.0 + + # Nonsymmetric operator: opt out of the managed GAMG/FMG block and + # use GMRES with an additive-Schwarz ILU preconditioner. RCM + # ordering improves the ILU fill on convection-dominated operators. + self._pc_option_prefix = None + self.petsc_options["ksp_type"] = "gmres" + self.petsc_options["ksp_gmres_restart"] = 200 + self.petsc_options["pc_type"] = "asm" + self.petsc_options["sub_pc_type"] = "ilu" + self.petsc_options["sub_pc_factor_mat_ordering_type"] = "rcm" + self.petsc_options["snes_rtol"] = 1.0e-8 + self.petsc_options["snes_max_it"] = 20 + + # ------------------------------------------------------------------ + # Scheme description + # ------------------------------------------------------------------ + + @property + def integrator(self) -> str: + """``"bdf"`` or ``"am"``.""" + return self._integrator + + @property + def order(self) -> int: + """Requested order of the time integration.""" + return self._time_order + + @property + def theta(self) -> float: + """Adams-Moulton blend at order 1 (1.0 backward Euler, 0.5 Crank-Nicolson).""" + return self._theta + + @property + def delta_t(self): + r"""The timestep :math:`\Delta t` as a UW expression (set by :meth:`solve`).""" + return self._delta_t + + @property + def V_fn(self): + """Advecting velocity, ``(1, dim)``.""" + return self._V_fn + + @V_fn.setter + def V_fn(self, value): + self._V_fn = _as_row_vector(value, self.mesh.dim) + self.is_setup = False + + @property + def f(self): + """Volumetric source term.""" + return self._f + + @f.setter + def f(self, value): + self._f = sympy.Matrix((value,)) + self._needs_function_rewire = True + + @property + def supg_weight(self) -> float: + """Scale of the SUPG term: 1 (default) or 0 for plain Galerkin. No rebuild.""" + return float(self._supg_weight.sym) + + @supg_weight.setter + def supg_weight(self, value): + self._supg_weight.sym = float(value) + + @property + def tau_weights(self): + r"""The weights :math:`(C_t, C_u, C_\kappa)` of the three terms in :math:`\tau`.""" + return tuple(float(w.sym) for w in self._tau_weights) + + @tau_weights.setter + def tau_weights(self, values): + ct, cu, ck = (float(v) for v in values) + self._tau_weights[0].sym = ct + self._tau_weights[1].sym = cu + self._tau_weights[2].sym = ck + + # ------------------------------------------------------------------ + # Residual pieces (raw field symbols only, so the Jacobian sees them) + # ------------------------------------------------------------------ + + def _states(self): + r"""``[phi^{n+1}, phi^{n}, phi^{n-1}, ...]`` as scalar field symbols.""" + return [self.u.sym[0]] + [ps.sym[0] for ps in self.DuDt.psi_star] + + def _spatial_weights(self): + """Weight of the spatial operator at each time level of ``_states``.""" + n = len(self.DuDt.psi_star) + if self._integrator == "bdf": + return [sympy.Integer(1)] + [sympy.Integer(0)] * n + return self.DuDt.am_coefficient_expressions[: n + 1] + + def _time_derivative(self): + if self._integrator == "bdf": + return self.DuDt.bdf()[0] / self._delta_t + phi_new, phi_old = self._states()[:2] + return (phi_new - phi_old) / self._delta_t + + def _advection(self): + dim = self.mesh.dim + u = self._V_fn + total = sympy.Integer(0) + for w, phi in zip(self._spatial_weights(), self._states()): + if w == 0: + continue + grad = self.mesh.vector.gradient(phi) + total = total + w * sum(u[0, i] * grad[0, i] for i in range(dim)) + return total + + def _diffusive_flux(self): + r"""``(1, dim)`` flux :math:`\sum_k w_k\,\nabla\phi^{(k)}\cdot\kappa` from the constitutive tensor.""" + dim = self.mesh.dim + c = self.constitutive_model.c + total = sympy.zeros(1, dim) + for w, phi in zip(self._spatial_weights(), self._states()): + if w == 0: + continue + grad = self.mesh.vector.gradient(phi) + total = total + w * (grad * c) + return total + + def _strong_residual(self): + return self._time_derivative() + self._advection() - self._f[0] + + def _scalar_diffusivity(self): + kappa = self.constitutive_model.Parameters.diffusivity + if isinstance(kappa, sympy.MatrixBase): + raise ValueError( + "The SUPG parameter needs a scalar diffusivity; anisotropic " + "diffusion is not supported by this solver." + ) + return kappa + + def _tau(self): + dim = self.mesh.dim + u = self._V_fn + u_mag2 = sum(u[0, i] ** 2 for i in range(dim)) + h = self.mesh.cell_size() + kappa = self._scalar_diffusivity() + if self._integrator == "bdf": + c0 = self.DuDt.bdf_coefficient_expressions[0] + else: + c0 = sympy.Integer(1) + ct, cu, ck = self._tau_weights + transient = (ct * c0 / self._delta_t) ** 2 + advective = (cu * sympy.sqrt(u_mag2) / h) ** 2 + diffusive = (ck * kappa / h ** 2) ** 2 + return self._supg_weight / sympy.sqrt(transient + advective + diffusive + 1.0e-30) + + F0 = Template( + r"f_0(\phi)", + lambda self: sympy.Matrix([[self._strong_residual()]]), + "Strong residual of the time scheme: time derivative, advection and source.", + ) + F1 = Template( + r"\mathbf{F}_1(\phi)", + lambda self: self._diffusive_flux() + self._tau() * self._strong_residual() * self._V_fn, + "Diffusive flux of the time scheme plus the SUPG flux tau R u.", + ) + + # ------------------------------------------------------------------ + # Timestep and solve + # ------------------------------------------------------------------ + + @timing.routine_timer_decorator + def estimate_dt(self, direction_aware: bool = False, percentile: float = 0.0): + r"""Cell-crossing timestep, as a resolution guide. + + The minimum over cells of :math:`h/|\mathbf{u}|` and + :math:`h^2/\kappa`, exactly as for the semi-Lagrangian solver. It is + not a stability limit for this scheme, and on a mesh refined for + another problem it is far smaller than the timestep the transported + field needs. Choose the timestep from the feature being transported: + :math:`|\mathbf{u}|\Delta t` should be a fraction of its width. + + Parameters + ---------- + direction_aware : bool, default False + Use the per-cell extent along the local velocity. + percentile : float, default 0.0 + Global percentile of the per-cell timesteps instead of the minimum. + """ + dt_estimate, dt_adv, dt_diff = _advective_diffusive_dt( + self.constitutive_model.K, self._V_fn, self.mesh, + direction_aware=direction_aware, percentile=percentile) + self.dt_adv = dt_adv if not np.isinf(dt_adv) else 0.0 + self.dt_diff = dt_diff if not np.isinf(dt_diff) else 0.0 + if np.isinf(dt_estimate): + return np.inf + return _dimensionalise_dt(dt_estimate) + + def solve( + self, + *, + timestep=None, + zero_init_guess: Optional[bool] = None, + verbose: bool = False, + _force_setup: bool = False, + divergence_retries: int = 0, + ): + r"""Advance :math:`\phi` by one step of size ``timestep``. + + ``timestep`` is required and keyword-only. Changing it between calls + updates a runtime constant of the compiled kernels; nothing is + recompiled. + """ + if timestep is None: + raise ValueError( + "solve() requires timestep=
; there is no default timestep." + ) + dt = float(_nondimensionalise_timestep(timestep)) + if dt <= 0.0: + raise ValueError(f"timestep must be positive, not {dt}.") + if dt != self._last_timestep: + self._delta_t.sym = dt + self._last_timestep = dt + + if _force_setup: + self._needs_function_rewire = True + if not self.constitutive_model._solver_is_setup: + self._needs_function_rewire = True + if not self.is_setup: + self._setup_pointwise_functions(verbose) + self._setup_discretisation(verbose) + self._setup_solver(verbose) + + self.DuDt.update_pre_solve(dt, verbose=verbose) + super().solve(zero_init_guess, _force_setup, divergence_retries=divergence_retries) + _invalidate_solution_cache(self.u) + self.DuDt.update_post_solve(dt, verbose=verbose) + + self.is_setup = True + self.constitutive_model._solver_is_setup = True diff --git a/tests/parallel/test_1077_advdiff_supg_parallel.py b/tests/parallel/test_1077_advdiff_supg_parallel.py new file mode 100644 index 000000000..ae9938fd9 --- /dev/null +++ b/tests/parallel/test_1077_advdiff_supg_parallel.py @@ -0,0 +1,49 @@ +"""The Eulerian SUPG solver gives the serial answer on any number of ranks. + +The scheme has no rank-local step: history is a mesh variable, the residual +is assembled by PETSc, the timestep is a runtime constant. So the integral +error against the rotating-Gaussian oracle after a few steps must match a +serial reference to solver tolerance, whatever the partition. + +Run: mpirun -n 2 python -m pytest --with-mpi tests/parallel/test_1077_advdiff_supg_parallel.py +""" +import numpy as np +import pytest +import sympy + +import underworld3 as uw + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a, pytest.mark.mpi] + +# Serial reference, res 16, BDF2, dt 0.05, 8 steps (recorded with this file; +# np=2 reproduced it to 1.4e-12). +SERIAL_ERROR = 0.0301522514 + + +def _run(): + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(-1.0, -1.0), maxCoords=(1.0, 1.0), cellSize=1.0 / 8, + qdegree=3, regular=False) + x, y = mesh.X + sol = uw.analytic.RotatingGaussian(mesh, sigma=0.12, centre_radius=0.5, omega=1.0) + T = uw.discretisation.MeshVariable("T1077", mesh, 1, degree=2) + T.array[:, 0, 0] = uw.function.evaluate(sol.at(0.0), T.coords).reshape(-1) + adv = uw.systems.AdvDiffusionSUPG(mesh, T, sympy.Matrix([[-y, x]]), order=2) + for b in ("Left", "Right", "Top", "Bottom"): + adv.add_dirichlet_bc(0.0, b) + dt = 0.05 + adv.DuDt.set_initial_history( + [uw.function.evaluate(sol.at(-k * dt), T.coords).reshape(-1, 1, 1) for k in range(2)], + dt=dt) + for _ in range(8): + adv.solve(timestep=dt) + return sol.error(sol.at(8 * dt), T, norm="integral") + + +def test_error_is_partition_independent(): + err = _run() + assert np.isfinite(err) and err < 0.05, err + gathered = uw.mpi.comm.allgather(err) + assert max(gathered) - min(gathered) < 1e-12, gathered + if SERIAL_ERROR is not None: + assert abs(err - SERIAL_ERROR) < 1e-8, (err, SERIAL_ERROR) diff --git a/tests/test_1055_advdiff_supg_api.py b/tests/test_1055_advdiff_supg_api.py new file mode 100644 index 000000000..2173fec2e --- /dev/null +++ b/tests/test_1055_advdiff_supg_api.py @@ -0,0 +1,165 @@ +"""API contract of the Eulerian SUPG advection-diffusion solver. + +Structural checks that run in seconds: the export, argument validation, the +scheme assembled from the history manager, and the rule that a change of +timestep is a change of a runtime constant, never a recompile. + +Run: pixi run python -m pytest tests/test_1055_advdiff_supg_api.py -v +""" +import numpy as np +import pytest +import sympy + +import underworld3 as uw + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + + +@pytest.fixture(scope="module") +def mesh(): + return uw.meshing.UnstructuredSimplexBox( + minCoords=(-1.0, -1.0), maxCoords=(1.0, 1.0), cellSize=0.25, qdegree=3) + + +def _solver(mesh, tag, **kwargs): + x, y = mesh.X + T = uw.discretisation.MeshVariable(f"T_{tag}", mesh, 1, degree=2) + T.array[:, 0, 0] = uw.function.evaluate( + sympy.exp(-((x - 0.5) ** 2 + y ** 2) / 0.03), T.coords).reshape(-1) + adv = uw.systems.AdvDiffusionSUPG(mesh, T, sympy.Matrix([[-y, x]]), **kwargs) + for b in ("Left", "Right", "Top", "Bottom"): + adv.add_dirichlet_bc(0.0, b) + return adv, T + + +def test_exported_and_constructs(mesh): + adv, _T = _solver(mesh, "a") + assert type(adv).__name__ == "SNES_AdvectionDiffusion_SUPG" + assert adv.integrator == "bdf" and adv.order == 1 + assert isinstance(adv.DuDt, uw.systems.ddt.Eulerian) + assert adv.DuDt.V_fn is None, "advection is implicit, not a history correction" + + +@pytest.mark.parametrize("tag, kwargs, message", [ + ("v0", dict(order=4), "order must be"), + ("v1", dict(integrator="rk4"), "integrator must be"), + ("v2", dict(integrator="bdf", theta=0.5), "theta applies"), + ("v3", dict(integrator="am", order=2, theta=0.5), "theta applies"), +]) +def test_scheme_arguments_are_validated(mesh, tag, kwargs, message): + with pytest.raises(ValueError, match=message): + _solver(mesh, tag, **kwargs) + + +def test_timestep_is_required(mesh): + adv, _T = _solver(mesh, "b") + with pytest.raises(ValueError, match="requires timestep"): + adv.solve() + + +def test_bdf1_diffusive_flux_is_the_constitutive_flux(mesh): + """At order 1 the assembled diffusive flux is exactly the constitutive + model's own flux of the new state; the history weights are inert.""" + adv, _T = _solver(mesh, "c") + adv.constitutive_model.Parameters.diffusivity = 0.7 + difference = adv._diffusive_flux() - adv.constitutive_model.flux.T + assert all(sympy.simplify(e) == 0 for e in difference) + + +def test_am_order2_uses_all_three_time_levels(mesh): + adv, _T = _solver(mesh, "d", integrator="am", order=2) + weights = adv._spatial_weights() + assert len(weights) == 3 + states = adv._states() + assert len(states) == 3 + # every history state appears (through its derivatives) in the advection operator + names = {str(atom.func) for atom in adv._advection().atoms(sympy.Function)} + for s in states[1:]: + assert any(str(s.func) in n for n in names), (s, names) + + +def test_timestep_change_is_a_constant_update_not_a_recompile(mesh): + adv, _T = _solver(mesh, "e", order=2) + adv.solve(timestep=0.01) + key = adv._current_jit_cache_key + names = [getattr(c, "name", str(c)) for c in adv.constants_manifest] + assert any(r"\Delta t" in n for n in names), names + assert any("BDF" in n for n in names), names + adv.solve(timestep=0.013) + assert adv._current_jit_cache_key == key + assert float(adv.delta_t.sym) == 0.013 + + +def test_timestep_change_reaches_the_kernels(mesh): + """A solver stepped 0.01 then 0.02 gives the same field as a fresh solver + stepped 0.02 from the same state: the constant is really updated.""" + adv1, T1 = _solver(mesh, "f1") + adv1.solve(timestep=0.01) + state = np.array(T1.array) + adv1.solve(timestep=0.02) + + adv2, T2 = _solver(mesh, "f2") + T2.array[...] = state + adv2.DuDt.initialise_history() + adv2.solve(timestep=0.02) + # to the linear-solver tolerance (measured 2e-11 against a 2e-2 control) + assert np.allclose(np.asarray(T1.array), np.asarray(T2.array), rtol=0, atol=1e-8) + + # negative control: a different timestep gives a visibly different field + adv3, T3 = _solver(mesh, "f3") + T3.array[...] = state + adv3.DuDt.initialise_history() + adv3.solve(timestep=0.01) + assert np.abs(np.asarray(T2.array) - np.asarray(T3.array)).max() > 1e-3 + + +def test_order_ramps_from_one_unless_history_is_planted(mesh): + adv, T = _solver(mesh, "g", order=2) + adv.solve(timestep=0.01) + assert adv.DuDt.effective_order == 1 + adv.solve(timestep=0.01) + assert adv.DuDt.effective_order == 2 + + adv2, T2 = _solver(mesh, "h", order=2) + adv2.DuDt.set_initial_history([np.array(T2.array), np.array(T2.array)], dt=0.01) + adv2.solve(timestep=0.01) + assert adv2.DuDt.effective_order == 2 + + +def test_galerkin_baseline_needs_no_rebuild(mesh): + adv, _T = _solver(mesh, "i") + adv.solve(timestep=0.01) + key = adv._current_jit_cache_key + adv.supg_weight = 0.0 + adv.solve(timestep=0.01) + assert adv._current_jit_cache_key == key + assert adv.supg_weight == 0.0 + + +def test_solves_on_an_adapt_child_with_its_own_preconditioner(): + """An adapt child carries a mesh-owned multigrid hierarchy that the + solver base installs opportunistically. This solver owns its (additive + Schwarz) preconditioner, so the pickup must be skipped: installing a + PCMG hierarchy on a non-MG preconditioner segfaulted inside PETSc.""" + base = uw.meshing.UnstructuredSimplexBox( + minCoords=(-1.0, -1.0), maxCoords=(1.0, 1.0), cellSize=0.25, qdegree=3, + refinement=1) + x, y = base.X + + def metric(pts): + h = np.where(np.abs(pts[:, 0]) < 0.1, 0.03, 0.125) + return 1.0 / h ** 2 + + child = base.adapt(metric, max_levels=2) + xc, yc = child.X + T = uw.discretisation.MeshVariable("T_child", child, 1, degree=2) + T.array[:, 0, 0] = uw.function.evaluate( + sympy.exp(-((xc - 0.5) ** 2 + yc ** 2) / 0.03), T.coords).reshape(-1) + adv = uw.systems.AdvDiffusionSUPG(child, T, sympy.Matrix([[-yc, xc]])) + for b in ("Left", "Right", "Top", "Bottom"): + adv.add_dirichlet_bc(0.0, b) + adv.solve(timestep=0.02) + assert adv.snes.getKSP().getPC().getType() == "asm" + assert adv._custom_mg is None + data = np.asarray(T.array[:, 0, 0]) + assert np.isfinite(data).all() and 0.9 < data.max() < 1.01 diff --git a/tests/test_1100_advdiff_supg_rotating_gaussian.py b/tests/test_1100_advdiff_supg_rotating_gaussian.py new file mode 100644 index 000000000..5607d22a6 --- /dev/null +++ b/tests/test_1100_advdiff_supg_rotating_gaussian.py @@ -0,0 +1,121 @@ +"""The Eulerian SUPG solver against the rotating Gaussian. + +Three properties measured on ``uw.analytic.RotatingGaussian`` (rigid rotation, +exact at every time): + +1. temporal order: the error at a quarter turn falls as dt (BDF1) and dt^2 + (BDF2) when the timestep is halved, with the exact history planted so + the multistep scheme runs at full order from the first step; +2. mesh refinement the scalar does not need leaves the answer alone: a band + refined to h/8 across the orbit, at the same timestep, gives the same + error to three digits even though its cells sit at a local Courant + number of several; +3. the round trip: after one revolution the field returns to its initial + state to a few per cent at a Courant number of one half. + +Run: pixi run python -m pytest tests/test_1100_advdiff_supg_rotating_gaussian.py -v +""" +import numpy as np +import pytest +import sympy + +import underworld3 as uw + +pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] + +SIGMA = 0.12 + + +def _box(res, refinement=0): + return uw.meshing.UnstructuredSimplexBox( + minCoords=(-1.0, -1.0), maxCoords=(1.0, 1.0), cellSize=2.0 / res, + qdegree=3, regular=False, refinement=refinement) + + +def _problem(mesh, tag, order, integrator="bdf", theta=1.0, kappa=0.0): + x, y = mesh.X + sol = uw.analytic.RotatingGaussian(mesh, sigma=SIGMA, centre_radius=0.5, + omega=1.0, diffusivity=kappa) + T = uw.discretisation.MeshVariable(f"T_{tag}", mesh, 1, degree=2) + T.array[:, 0, 0] = uw.function.evaluate(sol.at(0.0), T.coords).reshape(-1) + adv = uw.systems.AdvDiffusionSUPG(mesh, T, sympy.Matrix([[-y, x]]), + order=order, integrator=integrator, theta=theta) + adv.constitutive_model.Parameters.diffusivity = kappa + for b in ("Left", "Right", "Top", "Bottom"): + adv.add_dirichlet_bc(0.0, b) + return sol, T, adv + + +def _run(sol, T, adv, dt, t_end, plant=True): + nsteps = int(round(t_end / dt)) + dt = t_end / nsteps + if plant and adv.order > 1: + values = [uw.function.evaluate(sol.at(-k * dt), T.coords).reshape(-1, 1, 1) + for k in range(adv.order)] + adv.DuDt.set_initial_history(values, dt=dt) + for _ in range(nsteps): + adv.solve(timestep=dt) + return sol.error(sol.at(t_end), T, norm="integral") + + +@pytest.mark.parametrize("order, timesteps, expected_slope", [ + (1, (0.02, 0.01, 0.005), 1.0), + (2, (0.04, 0.02, 0.01), 2.0), +]) +def test_temporal_convergence_order(order, timesteps, expected_slope): + """Halving dt divides the quarter-turn error by 2 (BDF1) or 4 (BDF2). + + The timesteps sit where the temporal error dominates the fixed spatial + error but is still in its asymptotic range (backward Euler at + u dt > sigma/2 is already saturated), which is why the slope is checked + with a tolerance. + """ + mesh = _box(32) + t_end = float(sympy.pi) / 2 + errors = [] + for i, dt in enumerate(timesteps): + sol, T, adv = _problem(mesh, f"c{order}{i}", order) + errors.append(_run(sol, T, adv, dt, t_end)) + slopes = np.log2(np.array(errors[:-1]) / np.array(errors[1:])) + print(f"order {order}: errors {errors} slopes {slopes}") + assert slopes.min() > expected_slope - 0.35, (order, errors, slopes) + + +def test_refinement_the_scalar_does_not_need_leaves_the_error_alone(): + """A band at h/8 across the orbit, same dt as the uniform mesh.""" + dt = 0.0433 + t_end = float(sympy.pi) / 2 + + uniform = _box(32) + sol, T, adv = _problem(uniform, "u", 2) + err_uniform = _run(sol, T, adv, dt, t_end) + + base = _box(16, refinement=1) + fault = uw.meshing.Surface("band", base, + np.array([[0.0, -1.0, 0.0], [0.0, 1.0, 0.0]]), symbol="F") + fault.discretize() + h = 1.0 / 16 + + def metric(pts, _f=fault, _hn=h / 8, _hf=h, _core=0.03, _ramp=0.06): + d = _f.unsigned_distance(pts) + hh = np.where(d < _core, _hn, np.minimum(_hn + (_hf - _hn) * (d - _core) / _ramp, _hf)) + return 1.0 / hh ** 2 + + child = base.adapt(metric, max_levels=3) + assert float(np.min(child._radii)) < 0.3 * float(np.min(uniform._radii)) + + sol_c, T_c, adv_c = _problem(child, "b", 2) + err_band = _run(sol_c, T_c, adv_c, dt, t_end) + + # the band cells are at a local Courant number well above one + assert dt / float(adv_c.estimate_dt()) > 4.0 + assert abs(err_band - err_uniform) < 0.15 * err_uniform, (err_uniform, err_band) + + +def test_round_trip_at_moderate_courant(): + mesh = _box(32) + sol, T, adv = _problem(mesh, "r", 2) + err = _run(sol, T, adv, 0.5 * float(adv.estimate_dt()), float(sol.period)) + assert err < 0.03, err + data = np.asarray(T.array[:, 0, 0]) + assert data.min() > -0.02 and data.max() < 1.02, (data.min(), data.max()) From 451efe3fac16bb17caa3270c90f5e83d91d6cbc3 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Wed, 2 Sep 2026 17:34:29 -0700 Subject: [PATCH 09/20] Design note for the Eulerian SUPG solver; BDF2 becomes the default from the integrator study Rotating-Gaussian study at res 32, Courant 0.25 to 8, pure advection and kappa 1e-3: Adams-Moulton above order 1 blows up from Courant 1 (bounded stability region), BDF3 fails from Courant 4, Crank-Nicolson is three to four times more accurate than BDF2 at the same timestep but rings once the feature is under-resolved in time, backward Euler carries 20-40% error at any practical timestep. Cost per step is the same for every scheme. BDF2 is the robust default; the note records the alternatives and when to pick them. Underworld development team with AI support from Claude Code Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL --- .../semi-lagrangian-time-integration.md | 13 ++ .../design/eulerian-supg-transport.md | 199 ++++++++++++++++++ docs/developer/index.md | 1 + .../systems/advection_diffusion_eulerian.py | 16 +- tests/test_1055_advdiff_supg_api.py | 2 +- 5 files changed, 227 insertions(+), 4 deletions(-) create mode 100644 docs/developer/design/eulerian-supg-transport.md diff --git a/docs/advanced/semi-lagrangian-time-integration.md b/docs/advanced/semi-lagrangian-time-integration.md index 23a935db2..7812b7c78 100644 --- a/docs/advanced/semi-lagrangian-time-integration.md +++ b/docs/advanced/semi-lagrangian-time-integration.md @@ -112,6 +112,19 @@ $[\theta,\,1-\theta]$: `theta` is settable after construction: `adv_diff.DFDt.theta = 1.0`. +## The Eulerian alternative + +`uw.systems.AdvDiffusionSUPG` solves the same equation without a trace-back: +all terms are assembled on the mesh, implicit in time, with SUPG +stabilisation. Its `order=` and `integrator="bdf"|"am"` arguments select the +multistep scheme, built from the same stored history as above; `order=1, +integrator="am", theta=0.5` is Crank-Nicolson. The scheme is stable at any +cell Courant number, so cells refined for a Stokes problem never limit the +transport timestep; its accuracy is set by how far the transported feature +moves per step. The semi-Lagrangian scheme's accuracy is instead set by how +far a characteristic turns per step. The measurements behind that split are +in `docs/developer/design/eulerian-supg-transport.md`. + ## Related options - **`monotone_mode`** (`"clamp"` / `"pick"`) bounds the semi-Lagrangian diff --git a/docs/developer/design/eulerian-supg-transport.md b/docs/developer/design/eulerian-supg-transport.md new file mode 100644 index 000000000..268c108a6 --- /dev/null +++ b/docs/developer/design/eulerian-supg-transport.md @@ -0,0 +1,199 @@ +# Eulerian SUPG transport: design and measurements + +**Status**: implemented on `feature/eulerian-supg-transport` (2026-09-02), static mesh. +Supersedes the Crank-Nicolson prototype of issue #657 as the implementation route +while keeping its weak-form idea. + +## Why an Eulerian scheme + +Underworld3 meshes are usually refined for the momentum problem: faults, viscosity +jumps, boundary layers. A transported scalar rarely needs that resolution, so a +scheme whose timestep is bounded by the smallest cell pays for cells it does not +use. The semi-Lagrangian solver (`AdvDiffusionSLCN`) escapes that bound but pays +for departure points, which are expensive per step and irregular in parallel, and +its moving-mesh staging needs a lagged copy of the previous geometry. + +An implicit Eulerian scheme has no stability bound at all. Its cost is a +nonsymmetric solve per step, and its accuracy is bounded by how far the transported +feature moves in one step. The measurements below say when each is the better tool. + +## The scheme + +The equation is + +$$ +\frac{\partial \phi}{\partial t} + \mathbf{u}\cdot\nabla\phi + - \nabla\cdot(\kappa\nabla\phi) = f . +$$ + +Every past time level $\phi^{n}, \phi^{n-1}, \dots$ is a mesh variable held by an +`Eulerian` history manager, so first derivatives of past states are available in +the kernels and two multistep families share one code path: + +| `integrator` | time derivative | spatial operator | +|---|---|---| +| `bdf`, order $N$ | $\frac{1}{\Delta t}\sum_{k=0}^{N} c_k\,\phi^{n+1-k}$ | at $n+1$ only | +| `am`, order $N$ | $\frac{\phi^{n+1}-\phi^{n}}{\Delta t}$ | $\sum_{k=0}^{N} a_k\,S(\phi^{n+1-k})$ | + +with $S(\phi) = \mathbf{u}\cdot\nabla\phi - \nabla\cdot(\kappa\nabla\phi)$ and the +coefficients those the history manager already maintains (`theta` is the +Adams-Moulton weight at order 1; 0.5 is Crank-Nicolson). Both families ramp from +first order over the opening steps unless `solver.DuDt.set_initial_history` plants +the history. The pointwise residual is + +$$ +f_0 = R(\phi), \qquad +\mathbf{f}_1 = \sum_k w_k\,\kappa\nabla\phi^{n+1-k} + \tau\,R(\phi)\,\mathbf{u}, +$$ + +where $R$ is the strong residual of the chosen scheme (time derivative, advection +and source) and $w_k$ the spatial weights of the family. The SUPG term is the +Petrov-Galerkin test-function perturbation $\tau\,\mathbf{u}\cdot\nabla w$ written +as a flux against $\nabla w$, so PETSc needs no modified test space. + +$$ +\tau = \left[\left(\frac{2 c_0}{\Delta t}\right)^2 + + \left(\frac{2|\mathbf{u}|}{h}\right)^2 + + \left(\frac{4\kappa}{h^2}\right)^2\right]^{-1/2}, +\qquad h = \texttt{mesh.cell\_size()} . +$$ + +### Decisions and their reasons + +- **No diffusion in the strong residual.** PETSc's pointwise kernels see first + derivatives only, so $-\nabla\cdot(\kappa\nabla\phi)$ cannot appear in $R$. For + linear elements it vanishes identically; for higher orders this is the usual + inconsistency of SUPG without a Laplacian reconstruction. Diffusion enters as the + Galerkin flux only. +- **Every knob is a runtime constant.** The timestep, the multistep coefficients, + the three weights in $\tau$ and the overall SUPG weight are UW expressions routed + through PETSc's `constants[]` array. A change of timestep costs nothing; the + prototype recompiled its kernels on every change (1.2 s against 0.03 s for a step). +- **Diffusivity on the constitutive model**, as for every scalar solver, starting at + $\kappa = 0$. The prototype carried a float attribute with a warning bridge. +- **Own preconditioner.** The operator is nonsymmetric, so GMRES with an + additive-Schwarz ILU preconditioner replaces the managed GAMG block. The solver + sets `_pc_option_prefix = None`, and the mesh-owned multigrid pickup on adapt + children now respects that (it segfaulted otherwise). +- **Moving meshes, phase 1.** The unknown and its history stay on the default + `REMAP` transfer policy with the material velocity. The remap re-interpolates old + states onto the new nodes, so the Eulerian form is already correct to + interpolation accuracy. The `CARRY` + $\mathbf{u} - \mathbf{u}_\text{mesh}$ form + is phase 2 and must not be mixed with `REMAP`. +- **Not yet:** discontinuity capturing (the prototype's residual omitted the time + derivative and added first-order diffusion everywhere; a correct lagged residual + needs $\phi^{n-1}$), a streamline element length from a mesh-owned metric tensor, + the ALE hook. + +## Measurements + +Rotating Gaussian (`uw.analytic.RotatingGaussian`, $\sigma = 0.12$, orbit radius +0.5), P2 field, unstructured simplex box, one revolution; relative $L_2$ error at +the end. "Courant" is on the cell size. Study scripts and CSVs are in +`~/+Simulations/supg_vs_slcn_657/`. + +### Eulerian against semi-Lagrangian (the #657 prototype, Crank-Nicolson) + +| mesh | Courant | SUPG CN | SLCN | cost per step SUPG : SLCN | +|---|---|---|---|---| +| uniform 32 | 0.5 | 0.6% | 21% | 1 : 6.3 | +| uniform 32 | 2 | 9.8% | 7.7% | 1 : 6.4 | +| uniform 32 | 8 | 66%, min $-0.35$ | 8.8% | 1 : 5.6 | +| uniform 32 | 32 | 113% | 93%, mass $-32$% | 1 : 5.7 | +| uniform 64 | 2 | 2.5% | 2.2% | 1 : 3.6 | +| uniform 64 | 8 | 31% | 2.2% | 1 : 3.6 | +| band $h/9$ at $x = 0$ | 0.5 / 2 | 0.6% / 9.8% | 18% / 6.5% | 1 : 5.6 | + +Three facts follow. + +1. The implicit scheme is stable at any cell Courant number, and cells the scalar + does not need are free: the band refined to $h/9$ sits at local Courant 13 and + changes the error in the third digit only. +2. Its accuracy is set by $\mathbf{u}\Delta t$ against the feature width. The error + scales as $\Delta t^2$ for Crank-Nicolson, which is A-stable but not L-stable + and rings once the feature is under-resolved in time. +3. SLCN's error is flat in $\Delta t$ but accumulates at small Courant (one + interpolation per step), so it is the worse scheme exactly where it is not meant + to run; its limit is the arc a characteristic turns per step, about 10 degrees + for the RK2 trace-back, a property of the flow rather than the mesh. + +The new class reproduces the prototype's Crank-Nicolson numbers to four digits +(0.5993% and 9.777% at Courant 0.5 and 2 on the uniform mesh). + +### BDF against Adams-Moulton + +`time_integrator_study.py`: the same rotating Gaussian, res 32, every scheme +the class offers, at Courant 0.25 to 8; relative $L_2$ error after one +revolution, "X" where the run blew up (with the step). Pure advection first, +then $\kappa = 10^{-3}$ (cell Peclet about 40). + +| scheme | C 0.25 | 0.5 | 1 | 2 | 4 | 8 | +|---|---|---|---|---|---|---| +| BDF1 = backward Euler | 19% | 30% | 44% | 57% | 68% | 77% | +| BDF2 | 0.6% | 2.4% | 9.3% | 28% | 53% | 73% | +| BDF3 | 0.32% | 0.28% | 2.7% | 18% | X | X | +| Crank-Nicolson (`am`, 1, theta 0.5) | 0.27% | 0.6% | 2.5% | 9.8% | 31% | 66% | +| Adams-Moulton 2 (third order) | 0.28% | 0.24% | 0.24% | X@68 | X@41 | X@32 | +| Adams-Moulton 3 (fourth order) | 0.28% | 0.25% | X@155 | X@32 | X@22 | X@19 | + +| scheme, $\kappa = 10^{-3}$ | C 0.25 | 0.5 | 1 | 2 | 4 | 8 | +|---|---|---|---|---|---|---| +| BDF1 = backward Euler | 12% | 20% | 31% | 45% | 58% | 69% | +| BDF2 | 0.27% | 0.71% | 3.3% | 13% | 35% | 59% | +| BDF3 | 0.31% | 0.45% | 0.87% | 4.4% | 51% | X | +| Crank-Nicolson | 0.38% | 0.51% | 0.63% | 2.5% | 13% | 42% | +| Adams-Moulton 2 | 0.42% | 0.71% | 1.3% | X | X | X | +| Adams-Moulton 3 | 0.42% | 0.71% | X | X | X | X | + +Cost per step is the same for every scheme (0.058 to 0.068 s at res 32): the +history terms are extra kernel inputs, not extra solves. BDF1 and backward Euler +agree to every digit, which checks that the two families are assembled +consistently. + +What the table says: + +- **Adams-Moulton above order 1 is unusable for advection.** Its stability region + is bounded and covers only a short segment of the imaginary axis, so on a pure + advection operator it blows up once the Courant number reaches about 1, and + diffusion at this Peclet number does not rescue it. It is kept in the class for + the record and for diffusion-dominated use, with that warning in the docstring. +- **BDF3 is the most accurate scheme below Courant 1** (0.3%, on the spatial + floor) but it is not A-stable either, and it fails from Courant 4. +- **Crank-Nicolson is three to four times more accurate than BDF2 at the same + timestep** across the usable range, because it does not damp; the price is + ringing once the feature is under-resolved in time (minimum $-0.35$ at Courant 8 + against $-0.20$ for BDF2), and no damping of stiff modes at all. +- **BDF2 is the robust choice**: stable at every Courant number, damped, second + order, and the error is still set by $\mathbf{u}\Delta t$ against the feature + width. + +**Default: `order=2, integrator="bdf"`.** Defaults err toward robustness; a user +with a smooth field at Courant 2 or below gets the better answer from +`integrator="am", order=1, theta=0.5` (Crank-Nicolson), and below Courant 1 from +`order=3`. Backward Euler (order 1) is a first-order scheme with 20 to 40% error +at any practical timestep and is not a sensible default for transport. + +### Temporal convergence (tests/test_1100) + +Quarter-turn error on the uniform res-32 mesh with the exact history planted: +BDF1 slopes 0.80 and 0.88 between $\Delta t$ = 0.02, 0.01, 0.005; BDF2 slopes above +1.65 between 0.04, 0.02, 0.01. + +## What the timestep estimate means + +`estimate_dt` returns the cell-crossing time, the same resolution estimate the +semi-Lagrangian solver reports, because that is the only quantity the mesh knows. +It is not a stability limit for either scheme. Choose the Eulerian timestep from +the transported feature: $|\mathbf{u}|\Delta t$ a fraction of its width. For SLCN +the honest limit is the trace-back arc, $\Delta t \lesssim 0.25 / \max|\nabla\mathbf{u}|$, +which is a separate change to that solver. + +## A defect found on the way + +The API test was flaky only after a test that dropped mesh variables. The cause +is general and predates this work: `mesh.vars` holds variables weakly, a +garbage-collected variable leaves its PETSc field in the DM, and both +`Mesh.update_lvec` and the JIT's auxiliary-field offsets assumed the registry and +the DM fields line up by position. Every later variable was then packed into, and +read from, the wrong slots. Fixed in the same branch (pack by field name, offsets +from the DM's field list) with `tests/test_1058_dropped_meshvariable_aux_layout.py`. diff --git a/docs/developer/index.md b/docs/developer/index.md index c32976e1f..26a102965 100644 --- a/docs/developer/index.md +++ b/docs/developer/index.md @@ -165,6 +165,7 @@ design/TURBULENCE_MODEL_DESIGN design/declined-coord-units-proposal design/nonlinear-solver-homotopy-warmstart design/fault-zone-hybrid-architecture +design/eulerian-supg-transport ``` ```{toctree} diff --git a/src/underworld3/systems/advection_diffusion_eulerian.py b/src/underworld3/systems/advection_diffusion_eulerian.py index 2dc77ab2f..25bdc0209 100644 --- a/src/underworld3/systems/advection_diffusion_eulerian.py +++ b/src/underworld3/systems/advection_diffusion_eulerian.py @@ -139,9 +139,19 @@ class SNES_AdvectionDiffusion_SUPG(SNES_Scalar): Continuous scalar field :math:`\phi`. V_fn : MeshVariable or sympy Matrix Advecting velocity, ``(1, dim)``. - order : int, default 1 - Order of the time integration, 1 to 3. + order : int, default 2 + Order of the time integration, 1 to 3. BDF2 is the default because it + is stable at every Courant number and damped. Measured on a rotating + Gaussian (``docs/developer/design/eulerian-supg-transport.md``): + Crank-Nicolson is three to four times more accurate than BDF2 at the + same timestep below Courant 2 but rings once the feature is + under-resolved in time; BDF3 is the most accurate scheme below + Courant 1 and fails from Courant 4; backward Euler (order 1) carries + 20 to 40% error at any practical timestep. integrator : {"bdf", "am"}, default "bdf" + Adams-Moulton above order 1 has a bounded stability region and blows + up on an advection operator from about Courant 1; it is provided for + diffusion-dominated problems and for comparison. theta : float, default 1.0 Adams-Moulton blend at order 1 only (0.5 is Crank-Nicolson). Must be 1.0 for BDF and for Adams-Moulton above order 1. @@ -174,7 +184,7 @@ def __init__( mesh: uw.discretisation.Mesh, u_Field: uw.discretisation.MeshVariable, V_fn, - order: int = 1, + order: int = 2, integrator: str = "bdf", theta: float = 1.0, verbose: bool = False, diff --git a/tests/test_1055_advdiff_supg_api.py b/tests/test_1055_advdiff_supg_api.py index 2173fec2e..bb95e3fd4 100644 --- a/tests/test_1055_advdiff_supg_api.py +++ b/tests/test_1055_advdiff_supg_api.py @@ -35,7 +35,7 @@ def _solver(mesh, tag, **kwargs): def test_exported_and_constructs(mesh): adv, _T = _solver(mesh, "a") assert type(adv).__name__ == "SNES_AdvectionDiffusion_SUPG" - assert adv.integrator == "bdf" and adv.order == 1 + assert adv.integrator == "bdf" and adv.order == 2 assert isinstance(adv.DuDt, uw.systems.ddt.Eulerian) assert adv.DuDt.V_fn is None, "advection is implicit, not a history correction" From f076781dfc6ca83599254ceab769aa0ddd18f46c Mon Sep 17 00:00:00 2001 From: lmoresi Date: Wed, 2 Sep 2026 18:13:03 -0700 Subject: [PATCH 10/20] Integrator study, res 64: BDF3 grows slowly on pure advection at any Courant number BDF2 and Crank-Nicolson track their res-32 errors at the same u dt. BDF3's stability region misses the imaginary axis near the origin, so the low-frequency modes of a finer mesh grow: 31x the exact field after 590 steps at Courant 1. Safe only with diffusion, below Courant 2. Note and docstring updated; the BDF2 default stands. Underworld development team with AI support from Claude Code Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL --- .../design/eulerian-supg-transport.md | 31 ++++++++++++++++--- .../systems/advection_diffusion_eulerian.py | 3 +- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/docs/developer/design/eulerian-supg-transport.md b/docs/developer/design/eulerian-supg-transport.md index 268c108a6..7410e80ab 100644 --- a/docs/developer/design/eulerian-supg-transport.md +++ b/docs/developer/design/eulerian-supg-transport.md @@ -145,7 +145,27 @@ then $\kappa = 10^{-3}$ (cell Peclet about 40). | Adams-Moulton 2 | 0.42% | 0.71% | 1.3% | X | X | X | | Adams-Moulton 3 | 0.42% | 0.71% | X | X | X | X | -Cost per step is the same for every scheme (0.058 to 0.068 s at res 32): the +At res 64 (pure advection, Courant 1 to 8, 590 to 74 steps per revolution): + +| scheme, res 64 | C 1 | 2 | 4 | 8 | +|---|---|---|---|---| +| BDF1 = backward Euler | 30% | 43% | 57% | 68% | +| BDF2 | 2.5% | 9.1% | 27% | 53% | +| BDF3 | 3100% (slow growth) | 1.9% | 17% | 130% | +| Crank-Nicolson | 0.62% | 2.5% | 9.5% | 31% | +| Adams-Moulton 2 | 310% (slow growth) | X@76 | X@49 | X@38 | +| Adams-Moulton 3 | X@116 | X@37 | X@25 | X@22 | + +BDF2 and Crank-Nicolson track their res-32 values at the same $\mathbf{u}\Delta t$ +(the error is set by the timestep, not the mesh). BDF3 is not safe for pure +advection at any Courant number: its stability region misses the imaginary axis +near the origin, so the low-frequency modes a finer mesh carries grow slowly (31 +times the exact field after 590 steps at Courant 1, where the coarser mesh with +half the steps still looked fine); with $\kappa = 10^{-3}$ it behaved. Use it +only with diffusion and below Courant 2. + +Cost per step is the same for every scheme (0.058 to 0.068 s at res 32, 0.32 to +0.36 s at res 64): the history terms are extra kernel inputs, not extra solves. BDF1 and backward Euler agree to every digit, which checks that the two families are assembled consistently. @@ -157,8 +177,9 @@ What the table says: advection operator it blows up once the Courant number reaches about 1, and diffusion at this Peclet number does not rescue it. It is kept in the class for the record and for diffusion-dominated use, with that warning in the docstring. -- **BDF3 is the most accurate scheme below Courant 1** (0.3%, on the spatial - floor) but it is not A-stable either, and it fails from Courant 4. +- **BDF3 is the most accurate scheme below Courant 1 with diffusion present** + (0.3%, on the spatial floor) but it is not A-stable, fails from Courant 4, and + on pure advection grows slowly at any Courant number (the res-64 rows). - **Crank-Nicolson is three to four times more accurate than BDF2 at the same timestep** across the usable range, because it does not damp; the price is ringing once the feature is under-resolved in time (minimum $-0.35$ at Courant 8 @@ -169,8 +190,8 @@ What the table says: **Default: `order=2, integrator="bdf"`.** Defaults err toward robustness; a user with a smooth field at Courant 2 or below gets the better answer from -`integrator="am", order=1, theta=0.5` (Crank-Nicolson), and below Courant 1 from -`order=3`. Backward Euler (order 1) is a first-order scheme with 20 to 40% error +`integrator="am", order=1, theta=0.5` (Crank-Nicolson), and with diffusion below +Courant 1 from `order=3`. Backward Euler (order 1) is a first-order scheme with 20 to 40% error at any practical timestep and is not a sensible default for transport. ### Temporal convergence (tests/test_1100) diff --git a/src/underworld3/systems/advection_diffusion_eulerian.py b/src/underworld3/systems/advection_diffusion_eulerian.py index 25bdc0209..6c8e23f41 100644 --- a/src/underworld3/systems/advection_diffusion_eulerian.py +++ b/src/underworld3/systems/advection_diffusion_eulerian.py @@ -146,7 +146,8 @@ class SNES_AdvectionDiffusion_SUPG(SNES_Scalar): Crank-Nicolson is three to four times more accurate than BDF2 at the same timestep below Courant 2 but rings once the feature is under-resolved in time; BDF3 is the most accurate scheme below - Courant 1 and fails from Courant 4; backward Euler (order 1) carries + Courant 1 when diffusion is present, fails from Courant 4, and on + pure advection grows slowly at any Courant number; backward Euler (order 1) carries 20 to 40% error at any practical timestep. integrator : {"bdf", "am"}, default "bdf" Adams-Moulton above order 1 has a bounded stability region and blows From 2b9941c1f58d7bb1917e0ad09fa729cdf1de6727 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Wed, 2 Sep 2026 21:16:48 -0700 Subject: [PATCH 11/20] AdvDiffusionSUPG takes the semi-Lagrangian solver's interface: a drop-in replacement The constructor, order, theta, f, V_fn, constitutive_model, delta_t, estimate_dt and solve keep the meaning they have for AdvDiffusionSLCN, so a script changes the class name and nothing else. order=1 with theta=0.5 is Crank-Nicolson and the default, as for SLCN; order=2 takes theta=1 (BDF2) unless 0.5 is asked for explicitly, which is refused for the reason the SLCN documentation gives. The trace-back-only arguments (restore_points_func, monotone_mode, old_frame_traceback, DFDt) are accepted and ignored with a warning. integrator is inferred and only needs setting to reach the higher Adams-Moulton rules. delta_t is settable and solve() reuses it; the notebook viewer reports the scheme. User page docs/advanced/eulerian-advection-diffusion.md with the swap table and the when-to-use-which guidance. Underworld development team with AI support from Claude Code Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL --- docs/advanced/eulerian-advection-diffusion.md | 105 ++++++++ docs/advanced/index.md | 1 + .../design/eulerian-supg-transport.md | 16 +- .../Ex_AdvectionDiffusionSUPG_RotationTest.py | 13 +- .../systems/advection_diffusion_eulerian.py | 240 ++++++++++++------ tests/test_1055_advdiff_supg_api.py | 32 ++- 6 files changed, 312 insertions(+), 95 deletions(-) create mode 100644 docs/advanced/eulerian-advection-diffusion.md diff --git a/docs/advanced/eulerian-advection-diffusion.md b/docs/advanced/eulerian-advection-diffusion.md new file mode 100644 index 000000000..9e0951526 --- /dev/null +++ b/docs/advanced/eulerian-advection-diffusion.md @@ -0,0 +1,105 @@ +# Eulerian advection-diffusion (SUPG): a drop-in for SLCN + +`uw.systems.AdvDiffusionSUPG` solves the same scalar transport equation as the +semi-Lagrangian solver `uw.systems.AdvDiffusionSLCN`, + +$$ +\frac{\partial \phi}{\partial t} + \mathbf{u}\cdot\nabla\phi + - \nabla\cdot(\kappa\nabla\phi) = f , +$$ + +but assembles every term on the mesh, implicit in time, with streamline-upwind +(SUPG) stabilisation. There is no trace-back and no departure point. The two +classes share their interface, so switching is one line: + +```python +adv = uw.systems.AdvDiffusionSUPG(mesh, T, v.sym, order=1) # was AdvDiffusionSLCN +adv.constitutive_model = uw.constitutive_models.DiffusionModel +adv.constitutive_model.Parameters.diffusivity = 1.0e-3 +adv.add_dirichlet_bc(1.0, "Bottom") +adv.add_dirichlet_bc(0.0, "Top") + +dt = 0.5 * adv.estimate_dt() +adv.solve(timestep=dt) +``` + +## What carries over + +| SLCN | SUPG | note | +|---|---|---| +| `order=1, theta=0.5` | same | Crank-Nicolson, the default for both | +| `order=1, theta=1.0` | same | backward Euler | +| `order=2, theta=1.0` | same | SL-BDF2 becomes BDF2 | +| `order=2, theta=0.5` | refused | refused for the same reason: a BDF stencil does not pair with a centred flux | +| `f`, `V_fn`, `constitutive_model`, `delta_t` | same | | +| `estimate_dt(direction_aware, percentile)` | same | the cell-crossing time, a resolution guide for both | +| `solve(zero_init_guess, timestep, ...)` | same | | +| `DuDt.set_initial_history(values, dt)` | same | plant an exact history to start at full order | +| `restore_points_func`, `monotone_mode`, `old_frame_traceback`, `DFDt` | ignored, with a warning | they configure the trace-back | + +`order=3` (BDF3) is available; see below for when it is safe. `integrator="am"` +above order 1 reaches the higher Adams-Moulton rules, which are for +diffusion-dominated problems only. + +## When to use which + +Both solvers are free of any stability limit on the timestep, so cells refined +for the Stokes problem never dictate the transport step. They differ in what +bounds their accuracy and in what a step costs. + +**Eulerian SUPG.** The error is set by how far the transported feature moves per +step relative to its own width, as $(\mathbf{u}\Delta t)^2$ for the second-order +schemes. It does not depend on the cell size at all: on a rotating Gaussian a band +refined to $h/9$, with its cells at a local Courant number of 13, changes the error +in the third digit only. A step costs one nonsymmetric solve, four to six times +less than a semi-Lagrangian step in serial, and it needs no departure points in +parallel. On a moving mesh the field and its history are re-interpolated by the +ordinary remesh transfer, so no special staging is needed. + +**Semi-Lagrangian.** The error is nearly independent of the timestep but +accumulates one interpolation per step, so at small Courant numbers it is the +worse scheme (21% against 0.6% after one revolution at Courant 0.5 on the same +mesh). Its limit is the arc a characteristic turns per step, about 10 degrees for +the RK2 trace-back, a property of the flow rather than the mesh. Above roughly +Courant 2 on the feature's own scale it keeps its accuracy where the Eulerian +scheme loses it. + +A practical rule: if the timestep is chosen so that the temperature field itself +is resolved in time (a fraction of a feature width per step), the Eulerian solver +is cheaper and more accurate; if the step is deliberately long relative to the +transported features, the semi-Lagrangian solver is the one that survives it. + +## Choosing the time scheme + +Measured on a rotating Gaussian, one revolution, relative $L_2$ error; the full +tables are in the design note. + +| scheme | behaviour | +|---|---| +| Crank-Nicolson (`order=1`) | three to four times more accurate than BDF2 at the same timestep below Courant 2; rings once the feature is under-resolved in time | +| BDF2 (`order=2`) | damped and stable at every Courant number; the choice for sharp or under-resolved fields | +| BDF3 (`order=3`) | the most accurate scheme below Courant 1 when diffusion is present; on pure advection it grows slowly at any Courant number, so use it only with diffusion | +| backward Euler (`order=1, theta=1.0`) | 20 to 40% error at any practical timestep; not for transport | +| Adams-Moulton 2, 3 (`integrator="am"`) | third and fourth order below Courant 1; blow up on advection from about Courant 1 | + +All schemes cost the same per step: the history terms are extra kernel inputs, +not extra solves. Changing the timestep between steps changes a runtime constant +of the compiled kernels; nothing is recompiled. + +## Details that differ from SLCN + +- The strong residual used in the SUPG term carries the time derivative and the + advection but no diffusion term, because PETSc's pointwise kernels see first + derivatives only. For linear elements the missing term is identically zero. +- The stabilisation parameter uses the local cell size (`mesh.cell_size()`) and + three weights that are runtime constants (`solver.tau_weights`); + `solver.supg_weight = 0` gives the plain Galerkin scheme for comparison. +- The linear system is nonsymmetric, so the solver defaults to GMRES with an + additive-Schwarz ILU preconditioner instead of algebraic multigrid. Every + option can be overridden through `solver.petsc_options`. + +## Further reading + +- Design note and measurements: `docs/developer/design/eulerian-supg-transport.md` +- The semi-Lagrangian schemes: {doc}`semi-lagrangian-time-integration` +- Example: `docs/examples/convection/advanced/Ex_AdvectionDiffusionSUPG_RotationTest.py` diff --git a/docs/advanced/index.md b/docs/advanced/index.md index cb47f8dea..b569bf0a7 100644 --- a/docs/advanced/index.md +++ b/docs/advanced/index.md @@ -139,6 +139,7 @@ custom-meshes curved-boundary-conditions mesh-adaptation semi-lagrangian-time-integration +eulerian-advection-diffusion porous-flow snapshot-restore troubleshooting diff --git a/docs/developer/design/eulerian-supg-transport.md b/docs/developer/design/eulerian-supg-transport.md index 7410e80ab..580870ebf 100644 --- a/docs/developer/design/eulerian-supg-transport.md +++ b/docs/developer/design/eulerian-supg-transport.md @@ -188,11 +188,17 @@ What the table says: order, and the error is still set by $\mathbf{u}\Delta t$ against the feature width. -**Default: `order=2, integrator="bdf"`.** Defaults err toward robustness; a user -with a smooth field at Courant 2 or below gets the better answer from -`integrator="am", order=1, theta=0.5` (Crank-Nicolson), and with diffusion below -Courant 1 from `order=3`. Backward Euler (order 1) is a first-order scheme with 20 to 40% error -at any practical timestep and is not a sensible default for transport. +**Interface and default.** The class is a drop-in replacement for the +semi-Lagrangian solver: the same constructor, and `order` and `theta` with the same +meaning (`order=1, theta=0.5` is Crank-Nicolson and the default, as for SLCN; +`order=2, theta=1.0` is BDF2, the counterpart of SL-BDF2; `order=2, theta=0.5` is +refused for the reason the SLCN documentation gives). `integrator` is inferred and +only needs setting to reach Adams-Moulton above order 1. The choice of +Crank-Nicolson as the default follows the drop-in contract and the table: it is +the more accurate scheme wherever the answer is good, and where it rings the +answer is already wrong for every scheme. A user who wants damping asks for +`order=2`; below Courant 1 with diffusion, `order=3`. Backward Euler is not a +sensible choice for transport. ### Temporal convergence (tests/test_1100) diff --git a/docs/examples/convection/advanced/Ex_AdvectionDiffusionSUPG_RotationTest.py b/docs/examples/convection/advanced/Ex_AdvectionDiffusionSUPG_RotationTest.py index d5a0db03e..5987ac3bc 100644 --- a/docs/examples/convection/advanced/Ex_AdvectionDiffusionSUPG_RotationTest.py +++ b/docs/examples/convection/advanced/Ex_AdvectionDiffusionSUPG_RotationTest.py @@ -37,13 +37,14 @@ timestep is a runtime constant of the compiled kernels. - **SUPG stabilisation**: the streamline-upwind test-function perturbation written as a flux, so PETSc needs no modified test space. -- **Multistep order**: `order=1, 2, 3` with `integrator="bdf"` or `"am"`. +- **Drop-in for SLCN**: the same constructor, `order`, `theta`, `estimate_dt` + and `solve`; change the class name and nothing else. ## Parameters - `uw_res`: cells across the box - `uw_courant`: timestep as a multiple of the cell-crossing time -- `uw_order`, `uw_integrator`, `uw_theta`: the time scheme +- `uw_order`, `uw_theta`: the time scheme, with the semi-Lagrangian solver's meaning - `uw_diffusivity`: thermal diffusivity (0 is pure advection) """ @@ -67,9 +68,8 @@ params = uw.Params( uw_res=32, uw_courant=1.0, - uw_order=2, - uw_integrator="bdf", - uw_theta=1.0, + uw_order=1, # 1 with theta 0.5 is Crank-Nicolson; 2 with theta 1.0 is BDF2 + uw_theta=0.5, uw_diffusivity=0.0, uw_sigma=0.12, ) @@ -104,8 +104,7 @@ # %% adv_diff = uw.systems.AdvDiffusionSUPG( - mesh, T, velocity, order=params.uw_order, - integrator=params.uw_integrator, theta=params.uw_theta) + mesh, T, velocity, order=params.uw_order, theta=params.uw_theta) adv_diff.constitutive_model.Parameters.diffusivity = params.uw_diffusivity for boundary in ("Left", "Right", "Top", "Bottom"): adv_diff.add_dirichlet_bc(0.0, boundary) diff --git a/src/underworld3/systems/advection_diffusion_eulerian.py b/src/underworld3/systems/advection_diffusion_eulerian.py index 6c8e23f41..924aae9aa 100644 --- a/src/underworld3/systems/advection_diffusion_eulerian.py +++ b/src/underworld3/systems/advection_diffusion_eulerian.py @@ -20,9 +20,11 @@ turns in one step. See ``docs/developer/design/eulerian-supg-transport.md``. """ +import warnings + import numpy as np import sympy -from typing import Optional +from typing import Callable, Optional, Union import underworld3 as uw import underworld3.timing as timing @@ -64,35 +66,75 @@ class SNES_AdvectionDiffusion_SUPG(SNES_Scalar): \frac{\partial \phi}{\partial t} + \mathbf{u}\cdot\nabla\phi - \nabla\cdot(\kappa\nabla\phi) = f - Two families of time integration are built from the same stored history - :math:`\phi^{n}, \phi^{n-1}, \dots` (real mesh variables, so their - gradients are available inside the kernels): + A drop-in replacement for :class:`~underworld3.systems.solvers.SNES_AdvectionDiffusion` + (``uw.systems.AdvDiffusionSLCN``): the constructor, ``order``, ``theta``, + ``f``, ``V_fn``, ``constitutive_model``, ``delta_t``, ``estimate_dt`` and + ``solve`` all keep the semi-Lagrangian solver's meaning, so a script changes + the class name and nothing else:: + + adv = uw.systems.AdvDiffusionSUPG(mesh, T, v.sym, order=1) # was AdvDiffusionSLCN + adv.constitutive_model = uw.constitutive_models.DiffusionModel + adv.constitutive_model.Parameters.diffusivity = 1.0e-3 + adv.add_dirichlet_bc(0.0, "Left") + adv.solve(timestep=dt) + + The arguments that only make sense for a trace-back + (``restore_points_func``, ``monotone_mode``, ``old_frame_traceback``, + ``DFDt``) are accepted and ignored with a warning. + + **Time schemes.** ``order`` and ``theta`` select the same schemes as for + the semi-Lagrangian solver: + + ========== ======= ===================================================== + ``order`` ``theta`` scheme + ========== ======= ===================================================== + 1 0.5 Crank-Nicolson (default; the SLCN convention) + 1 1.0 backward Euler + 2 1.0 BDF2, all spatial terms at :math:`n+1` (the SL-BDF2 convention) + 3 1.0 BDF3 + ========== ======= ===================================================== - ``integrator="bdf"`` (backward differentiation, order 1-3) + ``order=2`` with ``theta=0.5`` is refused, as the semi-Lagrangian + documentation says: a BDF stencil pairs with terms at :math:`n+1`, not + with a centred flux. Every past time level is a mesh variable held by an + :class:`~underworld3.systems.ddt.Eulerian` history manager, so gradients + of past states are available in the kernels and both families come from + one code path: + + ``integrator="bdf"`` (order :math:`N`) .. math:: \frac{1}{\Delta t}\sum_{k=0}^{N} c_k\,\phi^{n+1-k} + \mathbf{u}\cdot\nabla\phi^{n+1} - \nabla\cdot(\kappa\nabla\phi^{n+1}) = f - ``integrator="am"`` (Adams-Moulton, order 1-3; ``theta`` at order 1) + ``integrator="am"`` (order :math:`N`; ``theta`` at order 1) .. math:: \frac{\phi^{n+1}-\phi^{n}}{\Delta t} + \sum_{k=0}^{N} a_k\left[\mathbf{u}\cdot\nabla\phi^{n+1-k} - \nabla\cdot(\kappa\nabla\phi^{n+1-k})\right] = f - Order 1 with ``theta=1`` is backward Euler in both families; - ``integrator="am", order=1, theta=0.5`` is Crank-Nicolson. The BDF - coefficients :math:`c_k` and Adams-Moulton weights :math:`a_k` are the - ones the :class:`~underworld3.systems.ddt.Eulerian` manager maintains; - both ramp from first order over the opening steps unless a history is - planted with ``solver.DuDt.set_initial_history``. A BDF3 request falls - back to variable-step BDF2 whenever consecutive timesteps differ by more - than 5%. + ``integrator`` is inferred from ``order`` and ``theta`` (Adams-Moulton at + order 1, BDF above) and only needs setting to reach Adams-Moulton above + order 1, which is provided for diffusion-dominated problems: its bounded + stability region makes it blow up on an advection operator from about + Courant 1. Both families ramp from first order over the opening steps + unless a history is planted with ``solver.DuDt.set_initial_history``. A + BDF3 request falls back to variable-step BDF2 whenever consecutive + timesteps differ by more than 5%. + + **Which scheme.** Measured on a rotating Gaussian + (``docs/developer/design/eulerian-supg-transport.md``): Crank-Nicolson is + three to four times more accurate than BDF2 at the same timestep below + Courant 2 on the feature scale, and rings once the feature is + under-resolved in time; BDF2 is damped and stable at every Courant + number; BDF3 is the most accurate scheme below Courant 1 when diffusion + is present but grows slowly on pure advection; backward Euler carries 20 + to 40% error at any practical timestep. **Weak form.** With the strong residual of the chosen scheme - :math:`R(\phi)` (time derivative and advection; see below) the residual + :math:`R(\phi)` (time derivative, advection, source) the residual assembled through PETSc's pointwise interface is .. math:: @@ -117,20 +159,22 @@ class SNES_AdvectionDiffusion_SUPG(SNES_Scalar): + \left(\frac{2|\mathbf{u}|}{h}\right)^2 + \left(\frac{4\kappa}{h^2}\right)^2\right]^{-1/2} - with :math:`h` the local cell size (``mesh.cell_size()``, the - :math:`\mathrm{volume}^{1/d}` equivalent radius) and :math:`c_0` the - leading multistep coefficient. The three weights are runtime constants - (``tau_weights``) and ``supg_weight`` scales the whole term, so a - Galerkin baseline needs no rebuild. + with :math:`h` the local cell size (``mesh.cell_size()``) and + :math:`c_0` the leading multistep coefficient. The three weights are + runtime constants (``tau_weights``) and ``supg_weight`` scales the whole + term, so a Galerkin baseline needs no rebuild. - **What limits the timestep.** Nothing, for stability. The implicit + **What limits the timestep.** Nothing, for stability: the implicit scheme is stable at any cell Courant number, including on cells refined for a Stokes problem that the scalar does not need. Accuracy is set by how far the transported feature moves per step relative to its own - width: the error grows as :math:`(\mathbf{u}\Delta t)^2` for the - second-order schemes, and the transient term of :math:`\tau` cannot - hide that. :meth:`estimate_dt` returns the cell-crossing time as a - resolution guide only. + width, as :math:`(\mathbf{u}\Delta t)^2` for the second-order schemes. + :meth:`estimate_dt` returns the cell-crossing time as a resolution guide, + exactly as the semi-Lagrangian solver does. Against that solver: the + semi-Lagrangian error is flat in the timestep but accumulates one + interpolation per step, and its limit is the arc a characteristic turns + per step; the Eulerian solve costs four to six times less per step in + serial and needs no departure points in parallel. Parameters ---------- @@ -139,42 +183,31 @@ class SNES_AdvectionDiffusion_SUPG(SNES_Scalar): Continuous scalar field :math:`\phi`. V_fn : MeshVariable or sympy Matrix Advecting velocity, ``(1, dim)``. - order : int, default 2 - Order of the time integration, 1 to 3. BDF2 is the default because it - is stable at every Courant number and damped. Measured on a rotating - Gaussian (``docs/developer/design/eulerian-supg-transport.md``): - Crank-Nicolson is three to four times more accurate than BDF2 at the - same timestep below Courant 2 but rings once the feature is - under-resolved in time; BDF3 is the most accurate scheme below - Courant 1 when diffusion is present, fails from Courant 4, and on - pure advection grows slowly at any Courant number; backward Euler (order 1) carries - 20 to 40% error at any practical timestep. - integrator : {"bdf", "am"}, default "bdf" - Adams-Moulton above order 1 has a bounded stability region and blows - up on an advection operator from about Courant 1; it is provided for - diffusion-dominated problems and for comparison. - theta : float, default 1.0 - Adams-Moulton blend at order 1 only (0.5 is Crank-Nicolson). Must be - 1.0 for BDF and for Adams-Moulton above order 1. + order : int, default 1 + Time-integration order, 1 to 3 (see the table above). + theta : float, optional + Crank-Nicolson blend at order 1: 0.5 (the default there) is + Crank-Nicolson, 1.0 is backward Euler. Above order 1 the only + consistent value is 1.0, which is taken when ``theta`` is not given + and refused when 0.5 is asked for explicitly. + integrator : {"bdf", "am"}, optional + Inferred from ``order`` and ``theta`` when omitted. verbose : bool, default False DuDt : Eulerian, optional A pre-built history manager (order at least ``order``, no ``V_fn``). + restore_points_func, monotone_mode, old_frame_traceback, DFDt + Semi-Lagrangian arguments, accepted for drop-in compatibility and + ignored with a warning: there is no trace-back here. Notes ----- - The diffusivity is set through the constitutive model, as for the other - scalar solvers; the solver starts with a + The diffusivity is set through the constitutive model, as for every + scalar solver; the solver starts with a :class:`~underworld3.constitutive_models.DiffusionModel` at - :math:`\kappa = 0` (pure advection):: - - adv = uw.systems.AdvDiffusionSUPG(mesh, T, v.sym, order=2) - adv.constitutive_model.Parameters.diffusivity = 1.0e-3 - adv.add_dirichlet_bc(0.0, "Left") - adv.solve(timestep=dt) - - The linear system is nonsymmetric, so the preconditioner defaults are - GMRES with an additive-Schwarz ILU preconditioner rather than the - algebraic multigrid the symmetric scalar solvers use. + :math:`\kappa = 0` (pure advection). The linear system is nonsymmetric, + so the solver defaults to GMRES with an additive-Schwarz ILU + preconditioner rather than the algebraic multigrid the symmetric scalar + solvers use; every option is overridable through ``petsc_options``. """ _INTEGRATORS = ("bdf", "am") @@ -185,29 +218,52 @@ def __init__( mesh: uw.discretisation.Mesh, u_Field: uw.discretisation.MeshVariable, V_fn, - order: int = 2, - integrator: str = "bdf", - theta: float = 1.0, + order: int = 1, + theta: Optional[float] = None, + integrator: Optional[str] = None, verbose: bool = False, DuDt: Optional[Eulerian_DDt] = None, + DFDt=None, + restore_points_func: Optional[Callable] = None, + monotone_mode: Optional[str] = None, + old_frame_traceback: bool = False, ): if not u_Field.continuous: raise ValueError( "u_Field must be a continuous MeshVariable: the SUPG weak form " "is continuous Galerkin." ) - if integrator not in self._INTEGRATORS: - raise ValueError( - f"integrator must be one of {self._INTEGRATORS}, not {integrator!r}." + ignored = [name for name, value in ( + ("restore_points_func", restore_points_func), + ("monotone_mode", monotone_mode), + ("old_frame_traceback", old_frame_traceback), + ("DFDt", DFDt), + ) if value] + if ignored: + warnings.warn( + f"AdvDiffusionSUPG ignores {', '.join(ignored)}: these configure " + "the semi-Lagrangian trace-back and the Eulerian scheme has none.", + stacklevel=2, ) order = int(order) if order not in (1, 2, 3): raise ValueError(f"order must be 1, 2 or 3, not {order}.") - theta = float(theta) + # theta means what it means for the semi-Lagrangian solver: the + # Crank-Nicolson blend at order 1. Left unset, order 2 and 3 take the + # only consistent value; set explicitly to 0.5 there, it is refused. + theta = float(theta) if theta is not None else (0.5 if order == 1 else 1.0) + if integrator is None: + integrator = "am" if order == 1 else "bdf" + if integrator not in self._INTEGRATORS: + raise ValueError( + f"integrator must be one of {self._INTEGRATORS}, not {integrator!r}." + ) if theta != 1.0 and not (integrator == "am" and order == 1): raise ValueError( - "theta applies to integrator='am' at order 1 only " - "(0.5 is Crank-Nicolson); higher orders and BDF take theta=1." + "theta applies at order 1 only (0.5 is Crank-Nicolson, 1.0 is " + "backward Euler); order 2 and 3 take theta=1.0, the same rule as " + "the semi-Lagrangian solver (a BDF stencil pairs with terms at n+1, " + "not with a centred flux)." ) super().__init__(mesh, u_Field, u_Field.degree, verbose, DuDt=DuDt, DFDt=None) @@ -277,6 +333,19 @@ def __init__( self.petsc_options["snes_rtol"] = 1.0e-8 self.petsc_options["snes_max_it"] = 20 + def _object_viewer(self): + from IPython.display import Latex, display + + super()._object_viewer() + scheme = {("am", 1): f"Adams-Moulton order 1, theta = {self._theta}", + ("bdf", 1): "backward Euler"}.get( + (self._integrator, self._time_order), + f"{self._integrator.upper()} order {self._time_order}") + display(Latex(r"$\quad\mathrm{u} = $ " + self.u.sym._repr_latex_())) + display(Latex(r"$\quad\mathbf{v} = $ " + self._V_fn._repr_latex_())) + display(Latex(r"$\quad\Delta t = $ " + self._delta_t._repr_latex_())) + display(Latex(rf"$\quad$ time scheme: {scheme}")) + # ------------------------------------------------------------------ # Scheme description # ------------------------------------------------------------------ @@ -298,9 +367,24 @@ def theta(self) -> float: @property def delta_t(self): - r"""The timestep :math:`\Delta t` as a UW expression (set by :meth:`solve`).""" + r"""The timestep :math:`\Delta t` as a UW expression. + + Set by :meth:`solve`, or assign it directly (a number or a quantity + with time units) and call ``solve()`` without ``timestep``, as with + the semi-Lagrangian solver. A new value updates a runtime constant of + the compiled kernels; nothing is recompiled. + """ return self._delta_t + @delta_t.setter + def delta_t(self, value): + dt = float(_nondimensionalise_timestep(value)) + if dt <= 0.0: + raise ValueError(f"timestep must be positive, not {dt}.") + if dt != self._last_timestep: + self._delta_t.sym = dt + self._last_timestep = dt + @property def V_fn(self): """Advecting velocity, ``(1, dim)``.""" @@ -458,29 +542,27 @@ def estimate_dt(self, direction_aware: bool = False, percentile: float = 0.0): def solve( self, - *, - timestep=None, zero_init_guess: Optional[bool] = None, - verbose: bool = False, + timestep=None, _force_setup: bool = False, + _evalf: bool = False, + verbose: bool = False, divergence_retries: int = 0, ): - r"""Advance :math:`\phi` by one step of size ``timestep``. + r"""Advance :math:`\phi` by one step. - ``timestep`` is required and keyword-only. Changing it between calls - updates a runtime constant of the compiled kernels; nothing is - recompiled. + Same signature as the semi-Lagrangian solver. ``timestep`` sets + :attr:`delta_t`; omit it to reuse the value already set. Changing it + between calls updates a runtime constant of the compiled kernels; + nothing is recompiled. """ - if timestep is None: + if timestep is not None: + self.delta_t = timestep + elif self._last_timestep is None: raise ValueError( - "solve() requires timestep=
; there is no default timestep." + "solve() needs a timestep: pass timestep=
or set solver.delta_t first." ) - dt = float(_nondimensionalise_timestep(timestep)) - if dt <= 0.0: - raise ValueError(f"timestep must be positive, not {dt}.") - if dt != self._last_timestep: - self._delta_t.sym = dt - self._last_timestep = dt + dt = self._last_timestep if _force_setup: self._needs_function_rewire = True diff --git a/tests/test_1055_advdiff_supg_api.py b/tests/test_1055_advdiff_supg_api.py index bb95e3fd4..d1e045f10 100644 --- a/tests/test_1055_advdiff_supg_api.py +++ b/tests/test_1055_advdiff_supg_api.py @@ -32,14 +32,38 @@ def _solver(mesh, tag, **kwargs): return adv, T -def test_exported_and_constructs(mesh): +def test_exported_and_constructs_with_the_slcn_defaults(mesh): adv, _T = _solver(mesh, "a") assert type(adv).__name__ == "SNES_AdvectionDiffusion_SUPG" - assert adv.integrator == "bdf" and adv.order == 2 + # order 1, theta 0.5: Crank-Nicolson, the semi-Lagrangian solver's default + assert adv.integrator == "am" and adv.order == 1 and adv.theta == 0.5 assert isinstance(adv.DuDt, uw.systems.ddt.Eulerian) assert adv.DuDt.V_fn is None, "advection is implicit, not a history correction" +def test_slcn_order_theta_pairs_select_the_documented_schemes(mesh): + assert _solver(mesh, "p1", order=1, theta=1.0)[0].integrator == "am" # backward Euler + assert _solver(mesh, "p2", order=2, theta=1.0)[0].integrator == "bdf" # SL-BDF2's counterpart + assert _solver(mesh, "p3", order=2)[0].integrator == "bdf" # theta 0.5 only bites at order 1 + with pytest.raises(ValueError, match="theta applies"): + _solver(mesh, "p4", order=2, theta=0.5) + + +def test_semi_lagrangian_only_arguments_are_ignored_with_a_warning(mesh): + with pytest.warns(UserWarning, match="monotone_mode, old_frame_traceback"): + adv, _T = _solver(mesh, "q", monotone_mode="clamp", old_frame_traceback=True) + adv.solve(timestep=0.01) + + +def test_solve_takes_the_slcn_signature_and_delta_t(mesh): + adv, T = _solver(mesh, "s") + adv.solve(False, 0.01) # positional, as SLCN allows + adv.delta_t = 0.02 # set once ... + adv.solve() # ... and reuse + assert float(adv.delta_t.sym) == 0.02 + assert np.isfinite(np.asarray(T.array)).all() + + @pytest.mark.parametrize("tag, kwargs, message", [ ("v0", dict(order=4), "order must be"), ("v1", dict(integrator="rk4"), "integrator must be"), @@ -53,14 +77,14 @@ def test_scheme_arguments_are_validated(mesh, tag, kwargs, message): def test_timestep_is_required(mesh): adv, _T = _solver(mesh, "b") - with pytest.raises(ValueError, match="requires timestep"): + with pytest.raises(ValueError, match="needs a timestep"): adv.solve() def test_bdf1_diffusive_flux_is_the_constitutive_flux(mesh): """At order 1 the assembled diffusive flux is exactly the constitutive model's own flux of the new state; the history weights are inert.""" - adv, _T = _solver(mesh, "c") + adv, _T = _solver(mesh, "c", order=1, theta=1.0, integrator="bdf") adv.constitutive_model.Parameters.diffusivity = 0.7 difference = adv._diffusive_flux() - adv.constitutive_model.flux.T assert all(sympy.simplify(e) == 0 for e in difference) From 252165e791a4456fda158121a4beb7c6a4e33d86 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Wed, 2 Sep 2026 21:48:05 -0700 Subject: [PATCH 12/20] Drop the integrator argument: order and theta already reach every safe scheme The only schemes the argument added were Adams-Moulton at orders 2 and 3, which the integrator study shows blowing up on advection from Courant 1. The multistep family now follows the order (the theta rule at order 1, BDF above); the higher Adams-Moulton assembly stays in the code, reachable only by switching the family on the instance, which is how the study measured it. Underworld development team with AI support from Claude Code Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL --- docs/advanced/eulerian-advection-diffusion.md | 6 +-- .../semi-lagrangian-time-integration.md | 6 +-- .../design/eulerian-supg-transport.md | 16 ++++---- .../systems/advection_diffusion_eulerian.py | 39 ++++++++----------- tests/test_1055_advdiff_supg_api.py | 22 ++++++----- ...est_1100_advdiff_supg_rotating_gaussian.py | 6 +-- 6 files changed, 46 insertions(+), 49 deletions(-) diff --git a/docs/advanced/eulerian-advection-diffusion.md b/docs/advanced/eulerian-advection-diffusion.md index 9e0951526..a18f18925 100644 --- a/docs/advanced/eulerian-advection-diffusion.md +++ b/docs/advanced/eulerian-advection-diffusion.md @@ -37,9 +37,7 @@ adv.solve(timestep=dt) | `DuDt.set_initial_history(values, dt)` | same | plant an exact history to start at full order | | `restore_points_func`, `monotone_mode`, `old_frame_traceback`, `DFDt` | ignored, with a warning | they configure the trace-back | -`order=3` (BDF3) is available; see below for when it is safe. `integrator="am"` -above order 1 reaches the higher Adams-Moulton rules, which are for -diffusion-dominated problems only. +`order=3` (BDF3) is available; see below for when it is safe. ## When to use which @@ -80,7 +78,7 @@ tables are in the design note. | BDF2 (`order=2`) | damped and stable at every Courant number; the choice for sharp or under-resolved fields | | BDF3 (`order=3`) | the most accurate scheme below Courant 1 when diffusion is present; on pure advection it grows slowly at any Courant number, so use it only with diffusion | | backward Euler (`order=1, theta=1.0`) | 20 to 40% error at any practical timestep; not for transport | -| Adams-Moulton 2, 3 (`integrator="am"`) | third and fourth order below Courant 1; blow up on advection from about Courant 1 | +| Adams-Moulton 2, 3 (not offered) | third and fourth order below Courant 1 but blow up on advection from about Courant 1, which is why there is no knob for them | All schemes cost the same per step: the history terms are extra kernel inputs, not extra solves. Changing the timestep between steps changes a runtime constant diff --git a/docs/advanced/semi-lagrangian-time-integration.md b/docs/advanced/semi-lagrangian-time-integration.md index 7812b7c78..f48c36ed7 100644 --- a/docs/advanced/semi-lagrangian-time-integration.md +++ b/docs/advanced/semi-lagrangian-time-integration.md @@ -116,9 +116,9 @@ $[\theta,\,1-\theta]$: `uw.systems.AdvDiffusionSUPG` solves the same equation without a trace-back: all terms are assembled on the mesh, implicit in time, with SUPG -stabilisation. Its `order=` and `integrator="bdf"|"am"` arguments select the -multistep scheme, built from the same stored history as above; `order=1, -integrator="am", theta=0.5` is Crank-Nicolson. The scheme is stable at any +stabilisation. Its `order=` and `theta=` arguments mean what they mean here: +`order=1, theta=0.5` is Crank-Nicolson, `order=2` is BDF2, built from the same +stored history as above. The scheme is stable at any cell Courant number, so cells refined for a Stokes problem never limit the transport timestep; its accuracy is set by how far the transported feature moves per step. The semi-Lagrangian scheme's accuracy is instead set by how diff --git a/docs/developer/design/eulerian-supg-transport.md b/docs/developer/design/eulerian-supg-transport.md index 580870ebf..8b98803c5 100644 --- a/docs/developer/design/eulerian-supg-transport.md +++ b/docs/developer/design/eulerian-supg-transport.md @@ -30,10 +30,10 @@ Every past time level $\phi^{n}, \phi^{n-1}, \dots$ is a mesh variable held by a `Eulerian` history manager, so first derivatives of past states are available in the kernels and two multistep families share one code path: -| `integrator` | time derivative | spatial operator | +| family | time derivative | spatial operator | |---|---|---| -| `bdf`, order $N$ | $\frac{1}{\Delta t}\sum_{k=0}^{N} c_k\,\phi^{n+1-k}$ | at $n+1$ only | -| `am`, order $N$ | $\frac{\phi^{n+1}-\phi^{n}}{\Delta t}$ | $\sum_{k=0}^{N} a_k\,S(\phi^{n+1-k})$ | +| BDF, order $N$ (`order=2, 3`) | $\frac{1}{\Delta t}\sum_{k=0}^{N} c_k\,\phi^{n+1-k}$ | at $n+1$ only | +| theta rule (`order=1`; Adams-Moulton of $N$ steps internally) | $\frac{\phi^{n+1}-\phi^{n}}{\Delta t}$ | $\sum_{k=0}^{N} a_k\,S(\phi^{n+1-k})$ | with $S(\phi) = \mathbf{u}\cdot\nabla\phi - \nabla\cdot(\kappa\nabla\phi)$ and the coefficients those the history manager already maintains (`theta` is the @@ -175,8 +175,8 @@ What the table says: - **Adams-Moulton above order 1 is unusable for advection.** Its stability region is bounded and covers only a short segment of the imaginary axis, so on a pure advection operator it blows up once the Courant number reaches about 1, and - diffusion at this Peclet number does not rescue it. It is kept in the class for - the record and for diffusion-dominated use, with that warning in the docstring. + diffusion at this Peclet number does not rescue it. The assembly code handles + it, but no public argument reaches it. - **BDF3 is the most accurate scheme below Courant 1 with diffusion present** (0.3%, on the spatial floor) but it is not A-stable, fails from Courant 4, and on pure advection grows slowly at any Courant number (the res-64 rows). @@ -192,8 +192,10 @@ What the table says: semi-Lagrangian solver: the same constructor, and `order` and `theta` with the same meaning (`order=1, theta=0.5` is Crank-Nicolson and the default, as for SLCN; `order=2, theta=1.0` is BDF2, the counterpart of SL-BDF2; `order=2, theta=0.5` is -refused for the reason the SLCN documentation gives). `integrator` is inferred and -only needs setting to reach Adams-Moulton above order 1. The choice of +refused for the reason the SLCN documentation gives). There is no `integrator` +argument: the family follows the order, and the only schemes that argument would +have added, Adams-Moulton at orders 2 and 3, are the ones the table rules out. +The study reached them by switching the family on the instance. The choice of Crank-Nicolson as the default follows the drop-in contract and the table: it is the more accurate scheme wherever the answer is good, and where it rings the answer is already wrong for every scheme. A user who wants damping asks for diff --git a/src/underworld3/systems/advection_diffusion_eulerian.py b/src/underworld3/systems/advection_diffusion_eulerian.py index 924aae9aa..476a0b5db 100644 --- a/src/underworld3/systems/advection_diffusion_eulerian.py +++ b/src/underworld3/systems/advection_diffusion_eulerian.py @@ -101,28 +101,26 @@ class SNES_AdvectionDiffusion_SUPG(SNES_Scalar): of past states are available in the kernels and both families come from one code path: - ``integrator="bdf"`` (order :math:`N`) + backward differentiation (order :math:`N \ge 2`) .. math:: \frac{1}{\Delta t}\sum_{k=0}^{N} c_k\,\phi^{n+1-k} + \mathbf{u}\cdot\nabla\phi^{n+1} - \nabla\cdot(\kappa\nabla\phi^{n+1}) = f - ``integrator="am"`` (order :math:`N`; ``theta`` at order 1) + the :math:`\theta` rule (order 1; Adams-Moulton of one step) .. math:: \frac{\phi^{n+1}-\phi^{n}}{\Delta t} + \sum_{k=0}^{N} a_k\left[\mathbf{u}\cdot\nabla\phi^{n+1-k} - \nabla\cdot(\kappa\nabla\phi^{n+1-k})\right] = f - ``integrator`` is inferred from ``order`` and ``theta`` (Adams-Moulton at - order 1, BDF above) and only needs setting to reach Adams-Moulton above - order 1, which is provided for diffusion-dominated problems: its bounded - stability region makes it blow up on an advection operator from about - Courant 1. Both families ramp from first order over the opening steps - unless a history is planted with ``solver.DuDt.set_initial_history``. A - BDF3 request falls back to variable-step BDF2 whenever consecutive - timesteps differ by more than 5%. + The higher Adams-Moulton rules are assembled by the same code but are + not offered: their bounded stability region blows up on an advection + operator from about Courant 1 (see the design note). Both families ramp + from first order over the opening steps unless a history is planted with + ``solver.DuDt.set_initial_history``. A BDF3 request falls back to + variable-step BDF2 whenever consecutive timesteps differ by more than 5%. **Which scheme.** Measured on a rotating Gaussian (``docs/developer/design/eulerian-supg-transport.md``): Crank-Nicolson is @@ -190,8 +188,6 @@ class SNES_AdvectionDiffusion_SUPG(SNES_Scalar): Crank-Nicolson, 1.0 is backward Euler. Above order 1 the only consistent value is 1.0, which is taken when ``theta`` is not given and refused when 0.5 is asked for explicitly. - integrator : {"bdf", "am"}, optional - Inferred from ``order`` and ``theta`` when omitted. verbose : bool, default False DuDt : Eulerian, optional A pre-built history manager (order at least ``order``, no ``V_fn``). @@ -210,8 +206,6 @@ class SNES_AdvectionDiffusion_SUPG(SNES_Scalar): solvers use; every option is overridable through ``petsc_options``. """ - _INTEGRATORS = ("bdf", "am") - @timing.routine_timer_decorator def __init__( self, @@ -220,7 +214,6 @@ def __init__( V_fn, order: int = 1, theta: Optional[float] = None, - integrator: Optional[str] = None, verbose: bool = False, DuDt: Optional[Eulerian_DDt] = None, DFDt=None, @@ -252,13 +245,13 @@ def __init__( # Crank-Nicolson blend at order 1. Left unset, order 2 and 3 take the # only consistent value; set explicitly to 0.5 there, it is refused. theta = float(theta) if theta is not None else (0.5 if order == 1 else 1.0) - if integrator is None: - integrator = "am" if order == 1 else "bdf" - if integrator not in self._INTEGRATORS: - raise ValueError( - f"integrator must be one of {self._INTEGRATORS}, not {integrator!r}." - ) - if theta != 1.0 and not (integrator == "am" and order == 1): + # The multistep family follows the order: the Adams-Moulton (theta) + # rule at order 1, backward differentiation above. Adams-Moulton at + # orders 2 and 3 is assembled by the same code but is not offered: + # its bounded stability region blows up on an advection operator + # from about Courant 1 (design note, integrator study). + integrator = "am" if order == 1 else "bdf" + if theta != 1.0 and order != 1: raise ValueError( "theta applies at order 1 only (0.5 is Crank-Nicolson, 1.0 is " "backward Euler); order 2 and 3 take theta=1.0, the same rule as " @@ -352,7 +345,7 @@ def _object_viewer(self): @property def integrator(self) -> str: - """``"bdf"`` or ``"am"``.""" + """The multistep family in use: ``"am"`` (the theta rule) at order 1, ``"bdf"`` above.""" return self._integrator @property diff --git a/tests/test_1055_advdiff_supg_api.py b/tests/test_1055_advdiff_supg_api.py index d1e045f10..590dc2b61 100644 --- a/tests/test_1055_advdiff_supg_api.py +++ b/tests/test_1055_advdiff_supg_api.py @@ -66,9 +66,9 @@ def test_solve_takes_the_slcn_signature_and_delta_t(mesh): @pytest.mark.parametrize("tag, kwargs, message", [ ("v0", dict(order=4), "order must be"), - ("v1", dict(integrator="rk4"), "integrator must be"), - ("v2", dict(integrator="bdf", theta=0.5), "theta applies"), - ("v3", dict(integrator="am", order=2, theta=0.5), "theta applies"), + ("v1", dict(order=0), "order must be"), + ("v2", dict(order=2, theta=0.5), "theta applies"), + ("v3", dict(order=3, theta=0.5), "theta applies"), ]) def test_scheme_arguments_are_validated(mesh, tag, kwargs, message): with pytest.raises(ValueError, match=message): @@ -81,17 +81,21 @@ def test_timestep_is_required(mesh): adv.solve() -def test_bdf1_diffusive_flux_is_the_constitutive_flux(mesh): - """At order 1 the assembled diffusive flux is exactly the constitutive - model's own flux of the new state; the history weights are inert.""" - adv, _T = _solver(mesh, "c", order=1, theta=1.0, integrator="bdf") +def test_bdf_diffusive_flux_is_the_constitutive_flux(mesh): + """For the BDF family the assembled diffusive flux is exactly the + constitutive model's own flux of the new state; no history enters it.""" + adv, _T = _solver(mesh, "c", order=2) adv.constitutive_model.Parameters.diffusivity = 0.7 difference = adv._diffusive_flux() - adv.constitutive_model.flux.T assert all(sympy.simplify(e) == 0 for e in difference) -def test_am_order2_uses_all_three_time_levels(mesh): - adv, _T = _solver(mesh, "d", integrator="am", order=2) +def test_multistep_weights_reach_every_stored_time_level(mesh): + # The theta rule at higher order is assembled by the same code; it is not + # offered publicly (unstable for advection), so the family is switched + # on the instance here to cover the weighted-sum path. + adv, _T = _solver(mesh, "d", order=2) + adv._integrator = "am" weights = adv._spatial_weights() assert len(weights) == 3 states = adv._states() diff --git a/tests/test_1100_advdiff_supg_rotating_gaussian.py b/tests/test_1100_advdiff_supg_rotating_gaussian.py index 5607d22a6..336ea74e4 100644 --- a/tests/test_1100_advdiff_supg_rotating_gaussian.py +++ b/tests/test_1100_advdiff_supg_rotating_gaussian.py @@ -32,14 +32,14 @@ def _box(res, refinement=0): qdegree=3, regular=False, refinement=refinement) -def _problem(mesh, tag, order, integrator="bdf", theta=1.0, kappa=0.0): +def _problem(mesh, tag, order, theta=None, kappa=0.0): x, y = mesh.X sol = uw.analytic.RotatingGaussian(mesh, sigma=SIGMA, centre_radius=0.5, omega=1.0, diffusivity=kappa) T = uw.discretisation.MeshVariable(f"T_{tag}", mesh, 1, degree=2) T.array[:, 0, 0] = uw.function.evaluate(sol.at(0.0), T.coords).reshape(-1) adv = uw.systems.AdvDiffusionSUPG(mesh, T, sympy.Matrix([[-y, x]]), - order=order, integrator=integrator, theta=theta) + order=order, theta=theta) adv.constitutive_model.Parameters.diffusivity = kappa for b in ("Left", "Right", "Top", "Bottom"): adv.add_dirichlet_bc(0.0, b) @@ -74,7 +74,7 @@ def test_temporal_convergence_order(order, timesteps, expected_slope): t_end = float(sympy.pi) / 2 errors = [] for i, dt in enumerate(timesteps): - sol, T, adv = _problem(mesh, f"c{order}{i}", order) + sol, T, adv = _problem(mesh, f"c{order}{i}", order, theta=1.0) errors.append(_run(sol, T, adv, dt, t_end)) slopes = np.log2(np.array(errors[:-1]) / np.array(errors[1:])) print(f"order {order}: errors {errors} slopes {slopes}") From 417f7b88504fb7075e9f670478a7c41dbf8aba68 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Wed, 2 Sep 2026 22:05:51 -0700 Subject: [PATCH 13/20] An accuracy-based timestep for the Eulerian solver; credit NengLu in the module and note estimate_dt now returns the step at which the field changes by a fraction (0.02) of its range: from the advective rate |u . grad phi| at the vertices before the first solve, and from the rate the last step actually produced after it. The cell-crossing time the semi-Lagrangian solver reports is not a stability limit for this scheme and says nothing about its accuracy; it stays available as basis='resolution'. The estimate is mesh-independent, which the band test now checks (the resolution estimate collapses 3x on the refined child, the accuracy estimate moves under 25%), and at the default fraction Crank-Nicolson completes the rotating-Gaussian round trip under one per cent. The advective rate uses the vertex Clement gradient rather than a point evaluation of a derivative expression, which fails on a mesh carrying many variables. Underworld development team with AI support from Claude Code Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL --- docs/advanced/eulerian-advection-diffusion.md | 14 +- .../design/eulerian-supg-transport.md | 34 +++-- .../Ex_AdvectionDiffusionSUPG_RotationTest.py | 34 ++--- .../systems/advection_diffusion_eulerian.py | 127 +++++++++++++++--- tests/test_1055_advdiff_supg_api.py | 27 ++++ ...est_1100_advdiff_supg_rotating_gaussian.py | 23 +++- 6 files changed, 205 insertions(+), 54 deletions(-) diff --git a/docs/advanced/eulerian-advection-diffusion.md b/docs/advanced/eulerian-advection-diffusion.md index a18f18925..d62a6d416 100644 --- a/docs/advanced/eulerian-advection-diffusion.md +++ b/docs/advanced/eulerian-advection-diffusion.md @@ -19,10 +19,20 @@ adv.constitutive_model.Parameters.diffusivity = 1.0e-3 adv.add_dirichlet_bc(1.0, "Bottom") adv.add_dirichlet_bc(0.0, "Top") -dt = 0.5 * adv.estimate_dt() +dt = adv.estimate_dt() # accuracy-based: 2% of the field's range per step adv.solve(timestep=dt) ``` +The one deliberate difference is the timestep estimate. The semi-Lagrangian +`estimate_dt` reports the cell-crossing time, which for this solver is neither +a stability limit nor an accuracy one. The Eulerian solver's `estimate_dt` +instead returns the step at which the field changes by a given fraction of its +range (0.02 by default), from the advective rate before the first solve and +from the rate the last step actually produced after it. It does not depend on +the mesh, so cells refined for the Stokes problem do not shrink it. A script +that sizes its step in Courant numbers can still ask for +`estimate_dt(basis="resolution")`. + ## What carries over | SLCN | SUPG | note | @@ -32,7 +42,7 @@ adv.solve(timestep=dt) | `order=2, theta=1.0` | same | SL-BDF2 becomes BDF2 | | `order=2, theta=0.5` | refused | refused for the same reason: a BDF stencil does not pair with a centred flux | | `f`, `V_fn`, `constitutive_model`, `delta_t` | same | | -| `estimate_dt(direction_aware, percentile)` | same | the cell-crossing time, a resolution guide for both | +| `estimate_dt()` | accuracy-based by default | the field may change by `fraction` (0.02) of its range per step; `basis="resolution"` returns the cell-crossing time SLCN reports | | `solve(zero_init_guess, timestep, ...)` | same | | | `DuDt.set_initial_history(values, dt)` | same | plant an exact history to start at full order | | `restore_points_func`, `monotone_mode`, `old_frame_traceback`, `DFDt` | ignored, with a warning | they configure the trace-back | diff --git a/docs/developer/design/eulerian-supg-transport.md b/docs/developer/design/eulerian-supg-transport.md index 8b98803c5..ff894b4f2 100644 --- a/docs/developer/design/eulerian-supg-transport.md +++ b/docs/developer/design/eulerian-supg-transport.md @@ -1,8 +1,14 @@ # Eulerian SUPG transport: design and measurements **Status**: implemented on `feature/eulerian-supg-transport` (2026-09-02), static mesh. -Supersedes the Crank-Nicolson prototype of issue #657 as the implementation route -while keeping its weak-form idea. + +**Credit.** The SUPG weak form used here (the test-function perturbation written as +a flux, so PETSc needs no modified test space), its first working implementation on +PetscDS with P2 elements, the LeVeque swirling-flow comparison against SLCN and the +conservative level-set pipeline that motivated it are NengLu's, on the `levelset` +branch of issue #657. This note builds on that prototype: same formulation and +stabilisation parameter, time integration moved onto the symbolic history +machinery, and the measurements added. ## Why an Eulerian scheme @@ -210,12 +216,24 @@ BDF1 slopes 0.80 and 0.88 between $\Delta t$ = 0.02, 0.01, 0.005; BDF2 slopes ab ## What the timestep estimate means -`estimate_dt` returns the cell-crossing time, the same resolution estimate the -semi-Lagrangian solver reports, because that is the only quantity the mesh knows. -It is not a stability limit for either scheme. Choose the Eulerian timestep from -the transported feature: $|\mathbf{u}|\Delta t$ a fraction of its width. For SLCN -the honest limit is the trace-back arc, $\Delta t \lesssim 0.25 / \max|\nabla\mathbf{u}|$, -which is a separate change to that solver. +The cell-crossing time is not a stability limit for either scheme and says +nothing about this one's accuracy, so the Eulerian solver's `estimate_dt` measures +the field instead: + +$$ +\Delta t = f\,\frac{\max\phi - \min\phi}{\max|\dot\phi|}, +$$ + +with $\dot\phi$ the advective rate $|\mathbf{u}\cdot\nabla\phi|$ before the first +solve and the realised rate $|\phi^{n+1}-\phi^{n}|/\Delta t$ after it (diffusion +and sources included). On the rotating Gaussian the fraction at Courant 0.5 on +the res-32 mesh is about 0.03 (0.6% Crank-Nicolson error) and at Courant 1 about +0.07 (2.5%); the default $f = 0.02$ therefore sits at a few tenths of a per cent. +The estimate is mesh-independent by construction, which is the property the +transport note's section 1 asks for; `basis="resolution"` still returns the +semi-Lagrangian solver's cell-crossing time. For SLCN the honest limit is the +trace-back arc, $\Delta t \lesssim 0.25 / \max|\nabla\mathbf{u}|$, which is a separate +change to that solver. ## A defect found on the way diff --git a/docs/examples/convection/advanced/Ex_AdvectionDiffusionSUPG_RotationTest.py b/docs/examples/convection/advanced/Ex_AdvectionDiffusionSUPG_RotationTest.py index 5987ac3bc..61da93c87 100644 --- a/docs/examples/convection/advanced/Ex_AdvectionDiffusionSUPG_RotationTest.py +++ b/docs/examples/convection/advanced/Ex_AdvectionDiffusionSUPG_RotationTest.py @@ -27,9 +27,10 @@ error is measured directly rather than inferred from a picture. The scheme is stable at any cell Courant number; what limits the timestep -is how far the anomaly moves per step relative to its own width. Try -`-uw_courant 4` to see the accuracy fall off as `dt**2` while the solve -stays perfectly stable, and `-uw_order 2` to see the second-order scheme. +is how far the anomaly moves per step relative to its own width, which is +what the solver's own `estimate_dt` measures. Try `-uw_dt_fraction 0.1` to +see the accuracy fall off as `dt**2` while the solve stays perfectly +stable, and `-uw_order 2` for the damped second-order scheme. ## Key Concepts @@ -43,7 +44,7 @@ ## Parameters - `uw_res`: cells across the box -- `uw_courant`: timestep as a multiple of the cell-crossing time +- `uw_dt_fraction`: allowed change of the field per step (the timestep follows) - `uw_order`, `uw_theta`: the time scheme, with the semi-Lagrangian solver's meaning - `uw_diffusivity`: thermal diffusivity (0 is pure advection) """ @@ -60,14 +61,14 @@ Override from the command line: ```bash -python Ex_AdvectionDiffusionSUPG_RotationTest.py -uw_courant 4 -uw_order 2 +python Ex_AdvectionDiffusionSUPG_RotationTest.py -uw_dt_fraction 0.1 -uw_order 2 ``` """ # %% params = uw.Params( uw_res=32, - uw_courant=1.0, + uw_dt_fraction=0.02, # allowed change of T per step, as a fraction of its range uw_order=1, # 1 with theta 0.5 is Crank-Nicolson; 2 with theta 1.0 is BDF2 uw_theta=0.5, uw_diffusivity=0.0, @@ -113,16 +114,19 @@ """ ## Time loop -`estimate_dt` returns the cell-crossing time. It is a resolution guide, not a -stability limit, so the timestep is a chosen multiple of it. For a multistep -scheme the exact history is planted so the first step already runs at full -order. +`estimate_dt` returns an accuracy-based step: the field may change by +`uw_dt_fraction` of its range per step. It does not depend on the mesh; the +cell-crossing time the semi-Lagrangian solver reports is available with +`basis="resolution"` and is printed for comparison. For a multistep scheme the +exact history is planted so the first step already runs at full order. """ # %% period = float(exact.period) -dt_cell = float(adv_diff.estimate_dt()) -n_steps = int(np.ceil(period / (params.uw_courant * dt_cell))) +dt_accuracy = float(adv_diff.estimate_dt(fraction=params.uw_dt_fraction)) +dt_cell = float(adv_diff.estimate_dt(basis="resolution")) +uw.pprint(f"accuracy-based dt {dt_accuracy:.4g}, cell-crossing dt {dt_cell:.4g}") +n_steps = int(np.ceil(period / dt_accuracy)) dt = period / n_steps if params.uw_order > 1: @@ -142,9 +146,9 @@ """ ## Result -After one revolution the field should match its initial state. At a Courant -number of one half the round-trip error is below one per cent on this mesh; -it grows as `dt**2` from there. +After one revolution the field should match its initial state. At the +default fraction the round-trip error is a few tenths of a per cent on this +mesh; it grows as `dt**2` with the fraction. """ # %% diff --git a/src/underworld3/systems/advection_diffusion_eulerian.py b/src/underworld3/systems/advection_diffusion_eulerian.py index 476a0b5db..a6c3d36dc 100644 --- a/src/underworld3/systems/advection_diffusion_eulerian.py +++ b/src/underworld3/systems/advection_diffusion_eulerian.py @@ -18,6 +18,11 @@ and its accuracy is set by how far the transported feature moves in one step; the semi-Lagrangian scheme's accuracy is set by how far a characteristic turns in one step. See ``docs/developer/design/eulerian-supg-transport.md``. + +The SUPG weak form, the Petrov-Galerkin test-function perturbation written +as a flux so that PETSc needs no modified test space, and its first +implementation on PetscDS are NengLu's (issue #657, branch ``levelset``); +this module keeps that formulation and its stabilisation parameter. """ import warnings @@ -167,8 +172,10 @@ class SNES_AdvectionDiffusion_SUPG(SNES_Scalar): for a Stokes problem that the scalar does not need. Accuracy is set by how far the transported feature moves per step relative to its own width, as :math:`(\mathbf{u}\Delta t)^2` for the second-order schemes. - :meth:`estimate_dt` returns the cell-crossing time as a resolution guide, - exactly as the semi-Lagrangian solver does. Against that solver: the + :meth:`estimate_dt` therefore returns an accuracy-based step, the + allowed change of the field per step as a fraction of its range, and + only reports the cell-crossing time on request + (``basis="resolution"``). Against the semi-Lagrangian solver: the semi-Lagrangian error is flat in the timestep but accumulates one interpolation per step, and its limit is the arc a characteristic turns per step; the Eulerian solve costs four to six times less per step in @@ -271,6 +278,7 @@ def __init__( self._delta_t = public_expression( rf"\Delta t_{{{tag}}}", 1.0, "Eulerian advection-diffusion timestep") self._last_timestep = None + self._last_change_rate = None # SUPG on/off and the three tau weights are runtime constants: the # compiled kernels read them from PETSc's constants[] array. @@ -507,31 +515,99 @@ def _tau(self): # ------------------------------------------------------------------ @timing.routine_timer_decorator - def estimate_dt(self, direction_aware: bool = False, percentile: float = 0.0): - r"""Cell-crossing timestep, as a resolution guide. - - The minimum over cells of :math:`h/|\mathbf{u}|` and - :math:`h^2/\kappa`, exactly as for the semi-Lagrangian solver. It is - not a stability limit for this scheme, and on a mesh refined for - another problem it is far smaller than the timestep the transported - field needs. Choose the timestep from the feature being transported: - :math:`|\mathbf{u}|\Delta t` should be a fraction of its width. + def estimate_dt(self, fraction: float = 0.02, basis: str = "accuracy", + direction_aware: bool = False, percentile: float = 0.0): + r"""A timestep for this scheme, chosen for accuracy. + + The implicit scheme has no stability limit, so the cell-crossing time + the semi-Lagrangian solver reports says nothing about how large a step + this solver can take. What bounds the error is how much the field + changes per step, and that is what the default estimate measures: + + .. math:: + \Delta t = f\,\frac{\max\phi - \min\phi} + {\max\left|\dot\phi\right|} + + with :math:`\dot\phi` the rate of change of the field. Before the + first solve that rate is the advective one, :math:`|\mathbf{u}\cdot + \nabla\phi|` at the mesh vertices; after a solve it is the rate the + last step actually produced, :math:`|\phi^{n+1}-\phi^{n}|/\Delta t`, + which includes diffusion and sources. The estimate is independent of + the mesh, so a band of cells refined for another problem does not + shrink it; it does shrink for a feature that is genuinely + under-resolved, which is the honest answer. + + On the rotating Gaussian (``docs/developer/design/eulerian-supg-transport.md``) + ``fraction=0.02`` gives Crank-Nicolson a round-trip error of a few + tenths of a per cent after one revolution and BDF2 about 1.5%; + ``fraction=0.07`` gives 2.5% and 9%. Parameters ---------- - direction_aware : bool, default False - Use the per-cell extent along the local velocity. - percentile : float, default 0.0 - Global percentile of the per-cell timesteps instead of the minimum. + fraction : float, default 0.02 + Allowed change of the field per step as a fraction of its range. + basis : {"accuracy", "resolution"} + ``"resolution"`` returns the cell-crossing / diffusion time the + semi-Lagrangian solver's ``estimate_dt`` returns, for scripts that + size the step in Courant numbers. + direction_aware, percentile + Forwarded to the resolution estimate; ignored otherwise. + + Returns + ------- + pint.Quantity or float + With physical time units if a model with reference scales is + active, otherwise nondimensional. ``inf`` if nothing changes. """ - dt_estimate, dt_adv, dt_diff = _advective_diffusive_dt( - self.constitutive_model.K, self._V_fn, self.mesh, - direction_aware=direction_aware, percentile=percentile) - self.dt_adv = dt_adv if not np.isinf(dt_adv) else 0.0 - self.dt_diff = dt_diff if not np.isinf(dt_diff) else 0.0 - if np.isinf(dt_estimate): + from mpi4py import MPI + + if basis == "resolution": + dt_estimate, dt_adv, dt_diff = _advective_diffusive_dt( + self.constitutive_model.K, self._V_fn, self.mesh, + direction_aware=direction_aware, percentile=percentile) + self.dt_adv = dt_adv if not np.isinf(dt_adv) else 0.0 + self.dt_diff = dt_diff if not np.isinf(dt_diff) else 0.0 + if np.isinf(dt_estimate): + return np.inf + return _dimensionalise_dt(dt_estimate) + if basis != "accuracy": + raise ValueError(f"basis must be 'accuracy' or 'resolution', not {basis!r}.") + + comm = uw.mpi.comm + values = np.asarray(self.u.array).reshape(-1) + lo = comm.allreduce(float(values.min()) if values.size else np.inf, op=MPI.MIN) + hi = comm.allreduce(float(values.max()) if values.size else -np.inf, op=MPI.MAX) + field_range = hi - lo + + if self._last_change_rate is not None: + rate = self._last_change_rate + else: + rate = self._advective_rate() + self.dt_accuracy = fraction * field_range / rate if rate > 0.0 else np.inf + if np.isinf(self.dt_accuracy) or field_range <= 0.0: return np.inf - return _dimensionalise_dt(dt_estimate) + return _dimensionalise_dt(self.dt_accuracy) + + def _advective_rate(self): + r"""Global maximum of :math:`|\mathbf{u}\cdot\nabla\phi|` at the mesh vertices. + + The gradient is the Clement recovery at the vertices (no point + location, so it is safe on a mesh carrying many variables) and the + velocity is evaluated at the same points. + """ + from mpi4py import MPI + from underworld3.function.gradient_evaluation import compute_clement_gradient_at_nodes + + coords = np.asarray(self.mesh.X.coords) + n = coords.shape[0] + if n: + grad = np.asarray(compute_clement_gradient_at_nodes(self.u), dtype=float).reshape(n, -1) + vel = uw.function.evaluate(self._V_fn, coords) + vel = np.asarray(getattr(vel, "magnitude", vel), dtype=float).reshape(n, -1) + local = float(np.abs((vel[:, :grad.shape[1]] * grad).sum(axis=1)).max()) + else: + local = 0.0 + return uw.mpi.comm.allreduce(local, op=MPI.MAX) def solve( self, @@ -569,6 +645,13 @@ def solve( self.DuDt.update_pre_solve(dt, verbose=verbose) super().solve(zero_init_guess, _force_setup, divergence_retries=divergence_retries) _invalidate_solution_cache(self.u) + # The realised rate of change of the field over this step feeds the + # accuracy-based estimate_dt; psi_star[0] still holds phi^n here. + from mpi4py import MPI + change = np.abs(np.asarray(self.u.array).reshape(-1) + - np.asarray(self.DuDt.psi_star[0].array).reshape(-1)) + local = float(change.max()) if change.size else 0.0 + self._last_change_rate = uw.mpi.comm.allreduce(local, op=MPI.MAX) / dt self.DuDt.update_post_solve(dt, verbose=verbose) self.is_setup = True diff --git a/tests/test_1055_advdiff_supg_api.py b/tests/test_1055_advdiff_supg_api.py index 590dc2b61..69a8f6657 100644 --- a/tests/test_1055_advdiff_supg_api.py +++ b/tests/test_1055_advdiff_supg_api.py @@ -191,3 +191,30 @@ def metric(pts): assert adv._custom_mg is None data = np.asarray(T.array[:, 0, 0]) assert np.isfinite(data).all() and 0.9 < data.max() < 1.01 + + +def test_estimate_dt_is_accuracy_based_and_resolution_on_request(mesh): + """The default estimate follows the field, not the mesh; the resolution + basis reproduces the semi-Lagrangian solver's cell-crossing time.""" + adv, T = _solver(mesh, "t") + dt_acc = float(adv.estimate_dt()) + dt_res = float(adv.estimate_dt(basis="resolution")) + assert np.isfinite(dt_acc) and dt_acc > 0 and np.isfinite(dt_res) and dt_res > 0 + # a tighter fraction is a proportionally smaller step + assert float(adv.estimate_dt(fraction=0.01)) == pytest.approx(0.5 * dt_acc) + + x, y = mesh.X + T2 = uw.discretisation.MeshVariable("T_t2", mesh, 1, degree=2) + slcn = uw.systems.AdvDiffusionSLCN(mesh, T2, sympy.Matrix([[-y, x]])) + slcn.constitutive_model = uw.constitutive_models.DiffusionModel + slcn.constitutive_model.Parameters.diffusivity = 0.0 + assert dt_res == pytest.approx(float(slcn.estimate_dt()), rel=1e-12) + + # after a step the estimate uses the realised rate of change + adv.solve(timestep=dt_acc) + assert adv._last_change_rate > 0 + dt_after = float(adv.estimate_dt()) + assert np.isfinite(dt_after) and 0.2 * dt_acc < dt_after < 5 * dt_acc + + with pytest.raises(ValueError, match="basis must be"): + adv.estimate_dt(basis="courant") diff --git a/tests/test_1100_advdiff_supg_rotating_gaussian.py b/tests/test_1100_advdiff_supg_rotating_gaussian.py index 336ea74e4..4415bdc03 100644 --- a/tests/test_1100_advdiff_supg_rotating_gaussian.py +++ b/tests/test_1100_advdiff_supg_rotating_gaussian.py @@ -10,8 +10,8 @@ refined to h/8 across the orbit, at the same timestep, gives the same error to three digits even though its cells sit at a local Courant number of several; -3. the round trip: after one revolution the field returns to its initial - state to a few per cent at a Courant number of one half. +3. the round trip: at the solver's own accuracy-based timestep the field + returns to its initial state after one revolution to under one per cent. Run: pixi run python -m pytest tests/test_1100_advdiff_supg_rotating_gaussian.py -v """ @@ -108,14 +108,23 @@ def metric(pts, _f=fault, _hn=h / 8, _hf=h, _core=0.03, _ramp=0.06): err_band = _run(sol_c, T_c, adv_c, dt, t_end) # the band cells are at a local Courant number well above one - assert dt / float(adv_c.estimate_dt()) > 4.0 + assert dt / float(adv_c.estimate_dt(basis="resolution")) > 4.0 assert abs(err_band - err_uniform) < 0.15 * err_uniform, (err_uniform, err_band) + # the accuracy-based estimate follows the field, so the band does not + # shrink it, while the resolution estimate collapses with the cells + dt_acc_uniform = float(adv.estimate_dt()) + dt_acc_band = float(adv_c.estimate_dt()) + assert abs(dt_acc_band - dt_acc_uniform) < 0.25 * dt_acc_uniform, (dt_acc_uniform, dt_acc_band) + assert float(adv.estimate_dt(basis="resolution")) > 3.0 * float(adv_c.estimate_dt(basis="resolution")) -def test_round_trip_at_moderate_courant(): + +def test_round_trip_at_the_default_timestep(): + """The solver's own defaults: Crank-Nicolson at the accuracy-based step + (2% of the range per step). BDF2 at the same step lands near 1.5%.""" mesh = _box(32) - sol, T, adv = _problem(mesh, "r", 2) - err = _run(sol, T, adv, 0.5 * float(adv.estimate_dt()), float(sol.period)) - assert err < 0.03, err + sol, T, adv = _problem(mesh, "r", 1) + err = _run(sol, T, adv, float(adv.estimate_dt()), float(sol.period)) + assert err < 0.01, err data = np.asarray(T.array[:, 0, 0]) assert data.min() > -0.02 and data.max() < 1.02, (data.min(), data.max()) From a2a3e1288991adb3f3d76594037e5b1126ff5be9 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Wed, 2 Sep 2026 22:11:04 -0700 Subject: [PATCH 14/20] Level-set transport on the shared solver interface: one module, both transport solvers NengLu's conservative level set (issue #657): tanh-profile initialisation from a signed distance or a polygon/curve, the Parameswaran-Mandal reinitialisation integrated with SSP-RK3, the Zhang-Zou-Greaves global mass correction, and material properties blended across the interface. The two copies of that pipeline (one per transport solver) become uw.systems.LevelSetSolver(..., advection='supg'|'slcn') on the drop-in solver interface; the prototype SUPG solver and the structured-grid ENO reinitialisation that was already switched off are retired. Fixed on the way: the wrapper called the transport solver's solve() positionally after it became keyword-only, so the comparison script could not run; shapely is now an optional import with a clear message (signed_distance= needs none of it); deprecated data access replaced. The LeVeque swirling-flow comparison moves to the examples in the repository's script conventions; a rotation test checks both transport choices hold the volume, keep the profile sharp and bring the circle back. Underworld development team with AI support from Claude Code Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL --- docs/advanced/index.md | 1 + docs/advanced/level-set-transport.md | 68 ++ .../Ex_LevelSet_LeVeque_SUPG_vs_SLCN.py | 178 ++++ src/underworld3/systems/__init__.py | 5 +- src/underworld3/systems/level_set.py | 592 +++++++++++ src/underworld3/systems/level_set_SLCN.py | 939 ------------------ src/underworld3/systems/level_set_SUPG.py | 745 -------------- src/underworld3/systems/solver_supg.py | 829 ---------------- .../Ex_AdvectionDiffusion_1dBlock_slcn.py | 252 ----- ..._AdvectionDiffusion_1dBlock_supg_dcterm.py | 225 ----- .../test_tem/LeVeque_swirling_supg_vs_slcn.py | 343 ------- tests/test_1100_levelset_rotation.py | 81 ++ 12 files changed, 924 insertions(+), 3334 deletions(-) create mode 100644 docs/advanced/level-set-transport.md create mode 100644 docs/examples/convection/advanced/Ex_LevelSet_LeVeque_SUPG_vs_SLCN.py create mode 100644 src/underworld3/systems/level_set.py delete mode 100644 src/underworld3/systems/level_set_SLCN.py delete mode 100644 src/underworld3/systems/level_set_SUPG.py delete mode 100644 src/underworld3/systems/solver_supg.py delete mode 100644 src/underworld3/systems/test_tem/Ex_AdvectionDiffusion_1dBlock_slcn.py delete mode 100644 src/underworld3/systems/test_tem/Ex_AdvectionDiffusion_1dBlock_supg_dcterm.py delete mode 100644 src/underworld3/systems/test_tem/LeVeque_swirling_supg_vs_slcn.py create mode 100644 tests/test_1100_levelset_rotation.py diff --git a/docs/advanced/index.md b/docs/advanced/index.md index b569bf0a7..be59f652e 100644 --- a/docs/advanced/index.md +++ b/docs/advanced/index.md @@ -140,6 +140,7 @@ curved-boundary-conditions mesh-adaptation semi-lagrangian-time-integration eulerian-advection-diffusion +level-set-transport porous-flow snapshot-restore troubleshooting diff --git a/docs/advanced/level-set-transport.md b/docs/advanced/level-set-transport.md new file mode 100644 index 000000000..bf06c33bc --- /dev/null +++ b/docs/advanced/level-set-transport.md @@ -0,0 +1,68 @@ +# Conservative level sets + +`uw.systems.LevelSetSolver` carries a material interface as the 0.5 contour of a +smoothed indicator + +$$ +\psi = \tfrac12\left(1 + \tanh\frac{\varphi}{2\varepsilon}\right), +$$ + +where $\varphi$ is the signed distance to the interface (positive inside) and +$\varepsilon$ the interface thickness, a fraction of the local cell size. The +field is transported by an ordinary scalar solver; what makes it a level set is +what happens after each step: + +- **reinitialisation** restores the $\tanh$ profile without moving the 0.5 + contour (Parameswaran and Mandal 2023, integrated in pseudo-time with SSP-RK3); +- **mass correction** restores the enclosed volume by a uniform, clipped shift + found by bisection (Zhang, Zou and Greaves 2010). + +Neither depends on the transport scheme, so the solver takes either the Eulerian +SUPG solver (the default) or the semi-Lagrangian one. + +```python +from underworld3.systems import level_set + +psi = uw.discretisation.MeshVariable("psi", mesh, 1, degree=2) +eps = level_set.interface_thickness(mesh, psi, scale=0.35) +level_set.initialise_psi(psi, eps, interface_geometry="polygon", + interface_coordinates=circle_points) # or signed_distance=... + +ls = uw.systems.LevelSetSolver(psi, velocity=v.sym, epsilon=eps) # advection="slcn" to compare +for step in range(n_steps): + ls.solve(dt) # advect, reinitialise when due, restore the volume + +viscosity = level_set.material_property_field(psi.sym[0], [eta_outside, eta_inside], "geometric") +``` + +## Choices + +| argument | meaning | +|---|---| +| `advection` | `"supg"` (default) or `"slcn"`; both run pure advection | +| `order`, `theta` | the transport solver's time scheme; Crank-Nicolson by default, which preserves the profile's amplitude between reinitialisations | +| `reini_frequency`, `reini_steps`, `reini_dt` | how often, how many pseudo-time steps, and how long each is (half the smallest $\varepsilon$ by default) | +| `conserve_mass` | apply the global correction after every step | +| `adv_solver_bc` | box wall labels on which a zero normal gradient is imposed by copying the neighbouring interior nodes | + +`initialise_psi` accepts a precomputed signed distance, or a polygon, curve or +`shapely` geometry (the latter three need the optional `shapely` package). +`material_property_field` blends a property across one or more level sets with +a sharp, arithmetic, geometric or harmonic transition. + +## Which transport solver + +On the LeVeque swirling flow at 64 by 64 and Courant 0.5 (period 2), the SUPG +level set returns with a shape error of 0.028 against 0.051 for the +semi-Lagrangian one, at half the wall time; the mass correction pins both to +the same volume. The Eulerian solver's advantage is the same as for any scalar: +no interpolation loss per step, and cells refined for the Stokes problem cost +nothing. See {doc}`eulerian-advection-diffusion`. The example +`docs/examples/convection/advanced/Ex_LevelSet_LeVeque_SUPG_vs_SLCN.py` runs the +comparison. + +## Credit + +The level-set pipeline, its SUPG transport and the LeVeque comparison are +NengLu's contribution (issue #657); this module unifies the two variants of +that work on the shared solver interface. diff --git a/docs/examples/convection/advanced/Ex_LevelSet_LeVeque_SUPG_vs_SLCN.py b/docs/examples/convection/advanced/Ex_LevelSet_LeVeque_SUPG_vs_SLCN.py new file mode 100644 index 000000000..9eb482238 --- /dev/null +++ b/docs/examples/convection/advanced/Ex_LevelSet_LeVeque_SUPG_vs_SLCN.py @@ -0,0 +1,178 @@ +# --- +# jupyter: +# jupytext: +# formats: py:percent +# text_representation: +# extension: .py +# format_name: percent +# format_version: '1.3' +# kernelspec: +# display_name: Python 3 +# language: python +# name: python3 +# --- + +# %% [markdown] +""" +# Level set in the LeVeque swirling flow: SUPG against SLCN + +**PHYSICS:** convection +**DIFFICULTY:** advanced + +## Description + +The swirling deformation flow of LeVeque (1996), the standard stress test +for interface transport: a circle is stretched into a thin spiral filament +for half a period, then the flow reverses exactly and the circle should +come back. Any irreversible error, whether interpolation loss in a +trace-back or stabilisation diffusion, shows up as a failure to recover +the initial shape. + +The same conservative level set is carried by the two transport solvers, +Eulerian SUPG and semi-Lagrangian, under the same velocity and timestep, +each with its own reinitialisation and mass correction. The script reports +the shape error against the frozen initial field, the enclosed volume, and +the wall time of each. + +The stream function is + +$$\psi(x, y, t) = \frac{1}{\pi}\sin^2(\pi x)\,\sin^2(\pi y)\,\cos(\pi t / T)$$ + +with period `T`: 2 (LeVeque's own value, a gentle round trip) or 8 +(Enright et al. 2002, filaments thinner than the mesh). + +Contributed by NengLu (issue #657); converted to the repository's script +conventions. + +## Parameters + +- `uw_res`: cells across the unit square +- `uw_period`: reversal period `T` +- `uw_courant`: timestep as a multiple of the cell-crossing time +""" + +# %% +import os +import time + +import numpy as np +import sympy + +import underworld3 as uw +from underworld3.systems import level_set + +# %% +params = uw.Params( + uw_res=64, + uw_period=2.0, + uw_courant=0.5, + uw_reini_frequency=5, + uw_outdir="output/levelset_leveque", +) + +# %% [markdown] +""" +## Mesh and the time-dependent velocity +""" + +# %% +mesh = uw.meshing.StructuredQuadBox( + elementRes=(params.uw_res, params.uw_res), minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0)) +x, y = mesh.X + +v = uw.discretisation.MeshVariable("V", mesh, mesh.dim, degree=2) + +stream = (1 / sympy.pi) * sympy.sin(sympy.pi * x) ** 2 * sympy.sin(sympy.pi * y) ** 2 +u_x, u_y = -sympy.diff(stream, y), sympy.diff(stream, x) + + +def set_velocity(t): + modulation = float(np.cos(np.pi * t / params.uw_period)) + v.array[:, 0, 0] = modulation * uw.function.evaluate(u_x, v.coords).reshape(-1) + v.array[:, 0, 1] = modulation * uw.function.evaluate(u_y, v.coords).reshape(-1) + + +# %% [markdown] +""" +## Two level sets, one initial circle, one per solver +""" + +# %% +radius, centre = 0.15, (0.5, 0.75) +angles = np.linspace(0.0, 2.0 * np.pi, 91) +circle = np.column_stack((centre[0] + radius * np.cos(angles), centre[1] + radius * np.sin(angles))) + +solvers = {} +for name in ("supg", "slcn"): + psi = uw.discretisation.MeshVariable(f"psi_{name}", mesh, 1, degree=2) + eps = level_set.interface_thickness(mesh, psi, scale=0.35) + level_set.initialise_psi(psi, eps, interface_geometry="polygon", interface_coordinates=circle) + psi0 = uw.discretisation.MeshVariable(f"psi0_{name}", mesh, 1, degree=2) + psi0.array[...] = psi.array[...] + solver = uw.systems.LevelSetSolver( + psi, velocity=v.sym, epsilon=eps, advection=name, + reini_steps=1, reini_frequency=params.uw_reini_frequency) + solvers[name] = dict(psi=psi, psi0=psi0, solver=solver, wall=0.0) + + +def shape_error(psi, psi0): + return float(np.sqrt(max(uw.maths.Integral(mesh, (psi.sym[0] - psi0.sym[0]) ** 2).evaluate(), 0.0))) + + +# %% [markdown] +""" +## Time loop + +Both solvers take the same step, chosen as a multiple of the cell-crossing +time so the comparison is at equal Courant number. +""" + +# %% +dt = params.uw_courant / params.uw_res +n_steps = int(np.round(params.uw_period / dt)) +dt = params.uw_period / n_steps +report_every = max(1, n_steps // 16) +initial_area = np.pi * radius ** 2 + +t = 0.0 +for step in range(n_steps): + set_velocity(t) + for name, s in solvers.items(): + t0 = time.perf_counter() + s["solver"].solve(dt) + s["wall"] += time.perf_counter() - t0 + t += dt + if step % report_every == 0 or step == n_steps - 1: + for name, s in solvers.items(): + volume = s["solver"].interface_volume() + uw.pprint(f"t = {t:6.3f} {name}: volume drift {100 * (volume - initial_area) / initial_area:+.3f}% " + f"shape error {shape_error(s['psi'], s['psi0']):.3e} wall {s['wall']:.1f} s") + +# %% [markdown] +""" +## Round trip + +At `t = T` the flow has returned the fluid to where it started; the shape +error measures what the transport did not undo. +""" + +# %% +for name, s in solvers.items(): + uw.pprint(f"{name}: round-trip shape error {shape_error(s['psi'], s['psi0']):.3e}, " + f"total wall {s['wall']:.1f} s") + +# %% +if uw.mpi.size == 1: + import pyvista as pv + import underworld3.visualisation as vis + + pl = pv.Plotter(window_size=(900, 450), shape=(1, 2)) + for i, (name, s) in enumerate(solvers.items()): + pvmesh = vis.mesh_to_pv_mesh(mesh) + pvmesh.point_data["psi"] = vis.scalar_fn_to_pv_points(pvmesh, s["psi"].sym) + pl.subplot(0, i) + pl.add_mesh(pvmesh, scalars="psi", cmap="RdBu_r", clim=(0, 1), show_edges=False) + pl.add_mesh(pvmesh.contour([0.5], scalars="psi"), color="black", line_width=2) + pl.add_text(name, font_size=10) + os.makedirs(params.uw_outdir, exist_ok=True) + pl.show(cpos="xy", screenshot=os.path.join(params.uw_outdir, "leveque_round_trip.png")) diff --git a/src/underworld3/systems/__init__.py b/src/underworld3/systems/__init__.py index 6949c516f..3841861b1 100644 --- a/src/underworld3/systems/__init__.py +++ b/src/underworld3/systems/__init__.py @@ -21,6 +21,8 @@ Advection-diffusion with semi-Lagrangian transport. AdvDiffusionSUPG : class Advection-diffusion, implicit Eulerian with SUPG stabilisation. +LevelSetSolver : class + Conservative level-set transport (advection, reinitialisation, mass correction). NavierStokes : class Navier-Stokes equations with inertia. Diffusion : class @@ -95,4 +97,5 @@ from .yield_continuation import yield_continuation, YieldHomotopyControl from .solve_report import SolveReport -from .solver_supg import SNES_AdvectionDiffusion_SUPG as AdvDiffusionSUPG +from . import level_set +from .level_set import LevelSetSolver diff --git a/src/underworld3/systems/level_set.py b/src/underworld3/systems/level_set.py new file mode 100644 index 000000000..b1c41888f --- /dev/null +++ b/src/underworld3/systems/level_set.py @@ -0,0 +1,592 @@ +r"""Conservative level-set transport: advection, reinitialisation, mass correction. + +A conservative level set represents an interface by the 0.5 contour of a +smoothed indicator + +.. math:: + \psi = \tfrac12\left(1 + \tanh\frac{\varphi}{2\varepsilon}\right), + +with :math:`\varphi` the signed distance to the interface and +:math:`\varepsilon` the interface thickness. Transport of :math:`\psi` is +ordinary scalar advection; what makes it a level set is what happens between +steps: a reinitialisation that restores the :math:`\tanh` profile without +moving the 0.5 contour, and a global correction that restores the enclosed +volume. Both are post-step operations on the field, so any scalar transport +solver can carry it. + +:class:`LevelSetSolver` takes the Eulerian SUPG solver +(:class:`~underworld3.systems.AdvDiffusionSUPG`) by default and the +semi-Lagrangian solver on request. The reinitialisation equation is that of +Parameswaran and Mandal (2023), integrated in pseudo-time with SSP-RK3 +(Gottlieb and Shu 1998); the mass correction is the uniform shift of Zhang, +Zou and Greaves (2010). The signed-distance helpers accept a polygon or a +curve (they use ``shapely``, an optional dependency) or a precomputed +distance array. :func:`material_property_field` blends material properties +across the interface, in the manner of g-adopt's ``field_interface``. + +The level-set pipeline, the SUPG transport it drove and the LeVeque +swirling-flow comparison are NengLu's (issue #657, branch ``levelset``); +this module unifies the two variants of that work on one solver interface. + +References +---------- +Parameswaran, S. and Mandal, J. C. (2023). A stable interface-preserving +reinitialization equation for conservative level set method. European +Journal of Mechanics B/Fluids, 98, 40-63. + +Zhang, Y., Zou, Q. and Greaves, D. (2010). Numerical simulation of +free-surface flow using the level-set method with global mass correction. +International Journal for Numerical Methods in Fluids, 63, 651-680. +""" + +import warnings +from typing import Optional + +import numpy as np +import sympy + +import underworld3 as uw +from underworld3 import discretisation, systems + + +# --------------------------------------------------------------------------- +# Initial condition +# --------------------------------------------------------------------------- + +def _tanh_profile(distance, epsilon): + r"""The conservative level-set profile :math:`\tfrac12(1 + \tanh(\varphi/2\varepsilon))`.""" + return (1.0 + np.tanh(np.asarray(distance) / (2.0 * np.asarray(epsilon)))) / 2.0 + + +def _shapely(): + """The optional ``shapely`` dependency, or a clear error.""" + try: + from shapely import geometry + from shapely import prepare + except ImportError as exc: + raise ImportError( + "The level-set geometry helpers need the optional package " + "'shapely' (pip install shapely). Pass signed_distance= to " + "initialise_psi to avoid it." + ) from exc + return geometry, prepare + + +def initialise_psi( + psi: discretisation.MeshVariable, + epsilon, + *, + signed_distance=None, + interface_geometry: Optional[str] = None, + interface=None, + interface_coordinates=None, + boundary_coordinates=None, +) -> None: + r"""Fill ``psi`` with the conservative level-set profile of an interface. + + Parameters + ---------- + psi : MeshVariable + Scalar field to fill; 1 inside the interface, 0 outside, 0.5 on it. + epsilon : MeshVariable or float + Interface thickness (see :func:`interface_thickness`). + signed_distance : ndarray, optional + Precomputed signed distance at ``psi``'s nodes, positive inside. + When given, the geometry arguments are ignored and ``shapely`` is + not needed. + interface_geometry : {"curve", "polygon", "shapely"}, optional + How the interface is described. + interface : shapely LineString or Polygon, optional + For ``interface_geometry="shapely"``. + interface_coordinates : sequence of (x, y), optional + Vertices of the curve or polygon. + boundary_coordinates : sequence of (x, y), optional + Extra vertices that close an open curve into the polygon that + defines the inside. + """ + eps = epsilon.array[:, 0, 0] if hasattr(epsilon, "array") else float(epsilon) + if signed_distance is not None: + psi.array[:, 0, 0] = _tanh_profile(signed_distance, eps) + return + if interface_geometry is None: + raise ValueError("Provide either signed_distance or interface_geometry.") + if interface_coordinates is None and interface_geometry != "shapely": + raise ValueError( + f"interface_coordinates is required for interface_geometry={interface_geometry!r}.") + distance = _signed_distance_from_geometry( + interface_geometry, interface, interface_coordinates, boundary_coordinates, + np.asarray(psi.coords)) + psi.array[:, 0, 0] = _tanh_profile(distance, eps) + + +def interface_thickness( + mesh: discretisation.Mesh, + phi: discretisation.MeshVariable, + *, + scale: float = 0.35, + use_min_edge_length: bool = False, +) -> discretisation.MeshVariable: + r"""Interface thickness :math:`\varepsilon` from the local cell size. + + :math:`\varepsilon = \mathrm{scale}\cdot V^{1/d}/\sqrt{d}` per cell + (or ``scale`` times the shortest edge), carried to ``phi``'s nodes from + the nearest cell centroid. Returned as a scalar MeshVariable of the same + degree as ``phi``. + """ + from scipy.spatial import cKDTree + + dm = mesh.dm + dim = mesh.dim + c_start, c_end = dm.getHeightStratum(0) + n_cells = c_end - c_start + cell_epsilon = np.empty(n_cells) + cell_centroids = np.empty((n_cells, dim)) + + if use_min_edge_length: + if mesh.qdegree > 1: + raise ValueError("use_min_edge_length=True needs a straight-edged mesh (qdegree=1).") + v_start, v_end = dm.getDepthStratum(0) + coords = np.asarray(mesh.X.coords) + for i, cell in enumerate(range(c_start, c_end)): + closure, _ = dm.getTransitiveClosure(cell) + verts = [p - v_start for p in closure if v_start <= p < v_end] + v_coords = coords[verts] + edges = [np.linalg.norm(v_coords[a] - v_coords[b]) + for a in range(len(verts)) for b in range(a + 1, len(verts))] + cell_epsilon[i] = scale * min(edges) + cell_centroids[i, :] = v_coords.mean(axis=0) + else: + factor = scale / np.sqrt(dim) + for i, cell in enumerate(range(c_start, c_end)): + vol, centroid, _ = dm.computeCellGeometryFVM(cell) + cell_epsilon[i] = factor * float(np.asarray(vol).ravel()[0]) ** (1.0 / dim) + cell_centroids[i, :] = np.asarray(centroid).ravel()[:dim] + + epsilon = discretisation.MeshVariable( + r"\epsilon", mesh, 1, degree=phi.degree, continuous=phi.continuous) + _, nearest = cKDTree(cell_centroids).query(np.asarray(phi.coords)) + epsilon.array[:, 0, 0] = cell_epsilon[nearest] + return epsilon + + +def _signed_distance_closed(polygon, points): + """Positive inside a closed polygon, negative outside.""" + geometry, prepare = _shapely() + prepare(polygon) + boundary = polygon.boundary + inside = np.array([polygon.contains(geometry.Point(p)) for p in points]) + distance = np.array([boundary.distance(geometry.Point(p)) for p in points]) + return np.where(inside, distance, -distance) + + +def _signed_distance_open(curve, enclosed, points): + """Distance to an open curve, signed by the polygon that defines the inside.""" + geometry, prepare = _shapely() + prepare(enclosed) + inside = np.array([enclosed.intersects(geometry.Point(p)) for p in points]) + distance = np.array([curve.distance(geometry.Point(p)) for p in points]) + return np.where(inside, distance, -distance) + + +def _signed_distance_from_geometry(interface_geometry, interface, interface_coordinates, + boundary_coordinates, points): + geometry, _prepare = _shapely() + + def closed_from(coords): + if boundary_coordinates is not None: + raise ValueError("boundary_coordinates is only for an open interface.") + return _signed_distance_closed(geometry.Polygon(coords), points) + + def open_from(curve, coords): + if boundary_coordinates is None: + raise ValueError("boundary_coordinates must close an open interface.") + enclosed = geometry.Polygon(np.vstack((coords, boundary_coordinates))) + return _signed_distance_open(curve, enclosed, points) + + if interface_geometry == "curve": + curve = geometry.LineString(interface_coordinates) + return closed_from(interface_coordinates) if curve.is_closed else open_from(curve, interface_coordinates) + if interface_geometry == "polygon": + if boundary_coordinates is None: + return _signed_distance_closed(geometry.Polygon(interface_coordinates), points) + return open_from(geometry.LineString(interface_coordinates), interface_coordinates) + if interface_geometry == "shapely": + if interface is None: + raise ValueError("interface must be given for interface_geometry='shapely'.") + if isinstance(interface, geometry.Polygon): + return _signed_distance_closed(interface, points) + return open_from(interface, np.asarray(interface.coords)) + raise ValueError( + f"Unknown interface_geometry={interface_geometry!r}; choose 'curve', 'polygon' or 'shapely'.") + + +# --------------------------------------------------------------------------- +# The solver +# --------------------------------------------------------------------------- + +class LevelSetSolver: + r"""Conservative level-set transport on a scalar transport solver. + + Each call to :meth:`solve` advects the level set by one step, applies + the optional wall correction, reinitialises when due, and restores the + enclosed volume. + + **Reinitialisation** integrates, in pseudo-time :math:`\tau`, + + .. math:: + \frac{\partial\psi}{\partial\tau} + = -\psi(1-\psi)(1-2\psi) + \varepsilon(1-2\psi)|\nabla\psi| + + (Parameswaran and Mandal 2023, eq. 17) with SSP-RK3. Both terms carry the + factor :math:`(1-2\psi)`, so :math:`\psi = 0.5` is a fixed point: the + profile sharpens to width :math:`\varepsilon` without moving the + interface. :math:`|\nabla\psi|` is an :math:`L_2` projection onto the + mesh at each stage. + + **Mass correction** finds the uniform shift :math:`\delta` with + :math:`\int \mathrm{clip}(\psi + \delta, 0, 1)\,d\Omega` equal to the + initial enclosed volume, by bisection (the map is monotone), and leaves + the field in that clipped, shifted state (Zhang, Zou and Greaves 2010). + + Parameters + ---------- + level_set : MeshVariable + Continuous scalar field holding :math:`\psi`. + velocity : MeshVariable or sympy Matrix + Advecting velocity. + epsilon : MeshVariable + Interface thickness (:func:`interface_thickness`). + advection : {"supg", "slcn"}, default "supg" + The transport solver: the Eulerian SUPG solver or the semi-Lagrangian + one. Both are pure advection here. + order, theta : int, float + Time scheme of the transport solver (Crank-Nicolson by default, the + same meaning for both solvers). + reini_dt : float, optional + Pseudo-time step of the reinitialisation (default half the smallest + :math:`\varepsilon`). + reini_steps : int, default 5 + Pseudo-time steps per reinitialisation. + reini_frequency : int, optional + Advection steps between reinitialisations; by default from the + domain size and :math:`\varepsilon`. + adv_solver_opts : dict, optional + PETSc options forwarded to the transport solver. + adv_solver_bc : sequence of str, optional + Box wall labels on which a zero normal gradient is imposed after + each step by copying the neighbouring interior nodes (a box-mesh + convenience). + conserve_mass : bool, default True + Apply the global mass correction after each step. + mass_correction_tol, mass_correction_max_iter + Bisection tolerance on the volume and iteration cap. + + Examples + -------- + >>> mesh = uw.meshing.UnstructuredSimplexBox(cellSize=1 / 32) + >>> psi = uw.discretisation.MeshVariable("psi", mesh, 1, degree=2) + >>> eps = uw.systems.level_set.interface_thickness(mesh, psi) + >>> uw.systems.level_set.initialise_psi(psi, eps, interface_geometry="polygon", + ... interface_coordinates=circle_points) + >>> ls = uw.systems.LevelSetSolver(psi, velocity=v.sym, epsilon=eps) + >>> for step in range(100): + ... ls.solve(dt) + """ + + def __init__( + self, + level_set: discretisation.MeshVariable, + *, + velocity, + epsilon: discretisation.MeshVariable, + advection: str = "supg", + order: int = 1, + theta: float = 0.5, + reini_dt: Optional[float] = None, + reini_steps: int = 5, + reini_frequency: Optional[int] = None, + adv_solver_opts: Optional[dict] = None, + adv_solver_bc=None, + conserve_mass: bool = True, + mass_correction_tol: float = 1.0e-10, + mass_correction_max_iter: int = 40, + ) -> None: + if level_set.num_components != 1: + raise ValueError("level_set must be a scalar MeshVariable.") + if not level_set.continuous: + raise ValueError("level_set must be a continuous MeshVariable.") + if advection not in ("supg", "slcn"): + raise ValueError(f"advection must be 'supg' or 'slcn', not {advection!r}.") + + self.phi = level_set + self.mesh = level_set.mesh + self.velocity = velocity.sym if isinstance(velocity, discretisation.MeshVariable) else velocity + self.epsilon = epsilon + self.advection = advection + self.reini_dt = float(reini_dt) if reini_dt is not None else 0.5 * self._global_min_epsilon() + self.reini_steps = int(reini_steps) + self.step = 0 + + if advection == "supg": + self._adv_solver = systems.AdvDiffusionSUPG( + self.mesh, self.phi, self.velocity, order=order, theta=theta) + else: + history = systems.ddt.SemiLagrangian( + self.mesh, self.phi.sym, self.velocity, + vtype=uw.VarType.SCALAR, degree=self.phi.degree, continuous=self.phi.continuous, + varsymbol="cphi", bcs=[], order=order, smoothing=0.0, + monotone_mode="clamp", theta=theta) + self._adv_solver = systems.AdvDiffusionSLCN( + self.mesh, u_Field=self.phi, V_fn=self.velocity, order=order, + DuDt=history, theta=theta) + self._adv_solver.constitutive_model = uw.constitutive_models.DiffusionModel + self._adv_solver.constitutive_model.Parameters.diffusivity = 0.0 + self._adv_solver_bc = adv_solver_bc + for key, value in (adv_solver_opts or {}).items(): + self._adv_solver.petsc_options[key] = value + + # |grad psi| for the reinitialisation, projected onto the mesh + self._grad_magnitude = sympy.sqrt(sum(g ** 2 for g in self.mesh.vector.gradient(self.phi.sym[0]))) + self.phi_grad = discretisation.MeshVariable( + r"|\nabla\psi|", self.mesh, 1, degree=self.phi.degree, continuous=self.phi.continuous) + self._grad_projector = systems.Projection(self.mesh, self.phi_grad, degree=self.phi.degree) + self._grad_projector.uw_function = self._grad_magnitude + + self._reini_frequency = int(reini_frequency) if reini_frequency is not None else self._default_frequency() + + self.conserve_mass = conserve_mass + self._mass_correction_tol = float(mass_correction_tol) + self._mass_correction_max_iter = int(mass_correction_max_iter) + self._target_volume = self.interface_volume() if conserve_mass else None + + # ------------------------------------------------------------------ + # Public interface + # ------------------------------------------------------------------ + + @property + def advection_solver(self): + """The transport solver carrying the level set.""" + return self._adv_solver + + @property + def reini_frequency(self) -> int: + """Advection steps between reinitialisations.""" + return self._reini_frequency + + def estimate_dt(self, **kwargs): + """The transport solver's timestep estimate (see its ``estimate_dt``).""" + return self._adv_solver.estimate_dt(**kwargs) + + def solve(self, dt: float, *, reinitialise: bool = True) -> None: + """Advance the level set by one step of size ``dt``.""" + self._adv_solver.solve(timestep=dt) + if self._adv_solver_bc: + self._apply_boundary_neumann(labels=self._adv_solver_bc) + self.step += 1 + + if reinitialise and self.step % self._reini_frequency == 0: + self.reinitialise() + if self._adv_solver_bc: + self._apply_boundary_neumann(labels=self._adv_solver_bc) + + if self.conserve_mass: + self._correct_mass(self._target_volume) + + def reinitialise(self) -> None: + """Run ``reini_steps`` SSP-RK3 pseudo-time steps of the reinitialisation equation.""" + for _ in range(self.reini_steps): + self._reini_ssprk3_step(self.reini_dt) + + def interface_volume(self) -> float: + r"""The enclosed volume :math:`\int\psi\,d\Omega`.""" + return uw.maths.Integral(self.mesh, self.phi.sym[0]).evaluate() + + def clamp(self, lo: float = 0.0, hi: float = 1.0) -> None: + """Clip the field to ``[lo, hi]`` in place. Not mass-conserving on its own.""" + self.phi.array[:, 0, 0] = np.clip(self.phi.array[:, 0, 0], lo, hi) + + # ------------------------------------------------------------------ + # Reinitialisation + # ------------------------------------------------------------------ + + def _rhs(self, values: np.ndarray) -> np.ndarray: + """The right-hand side of the reinitialisation equation at nodal values.""" + self.phi.array[:, 0, 0] = values + self._grad_projector.uw_function = self._grad_magnitude + self._grad_projector.solve() + grad = np.asarray(self.phi_grad.array[:, 0, 0]) + eps = np.asarray(self.epsilon.array[:, 0, 0]) + sharpening = -values * (1.0 - values) * (1.0 - 2.0 * values) + balance = eps * (1.0 - 2.0 * values) * grad + return sharpening + balance + + def _reini_ssprk3_step(self, dtau: float) -> None: + psi0 = np.array(self.phi.array[:, 0, 0]) + psi1 = psi0 + dtau * self._rhs(psi0) + psi2 = 0.75 * psi0 + 0.25 * psi1 + 0.25 * dtau * self._rhs(psi1) + self.phi.array[:, 0, 0] = psi0 / 3.0 + 2.0 * psi2 / 3.0 + 2.0 * dtau * self._rhs(psi2) / 3.0 + + def _global_min_epsilon(self) -> float: + from mpi4py import MPI + values = np.asarray(self.epsilon.array[:, 0, 0]) + local = float(values.min()) if values.size else np.inf + return uw.mpi.comm.allreduce(local, op=MPI.MIN) + + def _default_frequency(self) -> int: + """Reinitialise every step on a coarse mesh, less often as it refines.""" + from mpi4py import MPI + coords = np.asarray(self.mesh.X.coords) + dim = coords.shape[1] + hi = np.array([uw.mpi.comm.allreduce(float(coords[:, i].max()) if len(coords) else -np.inf, op=MPI.MAX) + for i in range(dim)]) + lo = np.array([uw.mpi.comm.allreduce(float(coords[:, i].min()) if len(coords) else np.inf, op=MPI.MIN) + for i in range(dim)]) + domain_size = float(np.sqrt(np.sum((hi - lo) ** 2))) + return max(1, round(4.9e-3 * domain_size / self._global_min_epsilon() - 0.25)) + + # ------------------------------------------------------------------ + # Wall correction (box meshes) + # ------------------------------------------------------------------ + + def _apply_boundary_neumann(self, labels=("Left", "Right", "Top", "Bottom")) -> None: + """Zero normal gradient on box walls: copy the neighbouring interior row or column.""" + from mpi4py import MPI + comm = uw.mpi.comm + + coords = np.asarray(self.phi.coords) + n_local = coords.shape[0] + axis_for_label = {"Left": 0, "Right": 0, "Top": 1, "Bottom": 1} + is_min_side = {"Left": True, "Right": False, "Top": False, "Bottom": True} + + for label in labels: + axis = axis_for_label[label] + tang = 1 - axis + op = MPI.MIN if is_min_side[label] else MPI.MAX + if n_local: + local_extreme = coords[:, axis].min() if is_min_side[label] else coords[:, axis].max() + else: + local_extreme = np.inf if is_min_side[label] else -np.inf + wall_val = comm.allreduce(float(local_extreme), op=op) + + local_axis_vals = np.unique(coords[:, axis]) if n_local else np.empty(0) + all_axis_vals = np.unique(np.concatenate(comm.allgather(local_axis_vals))) + if all_axis_vals.size < 2: + continue + inner_val = all_axis_vals[np.argsort(np.abs(all_axis_vals - wall_val))][1] + + inner_idx = np.where(np.isclose(coords[:, axis], inner_val, atol=1e-8))[0] if n_local else np.empty(0, dtype=int) + local_pairs = (np.column_stack((coords[inner_idx, tang], np.asarray(self.phi.array[inner_idx, 0, 0]))) + if len(inner_idx) else np.empty((0, 2))) + gathered = [p for p in comm.allgather(local_pairs) if p.shape[0] > 0] + if not gathered: + continue + table = np.vstack(gathered) + table = table[np.argsort(table[:, 0])] + table = table[np.concatenate(([True], np.diff(table[:, 0]) > 1e-10))] + + wall_idx = np.where(np.isclose(coords[:, axis], wall_val, atol=1e-8))[0] if n_local else np.empty(0, dtype=int) + if len(wall_idx) == 0: + continue + wall_tang = coords[wall_idx, tang] + pos = np.clip(np.searchsorted(table[:, 0], wall_tang), 1, len(table) - 1) + left_err = np.abs(wall_tang - table[pos - 1, 0]) + right_err = np.abs(table[pos, 0] - wall_tang) + nearest = np.where(right_err < left_err, pos, pos - 1) + good = np.minimum(left_err, right_err) <= 1e-6 + if not np.all(good): + warnings.warn( + f"Wall correction on {label!r}: {np.count_nonzero(~good)} wall node(s) " + "have no interior counterpart; left unchanged.", stacklevel=2) + self.phi.array[wall_idx[good], 0, 0] = table[nearest[good], 1] + + # ------------------------------------------------------------------ + # Mass correction + # ------------------------------------------------------------------ + + def _correct_mass(self, target: float, lo: float = 0.0, hi: float = 1.0) -> None: + """Uniform shift, clipped, restoring the enclosed volume to ``target``.""" + data0 = np.array(self.phi.array[:, 0, 0]) + + def volume_for_shift(delta: float) -> float: + self.phi.array[:, 0, 0] = np.clip(data0 + delta, lo, hi) + return self.interface_volume() + + v0 = volume_for_shift(0.0) + if abs(v0 - target) < self._mass_correction_tol: + return + + span = hi - lo + if v0 < target: + lo_d, hi_d = 0.0, max(span * 1.0e-3, 1.0e-8) + tries = 0 + while volume_for_shift(hi_d) < target and tries < 30: + hi_d *= 2.0 + tries += 1 + else: + lo_d, hi_d = -max(span * 1.0e-3, 1.0e-8), 0.0 + tries = 0 + while volume_for_shift(lo_d) > target and tries < 30: + lo_d *= 2.0 + tries += 1 + + if not (volume_for_shift(lo_d) <= target <= volume_for_shift(hi_d)): + warnings.warn( + f"Mass correction could not bracket the target volume {target:.6g}; " + "the field is probably pinned at its bounds everywhere.", stacklevel=2) + return + + mid = 0.0 + for _ in range(self._mass_correction_max_iter): + mid = 0.5 * (lo_d + hi_d) + vmid = volume_for_shift(mid) + if abs(vmid - target) < self._mass_correction_tol: + break + if vmid < target: + lo_d = mid + else: + hi_d = mid + volume_for_shift(mid) + + +# --------------------------------------------------------------------------- +# Material properties across the interface +# --------------------------------------------------------------------------- + +def material_property_field(level_set, field_values, interface: str): + r"""A material property blended across one or more level sets. + + Parameters + ---------- + level_set : sympy expression or list of them + The level-set field(s), e.g. ``psi.sym[0]``; with several, the last + is the innermost. + field_values : list of float + One value per material, innermost last. + interface : {"sharp", "sharp_adjoint", "arithmetic", "geometric", "harmonic"} + How the property crosses the interface. + """ + kinds = ("sharp", "sharp_adjoint", "arithmetic", "geometric", "harmonic") + if interface not in kinds: + raise ValueError(f"interface must be one of {kinds}, not {interface!r}.") + + level_sets = list(level_set) if isinstance(level_set, (list, tuple)) else [level_set] + values = list(field_values) + + result = None + while level_sets: + ls = sympy.Max(sympy.Min(level_sets.pop(), 1), 0) + value = values.pop() + other = values.pop() if not level_sets else result + if interface == "sharp": + result = sympy.Piecewise((value, ls > sympy.Rational(1, 2)), (other, True)) + elif interface == "sharp_adjoint": + shifted = ls - sympy.Rational(1, 2) + heaviside = (shifted + sympy.Abs(shifted)) / 2 / shifted + result = value * heaviside + other * (1 - heaviside) + elif interface == "arithmetic": + result = value * ls + other * (1 - ls) + elif interface == "geometric": + result = value ** ls * other ** (1 - ls) + else: + result = 1 / (ls / value + (1 - ls) / other) + return result diff --git a/src/underworld3/systems/level_set_SLCN.py b/src/underworld3/systems/level_set_SLCN.py deleted file mode 100644 index b19b07d1d..000000000 --- a/src/underworld3/systems/level_set_SLCN.py +++ /dev/null @@ -1,939 +0,0 @@ -import numpy as np -from typing import Optional -import warnings -import sympy - -from petsc4py import PETSc # NOTE: added -- _apply_boundary_neumann uses - # PETSc.COMM_WORLD but this file had no - # module-level PETSc import at all (a latent - # NameError-on-call bug in the previous - # version, unrelated to the solver rewrite). - -import underworld3 as uw -from underworld3 import discretisation, systems -from underworld3.utilities._api_tools import Template -from typing import Optional - -from shapely import geometry as sl -from shapely import prepare as _shapely_prepare - -def initialise_psi( - psi, - epsilon, - signed_distance: np.ndarray | None = None, - interface_geometry: str | None = None, - interface: sl.LineString | sl.Polygon | None = None, - interface_coordinates = None, - boundary_coordinates = None, -) -> None: - """ - Fill a UW3 MeshVariable *psi* with conservative level-set (CLS) values: - - psi = ( 1 + tanh( phi / (2 * epsilon) ) ) / 2 - - where *phi* is the signed-distance function (positive on the '1-side'). - - Parameters - ---------- - psi : uw.discretisation.MeshVariable - Target level-set field. - epsilon : float or ndarray, shape (n_nodes,) - Interface thickness. Use ``interface_thickness()`` to compute it. - - Keyword-only - ------------ - signed_distance : ndarray, shape (n_nodes,), optional - Pre-computed signed distances. If supplied, all geometry arguments - are ignored and the CLS field is written immediately. - - interface_geometry : {'curve', 'polygon', 'circle', 'shapely'} - How the interface is described (ignored when *signed_distance* is given). - - interface : shapely.LineString or shapely.Polygon, optional - Required when ``interface_geometry='shapely'``. - - interface_coordinates : list of (x, y) or ((cx, cy), radius) - Vertex list for 'curve'/'polygon', or (centre, radius) for 'circle'. - - boundary_coordinates : list of (x, y), optional - Extra boundary points used to close an open interface into a polygon - that defines the '1-side'. - - Notes - ----- - * ``psi → 1`` inside the interface (positive signed distance) - * ``psi = 0.5`` on the interface - * ``psi → 0`` outside the interface (negative signed distance) - - References - ---------- - Parameswaran & Mandal (2023), Eur. J. Mech.-B/Fluids, 98, 40-63. - g-ADOPT ``assign_level_set_values``: - https://github.com/g-adopt/g-adopt/blob/main/gadopt/level_set_tools.py - """ - - if signed_distance is not None: - psi.data[:, 0] = _tanh_profile(signed_distance, epsilon) - return - - if interface_geometry is None: - raise ValueError( - "Provide either 'signed_distance' or 'interface_geometry'." - ) - - if interface_coordinates is None and interface_geometry != "shapely": - raise ValueError( - "'interface_coordinates' is required when " - f"interface_geometry='{interface_geometry}'." - ) - - points = psi.coords # shape (n_nodes, dim) - signed_distance = _signed_distance_from_geometry( - interface_geometry, - interface, - interface_coordinates, - boundary_coordinates, - points,) - epsilon_data = epsilon.data[:,0] - psi.data[:, 0] = _tanh_profile(signed_distance, epsilon_data) - - -def interface_thickness( - mesh: uw.discretisation.Mesh, - phi: uw.discretisation.MeshVariable, - *, - scale: float = 0.35, - use_min_edge_length: bool = False, -) -> uw.discretisation.MeshVariable: - """Compute a spatially-varying interface thickness ε on the same mesh and - degree as *phi*, returned as a scalar ``MeshVariable``. - """ - if use_min_edge_length and mesh.qdegree > 1: - raise ValueError( - "use_min_edge_length=True is only valid for straight-edged meshes " - "(qdegree=1)." - ) - - from scipy.spatial import cKDTree - - dm = mesh.dm - dim = mesh.dim - c_start, c_end = dm.getHeightStratum(0) # cell range in the DMPlex - n_cells = c_end - c_start - cell_epsilon = np.empty(n_cells, dtype=float) - cell_centroids = np.empty((n_cells, dim), dtype=float) - - if not use_min_edge_length: - scale_factor = scale / np.sqrt(dim) - for i, cell in enumerate(range(c_start, c_end)): - vol, centroid, _ = dm.computeCellGeometryFVM(cell) - cell_epsilon[i] = scale_factor * float(np.asarray(vol).ravel()[0]) ** (1.0 / dim) - cell_centroids[i, :] = np.asarray(centroid).ravel()[:dim] - else: - v_start, v_end = dm.getDepthStratum(0) - coords = mesh.data # (n_vertices, dim) - for i, cell in enumerate(range(c_start, c_end)): - closure, _ = dm.getTransitiveClosure(cell) - verts = [p for p in closure if v_start <= p < v_end] - v_coords = coords[[p - v_start for p in verts]] - # minimum pairwise edge length - min_edge = np.inf - for a in range(len(v_coords)): - for b in range(a + 1, len(v_coords)): - d = np.linalg.norm(v_coords[a] - v_coords[b]) - if d < min_edge: - min_edge = d - cell_epsilon[i] = scale * min_edge - cell_centroids[i, :] = v_coords.mean(axis=0) - - epsilon_var = uw.discretisation.MeshVariable( - r"\epsilon", mesh, 1, degree=phi.degree,continuous=phi.continuous - ) - node_coords = phi.coords # (n_nodes, dim) - tree = cKDTree(cell_centroids) - _, nearest = tree.query(node_coords) # nearest[i] = cell index for node i - - epsilon_var.data[:, 0] = cell_epsilon[nearest] - return epsilon_var - - -def _sgn_dist_closed(interface: sl.Polygon, points: np.ndarray) -> np.ndarray: - """Signed distance: positive inside, negative outside a closed polygon.""" - _shapely_prepare(interface) - boundary = interface.boundary - sgn = np.where( - [interface.contains(sl.Point(p)) for p in points], 1.0, -1.0 - ) - dist = np.array([boundary.distance(sl.Point(p)) for p in points]) - return sgn * dist - -def _sgn_dist_open( - interface: sl.LineString, - enclosed_side: sl.Polygon, - points: np.ndarray, -) -> np.ndarray: - """Signed distance w.r.t. an open interface; sign from enclosed polygon.""" - _shapely_prepare(enclosed_side) - sgn = np.where( - [enclosed_side.intersects(sl.Point(p)) for p in points], 1.0, -1.0 - ) - dist = np.array([interface.distance(sl.Point(p)) for p in points]) - return sgn * dist - -def _tanh_profile(phi: np.ndarray, epsilon: float | np.ndarray) -> np.ndarray: - """CLS tanh profile: (1 + tanh(phi / 2ε)) / 2""" - return (1.0 + np.tanh(np.asarray(phi) / (2.0 * np.asarray(epsilon)))) / 2.0 - -def _signed_distance_from_geometry( - interface_geometry: str, - interface, - interface_coordinates, - boundary_coordinates, - points: np.ndarray, -) -> np.ndarray: - """Dispatch to the correct signed-distance routine based on geometry type.""" - - match interface_geometry: - - case "curve": - itf = sl.LineString(interface_coordinates) - if itf.is_closed: - _require_no_boundary(boundary_coordinates, "closed curve") - return _sgn_dist_closed(sl.Polygon(itf), points) - else: - _require_boundary(boundary_coordinates, "open curve") - enclosed = sl.Polygon( - np.vstack((interface_coordinates, boundary_coordinates)) - ) - return _sgn_dist_open(itf, enclosed, points) - - case "polygon": - if boundary_coordinates is None: - return _sgn_dist_closed(sl.Polygon(interface_coordinates), points) - else: - itf = sl.LineString(interface_coordinates) - enclosed = sl.Polygon( - np.vstack((interface_coordinates, boundary_coordinates)) - ) - return _sgn_dist_open(itf, enclosed, points) - - case "shapely": - if interface is None: - raise ValueError( - "'interface' must be provided when interface_geometry='shapely'." - ) - if isinstance(interface, sl.Polygon): - return _sgn_dist_closed(interface, points) - else: # LineString - _require_boundary(boundary_coordinates, "shapely LineString") - enclosed = sl.Polygon( - np.vstack((interface.coords, boundary_coordinates)) - ) - return _sgn_dist_open(interface, enclosed, points) - - case _: - raise ValueError( - f"Unknown interface_geometry='{interface_geometry}'. " - "Choose from: 'curve', 'polygon', 'shapely'." - ) - -def _require_boundary(boundary_coordinates, context: str) -> None: - if boundary_coordinates is None: - raise ValueError( - f"'boundary_coordinates' must be supplied for an {context}." - ) - -def _require_no_boundary(boundary_coordinates, context: str) -> None: - if boundary_coordinates is not None: - raise ValueError( - f"'boundary_coordinates' must not be provided for a {context}." - ) - -def _allreduce_min(value: float) -> float: - """Global MPI min via PETSc.COMM_WORLD (works in serial and parallel).""" - from petsc4py import PETSc - from mpi4py import MPI - return PETSc.COMM_WORLD.tompi4py().allreduce(float(value), op=MPI.MIN) - -def _allreduce_max(value: float) -> float: - """Global MPI max via PETSc.COMM_WORLD (works in serial and parallel).""" - from petsc4py import PETSc - from mpi4py import MPI - return PETSc.COMM_WORLD.tompi4py().allreduce(float(value), op=MPI.MAX) - -# ============================================================================= -# Reinitialisation gradient: 2nd-order ENO + Godunov upwinding -# (Osher & Shu, 1991; Jiang & Peng, 2000; Sussman, Smereka & Osher, 1994) -# ============================================================================= -def _snap_to_grid_indices(vals: np.ndarray, rtol: float): - """Map coordinates that should form an evenly-spaced 1D grid (possibly - with small floating-point noise between nominally-equal values) onto - integer grid indices, robustly. - - Deliberately NOT a sequential/chain tolerance-merge (compare each - sorted value only to the previous *accepted* one): that construction - is fragile by design -- A within tol of B and B within tol of C does - not imply A is within tol of C, so a slow "creep" across several - values can chain-merge a run that should have been split into - distinct grid lines, or (as observed in practice, on a real Q2 mesh - where shared-vertex DOF coordinates are computed via different - elements' local coordinate maps and needn't be bit-identical) merge - inconsistently depending on value order, breaking the index mapping. - - Instead: estimate the true grid spacing from the smallest gap between - sorted *unique* values that's clearly not just noise (bigger than - ``rtol`` times the value range), then snap every value to the nearest - integer multiple of that spacing from a single fixed reference (the - minimum value) -- every value is compared against one global - reference, not against its neighbours in a chain, so there's no - order-dependent failure mode. - - Returns (indices, spacing, origin). - """ - uniq = np.unique(vals) - if uniq.size == 1: - return np.zeros(vals.shape, dtype=int), 1.0, float(uniq[0]) - - span = uniq[-1] - uniq[0] - noise_floor = rtol * max(span, 1.0) - gaps = np.diff(uniq) - significant = gaps[gaps > noise_floor] - if significant.size == 0: - raise RuntimeError( - "_snap_to_grid_indices: no gap between distinct coordinate " - f"values exceeds the noise floor ({noise_floor:.3e}, from " - f"rtol={rtol:.1e} x span={span:.3e}) -- either every point is " - "genuinely coincident, or rtol needs loosening for this mesh's " - "coordinate scale." - ) - spacing = float(np.min(significant)) - origin = float(uniq[0]) - - indices = np.round((vals - origin) / spacing).astype(int) - return indices, spacing, origin - - -class _StructuredGrid: - """Maps a continuous Lagrange field's DOFs on a structured - (Cartesian-topology) quad mesh to a regular ``(ny, nx)`` array of - nodes, so classical Cartesian finite-difference schemes -- here, - 2nd-order ENO -- can be applied via plain array indexing instead of - unstructured per-cell stencils. - - Built once from ``var.coords`` via :func:`_snap_to_grid_indices` - (snap-to-nearest-multiple-of-estimated-spacing from a fixed - reference, robust to floating-point noise between nominally-equal - shared-vertex coordinates -- see that function's docstring for why a - naive sequential tolerance merge is NOT used here), agnostic to - whatever internal DOF ordering UW3 happens to use, as long as the - node set genuinely forms a regular grid (true for any Lagrange degree - on ``uw.meshing.StructuredQuadBox``; raises ``RuntimeError`` rather - than a silently-wrong mapping if it does not, e.g. on an unstructured - or simplex mesh). - - **Serial only.** ``var.coords`` is the *rank-local* DOF set; a - parallel-consistent version would need an allgather of coordinates and - a halo exchange for the two ghost points ENO needs at every - partition boundary. Not implemented here -- run this on a single rank, - or extend this class before trusting it in parallel. - """ - - def __init__(self, var: uw.discretisation.MeshVariable, rtol: float = 1.0e-6): - coords = np.asarray(var.coords) - if coords.shape[1] != 2: - raise NotImplementedError("_StructuredGrid currently implements 2D only.") - x, y = coords[:, 0], coords[:, 1] - - ix, self.dx, self._x0 = _snap_to_grid_indices(x, rtol) - iy, self.dy, self._y0 = _snap_to_grid_indices(y, rtol) - - self.nx = int(ix.max()) + 1 - self.ny = int(iy.max()) + 1 - if self.nx * self.ny != coords.shape[0]: - raise RuntimeError( - f"_StructuredGrid: {coords.shape[0]} DOFs do not factor into " - f"a {self.ny} x {self.nx} regular grid ({self.ny * self.nx} " - "expected) -- this mesh/field does not have a genuinely " - "structured (Cartesian-topology) node layout, or `rtol` " - "needs adjusting for its coordinate noise/spacing scale " - f"(dx={self.dx:.3e}, dy={self.dy:.3e} were the estimated " - "spacings)." - ) - - flat_idx = iy * self.nx + ix - if np.unique(flat_idx).size != coords.shape[0]: - raise RuntimeError( - "_StructuredGrid: DOF-to-grid-index mapping is not one-to-one " - f"even though nx*ny matched (nx={self.nx}, ny={self.ny}, " - f"dx={self.dx:.3e}, dy={self.dy:.3e}) -- try loosening/" - "tightening `rtol` for this mesh's actual coordinate noise " - "scale." - ) - - self._i, self._j = ix, iy - - def to_grid(self, flat_array: np.ndarray) -> np.ndarray: - grid = np.empty((self.ny, self.nx)) - grid[self._j, self._i] = flat_array - return grid - - def to_dofs(self, grid_array: np.ndarray) -> np.ndarray: - return grid_array[self._j, self._i] - - -def _minmod(a: np.ndarray, b: np.ndarray) -> np.ndarray: - """Standard two-argument minmod: same sign as both, magnitude the - smaller of the two -- zero if they disagree in sign.""" - same_sign = np.sign(a) == np.sign(b) - return np.where(same_sign, np.sign(a) * np.minimum(np.abs(a), np.abs(b)), 0.0) - - -def _eno2_one_sided(f: np.ndarray, h: float, axis: int): - """Second-order ENO one-sided derivatives (Osher & Shu, 1991) of a - regular-grid array ``f`` along ``axis``, spacing ``h``. - - Classic HJ-ENO2 construction: start from the first-order one-sided - (upwind) difference, then correct it with the smaller-magnitude - (same-sign) of the two neighbouring second-difference estimates -- - i.e. pick whichever of the two candidate quadratic stencils is - smoother, so the reconstruction doesn't differ across a kink in the - field. Returns ``(D_minus, D_plus)``, each the same shape as ``f``. - - The two ghost points ENO needs on each side of the domain are filled - by constant (edge-value) extrapolation -- a simple, standard choice - that approximates a zero-gradient/Neumann boundary, consistent with - this module's existing ``_apply_boundary_neumann`` treatment - elsewhere. - """ - pad_width = [(0, 0)] * f.ndim - pad_width[axis] = (2, 2) - fp = np.pad(f, pad_width, mode="edge") - - n = f.shape[axis] - - def shift(k): - sl_ = [slice(None)] * f.ndim - sl_[axis] = slice(2 + k, 2 + k + n) - return fp[tuple(sl_)] - - fm2, fm1, f0, fp1, fp2 = shift(-2), shift(-1), shift(0), shift(1), shift(2) - - D1_mh = (f0 - fm1) / h # D_{i-1/2} - D1_ph = (fp1 - f0) / h # D_{i+1/2} - D1_m3h = (fm1 - fm2) / h # D_{i-3/2} - D1_p3h = (fp2 - fp1) / h # D_{i+3/2} - - D2_im1 = (D1_mh - D1_m3h) / h - D2_i = (D1_ph - D1_mh) / h - D2_ip1 = (D1_p3h - D1_ph) / h - - D_minus = D1_mh + (h / 2.0) * _minmod(D2_im1, D2_i) - D_plus = D1_ph - (h / 2.0) * _minmod(D2_i, D2_ip1) - return D_minus, D_plus - - -def _grad_magnitude_eno2(phi: np.ndarray, phi0_sign: np.ndarray, dx: float, dy: float) -> np.ndarray: - """``|grad phi|`` via 2nd-order ENO one-sided differences (Osher & Shu, - 1991; Jiang & Peng, 2000) combined with Godunov's upwind selection - based on the sign of the *frozen* reference field ``phi0_sign`` - (Sussman, Smereka & Osher, 1994) -- the standard, stable numerical - Hamiltonian for the reinitialisation equation's gradient term. - - ``phi0_sign`` should be ``sign(phi0 - 0.5)`` for a CLS field in - [0, 1] (interface at 0.5), computed *once* at the start of a - reinitialisation call and held fixed through all of its pseudo-time - stages -- re-deriving the sign from the evolving field at every stage - would let the upwind choice itself drift as the profile sharpens, - which is exactly the kind of inconsistency Godunov upwinding is meant - to avoid. - """ - Dxm, Dxp = _eno2_one_sided(phi, dx, axis=1) # x varies along columns - Dym, Dyp = _eno2_one_sided(phi, dy, axis=0) # y varies along rows - - pos = phi0_sign > 0 - ax = np.where(pos, np.maximum(Dxm, 0.0), np.minimum(Dxm, 0.0)) - bx = np.where(pos, np.minimum(Dxp, 0.0), np.maximum(Dxp, 0.0)) - ay = np.where(pos, np.maximum(Dym, 0.0), np.minimum(Dym, 0.0)) - by = np.where(pos, np.minimum(Dyp, 0.0), np.maximum(Dyp, 0.0)) - - gx2 = np.maximum(ax ** 2, bx ** 2) - gy2 = np.maximum(ay ** 2, by ** 2) - return np.sqrt(gx2 + gy2) - - -class LevelSetSolver: - """Conservative level-set advection + reinitialisation solver for UW3. - - Advection: Crank-Nicolson + SUPG (Brooks & Hughes, 1982), via - :class:`SUPGAdvection` -- a hand-built weak-form solver on UW3's - generic ``SNES_Scalar`` scaffolding, NOT ``AdvDiffusionSLCN``/ - ``SemiLagrangian``. - - Reinitialisation: Eq. (17) of Parameswaran & Mandal (2023), - - d(phi)/d(tau) = -phi(1-phi)(1-2phi) + eps(1-2phi)|grad phi|, - - integrated with the three-stage SSP-RK3 ("TVD Runge-Kutta") scheme of - Gottlieb & Shu (1998) -- unchanged from the previous version of this - file, since that was already the scheme requested. ``|grad phi|`` is - now computed via 2nd-order ENO + Godunov upwinding (see - :func:`_grad_magnitude_eno2`) on a regular node grid, NOT - ``uw.systems.Projection``. This currently restricts ``LevelSetSolver`` - to a structured (Cartesian-topology) mesh, e.g. - ``uw.meshing.StructuredQuadBox`` -- :class:`_StructuredGrid` raises - ``RuntimeError`` rather than silently mis-mapping on anything else. - - Parameters - ---------- - level_set : MeshVariable - Scalar ``MeshVariable`` (degree >= 1, continuous) that holds the - CLS field phi. Its mesh is used for all sub-solvers. - velocity : MeshVariable.sym or sympy expression - Velocity field used for advection. Typically the `.sym` of a - Stokes velocity ``MeshVariable``. - epsilon : MeshVariable - Interface thickness field ε (see ``interface_thickness()``). - reini_dt : float, optional - Pseudo-time step for reinitialisation (default 0.5 x eps). - reini_steps : int, optional - Number of pseudo-time steps per reinitialisation call (default 5). - reini_frequency : int or None, optional - How many advection steps between reinitialisation passes. - ``None`` uses the automatic strategy (see `_default_frequency`). - theta : float, optional - Time-integration parameter for the advection solver (default 0.5, - Crank-Nicolson; 1.0 would be backward-Euler). - adv_solver_opts : dict, optional - Extra PETSc options forwarded to the advection solver. - adv_solver_bc : sequence of str, optional - Mesh boundary labels (e.g. ``["Left","Right","Top","Bottom"]``) to - apply the zero-normal-gradient Neumann correction to after every - advection/reinitialisation pass (see `_apply_boundary_neumann`). - - Usage example - ------------- - >>> import underworld3 as uw, sympy - >>> mesh = uw.meshing.StructuredQuadBox(elementRes=(32, 32)) - >>> phi = uw.discretisation.MeshVariable("phi", mesh, 1, degree=2, continuous=True) - >>> v_sol = uw.discretisation.MeshVariable("v", mesh, 2, degree=2) - >>> eps = interface_thickness(mesh, phi) - >>> ls = LevelSetSolver(phi, velocity=v_sol.sym, epsilon=eps) - >>> for step in range(100): - ... ls.solve(dt=1e-3) # advect (+ reinitialise if due) - """ - - def __init__( - self, - level_set: discretisation.MeshVariable, - *, - velocity, - epsilon, - reini_dt: Optional[float] = None, - reini_steps: int = 5, - reini_frequency: Optional[int] = None, - theta: float = 0.5, - adv_solver_opts: Optional[dict] = None, - adv_solver_bc: Optional[dict] = None, - conserve_mass: bool = True, - mass_correction_tol: float = 1.0e-10, - mass_correction_max_iter: int = 40, - ) -> None: - if level_set.num_components != 1: - raise ValueError("`level_set` must be a scalar MeshVariable.") - if not level_set.continuous: - raise ValueError( - "`level_set` must be a CONTINUOUS MeshVariable -- " - "SUPGAdvection assembles a continuous-Galerkin weak form " - "and needs shared vertex/edge DOFs across cells." - ) - - self.phi = level_set - self.mesh = level_set.mesh - self.velocity = velocity - self.epsilon = epsilon - self.reini_dt = float(reini_dt) if reini_dt is not None else 0.5 * float(epsilon.data[:, 0].min()) - self.reini_steps = int(reini_steps) - self.step = 0 # counts physical advection steps taken - - # ---- Advection solver (SLCN, zero diffusivity) -------------------- - - self._comp_ddt = uw.systems.ddt.SemiLagrangian( - self.mesh, self.phi.sym, self.velocity, - vtype=uw.VarType.SCALAR, degree=self.phi.degree, continuous=self.phi.continuous, - varsymbol="cphi", bcs=[], order=1, smoothing=0.0, - monotone_mode="clamp", theta=0.5, old_frame_traceback=True, - ) - - self._adv_solver = systems.AdvDiffusionSLCN( - self.mesh, - u_Field=self.phi, - V_fn=self.velocity,order=1, DuDt=self._comp_ddt, - ) - # Zero diffusivity → pure advection - self._adv_solver.constitutive_model = uw.constitutive_models.DiffusionModel - self._adv_solver.constitutive_model.Parameters.diffusivity = 0. - self._adv_solver.tolerance = 1.0e-4 - self._adv_solver_bc = adv_solver_bc - - # ---- Reinitialisation: ENO2/Godunov gradient on a regular grid ---- - self._grid = _StructuredGrid(self.phi) - self._phi0_sign_grid = None # frozen sign(phi0-0.5), set in reinitialise() - - # ---- Reinitialisation frequency ----------------------------------- - if reini_frequency is None: - self._reini_frequency = self._default_frequency() - else: - self._reini_frequency = int(reini_frequency) - - # ---- Global mass correction (Zhang, Zou & Greaves 2010) ----------- - # Neither the reinitialisation equation (Eq. 17 is contour- - # preserving, not mass-preserving) nor a raw clamp() (an - # unweighted np.clip -- adds mass wherever it zeros an undershoot, - # removes it wherever it caps an overshoot; if that's not - # symmetric, e.g. from mild cross-wind oscillation SUPG alone - # doesn't fully suppress, the imbalance accumulates every step) - # come with any conservation guarantee. Advection itself does, in - # theory, for a divergence-free/boundary-vanishing velocity field - # (see SUPGAdvection's docstring), so this corrects for the other - # two rather than second-guessing the advection solve. - self.conserve_mass = conserve_mass - self._mass_correction_tol = float(mass_correction_tol) - self._mass_correction_max_iter = int(mass_correction_max_iter) - self._target_volume = self.interface_volume() if conserve_mass else None - - - # ------------------------------------------------------------------ - # Public interface - # ------------------------------------------------------------------ - - def solve(self, dt: float, *, reinitialise: bool = True) -> None: - self._adv_solver.solve(timestep=dt) - if self._adv_solver_bc: - self._apply_boundary_neumann(labels=self._adv_solver_bc) - self.step += 1 - - if reinitialise and (self.step % self._reini_frequency == 0): - self.reinitialise() - if self._adv_solver_bc: - self._apply_boundary_neumann(labels=self._adv_solver_bc) - - if self.conserve_mass: - self._correct_mass(self._target_volume) - - def reinitialise(self) -> None: - """Run `reini_steps` pseudo-time steps of CLS reinitialisation. - - Each step integrates Eq. (17) of Parameswaran & Mandal (2023), - - ∂φ/∂τₙ = θ [ −φ(1−φ)(1−2φ) + ε(1−2φ)|∇φ| ] - - using the three-stage SSP-RK3 scheme the paper validates all of its - results with (their Eq. 28) -- unchanged. |∇φ| is now computed by - 2nd-order ENO + Godunov upwinding (Osher & Shu, 1991; Jiang & Peng, - 2000; Sussman, Smereka & Osher, 1994) on the regular node grid - rather than an L2 projection; its upwind sign reference - sign(phi-0.5) is frozen HERE, once, before the pseudo-time loop. - Both RHS terms share the factor (1−2φ), so φ = 0.5 (the interface) - is a fixed point -- reinitialisation sharpens the profile without - moving the 0.5-contour. - """ - phi0_grid = self._grid.to_grid(self.phi.data[:, 0]) - self._phi0_sign_grid = np.sign(phi0_grid - 0.5) - - for _ in range(self.reini_steps): - self._reini_ssprk3_step(self.reini_dt) - - # ------------------------------------------------------------------ - # Internal helpers - # ------------------------------------------------------------------ - - def _rhs(self, phi_values: np.ndarray) -> np.ndarray: - """Evaluate the RHS of Eq. (17), L(φ), at a given nodal φ array. - - Reshapes `phi_values` onto the regular node grid, computes - |grad phi| there via ENO2 + Godunov upwinding against the frozen - sign reference from `reinitialise()`, then reshapes back and - combines the sharpening and balancing terms nodally: - - sharpening = −φ(1−φ)(1−2φ) balance = ε(1−2φ)|∇φ| - """ - phi_grid = self._grid.to_grid(phi_values) - gmag_grid = _grad_magnitude_eno2( - phi_grid, self._phi0_sign_grid, self._grid.dx, self._grid.dy - ) - grad = self._grid.to_dofs(gmag_grid) - eps = self.epsilon.data[:, 0] - - sharpening = -phi_values * (1 - phi_values) * (1 - 2 * phi_values) - balance = eps * (1 - 2 * phi_values) * grad - return sharpening + balance # theta = 1 - - def _reini_ssprk3_step(self, dtau: float) -> None: - """One SSP-RK3 pseudo-time step of Eq. (17) (Eq. 28, Parameswaran & - Mandal 2023): - - φ⁽¹⁾ = φⁿ + Δτ L(φⁿ) - φ⁽²⁾ = ¾φⁿ + ¼φ⁽¹⁾ + ¼Δτ L(φ⁽¹⁾) - φⁿ⁺¹ = ⅓φⁿ + ⅔φ⁽²⁾ + ⅔Δτ L(φ⁽²⁾) - - ``self.phi.data`` holds φⁿ on entry and φⁿ⁺¹ on exit. - """ - psi0 = self.phi.data[:, 0].copy() - - L0 = self._rhs(psi0) - psi1 = psi0 + dtau * L0 - - L1 = self._rhs(psi1) - psi2 = 0.75 * psi0 + 0.25 * psi1 + 0.25 * dtau * L1 - - L2 = self._rhs(psi2) - psi_new = (1.0 / 3.0) * psi0 + (2.0 / 3.0) * psi2 + (2.0 / 3.0) * dtau * L2 - - self.phi.data[:, 0] = psi_new - - def _default_frequency(self) -> int: - """Automatic reinitialisation frequency. - - reinitialise every step up to a reference cell size, then scale down as the mesh refines. - Falls back to 1 for non-Cartesian or unusual meshes. - """ - try: - coords = self.mesh.data - max_c = np.array([_allreduce_max(coords[:, i].max()) - for i in range(coords.shape[1])]) - min_c = np.array([_allreduce_min(coords[:, i].min()) - for i in range(coords.shape[1])]) - domain_size = float(np.sqrt(np.sum((max_c - min_c) ** 2))) - return max(1, round(4.9e-3 * domain_size / self.epsilon.data.min() - 0.25)) - except Exception: - warnings.warn( - "Could not compute domain size for reinitialisation frequency; " - "defaulting to every step.", stacklevel=2 - ) - return 1 - - def _apply_boundary_neumann(self, labels=("Left", "Right", "Top", "Bottom")) -> None: - """Enforce zero-normal-gradient at the given mesh boundaries by copying - the adjacent interior row/column of nodes onto the wall nodes. - """ - from mpi4py import MPI - comm = PETSc.COMM_WORLD.tompi4py() - - coords = self.phi.coords - n_local = coords.shape[0] - - axis_for_label = {"Left": 0, "Right": 0, "Top": 1, "Bottom": 1} - reduce_for_label = { - "Left": _allreduce_min, "Right": _allreduce_max, - "Top": _allreduce_max, "Bottom": _allreduce_min, - } - is_min_side = {"Left": True, "Right": False, "Top": False, "Bottom": True} - - for label in labels: - axis = axis_for_label[label] - tang = 1 - axis - - # global wall coordinate -- +/-inf on an empty rank so it can't - # corrupt the min/max reduction - if n_local: - local_extreme = (coords[:, axis].min() if is_min_side[label] - else coords[:, axis].max()) - else: - local_extreme = np.inf if is_min_side[label] else -np.inf - wall_val = reduce_for_label[label](float(local_extreme)) - - # global interior-column coordinate - local_axis_vals = np.unique(coords[:, axis]) if n_local else np.empty(0) - all_axis_vals = np.unique(np.concatenate(comm.allgather(local_axis_vals))) - if all_axis_vals.size < 2: - continue # degenerate mesh extent in this direction - ordered = all_axis_vals[np.argsort(np.abs(all_axis_vals - wall_val))] - inner_val = ordered[1] # nearest distinct coordinate to the wall - - # this rank's contribution to the global interior-column lookup table - if n_local: - inner_idx = np.where(np.isclose(coords[:, axis], inner_val, atol=1e-8))[0] - else: - inner_idx = np.empty(0, dtype=int) - local_pairs = (np.column_stack((coords[inner_idx, tang], self.phi.data[inner_idx, 0])) - if len(inner_idx) else np.empty((0, 2))) - - gathered = [p for p in comm.allgather(local_pairs) if p.shape[0] > 0] - if not gathered: - continue - global_pairs = np.vstack(gathered) - - # de-duplicate shared/ghost dofs reported by more than one rank - order = np.argsort(global_pairs[:, 0]) - global_pairs = global_pairs[order] - uniq = np.concatenate(([True], np.diff(global_pairs[:, 0]) > 1e-10)) - global_pairs = global_pairs[uniq] - - # this rank's own wall dofs (may be empty on this rank) - wall_idx = (np.where(np.isclose(coords[:, axis], wall_val, atol=1e-8))[0] - if n_local else np.empty(0, dtype=int)) - if len(wall_idx) == 0: - continue # this rank owns no nodes on this wall - - # nearest-neighbour lookup against the global table - wall_tang = coords[wall_idx, tang] - pos = np.clip(np.searchsorted(global_pairs[:, 0], wall_tang), 1, len(global_pairs) - 1) - left_err = np.abs(wall_tang - global_pairs[pos - 1, 0]) - right_err = np.abs(global_pairs[pos, 0] - wall_tang) - nearest = np.where(right_err < left_err, pos, pos - 1) - err = np.minimum(left_err, right_err) - - good = err <= 1e-6 - if not np.all(good): - warnings.warn( - f"[rank {comm.rank}] Neumann BC on '{label}': " - f"{np.count_nonzero(~good)} wall node(s) had no matching " - f"interior-column coordinate within tolerance (max err " - f"{err.max():.3e}); those left unchanged.", - stacklevel=2, - ) - - self.phi.data[wall_idx[good], 0] = global_pairs[nearest[good], 1] - # ------------------------------------------------------------------ - # Diagnostics - # ------------------------------------------------------------------ - - @property - def reini_frequency(self) -> int: - """Reinitialisation frequency (advection steps between calls).""" - return self._reini_frequency - - def interface_volume(self) -> float: - """Return ∫φ dΩ (approximate enclosed volume for mass-conservation checks).""" - integ = uw.maths.Integral(self.mesh, self.phi.sym[0, 0]) - return integ.evaluate() - - def clamp(self, lo: float = 0.0, hi: float = 1.0) -> None: - """Clamp φ values to [lo, hi] in place (post-advection safeguard). - - This is a raw, unweighted np.clip -- NOT mass-conservative on its - own (see `_correct_mass` and the `conserve_mass` constructor - option, which is what actually keeps `interface_volume()` from - drifting over many steps; calling this on top of a mass-corrected - `solve()` is a harmless no-op, since the state is already inside - [lo, hi] by then). - """ - self.phi.data[:, 0] = np.clip(self.phi.data[:, 0], lo, hi) - - def _correct_mass(self, target: float, lo: float = 0.0, hi: float = 1.0) -> None: - """Global mass correction (Zhang, Zou & Greaves 2010): find a - single uniform additive shift `delta` such that - - INT_Omega clip(phi + delta, lo, hi) dOmega == target, - - and leave `self.phi.data` in that clipped, shifted state. - - The map `delta -> resulting volume` is monotone non-decreasing - (increasing delta can only raise or hold every clipped nodal - value, never lower one), so a plain bisection is guaranteed to - converge -- no Newton/derivative needed, and no assumption about - how oscillatory or well-behaved the *current* field is beyond - that monotonicity, which holds unconditionally for a clip. - - This does not know or care *why* the volume drifted (reinit, - clamp asymmetry, or anything else); it is a final, cheap - (a handful of `interface_volume()` evaluations, not a new SNES - solve) correction applied once per `solve()` call. - """ - data0 = self.phi.data[:, 0].copy() - - def vol_for_shift(delta: float) -> float: - self.phi.data[:, 0] = np.clip(data0 + delta, lo, hi) - return self.interface_volume() - - v0 = vol_for_shift(0.0) - if abs(v0 - target) < self._mass_correction_tol: - return # already within tolerance; state from delta=0 stands - - span = hi - lo - if v0 < target: - lo_d, hi_d = 0.0, max(span * 1.0e-3, 1.0e-8) - tries = 0 - while vol_for_shift(hi_d) < target and tries < 30: - hi_d *= 2.0 - tries += 1 - else: - lo_d, hi_d = -max(span * 1.0e-3, 1.0e-8), 0.0 - tries = 0 - while vol_for_shift(lo_d) > target and tries < 30: - lo_d *= 2.0 - tries += 1 - - if not (vol_for_shift(lo_d) <= target <= vol_for_shift(hi_d)): - warnings.warn( - "_correct_mass: could not bracket the target volume " - f"({target:.6g}) within delta in [{lo_d:.3g}, {hi_d:.3g}] " - "-- leaving the field at its widest attempted shift rather " - "than an unbracketed (unreliable) bisection result. This " - "usually means the whole field is already pinned at lo or " - "hi, with nothing left to shift.", - stacklevel=2, - ) - return - - mid = 0.0 - for _ in range(self._mass_correction_max_iter): - mid = 0.5 * (lo_d + hi_d) - vmid = vol_for_shift(mid) - if abs(vmid - target) < self._mass_correction_tol: - break - if vmid < target: - lo_d = mid - else: - hi_d = mid - vol_for_shift(mid) # leave self.phi.data at the converged shift - - -def material_property_field( - level_set: sympy.Expr | list[sympy.Expr], - field_values: list[float], - interface: str, -) -> sympy.Expr: - """Generates sympy algebra describing a physical property across the domain. - Args: - level_set: - A sympy expression for the level set (typically `mesh_variable.sym[0, 0]`), - or a list thereof - field_values: - A list of physical-property values specific to each material - interface: - A string specifying how property transitions between materials are calculated - Returns: - Sympy algebra representing the physical property throughout the domain - """ - impl_interface = ["sharp", "sharp_adjoint", "arithmetic", "geometric", "harmonic"] - if interface not in impl_interface: - raise ValueError(f"Interface must be one of {impl_interface}") - - level_set = level_set.copy() if isinstance(level_set, list) else [level_set] - field_values = field_values.copy() - - result = None - while level_set: - ls = sympy.Max(sympy.Min(level_set.pop(), 1), 0) - - # Deepest (last) level set: pull both surrounding field values at once. - # Otherwise: pull one field value and combine with the running result. - field_value = field_values.pop() - other_side = field_values.pop() if not level_set else result - - match interface: - case "sharp": - result = sympy.Piecewise((field_value, ls > sympy.Rational(1, 2)), (other_side, True)) - case "sharp_adjoint": - ls_shift = ls - sympy.Rational(1, 2) - heaviside = (ls_shift + sympy.Abs(ls_shift)) / 2 / ls_shift - - result = field_value * heaviside + other_side * (1 - heaviside) - case "arithmetic": - result = field_value * ls + other_side * (1 - ls) - case "geometric": - result = field_value**ls * other_side ** (1 - ls) - case "harmonic": - result = 1 / (ls / field_value + (1 - ls) / other_side) - return result diff --git a/src/underworld3/systems/level_set_SUPG.py b/src/underworld3/systems/level_set_SUPG.py deleted file mode 100644 index 3e0a1a7c0..000000000 --- a/src/underworld3/systems/level_set_SUPG.py +++ /dev/null @@ -1,745 +0,0 @@ -import numpy as np -from typing import Optional -import warnings -import sympy - -from petsc4py import PETSc # NOTE: added -- _apply_boundary_neumann uses - # PETSc.COMM_WORLD but this file had no - # module-level PETSc import at all (a latent - # NameError-on-call bug in the previous - # version, unrelated to the solver rewrite). - -import underworld3 as uw -from underworld3 import discretisation, systems -from underworld3.utilities._api_tools import Template -from typing import Optional - -from shapely import geometry as sl -from shapely import prepare as _shapely_prepare - -from underworld3.systems import AdvDiffusionSUPG - -def initialise_psi( - psi, - epsilon, - signed_distance: np.ndarray | None = None, - interface_geometry: str | None = None, - interface: sl.LineString | sl.Polygon | None = None, - interface_coordinates = None, - boundary_coordinates = None, -) -> None: - """ - Fill a UW3 MeshVariable *psi* with conservative level-set (CLS) values: - - psi = ( 1 + tanh( phi / (2 * epsilon) ) ) / 2 - - where *phi* is the signed-distance function (positive on the '1-side'). - - Parameters - ---------- - psi : uw.discretisation.MeshVariable - Target level-set field. - epsilon : float or ndarray, shape (n_nodes,) - Interface thickness. Use ``interface_thickness()`` to compute it. - - Keyword-only - ------------ - signed_distance : ndarray, shape (n_nodes,), optional - Pre-computed signed distances. If supplied, all geometry arguments - are ignored and the CLS field is written immediately. - - interface_geometry : {'curve', 'polygon', 'circle', 'shapely'} - How the interface is described (ignored when *signed_distance* is given). - - interface : shapely.LineString or shapely.Polygon, optional - Required when ``interface_geometry='shapely'``. - - interface_coordinates : list of (x, y) or ((cx, cy), radius) - Vertex list for 'curve'/'polygon', or (centre, radius) for 'circle'. - - boundary_coordinates : list of (x, y), optional - Extra boundary points used to close an open interface into a polygon - that defines the '1-side'. - - Notes - ----- - * ``psi → 1`` inside the interface (positive signed distance) - * ``psi = 0.5`` on the interface - * ``psi → 0`` outside the interface (negative signed distance) - - References - ---------- - Parameswaran & Mandal (2023), Eur. J. Mech.-B/Fluids, 98, 40-63. - g-ADOPT ``assign_level_set_values``: - https://github.com/g-adopt/g-adopt/blob/main/gadopt/level_set_tools.py - """ - - if signed_distance is not None: - psi.data[:, 0] = _tanh_profile(signed_distance, epsilon) - return - - if interface_geometry is None: - raise ValueError( - "Provide either 'signed_distance' or 'interface_geometry'." - ) - - if interface_coordinates is None and interface_geometry != "shapely": - raise ValueError( - "'interface_coordinates' is required when " - f"interface_geometry='{interface_geometry}'." - ) - - points = psi.coords # shape (n_nodes, dim) - signed_distance = _signed_distance_from_geometry( - interface_geometry, - interface, - interface_coordinates, - boundary_coordinates, - points,) - epsilon_data = epsilon.data[:,0] - psi.data[:, 0] = _tanh_profile(signed_distance, epsilon_data) - - -def interface_thickness( - mesh: uw.discretisation.Mesh, - phi: uw.discretisation.MeshVariable, - *, - scale: float = 0.35, - use_min_edge_length: bool = False, -) -> uw.discretisation.MeshVariable: - """Compute a spatially-varying interface thickness ε on the same mesh and - degree as *phi*, returned as a scalar ``MeshVariable``. - """ - if use_min_edge_length and mesh.qdegree > 1: - raise ValueError( - "use_min_edge_length=True is only valid for straight-edged meshes " - "(qdegree=1)." - ) - - from scipy.spatial import cKDTree - - dm = mesh.dm - dim = mesh.dim - c_start, c_end = dm.getHeightStratum(0) # cell range in the DMPlex - n_cells = c_end - c_start - cell_epsilon = np.empty(n_cells, dtype=float) - cell_centroids = np.empty((n_cells, dim), dtype=float) - - if not use_min_edge_length: - scale_factor = scale / np.sqrt(dim) - for i, cell in enumerate(range(c_start, c_end)): - vol, centroid, _ = dm.computeCellGeometryFVM(cell) - cell_epsilon[i] = scale_factor * float(np.asarray(vol).ravel()[0]) ** (1.0 / dim) - cell_centroids[i, :] = np.asarray(centroid).ravel()[:dim] - else: - v_start, v_end = dm.getDepthStratum(0) - coords = mesh.data # (n_vertices, dim) - for i, cell in enumerate(range(c_start, c_end)): - closure, _ = dm.getTransitiveClosure(cell) - verts = [p for p in closure if v_start <= p < v_end] - v_coords = coords[[p - v_start for p in verts]] - # minimum pairwise edge length - min_edge = np.inf - for a in range(len(v_coords)): - for b in range(a + 1, len(v_coords)): - d = np.linalg.norm(v_coords[a] - v_coords[b]) - if d < min_edge: - min_edge = d - cell_epsilon[i] = scale * min_edge - cell_centroids[i, :] = v_coords.mean(axis=0) - - epsilon_var = uw.discretisation.MeshVariable( - r"\epsilon", mesh, 1, degree=phi.degree,continuous=phi.continuous - ) - node_coords = phi.coords # (n_nodes, dim) - tree = cKDTree(cell_centroids) - _, nearest = tree.query(node_coords) # nearest[i] = cell index for node i - - epsilon_var.data[:, 0] = cell_epsilon[nearest] - return epsilon_var - - -def _sgn_dist_closed(interface: sl.Polygon, points: np.ndarray) -> np.ndarray: - """Signed distance: positive inside, negative outside a closed polygon.""" - _shapely_prepare(interface) - boundary = interface.boundary - sgn = np.where( - [interface.contains(sl.Point(p)) for p in points], 1.0, -1.0 - ) - dist = np.array([boundary.distance(sl.Point(p)) for p in points]) - return sgn * dist - -def _sgn_dist_open( - interface: sl.LineString, - enclosed_side: sl.Polygon, - points: np.ndarray, -) -> np.ndarray: - """Signed distance w.r.t. an open interface; sign from enclosed polygon.""" - _shapely_prepare(enclosed_side) - sgn = np.where( - [enclosed_side.intersects(sl.Point(p)) for p in points], 1.0, -1.0 - ) - dist = np.array([interface.distance(sl.Point(p)) for p in points]) - return sgn * dist - -def _tanh_profile(phi: np.ndarray, epsilon: float | np.ndarray) -> np.ndarray: - """CLS tanh profile: (1 + tanh(phi / 2ε)) / 2""" - return (1.0 + np.tanh(np.asarray(phi) / (2.0 * np.asarray(epsilon)))) / 2.0 - -def _signed_distance_from_geometry( - interface_geometry: str, - interface, - interface_coordinates, - boundary_coordinates, - points: np.ndarray, -) -> np.ndarray: - """Dispatch to the correct signed-distance routine based on geometry type.""" - - match interface_geometry: - - case "curve": - itf = sl.LineString(interface_coordinates) - if itf.is_closed: - _require_no_boundary(boundary_coordinates, "closed curve") - return _sgn_dist_closed(sl.Polygon(itf), points) - else: - _require_boundary(boundary_coordinates, "open curve") - enclosed = sl.Polygon( - np.vstack((interface_coordinates, boundary_coordinates)) - ) - return _sgn_dist_open(itf, enclosed, points) - - case "polygon": - if boundary_coordinates is None: - return _sgn_dist_closed(sl.Polygon(interface_coordinates), points) - else: - itf = sl.LineString(interface_coordinates) - enclosed = sl.Polygon( - np.vstack((interface_coordinates, boundary_coordinates)) - ) - return _sgn_dist_open(itf, enclosed, points) - - case "shapely": - if interface is None: - raise ValueError( - "'interface' must be provided when interface_geometry='shapely'." - ) - if isinstance(interface, sl.Polygon): - return _sgn_dist_closed(interface, points) - else: # LineString - _require_boundary(boundary_coordinates, "shapely LineString") - enclosed = sl.Polygon( - np.vstack((interface.coords, boundary_coordinates)) - ) - return _sgn_dist_open(interface, enclosed, points) - - case _: - raise ValueError( - f"Unknown interface_geometry='{interface_geometry}'. " - "Choose from: 'curve', 'polygon', 'shapely'." - ) - -def _require_boundary(boundary_coordinates, context: str) -> None: - if boundary_coordinates is None: - raise ValueError( - f"'boundary_coordinates' must be supplied for an {context}." - ) - -def _require_no_boundary(boundary_coordinates, context: str) -> None: - if boundary_coordinates is not None: - raise ValueError( - f"'boundary_coordinates' must not be provided for a {context}." - ) - -def _allreduce_min(value: float) -> float: - """Global MPI min via PETSc.COMM_WORLD (works in serial and parallel).""" - from petsc4py import PETSc - from mpi4py import MPI - return PETSc.COMM_WORLD.tompi4py().allreduce(float(value), op=MPI.MIN) - -def _allreduce_max(value: float) -> float: - """Global MPI max via PETSc.COMM_WORLD (works in serial and parallel).""" - from petsc4py import PETSc - from mpi4py import MPI - return PETSc.COMM_WORLD.tompi4py().allreduce(float(value), op=MPI.MAX) - - -class LevelSetSolver: - """Conservative level-set advection + reinitialisation solver for UW3. - - Advection: Crank-Nicolson + SUPG (Brooks & Hughes, 1982), via - :class:`SUPGAdvection` -- a hand-built weak-form solver on UW3's - generic ``SNES_Scalar`` scaffolding, NOT ``AdvDiffusionSLCN``/ - ``SemiLagrangian``. - - Reinitialisation: Eq. (17) of Parameswaran & Mandal (2023), - - d(phi)/d(tau) = -phi(1-phi)(1-2phi) + eps(1-2phi)|grad phi|, - - integrated with the three-stage SSP-RK3 ("TVD Runge-Kutta") scheme of - Gottlieb & Shu (1998) -- unchanged from the previous version of this - file, since that was already the scheme requested. ``|grad phi|`` is - now computed via 2nd-order ENO + Godunov upwinding (see - :func:`_grad_magnitude_eno2`) on a regular node grid, NOT - ``uw.systems.Projection``. This currently restricts ``LevelSetSolver`` - to a structured (Cartesian-topology) mesh, e.g. - ``uw.meshing.StructuredQuadBox`` -- :class:`_StructuredGrid` raises - ``RuntimeError`` rather than silently mis-mapping on anything else. - - Parameters - ---------- - level_set : MeshVariable - Scalar ``MeshVariable`` (degree >= 1, continuous) that holds the - CLS field phi. Its mesh is used for all sub-solvers. - velocity : MeshVariable.sym or sympy expression - Velocity field used for advection. Typically the `.sym` of a - Stokes velocity ``MeshVariable``. - epsilon : MeshVariable - Interface thickness field ε (see ``interface_thickness()``). - reini_dt : float, optional - Pseudo-time step for reinitialisation (default 0.5 x eps). - reini_steps : int, optional - Number of pseudo-time steps per reinitialisation call (default 5). - reini_frequency : int or None, optional - How many advection steps between reinitialisation passes. - ``None`` uses the automatic strategy (see `_default_frequency`). - theta : float, optional - Time-integration parameter for the advection solver (default 0.5, - Crank-Nicolson; 1.0 would be backward-Euler). - adv_solver_opts : dict, optional - Extra PETSc options forwarded to the advection solver. - adv_solver_bc : sequence of str, optional - Mesh boundary labels (e.g. ``["Left","Right","Top","Bottom"]``) to - apply the zero-normal-gradient Neumann correction to after every - advection/reinitialisation pass (see `_apply_boundary_neumann`). - - Usage example - ------------- - >>> import underworld3 as uw, sympy - >>> mesh = uw.meshing.StructuredQuadBox(elementRes=(32, 32)) - >>> phi = uw.discretisation.MeshVariable("phi", mesh, 1, degree=2, continuous=True) - >>> v_sol = uw.discretisation.MeshVariable("v", mesh, 2, degree=2) - >>> eps = interface_thickness(mesh, phi) - >>> ls = LevelSetSolver(phi, velocity=v_sol.sym, epsilon=eps) - >>> for step in range(100): - ... ls.solve(dt=1e-3) # advect (+ reinitialise if due) - """ - - def __init__( - self, - level_set: discretisation.MeshVariable, - *, - velocity, - epsilon, - reini_dt: Optional[float] = None, - reini_steps: int = 5, - reini_frequency: Optional[int] = None, - theta: float = 0.5, - adv_solver_opts: Optional[dict] = None, - adv_solver_bc: Optional[dict] = None, - conserve_mass: bool = True, - mass_correction_tol: float = 1.0e-10, - mass_correction_max_iter: int = 40, - ) -> None: - if level_set.num_components != 1: - raise ValueError("`level_set` must be a scalar MeshVariable.") - if not level_set.continuous: - raise ValueError( - "`level_set` must be a CONTINUOUS MeshVariable -- " - "SUPGAdvection assembles a continuous-Galerkin weak form " - "and needs shared vertex/edge DOFs across cells." - ) - - self.phi = level_set - self.mesh = level_set.mesh - self.velocity = velocity - self.epsilon = epsilon - self.reini_dt = float(reini_dt) if reini_dt is not None else 0.5 * float(epsilon.data[:, 0].min()) - self.reini_steps = int(reini_steps) - self.step = 0 # counts physical advection steps taken - - # ---- Advection solver: Crank-Nicolson + SUPG ---------------------- - self._adv_solver = AdvDiffusionSUPG(self.mesh, self.phi, self.velocity, theta=theta) - self._adv_solver_bc = adv_solver_bc - - if adv_solver_opts: - for k, v in adv_solver_opts.items(): - self._adv_solver.petsc_options[k] = v - - # no use ---- Reinitialisation: ENO2/Godunov gradient on a regular grid ---- - #self._grid = _StructuredGrid(self.phi) - #self._phi0_sign_grid = None # frozen sign(phi0-0.5), set in reinitialise() - - grad_s = self.mesh.vector.gradient(self.phi.sym) - self._grad_mag = sympy.sqrt(sum(g**2 for g in grad_s)) - - # ---- Gradient vector field ∇φ ------------------------------------- - self.phi_grad = discretisation.MeshVariable( - r"|\nabla\phi|", - self.mesh, - 1, - degree= self.phi.degree,continuous = self.phi.continuous - ) - self._grad_projector = systems.Projection(self.mesh, self.phi_grad,degree= self.phi.degree) - self._grad_projector.uw_function = self._grad_mag - - # ---- Reinitialisation frequency ----------------------------------- - if reini_frequency is None: - self._reini_frequency = self._default_frequency() - else: - self._reini_frequency = int(reini_frequency) - - # ---- Global mass correction (Zhang, Zou & Greaves 2010) ----------- - # Neither the reinitialisation equation (Eq. 17 is contour- - # preserving, not mass-preserving) nor a raw clamp() (an - # unweighted np.clip -- adds mass wherever it zeros an undershoot, - # removes it wherever it caps an overshoot; if that's not - # symmetric, e.g. from mild cross-wind oscillation SUPG alone - # doesn't fully suppress, the imbalance accumulates every step) - # come with any conservation guarantee. Advection itself does, in - # theory, for a divergence-free/boundary-vanishing velocity field - # (see SUPGAdvection's docstring), so this corrects for the other - # two rather than second-guessing the advection solve. - self.conserve_mass = conserve_mass - self._mass_correction_tol = float(mass_correction_tol) - self._mass_correction_max_iter = int(mass_correction_max_iter) - self._target_volume = self.interface_volume() if conserve_mass else None - - - # ------------------------------------------------------------------ - # Public interface - # ------------------------------------------------------------------ - - def solve(self, dt: float, *, reinitialise: bool = True) -> None: - self._adv_solver.solve(dt) - if self._adv_solver_bc: - self._apply_boundary_neumann(labels=self._adv_solver_bc) - self.step += 1 - - if reinitialise and (self.step % self._reini_frequency == 0): - self.reinitialise() - if self._adv_solver_bc: - self._apply_boundary_neumann(labels=self._adv_solver_bc) - - if self.conserve_mass: - self._correct_mass(self._target_volume) - - def reinitialise(self) -> None: - """Run `reini_steps` pseudo-time steps of CLS reinitialisation. - - Each step integrates Eq. (17) of Parameswaran & Mandal (2023), - - ∂φ/∂τₙ = θ [ −φ(1−φ)(1−2φ) + ε(1−2φ)|∇φ| ] - - using the three-stage SSP-RK3 scheme the paper validates all of its - results with (their Eq. 28) -- unchanged. |∇φ| is now computed by - 2nd-order ENO + Godunov upwinding (Osher & Shu, 1991; Jiang & Peng, - 2000; Sussman, Smereka & Osher, 1994) on the regular node grid - rather than an L2 projection; its upwind sign reference - sign(phi-0.5) is frozen HERE, once, before the pseudo-time loop. - Both RHS terms share the factor (1−2φ), so φ = 0.5 (the interface) - is a fixed point -- reinitialisation sharpens the profile without - moving the 0.5-contour. - """ - for _ in range(self.reini_steps): - self._reini_ssprk3_step(self.reini_dt) - - # ------------------------------------------------------------------ - # Internal helpers - # ------------------------------------------------------------------ - - def _update_gradient(self) -> None: - """L2-project |∇φ| onto ``phi_grad`` from the *current* φ data. - """ - self._grad_projector.uw_function = self._grad_mag - self._grad_projector.solve() - - def _rhs(self, phi_values: np.ndarray) -> np.ndarray: - """Evaluate the RHS of Eq. (17), L(φ), at a given nodal φ array. - - Writes ``phi_values`` into ``self.phi`` first (so the gradient - projector, built from ``self.phi.sym``, sees the correct SSP-RK3 - stage value), then projects |∇φ| and combines the sharpening and - balancing terms nodally: - - sharpening = −φ(1−φ)(1−2φ) balance = ε(1−2φ)|∇φ| - """ - self.phi.data[:, 0] = phi_values - self._update_gradient() - grad = self.phi_grad.data[:, 0] - eps = self.epsilon.data[:, 0] - - sharpening = -phi_values * (1 - phi_values) * (1 - 2 * phi_values) - balance = eps * (1 - 2 * phi_values) * grad - return sharpening + balance # theta = 1 - - def _reini_ssprk3_step(self, dtau: float) -> None: - """One SSP-RK3 pseudo-time step of Eq. (17) (Eq. 28, Parameswaran & - Mandal 2023): - - φ⁽¹⁾ = φⁿ + Δτ L(φⁿ) - φ⁽²⁾ = ¾φⁿ + ¼φ⁽¹⁾ + ¼Δτ L(φ⁽¹⁾) - φⁿ⁺¹ = ⅓φⁿ + ⅔φ⁽²⁾ + ⅔Δτ L(φ⁽²⁾) - - ``self.phi.data`` holds φⁿ on entry and φⁿ⁺¹ on exit. - """ - psi0 = self.phi.data[:, 0].copy() - - L0 = self._rhs(psi0) - psi1 = psi0 + dtau * L0 - - L1 = self._rhs(psi1) - psi2 = 0.75 * psi0 + 0.25 * psi1 + 0.25 * dtau * L1 - - L2 = self._rhs(psi2) - psi_new = (1.0 / 3.0) * psi0 + (2.0 / 3.0) * psi2 + (2.0 / 3.0) * dtau * L2 - - self.phi.data[:, 0] = psi_new - - - def _default_frequency(self) -> int: - """Automatic reinitialisation frequency. - - reinitialise every step up to a reference cell size, then scale down as the mesh refines. - Falls back to 1 for non-Cartesian or unusual meshes. - """ - try: - coords = self.mesh.data - max_c = np.array([_allreduce_max(coords[:, i].max()) - for i in range(coords.shape[1])]) - min_c = np.array([_allreduce_min(coords[:, i].min()) - for i in range(coords.shape[1])]) - domain_size = float(np.sqrt(np.sum((max_c - min_c) ** 2))) - return max(1, round(4.9e-3 * domain_size / self.epsilon.data.min() - 0.25)) - except Exception: - warnings.warn( - "Could not compute domain size for reinitialisation frequency; " - "defaulting to every step.", stacklevel=2 - ) - return 1 - - def _apply_boundary_neumann(self, labels=("Left", "Right", "Top", "Bottom")) -> None: - """Enforce zero-normal-gradient at the given mesh boundaries by copying - the adjacent interior row/column of nodes onto the wall nodes. - """ - from mpi4py import MPI - comm = PETSc.COMM_WORLD.tompi4py() - - coords = self.phi.coords - n_local = coords.shape[0] - - axis_for_label = {"Left": 0, "Right": 0, "Top": 1, "Bottom": 1} - reduce_for_label = { - "Left": _allreduce_min, "Right": _allreduce_max, - "Top": _allreduce_max, "Bottom": _allreduce_min, - } - is_min_side = {"Left": True, "Right": False, "Top": False, "Bottom": True} - - for label in labels: - axis = axis_for_label[label] - tang = 1 - axis - - # global wall coordinate -- +/-inf on an empty rank so it can't - # corrupt the min/max reduction - if n_local: - local_extreme = (coords[:, axis].min() if is_min_side[label] - else coords[:, axis].max()) - else: - local_extreme = np.inf if is_min_side[label] else -np.inf - wall_val = reduce_for_label[label](float(local_extreme)) - - # global interior-column coordinate - local_axis_vals = np.unique(coords[:, axis]) if n_local else np.empty(0) - all_axis_vals = np.unique(np.concatenate(comm.allgather(local_axis_vals))) - if all_axis_vals.size < 2: - continue # degenerate mesh extent in this direction - ordered = all_axis_vals[np.argsort(np.abs(all_axis_vals - wall_val))] - inner_val = ordered[1] # nearest distinct coordinate to the wall - - # this rank's contribution to the global interior-column lookup table - if n_local: - inner_idx = np.where(np.isclose(coords[:, axis], inner_val, atol=1e-8))[0] - else: - inner_idx = np.empty(0, dtype=int) - local_pairs = (np.column_stack((coords[inner_idx, tang], self.phi.data[inner_idx, 0])) - if len(inner_idx) else np.empty((0, 2))) - - gathered = [p for p in comm.allgather(local_pairs) if p.shape[0] > 0] - if not gathered: - continue - global_pairs = np.vstack(gathered) - - # de-duplicate shared/ghost dofs reported by more than one rank - order = np.argsort(global_pairs[:, 0]) - global_pairs = global_pairs[order] - uniq = np.concatenate(([True], np.diff(global_pairs[:, 0]) > 1e-10)) - global_pairs = global_pairs[uniq] - - # this rank's own wall dofs (may be empty on this rank) - wall_idx = (np.where(np.isclose(coords[:, axis], wall_val, atol=1e-8))[0] - if n_local else np.empty(0, dtype=int)) - if len(wall_idx) == 0: - continue # this rank owns no nodes on this wall - - # nearest-neighbour lookup against the global table - wall_tang = coords[wall_idx, tang] - pos = np.clip(np.searchsorted(global_pairs[:, 0], wall_tang), 1, len(global_pairs) - 1) - left_err = np.abs(wall_tang - global_pairs[pos - 1, 0]) - right_err = np.abs(global_pairs[pos, 0] - wall_tang) - nearest = np.where(right_err < left_err, pos, pos - 1) - err = np.minimum(left_err, right_err) - - good = err <= 1e-6 - if not np.all(good): - warnings.warn( - f"[rank {comm.rank}] Neumann BC on '{label}': " - f"{np.count_nonzero(~good)} wall node(s) had no matching " - f"interior-column coordinate within tolerance (max err " - f"{err.max():.3e}); those left unchanged.", - stacklevel=2, - ) - - self.phi.data[wall_idx[good], 0] = global_pairs[nearest[good], 1] - # ------------------------------------------------------------------ - # Diagnostics - # ------------------------------------------------------------------ - - @property - def reini_frequency(self) -> int: - """Reinitialisation frequency (advection steps between calls).""" - return self._reini_frequency - - def interface_volume(self) -> float: - """Return ∫φ dΩ (approximate enclosed volume for mass-conservation checks).""" - integ = uw.maths.Integral(self.mesh, self.phi.sym[0, 0]) - return integ.evaluate() - - def clamp(self, lo: float = 0.0, hi: float = 1.0) -> None: - """Clamp φ values to [lo, hi] in place (post-advection safeguard). - - This is a raw, unweighted np.clip -- NOT mass-conservative on its - own (see `_correct_mass` and the `conserve_mass` constructor - option, which is what actually keeps `interface_volume()` from - drifting over many steps; calling this on top of a mass-corrected - `solve()` is a harmless no-op, since the state is already inside - [lo, hi] by then). - """ - self.phi.data[:, 0] = np.clip(self.phi.data[:, 0], lo, hi) - - def _correct_mass(self, target: float, lo: float = 0.0, hi: float = 1.0) -> None: - """Global mass correction (Zhang, Zou & Greaves 2010): find a - single uniform additive shift `delta` such that - - INT_Omega clip(phi + delta, lo, hi) dOmega == target, - - and leave `self.phi.data` in that clipped, shifted state. - - The map `delta -> resulting volume` is monotone non-decreasing - (increasing delta can only raise or hold every clipped nodal - value, never lower one), so a plain bisection is guaranteed to - converge -- no Newton/derivative needed, and no assumption about - how oscillatory or well-behaved the *current* field is beyond - that monotonicity, which holds unconditionally for a clip. - - This does not know or care *why* the volume drifted (reinit, - clamp asymmetry, or anything else); it is a final, cheap - (a handful of `interface_volume()` evaluations, not a new SNES - solve) correction applied once per `solve()` call. - """ - data0 = self.phi.data[:, 0].copy() - - def vol_for_shift(delta: float) -> float: - self.phi.data[:, 0] = np.clip(data0 + delta, lo, hi) - return self.interface_volume() - - v0 = vol_for_shift(0.0) - if abs(v0 - target) < self._mass_correction_tol: - return # already within tolerance; state from delta=0 stands - - span = hi - lo - if v0 < target: - lo_d, hi_d = 0.0, max(span * 1.0e-3, 1.0e-8) - tries = 0 - while vol_for_shift(hi_d) < target and tries < 30: - hi_d *= 2.0 - tries += 1 - else: - lo_d, hi_d = -max(span * 1.0e-3, 1.0e-8), 0.0 - tries = 0 - while vol_for_shift(lo_d) > target and tries < 30: - lo_d *= 2.0 - tries += 1 - - if not (vol_for_shift(lo_d) <= target <= vol_for_shift(hi_d)): - warnings.warn( - "_correct_mass: could not bracket the target volume " - f"({target:.6g}) within delta in [{lo_d:.3g}, {hi_d:.3g}] " - "-- leaving the field at its widest attempted shift rather " - "than an unbracketed (unreliable) bisection result. This " - "usually means the whole field is already pinned at lo or " - "hi, with nothing left to shift.", - stacklevel=2, - ) - return - - mid = 0.0 - for _ in range(self._mass_correction_max_iter): - mid = 0.5 * (lo_d + hi_d) - vmid = vol_for_shift(mid) - if abs(vmid - target) < self._mass_correction_tol: - break - if vmid < target: - lo_d = mid - else: - hi_d = mid - vol_for_shift(mid) # leave self.phi.data at the converged shift - - -def material_property_field( - level_set: sympy.Expr | list[sympy.Expr], - field_values: list[float], - interface: str, -) -> sympy.Expr: - """Generates sympy algebra describing a physical property across the domain. - Args: - level_set: - A sympy expression for the level set (typically `mesh_variable.sym[0, 0]`), - or a list thereof - field_values: - A list of physical-property values specific to each material - interface: - A string specifying how property transitions between materials are calculated - Returns: - Sympy algebra representing the physical property throughout the domain - """ - impl_interface = ["sharp", "sharp_adjoint", "arithmetic", "geometric", "harmonic"] - if interface not in impl_interface: - raise ValueError(f"Interface must be one of {impl_interface}") - - level_set = level_set.copy() if isinstance(level_set, list) else [level_set] - field_values = field_values.copy() - - result = None - while level_set: - ls = sympy.Max(sympy.Min(level_set.pop(), 1), 0) - - # Deepest (last) level set: pull both surrounding field values at once. - # Otherwise: pull one field value and combine with the running result. - field_value = field_values.pop() - other_side = field_values.pop() if not level_set else result - - match interface: - case "sharp": - result = sympy.Piecewise((field_value, ls > sympy.Rational(1, 2)), (other_side, True)) - case "sharp_adjoint": - ls_shift = ls - sympy.Rational(1, 2) - heaviside = (ls_shift + sympy.Abs(ls_shift)) / 2 / ls_shift - - result = field_value * heaviside + other_side * (1 - heaviside) - case "arithmetic": - result = field_value * ls + other_side * (1 - ls) - case "geometric": - result = field_value**ls * other_side ** (1 - ls) - case "harmonic": - result = 1 / (ls / field_value + (1 - ls) / other_side) - return result diff --git a/src/underworld3/systems/solver_supg.py b/src/underworld3/systems/solver_supg.py deleted file mode 100644 index 4270fb315..000000000 --- a/src/underworld3/systems/solver_supg.py +++ /dev/null @@ -1,829 +0,0 @@ -import sympy -from sympy import sympify -import numpy as np -import warnings - -from typing import Optional, Callable, Union - -import underworld3 as uw -from underworld3.systems import SNES_Scalar, SNES_Vector, SNES_Stokes_SaddlePt -from underworld3.cython.generic_solvers import SNES_MultiComponent -from underworld3 import VarType -import underworld3.timing as timing -from underworld3.utilities import memprobe -from underworld3.utilities._api_tools import ( - uw_object, - SymbolicProperty, - Parameter, - Template, - ExpressionProperty, -) - -from underworld3.function import expression as public_expression - -# estimate_dt() below needs two module-level helpers that live alongside -# SNES_AdvectionDiffusion (the SLCN solver) in underworld3/systems/solvers.py. -# They are prefixed with an underscore (module-private by convention) but -# not otherwise protected, so a direct import works fine. -from underworld3.systems.solvers import ( - _global_max_diffusivity, - _centroid_velocities_nd, -) - -def _as_row_vector(V_fn, dim): - r"""Coerce a velocity expression into a ``(1, dim)`` sympy row-vector - Matrix matching the mesh's dimensionality, or raise a CLEAR error at - construction time instead of the cryptic sympy ``IndexError`` that - would otherwise surface much later, deep inside a compiled residual - lambda (``u[0, i]`` for ``i in range(dim)`` on a too-narrow matrix -- - e.g. a scalar/1-component velocity on a 2-D mesh). - - A common source of this mismatch: a "1D" test built on a thin 2-D - strip mesh (``mesh.dim == 2``) with a genuinely 1-component velocity - field/expression -- UW3 doesn't have a bare 1-D mesh type for this, - so the velocity still needs an explicit second (zero) component, - e.g. ``sympy.Matrix([[vx, 0]])``, not a plain scalar ``vx``. - """ - if isinstance(V_fn, sympy.MatrixBase): - if V_fn.shape == (1, dim): - return V_fn - if V_fn.shape == (dim, 1): - # Common transpose slip (column vector instead of row). - return V_fn.T - raise ValueError( - f"V_fn has shape {V_fn.shape}, but the mesh is {dim}-D -- " - f"expected a (1, {dim}) row vector (e.g. `v.sym` from a " - f"{dim}-component vector MeshVariable). If this is meant to " - f"be a 1-D-in-x flow on a thin {dim}-D mesh, pass an explicit " - f"{dim}-component vector, e.g. `sympy.Matrix([[vx, 0]])` for " - f"dim=2, not a bare scalar or a mismatched-shape Matrix." - ) - # Plain scalar (python number or bare sympy scalar expression, not - # wrapped in a Matrix at all). - if dim == 1: - return sympy.Matrix([[V_fn]]) - raise ValueError( - f"V_fn is a scalar, but the mesh is {dim}-D -- expected a " - f"(1, {dim}) row vector. If this is meant to be a 1-D-in-x flow " - f"on a thin {dim}-D mesh, pass e.g. `sympy.Matrix([[V_fn, 0]])` " - f"(dim=2) rather than the bare scalar `V_fn`." - ) - - -class SNES_AdvectionDiffusion_SUPG(SNES_Scalar): - r"""Advection-diffusion equation solver using Crank-Nicolson time integration + - streamline-upwind Petrov-Galerkin (SUPG, Brooks & Hughes 1982) stabilisation: - - .. math:: - \frac{\partial \phi}{\partial t} + \mathbf{u}\cdot\nabla\phi - - \nabla\cdot(\kappa\nabla\phi) = 0, - - Diffusivity :math:`\kappa` defaults to ``0.0`` -- pure advection, - identical to the original single-purpose advection-only version of - this class. Set :attr:`diffusivity` (a plain number or a - symbolic/field expression) to solve advection-diffusion instead; no - other API changes are needed and :meth:`solve` is unchanged either way. - - Weak form - --------- - Writing the ADVECTION-ONLY strong-form residual at the - Crank-Nicolson-averaged state - - .. math:: - R = \frac{\phi^{n+1}-\phi^{n}}{\Delta t} - + \mathbf{u}\cdot\nabla\phi_{CN}, - \qquad - \phi_{CN} = \theta\,\phi^{n+1} + (1-\theta)\,\phi^{n} - \quad(\theta=0.5 \Rightarrow \text{Crank-Nicolson}), - - the SUPG-perturbed test function :math:`w + \tau\,\mathbf{u}\cdot\nabla w` - gives the weak form actually assembled here (UW3's residual convention - :math:`\int_\Omega (w F_0 + \nabla w \cdot \mathbf{F}_1)\,d\Omega = 0`): - - .. math:: - F_0 = R, - \qquad - \mathbf{F}_1 = \kappa\,\nabla\phi_{CN} + \tau\,R\,\mathbf{u}. - - The diffusive term enters ONLY through :math:`\mathbf{F}_1`, as a - standard consistent Galerkin flux -- exactly how ``SNES_Poisson`` - builds :math:`\kappa\nabla u` -- never as a literal second derivative - inside :math:`F_0` (which the pointwise-residual PETSc API cannot - express directly; :math:`\int_\Omega w\,[-\nabla\cdot(\kappa\nabla\phi)] - \,d\Omega = \int_\Omega \nabla w\cdot\kappa\nabla\phi\,d\Omega` after - integration by parts, with the boundary term dropped -- i.e. a natural - zero-flux/Neumann condition on any boundary without an explicit - Dirichlet BC). Diffusion is elliptic/self-adjoint and does not need - Petrov-Galerkin stabilisation, so :math:`R` -- and hence the SUPG term - :math:`\tau R\mathbf{u}` -- stays advection-only regardless of - :math:`\kappa`; this is standard SUPG practice, not a simplification. - - :math:`\tau` is the Tezduyar-style inverse-norm stabilisation - parameter, safely regularised for :math:`\Delta t\to0`, - :math:`|\mathbf u|\to0` AND (now) :math:`\kappa\to0`: - - .. math:: - \tau = \left[ \left(\frac{2}{\Delta t}\right)^2 - + \left(\frac{2|\mathbf u|}{h}\right)^2 - + \left(\frac{4\kappa}{h^2}\right)^2 - \right]^{-1/2}, - - with :math:`h` = ``mesh.cell_size()`` (UW3's per-cell characteristic - length). The extra diffusive term keeps :math:`\tau` (and hence the - SUPG correction) from over-stabilising a diffusion-dominated (low - element-Peclet-number) problem, where diffusion's own ellipticity - already provides stability; it vanishes identically at - :math:`\kappa=0`, recovering the pure-advection :math:`\tau` unchanged. - - A separate ``phi_old`` MeshVariable carries :math:`\phi^{n}`, updated - by :meth:`solve` before each call -- there is no ``DuDt``/ - ``SemiLagrangian``/``Lagrangian`` time-derivative handler involved - anywhere in this class. - - Parameters - ---------- - mesh : Mesh - The computational mesh. - u_Field : MeshVariable - Scalar field :math:`\phi` being advected (and, optionally, - diffused). Must be continuous (shared vertex/edge DOFs) -- SUPG - assembles a continuous-Galerkin weak form. - V_fn : MeshVariable.sym or sympy expression - Advecting velocity :math:`\mathbf{u}`. - theta : float, optional - Crank-Nicolson blend (default 0.5); 1.0 is backward-Euler. - diffusivity : float or sympy expression, optional - Diffusivity :math:`\kappa` (default ``0.0`` -- pure advection). A - plain number is captured as a literal inside the compiled F0/F1 - kernels, exactly like ``dt`` -- re-assign :attr:`diffusivity` to - change it later (this forces a rebuild, see the setter). A - symbolic/field expression (e.g. another MeshVariable's ``.sym``) - already updates its own live value with no rebuild needed. - discontinuity_capturing : bool, optional - Add a crosswind discontinuity-capturing (DC) term to F1 (default - ``False``, i.e. plain streamline-only SUPG). Pure SUPG has no - mechanism to damp oscillations ACROSS a steep, under-resolved - front -- this appears as trailing Gibbs-like ringing behind a - translating front, REGARDLESS of diffusivity (it happens even - at diffusivity=0). Turn this on if you see that. See - :meth:`_dc_flux` for the full rationale/formula. - dc_coefficient : float, optional - Discontinuity-capturing strength :math:`C_{dc}` (default - ``1.0``); only matters when ``discontinuity_capturing=True``. - dc_streamwise_weight : float, optional - How much of the ALONG-FLOW component of :math:`\nabla\phi` the - DC flux includes, in ``[0, 1]`` (default ``0.0``). ``0.0`` is - pure crosswind (textbook Hughes-Mallet -- correct for genuinely - multi-D fronts, where the streamline SUPG term already handles - the along-flow direction, but goes essentially INERT for a front - varying only along the flow, e.g. 1D-in-x on a thin 2D strip - mesh with no y-variation). ``1.0`` is the full gradient, which - DOES engage on such a front but now double-counts diffusion in - the same direction SUPG already stabilises -- expect visible - peak-amplitude loss alongside the ripple suppression. Intermediate - values (e.g. ``0.2-0.5``) trade between the two: sweep this - (and/or ``dc_coefficient``) to find the smallest combination that - still suppresses ringing without eating into the peak. - verbose : bool, optional - Enable verbose SNES output. - - Examples - -------- - Pure advection (identical behaviour to the original single-purpose - class this generalises): - - >>> adv = SNES_AdvectionDiffusion_SUPG(mesh, phi, v.sym) - >>> adv.solve(timestep=1e-3) - - Advection-diffusion: - - >>> adv = SNES_AdvectionDiffusion_SUPG(mesh, phi, v.sym, diffusivity=1.0e-4) - >>> adv.solve(timestep=1e-3) - >>> adv.diffusivity = 2.0e-4 # change later; rebuilds automatically - >>> adv.solve(timestep=1e-3) - - Steep-front advection with trailing-ripple suppression: - - >>> adv = SNES_AdvectionDiffusion_SUPG(mesh, phi, v.sym, - ... discontinuity_capturing=True) - >>> adv.solve(timestep=1e-3) - """ - - @timing.routine_timer_decorator - def __init__( - self, - mesh: uw.discretisation.Mesh, - u_Field: uw.discretisation.MeshVariable, - V_fn, - theta: float = 0.5, - diffusivity=0.0, - discontinuity_capturing: bool = False, - dc_coefficient: float = 1.0, - dc_streamwise_weight: float = 0.0, - verbose: bool = False, - ): - if not u_Field.continuous: - raise ValueError( - "`u_Field` must be a CONTINUOUS MeshVariable -- SUPGAdvection " - "assembles a continuous-Galerkin weak form and needs shared " - "vertex/edge DOFs across cells." - ) - - super().__init__(mesh, u_Field, degree=u_Field.degree, verbose=verbose) - - self._constitutive_model = uw.constitutive_models.Constitutive_Model(self.Unknowns) - - if isinstance(V_fn, uw.discretisation.MeshVariable): - V_fn = V_fn.sym - self._V_fn = _as_row_vector(V_fn, mesh.dim) - self.theta_cn = float(theta) - - self.phi_old = uw.discretisation.MeshVariable( - rf"\phi^{{n}}_{{{id(self)}}}", - mesh, 1, degree=u_Field.degree, continuous=u_Field.continuous, - ) - self.phi_old.data[:, 0] = u_Field.data[:, 0] - - self._dt_value = 1.0 - self._last_dt = None - - self._diffusivity = diffusivity - self._last_diffusivity = None - - # Discontinuity-capturing (DC) term: OFF by default, so nothing - # changes for existing callers. See _dc_flux() for the rationale - # -- pure streamline SUPG has no crosswind damping, so a steep, - # under-resolved front can ring (Gibbs-like oscillations) even - # with diffusivity=0. This is additive to F1, not a separate - # code path: discontinuity_capturing=False makes _dc_flux() - # symbolically zero, exactly like diffusivity=0 recovers pure - # advection through the SAME F1 expression rather than a branch. - self._discontinuity_capturing = bool(discontinuity_capturing) - self._dc_coefficient = float(dc_coefficient) - # dc_streamwise_weight=0.0 (default) restricts the DC flux to the - # component of grad(phi) ORTHOGONAL to the flow, on the reasoning - # that tau*R*u already handles the along-flow direction -- this - # is the textbook Hughes-Mallet formulation and is the right - # choice for genuinely multi-D fronts. But it goes essentially - # INERT for a problem where phi varies (near-)only ALONG the - # flow direction (e.g. a 1D-in-x front on a thin 2D strip mesh, - # with no y-variation): there, grad(phi) is already ~parallel to - # u, so the crosswind component ~0 and DC contributes nothing, - # regardless of dc_coefficient. Set this closer to 1.0 to include - # more of the along-flow component (accepting some double- - # counting with the streamline term in exchange for DC actually - # engaging) -- a continuous knob rather than an all-or-nothing - # switch, since dc_streamwise_weight=1.0 (full gradient) visibly - # eats into peak amplitude alongside suppressing ripples. - self._dc_streamwise_weight = float(np.clip(dc_streamwise_weight, 0.0, 1.0)) - - self.petsc_options["snes_rtol"] = 1.0e-8 - self.petsc_options["snes_max_it"] = 20 - - # KSP/PC defaults for a genuinely non-symmetric operator: unlike - # SLCN (whose SemiLagrangian trace-back leaves a much more - # diffusion/Poisson-like, closer-to-SPD system after each step), - # this class solves the FULL SUPG-stabilised convection-diffusion - # operator directly every step -- at high element Peclet number - # (advection-dominated) that operator is strongly non-symmetric - # and non-normal. - # - # PETSc's bare defaults (GMRES, restart=30) commonly STAGNATE on - # exactly this kind of operator: it can land back in essentially - # the same Krylov subspace every 30 iterations, producing a - # residual that's bit-identical for thousands of iterations - # rather than slowly decaying -- easy to mistake for "just needs - # more iterations" when it actually needs a bigger subspace - # and/or a preconditioner that respects the non-symmetry. GAMG - # (algebraic multigrid) is the wrong FAMILY here for the same - # reason: its classical smoothed-aggregation coarsening and - # default Chebyshev/SOR smoothers assume a near-SPD operator - # (elasticity/Poisson) and silently misbehave on this one rather - # than failing loudly. - # - # ASM+ILU with a larger GMRES restart is the standard robust - # choice for non-symmetric SUPG systems at small-to-moderate - # scale; RCM reordering measurably helps ILU's fill-in quality on - # convection-dominated operators specifically. None of this is - # exact (unlike direct LU, which IS exact and fine to use instead - # while your problem stays small/test-scale -- just won't scale - # to a production-size mesh). All overridable after construction, - # e.g. `adv_diff.petsc_options.setValue('pc_type', 'lu')`. - self.petsc_options["ksp_type"] = "gmres" - self.petsc_options["ksp_gmres_restart"] = 200 - self.petsc_options["pc_type"] = "asm" - self.petsc_options["sub_pc_type"] = "ilu" - self.petsc_options["sub_pc_factor_mat_ordering_type"] = "rcm" - - @property - def diffusivity(self): - r"""Diffusivity :math:`\kappa`. ``0.0`` (default) is pure - advection. Re-assigning a genuinely different value forces a - kernel rebuild on the next :meth:`solve` -- see the class - docstring.""" - return self._diffusivity - - @diffusivity.setter - def diffusivity(self, value): - # Same reasoning as the `dt`-change guard in solve(): a plain - # number is captured as a literal inside the compiled F0/F1 - # kernels (via _tau() and F1's sympy expression), so a genuine - # VALUE change needs those kernels re-evaluated and rebuilt. A - # symbolic/field expression (e.g. a MeshVariable.sym) already - # updates its own live value with no rebuild -- the isclose() - # check below can't meaningfully compare those, so it - # conservatively treats a non-numeric value as "changed". - try: - unchanged = np.isclose( - float(self._diffusivity), float(value), rtol=1e-12, atol=1e-15) - except (TypeError, ValueError): - unchanged = False - self._diffusivity = value - if not unchanged: - self.is_setup = False - - @property - def discontinuity_capturing(self): - """Whether the crosswind discontinuity-capturing (DC) term is - added to F1 -- see :meth:`_dc_flux`. ``False`` (default) is - plain streamline-only SUPG, unchanged from before this feature - existed. Re-assigning forces a kernel rebuild.""" - return self._discontinuity_capturing - - @discontinuity_capturing.setter - def discontinuity_capturing(self, value): - value = bool(value) - if value != self._discontinuity_capturing: - self._discontinuity_capturing = value - self.is_setup = False - - @property - def dc_coefficient(self): - r"""Discontinuity-capturing coefficient :math:`C_{dc}` (default - ``1.0``) -- only matters when :attr:`discontinuity_capturing` is - True. Larger values damp front-adjacent ringing more aggressively - but smear the front more; there's no universal "correct" value, - it's a per-problem tuning knob (literature values commonly range - ~0.5-2.0). Re-assigning forces a kernel rebuild.""" - return self._dc_coefficient - - @dc_coefficient.setter - def dc_coefficient(self, value): - try: - unchanged = np.isclose( - float(self._dc_coefficient), float(value), rtol=1e-12, atol=1e-15) - except (TypeError, ValueError): - unchanged = False - self._dc_coefficient = float(value) - if not unchanged: - self.is_setup = False - - @property - def dc_streamwise_weight(self): - """How much of the along-flow component of grad(phi) the DC - flux includes, in [0, 1] (default 0.0 -- pure crosswind, the - textbook choice, but inert for a front varying only along the - flow -- see the constructor docstring). 1.0 is the full gradient - (double-counts with the streamline SUPG term; expect peak- - amplitude loss). Sweep this alongside dc_coefficient to find the - smallest combination that suppresses ringing without eating - into the peak. Re-assigning forces a kernel rebuild.""" - return self._dc_streamwise_weight - - @dc_streamwise_weight.setter - def dc_streamwise_weight(self, value): - value = float(np.clip(value, 0.0, 1.0)) - try: - unchanged = np.isclose( - self._dc_streamwise_weight, value, rtol=1e-12, atol=1e-15) - except (TypeError, ValueError): - unchanged = False - self._dc_streamwise_weight = value - if not unchanged: - self.is_setup = False - - def _sync_diffusivity_from_constitutive_model(self): - """Compatibility bridge for the SLCN-solver idiom - ``adv_diff.constitutive_model = uw.constitutive_models.DiffusionModel; - adv_diff.constitutive_model.Parameters.diffusivity = X``. - - F0/F1 on THIS class are hand-written and never read - ``constitutive_model.flux``/``.K`` (see class docstring) -- the - ``constitutive_model`` attribute otherwise exists only as a - non-None placeholder the base class expects. Without this - bridge, that (very natural, SLCN-idiomatic) assignment is a - SILENT no-op: no error, but the residual keeps using whatever - :attr:`diffusivity` already was (0.0/pure-advection by default), - which is a dangerous trap -- it can produce a well-posed-looking - script that's actually solving the wrong PDE, or (as with - Dirichlet BCs on both ends of a would-be diffusive problem) an - ill-posed one that fails opaquely deep inside the linear solve. - - Best-effort: swallows anything unexpected about the constitutive - model's shape (it's a bridge for an API this class doesn't own), - and only overrides :attr:`diffusivity` when it finds a genuinely - different value to adopt. - """ - cm = getattr(self, "_constitutive_model", None) - if cm is None: - return - try: - cm_kappa = cm.Parameters.diffusivity - except AttributeError: - return - if cm_kappa is None: - return - # Unwrap a UWexpression-like Parameter to its underlying symbol/value. - cm_kappa_val = getattr(cm_kappa, "sym", cm_kappa) - try: - unchanged = np.isclose( - float(self._diffusivity), float(cm_kappa_val), - rtol=1e-12, atol=1e-15) - except (TypeError, ValueError): - unchanged = False - if not unchanged: - warnings.warn( - "SNES_AdvectionDiffusion_SUPG: adopting diffusivity=" - f"{cm_kappa_val} from constitutive_model.Parameters.diffusivity " - "(this class's F0/F1 don't read the constitutive model " - "directly -- set `adv_diff.diffusivity = ...` instead to " - "avoid relying on this compatibility bridge).", - stacklevel=2, - ) - self.diffusivity = cm_kappa_val - - def _tau(self): - """Tezduyar-style advection-diffusion SUPG parameter. - The diffusive term vanishes identically at - diffusivity=0, recovering the pure-advection tau unchanged.""" - dim = self.mesh.dim - u = self._V_fn - u_mag2 = sum(u[0, i] ** 2 for i in range(dim)) - h = self.mesh.cell_size() - inv_dt_term = (2.0 / self._dt_value) ** 2 - inv_h_term = (2.0 * sympy.sqrt(u_mag2) / h) ** 2 - inv_diff_term = (4.0 * self._diffusivity / h**2) ** 2 - return 1.0 / sympy.sqrt(inv_dt_term + inv_h_term + inv_diff_term + 1.0e-30) - - def _phi_cn(self): - """Crank-Nicolson blended state phi_CN = theta*phi + (1-theta)*phi_old.""" - phi = self.u.sym[0, 0] - phi_old = self.phi_old.sym[0, 0] - return self.theta_cn * phi + (1.0 - self.theta_cn) * phi_old - - def _grad_phi_cn(self): - """(1, dim) row matrix grad(phi_CN), shared by the SUPG residual - and the diffusive flux so both see the SAME Crank-Nicolson state.""" - return self.mesh.vector.gradient(self._phi_cn()) - - def _strong_residual(self): - """ADVECTION-ONLY strong residual R = (phi-phi_old)/dt + - u.grad(phi_CN). This is what SUPG stabilises (F1's tau*R*u term) - and is ALSO the complete F0 Galerkin term: diffusion contributes - nothing here by construction (see class docstring) -- it enters - only as a consistent Galerkin flux in F1, so this residual is - identical whether diffusivity is zero or not.""" - dim = self.mesh.dim - grad_cn = self._grad_phi_cn() - u = self._V_fn - advective = sum(u[0, i] * grad_cn[0, i] for i in range(dim)) - phi = self.u.sym[0, 0] - phi_old = self.phi_old.sym[0, 0] - return (phi - phi_old) / self._dt_value + advective - - def _dc_flux(self): - r"""Discontinuity-capturing (DC) flux, Hughes & Mallet (1986) / - Codina (1993) style. Zero (a (1, dim) zero row) unless - :attr:`discontinuity_capturing` is True -- additive to F1 - exactly like the diffusive term, no separate code path. - - Pure streamline SUPG (the `tau*R*u` term) only damps - oscillations ALONG the flow direction; it has no mechanism to - damp them CROSSWIND. On a steep, under-resolved front this shows - up as Gibbs-like ringing trailing the front -- and crucially, - this happens regardless of physical diffusivity (it appears even - at diffusivity=0, pure advection): it's a property of streamline - SUPG's stabilisation, not an interaction with the diffusive - term. - - The fix adds isotropic-looking but effectively CROSSWIND-ONLY - artificial diffusion (the along-flow component is subtracted - out, since tau*R*u already handles that direction -- adding it - again here would double up the streamline diffusion): - - .. math:: - \nu_{dc} = C_{dc}\,h\,\frac{|R^{n}|}{\|\nabla\phi^{n}\|}, - \qquad - \mathbf{F}_{1,dc} = \nu_{dc}\, - \left(\nabla\phi_{CN} - (\nabla\phi_{CN}\cdot\hat{\mathbf u})\,\hat{\mathbf u}\right) - - Note the coefficient :math:`\nu_{dc}` uses the KNOWN, previous- - timestep state (:math:`R^n`, :math:`\nabla\phi^n`, both from - ``phi_old``) -- see the note below on why -- while it multiplies - the CURRENT (unknown) :math:`\nabla\phi_{CN}`. :math:`R^n` is - the advective part of the strong residual evaluated at - ``phi_old`` alone, so :math:`\nu_{dc}` is automatically near-zero - away from steep fronts (where :math:`\nabla\phi^n` is already - small or the flow is locally well-resolved) and only activates - where genuinely needed -- it doesn't add diffusion uniformly - across the domain. - Both the residual-magnitude and gradient-magnitude in the ratio - are regularised (``+1e-30`` inside the sqrt) against division by - a vanishing gradient in smooth regions. - - Crucially, :math:`\nu_{dc}` (the coefficient) is evaluated from - ``phi_old`` ONLY -- never the unknown :math:`\phi^{n+1}` -- and - is applied multiplying :math:`\nabla\phi_{CN}` (linear in the - unknown). A first version of this used the CURRENT strong - residual/gradient for :math:`\nu_{dc}` too, which makes the - whole term genuinely nonlinear in :math:`\phi`: a - :math:`|R|/\|\nabla\phi\|` ratio evaluated at the unknown is a - well-known source of Newton stagnation (the SNES residual - locking onto an exact plateau, ``DIVERGED_LINE_SEARCH`` / - ``DIVERGED_MAX_IT``) rather than a clean single-iteration linear - solve. Freezing :math:`\nu_{dc}` at ``phi_old`` (a standard - lagged/Picard treatment for shock-capturing terms, e.g. Codina - 1993) keeps the whole solve LINEAR -- one Newton iteration, like - the rest of this class -- at the cost of a one-timestep-lagged - coefficient, which is a good trade since :math:`\phi` doesn't - move far in a single step. - """ - dim = self.mesh.dim - if not self._discontinuity_capturing: - return sympy.zeros(1, dim) - - u = self._V_fn - u_mag2 = sum(u[0, i] ** 2 for i in range(dim)) - u_mag = sympy.sqrt(u_mag2 + 1.0e-30) - u_hat = u / u_mag - - # --- nu_dc computed from phi_old ONLY (known, not the SNES - # unknown) -- see docstring above. --------------------------- - grad_old = self.mesh.vector.gradient(self.phi_old.sym[0, 0]) - advective_old = sum(u[0, i] * grad_old[0, i] for i in range(dim)) - grad_norm_old = sympy.sqrt( - sum(grad_old[0, i] ** 2 for i in range(dim)) + 1.0e-30) - # sympy.Abs() would be mathematically correct but sympy can - # rewrite it via re()/im() (real/imaginary part) when it hasn't - # been told the argument is real -- UW3's C99 JIT printer - # doesn't support those. sqrt(x**2 + eps) is a standard - # regularised abs() that sidesteps Abs/re/im entirely, and is - # smooth (differentiable) at 0, which is preferable for - # Newton's method anyway. - abs_R_old = sympy.sqrt(advective_old ** 2 + 1.0e-30) - - h = self.mesh.cell_size() - nu_dc = self._dc_coefficient * h * abs_R_old / grad_norm_old - - # --- applied to the CURRENT (unknown) CN gradient -- blended - # between crosswind-only (weight=0, default, textbook - # Hughes-Mallet) and the full gradient (weight=1, needed for a - # front that varies only along the flow direction, where the - # crosswind component is ~0 and weight=0 would be inert -- see - # dc_streamwise_weight's docstring). Either way this stays - # LINEAR in phi: nu_dc above is now just a known field and - # grad(phi_CN) is a linear (gradient) operator on the unknown. - grad_cn = self._grad_phi_cn() - grad_cn_along_mag = sum(grad_cn[0, i] * u_hat[0, i] for i in range(dim)) - grad_cn_along = grad_cn_along_mag * u_hat - grad_cn_cross = grad_cn - grad_cn_along - grad_cn_target = grad_cn_cross + self._dc_streamwise_weight * grad_cn_along - - return nu_dc * grad_cn_target - - F0 = Template( - r"f_0(\phi)", - lambda self: sympy.Matrix([[self._strong_residual()]]), - "Galerkin (w-tested) part of the residual -- the advection-only " - "strong residual R itself. Diffusion never appears here (it's a " - "flux term, see F1); this term is IDENTICAL whether diffusivity " - "is zero or not.", - ) - F1 = Template( - r"\mathbf{F}_1(\phi)", - lambda self: ( - self._diffusivity * self._grad_phi_cn() - + self._tau() * self._strong_residual() * self._V_fn - + self._dc_flux() - ), - r"Consistent Galerkin diffusive flux kappa*grad(phi_CN) (zero at " - r"diffusivity=0), the SUPG-stabilised advective flux tau*R*u " - r"(\nabla w$-tested), plus the crosswind discontinuity-capturing " - r"flux (zero unless discontinuity_capturing=True).", - ) - - # ------------------------------------------------------------------ - @timing.routine_timer_decorator - def estimate_dt(self, direction_aware: bool = False, percentile: float = 0.0): - r""" - Estimate an appropriate timestep for the advection-diffusion solver. - - Ported from ``SNES_AdvectionDiffusion.estimate_dt`` (the SLCN - solver) -- see that docstring for the full rationale. The only - difference is where :math:`\kappa` comes from: SLCN reads it off - a constitutive model (``self.constitutive_model.K``), whereas - this class carries a plain :attr:`diffusivity` attribute (float - or symbolic/field expression), used directly below. - - Unlike SLCN, this is an EXPLICIT-in-structure SUPG scheme, not - unconditionally stable -- the returned :math:`\delta t` is not - just a *convenience* estimate here, it is closer to a genuine - stability/accuracy requirement for the advective part; see the - ``percentile`` note below for how much margin different - reductions give you. - - This is an implicit (per-step SNES) solver so the returned - :math:`\delta t` is the minimum of: - - - :math:`\delta t_{\textrm{diff}}`: typical time for diffusion across an element - - :math:`\delta t_{\textrm{adv}}`: typical element-crossing time for a fluid parcel - - Parameters - ---------- - direction_aware : bool, default False - If True, the advective dt uses the per-cell extent - *along the local velocity direction* — `h_eff_c = - max_i(s_i) - min_i(s_i)` where `s_i = (x_i - - centroid) · v̂` over the cell vertices. This is the - distance material actually traverses through the cell - per unit ``|v|``, and is **always ≥ the isotropic - mesh._radii estimate**, by 1.5–3× for equant cells - (geometric factor) and up to ~10× for cells that the - mover has stretched along the flow direction. On - adapted meshes the gain is substantial; on uniform - meshes it's the geometric factor only. Off by - default to preserve historical behaviour; safe to - enable everywhere once validated. - percentile : float, default 0.0 - How the per-element timesteps are reduced to one global - value. ``0`` (the default) takes the strict global - MINIMUM — a single cell sets the limit. A value ``> 0`` - takes that global percentile of the per-element dt - instead (``50`` = median), so a few anisotropic sliver - cells (velocity *across* a thin cell) cannot collapse - the timestep. Unlike SLCN, this solver is NOT - unconditionally stable, so a nonzero ``percentile`` here - trades a guaranteed margin for a less conservative dt -- - validate against ``percentile=0`` before trusting it in - production. - - Returns - ------- - pint.Quantity or float - The recommended timestep with physical time units if a model - with reference scales is available, otherwise nondimensional. - """ - - ### required modules - from mpi4py import MPI - - comm = uw.mpi.comm - - # See _sync_diffusivity_from_constitutive_model()'s docstring: - # picks up `constitutive_model.Parameters.diffusivity` if that's - # how the caller set it (the SLCN idiom), so the estimate matches - # what solve() will actually use. - self._sync_diffusivity_from_constitutive_model() - - ## global max diffusivity. SLCN reads this off a constitutive - ## model's unified .K property; this class has no constitutive - ## model wired up for diffusion (see class docstring) -- its - ## diffusivity lives directly on self._diffusivity (float or a - ## symbolic/field expression), which _global_max_diffusivity - ## accepts exactly the same way it accepts self.constitutive_model.K. - diffusivity_glob = _global_max_diffusivity( - self._diffusivity, self.mesh) - - ### velocity values at element centroids (nondimensional) - vel = _centroid_velocities_nd(self._V_fn, self.mesh) - - # Get per-element velocity magnitudes - vel_magnitudes = np.linalg.norm(vel, axis=1) - - # Get per-element radii (characteristic element size) - element_radii = self.mesh._radii - - ## estimate dt of adv and diff components using per-element approach - ## dt_adv_i = h_i / |v_i| for advection - ## dt_diff_i = h_i^2 / κ for diffusion (using global κ for now) - - # Reduce per-element dt to one global value. Default (percentile=0) = - # strict global MINIMUM — one cell sets the limit. percentile>0 takes the - # Nth global percentile (50 = median) of the per-element dt instead, so a - # few anisotropic SLIVER cells (velocity ACROSS a thin cell) don't collapse - # dt -- see the percentile note above: SLCN is unconditionally stable so - # this trade-off is free there, it is NOT free here. - def _reduce_dt(per_elem): - fin = per_elem[np.isfinite(per_elem)] if len(per_elem) else per_elem - if percentile and percentile > 0: - gathered = comm.allgather(np.ascontiguousarray(fin, dtype=float)) - allv = (np.concatenate([a for a in gathered if a.size]) - if any(a.size for a in gathered) else np.empty(0)) - return float(np.percentile(allv, percentile)) if allv.size else np.inf - loc = float(np.min(fin)) if len(fin) else np.inf - return comm.allreduce(loc, op=MPI.MIN) - - # Per-element diffusive timestep (all elements use same diffusivity) - if diffusivity_glob > 0: - dt_diff_per_element = (element_radii ** 2) / diffusivity_glob - else: - dt_diff_per_element = np.array([np.inf]) - - # Per-element advective timestep — either isotropic - # (mesh._radii / |v|) or direction-aware (v-aligned cell - # extent / |v|). - if direction_aware: - # Per-cell vertex indices (triangle / tet). - from underworld3.meshing.smoothing import _tri_cells - tris = _tri_cells(self.mesh.dm) - if tris is None: - # Fall back to isotropic for non-triangle meshes. - h_per_element = element_radii - else: - coords = np.asarray(self.mesh.X.coords) - centroids = coords[tris].mean(axis=1) - # v-hat per cell (use centroid v we already have) - vhat = np.where( - vel_magnitudes[:, None] > 0, - vel / np.maximum(vel_magnitudes[:, None], - 1.0e-30), - 0.0) - D = coords[tris] - centroids[:, None, :] - # Signed projections along v̂ per cell vertex - s = np.einsum('cvd,cd->cv', D, vhat) - h_per_element = s.max(axis=1) - s.min(axis=1) - # Sanity-floor — for zero-velocity cells s=0 - # ⇒ h_eff=0 ⇒ dt_adv=inf via the where below - h_per_element = np.maximum( - h_per_element, 0.0) - else: - h_per_element = element_radii - - with np.errstate(divide='ignore', invalid='ignore'): - dt_adv_per_element = np.where( - vel_magnitudes > 0, - h_per_element / vel_magnitudes, - np.inf - ) - # Global reduction — strict min (percentile=0) or Nth percentile (median). - min_dt_diff_glob = _reduce_dt(dt_diff_per_element) - min_dt_adv_glob = _reduce_dt(dt_adv_per_element) - - # Store for user inspection - self.dt_adv = min_dt_adv_glob if not np.isinf(min_dt_adv_glob) else 0.0 - self.dt_diff = min_dt_diff_glob if not np.isinf(min_dt_diff_glob) else 0.0 - - # Take overall minimum (respecting infinity for zero velocity/diffusivity cases) - dt_estimate = min(min_dt_diff_glob, min_dt_adv_glob) - - # If both are infinite (no velocity and no diffusivity), return infinity - if np.isinf(dt_estimate): - return np.inf - - # Dimensionalise the result to physical time - try: - return uw.dimensionalise(np.squeeze(dt_estimate), {'[time]': 1}) - except Exception: - # Fallback: return plain nondimensional number - return np.squeeze(dt_estimate) - - # ------------------------------------------------------------------ - def solve(self, *, timestep: float = None, zero_init_guess: bool = False, **kwargs) -> None: - """Advance phi by one Crank-Nicolson step of size `timestep`. Updates - phi_old from the current field *before* solving, then performs one - implicit weak-form SNES solve for phi^{n+1} (warm-started from the - current field unless zero_init_guess=True). Identical for pure - advection (diffusivity=0, the default) and advection-diffusion - (diffusivity != 0) -- there is no separate code path. - - `timestep` (matching SNES_AdvectionDiffusion's/SLCN's calling - convention, `.solve(timestep=dt)`) is keyword-only DELIBERATELY: - an earlier version of this class named the parameter `dt` and took - it positionally, and a caller written against SLCN's - `solve(zero_init_guess, timestep, ...)` order that passed a plain - `.solve(dt)` positional call silently landed `dt` in - `zero_init_guess` instead, leaving `timestep` at its default and - producing a `None`-propagation crash two calls deeper (see - ``ddt.py``'s `_trace_departure_points`, `0.5 * dt_for_calc` with - `dt_for_calc=None`) instead of a clear error at the call site - itself. Keyword-only trades that silent mis-binding for an - immediate, loud `TypeError` if a caller ever gets this wrong again. - """ - if timestep is None: - raise ValueError( - "SNES_AdvectionDiffusion_SUPG.solve() requires `timestep` " - "(e.g. `adv_diff.solve(timestep=dt)`) -- there is no default." - ) - self._sync_diffusivity_from_constitutive_model() - dt = float(timestep) - self.phi_old.data[:, 0] = self.u.data[:, 0] - if dt != self._last_dt: - # dt is captured as a plain float inside the F0/F1 lambdas, so - # a genuine change in dt needs the residual re-evaluated (and - # hence the DS/JIT kernels rebuilt) -- but only THEN, not on - # every call with an unchanged dt, which would force a needless - # rebuild every single timestep. - self._dt_value = dt - self._last_dt = dt - self.is_setup = False - super().solve(zero_init_guess=zero_init_guess, **kwargs) \ No newline at end of file diff --git a/src/underworld3/systems/test_tem/Ex_AdvectionDiffusion_1dBlock_slcn.py b/src/underworld3/systems/test_tem/Ex_AdvectionDiffusion_1dBlock_slcn.py deleted file mode 100644 index b34089851..000000000 --- a/src/underworld3/systems/test_tem/Ex_AdvectionDiffusion_1dBlock_slcn.py +++ /dev/null @@ -1,252 +0,0 @@ -# --- -# jupyter: -# jupytext: -# text_representation: -# extension: .py -# format_name: light -# format_version: '1.5' -# jupytext_version: 1.16.1 -# kernelspec: -# display_name: Python 3 (ipykernel) -# language: python -# name: python3 -# --- - -# # Advection-diffusion (1d / cross mesh) -# -# - Using the adv_diff solver. -# - Advection of the rectangular pulse vertically as it also diffuses. The velocity is 0.05 and has a diffusivity value of 1, 0.1 or 0.01 -# - Benchmark comparison between 1D analytical solution and 2D UW numerical model. -# -# ![](Figures/AdvectionTestFigure.png) -# -# *Figure: typical results from this test. Quad mesh v. unstructured triangles with equivalent -# resolution. $\kappa=1$, $\mathbf{v}=(1000,0)$, $t_0 = 0.0001$, $\delta t = 0.0003$. The error looks significantly larger with triangles but you can see that it is dominated by a relatively small* phase error *where the speed of propagation is slightly different from the analytic case.* -# -# -# ## How to test advection or diffusion only -# - Set velocity to 0 to test diffusion only. -# - Set diffusivity (k) to 0 to test advection only. -# -# -# ## Analytic solution -# -# $$ -# T(x,t) = -# \frac{\operatorname{erf}{\left(\frac{- \mathrm{x} + v \left(t + {t_0}\right) + \frac{{\delta}}{2} + {x_0}}{2 \sqrt{\kappa \left(t + {t_0}\right)}} \right)}}{2} + \frac{\operatorname{erf}{\left(\frac{\mathrm{x} - v \left(t + {t_0}\right) + \frac{{\delta}}{2} - {x_0}}{2 \sqrt{\kappa \left(t + {t_0}\right)}} \right)}}{2} -# $$ -# -# Where $x,y$ describe the coordinate frame, $v$ is the horizontal velocity that advects the temperature, $\delta$ is the width of the temperature anomaly, $x_0$ is the initial midpoint of the temperature anomaly. $\kappa$ is the thermal diffusivity, $t_0$ is the time at which we turn on the horizontal velocity. -# -# Note: this solution is derived from the diffusion of a step which is applied to the leading and trailing edges of the block. The solution is valid while the diffusion fronts from each interface remain independent of each other. (This is ill-defined from the problem, but the most obvious test is to look a the time that the block temperature drops below 1 to the tolerance of the solver). -# - -import nest_asyncio -nest_asyncio.apply() - -import underworld3 as uw -import numpy as np -import sympy -import math -import os - -from scipy import special - -if uw.mpi.size == 1: - import matplotlib.pyplot as plt - -import underworld3.systems.level_set_SLCN as ls_slcn -import underworld3.systems.level_set_SUPG as ls_supg - -import sys - -init_t = 0.0001 -dt = 0.0006 -velocity = 1000. -centre = 0.1 -width = 0.2 - - -### min and max temps -tmin = 0. # temp min -tmax = 1.0 # temp max - -# I think we should get into the habit of doing this consistently with the PETSc interface - -res = uw.options.getReal("model_resolution", default=16) -kappa = uw.options.getInt("kappa", default=1) -Tdegree = uw.options.getInt("Tdeg", default=3) -Vdegree = uw.options.getInt("Vdeg", default=2) -simplex = uw.options.getBool("simplex", default=True) - - - -# Tdegree = int(sys.argv[1]) -# Vdegree = int(sys.argv[2]) -# kappa = float(sys.argv[3]) # 1, 0.1, 0.01 # diffusive constant -# res = int(sys.argv[4]) -# simplex = sys.argv[5].lower() - - -outputPath = f'./output/1dblock_adv_diff_slcn/' - -if uw.mpi.rank == 0: - # checking if the directory - if not os.path.exists(outputPath): - os.makedirs(outputPath) -# - - - -xmin, xmax = 0, 1 -ymin, ymax = 0, 0.2 - - -if simplex == True: - mesh = uw.meshing.UnstructuredSimplexBox( - minCoords=(xmin, ymin), maxCoords=(xmax, ymax), cellSize=(ymax-ymin)/res, regular=False, qdegree=max(Tdegree, Vdegree) ) -else: - mesh = uw.meshing.StructuredQuadBox( - elementRes=(int(res)*5, int(res)), minCoords=(xmin, ymin), maxCoords=(xmax, ymax), qdegree=max(Tdegree, Vdegree), - ) - - -x,y = mesh.X - -x0 = sympy.symbols(r"{x_0}") -t0 = sympy.symbols(r"{t_0}") -delta = sympy.symbols(r"{\delta}") -ks = sympy.symbols(r"\kappa") -ts = sympy.symbols("t") -vs = sympy.symbols("v") - -Ts = ( sympy.erf( (x0 + delta/2 - x+(vs*(ts+t0))) / (2*sympy.sqrt(ks*(ts+t0)))) + sympy.erf( (-x0 + delta/2 + x-((ts+t0)*vs)) / (2*sympy.sqrt(ks*(ts+t0)))) ) / 2 -Ts - - -def build_analytic_fn_at_t(time): - fn = Ts.subs({vs:velocity, ts:time, ks:kappa, delta:width, x0:centre, t0:init_t}) - return fn - -Ts0 = build_analytic_fn_at_t(time=0.0) -TsVKT = build_analytic_fn_at_t(time=dt) - -# + -# Create the mesh var -T = uw.discretisation.MeshVariable("T", mesh, 1, degree=Tdegree) - -# This is the velocity field - -v = sympy.Matrix([velocity, 0]) -# - - -# #### Create the advDiff solver - -adv_diff = uw.systems.AdvDiffusionSLCN( - mesh, - u_Field=T, - V_fn=v, -) - - -adv_diff.constitutive_model = uw.constitutive_models.DiffusionModel -adv_diff.constitutive_model.Parameters.diffusivity = 1.0 - -adv_diff.constitutive_model.Parameters.diffusivity.value - -adv_diff.add_dirichlet_bc(tmin, "Left") -adv_diff.add_dirichlet_bc(tmin, "Right") - - -print(adv_diff.estimate_dt()) -steps = 10 - - -with mesh.access(T): - T.data[:,0] = uw.function.evaluate(Ts0, T.coords)[:,0,0] - -step = 0 -model_time = 0.0 - -adv_diff.petsc_options["snes_monitor_short"] = None - - -for step in range(0, steps): - mesh.write_timestep("mesh", meshUpdates=False, meshVars=[T], - outputPath=outputPath, index=step) - adv_diff.solve(timestep=dt/steps, zero_init_guess=False) - model_time += dt/steps - print(f"Timestep: {step}/{steps}, model time {model_time}") - -if uw.mpi.size == 1: - - import pyvista as pv - import underworld3.visualisation as vis - - pvmesh = vis.mesh_to_pv_mesh(mesh) - pvmesh.point_data["V"] = vis.vector_fn_to_pv_points(pvmesh, sympy.Matrix([velocity, 0]).T) - pvmesh.point_data["T"] = vis.scalar_fn_to_pv_points(pvmesh, T.sym) - pvmesh.point_data["Ta"] = vis.scalar_fn_to_pv_points(pvmesh, Ts0) - pvmesh.point_data["dT"] = pvmesh.point_data["T"] - pvmesh.point_data["Ta"] - - T_points = vis.meshVariable_to_pv_cloud(T) - T_points.point_data["T"] = vis.scalar_fn_to_pv_points(T_points, T.sym) - T_points.point_data["Ta"] = vis.scalar_fn_to_pv_points(T_points, TsVKT) - T_points.point_data["T0"] = vis.scalar_fn_to_pv_points(T_points, Ts0) - T_points.point_data["Tp"] = (T_points.point_data["T0"] + T_points.point_data["Ta"])/2 - T_points.point_data["dT"] = T_points.point_data["T"] - T_points.point_data["Ta"] - - pvmesh2 = vis.mesh_to_pv_mesh(mesh) - pvmesh2.point_data["T"] = vis.scalar_fn_to_pv_points(pvmesh2, T.sym) - pvmesh2.point_data["T0"] = vis.scalar_fn_to_pv_points(pvmesh2, Ts0) - pvmesh2.points[:,1] += 0.3 - - pvmesh3 = vis.mesh_to_pv_mesh(mesh) - pvmesh3.point_data["Ta"] = vis.scalar_fn_to_pv_points(pvmesh2, TsVKT) - pvmesh3.points[:,1] -= 0.3 - - - pl = pv.Plotter() - - pl.add_mesh( - pvmesh2, - cmap="coolwarm", - edge_color="Black", - show_edges=True, - scalars="T0", - use_transparency=False, - show_scalar_bar=False, - opacity=1, - ) - - - pl.add_mesh( - - pvmesh3, - cmap="coolwarm", - edge_color="Black", - show_edges=True, - scalars="Ta", - use_transparency=False, - show_scalar_bar=False, - opacity=1, - ) - - pl.add_points(T_points, color="White", - scalars="dT", cmap="coolwarm", - point_size=5.0, opacity=0.5) - - - pl.add_arrows(pvmesh.points, pvmesh.point_data["V"], mag=0.00003, opacity=0.5, show_scalar_bar=False) - - # pl.add_points(pdata) - - pl.show(cpos="xy",screenshot=outputPath+"output.png") - - - # return vsol - -T_points.point_data["dT"].max() - -adv_diff - -adv_diff.F1 \ No newline at end of file diff --git a/src/underworld3/systems/test_tem/Ex_AdvectionDiffusion_1dBlock_supg_dcterm.py b/src/underworld3/systems/test_tem/Ex_AdvectionDiffusion_1dBlock_supg_dcterm.py deleted file mode 100644 index cbe59d4fd..000000000 --- a/src/underworld3/systems/test_tem/Ex_AdvectionDiffusion_1dBlock_supg_dcterm.py +++ /dev/null @@ -1,225 +0,0 @@ -# --- -# jupyter: -# jupytext: -# text_representation: -# extension: .py -# format_name: light -# format_version: '1.5' -# jupytext_version: 1.16.1 -# kernelspec: -# display_name: Python 3 (ipykernel) -# language: python -# name: python3 -# --- - -# # Advection-diffusion (1d / cross mesh) -# -# - Using the adv_diff solver. -# - Advection of the rectangular pulse vertically as it also diffuses. The velocity is 0.05 and has a diffusivity value of 1, 0.1 or 0.01 -# - Benchmark comparison between 1D analytical solution and 2D UW numerical model. -# -# ![](Figures/AdvectionTestFigure.png) -# -# *Figure: typical results from this test. Quad mesh v. unstructured triangles with equivalent -# resolution. $\kappa=1$, $\mathbf{v}=(1000,0)$, $t_0 = 0.0001$, $\delta t = 0.0003$. The error looks significantly larger with triangles but you can see that it is dominated by a relatively small* phase error *where the speed of propagation is slightly different from the analytic case.* -# -# -# ## How to test advection or diffusion only -# - Set velocity to 0 to test diffusion only. -# - Set diffusivity (k) to 0 to test advection only. -# -# -# ## Analytic solution -# -# $$ -# T(x,t) = -# \frac{\operatorname{erf}{\left(\frac{- \mathrm{x} + v \left(t + {t_0}\right) + \frac{{\delta}}{2} + {x_0}}{2 \sqrt{\kappa \left(t + {t_0}\right)}} \right)}}{2} + \frac{\operatorname{erf}{\left(\frac{\mathrm{x} - v \left(t + {t_0}\right) + \frac{{\delta}}{2} - {x_0}}{2 \sqrt{\kappa \left(t + {t_0}\right)}} \right)}}{2} -# $$ -# -# Where $x,y$ describe the coordinate frame, $v$ is the horizontal velocity that advects the temperature, $\delta$ is the width of the temperature anomaly, $x_0$ is the initial midpoint of the temperature anomaly. $\kappa$ is the thermal diffusivity, $t_0$ is the time at which we turn on the horizontal velocity. -# -# Note: this solution is derived from the diffusion of a step which is applied to the leading and trailing edges of the block. The solution is valid while the diffusion fronts from each interface remain independent of each other. (This is ill-defined from the problem, but the most obvious test is to look a the time that the block temperature drops below 1 to the tolerance of the solver). -# - -import nest_asyncio -nest_asyncio.apply() - -import underworld3 as uw -import numpy as np -import sympy -import math -import os - -from scipy import special - -if uw.mpi.size == 1: - import matplotlib.pyplot as plt - -import sys - -init_t = 0.0001 -dt = 0.0006 -velocity = 1000. -centre = 0.1 -width = 0.2 - - -tmin = 0. # temp min -tmax = 1.0 # temp max - -res = uw.options.getReal("model_resolution", default=16) -kappa = uw.options.getInt("kappa", default=1) -Tdegree = uw.options.getInt("Tdeg", default=3) -Vdegree = uw.options.getInt("Vdeg", default=2) -simplex = uw.options.getBool("simplex", default=True) - -outputPath = f'./output/1dblock_adv_diff_supg_dcterm/' - -if uw.mpi.rank == 0: - # checking if the directory - if not os.path.exists(outputPath): - os.makedirs(outputPath) - - -xmin, xmax = 0, 1 -ymin, ymax = 0, 0.2 - -## Quads -if simplex == True: - mesh = uw.meshing.UnstructuredSimplexBox( - minCoords=(xmin, ymin), maxCoords=(xmax, ymax), cellSize=(ymax-ymin)/res, regular=False, qdegree=max(Tdegree, Vdegree) ) -else: - mesh = uw.meshing.StructuredQuadBox( - elementRes=(int(res)*5, int(res)), minCoords=(xmin, ymin), maxCoords=(xmax, ymax), qdegree=max(Tdegree, Vdegree), - ) - -x,y = mesh.X - -x0 = sympy.symbols(r"{x_0}") -t0 = sympy.symbols(r"{t_0}") -delta = sympy.symbols(r"{\delta}") -ks = sympy.symbols(r"\kappa") -ts = sympy.symbols("t") -vs = sympy.symbols("v") - -Ts = ( sympy.erf( (x0 + delta/2 - x+(vs*(ts+t0))) / (2*sympy.sqrt(ks*(ts+t0)))) + sympy.erf( (-x0 + delta/2 + x-((ts+t0)*vs)) / (2*sympy.sqrt(ks*(ts+t0)))) ) / 2 - -def build_analytic_fn_at_t(time): - fn = Ts.subs({vs:velocity, ts:time, ks:kappa, delta:width, x0:centre, t0:init_t}) - return fn - -Ts0 = build_analytic_fn_at_t(time=0.0) -TsVKT = build_analytic_fn_at_t(time=dt) - - -T = uw.discretisation.MeshVariable("T", mesh, 1, degree=Tdegree) - -v = sympy.Matrix([velocity, 0]) - - -from underworld3.systems import AdvDiffusionSUPG -adv_diff = AdvDiffusionSUPG( - mesh, u_Field=T, V_fn=v, - diffusivity=0.0, - discontinuity_capturing=True, - dc_coefficient=0.2, - dc_streamwise_weight=0.3, # was: dc_crosswind_only=False (i.e. weight=1.0) -) - -adv_diff.add_dirichlet_bc(tmin, "Left") -adv_diff.add_dirichlet_bc(tmin, "Right") - -print(adv_diff.estimate_dt()) -steps = int(dt // (12*adv_diff.estimate_dt())) - - -with mesh.access(T): - T.data[:,0] = uw.function.evaluate(Ts0, T.coords)[:,0,0] - -step = 0 -model_time = 0.0 - -adv_diff.petsc_options["snes_monitor_short"] = None - - -for step in range(0, steps): - mesh.write_timestep("mesh", meshUpdates=False, meshVars=[T], - outputPath=outputPath, index=step) - adv_diff.solve(timestep=dt/steps, zero_init_guess=False) - model_time += dt/steps - print(f"Timestep: {step}/{steps}, model time {model_time}") - - -if uw.mpi.size == 1: - - import pyvista as pv - import underworld3.visualisation as vis - - pvmesh = vis.mesh_to_pv_mesh(mesh) - pvmesh.point_data["V"] = vis.vector_fn_to_pv_points(pvmesh, sympy.Matrix([velocity, 0]).T) - pvmesh.point_data["T"] = vis.scalar_fn_to_pv_points(pvmesh, T.sym) - pvmesh.point_data["Ta"] = vis.scalar_fn_to_pv_points(pvmesh, Ts0) - pvmesh.point_data["dT"] = pvmesh.point_data["T"] - pvmesh.point_data["Ta"] - - T_points = vis.meshVariable_to_pv_cloud(T) - T_points.point_data["T"] = vis.scalar_fn_to_pv_points(T_points, T.sym) - T_points.point_data["Ta"] = vis.scalar_fn_to_pv_points(T_points, TsVKT) - T_points.point_data["T0"] = vis.scalar_fn_to_pv_points(T_points, Ts0) - T_points.point_data["Tp"] = (T_points.point_data["T0"] + T_points.point_data["Ta"])/2 - T_points.point_data["dT"] = T_points.point_data["T"] - T_points.point_data["Ta"] - - pvmesh2 = vis.mesh_to_pv_mesh(mesh) - pvmesh2.point_data["T"] = vis.scalar_fn_to_pv_points(pvmesh2, T.sym) - pvmesh2.point_data["T0"] = vis.scalar_fn_to_pv_points(pvmesh2, Ts0) - pvmesh2.points[:,1] += 0.3 - - pvmesh3 = vis.mesh_to_pv_mesh(mesh) - pvmesh3.point_data["Ta"] = vis.scalar_fn_to_pv_points(pvmesh2, TsVKT) - pvmesh3.points[:,1] -= 0.3 - - - pl = pv.Plotter() - - pl.add_mesh( - pvmesh2, - cmap="coolwarm", - edge_color="Black", - show_edges=True, - scalars="T0", - use_transparency=False, - show_scalar_bar=False, - opacity=1, - ) - - - pl.add_mesh( - - pvmesh3, - cmap="coolwarm", - edge_color="Black", - show_edges=True, - scalars="Ta", - use_transparency=False, - show_scalar_bar=False, - opacity=1, - ) - - pl.add_points(T_points, color="White", - scalars="dT", cmap="coolwarm", - point_size=5.0, opacity=0.5) - - - pl.add_arrows(pvmesh.points, pvmesh.point_data["V"], mag=0.00003, opacity=0.5, show_scalar_bar=False) - - # pl.add_points(pdata) - - pl.show(cpos="xy",screenshot=outputPath+"output.png") - - - # return vsol - -T_points.point_data["dT"].max() - -adv_diff - -adv_diff.F1 \ No newline at end of file diff --git a/src/underworld3/systems/test_tem/LeVeque_swirling_supg_vs_slcn.py b/src/underworld3/systems/test_tem/LeVeque_swirling_supg_vs_slcn.py deleted file mode 100644 index cef3ff54c..000000000 --- a/src/underworld3/systems/test_tem/LeVeque_swirling_supg_vs_slcn.py +++ /dev/null @@ -1,343 +0,0 @@ -#!/usr/bin/env python -# coding: utf-8 -""" -LeVeque (1996) swirling deformation-flow benchmark -==================================================== - -Runs the SAME conservative level-set (CLS) advection problem through TWO -independent solvers on the SAME mesh, under the SAME analytic velocity -field, and compares them head-to-head: - - * ``level_set_SUPG.LevelSetSolver`` -- Crank-Nicolson + SUPG - (Brooks & Hughes 1982), a hand-built implicit weak-form solve on - ``uw.systems.SNES_Scalar`` (see ``SUPGAdvection``). - * ``level_set_SLCN.LevelSetSolver`` -- UW3's canned - ``AdvDiffusionSLCN`` (i.e. ``SNES_AdvectionDiffusion`` + - ``SemiLagrangian``), the "old"/built-in solver. - -Benchmark ---------- -LeVeque, R.J. (1996), "High-resolution conservative algorithms for -advection in incompressible flow," SIAM J. Numer. Anal. 33(2):627-665, -introduced the "swirling deformation flow" velocity field derived from -the stream function - - psi(x,y,t) = (1/pi) sin^2(pi*x) sin^2(pi*y) cos(pi*t/T), - -giving - - u = -dpsi/dy = -sin^2(pi*x) sin(2*pi*y) cos(pi*t/T) - v = dpsi/dx = sin^2(pi*y) sin(2*pi*x) cos(pi*t/T). - -This is the SAME formula used in the earlier ``SingleVortex_*`` scripts -(it is the standard "single vortex" test of Bell, Colella & Glaz (1989) -/ Enright et al. (2002), who use exactly this LeVeque stream function -with T=8 -- the two names refer to the same benchmark in the level-set -literature). The cos(pi*t/T) modulation makes the flow time-reversing: -the swirl runs "forward" for t in [0, T/2), stretching/spiralling the -interface into thin filaments, then EXACTLY reverses, so at t=T the -interface should return to its initial shape and position. That -round-trip is what makes this such a discriminating test -- any -irreversible numerical diffusion (interpolation smoothing in a -semi-Lagrangian trace-back, or over-diffusive stabilisation) shows up -directly as a FAILURE to recover the sharp initial shape, not just as a -transient blur that self-heals. - -Diagnostics recorded for each solver, at every save interval: - - * ``interface_volume`` -- ∫phi dΩ (mass-conservation drift). - * shape (L2) error -- sqrt(∫(phi - phi_0)^2 dΩ), phi_0 the - FROZEN initial field; large mid-run - (filaments under-resolved / no longer - matching phi_0's position) but should - return close to its t=0 value (~0) at - t=T if the round-trip is well resolved. - * wall-clock time per `solve(dt)` call (advection + reinitialisation + - mass correction together, i.e. the full per-step user-facing cost). - -Output: a comparison plot (volume drift, shape error, cumulative -wall-clock, all vs model time) plus periodic XDMF/HDF5 checkpoints for -each solver in separate folders for Paraview inspection, and a short -printed summary table at t=T. - -Usage ------ - python LeVeque_swirling_supg_vs_slcn.py [--xres 64] [--T 8.0] [--severity] - -``--severity`` is a shortcut for a shorter reversal period (T=2, the -value LeVeque's own paper favours) which reverses BEFORE the filaments -have thinned as much -- a gentler round-trip, useful as a quick sanity -check before committing to the full T=8 filament-resolution stress test. -""" - -import argparse -import os -import sys -import time -from datetime import datetime - -import numpy as np -import matplotlib.pyplot as plt -import sympy -from mpi4py import MPI - -import underworld3 as uw - -import underworld3.systems.level_set_SLCN as ls_slcn -import underworld3.systems.level_set_SUPG as ls_supg - - -# ============================================================================= -# CLI / problem setup -# ============================================================================= - -parser = argparse.ArgumentParser(description=__doc__) -parser.add_argument("--xres", type=int, default=64, help="mesh resolution (square)") -parser.add_argument("--T", type=float, default=8.0, - help="reversal period T (LeVeque's own paper uses 2; " - "Enright et al. 2002 use 8 for a much more severe " - "filament-stretching stress test -- default here)") -parser.add_argument("--severity", action="store_true", - help="shortcut for --T 2.0 (gentler round-trip)") -parser.add_argument("--save-dtime", type=float, default=None, - help="model-time interval between diagnostics/checkpoints " - "(default: T/64)") -parser.add_argument("--outdir", type=str, default="op_LeVeque_swirling_supg_vs_slcn/") -args = parser.parse_args() - -xmin, xmax = 0.0, 1.0 -ymin, ymax = 0.0, 1.0 -xres = yres = args.xres - -T_reversal = 2.0 if args.severity else args.T -dt_set = 0.5 / xres # same CFL-based dt convention as SingleVortex_* -save_dtime = args.save_dtime if args.save_dtime is not None else T_reversal / 64.0 -save_every = max(1, int(np.round(save_dtime / dt_set))) -max_steps = int(np.round(save_every * (T_reversal / save_dtime))) + 1 - -outputPath = args.outdir -if uw.mpi.rank == 0: - for sub in ("supg", "slcn"): - p = os.path.join(outputPath, sub) - os.makedirs(p, exist_ok=True) - for f in os.listdir(p): - os.remove(os.path.join(p, f)) - print(f"LeVeque swirling deformation flow: xres={xres}, T={T_reversal}, " - f"dt={dt_set:.5g}, max_steps={max_steps}, save_every={save_every}") - - -# ============================================================================= -# Mesh + shared velocity field (identical for both solvers -> a fair, purely -# solver-attributable comparison -- neither solver ever sees a different u) -# ============================================================================= - -mesh = uw.meshing.StructuredQuadBox( - elementRes=(xres, yres), minCoords=(xmin, ymin), maxCoords=(xmax, ymax)) - -v = uw.discretisation.MeshVariable("V", mesh, mesh.dim, degree=2, continuous=True) -timeField = uw.discretisation.MeshVariable("time", mesh, 1, degree=1) - -x, y = mesh.N.x, mesh.N.y - - -def make_velocity_expr(t_val: float): - """LeVeque (1996) swirling deformation-flow velocity at model time t_val, - from stream function psi = (1/pi) sin^2(pi x) sin^2(pi y) cos(pi t/T).""" - stream = (1 / sympy.pi) * sympy.sin(sympy.pi * x) ** 2 * sympy.sin(sympy.pi * y) ** 2 - u_ = -sympy.diff(stream, y) - v_ = sympy.diff(stream, x) - modulation = sympy.cos(sympy.pi * t_val / T_reversal) - return sympy.Matrix([[u_ * modulation, v_ * modulation]]) - - -def update_velocity(t_val: float): - v_expr = make_velocity_expr(t_val) - with mesh.access(v): - v.data[:, 0] = uw.function.evaluate(v_expr[0, 0], v.coords)[:, 0, 0] - v.data[:, 1] = uw.function.evaluate(v_expr[0, 1], v.coords)[:, 0, 0] - - -# ============================================================================= -# Two independent level-set fields, SAME initial geometry, one per solver -# ============================================================================= - -radius = 0.15 -centre = [0.5, 0.75] -num_points = 91 -angles = np.linspace(0, 2 * np.pi, num_points) -x0 = radius * np.cos(angles) + centre[0] -y0 = radius * np.sin(angles) + centre[1] -interface_coords = np.ascontiguousarray(np.array([x0, y0]).T) -polygon = np.vstack((interface_coords, interface_coords[0, :])) - -psi_supg = uw.discretisation.MeshVariable(r"\psi_{supg}", mesh, 1, degree=2, continuous=True) -psi_slcn = uw.discretisation.MeshVariable(r"\psi_{slcn}", mesh, 1, degree=2, continuous=True) - -eps_supg = ls_supg.interface_thickness(mesh, psi_supg, scale=0.35) -eps_slcn = ls_slcn.interface_thickness(mesh, psi_slcn, scale=0.35) - -ls_supg.initialise_psi(psi_supg, eps_supg, interface_geometry="polygon", - interface_coordinates=polygon) -ls_slcn.initialise_psi(psi_slcn, eps_slcn, interface_geometry="polygon", - interface_coordinates=polygon) - -# Frozen t=0 snapshots for the round-trip shape-error metric. -psi0_supg = uw.discretisation.MeshVariable(r"\psi^0_{supg}", mesh, 1, degree=2, continuous=True) -psi0_slcn = uw.discretisation.MeshVariable(r"\psi^0_{slcn}", mesh, 1, degree=2, continuous=True) -with mesh.access(psi0_supg, psi0_slcn): - psi0_supg.data[:, 0] = psi_supg.data[:, 0] - psi0_slcn.data[:, 0] = psi_slcn.data[:, 0] - -solver_supg = ls_supg.LevelSetSolver( - psi_supg, velocity=v.sym, epsilon=eps_supg, reini_steps=1, reini_frequency=5) -solver_slcn = ls_slcn.LevelSetSolver( - psi_slcn, velocity=v.sym, epsilon=eps_slcn, reini_steps=1, reini_frequency=5) - -initial_area = np.pi * radius ** 2 - - -def shape_error(psi, psi0): - """sqrt(integral((psi - psi0)^2) dOmega) -- 0 for a perfect round-trip.""" - integ = uw.maths.Integral(mesh, (psi.sym[0, 0] - psi0.sym[0, 0]) ** 2) - return float(np.sqrt(max(integ.evaluate(), 0.0))) - - -# ============================================================================= -# Time loop -- both solvers advanced by the SAME dt, under the SAME v, at -# the SAME model times, so any divergence between them is attributable -# purely to the advection scheme. -# ============================================================================= - -history = { - "t": [], - "vol_supg": [], "vol_slcn": [], - "err_supg": [], "err_slcn": [], - "cumtime_supg": [], "cumtime_slcn": [], -} -walltime_supg = 0.0 -walltime_slcn = 0.0 - -step, time_now, dt = 0, 0.0, 0.0 - -while step < max_steps: - if uw.mpi.rank == 0: - msg = (f"Step: {step:5d} Model Time: {time_now:7.4f} dt: {dt:7.4f} " - f"({datetime.now().strftime('%Y-%m-%d %H:%M:%S')})\n") - sys.stdout.write(msg) - sys.stdout.flush() - - update_velocity(time_now) - - if step % save_every == 0: - vol_supg = solver_supg.interface_volume() - vol_slcn = solver_slcn.interface_volume() - err_supg = shape_error(psi_supg, psi0_supg) - err_slcn = shape_error(psi_slcn, psi0_slcn) - - history["t"].append(time_now) - history["vol_supg"].append(vol_supg) - history["vol_slcn"].append(vol_slcn) - history["err_supg"].append(err_supg) - history["err_slcn"].append(err_slcn) - history["cumtime_supg"].append(walltime_supg) - history["cumtime_slcn"].append(walltime_slcn) - - if uw.mpi.rank == 0: - print(f" SUPG: volume={vol_supg:.6f} " - f"(drift={100*(vol_supg-initial_area)/initial_area:+.3f}%) " - f"shape_err={err_supg:.5e} cum_wall={walltime_supg:.2f}s") - print(f" SLCN: volume={vol_slcn:.6f} " - f"(drift={100*(vol_slcn-initial_area)/initial_area:+.3f}%) " - f"shape_err={err_slcn:.5e} cum_wall={walltime_slcn:.2f}s") - - timeField.data[:, 0] = time_now - mesh.write_timestep("mesh", meshUpdates=False, meshVars=[v, psi_supg, timeField], - outputPath=os.path.join(outputPath, "supg"), index=step) - mesh.write_timestep("mesh", meshUpdates=False, meshVars=[v, psi_slcn, timeField], - outputPath=os.path.join(outputPath, "slcn"), index=step) - - dt = dt_set - - t0 = time.perf_counter() - solver_supg.solve(dt=dt) - dt_wall = time.perf_counter() - t0 - walltime_supg += (dt_wall if uw.mpi.comm.size == 1 - else uw.mpi.comm.allreduce(dt_wall, op=MPI.MAX)) - - t0 = time.perf_counter() - solver_slcn.solve(dt=dt) - dt_wall = time.perf_counter() - t0 - walltime_slcn += (dt_wall if uw.mpi.comm.size == 1 - else uw.mpi.comm.allreduce(dt_wall, op=MPI.MAX)) - - step += 1 - time_now += dt - - -# ============================================================================= -# Final round-trip summary (flow has returned to t=0 configuration) -# ============================================================================= - -final_vol_supg = solver_supg.interface_volume() -final_vol_slcn = solver_slcn.interface_volume() -final_err_supg = shape_error(psi_supg, psi0_supg) -final_err_slcn = shape_error(psi_slcn, psi0_slcn) - -if uw.mpi.rank == 0: - print("\n" + "=" * 70) - print(f"LeVeque swirling deformation flow -- round-trip summary at t={time_now:.4f}") - print("=" * 70) - print(f"{'':14s}{'volume drift %':>16s}{'shape L2 error':>18s}{'total wall (s)':>18s}") - print(f"{'SUPG':14s}{100*(final_vol_supg-initial_area)/initial_area:16.4f}" - f"{final_err_supg:18.5e}{walltime_supg:18.2f}") - print(f"{'SLCN (old)':14s}{100*(final_vol_slcn-initial_area)/initial_area:16.4f}" - f"{final_err_slcn:18.5e}{walltime_slcn:18.2f}") - print("=" * 70) - print("Lower shape L2 error at t=T = better round-trip shape recovery " - "(less irreversible numerical diffusion). Lower |volume drift| = " - "better mass conservation. Lower total wall time = faster.") - - -# ============================================================================= -# Comparison plot -# ============================================================================= - -if uw.mpi.rank == 0: - t_arr = np.array(history["t"]) - fig, axes = plt.subplots(1, 3, figsize=(15, 4.2)) - - ax = axes[0] - ax.plot(t_arr, 100 * (np.array(history["vol_supg"]) - initial_area) / initial_area, - label="SUPG", lw=2) - ax.plot(t_arr, 100 * (np.array(history["vol_slcn"]) - initial_area) / initial_area, - label="SLCN (old)", lw=2, ls="--") - ax.axhline(0, color="k", lw=0.5) - ax.set_xlabel("model time") - ax.set_ylabel("volume drift (%)") - ax.set_title("Mass conservation") - ax.legend() - - ax = axes[1] - ax.semilogy(t_arr, np.maximum(history["err_supg"], 1e-16), label="SUPG", lw=2) - ax.semilogy(t_arr, np.maximum(history["err_slcn"], 1e-16), label="SLCN (old)", - lw=2, ls="--") - ax.axvline(T_reversal / 2, color="gray", lw=0.7, ls=":", label="flow reversal (T/2)") - ax.set_xlabel("model time") - ax.set_ylabel(r"shape error $\|\phi-\phi_0\|_2$") - ax.set_title("Round-trip shape recovery\n(should dip back down near t=T)") - ax.legend(fontsize=8) - - ax = axes[2] - ax.plot(t_arr, history["cumtime_supg"], label="SUPG", lw=2) - ax.plot(t_arr, history["cumtime_slcn"], label="SLCN (old)", lw=2, ls="--") - ax.set_xlabel("model time") - ax.set_ylabel("cumulative wall time (s)") - ax.set_title("Performance") - ax.legend() - - fig.suptitle(f"LeVeque (1996) swirling deformation flow -- SUPG vs SLCN " - f"(xres={xres}, T={T_reversal})") - fig.tight_layout() - fig_path = os.path.join(outputPath, "supg_vs_slcn_comparison.png") - fig.savefig(fig_path, dpi=150) - print(f"\nComparison plot written to {fig_path}") diff --git a/tests/test_1100_levelset_rotation.py b/tests/test_1100_levelset_rotation.py new file mode 100644 index 000000000..04a733916 --- /dev/null +++ b/tests/test_1100_levelset_rotation.py @@ -0,0 +1,81 @@ +"""Conservative level set under rigid rotation, on both transport solvers. + +A circle carried once round the box centre must come back: the enclosed +volume is held by the mass correction throughout, the 0.5 contour returns to +its initial position, and the reinitialisation keeps the profile at its +thickness rather than letting it smear. + +Run: pixi run python -m pytest tests/test_1100_levelset_rotation.py -v +""" +import numpy as np +import pytest +import sympy + +import underworld3 as uw +from underworld3.systems import level_set + +pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] + +RADIUS = 0.15 +CENTRE = (0.5, 0.75) + + +def _setup(tag, advection): + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=1.0 / 32, qdegree=3) + x, y = mesh.X + psi = uw.discretisation.MeshVariable(f"psi_{tag}", mesh, 1, degree=2) + eps = level_set.interface_thickness(mesh, psi, scale=0.35) + distance = RADIUS - np.sqrt((psi.coords[:, 0] - CENTRE[0]) ** 2 + (psi.coords[:, 1] - CENTRE[1]) ** 2) + level_set.initialise_psi(psi, eps, signed_distance=distance) + psi0 = np.array(psi.array) + # rigid rotation about the box centre, one revolution in 2 pi + velocity = sympy.Matrix([[-(y - 0.5), x - 0.5]]) + solver = uw.systems.LevelSetSolver(psi, velocity=velocity, epsilon=eps, advection=advection, + reini_steps=1, reini_frequency=5) + return mesh, psi, psi0, solver + + +@pytest.mark.parametrize("advection", ["supg", "slcn"]) +def test_circle_returns_after_one_revolution(advection): + mesh, psi, psi0, solver = _setup(advection, advection) + area0 = solver.interface_volume() + assert area0 == pytest.approx(np.pi * RADIUS ** 2, rel=0.02) + + n_steps = 200 + dt = 2.0 * np.pi / n_steps + for _ in range(n_steps): + solver.solve(dt) + assert solver.interface_volume() == pytest.approx(area0, rel=1e-6) + + data = np.asarray(psi.array).reshape(-1) + assert data.min() >= 0.0 and data.max() <= 1.0 + # profile stays sharp: the transition band holds a small share of nodes + in_band = np.mean((data > 0.05) & (data < 0.95)) + assert in_band < 0.12, in_band + # the 0.5 contour is back: nodal mismatch of the indicator is small + mismatch = np.mean(np.abs((data > 0.5).astype(float) - (psi0.reshape(-1) > 0.5).astype(float))) + assert mismatch < 0.01, mismatch + + +def test_initialise_from_polygon_needs_shapely_or_a_distance(): + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=1.0 / 8, qdegree=2) + psi = uw.discretisation.MeshVariable("psi_poly", mesh, 1, degree=1) + eps = level_set.interface_thickness(mesh, psi) + pytest.importorskip("shapely") + angles = np.linspace(0.0, 2.0 * np.pi, 33) + circle = np.column_stack((CENTRE[0] + RADIUS * np.cos(angles), CENTRE[1] + RADIUS * np.sin(angles))) + level_set.initialise_psi(psi, eps, interface_geometry="polygon", interface_coordinates=circle) + data = np.asarray(psi.array).reshape(-1) + assert 0.0 <= data.min() and data.max() <= 1.0 and 0.0 < data.mean() < 0.2 + + +def test_material_property_field_blends(): + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=1.0 / 8, qdegree=2) + psi = uw.discretisation.MeshVariable("psi_mat", mesh, 1, degree=1) + field = level_set.material_property_field(psi.sym[0], [1.0, 100.0], "arithmetic") + assert field.subs(psi.sym[0], 1) == 100.0 and field.subs(psi.sym[0], 0) == 1.0 + with pytest.raises(ValueError, match="interface must be one of"): + level_set.material_property_field(psi.sym[0], [1.0, 100.0], "cubic") From 58728e14fcae60795013d064b739c24fbcd668f5 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Thu, 3 Sep 2026 12:30:50 -0700 Subject: [PATCH 15/20] Level set: a secant iteration for the mass correction, five integrals instead of thirty The correction restores the enclosed volume by a uniform clipped shift whose root the previous code found by bracketing and bisecting to 1e-10, with one integral over the mesh per trial: 15 to 30 integrals per step, most of the cost of a level-set step. The map is monotone and its slope is the area of the transition band, so a bracketed secant iteration started from the nodal estimate of that area converges in a few evaluations. Same volume to ten digits on the perturbed rotating circle (5 integrals against 32, 0.20 s against 0.59 s); the bracket is kept as a safeguard. Underworld development team with AI support from Claude Code Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL --- src/underworld3/systems/level_set.py | 88 ++++++++++++++++++---------- 1 file changed, 57 insertions(+), 31 deletions(-) diff --git a/src/underworld3/systems/level_set.py b/src/underworld3/systems/level_set.py index b1c41888f..0a79f0f1b 100644 --- a/src/underworld3/systems/level_set.py +++ b/src/underworld3/systems/level_set.py @@ -245,8 +245,11 @@ class LevelSetSolver: **Mass correction** finds the uniform shift :math:`\delta` with :math:`\int \mathrm{clip}(\psi + \delta, 0, 1)\,d\Omega` equal to the - initial enclosed volume, by bisection (the map is monotone), and leaves - the field in that clipped, shifted state (Zhang, Zou and Greaves 2010). + initial enclosed volume and leaves the field in that clipped, shifted + state (Zhang, Zou and Greaves 2010). The map is monotone and its slope is + the area of the transition band, so a bracketed secant iteration started + from that slope reaches the target in a few integrals (five against about + thirty for the bisection it replaces, to the same 1e-10). Parameters ---------- @@ -504,7 +507,18 @@ def _apply_boundary_neumann(self, labels=("Left", "Right", "Top", "Bottom")) -> # ------------------------------------------------------------------ def _correct_mass(self, target: float, lo: float = 0.0, hi: float = 1.0) -> None: - """Uniform shift, clipped, restoring the enclosed volume to ``target``.""" + r"""Uniform shift, clipped, restoring the enclosed volume to ``target``. + + Finds :math:`\delta` with :math:`\int\mathrm{clip}(\psi+\delta, lo, hi)\,d\Omega + = V_{\rm target}` and leaves the field in that state. The clip makes + the map :math:`\delta \mapsto V` nonlinear but monotone: it only moves + the transition band, so its slope is the band's area. A secant + iteration started from that slope converges in a few evaluations; + every evaluation is one integral over the mesh, which is what made + the bisection this replaces cost most of a level-set step. The + bracket is kept as a safeguard: an iterate that leaves it falls back + to its midpoint. + """ data0 = np.array(self.phi.array[:, 0, 0]) def volume_for_shift(delta: float) -> float: @@ -512,40 +526,52 @@ def volume_for_shift(delta: float) -> float: return self.interface_volume() v0 = volume_for_shift(0.0) - if abs(v0 - target) < self._mass_correction_tol: + residual = target - v0 + if abs(residual) < self._mass_correction_tol: return + # First guess: only the band moves, so dV/d(delta) is about its area. + # The nodal fraction of the field inside (lo, hi) times the domain area + # is a fair estimate of that on a reasonably uniform mesh. span = hi - lo - if v0 < target: - lo_d, hi_d = 0.0, max(span * 1.0e-3, 1.0e-8) - tries = 0 - while volume_for_shift(hi_d) < target and tries < 30: - hi_d *= 2.0 - tries += 1 - else: - lo_d, hi_d = -max(span * 1.0e-3, 1.0e-8), 0.0 - tries = 0 - while volume_for_shift(lo_d) > target and tries < 30: - lo_d *= 2.0 - tries += 1 - - if not (volume_for_shift(lo_d) <= target <= volume_for_shift(hi_d)): - warnings.warn( - f"Mass correction could not bracket the target volume {target:.6g}; " - "the field is probably pinned at its bounds everywhere.", stacklevel=2) - return + band = float(np.mean((data0 > lo + 1e-6 * span) & (data0 < hi - 1e-6 * span))) + domain = self._domain_volume() + slope = max(band * domain, 1e-12) + d_prev, v_prev = 0.0, v0 + d_cur = residual / slope + lo_d, hi_d = (0.0, np.inf) if residual > 0 else (-np.inf, 0.0) - mid = 0.0 for _ in range(self._mass_correction_max_iter): - mid = 0.5 * (lo_d + hi_d) - vmid = volume_for_shift(mid) - if abs(vmid - target) < self._mass_correction_tol: - break - if vmid < target: - lo_d = mid + v_cur = volume_for_shift(d_cur) + r_cur = v_cur - target + if abs(r_cur) < self._mass_correction_tol: + return + # keep the bracket [lo_d, hi_d] around the root (V is monotone) + if r_cur < 0: + lo_d = max(lo_d, d_cur) + else: + hi_d = min(hi_d, d_cur) + dv = v_cur - v_prev + if abs(dv) > 0: + d_next = d_cur - r_cur * (d_cur - d_prev) / dv else: - hi_d = mid - volume_for_shift(mid) + d_next = d_cur + 2.0 * (d_cur - d_prev) + if not (lo_d < d_next < hi_d) or not np.isfinite(d_next): + if np.isfinite(lo_d) and np.isfinite(hi_d): + d_next = 0.5 * (lo_d + hi_d) + else: + d_next = 2.0 * d_cur + d_prev, v_prev, d_cur = d_cur, v_cur, d_next + + warnings.warn( + f"Mass correction did not reach the target volume {target:.6g} in " + f"{self._mass_correction_max_iter} iterations; leaving the field at the last shift.", + stacklevel=2) + + def _domain_volume(self) -> float: + if not hasattr(self, "_domain_volume_value"): + self._domain_volume_value = float(uw.maths.Integral(self.mesh, sympy.Integer(1)).evaluate()) + return self._domain_volume_value # --------------------------------------------------------------------------- From bef4d9f7c8574690234659d069f75201170e3f01 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Thu, 3 Sep 2026 14:29:17 -0700 Subject: [PATCH 16/20] Level set: impose the far-field value on the boundary when the flow crosses it A continuous-Galerkin transport with no value on an inflow boundary lets mass in: measured as a 4% volume drift in twenty steps of a rotating circle against 8e-5 with the far-field value imposed, on a flow that crosses the box walls. LevelSetSolver takes far_field= and applies it as a Dirichlet condition on every mesh boundary; the rotation test uses it. With it, the SUPG transport conserves the enclosed volume to solver tolerance on its own and the reinitialisation changes it at second order, so the global shift corrector is doing its work for the semi-Lagrangian transport and the clipping, not for the Eulerian scheme. Underworld development team with AI support from Claude Code Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL --- docs/advanced/level-set-transport.md | 1 + src/underworld3/systems/level_set.py | 11 +++++++++++ tests/test_1100_levelset_rotation.py | 3 ++- 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/advanced/level-set-transport.md b/docs/advanced/level-set-transport.md index bf06c33bc..ace09b01e 100644 --- a/docs/advanced/level-set-transport.md +++ b/docs/advanced/level-set-transport.md @@ -42,6 +42,7 @@ viscosity = level_set.material_property_field(psi.sym[0], [eta_outside, eta_insi | `advection` | `"supg"` (default) or `"slcn"`; both run pure advection | | `order`, `theta` | the transport solver's time scheme; Crank-Nicolson by default, which preserves the profile's amplitude between reinitialisations | | `reini_frequency`, `reini_steps`, `reini_dt` | how often, how many pseudo-time steps, and how long each is (half the smallest $\varepsilon$ by default) | +| `far_field` | the value of $\psi$ imposed on the domain boundary; set it whenever the flow crosses the boundary (an inflow boundary with no value lets mass in) | | `conserve_mass` | apply the global correction after every step | | `adv_solver_bc` | box wall labels on which a zero normal gradient is imposed by copying the neighbouring interior nodes | diff --git a/src/underworld3/systems/level_set.py b/src/underworld3/systems/level_set.py index 0a79f0f1b..c60c3bbff 100644 --- a/src/underworld3/systems/level_set.py +++ b/src/underworld3/systems/level_set.py @@ -273,6 +273,13 @@ class LevelSetSolver: reini_frequency : int, optional Advection steps between reinitialisations; by default from the domain size and :math:`\varepsilon`. + far_field : float, optional + Value of :math:`\psi` imposed on every mesh boundary (0 outside the + interface, 1 inside). Set it whenever the flow crosses the domain + boundary: a continuous-Galerkin scheme with no value on an inflow + boundary lets mass in, measured as a 4% volume drift in twenty steps + of a rotating circle against 8e-5 with the value imposed. Leave it + unset only when the boundary is a streamline. adv_solver_opts : dict, optional PETSc options forwarded to the transport solver. adv_solver_bc : sequence of str, optional @@ -308,6 +315,7 @@ def __init__( reini_dt: Optional[float] = None, reini_steps: int = 5, reini_frequency: Optional[int] = None, + far_field: Optional[float] = None, adv_solver_opts: Optional[dict] = None, adv_solver_bc=None, conserve_mass: bool = True, @@ -344,6 +352,9 @@ def __init__( DuDt=history, theta=theta) self._adv_solver.constitutive_model = uw.constitutive_models.DiffusionModel self._adv_solver.constitutive_model.Parameters.diffusivity = 0.0 + if far_field is not None: + for boundary in self.mesh.boundaries: + self._adv_solver.add_dirichlet_bc(float(far_field), boundary.name) self._adv_solver_bc = adv_solver_bc for key, value in (adv_solver_opts or {}).items(): self._adv_solver.petsc_options[key] = value diff --git a/tests/test_1100_levelset_rotation.py b/tests/test_1100_levelset_rotation.py index 04a733916..41807b15e 100644 --- a/tests/test_1100_levelset_rotation.py +++ b/tests/test_1100_levelset_rotation.py @@ -31,8 +31,9 @@ def _setup(tag, advection): psi0 = np.array(psi.array) # rigid rotation about the box centre, one revolution in 2 pi velocity = sympy.Matrix([[-(y - 0.5), x - 0.5]]) + # the rotating flow crosses the walls: impose the far-field value there solver = uw.systems.LevelSetSolver(psi, velocity=velocity, epsilon=eps, advection=advection, - reini_steps=1, reini_frequency=5) + far_field=0.0, reini_steps=1, reini_frequency=5) return mesh, psi, psi0, solver From 5b168e0704cc0e9f70741d1732608f01c7c29257 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Thu, 3 Sep 2026 17:18:38 -0700 Subject: [PATCH 17/20] Level set: mass correction automatic by transport, clip after every stage, band thickness measured conserve_mass='auto' turns the global correction on for the semi-Lagrangian transport, which loses volume by interpolation, and off for the Eulerian one, which conserves it to solver tolerance by itself; volume_drift reports it either way. The field is clipped to [0, 1] after the advection and after the reinitialisation (its RK stages leave 1e-6 undershoots), whatever the corrector setting, since the reinitialisation equation assumes that range. What the clip costs depends on the band thickness, measured on a rotating circle at 32 cells across over one revolution with SUPG and no corrector: 0.84% at interface_thickness(scale=0.35), the g-adopt default, which is a band well under one cell (eps = h/8) that a continuous-Galerkin transport rings at; 0.28% at 1.0; 0.18% at 2.0 (eps = 0.7 h, ringing gone); 0.85% at 3.0, where the reinitialisation's curvature error takes over. Documented in the helper and the user page; the rotation test uses scale 2. Parallel timings of the level-set step (LeVeque flow, 128x128 and 256x256, 1 to 8 ranks) recorded in the design note: the Eulerian advection is seven times cheaper in serial and about five times at eight ranks, its answer is partition-independent to ten digits, and the semi-Lagrangian answer moves with the partition (issue #682). Underworld development team with AI support from Claude Code Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL --- docs/advanced/eulerian-advection-diffusion.md | 5 ++ docs/advanced/level-set-transport.md | 26 ++++++++- .../design/eulerian-supg-transport.md | 36 ++++++++++++ src/underworld3/systems/level_set.py | 55 +++++++++++++++++-- tests/test_1100_levelset_rotation.py | 22 ++++++-- 5 files changed, 131 insertions(+), 13 deletions(-) diff --git a/docs/advanced/eulerian-advection-diffusion.md b/docs/advanced/eulerian-advection-diffusion.md index d62a6d416..1fa7a9609 100644 --- a/docs/advanced/eulerian-advection-diffusion.md +++ b/docs/advanced/eulerian-advection-diffusion.md @@ -72,6 +72,11 @@ the RK2 trace-back, a property of the flow rather than the mesh. Above roughly Courant 2 on the feature's own scale it keeps its accuracy where the Eulerian scheme loses it. +In parallel the Eulerian step stays seven times cheaper in serial and about five +times at eight ranks (the departure-point search parallelises perfectly, the +ILU preconditioner a little less), and its answer is identical to ten digits +at every rank count, where the semi-Lagrangian answer moves with the partition. + A practical rule: if the timestep is chosen so that the temperature field itself is resolved in time (a fraction of a feature width per step), the Eulerian solver is cheaper and more accurate; if the step is deliberately long relative to the diff --git a/docs/advanced/level-set-transport.md b/docs/advanced/level-set-transport.md index ace09b01e..9f8cb8b3b 100644 --- a/docs/advanced/level-set-transport.md +++ b/docs/advanced/level-set-transport.md @@ -43,14 +43,38 @@ viscosity = level_set.material_property_field(psi.sym[0], [eta_outside, eta_insi | `order`, `theta` | the transport solver's time scheme; Crank-Nicolson by default, which preserves the profile's amplitude between reinitialisations | | `reini_frequency`, `reini_steps`, `reini_dt` | how often, how many pseudo-time steps, and how long each is (half the smallest $\varepsilon$ by default) | | `far_field` | the value of $\psi$ imposed on the domain boundary; set it whenever the flow crosses the boundary (an inflow boundary with no value lets mass in) | -| `conserve_mass` | apply the global correction after every step | +| `conserve_mass` | `"auto"` (default): the global correction is on for `"slcn"`, which loses volume by interpolation, and off for `"supg"`, which conserves it to solver tolerance on its own; the clip to [0, 1] of the ringing at a one-cell band then costs about 0.2% per revolution, which `volume_drift` reports | | `adv_solver_bc` | box wall labels on which a zero normal gradient is imposed by copying the neighbouring interior nodes | +**Band thickness.** `interface_thickness(scale=0.35)`, the g-adopt default, +gives a band well under one cell, which a continuous-Galerkin transport rings +at. Measured on a rotating circle at 32 cells across, one revolution, SUPG with +no mass correction: + +| `scale` | $\varepsilon / h$ | volume drift | +|---|---|---| +| 0.35 | 0.12 | +0.84% (clipped ringing) | +| 1.0 | 0.36 | +0.28% | +| 2.0 | 0.71 | -0.18% (ringing gone) | +| 3.0 | 1.07 | -0.85% (reinitialisation curvature error) | + +For the SUPG transport a `scale` of 1.5 to 2, a band of two to three cells, is +the sensible setting; the thickness trades interface resolution for a clean +transport. + `initialise_psi` accepts a precomputed signed distance, or a polygon, curve or `shapely` geometry (the latter three need the optional `shapely` package). `material_property_field` blends a property across one or more level sets with a sharp, arithmetic, geometric or harmonic transition. +## Cost + +Per step at 64 by 64 (LeVeque flow, Courant 0.5, reinitialisation every fifth +step): the SUPG advection takes 0.13 s and the SLCN advection 1.24 s; the +reinitialisation 0.06 to 0.11 s averaged; the mass correction 0.13 to 0.24 s. +Since the Eulerian transport does not need the correction, its level-set step +costs about 0.19 s against 1.59 s for the semi-Lagrangian one. + ## Which transport solver On the LeVeque swirling flow at 64 by 64 and Courant 0.5 (period 2), the SUPG diff --git a/docs/developer/design/eulerian-supg-transport.md b/docs/developer/design/eulerian-supg-transport.md index ff894b4f2..2be9414e3 100644 --- a/docs/developer/design/eulerian-supg-transport.md +++ b/docs/developer/design/eulerian-supg-transport.md @@ -214,6 +214,42 @@ Quarter-turn error on the uniform res-32 mesh with the exact history planted: BDF1 slopes 0.80 and 0.88 between $\Delta t$ = 0.02, 0.01, 0.005; BDF2 slopes above 1.65 between 0.04, 0.02, 0.01. +## Parallel + +LeVeque flow, conservative level set, 20 steps at 128 by 128 (16,384 cells) and 10 at +256 by 256 (65,536 cells), wall time per advection step max-reduced over ranks; +reinitialisation every fifth step timed separately (`~/+Simulations/supg_vs_slcn_657/parallel/`). + +| ranks | SUPG 128 | SLCN 128 | SUPG 256 | SLCN 256 | +|---|---|---|---|---| +| 1 | 0.55 s | 3.98 s | 2.22 s | 15.7 s | +| 2 | 0.28 s | 1.77 s | | | +| 4 | 0.145 s | 1.17 s | 0.80 s | 4.12 s | +| 8 | 0.126 s | 1.06 s | 0.67 s | 3.50 s | + +Three observations. + +- Per step the Eulerian solve is seven times cheaper in serial at both sizes; the + gap narrows to about five times at eight ranks, because the departure-point + work of the semi-Lagrangian scheme parallelises perfectly while the + additive-Schwarz ILU preconditioner needs more GMRES iterations as its + subdomains shrink (SUPG speed-up 3.3 at eight ranks on 256 by 256 against 4.5 + for SLCN). A multigrid or a two-level Schwarz preconditioner for the + nonsymmetric operator would recover that; the assembly itself scales. +- The Eulerian answer is partition-independent: the enclosed volume agrees to + ten digits at every rank count. The semi-Lagrangian answer is not: it moves in + the sixth digit at two and four ranks and by 1.6% at eight ranks on the + 128 by 128 mesh (0.06957 against 0.07068), which points at departure points + near partition boundaries being sampled wrongly at higher rank counts. That + is a defect in the semi-Lagrangian trace-back to chase separately; the + Eulerian scheme has no such path. +- The 128 by 128 problem is too small for eight ranks (about 2,000 cells each); + the 256 by 256 rows are the ones to read for scaling. + +These are timings at equal step. The fair cost comparison is per unit of simulated +time at equal error, each scheme at its own accuracy-limited step (the field-change +fraction for SUPG, the trace-back arc for SLCN), which is the next measurement. + ## What the timestep estimate means The cell-crossing time is not a stability limit for either scheme and says diff --git a/src/underworld3/systems/level_set.py b/src/underworld3/systems/level_set.py index c60c3bbff..7d4a5e5da 100644 --- a/src/underworld3/systems/level_set.py +++ b/src/underworld3/systems/level_set.py @@ -40,7 +40,7 @@ """ import warnings -from typing import Optional +from typing import Optional, Union import numpy as np import sympy @@ -132,6 +132,16 @@ def interface_thickness( (or ``scale`` times the shortest edge), carried to ``phi``'s nodes from the nearest cell centroid. Returned as a scalar MeshVariable of the same degree as ``phi``. + + ``scale=0.35`` (the default, following the discontinuous-Galerkin + setting of g-adopt) gives :math:`\varepsilon \approx h/8` on triangles, + a band well under one cell. A continuous-Galerkin transport rings at + that: on a rotating circle at 32 cells across, the clip of the ringing + changed the volume by 0.84% per revolution at 0.35, 0.28% at 1.0 and + 0.18% at 2.0 (:math:`\varepsilon \approx 0.7h`, a band of two to three + cells, ringing gone); at 3.0 the reinitialisation's own curvature error + takes over (0.85%). For the SUPG transport, ``scale`` between 1.5 and 2 + is the sensible setting. """ from scipy.spatial import cKDTree @@ -286,8 +296,18 @@ class LevelSetSolver: Box wall labels on which a zero normal gradient is imposed after each step by copying the neighbouring interior nodes (a box-mesh convenience). - conserve_mass : bool, default True - Apply the global mass correction after each step. + conserve_mass : bool or "auto", default "auto" + Apply the global mass correction after each step. ``"auto"`` turns + it on for the semi-Lagrangian transport, which loses volume through + interpolation, and off for the Eulerian one, which conserves the + enclosed volume to solver tolerance on its own once ``far_field`` is + set where the flow crosses the boundary (measured: 8e-5 over twenty + steps; the reinitialisation changes it at second order only). What + does change it is the clip to [0, 1] of the transport's ringing at a + thin band: 0.84% per revolution of a circle at the default thickness + (``scale=0.35``), 0.18% at ``scale=2.0`` (see + :func:`interface_thickness`). Turn the correction on if that + matters; it costs about as much as the Eulerian advection step. mass_correction_tol, mass_correction_max_iter Bisection tolerance on the volume and iteration cap. @@ -318,7 +338,7 @@ def __init__( far_field: Optional[float] = None, adv_solver_opts: Optional[dict] = None, adv_solver_bc=None, - conserve_mass: bool = True, + conserve_mass: Union[bool, str] = "auto", mass_correction_tol: float = 1.0e-10, mass_correction_max_iter: int = 40, ) -> None: @@ -368,10 +388,13 @@ def __init__( self._reini_frequency = int(reini_frequency) if reini_frequency is not None else self._default_frequency() - self.conserve_mass = conserve_mass + if conserve_mass == "auto": + conserve_mass = advection == "slcn" + self.conserve_mass = bool(conserve_mass) self._mass_correction_tol = float(mass_correction_tol) self._mass_correction_max_iter = int(mass_correction_max_iter) - self._target_volume = self.interface_volume() if conserve_mass else None + self._clip_volume_change = 0.0 + self._target_volume = self.interface_volume() # ------------------------------------------------------------------ # Public interface @@ -394,12 +417,17 @@ def estimate_dt(self, **kwargs): def solve(self, dt: float, *, reinitialise: bool = True) -> None: """Advance the level set by one step of size ``dt``.""" self._adv_solver.solve(timestep=dt) + # The reinitialisation equation assumes psi in [0, 1]; the transport + # can overshoot at a band a cell wide, so clip before anything reads + # the field. Records what the clip removed, for the volume budget. + self._clip_volume_change += self._clip_in_place() if self._adv_solver_bc: self._apply_boundary_neumann(labels=self._adv_solver_bc) self.step += 1 if reinitialise and self.step % self._reini_frequency == 0: self.reinitialise() + self._clip_volume_change += self._clip_in_place() # RK stages can leave 1e-6 undershoots if self._adv_solver_bc: self._apply_boundary_neumann(labels=self._adv_solver_bc) @@ -411,6 +439,11 @@ def reinitialise(self) -> None: for _ in range(self.reini_steps): self._reini_ssprk3_step(self.reini_dt) + @property + def volume_drift(self) -> float: + """Relative change of the enclosed volume since construction.""" + return (self.interface_volume() - self._target_volume) / self._target_volume + def interface_volume(self) -> float: r"""The enclosed volume :math:`\int\psi\,d\Omega`.""" return uw.maths.Integral(self.mesh, self.phi.sym[0]).evaluate() @@ -419,6 +452,16 @@ def clamp(self, lo: float = 0.0, hi: float = 1.0) -> None: """Clip the field to ``[lo, hi]`` in place. Not mass-conserving on its own.""" self.phi.array[:, 0, 0] = np.clip(self.phi.array[:, 0, 0], lo, hi) + def _clip_in_place(self) -> float: + """Clip to [0, 1]; return the volume the clip changed (a nodal estimate).""" + values = np.asarray(self.phi.array[:, 0, 0]) + clipped = np.clip(values, 0.0, 1.0) + if np.array_equal(values, clipped): + return 0.0 + before = self.interface_volume() + self.phi.array[:, 0, 0] = clipped + return self.interface_volume() - before + # ------------------------------------------------------------------ # Reinitialisation # ------------------------------------------------------------------ diff --git a/tests/test_1100_levelset_rotation.py b/tests/test_1100_levelset_rotation.py index 41807b15e..c609499d1 100644 --- a/tests/test_1100_levelset_rotation.py +++ b/tests/test_1100_levelset_rotation.py @@ -1,8 +1,9 @@ """Conservative level set under rigid rotation, on both transport solvers. A circle carried once round the box centre must come back: the enclosed -volume is held by the mass correction throughout, the 0.5 contour returns to -its initial position, and the reinitialisation keeps the profile at its +volume is held throughout (by the scheme itself for the Eulerian transport, +by the mass correction for the semi-Lagrangian one), the 0.5 contour returns +to its initial position, and the reinitialisation keeps the profile at its thickness rather than letting it smear. Run: pixi run python -m pytest tests/test_1100_levelset_rotation.py -v @@ -25,7 +26,9 @@ def _setup(tag, advection): minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=1.0 / 32, qdegree=3) x, y = mesh.X psi = uw.discretisation.MeshVariable(f"psi_{tag}", mesh, 1, degree=2) - eps = level_set.interface_thickness(mesh, psi, scale=0.35) + # a band of two to three cells; the g-adopt default 0.35 is under one + # cell and a continuous-Galerkin transport rings at it + eps = level_set.interface_thickness(mesh, psi, scale=2.0) distance = RADIUS - np.sqrt((psi.coords[:, 0] - CENTRE[0]) ** 2 + (psi.coords[:, 1] - CENTRE[1]) ** 2) level_set.initialise_psi(psi, eps, signed_distance=distance) psi0 = np.array(psi.array) @@ -41,19 +44,26 @@ def _setup(tag, advection): def test_circle_returns_after_one_revolution(advection): mesh, psi, psi0, solver = _setup(advection, advection) area0 = solver.interface_volume() - assert area0 == pytest.approx(np.pi * RADIUS ** 2, rel=0.02) + # int psi exceeds pi r^2 by O(eps^2 / r^2) for a curved band: 12% here + assert area0 == pytest.approx(np.pi * RADIUS ** 2, rel=0.15) + # the corrector is on by default for SLCN only; the Eulerian transport + # holds the volume by itself to solver tolerance, and what remains is + # the clip of residual ringing and the reinitialisation's curvature + # error, 0.18% per revolution at this band thickness + assert solver.conserve_mass == (advection == "slcn") + volume_tol = 1e-6 if advection == "slcn" else 5e-3 n_steps = 200 dt = 2.0 * np.pi / n_steps for _ in range(n_steps): solver.solve(dt) - assert solver.interface_volume() == pytest.approx(area0, rel=1e-6) + assert abs(solver.volume_drift) < volume_tol, solver.volume_drift data = np.asarray(psi.array).reshape(-1) assert data.min() >= 0.0 and data.max() <= 1.0 # profile stays sharp: the transition band holds a small share of nodes in_band = np.mean((data > 0.05) & (data < 0.95)) - assert in_band < 0.12, in_band + assert in_band < 0.2, in_band # the 0.5 contour is back: nodal mismatch of the indicator is small mismatch = np.mean(np.abs((data > 0.5).astype(float) - (psi0.reshape(-1) > 0.5).astype(float))) assert mismatch < 0.01, mismatch From bd0b1a5ce8637dc1fd0f8e9392b1faadb348b79c Mon Sep 17 00:00:00 2001 From: lmoresi Date: Thu, 3 Sep 2026 22:28:32 -0700 Subject: [PATCH 18/20] Match the Krylov tolerance to the SNES tolerance, and make preconditioner="fmg" a real switch on the SUPG solver The Eulerian SUPG step took two Newton iterations on a linear operator: the Krylov default (rtol 1e-5) does not reach the SNES tolerance (1e-8), and the second Jacobian assembly cost more than every linear solve of the step. The Krylov tolerance is now 1e-9 and a step is one Newton iteration: 1.54 s to 0.91 s per step at 256^2 in serial. Measured against geometric multigrid at matched tolerances (design note, "Preconditioner"), GMRES with additive-Schwarz ILU is the cheaper linear solve at every Courant number from 1/2 to 32 and its iteration count is the same on one and eight ranks; the multigrid's cycle count grows with the Courant number nearly as fast, and a cycle costs about three Schwarz iterations. Schwarz stays the default on every mesh. preconditioner = "fmg" now hands the block to the managed multigrid route (custom-P transfers over the refinement hierarchy or an adapt child's coarse tail, flexible GMRES outside) for the rank count where a one-level method runs out of coarse space. The solver's solve() builds through the base _build, where a preconditioner choice is resolved; the pre-run of the three setup stages marked the solver set up first, so the request was silently inert. The semi-Lagrangian solvers share that pattern and the defect (#683). Underworld development team with AI support from Claude Code Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL --- docs/advanced/eulerian-advection-diffusion.md | 11 +- .../design/eulerian-supg-transport.md | 64 +++++++++- .../systems/advection_diffusion_eulerian.py | 112 +++++++++++++++--- tests/test_1055_advdiff_supg_api.py | 30 +++++ 4 files changed, 194 insertions(+), 23 deletions(-) diff --git a/docs/advanced/eulerian-advection-diffusion.md b/docs/advanced/eulerian-advection-diffusion.md index d62a6d416..950ea84cb 100644 --- a/docs/advanced/eulerian-advection-diffusion.md +++ b/docs/advanced/eulerian-advection-diffusion.md @@ -102,9 +102,14 @@ of the compiled kernels; nothing is recompiled. - The stabilisation parameter uses the local cell size (`mesh.cell_size()`) and three weights that are runtime constants (`solver.tau_weights`); `solver.supg_weight = 0` gives the plain Galerkin scheme for comparison. -- The linear system is nonsymmetric, so the solver defaults to GMRES with an - additive-Schwarz ILU preconditioner instead of algebraic multigrid. Every - option can be overridden through `solver.petsc_options`. +- The linear system is nonsymmetric, so the solver uses GMRES with an + additive-Schwarz ILU preconditioner, with the Krylov tolerance matched to the + SNES tolerance so that a step is one Newton iteration. Measured, this is the + cheaper solve at every Courant number up to eight ranks and its iteration + count does not grow with the rank count. `solver.preconditioner = "fmg"` + switches to geometric multigrid over the mesh's refinement hierarchy + (`refinement >= 1`) for very large rank counts. Every option can be + overridden through `solver.petsc_options`. ## Further reading diff --git a/docs/developer/design/eulerian-supg-transport.md b/docs/developer/design/eulerian-supg-transport.md index ff894b4f2..8b95b3220 100644 --- a/docs/developer/design/eulerian-supg-transport.md +++ b/docs/developer/design/eulerian-supg-transport.md @@ -77,10 +77,24 @@ $$ prototype recompiled its kernels on every change (1.2 s against 0.03 s for a step). - **Diffusivity on the constitutive model**, as for every scalar solver, starting at $\kappa = 0$. The prototype carried a float attribute with a warning bridge. -- **Own preconditioner.** The operator is nonsymmetric, so GMRES with an - additive-Schwarz ILU preconditioner replaces the managed GAMG block. The solver - sets `_pc_option_prefix = None`, and the mesh-owned multigrid pickup on adapt - children now respects that (it segfaulted otherwise). +- **Additive-Schwarz ILU, one Newton iteration per step.** The operator is + nonsymmetric, so the smoother and the outer Krylov solver have to be safe for + one. Measured (below), GMRES with an additive-Schwarz ILU preconditioner is the + cheaper linear solve at every Courant number from 1/2 to 32 and its iteration + count does not change between one and eight ranks; geometric multigrid's cycle + count grows with the Courant number nearly as fast, and a cycle costs about + three Schwarz iterations. The linear solve is under a tenth of a step either + way; assembly is the rest. What did matter was the tolerance pair: the Krylov + default (1e-5) does not reach the SNES tolerance (1e-8), so the SNES took a + second Newton step on a linear operator, and that Jacobian assembly cost more + than every linear solve of the step. The Krylov tolerance is now 1e-9. + `preconditioner = "fmg"` hands the block to the managed multigrid route + (custom-P transfers over the refinement hierarchy or an adapt child's coarse + tail, flexible GMRES outside), for the rank count where a one-level method + runs out of coarse space. The solver's `solve()` builds through the base + `_build`, which is where a preconditioner choice is resolved; the + semi-Lagrangian solvers run the three setup stages directly and their + `preconditioner` property is inert as a result (#683). - **Moving meshes, phase 1.** The unknown and its history stay on the default `REMAP` transfer policy with the material velocity. The remap re-interpolates old states onto the new nodes, so the Eulerian form is already correct to @@ -214,6 +228,48 @@ Quarter-turn error on the uniform res-32 mesh with the exact history planted: BDF1 slopes 0.80 and 0.88 between $\Delta t$ = 0.02, 0.01, 0.005; BDF2 slopes above 1.65 between 0.04, 0.02, 0.01. +### Preconditioner + +Level-set advection step (`uw.systems.level_set`, a two-cell band, P2, +Crank-Nicolson) on a structured quad box built with a refinement hierarchy, so +every solver sees the same finest operator; the vortex velocity field of the +level-set study. Wall time per step over ten steps after a warm-up step, on a +sixteen-core workstation. Script and logs: +`~/+Simulations/supg_vs_slcn_657/parallel/fmg_timing.py`, `fmg.log`. + +**Schwarz against geometric multigrid at matched tolerances** (Krylov 1e-9, +SNES 1e-8; one Newton iteration per step for both), 256², three levels: + +| Courant | GMRES + ASM-ILU, its (np 1 / 8) | s/step (np 1 / 8) | fgmres + FMG, cycles (np 1 / 8) | s/step (np 1 / 8) | +|---|---|---|---|---| +| 1/2 | 5 / 5 | 0.913 / 0.121 | 1 / 1 | 0.943 / 0.145 | +| 2 | 8.9 / 8.6 | 0.925 / 0.141 | 3.4 / 3.6 | 1.079 / 0.178 | +| 8 | 16.6 / 16.5 | 0.971 / 0.146 | 12.8 / 12.8 | 1.657 / 0.299 | +| 32 | 37 / 37.8 | 1.128 / 0.172 | 23.8 / 24.1 | 2.347 / 0.457 | + +The multigrid smoother is the managed bundle's gmres/4 + SOR with Galerkin coarse +operators, which inherit the fine-grid $\tau$; four levels instead of three +changes nothing at Courant 1/2 (one cycle, 0.935 s either way), so the coarse +operators are not under-stabilised there. Above Courant 8 the scheme rings (the +range of $\phi$ reaches $-0.29$ to $1.29$ at Courant 8), so the rows where +multigrid's cycle count is closest to the Schwarz count are rows nobody runs. + +**Where the step goes** (`-log_view`, np 1, Courant 1/2, eleven solves): residual +evaluation 4.0 s, Jacobian evaluation 4.4 s, `KSPSolve` 0.36 s under Schwarz and +0.95 s under multigrid. With the Krylov tolerance left at its default of 1e-5 the +Schwarz solver stopped at three iterations, the SNES took a second Newton step +(22 Jacobian assemblies over eleven solves), and the step cost 1.54 s; one +multigrid cycle happens to reduce the residual below the SNES tolerance, so it +took one. That looked like a 1.65x win for multigrid and was a Jacobian +assembly. + +**Controls** (Krylov tolerance at its default, 256², np 1 / 8): algebraic +multigrid (the managed GAMG bundle) 5 iterations, 2.07 / 0.245 s; the "fast" +smoother (richardson/3 + SOR) 0.933 s, the same as gmres/4; gmres/2 needs two +cycles and costs 1.61 s; an ILU smoother 1.62 s. At 512² with four levels the +unmatched rows read 6.12 / 0.84 s (Schwarz, two Newton steps) against 3.71 / +0.58 s (multigrid). + ## What the timestep estimate means The cell-crossing time is not a stability limit for either scheme and says diff --git a/src/underworld3/systems/advection_diffusion_eulerian.py b/src/underworld3/systems/advection_diffusion_eulerian.py index a6c3d36dc..2b7cc2c9e 100644 --- a/src/underworld3/systems/advection_diffusion_eulerian.py +++ b/src/underworld3/systems/advection_diffusion_eulerian.py @@ -208,9 +208,14 @@ class SNES_AdvectionDiffusion_SUPG(SNES_Scalar): scalar solver; the solver starts with a :class:`~underworld3.constitutive_models.DiffusionModel` at :math:`\kappa = 0` (pure advection). The linear system is nonsymmetric, - so the solver defaults to GMRES with an additive-Schwarz ILU - preconditioner rather than the algebraic multigrid the symmetric scalar - solvers use; every option is overridable through ``petsc_options``. + so the solver uses GMRES with an additive-Schwarz ILU preconditioner, the + Krylov tolerance matched to the SNES tolerance so that a step is one + Newton iteration. ``preconditioner = "fmg"`` hands the linear solve to + geometric multigrid over the mesh's refinement hierarchy (a flexible GMRES + outer solver, Galerkin coarse operators); measured, the Schwarz solve is + cheaper at every Courant number to eight ranks, and multigrid is there for + the rank count where a one-level method runs out of coarse space. Every + option is overridable through ``petsc_options``. """ @timing.routine_timer_decorator @@ -322,18 +327,91 @@ def __init__( self.constitutive_model = uw.constitutive_models.DiffusionModel self.constitutive_model.Parameters.diffusivity = 0.0 - # Nonsymmetric operator: opt out of the managed GAMG/FMG block and - # use GMRES with an additive-Schwarz ILU preconditioner. RCM - # ordering improves the ILU fill on convection-dominated operators. - self._pc_option_prefix = None - self.petsc_options["ksp_type"] = "gmres" - self.petsc_options["ksp_gmres_restart"] = 200 - self.petsc_options["pc_type"] = "asm" - self.petsc_options["sub_pc_type"] = "ilu" - self.petsc_options["sub_pc_factor_mat_ordering_type"] = "rcm" + # Linear solver: additive-Schwarz ILU by default, the managed multigrid + # block on request (see ``preconditioner``). One Newton iteration per + # step: the operator is linear in phi, so the Krylov tolerance must + # reach the SNES tolerance or the SNES takes a second step, and a + # second Jacobian assembly costs more than every linear solve of the + # step (design note, "Preconditioner"). + self._set_linear_solver(multigrid=False) self.petsc_options["snes_rtol"] = 1.0e-8 + self.petsc_options["ksp_rtol"] = 1.0e-9 self.petsc_options["snes_max_it"] = 20 + # ------------------------------------------------------------------ + # Linear solver + # ------------------------------------------------------------------ + + _SCHWARZ_OPTIONS = { + "ksp_type": "gmres", + "ksp_gmres_restart": 200, + "pc_type": "asm", + "sub_pc_type": "ilu", + # RCM ordering improves the ILU fill on a convection-dominated operator. + "sub_pc_factor_mat_ordering_type": "rcm", + } + + def _set_linear_solver(self, multigrid: bool): + """Own the linear solver (GMRES + additive-Schwarz ILU) or hand it to + the managed multigrid block. + + Measured on the level-set advection step at 256^2 and 512^2 (design + note, "Preconditioner"): with the Krylov tolerance matched to the + SNES tolerance, additive Schwarz with ILU is the cheaper linear solve + at every Courant number from 1/2 to 32, its iteration count does not + change between one and eight ranks, and the geometric multigrid's + cycle count grows with the Courant number nearly as fast as the + Schwarz iteration count while each cycle costs about three Schwarz + iterations. The linear solve is under a tenth of the step either way; + assembly is the rest. Multigrid keeps its coarse space for a rank + count where a one-level method runs out of one, which is what + ``preconditioner = "fmg"`` is for. + """ + from underworld3.utilities import multigrid_options + + opts = self.petsc_options + bundle_keys = set() + for bundle in (multigrid_options.gamg_bundle(), + multigrid_options.geometric_mg_bundle()): + bundle_keys |= set(bundle.settings) | set(bundle.stale) + if multigrid: + # The managed block starts from the scalar solver's own keys + # (GMRES + the GAMG bundle) and _apply_preconditioner_options + # resolves the request against the mesh hierarchy at build time. + self._pc_option_prefix = "" + for key in self._SCHWARZ_OPTIONS: + opts.delValue(key) + self._push_managed_option("ksp_type", "gmres") + for key, value in multigrid_options.gamg_bundle().settings.items(): + self._push_managed_option(key, value) + else: + self._pc_option_prefix = None + for key in bundle_keys | {"ksp_type"}: + opts.delValue(key) + self._managed_pc_options.pop(self.petsc_options_prefix + key, None) + for key, value in self._SCHWARZ_OPTIONS.items(): + opts[key] = value + + @property + def preconditioner(self): + """Linear preconditioner: ``"auto"`` (default), ``"fmg"`` or ``"gamg"``. + + ``"auto"`` is GMRES with an additive-Schwarz ILU preconditioner, the + measured choice for this operator (see :meth:`_set_linear_solver`). + ``"fmg"`` hands the block to the managed geometric-multigrid route: + custom-P transfers over the mesh's refinement hierarchy or an adapt + child's coarse tail, installed on the live PC at the first solve, + under a flexible GMRES outer solver; without a hierarchy it warns and + degrades to GAMG. ``"gamg"`` is algebraic multigrid. Setting the + property rebuilds the solver at the next solve. + """ + return self._preconditioner + + @preconditioner.setter + def preconditioner(self, value): + SNES_Scalar.preconditioner.fset(self, value) + self._set_linear_solver(multigrid=self._preconditioner != "auto") + def _object_viewer(self): from IPython.display import Latex, display @@ -637,10 +715,12 @@ def solve( self._needs_function_rewire = True if not self.constitutive_model._solver_is_setup: self._needs_function_rewire = True - if not self.is_setup: - self._setup_pointwise_functions(verbose) - self._setup_discretisation(verbose) - self._setup_solver(verbose) + # The base ``_build`` resolves the preconditioner choice against the + # mesh hierarchy before the SNES reads its options. Running the three + # setup stages directly here (the semi-Lagrangian solvers' pattern) + # marks the solver set up, so ``_build`` returned early and the + # geometric-multigrid request was silently inert (#683). + self._build(verbose) self.DuDt.update_pre_solve(dt, verbose=verbose) super().solve(zero_init_guess, _force_setup, divergence_retries=divergence_retries) diff --git a/tests/test_1055_advdiff_supg_api.py b/tests/test_1055_advdiff_supg_api.py index 69a8f6657..4d53ffeb5 100644 --- a/tests/test_1055_advdiff_supg_api.py +++ b/tests/test_1055_advdiff_supg_api.py @@ -164,6 +164,36 @@ def test_galerkin_baseline_needs_no_rebuild(mesh): assert adv.supg_weight == 0.0 +def test_multigrid_is_one_switch_away_on_a_refinement_hierarchy(): + """The default linear solver is GMRES + additive-Schwarz ILU on any mesh, + one Newton iteration per step. ``preconditioner = "fmg"`` on a mesh with + a refinement hierarchy hands the block to geometric multigrid: custom-P + transfers over ``mesh.dm_hierarchy`` installed on the live PC at the next + solve, under a flexible outer Krylov solver; the two agree to the solve + tolerance, and switching back rebuilds the Schwarz solver.""" + refined = uw.meshing.UnstructuredSimplexBox( + minCoords=(-1.0, -1.0), maxCoords=(1.0, 1.0), cellSize=0.5, qdegree=3, + refinement=2) + schwarz, T_s = _solver(refined, "schwarz") + schwarz.solve(timestep=0.01) + assert schwarz.snes.getKSP().getPC().getType() == "asm" + assert schwarz.snes.getIterationNumber() == 1 + + multigrid, T_m = _solver(refined, "multigrid") + multigrid.preconditioner = "fmg" + multigrid.solve(timestep=0.01) + ksp = multigrid.snes.getKSP() + assert ksp.getType() == "fgmres" + assert ksp.getPC().getType() == "mg" + assert ksp.getPC().getMGLevels() == len(refined.dm_hierarchy) == 3 + a, b = np.array(T_s.array[:, 0, 0]), np.array(T_m.array[:, 0, 0]) + assert np.abs(a - b).max() < 1e-6 * np.abs(a).max() + + multigrid.preconditioner = "auto" + multigrid.solve(timestep=0.01) + assert multigrid.snes.getKSP().getPC().getType() == "asm" + + def test_solves_on_an_adapt_child_with_its_own_preconditioner(): """An adapt child carries a mesh-owned multigrid hierarchy that the solver base installs opportunistically. This solver owns its (additive From ce783b1b1ac50b5f4ba6184d729ab9cda435e756 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Thu, 3 Sep 2026 22:33:07 -0700 Subject: [PATCH 19/20] Design note: the 512^2 rows at matched tolerance --- docs/developer/design/eulerian-supg-transport.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/developer/design/eulerian-supg-transport.md b/docs/developer/design/eulerian-supg-transport.md index 8b95b3220..5ac561e2b 100644 --- a/docs/developer/design/eulerian-supg-transport.md +++ b/docs/developer/design/eulerian-supg-transport.md @@ -268,7 +268,8 @@ multigrid (the managed GAMG bundle) 5 iterations, 2.07 / 0.245 s; the "fast" smoother (richardson/3 + SOR) 0.933 s, the same as gmres/4; gmres/2 needs two cycles and costs 1.61 s; an ILU smoother 1.62 s. At 512² with four levels the unmatched rows read 6.12 / 0.84 s (Schwarz, two Newton steps) against 3.71 / -0.58 s (multigrid). +0.58 s (multigrid); matched, with the shipped defaults, 3.51 / 0.48 s (Schwarz, +5 iterations) against 3.62 / 0.54 s (multigrid, one cycle). ## What the timestep estimate means From 4295af77e28d276e7faf580e4c05f2470d8955d9 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Fri, 4 Sep 2026 12:08:48 -0700 Subject: [PATCH 20/20] Let theta be set after construction, as the semi-Lagrangian solver allows The shipped convection examples set adv_diff.theta = 0.5 after building the solver; the Eulerian drop-in refused it. The blend is a runtime constant refreshed from the history manager before every solve, so the setter updates it without a recompile (order 1 only, the constructor's rule). Vector and tensor unknowns join the design note's deferred list: the solver is scalar, where the semi-Lagrangian trace-back carries them. Underworld development team with AI support from Claude Code Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL --- .../design/eulerian-supg-transport.md | 3 ++- .../systems/advection_diffusion_eulerian.py | 18 +++++++++++++++++- tests/test_1055_advdiff_supg_api.py | 15 +++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/docs/developer/design/eulerian-supg-transport.md b/docs/developer/design/eulerian-supg-transport.md index 5ac561e2b..55999bd3e 100644 --- a/docs/developer/design/eulerian-supg-transport.md +++ b/docs/developer/design/eulerian-supg-transport.md @@ -103,7 +103,8 @@ $$ - **Not yet:** discontinuity capturing (the prototype's residual omitted the time derivative and added first-order diffusion everywhere; a correct lagged residual needs $\phi^{n-1}$), a streamline element length from a mesh-owned metric tensor, - the ALE hook. + the ALE hook, and vector or tensor unknowns: the solver is scalar, where the + semi-Lagrangian trace-back carries vectors and tensors through the same machinery. ## Measurements diff --git a/src/underworld3/systems/advection_diffusion_eulerian.py b/src/underworld3/systems/advection_diffusion_eulerian.py index 2b7cc2c9e..d294bdb35 100644 --- a/src/underworld3/systems/advection_diffusion_eulerian.py +++ b/src/underworld3/systems/advection_diffusion_eulerian.py @@ -441,9 +441,25 @@ def order(self) -> int: @property def theta(self) -> float: - """Adams-Moulton blend at order 1 (1.0 backward Euler, 0.5 Crank-Nicolson).""" + """Adams-Moulton blend at order 1 (1.0 backward Euler, 0.5 Crank-Nicolson). + + Settable after construction, as on the semi-Lagrangian solver: the + blend is a runtime constant of the compiled kernels, refreshed from + the history manager before every solve, so nothing is recompiled. + """ return self._theta + @theta.setter + def theta(self, value): + value = float(value) + if value != 1.0 and self._time_order != 1: + raise ValueError( + "theta applies at order 1 only (0.5 is Crank-Nicolson, 1.0 is " + "backward Euler); order 2 and 3 take theta=1.0." + ) + self._theta = value + self.DuDt.theta = value + @property def delta_t(self): r"""The timestep :math:`\Delta t` as a UW expression. diff --git a/tests/test_1055_advdiff_supg_api.py b/tests/test_1055_advdiff_supg_api.py index 4d53ffeb5..9b39dc983 100644 --- a/tests/test_1055_advdiff_supg_api.py +++ b/tests/test_1055_advdiff_supg_api.py @@ -49,6 +49,21 @@ def test_slcn_order_theta_pairs_select_the_documented_schemes(mesh): _solver(mesh, "p4", order=2, theta=0.5) +def test_theta_is_settable_after_construction_as_on_slcn(mesh): + """The convection examples set ``adv_diff.theta = 0.5`` after constructing + the semi-Lagrangian solver; the drop-in accepts the same, refreshing the + Adams-Moulton weights at the next solve without a recompile.""" + adv, _T = _solver(mesh, "th") + adv.solve(timestep=0.01) + key = adv._current_jit_cache_key + adv.theta = 1.0 + adv.solve(timestep=0.01) + assert adv.theta == 1.0 and adv.DuDt.theta == 1.0 + assert adv._current_jit_cache_key == key + with pytest.raises(ValueError, match="theta applies"): + _solver(mesh, "th2", order=2)[0].theta = 0.5 + + def test_semi_lagrangian_only_arguments_are_ignored_with_a_warning(mesh): with pytest.warns(UserWarning, match="monotone_mode, old_frame_traceback"): adv, _T = _solver(mesh, "q", monotone_mode="clamp", old_frame_traceback=True)