Skip to content

ETD Maxwell integrator: a viscosity that varies in space keeps its elastic response - #851

Open
lmoresi wants to merge 15 commits into
developmentfrom
bugfix/etd-variable-maxwell-time
Open

lmoresi wants to merge 15 commits into
developmentfrom
bugfix/etd-variable-maxwell-time

Conversation

@lmoresi

@lmoresi lmoresi commented Oct 18, 2026

Copy link
Copy Markdown
Member

The exponential (ETD) Maxwell integrator formed alpha = exp(-dt/tau) and phi from float(viscosity)/float(modulus). A viscosity written as an expression of position or of a field (a damage band, a temperature-dependent law) has no float; the Maxwell time fell to None, which the coefficient update reads as the viscous limit (alpha = phi = 0). The model silently lost its elastic response wherever the viscosity was not a plain number (measured on a box fault model: first-step end velocity 0.007, the viscous value, against 0.061).

What changes:

  • _etd_maxwell_time(eta, mu), used by all three callers (ViscoElasticPlasticFlowModel, TransverseIsotropicVEPFlowModel, TransverseIsotropicVEPSplitFlowModel): a number when both parameters are numbers, otherwise the ratio of the parameters' own expression atoms, so the kernels evaluate it pointwise and the units system scales it.
  • ddt._update_exp_values(..., dt_symbol=...): the symbolic coefficients take the step as the dt_elastic atom, so a new step reaches the kernels as a constant (no recompile, no stale coefficient); phi is -expm1(-x)/x; the Maxwell time and the modulus are floored with uw.maths.functions.vanishing, so a vanishing modulus is the elastic limit as on the numeric path.
  • tests/test_1053_ve_etd_variable_maxwell_time.py (level_2, tier_b): a numerically uniform expression (1 + 1e-12 x) must reproduce the constant to 1e-10 at order 1 and 2, across a changing step, and for the transversely isotropic model; a weak band is more compliant than the uniform block and far from the viscous limit of the same banded model. All five fail on development; the step-change test also fails when the step is frozen into the kernel as a number. 5 passed serial and on mpirun -n 2.

The fix first appeared on feature/tidal-shell (9e97670); it is ported here so it does not wait on that branch, and extended after review.

Internal adversarial review, two rounds (findings and evidence in the comment below). Left open, all predating this branch: a dt_elastic that cannot be read as a number still reads as the viscous limit; ETD's Maxwell time uses the raw shear_viscosity_0, not the yield-modified viscosity; ETD-2 under a step traction gives a velocity that alternates in sign.

Checklist (UW3 Style Charter — docs/developer/UW3_STYLE_CHARTER.md)

  • I have read the Style Charter and followed it over the surrounding code (S2)
  • No drive-by refactors, renames, or "while I was here" cleanups (S9)
  • Bug fixes ship the regression test, written first, with level_* and tier_* markers (S8)
  • No hedging names (maybe_ / try_ / do_) and no commented-out code (S3, S4)
  • Every exception swallow states its sanctioned failure mode in a comment (S4)
  • New data access uses .array / mesh.X.coords — no with ....access(...), no mesh.data (S7)
  • Parallel safety considered — np2/np4 checked where swarm/mesh/solver dispatch is touched (S11)
  • No pixi.toml / pixi.lock or other dependency changes riding in an unrelated PR
  • AI-assisted work carries the attribution line below

Underworld development team with AI support from Claude Code

…astic response

The exponential coefficients alpha = exp(-dt/tau) and phi were formed from
float(viscosity)/float(modulus). A viscosity written as an expression of
position (a damage band, a temperature-dependent law) raised there and the
Maxwell time fell silently to None, which the coefficient update reads as the
viscous limit: the model lost its elastic response wherever the viscosity was
not a plain number (measured on the box fault model: a first-step end
velocity of 0.007, the viscous value, against 0.061 with a constant).

The coefficients are now formed symbolically when the Maxwell time is an
expression, in the shared helper and at both constitutive-model callers; a
constant viscosity takes the numeric path as before.

