Skip to content

Make mesh.cell_size() local and partition independent - #692

Merged
lmoresi merged 3 commits into
underworldcode:developmentfrom
gthyagi:bugfix/cell-local-stabilization-length
Sep 6, 2026
Merged

Make mesh.cell_size() local and partition independent#692
lmoresi merged 3 commits into
underworldcode:developmentfrom
gthyagi:bugfix/cell-local-stabilization-length

Conversation

@gthyagi

@gthyagi gthyagi commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #569 and fixes #687 by making Mesh.cell_size() a per-cell geometric quantity that is independent of MPI partitioning.

The previous field used self._radii, whose nearest-centroid kd-tree contains only centroids held by the current rank. Near partition boundaries, the nearest available centroid can therefore change with rank count. Nitsche penalties and SUPG stabilization terms built from mesh.cell_size() consequently changed with the partition.

Change

  • Compute each cell's characteristic length as the RMS distance of its vertices from that cell's own centroid.
  • Read vertices from the DMPlex vertex stratum and coordinate-section offsets, covering triangles, tetrahedra, quadrilaterals, and hexahedra.
  • Cache the values as _cell_radii; the name describes cell geometry and does not overload own/owned, which elsewhere denotes MPI-rank ownership.
  • Use _cell_radii only for mesh.cell_size() and refresh its field after mesh deformation through the existing reinitialization path.
  • Preserve legacy kd-tree radii for global timestep estimates, adaptivity, and mesh-relaxation consumers.
  • Update the cell_size() and Nitsche test documentation to state the exact definition and the default Nitsche local-h consequence.

Why the scope is narrow

This does not redefine get_min_radius(), get_max_radius(), or get_mean_radius(). It corrects only the local field consumed by spatially varying stabilization and penalty terms. Follow-up issue #694 tracks the global accessor contract.

Regression coverage

  • Independent vertex-coordinate oracle for simplex and tensor meshes in 2-D and 3-D.
  • Initial and deformed geometries.
  • Analytical regular-square control and preservation of legacy global radius values.
  • Nitsche local-h magnitude, deformation refresh, and free-slip solve checks.
  • Enumerated tests/parallel/ regression that gathers every owned-cell (centroid, cell radius) row and compares the complete sorted table with a fresh np=1 process using the same cached Gmsh mesh.

Validation

Run Result Pytest time
Focused serial geometry/Nitsche suite 9 passed 7.93 s
Cell-by-cell partition regression, np=2 passed 9.15 s
Cell-by-cell partition regression, np=4 passed 6.72 s
Cell-by-cell partition regression, np=8 passed 8.68 s

The Stokes case emits the existing expected GAMG fallback warning because its tiny mesh has no multigrid hierarchy; all assertions pass.

This is split from #689 so the generic mesh-size correction can be reviewed and merged independently of Eulerian SUPG transport.

Underworld development team with AI support from Claude Code

…#687)

Adapt only the mesh-size correction from lmoresi's 68e545f on feature/navier-stokes-supg; do not import Navier-Stokes or other branch changes. Cache _radii_own from current DM vertex coordinates and use it for mesh.cell_size(). Preserve the legacy kd-tree radius arrays and global timestep/mesh-motion consumers.

Use coordinate-section offsets and the full vertex stratum so the own-cell RMS definition also handles hexahedra, which have eight vertices but six faces. Correct the field documentation and Nitsche mechanism tests for the new definition; retain physical solve tolerances and use the exact nearest-centroid <= own-centroid ordering instead of an arbitrary approximate-equality tolerance.

Add a first-failing independent geometry/deformation regression for triangles, tetrahedra, quadrilaterals and hexahedra plus a regular-square analytical control. Before: four failures in serial and on eight ranks. After rebuild: 21 passed/one expected skip serial (22.90 s), 22 passed on eight ranks (40.45 s), covering Nitsche solves, radius accessors, frozen PC2 migration and memory/disk snapshots. Own-cell geometry error is zero in these tests; style and whitespace gates pass.
@gthyagi
gthyagi requested a review from lmoresi as a code owner September 5, 2026 21:35
@lmoresi

lmoresi commented Sep 5, 2026

Copy link
Copy Markdown
Member

Reviewed by measurement. The fix works, exactly as claimed, and the defect
is real on current development. One gap worth closing before merge.

The fix does what it says

Per-cell values gathered and compared across rank counts on the same mesh
(198 cells, UnstructuredSimplexBox, cellSize=0.12), reading _radii_own
and _radii directly rather than sampling through evaluate():

field max abs diff np1 vs np2 np1 vs np4
_radii_own — what cell_size() now uses 0.000e+00 0.000e+00
_radii — the legacy kd-tree field 3.261e-03 4.110e-03

So the new field is bit-identical across partitions and the old one is not.
That also reproduces the original defect independently, which is worth having
on the record.

The gap: the property is never checked at more than one rank count

tests/test_0010_cell_size_geometry.py runs in the serial batch
(scripts/test.sh line 85, tests/test_00[0-4]*py). The new tests are written
with uw.mpi.comm.allgather, so they would be meaningful under mpirun — but
nothing runs them there.

What they assert is a within-rank oracle: each cell's size matches its own
vertices. That is correct, and it is not the claim in the title. Partition
independence is a statement about two different rank counts agreeing, and no
test compares two rank counts.

This is the shape that has bitten this repo twice in the last month. #675 was
two files that matched no glob in either test script and failed the first time
CI ever ran them; #615 fixed the enumeration for tests/parallel/ specifically
so a file could not go unrun. A test that is about parallel behaviour and only
ever executes at np=1 is the same hole in a different place.

Concretely: a tests/parallel/ case that builds the mesh, gathers
(centroid, cell_size), and asserts the sorted set matches a stored np=1
reference — or simply that _radii_own is bit-identical to the serial
reference, which is what the table above measures. tests/parallel/ is
enumerated since #615, so it will actually run.

Two smaller points

#687 duplicates #569 (15 August), which is still open and carries something
this PR description does not: the consequence. add_nitsche_bc(local_h=True) is
the default, so Nitsche answers moved 6.6e-03 between rank counts. Worth
closing both, and worth stating the Nitsche consequence in the changelog — it
is the reason anyone cares.

Scope is right. Leaving get_min_radius() / get_max_radius() /
get_mean_radius() and the timestep, adaptivity and relaxation consumers on the
legacy field is the conservative call, and the PR says so plainly.

Nothing here blocks the approach. The fix is correct and I would merge it with a
parallel guard added.

@lmoresi

lmoresi commented Sep 5, 2026

Copy link
Copy Markdown
Member

Follow-up on "why the scope is narrow", since the obvious next question is
whether everything else built on the legacy radii moves too. Measured on
development (UnstructuredSimplexBox, cellSize=0.12, 198 cells):

quantity np=1 np=2 np=4 np=8
per-cell _radii max abs diff 3.3e-03 4.1e-03
get_min_radius() 0.0502908499358 same same same
get_max_radius() 0.0670934714626 same same 0.070373950134
get_mean_radius() 0.0592989430362 0.0594222426856 0.0594775301273 0.0597593860929
SNES_Stokes.estimate_dt(), prescribed velocity 0.0545712806276149 identical identical identical

Timesteps are not currently affected, and the reason matters. Every
estimate_dt path reduces to a global minimumsolvers.py:947, 4745,
swarm.py, free_surface.py via get_min_radius(), and SNES_Stokes via
min(h_i/|v_i|) over the raw per-cell array. The minimum turned out to be
bit-stable across 1/2/4/8 here, so estimate_dt() is bit-identical.

I first measured a ~1e-5 spread in estimate_dt() and was ready to call it
partition dependence. It was not: re-running with the velocity written in
analytically rather than solved gives bit-identical answers at every rank count,
so that spread was the iterative solve, not the radii. Worth recording so nobody
else chases it.

But the insulation is incidental, not structural. The min is stable because
the minimising cell happens not to sit where the kd-tree lookup differs. A mesh
whose smallest cell lies on a partition boundary would move the timestep, and
nothing in the code prevents that.

What is already partition-dependent today, and is not fixed by this PR:

  • get_max_radius() — moves at np=8, by 4.9%.
  • get_mean_radius() — moves at every rank count.

So the narrow scope is defensible for cell_size(), but "preserve legacy
kd-tree radii for global timestep estimates, adaptivity, and mesh-relaxation
consumers" leaves those two returning partition-dependent numbers. Adaptivity
and relaxation consume them.

One more, minor: the follow_metric docstring in
src/underworld3/meshing/smoothing/api.py shows
gradient_smoothing_length=2.0 * mesh._radii.mean(). That is a rank-local
mean — a different value on every rank — so as a documented recipe it teaches
the partition dependence rather than avoiding it. Docstring only, not live code.

None of this needs to be in this PR. It is the follow-on issue, and #569 is
probably the right place for it.

@lmoresi

lmoresi commented Sep 5, 2026

Copy link
Copy Markdown
Member

Filed #694 for the other half, with your ruling recorded: get_*_radius() ought to be rank-independent because that is the contract they advertise, and anything local-only needs to say so in its name.

The fix is half-built here — _radii_own is partition-independent by construction, so building the three accessors on it makes all of them rank-count-independent and makes their docstrings true. Measured on this branch: per-cell own values are bit-identical at np=1/2/4 where the legacy field differs by 3.3e-03 and 4.1e-03.

Not asking for it in this PR. The scope here is right; #694 is the follow-on.

@lmoresi

lmoresi commented Sep 6, 2026

Copy link
Copy Markdown
Member

One naming request before this lands, and it is worth doing now because private
fields get copied.

_radii_own reads as the opposite of what it is. In this codebase own /
owned consistently means rank ownership:

swarm.py:3936             "keeping only locally-owned points"
petsc_compat.h:297        "sum local owned contributions across all ranks"
petsc_generic_snes_solvers.pyx:3032  "ghost ones and owned by a different rank"
petsc_generic_snes_solvers.pyx:3579  "closure DOFs that are non-owned on this rank"

So _radii_own parses as "the radii of the cells this rank owns" — a partition
concept — when it is the one field here that is partition-independent by
construction
. That is an unusually costly name for a field whose entire purpose
is the distinction it obscures.

Neither field is about rank ownership. Both are per-cell local arrays. The
real distinction is where the number comes from:

field derived from partition-dependent
_radii kd-tree over this rank's centroids — nearest-neighbour spacing yes
_radii_own this cell's own vertices about its own centroid no

