From 7775c5f8cc124f649d6a6158469954b8afe2fde4 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Wed, 22 Jul 2026 11:50:00 +1000 Subject: [PATCH 01/30] 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/30] 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/30] =?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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] =?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/30] 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/30] =?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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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 da5a37b3fdcb56609d1f434d947fde4b4c86d24e Mon Sep 17 00:00:00 2001 From: lmoresi Date: Mon, 27 Jul 2026 17:58:47 +1000 Subject: [PATCH 21/30] feat(solver): wall-clock guard and sub-solve work gauge A hard viscoplastic Stokes solve does not fail, it grinds -- for hours, inside a single Newton step. Two measurements say why nothing outside PETSc can stop it: an iteration cap never fires because the grind happens within one outer Krylov iteration, so the counter it watches does not advance; and a Python signal.alarm never fires because Python runs signal handlers only between bytecodes while control sits in PETSc's C code (a 90s alarm measured unfired at 10 minutes). So the deadline lives in PETSc's own convergence tests. solver.guard( wall_per_step=...) restarts a budget at each outer KSP solve -- once per Newton step -- and checks it inside the fieldsplit sub-KSP tests, where iterations are frequent enough for seconds of granularity. A guard exit is a report, not an error: solve_report.deadline_expired distinguishes it from a real divergence. The reason returned matters and is easy to get wrong. PETSc's KSPCheckSolve deliberately exempts KSP_DIVERGED_ITS from marking the preconditioner failed, so a guard returning DIVERGED_MAX_IT from a sub-KSP does nothing at all -- measured, the outer solve absorbed 36 truncated velocity solves and reported CONVERGED. DIVERGED_BREAKDOWN marks the sub-PC failed and surfaces as DIVERGED_LINEAR_SOLVE. In parallel the expiry decision is reduced over the solver's communicator: wall clocks are not synchronised, and a convergence test returning different verdicts on different ranks deadlocks the next collective. ptest_0203 provokes this with deliberately skewed per-rank clocks -- it passes at np=2 and np=4, and deadlocks when the reduction is removed (confirmed at a 90s limit). Also adds the work axis the guard needs. solve_report.ksp_its counts OUTER Krylov iterations, which Eisenstat-Walker collapses to about one per Newton step; measured on a small box, outer 1 against 285 multigrid cycles in the velocity block. solve_report.sub records iterations and applications per fieldsplit block, collected by monitors so it cannot influence a solve. Cost of leaving it on: 624 callbacks in a 280ms solve, no measurable change in wall time. Known and documented rather than hidden: on a solver's first solve the fieldsplit blocks do not exist until PCSetUp runs part-way through, so one preconditioner application is uncovered and that solve's sub-counts are a lower bound (SubSolveReport.complete says so). guard() raises on the rotated free-slip path, which solves outside self.snes and cannot be reached. Underworld development team with AI support from Claude Code --- .../design/solver-wall-clock-guard.md | 145 ++++++++ docs/developer/index.md | 1 + .../cython/petsc_generic_snes_solvers.pyx | 89 +++++ src/underworld3/systems/__init__.py | 5 + src/underworld3/systems/solve_report.py | 20 +- src/underworld3/systems/solver_health.py | 315 ++++++++++++++++++ .../ptest_0203_wallclock_guard_parallel.py | 109 ++++++ tests/test_0203_solver_wallclock_guard.py | 203 +++++++++++ 8 files changed, 885 insertions(+), 2 deletions(-) create mode 100644 docs/developer/design/solver-wall-clock-guard.md create mode 100644 src/underworld3/systems/solver_health.py create mode 100644 tests/parallel/ptest_0203_wallclock_guard_parallel.py create mode 100644 tests/test_0203_solver_wallclock_guard.py diff --git a/docs/developer/design/solver-wall-clock-guard.md b/docs/developer/design/solver-wall-clock-guard.md new file mode 100644 index 000000000..4aacc731f --- /dev/null +++ b/docs/developer/design/solver-wall-clock-guard.md @@ -0,0 +1,145 @@ +# Bounding a solver grind: the wall-clock guard and the sub-solve work gauge + +**Status**: implemented. `solver.guard(wall_per_step=...)`, `solver.unguard()`, +`solve_report.sub`, `solve_report.deadline_expired`. +Implementation: `src/underworld3/systems/solver_health.py`. + +## The problem + +A viscoplastic Stokes solve near its conditioning floor does not fail. It *grinds* — for +hours, inside a single Newton step, with no error and no sign of stopping. Any study that +sweeps a parameter plane hits this immediately: the interesting cells are exactly the +unreachable ones, and an unreachable cell costs unbounded time. + +The obvious defences do not work, and both were measured before this was written. + +**An iteration cap does not bound wall time.** Capping nonlinear iterations, outer Krylov +iterations, or inner iterations all leave the same hole: below the conditioning floor a +single Newton step grinds *within one outer Krylov iteration*. The counter the cap watches +never advances, so the cap never fires. Measured: 3814 s inside one outer iteration. + +Nor can an iteration cap be tuned around the problem. Tight caps terminate quickly but +classify wrongly in *both* directions — a continuation march settles at its starting +parameter and reports success (a false rescue), while a step that would have converged in +fifteen iterations is called unreachable at ten (a false failure). Loose caps classify +correctly and run past ten minutes a station. The number of iterations a feasible step +needs is not knowable in advance, which is precisely why a *time* budget is the right +independent guard. + +**A Python timer does not fire.** `signal.alarm` schedules a handler, but Python runs +signal handlers only between bytecodes, and during the grind control is inside PETSc's C +code the entire time. Measured: a 90 s alarm still unfired at 10 minutes. + +So the only code that runs during a grind is PETSc's own. The guard has to live there. + +## The construction + +Three pieces, each chosen for a reason that was measured rather than assumed. + +**Where the clock starts.** The outer KSP runs exactly once per Newton step, so its +iteration 0 is the natural place to restart the budget. No SNES hook is needed — the +original design proposed resetting the deadline from a custom SNES convergence test, but +that would mean reimplementing `SNESConvergedDefault` (petsc4py exposes no way to chain to +it) for no gain. + +**Where the deadline is checked.** In convergence tests on the outer KSP *and on every +fieldsplit sub-KSP*. The sub-KSPs are where the granularity is: in a representative solve +the outer KSP performed 1 iteration while the velocity block performed 285, so only the +sub-KSP tests can interrupt a grind at anything finer than "the whole Newton step". + +**Which diverged reason is returned.** This is the part that is easy to get wrong, and the +earlier prototype did. PETSc's `KSPCheckSolve` deliberately *exempts* `KSP_DIVERGED_ITS` +from marking the preconditioner as failed — truncating an inner solve at its iteration cap +is normal behaviour, not an error. A guard that returns `DIVERGED_MAX_IT` from a sub-KSP +therefore does nothing at all: measured, the outer solve absorbed 36 truncated velocity +solves and reported CONVERGED. Returning `DIVERGED_BREAKDOWN` marks the sub-preconditioner +failed, which surfaces as `DIVERGED_PC_FAILED` at the outer KSP and `DIVERGED_LINEAR_SOLVE` +at the SNES. + +Because a custom convergence test *replaces* the default rather than composing with it, +the guard also carries the ordinary rtol / atol / divtol semantics itself. Tolerances are +re-read every iteration, because Eisenstat–Walker rewrites the outer rtol before each +Newton step. + +### Parallel + +Wall clocks are not synchronised across ranks, so "has the budget run out" is exactly the +kind of question that splits a communicator — and a PETSc convergence test that returns +different verdicts on different ranks leaves the ranks on different code paths, so the +next collective deadlocks. The expiry decision is therefore reduced over the solver's +communicator before it is acted on. This is well posed because Krylov iterations are +globally synchronised, so every rank reaches the same check the same number of times. + +The reduction is one integer per iteration, against a multigrid cycle, and every iteration +already pays for at least one norm reduction — so it does not change the communication +character of the solve. + +`tests/parallel/ptest_0203_wallclock_guard_parallel.py` provokes the hazard deliberately +with deliberately skewed per-rank clocks. With the reduction it passes at np=2 and np=4; +with the reduction removed it deadlocks (confirmed at a 90 s limit). + +The extra reductions are paid only on the guarded path — an unarmed solver installs no +convergence tests at all, so the default solve is untouched. + +### What the guard does not cover + +**Rotated free-slip.** That path runs its own Krylov loop outside `self.snes` +(`utilities/rotated_bc.py`), which the deadline cannot reach. `guard()` raises there +rather than pretending, matching `estimate_difficulty()`. + +**A `preonly` outer KSP.** With no outer iterations there is no convergence test to start +the clock, so the guard is inert. A direct solve is not the thing this guard exists to +bound, but it is worth knowing that it is silent rather than protective. + +**The first preconditioner application.** On a solver's **first** solve the fieldsplit +blocks do not exist when the solve begins: +they are created by `PCSetUp`, which runs inside the first `KSPSolve`. The guard attaches +to them at the earliest reachable moment — the outer KSP's iteration 0 — which is *after* +the preconditioner has been applied once. So one preconditioner application on the first +solve is outside the deadline. Every solve after that is fully covered, because the blocks +persist. + +A driver that cares should do one cheap solve before arming the guard, which it usually +does anyway to pay for the JIT compile. + +## The work gauge + +`solve_report.ksp_its` counts outer Krylov iterations. Under Eisenstat–Walker that is about +one per Newton step, so it is not a measure of cost — using it as a work axis understates a +Stokes solve by two orders of magnitude. Measured on a small box: outer 1, velocity block +285, pressure block 9. + +`solve_report.sub` records iterations and applications per fieldsplit block. For the +velocity block under `pc_type=mg` that iteration count *is* the multigrid cycle count. It +is collected by monitors, which observe and never decide, so it cannot change a solve. + +The same first-solve limitation applies, and the report says so: `SubSolveReport.complete` +is `False` when the block was instrumented part-way through the solve, meaning `its` is a +lower bound. Measured, the first solve reports about half the true count. Every later solve +is exact. + +Cost of leaving the gauge always on: 624 monitor callbacks in a 280 ms solve, no measurable +change in wall time (medians 282 ms instrumented against 284 ms not, run alternately; +run-to-run noise is larger than the difference). + +## Usage + +```python +stokes.guard(wall_per_step=150.0) +stokes.solve() +if stokes.solve_report.deadline_expired: + ... # too hard at these parameters — back off a continuation step +stokes.unguard() + +cycles = stokes.solve_report.sub["velocity"].its # the honest work axis +``` + +A guard exit is a report, not an exception: the current iterate is left in the fields, so a +continuation driver can revert or retry from it. + +## Related + +- `docs/developer/design/nonlinear-solver-homotopy-warmstart.md` — the δ-continuation this + guard exists to make sweepable. +- `docs/developer/design/solver-strategies-catalogue.md` — the solver configurations whose + cost this measures. diff --git a/docs/developer/index.md b/docs/developer/index.md index 977e09ac8..484186486 100644 --- a/docs/developer/index.md +++ b/docs/developer/index.md @@ -151,6 +151,7 @@ design/mesh-adaptation-formulation design/LAYER2_SBR_ADAPT_ON_TOP design/NVB_GRADED_ADAPT design/MULTIGRID_MINIMAL_CONTROL_2026-07 +design/solver-wall-clock-guard design/ARCHITECTURE_ANALYSIS design/MATHEMATICAL_MIXIN_DESIGN design/COORDINATE_MIGRATION_GUIDE diff --git a/src/underworld3/cython/petsc_generic_snes_solvers.pyx b/src/underworld3/cython/petsc_generic_snes_solvers.pyx index 0fda2ebd4..30b57e140 100644 --- a/src/underworld3/cython/petsc_generic_snes_solvers.pyx +++ b/src/underworld3/cython/petsc_generic_snes_solvers.pyx @@ -127,6 +127,10 @@ class SolverBaseClass(uw_object): self._difficulty_probe = False self._difficulty_max_it = None self._resume_abs_target = None + # PETSc-level instrumentation: the sub-solve work gauge, and the + # wall-clock deadline once guard() arms it. Created on the first solve + # (systems/solver_health.py) because it needs a live SNES to attach to. + self._instrumentation = None # Preconditioner selection — see the `preconditioner` property. # `_pc_option_prefix` is set by subclasses that participate in the easy @@ -1008,6 +1012,13 @@ class SolverBaseClass(uw_object): reduction = (fnorm / fnorm0) if (fnorm0 not in (None, 0.0)) else None rho = contraction(hist) + # Sub-solve work, and whether a wall-clock guard cut this solve short. Absent + # for a solver whose instrumentation has never attached (no solve yet). + instrumentation = self._instrumentation + sub = instrumentation.sub_reports() if instrumentation is not None else {} + deadline_expired = (instrumentation is not None + and instrumentation.deadline_expired) + report = SolveReport( reason=reason, reason_str=reason_string(reason), @@ -1021,6 +1032,8 @@ class SolverBaseClass(uw_object): fev=fev, history=hist, bounded=bool(bounded), + sub=sub, + deadline_expired=deadline_expired, ) self._solve_report = report self._solve_history.append(report) @@ -1071,6 +1084,76 @@ class SolverBaseClass(uw_object): self._solve_history.append(report) return report + def guard(self, *, wall_per_step): + r"""Bound the wall-clock time of each Newton step. Opt-in; changes termination. + + A hard nonlinear solve can grind for hours *inside a single Newton step*, and no + iteration cap can stop it: the grind happens within one outer Krylov iteration, + so the counter the cap watches never advances. Nor can a Python timer stop it — + Python runs signal handlers only between bytecodes, and control is inside PETSc + the whole time. The deadline therefore lives in PETSc's own convergence tests, + where it is checked between the *inner* iterations of the fieldsplit blocks. + + When the budget runs out the solve stops with ``DIVERGED_LINEAR_SOLVE`` and + ``solve_report.deadline_expired`` set, leaving the current iterate in the fields. + It is a report, not an error: a continuation driver reads it and steps back. + + Parameters + ---------- + wall_per_step : float + Seconds of wall clock allowed per Newton step. The clock restarts at the + beginning of every step, so a solve taking ``n`` steps may run for up to + roughly ``n * wall_per_step`` seconds. + + Notes + ----- + The budget is enforced at inner-iteration granularity, so the overrun beyond it + is at most the cost of one multigrid cycle. In parallel the expiry decision is + reduced over the solver's communicator, because PETSc deadlocks if convergence + tests return different verdicts on different ranks. + + Not available with rotated free-slip BCs: that path runs its own Krylov loop + outside ``self.snes`` (``utilities/rotated_bc.py``), which the deadline cannot + reach — so arming a guard there would look like protection and provide none. + + On a solver's FIRST solve the fieldsplit blocks do not exist until PETSc has set + up the preconditioner, part-way through that solve, so the first preconditioner + application runs unguarded. Every solve after that is fully covered. A driver + that needs the first one covered should do one cheap solve before arming. + + Examples + -------- + >>> stokes.guard(wall_per_step=150.0) + >>> stokes.solve() + >>> if stokes.solve_report.deadline_expired: + ... ... # too hard at these parameters; back off + >>> stokes.unguard() + + See Also + -------- + unguard : remove the deadline. + estimate_difficulty : bound the *work* (an iteration count) instead. + """ + if getattr(self, "_rotated_freeslip_bcs", None): + raise NotImplementedError( + "guard() is not available with rotated free-slip BCs: that path runs " + "its own Krylov loop outside self.snes (utilities/rotated_bc.py), so " + "the deadline cannot reach it and the guard would be silently inert." + ) + self._solver_instrumentation().arm(wall_per_step) + + def unguard(self): + """Remove the wall-clock deadline set by :meth:`guard`, restoring PETSc defaults.""" + if self._instrumentation is not None: + self._instrumentation.disarm() + + def _solver_instrumentation(self): + """The solver's PETSc instrumentation, created on first use.""" + if self._instrumentation is None: + from underworld3.systems.solver_health import SolverInstrumentation + self._instrumentation = SolverInstrumentation() + return self._instrumentation + def estimate_difficulty(self, max_nl_its, *, warm=True, **solve_kwargs): """Run a bounded, resumable solve to *estimate solver difficulty* and return its report. @@ -1424,6 +1507,12 @@ class SolverBaseClass(uw_object): except Exception: pass + # Attach the sub-solve gauge (and the wall-clock deadline, if guard() armed + # one) to the CURRENT SNES and clear its per-solve counters. Here rather than + # in _build because a rebuilt solver gets a new SNES and everything attached to + # the old one is dropped; every solve funnels through this method. + self._solver_instrumentation().begin_solve(self.snes) + # Single control point for the bounded/resumable difficulty solve. Runs AFTER # every per-solve setFromOptions (base and Stokes), so overrides here win. Gated # on _difficulty_probe so it is active ONLY inside estimate_difficulty — a plain diff --git a/src/underworld3/systems/__init__.py b/src/underworld3/systems/__init__.py index 97ca09960..9db6407ae 100644 --- a/src/underworld3/systems/__init__.py +++ b/src/underworld3/systems/__init__.py @@ -79,6 +79,11 @@ from .free_surface import FreeSurface +# Records left by every solve — read via solver.solve_report. SubSolveReport is what +# solve_report.sub holds, one entry per fieldsplit block (see solver_health). +from .solve_report import SolveReport +from .solver_health import SubSolveReport + # are the Lagrangian implementations actually distinct in reality ? from .ddt import Lagrangian as Lagrangian_DDt from .ddt import SemiLagrangian as SemiLagragian_DDt diff --git a/src/underworld3/systems/solve_report.py b/src/underworld3/systems/solve_report.py index 8cdd6358d..ecd261617 100644 --- a/src/underworld3/systems/solve_report.py +++ b/src/underworld3/systems/solve_report.py @@ -11,8 +11,8 @@ """ from __future__ import annotations -from dataclasses import dataclass -from typing import Optional, Tuple +from dataclasses import dataclass, field +from typing import Mapping, Optional, Tuple # PETSc SNESConvergedReason codes -> short names. Mirrors the compact map in # SolverBaseClass._convergence_reasons; duplicated here so this module imports without the @@ -115,6 +115,17 @@ class SolveReport: bounded ``True`` when this report came from an iteration-capped (``estimate_difficulty``) solve — i.e. work was truncated, not a genuine failure. + sub + Work done by each fieldsplit sub-solve, keyed by block name (``"velocity"``, + ``"pressure"`` for Stokes). ``ksp_its`` above counts only the OUTER Krylov + iterations, which Eisenstat–Walker collapses to about one per Newton step — the + real cost of a Stokes solve is ``sub["velocity"].its`` multigrid cycles. Empty + for solvers with no fieldsplit preconditioner. See + ``underworld3.systems.solver_health``. + deadline_expired + ``True`` when a wall-clock guard armed with ``solver.guard(...)`` cut this solve + short. Distinguishes "the budget ran out" from a genuine divergence: both report + ``DIVERGED_LINEAR_SOLVE``. """ reason: int @@ -129,12 +140,17 @@ class SolveReport: fev: Optional[int] = None history: Tuple[float, ...] = () bounded: bool = False + sub: Mapping[str, "SubSolveReport"] = field(default_factory=dict) + deadline_expired: bool = False def __str__(self) -> str: rho = f"{self.rho:.3f}" if self.rho is not None else "n/a" + sub = "".join(f", {r.name}={r.its}" for r in self.sub.values()) return ( f"SolveReport({self.reason_str}, nl={self.nl_its}, ksp={self.ksp_its}, " f"|F|={self.fnorm:.3e}, rho={rho}" + + sub + (", bounded" if self.bounded else "") + + (", deadline" if self.deadline_expired else "") + ")" ) diff --git a/src/underworld3/systems/solver_health.py b/src/underworld3/systems/solver_health.py new file mode 100644 index 000000000..c86660ecd --- /dev/null +++ b/src/underworld3/systems/solver_health.py @@ -0,0 +1,315 @@ +r"""Sub-solve work gauges and the wall-clock guard for SNES-based solvers. + +Two facilities, deliberately separate, both attached to the solver's own PETSc objects. + +**The gauge** (always on, observation only). A nonlinear Stokes solve reports about one +*outer* KSP iteration per Newton step, because Eisenstat--Walker loosens the outer +tolerance to whatever the current Newton step deserves. The work is really done inside +the ``fieldsplit_velocity_`` sub-KSP, one multigrid cycle per iteration, and inside the +``fieldsplit_pressure_`` sub-KSP, one *velocity solve* per iteration. So +``solve_report.ksp_its`` is not a measure of cost. ``solve_report.sub`` is: iterations +and applications per fieldsplit block, which is what "how expensive was this solve" +actually means. + +**The guard** (opt-in, changes termination). ``solver.guard(wall_per_step=...)`` bounds +the wall-clock time of each Newton step. Nothing outside PETSc can do this: + +* an iteration cap does not bound wall time, because below the conditioning floor a + *single* Newton step grinds inside one outer KSP iteration -- the outer count never + advances, so no cap on it can fire; +* a Python ``signal.alarm`` does not fire either, because Python runs signal handlers + only between bytecodes and control is inside PETSc's C code the whole time. Measured: + a 90 s alarm sat unfired at 10 minutes. + +The only code that runs during the grind is PETSc's own convergence test, so that is +where the deadline lives. It is reset at the start of every outer KSP solve -- which is +once per Newton step -- and checked inside the sub-KSP tests, where iterations are +frequent enough to give seconds of granularity. + +**Which diverged reason** matters, and is not obvious. PETSc's ``KSPCheckSolve`` +deliberately *exempts* ``KSP_DIVERGED_ITS`` from marking the preconditioner as failed -- +truncating an inner solve at its iteration cap is normal, not an error. So a guard that +returns ``DIVERGED_MAX_IT`` from a sub-KSP silently does nothing: measured, the outer +solve absorbed 36 truncated velocity solves and still reported CONVERGED. Returning +``DIVERGED_BREAKDOWN`` marks the sub-preconditioner failed, which surfaces at the outer +KSP as ``DIVERGED_PC_FAILED`` and at the SNES as ``DIVERGED_LINEAR_SOLVE``. + +See ``docs/developer/design/solver-wall-clock-guard.md``. +""" + +from __future__ import annotations + +import math +import time +from dataclasses import dataclass +from typing import Dict + +import numpy as np +from mpi4py import MPI +from petsc4py import PETSc + +# Marking the sub-preconditioner failed is the whole point -- see the module docstring. +_DEADLINE_REASON = PETSc.KSP.ConvergedReason.DIVERGED_BREAKDOWN + + +@dataclass(frozen=True) +class SubSolveReport: + """Work done by one fieldsplit sub-solve over a single outer solve. + + Attributes + ---------- + name + Block name taken from the sub-KSP's options prefix -- ``"velocity"`` or + ``"pressure"`` for the Stokes saddle-point solver. + its + Iterations summed over every application of this sub-solve. For the velocity + block under ``pc_type=mg`` this is the multigrid cycle count, i.e. the honest + work axis. + applications + Number of times the sub-solve ran during the outer solve. + complete + ``False`` when the block was instrumented *during* this solve rather than + before it, which happens on a solver's very first solve: the fieldsplit blocks + do not exist until ``PCSetUp`` has run, and that runs inside the first + ``KSPSolve``. Everything the preconditioner did before then is not in the + count, so treat ``its`` as a lower bound. Every later solve is exact -- so a + driver that wants exact work should do one cheap solve first, which it usually + does anyway to pay for the JIT compile. + """ + + name: str + its: int + applications: int + complete: bool = True + + def __str__(self) -> str: + return (f"{self.name}: {self.its} its / {self.applications} applications" + + ("" if self.complete else " (lower bound)")) + + +def _block_name(ksp): + """Readable block name from a sub-KSP options prefix. + + Prefixes look like ``Solver_6_fieldsplit_velocity_``; the part that identifies the + block is the tail after the last ``fieldsplit_``. + """ + prefix = ksp.getOptionsPrefix() or "" + tail = prefix.rsplit("fieldsplit_", 1)[-1] + return tail.strip("_") or prefix.strip("_") or "sub" + + +def _default_convergence(ksp, its, rnorm, state): + """Reproduce ``KSPConvergedDefault``'s verdict for this iteration. + + A custom convergence test *replaces* the default -- petsc4py exposes no way to chain + to it -- so a guard has to carry the ordinary rtol / atol / divtol semantics itself, + or a healthy KSP would never converge. Tolerances are read fresh each iteration + because Eisenstat--Walker rewrites the outer rtol before every Newton step. + """ + R = PETSc.KSP.ConvergedReason + if its == 0: + state["r0"] = rnorm if rnorm > 0.0 else 1.0 + rtol, atol, divtol, _max_it = ksp.getTolerances() + if rnorm != rnorm: # NaN + return R.DIVERGED_NANORINF + if rnorm <= atol: + return R.CONVERGED_ATOL + if rnorm <= rtol * state["r0"]: + return R.CONVERGED_RTOL + if rnorm >= divtol * state["r0"]: + return R.DIVERGED_DTOL + return R.ITERATING + + +class _InstrumentedKSP: + """One KSP the instrumentation is attached to, and everything it needs. + + Holds the iteration counters (fed by a monitor, which cannot affect the solve) and, + when the guard is armed, the deadline-checking convergence test that replaces the + default one. + """ + + def __init__(self, ksp, name, instrumentation, is_outer, mid_solve): + self.ksp = ksp + self.name = name + self.its = 0 + self.applications = 0 + self.complete = not mid_solve + self._instrumentation = instrumentation + self.is_outer = is_outer + self._state = {"r0": 1.0} + ksp.setMonitor(self._monitor) + + def reset(self): + self.its = 0 + self.applications = 0 + self.complete = True # attached before this solve began + + def report(self): + return SubSolveReport(name=self.name, its=self.its, + applications=self.applications, complete=self.complete) + + def _monitor(self, ksp, its, rnorm): + # PETSc calls the monitor once per iteration including iteration 0, which + # reports the initial residual rather than work done -- so a call at 0 opens a + # new application and every later call is one iteration. + if its == 0: + self.applications += 1 + if self.is_outer: + # The outer KSP runs once per Newton step, so its iteration 0 is the + # natural place to start that step's clock -- no SNES hook needed. The + # sub-KSPs do not exist until PCSetUp has run, and PCSetUp runs inside + # KSPSolve, so this is the earliest they can be reached. + self._instrumentation.open_step() + self._instrumentation.attach_sub_ksps(ksp, mid_solve=True) + else: + self.its += 1 + + def _test(self, ksp, its, rnorm): + if self.is_outer and its == 0: + # PETSc does not fix the order of the monitor and the convergence test, so + # whichever runs first at iteration 0 opens the step. Both are idempotent. + self._instrumentation.open_step() + self._instrumentation.attach_sub_ksps(ksp, mid_solve=True) + if self._instrumentation.deadline_passed(): + self._instrumentation.deadline_expired = True + return _DEADLINE_REASON + return _default_convergence(ksp, its, rnorm, self._state) + + def install_test(self): + self.ksp.setConvergenceTest(self._test) + + def remove_test(self): + self.ksp.setConvergenceTest(None) # restores KSPConvergedDefault + + +class SolverInstrumentation: + """A solver's PETSc instrumentation: the sub-solve gauge, and the guard when armed. + + One instance per solver, created on first use. It re-attaches itself to whatever + SNES the solver currently holds, because a setup-dirtying change (remesh, adapt, a + rebuilt discretisation) replaces the SNES and drops everything attached to the old + one. + """ + + def __init__(self): + self.wall_per_step = None # None => guard disarmed + self.deadline_expired = False + self._ksps: Dict[int, _InstrumentedKSP] = {} # keyed by PETSc handle + self._outer_handle = None + self._expires = math.inf + self._comm = None + self._flag = np.zeros(1, dtype=np.int32) + + # ---------------------------------------------------------------- arming + + @property + def armed(self): + return self.wall_per_step is not None + + def arm(self, wall_per_step): + """Set the per-Newton-step wall-clock budget, in seconds.""" + budget = float(wall_per_step) + if not budget > 0.0: + raise ValueError( + f"wall_per_step must be a positive number of seconds (got " + f"{wall_per_step!r}); call unguard() to remove the deadline." + ) + self.wall_per_step = budget + for entry in self._ksps.values(): + entry.install_test() + + def disarm(self): + self.wall_per_step = None + self._expires = math.inf + for entry in self._ksps.values(): + entry.remove_test() + + # ------------------------------------------------------------ attachment + + def begin_solve(self, snes): + """Prepare instrumentation for a solve that is about to start. + + Attaches to the current SNES's outer KSP if it is one we have not seen, and + clears the per-solve counters. Called from the solver's single solve funnel, so + a recreated SNES is picked up automatically. + """ + self.deadline_expired = False + self._expires = math.inf + + outer = snes.getKSP() + if self._outer_handle not in (None, outer.handle): + # A rebuilt solver (remesh, adapt, new discretisation) carries a new SNES, + # and everything attached to the old one went with it. Start over rather + # than accumulate blocks that can never report again. + self._ksps.clear() + self._outer_handle = outer.handle + + for entry in self._ksps.values(): + entry.reset() + self._attach(outer, name="outer", is_outer=True, mid_solve=False) + + def attach_sub_ksps(self, ksp, mid_solve): + """Attach to the fieldsplit blocks of ``ksp``'s preconditioner. + + Only ever called from inside a solve: the blocks do not exist until ``PCSetUp`` + has run, and asking for them earlier is a PETSc error rather than an empty list. + They persist across solves, so this attaches once and then does nothing. + """ + pc = ksp.getPC() + if pc.getType() != "fieldsplit": + return # no sub-solves to account for + for sub in pc.getFieldSplitSubKSP(): + self._attach(sub, name=_block_name(sub), is_outer=False, + mid_solve=mid_solve) + + def _attach(self, ksp, name, is_outer, mid_solve): + if ksp.handle in self._ksps: + return + entry = _InstrumentedKSP(ksp, name, self, is_outer, mid_solve) + self._ksps[ksp.handle] = entry + if is_outer: + self._comm = ksp.getComm().tompi4py() + if self.armed: + entry.install_test() + + # ------------------------------------------------------------ the deadline + + def open_step(self): + """Start the clock for one Newton step.""" + if self.armed: + self._expires = time.monotonic() + self.wall_per_step + else: + self._expires = math.inf + + def deadline_passed(self): + """Has the budget run out? The answer must be identical on every rank. + + A convergence test that returns different reasons on different ranks leaves the + ranks on different PETSc code paths and the next collective deadlocks. Wall + clocks are not synchronised across ranks, so the local answer is reduced over + the KSP's own communicator. That costs one integer reduction per iteration, + against a multigrid cycle -- and every iteration already pays for at least one + norm reduction, so it does not change the communication character of the solve. + """ + if self._expires == math.inf: + return False + local = 1 if time.monotonic() > self._expires else 0 + if self._comm is None or self._comm.size == 1: + return bool(local) + self._flag[0] = local + self._comm.Allreduce(MPI.IN_PLACE, self._flag, op=MPI.MAX) + return bool(self._flag[0]) + + # ---------------------------------------------------------------- readout + + def sub_reports(self): + """Per-block work for the solve that just finished. + + The outer KSP is excluded: its count is already ``solve_report.ksp_its``. + """ + return { + entry.name: entry.report() + for entry in self._ksps.values() + if not entry.is_outer and entry.applications > 0 + } diff --git a/tests/parallel/ptest_0203_wallclock_guard_parallel.py b/tests/parallel/ptest_0203_wallclock_guard_parallel.py new file mode 100644 index 000000000..f19ed9436 --- /dev/null +++ b/tests/parallel/ptest_0203_wallclock_guard_parallel.py @@ -0,0 +1,109 @@ +"""MPI test for the wall-clock guard (``systems/solver_health.py``). + +Run via mpi_runner.sh (mpirun -np N python ptest_0203_*.py). + +The parallel hazard is specific and severe: a PETSc convergence test that returns +different verdicts on different ranks leaves the ranks on different code paths, and the +next collective inside PETSc deadlocks. Wall clocks are not synchronised across ranks, +so "has the budget run out" is exactly the kind of question that would split them — +which is why the guard reduces the answer over the solver's communicator. + +**This script completing at all is the main assertion.** A split verdict hangs rather +than fails; the runner's timeout is what catches it. The explicit assertions then check +that the ranks agree about what happened, and that a guarded solve with a generous +budget still produces the same answer as an unguarded one. +""" +import numpy as np +import sympy + +import underworld3 as uw + +mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.05, qdegree=3 +) +v = uw.discretisation.MeshVariable("Up", 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 +x, y = mesh.X +stokes.bodyforce = sympy.Matrix( + [0.0, -sympy.cos(sympy.pi * x) * sympy.sin(sympy.pi * y)] +) +for wall in ("Top", "Bottom", "Left", "Right"): + stokes.add_dirichlet_bc((0.0, 0.0), wall) +stokes.petsc_options["snes_error_if_not_converged"] = "false" + +# Unguarded reference. +stokes.solve() +stokes.solve(zero_init_guess=True) +reference = np.asarray(v.array).copy() +assert stokes.solve_report.converged, f"rank {uw.mpi.rank}: reference solve failed" + +# A generous budget must not disturb the answer on any rank. +stokes.guard(wall_per_step=600.0) +stokes.solve(zero_init_guess=True) +report = stokes.solve_report +assert report.converged, f"rank {uw.mpi.rank}: generous budget broke a healthy solve" +assert not report.deadline_expired +drift = float(np.abs(np.asarray(v.array) - reference).max()) +assert drift < 1.0e-6, f"rank {uw.mpi.rank}: guarded answer drifted by {drift:.3e}" + +# A budget that cannot be met. Every rank must reach the same verdict — if the reduction +# in deadline_passed() were dropped, the ranks whose clock had not yet expired would +# keep iterating while the others reported failure, and PETSc would deadlock here. +stokes.guard(wall_per_step=1.0e-6) +stokes.solve(zero_init_guess=True) +cut = stokes.solve_report +stokes.unguard() + +assert cut.deadline_expired, f"rank {uw.mpi.rank}: the deadline never fired" +assert not cut.converged, f"rank {uw.mpi.rank}: a cut solve reported success" + +verdicts = uw.mpi.comm.allgather((cut.deadline_expired, cut.converged, cut.reason)) +assert len(set(verdicts)) == 1, f"ranks disagree about the guarded solve: {verdicts}" + +# And the guard must come off cleanly on every rank. +stokes.solve(zero_init_guess=True) +assert stokes.solve_report.converged, f"rank {uw.mpi.rank}: unguard did not restore" + + +# --- the hazard, provoked deliberately ------------------------------------------------ +# The checks above cannot show the reduction is doing anything: with a 1e-6 s budget +# every rank's clock expires at once, so a per-rank decision would agree by luck. Skew +# the clocks instead — rank 0's runs fast, the others' barely move — so the ranks WOULD +# reach opposite verdicts if each decided alone. This relies on every rank calling the +# convergence tests the same number of times, which holds because Krylov iterations are +# globally synchronised; that is what makes the reduction well-posed rather than a +# guess. Without the reduction this section deadlocks. +from underworld3.systems import solver_health + + +class _RankSkewedClock: + def __init__(self, rank): + self.now = 0.0 + self.tick = 10.0 if rank == 0 else 1.0e-9 + + + def monotonic(self): + self.now += self.tick + return self.now + + +solver_health.time = _RankSkewedClock(uw.mpi.rank) +stokes.guard(wall_per_step=50.0) # only rank 0's clock can reach this +stokes.solve(zero_init_guess=True) +skewed = stokes.solve_report +stokes.unguard() + +assert skewed.deadline_expired, ( + f"rank {uw.mpi.rank}: one rank's clock expired but this rank did not follow — " + "the expiry decision was not reduced across the communicator" +) +skewed_verdicts = uw.mpi.comm.allgather((skewed.deadline_expired, skewed.reason)) +assert len(set(skewed_verdicts)) == 1, ( + f"ranks disagree under skewed clocks: {skewed_verdicts}" +) + +uw.pprint(f"ptest_0203: wall-clock guard consistent across {uw.mpi.size} ranks") diff --git a/tests/test_0203_solver_wallclock_guard.py b/tests/test_0203_solver_wallclock_guard.py new file mode 100644 index 000000000..83139dca8 --- /dev/null +++ b/tests/test_0203_solver_wallclock_guard.py @@ -0,0 +1,203 @@ +"""Sub-solve work gauge and the wall-clock guard (``systems/solver_health.py``). + +These tests are written against the two ways the feature could be fake: + +* the gauge could be the outer Krylov count under a new name, which would be useless — + so it is asserted to see work the outer count cannot; +* the guard could fail to stop a grind, or could stop a healthy solve. Both are checked, + and the healthy-solve check compares the *answer* against an unguarded solve, because + the guard replaces PETSc's convergence tests and a sloppy replacement would quietly + move where the solve stops. + +The test that has to prove the guard bites *inside* the fieldsplit blocks — the claim +the whole design rests on — drives a fake clock rather than a wall-clock budget. A +budget in seconds cannot express "expire during the block solves" on an unknown machine: +too large and the solve finishes, too small and it expires at the outer iteration +before the blocks are ever reached. +""" + +import numpy as np +import pytest +import sympy + +import underworld3 as uw +from underworld3.systems import solver_health + + +class _TickingClock: + """A clock that advances one tick per reading. + + Makes the deadline expire after a known number of convergence-test checks instead + of after an interval of real time, so the test asserts the same thing on a fast + machine and a slow one. + """ + + def __init__(self, tick=1.0): + self.tick = tick + self.now = 0.0 + + def monotonic(self): + self.now += self.tick + return self.now + + +@pytest.fixture(scope="module") +def stokes_box(): + """A small, well-conditioned Stokes solve. + + Deliberately easy: these tests are about the instrumentation, and a genuinely hard + solve would make them slow and their timing fragile. + """ + mesh = uw.meshing.UnstructuredSimplexBox( + minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.1, qdegree=3 + ) + v = uw.discretisation.MeshVariable("Ug", mesh, mesh.dim, degree=2) + p = uw.discretisation.MeshVariable("Pg", mesh, 1, degree=1, continuous=True) + + solver = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) + solver.constitutive_model = uw.constitutive_models.ViscousFlowModel + solver.constitutive_model.Parameters.shear_viscosity_0 = 1.0 + x, y = mesh.X + solver.bodyforce = sympy.Matrix( + [0.0, -sympy.cos(sympy.pi * x) * sympy.sin(sympy.pi * y)] + ) + for wall in ("Top", "Bottom", "Left", "Right"): + solver.add_dirichlet_bc((0.0, 0.0), wall) + # Guard exits are reports, not errors — a driver reads them and steps back. + solver.petsc_options["snes_error_if_not_converged"] = "false" + + solver.solve() # first solve: pays the JIT, and attaches the instrumentation + return solver, v + + +@pytest.mark.level_1 +@pytest.mark.tier_a +def test_sub_solve_gauge_sees_work_the_outer_count_cannot(stokes_box): + """``ksp_its`` counts outer Krylov iterations, which Eisenstat--Walker collapses to + about one per Newton step. The multigrid cycles are in the velocity block.""" + solver, _ = stokes_box + solver.solve(zero_init_guess=True) + report = solver.solve_report + + assert report.converged + assert "velocity" in report.sub, "no velocity block work recorded" + assert "pressure" in report.sub, "no pressure block work recorded" + # The point of the gauge: the outer count is not the cost. Two orders of magnitude + # apart in practice; one order of magnitude is a safe floor for the assertion. + assert report.sub["velocity"].its > 10 * report.ksp_its + assert report.sub["velocity"].applications >= 1 + # Attached before this solve began, so the count is exact rather than a lower bound. + assert report.sub["velocity"].complete + + +@pytest.mark.level_1 +@pytest.mark.tier_a +def test_generous_budget_leaves_a_healthy_solve_alone(stokes_box): + """The guard replaces PETSc's convergence tests. If the replacement were wrong the + solve would stop somewhere else — so compare the answer, not just the reason.""" + solver, v = stokes_box + solver.unguard() + solver.solve(zero_init_guess=True) + unguarded = np.asarray(v.array).copy() + assert solver.solve_report.converged + + solver.guard(wall_per_step=600.0) + solver.solve(zero_init_guess=True) + guarded_report = solver.solve_report + solver.unguard() + + assert guarded_report.converged + assert not guarded_report.deadline_expired + drift = np.abs(np.asarray(v.array) - unguarded).max() + assert drift < 1.0e-6, f"guarded answer differs from unguarded by {drift:.3e}" + + +@pytest.mark.level_1 +@pytest.mark.tier_a +def test_exhausted_budget_stops_the_solve_and_reports_it(stokes_box): + """A budget too small to finish must stop the solve, and must not claim success. + + Reporting matters as much as stopping: a solve cut short that still said + ``converged`` would silently corrupt a continuation driver, which reads exactly + this to decide whether a parameter station is reachable. + """ + solver, _ = stokes_box + solver.guard(wall_per_step=1.0e-6) + solver.solve(zero_init_guess=True) + report = solver.solve_report + solver.unguard() + + assert report.deadline_expired, "the wall-clock deadline never fired" + assert not report.converged, "a solve cut short by the deadline reported success" + assert report.reason_str == "DIVERGED_LINEAR_SOLVE" + + +@pytest.mark.level_1 +@pytest.mark.tier_a +def test_deadline_bites_inside_the_fieldsplit_blocks(stokes_box, monkeypatch): + """The claim the design rests on: the deadline is honoured *below* the outer Krylov + iteration, where an iteration cap has nothing to count. + + The clock is set to expire after roughly fifty convergence-test checks. The outer + test contributes only a couple of those, so expiry necessarily happens inside the + block solves — and the assertions confirm it: the outer iteration never completed + (``ksp_its == 0``, so no cap on it could have fired) while the velocity block had + already run many multigrid cycles. + """ + solver, _ = stokes_box + clock = _TickingClock(tick=1.0) + monkeypatch.setattr(solver_health, "time", clock) + + solver.guard(wall_per_step=50.0) # fifty ticks == fifty checks + solver.solve(zero_init_guess=True) + report = solver.solve_report + solver.unguard() + + assert report.deadline_expired + assert not report.converged + assert report.ksp_its == 0, "the outer Krylov iteration completed; nothing to prove" + assert report.sub["velocity"].its >= 10, ( + "the velocity block did no real work before the deadline fired, so this does " + "not show the deadline reaching inside the blocks" + ) + + +@pytest.mark.level_1 +@pytest.mark.tier_a +def test_unguard_restores_normal_convergence(stokes_box): + """The deadline must be removable — otherwise a driver that guards one probe would + poison every later solve on the same solver.""" + solver, _ = stokes_box + solver.guard(wall_per_step=1.0e-6) + solver.solve(zero_init_guess=True) + assert solver.solve_report.deadline_expired + + solver.unguard() + solver.solve(zero_init_guess=True) + report = solver.solve_report + assert report.converged + assert not report.deadline_expired + + +@pytest.mark.level_1 +@pytest.mark.tier_a +def test_guard_rejects_a_meaningless_budget(stokes_box): + solver, _ = stokes_box + for bad in (0.0, -1.0): + with pytest.raises(ValueError, match="positive"): + solver.guard(wall_per_step=bad) + + +@pytest.mark.level_1 +@pytest.mark.tier_a +def test_guard_refuses_the_rotated_free_slip_path(stokes_box): + """Rotated free-slip runs its own Krylov loop outside ``self.snes``, so the deadline + cannot reach it. Refusing is the point: a guard that attaches and never fires looks + like protection and is not.""" + solver, _ = stokes_box + solver.add_rotated_freeslip_bc(0, "Top") + try: + with pytest.raises(NotImplementedError, match="rotated free-slip"): + solver.guard(wall_per_step=10.0) + finally: + solver._rotated_freeslip_bcs.clear() From 384fafd6fc8baef9cc732a58e82a56a1631efd5f Mon Sep 17 00:00:00 2001 From: lmoresi Date: Mon, 27 Jul 2026 18:17:57 +1000 Subject: [PATCH 22/30] 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()) From 2206c003865e648030784f3954fdd1a9abf9a109 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Mon, 27 Jul 2026 18:28:13 +1000 Subject: [PATCH 23/30] fix(solver): close the adversarial-review findings on the wall-clock guard Two independent reviewers were given the change and told to break it. Four of the findings were real defects in the new code and are fixed here; two more were claims in the docs that measurement did not support. MAJOR - the deadline was disabled for the first preconditioner application of EVERY solve, not just the first. begin_solve() left the clock at infinity and only outer-KSP iteration 0 armed it -- but PETSc's default for GMRES is LEFT preconditioning, and KSPInitialResidual applies the preconditioner BEFORE iteration 0. Verified: for pc_side LEFT the sub-block monitors fire before the outer monitor. So one full velocity solve plus one pressure solve ran outside the budget on every solve. The clock now starts in begin_solve(). MAJOR - the premise that forced a hand-rolled convergence test was false. petsc4py DOES expose composition: KSP.addConvergenceTest(prepend=True) runs a test in FRONT of the native one, and returning ITERATING hands the decision back to KSPConvergedDefault with its options-configured context intact. Verified: a prepended always-ITERATING test gives identical iteration count and reason to an uninstrumented solve. _default_convergence is deleted, and with it four silently dropped KSPConvergedDefault behaviours (-ksp_converged_maxits, the non-zero-initial-guess residual convention, the norm-type early return, -ksp_min_it) and a bug reporting an infinite residual as CONVERGED_RTOL. It also makes unguard() exact: the test stays installed and goes inert, which IS the uninstrumented behaviour, rather than being swapped for a fresh default context. MAJOR - the instrumentation held petsc4py references to a destroyed SNES's KSP, PC and multigrid hierarchy until the next solve, so a rebuilding solver carried two hierarchies at once. That is the leak BUGFIX(#157) was written to close. It now releases them before the destroy. MAJOR - guard()'s rotated-free-slip refusal was order-dependent: arming first and adding the BC afterwards armed cleanly and then sat inert on a path that never reaches the instrumentation. Re-checked at solve time. MINOR - preonly and richardson outer KSPs are now skipped: both change what they DO when a monitor is attached (gated on ksp->numbermonitors in PETSc), and neither iterates, so instrumenting them was all cost and no information. MEASURED, NOT FIXED - a reviewer argued a retry or a continuation stage would be handed a fresh budget after an expiry. PETSc prevents this on its own: once the sub-preconditioner is marked failed the next snes.solve bails before iterating, so removing the expiry latch changed the cost by 34 ticks against 32. The latch is kept because that protection is implicit, but it is belt-and-braces and no test claims to prove otherwise. Tests reworked against the second reviewer's demonstration that several of them passed with the feature broken. Now covered: cost parity (not just answer parity) between a guarded and an unguarded solve; the per-Newton-step meaning of wall_per_step, on a nonlinear model, since one linear solve cannot distinguish per-step from per-solve; the first-solve lower-bound contract; arming before the first solve; both call orders of the rotated refusal. The suite is tier_b, not tier_a: Charter 8 reserves tier A for tests that have been through use in anger. The parallel test is registered in mpi_runner.sh -- it was unreachable by any runner before, so the hazard the design calls severe had no automated coverage. Underworld development team with AI support from Claude Code --- .../design/solver-wall-clock-guard.md | 81 ++++-- .../cython/petsc_generic_snes_solvers.pyx | 25 +- src/underworld3/systems/__init__.py | 4 +- src/underworld3/systems/solve_report.py | 5 +- src/underworld3/systems/solver_health.py | 137 ++++++--- tests/parallel/mpi_runner.sh | 9 + tests/test_0203_solver_wallclock_guard.py | 273 ++++++++++++------ 7 files changed, 375 insertions(+), 159 deletions(-) diff --git a/docs/developer/design/solver-wall-clock-guard.md b/docs/developer/design/solver-wall-clock-guard.md index 4aacc731f..1c8dd35ac 100644 --- a/docs/developer/design/solver-wall-clock-guard.md +++ b/docs/developer/design/solver-wall-clock-guard.md @@ -36,11 +36,17 @@ So the only code that runs during a grind is PETSc's own. The guard has to live Three pieces, each chosen for a reason that was measured rather than assumed. -**Where the clock starts.** The outer KSP runs exactly once per Newton step, so its -iteration 0 is the natural place to restart the budget. No SNES hook is needed — the -original design proposed resetting the deadline from a custom SNES convergence test, but -that would mean reimplementing `SNESConvergedDefault` (petsc4py exposes no way to chain to -it) for no gain. +**Where the clock starts.** At the beginning of each solve, and again at each outer KSP +iteration 0 — the outer KSP runs exactly once per Newton step, so that is the per-step +restart. No SNES hook is needed. + +Starting it at the beginning of the solve rather than only at outer iteration 0 is not +cosmetic. Under left preconditioning — PETSc's default for GMRES, which is the Stokes +outer solver — `KSPInitialResidual` applies the preconditioner *before* iteration 0. An +earlier version of this guard armed only at iteration 0 and therefore left one full +velocity solve plus one pressure solve outside the budget on **every** solve, not just +the first. Measured: for `pc_side` LEFT the sub-block monitors fire before the outer +monitor. **Where the deadline is checked.** In convergence tests on the outer KSP *and on every fieldsplit sub-KSP*. The sub-KSPs are where the granularity is: in a representative solve @@ -56,10 +62,20 @@ solves and reported CONVERGED. Returning `DIVERGED_BREAKDOWN` marks the sub-prec failed, which surfaces as `DIVERGED_PC_FAILED` at the outer KSP and `DIVERGED_LINEAR_SOLVE` at the SNES. -Because a custom convergence test *replaces* the default rather than composing with it, -the guard also carries the ordinary rtol / atol / divtol semantics itself. Tolerances are -re-read every iteration, because Eisenstat–Walker rewrites the outer rtol before each -Newton step. +**How it coexists with PETSc's own test.** `KSP.addConvergenceTest(..., prepend=True)` +runs the guard *in front of* whatever native test the KSP is configured with; returning +`ITERATING` hands the decision straight back to `KSPConvergedDefault`, with its +options-configured context intact (`-ksp_converged_maxits`, the non-zero-initial-guess +residual convention, the norm-type early return, `-ksp_min_it`). Verified: a prepended +test that always returns `ITERATING` gives identical iteration count and reason to an +uninstrumented solve. + +An earlier version of this guard used `setConvergenceTest`, which *replaces* the default, +and so had to reimplement rtol/atol/divtol — silently dropping four `KSPConvergedDefault` +behaviours and mis-reporting an infinite residual as converged. Prepending removes that +whole class of divergence. It also makes `unguard()` exact rather than approximate: PETSc +offers no way to remove an added test, so the test stays installed and simply goes inert +when there is no budget, which is precisely the uninstrumented behaviour. ### Parallel @@ -75,8 +91,17 @@ already pays for at least one norm reduction — so it does not change the commu character of the solve. `tests/parallel/ptest_0203_wallclock_guard_parallel.py` provokes the hazard deliberately -with deliberately skewed per-rank clocks. With the reduction it passes at np=2 and np=4; -with the reduction removed it deadlocks (confirmed at a 90 s limit). +with skewed per-rank clocks, and is registered in `tests/parallel/mpi_runner.sh`. With the +reduction it passes at np=2 and np=4; with the reduction removed it deadlocks (confirmed +at a 90 s limit). Note the failure mode: a dropped reduction *hangs* rather than fails, so +it must be run under a timeout. + +**After a deadline exit, PETSc will not restart the grind by itself.** Once the +sub-preconditioner is marked failed, a subsequent `snes.solve` in the same call — a +warm-start retry, or the second stage of a Picard-to-Newton continuation — bails before it +reaches an iteration. Measured with the expiry latch removed: 34 clock ticks against 32 +with it. The latch is kept because relying on PC-failure propagation is implicit rather +than guaranteed, but it is belt-and-braces, and no test claims to prove otherwise. The extra reductions are paid only on the guarded path — an unarmed solver installs no convergence tests at all, so the default solve is untouched. @@ -87,19 +112,22 @@ convergence tests at all, so the default solve is untouched. (`utilities/rotated_bc.py`), which the deadline cannot reach. `guard()` raises there rather than pretending, matching `estimate_difficulty()`. -**A `preonly` outer KSP.** With no outer iterations there is no convergence test to start -the clock, so the guard is inert. A direct solve is not the thing this guard exists to -bound, but it is worth knowing that it is silent rather than protective. - -**The first preconditioner application.** On a solver's **first** solve the fieldsplit -blocks do not exist when the solve begins: -they are created by `PCSetUp`, which runs inside the first `KSPSolve`. The guard attaches -to them at the earliest reachable moment — the outer KSP's iteration 0 — which is *after* -the preconditioner has been applied once. So one preconditioner application on the first -solve is outside the deadline. Every solve after that is fully covered, because the blocks -persist. - -A driver that cares should do one cheap solve before arming the guard, which it usually +**A `preonly` or `richardson` outer KSP.** These are skipped entirely, because both change +what they *do* when a monitor is attached — `KSPSolve_PREONLY` computes a norm, a matvec +and a second norm purely to have something to report, and `KSPRICHARDSON` gives up the +fused `PCApplyRichardson` path. Neither iterates, so there is nothing for the gauge to +count or the deadline to bound; instrumenting them would be all cost and no information. +A direct solve is not what this guard exists to bound, but it is silent there rather than +protective. + +**The first solve's sub-KSP counts.** On a solver's **first** solve the fieldsplit blocks +do not exist when the solve begins: they are created by `PCSetUp`, which runs inside the +first `KSPSolve`. The guard attaches to them at the earliest reachable moment, which is +after the preconditioner has been applied once — so that solve's block counts are a lower +bound and `SubSolveReport.complete` is `False` to say so. The *deadline* is unaffected, +because the clock is already running from the start of the solve. + +A driver that wants exact work should do one cheap solve before arming, which it usually does anyway to pay for the JIT compile. ## The work gauge @@ -122,6 +150,11 @@ Cost of leaving the gauge always on: 624 monitor callbacks in a 280 ms solve, no change in wall time (medians 282 ms instrumented against 284 ms not, run alternately; run-to-run noise is larger than the difference). +The instrumentation holds petsc4py references to the KSPs it watches, and those +reference-count the PC and the whole multigrid hierarchy. It therefore releases them when +the solver tears its SNES down, or a rebuilding solver would carry two hierarchies at once +— the leak `BUGFIX(#157)` exists to prevent. + ## Usage ```python diff --git a/src/underworld3/cython/petsc_generic_snes_solvers.pyx b/src/underworld3/cython/petsc_generic_snes_solvers.pyx index 30b57e140..95c676bf1 100644 --- a/src/underworld3/cython/petsc_generic_snes_solvers.pyx +++ b/src/underworld3/cython/petsc_generic_snes_solvers.pyx @@ -928,6 +928,10 @@ class SolverBaseClass(uw_object): self._resume_abs_target = None if self.snes is not None: + # Drop the instrumentation's references to this SNES's KSP hierarchy first, + # or destroy() only decrements and the old hierarchy stays resident. + if getattr(self, "_instrumentation", None) is not None: + self._instrumentation.release() self.snes.destroy() self.snes = None @@ -1103,7 +1107,10 @@ class SolverBaseClass(uw_object): wall_per_step : float Seconds of wall clock allowed per Newton step. The clock restarts at the beginning of every step, so a solve taking ``n`` steps may run for up to - roughly ``n * wall_per_step`` seconds. + roughly ``n * wall_per_step`` seconds. Once the deadline fires it stays + fired for the rest of that ``solve()`` call, so a Picard-to-Newton + continuation or a warm-start retry cannot quietly buy itself a fresh + budget. Notes ----- @@ -1682,6 +1689,12 @@ class SolverBaseClass(uw_object): if getattr(self, "snes", None) is not None: if verbose and uw.mpi.rank == 0: print(f"Destroy solver SNES", flush=True) + # Instrumentation holds petsc4py references to this SNES's KSP and its + # fieldsplit blocks, which reference-count the PC and the whole multigrid + # hierarchy. Drop them BEFORE the destroy or the old hierarchy survives + # alongside the new one -- the leak BUGFIX(#157) above exists to prevent. + if getattr(self, "_instrumentation", None) is not None: + self._instrumentation.release() self.snes.destroy() self.snes = None @@ -8438,6 +8451,16 @@ class SNES_Stokes_SaddlePt(SolverBaseClass): # F(u), J(u) and the v_n=0 constraint every iteration. It honours # zero_init_guess (warm start), the Picard warmup count, and the # consistent_jacobian tangent (Picard / Newton / continuation). + # guard() refuses rotated free-slip, but the BC can be added AFTER arming. + # Re-check here: this path never reaches the instrumentation, so an armed + # guard would be silently inert -- the exact state guard() exists to refuse. + if (getattr(self, "_instrumentation", None) is not None + and self._instrumentation.armed): + raise NotImplementedError( + "a wall-clock guard is armed but this solve takes the rotated " + "free-slip path, which runs its own Krylov loop outside self.snes " + "and cannot be bounded by it. Call unguard() first." + ) from underworld3.utilities.rotated_bc import ( solve_rotated_freeslip, solve_rotated_freeslip_nonlinear) if not self._residual_is_nonlinear(): diff --git a/src/underworld3/systems/__init__.py b/src/underworld3/systems/__init__.py index 9db6407ae..3a53a34cf 100644 --- a/src/underworld3/systems/__init__.py +++ b/src/underworld3/systems/__init__.py @@ -79,9 +79,7 @@ from .free_surface import FreeSurface -# Records left by every solve — read via solver.solve_report. SubSolveReport is what -# solve_report.sub holds, one entry per fieldsplit block (see solver_health). -from .solve_report import SolveReport +# What solve_report.sub holds — one entry per fieldsplit block (see solver_health). from .solver_health import SubSolveReport # are the Lagrangian implementations actually distinct in reality ? diff --git a/src/underworld3/systems/solve_report.py b/src/underworld3/systems/solve_report.py index ecd261617..10d1ac3e5 100644 --- a/src/underworld3/systems/solve_report.py +++ b/src/underworld3/systems/solve_report.py @@ -12,7 +12,10 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Mapping, Optional, Tuple +from typing import TYPE_CHECKING, Mapping, Optional, Tuple + +if TYPE_CHECKING: # keeps this module importable on its own + from underworld3.systems.solver_health import SubSolveReport # PETSc SNESConvergedReason codes -> short names. Mirrors the compact map in # SolverBaseClass._convergence_reasons; duplicated here so this module imports without the diff --git a/src/underworld3/systems/solver_health.py b/src/underworld3/systems/solver_health.py index c86660ecd..9c946615f 100644 --- a/src/underworld3/systems/solver_health.py +++ b/src/underworld3/systems/solver_health.py @@ -34,6 +34,16 @@ ``DIVERGED_BREAKDOWN`` marks the sub-preconditioner failed, which surfaces at the outer KSP as ``DIVERGED_PC_FAILED`` and at the SNES as ``DIVERGED_LINEAR_SOLVE``. +**The guard does not replace PETSc's convergence test, it runs in front of it.** +``KSP.addConvergenceTest(..., prepend=True)`` composes with whatever native test the KSP +is configured with, so returning ``ITERATING`` hands the decision straight back to +``KSPConvergedDefault`` -- with its options-configured context intact +(``-ksp_converged_maxits``, the non-zero-initial-guess residual convention, the +norm-type early return, and the rest). Verified: a prepended test that always returns +``ITERATING`` gives bit-identical iteration count and reason to an uninstrumented solve. +A disarmed guard is therefore genuinely inert rather than approximately inert, which a +hand-rolled reimplementation of the default test could never be. + See ``docs/developer/design/solver-wall-clock-guard.md``. """ @@ -51,6 +61,12 @@ # Marking the sub-preconditioner failed is the whole point -- see the module docstring. _DEADLINE_REASON = PETSc.KSP.ConvergedReason.DIVERGED_BREAKDOWN +# KSP types whose behaviour changes when a monitor is attached (both are gated on +# ksp->numbermonitors in PETSc): preonly.c computes norms it would otherwise skip, and +# rich.c abandons the fused PCApplyRichardson path. Neither iterates, so there is +# nothing here for the gauge to count or the guard to bound. +_MONITOR_SENSITIVE_KSPS = ("preonly", "richardson") + @dataclass(frozen=True) class SubSolveReport: @@ -59,8 +75,10 @@ class SubSolveReport: Attributes ---------- name - Block name taken from the sub-KSP's options prefix -- ``"velocity"`` or - ``"pressure"`` for the Stokes saddle-point solver. + Block name taken from the sub-KSP's options prefix -- ``"velocity"`` and + ``"pressure"`` for the Stokes saddle-point solver. A solver that builds its + splits by DM field index instead (the block-constrained Stokes path) names them + ``"0"``, ``"1"``, ...; read the keys rather than assuming them. its Iterations summed over every application of this sub-solve. For the velocity block under ``pc_type=mg`` this is the multigrid cycle count, i.e. the honest @@ -98,35 +116,12 @@ def _block_name(ksp): return tail.strip("_") or prefix.strip("_") or "sub" -def _default_convergence(ksp, its, rnorm, state): - """Reproduce ``KSPConvergedDefault``'s verdict for this iteration. - - A custom convergence test *replaces* the default -- petsc4py exposes no way to chain - to it -- so a guard has to carry the ordinary rtol / atol / divtol semantics itself, - or a healthy KSP would never converge. Tolerances are read fresh each iteration - because Eisenstat--Walker rewrites the outer rtol before every Newton step. - """ - R = PETSc.KSP.ConvergedReason - if its == 0: - state["r0"] = rnorm if rnorm > 0.0 else 1.0 - rtol, atol, divtol, _max_it = ksp.getTolerances() - if rnorm != rnorm: # NaN - return R.DIVERGED_NANORINF - if rnorm <= atol: - return R.CONVERGED_ATOL - if rnorm <= rtol * state["r0"]: - return R.CONVERGED_RTOL - if rnorm >= divtol * state["r0"]: - return R.DIVERGED_DTOL - return R.ITERATING - - class _InstrumentedKSP: """One KSP the instrumentation is attached to, and everything it needs. - Holds the iteration counters (fed by a monitor, which cannot affect the solve) and, - when the guard is armed, the deadline-checking convergence test that replaces the - default one. + Holds the iteration counters (fed by a monitor, which cannot affect the solve) and + the deadline-checking convergence test, which runs *in front of* the KSP's native + test rather than replacing it. """ def __init__(self, ksp, name, instrumentation, is_outer, mid_solve): @@ -137,7 +132,7 @@ def __init__(self, ksp, name, instrumentation, is_outer, mid_solve): self.complete = not mid_solve self._instrumentation = instrumentation self.is_outer = is_outer - self._state = {"r0": 1.0} + self._test_installed = False ksp.setMonitor(self._monitor) def reset(self): @@ -167,20 +162,31 @@ def _monitor(self, ksp, its, rnorm): def _test(self, ksp, its, rnorm): if self.is_outer and its == 0: - # PETSc does not fix the order of the monitor and the convergence test, so - # whichever runs first at iteration 0 opens the step. Both are idempotent. + # PETSc does not fix the order of the monitor and the convergence test at + # iteration 0, so whichever runs first opens the step. The second call + # re-reads the clock and pushes the deadline out by the gap between them -- + # microseconds against a multigrid cycle, and never in the unsafe direction. self._instrumentation.open_step() self._instrumentation.attach_sub_ksps(ksp, mid_solve=True) if self._instrumentation.deadline_passed(): self._instrumentation.deadline_expired = True return _DEADLINE_REASON - return _default_convergence(ksp, its, rnorm, self._state) + # Hand the decision back to the KSP's own test, whatever it is configured to be. + return PETSc.KSP.ConvergedReason.ITERATING def install_test(self): - self.ksp.setConvergenceTest(self._test) + """Prepend the deadline test, once and for all. - def remove_test(self): - self.ksp.setConvergenceTest(None) # restores KSPConvergedDefault + PETSc allows exactly one ``addConvergenceTest`` per KSP and offers no way to + take it off again, so the test is installed on first arming and left in place; + ``disarm()`` makes it inert instead of removing it. That is a true restore + rather than an approximate one: with no budget set the test returns ``ITERATING`` + immediately and the KSP's native test decides exactly as it would have. + """ + if self._test_installed: + return + self.ksp.addConvergenceTest(self._test, prepend=True) + self._test_installed = True class SolverInstrumentation: @@ -194,7 +200,7 @@ class SolverInstrumentation: def __init__(self): self.wall_per_step = None # None => guard disarmed - self.deadline_expired = False + self.deadline_expired = False # latched for the whole of one solve self._ksps: Dict[int, _InstrumentedKSP] = {} # keyed by PETSc handle self._outer_handle = None self._expires = math.inf @@ -222,8 +228,21 @@ def arm(self, wall_per_step): def disarm(self): self.wall_per_step = None self._expires = math.inf - for entry in self._ksps.values(): - entry.remove_test() + # The prepended tests stay installed -- PETSc has no way to remove them -- but + # with no budget they return ITERATING and the native test decides, which is + # exactly the uninstrumented behaviour. + + def release(self): + """Drop every PETSc reference, keeping the arming state. + + petsc4py increments the reference count of a KSP handed out by ``getKSP`` or + ``getFieldSplitSubKSP``, so holding these entries keeps a destroyed solver's KSP, + PC and whole multigrid hierarchy alive. A solver that rebuilds in a loop would + then carry two hierarchies at once -- the leak BUGFIX(#157) was written to close. + Called from the solver when it tears its SNES down. + """ + self._ksps.clear() + self._outer_handle = None # ------------------------------------------------------------ attachment @@ -235,9 +254,15 @@ def begin_solve(self, snes): a recreated SNES is picked up automatically. """ self.deadline_expired = False - self._expires = math.inf outer = snes.getKSP() + if outer.getType() in _MONITOR_SENSITIVE_KSPS: + # These two change what they DO when a monitor is attached: KSPSolve_PREONLY + # adds a norm, a matvec and a second norm purely to have something to report, + # and KSPRICHARDSON gives up the fused PCApplyRichardson path. Neither has + # outer iterations to bound or to count, so instrumenting them would be all + # cost and no information. + return if self._outer_handle not in (None, outer.handle): # A rebuilt solver (remesh, adapt, new discretisation) carries a new SNES, # and everything attached to the old one went with it. Start over rather @@ -248,6 +273,13 @@ def begin_solve(self, snes): for entry in self._ksps.values(): entry.reset() self._attach(outer, name="outer", is_outer=True, mid_solve=False) + # Start the clock HERE, not at the outer KSP's first iteration. Under left + # preconditioning -- PETSc's default for GMRES, which is the Stokes outer solver + # -- KSPInitialResidual applies the preconditioner BEFORE iteration 0, so a + # clock that only started at iteration 0 would leave one full velocity solve + # plus one pressure solve outside the budget on every single solve. Measured: + # the sub-block monitors fire before the outer monitor for pc_side LEFT. + self.open_step() def attach_sub_ksps(self, ksp, mid_solve): """Attach to the fieldsplit blocks of ``ksp``'s preconditioner. @@ -276,11 +308,24 @@ def _attach(self, ksp, name, is_outer, mid_solve): # ------------------------------------------------------------ the deadline def open_step(self): - """Start the clock for one Newton step.""" - if self.armed: - self._expires = time.monotonic() + self.wall_per_step - else: - self._expires = math.inf + """Start the clock for one Newton step. + + Once the deadline has fired it stays fired for the rest of the solve, so a + solve that runs the SNES more than once -- the Picard-to-Newton continuation, or + a warm-start retry after divergence -- cannot hand each attempt a fresh full + budget. + + MEASURED: PETSc already prevents this on its own. After a deadline exit the + sub-preconditioner is marked failed, and the next ``snes.solve`` bails before it + reaches an iteration, so neither a retry nor a continuation stage re-armed even + with the latch removed (tick counts 32 against 34). The latch is kept because + that protection is an implicit consequence of PC-failure propagation rather than + a guarantee, and because it makes the intended semantics explicit -- but it is + belt-and-braces, and there is deliberately no test claiming to prove otherwise. + """ + if not self.armed or self.deadline_expired: + return + self._expires = time.monotonic() + self.wall_per_step def deadline_passed(self): """Has the budget run out? The answer must be identical on every rank. @@ -292,7 +337,9 @@ def deadline_passed(self): against a multigrid cycle -- and every iteration already pays for at least one norm reduction, so it does not change the communication character of the solve. """ - if self._expires == math.inf: + if self.deadline_expired: + return True # latched, and latched identically on every rank + if not self.armed: return False local = 1 if time.monotonic() > self._expires else 0 if self._comm is None or self._comm.size == 1: diff --git a/tests/parallel/mpi_runner.sh b/tests/parallel/mpi_runner.sh index c87eaaed1..9ac17118c 100755 --- a/tests/parallel/mpi_runner.sh +++ b/tests/parallel/mpi_runner.sh @@ -43,3 +43,12 @@ echo "ptest 0004 checkpoint FMG hierarchy -np 2" mpirun -np 2 $PYTHON ./ptest_0004_checkpoint_fmg_hierarchy.py echo "ptest 0004 checkpoint FMG hierarchy -np 3 (uneven partition)" mpirun -np 3 $PYTHON ./ptest_0004_checkpoint_fmg_hierarchy.py + +# The wall-clock guard's expiry decision must be identical on every rank: a PETSc +# convergence test that disagrees across ranks deadlocks the next collective. This +# script skews the per-rank clocks deliberately, so it HANGS rather than fails if the +# reduction is ever dropped -- run it under a timeout. +echo "ptest 0203 wall-clock guard -np 2" +mpirun --timeout 300 -np 2 $PYTHON ./ptest_0203_wallclock_guard_parallel.py +echo "ptest 0203 wall-clock guard -np 4" +mpirun --timeout 300 -np 4 $PYTHON ./ptest_0203_wallclock_guard_parallel.py diff --git a/tests/test_0203_solver_wallclock_guard.py b/tests/test_0203_solver_wallclock_guard.py index 83139dca8..0aa7e076a 100644 --- a/tests/test_0203_solver_wallclock_guard.py +++ b/tests/test_0203_solver_wallclock_guard.py @@ -1,19 +1,24 @@ """Sub-solve work gauge and the wall-clock guard (``systems/solver_health.py``). -These tests are written against the two ways the feature could be fake: - -* the gauge could be the outer Krylov count under a new name, which would be useless — - so it is asserted to see work the outer count cannot; -* the guard could fail to stop a grind, or could stop a healthy solve. Both are checked, - and the healthy-solve check compares the *answer* against an unguarded solve, because - the guard replaces PETSc's convergence tests and a sloppy replacement would quietly - move where the solve stops. - -The test that has to prove the guard bites *inside* the fieldsplit blocks — the claim -the whole design rests on — drives a fake clock rather than a wall-clock budget. A -budget in seconds cannot express "expire during the block solves" on an unknown machine: -too large and the solve finishes, too small and it expires at the outer iteration -before the blocks are ever reached. +Written against the ways the feature could be fake: + +* the gauge could be the outer Krylov count under a new name — so it is asserted to see + work the outer count cannot, and to admit when its count is only a lower bound; +* the guard could fail to stop a grind, or stop a healthy solve. Both are checked, and + the healthy-solve check compares *iteration counts* as well as the answer: the guard + runs a test in front of PETSc's own, and a test that perturbed convergence would move + the cost long before it moved the answer; +* ``wall_per_step`` could mean per *solve* rather than per Newton step, which one linear + solve cannot distinguish — so the re-arming is exercised on a nonlinear model; +* ``unguard()`` could leave the guard half-attached. + +The tests that must prove *where* the deadline bites drive an injected clock rather than +a wall-clock budget. A budget in seconds cannot express "expire during the block solves" +on an unknown machine: too large and the solve finishes, too small and it expires at the +outer iteration before the blocks are reached. Its limitation is stated where it is used. + +Tier B, not A: these are new, and Charter §8 reserves tier A for tests that have been +through review and use in anger. """ import numpy as np @@ -23,13 +28,17 @@ import underworld3 as uw from underworld3.systems import solver_health +pytestmark = [pytest.mark.level_1, pytest.mark.tier_b] + class _TickingClock: """A clock that advances one tick per reading. - Makes the deadline expire after a known number of convergence-test checks instead - of after an interval of real time, so the test asserts the same thing on a fast - machine and a slow one. + Makes the deadline expire after a known number of convergence-test checks instead of + after an interval of real time, so the test asserts the same thing on a fast machine + and a slow one. Note what this cannot do: because PETSc's real work costs zero ticks, + expiry is guaranteed for any budget — these tests can catch a guard that never fires, + not one that fires too eagerly. The real-clock test below covers that direction. """ def __init__(self, tick=1.0): @@ -41,22 +50,16 @@ def monotonic(self): return self.now -@pytest.fixture(scope="module") -def stokes_box(): - """A small, well-conditioned Stokes solve. - - Deliberately easy: these tests are about the instrumentation, and a genuinely hard - solve would make them slow and their timing fragile. - """ +def _stokes(tag, viscosity): mesh = uw.meshing.UnstructuredSimplexBox( minCoords=(0.0, 0.0), maxCoords=(1.0, 1.0), cellSize=0.1, qdegree=3 ) - v = uw.discretisation.MeshVariable("Ug", mesh, mesh.dim, degree=2) - p = uw.discretisation.MeshVariable("Pg", mesh, 1, degree=1, continuous=True) + v = uw.discretisation.MeshVariable(f"U{tag}", mesh, mesh.dim, degree=2) + p = uw.discretisation.MeshVariable(f"P{tag}", mesh, 1, degree=1, continuous=True) solver = uw.systems.Stokes(mesh, velocityField=v, pressureField=p) solver.constitutive_model = uw.constitutive_models.ViscousFlowModel - solver.constitutive_model.Parameters.shear_viscosity_0 = 1.0 + solver.constitutive_model.Parameters.shear_viscosity_0 = viscosity(v) x, y = mesh.X solver.bodyforce = sympy.Matrix( [0.0, -sympy.cos(sympy.pi * x) * sympy.sin(sympy.pi * y)] @@ -65,17 +68,36 @@ def stokes_box(): solver.add_dirichlet_bc((0.0, 0.0), wall) # Guard exits are reports, not errors — a driver reads them and steps back. solver.petsc_options["snes_error_if_not_converged"] = "false" - - solver.solve() # first solve: pays the JIT, and attaches the instrumentation return solver, v -@pytest.mark.level_1 -@pytest.mark.tier_a -def test_sub_solve_gauge_sees_work_the_outer_count_cannot(stokes_box): +@pytest.fixture(scope="module") +def linear_box(): + """A small, well-conditioned LINEAR Stokes solve — one Newton step.""" + solver, v = _stokes("g", lambda v: 1.0) + solver.solve() # first solve: pays the JIT, and attaches the instrumentation + yield solver, v + solver.unguard() + + +@pytest.fixture +def guarded(linear_box): + """Hands back the shared solver and guarantees it is disarmed afterwards. + + Without this a test that fails part-way leaves the module-scoped solver armed at a + microsecond budget, and every later test in the file fails for the wrong reason. + """ + solver, v = linear_box + try: + yield solver, v + finally: + solver.unguard() + + +def test_sub_solve_gauge_sees_work_the_outer_count_cannot(guarded): """``ksp_its`` counts outer Krylov iterations, which Eisenstat--Walker collapses to about one per Newton step. The multigrid cycles are in the velocity block.""" - solver, _ = stokes_box + solver, _ = guarded solver.solve(zero_init_guess=True) report = solver.solve_report @@ -90,68 +112,85 @@ def test_sub_solve_gauge_sees_work_the_outer_count_cannot(stokes_box): assert report.sub["velocity"].complete -@pytest.mark.level_1 -@pytest.mark.tier_a -def test_generous_budget_leaves_a_healthy_solve_alone(stokes_box): - """The guard replaces PETSc's convergence tests. If the replacement were wrong the - solve would stop somewhere else — so compare the answer, not just the reason.""" - solver, v = stokes_box +def test_first_solve_admits_its_count_is_a_lower_bound(): + """A fresh solver cannot see the fieldsplit blocks until PETSc has set up the + preconditioner, part-way through its first solve. The count that results is short — + measured, by about half — so the report must say so rather than pass it off as + exact.""" + solver, _ = _stokes("lb", lambda v: 1.0) + solver.solve() + first = solver.solve_report.sub["velocity"] + solver.solve(zero_init_guess=True) + second = solver.solve_report.sub["velocity"] + + assert not first.complete, "the first solve claimed an exact count it cannot have" + assert second.complete + assert second.its > first.its, ( + "the first solve should undercount; if it does not, the 'lower bound' contract " + "is either wrong or no longer needed" + ) + + +def test_generous_budget_changes_neither_the_answer_nor_the_cost(guarded): + """The guard runs a test in front of PETSc's own. If that composition perturbed + convergence at all, the iteration counts would move first — long before the answer + did, and invisibly for anything confined to the sub-blocks.""" + solver, v = guarded solver.unguard() solver.solve(zero_init_guess=True) unguarded = np.asarray(v.array).copy() - assert solver.solve_report.converged + before = solver.solve_report + assert before.converged solver.guard(wall_per_step=600.0) solver.solve(zero_init_guess=True) - guarded_report = solver.solve_report - solver.unguard() + after = solver.solve_report - assert guarded_report.converged - assert not guarded_report.deadline_expired + assert after.converged + assert not after.deadline_expired drift = np.abs(np.asarray(v.array) - unguarded).max() assert drift < 1.0e-6, f"guarded answer differs from unguarded by {drift:.3e}" + # Cost parity — the sharp assertion. An armed-but-unexpired guard must hand every + # convergence decision straight back to PETSc. + assert after.nl_its == before.nl_its + assert after.ksp_its == before.ksp_its + assert after.sub["velocity"].its == before.sub["velocity"].its + assert after.sub["pressure"].its == before.sub["pressure"].its -@pytest.mark.level_1 -@pytest.mark.tier_a -def test_exhausted_budget_stops_the_solve_and_reports_it(stokes_box): +def test_exhausted_budget_stops_the_solve_and_reports_it(guarded): """A budget too small to finish must stop the solve, and must not claim success. Reporting matters as much as stopping: a solve cut short that still said - ``converged`` would silently corrupt a continuation driver, which reads exactly - this to decide whether a parameter station is reachable. + ``converged`` would silently corrupt a continuation driver, which reads exactly this + to decide whether a parameter station is reachable. """ - solver, _ = stokes_box + solver, _ = guarded solver.guard(wall_per_step=1.0e-6) solver.solve(zero_init_guess=True) report = solver.solve_report - solver.unguard() assert report.deadline_expired, "the wall-clock deadline never fired" assert not report.converged, "a solve cut short by the deadline reported success" assert report.reason_str == "DIVERGED_LINEAR_SOLVE" -@pytest.mark.level_1 -@pytest.mark.tier_a -def test_deadline_bites_inside_the_fieldsplit_blocks(stokes_box, monkeypatch): +def test_deadline_bites_inside_the_fieldsplit_blocks(guarded, monkeypatch): """The claim the design rests on: the deadline is honoured *below* the outer Krylov iteration, where an iteration cap has nothing to count. - The clock is set to expire after roughly fifty convergence-test checks. The outer - test contributes only a couple of those, so expiry necessarily happens inside the - block solves — and the assertions confirm it: the outer iteration never completed + The clock expires after roughly fifty convergence-test checks. The outer test + contributes only a couple of those, so expiry necessarily happens inside the block + solves — and the assertions confirm it: the outer iteration never completed (``ksp_its == 0``, so no cap on it could have fired) while the velocity block had already run many multigrid cycles. """ - solver, _ = stokes_box - clock = _TickingClock(tick=1.0) - monkeypatch.setattr(solver_health, "time", clock) + solver, _ = guarded + monkeypatch.setattr(solver_health, "time", _TickingClock(tick=1.0)) solver.guard(wall_per_step=50.0) # fifty ticks == fifty checks solver.solve(zero_init_guess=True) report = solver.solve_report - solver.unguard() assert report.deadline_expired assert not report.converged @@ -162,42 +201,106 @@ def test_deadline_bites_inside_the_fieldsplit_blocks(stokes_box, monkeypatch): ) -@pytest.mark.level_1 -@pytest.mark.tier_a -def test_unguard_restores_normal_convergence(stokes_box): - """The deadline must be removable — otherwise a driver that guards one probe would - poison every later solve on the same solver.""" - solver, _ = stokes_box +def test_the_budget_is_per_newton_step_not_per_solve(monkeypatch): + """``wall_per_step`` is the only parameter the feature takes, and a linear solve + cannot tell its two possible meanings apart — one Newton step is one solve. + + On a nonlinear model, run the solve once to learn how many clock ticks it costs in + total, then re-run it with a budget of 90% of that. A per-SOLVE budget expires; a + per-STEP budget does not, because no single Newton step costs that much. So the + solve converging *is* the proof that the deadline restarted, and the tick count + confirms it really did outlive one budget's worth. + """ + solver, _ = _stokes("ps", lambda v: 1.0 + 5.0 * v.sym.dot(v.sym)) + solver.solve() + assert solver.solve_report.nl_its >= 2, ( + "fixture is not nonlinear enough to distinguish per-step from per-solve" + ) + + clock = _TickingClock(tick=1.0) + monkeypatch.setattr(solver_health, "time", clock) + solver.guard(wall_per_step=1.0e9) # never expires: just count the ticks + try: + solver.solve(zero_init_guess=True) + assert not solver.solve_report.deadline_expired + total = clock.now + + clock.now = 0.0 + budget = 0.9 * total + solver.guard(wall_per_step=budget) + solver.solve(zero_init_guess=True) + finally: + solver.unguard() + + report = solver.solve_report + assert not report.deadline_expired, ( + f"a budget of {budget:.0f} ticks expired, so it was being applied to the whole " + f"solve rather than restarted for each of its {report.nl_its} Newton steps" + ) + assert report.converged + assert clock.now > budget, ( + f"the solve cost only {clock.now:.0f} ticks against a {budget:.0f}-tick budget, " + "so it never outlived one budget and proves nothing about restarting" + ) + + +def test_a_guard_armed_before_the_first_solve_still_fires(): + """The documented usage is ``guard(...)`` then ``solve()`` on a fresh solver, where + the KSP the guard must attach to does not exist yet.""" + solver, _ = _stokes("cold", lambda v: 1.0) + solver.guard(wall_per_step=1.0e-6) + try: + solver.solve() + finally: + solver.unguard() + assert solver.solve_report.deadline_expired + assert not solver.solve_report.converged + + +def test_unguard_leaves_the_solver_exactly_as_it_was(guarded): + """The deadline must come off completely — otherwise a driver that guards one probe + poisons every later solve on the same solver, in cost if not in answer.""" + solver, _ = guarded + solver.unguard() + solver.solve(zero_init_guess=True) + never_guarded = solver.solve_report + solver.guard(wall_per_step=1.0e-6) solver.solve(zero_init_guess=True) assert solver.solve_report.deadline_expired solver.unguard() solver.solve(zero_init_guess=True) - report = solver.solve_report - assert report.converged - assert not report.deadline_expired + after = solver.solve_report + assert after.converged + assert not after.deadline_expired + assert after.nl_its == never_guarded.nl_its + assert after.ksp_its == never_guarded.ksp_its + assert after.sub["velocity"].its == never_guarded.sub["velocity"].its -@pytest.mark.level_1 -@pytest.mark.tier_a -def test_guard_rejects_a_meaningless_budget(stokes_box): - solver, _ = stokes_box +def test_guard_rejects_a_meaningless_budget(guarded): + solver, _ = guarded for bad in (0.0, -1.0): with pytest.raises(ValueError, match="positive"): solver.guard(wall_per_step=bad) -@pytest.mark.level_1 -@pytest.mark.tier_a -def test_guard_refuses_the_rotated_free_slip_path(stokes_box): +def test_guard_refuses_the_rotated_free_slip_path_whichever_order(): """Rotated free-slip runs its own Krylov loop outside ``self.snes``, so the deadline - cannot reach it. Refusing is the point: a guard that attaches and never fires looks - like protection and is not.""" - solver, _ = stokes_box + cannot reach it. Refusing is the point — a guard that attaches and never fires looks + like protection and is not. + + Both orders matter: arming the guard first and adding the BC afterwards used to slip + through the arm-time check and produce exactly that silent no-op. + """ + solver, _ = _stokes("rot1", lambda v: 1.0) solver.add_rotated_freeslip_bc(0, "Top") - try: - with pytest.raises(NotImplementedError, match="rotated free-slip"): - solver.guard(wall_per_step=10.0) - finally: - solver._rotated_freeslip_bcs.clear() + with pytest.raises(NotImplementedError, match="rotated free-slip"): + solver.guard(wall_per_step=10.0) + + other, _ = _stokes("rot2", lambda v: 1.0) + other.guard(wall_per_step=10.0) + other.add_rotated_freeslip_bc(0, "Top") + with pytest.raises(NotImplementedError, match="rotated free-slip"): + other.solve() From 928e7fea377162104cc540cc586b0d468f24a6b9 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Mon, 27 Jul 2026 18:34:19 +1000 Subject: [PATCH 24/30] docs(review): adversarial review of the wall-clock guard Underworld development team with AI support from Claude Code --- ...ver-wall-clock-guard-adversarial-review.md | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 docs/reviews/2026-07/solver-wall-clock-guard-adversarial-review.md diff --git a/docs/reviews/2026-07/solver-wall-clock-guard-adversarial-review.md b/docs/reviews/2026-07/solver-wall-clock-guard-adversarial-review.md new file mode 100644 index 000000000..d3d7e9302 --- /dev/null +++ b/docs/reviews/2026-07/solver-wall-clock-guard-adversarial-review.md @@ -0,0 +1,192 @@ +# Adversarial review — wall-clock guard and sub-solve work gauge + +Branch `feature/solver-wallclock-guard` vs `development`. + +Method: two independent adversarial reviewers, each given one dimension and instructed to +break the change rather than praise it. Both were told to report only findings they had +verified against the source; both went further and verified empirically, one by driving +standalone petsc4py to establish PETSc's callback ordering, the other by neutering parts +of the implementation and observing which tests still passed. + +**Verdict: not ready as submitted.** Four major defects in the new code, two false claims +in its documentation, and a test suite that a reviewer showed would pass with several +parts of the feature broken. All are addressed on the branch; the two findings that +measurement did not sustain are recorded as such rather than quietly fixed. + +--- + +## MAJOR — the deadline was disabled for the first preconditioner application of *every* solve + +`begin_solve()` left the clock at infinity, and only outer-KSP iteration 0 armed it. But +PETSc's default for GMRES — the Stokes outer solver — is **left** preconditioning, and +`KSPInitialResidual` applies the preconditioner *before* iteration 0. The reviewer +established the ordering directly: + +``` +outer=gmres pc_side=LEFT : sub0_mon(0), sub0_mon(1), sub1_mon(0), sub1_mon(1), outer_mon(0), ... +outer=fgmres pc_side=RIGHT: outer_mon(0), sub0_mon(0), ... +``` + +So one complete velocity-block solve plus one pressure-block solve ran entirely outside +the budget — on every solve, not just the first. That is precisely the grind the guard +exists to bound. Both the docstring and the design note described this as a first-solve +limitation caused by the blocks not yet existing; the blocks exist from solve 2 onward, +and the clock was simply not running. + +**Fixed**: the clock starts in `begin_solve()`, and outer iteration 0 restarts it per +Newton step. + +## MAJOR — the premise that forced a hand-rolled convergence test was false + +Both the code and the design note asserted that "a custom convergence test replaces the +default — petsc4py exposes no way to chain to it". petsc4py ships +`KSP.addConvergenceTest(fn, prepend=True)`, which composes the custom test with the +*native* one, whatever it is configured to be. + +Verified here before acting on it: + +| | iterations | reason | +|---|---|---| +| uninstrumented | 30 | CONVERGED_RTOL | +| prepended test returning `ITERATING` | 30 | CONVERGED_RTOL | +| prepended test returning `DIVERGED_BREAKDOWN` at it 3 | 3 | DIVERGED_BREAKDOWN | + +Every divergence the reviewer then listed was a self-inflicted consequence of +reimplementing rather than prepending: four dropped `KSPConvergedDefault` behaviours +(`-ksp_converged_maxits`, the non-zero-initial-guess residual convention, the norm-type +early return, `-ksp_min_it`), and a NaN guard written as `rnorm != rnorm` that let an +infinite residual through to be reported as `CONVERGED_RTOL` — silent false convergence, +in exactly the inf/inf viscoplastic regime the guard targets. + +**Fixed**: `_default_convergence` is deleted. The guard returns `ITERATING` and PETSc +decides. This also makes `unguard()` exact rather than approximate — PETSc offers no way +to remove an added test, so it stays installed and goes inert, which *is* the +uninstrumented behaviour, instead of being swapped for a freshly created default context +that would have lost any options-configured flags. + +## MAJOR — the instrumentation re-opened the leak `BUGFIX(#157)` was written to close + +`SNES.getKSP()` and `PC.getFieldSplitSubKSP()` both increment the reference count. The +instrumentation held those entries until the *next* solve, so a rebuild +(`is_setup=False`, remesh, adapt) did not free the old KSP, PC, coarse operators or +multigrid hierarchy: they survived precisely while the new hierarchy was being built. + +The comment at the destroy site records that this exact retention "at Gadi scale … push[ed] +past memory limits" for `SNES_Tensor_Projection.solve()` cycling six components in 3D — +a loop that drives `is_setup=False` on every component. + +**Fixed**: the instrumentation releases its references before the SNES is destroyed. + +## MAJOR — `guard()`'s rotated-free-slip refusal was bypassed by call order + +Found independently by both reviewers, and demonstrated live: `guard(...)` then +`add_rotated_freeslip_bc(...)` armed without error, and the subsequent solve took the +rotated path — which never reaches the instrumentation — and reported +`deadline_expired=False, converged=True`. The guard was silently inert: "looks like +protection and is not", which is the exact state the `NotImplementedError` exists to +prevent. + +**Fixed**: re-checked at solve time as well as at arm time; the test now covers both +orders. + +## MINOR — `preonly` and `richardson` outer KSPs are changed by being monitored + +Both are gated on `ksp->numbermonitors` in PETSc: `KSPSolve_PREONLY` adds a norm, a +matvec and a second norm purely to have something to report, and `KSPRICHARDSON` gives up +the fused `PCApplyRichardson` path. The gauge attached a monitor to the outer KSP of every +solver unconditionally, and `model.py` ships `preonly` presets. + +**Fixed**: both types are skipped. Neither iterates, so there was nothing to count or +bound — it was all cost and no information. + +--- + +## The test suite was the sharpest hit + +The second reviewer neutered parts of the implementation and recorded which tests still +passed. Four separate holes: + +- **`unguard()` was untested.** Making the removal a no-op left 7/7 passing. The test + passed only because disarming *also* reset the deadline, so the solve converged — it + never observed which convergence test was installed. +- **No work parity assertion.** Tightening the guard's `rtol` by 1e-4 left 7/7 passing. + The healthy-solve test compared field values only, so any change confined to the + sub-blocks — which cannot move the answer, only the cost — was invisible. Against + "Solver Stability is Paramount" that is the wrong thing to be blind to. +- **`wall_per_step`'s per-Newton-step meaning was never exercised.** The fixture was + constant-viscosity, hence linear, hence `nl_its == 1` on every solve in the file — so + per-step and per-solve budgets were indistinguishable. That is the entire meaning of + the only parameter the feature takes. +- **The parallel test was dead code.** Not listed in `mpi_runner.sh`, referenced by no CI + workflow, and not pytest-collectable (`ptest_*` does not match `test_*`). The serial + suite did not cover the reduction either — replacing the `Allreduce` with a rank-local + read left 7/7 passing. So the hazard the design note calls "specific and severe" had + zero automated coverage. + +Also: the rotated-BC test corrupted the module-scoped fixture (`add_rotated_freeslip_bc` +sets `is_setup=False`), so reordering the file made the *gauge* test fail — a failure +pointing at the wrong culprit. And tests left the solver armed at a microsecond budget if +they failed part-way, cascading into everything after. + +**Fixed**: cost parity is asserted alongside answer parity; the per-step semantics is +exercised on a nonlinear model by measuring the solve's total cost and then setting a +budget only a per-*solve* interpretation would blow; the first-solve lower-bound contract +and the arm-before-first-solve path are covered; the rotated tests use their own solvers; +a fixture guarantees disarming. The parallel test is registered in `mpi_runner.sh`. + +The suite is marked **tier_b, not tier_a**. Charter §8 reserves tier A for hardened tests; +these are new and have never run in CI. + +--- + +## Findings that measurement did NOT sustain + +Recorded rather than silently fixed, because the reasoning was sound and only the +conclusion was wrong. + +**"A retry or continuation stage is handed a fresh budget after an expiry."** Mechanically +true — `deadline_expired` latches while `open_step()` could re-arm — and a real concern, +since a deadline exit is a negative reason and therefore *triggers* a warm-start retry. +But PETSc prevents it independently: once the sub-preconditioner is marked failed, the +next `snes.solve` in the same call bails before reaching an iteration. Measured with the +expiry latch removed, on both the retry and the continuation path: 34 clock ticks against +32 with it, and `converged=False` either way. The latch is kept — relying on PC-failure +propagation is implicit rather than guaranteed — but it is belt-and-braces, and no test +claims to prove otherwise. + +**"PETSc handles in `_ksps` could be reused after an object is freed, aliasing a new +KSP."** Checked and clean: the retained reference keeps the address occupied, so no freed +handle can alias. The same fact is what caused the memory-retention finding above — the +aliasing safety was being paid for in memory. + +--- + +## Verified clean + +- `Allreduce(MPI.IN_PLACE, flag, MPI.MAX)` on the outer KSP's communicator is correct, and + the fieldsplit blocks live on that same communicator. +- `DIVERGED_BREAKDOWN` returned at outer iteration 0 is safe (`KSPGMRESBuildSoln` handles + `it-1 == -1` explicitly). +- The rotated path builds its own report, so `sub` / `deadline_expired` default rather + than carrying stale values. +- `ksp.getTolerances()` returns `(rtol, atol, divtol, max_it)` — a different order from + SNES — and was read correctly. +- The fake clock intercepts every clock read the guard makes: `time.monotonic` appears + exactly twice in the diff, both via the module global. +- A non-fieldsplit solver (Poisson) reports `sub == {}`, and the guard still fires on its + outer KSP. +- The `DIVERGED_BREAKDOWN`-not-`DIVERGED_MAX_IT` claim is load-bearing *and* caught by the + suite: swapping the reason back fails the "bites inside the blocks" test. +- The parallel claim is true: the ptest passes at np=2 and hangs past a 120 s limit when + the reduction is replaced by a rank-local read. + +## Known limitations, documented rather than hidden + +- The injected-clock tests can catch a guard that never fires, not one that fires too + eagerly — with a fake clock, PETSc's real work costs zero ticks, so expiry is guaranteed + for any budget. The real-clock generous-budget test covers the other direction. +- `SubSolveReport.complete` is set conservatively: `False` whenever the block was attached + mid-solve, even in a right-preconditioned configuration where the count would in fact be + exact. +- `solve_report.sub` keys come from the split names, so the block-constrained Stokes path + yields `"0"` / `"1"` rather than `"velocity"` / `"pressure"`. Documented; read the keys. From c105994c8384340b61532992fce27313dacbd404 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Mon, 27 Jul 2026 19:12:21 +1000 Subject: [PATCH 25/30] fix(solve-report): SNES converged-reason table was off by one against PETSc Every positive SNES reason was shifted by one slot, and the table claimed a code 1 that PETSc does not define: reason 2 PETSc CONVERGED_FNORM_ABS reported as CONVERGED_FNORM_RELATIVE reason 3 PETSc CONVERGED_FNORM_RELATIVE reported as CONVERGED_SNORM_RELATIVE reason 4 PETSc CONVERGED_SNORM_RELATIVE reported as CONVERGED_ITS reason 5 PETSc CONVERGED_ITS reported as UNKNOWN_5 The direction matters. A solve that stopped on the STEP norm -- the weakest criterion, and what a stalled viscoplastic solve reports -- was labelled CONVERGED_ITS, while a genuine residual convergence was labelled CONVERGED_SNORM_RELATIVE. Reading a difficulty report is how a continuation driver decides whether a parameter station is reachable, so a mislabelled convergence is the kind of error that quietly puts false rescues on a regime map. Found while checking why a hard plastic station was reporting CONVERGED_ITS. Also adds DIVERGED_OBJECTIVE_DOMAIN (-13) and DIVERGED_OBJECTIVE_NANORINF (-14), which were unmapped and surfaced as UNKNOWN_n, and corrects -4 to its PETSc name DIVERGED_FUNCTION_NANORINF. The table is hand-written on purpose -- the module must import without the Cython extension -- so it can drift, and the existing test pinned it to ITSELF (reason_string(2) == 'CONVERGED_FNORM_RELATIVE') rather than to the enum, which is how this survived. The KSP table next to it WAS checked against petsc4py and was correct. test_snes_reason_table_matches_petsc now checks both directions: every label matches the enum, and every reason PETSc can return is mapped. Underworld development team with AI support from Claude Code --- src/underworld3/systems/solve_report.py | 24 ++++++++++------ tests/test_1058_solve_report.py | 37 ++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/src/underworld3/systems/solve_report.py b/src/underworld3/systems/solve_report.py index 10d1ac3e5..f3861b856 100644 --- a/src/underworld3/systems/solve_report.py +++ b/src/underworld3/systems/solve_report.py @@ -17,19 +17,23 @@ if TYPE_CHECKING: # keeps this module importable on its own from underworld3.systems.solver_health import SubSolveReport -# PETSc SNESConvergedReason codes -> short names. Mirrors the compact map in -# SolverBaseClass._convergence_reasons; duplicated here so this module imports without the -# Cython solver extension present. +# PETSc SNESConvergedReason codes -> short names. Duplicated here (rather than read from +# petsc4py) so this module imports without the Cython solver extension present -- which +# means it can drift, and it HAD: every positive code was shifted by one, so a solve that +# stopped on the STEP norm (the weakest criterion, and what a stalled plastic solve +# reports) was labelled CONVERGED_ITS, and a genuine residual convergence was labelled +# CONVERGED_SNORM_RELATIVE. There is no code 1. test_1055 now checks this table against +# petsc4py's enum, which is the only thing that can keep a hand-copy honest. REASON_STRINGS = { - 1: "CONVERGED_FNORM_ABS", - 2: "CONVERGED_FNORM_RELATIVE", - 3: "CONVERGED_SNORM_RELATIVE", - 4: "CONVERGED_ITS", - 0: "ITERATING", + 0: "CONVERGED_ITERATING", + 2: "CONVERGED_FNORM_ABS", + 3: "CONVERGED_FNORM_RELATIVE", + 4: "CONVERGED_SNORM_RELATIVE", + 5: "CONVERGED_ITS", -1: "DIVERGED_FUNCTION_DOMAIN", -2: "DIVERGED_FUNCTION_COUNT", -3: "DIVERGED_LINEAR_SOLVE", - -4: "DIVERGED_FNORM_NAN", + -4: "DIVERGED_FUNCTION_NANORINF", -5: "DIVERGED_MAX_IT", -6: "DIVERGED_LINE_SEARCH", -7: "DIVERGED_INNER", @@ -37,6 +41,8 @@ -9: "DIVERGED_DTOL", -10: "DIVERGED_JACOBIAN_DOMAIN", -11: "DIVERGED_TR_DELTA", + -13: "DIVERGED_OBJECTIVE_DOMAIN", + -14: "DIVERGED_OBJECTIVE_NANORINF", } diff --git a/tests/test_1058_solve_report.py b/tests/test_1058_solve_report.py index be3de21da..bda37fcaa 100644 --- a/tests/test_1058_solve_report.py +++ b/tests/test_1058_solve_report.py @@ -145,7 +145,10 @@ def test_resume_is_lossless_and_same_termination(): @pytest.mark.level_1 @pytest.mark.tier_a def test_solve_report_helpers(): - assert reason_string(2) == "CONVERGED_FNORM_RELATIVE" + # 2 is CONVERGED_FNORM_ABS in PETSc. This line previously asserted + # CONVERGED_FNORM_RELATIVE — it pinned the table to itself rather than to the enum, + # which is how the off-by-one survived. See test_snes_reason_table_matches_petsc. + assert reason_string(2) == "CONVERGED_FNORM_ABS" assert reason_string(-5) == "DIVERGED_MAX_IT" assert reason_string(999).startswith("UNKNOWN") assert contraction([50.0, 1e-3, 1e-6, 1e-9]) is not None @@ -261,3 +264,35 @@ def test_ksp_reason_table_matches_petsc(): assert label == f"KSP_{enum_names[code]}", (code, label, enum_names[code]) assert ksp_reason_string(-3) == "KSP_DIVERGED_MAX_IT" assert ksp_reason_string(999).startswith("KSP_UNKNOWN") + + +@pytest.mark.level_1 +@pytest.mark.tier_a +def test_snes_reason_table_matches_petsc(): + """The SNES table is hand-written for the same reason as the KSP one, and unlike the + KSP one it was never pinned to the enum — so it drifted. Every positive code was + shifted by one: a solve that stopped on the STEP norm (the weakest criterion, and + what a stalled viscoplastic solve reports) was labelled CONVERGED_ITS, while a + genuine residual convergence was labelled CONVERGED_SNORM_RELATIVE. Reading a + difficulty report is how continuation drivers decide whether a station is reachable, + so the labels have to be the real ones.""" + from petsc4py import PETSc + from underworld3.systems.solve_report import REASON_STRINGS, reason_string + + enum_names = {} + for name, value in vars(PETSc.SNES.ConvergedReason).items(): + if isinstance(value, int) and not name.startswith("_"): + enum_names.setdefault(value, name) + + for code, label in REASON_STRINGS.items(): + assert code in enum_names, f"{code} ({label}) is not a PETSc SNES reason at all" + assert label == enum_names[code], (code, label, enum_names[code]) + + # Every reason PETSc can return must be nameable — an UNKNOWN_n in a report is a + # gap in the table, and the ones that went missing were real diverged states. + for code in enum_names: + assert code in REASON_STRINGS, f"PETSc reason {code} ({enum_names[code]}) unmapped" + + assert reason_string(4) == "CONVERGED_SNORM_RELATIVE" + assert reason_string(5) == "CONVERGED_ITS" + assert reason_string(999).startswith("UNKNOWN") From cfa10def00d1841c8c060d73fd82041d0d6f3f56 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Wed, 29 Jul 2026 17:12:40 +1000 Subject: [PATCH 26/30] =?UTF-8?q?feat(constitutive):=20yield=5Fanchor=20?= =?UTF-8?q?=E2=80=94=20which=20side=20of=20exact=20Min=20the=20soft-min=20?= =?UTF-8?q?sits=20on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Smoothing a corner by delta moves the whole curve, so each soft-min family has one free constant. Until now it was fixed implicitly, and differently per family, with the consequence buried in the algebra: both families ended up sitting BELOW exact Min at and above the yield point. For a homotopy that is the wrong direction — an entry problem weaker than the sharp problem is not an easier version of it, it is a different and softer problem. `yield_anchor` names the choice and applies it to both families: "onset" (default, the historical law) pins the unyielded limit f -> 0. The sqrt family then undershoots for f < 2 — a third under-stressed at nominal yield — and the power mean degenerates to the p-norm, whose viscosity falls away toward zero as delta grows. "yield" pins f = 1, so tau/tau_y is exactly 1 at the yield point for every delta and the curve is >= exact Min everywhere. The sqrt family reaches it with the offset delta/2; the power mean needs no offset at all — averaging its two terms is what makes it a generalised MEAN rather than a p-NORM, and a generalised mean returns the common value when its arguments are equal. The cost of "yield" is stiffened unyielded material, bounded by 2 for the sqrt family and by 2^delta for the power mean. That bound is why the power mean's entry delta is O(1) where the sqrt family's is O(10) — the two deltas are not the same parameter, so the entry now comes from the family (`_YIELD_HOMOTOPY_DELTA0`) rather than from a caller carrying one number across both. `yield_continuation` gains `smoother=` and `anchor=` so a march can pick both, and refuses them when handed a ready-made control rather than silently ignoring them. The default stays "onset": flipping it changes results for every existing yield_mode="softmin" user, and that is a maintainer decision, not a side effect of naming the knob. Tests that exercise the old side now set the anchor explicitly instead of relying on the default, and test_1059 asserts the OLD anchor FAILS the new behaviour so it cannot pass against a no-op. Underworld development team with AI support from Claude Code --- .../nonlinear-solver-homotopy-warmstart.md | 26 +++ src/underworld3/constitutive_models.py | 190 ++++++++++++++---- .../cython/petsc_generic_snes_solvers.pyx | 10 +- src/underworld3/systems/yield_continuation.py | 56 +++++- tests/test_1055_yield_smoother.py | 12 +- tests/test_1059_yield_anchor.py | 116 +++++++++++ 6 files changed, 361 insertions(+), 49 deletions(-) create mode 100644 tests/test_1059_yield_anchor.py diff --git a/docs/developer/design/nonlinear-solver-homotopy-warmstart.md b/docs/developer/design/nonlinear-solver-homotopy-warmstart.md index 0ce8dda93..27d58e0ee 100644 --- a/docs/developer/design/nonlinear-solver-homotopy-warmstart.md +++ b/docs/developer/design/nonlinear-solver-homotopy-warmstart.md @@ -146,6 +146,32 @@ multi-parameter is not, and is unnecessary here. The rate-strengthening ξ term *non-homotopic* regularisation, not a homotopy — it belongs in a user-domain loop *around* `solve()`, not inside it. +#### Which side of `Min` the entry problem sits on (`yield_anchor`) + +Smoothing a corner by δ moves the whole curve, so each soft-min family has one free +constant. It is fixed by choosing which point the smoothed law must reproduce exactly, +and that choice — not the choice of family — decides whether the regularised problem is +*stiffer* or *weaker* than the sharp one. Writing $f = \eta_{ve}/\eta_{pl}$ for the +overstress ratio ($f = 1$ is the yield point): + +| `yield_anchor` | exact at | sqrt family | power mean | +|---|---|---|---| +| `"onset"` (default) | $f \to 0$, the unyielded branch | undershoots for $f < 2$ | the p-norm; $\eta \to 0$ as δ grows | +| `"yield"` | $f = 1$, the yield point | offset $\delta/2$; $\ge$ `Min` | the true power mean (the $1/2$); $\ge$ `Min` | + +**A homotopy wants `"yield"`.** An entry problem that sits *below* the sharp law is +weaker than the problem it is supposed to lead to, which is the opposite of an easier +version of it. Under `"onset"` both families are weaker near the yield point, and that +bites hardest exactly where the overstress ratio is O(1) — since the rate term floors +the plastic viscosity ($\eta_{pl} \ge \xi$, so $f \le 1/\xi$), a held ξ puts the whole +domain in the softened region. The cost of `"yield"` is stiffened unyielded material, +bounded by 2 for the sqrt family and by $2^{\delta}$ for the power mean; that bound is +why the power mean's entry δ is O(1) while the sqrt family's is O(10). + +The default is `"onset"` because it is the historical law, not because it is the better +one. Changing it alters results for every existing `yield_mode="softmin"` user, so it is +a separate maintainer decision. + ### Layer 3 — smoother as a consistent-Newton consequence Turning on the consistent tangent adds the `∂η/∂(grad v)` term, which makes the diff --git a/src/underworld3/constitutive_models.py b/src/underworld3/constitutive_models.py index 35f34a641..752026a45 100644 --- a/src/underworld3/constitutive_models.py +++ b/src/underworld3/constitutive_models.py @@ -958,25 +958,86 @@ def _get_yield_softness(self): 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). + # The offset fixes WHERE the smoothed curve is pinned to the exact law. + # 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). See `yield_anchor`. 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", + self._yield_offset_for_anchor(), + "Yield soft-min offset; tracks δ so the chosen anchor is exact", ) else: self._yield_softness_expr.sym = sympy.Float(delta_value) return self._yield_softness_expr + def _yield_offset_for_anchor(self): + r"""The sqrt soft-min offset that pins the chosen anchor, symbolic in δ.""" + delta = self._yield_softness_expr + if getattr(self, "_yield_anchor", "onset") == "yield": + return delta / 2 + return (-1 + sympy.sqrt(1 + delta**2)) / 2 + def _get_yield_offset(self): - """The onset-offset constant atom (lazily created alongside δ).""" + """The offset constant atom (lazily created alongside δ).""" if getattr(self, "_yield_offset_expr", None) is None: self._get_yield_softness() return self._yield_offset_expr + @property + def yield_anchor(self): + r"""Which point of the ``"softmin"`` yield law is pinned to the exact ``Min``. + + A soft-min rounds the yield corner by δ, and rounding a corner moves the whole + curve — so the family has one free constant, fixed by choosing WHICH point the + smoothed law must reproduce exactly. Both choices apply to both smoother + families, and both recover ``Min`` as :math:`\delta \to 0`; they differ only in + which SIDE of the exact law the curve sits on in between. Writing + :math:`f = \eta_{ve} / \eta_{pl}` for the overstress ratio (so :math:`f = 1` is + the yield point and :math:`f < 1` is unyielded): + + ``"onset"`` (default, historical) + Pins the unyielded limit :math:`f \to 0`, where :math:`\eta = \eta_{ve}` + exactly. The curve then sits BELOW the exact law at and above the yield + point — the sqrt family undershoots for :math:`f < 2` (at + :math:`\delta = 64`, :math:`\eta/\eta_{ve} = 0.80` at :math:`f = 0.5` where + the exact law gives 1.0, and the yield point itself is a third + under-stressed, :math:`\tau/\tau_y = 0.67`), and the power mean degenerates + to the p-norm :math:`(\eta_{ve}^{-s} + \eta_{pl}^{-s})^{-1/s}`, which falls + away to :math:`\eta \to 0` as δ grows. So neither family approaches the + yield surface from above under this anchor. That matters whenever the + problem's overstress ratio is O(1), because then the smoothed problem is + WEAKER than the sharp one it is supposed to be an easy version of. + + ``"yield"`` + Pins :math:`f = 1`, so :math:`\tau/\tau_y = 1` exactly at the yield point + for every δ and the curve is :math:`\ge` the exact law everywhere — a + genuine approach from above, which is the safe direction for a homotopy + entry. The sqrt family gets there with the offset :math:`\delta/2`; the + power mean needs no offset at all, since averaging the two terms, + :math:`\eta = ((\eta_{ve}^{-s} + \eta_{pl}^{-s})/2)^{-1/s}`, makes it a + generalised mean, and a generalised mean returns the common value when its + arguments are equal. The cost is stiffened unyielded material, bounded by a + factor 2 for the sqrt family and by :math:`2^{\delta}` for the power mean, + both decaying to 1 as :math:`\delta \to 0`. The power-mean bound is why its + entry δ is O(1) and not O(10). + """ + return getattr(self, "_yield_anchor", "onset") + + @yield_anchor.setter + def yield_anchor(self, value): + if value not in ("onset", "yield"): + raise ValueError( + f"yield_anchor must be 'onset' or 'yield', got {value!r}") + self._yield_anchor = value + # The sqrt family's offset atom holds the anchor's FORMULA, so it must be + # rebuilt, not merely revalued — and _get_yield_softness only builds it + # alongside δ. (The power mean carries the anchor in _combine_yield itself, so + # for that family the _reset is the whole of the update.) + self._yield_offset_expr = None + self._yield_softness_expr = None + self._reset() + 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``: @@ -984,14 +1045,18 @@ def _combine_yield(self, eta_ve, eta_pl): - ``"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). + ``self.yield_smoother`` (``"sqrt"`` or ``"powermean"``) and on the side of + exact ``Min`` chosen by ``self.yield_anchor``. Which side is the property + that matters for a homotopy, and it belongs to the ANCHOR, not the family: + ``"onset"`` puts both families below ``Min`` near the yield point, + ``"yield"`` puts both on or above it. 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). + lands within a factor :math:`2^{\pm(\delta + 0.001)}` of ``Min``, i.e. 0.07 % + at ``δ = 0``. 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 @@ -1008,7 +1073,7 @@ def _combine_yield(self, eta_ve, eta_pl): 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 + # Soft-min of order -s 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)) @@ -1021,7 +1086,14 @@ def _combine_yield(self, eta_ve, eta_pl): # 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) + softmin = a ** (-s) + b ** (-s) + if self.yield_anchor == "yield": + # Averaging the two terms is what makes this a power MEAN rather than + # a p-NORM, and it is the whole of the "yield" anchor for this family: + # a generalised mean returns the common value when its arguments are + # equal, so η = η_ve = η_pl exactly at f = 1 — no offset atom needed. + softmin = softmin / 2 + return N * softmin ** (-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() @@ -1037,19 +1109,30 @@ def yield_smoother(self): 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. + ``g = 1 + ½(f−1+√((f−1)²+δ²)) − offset``, ``f = η_ve/η_pl``. Exact + ``Min`` at ``δ=0``. Its smoothing authority saturates: as ``δ → ∞``, + ``η → 2 η_ve/(f+2)``, so the most it can move a point is a factor + ``2f/(f+2)`` — which tends to 1 as ``f → 1``. On a problem whose + overstress ratio is O(1) this family therefore has very little room to + build an easier problem, however large δ is. + - ``"powermean"``: the soft-min of order ``−s``, + ``η_eff = (η_ve^(−s) + η_pl^(−s))^(−1/s)`` with ``s = 1/(δ + 0.001)`` + (``s→∞`` ⇒ ``Min``). Unlike the sqrt family it does not saturate — it + keeps moving with δ — which is what makes it usable where the sqrt family + has no range. + + ``δ`` is NOT the same parameter in the two families and their schedules do + not transfer: for the power mean ``s = 1/(δ + 0.001)``, so ``δ = 1`` is + already the harmonic mean and useful entries are ``δ ≤ 1``; for the sqrt + family δ is a percentage stress deviation and a generous entry is O(10). + Take the entry from ``_yield_homotopy_control``, which asks the family. + + Which SIDE of ``Min`` a family sits on is set by ``yield_anchor``, not by the + family — see that property. 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*. + (``s≈1``, the parameter-free harmonic mean) since ``δ=0`` is the sharp + hard-``Min`` limit it only *approaches*. """ return getattr(self, "_yield_smoother", "sqrt") @@ -1112,7 +1195,15 @@ def _apply_floor(self, value, floor): # solve fails outright (DIVERGED_LINEAR_SOLVE). _yield_homotopy_tangent = True - def _yield_homotopy_control(self): + #: Entry δ per soft-min family. These are NOT interchangeable numbers: for the + #: power mean the sharpness is s = 1/(δ + 0.001), so δ ≤ 1 and δ = 1 IS the harmonic + #: mean; for the sqrt family δ is a percentage stress deviation and a generous entry + #: is O(10). A sqrt-family δ handed to the power mean asks for a law a factor 2^δ + #: away from Min — 65000 at δ=16 — which is why the entry has to come from the + #: family, not the caller. + _YIELD_HOMOTOPY_DELTA0 = {"powermean": 1.0, "sqrt": 16.0} + + def _yield_homotopy_control(self, smoother=None, anchor=None): """Put this model in its smooth (δ-parameterised) yield mode and describe how to march it. @@ -1122,22 +1213,52 @@ def _yield_homotopy_control(self): ``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. + Defaults to the power-mean 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. + + **Which family suits a given problem is not settled by theory** — it is set by + the problem's overstress ratio :math:`f = \\eta_{ve}/\\eta_{pl}`, which is cheap + to measure on the viscous seed before choosing. The sqrt family's smoothing + saturates at a factor :math:`2f/(f+2)`, so on a problem where f is O(1) it can + barely build an easier problem at all, however large δ is; the power mean does + not saturate and has range there. Where f is large the position reverses. + + The side the regularised law sits on is set by ``anchor=``, not by the family. + An entry problem should be no WEAKER than the sharp problem it precedes, so + ``anchor="yield"`` is the safe direction for both families — see + :attr:`yield_anchor`. + + Parameters + ---------- + smoother : {"powermean", "sqrt"}, optional + Soft-min family to march. ``None`` keeps the default (power mean). + anchor : {"onset", "yield"}, optional + Which point the smoothed law reproduces exactly. ``None`` leaves the + model's current :attr:`yield_anchor` alone. Returns ------- YieldHomotopyControl ``set_delta`` (model-owned setter for δ), ``tangent`` (the - ``consistent_jacobian`` value to use), and ``delta`` (the ``constants[]`` - atom itself, for diagnostics). + ``consistent_jacobian`` value to use), ``delta`` (the ``constants[]`` + atom itself, for diagnostics) and ``delta0`` (the family's entry δ). """ from underworld3.systems.yield_continuation import YieldHomotopyControl + if smoother is None: + smoother = "powermean" + if smoother not in self._YIELD_HOMOTOPY_DELTA0: + raise ValueError( + f"smoother must be one of {sorted(self._YIELD_HOMOTOPY_DELTA0)}, " + f"got {smoother!r}" + ) + self.yield_mode = "softmin" - self.yield_smoother = "powermean" + self.yield_smoother = smoother + if anchor is not None: + self.yield_anchor = anchor # 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 @@ -1155,6 +1276,7 @@ def set_delta(value): set_delta=set_delta, tangent=self._yield_homotopy_tangent, delta=self._get_yield_softness(), + delta0=self._YIELD_HOMOTOPY_DELTA0[smoother], ) diff --git a/src/underworld3/cython/petsc_generic_snes_solvers.pyx b/src/underworld3/cython/petsc_generic_snes_solvers.pyx index 8f03527fc..2eaaca83a 100644 --- a/src/underworld3/cython/petsc_generic_snes_solvers.pyx +++ b/src/underworld3/cython/petsc_generic_snes_solvers.pyx @@ -8555,8 +8555,14 @@ class SNES_Stokes_SaddlePt(SolverBaseClass): 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. + ``smoother``, ``delta0``, ``down``, ``dmin``, ``entry_maxit``, + ``step_maxit``, ``retries``. All are defaulted; tuning them is optional. + ``smoother`` picks the soft-min family — ``"powermean"`` (default, + approaches the yield surface from below) or ``"sqrt"`` (from above). Which + gives the better cold entry is problem-dependent, so it is worth trying both + when a march will not start. Leave ``delta0`` unset unless you have a + reason: each family supplies its own entry, and the two δ are not the same + parameter. Returns ------- diff --git a/src/underworld3/systems/yield_continuation.py b/src/underworld3/systems/yield_continuation.py index ff162619b..b6feeb10a 100644 --- a/src/underworld3/systems/yield_continuation.py +++ b/src/underworld3/systems/yield_continuation.py @@ -40,17 +40,26 @@ class YieldHomotopyControl: yield, the frozen/Picard tangent for the elastic (VEP) models. delta The δ ``constants[]`` atom itself, for diagnostics. + delta0 + The entry δ this family should START from. Model-owned because δ does NOT mean + the same thing in the two soft-min families: for the power mean the sharpness is + ``s = 1/(δ + 0.001)``, so δ ≤ 1 and δ = 1 IS the harmonic mean, while for the + sqrt family δ is a percentage stress deviation and a generous entry is O(10). + Carrying one number across both families is a category error. """ set_delta: Callable[[float], None] tangent: Any delta: Optional[Any] = None + delta0: float = 1.0 def yield_continuation( solver, control=None, - delta0=1.0, + smoother=None, + anchor=None, + delta0=None, down=0.5, dmin=1.0e-3, entry_maxit=30, @@ -74,10 +83,9 @@ def yield_continuation( 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. + Starting δ is deliberately **large**, where the power-mean soft-min stays 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 ---------- @@ -86,8 +94,26 @@ def yield_continuation( 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 about as cheap to solve as small, so be generous. + smoother : {"powermean", "sqrt"}, optional + Which soft-min FAMILY to march. Default (``None``) leaves the model's own + choice, which is the power mean. The families differ in how much room δ gives + them: the sqrt law's smoothing saturates at a factor :math:`2f/(f+2)` in the + overstress ratio :math:`f = \eta_{ve}/\eta_{pl}`, so where f is O(1) it can + barely build an easier problem however large δ is, while the power mean does not + saturate. Where f is large the position reverses. f is cheap to measure on the + viscous seed, so choose the family from it rather than by reputation. + anchor : {"onset", "yield"}, optional + Which point of the smoothed law is pinned to the exact one — see + ``ViscousFlowModel.yield_anchor``. Applies to BOTH families. Default (``None``) + leaves the model's own choice. ``"yield"`` keeps the stress exact at the yield + point for every δ and puts the law on or above the exact one throughout; + ``"onset"`` (the historical default) is exact on the unyielded branch but sits + BELOW the exact law at and above the yield point, which makes the entry problem + WEAKER than the sharp problem it is supposed to lead to. + delta0 : float, optional + Starting (smooth) δ. Defaults to the family's own entry (``control.delta0``), + because δ is NOT the same parameter in the two families — see + :class:`YieldHomotopyControl`. Do not carry one number across both. down : float Nominal multiplicative step, :math:`0 < down < 1` (δ ← δ·down per success). Adapted during the march as described above. @@ -118,8 +144,6 @@ 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) @@ -129,7 +153,19 @@ def yield_continuation( "homotopy (supports_yield_homotopy). Got " f"{type(cm).__name__ if cm is not None else None}." ) - control = cm._yield_homotopy_control() + control = cm._yield_homotopy_control(smoother=smoother, anchor=anchor) + elif smoother is not None or anchor is not None: + raise ValueError( + "smoother=/anchor= configure the soft-min when this driver builds the " + "control; they cannot override a control that was passed in ready-made. " + "Set them on the model before building the control instead." + ) + + # Each family has its own natural entry, and they are not interchangeable numbers. + if delta0 is None: + delta0 = control.delta0 + if not delta0 > 0.0: + raise ValueError(f"delta0 must be positive, got {delta0}") # 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. diff --git a/tests/test_1055_yield_smoother.py b/tests/test_1055_yield_smoother.py index b1383a80a..580b29df4 100644 --- a/tests/test_1055_yield_smoother.py +++ b/tests/test_1055_yield_smoother.py @@ -78,8 +78,12 @@ def test_delta0_is_exact_min(): @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/δ→∞).""" + """Power-mean smooth-min UNDER THE ONSET ANCHOR: ≤ Min everywhere (no over-yield), + overflow-safe on geodynamic ranges, and → Min as δ→0 (s=1/δ→∞). + + Which side of Min the law sits on belongs to ``yield_anchor``, not to the family, so + the anchor is set explicitly here: under ``"yield"`` this same family is ≥ Min by + construction and every assertion below would (correctly) invert.""" 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) @@ -88,6 +92,7 @@ def test_powermean_smoother_undershoots_min(): s.constitutive_model = cm cm.yield_mode = "softmin" # dev: soft-min family lives under "softmin" + cm.yield_anchor = "onset" # the law under test; not left to the default 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" @@ -169,8 +174,9 @@ def test_dp_model_smoother_optin(): 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 + # opt into the power-mean homotopy: undershoots Min under the onset anchor cm.yield_mode = "softmin" + cm.yield_anchor = "onset" # the side under test; not the default 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)]: diff --git a/tests/test_1059_yield_anchor.py b/tests/test_1059_yield_anchor.py new file mode 100644 index 000000000..16765d854 --- /dev/null +++ b/tests/test_1059_yield_anchor.py @@ -0,0 +1,116 @@ +"""Which point of the soft-min yield law is pinned to exact Min (`yield_anchor`). + +A soft-min rounds the yield corner by delta, and rounding a corner moves the whole +curve -- so the family has one free constant, fixed by choosing which point the +smoothed law must reproduce exactly. That choice decides which SIDE of the exact law +the curve sits on, and for a homotopy the side is the property that matters: an entry +problem that is WEAKER than the sharp problem is not an easier version of it. + +The historical anchor pins the unyielded limit f -> 0. Both families then sit BELOW +exact Min at and above the yield point -- the sqrt family undershoots for f < 2 (a +third under-stressed at nominal yield), and the power mean degenerates to the p-norm, +whose viscosity falls away towards zero as delta grows. + +Anchoring at the yield point f = 1 instead puts both families on or above exact Min +everywhere. The sqrt family gets there with the offset delta/2; the power mean needs no +offset, because averaging its two terms makes it a generalised mean and a generalised +mean returns the common value when its arguments are equal. + +The tests that pin the new behaviour also assert the OLD anchor FAILS it, so they +cannot pass against a no-op implementation. +""" +import numpy as np +import pytest +import sympy + +import underworld3 as uw +from underworld3.function.expressions import unwrap + + +def _model(smoother="sqrt"): + m = uw.meshing.UnstructuredSimplexBox(minCoords=(0,0), maxCoords=(1,1), cellSize=0.5, qdegree=2) + v = uw.discretisation.MeshVariable('U', m, m.dim, degree=2) + p = uw.discretisation.MeshVariable('P', m, 1, degree=1) + s = uw.systems.Stokes(m, velocityField=v, pressureField=p) + s.constitutive_model = uw.constitutive_models.ViscoPlasticFlowModel + c = s.constitutive_model; c.yield_mode='softmin'; c.yield_smoother=smoother + return c + +def eta(c, f, d): + """eta_eff/eta_ve at overstress f (eta_ve=1 => eta_pl=1/f).""" + c.yield_softness = d + e = c._combine_yield(sympy.Float(1.0), sympy.Float(1.0/f) if f>0 else sympy.oo) + return float(sympy.N(unwrap(e, keep_constants=False))) + +FS = (0.25, 0.5, 1.0, 2.0, 4.0, 10.0) + +# delta is NOT the same parameter in the two families: for the power mean +# s = 1/(delta + 0.001), so delta = 1 is already the harmonic mean and the law sits a +# factor 2^delta from Min. Each family is therefore probed over its own useful range. +DELTAS = {"sqrt": (1.0, 16.0, 64.0), "powermean": (0.1, 0.25, 1.0)} + +@pytest.mark.level_1 +@pytest.mark.tier_a +def test_delta_zero_is_exact_min_for_both_anchors(): + """Only the sqrt family is exactly Min at delta=0; the power mean's sharpness + saturates at s=1000, so it is checked against its own 2^0.001 bound.""" + c = _model("sqrt") + for anchor in ("onset", "yield"): + c.yield_anchor = anchor + for f in FS: + assert abs(eta(c, f, 0.0) - 1.0/max(1.0, f)) < 1e-12, (anchor, f) + c = _model("powermean") + for anchor in ("onset", "yield"): + c.yield_anchor = anchor + for f in FS: + # The bound is attained, not approached: 2^0.001 - 1 = 6.93e-4 exactly. + assert abs(eta(c, f, 0.0)/(1.0/max(1.0, f)) - 1.0) < 1e-3, (anchor, f) + +@pytest.mark.level_1 +@pytest.mark.tier_a +@pytest.mark.parametrize("smoother", ("sqrt", "powermean")) +def test_yield_anchor_keeps_onset_stress_exact(smoother): + """tau/tau_y = f*eta/eta_ve = 1 at f=1 for every delta. The onset anchor does NOT.""" + c = _model(smoother); c.yield_anchor = "yield" + for d in DELTAS[smoother]: + assert abs(eta(c, 1.0, d) - 1.0) < 1e-10, d + # The defect being fixed: sqrt is a third under-stressed at nominal yield, and the + # p-norm the power mean falls back to is HALF the correct viscosity at delta=1. + c.yield_anchor = "onset" + assert abs(eta(c, 1.0, DELTAS[smoother][-1]) - 1.0) > 0.3 + +@pytest.mark.level_1 +@pytest.mark.tier_a +@pytest.mark.parametrize("smoother", ("sqrt", "powermean")) +def test_yield_anchor_never_falls_below_exact_min(smoother): + """A genuine approach from ABOVE. The onset anchor undershoots.""" + c = _model(smoother); c.yield_anchor = "yield" + for d in DELTAS[smoother]: + for f in FS: + assert eta(c, f, d) >= 1.0/max(1.0, f) - 1e-12, (d, f) + c.yield_anchor = "onset" + assert eta(c, 0.5, DELTAS[smoother][-1]) < 1.0 - 1e-3 + +@pytest.mark.level_1 +@pytest.mark.tier_a +def test_yield_anchor_removes_the_power_mean_degenerate_basin(): + """The p-norm's viscosity collapses towards zero as delta grows -- the 'spurious + fully-yielded fixed point'. It is an artefact of the missing 1/2: the power mean is + bounded below by exact Min for every delta, and tends to the geometric mean.""" + c = _model("powermean") + c.yield_anchor = "onset" + collapse = [eta(c, 10.0, d) for d in (1.0, 4.0, 16.0)] + assert collapse[-1] < 0.01 * 0.1 # 0.1 is exact Min at f=10 + assert collapse[0] > collapse[1] > collapse[2] # monotone collapse + c.yield_anchor = "yield" + bounded = [eta(c, 10.0, d) for d in (1.0, 4.0, 16.0)] + assert all(v >= 0.1 for v in bounded) + assert max(bounded) < np.sqrt(1.0 * 0.1) * 1.001 # geometric mean is the ceiling + +@pytest.mark.level_1 +@pytest.mark.tier_a +def test_anchor_rejects_nonsense_and_defaults_to_onset(): + c = _model() + assert c.yield_anchor == "onset" + with pytest.raises(ValueError): + c.yield_anchor = "middle" From f1555b3a4bc0c6a06191b26253f1b850824036cf Mon Sep 17 00:00:00 2001 From: lmoresi Date: Wed, 29 Jul 2026 19:07:25 +1000 Subject: [PATCH 27/30] fix(solver): make the Stokes solver settings reachable (#477, D-22/D2) Two settings a user could write, could read back from petsc_options, and could never actually get. Both had the same shape: a setter re-run inside solve(), overwriting the user's value between it being set and PETSc reading it at setFromOptions(). * `fieldsplit_velocity_ksp_rtol` and `fieldsplit_pressure_ksp_rtol`. solve() round-tripped `self.tolerance`, and the tolerance setter re-derives both margins. solve() now re-asserts only the OUTER keys, via _reassert_outer_tolerances(). Setting `tolerance` still re-derives the margins from it -- that is the property's job and users expect it. * `snes_max_it`, previously a hardcoded 50 pushed before every solve. Now resolved once per solve against what this solver last pushed, with a latch: the second solve reads back OUR push of THEIR value and would otherwise mistake it for our own default. Why this is worth a fix rather than a documentation note: the failure was INVISIBLE. A sweep over an inner tolerance returns rows identical to five significant figures, which reads as "this variable does not matter" rather than "this variable was never set". It cost a twelve-cell configuration sweep before the identical cells were followed up: outer vel_rtol schur_mu | ok nl vel wall |F|/|F0| gmres tight eta_eff | F 37 36633 779.5s 4.9134e-01 gmres 1e-4 eta_eff | F 37 36612 771.9s 4.9134e-01 Six decades of requested tolerance, one trajectory. It also means the Citcom inner margins (0.033 velocity, 0.1 pressure) have never been tunable by anyone. Their EXISTENCE is principled -- inexact inner solves must stay well below the tolerance asked of the outer solve -- but their SIZE is inherited convention with no derivation on record, and no one could have measured an alternative. Defaults are unchanged: verified by a reachability audit that checks the live PETSc objects after pc.setUp() in both directions -- stock settings still give snes_max_it 50, outer gmres, vel rtol tolerance*0.033, pressure rtol tolerance*0.1 -- and every override now arrives. 90 solver tests pass. Underworld development team with AI support from Claude Code --- .../cython/petsc_generic_snes_solvers.pyx | 88 +++++++++++++++---- 1 file changed, 73 insertions(+), 15 deletions(-) diff --git a/src/underworld3/cython/petsc_generic_snes_solvers.pyx b/src/underworld3/cython/petsc_generic_snes_solvers.pyx index 2eaaca83a..1a6472a41 100644 --- a/src/underworld3/cython/petsc_generic_snes_solvers.pyx +++ b/src/underworld3/cython/petsc_generic_snes_solvers.pyx @@ -6262,6 +6262,15 @@ class SNES_Stokes_SaddlePt(SolverBaseClass): """ return self._tolerance + #: Inner-solve tolerance margins, as a fraction of the outer tolerance. The inner + #: solves are deliberately inexact (Citcom; Moresi & Solomatov 1995) — which is why + #: the sub-blocks are flexible Krylov methods — but the inexactness must stay well + #: BELOW the tolerance demanded of the outer solve. These factors are that margin. + #: Their existence is principled; their size is inherited convention, so they are + #: DEFAULTS a user may override rather than values this property owns outright. + _INNER_RTOL_MARGIN = {"fieldsplit_pressure_ksp_rtol": 0.1, + "fieldsplit_velocity_ksp_rtol": 0.033} + @tolerance.setter def tolerance(self, value): self._tolerance = value @@ -6270,8 +6279,60 @@ class SNES_Stokes_SaddlePt(SolverBaseClass): self.petsc_options["snes_ksp_ew_version"] = 3 self.petsc_options["ksp_atol"] = self._tolerance * 1.0e-6 - self.petsc_options["fieldsplit_pressure_ksp_rtol"] = self._tolerance * 0.1 # rule of thumb - self.petsc_options["fieldsplit_velocity_ksp_rtol"] = self._tolerance * 0.033 + + # Setting the tolerance re-derives the inner margins from it — that is this + # property's job, and a user who changes the tolerance expects it. What must NOT + # happen is `solve()` re-deriving them on every call: it did (pyx `solve()` + # round-trips `self.tolerance` immediately before `setFromOptions()`), which + # overwrote any user value between it being set and PETSc reading it and made + # both documented options silently unreachable (#477). `solve()` now re-asserts + # only the outer keys, via `_reassert_outer_tolerances`. + for key, margin in self._INNER_RTOL_MARGIN.items(): + self.petsc_options[key] = self._tolerance * margin + + def _resolve_snes_max_it(self, default): + """The nonlinear iteration cap for this solve, honouring a user-set + ``snes_max_it``. + + ``solve()`` pushes this option before every solve, so it has to be able to tell + its OWN previous push from a value the user set — otherwise the first solve makes + the option permanently unreachable. Call once, before this solve pushes anything. + """ + pushed = getattr(self, "_snes_max_it_pushed", None) + user = getattr(self, "_snes_max_it_user", None) + if self.petsc_options.hasName("snes_max_it"): + try: + current = int(self.petsc_options.getInt("snes_max_it")) + except Exception: + return default + if pushed is None or current != pushed: + # Never pushed by us, or the user has moved it since. Latch: from here on + # the option is theirs, because the next solve will read back OUR push of + # THEIR value and would otherwise mistake it for our own default. + self._snes_max_it_user = current + return current + if user is not None: + return user + return default + + def _push_snes_max_it(self, value): + """Push ``snes_max_it`` and remember what was pushed, so a later solve can tell + this solver's own value apart from a user override.""" + value = int(value) + self.petsc_options.setValue("snes_max_it", value) + self._snes_max_it_pushed = value + + def _reassert_outer_tolerances(self): + """Re-push the OUTER tolerance keys before a solve, leaving the inner margins be. + + `solve()` may have changed `snes_max_it` and the SNES type for a Picard warm-up, + so the outer settings are re-asserted before the real solve. The sub-block rtols + are deliberately excluded: they belong to whoever set them last, which may be the + user (#477). Overwriting them here is what made them unsettable.""" + self.petsc_options["snes_rtol"] = self._tolerance + self.petsc_options["snes_ksp_ew"] = None + self.petsc_options["snes_ksp_ew_version"] = 3 + self.petsc_options["ksp_atol"] = self._tolerance * 1.0e-6 @property @@ -8691,15 +8752,12 @@ class SNES_Stokes_SaddlePt(SolverBaseClass): UW_DMSetTime(_time_dm_stokes.dm, t_nd) # Keep a record of these set-up parameters - tolerance = self.tolerance snes_type = self.snes.getType() - # NOTE: hardcoded iteration cap. Unlike tolerance/snes_type above (read - # from current state), this value is pushed to petsc_options before the - # final solve below, silently clobbering any user-set snes_max_it on - # every solve. Respecting the user's setting is a behaviour change - # deferred to the benchmarked D2 sub-wave — see - # docs/reviews/2026-07/REMEDIATION-WORKLIST.md rows D-22/D2 (ruling D18). - snes_max_it = 50 + # Resolved ONCE, before any of this solve's own pushes, so the resolver compares + # against the previous solve's push rather than this one's. (Was a hardcoded 50 + # pushed unconditionally, which made `snes_max_it` unreachable — worklist rows + # D-22/D2, ruling D18; same failure shape as the sub-block rtols in #477.) + snes_max_it = self._resolve_snes_max_it(50) self.mesh.update_lvec() self.dm.setAuxiliaryVec(self.mesh.lvec, None) @@ -8716,7 +8774,7 @@ class SNES_Stokes_SaddlePt(SolverBaseClass): print(f"SNES pre-solve - non-zero initial guess", flush=True) - self.petsc_options.setValue("snes_max_it", 0) + self._push_snes_max_it(0) self.snes.setType("nrichardson") self.snes.setFromOptions() # PETSc may rebuild operator state after setFromOptions(), so reattach @@ -8759,8 +8817,8 @@ class SNES_Stokes_SaddlePt(SolverBaseClass): # Picard solves if requested if picard != 0: - self.petsc_options.setValue("snes_max_it", abs(picard)) - self.tolerance = tolerance + self._push_snes_max_it(abs(picard)) + self._reassert_outer_tolerances() self.snes.atol = self.atol self.snes.setType("nrichardson") self.snes.setFromOptions() @@ -8772,9 +8830,9 @@ class SNES_Stokes_SaddlePt(SolverBaseClass): # warmup above was taken: restore the configured SNES type and # tolerances, then solve. self.snes.setType(snes_type) - self.tolerance = tolerance + self._reassert_outer_tolerances() self.snes.atol = self.atol - self.petsc_options.setValue("snes_max_it", snes_max_it) + self._push_snes_max_it(snes_max_it) self.snes.setFromOptions() self._attach_stokes_nullspace() # Custom geometric-MG prolongation on the velocity block (if registered From 5ad68b2fcd71b98fa1b048ecedeb0dcd0768dbde Mon Sep 17 00:00:00 2001 From: lmoresi Date: Wed, 29 Jul 2026 19:47:09 +1000 Subject: [PATCH 28/30] docs(solvers): the Stokes fieldsplit is two nested Krylov loops Written because the packaging genuinely obscures the algorithm: `pc_type=fieldsplit` with `schur_fact_type=full` reads as one solver and is two nested Krylov loops with a third inside them, and which loop does the work is the difference between tuning the solver and permuting options. The section maps PETSc's arrangement onto the one most UW users already carry: * a pressure Krylov solve IS Citcom (Moresi & Solomatov 1995) -- an outer pressure iteration with an inexact multigrid velocity solve inside it, with the outer KSP reduced to a formality at one or two iterations; * `fieldsplit_pressure_ksp_type=preonly` is the Elman-Silvester-Wathen block preconditioner instead -- the Schur system is never solved and the OUTER Krylov does all the coupling work. Same work, different loop. UW3 takes no position: it is problem-dependent, and worth measuring on the problem at hand. What the section does insist on is that the choice interacts with the outer restart (under `preonly` every coupling iteration is an outer one, so GMRES's 30-iteration restart lands exactly where a deep residual is being ground out), that the inner margins were designed for the Citcom shape, and that `preonly` on the VELOCITY block under a Schur factorisation is a defect rather than a design choice. Also records the diagnostic that was missing: sub["velocity"].its is a COST measure, and a restart stagnation is visible only in the OUTER count. Underworld development team with AI support from Claude Code --- docs/developer/subsystems/solvers.md | 106 +++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/docs/developer/subsystems/solvers.md b/docs/developer/subsystems/solvers.md index e6710bbb2..0ba6a0db5 100644 --- a/docs/developer/subsystems/solvers.md +++ b/docs/developer/subsystems/solvers.md @@ -178,6 +178,112 @@ def validate_unknowns_sharing(multi_material_model): - ⚠️ Preconditioner selection missing - Could benefit from optimization examples +## The Stokes fieldsplit: two nested Krylov loops + +The Stokes saddle-point solver is configured as + +``` +pc_type = fieldsplit +pc_fieldsplit_type = schur +pc_fieldsplit_schur_fact_type = full +pc_fieldsplit_schur_precondition = a11 +``` + +which is easy to read as one solver and is in fact **two nested Krylov loops with a third +inside them**. Knowing which loop does the work is the difference between tuning the +solver and permuting options. + +For the saddle system + +$$ +\begin{bmatrix} A & B^{T} \\ B & 0 \end{bmatrix} +\begin{bmatrix} u \\ p \end{bmatrix} = +\begin{bmatrix} f \\ g \end{bmatrix} +$$ + +`fieldsplit` is a **preconditioner for a Krylov method on the full coupled system**. With +`schur_fact_type = full`, one application of that preconditioner is the block +factorisation, which needs $A^{-1}$ twice and $\hat{S}^{-1}$ once, where +$S = -B A^{-1} B^{T}$ is the Schur complement and $\hat S$ is its preconditioner — with +`schur_precondition = a11`, the $1/\mu$-weighted pressure mass matrix that +`saddle_preconditioner` supplies. + +The three solves: + +| loop | iterates on | cost of one iteration | +|---|---|---| +| **outer KSP** | the full coupled residual | one preconditioner application | +| **pressure sub-KSP** | the Schur system $S p = r$ | a MatMult by $S$ — i.e. **a velocity solve** | +| **velocity sub-KSP** | $A u = r$, preconditioned by FMG or GAMG | one multigrid cycle per Krylov step | + +### Two designs, and the same work in different loops + +The pressure block's `ksp_type` chooses between two genuinely different algorithms. + +**A pressure Krylov solve gives you Citcom.** The Schur system is actually solved, so the +block preconditioner is close to the exact inverse and the outer KSP converges in one or +two iterations — a formality wrapped around a Uzawa solve. This is the classical +arrangement (Moresi & Solomatov 1995): an outer *pressure* iteration with an inexact +multigrid velocity solve inside it. UW3's inner-tolerance margins (`0.033` velocity, +`0.1` pressure, applied by the `tolerance` setter) were designed for this shape. + +**`fieldsplit_pressure_ksp_type = preonly` gives you a block preconditioner.** The Schur +system is never solved; $\hat S^{-1}$ is a single mass-matrix application, the fieldsplit +becomes a cheap approximate preconditioner, and the **outer** Krylov does all the coupling +work. This is the Elman–Silvester–Wathen approach, standard in the finite-element +literature. It is not a degenerate configuration — but it is not Citcom, and the margins +above are not aimed at it. + +Neither is universally better, and UW3 does not take a position: the choice is +problem-dependent and worth measuring on the problem at hand. What matters is knowing +which one is in force, because the diagnostics differ. + +```{important} The two choices interact with the outer restart +Under `preonly`, *every* coupling iteration is an **outer** iteration — and the outer loop +is where GMRES's restart lives. A problem needing more outer iterations than +`ksp_gmres_restart` (PETSc default 30) discards its Krylov space and stagnates, exactly +where a deep residual is being ground out. Under a pressure Krylov the outer count stays +at one or two, so the restart never bites there; it moves into the pressure KSP, which can +be given its own. + +A `preonly` configuration therefore wants a **flexible outer method and a generous +restart**. Note also that the outer preconditioner *varies between applications* whenever +a sub-block is itself a Krylov solve — which a non-flexible GMRES assumes away. +``` + +```{tip} Report the outer iteration count +`solve_report.sub["velocity"].its` is the total multigrid cycle count — a **cost** measure. +A restart stagnation looks like a large **outer** count (`solve_report.ksp_its`) with a +poor residual, and is invisible in the velocity total. When diagnosing a Stokes solve that +grinds, print both, plus `stokes.snes.getKSP().getConvergedReason()`. +``` + +### Inner tolerances are deliberately inexact, and bounded + +The inner solves are inexact on purpose — you do not solve the velocity block exactly in +order to apply the Schur complement. Two consequences hold together: + +1. inexact inner solves perturb the outer search directions, so **the outer Krylov must be + flexible**; and +2. the inexactness is **bounded** — the inner solves must still converge well below the + tolerance demanded of the outer solve. The `0.033` and `0.1` factors are that margin. + +"Flexible or not" and "how tight is the inner tolerance" are two halves of one decision, +not independent axes: flexibility buys tolerance of inexactness, and the margin bounds how +inexact. `preonly` is the degenerate end — no tolerance, hence no margin — which is why it +belongs to a design where the *outer* loop is doing the converging. + +Both `fieldsplit_velocity_ksp_rtol` and `fieldsplit_pressure_ksp_rtol` are settable, and +setting `tolerance` re-derives them from it — so set `tolerance` first, then any override. + +```{warning} `preonly` under the Schur is a different matter +`preonly` on the **pressure** block is a design choice, as described above. `preonly` on +the **velocity** block, under `schur_fact_type = full`, is a defect: PCFieldSplit applies +$A^{-1}$ *through* the velocity sub-KSP when forming the Schur action, so an inexact +velocity solve hands the pressure Krylov a different operator than the one its +preconditioner was built for. +``` + ## Critical Stability Note ```{warning} Solver Stability is Paramount From 954ff4f3952d51d65fd37f3fcb741eb636102afb Mon Sep 17 00:00:00 2001 From: lmoresi Date: Wed, 29 Jul 2026 20:23:21 +1000 Subject: [PATCH 29/30] fix(solver): make Eisenstat-Walker switchable Third instance of the #477 pattern, found by the fix for the first two: the EW flags were re-pushed by `_reassert_outer_tolerances()` on every solve, so `snes_ksp_ew` could not be turned off. solve() never changes them, so they stay in the options DB from the `tolerance` setter and setFromOptions picks them up regardless -- the re-assert bought nothing and cost the ability to disable the feature. It matters on this knob more than most. EW re-picks the OUTER KSP rtol at every Newton step and OVERRIDES whatever `ksp_rtol` was set (rtol_0 = 0.3 on the first iteration), so "how hard is the linear solve actually being asked to work" is not an answerable question while EW cannot be switched off. That question is live: at the Spiegelman notch's eta*V = 5e23 cliff every linear component converges and the NONLINEAR iteration still stalls at |F|/|F0| ~ 0.6, and one candidate explanation is a chain of deliberately-undersolved Newton directions. Defaults unchanged (outer rtol 0.3 = EW rtol_0); with EW off a user-set ksp_rtol = 1e-3 now arrives. Underworld development team with AI support from Claude Code --- src/underworld3/cython/petsc_generic_snes_solvers.pyx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/underworld3/cython/petsc_generic_snes_solvers.pyx b/src/underworld3/cython/petsc_generic_snes_solvers.pyx index 1a6472a41..a9b9ccb32 100644 --- a/src/underworld3/cython/petsc_generic_snes_solvers.pyx +++ b/src/underworld3/cython/petsc_generic_snes_solvers.pyx @@ -6330,9 +6330,14 @@ class SNES_Stokes_SaddlePt(SolverBaseClass): are deliberately excluded: they belong to whoever set them last, which may be the user (#477). Overwriting them here is what made them unsettable.""" self.petsc_options["snes_rtol"] = self._tolerance - self.petsc_options["snes_ksp_ew"] = None - self.petsc_options["snes_ksp_ew_version"] = 3 self.petsc_options["ksp_atol"] = self._tolerance * 1.0e-6 + # The Eisenstat-Walker flags are NOT re-asserted here. solve() never changes + # them, so they stay in the options DB from the `tolerance` setter and + # setFromOptions picks them up regardless — while re-asserting would make + # `snes_ksp_ew` impossible to switch OFF, which is the same defect as #477 on a + # knob that matters: EW re-picks the outer KSP rtol every Newton step and + # OVERRIDES ksp_rtol, so "how hard is the linear solve actually being asked to + # work" is not answerable without being able to disable it. @property From 5d5a59e60a3d996b2166c6a1ba50c615a03be7f4 Mon Sep 17 00:00:00 2001 From: lmoresi Date: Thu, 30 Jul 2026 20:59:28 +1000 Subject: [PATCH 30/30] viscosity_min_rounding: give the floor its own rounding scale, decoupled from delta (#472 workaround) A floor is a corner; the only rounding previously available came from yield_mode (zero under "min", delta*|floor| under the smooth modes) so the smoothing vanished exactly where a yield homotopy lands. The notch drivers set a few per cent of the floor and the cutoff becomes usable with the consistent tangent at any delta. Underworld development team with AI support from Claude Code --- src/underworld3/constitutive_models.py | 56 ++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/src/underworld3/constitutive_models.py b/src/underworld3/constitutive_models.py index 752026a45..4aa859b8e 100644 --- a/src/underworld3/constitutive_models.py +++ b/src/underworld3/constitutive_models.py @@ -1157,7 +1157,44 @@ def yield_smoother(self, value): # 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): + @property + def viscosity_min_rounding(self): + r"""Rounding scale :math:`\epsilon` for the VISCOSITY floor, in viscosity units. + + A floor is a corner, and at :math:`\epsilon = 0` the smooth-max expression is + algebraically exactly ``Max`` — non-differentiable, which the consistent-Newton + tangent cannot cope with (it dies at ``nl=0, DIVERGED_LINEAR_SOLVE``). Without + this property the only rounding available came from ``yield_mode``: zero under + ``"min"``, and :math:`\delta\,|floor|` under the smooth modes, so the smoothing + **vanished as δ → 0** — exactly where a yield homotopy lands. + + The floor is a different corner from the yield surface and needs a scale of its + own. Set this to a small fraction of the floor (a few per cent is usually ample) + and the cutoff becomes usable with ``consistent_jacobian=True`` at any δ, + including δ = 0. + + ``None`` (the default) keeps the historical ``yield_mode``-derived behaviour, so + nothing changes for existing models. + + Notes + ----- + This matters for more than differentiability. A viscosity floor bounds how weak + yielded material can become, and therefore bounds the viscosity CONTRAST the + solution can develop — which is to say it bounds how localised the solution can + be. A floor that is relaxed toward zero is a continuation from a diffuse, + uniquely-solvable viscous problem toward the localised (near rigid-plastic) + limit, tracking one branch as it sharpens. That is a solution-SELECTION + homotopy, not a smoothness fix, and it is why the floor needs to be usable + independently of δ. + """ + return getattr(self, "_viscosity_min_rounding", None) + + @viscosity_min_rounding.setter + def viscosity_min_rounding(self, value): + self._viscosity_min_rounding = value + self._reset() + + def _apply_floor(self, value, floor, rounding=None): r"""Impose the lower bound :math:`value \ge floor`. In ``yield_mode="min"`` this is the exact hard ``sympy.Max(value, floor)`` — @@ -1184,8 +1221,9 @@ def _apply_floor(self, value, floor): # 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 + if rounding is None: + 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 @@ -1469,7 +1507,8 @@ def viscosity(self): if inner_self.shear_viscosity_min.sym != -sympy.oo: self._plastic_eff_viscosity._sym = self._apply_floor( - effective_viscosity, inner_self.shear_viscosity_min + effective_viscosity, inner_self.shear_viscosity_min, + rounding=self.viscosity_min_rounding, ) else: @@ -2146,6 +2185,15 @@ def viscosity(self): # BDF-2 Jacobian. Those modes are already smooth and bounded. if inner_self.shear_viscosity_min.sym != -sympy.oo: + rounding = self.viscosity_min_rounding + if rounding is not None: + # An explicit rounding scale makes the cutoff differentiable, which is + # what the skip below existed to avoid needing: there is no outer Max to + # nest, so the floor can be honoured in the smooth yield modes too. + return self._apply_floor( + effective_viscosity, inner_self.shear_viscosity_min, + rounding=rounding, + ) if self.is_viscoplastic and self._yield_mode in ("harmonic", "softmin"): return effective_viscosity else: