Skip to content

Stress histories in a units-aware model: reduce on the way in, read back in Pa (#788) - #789

Merged
lmoresi merged 4 commits into
developmentfrom
bugfix/stress-history-units
Oct 8, 2026
Merged

lmoresi merged 4 commits into
developmentfrom
bugfix/stress-history-units

Conversation

@lmoresi

@lmoresi lmoresi commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

Stacked on #785. Makes the viscoelastic stress histories, and swarm advection, work in a model with reference quantities.

Design. A history's stores hold non-dimensional values in .data, which is what the solver reads and what copies between stores go through. A solver builds its stress history with the stress's units, so .array and evaluate of the store read in Pa, as for any MeshVariable. What enters is reduced on the way in: the timestep where each flavour receives it, and anything written from evaluate (one rule, _write_evaluated: a result that carries units is reduced, one that does not is already non-dimensional).

What failed before, Maxwell shear box with reference quantities (1 km, 1e21 Pa s, 1 Myr):

before
semi_lagrangian TypeError on np.copy of a unit-aware array
integration_point TypeError: unhashable UWQuantity (timestep in the trace cache key)
lagrangian 1.5e148 after 20 steps; and the particles did not move at all (swarm advection used the dimensional velocity)
forward, eulerian correct non-dimensional stress, nothing read back in Pa
order 2, any flavour silently BDF-1 whenever the timestep unit differs from the reference time unit
a history started from a moving flow initial level written dimensional (Eulerian, Lagrangian_Swarm)

Test test_1064_stress_history_units: every flavour, in a units model with the timestep in kyr (not the reference unit) and as the same problem in plain numbers, from a moving start, at orders 1 and 2. The stores agree to 1e-6, and the stress reads back in Pa as the non-dimensional value times the stress scale.

The stress-history, swarm and advection test files pass (324, plus one known fragile test that now passes); the style gate is clean.

History of this PR. The first version stamped the flux's units onto every store; the second made the stores unitless with a separate dimensional read (carried). Adversarial review found the second reimplemented MeshVariable units and that its test was blind to the timestep, the order-2 guard, the initial writes and the swarm motion; this version replaces both.

Fixes #788.

Underworld development team with AI support from Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_017kSkAq7oJ5J3XisuvLBovo

@lmoresi

lmoresi commented Sep 24, 2026

Copy link
Copy Markdown
Member Author

Adversarial review before the push, one pass over the diff with probes run in the worktree. Found and fixed in the pushed commit:

  • The stamp read the flux's units with get_units, and the flux contains the store's own symbol, so the store's units fed back: pascal * second ** 2 / megayear ** 2 on the first stamp, one more s/Myr factor on every re-assignment (a parameter change re-assigns psi_fn), and .array read 2.7e34. The stamp now uses reduced units, a fixed point under re-assignment; the test re-assigns the flux and checks the units and the value again.
  • The integration-point flavour's projection scratch was in its store list and read back without reduction, so one re-assignment put dimensional values into storage (8.7e14 Pa after one step). It is scratch and is no longer stamped.
  • The per-flavour store lists had been inserted between a timing decorator and __init__, timing the getter instead of the constructor.
  • _units_of_components raised on a scalar flux; it coerces now, and refuses components of different dimensionality rather than taking the first.
  • The particle flavour computed its own units at construction, before its slots existed, and nothing stamped after; every flavour that assigns psi_fn before building its slots now stamps once the slots exist. Symbolic lists no stores.
  • The test now has a .array baseline in Pa and drops a tautological units check.

Recorded, not fixed: the strain-rate forcing stores keep units=None; get_units returns None for some fluxes that mix a unitless symbol in; _dt keeps the caller's form in the trace-back flavours. Checked and fine: the component layout against the old .array[:, i, j] semantics for symmetric and full tensors, the timestep reduction on floats, quantities and expressions, snapshot restore by name, the JIT reading non-dimensional storage regardless of the stamp.

…ork arrays, one dimensional read (#788)

Every history store is a non-dimensional work array behind the units
boundary and carries no units of its own. In a model with reference
quantities the histories failed in four ways: the nodal commit and the SUPG
carry copied through unit-aware .array views (np.copy raised); the particle
flavours wrote the dimensional values evaluate returns straight into storage
and ran away (1e148 after 20 steps); the integration-point trace cache keyed
on an unhashable unit-carrying timestep; and nothing read back in pascals.

- The timestep is reduced where every flavour receives it.
- Anything written from evaluate is reduced on the way in.
- Copies between stores go through .data with the component layout
  (EnhancedMeshVariable gains the layout delegate).
- DFDt.carried(level) is the one way to read a history back: the constitutive
  model supplies the map from the stored value to the stress, the value times
  the model's unit of stress, so the read is in pascals when reference scales
  are set and unchanged when they are not.

Test: the Maxwell shear box with reference quantities, all five
stress_transport flavours, read through carried and held to the loading curve
in Pa at the order-1 tolerance.

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 force-pushed the bugfix/stress-history-units branch from 55e12bf to 4462b7d Compare September 26, 2026 01:10
…eparate dimensional read (#788)

Adversarial review of the previous commit found the separate read (carried,
decode_history, a reference-stress expression, a private map handed from the
solver) reimplements what MeshVariable units already do, and found defects the
test could not see. Now:

- A solver builds its stress history with the stress's units (the flux it hands
  over at construction is a zero placeholder and cannot say so). .data stays
  non-dimensional; .array and evaluate read in Pa. One helper,
  _history_units, replaces the derivation pasted into four flavours, and
  Eulerian/EulerianSUPG stores take units too.
- One rule for writing an evaluation into a store (_write_evaluated): a result
  that carries units is reduced, one that does not is already non-dimensional;
  the re-wraps that assumed an unlabelled result was dimensional are gone, and
  so are the units attached before reducing in the inflow, integration-point and
  forward writes. The Eulerian and Lagrangian_Swarm initialisations were
  writing dimensional values unreduced.
- The BDF-2 step-ratio guard compared a dimensional dt_elastic with the
  non-dimensional history and silently fell back to BDF-1 whenever the time
  unit differed from the reference; both are compared non-dimensionally.
- The mirror write in the component copies (wrong for a full tensor, a no-op for
  a symmetric one) is removed; one timestep reducer, built on _as_float.
- Swarm advection wrote the dimensional velocity global_evaluate returns into
  its model-unit arithmetic, so in a units model the particles did not move
  (every swarm-carried quantity, not only stress); the reads are reduced.

Test: every flavour, in a units model with the timestep in kyr (not the
reference unit) and as the same problem in plain numbers, from a moving start,
orders 1 and 2: the stores agree to 1e-6 and the stress reads back in Pa as
the non-dimensional value times the stress scale. The previous test was blind
to the timestep, the order-2 guard, the initial writes and the swarm motion.

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 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 keeps this branch's broader `tests/test_110*py`. development
narrowed it to `test_1100*py`, which matches neither
test_1101_advdiff_swarm_rotating_gaussian.py nor the other test_110* files this
branch adds — #721 recurring. scripts/check_test_coverage.py verifies the
resolution.

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

The fifth takes both sides. This branch hoists `update_exp_coefficients` /
`_exp_alpha` / `_exp_phi` onto `_DDtBase` (#739, 2026-09-14), replacing three
per-flavour copies that date from 2026-04-28 (b6b0e7a); development adds
`_note_history_shift` in that same place. Keeping both duplicates nothing,
because this branch's own change already deletes the three copies and
development never touched them — verified on the merged tree: exactly one
definition of each of the four.

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
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.

1 participant