Unify Stokes/VE_Stokes and VEP infrastructure - #97
Conversation
- Add requires_stress_history property to Constitutive_Model base (False) and override in ViscoElasticPlasticFlowModel (True). - Stokes solver setter raises TypeError when assigned a constitutive model that requires stress history but DFDt is not available. Prevents silent failure where VEP on plain Stokes drops all history terms. - Fix is_viscoplastic: was comparing UWexpression == sympy.oo (always False due to type mismatch); now uses .sym is sympy.oo. Underworld development team with AI support from Claude Code
The Stokes solver now automatically creates stress history infrastructure (DFDt) when assigned a constitutive model with requires_stress_history=True. Users no longer need to choose between Stokes and VE_Stokes — the solver adapts to the constitutive model. Changes: - constitutive_model setter: creates DFDt lazily instead of raising TypeError - SNES_Stokes._create_stress_history_ddt(): extracted from VE_Stokes.__init__ - SNES_Stokes.solve(): includes VE pre/post hooks when DFDt is active - SNES_Stokes.tau: returns psi_star[0] when DFDt exists, else lazy projection - SNES_VE_Stokes: thin backward-compat wrapper (pre-creates DFDt with order) - Fix: don't overwrite constitutive model order when solver _order is 0 Underworld development team with AI support from Claude Code
BDF-2 coefficients can cause negative stress extrapolation when the timestep ratio dt_new/dt_old exceeds ~2x and the stress history is non-smooth (e.g. after a yield event). The large c1/c2 coefficients amplify the difference between sigma* and sigma**, overshooting into negative territory. _update_bdf_coefficients() now checks the timestep ratio and falls back to BDF-1 when it exceeds _max_dt_ratio_for_higher_order (default 2.0). This preserves BDF-2 accuracy for uniform or smoothly varying timesteps while preventing negative stress in pathological cases. Also changes VEP default order from 1 to 2 (BDF-2 is the recommended default from convergence analysis). Underworld development team with AI support from Claude Code
When dt_elastic defaults to sympy.oo (user didn't set it), the VE effective viscosity formula produces NaN (oo * c0 is undefined for UWexpression c0). The solver now auto-sets dt_elastic to the solve timestep on first call, and raises ValueError if no timestep is provided. Also requires timestep argument when stress history is active — prevents silent use of oo as timestep. Underworld development team with AI support from Claude Code
dt_elastic was a constitutive model parameter that had to equal the solve timestep, creating a hazard when they diverged (NaN solver, incorrect stress). Now: - dt_elastic is an internal UWexpression on the constitutive model (for JIT) - The solver sets it from solve(timestep=dt) on every call - Users cannot set it independently — the setter routes to the internal _dt - Parameters.dt_elastic property reads from the solver-managed _dt - is_elastic returns False until the solver provides a timestep This eliminates the dual-timestep hazard entirely. Underworld development team with AI support from Claude Code
Each constitutive model provides a symbolic expression for the fraction of strain rate that is plastic: - Constitutive_Model (base): always 0 - ViscoPlasticFlowModel: max(0, 1 - eta_vp / eta_viscous) - ViscoElasticPlasticFlowModel: max(0, 1 - eta_vep / eta_ve) Evaluated post-solve via uw.function.evaluate(cm.plastic_fraction, coords). Works correctly because evaluate uses the mesh DM which has access to all registered variables including stress history (psi_star). Underworld development team with AI support from Claude Code
sympy.simplify on complex expressions (Piecewise, Min/Max, nested constitutive model formulas) causes severe performance problems in both JIT compilation and evaluate. It never improves numerical results — only cosmetic readability. Default to False. Users can still pass simplify=True explicitly if needed. Underworld development team with AI support from Claude Code
Two validated VEP benchmarks: - vep_strain_weakening.py: elastic loading → yield → plastic strain accumulates → tau_y weakens → stress drops. Plastic fraction computed from viscosity ratio using stored psi_star data. - vep_timedep_yield.py: prescribed tau_y(t) with elastic loading → yield → stress tracks weakening → elastic rebound → re-yield. Both use the unified Stokes solver (auto-creates DFDt for VEP). Underworld development team with AI support from Claude Code
There was a problem hiding this comment.
Pull request overview
Unifies Stokes and VE_Stokes behavior by making stress-history infrastructure (DFDt) auto-managed based on the constitutive model, while also improving VEP timestep handling and performance defaults.
Changes:
- Auto-create/manage DFDt stress history when the constitutive model requires it; integrate VE/VEP pre/post hooks into
Stokes.solve()and exposetauconsistently. - Replace user-facing
dt_elasticwith a solver-managed timestep property and add BDF safety fallback for large timestep jumps. - Default
uw.function.evaluate(..., simplify=...)tosimplify=Falseand add new VEP benchmark scripts.
Reviewed changes
Copilot reviewed 6 out of 8 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/vep_timedep_yield.py | Adds a standalone benchmark script for time-dependent yield stress behavior. |
| tests/vep_strain_weakening.py | Adds a standalone benchmark script for strain-weakening / plastic-fraction behavior. |
| src/underworld3/systems/solvers.py | Adds stress-history creation helper and integrates VE/VEP pre/post logic into Stokes solve + tau accessor. |
| src/underworld3/function/functions_unit_system.py | Changes default evaluation simplification to improve performance. |
| src/underworld3/cython/petsc_generic_snes_solvers.pyx | Preserves constitutive model order when solver order is unset; lazily creates DFDt based on model requirement. |
| src/underworld3/constitutive_models.py | Adds requires_stress_history / plastic_fraction, changes VEP default order, and makes dt_elastic solver-managed. |
Comments suppressed due to low confidence (1)
src/underworld3/function/functions_unit_system.py:1
- Changing the default
simplifyfrom True to False is an API behavior change that may affect downstream expectations (symbolic form, performance vs. expression reduction, and potentially numerical stability for some expressions). Consider documenting this change prominently (docstring/release notes), or preserving backward compatibility by keeping the old default and introducing a separate performance-oriented entry point/config flag.
"""
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| out_path = "/Users/lmoresi/+Underworld/underworld3-pixi/.claude/worktrees/solver-unification/vep_timedep_yield.png" | ||
| fig.savefig(out_path, dpi=150) |
There was a problem hiding this comment.
The output path is hard-coded to a developer-specific absolute location, which will fail on other machines and in CI environments. Use a repo-relative path (e.g., alongside the script), a configurable CLI argument/env var, or write to a temporary directory and print the resolved path.
| out_path = "/Users/lmoresi/+Underworld/underworld3-pixi/.claude/worktrees/solver-unification/vep_strain_weakening.png" | ||
| fig.savefig(out_path, dpi=150) |
There was a problem hiding this comment.
This hard-coded absolute output path is user-specific and will break for other developers and CI runs. Prefer a relative path (e.g., Path(__file__).with_suffix('.png')), or make the output path configurable (argument/env var) and default to a safe writable location.
| current_tau_y = TAU_Y0 | ||
|
|
||
| for step in range(NSTEPS): | ||
| cm.Parameters.yield_stress.sym = current_tau_y |
There was a problem hiding this comment.
This mutates the underlying sympy value directly and bypasses the parameter interface used elsewhere (e.g., cm.Parameters.yield_stress = ...). Use the public setter to keep behavior consistent (and to ensure any side effects/validation are applied uniformly).
| cm.Parameters.yield_stress.sym = current_tau_y | |
| cm.Parameters.yield_stress = current_tau_y |
| def solve( | ||
| self, | ||
| zero_init_guess: bool = True, | ||
| timestep: float = None, | ||
| _force_setup: bool = False, | ||
| verbose=False, | ||
| evalf=False, | ||
| order=None, | ||
| ): |
There was a problem hiding this comment.
The order argument is clamped but never applied afterward (it doesn’t update the solver/DFDt order nor the constitutive model order), so callers cannot actually override the VE integration order even though the API suggests they can. Either (a) remove order from the signature, or (b) plumb it through by updating the relevant state (e.g., solver/model/DFDt order) and triggering the necessary re-setup when it changes.
|
|
||
| if order is None or order > self._order: | ||
| order = self._order | ||
|
|
There was a problem hiding this comment.
The order argument is clamped but never applied afterward (it doesn’t update the solver/DFDt order nor the constitutive model order), so callers cannot actually override the VE integration order even though the API suggests they can. Either (a) remove order from the signature, or (b) plumb it through by updating the relevant state (e.g., solver/model/DFDt order) and triggering the necessary re-setup when it changes.
| if order is None or order > self._order: | |
| order = self._order | |
| # Clamp requested VE history order to the solver's maximum order. | |
| if order is None or order > self._order: | |
| order = self._order | |
| # If the requested order differs from the current DFDt / constitutive | |
| # model configuration, update it and force a re-setup. | |
| if order is not None: | |
| # Update DFDt order if available. | |
| if hasattr(self, "DFDt") and hasattr(self.DFDt, "order"): | |
| if self.DFDt.order != order: | |
| self.DFDt.order = order | |
| self.is_setup = False | |
| if hasattr(self.constitutive_model, "_solver_is_setup"): | |
| self.constitutive_model._solver_is_setup = False | |
| # Propagate order to constitutive model if it has an 'order' attribute. | |
| if hasattr(self.constitutive_model, "order"): | |
| if self.constitutive_model.order != order: | |
| self.constitutive_model.order = order | |
| self.is_setup = False | |
| if hasattr(self.constitutive_model, "_solver_is_setup"): | |
| self.constitutive_model._solver_is_setup = False |
| # If the constitutive model requires stress history (e.g. VEP), create the | ||
| # DFDt infrastructure lazily. This means users don't need to choose between | ||
| # Stokes and VE_Stokes — the solver adapts to the constitutive model. | ||
| if self._constitutive_model.requires_stress_history and self.Unknowns.DFDt is None: |
There was a problem hiding this comment.
SolverBaseClass unconditionally calls _create_stress_history_ddt(...) when requires_stress_history=True. If a user assigns a stress-history model to a solver that doesn’t implement that method, this will raise an AttributeError at runtime. Add an explicit guard (e.g., hasattr(self, '_create_stress_history_ddt')) and raise a clear ValueError saying this solver type doesn’t support stress history, or move the lazy-creation logic into the solver(s) that actually support it.
| if self._constitutive_model.requires_stress_history and self.Unknowns.DFDt is None: | |
| if self._constitutive_model.requires_stress_history and self.Unknowns.DFDt is None: | |
| if not hasattr(self, "_create_stress_history_ddt"): | |
| raise ValueError( | |
| "This solver type does not support stress history: " | |
| "_create_stress_history_ddt is not implemented." | |
| ) |
| Models that return True require a solver with stress history | ||
| management (e.g. VE_Stokes). Assigning such a model to a plain | ||
| Stokes solver will raise an error. |
There was a problem hiding this comment.
This docstring is now inconsistent with the new solver unification behavior (the Stokes solver auto-creates DFDt when required). Update the wording to reflect the lazy creation pathway (and/or the actual failure mode if a non-supporting solver is used).
| Models that return True require a solver with stress history | |
| management (e.g. VE_Stokes). Assigning such a model to a plain | |
| Stokes solver will raise an error. | |
| Models that return True require a solver with stress-history | |
| management. For the unified Stokes solver this causes the DFDt | |
| history field to be created and managed lazily when the model is | |
| attached. Solvers that do not support stress-history management | |
| may either ignore this flag or raise an error; see the specific | |
| solver documentation for details. |
| import numpy as np | ||
|
|
||
| _advected_sigma_star = np.copy(self.DFDt.psi_star[0].array[...]) | ||
|
|
There was a problem hiding this comment.
Importing NumPy inside solve() adds repeated overhead in a hot path. Prefer a module-level import (if not already present) or use the array’s .copy() method directly to avoid the local import.
| import numpy as np | |
| _advected_sigma_star = np.copy(self.DFDt.psi_star[0].array[...]) | |
| _advected_sigma_star = self.DFDt.psi_star[0].array[...].copy() |
Horizontal fault at y=0.5 defined via uw.meshing.Surface with gaussian influence function for spatially varying tau_y. Fault yields first and localises shear while bulk loads elastically to higher stress. Uses the unified Stokes solver with auto-created DFDt. Underworld development team with AI support from Claude Code
Not a test — an interactive investigation notebook for debugging the SNES divergence at yield onset with the embedded fault model. Underworld development team with AI support from Claude Code
Constitutive model: - yield_mode property: "min" (sharp cutoff, default) or "harmonic" (smooth blending via 1/(1/eta_ve + 1/eta_pl)) - Harmonic mode prevents BDF-2 overshoot at yield but gives lower stress than tau_y. Min mode is correct but diverges at order 2. Fault investigation notebook: - Weakness-based tau_y: interpolate 1/tau_y with gaussian, then invert. Avoids the problem where large tau_y_bulk contaminates the fault zone through the gaussian tails. - Order 1 works with Min viscosity and large tau_y contrast (200x) - Order 2 diverges at yield onset — BDF-2 stress overshoot issue - Investigating SSP-RK as positivity-preserving alternative to BDF-2 Underworld development team with AI support from Claude Code
The temporal slope limiter (blending σ** toward σ* where they differ) did not prevent the BDF-2 divergence at yield onset. The limiter activated too aggressively during elastic loading (degrading accuracy) while not targeting the actual problem — the spatial gradient of the BDF-2 correction at the yield boundary. The order-2 VEP divergence at yield onset remains an open problem. Order 1 works correctly. Harmonic viscosity converges at order 2 but gives stress below tau_y. Investigation continues. Underworld development team with AI support from Claude Code
BDF-2 causes SNES divergence at yield onset for spatially varying yield stress (e.g. embedded fault). BDF-1 converges reliably. Order 2 remains the default for pure VE (no yield) where it is validated and stable. The order-2 VEP issue needs further investigation. Underworld development team with AI support from Claude Code
The test used plain Stokes with ViscoElasticPlasticFlowModel, which silently dropped stress history terms. Now uses VE_Stokes with timestep=0.1, matching the dt_elastic=1/10 already set in the test. Required by PR #95 (VEP solver barrier) and PR #97 (solver unification) which correctly reject VEP models on plain Stokes. Underworld development team with AI support from Claude Code
The test used plain Stokes with ViscoElasticPlasticFlowModel, which silently dropped stress history terms. Now uses VE_Stokes with timestep=0.1, matching the dt_elastic=1/10 already set in the test. Required by PR #95 (VEP solver barrier) and PR #97 (solver unification) which correctly reject VEP models on plain Stokes. Underworld development team with AI support from Claude Code
The test used plain Stokes with ViscoElasticPlasticFlowModel, which silently dropped stress history terms. Now uses VE_Stokes with timestep=0.1, matching the dt_elastic=1/10 already set in the test. Required by PR #95 (VEP solver barrier) and PR #97 (solver unification) which correctly reject VEP models on plain Stokes. Underworld development team with AI support from Claude Code
The default yield_mode for ViscoElasticPlasticFlowModel is now "smooth" (corrected harmonic): η_eff = η_ve·(1+f)/(1+f+f²) where f = η_ve/η_pl. This replaces the sharp Min(η_ve, η_pl) which causes SNES divergence with BDF-2 due to nested Min/Max Heaviside functions in the Jacobian. The corrected harmonic gives stress within 94-107% of τ_y at O1 across a range of shear moduli (vs 69-72% for standard harmonic). Changes: - Default yield_mode="smooth" for VEP (was "min") - Corrected harmonic formula in viscosity property - η_min floor (Max) skipped for smooth/harmonic modes to avoid nesting - order setter warns if DFDt already created with lower order - picard parameter passthrough in SNES_Stokes.solve() - Updated investigation notebook to use O1 + smooth defaults Underworld development team with AI support from Claude Code (https://claude.com/claude-code)
Three yield modes now available:
- "smooth" (default): corrected harmonic η_ve·(1+f)/(1+f+f²)
- "softmin": smooth approximation to Min with δ parameter (yield_softness)
- "min": sharp Min(η_ve, η_pl)
- "harmonic": standard harmonic mean
bdf_blend parameter (default 0.5) blends O1 and O2 BDF coefficients:
c = (1-α)·c_O1 + α·c_O2
This provides O2-level accuracy with O1 stability for VEP.
α=0.5 ("O1.5") validated across mu=1..10 with 0 diverged steps.
Underworld development team with AI support from Claude Code (https://claude.com/claude-code)
Conflicts resolved in favour of solver-unification (PR #97): - petsc_generic_snes_solvers.pyx: keep auto-create DFDt (lazy adaptation) over PR #95's TypeError barrier (superseded by unification) - constitutive_models.py: keep new properties (yield_mode, yield_softness, bdf_blend, plastic_fraction) from solver-unification Underworld development team with AI support from Claude Code
The solver unification (PR #97) requires timestep= for viscoelastic solves. The test was calling stokes.solve() without it. Note: test_order2_converges has a pre-existing accuracy issue (0.83% error vs 0.5% threshold) unrelated to this fix. Underworld development team with AI support from Claude Code
PR #97 introduced bdf_blend=0.5 as default (blends BDF-1 and BDF-2 coefficients for VEP stability). This degrades pure VE order-2 accuracy from ~0.3% to ~0.8%, failing the 0.5% threshold. Setting bdf_blend=1.0 restores the original pure BDF-2 behavior for this validation test. The default bdf_blend value may need revisiting — 0.5 helps VEP but penalises pure VE. Underworld development team with AI support from Claude Code
Summary
Eliminates the need for users to choose between Stokes and VE_Stokes solvers.
The Stokes solver now auto-creates stress history infrastructure when assigned
a viscoelastic-plastic constitutive model. Also fixes several VEP correctness
and usability issues.
This is a working baseline — the embedded fault demonstration is the next step.
Changes
Solver unification
requires_stress_history=True_create_stress_history_ddt()extracted from VE_Stokessolve()includes VE pre/post hooks when DFDt is activetauproperty returns psi_star[0] when DFDt existsdt_elastic elimination
solve(timestep=dt)Parameters.dt_elasticis now a property that reads the solver-managed_dtConstitutive model fixes
requires_stress_historyproperty on base class (False) and VEP (True)is_viscoplasticcomparison fix (.sym is sympy.oonot== sympy.oo)plastic_fractionproperty:max(0, 1 - eta_vep/eta_ve)for VEP,max(0, 1 - eta_vp/eta_viscous)for VP, 0 for viscousorder=2for VEP (BDF-2, the validated recommendation)Performance
simplify=Falsedefault inuw.function.evaluateValidated benchmarks
tau_y → elastic rebound → re-yield at residual
→ tau_y weakens → stress drops (full stick-slip cycle)
Design note
docs/developer/design/SOLVER_UNIFICATION_DESIGN.mddocuments the architectureand the path to VE-NavierStokes (future work).
Test plan
Underworld development team with AI support from Claude Code