A nomenclature that says that, per Charter §3 ("names state what a thing IS"):

  • _radii_own_cell_radii — the cell's own geometry, nothing else in it
  • _radii_neighbour_radii — says where the number comes from, and
    implies why it moves with the partition

_cell_radii / _neighbour_radii reads correctly at every call site and leaves
own meaning what it means everywhere else. Renaming the legacy field is a
larger diff and could reasonably be split out — but at minimum the new one
should not be called _radii_own.

Whatever is chosen, #694 will inherit it: the fix there is pointing
get_min/max/mean_radius() at this field, so the name lands in three more
call sites the moment that is done.

Rename the new per-cell geometric radius cache from _radii_own to _cell_radii so the name describes cell geometry rather than rank ownership. Update the focused Nitsche and deformation checks accordingly.\n\nAdd an enumerated parallel regression that gathers owned-cell centroid/radius pairs and compares the complete sorted table with a fresh single-rank run on the same cached Gmsh mesh. This directly guards the rank-count-independence claim at np=2, np=4 and np=8 instead of relying only on within-rank geometric identities.\n\nValidated locally with 9 focused serial tests and the new MPI test at 2, 4 and 8 ranks.
@gthyagi

gthyagi commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

Addressed the requested merge gap in 6fde1ac9.

  • Renamed the new geometry-derived cache from _radii_own to _cell_radii; own no longer conflicts with the codebase's rank-ownership terminology.
  • Added tests/parallel/test_1077_cell_size_partition_independence.py, which gathers owned-cell (centroid, cell radius) rows and compares the complete sorted table against a fresh np=1 run on the same cached Gmsh mesh.
  • The test is under the enumerated tests/parallel/ suite, so CI cannot omit it through a filename glob.

Local validation after rebuilding the branch:

Run Result Time
Focused serial geometry/Nitsche suite 9 passed 7.93 s
New partition regression, np=2 passed 9.15 s
New partition regression, np=4 passed 6.72 s
New partition regression, np=8 passed 8.68 s

This directly tests the title claim across rank counts; the existing independent vertex-geometry oracle remains as the separate correctness/deformation check. The practical Nitsche consequence remains documented in Mesh.cell_size(): default local-h penalty scaling no longer changes with partition-local neighbour spacing.

@gthyagi

gthyagi commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

For sequencing context, this fix is also the direct cause of #689's current sole CI failure (test_error_is_partition_independent, absolute difference 1.823e-08 against a 1e-08 gate). Once #691/#692 land, the Eulerian SUPG target branch can be synced and #689 rerun without carrying either generic mesh fix in its feature diff.

Remove the local_h=False workaround from the boundary-normal MPI regression now that Mesh.cell_size() is partition independent. The test again exercises the public local_h=True default and compares its Nitsche solve with a fresh serial process.\n\nRecord the user-visible consequence in the development changelog: the rank-local centroid kd-tree moved the default Nitsche velocity answer by 6.6e-3, while the cell-geometry replacement is identical cell by cell from one through eight ranks.\n\nValidated the focused Nitsche regression at 2, 4 and 8 Open MPI ranks (10.99 s, 7.31 s and 9.60 s respectively).
@gthyagi

