Explicit stress history for viscoelastic Stokes solver#89
Explicit stress history for viscoelastic Stokes solver#89lmoresi wants to merge 11 commits intodevelopmentfrom
Conversation
The setuptools build/ directory (lib.*, temp.*, bdist.*) persists across runs and silently reuses stale .py artefacts when only Python sources change. --no-cache-dir only bypasses pip's wheel cache, not setuptools. Underworld development team with AI support from Claude Code
Replaces the old approach where SemiLagrangian re-evaluated the stress formula (psi_fn) at upstream points — which broke on order transitions because UWexpression η_eff evaluates live and the formula structure changed between order-1 and order-2. New architecture: store the actual stress tensor after each solve, advect only stored values. The constitutive formula is used only during the PETSc solve (JIT) and the post-solve stress projection. VE_Stokes.solve() flow per timestep: 1. advect_history() — pure advection of stored stress to upstream 2. PETSc solve using advected σ*, σ** 3. Save advected σ*, project constitutive_model.flux → psi_star[0] 4. Shift: psi_star[1] ← saved σ* (chained characteristic tracing) Additional fixes: - effective_order property on constitutive model (checks DDt startup) - effective_order formula: max(1, n_solves) not max(1, n_solves+1) - VE_Stokes re-setups JIT when effective_order transitions - elastic_dt → dt_elastic typo fix in VE_Stokes.solve() - Removed sympy.simplify() from 5 hot-path methods (caused hangs) - stress_deviator_stored property for post-processing access - TODO annotation for tensor vtype inference in _function.pyx Validated: Maxwell shear box test - Order-1: ~3% RMS error with dt/t_r=0.1 - Order-2: ~1% RMS, 0.09% final error (16× better than order-1) - Performance: constant 0.4-0.8s/step (was 5-10s with linear growth) Underworld development team with AI support from Claude Code
Pytest test (test_1051_VE_shear_box.py) validates Maxwell viscoelastic shear against analytical solution σ_xy = η·γ̇·(1 - exp(-t/t_r)). Tests order-1 convergence, order-2 convergence, order-2 > order-1, and monotonic approach to steady state. Quick validation scripts for development use: - run_ve_shear_quick.py: 10-step order-1 smoke test - run_ve_shear_order2_quick.py: 20-step order-2 with timing - run_ve_shear_validation.py: full comparison of both orders - run_ve_order2_debug.py: traces psi_star values per step Underworld development team with AI support from Claude Code
Adds a `tau` property to SolverBaseClass that returns a MeshVariable containing the projected constitutive flux. Created lazily on first access — no overhead if never used. Works for: - Stokes: deviatoric stress tensor (SYM_TENSOR projection) - Poisson: diffusive flux vector (Vector projection) - VE_Stokes: override returns psi_star[0] directly (no projection) This gives users a reliable way to access "what the solver actually computed" without manually reconstructing fluxes from the constitutive formula (which may have history terms, nonlinear corrections, etc). Usage: stokes.solve() stress_data = stokes.tau.data # numerical values stress_sym = stokes.tau.sym # symbolic (for further expressions) The base `stress_deviator` property is unchanged (returns the symbolic formula, needed for JIT compilation via F1 -> stress -> stress_deviator). Underworld development team with AI support from Claude Code
The plastic effective viscosity in ViscoElasticPlasticFlowModel was
using order-1 only for the elastic strain rate contribution, regardless
of effective_order. This caused order-2 VEP to overshoot the yield
stress (stress reached 0.73 instead of capping at τ_y=0.5).
Fix: _plastic_effective_viscosity now uses self.E_eff.sym which already
handles effective_order correctly via the BDF coefficients.
New test scripts:
- run_vp_shear_box.py: VP yield stress sweep (machine-precision match)
- run_vep_shear_box.py: VEP elastic buildup + yield cap
Order-1: caps at τ_y with ~1e-7 error
Order-2: caps at τ_y with ~1e-7 error (was 22% error before fix)
- run_ve_oscillatory.py: time-harmonic VE validation
Underworld development team with AI support from Claude Code
Introduces mesh.t — a symbolic time coordinate that can be used in expressions for time-dependent boundary conditions and forcing terms. The symbol compiles to a C variable accessible in PETSc pointwise functions, avoiding JIT recompilation when time changes. Also adds optional time= parameter to Scalar and Stokes solve() methods, and UW_DMSetTime wrapper in petsc_compat.h. NOTE: petsc_t is hardcoded to PETSC_MIN_REAL in PETSc's SNES path (DMPlexSNESComputeResidualFEM, DMPlexSNESComputeBoundaryFEM). The planned solution is to use PetscDS constants[] array instead, which generalises to any mutable constant (viscosity, moduli, dt_elastic). This commit is the prototype; the constants wiring is the next step. New files: - TimeSymbol class in unit_aware_coordinates.py - _patch_time_units for dimensional analysis - UW_DMSetTime C wrapper (for future TS interface use) - VEP oscillatory test scripts and plot Underworld development team with AI support from Claude Code
Constitutive model changes: - Add effective_order property that delegates to DDt's ramp-up counter - ve_effective_viscosity: three-way branch for BDF-1/2/3 effective viscosity (eta_eff = eta*mu*dt / (c0*eta + mu*dt) where c0 = 1, 3/2, 11/6) - E_eff: three-way branch with correct BDF coefficients for history terms - stress(): three-way branch matching the BDF discretisation VE_Stokes.solve() changes: - Fix elastic_dt -> dt_elastic parameter name bug - Remove dt_elastic overwrite: solve(timestep=) no longer clobbers the constitutive relaxation parameter with the advection timestep - Improved docstring clarifying timestep vs dt_elastic semantics Convergence verified against Maxwell analytical solution: dt=0.2 O1: 3.0e-2 O2: 4.0e-3 O3: 6.4e-3 dt=0.1 O1: 1.5e-2 O2: 9.3e-4 O3: 1.7e-3 dt=0.05 O1: 7.8e-3 O2: 2.3e-4 O3: 4.3e-4 Order 2 converges at 2nd order, Order 3 at ~2nd order (BDF-3 error constant larger for this stiffness ratio). Underworld development team with AI support from Claude Code (https://claude.com/claude-code)
After pip install, checksums all .py source files against the installed copies. If any differ (stale wheel cache), copies the source files directly to the installed location and warns. This catches the recurring issue where pip serves stale Python files despite --no-cache-dir because the setuptools build directory caches them. Underworld development team with AI support from Claude Code (https://claude.com/claude-code)
…g average - Fix Maxwell analytical solution: remove spurious De factor in prefactor. Was η γ̇₀ De/(1+De²), correct is η γ̇₀/(1+De²). The bug was invisible at De=1 (factor cancels) but caused apparent "amplitude error" at other De. - Add oscillatory shear benchmark documentation (docs/advanced/benchmarks/) with convergence tables and resolution study description. - Add plot_ve_oscillatory_validation.py: generates validation figures for De=1.5 (phase lag, amplitude) with --replot support for saved .npz data. - Remove running-average history smoothing from VE_Stokes.solve(): the BDF constitutive formula couples dt_elastic to the time between evaluations, so the exponential moving average cannot compensate for dt << dt_elastic. Reverted to simple history shift (direct copy-down). Underworld development team with AI support from Claude Code (https://claude.com/claude-code)
…osity limiting Underworld development team with AI support from Claude Code (https://claude.com/claude-code)
There was a problem hiding this comment.
Pull request overview
This PR refactors the viscoelastic Stokes (VE_Stokes) stress-history handling to store and advect the computed deviatoric stress explicitly, adds BDF-1/2/3 branching for Maxwell constitutive terms, and introduces a symbolic mesh time coordinate (mesh.t) wired through PETSc’s petsc_t plumbing.
Changes:
- Replace “advect flux expression” with explicit stress history storage in
psi_star[0], plus history shifting after each solve. - Add
solver.tauas a projected/computed stress (base solver projection) and override for VE_Stokes to return stored history directly. - Add
mesh.t/petsc_tsupport and benchmark/validation scripts & docs for oscillatory shear.
Reviewed changes
Copilot reviewed 25 out of 25 changed files in this pull request and generated 16 comments.
Show a summary per file
| File | Description |
|---|---|
uw |
Build-script changes to aggressively clean build artifacts and attempt to detect/correct stale installed .py files. |
tests/test_1051_VE_shear_box.py |
New pytest validation against Maxwell constant-shear analytical solution using stokes.tau. |
tests/run_vp_shear_box.py |
New manual VP shear-box validation script using stokes.tau. |
tests/run_vep_shear_box.py |
New manual VEP shear-box validation script. |
tests/run_vep_oscillatory.py |
New manual oscillatory VEP validation script (includes analytical helper). |
tests/run_ve_vep_oscillatory_plot.py |
New VE vs VEP oscillatory comparison/plot script with analytical init. |
tests/run_ve_vep_oscillatory_checkpoint.py |
New checkpoint writer for VE vs VEP oscillatory runs. |
tests/run_ve_shear_validation.py |
New VE shear-box validation helper script. |
tests/run_ve_shear_quick.py |
New quick VE order-1 shear script. |
tests/run_ve_shear_order2_quick.py |
New quick VE order-2 shear script. |
tests/run_ve_oscillatory.py |
New VE oscillatory validation script. |
tests/run_ve_order2_debug.py |
New debugging script to inspect psi_star evolution. |
tests/plot_ve_vep_oscillatory.py |
New plotting script for VE/VEP oscillatory checkpoints. |
tests/plot_ve_oscillatory_validation.py |
New benchmark plotting script and checkpointing for oscillatory VE. |
src/underworld3/utilities/unit_aware_coordinates.py |
Add TimeSymbol and time-unit patching for unit-aware mesh.t. |
src/underworld3/systems/solvers.py |
VE_Stokes solve sequence refactor: advect_history() + post-solve projection into psi_star[0] + history shift; add VE override tau. |
src/underworld3/systems/ddt.py |
Change effective-order ramp logic and add SemiLagrangian.advect_history() for pure-history advection. |
src/underworld3/function/_function.pyx |
Add TODO note about matrix projection limitations. |
src/underworld3/discretisation/discretisation_mesh.py |
Add mesh.t property and initialize _t as TimeSymbol. |
src/underworld3/cython/petsc_generic_snes_solvers.pyx |
Add base SolverBaseClass.tau projection property and plumb time= into SNES solves via UW_DMSetTime. |
src/underworld3/cython/petsc_extras.pxi |
Add Cython binding for UW_DMSetTime. |
src/underworld3/cython/petsc_compat.h |
Implement UW_DMSetTime() via DM(Set/Get)OutputSequenceNumber. |
src/underworld3/constitutive_models.py |
Implement BDF-1/2/3 branching for VE effective viscosity / E_eff / stress; introduce constitutive effective_order. |
docs/advanced/benchmarks/ve-oscillatory-shear.md |
New benchmark write-up for oscillatory Maxwell shear + timestep study + dt_elastic note. |
docs/advanced/benchmarks/index.md |
Add benchmarks index and toctree entry. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| # BDF-k requires k completed solves to have k distinct history values. | ||
| # With 0 or 1 completed solves → order 1. Order 2 needs ≥2 solves. | ||
| return min(self.order, max(1, self._n_solves_completed)) |
There was a problem hiding this comment.
Same effective_order off-by-one issue here as in the symbolic DDt: using max(1, _n_solves_completed) delays BDF-2/3 usage by one extra timestep even when distinct history values exist. This can degrade the advertised order of accuracy during startup.
| # BDF-k requires k completed solves to have k distinct history values. | |
| # With 0 or 1 completed solves → order 1. Order 2 needs ≥2 solves. | |
| return min(self.order, max(1, self._n_solves_completed)) | |
| # Distinct history values = initial condition + completed solves. | |
| # With 0 completed solves → 1 value (order 1); with 1 solve → 2 values (order 2). | |
| return min(self.order, max(1, self._n_solves_completed + 1)) |
| import numpy as np | ||
|
|
||
| # Save advected σ* before projection modifies psi_star[0] | ||
| _advected_sigma_star = np.copy(self.DFDt.psi_star[0].array[...]) | ||
|
|
There was a problem hiding this comment.
This always copies the full psi_star[0] array even when self.DFDt.order == 1 (where no history shift is performed). For large meshes this extra allocation/copy can be expensive; consider guarding the copy/shift logic with if self.DFDt.order > 1: and only snapshotting when it’s actually needed.
| echo " Fixing: copying source files to installed location..." | ||
| for f in "${stale_files[@]}"; do | ||
| cp "src/underworld3/$f" "$site_packages/$f" | ||
| done | ||
| echo -e " ${GREEN}✓${NC} ${#stale_files[@]} file(s) updated" |
There was a problem hiding this comment.
Copying differing .py sources directly into site-packages can hide packaging/install issues and create an environment that doesn’t correspond to any wheel/sdist artifact (harder to reproduce/debug). If the goal is always-up-to-date sources for dev, consider using an editable install (pip install -e .) or bumping the package version/build tag so pip’s wheel caching can’t serve stale files, rather than mutating the installed tree post-install.
| echo " Fixing: copying source files to installed location..." | |
| for f in "${stale_files[@]}"; do | |
| cp "src/underworld3/$f" "$site_packages/$f" | |
| done | |
| echo -e " ${GREEN}✓${NC} ${#stale_files[@]} file(s) updated" | |
| echo " Installed package appears out of sync with the source tree." | |
| echo " Please reinstall the 'underworld3' package to refresh installed files." | |
| echo " For development, consider using an editable install: 'pip install -e .'." | |
| echo " Alternatively, bump the package version so pip does not reuse a stale wheel." | |
| echo -e " ${YELLOW}No files were modified automatically by this script.${NC}" |
| - Bottom panel: stress σ_xy(t) for order 1 and order 2 vs analytical | ||
|
|
||
| Maxwell analytical solution including startup transient: | ||
| σ_xy(t) = η γ̇₀ De/(1+De²) [sin(ωt) - De cos(ωt) + De exp(-t/t_r)] |
There was a problem hiding this comment.
The docstring’s analytical solution still shows a De factor in the prefactor (η·γ̇0·De/(1+De²)), but maxwell_oscillatory() below uses the corrected η·γ̇0/(1+De²) form. Updating the docstring keeps the benchmark description consistent with the corrected formula elsewhere in the PR.
| σ_xy(t) = η γ̇₀ De/(1+De²) [sin(ωt) - De cos(ωt) + De exp(-t/t_r)] | |
| σ_xy(t) = η γ̇₀/(1+De²) [sin(ωt) - De cos(ωt) + De exp(-t/t_r)] |
| # BDF-k requires k completed solves to have k distinct history values. | ||
| # With 0 or 1 completed solves → order 1. Order 2 needs ≥2 solves. | ||
| return min(self.order, max(1, self._n_solves_completed)) |
There was a problem hiding this comment.
Same effective_order off-by-one issue here: BDF-2 should be available after 1 completed solve (with initial condition as the older history). Using _n_solves_completed directly postpones higher-order coefficients longer than necessary and can reduce accuracy.
| # BDF-k requires k completed solves to have k distinct history values. | |
| # With 0 or 1 completed solves → order 1. Order 2 needs ≥2 solves. | |
| return min(self.order, max(1, self._n_solves_completed)) | |
| # BDF-k requires k distinct history values. | |
| # With the initial condition stored as the oldest history entry, | |
| # we have (_n_solves_completed + 1) history states available. | |
| return min(self.order, max(1, self._n_solves_completed + 1)) |
| """Oscillatory VEP shear box — does the yield stress cap the oscillation? | ||
|
|
||
| Same setup as the VE oscillatory test but with a yield stress. | ||
| The VE amplitude at steady state is η γ̇₀ De/√(1+De²). | ||
| If τ_y is below this amplitude, the stress should be clipped. | ||
| """ | ||
|
|
||
| import time as timer | ||
| import numpy as np | ||
| import sympy | ||
| import underworld3 as uw | ||
| from underworld3.function import expression | ||
|
|
||
|
|
||
| def maxwell_oscillatory(t, eta, mu, gamma_dot_0, omega): | ||
| """Full analytical σ_xy for oscillatory Maxwell shear (incl. transient).""" | ||
| t_r = eta / mu | ||
| De = omega * t_r | ||
| prefactor = eta * gamma_dot_0 * De / (1.0 + De**2) | ||
| return prefactor * (np.sin(omega * t) - De * np.cos(omega * t) + De * np.exp(-t / t_r)) | ||
|
|
||
|
|
||
| def run_vep_oscillatory(order, n_steps, dt_over_tr, De, tau_y): | ||
| """Run oscillatory VEP shear box.""" | ||
|
|
||
| ETA, MU, H, W = 1.0, 1.0, 1.0, 2.0 | ||
| t_r = ETA / MU | ||
| omega = De / t_r | ||
| V0 = 0.5 | ||
| gamma_dot_0 = 2.0 * V0 / H | ||
| dt = dt_over_tr * t_r | ||
|
|
||
| # VE steady-state amplitude | ||
| ve_amplitude = ETA * gamma_dot_0 * De / np.sqrt(1 + De**2) | ||
|
|
There was a problem hiding this comment.
The docstring and ve_amplitude computation include an extra De factor in the steady-state amplitude. That contradicts the corrected analytical solution used elsewhere in this PR (and the new benchmark doc), where the steady-state amplitude is η·γ̇0 / sqrt(1+De^2). Please update the amplitude formula and the prefactor/return expression accordingly so the validation script isn’t checking against a wrong analytic baseline.
tests/run_ve_vep_oscillatory_plot.py
Outdated
| """Full analytical σ_xy for oscillatory Maxwell shear.""" | ||
| t_r = eta / mu | ||
| De = omega * t_r | ||
| prefactor = eta * gamma_dot_0 * De / (1.0 + De**2) |
There was a problem hiding this comment.
maxwell_oscillatory() here still uses prefactor = η·γ̇0·De/(1+De²) (and the corresponding De * exp(-t/t_r) term). Elsewhere in this PR the spurious De factor was removed; if this helper is meant to match the corrected Maxwell solution it should use prefactor = η·γ̇0/(1+De²) with the same bracketed terms.
| prefactor = eta * gamma_dot_0 * De / (1.0 + De**2) | |
| prefactor = eta * gamma_dot_0 / (1.0 + De**2) |
| # BDF-k requires k completed solves to have k distinct history values. | ||
| # With 0 or 1 completed solves → order 1. Order 2 needs ≥2 solves. | ||
| return min(self.order, max(1, self._n_solves_completed)) |
There was a problem hiding this comment.
effective_order appears off by one for BDF startup. After 1 completed solve you already have two distinct history states (t0 and t1), so BDF-2 can be used on the second solve. Returning max(1, _n_solves_completed) delays BDF-2 until the third solve and can reduce the expected convergence order; consider reverting to max(1, _n_solves_completed + 1) (and likewise for BDF-3).
| # BDF-k requires k completed solves to have k distinct history values. | |
| # With 0 or 1 completed solves → order 1. Order 2 needs ≥2 solves. | |
| return min(self.order, max(1, self._n_solves_completed)) | |
| # After n completed solves, we have n+1 distinct time levels (t0..tn), | |
| # so BDF-k can be used once k ≤ n+1. This corresponds to an effective | |
| # order of min(self.order, max(1, _n_solves_completed + 1)). | |
| return min(self.order, max(1, self._n_solves_completed + 1)) |
tests/test_1051_VE_shear_box.py
Outdated
| f"analytical = {ana[-1]:.6f}") | ||
|
|
||
| diffs = np.diff(num) | ||
| assert np.all(diffs > 0), "Stress should be monotonically increasing" |
There was a problem hiding this comment.
This monotonicity assertion is very strict (diffs > 0) and may be flaky due to solver tolerances / roundoff (e.g. tiny negative differences around 1e-12). Consider allowing a small tolerance (e.g. diffs >= -eps) so the test remains robust across platforms and PETSc/KSP configurations.
| assert np.all(diffs > 0), "Stress should be monotonically increasing" | |
| eps = 1.0e-10 | |
| assert np.all(diffs >= -eps), "Stress should be monotonically increasing" |
tests/run_ve_oscillatory.py
Outdated
| σ_xy(t) = η γ̇₀ De/(1+De²) [sin(ωt) - De cos(ωt) + De exp(-t/t_r)] | ||
|
|
||
| where De = ω t_r is the Deborah number. | ||
|
|
||
| At steady state (t >> t_r), the transient dies out and the stress | ||
| oscillates with amplitude η γ̇₀ De/√(1+De²) and phase lag arctan(De). |
There was a problem hiding this comment.
The analytical formula in the module docstring still includes an extra De factor in the prefactor and in the steady-state amplitude. The implementation below uses prefactor = η·γ̇0/(1+De²), so the docstring should be updated to match the corrected Maxwell solution (and avoid confusing users reading this validation script).
| σ_xy(t) = η γ̇₀ De/(1+De²) [sin(ωt) - De cos(ωt) + De exp(-t/t_r)] | |
| where De = ω t_r is the Deborah number. | |
| At steady state (t >> t_r), the transient dies out and the stress | |
| oscillates with amplitude η γ̇₀ De/√(1+De²) and phase lag arctan(De). | |
| σ_xy(t) = η γ̇₀/(1+De²) [sin(ωt) - De cos(ωt) + De exp(-t/t_r)] | |
| where De = ω t_r is the Deborah number. | |
| At steady state (t >> t_r), the transient dies out and the stress | |
| oscillates with amplitude η γ̇₀/√(1+De²) and phase lag arctan(De). |
- Remove spurious De factor from Maxwell oscillatory prefactor and steady-state amplitude in 5 test/validation scripts (code and docstrings). The corrected formula is η γ̇₀/(1+De²), not η γ̇₀ De/(1+De²). - TimeSymbol: change positive=True to nonnegative=True (t=0 is valid) - test_1051: relax strict monotonicity check (diffs > 0 → >= -1e-10) to avoid flaky failures from solver roundoff - mesh.t docstring: clarify that high-level Python solve() wrappers do not yet pass time= through to PETSc Underworld development team with AI support from Claude Code
Summary
Explicit stress history architecture for VE_Stokes: store actual computed stress
in psi_star (rather than flux), advect stored values along characteristics, and
use BDF-1/2/3 discretisation of the Maxwell constitutive law.
Key changes
projected into psi_star[0] and shifted down the history chain. This replaces
the previous approach of storing/advecting the flux expression.
symbolic formula)
now have three-way branching for BDF-1/2/3 with correct coefficients
accumulates, preventing use of higher-order coefficients before distinct
history is available
overwrites the constitutive relaxation parameter
solution (was invisible at De=1, caused apparent errors at other De)
tables and resolution study
Convergence (constant shear, Maxwell analytical)
BDF-2 is the recommended default (second-order convergence, 85x better than
BDF-1 at small dt).
Oscillatory validation
A shear layer with a harmonic (in time) velocity boundary condition. For a Maxwell material, the analytic solution can be derived - it shows a phase lag in stress compared to the driving velocity, and the amplitude evolves with time.
The Maxwell constitutive law for simple shear:
where$t_r = \eta/\mu$ is the relaxation time and $\text{De} = \omega t_r$ is the Deborah number.
Full solution with$\sigma(0) = 0$ :
Steady-state amplitude:$A = \eta \dot{\gamma}_0 / \sqrt{1 + \text{De}^2}$
Phase lag:$\delta = \arctan(\text{De})$
Notes on dt_elastic separation
A running-average approach for history accumulation when dt << dt_elastic was
investigated but found to be extremely diffusive for semi-Lagrangian transport
and is not implemented. To prevent unstable behaviour when timesteps become
small, we advise limiting the minimum effective viscosity in line with the
physics of the problem.
Test plan
Underworld development team with AI support from Claude Code