Skip to content

Forward-from-nodes history; semi-Lagrangian schemes named by trace and launch; every history equals serial in parallel - #800

Open
lmoresi wants to merge 24 commits into
developmentfrom
feature/forward-from-nodes
Open

lmoresi wants to merge 24 commits into
developmentfrom
feature/forward-from-nodes

Conversation

@lmoresi

@lmoresi lmoresi commented Sep 27, 2026

Copy link
Copy Markdown
Member

Stacked on #795.

What changes

One entry point for the semi-Lagrangian histories. uw.systems.ddt.SemiLagrangian(mesh, psi_fn, V_fn, vtype, trace=, launch=) selects one of four schemes:

trace launch class (former name)
backward nodes (default) BackwardNodesSemiLagrangian (SemiLagrangian)
backward integration_points BackwardIntegrationPointsSemiLagrangian (IntegrationPointSemiLagrangian)
forward integration_points ForwardIntegrationPointsSemiLagrangian (ForwardSemiLagrangian)
forward nodes ForwardNodesSemiLagrangian (new)

A keyword the chosen scheme does not take is a TypeError. The former class names and the former stress_transport strings (semi_lagrangian, integration_point, forward) still work, with a FutureWarning. stress_transport takes backward_nodes (default), backward_integration_points, forward_integration_points, forward_nodes, lagrangian, eulerian; AdvDiffusionSLCN(transport=...) chooses its value history among the four semi-Lagrangian schemes.

Forward from nodes. The history launches its values from the field's nodes and from a lattice inside every element (the discontinuous basis two degrees up), carries them one step forward, fits the arrivals in each cell at the field's degree, and projects the per-cell fits onto the continuous store. A cell with too few arrivals takes a linear fit to its own arrivals, or their mean; an arrival on a shared face is fitted in every cell that contains it. A stress is committed by L2 projection onto the store and launched from that field. Rotating diffusing Gaussian, P2, SLCN solver, integral error:

half turn, disc half turn, box full turn, disc
forward from nodes 1.78e-2 6.57e-2 8.14e-2
backward from nodes 2.52e-2 6.43e-2 8.94e-2

The box, where the flow crosses every wall, is slightly worse than the backward scheme. A serial-only version that let thin cells borrow arrivals from their neighbours reached 1.68e-2 and 6.32e-2; that borrowing depends on the partition and was removed. First order; a fixed mesh.

Every history gives the serial answer in parallel. tests/parallel/test_1066 holds the six stress histories (turned-over Maxwell box) and the four semi-Lagrangian value histories (rotating Gaussian with inflow) to 1e-6 of their serial values at np >= 3; verified at np 3, 4 and 6. Five sources of partition dependence were removed:

  1. History projections were solved at the projection default tolerance (1e-4) with an aggregating multigrid; they are now CG with Jacobi to 1e-12 from a zero start (the warm start also made a restart differ from a straight run).
  2. The nodal and integration-point histories recorded the current field by evaluating it at nudged nodes (serial) or by copying it (parallel, and only when handed the variable itself); a field that is a mesh variable is now always copied.
  3. Behaviour change of the default scheme: the nodal trace-back took its start velocity at the node nudged 0.1% toward "the closest cell's" centroid. A seam node was nudged differently on each rank, and a node on a no-slip wall was given a velocity. The trace now starts at the node. Serial answers near walls change; all 388 tests in the transport, Navier-Stokes and viscoelastic set pass unchanged.
  4. global_evaluate: a point that the swarm migration handed to a rank whose cells do not contain it was given the nearest-centroid rbf extrapolation (3e-2 at a steep stress, np 4). The rank whose cell contains it now evaluates it with the FE interpolant. This runs only where the cell hint is authoritative (no DMLocatePoints collective inside), and a finite rbf value is kept where the FE value is NaN.
  5. Cell tie-breaks in the forward fits, described above.

Open

Tests

tests/parallel/test_1066 (new), test_1062 tightened to 1e-6, test_1102 (new, forward from nodes against hard baselines), test_1063 covers forward_nodes, test_1059 covers selection, renamed strings and the Maxwell box for every history. Serial transport/NS/VE set: 388 passed. Parallel np 3/4/6: 10 passed, 1 xfail at np >= 4.

🤖 Generated with Claude Code

https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo

lmoresi and others added 4 commits September 26, 2026 13:24
…lement interiors

Forward from where the values are known best. A field is known exactly at its
own nodes and, since its interpolant is a polynomial inside each element, at
any interior point. ForwardNodesSemiLagrangian launches the values at the nodes
and at the interior lattice of every element (the discontinuous basis one
degree up), carries them one step forward on the shared characteristic trace,
fits the arrivals in each cell at the field's own degree (the cell polynomial
projector, with its thin-cell and conditioning fallbacks) and reads the fit
back at the nodes. The fit never crosses an element boundary, where the
interpolant has a kink; a neighbourhood fit across cells was tried first and
smoothed (disc error 3.6e-2).

