From f7d1a216492756281ada84373f9ca2eaefb2dde0 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Wed, 2 Sep 2026 17:29:58 -0700 Subject: [PATCH 01/35] 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 02/35] 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 03/35] 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 04/35] 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 05/35] 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 06/35] 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 07/35] 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 08/35] 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 09/35] 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 10/35] 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 11/35] 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 bd0b1a5ce8637dc1fd0f8e9392b1faadb348b79c Mon Sep 17 00:00:00 2001 From: lmoresi Date: Thu, 3 Sep 2026 22:28:32 -0700 Subject: [PATCH 12/35] 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 13/35] 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 14/35] 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) From d17be4166a424d808932430e7c3a32c8bd28e3c3 Mon Sep 17 00:00:00 2001 From: Tyagi Date: Sat, 5 Sep 2026 14:48:42 +1000 Subject: [PATCH 15/35] feat: integrate CitcomS predictor-corrector into shared Eulerian SUPG Use one public AdvDiffusionSUPG class and one F0/F1 assembly for implicit CN/BE/BDF and explicit CitcomS updates. Preserve the P1 positive lumped mass, gamma=0.5 two-correction update, directional tau and conservative timestep estimate, cached PETSc vectors and geometry. Replace the former implementation with compatibility imports; no duplicate solver remains. Register timestep-estimator and integrator snapshot state, omit unused CitcomS DDt history, retain explicit BDF selection and custom tau, and validate incompatible settings. Add frozen-source triangle/tetrahedron equivalence and in-memory/disk restart tests. Replace the MPI Gaussian cross-host golden value with a same-host serial reference without loosening the threshold. Validation: rebuilt Mac worktree; 25 unified/API tests passed, 13 residual tests passed, seven MPI tests passed per rank including the Gaussian regression, and nine expanded two-rank migration/snapshot tests passed per rank. Style gate and diff whitespace checks passed. Gadi coupled benchmarks and memory gate are still pending; this is not a production-acceptance claim. Underworld development team with AI support from OpenAI Codex. --- docs/advanced/eulerian-advection-diffusion.md | 68 +- docs/advanced/supg-transport.md | 11 + docs/developer/CHANGELOG.md | 19 + src/underworld3/systems/advdiff_supg.py | 11 + .../systems/advection_diffusion_eulerian.py | 747 ++++++++++++++---- .../test_1077_advdiff_supg_parallel.py | 21 +- tests/test_1113_advdiff_supg_residual.py | 252 ++++++ tests/test_1115_advdiff_supg_transient.py | 189 +++++ tests/test_1116_supg_unified.py | 129 +++ 9 files changed, 1298 insertions(+), 149 deletions(-) create mode 100644 docs/advanced/supg-transport.md create mode 100644 src/underworld3/systems/advdiff_supg.py create mode 100644 tests/test_1113_advdiff_supg_residual.py create mode 100644 tests/test_1115_advdiff_supg_transient.py create mode 100644 tests/test_1116_supg_unified.py diff --git a/docs/advanced/eulerian-advection-diffusion.md b/docs/advanced/eulerian-advection-diffusion.md index 950ea84cb..446e5bcbb 100644 --- a/docs/advanced/eulerian-advection-diffusion.md +++ b/docs/advanced/eulerian-advection-diffusion.md @@ -111,7 +111,73 @@ of the compiled kernels; nothing is recompiled. (`refinement >= 1`) for very large rank counts. Every option can be overridden through `solver.petsc_options`. -## Further reading +## CitcomS Predictor-Corrector + +The same public solver also provides the continuous-P1, row-lumped +predictor-corrector used by the Zhong mantle-convection benchmark: + +```python +Tdot = uw.discretisation.MeshVariable("Tdot", mesh, 1, degree=1) +adv = uw.systems.AdvDiffusionSUPG( + mesh, T, v.sym, + time_integrator="citcoms", + temperature_rate_field=Tdot, +) +adv.constitutive_model.Parameters.diffusivity = 1.0 +adv.add_dirichlet_bc(0.0, "Upper") +adv.add_dirichlet_bc(1.0, "Lower") +adv.solve(timestep=adv.estimate_dt()) +``` + +This is an optional time integrator, not a second SUPG solver. The implicit +and CitcomS paths share the source/advection/diffusion residual, boundary +assembly, and runtime constants. Only the temporal update and its +stabilisation/timestep policy differ. + +CitcomS predicts temperature with `(1-gamma)*dt*Tdot`, resets the rate, +then applies `delta_rate=-M_L^-1*F` to the rate and +`gamma*dt*delta_rate` to temperature. Defaults are `adv_gamma=0.5` and +two corrections. Boundary values are reinserted at each correction. +Automatic geometry is restricted to 2-D triangles and 3-D tetrahedra. + +Its steady tau is `h/(2*speed) * max(0, 1-1/Pe)`, with +`Pe=speed*h/(2*kappa)` and directional simplex +`h=2*speed/sum_a(abs(u.grad(N_a)))`. Zero velocity gives zero tau; +zero diffusivity uses the advective limit. It is not the generic transient +norm tau. + +The CitcomS timestep estimate is `0.9*min(dt_adv, dt_diff)`, using the +directional advective rate and the row-sum bound on the lumped diffusion +operator. The implicit field-change estimate is not a stability bound for +this method. A fixed comparison timestep must respect the explicit bound. +SUPG does not guarantee a nodal maximum principle; check temperature bounds +and heat balance for every method. + +Diffusion is absent only from the strong SUPG residual. Its omission is +exact for affine P1 fields with elementwise constant diffusivity, not for +arbitrary curved mappings, variable coefficients or P2 temperature. + +### Checkpoint State + +```python +orchestration_model = uw.get_default_model() +orchestration_model.save_state(file="checkpoint.h5") +# With the matching model, fields and integration method constructed: +orchestration_model.load_state("checkpoint.h5") +``` + +The PETSc-backed snapshot captures T and the required history automatically: +Tdot and startup status for CitcomS; DDt fields, timestep history, theta, +and the field-change estimator state for implicit integration. A T-only +checkpoint is not an exact restart. Disk snapshots currently require the +same model layout and MPI rank count. Old full-model snapshots with a +different solver/history layout require migration; importing the old module +name does not make those layouts equivalent. + +Implementation ownership is `systems/advection_diffusion_eulerian.py`. +`systems/advdiff_supg.py` contains compatibility imports only. + +## Further Reading - Design note and measurements: `docs/developer/design/eulerian-supg-transport.md` - The semi-Lagrangian schemes: {doc}`semi-lagrangian-time-integration` diff --git a/docs/advanced/supg-transport.md b/docs/advanced/supg-transport.md new file mode 100644 index 000000000..0afcc7fd7 --- /dev/null +++ b/docs/advanced/supg-transport.md @@ -0,0 +1,11 @@ +# SUPG Scalar Transport + +The general implicit solver and the CitcomS predictor-corrector now share +one implementation and one public class, `uw.systems.AdvDiffusionSUPG`. + +See [Eulerian advection-diffusion](eulerian-advection-diffusion.md) for the +method table, CitcomS mode, timestep policies, and restart requirements. + +Existing scripts selecting `time_integrator="citcoms"` retain that method. +The general default is now Crank-Nicolson; select `theta=1.0` or +`time_integrator="bdf"` explicitly for backward Euler. diff --git a/docs/developer/CHANGELOG.md b/docs/developer/CHANGELOG.md index d578ca502..99d52c12b 100644 --- a/docs/developer/CHANGELOG.md +++ b/docs/developer/CHANGELOG.md @@ -6,6 +6,25 @@ This log tracks significant development work at a conceptual level, suitable for ## 2026 Q3 (July – September) +### Unified SUPG Time Integrators (September 2026) + +The Eulerian SUPG solver now owns the CitcomS P1 predictor-corrector as an +optional time integrator. CN, backward Euler, BDF2 and CitcomS use one public +class and common residual assembly; the former SUPG module contains imports +only. CitcomS retains its directional simplex stabilisation, positive lumped +mass, two corrections, explicit timestep bound and cached workspaces. It no +longer allocates unused implicit history fields. + +Snapshot state includes the implicit field-change timestep estimator as well +as CitcomS startup state. Focused tests compare the migrated implementation +against frozen pre-migration source on triangles and tetrahedra and exercise +in-memory and PETSc-backed snapshot/replay. The Gaussian MPI test now obtains +its serial reference on the same host and mesh rather than comparing against +a host-dependent stored value; its error threshold is unchanged. Production +Gadi benchmark and memory acceptance remain separate validation gates. + +See [the transport guide](../advanced/eulerian-advection-diffusion.md). + ### A Singular Recovery Mass, Mistaken for a Penalty Defect (August 2026) **The grad-div penalty default stays off**, but the reason it was held off turned out to diff --git a/src/underworld3/systems/advdiff_supg.py b/src/underworld3/systems/advdiff_supg.py new file mode 100644 index 000000000..050e8cba5 --- /dev/null +++ b/src/underworld3/systems/advdiff_supg.py @@ -0,0 +1,11 @@ +"""Compatibility imports for pre-unification SUPG users. + +The only implementation lives in advection_diffusion_eulerian. +""" + +from .advection_diffusion_eulerian import ( + AdvDiffusionSUPGState, + SNES_AdvectionDiffusion_SUPG as SNES_AdvectionDiffusionSUPG, +) + +__all__ = ["AdvDiffusionSUPGState", "SNES_AdvectionDiffusionSUPG"] diff --git a/src/underworld3/systems/advection_diffusion_eulerian.py b/src/underworld3/systems/advection_diffusion_eulerian.py index d294bdb35..3532d0f73 100644 --- a/src/underworld3/systems/advection_diffusion_eulerian.py +++ b/src/underworld3/systems/advection_diffusion_eulerian.py @@ -26,6 +26,11 @@ """ import warnings +import math +from dataclasses import dataclass + +from petsc4py import PETSc +from underworld3.checkpoint.state import SnapshottableState import numpy as np import sympy @@ -39,6 +44,7 @@ from underworld3.systems.ddt import Eulerian as Eulerian_DDt from underworld3.systems.solvers import ( _advective_diffusive_dt, + _centroid_velocities_nd, _dimensionalise_dt, _invalidate_solution_cache, _nondimensionalise_timestep, @@ -64,158 +70,107 @@ def _as_row_vector(V_fn, dim): ) -class SNES_AdvectionDiffusion_SUPG(SNES_Scalar): - r"""Eulerian advection-diffusion solver, implicit in time, SUPG in space. +@dataclass +class AdvDiffusionSUPGState(SnapshottableState): + """Integrator metadata; fields and DDt history are captured separately.""" - .. math:: - \frac{\partial \phi}{\partial t} + \mathbf{u}\cdot\nabla\phi - - \nabla\cdot(\kappa\nabla\phi) = f - - 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 - ========== ======= ===================================================== - - ``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: - - 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 + time_integrator: str = "implicit" + rate_initialised: bool = False + last_timestep: Optional[float] = None + last_change_rate: Optional[float] = None + order: int = 1 + theta: float = 0.5 + adv_gamma: float = 0.5 + corrector_steps: int = 2 - 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 - - 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 - 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, advection, source) the residual - assembled through PETSc's pointwise interface is +class SNES_AdvectionDiffusion_SUPG(SNES_Scalar): + r"""Scalar transport with shared SUPG assembly and selectable time integration. .. 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.** + \partial_t T + \mathbf{u}\cdot\nabla T + - \nabla\cdot(\kappa\nabla T) = f. - .. 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()``) 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, as :math:`(\mathbf{u}\Delta t)^2` for the second-order schemes. - :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 - serial and needs no departure points in parallel. + Velocity and material coefficients are frozen during each update. Parameters ---------- mesh : Mesh + Computational volume mesh. u_Field : MeshVariable - Continuous scalar field :math:`\phi`. + Continuous scalar field. V_fn : MeshVariable or sympy Matrix - Advecting velocity, ``(1, dim)``. + Advecting velocity. order : int, default 1 - Time-integration order, 1 to 3 (see the table above). + Implicit history order: 1, 2 or 3. Leave at 1 for CitcomS, + which manages its own rate instead of DDt history. 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. - verbose : bool, default False + At order 1, 0.5 selects Crank-Nicolson (implicit default) and 1 + backward Euler. Orders 2 and 3 require 1. Leave unset for CitcomS. + time_integrator : {"implicit", "citcoms", "bdf"}, default "implicit" + "implicit" selects CN/BE/BDF2/BDF3 through order and theta. + "citcoms" selects the P1 lumped-mass predictor-corrector used by + CitcomS-style mantle-convection benchmarks. "bdf" retains the + previous BDF selection, including backward Euler at order 1. + temperature_rate_field : MeshVariable, optional + Separate continuous P1 field storing the CitcomS rate. A stable + name such as Tdot is useful for field checkpoints. Created internally + if omitted; not used by implicit integrators. + adv_gamma : float, default 0.5 + CitcomS predictor/corrector weight, in (0, 1]. + corrector_steps : int, default 2 + Number of fixed CitcomS residual corrections. + tau : scalar expression, optional + Explicit stabilisation parameter; zero gives Galerkin transport. + tau_model : {"generic", "citcoms"}, optional + Defaults to the time integrator's model. Generic implicit transport + uses the transient norm of time, advection and diffusion scales. + CitcomS uses a clipped steady parameter on directional simplex + lengths. Its automatic operations require triangles or tetrahedra. DuDt : Eulerian, optional - A pre-built history manager (order at least ``order``, no ``V_fn``). + Pre-built implicit history manager with V_fn=None. Not used by CitcomS. + verbose : bool, default False + Solver verbosity. 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. + SLCN-only compatibility arguments, ignored with a warning for + implicit transport. CitcomS rejects supplied history operators. Notes ----- - 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). The linear system is nonsymmetric, - 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``. + All methods share the same pointwise assembly: + + .. math:: + F_0 = R,\qquad + \mathbf{F}_1 = \kappa\nabla T + \tau R\mathbf{u}. + + The implicit method's spatial history weights apply to diffusion and + advection. Diffusion is omitted only from the strong SUPG residual. + It vanishes identically for affine P1 elements with elementwise constant + diffusivity; curved mappings, variable diffusivity and higher-order fields + need separately validated flux-divergence recovery for full consistency. + + CitcomS predicts with (1-gamma)*dt*Tdot, resets the rate, then applies + fixed corrections delta_rate=-M_L^-1*F to the rate and gamma*dt*delta_rate + to temperature. Boundary values are reinserted at every correction. + Its default timestep is 0.9*min(dt_adv, dt_diff), not the implicit + field-change accuracy estimate. + + Implicit transport defaults to GMRES/ASM-ILU. preconditioner="fmg" + selects geometric multigrid when a mesh hierarchy is available. + CN may ring for under-resolved features; BDF3 may amplify pure advection. + The field-change timestep estimate is an accuracy heuristic, not a + guarantee of bounded temperature. + + The default Model's save_state() captures integrator metadata and all + registered fields. Pass file=... for a persistent PETSc-backed snapshot. + Restore into a matching model, mesh and integration method. + + Examples + -------- + >>> thermal = uw.systems.AdvDiffusionSUPG(mesh, T, U.sym, + ... time_integrator="citcoms", temperature_rate_field=Tdot) + >>> thermal.constitutive_model.Parameters.diffusivity = 1.0 + >>> thermal.solve(timestep=thermal.estimate_dt()) """ @timing.routine_timer_decorator @@ -232,12 +187,56 @@ def __init__( restore_points_func: Optional[Callable] = None, monotone_mode: Optional[str] = None, old_frame_traceback: bool = False, + *, + time_integrator: str = "implicit", + temperature_rate_field: Optional[uw.discretisation.MeshVariable] = None, + adv_gamma: float = 0.5, + corrector_steps: int = 2, + tau=None, + tau_model: Optional[str] = None, ): if not u_Field.continuous: raise ValueError( "u_Field must be a continuous MeshVariable: the SUPG weak form " "is continuous Galerkin." ) + if time_integrator not in ("implicit", "bdf", "citcoms"): + raise ValueError("time_integrator must be 'implicit', 'bdf' or 'citcoms'.") + if u_Field.num_components != 1: + raise ValueError("u_Field must be scalar.") + if mesh.dim != mesh.cdim: + raise NotImplementedError("SUPG currently requires a volume mesh.") + if time_integrator == "citcoms": + if u_Field.degree != 1: + raise ValueError("The CitcomS predictor-corrector requires continuous P1 temperature.") + if order != 1 or (theta is not None and float(theta) != 1.0): + raise ValueError("CitcomS uses gamma, not order/theta; leave order=1 and theta unset.") + if DuDt is not None or DFDt is not None: + raise ValueError("CitcomS manages its own derivative; do not supply DuDt or DFDt.") + if not 0.0 < float(adv_gamma) <= 1.0: + raise ValueError("adv_gamma must be in (0, 1].") + if int(corrector_steps) != corrector_steps or corrector_steps < 1: + raise ValueError("corrector_steps must be a positive integer.") + if temperature_rate_field is not None and ( + temperature_rate_field is u_Field + or temperature_rate_field.mesh is not mesh + or temperature_rate_field.degree != 1 + or not temperature_rate_field.continuous + or temperature_rate_field.num_components != 1 + ): + raise ValueError("temperature_rate_field must be a separate continuous scalar P1 variable on the solver mesh.") + elif temperature_rate_field is not None or adv_gamma != 0.5 or corrector_steps != 2: + raise ValueError("temperature_rate_field, adv_gamma and corrector_steps configure CitcomS only.") + if time_integrator in ("bdf", "citcoms"): + if theta is not None and float(theta) != 1.0: + raise ValueError("The bdf and citcoms modes require theta=1.0.") + theta = 1.0 + if tau_model is None: + tau_model = "citcoms" if time_integrator == "citcoms" else "generic" + if tau_model not in ("generic", "citcoms"): + raise ValueError("tau_model must be 'generic' or 'citcoms'.") + if time_integrator == "citcoms" and tau_model != "citcoms": + raise ValueError("CitcomS requires its steady tau model; supply tau for a custom value.") ignored = [name for name, value in ( ("restore_points_func", restore_points_func), ("monotone_mode", monotone_mode), @@ -262,7 +261,8 @@ def __init__( # 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" + integrator = ("citcoms" if time_integrator == "citcoms" else + "bdf" if time_integrator == "bdf" or order > 1 else "am") if theta != 1.0 and order != 1: raise ValueError( "theta applies at order 1 only (0.5 is Crank-Nicolson, 1.0 is " @@ -273,6 +273,10 @@ def __init__( super().__init__(mesh, u_Field, u_Field.degree, verbose, DuDt=DuDt, DFDt=None) + self.time_integrator = time_integrator + self.tau_model = tau_model + self.adv_gamma = float(adv_gamma) + self.corrector_steps = int(corrector_steps) self.f = sympy.Matrix.zeros(1, 1) self._integrator = integrator self._time_order = order @@ -295,7 +299,9 @@ def __init__( public_expression(rf"C^{{\tau}}_{{\kappa,{tag}}}", 4.0, "tau diffusive weight"), ] - if DuDt is None: + if time_integrator == "citcoms": + self.Unknowns.DuDt = None + elif DuDt is None: self.Unknowns.DuDt = Eulerian_DDt( self.mesh, u_Field, @@ -311,13 +317,15 @@ def __init__( smoothing=0.0, ) else: + if not isinstance(DuDt, Eulerian_DDt): + raise TypeError("DuDt must be an Eulerian history manager.") 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 " + "DuDt.V_fn must be None: advection is assembled " "implicitly by this solver, not as an explicit history correction." ) self.Unknowns.DuDt = DuDt @@ -338,6 +346,33 @@ def __init__( self.petsc_options["ksp_rtol"] = 1.0e-9 self.petsc_options["snes_max_it"] = 20 + self._tau_override = None if tau is None else sympy.sympify(tau) + self._automatic_tau = tau is None and tau_model == "citcoms" + self._supg_h = None + self._supg_tau = None + self._temperature_rate = None + self._lumped_mass = None + self._lumped_mass_mesh_version = None + self._citcoms_work_vectors = None + self._citcoms_work_mesh_version = None + self._simplex_data_cache = None + self._simplex_data_mesh_version = None + self._directional_rate_work = None + self._directional_rate_mesh_version = None + self._diffusion_dt_cache = None + self._rate_initialised = False + if time_integrator == "citcoms": + self._temperature_rate = temperature_rate_field + if self._temperature_rate is None: + self._temperature_rate = uw.discretisation.MeshVariable( + f"_supg_dTdt_{tag}", mesh, 1, degree=1, continuous=True) + if self._automatic_tau: + self._supg_h = uw.discretisation.MeshVariable( + f"_supg_h_{tag}", mesh, 1, degree=0, continuous=False) + self._supg_tau = uw.discretisation.MeshVariable( + f"_supg_tau_{tag}", mesh, 1, degree=0, continuous=False) + uw.get_default_model()._register_state_bearer(self) + # ------------------------------------------------------------------ # Linear solver # ------------------------------------------------------------------ @@ -457,8 +492,11 @@ def theta(self, value): "theta applies at order 1 only (0.5 is Crank-Nicolson, 1.0 is " "backward Euler); order 2 and 3 take theta=1.0." ) + if self.time_integrator == "citcoms" and value != 1.0: + raise ValueError("CitcomS uses adv_gamma, not theta.") self._theta = value - self.DuDt.theta = value + if self.DuDt is not None: + self.DuDt.theta = value @property def delta_t(self): @@ -474,7 +512,7 @@ def delta_t(self): @delta_t.setter def delta_t(self, value): dt = float(_nondimensionalise_timestep(value)) - if dt <= 0.0: + if not np.isfinite(dt) or dt <= 0.0: raise ValueError(f"timestep must be positive, not {dt}.") if dt != self._last_timestep: self._delta_t.sym = dt @@ -527,16 +565,22 @@ def tau_weights(self, values): def _states(self): r"""``[phi^{n+1}, phi^{n}, phi^{n-1}, ...]`` as scalar field symbols.""" + if self.time_integrator == "citcoms": + return [self.u.sym[0]] 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``.""" + if self.time_integrator == "citcoms": + return [sympy.Integer(1)] 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.time_integrator == "citcoms": + return self._temperature_rate.sym[0] if self._integrator == "bdf": return self.DuDt.bdf()[0] / self._delta_t phi_new, phi_old = self._states()[:2] @@ -575,9 +619,21 @@ def _scalar_diffusivity(self): "The SUPG parameter needs a scalar diffusivity; anisotropic " "diffusion is not supported by this solver." ) + value = sympy.sympify(uw.function.unwrap(kappa)) + if value.is_number and (not np.isfinite(float(value)) or float(value) < 0.0): + raise ValueError("SUPG diffusivity must be finite and non-negative.") return kappa + @property + def tau(self): + """SUPG parameter used by the shared residual.""" + return self._tau() + def _tau(self): + if self._tau_override is not None: + return self._supg_weight * self._tau_override + if self._automatic_tau: + return self._supg_weight * self._supg_tau.sym[0] dim = self.mesh.dim u = self._V_fn u_mag2 = sum(u[0, i] ** 2 for i in range(dim)) @@ -609,7 +665,7 @@ def _tau(self): # ------------------------------------------------------------------ @timing.routine_timer_decorator - def estimate_dt(self, fraction: float = 0.02, basis: str = "accuracy", + def estimate_dt(self, fraction: float = 0.02, basis: Optional[str] = None, direction_aware: bool = False, percentile: float = 0.0): r"""A timestep for this scheme, chosen for accuracy. @@ -655,6 +711,14 @@ def estimate_dt(self, fraction: float = 0.02, basis: str = "accuracy", """ from mpi4py import MPI + if self.time_integrator == "citcoms": + if basis not in (None, "stability"): + raise ValueError("CitcomS requires basis='stability', not an implicit accuracy estimate.") + if fraction != 0.02 or direction_aware or percentile != 0.0: + raise ValueError("CitcomS uses its fixed 0.9 stability factor and directional simplex length.") + return _dimensionalise_dt(self._estimate_citcoms_dt()) + if basis is None: + basis = "accuracy" if basis == "resolution": dt_estimate, dt_adv, dt_diff = _advective_diffusive_dt( self.constitutive_model.K, self._V_fn, self.mesh, @@ -729,6 +793,9 @@ def solve( if _force_setup: self._needs_function_rewire = True + if self.time_integrator == "citcoms": + return self._solve_citcoms(dt, verbose=verbose) + self._update_automatic_tau() if not self.constitutive_model._solver_is_setup: self._needs_function_rewire = True # The base ``_build`` resolves the preconditioner choice against the @@ -752,3 +819,405 @@ def solve( self.is_setup = True self.constitutive_model._solver_is_setup = True + + @property + def temperature_rate(self): + """Stored derivative for CitcomS, or None for an implicit method.""" + return self._temperature_rate + + @property + def state(self): + """Integrator metadata for snapshots; fields are captured by their mesh.""" + return AdvDiffusionSUPGState( + time_integrator=self.time_integrator, + rate_initialised=self._rate_initialised, + last_timestep=self._last_timestep, + last_change_rate=self._last_change_rate, + order=self.order, theta=self.theta, + adv_gamma=self.adv_gamma, corrector_steps=self.corrector_steps, + ) + + @state.setter + def state(self, state): + if not isinstance(state, AdvDiffusionSUPGState): + raise TypeError("AdvDiffusionSUPG state has the wrong type.") + if (state.time_integrator != self.time_integrator + or state.order != self.order + or state.adv_gamma != self.adv_gamma + or state.corrector_steps != self.corrector_steps): + raise ValueError("AdvDiffusionSUPG integration settings changed since snapshot.") + self.theta = state.theta + self._rate_initialised = bool(state.rate_initialised) + self._last_timestep = None + if state.last_timestep is not None: + self.delta_t = state.last_timestep + self._last_change_rate = state.last_change_rate + + def _simplex_data(self): + """Return local simplex connectivity, basis gradients, and volumes.""" + from underworld3.meshing.smoothing import _tet_cells, _tri_cells + + mesh_version = getattr(self.mesh, "_mesh_version", 0) + if ( + self._simplex_data_cache is not None + and self._simplex_data_mesh_version == mesh_version + ): + return self._simplex_data_cache + + cells = ( + _tri_cells(self.mesh.dm) + if self.mesh.dim == 2 + else _tet_cells(self.mesh.dm) if self.mesh.dim == 3 else None + ) + if cells is None or self.mesh.dim != self.mesh.cdim: + raise NotImplementedError( + "Automatic SUPG operations require a 2-D or 3-D volume " "simplex mesh." + ) + + coords = np.asarray(self.mesh.X.coords) + cell_coords = coords[cells] + edges = cell_coords[:, 1:, :] - cell_coords[:, :1, :] + try: + inverse_edges = np.linalg.inv(edges) + except np.linalg.LinAlgError as error: + raise RuntimeError("Cannot operate on a singular simplex.") from error + + gradients = np.empty_like(cell_coords) + gradients[:, 1:, :] = np.transpose(inverse_edges, (0, 2, 1)) + gradients[:, 0, :] = -gradients[:, 1:, :].sum(axis=1) + volumes = np.abs(np.linalg.det(edges)) / math.factorial(self.mesh.dim) + self._simplex_data_cache = (cells, gradients, volumes) + self._simplex_data_mesh_version = mesh_version + return self._simplex_data_cache + + def _streamline_directional_rate(self, gradients, velocity): + """Return ``sum_a |u.grad(N_a)|`` using reusable cell work arrays.""" + mesh_version = getattr(self.mesh, "_mesh_version", 0) + cell_count = velocity.shape[0] + if ( + self._directional_rate_work is None + or self._directional_rate_mesh_version != mesh_version + or self._directional_rate_work[0].shape != (cell_count,) + ): + self._directional_rate_work = ( + np.empty(cell_count, dtype=float), + np.empty(cell_count, dtype=float), + ) + self._directional_rate_mesh_version = mesh_version + + directional_rate, projection = self._directional_rate_work + directional_rate.fill(0.0) + for basis_index in range(gradients.shape[1]): + np.einsum( + "cd,cd->c", + gradients[:, basis_index, :], + velocity, + out=projection, + ) + np.abs(projection, out=projection) + np.add(directional_rate, projection, out=directional_rate) + return directional_rate + + def _cell_diffusivity(self, cell_count): + """Evaluate non-negative scalar diffusivity at cell centroids.""" + diffusivity_expr = sympy.sympify(self.constitutive_model.K) + if isinstance(diffusivity_expr, sympy.MatrixBase): + raise NotImplementedError( + "Automatic SUPG operations require scalar isotropic " + "diffusivity; supply tau explicitly for tensor diffusivity." + ) + diffusivity = uw.function.evaluate(diffusivity_expr, self.mesh._centroids) + if hasattr(diffusivity, "units") and diffusivity.units is not None: + diffusivity = uw.non_dimensionalise(diffusivity) + elif hasattr(diffusivity, "magnitude"): + diffusivity = diffusivity.magnitude + diffusivity = np.asarray(diffusivity, dtype=float).reshape(-1) + if diffusivity.size == 1: + diffusivity = np.full(cell_count, diffusivity.item()) + if diffusivity.shape != (cell_count,): + raise ValueError("Diffusivity must evaluate to one scalar per cell.") + if np.any(diffusivity < 0.0): + raise ValueError("SUPG diffusivity must be non-negative.") + return diffusivity + + def _update_automatic_tau(self): + """Update local simplex streamline lengths and automatic tau values.""" + if not self._automatic_tau: + if self._tau_override is None: + self._scalar_diffusivity() + return + if self.constitutive_model is None: + raise RuntimeError( + "Set constitutive_model before solving AdvDiffusionSUPG." + ) + + _, gradients, _ = self._simplex_data() + + velocity = _centroid_velocities_nd(self.V_fn, self.mesh) + speed = np.linalg.norm(velocity, axis=1) + directional_rate = self._streamline_directional_rate(gradients, velocity) + h_stream = np.divide( + 2.0 * speed, + directional_rate, + out=np.zeros_like(speed), + where=directional_rate > 0.0, + ) + + diffusivity = self._cell_diffusivity(speed.size) + + tau_steady = np.zeros_like(speed) + moving = speed > np.finfo(float).eps + diffusive = moving & (diffusivity > 0.0) + nondiffusive = moving & ~diffusive + + if np.any(diffusive): + pe = speed[diffusive] * h_stream[diffusive] / (2.0 * diffusivity[diffusive]) + tau_steady[diffusive] = ( + h_stream[diffusive] + * np.maximum(0.0, 1.0 - 1.0 / pe) + / (2.0 * speed[diffusive]) + ) + tau_steady[nondiffusive] = h_stream[nondiffusive] / (2.0 * speed[nondiffusive]) + + tau_values = tau_steady + + if self._supg_h.array.shape[0] != h_stream.size: + raise RuntimeError("SUPG P0 field and local simplex counts do not match.") + self._supg_h.array[:, 0, 0] = h_stream + self._supg_tau.array[:, 0, 0] = tau_values + + def _setup_citcoms_residual(self, verbose=False): + """Build the reusable residual assembler for predictor-corrector steps.""" + if not self.constitutive_model._solver_is_setup: + self._needs_function_rewire = True + self._build(verbose) + self.is_setup = True + self.constitutive_model._solver_is_setup = True + + def _assemble_lumped_mass(self): + """Assemble positive P1 simplex row-sum masses on free global DOFs.""" + mesh_version = getattr(self.mesh, "_mesh_version", 0) + if ( + self._lumped_mass is not None + and self._lumped_mass_mesh_version == mesh_version + ): + return self._lumped_mass + if self._lumped_mass is not None: + self._lumped_mass.destroy() + self._lumped_mass = None + + from underworld3.meshing.smoothing import _owned_cell_mask + + cells, _, volumes = self._simplex_data() + owned = _owned_cell_mask(self.mesh.dm) + + local_mass = self.dm.createLocalVector() + global_mass = self.dm.createGlobalVector() + local_mass.set(0.0) + global_mass.set(0.0) + section = self.dm.getLocalSection() + vertex_start, _ = self.mesh.dm.getDepthStratum(0) + + for cell_index in np.flatnonzero(owned): + contribution = volumes[cell_index] / (self.mesh.dim + 1) + for vertex_index in cells[cell_index]: + offset = section.getOffset(vertex_start + int(vertex_index)) + if offset >= 0: + local_mass.array[offset] += contribution + + self.dm.localToGlobal( + local_mass, + global_mass, + addv=PETSc.InsertMode.ADD_VALUES, + ) + local_mass.destroy() + if global_mass.getLocalSize() and np.any(global_mass.array <= 0.0): + global_mass.destroy() + raise RuntimeError("CitcomS P1 lumped mass contains non-positive rows.") + + self._lumped_mass = global_mass + self._lumped_mass_mesh_version = mesh_version + return self._lumped_mass + + def _citcoms_vectors(self): + """Return reusable global vectors for predictor-corrector updates.""" + mesh_version = getattr(self.mesh, "_mesh_version", 0) + if ( + self._citcoms_work_vectors is not None + and self._citcoms_work_mesh_version == mesh_version + ): + return self._citcoms_work_vectors + + if self._citcoms_work_vectors is not None: + for vector in self._citcoms_work_vectors: + vector.destroy() + + solution = self.dm.createGlobalVector() + residual = solution.duplicate() + delta_rate = solution.duplicate() + rate = solution.duplicate() + self._citcoms_work_vectors = (solution, residual, delta_rate, rate) + self._citcoms_work_mesh_version = mesh_version + return self._citcoms_work_vectors + + @timing.routine_timer_decorator + def _estimate_citcoms_dt(self): + """Estimate a simplex advection-diffusion timestep. + + The CitcomS-compatible predictor-corrector uses + ``0.9 * min(1/max(lambda_adv), 2/max(rowsum(abs(M_L^-1 K))))``. + The same conservative value is also available for the implicit BDF + path as a resolution-accuracy estimate. + """ + from mpi4py import MPI + from underworld3.meshing.smoothing import _owned_cell_mask + + cells, gradients, volumes = self._simplex_data() + velocity = _centroid_velocities_nd(self.V_fn, self.mesh) + directional_rate = self._streamline_directional_rate(gradients, velocity) + local_adv_rate = ( + float(np.max(directional_rate)) if directional_rate.size else 0.0 + ) + adv_rate = uw.mpi.comm.allreduce(local_adv_rate, op=MPI.MAX) + dt_adv = 1.0 / adv_rate if adv_rate > 0.0 else np.inf + + diffusivity = self._cell_diffusivity(len(cells)) + has_diffusivity = bool( + uw.mpi.comm.allreduce( + int(np.any(diffusivity > 0.0)), + op=MPI.MAX, + ) + ) + if not has_diffusivity: + dt_diff = np.inf + else: + self._setup_citcoms_residual() + mass = self._assemble_lumped_mass() + diffusion_signature = ( + getattr(self.mesh, "_mesh_version", 0), + hash(diffusivity.tobytes()), + ) + local_cache_valid = ( + self._diffusion_dt_cache is not None + and self._diffusion_dt_cache[0] == diffusion_signature + ) + cache_valid = bool( + uw.mpi.comm.allreduce(int(local_cache_valid), op=MPI.MIN) + ) + if cache_valid: + dt_diff = self._diffusion_dt_cache[1] + self.dt_adv = dt_adv + self.dt_diff = dt_diff + return 0.9 * min(dt_adv, dt_diff) + + stiffness = self.dm.createMatrix() + stiffness.setOption(PETSc.Mat.Option.NEW_NONZERO_LOCATION_ERR, False) + section = self.dm.getLocalSection() + vertex_start, _ = self.mesh.dm.getDepthStratum(0) + owned = _owned_cell_mask(self.mesh.dm) + + for cell_index in np.flatnonzero(owned): + points = [vertex_start + int(index) for index in cells[cell_index]] + local_dofs = [section.getOffset(point) for point in points] + element_stiffness = ( + diffusivity[cell_index] + * volumes[cell_index] + * gradients[cell_index].dot(gradients[cell_index].T) + ) + stiffness.setValuesLocal( + local_dofs, + local_dofs, + element_stiffness, + addv=PETSc.InsertMode.ADD_VALUES, + ) + stiffness.assemble() + + row_start, row_end = stiffness.getOwnershipRange() + local_diff_rate = 0.0 + for row in range(row_start, row_end): + _, values = stiffness.getRow(row) + row_sum = float(np.sum(np.abs(values))) + local_diff_rate = max( + local_diff_rate, + row_sum / mass.array[row - row_start], + ) + diff_rate = uw.mpi.comm.allreduce(local_diff_rate, op=MPI.MAX) + stiffness.destroy() + dt_diff = 2.0 / diff_rate if diff_rate > 0.0 else np.inf + self._diffusion_dt_cache = (diffusion_signature, dt_diff) + + self.dt_adv = dt_adv + self.dt_diff = dt_diff + return 0.9 * min(dt_adv, dt_diff) + + def _compute_citcoms_residual(self, solution=None, residual=None): + """Assemble the residual at the current temperature and rate.""" + if solution is None: + solution = self.dm.createGlobalVector() + if residual is None: + residual = solution.duplicate() + solution.set(0.0) + self.dm.localToGlobal(self.u.vec, solution, addv=False) + residual.set(0.0) + self.mesh.update_lvec() + self.dm.setAuxiliaryVec(self.mesh.lvec, None) + self._update_constants() + self.snes.computeFunction(solution, residual) + return solution, residual + + def _solve_citcoms(self, timestep, verbose=False): + """Advance one CitcomS-compatible predictor-corrector timestep.""" + if timestep is None: + timestep = float(self.delta_t.data) + self.delta_t = timestep + dt = float(self.delta_t.data) + if dt <= 0.0: + raise ValueError("AdvDiffusionSUPG requires a positive timestep.") + + self._update_automatic_tau() + self._setup_citcoms_residual(verbose) + mass = self._assemble_lumped_mass() + temperature_global, residual, delta_rate, rate_global = self._citcoms_vectors() + + if not self._rate_initialised: + self._temperature_rate.array[:, 0, 0] = 0.0 + self._compute_citcoms_residual(temperature_global, residual) + delta_rate.pointwiseDivide(residual, mass) + delta_rate.scale(-1.0) + self._temperature_rate.vec.set(0.0) + self.dm.globalToLocal(delta_rate, self._temperature_rate.vec) + self.mesh._stale_lvec = True + self._rate_initialised = True + + self.u.array[:, 0, 0] += ( + (1.0 - self.adv_gamma) * dt * self._temperature_rate.array[:, 0, 0] + ) + self._temperature_rate.array[:, 0, 0] = 0.0 + self.mesh._stale_lvec = True + + from underworld3.cython.petsc_discretisation import ( + petsc_dm_insert_boundary_values, + ) + + for _ in range(self.corrector_steps): + self._compute_citcoms_residual(temperature_global, residual) + delta_rate.pointwiseDivide(residual, mass) + delta_rate.scale(-1.0) + + rate_global.set(0.0) + self.dm.localToGlobal(self._temperature_rate.vec, rate_global, addv=False) + rate_global.axpy(1.0, delta_rate) + temperature_global.axpy(self.adv_gamma * dt, delta_rate) + + self._temperature_rate.vec.set(0.0) + self.u.vec.set(0.0) + self.dm.globalToLocal(rate_global, self._temperature_rate.vec) + self.dm.globalToLocal(temperature_global, self.u.vec) + petsc_dm_insert_boundary_values(self.dm, self.u.vec) + self.mesh._stale_lvec = True + + _invalidate_solution_cache(self.u) + _invalidate_solution_cache(self._temperature_rate) + self.is_setup = True + self.constitutive_model._solver_is_setup = True + return diff --git a/tests/parallel/test_1077_advdiff_supg_parallel.py b/tests/parallel/test_1077_advdiff_supg_parallel.py index ae9938fd9..8d0c9d27a 100644 --- a/tests/parallel/test_1077_advdiff_supg_parallel.py +++ b/tests/parallel/test_1077_advdiff_supg_parallel.py @@ -12,14 +12,10 @@ import sympy import underworld3 as uw +from serial_reference import emit, mesh_fingerprint, serial_reference 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, @@ -37,13 +33,20 @@ def _run(): dt=dt) for _ in range(8): adv.solve(timestep=dt) - return sol.error(sol.at(8 * dt), T, norm="integral") + return sol.error(sol.at(8 * dt), T, norm="integral"), mesh_fingerprint(mesh) def test_error_is_partition_independent(): - err = _run() + err, fingerprint = _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) + reference = serial_reference(__file__, "gaussian") + assert int(fingerprint[0]) == int(reference["fingerprint"][0]) + np.testing.assert_allclose(fingerprint[1], reference["fingerprint"][1], rtol=1e-12) + assert abs(err - reference["values"][0]) < 1e-8, (err, reference) + + +if __name__ == "__main__": + error, fingerprint = _run() + emit([error], fingerprint) diff --git a/tests/test_1113_advdiff_supg_residual.py b/tests/test_1113_advdiff_supg_residual.py new file mode 100644 index 000000000..968f05f9a --- /dev/null +++ b/tests/test_1113_advdiff_supg_residual.py @@ -0,0 +1,252 @@ +"""Focused tests for the implicit SUPG scalar transport residual.""" + +import numpy as np +import pytest + +import underworld3 as uw + + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + + +def _mesh_temperature_velocity(prefix, velocity=(1.0, 0.0)): + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), + maxCoords=(1.0, 1.0), + cellSize=0.3, + regular=False, + ) + temperature = uw.discretisation.MeshVariable(f"T_{prefix}", mesh, 1, degree=1) + flow = uw.discretisation.MeshVariable(f"U_{prefix}", mesh, mesh.dim, degree=1) + with mesh.access(temperature, flow): + temperature.data[:, 0] = temperature.coords[:, 0] + flow.data[:, 0] = velocity[0] + flow.data[:, 1] = velocity[1] + return mesh, temperature, flow + + +def _configure_diffusion(solver, diffusivity=0.1): + solver.constitutive_model = uw.constitutive_models.DiffusionModel + solver.constitutive_model.Parameters.diffusivity = diffusivity + + +def test_public_api_and_residual_shapes(): + mesh, temperature, velocity = _mesh_temperature_velocity("api") + thermal = uw.systems.AdvDiffusionSUPG( + mesh, u_Field=temperature, V_fn=velocity.sym, tau=0.0 + ) + _configure_diffusion(thermal) + thermal.delta_t = 0.01 + + assert thermal.F0.sym.shape == (1, 1) + assert thermal.F1.sym.shape == (1, mesh.cdim) + assert float(thermal.tau) == 0.0 + + +def test_rejects_double_counted_eulerian_advection(): + mesh, temperature, velocity = _mesh_temperature_velocity("double") + history = uw.systems.Eulerian_DDt( + mesh, + temperature, + vtype=uw.VarType.SCALAR, + degree=temperature.degree, + continuous=temperature.continuous, + V_fn=velocity.sym, + ) + + with pytest.raises(ValueError, match="DuDt.V_fn must be None"): + uw.systems.AdvDiffusionSUPG( + mesh, + u_Field=temperature, + V_fn=velocity.sym, + DuDt=history, + ) + + +@pytest.mark.parametrize("theta", (0.0, 0.5)) +def test_rejects_nonimplicit_flux_history(theta): + mesh, temperature, velocity = _mesh_temperature_velocity(f"theta_{theta}") + with pytest.raises(ValueError, match="theta=1.0"): + uw.systems.AdvDiffusionSUPG( + mesh, + u_Field=temperature, + V_fn=velocity.sym, + theta=theta, + time_integrator="bdf", + ) + + +def test_automatic_tau_is_finite_and_bounded_by_transient_scale(): + mesh, temperature, velocity = _mesh_temperature_velocity("tau") + thermal = uw.systems.AdvDiffusionSUPG(mesh, u_Field=temperature, V_fn=velocity.sym) + _configure_diffusion(thermal, diffusivity=0.1) + thermal.delta_t = 0.02 + thermal._update_automatic_tau() + + tau = uw.function.evaluate(thermal.tau, mesh._centroids) + assert np.all(np.isfinite(tau)) + assert np.all(tau > 0.0) + assert np.all(tau <= 0.01) + + +def test_negative_diffusivity_is_rejected(): + mesh, temperature, velocity = _mesh_temperature_velocity("negative_k") + thermal = uw.systems.AdvDiffusionSUPG(mesh, u_Field=temperature, V_fn=velocity.sym) + _configure_diffusion(thermal, diffusivity=-0.1) + thermal.delta_t = 0.01 + + with pytest.raises(ValueError, match="non-negative"): + thermal._update_automatic_tau() + + +def test_zero_velocity_matches_diffusion_solver(): + mesh_a, temperature_a, velocity = _mesh_temperature_velocity( + "supg_zero", velocity=(0.0, 0.0) + ) + mesh_b, temperature_b, _ = _mesh_temperature_velocity( + "diffusion", velocity=(0.0, 0.0) + ) + with mesh_a.access(temperature_a), mesh_b.access(temperature_b): + temperature_a.data[:, 0] = np.sin(np.pi * temperature_a.coords[:, 0]) + temperature_b.data[:, 0] = np.sin(np.pi * temperature_b.coords[:, 0]) + + supg = uw.systems.AdvDiffusionSUPG( + mesh_a, u_Field=temperature_a, V_fn=velocity.sym, theta=1.0) + diffusion = uw.systems.Diffusion(mesh_b, u_Field=temperature_b, theta=1.0) + _configure_diffusion(supg, diffusivity=0.1) + _configure_diffusion(diffusion, diffusivity=0.1) + # Compare equations at the same solve accuracy, not two preconditioners' + # different default stopping criteria. + for solver in (supg, diffusion): + solver.petsc_options["ksp_rtol"] = 1.0e-13 + solver.petsc_options["snes_rtol"] = 1.0e-12 + solver.petsc_options["snes_atol"] = 1.0e-13 + + supg.solve(timestep=0.01, zero_init_guess=False) + diffusion.solve(timestep=0.01, zero_init_guess=False) + + np.testing.assert_allclose( + temperature_a.data, + temperature_b.data, + rtol=1.0e-11, + atol=1.0e-11, + ) + + +def test_citcoms_integrator_requires_continuous_p1_temperature(): + mesh, temperature, velocity = _mesh_temperature_velocity("citcoms_p1") + temperature_p2 = uw.discretisation.MeshVariable("T_citcoms_p2", mesh, 1, degree=2) + + with pytest.raises(ValueError, match="continuous P1"): + uw.systems.AdvDiffusionSUPG( + mesh, + u_Field=temperature_p2, + V_fn=velocity.sym, + time_integrator="citcoms", + ) + + +def test_citcoms_lumped_mass_matches_constant_residual(): + mesh, temperature, velocity = _mesh_temperature_velocity( + "citcoms_mass", velocity=(0.0, 0.0) + ) + thermal = uw.systems.AdvDiffusionSUPG( + mesh, + u_Field=temperature, + V_fn=velocity.sym, + time_integrator="citcoms", + tau=0.0, + ) + _configure_diffusion(thermal, diffusivity=0.0) + thermal.delta_t = 0.01 + thermal._setup_citcoms_residual() + mass = thermal._assemble_lumped_mass() + thermal._temperature_rate.data[:, 0] = 1.0 + solution, residual = thermal._compute_citcoms_residual() + + np.testing.assert_allclose(residual.array / mass.array, 1.0, atol=1.0e-14) + assert mass.min()[1] > 0.0 + solution.destroy() + residual.destroy() + + +def test_citcoms_constant_source_is_exact_from_first_step(): + mesh, temperature, velocity = _mesh_temperature_velocity( + "citcoms_source", velocity=(0.0, 0.0) + ) + temperature.data[:, 0] = 0.0 + thermal = uw.systems.AdvDiffusionSUPG( + mesh, + u_Field=temperature, + V_fn=velocity.sym, + time_integrator="citcoms", + tau=0.0, + ) + _configure_diffusion(thermal, diffusivity=0.0) + thermal.f = 1.0 + + thermal.solve(timestep=0.1) + + np.testing.assert_allclose(temperature.data, 0.1, atol=1.0e-14) + np.testing.assert_allclose(thermal._temperature_rate.data, 1.0, atol=1.0e-14) + + +def test_citcoms_reuses_predictor_corrector_work_vectors(): + mesh, temperature, velocity = _mesh_temperature_velocity( + "citcoms_workspace", velocity=(0.0, 0.0) + ) + thermal = uw.systems.AdvDiffusionSUPG( + mesh, + u_Field=temperature, + V_fn=velocity.sym, + time_integrator="citcoms", + tau=0.0, + ) + _configure_diffusion(thermal, diffusivity=0.0) + + thermal.solve(timestep=0.01) + vector_handles = tuple(vector.handle for vector in thermal._citcoms_work_vectors) + thermal.solve(timestep=0.01) + + assert ( + tuple(vector.handle for vector in thermal._citcoms_work_vectors) + == vector_handles + ) + + +def test_citcoms_timestep_uses_advection_and_lumped_diffusion_limits(): + mesh, temperature, velocity = _mesh_temperature_velocity("citcoms_dt") + thermal = uw.systems.AdvDiffusionSUPG( + mesh, + u_Field=temperature, + V_fn=velocity.sym, + time_integrator="citcoms", + ) + _configure_diffusion(thermal, diffusivity=0.1) + + timestep = thermal.estimate_dt() + + assert np.isfinite(timestep) + assert timestep == pytest.approx(0.9 * min(thermal.dt_adv, thermal.dt_diff)) + assert thermal.dt_adv > 0.0 + assert thermal.dt_diff > 0.0 + + +def test_timestep_diffusivity_branch_is_collective(): + mesh, temperature, velocity = _mesh_temperature_velocity("collective_diffusivity") + thermal = uw.systems.AdvDiffusionSUPG( + mesh, + u_Field=temperature, + V_fn=velocity.sym, + time_integrator="citcoms", + ) + _configure_diffusion(thermal, diffusivity=0.1) + thermal.delta_t = 0.01 + thermal._cell_diffusivity = lambda count: ( + np.ones(count) if uw.mpi.rank == 0 else np.zeros(count) + ) + + timestep = thermal.estimate_dt() + + assert np.isfinite(timestep) + assert thermal.dt_diff > 0.0 diff --git a/tests/test_1115_advdiff_supg_transient.py b/tests/test_1115_advdiff_supg_transient.py new file mode 100644 index 000000000..58ccda6ac --- /dev/null +++ b/tests/test_1115_advdiff_supg_transient.py @@ -0,0 +1,189 @@ +"""Temporal convergence validation for implicit SUPG transport.""" + +import numpy as np +import pytest +import sympy + +import underworld3 as uw + + +pytestmark = pytest.mark.level_3 + + +def _transient_state(timestep, order): + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), + maxCoords=(1.0, 1.0), + cellSize=0.22, + regular=True, + qdegree=3, + ) + token = str(timestep).replace(".", "p") + temperature = uw.discretisation.MeshVariable( + f"T_supg_time_{order}_{token}", mesh, 1, degree=1 + ) + velocity = uw.discretisation.MeshVariable( + f"U_supg_time_{order}_{token}", mesh, mesh.dim, degree=1 + ) + x, y = mesh.X + shape = sympy.sin(sympy.pi * x) * sympy.sin(sympy.pi * y) + diffusivity = 0.05 + advection_speed = 0.4 + with mesh.access(temperature, velocity): + temperature.data[:, 0] = uw.function.evaluate( + shape, temperature.coords + ).reshape(-1) + velocity.data[:, 0] = advection_speed + velocity.data[:, 1] = 0.0 + + thermal = uw.systems.AdvDiffusionSUPG( + mesh, + u_Field=temperature, + V_fn=velocity.sym, + order=order, + time_integrator="bdf", + tau=0.0, + ) + thermal.constitutive_model = uw.constitutive_models.DiffusionModel + thermal.constitutive_model.Parameters.diffusivity = diffusivity + for boundary in ("Left", "Right", "Top", "Bottom"): + thermal.add_dirichlet_bc(0.0, boundary) + + final_time = 0.2 + for step in range(round(final_time / timestep)): + new_time = (step + 1) * timestep + amplitude = np.exp(-new_time) + thermal.f = amplitude * ( + (-1.0 + 2.0 * diffusivity * sympy.pi**2) * shape + + advection_speed + * sympy.pi + * sympy.cos(sympy.pi * x) + * sympy.sin(sympy.pi * y) + ) + thermal.solve(timestep=timestep, zero_init_guess=False) + + return temperature.data[:, 0].copy() + + +@pytest.mark.parametrize( + ("order", "minimum_rate"), + ((1, 0.9), (2, 1.8)), +) +def test_bdf_temporal_convergence(order, minimum_rate): + reference = _transient_state(0.003125, 2) + timesteps = (0.05, 0.025, 0.0125) + errors = [ + np.linalg.norm(_transient_state(timestep, order) - reference) + / np.sqrt(reference.size) + for timestep in timesteps + ] + rates = [ + np.log(errors[index] / errors[index + 1]) / np.log(2.0) + for index in range(2) + ] + + assert errors[0] > errors[1] > errors[2] + assert min(rates) > minimum_rate + + +def _citcoms_decay_error(timestep): + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), + maxCoords=(1.0, 1.0), + cellSize=0.5, + regular=True, + ) + token = str(timestep).replace(".", "p") + temperature = uw.discretisation.MeshVariable( + f"T_citcoms_decay_{token}", mesh, 1, degree=1 + ) + velocity = uw.discretisation.MeshVariable( + f"U_citcoms_decay_{token}", mesh, mesh.dim, degree=1 + ) + temperature.data[:, 0] = 1.0 + + thermal = uw.systems.AdvDiffusionSUPG( + mesh, + u_Field=temperature, + V_fn=velocity.sym, + time_integrator="citcoms", + tau=0.0, + ) + thermal.constitutive_model = uw.constitutive_models.DiffusionModel + thermal.constitutive_model.Parameters.diffusivity = 0.0 + thermal.f = -temperature.sym[0] + + for _ in range(round(1.0 / timestep)): + thermal.solve(timestep=timestep) + + return abs(float(np.mean(temperature.data[:, 0])) - np.exp(-1.0)) + + +def test_citcoms_predictor_corrector_is_second_order_for_scalar_decay(): + errors = [_citcoms_decay_error(dt) for dt in (0.1, 0.05, 0.025)] + rates = [ + np.log(errors[index] / errors[index + 1]) / np.log(2.0) + for index in range(2) + ] + + assert errors[0] > errors[1] > errors[2] + assert min(rates) > 1.9 + + +def _citcoms_rotation_return_error(cell_size): + mesh = uw.meshing.Annulus( + radiusOuter=1.0, + radiusInner=0.5, + cellSize=cell_size, + qdegree=4, + ) + token = str(cell_size).replace(".", "p") + temperature = uw.discretisation.MeshVariable( + f"T_citcoms_rotation_{token}", mesh, 1, degree=1 + ) + velocity = uw.discretisation.MeshVariable( + f"U_citcoms_rotation_{token}", mesh, mesh.dim, degree=1 + ) + x, y = mesh.X + initial = sympy.exp(-30.0 * (x**2 + (y - 0.75) ** 2)) + + with mesh.access(temperature, velocity): + temperature.data[:, 0] = uw.function.evaluate( + initial, temperature.coords + ).reshape(-1) + velocity.data[:, 0] = -2.0 * np.pi * velocity.coords[:, 1] + velocity.data[:, 1] = 2.0 * np.pi * velocity.coords[:, 0] + + thermal = uw.systems.AdvDiffusionSUPG( + mesh, + u_Field=temperature, + V_fn=velocity.sym, + time_integrator="citcoms", + ) + thermal.constitutive_model = uw.constitutive_models.DiffusionModel + thermal.constitutive_model.Parameters.diffusivity = 0.0 + + step_count = int(np.ceil(1.0 / thermal.estimate_dt())) + timestep = 1.0 / step_count + for _ in range(step_count): + thermal.solve(timestep=timestep) + + error = float( + np.sqrt( + uw.maths.Integral( + mesh, fn=(temperature.sym[0] - initial) ** 2 + ).evaluate() + ) + ) + initial_norm = float( + np.sqrt(uw.maths.Integral(mesh, fn=initial**2).evaluate()) + ) + return error / initial_norm + + +def test_citcoms_rotation_return_error_decreases_with_refinement(): + coarse_error = _citcoms_rotation_return_error(0.2) + fine_error = _citcoms_rotation_return_error(0.1) + + assert fine_error < 0.9 * coarse_error + assert fine_error < 0.7 diff --git a/tests/test_1116_supg_unified.py b/tests/test_1116_supg_unified.py new file mode 100644 index 000000000..0988a0d14 --- /dev/null +++ b/tests/test_1116_supg_unified.py @@ -0,0 +1,129 @@ +"""Shared SUPG integration, restart, and pre-migration equivalence.""" + +import importlib.util +import os +import sys + +import numpy as np +import pytest +import sympy + +import underworld3 as uw + +pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] + + +def _problem(dim, tag): + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0,) * dim, maxCoords=(1.0,) * dim, + cellSize=0.5, qdegree=4, regular=False, + ) + temperature = uw.discretisation.MeshVariable(f"T_{tag}", mesh, 1, degree=1) + velocity = uw.discretisation.MeshVariable(f"U_{tag}", mesh, dim, degree=1) + temperature.array[:, 0, 0] = temperature.coords[:, 0] + velocity.array[:, 0, :] = 0.2 + return mesh, temperature, velocity + + +@pytest.mark.parametrize("dim", [2, 3]) +def test_citcoms_matches_pre_migration_implementation(dim): + """Optional release gate against the frozen source from commit 87b3711d. + + Both assemblers see the same mesh, fields, partition and time sequence. + The frozen source is an external test artifact, not another installed solver. + """ + baseline = os.environ.get("UW_SUPG_BASELINE_FILE") + if baseline is None: + pytest.skip("Set UW_SUPG_BASELINE_FILE to the frozen pre-migration module.") + spec = importlib.util.spec_from_file_location("_supg_baseline", baseline) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + + mesh, temperature, velocity = _problem(dim, f"migration_{dim}") + reference = uw.discretisation.MeshVariable("T_reference", mesh, 1, degree=1) + rate = uw.discretisation.MeshVariable("Tdot", mesh, 1, degree=1) + reference_rate = uw.discretisation.MeshVariable("Tdot_reference", mesh, 1, degree=1) + shape = sympy.prod(sympy.sin(sympy.pi * x) for x in mesh.X) + temperature.array[:, 0, 0] = uw.function.evaluate(shape, temperature.coords).reshape(-1) + reference.array[...] = temperature.array + current = uw.systems.AdvDiffusionSUPG( + mesh, temperature, velocity.sym, time_integrator="citcoms", + temperature_rate_field=rate, + ) + previous = module.SNES_AdvectionDiffusionSUPG( + mesh, reference, velocity.sym, time_integrator="citcoms", + temperature_rate_field=reference_rate, + ) + for solver in (current, previous): + solver.constitutive_model = uw.constitutive_models.DiffusionModel + solver.constitutive_model.Parameters.diffusivity = 0.01 + solver.f = 0.1 * shape + for boundary in mesh.boundaries: + if boundary.name not in ("All_Boundaries", "Null_Boundary"): + solver.add_dirichlet_bc(0.0, boundary.name) + + for step in range(6): + velocity.array[:, 0, :] = 0.2 * (1.0 + step / 10.0) + dt = min(0.002, float(current.estimate_dt())) + np.testing.assert_allclose( + current.estimate_dt(), previous.estimate_dt(), rtol=1e-12, atol=1e-14) + current.solve(timestep=dt) + previous.solve(timestep=dt) + np.testing.assert_allclose(temperature.array, reference.array, rtol=1e-11, atol=1e-12) + np.testing.assert_allclose(rate.array, reference_rate.array, rtol=1e-10, atol=1e-11) + + +@pytest.mark.parametrize("settings", [ + {"time_integrator": "citcoms"}, {"order": 1}, {"order": 2}, +]) +@pytest.mark.parametrize("disk", [False, True]) +def test_snapshot_restores_fields_and_timestep_estimator(settings, disk, tmp_path): + uw.reset_default_model() + orchestration_model = uw.get_default_model() + mesh, temperature, velocity = _problem(2, "snapshot") + thermal = uw.systems.AdvDiffusionSUPG(mesh, temperature, velocity.sym, **settings) + thermal.constitutive_model.Parameters.diffusivity = 0.05 + thermal.add_dirichlet_bc(0.0, "Left") + thermal.add_dirichlet_bc(1.0, "Right") + for _ in range(3): + thermal.solve(timestep=0.002) + if disk: + path = uw.mpi.comm.bcast(str(tmp_path / "thermal.h5"), root=0) + snapshot = orchestration_model.save_state(file=path) + else: + snapshot = orchestration_model.save_state() + saved_temperature = np.array(temperature.array) + estimate = thermal.estimate_dt() + thermal.solve(timestep=0.003) + expected = np.array(temperature.array) + expected_state = thermal.state + expected_rate = None if thermal.temperature_rate is None else np.array(thermal.temperature_rate.array) + orchestration_model.load_state(snapshot) + np.testing.assert_array_equal(temperature.array, saved_temperature) + assert thermal.estimate_dt() == pytest.approx(estimate, rel=1e-14) + thermal.solve(timestep=0.01) + orchestration_model.load_state(snapshot) + thermal.solve(timestep=0.003) + # Rebuilding an implicit Krylov solve can change final rounding, but the + # restored fields above must be exact and replay must agree near machine precision. + np.testing.assert_allclose(temperature.array, expected, rtol=2e-14, atol=2e-14) + assert thermal.state.last_timestep == expected_state.last_timestep + if expected_state.last_change_rate is not None: + assert thermal.state.last_change_rate == pytest.approx( + expected_state.last_change_rate, rel=5e-12, abs=1e-12) + if expected_rate is not None: + np.testing.assert_array_equal(thermal.temperature_rate.array, expected_rate) + uw.reset_default_model() + + +def test_citcoms_does_not_allocate_unused_multistep_history(): + mesh, temperature, velocity = _problem(2, "history") + thermal = uw.systems.AdvDiffusionSUPG( + mesh, temperature, velocity.sym, time_integrator="citcoms") + assert thermal.DuDt is None + assert thermal.temperature_rate is not None + with pytest.raises(ValueError, match="stability"): + thermal.estimate_dt(basis="accuracy") + with pytest.raises(ValueError, match="gamma"): + thermal.theta = 0.5 From a1c1ab586d8c8bb8b2c73187db5cea0571e3134a Mon Sep 17 00:00:00 2001 From: Tyagi Date: Sat, 5 Sep 2026 15:05:54 +1000 Subject: [PATCH 16/35] test: align SUPG numerical regressions with the unified API Select backward Euler explicitly for quasi-steady manufactured and high-Peclet tests. Check the public generic tau expression instead of the removed P0 implementation detail. Compare spherical serial/MPI values on the same host and cached triangulation; keep manufactured convergence as the absolute accuracy gate. Apply the same near-machine-precision implicit replay tolerance as the unified snapshot tests. Syntax and whitespace checks pass; Gadi numerical validation remains pending. --- tests/test_1114_advdiff_supg.py | 474 ++++++++++++++++++++++++++++++++ 1 file changed, 474 insertions(+) create mode 100644 tests/test_1114_advdiff_supg.py diff --git a/tests/test_1114_advdiff_supg.py b/tests/test_1114_advdiff_supg.py new file mode 100644 index 000000000..0f8f8ab14 --- /dev/null +++ b/tests/test_1114_advdiff_supg.py @@ -0,0 +1,474 @@ +"""Numerical validation for implicit and predictor-corrector SUPG transport.""" + +from pathlib import Path +import sys + +import numpy as np +import pytest +import sympy + +import underworld3 as uw + + +pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] + + +def test_simplex_geometry_is_reused_between_automatic_operations(): + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), + maxCoords=(1.0, 1.0), + cellSize=0.25, + regular=True, + ) + temperature = uw.discretisation.MeshVariable( + "T_geometry_cache", mesh, 1, degree=1 + ) + velocity = uw.discretisation.MeshVariable( + "U_geometry_cache", mesh, mesh.dim, degree=1 + ) + thermal = uw.systems.AdvDiffusionSUPG( + mesh, + u_Field=temperature, + V_fn=velocity.sym, + time_integrator="citcoms", + ) + thermal.constitutive_model = uw.constitutive_models.DiffusionModel + thermal.constitutive_model.Parameters.diffusivity = 1.0 + + first = thermal._simplex_data() + second = thermal._simplex_data() + + assert all(a is b for a, b in zip(first, second)) + + sample_velocity = np.column_stack( + ( + np.linspace(0.1, 0.9, len(first[0])), + np.linspace(-0.3, 0.4, len(first[0])), + ) + ) + expected_rate = np.abs( + np.einsum("cad,cd->ca", first[1], sample_velocity) + ).sum(axis=1) + first_rate = thermal._streamline_directional_rate( + first[1], sample_velocity + ) + second_rate = thermal._streamline_directional_rate( + first[1], sample_velocity + ) + + np.testing.assert_allclose(first_rate, expected_rate) + assert first_rate is second_rate + + deformed = mesh.X.coords.copy() + deformed[:, 0] *= 1.1 + mesh.deform(deformed) + third = thermal._simplex_data() + + assert all(a is not b for a, b in zip(first, third)) + assert not np.isclose(first[2].sum(), third[2].sum()) + assert thermal._streamline_directional_rate( + third[1], sample_velocity + ) is not first_rate + + +def test_tetrahedron_streamline_length_is_geometry_invariant(): + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0, 0.0), + maxCoords=(1.0, 1.0, 1.0), + cellSize=1.0, + regular=True, + ) + temperature = uw.discretisation.MeshVariable( + "T_tet_streamline", mesh, 1, degree=1 + ) + velocity_field = uw.discretisation.MeshVariable( + "U_tet_streamline", mesh, mesh.dim, degree=1 + ) + thermal = uw.systems.AdvDiffusionSUPG( + mesh, + u_Field=temperature, + V_fn=velocity_field.sym, + time_integrator="citcoms", + ) + + lengths = np.array((2.0, 1.0, 0.5)) + gradients = np.vstack((-1.0 / lengths, np.diag(1.0 / lengths)))[None, :, :] + velocity = np.array(((0.8, 0.3, 0.2),)) + speed = np.linalg.norm(velocity, axis=1) + expected_length = speed / np.sum(velocity / lengths, axis=1) + + directional_rate = thermal._streamline_directional_rate( + gradients, velocity + ).copy() + streamline_length = 2.0 * speed / directional_rate + np.testing.assert_allclose(streamline_length, expected_length) + + permutation = (2, 0, 3, 1) + permuted_rate = thermal._streamline_directional_rate( + gradients[:, permutation, :], velocity + ) + np.testing.assert_allclose(permuted_rate, directional_rate) + + rotation = np.array( + ( + (0.0, -1.0, 0.0), + (1.0, 0.0, 0.0), + (0.0, 0.0, 1.0), + ) + ) + rotated_gradients = gradients @ rotation.T + rotated_velocity = velocity @ rotation.T + rotated_rate = thermal._streamline_directional_rate( + rotated_gradients, rotated_velocity + ) + np.testing.assert_allclose(rotated_rate, directional_rate) + + +def _high_peclet_solution(tau, name): + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), + maxCoords=(1.0, 1.0), + cellSize=0.22, + regular=True, + ) + temperature = uw.discretisation.MeshVariable( + f"T_layer_{name}", mesh, 1, degree=1 + ) + velocity = uw.discretisation.MeshVariable( + f"U_layer_{name}", mesh, mesh.dim, degree=1 + ) + with mesh.access(temperature, velocity): + temperature.data[:, 0] = temperature.coords[:, 0] + velocity.data[:, 0] = 1.0 + velocity.data[:, 1] = 0.0 + + thermal = uw.systems.AdvDiffusionSUPG( + mesh, + u_Field=temperature, + V_fn=velocity.sym, + tau=tau, + time_integrator="bdf", + ) + thermal.constitutive_model = uw.constitutive_models.DiffusionModel + thermal.constitutive_model.Parameters.diffusivity = 0.01 + thermal.add_dirichlet_bc(0.0, "Left") + thermal.add_dirichlet_bc(1.0, "Right") + thermal.solve(timestep=1.0e6, zero_init_guess=False) + + x = temperature.coords[:, 0] + exact = np.expm1(100.0 * x) / np.expm1(100.0) + rms_error = float(np.sqrt(np.mean((temperature.data[:, 0] - exact) ** 2))) + return temperature.data.copy(), rms_error + + +def test_supg_reduces_high_peclet_oscillation_and_error(): + galerkin, galerkin_error = _high_peclet_solution(0.0, "galerkin") + supg, supg_error = _high_peclet_solution(None, "supg") + + galerkin_overshoot = max(0.0, float(galerkin.max() - 1.0)) + galerkin_undershoot = max(0.0, float(-galerkin.min())) + supg_overshoot = max(0.0, float(supg.max() - 1.0)) + supg_undershoot = max(0.0, float(-supg.min())) + + assert supg_overshoot < galerkin_overshoot + assert supg_undershoot < galerkin_undershoot + assert supg_error < 0.2 * galerkin_error + + +def _manufactured_error(cell_size, degree): + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), + maxCoords=(1.0, 1.0), + cellSize=cell_size, + regular=True, + qdegree=4, + ) + temperature = uw.discretisation.MeshVariable( + f"T_mms_{degree}_{cell_size}", mesh, 1, degree=degree + ) + velocity = uw.discretisation.MeshVariable( + f"U_mms_{degree}_{cell_size}", mesh, mesh.dim, degree=1 + ) + x, y = mesh.X + exact = sympy.sin(sympy.pi * x) * sympy.sin(sympy.pi * y) + diffusivity = 0.1 + with mesh.access(temperature, velocity): + temperature.data[:, 0] = uw.function.evaluate( + exact, temperature.coords + ).reshape(-1) + velocity.data[:, 0] = 1.0 + velocity.data[:, 1] = 0.0 + + thermal = uw.systems.AdvDiffusionSUPG( + mesh, u_Field=temperature, V_fn=velocity.sym, time_integrator="bdf" + ) + thermal.constitutive_model = uw.constitutive_models.DiffusionModel + thermal.constitutive_model.Parameters.diffusivity = diffusivity + thermal.f = ( + sympy.pi * sympy.cos(sympy.pi * x) * sympy.sin(sympy.pi * y) + + 2.0 * diffusivity * sympy.pi**2 * exact + ) + for boundary in ("Left", "Right", "Top", "Bottom"): + thermal.add_dirichlet_bc(0.0, boundary) + thermal.solve(timestep=1.0e8, zero_init_guess=False) + + return float( + np.sqrt( + uw.maths.Integral( + mesh, fn=(temperature.sym[0] - exact) ** 2 + ).evaluate() + ) + ) + + +@pytest.mark.parametrize("degree", (1, 2)) +def test_manufactured_solution_converges_under_refinement(degree): + cell_sizes = (0.3, 0.2, 0.13) + errors = [_manufactured_error(cell_size, degree) for cell_size in cell_sizes] + final_rate = np.log(errors[-2] / errors[-1]) / np.log( + cell_sizes[-2] / cell_sizes[-1] + ) + + assert errors[0] > errors[1] > errors[2] + assert final_rate > 1.5 + + +def _spherical_implicit_response(): + mesh = uw.meshing.SphericalShell( + radiusInner=0.55, + radiusOuter=1.0, + cellSize=0.4, + qdegree=2, + ) + temperature = uw.discretisation.MeshVariable( + "T_supg_spherical", mesh, 1, degree=1 + ) + velocity = uw.discretisation.MeshVariable( + "U_supg_spherical", mesh, mesh.dim, degree=1 + ) + with mesh.access(temperature, velocity): + coords = temperature.coords + radii = np.linalg.norm(coords, axis=1) + temperature.data[:, 0] = (1.0 - radii) / 0.45 + 0.01 * coords[:, 0] + velocity.data[:, 0] = -0.02 * coords[:, 1] + velocity.data[:, 1] = 0.02 * coords[:, 0] + velocity.data[:, 2] = 0.0 + + thermal = uw.systems.AdvDiffusionSUPG( + mesh, + u_Field=temperature, + V_fn=velocity.sym, + ) + thermal.constitutive_model = uw.constitutive_models.DiffusionModel + thermal.constitutive_model.Parameters.diffusivity = 0.01 + thermal.add_dirichlet_bc(0.0, "Upper") + thermal.add_dirichlet_bc(1.0, "Lower") + + for _ in range(3): + thermal.solve(timestep=1.0e-3, zero_init_guess=False) + + temperature_l2_squared = float( + uw.maths.Integral(mesh, fn=temperature.sym[0] ** 2).evaluate() + ) + assert np.all(np.isfinite(temperature.data)) + assert np.all(np.isfinite(uw.function.evaluate(thermal.tau, mesh._centroids))) + return temperature_l2_squared, mesh + + +def _compare_spherical_with_serial(run, kind): + sys.path.insert(0, str(Path(__file__).parent / "parallel")) + from serial_reference import compare, mesh_fingerprint, serial_reference + + value, mesh = run() + # Absolute accuracy is covered by the manufactured-solution tests above; + # this gate compares partitions of the same cached spherical triangulation. + assert np.isfinite(value) and value > 0.0 + compare([value], serial_reference(__file__, kind), [1e-8], ["integral T^2"], + mesh_fingerprint(mesh), f"SUPG {kind}") + + +def test_spherical_shell_supg_is_parallel_safe(): + _compare_spherical_with_serial(_spherical_implicit_response, "implicit") + + +def test_bdf2_snapshot_restore_leaves_no_discarded_step_trace(): + uw.reset_default_model() + model = uw.get_default_model() + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), + maxCoords=(1.0, 1.0), + cellSize=0.3, + regular=True, + ) + temperature = uw.discretisation.MeshVariable( + "T_supg_restart", mesh, 1, degree=1 + ) + velocity = uw.discretisation.MeshVariable( + "U_supg_restart", mesh, mesh.dim, degree=1 + ) + with mesh.access(temperature, velocity): + temperature.data[:, 0] = np.sin(np.pi * temperature.coords[:, 0]) + velocity.data[:, 0] = 0.1 + velocity.data[:, 1] = 0.0 + + thermal = uw.systems.AdvDiffusionSUPG( + mesh, + u_Field=temperature, + V_fn=velocity.sym, + order=2, + ) + thermal.constitutive_model = uw.constitutive_models.DiffusionModel + thermal.constitutive_model.Parameters.diffusivity = 0.05 + thermal.add_dirichlet_bc(0.0, "Left") + thermal.add_dirichlet_bc(0.0, "Right") + + for _ in range(3): + thermal.solve(timestep=0.01, zero_init_guess=False) + snapshot = model.save_state() + + model.load_state(snapshot) + for _ in range(3): + thermal.solve(timestep=0.01, zero_init_guess=False) + reference = temperature.data.copy() + + model.load_state(snapshot) + thermal.solve(timestep=0.2, zero_init_guess=False) + model.load_state(snapshot) + for _ in range(3): + thermal.solve(timestep=0.01, zero_init_guess=False) + resumed = temperature.data.copy() + + np.testing.assert_allclose(resumed, reference, rtol=2e-14, atol=2e-14) + uw.reset_default_model() + + +def test_repeated_solves_keep_histories_and_transient_state_bounded(): + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), + maxCoords=(1.0, 1.0), + cellSize=0.3, + regular=True, + ) + temperature = uw.discretisation.MeshVariable( + "T_supg_lifecycle", mesh, 1, degree=1 + ) + velocity = uw.discretisation.MeshVariable( + "U_supg_lifecycle", mesh, mesh.dim, degree=1 + ) + with mesh.access(temperature, velocity): + temperature.data[:, 0] = temperature.coords[:, 0] + velocity.data[:, 0] = 0.1 + velocity.data[:, 1] = 0.0 + + thermal = uw.systems.AdvDiffusionSUPG( + mesh, u_Field=temperature, V_fn=velocity.sym + ) + thermal.constitutive_model = uw.constitutive_models.DiffusionModel + thermal.constitutive_model.Parameters.diffusivity = 0.05 + live_swarms = len(mesh._registered_swarms) + + for _ in range(38): + thermal.solve(timestep=0.001, zero_init_guess=False) + assert len(mesh._registered_swarms) == live_swarms + + assert len(thermal.solve_history) == 32 + assert np.all(np.isfinite(temperature.data)) + + +def _spherical_citcoms_response(): + mesh = uw.meshing.SphericalShell( + radiusInner=0.55, + radiusOuter=1.0, + cellSize=0.25, + qdegree=2, + ) + temperature = uw.discretisation.MeshVariable( + "T_citcoms_spherical", mesh, 1, degree=1 + ) + velocity = uw.discretisation.MeshVariable( + "U_citcoms_spherical", mesh, mesh.dim, degree=1 + ) + with mesh.access(temperature, velocity): + coords = temperature.coords + radii = np.linalg.norm(coords, axis=1) + temperature.data[:, 0] = (1.0 - radii) / 0.45 + 0.01 * coords[:, 0] + velocity.data[:, 0] = -0.02 * coords[:, 1] + velocity.data[:, 1] = 0.02 * coords[:, 0] + velocity.data[:, 2] = 0.0 + + thermal = uw.systems.AdvDiffusionSUPG( + mesh, + u_Field=temperature, + V_fn=velocity.sym, + time_integrator="citcoms", + ) + thermal.constitutive_model = uw.constitutive_models.DiffusionModel + thermal.constitutive_model.Parameters.diffusivity = 0.01 + thermal.add_dirichlet_bc(0.0, "Upper") + thermal.add_dirichlet_bc(1.0, "Lower") + thermal.solve(timestep=1.0e-3) + + temperature_l2_squared = float( + uw.maths.Integral(mesh, fn=temperature.sym[0] ** 2).evaluate() + ) + assert thermal._lumped_mass.getSize() > 0 + assert np.all(np.isfinite(temperature.data)) + return temperature_l2_squared, mesh + + +def test_citcoms_spherical_shell_is_parallel_safe(): + _compare_spherical_with_serial(_spherical_citcoms_response, "citcoms") + + +def test_citcoms_snapshot_restores_startup_state_exactly(): + uw.reset_default_model() + model = uw.get_default_model() + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), + maxCoords=(1.0, 1.0), + cellSize=0.3, + regular=True, + ) + temperature = uw.discretisation.MeshVariable( + "T_citcoms_restart", mesh, 1, degree=1 + ) + velocity = uw.discretisation.MeshVariable( + "U_citcoms_restart", mesh, mesh.dim, degree=1 + ) + with mesh.access(temperature, velocity): + temperature.data[:, 0] = 1.0 + + thermal = uw.systems.AdvDiffusionSUPG( + mesh, + u_Field=temperature, + V_fn=velocity.sym, + time_integrator="citcoms", + tau=0.0, + ) + thermal.constitutive_model = uw.constitutive_models.DiffusionModel + thermal.constitutive_model.Parameters.diffusivity = 0.0 + thermal.f = -temperature.sym[0] + + initial = model.save_state() + thermal.solve(timestep=0.05) + reference_temperature = temperature.data.copy() + reference_rate = thermal._temperature_rate.data.copy() + + model.load_state(initial) + assert not thermal._rate_initialised + thermal.solve(timestep=0.05) + + np.testing.assert_array_equal(temperature.data, reference_temperature) + np.testing.assert_array_equal( + thermal._temperature_rate.data, reference_rate + ) + uw.reset_default_model() + + +if __name__ == "__main__": + sys.path.insert(0, str(Path(__file__).parent / "parallel")) + from serial_reference import emit, mesh_fingerprint + + run = _spherical_citcoms_response if sys.argv[1] == "citcoms" else _spherical_implicit_response + value, mesh = run() + emit([value], mesh_fingerprint(mesh)) From 4d5a660eb6ffdc1fb965097679143755d8d85172 Mon Sep 17 00:00:00 2001 From: Tyagi Date: Sat, 5 Sep 2026 15:14:39 +1000 Subject: [PATCH 17/35] fix: reject unsupported SUPG partitions collectively The Gadi eight-rank migration gate failed before any timestep because the tiny triangle fixture leaves unsupported local simplex layouts on some ranks. Propagate layout errors to every rank before entering timestep reductions. Increase the equivalence fixture resolution and add a separate empty-partition rejection regression. Preserve numerical algorithms and tolerances. Rebuilt locally; Gadi validation pending. --- docs/advanced/eulerian-advection-diffusion.md | 6 ++++++ docs/developer/CHANGELOG.md | 6 ++++++ .../systems/advection_diffusion_eulerian.py | 17 ++++++++++++----- tests/test_1116_supg_unified.py | 16 ++++++++++++++-- 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/docs/advanced/eulerian-advection-diffusion.md b/docs/advanced/eulerian-advection-diffusion.md index 446e5bcbb..41247bf63 100644 --- a/docs/advanced/eulerian-advection-diffusion.md +++ b/docs/advanced/eulerian-advection-diffusion.md @@ -175,6 +175,12 @@ different solver/history layout require migration; importing the old module name does not make those layouts equivalent. Implementation ownership is `systems/advection_diffusion_eulerian.py`. + +Automatic CitcomS simplex geometry currently requires a non-empty volume +partition on every rank. If a very small test mesh leaves ranks empty, the +solver rejects that layout collectively before mass assembly. Use fewer +ranks or a sufficiently resolved test mesh; an empty partition is not +silently interpreted as unsupported physics on only one rank. `systems/advdiff_supg.py` contains compatibility imports only. ## Further Reading diff --git a/docs/developer/CHANGELOG.md b/docs/developer/CHANGELOG.md index 99d52c12b..80f26d47d 100644 --- a/docs/developer/CHANGELOG.md +++ b/docs/developer/CHANGELOG.md @@ -23,6 +23,12 @@ its serial reference on the same host and mesh rather than comparing against a host-dependent stored value; its error threshold is unchanged. Production Gadi benchmark and memory acceptance remain separate validation gates. +The first eight-rank gate exposed an inherited empty-partition limitation +in the automatic simplex helper. Layout rejection is now collective, so +unsupported local geometry cannot leave peers waiting in reductions. The +old/new equivalence fixture has enough cells for eight ranks, and a separate +test exercises collective rejection where the partition has empty ranks. + See [the transport guide](../advanced/eulerian-advection-diffusion.md). ### A Singular Recovery Mass, Mistaken for a Penalty Defect (August 2026) diff --git a/src/underworld3/systems/advection_diffusion_eulerian.py b/src/underworld3/systems/advection_diffusion_eulerian.py index 3532d0f73..17ba6fb9f 100644 --- a/src/underworld3/systems/advection_diffusion_eulerian.py +++ b/src/underworld3/systems/advection_diffusion_eulerian.py @@ -854,7 +854,7 @@ def state(self, state): self._last_change_rate = state.last_change_rate def _simplex_data(self): - """Return local simplex connectivity, basis gradients, and volumes.""" + """Return local simplex data; validate the layout collectively on rebuild.""" from underworld3.meshing.smoothing import _tet_cells, _tri_cells mesh_version = getattr(self.mesh, "_mesh_version", 0) @@ -869,9 +869,17 @@ def _simplex_data(self): if self.mesh.dim == 2 else _tet_cells(self.mesh.dm) if self.mesh.dim == 3 else None ) - if cells is None or self.mesh.dim != self.mesh.cdim: + cell_start, cell_end = self.mesh.dm.getHeightStratum(0) + invalid = ( + (uw.mpi.rank, cell_end - cell_start, self.mesh.dim, self.mesh.cdim) + if cells is None or self.mesh.dim != self.mesh.cdim else None + ) + invalid_ranks = [item for item in uw.mpi.comm.allgather(invalid) if item is not None] + if invalid_ranks: raise NotImplementedError( - "Automatic SUPG operations require a 2-D or 3-D volume " "simplex mesh." + "Automatic CitcomS operations require a non-empty 2-D or 3-D " + "volume simplex partition on every rank. Unsupported local " + f"layouts (rank, cells, dim, cdim): {invalid_ranks}." ) coords = np.asarray(self.mesh.X.coords) @@ -1066,8 +1074,7 @@ def _estimate_citcoms_dt(self): The CitcomS-compatible predictor-corrector uses ``0.9 * min(1/max(lambda_adv), 2/max(rowsum(abs(M_L^-1 K))))``. - The same conservative value is also available for the implicit BDF - path as a resolution-accuracy estimate. + Generic implicit transport retains its separate Eulerian estimator. """ from mpi4py import MPI from underworld3.meshing.smoothing import _owned_cell_mask diff --git a/tests/test_1116_supg_unified.py b/tests/test_1116_supg_unified.py index 0988a0d14..96dbad7bc 100644 --- a/tests/test_1116_supg_unified.py +++ b/tests/test_1116_supg_unified.py @@ -13,10 +13,10 @@ pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] -def _problem(dim, tag): +def _problem(dim, tag, cellsize=0.25): mesh = uw.meshing.UnstructuredSimplexBox( minCoords=(0.0,) * dim, maxCoords=(1.0,) * dim, - cellSize=0.5, qdegree=4, regular=False, + cellSize=cellsize, qdegree=4, regular=False, ) temperature = uw.discretisation.MeshVariable(f"T_{tag}", mesh, 1, degree=1) velocity = uw.discretisation.MeshVariable(f"U_{tag}", mesh, dim, degree=1) @@ -127,3 +127,15 @@ def test_citcoms_does_not_allocate_unused_multistep_history(): thermal.estimate_dt(basis="accuracy") with pytest.raises(ValueError, match="gamma"): thermal.theta = 0.5 + + +def test_empty_partition_is_rejected_on_every_rank(): + mesh, temperature, velocity = _problem(2, "empty_partition", cellsize=0.5) + counts = uw.mpi.comm.allgather( + mesh.dm.getHeightStratum(0)[1] - mesh.dm.getHeightStratum(0)[0]) + if min(counts) > 0: + pytest.skip(f"This partition has no empty ranks: {counts}") + thermal = uw.systems.AdvDiffusionSUPG( + mesh, temperature, velocity.sym, time_integrator="citcoms") + with pytest.raises(NotImplementedError, match="on every rank"): + thermal.estimate_dt() From cbebabd92688ee70eb991bed54c259c942e4579c Mon Sep 17 00:00:00 2001 From: Tyagi Date: Sat, 5 Sep 2026 15:23:32 +1000 Subject: [PATCH 18/35] test: solve implicit replay to the asserted field accuracy Gadi preserved snapshot fields exactly but a rebuilt CN solve differed by 3.45e-10 with the default 1e-9 Krylov tolerance. Tighten only the restart-test KSP/SNES tolerances and retain the near-machine-precision replay assertion. Production solver defaults are unchanged. --- tests/test_1114_advdiff_supg.py | 4 ++++ tests/test_1116_supg_unified.py | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/tests/test_1114_advdiff_supg.py b/tests/test_1114_advdiff_supg.py index 0f8f8ab14..7a84d8624 100644 --- a/tests/test_1114_advdiff_supg.py +++ b/tests/test_1114_advdiff_supg.py @@ -317,6 +317,10 @@ def test_bdf2_snapshot_restore_leaves_no_discarded_step_trace(): V_fn=velocity.sym, order=2, ) + thermal.petsc_options["ksp_rtol"] = 1e-14 + thermal.petsc_options["ksp_atol"] = 0.0 + thermal.petsc_options["snes_rtol"] = 1e-13 + thermal.petsc_options["snes_atol"] = 1e-14 thermal.constitutive_model = uw.constitutive_models.DiffusionModel thermal.constitutive_model.Parameters.diffusivity = 0.05 thermal.add_dirichlet_bc(0.0, "Left") diff --git a/tests/test_1116_supg_unified.py b/tests/test_1116_supg_unified.py index 96dbad7bc..dc60045b3 100644 --- a/tests/test_1116_supg_unified.py +++ b/tests/test_1116_supg_unified.py @@ -83,6 +83,12 @@ def test_snapshot_restores_fields_and_timestep_estimator(settings, disk, tmp_pat orchestration_model = uw.get_default_model() mesh, temperature, velocity = _problem(2, "snapshot") thermal = uw.systems.AdvDiffusionSUPG(mesh, temperature, velocity.sym, **settings) + # Replay is compared near machine precision, independently of the default + # stopping tolerance and the preconditioner rebuilt after a discarded step. + thermal.petsc_options["ksp_rtol"] = 1e-14 + thermal.petsc_options["ksp_atol"] = 0.0 + thermal.petsc_options["snes_rtol"] = 1e-13 + thermal.petsc_options["snes_atol"] = 1e-14 thermal.constitutive_model.Parameters.diffusivity = 0.05 thermal.add_dirichlet_bc(0.0, "Left") thermal.add_dirichlet_bc(1.0, "Right") From 62082aa174dcf725073570761c93cef67232f779 Mon Sep 17 00:00:00 2001 From: Tyagi Date: Sat, 5 Sep 2026 15:28:59 +1000 Subject: [PATCH 19/35] test: derive timestep-estimator replay bound from field precision Eight-rank PC2 migration and field replay now pass. The CN rate differed by 2.04e-12 because taking a timestep derivative divides field roundoff by dt=0.003. Propagate the unchanged 2e-14 field comparison bound through max(abs(delta T))/dt rather than using an unrelated fixed rate tolerance. Snapshot restoration remains exact and production settings are unchanged. --- tests/test_1116_supg_unified.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_1116_supg_unified.py b/tests/test_1116_supg_unified.py index dc60045b3..3aaa53367 100644 --- a/tests/test_1116_supg_unified.py +++ b/tests/test_1116_supg_unified.py @@ -116,8 +116,13 @@ def test_snapshot_restores_fields_and_timestep_estimator(settings, disk, tmp_pat np.testing.assert_allclose(temperature.array, expected, rtol=2e-14, atol=2e-14) assert thermal.state.last_timestep == expected_state.last_timestep if expected_state.last_change_rate is not None: + # The estimator is max(|T_new - T_old|) / dt. Propagate the field + # assertion's absolute-plus-relative bound through that division. + field_bound = max(uw.mpi.comm.allgather( + 2e-14 * (1.0 + float(np.max(np.abs(expected), initial=0.0))))) assert thermal.state.last_change_rate == pytest.approx( - expected_state.last_change_rate, rel=5e-12, abs=1e-12) + expected_state.last_change_rate, rel=0.0, + abs=field_bound / expected_state.last_timestep) if expected_rate is not None: np.testing.assert_array_equal(thermal.temperature_rate.array, expected_rate) uw.reset_default_model() From 1e97e6a20f935f89511577d3cb6a394e1737ce59 Mon Sep 17 00:00:00 2001 From: Tyagi Date: Sat, 5 Sep 2026 16:03:17 +1000 Subject: [PATCH 20/35] test: add independent analytical gates for CitcomS PC2 transport Exercise the explicit P1 gamma=0.5 two-correction path against smooth published pulse solutions on triangles/tetrahedra, the existing rotating-Gaussian oracle, and exact radial diffusion in a spherical shell. Keep SUPG active for advection and prescribe velocity without Stokes. Bound finite-domain tails, use common fixed timesteps for spatial refinement, check absolute errors and convergence, and optionally retain compact HDF5 diagnostics for same-host serial/MPI comparison. Verify the spherical equation symbolically. Solver implementation unchanged. Syntax checks pass; numerical Gadi validation is pending. --- tests/test_1117_supg_pc2_analytical.py | 196 +++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 tests/test_1117_supg_pc2_analytical.py diff --git a/tests/test_1117_supg_pc2_analytical.py b/tests/test_1117_supg_pc2_analytical.py new file mode 100644 index 000000000..3d493e3d5 --- /dev/null +++ b/tests/test_1117_supg_pc2_analytical.py @@ -0,0 +1,196 @@ +"""Independent exact-solution gates for P1 CitcomS predictor-corrector transport. + +No Stokes solve or fine numerical reference is used. The channel pulse is the +translated, initially smoothed pulse of Calhoun & LeVeque (2000), section 6.1, +equations 45-48, with rescaled width, origin and time: +https://doi.org/10.1006/jcph.1999.6369 + +The rotation uses uw.analytic.RotatingGaussian. The spherical test follows +directly from (r*T)_t = kappa*(r*T)_rr. All spatial refinements use one fixed +timestep selected from the most restrictive mesh. Set UW_PC2_RESULTS to retain +small HDF5 metrics files; serial and MPI runs must share UW_MESH_CACHE_DIR. +""" + +import math +import os +from pathlib import Path +import time + +import numpy as np +import pytest +import sympy +from mpi4py import MPI + +import underworld3 as uw + +pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] + + +def _pulse(x, time_value, speed, diffusivity): + # Nonzero initial smoothing avoids the unresolved top-hat singularity. + width = sympy.sqrt(4.0 * (0.01 + diffusivity * time_value)) + distance = x - speed * time_value + return (sympy.erf((0.25 - distance) / width) + + sympy.erf((0.25 + distance) / width)) / 2 + + +def _problem(case, h, dim=2, speed=0.0, diffusivity=0.0): + if case == "shell": + mesh = uw.meshing.SphericalShell( + radiusInner=0.55, radiusOuter=1.0, cellSize=h, qdegree=4) + else: + lower = (-2.0, -2.0) if case == "rotation" else (-1.5,) + (-0.25,) * (dim - 1) + upper = tuple(-value for value in lower) + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=lower, maxCoords=upper, cellSize=h, + qdegree=4, regular=False) + temperature = uw.discretisation.MeshVariable("T", mesh, 1, degree=1) + velocity = uw.discretisation.MeshVariable("U", mesh, mesh.dim, degree=1) + velocity.array[...] = 0.0 + + if case == "rotation": + oracle = uw.analytic.RotatingGaussian( + mesh, sigma=0.2, centre_radius=0.5, omega=1.0, + diffusivity=diffusivity) + initial = oracle.at(0.0) + end_time = float(sympy.pi / 2) + exact = oracle.at(end_time) + velocity.array[:, 0, 0] = -velocity.coords[:, 1] + velocity.array[:, 0, 1] = velocity.coords[:, 0] + boundaries = ("Left", "Right", "Top", "Bottom") + # Max Gaussian tail on the square throughout this quarter turn. + variance = 0.2**2 + 2 * diffusivity * end_time + boundary_tail = 0.2**2 / variance * math.exp(-1.5**2 / (2 * variance)) + elif case == "shell": + radius = sympy.sqrt(sum(x**2 for x in mesh.X)) + initial = 0.55 / radius * sympy.sin(sympy.pi * (radius - 0.55) / 0.45) + end_time = 0.2 + exact = initial * sympy.exp(-diffusivity * (sympy.pi / 0.45)**2 * end_time) + boundaries = ("Lower", "Upper") + boundary_tail = 0.0 + else: + initial = _pulse(mesh.X[0], 0.0, speed, diffusivity) + end_time = 0.2 + exact = _pulse(mesh.X[0], end_time, speed, diffusivity) + velocity.array[:, 0, 0] = speed + # Zero transverse diffusive flux is exact. End-wall tails are bounded + # analytically, not silently treated as exactly zero. + boundaries = ("Left", "Right") + boundary_tail = math.erfc( + (1.5 - 0.25 - abs(speed) * end_time) + / math.sqrt(4 * (0.01 + diffusivity * end_time))) + assert boundary_tail < 1e-10, boundary_tail + temperature.array[:, 0, 0] = uw.function.evaluate( + initial, temperature.coords).reshape(-1) + thermal = uw.systems.AdvDiffusionSUPG( + mesh, temperature, velocity.sym, time_integrator="citcoms", + adv_gamma=0.5, corrector_steps=2) + thermal.constitutive_model.Parameters.diffusivity = diffusivity + for boundary in boundaries: + thermal.add_dirichlet_bc(0.0, boundary) + return mesh, temperature, thermal, initial, exact, end_time, boundary_tail + + +def _integral(mesh, expression): + return float(uw.maths.Integral(mesh, fn=expression).evaluate()) + + +def _save_result(name, metrics): + """Optional rank-zero output, with write failures propagated collectively.""" + error = None + if uw.mpi.rank == 0: + print("PC2_ANALYTICAL " + name + " " + " ".join( + f"{key}={value:.12g}" for key, value in metrics.items()), flush=True) + directory = os.environ.get("UW_PC2_RESULTS") + if directory: + try: + import h5py + + target = Path(directory) / f"ncpus_{uw.mpi.size}" + target.mkdir(parents=True, exist_ok=True) + with h5py.File(target / f"{name}.h5", "w") as output: + output.attrs["method"] = "citcoms_pc2" + for key, value in metrics.items(): + output[key] = value + except Exception as exc: + error = f"Cannot write PC2 analytical metrics: {exc}" + error = uw.mpi.comm.bcast(error, root=0) + assert error is None, error + + +def _spatial_refinement(case, sizes, dim=2, speed=0.0, diffusivity=0.0): + problems = [_problem(case, h, dim, speed, diffusivity) for h in sizes] + dt_limit = min(float(problem[2].estimate_dt()) for problem in problems) + end_time = problems[0][5] + # Round down before choosing an integer number of steps, avoiding a + # partition-dependent ceil when a stability estimate differs by roundoff. + dt_cap = 2.0**math.floor(math.log2(min(0.005, 0.5 * dt_limit))) + steps = math.ceil(end_time / dt_cap) + timestep = end_time / steps + errors = [] + for h, (mesh, temperature, thermal, initial, exact, _, tail) in zip(sizes, problems): + norm_squared = _integral(mesh, exact**2) + initial_norm_squared = _integral(mesh, initial**2) + interpolation_error = math.sqrt(_integral( + mesh, (temperature.sym[0] - initial)**2) / initial_norm_squared) + start = time.perf_counter() + for _ in range(steps): + thermal.solve(timestep=timestep) + solve_seconds = uw.mpi.comm.allreduce(time.perf_counter() - start, op=MPI.MAX) + error = math.sqrt(_integral(mesh, (temperature.sym[0] - exact)**2) / norm_squared) + heat = _integral(mesh, temperature.sym[0]) + exact_heat = _integral(mesh, exact) + nodal = temperature.array[:, 0, 0] + minimum = uw.mpi.comm.allreduce(float(np.min(nodal, initial=np.inf)), op=MPI.MIN) + maximum = uw.mpi.comm.allreduce(float(np.max(nodal, initial=-np.inf)), op=MPI.MAX) + metrics = dict( + cellsize=h, dim=mesh.dim, ncpus=uw.mpi.size, timestep=timestep, + steps=steps, end_time=end_time, relative_l2=error, + initial_relative_l2=interpolation_error, + temperature_integral=heat, exact_temperature_integral=exact_heat, + relative_heat_error=(heat - exact_heat) / exact_heat, + minimum=minimum, maximum=maximum, solve_seconds=solve_seconds, + boundary_tail_bound=tail, volume=_integral(mesh, sympy.Integer(1))) + if case == "rotation": + centre = [_integral(mesh, coordinate * temperature.sym[0]) / heat + for coordinate in mesh.X] + metrics["phase_error_radians"] = math.atan2(centre[1], centre[0]) - end_time + if speed != 0 or case == "rotation": + tau = uw.function.evaluate(thermal.tau, mesh._centroids) + assert uw.mpi.comm.allreduce(bool(np.any(tau > 0)), op=MPI.LOR) + name = f"{case}_{mesh.dim}d_u{speed:g}_k{diffusivity:g}_h{h:g}" + _save_result(name, metrics) + assert np.isfinite(error) and minimum > -0.05 and maximum < 1.05, metrics + errors.append(error) + return errors + + +@pytest.mark.parametrize("dim", [2, 3]) +@pytest.mark.parametrize("speed,diffusivity", [(1.0, 0.0), (0.0, 0.01), (1.0, 0.01)]) +def test_pc2_exact_channel_pulse(dim, speed, diffusivity): + errors = _spatial_refinement("pulse", (0.125, 0.0625), dim, speed, diffusivity) + assert errors[1] < 0.7 * errors[0], errors + assert errors[1] < 0.05, errors + + +def test_pc2_exact_rotating_gaussian(): + errors = _spatial_refinement("rotation", (0.125, 0.0625)) + assert errors[1] < 0.7 * errors[0], errors + assert errors[1] < 0.10, errors + + +def test_pc2_exact_spherical_diffusion(): + errors = _spatial_refinement("shell", (0.25, 0.125), dim=3, diffusivity=0.02) + assert errors[1] < 0.7 * errors[0], errors + assert errors[1] < 0.08, errors + + +def test_exact_spherical_diffusion_satisfies_radial_heat_equation(): + r, t, ri, thickness, kappa = sympy.symbols("r t ri d kappa", positive=True) + exact = ri / r * sympy.sin(sympy.pi * (r - ri) / thickness) * sympy.exp( + -kappa * (sympy.pi / thickness)**2 * t) + residual = sympy.diff(exact, t) - kappa * ( + sympy.diff(exact, r, 2) + 2 / r * sympy.diff(exact, r)) + assert sympy.simplify(residual) == 0 + assert sympy.simplify(exact.subs(r, ri)) == 0 + assert sympy.simplify(exact.subs(r, ri + thickness)) == 0 From 4ffdfa91cbb20bf5f93018942de0b84890255ca0 Mon Sep 17 00:00:00 2001 From: Tyagi Date: Sat, 5 Sep 2026 17:14:19 +1000 Subject: [PATCH 21/35] test: separate spherical PC2 spatial and temporal accuracy Refine the spherical diffusion pair from 1/4-1/8 to 1/8-1/16 without relaxing the 8 percent absolute L2 criterion. Add fixed-1/8 dt, dt/2 and dt/4 trajectories, restoring identical T/Tdot/startup state and comparing full FE fields. Record input Gmsh SHA256 and short mesh filenames for strict same-mesh serial/MPI comparison. Reuse a focused advance/measurement helper instead of duplicating the diagnostic code. Solver implementation unchanged. Syntax checks pass; bounded Gadi numerical follow-up pending. --- tests/test_1117_supg_pc2_analytical.py | 127 ++++++++++++++++++------- 1 file changed, 91 insertions(+), 36 deletions(-) diff --git a/tests/test_1117_supg_pc2_analytical.py b/tests/test_1117_supg_pc2_analytical.py index 3d493e3d5..71effc7fc 100644 --- a/tests/test_1117_supg_pc2_analytical.py +++ b/tests/test_1117_supg_pc2_analytical.py @@ -8,9 +8,11 @@ The rotation uses uw.analytic.RotatingGaussian. The spherical test follows directly from (r*T)_t = kappa*(r*T)_rr. All spatial refinements use one fixed timestep selected from the most restrictive mesh. Set UW_PC2_RESULTS to retain -small HDF5 metrics files; serial and MPI runs must share UW_MESH_CACHE_DIR. +small HDF5 metrics files. Separate jobs use separate UW_MESH_CACHE_DIR paths; +Gmsh SHA256 fingerprints must match before comparing their numerical results. """ +import hashlib import math import os from pathlib import Path @@ -22,6 +24,7 @@ from mpi4py import MPI import underworld3 as uw +from underworld3.meshing._mesh_files import mesh_file_path pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] @@ -35,15 +38,24 @@ def _pulse(x, time_value, speed, diffusivity): def _problem(case, h, dim=2, speed=0.0, diffusivity=0.0): + filename = mesh_file_path(f"pc2_{case}_{dim}d_h{h:g}.msh") if case == "shell": mesh = uw.meshing.SphericalShell( - radiusInner=0.55, radiusOuter=1.0, cellSize=h, qdegree=4) + radiusInner=0.55, radiusOuter=1.0, cellSize=h, qdegree=4, filename=filename) else: lower = (-2.0, -2.0) if case == "rotation" else (-1.5,) + (-0.25,) * (dim - 1) upper = tuple(-value for value in lower) mesh = uw.meshing.UnstructuredSimplexBox( minCoords=lower, maxCoords=upper, cellSize=h, - qdegree=4, regular=False) + qdegree=4, regular=False, filename=filename) + fingerprint = None + if uw.mpi.rank == 0: + try: + fingerprint = (None, hashlib.sha256(Path(filename).read_bytes()).hexdigest()) + except OSError as exc: + fingerprint = (str(exc), None) + failure, mesh_sha256 = uw.mpi.comm.bcast(fingerprint, root=0) + assert failure is None, failure temperature = uw.discretisation.MeshVariable("T", mesh, 1, degree=1) velocity = uw.discretisation.MeshVariable("U", mesh, mesh.dim, degree=1) velocity.array[...] = 0.0 @@ -88,7 +100,7 @@ def _problem(case, h, dim=2, speed=0.0, diffusivity=0.0): thermal.constitutive_model.Parameters.diffusivity = diffusivity for boundary in boundaries: thermal.add_dirichlet_bc(0.0, boundary) - return mesh, temperature, thermal, initial, exact, end_time, boundary_tail + return mesh, temperature, thermal, initial, exact, end_time, boundary_tail, mesh_sha256 def _integral(mesh, expression): @@ -100,7 +112,8 @@ def _save_result(name, metrics): error = None if uw.mpi.rank == 0: print("PC2_ANALYTICAL " + name + " " + " ".join( - f"{key}={value:.12g}" for key, value in metrics.items()), flush=True) + f"{key}={value}" if isinstance(value, str) else f"{key}={value:.12g}" + for key, value in metrics.items()), flush=True) directory = os.environ.get("UW_PC2_RESULTS") if directory: try: @@ -118,50 +131,61 @@ def _save_result(name, metrics): assert error is None, error -def _spatial_refinement(case, sizes, dim=2, speed=0.0, diffusivity=0.0): - problems = [_problem(case, h, dim, speed, diffusivity) for h in sizes] +def _step_count(problems): dt_limit = min(float(problem[2].estimate_dt()) for problem in problems) end_time = problems[0][5] # Round down before choosing an integer number of steps, avoiding a # partition-dependent ceil when a stability estimate differs by roundoff. dt_cap = 2.0**math.floor(math.log2(min(0.005, 0.5 * dt_limit))) - steps = math.ceil(end_time / dt_cap) + return math.ceil(end_time / dt_cap) + + +def _advance(problem, h, steps): + mesh, temperature, thermal, initial, exact, end_time, tail, mesh_sha256 = problem timestep = end_time / steps + norm_squared = _integral(mesh, exact**2) + interpolation_error = math.sqrt(_integral( + mesh, (temperature.sym[0] - initial)**2) / _integral(mesh, initial**2)) + start = time.perf_counter() + for _ in range(steps): + thermal.solve(timestep=timestep) + solve_seconds = uw.mpi.comm.allreduce(time.perf_counter() - start, op=MPI.MAX) + error = math.sqrt(_integral(mesh, (temperature.sym[0] - exact)**2) / norm_squared) + heat = _integral(mesh, temperature.sym[0]) + exact_heat = _integral(mesh, exact) + nodal = temperature.array[:, 0, 0] + minimum = uw.mpi.comm.allreduce(float(np.min(nodal, initial=np.inf)), op=MPI.MIN) + maximum = uw.mpi.comm.allreduce(float(np.max(nodal, initial=-np.inf)), op=MPI.MAX) + return dict( + cellsize=h, dim=mesh.dim, ncpus=uw.mpi.size, timestep=timestep, + steps=steps, end_time=end_time, relative_l2=error, + initial_relative_l2=interpolation_error, + temperature_integral=heat, exact_temperature_integral=exact_heat, + relative_heat_error=(heat - exact_heat) / exact_heat, + minimum=minimum, maximum=maximum, solve_seconds=solve_seconds, + boundary_tail_bound=tail, volume=_integral(mesh, sympy.Integer(1)), + mesh_sha256=mesh_sha256) + + +def _spatial_refinement(case, sizes, dim=2, speed=0.0, diffusivity=0.0): + problems = [_problem(case, h, dim, speed, diffusivity) for h in sizes] + steps = _step_count(problems) errors = [] - for h, (mesh, temperature, thermal, initial, exact, _, tail) in zip(sizes, problems): - norm_squared = _integral(mesh, exact**2) - initial_norm_squared = _integral(mesh, initial**2) - interpolation_error = math.sqrt(_integral( - mesh, (temperature.sym[0] - initial)**2) / initial_norm_squared) - start = time.perf_counter() - for _ in range(steps): - thermal.solve(timestep=timestep) - solve_seconds = uw.mpi.comm.allreduce(time.perf_counter() - start, op=MPI.MAX) - error = math.sqrt(_integral(mesh, (temperature.sym[0] - exact)**2) / norm_squared) - heat = _integral(mesh, temperature.sym[0]) - exact_heat = _integral(mesh, exact) - nodal = temperature.array[:, 0, 0] - minimum = uw.mpi.comm.allreduce(float(np.min(nodal, initial=np.inf)), op=MPI.MIN) - maximum = uw.mpi.comm.allreduce(float(np.max(nodal, initial=-np.inf)), op=MPI.MAX) - metrics = dict( - cellsize=h, dim=mesh.dim, ncpus=uw.mpi.size, timestep=timestep, - steps=steps, end_time=end_time, relative_l2=error, - initial_relative_l2=interpolation_error, - temperature_integral=heat, exact_temperature_integral=exact_heat, - relative_heat_error=(heat - exact_heat) / exact_heat, - minimum=minimum, maximum=maximum, solve_seconds=solve_seconds, - boundary_tail_bound=tail, volume=_integral(mesh, sympy.Integer(1))) + for h, problem in zip(sizes, problems): + mesh, temperature, thermal = problem[:3] + metrics = _advance(problem, h, steps) if case == "rotation": - centre = [_integral(mesh, coordinate * temperature.sym[0]) / heat + centre = [_integral(mesh, coordinate * temperature.sym[0]) / metrics["temperature_integral"] for coordinate in mesh.X] - metrics["phase_error_radians"] = math.atan2(centre[1], centre[0]) - end_time + metrics["phase_error_radians"] = math.atan2(centre[1], centre[0]) - metrics["end_time"] if speed != 0 or case == "rotation": tau = uw.function.evaluate(thermal.tau, mesh._centroids) assert uw.mpi.comm.allreduce(bool(np.any(tau > 0)), op=MPI.LOR) name = f"{case}_{mesh.dim}d_u{speed:g}_k{diffusivity:g}_h{h:g}" _save_result(name, metrics) - assert np.isfinite(error) and minimum > -0.05 and maximum < 1.05, metrics - errors.append(error) + assert (np.isfinite(metrics["relative_l2"]) + and metrics["minimum"] > -0.05 and metrics["maximum"] < 1.05), metrics + errors.append(metrics["relative_l2"]) return errors @@ -180,11 +204,42 @@ def test_pc2_exact_rotating_gaussian(): def test_pc2_exact_spherical_diffusion(): - errors = _spatial_refinement("shell", (0.25, 0.125), dim=3, diffusivity=0.02) + errors = _spatial_refinement("shell", (0.125, 0.0625), dim=3, diffusivity=0.02) assert errors[1] < 0.7 * errors[0], errors assert errors[1] < 0.08, errors +def test_pc2_spherical_diffusion_timestep_sensitivity(): + """Separate finite-step changes from the continuum spatial error at h=1/8.""" + problem = _problem("shell", 0.125, dim=3, diffusivity=0.02) + mesh, temperature, thermal = problem[:3] + difference = uw.discretisation.MeshVariable("T_difference", mesh, 1, degree=1) + initial_values = np.array(temperature.array) + initial_state = thermal.state + base_steps = _step_count([problem]) + metrics, solutions = [], [] + for factor in (1, 2, 4): + temperature.array[...] = initial_values + thermal.temperature_rate.array[...] = 0.0 + thermal.state = initial_state + metrics.append(_advance(problem, 0.125, base_steps * factor)) + solutions.append(np.array(temperature.array)) + norm_squared = _integral(mesh, problem[4]**2) + changes = [] + for index in (0, 1): + difference.array[...] = solutions[index] - solutions[index + 1] + changes.append(math.sqrt(_integral(mesh, difference.sym[0]**2) / norm_squared)) + metrics[index]["relative_difference_to_next_dt"] = changes[-1] + if min(changes) > 0: + metrics[-1]["observed_time_order"] = math.log2(changes[0] / changes[1]) + for factor, result in zip((1, 2, 4), metrics): + _save_result(f"shell_time_h0.125_dtdiv{factor}", result) + assert all(np.isfinite(item["relative_l2"]) for item in metrics), metrics + assert all(item["minimum"] > -0.05 and item["maximum"] < 1.05 for item in metrics), metrics + assert changes[1] < changes[0], changes + assert changes[1] < 0.05 * metrics[-1]["relative_l2"], (changes, metrics) + + def test_exact_spherical_diffusion_satisfies_radial_heat_equation(): r, t, ri, thickness, kappa = sympy.symbols("r t ri d kappa", positive=True) exact = ri / r * sympy.sin(sympy.pi * (r - ri) / thickness) * sympy.exp( From f7949c92dc609672c6f01a7a5296008d4013f509 Mon Sep 17 00:00:00 2001 From: Tyagi Date: Sat, 5 Sep 2026 23:17:52 +1000 Subject: [PATCH 22/35] test: allow explicit cellsize in SUPG partition diagnostic helper Keep test 1077's default cellsize 1/8, numerical setup, 5% analytical bound and 1e-8 serial/MPI comparison unchanged. Expose only the internal helper's mesh size for the requested eight-rank 1/4, 1/8 and 1/16 Mac investigation after an empty-partition mesh. All three resolutions completed after mesh fix 7e17bec3. Same-host serial/MPI errors agreed within 8.47e-9 at 1/8 and 2.46e-9 at 1/16. Preserve and document the independent 1/4 discrepancy of 7.00e-7; it persists with tighter solves on identical mesh hashes. Do not relax the original gate. --- tests/parallel/test_1077_advdiff_supg_parallel.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/parallel/test_1077_advdiff_supg_parallel.py b/tests/parallel/test_1077_advdiff_supg_parallel.py index 8d0c9d27a..56aa7a973 100644 --- a/tests/parallel/test_1077_advdiff_supg_parallel.py +++ b/tests/parallel/test_1077_advdiff_supg_parallel.py @@ -16,9 +16,10 @@ pytestmark = [pytest.mark.level_1, pytest.mark.tier_a, pytest.mark.mpi] -def _run(): +def _run(cellsize=1.0 / 8): + """Shared solve for the fixed 1/8 gate and explicit resolution diagnostics.""" mesh = uw.meshing.UnstructuredSimplexBox( - minCoords=(-1.0, -1.0), maxCoords=(1.0, 1.0), cellSize=1.0 / 8, + minCoords=(-1.0, -1.0), maxCoords=(1.0, 1.0), cellSize=cellsize, qdegree=3, regular=False) x, y = mesh.X sol = uw.analytic.RotatingGaussian(mesh, sigma=0.12, centre_radius=0.5, omega=1.0) From cbc9901111d6c36700456ab4b73ec304a6800ec3 Mon Sep 17 00:00:00 2001 From: Tyagi Date: Sat, 5 Sep 2026 23:56:43 +1000 Subject: [PATCH 23/35] test: isolate finite-correction PC2 diffusion time accuracy Add tiny 2D triangle and 3D tetrahedron tests with independent analytical P1 element matrices and exact semidiscrete eigenmode references. Compare every UW3 T/Tdot update against a closed-form two-correction map without using a fine numerical solution, Stokes, or A1. Demonstrate that consistent residual mass with lumped correction mass and two fixed corrections approaches (2I-D^-1 M)D^-1 K, with first-order timestep differences. Separate the startup-rate error using exactly solved CN and genuinely lumped-residual controls, which establish why uniform scalar decay is insufficient. Document the limitation without changing CitcomS semantics, solver defaults, or prior tolerances. Four tests pass on Mac serial (17.46 s) and eight MPI ranks (32.91 s); matching mesh hashes and update discrepancies below 4e-14. Style gate and whitespace checks pass. --- docs/advanced/eulerian-advection-diffusion.md | 26 +++ tests/test_1118_pc2_diffusion_time.py | 179 ++++++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 tests/test_1118_pc2_diffusion_time.py diff --git a/docs/advanced/eulerian-advection-diffusion.md b/docs/advanced/eulerian-advection-diffusion.md index 41247bf63..86ee5dca7 100644 --- a/docs/advanced/eulerian-advection-diffusion.md +++ b/docs/advanced/eulerian-advection-diffusion.md @@ -140,6 +140,32 @@ then applies `delta_rate=-M_L^-1*F` to the rate and two corrections. Boundary values are reinserted at each correction. Automatic geometry is restricted to 2-D triangles and 3-D tetrahedra. +### Finite-Correction Accuracy + +The correction mass is lumped, but the time-derivative term in the residual +uses the consistent finite-element mass. Therefore `adv_gamma=0.5` and two +corrections do **not** guarantee second-order time convergence for a +nonuniform temperature field at fixed mesh. In pure diffusion, with +consistent mass $M$, stiffness $K$, and $D=\operatorname{diag}(M\mathbf{1})$, +two corrections approach the operator +$(2I-D^{-1}M)D^{-1}K$ as the timestep vanishes. This generally differs from +both $M^{-1}K$ and $D^{-1}K$. The startup rate $-D^{-1}KT$ is also only an +approximation to the consistent semidiscrete rate $-M^{-1}KT$. + +`tests/test_1118_pc2_diffusion_time.py` isolates these effects on tiny +triangular/tetrahedral meshes using independently integrated element +matrices and exact discrete eigenmode/matrix-exponential solutions. It +reproduces first-order timestep differences in serial and MPI. Uniform +scalar decay is a special case where consistent and lumped mass agree; +second order in that test does not establish PDE time accuracy. + +The CitcomS-compatible mode retains its fixed-correction semantics. Do not +silently replace its residual mass or increase the iteration count and +still claim an unchanged paper-reproduction method. An accurately solved +implicit Crank-Nicolson update with consistent initialization provides a +separate second-order reference. Production-scale validation is separate +from these small mathematical tests. + Its steady tau is `h/(2*speed) * max(0, 1-1/Pe)`, with `Pe=speed*h/(2*kappa)` and directional simplex `h=2*speed/sum_a(abs(u.grad(N_a)))`. Zero velocity gives zero tau; diff --git a/tests/test_1118_pc2_diffusion_time.py b/tests/test_1118_pc2_diffusion_time.py new file mode 100644 index 000000000..30fb5716f --- /dev/null +++ b/tests/test_1118_pc2_diffusion_time.py @@ -0,0 +1,179 @@ +"""Isolate finite-correction PC2 time error without Stokes or a fine mesh. + +Analytical P1 element integrals independently supply M, K and D=diag(M*1). +The exact semidiscrete solution is a generalized eigenmode exp(-lambda*t). +Small dense SciPy matrices are an independent test oracle, not solver code. + +With consistent M in the residual and two D-preconditioned corrections, +the dt->0 operator is (2I-D^-1 M)D^-1 K, not M^-1 K or D^-1 K. This test +documents that limitation; it does NOT certify second-order PDE accuracy. +""" + +import hashlib +import math + +import numpy as np +import pytest +from scipy.linalg import eigh, expm + +import underworld3 as uw +from underworld3.meshing.smoothing import _owned_cell_mask, _tet_cells, _tri_cells + +pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] + + +def _p1_matrices(mesh): + """Integrate affine basis functions independently of the solver assembly.""" + cells = (_tri_cells if mesh.dim == 2 else _tet_cells)(mesh.dm) + local_cells = np.asarray(mesh.X.coords)[cells[_owned_cell_mask(mesh.dm)]] + vertices = np.concatenate(uw.mpi.comm.allgather(local_cells)) + coords = np.unique(vertices.reshape(-1, mesh.dim).round(12), axis=0) + indices = {tuple(point): index for index, point in enumerate(coords)} + connectivity = np.array([ + [indices[tuple(point.round(12))] for point in cell] for cell in vertices + ]) + canonical = np.sort(connectivity, axis=1) + canonical = canonical[np.lexsort(canonical.T[::-1])] + fingerprint = hashlib.sha256(coords.tobytes() + canonical.tobytes()).hexdigest() + mass = np.zeros((len(coords), len(coords))) + stiffness = np.zeros_like(mass) + for ids, cell in zip(connectivity, vertices): + affine = np.column_stack([np.ones(mesh.dim + 1), cell]) + gradients = np.linalg.inv(affine)[1:, :].T + volume = abs(np.linalg.det(affine)) / math.factorial(mesh.dim) + mass[np.ix_(ids, ids)] += volume * ( + np.ones((mesh.dim + 1, mesh.dim + 1)) + np.eye(mesh.dim + 1) + ) / ((mesh.dim + 1) * (mesh.dim + 2)) + stiffness[np.ix_(ids, ids)] += 0.1 * volume * gradients @ gradients.T + np.testing.assert_allclose(mass.sum(), 1.0, atol=1e-12) + np.testing.assert_allclose(stiffness.sum(axis=1), 0.0, atol=1e-12) + return coords, mass, stiffness, len(vertices), fingerprint + + +def _norm(values, mass): + return float(np.sqrt(values @ mass @ values)) + + +def _orders(values): + return np.log2(np.asarray(values[:-1]) / values[1:]) + + +@pytest.fixture(params=[2, 3]) +def diffusion(request): + dim = request.param + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0,) * dim, maxCoords=(1.0,) * dim, + cellSize=0.25, qdegree=4, regular=False, + ) + temperature = uw.discretisation.MeshVariable("T", mesh, 1, degree=1) + velocity = uw.discretisation.MeshVariable("U", mesh, dim, degree=1) + velocity.array[...] = 0.0 + thermal = uw.systems.AdvDiffusionSUPG( + mesh, temperature, velocity.sym, time_integrator="citcoms", + ) + thermal.constitutive_model.Parameters.diffusivity = 0.1 + # Natural homogeneous Neumann boundaries remove constrained-DOF effects. + coords, mass, stiffness, cell_count, fingerprint = _p1_matrices(mesh) + indices = {tuple(point): index for index, point in enumerate(coords)} + local_ids = np.array([ + indices[tuple(point.round(12))] for point in np.asarray(temperature.coords) + ]) + eigenvalues, eigenvectors = eigh(stiffness, mass) + np.testing.assert_allclose(eigenvalues[0], 0.0, atol=1e-12) + initial = eigenvectors[:, 1].copy() + initial *= np.sign(initial[np.argmax(np.abs(initial))]) + initial /= np.max(np.abs(initial)) + uw.pprint( + f"PC2_ORACLE dim={dim} cells={cell_count} vertices={len(coords)} " + f"mesh_sha256={fingerprint} lambda={eigenvalues[1]:.12g}") + return thermal, temperature, local_ids, mass, stiffness, initial, eigenvalues, eigenvectors + + +def test_two_corrections_match_independent_diffusion_map(diffusion): + thermal, temperature, ids, mass, stiffness, initial, eigenvalues, _ = diffusion + lumped = mass.sum(axis=1) + H = mass / lumped[:, None] + J = stiffness / lumped[:, None] + identity = np.eye(len(initial)) + final_time = 0.1 + exact = initial * np.exp(-eigenvalues[1] * final_time) + effective = expm(-final_time * (2 * identity - H) @ J) @ initial + initial_state = thermal.state + dt_limit = thermal.estimate_dt() + solutions, effective_errors = [], [] + for steps in (16, 32, 64, 128): + dt = final_time / steps + assert dt < dt_limit + temperature.array[:, 0, 0] = initial[ids] + thermal.temperature_rate.array[...] = 0.0 + thermal.state = initial_state + expected = initial.copy() + rate = -J @ initial + # Algebraically eliminate both corrections; do not call UW3 residuals. + B = (2 * identity - H - 0.5 * dt * J) @ J + discrepancy = np.zeros(2) + for _ in range(steps): + predictor = expected + 0.5 * dt * rate + rate = -B @ predictor + expected = predictor + 0.5 * dt * rate + thermal.solve(timestep=dt) + discrepancy = np.maximum(discrepancy, [ + np.max(np.abs(temperature.array[:, 0, 0] - expected[ids])), + np.max(np.abs(thermal.temperature_rate.array[:, 0, 0] - rate[ids])), + ]) + discrepancy = np.max(uw.mpi.comm.allgather(discrepancy), axis=0) + assert discrepancy[0] < 1e-11 and discrepancy[1] < 1e-10, discrepancy + actual = np.zeros(len(initial)) + for local_ids, values in uw.mpi.comm.allgather( + (ids, np.array(temperature.array[:, 0, 0]))): + actual[local_ids] = values + solutions.append(actual) + effective_error = _norm(actual - effective, mass) / _norm(effective, mass) + effective_errors.append(effective_error) + uw.pprint( + f"PC2_DIFFUSION dim={thermal.mesh.dim} steps={steps} dt={dt:.12g} " + f"consistent_error={_norm(actual-exact, mass)/_norm(exact, mass):.12g} " + f"effective_error={effective_error:.12g} " + f"T_map_error={discrepancy[0]:.12g} Tdot_map_error={discrepancy[1]:.12g}") + changes = [_norm(a - b, mass) for a, b in zip(solutions, solutions[1:])] + rates = _orders(changes) + # Detect the known finite-correction limit, not a general accuracy guarantee. + assert np.all((0.9 < rates) & (rates < 1.15)), rates + assert np.all((0.9 < _orders(effective_errors)) & (_orders(effective_errors) < 1.15)) + uw.pprint(f"PC2_TIME_ORDER dim={thermal.mesh.dim} rates={rates.tolist()}") + + +def test_exact_matrix_controls_separate_mass_and_startup(diffusion): + thermal, _, _, mass, stiffness, initial, eigenvalues, eigenvectors = diffusion + D = mass.sum(axis=1) + J = stiffness / D[:, None] + final_time = 0.1 + consistent_exact = initial * np.exp(-eigenvalues[1] * final_time) + lumped_exact = expm(-final_time * J) @ initial + errors = {"consistent_cn": [], "cn_lumped_startup": [], "lumped_pc2": []} + for steps in (16, 32, 64, 128): + dt = final_time / steps + factors = (1 - 0.5 * dt * eigenvalues) / (1 + 0.5 * dt * eigenvalues) + consistent = initial * factors[1]**steps + errors["consistent_cn"].append(_norm(consistent - consistent_exact, mass)) + # Exactly converged corrections cannot repair an inconsistent first rate. + predictor = initial - 0.5 * dt * J @ initial + amplitudes = (eigenvectors.T @ mass @ predictor) / (1 + 0.5 * dt * eigenvalues) + bad_start = eigenvectors @ (factors**(steps - 1) * amplitudes) + errors["cn_lumped_startup"].append(_norm(bad_start - consistent_exact, mass)) + # A genuinely lumped residual has H=I; this is a diagnostic alternative, + # not a replacement of the CitcomS mode installed in UW3. + values, rate = initial.copy(), -J @ initial + B = (np.eye(len(initial)) - 0.5 * dt * J) @ J + for _ in range(steps): + predictor = values + 0.5 * dt * rate + rate = -B @ predictor + values = predictor + 0.5 * dt * rate + errors["lumped_pc2"].append(_norm(values - lumped_exact, mass)) + for name, values in errors.items(): + rates = _orders(values) + uw.pprint(f"PC2_CONTROL dim={thermal.mesh.dim} name={name} errors={values} rates={rates.tolist()}") + if name == "cn_lumped_startup": + assert np.all((0.9 < rates) & (rates < 1.15)), rates + else: + assert np.all((1.9 < rates) & (rates < 2.2)), rates From 5ab1e695d9b8be0ff23526b7c630c390def4c157 Mon Sep 17 00:00:00 2001 From: Tyagi Date: Sun, 6 Sep 2026 00:21:36 +1000 Subject: [PATCH 24/35] test: verify UW3 CN against exact discrete diffusion modes Exercise the actual shared Eulerian CN solver on the same tiny triangle/tetrahedron meshes and timestep sequence as the PC2 diagnosis. Check the exact CN amplification map and second-order error against independently assembled generalized eigenmodes. Two tests pass in serial (26.61 s) and on eight MPI ranks (13.69 s), with order 2.00 in both dimensions. Finest relative errors are approximately 5.4e-9 and 5.7e-9. No Stokes/A1 run, numerical-method changes, or relaxed error tolerances. --- tests/test_1118_pc2_diffusion_time.py | 37 +++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/test_1118_pc2_diffusion_time.py b/tests/test_1118_pc2_diffusion_time.py index 30fb5716f..b5e301043 100644 --- a/tests/test_1118_pc2_diffusion_time.py +++ b/tests/test_1118_pc2_diffusion_time.py @@ -177,3 +177,40 @@ def test_exact_matrix_controls_separate_mass_and_startup(diffusion): assert np.all((0.9 < rates) & (rates < 1.15)), rates else: assert np.all((1.9 < rates) & (rates < 2.2)), rates + + +def test_uw3_cn_is_second_order_for_discrete_diffusion(diffusion): + pc2, _, ids, mass, _, initial, eigenvalues, _ = diffusion + exact = initial * np.exp(-0.1 * eigenvalues[1]) + errors = [] + for steps in (16, 32, 64, 128): + temperature = uw.discretisation.MeshVariable( + f"T_cn_{steps}", pc2.mesh, 1, degree=1) + temperature.array[:, 0, 0] = initial[ids] + thermal = uw.systems.AdvDiffusionSUPG( + pc2.mesh, temperature, pc2.V_fn, order=1, theta=0.5) + thermal.constitutive_model.Parameters.diffusivity = 0.1 + # Time error on the finest dt is about 1e-8; solver error must be smaller. + thermal.petsc_options["ksp_rtol"] = 1e-14 + thermal.petsc_options["ksp_atol"] = 0.0 + thermal.petsc_options["snes_rtol"] = 1e-13 + thermal.petsc_options["snes_atol"] = 1e-14 + dt = 0.1 / steps + for _ in range(steps): + thermal.solve(timestep=dt) + factor = (1 - 0.5 * dt * eigenvalues[1]) / (1 + 0.5 * dt * eigenvalues[1]) + expected = initial * factor**steps + discrepancy = max(uw.mpi.comm.allgather(float(np.max( + np.abs(temperature.array[:, 0, 0] - expected[ids]))))) + assert discrepancy < 1e-10, discrepancy + actual = np.zeros(len(initial)) + for local_ids, values in uw.mpi.comm.allgather( + (ids, np.array(temperature.array[:, 0, 0]))): + actual[local_ids] = values + errors.append(_norm(actual - exact, mass) / _norm(exact, mass)) + uw.pprint( + f"UW3_CN_DIFFUSION dim={pc2.mesh.dim} steps={steps} dt={dt:.12g} " + f"relative_error={errors[-1]:.12g} map_error={discrepancy:.12g}") + rates = _orders(errors) + assert np.all((1.9 < rates) & (rates < 2.2)), rates + uw.pprint(f"UW3_CN_TIME_ORDER dim={pc2.mesh.dim} rates={rates.tolist()}") From e4a28c743b84a9a0962d05f4f460ca73b17d5d87 Mon Sep 17 00:00:00 2001 From: Tyagi Date: Sun, 6 Sep 2026 00:28:51 +1000 Subject: [PATCH 25/35] fix: register generic SUPG geometry before fresh-process restore A tiny three-process restart regression exposed a missing _h_cell field: implicit SUPG previously created its generic-tau geometry field only during residual construction, while snapshots require the same fields registered before loading. Materialize this existing dependency at solver construction, only for automatic generic tau; do not relax snapshot schema checks or change the time integration. Add independent full/write/resume worker tests for PC2, CN and BDF2 on a 370-tetrahedron mesh with varying timesteps and velocity. Require exact restored fields/state, then compare step-12 continuation with uninterrupted runs. The CN case demonstrably failed before the four-line constructor fix. After rebuild: serial 3 passed in 48.95 s; eight-rank workers 3 passed in 58.98 s. PC2 continuation is exact; CN/BDF2 fields agree within 8e-16. Update the user guide with the fresh-interpreter workflow. No Stokes/A1 run or checkpoint tolerance relaxation. --- docs/advanced/eulerian-advection-diffusion.md | 7 ++ .../systems/advection_diffusion_eulerian.py | 4 + tests/parallel/ptest_1119_supg_restart.py | 106 ++++++++++++++++++ tests/test_1119_supg_process_restart.py | 77 +++++++++++++ 4 files changed, 194 insertions(+) create mode 100644 tests/parallel/ptest_1119_supg_restart.py create mode 100644 tests/test_1119_supg_process_restart.py diff --git a/docs/advanced/eulerian-advection-diffusion.md b/docs/advanced/eulerian-advection-diffusion.md index 86ee5dca7..5ab4f70b3 100644 --- a/docs/advanced/eulerian-advection-diffusion.md +++ b/docs/advanced/eulerian-advection-diffusion.md @@ -200,6 +200,13 @@ same model layout and MPI rank count. Old full-model snapshots with a different solver/history layout require migration; importing the old module name does not make those layouts equivalent. +A fresh interpreter can construct the matching mesh, variables, and solver, +then load the snapshot without a dummy timestep. Generic SUPG registers its +cell-size geometry dependency at construction so the saved auxiliary field +is present before the first residual build. The independent process test +`tests/test_1119_supg_process_restart.py` checks PC2, CN, and BDF2 with changing +velocity and timesteps, including exact restored history and continuation. + Implementation ownership is `systems/advection_diffusion_eulerian.py`. Automatic CitcomS simplex geometry currently requires a non-empty volume diff --git a/src/underworld3/systems/advection_diffusion_eulerian.py b/src/underworld3/systems/advection_diffusion_eulerian.py index 17ba6fb9f..085aff9ba 100644 --- a/src/underworld3/systems/advection_diffusion_eulerian.py +++ b/src/underworld3/systems/advection_diffusion_eulerian.py @@ -371,6 +371,10 @@ def __init__( f"_supg_h_{tag}", mesh, 1, degree=0, continuous=False) self._supg_tau = uw.discretisation.MeshVariable( f"_supg_tau_{tag}", mesh, 1, degree=0, continuous=False) + elif self._tau_override is None: + # Generic tau needs this field on a fresh-process checkpoint restore, + # before the first residual build would otherwise create it lazily. + mesh.cell_size() uw.get_default_model()._register_state_bearer(self) # ------------------------------------------------------------------ diff --git a/tests/parallel/ptest_1119_supg_restart.py b/tests/parallel/ptest_1119_supg_restart.py new file mode 100644 index 000000000..5eb4a68ed --- /dev/null +++ b/tests/parallel/ptest_1119_supg_restart.py @@ -0,0 +1,106 @@ +"""Worker for a fresh-process SUPG restart; no Stokes or A1 setup.""" + +from dataclasses import asdict +import h5py +import numpy as np + +import underworld3 as uw + + +params = uw.Params( + uw_method=uw.Param("pc2", type=uw.ParamType.STRING), + uw_phase=uw.Param("full", type=uw.ParamType.STRING), +) +assert params.uw_method in ("pc2", "cn", "bdf2") +assert params.uw_phase in ("full", "write", "resume") +uw.reset_default_model() +orchestration_model = uw.get_default_model() +mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0, 0.0), maxCoords=(1.0, 1.0, 1.0), + cellSize=0.25, qdegree=4, regular=False, filename="mesh.msh", +) +temperature = uw.discretisation.MeshVariable("T", mesh, 1, degree=1) +velocity = uw.discretisation.MeshVariable("U", mesh, 3, degree=1) +temperature.array[:, 0, 0] = np.prod(np.sin(np.pi * np.asarray(temperature.coords)), axis=1) +velocity.array[...] = 0.0 +velocity.array[:, 0, 0] = 0.2 +settings = ({"time_integrator": "citcoms"} if params.uw_method == "pc2" + else {"order": 1, "theta": 0.5} if params.uw_method == "cn" + else {"order": 2}) +thermal = uw.systems.AdvDiffusionSUPG(mesh, temperature, velocity.sym, **settings) +thermal.constitutive_model.Parameters.diffusivity = 0.01 +for boundary in mesh.boundaries: + if boundary.name not in ("All_Boundaries", "Null_Boundary"): + thermal.add_dirichlet_bc(0.0, boundary.name) +thermal.petsc_options["ksp_rtol"] = 1e-14 +thermal.petsc_options["ksp_atol"] = 0.0 +thermal.petsc_options["snes_rtol"] = 1e-13 +thermal.petsc_options["snes_atol"] = 1e-14 +orchestration_model.tracker.step = 0 +orchestration_model.tracker.time = 0.0 + + +def capture(): + """All evolving fields and numerical metadata, separately from PETSc files.""" + fields = [temperature, velocity] + if thermal.temperature_rate is not None: + fields.append(thermal.temperature_rate) + else: + fields.extend(thermal.DuDt.psi_star) + record = {field.clean_name: np.array(field.array) for field in fields} + record["coords"] = np.asarray(temperature.coords) + record["step"] = orchestration_model.tracker.step + record["time"] = orchestration_model.tracker.time + record["estimate_dt"] = float(thermal.estimate_dt()) + for name, value in asdict(thermal.state).items(): + record["solver_" + name] = "None" if value is None else value + if thermal.DuDt is not None: + for name, value in asdict(thermal.DuDt.state).items(): + if name != "psi_star_var_names": + record["history_" + name] = "None" if value is None else value + return record + + +if params.uw_phase == "resume": + orchestration_model.load_state("checkpoint.h5") + restored = capture() + failure = None + try: + with h5py.File(f"write_rank{uw.mpi.rank}.h5", "r") as saved: + assert set(saved) == set(restored) + for name, actual in restored.items(): + expected = saved[name][()] + if isinstance(expected, bytes): + expected = expected.decode() + np.testing.assert_array_equal(actual, expected, err_msg=name) + except Exception as error: + # Every rank must report a failed restore before peers enter a solve. + failure = str(error) + failures = uw.mpi.comm.allgather(failure) + assert not any(failures), failures + uw.pprint(f"SUPG_RESTORE_EXACT method={params.uw_method} ranks={uw.mpi.size}") + +end_step = 5 if params.uw_phase == "write" else 12 +for step in range(orchestration_model.tracker.step, end_step): + dt = (0.002, 0.003, 0.0015, 0.0025)[step % 4] + velocity.array[:, 0, 0] = 0.2 * (1.0 + 0.1 * np.sin(step)) + thermal.solve(timestep=dt) + orchestration_model.tracker.step = step + 1 + orchestration_model.tracker.time += dt + orchestration_model.tracker.dt = dt + +if params.uw_phase == "write": + orchestration_model.save_state(file="checkpoint.h5") + +record = capture() +failure = None +try: + with h5py.File(f"{params.uw_phase}_rank{uw.mpi.rank}.h5", "w") as output: + for name, value in record.items(): + output[name] = value +except Exception as error: + # Rank-local test output errors must not strand peers on shutdown. + failure = str(error) +failures = uw.mpi.comm.allgather(failure) +assert not any(failures), failures +uw.pprint(f"SUPG_RESTART_STAGE phase={params.uw_phase} method={params.uw_method} step={end_step}") diff --git a/tests/test_1119_supg_process_restart.py b/tests/test_1119_supg_process_restart.py new file mode 100644 index 000000000..16983815f --- /dev/null +++ b/tests/test_1119_supg_process_restart.py @@ -0,0 +1,77 @@ +"""Fresh-process transport snapshots: pc2, CN and BDF2 on tiny tetrahedra. + +Run this parent pytest in serial. UW_SUPG_TEST_RANKS=8 requests eight-rank +workers; the default uses singleton workers. Every phase starts a fresh +interpreter. No forked in-memory snapshot can satisfy the restore check. +""" + +import os +from pathlib import Path +import signal +import shutil +import subprocess +import sys + +import h5py +import numpy as np +import pytest + +import underworld3 as uw +from parallel.serial_reference import _MPI_ENV_PREFIXES + +pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] + + +@pytest.mark.parametrize("method", ["pc2", "cn", "bdf2"]) +def test_fresh_process_transport_restart(method, tmp_path): + if uw.mpi.size != 1: + pytest.skip("Run the parent in serial; UW_SUPG_TEST_RANKS selects worker ranks.") + ranks = int(os.environ.get("UW_SUPG_TEST_RANKS", "1")) + root = Path(__file__).resolve().parents[1] + worker = root / "tests/parallel/ptest_1119_supg_restart.py" + env = {key: value for key, value in os.environ.items() + if not key.startswith(_MPI_ENV_PREFIXES)} + launcher = [] + if ranks > 1: + executable = Path(sys.executable).with_name("mpirun") + if not executable.is_file(): + executable = shutil.which("mpirun") + assert executable, "Activate the matching MPI environment before this test." + launcher = [str(executable), "-np", str(ranks)] + for phase in ("full", "write", "resume"): + command = [sys.executable, str(root / "scripts/mpi_supervisor.py"), + "--silence", "45", "--", *launcher, + sys.executable, "-m", "mpi4py", str(worker), + "-uw_method", method, "-uw_phase", phase] + with (tmp_path / f"{phase}.log").open("w") as log: + process = subprocess.Popen(command, cwd=tmp_path, env=env, + stdout=log, stderr=subprocess.STDOUT, + start_new_session=True) + try: + status = process.wait(timeout=120) + except subprocess.TimeoutExpired: + # Bound the entire child group, including its MPI ranks. + os.killpg(process.pid, signal.SIGKILL) + process.wait() + pytest.fail(f"{method}/{phase} exceeded 120 seconds; see {tmp_path}") + assert status == 0, (tmp_path / f"{phase}.log").read_text(errors="replace") + maxima = {"field": 0.0, "estimate": 0.0} + for rank in range(ranks): + with h5py.File(tmp_path / f"full_rank{rank}.h5") as full, h5py.File( + tmp_path / f"resume_rank{rank}.h5") as resumed: + assert set(full) == set(resumed) + for name in full: + expected, actual = full[name][()], resumed[name][()] + if name == "estimate_dt" or name == "solver_last_change_rate": + if isinstance(expected, bytes): + assert actual == expected + continue + np.testing.assert_allclose(actual, expected, rtol=1e-9, atol=1e-10, err_msg=name) + maxima["estimate"] = max(maxima["estimate"], float(np.max(np.abs(actual-expected)))) + elif name in ("coords", "step", "time") or name.startswith(("solver_", "history_")): + np.testing.assert_array_equal(actual, expected, err_msg=name) + else: + np.testing.assert_allclose(actual, expected, rtol=1e-11, atol=1e-12, err_msg=name) + maxima["field"] = max(maxima["field"], float(np.max(np.abs(actual-expected)))) + print(f"SUPG_FRESH_RESTART method={method} ranks={ranks} " + f"max_field_error={maxima['field']:.12g} max_estimator_error={maxima['estimate']:.12g}", flush=True) From b3a31ceb262eab8e1b373b881960df71ffc975ab Mon Sep 17 00:00:00 2001 From: Tyagi Date: Sun, 6 Sep 2026 00:57:11 +1000 Subject: [PATCH 26/35] test: make SUPG pulse assertions partition independent Reduce timestep-control changes, FMG comparison norms, and adapted-mesh pulse maxima globally instead of assuming every partition contains the Gaussian peak. Make equality and finite-value failures collective before subsequent solver calls. Keep the existing 1e-3 change, 1e-6 relative error, and 0.9-1.01 peak thresholds. No equations or solver tolerances change. The prior rank-local failures stranded peers in later PETSc collectives. Validation: all 48 API/residual/migration/diffusion checks pass on eight Mac ranks in 152.40 s. The three modified API tests also pass in the final 12-test serial lifecycle sequence (98.14 s). Global FMG discrepancy is 3.04e-10 with amplitude 0.96026 on eight ranks. --- tests/test_1055_advdiff_supg_api.py | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/tests/test_1055_advdiff_supg_api.py b/tests/test_1055_advdiff_supg_api.py index 9b39dc983..38d7ce889 100644 --- a/tests/test_1055_advdiff_supg_api.py +++ b/tests/test_1055_advdiff_supg_api.py @@ -146,14 +146,19 @@ def test_timestep_change_reaches_the_kernels(mesh): 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) + matches = np.allclose(np.asarray(T1.array), np.asarray(T2.array), rtol=0, atol=1e-8) + assert all(uw.mpi.comm.allgather(matches)) # 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 + # The pulse need not occupy every partition; require a global change. + local_change = float(np.abs(np.asarray(T2.array) - np.asarray(T3.array)).max(initial=0.0)) + changes = uw.mpi.comm.allgather(local_change) + uw.pprint(f"SUPG_DT_CONTROL rank_changes={changes}") + assert max(changes) > 1e-3 def test_order_ramps_from_one_unless_history_is_planted(mesh): @@ -202,7 +207,11 @@ def test_multigrid_is_one_switch_away_on_a_refinement_hierarchy(): 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() + # Compare global infinity norms, independent of where the pulse is partitioned. + error = max(uw.mpi.comm.allgather(float(np.abs(a - b).max(initial=0.0)))) + scale = max(uw.mpi.comm.allgather(float(np.abs(a).max(initial=0.0)))) + uw.pprint(f"SUPG_FMG_CONTROL error={error:.12g} scale={scale:.12g}") + assert error < 1e-6 * scale multigrid.preconditioner = "auto" multigrid.solve(timestep=0.01) @@ -235,7 +244,9 @@ def metric(pts): 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 + assert all(uw.mpi.comm.allgather(bool(np.isfinite(data).all()))) + maximum = max(uw.mpi.comm.allgather(float(data.max(initial=-np.inf)))) + assert 0.9 < maximum < 1.01 def test_estimate_dt_is_accuracy_based_and_resolution_on_request(mesh): From d199d0c1ecb3eae82d9d352fb76ab2d941252595 Mon Sep 17 00:00:00 2001 From: Tyagi Date: Sun, 6 Sep 2026 01:00:50 +1000 Subject: [PATCH 27/35] test: add bounded transport memory regression and supervised restart checks Exercise PC2, CN and BDF2 for 200 changing-velocity updates on tiny triangles and tetrahedra, without Stokes, checkpoint output, reaction diagnostics or forced GC. Record per-rank current RSS after warm-up and require stable solver/vector handles and PC2 workspaces. Keep preset per-rank bounds of 16 MiB growth and 0.05 MiB/step late slope, with collective temperature-finiteness checks. Delegate fresh-process restart execution caps and descendant cleanup to the existing MPI supervisor instead of duplicating process-group termination. Document actual UW3 CN second-order discrete diffusion validation and the distinction between small lifecycle regression and production-scale acceptance. Validation: final serial API/restart/memory sequence 12 passed in 98.14 s; final eight-rank restart 3 passed in 60.59 s; final eight-rank memory 6 passed per rank in 60.56 s. Maximum resumed field discrepancy 8.89e-16, PC2 exact. Repeated memory checks pass unchanged limits; no universal zero-leak or second-order PC2 claim. Style gate and git diff --check pass. --- docs/advanced/eulerian-advection-diffusion.md | 31 ++++++- tests/test_1119_supg_process_restart.py | 17 ++-- tests/test_1120_supg_memory.py | 89 +++++++++++++++++++ 3 files changed, 123 insertions(+), 14 deletions(-) create mode 100644 tests/test_1120_supg_memory.py diff --git a/docs/advanced/eulerian-advection-diffusion.md b/docs/advanced/eulerian-advection-diffusion.md index 5ab4f70b3..c3c95bab6 100644 --- a/docs/advanced/eulerian-advection-diffusion.md +++ b/docs/advanced/eulerian-advection-diffusion.md @@ -163,8 +163,11 @@ The CitcomS-compatible mode retains its fixed-correction semantics. Do not silently replace its residual mass or increase the iteration count and still claim an unchanged paper-reproduction method. An accurately solved implicit Crank-Nicolson update with consistent initialization provides a -separate second-order reference. Production-scale validation is separate -from these small mathematical tests. +separate second-order reference. The same test file also exercises actual +UW3 CN (not only a matrix control): temporal order 2.00 on both geometries +in serial and on eight ranks, with the nodal CN amplification map agreeing +within 1.6e-14. Production-scale validation is separate from these small +mathematical tests. Its steady tau is `h/(2*speed) * max(0, 1-1/Pe)`, with `Pe=speed*h/(2*kappa)` and directional simplex @@ -216,6 +219,30 @@ ranks or a sufficiently resolved test mesh; an empty partition is not silently interpreted as unsupported physics on only one rank. `systems/advdiff_supg.py` contains compatibility imports only. +### Small Lifecycle Regressions + +`tests/test_1120_supg_memory.py` runs PC2, CN, and BDF2 for 200 updates on +tiny triangles and tetrahedra with prescribed changing velocity. It checks +current per-rank RSS after 40 warm-up steps, fits late slopes over steps +120-200, and checks stable solver/vector handles and PC2 workspace reuse. +There are no Stokes solves, checkpoints, reaction diagnostics, or forced +garbage collections in the measured loop. Serial and eight-rank tests pass +the preset limits of 16 MiB growth and 0.05 MiB/step per rank. These bounds +detect repeated-allocation regressions, not prove zero leaks at every size. + +Run the restart parent in serial; it starts independent worker interpreters +and uses the existing MPI supervisor for bounded cleanup: + +```bash +python -m pytest -x -s tests/test_1119_supg_process_restart.py +UW_SUPG_TEST_RANKS=8 python -m pytest -x -s tests/test_1119_supg_process_restart.py +python -m pytest -x -s tests/test_1120_supg_memory.py +mpirun -np 8 python -m mpi4py -m pytest --with-mpi -x -s tests/test_1120_supg_memory.py +``` + +Use the MPI launcher matching the active Python/PETSc environment. These +small mathematical and lifecycle checks do not require a coupled A1 run. + ## Further Reading - Design note and measurements: `docs/developer/design/eulerian-supg-transport.md` diff --git a/tests/test_1119_supg_process_restart.py b/tests/test_1119_supg_process_restart.py index 16983815f..eb5ed316a 100644 --- a/tests/test_1119_supg_process_restart.py +++ b/tests/test_1119_supg_process_restart.py @@ -7,7 +7,6 @@ import os from pathlib import Path -import signal import shutil import subprocess import sys @@ -40,20 +39,14 @@ def test_fresh_process_transport_restart(method, tmp_path): launcher = [str(executable), "-np", str(ranks)] for phase in ("full", "write", "resume"): command = [sys.executable, str(root / "scripts/mpi_supervisor.py"), - "--silence", "45", "--", *launcher, + "--silence", "45", "--hard-cap", "60", "--", *launcher, sys.executable, "-m", "mpi4py", str(worker), "-uw_method", method, "-uw_phase", phase] with (tmp_path / f"{phase}.log").open("w") as log: - process = subprocess.Popen(command, cwd=tmp_path, env=env, - stdout=log, stderr=subprocess.STDOUT, - start_new_session=True) - try: - status = process.wait(timeout=120) - except subprocess.TimeoutExpired: - # Bound the entire child group, including its MPI ranks. - os.killpg(process.pid, signal.SIGKILL) - process.wait() - pytest.fail(f"{method}/{phase} exceeded 120 seconds; see {tmp_path}") + # The supervisor owns all descendant ranks, including separate + # process groups, and performs bounded diagnosis/cleanup on timeout. + status = subprocess.run(command, cwd=tmp_path, env=env, + stdout=log, stderr=subprocess.STDOUT).returncode assert status == 0, (tmp_path / f"{phase}.log").read_text(errors="replace") maxima = {"field": 0.0, "estimate": 0.0} for rank in range(ranks): diff --git a/tests/test_1120_supg_memory.py b/tests/test_1120_supg_memory.py new file mode 100644 index 000000000..8f1b94fdc --- /dev/null +++ b/tests/test_1120_supg_memory.py @@ -0,0 +1,89 @@ +"""Bounded transport-only memory regression on tiny volume simplices. + +No Stokes, reaction diagnostics, checkpoints, or forced garbage collection +occur in the measured loop. RSS is current resident memory, not peak RSS. +This catches repeated allocation regressions; it cannot certify every +production mesh or long coupled trajectory as leak-free. +""" + +import time + +import numpy as np +import pytest + +import underworld3 as uw +from underworld3.utilities import memprobe + +pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] + + +def _workspace(thermal): + """Record identities, not contents that should change during transport.""" + identity = [thermal.snes.handle, thermal.dm.handle, + tuple((name, field.vec.handle) for name, field in thermal.mesh.vars.items())] + if thermal.time_integrator == "citcoms": + identity.extend([ + thermal._lumped_mass.handle, + tuple(vector.handle for vector in thermal._citcoms_work_vectors), + tuple(id(array) for array in thermal._simplex_data_cache), + tuple(id(array) for array in thermal._directional_rate_work), + ]) + return identity + + +@pytest.mark.parametrize("dim", [2, 3]) +@pytest.mark.parametrize("method", ["pc2", "cn", "bdf2"]) +def test_repeated_transport_memory_and_workspace_reuse(dim, method): + pytest.importorskip("psutil", reason="This test requires current RSS, not peak RSS.") + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0,) * dim, maxCoords=(1.0,) * dim, + cellSize=0.25, qdegree=4, regular=False, + ) + temperature = uw.discretisation.MeshVariable("T", mesh, 1, degree=1) + velocity = uw.discretisation.MeshVariable("U", mesh, dim, degree=1) + temperature.array[:, 0, 0] = np.prod(np.sin(np.pi * np.asarray(temperature.coords)), axis=1) + velocity.array[...] = 0.0 + velocity.array[:, 0, 0] = 0.2 + settings = ({"time_integrator": "citcoms"} if method == "pc2" + else {"order": 1, "theta": 0.5} if method == "cn" + else {"order": 2}) + thermal = uw.systems.AdvDiffusionSUPG(mesh, temperature, velocity.sym, **settings) + thermal.constitutive_model.Parameters.diffusivity = 0.01 + for boundary in mesh.boundaries: + if boundary.name not in ("All_Boundaries", "Null_Boundary"): + thermal.add_dirichlet_bc(0.0, boundary.name) + samples = [] + start = time.perf_counter() + for step in range(1, 201): + velocity.array[:, 0, 0] = 0.2 * (1.0 + 0.1 * np.sin(step)) + dt = (0.001, 0.0015, 0.002, 0.0025)[step % 4] + thermal.estimate_dt() + thermal.solve(timestep=dt) + if step == 40: + workspace = _workspace(thermal) + if step >= 40 and step % 10 == 0: + unchanged = _workspace(thermal) == workspace + assert all(uw.mpi.comm.allgather(unchanged)), "Solver workspace was reallocated" + rss = uw.mpi.comm.allgather(memprobe.snapshot()["rss_mb"]) + samples.append((step, *rss)) + elapsed = max(uw.mpi.comm.allgather(time.perf_counter() - start)) + samples = np.asarray(samples) + late = samples[samples[:, 0] >= 120] + slopes = np.polyfit(late[:, 0], late[:, 1:], 1)[0] + growth = samples[-1, 1:] - samples[0, 1:] + nodal = np.asarray(temperature.array) + assert all(uw.mpi.comm.allgather(bool(np.isfinite(nodal).all()))) + minimum = min(uw.mpi.comm.allgather(float(np.min(nodal)))) + maximum = max(uw.mpi.comm.allgather(float(np.max(nodal)))) + uw.pprint( + f"SUPG_MEMORY method={method} dim={dim} ranks={uw.mpi.size} seconds={elapsed:.6f} " + f"rss_start_mib={samples[0, 1:].sum():.6f} rss_end_mib={samples[-1, 1:].sum():.6f} " + f"growth_mib={growth.sum():.6f} late_slope_mib_per_step={slopes.sum():.9f} " + f"max_rank_growth_mib={growth.max():.6f} max_rank_slope={slopes.max():.9f} " + f"Tmin={minimum:.9g} Tmax={maximum:.9g}") + uw.pprint(f"SUPG_MEMORY_SAMPLES method={method} dim={dim} values={samples.tolist()}") + assert np.isfinite(samples).all() + assert -0.05 < minimum and maximum < 1.05 + # Fixed pre-run bounds allow allocator noise but reject sustained growth. + assert growth.max() < 16.0, growth + assert slopes.max() < 0.05, slopes From 46c1da4504388b1a990f0105301b9ad7ecdfe157 Mon Sep 17 00:00:00 2001 From: Tyagi Date: Sun, 6 Sep 2026 02:39:09 +1000 Subject: [PATCH 28/35] test: keep SUPG restart gate usable on the transport branch Run singleton fresh-process phases directly when the target branch predates the MPI supervisor merged in development by #678. Keep MPI restart validation conditional on that supervisor so parallel descendants remain bounded and diagnosable. This lets the SUPG feature branch validate PC2, CN, and BDF2 restart state without importing the unrelated 691-line supervisor change into this review. Underworld development team with AI support from Claude Code. --- tests/test_1119_supg_process_restart.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/test_1119_supg_process_restart.py b/tests/test_1119_supg_process_restart.py index eb5ed316a..1b30190b3 100644 --- a/tests/test_1119_supg_process_restart.py +++ b/tests/test_1119_supg_process_restart.py @@ -28,6 +28,7 @@ def test_fresh_process_transport_restart(method, tmp_path): ranks = int(os.environ.get("UW_SUPG_TEST_RANKS", "1")) root = Path(__file__).resolve().parents[1] worker = root / "tests/parallel/ptest_1119_supg_restart.py" + supervisor = root / "scripts/mpi_supervisor.py" env = {key: value for key, value in os.environ.items() if not key.startswith(_MPI_ENV_PREFIXES)} launcher = [] @@ -37,16 +38,18 @@ def test_fresh_process_transport_restart(method, tmp_path): executable = shutil.which("mpirun") assert executable, "Activate the matching MPI environment before this test." launcher = [str(executable), "-np", str(ranks)] + if ranks > 1 and not supervisor.is_file(): + pytest.skip("MPI restart supervision requires development commit #678.") for phase in ("full", "write", "resume"): - command = [sys.executable, str(root / "scripts/mpi_supervisor.py"), - "--silence", "45", "--hard-cap", "60", "--", *launcher, - sys.executable, "-m", "mpi4py", str(worker), - "-uw_method", method, "-uw_phase", phase] + worker_command = [*launcher, sys.executable, "-m", "mpi4py", str(worker), + "-uw_method", method, "-uw_phase", phase] + command = ([sys.executable, str(supervisor), "--silence", "45", + "--hard-cap", "60", "--", *worker_command] + if supervisor.is_file() else worker_command) with (tmp_path / f"{phase}.log").open("w") as log: - # The supervisor owns all descendant ranks, including separate - # process groups, and performs bounded diagnosis/cleanup on timeout. status = subprocess.run(command, cwd=tmp_path, env=env, - stdout=log, stderr=subprocess.STDOUT).returncode + stdout=log, stderr=subprocess.STDOUT, + timeout=65).returncode assert status == 0, (tmp_path / f"{phase}.log").read_text(errors="replace") maxima = {"field": 0.0, "estimate": 0.0} for rank in range(ranks): From 40b815e20c1a9bc0fdc6bf12adae80f43fd65564 Mon Sep 17 00:00:00 2001 From: Tyagi Date: Sun, 6 Sep 2026 02:48:44 +1000 Subject: [PATCH 29/35] test: gate distributed SUPG snapshots on checkpoint capability Detect the same-layout checkpoint API added by upstream #674 before asserting MPI disk replay. Older transport-branch checkouts continue to test serial disk restore and MPI in-memory restore; rebased development checkouts automatically exercise the full distributed disk path. This keeps the SUPG PR focused while making its dependency on the already-merged checkpoint fix explicit. Underworld development team with AI support from Claude Code. --- tests/test_1116_supg_unified.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/test_1116_supg_unified.py b/tests/test_1116_supg_unified.py index 3aaa53367..a33b113a2 100644 --- a/tests/test_1116_supg_unified.py +++ b/tests/test_1116_supg_unified.py @@ -1,6 +1,7 @@ """Shared SUPG integration, restart, and pre-migration equivalence.""" import importlib.util +import inspect import os import sys @@ -82,6 +83,10 @@ def test_snapshot_restores_fields_and_timestep_estimator(settings, disk, tmp_pat uw.reset_default_model() orchestration_model = uw.get_default_model() mesh, temperature, velocity = _problem(2, "snapshot") + if (disk and uw.mpi.size > 1 and + "same_layout" not in inspect.signature( + temperature.read_checkpoint).parameters): + pytest.skip("MPI disk restore requires checkpoint fix #674.") thermal = uw.systems.AdvDiffusionSUPG(mesh, temperature, velocity.sym, **settings) # Replay is compared near machine precision, independently of the default # stopping tolerance and the preconditioner rebuilt after a discarded step. From 048ee09b8937240ad09a68f84d277380e7c5e0c4 Mon Sep 17 00:00:00 2001 From: Tyagi Date: Sun, 6 Sep 2026 07:10:04 +1000 Subject: [PATCH 30/35] test: move the SUPG memory soak out of routine CI Keep six fast Level 2 workspace-reuse checks for PC2, CN, and BDF2 on triangles and tetrahedra. They run eight updates and retain the deterministic object-identity, finite-field, and boundedness assertions. Reclassify the 200-update RSS and late-slope regression as an opt-in Level 3 slow test selected with UW_RUN_SUPG_MEMORY_SOAK=1. Preserve its warm-up, sampling, thresholds, and six-case matrix, and document both execution paths. Validation: default serial 6 passed/6 skipped in 16.47 s wall; default eight-rank 6 passed/6 skipped per rank in 35.03 s wall; representative opt-in PC2 triangle soak passed in 10.83 s wall. Deprecated-pattern and whitespace checks pass. Underworld development team with AI support from Claude Code. --- docs/advanced/eulerian-advection-diffusion.md | 21 +++--- tests/test_1120_supg_memory.py | 65 ++++++++++++++----- 2 files changed, 62 insertions(+), 24 deletions(-) diff --git a/docs/advanced/eulerian-advection-diffusion.md b/docs/advanced/eulerian-advection-diffusion.md index c3c95bab6..a01b0bd5e 100644 --- a/docs/advanced/eulerian-advection-diffusion.md +++ b/docs/advanced/eulerian-advection-diffusion.md @@ -221,14 +221,14 @@ silently interpreted as unsupported physics on only one rank. ### Small Lifecycle Regressions -`tests/test_1120_supg_memory.py` runs PC2, CN, and BDF2 for 200 updates on -tiny triangles and tetrahedra with prescribed changing velocity. It checks -current per-rank RSS after 40 warm-up steps, fits late slopes over steps -120-200, and checks stable solver/vector handles and PC2 workspace reuse. -There are no Stokes solves, checkpoints, reaction diagnostics, or forced -garbage collections in the measured loop. Serial and eight-rank tests pass -the preset limits of 16 MiB growth and 0.05 MiB/step per rank. These bounds -detect repeated-allocation regressions, not prove zero leaks at every size. +`tests/test_1120_supg_memory.py` checks PC2, CN, and BDF2 workspace reuse over +eight updates on tiny triangles and tetrahedra. This fast Level 2 test runs by +default. The same file also provides an opt-in 200-update Level 3 soak test. +The soak records current per-rank RSS after 40 warm-up steps, fits late slopes +over steps 120-200, and checks stable solver/vector handles and PC2 workspace +reuse. There are no Stokes solves, checkpoints, reaction diagnostics, or +forced garbage collections in either loop. RSS measurements are platform +sensitive and are not part of routine CI. Run the restart parent in serial; it starts independent worker interpreters and uses the existing MPI supervisor for bounded cleanup: @@ -237,7 +237,10 @@ and uses the existing MPI supervisor for bounded cleanup: python -m pytest -x -s tests/test_1119_supg_process_restart.py UW_SUPG_TEST_RANKS=8 python -m pytest -x -s tests/test_1119_supg_process_restart.py python -m pytest -x -s tests/test_1120_supg_memory.py -mpirun -np 8 python -m mpi4py -m pytest --with-mpi -x -s tests/test_1120_supg_memory.py +UW_RUN_SUPG_MEMORY_SOAK=1 python -m pytest -x -s \ + tests/test_1120_supg_memory.py -k repeated_transport_memory +UW_RUN_SUPG_MEMORY_SOAK=1 mpirun -np 8 python -m mpi4py -m pytest \ + --with-mpi -x -s tests/test_1120_supg_memory.py -k repeated_transport_memory ``` Use the MPI launcher matching the active Python/PETSc environment. These diff --git a/tests/test_1120_supg_memory.py b/tests/test_1120_supg_memory.py index 8f1b94fdc..5e5aa974f 100644 --- a/tests/test_1120_supg_memory.py +++ b/tests/test_1120_supg_memory.py @@ -1,11 +1,11 @@ -"""Bounded transport-only memory regression on tiny volume simplices. +"""Fast workspace reuse and opt-in transport memory soak tests. No Stokes, reaction diagnostics, checkpoints, or forced garbage collection -occur in the measured loop. RSS is current resident memory, not peak RSS. -This catches repeated allocation regressions; it cannot certify every -production mesh or long coupled trajectory as leak-free. +occur in either loop. The default test checks stable object identities over +eight updates. Set UW_RUN_SUPG_MEMORY_SOAK=1 to run the 200-update RSS test. """ +import os import time import numpy as np @@ -14,7 +14,7 @@ import underworld3 as uw from underworld3.utilities import memprobe -pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] +pytestmark = pytest.mark.tier_b def _workspace(thermal): @@ -31,34 +31,69 @@ def _workspace(thermal): return identity -@pytest.mark.parametrize("dim", [2, 3]) -@pytest.mark.parametrize("method", ["pc2", "cn", "bdf2"]) -def test_repeated_transport_memory_and_workspace_reuse(dim, method): - pytest.importorskip("psutil", reason="This test requires current RSS, not peak RSS.") +def _transport_problem(dim, method): mesh = uw.meshing.UnstructuredSimplexBox( minCoords=(0.0,) * dim, maxCoords=(1.0,) * dim, cellSize=0.25, qdegree=4, regular=False, ) temperature = uw.discretisation.MeshVariable("T", mesh, 1, degree=1) velocity = uw.discretisation.MeshVariable("U", mesh, dim, degree=1) - temperature.array[:, 0, 0] = np.prod(np.sin(np.pi * np.asarray(temperature.coords)), axis=1) + temperature.array[:, 0, 0] = np.prod( + np.sin(np.pi * np.asarray(temperature.coords)), axis=1) velocity.array[...] = 0.0 velocity.array[:, 0, 0] = 0.2 settings = ({"time_integrator": "citcoms"} if method == "pc2" else {"order": 1, "theta": 0.5} if method == "cn" else {"order": 2}) - thermal = uw.systems.AdvDiffusionSUPG(mesh, temperature, velocity.sym, **settings) + thermal = uw.systems.AdvDiffusionSUPG( + mesh, temperature, velocity.sym, **settings) thermal.constitutive_model.Parameters.diffusivity = 0.01 for boundary in mesh.boundaries: if boundary.name not in ("All_Boundaries", "Null_Boundary"): thermal.add_dirichlet_bc(0.0, boundary.name) + return thermal, temperature, velocity + + +def _advance(thermal, velocity, step): + velocity.array[:, 0, 0] = 0.2 * (1.0 + 0.1 * np.sin(step)) + dt = (0.001, 0.0015, 0.002, 0.0025)[step % 4] + thermal.estimate_dt() + thermal.solve(timestep=dt) + + +@pytest.mark.level_2 +@pytest.mark.parametrize("dim", [2, 3]) +@pytest.mark.parametrize("method", ["pc2", "cn", "bdf2"]) +def test_transport_workspace_reuse(dim, method): + thermal, temperature, velocity = _transport_problem(dim, method) + _advance(thermal, velocity, 1) + workspace = _workspace(thermal) + for step in range(2, 9): + _advance(thermal, velocity, step) + unchanged = _workspace(thermal) == workspace + assert all(uw.mpi.comm.allgather(unchanged)), "Solver workspace was reallocated" + nodal = np.asarray(temperature.array) + assert all(uw.mpi.comm.allgather(bool(np.isfinite(nodal).all()))) + minimum = min(uw.mpi.comm.allgather(float(np.min(nodal)))) + maximum = max(uw.mpi.comm.allgather(float(np.max(nodal)))) + assert -0.05 < minimum and maximum < 1.05 + + +@pytest.mark.level_3 +@pytest.mark.slow +@pytest.mark.skipif( + os.environ.get("UW_RUN_SUPG_MEMORY_SOAK") != "1", + reason="Set UW_RUN_SUPG_MEMORY_SOAK=1 to run the 200-update RSS regression.", +) +@pytest.mark.parametrize("dim", [2, 3]) +@pytest.mark.parametrize("method", ["pc2", "cn", "bdf2"]) +def test_repeated_transport_memory_and_workspace_reuse(dim, method): + pytest.importorskip("psutil", reason="This test requires current RSS, not peak RSS.") + thermal, temperature, velocity = _transport_problem(dim, method) samples = [] start = time.perf_counter() for step in range(1, 201): - velocity.array[:, 0, 0] = 0.2 * (1.0 + 0.1 * np.sin(step)) - dt = (0.001, 0.0015, 0.002, 0.0025)[step % 4] - thermal.estimate_dt() - thermal.solve(timestep=dt) + _advance(thermal, velocity, step) if step == 40: workspace = _workspace(thermal) if step >= 40 and step % 10 == 0: From 5e7b687aba0cea26ab231fdb974afc9525b935a4 Mon Sep 17 00:00:00 2001 From: Tyagi Date: Sun, 6 Sep 2026 08:44:27 +1000 Subject: [PATCH 31/35] feat(transport): add residual-converged predictor-corrector Preserve the fixed two-correction CitcomS compatibility mode while adding an explicitly selected pc_converged integrator. Iterate the full Petrov-Galerkin rate residual at startup and each timestep, using the lumped mass only as a reusable correction preconditioner and failing clearly when configured tolerances are not reached. Persist correction controls in solver snapshots, expose convergence diagnostics, and extend workspace and fresh-process restart coverage. Add independent finite-element diffusion tests proving second-order convergence and exact trapezoidal-map agreement in 2D/3D serial and MPI without relying on the Zhong A1 model. Document the mathematical distinction from fixed PC2 and the current repeated-residual performance cost. --- docs/advanced/eulerian-advection-diffusion.md | 42 ++- .../systems/advection_diffusion_eulerian.py | 303 ++++++++++++++---- tests/parallel/ptest_1119_supg_restart.py | 4 +- tests/test_1113_advdiff_supg_residual.py | 52 +++ tests/test_1118_pc2_diffusion_time.py | 45 +++ tests/test_1119_supg_process_restart.py | 4 +- tests/test_1120_supg_memory.py | 6 +- 7 files changed, 380 insertions(+), 76 deletions(-) diff --git a/docs/advanced/eulerian-advection-diffusion.md b/docs/advanced/eulerian-advection-diffusion.md index a01b0bd5e..82abac876 100644 --- a/docs/advanced/eulerian-advection-diffusion.md +++ b/docs/advanced/eulerian-advection-diffusion.md @@ -169,6 +169,35 @@ in serial and on eight ranks, with the nodal CN amplification map agreeing within 1.6e-14. Production-scale validation is separate from these small mathematical tests. +For a predictor-corrector reference that retains the same SUPG residual and +gamma update, select the residual-converged mode explicitly: + +```python +adv = uw.systems.AdvDiffusionSUPG( + mesh, + T, + v.sym, + time_integrator="pc_converged", + temperature_rate_field=Tdot, +) +``` + +This mode uses the row-lumped mass only as an iterative preconditioner. It +solves the consistent Petrov-Galerkin rate equation at startup and after each +prediction until the full residual is no larger than the greater of +`corrector_atol` and `corrector_rtol*initial_residual`. The defaults are +`corrector_rtol=1e-10`, `corrector_atol=1e-12`, and +`max_corrector_steps=100`; non-convergence raises `RuntimeError` rather than +silently accepting an inaccurate step. + +The exact discrete diffusion regression measures temporal order 2.00 in both +2-D and 3-D, in serial and on eight ranks, and agrees with the trapezoidal +amplification map to below 5.2e-14. With deliberately strict `1e-12` relative +tolerance, its small meshes require 48-63 corrections per step in 2-D and +63-81 in 3-D. The mode is therefore an accuracy reference, not a claim that +repeated diagonal correction is the most efficient production-scale +consistent-mass solve. + Its steady tau is `h/(2*speed) * max(0, 1-1/Pe)`, with `Pe=speed*h/(2*kappa)` and directional simplex `h=2*speed/sum_a(abs(u.grad(N_a)))`. Zero velocity gives zero tau; @@ -196,12 +225,13 @@ orchestration_model.load_state("checkpoint.h5") ``` The PETSc-backed snapshot captures T and the required history automatically: -Tdot and startup status for CitcomS; DDt fields, timestep history, theta, -and the field-change estimator state for implicit integration. A T-only -checkpoint is not an exact restart. Disk snapshots currently require the -same model layout and MPI rank count. Old full-model snapshots with a -different solver/history layout require migration; importing the old module -name does not make those layouts equivalent. +Tdot, startup status, and correction controls for both predictor-corrector +modes; DDt fields, timestep history, theta, and the field-change estimator +state for implicit integration. A T-only checkpoint is not an exact restart. +Disk snapshots currently require the same model layout and MPI rank count. +Old full-model snapshots with a different solver/history layout require +migration; importing the old module name does not make those layouts +equivalent. A fresh interpreter can construct the matching mesh, variables, and solver, then load the snapshot without a dummy timestep. Generic SUPG registers its diff --git a/src/underworld3/systems/advection_diffusion_eulerian.py b/src/underworld3/systems/advection_diffusion_eulerian.py index 085aff9ba..8f6a03552 100644 --- a/src/underworld3/systems/advection_diffusion_eulerian.py +++ b/src/underworld3/systems/advection_diffusion_eulerian.py @@ -82,6 +82,9 @@ class AdvDiffusionSUPGState(SnapshottableState): theta: float = 0.5 adv_gamma: float = 0.5 corrector_steps: int = 2 + corrector_rtol: float = 1.0e-10 + corrector_atol: float = 1.0e-12 + max_corrector_steps: int = 100 class SNES_AdvectionDiffusion_SUPG(SNES_Scalar): @@ -102,24 +105,32 @@ class SNES_AdvectionDiffusion_SUPG(SNES_Scalar): V_fn : MeshVariable or sympy Matrix Advecting velocity. order : int, default 1 - Implicit history order: 1, 2 or 3. Leave at 1 for CitcomS, - which manages its own rate instead of DDt history. + Implicit history order: 1, 2 or 3. Leave at 1 for either + predictor-corrector mode, which manages its own rate. theta : float, optional At order 1, 0.5 selects Crank-Nicolson (implicit default) and 1 - backward Euler. Orders 2 and 3 require 1. Leave unset for CitcomS. - time_integrator : {"implicit", "citcoms", "bdf"}, default "implicit" + backward Euler. Orders 2 and 3 require 1. Leave unset for either + predictor-corrector mode. + time_integrator : {"implicit", "citcoms", "pc_converged", "bdf"}, default "implicit" "implicit" selects CN/BE/BDF2/BDF3 through order and theta. "citcoms" selects the P1 lumped-mass predictor-corrector used by - CitcomS-style mantle-convection benchmarks. "bdf" retains the - previous BDF selection, including backward Euler at order 1. + CitcomS-style mantle-convection benchmarks. "pc_converged" uses the + same predictor-multicorrector residual but iterates its consistent + mass equation to tolerance. "bdf" retains the previous BDF selection, + including backward Euler at order 1. temperature_rate_field : MeshVariable, optional - Separate continuous P1 field storing the CitcomS rate. A stable - name such as Tdot is useful for field checkpoints. Created internally - if omitted; not used by implicit integrators. + Separate continuous P1 field storing the predictor-corrector rate. A + stable name such as Tdot is useful for field checkpoints. Created + internally if omitted; not used by implicit integrators. adv_gamma : float, default 0.5 CitcomS predictor/corrector weight, in (0, 1]. corrector_steps : int, default 2 Number of fixed CitcomS residual corrections. + corrector_rtol, corrector_atol : float + Relative and absolute residual tolerances for ``pc_converged``. + max_corrector_steps : int, default 100 + Maximum residual corrections for ``pc_converged``. Failure to reach + the requested tolerance raises ``RuntimeError``. tau : scalar expression, optional Explicit stabilisation parameter; zero gives Galerkin transport. tau_model : {"generic", "citcoms"}, optional @@ -128,12 +139,14 @@ class SNES_AdvectionDiffusion_SUPG(SNES_Scalar): CitcomS uses a clipped steady parameter on directional simplex lengths. Its automatic operations require triangles or tetrahedra. DuDt : Eulerian, optional - Pre-built implicit history manager with V_fn=None. Not used by CitcomS. + Pre-built implicit history manager with V_fn=None. Not used by either + predictor-corrector mode. verbose : bool, default False Solver verbosity. restore_points_func, monotone_mode, old_frame_traceback, DFDt SLCN-only compatibility arguments, ignored with a warning for - implicit transport. CitcomS rejects supplied history operators. + implicit transport. Predictor-corrector modes reject supplied history + operators. Notes ----- @@ -155,6 +168,11 @@ class SNES_AdvectionDiffusion_SUPG(SNES_Scalar): Its default timestep is 0.9*min(dt_adv, dt_diff), not the implicit field-change accuracy estimate. + ``pc_converged`` uses gamma=0.5 and the same update relation, but treats + the lumped mass only as a correction preconditioner. It converges the full + Petrov-Galerkin residual during rate initialisation and every timestep, + recovering the consistent semidiscrete trapezoidal update. + Implicit transport defaults to GMRES/ASM-ILU. preconditioner="fmg" selects geometric multigrid when a mesh hierarchy is available. CN may ring for under-resolved features; BDF3 may amplify pure advection. @@ -192,6 +210,9 @@ def __init__( temperature_rate_field: Optional[uw.discretisation.MeshVariable] = None, adv_gamma: float = 0.5, corrector_steps: int = 2, + corrector_rtol: float = 1.0e-10, + corrector_atol: float = 1.0e-12, + max_corrector_steps: int = 100, tau=None, tau_model: Optional[str] = None, ): @@ -200,19 +221,29 @@ def __init__( "u_Field must be a continuous MeshVariable: the SUPG weak form " "is continuous Galerkin." ) - if time_integrator not in ("implicit", "bdf", "citcoms"): - raise ValueError("time_integrator must be 'implicit', 'bdf' or 'citcoms'.") + pc_integrators = ("citcoms", "pc_converged") + if time_integrator not in ("implicit", "bdf", *pc_integrators): + raise ValueError( + "time_integrator must be 'implicit', 'bdf', 'citcoms' or " + "'pc_converged'." + ) if u_Field.num_components != 1: raise ValueError("u_Field must be scalar.") if mesh.dim != mesh.cdim: raise NotImplementedError("SUPG currently requires a volume mesh.") - if time_integrator == "citcoms": + if time_integrator in pc_integrators: if u_Field.degree != 1: - raise ValueError("The CitcomS predictor-corrector requires continuous P1 temperature.") + raise ValueError("Predictor-corrector transport requires continuous P1 temperature.") if order != 1 or (theta is not None and float(theta) != 1.0): - raise ValueError("CitcomS uses gamma, not order/theta; leave order=1 and theta unset.") + raise ValueError( + "Predictor-corrector transport uses gamma, not order/theta; " + "leave order=1 and theta unset." + ) if DuDt is not None or DFDt is not None: - raise ValueError("CitcomS manages its own derivative; do not supply DuDt or DFDt.") + raise ValueError( + "Predictor-corrector transport manages its own derivative; " + "do not supply DuDt or DFDt." + ) if not 0.0 < float(adv_gamma) <= 1.0: raise ValueError("adv_gamma must be in (0, 1].") if int(corrector_steps) != corrector_steps or corrector_steps < 1: @@ -225,18 +256,44 @@ def __init__( or temperature_rate_field.num_components != 1 ): raise ValueError("temperature_rate_field must be a separate continuous scalar P1 variable on the solver mesh.") - elif temperature_rate_field is not None or adv_gamma != 0.5 or corrector_steps != 2: - raise ValueError("temperature_rate_field, adv_gamma and corrector_steps configure CitcomS only.") - if time_integrator in ("bdf", "citcoms"): + if time_integrator == "pc_converged": + if float(adv_gamma) != 0.5: + raise ValueError("pc_converged requires adv_gamma=0.5 for second-order time accuracy.") + if corrector_steps != 2: + raise ValueError("corrector_steps configures fixed CitcomS corrections only.") + if not np.isfinite(float(corrector_rtol)) or float(corrector_rtol) <= 0.0: + raise ValueError("corrector_rtol must be finite and positive.") + if not np.isfinite(float(corrector_atol)) or float(corrector_atol) < 0.0: + raise ValueError("corrector_atol must be finite and non-negative.") + if (int(max_corrector_steps) != max_corrector_steps + or max_corrector_steps < 1): + raise ValueError("max_corrector_steps must be a positive integer.") + elif (corrector_rtol != 1.0e-10 or corrector_atol != 1.0e-12 + or max_corrector_steps != 100): + raise ValueError( + "corrector_rtol, corrector_atol and max_corrector_steps " + "configure pc_converged only." + ) + elif (temperature_rate_field is not None or adv_gamma != 0.5 + or corrector_steps != 2 or corrector_rtol != 1.0e-10 + or corrector_atol != 1.0e-12 or max_corrector_steps != 100): + raise ValueError( + "temperature_rate_field and predictor-corrector controls require " + "time_integrator='citcoms' or 'pc_converged'." + ) + if time_integrator in ("bdf", *pc_integrators): if theta is not None and float(theta) != 1.0: - raise ValueError("The bdf and citcoms modes require theta=1.0.") + raise ValueError("The bdf and predictor-corrector modes require theta=1.0.") theta = 1.0 if tau_model is None: - tau_model = "citcoms" if time_integrator == "citcoms" else "generic" + tau_model = "citcoms" if time_integrator in pc_integrators else "generic" if tau_model not in ("generic", "citcoms"): raise ValueError("tau_model must be 'generic' or 'citcoms'.") - if time_integrator == "citcoms" and tau_model != "citcoms": - raise ValueError("CitcomS requires its steady tau model; supply tau for a custom value.") + if time_integrator in pc_integrators and tau_model != "citcoms": + raise ValueError( + "Predictor-corrector transport requires the CitcomS steady tau " + "model; supply tau for a custom value." + ) ignored = [name for name, value in ( ("restore_points_func", restore_points_func), ("monotone_mode", monotone_mode), @@ -261,7 +318,7 @@ def __init__( # 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 = ("citcoms" if time_integrator == "citcoms" else + integrator = (time_integrator if time_integrator in pc_integrators else "bdf" if time_integrator == "bdf" or order > 1 else "am") if theta != 1.0 and order != 1: raise ValueError( @@ -277,6 +334,12 @@ def __init__( self.tau_model = tau_model self.adv_gamma = float(adv_gamma) self.corrector_steps = int(corrector_steps) + self.corrector_rtol = float(corrector_rtol) + self.corrector_atol = float(corrector_atol) + self.max_corrector_steps = int(max_corrector_steps) + self.last_corrector_iterations = 0 + self.last_corrector_residual = np.inf + self.corrector_target = np.inf self.f = sympy.Matrix.zeros(1, 1) self._integrator = integrator self._time_order = order @@ -299,7 +362,7 @@ def __init__( public_expression(rf"C^{{\tau}}_{{\kappa,{tag}}}", 4.0, "tau diffusive weight"), ] - if time_integrator == "citcoms": + if time_integrator in pc_integrators: self.Unknowns.DuDt = None elif DuDt is None: self.Unknowns.DuDt = Eulerian_DDt( @@ -361,7 +424,7 @@ def __init__( self._directional_rate_mesh_version = None self._diffusion_dt_cache = None self._rate_initialised = False - if time_integrator == "citcoms": + if time_integrator in pc_integrators: self._temperature_rate = temperature_rate_field if self._temperature_rate is None: self._temperature_rate = uw.discretisation.MeshVariable( @@ -496,8 +559,8 @@ def theta(self, value): "theta applies at order 1 only (0.5 is Crank-Nicolson, 1.0 is " "backward Euler); order 2 and 3 take theta=1.0." ) - if self.time_integrator == "citcoms" and value != 1.0: - raise ValueError("CitcomS uses adv_gamma, not theta.") + if self.time_integrator in ("citcoms", "pc_converged") and value != 1.0: + raise ValueError("Predictor-corrector transport uses adv_gamma, not theta.") self._theta = value if self.DuDt is not None: self.DuDt.theta = value @@ -569,13 +632,13 @@ def tau_weights(self, values): def _states(self): r"""``[phi^{n+1}, phi^{n}, phi^{n-1}, ...]`` as scalar field symbols.""" - if self.time_integrator == "citcoms": + if self.time_integrator in ("citcoms", "pc_converged"): return [self.u.sym[0]] 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``.""" - if self.time_integrator == "citcoms": + if self.time_integrator in ("citcoms", "pc_converged"): return [sympy.Integer(1)] n = len(self.DuDt.psi_star) if self._integrator == "bdf": @@ -583,7 +646,7 @@ def _spatial_weights(self): return self.DuDt.am_coefficient_expressions[: n + 1] def _time_derivative(self): - if self.time_integrator == "citcoms": + if self.time_integrator in ("citcoms", "pc_converged"): return self._temperature_rate.sym[0] if self._integrator == "bdf": return self.DuDt.bdf()[0] / self._delta_t @@ -715,11 +778,17 @@ def estimate_dt(self, fraction: float = 0.02, basis: Optional[str] = None, """ from mpi4py import MPI - if self.time_integrator == "citcoms": + if self.time_integrator in ("citcoms", "pc_converged"): if basis not in (None, "stability"): - raise ValueError("CitcomS requires basis='stability', not an implicit accuracy estimate.") + raise ValueError( + "Predictor-corrector transport requires basis='stability', " + "not an implicit accuracy estimate." + ) if fraction != 0.02 or direction_aware or percentile != 0.0: - raise ValueError("CitcomS uses its fixed 0.9 stability factor and directional simplex length.") + raise ValueError( + "Predictor-corrector transport uses its fixed 0.9 stability " + "factor and directional simplex length." + ) return _dimensionalise_dt(self._estimate_citcoms_dt()) if basis is None: basis = "accuracy" @@ -797,8 +866,8 @@ def solve( if _force_setup: self._needs_function_rewire = True - if self.time_integrator == "citcoms": - return self._solve_citcoms(dt, verbose=verbose) + if self.time_integrator in ("citcoms", "pc_converged"): + return self._solve_predictor_corrector(dt, verbose=verbose) self._update_automatic_tau() if not self.constitutive_model._solver_is_setup: self._needs_function_rewire = True @@ -826,7 +895,7 @@ def solve( @property def temperature_rate(self): - """Stored derivative for CitcomS, or None for an implicit method.""" + """Stored predictor-corrector derivative, or None for an implicit method.""" return self._temperature_rate @property @@ -839,6 +908,9 @@ def state(self): last_change_rate=self._last_change_rate, order=self.order, theta=self.theta, adv_gamma=self.adv_gamma, corrector_steps=self.corrector_steps, + corrector_rtol=self.corrector_rtol, + corrector_atol=self.corrector_atol, + max_corrector_steps=self.max_corrector_steps, ) @state.setter @@ -848,7 +920,10 @@ def state(self, state): if (state.time_integrator != self.time_integrator or state.order != self.order or state.adv_gamma != self.adv_gamma - or state.corrector_steps != self.corrector_steps): + or state.corrector_steps != self.corrector_steps + or state.corrector_rtol != self.corrector_rtol + or state.corrector_atol != self.corrector_atol + or state.max_corrector_steps != self.max_corrector_steps): raise ValueError("AdvDiffusionSUPG integration settings changed since snapshot.") self.theta = state.theta self._rate_initialised = bool(state.rate_initialised) @@ -1076,7 +1151,7 @@ def _citcoms_vectors(self): def _estimate_citcoms_dt(self): """Estimate a simplex advection-diffusion timestep. - The CitcomS-compatible predictor-corrector uses + The predictor-corrector modes use ``0.9 * min(1/max(lambda_adv), 2/max(rowsum(abs(M_L^-1 K))))``. Generic implicit transport retains its separate Eulerian estimator. """ @@ -1176,8 +1251,86 @@ def _compute_citcoms_residual(self, solution=None, residual=None): self.snes.computeFunction(solution, residual) return solution, residual - def _solve_citcoms(self, timestep, verbose=False): - """Advance one CitcomS-compatible predictor-corrector timestep.""" + def _apply_pc_correction( + self, + temperature_global, + residual, + delta_rate, + rate_global, + mass, + dt, + *, + advance_temperature, + ): + """Apply one lumped-preconditioned correction to rate and temperature.""" + delta_rate.pointwiseDivide(residual, mass) + delta_rate.scale(-1.0) + rate_global.set(0.0) + self.dm.localToGlobal(self._temperature_rate.vec, rate_global, addv=False) + rate_global.axpy(1.0, delta_rate) + if advance_temperature: + temperature_global.axpy(self.adv_gamma * dt, delta_rate) + + self._temperature_rate.vec.set(0.0) + self.dm.globalToLocal(rate_global, self._temperature_rate.vec) + if advance_temperature: + from underworld3.cython.petsc_discretisation import ( + petsc_dm_insert_boundary_values, + ) + + self.u.vec.set(0.0) + self.dm.globalToLocal(temperature_global, self.u.vec) + petsc_dm_insert_boundary_values(self.dm, self.u.vec) + self.mesh._stale_lvec = True + + def _converge_pc_residual( + self, + temperature_global, + residual, + delta_rate, + rate_global, + mass, + dt, + *, + advance_temperature, + ): + """Iterate the predictor-corrector residual to its configured tolerance.""" + initial_norm = None + for corrections in range(self.max_corrector_steps + 1): + self._compute_citcoms_residual(temperature_global, residual) + residual_norm = float(residual.norm(PETSc.NormType.NORM_2)) + if not np.isfinite(residual_norm): + raise RuntimeError("pc_converged produced a non-finite residual norm.") + if initial_norm is None: + initial_norm = residual_norm + self.corrector_target = max( + self.corrector_atol, + self.corrector_rtol * initial_norm, + ) + self.last_corrector_iterations = corrections + self.last_corrector_residual = residual_norm + if residual_norm <= self.corrector_target: + return + if corrections == self.max_corrector_steps: + break + self._apply_pc_correction( + temperature_global, + residual, + delta_rate, + rate_global, + mass, + dt, + advance_temperature=advance_temperature, + ) + raise RuntimeError( + "pc_converged did not reach its predictor-corrector residual " + f"tolerance after {self.max_corrector_steps} corrections: " + f"residual={self.last_corrector_residual:.6e}, " + f"target={self.corrector_target:.6e}." + ) + + def _solve_predictor_corrector(self, timestep, verbose=False): + """Advance one fixed or residual-converged predictor-corrector step.""" if timestep is None: timestep = float(self.delta_t.data) self.delta_t = timestep @@ -1192,12 +1345,24 @@ def _solve_citcoms(self, timestep, verbose=False): if not self._rate_initialised: self._temperature_rate.array[:, 0, 0] = 0.0 - self._compute_citcoms_residual(temperature_global, residual) - delta_rate.pointwiseDivide(residual, mass) - delta_rate.scale(-1.0) - self._temperature_rate.vec.set(0.0) - self.dm.globalToLocal(delta_rate, self._temperature_rate.vec) - self.mesh._stale_lvec = True + if self.time_integrator == "pc_converged": + self.mesh._stale_lvec = True + self._converge_pc_residual( + temperature_global, + residual, + delta_rate, + rate_global, + mass, + dt, + advance_temperature=False, + ) + else: + self._compute_citcoms_residual(temperature_global, residual) + delta_rate.pointwiseDivide(residual, mass) + delta_rate.scale(-1.0) + self._temperature_rate.vec.set(0.0) + self.dm.globalToLocal(delta_rate, self._temperature_rate.vec) + self.mesh._stale_lvec = True self._rate_initialised = True self.u.array[:, 0, 0] += ( @@ -1206,26 +1371,34 @@ def _solve_citcoms(self, timestep, verbose=False): self._temperature_rate.array[:, 0, 0] = 0.0 self.mesh._stale_lvec = True - from underworld3.cython.petsc_discretisation import ( - petsc_dm_insert_boundary_values, - ) - - for _ in range(self.corrector_steps): - self._compute_citcoms_residual(temperature_global, residual) - delta_rate.pointwiseDivide(residual, mass) - delta_rate.scale(-1.0) - - rate_global.set(0.0) - self.dm.localToGlobal(self._temperature_rate.vec, rate_global, addv=False) - rate_global.axpy(1.0, delta_rate) - temperature_global.axpy(self.adv_gamma * dt, delta_rate) + if self.time_integrator == "pc_converged": + from underworld3.cython.petsc_discretisation import ( + petsc_dm_insert_boundary_values, + ) - self._temperature_rate.vec.set(0.0) - self.u.vec.set(0.0) - self.dm.globalToLocal(rate_global, self._temperature_rate.vec) - self.dm.globalToLocal(temperature_global, self.u.vec) petsc_dm_insert_boundary_values(self.dm, self.u.vec) self.mesh._stale_lvec = True + self._converge_pc_residual( + temperature_global, + residual, + delta_rate, + rate_global, + mass, + dt, + advance_temperature=True, + ) + else: + for _ in range(self.corrector_steps): + self._compute_citcoms_residual(temperature_global, residual) + self._apply_pc_correction( + temperature_global, + residual, + delta_rate, + rate_global, + mass, + dt, + advance_temperature=True, + ) _invalidate_solution_cache(self.u) _invalidate_solution_cache(self._temperature_rate) diff --git a/tests/parallel/ptest_1119_supg_restart.py b/tests/parallel/ptest_1119_supg_restart.py index 5eb4a68ed..9b5dc2ef3 100644 --- a/tests/parallel/ptest_1119_supg_restart.py +++ b/tests/parallel/ptest_1119_supg_restart.py @@ -11,7 +11,7 @@ uw_method=uw.Param("pc2", type=uw.ParamType.STRING), uw_phase=uw.Param("full", type=uw.ParamType.STRING), ) -assert params.uw_method in ("pc2", "cn", "bdf2") +assert params.uw_method in ("pc2", "pc_converged", "cn", "bdf2") assert params.uw_phase in ("full", "write", "resume") uw.reset_default_model() orchestration_model = uw.get_default_model() @@ -25,6 +25,8 @@ velocity.array[...] = 0.0 velocity.array[:, 0, 0] = 0.2 settings = ({"time_integrator": "citcoms"} if params.uw_method == "pc2" + else {"time_integrator": "pc_converged"} + if params.uw_method == "pc_converged" else {"order": 1, "theta": 0.5} if params.uw_method == "cn" else {"order": 2}) thermal = uw.systems.AdvDiffusionSUPG(mesh, temperature, velocity.sym, **settings) diff --git a/tests/test_1113_advdiff_supg_residual.py b/tests/test_1113_advdiff_supg_residual.py index 968f05f9a..74d80bcb3 100644 --- a/tests/test_1113_advdiff_supg_residual.py +++ b/tests/test_1113_advdiff_supg_residual.py @@ -146,6 +146,58 @@ def test_citcoms_integrator_requires_continuous_p1_temperature(): ) +def test_converged_pc_validates_correction_controls(): + mesh, temperature, velocity = _mesh_temperature_velocity("pc_converged_api") + thermal = uw.systems.AdvDiffusionSUPG( + mesh, + u_Field=temperature, + V_fn=velocity.sym, + time_integrator="pc_converged", + ) + assert thermal.integrator == "pc_converged" + assert thermal.corrector_rtol == pytest.approx(1.0e-10) + assert thermal.corrector_atol == pytest.approx(1.0e-12) + assert thermal.max_corrector_steps == 100 + + invalid = ( + ({"corrector_rtol": 0.0}, "corrector_rtol"), + ({"corrector_atol": -1.0}, "corrector_atol"), + ({"max_corrector_steps": 0}, "max_corrector_steps"), + ({"adv_gamma": 0.6}, "adv_gamma=0.5"), + ) + for kwargs, message in invalid: + with pytest.raises(ValueError, match=message): + uw.systems.AdvDiffusionSUPG( + mesh, + u_Field=temperature, + V_fn=velocity.sym, + time_integrator="pc_converged", + **kwargs, + ) + + +def test_converged_pc_fails_when_residual_tolerance_is_not_reached(): + mesh, temperature, velocity = _mesh_temperature_velocity( + "pc_converged_failure", velocity=(0.0, 0.0) + ) + temperature.array[:, 0, 0] = np.prod( + np.sin(np.pi * np.asarray(temperature.coords)), axis=1 + ) + thermal = uw.systems.AdvDiffusionSUPG( + mesh, + u_Field=temperature, + V_fn=velocity.sym, + time_integrator="pc_converged", + corrector_rtol=1.0e-15, + corrector_atol=0.0, + max_corrector_steps=1, + ) + _configure_diffusion(thermal, diffusivity=0.1) + + with pytest.raises(RuntimeError, match="did not reach"): + thermal.solve(timestep=0.01) + + def test_citcoms_lumped_mass_matches_constant_residual(): mesh, temperature, velocity = _mesh_temperature_velocity( "citcoms_mass", velocity=(0.0, 0.0) diff --git a/tests/test_1118_pc2_diffusion_time.py b/tests/test_1118_pc2_diffusion_time.py index b5e301043..61f54cf60 100644 --- a/tests/test_1118_pc2_diffusion_time.py +++ b/tests/test_1118_pc2_diffusion_time.py @@ -214,3 +214,48 @@ def test_uw3_cn_is_second_order_for_discrete_diffusion(diffusion): rates = _orders(errors) assert np.all((1.9 < rates) & (rates < 2.2)), rates uw.pprint(f"UW3_CN_TIME_ORDER dim={pc2.mesh.dim} rates={rates.tolist()}") + + +def test_converged_pc_is_second_order_for_discrete_diffusion(diffusion): + pc2, _, ids, mass, _, initial, eigenvalues, _ = diffusion + temperature = uw.discretisation.MeshVariable( + "T_pc_converged", pc2.mesh, 1, degree=1) + thermal = uw.systems.AdvDiffusionSUPG( + pc2.mesh, + temperature, + pc2.V_fn, + time_integrator="pc_converged", + corrector_rtol=1.0e-12, + corrector_atol=1.0e-14, + max_corrector_steps=200, + ) + thermal.constitutive_model.Parameters.diffusivity = 0.1 + initial_state = thermal.state + exact = initial * np.exp(-0.1 * eigenvalues[1]) + errors = [] + for steps in (4, 8, 16): + temperature.array[:, 0, 0] = initial[ids] + thermal.temperature_rate.array[...] = 0.0 + thermal.state = initial_state + dt = 0.1 / steps + for _ in range(steps): + thermal.solve(timestep=dt) + actual = np.zeros(len(initial)) + for local_ids, values in uw.mpi.comm.allgather( + (ids, np.array(temperature.array[:, 0, 0]))): + actual[local_ids] = values + factor = (1 - 0.5 * dt * eigenvalues[1]) / (1 + 0.5 * dt * eigenvalues[1]) + expected = initial * factor**steps + map_error = _norm(actual - expected, mass) / _norm(expected, mass) + errors.append(_norm(actual - exact, mass) / _norm(exact, mass)) + assert map_error < 1.0e-9, map_error + assert thermal.last_corrector_iterations <= thermal.max_corrector_steps + assert thermal.last_corrector_residual <= thermal.corrector_target + uw.pprint( + f"PC_CONVERGED_DIFFUSION dim={pc2.mesh.dim} steps={steps} " + f"dt={dt:.12g} relative_error={errors[-1]:.12g} " + f"map_error={map_error:.12g} corrections={thermal.last_corrector_iterations} " + f"residual={thermal.last_corrector_residual:.12g}") + rates = _orders(errors) + assert np.all((1.9 < rates) & (rates < 2.2)), rates + uw.pprint(f"PC_CONVERGED_TIME_ORDER dim={pc2.mesh.dim} rates={rates.tolist()}") diff --git a/tests/test_1119_supg_process_restart.py b/tests/test_1119_supg_process_restart.py index 1b30190b3..d0ca1c9b0 100644 --- a/tests/test_1119_supg_process_restart.py +++ b/tests/test_1119_supg_process_restart.py @@ -1,4 +1,4 @@ -"""Fresh-process transport snapshots: pc2, CN and BDF2 on tiny tetrahedra. +"""Fresh-process transport snapshots on tiny tetrahedra. Run this parent pytest in serial. UW_SUPG_TEST_RANKS=8 requests eight-rank workers; the default uses singleton workers. Every phase starts a fresh @@ -21,7 +21,7 @@ pytestmark = [pytest.mark.level_2, pytest.mark.tier_b] -@pytest.mark.parametrize("method", ["pc2", "cn", "bdf2"]) +@pytest.mark.parametrize("method", ["pc2", "pc_converged", "cn", "bdf2"]) def test_fresh_process_transport_restart(method, tmp_path): if uw.mpi.size != 1: pytest.skip("Run the parent in serial; UW_SUPG_TEST_RANKS selects worker ranks.") diff --git a/tests/test_1120_supg_memory.py b/tests/test_1120_supg_memory.py index 5e5aa974f..727913bba 100644 --- a/tests/test_1120_supg_memory.py +++ b/tests/test_1120_supg_memory.py @@ -21,7 +21,7 @@ def _workspace(thermal): """Record identities, not contents that should change during transport.""" identity = [thermal.snes.handle, thermal.dm.handle, tuple((name, field.vec.handle) for name, field in thermal.mesh.vars.items())] - if thermal.time_integrator == "citcoms": + if thermal.time_integrator in ("citcoms", "pc_converged"): identity.extend([ thermal._lumped_mass.handle, tuple(vector.handle for vector in thermal._citcoms_work_vectors), @@ -43,6 +43,8 @@ def _transport_problem(dim, method): velocity.array[...] = 0.0 velocity.array[:, 0, 0] = 0.2 settings = ({"time_integrator": "citcoms"} if method == "pc2" + else {"time_integrator": "pc_converged"} + if method == "pc_converged" else {"order": 1, "theta": 0.5} if method == "cn" else {"order": 2}) thermal = uw.systems.AdvDiffusionSUPG( @@ -63,7 +65,7 @@ def _advance(thermal, velocity, step): @pytest.mark.level_2 @pytest.mark.parametrize("dim", [2, 3]) -@pytest.mark.parametrize("method", ["pc2", "cn", "bdf2"]) +@pytest.mark.parametrize("method", ["pc2", "pc_converged", "cn", "bdf2"]) def test_transport_workspace_reuse(dim, method): thermal, temperature, velocity = _transport_problem(dim, method) _advance(thermal, velocity, 1) From 0e90dcb1096ef02d833617142eb98d83fddd92b0 Mon Sep 17 00:00:00 2001 From: Tyagi Date: Sat, 5 Sep 2026 23:08:01 +1000 Subject: [PATCH 32/35] fix: infer mesh cell family collectively on empty MPI ranks DMPlexIsSimplex returns false on an empty partition. Using that local value for coordinate FE construction mixed simplex and tensor bases across the same communicator, consuming different PETSc message tags. A subsequent mesh HDF5 labelsLoad then blocked in PetscSFSetUp_Basic/MPI_Waitall. Gather cell-family decisions from populated ranks before FE setup and use the same classification for element metadata. Preserve the constructor hint for an entirely empty mesh; do not change SUPG algorithms, solver tolerances or MPI providers. Add single-cell triangle/tet/quad/hex regressions that require empty ranks and subsequently load another mesh, checking P2 volume and boundary integrals. The triangle regression failed before the fix. The original SUPG migration/partition sequence plus all four regressions passed on eight Mac ranks: 15 tests in 26.05 s, 32.30 s including launcher, 2.74 GiB peak process-tree RSS. The pre-fix sequence hung and the mesh-only reproducer also hung. Gadi rerun remains pending. --- docs/developer/subsystems/meshing.md | 20 ++++++- .../discretisation/discretisation_mesh.py | 16 ++++-- .../test_0781_empty_rank_mesh_sequence.py | 53 +++++++++++++++++++ 3 files changed, 85 insertions(+), 4 deletions(-) create mode 100644 tests/parallel/test_0781_empty_rank_mesh_sequence.py diff --git a/docs/developer/subsystems/meshing.md b/docs/developer/subsystems/meshing.md index dfcc44416..be83085c1 100644 --- a/docs/developer/subsystems/meshing.md +++ b/docs/developer/subsystems/meshing.md @@ -33,6 +33,24 @@ The meshing subsystem handles computational mesh generation and manipulation for - QuadBox / HexBox # Structured meshes ``` +## Empty MPI Partitions + +Use `mesh.isSimplex` for the mesh-wide cell family. PETSc's +`mesh.dm.isSimplex()` is a rank-local query and returns `False` on a rank +with no cells, even when the distributed mesh consists of triangles or +tetrahedra. UW3 infers the family collectively from populated ranks before +constructing coordinate finite elements or element metadata. + +This is also an MPI correctness requirement: constructing simplex and +tensor-product coordinate elements on the same communicator consumes +different PETSc message tags. The first mesh may appear to construct +successfully, but a later HDF5 boundary-label load can deadlock in +`PetscSFSetUp_Basic`. This failure does not require a transport solver. + +`tests/parallel/test_0781_empty_rank_mesh_sequence.py` covers triangles, +tetrahedra, quadrilaterals and hexahedra with deliberately empty partitions, +then verifies a second mesh's volume and boundary integrals using P2 data. + ## Documentation Needs ### Critical Gaps @@ -71,4 +89,4 @@ This section needs: --- -*This document serves as a placeholder for comprehensive meshing system documentation.* \ No newline at end of file +*This document serves as a placeholder for comprehensive meshing system documentation.* diff --git a/src/underworld3/discretisation/discretisation_mesh.py b/src/underworld3/discretisation/discretisation_mesh.py index c0e65cf82..ee304a28b 100644 --- a/src/underworld3/discretisation/discretisation_mesh.py +++ b/src/underworld3/discretisation/discretisation_mesh.py @@ -589,9 +589,19 @@ def __init__( self._setup_symbolic_coordinates(coordinate_system_type) try: - self.isSimplex = self.dm.isSimplex() + local_simplex = self.dm.isSimplex() except: - self.isSimplex = simplex + local_simplex = simplex + + # DMPlexIsSimplex is rank-local and returns False on empty ranks. + # Coordinate FE construction must use one cell family everywhere; + # mixed simplex/tensor construction desynchronises PETSc MPI tags. + cell_start, cell_end = self.dm.getHeightStratum(0) + cell_families = self.dm.comm.tompi4py().allgather( + local_simplex if cell_end > cell_start else None + ) + populated_families = [family for family in cell_families if family is not None] + self.isSimplex = all(populated_families) if populated_families else simplex # Using WeakValueDictionary to prevent circular references self._vars = weakref.WeakValueDictionary() @@ -650,7 +660,7 @@ class ElementInfo: entities: tuple face_entities: tuple - if self.dm.isSimplex(): + if self.isSimplex: if self.dim == 2: self._element = ElementInfo("triangle", (1, 3, 3), (0, 1, 2)) else: diff --git a/tests/parallel/test_0781_empty_rank_mesh_sequence.py b/tests/parallel/test_0781_empty_rank_mesh_sequence.py new file mode 100644 index 000000000..68e7f2a54 --- /dev/null +++ b/tests/parallel/test_0781_empty_rank_mesh_sequence.py @@ -0,0 +1,53 @@ +"""Empty partitions must not change the cell family or poison later mesh loads. + +The original failure appeared in SUPG test 1077 after the empty-partition +test: rank-local DMPlexIsSimplex returned False on empty ranks, so the +coordinate FE consumed different COMM_WORLD tags. The next HDF5 label load +then hung in PetscSFSetUp_Basic. No transport solve is needed to reproduce it. +""" + +import numpy as np +import pytest +from petsc4py import PETSc + +import underworld3 as uw + +pytestmark = [pytest.mark.mpi(min_size=2), pytest.mark.level_2, + pytest.mark.tier_b, pytest.mark.timeout(120)] + + +@pytest.mark.parametrize("dim", [2, 3]) +@pytest.mark.parametrize("simplex", [True, False]) +def test_cell_family_and_next_mesh_load_on_empty_ranks(dim, simplex): + if simplex: + coords = np.vstack([np.zeros(dim), np.eye(dim)]) + cells = np.arange(dim + 1, dtype=PETSc.IntType).reshape(1, -1) + dm = PETSc.DMPlex().createFromCellList(dim, cells, coords) + else: + dm = PETSc.DMPlex().createBoxMesh([1] * dim, simplex=False) + first = uw.discretisation.Mesh(dm, simplex=simplex, qdegree=3) + start, end = first.dm.getHeightStratum(0) + counts = uw.mpi.comm.allgather(end - start) + assert min(counts) == 0 and sum(counts) == 1, counts + + families = uw.mpi.comm.allgather(first.isSimplex) + assert families == [simplex] * uw.mpi.size, families + expected = { (2, True): "triangle", (3, True): "tetrahedron", + (2, False): "quadrilateral", (3, False): "hexahedron" } + elements = uw.mpi.comm.allgather(first._element.type) + assert elements == [expected[dim, simplex]] * uw.mpi.size, elements + + # This public constructor exercises the HDF5 label SF exchange that hung. + second = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0,) * dim, maxCoords=(1.0,) * dim, + cellSize=0.25, qdegree=3, regular=False, + ) + field = uw.discretisation.MeshVariable("T", second, 1, degree=2) + field.array[:, 0, 0] = 1.0 + volume = uw.maths.Integral(second, field.sym[0]).evaluate() + assert np.isclose(volume, 1.0, rtol=1e-12, atol=1e-12), volume + for boundary in second.boundaries: + if boundary.name in ("Null_Boundary", "All_Boundaries"): + continue + area = uw.maths.BdIntegral(second, field.sym[0], boundary=boundary.name).evaluate() + assert np.isclose(area, 1.0, rtol=1e-12, atol=1e-12), (boundary.name, area) From 0f6b78fa530ac98bcc968666927865ffa5ac2fb2 Mon Sep 17 00:00:00 2001 From: Tyagi Date: Sun, 6 Sep 2026 01:17:47 +1000 Subject: [PATCH 33/35] fix: make stabilization cell sizes local to each cell (#687) Adapt only the mesh-size correction from lmoresi's 68e545fd on feature/navier-stokes-supg; do not import Navier-Stokes or other branch changes. Cache _radii_own from current DM vertex coordinates and use it for mesh.cell_size(). Preserve the legacy kd-tree radius arrays and global timestep/mesh-motion consumers. Use coordinate-section offsets and the full vertex stratum so the own-cell RMS definition also handles hexahedra, which have eight vertices but six faces. Correct the field documentation and Nitsche mechanism tests for the new definition; retain physical solve tolerances and use the exact nearest-centroid <= own-centroid ordering instead of an arbitrary approximate-equality tolerance. Add a first-failing independent geometry/deformation regression for triangles, tetrahedra, quadrilaterals and hexahedra plus a regular-square analytical control. Before: four failures in serial and on eight ranks. After rebuild: 21 passed/one expected skip serial (22.90 s), 22 passed on eight ranks (40.45 s), covering Nitsche solves, radius accessors, frozen PC2 migration and memory/disk snapshots. Own-cell geometry error is zero in these tests; style and whitespace gates pass. --- .../discretisation/discretisation_mesh.py | 48 +++++++------- tests/test_0010_cell_size_geometry.py | 66 +++++++++++++++++++ tests/test_1065_nitsche_local_h.py | 23 +++---- 3 files changed, 103 insertions(+), 34 deletions(-) create mode 100644 tests/test_0010_cell_size_geometry.py diff --git a/src/underworld3/discretisation/discretisation_mesh.py b/src/underworld3/discretisation/discretisation_mesh.py index ee304a28b..c15a24c64 100644 --- a/src/underworld3/discretisation/discretisation_mesh.py +++ b/src/underworld3/discretisation/discretisation_mesh.py @@ -3234,7 +3234,8 @@ def cell_size(self): Returns the ``.sym`` of a cell-constant (degree-0, discontinuous) scalar MeshVariable holding each cell's characteristic length (the - ``volume**(1/dim)`` equivalent radius, i.e. ``self._radii``). Unlike + RMS distance of its vertices from their own centroid). This is a + purely cell-local quantity, independent of the MPI partition. Unlike the single *global* scalar from :meth:`get_min_radius` (the smallest cell anywhere), this varies cell to cell, so a stabilisation that scales as :math:`1/h` — e.g. the Nitsche free-slip penalty @@ -3300,9 +3301,9 @@ def _refresh(): def _assemble_cell_size(self, var): """Fill ``var`` (degree-0 scalar) with each cell's characteristic size. - Uses the per-cell characteristic lengths ``self._radii`` computed by + Uses the own-cell characteristic lengths ``self._radii_own`` computed by :meth:`_get_mesh_sizes` on the *current* geometry. A degree-0 - discontinuous variable's local DOFs and ``self._radii`` are BOTH + discontinuous variable's local DOFs and ``self._radii_own`` are BOTH indexed by this rank's cell-stratum order, so a direct assignment is correct on every rank. @@ -3310,24 +3311,9 @@ def _assemble_cell_size(self, var): access, no collective): mixing a rank-local fast path with a collective fallback would diverge across ranks and deadlock, because ``var.coords`` triggers the collective ``_get_coords_for_basis``.""" - # TODO(BUG): this field is PARTITION-DEPENDENT, and so therefore is the - # Nitsche penalty gamma*mu/h that consumes it (local_h=True, the default). - # Not the indexing here — the values. `_get_mesh_sizes` measures a cell by - # the distance from its vertices to the NEAREST CENTROID in a kd-tree built - # from THIS RANK's centroids, so near a partition seam the nearest centroid - # may simply be absent. Measured on Annulus(cellSize=0.12): the field's sum - # is 26.0822 at np=1, 26.1211 at np=2 and 26.1386 at np=4, and its max moves - # at np=4. End to end that is 6.6e-03 in the velocity of a Nitsche free-slip - # annulus and it does NOT shrink with solver tolerance. - # This is a DIFFERENT defect from the boundary normal fixed for #564 (which - # is now clean: the same solve with local_h=False agrees to 3.6e-10 at - # np=1..4). It is the local h that is left, and it also reaches every other - # consumer of `cell_size()`. Not fixed here because `_get_mesh_sizes` also - # feeds `get_min_radius`, the adaptivity metrics and the free-surface - # relaxation, and it needs its own benchmarking. - # Guard/measurement: tests/parallel/test_1069_boundary_normal_parallel.py - # (_nitsche_annulus_diagnostics docstring records the numbers). - radii = numpy.asarray(self._radii).reshape(-1) + # Own-cell radii fix #687 without changing the legacy kd-tree radii + # used by global timestep estimates, adaptivity, and mesh relaxation. + radii = numpy.asarray(self._radii_own).reshape(-1) # Empty partition (no local cells): nothing to fill on this rank. if radii.size == 0 or var.data.shape[0] == 0: return @@ -6895,8 +6881,11 @@ def _eval_use_robust_location(self) -> bool: def _get_mesh_sizes(self, verbose=False): """ - Obtain the (local) mesh radii and centroids using kdtree distances - This routine is called when the mesh is built / rebuilt + Cache own-cell radii for cell_size and return legacy kd-tree radii. + + Own-cell sizes use current DM vertices, so neither partition-local + neighbours nor stale coordinate views affect stabilization (#687). + Legacy radii remain unchanged for their other consumers. """ centroids = self._get_coords_for_basis(0, False) @@ -6909,6 +6898,9 @@ def _get_mesh_sizes(self, verbose=False): cell_length = np.empty(centroids.shape[0]) cell_min_r = np.empty(centroids.shape[0]) cell_r = np.empty(centroids.shape[0]) + cell_r_own = np.empty(centroids.shape[0]) + coordinate_section = self.dm.getCoordinateDM().getLocalSection() + vertex_coordinates = self.dm.getCoordinatesLocal().array for cell in range(cEnd - cStart): cell_num_points = self.dm.getConeSize(cell) @@ -6922,6 +6914,16 @@ def _get_mesh_sizes(self, verbose=False): cell_r[cell] = np.sqrt(distsq.mean()) cell_min_r[cell] = np.sqrt(distsq.min()) + # A hex has six faces but eight vertices: select the vertex + # stratum, not a cone-sized suffix of its transitive closure. + closure = self.dm.getTransitiveClosure(cStart + cell)[0] + vertices = closure[(closure >= pStart) & (closure < pEnd)] + offsets = np.array([coordinate_section.getOffset(int(v)) for v in vertices]) + own_coords = vertex_coordinates[offsets[:, None] + np.arange(self.cdim)] + delta = own_coords - own_coords.mean(axis=0) + cell_r_own[cell] = np.sqrt(np.mean(np.sum(delta ** 2, axis=1))) + + self._radii_own = cell_r_own return cell_min_r, cell_r, centroids, cell_length # ========== diff --git a/tests/test_0010_cell_size_geometry.py b/tests/test_0010_cell_size_geometry.py new file mode 100644 index 000000000..9e955328e --- /dev/null +++ b/tests/test_0010_cell_size_geometry.py @@ -0,0 +1,66 @@ +"""Issue #687: cell_size is an own-cell geometric quantity, including after deform. + +The independent oracle reads vertex coordinates through the coordinate section; +it does not use the mesh's cached radii or centroid kd-tree. Run serial and MPI. +""" + +import numpy as np +import pytest + +import underworld3 as uw + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_b] + + +def _vertex_rms(mesh): + dm = mesh.dm + section = dm.getCoordinateDM().getLocalSection() + coordinates = dm.getCoordinatesLocal().array + start, end = dm.getHeightStratum(0) + first_vertex, last_vertex = dm.getDepthStratum(0) + radii = [] + for cell in range(start, end): + vertices = [int(point) for point in dm.getTransitiveClosure(cell)[0] + if first_vertex <= point < last_vertex] + points = np.array([coordinates[section.getOffset(v):section.getOffset(v) + mesh.cdim] + for v in vertices]) + radii.append(np.sqrt(np.mean(np.sum((points - points.mean(axis=0)) ** 2, axis=1)))) + return np.asarray(radii) + + +@pytest.mark.parametrize("dim", [2, 3]) +@pytest.mark.parametrize("simplex", [True, False], ids=["simplex", "tensor"]) +def test_cell_size_matches_own_vertices_and_tracks_deform(dim, simplex): + geometry = dict(minCoords=(0.0,) * dim, maxCoords=(1.0,) * dim, qdegree=3) + mesh = (uw.meshing.UnstructuredSimplexBox(**geometry, cellSize=0.25, regular=False) + if simplex else uw.meshing.StructuredQuadBox(**geometry, elementRes=(4,) * dim)) + mesh.cell_size() + field = mesh._cell_size_variable + errors = [] + for phase in ("initial", "deformed"): + if phase == "deformed": + coordinates = np.array(mesh.X.coords) + coordinates[:, 0] = 1.7 * coordinates[:, 0] + 0.2 * coordinates[:, 1] + mesh.deform(coordinates) + expected = _vertex_rms(mesh) + actual = np.asarray(field.array[:, 0, 0]) + shapes_match = actual.shape == expected.shape + assert all(uw.mpi.comm.allgather(shapes_match)), (actual.shape, expected.shape) + local_error = float(np.abs(actual - expected).max(initial=0.0)) + error = max(uw.mpi.comm.allgather(local_error)) + errors.append(error) + uw.pprint(f"CELL_SIZE_GEOMETRY dim={dim} simplex={simplex} phase={phase} " + f"ranks={uw.mpi.size} max_error={error:.12g}") + assert max(errors) < 1e-12, errors + + +def test_regular_square_cell_size_keeps_global_radius(): + mesh = uw.meshing.StructuredQuadBox(elementRes=(4, 4), qdegree=2) + legacy = np.array(mesh._radii) + global_radius = mesh.get_min_radius() + mesh.cell_size() + expected = np.sqrt(2.0) / 8.0 + error = float(np.abs(np.asarray(mesh._cell_size_variable.array) - expected).max(initial=0.0)) + assert max(uw.mpi.comm.allgather(error)) < 1e-12 + assert global_radius == pytest.approx(expected, rel=1e-12) + assert all(uw.mpi.comm.allgather(np.array_equal(mesh._radii, legacy))) diff --git a/tests/test_1065_nitsche_local_h.py b/tests/test_1065_nitsche_local_h.py index 15c0bcb3b..971130a0f 100644 --- a/tests/test_1065_nitsche_local_h.py +++ b/tests/test_1065_nitsche_local_h.py @@ -152,11 +152,11 @@ def _box_wobble(X0, amp): # -------------------------------------------------------------------------- def test_cell_size_is_local_per_cell(): """``mesh.cell_size()`` is a per-cell field equal to each cell's - characteristic size (``mesh._radii``), not the single global minimum.""" + own-vertex RMS size (``mesh._radii_own``), not the single global minimum.""" mesh = _graded_box() h = mesh.cell_size() # sympy symbol -> backed by a P0 field - field = np.asarray(mesh._cell_size_variable.data[:, 0]).reshape(-1) - radii = np.asarray(mesh._radii).reshape(-1) + field = np.asarray(mesh._cell_size_variable.array[:, 0, 0]).reshape(-1) + radii = np.asarray(mesh._radii_own).reshape(-1) # field exactly mirrors the per-cell characteristic size (rank-local check, # reduced to a single global pass/fail so all ranks agree) @@ -168,8 +168,9 @@ def test_cell_size_is_local_per_cell(): gfmin, gfmax = _gmin(field), _gmax(field) assert gfmax / gfmin > 3.0 - # the global scalar that global-h would use is just the minimum cell size - assert np.isclose(mesh.get_min_radius(), gfmin, rtol=1e-6) + # The unchanged nearest-centroid minimum cannot exceed the own-cell + # minimum; these are no longer the same definition on an irregular mesh. + assert 0.0 < mesh.get_min_radius() <= gfmin * (1.0 + 1e-12) def test_local_h_at_coarse_freeslip_boundary_exceeds_global_min(): @@ -177,13 +178,13 @@ def test_local_h_at_coarse_freeslip_boundary_exceeds_global_min(): times the global minimum. global-h would over-stiffen that penalty by exactly this factor; local-h scales it correctly.""" mesh = _graded_box(h_fine=0.04, h_coarse=0.12) - # build/exercise the field; its data equals mesh._radii (asserted in + # build/exercise the field; its data equals mesh._radii_own (asserted in # test_cell_size_is_local_per_cell), so we read the per-cell sizes directly - # from _radii / _centroids — a rank-local lookup, avoiding the collective + # from the field / _centroids — a rank-local lookup, avoiding the collective # arbitrary-point uw.function.evaluate (which deadlocks in parallel). _ = mesh.cell_size() cen = np.asarray(mesh._centroids) - radii = np.asarray(mesh._radii).reshape(-1) + radii = np.asarray(mesh._cell_size_variable.array[:, 0, 0]).reshape(-1) near_top = cen[:, 1] > 0.85 # cells adjacent to the Top free-slip edge h_top = radii[near_top] @@ -204,14 +205,14 @@ def test_cell_size_tracks_deformation(): the Nitsche mis-scaling on the free surface.""" mesh = _graded_box() _ = mesh.cell_size() - h_before = mesh._cell_size_variable.data[:, 0].copy() + h_before = np.array(mesh._cell_size_variable.array[:, 0, 0]) X = np.asarray(mesh.X.coords).copy() moved = mesh.deform(_box_wobble(X, amp=0.04)) assert moved # geometry actually changed - h_after = mesh._cell_size_variable.data[:, 0].copy() - radii_after = np.asarray(mesh._radii).reshape(-1) + h_after = np.array(mesh._cell_size_variable.array[:, 0, 0]) + radii_after = np.asarray(mesh._radii_own).reshape(-1) # not stale: the field changed with the geometry SOMEWHERE (global OR) ... nb = min(h_after.shape[0], h_before.shape[0]) From accf259b3f517fbc14c292377764164df12d459e Mon Sep 17 00:00:00 2001 From: Tyagi Date: Sun, 6 Sep 2026 21:37:44 +1000 Subject: [PATCH 34/35] test: prove cell-size partition independence Rename the new per-cell geometric radius cache from _radii_own to _cell_radii so the name describes cell geometry rather than rank ownership. Update the focused Nitsche and deformation checks accordingly.\n\nAdd an enumerated parallel regression that gathers owned-cell centroid/radius pairs and compares the complete sorted table with a fresh single-rank run on the same cached Gmsh mesh. This directly guards the rank-count-independence claim at np=2, np=4 and np=8 instead of relying only on within-rank geometric identities.\n\nValidated locally with 9 focused serial tests and the new MPI test at 2, 4 and 8 ranks. --- .../discretisation/discretisation_mesh.py | 12 ++--- ...t_1077_cell_size_partition_independence.py | 47 +++++++++++++++++++ tests/test_1065_nitsche_local_h.py | 8 ++-- 3 files changed, 57 insertions(+), 10 deletions(-) create mode 100644 tests/parallel/test_1077_cell_size_partition_independence.py diff --git a/src/underworld3/discretisation/discretisation_mesh.py b/src/underworld3/discretisation/discretisation_mesh.py index c15a24c64..c3b71ab23 100644 --- a/src/underworld3/discretisation/discretisation_mesh.py +++ b/src/underworld3/discretisation/discretisation_mesh.py @@ -3301,9 +3301,9 @@ def _refresh(): def _assemble_cell_size(self, var): """Fill ``var`` (degree-0 scalar) with each cell's characteristic size. - Uses the own-cell characteristic lengths ``self._radii_own`` computed by + Uses the cell-geometry characteristic lengths ``self._cell_radii`` computed by :meth:`_get_mesh_sizes` on the *current* geometry. A degree-0 - discontinuous variable's local DOFs and ``self._radii_own`` are BOTH + discontinuous variable's local DOFs and ``self._cell_radii`` are BOTH indexed by this rank's cell-stratum order, so a direct assignment is correct on every rank. @@ -3313,7 +3313,7 @@ def _assemble_cell_size(self, var): ``var.coords`` triggers the collective ``_get_coords_for_basis``.""" # Own-cell radii fix #687 without changing the legacy kd-tree radii # used by global timestep estimates, adaptivity, and mesh relaxation. - radii = numpy.asarray(self._radii_own).reshape(-1) + radii = numpy.asarray(self._cell_radii).reshape(-1) # Empty partition (no local cells): nothing to fill on this rank. if radii.size == 0 or var.data.shape[0] == 0: return @@ -6898,7 +6898,7 @@ def _get_mesh_sizes(self, verbose=False): cell_length = np.empty(centroids.shape[0]) cell_min_r = np.empty(centroids.shape[0]) cell_r = np.empty(centroids.shape[0]) - cell_r_own = np.empty(centroids.shape[0]) + cell_radii = np.empty(centroids.shape[0]) coordinate_section = self.dm.getCoordinateDM().getLocalSection() vertex_coordinates = self.dm.getCoordinatesLocal().array @@ -6921,9 +6921,9 @@ def _get_mesh_sizes(self, verbose=False): offsets = np.array([coordinate_section.getOffset(int(v)) for v in vertices]) own_coords = vertex_coordinates[offsets[:, None] + np.arange(self.cdim)] delta = own_coords - own_coords.mean(axis=0) - cell_r_own[cell] = np.sqrt(np.mean(np.sum(delta ** 2, axis=1))) + cell_radii[cell] = np.sqrt(np.mean(np.sum(delta ** 2, axis=1))) - self._radii_own = cell_r_own + self._cell_radii = cell_radii return cell_min_r, cell_r, centroids, cell_length # ========== diff --git a/tests/parallel/test_1077_cell_size_partition_independence.py b/tests/parallel/test_1077_cell_size_partition_independence.py new file mode 100644 index 000000000..4348ece63 --- /dev/null +++ b/tests/parallel/test_1077_cell_size_partition_independence.py @@ -0,0 +1,47 @@ +"""Rank-count regression for the cell-local stabilization length. + +The parallel result is compared with a fresh single-rank run on the same Gmsh +mesh. This checks the complete cell geometry table, not merely a reduction or +a within-rank geometric identity. +""" + +import numpy as np +import pytest + +import underworld3 as uw + +from serial_reference import emit, mesh_fingerprint, serial_reference + + +pytestmark = [pytest.mark.mpi(min_size=2), pytest.mark.timeout(300)] + + +def _cell_geometry_table(): + mesh = uw.meshing.UnstructuredSimplexBox(cellSize=0.12, qdegree=2) + mesh.cell_size() + + local = np.column_stack((mesh._centroids, mesh._cell_radii)) + local = local[mesh._get_owned_cells_mask()] + gathered = uw.mpi.comm.allgather(local) + table = np.vstack(gathered) + order = np.lexsort(tuple(table[:, axis] for axis in reversed(range(mesh.dim)))) + return table[order].reshape(-1), mesh_fingerprint(mesh) + + +def test_cell_size_matches_single_rank_cell_by_cell(): + values, fingerprint = _cell_geometry_table() + reference = serial_reference(__file__, "cell_size") + + assert int(fingerprint[0]) == int(reference["fingerprint"][0]) + assert np.isclose( + fingerprint[1], reference["fingerprint"][1], rtol=1.0e-12, atol=0.0 + ) + + expected = np.asarray(reference["values"]) + assert values.shape == expected.shape + np.testing.assert_allclose(values, expected, rtol=0.0, atol=1.0e-14) + + +if __name__ == "__main__": + _values, _fingerprint = _cell_geometry_table() + emit(_values, _fingerprint) diff --git a/tests/test_1065_nitsche_local_h.py b/tests/test_1065_nitsche_local_h.py index 971130a0f..e6c225611 100644 --- a/tests/test_1065_nitsche_local_h.py +++ b/tests/test_1065_nitsche_local_h.py @@ -152,11 +152,11 @@ def _box_wobble(X0, amp): # -------------------------------------------------------------------------- def test_cell_size_is_local_per_cell(): """``mesh.cell_size()`` is a per-cell field equal to each cell's - own-vertex RMS size (``mesh._radii_own``), not the single global minimum.""" + own-vertex RMS size (``mesh._cell_radii``), not the single global minimum.""" mesh = _graded_box() h = mesh.cell_size() # sympy symbol -> backed by a P0 field field = np.asarray(mesh._cell_size_variable.array[:, 0, 0]).reshape(-1) - radii = np.asarray(mesh._radii_own).reshape(-1) + radii = np.asarray(mesh._cell_radii).reshape(-1) # field exactly mirrors the per-cell characteristic size (rank-local check, # reduced to a single global pass/fail so all ranks agree) @@ -178,7 +178,7 @@ def test_local_h_at_coarse_freeslip_boundary_exceeds_global_min(): times the global minimum. global-h would over-stiffen that penalty by exactly this factor; local-h scales it correctly.""" mesh = _graded_box(h_fine=0.04, h_coarse=0.12) - # build/exercise the field; its data equals mesh._radii_own (asserted in + # build/exercise the field; its data equals mesh._cell_radii (asserted in # test_cell_size_is_local_per_cell), so we read the per-cell sizes directly # from the field / _centroids — a rank-local lookup, avoiding the collective # arbitrary-point uw.function.evaluate (which deadlocks in parallel). @@ -212,7 +212,7 @@ def test_cell_size_tracks_deformation(): assert moved # geometry actually changed h_after = np.array(mesh._cell_size_variable.array[:, 0, 0]) - radii_after = np.asarray(mesh._radii_own).reshape(-1) + radii_after = np.asarray(mesh._cell_radii).reshape(-1) # not stale: the field changed with the geometry SOMEWHERE (global OR) ... nb = min(h_after.shape[0], h_before.shape[0]) From f41bcd2fd4d7348453edcd67a9a67a8c8d3a0a40 Mon Sep 17 00:00:00 2001 From: Tyagi Date: Sun, 6 Sep 2026 21:56:35 +1000 Subject: [PATCH 35/35] test: restore default Nitsche local-h path Remove the local_h=False workaround from the boundary-normal MPI regression now that Mesh.cell_size() is partition independent. The test again exercises the public local_h=True default and compares its Nitsche solve with a fresh serial process.\n\nRecord the user-visible consequence in the development changelog: the rank-local centroid kd-tree moved the default Nitsche velocity answer by 6.6e-3, while the cell-geometry replacement is identical cell by cell from one through eight ranks.\n\nValidated the focused Nitsche regression at 2, 4 and 8 Open MPI ranks (10.99 s, 7.31 s and 9.60 s respectively). --- docs/developer/CHANGELOG.md | 5 +++++ .../test_1069_boundary_normal_parallel.py | 16 +++++++--------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/developer/CHANGELOG.md b/docs/developer/CHANGELOG.md index 80f26d47d..b62edfb93 100644 --- a/docs/developer/CHANGELOG.md +++ b/docs/developer/CHANGELOG.md @@ -492,6 +492,11 @@ in `Stokes_Constrained` (#224), then made parallel-correct. minimum radius, restoring correct stiffness on graded and adapted meshes (#275). +- The local size now comes from each cell's own geometry instead of a kd-tree + over the centroids held by the current MPI rank. The old field changed at + partition boundaries and moved the default ``local_h=True`` Nitsche velocity + answer by 6.6e-3 between rank counts; the replacement is cell-by-cell + identical from one to eight ranks (#569, #687). - `mesh.boundary_slip` API with `BoundingSurface` objects for boundary tangent-slip (#225); `Surface.influence_function` respects finite edges (#241). diff --git a/tests/parallel/test_1069_boundary_normal_parallel.py b/tests/parallel/test_1069_boundary_normal_parallel.py index 2eb43c712..036f39a45 100644 --- a/tests/parallel/test_1069_boundary_normal_parallel.py +++ b/tests/parallel/test_1069_boundary_normal_parallel.py @@ -333,14 +333,12 @@ def _nitsche_annulus_diagnostics(): leakage. Both are stable from tolerance 1e-9 to 1e-12, so neither is the linear solve. - ``local_h=False`` is deliberate and it is not a workaround for this fix. The - default ``local_h=True`` scales the Nitsche penalty by ``mesh.cell_size()``, which - is built from ``Mesh._get_mesh_sizes`` — a kd-tree query against THIS RANK's cell - centroids, and so partition-dependent in its own right (on this mesh the field's - sum is 26.0822 at np=1, 26.1211 at np=2, 26.1386 at np=4, and its max moves at - np=4). That is a SEPARATE defect from the boundary normal, it is not what #564 is - about, and leaving it in would make this test measure the two together. See the - TODO(BUG) on ``Mesh._assemble_cell_size``. + This test now leaves ``local_h`` at its default ``True``. Before #569/#687, + doing so mixed the boundary-normal regression with a second partition-dependent + input from ``mesh.cell_size()``; this test therefore had to disable the public + default. The cell-local geometric size is now partition independent, so retaining + the default jointly guards the normal assembly and the Nitsche penalty path users + actually run. """ RI, RO = 0.5, 1.0 mesh = uw.meshing.Annulus(radiusInner=RI, radiusOuter=RO, cellSize=0.12, qdegree=3) @@ -357,7 +355,7 @@ def _nitsche_annulus_diagnostics(): y / r * sympy.cos(4 * theta) * (r - RI) * (RO - r) * 40.0]]) stokes.add_essential_bc((0.0, 0.0), "Lower") # default normal= is the assembled one — that is what is under test - stokes.add_nitsche_bc(0.0, "Upper", local_h=False) + stokes.add_nitsche_bc(0.0, "Upper") stokes.tolerance = 1.0e-9 stokes.petsc_options["snes_type"] = "ksponly" stokes.solve()