Skip to content

Eulerian SUPG advection-diffusion with symbolic BDF and Adams-Moulton orders (#657 follow-on) - #673

Open
lmoresi wants to merge 13 commits into
developmentfrom
feature/eulerian-supg-transport
Open

Eulerian SUPG advection-diffusion with symbolic BDF and Adams-Moulton orders (#657 follow-on)#673
lmoresi wants to merge 13 commits into
developmentfrom
feature/eulerian-supg-transport

Conversation

@lmoresi

@lmoresi lmoresi commented Sep 3, 2026

Copy link
Copy Markdown
Member

Credit

The SUPG weak form used here, writing the Petrov-Galerkin test-function perturbation as a flux so PETSc needs no modified test space, and its first working implementation on PetscDS with P2 elements are @NengLu's, from the levelset branch of issue #657, together with the LeVeque swirling-flow comparison against SLCN and the conservative level-set pipeline (reinitialisation and mass correction) that motivated it. This PR keeps that idea and its tau parameter, builds the time integration on the symbolic history machinery, and adds the measurements; it would not exist without that prototype. A follow-on PR brings the level-set solver from the same branch onto this class.

Follows issue #657 (@NengLu's SUPG and level-set branch). This PR builds the Eulerian transport solver on the symbolic time-history machinery so that the scheme's order is a construction argument, adds the analytic oracle and the tests that measure it, and fixes two defects found on the way, one of them general.

What is here

uw.systems.AdvDiffusionSUPG(mesh, T, V_fn, order=2, integrator="bdf") (systems/advection_diffusion_eulerian.py). Fully implicit Eulerian advection-diffusion with SUPG stabilisation. Every past time level is a mesh variable held by the Eulerian history manager, so BDF 1-3 and Adams-Moulton 1-3 (theta at order 1, so am, 1, 0.5 is Crank-Nicolson) are assembled from the same stored history, including the diffusive flux of past states. The SUPG term is the test-function perturbation written as a flux, as in the prototype. The timestep, multistep coefficients and the tau weights are runtime constants of the compiled kernels: a change of timestep costs nothing (the prototype recompiled on every change, 1.2 s against 0.03 s for a step). Diffusivity is set on the constitutive model like every scalar solver. The class reproduces @NengLu's Crank-Nicolson prototype to four digits.

uw.analytic.RotatingGaussian: a Gaussian carried round the origin by rigid rotation while diffusing, exact at every time, so a transport scheme's error is measured rather than eyeballed. Also fixes AnalyticSolution.error(norm="integral") for scalar variables, which had never been exercised.

Tests (named to match the CI batch globs): test_1055 (API, no-recompile contract, adapt-child solve), test_1100_advdiff_supg_rotating_gaussian (temporal order 1 and 2, band invariance, round trip), tests/parallel/test_1077 (np=2 equals serial to 1e-12), test_1058 (the aux-layout fix below).

Design note docs/developer/design/eulerian-supg-transport.md with the measurements, and an example docs/examples/convection/advanced/Ex_AdvectionDiffusionSUPG_RotationTest.py.

What was measured

Rotating Gaussian, P2, unstructured simplex box, one revolution, relative L2 error. The implicit scheme is stable at any cell Courant number, and cells the scalar does not need are free: a band refined to h/9 at local Courant 13 changes the error in the third digit only. Its accuracy is set by u dt against the feature width (dt^2 for the second-order schemes). SLCN's error is flat in dt but accumulates at small Courant (21% against 0.6% at Courant 0.5 on the same mesh) and costs 4 to 6 times more per step in serial; its limit is the trace-back arc, about 10 degrees per step.

The integrator study (res 32, Courant 0.25 to 8, pure advection and kappa 1e-3) decides the default: 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, and backward Euler carries 20 to 40% error at any practical timestep. Cost per step is identical across schemes. BDF2 is the robust default; the note says when to pick the others.

Defects fixed

  1. Auxiliary fields packed and indexed by position (Mesh.update_lvec, _jitextension). mesh.vars holds variables weakly; a variable released by uw.reset_default_model() (the test suite does it between tests) or by the statistics helpers' deliberate del mesh.vars[...] leaves its PETSc field in the DM. Every later variable was then packed into, and read from, the wrong slots: measured as 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. Now packed by field name (orphans zeroed) and offsets read from the DM's own field list. test_1058 fails 2 of 3 checks without the fix. This predates the PR and affects any solver with auxiliary fields; it could be cherry-picked on its own.
  2. Mesh-owned multigrid pickup on a solver that owns its preconditioner (custom_mg.build_transfers): installing the adapt child's PCMG hierarchy on an additive-Schwarz PC segfaulted inside PETSc. _pc_option_prefix is None now counts as the explicit choice it is.

Not in this PR (recorded in the design note)

ALE mesh velocity (phase 1 keeps the default REMAP transfer with the material velocity, which is already correct to interpolation accuracy), a streamline element length from a mesh-owned metric tensor, a corrected discontinuity-capturing term, and the trajectory-based estimate_dt for SLCN (separate small PR). The level-set wrappers and scripts stay on the levelset branch for their author to rebase onto this solver.

Underworld development team with AI support from Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL

lmoresi and others added 7 commits September 2, 2026 17:29
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL
…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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL
…r 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL
…fusion 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL
…nditioner

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL
…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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL
…om 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL
Copilot AI lite review requested due to automatic review settings September 3, 2026 00:37
@lmoresi

lmoresi commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

Adversarial review of this PR, by the same session that wrote it. Findings first, evidence with each.

1. The strong residual has no diffusion term, so SUPG is inconsistent for P2 fields with kappa > 0. PETSc kernels see first derivatives only; the class documents this and the study ran at cell Peclet ~40 without visible harm, but nobody has measured the P2 error with kappa large enough for the missing term to matter. A lagged projected Laplacian is the known repair. Not fixed here.

2. tau uses mesh.cell_size(), which is volume^(1/d), not an edge length, and is partition-dependent by ~1e-3 (documented TODO in _assemble_cell_size). The weights (2, 2, 4) were kept from the prototype and shown to reproduce its numbers; they were not calibrated against the streamline length. The np=2 test passes to 1e-12 on a uniform mesh, which does not exercise the seam dependence.

3. BDF3 silently falls back to variable-step BDF2 when consecutive timesteps differ by more than 5% (ddt._bdf_coefficients). A user who varies dt gets second order without being told. Documented in the docstring, not warned at runtime.

4. Adams-Moulton orders 2 and 3 are accepted by the constructor and blow up from Courant 1 on advection. We kept them with a docstring warning rather than refusing them. A runtime check is impossible without knowing the flow; a warning at construction would be cheap. Judgement call, flagged.

5. Orphaned DM fields are zeroed at packing but never reclaimed. The fix makes the layout correct; the DM still grows by one field per dropped variable for the life of the mesh. A script that creates variables in a loop and drops them will see its aux vector and its kernels grow. That is the pre-existing memory behaviour, now merely correct.

6. The default order=2 changes nothing for existing users (new class), but theta is silently ignored at AM order 2 and 3. We raise if theta != 1 there, so it is loud, not silent. Verified by test_scheme_arguments_are_validated.

7. The rotating-Gaussian convergence slopes for BDF1 are 0.80 and 0.88, not 1. The test tolerance is 0.35 below the expected order. The pre-asymptotic behaviour is real (backward Euler saturates fast on this problem); a tighter test would need smaller timesteps and a finer mesh than a level_2 test can afford.

8. The study's res-64 rows were still running at PR time. The res-32 table is complete and decides the default; res 64 is confirmation and will be appended to the design note when it lands.

What we checked and found sound: the two families agree to every digit where they coincide (BDF1 = backward Euler); Crank-Nicolson reproduces the #657 prototype to four digits at two Courant numbers; a timestep change keeps the JIT cache key and changes the answer by 2e-11 against a 2e-2 negative control; the aux-layout regression test fails 2 of 3 checks with the fix reverted; core batches test_00xx to test_02xx pass (398) with the layout fix in place.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The new Eulerian solver currently uses mesh.dim where the scalar solver infrastructure expects embedded-coordinate (mesh.cdim) vector sizes, which will break manifold meshes, and it drops the verbose flag when delegating to the base SNES solve.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds a fully implicit Eulerian advection–diffusion SUPG transport solver built on the existing symbolic time-history (ddt.Eulerian) machinery, plus an analytic “rotating Gaussian” oracle and a suite of tests/notes validating temporal order, timestep-constant (no-recompile) behavior, mesh-adaptation invariance, and parallel partition-independence. It also fixes two infrastructure defects uncovered during development: auxiliary-field packing/offseting after dropped MeshVariables, and a multigrid pickup edge case when a solver explicitly owns its PC.

Changes:

  • Introduce uw.systems.AdvDiffusionSUPG (SNES_AdvectionDiffusion_SUPG) with BDF/Adams–Moulton order selection and runtime-constant timestep/coefficients.
  • Add uw.analytic.RotatingGaussian and tests that quantitatively measure temporal convergence, refinement invariance, and serial/parallel consistency.
  • Fix auxiliary-field layout robustness (Mesh.update_lvec, JIT aux offsets) and guard mesh-owned MG pickup for solvers with _pc_option_prefix is None.
File summaries
File Description
src/underworld3/systems/advection_diffusion_eulerian.py New Eulerian implicit SUPG advection–diffusion solver implementation and API.
src/underworld3/systems/__init__.py Exports AdvDiffusionSUPG.
src/underworld3/systems/ddt.py Exposes BDF/AM coefficient symbols for solvers assembling their own weighted history sums.
src/underworld3/systems/solvers.py Refactors shared adv/diff timestep estimation into reusable helpers.
src/underworld3/utilities/custom_mg.py Prevents MG pickup when solver explicitly owns its PC (_pc_option_prefix is None).
src/underworld3/utilities/_jitextension.py Fixes auxiliary-field component offsets to account for orphan DM fields.
src/underworld3/discretisation/discretisation_mesh.py Fixes Mesh.update_lvec() to pack aux data by DM field name and zero orphans.
src/underworld3/analytic/transport.py Adds RotatingGaussian analytic transport oracle.
src/underworld3/analytic/_base.py Fixes AnalyticSolution.error(norm="integral") for scalar mesh variables (1x1 Matrix symbol).
src/underworld3/analytic/__init__.py Exports RotatingGaussian.
tests/test_1055_advdiff_supg_api.py API/no-recompile/argument validation and adapt-child PC ownership test.
tests/test_1058_dropped_meshvariable_aux_layout.py Regression tests for dropped-variable orphan DM field layout bug.
tests/test_1100_advdiff_supg_rotating_gaussian.py Accuracy/temporal order, refinement invariance, and round-trip tests against analytic oracle.
tests/parallel/test_1077_advdiff_supg_parallel.py Parallel partition-independence test against a serial reference error.
docs/developer/design/eulerian-supg-transport.md Design note capturing decisions and measurements.
docs/developer/index.md Adds new design note to the developer docs toctree.
docs/advanced/semi-lagrangian-time-integration.md Cross-references the new Eulerian alternative.
docs/examples/convection/advanced/Ex_AdvectionDiffusionSUPG_RotationTest.py Example script demonstrating solver usage and measured error against the oracle.
Review details

Suppressed comments (3)

src/underworld3/systems/advection_diffusion_eulerian.py:219

  • The velocity coercion should be done in embedded coordinate dimension (mesh.cdim) to match SNES_Scalar's expectations for flux/gradients; using mesh.dim will break manifold meshes and can mismatch F1's required size.
        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)

src/underworld3/systems/advection_diffusion_eulerian.py:369

  • _advection() uses mesh.dim to form u·∇phi, but SNES_Scalar kernels (and UW's variable gradients) are defined in embedded coordinates (mesh.cdim). On manifold meshes this will either be wrong or fail when V_fn has cdim components.
    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()):

src/underworld3/systems/advection_diffusion_eulerian.py:380

  • _diffusive_flux() builds a (1, mesh.dim) vector, but SNES_Scalar reshapes fluxes to mesh.cdim. This will raise at runtime on manifold meshes (dim!=cdim). Build the flux in embedded dimension (cdim) instead.
    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)
  • Files reviewed: 18/18 changed files
  • Comments generated: 2
  • Review effort level: Lite

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

Comment on lines +41 to +57
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__}."
)
Comment on lines +493 to +495
self.DuDt.update_pre_solve(dt, verbose=verbose)
super().solve(zero_init_guess, _force_setup, divergence_retries=divergence_retries)
_invalidate_solution_cache(self.u)
…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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL
@lmoresi

lmoresi commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

Res-64 rows of the integrator study are in (design note updated in the last commit). BDF2 and Crank-Nicolson track their res-32 errors at the same u dt. BDF3 is not safe for pure advection at any Courant number: its stability region misses the imaginary axis near the origin, so the finer mesh's low-frequency modes grow slowly (31x the exact field after 590 steps at Courant 1); it behaves with kappa = 1e-3. Review item 8 is closed; the BDF2 default stands.

@lmoresi

lmoresi commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

The auxiliary-field layout fix (commit e84dea9 and its test) is extracted as #676 so it can land on development ahead of this PR; this branch will rebase onto it once merged.

…-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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL
@lmoresi

lmoresi commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

Interface change after discussion: AdvDiffusionSUPG now takes the semi-Lagrangian solver's signature and semantics so it is a drop-in replacement (change the class name, 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 docs give; the trace-back-only arguments are accepted and ignored with a warning; delta_t is settable and solve() reuses it. New user page docs/advanced/eulerian-advection-diffusion.md with the swap table and when to use which solver. Review items 4 and 6 stand as written; the default moved from BDF2 to Crank-Nicolson by the drop-in contract, and the design note records why that is also the better default on the measurements.