Test: tests/test_1093 — a uniform-valued expression must match the constant
it equals, and a weak band must not make the block viscous. Both shown
failing before the fix.

Underworld development team with AI support from Claude Code
…egulariser

The symbolic path floored tau with a literal 1e-300; the Charter's
regulariser is uw.maths.functions.vanishing. The comment records where
phi = (1 - alpha)/x loses digits (x below ~1e-12) and that order 1 does not
use phi.

Underworld development team with AI support from Claude Code
…oured

Review of the ported fix (four findings):

- The symbolic coefficients took the step as a number, frozen into the
  compiled kernel: a change of step left the kernels on an earlier step's
  alpha (Stokes 1-8 %, Navier-Stokes 77 % on a step change). The step now
  enters as the dt_elastic atom, so a new value reaches the kernels as a
  constant.
- The Maxwell time is formed from the parameters' own atoms, so the units
  system scales it: a dimensional modulus with a varying viscosity raised
  SympifyError (alpha now evaluates to exp(-0.125) in a units model).
- One helper, _etd_maxwell_time, for all three callers; the experimental
  TransverseIsotropicVEPSplitFlowModel still had the silent viscous
  fallback.
- The test moves to test_1053 (test_109* is outside every CI batch) and
  covers order 1 and 2, a changing step (fails when the step is frozen as
  a number), the transversely isotropic model, and a weak band against
  the uniform block and the band's own viscous limit; all at 1e-10
  against the constant. All five fail on a build without the fix.

Underworld development team with AI support from Claude Code
Second review round (no blockers): at order 2 phi = (1 - e^-x)/x lost its
digits as x -> 0 and was 0/0 where the modulus vanishes. phi is now
-expm1(-x)/x (printed as C expm1), and the symbolic Maxwell time floors the
modulus with the declared regulariser, so mu -> 0 is the elastic limit as on
the numeric path. Test docstring states intent; a redundant dt_elastic
assignment goes.

Underworld development team with AI support from Claude Code
Copilot AI balanced review requested due to automatic review settings October 18, 2026 01:37

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.

@lmoresi

lmoresi commented Oct 18, 2026

Copy link
Copy Markdown
Member Author

Internal adversarial review — two rounds, against builds with and without the fix.

Round 1 (on the first port):

  • Blocking, fixed: the symbolic coefficients took the step as a number frozen into the kernel; a change of step left alpha on an earlier step (Stokes ETD-1 1–2 %, ETD-2 4–8 %, NS 77 %). Now the dt_elastic atom — step-change cases agree with the constant to ≤ 1.6e-12 (Stokes/NS, ETD-1/2, nodal/Eulerian/integration-point histories), including the exact viscous value at dt = 1e6.
  • Blocking, fixed: the test was outside every CI glob (test_109*); now test_1053, check_test_coverage.py OK (355 files).
  • Fixed: dimensional modulus + varying viscosity raised SympifyError; alpha now evaluates to exp(−0.125) in a units model (0.8824969), quantity vs quantity×(1+1e-12x) ≤ 1.8e-12.
  • Fixed: the experimental TransverseIsotropicVEPSplitFlowModel kept the silent fallback; one helper for the three callers (Charter §5).
  • Fixed: test gaps (order 2, step change, TI) and loose tolerances.

Round 2: no blockers. Nit fixed: phi = 0/0 where mu = 0 and lost digits as x → 0 — now -expm1(-x)/x with the modulus floored by vanishing.

Attacks that failed: constant ↔ expression switches mid-run (identical to the constant run both ways); a mesh-variable viscosity changed between solves (exact viscous scaling); recompiles (none on a step change: 0.5–1.0 s vs 0.4–0.9 s per solve); TI-VEP order 1/2 (≤ 1.2e-13); mpirun -n 2 (5 passed); all five tests fail on development.

Not changed here (predates the branch): an unreadable dt_elastic still reads as the viscous limit; ETD's tau uses the raw shear_viscosity_0; ETD-2 under a step traction alternates in sign.

No open issue covers this bug.


