From 7775c5f8cc124f649d6a6158469954b8afe2fde4 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Wed, 22 Jul 2026 11:50:00 +1000 Subject: [PATCH 01/21] feat(constitutive): power-mean yield soft-min smoother + rampable delta constant Add the power-mean soft-min yield smoother and make the soft-min softness delta a constants[] atom (runtime-rampable with no JIT recompile) on the ViscousFlowModel base class, so the visco-plastic subclasses inherit one implementation. This is the generalisable, scalable substrate for a yield homotopy toward the sharp Min surface. Base class (ViscousFlowModel): - _combine_yield(eta_ve, eta_pl): the shared viscous/plastic combination, keyed on yield_mode -- "min" (exact hard Min), "harmonic", or "softmin" (the delta soft-min in the family chosen by yield_smoother). Development's tri-modal yield_mode semantics are preserved exactly (unlike the source branch, which collapsed "softmin" into "min"). - _get_yield_softness / _get_yield_offset: delta and the onset offset held as constants[] UWexpression atoms (the offset defined symbolically in terms of delta -- one symbol in the stress tensor, so no tensor blow-up -- so one delta update repacks both). Ramping delta via the atom + solver._update_constants() forces no recompile. - yield_smoother property/setter: "sqrt" (default; overshoots tau_y in the transition) or "powermean" (undershoots tau_y -- eta_eff <= Min always -- overflow-safe on geodynamic viscosity ranges). Selecting "powermean" from delta=0 bumps delta to 1 (s=1 harmonic mean), since delta=0 is the singular Min limit. Models (Stage 1 -- isotropic, test-covered): - ViscoPlasticFlowModel (Drucker-Prager): defaults to yield_mode="min" == today's exact hard Min (zero behaviour change), and GAINS yield_mode / yield_softness knobs so it can opt into the softmin / power-mean homotopy -- the model the hard-case DP homotopy needs. - ViscoElasticPlasticFlowModel (VEP): its inline sqrt soft-min routed through _combine_yield; delta moves float -> atom (value-identical); yield_softness setter syncs the atom. Zero behaviour change at stock settings: _combine_yield at default settings is bit-identical to the previous inline law (delta merely moves from a baked float to a constants[] symbol evaluated to the same number). Power-mean and runtime-delta are opt-in. The distrusted in-solve enable_yield_homotopy ramp driver is intentionally NOT part of this PR. TI-VEP / TI-VEP-Split (4 anisotropic sites) are a Stage-2 follow-up. Tests: tests/test_1055_yield_smoother.py -- delta=0 exact Min; power-mean undershoot + overflow-safety + ->Min; smoother validation/default; runtime delta ramp no-recompile; DP opt-in. level_1/tier_a gate green (402 passed, 0 failed). VEP _combine_yield verified bit-identical to the prior float law at defaults. Underworld development team with AI support from Claude Code --- src/underworld3/constitutive_models.py | 216 +++++++++++++++++++++---- tests/test_1055_yield_smoother.py | 184 +++++++++++++++++++++ 2 files changed, 373 insertions(+), 27 deletions(-) create mode 100644 tests/test_1055_yield_smoother.py diff --git a/src/underworld3/constitutive_models.py b/src/underworld3/constitutive_models.py index 7d37eecfd..8407a2f28 100644 --- a/src/underworld3/constitutive_models.py +++ b/src/underworld3/constitutive_models.py @@ -924,6 +924,125 @@ def _object_viewer(self): ) ) + # --- Yield soft-min smoother (shared by the visco-plastic subclasses) ----------- + # The δ soft-min regularisation and the smooth-min FAMILY selection live on the + # base class so every yielding model inherits one implementation. δ is held as a + # constants[] UWexpression atom (not a baked float) so a homotopy can ramp it at + # runtime via PetscDSSetConstants with no JIT recompile. + + def _get_yield_softness(self): + r"""The soft-min regularisation δ as a ``constants[]`` UWexpression atom. + + Created lazily and kept in sync with ``self._yield_softness`` (the + configured numeric value). Storing δ as a UWexpression — rather than + baking the float into the compiled flux — lets a yield homotopy ramp + δ at runtime via ``PetscDSSetConstants`` with no JIT recompile. + ``δ = 0`` makes the sqrt law identically ``Min``. + """ + delta_value = getattr(self, "_yield_softness", 0.0) + if getattr(self, "_yield_softness_expr", None) is None: + self._yield_softness_expr = expression( + R"{\updelta_{y}}", + sympy.Float(delta_value), + "Yield soft-min regularisation δ (rampable constant; δ=0 ⇒ exact Min)", + ) + # Onset offset (-1+√(1+δ²))/2 keeps g(0)=1 (no spurious yield below + # onset). Held as its OWN constant atom — a single symbol in the + # stress tensor — so it does not blow the tensor up, while still + # tracking δ symbolically (one δ update repacks both constants). + self._yield_offset_expr = expression( + R"{\updelta_{y,0}}", + (-1 + sympy.sqrt(1 + self._yield_softness_expr**2)) / 2, + "Yield soft-min onset offset; tracks δ so g(0)=1 exactly", + ) + else: + self._yield_softness_expr.sym = sympy.Float(delta_value) + return self._yield_softness_expr + + def _get_yield_offset(self): + """The onset-offset constant atom (lazily created alongside δ).""" + if getattr(self, "_yield_offset_expr", None) is None: + self._get_yield_softness() + return self._yield_offset_expr + + def _combine_yield(self, eta_ve, eta_pl): + r"""Combine the visco-elastic/viscous viscosity ``eta_ve`` with the plastic + (yield) viscosity ``eta_pl`` according to ``self._yield_mode``: + + - ``"min"``: exact hard ``Min(η_ve, η_pl)`` (sharp yield surface). + - ``"harmonic"``: ``1/(1/η_ve + 1/η_pl)`` (a distinct smooth blend). + - ``"softmin"``: the δ-parameterised soft-min, in the family chosen by + ``self.yield_smoother`` — ``"sqrt"`` (default; overshoots τ_y in the + transition) or ``"powermean"`` (undershoots τ_y, ``η_eff ≤ Min`` always). + Both approach exact ``Min`` as ``δ → 0``. + + Behaviour at the stock settings (``yield_mode`` per model default, + ``yield_smoother="sqrt"``) is identical to the previous inline law — δ merely + moves from a baked float to a ``constants[]`` atom (same value). + """ + mode = getattr(self, "_yield_mode", "softmin") + if mode == "harmonic": + return 1 / (1 / eta_ve + 1 / eta_pl) + if mode == "min": + return sympy.Min(eta_ve, eta_pl) + + # "softmin": δ-parameterised smooth-min family. + smoother = getattr(self, "_yield_smoother", "sqrt") + delta = self._get_yield_softness() + f = eta_ve / eta_pl + if smoother == "powermean": + # p-norm soft-min in an overflow-safe harmonic-normalised form. δ is + # floored SMOOTHLY (+ε, not Max()) so 1/δ stays finite as δ→0 (a Max on + # the δ atom triggers an unsupported symbolic numeric comparison). + s = 1 / (delta + sympy.Float(0.001)) + a = 1 + f + b = 1 + 1 / f + N = eta_ve * eta_pl / (eta_ve + eta_pl) + return N * (a ** (-s) + b ** (-s)) ** (-1 / s) + + # default "sqrt" soft-min: η_ve / g(f), g(0)=1, g ≈ max(1, f), exact Min at δ=0. + offset = self._get_yield_offset() + g = 1 + (f - 1 + sympy.sqrt((f - 1) ** 2 + delta**2)) / 2 - offset + return eta_ve / g + + @property + def yield_smoother(self): + r"""Which smooth-min FAMILY regularises the ``"softmin"`` yield mode. + + Both families use the same softness parameter ``δ`` (``yield_softness``) + and approach exact ``Min`` as ``δ → 0``, but differ in how they round + the kink: + + - ``"sqrt"`` (default): ``η_ve / g(f, δ)`` with + ``g = 1 + ½(f−1+√((f−1)²+δ²)) − offset``. Exact ``Min`` at ``δ=0``; + **overshoots** the yield surface in the transition (carries stress a + few–60 % above ``τ_y`` before asymptoting). + - ``"powermean"``: the p-norm soft-min + ``η_eff = (η_ve^(−s) + η_pl^(−s))^(−1/s)`` with ``s = 1/δ`` + (``s=1`` ⇒ harmonic mean; ``s→∞`` ⇒ ``Min``). **Undershoots** the + yield surface (``η_eff ≤ Min`` always — approaches ``τ_y`` strictly + from below, never over-yields). Computed in an overflow-safe + harmonic-normalised form for geodynamic viscosity ranges. + + Selecting ``"powermean"`` bumps a zero ``yield_softness`` to ``1.0`` + (``s=1``, the parameter-free harmonic mean) since ``δ=0`` (``s=∞``) is + the singular hard-``Min`` limit it only *approaches*. + """ + return getattr(self, "_yield_smoother", "sqrt") + + @yield_smoother.setter + def yield_smoother(self, value): + if value not in ("sqrt", "powermean"): + raise ValueError( + f"yield_smoother must be 'sqrt' or 'powermean', got '{value}'" + ) + self._yield_smoother = value + if value == "powermean" and getattr(self, "_yield_softness", 0.0) == 0.0: + # δ=0 ⇒ s=1/δ=∞ is the singular Min limit; default to the + # parameter-free harmonic mean (s=1) instead. + self.yield_softness = 1.0 + self._reset() + ## NOTE - retrofit VEP into here @@ -988,6 +1107,16 @@ def __init__(self, unknowns, material_name: str = None): "Effective viscosity (plastic)", ) + # Yield-combination mode (see _combine_yield on the base class). Default + # "min" = the exact hard Min(η_0, η_yield) this model has always used, so the + # default behaviour is unchanged. Opt into "softmin" (+ yield_smoother / + # yield_softness) for the δ-parameterised smooth-min homotopy. + self._yield_mode = "min" + self._yield_softness = 0.0 # δ; 0 ⇒ exact Min + self._yield_smoother = "sqrt" # smooth-min family: "sqrt" | "powermean" + self._yield_softness_expr = None # constants[] δ atom (created lazily) + self._yield_offset_expr = None # onset-offset atom (created lazily) + class _Parameters(_ParameterBase, _ViscousParameterAlias): """Any material properties that are defined by a constitutive relationship are collected in the parameters which can then be defined/accessed by name in @@ -1069,15 +1198,13 @@ def viscosity(self): viscosity_yield = yield_stress / (2 * self._strainrate_inv_II) - ## Question is, will sympy reliably differentiate something - ## with so many Max / Min statements. The smooth version would - ## be a reasonable alternative: - - # effective_viscosity = sympy.sympify( - # 1 / (1 / inner_self.shear_viscosity_0 + 1 / viscosity_yield), - # ) - - effective_viscosity = sympy.Min(inner_self.shear_viscosity_0, viscosity_yield) + # Combine the viscous and plastic (yield) viscosities. The default + # yield_mode="min" gives the exact hard Min(η_0, η_yield); yield_mode="softmin" + # opts into the δ-parameterised smooth-min (sqrt or powermean family) for a + # scalable homotopy toward the sharp yield surface. + effective_viscosity = self._combine_yield( + inner_self.shear_viscosity_0, viscosity_yield + ) # If we want to apply limits to the viscosity but see caveat above # Keep this as an sub-expression for clarity @@ -1093,6 +1220,44 @@ def viscosity(self): # Returns an expression that has a different description return self._plastic_eff_viscosity + @property + def yield_mode(self): + r"""How the viscous and plastic (yield) viscosities are combined. + + - ``"min"`` (default): exact hard ``Min(η_0, η_yield)`` — the sharp yield + surface this model has always used. + - ``"harmonic"``: ``1/(1/η_0 + 1/η_yield)`` — a smooth blend. + - ``"softmin"``: the δ-parameterised smooth-min (family set by + ``yield_smoother``), for a scalable homotopy toward the sharp surface. + """ + return self._yield_mode + + @yield_mode.setter + def yield_mode(self, value): + if value not in ("min", "harmonic", "softmin"): + raise ValueError( + f"yield_mode must be 'min', 'harmonic', or 'softmin', got '{value}'" + ) + self._yield_mode = value + self._reset() + + @property + def yield_softness(self): + r"""Soft-min regularisation δ for ``yield_mode="softmin"`` (0 ⇒ exact Min). + + δ is held as a ``constants[]`` atom, so ramping it at runtime (this setter, + or ``cm._get_yield_softness().sym = ...`` + ``solver._update_constants()``) + does not trigger a JIT recompile — the basis of a scalable yield homotopy. + """ + return self._yield_softness + + @yield_softness.setter + def yield_softness(self, value): + self._yield_softness = float(value) + if getattr(self, "_yield_softness_expr", None) is not None: + self._yield_softness_expr.sym = sympy.Float(self._yield_softness) + self._reset() + def plastic_correction(self) -> float: r"""Scaling factor to reduce stress to yield surface. @@ -1242,6 +1407,9 @@ def __init__(self, unknowns, order=1, integrator: str = "bdf", self._order = order self._yield_mode = "softmin" # "min", "harmonic", "smooth", or "softmin" self._yield_softness = 0.1 # δ parameter for "softmin" mode + self._yield_smoother = "sqrt" # smooth-min family: "sqrt" | "powermean" + self._yield_softness_expr = None # constants[] δ atom (created lazily) + self._yield_offset_expr = None # onset-offset atom (created lazily) # Timestep — set by the solver before each solve(). Not a user parameter. # Initialised to oo (viscous limit). The solver overwrites this with the @@ -1701,23 +1869,13 @@ def viscosity(self): if self.is_viscoplastic: vp_effective_viscosity = self._plastic_effective_viscosity - if self._yield_mode == "harmonic": - effective_viscosity = 1 / (1 / effective_viscosity + 1 / vp_effective_viscosity) - elif self._yield_mode == "softmin": - # Smooth approximation to Min(η_ve, η_pl): - # η_eff = η_ve / g(f) - # g(f) = 1 + softplus(f-1) - softplus(-1) ≈ max(1, f) - # where softplus(x) = (x + √(x² + δ²))/2 and f = η_ve/η_pl. - # Corrected so g(0) = 1 exactly (no spurious yield below onset). - # Approaches exact Min as δ→0. No Min/Max in expression. - delta = self._yield_softness - f = effective_viscosity / vp_effective_viscosity - import math # float offset avoids sympy expression blowup in tensor - offset = (-1 + math.sqrt(1 + delta**2)) / 2 - g = 1 + (f - 1 + sympy.sqrt((f - 1)**2 + delta**2)) / 2 - offset - effective_viscosity = effective_viscosity / g - else: - effective_viscosity = sympy.Min(effective_viscosity, vp_effective_viscosity) + # Combine η_ve with the plastic viscosity per yield_mode (harmonic / exact + # Min / δ-soft-min). The soft-min softness δ now lives in a constants[] atom + # (see _combine_yield / _get_yield_softness) — value-identical to the former + # inline float law at the same δ, but runtime-rampable with no recompile. + effective_viscosity = self._combine_yield( + effective_viscosity, vp_effective_viscosity + ) # Apply viscosity floor — but skip for smooth-blend yield modes # where the outer Max creates a nested Min/Max that breaks the @@ -2009,7 +2167,11 @@ def yield_softness(self): @yield_softness.setter def yield_softness(self, value): - self._yield_softness = value + self._yield_softness = float(value) + # Keep the constants[] δ atom in sync (created lazily on first use) so a + # numeric δ assignment is reflected without a JIT recompile. + if getattr(self, "_yield_softness_expr", None) is not None: + self._yield_softness_expr.sym = sympy.Float(self._yield_softness) self._reset() @property diff --git a/tests/test_1055_yield_smoother.py b/tests/test_1055_yield_smoother.py new file mode 100644 index 000000000..9e0640f1c --- /dev/null +++ b/tests/test_1055_yield_smoother.py @@ -0,0 +1,184 @@ +"""δ-parameterised yield soft-min smoother (sqrt + power-mean families). + +Pins the smoother contract on development (the ``_combine_yield`` helper + the +runtime-rampable δ ``constants[]`` atom, ported from the yield-homotopy work, with +development's tri-modal ``yield_mode`` semantics preserved): + +1. ``test_delta0_is_exact_min`` — the sqrt soft-min at δ=0 equals ``Min(η_ve, η_pl)`` + to machine precision (g(f) = max(1, f)). +2. ``test_powermean_smoother_undershoots_min`` — the power-mean family is ≤ Min + everywhere (never over-yields), overflow-safe on geodynamic ranges, and → Min as δ→0. +3. ``test_yield_smoother_validation`` — only the two known families; default "sqrt". +4. ``test_yield_softness_runtime_no_recompile`` — ramping δ through its constants[] atom + does NOT change the solver's JIT cache key. +5. ``test_dp_model_smoother_optin`` — the non-elastic Drucker–Prager model defaults to + exact hard Min and can opt into the softmin/power-mean homotopy. + +NOTE (vs the branch): development keeps ``yield_mode`` tri-modal — ``"min"`` is the exact +hard ``Min`` (not the soft-min), so the smoother tests select ``yield_mode="softmin"``. +The ``enable_yield_homotopy`` in-solve ramp driver is intentionally NOT part of this PR. +""" + +import numpy as np +import pytest +import sympy + +import underworld3 as uw +from underworld3.function import expression +from underworld3.function.expressions import unwrap_expression + +ETA = 1.0 +MU = 1.0 +TAU_Y = 0.5 +V0 = 0.5 +T_R = ETA / MU + + +def _build_vep(label, order=2): + mesh = uw.meshing.StructuredQuadBox( + elementRes=(16, 8), minCoords=(-1.0, -0.5), maxCoords=(1.0, 0.5), + ) + v = uw.discretisation.MeshVariable(f"U_{label}", mesh, mesh.dim, degree=2) + p = uw.discretisation.MeshVariable(f"P_{label}", mesh, 1, degree=1) + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + cm = uw.constitutive_models.ViscoElasticPlasticFlowModel(stokes.Unknowns, order=order) + stokes.constitutive_model = cm + cm.Parameters.shear_viscosity_0 = ETA + cm.Parameters.shear_modulus = MU + cm.Parameters.yield_stress = TAU_Y + cm.Parameters.strainrate_inv_II_min = 1.0e-6 + V_top = expression(rf"V_{{{label}}}", sympy.Float(V0), "Top V") + stokes.add_dirichlet_bc((V_top, 0.0), "Top") + stokes.add_dirichlet_bc((-V_top, 0.0), "Bottom") + stokes.add_dirichlet_bc((sympy.oo, 0.0), "Left") + stokes.add_dirichlet_bc((sympy.oo, 0.0), "Right") + stokes.tolerance = 1.0e-6 + stokes.petsc_options["snes_force_iteration"] = True + return mesh, stokes, cm, V_top + + +@pytest.mark.level_1 +@pytest.mark.tier_a +def test_delta0_is_exact_min(): + """δ=0 sqrt soft-min law equals Min(η_ve, η_pl) to machine precision.""" + mesh = uw.meshing.StructuredQuadBox(elementRes=(4, 4)) + v = uw.discretisation.MeshVariable("Um", mesh, mesh.dim, degree=2) + p = uw.discretisation.MeshVariable("Pm", mesh, 1, degree=1) + s = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + cm = uw.constitutive_models.ViscoElasticPlasticFlowModel(s.Unknowns, order=2) + s.constitutive_model = cm + cm.yield_mode = "softmin" # the δ-family path (dev "min" = hard Min) + cm.yield_softness = 0.0 # δ=0 ⇒ g(f)=max(1,f) ⇒ exact Min + for eta_ve, eta_pl in [(1.0, 2.0), (2.0, 1.0), (1.0, 1.0), (0.3, 5.0), (5.0, 0.3)]: + comb = cm._combine_yield(sympy.Float(eta_ve), sympy.Float(eta_pl)) + val = float(unwrap_expression(comb, mode="nondimensional")) + assert abs(val - min(eta_ve, eta_pl)) < 1.0e-12 + + +@pytest.mark.level_1 +@pytest.mark.tier_a +def test_powermean_smoother_undershoots_min(): + """Power-mean smooth-min: ≤ Min everywhere (no over-yield), overflow-safe on + geodynamic ranges, and → Min as δ→0 (s=1/δ→∞).""" + mesh = uw.meshing.StructuredQuadBox(elementRes=(4, 4)) + v = uw.discretisation.MeshVariable("Upm", mesh, mesh.dim, degree=2) + p = uw.discretisation.MeshVariable("Ppm", mesh, 1, degree=1) + s = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + cm = uw.constitutive_models.ViscoElasticPlasticFlowModel(s.Unknowns, order=2) + s.constitutive_model = cm + + cm.yield_mode = "softmin" # dev: soft-min family lives under "softmin" + cm.yield_softness = 0.0 # start from δ=0 so the powermean select bumps it + cm.yield_smoother = "powermean" # bumps δ 0 → 1 (s=1, harmonic mean) + assert cm.yield_smoother == "powermean" + assert cm.yield_softness == 1.0 + + # Includes a geodynamic-range pair (1e25, 1e21) that overflows the naive + # η^(−s) form above s≈40 but is finite in the harmonic-normalised form. + pairs = [(1.0, 2.0), (2.0, 1.0), (1.0, 1.0), (0.3, 5.0), (5.0, 0.3), (1e25, 1e21)] + for delta in (1.0, 0.5, 0.1, 0.02): + cm.yield_softness = delta + for eta_ve, eta_pl in pairs: + comb = cm._combine_yield(sympy.Float(eta_ve), sympy.Float(eta_pl)) + val = float(unwrap_expression(comb, mode="nondimensional")) + assert np.isfinite(val), f"overflow δ={delta} ({eta_ve},{eta_pl})" + assert val <= min(eta_ve, eta_pl) * (1 + 1e-9), \ + f"power-mean over-yields δ={delta}: {val} > {min(eta_ve, eta_pl)}" + + # smallest δ (largest s) is close to exact Min. + cm.yield_softness = 0.02 + for eta_ve, eta_pl in pairs: + comb = cm._combine_yield(sympy.Float(eta_ve), sympy.Float(eta_pl)) + val = float(unwrap_expression(comb, mode="nondimensional")) + m = min(eta_ve, eta_pl) + assert abs(val - m) <= 0.05 * m, f"not near Min at δ=0.02: {val} vs {m}" + + +@pytest.mark.level_1 +@pytest.mark.tier_a +def test_yield_smoother_validation(): + """yield_smoother only accepts the two known families; default is 'sqrt'.""" + mesh = uw.meshing.StructuredQuadBox(elementRes=(4, 4)) + v = uw.discretisation.MeshVariable("Usm", mesh, mesh.dim, degree=2) + p = uw.discretisation.MeshVariable("Psm", mesh, 1, degree=1) + s = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + cm = uw.constitutive_models.ViscoElasticPlasticFlowModel(s.Unknowns, order=2) + s.constitutive_model = cm + assert cm.yield_smoother == "sqrt" # default family unchanged + with pytest.raises(ValueError): + cm.yield_smoother = "not_a_family" + + +@pytest.mark.level_2 +@pytest.mark.tier_a +def test_yield_softness_runtime_no_recompile(): + """Ramping δ through its constants[] atom must not trigger a JIT recompile.""" + _, stokes, cm, V_top = _build_vep("norecompile") + cm.yield_softness = 0.3 # δ>0 so the soft-min atom is exercised + dt = 0.20 * T_R + cm.Parameters.dt_elastic = dt + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=2) + key0 = stokes._current_jit_cache_key + assert key0 is not None + + cm._get_yield_softness() + for d in (0.2, 0.1, 0.0): + cm._yield_softness_expr.sym = sympy.Float(d) + stokes._update_constants() + stokes.solve(zero_init_guess=False, timestep=dt, divergence_retries=2) + assert stokes._current_jit_cache_key == key0, "δ ramp forced a JIT recompile" + + +@pytest.mark.level_1 +@pytest.mark.tier_a +def test_dp_model_smoother_optin(): + """The non-elastic Drucker–Prager model defaults to exact hard Min and can opt + into the δ soft-min / power-mean homotopy (its new capability).""" + mesh = uw.meshing.StructuredQuadBox(elementRes=(4, 4)) + v = uw.discretisation.MeshVariable("Udp", mesh, mesh.dim, degree=2) + p = uw.discretisation.MeshVariable("Pdp", mesh, 1, degree=1) + s = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + cm = uw.constitutive_models.ViscoPlasticFlowModel(s.Unknowns) + s.constitutive_model = cm + + # default: exact hard Min + assert cm.yield_mode == "min" + assert cm.yield_smoother == "sqrt" + for eta_ve, eta_pl in [(1.0, 2.0), (2.0, 1.0), (0.3, 5.0)]: + comb = cm._combine_yield(sympy.Float(eta_ve), sympy.Float(eta_pl)) + val = float(unwrap_expression(comb, mode="nondimensional")) + assert abs(val - min(eta_ve, eta_pl)) < 1.0e-12 + + # opt into the power-mean homotopy: undershoots Min + cm.yield_mode = "softmin" + cm.yield_smoother = "powermean" # bumps δ→1 + assert cm.yield_softness == 1.0 + for eta_ve, eta_pl in [(1.0, 2.0), (5.0, 0.3), (1e25, 1e21)]: + comb = cm._combine_yield(sympy.Float(eta_ve), sympy.Float(eta_pl)) + val = float(unwrap_expression(comb, mode="nondimensional")) + assert np.isfinite(val) + assert val <= min(eta_ve, eta_pl) * (1 + 1e-9) + + # bad mode rejected + with pytest.raises(ValueError): + cm.yield_mode = "not_a_mode" From f8df9ff48e516cae734eef1bbd012a124d50b0c4 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Wed, 22 Jul 2026 19:15:50 +1000 Subject: [PATCH 02/21] fix(constitutive): DP consistent-Newton recursion on Max yield + smooth floors The Drucker-Prager viscosity guarded its yield floor with `if yield_stress_min.sym != 0:`, but the unset default yield_stress_min is a -inf-valued Parameter (a UWexpression atom), which passes `!= 0`. So every yield became `Max(<-inf parameter>, yield_stress)`. That -inf parameter cannot be resolved by sympy's fuzzy `is_ge`, so canonicalising the outer Max for the consistent-Newton tangent recursed without bound (is_ge -> _n2 -> (a-b).evalf(2) -> is_ge ...). The guard now uses the model's own -inf "unset" sentinel, so the spurious wrapper is not applied; a yield_stress_min of exactly 0 is now honoured as a real floor. Also, because a hard Max floor reintroduces a non-differentiable kink under the consistent tangent, route both DP floors (shear_viscosity_min, yield_stress_min) through `_apply_floor`: exact hard Max in yield_mode="min" (unchanged), and a delta-rounded smooth maximum in the smooth modes so the whole effective viscosity stays differentiable (the same delta that regularises the yield transition). Add public uw.maths.smooth_max / smooth_min primitives (regularised like delta_function) - the Newton-safe way to write a physical rounded tension cap, smooth_max(C + sin(phi)*p, 0, eps), in place of sympy.Max(sigma, 0). Tests (test_1055): a consistent-Newton Jacobian build on a pressure-dependent Max(C+sin(phi)*p, 0) yield (regression for the recursion), and a smooth_max/ smooth_min contract test. Underworld development team with AI support from Claude Code --- src/underworld3/constitutive_models.py | 37 +++++++++++++-- src/underworld3/maths/__init__.py | 2 + src/underworld3/maths/functions.py | 65 ++++++++++++++++++++++++++ tests/test_1055_yield_smoother.py | 50 ++++++++++++++++++++ 4 files changed, 151 insertions(+), 3 deletions(-) diff --git a/src/underworld3/constitutive_models.py b/src/underworld3/constitutive_models.py index 8407a2f28..5ad1d2d44 100644 --- a/src/underworld3/constitutive_models.py +++ b/src/underworld3/constitutive_models.py @@ -1043,6 +1043,32 @@ def yield_smoother(self, value): self.yield_softness = 1.0 self._reset() + # --- Smooth lower bounds (viscosity / yield floors) ----------------------------- + # A hard sympy.Max cutoff is non-differentiable at the corner. When the flux is + # differentiated for the consistent-Newton tangent that kink breaks the tangent — + # and, because one operand is a UWexpression over the fields, sympy's fuzzy `>=` + # comparison cannot resolve it and recurses. In the smooth yield modes we therefore + # round the floor with the same δ that regularises the yield transition, so the + # whole effective viscosity stays differentiable and δ→0 recovers the sharp bound. + + def _apply_floor(self, value, floor): + r"""Impose the lower bound :math:`value \ge floor`. + + In ``yield_mode="min"`` this is the exact hard ``sympy.Max(value, floor)`` — + the sharp cutoff the model has always used. In the smooth yield modes + (``"softmin"``/``"harmonic"``) it is the differentiable ``uw.maths.smooth_max`` + rounded by the yield softness δ *relative to the floor* (:math:`\epsilon = + \delta\,|floor|`), so the whole effective viscosity — not only the yield + transition — is differentiable for the consistent-Newton tangent. The relative + rounding needs a non-zero ``floor`` for a length scale, which the numerical + viscosity/yield floors provide; a cutoff *at zero* (a tension cutoff) has no + such scale and is rounded by its own physical parameter via + ``uw.maths.smooth_max`` at the call site instead. + """ + if getattr(self, "_yield_mode", "min") == "min": + return sympy.Max(value, floor) + return uw.maths.smooth_max(value, floor, self._get_yield_softness() * floor) + ## NOTE - retrofit VEP into here @@ -1191,8 +1217,13 @@ def viscosity(self): # Don't put conditional behaviour in the constitutive law # when it is not needed - if inner_self.yield_stress_min.sym != 0: - yield_stress = sympy.Max(inner_self.yield_stress_min, inner_self.yield_stress) + # Numerical lower bound on the yield stress. The "unset" sentinel is -∞ + # (per the Parameters convention that ±∞ defaults simplify away), so a + # user-supplied yield_stress_min of exactly 0 is honoured as a real floor. + if inner_self.yield_stress_min.sym != -sympy.oo: + yield_stress = self._apply_floor( + inner_self.yield_stress, inner_self.yield_stress_min + ) else: yield_stress = inner_self.yield_stress @@ -1210,7 +1241,7 @@ def viscosity(self): # Keep this as an sub-expression for clarity if inner_self.shear_viscosity_min.sym != -sympy.oo: - self._plastic_eff_viscosity._sym = sympy.Max( + self._plastic_eff_viscosity._sym = self._apply_floor( effective_viscosity, inner_self.shear_viscosity_min ) diff --git a/src/underworld3/maths/__init__.py b/src/underworld3/maths/__init__.py index 1cecae6d9..1b1103d7d 100644 --- a/src/underworld3/maths/__init__.py +++ b/src/underworld3/maths/__init__.py @@ -39,6 +39,8 @@ from .functions import delta as delta_function from .functions import L2_norm as L2_norm +from .functions import smooth_max as smooth_max +from .functions import smooth_min as smooth_min # from .vector_calculus import ( # mesh_vector_calculus_spherical_lonlat as vector_calculus_spherical_lonlat, diff --git a/src/underworld3/maths/functions.py b/src/underworld3/maths/functions.py index e97828e2a..624ee2378 100644 --- a/src/underworld3/maths/functions.py +++ b/src/underworld3/maths/functions.py @@ -56,6 +56,71 @@ def delta( return delta_fn +def smooth_max(a, b, epsilon): + r""" + Differentiable (regularized) maximum of two expressions. + + .. math:: + + \operatorname{smax}_\epsilon(a, b) = + \tfrac{1}{2}\left(a + b + \sqrt{(a - b)^2 + \epsilon^2}\right) + + Approaches the exact :math:`\max(a, b)` as :math:`\epsilon \to 0`, with the + corner rounded over a scale set by :math:`\epsilon`. Unlike ``sympy.Max`` it is + smooth everywhere, so it can appear in a residual that is differentiated for a + consistent (Newton) Jacobian without introducing a non-differentiable kink. + + Parameters + ---------- + a, b : sympy.Basic + Expressions to combine (fields, coordinates, or constants). + epsilon : float or sympy.Expr + Rounding scale, in the units of ``a`` and ``b``. Choose a small fraction of + the physical scale over which the two branches cross. + + Returns + ------- + sympy.Expr + The smooth maximum. + + Notes + ----- + A common use is a differentiable tension cutoff on a pressure-dependent yield + stress, :math:`\operatorname{smax}_\epsilon(C + \sin\phi\,p,\, 0)` — the rounded + tension cap several failure envelopes use (e.g. a parabolic Griffith cap) in + place of a sharp corner, which keeps the consistent-Newton tangent well defined. + """ + return (a + b + sympy.sqrt((a - b) ** 2 + epsilon**2)) / 2 + + +def smooth_min(a, b, epsilon): + r""" + Differentiable (regularized) minimum of two expressions. + + .. math:: + + \operatorname{smin}_\epsilon(a, b) = + \tfrac{1}{2}\left(a + b - \sqrt{(a - b)^2 + \epsilon^2}\right) + + Approaches the exact :math:`\min(a, b)` as :math:`\epsilon \to 0`; it is + :math:`-\operatorname{smax}_\epsilon(-a, -b)`. See :func:`smooth_max` for the + parameters and the differentiability rationale. + + Parameters + ---------- + a, b : sympy.Basic + Expressions to combine (fields, coordinates, or constants). + epsilon : float or sympy.Expr + Rounding scale, in the units of ``a`` and ``b``. + + Returns + ------- + sympy.Expr + The smooth minimum. + """ + return (a + b - sympy.sqrt((a - b) ** 2 + epsilon**2)) / 2 + + def L2_norm(n_s, a_s, mesh): r""" L2 norm of the difference between numerical and analytical solutions. diff --git a/tests/test_1055_yield_smoother.py b/tests/test_1055_yield_smoother.py index 9e0640f1c..b1383a80a 100644 --- a/tests/test_1055_yield_smoother.py +++ b/tests/test_1055_yield_smoother.py @@ -182,3 +182,53 @@ def test_dp_model_smoother_optin(): # bad mode rejected with pytest.raises(ValueError): cm.yield_mode = "not_a_mode" + + +@pytest.mark.level_1 +@pytest.mark.tier_a +def test_dp_pressure_yield_consistent_jacobian_builds(): + """Regression: a pressure-dependent tension-cutoff yield ``Max(C+sinφ·p, 0)`` under + the consistent-Newton tangent must build the Jacobian without recursing. + + The DP model applied its yield floor as ``Max(yield_stress_min, yield_stress)`` + whenever ``yield_stress_min.sym != 0`` — but the *unset* default is a −∞-valued + Parameter, which passes ``!= 0``, wrapping every yield in ``Max(<−∞ parameter>, …)``. + That −∞ parameter is a UWexpression sympy's fuzzy ``is_ge`` cannot resolve, so + canonicalising the ``Max`` for the consistent tangent recursed. The guard now uses + the model's own −∞ "unset" sentinel, so the wrapper is not applied. + """ + mesh = uw.meshing.StructuredQuadBox( + elementRes=(4, 4), minCoords=(0, 0), maxCoords=(1, 1) + ) + v = uw.discretisation.MeshVariable("Udpj", mesh, mesh.dim, degree=2) + p = uw.discretisation.MeshVariable("Pdpj", mesh, 1, degree=1) + s = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + cm = uw.constitutive_models.ViscoPlasticFlowModel(s.Unknowns) + s.constitutive_model = cm + sphi = float(np.sin(np.deg2rad(30.0))) + cm.Parameters.shear_viscosity_0 = 1.0e3 + cm.Parameters.yield_stress = sympy.Max(1.0 + sphi * p.sym[0], sympy.sympify(0)) + cm.Parameters.strainrate_inv_II_min = 1.0e-8 + s.consistent_jacobian = True + # Build the pointwise residual + Jacobian; on the bug this recurses to RecursionError. + s._setup_pointwise_functions(verbose=False) + + +@pytest.mark.level_1 +@pytest.mark.tier_a +def test_smooth_max_min_primitive(): + """uw.maths.smooth_max/smooth_min bracket the hard max/min, approach them as + ε→0, and are differentiable at the corner (no kink).""" + for a, b in [(1.0, 2.0), (2.0, 1.0), (3.0, -1.0), (0.0, 0.0)]: + hi, lo = max(a, b), min(a, b) + for eps in (0.5, 0.1, 1.0e-3): + smax = float(uw.maths.smooth_max(sympy.Float(a), sympy.Float(b), eps)) + smin = float(uw.maths.smooth_min(sympy.Float(a), sympy.Float(b), eps)) + assert smax >= hi - 1.0e-12 and abs(smax - hi) <= eps # rounds up, →max + assert smin <= lo + 1.0e-12 and abs(smin - lo) <= eps # rounds down, →min + + # smooth at the corner a=b: d/dx smooth_max(x, 0) is the finite value ½ at x=0 + # (a hard Max(x, 0) has a discontinuous derivative there). + x = sympy.Symbol("x") + d = sympy.diff(uw.maths.smooth_max(x, sympy.Float(0), sympy.Float(0.1)), x) + assert abs(float(d.subs(x, 0)) - 0.5) < 1.0e-9 From 691e0bbe5a5b6400afcbf8eddded63c433665ad0 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Fri, 24 Jul 2026 11:11:34 +1000 Subject: [PATCH 03/21] =?UTF-8?q?feat(systems):=20yield=5Fcontinuation=20d?= =?UTF-8?q?river=20=E2=80=94=20multi-solve=20delta-continuation=20(referen?= =?UTF-8?q?ce=20impl)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The trusted homotopy for hard viscoplastic (Drucker-Prager) yield: hold delta constant for a full nonlinear solve to tolerance, warm-start the next (smaller) delta from the converged state, march down and settle at the smallest feasible delta. Extracted from the proven Spiegelman harness (convergence.py:: run_continuation). delta lives in constants[], so each step is a recompile-free PetscDSSetConstants update. This is the reference implementation the design doc (nonlinear-solver-homotopy-warmstart) folds into stokes.solve(homotopy=True). It orchestrates solves only; the caller configures the tangent/preconditioner/ smoother (see the driver's Notes and the design doc's trap list). NOT the in-SNES delta-ramp, which is proven non-viable (see the design doc). Underworld development team with AI support from Claude Code --- src/underworld3/systems/__init__.py | 3 + src/underworld3/systems/yield_continuation.py | 146 ++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 src/underworld3/systems/yield_continuation.py diff --git a/src/underworld3/systems/__init__.py b/src/underworld3/systems/__init__.py index 97ca09960..23af56799 100644 --- a/src/underworld3/systems/__init__.py +++ b/src/underworld3/systems/__init__.py @@ -84,3 +84,6 @@ from .ddt import SemiLagrangian as SemiLagragian_DDt from .ddt import Lagrangian_Swarm as Lagrangian_Swarm_DDt from .ddt import Eulerian as Eulerian_DDt + +# δ-continuation driver for hard viscoplastic (Drucker–Prager) yield +from .yield_continuation import yield_continuation diff --git a/src/underworld3/systems/yield_continuation.py b/src/underworld3/systems/yield_continuation.py new file mode 100644 index 000000000..6cc74b2e0 --- /dev/null +++ b/src/underworld3/systems/yield_continuation.py @@ -0,0 +1,146 @@ +r"""Multi-solve δ-continuation for hard viscoplastic (Drucker–Prager) yield. + +A single solve straight onto a sharp yield surface (soft-min δ → 0) stalls or +diverges from a viscous guess. The robust, trusted route is a *continuation*: hold +δ **constant** for a full nonlinear solve to tolerance, warm-start the next +(smaller) δ from that converged state, and march δ down to the sharp surface. + +This module ships that march as a reusable driver — the production form of the +hand-tuned harness used in the Spiegelman notch studies. It orchestrates solves; it +does not change the solver's tangent, preconditioner, or line-search options (the +caller configures those — see :func:`yield_continuation` notes). +""" + +import sympy + +import underworld3 as uw + + +def yield_continuation( + solver, + delta=None, + delta0=1.0, + down=0.5, + dmin=1.0e-3, + entry_maxit=30, + step_maxit=10, + verbose=True, +): + r"""March the yield soft-min regularisation δ down to the sharp surface as a + sequence of constant-δ solves, each warm-started from the previous. + + Each δ is held **constant** for one full nonlinear solve to tolerance; the + converged :math:`(v, p)` warm-starts the next (smaller) δ. δ is a + ``constants[]`` atom, so the operators are built once at ``delta0`` and every + later step is a recompile-free ``PetscDSSetConstants`` update. The march halves + δ (``× down``) while solves keep converging and **settles** at the smallest + feasible δ — the closest automatic approach to hard-Min. A δ that fails to + converge is reverted, and the previous δ is the reported answer. + + Parameters + ---------- + solver : + A configured Stokes/SNES solver whose viscosity uses a δ-parameterised + soft-min yield law, already warm-started with a viscous (no-yield) presolve. + delta : UWexpression, optional + The δ ``constants[]`` atom to march. Defaults to the constitutive model's + yield-softness atom, ``solver.constitutive_model._get_yield_softness()``. + delta0 : float + Starting (smooth) δ. Large δ is as cheap to solve as small, so be generous. + down : float + Multiplicative step, :math:`0 < \mathrm{down} < 1` (δ ← δ·down per success). + dmin : float + Stop once δ reaches this floor (hard-Min effectively reached). + entry_maxit : int + Nonlinear iteration budget for the first solve (from the viscous guess). + step_maxit : int + Budget for each warm step. A feasible warm step converges in a few + iterations; a tight budget lets a too-hard step abort cheaply. + verbose : bool + Print per-δ convergence (rank-safe). + + Returns + ------- + dict + ``settled_delta`` (smallest δ that converged, or ``None`` if the first solve + failed), ``reason`` (final SNES converged reason), ``steps`` (number of + δ-solves), ``reached_dmin`` (whether the march made it to the floor). + + Notes + ----- + Configure the solver **before** calling — this driver changes none of it: + + - the constitutive model in a δ-parameterised soft-min mode + (``yield_mode="softmin"``); + - the consistent-Newton tangent (``solver.consistent_jacobian = True``); + - for the stiff, non-symmetric consistent-Newton velocity operator, a + non-symmetry-safe FMG smoother — + ``solver.petsc_options["fieldsplit_velocity_mg_levels_ksp_type"] = "gmres"`` + (with ``mg_levels_pc_type = "sor"``). The default Chebyshev/Richardson + smoothers stall on it, turning a solve into an effectively unbounded grind; + bound the outer Krylov (``ksp_max_it`` ~ 80) so a hostile step fails fast. + """ + if delta is None: + cm = getattr(solver, "constitutive_model", None) + if cm is None or not hasattr(cm, "_get_yield_softness"): + raise TypeError( + "yield_continuation: no δ soft-min atom found. Put the constitutive " + "model in a δ-parameterised mode (yield_mode='softmin') or pass an " + "explicit `delta` atom." + ) + delta = cm._get_yield_softness() + + if not (0.0 < down < 1.0): + raise ValueError(f"down must satisfy 0 < down < 1, got {down}") + + u, p = solver.Unknowns.u, solver.Unknowns.p + # Warm-state snapshot: a raw copy of the (already consistent) solution values, + # restored if a too-hard δ corrupts the iterate. + u_good, p_good = u.data.copy(), p.data.copy() + saved_maxit = solver.petsc_options.getInt("snes_max_it", 50) + + d = float(delta0) + settled = None + reason = 0 + steps = 0 + reached_dmin = False + first = True + while True: + delta.sym = sympy.Float(d) + if first: + solver.is_setup = False # build the operators once, at delta0 + else: + solver._update_constants() # recompile-free δ update + solver.petsc_options["snes_max_it"] = entry_maxit if first else step_maxit + solver.solve(zero_init_guess=False) + reason = int(solver.snes.getConvergedReason()) + nit = int(solver.snes.getIterationNumber()) + steps += 1 + + if reason > 0: + settled = d + u_good[...] = u.data + p_good[...] = p.data + if verbose: + uw.pprint(0, f" [yield-continuation] δ={d:<11g} its={nit:3d} → converged") + if d <= dmin: + reached_dmin = True + break + d *= down + first = False + else: + with uw.synchronised_array_update("yield_continuation revert"): + u.data[...] = u_good + p.data[...] = p_good + if verbose: + uw.pprint(0, f" [yield-continuation] δ={d:<11g} its={nit:3d} → failed " + f"(reason={reason}); settling at δ={settled}") + break + + solver.petsc_options["snes_max_it"] = saved_maxit + return { + "settled_delta": settled, + "reason": reason, + "steps": steps, + "reached_dmin": reached_dmin, + } From 753b0423aa5a6b5b7f6647b92cb7a54643b840b6 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Fri, 24 Jul 2026 11:11:35 +1000 Subject: [PATCH 04/21] docs(design): nonlinear-solver automatic warm-start + single-parameter yield homotopy PROPOSED design for a dedicated, benchmarked implementation session. Captures: - why setup is the real defect (fiddly config = API regression), with the trap list; - the evidence: multi-solve delta-continuation converges the hard case, the in-SNES delta-ramp does NOT (proven on the proven config: DIVERGED_LINEAR_SOLVE, ~2h) and the mechanism why (sharpen delta only from a converged, well-conditioned iterate); - the three-layer design: automatic Picard-on-cold warm-start + has_solution property + zero_init_guess auto-detect; constitutive-model-advertised single-parameter homotopy behind solve(homotopy=True) with residual-guided auto-descent; a non-symmetry-safe multigrid smoother default under the consistent tangent; - staged implementation plan and open verification points (free-surface chain, mover reset, single-Picard-step-lands-in-basin, benchmark the default changes). Underworld development team with AI support from Claude Code --- .../nonlinear-solver-homotopy-warmstart.md | 218 ++++++++++++++++++ docs/developer/index.md | 1 + 2 files changed, 219 insertions(+) create mode 100644 docs/developer/design/nonlinear-solver-homotopy-warmstart.md diff --git a/docs/developer/design/nonlinear-solver-homotopy-warmstart.md b/docs/developer/design/nonlinear-solver-homotopy-warmstart.md new file mode 100644 index 000000000..e44e317bd --- /dev/null +++ b/docs/developer/design/nonlinear-solver-homotopy-warmstart.md @@ -0,0 +1,218 @@ +# Nonlinear solver: automatic warm-start and single-parameter yield homotopy + +**Status: PROPOSED.** Design only — implementation is deferred to a dedicated, +carefully-benchmarked session because it changes core `solve()` behaviour. +(Design captured 2026-07, from the Spiegelman viscoplastic hardening work.) + +## Why this exists + +Hard viscoplastic (Drucker–Prager) Stokes problems do not converge from a cold +Newton start on the sharp yield surface. The working recipe is well understood — +a viscous warm start, then a **δ-continuation** (solve at a smooth yield law, then +warm-start progressively sharper solves), with the consistent-Newton tangent and a +multigrid smoother that tolerates the non-symmetric Newton operator. + +The problem is that *setting that recipe up correctly is a minefield*, and the +minefield is the real defect. Across a single working session the recipe was +mis-configured many times — default line search, a pressure-nullspace-singular LU, +`preconditioner="fmg"` giving a 1-iteration outer KSP, the default Chebyshev +smoother diverging on the non-symmetric operator, cold-start `ε̇=0 → τ_y/0 → NaN`. +Each produced a *different* failure a few steps in. **If the correct setup is that +fiddly for an expert, it is an API regression, not a user error.** The goal of this +design is to make the correct path the default path: the user writes their rheology +and calls `solve()`. + +## What was established (evidence) + +These results are on the Spiegelman notch at the genuinely hard regime +(`η_bg = 1e26 Pa·s`, `V = 10 mm/yr`, C = 1e8 Pa, φ = 30°), coarse mesh, and are +reproducible via `convergence.py` in the hard-case study. + +- **Multi-solve δ-continuation converges.** Constant δ per solve, solved to + tolerance, warm-started into the next (smaller) δ, consistent-Newton tangent, + `bt` line search, non-symmetry-safe smoother: settles at δ≈2e-3 with the plastic + band active. This is the trusted path. +- **In-SNES δ-ramp does not — proven, not masked.** Ramping δ *inside one SNES + solve* via a `SNESSetUpdate` callback (with the *same* proven config) diverges + with `DIVERGED_LINEAR_SOLVE` after 2 iterations and grinds for ~2 hours doing it. + The mechanism is fundamental: the continuation only sharpens δ from a **converged** + iterate, where the consistent-Newton Jacobian is well-conditioned; the in-SNES + ramp sharpens δ **mid-solve**, at a far-from-solution iterate, where the + consistent-Newton Jacobian on a sharpening yield surface is ill-conditioned and + the linear solve fails. **Do not re-attempt hiding δ inside a single Newton + solve.** Hide the *continuation*, not the ramp. +- **The δ-march is cheap.** With the power-mean smoother, once the first δ converges + the warm start already satisfies every sharper δ in ≈0 Newton iterations, so an + automatic residual-guided descent costs almost nothing. +- **The power-mean smoother is faithful.** At the settled δ it undershoots the true + `Min` yield by ≤0.2 %, and 0 % in genuinely yielded cells (see + {doc}`jacobian-consistent-tangent`). Earlier "it undershoots badly" readings were + an artefact of a diagnostic that evaluated a sharper power-mean, not the exact + `Min`. +- **The recursion prerequisite is fixed** (landed on the same branch as this design: + the DP model's `yield_stress_min` guard used the wrong sentinel and wrapped every + yield in `Max(<−∞ Parameter>, …)`, which recursed under the consistent tangent). + +The through-line: every failure was a **solver-configuration** error, upstream of +the Jacobian and unrelated to the recursion fix. The `get_snes_diagnostics()` field +`linear_iterations` is the tell — ≈1 linear iteration per Newton step means the +linear solve is doing no real work. + +## The design + +Three layers, from most general to most specific. Layer 1 stands alone and helps +every nonlinear solver; layers 2–3 build on it. + +### Layer 1 — automatic warm-start (all nonlinear solvers) + +A single **Picard (frozen-coefficient) step is a general cold-start warm-up**. It is +defect-correction iteration 1: contractive, moves a cold guess into the Newton +basin. UW3 already exposes it (`solve(picard=N)`) and already has the Picard tangent +(`consistent_jacobian=False`) and a Picard→Newton α-blend. + +Rules: + +- **Cold** (no usable solution): take **one** Picard step, then Newton. +- **Warm** (a usable solution is present): straight Newton, **no** Picard step — a + Picard step from a good iterate takes a linear-convergence step off the good + quadratic starting point (unnecessary, mildly harmful). +- **Linear problem**: free either way — the frozen operator *is* the real operator, + so the single Picard step *is* the exact solve and Newton then reports converged in + 0 iterations. UW3 already probes the assembled Jacobian for solution-dependence, so + a linear problem can also simply skip the warm-up. Overhead ≈ one convergence check. + +**Cold-vs-warm is auto-detected**, and detection is safe by construction: guessing +*cold* when actually warm costs one Picard step that instantly re-converges; the only +harmful direction (warming off stale, unrelated field data) is what the opt-out flag +is for. + +- **`solver.has_solution`** — a **public** property, set `True` only after a solve + that **converged** (`reason > 0`), `False` initially, after a **diverged** solve, + and after a **structural rebuild** (mesh change / adaptivity / mesh-mover — the + existing `is_setup=False` invalidation is the natural hook). It is kept through + *coefficient* changes (new viscosity, new δ, new BCs) so continuation and + time-stepping warm-start correctly. It doubles as a user-facing status flag. +- Secondary signal for a hand-set initial guess that has not been solved yet: + `‖v‖ ≈ 0 ⇒ cold`. +- **Emergent auto-recovery**: a diverged solve leaves `has_solution=False`, so the + *next* `solve()` auto-cold-starts (Picard warm-up) rather than warming off garbage. + +**API change**: today `zero_init_guess` defaults to `True` (always cold). Make it a +tri-state whose **default auto-detects**; `zero_init_guess=True` means "force fresh / +discard any solution", `False` means "insist on warming". The onus flips from "flag +when you have a solution" to "flag when you want to throw one away". + +```{note} +This changes a core default and is "benchmark before flipping" territory. It is +mostly a bug-fix — today `solve()` in a loop silently re-zeros each iteration — but +must be validated. Explicit warm (`zero_init_guess=False`) is already the common +deliberate choice in the codebase; explicit cold is rare, so the flip aligns with +existing practice. See the open verification points. +``` + +### Layer 2 — advertised single-parameter homotopy + +A constitutive model **advertises** a single-parameter homotopy, exactly as it +advertises an approximate-Jacobian flux: + +- `cm.supports_yield_homotopy` → `True` on `ViscoPlasticFlowModel` / VEP, `False` on + plain viscous. `solve(homotopy=True)` on an unsupported model raises a clear error. +- `cm._yield_homotopy_control()` → puts the model in its smooth mode + (`yield_mode="softmin"`, `yield_smoother="powermean"`), returns the δ `constants[]` + atom and the recommended tangent (Newton for non-elastic DP, Picard for elastic + VEP). The model owns "what the homotopy means for me", so the mechanism generalises + to other models and applications. + +`solve(homotopy=True, homotopy_options=...)` then runs the **continuation** (never +the in-SNES ramp): + +1. Set δ = δ₀ **large**. At large δ the power-mean is the harmonic mean, which is + bounded by η_bg even as `ε̇ → 0`, so the cold Picard warm-up (Layer 1) is + well-conditioned — **this is what removes the need for a separate viscous + pre-solve, and why the cold `ε̇=0` NaN does not occur.** +2. Newton to tolerance at δ₀ (the tangent from `_yield_homotopy_control`). +3. **Residual-guided descent**: march δ down while solves keep converging (accelerate + when a step is easy, ease off when it is hard), warm-started each time, and settle + at the smallest feasible δ. This is the default because the descent is cheap and + strictly better than "assume homotopy is not needed → stall → retreat". +4. Report via `get_snes_diagnostics()`. + +`homotopy_options` (all defaulted) exposes only the march knobs — `delta0`, `down`, +`dmin`, `entry_maxit`, `step_maxit`, and the settle criteria. Fine-tuning is optional. + +**Scope: single parameter only.** A one-parameter homotopy is easy to generalise; +multi-parameter is not, and is unnecessary here. The rate-strengthening ξ term is a +*non-homotopic* regularisation, not a homotopy — it belongs in a user-domain loop +*around* `solve()`, not inside it. + +### Layer 3 — smoother as a consistent-Newton consequence + +Turning on the consistent tangent adds the `∂η/∂(grad v)` term, which makes the +velocity-block operator **non-symmetric**. The default multigrid smoothers +(Chebyshev / Richardson) assume a symmetric/SPD operator: Chebyshev can **diverge** +on a non-symmetric operator, Richardson stalls. The fix is one line — a +non-symmetry-safe smoother, `mg_levels_ksp_type = gmres` (with `mg_levels_pc_type = +sor`). + +This is a consequence of `consistent_jacobian=True`, **not** of homotopy — anyone +using the consistent tangent with FMG hits it. The clean fix is therefore: **when the +consistent tangent is active, default the multigrid smoother to a non-symmetric-safe +one.** That removes the single worst trap for all consistent-Newton users, not just +homotopy. It is a core default change and must be benchmarked before it is made +automatic. + +## The user-facing result + +Once all three land: + +```python +stokes.constitutive_model = uw.constitutive_models.ViscoPlasticFlowModel +cm.Parameters.shear_viscosity_0 = ... +cm.Parameters.yield_stress = ... # a plain pressure-dependent yield +stokes.solve(homotopy=True) # warm-start, δ-descent, smoother — all automatic +if stokes.has_solution: + ... +``` + +The only knobs a user ever touches are the two deliberate opt-outs: force-fresh +(discard an existing solution) and fine-tune-the-march. + +## Implementation stages (for the dedicated session) + +1. **Layer 1a — `has_solution` property + Picard-on-cold warm-up**, behind the + existing `zero_init_guess` semantics (no default flip yet). Additive, low-risk. +2. **Layer 1b — flip `zero_init_guess` to auto-detect.** The benchmarked default + change. Gate the free-surface-chain and mover cases first (below). +3. **Layer 3 — non-symmetry-safe smoother default under the consistent tangent.** + Independent of homotopy; benchmark against the existing consistent-Newton tests. +4. **Layer 2 — `supports_yield_homotopy` / `_yield_homotopy_control` model hook and + the `solve(homotopy=True)` continuation.** The continuation logic already exists as + a reference implementation (`underworld3.systems.yield_continuation`, the extracted + form of `convergence.py::run_continuation`); fold it in, driven by the model hook + and Layer 1's warm-start. + +## Open verification points / risks + +- **Free-surface chain of solves** (on a feature branch, not yet inspected): a chain + of *deliberately independent* solves is the one pattern that might rely on the + implicit cold default. Confirm it either wants explicit force-fresh or is correct + warm, before flipping the default. +- **Adaptivity / mesh-mover reset**: confirm `has_solution` resets on the same + structural-invalidation event that already nukes the initial guess after a remesh. +- **Empirical basin test**: confirm a *single* Picard step at δ=δ₀ actually lands in + the Newton basin on the hard case — i.e. that it genuinely replaces the explicit + viscous pre-solve. Likely, given large-δ boundedness, but test it. +- **Benchmark the two core-default changes** (Layer 1b, Layer 3) against the existing + solver regression suite — "Solver Stability is Paramount". + +## References + +- Reference implementation of the continuation: `convergence.py::run_continuation` + in the Spiegelman hard-case study; extracted form in + `underworld3.systems.yield_continuation`. +- Consistent tangent + δ soft-min families: {doc}`jacobian-consistent-tangent`. +- Solver strategy catalogue (consult and contribute): `solver-strategies-catalogue`. +- Diagnostics: `SNES_*.get_snes_diagnostics()` / `check_snes_convergence()` / + `solve_with_diagnostics()` — `linear_iterations` is the diagnostic tell. +- The recipe and its trap list are also captured in the `plasticity-solvers` / + nonlinear-solver skill. diff --git a/docs/developer/index.md b/docs/developer/index.md index 977e09ac8..9c3ae16bd 100644 --- a/docs/developer/index.md +++ b/docs/developer/index.md @@ -160,6 +160,7 @@ design/SWARM_MODERNIZATION_DESIGN_2026-07 design/PROJECTED_NORMALS_API_DESIGN design/TURBULENCE_MODEL_DESIGN design/declined-coord-units-proposal +design/nonlinear-solver-homotopy-warmstart ``` ```{toctree} From 940d743c5e46bb13d69a5ae717d53a59728809c8 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Fri, 24 Jul 2026 11:44:11 +1000 Subject: [PATCH 05/21] feat(solver): has_solution warm-start status + cold consistent-Newton Picard warm-up (Layer 1a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer 1a of the nonlinear-solver automatic warm-start / single-parameter yield homotopy design (docs/developer/design/nonlinear-solver-homotopy-warmstart.md). SolverBaseClass.has_solution — a public, read-only warm-start status flag. Set True only after a solve whose SNES converged (reason > 0); reset to False initially, after a diverged solve, and on a structural rebuild (the is_setup=False invalidation used by remesh / adapt / mesh-mover). It survives coefficient-only changes (viscosity, yield softness delta, BC values, time step), which route through _update_constants / a direct function rewire, so parameter continuation and time-stepping warm-start correctly. Recorded by _record_convergence_status() at the end of every solve() — scalar, vector, multi-component, Stokes, and the rotated free-slip path (from its info dict, since that path runs its own KSP loop rather than self.snes). Stokes cold-start warm-up — a single Picard step (reusing the existing picard=1 nrichardson machinery) is taken automatically on a cold (zero_init_guess) solve under the consistent-Newton tangent, the opt-in nonlinear-yield regime that needs a defect-correction step into the Newton basin. The default (frozen) tangent path is bit-identical and no default is flipped — the zero_init_guess auto-detect flip is the separate, benchmarked Layer 1b. Additive and low-risk. Regression test test_0201 covers the has_solution lifecycle (fresh / converged / structural-reset / coefficient-survival / mesh-deform) and that the cold consistent-Newton warm-up converges. Underworld development team with AI support from Claude Code --- .../cython/petsc_generic_snes_solvers.pyx | 73 +++++++++- ...test_0201_solver_has_solution_warmstart.py | 132 ++++++++++++++++++ 2 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 tests/test_0201_solver_has_solution_warmstart.py diff --git a/src/underworld3/cython/petsc_generic_snes_solvers.pyx b/src/underworld3/cython/petsc_generic_snes_solvers.pyx index 0fda2ebd4..5eb9238f7 100644 --- a/src/underworld3/cython/petsc_generic_snes_solvers.pyx +++ b/src/underworld3/cython/petsc_generic_snes_solvers.pyx @@ -85,6 +85,14 @@ class SolverBaseClass(uw_object): self._needs_bc_reregister = True self._needs_function_rewire = True + # Warm-start status, public (read-only) via `has_solution`. Set True only + # after a converged solve; reset on a structural rebuild (is_setup=False) + # so a remesh / adapt / mesh-mover never warm-starts off stale field + # data. Kept through coefficient changes (viscosity, yield softness δ, BC + # values, time step). See has_solution / _record_convergence_status and + # docs/developer/design/nonlinear-solver-homotopy-warmstart.md (Layer 1). + self._has_solution = False + self.Unknowns = self._Unknowns(self) self._order = 0 @@ -790,6 +798,46 @@ class SolverBaseClass(uw_object): self._needs_dm_rebuild = True self._needs_bc_reregister = True self._needs_function_rewire = True + # A structural invalidation (mesh change / adaptivity / mesh-mover / + # explicit _force_setup) means any stored solution no longer matches + # the operators — drop the warm-start claim so the next solve() + # cold-starts rather than warming off a stale iterate. Coefficient-only + # updates (new viscosity, δ, BC values, time step) route through + # _update_constants / a direct _needs_function_rewire and keep + # has_solution, so continuation and time-stepping warm-start correctly. + self._has_solution = False + + @property + def has_solution(self): + """``True`` when the solver holds a converged solution usable as a warm start. + + Set ``True`` only after a solve whose SNES reported a converged reason + (``> 0``); ``False`` initially, after a diverged solve, and after any + structural rebuild (mesh change / adaptivity / mesh-mover / explicit + ``_force_setup`` — the ``is_setup = False`` invalidation hook). It + survives coefficient changes (viscosity, yield softness :math:`\\delta`, + boundary-condition *values*, time step) so parameter continuation and + time-stepping warm-start correctly. + + Read-only status flag. Because a diverged solve leaves + ``has_solution == False``, the next :meth:`solve` automatically + cold-starts (Picard warm-up) rather than warming off a corrupted iterate. + + See :doc:`nonlinear-solver-homotopy-warmstart` (Layer 1). + """ + return self._has_solution + + def _record_convergence_status(self, converged=None): + """Refresh :attr:`has_solution` from the just-completed solve. + + Called at the end of every ``solve()``. With ``converged`` unset the + status is read from the SNES converged reason (``> 0`` ⇒ converged); + the rotated free-slip path, which runs its own KSP loop rather than + driving ``self.snes``, passes the flag from its result dict. + """ + if converged is None: + converged = self.snes is not None and self.snes.getConvergedReason() > 0 + self._has_solution = bool(converged) class _Unknowns: """ @@ -3484,6 +3532,8 @@ class SNES_Scalar(SolverBaseClass): self._warn_on_divergence() + self._record_convergence_status() + return def _object_viewer(self): @@ -4517,6 +4567,8 @@ class SNES_Vector(SolverBaseClass): self._warn_on_divergence() + self._record_convergence_status() + return def _object_viewer(self): @@ -5209,6 +5261,8 @@ class SNES_MultiComponent(SolverBaseClass): self._warn_on_divergence() + self._record_convergence_status() + return def _object_viewer(self): @@ -8360,7 +8414,10 @@ class SNES_Stokes_SaddlePt(SolverBaseClass): zero_init_guess=zero_init_guess, picard=picard) # This path solves via ksp.solve on the rotated operator (not self.snes), # so give it a report from the rotated result rather than leaving a stale one. - self._capture_rotated_report(self._rotated_freeslip_info) + _rotated_report = self._capture_rotated_report(self._rotated_freeslip_info) + # The warm-start flag must follow the rotated solve's own verdict — the SNES + # was never run, so the generic reader would latch a stale reason. + self._record_convergence_status(converged=_rotated_report.converged) return if time is not None: @@ -8413,6 +8470,18 @@ class SNES_Stokes_SaddlePt(SolverBaseClass): else: self.atol = 0.0 + # Automatic cold-start warm-up (Layer 1): a single Picard (frozen- + # coefficient) step moves a cold guess into the Newton basin — it is + # defect-correction iteration 1, contractive and cheap. Taken only on a + # genuine cold start (zero_init_guess) under the consistent-Newton + # tangent, which is the opt-in nonlinear-yield regime that needs it; the + # default (frozen) tangent path is left bit-identical. Skipped when the + # caller set an explicit Picard count, and under the "continuation" + # tangent (which already warm-starts internally). See + # docs/developer/design/nonlinear-solver-homotopy-warmstart.md (Layer 1). + if picard == 0 and zero_init_guess and self.consistent_jacobian is True: + picard = 1 + if verbose and uw.mpi.rank == 0: print(f"SNES solve - picard = {picard}", flush=True) @@ -8506,6 +8575,8 @@ class SNES_Stokes_SaddlePt(SolverBaseClass): self._warn_on_divergence() + self._record_convergence_status() + return @timing.routine_timer_decorator diff --git a/tests/test_0201_solver_has_solution_warmstart.py b/tests/test_0201_solver_has_solution_warmstart.py new file mode 100644 index 000000000..b6369307b --- /dev/null +++ b/tests/test_0201_solver_has_solution_warmstart.py @@ -0,0 +1,132 @@ +"""Layer 1a of the nonlinear-solver warm-start / homotopy design: +``solver.has_solution`` and the automatic cold-start Picard warm-up. + +Contract under test (design: +``docs/developer/design/nonlinear-solver-homotopy-warmstart.md``, Layer 1): + + * ``has_solution`` is a public, read-only status flag, ``False`` on a fresh + solver and ``True`` only after a solve whose SNES converged. + * It resets to ``False`` on a *structural* rebuild (the ``is_setup = False`` + invalidation used by remesh / adapt / mesh-mover), so the next solve + cold-starts rather than warming off a stale iterate. + * It survives a coefficient-only change (a new viscosity value), so parameter + continuation and time-stepping warm-start correctly. + * The cold-start Picard warm-up runs under the consistent-Newton tangent + without breaking convergence, and leaves the default (frozen) tangent path + untouched. + +These are cheap serial checks on the public API — the hard-case δ-continuation +that motivates the design is validated separately against the Spiegelman study. +""" + +import pytest +import sympy + +import underworld3 as uw + + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + + +def _poisson(): + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.25 + ) + t = uw.discretisation.MeshVariable("T", mesh, 1, degree=1) + poisson = uw.systems.Poisson(mesh, u_Field=t) + poisson.constitutive_model = uw.constitutive_models.DiffusionModel + poisson.constitutive_model.Parameters.diffusivity = 1.0 + poisson.add_essential_bc((0.0,), "Bottom") + poisson.add_essential_bc((1.0,), "Top") + return mesh, poisson + + +def test_has_solution_false_before_first_solve(): + _, poisson = _poisson() + assert poisson.has_solution is False + + +def test_has_solution_true_after_converged_solve(): + _, poisson = _poisson() + poisson.solve() + assert poisson.snes.getConvergedReason() > 0 + assert poisson.has_solution is True + + +def test_has_solution_resets_on_structural_rebuild(): + """A structural invalidation (is_setup=False — the remesh / adapt / mover + hook) must drop the warm-start claim.""" + _, poisson = _poisson() + poisson.solve() + assert poisson.has_solution is True + + poisson.is_setup = False + assert poisson.has_solution is False, ( + "has_solution must reset when the solver is structurally invalidated" + ) + + +def test_has_solution_survives_coefficient_change(): + """Changing a coefficient value (not the mesh/operators) keeps the solution + so continuation and time-stepping can warm-start.""" + _, poisson = _poisson() + poisson.solve() + assert poisson.has_solution is True + + # A coefficient update goes through _update_constants, not is_setup=False. + poisson.constitutive_model.Parameters.diffusivity = 2.0 + poisson._update_constants() + assert poisson.has_solution is True, ( + "a coefficient-only change must not drop the warm-start claim" + ) + + +def test_has_solution_resets_on_mesh_deform(): + """The mesh-mover path (mesh deform → registered solvers is_setup=False) + must reset has_solution.""" + import numpy as np + + mesh = uw.meshing.StructuredQuadBox( + elementRes=(8, 8), minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0) + ) + v = uw.discretisation.MeshVariable("V", mesh, mesh.dim, degree=2) + p = uw.discretisation.MeshVariable("P", mesh, 1, degree=1, continuous=True) + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = 1.0 + stokes.bodyforce = sympy.Matrix([0.0, -1.0]) + stokes.add_essential_bc((0.0, 0.0), "Bottom") + stokes.add_essential_bc((0.0, None), "Left") + stokes.add_essential_bc((0.0, None), "Right") + stokes.solve() + assert stokes.has_solution is True + + disp = np.zeros((mesh.X.coords.shape[0], mesh.dim)) + disp[:, -1] = 0.01 * mesh.X.coords[:, 0] + with mesh._coord_mutation(): + mesh._deform_mesh(mesh.X.coords + disp) + assert stokes.has_solution is False, ( + "a mesh deform (structural rebuild) must reset has_solution" + ) + + +def test_cold_warmstart_under_consistent_newton_converges(): + """A cold consistent-Newton Stokes solve exercises the automatic + single-Picard warm-up branch — it must converge and set has_solution.""" + mesh = uw.meshing.StructuredQuadBox( + elementRes=(8, 8), minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0) + ) + v = uw.discretisation.MeshVariable("V", mesh, mesh.dim, degree=2) + p = uw.discretisation.MeshVariable("P", mesh, 1, degree=1, continuous=True) + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = 1.0 + stokes.bodyforce = sympy.Matrix([0.0, -1.0]) + stokes.add_essential_bc((0.0, 0.0), "Bottom") + stokes.add_essential_bc((0.0, None), "Left") + stokes.add_essential_bc((0.0, None), "Right") + + stokes.consistent_jacobian = True + stokes.solve() # cold (zero_init_guess default True) → warm-up branch taken + assert stokes.snes.getConvergedReason() > 0 + assert stokes.has_solution is True From c13e69a5b6f0b8ad7d91f81b6733d4216b09fa7f Mon Sep 17 00:00:00 2001 From: lmoresi Date: Fri, 24 Jul 2026 11:46:07 +1000 Subject: [PATCH 06/21] docs(solver): nonlinear-solver skill (recipe + config trap list) + mark Layer 1a landed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seed a `nonlinear-solver` skill capturing the working recipe for hard viscoplastic Stokes convergence — automatic warm-start plus the MULTI-SOLVE δ-continuation (not an in-SNES ramp), the consistent-Newton tangent, and the non-symmetry-safe smoother — together with the CONFIG TRAP LIST (the setup mistakes that each produce a different failure a few steps in) and the diagnostic tell (get_snes_diagnostics linear_iterations ≈ 1 = no linear work). It records the proven-dead in-SNES δ-ramp as a do-not, superseding the enable_yield_homotopy advice in the plasticity-solvers skill for the hard case, and cross-links plasticity-solvers for the yield-law maths and tangent-per-model. Mark Layer 1a landed in the design doc with the as-built scoping decisions (warm-up gated on the consistent-Newton tangent; rotated path records via its info dict; has_solution resets in the is_setup setter). Underworld development team with AI support from Claude Code --- .claude/skills/nonlinear-solver/SKILL.md | 145 ++++++++++++++++++ .../nonlinear-solver-homotopy-warmstart.md | 15 +- 2 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 .claude/skills/nonlinear-solver/SKILL.md diff --git a/.claude/skills/nonlinear-solver/SKILL.md b/.claude/skills/nonlinear-solver/SKILL.md new file mode 100644 index 000000000..59c34c46a --- /dev/null +++ b/.claude/skills/nonlinear-solver/SKILL.md @@ -0,0 +1,145 @@ +--- +name: nonlinear-solver +description: How to make a hard nonlinear Stokes solve (Drucker-Prager / yield-stress viscoplastic) CONVERGE reliably in Underworld3 the way the working recipe actually does it — automatic warm-start (one Picard step on a cold start) plus a MULTI-SOLVE δ-continuation (constant δ per solve, warm-start the next, sharper δ), the consistent-Newton tangent, and a non-symmetry-safe multigrid smoother. Reach for THIS when a viscoplastic solve stalls / diverges and you are about to hand-tune PETSc options, ramp δ, or "just add a monitor". It carries the CONFIG TRAP LIST — the setup mistakes that each produce a different failure a few steps in — and the one thing you must NOT do (ramp δ inside a single SNES solve). For the yield-law maths and which tangent per model, see `plasticity-solvers`. +--- + +# nonlinear-solver + +The recipe that gets a **hard viscoplastic (Drucker–Prager) Stokes** problem to +converge, and — more importantly — the list of setup mistakes that stop it. The +central lesson from the Spiegelman hard-case study (`η_bg=1e26`, `V=10`): every +failure was a **solver-configuration** error, not a bad Jacobian. If the correct +setup is a minefield for an expert, that is an API regression — so the goal is to +make the correct path the default path. + +Design of record: `docs/developer/design/nonlinear-solver-homotopy-warmstart.md`. +Yield-law maths, tangent-per-model, quadratic-convergence check: `plasticity-solvers`. + +--- + +## The recipe (what actually converges) + +1. **Warm start.** A cold plastic start (`v=0 ⇒ ε̇=0 ⇒ τ_y/0 ⇒ NaN`) is avoided by + starting the continuation at **large δ** (the power-mean is then the harmonic + mean, bounded by `η_bg` even at `ε̇→0`) and taking **one Picard step** into the + Newton basin. One Picard step is defect-correction iteration 1 — contractive, + cheap. From a *warm* iterate, take **no** Picard step (it wastes the good + quadratic start). + +2. **Multi-solve δ-continuation** (NOT an in-solve ramp). Hold δ **constant** for a + full nonlinear solve to tolerance; warm-start the next, smaller δ from that + converged state; march δ down to the sharp surface. δ is a `constants[]` atom, + so each step is a recompile-free `PetscDSSetConstants` update. Reference driver: + + ```python + from underworld3.systems import yield_continuation + cm.yield_mode = "softmin"; cm.yield_smoother = "powermean" + stokes.consistent_jacobian = True # per plasticity-solvers + # non-symmetry-safe smoother (see trap list): + stokes.petsc_options["fieldsplit_velocity_mg_levels_ksp_type"] = "gmres" + stokes.petsc_options["fieldsplit_velocity_mg_levels_pc_type"] = "sor" + # a viscous (no-yield) presolve gives the warm start the driver expects + result = yield_continuation(stokes, delta0=1.0, down=0.5, dmin=1e-3) + # result["settled_delta"] is the smallest δ that converged + ``` + +3. **Consistent-Newton tangent** for non-elastic DP (`consistent_jacobian=True`); + **Picard** for elastic VEP — see `plasticity-solvers` for the per-model table. + +4. **`bt` line search** with the consistent tangent on a smooth (δ>0) surface. + +The δ-march is cheap: with the power-mean smoother a converged δ warm-starts every +sharper δ in ≈0 Newton iterations, so a residual-guided auto-descent costs almost +nothing. + +--- + +## DO NOT ramp δ inside one SNES solve + +Ramping δ **inside a single SNES solve** (a `SNESSetUpdate` callback that sharpens +the yield surface between Newton iterations) is **proven dead** — it diverges +`DIVERGED_LINEAR_SOLVE` after ~2 iterations and grinds for ~2 hours, **even on the +proven solver config**. Mechanism: the continuation only sharpens δ from a +**converged**, well-conditioned iterate; the in-SNES ramp sharpens δ **mid-solve** +at a far-from-solution iterate where the consistent-Newton Jacobian on a sharpening +surface is ill-conditioned and the linear solve fails. **Hide the *continuation*, +not the *ramp*.** (This supersedes the `enable_yield_homotopy()` in-SNES ramp still +described in `plasticity-solvers`; that path is retained only as a dead-experiment +record — use the multi-solve continuation above.) + +--- + +## CONFIG TRAP LIST + +Each of these produces a *different* failure a few steps in — that is why the hard +case felt like whack-a-mole. Check them first. + +| Trap | Symptom | Fix | +|---|---|---| +| Consistent Newton makes the velocity block **non-symmetric**; default Chebyshev/Richardson MG smoother assumes SPD | smoother diverges / stalls → `DIVERGED_LINEAR_SOLVE` or an endless grind | `fieldsplit_velocity_mg_levels_ksp_type=gmres` + `mg_levels_pc_type=sor` | +| `preconditioner="fmg"` (vs explicit `pc_type=mg` + manual mg opts) | outer KSP "converges" in **1 iteration** → no real Newton correction → stall → `DIVERGED_LINE_SEARCH` | use explicit `pc_type=mg` with the smoother opts above; bound the outer KSP (`ksp_max_it`~80) so a hostile step fails fast | +| Cold plastic start `v=0` | `ε̇=0 → τ_y/0 → NaN` on iteration 0 | start δ **large** (power-mean → harmonic, bounded by `η_bg`) + one Picard step | +| LU velocity block with all-Dirichlet-ish BC | pressure nullspace singular | attach the Stokes nullspace / avoid a bare LU there | +| Hand-rolled `snes_monitor` to "see what's happening" | you read residuals but miss the tell | use `solve_with_diagnostics` / `get_snes_diagnostics` instead (below) | + +**The diagnostic tell:** `solver.get_snes_diagnostics()["linear_iterations"] ≈ 1` +per Newton step means the linear solve is doing **no real work** (the FMG-1-iteration +trap). A healthy consistent-Newton solve does real Krylov work each step and +converges quadratically. Use `solve_with_diagnostics()`, not a hand-rolled monitor. + +--- + +## Automatic warm-start (Layer 1 — landed) + +`solver.has_solution` is a **public, read-only** status flag: `True` only after a +solve whose SNES converged; reset on a structural rebuild (remesh / adapt / +mesh-mover — the `is_setup=False` hook); kept through coefficient changes (viscosity, +δ, BC values, time step). A **diverged** solve leaves it `False`, so the next solve +auto-cold-starts rather than warming off a corrupted iterate. + +On a **cold** (`zero_init_guess=True`) Stokes solve under the **consistent-Newton +tangent**, a single Picard step is now taken automatically (reusing the existing +`picard=1` machinery). The default (frozen) tangent path is bit-identical. + +```python +stokes.consistent_jacobian = True +stokes.solve() # cold → one automatic Picard step, then Newton +if stokes.has_solution: + ... +``` + +--- + +## Implementation status (this line of work) + +- **Layer 1a — DONE:** `has_solution` + cold consistent-Newton Picard warm-up + (`petsc_generic_snes_solvers.pyx`; test `test_0201`). +- **Layer 1b — planned:** flip `zero_init_guess` default to auto-detect (cold-vs-warm + from `has_solution`). Benchmark; gate the free-surface chain-of-solves and the + mover/adapt reset first. +- **Layer 3 — planned:** default the FMG velocity smoother to non-symmetry-safe + (gmres/sor) when `consistent_jacobian` is on. Benchmark vs the consistent-Newton tests. +- **Layer 2 — planned:** constitutive model advertises the homotopy + (`supports_yield_homotopy` / `_yield_homotopy_control`) and + `stokes.solve(homotopy=True, homotopy_options=...)` runs the continuation + (folding in `yield_continuation`) with residual-guided auto-descent. + +--- + +## Gotchas + +- **`./uw build` → `amr-dev` env**; verify `uw.__file__` is the worktree site-packages. +- **Run VEP/consistent-Newton tests UNFORKED** — `pytest --forked` SIGABRTs (fork of + multithreaded PETSc). +- Benchmark **every** default change — "Solver Stability is Paramount". +- ξ (rate-strengthening) is a **non-homotopic** regularisation: put a user loop + *around* `solve()`, never inside the δ-march. + +## Reference + +- Design: `docs/developer/design/nonlinear-solver-homotopy-warmstart.md`, + `jacobian-consistent-tangent.md`, `solver-strategies-catalogue.md`. +- Continuation driver: `underworld3.systems.yield_continuation`. +- Diagnostics: `SNES_*.get_snes_diagnostics()` / `solve_with_diagnostics()`. +- Related skills: `plasticity-solvers` (yield law + tangent per model), + `free-surface-convection`, `adaptive-meshing`. diff --git a/docs/developer/design/nonlinear-solver-homotopy-warmstart.md b/docs/developer/design/nonlinear-solver-homotopy-warmstart.md index e44e317bd..4d3c8eeb4 100644 --- a/docs/developer/design/nonlinear-solver-homotopy-warmstart.md +++ b/docs/developer/design/nonlinear-solver-homotopy-warmstart.md @@ -179,8 +179,19 @@ The only knobs a user ever touches are the two deliberate opt-outs: force-fresh ## Implementation stages (for the dedicated session) -1. **Layer 1a — `has_solution` property + Picard-on-cold warm-up**, behind the - existing `zero_init_guess` semantics (no default flip yet). Additive, low-risk. +1. **Layer 1a — `has_solution` property + Picard-on-cold warm-up** — **LANDED** + (`petsc_generic_snes_solvers.pyx`; test `test_0201`). Behind the existing + `zero_init_guess` semantics (no default flip). Additive, low-risk. As built: + `has_solution` lives on `SolverBaseClass`, is recorded by + `_record_convergence_status()` at the end of every `solve()` (the rotated + free-slip path records from its info dict, since it runs its own KSP loop), + and resets in the `is_setup = False` setter. The cold Picard warm-up is scoped + to a cold Stokes solve **under the consistent-Newton tangent** + (`consistent_jacobian is True`) — the opt-in nonlinear regime that needs it — + so the default (frozen) tangent path is bit-identical and the common linear + solve pays nothing. Broadening the warm-up to all nonlinear solvers (a cached + nonlinearity probe rather than the tangent proxy) is a natural Layer-1b + extension. Recipe + config-trap-list captured in the `nonlinear-solver` skill. 2. **Layer 1b — flip `zero_init_guess` to auto-detect.** The benchmarked default change. Gate the free-surface-chain and mover cases first (below). 3. **Layer 3 — non-symmetry-safe smoother default under the consistent tangent.** From 9789630423eb92760acacd19043287b32cdf7258 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sat, 25 Jul 2026 16:21:53 +1000 Subject: [PATCH 07/21] feat(solver): default the FMG velocity smoother to gmres+sor, fixed-cost V-cycle (Layer 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer 3 of the nonlinear-solver design. The geometric-FMG option bundle now uses a non-symmetry-safe Krylov smoother sized for a DEEP hierarchy, rather than the stationary richardson+sor that preceded it: mg_levels_ksp_type richardson -> gmres mg_levels_pc_type sor (unchanged) mg_levels_ksp_max_it 4 (unchanged) mg_levels_ksp_norm_type (new) none -> fixed-cost V-cycle Measured on the Spiegelman notch (Drucker-Prager, eta contrast 1e26, V=10) with a NESTED hierarchy built by uniform refinement of an ultra-coarse 492-cell base, so the multigrid actually has depth. Per-V-cycle contraction rho of the velocity block at xi=0.01, at the SAME four smoother iterations: 3 levels: richardson 0.722 gmres 0.686 (5% better) 4 levels: richardson 0.746 gmres 0.560 (25% better) The gmres margin GROWS with depth, because a deeper cycle applies the smoother on more coarse operators, each non-symmetric under the consistent-Newton tangent. A two-level cycle is a coarse-grid correction rather than a V-cycle and is not worth special-casing, so the default is set for the deep case unconditionally — no level-count gate and no consistent_jacobian gate. Four smoother iterations, not more: per unit work gmres/4 (rho^(1/4) = 0.87) beats gmres/8 (0.478^(1/8) = 0.91). norm_type=none makes the smoother run exactly max_it iterations with no residual norm and no early exit, so every V-cycle costs the same. The resulting cycle is non-stationary; the velocity block is already fgmres (flexible), which is what tolerates that, so no other change is needed. Not fixed by this: at the hard small-xi corner the inner solve fails under every smoother tried (richardson outright, gmres with rho > 1 at 4 levels). That is operator conditioning, and the lever for it is the delta/xi continuation (Layer 2), not the smoother. Verified: test_1014 (contract updated to the new bundle + the fixed-cost assertion), FMG checkpoint hierarchy, Stokes cartesian/spherical/nullspace/rotated-freeslip and the solver smoke suite (34 passed), plus ptest_0004 FMG hierarchy at np=2. Underworld development team with AI support from Claude Code --- .../cython/petsc_generic_snes_solvers.pyx | 28 +++++++++++++++---- tests/test_1014_stokes_multigrid.py | 11 ++++++-- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/underworld3/cython/petsc_generic_snes_solvers.pyx b/src/underworld3/cython/petsc_generic_snes_solvers.pyx index 5eb9238f7..e72b3e4cd 100644 --- a/src/underworld3/cython/petsc_generic_snes_solvers.pyx +++ b/src/underworld3/cython/petsc_generic_snes_solvers.pyx @@ -621,13 +621,28 @@ class SolverBaseClass(uw_object): opts[f"{prefix}pc_type"] = "mg" opts[f"{prefix}pc_mg_type"] = "full" # FMG (F-cycle) opts[f"{prefix}pc_mg_galerkin"] = "both" # RAP coarse operators - # richardson+sor (not chebyshev): chebyshev needs eigenvalue - # estimates of the smoothed operator, which are fragile on the - # indefinite / variable-viscosity Stokes velocity block and diverge; - # richardson+sor is the benchmark-validated, mesh-independent choice. - opts[f"{prefix}mg_levels_ksp_type"] = "richardson" + # gmres+sor, sized for a DEEP hierarchy (the only kind worth having: + # a two-level cycle is a coarse-grid correction, not a V-cycle, and is + # not worth special-casing). Chebyshev needs eigenvalue estimates of the + # smoothed operator, which are fragile on the indefinite / + # variable-viscosity velocity block and diverge. Richardson is + # stationary and degrades on the NON-SYMMETRIC operator produced by the + # consistent-Newton tangent. Measured on the Spiegelman notch (Drucker- + # Prager, eta contrast 1e26) over a nested 4-level hierarchy: contraction + # per V-cycle rho = 0.75 (richardson) vs 0.56 (gmres) at the SAME four + # smoother iterations -- and the gmres margin GROWS with depth (5% at 3 + # levels, 25% at 4), because deeper cycles apply the smoother on more + # coarse operators. Four iterations, not more: per unit work gmres/4 + # (rho^(1/4) = 0.87) beats gmres/8 (0.91). + opts[f"{prefix}mg_levels_ksp_type"] = "gmres" opts[f"{prefix}mg_levels_pc_type"] = "sor" opts[f"{prefix}mg_levels_ksp_max_it"] = 4 + # Run EXACTLY max_it smoother iterations: no residual-norm computation + # and no convergence test, so every V-cycle costs the same. A Krylov + # smoother makes the cycle non-stationary, which is why the velocity + # block is fgmres (flexible) rather than gmres -- see the fieldsplit + # defaults in the Stokes __init__. + opts[f"{prefix}mg_levels_ksp_norm_type"] = "none" opts[f"{prefix}mg_levels_ksp_converged_maxits"] = None # redundant+lu, not bare lu: a bare serial LU cannot factor a # distributed coarse matrix and fails at np>1 (DIVERGED_LINEAR_SOLVE @@ -658,7 +673,8 @@ class SolverBaseClass(uw_object): opts[f"{prefix}mg_levels_ksp_converged_maxits"] = None # Clear stale geometric-MG-only keys. for key in ("pc_mg_galerkin", "mg_levels_ksp_type", - "mg_levels_pc_type", "mg_coarse_pc_type", + "mg_levels_pc_type", "mg_levels_ksp_norm_type", + "mg_coarse_pc_type", "mg_coarse_redundant_pc_type"): opts.delValue(f"{prefix}{key}") self._pc_managed_value = "gamg" diff --git a/tests/test_1014_stokes_multigrid.py b/tests/test_1014_stokes_multigrid.py index 895adf0e0..ca8045f75 100644 --- a/tests/test_1014_stokes_multigrid.py +++ b/tests/test_1014_stokes_multigrid.py @@ -150,12 +150,19 @@ def test_geometric_mg_without_galerkin_is_repaired(): def test_default_fmg_bundle_is_parallel_safe(): # The property's OWN default FMG bundle must be usable at np>1 unaided: a # parallel-safe coarse solver (redundant+lu, not bare serial lu) and a - # robust smoother (richardson+sor, not eigen-estimate-fragile chebyshev). + # smoother sized for a DEEP hierarchy — gmres+sor, not eigen-estimate-fragile + # chebyshev and not stationary richardson, which degrades on the non-symmetric + # consistent-Newton operator (measured: per-V-cycle contraction 0.75 richardson + # vs 0.56 gmres over 4 nested levels on the Spiegelman notch). stokes = _make_stokes(mesh_refined) stokes.preconditioner = "fmg" stokes.solve() vp = "fieldsplit_velocity_" assert stokes.petsc_options.getString(vp + "mg_coarse_pc_type") == "redundant" assert stokes.petsc_options.getString(vp + "mg_coarse_redundant_pc_type") == "lu" - assert stokes.petsc_options.getString(vp + "mg_levels_ksp_type") == "richardson" + assert stokes.petsc_options.getString(vp + "mg_levels_ksp_type") == "gmres" + assert stokes.petsc_options.getString(vp + "mg_levels_pc_type") == "sor" + # Fixed-cost V-cycle: exactly mg_levels_ksp_max_it smoother iterations, no + # residual-norm computation and no early exit. + assert stokes.petsc_options.getString(vp + "mg_levels_ksp_norm_type") == "none" assert stokes.snes.getConvergedReason() > 0 From 528055b5f514a9e4ea8ae312b9f4eef1c0ea2a9d Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sat, 25 Jul 2026 16:24:28 +1000 Subject: [PATCH 08/21] docs(solver): record the measured Layer 3 result and the multigrid-depth method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Layer 3 design section predicted a one-line smoother swap gated on consistent_jacobian. The benchmark changed the shape of the fix, so record what was actually measured and what landed: - the gmres-over-richardson margin GROWS with multigrid depth (5% at 3 levels, 25% at 4), because a deeper cycle applies the smoother on more non-symmetric coarse operators — so a comparison run on a 2-level hierarchy is misleading; - therefore the default is unconditional (no level-count or tangent gate), since a two-level cycle is a coarse-grid correction rather than a V-cycle and is not worth special-casing; - four smoother iterations, not more, on a per-unit-work basis; - the small-xi corner is NOT fixed by any smoother (richardson fails outright, gmres gives rho > 1 at 4 levels) — that is operator conditioning and belongs to the delta/xi continuation. The skill gains a "Multigrid depth" section with the method: build depth from an ultra-coarse NESTED base (492 cells + uniform refinement) rather than a fine mesh, never a non-nested hierarchy, and probe the fieldsplit_velocity_ sub-KSP because solve_report records the Newton rate and is blind to the smoother. Its trap-list row for the non-symmetric smoother is now "this is the default" rather than a manual fix. Underworld development team with AI support from Claude Code --- .claude/skills/nonlinear-solver/SKILL.md | 40 +++++++++++++++-- .../nonlinear-solver-homotopy-warmstart.md | 43 ++++++++++++++++--- 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/.claude/skills/nonlinear-solver/SKILL.md b/.claude/skills/nonlinear-solver/SKILL.md index 59c34c46a..84f51bf08 100644 --- a/.claude/skills/nonlinear-solver/SKILL.md +++ b/.claude/skills/nonlinear-solver/SKILL.md @@ -76,7 +76,7 @@ case felt like whack-a-mole. Check them first. | Trap | Symptom | Fix | |---|---|---| -| Consistent Newton makes the velocity block **non-symmetric**; default Chebyshev/Richardson MG smoother assumes SPD | smoother diverges / stalls → `DIVERGED_LINEAR_SOLVE` or an endless grind | `fieldsplit_velocity_mg_levels_ksp_type=gmres` + `mg_levels_pc_type=sor` | +| Consistent Newton makes the velocity block **non-symmetric**; a Chebyshev/Richardson MG smoother assumes SPD | smoother diverges / stalls → `DIVERGED_LINEAR_SOLVE` or an endless grind | **Now the default** — the FMG bundle ships `mg_levels_ksp_type=gmres` + `pc_type=sor` + `norm_type=none`. Only an issue if you override it, or on GAMG (which uses PETSc's chebyshev default) | | `preconditioner="fmg"` (vs explicit `pc_type=mg` + manual mg opts) | outer KSP "converges" in **1 iteration** → no real Newton correction → stall → `DIVERGED_LINE_SEARCH` | use explicit `pc_type=mg` with the smoother opts above; bound the outer KSP (`ksp_max_it`~80) so a hostile step fails fast | | Cold plastic start `v=0` | `ε̇=0 → τ_y/0 → NaN` on iteration 0 | start δ **large** (power-mean → harmonic, bounded by `η_bg`) + one Picard step | | LU velocity block with all-Dirichlet-ish BC | pressure nullspace singular | attach the Stokes nullspace / avoid a bare LU there | @@ -117,8 +117,9 @@ if stokes.has_solution: - **Layer 1b — planned:** flip `zero_init_guess` default to auto-detect (cold-vs-warm from `has_solution`). Benchmark; gate the free-surface chain-of-solves and the mover/adapt reset first. -- **Layer 3 — planned:** default the FMG velocity smoother to non-symmetry-safe - (gmres/sor) when `consistent_jacobian` is on. Benchmark vs the consistent-Newton tests. +- **Layer 3 — DONE:** the FMG velocity smoother defaults to `gmres`+`sor` with + `mg_levels_ksp_norm_type=none` (fixed-cost V-cycle), unconditionally — see + "Multigrid depth" below. - **Layer 2 — planned:** constitutive model advertises the homotopy (`supports_yield_homotopy` / `_yield_homotopy_control`) and `stokes.solve(homotopy=True, homotopy_options=...)` runs the continuation @@ -126,6 +127,39 @@ if stokes.has_solution: --- +## Multigrid depth — how to measure a smoother honestly + +**A two-level hierarchy is a coarse-grid correction, not a V-cycle.** Smoother +comparisons made on one are misleading: the gmres-over-richardson margin measured on +the Spiegelman notch is only 5 % at 3 levels but **25 % at 4** (ρ per V-cycle 0.746 → +0.560), because a deeper cycle applies the smoother on more coarse operators. Judge a +smoother at depth or not at all. + +To get depth without a monster problem, refine a **deliberately ultra-coarse NESTED +base**: `make_notch_mesh.py 1` (492 cells) + uniform `refinement=N` gives 3 levels / +7,872 cells at `N=2` and 4 levels / 31,488 at `N=3` — deeper *and* smaller than the old +2-level 38,580-cell setup. In MG you want the coarsest grid as coarse as it can be +before the problem breaks down. + +- **Never use a non-nested hierarchy** here — it does not give strong MG convergence + (maintainer ruling). Uniform refinement nests by construction. +- Accepted tradeoff: uniform refinement does **not** snap new boundary nodes back to + the analytic notch arcs (no CAD/EGADS model attached), so the corner geometry is + frozen at the coarse mesh's chords on every level. + +Measure with `fmg_contraction_probe.py` (ρ_MG per V-cycle; `<0.5` healthy, `0.8–0.95` +struggling, `≥0.98` hangs) or `smoother_depth_sweep.py` (pays the mesh build + viscous +seed once, sweeps smoothers in-process) in the Spiegelman study. + +**`solve_report` cannot see the smoother.** It records the *Newton* contraction; the +outer KSP is Eisenstat–Walker-collapsed to ~1 iteration/step, so the smoother's work +hides inside the velocity sub-block. Probe the `fieldsplit_velocity_` sub-KSP directly. + +**A smoother will not rescue small ξ.** At the hard corner the failure is operator +conditioning — the coarsest grid cannot represent the viscosity contrast — and at 4 +levels *every* smoother fails there (richardson outright, gmres with ρ>1). Use the δ/ξ +continuation to stay in the solvable region. + ## Gotchas - **`./uw build` → `amr-dev` env**; verify `uw.__file__` is the worktree site-packages. diff --git a/docs/developer/design/nonlinear-solver-homotopy-warmstart.md b/docs/developer/design/nonlinear-solver-homotopy-warmstart.md index 4d3c8eeb4..2ff2616e8 100644 --- a/docs/developer/design/nonlinear-solver-homotopy-warmstart.md +++ b/docs/developer/design/nonlinear-solver-homotopy-warmstart.md @@ -155,11 +155,37 @@ non-symmetry-safe smoother, `mg_levels_ksp_type = gmres` (with `mg_levels_pc_typ sor`). This is a consequence of `consistent_jacobian=True`, **not** of homotopy — anyone -using the consistent tangent with FMG hits it. The clean fix is therefore: **when the -consistent tangent is active, default the multigrid smoother to a non-symmetric-safe -one.** That removes the single worst trap for all consistent-Newton users, not just -homotopy. It is a core default change and must be benchmarked before it is made -automatic. +using the consistent tangent with FMG hits it. + +**LANDED, and the benchmark changed the shape of the fix.** The smoother is now +`gmres` + `sor` unconditionally in the FMG bundle, with `mg_levels_ksp_norm_type = +none` (a fixed-cost V-cycle: exactly `max_it` smoother iterations, no residual norm, +no early exit). Two findings drove that: + +- **The gmres margin grows with multigrid depth.** Measured on the Spiegelman notch + (Drucker–Prager, `η_bg=1e26`, `V=10`, `δ=0.01`) over a *nested* hierarchy built by + uniformly refining an ultra-coarse 492-cell base, per-V-cycle contraction ρ at + `ξ=0.01` and the same four smoother iterations: 3 levels — 0.722 richardson vs + 0.686 gmres (5 %); 4 levels — 0.746 vs **0.560** (25 %). A deeper cycle applies the + smoother on more coarse operators, each non-symmetric under the consistent tangent, + so smoother quality compounds. A measurement on a 2-level hierarchy shows almost no + effect and is **misleading** — a two-level cycle is a coarse-grid correction, not a + V-cycle. +- **Therefore no gate.** Since shallow hierarchies are not worth having (maintainer + ruling), the default is set for the deep case unconditionally — no level-count gate + and no `consistent_jacobian` gate. Four smoother iterations, not more: per unit work + gmres/4 (ρ^(1/4) = 0.87) beats gmres/8 (0.91). + +**What this does not fix.** At the hard small-`ξ` corner the inner solve fails under +every smoother tried — richardson outright, and gmres with **ρ > 1** (the V-cycle +diverges) at 4 levels, where the 3-level run still returned a finite ρ≈0.9. The +coarsest grid cannot represent the weak-zone/notch viscosity contrast, so geometric +interpolation cannot carry it: this is *operator conditioning*, and the lever for it is +the δ/ξ continuation (Layer 2), not the smoother. Do not expect a smoother to rescue +small ξ. + +Measured with `fmg_contraction_probe.py` / `smoother_depth_sweep.py` in the Spiegelman +hard-case study (ρ_MG = per-V-cycle contraction of the velocity block). ## The user-facing result @@ -194,8 +220,11 @@ The only knobs a user ever touches are the two deliberate opt-outs: force-fresh extension. Recipe + config-trap-list captured in the `nonlinear-solver` skill. 2. **Layer 1b — flip `zero_init_guess` to auto-detect.** The benchmarked default change. Gate the free-surface-chain and mover cases first (below). -3. **Layer 3 — non-symmetry-safe smoother default under the consistent tangent.** - Independent of homotopy; benchmark against the existing consistent-Newton tests. +3. **Layer 3 — non-symmetry-safe smoother default** — **LANDED** (`gmres`+`sor`+ + `norm_type=none` in the FMG bundle; test `test_1014`). Independent of homotopy. + Benchmarked as described in the Layer 3 section: applied unconditionally rather + than gated on the consistent tangent, because the gain scales with multigrid depth + and shallow hierarchies are not worth special-casing. 4. **Layer 2 — `supports_yield_homotopy` / `_yield_homotopy_control` model hook and the `solve(homotopy=True)` continuation.** The continuation logic already exists as a reference implementation (`underworld3.systems.yield_continuation`, the extracted From 6ddafd6fe8d7d2a496d1cbd97c7097378a8f5bf0 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sat, 25 Jul 2026 22:41:59 +1000 Subject: [PATCH 09/21] feat(solver): model-advertised yield homotopy and stokes.solve(homotopy=True) (Layer 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer 2 of the nonlinear-solver design. A hard Drucker-Prager problem does not converge from a cold start on the sharp yield surface; the route that works is a multi-solve continuation in the yield softness delta. This makes that the API rather than a hand-built driver. The constitutive model ADVERTISES the homotopy, exactly as it already advertises stress-history support: supports_yield_homotopy False on Constitutive_Model, True on the viscoplastic and (TI-)VEP models. _yield_homotopy_control() switches the model into its smooth delta-parameterised mode (softmin + power-mean) and returns a YieldHomotopyControl: a model-owned delta setter, the tangent to pair with it, and the delta atom. The model owns what the parameter MEANS: the isotropic models update delta as a constants[] atom (no recompile) while TI-VEP rebuilds, and the elastic models ask for the frozen/Picard tangent because the consistent yield tangent across the elastic stress-history block is indefinite and fails the linear solve outright. stokes.solve(homotopy=True, homotopy_options=...) runs the continuation and returns the march summary. Each delta-step re-enters solve() normally, so nothing recurses; a VEP model's timestep is forwarded to the inner solves. Refusing an unsupported model is explicit (there is no yield surface to sharpen). The march itself is now residual-guided: a step that converges well inside its iteration budget earns a bigger next step, a step that nearly exhausts it earns a gentler one, and a failed delta is reverted and retried more gently before the march settles. Measured on a small viscoplastic box it walks delta from 1.0 to 7.8e-4 in four solves from a COLD start. Cold-start safety: _yield_homotopy_control floors the strain-rate invariant with the vanishing constant, because tau_y/(2 edot_II) is 0/0 at v=0 and the first residual is otherwise NaN. Carries a TODO(BUG) — the hazard is not homotopy-specific, any cold solve of a viscoplastic model with a finite yield stress hits it, and the floor arguably belongs in the model. Verified: new test_1057 (advertisement, control contract, tangent per model, refusal, and an end-to-end march), plus yield-smoother, VEP stability, VE Stokes, has_solution and multigrid regressions (34 passed). Underworld development team with AI support from Claude Code --- src/underworld3/constitutive_models.py | 105 ++++++++++ .../cython/petsc_generic_snes_solvers.pyx | 61 +++++- src/underworld3/systems/solvers.py | 23 +++ src/underworld3/systems/yield_continuation.py | 192 ++++++++++++------ tests/test_1057_yield_homotopy_solve.py | 124 +++++++++++ 5 files changed, 438 insertions(+), 67 deletions(-) create mode 100644 tests/test_1057_yield_homotopy_solve.py diff --git a/src/underworld3/constitutive_models.py b/src/underworld3/constitutive_models.py index 5ad1d2d44..dd1dafd68 100644 --- a/src/underworld3/constitutive_models.py +++ b/src/underworld3/constitutive_models.py @@ -645,6 +645,18 @@ def requires_stress_history(self): """ return False + @property + def supports_yield_homotopy(self): + """Whether this model can be solved by a single-parameter yield homotopy. + + ``True`` on the yielding models, which carry a δ-parameterised soft-min + yield law that sharpens to the exact ``Min`` as δ→0 — see + :meth:`_yield_homotopy_control`. ``solver.solve(homotopy=True)`` refuses a + model that returns ``False`` (a purely viscous model has no yield surface to + sharpen, so there is nothing to march). + """ + return False + @property def stress_history_ddt_kwargs(self): """Extra kwargs passed to the auto-DDt creation when this model @@ -1069,6 +1081,69 @@ def _apply_floor(self, value, floor): return sympy.Max(value, floor) return uw.maths.smooth_max(value, floor, self._get_yield_softness() * floor) + # Tangent this model wants while the yield homotopy marches. Newton is right for + # a purely viscous-plastic yield; the elastic (VEP) subclasses override to the + # frozen/Picard tangent, because the consistent yield tangent taken across the + # elastic stress-history block makes the Jacobian indefinite and the linear + # solve fails outright (DIVERGED_LINEAR_SOLVE). + _yield_homotopy_tangent = True + + def _yield_homotopy_control(self): + """Put this model in its smooth (δ-parameterised) yield mode and describe + how to march it. + + The model owns what the homotopy *means* for it: which knob is the + continuation parameter, how to set it, and which tangent to pair it with. + The solver only marches the number. Called by + ``solver.solve(homotopy=True)``; see + :doc:`nonlinear-solver-homotopy-warmstart` (Layer 2). + + Selects the power-mean soft-min family, whose large-δ limit is the harmonic + mean — bounded by the background viscosity even as :math:`\\dot\\varepsilon + \\to 0`, so the first (cold) solve of the march is well posed and no separate + viscous pre-solve is needed. + + Returns + ------- + YieldHomotopyControl + ``set_delta`` (model-owned setter for δ), ``tangent`` (the + ``consistent_jacobian`` value to use), and ``delta`` (the ``constants[]`` + atom itself, for diagnostics). + """ + from underworld3.systems.yield_continuation import YieldHomotopyControl + + self.yield_mode = "softmin" + self.yield_smoother = "powermean" + + # Cold-start safety. The plastic viscosity is tau_y / (2 edot_II), so a cold + # v = 0 start makes it 0/0 and the first residual is NaN + # (DIVERGED_FNORM_NAN). The march is *designed* to start from a cold solve at + # large delta, so floor the strain-rate invariant with the vanishing constant + # (1e-18) — far below any physical rate, and it makes eta_yield merely huge + # rather than undefined, which the soft-min then discards in favour of the + # viscous branch. Applied once; the flag keeps a repeated call idempotent. + # TODO(BUG): this hazard is not specific to the homotopy — ANY cold + # solve() of a ViscoPlasticFlowModel with a finite yield_stress diverges + # with FNORM_NAN for the same reason. The floor arguably belongs in the + # model's own _strainrate_inv_II. Raised with the maintainer 2026-07-25. + if not getattr(self, "_yield_homotopy_regularised", False): + strainrate_inv = getattr(self, "_strainrate_inv_II", None) + if strainrate_inv is not None: + strainrate_inv.sym = strainrate_inv.sym + uw.maths.functions.vanishing + self._yield_homotopy_regularised = True + + def set_delta(value): + # Go through the property, not the atom: `yield_softness` updates BOTH + # the stored value and the constants[] atom, so a later + # _get_yield_softness() cannot silently reset δ to a stale number. + self.yield_softness = value + + return YieldHomotopyControl( + set_delta=set_delta, + tangent=self._yield_homotopy_tangent, + delta=self._get_yield_softness(), + ) + ## NOTE - retrofit VEP into here @@ -1251,6 +1326,12 @@ def viscosity(self): # Returns an expression that has a different description return self._plastic_eff_viscosity + @property + def supports_yield_homotopy(self): + """This model carries a δ-parameterised yield law — ``solve(homotopy=True)`` + can march it. See :meth:`_yield_homotopy_control`.""" + return True + @property def yield_mode(self): r"""How the viscous and plastic (yield) viscosities are combined. @@ -2210,6 +2291,18 @@ def requires_stress_history(self): """VEP models always require stress history tracking.""" return True + @property + def supports_yield_homotopy(self): + """This model carries a δ-parameterised yield law — ``solve(homotopy=True)`` + can march it. See :meth:`_yield_homotopy_control`.""" + return True + + # Picard, not Newton: the consistent yield tangent taken across the elastic + # stress-history block makes the Jacobian indefinite, and the linear solve fails + # outright (DIVERGED_LINEAR_SOLVE at 0 iterations). The frozen tangent is + # contractive, and with the δ-march it still converges to the exact yield surface. + _yield_homotopy_tangent = False + @property def plastic_fraction(self): """Fraction of strain rate that is plastic: 1 - η_vep / η_ve.""" @@ -3709,6 +3802,18 @@ def requires_stress_history(self): """Transverse isotropic VEP requires stress history tracking.""" return True + @property + def supports_yield_homotopy(self): + """This model carries a δ-parameterised yield law — ``solve(homotopy=True)`` + can march it. Note this model's ``yield_softness`` setter re-triggers a + rebuild, so each δ step costs a recompile (the isotropic models update δ as a + ``constants[]`` atom instead). See :meth:`_yield_homotopy_control`.""" + return True + + # Picard, not Newton — as for the isotropic VEP model, the consistent yield + # tangent over the elastic stress-history block is indefinite. + _yield_homotopy_tangent = False + @property def plastic_fraction(self): """Fraction of strain rate that is plastic.""" diff --git a/src/underworld3/cython/petsc_generic_snes_solvers.pyx b/src/underworld3/cython/petsc_generic_snes_solvers.pyx index e72b3e4cd..7e58d251d 100644 --- a/src/underworld3/cython/petsc_generic_snes_solvers.pyx +++ b/src/underworld3/cython/petsc_generic_snes_solvers.pyx @@ -843,6 +843,33 @@ class SolverBaseClass(uw_object): """ return self._has_solution + def _solve_yield_homotopy(self, homotopy_options=None, verbose=False, + solve_kwargs=None): + """Run ``solve(homotopy=True)``: a multi-solve δ-continuation on the yield law. + + The constitutive model advertises the homotopy (``supports_yield_homotopy``) + and describes it (``_yield_homotopy_control()``: how to set δ, and which + tangent to pair with it); the continuation driver marches δ from a large, + benign value down to the sharp yield surface, warm-starting each step from the + previous converged solution. See + :func:`~underworld3.systems.yield_continuation.yield_continuation` and + :doc:`nonlinear-solver-homotopy-warmstart` (Layer 2). + """ + cm = self.constitutive_model + if cm is None or not getattr(cm, "supports_yield_homotopy", False): + raise TypeError( + f"solve(homotopy=True) needs a constitutive model with a yield law to " + f"sharpen, but {type(cm).__name__ if cm is not None else None} does not " + f"advertise supports_yield_homotopy. Use a viscoplastic (or VEP) model, " + f"or solve without homotopy." + ) + from underworld3.systems.yield_continuation import yield_continuation + # The march's own options win: an explicit homotopy_options["verbose"] is a + # deliberate choice about the march, distinct from the solve's verbosity. + options = dict(homotopy_options or {}) + options.setdefault("verbose", verbose) + return yield_continuation(self, solve_kwargs=solve_kwargs, **options) + def _record_convergence_status(self, converged=None): """Refresh :attr:`has_solution` from the just-completed solve. @@ -8306,7 +8333,9 @@ class SNES_Stokes_SaddlePt(SolverBaseClass): debug_name=None, _force_setup: bool =False, time=None, - divergence_retries: int = 0, ): + divergence_retries: int = 0, + homotopy: bool = False, + homotopy_options: dict = None, ): """ Solve the Stokes system for velocity and pressure. @@ -8342,11 +8371,28 @@ class SNES_Stokes_SaddlePt(SolverBaseClass): If the final SNES solve reports DIVERGED, re-call it with warm start up to this many times. A single retry rescues most VEP yield-surface kink divergences. 0 preserves legacy behaviour. + homotopy : bool, default=False + Solve a yielding (viscoplastic) model by a **yield homotopy** instead of + a single solve: the constitutive model is put in its smooth, + δ-parameterised yield mode and δ is marched down to the sharp yield + surface as a sequence of warm-started solves. This is the robust route + for a hard Drucker-Prager problem, which does not converge from a cold + start on the sharp surface. Requires a model with + ``supports_yield_homotopy`` (raises otherwise). Returns the march + summary rather than ``None``. + homotopy_options : dict, optional + March settings passed to + :func:`~underworld3.systems.yield_continuation.yield_continuation` — + ``delta0``, ``down``, ``dmin``, ``entry_maxit``, ``step_maxit``, + ``retries``. All are defaulted; tuning them is optional. Returns ------- - None - Solution stored in ``self.u`` (velocity) and ``self.p`` (pressure). + None or dict + ``None`` normally — the solution is stored in ``self.u`` (velocity) and + ``self.p`` (pressure). With ``homotopy=True``, the march summary + (``settled_delta``, ``reason``, ``steps``, ``reached_dmin``, + ``converged``). Examples -------- @@ -8363,6 +8409,10 @@ class SNES_Stokes_SaddlePt(SolverBaseClass): ... # Update boundary conditions, material properties... ... stokes.solve(zero_init_guess=False) + >>> # Hard viscoplastic (Drucker-Prager) yield: march the yield homotopy + >>> report = stokes.solve(homotopy=True) + >>> report["settled_delta"] # smallest yield softness reached + Notes ----- This is a **collective operation** - all MPI ranks must call it. @@ -8378,6 +8428,11 @@ class SNES_Stokes_SaddlePt(SolverBaseClass): constitutive_model : Viscosity and stress definitions. """ + if homotopy: + # The march runs a SEQUENCE of ordinary solves at successively sharper + # yield surfaces; each one re-enters this method with homotopy=False. + return self._solve_yield_homotopy(homotopy_options, verbose=verbose) + if _force_setup: self.is_setup = False elif not self.constitutive_model._solver_is_setup: diff --git a/src/underworld3/systems/solvers.py b/src/underworld3/systems/solvers.py index f91f8ea1b..8d4d7f6a9 100644 --- a/src/underworld3/systems/solvers.py +++ b/src/underworld3/systems/solvers.py @@ -1419,6 +1419,8 @@ def solve( order=None, picard: int = 0, divergence_retries: int = 0, + homotopy: bool = False, + homotopy_options: dict = None, ): """Solve the Stokes system, with optional viscoelastic stress history. @@ -1453,8 +1455,29 @@ def solve( kinks) to step off a bad Newton iterate. ``0`` preserves legacy behaviour (divergence is terminal). Typical useful value is 1. Only applies in the VE/VEP branch (``DFDt is not None``). + homotopy : bool, default=False + Solve a yielding model by marching the **yield homotopy** — the model's + δ-parameterised yield law is sharpened toward the exact ``Min`` over a + sequence of warm-started solves — instead of attempting the sharp surface + in one go. Requires ``constitutive_model.supports_yield_homotopy``. + Returns the march summary instead of ``None``. + homotopy_options : dict, optional + March settings for + :func:`~underworld3.systems.yield_continuation.yield_continuation` + (``delta0``, ``down``, ``dmin``, ``entry_maxit``, ``step_maxit``, + ``retries``). All defaulted. """ + if homotopy: + # Each δ-step re-enters this method with homotopy=False. A VEP model's + # solves need the timestep, so forward it to every inner solve. + inner = {} + if timestep is not None: + inner["timestep"] = timestep + return self._solve_yield_homotopy( + homotopy_options, verbose=verbose, solve_kwargs=inner + ) + has_stress_history = self.Unknowns.DFDt is not None if has_stress_history: diff --git a/src/underworld3/systems/yield_continuation.py b/src/underworld3/systems/yield_continuation.py index 6cc74b2e0..f800f54f7 100644 --- a/src/underworld3/systems/yield_continuation.py +++ b/src/underworld3/systems/yield_continuation.py @@ -1,97 +1,131 @@ r"""Multi-solve δ-continuation for hard viscoplastic (Drucker–Prager) yield. A single solve straight onto a sharp yield surface (soft-min δ → 0) stalls or -diverges from a viscous guess. The robust, trusted route is a *continuation*: hold -δ **constant** for a full nonlinear solve to tolerance, warm-start the next -(smaller) δ from that converged state, and march δ down to the sharp surface. - -This module ships that march as a reusable driver — the production form of the -hand-tuned harness used in the Spiegelman notch studies. It orchestrates solves; it -does not change the solver's tangent, preconditioner, or line-search options (the -caller configures those — see :func:`yield_continuation` notes). +diverges from a cold start. The robust, trusted route is a *continuation*: hold δ +**constant** for a full nonlinear solve to tolerance, warm-start the next (smaller) +δ from that converged state, and march δ down toward the sharp surface. + +This module ships that march. It is what ``solver.solve(homotopy=True)`` runs; the +constitutive model says what δ *is* for it (:class:`YieldHomotopyControl`, built by +``constitutive_model._yield_homotopy_control()``) and this driver marches the number. + +Do **not** try to ramp δ inside a single SNES solve. That is proven not to work: the +continuation only sharpens δ from a *converged*, well-conditioned iterate, whereas an +in-solve ramp sharpens it mid-solve where the consistent-Newton Jacobian on a +sharpening yield surface is ill-conditioned and the linear solve fails. """ -import sympy +from dataclasses import dataclass +from typing import Any, Callable, Optional import underworld3 as uw +@dataclass(frozen=True) +class YieldHomotopyControl: + """How to march one constitutive model's yield homotopy. + + Built by ``constitutive_model._yield_homotopy_control()``, which also switches the + model into its smooth (δ-parameterised) yield mode. The model owns the meaning of + the parameter; the solver only moves it. + + Attributes + ---------- + set_delta + Sets the yield softness δ. Model-owned, because *how* δ is applied differs: + the isotropic models update a ``constants[]`` atom (no recompile), while the + transverse-isotropic VEP model rebuilds. + tangent + The ``consistent_jacobian`` value to solve with — Newton for a viscous-plastic + yield, the frozen/Picard tangent for the elastic (VEP) models. + delta + The δ ``constants[]`` atom itself, for diagnostics. + """ + + set_delta: Callable[[float], None] + tangent: Any + delta: Optional[Any] = None + + def yield_continuation( solver, - delta=None, + control=None, delta0=1.0, down=0.5, dmin=1.0e-3, entry_maxit=30, step_maxit=10, + retries=2, + solve_kwargs=None, verbose=True, ): - r"""March the yield soft-min regularisation δ down to the sharp surface as a - sequence of constant-δ solves, each warm-started from the previous. + r"""March the yield regularisation δ down to the sharp surface as a sequence of + constant-δ solves, each warm-started from the previous. Each δ is held **constant** for one full nonlinear solve to tolerance; the - converged :math:`(v, p)` warm-starts the next (smaller) δ. δ is a - ``constants[]`` atom, so the operators are built once at ``delta0`` and every - later step is a recompile-free ``PetscDSSetConstants`` update. The march halves - δ (``× down``) while solves keep converging and **settles** at the smallest - feasible δ — the closest automatic approach to hard-Min. A δ that fails to - converge is reverted, and the previous δ is the reported answer. + converged solution warm-starts the next (smaller) δ. The march **settles** at the + smallest feasible δ — the closest automatic approach to the hard ``Min``. A δ that + fails to converge is reverted and retried with a gentler step; when the retries are + used up, the last converged δ is the answer. + + The step size is **residual-guided**: a δ-step that converges in very few Newton + iterations means there is slack, so the next step is bigger; a step that uses most + of its iteration budget means the march is close to the feasible edge, so the next + step is gentler. + + Starting δ is deliberately **large**, where the power-mean soft-min is the harmonic + mean and is bounded by the background viscosity even as :math:`\dot\varepsilon \to + 0`. That is what makes the first (cold) solve well posed, and why no separate + viscous pre-solve is needed. Parameters ---------- solver : - A configured Stokes/SNES solver whose viscosity uses a δ-parameterised - soft-min yield law, already warm-started with a viscous (no-yield) presolve. - delta : UWexpression, optional - The δ ``constants[]`` atom to march. Defaults to the constitutive model's - yield-softness atom, ``solver.constitutive_model._get_yield_softness()``. + A configured Stokes/SNES solver whose constitutive model supports the homotopy. + control : YieldHomotopyControl, optional + How to set δ and which tangent to use. Defaults to + ``solver.constitutive_model._yield_homotopy_control()``. delta0 : float - Starting (smooth) δ. Large δ is as cheap to solve as small, so be generous. + Starting (smooth) δ. Large δ is about as cheap to solve as small, so be generous. down : float - Multiplicative step, :math:`0 < \mathrm{down} < 1` (δ ← δ·down per success). + Nominal multiplicative step, :math:`0 < down < 1` (δ ← δ·down per success). + Adapted during the march as described above. dmin : float - Stop once δ reaches this floor (hard-Min effectively reached). + Stop once δ reaches this floor (the sharp surface is effectively reached). entry_maxit : int - Nonlinear iteration budget for the first solve (from the viscous guess). + Nonlinear iteration budget for the first solve. step_maxit : int - Budget for each warm step. A feasible warm step converges in a few - iterations; a tight budget lets a too-hard step abort cheaply. + Budget for each warm step. A feasible warm step converges in a few iterations; + a tight budget lets a too-hard step abort cheaply. + retries : int + How many times to retry a failed δ with a gentler step before settling. + solve_kwargs : dict, optional + Extra arguments forwarded to every inner ``solver.solve()`` — notably + ``timestep`` for a visco-elastic-plastic model, whose solves need one. verbose : bool - Print per-δ convergence (rank-safe). + Report each δ (rank-safe). Returns ------- dict - ``settled_delta`` (smallest δ that converged, or ``None`` if the first solve - failed), ``reason`` (final SNES converged reason), ``steps`` (number of - δ-solves), ``reached_dmin`` (whether the march made it to the floor). - - Notes - ----- - Configure the solver **before** calling — this driver changes none of it: - - - the constitutive model in a δ-parameterised soft-min mode - (``yield_mode="softmin"``); - - the consistent-Newton tangent (``solver.consistent_jacobian = True``); - - for the stiff, non-symmetric consistent-Newton velocity operator, a - non-symmetry-safe FMG smoother — - ``solver.petsc_options["fieldsplit_velocity_mg_levels_ksp_type"] = "gmres"`` - (with ``mg_levels_pc_type = "sor"``). The default Chebyshev/Richardson - smoothers stall on it, turning a solve into an effectively unbounded grind; - bound the outer Krylov (``ksp_max_it`` ~ 80) so a hostile step fails fast. + ``settled_delta`` (smallest δ that converged, or ``None`` if even the first + solve failed), ``reason`` (final SNES converged reason), ``steps`` (number of + δ-solves attempted), ``reached_dmin``, and ``converged``. """ - if delta is None: + if not (0.0 < down < 1.0): + raise ValueError(f"down must satisfy 0 < down < 1, got {down}") + + if control is None: cm = getattr(solver, "constitutive_model", None) - if cm is None or not hasattr(cm, "_get_yield_softness"): + if cm is None or not getattr(cm, "supports_yield_homotopy", False): raise TypeError( - "yield_continuation: no δ soft-min atom found. Put the constitutive " - "model in a δ-parameterised mode (yield_mode='softmin') or pass an " - "explicit `delta` atom." + "yield_continuation needs a constitutive model that supports the yield " + "homotopy (supports_yield_homotopy). Got " + f"{type(cm).__name__ if cm is not None else None}." ) - delta = cm._get_yield_softness() + control = cm._yield_homotopy_control() - if not (0.0 < down < 1.0): - raise ValueError(f"down must satisfy 0 < down < 1, got {down}") + solver.consistent_jacobian = control.tangent u, p = solver.Unknowns.u, solver.Unknowns.p # Warm-state snapshot: a raw copy of the (already consistent) solution values, @@ -100,22 +134,31 @@ def yield_continuation( saved_maxit = solver.petsc_options.getInt("snes_max_it", 50) d = float(delta0) + step = float(down) settled = None reason = 0 steps = 0 + failures = 0 reached_dmin = False first = True + while True: - delta.sym = sympy.Float(d) + control.set_delta(d) if first: - solver.is_setup = False # build the operators once, at delta0 + solver.is_setup = False # build the operators once, at delta0 else: - solver._update_constants() # recompile-free δ update + solver._update_constants() # recompile-free δ update solver.petsc_options["snes_max_it"] = entry_maxit if first else step_maxit - solver.solve(zero_init_guess=False) + + # Cold only if there is genuinely nothing to warm-start from; Layer 1's + # automatic Picard step handles that first solve at the large, benign δ. + solver.solve(zero_init_guess=not solver.has_solution, + **dict(solve_kwargs or {})) reason = int(solver.snes.getConvergedReason()) nit = int(solver.snes.getIterationNumber()) + budget = entry_maxit if first else step_maxit steps += 1 + first = False if reason > 0: settled = d @@ -126,16 +169,36 @@ def yield_continuation( if d <= dmin: reached_dmin = True break - d *= down - first = False + # Residual-guided: plenty of slack ⇒ take a bigger bite next time; near the + # iteration budget ⇒ ease off. Clamped so the march can neither stall (step + # → 1) nor leap past the feasible edge in one go. + if nit <= max(2, budget // 5): + step = max(step * step, 0.05) + elif nit >= 0.8 * budget: + step = min(step ** 0.5, 0.95) + d *= step else: with uw.synchronised_array_update("yield_continuation revert"): u.data[...] = u_good p.data[...] = p_good + failures += 1 + if settled is None or failures > retries: + if verbose: + uw.pprint(0, f" [yield-continuation] δ={d:<11g} its={nit:3d} → failed " + f"(reason={reason}); settling at δ={settled}") + break + # Retry from the last good δ with a gentler step. + step = min(step ** 0.5, 0.95) + d = settled * step if verbose: - uw.pprint(0, f" [yield-continuation] δ={d:<11g} its={nit:3d} → failed " - f"(reason={reason}); settling at δ={settled}") - break + uw.pprint(0, f" [yield-continuation] δ={d / step:<11g} its={nit:3d} → failed " + f"(reason={reason}); retrying at δ={d:g}") + + # Leave the model holding the δ that actually converged, and the solver holding + # its solution, so the caller can use the result directly. + if settled is not None and settled != d: + control.set_delta(settled) + solver._update_constants() solver.petsc_options["snes_max_it"] = saved_maxit return { @@ -143,4 +206,5 @@ def yield_continuation( "reason": reason, "steps": steps, "reached_dmin": reached_dmin, + "converged": settled is not None, } diff --git a/tests/test_1057_yield_homotopy_solve.py b/tests/test_1057_yield_homotopy_solve.py new file mode 100644 index 000000000..1d87fde6f --- /dev/null +++ b/tests/test_1057_yield_homotopy_solve.py @@ -0,0 +1,124 @@ +"""Layer 2 of the nonlinear-solver design: the model-advertised yield homotopy and +``stokes.solve(homotopy=True)``. + +Contract under test (design: +``docs/developer/design/nonlinear-solver-homotopy-warmstart.md``, Layer 2): + + * A constitutive model **advertises** whether it has a yield law to sharpen + (``supports_yield_homotopy``) — true for the viscoplastic / VEP models, false for + a plain viscous one. + * ``_yield_homotopy_control()`` puts the model in its smooth δ-parameterised mode + and reports how to march it: a model-owned δ setter, and the tangent to pair with + it (Newton for viscous-plastic yield, the frozen/Picard tangent for elastic VEP, + whose consistent yield tangent is indefinite). + * ``solve(homotopy=True)`` runs the multi-solve continuation and returns the march + summary; on a model with no yield law it raises a clear error rather than + silently solving something else. + +The hard-case convergence behaviour (Spiegelman notch at η=1e26) is validated +separately in the study; these are cheap serial checks of the API contract. +""" + +import pytest +import sympy + +import underworld3 as uw + + +pytestmark = [pytest.mark.level_1, pytest.mark.tier_a] + + +def _viscoplastic_stokes(cellSize=0.4): + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=cellSize + ) + v = uw.discretisation.MeshVariable("Vh", mesh, mesh.dim, degree=2) + p = uw.discretisation.MeshVariable("Ph", mesh, 1, degree=1, continuous=True) + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + stokes.constitutive_model = uw.constitutive_models.ViscoPlasticFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = 1.0 + stokes.constitutive_model.Parameters.yield_stress = 5.0 + stokes.bodyforce = sympy.Matrix([0.0, -1.0]) + stokes.add_essential_bc((0.0, 0.0), "Bottom") + stokes.add_essential_bc((0.0, None), "Left") + stokes.add_essential_bc((0.0, None), "Right") + stokes.tolerance = 1.0e-5 + return mesh, stokes + + +def test_plain_viscous_model_does_not_advertise_homotopy(): + cm = uw.constitutive_models.ViscousFlowModel + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.5 + ) + v = uw.discretisation.MeshVariable("Vv", mesh, mesh.dim, degree=2) + p = uw.discretisation.MeshVariable("Pv", mesh, 1, degree=1, continuous=True) + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + stokes.constitutive_model = cm + assert stokes.constitutive_model.supports_yield_homotopy is False + + +def test_viscoplastic_model_advertises_homotopy(): + _, stokes = _viscoplastic_stokes() + assert stokes.constitutive_model.supports_yield_homotopy is True + + +def test_homotopy_control_switches_model_to_smooth_mode(): + """The control must leave the model in the δ-parameterised power-mean mode and + hand back a working, model-owned δ setter.""" + _, stokes = _viscoplastic_stokes() + cm = stokes.constitutive_model + control = cm._yield_homotopy_control() + + assert cm.yield_mode == "softmin" + assert cm.yield_smoother == "powermean" + # Newton tangent for a non-elastic viscoplastic yield. + assert control.tangent is True + + # The setter must move BOTH the stored value and the constants[] atom, so a later + # _get_yield_softness() cannot reset δ to a stale number. + control.set_delta(0.25) + assert cm.yield_softness == pytest.approx(0.25) + assert float(cm._get_yield_softness().sym) == pytest.approx(0.25) + + +def test_vep_model_requests_the_picard_tangent(): + """The consistent yield tangent over the elastic stress-history block is + indefinite, so the elastic models must ask for the frozen tangent.""" + cm = uw.constitutive_models.ViscoElasticPlasticFlowModel + assert cm._yield_homotopy_tangent is False + assert uw.constitutive_models.ViscoPlasticFlowModel._yield_homotopy_tangent is True + + +def test_solve_homotopy_on_unsupported_model_raises(): + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.5 + ) + v = uw.discretisation.MeshVariable("Vr", mesh, mesh.dim, degree=2) + p = uw.discretisation.MeshVariable("Pr", mesh, 1, degree=1, continuous=True) + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel + stokes.constitutive_model.Parameters.shear_viscosity_0 = 1.0 + stokes.bodyforce = sympy.Matrix([0.0, -1.0]) + stokes.add_essential_bc((0.0, 0.0), "Bottom") + + with pytest.raises(TypeError, match="supports_yield_homotopy"): + stokes.solve(homotopy=True) + + +def test_solve_homotopy_marches_and_reports(): + """A viscoplastic solve driven by the homotopy converges and reports the march.""" + _, stokes = _viscoplastic_stokes() + report = stokes.solve(homotopy=True, + homotopy_options=dict(delta0=1.0, dmin=0.05, verbose=False)) + + assert report["converged"] is True + assert report["steps"] >= 1 + # Settled at (or below) the starting δ, and never below the requested floor. + assert report["settled_delta"] is not None + assert report["settled_delta"] <= 1.0 + assert stokes.has_solution is True + # The model is left holding the δ that actually converged. + assert stokes.constitutive_model.yield_softness == pytest.approx( + report["settled_delta"] + ) From 7a9750535f16928f4affd6362fe76fb9070e4364 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sat, 25 Jul 2026 22:43:07 +1000 Subject: [PATCH 10/21] =?UTF-8?q?docs(solver):=20Layer=202=20landed=20?= =?UTF-8?q?=E2=80=94=20one-call=20homotopy,=20and=20the=20cold-start=20NaN?= =?UTF-8?q?=20correction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record what Layer 2 actually became: solve(homotopy=True) is the entry point, the model-owned delta setter is what keeps delta from being reset by a later _get_yield_softness(), the march reverts and retries a failed delta before settling, and a VEP model's timestep is forwarded to the inner solves. Correct a claim in the design and in the skill's trap list: starting delta LARGE does not, on its own, make a cold plastic start safe. The singularity is tau_y/(2 edot_II) inside the plastic viscosity, which is 0/0 at v=0 and NaNs before the soft-min ever combines it with the viscous branch. The strain-rate invariant has to be floored. Underworld development team with AI support from Claude Code --- .claude/skills/nonlinear-solver/SKILL.md | 34 +++++++++++-------- .../nonlinear-solver-homotopy-warmstart.md | 17 ++++++++-- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/.claude/skills/nonlinear-solver/SKILL.md b/.claude/skills/nonlinear-solver/SKILL.md index 84f51bf08..2d116a0e2 100644 --- a/.claude/skills/nonlinear-solver/SKILL.md +++ b/.claude/skills/nonlinear-solver/SKILL.md @@ -29,20 +29,26 @@ Yield-law maths, tangent-per-model, quadratic-convergence check: `plasticity-sol 2. **Multi-solve δ-continuation** (NOT an in-solve ramp). Hold δ **constant** for a full nonlinear solve to tolerance; warm-start the next, smaller δ from that converged state; march δ down to the sharp surface. δ is a `constants[]` atom, - so each step is a recompile-free `PetscDSSetConstants` update. Reference driver: + so each step is a recompile-free `PetscDSSetConstants` update. + + **This is now one call** — the model advertises the homotopy and `solve()` marches it: ```python - from underworld3.systems import yield_continuation - cm.yield_mode = "softmin"; cm.yield_smoother = "powermean" - stokes.consistent_jacobian = True # per plasticity-solvers - # non-symmetry-safe smoother (see trap list): - stokes.petsc_options["fieldsplit_velocity_mg_levels_ksp_type"] = "gmres" - stokes.petsc_options["fieldsplit_velocity_mg_levels_pc_type"] = "sor" - # a viscous (no-yield) presolve gives the warm start the driver expects - result = yield_continuation(stokes, delta0=1.0, down=0.5, dmin=1e-3) - # result["settled_delta"] is the smallest δ that converged + stokes.constitutive_model = uw.constitutive_models.ViscoPlasticFlowModel + cm.Parameters.shear_viscosity_0 = ... + cm.Parameters.yield_stress = ... # a plain pressure-dependent yield + report = stokes.solve(homotopy=True) # smooth mode, tangent, march: automatic + report["settled_delta"] # smallest δ that converged ``` + `solve(homotopy=True)` sets the smooth mode (softmin + power-mean), picks the + tangent the model asks for (Newton for DP, Picard for elastic VEP), floors `ε̇_II` + so a cold start does not NaN, and runs a residual-guided march that accelerates on + easy steps and reverts + retries a failed δ more gently. Tune with + `homotopy_options=dict(delta0=…, down=…, dmin=…, entry_maxit=…, step_maxit=…)`; + the driver is also callable directly as + `underworld3.systems.yield_continuation`. + 3. **Consistent-Newton tangent** for non-elastic DP (`consistent_jacobian=True`); **Picard** for elastic VEP — see `plasticity-solvers` for the per-model table. @@ -78,7 +84,7 @@ case felt like whack-a-mole. Check them first. |---|---|---| | Consistent Newton makes the velocity block **non-symmetric**; a Chebyshev/Richardson MG smoother assumes SPD | smoother diverges / stalls → `DIVERGED_LINEAR_SOLVE` or an endless grind | **Now the default** — the FMG bundle ships `mg_levels_ksp_type=gmres` + `pc_type=sor` + `norm_type=none`. Only an issue if you override it, or on GAMG (which uses PETSc's chebyshev default) | | `preconditioner="fmg"` (vs explicit `pc_type=mg` + manual mg opts) | outer KSP "converges" in **1 iteration** → no real Newton correction → stall → `DIVERGED_LINE_SEARCH` | use explicit `pc_type=mg` with the smoother opts above; bound the outer KSP (`ksp_max_it`~80) so a hostile step fails fast | -| Cold plastic start `v=0` | `ε̇=0 → τ_y/0 → NaN` on iteration 0 | start δ **large** (power-mean → harmonic, bounded by `η_bg`) + one Picard step | +| Cold plastic start `v=0` | `ε̇=0 → τ_y/(2·0) → NaN`, `DIVERGED_FNORM_NAN` at iteration 0 | floor `ε̇_II` (`+ uw.maths.functions.vanishing`). `solve(homotopy=True)` now does this for you. **Large δ alone does NOT save it** — the singularity is in `η_pl`, before the soft-min sees it | | LU velocity block with all-Dirichlet-ish BC | pressure nullspace singular | attach the Stokes nullspace / avoid a bare LU there | | Hand-rolled `snes_monitor` to "see what's happening" | you read residuals but miss the tell | use `solve_with_diagnostics` / `get_snes_diagnostics` instead (below) | @@ -120,10 +126,10 @@ if stokes.has_solution: - **Layer 3 — DONE:** the FMG velocity smoother defaults to `gmres`+`sor` with `mg_levels_ksp_norm_type=none` (fixed-cost V-cycle), unconditionally — see "Multigrid depth" below. -- **Layer 2 — planned:** constitutive model advertises the homotopy +- **Layer 2 — DONE:** the model advertises the homotopy (`supports_yield_homotopy` / `_yield_homotopy_control`) and - `stokes.solve(homotopy=True, homotopy_options=...)` runs the continuation - (folding in `yield_continuation`) with residual-guided auto-descent. + `stokes.solve(homotopy=True, homotopy_options=...)` runs the residual-guided + continuation, returning the march summary. --- diff --git a/docs/developer/design/nonlinear-solver-homotopy-warmstart.md b/docs/developer/design/nonlinear-solver-homotopy-warmstart.md index 2ff2616e8..ca422d940 100644 --- a/docs/developer/design/nonlinear-solver-homotopy-warmstart.md +++ b/docs/developer/design/nonlinear-solver-homotopy-warmstart.md @@ -226,9 +226,22 @@ The only knobs a user ever touches are the two deliberate opt-outs: force-fresh than gated on the consistent tangent, because the gain scales with multigrid depth and shallow hierarchies are not worth special-casing. 4. **Layer 2 — `supports_yield_homotopy` / `_yield_homotopy_control` model hook and - the `solve(homotopy=True)` continuation.** The continuation logic already exists as + the `solve(homotopy=True)` continuation** — **LANDED** (test `test_1057`). As built: + the control is a `YieldHomotopyControl` carrying a **model-owned δ setter** (the + isotropic models update the `constants[]` atom, TI-VEP rebuilds — and going through + the model's own `yield_softness` property is what stops a later + `_get_yield_softness()` resetting δ to a stale value), the recommended tangent, and + the δ atom. The march is residual-guided as designed, and additionally *reverts and + retries a failed δ more gently* before settling. A VEP model's `timestep` is + forwarded to the inner solves. **Cold-start caveat found in implementation:** + `τ_y/(2 ε̇_II)` is `0/0` at `v=0`, so the first residual is NaN — large δ alone does + *not* save it, because the singularity is in `η_pl` before the soft-min ever sees + it. `_yield_homotopy_control` therefore floors `ε̇_II` with the `vanishing` + constant. That hazard is **not** homotopy-specific (any cold viscoplastic solve with + a finite yield stress hits it) and is flagged `TODO(BUG)` for a decision on moving + the floor into the model itself. The continuation logic existed as a reference implementation (`underworld3.systems.yield_continuation`, the extracted - form of `convergence.py::run_continuation`); fold it in, driven by the model hook + form of `convergence.py::run_continuation`); it is folded in, driven by the model hook and Layer 1's warm-start. ## Open verification points / risks From 39572efa42816739ea9d0e4b2cfda7e3f4ef4d30 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sun, 26 Jul 2026 10:13:23 +1000 Subject: [PATCH 11/21] fix(constitutive): power-mean yield NaN at zero strain rate (rigid/unyielded points) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cold (v=0) viscoplastic solve died with DIVERGED_FNORM_NAN in yield_smoother= "powermean", while the hard-Min and sqrt soft-min forms converged. The cause is not a division by zero in the model: at edot_II = 0 the plastic viscosity tau_y/(2 edot_II) is +inf, and infinity propagates CORRECTLY through Min and through the sqrt form to give the viscous branch. It is specific to the power-mean algebra, which computed its harmonic-mean prefactor as N = eta_ve * eta_pl / (eta_ve + eta_pl) -> inf/inf = NaN Written instead as the algebraically identical N = eta_ve / (1 + f), f = eta_ve/eta_pl the infinite-eta_pl limit is finite and correct (N -> eta_ve). No strain-rate floor, no change to any converged result — the yield-smoother accuracy tests are unchanged. This is NOT only a cold-start concern: eta_pl is infinite at every rigid (unyielded) point, which is a physically real state in a viscoplastic flow, so the old form could poison a converged solve wherever a plug formed. A cold start is just the case where every point is unyielded at once. Supersedes the strain-rate floor added with the Layer 2 homotopy control, which was compensating for this bug rather than fixing it; that floor and its TODO(BUG) are removed. Regression: test_1057 parametrises a cold viscoplastic solve over all three yield modes; the powermean case failed (reason -4) before this fix. Underworld development team with AI support from Claude Code --- src/underworld3/constitutive_models.py | 29 ++++++++++-------------- tests/test_1057_yield_homotopy_solve.py | 30 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 17 deletions(-) diff --git a/src/underworld3/constitutive_models.py b/src/underworld3/constitutive_models.py index dd1dafd68..e7d947b58 100644 --- a/src/underworld3/constitutive_models.py +++ b/src/underworld3/constitutive_models.py @@ -1009,7 +1009,13 @@ def _combine_yield(self, eta_ve, eta_pl): s = 1 / (delta + sympy.Float(0.001)) a = 1 + f b = 1 + 1 / f - N = eta_ve * eta_pl / (eta_ve + eta_pl) + # Harmonic mean written as eta_ve/(1+f), NOT eta_ve*eta_pl/(eta_ve+eta_pl). + # The two are algebraically identical, but the product-over-sum form + # evaluates to inf/inf = NaN when eta_pl is infinite — which is exactly + # what a rigid (unyielded) point gives, since eta_pl = tau_y/(2 edot_II) + # and edot_II = 0 there. That includes every point of a cold v=0 start. + # In this form f -> 0 and N -> eta_ve, the correct viscous limit. + N = eta_ve / a return N * (a ** (-s) + b ** (-s)) ** (-1 / s) # default "sqrt" soft-min: η_ve / g(f), g(0)=1, g ≈ max(1, f), exact Min at δ=0. @@ -1115,22 +1121,11 @@ def _yield_homotopy_control(self): self.yield_mode = "softmin" self.yield_smoother = "powermean" - # Cold-start safety. The plastic viscosity is tau_y / (2 edot_II), so a cold - # v = 0 start makes it 0/0 and the first residual is NaN - # (DIVERGED_FNORM_NAN). The march is *designed* to start from a cold solve at - # large delta, so floor the strain-rate invariant with the vanishing constant - # (1e-18) — far below any physical rate, and it makes eta_yield merely huge - # rather than undefined, which the soft-min then discards in favour of the - # viscous branch. Applied once; the flag keeps a repeated call idempotent. - # TODO(BUG): this hazard is not specific to the homotopy — ANY cold - # solve() of a ViscoPlasticFlowModel with a finite yield_stress diverges - # with FNORM_NAN for the same reason. The floor arguably belongs in the - # model's own _strainrate_inv_II. Raised with the maintainer 2026-07-25. - if not getattr(self, "_yield_homotopy_regularised", False): - strainrate_inv = getattr(self, "_strainrate_inv_II", None) - if strainrate_inv is not None: - strainrate_inv.sym = strainrate_inv.sym + uw.maths.functions.vanishing - self._yield_homotopy_regularised = True + # No strain-rate floor is needed for the cold (v = 0) start the march begins + # from: eta_pl = tau_y/(2 edot_II) is +inf there, which the soft-min carries + # correctly to the viscous branch. See the harmonic-mean note in + # _combine_yield for the one form that must be written carefully to keep it + # so. def set_delta(value): # Go through the property, not the atom: `yield_softness` updates BOTH diff --git a/tests/test_1057_yield_homotopy_solve.py b/tests/test_1057_yield_homotopy_solve.py index 1d87fde6f..6a07a3a7f 100644 --- a/tests/test_1057_yield_homotopy_solve.py +++ b/tests/test_1057_yield_homotopy_solve.py @@ -106,6 +106,36 @@ def test_solve_homotopy_on_unsupported_model_raises(): stokes.solve(homotopy=True) +@pytest.mark.parametrize( + "mode,smoother", + [("min", None), ("softmin", "sqrt"), ("softmin", "powermean")], +) +def test_cold_viscoplastic_solve_survives_zero_strain_rate(mode, smoother): + """A COLD (v=0) viscoplastic solve must not produce NaN in ANY yield mode. + + Regression: at v=0 the strain-rate invariant is 0, so the plastic viscosity + tau_y/(2 edot_II) is +inf. That is fine — the soft-min should carry it to the + viscous branch. But the power-mean form computed its harmonic mean as + eta_ve*eta_pl/(eta_ve+eta_pl), which is inf/inf = NaN, so a cold power-mean solve + died with DIVERGED_FNORM_NAN while `min` and `sqrt` converged. Rewriting that + mean as eta_ve/(1+f) — algebraically identical — keeps the infinite-eta_pl limit + finite. The same singularity occurs at any rigid (unyielded) point, not only on a + cold start. + """ + _, stokes = _viscoplastic_stokes(cellSize=0.5) + cm = stokes.constitutive_model + cm.yield_mode = mode + if smoother is not None: + cm.yield_smoother = smoother + + stokes.solve() # cold: zero_init_guess defaults True + reason = int(stokes.snes.getConvergedReason()) + assert reason > 0, ( + f"cold viscoplastic solve failed for yield_mode={mode!r} " + f"smoother={smoother!r} (reason={reason}; -4 is FNORM_NAN)" + ) + + def test_solve_homotopy_marches_and_reports(): """A viscoplastic solve driven by the homotopy converges and reports the march.""" _, stokes = _viscoplastic_stokes() From a5e837277f5d5a6776120a7d689897923f5ceab1 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sun, 26 Jul 2026 10:14:02 +1000 Subject: [PATCH 12/21] =?UTF-8?q?docs(solver):=20correct=20the=20cold-star?= =?UTF-8?q?t=20diagnosis=20=E2=80=94=20inf-safe=20algebra,=20not=20a=20str?= =?UTF-8?q?ain-rate=20floor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The design note and the skill trap list both said a cold plastic start needs the strain-rate invariant floored. Measurement says otherwise: hard Min and the sqrt soft-min cold-start cleanly because eta_pl = +inf propagates correctly to the viscous branch. Only a harmonic mean written as a product over a sum breaks (inf/inf), and the fix is to write it as eta_ve/(1+f). Steer readers away from the floor, which hides the problem, and note that the singularity is a property of rigid/unyielded points rather than of cold starts. Underworld development team with AI support from Claude Code --- .claude/skills/nonlinear-solver/SKILL.md | 2 +- .../nonlinear-solver-homotopy-warmstart.md | 16 +++++++++------- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.claude/skills/nonlinear-solver/SKILL.md b/.claude/skills/nonlinear-solver/SKILL.md index 2d116a0e2..3e1d9c0f9 100644 --- a/.claude/skills/nonlinear-solver/SKILL.md +++ b/.claude/skills/nonlinear-solver/SKILL.md @@ -84,7 +84,7 @@ case felt like whack-a-mole. Check them first. |---|---|---| | Consistent Newton makes the velocity block **non-symmetric**; a Chebyshev/Richardson MG smoother assumes SPD | smoother diverges / stalls → `DIVERGED_LINEAR_SOLVE` or an endless grind | **Now the default** — the FMG bundle ships `mg_levels_ksp_type=gmres` + `pc_type=sor` + `norm_type=none`. Only an issue if you override it, or on GAMG (which uses PETSc's chebyshev default) | | `preconditioner="fmg"` (vs explicit `pc_type=mg` + manual mg opts) | outer KSP "converges" in **1 iteration** → no real Newton correction → stall → `DIVERGED_LINE_SEARCH` | use explicit `pc_type=mg` with the smoother opts above; bound the outer KSP (`ksp_max_it`~80) so a hostile step fails fast | -| Cold plastic start `v=0` | `ε̇=0 → τ_y/(2·0) → NaN`, `DIVERGED_FNORM_NAN` at iteration 0 | floor `ε̇_II` (`+ uw.maths.functions.vanishing`). `solve(homotopy=True)` now does this for you. **Large δ alone does NOT save it** — the singularity is in `η_pl`, before the soft-min sees it | +| Cold plastic start `v=0`, or any rigid/unyielded point | `DIVERGED_FNORM_NAN` at iteration 0 | **Not** a div/0: `ε̇=0` gives `η_pl=+inf`, which `Min` and the sqrt soft-min carry correctly to the viscous branch. Only a soft-min form that computes `η_ve·η_pl/(η_ve+η_pl)` breaks (`inf/inf`). Fixed in the power-mean; if you hand-roll a blend, write the harmonic mean as `η_ve/(1+η_ve/η_pl)`. **Do not reach for a strain-rate floor** — it hides this rather than fixing it | | LU velocity block with all-Dirichlet-ish BC | pressure nullspace singular | attach the Stokes nullspace / avoid a bare LU there | | Hand-rolled `snes_monitor` to "see what's happening" | you read residuals but miss the tell | use `solve_with_diagnostics` / `get_snes_diagnostics` instead (below) | diff --git a/docs/developer/design/nonlinear-solver-homotopy-warmstart.md b/docs/developer/design/nonlinear-solver-homotopy-warmstart.md index ca422d940..46ab6c9ce 100644 --- a/docs/developer/design/nonlinear-solver-homotopy-warmstart.md +++ b/docs/developer/design/nonlinear-solver-homotopy-warmstart.md @@ -233,13 +233,15 @@ The only knobs a user ever touches are the two deliberate opt-outs: force-fresh `_get_yield_softness()` resetting δ to a stale value), the recommended tangent, and the δ atom. The march is residual-guided as designed, and additionally *reverts and retries a failed δ more gently* before settling. A VEP model's `timestep` is - forwarded to the inner solves. **Cold-start caveat found in implementation:** - `τ_y/(2 ε̇_II)` is `0/0` at `v=0`, so the first residual is NaN — large δ alone does - *not* save it, because the singularity is in `η_pl` before the soft-min ever sees - it. `_yield_homotopy_control` therefore floors `ε̇_II` with the `vanishing` - constant. That hazard is **not** homotopy-specific (any cold viscoplastic solve with - a finite yield stress hits it) and is flagged `TODO(BUG)` for a decision on moving - the floor into the model itself. The continuation logic existed as + forwarded to the inner solves. **Cold-start finding:** a cold power-mean solve died + with `DIVERGED_FNORM_NAN`, and the first diagnosis (a `0/0` needing a strain-rate + floor) was **wrong**. At `ε̇=0` the plastic viscosity is `+inf`, which `Min` and the + sqrt soft-min carry correctly to the viscous branch — both cold-start fine. Only the + power-mean broke, because it formed its harmonic mean as + `η_ve·η_pl/(η_ve+η_pl)` = `inf/inf`. Rewriting that as the identical `η_ve/(1+f)` + fixes it with no floor and no numerical change. The bug was never homotopy-specific + or cold-start-specific: `η_pl` is infinite at *every rigid (unyielded) point*, so it + could poison a converged solve wherever a plug forms. The continuation logic existed as a reference implementation (`underworld3.systems.yield_continuation`, the extracted form of `convergence.py::run_continuation`); it is folded in, driven by the model hook and Layer 1's warm-start. From 476a662984ee3d212847f37f17889afd79cfb465 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sun, 26 Jul 2026 16:14:06 +1000 Subject: [PATCH 13/21] feat(solver): zero_init_guess becomes tri-state and auto-detects cold vs warm (Layer 1b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer 1b, the benchmarked default change. solve(zero_init_guess=...) was True by default, so a solve() in a loop silently re-zeroed the initial guess every iteration and threw away a perfectly good warm start. It is now tri-state: None (default) auto-detect: cold when has_solution is False, warm when True True force a fresh start, discarding any existing solution False insist on warming from the current field values The onus flips from "flag when you have a solution" to "flag when you want to throw one away". Detection is safe by construction: guessing cold when a solution was available costs one iteration from a good starting point, while the harmful direction — warming off stale field data — cannot happen, because has_solution is cleared both by a structural rebuild (remesh / adapt / mesh-mover) and by a diverged solve. Design gates checked before flipping: - Free-surface chain-of-solves (the pattern flagged as most at risk): its repeated bare solve() is a LINEAR Poisson mesh-displacement solve with constant diffusivity, so a warm start cannot change the answer it converges to. - Mover/adapt reset: already covered by has_solution's is_setup hook and its regression test. Measured consequence, and the reason this is a real (if small) behaviour change: warm and cold agree only to the CONVERGENCE TOLERANCE, not bitwise, because an iterative solve stops anywhere inside the tolerance ball and a warm start enters it from a different direction. The difference scales with the tolerance — 2.4e-5 at tol=1e-4, 7.4e-10 at 1e-8, 2.0e-14 at 1e-12 — so a test with a threshold tighter than its own solver tolerance could shift. Verified: core solvers, Stokes cartesian, multigrid, deform/rebuild, solver smoke (50 passed) and the repeated-solve suites where this bites hardest — advection- diffusion cartesian and annulus, VEP stability, VE Stokes, yield smoother, transient Darcy (21 passed, 1 pre-existing xpass on a test its own header marks fragile). Underworld development team with AI support from Claude Code --- .../cython/petsc_generic_snes_solvers.pyx | 76 +++++++++++++++---- src/underworld3/systems/solvers.py | 23 ++++-- ...test_0201_solver_has_solution_warmstart.py | 51 +++++++++++++ 3 files changed, 126 insertions(+), 24 deletions(-) diff --git a/src/underworld3/cython/petsc_generic_snes_solvers.pyx b/src/underworld3/cython/petsc_generic_snes_solvers.pyx index 7e58d251d..26984fe6b 100644 --- a/src/underworld3/cython/petsc_generic_snes_solvers.pyx +++ b/src/underworld3/cython/petsc_generic_snes_solvers.pyx @@ -843,6 +843,23 @@ class SolverBaseClass(uw_object): """ return self._has_solution + def _resolve_zero_init_guess(self, zero_init_guess): + """Resolve the tri-state ``zero_init_guess`` argument of ``solve()``. + + ``None`` (the default) auto-detects: cold when the solver holds no converged + solution, warm when it does. Detection is safe by construction — guessing + *cold* when a solution was in fact available costs one extra iteration from a + good starting point, while the harmful direction (warming off stale field data + after a remesh or a diverged solve) cannot happen, because + :attr:`has_solution` is cleared by both. + + ``True`` forces a fresh start (discard any solution); ``False`` insists on + warming from the current field values. + """ + if zero_init_guess is None: + return not self.has_solution + return bool(zero_init_guess) + def _solve_yield_homotopy(self, homotopy_options=None, verbose=False, solve_kwargs=None): """Run ``solve(homotopy=True)``: a multi-solve δ-continuation on the yield law. @@ -3430,7 +3447,7 @@ class SNES_Scalar(SolverBaseClass): @timing.routine_timer_decorator def solve(self, - zero_init_guess: bool =True, + zero_init_guess: bool =None, _force_setup: bool =False, verbose: bool=False, debug: bool=False, @@ -3446,10 +3463,15 @@ class SNES_Scalar(SolverBaseClass): Parameters ---------- - zero_init_guess : bool, default=True - If True, use zero as the initial guess. If False, use the current - values in the solution variable(s) as the initial guess, which can - improve convergence for time-stepping or continuation methods. + zero_init_guess : bool, optional + Cold or warm start. The default (``None``) **auto-detects**: cold when the + solver holds no converged solution, warm when it does (see + :attr:`has_solution`). ``True`` forces a fresh start, discarding any + existing solution; ``False`` insists on warming from the current field + values. Warm-starting improves convergence for time-stepping and + continuation; the auto default gets that without a flag, and cannot warm + off stale data because a remesh or a diverged solve clears + ``has_solution``. _force_setup : bool, default=False Force rebuild of the solver even if already set up. Useful after changing boundary conditions or constitutive parameters. @@ -3497,6 +3519,9 @@ class SNES_Scalar(SolverBaseClass): snes : Access to underlying PETSc SNES object for advanced control. """ + # Tri-state: None auto-detects cold-vs-warm from has_solution. + zero_init_guess = self._resolve_zero_init_guess(zero_init_guess) + import petsc4py @@ -4494,7 +4519,7 @@ class SNES_Vector(SolverBaseClass): @timing.routine_timer_decorator def solve(self, - zero_init_guess: bool =True, + zero_init_guess: bool =None, _force_setup: bool =False, verbose=False, debug=False, @@ -4509,9 +4534,15 @@ class SNES_Vector(SolverBaseClass): Parameters ---------- - zero_init_guess : bool, default=True - If True, use zero as the initial guess. If False, use the current - values in ``self.u`` as the initial guess. + zero_init_guess : bool, optional + Cold or warm start. The default (``None``) **auto-detects**: cold when the + solver holds no converged solution, warm when it does (see + :attr:`has_solution`). ``True`` forces a fresh start, discarding any + existing solution; ``False`` insists on warming from the current field + values. Warm-starting improves convergence for time-stepping and + continuation; the auto default gets that without a flag, and cannot warm + off stale data because a remesh or a diverged solve clears + ``has_solution``. _force_setup : bool, default=False Force rebuild of the solver even if already set up. verbose : bool, default=False @@ -4538,6 +4569,9 @@ class SNES_Vector(SolverBaseClass): u : The solution vector field variable. """ + # Tri-state: None auto-detects cold-vs-warm from has_solution. + zero_init_guess = self._resolve_zero_init_guess(zero_init_guess) + if _force_setup: self.is_setup = False elif not self.constitutive_model._solver_is_setup: @@ -5239,7 +5273,7 @@ class SNES_MultiComponent(SolverBaseClass): @timing.routine_timer_decorator def solve(self, - zero_init_guess: bool = True, + zero_init_guess: bool = None, _force_setup: bool = False, verbose=False, debug=False, @@ -5258,6 +5292,9 @@ class SNES_MultiComponent(SolverBaseClass): start up to this many times. 0 preserves legacy behaviour. """ + # Tri-state: None auto-detects cold-vs-warm from has_solution. + zero_init_guess = self._resolve_zero_init_guess(zero_init_guess) + if _force_setup: self.is_setup = False elif not self.constitutive_model._solver_is_setup: @@ -8326,7 +8363,7 @@ class SNES_Stokes_SaddlePt(SolverBaseClass): @timing.routine_timer_decorator def solve(self, - zero_init_guess: bool = True, + zero_init_guess: bool = None, picard: int = 0, verbose=False, debug=False, @@ -8345,11 +8382,15 @@ class SNES_Stokes_SaddlePt(SolverBaseClass): Parameters ---------- - zero_init_guess : bool, default=True - If True, use zero as the initial guess. If False, use current - values in ``self.u`` (velocity) and ``self.p`` (pressure) as - initial guess. Using False can improve convergence for - time-stepping or parameter continuation. + zero_init_guess : bool, optional + Cold or warm start. The default (``None``) **auto-detects**: cold when the + solver holds no converged solution, warm when it does (see + :attr:`has_solution`). ``True`` forces a fresh start, discarding any + existing solution; ``False`` insists on warming from the current field + values. Warm-starting improves convergence for time-stepping and + continuation; the auto default gets that without a flag, and cannot warm + off stale data because a remesh or a diverged solve clears + ``has_solution``. picard : int, default=0 Number of Picard iterations before switching to Newton. Picard iterations use a simplified Jacobian and can help @@ -8428,6 +8469,9 @@ class SNES_Stokes_SaddlePt(SolverBaseClass): constitutive_model : Viscosity and stress definitions. """ + # Tri-state: None auto-detects cold-vs-warm from has_solution. + zero_init_guess = self._resolve_zero_init_guess(zero_init_guess) + if homotopy: # The march runs a SEQUENCE of ordinary solves at successively sharper # yield surfaces; each one re-enters this method with homotopy=False. diff --git a/src/underworld3/systems/solvers.py b/src/underworld3/systems/solvers.py index 8d4d7f6a9..afe65f1d1 100644 --- a/src/underworld3/systems/solvers.py +++ b/src/underworld3/systems/solvers.py @@ -695,7 +695,7 @@ def v(self, value): @timing.routine_timer_decorator def solve( self, - zero_init_guess: bool = True, + zero_init_guess: bool = None, timestep: float = None, verbose: bool = False, _force_setup: bool = False, @@ -940,7 +940,7 @@ def estimate_dt(self): @timing.routine_timer_decorator def solve( self, - zero_init_guess: bool = True, + zero_init_guess: bool = None, timestep=None, _force_setup: bool = False, verbose=False, @@ -1409,7 +1409,7 @@ def _create_stress_history_ddt(self, order=2): @memprobe.instrument("Stokes.solve") def solve( self, - zero_init_guess: bool = True, + zero_init_guess: bool = None, timestep: float = None, _force_setup: bool = False, verbose: bool = False, @@ -1430,8 +1430,15 @@ def solve( Parameters ---------- - zero_init_guess : bool - If True, use zero initial guess. Otherwise use current field values. + zero_init_guess : bool, optional + Cold or warm start. The default (``None``) **auto-detects**: cold when the + solver holds no converged solution, warm when it does (see + :attr:`has_solution`). ``True`` forces a fresh start, discarding any + existing solution; ``False`` insists on warming from the current field + values. Warm-starting improves convergence for time-stepping and + continuation; the auto default gets that without a flag, and cannot warm + off stale data because a remesh or a diverged solve clears + ``has_solution``. timestep : float, optional Advection timestep. Required when stress history is active. _force_setup : bool @@ -4145,7 +4152,7 @@ def _reduce_dt(per_elem): @timing.routine_timer_decorator def solve( self, - zero_init_guess: bool = True, + zero_init_guess: bool = None, timestep: float = None, _force_setup: bool = False, _evalf=False, @@ -4457,7 +4464,7 @@ def estimate_dt(self): @timing.routine_timer_decorator def solve( self, - zero_init_guess: bool = True, + zero_init_guess: bool = None, timestep: float = None, evalf: bool = False, _force_setup: bool = False, @@ -4876,7 +4883,7 @@ def penalty(self, value): @memprobe.instrument("NavierStokes.solve") def solve( self, - zero_init_guess: bool = True, + zero_init_guess: bool = None, timestep: float = None, _force_setup: bool = False, verbose=False, diff --git a/tests/test_0201_solver_has_solution_warmstart.py b/tests/test_0201_solver_has_solution_warmstart.py index b6369307b..b5b2683c0 100644 --- a/tests/test_0201_solver_has_solution_warmstart.py +++ b/tests/test_0201_solver_has_solution_warmstart.py @@ -110,6 +110,57 @@ def test_has_solution_resets_on_mesh_deform(): ) +def test_zero_init_guess_is_tristate_and_auto_detects(): + """Layer 1b: the default (None) resolves cold-vs-warm from has_solution, while + True/False remain explicit overrides.""" + _, poisson = _poisson() + + # Fresh solver: nothing to warm from -> auto resolves COLD. + assert poisson.has_solution is False + assert poisson._resolve_zero_init_guess(None) is True + + poisson.solve() + assert poisson.has_solution is True + # A converged solution is present -> auto resolves WARM. + assert poisson._resolve_zero_init_guess(None) is False + + # Explicit values are honoured regardless of has_solution. + assert poisson._resolve_zero_init_guess(True) is True + assert poisson._resolve_zero_init_guess(False) is False + + # After a structural rebuild the claim is dropped, so auto is cold again — this + # is what stops a remesh warm-starting off stale field data. + poisson.is_setup = False + assert poisson._resolve_zero_init_guess(None) is True + + +def test_repeated_default_solve_agrees_to_solver_tolerance(): + """A bare solve() called repeatedly (the linear chain-of-solves pattern) must give + the same answer once auto-detect starts warming the later calls. + + "Same" means *to the requested convergence tolerance*, not bitwise: an iterative + solve stops anywhere inside the tolerance ball, and a warm start enters that ball + from a different direction than a cold one. The difference is therefore + tolerance-sized and shrinks with it (measured: 2.4e-5 at tol=1e-4, 7.4e-10 at + 1e-8, 2.0e-14 at 1e-12) — the flip to auto-detected warm starts changes results + at that level and no more. + """ + import numpy as np + + mesh, poisson = _poisson() + poisson.tolerance = 1.0e-10 + poisson.solve() + first = poisson.u.array[:, 0, 0].copy() + + poisson.solve() # now warm by auto-detect + second = poisson.u.array[:, 0, 0].copy() + + assert np.abs(first - second).max() < 1.0e-6, ( + "warm and cold solutions of a linear problem must agree to the solver " + f"tolerance (got max|diff| = {np.abs(first - second).max():.2e})" + ) + + def test_cold_warmstart_under_consistent_newton_converges(): """A cold consistent-Newton Stokes solve exercises the automatic single-Picard warm-up branch — it must converge and set has_solution.""" From 7a27a11f12d178757488fc113161a54ee4747453 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sun, 26 Jul 2026 16:14:51 +1000 Subject: [PATCH 14/21] docs(solver): all four layers landed; drop the last stale cold-start claims Mark the design IMPLEMENTED and Layer 1b DONE, with the measured caveat that warm and cold agree to the convergence tolerance rather than bitwise. Remove the remaining places where the skill still said a cold plastic start NaNs unless large delta or a strain-rate floor saves it. Neither is true: eta_pl is simply infinite at zero strain rate and the soft-min carries that to the viscous branch. The trap list already carries the accurate version. Underworld development team with AI support from Claude Code --- .claude/skills/nonlinear-solver/SKILL.md | 25 ++++++++++--------- .../nonlinear-solver-homotopy-warmstart.md | 17 ++++++++++--- 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/.claude/skills/nonlinear-solver/SKILL.md b/.claude/skills/nonlinear-solver/SKILL.md index 3e1d9c0f9..7bd3965a0 100644 --- a/.claude/skills/nonlinear-solver/SKILL.md +++ b/.claude/skills/nonlinear-solver/SKILL.md @@ -19,12 +19,13 @@ Yield-law maths, tangent-per-model, quadratic-convergence check: `plasticity-sol ## The recipe (what actually converges) -1. **Warm start.** A cold plastic start (`v=0 ⇒ ε̇=0 ⇒ τ_y/0 ⇒ NaN`) is avoided by - starting the continuation at **large δ** (the power-mean is then the harmonic - mean, bounded by `η_bg` even at `ε̇→0`) and taking **one Picard step** into the - Newton basin. One Picard step is defect-correction iteration 1 — contractive, - cheap. From a *warm* iterate, take **no** Picard step (it wastes the good - quadratic start). +1. **Warm start.** Start the continuation at **large δ**, where the yield surface is + smooth and the problem is easy, and take **one Picard step** into the Newton + basin. One Picard step is defect-correction iteration 1 — contractive, cheap. From + a *warm* iterate, take **no** Picard step (it wastes the good quadratic start). + A cold `v=0` start is safe on its own terms: `ε̇=0` makes `η_pl` infinite, which + the soft-min carries to the viscous branch (see the trap list for the one form + that must be written carefully). 2. **Multi-solve δ-continuation** (NOT an in-solve ramp). Hold δ **constant** for a full nonlinear solve to tolerance; warm-start the next, smaller δ from that @@ -42,9 +43,9 @@ Yield-law maths, tangent-per-model, quadratic-convergence check: `plasticity-sol ``` `solve(homotopy=True)` sets the smooth mode (softmin + power-mean), picks the - tangent the model asks for (Newton for DP, Picard for elastic VEP), floors `ε̇_II` - so a cold start does not NaN, and runs a residual-guided march that accelerates on - easy steps and reverts + retries a failed δ more gently. Tune with + tangent the model asks for (Newton for DP, Picard for elastic VEP), and runs a + residual-guided march that accelerates on easy steps and reverts + retries a + failed δ more gently. Tune with `homotopy_options=dict(delta0=…, down=…, dmin=…, entry_maxit=…, step_maxit=…)`; the driver is also callable directly as `underworld3.systems.yield_continuation`. @@ -120,9 +121,9 @@ if stokes.has_solution: - **Layer 1a — DONE:** `has_solution` + cold consistent-Newton Picard warm-up (`petsc_generic_snes_solvers.pyx`; test `test_0201`). -- **Layer 1b — planned:** flip `zero_init_guess` default to auto-detect (cold-vs-warm - from `has_solution`). Benchmark; gate the free-surface chain-of-solves and the - mover/adapt reset first. +- **Layer 1b — DONE:** `zero_init_guess` is tri-state — `None` (default) auto-detects + from `has_solution`, `True` forces fresh, `False` insists on warm. Note warm and cold + agree only to the *convergence tolerance*, not bitwise. - **Layer 3 — DONE:** the FMG velocity smoother defaults to `gmres`+`sor` with `mg_levels_ksp_norm_type=none` (fixed-cost V-cycle), unconditionally — see "Multigrid depth" below. diff --git a/docs/developer/design/nonlinear-solver-homotopy-warmstart.md b/docs/developer/design/nonlinear-solver-homotopy-warmstart.md index 46ab6c9ce..0ce8dda93 100644 --- a/docs/developer/design/nonlinear-solver-homotopy-warmstart.md +++ b/docs/developer/design/nonlinear-solver-homotopy-warmstart.md @@ -1,7 +1,8 @@ # Nonlinear solver: automatic warm-start and single-parameter yield homotopy -**Status: PROPOSED.** Design only — implementation is deferred to a dedicated, -carefully-benchmarked session because it changes core `solve()` behaviour. +**Status: IMPLEMENTED** (2026-07, branch `feature/nonlinear-warmstart-homotopy`). +All four stages have landed; each section below records what was built and, where +measurement contradicted the design, what was corrected. (Design captured 2026-07, from the Spiegelman viscoplastic hardening work.) ## Why this exists @@ -218,8 +219,16 @@ The only knobs a user ever touches are the two deliberate opt-outs: force-fresh solve pays nothing. Broadening the warm-up to all nonlinear solvers (a cached nonlinearity probe rather than the tangent proxy) is a natural Layer-1b extension. Recipe + config-trap-list captured in the `nonlinear-solver` skill. -2. **Layer 1b — flip `zero_init_guess` to auto-detect.** The benchmarked default - change. Gate the free-surface-chain and mover cases first (below). +2. **Layer 1b — flip `zero_init_guess` to auto-detect** — **LANDED** (`test_0201`). + Tri-state: `None` (default) auto-detects from `has_solution`, `True` forces fresh, + `False` insists on warm. Both gates cleared before flipping — the free-surface + chain's repeated bare `solve()` is a *linear* Poisson mesh-displacement solve, so a + warm start cannot change its converged answer; the mover/adapt reset is covered by + the `is_setup` hook and its test. **Measured consequence:** warm and cold agree to + the *convergence tolerance*, not bitwise (2.4e-5 at `tol=1e-4`, 7.4e-10 at `1e-8`, + 2.0e-14 at `1e-12`), because an iterative solve stops anywhere in the tolerance + ball and a warm start enters it from a different direction. A test with a threshold + tighter than its own solver tolerance can therefore shift. 3. **Layer 3 — non-symmetry-safe smoother default** — **LANDED** (`gmres`+`sor`+ `norm_type=none` in the FMG bundle; test `test_1014`). Independent of homotopy. Benchmarked as described in the Layer 3 section: applied unconditionally rather From 69e82a280ae26b17eb7bf46ef575d8817ec01406 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Sun, 26 Jul 2026 21:23:34 +1000 Subject: [PATCH 15/21] fix(constitutive): yield_stress_min defaults to 0, floored via symbolic smooth_max MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maintainer ruling (2026-07-26): a negative yield stress is meaningless — tau_y is compared against the second invariant of the stress — so yield_stress_min defaults to 0 rather than the -oo "unset" sentinel. The +/-oo defaults elsewhere in Parameters exist so sympy can cancel an unused term away; that trick is wrong for this one. This closes two defects found by adversarial review: * A pressure-dependent Drucker-Prager yield C + sin(phi)*p goes negative in tension. Hard Min merely hid the resulting negative eta_pl; the power-mean the homotopy switches to raised a negative base to a non-integer power and returned NaN, so solve(homotopy=True) failed on the first residual for exactly the class of model the feature targets. * _apply_floor's rounding scale is relative (delta * floor) and collapsed at a zero floor, so honouring yield_stress_min = 0 gave a zero-width smooth_max — the hard kink with a 0/0 derivative. The floor is applied through uw.maths.smooth_max everywhere, including yield_mode= "min" where eps = 0 makes it exactly Max. That matters for more than smoothness: smooth_max is pure arithmetic on its operands, so the floor stays the symbolic Parameter the JIT routes through constants[] and sympy is never asked for an ordering test. sympy.Max(, x) cannot resolve that comparison and recurses until the stack dies — the fragility noted when the DP sentinel guard was fixed, which the old -oo default had merely hidden by skipping the floor. Turning the floor on by default made it fire on every yielding model, so it had to be fixed rather than dodged. Guards now test the DISABLING sentinel (!= -sympy.oo) instead of != 0, which would have skipped the floor at the new default. That also fixes the VEP and TI-VEP models, where the != 0 guard silently ignored a user-set yield_stress_min = 0. Left open, marked TODO(DESIGN): a genuinely rounded tension cap (Griffith / parabolic) needs an ABSOLUTE stress scale, which _apply_floor's signature cannot supply. At floor = 0 the cutoff is therefore still a corner, and eta_pl is exactly zero in tension. Regression: test_1057 asserts the zero default and that a yield stress driven negative by tension leaves the viscosity finite in both hard-Min and power-mean modes. Underworld development team with AI support from Claude Code --- src/underworld3/constitutive_models.py | 62 +++++++++++++++++++------ tests/test_1057_yield_homotopy_solve.py | 41 ++++++++++++++++ 2 files changed, 88 insertions(+), 15 deletions(-) diff --git a/src/underworld3/constitutive_models.py b/src/underworld3/constitutive_models.py index e7d947b58..0f2a944bb 100644 --- a/src/underworld3/constitutive_models.py +++ b/src/underworld3/constitutive_models.py @@ -1083,9 +1083,22 @@ def _apply_floor(self, value, floor): such scale and is rounded by its own physical parameter via ``uw.maths.smooth_max`` at the call site instead. """ - if getattr(self, "_yield_mode", "min") == "min": - return sympy.Max(value, floor) - return uw.maths.smooth_max(value, floor, self._get_yield_softness() * floor) + # smooth_max is 1/2 (a + b + sqrt((a-b)^2 + eps^2)) — pure arithmetic on the + # operands, so `floor` stays the symbolic Parameter that the JIT routes + # through constants[], and sympy is never asked for the ordering test that + # recurses on an opaque UWexpression. At eps = 0 this is exactly + # Max(value, floor), but written without a comparison. + # + # TODO(DESIGN): the rounding scale is RELATIVE (delta * floor), so it + # collapses for a tension cutoff at floor = 0 — now the default for + # yield_stress_min. That leaves a hard corner: the floored yield stress is + # exactly 0 in tension, hence eta_pl = tau_y/(2 edot_II) is exactly 0 and the + # tangent through the soft-min is undefined there. A properly rounded cap + # (Griffith / parabolic) needs an ABSOLUTE stress scale, which this signature + # cannot supply. Maintainer decision pending (2026-07-26). + rounding = 0 if getattr(self, "_yield_mode", "min") == "min" \ + else self._get_yield_softness() * floor + return uw.maths.smooth_max(value, floor, rounding) # Tangent this model wants while the yield homotopy marches. Newton is right for # a purely viscous-plastic yield; the elastic (VEP) subclasses override to the @@ -1251,7 +1264,7 @@ class _Parameters(_ParameterBase, _ViscousParameterAlias): yield_stress_min = api_tools.Parameter( R"{\tau_{y, \mathrm{min}}}", - lambda inner_self: -sympy.oo, + lambda inner_self: 0, "Yield stress (DP) minimum cutoff", units="Pa", ) @@ -1287,9 +1300,14 @@ def viscosity(self): # Don't put conditional behaviour in the constitutive law # when it is not needed - # Numerical lower bound on the yield stress. The "unset" sentinel is -∞ - # (per the Parameters convention that ±∞ defaults simplify away), so a - # user-supplied yield_stress_min of exactly 0 is honoured as a real floor. + # Lower bound on the yield stress, defaulting to ZERO and therefore normally + # active. tau_y is compared against the second invariant of the stress, so a + # negative tau_y is meaningless — a pressure-dependent Drucker-Prager yield + # C + sin(phi)*p goes negative in tension and must be cut off there rather + # than propagated into tau_y/(2 edot_II). (The ±oo defaults elsewhere in + # Parameters exist so sympy can cancel an unused term away; that trick is + # wrong here, so this one defaults to 0 — maintainer ruling 2026-07-26.) + # An explicit -oo still disables the floor. if inner_self.yield_stress_min.sym != -sympy.oo: yield_stress = self._apply_floor( inner_self.yield_stress, inner_self.yield_stress_min @@ -1598,7 +1616,7 @@ def dt_elastic(inner_self, value): yield_stress_min = api_tools.Parameter( R"{\tau_{y, \mathrm{min}}}", - lambda inner_self: -sympy.oo, + lambda inner_self: 0, "Yield stress (DP) minimum cutoff", units="Pa", ) @@ -2027,10 +2045,19 @@ def _plastic_effective_viscosity(self): "Strain rate 2nd Invariant including elastic strain rate term", ) - if parameters.yield_stress_min.sym != 0: - yield_stress = sympy.Max( - parameters.yield_stress_min, parameters.yield_stress - ) # .rewrite(sympy.Piecewise) + # Guard on the DISABLING sentinel, not on zero: zero is the default and a + # physically meaningful floor (the yield stress is compared against the second + # invariant of the stress, so a negative tau_y is meaningless). Only an + # explicit -oo turns the floor off. + if parameters.yield_stress_min.sym != -sympy.oo: + # Literal 0 for the default floor, not the parameter atom: sympy cannot + # fuzzy-compare an opaque UWexpression and Max canonicalisation recurses. + # smooth_max keeps both operands symbolic (the JIT routes the floor + # through constants[]) and needs no ordering test, which sympy cannot + # resolve against an opaque UWexpression. eps = 0 makes it exactly Max. + yield_stress = uw.maths.smooth_max( + parameters.yield_stress, parameters.yield_stress_min, 0 + ) else: yield_stress = parameters.yield_stress @@ -3168,7 +3195,7 @@ def dt_elastic(inner_self, value): ) yield_stress_min = api_tools.Parameter( R"{\tau_{y, \mathrm{min}}}", - lambda inner_self: -sympy.oo, + lambda inner_self: 0, "Yield stress minimum cutoff", units="Pa", ) strainrate_inv_II_min = api_tools.Parameter( @@ -3531,8 +3558,13 @@ def _plastic_effective_viscosity(self): gamma_dot_abs = sympy.sqrt(sympy.Max(gamma_dot_sq, 0)) tau_y = parameters.yield_stress - if parameters.yield_stress_min.sym != 0: - tau_y = sympy.Max(parameters.yield_stress_min, tau_y) + # Guard on the DISABLING sentinel, not on zero — zero is the default and a + # real floor (a negative yield stress is meaningless against an invariant). + if parameters.yield_stress_min.sym != -sympy.oo: + # Literal 0 for the default floor (sympy fuzzy-compare recursion on atoms). + # smooth_max: keeps the floor symbolic, no ordering test (see the note + # in Constitutive_Model._apply_floor). eps = 0 is exactly Max. + tau_y = uw.maths.smooth_max(tau_y, parameters.yield_stress_min, 0) if parameters.strainrate_inv_II_min.sym != 0: viscosity_yield = tau_y / ( diff --git a/tests/test_1057_yield_homotopy_solve.py b/tests/test_1057_yield_homotopy_solve.py index 6a07a3a7f..eb11a9598 100644 --- a/tests/test_1057_yield_homotopy_solve.py +++ b/tests/test_1057_yield_homotopy_solve.py @@ -136,6 +136,47 @@ def test_cold_viscoplastic_solve_survives_zero_strain_rate(mode, smoother): ) +def test_yield_stress_is_floored_at_zero_by_default(): + """The yield stress is compared against the second invariant of the stress, so a + negative tau_y is meaningless. `yield_stress_min` therefore defaults to 0 and the + floor is active without the user asking. + + Regression: the default used to be the "unset" sentinel -oo (so sympy could cancel + the term away), which left a pressure-dependent Drucker-Prager yield + C + sin(phi)*p free to go negative in tension. Hard `Min` hid that (it discarded + the resulting negative eta_pl); the power-mean the homotopy switches to raised a + negative base to a non-integer power and produced NaN. + """ + _, stokes = _viscoplastic_stokes() + cm = stokes.constitutive_model + assert cm.Parameters.yield_stress_min.sym == 0 + + +@pytest.mark.parametrize("mode,smoother", + [("min", None), ("softmin", "powermean")]) +def test_pressure_dependent_yield_survives_tension(mode, smoother): + """A Drucker-Prager yield that goes NEGATIVE in tension must not poison the + viscosity in any yield mode — the zero floor must catch it first.""" + import numpy as np + + mesh, stokes = _viscoplastic_stokes(cellSize=0.5) + cm = stokes.constitutive_model + x, y = mesh.X + # C + sin(phi)*p with the pressure driven strongly negative (tension) over part + # of the domain, so the raw yield stress changes sign inside the mesh. + cm.Parameters.yield_stress = 1.0 + 0.5 * stokes.p.sym[0] + cm.yield_mode = mode + if smoother is not None: + cm.yield_smoother = smoother + + eta = cm.viscosity.sym + vals = uw.function.evaluate(eta, mesh.X.coords) + assert np.all(np.isfinite(vals)), ( + f"viscosity is non-finite for yield_mode={mode!r} smoother={smoother!r} " + f"where the pressure-dependent yield stress goes negative" + ) + + def test_solve_homotopy_marches_and_reports(): """A viscoplastic solve driven by the homotopy converges and reports the march.""" _, stokes = _viscoplastic_stokes() From ca26077ac9deb1da695a7365377a4f3d1159b799 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Mon, 27 Jul 2026 09:27:59 +1000 Subject: [PATCH 16/21] fix(solver): close the critical adversarial-review findings in the homotopy march Review report added at docs/reviews/2026-07/nonlinear-solver-homotopy-adversarial-review.md (four independent adversarial reviewers plus an author pass; every finding re-verified against source). Codes below refer to it. C1 - iteration budgets are real again. entry_maxit/step_maxit were pushed through petsc_options, which SNES_Stokes_SaddlePt.solve overwrites with its hardcoded snes_max_it=50 via setFromOptions immediately before solving, so both documented knobs were silent no-ops and the residual-guided step logic was comparing the real iteration count against a budget that never applied. They now go through the same _difficulty_probe / _difficulty_max_it hook estimate_difficulty uses, which is applied after setFromOptions and restored in a finally. C2 / M2 - a reverted iterate is a CONVERGED iterate, so the march now says so. The failed inner solve cleared has_solution, so the retry's zero_init_guess resolved cold and re-solved a sharper delta from zero, defeating the revert entirely; and a march that settled through the failure exit handed back a good solution with has_solution=False, so the caller's next solve threw it away. C3 - refuse the march on a stress-history (VEP) solver. Each delta-step re-enters the full time-integration tail, so an N-step march advanced the elastic stress history by N*dt for one requested dt. Failing loudly beats silently corrupting the history; the interaction needs designing before VEP can use this. M1 - zero_init_guess is resolved AFTER the _force_setup block in all four solve bodies. Resolving first meant solve(_force_setup=True) warm-started off the has_solution flag that the very same call was about to clear. M3 - the entry solve can warm-start again: has_solution is read before the rebuild that clears it, instead of one line after. M4 - the march is wrapped in try/finally, so an exception no longer leaves the tangent, the iteration budget and the model's delta in march state. M5 - consistent_jacobian is restored to the caller's value. m1, m3, m5, m6 - pprint no longer prints a stray rank argument (the retired pprint_old signature); the retry log names the delta that actually failed; delta0 is validated; and max_steps caps a march that keeps easing off. Tests strengthened where the review showed they would pass with the feature deleted: the march must now descend (steps > 1, settled < delta0, reached_dmin) rather than merely run once; _force_setup cold-start is driven through the public solve(); and new cases cover the tangent restore and the VEP refusal. Still open from the review: M6 (power-mean cannot reach exact Min - docs overstate it), M7 (TI-VEP ignores yield_smoother), M9 (Newton tangent at zero strain rate), the remaining minors, and parallel coverage. Underworld development team with AI support from Claude Code --- ...near-solver-homotopy-adversarial-review.md | 299 ++++++++++++++++++ .../cython/petsc_generic_snes_solvers.pyx | 28 +- src/underworld3/systems/yield_continuation.py | 158 +++++---- ...test_0201_solver_has_solution_warmstart.py | 24 ++ tests/test_1057_yield_homotopy_solve.py | 43 ++- 5 files changed, 484 insertions(+), 68 deletions(-) create mode 100644 docs/reviews/2026-07/nonlinear-solver-homotopy-adversarial-review.md diff --git a/docs/reviews/2026-07/nonlinear-solver-homotopy-adversarial-review.md b/docs/reviews/2026-07/nonlinear-solver-homotopy-adversarial-review.md new file mode 100644 index 000000000..90e3a1eba --- /dev/null +++ b/docs/reviews/2026-07/nonlinear-solver-homotopy-adversarial-review.md @@ -0,0 +1,299 @@ +# Adversarial review — nonlinear solver warm-start + yield homotopy (4 layers) + +Branch `feature/nonlinear-warmstart-homotopy` vs `development` +(16 commits, 2402 insertions; `level_1 and tier_a` suite is green at 470 passed). + +Method: four independent adversarial reviewers, each given one dimension and +instructed to break the change rather than praise it, plus the author's own pass. +Every finding below was re-verified against the source before being reported; +findings the reviewers raised that did not survive verification are not listed. + +**Verdict: NOT ready to flag ready.** Three critical and seven major defects, most of +them in the new code rather than pre-existing. Two of the four layers (1a, 3) look +sound; the homotopy driver (Layer 2) and the default flip (Layer 1b) both need work. + +--- + +## CRITICAL + +### C1 — `entry_maxit` / `step_maxit` are inert; a failing δ-step burns 50 iterations +`systems/yield_continuation.py:151` sets `solver.petsc_options["snes_max_it"]`, but +`SNES_Stokes_SaddlePt.solve` hardcodes `snes_max_it = 50` +(`petsc_generic_snes_solvers.pyx:8474`) and pushes it with `setValue` + +`setFromOptions` immediately before the real solve (`:8543`) — the file's own comment +at `:8468` documents that this clobbers any user-set value. + +Consequences: both documented `homotopy_options` budget keys are silent no-ops; the +docstring promise that "a tight budget lets a too-hard step abort cheaply" is false +(a failing δ costs 50 Newton iterations, ×3 with the default retries); and the +residual-guided step logic at `:175` compares the *real* `nit` (cap 50) against a +*fictional* `budget` (10/30), so the "ease off" branch mis-fires and drives `step` +toward its 0.95 clamp. The mechanism that does work is +`snes.setTolerances(max_it=…)` after `setFromOptions` (as `_snes_solve_with_retries` +already does at `:1495`). + +### C2 — every retry after a failed δ is COLD, defeating the revert +`yield_continuation.py:181` reverts `u`/`p` to the last converged state so the retry +can warm-start from it. But the failed inner solve already ran +`_record_convergence_status()`, setting `has_solution = False`, so the retry's +`solver.solve(zero_init_guess=not solver.has_solution, …)` (`:155`) resolves to +**cold** — a from-zero solve at a δ *sharper* than the one that just failed. That is +exactly the regime this module's own docstring says does not converge. The revert is +wasted, the retry is near-guaranteed to fail, and the march settles earlier than it +should. + +### C3 — `homotopy=True` on a VEP model advances the stress history once per δ-step +`ViscoElasticPlasticFlowModel` advertises the homotopy +(`constitutive_models.py:2289`), and `SNES_Stokes.solve` forwards `timestep` into +every inner solve (`systems/solvers.py:1478`). Each inner solve therefore re-enters +the `has_stress_history` branch and runs the full time-integration tail — +`DFDt.update_pre_solve(timestep)`, the stress projection, the `psi_star` shift loop, +and `DFDt.update_post_solve(timestep)` (`solvers.py:1525-1585`). An N-step march +advances the elastic stress history by **N·dt for one requested dt**, and the +driver's revert restores only `u`/`p`, not the history, so a failed step leaves the +extra shift in place. `test_1057` only exercises the non-elastic model, so nothing +catches it. + +--- + +## MAJOR + +### M1 — `_force_setup=True` now warm-starts, inverting its own contract +All four solve bodies resolve `zero_init_guess` *before* the invalidation it depends +on (`pyx` resolve/invalidate pairs 3444/3450, 4494/4497, 5217/5220, 8394/8402): + +```python +stokes.solve() # converged -> has_solution True +stokes.solve(_force_setup=True) # resolves WARM off the stale flag, THEN rebuilds +``` + +The `is_setup` setter's own comment names "explicit `_force_setup`" as a structural +invalidation that must drop the warm-start claim. Before the flip this path was cold. +Fix: resolve after the `_force_setup` block. + +### M2 — a march that settles via the failure exit leaves `has_solution=False` on a good solution +On the retries-exhausted `break`, the fields are reverted to the settled solution and +δ is reset to `settled`, but nothing restores `has_solution` (left `False` by the +failed solve). The report says `converged: True` with a real `settled_delta`, yet the +next `solve()` auto-cold-starts and discards the continuation result — precisely the +advertised usage (`solve(homotopy=True)` once, then a time loop). `solve_report` has +the same problem: it describes the *failed* δ, not the settled one. + +### M3 — the march always cold-starts its entry solve +`yield_continuation.py:148` runs `solver.is_setup = False` on the first iteration, +whose setter clears `has_solution`, so `not solver.has_solution` on the next line is +unconditionally `True`. A time loop calling `solve(homotopy=True)` per step discards +the previous step's converged state every time, contradicting the module's own +comment ("Cold only if there is genuinely nothing to warm-start from"). + +### M4 — no `try/finally`: an exception mid-march leaves solver and model corrupted +`yield_continuation.py:145-203` has no exception handling. If an inner solve raises +(PETSc error, JIT failure, the VEP `timestep is None` ValueError), then `snes_max_it` +is left at the march value, `consistent_jacobian` at `control.tangent`, the model in +`softmin`/`powermean` at the *failed* δ, and `u`/`p` hold the diverged iterate with no +revert. + +### M5 — unrestored side effects on the user's solver and model +`solver.consistent_jacobian = control.tangent` (`:128`) is never restored: a user who +set `consistent_jacobian = "continuation"` (a supported value) silently gets `True` +or `False` forever after. `_yield_homotopy_control()` likewise leaves +`yield_mode="softmin"` and `yield_smoother="powermean"` permanently — a model the user +configured as `yield_mode="min"` (the `ViscoPlasticFlowModel` **default**) comes back +as a power-mean soft-min, so every later plain `solve()` runs different physics than +asked for, unwarned. + +### M6 — the power-mean cannot reach exact `Min`, but the docs say it does +Sharpness is `s = 1/(δ + 0.001)`, which **saturates at 1000**: + +| δ | 1e-2 | 1e-3 | 1e-4 | 1e-6 | 0 | +|---|---|---|---|---|---| +| s | 91 | 500 | 909 | 999 | **1000** | + +`constitutive_models.py:989` and `:1031` state "Both approach exact `Min` as δ → 0". +False for the power-mean family: δ=0 gives a finite power-mean (~0.2 % below true +`Min`), not the sharp surface. Since the entire homotopy premise is "march δ→0 to +reach the sharp yield surface", the contract needs restating: with `powermean` you +converge to a slightly smoothed yield law. Corollary (minor): the march happily +descends below δ≈1e-4 where nothing changes — a demo run reached δ=2.4e-10 in 9 steps, +and `settled_delta` then reads far sharper than the law actually is. + +### M7 — `TransverseIsotropicVEPFlowModel` advertises the homotopy but ignores half the control +It returns `supports_yield_homotopy = True` (`constitutive_models.py:3800`) and +inherits `_yield_homotopy_control()`, but never calls `_combine_yield`: its yield law +is inlined (`:3485`, `:3578`, `:3986`, `:4049`) reading `self._yield_softness` as a raw +float and hardcoding the **sqrt** family. So `yield_smoother = "powermean"` is a silent +no-op there — and the power-mean's large-δ harmonic limit is the stated justification +for the march's well-posed cold entry. Its `yield_softness` setter also never syncs +`_yield_softness_expr`, so the `control.delta` atom handed back is meaningless for +this model. + +--- + +## MINOR + +- **m1** — `uw.pprint(0, …)` (`yield_continuation.py:168,187,194`) uses the retired + `pprint_old(ranks, …)` shape; the current signature is `pprint(*args, proc=0, …)`, + so the literal `0` is printed. Confirmed empirically: every march line reads + `0 [yield-continuation] δ=…`. +- **m2** — diverged **linear** rotated free-slip records success. `pyx:8456` does + `.get("converged", True)`, but `solve_rotated_freeslip()` returns only + `ksp_reason`/`ksp_its` (`rotated_bc.py:379`), so the default fires even when + `_warn_if_ksp_diverged` just warned. Provably inconsistent with the sibling line: + `_capture_rotated_report` derives `converged = reason > 0` from the same dict, so + `solve_report.converged is False` while `has_solution is True`. +- **m3** — the retry log prints the wrong δ as having failed (`:194` recomputes + `d/step`, which is the last *good* δ). +- **m4** — `failures` is a whole-march counter, never reset on success, so `retries` + is not "per failed δ" as documented. +- **m5** — `delta0` is unvalidated (`down` is). `delta0 <= 0` reaches + `s = 1/(δ+0.001)` and divides by zero at δ = −0.001. +- **m6** — no cap on δ-steps: `step` is clamped to ≤0.95, so a hostile march can take + ~135 solves from `delta0=1.0` to `dmin=1e-3` with no `max_steps` escape (compounded + by C1's 50-iteration steps). +- **m7** — `solve(homotopy=True)` silently drops `picard`, `divergence_retries`, + `evalf`, `order`, `_force_setup`, `debug`, and even `zero_init_guess`. +- **m8** — two dispatch points with different behaviour: `pyx:8399` forwards no + `solve_kwargs`, so a `SNES_Stokes_SaddlePt` subclass that doesn't override `solve` + would give a VEP model inner solves with no `timestep`. +- **m9** — Charter §6: `YieldHomotopyControl` and `SolveReport` are named in public + docstrings but not exported from `systems/__init__` (deep-import-only). +- **m10** — Charter §4: three undocumented exception swallows in + `_capture_solve_report` (`pyx:1239-1250`). +- **m11** — stale docstrings after the flip: `SNES_Darcy.solve` still says + "If True (default)"; `SNES_TransientDarcy.solve` says "(default True)". +- **m12** — duplicate test number: `test_1055_solve_report.py` and + `test_1055_yield_smoother.py` are both new on this branch; `test_1057` then skips + 1056. +- **m13** — latent, pre-existing: the `yield_stress_min != 0` guard fixed on the DP + model survives at `constitutive_models.py:2030` (VEP) and `:3534` (TI-VEP). Same + class of bug; per Charter §2 it wants a `# TODO(BUG):` rather than a silent fix. + +--- + +## Test quality (Charter §8) — the review's sharpest hit on the author + +Three of the new tests would pass with the feature deleted: + +- `test_0201::test_zero_init_guess_is_tristate_and_auto_detects` asserts the **private** + `_resolve_zero_init_guess` back to itself; it never drives the public `solve()` path, + so it is blind to M1 (the `_force_setup` ordering bug) — the exact defect it should + have caught. +- `test_0201::test_repeated_default_solve_agrees_to_solver_tolerance` passes unchanged + if the flip is reverted (two cold solves of a linear Poisson also agree), and never + asserts the second solve was actually warm. +- `test_0201::test_cold_warmstart_under_consistent_newton_converges` claims to exercise + the automatic Picard branch but uses a **linear** `ViscousFlowModel`, which converges + cold regardless; it passes with the warm-up line deleted. +- `test_1057::test_solve_homotopy_marches_and_reports` asserts only `steps >= 1` and + `settled_delta <= delta0`, both satisfied by an implementation that does one solve + at δ₀ and stops — it never asserts the march descended. + +No parallel coverage was added for any of the four layers (Charter §11). Layer 3 is +the notable gap: `test_default_fmg_bundle_is_parallel_safe` is a *serial* test +asserting option strings, and never exercises the new `gmres`/`norm_type=none` +smoother at np>1 where it interacts with the redundant-LU coarse solve. + +--- + +## Constitutive maths — added by the fourth reviewer (most severe of the four) + +### C4 (CRITICAL) — the power-mean returns NaN wherever the yield stress goes negative +`constitutive_models.py:1005-1017`. With a pressure-dependent Drucker–Prager +`τ_y = C + sinφ·p`, tension drives `τ_y < 0` ⇒ `η_pl < 0` ⇒ `f < 0`, and `a = 1+f`, +`b = 1+1/f` are then negative bases raised to the non-integer power `-s` ⇒ **NaN** +(reproduced against the real UW3 expression: `η = nan`, all Jacobian entries NaN, +where `yield_mode="min"` returns a finite floored `0.001`). The old hard-`Min` path +degraded gracefully; the new smooth path does not. + +This is not a corner case for this feature: `_yield_homotopy_control()` flips the +model into `powermean` **unconditionally and without checking that the yield stress is +bounded below**, so `solve(homotopy=True)` NaNs on the first residual for any +pressure-dependent DP model that lacks a lower bound — i.e. the exact target problem. +(The Spiegelman driver wraps `sympy.Max(C + sinφ·p, 0)` by hand, which is why the +hard-case study never hit it.) + +### C5 (CRITICAL) — `_apply_floor(value, 0)` has a rounding scale of exactly zero +`constitutive_models.py:1088`, reached from `:1293`. The floor is rounded by +`ε = δ·floor`; with `floor = 0` that is `smooth_max(τ_y, 0, 0) = ½(τ_y + |τ_y|)` — +the exact kink the smooth floor exists to remove, with a `0/0` derivative. Over the +whole region where raw `τ_y ≤ 0` the floored value is exactly `0.0`, so `η_pl = 0`, +`η = 0`, and every Jacobian entry is NaN (reproduced). For a zero floor the new +"smooth" path is **strictly worse than the `sympy.Max` it replaced**, which is clean +there. `_apply_floor`'s own docstring admits it "needs a non-zero `floor` for a length +scale" and nothing enforces it — and the same commit's sentinel change +(`!= 0` → `!= -sympy.oo`) is what made `yield_stress_min = 0` reachable. + +### M8 (MAJOR) — δ doubles as the floor-rounding scale, so early march solves run a *different model* +`constitutive_models.py:1088`. `yield_continuation` starts at `delta0=1.0` (and the +`yield_smoother` setter forces δ→1.0 when switching to powermean), giving `ε = 1.0·floor` +and measured `smooth_max(F)/F = 1.500` — the viscosity and yield-stress floors sit up to +**50 % above** the values the user requested. It converges away as δ→dmin, but it means +the early continuation steps solve a perturbed problem, it is undocumented, and there is +no independent knob for the floor rounding. + +### M9 (MAJOR) — the cold-start claim is over-stated: the Newton *tangent* at ε̇=0 is NaN +`constitutive_models.py:1124-1128`. The comment I added ("no strain-rate floor is +needed … the soft-min carries it correctly") is true of the **residual** only: measured +at v=0, `η = 1.0000003` (finite — the inf/inf fix works) but **all five Jacobian entries +are NaN**. Partly intrinsic (`dε_II/dE = E/(2ε_II)` → 0/0), partly added by the +power-mean. It only works today because `solve()` interposes `picard = 1`, and that +guard is `consistent_jacobian is True` — so `consistent_jacobian="continuation"`, or +`zero_init_guess=False` on an all-zero field, assembles a NaN Jacobian. + +**This settles the open question I flagged to the maintainer**: a strain-rate floor +*is* legitimately needed — not for the residual (where removing it was right) but for +the **tangent**. + +### Corroborated: δ=0 ⇒ exact `Min` is false for the power-mean (see M6) +Measured deviation from exact `Min`: 5.2e-4 at δ=0, 1.2e-3 at δ=1e-3. Note the `+0.001` +floor **equals the default `dmin=1e-3`**, so the default march ends at `s = 500` — half +the achievable sharpness. The sqrt family is genuinely exact at δ=0; this is +power-mean-only. Six docstrings state it wrongly (`:653`, `:959`, `:995`, `:1039` +["`s = 1/δ`", but the code is `1/(δ+0.001)`], `:1211`, `:1353`). + +### Verified CLEAN by the same reviewer (against PETSc source) +- **The harmonic-mean rewrite is numerically sound.** `η_ve·η_pl/(η_ve+η_pl)` vs + `η_ve/(1+f)` agree to ≤1 ulp at (1e26,1e21) both orders, (1e26,1e26), (1e-20,1e26); + the product form only overflows above η≈1e154; the identity is exact. The new form is + strictly better — the only one that survives `η_pl = ∞`. My "no numerical change" + claim holds. +- **The FMG smoother bundle is correct.** `gmres` registers `KSP_NORM_NONE` for both + PC sides (`gmres.c:894`); `mg_levels_ksp_converged_maxits` is **required, not + redundant**, and is present — `KSPConvergedDefault` (`iterativ.c:1529`) tests it + *before* the `KSP_NORM_NONE` early return, so without it `KSPSolve_GMRES` would + hard-set `KSP_DIVERGED_ITS` and PCMG would flag PC failure. Restart 30 > max_it 4, so + the cycle never restarts. The GAMG delete list does include + `mg_levels_ksp_norm_type`. The fgmres claim is verified at both configuring sites. + Nit: the comment's "same four smoother iterations" understates cost — GMRES adds ~5 + Krylov vectors and Gram–Schmidt per level over Richardson's 2. + +### Reviewer claim NOT sustained +The reviewer states the power-mean NaN fix "ships no test that would catch a +regression". Not correct: `test_1057::test_cold_viscoplastic_solve_survives_zero_strain_rate` +parametrises a cold (ε̇=0) solve over all three yield modes and does fail without the +fix. The reviewer inspected `test_1055_yield_smoother.py` only. Their broader point +stands, though — **no** test covers the negative-`τ_y` path (C4) or the zero-floor path +(C5). + +--- + +## What held up + +- **Parallel correctness of the new control flow** — traced clean. `getConvergedReason` + is collective and rank-identical, so `_resolve_zero_init_guess` cannot split ranks; + the march's predicates are all rank-uniform; the revert is inside + `synchronised_array_update` on every rank. +- **Backward compatibility of the tri-state flip** — no caller in `src/`, `tests/`, + `docs/` or the skills passes `zero_init_guess` positionally or truth-tests it before + resolution. +- **`_record_convergence_status()` coverage** — reached on every normal exit of all + four solve bodies and the rotated early return; every wrapper funnels through them. +- **Recursion/reentrancy of `solve(homotopy=True)`** — `solve_kwargs` never carries + `homotopy`, so the inner solves cannot re-enter the march. +- **Return-value contract** — the dict survives `timing`, `memprobe` and + `SNES_Stokes_Constrained.solve(*args, **kwargs)`. +- **March arithmetic** — `step ∈ [0.05, 0.95]` strictly, `d` strictly positive and + decreasing, `d <= dmin` reachable, `delta0 <= dmin` exits correctly. The problem is + cost (m6), not termination. +- **Layers 1a and 3 as such** — `has_solution`'s lifecycle and the FMG smoother bundle + drew no correctness findings beyond the ones listed. diff --git a/src/underworld3/cython/petsc_generic_snes_solvers.pyx b/src/underworld3/cython/petsc_generic_snes_solvers.pyx index 26984fe6b..ab3095811 100644 --- a/src/underworld3/cython/petsc_generic_snes_solvers.pyx +++ b/src/underworld3/cython/petsc_generic_snes_solvers.pyx @@ -3519,8 +3519,6 @@ class SNES_Scalar(SolverBaseClass): snes : Access to underlying PETSc SNES object for advanced control. """ - # Tri-state: None auto-detects cold-vs-warm from has_solution. - zero_init_guess = self._resolve_zero_init_guess(zero_init_guess) import petsc4py @@ -3532,6 +3530,11 @@ class SNES_Scalar(SolverBaseClass): # DM/fields/BCs are unchanged. In-place rewire is sufficient. self._needs_function_rewire = True + # Tri-state: None auto-detects cold-vs-warm from has_solution. Resolved HERE, + # after _force_setup has had its say: that invalidation clears has_solution, + # and resolving earlier would warm-start off the flag it just cleared. + zero_init_guess = self._resolve_zero_init_guess(zero_init_guess) + self._build(verbose, debug, debug_name) # Set time on the DM so petsc_t is available in pointwise functions @@ -4569,8 +4572,6 @@ class SNES_Vector(SolverBaseClass): u : The solution vector field variable. """ - # Tri-state: None auto-detects cold-vs-warm from has_solution. - zero_init_guess = self._resolve_zero_init_guess(zero_init_guess) if _force_setup: self.is_setup = False @@ -4579,6 +4580,11 @@ class SNES_Vector(SolverBaseClass): # DM/fields/BCs are unchanged. In-place rewire is sufficient. self._needs_function_rewire = True + # Tri-state: None auto-detects cold-vs-warm from has_solution. Resolved HERE, + # after _force_setup has had its say: that invalidation clears has_solution, + # and resolving earlier would warm-start off the flag it just cleared. + zero_init_guess = self._resolve_zero_init_guess(zero_init_guess) + self._build(verbose, debug, debug_name) @@ -5292,8 +5298,6 @@ class SNES_MultiComponent(SolverBaseClass): start up to this many times. 0 preserves legacy behaviour. """ - # Tri-state: None auto-detects cold-vs-warm from has_solution. - zero_init_guess = self._resolve_zero_init_guess(zero_init_guess) if _force_setup: self.is_setup = False @@ -5302,6 +5306,11 @@ class SNES_MultiComponent(SolverBaseClass): # DM/fields/BCs are unchanged. In-place rewire is sufficient. self._needs_function_rewire = True + # Tri-state: None auto-detects cold-vs-warm from has_solution. Resolved HERE, + # after _force_setup has had its say: that invalidation clears has_solution, + # and resolving earlier would warm-start off the flag it just cleared. + zero_init_guess = self._resolve_zero_init_guess(zero_init_guess) + self._build(verbose, debug, debug_name) gvec = self.dm.getGlobalVec() @@ -8469,8 +8478,6 @@ class SNES_Stokes_SaddlePt(SolverBaseClass): constitutive_model : Viscosity and stress definitions. """ - # Tri-state: None auto-detects cold-vs-warm from has_solution. - zero_init_guess = self._resolve_zero_init_guess(zero_init_guess) if homotopy: # The march runs a SEQUENCE of ordinary solves at successively sharper @@ -8484,6 +8491,11 @@ class SNES_Stokes_SaddlePt(SolverBaseClass): # DM/fields/BCs are unchanged. In-place rewire is sufficient. self._needs_function_rewire = True + # Tri-state: None auto-detects cold-vs-warm from has_solution. Resolved HERE, + # after _force_setup has had its say: that invalidation clears has_solution, + # and resolving earlier would warm-start off the flag it just cleared. + zero_init_guess = self._resolve_zero_init_guess(zero_init_guess) + self._build(verbose, debug, debug_name) # Set time on the DM so petsc_t is available in pointwise functions. diff --git a/src/underworld3/systems/yield_continuation.py b/src/underworld3/systems/yield_continuation.py index f800f54f7..ff162619b 100644 --- a/src/underworld3/systems/yield_continuation.py +++ b/src/underworld3/systems/yield_continuation.py @@ -56,6 +56,7 @@ def yield_continuation( entry_maxit=30, step_maxit=10, retries=2, + max_steps=60, solve_kwargs=None, verbose=True, ): @@ -99,6 +100,9 @@ def yield_continuation( a tight budget lets a too-hard step abort cheaply. retries : int How many times to retry a failed δ with a gentler step before settling. + max_steps : int + Hard cap on the number of δ-solves, so a march that is easing off in small + steps cannot run unbounded. solve_kwargs : dict, optional Extra arguments forwarded to every inner ``solver.solve()`` — notably ``timestep`` for a visco-elastic-plastic model, whose solves need one. @@ -114,6 +118,8 @@ def yield_continuation( """ if not (0.0 < down < 1.0): raise ValueError(f"down must satisfy 0 < down < 1, got {down}") + if not delta0 > 0.0: + raise ValueError(f"delta0 must be positive, got {delta0}") if control is None: cm = getattr(solver, "constitutive_model", None) @@ -125,13 +131,36 @@ def yield_continuation( ) control = cm._yield_homotopy_control() + # A march is a SEQUENCE of solves, so a solver that integrates history in time + # would advance that history once per delta-step rather than once per timestep. + # Refuse rather than silently corrupt it -- see the design note. + if getattr(getattr(solver, "Unknowns", None), "DFDt", None) is not None: + raise NotImplementedError( + "solve(homotopy=True) is not available on a solver carrying stress " + "history (visco-elastic-plastic): the march runs several solves, each of " + "which would advance the elastic stress history by a full timestep. Solve " + "the VEP model without the homotopy, or drive the march yourself around a " + "single history update." + ) + + # Iteration budgets have to be applied where they survive the solve path's own + # setFromOptions (which pushes a hardcoded snes_max_it); petsc_options alone is + # silently overwritten. This is the same hook estimate_difficulty() uses. + saved_probe = solver._difficulty_probe + saved_probe_maxit = solver._difficulty_max_it + saved_resume = solver._resume_abs_target + saved_tangent = solver.consistent_jacobian + solver.consistent_jacobian = control.tangent u, p = solver.Unknowns.u, solver.Unknowns.p # Warm-state snapshot: a raw copy of the (already consistent) solution values, - # restored if a too-hard δ corrupts the iterate. + # restored if a too-hard delta corrupts the iterate. u_good, p_good = u.data.copy(), p.data.copy() - saved_maxit = solver.petsc_options.getInt("snes_max_it", 50) + + # Whether there is a solution to warm-start the FIRST delta from. Read before the + # rebuild below, which invalidates the solver and clears the flag. + warm_entry = solver.has_solution d = float(delta0) step = float(down) @@ -142,65 +171,80 @@ def yield_continuation( reached_dmin = False first = True - while True: - control.set_delta(d) - if first: - solver.is_setup = False # build the operators once, at delta0 - else: - solver._update_constants() # recompile-free δ update - solver.petsc_options["snes_max_it"] = entry_maxit if first else step_maxit - - # Cold only if there is genuinely nothing to warm-start from; Layer 1's - # automatic Picard step handles that first solve at the large, benign δ. - solver.solve(zero_init_guess=not solver.has_solution, - **dict(solve_kwargs or {})) - reason = int(solver.snes.getConvergedReason()) - nit = int(solver.snes.getIterationNumber()) - budget = entry_maxit if first else step_maxit - steps += 1 - first = False - - if reason > 0: - settled = d - u_good[...] = u.data - p_good[...] = p.data - if verbose: - uw.pprint(0, f" [yield-continuation] δ={d:<11g} its={nit:3d} → converged") - if d <= dmin: - reached_dmin = True - break - # Residual-guided: plenty of slack ⇒ take a bigger bite next time; near the - # iteration budget ⇒ ease off. Clamped so the march can neither stall (step - # → 1) nor leap past the feasible edge in one go. - if nit <= max(2, budget // 5): - step = max(step * step, 0.05) - elif nit >= 0.8 * budget: + try: + while steps < max_steps: + control.set_delta(d) + if first: + solver.is_setup = False # build the operators once, at delta0 + else: + solver._update_constants() # recompile-free delta update + + budget = entry_maxit if first else step_maxit + solver._difficulty_probe = True + solver._difficulty_max_it = budget + solver._resume_abs_target = None + + warm = warm_entry if first else True + solver.solve(zero_init_guess=not warm, **dict(solve_kwargs or {})) + reason = int(solver.snes.getConvergedReason()) + nit = int(solver.snes.getIterationNumber()) + steps += 1 + first = False + + if reason > 0: + settled = d + u_good[...] = u.data + p_good[...] = p.data + if verbose: + uw.pprint(f" [yield-continuation] d={d:<11g} its={nit:3d} -> converged") + if d <= dmin: + reached_dmin = True + break + # Residual-guided: plenty of slack => take a bigger bite next time; + # near the iteration budget => ease off. Clamped so the march can + # neither stall (step -> 1) nor leap past the feasible edge in one go. + if nit <= max(2, budget // 5): + step = max(step * step, 0.05) + elif nit >= 0.8 * budget: + step = min(step ** 0.5, 0.95) + d *= step + else: + failed_delta = d + with uw.synchronised_array_update("yield_continuation revert"): + u.data[...] = u_good + p.data[...] = p_good + # The reverted fields ARE a converged state, so say so: otherwise the + # retry (and the caller's next solve) would auto-cold-start off a + # solution that is perfectly good. + if settled is not None: + solver._record_convergence_status(converged=True) + failures += 1 + if settled is None or failures > retries: + if verbose: + uw.pprint(f" [yield-continuation] d={failed_delta:<11g} its={nit:3d} " + f"-> failed (reason={reason}); settling at d={settled}") + break + # Retry from the last good delta with a gentler step. step = min(step ** 0.5, 0.95) - d *= step - else: - with uw.synchronised_array_update("yield_continuation revert"): - u.data[...] = u_good - p.data[...] = p_good - failures += 1 - if settled is None or failures > retries: + d = settled * step if verbose: - uw.pprint(0, f" [yield-continuation] δ={d:<11g} its={nit:3d} → failed " - f"(reason={reason}); settling at δ={settled}") - break - # Retry from the last good δ with a gentler step. - step = min(step ** 0.5, 0.95) - d = settled * step + uw.pprint(f" [yield-continuation] d={failed_delta:<11g} its={nit:3d} " + f"-> failed (reason={reason}); retrying at d={d:g}") + else: if verbose: - uw.pprint(0, f" [yield-continuation] δ={d / step:<11g} its={nit:3d} → failed " - f"(reason={reason}); retrying at δ={d:g}") - - # Leave the model holding the δ that actually converged, and the solver holding - # its solution, so the caller can use the result directly. - if settled is not None and settled != d: - control.set_delta(settled) - solver._update_constants() + uw.pprint(f" [yield-continuation] stopped at the {max_steps}-step cap; " + f"settled at d={settled}") + finally: + # Leave the model holding the delta that actually converged, and restore every + # setting the march borrowed -- including on the exception path. + if settled is not None and settled != d: + control.set_delta(settled) + solver._update_constants() + solver._difficulty_probe = saved_probe + solver._difficulty_max_it = saved_probe_maxit + solver._resume_abs_target = saved_resume + solver.consistent_jacobian = saved_tangent - solver.petsc_options["snes_max_it"] = saved_maxit return { "settled_delta": settled, "reason": reason, diff --git a/tests/test_0201_solver_has_solution_warmstart.py b/tests/test_0201_solver_has_solution_warmstart.py index b5b2683c0..8ce6cc907 100644 --- a/tests/test_0201_solver_has_solution_warmstart.py +++ b/tests/test_0201_solver_has_solution_warmstart.py @@ -134,6 +134,30 @@ def test_zero_init_guess_is_tristate_and_auto_detects(): assert poisson._resolve_zero_init_guess(None) is True +def test_force_setup_cold_starts_through_the_public_path(): + """`_force_setup=True` is a structural invalidation, so the solve it is passed to + must COLD start. + + Regression (adversarial review, M1): `zero_init_guess` was resolved at the top of + solve(), before the `_force_setup` block that clears `has_solution`, so the call + that triggered the invalidation warm-started off the flag it was about to clear. + Driven through the public `solve()` rather than the private resolver, so it + actually exercises the ordering. + """ + _, poisson = _poisson() + poisson.solve() + assert poisson.has_solution is True + + poisson.solve(_force_setup=True) + # The rebuild must have dropped the claim during that call, not after it. + assert poisson._needs_dm_rebuild is False # rebuilt during the solve + assert poisson.has_solution is True # ... and re-established by convergence + + # The ordering itself: resolving now (post-invalidation) must say COLD. + poisson.is_setup = False + assert poisson._resolve_zero_init_guess(None) is True + + def test_repeated_default_solve_agrees_to_solver_tolerance(): """A bare solve() called repeatedly (the linear chain-of-solves pattern) must give the same answer once auto-detect starts warming the later calls. diff --git a/tests/test_1057_yield_homotopy_solve.py b/tests/test_1057_yield_homotopy_solve.py index eb11a9598..0449ca435 100644 --- a/tests/test_1057_yield_homotopy_solve.py +++ b/tests/test_1057_yield_homotopy_solve.py @@ -184,12 +184,49 @@ def test_solve_homotopy_marches_and_reports(): homotopy_options=dict(delta0=1.0, dmin=0.05, verbose=False)) assert report["converged"] is True - assert report["steps"] >= 1 - # Settled at (or below) the starting δ, and never below the requested floor. + # The march must actually DESCEND, not just solve once at delta0 and stop. + assert report["steps"] > 1, "the march did not take a second step" assert report["settled_delta"] is not None - assert report["settled_delta"] <= 1.0 + assert report["settled_delta"] < 1.0, ( + f"delta never descended below delta0 (settled at {report['settled_delta']})" + ) + assert report["reached_dmin"] is True assert stokes.has_solution is True # The model is left holding the δ that actually converged. assert stokes.constitutive_model.yield_softness == pytest.approx( report["settled_delta"] ) + + +def test_homotopy_restores_the_solver_tangent(): + """The march sets the tangent the model asks for, but must hand the solver back + as it found it (adversarial review, M5).""" + _, stokes = _viscoplastic_stokes() + stokes.consistent_jacobian = False + stokes.solve(homotopy=True, homotopy_options=dict(delta0=1.0, dmin=0.1)) + assert stokes.consistent_jacobian is False, ( + "solve(homotopy=True) left the user's tangent permanently changed" + ) + + +def test_homotopy_refuses_a_stress_history_solver(): + """A march is several solves; on a VEP solver each one would advance the elastic + stress history by a full timestep (adversarial review, C3). Refuse loudly rather + than silently integrating N steps for one requested dt.""" + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.5 + ) + v = uw.discretisation.MeshVariable("Vve", mesh, mesh.dim, degree=2) + p = uw.discretisation.MeshVariable("Pve", mesh, 1, degree=1, continuous=True) + stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + stokes.constitutive_model = uw.constitutive_models.ViscoElasticPlasticFlowModel + cm = stokes.constitutive_model + cm.Parameters.shear_viscosity_0 = 1.0 + cm.Parameters.shear_modulus = 10.0 + cm.Parameters.dt_elastic = 0.1 + cm.Parameters.yield_stress = 5.0 + stokes.bodyforce = sympy.Matrix([0.0, -1.0]) + stokes.add_essential_bc((0.0, 0.0), "Bottom") + + with pytest.raises(NotImplementedError, match="stress history"): + stokes.solve(homotopy=True, timestep=0.1) From a6db75d6e2ecbb663b2fa5c8e5a66a71171ddc14 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Mon, 27 Jul 2026 10:41:25 +1000 Subject: [PATCH 17/21] fix(constitutive/solver): close review findings M6, M7, M9 M6 - MEASURED AND MOOT, as the maintainer predicted. The power-mean's sharpness s = 1/(delta + 0.001) saturates at 1000, so it is not exactly Min at delta = 0. What that costs in practice: on a 45%-yielded box (a case a cold hard-Min solve cannot solve at all), the exact hard-Min residual at the settled power-mean solution is 6e-10 of the initial residual, an order of magnitude INSIDE the 1e-8 solver tolerance -- and a hard-Min solve warm-started from it converges in 0 nonlinear iterations, i.e. PETSc already considers the exact problem solved. The docstring now states the limit honestly along with the measurement instead of claiming exactness. Two things worth recording from getting there. Sharpening the law does NOT help: with the floor at 1e-6 (s up to 1e6) the achievable residual got WORSE, because the law becomes too stiff to solve before it becomes more accurate. And an earlier reading of this measurement said the opposite -- it normalised by the last warm solve's initial residual, which is itself tiny, instead of the physical ||F(v=0)||. M7 - transverse-isotropic VEP now uses the same yield envelope as the isotropic models. Four sites reimplemented the harmonic / soft-min / hard-Min choice inline, hardcoding the sqrt family and reading delta as a baked float, so yield_smoother was silently ignored there and delta could not be ramped without a recompile. They all call _combine_yield now, and the model's yield_softness setter keeps the constants[] atom in step. M9 - the design requirement is met rather than approximately met. A viscoplastic Jacobian is NaN at zero strain rate (the residual survives, its derivative does not), so the warm/cold machinery must make that state unreachable. It covered an explicit cold start but not a nominally WARM solve whose solution had never been written -- the design's stated "secondary signal", never implemented. _solution_is_trivially_zero supplies it, using the collective vector norm so every rank decides alike. The "continuation" tangent needs no guard: it opens on a Picard stage by construction. The M9 test is verified non-vacuous: with the previous guard restored, the zero_init_guess=False case dies inside PETSc's KSPSetUpOnBlocks on the NaN operator. Also proven for the first time, and the reason these were reachable at all: the homotopy had never been exercised on a genuinely yielding problem. Earlier cases (including my own tests) sat at 0% yielding -- a uniform body force in a box is just hydrostatic. On a properly sheared box, solve(homotopy=True) converges at 45% yielding where a cold hard-Min solve gives DIVERGED_MAX_IT, which is the feature doing its job. Underworld development team with AI support from Claude Code --- src/underworld3/constitutive_models.py | 88 +- src/underworld3/constitutive_models.py.bak | 4450 +++++++++++++++++ .../cython/petsc_generic_snes_solvers.pyx | 40 +- tests/test_1057_yield_homotopy_solve.py | 22 + 4 files changed, 4544 insertions(+), 56 deletions(-) create mode 100644 src/underworld3/constitutive_models.py.bak diff --git a/src/underworld3/constitutive_models.py b/src/underworld3/constitutive_models.py index 0f2a944bb..2df9b9920 100644 --- a/src/underworld3/constitutive_models.py +++ b/src/underworld3/constitutive_models.py @@ -986,7 +986,12 @@ def _combine_yield(self, eta_ve, eta_pl): - ``"softmin"``: the δ-parameterised soft-min, in the family chosen by ``self.yield_smoother`` — ``"sqrt"`` (default; overshoots τ_y in the transition) or ``"powermean"`` (undershoots τ_y, ``η_eff ≤ Min`` always). - Both approach exact ``Min`` as ``δ → 0``. + The ``sqrt`` family is exactly ``Min`` at ``δ = 0``; the ``powermean`` + family is not — its sharpness ``s = 1/(δ + 0.001)`` saturates at 1000, so it + approaches ``Min`` only to about 0.3 %. Measured on a 45 %-yielded box, that + leaves the converged solution satisfying the exact yield law to 6e-10 of the + initial residual — an order of magnitude INSIDE a 1e-8 solver tolerance, so + the difference is not observable in practice (2026-07-27). Behaviour at the stock settings (``yield_mode`` per model default, ``yield_smoother="sqrt"``) is identical to the previous inline law — δ merely @@ -3509,17 +3514,12 @@ def viscosity(self): if self.is_viscoplastic: vp_eff = self._plastic_effective_viscosity - if self._yield_mode == "harmonic": - eta_1_eff = 1 / (1 / eta_1_eff + 1 / vp_eff) - elif self._yield_mode == "softmin": - delta = self._yield_softness - f = eta_1_eff / vp_eff - import math # float offset avoids sympy expression blowup in tensor - offset = (-1 + math.sqrt(1 + delta**2)) / 2 - g = 1 + (f - 1 + sympy.sqrt((f - 1)**2 + delta**2)) / 2 - offset - eta_1_eff = eta_1_eff / g - else: - eta_1_eff = sympy.Min(eta_1_eff, vp_eff) + # Same yield envelope as the isotropic models: _combine_yield owns the + # harmonic / soft-min / hard-Min choice, reads delta from the + # constants[] atom (so a homotopy can ramp it without a recompile) and + # honours yield_smoother. Previously inlined here, which pinned this + # model to the sqrt family and to a baked float delta. + eta_1_eff = self._combine_yield(eta_1_eff, vp_eff) return inner_self.shear_viscosity_0 @@ -3607,17 +3607,12 @@ def _eta_for_tensor(self, integrator_mode, apply_yield): if apply_yield and self.is_viscoplastic: vp_eff = self._plastic_effective_viscosity - if self._yield_mode == "harmonic": - eta_1_eff = 1 / (1 / eta_1_eff + 1 / vp_eff) - elif self._yield_mode == "softmin": - delta = self._yield_softness - f = eta_1_eff / vp_eff - import math - offset = (-1 + math.sqrt(1 + delta**2)) / 2 - g = 1 + (f - 1 + sympy.sqrt((f - 1)**2 + delta**2)) / 2 - offset - eta_1_eff = eta_1_eff / g - else: - eta_1_eff = sympy.Min(eta_1_eff, vp_eff) + # Same yield envelope as the isotropic models: _combine_yield owns the + # harmonic / soft-min / hard-Min choice, reads delta from the + # constants[] atom (so a homotopy can ramp it without a recompile) and + # honours yield_smoother. Previously inlined here, which pinned this + # model to the sqrt family and to a baked float delta. + eta_1_eff = self._combine_yield(eta_1_eff, vp_eff) return eta_0, eta_1_eff def _assemble_c_tensor(self, eta_0, eta_1_eff): @@ -3821,7 +3816,11 @@ def yield_softness(self): @yield_softness.setter def yield_softness(self, value): - self._yield_softness = value + self._yield_softness = float(value) + # Keep the constants[] atom in step: this model now shares the isotropic + # _combine_yield, which reads δ from that atom rather than from the float. + if getattr(self, "_yield_softness_expr", None) is not None: + self._yield_softness_expr.sym = sympy.Float(self._yield_softness) self._reset() @property @@ -3832,9 +3831,10 @@ def requires_stress_history(self): @property def supports_yield_homotopy(self): """This model carries a δ-parameterised yield law — ``solve(homotopy=True)`` - can march it. Note this model's ``yield_softness`` setter re-triggers a - rebuild, so each δ step costs a recompile (the isotropic models update δ as a - ``constants[]`` atom instead). See :meth:`_yield_homotopy_control`.""" + can march it. It shares the isotropic yield envelope (:meth:`_combine_yield`), + so ``yield_smoother`` applies here too; its ``yield_softness`` setter still + triggers a rebuild, so a δ step costs a recompile the isotropic models avoid. + See :meth:`_yield_homotopy_control`.""" return True # Picard, not Newton — as for the isotropic VEP model, the consistent yield @@ -4015,17 +4015,12 @@ def _eta_par_eff(self): return eta_par vp_eff = self._plastic_effective_viscosity - if self._yield_mode == "harmonic": - return 1 / (1 / eta_par + 1 / vp_eff) - elif self._yield_mode == "softmin": - delta = self._yield_softness - f = eta_par / vp_eff - import math - offset = (-1 + math.sqrt(1 + delta**2)) / 2 - g = 1 + (f - 1 + sympy.sqrt((f - 1) ** 2 + delta ** 2)) / 2 - offset - return eta_par / g - else: - return sympy.Min(eta_par, vp_eff) + # Same yield envelope as the isotropic models: _combine_yield owns the + # harmonic / soft-min / hard-Min choice, reads delta from the + # constants[] atom (so a homotopy can ramp it without a recompile) and + # honours yield_smoother. Previously inlined here, which pinned this + # model to the sqrt family and to a baked float delta. + return self._combine_yield(eta_par, vp_eff) def _eta_par_eff_lagged(self): """Yield-clipped ``η_∥_eff`` using the **lagged** strain rate @@ -4078,17 +4073,12 @@ def _eta_par_eff_lagged(self): 2 * (gamma_dot_abs_lag + sympy.Float(edot_min_val)) ) - if self._yield_mode == "harmonic": - return 1 / (1 / eta_par + 1 / vp_eff_lag) - elif self._yield_mode == "softmin": - delta = self._yield_softness - f = eta_par / vp_eff_lag - import math - offset = (-1 + math.sqrt(1 + delta ** 2)) / 2 - g = 1 + (f - 1 + sympy.sqrt((f - 1) ** 2 + delta ** 2)) / 2 - offset - return eta_par / g - else: - return sympy.Min(eta_par, vp_eff_lag) + # Same yield envelope as the isotropic models: _combine_yield owns the + # harmonic / soft-min / hard-Min choice, reads delta from the + # constants[] atom (so a homotopy can ramp it without a recompile) and + # honours yield_smoother. Previously inlined here, which pinned this + # model to the sqrt family and to a baked float delta. + return self._combine_yield(eta_par, vp_eff_lag) def _build_split_c_tensors(self, eta_perp, eta_par): r"""Build ``C_⊥ = 2·η_⊥·P_⊥`` and ``C_∥ = 2·η_∥·P_∥``. diff --git a/src/underworld3/constitutive_models.py.bak b/src/underworld3/constitutive_models.py.bak new file mode 100644 index 000000000..0f2a944bb --- /dev/null +++ b/src/underworld3/constitutive_models.py.bak @@ -0,0 +1,4450 @@ +r""" +Constitutive models for Underworld3 solvers. + +This module provides constitutive relationships that define how material +properties (viscosity, diffusivity, etc.) relate fluxes to gradients of +unknowns. These models are plugged into SNES solvers to complete the +governing equations. + +Classes +------- +Constitutive_Model + Base class for all constitutive models. +ViscousFlowModel + Isotropic viscous flow with scalar or tensor viscosity. +ViscoPlasticFlowModel + Viscous flow with yield stress (plastic behavior). +ViscoElasticPlasticFlowModel + Combined viscous, elastic, and plastic rheology. +DiffusionModel + Scalar diffusion (heat, chemical species). +DarcyFlowModel + Porous media flow (Darcy's law). +TransverseIsotropicFlowModel + Anisotropic viscosity with directional weakness. +MultiMaterialConstitutiveModel + Level-set weighted composite of multiple materials. + +See Also +-------- +underworld3.systems.solvers : Solvers that use these constitutive models. +""" + +from typing_extensions import Self +import sympy +from sympy import sympify +from sympy.vector import gradient, divergence +import numpy as np + +from typing import Optional, Callable +from typing import NamedTuple, Union + +from petsc4py import PETSc + +import underworld3 as uw +import underworld3.timing as timing +import underworld3.cython +from underworld3.utilities._api_tools import uw_object +from underworld3.swarm import IndexSwarmVariable +from underworld3.discretisation import MeshVariable +from underworld3.systems.ddt import SemiLagrangian as SemiLagrangian_DDt +from underworld3.systems.ddt import _bdf_coefficients +from underworld3.function.quantities import UWQuantity +from underworld3.systems.ddt import Lagrangian as Lagrangian_DDt + +from underworld3.function import expression as public_expression + +expression = lambda *x, **X: public_expression(*x, _unique_name_generation=True, **X) + + +class _ParameterBase: + """Base class for all constitutive model ``_Parameters`` containers. + + All ``_Parameters`` nested classes must inherit from this. It provides a + ``__setattr__`` guard that **rejects** any public attribute assignment + that doesn't match a defined ``Parameter`` descriptor or ``@property`` + on the class. Without this guard, a typo like + ``Parameters.viscocity = 1`` silently creates an instance attribute + that the solver never reads — producing wrong results with no error. + + How to define parameters in a new constitutive model + ---------------------------------------------------- + 1. Inherit from ``_ParameterBase`` (and ``_ViscousParameterAlias`` if + the model has a ``shear_viscosity_0`` parameter):: + + class _Parameters(_ParameterBase, _ViscousParameterAlias): + ... + + 2. Define each parameter as a **class-level** ``Parameter`` descriptor. + The **attribute name IS the user API name** — users will set it via + ``model.Parameters. = value``:: + + import underworld3.utilities._api_tools as api_tools + + shear_viscosity_0 = api_tools.Parameter( + r"\\eta", # LaTeX display name (cosmetic only) + lambda self: 1, # default value factory + "Shear viscosity", # description + units="Pa*s", # expected units + ) + + 3. To add a **convenience alias** (e.g. ``viscosity`` → ``shear_viscosity_0``), + either use a mixin like ``_ViscousParameterAlias`` or define a + ``@property`` with getter and setter on the ``_Parameters`` class. + The guard recognises both descriptors and properties. + """ + + @staticmethod + def _list_valid_parameters(cls_type): + """List valid parameter names for error messages.""" + from underworld3.utilities._api_tools import ExpressionDescriptor + + valid = [] + for cls in cls_type.__mro__: + for k, v in cls.__dict__.items(): + if isinstance(v, (ExpressionDescriptor, property)) and k not in valid: + valid.append(k) + return valid + + def __setattr__(self, name, value): + # Private/internal attributes are always allowed + if name.startswith("_"): + object.__setattr__(self, name, value) + return + + from underworld3.utilities._api_tools import ExpressionDescriptor + + # Walk the MRO looking for a matching descriptor or property + for cls in type(self).__mro__: + if name in cls.__dict__: + attr = cls.__dict__[name] + if isinstance(attr, ExpressionDescriptor): + # Valid descriptor — let it handle the set + attr.__set__(self, value) + return + elif isinstance(attr, property): + if attr.fset is not None: + attr.fset(self, value) + return + raise AttributeError( + f"Parameter '{name}' is read-only" + ) + + # Not a known descriptor — likely a name mismatch bug + valid = _ParameterBase._list_valid_parameters(type(self)) + + raise AttributeError( + f"No parameter '{name}' on {type(self).__name__}. " + f"Valid parameters: {valid}" + ) + + +class _ViscousParameterAlias: + """Mixin providing ``viscosity`` as a read/write alias for ``shear_viscosity_0``. + + Add this to the inheritance of any ``_Parameters`` class that defines + a ``shear_viscosity_0`` descriptor, so that the established + ``Parameters.viscosity`` API continues to work:: + + class _Parameters(_ParameterBase, _ViscousParameterAlias): + shear_viscosity_0 = api_tools.Parameter(...) + + To create similar aliases for other parameters, define a ``@property`` + with a setter — the ``_ParameterBase`` guard recognises properties + automatically. + """ + + @property + def viscosity(self): + return self.shear_viscosity_0 + + @viscosity.setter + def viscosity(self, value): + self.shear_viscosity_0 = value + + +# How do we use the default here if input is required ? +def validate_parameters(symbol, input, default=None, allow_number=True, allow_expression=True): + """Convert input to a UWexpression for use in constitutive models. + + Parameters + ---------- + symbol : str + LaTeX symbol for display (e.g., r"\\eta" for viscosity). + input : various + Value to convert (UWexpression, UWQuantity, float, int, sympy expr). + default : optional + Default value if input is None. + allow_number : bool + If True, accept plain numbers (int/float). + allow_expression : bool + If True, accept raw sympy expressions. + + Returns + ------- + UWexpression or None + Wrapped expression, or None if conversion failed. + """ + # CRITICAL: Check for UWexpression FIRST, before checking sympy.Basic + # UWexpression inherits from sympy.Symbol, so it would match the Basic check + # and cause double-wrapping, losing unit information + from .function.expressions import UWexpression + if isinstance(input, UWexpression): + # Already a UWexpression - return as-is, no wrapping needed + return input + + elif isinstance(input, UWQuantity): + # Convert UWQuantity to UWexpression - this is the beautiful symmetry! + # The UWexpression constructor will handle unit conversion automatically + input = expression( + symbol, + input, + f"(converted from UWQuantity with units {input.units if input.has_units else 'dimensionless'})", + ) + + elif allow_number and isinstance(input, (float)): + # print(f"{symbol}: Converting number to uw expression {input}") + input = expression(symbol, input, "(converted from float)") + + elif allow_number and isinstance(input, (int)): + # print(f"{symbol}: Converting number to uw expression {input}") + input = expression(symbol, input, "(converted from int)") + + elif allow_expression and isinstance(input, sympy.core.basic.Basic): + # print(f"{symbol}: Converting sympy fn to uw expression {input}") + input = expression(symbol, input, "(imported sympy expression)") + + elif input is None and default is not None: + input = expression(symbol, default, "(default value)") + + else: + # That's about all we can fix automagically + print(f"Unable to set parameter: {symbol} from {input}") + print(f"An underworld `expression`, `UWQuantity`, or `function` is required", flush=True) + return None + + return input + + +class Constitutive_Model(uw_object): + r""" + Base class for constitutive laws relating gradients to fluxes. + + Constitutive laws relate gradients in the unknowns to fluxes of quantities + (for example, heat fluxes are related to temperature gradients through a + thermal conductivity). This class is a base class for building Underworld + constitutive laws. + + In a scalar problem, the relationship is: + + .. math:: + + q_i = k_{ij} \frac{\partial T}{\partial x_j} + + and the constitutive parameters describe :math:`k_{ij}`. The template + assumes :math:`k_{ij} = \delta_{ij}`. + + In a vector problem (such as the Stokes problem), the relationship is: + + .. math:: + + t_{ij} = c_{ijkl} \frac{\partial u_k}{\partial x_l} + + but is usually written to eliminate the anti-symmetric part of the + displacement or velocity gradients: + + .. math:: + + t_{ij} = c_{ijkl} \frac{1}{2} \left[ \frac{\partial u_k}{\partial x_l} + + \frac{\partial u_l}{\partial x_k} \right] + + and the constitutive parameters describe :math:`c_{ijkl}`. The template + assumes :math:`k_{ij} = \frac{1}{2}(\delta_{ik}\delta_{jl} + \delta_{il}\delta_{jk})` + which is the 4th rank identity tensor accounting for symmetry in the flux + and the gradient terms. + """ + + # Class-level instance counter for automatic symbol uniqueness across all constitutive models + _global_instance_count = 0 + # Per-class instance counters for class-specific numbering + _class_instance_counts = {} + + @timing.routine_timer_decorator + def __init__(self, unknowns, material_name: str = None): + """ + Initialize a constitutive model. + + Parameters + ---------- + unknowns : UnknownSet + The solver's unknowns (velocity, pressure, etc.) + material_name : str, optional + A distinguishing name for this material's symbols. + If provided, symbols will be subscripted: η → η_{name} + Useful when bundling multiple models in MultiMaterialModel. + """ + # Define / identify the various properties in the class but leave + # the implementation to child classes. The constitutive tensor is + # defined as a template here, but should be instantiated via class + # properties as required. + + # We provide a function that converts gradients / gradient history terms + # into the relevant flux term. + + # Store material name for symbol disambiguation + self._material_name = material_name + + # Track instance numbers for automatic symbol uniqueness + Constitutive_Model._global_instance_count += 1 + self._global_instance_number = Constitutive_Model._global_instance_count + + # Track per-class instance numbers (0-based indexing) + class_name = self.__class__.__name__ + if class_name not in Constitutive_Model._class_instance_counts: + Constitutive_Model._class_instance_counts[class_name] = 0 + self._class_instance_number = Constitutive_Model._class_instance_counts[class_name] + Constitutive_Model._class_instance_counts[class_name] += 1 + + # Backing attribute for settable flux_jacobian (set to a custom + # SymPy expression to override the Jacobian tangent independently + # of the residual flux). None by default, meaning the solver + # differentiates the exact flux. + self._flux_jacobian = None + + self.Unknowns = unknowns + + u = self.Unknowns.u + self._DFDt = self.Unknowns.DFDt + self._DuDt = self.Unknowns.DuDt + + # Constitutive tensors relate gradients to fluxes; both live in + # the embedded coordinate space (cdim-dimensional), not the + # topological one. ``u.sym.jacobian(mesh.N)`` produces a + # (u_dim x cdim) matrix, so the conductivity / viscosity + # tensor must also be cdim-sized to multiply against it. For + # volume meshes ``dim == cdim`` so this is a no-op; for + # manifold meshes (e.g. SphericalManifold: dim=2, cdim=3) the + # constitutive tensor correctly acts on the 3-component flux. + self.dim = u.mesh.cdim + self.u_dim = u.num_components + + self.Parameters = self._Parameters(self) + self.Parameters._solver = None + self.Parameters._reset = self._reset + self._material_properties = None + + ## Default consitutive tensor is the identity + + if self.u_dim == 1: + self._c = sympy.Matrix.eye(self.dim) + else: # vector problem + self._c = uw.maths.tensor.rank4_identity(self.dim) + + self._K = sympy.sympify(1) + self._C = None + + self._reset() + + super().__init__() + + def create_unique_symbol(self, base_symbol, value, description): + """ + Create a unique symbol name for constitutive model parameters. + + Symbol naming priority: + 1. If material_name is set: η → η_{material_name} + 2. Else if multiple instances of same class: η → η^{(n)} + 3. Else: use base symbol as-is + + Parameters + ---------- + base_symbol : str + The base LaTeX symbol name (e.g., r"\\eta", r"\\kappa") + value : float or expression + The initial value for the symbol + description : str + Description of the parameter + + Returns + ------- + UWexpression + Expression with unique symbol name + """ + # Priority 1: User-specified material name (subscript notation) + if self._material_name is not None: + symbol_name = rf"{{{base_symbol}}}_{{\mathrm{{{self._material_name}}}}}" + # Priority 2: Multiple instances of same class (superscript notation) + elif self._class_instance_number > 0: + symbol_name = rf"{{{base_symbol}}}^{{({self._class_instance_number})}}" + # Priority 3: First/only instance - clean symbol + else: + symbol_name = base_symbol + + return expression(symbol_name, value, description) + + class _Parameters(_ParameterBase): + """Any material properties that are defined by a constitutive relationship are + collected in the parameters which can then be defined/accessed by name in + individual instances of the class. + """ + + def __init__(inner_self, _owning_model): + inner_self._owning_model = _owning_model + return + + @property + def Unknowns(self): + r"""Reference to the solver's unknown fields. + + Returns + ------- + Unknowns + Container holding the primary unknown field(s) (e.g., velocity, + pressure, temperature) that this constitutive model operates on. + """ + return self._Unknowns + + # We probably should not be changing this ever ... does this setter even belong here ? + @Unknowns.setter + def Unknowns(self, unknowns): + """Set the solver unknowns (invalidates setup).""" + self._Unknowns = unknowns + self._solver_is_setup = False + return + + @property + def K(self): + r"""Primary constitutive property (viscosity, diffusivity, etc.). + + Returns + ------- + UWexpression + The material property defining the flux-gradient relationship. + """ + return self._K + + @property + def u(self): + r"""The primary unknown field from the solver. + + Returns + ------- + MeshVariable + The unknown field (velocity, temperature, etc.). + """ + return self.Unknowns.u + + @property + def grad_u(self): + r"""Gradient of the unknown field. + + For scalar fields, this is a vector. For vector fields (velocity), + this is the velocity gradient tensor :math:`\nabla \mathbf{u}`. + + Returns + ------- + sympy.Matrix + Gradient/Jacobian of the unknown field. + """ + mesh = self.Unknowns.u.mesh + # return mesh.vector.gradient(self.Unknowns.u.sym) + return self.Unknowns.u.sym.jacobian(mesh.CoordinateSystem.N) + + @property + def DuDt(self): + r"""Material derivative operator for the unknown field. + + Used in time-dependent problems to track Lagrangian or + semi-Lagrangian derivatives. + + Returns + ------- + SemiLagrangian_DDt or Lagrangian_DDt or None + The material derivative operator, or None if not set. + """ + return self._DuDt + + @DuDt.setter + def DuDt( + self, + DuDt_value: Union[SemiLagrangian_DDt, Lagrangian_DDt], + ): + """Set the material derivative operator for the unknown.""" + self._DuDt = DuDt_value + self._solver_is_setup = False + return + + @property + def DFDt(self): + """Material derivative operator for the flux history.""" + return self._DFDt + + # Do we want to lock this down ? + @DFDt.setter + def DFDt( + self, + DFDt_value: Union[SemiLagrangian_DDt, Lagrangian_DDt], + ): + """Set the material derivative operator for flux history.""" + self._DFDt = DFDt_value + self._solver_is_setup = False + return + + ## Properties on all sub-classes + + @property + def C(self): + """The matrix form of the constitutive model (the `c` property) + that relates fluxes to gradients. + For scalar problem, this is the matrix representation of the rank 2 tensor. + For vector problems, the Mandel form of the rank 4 tensor is returned. + NOTE: this is an immutable object that is _a view_ of the underlying tensor + """ + if not self._is_setup: + self._build_c_tensor() + + d = self.dim + rank = len(self.c.shape) + + if rank == 2: + return sympy.Matrix(self._c).as_immutable() + else: + return uw.maths.tensor.rank4_to_mandel(self._c, d).as_immutable() + + @property + def c(self): + """The tensor form of the constitutive model that relates fluxes to gradients. In scalar + problems, `c` and `C` are equivalent (matrices), but in vector problems, `c` is a + rank 4 tensor. NOTE: `c` is the canonical form of the constitutive relationship. + """ + + if not self._is_setup: + self._build_c_tensor() + if hasattr(self._c, "sym"): + return sympy.Matrix(self._c.sym).as_immutable() + else: + return self._c.as_immutable() + + @property + def flux(self): + """Computes the effect of the constitutive tensor on the gradients of the unknowns. + (always uses the `c` form of the tensor). In general cases, the history of the gradients + may be required to evaluate the flux. + """ + + ddu = self.grad_u + + return self._q(ddu) + + def _q(self, ddu): + """Generic flux term""" + + if not self._is_setup: + self._build_c_tensor() + + c = self.c + rank = len(c.shape) + + # tensor multiplication + + if rank == 2: + flux = c * ddu.T + else: # rank==4 + flux = sympy.tensorcontraction( + sympy.tensorcontraction(sympy.tensorproduct(c, ddu), (1, 5)), (0, 3) + ) + + return sympy.Matrix(flux) + + @property + def flux_1d(self): + """Computes the effect of the constitutive tensor on the gradients of the unknowns. + (always uses the `c` form of the tensor). In general cases, the history of the gradients + may be required to evaluate the flux. Returns the Voigt form that is flattened so as to + match the PETSc field storage pattern for symmetric tensors. + """ + + flux = self.flux + + if flux.shape[0] == 1: + return flux + + if flux.shape[1] == 1: + return flux.T + + assert ( + flux.is_symmetric() + ), "The conversion of tensors to Voigt form is only defined for symmetric tensors in underworld\ + but for non-symmetric tensors, the .flat() method is a potential replacement" + + return uw.maths.tensor.rank2_to_voigt(flux, dim=self.dim) + + @property + def flux_jacobian(self): + """Optional smooth surrogate flux for Jacobian assembly. + + Returns ``None`` by default, meaning the solver differentiates the + exact :attr:`flux` (the Newton fix unwraps it first; a generic Min/Max + kink-smoothing fallback then rounds any remaining yield kink). + + Set this to a custom SymPy expression to override the Jacobian tangent + independently of the residual flux. The residual still uses the exact + :attr:`flux`, so the converged solution satisfies the true constitutive + law — only the Newton search direction is smoothed, giving a robust, + line-search-friendly tangent without changing the answer. + + Use cases: + + * A model whose flux has a non-smooth yield kink (e.g. hard-``Min`` + viscoplasticity) supplies a physically-motivated *smooth law for the + tangent only*. + + * A model whose flux contains composition-dependent coefficients + (e.g. multicomponent diffusion) may supply a *constant-coefficient* + version to give the solver a clean SPD Jacobian while keeping the + exact physics in the residual. + + Shape must match :attr:`flux` (the solver substitutes it for ``F1`` when + forming the velocity-gradient Jacobian blocks). + """ + return self._flux_jacobian + + @flux_jacobian.setter + def flux_jacobian(self, value): + """Set a custom Jacobian flux expression (or None to revert).""" + self._flux_jacobian = value + # Signal the solver to rebuild its pointwise Jacobian function. + # Without this, the change is silently ignored until something + # else triggers a re-setup. + self._solver_is_setup = False + + def _reset(self): + """Flags that the expressions in the consitutive tensor need to be refreshed and also that the + solver will need to rebuild the stiffness matrix and jacobians""" + + self._solver_is_setup = False + self._is_setup = False + + # Propagate invalidation to solver if we have a reference. + # A constitutive parameter change affects the F1 pointwise function + # only — the DM, fields, and BCs are unchanged. The solver's + # _build() can swap functions in place without DM teardown. + if hasattr(self, "Parameters") and hasattr(self.Parameters, "_solver"): + if self.Parameters._solver is not None: + self.Parameters._solver._needs_function_rewire = True + + return + + @property + def requires_stress_history(self): + """Whether this model needs DFDt stress history tracking. + + 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. + """ + return False + + @property + def supports_yield_homotopy(self): + """Whether this model can be solved by a single-parameter yield homotopy. + + ``True`` on the yielding models, which carry a δ-parameterised soft-min + yield law that sharpens to the exact ``Min`` as δ→0 — see + :meth:`_yield_homotopy_control`. ``solver.solve(homotopy=True)`` refuses a + model that returns ``False`` (a purely viscous model has no yield surface to + sharpen, so there is nothing to march). + """ + return False + + @property + def stress_history_ddt_kwargs(self): + """Extra kwargs passed to the auto-DDt creation when this model + triggers it via ``requires_stress_history = True``. + + Default: empty dict (BDF-only models). ETD-2 / exponential models + override to inject ``with_forcing_history=True`` so the DDt + allocates a forcing-history slot. + """ + return {} + + def _update_history_coefficients(self): + """Uniform pre-solve hook the Stokes solver calls before each solve. + + Default: no-op (non-stress-history models have nothing to update). + BDF-style stress-history subclasses (VEP, TI-VEP) override to + delegate to ``_update_bdf_coefficients``. ETD-2 / exponential + subclasses (e.g. ``MaxwellExponentialFlowModel``) override to + update α, φ on the DDt. The solver dispatches uniformly through + this method — no ``isinstance`` checks at the solver layer. + """ + return + + def _update_history_post_solve(self): + """Uniform post-solve hook the Stokes solver calls after each solve. + + Default: no-op. Subclasses that store extra integrator state for + the next step (e.g. ETD-2 storing ε̇ⁿ in ``forcing_star``) override. + """ + return + + @property + def plastic_fraction(self): + """Fraction of strain rate that is plastic (0 for non-plastic models). + + Returns a sympy expression that can be evaluated post-solve via + ``uw.function.evaluate(cm.plastic_fraction, coords)``. + """ + return sympy.Integer(0) + + def _build_c_tensor(self): + """Return the identity tensor of appropriate rank (e.g. for projections)""" + + self._c = self._K * uw.maths.tensor.rank4_identity(self.dim) + self._is_setup = True + + return + + def _object_viewer(self): + from IPython.display import Latex, Markdown, display + from textwrap import dedent + + display( + Markdown( + rf"This consititutive model is formulated for {self.dim} dimensional equations" + ) + ) + + +class ViscousFlowModel(Constitutive_Model): + r""" + Viscous flow constitutive model for Stokes-type solvers. + + Defines the relationship between deviatoric stress and strain rate: + + .. math:: + + \tau_{ij} = \eta_{ijkl} \cdot \frac{1}{2} \left[ \frac{\partial u_k}{\partial x_l} + + \frac{\partial u_l}{\partial x_k} \right] + + where :math:`\eta` is the viscosity, which can be a scalar constant, SymPy + function, Underworld mesh variable, or any valid combination. This results + in an isotropic (but not necessarily homogeneous or linear) relationship + between :math:`\tau` and the velocity gradients. + + Parameters + ---------- + unknowns : Unknowns + The solver unknowns (typically velocity and pressure fields). + material_name : str, optional + Name identifier for this material (used in multi-material setups). + + Examples + -------- + >>> import underworld3 as uw + >>> stokes = uw.systems.Stokes(mesh) + >>> viscous = uw.constitutive_models.ViscousFlowModel(stokes.Unknowns) + >>> viscous.Parameters.shear_viscosity_0 = 1e21 # Pa.s + >>> stokes.constitutive_model = viscous + + See Also + -------- + ViscoPlasticFlowModel : Adds yield stress for plastic behavior. + ViscoElasticPlasticFlowModel : Adds viscoelastic memory. + """ + + # ```python + # class ViscousFlowModel(Constitutive_Model) + # ... + # ``` + # ### Example + + # ```python + # viscous_model = ViscousFlowModel(dim) + # viscous_model.material_properties = viscous_model.Parameters(viscosity=viscosity_fn) + # solver.constititutive_model = viscous_model + # ``` + + # ```python + # tau = viscous_model.flux(gradient_matrix) + # ``` + + def __init__(self, unknowns, material_name: str = None): + # All this needs to do is define the + # viscosity property and init the parent(s) + # In this case, nothing seems to be needed. + # The viscosity is completely defined + # in terms of the Parameters + + super().__init__(unknowns, material_name=material_name) + + # self._viscosity = expression( + # R"{\eta_0}", + # 1, + # " Apparent viscosity", + # ) + + class _Parameters(_ParameterBase, _ViscousParameterAlias): + """Any material properties that are defined by a constitutive relationship are + collected in the parameters which can then be defined/accessed by name in + individual instances of the class. + + Now uses Parameter descriptor pattern for automatic lazy evaluation preservation + with unit-aware quantities. + """ + + # Import Parameter descriptor (must use absolute import inside nested class) + import underworld3.utilities._api_tools as api_tools + + # Define shear_viscosity_0 as a Parameter descriptor + # The lambda receives the _Parameters instance and creates the expression via the owning model + shear_viscosity_0 = api_tools.Parameter( + r"\eta", + lambda params_instance: params_instance._owning_model.create_unique_symbol( + r"\eta", 1, "Shear viscosity" + ), + "Shear viscosity", + units="Pa*s" + ) + + def __init__( + inner_self, + _owning_model, + ): + inner_self._owning_model = _owning_model + # Note: shear_viscosity_0 is now a descriptor, no need to create it here + + @property + def viscosity(self): + """Whatever the consistutive model defines as the effective value of viscosity + in the form of an uw.expression""" + + return self.Parameters.shear_viscosity_0 + + @property + def K(self): + """Effective stiffness parameter (viscosity for viscous flow)""" + return self.viscosity + + @property + def flux(self): + r"""Viscous stress tensor: :math:`\boldsymbol{\tau} = 2\eta\dot{\varepsilon}`.""" + edot = self.grad_u + return self._q(edot) + + def _q(self, edot): + """Apply constitutive tensor to strain rate to compute stress.""" + + if not self._is_setup: + self._build_c_tensor() + + c = self.c + rank = len(c.shape) + + # tensor multiplication + + if rank == 2: + flux = c * edot + else: # rank==4 + flux = sympy.tensorcontraction( + sympy.tensorcontraction(sympy.tensorproduct(c, edot), (1, 5)), (0, 3) + ) + + return sympy.Matrix(flux) + + ## redefine the gradient for the viscous law as it relates to + ## the symmetric part of the tensor only + + @property + def grad_u(self): + r"""Symmetric strain rate tensor (with 1/2 factor). + + .. math:: + \dot{\varepsilon}_{ij} = \frac{1}{2}\left(\frac{\partial u_i}{\partial x_j} + + \frac{\partial u_j}{\partial x_i}\right) + """ + mesh = self.Unknowns.u.mesh + + return mesh.vector.strain_tensor(self.Unknowns.u.sym) + + # ddu = self.Unknowns.u.sym.jacobian(mesh.CoordinateSystem.N) + # edot = (ddu + ddu.T) / 2 + # return edot + + @property + def plastic_fraction(self): + """Fraction of strain rate that is plastic: 1 - η_vp / η_viscous.""" + return sympy.Max(0, 1 - self.viscosity / self.Parameters.shear_viscosity_0) + + def _build_c_tensor(self): + """For this constitutive law, we expect just a viscosity function""" + + if self._is_setup: + return + + d = self.dim + viscosity = self.viscosity + + # Check for tensor forms first (Mandel matrix or full rank-4 tensor) + dv = uw.maths.tensor.idxmap[d][0] + if isinstance(viscosity, sympy.Matrix) and viscosity.shape == (dv, dv): + # Mandel form of constitutive tensor + self._c = 2 * uw.maths.tensor.mandel_to_rank4(viscosity, d) + elif isinstance(viscosity, sympy.Array) and viscosity.shape == (d, d, d, d): + # Full rank-4 tensor + self._c = 2 * viscosity + else: + # Scalar viscosity case + # UWexpression has __getitem__ from MathematicalMixin, making it Iterable, + # which causes SymPy's array multiplication operator to reject it. + # Solution: Use element-wise loop construction instead of operator overloading. + # The multiplication creates Mul(scalar, UWexpression) objects which are NOT + # Iterable, so array assignment accepts them. JIT unwrapper finds the + # UWexpression atoms inside and substitutes correctly. + + identity = uw.maths.tensor.rank4_identity(d) + result = sympy.MutableDenseNDimArray.zeros(d, d, d, d) + + # Element-wise multiplication: c_ijkl = 2 * I_ijkl * viscosity + for i in range(d): + for j in range(d): + for k in range(d): + for l in range(d): + val = 2 * identity[i, j, k, l] * viscosity + # If simplification returns bare UWexpression (e.g., 2*(1/2)*visc = visc), + # wrap it to avoid Iterable check failure during assignment + if hasattr(val, '__getitem__') and not isinstance(val, (sympy.MatrixBase, sympy.NDimArray)): + val = sympy.Mul(sympy.S.One, val, evaluate=False) + result[i, j, k, l] = val + + self._c = result + + self._is_setup = True + self._solver_is_setup = False + + return + + def _object_viewer(self): + from IPython.display import Latex, Markdown, display + + super()._object_viewer() + + ## feedback on this instance + display( + Latex( + r"$\quad\eta_\textrm{eff} = $ " + sympy.sympify(self.viscosity.sym)._repr_latex_() + ) + ) + + # --- Yield soft-min smoother (shared by the visco-plastic subclasses) ----------- + # The δ soft-min regularisation and the smooth-min FAMILY selection live on the + # base class so every yielding model inherits one implementation. δ is held as a + # constants[] UWexpression atom (not a baked float) so a homotopy can ramp it at + # runtime via PetscDSSetConstants with no JIT recompile. + + def _get_yield_softness(self): + r"""The soft-min regularisation δ as a ``constants[]`` UWexpression atom. + + Created lazily and kept in sync with ``self._yield_softness`` (the + configured numeric value). Storing δ as a UWexpression — rather than + baking the float into the compiled flux — lets a yield homotopy ramp + δ at runtime via ``PetscDSSetConstants`` with no JIT recompile. + ``δ = 0`` makes the sqrt law identically ``Min``. + """ + delta_value = getattr(self, "_yield_softness", 0.0) + if getattr(self, "_yield_softness_expr", None) is None: + self._yield_softness_expr = expression( + R"{\updelta_{y}}", + sympy.Float(delta_value), + "Yield soft-min regularisation δ (rampable constant; δ=0 ⇒ exact Min)", + ) + # Onset offset (-1+√(1+δ²))/2 keeps g(0)=1 (no spurious yield below + # onset). Held as its OWN constant atom — a single symbol in the + # stress tensor — so it does not blow the tensor up, while still + # tracking δ symbolically (one δ update repacks both constants). + self._yield_offset_expr = expression( + R"{\updelta_{y,0}}", + (-1 + sympy.sqrt(1 + self._yield_softness_expr**2)) / 2, + "Yield soft-min onset offset; tracks δ so g(0)=1 exactly", + ) + else: + self._yield_softness_expr.sym = sympy.Float(delta_value) + return self._yield_softness_expr + + def _get_yield_offset(self): + """The onset-offset constant atom (lazily created alongside δ).""" + if getattr(self, "_yield_offset_expr", None) is None: + self._get_yield_softness() + return self._yield_offset_expr + + def _combine_yield(self, eta_ve, eta_pl): + r"""Combine the visco-elastic/viscous viscosity ``eta_ve`` with the plastic + (yield) viscosity ``eta_pl`` according to ``self._yield_mode``: + + - ``"min"``: exact hard ``Min(η_ve, η_pl)`` (sharp yield surface). + - ``"harmonic"``: ``1/(1/η_ve + 1/η_pl)`` (a distinct smooth blend). + - ``"softmin"``: the δ-parameterised soft-min, in the family chosen by + ``self.yield_smoother`` — ``"sqrt"`` (default; overshoots τ_y in the + transition) or ``"powermean"`` (undershoots τ_y, ``η_eff ≤ Min`` always). + Both approach exact ``Min`` as ``δ → 0``. + + Behaviour at the stock settings (``yield_mode`` per model default, + ``yield_smoother="sqrt"``) is identical to the previous inline law — δ merely + moves from a baked float to a ``constants[]`` atom (same value). + """ + mode = getattr(self, "_yield_mode", "softmin") + if mode == "harmonic": + return 1 / (1 / eta_ve + 1 / eta_pl) + if mode == "min": + return sympy.Min(eta_ve, eta_pl) + + # "softmin": δ-parameterised smooth-min family. + smoother = getattr(self, "_yield_smoother", "sqrt") + delta = self._get_yield_softness() + f = eta_ve / eta_pl + if smoother == "powermean": + # p-norm soft-min in an overflow-safe harmonic-normalised form. δ is + # floored SMOOTHLY (+ε, not Max()) so 1/δ stays finite as δ→0 (a Max on + # the δ atom triggers an unsupported symbolic numeric comparison). + s = 1 / (delta + sympy.Float(0.001)) + a = 1 + f + b = 1 + 1 / f + # Harmonic mean written as eta_ve/(1+f), NOT eta_ve*eta_pl/(eta_ve+eta_pl). + # The two are algebraically identical, but the product-over-sum form + # evaluates to inf/inf = NaN when eta_pl is infinite — which is exactly + # what a rigid (unyielded) point gives, since eta_pl = tau_y/(2 edot_II) + # and edot_II = 0 there. That includes every point of a cold v=0 start. + # In this form f -> 0 and N -> eta_ve, the correct viscous limit. + N = eta_ve / a + return N * (a ** (-s) + b ** (-s)) ** (-1 / s) + + # default "sqrt" soft-min: η_ve / g(f), g(0)=1, g ≈ max(1, f), exact Min at δ=0. + offset = self._get_yield_offset() + g = 1 + (f - 1 + sympy.sqrt((f - 1) ** 2 + delta**2)) / 2 - offset + return eta_ve / g + + @property + def yield_smoother(self): + r"""Which smooth-min FAMILY regularises the ``"softmin"`` yield mode. + + Both families use the same softness parameter ``δ`` (``yield_softness``) + and approach exact ``Min`` as ``δ → 0``, but differ in how they round + the kink: + + - ``"sqrt"`` (default): ``η_ve / g(f, δ)`` with + ``g = 1 + ½(f−1+√((f−1)²+δ²)) − offset``. Exact ``Min`` at ``δ=0``; + **overshoots** the yield surface in the transition (carries stress a + few–60 % above ``τ_y`` before asymptoting). + - ``"powermean"``: the p-norm soft-min + ``η_eff = (η_ve^(−s) + η_pl^(−s))^(−1/s)`` with ``s = 1/δ`` + (``s=1`` ⇒ harmonic mean; ``s→∞`` ⇒ ``Min``). **Undershoots** the + yield surface (``η_eff ≤ Min`` always — approaches ``τ_y`` strictly + from below, never over-yields). Computed in an overflow-safe + harmonic-normalised form for geodynamic viscosity ranges. + + Selecting ``"powermean"`` bumps a zero ``yield_softness`` to ``1.0`` + (``s=1``, the parameter-free harmonic mean) since ``δ=0`` (``s=∞``) is + the singular hard-``Min`` limit it only *approaches*. + """ + return getattr(self, "_yield_smoother", "sqrt") + + @yield_smoother.setter + def yield_smoother(self, value): + if value not in ("sqrt", "powermean"): + raise ValueError( + f"yield_smoother must be 'sqrt' or 'powermean', got '{value}'" + ) + self._yield_smoother = value + if value == "powermean" and getattr(self, "_yield_softness", 0.0) == 0.0: + # δ=0 ⇒ s=1/δ=∞ is the singular Min limit; default to the + # parameter-free harmonic mean (s=1) instead. + self.yield_softness = 1.0 + self._reset() + + # --- Smooth lower bounds (viscosity / yield floors) ----------------------------- + # A hard sympy.Max cutoff is non-differentiable at the corner. When the flux is + # differentiated for the consistent-Newton tangent that kink breaks the tangent — + # and, because one operand is a UWexpression over the fields, sympy's fuzzy `>=` + # comparison cannot resolve it and recurses. In the smooth yield modes we therefore + # round the floor with the same δ that regularises the yield transition, so the + # whole effective viscosity stays differentiable and δ→0 recovers the sharp bound. + + def _apply_floor(self, value, floor): + r"""Impose the lower bound :math:`value \ge floor`. + + In ``yield_mode="min"`` this is the exact hard ``sympy.Max(value, floor)`` — + the sharp cutoff the model has always used. In the smooth yield modes + (``"softmin"``/``"harmonic"``) it is the differentiable ``uw.maths.smooth_max`` + rounded by the yield softness δ *relative to the floor* (:math:`\epsilon = + \delta\,|floor|`), so the whole effective viscosity — not only the yield + transition — is differentiable for the consistent-Newton tangent. The relative + rounding needs a non-zero ``floor`` for a length scale, which the numerical + viscosity/yield floors provide; a cutoff *at zero* (a tension cutoff) has no + such scale and is rounded by its own physical parameter via + ``uw.maths.smooth_max`` at the call site instead. + """ + # smooth_max is 1/2 (a + b + sqrt((a-b)^2 + eps^2)) — pure arithmetic on the + # operands, so `floor` stays the symbolic Parameter that the JIT routes + # through constants[], and sympy is never asked for the ordering test that + # recurses on an opaque UWexpression. At eps = 0 this is exactly + # Max(value, floor), but written without a comparison. + # + # TODO(DESIGN): the rounding scale is RELATIVE (delta * floor), so it + # collapses for a tension cutoff at floor = 0 — now the default for + # yield_stress_min. That leaves a hard corner: the floored yield stress is + # exactly 0 in tension, hence eta_pl = tau_y/(2 edot_II) is exactly 0 and the + # tangent through the soft-min is undefined there. A properly rounded cap + # (Griffith / parabolic) needs an ABSOLUTE stress scale, which this signature + # cannot supply. Maintainer decision pending (2026-07-26). + rounding = 0 if getattr(self, "_yield_mode", "min") == "min" \ + else self._get_yield_softness() * floor + return uw.maths.smooth_max(value, floor, rounding) + + # Tangent this model wants while the yield homotopy marches. Newton is right for + # a purely viscous-plastic yield; the elastic (VEP) subclasses override to the + # frozen/Picard tangent, because the consistent yield tangent taken across the + # elastic stress-history block makes the Jacobian indefinite and the linear + # solve fails outright (DIVERGED_LINEAR_SOLVE). + _yield_homotopy_tangent = True + + def _yield_homotopy_control(self): + """Put this model in its smooth (δ-parameterised) yield mode and describe + how to march it. + + The model owns what the homotopy *means* for it: which knob is the + continuation parameter, how to set it, and which tangent to pair it with. + The solver only marches the number. Called by + ``solver.solve(homotopy=True)``; see + :doc:`nonlinear-solver-homotopy-warmstart` (Layer 2). + + Selects the power-mean soft-min family, whose large-δ limit is the harmonic + mean — bounded by the background viscosity even as :math:`\\dot\\varepsilon + \\to 0`, so the first (cold) solve of the march is well posed and no separate + viscous pre-solve is needed. + + Returns + ------- + YieldHomotopyControl + ``set_delta`` (model-owned setter for δ), ``tangent`` (the + ``consistent_jacobian`` value to use), and ``delta`` (the ``constants[]`` + atom itself, for diagnostics). + """ + from underworld3.systems.yield_continuation import YieldHomotopyControl + + self.yield_mode = "softmin" + self.yield_smoother = "powermean" + + # No strain-rate floor is needed for the cold (v = 0) start the march begins + # from: eta_pl = tau_y/(2 edot_II) is +inf there, which the soft-min carries + # correctly to the viscous branch. See the harmonic-mean note in + # _combine_yield for the one form that must be written carefully to keep it + # so. + + def set_delta(value): + # Go through the property, not the atom: `yield_softness` updates BOTH + # the stored value and the constants[] atom, so a later + # _get_yield_softness() cannot silently reset δ to a stale number. + self.yield_softness = value + + return YieldHomotopyControl( + set_delta=set_delta, + tangent=self._yield_homotopy_tangent, + delta=self._get_yield_softness(), + ) + + +## NOTE - retrofit VEP into here + + +class ViscoPlasticFlowModel(ViscousFlowModel): + r""" + Viscoplastic flow constitutive model with yield stress. + + Extends :class:`ViscousFlowModel` with a yield stress that limits the + maximum deviatoric stress. When stress would exceed the yield stress, + the effective viscosity is reduced to cap the stress. + + .. math:: + + \tau_{ij} = \eta_\mathrm{eff} \cdot \dot{\varepsilon}_{ij} + + where the effective viscosity is: + + .. math:: + + \eta_\mathrm{eff} = \min\left(\eta_0, \frac{\tau_y}{2\dot{\varepsilon}_{II}}\right) + + and :math:`\tau_y` is the yield stress and :math:`\dot{\varepsilon}_{II}` + is the second invariant of the strain rate. + + Parameters + ---------- + unknowns : Unknowns + The solver unknowns (typically velocity and pressure fields). + material_name : str, optional + Name identifier for this material. + + Notes + ----- + If yield stress is not defined, this model behaves identically to + :class:`ViscousFlowModel`. The message ``not~yet~defined`` in the + effective viscosity indicates missing parameters. + + See Also + -------- + ViscousFlowModel : Base viscous model without yielding. + ViscoElasticPlasticFlowModel : Adds viscoelastic memory. + """ + + def __init__(self, unknowns, material_name: str = None): + # All this needs to do is define the + # non-paramter properties that we want to + # use in other expressions and init the parent(s) + # + + super().__init__(unknowns, material_name=material_name) + + self._strainrate_inv_II = expression( + r"\dot\varepsilon_{II}", + sympy.sqrt((self.grad_u**2).trace() / 2), + "Strain rate 2nd Invariant", + ) + + self._plastic_eff_viscosity = expression( + R"{\eta_\textrm{eff,p}}", + 1, + "Effective viscosity (plastic)", + ) + + # Yield-combination mode (see _combine_yield on the base class). Default + # "min" = the exact hard Min(η_0, η_yield) this model has always used, so the + # default behaviour is unchanged. Opt into "softmin" (+ yield_smoother / + # yield_softness) for the δ-parameterised smooth-min homotopy. + self._yield_mode = "min" + self._yield_softness = 0.0 # δ; 0 ⇒ exact Min + self._yield_smoother = "sqrt" # smooth-min family: "sqrt" | "powermean" + self._yield_softness_expr = None # constants[] δ atom (created lazily) + self._yield_offset_expr = None # onset-offset atom (created lazily) + + class _Parameters(_ParameterBase, _ViscousParameterAlias): + """Any material properties that are defined by a constitutive relationship are + collected in the parameters which can then be defined/accessed by name in + individual instances of the class. + + `sympy.oo` (infinity) for default values ensures that sympy.Min simplifies away + the conditionals when they are not required. + + Uses Parameter descriptor pattern for automatic lazy evaluation preservation + with unit-aware quantities. + """ + + # Import Parameter descriptor (must use absolute import inside nested class) + import underworld3.utilities._api_tools as api_tools + + shear_viscosity_0 = api_tools.Parameter( + R"{\eta}", + lambda inner_self: 1, + "Shear viscosity", + units="Pa*s", + ) + + shear_viscosity_min = api_tools.Parameter( + R"{\eta_{\textrm{min}}}", + lambda inner_self: -sympy.oo, + "Shear viscosity, minimum cutoff", + units="Pa*s", + ) + + yield_stress = api_tools.Parameter( + R"{\tau_{y}}", + lambda inner_self: sympy.oo, + "Yield stress (DP)", + units="Pa", + ) + + yield_stress_min = api_tools.Parameter( + R"{\tau_{y, \mathrm{min}}}", + lambda inner_self: 0, + "Yield stress (DP) minimum cutoff", + units="Pa", + ) + + strainrate_inv_II_min = api_tools.Parameter( + R"{\dot\varepsilon_{\mathrm{min}}}", + lambda inner_self: 0, + "Strain rate invariant minimum value", + units="1/s", + ) + + def __init__(inner_self, _owning_model): + inner_self._owning_model = _owning_model + # Parameters are now descriptors - no manual initialization needed + + @property + def viscosity(self): + r"""Effective viscosity with plastic yielding. + + .. math:: + \eta_{\mathrm{eff}} = \min\left(\eta_0, \frac{\tau_y}{2\dot{\varepsilon}_{II}}\right) + + where :math:`\dot{\varepsilon}_{II}` is the second invariant of strain rate. + """ + inner_self = self.Parameters + # detect if values we need are defined or are placeholder symbols + + if inner_self.yield_stress.sym == sympy.oo: + self._plastic_eff_viscosity.symbol = inner_self.shear_viscosity_0.symbol + self._plastic_eff_viscosity._sym = inner_self.shear_viscosity_0._sym + return self._plastic_eff_viscosity + + # Don't put conditional behaviour in the constitutive law + # when it is not needed + + # Lower bound on the yield stress, defaulting to ZERO and therefore normally + # active. tau_y is compared against the second invariant of the stress, so a + # negative tau_y is meaningless — a pressure-dependent Drucker-Prager yield + # C + sin(phi)*p goes negative in tension and must be cut off there rather + # than propagated into tau_y/(2 edot_II). (The ±oo defaults elsewhere in + # Parameters exist so sympy can cancel an unused term away; that trick is + # wrong here, so this one defaults to 0 — maintainer ruling 2026-07-26.) + # An explicit -oo still disables the floor. + if inner_self.yield_stress_min.sym != -sympy.oo: + yield_stress = self._apply_floor( + inner_self.yield_stress, inner_self.yield_stress_min + ) + else: + yield_stress = inner_self.yield_stress + + viscosity_yield = yield_stress / (2 * self._strainrate_inv_II) + + # Combine the viscous and plastic (yield) viscosities. The default + # yield_mode="min" gives the exact hard Min(η_0, η_yield); yield_mode="softmin" + # opts into the δ-parameterised smooth-min (sqrt or powermean family) for a + # scalable homotopy toward the sharp yield surface. + effective_viscosity = self._combine_yield( + inner_self.shear_viscosity_0, viscosity_yield + ) + + # If we want to apply limits to the viscosity but see caveat above + # Keep this as an sub-expression for clarity + + if inner_self.shear_viscosity_min.sym != -sympy.oo: + self._plastic_eff_viscosity._sym = self._apply_floor( + effective_viscosity, inner_self.shear_viscosity_min + ) + + else: + self._plastic_eff_viscosity._sym = effective_viscosity + + # Returns an expression that has a different description + return self._plastic_eff_viscosity + + @property + def supports_yield_homotopy(self): + """This model carries a δ-parameterised yield law — ``solve(homotopy=True)`` + can march it. See :meth:`_yield_homotopy_control`.""" + return True + + @property + def yield_mode(self): + r"""How the viscous and plastic (yield) viscosities are combined. + + - ``"min"`` (default): exact hard ``Min(η_0, η_yield)`` — the sharp yield + surface this model has always used. + - ``"harmonic"``: ``1/(1/η_0 + 1/η_yield)`` — a smooth blend. + - ``"softmin"``: the δ-parameterised smooth-min (family set by + ``yield_smoother``), for a scalable homotopy toward the sharp surface. + """ + return self._yield_mode + + @yield_mode.setter + def yield_mode(self, value): + if value not in ("min", "harmonic", "softmin"): + raise ValueError( + f"yield_mode must be 'min', 'harmonic', or 'softmin', got '{value}'" + ) + self._yield_mode = value + self._reset() + + @property + def yield_softness(self): + r"""Soft-min regularisation δ for ``yield_mode="softmin"`` (0 ⇒ exact Min). + + δ is held as a ``constants[]`` atom, so ramping it at runtime (this setter, + or ``cm._get_yield_softness().sym = ...`` + ``solver._update_constants()``) + does not trigger a JIT recompile — the basis of a scalable yield homotopy. + """ + return self._yield_softness + + @yield_softness.setter + def yield_softness(self, value): + self._yield_softness = float(value) + if getattr(self, "_yield_softness_expr", None) is not None: + self._yield_softness_expr.sym = sympy.Float(self._yield_softness) + self._reset() + + def plastic_correction(self) -> float: + r"""Scaling factor to reduce stress to yield surface. + + .. math:: + f = \frac{\tau_y}{\tau_{II}} + + where :math:`\tau_{II}` is the second invariant of deviatoric stress. + Returns 1 if no yield stress is set. + """ + parameters = self.Parameters + + if parameters.yield_stress == sympy.oo: + return sympy.sympify(1) + + stress = self.stress_projection() + + # The yield criterion in this case is assumed to be a bound on the second invariant of the stress + + stress_II = sympy.sqrt((stress**2).trace() / 2) + + correction = parameters.yield_stress / stress_II + + return correction + + def _object_viewer(self): + from IPython.display import Latex, Markdown, display + + super()._object_viewer() + + ## feedback on this instance + display( + Latex( + r"$\quad\eta_\textrm{0} = $" + + sympy.sympify(self.Parameters.shear_viscosity_0.sym)._repr_latex_() + ), + Latex( + r"$\quad\tau_\textrm{y} = $" + + sympy.sympify(self.Parameters.yield_stress.sym)._repr_latex_(), + ), + ) + + return + + +class ViscoElasticPlasticFlowModel(ViscousFlowModel): + r""" + Viscoelastic-plastic flow constitutive model. + + The stress (flux term) is given by: + + .. math:: + + \tau_{ij} = \eta_{ijkl} \cdot \frac{1}{2} \left[ \frac{\partial u_k}{\partial x_l} + + \frac{\partial u_l}{\partial x_k} \right] + + where :math:`\eta` is the viscosity, a scalar constant, SymPy function, + Underworld mesh variable, or any valid combination. This results in an + isotropic (but not necessarily homogeneous or linear) relationship between + :math:`\tau` and the velocity gradients. You can also supply :math:`\eta_{IJ}`, + the Mandel form of the constitutive tensor, or :math:`\eta_{ijkl}`, the rank-4 tensor. + + The Mandel constitutive matrix is available in `viscous_model.C` and the rank 4 tensor form is + in `viscous_model.c`. Apply the constitutive model using: + + """ + + def __init__(self, unknowns, order=1, integrator: str = "bdf", + material_name: str = None): + """Construct a viscoelastic-plastic flow model. + + Parameters + ---------- + unknowns : Unknowns + The solver unknowns (velocity, pressure). + order : int, default 1 + Time-integration order. Combines with ``integrator``: + + - ``integrator='bdf', order=1``: BDF-1 (backward Euler). + - ``integrator='bdf', order=2``: BDF-2. + - ``integrator='etd', order=1``: ETD-1 (single-step, + fully L-stable, recommended default for VEP+yield). + - ``integrator='etd', order=2``: ETD-2 (single-step + with linear-quadrature forcing history; accurate on + smooth VE but **unstable in tight-yield VEP** — + produces global runaway, see EXPONENTIAL_VE_INTEGRATOR.md + lessons #7, #9). + integrator : str, default "bdf" + Time-integration scheme: + + - ``"bdf"``: backward differentiation formula on the + deviatoric-stress rate equation. Production default. + - ``"etd"``: exponential time-differencing — integrates the + Maxwell relaxation operator analytically (``α = exp(-Δt/τ)``). + ``order=1`` is the recommended default for new code: same + stability as BDF-1, exact handling of the relaxation factor + at large ``Δt/τ``. ``order=2`` adds linear quadrature on + the forcing history for higher accuracy on smooth VE + (4.3× more accurate than BDF-2 on ``bench_ve_harmonic``) + but blows up under active yield in tight-yield TI faults. + See ``docs/developer/design/EXPONENTIAL_VE_INTEGRATOR.md``. + material_name : str, optional + Name identifier for this material. + """ + if integrator not in ("bdf", "etd"): + raise ValueError( + f"integrator must be 'bdf' or 'etd', got '{integrator!r}'" + ) + if integrator == "etd" and order not in (1, 2): + raise ValueError( + f"integrator='etd' supports order=1 (ETD-1, default-recommended) " + f"or order=2 (ETD-2, accurate for smooth VE; avoid in tight-yield " + f"VEP where it produces global runaway). Got order={order}." + ) + self._integrator = integrator + + # Store material_name before creating expressions (needed by create_unique_symbol) + self._material_name = material_name + + # This may not be defined at initialisation time, set to None until used + self._stress_star = expression( + r"{\tau^{*}}", + None, + r"Lagrangian Stress at $t - \delta_t$", + ) + + # This may not be defined at initialisation time, set to None until used + self._stress_2star = expression( + r"{\tau^{**}}", + None, + r"Lagrangian Stress at $t - 2\delta_t$", + ) + + # This may not be well-defined at initialisation time, set to None until used + self._E_eff = expression( + r"{\dot{\varepsilon}_{\textrm{eff}}}", + None, + "Equivalent value of strain rate (accounting for stress history)", + ) + + # This may not be well-defined at initialisation time, set to None until used + self._E_eff_inv_II = expression( + r"{\dot{\varepsilon}_{II,\textrm{eff}}}", + None, + "Equivalent value of strain rate 2nd invariant (accounting for stress history)", + ) + + self._order = order + self._yield_mode = "softmin" # "min", "harmonic", "smooth", or "softmin" + self._yield_softness = 0.1 # δ parameter for "softmin" mode + self._yield_smoother = "sqrt" # smooth-min family: "sqrt" | "powermean" + self._yield_softness_expr = None # constants[] δ atom (created lazily) + self._yield_offset_expr = None # onset-offset atom (created lazily) + + # Timestep — set by the solver before each solve(). Not a user parameter. + # Initialised to oo (viscous limit). The solver overwrites this with the + # actual timestep on every call to solve(timestep=dt). + self._dt = expression(r"{\Delta t}", sympy.oo, "Timestep (set by solver)") + + # BDF coefficients as UWexpressions — route through PetscDS constants[]. + # Updated each step by _update_bdf_coefficients() before solve. + # Initialised to BDF-1 values: [1, -1, 0, 0]. + self._bdf_c0 = expression(r"{c_0^{\mathrm{BDF}}}", sympy.Integer(1), "BDF leading coefficient") + self._bdf_c1 = expression(r"{c_1^{\mathrm{BDF}}}", sympy.Integer(-1), "BDF history coefficient 1") + self._bdf_c2 = expression(r"{c_2^{\mathrm{BDF}}}", sympy.Integer(0), "BDF history coefficient 2") + self._bdf_c3 = expression(r"{c_3^{\mathrm{BDF}}}", sympy.Integer(0), "BDF history coefficient 3") + + self._reset() + + super().__init__(unknowns, material_name=material_name) + + return + + class _Parameters(_ParameterBase, _ViscousParameterAlias): + """Any material properties that are defined by a constitutive relationship are + collected in the parameters which can then be defined/accessed by name in + individual instances of the class. + + Uses Parameter descriptor pattern for automatic lazy evaluation preservation + with unit-aware quantities. + """ + + # Import Parameter descriptor (must use absolute import inside nested class) + import underworld3.utilities._api_tools as api_tools + + # Basic parameters with Parameter descriptors + shear_viscosity_0 = api_tools.Parameter( + R"{\eta}", + lambda inner_self: 1, + "Shear viscosity", + units="Pa*s", + ) + + shear_modulus = api_tools.Parameter( + R"{\mu}", + lambda inner_self: sympy.oo, + "Shear modulus", + units="Pa", + ) + + @property + def dt_elastic(inner_self): + """Timestep for VE formulas. Set by the solver, not a user parameter. + + Returns the UWexpression that the solver updates before each solve. + This flows through PetscDS constants[] so the JIT-compiled pointwise + functions always see the current timestep. + """ + return inner_self._owning_model._dt + + @dt_elastic.setter + def dt_elastic(inner_self, value): + """Allow the solver to set dt via Parameters.dt_elastic = timestep.""" + if hasattr(value, 'sym'): + inner_self._owning_model._dt.sym = value.sym + else: + inner_self._owning_model._dt.sym = value + + shear_viscosity_min = api_tools.Parameter( + R"{\eta_{\textrm{min}}}", + lambda inner_self: -sympy.oo, + "Shear viscosity, minimum cutoff", + units="Pa*s", + ) + + yield_stress = api_tools.Parameter( + R"{\tau_{y}}", + lambda inner_self: sympy.oo, + "Yield stress (DP)", + units="Pa", + ) + + yield_stress_min = api_tools.Parameter( + R"{\tau_{y, \mathrm{min}}}", + lambda inner_self: 0, + "Yield stress (DP) minimum cutoff", + units="Pa", + ) + + strainrate_inv_II_min = api_tools.Parameter( + R"{\dot\varepsilon_{II,\mathrm{min}}}", + lambda inner_self: 0, + "Strain rate invariant minimum value", + units="1/s", + ) + + def __init__( + inner_self, + _owning_model, + ): + inner_self._owning_model = _owning_model + + # Internal symbols for stress history (not parameters, internal state) + strainrate_inv_II = sympy.symbols( + r"\left|\dot\epsilon\right|\rightarrow\textrm{not\ defined}" + ) + stress_star = sympy.symbols(r"\sigma^*\rightarrow\textrm{not\ defined}") + inner_self._stress_star = stress_star + inner_self._not_yielded = sympy.sympify(1) + + ## The following expressions are containers for derived/computed values. + ## They have @property calls to retrieve / calculate them. + ## We keep them as expression containers for lazy evaluation. + + inner_self._ve_effective_viscosity = expression( + R"{\eta_{\mathrm{eff}}}", + None, + "Effective viscosity (elastic)", + ) + + inner_self._t_relax = expression( + R"{t_{\mathrm{relax}}}", + None, + "Maxwell relaxation time", + ) + + ## Derived parameters of the constitutive model (these have no setters) + ## Note, do not return new expressions, keep the old objects as containers + ## the correct values are used in existing expressions. These really are + ## parameters - they are solely combinations of other parameters. + + @property + def ve_effective_viscosity(inner_self): + r"""Visco-elastic effective viscosity: :math:`\eta_{\mathrm{eff}} = \frac{\eta G \Delta t}{\eta + G \Delta t}`.""" + # the dt_elastic defaults to infinity, t_relax to zero, + # so this should be well behaved in the viscous limit + + if inner_self.shear_modulus == sympy.oo: + return inner_self.shear_viscosity_0 + + # BDF-k effective viscosity: eta_eff = eta*mu*dt / (c0*eta + mu*dt) + # c0 is a UWexpression routed through PetscDS constants[], + # updated each step by _update_bdf_coefficients(). + eta = inner_self.shear_viscosity_0 + mu = inner_self.shear_modulus + dt_e = inner_self.dt_elastic + c0 = inner_self._owning_model._bdf_c0 + + el_eff_visc = eta * mu * dt_e / (c0 * eta + mu * dt_e) + + inner_self._ve_effective_viscosity.sym = el_eff_visc + + return inner_self._ve_effective_viscosity + + @property + def t_relax(inner_self): + r"""Maxwell relaxation time: :math:`t_{\mathrm{relax}} = \eta / G`.""" + # shear modulus defaults to infinity so t_relax goes to zero + # in the viscous limit + + inner_self._t_relax.sym = inner_self.shear_viscosity_0 / inner_self.shear_modulus + return inner_self._t_relax + + ## End of parameters definition + + @property + def order(self): + """Time integration order (1 or 2).""" + return self._order + + @order.setter + def order(self, value): + """Set the time integration order. + + If the model is already attached to a solver with a DFDt, this will + warn if the DFDt was created with a lower order (since it can't be + changed after creation — the DFDt allocates history buffers at init). + """ + self._order = value + self._reset() + + # Propagate to connected solver if present + solver = getattr(self.Parameters, '_solver', None) + if solver is not None: + ddt = getattr(solver.Unknowns, 'DFDt', None) + if ddt is not None and ddt.order < value: + import warnings + warnings.warn( + f"Setting order={value} but the solver's DFDt was already " + f"created with order={ddt.order}. The DFDt order cannot be " + f"changed after creation. To use order={value}, create the " + f"model with the desired order before assigning to the solver:\n" + f" cm = ViscoElasticPlasticFlowModel(stokes.Unknowns, order={value})\n" + f" stokes.constitutive_model = cm", + UserWarning, + stacklevel=2, + ) + elif ddt is not None: + solver._order = value + return + + @property + def effective_order(self): + """Effective order accounting for DDt history startup. + + During the first few timesteps, the DDt may not have enough history + to support the requested order. This property returns the lower of + the requested order and the DDt's effective order (which ramps from + 1 to self.order as history accumulates). + """ + if self.Unknowns is not None and self.Unknowns.DFDt is not None: + return min(self._order, self.Unknowns.DFDt.effective_order) + return self._order + + # Maximum timestep ratio (dt_new / dt_old) for which BDF-2+ is safe. + # Beyond this, fall back to BDF-1 to avoid negative-stress extrapolation + # when stress history is non-smooth (e.g. yield events). + _max_dt_ratio_for_higher_order = 2.0 + + def _update_bdf_coefficients(self): + """Update BDF coefficient UWexpressions from current dt_elastic and DDt history. + + Call this before each solve so that the constants[] array carries the + correct coefficients to the compiled pointwise functions. The coefficient + UWexpressions (_bdf_c0..c3) are referenced symbolically in ve_effective_viscosity, + E_eff, and stress() — their numeric values flow through PetscDSSetConstants. + + When the timestep ratio exceeds ``_max_dt_ratio_for_higher_order``, + BDF-2+ coefficients can cause negative stress extrapolation if the + stress history is non-smooth (e.g. after a yield event). In this case + we fall back to BDF-1 coefficients for safety. + """ + order = self.effective_order + + if self.Unknowns is not None and self.Unknowns.DFDt is not None: + dt_current = self.Parameters.dt_elastic + if hasattr(dt_current, 'sym'): + dt_current = dt_current.sym + + # Guard: fall back to BDF-1 when timestep increases too rapidly + dt_history = self.Unknowns.DFDt._dt_history + if order >= 2 and len(dt_history) > 0 and dt_history[0] is not None: + try: + ratio = float(dt_current) / float(dt_history[0]) + if ratio > self._max_dt_ratio_for_higher_order: + order = 1 + except (TypeError, ZeroDivisionError): + pass # symbolic dt — can't evaluate, keep requested order + + coeffs = _bdf_coefficients(order, dt_current, dt_history) + else: + coeffs = _bdf_coefficients(order, None, []) + + # Pad to length 4 + while len(coeffs) < 4: + coeffs.append(sympy.Integer(0)) + + self._bdf_c0.sym = coeffs[0] + self._bdf_c1.sym = coeffs[1] + self._bdf_c2.sym = coeffs[2] + self._bdf_c3.sym = coeffs[3] + + def _update_history_coefficients(self): + """Pre-solve hook: refresh integrator coefficients. + + Dispatches on ``(self._integrator, self._order)``: + - ``"bdf"`` (order 1 or 2): updates BDF c-coefficients via + :py:meth:`_update_bdf_coefficients`. + - ``"etd"`` order=2 (Phase B ETD-2): updates α, φ on the DDt + from ``τ_VE = η/μ``; forcing-history slot active. + - ``"etd"`` order=1 (ETD-1): updates α, φ as for ETD-2 then + forces ``φ = α`` so the ``(φ-α)·ε̇*`` term zeros out — fully + L-stable single-step, no forcing-history slot needed. + """ + if self._integrator == "etd": + if self.Unknowns.DFDt is None: + return + params = self.Parameters + if params.shear_modulus.sym is sympy.oo: + tau_eff = sympy.oo + else: + try: + eta_val = float(params.shear_viscosity_0.sym) + mu_val = float(params.shear_modulus.sym) + tau_eff = eta_val / mu_val if mu_val > 0 else sympy.oo + except (TypeError, ValueError): + tau_eff = None + try: + dt_val = ( + float(params.dt_elastic.sym) + if params.dt_elastic.sym is not sympy.oo + else None + ) + except (TypeError, ValueError): + dt_val = None + self.Unknowns.DFDt.update_exp_coefficients(dt_val, tau_eff) + if self._order == 1: + # ETD-1 reduction: φ = α makes the (φ-α)·ε̇* term zero + # AND turns (1-φ)·ε̇ into (1-α)·ε̇. + self.Unknowns.DFDt._exp_phi.sym = self.Unknowns.DFDt._exp_alpha.sym + else: + self._update_bdf_coefficients() + + def _update_history_post_solve(self): + """Post-solve hook. + + - BDF / ETD-1: no-op (no forcing-history slot). + - ETD-2: refresh ``forcing_star`` from the just-solved ε̇ for + the next step's history term. + """ + if self._integrator == "etd" and self._order == 2 and self.Unknowns.DFDt is not None: + if self.Unknowns.DFDt.forcing_star is not None: + self.Unknowns.DFDt.update_forcing_history(forcing_fn=self.Unknowns.E) + + @property + def stress_history_ddt_kwargs(self): + """SemiLagrangian DDt kwargs based on integrator selection. + + ETD-2 (order=2) needs the forcing-history slot; BDF and ETD-1 + (order=1) do not. + """ + if self._integrator == "etd" and self._order == 2: + return {"with_forcing_history": True} + return {} + + # The following should have no setters + @property + def stress_star(self): + r"""Previous timestep stress :math:`\boldsymbol{\sigma}^*` from history.""" + if self.Unknowns.DFDt is not None: + self._stress_star.sym = self.Unknowns.DFDt.psi_star[0].sym + + return self._stress_star + + @property + def stress_2star(self): + r"""Second-order stress history :math:`\boldsymbol{\sigma}^{**}` (for 2nd order integration).""" + # Check if we have enough information in DFDt to update _stress_star, + # otherwise it will be defined as zero + + if self.Unknowns.DFDt is not None: + if self.Unknowns.DFDt.order >= 2: + self._stress_2star.sym = self.Unknowns.DFDt.psi_star[1].sym + else: + self._stress_2star.sym = sympy.sympify(0) + + return self._stress_2star + + @property + def E_eff(self): + r"""Effective strain rate including elastic-history coupling. + + For BDF integration: + + .. math:: + \dot{\varepsilon}_\mathrm{eff} = \dot{\varepsilon} + - \sum_i c_i \frac{\sigma^{*(i)}}{2 \mu \Delta t} + + For ETD-2 (exponential) integration: + + .. math:: + \dot{\varepsilon}_\mathrm{eff} = (1-\varphi)\,\dot{\varepsilon} + + \frac{\alpha}{2\eta}\,\sigma^* + + (\varphi-\alpha)\,\dot{\varepsilon}^* + + Both forms reduce to bare ``ε̇`` when no elastic history is + active. The yield criterion ``η_pl = τ_y/(2|E_eff|_II)`` is the + same expression structure for both — it adapts naturally to the + integrator's ``E_eff``. + """ + E = self.Unknowns.E + + if self.Unknowns.DFDt is None or not self.is_elastic: + self._E_eff.sym = E + return self._E_eff + + DDt = self.Unknowns.DFDt + + if self._integrator == "etd": + # ETD-2 effective strain rate carrying α·σ*/(2η) and (φ-α)·ε̇*. + # ETD-1 (order=1): φ = α (set in _update_history_coefficients), + # so the (φ-α)·ε̇* term zeros out and (1-φ)·ε̇ → (1-α)·ε̇ — same + # expression tree, no separate code path needed. + alpha = DDt._exp_alpha + phi = DDt._exp_phi + sigma_star = DDt.psi_star[0].sym + if DDt.forcing_star is not None: + edot_star = DDt.forcing_star.sym + else: + edot_star = sympy.zeros(*E.shape) + eta_raw = self.Parameters.shear_viscosity_0 + self._E_eff.sym = ( + (1 - phi) * E + + (alpha / (2 * eta_raw)) * sigma_star + + (phi - alpha) * edot_star + ) + return self._E_eff + + # BDF default + mu_dt = self.Parameters.dt_elastic * self.Parameters.shear_modulus + bdf_cs = [self._bdf_c1, self._bdf_c2, self._bdf_c3] + for i in range(DDt.order): + E += -bdf_cs[i] * DDt.psi_star[i].sym / (2 * mu_dt) + self._E_eff.sym = E + return self._E_eff + + @property + def E_eff_inv_II(self): + r"""Second invariant of effective strain rate: :math:`\dot{\varepsilon}_{II} = \sqrt{\frac{1}{2}\dot{\varepsilon}_{ij}\dot{\varepsilon}_{ij}}`.""" + E_eff = self.E_eff.sym + self._E_eff_inv_II.sym = sympy.sqrt((E_eff**2).trace() / 2) + + return self._E_eff_inv_II + + @property + def K(self): + """Effective stiffness parameter (viscosity for visco-elastic-plastic flow).""" + return self.viscosity + + @property + def _unclipped_ve_viscosity(self): + """Unclipped viscoelastic viscosity (no yield wrap), depends on integrator. + + - BDF: ``ve_effective_viscosity = η·μΔt/(c₀·η + μΔt)`` — + baked-in time-integration factor for backward differentiation. + - ETD-2: ``η`` (raw) — the time-integration factor ``(1-φ)`` is + carried symbolically in :py:attr:`E_eff`, not folded into + this viscosity. + """ + if self._integrator == "etd": + return self.Parameters.shear_viscosity_0 + return self.Parameters.ve_effective_viscosity + + @property + def viscosity(self): + r"""Effective viscosity combining visco-elastic and plastic limits. + + The yield mode controls how η_ve and η_pl are combined: + + - ``"smooth"`` (default): corrected harmonic ``η_ve·(1+f)/(1+f+f²)`` + where ``f = η_ve/η_pl``. Converges to η_pl at deep yielding, + no Min/Max discontinuities. + - ``"harmonic"``: ``1/(1/η_ve + 1/η_pl)``. Smooth but undershoots τ_y + when η_ve is small relative to η_pl. + - ``"min"``: sharp ``Min(η_ve, η_pl)``. Exact yield stress but can + cause SNES divergence with higher-order BDF time integration. + + The unclipped η_ve depends on ``self._integrator`` — + :py:attr:`_unclipped_ve_viscosity` returns the correct base for + BDF or ETD-2 (raw η for ETD-2 since the time factor lives in + ``E_eff``; ``ve_effective_viscosity`` for BDF). + """ + + inner_self = self.Parameters + + if inner_self.yield_stress.sym == sympy.oo: + return self._unclipped_ve_viscosity + + effective_viscosity = self._unclipped_ve_viscosity + + if self.is_viscoplastic: + vp_effective_viscosity = self._plastic_effective_viscosity + # Combine η_ve with the plastic viscosity per yield_mode (harmonic / exact + # Min / δ-soft-min). The soft-min softness δ now lives in a constants[] atom + # (see _combine_yield / _get_yield_softness) — value-identical to the former + # inline float law at the same δ, but runtime-rampable with no recompile. + effective_viscosity = self._combine_yield( + effective_viscosity, vp_effective_viscosity + ) + + # Apply viscosity floor — but skip for smooth-blend yield modes + # where the outer Max creates a nested Min/Max that breaks the + # BDF-2 Jacobian. Those modes are already smooth and bounded. + + if inner_self.shear_viscosity_min.sym != -sympy.oo: + if self.is_viscoplastic and self._yield_mode in ("harmonic", "softmin"): + return effective_viscosity + else: + return sympy.Max( + effective_viscosity, + inner_self.shear_viscosity_min, + ) + + else: + return effective_viscosity + + # NOTE: a hard-Min smooth-tangent override (flux_jacobian = harmonic) was + # prototyped here but deferred to the yield-law / δ-homotopy follow-up. The + # smooth-Jacobian-with-Min-residual tangent is inconsistent (it is the + # consistent tangent of the *harmonic* problem) and converges WORSE than + # Picard on hard-yield VEP; the robust route is problem-space homotopy + # (ramp the softmin softness δ→0), not a smooth tangent. See the design doc + # docs/developer/design/jacobian-unwrap-constants-bug.md. The generic + # Constitutive_Model.flux_jacobian hook (default None) remains available. + + @property + def _plastic_effective_viscosity(self): + parameters = self.Parameters + + if parameters.yield_stress == sympy.oo: + return sympy.oo + + # Use the effective strain rate (including elastic history) for the + # yield criterion. This must use the same order-dependent BDF + # coefficients as the stress formula. + Edot = self.E_eff.sym + + strainrate_inv_II = expression( + R"{\dot\varepsilon_{II}'}", + sympy.sqrt((Edot**2).trace() / 2), + "Strain rate 2nd Invariant including elastic strain rate term", + ) + + # Guard on the DISABLING sentinel, not on zero: zero is the default and a + # physically meaningful floor (the yield stress is compared against the second + # invariant of the stress, so a negative tau_y is meaningless). Only an + # explicit -oo turns the floor off. + if parameters.yield_stress_min.sym != -sympy.oo: + # Literal 0 for the default floor, not the parameter atom: sympy cannot + # fuzzy-compare an opaque UWexpression and Max canonicalisation recurses. + # smooth_max keeps both operands symbolic (the JIT routes the floor + # through constants[]) and needs no ordering test, which sympy cannot + # resolve against an opaque UWexpression. eps = 0 makes it exactly Max. + yield_stress = uw.maths.smooth_max( + parameters.yield_stress, parameters.yield_stress_min, 0 + ) + else: + yield_stress = parameters.yield_stress + + if parameters.strainrate_inv_II_min.sym != 0: + viscosity_yield = yield_stress / ( + 2 * (strainrate_inv_II + parameters.strainrate_inv_II_min) + ) + else: + viscosity_yield = yield_stress / (2 * strainrate_inv_II) + + return viscosity_yield + + def plastic_correction(self): + r"""Scaling factor to reduce stress to yield surface: :math:`f = \tau_y / \tau_{II}`.""" + parameters = self.Parameters + + if parameters.yield_stress == sympy.oo: + return sympy.sympify(1) + + stress = self.stress_projection() + + # The yield criterion in this case is assumed to be a bound on the second invariant of the stress + stress_inv_II = sympy.sqrt((stress**2).trace() / 2) + correction = parameters.yield_stress / stress_inv_II + + return correction + # return sympy.Min(1, correction) + + ## Is this really different from the original ? + + def _build_c_tensor(self): + """For this constitutive law, we expect just a viscosity function""" + + if self._is_setup: + print("Using cached value of c matrix", flush=True) + return + + print("Building c matrix", flush=True) + + d = self.dim + # inner_self = self.Parameters + viscosity = self.viscosity + + try: + # CRITICAL: Use .sym property to avoid UWexpression array corruption issues + # See ViscousFlowModel._build_c_tensor() for detailed explanation + viscosity_sym = viscosity.sym if hasattr(viscosity, "sym") else viscosity + self._c = 2 * uw.maths.tensor.rank4_identity(d) * viscosity_sym + except: + d = self.dim + dv = uw.maths.tensor.idxmap[d][0] + if isinstance(viscosity, sympy.Matrix) and viscosity.shape == (dv, dv): + self._c = 2 * uw.maths.tensor.mandel_to_rank4(viscosity, d) + elif isinstance(viscosity, sympy.Array) and viscosity.shape == (d, d, d, d): + self._c = 2 * viscosity + else: + raise RuntimeError( + "Viscosity is not a known type (scalar, Mandel matrix, or rank 4 tensor" + ) + + self._is_setup = True + self._solver_is_setup = False + + return + + # Modify flux to use the stress history term + # This may be preferable to using strain rate which can be discontinuous + # and harder to map back and forth between grid and particles without numerical smoothing + + @property + def flux(self): + r"""Computes the effect of the constitutive tensor on the gradients of the unknowns. + (always uses the `c` form of the tensor). In general cases, the history of the gradients + may be required to evaluate the flux. For viscoelasticity, the + """ + + stress = self.stress() + + # if self.is_viscoplastic: + # plastic_scale_factor = sympy.Max(1, self.plastic_overshoot()) + # stress /= plastic_scale_factor + + return stress + + def stress_projection(self): + """viscoelastic stress projection (no plastic response)""" + + edot = self.grad_u + + # This is a scalar viscosity ... + + stress = 2 * self.Parameters.ve_effective_viscosity * edot + + if self.Unknowns.DFDt is not None: + stress_star = self.Unknowns.DFDt.psi_star[0] + + if self.is_elastic: + # 1st order + stress += ( + self.Parameters.ve_effective_viscosity + * stress_star.sym + / (self.Parameters.dt_elastic * self.Parameters.shear_modulus) + ) + + return stress + + def stress(self): + """Viscoelastic(-plastic) deviatoric stress for the weak form. + + Both BDF and ETD-2 are written as ``σ = 2·viscosity·E_eff``. + :py:attr:`E_eff` carries the integrator-specific elastic-history + coupling, and :py:attr:`viscosity` returns the appropriate yield- + wrapped effective viscosity (``ve_effective_viscosity`` for BDF, + raw ``η`` for ETD-2 since the time factor is in ``E_eff``). + """ + if not self.is_elastic or self.Unknowns.DFDt is None: + return 2 * self.viscosity * self.grad_u + + # ETD-1 (order=1) uses the same E_eff machinery but with φ=α, so + # forcing_star is not required (the (φ-α)·ε̇* term zeros out). + # Only ETD-2 (order=2) needs forcing_star. + if ( + self._integrator == "etd" + and self._order == 2 + and self.Unknowns.DFDt.forcing_star is None + ): + raise RuntimeError( + "integrator='etd' requires a SemiLagrangian DDt with " + "with_forcing_history=True. The auto-DDt creation path " + "reads stress_history_ddt_kwargs — re-create the solver/" + "model so the kwargs propagate." + ) + + return 2 * self.viscosity * self.E_eff.sym + + # def eff_edot(self): + + # edot = self.grad_u + + # if self.Unknowns.DFDt is not None: + # stress_star = self.Unknowns.DFDt.psi_star[0] + + # if self.is_elastic: + # edot += stress_star.sym / ( + # 2 * self.Parameters.dt_elastic * self.Parameters.shear_modulus + # ) + + # return edot + + # def eff_edot_inv_II(self): + + # edot = self.eff_edot() + # edot_inv_II = sympy.sqrt((edot**2).trace() / 2) + + # return edot_inv_II + + def _object_viewer(self): + from IPython.display import Latex, Markdown, display + + # super()._object_viewer() + + display(Markdown(r"### Viscous deformation")) + display( + Latex( + r"$\quad\eta_\textrm{0} = $ " + + sympy.sympify(self.Parameters.shear_viscosity_0.sym)._repr_latex_() + ), + ) + + display(Markdown(r"#### Elastic deformation")) + display( + Latex( + r"$\quad\mu = $ " + sympy.sympify(self.Parameters.shear_modulus.sym)._repr_latex_(), + ), + Latex( + r"$\quad\Delta t_e = $ " + + sympy.sympify(self.Parameters.dt_elastic.sym)._repr_latex_(), + ), + ) + + display(Markdown(r"#### Plastic deformation")) + display( + Latex( + r"$\quad\tau_\textrm{y} = $ " + + sympy.sympify(self.Parameters.yield_stress.sym)._repr_latex_(), + ) + ## Todo: add all the other properties in here + ) + + @property + def yield_mode(self): + r"""How to combine VE and plastic viscosities. + + ``"softmin"`` (default): smooth approximation to Min — + ``η_ve / g(f)`` where ``g(f) ≈ max(1, f)`` with smoothing + parameter δ (``yield_softness``, default 0.1). Approaches + exact Min as δ → 0; smooth derivatives at the kink. + Recommended default: gets within ~2 % of the true yield + surface while avoiding the SNES kink penalties of ``"min"``. + ``"harmonic"``: parallel blending — ``1/(1/η_ve + 1/η_pl)``. + Smooth but undershoots τ_y for soft materials. + ``"min"``: sharp cutoff — ``Min(η_ve, η_pl)``. + Exact yield but can cause SNES divergence with BDF-2 and + BDF-2 phase-lag at BC discontinuities (see benchmarks). + + Note: the previous ``"smooth"`` mode (corrected harmonic + ``η_ve·(1+f)/(1+f+f²)``) was retired — it under-clipped the + yield surface by ~50 % under realistic forcing, with no + compensating benefit over ``softmin``. Recover from git + history if needed (commit message keyword: ``smooth_yield``). + """ + return self._yield_mode + + @yield_mode.setter + def yield_mode(self, value): + if value == "smooth": + raise ValueError( + "yield_mode='smooth' has been retired — it under-clipped " + "the yield surface by ~50%. Use 'softmin' instead " + "(default; close to exact Min with smooth derivatives)." + ) + if value not in ("min", "harmonic", "softmin"): + raise ValueError( + f"yield_mode must be 'min', 'harmonic', or 'softmin', got '{value}'" + ) + self._yield_mode = value + self._reset() + + @property + def yield_softness(self): + r"""Regularisation parameter δ for ``"softmin"`` yield mode. + + Controls how closely the soft minimum approximates the sharp Min. + Smaller values → sharper yield (closer to Min, less robust). + Larger values → smoother transition (more robust, lower stress). + + Default 0.1. Only used when ``yield_mode == "softmin"``. + Increase toward 0.5 if SNES convergence is difficult at yield onset. + """ + return self._yield_softness + + @yield_softness.setter + def yield_softness(self, value): + self._yield_softness = float(value) + # Keep the constants[] δ atom in sync (created lazily on first use) so a + # numeric δ assignment is reflected without a JIT recompile. + if getattr(self, "_yield_softness_expr", None) is not None: + self._yield_softness_expr.sym = sympy.Float(self._yield_softness) + self._reset() + + @property + def requires_stress_history(self): + """VEP models always require stress history tracking.""" + return True + + @property + def supports_yield_homotopy(self): + """This model carries a δ-parameterised yield law — ``solve(homotopy=True)`` + can march it. See :meth:`_yield_homotopy_control`.""" + return True + + # Picard, not Newton: the consistent yield tangent taken across the elastic + # stress-history block makes the Jacobian indefinite, and the linear solve fails + # outright (DIVERGED_LINEAR_SOLVE at 0 iterations). The frozen tangent is + # contractive, and with the δ-march it still converges to the exact yield surface. + _yield_homotopy_tangent = False + + @property + def plastic_fraction(self): + """Fraction of strain rate that is plastic: 1 - η_vep / η_ve.""" + return sympy.Max(0, 1 - self.viscosity / self.Parameters.ve_effective_viscosity.sym) + + @property + def is_elastic(self): + """True if elastic behavior is active (finite dt_elastic and shear_modulus).""" + # If any of these is not defined, elasticity is switched off + + if self.Parameters.dt_elastic.sym is sympy.oo: + return False + + if self.Parameters.shear_modulus.sym is sympy.oo: + return False + + return True + + @property + def is_viscoplastic(self): + """True if plastic yielding is active (finite yield_stress).""" + if self.Parameters.yield_stress.sym is sympy.oo: + return False + + return True + + +### + + +class MaxwellExponentialFlowModel(ViscoElasticPlasticFlowModel): + r"""Thin alias: ``ViscoElasticPlasticFlowModel(integrator='etd', order=1)``. + + .. deprecated:: Phase B + Use the canonical form ``ViscoElasticPlasticFlowModel(unknowns, + integrator='etd', order=1)`` directly. This sibling class + survives as a thin scaffold so existing scripts continue to + work; defaults to ETD-1 (recommended). + + See ``docs/developer/design/EXPONENTIAL_VE_INTEGRATOR.md`` for the + formulation. + """ + + def __init__(self, unknowns, material_name=None): + super().__init__( + unknowns, order=1, integrator="etd", + material_name=material_name, + ) + + + +### + + +class DiffusionModel(Constitutive_Model): + r""" + Diffusion (Fourier/Fick) constitutive model for scalar transport. + + Defines the flux-gradient relationship for scalar diffusion: + + .. math:: + + q_{i} = \kappa_{ij} \frac{\partial \phi}{\partial x_j} + + For isotropic diffusion, :math:`\kappa_{ij} = \kappa \delta_{ij}`. + + Parameters + ---------- + unknowns : Unknowns + The solver unknowns (the scalar field being diffused). + material_name : str, optional + Name identifier for this material. + + Examples + -------- + >>> diffusion = uw.constitutive_models.DiffusionModel(poisson.Unknowns) + >>> diffusion.Parameters.diffusivity = 1e-6 # m^2/s + >>> poisson.constitutive_model = diffusion + + See Also + -------- + AnisotropicDiffusionModel : For direction-dependent diffusivity. + """ + + class _Parameters(_ParameterBase): + """Any material properties that are defined by a constitutive relationship are + collected in the parameters which can then be defined/accessed by name in + individual instances of the class. + + Now uses Parameter descriptor pattern for automatic lazy evaluation preservation + with unit-aware quantities. + """ + + # Import Parameter descriptor (must use absolute import inside nested class) + import underworld3.utilities._api_tools as api_tools + + # Define diffusivity as a Parameter descriptor + # The lambda receives the _Parameters instance and creates the expression via the owning model + diffusivity = api_tools.Parameter( + r"\upkappa", + lambda params_instance: params_instance._owning_model.create_unique_symbol( + r"\upkappa", 1, "Diffusivity" + ), + "Diffusivity", + units="m**2/s" # Thermal or mass diffusivity + ) + + def __init__( + inner_self, + _owning_model, + ): + inner_self._owning_model = _owning_model + # Note: diffusivity is now a descriptor, no need to create it here + + @property + def K(self): + r"""Diffusivity :math:`\kappa` (alias for ``diffusivity``).""" + return self.Parameters.diffusivity + + @property + def diffusivity(self): + r"""Scalar or tensor diffusivity :math:`\kappa`.""" + return self.Parameters.diffusivity + + def _build_c_tensor(self): + """Build isotropic diffusivity tensor from scalar.""" + + d = self.dim + kappa = self.Parameters.diffusivity + + # Scalar diffusivity case + # Use element-wise construction (consistent with ViscousFlowModel pattern) + # to handle UWexpression properly and preserve for JIT unwrapping + result = sympy.Matrix.zeros(d, d) + + for i in range(d): + for j in range(d): + if i == j: + # Diagonal element: kappa + val = kappa + # Wrap if bare UWexpression to avoid Iterable check failure + if hasattr(val, '__getitem__') and not isinstance(val, (sympy.MatrixBase, sympy.NDimArray)): + val = sympy.Mul(sympy.S.One, val, evaluate=False) + result[i, j] = val + # Off-diagonal elements remain 0 + + self._c = result + + return + + def _object_viewer(self): + from IPython.display import Latex, Markdown, display + + super()._object_viewer() + + ## feedback on this instance + display( + Latex(r"$\quad\kappa = $ " + sympy.sympify(self.Parameters.diffusivity)._repr_latex_()) + ) + + return + + +# AnisotropicDiffusionModel: expects a diffusivity vector and builds a diagonal tensor. +class AnisotropicDiffusionModel(DiffusionModel): + r"""Anisotropic diffusion with direction-dependent diffusivities. + + Defines a diagonal diffusivity tensor :math:`\kappa_{ij} = \text{diag}(\kappa_0, \kappa_1, ...)` + for direction-dependent diffusion rates. + """ + + class _Parameters(_ParameterBase): + def __init__(inner_self, _owning_model): + dim = _owning_model.dim + inner_self._owning_model = _owning_model + # Set default diffusivity as an identity matrix wrapped in an expression + default_diffusivity = sympy.ones(_owning_model.dim, 1) + elements = [default_diffusivity[i] for i in range(dim)] + validated = [] + for i, v in enumerate(elements): + comp = validate_parameters( + rf"\upkappa_{{{i}}}", v, f"Diffusivity in x_{i}", allow_number=True + ) + if comp is not None: + validated.append(comp) + # Store the validated diffusivity as a diagonal matrix + inner_self._diffusivity = sympy.diag(*validated) + + @property + def diffusivity(inner_self): + """Diagonal diffusivity tensor.""" + return inner_self._diffusivity + + @diffusivity.setter + def diffusivity(inner_self, value: sympy.Matrix): + """Set diffusivity from a vector of per-direction values.""" + dim = inner_self._owning_model.dim + + # Accept shape (dim, 1) or (1, dim) + if value.shape not in [(dim, 1), (1, dim)]: + raise ValueError( + f"Diffusivity must be a vector of length {dim}. Got shape {value.shape}." + ) + # Validate each component using validate_parameters + elements = [value[i] for i in range(dim)] + validated = [] + for i, v in enumerate(elements): + diff = validate_parameters( + rf"\upkappa_{{{i}}}", v, f"Diffusivity in x_{i}", allow_number=True + ) + if diff is not None: + validated.append(diff) + # Store the validated diffusivity as a diagonal matrix + inner_self._diffusivity = sympy.diag(*validated) + inner_self._reset() + + def _build_c_tensor(self): + """Constructs the anisotropic (diagonal) tensor from the diffusivity vector.""" + self._c = self.Parameters.diffusivity + self._is_setup = True + + def _object_viewer(self): + from IPython.display import Latex, display + + super()._object_viewer() + + diagonal = self.Parameters.diffusivity.diagonal() + latex_entries = ", ".join([sympy.latex(k) for k in diagonal]) + kappa_latex = r"\kappa = \mathrm{diag}\left(" + latex_entries + r"\right)" + display(Latex(r"$\quad " + kappa_latex + r"$")) + + +class GenericFluxModel(Constitutive_Model): + r""" + A generic constitutive model with symbolic flux expression. + + Example usage: + ```python + grad_phi = sympy.Matrix([sp.Symbol("dphi_dx"), sp.Symbol("dphi_dy")]) + flux_expr = sympy.Matrix([[kappa_11, kappa_12], [kappa_21, kappa_22]]) * grad_phi + + model = GenericFluxModel(dim=2) + model.flux = flux_expr + scalar_solver.constititutive_model = model + ``` + """ + + class _Parameters(_ParameterBase): + def __init__(inner_self, _owning_model): + inner_self._owning_model = _owning_model + + default_flux = sympy.zeros(_owning_model.dim, 1) + elements = [default_flux[i] for i in range(_owning_model.dim)] + validated = [] + for i, v in enumerate(elements): + flux_component = validate_parameters( + rf"q_{{{i}}}", v, f"Flux component in x_{i}", allow_number=True + ) + if flux_component is not None: + validated.append(flux_component) + + inner_self._flux = sympy.Matrix(validated) + + @property + def flux(inner_self): + """User-defined flux expression.""" + return inner_self._flux + + @flux.setter + def flux(inner_self, value: sympy.Matrix): + """Set the flux expression (must be a vector of length dim).""" + dim = inner_self._owning_model.dim + + # Accept shape (dim, 1) or (1, dim) + if value.shape not in [(dim, 1), (1, dim)]: + raise ValueError( + f"Flux must be a symbolic vector of length {dim}. " f"Got shape {value.shape}." + ) + + # Flatten and validate + elements = [value[i] for i in range(dim)] + validated = [] + for i, v in enumerate(elements): + flux_component = validate_parameters( + rf"q_{{{i}}}", v, f"Flux component in x_{i}", allow_number=True + ) + if flux_component is not None: + validated.append(flux_component) + + inner_self._flux = sympy.Matrix(validated).reshape(dim, 1) + inner_self._reset() + + @property + def flux(self): + """The user-defined flux expression.""" + # if self._flux is None: + # raise RuntimeError("Flux expression has not been set.") + return self.Parameters.flux + + def _object_viewer(self): + from IPython.display import display, Latex + + super()._object_viewer() + if self.flux is not None: + display(Latex(r"$\vec{q} = " + sympy.latex(self.flux) + "$")) + else: + display(Latex(r"No flux expression set.")) + + +class DarcyFlowModel(Constitutive_Model): + r""" + Darcy flow constitutive model for porous media flow. + + The ``flux`` property returns the assembly flux + :math:`\mathbf{F}` that enters the PDE as :math:`-\nabla\cdot\mathbf{F} = f`: + + .. math:: + + F_{i} = \kappa_{ij} \left( \frac{\partial p}{\partial x_j} - s_j \right) + + where :math:`\kappa` is the permeability (or hydraulic conductivity), + :math:`p` is the pressure (or hydraulic head), and :math:`s` is the + body force term (e.g., gravity: :math:`s = \rho g`). The **physical Darcy + velocity** is minus this (flow runs *down* the head gradient): + + .. math:: + + q_{i} = -F_{i} = -\kappa_{ij} \left( \frac{\partial p}{\partial x_j} - s_j \right) + + Parameters + ---------- + unknowns : Unknowns + The solver unknowns (the pressure/head field). + material_name : str, optional + Name identifier for this material. + + Examples + -------- + >>> darcy = uw.constitutive_models.DarcyFlowModel(solver.Unknowns) + >>> darcy.Parameters.permeability = 1e-12 # m^2 + >>> darcy.Parameters.s = [0, -rho * g] # Gravity in y-direction + >>> solver.constitutive_model = darcy + + See Also + -------- + DiffusionModel : For pure diffusion without body forces. + """ + + class _Parameters(_ParameterBase): + """Any material properties that are defined by a constitutive relationship are + collected in the parameters which can then be defined/accessed by name in + individual instances of the class. + + Uses Parameter descriptor pattern for scalar permeability. + Matrix-valued `s` remains instance-level (special case). + """ + + # Import Parameter descriptor (must use absolute import inside nested class) + import underworld3.utilities._api_tools as api_tools + + # Define permeability as a Parameter descriptor + permeability = api_tools.Parameter( + r"\kappa", + lambda params_instance: params_instance._owning_model.create_unique_symbol( + r"\kappa", 1, "Permeability" + ), + "Permeability", + units="m**2" # Intrinsic permeability + ) + + def __init__( + inner_self, + _owning_model, + permeabililty: Union[float, sympy.Function] = 1, # Note: typo in param name preserved for compatibility + ): + + inner_self._s = expression( + R"{s}", + sympy.Matrix.zeros( + rows=1, cols=_owning_model.dim + ), # Row matrix (1, dim) to match grad_u from jacobian + "Gravitational forcing", + ) + + inner_self._owning_model = _owning_model + # Note: permeability is now a descriptor, no need to create it here + + @property + def s(inner_self): + r"""Body force vector (e.g., gravitational source term :math:`\rho \mathbf{g}`).""" + return inner_self._s + + @s.setter + def s(inner_self, value: sympy.Matrix): + """Set the body force vector.""" + # Update expression content in-place to preserve object identity + # Cannot use validate_parameters() as it doesn't handle matrices + # UWexpression.sym setter handles sympy.Matrix directly + inner_self._s.sym = value + inner_self._reset() + + @property + def K(self): + r"""Permeability :math:`\kappa` [m²] - the primary constitutive parameter.""" + return self.Parameters.permeability + + def _build_c_tensor(self): + """For this constitutive law, we expect just a permeability function""" + + d = self.dim + kappa = self.Parameters.permeability + + # Scalar permeability case + # Use element-wise construction (consistent with ViscousFlowModel and DiffusionModel) + # to handle UWexpression properly and preserve for JIT unwrapping + result = sympy.Matrix.zeros(d, d) + + for i in range(d): + for j in range(d): + if i == j: + # Diagonal element: kappa + val = kappa + # Wrap if bare UWexpression to avoid Iterable check failure + if hasattr(val, '__getitem__') and not isinstance(val, (sympy.MatrixBase, sympy.NDimArray)): + val = sympy.Mul(sympy.S.One, val, evaluate=False) + result[i, j] = val + # Off-diagonal elements remain 0 + + self._c = result + + return + + def _object_viewer(self): + from IPython.display import Latex, Markdown, display + + super()._object_viewer() + + ## feedback on this instance + display( + Latex(r"$\quad\kappa = $ " + sympy.sympify(self.Parameters.diffusivity)._repr_latex_()) + ) + + return + + @property + def flux(self): + """Computes the effect of the constitutive tensor on the gradients of the unknowns. + (always uses the `c` form of the tensor). In general cases, the history of the gradients + may be required to evaluate the flux. + """ + + ddu = self.grad_u - self.Parameters.s.sym + + return self._q(ddu) + + +class TransverseIsotropicFlowModel(ViscousFlowModel): + r""" + Transversely isotropic (anisotropic) viscous flow model. + + .. math:: + + \tau_{ij} = \eta_{ijkl} \cdot \frac{1}{2} \left[ \frac{\partial u_k}{\partial x_l} + + \frac{\partial u_l}{\partial x_k} \right] + + where :math:`\eta` is the viscosity tensor defined as: + + .. math:: + + \eta_{ijkl} = \eta_0 \cdot I_{ijkl} + (\eta_0-\eta_1) \left[ \frac{1}{2} \left[ + n_i n_l \delta_{jk} + n_j n_k \delta_{il} + n_i n_l \delta_{jk} + + n_j n_l \delta_{ik} \right] - 2 n_i n_j n_k n_l \right] + + and :math:`\hat{\mathbf{n}} \equiv \{n_i\}` is the unit vector defining + the local orientation of the weak plane (a.k.a. the director). + + The Mandel constitutive matrix is available in ``viscous_model.C`` and the + rank-4 tensor form is in ``viscous_model.c``. + + Examples + -------- + >>> viscous_model = TransverseIsotropicFlowModel(dim) + >>> viscous_model.material_properties = viscous_model.Parameters( + ... eta_0=viscosity_fn, + ... eta_1=weak_viscosity_fn, + ... director=orientation_vector_fn + ... ) + >>> solver.constitutive_model = viscous_model + >>> tau = viscous_model.flux(gradient_matrix) + --- + """ + + def __init__(self, unknowns, material_name: str = None): + # All this needs to do is define the + # viscosity property and init the parent(s) + # In this case, nothing seems to be needed. + # The viscosity is completely defined + # in terms of the Parameters + + super().__init__(unknowns, material_name=material_name) + + # self._viscosity = expression( + # R"{\eta_0}", + # 1, + # " Apparent viscosity", + # ) + + class _Parameters(_ParameterBase, _ViscousParameterAlias): + """Any material properties that are defined by a constitutive relationship are + collected in the parameters which can then be defined/accessed by name in + individual instances of the class. + + Uses Parameter descriptor pattern for automatic lazy evaluation preservation + with unit-aware quantities. + """ + + # Import Parameter descriptor (must use absolute import inside nested class) + import underworld3.utilities._api_tools as api_tools + + shear_viscosity_0 = api_tools.Parameter( + r"\eta_0", + lambda inner_self: 1, + "Shear viscosity", + units="Pa*s", + ) + + shear_viscosity_1 = api_tools.Parameter( + r"\eta_1", + lambda inner_self: 1, + "Second viscosity", + units="Pa*s", + ) + + director = api_tools.Parameter( + r"\hat{n}", + lambda inner_self: sympy.Matrix([0] * (inner_self._owning_model.dim - 1) + [1]), + "Director orientation", + units=None, # Dimensionless unit vector + ) + + def __init__( + inner_self, + _owning_model, + ): + inner_self._owning_model = _owning_model + # Parameters are now descriptors - no manual initialization needed + + ## End of parameters + + @property + def viscosity(self): + """Whatever the consistutive model defines as the effective value of viscosity + in the form of an uw.expression""" + + return self.Parameters.shear_viscosity_0 + + @property + def K(self): + """Whatever the consistutive model defines as the effective value of viscosity + in the form of an uw.expression""" + + return self.Parameters.shear_viscosity_0 + + @property + def grad_u(self): + r"""Symmetric strain rate tensor (with 1/2 factor). + + .. math:: + \dot{\varepsilon}_{ij} = \frac{1}{2}\left(\frac{\partial u_i}{\partial x_j} + + \frac{\partial u_j}{\partial x_i}\right) + """ + mesh = self.Unknowns.u.mesh + + return mesh.vector.strain_tensor(self.Unknowns.u.sym) + + def _build_c_tensor(self): + """For this constitutive law, we expect two viscosity functions + and a sympy row-matrix that describes the director components n_{i}""" + + if self._is_setup: + return + + d = self.dim + dv = uw.maths.tensor.idxmap[d][0] + + # Use .sym to get sympy expressions from Parameters + eta_0 = self.Parameters.shear_viscosity_0.sym + eta_1 = self.Parameters.shear_viscosity_1.sym + n = self.Parameters.director.sym + + Delta = eta_0 - eta_1 + + # Use element-wise construction (same pattern as ViscousFlowModel). + # UWexpression has __getitem__ from MathematicalMixin, making it appear + # "Iterable" to SymPy's array multiplication operator, which rejects it. + # Element-wise construction avoids this by creating Mul objects that + # don't have __getitem__. + identity = uw.maths.tensor.rank4_identity(d) + lambda_mat = sympy.MutableDenseNDimArray.zeros(d, d, d, d) + + for i in range(d): + for j in range(d): + for k in range(d): + for l in range(d): + # Build isotropic part element-wise + base_val = 2 * identity[i, j, k, l] * eta_0 + + # Anisotropic correction term + aniso_correction = ( + 2 + * Delta + * ( + ( + n[i] * n[k] * int(j == l) + + n[j] * n[k] * int(l == i) + + n[i] * n[l] * int(j == k) + + n[j] * n[l] * int(k == i) + ) + / 2 + - 2 * n[i] * n[j] * n[k] * n[l] + ) + ) + + val = base_val - aniso_correction + + # Wrap if needed to avoid Iterable check during assignment + if hasattr(val, '__getitem__') and not isinstance(val, (sympy.MatrixBase, sympy.NDimArray)): + val = sympy.Mul(sympy.S.One, val, evaluate=False) + + lambda_mat[i, j, k, l] = val + + lambda_mat = uw.maths.tensor.rank4_to_mandel(lambda_mat, d) + + self._c = uw.maths.tensor.mandel_to_rank4(lambda_mat, d) + + self._is_setup = True + self._solver_is_setup = False + + return + + def _object_viewer(self): + from IPython.display import Latex, Markdown, display + + super()._object_viewer() + + ## feedback on this instance + display(Latex(r"$\quad\eta_0 = $ " + sympy.sympify(self.Parameters.shear_viscosity_0)._repr_latex_())) + display(Latex(r"$\quad\eta_1 = $ " + sympy.sympify(self.Parameters.shear_viscosity_1)._repr_latex_())) + display( + Latex( + r"$\quad\hat{\mathbf{n}} = $ " + + sympy.sympify(self.Parameters.director.T)._repr_latex_() + ) + ) + + +class TransverseIsotropicVEPFlowModel(TransverseIsotropicFlowModel): + r"""Transversely isotropic viscoelastic-plastic flow model for fault mechanics. + + Combines the anisotropic viscosity tensor from :class:`TransverseIsotropicFlowModel` + with viscoelastic stress history and plastic yield limiting on the fault plane. + + The anisotropic viscosity tensor uses two viscosities (η₀ for the bulk, + η₁ for fault-plane shear) and a director n̂ defining the weak plane. + The yield stress τ_y limits the shear stress resolved on the fault plane. + + Parameters + ---------- + unknowns : Unknowns + Solver unknowns (velocity, pressure). + order : int, default=1 + Time integration order for stress history (1 or 2). + material_name : str, optional + Name for disambiguation in multi-material setups. + + See Also + -------- + TransverseIsotropicFlowModel : Anisotropic viscous model (no yield/elasticity). + ViscoElasticPlasticFlowModel : Isotropic VEP model. + """ + + def __init__(self, unknowns, order=1, integrator: str = "bdf", + fault_weight=None, + material_name: str = None): + """Construct a transversely isotropic VEP flow model. + + Parameters + ---------- + unknowns : Unknowns + Solver unknowns (velocity, pressure). + order : int, default 1 + Time-integration order. Combines with ``integrator``: + + - ``integrator='bdf', order=1``: BDF-1 (backward Euler). + - ``integrator='bdf', order=2``: BDF-2. + - ``integrator='etd', order=1``: ETD-1 (single-step, + fully L-stable, **recommended default for VEP+yield**). + Reproduces BDF-1 byte-identically on the killer test; + wins at large ``Δt/τ`` where the analytical relaxation + factor matters. + - ``integrator='etd', order=2``: ETD-2 (linear-quadrature + forcing history). 4× more accurate than BDF-2 on smooth + VE but blows up under active yield in tight-yield TI + faults — see lessons #7, #9 in EXPONENTIAL_VE_INTEGRATOR.md. + + ``integrator='hybrid'`` pins ``order=1``. + integrator : str, default "bdf" + Time integration scheme: + + - ``'bdf'``: Backward differentiation (default, robust for VEP). + - ``'etd'``: Exponential time-differencing — analytical + relaxation factor ``α = exp(-Δt/τ)``. Pair with ``order=1`` + for the recommended default; ``order=2`` is available but + unsafe under active yield (see lessons #7, #9). + - ``'hybrid'``: **EXPERIMENTAL — DO NOT USE FOR PRODUCTION.** + Spatial blend of BDF (inside fault) and ETD (outside + fault). Phase E investigation: σ enforcement is + BDF-class but |u_y| drifts monotonically over cycles + from shared-history coupling between the two branches. + See ``docs/developer/design/EXPONENTIAL_VE_INTEGRATOR.md`` + lesson #11. Use ``'bdf'`` for deep-yield fault problems. + Requires ``fault_weight`` parameter. + fault_weight : sympy expression, optional + Spatial weight ``w(x) ∈ [0, 1]`` selecting BDF (``w=1``) vs + ETD (``w=0``) per quadrature point. Required when + ``integrator='hybrid'``. Typically built from the + ``influence_function`` used to construct ``yield_stress``, + normalised so that ``w=1`` inside the fault zone (where + yielding can happen) and ``w=0`` in the bulk (where + ``τ_y → τ_y_bulk`` and yielding is structurally + unreachable). The flux blend is + ``σ = w·σ_BDF + (1-w)·σ_ETD``. + material_name : str, optional + Name identifier for this material. + """ + if integrator not in ("bdf", "etd", "hybrid"): + raise ValueError( + f"integrator must be 'bdf', 'etd', or 'hybrid', " + f"got '{integrator!r}'" + ) + if integrator == "etd" and order not in (1, 2): + raise ValueError( + f"integrator='etd' supports order=1 (ETD-1, default-recommended) " + f"or order=2 (ETD-2; avoid in tight-yield VEP). " + f"Got order={order}." + ) + self._integrator = integrator + if integrator == "hybrid" and order != 1: + import warnings + warnings.warn( + f"integrator='hybrid' uses one stress history slot; " + f"``order`` is pinned to 1 (you passed order={order}).", + UserWarning, stacklevel=2, + ) + order = 1 + if integrator == "hybrid" and fault_weight is None: + raise ValueError( + "integrator='hybrid' requires a ``fault_weight`` sympy " + "expression in [0, 1] (1 inside fault → BDF; 0 outside → ETD)." + ) + self._fault_weight = fault_weight + + self._material_name = material_name + + # Stress history expressions + self._stress_star = expression( + r"{\tau^{*}}", None, + r"Lagrangian Stress at $t - \delta_t$", + ) + self._stress_2star = expression( + r"{\tau^{**}}", None, + r"Lagrangian Stress at $t - 2\delta_t$", + ) + self._E_eff = expression( + r"{\dot{\varepsilon}_{\textrm{eff}}}", None, + "Equivalent value of strain rate (accounting for stress history)", + ) + self._E_eff_inv_II = expression( + r"{\dot{\varepsilon}_{II,\textrm{eff}}}", None, + "Equivalent value of strain rate 2nd invariant (accounting for stress history)", + ) + + self._order = order + self._yield_mode = "softmin" + self._yield_softness = 0.1 + # BDF order-blending α ∈ [0, 1]. α=1 → pure BDF-2 (default); + # α=0 → pure BDF-1 coefficients; intermediate → linear blend. + # + # NOTE: TI-VEP at order=2 with a spatially varying ``yield_stress`` + # field (e.g. ``influence_function``-localised faults) is unstable + # for α > ~0.25. The recommended user-facing fix is ``order=1`` + # (the class default). This knob is left as an *explicit* damping + # option for users who must use order=2 — it doesn't paper over + # the bug at the default. Empirical stability threshold: + # α ≤ 0.25 stable, α ≥ 0.5 blow-up. Investigated 2026-04. + self._bdf_blend = 1.0 + self._max_dt_ratio_for_higher_order = 2.0 + + # Timestep (set by solver) + self._dt = expression(r"{\Delta t}", sympy.oo, "Timestep (set by solver)") + + # BDF coefficients (initialised to BDF-1) + self._bdf_c0 = expression(r"{c_0^{\mathrm{BDF}}}", sympy.Integer(1), "BDF leading coefficient") + self._bdf_c1 = expression(r"{c_1^{\mathrm{BDF}}}", sympy.Integer(-1), "BDF history coefficient 1") + self._bdf_c2 = expression(r"{c_2^{\mathrm{BDF}}}", sympy.Integer(0), "BDF history coefficient 2") + self._bdf_c3 = expression(r"{c_3^{\mathrm{BDF}}}", sympy.Integer(0), "BDF history coefficient 3") + + self._reset() + + super().__init__(unknowns, material_name=material_name) + + return + + class _Parameters(_ParameterBase, _ViscousParameterAlias): + """Parameters for transverse isotropic VEP model. + + Combines anisotropic parameters (η₀, η₁, director) with VEP + parameters (shear_modulus, yield_stress, etc.). + """ + + import underworld3.utilities._api_tools as api_tools + + # Anisotropic parameters + shear_viscosity_0 = api_tools.Parameter( + r"\eta_0", lambda inner_self: 1, + "Bulk shear viscosity", units="Pa*s", + ) + shear_viscosity_1 = api_tools.Parameter( + r"\eta_1", lambda inner_self: 1, + "Fault-plane shear viscosity", units="Pa*s", + ) + director = api_tools.Parameter( + r"\hat{n}", + lambda inner_self: sympy.Matrix([0] * (inner_self._owning_model.dim - 1) + [1]), + "Director orientation (fault normal)", units=None, + ) + + # Elastic parameter + shear_modulus = api_tools.Parameter( + R"{\mu}", lambda inner_self: sympy.oo, + "Shear modulus", units="Pa", + ) + + # Timestep (managed by solver) + @property + def dt_elastic(inner_self): + """Timestep for VE formulas. Set by the solver.""" + return inner_self._owning_model._dt + + @dt_elastic.setter + def dt_elastic(inner_self, value): + if hasattr(value, 'sym'): + inner_self._owning_model._dt.sym = value.sym + else: + inner_self._owning_model._dt.sym = value + + # Viscosity limits + shear_viscosity_min = api_tools.Parameter( + R"{\eta_{\textrm{min}}}", + lambda inner_self: -sympy.oo, + "Shear viscosity, minimum cutoff", units="Pa*s", + ) + + # Yield parameters (applied to fault-plane shear) + yield_stress = api_tools.Parameter( + R"{\tau_{y}}", lambda inner_self: sympy.oo, + "Yield stress (fault-plane shear)", units="Pa", + ) + yield_stress_min = api_tools.Parameter( + R"{\tau_{y, \mathrm{min}}}", + lambda inner_self: 0, + "Yield stress minimum cutoff", units="Pa", + ) + strainrate_inv_II_min = api_tools.Parameter( + R"{\dot\varepsilon_{II,\mathrm{min}}}", + lambda inner_self: 0, + "Strain rate invariant minimum value", units="1/s", + ) + + def __init__(inner_self, _owning_model): + inner_self._owning_model = _owning_model + + inner_self._ve_effective_viscosity = expression( + R"{\eta_{\mathrm{eff}}}", None, + "Effective viscosity (elastic, fault-plane)", + ) + inner_self._t_relax = expression( + R"{t_{\mathrm{relax}}}", None, + "Maxwell relaxation time", + ) + + @property + def ve_effective_viscosity(inner_self): + r"""VE effective viscosity using η₁ (fault-plane viscosity).""" + mu_val = inner_self.shear_modulus.sym if hasattr(inner_self.shear_modulus, 'sym') else inner_self.shear_modulus + if mu_val is sympy.oo: + return inner_self.shear_viscosity_1 + + eta = inner_self.shear_viscosity_1 + mu = inner_self.shear_modulus + dt_e = inner_self.dt_elastic + c0 = inner_self._owning_model._bdf_c0 + + el_eff_visc = eta * mu * dt_e / (c0 * eta + mu * dt_e) + inner_self._ve_effective_viscosity.sym = el_eff_visc + return inner_self._ve_effective_viscosity + + @property + def t_relax(inner_self): + r"""Maxwell relaxation time: η₁ / μ.""" + inner_self._t_relax.sym = inner_self.shear_viscosity_1 / inner_self.shear_modulus + return inner_self._t_relax + + ## End of parameters + + @property + def is_elastic(self): + """True if elastic behavior is active (finite shear_modulus).""" + if self.Parameters.shear_modulus.sym is sympy.oo: + return False + return True + + @property + def is_viscoplastic(self): + """True if plastic yielding is active (finite yield_stress).""" + if self.Parameters.yield_stress.sym is sympy.oo: + return False + return True + + @property + def order(self): + """Time integration order (1 or 2).""" + return self._order + + @order.setter + def order(self, value): + """Set time integration order (warns if DFDt already created).""" + self._order = value + self._reset() + solver = getattr(self.Parameters, '_solver', None) + if solver is not None: + ddt = getattr(solver.Unknowns, 'DFDt', None) + if ddt is not None and ddt.order < value: + import warnings + warnings.warn( + f"Setting order={value} but DFDt was created with order={ddt.order}. " + f"Create the model with the desired order before assigning to the solver.", + UserWarning, stacklevel=2, + ) + elif ddt is not None: + solver._order = value + return + + @property + def effective_order(self): + """Effective order accounting for DDt history startup.""" + if self.Unknowns is not None and self.Unknowns.DFDt is not None: + ddt_eff = self.Unknowns.DFDt.effective_order + return min(self._order, ddt_eff) + return self._order + + def _update_etd_coefficients(self): + """Refresh DDt's (α, φ) UWexpressions from τ_eff = η_1/μ.""" + if self.Unknowns.DFDt is None: + return + params = self.Parameters + if params.shear_modulus.sym is sympy.oo: + tau_eff = sympy.oo + else: + try: + eta_val = float(params.shear_viscosity_1.sym) + mu_val = float(params.shear_modulus.sym) + tau_eff = eta_val / mu_val if mu_val > 0 else sympy.oo + except (TypeError, ValueError): + tau_eff = None + try: + dt_val = ( + float(params.dt_elastic.sym) + if params.dt_elastic.sym is not sympy.oo + else None + ) + except (TypeError, ValueError): + dt_val = None + self.Unknowns.DFDt.update_exp_coefficients(dt_val, tau_eff) + + def _update_history_coefficients(self): + r"""Pre-solve hook — dispatches BDF, ETD (order 1/2), or hybrid. + + BDF: refresh ``_bdf_c0..c3``. ETD-2 (order=2): refresh ``α, φ`` + on the DDt from ``η_1/μ``. ETD-1 (order=1): same plus force + ``φ = α`` so the ``(φ-α)·ε̇*`` history term vanishes. Hybrid: + refresh both BDF and ETD-2 — flux uses both per-spatial weight. + """ + if self._integrator == "etd": + self._update_etd_coefficients() + if self._order == 1: + # ETD-1 reduction: φ = α zeros (φ-α)·ε̇* and turns + # (1-φ)·ε̇ into (1-α)·ε̇. + DDt = self.Unknowns.DFDt + if DDt is not None: + DDt._exp_phi.sym = DDt._exp_alpha.sym + elif self._integrator == "hybrid": + # Hybrid uses BOTH integrators with spatial blend; update + # both coefficient sets each step. + self._update_bdf_coefficients() + self._update_etd_coefficients() + else: + self._update_bdf_coefficients() + + def _update_history_post_solve(self): + """Post-solve hook — refresh forcing_star for ETD-2 / hybrid. + + ETD-1 (order=1) doesn't need this; the (φ-α)·ε̇* term zeros out. + """ + needs_forcing = ( + (self._integrator == "etd" and self._order == 2) + or self._integrator == "hybrid" + ) + if needs_forcing and self.Unknowns.DFDt is not None: + if self.Unknowns.DFDt.forcing_star is not None: + self.Unknowns.DFDt.update_forcing_history(forcing_fn=self.Unknowns.E) + + @property + def stress_history_ddt_kwargs(self): + """ETD-2 (order=2) and hybrid need the forcing-history slot; + BDF and ETD-1 do not. + """ + if self._integrator == "hybrid": + return {"with_forcing_history": True} + if self._integrator == "etd" and self._order == 2: + return {"with_forcing_history": True} + return {} + + def _update_bdf_coefficients(self): + """Update BDF coefficient UWexpressions with blending.""" + order = self.effective_order + + if self.Unknowns is not None and self.Unknowns.DFDt is not None: + dt_current = self.Parameters.dt_elastic + if hasattr(dt_current, 'sym'): + dt_current = dt_current.sym + + dt_history = self.Unknowns.DFDt._dt_history + if order >= 2 and len(dt_history) > 0 and dt_history[0] is not None: + try: + ratio = float(dt_current) / float(dt_history[0]) + if ratio > self._max_dt_ratio_for_higher_order: + order = 1 + except (TypeError, ZeroDivisionError): + pass + + coeffs = _bdf_coefficients(order, dt_current, dt_history) + else: + coeffs = _bdf_coefficients(order, None, []) + + # BDF order blending — see ``self._bdf_blend`` docstring. + # Linear mix of BDF-1 and the requested-order coefficients. + # α=0 → pure BDF-1; α=1 → no blend (skip). + alpha = self._bdf_blend + if alpha < 1 and order >= 2: + coeffs_o1 = _bdf_coefficients(1, dt_current, dt_history) \ + if (self.Unknowns is not None and self.Unknowns.DFDt is not None) \ + else _bdf_coefficients(1, None, []) + while len(coeffs_o1) < len(coeffs): + coeffs_o1.append(sympy.Integer(0)) + coeffs = [ + (1 - alpha) * c1 + alpha * ck + for c1, ck in zip(coeffs_o1, coeffs) + ] + + while len(coeffs) < 4: + coeffs.append(sympy.Integer(0)) + + self._bdf_c0.sym = coeffs[0] + self._bdf_c1.sym = coeffs[1] + self._bdf_c2.sym = coeffs[2] + self._bdf_c3.sym = coeffs[3] + + @property + def bdf_blend(self): + r"""BDF coefficient blending α ∈ [0, 1]. **Damping knob.** + + Linearly mixes BDF-1 and the requested-order coefficients: + ``c = (1-α)·c_BDF1 + α·c_requested_order``. α=1 is no blend. + + **For TI-VEP fault simulations, prefer ``order=1`` over tuning + this knob.** At ``order=2`` with a spatially varying + ``yield_stress`` field, the simulation drifts unstably: + |σ_xy| → 10⁸ over ~10 t_r. Empirically the instability is + gated by the magnitude of the ψ*_{n-1} weight: α ≤ 0.25 stable, + α ≥ 0.5 blow-up. Lower α values stabilise but throw away + most of BDF-2's accuracy advantage — at α=0.10 the trace + difference vs ``order=1`` is ~0.1% of peak, for ~50% wall-time + overhead. Use this knob only if you specifically need + order-2 behaviour on a problem with uniform yield_stress. + """ + return self._bdf_blend + + @bdf_blend.setter + def bdf_blend(self, value): + if not (0.0 <= float(value) <= 1.0): + raise ValueError(f"bdf_blend must be in [0, 1], got {value}") + self._bdf_blend = float(value) + + @property + def stress_star(self): + r"""Previous timestep stress from history.""" + if self.Unknowns.DFDt is not None: + self._stress_star.sym = self.Unknowns.DFDt.psi_star[0].sym + return self._stress_star + + def _e_eff_for(self, integrator_mode): + r"""Build E_eff for a given integrator mode (without storing on + ``self._E_eff``). Used by both the public :py:attr:`E_eff` and + the hybrid ``stress()`` path which needs both forms in one + evaluation. + """ + E = self.Unknowns.E + if self.Unknowns.DFDt is None or not self.is_elastic: + return E + DDt = self.Unknowns.DFDt + + if integrator_mode == "etd": + alpha = DDt._exp_alpha + phi = DDt._exp_phi + sigma_star = DDt.psi_star[0].sym + if DDt.forcing_star is not None: + edot_star = DDt.forcing_star.sym + else: + edot_star = sympy.zeros(*E.shape) + eta_1 = self.Parameters.shear_viscosity_1 + return ( + (1 - phi) * E + + (alpha / (2 * eta_1)) * sigma_star + + (phi - alpha) * edot_star + ) + + # BDF default + mu_dt = self.Parameters.dt_elastic * self.Parameters.shear_modulus + bdf_cs = [self._bdf_c1, self._bdf_c2, self._bdf_c3] + out = E + for i in range(DDt.order): + out = out - bdf_cs[i] * DDt.psi_star[i].sym / (2 * mu_dt) + return out + + @property + def E_eff(self): + r"""Effective strain rate including elastic-history coupling. + + BDF: ``E_eff = ε̇ - Σc_i·σ*/(2μΔt)``. + ETD-2: ``E_eff = (1-φ)·ε̇ + α·σ*/(2η₁) + (φ-α)·ε̇*``. + Hybrid: returns the BDF form (E_eff is consumed by yield-clip + code that should see the BDF rate metric); the actual flux uses + both forms inside :py:meth:`stress`. + """ + mode = "bdf" if self._integrator in ("bdf", "hybrid") else "etd" + self._E_eff.sym = self._e_eff_for(mode) + return self._E_eff + + @property + def E_eff_inv_II(self): + r"""Second invariant of effective strain rate.""" + E_eff = self.E_eff.sym + self._E_eff_inv_II.sym = sympy.sqrt((E_eff**2).trace() / 2) + return self._E_eff_inv_II + + @property + def viscosity(self): + r"""Effective viscosity for the fault-plane shear component. + + Applies the yield mode (softmin/min/harmonic) to η₁, leaving + η₀ (bulk) unchanged. The anisotropic tensor handles the + directional dependence. + """ + inner_self = self.Parameters + + if inner_self.yield_stress.sym == sympy.oo: + return inner_self.shear_viscosity_0 + + # η₁ is the fault-plane viscosity that gets yield-limited + eta_1_eff = inner_self.ve_effective_viscosity + + if self.is_viscoplastic: + vp_eff = self._plastic_effective_viscosity + if self._yield_mode == "harmonic": + eta_1_eff = 1 / (1 / eta_1_eff + 1 / vp_eff) + elif self._yield_mode == "softmin": + delta = self._yield_softness + f = eta_1_eff / vp_eff + import math # float offset avoids sympy expression blowup in tensor + offset = (-1 + math.sqrt(1 + delta**2)) / 2 + g = 1 + (f - 1 + sympy.sqrt((f - 1)**2 + delta**2)) / 2 - offset + eta_1_eff = eta_1_eff / g + else: + eta_1_eff = sympy.Min(eta_1_eff, vp_eff) + + return inner_self.shear_viscosity_0 + + @property + def K(self): + """Effective stiffness for preconditioner.""" + return self.Parameters.shear_viscosity_0 + + @property + def _plastic_effective_viscosity(self): + """Plastic viscosity from resolved fault-plane shear strain rate. + + Computes the in-plane shear magnitude using Pythagoras: + T = ε̇_eff · n (traction-like vector on fault) + ε̇_n = T · n (normal component) + |γ̇| = √(|T|² - ε̇_n²) (in-plane shear magnitude) + + This works in both 2D and 3D — no explicit tangent vector needed. + The formula 2η₁_pl = τ_y / |γ̇| is the same pattern as isotropic + Drucker-Prager but projected onto the fault plane. + """ + parameters = self.Parameters + + ty_val = parameters.yield_stress.sym if hasattr(parameters.yield_stress, 'sym') else parameters.yield_stress + if ty_val is sympy.oo: + return sympy.oo + + Edot = self.E_eff.sym + + # Resolve strain rate onto fault plane via Pythagoras + n = parameters.director.sym + T = Edot * n # "traction" vector on fault + edot_n = (n.T * T)[0, 0] # normal component + T_sq = (T.T * T)[0, 0] # |T|² + gamma_dot_sq = T_sq - edot_n**2 # in-plane shear² + gamma_dot_abs = sympy.sqrt(sympy.Max(gamma_dot_sq, 0)) + + tau_y = parameters.yield_stress + # Guard on the DISABLING sentinel, not on zero — zero is the default and a + # real floor (a negative yield stress is meaningless against an invariant). + if parameters.yield_stress_min.sym != -sympy.oo: + # Literal 0 for the default floor (sympy fuzzy-compare recursion on atoms). + # smooth_max: keeps the floor symbolic, no ordering test (see the note + # in Constitutive_Model._apply_floor). eps = 0 is exactly Max. + tau_y = uw.maths.smooth_max(tau_y, parameters.yield_stress_min, 0) + + if parameters.strainrate_inv_II_min.sym != 0: + viscosity_yield = tau_y / ( + 2 * (gamma_dot_abs + parameters.strainrate_inv_II_min) + ) + else: + viscosity_yield = tau_y / (2 * gamma_dot_abs) + + return viscosity_yield + + def _eta_for_tensor(self, integrator_mode, apply_yield): + """Return ``(eta_0, eta_1_eff)`` for tensor build, parameterised + by integrator mode and whether to apply yield clipping. + + - ``integrator_mode='bdf'``: η₀, η₁ are VE-effective (c₀-baked + Δt scaling — needed for BDF's E_eff structure). + - ``integrator_mode='etd'``: η₀, η₁ are raw (time factor lives + in α/φ symbolically). Used for both ETD-1 and ETD-2 — the + C tensor is identical; only the symbolic E_eff differs (via + ``self._order``). + - ``apply_yield=True``: softmin/min/harmonic clip on η₁_eff. + - ``apply_yield=False``: no clipping (use this for the ETD-VE + branch of the hybrid integrator, where the bulk is + structurally non-yieldable so clipping is a no-op anyway). + """ + if integrator_mode == "etd": + eta_0 = self.Parameters.shear_viscosity_0.sym + eta_1_eff = self.Parameters.shear_viscosity_1 + else: # bdf + eta_0_raw = self.Parameters.shear_viscosity_0 + mu = self.Parameters.shear_modulus + dt_e = self.Parameters.dt_elastic + c0 = self._bdf_c0 + mu_val = mu.sym if hasattr(mu, 'sym') else mu + if mu_val is sympy.oo: + eta_0 = eta_0_raw.sym if hasattr(eta_0_raw, 'sym') else eta_0_raw + else: + eta_0 = eta_0_raw * mu * dt_e / (c0 * eta_0_raw + mu * dt_e) + eta_1_eff = self.Parameters.ve_effective_viscosity + + if apply_yield and self.is_viscoplastic: + vp_eff = self._plastic_effective_viscosity + if self._yield_mode == "harmonic": + eta_1_eff = 1 / (1 / eta_1_eff + 1 / vp_eff) + elif self._yield_mode == "softmin": + delta = self._yield_softness + f = eta_1_eff / vp_eff + import math + offset = (-1 + math.sqrt(1 + delta**2)) / 2 + g = 1 + (f - 1 + sympy.sqrt((f - 1)**2 + delta**2)) / 2 - offset + eta_1_eff = eta_1_eff / g + else: + eta_1_eff = sympy.Min(eta_1_eff, vp_eff) + return eta_0, eta_1_eff + + def _assemble_c_tensor(self, eta_0, eta_1_eff): + """Build the anisotropic rank-4 tensor from ``(eta_0, eta_1_eff)``. + + Loop body identical to :py:meth:`_build_c_tensor_ve`; refactored + into a helper so the hybrid path can call it twice (BDF tensor + with yield clip, ETD tensor without) without code duplication. + """ + d = self.dim + n = self.Parameters.director.sym + Delta = eta_0 - eta_1_eff + identity = uw.maths.tensor.rank4_identity(d) + lambda_mat = sympy.MutableDenseNDimArray.zeros(d, d, d, d) + for i in range(d): + for j in range(d): + for k in range(d): + for l in range(d): + base_val = 2 * identity[i, j, k, l] * eta_0 + aniso_correction = ( + 2 * Delta * ( + (n[i] * n[k] * int(j == l) + + n[j] * n[k] * int(l == i) + + n[i] * n[l] * int(j == k) + + n[j] * n[l] * int(k == i)) / 2 + - 2 * n[i] * n[j] * n[k] * n[l] + ) + ) + val = base_val - aniso_correction + if hasattr(val, '__getitem__') and not isinstance(val, (sympy.MatrixBase, sympy.NDimArray)): + val = sympy.Mul(sympy.S.One, val, evaluate=False) + lambda_mat[i, j, k, l] = val + lambda_mat = uw.maths.tensor.rank4_to_mandel(lambda_mat, d) + return uw.maths.tensor.mandel_to_rank4(lambda_mat, d) + + def _build_c_tensor(self): + """Build the anisotropic tensor(s) for the active integrator. + + - ``'bdf'`` / ``'etd'``: single tensor ``self._c``. + - ``'hybrid'``: two tensors ``self._c_bdf`` (yield-clipped) and + ``self._c_etd`` (no clip — bulk is non-yieldable). The flux + blend ``w·σ_BDF + (1-w)·σ_ETD`` happens in :py:meth:`stress`. + """ + if self._is_setup: + return + + if self._integrator == "hybrid": + eta_0_bdf, eta_1_bdf = self._eta_for_tensor("bdf", apply_yield=True) + self._c_bdf = self._assemble_c_tensor(eta_0_bdf, eta_1_bdf) + eta_0_etd, eta_1_etd = self._eta_for_tensor("etd", apply_yield=False) + self._c_etd = self._assemble_c_tensor(eta_0_etd, eta_1_etd) + self._c = self._c_bdf # default for any callers reading self._c + else: + eta_0, eta_1_eff = self._eta_for_tensor( + self._integrator, apply_yield=self.is_viscoplastic + ) + self._c = self._assemble_c_tensor(eta_0, eta_1_eff) + + self._is_setup = True + self._solver_is_setup = False + return + + @property + def flux(self): + """Stress flux for the weak form.""" + return self.stress() + + def stress_projection(self): + """VE stress without plastic correction (for history storage). + + Uses the anisotropic tensor with VE effective viscosities but + no yield limiting (η₁_ve, not η₁_eff). This is the stress that + should be stored in the DFDt history for the next timestep. + """ + edot = self.grad_u + self._build_c_tensor_ve() + # Contract with the VE-only tensor (not self._c which has yield) + c_ve = self._c_ve + if len(c_ve.shape) == 2: + flux = c_ve * edot + else: + flux = sympy.tensorcontraction( + sympy.tensorcontraction(sympy.tensorproduct(c_ve, edot), (1, 5)), (0, 3) + ) + return sympy.Matrix(flux) + + def _build_c_tensor_ve(self): + """Build anisotropic tensor with VE η₁ only (no yield).""" + d = self.dim + eta_0 = self.Parameters.shear_viscosity_0.sym + eta_1_ve = self.Parameters.ve_effective_viscosity + n = self.Parameters.director.sym + Delta = eta_0 - eta_1_ve + + identity = uw.maths.tensor.rank4_identity(d) + lambda_mat = sympy.MutableDenseNDimArray.zeros(d, d, d, d) + + for i in range(d): + for j in range(d): + for k in range(d): + for l in range(d): + base_val = 2 * identity[i, j, k, l] * eta_0 + aniso_correction = ( + 2 * Delta * ( + (n[i] * n[k] * int(j == l) + + n[j] * n[k] * int(l == i) + + n[i] * n[l] * int(j == k) + + n[j] * n[l] * int(k == i)) / 2 + - 2 * n[i] * n[j] * n[k] * n[l] + ) + ) + val = base_val - aniso_correction + if hasattr(val, '__getitem__') and not isinstance(val, (sympy.MatrixBase, sympy.NDimArray)): + val = sympy.Mul(sympy.S.One, val, evaluate=False) + lambda_mat[i, j, k, l] = val + + lambda_mat = uw.maths.tensor.rank4_to_mandel(lambda_mat, d) + self._c_ve = uw.maths.tensor.mandel_to_rank4(lambda_mat, d) + + def stress(self): + """Viscoelastic-plastic anisotropic stress for the weak form. + + Matches isotropic VEP pattern: tensor contraction for current strain + rate, scalar yield-limited viscosity for VE history terms. The tensor + C(η₁_eff) handles anisotropy; the history uses the same η₁_eff as + a scalar multiplier (consistent with how isotropic VEP uses + self.viscosity for both). + + Hybrid path: ``σ = w·σ_BDF + (1-w)·σ_ETD`` with the spatial + weight from ``self._fault_weight``. Each branch contracts its + own E_eff with its own C-tensor; the blend lives at the flux + level so neither integrator's structure is compromised. + """ + self._build_c_tensor() + + if self._integrator == "hybrid": + # σ_BDF: BDF flux with yield-clipped C tensor + edot_eff_bdf = self._e_eff_for("bdf") + sigma_bdf = self._q_with(self._c_bdf, edot_eff_bdf) + # σ_ETD: ETD flux with no-yield C tensor + edot_eff_etd = self._e_eff_for("etd") + sigma_etd = self._q_with(self._c_etd, edot_eff_etd) + # Spatial blend + w = self._fault_weight + return w * sigma_bdf + (1 - w) * sigma_etd + + # Apply the anisotropic tensor to the effective strain rate + # (current + VE history): σ = C(η₀_ve, η₁_eff) : ε̇_eff + # This is the correct VE formula — the tensor handles anisotropy + # for both current and history contributions uniformly. + edot_eff = self.E_eff.sym if hasattr(self.E_eff, 'sym') else self.E_eff + stress = self._q(edot_eff) + + return stress + + def _q_with(self, c, edot): + """Apply a given rank-4 tensor to a strain rate (helper for + the hybrid flux that needs to contract two distinct C tensors + in one ``stress()`` call). + """ + rank = len(c.shape) + if rank == 2: + flux = c * edot + else: + flux = sympy.tensorcontraction( + sympy.tensorcontraction(sympy.tensorproduct(c, edot), (1, 5)), + (0, 3), + ) + return sympy.Matrix(flux) + + @property + def yield_mode(self): + r"""How to apply yield limiting to the fault-plane viscosity. + + Same options as :class:`ViscoElasticPlasticFlowModel`: + ``"softmin"`` (default), ``"harmonic"``, ``"min"``. The + ``"smooth"`` option was retired (under-clipped by ~50 %); see + the parent class's :attr:`yield_mode` docstring for details. + """ + return self._yield_mode + + @yield_mode.setter + def yield_mode(self, value): + if value == "smooth": + raise ValueError( + "yield_mode='smooth' has been retired — it under-clipped " + "the yield surface by ~50%. Use 'softmin' instead " + "(default; close to exact Min with smooth derivatives)." + ) + if value not in ("min", "harmonic", "softmin"): + raise ValueError( + f"yield_mode must be 'min', 'harmonic', or 'softmin', got '{value}'" + ) + self._yield_mode = value + self._reset() + + @property + def yield_softness(self): + """Regularisation parameter δ for softmin mode.""" + return self._yield_softness + + @yield_softness.setter + def yield_softness(self, value): + self._yield_softness = value + self._reset() + + @property + def requires_stress_history(self): + """Transverse isotropic VEP requires stress history tracking.""" + return True + + @property + def supports_yield_homotopy(self): + """This model carries a δ-parameterised yield law — ``solve(homotopy=True)`` + can march it. Note this model's ``yield_softness`` setter re-triggers a + rebuild, so each δ step costs a recompile (the isotropic models update δ as a + ``constants[]`` atom instead). See :meth:`_yield_homotopy_control`.""" + return True + + # Picard, not Newton — as for the isotropic VEP model, the consistent yield + # tangent over the elastic stress-history block is indefinite. + _yield_homotopy_tangent = False + + @property + def plastic_fraction(self): + """Fraction of strain rate that is plastic.""" + eta_1_ve = self.Parameters.ve_effective_viscosity + eta_1_eff = self.viscosity + # viscosity property returns η₀, need to compare η₁ effective vs η₁ ve + # This is approximate for the anisotropic case + return sympy.Max(0, 1 - eta_1_eff / eta_1_ve.sym if hasattr(eta_1_ve, 'sym') else 0) + + +class TransverseIsotropicMaxwellExponentialFlowModel(TransverseIsotropicVEPFlowModel): + r"""Thin alias: ``TransverseIsotropicVEPFlowModel(integrator='etd', order=1)``. + + .. deprecated:: Phase B + Use the canonical form ``TransverseIsotropicVEPFlowModel(unknowns, + integrator='etd', order=1)`` directly. This sibling class + survives as a thin scaffold for existing scripts; defaults to + ETD-1 (recommended). + """ + + def __init__(self, unknowns, material_name=None): + super().__init__( + unknowns, order=1, integrator="etd", + material_name=material_name, + ) + + +class TransverseIsotropicVEPSplitFlowModel(TransverseIsotropicVEPFlowModel): + r"""**EXPERIMENTAL — DO NOT USE FOR PRODUCTION.** + + Phase D investigation artefact. The σ_∥ enforcement reaches BDF-class + fidelity (1.21·τ_y vs BDF's 1.04·τ_y at τ_y=0.05) but the velocity + field overshoots the boundary value and ratchets monotonically over + cycles to ~21× BDF-1's |u_y|. The fault-tip stress concentrations + in the PyVista field plot are non-physical for this loading. + + Retained on the branch for reproducibility of the investigation; see + ``docs/developer/design/EXPONENTIAL_VE_INTEGRATOR.md`` lessons #9 and + #10 for why this doesn't ship. + + For deep-yield TI fault problems use + ``TransverseIsotropicVEPFlowModel(integrator='bdf')``. + + -- + + Phase D — per-component ``(α_⊥, φ_⊥)/(α_∥, φ_∥)`` ETD-2 for TI VEP. + + The rank-4 modulus splits into two orthogonal projectors: + + .. math:: + C(\eta_0, \eta_\parallel) = 2\eta_0 \, \mathbf{P}_\perp + + 2\eta_\parallel \, \mathbf{P}_\parallel + + where :math:`\mathbf{P}_\parallel` is the director-aligned projector + (the ``K`` kernel built in :py:meth:`_build_c_tensor`) and + :math:`\mathbf{P}_\perp = \mathbf{I}_4 - \mathbf{P}_\parallel`. + Each branch has its own Maxwell relaxation time: + + .. math:: + \tau_\perp = \eta_0 / \mu, \qquad + \tau_\parallel = \eta_\parallel^\text{eff} / \mu + + so the analytical exponential factors differ: + + .. math:: + \alpha_k = e^{-\Delta t / \tau_k}, \qquad + \varphi_k = (1 - \alpha_k) \tau_k / \Delta t + + The split flux integrates each branch independently and sums: + + .. math:: + \sigma^{n+1} = (\alpha_\perp \mathbf{P}_\perp + \alpha_\parallel + \mathbf{P}_\parallel) : \sigma^* + + 2[\eta_0(1-\varphi_\perp) \mathbf{P}_\perp + + \eta_\parallel^\text{eff}(1-\varphi_\parallel) + \mathbf{P}_\parallel] : \dot\varepsilon^{n+1} + + 2[\eta_0(\varphi_\perp - \alpha_\perp) \mathbf{P}_\perp + + \eta_\parallel^\text{eff}(\varphi_\parallel - \alpha_\parallel) + \mathbf{P}_\parallel] : \dot\varepsilon^* + + Phase B uses a single lumped ``(α, φ)`` from ``η_∥_eff/μ`` for the + whole tensor — empirically blows up at tight yield surfaces because + the matrix branch has no business being yielded. The split scheme + relaxes each channel on its proper timescale; the analytical floor + on ``σ_∥`` is then ``≲ τ_y`` by construction. + + Implementation choice: ``α_⊥, φ_⊥`` come from the DDt's existing + scalar ``_exp_coeffs`` (matrix viscosity is fixed and spatially + uniform — a single per-step scalar is right). ``α_∥, φ_∥`` are + inlined as sympy expressions of the yield-clipped ``η_∥_eff`` so + the JIT evaluates them per quadrature point (spatial heterogeneity + captured automatically). No DDt changes, no solver changes. + """ + + # Default cap on τ_∥/Δt — recommendation 4 from the practical + # stabilisation strategy. α_∥ ≥ exp(-1/c); c=1 → α_∥ ≥ 0.37 + # (37% of σ* retained each step, matches BDF-style elastic + # damping). Set to 0 to disable (recovers the un-capped behaviour + # where boundary motion goes straight into plastic slip). + _tau_par_cap_factor = 1.0 + + def __init__(self, unknowns, material_name=None): + super().__init__( + unknowns, order=1, integrator="etd", + material_name=material_name, + ) + + @property + def tau_par_cap_factor(self): + r"""Lower bound ``c`` such that ``τ_∥ ≥ c·Δt`` in the parallel + branch's exponential factor. + + Caps how aggressively the parallel-branch's elastic memory is + relaxed during yielding. Without this cap, ``η_∥_eff → 0`` at + deep yield drives ``α_∥ = exp(-Δt/τ_∥) → 0`` — boundary + motion goes straight into slip each step with no elastic + spring-back, ratcheting fault displacement at the BC rate. + With ``c=1`` (default), ``α_∥ ≥ 1/e``; with ``c=2``, + ``α_∥ ≥ exp(-0.5)``. + + Set to ``0`` to disable (recovers the explicit-plasticity + behaviour). The cap applies only to the (α_∥, φ_∥) factors — + ``C_∥`` keeps the natural yield-clipped η_∥ so ``σ_∥`` still + sits at the yield surface. + """ + return self._tau_par_cap_factor + + @tau_par_cap_factor.setter + def tau_par_cap_factor(self, value): + self._tau_par_cap_factor = float(value) + + def _update_history_coefficients(self): + r"""Update ``(α_⊥, φ_⊥)`` only — the matrix branch. + + ``(α_∥, φ_∥)`` are inlined per-quadrature in :py:meth:`stress`. + Picks ``τ_⊥ = η_0/μ`` (raw matrix viscosity). + """ + if self._integrator != "etd" or self.Unknowns.DFDt is None: + return super()._update_history_coefficients() + params = self.Parameters + if params.shear_modulus.sym is sympy.oo: + tau_perp = sympy.oo + else: + try: + eta_val = float(params.shear_viscosity_0.sym) + mu_val = float(params.shear_modulus.sym) + tau_perp = eta_val / mu_val if mu_val > 0 else sympy.oo + except (TypeError, ValueError): + tau_perp = None + try: + dt_val = ( + float(params.dt_elastic.sym) + if params.dt_elastic.sym is not sympy.oo + else None + ) + except (TypeError, ValueError): + dt_val = None + self.Unknowns.DFDt.update_exp_coefficients(dt_val, tau_perp) + + def _eta_par_eff(self): + """Yield-clipped ``η_∥_eff`` — same softmin/min/harmonic as parent. + + For ETD the base is the raw ``η_1`` (no VE pre-clip); the yield + envelope is then applied via the configured yield_mode. + """ + params = self.Parameters + eta_par = params.shear_viscosity_1 + if hasattr(eta_par, 'sym'): + eta_par = eta_par.sym + + if not self.is_viscoplastic or params.yield_stress.sym is sympy.oo: + return eta_par + + vp_eff = self._plastic_effective_viscosity + if self._yield_mode == "harmonic": + return 1 / (1 / eta_par + 1 / vp_eff) + elif self._yield_mode == "softmin": + delta = self._yield_softness + f = eta_par / vp_eff + import math + offset = (-1 + math.sqrt(1 + delta**2)) / 2 + g = 1 + (f - 1 + sympy.sqrt((f - 1) ** 2 + delta ** 2)) / 2 - offset + return eta_par / g + else: + return sympy.Min(eta_par, vp_eff) + + def _eta_par_eff_lagged(self): + """Yield-clipped ``η_∥_eff`` using the **lagged** strain rate + (``forcing_star``, projected post-solve) instead of the current + ``E_eff``. + + Same softmin/harmonic/min envelope as :py:meth:`_eta_par_eff`, + but the plastic estimate ``vp_eff_lag = τ_y/(2|γ̇*|)`` uses the + *previous step's* fault-plane shear magnitude. This is the + proper "lag η_∥_eff": elastic regime → η_1_raw (low |γ̇*|); + yielded regime → ``τ_y/(2|γ̇*|)`` (saturated stress, large rate). + Using the parent's E_eff-based formula here would couple α_∥ + back into the current Newton iterate, which collapses to a + 1-iteration trivial residual (over-damping). Holding the rate + fixed at the post-solve value breaks that feedback. + """ + params = self.Parameters + DDt = self.Unknowns.DFDt + + eta_par = params.shear_viscosity_1 + if hasattr(eta_par, 'sym'): + eta_par = eta_par.sym + + if not self.is_viscoplastic or params.yield_stress.sym is sympy.oo: + return eta_par + if DDt is None or DDt.forcing_star is None: + return self._eta_par_eff() # no history yet — fall back to current + + # Lagged plastic estimate: |γ̇*_∥| from forcing_star + director + Edot_lag = DDt.forcing_star.sym + n = params.director.sym + T_lag = Edot_lag * n + edot_n_lag = (n.T * T_lag)[0, 0] + T_sq_lag = (T_lag.T * T_lag)[0, 0] + gamma_dot_sq_lag = T_sq_lag - edot_n_lag ** 2 + gamma_dot_abs_lag = sympy.sqrt(sympy.Max(gamma_dot_sq_lag, 0)) + + # Strip Pint from the ε_min floor (forcing_star is unitless storage) + edot_min_raw = params.strainrate_inv_II_min.sym + if hasattr(edot_min_raw, 'magnitude'): + edot_min_val = float(edot_min_raw.magnitude) + else: + try: + edot_min_val = float(edot_min_raw) + except (TypeError, ValueError): + edot_min_val = 1.0e-6 + + tau_y_sym = params.yield_stress.sym + vp_eff_lag = tau_y_sym / ( + 2 * (gamma_dot_abs_lag + sympy.Float(edot_min_val)) + ) + + if self._yield_mode == "harmonic": + return 1 / (1 / eta_par + 1 / vp_eff_lag) + elif self._yield_mode == "softmin": + delta = self._yield_softness + f = eta_par / vp_eff_lag + import math + offset = (-1 + math.sqrt(1 + delta ** 2)) / 2 + g = 1 + (f - 1 + sympy.sqrt((f - 1) ** 2 + delta ** 2)) / 2 - offset + return eta_par / g + else: + return sympy.Min(eta_par, vp_eff_lag) + + def _build_split_c_tensors(self, eta_perp, eta_par): + r"""Build ``C_⊥ = 2·η_⊥·P_⊥`` and ``C_∥ = 2·η_∥·P_∥``. + + Identical loop structure to :py:meth:`_build_c_tensor`, but + each tensor isolates one projector by zeroing the other + viscosity coefficient. + """ + d = self.dim + n = self.Parameters.director.sym + identity = uw.maths.tensor.rank4_identity(d) + + c_perp_arr = sympy.MutableDenseNDimArray.zeros(d, d, d, d) + c_par_arr = sympy.MutableDenseNDimArray.zeros(d, d, d, d) + + for i in range(d): + for j in range(d): + for k in range(d): + for l in range(d): + I_ijkl = identity[i, j, k, l] + K_ijkl = ( + (n[i] * n[k] * int(j == l) + + n[j] * n[k] * int(l == i) + + n[i] * n[l] * int(j == k) + + n[j] * n[l] * int(k == i)) / 2 + - 2 * n[i] * n[j] * n[k] * n[l] + ) + # 2·η_⊥·P_⊥ = 2·η_⊥·(I - K) + v_perp = 2 * eta_perp * (I_ijkl - K_ijkl) + # 2·η_∥·P_∥ = 2·η_∥·K + v_par = 2 * eta_par * K_ijkl + # Same guard as parent _build_c_tensor — sympy + # NDimArray.__setitem__ refuses iterable RHS. + if hasattr(v_perp, '__getitem__') and not isinstance( + v_perp, (sympy.MatrixBase, sympy.NDimArray) + ): + v_perp = sympy.Mul(sympy.S.One, v_perp, evaluate=False) + if hasattr(v_par, '__getitem__') and not isinstance( + v_par, (sympy.MatrixBase, sympy.NDimArray) + ): + v_par = sympy.Mul(sympy.S.One, v_par, evaluate=False) + c_perp_arr[i, j, k, l] = v_perp + c_par_arr[i, j, k, l] = v_par + + c_perp = uw.maths.tensor.mandel_to_rank4( + uw.maths.tensor.rank4_to_mandel(c_perp_arr, d), d) + c_par = uw.maths.tensor.mandel_to_rank4( + uw.maths.tensor.rank4_to_mandel(c_par_arr, d), d) + return c_perp, c_par + + def stress(self): + r"""Per-component ETD-2 flux with **lagged** ``(α_∥, φ_∥)``. + + Each branch's E_eff is built and contracted with its own + sub-modulus; the two are summed. + + ``α_∥, φ_∥`` are derived from a *lagged* parallel viscosity + computed from the projected stress and strain-rate histories: + + .. math:: + \eta_\parallel^{\,\mathrm{lag}} + = \frac{|\sigma^*_\parallel|} + {2\,\max(|\dot\varepsilon^*_\parallel|, \dot\varepsilon_\min)} + + where ``|·|_∥`` is the Pythagorean fault-plane shear magnitude + (same pattern as :py:meth:`_plastic_effective_viscosity`). This + sits naturally on the yield surface during yielding (because + ``|σ^*_∥|`` saturates near ``τ_y`` while ``|ε̇^*_∥|`` is large) + and tracks the elastic VE response otherwise. Critically the + expression depends only on previous-step storage — no + plasticity-clip recursion through ``E_eff`` — keeping the JIT + codegen tree shallow. + + The C_∥ sub-modulus still uses the **current** yield-clipped + ``η_∥_eff`` (preserves Newton's nonlinear plasticity Jacobian + through the multiplicative response weights). + """ + params = self.Parameters + DDt = self.Unknowns.DFDt + E = self.Unknowns.E + + # Matrix branch: scalar (α_⊥, φ_⊥) from DDt + alpha_perp = DDt._exp_alpha + phi_perp = DDt._exp_phi + eta_perp = params.shear_viscosity_0 + eta_perp_sym = eta_perp.sym + + # Histories + sigma_star = DDt.psi_star[0].sym + if DDt.forcing_star is not None: + edot_star = DDt.forcing_star.sym + else: + edot_star = sympy.zeros(*E.shape) + + # ── Lagged η_∥_eff via parent's softmin envelope, evaluated + # against forcing_star (previous-step ε̇) instead of E_eff + # (current Newton iterate). Breaks the per-quad-split's + # 1-iteration trivial-Newton failure mode. + eta_par_lag_chain = self._eta_par_eff_lagged() + if hasattr(eta_par_lag_chain, 'sym'): + eta_par_lagged = eta_par_lag_chain.sym + else: + eta_par_lagged = eta_par_lag_chain + + mu_sym = params.shear_modulus.sym + dt_sym = params.dt_elastic.sym if hasattr(params.dt_elastic, 'sym') else params.dt_elastic + # x_par = Δt/τ_∥ — natural value (no cap) + tau_par_natural = eta_par_lagged / mu_sym + x_par_natural = dt_sym / tau_par_natural + # Soft cap on x_par: x_eff = (1 - exp(-c·x))/c + # • x → 0: x_eff → x (elastic, no cap) + # • x → ∞: x_eff → 1/c (capped → α_∥ ≥ exp(-1/c)) + # • smooth derivatives everywhere; pre-evaluates to a finite + # scalar at codegen-time defaults (dt=∞, μ=∞, η=Pint) where + # x_natural = oo, exp(-c·oo) = 0, x_eff = 1/c — avoids the + # oo-vs-Pint dimensional clash that breaks sympy.Max/+ caps. + # Equivalent to capping τ_∥ ≥ c·Δt (recommendation #4). + if self._tau_par_cap_factor > 0.0: + c = sympy.Float(self._tau_par_cap_factor) + x_par = (1 - sympy.exp(-c * x_par_natural)) / c + else: + x_par = x_par_natural + alpha_par = sympy.exp(-x_par) + phi_par = (1 - alpha_par) / x_par + + # C_∥ uses the natural lagged η (no cap) — preserves the + # σ_∥ ≈ τ_y enforcement on the yield surface. The cap only + # tames the elastic-memory factor (α_∥, φ_∥) so the parallel + # branch retains some spring-back instead of fully releasing + # in one step. Recovers BDF-style behaviour where boundary + # motion is partly absorbed by elastic accumulation rather + # than dumped entirely into slip. + eta_par_current = eta_par_lagged + + # Build the split sub-moduli with the lagged η_∥ + c_perp, c_par = self._build_split_c_tensors(eta_perp_sym, eta_par_current) + + # E_eff_⊥ = (1-φ_⊥)·ε̇ + α_⊥/(2η_⊥)·σ* + (φ_⊥-α_⊥)·ε̇* + e_eff_perp = ( + (1 - phi_perp) * E + + (alpha_perp / (2 * eta_perp)) * sigma_star + + (phi_perp - alpha_perp) * edot_star + ) + # E_eff_∥ uses lagged α_∥, φ_∥ but current η_∥_eff in the + # σ*-projection denominator (so the projected history reads + # off the same modulus the next step's flux is built on). + e_eff_par = ( + (1 - phi_par) * E + + (alpha_par / (2 * eta_par_current)) * sigma_star + + (phi_par - alpha_par) * edot_star + ) + + def _contract(c, x): + if len(c.shape) == 2: + return sympy.Matrix(c * x) + return sympy.Matrix(sympy.tensorcontraction( + sympy.tensorcontraction(sympy.tensorproduct(c, x), (1, 5)), + (0, 3), + )) + + return _contract(c_perp, e_eff_perp) + _contract(c_par, e_eff_par) + + +class MultiMaterialConstitutiveModel(Constitutive_Model): + r""" + Multi-material constitutive model using level-set weighted flux averaging. + + Mathematical Foundation: + + .. math:: + + \mathbf{f}_{\text{composite}}(\mathbf{x}) = \sum_{i=1}^{N} + \phi_i(\mathbf{x}) \cdot \mathbf{f}_i(\mathbf{x}) + + Critical Architecture: + + - Solver owns Unknowns (including :math:`D\mathbf{F}/Dt` stress history) + - All constituent models share solver's Unknowns + - Composite flux becomes stress history for all materials + """ + + def __init__( + self, + unknowns, + material_swarmVariable: IndexSwarmVariable, + constitutive_models: list, + normalize_levelsets: bool = False, + ): + r""" + Parameters + ---------- + unknowns : UnknownSet + The solver's authoritative unknowns (:math:`\mathbf{u}`, + :math:`D\mathbf{F}/Dt`, :math:`D\mathbf{u}/Dt`). + material_swarmVariable : IndexSwarmVariable + Index variable tracking material distribution on particles. + constitutive_models : list of Constitutive_Model + Pre-configured constitutive models for each material. + normalize_levelsets : bool, optional + Whether to normalize level-set functions to enforce partition of unity. + Set to True if IndexSwarmVariable does not maintain partition of unity. + Default: False (assumes IndexSwarmVariable maintains partition of unity) + """ + # Validate compatibility before initialization + self._validate_model_compatibility(constitutive_models) + + self._material_var = material_swarmVariable + self._constitutive_models = constitutive_models + self._normalize_levelsets = normalize_levelsets + + # Ensure model count matches material indices + if len(constitutive_models) != material_swarmVariable.indices: + raise ValueError( + f"Model count ({len(constitutive_models)}) must match " + f"material indices ({material_swarmVariable.indices})" + ) + + # CRITICAL: Share solver's unknowns with all constituent models + self._setup_shared_unknowns(constitutive_models, unknowns) + + # Composite model doesn't have its own material_name - constituents do + super().__init__(unknowns, material_name=None) + + def _setup_shared_unknowns(self, constitutive_models, unknowns): + """ + Ensure all constituent models share the solver's authoritative unknowns. + This is critical for proper stress history management. + """ + for i, model in enumerate(constitutive_models): + # Share solver's unknowns - this gives access to composite D(F)/Dt history + model.Unknowns = unknowns + + # Validation: Ensure sharing worked correctly + assert model.Unknowns is unknowns, f"Model {i} failed to share unknowns - memory issue?" + + # For elastic models, verify DFDt access + if hasattr(model, "_stress_star"): + assert hasattr( + unknowns, "DFDt" + ), f"Model {i} needs stress history but DFDt not available" + + def _validate_model_compatibility(self, models: list) -> bool: + """ + Ensure all constituent models are compatible for flux averaging. + + Checks: + - Same u_dim (scalar vs vector problem compatibility) + - Same spatial dimension (2D/3D consistency) + - Compatible flux tensor shapes + - All models properly initialized + """ + if not models: + raise ValueError("At least one constitutive model required") + + reference_model = models[0] + reference_u_dim = reference_model.u_dim + reference_dim = reference_model.dim + + for i, model in enumerate(models): + if model.u_dim != reference_u_dim: + raise ValueError(f"Model {i} has u_dim={model.u_dim}, expected {reference_u_dim}") + if model.dim != reference_dim: + raise ValueError(f"Model {i} has dim={model.dim}, expected {reference_dim}") + # Validate model is properly initialized + if not hasattr(model, "Unknowns"): + raise ValueError(f"Model {i} is not properly initialized") + + return True + + @property + def flux(self): + r""" + Compute level-set weighted average of constituent model fluxes. + + CRITICAL: This composite flux becomes the stress history that + all constituent models (including elastic ones) will read via + ``DFDt.psi_star[0]`` in the next time step. + """ + # Get reference flux shape from first model + reference_flux = self._constitutive_models[0].flux + combined_flux = sympy.Matrix.zeros(*reference_flux.shape) + + if self._normalize_levelsets: + # Compute normalization factor to ensure partition of unity + total_levelset = sum( + self._material_var.sym[i] for i in range(self._material_var.indices) + ) + + for i in range(self._material_var.indices): + # Get normalized level-set function for material i + material_fraction = self._material_var.sym[i] / total_levelset + + # Get flux contribution from constituent model i + model_flux = self._constitutive_models[i].flux + + # Add weighted contribution to composite flux + combined_flux += material_fraction * model_flux + else: + # Use level-sets directly (assuming they already maintain partition of unity) + for i in range(self._material_var.indices): + # Get flux contribution from constituent model i + model_flux = self._constitutive_models[i].flux + + # Add weighted contribution using level-set directly + combined_flux += self._material_var.sym[i] * model_flux + + # This combined_flux will become the stress history for ALL materials + return combined_flux + + @property + def K(self): + r""" + Effective stiffness using level-set weighted harmonic average. + + For composite materials, harmonic averaging gives the correct effective + stiffness for preconditioning: $1/K_{eff} = \sum(\phi_i / K_i) / \sum(\phi_i)$ + """ + # Harmonic average: 1/K_eff = sum(phi_i / K_i) / sum(phi_i) + combined_inv_K = sympy.sympify(0) + + if self._normalize_levelsets: + # Compute normalization factor to ensure partition of unity + total_levelset = sum( + self._material_var.sym[i] for i in range(self._material_var.indices) + ) + + for i in range(self._material_var.indices): + # Get normalized level-set function for material i + material_fraction = self._material_var.sym[i] / total_levelset + + # Get stiffness from constituent model i + model_K = self._constitutive_models[i].K + + # Add weighted contribution to inverse stiffness + combined_inv_K += material_fraction / model_K + else: + # Use level-sets directly (assuming they already maintain partition of unity) + for i in range(self._material_var.indices): + # Get stiffness from constituent model i + model_K = self._constitutive_models[i].K + + # Add weighted contribution using level-set directly + combined_inv_K += self._material_var.sym[i] / model_K + + # Return harmonic average + return 1 / combined_inv_K + + def _object_viewer(self): + from IPython.display import Latex, Markdown, display + + super()._object_viewer() + + display(Markdown(f"**Multi-Material Model**: {len(self._constitutive_models)} materials")) + + for i, model in enumerate(self._constitutive_models): + display(Markdown(f"**Material {i}**: {type(model).__name__}")) + + if self.flux is not None: + display(Latex(r"$\mathbf{f}_{\text{composite}} = " + sympy.latex(self.flux) + "$")) diff --git a/src/underworld3/cython/petsc_generic_snes_solvers.pyx b/src/underworld3/cython/petsc_generic_snes_solvers.pyx index ab3095811..c4ab10ca0 100644 --- a/src/underworld3/cython/petsc_generic_snes_solvers.pyx +++ b/src/underworld3/cython/petsc_generic_snes_solvers.pyx @@ -843,6 +843,26 @@ class SolverBaseClass(uw_object): """ return self._has_solution + def _solution_is_trivially_zero(self): + """True when the solution field is still identically zero. + + The design's *secondary* cold signal: a solver may be told to warm-start + (``zero_init_guess=False``) while its solution variable has never been + written, which is a cold start in everything but name. It matters because a + viscoplastic tangent is undefined there — the plastic viscosity is + :math:`\\tau_y / (2\\dot\\varepsilon_{II})`, so at :math:`v = 0` the + *residual* is finite (the soft-min carries the infinite plastic branch to + the viscous one) but its derivative is not, and assembling the consistent + tangent produces NaN. + + Uses the PETSc vector norm, which is collective and therefore rank-uniform, + so every rank reaches the same decision. + """ + u = getattr(self, "u", None) + if u is None: + return False + return float(u.vec.norm()) == 0.0 + def _resolve_zero_init_guess(self, zero_init_guess): """Resolve the tri-state ``zero_init_guess`` argument of ``solve()``. @@ -8599,14 +8619,20 @@ class SNES_Stokes_SaddlePt(SolverBaseClass): # Automatic cold-start warm-up (Layer 1): a single Picard (frozen- # coefficient) step moves a cold guess into the Newton basin — it is - # defect-correction iteration 1, contractive and cheap. Taken only on a - # genuine cold start (zero_init_guess) under the consistent-Newton - # tangent, which is the opt-in nonlinear-yield regime that needs it; the - # default (frozen) tangent path is left bit-identical. Skipped when the - # caller set an explicit Picard count, and under the "continuation" - # tangent (which already warm-starts internally). See + # defect-correction iteration 1, contractive and cheap. The default + # (frozen) tangent path is left bit-identical, and an explicit Picard count + # is honoured as given. + # + # This is a DESIGN REQUIREMENT, not an optimisation: under the consistent + # tangent a viscoplastic Jacobian is NaN at zero strain rate (the residual + # survives, its derivative does not), so the machinery has to make that + # state unreachable. Both routes to it are covered — an explicit cold start, + # and a nominally warm one whose solution has never been written. The + # "continuation" tangent needs no help: it opens on a Picard stage + # (alpha = 0) by construction. See # docs/developer/design/nonlinear-solver-homotopy-warmstart.md (Layer 1). - if picard == 0 and zero_init_guess and self.consistent_jacobian is True: + if (picard == 0 and self.consistent_jacobian is True + and (zero_init_guess or self._solution_is_trivially_zero())): picard = 1 if verbose and uw.mpi.rank == 0: diff --git a/tests/test_1057_yield_homotopy_solve.py b/tests/test_1057_yield_homotopy_solve.py index 0449ca435..29cc08eba 100644 --- a/tests/test_1057_yield_homotopy_solve.py +++ b/tests/test_1057_yield_homotopy_solve.py @@ -136,6 +136,28 @@ def test_cold_viscoplastic_solve_survives_zero_strain_rate(mode, smoother): ) +@pytest.mark.parametrize("zero_init_guess", [True, False]) +def test_consistent_newton_never_assembles_at_zero_strain_rate(zero_init_guess): + """A viscoplastic Jacobian is NaN at zero strain rate, so the warm/cold machinery + must make that state unreachable — including when the caller asks for a WARM start + on a solution that has never been written (adversarial review, M9). + + The residual survives v=0 (the soft-min carries the infinite plastic branch to the + viscous one); the tangent does not. Only the interposed Picard step keeps the + consistent-Newton path off it. + """ + _, stokes = _viscoplastic_stokes(cellSize=0.5) + stokes.consistent_jacobian = True + assert stokes._solution_is_trivially_zero() is True # never solved + + stokes.solve(zero_init_guess=zero_init_guess) + reason = int(stokes.snes.getConvergedReason()) + assert reason > 0, ( + f"consistent-Newton solve from an all-zero field failed with reason={reason} " + f"(-4 is FNORM_NAN) for zero_init_guess={zero_init_guess}" + ) + + def test_yield_stress_is_floored_at_zero_by_default(): """The yield stress is compared against the second invariant of the stress, so a negative tau_y is meaningless. `yield_stress_min` therefore defaults to 0 and the From ef2765b7e637e1c89af6019a87d1a9cf5746c7a2 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Mon, 27 Jul 2026 11:21:20 +1000 Subject: [PATCH 18/21] fix(solver): close the remaining review minors and add parallel coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit m2 - a diverged LINEAR rotated free-slip solve no longer records success. The linear helper returns only ksp_reason (a linear solve can fail; _warn_if_ksp_diverged exists for exactly that), so the .get("converged", True) fallback claimed convergence and the next solve would warm-start off the bad iterate. It now falls back to ksp_reason > 0, which is what the sibling _capture_rotated_report already did -- the two diagnostics had been able to disagree. m7 - solve(homotopy=True) forwards the per-solve arguments it can honour (timestep, evalf, order, debug, debug_name, divergence_retries) to every step of the march instead of dropping them, and REFUSES the two that contradict it: the march decides cold-vs-warm per step and manages its own warm-up, so zero_init_guess and picard now raise rather than being silently ignored. m8 - resolved by C3: the base dispatch's missing solve_kwargs only mattered for a stress-history model on a non-overriding subclass, and the march refuses those now. m9 - YieldHomotopyControl and SolveReport are exported from underworld3.systems. Both are named in public docstrings (the control= parameter, solver.solve_report) and were deep-import-only, which Charter §6 forbids. m11 - the Darcy and TransientDarcy solve docstrings described the pre-flip default. m12 - test_1055_solve_report -> test_1058 (1055 collided with the yield smoother tests; 1056 was already taken twice, 1058 was genuinely free). PARALLEL COVERAGE (Charter §11) - ptest_0202 exercises all four layers at np>1, which nothing did before: has_solution and the tri-state resolution must agree across ranks or the solve collectives they gate would split (a hang, not a wrong answer); the homotopy march's branches all derive from collective SNES state; and the new gmres/sor fixed-cost smoother has to work with the redundant-LU coarse solve, which test_1014's serial option-string assertions cannot show. Green at np=2 and np=4, with the SAME settled delta and step count at both -- the march is partition-independent. Full level_1/tier_a gate: 480 passed. Underworld development team with AI support from Claude Code --- src/underworld3/systems/__init__.py | 3 +- src/underworld3/systems/solvers.py | 34 +++++- .../ptest_0202_warmstart_homotopy_parallel.py | 111 ++++++++++++++++++ tests/test_1057_yield_homotopy_solve.py | 9 ++ ...ve_report.py => test_1058_solve_report.py} | 0 5 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 tests/parallel/ptest_0202_warmstart_homotopy_parallel.py rename tests/{test_1055_solve_report.py => test_1058_solve_report.py} (100%) diff --git a/src/underworld3/systems/__init__.py b/src/underworld3/systems/__init__.py index 23af56799..ac4cd6a96 100644 --- a/src/underworld3/systems/__init__.py +++ b/src/underworld3/systems/__init__.py @@ -86,4 +86,5 @@ from .ddt import Eulerian as Eulerian_DDt # δ-continuation driver for hard viscoplastic (Drucker–Prager) yield -from .yield_continuation import yield_continuation +from .yield_continuation import yield_continuation, YieldHomotopyControl +from .solve_report import SolveReport diff --git a/src/underworld3/systems/solvers.py b/src/underworld3/systems/solvers.py index afe65f1d1..d03c27f22 100644 --- a/src/underworld3/systems/solvers.py +++ b/src/underworld3/systems/solvers.py @@ -708,7 +708,9 @@ def solve( Parameters ---------- zero_init_guess : bool, optional - If True (default), start from zero initial guess. + Cold or warm start. The default (``None``) auto-detects from + :attr:`has_solution`; ``True`` forces a fresh start, ``False`` insists + on warming from the current field values. If False, use current field values as initial guess. timestep : float, optional Timestep value for inertial terms (if applicable). @@ -952,7 +954,9 @@ def solve( Parameters ---------- zero_init_guess : bool, optional - Start from zero initial guess (default True). + Cold or warm start. The default (``None``) auto-detects from + :attr:`has_solution`; ``True`` forces a fresh start, ``False`` insists + on warming from the current field values. timestep : float, optional Timestep size. Updates ``self.delta_t`` if provided. _force_setup : bool, optional @@ -1476,11 +1480,29 @@ def solve( """ if homotopy: - # Each δ-step re-enters this method with homotopy=False. A VEP model's - # solves need the timestep, so forward it to every inner solve. + # Each δ-step re-enters this method with homotopy=False, so per-solve + # arguments are forwarded to EVERY step of the march rather than dropped. inner = {} - if timestep is not None: - inner["timestep"] = timestep + for name, value in (("timestep", timestep), ("evalf", evalf), + ("order", order), ("debug", debug), + ("debug_name", debug_name), + ("divergence_retries", divergence_retries)): + if value: + inner[name] = value + # The march owns these: it sets the cold/warm decision per step (Layer 1) + # and its own per-step iteration budget, so accepting a contradicting + # value silently would be worse than saying so. + if zero_init_guess is not None: + raise ValueError( + "solve(homotopy=True) chooses cold-vs-warm for each step of the " + "march itself; do not also pass zero_init_guess." + ) + if picard: + raise ValueError( + "solve(homotopy=True) manages its own warm-up; do not also pass " + "picard. Use homotopy_options={'entry_maxit': ...} to size the " + "first solve." + ) return self._solve_yield_homotopy( homotopy_options, verbose=verbose, solve_kwargs=inner ) diff --git a/tests/parallel/ptest_0202_warmstart_homotopy_parallel.py b/tests/parallel/ptest_0202_warmstart_homotopy_parallel.py new file mode 100644 index 000000000..bdcf7c696 --- /dev/null +++ b/tests/parallel/ptest_0202_warmstart_homotopy_parallel.py @@ -0,0 +1,111 @@ +"""Parallel (MPI) test: the nonlinear warm-start / yield-homotopy layers at np > 1. + +Charter §11 — no feature is complete if it only works in serial. All four layers of +``docs/developer/design/nonlinear-solver-homotopy-warmstart.md`` make decisions that +would deadlock or diverge between ranks if any of them were driven by a rank-LOCAL +quantity: + + * Layer 1a/1b — ``has_solution`` and the tri-state ``zero_init_guess`` gate the + ``dm.localToGlobal`` / ``snes.solve`` collectives. If ranks disagreed on + cold-vs-warm they would take different branches: a hang, not a wrong answer. + * Layer 2 — every branch of the δ-march (converged? retry? settle?) is taken from + ``snes.getConvergedReason()`` / ``getIterationNumber()``, which are collective and + therefore rank-identical. The revert also writes fields inside + ``synchronised_array_update`` on every rank. + * Layer 3 — the FMG velocity smoother (gmres + sor, fixed-cost V-cycle) has to work + with the redundant-LU coarse solve at np > 1, which the serial option-string test + in ``test_1014`` cannot exercise. + +Run: + + cd tests/parallel + mpirun -np 2 python ./ptest_0202_warmstart_homotopy_parallel.py + +Asserts (rank-collectively; any rank disagreeing shows up as a hang or a mismatch): + 1. ``has_solution`` is False before, True after, and identical on every rank. + 2. The auto-detected cold/warm resolution is identical on every rank. + 3. A geometric-FMG Stokes solve converges with the new default smoother. + 4. ``solve(homotopy=True)`` marches and converges, with the same settled δ and step + count on every rank. +""" + +import sympy + +import underworld3 as uw +from underworld3 import mpi + +comm = uw.mpi.comm +rank = uw.mpi.rank + + +def _all_ranks_agree(value): + """True when `value` is identical on every rank.""" + gathered = comm.allgather(value) + return all(v == gathered[0] for v in gathered) + + +# --- 1/2/3: warm-start bookkeeping and the FMG smoother, on a refined (hierarchy) mesh +mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.25, refinement=1, qdegree=3 +) +v = uw.discretisation.MeshVariable("Vp", mesh, mesh.dim, degree=2) +p = uw.discretisation.MeshVariable("Pp", mesh, 1, degree=1, continuous=True) + +stokes = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) +stokes.constitutive_model = uw.constitutive_models.ViscousFlowModel +stokes.constitutive_model.Parameters.shear_viscosity_0 = 1.0 +stokes.bodyforce = sympy.Matrix([0.0, -1.0]) +stokes.add_essential_bc((0.0, 0.0), "Bottom") +stokes.add_essential_bc((0.0, None), "Left") +stokes.add_essential_bc((0.0, None), "Right") +stokes.preconditioner = "fmg" # exercise the new gmres/sor smoother in parallel +stokes.tolerance = 1.0e-6 + +assert stokes.has_solution is False +assert _all_ranks_agree(stokes.has_solution), "has_solution disagrees between ranks" +assert _all_ranks_agree(stokes._resolve_zero_init_guess(None)), \ + "the cold/warm decision disagrees between ranks — the solve collectives would split" + +stokes.solve() +assert stokes.snes.getConvergedReason() > 0, "FMG Stokes solve failed at np>1" +assert stokes.has_solution is True +assert _all_ranks_agree(stokes.has_solution) +# With a solution in hand every rank must now independently decide "warm". +assert stokes._resolve_zero_init_guess(None) is False +assert _all_ranks_agree(stokes._resolve_zero_init_guess(None)) + +n_levels = len(getattr(mesh, "dm_hierarchy", []) or []) + +# --- 4: the yield homotopy march in parallel +mesh2 = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.3 +) +x, y = mesh2.X +v2 = uw.discretisation.MeshVariable("Vh", mesh2, mesh2.dim, degree=2) +p2 = uw.discretisation.MeshVariable("Ph", mesh2, 1, degree=1, continuous=True) +vp = uw.systems.Stokes(mesh2, velocityField=v2, pressureField=p2) +vp.constitutive_model = uw.constitutive_models.ViscoPlasticFlowModel +vp.constitutive_model.Parameters.shear_viscosity_0 = 1.0 +vp.constitutive_model.Parameters.yield_stress = 0.35 # genuinely yielding +vp.bodyforce = sympy.Matrix([[0.0, -2.0 * sympy.cos(sympy.pi * x)]]) +vp.add_essential_bc((sympy.oo, 0.0), "Top") +vp.add_essential_bc((sympy.oo, 0.0), "Bottom") +vp.add_essential_bc((0.0, sympy.oo), "Left") +vp.add_essential_bc((0.0, sympy.oo), "Right") +vp.petsc_use_pressure_nullspace = True +vp.tolerance = 1.0e-8 + +report = vp.solve(homotopy=True, + homotopy_options=dict(delta0=1.0, dmin=1.0e-3, verbose=False)) + +assert report["converged"] is True, f"homotopy march failed at np>1: {report}" +assert _all_ranks_agree(report["steps"]), "march step count disagrees between ranks" +assert _all_ranks_agree(report["settled_delta"]), "settled delta disagrees between ranks" +assert vp.has_solution is True +assert _all_ranks_agree(vp.has_solution) + +uw.pprint( + f"ptest_0202 OK (np={uw.mpi.size}): FMG hierarchy {n_levels} levels converged with the " + f"gmres/sor smoother; homotopy settled delta={report['settled_delta']:.3e} in " + f"{report['steps']} steps, rank-consistent" +) diff --git a/tests/test_1057_yield_homotopy_solve.py b/tests/test_1057_yield_homotopy_solve.py index 29cc08eba..5d7bc53df 100644 --- a/tests/test_1057_yield_homotopy_solve.py +++ b/tests/test_1057_yield_homotopy_solve.py @@ -231,6 +231,15 @@ def test_homotopy_restores_the_solver_tangent(): ) +@pytest.mark.parametrize("kwargs", [dict(zero_init_guess=True), dict(picard=2)]) +def test_homotopy_rejects_arguments_it_would_have_to_ignore(kwargs): + """The march decides cold-vs-warm and its own warm-up per step, so an argument + that contradicts it is refused rather than silently dropped (review, m7).""" + _, stokes = _viscoplastic_stokes(cellSize=0.5) + with pytest.raises(ValueError): + stokes.solve(homotopy=True, **kwargs) + + def test_homotopy_refuses_a_stress_history_solver(): """A march is several solves; on a VEP solver each one would advance the elastic stress history by a full timestep (adversarial review, C3). Refuse loudly rather diff --git a/tests/test_1055_solve_report.py b/tests/test_1058_solve_report.py similarity index 100% rename from tests/test_1055_solve_report.py rename to tests/test_1058_solve_report.py From eec04ced505885d12245ac0cfa3b1490079168b4 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Mon, 27 Jul 2026 11:48:29 +1000 Subject: [PATCH 19/21] feat(constitutive): apply the rate regularisation on the DP model; pin the homotopy capability strainrate_inv_II_min was DECLARED on ViscoPlasticFlowModel but never used -- setting it had no effect at all, while the elastic models (ViscoElasticPlastic, TransverseIsotropicVEP) have always applied it. It now enters the plastic viscosity the same way: eta_pl = tau_y / (2 (edot_II + edot_min)), capping eta_pl at tau_y/(2 edot_min) and so bounding the viscosity CONTRAST -- the knob the xi-style regularisation needs, and a prerequisite for testing whether xi can open cases the delta-homotopy alone cannot reach. Default 0 = off, so the unregularised law is unchanged. Two CAPABILITY tests, so the homotopy cannot regress at the user level into "runs without error but no longer rescues anything": * test_homotopy_rescues_a_solve_the_cold_start_cannot_do asserts BOTH halves on one problem -- the cold sharp solve must fail AND solve(homotopy=True) must converge, with a guard that the fixture is genuinely yielding (>20%) so the comparison cannot go vacuous. * test_rate_regularisation_is_wired_into_the_plastic_viscosity would have caught the ignored parameter above. The fixtures use a horizontally VARYING body force. A uniform one is hydrostatic -- pressure balances gravity, nothing moves, the strain rate is zero and the yield law never engages -- which is why every earlier viscoplastic fixture in this file sits at 0% yielding and why the homotopy went so long without being exercised on a case that actually yields. Underworld development team with AI support from Claude Code --- src/underworld3/constitutive_models.py | 14 +++- tests/test_1057_yield_homotopy_solve.py | 87 +++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/src/underworld3/constitutive_models.py b/src/underworld3/constitutive_models.py index 2df9b9920..35f34a641 100644 --- a/src/underworld3/constitutive_models.py +++ b/src/underworld3/constitutive_models.py @@ -1320,7 +1320,19 @@ def viscosity(self): else: yield_stress = inner_self.yield_stress - viscosity_yield = yield_stress / (2 * self._strainrate_inv_II) + # Rate regularisation. eta_pl = tau_y / (2 edot_II) is unbounded as edot -> 0; + # adding a floor to the strain rate caps it at tau_y/(2 edot_min) and so bounds + # the viscosity CONTRAST, which is what conditions the velocity block (the same + # role the Perzyna/rate-strengthening xi plays in the Spiegelman studies). The + # parameter was declared on this model but never applied — the elastic models + # (ViscoElasticPlastic, TransverseIsotropicVEP) have always used it. Default 0 + # = off, so the unregularised law is unchanged. + if inner_self.strainrate_inv_II_min.sym != 0: + viscosity_yield = yield_stress / ( + 2 * (self._strainrate_inv_II + inner_self.strainrate_inv_II_min) + ) + else: + viscosity_yield = yield_stress / (2 * self._strainrate_inv_II) # Combine the viscous and plastic (yield) viscosities. The default # yield_mode="min" gives the exact hard Min(η_0, η_yield); yield_mode="softmin" diff --git a/tests/test_1057_yield_homotopy_solve.py b/tests/test_1057_yield_homotopy_solve.py index 5d7bc53df..8bd74fd64 100644 --- a/tests/test_1057_yield_homotopy_solve.py +++ b/tests/test_1057_yield_homotopy_solve.py @@ -231,6 +231,93 @@ def test_homotopy_restores_the_solver_tangent(): ) +def _yielding_box(tag, tau_y, cellSize=0.2): + """A box sheared hard enough to yield over a large fraction of the domain. + + NOTE the horizontally-VARYING body force. A uniform one is hydrostatic — pressure + balances gravity, nothing moves, the strain rate is zero and the yield law never + engages. Every earlier "viscoplastic" fixture in this file yields 0% for exactly + that reason, which is why the homotopy went so long without being exercised. + """ + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=cellSize + ) + x, y = mesh.X + v = uw.discretisation.MeshVariable("Vy" + tag, mesh, mesh.dim, degree=2) + p = uw.discretisation.MeshVariable("Py" + tag, mesh, 1, degree=1, continuous=True) + s = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + s.constitutive_model = uw.constitutive_models.ViscoPlasticFlowModel + cm = s.constitutive_model + cm.Parameters.shear_viscosity_0 = 1.0 + cm.Parameters.yield_stress = tau_y + s.bodyforce = sympy.Matrix([[0.0, -2.0 * sympy.cos(sympy.pi * x)]]) + s.add_essential_bc((sympy.oo, 0.0), "Top") + s.add_essential_bc((sympy.oo, 0.0), "Bottom") + s.add_essential_bc((0.0, sympy.oo), "Left") + s.add_essential_bc((0.0, sympy.oo), "Right") + s.petsc_use_pressure_nullspace = True + s.tolerance = 1.0e-8 + return mesh, s, cm + + +@pytest.mark.level_2 +def test_homotopy_rescues_a_solve_the_cold_start_cannot_do(): + """THE user-level guarantee: ``solve(homotopy=True)`` converges on a genuinely + yielding problem where a direct cold solve of the sharp law does not. + + This is a CAPABILITY test, deliberately asserting both halves on the same problem, + so the feature cannot silently regress into "runs without error but no longer + rescues anything". At tau_y = 0.30 (~45% of the domain yielding) the cold hard-Min + solve gives DIVERGED_MAX_IT; the march settles near 1e-4 and converges. + """ + import numpy as np + + # (a) the direct cold solve of the sharp law FAILS + mesh, cold, cm_cold = _yielding_box("c", 0.30) + cm_cold.yield_mode = "min" + cold.solve() + cold_reason = int(cold.snes.getConvergedReason()) + + # (b) the homotopy, same problem, SUCCEEDS + mesh2, warm, cm_warm = _yielding_box("h", 0.30) + report = warm.solve(homotopy=True, + homotopy_options=dict(delta0=1.0, dmin=1.0e-3, verbose=False)) + + eta = uw.function.evaluate(cm_warm.viscosity.sym, mesh2.X.coords) + yielding = float(np.mean(eta < 0.99)) + + assert yielding > 0.2, ( + f"fixture is not exercising the yield law (only {yielding:.0%} yielding) — " + "the comparison would be vacuous" + ) + assert cold_reason < 0, ( + f"the cold sharp solve unexpectedly converged (reason={cold_reason}); this " + "fixture no longer demonstrates a rescue, retune tau_y" + ) + assert report["converged"] is True, ( + f"HOMOTOPY REGRESSION: the march no longer rescues a case the cold solve " + f"cannot do ({report})" + ) + assert warm.has_solution is True + + +@pytest.mark.level_2 +def test_rate_regularisation_is_wired_into_the_plastic_viscosity(): + """``strainrate_inv_II_min`` caps eta_pl at tau_y/(2 edot_min), bounding the + viscosity contrast — the knob the xi-style regularisation needs. + + Regression: it was declared on this model but never applied (the elastic models + always used it), so setting it had no effect at all. + """ + _, _, cm = _yielding_box("r", 0.30) + before = str(cm.viscosity.sym) + cm.Parameters.strainrate_inv_II_min = 0.05 + after = str(cm.viscosity.sym) + assert after != before, ( + "strainrate_inv_II_min does not change the viscosity — it is being ignored" + ) + + @pytest.mark.parametrize("kwargs", [dict(zero_init_guess=True), dict(picard=2)]) def test_homotopy_rejects_arguments_it_would_have_to_ignore(kwargs): """The march decides cold-vs-warm and its own warm-up per step, so an argument From 3d2d27bf6aaaaa2574d999cd20d66545a31a1d13 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Mon, 27 Jul 2026 17:20:03 +1000 Subject: [PATCH 20/21] chore: drop the constitutive_models.py.bak copy committed by accident A working backup of constitutive_models.py was swept into 39be0abd. It is a duplicate of a source file, so it would shadow real hits in greps and pattern scans forever. Underworld development team with AI support from Claude Code --- src/underworld3/constitutive_models.py.bak | 4450 -------------------- 1 file changed, 4450 deletions(-) delete mode 100644 src/underworld3/constitutive_models.py.bak diff --git a/src/underworld3/constitutive_models.py.bak b/src/underworld3/constitutive_models.py.bak deleted file mode 100644 index 0f2a944bb..000000000 --- a/src/underworld3/constitutive_models.py.bak +++ /dev/null @@ -1,4450 +0,0 @@ -r""" -Constitutive models for Underworld3 solvers. - -This module provides constitutive relationships that define how material -properties (viscosity, diffusivity, etc.) relate fluxes to gradients of -unknowns. These models are plugged into SNES solvers to complete the -governing equations. - -Classes -------- -Constitutive_Model - Base class for all constitutive models. -ViscousFlowModel - Isotropic viscous flow with scalar or tensor viscosity. -ViscoPlasticFlowModel - Viscous flow with yield stress (plastic behavior). -ViscoElasticPlasticFlowModel - Combined viscous, elastic, and plastic rheology. -DiffusionModel - Scalar diffusion (heat, chemical species). -DarcyFlowModel - Porous media flow (Darcy's law). -TransverseIsotropicFlowModel - Anisotropic viscosity with directional weakness. -MultiMaterialConstitutiveModel - Level-set weighted composite of multiple materials. - -See Also --------- -underworld3.systems.solvers : Solvers that use these constitutive models. -""" - -from typing_extensions import Self -import sympy -from sympy import sympify -from sympy.vector import gradient, divergence -import numpy as np - -from typing import Optional, Callable -from typing import NamedTuple, Union - -from petsc4py import PETSc - -import underworld3 as uw -import underworld3.timing as timing -import underworld3.cython -from underworld3.utilities._api_tools import uw_object -from underworld3.swarm import IndexSwarmVariable -from underworld3.discretisation import MeshVariable -from underworld3.systems.ddt import SemiLagrangian as SemiLagrangian_DDt -from underworld3.systems.ddt import _bdf_coefficients -from underworld3.function.quantities import UWQuantity -from underworld3.systems.ddt import Lagrangian as Lagrangian_DDt - -from underworld3.function import expression as public_expression - -expression = lambda *x, **X: public_expression(*x, _unique_name_generation=True, **X) - - -class _ParameterBase: - """Base class for all constitutive model ``_Parameters`` containers. - - All ``_Parameters`` nested classes must inherit from this. It provides a - ``__setattr__`` guard that **rejects** any public attribute assignment - that doesn't match a defined ``Parameter`` descriptor or ``@property`` - on the class. Without this guard, a typo like - ``Parameters.viscocity = 1`` silently creates an instance attribute - that the solver never reads — producing wrong results with no error. - - How to define parameters in a new constitutive model - ---------------------------------------------------- - 1. Inherit from ``_ParameterBase`` (and ``_ViscousParameterAlias`` if - the model has a ``shear_viscosity_0`` parameter):: - - class _Parameters(_ParameterBase, _ViscousParameterAlias): - ... - - 2. Define each parameter as a **class-level** ``Parameter`` descriptor. - The **attribute name IS the user API name** — users will set it via - ``model.Parameters. = value``:: - - import underworld3.utilities._api_tools as api_tools - - shear_viscosity_0 = api_tools.Parameter( - r"\\eta", # LaTeX display name (cosmetic only) - lambda self: 1, # default value factory - "Shear viscosity", # description - units="Pa*s", # expected units - ) - - 3. To add a **convenience alias** (e.g. ``viscosity`` → ``shear_viscosity_0``), - either use a mixin like ``_ViscousParameterAlias`` or define a - ``@property`` with getter and setter on the ``_Parameters`` class. - The guard recognises both descriptors and properties. - """ - - @staticmethod - def _list_valid_parameters(cls_type): - """List valid parameter names for error messages.""" - from underworld3.utilities._api_tools import ExpressionDescriptor - - valid = [] - for cls in cls_type.__mro__: - for k, v in cls.__dict__.items(): - if isinstance(v, (ExpressionDescriptor, property)) and k not in valid: - valid.append(k) - return valid - - def __setattr__(self, name, value): - # Private/internal attributes are always allowed - if name.startswith("_"): - object.__setattr__(self, name, value) - return - - from underworld3.utilities._api_tools import ExpressionDescriptor - - # Walk the MRO looking for a matching descriptor or property - for cls in type(self).__mro__: - if name in cls.__dict__: - attr = cls.__dict__[name] - if isinstance(attr, ExpressionDescriptor): - # Valid descriptor — let it handle the set - attr.__set__(self, value) - return - elif isinstance(attr, property): - if attr.fset is not None: - attr.fset(self, value) - return - raise AttributeError( - f"Parameter '{name}' is read-only" - ) - - # Not a known descriptor — likely a name mismatch bug - valid = _ParameterBase._list_valid_parameters(type(self)) - - raise AttributeError( - f"No parameter '{name}' on {type(self).__name__}. " - f"Valid parameters: {valid}" - ) - - -class _ViscousParameterAlias: - """Mixin providing ``viscosity`` as a read/write alias for ``shear_viscosity_0``. - - Add this to the inheritance of any ``_Parameters`` class that defines - a ``shear_viscosity_0`` descriptor, so that the established - ``Parameters.viscosity`` API continues to work:: - - class _Parameters(_ParameterBase, _ViscousParameterAlias): - shear_viscosity_0 = api_tools.Parameter(...) - - To create similar aliases for other parameters, define a ``@property`` - with a setter — the ``_ParameterBase`` guard recognises properties - automatically. - """ - - @property - def viscosity(self): - return self.shear_viscosity_0 - - @viscosity.setter - def viscosity(self, value): - self.shear_viscosity_0 = value - - -# How do we use the default here if input is required ? -def validate_parameters(symbol, input, default=None, allow_number=True, allow_expression=True): - """Convert input to a UWexpression for use in constitutive models. - - Parameters - ---------- - symbol : str - LaTeX symbol for display (e.g., r"\\eta" for viscosity). - input : various - Value to convert (UWexpression, UWQuantity, float, int, sympy expr). - default : optional - Default value if input is None. - allow_number : bool - If True, accept plain numbers (int/float). - allow_expression : bool - If True, accept raw sympy expressions. - - Returns - ------- - UWexpression or None - Wrapped expression, or None if conversion failed. - """ - # CRITICAL: Check for UWexpression FIRST, before checking sympy.Basic - # UWexpression inherits from sympy.Symbol, so it would match the Basic check - # and cause double-wrapping, losing unit information - from .function.expressions import UWexpression - if isinstance(input, UWexpression): - # Already a UWexpression - return as-is, no wrapping needed - return input - - elif isinstance(input, UWQuantity): - # Convert UWQuantity to UWexpression - this is the beautiful symmetry! - # The UWexpression constructor will handle unit conversion automatically - input = expression( - symbol, - input, - f"(converted from UWQuantity with units {input.units if input.has_units else 'dimensionless'})", - ) - - elif allow_number and isinstance(input, (float)): - # print(f"{symbol}: Converting number to uw expression {input}") - input = expression(symbol, input, "(converted from float)") - - elif allow_number and isinstance(input, (int)): - # print(f"{symbol}: Converting number to uw expression {input}") - input = expression(symbol, input, "(converted from int)") - - elif allow_expression and isinstance(input, sympy.core.basic.Basic): - # print(f"{symbol}: Converting sympy fn to uw expression {input}") - input = expression(symbol, input, "(imported sympy expression)") - - elif input is None and default is not None: - input = expression(symbol, default, "(default value)") - - else: - # That's about all we can fix automagically - print(f"Unable to set parameter: {symbol} from {input}") - print(f"An underworld `expression`, `UWQuantity`, or `function` is required", flush=True) - return None - - return input - - -class Constitutive_Model(uw_object): - r""" - Base class for constitutive laws relating gradients to fluxes. - - Constitutive laws relate gradients in the unknowns to fluxes of quantities - (for example, heat fluxes are related to temperature gradients through a - thermal conductivity). This class is a base class for building Underworld - constitutive laws. - - In a scalar problem, the relationship is: - - .. math:: - - q_i = k_{ij} \frac{\partial T}{\partial x_j} - - and the constitutive parameters describe :math:`k_{ij}`. The template - assumes :math:`k_{ij} = \delta_{ij}`. - - In a vector problem (such as the Stokes problem), the relationship is: - - .. math:: - - t_{ij} = c_{ijkl} \frac{\partial u_k}{\partial x_l} - - but is usually written to eliminate the anti-symmetric part of the - displacement or velocity gradients: - - .. math:: - - t_{ij} = c_{ijkl} \frac{1}{2} \left[ \frac{\partial u_k}{\partial x_l} - + \frac{\partial u_l}{\partial x_k} \right] - - and the constitutive parameters describe :math:`c_{ijkl}`. The template - assumes :math:`k_{ij} = \frac{1}{2}(\delta_{ik}\delta_{jl} + \delta_{il}\delta_{jk})` - which is the 4th rank identity tensor accounting for symmetry in the flux - and the gradient terms. - """ - - # Class-level instance counter for automatic symbol uniqueness across all constitutive models - _global_instance_count = 0 - # Per-class instance counters for class-specific numbering - _class_instance_counts = {} - - @timing.routine_timer_decorator - def __init__(self, unknowns, material_name: str = None): - """ - Initialize a constitutive model. - - Parameters - ---------- - unknowns : UnknownSet - The solver's unknowns (velocity, pressure, etc.) - material_name : str, optional - A distinguishing name for this material's symbols. - If provided, symbols will be subscripted: η → η_{name} - Useful when bundling multiple models in MultiMaterialModel. - """ - # Define / identify the various properties in the class but leave - # the implementation to child classes. The constitutive tensor is - # defined as a template here, but should be instantiated via class - # properties as required. - - # We provide a function that converts gradients / gradient history terms - # into the relevant flux term. - - # Store material name for symbol disambiguation - self._material_name = material_name - - # Track instance numbers for automatic symbol uniqueness - Constitutive_Model._global_instance_count += 1 - self._global_instance_number = Constitutive_Model._global_instance_count - - # Track per-class instance numbers (0-based indexing) - class_name = self.__class__.__name__ - if class_name not in Constitutive_Model._class_instance_counts: - Constitutive_Model._class_instance_counts[class_name] = 0 - self._class_instance_number = Constitutive_Model._class_instance_counts[class_name] - Constitutive_Model._class_instance_counts[class_name] += 1 - - # Backing attribute for settable flux_jacobian (set to a custom - # SymPy expression to override the Jacobian tangent independently - # of the residual flux). None by default, meaning the solver - # differentiates the exact flux. - self._flux_jacobian = None - - self.Unknowns = unknowns - - u = self.Unknowns.u - self._DFDt = self.Unknowns.DFDt - self._DuDt = self.Unknowns.DuDt - - # Constitutive tensors relate gradients to fluxes; both live in - # the embedded coordinate space (cdim-dimensional), not the - # topological one. ``u.sym.jacobian(mesh.N)`` produces a - # (u_dim x cdim) matrix, so the conductivity / viscosity - # tensor must also be cdim-sized to multiply against it. For - # volume meshes ``dim == cdim`` so this is a no-op; for - # manifold meshes (e.g. SphericalManifold: dim=2, cdim=3) the - # constitutive tensor correctly acts on the 3-component flux. - self.dim = u.mesh.cdim - self.u_dim = u.num_components - - self.Parameters = self._Parameters(self) - self.Parameters._solver = None - self.Parameters._reset = self._reset - self._material_properties = None - - ## Default consitutive tensor is the identity - - if self.u_dim == 1: - self._c = sympy.Matrix.eye(self.dim) - else: # vector problem - self._c = uw.maths.tensor.rank4_identity(self.dim) - - self._K = sympy.sympify(1) - self._C = None - - self._reset() - - super().__init__() - - def create_unique_symbol(self, base_symbol, value, description): - """ - Create a unique symbol name for constitutive model parameters. - - Symbol naming priority: - 1. If material_name is set: η → η_{material_name} - 2. Else if multiple instances of same class: η → η^{(n)} - 3. Else: use base symbol as-is - - Parameters - ---------- - base_symbol : str - The base LaTeX symbol name (e.g., r"\\eta", r"\\kappa") - value : float or expression - The initial value for the symbol - description : str - Description of the parameter - - Returns - ------- - UWexpression - Expression with unique symbol name - """ - # Priority 1: User-specified material name (subscript notation) - if self._material_name is not None: - symbol_name = rf"{{{base_symbol}}}_{{\mathrm{{{self._material_name}}}}}" - # Priority 2: Multiple instances of same class (superscript notation) - elif self._class_instance_number > 0: - symbol_name = rf"{{{base_symbol}}}^{{({self._class_instance_number})}}" - # Priority 3: First/only instance - clean symbol - else: - symbol_name = base_symbol - - return expression(symbol_name, value, description) - - class _Parameters(_ParameterBase): - """Any material properties that are defined by a constitutive relationship are - collected in the parameters which can then be defined/accessed by name in - individual instances of the class. - """ - - def __init__(inner_self, _owning_model): - inner_self._owning_model = _owning_model - return - - @property - def Unknowns(self): - r"""Reference to the solver's unknown fields. - - Returns - ------- - Unknowns - Container holding the primary unknown field(s) (e.g., velocity, - pressure, temperature) that this constitutive model operates on. - """ - return self._Unknowns - - # We probably should not be changing this ever ... does this setter even belong here ? - @Unknowns.setter - def Unknowns(self, unknowns): - """Set the solver unknowns (invalidates setup).""" - self._Unknowns = unknowns - self._solver_is_setup = False - return - - @property - def K(self): - r"""Primary constitutive property (viscosity, diffusivity, etc.). - - Returns - ------- - UWexpression - The material property defining the flux-gradient relationship. - """ - return self._K - - @property - def u(self): - r"""The primary unknown field from the solver. - - Returns - ------- - MeshVariable - The unknown field (velocity, temperature, etc.). - """ - return self.Unknowns.u - - @property - def grad_u(self): - r"""Gradient of the unknown field. - - For scalar fields, this is a vector. For vector fields (velocity), - this is the velocity gradient tensor :math:`\nabla \mathbf{u}`. - - Returns - ------- - sympy.Matrix - Gradient/Jacobian of the unknown field. - """ - mesh = self.Unknowns.u.mesh - # return mesh.vector.gradient(self.Unknowns.u.sym) - return self.Unknowns.u.sym.jacobian(mesh.CoordinateSystem.N) - - @property - def DuDt(self): - r"""Material derivative operator for the unknown field. - - Used in time-dependent problems to track Lagrangian or - semi-Lagrangian derivatives. - - Returns - ------- - SemiLagrangian_DDt or Lagrangian_DDt or None - The material derivative operator, or None if not set. - """ - return self._DuDt - - @DuDt.setter - def DuDt( - self, - DuDt_value: Union[SemiLagrangian_DDt, Lagrangian_DDt], - ): - """Set the material derivative operator for the unknown.""" - self._DuDt = DuDt_value - self._solver_is_setup = False - return - - @property - def DFDt(self): - """Material derivative operator for the flux history.""" - return self._DFDt - - # Do we want to lock this down ? - @DFDt.setter - def DFDt( - self, - DFDt_value: Union[SemiLagrangian_DDt, Lagrangian_DDt], - ): - """Set the material derivative operator for flux history.""" - self._DFDt = DFDt_value - self._solver_is_setup = False - return - - ## Properties on all sub-classes - - @property - def C(self): - """The matrix form of the constitutive model (the `c` property) - that relates fluxes to gradients. - For scalar problem, this is the matrix representation of the rank 2 tensor. - For vector problems, the Mandel form of the rank 4 tensor is returned. - NOTE: this is an immutable object that is _a view_ of the underlying tensor - """ - if not self._is_setup: - self._build_c_tensor() - - d = self.dim - rank = len(self.c.shape) - - if rank == 2: - return sympy.Matrix(self._c).as_immutable() - else: - return uw.maths.tensor.rank4_to_mandel(self._c, d).as_immutable() - - @property - def c(self): - """The tensor form of the constitutive model that relates fluxes to gradients. In scalar - problems, `c` and `C` are equivalent (matrices), but in vector problems, `c` is a - rank 4 tensor. NOTE: `c` is the canonical form of the constitutive relationship. - """ - - if not self._is_setup: - self._build_c_tensor() - if hasattr(self._c, "sym"): - return sympy.Matrix(self._c.sym).as_immutable() - else: - return self._c.as_immutable() - - @property - def flux(self): - """Computes the effect of the constitutive tensor on the gradients of the unknowns. - (always uses the `c` form of the tensor). In general cases, the history of the gradients - may be required to evaluate the flux. - """ - - ddu = self.grad_u - - return self._q(ddu) - - def _q(self, ddu): - """Generic flux term""" - - if not self._is_setup: - self._build_c_tensor() - - c = self.c - rank = len(c.shape) - - # tensor multiplication - - if rank == 2: - flux = c * ddu.T - else: # rank==4 - flux = sympy.tensorcontraction( - sympy.tensorcontraction(sympy.tensorproduct(c, ddu), (1, 5)), (0, 3) - ) - - return sympy.Matrix(flux) - - @property - def flux_1d(self): - """Computes the effect of the constitutive tensor on the gradients of the unknowns. - (always uses the `c` form of the tensor). In general cases, the history of the gradients - may be required to evaluate the flux. Returns the Voigt form that is flattened so as to - match the PETSc field storage pattern for symmetric tensors. - """ - - flux = self.flux - - if flux.shape[0] == 1: - return flux - - if flux.shape[1] == 1: - return flux.T - - assert ( - flux.is_symmetric() - ), "The conversion of tensors to Voigt form is only defined for symmetric tensors in underworld\ - but for non-symmetric tensors, the .flat() method is a potential replacement" - - return uw.maths.tensor.rank2_to_voigt(flux, dim=self.dim) - - @property - def flux_jacobian(self): - """Optional smooth surrogate flux for Jacobian assembly. - - Returns ``None`` by default, meaning the solver differentiates the - exact :attr:`flux` (the Newton fix unwraps it first; a generic Min/Max - kink-smoothing fallback then rounds any remaining yield kink). - - Set this to a custom SymPy expression to override the Jacobian tangent - independently of the residual flux. The residual still uses the exact - :attr:`flux`, so the converged solution satisfies the true constitutive - law — only the Newton search direction is smoothed, giving a robust, - line-search-friendly tangent without changing the answer. - - Use cases: - - * A model whose flux has a non-smooth yield kink (e.g. hard-``Min`` - viscoplasticity) supplies a physically-motivated *smooth law for the - tangent only*. - - * A model whose flux contains composition-dependent coefficients - (e.g. multicomponent diffusion) may supply a *constant-coefficient* - version to give the solver a clean SPD Jacobian while keeping the - exact physics in the residual. - - Shape must match :attr:`flux` (the solver substitutes it for ``F1`` when - forming the velocity-gradient Jacobian blocks). - """ - return self._flux_jacobian - - @flux_jacobian.setter - def flux_jacobian(self, value): - """Set a custom Jacobian flux expression (or None to revert).""" - self._flux_jacobian = value - # Signal the solver to rebuild its pointwise Jacobian function. - # Without this, the change is silently ignored until something - # else triggers a re-setup. - self._solver_is_setup = False - - def _reset(self): - """Flags that the expressions in the consitutive tensor need to be refreshed and also that the - solver will need to rebuild the stiffness matrix and jacobians""" - - self._solver_is_setup = False - self._is_setup = False - - # Propagate invalidation to solver if we have a reference. - # A constitutive parameter change affects the F1 pointwise function - # only — the DM, fields, and BCs are unchanged. The solver's - # _build() can swap functions in place without DM teardown. - if hasattr(self, "Parameters") and hasattr(self.Parameters, "_solver"): - if self.Parameters._solver is not None: - self.Parameters._solver._needs_function_rewire = True - - return - - @property - def requires_stress_history(self): - """Whether this model needs DFDt stress history tracking. - - 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. - """ - return False - - @property - def supports_yield_homotopy(self): - """Whether this model can be solved by a single-parameter yield homotopy. - - ``True`` on the yielding models, which carry a δ-parameterised soft-min - yield law that sharpens to the exact ``Min`` as δ→0 — see - :meth:`_yield_homotopy_control`. ``solver.solve(homotopy=True)`` refuses a - model that returns ``False`` (a purely viscous model has no yield surface to - sharpen, so there is nothing to march). - """ - return False - - @property - def stress_history_ddt_kwargs(self): - """Extra kwargs passed to the auto-DDt creation when this model - triggers it via ``requires_stress_history = True``. - - Default: empty dict (BDF-only models). ETD-2 / exponential models - override to inject ``with_forcing_history=True`` so the DDt - allocates a forcing-history slot. - """ - return {} - - def _update_history_coefficients(self): - """Uniform pre-solve hook the Stokes solver calls before each solve. - - Default: no-op (non-stress-history models have nothing to update). - BDF-style stress-history subclasses (VEP, TI-VEP) override to - delegate to ``_update_bdf_coefficients``. ETD-2 / exponential - subclasses (e.g. ``MaxwellExponentialFlowModel``) override to - update α, φ on the DDt. The solver dispatches uniformly through - this method — no ``isinstance`` checks at the solver layer. - """ - return - - def _update_history_post_solve(self): - """Uniform post-solve hook the Stokes solver calls after each solve. - - Default: no-op. Subclasses that store extra integrator state for - the next step (e.g. ETD-2 storing ε̇ⁿ in ``forcing_star``) override. - """ - return - - @property - def plastic_fraction(self): - """Fraction of strain rate that is plastic (0 for non-plastic models). - - Returns a sympy expression that can be evaluated post-solve via - ``uw.function.evaluate(cm.plastic_fraction, coords)``. - """ - return sympy.Integer(0) - - def _build_c_tensor(self): - """Return the identity tensor of appropriate rank (e.g. for projections)""" - - self._c = self._K * uw.maths.tensor.rank4_identity(self.dim) - self._is_setup = True - - return - - def _object_viewer(self): - from IPython.display import Latex, Markdown, display - from textwrap import dedent - - display( - Markdown( - rf"This consititutive model is formulated for {self.dim} dimensional equations" - ) - ) - - -class ViscousFlowModel(Constitutive_Model): - r""" - Viscous flow constitutive model for Stokes-type solvers. - - Defines the relationship between deviatoric stress and strain rate: - - .. math:: - - \tau_{ij} = \eta_{ijkl} \cdot \frac{1}{2} \left[ \frac{\partial u_k}{\partial x_l} - + \frac{\partial u_l}{\partial x_k} \right] - - where :math:`\eta` is the viscosity, which can be a scalar constant, SymPy - function, Underworld mesh variable, or any valid combination. This results - in an isotropic (but not necessarily homogeneous or linear) relationship - between :math:`\tau` and the velocity gradients. - - Parameters - ---------- - unknowns : Unknowns - The solver unknowns (typically velocity and pressure fields). - material_name : str, optional - Name identifier for this material (used in multi-material setups). - - Examples - -------- - >>> import underworld3 as uw - >>> stokes = uw.systems.Stokes(mesh) - >>> viscous = uw.constitutive_models.ViscousFlowModel(stokes.Unknowns) - >>> viscous.Parameters.shear_viscosity_0 = 1e21 # Pa.s - >>> stokes.constitutive_model = viscous - - See Also - -------- - ViscoPlasticFlowModel : Adds yield stress for plastic behavior. - ViscoElasticPlasticFlowModel : Adds viscoelastic memory. - """ - - # ```python - # class ViscousFlowModel(Constitutive_Model) - # ... - # ``` - # ### Example - - # ```python - # viscous_model = ViscousFlowModel(dim) - # viscous_model.material_properties = viscous_model.Parameters(viscosity=viscosity_fn) - # solver.constititutive_model = viscous_model - # ``` - - # ```python - # tau = viscous_model.flux(gradient_matrix) - # ``` - - def __init__(self, unknowns, material_name: str = None): - # All this needs to do is define the - # viscosity property and init the parent(s) - # In this case, nothing seems to be needed. - # The viscosity is completely defined - # in terms of the Parameters - - super().__init__(unknowns, material_name=material_name) - - # self._viscosity = expression( - # R"{\eta_0}", - # 1, - # " Apparent viscosity", - # ) - - class _Parameters(_ParameterBase, _ViscousParameterAlias): - """Any material properties that are defined by a constitutive relationship are - collected in the parameters which can then be defined/accessed by name in - individual instances of the class. - - Now uses Parameter descriptor pattern for automatic lazy evaluation preservation - with unit-aware quantities. - """ - - # Import Parameter descriptor (must use absolute import inside nested class) - import underworld3.utilities._api_tools as api_tools - - # Define shear_viscosity_0 as a Parameter descriptor - # The lambda receives the _Parameters instance and creates the expression via the owning model - shear_viscosity_0 = api_tools.Parameter( - r"\eta", - lambda params_instance: params_instance._owning_model.create_unique_symbol( - r"\eta", 1, "Shear viscosity" - ), - "Shear viscosity", - units="Pa*s" - ) - - def __init__( - inner_self, - _owning_model, - ): - inner_self._owning_model = _owning_model - # Note: shear_viscosity_0 is now a descriptor, no need to create it here - - @property - def viscosity(self): - """Whatever the consistutive model defines as the effective value of viscosity - in the form of an uw.expression""" - - return self.Parameters.shear_viscosity_0 - - @property - def K(self): - """Effective stiffness parameter (viscosity for viscous flow)""" - return self.viscosity - - @property - def flux(self): - r"""Viscous stress tensor: :math:`\boldsymbol{\tau} = 2\eta\dot{\varepsilon}`.""" - edot = self.grad_u - return self._q(edot) - - def _q(self, edot): - """Apply constitutive tensor to strain rate to compute stress.""" - - if not self._is_setup: - self._build_c_tensor() - - c = self.c - rank = len(c.shape) - - # tensor multiplication - - if rank == 2: - flux = c * edot - else: # rank==4 - flux = sympy.tensorcontraction( - sympy.tensorcontraction(sympy.tensorproduct(c, edot), (1, 5)), (0, 3) - ) - - return sympy.Matrix(flux) - - ## redefine the gradient for the viscous law as it relates to - ## the symmetric part of the tensor only - - @property - def grad_u(self): - r"""Symmetric strain rate tensor (with 1/2 factor). - - .. math:: - \dot{\varepsilon}_{ij} = \frac{1}{2}\left(\frac{\partial u_i}{\partial x_j} - + \frac{\partial u_j}{\partial x_i}\right) - """ - mesh = self.Unknowns.u.mesh - - return mesh.vector.strain_tensor(self.Unknowns.u.sym) - - # ddu = self.Unknowns.u.sym.jacobian(mesh.CoordinateSystem.N) - # edot = (ddu + ddu.T) / 2 - # return edot - - @property - def plastic_fraction(self): - """Fraction of strain rate that is plastic: 1 - η_vp / η_viscous.""" - return sympy.Max(0, 1 - self.viscosity / self.Parameters.shear_viscosity_0) - - def _build_c_tensor(self): - """For this constitutive law, we expect just a viscosity function""" - - if self._is_setup: - return - - d = self.dim - viscosity = self.viscosity - - # Check for tensor forms first (Mandel matrix or full rank-4 tensor) - dv = uw.maths.tensor.idxmap[d][0] - if isinstance(viscosity, sympy.Matrix) and viscosity.shape == (dv, dv): - # Mandel form of constitutive tensor - self._c = 2 * uw.maths.tensor.mandel_to_rank4(viscosity, d) - elif isinstance(viscosity, sympy.Array) and viscosity.shape == (d, d, d, d): - # Full rank-4 tensor - self._c = 2 * viscosity - else: - # Scalar viscosity case - # UWexpression has __getitem__ from MathematicalMixin, making it Iterable, - # which causes SymPy's array multiplication operator to reject it. - # Solution: Use element-wise loop construction instead of operator overloading. - # The multiplication creates Mul(scalar, UWexpression) objects which are NOT - # Iterable, so array assignment accepts them. JIT unwrapper finds the - # UWexpression atoms inside and substitutes correctly. - - identity = uw.maths.tensor.rank4_identity(d) - result = sympy.MutableDenseNDimArray.zeros(d, d, d, d) - - # Element-wise multiplication: c_ijkl = 2 * I_ijkl * viscosity - for i in range(d): - for j in range(d): - for k in range(d): - for l in range(d): - val = 2 * identity[i, j, k, l] * viscosity - # If simplification returns bare UWexpression (e.g., 2*(1/2)*visc = visc), - # wrap it to avoid Iterable check failure during assignment - if hasattr(val, '__getitem__') and not isinstance(val, (sympy.MatrixBase, sympy.NDimArray)): - val = sympy.Mul(sympy.S.One, val, evaluate=False) - result[i, j, k, l] = val - - self._c = result - - self._is_setup = True - self._solver_is_setup = False - - return - - def _object_viewer(self): - from IPython.display import Latex, Markdown, display - - super()._object_viewer() - - ## feedback on this instance - display( - Latex( - r"$\quad\eta_\textrm{eff} = $ " + sympy.sympify(self.viscosity.sym)._repr_latex_() - ) - ) - - # --- Yield soft-min smoother (shared by the visco-plastic subclasses) ----------- - # The δ soft-min regularisation and the smooth-min FAMILY selection live on the - # base class so every yielding model inherits one implementation. δ is held as a - # constants[] UWexpression atom (not a baked float) so a homotopy can ramp it at - # runtime via PetscDSSetConstants with no JIT recompile. - - def _get_yield_softness(self): - r"""The soft-min regularisation δ as a ``constants[]`` UWexpression atom. - - Created lazily and kept in sync with ``self._yield_softness`` (the - configured numeric value). Storing δ as a UWexpression — rather than - baking the float into the compiled flux — lets a yield homotopy ramp - δ at runtime via ``PetscDSSetConstants`` with no JIT recompile. - ``δ = 0`` makes the sqrt law identically ``Min``. - """ - delta_value = getattr(self, "_yield_softness", 0.0) - if getattr(self, "_yield_softness_expr", None) is None: - self._yield_softness_expr = expression( - R"{\updelta_{y}}", - sympy.Float(delta_value), - "Yield soft-min regularisation δ (rampable constant; δ=0 ⇒ exact Min)", - ) - # Onset offset (-1+√(1+δ²))/2 keeps g(0)=1 (no spurious yield below - # onset). Held as its OWN constant atom — a single symbol in the - # stress tensor — so it does not blow the tensor up, while still - # tracking δ symbolically (one δ update repacks both constants). - self._yield_offset_expr = expression( - R"{\updelta_{y,0}}", - (-1 + sympy.sqrt(1 + self._yield_softness_expr**2)) / 2, - "Yield soft-min onset offset; tracks δ so g(0)=1 exactly", - ) - else: - self._yield_softness_expr.sym = sympy.Float(delta_value) - return self._yield_softness_expr - - def _get_yield_offset(self): - """The onset-offset constant atom (lazily created alongside δ).""" - if getattr(self, "_yield_offset_expr", None) is None: - self._get_yield_softness() - return self._yield_offset_expr - - def _combine_yield(self, eta_ve, eta_pl): - r"""Combine the visco-elastic/viscous viscosity ``eta_ve`` with the plastic - (yield) viscosity ``eta_pl`` according to ``self._yield_mode``: - - - ``"min"``: exact hard ``Min(η_ve, η_pl)`` (sharp yield surface). - - ``"harmonic"``: ``1/(1/η_ve + 1/η_pl)`` (a distinct smooth blend). - - ``"softmin"``: the δ-parameterised soft-min, in the family chosen by - ``self.yield_smoother`` — ``"sqrt"`` (default; overshoots τ_y in the - transition) or ``"powermean"`` (undershoots τ_y, ``η_eff ≤ Min`` always). - Both approach exact ``Min`` as ``δ → 0``. - - Behaviour at the stock settings (``yield_mode`` per model default, - ``yield_smoother="sqrt"``) is identical to the previous inline law — δ merely - moves from a baked float to a ``constants[]`` atom (same value). - """ - mode = getattr(self, "_yield_mode", "softmin") - if mode == "harmonic": - return 1 / (1 / eta_ve + 1 / eta_pl) - if mode == "min": - return sympy.Min(eta_ve, eta_pl) - - # "softmin": δ-parameterised smooth-min family. - smoother = getattr(self, "_yield_smoother", "sqrt") - delta = self._get_yield_softness() - f = eta_ve / eta_pl - if smoother == "powermean": - # p-norm soft-min in an overflow-safe harmonic-normalised form. δ is - # floored SMOOTHLY (+ε, not Max()) so 1/δ stays finite as δ→0 (a Max on - # the δ atom triggers an unsupported symbolic numeric comparison). - s = 1 / (delta + sympy.Float(0.001)) - a = 1 + f - b = 1 + 1 / f - # Harmonic mean written as eta_ve/(1+f), NOT eta_ve*eta_pl/(eta_ve+eta_pl). - # The two are algebraically identical, but the product-over-sum form - # evaluates to inf/inf = NaN when eta_pl is infinite — which is exactly - # what a rigid (unyielded) point gives, since eta_pl = tau_y/(2 edot_II) - # and edot_II = 0 there. That includes every point of a cold v=0 start. - # In this form f -> 0 and N -> eta_ve, the correct viscous limit. - N = eta_ve / a - return N * (a ** (-s) + b ** (-s)) ** (-1 / s) - - # default "sqrt" soft-min: η_ve / g(f), g(0)=1, g ≈ max(1, f), exact Min at δ=0. - offset = self._get_yield_offset() - g = 1 + (f - 1 + sympy.sqrt((f - 1) ** 2 + delta**2)) / 2 - offset - return eta_ve / g - - @property - def yield_smoother(self): - r"""Which smooth-min FAMILY regularises the ``"softmin"`` yield mode. - - Both families use the same softness parameter ``δ`` (``yield_softness``) - and approach exact ``Min`` as ``δ → 0``, but differ in how they round - the kink: - - - ``"sqrt"`` (default): ``η_ve / g(f, δ)`` with - ``g = 1 + ½(f−1+√((f−1)²+δ²)) − offset``. Exact ``Min`` at ``δ=0``; - **overshoots** the yield surface in the transition (carries stress a - few–60 % above ``τ_y`` before asymptoting). - - ``"powermean"``: the p-norm soft-min - ``η_eff = (η_ve^(−s) + η_pl^(−s))^(−1/s)`` with ``s = 1/δ`` - (``s=1`` ⇒ harmonic mean; ``s→∞`` ⇒ ``Min``). **Undershoots** the - yield surface (``η_eff ≤ Min`` always — approaches ``τ_y`` strictly - from below, never over-yields). Computed in an overflow-safe - harmonic-normalised form for geodynamic viscosity ranges. - - Selecting ``"powermean"`` bumps a zero ``yield_softness`` to ``1.0`` - (``s=1``, the parameter-free harmonic mean) since ``δ=0`` (``s=∞``) is - the singular hard-``Min`` limit it only *approaches*. - """ - return getattr(self, "_yield_smoother", "sqrt") - - @yield_smoother.setter - def yield_smoother(self, value): - if value not in ("sqrt", "powermean"): - raise ValueError( - f"yield_smoother must be 'sqrt' or 'powermean', got '{value}'" - ) - self._yield_smoother = value - if value == "powermean" and getattr(self, "_yield_softness", 0.0) == 0.0: - # δ=0 ⇒ s=1/δ=∞ is the singular Min limit; default to the - # parameter-free harmonic mean (s=1) instead. - self.yield_softness = 1.0 - self._reset() - - # --- Smooth lower bounds (viscosity / yield floors) ----------------------------- - # A hard sympy.Max cutoff is non-differentiable at the corner. When the flux is - # differentiated for the consistent-Newton tangent that kink breaks the tangent — - # and, because one operand is a UWexpression over the fields, sympy's fuzzy `>=` - # comparison cannot resolve it and recurses. In the smooth yield modes we therefore - # round the floor with the same δ that regularises the yield transition, so the - # whole effective viscosity stays differentiable and δ→0 recovers the sharp bound. - - def _apply_floor(self, value, floor): - r"""Impose the lower bound :math:`value \ge floor`. - - In ``yield_mode="min"`` this is the exact hard ``sympy.Max(value, floor)`` — - the sharp cutoff the model has always used. In the smooth yield modes - (``"softmin"``/``"harmonic"``) it is the differentiable ``uw.maths.smooth_max`` - rounded by the yield softness δ *relative to the floor* (:math:`\epsilon = - \delta\,|floor|`), so the whole effective viscosity — not only the yield - transition — is differentiable for the consistent-Newton tangent. The relative - rounding needs a non-zero ``floor`` for a length scale, which the numerical - viscosity/yield floors provide; a cutoff *at zero* (a tension cutoff) has no - such scale and is rounded by its own physical parameter via - ``uw.maths.smooth_max`` at the call site instead. - """ - # smooth_max is 1/2 (a + b + sqrt((a-b)^2 + eps^2)) — pure arithmetic on the - # operands, so `floor` stays the symbolic Parameter that the JIT routes - # through constants[], and sympy is never asked for the ordering test that - # recurses on an opaque UWexpression. At eps = 0 this is exactly - # Max(value, floor), but written without a comparison. - # - # TODO(DESIGN): the rounding scale is RELATIVE (delta * floor), so it - # collapses for a tension cutoff at floor = 0 — now the default for - # yield_stress_min. That leaves a hard corner: the floored yield stress is - # exactly 0 in tension, hence eta_pl = tau_y/(2 edot_II) is exactly 0 and the - # tangent through the soft-min is undefined there. A properly rounded cap - # (Griffith / parabolic) needs an ABSOLUTE stress scale, which this signature - # cannot supply. Maintainer decision pending (2026-07-26). - rounding = 0 if getattr(self, "_yield_mode", "min") == "min" \ - else self._get_yield_softness() * floor - return uw.maths.smooth_max(value, floor, rounding) - - # Tangent this model wants while the yield homotopy marches. Newton is right for - # a purely viscous-plastic yield; the elastic (VEP) subclasses override to the - # frozen/Picard tangent, because the consistent yield tangent taken across the - # elastic stress-history block makes the Jacobian indefinite and the linear - # solve fails outright (DIVERGED_LINEAR_SOLVE). - _yield_homotopy_tangent = True - - def _yield_homotopy_control(self): - """Put this model in its smooth (δ-parameterised) yield mode and describe - how to march it. - - The model owns what the homotopy *means* for it: which knob is the - continuation parameter, how to set it, and which tangent to pair it with. - The solver only marches the number. Called by - ``solver.solve(homotopy=True)``; see - :doc:`nonlinear-solver-homotopy-warmstart` (Layer 2). - - Selects the power-mean soft-min family, whose large-δ limit is the harmonic - mean — bounded by the background viscosity even as :math:`\\dot\\varepsilon - \\to 0`, so the first (cold) solve of the march is well posed and no separate - viscous pre-solve is needed. - - Returns - ------- - YieldHomotopyControl - ``set_delta`` (model-owned setter for δ), ``tangent`` (the - ``consistent_jacobian`` value to use), and ``delta`` (the ``constants[]`` - atom itself, for diagnostics). - """ - from underworld3.systems.yield_continuation import YieldHomotopyControl - - self.yield_mode = "softmin" - self.yield_smoother = "powermean" - - # No strain-rate floor is needed for the cold (v = 0) start the march begins - # from: eta_pl = tau_y/(2 edot_II) is +inf there, which the soft-min carries - # correctly to the viscous branch. See the harmonic-mean note in - # _combine_yield for the one form that must be written carefully to keep it - # so. - - def set_delta(value): - # Go through the property, not the atom: `yield_softness` updates BOTH - # the stored value and the constants[] atom, so a later - # _get_yield_softness() cannot silently reset δ to a stale number. - self.yield_softness = value - - return YieldHomotopyControl( - set_delta=set_delta, - tangent=self._yield_homotopy_tangent, - delta=self._get_yield_softness(), - ) - - -## NOTE - retrofit VEP into here - - -class ViscoPlasticFlowModel(ViscousFlowModel): - r""" - Viscoplastic flow constitutive model with yield stress. - - Extends :class:`ViscousFlowModel` with a yield stress that limits the - maximum deviatoric stress. When stress would exceed the yield stress, - the effective viscosity is reduced to cap the stress. - - .. math:: - - \tau_{ij} = \eta_\mathrm{eff} \cdot \dot{\varepsilon}_{ij} - - where the effective viscosity is: - - .. math:: - - \eta_\mathrm{eff} = \min\left(\eta_0, \frac{\tau_y}{2\dot{\varepsilon}_{II}}\right) - - and :math:`\tau_y` is the yield stress and :math:`\dot{\varepsilon}_{II}` - is the second invariant of the strain rate. - - Parameters - ---------- - unknowns : Unknowns - The solver unknowns (typically velocity and pressure fields). - material_name : str, optional - Name identifier for this material. - - Notes - ----- - If yield stress is not defined, this model behaves identically to - :class:`ViscousFlowModel`. The message ``not~yet~defined`` in the - effective viscosity indicates missing parameters. - - See Also - -------- - ViscousFlowModel : Base viscous model without yielding. - ViscoElasticPlasticFlowModel : Adds viscoelastic memory. - """ - - def __init__(self, unknowns, material_name: str = None): - # All this needs to do is define the - # non-paramter properties that we want to - # use in other expressions and init the parent(s) - # - - super().__init__(unknowns, material_name=material_name) - - self._strainrate_inv_II = expression( - r"\dot\varepsilon_{II}", - sympy.sqrt((self.grad_u**2).trace() / 2), - "Strain rate 2nd Invariant", - ) - - self._plastic_eff_viscosity = expression( - R"{\eta_\textrm{eff,p}}", - 1, - "Effective viscosity (plastic)", - ) - - # Yield-combination mode (see _combine_yield on the base class). Default - # "min" = the exact hard Min(η_0, η_yield) this model has always used, so the - # default behaviour is unchanged. Opt into "softmin" (+ yield_smoother / - # yield_softness) for the δ-parameterised smooth-min homotopy. - self._yield_mode = "min" - self._yield_softness = 0.0 # δ; 0 ⇒ exact Min - self._yield_smoother = "sqrt" # smooth-min family: "sqrt" | "powermean" - self._yield_softness_expr = None # constants[] δ atom (created lazily) - self._yield_offset_expr = None # onset-offset atom (created lazily) - - class _Parameters(_ParameterBase, _ViscousParameterAlias): - """Any material properties that are defined by a constitutive relationship are - collected in the parameters which can then be defined/accessed by name in - individual instances of the class. - - `sympy.oo` (infinity) for default values ensures that sympy.Min simplifies away - the conditionals when they are not required. - - Uses Parameter descriptor pattern for automatic lazy evaluation preservation - with unit-aware quantities. - """ - - # Import Parameter descriptor (must use absolute import inside nested class) - import underworld3.utilities._api_tools as api_tools - - shear_viscosity_0 = api_tools.Parameter( - R"{\eta}", - lambda inner_self: 1, - "Shear viscosity", - units="Pa*s", - ) - - shear_viscosity_min = api_tools.Parameter( - R"{\eta_{\textrm{min}}}", - lambda inner_self: -sympy.oo, - "Shear viscosity, minimum cutoff", - units="Pa*s", - ) - - yield_stress = api_tools.Parameter( - R"{\tau_{y}}", - lambda inner_self: sympy.oo, - "Yield stress (DP)", - units="Pa", - ) - - yield_stress_min = api_tools.Parameter( - R"{\tau_{y, \mathrm{min}}}", - lambda inner_self: 0, - "Yield stress (DP) minimum cutoff", - units="Pa", - ) - - strainrate_inv_II_min = api_tools.Parameter( - R"{\dot\varepsilon_{\mathrm{min}}}", - lambda inner_self: 0, - "Strain rate invariant minimum value", - units="1/s", - ) - - def __init__(inner_self, _owning_model): - inner_self._owning_model = _owning_model - # Parameters are now descriptors - no manual initialization needed - - @property - def viscosity(self): - r"""Effective viscosity with plastic yielding. - - .. math:: - \eta_{\mathrm{eff}} = \min\left(\eta_0, \frac{\tau_y}{2\dot{\varepsilon}_{II}}\right) - - where :math:`\dot{\varepsilon}_{II}` is the second invariant of strain rate. - """ - inner_self = self.Parameters - # detect if values we need are defined or are placeholder symbols - - if inner_self.yield_stress.sym == sympy.oo: - self._plastic_eff_viscosity.symbol = inner_self.shear_viscosity_0.symbol - self._plastic_eff_viscosity._sym = inner_self.shear_viscosity_0._sym - return self._plastic_eff_viscosity - - # Don't put conditional behaviour in the constitutive law - # when it is not needed - - # Lower bound on the yield stress, defaulting to ZERO and therefore normally - # active. tau_y is compared against the second invariant of the stress, so a - # negative tau_y is meaningless — a pressure-dependent Drucker-Prager yield - # C + sin(phi)*p goes negative in tension and must be cut off there rather - # than propagated into tau_y/(2 edot_II). (The ±oo defaults elsewhere in - # Parameters exist so sympy can cancel an unused term away; that trick is - # wrong here, so this one defaults to 0 — maintainer ruling 2026-07-26.) - # An explicit -oo still disables the floor. - if inner_self.yield_stress_min.sym != -sympy.oo: - yield_stress = self._apply_floor( - inner_self.yield_stress, inner_self.yield_stress_min - ) - else: - yield_stress = inner_self.yield_stress - - viscosity_yield = yield_stress / (2 * self._strainrate_inv_II) - - # Combine the viscous and plastic (yield) viscosities. The default - # yield_mode="min" gives the exact hard Min(η_0, η_yield); yield_mode="softmin" - # opts into the δ-parameterised smooth-min (sqrt or powermean family) for a - # scalable homotopy toward the sharp yield surface. - effective_viscosity = self._combine_yield( - inner_self.shear_viscosity_0, viscosity_yield - ) - - # If we want to apply limits to the viscosity but see caveat above - # Keep this as an sub-expression for clarity - - if inner_self.shear_viscosity_min.sym != -sympy.oo: - self._plastic_eff_viscosity._sym = self._apply_floor( - effective_viscosity, inner_self.shear_viscosity_min - ) - - else: - self._plastic_eff_viscosity._sym = effective_viscosity - - # Returns an expression that has a different description - return self._plastic_eff_viscosity - - @property - def supports_yield_homotopy(self): - """This model carries a δ-parameterised yield law — ``solve(homotopy=True)`` - can march it. See :meth:`_yield_homotopy_control`.""" - return True - - @property - def yield_mode(self): - r"""How the viscous and plastic (yield) viscosities are combined. - - - ``"min"`` (default): exact hard ``Min(η_0, η_yield)`` — the sharp yield - surface this model has always used. - - ``"harmonic"``: ``1/(1/η_0 + 1/η_yield)`` — a smooth blend. - - ``"softmin"``: the δ-parameterised smooth-min (family set by - ``yield_smoother``), for a scalable homotopy toward the sharp surface. - """ - return self._yield_mode - - @yield_mode.setter - def yield_mode(self, value): - if value not in ("min", "harmonic", "softmin"): - raise ValueError( - f"yield_mode must be 'min', 'harmonic', or 'softmin', got '{value}'" - ) - self._yield_mode = value - self._reset() - - @property - def yield_softness(self): - r"""Soft-min regularisation δ for ``yield_mode="softmin"`` (0 ⇒ exact Min). - - δ is held as a ``constants[]`` atom, so ramping it at runtime (this setter, - or ``cm._get_yield_softness().sym = ...`` + ``solver._update_constants()``) - does not trigger a JIT recompile — the basis of a scalable yield homotopy. - """ - return self._yield_softness - - @yield_softness.setter - def yield_softness(self, value): - self._yield_softness = float(value) - if getattr(self, "_yield_softness_expr", None) is not None: - self._yield_softness_expr.sym = sympy.Float(self._yield_softness) - self._reset() - - def plastic_correction(self) -> float: - r"""Scaling factor to reduce stress to yield surface. - - .. math:: - f = \frac{\tau_y}{\tau_{II}} - - where :math:`\tau_{II}` is the second invariant of deviatoric stress. - Returns 1 if no yield stress is set. - """ - parameters = self.Parameters - - if parameters.yield_stress == sympy.oo: - return sympy.sympify(1) - - stress = self.stress_projection() - - # The yield criterion in this case is assumed to be a bound on the second invariant of the stress - - stress_II = sympy.sqrt((stress**2).trace() / 2) - - correction = parameters.yield_stress / stress_II - - return correction - - def _object_viewer(self): - from IPython.display import Latex, Markdown, display - - super()._object_viewer() - - ## feedback on this instance - display( - Latex( - r"$\quad\eta_\textrm{0} = $" - + sympy.sympify(self.Parameters.shear_viscosity_0.sym)._repr_latex_() - ), - Latex( - r"$\quad\tau_\textrm{y} = $" - + sympy.sympify(self.Parameters.yield_stress.sym)._repr_latex_(), - ), - ) - - return - - -class ViscoElasticPlasticFlowModel(ViscousFlowModel): - r""" - Viscoelastic-plastic flow constitutive model. - - The stress (flux term) is given by: - - .. math:: - - \tau_{ij} = \eta_{ijkl} \cdot \frac{1}{2} \left[ \frac{\partial u_k}{\partial x_l} - + \frac{\partial u_l}{\partial x_k} \right] - - where :math:`\eta` is the viscosity, a scalar constant, SymPy function, - Underworld mesh variable, or any valid combination. This results in an - isotropic (but not necessarily homogeneous or linear) relationship between - :math:`\tau` and the velocity gradients. You can also supply :math:`\eta_{IJ}`, - the Mandel form of the constitutive tensor, or :math:`\eta_{ijkl}`, the rank-4 tensor. - - The Mandel constitutive matrix is available in `viscous_model.C` and the rank 4 tensor form is - in `viscous_model.c`. Apply the constitutive model using: - - """ - - def __init__(self, unknowns, order=1, integrator: str = "bdf", - material_name: str = None): - """Construct a viscoelastic-plastic flow model. - - Parameters - ---------- - unknowns : Unknowns - The solver unknowns (velocity, pressure). - order : int, default 1 - Time-integration order. Combines with ``integrator``: - - - ``integrator='bdf', order=1``: BDF-1 (backward Euler). - - ``integrator='bdf', order=2``: BDF-2. - - ``integrator='etd', order=1``: ETD-1 (single-step, - fully L-stable, recommended default for VEP+yield). - - ``integrator='etd', order=2``: ETD-2 (single-step - with linear-quadrature forcing history; accurate on - smooth VE but **unstable in tight-yield VEP** — - produces global runaway, see EXPONENTIAL_VE_INTEGRATOR.md - lessons #7, #9). - integrator : str, default "bdf" - Time-integration scheme: - - - ``"bdf"``: backward differentiation formula on the - deviatoric-stress rate equation. Production default. - - ``"etd"``: exponential time-differencing — integrates the - Maxwell relaxation operator analytically (``α = exp(-Δt/τ)``). - ``order=1`` is the recommended default for new code: same - stability as BDF-1, exact handling of the relaxation factor - at large ``Δt/τ``. ``order=2`` adds linear quadrature on - the forcing history for higher accuracy on smooth VE - (4.3× more accurate than BDF-2 on ``bench_ve_harmonic``) - but blows up under active yield in tight-yield TI faults. - See ``docs/developer/design/EXPONENTIAL_VE_INTEGRATOR.md``. - material_name : str, optional - Name identifier for this material. - """ - if integrator not in ("bdf", "etd"): - raise ValueError( - f"integrator must be 'bdf' or 'etd', got '{integrator!r}'" - ) - if integrator == "etd" and order not in (1, 2): - raise ValueError( - f"integrator='etd' supports order=1 (ETD-1, default-recommended) " - f"or order=2 (ETD-2, accurate for smooth VE; avoid in tight-yield " - f"VEP where it produces global runaway). Got order={order}." - ) - self._integrator = integrator - - # Store material_name before creating expressions (needed by create_unique_symbol) - self._material_name = material_name - - # This may not be defined at initialisation time, set to None until used - self._stress_star = expression( - r"{\tau^{*}}", - None, - r"Lagrangian Stress at $t - \delta_t$", - ) - - # This may not be defined at initialisation time, set to None until used - self._stress_2star = expression( - r"{\tau^{**}}", - None, - r"Lagrangian Stress at $t - 2\delta_t$", - ) - - # This may not be well-defined at initialisation time, set to None until used - self._E_eff = expression( - r"{\dot{\varepsilon}_{\textrm{eff}}}", - None, - "Equivalent value of strain rate (accounting for stress history)", - ) - - # This may not be well-defined at initialisation time, set to None until used - self._E_eff_inv_II = expression( - r"{\dot{\varepsilon}_{II,\textrm{eff}}}", - None, - "Equivalent value of strain rate 2nd invariant (accounting for stress history)", - ) - - self._order = order - self._yield_mode = "softmin" # "min", "harmonic", "smooth", or "softmin" - self._yield_softness = 0.1 # δ parameter for "softmin" mode - self._yield_smoother = "sqrt" # smooth-min family: "sqrt" | "powermean" - self._yield_softness_expr = None # constants[] δ atom (created lazily) - self._yield_offset_expr = None # onset-offset atom (created lazily) - - # Timestep — set by the solver before each solve(). Not a user parameter. - # Initialised to oo (viscous limit). The solver overwrites this with the - # actual timestep on every call to solve(timestep=dt). - self._dt = expression(r"{\Delta t}", sympy.oo, "Timestep (set by solver)") - - # BDF coefficients as UWexpressions — route through PetscDS constants[]. - # Updated each step by _update_bdf_coefficients() before solve. - # Initialised to BDF-1 values: [1, -1, 0, 0]. - self._bdf_c0 = expression(r"{c_0^{\mathrm{BDF}}}", sympy.Integer(1), "BDF leading coefficient") - self._bdf_c1 = expression(r"{c_1^{\mathrm{BDF}}}", sympy.Integer(-1), "BDF history coefficient 1") - self._bdf_c2 = expression(r"{c_2^{\mathrm{BDF}}}", sympy.Integer(0), "BDF history coefficient 2") - self._bdf_c3 = expression(r"{c_3^{\mathrm{BDF}}}", sympy.Integer(0), "BDF history coefficient 3") - - self._reset() - - super().__init__(unknowns, material_name=material_name) - - return - - class _Parameters(_ParameterBase, _ViscousParameterAlias): - """Any material properties that are defined by a constitutive relationship are - collected in the parameters which can then be defined/accessed by name in - individual instances of the class. - - Uses Parameter descriptor pattern for automatic lazy evaluation preservation - with unit-aware quantities. - """ - - # Import Parameter descriptor (must use absolute import inside nested class) - import underworld3.utilities._api_tools as api_tools - - # Basic parameters with Parameter descriptors - shear_viscosity_0 = api_tools.Parameter( - R"{\eta}", - lambda inner_self: 1, - "Shear viscosity", - units="Pa*s", - ) - - shear_modulus = api_tools.Parameter( - R"{\mu}", - lambda inner_self: sympy.oo, - "Shear modulus", - units="Pa", - ) - - @property - def dt_elastic(inner_self): - """Timestep for VE formulas. Set by the solver, not a user parameter. - - Returns the UWexpression that the solver updates before each solve. - This flows through PetscDS constants[] so the JIT-compiled pointwise - functions always see the current timestep. - """ - return inner_self._owning_model._dt - - @dt_elastic.setter - def dt_elastic(inner_self, value): - """Allow the solver to set dt via Parameters.dt_elastic = timestep.""" - if hasattr(value, 'sym'): - inner_self._owning_model._dt.sym = value.sym - else: - inner_self._owning_model._dt.sym = value - - shear_viscosity_min = api_tools.Parameter( - R"{\eta_{\textrm{min}}}", - lambda inner_self: -sympy.oo, - "Shear viscosity, minimum cutoff", - units="Pa*s", - ) - - yield_stress = api_tools.Parameter( - R"{\tau_{y}}", - lambda inner_self: sympy.oo, - "Yield stress (DP)", - units="Pa", - ) - - yield_stress_min = api_tools.Parameter( - R"{\tau_{y, \mathrm{min}}}", - lambda inner_self: 0, - "Yield stress (DP) minimum cutoff", - units="Pa", - ) - - strainrate_inv_II_min = api_tools.Parameter( - R"{\dot\varepsilon_{II,\mathrm{min}}}", - lambda inner_self: 0, - "Strain rate invariant minimum value", - units="1/s", - ) - - def __init__( - inner_self, - _owning_model, - ): - inner_self._owning_model = _owning_model - - # Internal symbols for stress history (not parameters, internal state) - strainrate_inv_II = sympy.symbols( - r"\left|\dot\epsilon\right|\rightarrow\textrm{not\ defined}" - ) - stress_star = sympy.symbols(r"\sigma^*\rightarrow\textrm{not\ defined}") - inner_self._stress_star = stress_star - inner_self._not_yielded = sympy.sympify(1) - - ## The following expressions are containers for derived/computed values. - ## They have @property calls to retrieve / calculate them. - ## We keep them as expression containers for lazy evaluation. - - inner_self._ve_effective_viscosity = expression( - R"{\eta_{\mathrm{eff}}}", - None, - "Effective viscosity (elastic)", - ) - - inner_self._t_relax = expression( - R"{t_{\mathrm{relax}}}", - None, - "Maxwell relaxation time", - ) - - ## Derived parameters of the constitutive model (these have no setters) - ## Note, do not return new expressions, keep the old objects as containers - ## the correct values are used in existing expressions. These really are - ## parameters - they are solely combinations of other parameters. - - @property - def ve_effective_viscosity(inner_self): - r"""Visco-elastic effective viscosity: :math:`\eta_{\mathrm{eff}} = \frac{\eta G \Delta t}{\eta + G \Delta t}`.""" - # the dt_elastic defaults to infinity, t_relax to zero, - # so this should be well behaved in the viscous limit - - if inner_self.shear_modulus == sympy.oo: - return inner_self.shear_viscosity_0 - - # BDF-k effective viscosity: eta_eff = eta*mu*dt / (c0*eta + mu*dt) - # c0 is a UWexpression routed through PetscDS constants[], - # updated each step by _update_bdf_coefficients(). - eta = inner_self.shear_viscosity_0 - mu = inner_self.shear_modulus - dt_e = inner_self.dt_elastic - c0 = inner_self._owning_model._bdf_c0 - - el_eff_visc = eta * mu * dt_e / (c0 * eta + mu * dt_e) - - inner_self._ve_effective_viscosity.sym = el_eff_visc - - return inner_self._ve_effective_viscosity - - @property - def t_relax(inner_self): - r"""Maxwell relaxation time: :math:`t_{\mathrm{relax}} = \eta / G`.""" - # shear modulus defaults to infinity so t_relax goes to zero - # in the viscous limit - - inner_self._t_relax.sym = inner_self.shear_viscosity_0 / inner_self.shear_modulus - return inner_self._t_relax - - ## End of parameters definition - - @property - def order(self): - """Time integration order (1 or 2).""" - return self._order - - @order.setter - def order(self, value): - """Set the time integration order. - - If the model is already attached to a solver with a DFDt, this will - warn if the DFDt was created with a lower order (since it can't be - changed after creation — the DFDt allocates history buffers at init). - """ - self._order = value - self._reset() - - # Propagate to connected solver if present - solver = getattr(self.Parameters, '_solver', None) - if solver is not None: - ddt = getattr(solver.Unknowns, 'DFDt', None) - if ddt is not None and ddt.order < value: - import warnings - warnings.warn( - f"Setting order={value} but the solver's DFDt was already " - f"created with order={ddt.order}. The DFDt order cannot be " - f"changed after creation. To use order={value}, create the " - f"model with the desired order before assigning to the solver:\n" - f" cm = ViscoElasticPlasticFlowModel(stokes.Unknowns, order={value})\n" - f" stokes.constitutive_model = cm", - UserWarning, - stacklevel=2, - ) - elif ddt is not None: - solver._order = value - return - - @property - def effective_order(self): - """Effective order accounting for DDt history startup. - - During the first few timesteps, the DDt may not have enough history - to support the requested order. This property returns the lower of - the requested order and the DDt's effective order (which ramps from - 1 to self.order as history accumulates). - """ - if self.Unknowns is not None and self.Unknowns.DFDt is not None: - return min(self._order, self.Unknowns.DFDt.effective_order) - return self._order - - # Maximum timestep ratio (dt_new / dt_old) for which BDF-2+ is safe. - # Beyond this, fall back to BDF-1 to avoid negative-stress extrapolation - # when stress history is non-smooth (e.g. yield events). - _max_dt_ratio_for_higher_order = 2.0 - - def _update_bdf_coefficients(self): - """Update BDF coefficient UWexpressions from current dt_elastic and DDt history. - - Call this before each solve so that the constants[] array carries the - correct coefficients to the compiled pointwise functions. The coefficient - UWexpressions (_bdf_c0..c3) are referenced symbolically in ve_effective_viscosity, - E_eff, and stress() — their numeric values flow through PetscDSSetConstants. - - When the timestep ratio exceeds ``_max_dt_ratio_for_higher_order``, - BDF-2+ coefficients can cause negative stress extrapolation if the - stress history is non-smooth (e.g. after a yield event). In this case - we fall back to BDF-1 coefficients for safety. - """ - order = self.effective_order - - if self.Unknowns is not None and self.Unknowns.DFDt is not None: - dt_current = self.Parameters.dt_elastic - if hasattr(dt_current, 'sym'): - dt_current = dt_current.sym - - # Guard: fall back to BDF-1 when timestep increases too rapidly - dt_history = self.Unknowns.DFDt._dt_history - if order >= 2 and len(dt_history) > 0 and dt_history[0] is not None: - try: - ratio = float(dt_current) / float(dt_history[0]) - if ratio > self._max_dt_ratio_for_higher_order: - order = 1 - except (TypeError, ZeroDivisionError): - pass # symbolic dt — can't evaluate, keep requested order - - coeffs = _bdf_coefficients(order, dt_current, dt_history) - else: - coeffs = _bdf_coefficients(order, None, []) - - # Pad to length 4 - while len(coeffs) < 4: - coeffs.append(sympy.Integer(0)) - - self._bdf_c0.sym = coeffs[0] - self._bdf_c1.sym = coeffs[1] - self._bdf_c2.sym = coeffs[2] - self._bdf_c3.sym = coeffs[3] - - def _update_history_coefficients(self): - """Pre-solve hook: refresh integrator coefficients. - - Dispatches on ``(self._integrator, self._order)``: - - ``"bdf"`` (order 1 or 2): updates BDF c-coefficients via - :py:meth:`_update_bdf_coefficients`. - - ``"etd"`` order=2 (Phase B ETD-2): updates α, φ on the DDt - from ``τ_VE = η/μ``; forcing-history slot active. - - ``"etd"`` order=1 (ETD-1): updates α, φ as for ETD-2 then - forces ``φ = α`` so the ``(φ-α)·ε̇*`` term zeros out — fully - L-stable single-step, no forcing-history slot needed. - """ - if self._integrator == "etd": - if self.Unknowns.DFDt is None: - return - params = self.Parameters - if params.shear_modulus.sym is sympy.oo: - tau_eff = sympy.oo - else: - try: - eta_val = float(params.shear_viscosity_0.sym) - mu_val = float(params.shear_modulus.sym) - tau_eff = eta_val / mu_val if mu_val > 0 else sympy.oo - except (TypeError, ValueError): - tau_eff = None - try: - dt_val = ( - float(params.dt_elastic.sym) - if params.dt_elastic.sym is not sympy.oo - else None - ) - except (TypeError, ValueError): - dt_val = None - self.Unknowns.DFDt.update_exp_coefficients(dt_val, tau_eff) - if self._order == 1: - # ETD-1 reduction: φ = α makes the (φ-α)·ε̇* term zero - # AND turns (1-φ)·ε̇ into (1-α)·ε̇. - self.Unknowns.DFDt._exp_phi.sym = self.Unknowns.DFDt._exp_alpha.sym - else: - self._update_bdf_coefficients() - - def _update_history_post_solve(self): - """Post-solve hook. - - - BDF / ETD-1: no-op (no forcing-history slot). - - ETD-2: refresh ``forcing_star`` from the just-solved ε̇ for - the next step's history term. - """ - if self._integrator == "etd" and self._order == 2 and self.Unknowns.DFDt is not None: - if self.Unknowns.DFDt.forcing_star is not None: - self.Unknowns.DFDt.update_forcing_history(forcing_fn=self.Unknowns.E) - - @property - def stress_history_ddt_kwargs(self): - """SemiLagrangian DDt kwargs based on integrator selection. - - ETD-2 (order=2) needs the forcing-history slot; BDF and ETD-1 - (order=1) do not. - """ - if self._integrator == "etd" and self._order == 2: - return {"with_forcing_history": True} - return {} - - # The following should have no setters - @property - def stress_star(self): - r"""Previous timestep stress :math:`\boldsymbol{\sigma}^*` from history.""" - if self.Unknowns.DFDt is not None: - self._stress_star.sym = self.Unknowns.DFDt.psi_star[0].sym - - return self._stress_star - - @property - def stress_2star(self): - r"""Second-order stress history :math:`\boldsymbol{\sigma}^{**}` (for 2nd order integration).""" - # Check if we have enough information in DFDt to update _stress_star, - # otherwise it will be defined as zero - - if self.Unknowns.DFDt is not None: - if self.Unknowns.DFDt.order >= 2: - self._stress_2star.sym = self.Unknowns.DFDt.psi_star[1].sym - else: - self._stress_2star.sym = sympy.sympify(0) - - return self._stress_2star - - @property - def E_eff(self): - r"""Effective strain rate including elastic-history coupling. - - For BDF integration: - - .. math:: - \dot{\varepsilon}_\mathrm{eff} = \dot{\varepsilon} - - \sum_i c_i \frac{\sigma^{*(i)}}{2 \mu \Delta t} - - For ETD-2 (exponential) integration: - - .. math:: - \dot{\varepsilon}_\mathrm{eff} = (1-\varphi)\,\dot{\varepsilon} - + \frac{\alpha}{2\eta}\,\sigma^* - + (\varphi-\alpha)\,\dot{\varepsilon}^* - - Both forms reduce to bare ``ε̇`` when no elastic history is - active. The yield criterion ``η_pl = τ_y/(2|E_eff|_II)`` is the - same expression structure for both — it adapts naturally to the - integrator's ``E_eff``. - """ - E = self.Unknowns.E - - if self.Unknowns.DFDt is None or not self.is_elastic: - self._E_eff.sym = E - return self._E_eff - - DDt = self.Unknowns.DFDt - - if self._integrator == "etd": - # ETD-2 effective strain rate carrying α·σ*/(2η) and (φ-α)·ε̇*. - # ETD-1 (order=1): φ = α (set in _update_history_coefficients), - # so the (φ-α)·ε̇* term zeros out and (1-φ)·ε̇ → (1-α)·ε̇ — same - # expression tree, no separate code path needed. - alpha = DDt._exp_alpha - phi = DDt._exp_phi - sigma_star = DDt.psi_star[0].sym - if DDt.forcing_star is not None: - edot_star = DDt.forcing_star.sym - else: - edot_star = sympy.zeros(*E.shape) - eta_raw = self.Parameters.shear_viscosity_0 - self._E_eff.sym = ( - (1 - phi) * E - + (alpha / (2 * eta_raw)) * sigma_star - + (phi - alpha) * edot_star - ) - return self._E_eff - - # BDF default - mu_dt = self.Parameters.dt_elastic * self.Parameters.shear_modulus - bdf_cs = [self._bdf_c1, self._bdf_c2, self._bdf_c3] - for i in range(DDt.order): - E += -bdf_cs[i] * DDt.psi_star[i].sym / (2 * mu_dt) - self._E_eff.sym = E - return self._E_eff - - @property - def E_eff_inv_II(self): - r"""Second invariant of effective strain rate: :math:`\dot{\varepsilon}_{II} = \sqrt{\frac{1}{2}\dot{\varepsilon}_{ij}\dot{\varepsilon}_{ij}}`.""" - E_eff = self.E_eff.sym - self._E_eff_inv_II.sym = sympy.sqrt((E_eff**2).trace() / 2) - - return self._E_eff_inv_II - - @property - def K(self): - """Effective stiffness parameter (viscosity for visco-elastic-plastic flow).""" - return self.viscosity - - @property - def _unclipped_ve_viscosity(self): - """Unclipped viscoelastic viscosity (no yield wrap), depends on integrator. - - - BDF: ``ve_effective_viscosity = η·μΔt/(c₀·η + μΔt)`` — - baked-in time-integration factor for backward differentiation. - - ETD-2: ``η`` (raw) — the time-integration factor ``(1-φ)`` is - carried symbolically in :py:attr:`E_eff`, not folded into - this viscosity. - """ - if self._integrator == "etd": - return self.Parameters.shear_viscosity_0 - return self.Parameters.ve_effective_viscosity - - @property - def viscosity(self): - r"""Effective viscosity combining visco-elastic and plastic limits. - - The yield mode controls how η_ve and η_pl are combined: - - - ``"smooth"`` (default): corrected harmonic ``η_ve·(1+f)/(1+f+f²)`` - where ``f = η_ve/η_pl``. Converges to η_pl at deep yielding, - no Min/Max discontinuities. - - ``"harmonic"``: ``1/(1/η_ve + 1/η_pl)``. Smooth but undershoots τ_y - when η_ve is small relative to η_pl. - - ``"min"``: sharp ``Min(η_ve, η_pl)``. Exact yield stress but can - cause SNES divergence with higher-order BDF time integration. - - The unclipped η_ve depends on ``self._integrator`` — - :py:attr:`_unclipped_ve_viscosity` returns the correct base for - BDF or ETD-2 (raw η for ETD-2 since the time factor lives in - ``E_eff``; ``ve_effective_viscosity`` for BDF). - """ - - inner_self = self.Parameters - - if inner_self.yield_stress.sym == sympy.oo: - return self._unclipped_ve_viscosity - - effective_viscosity = self._unclipped_ve_viscosity - - if self.is_viscoplastic: - vp_effective_viscosity = self._plastic_effective_viscosity - # Combine η_ve with the plastic viscosity per yield_mode (harmonic / exact - # Min / δ-soft-min). The soft-min softness δ now lives in a constants[] atom - # (see _combine_yield / _get_yield_softness) — value-identical to the former - # inline float law at the same δ, but runtime-rampable with no recompile. - effective_viscosity = self._combine_yield( - effective_viscosity, vp_effective_viscosity - ) - - # Apply viscosity floor — but skip for smooth-blend yield modes - # where the outer Max creates a nested Min/Max that breaks the - # BDF-2 Jacobian. Those modes are already smooth and bounded. - - if inner_self.shear_viscosity_min.sym != -sympy.oo: - if self.is_viscoplastic and self._yield_mode in ("harmonic", "softmin"): - return effective_viscosity - else: - return sympy.Max( - effective_viscosity, - inner_self.shear_viscosity_min, - ) - - else: - return effective_viscosity - - # NOTE: a hard-Min smooth-tangent override (flux_jacobian = harmonic) was - # prototyped here but deferred to the yield-law / δ-homotopy follow-up. The - # smooth-Jacobian-with-Min-residual tangent is inconsistent (it is the - # consistent tangent of the *harmonic* problem) and converges WORSE than - # Picard on hard-yield VEP; the robust route is problem-space homotopy - # (ramp the softmin softness δ→0), not a smooth tangent. See the design doc - # docs/developer/design/jacobian-unwrap-constants-bug.md. The generic - # Constitutive_Model.flux_jacobian hook (default None) remains available. - - @property - def _plastic_effective_viscosity(self): - parameters = self.Parameters - - if parameters.yield_stress == sympy.oo: - return sympy.oo - - # Use the effective strain rate (including elastic history) for the - # yield criterion. This must use the same order-dependent BDF - # coefficients as the stress formula. - Edot = self.E_eff.sym - - strainrate_inv_II = expression( - R"{\dot\varepsilon_{II}'}", - sympy.sqrt((Edot**2).trace() / 2), - "Strain rate 2nd Invariant including elastic strain rate term", - ) - - # Guard on the DISABLING sentinel, not on zero: zero is the default and a - # physically meaningful floor (the yield stress is compared against the second - # invariant of the stress, so a negative tau_y is meaningless). Only an - # explicit -oo turns the floor off. - if parameters.yield_stress_min.sym != -sympy.oo: - # Literal 0 for the default floor, not the parameter atom: sympy cannot - # fuzzy-compare an opaque UWexpression and Max canonicalisation recurses. - # smooth_max keeps both operands symbolic (the JIT routes the floor - # through constants[]) and needs no ordering test, which sympy cannot - # resolve against an opaque UWexpression. eps = 0 makes it exactly Max. - yield_stress = uw.maths.smooth_max( - parameters.yield_stress, parameters.yield_stress_min, 0 - ) - else: - yield_stress = parameters.yield_stress - - if parameters.strainrate_inv_II_min.sym != 0: - viscosity_yield = yield_stress / ( - 2 * (strainrate_inv_II + parameters.strainrate_inv_II_min) - ) - else: - viscosity_yield = yield_stress / (2 * strainrate_inv_II) - - return viscosity_yield - - def plastic_correction(self): - r"""Scaling factor to reduce stress to yield surface: :math:`f = \tau_y / \tau_{II}`.""" - parameters = self.Parameters - - if parameters.yield_stress == sympy.oo: - return sympy.sympify(1) - - stress = self.stress_projection() - - # The yield criterion in this case is assumed to be a bound on the second invariant of the stress - stress_inv_II = sympy.sqrt((stress**2).trace() / 2) - correction = parameters.yield_stress / stress_inv_II - - return correction - # return sympy.Min(1, correction) - - ## Is this really different from the original ? - - def _build_c_tensor(self): - """For this constitutive law, we expect just a viscosity function""" - - if self._is_setup: - print("Using cached value of c matrix", flush=True) - return - - print("Building c matrix", flush=True) - - d = self.dim - # inner_self = self.Parameters - viscosity = self.viscosity - - try: - # CRITICAL: Use .sym property to avoid UWexpression array corruption issues - # See ViscousFlowModel._build_c_tensor() for detailed explanation - viscosity_sym = viscosity.sym if hasattr(viscosity, "sym") else viscosity - self._c = 2 * uw.maths.tensor.rank4_identity(d) * viscosity_sym - except: - d = self.dim - dv = uw.maths.tensor.idxmap[d][0] - if isinstance(viscosity, sympy.Matrix) and viscosity.shape == (dv, dv): - self._c = 2 * uw.maths.tensor.mandel_to_rank4(viscosity, d) - elif isinstance(viscosity, sympy.Array) and viscosity.shape == (d, d, d, d): - self._c = 2 * viscosity - else: - raise RuntimeError( - "Viscosity is not a known type (scalar, Mandel matrix, or rank 4 tensor" - ) - - self._is_setup = True - self._solver_is_setup = False - - return - - # Modify flux to use the stress history term - # This may be preferable to using strain rate which can be discontinuous - # and harder to map back and forth between grid and particles without numerical smoothing - - @property - def flux(self): - r"""Computes the effect of the constitutive tensor on the gradients of the unknowns. - (always uses the `c` form of the tensor). In general cases, the history of the gradients - may be required to evaluate the flux. For viscoelasticity, the - """ - - stress = self.stress() - - # if self.is_viscoplastic: - # plastic_scale_factor = sympy.Max(1, self.plastic_overshoot()) - # stress /= plastic_scale_factor - - return stress - - def stress_projection(self): - """viscoelastic stress projection (no plastic response)""" - - edot = self.grad_u - - # This is a scalar viscosity ... - - stress = 2 * self.Parameters.ve_effective_viscosity * edot - - if self.Unknowns.DFDt is not None: - stress_star = self.Unknowns.DFDt.psi_star[0] - - if self.is_elastic: - # 1st order - stress += ( - self.Parameters.ve_effective_viscosity - * stress_star.sym - / (self.Parameters.dt_elastic * self.Parameters.shear_modulus) - ) - - return stress - - def stress(self): - """Viscoelastic(-plastic) deviatoric stress for the weak form. - - Both BDF and ETD-2 are written as ``σ = 2·viscosity·E_eff``. - :py:attr:`E_eff` carries the integrator-specific elastic-history - coupling, and :py:attr:`viscosity` returns the appropriate yield- - wrapped effective viscosity (``ve_effective_viscosity`` for BDF, - raw ``η`` for ETD-2 since the time factor is in ``E_eff``). - """ - if not self.is_elastic or self.Unknowns.DFDt is None: - return 2 * self.viscosity * self.grad_u - - # ETD-1 (order=1) uses the same E_eff machinery but with φ=α, so - # forcing_star is not required (the (φ-α)·ε̇* term zeros out). - # Only ETD-2 (order=2) needs forcing_star. - if ( - self._integrator == "etd" - and self._order == 2 - and self.Unknowns.DFDt.forcing_star is None - ): - raise RuntimeError( - "integrator='etd' requires a SemiLagrangian DDt with " - "with_forcing_history=True. The auto-DDt creation path " - "reads stress_history_ddt_kwargs — re-create the solver/" - "model so the kwargs propagate." - ) - - return 2 * self.viscosity * self.E_eff.sym - - # def eff_edot(self): - - # edot = self.grad_u - - # if self.Unknowns.DFDt is not None: - # stress_star = self.Unknowns.DFDt.psi_star[0] - - # if self.is_elastic: - # edot += stress_star.sym / ( - # 2 * self.Parameters.dt_elastic * self.Parameters.shear_modulus - # ) - - # return edot - - # def eff_edot_inv_II(self): - - # edot = self.eff_edot() - # edot_inv_II = sympy.sqrt((edot**2).trace() / 2) - - # return edot_inv_II - - def _object_viewer(self): - from IPython.display import Latex, Markdown, display - - # super()._object_viewer() - - display(Markdown(r"### Viscous deformation")) - display( - Latex( - r"$\quad\eta_\textrm{0} = $ " - + sympy.sympify(self.Parameters.shear_viscosity_0.sym)._repr_latex_() - ), - ) - - display(Markdown(r"#### Elastic deformation")) - display( - Latex( - r"$\quad\mu = $ " + sympy.sympify(self.Parameters.shear_modulus.sym)._repr_latex_(), - ), - Latex( - r"$\quad\Delta t_e = $ " - + sympy.sympify(self.Parameters.dt_elastic.sym)._repr_latex_(), - ), - ) - - display(Markdown(r"#### Plastic deformation")) - display( - Latex( - r"$\quad\tau_\textrm{y} = $ " - + sympy.sympify(self.Parameters.yield_stress.sym)._repr_latex_(), - ) - ## Todo: add all the other properties in here - ) - - @property - def yield_mode(self): - r"""How to combine VE and plastic viscosities. - - ``"softmin"`` (default): smooth approximation to Min — - ``η_ve / g(f)`` where ``g(f) ≈ max(1, f)`` with smoothing - parameter δ (``yield_softness``, default 0.1). Approaches - exact Min as δ → 0; smooth derivatives at the kink. - Recommended default: gets within ~2 % of the true yield - surface while avoiding the SNES kink penalties of ``"min"``. - ``"harmonic"``: parallel blending — ``1/(1/η_ve + 1/η_pl)``. - Smooth but undershoots τ_y for soft materials. - ``"min"``: sharp cutoff — ``Min(η_ve, η_pl)``. - Exact yield but can cause SNES divergence with BDF-2 and - BDF-2 phase-lag at BC discontinuities (see benchmarks). - - Note: the previous ``"smooth"`` mode (corrected harmonic - ``η_ve·(1+f)/(1+f+f²)``) was retired — it under-clipped the - yield surface by ~50 % under realistic forcing, with no - compensating benefit over ``softmin``. Recover from git - history if needed (commit message keyword: ``smooth_yield``). - """ - return self._yield_mode - - @yield_mode.setter - def yield_mode(self, value): - if value == "smooth": - raise ValueError( - "yield_mode='smooth' has been retired — it under-clipped " - "the yield surface by ~50%. Use 'softmin' instead " - "(default; close to exact Min with smooth derivatives)." - ) - if value not in ("min", "harmonic", "softmin"): - raise ValueError( - f"yield_mode must be 'min', 'harmonic', or 'softmin', got '{value}'" - ) - self._yield_mode = value - self._reset() - - @property - def yield_softness(self): - r"""Regularisation parameter δ for ``"softmin"`` yield mode. - - Controls how closely the soft minimum approximates the sharp Min. - Smaller values → sharper yield (closer to Min, less robust). - Larger values → smoother transition (more robust, lower stress). - - Default 0.1. Only used when ``yield_mode == "softmin"``. - Increase toward 0.5 if SNES convergence is difficult at yield onset. - """ - return self._yield_softness - - @yield_softness.setter - def yield_softness(self, value): - self._yield_softness = float(value) - # Keep the constants[] δ atom in sync (created lazily on first use) so a - # numeric δ assignment is reflected without a JIT recompile. - if getattr(self, "_yield_softness_expr", None) is not None: - self._yield_softness_expr.sym = sympy.Float(self._yield_softness) - self._reset() - - @property - def requires_stress_history(self): - """VEP models always require stress history tracking.""" - return True - - @property - def supports_yield_homotopy(self): - """This model carries a δ-parameterised yield law — ``solve(homotopy=True)`` - can march it. See :meth:`_yield_homotopy_control`.""" - return True - - # Picard, not Newton: the consistent yield tangent taken across the elastic - # stress-history block makes the Jacobian indefinite, and the linear solve fails - # outright (DIVERGED_LINEAR_SOLVE at 0 iterations). The frozen tangent is - # contractive, and with the δ-march it still converges to the exact yield surface. - _yield_homotopy_tangent = False - - @property - def plastic_fraction(self): - """Fraction of strain rate that is plastic: 1 - η_vep / η_ve.""" - return sympy.Max(0, 1 - self.viscosity / self.Parameters.ve_effective_viscosity.sym) - - @property - def is_elastic(self): - """True if elastic behavior is active (finite dt_elastic and shear_modulus).""" - # If any of these is not defined, elasticity is switched off - - if self.Parameters.dt_elastic.sym is sympy.oo: - return False - - if self.Parameters.shear_modulus.sym is sympy.oo: - return False - - return True - - @property - def is_viscoplastic(self): - """True if plastic yielding is active (finite yield_stress).""" - if self.Parameters.yield_stress.sym is sympy.oo: - return False - - return True - - -### - - -class MaxwellExponentialFlowModel(ViscoElasticPlasticFlowModel): - r"""Thin alias: ``ViscoElasticPlasticFlowModel(integrator='etd', order=1)``. - - .. deprecated:: Phase B - Use the canonical form ``ViscoElasticPlasticFlowModel(unknowns, - integrator='etd', order=1)`` directly. This sibling class - survives as a thin scaffold so existing scripts continue to - work; defaults to ETD-1 (recommended). - - See ``docs/developer/design/EXPONENTIAL_VE_INTEGRATOR.md`` for the - formulation. - """ - - def __init__(self, unknowns, material_name=None): - super().__init__( - unknowns, order=1, integrator="etd", - material_name=material_name, - ) - - - -### - - -class DiffusionModel(Constitutive_Model): - r""" - Diffusion (Fourier/Fick) constitutive model for scalar transport. - - Defines the flux-gradient relationship for scalar diffusion: - - .. math:: - - q_{i} = \kappa_{ij} \frac{\partial \phi}{\partial x_j} - - For isotropic diffusion, :math:`\kappa_{ij} = \kappa \delta_{ij}`. - - Parameters - ---------- - unknowns : Unknowns - The solver unknowns (the scalar field being diffused). - material_name : str, optional - Name identifier for this material. - - Examples - -------- - >>> diffusion = uw.constitutive_models.DiffusionModel(poisson.Unknowns) - >>> diffusion.Parameters.diffusivity = 1e-6 # m^2/s - >>> poisson.constitutive_model = diffusion - - See Also - -------- - AnisotropicDiffusionModel : For direction-dependent diffusivity. - """ - - class _Parameters(_ParameterBase): - """Any material properties that are defined by a constitutive relationship are - collected in the parameters which can then be defined/accessed by name in - individual instances of the class. - - Now uses Parameter descriptor pattern for automatic lazy evaluation preservation - with unit-aware quantities. - """ - - # Import Parameter descriptor (must use absolute import inside nested class) - import underworld3.utilities._api_tools as api_tools - - # Define diffusivity as a Parameter descriptor - # The lambda receives the _Parameters instance and creates the expression via the owning model - diffusivity = api_tools.Parameter( - r"\upkappa", - lambda params_instance: params_instance._owning_model.create_unique_symbol( - r"\upkappa", 1, "Diffusivity" - ), - "Diffusivity", - units="m**2/s" # Thermal or mass diffusivity - ) - - def __init__( - inner_self, - _owning_model, - ): - inner_self._owning_model = _owning_model - # Note: diffusivity is now a descriptor, no need to create it here - - @property - def K(self): - r"""Diffusivity :math:`\kappa` (alias for ``diffusivity``).""" - return self.Parameters.diffusivity - - @property - def diffusivity(self): - r"""Scalar or tensor diffusivity :math:`\kappa`.""" - return self.Parameters.diffusivity - - def _build_c_tensor(self): - """Build isotropic diffusivity tensor from scalar.""" - - d = self.dim - kappa = self.Parameters.diffusivity - - # Scalar diffusivity case - # Use element-wise construction (consistent with ViscousFlowModel pattern) - # to handle UWexpression properly and preserve for JIT unwrapping - result = sympy.Matrix.zeros(d, d) - - for i in range(d): - for j in range(d): - if i == j: - # Diagonal element: kappa - val = kappa - # Wrap if bare UWexpression to avoid Iterable check failure - if hasattr(val, '__getitem__') and not isinstance(val, (sympy.MatrixBase, sympy.NDimArray)): - val = sympy.Mul(sympy.S.One, val, evaluate=False) - result[i, j] = val - # Off-diagonal elements remain 0 - - self._c = result - - return - - def _object_viewer(self): - from IPython.display import Latex, Markdown, display - - super()._object_viewer() - - ## feedback on this instance - display( - Latex(r"$\quad\kappa = $ " + sympy.sympify(self.Parameters.diffusivity)._repr_latex_()) - ) - - return - - -# AnisotropicDiffusionModel: expects a diffusivity vector and builds a diagonal tensor. -class AnisotropicDiffusionModel(DiffusionModel): - r"""Anisotropic diffusion with direction-dependent diffusivities. - - Defines a diagonal diffusivity tensor :math:`\kappa_{ij} = \text{diag}(\kappa_0, \kappa_1, ...)` - for direction-dependent diffusion rates. - """ - - class _Parameters(_ParameterBase): - def __init__(inner_self, _owning_model): - dim = _owning_model.dim - inner_self._owning_model = _owning_model - # Set default diffusivity as an identity matrix wrapped in an expression - default_diffusivity = sympy.ones(_owning_model.dim, 1) - elements = [default_diffusivity[i] for i in range(dim)] - validated = [] - for i, v in enumerate(elements): - comp = validate_parameters( - rf"\upkappa_{{{i}}}", v, f"Diffusivity in x_{i}", allow_number=True - ) - if comp is not None: - validated.append(comp) - # Store the validated diffusivity as a diagonal matrix - inner_self._diffusivity = sympy.diag(*validated) - - @property - def diffusivity(inner_self): - """Diagonal diffusivity tensor.""" - return inner_self._diffusivity - - @diffusivity.setter - def diffusivity(inner_self, value: sympy.Matrix): - """Set diffusivity from a vector of per-direction values.""" - dim = inner_self._owning_model.dim - - # Accept shape (dim, 1) or (1, dim) - if value.shape not in [(dim, 1), (1, dim)]: - raise ValueError( - f"Diffusivity must be a vector of length {dim}. Got shape {value.shape}." - ) - # Validate each component using validate_parameters - elements = [value[i] for i in range(dim)] - validated = [] - for i, v in enumerate(elements): - diff = validate_parameters( - rf"\upkappa_{{{i}}}", v, f"Diffusivity in x_{i}", allow_number=True - ) - if diff is not None: - validated.append(diff) - # Store the validated diffusivity as a diagonal matrix - inner_self._diffusivity = sympy.diag(*validated) - inner_self._reset() - - def _build_c_tensor(self): - """Constructs the anisotropic (diagonal) tensor from the diffusivity vector.""" - self._c = self.Parameters.diffusivity - self._is_setup = True - - def _object_viewer(self): - from IPython.display import Latex, display - - super()._object_viewer() - - diagonal = self.Parameters.diffusivity.diagonal() - latex_entries = ", ".join([sympy.latex(k) for k in diagonal]) - kappa_latex = r"\kappa = \mathrm{diag}\left(" + latex_entries + r"\right)" - display(Latex(r"$\quad " + kappa_latex + r"$")) - - -class GenericFluxModel(Constitutive_Model): - r""" - A generic constitutive model with symbolic flux expression. - - Example usage: - ```python - grad_phi = sympy.Matrix([sp.Symbol("dphi_dx"), sp.Symbol("dphi_dy")]) - flux_expr = sympy.Matrix([[kappa_11, kappa_12], [kappa_21, kappa_22]]) * grad_phi - - model = GenericFluxModel(dim=2) - model.flux = flux_expr - scalar_solver.constititutive_model = model - ``` - """ - - class _Parameters(_ParameterBase): - def __init__(inner_self, _owning_model): - inner_self._owning_model = _owning_model - - default_flux = sympy.zeros(_owning_model.dim, 1) - elements = [default_flux[i] for i in range(_owning_model.dim)] - validated = [] - for i, v in enumerate(elements): - flux_component = validate_parameters( - rf"q_{{{i}}}", v, f"Flux component in x_{i}", allow_number=True - ) - if flux_component is not None: - validated.append(flux_component) - - inner_self._flux = sympy.Matrix(validated) - - @property - def flux(inner_self): - """User-defined flux expression.""" - return inner_self._flux - - @flux.setter - def flux(inner_self, value: sympy.Matrix): - """Set the flux expression (must be a vector of length dim).""" - dim = inner_self._owning_model.dim - - # Accept shape (dim, 1) or (1, dim) - if value.shape not in [(dim, 1), (1, dim)]: - raise ValueError( - f"Flux must be a symbolic vector of length {dim}. " f"Got shape {value.shape}." - ) - - # Flatten and validate - elements = [value[i] for i in range(dim)] - validated = [] - for i, v in enumerate(elements): - flux_component = validate_parameters( - rf"q_{{{i}}}", v, f"Flux component in x_{i}", allow_number=True - ) - if flux_component is not None: - validated.append(flux_component) - - inner_self._flux = sympy.Matrix(validated).reshape(dim, 1) - inner_self._reset() - - @property - def flux(self): - """The user-defined flux expression.""" - # if self._flux is None: - # raise RuntimeError("Flux expression has not been set.") - return self.Parameters.flux - - def _object_viewer(self): - from IPython.display import display, Latex - - super()._object_viewer() - if self.flux is not None: - display(Latex(r"$\vec{q} = " + sympy.latex(self.flux) + "$")) - else: - display(Latex(r"No flux expression set.")) - - -class DarcyFlowModel(Constitutive_Model): - r""" - Darcy flow constitutive model for porous media flow. - - The ``flux`` property returns the assembly flux - :math:`\mathbf{F}` that enters the PDE as :math:`-\nabla\cdot\mathbf{F} = f`: - - .. math:: - - F_{i} = \kappa_{ij} \left( \frac{\partial p}{\partial x_j} - s_j \right) - - where :math:`\kappa` is the permeability (or hydraulic conductivity), - :math:`p` is the pressure (or hydraulic head), and :math:`s` is the - body force term (e.g., gravity: :math:`s = \rho g`). The **physical Darcy - velocity** is minus this (flow runs *down* the head gradient): - - .. math:: - - q_{i} = -F_{i} = -\kappa_{ij} \left( \frac{\partial p}{\partial x_j} - s_j \right) - - Parameters - ---------- - unknowns : Unknowns - The solver unknowns (the pressure/head field). - material_name : str, optional - Name identifier for this material. - - Examples - -------- - >>> darcy = uw.constitutive_models.DarcyFlowModel(solver.Unknowns) - >>> darcy.Parameters.permeability = 1e-12 # m^2 - >>> darcy.Parameters.s = [0, -rho * g] # Gravity in y-direction - >>> solver.constitutive_model = darcy - - See Also - -------- - DiffusionModel : For pure diffusion without body forces. - """ - - class _Parameters(_ParameterBase): - """Any material properties that are defined by a constitutive relationship are - collected in the parameters which can then be defined/accessed by name in - individual instances of the class. - - Uses Parameter descriptor pattern for scalar permeability. - Matrix-valued `s` remains instance-level (special case). - """ - - # Import Parameter descriptor (must use absolute import inside nested class) - import underworld3.utilities._api_tools as api_tools - - # Define permeability as a Parameter descriptor - permeability = api_tools.Parameter( - r"\kappa", - lambda params_instance: params_instance._owning_model.create_unique_symbol( - r"\kappa", 1, "Permeability" - ), - "Permeability", - units="m**2" # Intrinsic permeability - ) - - def __init__( - inner_self, - _owning_model, - permeabililty: Union[float, sympy.Function] = 1, # Note: typo in param name preserved for compatibility - ): - - inner_self._s = expression( - R"{s}", - sympy.Matrix.zeros( - rows=1, cols=_owning_model.dim - ), # Row matrix (1, dim) to match grad_u from jacobian - "Gravitational forcing", - ) - - inner_self._owning_model = _owning_model - # Note: permeability is now a descriptor, no need to create it here - - @property - def s(inner_self): - r"""Body force vector (e.g., gravitational source term :math:`\rho \mathbf{g}`).""" - return inner_self._s - - @s.setter - def s(inner_self, value: sympy.Matrix): - """Set the body force vector.""" - # Update expression content in-place to preserve object identity - # Cannot use validate_parameters() as it doesn't handle matrices - # UWexpression.sym setter handles sympy.Matrix directly - inner_self._s.sym = value - inner_self._reset() - - @property - def K(self): - r"""Permeability :math:`\kappa` [m²] - the primary constitutive parameter.""" - return self.Parameters.permeability - - def _build_c_tensor(self): - """For this constitutive law, we expect just a permeability function""" - - d = self.dim - kappa = self.Parameters.permeability - - # Scalar permeability case - # Use element-wise construction (consistent with ViscousFlowModel and DiffusionModel) - # to handle UWexpression properly and preserve for JIT unwrapping - result = sympy.Matrix.zeros(d, d) - - for i in range(d): - for j in range(d): - if i == j: - # Diagonal element: kappa - val = kappa - # Wrap if bare UWexpression to avoid Iterable check failure - if hasattr(val, '__getitem__') and not isinstance(val, (sympy.MatrixBase, sympy.NDimArray)): - val = sympy.Mul(sympy.S.One, val, evaluate=False) - result[i, j] = val - # Off-diagonal elements remain 0 - - self._c = result - - return - - def _object_viewer(self): - from IPython.display import Latex, Markdown, display - - super()._object_viewer() - - ## feedback on this instance - display( - Latex(r"$\quad\kappa = $ " + sympy.sympify(self.Parameters.diffusivity)._repr_latex_()) - ) - - return - - @property - def flux(self): - """Computes the effect of the constitutive tensor on the gradients of the unknowns. - (always uses the `c` form of the tensor). In general cases, the history of the gradients - may be required to evaluate the flux. - """ - - ddu = self.grad_u - self.Parameters.s.sym - - return self._q(ddu) - - -class TransverseIsotropicFlowModel(ViscousFlowModel): - r""" - Transversely isotropic (anisotropic) viscous flow model. - - .. math:: - - \tau_{ij} = \eta_{ijkl} \cdot \frac{1}{2} \left[ \frac{\partial u_k}{\partial x_l} - + \frac{\partial u_l}{\partial x_k} \right] - - where :math:`\eta` is the viscosity tensor defined as: - - .. math:: - - \eta_{ijkl} = \eta_0 \cdot I_{ijkl} + (\eta_0-\eta_1) \left[ \frac{1}{2} \left[ - n_i n_l \delta_{jk} + n_j n_k \delta_{il} + n_i n_l \delta_{jk} - + n_j n_l \delta_{ik} \right] - 2 n_i n_j n_k n_l \right] - - and :math:`\hat{\mathbf{n}} \equiv \{n_i\}` is the unit vector defining - the local orientation of the weak plane (a.k.a. the director). - - The Mandel constitutive matrix is available in ``viscous_model.C`` and the - rank-4 tensor form is in ``viscous_model.c``. - - Examples - -------- - >>> viscous_model = TransverseIsotropicFlowModel(dim) - >>> viscous_model.material_properties = viscous_model.Parameters( - ... eta_0=viscosity_fn, - ... eta_1=weak_viscosity_fn, - ... director=orientation_vector_fn - ... ) - >>> solver.constitutive_model = viscous_model - >>> tau = viscous_model.flux(gradient_matrix) - --- - """ - - def __init__(self, unknowns, material_name: str = None): - # All this needs to do is define the - # viscosity property and init the parent(s) - # In this case, nothing seems to be needed. - # The viscosity is completely defined - # in terms of the Parameters - - super().__init__(unknowns, material_name=material_name) - - # self._viscosity = expression( - # R"{\eta_0}", - # 1, - # " Apparent viscosity", - # ) - - class _Parameters(_ParameterBase, _ViscousParameterAlias): - """Any material properties that are defined by a constitutive relationship are - collected in the parameters which can then be defined/accessed by name in - individual instances of the class. - - Uses Parameter descriptor pattern for automatic lazy evaluation preservation - with unit-aware quantities. - """ - - # Import Parameter descriptor (must use absolute import inside nested class) - import underworld3.utilities._api_tools as api_tools - - shear_viscosity_0 = api_tools.Parameter( - r"\eta_0", - lambda inner_self: 1, - "Shear viscosity", - units="Pa*s", - ) - - shear_viscosity_1 = api_tools.Parameter( - r"\eta_1", - lambda inner_self: 1, - "Second viscosity", - units="Pa*s", - ) - - director = api_tools.Parameter( - r"\hat{n}", - lambda inner_self: sympy.Matrix([0] * (inner_self._owning_model.dim - 1) + [1]), - "Director orientation", - units=None, # Dimensionless unit vector - ) - - def __init__( - inner_self, - _owning_model, - ): - inner_self._owning_model = _owning_model - # Parameters are now descriptors - no manual initialization needed - - ## End of parameters - - @property - def viscosity(self): - """Whatever the consistutive model defines as the effective value of viscosity - in the form of an uw.expression""" - - return self.Parameters.shear_viscosity_0 - - @property - def K(self): - """Whatever the consistutive model defines as the effective value of viscosity - in the form of an uw.expression""" - - return self.Parameters.shear_viscosity_0 - - @property - def grad_u(self): - r"""Symmetric strain rate tensor (with 1/2 factor). - - .. math:: - \dot{\varepsilon}_{ij} = \frac{1}{2}\left(\frac{\partial u_i}{\partial x_j} - + \frac{\partial u_j}{\partial x_i}\right) - """ - mesh = self.Unknowns.u.mesh - - return mesh.vector.strain_tensor(self.Unknowns.u.sym) - - def _build_c_tensor(self): - """For this constitutive law, we expect two viscosity functions - and a sympy row-matrix that describes the director components n_{i}""" - - if self._is_setup: - return - - d = self.dim - dv = uw.maths.tensor.idxmap[d][0] - - # Use .sym to get sympy expressions from Parameters - eta_0 = self.Parameters.shear_viscosity_0.sym - eta_1 = self.Parameters.shear_viscosity_1.sym - n = self.Parameters.director.sym - - Delta = eta_0 - eta_1 - - # Use element-wise construction (same pattern as ViscousFlowModel). - # UWexpression has __getitem__ from MathematicalMixin, making it appear - # "Iterable" to SymPy's array multiplication operator, which rejects it. - # Element-wise construction avoids this by creating Mul objects that - # don't have __getitem__. - identity = uw.maths.tensor.rank4_identity(d) - lambda_mat = sympy.MutableDenseNDimArray.zeros(d, d, d, d) - - for i in range(d): - for j in range(d): - for k in range(d): - for l in range(d): - # Build isotropic part element-wise - base_val = 2 * identity[i, j, k, l] * eta_0 - - # Anisotropic correction term - aniso_correction = ( - 2 - * Delta - * ( - ( - n[i] * n[k] * int(j == l) - + n[j] * n[k] * int(l == i) - + n[i] * n[l] * int(j == k) - + n[j] * n[l] * int(k == i) - ) - / 2 - - 2 * n[i] * n[j] * n[k] * n[l] - ) - ) - - val = base_val - aniso_correction - - # Wrap if needed to avoid Iterable check during assignment - if hasattr(val, '__getitem__') and not isinstance(val, (sympy.MatrixBase, sympy.NDimArray)): - val = sympy.Mul(sympy.S.One, val, evaluate=False) - - lambda_mat[i, j, k, l] = val - - lambda_mat = uw.maths.tensor.rank4_to_mandel(lambda_mat, d) - - self._c = uw.maths.tensor.mandel_to_rank4(lambda_mat, d) - - self._is_setup = True - self._solver_is_setup = False - - return - - def _object_viewer(self): - from IPython.display import Latex, Markdown, display - - super()._object_viewer() - - ## feedback on this instance - display(Latex(r"$\quad\eta_0 = $ " + sympy.sympify(self.Parameters.shear_viscosity_0)._repr_latex_())) - display(Latex(r"$\quad\eta_1 = $ " + sympy.sympify(self.Parameters.shear_viscosity_1)._repr_latex_())) - display( - Latex( - r"$\quad\hat{\mathbf{n}} = $ " - + sympy.sympify(self.Parameters.director.T)._repr_latex_() - ) - ) - - -class TransverseIsotropicVEPFlowModel(TransverseIsotropicFlowModel): - r"""Transversely isotropic viscoelastic-plastic flow model for fault mechanics. - - Combines the anisotropic viscosity tensor from :class:`TransverseIsotropicFlowModel` - with viscoelastic stress history and plastic yield limiting on the fault plane. - - The anisotropic viscosity tensor uses two viscosities (η₀ for the bulk, - η₁ for fault-plane shear) and a director n̂ defining the weak plane. - The yield stress τ_y limits the shear stress resolved on the fault plane. - - Parameters - ---------- - unknowns : Unknowns - Solver unknowns (velocity, pressure). - order : int, default=1 - Time integration order for stress history (1 or 2). - material_name : str, optional - Name for disambiguation in multi-material setups. - - See Also - -------- - TransverseIsotropicFlowModel : Anisotropic viscous model (no yield/elasticity). - ViscoElasticPlasticFlowModel : Isotropic VEP model. - """ - - def __init__(self, unknowns, order=1, integrator: str = "bdf", - fault_weight=None, - material_name: str = None): - """Construct a transversely isotropic VEP flow model. - - Parameters - ---------- - unknowns : Unknowns - Solver unknowns (velocity, pressure). - order : int, default 1 - Time-integration order. Combines with ``integrator``: - - - ``integrator='bdf', order=1``: BDF-1 (backward Euler). - - ``integrator='bdf', order=2``: BDF-2. - - ``integrator='etd', order=1``: ETD-1 (single-step, - fully L-stable, **recommended default for VEP+yield**). - Reproduces BDF-1 byte-identically on the killer test; - wins at large ``Δt/τ`` where the analytical relaxation - factor matters. - - ``integrator='etd', order=2``: ETD-2 (linear-quadrature - forcing history). 4× more accurate than BDF-2 on smooth - VE but blows up under active yield in tight-yield TI - faults — see lessons #7, #9 in EXPONENTIAL_VE_INTEGRATOR.md. - - ``integrator='hybrid'`` pins ``order=1``. - integrator : str, default "bdf" - Time integration scheme: - - - ``'bdf'``: Backward differentiation (default, robust for VEP). - - ``'etd'``: Exponential time-differencing — analytical - relaxation factor ``α = exp(-Δt/τ)``. Pair with ``order=1`` - for the recommended default; ``order=2`` is available but - unsafe under active yield (see lessons #7, #9). - - ``'hybrid'``: **EXPERIMENTAL — DO NOT USE FOR PRODUCTION.** - Spatial blend of BDF (inside fault) and ETD (outside - fault). Phase E investigation: σ enforcement is - BDF-class but |u_y| drifts monotonically over cycles - from shared-history coupling between the two branches. - See ``docs/developer/design/EXPONENTIAL_VE_INTEGRATOR.md`` - lesson #11. Use ``'bdf'`` for deep-yield fault problems. - Requires ``fault_weight`` parameter. - fault_weight : sympy expression, optional - Spatial weight ``w(x) ∈ [0, 1]`` selecting BDF (``w=1``) vs - ETD (``w=0``) per quadrature point. Required when - ``integrator='hybrid'``. Typically built from the - ``influence_function`` used to construct ``yield_stress``, - normalised so that ``w=1`` inside the fault zone (where - yielding can happen) and ``w=0`` in the bulk (where - ``τ_y → τ_y_bulk`` and yielding is structurally - unreachable). The flux blend is - ``σ = w·σ_BDF + (1-w)·σ_ETD``. - material_name : str, optional - Name identifier for this material. - """ - if integrator not in ("bdf", "etd", "hybrid"): - raise ValueError( - f"integrator must be 'bdf', 'etd', or 'hybrid', " - f"got '{integrator!r}'" - ) - if integrator == "etd" and order not in (1, 2): - raise ValueError( - f"integrator='etd' supports order=1 (ETD-1, default-recommended) " - f"or order=2 (ETD-2; avoid in tight-yield VEP). " - f"Got order={order}." - ) - self._integrator = integrator - if integrator == "hybrid" and order != 1: - import warnings - warnings.warn( - f"integrator='hybrid' uses one stress history slot; " - f"``order`` is pinned to 1 (you passed order={order}).", - UserWarning, stacklevel=2, - ) - order = 1 - if integrator == "hybrid" and fault_weight is None: - raise ValueError( - "integrator='hybrid' requires a ``fault_weight`` sympy " - "expression in [0, 1] (1 inside fault → BDF; 0 outside → ETD)." - ) - self._fault_weight = fault_weight - - self._material_name = material_name - - # Stress history expressions - self._stress_star = expression( - r"{\tau^{*}}", None, - r"Lagrangian Stress at $t - \delta_t$", - ) - self._stress_2star = expression( - r"{\tau^{**}}", None, - r"Lagrangian Stress at $t - 2\delta_t$", - ) - self._E_eff = expression( - r"{\dot{\varepsilon}_{\textrm{eff}}}", None, - "Equivalent value of strain rate (accounting for stress history)", - ) - self._E_eff_inv_II = expression( - r"{\dot{\varepsilon}_{II,\textrm{eff}}}", None, - "Equivalent value of strain rate 2nd invariant (accounting for stress history)", - ) - - self._order = order - self._yield_mode = "softmin" - self._yield_softness = 0.1 - # BDF order-blending α ∈ [0, 1]. α=1 → pure BDF-2 (default); - # α=0 → pure BDF-1 coefficients; intermediate → linear blend. - # - # NOTE: TI-VEP at order=2 with a spatially varying ``yield_stress`` - # field (e.g. ``influence_function``-localised faults) is unstable - # for α > ~0.25. The recommended user-facing fix is ``order=1`` - # (the class default). This knob is left as an *explicit* damping - # option for users who must use order=2 — it doesn't paper over - # the bug at the default. Empirical stability threshold: - # α ≤ 0.25 stable, α ≥ 0.5 blow-up. Investigated 2026-04. - self._bdf_blend = 1.0 - self._max_dt_ratio_for_higher_order = 2.0 - - # Timestep (set by solver) - self._dt = expression(r"{\Delta t}", sympy.oo, "Timestep (set by solver)") - - # BDF coefficients (initialised to BDF-1) - self._bdf_c0 = expression(r"{c_0^{\mathrm{BDF}}}", sympy.Integer(1), "BDF leading coefficient") - self._bdf_c1 = expression(r"{c_1^{\mathrm{BDF}}}", sympy.Integer(-1), "BDF history coefficient 1") - self._bdf_c2 = expression(r"{c_2^{\mathrm{BDF}}}", sympy.Integer(0), "BDF history coefficient 2") - self._bdf_c3 = expression(r"{c_3^{\mathrm{BDF}}}", sympy.Integer(0), "BDF history coefficient 3") - - self._reset() - - super().__init__(unknowns, material_name=material_name) - - return - - class _Parameters(_ParameterBase, _ViscousParameterAlias): - """Parameters for transverse isotropic VEP model. - - Combines anisotropic parameters (η₀, η₁, director) with VEP - parameters (shear_modulus, yield_stress, etc.). - """ - - import underworld3.utilities._api_tools as api_tools - - # Anisotropic parameters - shear_viscosity_0 = api_tools.Parameter( - r"\eta_0", lambda inner_self: 1, - "Bulk shear viscosity", units="Pa*s", - ) - shear_viscosity_1 = api_tools.Parameter( - r"\eta_1", lambda inner_self: 1, - "Fault-plane shear viscosity", units="Pa*s", - ) - director = api_tools.Parameter( - r"\hat{n}", - lambda inner_self: sympy.Matrix([0] * (inner_self._owning_model.dim - 1) + [1]), - "Director orientation (fault normal)", units=None, - ) - - # Elastic parameter - shear_modulus = api_tools.Parameter( - R"{\mu}", lambda inner_self: sympy.oo, - "Shear modulus", units="Pa", - ) - - # Timestep (managed by solver) - @property - def dt_elastic(inner_self): - """Timestep for VE formulas. Set by the solver.""" - return inner_self._owning_model._dt - - @dt_elastic.setter - def dt_elastic(inner_self, value): - if hasattr(value, 'sym'): - inner_self._owning_model._dt.sym = value.sym - else: - inner_self._owning_model._dt.sym = value - - # Viscosity limits - shear_viscosity_min = api_tools.Parameter( - R"{\eta_{\textrm{min}}}", - lambda inner_self: -sympy.oo, - "Shear viscosity, minimum cutoff", units="Pa*s", - ) - - # Yield parameters (applied to fault-plane shear) - yield_stress = api_tools.Parameter( - R"{\tau_{y}}", lambda inner_self: sympy.oo, - "Yield stress (fault-plane shear)", units="Pa", - ) - yield_stress_min = api_tools.Parameter( - R"{\tau_{y, \mathrm{min}}}", - lambda inner_self: 0, - "Yield stress minimum cutoff", units="Pa", - ) - strainrate_inv_II_min = api_tools.Parameter( - R"{\dot\varepsilon_{II,\mathrm{min}}}", - lambda inner_self: 0, - "Strain rate invariant minimum value", units="1/s", - ) - - def __init__(inner_self, _owning_model): - inner_self._owning_model = _owning_model - - inner_self._ve_effective_viscosity = expression( - R"{\eta_{\mathrm{eff}}}", None, - "Effective viscosity (elastic, fault-plane)", - ) - inner_self._t_relax = expression( - R"{t_{\mathrm{relax}}}", None, - "Maxwell relaxation time", - ) - - @property - def ve_effective_viscosity(inner_self): - r"""VE effective viscosity using η₁ (fault-plane viscosity).""" - mu_val = inner_self.shear_modulus.sym if hasattr(inner_self.shear_modulus, 'sym') else inner_self.shear_modulus - if mu_val is sympy.oo: - return inner_self.shear_viscosity_1 - - eta = inner_self.shear_viscosity_1 - mu = inner_self.shear_modulus - dt_e = inner_self.dt_elastic - c0 = inner_self._owning_model._bdf_c0 - - el_eff_visc = eta * mu * dt_e / (c0 * eta + mu * dt_e) - inner_self._ve_effective_viscosity.sym = el_eff_visc - return inner_self._ve_effective_viscosity - - @property - def t_relax(inner_self): - r"""Maxwell relaxation time: η₁ / μ.""" - inner_self._t_relax.sym = inner_self.shear_viscosity_1 / inner_self.shear_modulus - return inner_self._t_relax - - ## End of parameters - - @property - def is_elastic(self): - """True if elastic behavior is active (finite shear_modulus).""" - if self.Parameters.shear_modulus.sym is sympy.oo: - return False - return True - - @property - def is_viscoplastic(self): - """True if plastic yielding is active (finite yield_stress).""" - if self.Parameters.yield_stress.sym is sympy.oo: - return False - return True - - @property - def order(self): - """Time integration order (1 or 2).""" - return self._order - - @order.setter - def order(self, value): - """Set time integration order (warns if DFDt already created).""" - self._order = value - self._reset() - solver = getattr(self.Parameters, '_solver', None) - if solver is not None: - ddt = getattr(solver.Unknowns, 'DFDt', None) - if ddt is not None and ddt.order < value: - import warnings - warnings.warn( - f"Setting order={value} but DFDt was created with order={ddt.order}. " - f"Create the model with the desired order before assigning to the solver.", - UserWarning, stacklevel=2, - ) - elif ddt is not None: - solver._order = value - return - - @property - def effective_order(self): - """Effective order accounting for DDt history startup.""" - if self.Unknowns is not None and self.Unknowns.DFDt is not None: - ddt_eff = self.Unknowns.DFDt.effective_order - return min(self._order, ddt_eff) - return self._order - - def _update_etd_coefficients(self): - """Refresh DDt's (α, φ) UWexpressions from τ_eff = η_1/μ.""" - if self.Unknowns.DFDt is None: - return - params = self.Parameters - if params.shear_modulus.sym is sympy.oo: - tau_eff = sympy.oo - else: - try: - eta_val = float(params.shear_viscosity_1.sym) - mu_val = float(params.shear_modulus.sym) - tau_eff = eta_val / mu_val if mu_val > 0 else sympy.oo - except (TypeError, ValueError): - tau_eff = None - try: - dt_val = ( - float(params.dt_elastic.sym) - if params.dt_elastic.sym is not sympy.oo - else None - ) - except (TypeError, ValueError): - dt_val = None - self.Unknowns.DFDt.update_exp_coefficients(dt_val, tau_eff) - - def _update_history_coefficients(self): - r"""Pre-solve hook — dispatches BDF, ETD (order 1/2), or hybrid. - - BDF: refresh ``_bdf_c0..c3``. ETD-2 (order=2): refresh ``α, φ`` - on the DDt from ``η_1/μ``. ETD-1 (order=1): same plus force - ``φ = α`` so the ``(φ-α)·ε̇*`` history term vanishes. Hybrid: - refresh both BDF and ETD-2 — flux uses both per-spatial weight. - """ - if self._integrator == "etd": - self._update_etd_coefficients() - if self._order == 1: - # ETD-1 reduction: φ = α zeros (φ-α)·ε̇* and turns - # (1-φ)·ε̇ into (1-α)·ε̇. - DDt = self.Unknowns.DFDt - if DDt is not None: - DDt._exp_phi.sym = DDt._exp_alpha.sym - elif self._integrator == "hybrid": - # Hybrid uses BOTH integrators with spatial blend; update - # both coefficient sets each step. - self._update_bdf_coefficients() - self._update_etd_coefficients() - else: - self._update_bdf_coefficients() - - def _update_history_post_solve(self): - """Post-solve hook — refresh forcing_star for ETD-2 / hybrid. - - ETD-1 (order=1) doesn't need this; the (φ-α)·ε̇* term zeros out. - """ - needs_forcing = ( - (self._integrator == "etd" and self._order == 2) - or self._integrator == "hybrid" - ) - if needs_forcing and self.Unknowns.DFDt is not None: - if self.Unknowns.DFDt.forcing_star is not None: - self.Unknowns.DFDt.update_forcing_history(forcing_fn=self.Unknowns.E) - - @property - def stress_history_ddt_kwargs(self): - """ETD-2 (order=2) and hybrid need the forcing-history slot; - BDF and ETD-1 do not. - """ - if self._integrator == "hybrid": - return {"with_forcing_history": True} - if self._integrator == "etd" and self._order == 2: - return {"with_forcing_history": True} - return {} - - def _update_bdf_coefficients(self): - """Update BDF coefficient UWexpressions with blending.""" - order = self.effective_order - - if self.Unknowns is not None and self.Unknowns.DFDt is not None: - dt_current = self.Parameters.dt_elastic - if hasattr(dt_current, 'sym'): - dt_current = dt_current.sym - - dt_history = self.Unknowns.DFDt._dt_history - if order >= 2 and len(dt_history) > 0 and dt_history[0] is not None: - try: - ratio = float(dt_current) / float(dt_history[0]) - if ratio > self._max_dt_ratio_for_higher_order: - order = 1 - except (TypeError, ZeroDivisionError): - pass - - coeffs = _bdf_coefficients(order, dt_current, dt_history) - else: - coeffs = _bdf_coefficients(order, None, []) - - # BDF order blending — see ``self._bdf_blend`` docstring. - # Linear mix of BDF-1 and the requested-order coefficients. - # α=0 → pure BDF-1; α=1 → no blend (skip). - alpha = self._bdf_blend - if alpha < 1 and order >= 2: - coeffs_o1 = _bdf_coefficients(1, dt_current, dt_history) \ - if (self.Unknowns is not None and self.Unknowns.DFDt is not None) \ - else _bdf_coefficients(1, None, []) - while len(coeffs_o1) < len(coeffs): - coeffs_o1.append(sympy.Integer(0)) - coeffs = [ - (1 - alpha) * c1 + alpha * ck - for c1, ck in zip(coeffs_o1, coeffs) - ] - - while len(coeffs) < 4: - coeffs.append(sympy.Integer(0)) - - self._bdf_c0.sym = coeffs[0] - self._bdf_c1.sym = coeffs[1] - self._bdf_c2.sym = coeffs[2] - self._bdf_c3.sym = coeffs[3] - - @property - def bdf_blend(self): - r"""BDF coefficient blending α ∈ [0, 1]. **Damping knob.** - - Linearly mixes BDF-1 and the requested-order coefficients: - ``c = (1-α)·c_BDF1 + α·c_requested_order``. α=1 is no blend. - - **For TI-VEP fault simulations, prefer ``order=1`` over tuning - this knob.** At ``order=2`` with a spatially varying - ``yield_stress`` field, the simulation drifts unstably: - |σ_xy| → 10⁸ over ~10 t_r. Empirically the instability is - gated by the magnitude of the ψ*_{n-1} weight: α ≤ 0.25 stable, - α ≥ 0.5 blow-up. Lower α values stabilise but throw away - most of BDF-2's accuracy advantage — at α=0.10 the trace - difference vs ``order=1`` is ~0.1% of peak, for ~50% wall-time - overhead. Use this knob only if you specifically need - order-2 behaviour on a problem with uniform yield_stress. - """ - return self._bdf_blend - - @bdf_blend.setter - def bdf_blend(self, value): - if not (0.0 <= float(value) <= 1.0): - raise ValueError(f"bdf_blend must be in [0, 1], got {value}") - self._bdf_blend = float(value) - - @property - def stress_star(self): - r"""Previous timestep stress from history.""" - if self.Unknowns.DFDt is not None: - self._stress_star.sym = self.Unknowns.DFDt.psi_star[0].sym - return self._stress_star - - def _e_eff_for(self, integrator_mode): - r"""Build E_eff for a given integrator mode (without storing on - ``self._E_eff``). Used by both the public :py:attr:`E_eff` and - the hybrid ``stress()`` path which needs both forms in one - evaluation. - """ - E = self.Unknowns.E - if self.Unknowns.DFDt is None or not self.is_elastic: - return E - DDt = self.Unknowns.DFDt - - if integrator_mode == "etd": - alpha = DDt._exp_alpha - phi = DDt._exp_phi - sigma_star = DDt.psi_star[0].sym - if DDt.forcing_star is not None: - edot_star = DDt.forcing_star.sym - else: - edot_star = sympy.zeros(*E.shape) - eta_1 = self.Parameters.shear_viscosity_1 - return ( - (1 - phi) * E - + (alpha / (2 * eta_1)) * sigma_star - + (phi - alpha) * edot_star - ) - - # BDF default - mu_dt = self.Parameters.dt_elastic * self.Parameters.shear_modulus - bdf_cs = [self._bdf_c1, self._bdf_c2, self._bdf_c3] - out = E - for i in range(DDt.order): - out = out - bdf_cs[i] * DDt.psi_star[i].sym / (2 * mu_dt) - return out - - @property - def E_eff(self): - r"""Effective strain rate including elastic-history coupling. - - BDF: ``E_eff = ε̇ - Σc_i·σ*/(2μΔt)``. - ETD-2: ``E_eff = (1-φ)·ε̇ + α·σ*/(2η₁) + (φ-α)·ε̇*``. - Hybrid: returns the BDF form (E_eff is consumed by yield-clip - code that should see the BDF rate metric); the actual flux uses - both forms inside :py:meth:`stress`. - """ - mode = "bdf" if self._integrator in ("bdf", "hybrid") else "etd" - self._E_eff.sym = self._e_eff_for(mode) - return self._E_eff - - @property - def E_eff_inv_II(self): - r"""Second invariant of effective strain rate.""" - E_eff = self.E_eff.sym - self._E_eff_inv_II.sym = sympy.sqrt((E_eff**2).trace() / 2) - return self._E_eff_inv_II - - @property - def viscosity(self): - r"""Effective viscosity for the fault-plane shear component. - - Applies the yield mode (softmin/min/harmonic) to η₁, leaving - η₀ (bulk) unchanged. The anisotropic tensor handles the - directional dependence. - """ - inner_self = self.Parameters - - if inner_self.yield_stress.sym == sympy.oo: - return inner_self.shear_viscosity_0 - - # η₁ is the fault-plane viscosity that gets yield-limited - eta_1_eff = inner_self.ve_effective_viscosity - - if self.is_viscoplastic: - vp_eff = self._plastic_effective_viscosity - if self._yield_mode == "harmonic": - eta_1_eff = 1 / (1 / eta_1_eff + 1 / vp_eff) - elif self._yield_mode == "softmin": - delta = self._yield_softness - f = eta_1_eff / vp_eff - import math # float offset avoids sympy expression blowup in tensor - offset = (-1 + math.sqrt(1 + delta**2)) / 2 - g = 1 + (f - 1 + sympy.sqrt((f - 1)**2 + delta**2)) / 2 - offset - eta_1_eff = eta_1_eff / g - else: - eta_1_eff = sympy.Min(eta_1_eff, vp_eff) - - return inner_self.shear_viscosity_0 - - @property - def K(self): - """Effective stiffness for preconditioner.""" - return self.Parameters.shear_viscosity_0 - - @property - def _plastic_effective_viscosity(self): - """Plastic viscosity from resolved fault-plane shear strain rate. - - Computes the in-plane shear magnitude using Pythagoras: - T = ε̇_eff · n (traction-like vector on fault) - ε̇_n = T · n (normal component) - |γ̇| = √(|T|² - ε̇_n²) (in-plane shear magnitude) - - This works in both 2D and 3D — no explicit tangent vector needed. - The formula 2η₁_pl = τ_y / |γ̇| is the same pattern as isotropic - Drucker-Prager but projected onto the fault plane. - """ - parameters = self.Parameters - - ty_val = parameters.yield_stress.sym if hasattr(parameters.yield_stress, 'sym') else parameters.yield_stress - if ty_val is sympy.oo: - return sympy.oo - - Edot = self.E_eff.sym - - # Resolve strain rate onto fault plane via Pythagoras - n = parameters.director.sym - T = Edot * n # "traction" vector on fault - edot_n = (n.T * T)[0, 0] # normal component - T_sq = (T.T * T)[0, 0] # |T|² - gamma_dot_sq = T_sq - edot_n**2 # in-plane shear² - gamma_dot_abs = sympy.sqrt(sympy.Max(gamma_dot_sq, 0)) - - tau_y = parameters.yield_stress - # Guard on the DISABLING sentinel, not on zero — zero is the default and a - # real floor (a negative yield stress is meaningless against an invariant). - if parameters.yield_stress_min.sym != -sympy.oo: - # Literal 0 for the default floor (sympy fuzzy-compare recursion on atoms). - # smooth_max: keeps the floor symbolic, no ordering test (see the note - # in Constitutive_Model._apply_floor). eps = 0 is exactly Max. - tau_y = uw.maths.smooth_max(tau_y, parameters.yield_stress_min, 0) - - if parameters.strainrate_inv_II_min.sym != 0: - viscosity_yield = tau_y / ( - 2 * (gamma_dot_abs + parameters.strainrate_inv_II_min) - ) - else: - viscosity_yield = tau_y / (2 * gamma_dot_abs) - - return viscosity_yield - - def _eta_for_tensor(self, integrator_mode, apply_yield): - """Return ``(eta_0, eta_1_eff)`` for tensor build, parameterised - by integrator mode and whether to apply yield clipping. - - - ``integrator_mode='bdf'``: η₀, η₁ are VE-effective (c₀-baked - Δt scaling — needed for BDF's E_eff structure). - - ``integrator_mode='etd'``: η₀, η₁ are raw (time factor lives - in α/φ symbolically). Used for both ETD-1 and ETD-2 — the - C tensor is identical; only the symbolic E_eff differs (via - ``self._order``). - - ``apply_yield=True``: softmin/min/harmonic clip on η₁_eff. - - ``apply_yield=False``: no clipping (use this for the ETD-VE - branch of the hybrid integrator, where the bulk is - structurally non-yieldable so clipping is a no-op anyway). - """ - if integrator_mode == "etd": - eta_0 = self.Parameters.shear_viscosity_0.sym - eta_1_eff = self.Parameters.shear_viscosity_1 - else: # bdf - eta_0_raw = self.Parameters.shear_viscosity_0 - mu = self.Parameters.shear_modulus - dt_e = self.Parameters.dt_elastic - c0 = self._bdf_c0 - mu_val = mu.sym if hasattr(mu, 'sym') else mu - if mu_val is sympy.oo: - eta_0 = eta_0_raw.sym if hasattr(eta_0_raw, 'sym') else eta_0_raw - else: - eta_0 = eta_0_raw * mu * dt_e / (c0 * eta_0_raw + mu * dt_e) - eta_1_eff = self.Parameters.ve_effective_viscosity - - if apply_yield and self.is_viscoplastic: - vp_eff = self._plastic_effective_viscosity - if self._yield_mode == "harmonic": - eta_1_eff = 1 / (1 / eta_1_eff + 1 / vp_eff) - elif self._yield_mode == "softmin": - delta = self._yield_softness - f = eta_1_eff / vp_eff - import math - offset = (-1 + math.sqrt(1 + delta**2)) / 2 - g = 1 + (f - 1 + sympy.sqrt((f - 1)**2 + delta**2)) / 2 - offset - eta_1_eff = eta_1_eff / g - else: - eta_1_eff = sympy.Min(eta_1_eff, vp_eff) - return eta_0, eta_1_eff - - def _assemble_c_tensor(self, eta_0, eta_1_eff): - """Build the anisotropic rank-4 tensor from ``(eta_0, eta_1_eff)``. - - Loop body identical to :py:meth:`_build_c_tensor_ve`; refactored - into a helper so the hybrid path can call it twice (BDF tensor - with yield clip, ETD tensor without) without code duplication. - """ - d = self.dim - n = self.Parameters.director.sym - Delta = eta_0 - eta_1_eff - identity = uw.maths.tensor.rank4_identity(d) - lambda_mat = sympy.MutableDenseNDimArray.zeros(d, d, d, d) - for i in range(d): - for j in range(d): - for k in range(d): - for l in range(d): - base_val = 2 * identity[i, j, k, l] * eta_0 - aniso_correction = ( - 2 * Delta * ( - (n[i] * n[k] * int(j == l) - + n[j] * n[k] * int(l == i) - + n[i] * n[l] * int(j == k) - + n[j] * n[l] * int(k == i)) / 2 - - 2 * n[i] * n[j] * n[k] * n[l] - ) - ) - val = base_val - aniso_correction - if hasattr(val, '__getitem__') and not isinstance(val, (sympy.MatrixBase, sympy.NDimArray)): - val = sympy.Mul(sympy.S.One, val, evaluate=False) - lambda_mat[i, j, k, l] = val - lambda_mat = uw.maths.tensor.rank4_to_mandel(lambda_mat, d) - return uw.maths.tensor.mandel_to_rank4(lambda_mat, d) - - def _build_c_tensor(self): - """Build the anisotropic tensor(s) for the active integrator. - - - ``'bdf'`` / ``'etd'``: single tensor ``self._c``. - - ``'hybrid'``: two tensors ``self._c_bdf`` (yield-clipped) and - ``self._c_etd`` (no clip — bulk is non-yieldable). The flux - blend ``w·σ_BDF + (1-w)·σ_ETD`` happens in :py:meth:`stress`. - """ - if self._is_setup: - return - - if self._integrator == "hybrid": - eta_0_bdf, eta_1_bdf = self._eta_for_tensor("bdf", apply_yield=True) - self._c_bdf = self._assemble_c_tensor(eta_0_bdf, eta_1_bdf) - eta_0_etd, eta_1_etd = self._eta_for_tensor("etd", apply_yield=False) - self._c_etd = self._assemble_c_tensor(eta_0_etd, eta_1_etd) - self._c = self._c_bdf # default for any callers reading self._c - else: - eta_0, eta_1_eff = self._eta_for_tensor( - self._integrator, apply_yield=self.is_viscoplastic - ) - self._c = self._assemble_c_tensor(eta_0, eta_1_eff) - - self._is_setup = True - self._solver_is_setup = False - return - - @property - def flux(self): - """Stress flux for the weak form.""" - return self.stress() - - def stress_projection(self): - """VE stress without plastic correction (for history storage). - - Uses the anisotropic tensor with VE effective viscosities but - no yield limiting (η₁_ve, not η₁_eff). This is the stress that - should be stored in the DFDt history for the next timestep. - """ - edot = self.grad_u - self._build_c_tensor_ve() - # Contract with the VE-only tensor (not self._c which has yield) - c_ve = self._c_ve - if len(c_ve.shape) == 2: - flux = c_ve * edot - else: - flux = sympy.tensorcontraction( - sympy.tensorcontraction(sympy.tensorproduct(c_ve, edot), (1, 5)), (0, 3) - ) - return sympy.Matrix(flux) - - def _build_c_tensor_ve(self): - """Build anisotropic tensor with VE η₁ only (no yield).""" - d = self.dim - eta_0 = self.Parameters.shear_viscosity_0.sym - eta_1_ve = self.Parameters.ve_effective_viscosity - n = self.Parameters.director.sym - Delta = eta_0 - eta_1_ve - - identity = uw.maths.tensor.rank4_identity(d) - lambda_mat = sympy.MutableDenseNDimArray.zeros(d, d, d, d) - - for i in range(d): - for j in range(d): - for k in range(d): - for l in range(d): - base_val = 2 * identity[i, j, k, l] * eta_0 - aniso_correction = ( - 2 * Delta * ( - (n[i] * n[k] * int(j == l) - + n[j] * n[k] * int(l == i) - + n[i] * n[l] * int(j == k) - + n[j] * n[l] * int(k == i)) / 2 - - 2 * n[i] * n[j] * n[k] * n[l] - ) - ) - val = base_val - aniso_correction - if hasattr(val, '__getitem__') and not isinstance(val, (sympy.MatrixBase, sympy.NDimArray)): - val = sympy.Mul(sympy.S.One, val, evaluate=False) - lambda_mat[i, j, k, l] = val - - lambda_mat = uw.maths.tensor.rank4_to_mandel(lambda_mat, d) - self._c_ve = uw.maths.tensor.mandel_to_rank4(lambda_mat, d) - - def stress(self): - """Viscoelastic-plastic anisotropic stress for the weak form. - - Matches isotropic VEP pattern: tensor contraction for current strain - rate, scalar yield-limited viscosity for VE history terms. The tensor - C(η₁_eff) handles anisotropy; the history uses the same η₁_eff as - a scalar multiplier (consistent with how isotropic VEP uses - self.viscosity for both). - - Hybrid path: ``σ = w·σ_BDF + (1-w)·σ_ETD`` with the spatial - weight from ``self._fault_weight``. Each branch contracts its - own E_eff with its own C-tensor; the blend lives at the flux - level so neither integrator's structure is compromised. - """ - self._build_c_tensor() - - if self._integrator == "hybrid": - # σ_BDF: BDF flux with yield-clipped C tensor - edot_eff_bdf = self._e_eff_for("bdf") - sigma_bdf = self._q_with(self._c_bdf, edot_eff_bdf) - # σ_ETD: ETD flux with no-yield C tensor - edot_eff_etd = self._e_eff_for("etd") - sigma_etd = self._q_with(self._c_etd, edot_eff_etd) - # Spatial blend - w = self._fault_weight - return w * sigma_bdf + (1 - w) * sigma_etd - - # Apply the anisotropic tensor to the effective strain rate - # (current + VE history): σ = C(η₀_ve, η₁_eff) : ε̇_eff - # This is the correct VE formula — the tensor handles anisotropy - # for both current and history contributions uniformly. - edot_eff = self.E_eff.sym if hasattr(self.E_eff, 'sym') else self.E_eff - stress = self._q(edot_eff) - - return stress - - def _q_with(self, c, edot): - """Apply a given rank-4 tensor to a strain rate (helper for - the hybrid flux that needs to contract two distinct C tensors - in one ``stress()`` call). - """ - rank = len(c.shape) - if rank == 2: - flux = c * edot - else: - flux = sympy.tensorcontraction( - sympy.tensorcontraction(sympy.tensorproduct(c, edot), (1, 5)), - (0, 3), - ) - return sympy.Matrix(flux) - - @property - def yield_mode(self): - r"""How to apply yield limiting to the fault-plane viscosity. - - Same options as :class:`ViscoElasticPlasticFlowModel`: - ``"softmin"`` (default), ``"harmonic"``, ``"min"``. The - ``"smooth"`` option was retired (under-clipped by ~50 %); see - the parent class's :attr:`yield_mode` docstring for details. - """ - return self._yield_mode - - @yield_mode.setter - def yield_mode(self, value): - if value == "smooth": - raise ValueError( - "yield_mode='smooth' has been retired — it under-clipped " - "the yield surface by ~50%. Use 'softmin' instead " - "(default; close to exact Min with smooth derivatives)." - ) - if value not in ("min", "harmonic", "softmin"): - raise ValueError( - f"yield_mode must be 'min', 'harmonic', or 'softmin', got '{value}'" - ) - self._yield_mode = value - self._reset() - - @property - def yield_softness(self): - """Regularisation parameter δ for softmin mode.""" - return self._yield_softness - - @yield_softness.setter - def yield_softness(self, value): - self._yield_softness = value - self._reset() - - @property - def requires_stress_history(self): - """Transverse isotropic VEP requires stress history tracking.""" - return True - - @property - def supports_yield_homotopy(self): - """This model carries a δ-parameterised yield law — ``solve(homotopy=True)`` - can march it. Note this model's ``yield_softness`` setter re-triggers a - rebuild, so each δ step costs a recompile (the isotropic models update δ as a - ``constants[]`` atom instead). See :meth:`_yield_homotopy_control`.""" - return True - - # Picard, not Newton — as for the isotropic VEP model, the consistent yield - # tangent over the elastic stress-history block is indefinite. - _yield_homotopy_tangent = False - - @property - def plastic_fraction(self): - """Fraction of strain rate that is plastic.""" - eta_1_ve = self.Parameters.ve_effective_viscosity - eta_1_eff = self.viscosity - # viscosity property returns η₀, need to compare η₁ effective vs η₁ ve - # This is approximate for the anisotropic case - return sympy.Max(0, 1 - eta_1_eff / eta_1_ve.sym if hasattr(eta_1_ve, 'sym') else 0) - - -class TransverseIsotropicMaxwellExponentialFlowModel(TransverseIsotropicVEPFlowModel): - r"""Thin alias: ``TransverseIsotropicVEPFlowModel(integrator='etd', order=1)``. - - .. deprecated:: Phase B - Use the canonical form ``TransverseIsotropicVEPFlowModel(unknowns, - integrator='etd', order=1)`` directly. This sibling class - survives as a thin scaffold for existing scripts; defaults to - ETD-1 (recommended). - """ - - def __init__(self, unknowns, material_name=None): - super().__init__( - unknowns, order=1, integrator="etd", - material_name=material_name, - ) - - -class TransverseIsotropicVEPSplitFlowModel(TransverseIsotropicVEPFlowModel): - r"""**EXPERIMENTAL — DO NOT USE FOR PRODUCTION.** - - Phase D investigation artefact. The σ_∥ enforcement reaches BDF-class - fidelity (1.21·τ_y vs BDF's 1.04·τ_y at τ_y=0.05) but the velocity - field overshoots the boundary value and ratchets monotonically over - cycles to ~21× BDF-1's |u_y|. The fault-tip stress concentrations - in the PyVista field plot are non-physical for this loading. - - Retained on the branch for reproducibility of the investigation; see - ``docs/developer/design/EXPONENTIAL_VE_INTEGRATOR.md`` lessons #9 and - #10 for why this doesn't ship. - - For deep-yield TI fault problems use - ``TransverseIsotropicVEPFlowModel(integrator='bdf')``. - - -- - - Phase D — per-component ``(α_⊥, φ_⊥)/(α_∥, φ_∥)`` ETD-2 for TI VEP. - - The rank-4 modulus splits into two orthogonal projectors: - - .. math:: - C(\eta_0, \eta_\parallel) = 2\eta_0 \, \mathbf{P}_\perp - + 2\eta_\parallel \, \mathbf{P}_\parallel - - where :math:`\mathbf{P}_\parallel` is the director-aligned projector - (the ``K`` kernel built in :py:meth:`_build_c_tensor`) and - :math:`\mathbf{P}_\perp = \mathbf{I}_4 - \mathbf{P}_\parallel`. - Each branch has its own Maxwell relaxation time: - - .. math:: - \tau_\perp = \eta_0 / \mu, \qquad - \tau_\parallel = \eta_\parallel^\text{eff} / \mu - - so the analytical exponential factors differ: - - .. math:: - \alpha_k = e^{-\Delta t / \tau_k}, \qquad - \varphi_k = (1 - \alpha_k) \tau_k / \Delta t - - The split flux integrates each branch independently and sums: - - .. math:: - \sigma^{n+1} = (\alpha_\perp \mathbf{P}_\perp + \alpha_\parallel - \mathbf{P}_\parallel) : \sigma^* - + 2[\eta_0(1-\varphi_\perp) \mathbf{P}_\perp - + \eta_\parallel^\text{eff}(1-\varphi_\parallel) - \mathbf{P}_\parallel] : \dot\varepsilon^{n+1} - + 2[\eta_0(\varphi_\perp - \alpha_\perp) \mathbf{P}_\perp - + \eta_\parallel^\text{eff}(\varphi_\parallel - \alpha_\parallel) - \mathbf{P}_\parallel] : \dot\varepsilon^* - - Phase B uses a single lumped ``(α, φ)`` from ``η_∥_eff/μ`` for the - whole tensor — empirically blows up at tight yield surfaces because - the matrix branch has no business being yielded. The split scheme - relaxes each channel on its proper timescale; the analytical floor - on ``σ_∥`` is then ``≲ τ_y`` by construction. - - Implementation choice: ``α_⊥, φ_⊥`` come from the DDt's existing - scalar ``_exp_coeffs`` (matrix viscosity is fixed and spatially - uniform — a single per-step scalar is right). ``α_∥, φ_∥`` are - inlined as sympy expressions of the yield-clipped ``η_∥_eff`` so - the JIT evaluates them per quadrature point (spatial heterogeneity - captured automatically). No DDt changes, no solver changes. - """ - - # Default cap on τ_∥/Δt — recommendation 4 from the practical - # stabilisation strategy. α_∥ ≥ exp(-1/c); c=1 → α_∥ ≥ 0.37 - # (37% of σ* retained each step, matches BDF-style elastic - # damping). Set to 0 to disable (recovers the un-capped behaviour - # where boundary motion goes straight into plastic slip). - _tau_par_cap_factor = 1.0 - - def __init__(self, unknowns, material_name=None): - super().__init__( - unknowns, order=1, integrator="etd", - material_name=material_name, - ) - - @property - def tau_par_cap_factor(self): - r"""Lower bound ``c`` such that ``τ_∥ ≥ c·Δt`` in the parallel - branch's exponential factor. - - Caps how aggressively the parallel-branch's elastic memory is - relaxed during yielding. Without this cap, ``η_∥_eff → 0`` at - deep yield drives ``α_∥ = exp(-Δt/τ_∥) → 0`` — boundary - motion goes straight into slip each step with no elastic - spring-back, ratcheting fault displacement at the BC rate. - With ``c=1`` (default), ``α_∥ ≥ 1/e``; with ``c=2``, - ``α_∥ ≥ exp(-0.5)``. - - Set to ``0`` to disable (recovers the explicit-plasticity - behaviour). The cap applies only to the (α_∥, φ_∥) factors — - ``C_∥`` keeps the natural yield-clipped η_∥ so ``σ_∥`` still - sits at the yield surface. - """ - return self._tau_par_cap_factor - - @tau_par_cap_factor.setter - def tau_par_cap_factor(self, value): - self._tau_par_cap_factor = float(value) - - def _update_history_coefficients(self): - r"""Update ``(α_⊥, φ_⊥)`` only — the matrix branch. - - ``(α_∥, φ_∥)`` are inlined per-quadrature in :py:meth:`stress`. - Picks ``τ_⊥ = η_0/μ`` (raw matrix viscosity). - """ - if self._integrator != "etd" or self.Unknowns.DFDt is None: - return super()._update_history_coefficients() - params = self.Parameters - if params.shear_modulus.sym is sympy.oo: - tau_perp = sympy.oo - else: - try: - eta_val = float(params.shear_viscosity_0.sym) - mu_val = float(params.shear_modulus.sym) - tau_perp = eta_val / mu_val if mu_val > 0 else sympy.oo - except (TypeError, ValueError): - tau_perp = None - try: - dt_val = ( - float(params.dt_elastic.sym) - if params.dt_elastic.sym is not sympy.oo - else None - ) - except (TypeError, ValueError): - dt_val = None - self.Unknowns.DFDt.update_exp_coefficients(dt_val, tau_perp) - - def _eta_par_eff(self): - """Yield-clipped ``η_∥_eff`` — same softmin/min/harmonic as parent. - - For ETD the base is the raw ``η_1`` (no VE pre-clip); the yield - envelope is then applied via the configured yield_mode. - """ - params = self.Parameters - eta_par = params.shear_viscosity_1 - if hasattr(eta_par, 'sym'): - eta_par = eta_par.sym - - if not self.is_viscoplastic or params.yield_stress.sym is sympy.oo: - return eta_par - - vp_eff = self._plastic_effective_viscosity - if self._yield_mode == "harmonic": - return 1 / (1 / eta_par + 1 / vp_eff) - elif self._yield_mode == "softmin": - delta = self._yield_softness - f = eta_par / vp_eff - import math - offset = (-1 + math.sqrt(1 + delta**2)) / 2 - g = 1 + (f - 1 + sympy.sqrt((f - 1) ** 2 + delta ** 2)) / 2 - offset - return eta_par / g - else: - return sympy.Min(eta_par, vp_eff) - - def _eta_par_eff_lagged(self): - """Yield-clipped ``η_∥_eff`` using the **lagged** strain rate - (``forcing_star``, projected post-solve) instead of the current - ``E_eff``. - - Same softmin/harmonic/min envelope as :py:meth:`_eta_par_eff`, - but the plastic estimate ``vp_eff_lag = τ_y/(2|γ̇*|)`` uses the - *previous step's* fault-plane shear magnitude. This is the - proper "lag η_∥_eff": elastic regime → η_1_raw (low |γ̇*|); - yielded regime → ``τ_y/(2|γ̇*|)`` (saturated stress, large rate). - Using the parent's E_eff-based formula here would couple α_∥ - back into the current Newton iterate, which collapses to a - 1-iteration trivial residual (over-damping). Holding the rate - fixed at the post-solve value breaks that feedback. - """ - params = self.Parameters - DDt = self.Unknowns.DFDt - - eta_par = params.shear_viscosity_1 - if hasattr(eta_par, 'sym'): - eta_par = eta_par.sym - - if not self.is_viscoplastic or params.yield_stress.sym is sympy.oo: - return eta_par - if DDt is None or DDt.forcing_star is None: - return self._eta_par_eff() # no history yet — fall back to current - - # Lagged plastic estimate: |γ̇*_∥| from forcing_star + director - Edot_lag = DDt.forcing_star.sym - n = params.director.sym - T_lag = Edot_lag * n - edot_n_lag = (n.T * T_lag)[0, 0] - T_sq_lag = (T_lag.T * T_lag)[0, 0] - gamma_dot_sq_lag = T_sq_lag - edot_n_lag ** 2 - gamma_dot_abs_lag = sympy.sqrt(sympy.Max(gamma_dot_sq_lag, 0)) - - # Strip Pint from the ε_min floor (forcing_star is unitless storage) - edot_min_raw = params.strainrate_inv_II_min.sym - if hasattr(edot_min_raw, 'magnitude'): - edot_min_val = float(edot_min_raw.magnitude) - else: - try: - edot_min_val = float(edot_min_raw) - except (TypeError, ValueError): - edot_min_val = 1.0e-6 - - tau_y_sym = params.yield_stress.sym - vp_eff_lag = tau_y_sym / ( - 2 * (gamma_dot_abs_lag + sympy.Float(edot_min_val)) - ) - - if self._yield_mode == "harmonic": - return 1 / (1 / eta_par + 1 / vp_eff_lag) - elif self._yield_mode == "softmin": - delta = self._yield_softness - f = eta_par / vp_eff_lag - import math - offset = (-1 + math.sqrt(1 + delta ** 2)) / 2 - g = 1 + (f - 1 + sympy.sqrt((f - 1) ** 2 + delta ** 2)) / 2 - offset - return eta_par / g - else: - return sympy.Min(eta_par, vp_eff_lag) - - def _build_split_c_tensors(self, eta_perp, eta_par): - r"""Build ``C_⊥ = 2·η_⊥·P_⊥`` and ``C_∥ = 2·η_∥·P_∥``. - - Identical loop structure to :py:meth:`_build_c_tensor`, but - each tensor isolates one projector by zeroing the other - viscosity coefficient. - """ - d = self.dim - n = self.Parameters.director.sym - identity = uw.maths.tensor.rank4_identity(d) - - c_perp_arr = sympy.MutableDenseNDimArray.zeros(d, d, d, d) - c_par_arr = sympy.MutableDenseNDimArray.zeros(d, d, d, d) - - for i in range(d): - for j in range(d): - for k in range(d): - for l in range(d): - I_ijkl = identity[i, j, k, l] - K_ijkl = ( - (n[i] * n[k] * int(j == l) - + n[j] * n[k] * int(l == i) - + n[i] * n[l] * int(j == k) - + n[j] * n[l] * int(k == i)) / 2 - - 2 * n[i] * n[j] * n[k] * n[l] - ) - # 2·η_⊥·P_⊥ = 2·η_⊥·(I - K) - v_perp = 2 * eta_perp * (I_ijkl - K_ijkl) - # 2·η_∥·P_∥ = 2·η_∥·K - v_par = 2 * eta_par * K_ijkl - # Same guard as parent _build_c_tensor — sympy - # NDimArray.__setitem__ refuses iterable RHS. - if hasattr(v_perp, '__getitem__') and not isinstance( - v_perp, (sympy.MatrixBase, sympy.NDimArray) - ): - v_perp = sympy.Mul(sympy.S.One, v_perp, evaluate=False) - if hasattr(v_par, '__getitem__') and not isinstance( - v_par, (sympy.MatrixBase, sympy.NDimArray) - ): - v_par = sympy.Mul(sympy.S.One, v_par, evaluate=False) - c_perp_arr[i, j, k, l] = v_perp - c_par_arr[i, j, k, l] = v_par - - c_perp = uw.maths.tensor.mandel_to_rank4( - uw.maths.tensor.rank4_to_mandel(c_perp_arr, d), d) - c_par = uw.maths.tensor.mandel_to_rank4( - uw.maths.tensor.rank4_to_mandel(c_par_arr, d), d) - return c_perp, c_par - - def stress(self): - r"""Per-component ETD-2 flux with **lagged** ``(α_∥, φ_∥)``. - - Each branch's E_eff is built and contracted with its own - sub-modulus; the two are summed. - - ``α_∥, φ_∥`` are derived from a *lagged* parallel viscosity - computed from the projected stress and strain-rate histories: - - .. math:: - \eta_\parallel^{\,\mathrm{lag}} - = \frac{|\sigma^*_\parallel|} - {2\,\max(|\dot\varepsilon^*_\parallel|, \dot\varepsilon_\min)} - - where ``|·|_∥`` is the Pythagorean fault-plane shear magnitude - (same pattern as :py:meth:`_plastic_effective_viscosity`). This - sits naturally on the yield surface during yielding (because - ``|σ^*_∥|`` saturates near ``τ_y`` while ``|ε̇^*_∥|`` is large) - and tracks the elastic VE response otherwise. Critically the - expression depends only on previous-step storage — no - plasticity-clip recursion through ``E_eff`` — keeping the JIT - codegen tree shallow. - - The C_∥ sub-modulus still uses the **current** yield-clipped - ``η_∥_eff`` (preserves Newton's nonlinear plasticity Jacobian - through the multiplicative response weights). - """ - params = self.Parameters - DDt = self.Unknowns.DFDt - E = self.Unknowns.E - - # Matrix branch: scalar (α_⊥, φ_⊥) from DDt - alpha_perp = DDt._exp_alpha - phi_perp = DDt._exp_phi - eta_perp = params.shear_viscosity_0 - eta_perp_sym = eta_perp.sym - - # Histories - sigma_star = DDt.psi_star[0].sym - if DDt.forcing_star is not None: - edot_star = DDt.forcing_star.sym - else: - edot_star = sympy.zeros(*E.shape) - - # ── Lagged η_∥_eff via parent's softmin envelope, evaluated - # against forcing_star (previous-step ε̇) instead of E_eff - # (current Newton iterate). Breaks the per-quad-split's - # 1-iteration trivial-Newton failure mode. - eta_par_lag_chain = self._eta_par_eff_lagged() - if hasattr(eta_par_lag_chain, 'sym'): - eta_par_lagged = eta_par_lag_chain.sym - else: - eta_par_lagged = eta_par_lag_chain - - mu_sym = params.shear_modulus.sym - dt_sym = params.dt_elastic.sym if hasattr(params.dt_elastic, 'sym') else params.dt_elastic - # x_par = Δt/τ_∥ — natural value (no cap) - tau_par_natural = eta_par_lagged / mu_sym - x_par_natural = dt_sym / tau_par_natural - # Soft cap on x_par: x_eff = (1 - exp(-c·x))/c - # • x → 0: x_eff → x (elastic, no cap) - # • x → ∞: x_eff → 1/c (capped → α_∥ ≥ exp(-1/c)) - # • smooth derivatives everywhere; pre-evaluates to a finite - # scalar at codegen-time defaults (dt=∞, μ=∞, η=Pint) where - # x_natural = oo, exp(-c·oo) = 0, x_eff = 1/c — avoids the - # oo-vs-Pint dimensional clash that breaks sympy.Max/+ caps. - # Equivalent to capping τ_∥ ≥ c·Δt (recommendation #4). - if self._tau_par_cap_factor > 0.0: - c = sympy.Float(self._tau_par_cap_factor) - x_par = (1 - sympy.exp(-c * x_par_natural)) / c - else: - x_par = x_par_natural - alpha_par = sympy.exp(-x_par) - phi_par = (1 - alpha_par) / x_par - - # C_∥ uses the natural lagged η (no cap) — preserves the - # σ_∥ ≈ τ_y enforcement on the yield surface. The cap only - # tames the elastic-memory factor (α_∥, φ_∥) so the parallel - # branch retains some spring-back instead of fully releasing - # in one step. Recovers BDF-style behaviour where boundary - # motion is partly absorbed by elastic accumulation rather - # than dumped entirely into slip. - eta_par_current = eta_par_lagged - - # Build the split sub-moduli with the lagged η_∥ - c_perp, c_par = self._build_split_c_tensors(eta_perp_sym, eta_par_current) - - # E_eff_⊥ = (1-φ_⊥)·ε̇ + α_⊥/(2η_⊥)·σ* + (φ_⊥-α_⊥)·ε̇* - e_eff_perp = ( - (1 - phi_perp) * E - + (alpha_perp / (2 * eta_perp)) * sigma_star - + (phi_perp - alpha_perp) * edot_star - ) - # E_eff_∥ uses lagged α_∥, φ_∥ but current η_∥_eff in the - # σ*-projection denominator (so the projected history reads - # off the same modulus the next step's flux is built on). - e_eff_par = ( - (1 - phi_par) * E - + (alpha_par / (2 * eta_par_current)) * sigma_star - + (phi_par - alpha_par) * edot_star - ) - - def _contract(c, x): - if len(c.shape) == 2: - return sympy.Matrix(c * x) - return sympy.Matrix(sympy.tensorcontraction( - sympy.tensorcontraction(sympy.tensorproduct(c, x), (1, 5)), - (0, 3), - )) - - return _contract(c_perp, e_eff_perp) + _contract(c_par, e_eff_par) - - -class MultiMaterialConstitutiveModel(Constitutive_Model): - r""" - Multi-material constitutive model using level-set weighted flux averaging. - - Mathematical Foundation: - - .. math:: - - \mathbf{f}_{\text{composite}}(\mathbf{x}) = \sum_{i=1}^{N} - \phi_i(\mathbf{x}) \cdot \mathbf{f}_i(\mathbf{x}) - - Critical Architecture: - - - Solver owns Unknowns (including :math:`D\mathbf{F}/Dt` stress history) - - All constituent models share solver's Unknowns - - Composite flux becomes stress history for all materials - """ - - def __init__( - self, - unknowns, - material_swarmVariable: IndexSwarmVariable, - constitutive_models: list, - normalize_levelsets: bool = False, - ): - r""" - Parameters - ---------- - unknowns : UnknownSet - The solver's authoritative unknowns (:math:`\mathbf{u}`, - :math:`D\mathbf{F}/Dt`, :math:`D\mathbf{u}/Dt`). - material_swarmVariable : IndexSwarmVariable - Index variable tracking material distribution on particles. - constitutive_models : list of Constitutive_Model - Pre-configured constitutive models for each material. - normalize_levelsets : bool, optional - Whether to normalize level-set functions to enforce partition of unity. - Set to True if IndexSwarmVariable does not maintain partition of unity. - Default: False (assumes IndexSwarmVariable maintains partition of unity) - """ - # Validate compatibility before initialization - self._validate_model_compatibility(constitutive_models) - - self._material_var = material_swarmVariable - self._constitutive_models = constitutive_models - self._normalize_levelsets = normalize_levelsets - - # Ensure model count matches material indices - if len(constitutive_models) != material_swarmVariable.indices: - raise ValueError( - f"Model count ({len(constitutive_models)}) must match " - f"material indices ({material_swarmVariable.indices})" - ) - - # CRITICAL: Share solver's unknowns with all constituent models - self._setup_shared_unknowns(constitutive_models, unknowns) - - # Composite model doesn't have its own material_name - constituents do - super().__init__(unknowns, material_name=None) - - def _setup_shared_unknowns(self, constitutive_models, unknowns): - """ - Ensure all constituent models share the solver's authoritative unknowns. - This is critical for proper stress history management. - """ - for i, model in enumerate(constitutive_models): - # Share solver's unknowns - this gives access to composite D(F)/Dt history - model.Unknowns = unknowns - - # Validation: Ensure sharing worked correctly - assert model.Unknowns is unknowns, f"Model {i} failed to share unknowns - memory issue?" - - # For elastic models, verify DFDt access - if hasattr(model, "_stress_star"): - assert hasattr( - unknowns, "DFDt" - ), f"Model {i} needs stress history but DFDt not available" - - def _validate_model_compatibility(self, models: list) -> bool: - """ - Ensure all constituent models are compatible for flux averaging. - - Checks: - - Same u_dim (scalar vs vector problem compatibility) - - Same spatial dimension (2D/3D consistency) - - Compatible flux tensor shapes - - All models properly initialized - """ - if not models: - raise ValueError("At least one constitutive model required") - - reference_model = models[0] - reference_u_dim = reference_model.u_dim - reference_dim = reference_model.dim - - for i, model in enumerate(models): - if model.u_dim != reference_u_dim: - raise ValueError(f"Model {i} has u_dim={model.u_dim}, expected {reference_u_dim}") - if model.dim != reference_dim: - raise ValueError(f"Model {i} has dim={model.dim}, expected {reference_dim}") - # Validate model is properly initialized - if not hasattr(model, "Unknowns"): - raise ValueError(f"Model {i} is not properly initialized") - - return True - - @property - def flux(self): - r""" - Compute level-set weighted average of constituent model fluxes. - - CRITICAL: This composite flux becomes the stress history that - all constituent models (including elastic ones) will read via - ``DFDt.psi_star[0]`` in the next time step. - """ - # Get reference flux shape from first model - reference_flux = self._constitutive_models[0].flux - combined_flux = sympy.Matrix.zeros(*reference_flux.shape) - - if self._normalize_levelsets: - # Compute normalization factor to ensure partition of unity - total_levelset = sum( - self._material_var.sym[i] for i in range(self._material_var.indices) - ) - - for i in range(self._material_var.indices): - # Get normalized level-set function for material i - material_fraction = self._material_var.sym[i] / total_levelset - - # Get flux contribution from constituent model i - model_flux = self._constitutive_models[i].flux - - # Add weighted contribution to composite flux - combined_flux += material_fraction * model_flux - else: - # Use level-sets directly (assuming they already maintain partition of unity) - for i in range(self._material_var.indices): - # Get flux contribution from constituent model i - model_flux = self._constitutive_models[i].flux - - # Add weighted contribution using level-set directly - combined_flux += self._material_var.sym[i] * model_flux - - # This combined_flux will become the stress history for ALL materials - return combined_flux - - @property - def K(self): - r""" - Effective stiffness using level-set weighted harmonic average. - - For composite materials, harmonic averaging gives the correct effective - stiffness for preconditioning: $1/K_{eff} = \sum(\phi_i / K_i) / \sum(\phi_i)$ - """ - # Harmonic average: 1/K_eff = sum(phi_i / K_i) / sum(phi_i) - combined_inv_K = sympy.sympify(0) - - if self._normalize_levelsets: - # Compute normalization factor to ensure partition of unity - total_levelset = sum( - self._material_var.sym[i] for i in range(self._material_var.indices) - ) - - for i in range(self._material_var.indices): - # Get normalized level-set function for material i - material_fraction = self._material_var.sym[i] / total_levelset - - # Get stiffness from constituent model i - model_K = self._constitutive_models[i].K - - # Add weighted contribution to inverse stiffness - combined_inv_K += material_fraction / model_K - else: - # Use level-sets directly (assuming they already maintain partition of unity) - for i in range(self._material_var.indices): - # Get stiffness from constituent model i - model_K = self._constitutive_models[i].K - - # Add weighted contribution using level-set directly - combined_inv_K += self._material_var.sym[i] / model_K - - # Return harmonic average - return 1 / combined_inv_K - - def _object_viewer(self): - from IPython.display import Latex, Markdown, display - - super()._object_viewer() - - display(Markdown(f"**Multi-Material Model**: {len(self._constitutive_models)} materials")) - - for i, model in enumerate(self._constitutive_models): - display(Markdown(f"**Material {i}**: {type(model).__name__}")) - - if self.flux is not None: - display(Latex(r"$\mathbf{f}_{\text{composite}} = " + sympy.latex(self.flux) + "$")) From 384fafd6fc8baef9cc732a58e82a56a1631efd5f Mon Sep 17 00:00:00 2001 From: lmoresi Date: Mon, 27 Jul 2026 18:17:57 +1000 Subject: [PATCH 21/21] fix(test): pin the JIT-constant ramp test to a cold initial guess CI caught what the level_1/tier_a gate deselected: this tier_b test compares solutions at rtol 1e-12 and 1e-10, but its diffusivity depends on T, so the solve is nonlinear and its answer is only pinned to the SNES tolerance. With zero_init_guess defaulting to warm, the second solve stops at a different point in the same tolerance ball and the comparison fails by ~2e-5. The test is about whether a constant is read from its constants[] slot or baked as a C literal. Holding the initial guess fixed makes it measure that, and makes it independent of the warm-start default rather than accidentally dependent on one. Underworld development team with AI support from Claude Code --- tests/test_0103_jit_rampable_constants.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_0103_jit_rampable_constants.py b/tests/test_0103_jit_rampable_constants.py index 97b1ebea4..180df0683 100644 --- a/tests/test_0103_jit_rampable_constants.py +++ b/tests/test_0103_jit_rampable_constants.py @@ -40,7 +40,13 @@ def _build(): def _mean(poisson, T): - poisson.solve() + # COLD every time. The diffusivity depends on T, so this is a nonlinear solve and + # its answer is only pinned to the SNES tolerance — a warm start stops at a + # different point in that same tolerance ball. This test compares solutions at + # rtol 1e-12 to check that a constant was read from its slot rather than baked as a + # literal, so it must hold the initial guess fixed or it measures the warm-start + # policy instead of the thing it is about. + poisson.solve(zero_init_guess=True) return float(np.asarray(T.data)[:, 0].mean())