…e 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL
@lmoresi

lmoresi commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

Removed the integrator argument: order and theta already reach every scheme that is safe (Crank-Nicolson, backward Euler, BDF2, BDF3 with diffusion), and the only settings the argument added were the Adams-Moulton orders 2 and 3 that the study shows blowing up on advection from Courant 1. The family now follows the order; the higher Adams-Moulton assembly remains reachable only by switching it on the instance, for the record. Review item 4 is closed by removal.

…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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL
@lmoresi

lmoresi commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

Timestep: estimate_dt() on the Eulerian solver is now accuracy-based. It returns the step at which the field changes by fraction (0.02) of its range, from the advective rate before the first solve and from the realised rate of the last step after it, so it does not depend on the mesh (the band test checks that the resolution estimate collapses 3x on the refined child while this one moves under 25%). The cell-crossing time SLCN reports is neither a stability nor an accuracy limit here; it stays available as basis='resolution'. At the default fraction the Crank-Nicolson round trip on the rotating Gaussian is under one per cent (test_1100). Credit to @NengLu is now in the PR description, the module docstring and the design note.

lmoresi and others added 2 commits September 3, 2026 22:28
…oner="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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL
@lmoresi

lmoresi commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

Linear solver settled (bd0b1a5): matched tolerances, Schwarz by default, preconditioner="fmg" as a real switch.