As the DuDt of the SLCN advection-diffusion solver on the rotating diffusing
Gaussian (P2 temperature, dt 0.02, kappa 0.01): half revolution on the disc
2.00e-2 (backward nodal history 2.52e-2, swarm 2.04e-2, SUPG 1.72e-2), peak
0.1873 against 0.1865 exact; the square box, where the flow crosses all four
walls, 6.17e-2 (backward 6.43e-2); a full revolution 7.63e-2 (backward 8.94e-2,
SUPG and swarm 8.2e-2). About twice the backward cost per step. First order,
serial (the fit near a partition seam needs the other rank's arrivals).

Test test_1102: disc and box, hard baselines.

Underworld development team with AI support from Claude Code

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo
… forward from nodes as an advection-diffusion option

ddt.SemiLagrangian(mesh, psi_fn, V_fn, vtype, trace=, launch=) selects one of
four schemes: BackwardNodesSemiLagrangian (was SemiLagrangian),
BackwardIntegrationPointsSemiLagrangian (was IntegrationPointSemiLagrangian),
ForwardIntegrationPointsSemiLagrangian (was ForwardSemiLagrangian) and
ForwardNodesSemiLagrangian. A keyword the chosen scheme does not take is a
TypeError. The old class names resolve with a FutureWarning.

stress_transport takes "backward_nodes" (default), "backward_integration_points",
"forward_integration_points", "lagrangian", "eulerian"; the old strings map with
a FutureWarning. AdvDiffusionSLCN(transport=...) chooses the value history among
the four semi-Lagrangian schemes.

ForwardNodesSemiLagrangian carries a field known at its nodes and refuses a
flux: a stress is formed at the integration points. A cell nothing reached now
keeps the field it launched (no fit carried between steps); inflow is detected
by the back-reflected point leaving the domain; a moved mesh is refused.
ForwardIntegrationPointsSemiLagrangian takes degree and refuses anything but 1.
BackwardNodesSemiLagrangian's continuous now defaults to True.

Underworld development team with AI support from Claude Code

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo
…l; forward from nodes carries a stress

Forward from nodes as a stress history: the stress is committed by L2
projection onto the continuous store and launched from that field. It is
offered by stress_transport="forward_nodes". The per-cell fits are projected
onto the store (a shared node weighs every cell), each cell's fit uses only its
own arrivals (a linear fit, their mean, or the launched field when too few),
the interior lattice is two degrees up, and an arrival on a shared face is
fitted in every cell that contains it. In parallel a seam node is launched by
its owner, and arrivals that left a rank or sit on a face are offered to every
rank.

Partition dependence removed from the existing histories:
- a field that is a mesh variable (T.sym as well as T) is recorded into the
  nodal and integration-point histories by copying its nodal data, in serial
  as in parallel, instead of evaluating it at nudged nodes;
- the nodal trace starts from the node itself: the 0.1% nudge toward the
  closest cell's centroid depended on which cells a rank holds and gave a
  no-slip wall node a velocity;
- every history projection is solved to 1e-10;
- global_evaluate: a point that the migration stranded but that a rank's cell
  contains is evaluated there with the FE interpolant, not by the
  nearest-centroid rbf extrapolation (3e-2 at a steep stress).

tests/parallel/test_1066: six stress and four advection histories equal serial
to 1e-6 at np >= 3 (verified np 3, 4, 6); the particle history is a strict
xfail at np >= 4 (1.2e-5, cause open). test_1062 tightened to 1e-6.

Underworld development team with AI support from Claude Code

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo
…tory projections

- forward from nodes: an inflow node is one whose back-reflected point the
  global restore moves (a per-rank "in domain" test called every partition
  face a boundary); ownership of seam nodes read from the global section
  (_owned_rows) instead of a write-and-read-back; the tracked field copied,
  not evaluated, at the nodes; a strictly-inside arrival fitted in its one
  cell only; containing_cells falls back to the locator for a point whose
  cell is not among the nearest centroids; quadrilateral centroids corrected.
- global_evaluate: the containment round keeps a finite rbf value where the FE
  value is NaN, and runs only where the cell hint is authoritative (no
  DMLocatePoints collective inside it); the deadlock note says so.
- every history projection is a CG/Jacobi solve (linear_solver moved to the
  projection mixin) to 1e-12, from zero: the warm start depended on a state a
  restart does not restore.
- Eulerian record uses _tracked_field; _tracked_field refuses another mesh's
  variable; stress_transport names map through one (trace, launch) table.
- tests: test_1066 resets the model per case, asserts the forward exchanges
  ran, gives the value histories an inflow, marks the particle case #797
  (xfail, np >= 4); test_1063 covers forward_nodes; test_1102 held to 1e-4.
  test_1066 at np 4 is the regression test for the containment round.
- TODO(DESIGN) notes: face points offered to every rank; the two forward
  flavours' arrival rules.

np 3/4/6: 10 passed (+1 xfail at 4/6); serial: 388 passed.

Underworld development team with AI support from Claude Code

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo
@lmoresi

lmoresi commented Sep 27, 2026

Copy link
Copy Markdown
Member Author

Adversarial review

Two review passes before the push (on c434d8a and on b20c992). Findings and disposition:

Fixed

  • The SemiLagrangian entry point passed positional arguments to schemes whose positional order differs (varsymbol=2, order=True silently); now keyword-only after vtype, and an argument the scheme does not take is a TypeError.
  • Forward from nodes kept the pre-solve fit for a cell with no arrivals, so an empty inlet cell read back the stress from before the solve; the fallback is now the field the cell launched, and nothing is carried between steps.
  • Forward-from-nodes inflow detection used the per-rank points_in_domain, which treats partition faces as boundary: every seam node would have taken the inflow value. Now the global restore test the other flavours use.
  • The global_evaluate containment round overwrote a finite rbf value with NaN for a located-but-NaN point (the case the fallback exists for), and could meet a DMLocatePoints collective on non-authoritative meshes through the interpolation cache. Now keeps finite values and runs only where the hint is authoritative.
  • The owned-node mask relied on a write-then-read-back through the ghost update (wrong inside a deferred update); now read from the global section.
  • A strictly-inside arrival could also be kept by a neighbour whose tolerance band it touched (partition-dependent); now only its own cell.
  • containing_cells could miss the containing cell beyond the k nearest centroids; now falls back to the locator. Quadrilateral centroids used the simplex formula.
  • History projections warm-started from a state a restart does not restore (restart test failed at 1e-12); now zero-start CG/Jacobi to 1e-12.
  • test_1102 windows could not see a default change (+-10%); now 1e-4. test_1066 resets the model per case, asserts the seam exchange ran, runs the inflow path; test_1063 covers forward_nodes.

Deferred (TODO(DESIGN) in the code)

  • Face arrivals are allgathered to every rank, including faces interior to a partition (no-slip wall nodes each step).
  • The forward integration-point exchange gives a face arrival to one cell; forward from nodes to every containing cell.

Filed: #797 (particle history, np >= 4), #798 (evalf extrapolation flag), #799 (serial evaluate near nodes).

Underworld development team with AI support from Claude Code

…Stokes velocity history offers every semi-Lagrangian scheme

global_evaluate applied monotone="clamp" on the rank that asked for a point,
bounding it by that rank's nearest nodes; for a departure point evaluated on
another rank those are the wrong neighbourhood. The bound now runs on the
evaluating rank (first pass and containment round), on non-dimensional values
like the nodal data it compares with. Serial results are unchanged. #682's
level-set reproduction (LeVeque swirl, 128^2 quad, P2, SLCN with clamp):
volume at np 1/2/4/8 now 0.0706771442/415/438/412 (np 8 was 1.6% off).

NavierStokesSLCN(velocity_transport=...) chooses the velocity history among
the four semi-Lagrangian schemes, through one _value_history helper shared
with AdvDiffusionSLCN(transport=...). Forward schemes need order=1; the
forward integration-point fit is linear and refuses a P2 velocity.

tests: test_1103 (lid-driven cavity, Re 100, three schemes against recorded
values and each other); test_1066 adds the cavity (equal to serial at np
3/4/6). tests/parallel at np 4: 168 passed, 1 xfailed, 1 failed
(test_1063_constrained_freeslip_parallel[ti], fails identically on #795's
05d5248). Serial set: 393 passed.

Underworld development team with AI support from Claude Code

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo
@lmoresi

lmoresi commented Sep 27, 2026

Copy link
Copy Markdown
Member Author

Added (28314aa):

Underworld development team with AI support from Claude Code

lmoresi and others added 2 commits September 28, 2026 12:41
…es solver takes any stress history and the log-conformation store

uw.systems.NavierStokes: the strong residual SUPG weights now includes the
divergence of the stress the history carries (_memory_stress: the model's
effective strain rate with the velocity's own derivatives set to zero, times
2 eta, plus the theta rule's stored levels; decoded for a log store). An
integration-point store has no derivative; its nodal snapshot stands in, an
O(dt) difference. On a developing Oldroyd-B channel (Re 250, Wi 0.6) the SUPG
solution moved toward the Galerkin one on the same mesh: velocity 5.0e-5 ->
3.2e-5, stress 3.4e-6 -> 1.6e-6. (Fully developed channel flow is blind to
this: the missing residual is constant along streamlines.)

NavierStokesSLCN:
- stress_transport, the stress-history factory and its prepare/carry/commit
  steps move from SNES_Stokes into _StressHistoryMixin, shared by both; the
  solver used to ignore stress_transport (a plain attribute) and always carry
  a backward nodal history, recorded a step late (#742).
- the momentum flux's theta rule reads the carried stress through the model
  (a log-conformation store decodes) and adds the solvent stress of the
  carried velocity; the solvent stress was missing from its momentum flux.
- its time order is the momentum's (_momentum_order); the stress history's
  order is the constitutive model's (the solver's order used to be forced on
  the model).
- a supplied DFDt is used (it was overwritten); the viscous-flux history is
  built only for a model without a stress history.

tests: test_1104 (SUPG memory stress: structure, snapshot stand-in, recorded
developing channel); test_1059 runs every stress transport and the log store
(N1 of upper-convected start-up 1.193 against 1.188 exact; the linear stress
store gives 1.108) through NavierStokesSLCN. Serial set 403 passed (+ the
test_1060 fix); parallel np 3/6 transport tests pass; tests/parallel at np 4
as before (#801 pre-existing, #797 xfail).

Underworld development team with AI support from Claude Code

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo
…ients in the SUPG memory term, snapshot stand-ins

- Both Navier-Stokes solvers form the viscoelastic momentum flux with one
  helper (_StressHistoryMixin._theta_rule_flux) using the MOMENTUM scheme's
  weights (DuDt.spatial_weights: BDF puts it all on the new level, the theta
  rule at first order); NavierStokesSLCN used the stress history's AM weights,
  a BDF2 derivative against a Crank-Nicolson flux by default. A stored level
  the stress history does not hold is an error, not a clamp.
- _memory_stress is the model's own flux with the velocity's derivatives set
  to zero (any model: the transversely isotropic one too; the solvent drops
  out), with expression containers that vary in space expanded first so a
  varying modulus or viscosity contributes its gradient; constant containers
  stay runtime parameters.
- History stores without a derivative give their stand-ins through one
  method (_derivative_stand_ins: the integration-point history's nodal
  snapshots, psi and forcing); used by the SUPG memory term and by the
  solvent term of an integration-point velocity history, which failed to
  compile with a viscoelastic model.
- Constitutive_Model._carried_stress_sym default (the stored level); the
  hasattr branches go.
- NavierStokesSLCN builds its viscous-flux history when a non-viscoelastic
  model is assigned (DFDt available before the first solve, as the tutorial
  expects); the flux_order override applies to that history only.
- docs: stress-transport "With inertia" matches the code.
- tests: test_1104 adds a numeric memory check, the varying-modulus
  expansion, NavierStokesSLCN on the developing channel (solvent, log store,
  momentum order 2 against model order 1) against recorded values, and an
  integration-point velocity history with a viscoelastic model.

Serial set 407 passed; np 3/6 transport tests pass; tests/parallel np 4 as
before (#801 pre-existing, #797 xfail).

Underworld development team with AI support from Claude Code

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo
@lmoresi

lmoresi commented Sep 29, 2026

Copy link
Copy Markdown
Member Author

Added (2adf50d, c0704a8): the SUPG residual sees the carried elastic stress, and NavierStokesSLCN takes any stress history.

SUPG. uw.systems.NavierStokes weighted a momentum residual that did not include the stress divergence. At the exact solution that residual is the elastic stress divergence, the dominant term at high Wi, and SUPG turned it into an O(tau) error. The residual now includes the divergence of the carried stress. That term is the model's flux with the velocity's own derivatives set to zero, with spatially varying parameters expanded so their gradients appear. On a developing Oldroyd-B channel (Re 250, Wi 0.6), the difference from the Galerkin solution on the same mesh fell from 5.0e-5 to 3.2e-5 in velocity and from 3.4e-6 to 1.6e-6 in stress. Fully developed channel flow cannot show this, because the missing residual is constant along streamlines.

NavierStokesSLCN. It ignored stress_transport (the assignment just created an attribute) and always carried a backward nodal history, one step late (#742). It dropped the solvent stress from its momentum flux, forced its momentum order onto the viscoelastic model, and overwrote a supplied DFDt. It now shares one stress-history lifecycle with the Stokes family (_StressHistoryMixin), and both Navier-Stokes solvers form the viscoelastic momentum flux the same way, with the momentum scheme's weights. It reads the log-conformation store: N1 in upper-convected start-up is 1.193 against 1.188 exact, where the linear stress store gives 1.108.

Adversarial review of 2adf50d

Fixed in c0704a8

  • NavierStokesSLCN with a viscoelastic model and velocity_transport="backward_integration_points" failed to compile: the solvent term differentiated an integration-point store. Stores without a derivative now give their nodal snapshots through _derivative_stand_ins.
  • The theta rule took the stress history's Adams-Moulton weights, which by default gave a BDF2 time derivative against a Crank-Nicolson flux. It now uses the momentum scheme's weights, and a missing stored level is an error rather than a clamp.
  • The SUPG memory divergence treated material parameters as constant containers, so the gradient of a spatially varying modulus or viscosity was lost. Non-constant containers are now expanded before differentiating.
  • The memory stress was hard-coded to the isotropic form. It is now the model's flux with the velocity's own derivatives removed.
  • The new NavierStokesSLCN tests ran a uniform-stress box, so every changed term had zero divergence and the tests could not fail. test_1104 adds a non-uniform developing channel against recorded values (solvent, log store, momentum order 2 against model order 1), the varying-modulus expansion, and an integration-point velocity history.
  • The docs contradicted the code on integration-point stores; hasattr branches are replaced by a base _carried_stress_sym; the viscous-flux history is built when the model is assigned.

Open

  • The objective-rate terms, the deformation-step terms and a yielding viscosity carry the velocity gradient, so their divergence needs second derivatives. They stay outside the residual, with the viscous term (TODO(DESIGN)).
  • The integration-point snapshot stand-in differs from the carried stress by more than O(dt) at an inflow.

Serial transport/NS/VE set: 407 passed. Transport tests at np 3/6 pass; tests/parallel at np 4 is as before (#801 pre-existing, #797 xfail).

Underworld development team with AI support from Claude Code

lmoresi and others added 8 commits September 29, 2026 14:27
uw.systems.NavierStokes(..., velocity_transport=...) carries the momentum on
the grid ("eulerian", SUPG, the default) or semi-Lagrangianly
("backward_nodes", "backward_integration_points",
"forward_integration_points", "forward_nodes"); stress_transport chooses the
viscoelastic stress history as before. uw.systems.AdvDiffusion(...,
transport=...) takes the same names. The composed solvers already held any
history manager; the argument builds it through the shared _value_history.

- the extrapolation level after a step is the velocity the step started from
  (it copied the history's first level: the departure-point velocity for a
  semi-Lagrangian history, and the wrong shape for an integration-point one);
- the stored-level viscous stress reads an integration-point velocity store
  through its nodal snapshot (it failed to compile);
- NavierStokesSLCN, NavierStokesSwarm (which carried no swarm) and
  AdvDiffusionSLCN resolve with a FutureWarning naming the replacement; the
  free-surface solver builds its composition solver by class, without it.

Lid-driven cavity (Re 100): the four velocity histories within 0.6% of the
grid scheme; the semi-Lagrangian path within 0.5% of the former
NavierStokesSLCN (the stored-level stress is now rebuilt from the carried
velocity); the viscoelastic developing channel reproduces it to 2e-8.
test_1103 covers all five, test_1066 checks them against serial at np 3/4/6.
Serial set 409 passed; tests/parallel np 4 as before (#801, #797).

Underworld development team with AI support from Claude Code

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo
…ter for the forward integration-point fit

store_smoothing (alpha = c h^2 in the projection that stores a new flux) moves to a
mixin shared by the backward and forward integration-point histories and the forward
nodal history. The forward integration-point history keeps flux_smoothing as an
absolute override and refuses both at once.

The forward integration-point fit gains fit_limiter (Barth-Jespersen: keep the
centroid value, scale the slope so no dof leaves the range of what arrived in the
cell) and records the unlimited fit's overshoot either way. A cell whose arrivals
bunch in a corner passes the conditioning test and extrapolates its slope across
the cell; on the viscoelastic cylinder (SUPG velocity, log-conformation store) one
such cell in the near wake put tau_II = 4 where nothing above 0.8 arrived, and
the next solve diverged.

Underworld development team with AI support from Claude Code
Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo
…vals in place of the per-cell fit

ParticleL2Projector (utilities/particle_projection.py) projects scattered weighted
values onto continuous P1 by one weighted least-squares solve: the finite-element
mass matrix with the points as its quadrature rule. A node is set by every point in
the patch of cells around it, so it is interpolated rather than extrapolated from
one cell's points; a weak pull towards the previous field keeps a node nothing
reached; the system is assembled per owning cell and summed across partition seams
through the local-to-global ADD, so the answer does not depend on the partition.

ForwardIntegrationPointsSemiLagrangian gains reconstruction="cell" (the per-cell
fit, the default) or "global" (the projection, read back at the store's interior
dofs). On the viscoelastic cylinder (Re 200, Wi 0.5, log-conformation store, SUPG
velocity) the per-cell fit needed store_smoothing 0.07 to survive and still failed
at t 5.2; the global projection runs to t 8 with no smoothing and no limiter, the
field's excursion beyond the carried range steady at 5%, and lands within 8% in
drag and 12% in lift of the Eulerian stress history at the same Strouhal number.

Underworld development team with AI support from Claude Code
Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo
… per-cell fit

ForwardNodesSemiLagrangian gains reconstruction="global": every arrival, from the
nodes and the interior lattice, projected onto the degree-1 store in one L2 solve
through ParticleL2Projector, each launch point weighing its share of the cell it
launched from and an arrival on a shared face counted once (the multiplicity is
summed over the ranks). The excursion of the field beyond the carried range is
recorded as for the integration-point flavour, and a shared helper measures it.

Underworld development team with AI support from Claude Code
Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo
…y can use the global projection

The projector takes degree=2 on triangles: rows over the vertices then the edges
(the layout of any continuous P2 variable), the quadratic simplex basis in
barycentric coordinates, and element mass and stiffness matrices by Dunavant's
degree-4 rule. A quadratic field sampled anywhere in the cells is projected
exactly, serially and in parallel. ForwardNodesSemiLagrangian accepts
reconstruction="global" at degree 2, so a P2 velocity carried forward from its
nodes is reconstructed by one projection instead of a quadratic fit per cell.

Underworld development team with AI support from Claude Code
Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo
…field; a bubble penalty for the degree-2 fit

Deficit fill (on by default): the share of a cell's measure that the arriving
weights do not cover enters as finite-element mass with the previous field as its
data, the mechanism the inflow cells already use. A cell the flow has emptied is
then held by what it carried rather than by a 1e-8 pull and its neighbours. On
the viscoelastic cylinder the P2 velocity history blew up at the end of the
inflow ramp without this (reversed flow at the wall from edge dofs set by two
arrivals) and passed it with it.

Bubble penalty (degree 2, bubble_penalty, default 0): each edge dof's departure
from the mean of its two vertices is penalised in units of the bubble's own mass.
P1 content is untouched; the quadratic content is kept only where the arrivals
support it. The data of a fully covered cell hold a bubble with an effective
weight of about 0.04 in these units (the fit trades a cell's bubble against its
neighbours' dofs), so 0.01 removes about a fifth of a resolved quadratic and 0.1
about three quarters. The cylinder's fully semi-Lagrangian run (both histories
by the global projection) survived developed shedding with the penalty where it
failed without it; the forward nodal history exposes it as bubble_penalty.

Underworld development team with AI support from Claude Code
Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo
…nges

Every stress history sets the smoothing of its commit projection each step, and
the setter requested a rewire of the pointwise functions unconditionally, so the
C source of the committed flux was regenerated every step (5 s a step for the
Oldroyd-B shear box, 22 s for FENE-P, whose expression is larger; the compiled
module was then found in the cache and not rebuilt). With the rewire gated on a
change of value the shear-box solve goes from 7.5 s to 1.2 s. The baselines of
test_1059, 1067, 1102, 1103 and 1104 are unchanged.

Underworld development team with AI support from Claude Code
Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo
…ion named as independent choices

ViscoElasticPlasticFlowModel(element="maxwell"|"jeffreys", relaxation="linear"|"fene_p")
with Parameters.extensibility (L^2). The three choices a viscoelastic model makes
are independent and named by their mechanics: the spring-dashpot arrangement
(Maxwell; Jeffreys, a dashpot in parallel = the solvent viscosity), the
relaxation law (linear = Hookean; FENE-P = finitely extensible with the Peterlin
closure) and the objective rate. UCM is maxwell + linear + upper-convected,
Oldroyd-B jeffreys + linear + upper-convected, FENE-P jeffreys + fene_p +
upper-convected; a Burgers body would add a second history.

FENE-P takes its step on the conformation at the rate f(c*)/lambda with
f = (L^2 - d)/(L^2 - tr c), f* read from the record before the step (explicit,
first order) and carried as nodal fields so the matrix exponential of the record
does not enter the compiled flux through the coefficients; the stress is
G (f* c - I). The stretching source acts on G (c* - I), which is the carried
stress only for a linear spring, and keeps its Oldroyd-B weight: the f* on the
relaxation rate cancels against the f* on the stress for every source. Against
the closed-form steady simple shear (f^2 (f - 1) = 2 Wi^2 / L^2) the scheme is
first order in dt/lambda: errors of +0.0065 / +0.0095 in tau_xy / N1 at
dt = 0.1 lambda halving at 0.05. With infinite extensibility it is Oldroyd-B to
1e-8.

Underworld development team with AI support from Claude Code
Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo
… docs for the forward reconstructions and FENE-P

AdvDiffusion: the diffusive flux reads a stored level without a derivative through
its snapshot stand-in, as the Navier-Stokes solver does, so transport=
'backward_integration_points' runs at the default theta 0.5 (test_0066's 'cn'
case is now a comparison, not a refusal); a nodal trace-back marks the field
CARRY for a moving mesh, as the old solver did; the SUPG options are refused off
the Eulerian path instead of being set on a manager that ignores them; a
discontinuous field is refused for every transport; the V_fn setter reaches the
characteristic trace; a theta change across 1.0 requests a rewire (the
integration-point form drops the stored level at 1); estimate_dt defaults to the
cell-crossing time for a semi-Lagrangian transport; `transport` is readable.
NavierStokes: the extrapolation state (a_var, u_prev) exists on the Eulerian
path only; the SUPG options are refused otherwise; solve(order=) is refused
rather than dropped; `velocity_transport` is readable. The deprecation warning
on the former names says what differs (defaults, the meaning of order, the form
of estimate_dt). Baselines for AdvDiffusion with each transport on the rotating
Gaussian (test_1102). Docs: the per-cell fit against the global projection, the
bubble penalty, store smoothing on all three histories, the element /
relaxation / objective-rate taxonomy and FENE-P.

Underworld development team with AI support from Claude Code
Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo
…ctor on an empty rank, and the rest

HIGH: the FENE-P fields were named by id(self), which differs across ranks, and
variable creation is collective and keyed by name; the first parallel runs
deadlocked at their first snapshot. They are named by a counter. The projector's
build reshaped an empty cell list with -1 and raised on a rank with no cells;
the basis size now comes from the degree. FENE-P with the default (infinite)
extensibility was 0/0; it is refused at the first solve, and the spring factor's
denominator is floored at one percent of L^2 so a record whose trace passes L^2
by the explicit lag does not turn relaxation into growth.

MED: `element` is derived from the solvent viscosity when undeclared and
enforced when declared (a Maxwell element with a dashpot is refused). The
forward integration-point global path averages the previous field at a seam
vertex over the local cells only; it now sums across seams, so the answer is
partition-independent as documented. The forward nodal launch weights summed to
1.3 cell measures; each cell now shares its measure among its lattice points and
its dofs, so a covered cell reads as covered. The projection's solve checks
convergence. The FENE-P refresh reads coords_nd. fit_limiter is a property that
refuses the global reconstruction. The per-cell overshoot is reduced over ranks.
A user's remesh policy is not overwritten. Tests: an arrival on a shared face is
weighed once; the launch weights sum to the domain; the element rules; infinite
extensibility refused; the deficit-fill test is serial-only.

LOW: stale docstrings on the forward integration-point history, the restart
note on the reconstruction options, the smoothing check compares as a number,
utilities exports particle_projection, .array writes.

Underworld development team with AI support from Claude Code
Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo
@lmoresi

lmoresi commented Oct 4, 2026

Copy link
Copy Markdown
Member Author

Adversarial review, two rounds (static reading; findings and what we did)

Round 1, the one-NavierStokes / one-AdvDiffusion commit (78e62a9). 14 findings; the HIGH ones were real defects: AdvDiffusion(transport="backward_integration_points") failed at its default θ = 0.5 (the diffusive flux differentiated the integration-point store; it now reads the snapshot stand-in as the Navier-Stokes solver does, and test_0066's "cn" case is a comparison, not a refusal); the composed AdvDiffusion had no CARRY remesh policy for a nodal trace-back (ported); the FutureWarning said the new class "is" the old one while the defaults, the meaning of order and the form of estimate_dt differ (the warning now says what differs). MED: the SUPG options were silently inert off the Eulerian path (refused, with the Navier-Stokes extrapolation state allocated on the Eulerian path only); V_fn did not reach the characteristic trace; estimate_dt returned inf for a uniform start on a semi-Lagrangian transport (defaults to the cell-crossing time there); a θ change across 1.0 did not rewire the integration-point form; a discontinuous field was accepted. Baselines for the composed solver with each transport (test_1102). Not done: sharing one trace between the velocity and stress histories (two traces, both correct).

Round 2, bf4e663..59366e9 (store smoothing on every history, ParticleL2Projector and reconstruction="global", the degree-2 projector, the deficit fill and bubble penalty, the smoothing-setter rewire fix, FENE-P, round 1's fixes). 22 findings. HIGH, all fixed in fda1286: the FENE-P fields were named by id(self), which differs across ranks, and the first parallel runs deadlocked at their first snapshot (named by a counter now); the projector's build raised on a rank with no cells; FENE-P with the default infinite extensibility was 0/0 (refused). MED, fixed: element= was a dead argument (derived when undeclared, enforced when declared); the forward integration-point global path averaged the previous field at a seam vertex over local cells only (summed across seams now); the forward nodal launch weights summed to 1.3 cell measures (each cell shares its measure among its points and dofs); no KSP convergence check; .coords instead of .coords_nd; fit_limiter settable after reconstruction="global"; the uniform-stress tests could not fail on the multiplicity or the weights (a face-arrival test and a weight-sum test added). LOW fixed: stale docstrings, the restart note, the smoothing check, the utilities export, .array writes, the per-cell overshoot reduced over ranks, a user's remesh policy left alone. Left as they are: _fene_exact_conformation (used by the tests), flux_smoothing (one caller left), the health check reading the exact f where the weak form reads the lagged f* (documented).

Checked and found correct by the reviewer: the P2 edge-to-vertex mapping and basis gradients, the Dunavant rule and bubble mass, the seam assembly with no-overlap distribution, the deficit fill against the inflow top-up, the multiplicity correction, the FENE-P lag's consistency, the rewire fix's structural compare.

Full serial suite after round 2: 2839 passed, 17 skipped, 20 xfailed (one test updated for the clearer ValueError); the stress-transport, projection, FENE-P and composed-solver suites re-run after the last fixes.

Underworld development team with AI support from Claude Code

lmoresi and others added 3 commits October 4, 2026 23:27
… the forward nodal launch weights summed across seams

The fill of a cell's uncovered share from the previous field acted on any
shortfall. In a flow every cell's received weight fluctuates about its measure,
so about half the cells were pulled a few percent towards the previous,
un-advected field each step: a lag that acted as diffusion (a quarter turn of
the rotating Gaussian lost 18% of its peak with the forward nodal history and
6% with the integration-point one). The fill now starts at half the measure
and is complete at zero: an ordinarily covered cell feels nothing, an emptied
cell is held by what it carried. The Gaussian's peak is back above the per-cell
fit's (0.786 against 0.769).

The forward nodal history's launch weights summed each dof's shares over the
rank's own cells, so a seam node launched by its owner carried half its share
and the parallel field differed from serial; the shares are summed across the
seams through the projector's assembly. test_1066 carries both forward
histories with the global projection against serial baselines (np 3 equals
serial to 1e-6 for the stress box and the rotating Gaussian).

Underworld development team with AI support from Claude Code
Claude-Session: https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo
…trace

The log-conformation record for FENE-P was log c with c = (sigma/G + I)/f*,
f* the lagged nodal spring factor. The conformation is bounded (tr c < L^2)
and the nodal projection that commits the record is not: at the cylinder
wall, where log c jumps by 4 across one cell, the projection overshot by a
factor 1.5 in c and put the record on the saturation floor (tr c 105 of
L^2 100 from a stress whose own conformation had 70). The step then
alternated between the saturated and the free spring and the velocity
multigrid stalled: the FENE-P "hang" at step ~50 on the cylinder at Wi 1
(res 20) and Wi 0.5 (res 40).

The record is now log(sigma/G + I) for both relaxation laws: log c for a
linear spring, log(f c) for FENE-P. The decode recovers the conformation
through the trace, f* = 1 + (tr e^psi - d)/L^2 and c* = e^psi/f*, so every
SPD record is an admissible conformation whatever the projection did. The
stress reads as G(e^psi - I) for both laws; the relaxation rate keeps its
first-order lag in f*. Shear-box error constants are unchanged
(+0.0068/+0.0094 at dt 0.1, halving at dt 0.05). Re 100 Wi 1 res 20 on the
cylinder now runs through the ramp and on (the previous record hung at
step 54); the record's spring factor at the wall sits 60% above the
stress's own, which is the projection overshoot made harmless.

A FENE-P snapshot written by the previous record decodes wrongly under
this one; none of those runs survived to be worth restarting.

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_017kSkAq7oJ5J3XisuvLBovo
From the review of 3170929: the trace decode f = 1 + (tr e^psi - d)/L^2 is
positive for every SPD record only when L^2 > d, so the refresh refuses an
extensibility at or below the dimension (test); the nodal field the weak
form reads, and the carried strain and stress built from it, are now
checked against a record written directly (test); the admissibility
assertion pins the closed-form value 502/51 instead of a band; _peterlin_np
had no caller and is gone, _fene_spring_factor_of_stress is the record
formula applied to sigma/G + I with its floor and its claim about the
encode removed; the constructor comment no longer describes the log c
record; the diagnostic reads L^2 at the points like G; the docs say which
of the f* readers keep the lag and that an old FENE-P snapshot decodes
short by f under this record.

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_017kSkAq7oJ5J3XisuvLBovo
@lmoresi

lmoresi commented Oct 5, 2026

Copy link
Copy Markdown
Member Author

Review of 3170929 (FENE-P: store log(f c), decode the conformation through the record's trace), fixes in 020b02f.

Cause of the FENE-P stall on the cylinder: the record was log c, the nodal projection that commits it overshoots at the wall (tr c 105 with L^2 100 from a stress whose own conformation had 70), the spring factor hit its floor and the velocity multigrid ground to the guard's hang limit. Saturation and the lagged rate were ruled out from the snapshots (f 4.65 at the last one, floor 100).

Findings and what we did:

  • The trace decode f = 1 + (tr e^psi - d)/L^2 is positive only for L^2 > d; nothing checked it. The refresh now refuses an extensibility at or below the dimension (test).
  • The nodal field the weak form reads was no longer tested after the round-trip test was rewritten. A test writes a record directly and checks the field, the carried strain and the carried stress against the closed form.
  • The admissibility assertion was a band (9.7 to 10.0) around a closed-form value (502/51); it pins the value now.
  • _peterlin_np had no caller; _fene_spring_factor_of_stress carried a floor and a docstring about the encode that the encode never used. Removed and rewritten as the record formula on sigma/G + I.
  • Constructor comment described the old record; the diagnostic read L^2 with float(); docs now say which f* readers keep the lag and that a FENE-P snapshot from the previous record decodes short by f.

Verified correct: encode/decode algebra; every reader of the record (stress_star, E_eff, DEVSS projection, the solvers' decode paths) reads G(e^psi - I); the step algebra is unchanged; inflow encoding; level handling (FENE-P is order 1); units; parallel structure of the refresh.

Shear-box errors against the closed form: +0.0068/+0.0094 at dt 0.1, +0.0034/+0.0051 at 0.05, +0.0018/+0.0027 at 0.025. Cylinder Re 100: Wi 1 res 20 past step 300 (hung at 54 before), Wi 0.5 res 40 past step 100 (stalled at 71 before). All 8 tests in test_1068 pass.

@lmoresi

lmoresi commented Oct 6, 2026

Copy link
Copy Markdown
Member Author

Why this is red, and what it needs

The two CI failures are not this PR's. They are byte-identical across #785, #789, #795 and #800:

tests/test_1060_nitsche_freeslip.py::test_nitsche_normal_velocity_zero
    assert np.float64(0.0001008010278702676) < 0.0001
tests/test_1070_free_surface_plume.py::test_freesurface_strong_constraint_beats_penalty
    AssertionError: strong constraint not better: 1.73e-02 vs penalty 3.25e-02

Both tests were already fixed on development, for exactly these numbers, and neither name exists there any more. development's CI is green at 1391e4aa. The replacements name the failures in their own docstrings:

  • test_1060 → test_nitsche_constrains_the_wall_normal_velocity, a relative bound of 0.1: "the previous version of this test asserted an ABSOLUTE 1e-4, which scaled with the buoyancy forcing rather than with anything about Nitsche, and sat a fraction of a percent from failing for unrelated reasons."
  • test_1070 → test_freesurface_strong_constraint_tracks_the_prescribed_rate, an absolute bound of 5e-2 against the datum: "It replaces a comparison against the penalty path (errors["strong"] < 0.5 * errors["penalty"]) … measured 2026-09-14: 1.06e-2 on macOS, 1.73e-2 on the Linux CI runner, where the annulus triangulates differently."

So this branch is stale, not broken — 58 commits behind development.

The merge is not clean, and the conflicts are the same on all four

git merge origin/development conflicts in exactly two files on every one of the four branches (#800 adds a third, tests/test_0066_integration_point_slcn.py):

scripts/test.sh — one hunk, take THIS branch's side. development narrowed the glob to tests/test_1100*py; this side has tests/test_110*py. test_1101_advdiff_swarm_rotating_gaussian.py matches none of development's globs (test_1100*, test_1110*, test_1120*), so the narrow side would leave a test file dark — #721 recurring. scripts/check_test_coverage.py verifies the resolution.

src/underworld3/systems/ddt.py — five hunks, and four of them are mechanical. Four have a zero-line development side: they are this branch's own additions (applies_inflow_value, commits_flux_in_post_solve, the inflow_value setter — the #745/#783 inflow work), flagged only because surrounding context moved. None of those three names exists on development. Take this branch's side.

The fifth, at the top of _DDtBase, is a real design conflict and needs the author: this branch adds update_exp_coefficients / _exp_alpha / _exp_phi to the base class; development has since put update_exp_coefficients in three per-flavour classes (ddt.py:1299, 1734, 3612) with _exp_alpha/_exp_phi at :3623, 3628, and adds _note_history_shift in that same place. Keeping both gives a base-class definition under three overrides. development's per-flavour layout is the later design, but which one this branch's ETD work is written against is the author's call, not a merge-tool decision — and this is the DDt core, where CLAUDE.md asks for benchmarking rather than a judgement.

Order matters

All four touch ddt.py in the same region, so they conflict with each other as well as with development. They want merging one at a time, smallest first (#789, #785, #795, then #800), with a rebuild and the transport tests between each — the base moves for the rest after every merge.

Found in the backlog sweep (#819). No commits pushed to this branch.

scripts/test.sh and ddt.py resolve as on the other stale transport branches:
the broader `tests/test_110*py` glob (#721), and four ddt.py hunks whose
development side is empty plus one that takes both (the base-class ETD
coefficients from #739 and development's `_note_history_shift`). Verified on
the merged tree: one definition of each, no duplicates.

The third conflict is this branch's own rename. It replaces
`IntegrationPointSemiLagrangian` with the trace-and-launch naming
(`BackwardIntegrationPointsSemiLagrangian`), while development added a
`@pytest.mark.tier_a` to the same test. Resolved as the new class name plus
development's marker, which reproduces development's decorator layout in that
file exactly -- including the blank lines between the stacked decorators, so
`tier_a` attaches to `test_rotating_gaussian_ip_accuracy` as it does there.

Underworld development team with AI support from Claude Code
lmoresi added a commit that referenced this pull request Oct 6, 2026
This branch is the base of a four-PR stack (#785 -> #789 -> #795 -> #800) and
was 71 commits behind development, which is why all four showed the same two
CI failures: tests development had already renamed
(test_1060_nitsche_freeslip's absolute 1e-4 bound and
test_1070_free_surface_plume's strong-vs-penalty ratio). The refresh belongs
here, at the root, so each PR's diff against its base stays its own work rather
than growing development's history.

Two conflicts, both this branch's own additions. scripts/test.sh keeps the
broader `tests/test_110*py` glob -- development narrowed it to `test_1100*py`,
which matches none of the other test_110* files here (#721 recurring);
scripts/check_test_coverage.py verifies it. Four of the five ddt.py hunks have
an empty development side, and the fifth takes both: this branch's base-class
`update_exp_coefficients` / `_exp_alpha` / `_exp_phi` (#739, which replaced
three per-flavour copies dating from b6b0e7a in April) and development's
`_note_history_shift`. Verified on the merged tree: one definition of each.

Underworld development team with AI support from Claude Code
The rename lands in four places the branch had not reached, and one anchor was
stale.

`SemiLagrangian` is a factory over (trace, launch), not a class, so it carried
no class-level description -- and `tests/test_0017_describe_and_render.py`
requires every family to answer at that level, while
`docs/developer/subsystems/describe-and-view.md` calls
`SemiLagrangian.describe_class()` directly. The factory now delegates
`describe_class` and `view` to the default scheme, which is what it builds when
neither axis is given, and exposes `schemes` for a caller that wants another.

Three expectations follow the rename: the describe contract and the step
transcript now name `BackwardNodesSemiLagrangian`, and the transport-schemes
guide's front matter lists the four trace-and-launch families instead of two
names no class carries (`tests/test_0030_capability_guides.py`).

`docs/advanced/eulerian-advection-diffusion.md` described three managers; the
section now says how the two axes select a scheme, and records that a forward
trace's per-cell fit is unstable where the flow empties a cell (#811).

The eulerian cavity anchor is re-recorded. It moved 4e-05 relative on merging
development, and the new value is development's own: the same cavity with the
default EulerianSUPG history gives [-0.129019, -0.05467728] there, before and
after #833, unchanged to nine digits from tolerance 1e-8 through 1e-12. The
original was recorded 2026-09-29, before this branch last merged development.

That leaves something worth knowing: the Eulerian SUPG velocity answer moved
on development and nothing there anchors it. This file is the only test that
can see it.

level_1 tier_a: 1428 passed, and the one failure it had (the capability guide)
is fixed here. test_1103: 7 passed.

Underworld development team with AI support from Claude Code
Copilot AI balanced review requested due to automatic review settings October 8, 2026 04:14

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

This branch has not been deployed

No deployments
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