Generated by Claude Code

The exponential integrator's alpha = exp(-dt/tau) and phi lived once on the
DFDt, written by each model's pre-solve hook from its own Maxwell time. Two
models sharing one history (the constituents of a multi-material model)
therefore both relaxed on whichever Maxwell time was written last: 60 %
error in the other material.

Each VEP / TI-VEP / TI-VEP-split model now owns its (alpha, phi)
(_ExponentialCoefficients), created like the DDt's and refreshed with
_update_exp_values from its own Maxwell time, keeping the dt_symbol path for
field Maxwell times; the ETD-1 reduction phi = alpha is applied to the
model's own pair. Nothing in the library reads the DDt's coefficients any
more; they are kept for outside callers of update_exp_coefficients
(TODO(DESIGN) at their allocation).

Single-material results are bit-identical (velocity, history, sampled
stress) for VEP BDF-1/2, ETD-1/2, ETD with a field viscosity, TI-VEP BDF
and ETD, and the TI split model.

Underworld development team with AI support from Claude Code
… viscosity

Before the solver sets a step, dt_elastic is infinite. With a finite
modulus, eta mu dt / (c0 eta + mu dt) then evaluates to inf/inf = nan:

- ViscoElasticPlasticFlowModel.ve_effective_viscosity guarded mu = inf but
  not dt = inf; it now returns the viscosity for either.
- TransverseIsotropicVEPFlowModel: the same guard in ve_effective_viscosity
  and in _eta_for_tensor. Its tensor is cached (_is_setup), and a model
  given its parameters before it meets a solver was built at dt = inf and
  kept the nan tensor once the step became finite; the dt_elastic setter
  now resets the model when the step crosses between infinite and finite.

Regression tests are in tests/test_1054_multimaterial_viscoelastic.py (next
commit). Single-material results with the parameters set after assignment
are unchanged bit for bit.

Underworld development team with AI support from Claude Code
…stituents

MultiMaterialConstitutiveModel took none of the stress-history interface
from its constituents: it reported requires_stress_history = False (so the
solver made no history), had no Parameters.dt_elastic (the VE solve
raised), and never called the constituents' history hooks, so a
viscoelastic constituent kept dt_elastic = inf -- nan (BDF) or a purely
viscous response (ETD), silently.

The composite now forwards: requires_stress_history (any constituent);
order (highest) and effective_order (highest: the solver rebuilds when it
changes, so it must follow the ramp of the highest-order constituent);
stress_history_ddt_kwargs (union; contradictory requests raise);
Parameters.dt_elastic (set on every viscoelastic constituent);
_update_history_coefficients / _update_history_post_solve; history_flux
(level-set sum of each constituent's history_flux or flux, sharing one
weighting helper with flux); Parameters._solver and _solver_is_setup, so a
constituent's parameter change rebuilds the solver's functions.

Mixed integrators and orders are allowed: the history stores the composite
stress, which inside each material is its own stress, and each constituent
reads it through its own scheme and coefficients (its own ETD alpha, phi
since the previous commits). The log-conformation refusal moves into
_validate_model_compatibility, which now states what combines.

tests/test_1054_multimaterial_viscoelastic.py: pure shear, two materials,
closed forms away from the interface -- identical constituents (5.6e-12),
two Maxwell times BDF and ETD (5.6e-4, interface smearing of the projected
history), BDF-1 beside ETD-1, BDF-2 beside BDF-1, VEP beside TI-VEP, yield
in one constituent, a constituent's parameter change between steps; plus
the single-material TI-VEP parameters-before-assignment and infinite-step
regressions. 15 of 18 fail on the base build; all pass serial and on two
ranks. Subsystem doc gains the combination rules.

Underworld development team with AI support from Claude Code
Since each viscoelastic model owns its (alpha, phi), nothing in src,
tests, examples or notebooks read the DDt's _exp_coeffs, _exp_alpha,
_exp_phi or update_exp_coefficients. Remove them, and the with_exp switch
of _init_coefficient_expressions; _create_exp_coefficients now names its
owner as the constitutive model. EXPONENTIAL_VE_INTEGRATOR.md no longer
says the coefficients live on the DDt. (docs/docstrings/review_queue.md
still lists them; it is generated by docstring_sweep.py.)