gthyagi commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. Consolidated answers and the resulting changes:

  1. Cross-rank claim: addressed in 6fde1ac9. The new enumerated tests/parallel/test_1077_cell_size_partition_independence.py compares the complete sorted owned-cell (centroid, cell radius) table with a fresh np=1 process on the same mesh. It passes at np=2, 4 and 8 with atol=1e-14.

  2. Naming: agreed. The new field is _cell_radii, not _radii_own; own/owned remains reserved for MPI ownership. I have not renamed legacy _radii in this PR because its remaining users and eventual deletion belong to the coordinated Cell size: one definition, one name — three are in play, the docstrings describe a fourth, and get_*_radius() is partition-dependent #694 cleanup.

  3. mesh.cell_size() is partition-dependent (kd-tree of this rank's centroids) — and add_nitsche_bc(local_h=True) is the DEFAULT, so Nitsche answers move 6.6e-03 between rank counts #569 and mesh.cell_size() is partition-dependent: the per-cell radius comes from a kd-tree over the rank's centroids, so cells at partition boundaries get a different size on each rank count #687: agreed that they are the same defect. The PR description now says Fixes #569 and fixes #687, so this PR will close both.

  4. Default Nitsche consequence: agreed, and now recorded in docs/developer/CHANGELOG.md: the old rank-local centroid kd-tree moved the default local_h=True velocity answer by 6.6e-3 between rank counts. Commit e6eaac28 also removes the local_h=False workaround from test_1069; the test now exercises the public default and matches its fresh serial reference at np=2, 4 and 8 (10.99 s, 7.31 s and 9.60 s).

  5. Global radius accessors and timestep: this PR does not claim to fix them. get_min/max/mean_radius() still reduce legacy _radii; that makes each call rank-symmetric but not rank-count independent. The observed get_min_radius()/estimate_dt() stability is incidental, as you measured. The follow_metric example using mesh._radii.mean() is likewise still wrong and belongs to Cell size: one definition, one name — three are in play, the docstrings describe a fourth, and get_*_radius() is partition-dependent #694.

  6. PETSc FVM question: neither legacy _radii nor this PR's _cell_radii uses DMPlexComputeGeometryFVM. This PR's value is the RMS distance of a cell's vertices from its own centroid. The evidence on Cell size: one definition, one name — three are in play, the docstrings describe a fourth, and get_*_radius() is partition-dependent #694 shows the PETSc volume**(1/dim) route now returns one finite, partition-identical value for simplex/tensor cells in 2-D/3-D and after deformation. I agree that it is the better final canonical definition: Cell size: one definition, one name — three are in play, the docstrings describe a fourth, and get_*_radius() is partition-dependent #694 should make that the sole _cell_radii, point cell_size() plus all three accessors at it, remove the kd-tree/dead arrays, and fix the follow_metric example. I am keeping that larger semantic cleanup separate because the comments here explicitly treated it as a follow-on rather than a blocker for Make mesh.cell_size() local and partition independent #692.

The branch is pushed at e6eaac28; GitHub CI has restarted for that head.

@lmoresi
lmoresi merged commit e5ceed7 into underworldcode:development Sep 6, 2026
2 checks passed
lmoresi added a commit that referenced this pull request Sep 7, 2026
…nded cell_size (#692) over the branch's #687 version

Same definition (RMS distance of a cell's vertices from its own centroid), one implementation.

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 added a commit that referenced this pull request Sep 7, 2026
…pped; Picard reductions on every pass

The merge of development took discretisation_mesh.py and test_1065 wholesale from
development, losing the orphaned-field packing by name (test_1058) and the rest of
the branch's non-conflicting edits; this is the hunk-by-hunk resolution with the
landed cell_size (#692). The Picard loop of the Navier-Stokes solver now takes its
two reductions on every pass, and the break predicate is recorded as rank-uniform
in the collective-guard scan.

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 added a commit that referenced this pull request Sep 8, 2026
…independent cell size (#687) (#688)

* Expose the BDF and Adams-Moulton coefficient symbols on the DDt managers

A solver that assembles its own weighted sum of history terms (an Eulerian
scheme applying a multistep rule to a spatial operator) needs the
constants-routed coefficient expressions, not just their current values.
Read-only accessors; no behaviour change.

Underworld development team with AI support from Claude Code

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

* Pack and index auxiliary fields by DM field, not by position in mesh.vars

A MeshVariable that is dropped and garbage-collected (the default Model
holds the only strong reference; uw.reset_default_model() releases it, and
the statistics helpers delete temporaries deliberately) leaves its PETSc
field in the DM. Mesh.update_lvec zipped mesh.vars.values() against the
field decomposition by position, and the JIT's petsc_a[] offsets were a
running count over the live variables, so every later variable was packed
into, and read from, the wrong slots. Measured: a P0 cell-size field landing
in a P2 slot as garbage, NaN residuals in one run and a subtly wrong answer
in the next, depending on when the collector ran.

update_lvec now packs by field name and zeroes an orphaned field; the JIT
reads component offsets from the DM's own field list and patches each
variable from its field_id. Regression test: 2 of its 3 checks fail without
the fix.

Underworld development team with AI support from Claude Code

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

* Add the RotatingGaussian transport oracle; fix the integral-norm error for scalar variables

A Gaussian carried round the origin by rigid rotation while diffusing is
exact at every time (rotation commutes with the Laplacian), so a transport
scheme's error can be measured directly and the round trip after one
revolution is an absolute check. AnalyticSolution.error(norm='integral')
added a 1x1 Matrix symbol to a scalar expression and had never been
exercised on a scalar variable.

Underworld development team with AI support from Claude Code

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

* Extract the per-element timestep estimate shared by the advection-diffusion solvers

The cell-crossing / diffusion-time reduction (isotropic or direction-aware,
minimum or percentile) becomes a module-level helper so the Eulerian
solver can call it rather than carrying a copy. SLCN behaviour unchanged.

Underworld development team with AI support from Claude Code

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

* Skip the mesh-owned multigrid pickup for a solver that owns its preconditioner

A solver with no managed option block (_pc_option_prefix is None) sets its
own PC; installing the adapt child's PCMG hierarchy on it segfaulted inside
PETSc (additive-Schwarz PC, PCMG calls). The gate now treats that state as
the explicit choice it is, alongside preconditioner='gamg' and the user
override latch.

Underworld development team with AI support from Claude Code

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

* Eulerian advection-diffusion with SUPG: BDF and Adams-Moulton orders from the symbolic history

uw.systems.AdvDiffusionSUPG(mesh, T, V_fn, order=N, integrator='bdf'|'am')
assembles the implicit weak form from the Eulerian DDt history: the BDF
stencil or the Adams-Moulton weights on the advective and diffusive terms
at every stored time level, plus the SUPG flux tau R u with the strong
residual of the same scheme. Timestep, multistep coefficients and the tau
weights are runtime constants of the compiled kernels, so a change of dt
costs nothing (the issue #657 prototype recompiled on every change).
Diffusivity comes from the constitutive model like every scalar solver.

Measured on the rotating Gaussian: stable at any cell Courant number, error
set by u dt against the feature width (dt^2 for the second-order schemes),
unchanged to three digits by a band refined to h/9 at local Courant 13;
Crank-Nicolson reproduces the prototype's numbers to four digits.
Tests: API and no-recompile contract, temporal convergence (slopes 0.8/0.9
for BDF1, 1.9 for BDF2), band invariance, round trip, np=2 = serial.

Underworld development team with AI support from Claude Code

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

* Design note for the Eulerian SUPG solver; BDF2 becomes the default from the integrator study

Rotating-Gaussian study at res 32, Courant 0.25 to 8, pure advection and
kappa 1e-3: Adams-Moulton above order 1 blows up from Courant 1 (bounded
stability region), BDF3 fails from Courant 4, Crank-Nicolson is three to
four times more accurate than BDF2 at the same timestep but rings once the
feature is under-resolved in time, backward Euler carries 20-40% error at
any practical timestep. Cost per step is the same for every scheme. BDF2 is
the robust default; the note records the alternatives and when to pick them.

Underworld development team with AI support from Claude Code

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

* Integrator study, res 64: BDF3 grows slowly on pure advection at any Courant number

BDF2 and Crank-Nicolson track their res-32 errors at the same u dt. BDF3's
stability region misses the imaginary axis near the origin, so the
low-frequency modes of a finer mesh grow: 31x the exact field after 590 steps
at Courant 1. Safe only with diffusion, below Courant 2. Note and docstring
updated; the BDF2 default stands.

Underworld development team with AI support from Claude Code

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

* AdvDiffusionSUPG takes the semi-Lagrangian solver's interface: a drop-in replacement

The constructor, order, theta, f, V_fn, constitutive_model, delta_t,
estimate_dt and solve keep the meaning they have for AdvDiffusionSLCN, so a
script changes the class name and nothing else. order=1 with theta=0.5 is
Crank-Nicolson and the default, as for SLCN; order=2 takes theta=1 (BDF2)
unless 0.5 is asked for explicitly, which is refused for the reason the SLCN
documentation gives. The trace-back-only arguments (restore_points_func,
monotone_mode, old_frame_traceback, DFDt) are accepted and ignored with a
warning. integrator is inferred and only needs setting to reach the higher
Adams-Moulton rules. delta_t is settable and solve() reuses it; the notebook
viewer reports the scheme. User page docs/advanced/eulerian-advection-diffusion.md
with the swap table and the when-to-use-which guidance.

Underworld development team with AI support from Claude Code

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

* Drop the integrator argument: order and theta already reach every safe scheme

The only schemes the argument added were Adams-Moulton at orders 2 and 3,
which the integrator study shows blowing up on advection from Courant 1.
The multistep family now follows the order (the theta rule at order 1, BDF
above); the higher Adams-Moulton assembly stays in the code, reachable only
by switching the family on the instance, which is how the study measured it.

Underworld development team with AI support from Claude Code

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

* An accuracy-based timestep for the Eulerian solver; credit NengLu in the module and note

estimate_dt now returns the step at which the field changes by a fraction
(0.02) of its range: from the advective rate |u . grad phi| at the vertices
before the first solve, and from the rate the last step actually produced
after it. The cell-crossing time the semi-Lagrangian solver reports is not
a stability limit for this scheme and says nothing about its accuracy; it
stays available as basis='resolution'. The estimate is mesh-independent,
which the band test now checks (the resolution estimate collapses 3x on
the refined child, the accuracy estimate moves under 25%), and at the
default fraction Crank-Nicolson completes the rotating-Gaussian round trip
under one per cent. The advective rate uses the vertex Clement gradient
rather than a point evaluation of a derivative expression, which fails on a
mesh carrying many variables.

Underworld development team with AI support from Claude Code

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

* Match the Krylov tolerance to the SNES tolerance, and make preconditioner="fmg" a real switch on the SUPG solver

The Eulerian SUPG step took two Newton iterations on a linear operator:
the Krylov default (rtol 1e-5) does not reach the SNES tolerance (1e-8),
and the second Jacobian assembly cost more than every linear solve of
the step. The Krylov tolerance is now 1e-9 and a step is one Newton
iteration: 1.54 s to 0.91 s per step at 256^2 in serial.

Measured against geometric multigrid at matched tolerances (design note,
"Preconditioner"), GMRES with additive-Schwarz ILU is the cheaper linear
solve at every Courant number from 1/2 to 32 and its iteration count is
the same on one and eight ranks; the multigrid's cycle count grows with
the Courant number nearly as fast, and a cycle costs about three Schwarz
iterations. Schwarz stays the default on every mesh.

preconditioner = "fmg" now hands the block to the managed multigrid
route (custom-P transfers over the refinement hierarchy or an adapt
child's coarse tail, flexible GMRES outside) for the rank count where a
one-level method runs out of coarse space. The solver's solve() builds
through the base _build, where a preconditioner choice is resolved; the
pre-run of the three setup stages marked the solver set up first, so the
request was silently inert. The semi-Lagrangian solvers share that
pattern and the defect (#683).

Underworld development team with AI support from Claude Code

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

* Design note: the 512^2 rows at matched tolerance

* Let theta be set after construction, as the semi-Lagrangian solver allows

The shipped convection examples set adv_diff.theta = 0.5 after building
the solver; the Eulerian drop-in refused it. The blend is a runtime
constant refreshed from the history manager before every solve, so the
setter updates it without a recompile (order 1 only, the constructor's
rule). Vector and tensor unknowns join the design note's deferred list:
the solver is scalar, where the semi-Lagrangian trace-back carries them.

Underworld development team with AI support from Claude Code

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

* Navier-Stokes with Eulerian SUPG momentum transport, and a partition-independent cell size

uw.systems.NavierStokesSUPG: the incompressible Navier-Stokes equations
on the Stokes saddle-point solver with the momentum advection assembled
implicitly and stabilised by the vector SUPG term F1 = tau R (x) a, the
counterpart of the scalar Eulerian solver. Crank-Nicolson at order 1,
BDF2 at order 2, with the velocity history on the mesh; no stress
history, the viscous stress at an earlier level is rebuilt from the
stored velocity through the constitutive model. The advecting velocity
is a choice: the second-order extrapolation 2u^n - u^{n-1} (one linear
solve per step, the default), Picard passes on the latest iterate, or
the unknown itself under Newton. The strong residual the SUPG term sees
carries the pressure gradient; without it the term is O(1) at the exact
solution and costs fifty times the Galerkin error on Kovasznay flow.

mesh.cell_size() now reports each cell's own radius, the RMS distance of
its vertices from its own centroid, taken from the DM's coordinates. The
kd-tree radius it used to copy picks the nearest centroid among the
rank's cells, so the field differed with the partition (#687, found
because the two-rank Navier-Stokes answer differed from serial by 5e-4
and matched to 1e-15 with a constant h); after a deform it also read
stale vertex coordinates against fresh centroids. get_min_radius and
the other consumers of the kd-tree radii are unchanged.

Tests: the solver's API contract (construction rules, one linear solve
per step, Picard passes, the Stokes limit, runtime-constant timestep and
theta), a two-rank Kovasznay error that matches serial to 1e-7, the
scalar parallel reference re-recorded for the new cell size, and the
Nitsche local-h tests reading the field's definition.

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

* Document the SUPG Navier-Stokes solver: user page and the design-note section with Kovasznay and cavity results

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

* Design note: cavity Courant 1 row, cylinder wake rows, and the corrected Re 1000 status

* Design note: the semi-Lagrangian cylinder row

* Design note: Re 1000 cavity rows, the finer cylinder mesh, and the Galerkin control that cannot run

* Swarm.advection: let estimate_dt see a rank that holds no particles (#693)

The velocity evaluated for the timestep estimate has shape (0, 1, dim) on
an empty rank, and reshape(0, -1) cannot infer the trailing size; the
empty-rank handling a few lines below never ran. Give reshape the size
explicitly. Found with passive tracers released at the inlet of the DFG
cylinder on four ranks, where every rank but the inlet's is empty at the
first step.

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

* Set the PETSc constants on the DS the integrals use, so expression values reach the kernels (#695)

uw.maths.Integral, BdIntegral and CellWiseIntegral compile their integrands
through the same JIT as the solvers, which routes every
uw.function.expression to PETSc's constants array, but none of them ever
called PetscDSSetConstants: the kernels read zeros, so any integrand with a
viscosity, a time or another expression in it integrated to nothing, and a
fresh Integral returned the same zero from the cache. Found on the DFG
cylinder drag, where the viscous traction (eta is an expression) vanished
and the drag read 23 to 28% low on two meshes without moving with the SUPG
weights.

Each class now packs the manifest and sets the constants right after the
objective; the boundary integral sets them on its sandbox DS, which has its
own discrete system. Regression test test_0503 covers the three classes, a
changed value without recompilation, and the constitutive-flux traction
that found 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

* Design note: the cylinder drag was the missing viscous traction (#695), the vortex-decay benchmark, and #696

The DFG cylinder section is rewritten around what the tau sweep found: the
stabilisation moves the drag by 2.6% and the deficit was the boundary
integral dropping the viscous part (#695). With the integrals fixed and only
the cylinder cells refined through gmsh at a fixed time step, drag, pressure
difference and Strouhal number converge onto the reference bands on the 1/20
channel mesh, the traction and reaction measurements close on each other,
and the whole-mesh 1/40 run buys less than the 1/320 cylinder cells do. The
Galerkin form that "could not run" was the GAMG fallback; the refinement
callback gives FMG on the gmsh mesh.

New Taylor-Green vortex-decay subsection (dt and h sweeps for CN and BDF2,
Galerkin against SUPG, the viscosity range, the advecting-velocity choices),
and the two defects it found: #695 and the zero-valued expression folding
(#696, raised, not patched).

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

* Design note: the FMG rows of the cylinder table (base mesh refined through the circle callback)

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

* Design note: the Picard row at 1/640 cylinder cells settles the lift overshoot as the extrapolation lag

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

* Take swarm.py from development (#680): the empty-rank estimate_dt guard supersedes the branch's reshape fix

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

* Design note: LU is serial-only on the velocity block; parallel tracers run with #680

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

* Design note: the FMG cylinder-refinement table (Picard, Newton, BDF2, four ranks)

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

* NavierStokesSUPG: an opt-in recovered viscous term in the SUPG residual

recovered_viscous=True projects the deviatoric stress of the advecting
velocity onto a continuous symmetric tensor before each solve pass and
puts its divergence in the strong residual the SUPG term sees. Without it
the residual lacks the viscous term (the kernels see first derivatives
only), an O(h^2) inconsistency for P2 velocity that shows on resolved
viscous flow: four times the Galerkin error on the 1/64 vortex-decay
mesh, sixteen times on Kovasznay at 1/32. The projection's function is set
on first use (it needs the constitutive model) and its default is a zero
matrix, not None.

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

* NavierStokesSUPG: the recovered viscous term is the previous level's momentum balance

The differentiated projection of the stress was unstable (1/64 vortex decay
and Kovasznay at 1/32 blew up) and did nothing at 1/32. Louis's form: the
momentum balance of the stored level gives div sigma^n = rho (Du/Dt)^n +
grad p^n - f from first derivatives of stored fields, so the residual the
SUPG term weights becomes the increment of the out-of-balance force between
levels, at the cost of one stored pressure level and no extra solve. At a
discrete steady state that residual vanishes and the stabilisation switches
off, which is a property to measure, not assume.

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

* Design note: the recovered viscous term measured (balance form = Galerkin accuracy on resolved flow, unstable on the cylinder)

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

* NavierStokesSUPG: recovered_smoothing projects the balance term with a screened-Poisson length

The plain balance term is unstable where advection dominates because it
carries the grid-scale residual of the previous step. With a smoothing
length the term is projected onto a continuous vector field (one vector
projection per step), keeping the smooth viscous divergence and filtering
the rest; zero keeps the plain form.

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

* Revert "NavierStokesSUPG: recovered_smoothing projects the balance term with a screened-Poisson length"

This reverts commit 19edda1.

* Revert "NavierStokesSUPG: the recovered viscous term is the previous level's momentum balance"

This reverts commit c5c72ec.

* Revert "NavierStokesSUPG: an opt-in recovered viscous term in the SUPG residual"

This reverts commit 42aaedf.

* Design note: the recovered viscous term measured three ways and withdrawn

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

* NavierStokesSUPG: tau_shape selects the Brooks-Hughes or doubly asymptotic parameter

The inverse-sum tau (Shakib-Tezduyar) is above the optimal 1-D curve at cell
Peclet numbers of order 1 to 10, where the resolved benchmarks sit. The
optimal shape tau = (h/2|a|)(coth Pe - 1/Pe) and its two-limit approximation
(h/2|a|) min(Pe/3, 1) are now selectable, each combined with the transient
term so the time step still caps them. coth is written through tanh: the C
printer rewrites coth through exp and drags the square root in |a| into
exp(log(.)), which brings arg() into the kernel.

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

* Design note: the shape of tau measured (Brooks-Hughes, doubly asymptotic)

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

* Design note: the Re 1000 cavity converged (94 to 96% of Ghia at 1/64); the rank-local v_max explained

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

* NavierStokesSUPG: peclet_weight turns the SUPG term off where the cell is diffusion-dominated

The term is multiplied by Pe^2 / (Pe^2 + Pe_c^2) with Pe the cell Peclet
number of the advecting velocity, so it is absent where it is not needed
(where it costs a fixed multiple of the Galerkin error) and full where
advection dominates. Zero (default) leaves the weight uniform.

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

* Design note: the weight by cell Peclet number measured (Galerkin accuracy where resolved, stabilisation kept on the cylinder)

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

* NavierStokesSUPG: the cell-Peclet weight is the default (Pe_c = 4)

Louis's ruling, the code being unreleased: the SUPG term is weighted by
Pe^2 / (Pe^2 + 16) by default, off where a cell is diffusion-dominated and
full where advection dominates. Kovasznay at 1/8 (the parallel test's
reference) goes from 3.83e-3 to 1.42e-3; the design note's earlier tables
were made at the uniform weight and say so. The scalar transport solver
keeps the uniform weight until its convection benchmarks are re-measured.

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

* AdvDiffusionSUPG: the cell-Peclet weight, as for the Navier-Stokes solver (Pe_c = 4)

Written without dividing by kappa, so pure advection (the default kappa = 0)
keeps the uniform weight and its tests do not move. The convection
benchmarks are re-measured with it in the design note.

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

* Docs: the cell-Peclet weight on the scalar solver's user page

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

* Design note: the cell-Peclet weight is the default of both solvers; the convection rows with it

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

* Examples: the SUPG Navier-Stokes solver on the lid-driven cavity and the Taylor-Green vortex

Two runnable examples in the repository's format: the cavity at Re 100
against Ghia (about four minutes) and the Taylor-Green vortex decay with
its exact error and energy decay (about a minute).

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 DDt history manager is the transport plugin: EulerianSUPG assembles advection and SUPG, the solvers compose

A solver that owns an unknown now composes its residual from three
contributions of its DuDt (time_derivative, advection, stabilisation_flux)
plus the levels and weights of the scheme (states, spatial_weights), and
never asks which flavour it holds. The new ddt.EulerianSUPG assembles the
implicit advection component-wise for a scalar, vector or tensor unknown
and the SUPG flux tau R (x) a of the solver's strong residual; the
history-carrying flavours answer zero for both. V_fn is data on the
manager (V_fn_history names the carrier of the stored levels, the stored
velocity for momentum), the timestep is a runtime constant every flavour
writes (delta_t), and the stabilisation knobs live on the manager with
the solvers' properties passing through.

AdvDiffusionSUPG and NavierStokesSUPG lose their own residual code and
compose the same way. A SemiLagrangian manager dropped into the scalar
solver reproduces AdvDiffusionSLCN on pure advection; a flattened
symmetric tensor is transported through SNES_MultiComponent with a
residual that is only the manager's terms (test_1057). The plain
Eulerian manager keeps its explicit splitting correction behind an
_advection_mode gate and gains num_components for MATRIX histories.

Regression: Kovasznay 1/16 and 1/32, the vortex decay at 1/32, the
Blankenbach box and both examples reproduce their recorded numbers to
every printed digit; the cylinder keeps its mean drag, lift extrema and
Strouhal number, with the drag peak moving 3.0797 -> 3.0802 (evaluation
order in a shedding wake). Two-rank tests keep their serial constants.

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 composing solvers take the generic names: AdvDiffusion and NavierStokes; the semi-Lagrangian classes keep their SLCN names

A solver that composes its transport from its DDt manager is not an SUPG
solver: SUPG is a property of the EulerianSUPG manager it holds by default,
and a SemiLagrangian manager makes the same solver a semi-Lagrangian
scheme. So uw.systems.AdvDiffusion and uw.systems.NavierStokes now name
the composing solvers (SNES_AdvectionDiffusion_Composed,
SNES_NavierStokes_Composed) and the SUPG class names are gone. The
semi-Lagrangian classes stay reachable as AdvDiffusionSLCN,
NavierStokesSLCN and NavierStokesSwarm; every existing use of the generic
names with the semi-Lagrangian meaning in docs, notebooks, examples and
tests is moved to the explicit SLCN name, so nothing changes 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

* Restore the branch's mesh changes the whole-file merge resolution dropped; Picard reductions on every pass

The merge of development took discretisation_mesh.py and test_1065 wholesale from
development, losing the orphaned-field packing by name (test_1058) and the rest of
the branch's non-conflicting edits; this is the hunk-by-hunk resolution with the
landed cell_size (#692). The Picard loop of the Navier-Stokes solver now takes its
two reductions on every pass, and the break predicate is recorded as rank-uniform
in the collective-guard scan.

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

* Parallel tests: a refinement hierarchy for the Navier-Stokes reference, platform-tolerant comparison to the serial error

The GAMG fallback on a mesh without a hierarchy gave a platform-dependent answer
(7% on the Linux CI); the test now refines a 1/4 mesh once so the velocity block
runs geometric multigrid, and the serial reference (0.00132279) is met by two and
four ranks to 3e-10. Both tests compare to the serial error at 1e-6 relative:
the partition effect they guard against was 5e-4 (#687), platforms differ at 1e-7.

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 the plugin and the rename: six fixes

From three reviews of the branch head (findings posted on #688):
- the base contract's shape helper collided with Symbolic's `_shape` attribute,
  so Symbolic.advection() raised instead of answering zero; renamed;
- a user-supplied EulerianSUPG on NavierStokes advected the stored level with
  the new velocity: the solver now sets V_fn and V_fn_history whoever built
  the manager, and its advection setter only steers such a manager;
- the change-rate bookkeeping read the manager's history `.array`, which fails
  for a SemiLagrangian history under units and for a swarm-backed history; it
  now diffs a copy of the unknown's data;
- a supplied manager silently overrode `order`/`theta`; a mismatch is an error,
  and the theta setter refuses a manager without theta;
- the 1-D tau shapes divided by the diffusivity (zoo at the manager's default);
- the timestep and SUPG knobs are created with unique names like the BDF
  coefficients, so they do not accumulate in the persistent registry;
- a bare scalar residual is accepted by stabilisation_flux.
Rename loose ends: an example that imported the bare NavierStokes name now uses
NavierStokesSLCN explicitly; tutorial 9 prose; the solver-unification design
table; API entries for the composing classes and the manager.

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

* DDt: a quantity timestep is non-dimensionalised before it reaches the kernels (#701)

_as_float took the magnitude of a Pint or UW quantity, so a semi-Lagrangian solver
stepped with 100 kyr under a 1 Myr reference time wrote 100 (not 0.1) into the
manager's runtime timestep and into the variable-step BDF bookkeeping. It now goes
through uw.non_dimensionalise, which handles both quantity types; without reference
scales the magnitude is what comes back. Test with a negative control in test_1057.

Closes #701.

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

* Swarm.advection: a clear error for a swarm that was never populated (#702)

DMSwarm reports a local size of -1 until particles are added on some rank, and
the advection then failed inside numpy with 'negative dimensions are not
allowed'. The empty rank of a populated swarm (size 0) is unchanged.

Closes #702.

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

* UWexpression never reports is_zero, is_positive or is_negative from its value (#696)

A runtime constant's value can change after construction, so sympy must not fold
on its current sign or on it being zero: exp(c) with c created at 0 evaluated to 1
at construction and a time ramp that started at t = 0 stayed frozen (found on the
Taylor-Green Dirichlet case). The three assumptions now answer None, as for a
plain Symbol; the value is read when the expression is unwrapped for compilation.
Control in test_0503: exp(c) survives, integrates to 1 at c = 0 and to e at c = 1.
Level-1 suite: 1704 passed.

Closes #696.

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 review of #688: NavierStokes.estimate_dt dimensionalises the accuracy estimate; EulerianSUPG takes no mutable default bcs

The accuracy basis returned a bare non-dimensional number while the resolution
fallback returns a quantity under a scaling model; both now come back through
_dimensionalise_dt (test under reference scales). The manager's bcs default is
None -> a fresh list; a caller's list is still kept by reference on purpose, so a
solver's live essential_bcs reach the projections.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
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