Skip to content

Conservative level-set transport on the shared solver interface (NengLu's #657 work) - #677

Open
lmoresi wants to merge 10 commits into
feature/eulerian-supg-transportfrom
feature/levelset-supg
Open

Conservative level-set transport on the shared solver interface (NengLu's #657 work)#677
lmoresi wants to merge 10 commits into
feature/eulerian-supg-transportfrom
feature/levelset-supg

Conversation

@lmoresi

@lmoresi lmoresi commented Sep 3, 2026

Copy link
Copy Markdown
Member

Credit

This is @NengLu's conservative level-set work from issue #657 (branch levelset), kept in its own commits at the base of this branch: the tanh-profile initialisation from a signed distance or a polygon, interface thickness from the local cell size, the Parameswaran and Mandal (2023) reinitialisation integrated with SSP-RK3, the Zhang, Zou and Greaves (2010) global mass correction, material properties blended across the interface, and the LeVeque swirling-flow comparison of SUPG against SLCN. The commits on top bring that work onto the shared solver interface of #673 and fix what stopped it running.

What changed on top

  • One module, both transport solvers. The two copies of the pipeline (level_set_SUPG.py and level_set_SLCN.py) become uw.systems.LevelSetSolver(psi, velocity=..., epsilon=..., advection="supg"|"slcn") in systems/level_set.py, with initialise_psi, interface_thickness and material_property_field alongside. The Eulerian solver is the default; order and theta pass straight through to either transport solver (Crank-Nicolson by default, which preserves the profile's amplitude between reinitialisations).
  • The prototype SUPG solver is retired in favour of AdvDiffusionSUPG from Eulerian SUPG advection-diffusion with symbolic BDF and Adams-Moulton orders (#657 follow-on) #673, which reproduces it to four digits, and the structured-grid ENO reinitialisation that was already switched off in the SUPG variant is dropped; the projection of the gradient magnitude is the general path.
  • Fixed: the wrapper called the transport solver's solve() positionally after it became keyword-only, so the LeVeque script could not run as pushed; shapely is now an optional import with a clear message (signed_distance= needs none of it); deprecated data access (mesh.data, .data[:, 0], mesh.access) replaced.
  • Examples and tests. The LeVeque comparison moves to docs/examples/convection/advanced/Ex_LevelSet_LeVeque_SUPG_vs_SLCN.py in the repository's script conventions (uw.Params, no argparse). The two 1-D block variants duplicated the existing example and one depended on the retired discontinuity-capturing term, so they are not carried over. tests/test_1100_levelset_rotation.py (level_2) checks, for both transport choices, that a circle carried once round the box holds its volume to 1e-6 at every step, keeps its profile sharp and comes back; plus the polygon initialisation and the property blend.
  • Docs: docs/advanced/level-set-transport.md.

Measured

LeVeque flow, 64 by 64, period 2, Courant 0.5: round-trip shape error 0.028 (SUPG) against 0.051 (SLCN) at half the wall time; the mass correction pins both to the same volume.

Base branch

Opened against feature/eulerian-supg-transport (#673) so the diff shows only the level-set work; to be retargeted at development once #673 merges.

Underworld development team with AI support from Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL

NengLu and others added 5 commits August 27, 2026 15:51
…transport solvers

NengLu's conservative level set (issue #657): tanh-profile initialisation
from a signed distance or a polygon/curve, the Parameswaran-Mandal
reinitialisation integrated with SSP-RK3, the Zhang-Zou-Greaves global mass
correction, and material properties blended across the interface. The two
copies of that pipeline (one per transport solver) become
uw.systems.LevelSetSolver(..., advection='supg'|'slcn') on the drop-in
solver interface; the prototype SUPG solver and the structured-grid ENO
reinitialisation that was already switched off are retired.

Fixed on the way: the wrapper called the transport solver's solve()
positionally after it became keyword-only, so the comparison script could
not run; shapely is now an optional import with a clear message
(signed_distance= needs none of it); deprecated data access replaced. The
LeVeque swirling-flow comparison moves to the examples in the repository's
script conventions; a rotation test checks both transport choices hold the
volume, keep the profile sharp and bring the circle back.

Underworld development team with AI support from Claude Code

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL
@lmoresi lmoresi mentioned this pull request Sep 3, 2026
@lmoresi

lmoresi commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

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

1. The reinitialisation projects |grad psi| three times per pseudo-time step (one L2 projection per SSP-RK3 stage), and the projection solver is a full SNES solve. At 64 by 64 that is most of the wall time of a level-set step (about 2 s against 0.3 s for the advection). Inherited from the prototype; a nodal Clement gradient would be cheaper and is what the transport solver's estimate_dt uses. Not changed here.

2. The mass correction is a uniform shift of the whole field, so it repairs volume by moving the contour everywhere by the same amount, not where the error was made. Standard for the method (Zhang, Zou and Greaves), but it means the "volume drift" a script reports after correction is always zero and says nothing about the transport. The example prints the drift for that reason only as a check that the corrector ran.

3. The wall correction (adv_solver_bc) is box-only and copies the neighbouring interior row or column onto the wall nodes. It assumes a structured node layout along the wall and is silent (a warning) when it cannot match a node. It is off by default and documented as a box-mesh convenience; it should not be used on unstructured meshes, and nothing stops a user from trying.

4. interface_thickness loops over cells in Python calling computeCellGeometryFVM per cell. Fine at these sizes, slow at 10^6 cells. The mesh's own per-cell size field (mesh.cell_size()) is the obvious replacement but is partition-dependent by ~1e-3, so the loop stays for now.

5. The default reinitialisation frequency is an empirical formula (4.9e-3 * domain size / min epsilon - 0.25, rounded) inherited from the prototype, with no reference. It gives every step on coarse meshes and less often as they refine; the rotation test and the LeVeque example override it.

6. Parallel. Every collective in the wrapper (volume integral, projections, the allreduces in the wall correction and the epsilon minimum) is unconditional, and the rotation test runs serially only. No np=2 run was made for this PR; the transport solvers underneath have theirs.

7. Dropped, deliberately: the structured-grid ENO reinitialisation (the SUPG variant already had it switched off), the two 1-D block scripts (one used the retired discontinuity-capturing term, both duplicated the existing example), and the SLCN variant's old_frame_traceback=True (meaningless on a static mesh).

Checked and sound: both transport choices pass the rotation test (volume to 1e-6 per step, profile sharp, circle back); the module imports without shapely; the style gate is clean; the branch keeps the original commits and authorship.

… instead of thirty

The correction restores the enclosed volume by a uniform clipped shift whose
root the previous code found by bracketing and bisecting to 1e-10, with one
integral over the mesh per trial: 15 to 30 integrals per step, most of the
cost of a level-set step. The map is monotone and its slope is the area of
the transition band, so a bracketed secant iteration started from the
nodal estimate of that area converges in a few evaluations. Same volume to
ten digits on the perturbed rotating circle (5 integrals against 32, 0.20 s
against 0.59 s); the bracket is kept as a safeguard.

Underworld development team with AI support from Claude Code

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL
@lmoresi

lmoresi commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

Mass correction: the bisection (15 to 30 integrals per step, most of a level-set step's cost) is replaced by a bracketed secant iteration on the same integral, started from the transition-band area as the slope. Same volume to ten digits on a perturbed circle at 64x64 with 5 integrals against 32 (0.20 s against 0.59 s). A lumped-mass version (one numpy sum per trial) would remove the integrals altogether but needs the PETSc global-to-local ordering plumbing; left for later.

…rosses it

A continuous-Galerkin transport with no value on an inflow boundary lets
mass in: measured as a 4% volume drift in twenty steps of a rotating circle
against 8e-5 with the far-field value imposed, on a flow that crosses the
box walls. LevelSetSolver takes far_field= and applies it as a Dirichlet
condition on every mesh boundary; the rotation test uses it. With it, the
SUPG transport conserves the enclosed volume to solver tolerance on its own
and the reinitialisation changes it at second order, so the global shift
corrector is doing its work for the semi-Lagrangian transport and the
clipping, not for the Eulerian scheme.

Underworld development team with AI support from Claude Code

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL
@lmoresi

lmoresi commented Sep 3, 2026

Copy link
Copy Markdown
Member Author

Added far_field=: the value of psi imposed on every mesh boundary. Without it a CG transport lets mass in through inflow boundaries (4% volume drift in 20 steps of a rotating circle that crosses the box walls; 8e-5 with it). With the walls handled, SUPG conserves the enclosed volume to solver tolerance and the reinitialisation changes it only at second order, so the global shift corrector is really there for SLCN's interpolation loss and the clipping. Rotation test now uses it; 4 tests pass with the secant corrector in place.

…tage, band thickness measured

conserve_mass='auto' turns the global correction on for the semi-Lagrangian
transport, which loses volume by interpolation, and off for the Eulerian
one, which conserves it to solver tolerance by itself; volume_drift reports
it either way. The field is clipped to [0, 1] after the advection and after
the reinitialisation (its RK stages leave 1e-6 undershoots), whatever the
corrector setting, since the reinitialisation equation assumes that range.

What the clip costs depends on the band thickness, measured on a rotating
circle at 32 cells across over one revolution with SUPG and no corrector:
0.84% at interface_thickness(scale=0.35), the g-adopt default, which is a
band well under one cell (eps = h/8) that a continuous-Galerkin transport
rings at; 0.28% at 1.0; 0.18% at 2.0 (eps = 0.7 h, ringing gone); 0.85% at
3.0, where the reinitialisation's curvature error takes over. Documented in
the helper and the user page; the rotation test uses scale 2.

Parallel timings of the level-set step (LeVeque flow, 128x128 and 256x256,
1 to 8 ranks) recorded in the design note: the Eulerian advection is seven
times cheaper in serial and about five times at eight ranks, its answer is
partition-independent to ten digits, and the semi-Lagrangian answer moves
with the partition (issue #682).

Underworld development team with AI support from Claude Code

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL
@lmoresi

lmoresi commented Sep 4, 2026

Copy link
Copy Markdown
Member Author

Three more things on this branch: (1) conserve_mass='auto' (on for SLCN, off for SUPG) with volume_drift reported; the field is clipped to [0, 1] after each stage regardless. (2) Band thickness matters more than anything else for the CG transport: interface_thickness(scale=0.35), the g-adopt default, is a band under one cell (eps = h/8) and SUPG rings at it (0.84% clipped volume per revolution with no corrector); scale 2.0 (eps = 0.7 h) removes the ringing and leaves 0.18% per revolution, which is the reinitialisation's curvature error. Documented; the test uses scale 2. (3) Parallel timings (128x128 and 256x256, 1 to 8 ranks) are in the design note: SUPG advection 7x cheaper in serial, about 5x at 8 ranks, partition-independent to ten digits; the SLCN answer moves with the partition, filed as #682.

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