Single-material results stay bit-identical to the base build (VEP BDF-1/2,
ETD-1/2, ETD with a field viscosity, TI-VEP BDF/ETD, TI split).

Underworld development team with AI support from Claude Code
The solver passes its history's order to a model it is given again
(constitutive_model.order = self._order). The composite forwarded that to
every viscoelastic constituent, so reassigning it, or a VE_Stokes(order=2)
or user history, silently turned a BDF-1 constituent into BDF-2 (error
against its BDF-1 closed form 3e-4 -> 9.2e-2). The setter now leaves the
constituents alone and refuses a history of lower order than a constituent
integrates at.

Tests (test_1054):
- reassigning the composite keeps orders (2, 1) and each closed form;
  fails with the previous forwarding setter.
- the constituent-change test now makes a structural change (a yield
  stress given to B between steps); the previous viscosity change was a
  rampable constant that needs no rebuild. Fails with the rebuild
  forwarding disabled (B [0.976, 1.18] against tau_y = 0.5).
- viscous beside VEP, tolerance from the measured 7.9e-3.
- the TI parameters-before test names its three controls.

constitutive-models.md: the smearing error grows with the stress jump
against the local stress; measured cases stated, including three materials
in thirds (4.1e-2).

Underworld development team with AI support from Claude Code
With units, float(par.sym) is the magnitude in the parameter's own units:
1e21 Pa s / 3.2e7 Pa is a Maxwell time in seconds, against a step of 100 kyr,
so alpha = 1 - 3e-12 and the model was purely elastic. The helpers now read
the non-dimensional value the kernels see (dt/tau = 0.1 in the new test, for
the isotropic and transversely isotropic models). Pre-existing: the base read
the same magnitudes.

Underworld development team with AI support from Claude Code
- The solver sets an explicit history order on a model before assigning it,
  so a composite whose constituent needs a higher-order history is refused
  and the solver keeps the model it had (it was left holding the refused one).
- With #851's scaled Maxwell time merged in, each constituent's ETD
  coefficient comes from its own scaled tau when eta, mu and dt carry units
  (test; the units test from #851 reads alpha from the model, where this
  branch keeps it).
- Stale comments about the history's exponential coefficients removed; the
  guide states how a composite and a single model take the solver's order.

Underworld development team with AI support from Claude Code
@lmoresi

lmoresi commented Oct 18, 2026

Copy link
Copy Markdown
Member Author

Post-review commit 189aa88: ETD coefficients from the scaled parameters

Found while reviewing the stacked multi-material branch. When η, μ and dt carry units, float(par.sym) is the magnitude in each parameter's own units: 1e21 Pa s / 3.2e7 Pa is a Maxwell time in seconds, against a step of 100 kyr. α came out as 1 − 3e-12, so the model was purely elastic. The base read the same magnitudes, so this predates the PR; the symbolic branch, through the JIT constants, was already scaled.

_etd_maxwell_time and _etd_timestep now read the non-dimensional value (unwrap_expression(..., mode="nondimensional")) through one helper, _nondimensional_float. No float(….sym) reads of a parameter remain in constitutive_models.py.

New test: test_dimensional_parameters_give_the_nondimensional_maxwell_time, for the isotropic and TI models, expects α = e^-0.1. It failed before the fix (α = 0.9999999999968) and passes after; test_1053 passes in full (7 tests). The BDF path was already correct with units (test_1064, and the reviewer's probe matched the plain-number run to 2.6e-14).


Generated by Claude Code

Picks up the test_1079 checkpoint-layout fix (#848), which was the only
failure in this branch's last CI run.

Underworld development team with AI support from Claude Code
Picks up the test_1079 checkpoint-layout fix (#848), which was the only
failure in this branch's last CI run.

Underworld development team with AI support from Claude Code
Viscoelastic constituents in the multi-material model

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