Skip to content

Unify Stokes/VE_Stokes and VEP infrastructure - #97

Merged
lmoresi merged 17 commits into
developmentfrom
feature/solver-unification
Mar 31, 2026
Merged

Unify Stokes/VE_Stokes and VEP infrastructure#97
lmoresi merged 17 commits into
developmentfrom
feature/solver-unification

Conversation

@lmoresi

@lmoresi lmoresi commented Mar 27, 2026

Copy link
Copy Markdown
Member

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

  • Stokes solver auto-creates DFDt when requires_stress_history=True
  • _create_stress_history_ddt() extracted from VE_Stokes
  • solve() includes VE pre/post hooks when DFDt is active
  • tau property returns psi_star[0] when DFDt exists
  • VE_Stokes retained as thin backward-compat wrapper
  • Constitutive model order preserved (not overwritten when solver _order=0)

dt_elastic elimination

  • No longer a user-facing parameter — solver sets it from solve(timestep=dt)
  • Prevents the silent NaN bug when dt_elastic defaulted to infinity
  • Parameters.dt_elastic is now a property that reads the solver-managed _dt

Constitutive model fixes

  • requires_stress_history property on base class (False) and VEP (True)
  • is_viscoplastic comparison fix (.sym is sympy.oo not == sympy.oo)
  • plastic_fraction property: max(0, 1 - eta_vep/eta_ve) for VEP,
    max(0, 1 - eta_vp/eta_viscous) for VP, 0 for viscous
  • BDF order fallback when timestep ratio > 2x (prevents negative stress)
  • Default order=2 for VEP (BDF-2, the validated recommendation)

Performance

  • simplify=False default in uw.function.evaluate

Validated benchmarks

  • Pure VE loading: stress matches Maxwell analytical to ~96%
  • VEP uniform yield: elastic loading → yield → stress clips at tau_y
  • Time-dependent yield: elastic loading → yield → stress tracks weakening
    tau_y → elastic rebound → re-yield at residual
  • Strain weakening: elastic loading → yield → plastic strain accumulates
    → tau_y weakens → stress drops (full stick-slip cycle)

Design note

docs/developer/design/SOLVER_UNIFICATION_DESIGN.md documents the architecture
and the path to VE-NavierStokes (future work).

Test plan

  • VE shear box (test_1051) passes
  • Stokes solver tests (test_1010) pass
  • VEP diagnostic: VE, VP, VEP side-by-side comparison
  • Time-dependent yield with elastic rebound
  • Strain weakening with plastic fraction feedback
  • CI pipeline
  • Embedded fault demonstration (next step)

Underworld development team with AI support from Claude Code

lmoresi added 8 commits March 26, 2026 15:33
- 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
Copilot AI review requested due to automatic review settings March 27, 2026 02:36

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.

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 expose tau consistently.
  • Replace user-facing dt_elastic with a solver-managed timestep property and add BDF safety fallback for large timestep jumps.
  • Default uw.function.evaluate(..., simplify=...) to simplify=False and 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 simplify from 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.

Comment on lines +135 to +136
out_path = "/Users/lmoresi/+Underworld/underworld3-pixi/.claude/worktrees/solver-unification/vep_timedep_yield.png"
fig.savefig(out_path, dpi=150)

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +149 to +150
out_path = "/Users/lmoresi/+Underworld/underworld3-pixi/.claude/worktrees/solver-unification/vep_strain_weakening.png"
fig.savefig(out_path, dpi=150)

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
current_tau_y = TAU_Y0

for step in range(NSTEPS):
cm.Parameters.yield_stress.sym = current_tau_y

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Suggested change
cm.Parameters.yield_stress.sym = current_tau_y
cm.Parameters.yield_stress = current_tau_y

Copilot uses AI. Check for mistakes.
Comment on lines +716 to +724
def solve(
self,
zero_init_guess: bool = True,
timestep: float = None,
_force_setup: bool = False,
verbose=False,
evalf=False,
order=None,
):

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +761 to +764

if order is None or order > self._order:
order = self._order

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
# 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:

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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."
)

Copilot uses AI. Check for mistakes.
Comment on lines +586 to +588
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.

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

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

Copilot uses AI. Check for mistakes.
Comment on lines +809 to +812
import numpy as np

_advected_sigma_star = np.copy(self.DFDt.psi_star[0].array[...])

Copilot AI Mar 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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()

Copilot uses AI. Check for mistakes.
lmoresi added 5 commits March 27, 2026 15:49
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
lmoresi added a commit that referenced this pull request Mar 30, 2026
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
lmoresi added a commit that referenced this pull request Mar 30, 2026
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
lmoresi added 4 commits March 30, 2026 15:41
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
@lmoresi
lmoresi merged commit c7c212b into development Mar 31, 2026
1 check passed
lmoresi added a commit that referenced this pull request Mar 31, 2026
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
lmoresi added a commit that referenced this pull request Apr 1, 2026
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
@lmoresi
lmoresi deleted the feature/solver-unification branch June 13, 2026 00:53
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