We set out to measure geometric multigrid against the solver's additive-Schwarz ILU on the level-set advection step (256² and 512² quad box with a refinement hierarchy, P2, Crank-Nicolson; script and logs in ~/+Simulations/supg_vs_slcn_657/parallel/). The first result read as a 1.65x win for multigrid (0.94 s against 1.56 s per step in serial, one F-cycle against three GMRES iterations). -log_view said otherwise: the Schwarz run was doing two Newton iterations per step on a linear operator, because the Krylov default tolerance (1e-5) does not reach the SNES tolerance (1e-8), and that second Jacobian assembly cost more than every linear solve of the step. The linear solve is under a tenth of a step either way; assembly is the rest.

With the Krylov tolerance at 1e-9 (now the default) both do one Newton step, and the comparison at 256² is:

Courant ASM-ILU its (np 1 / 8) s/step FMG cycles (np 1 / 8) s/step
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

Schwarz is cheaper at every Courant number and its count is the same on one and eight ranks. The multigrid's cycle count grows with the Courant number nearly as fast (the Galerkin coarse operators inherit the fine-grid tau; four levels equal three at Courant 1/2, so they are not under-stabilised there), and a cycle costs about three Schwarz iterations. At 512² with four levels the same holds: 3.51 / 0.48 s (Schwarz) against 3.62 / 0.54 s (multigrid). GAMG as a control was slowest (5 iterations, 2.07 s serial). Smoother variants made no difference. So the default is GMRES + ASM-ILU on every mesh, and solver.preconditioner = "fmg" hands the block to the managed multigrid route for the rank count where a one-level method has no coarse space, which we have not measured beyond eight ranks.

Two defects found on the way:

Tests: test_1055 gained the switch test (Schwarz and multigrid agree to the solve tolerance, and switching back rebuilds); the adapt-child test is unchanged in what it asserts. 18 serial and the np=2 parallel test pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants