Source code for underworld3.constitutive_models

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.<attribute_name> = 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

        self.Unknowns = unknowns

        u = self.Unknowns.u
        self._DFDt = self.Unknowns.DFDt
        self._DuDt = self.Unknowns.DuDt

        self.dim = u.mesh.dim
        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)

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


[docs] 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) # ```
[docs] 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_() ) )
## 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)", ) 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: -sympy.oo, "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 if inner_self.yield_stress_min.sym != 0: yield_stress = sympy.Max(inner_self.yield_stress_min, inner_self.yield_stress) else: yield_stress = inner_self.yield_stress 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) # 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 = sympy.Max( 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 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
[docs] 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: """
[docs] 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 # 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: -sympy.oo, "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 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) # 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 @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", ) if parameters.yield_stress_min.sym != 0: yield_stress = sympy.Max( parameters.yield_stress_min, parameters.yield_stress ) # .rewrite(sympy.Piecewise) 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
[docs] 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
[docs] 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
[docs] 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 = value self._reset() @property def requires_stress_history(self): """VEP models always require stress history tracking.""" return True @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, ) ###
[docs] 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."))
[docs] class DarcyFlowModel(Constitutive_Model): r""" Darcy flow constitutive model for porous media flow. Relates the Darcy flux to pressure gradients and body forces: .. math:: q_{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`). 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)
[docs] 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) --- """
[docs] 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: -sympy.oo, "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 if parameters.yield_stress_min.sym != 0: tau_y = sympy.Max(parameters.yield_stress_min, tau_y) 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 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)
[docs] 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 """
[docs] 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) + "$"))