Conservative level-set transport on the shared solver interface (NengLu's #657 work) - #677
Conservative level-set transport on the shared solver interface (NengLu's #657 work)#677lmoresi wants to merge 25 commits into
Conversation
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
…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
…-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
…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
…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
…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
|
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 ( 4. 5. The default reinitialisation frequency is an empirical formula ( 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 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
|
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
|
Added |
…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
|
Three more things on this branch: (1) |
…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
…supg # Conflicts: # docs/developer/design/eulerian-supg-transport.md
…lows 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL
|
Heads-up: #673 is closed as superseded by #688 (which targets development now). This branch needs the new names before it can merge: the composing scalar solver is 🤖 Generated with Claude Code |
|
#688 is merged to development (1d5e8c1), so this PR now targets development; it has 10 conflicts against it and still uses names that no longer exist. What to rebase onto: the scalar solver is 🤖 Generated with Claude Code |
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
level_set_SUPG.pyandlevel_set_SLCN.py) becomeuw.systems.LevelSetSolver(psi, velocity=..., epsilon=..., advection="supg"|"slcn")insystems/level_set.py, withinitialise_psi,interface_thicknessandmaterial_property_fieldalongside. The Eulerian solver is the default;orderandthetapass straight through to either transport solver (Crank-Nicolson by default, which preserves the profile's amplitude between reinitialisations).AdvDiffusionSUPGfrom 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.solve()positionally after it became keyword-only, so the LeVeque script could not run as pushed;shapelyis now an optional import with a clear message (signed_distance=needs none of it); deprecated data access (mesh.data,.data[:, 0],mesh.access) replaced.docs/examples/convection/advanced/Ex_LevelSet_LeVeque_SUPG_vs_SLCN.pyin 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/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 atdevelopmentonce #673 merges.Underworld development team with AI support from Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_018T2VHUGaZiQVJ95qQ4DiSL