"""This module defines boundary conditions.
Boundary conditions are used to specify the behavior of the solution on the boundary of the domain.
The boundary conditions can be Dirichlet, Neumann, or Robin boundary conditions.
Dirichlet boundary conditions are used to specify the solution on the boundary of the domain.
Neumann boundary conditions are used to specify the traction on the boundary of the domain.
Robin boundary conditions are used to specify a Robin type boundary condition
on the boundary of the domain. A pericardium boundary condition ties a surface to a fixed
surface around it, but lets it slide along that surface.
The boundary conditions are collected in a `BoundaryConditions` object.
"""
import logging
import math
import typing
from dataclasses import dataclass
from enum import Enum
import dolfinx
import ufl
from .surface import PericardialSurface, SampledSurface, TrackedSurface
from .units import Variable
logger = logging.getLogger(__name__)
[docs]
class RobinNormal(str, Enum):
r"""Which surface normal a :class:`RobinBC` spring or dashpot acts along.
Both measure the spring's extension by projecting the displacement (or
velocity) onto a normal, so neither is the true distance to the surface the
spring is anchored to.
``current``
The normal :math:`\mathbf{n}` and area :math:`da` of the current
configuration, pushed forward with Nanson's formula,
:math:`\mathbf{t} = k (\mathbf{u} \cdot \mathbf{n}) \mathbf{n}`. It
derives from no energy. It follows the surface as it deforms, so it
also resists displacement that turns normal as the wall rotates.
``reference``
The normal :math:`\mathbf{N}` and area :math:`dA` of the reference
configuration, :math:`\mathbf{t} = k (\mathbf{u} \cdot \mathbf{N}) \mathbf{N}`
(Pfaller et al. 2019, eqs. 4-5). It is the derivative of the energy
:math:`\frac{1}{2} \int k (\mathbf{u} \cdot \mathbf{N})^2 \, dA`, so its
Jacobian is symmetric. It assumes small rotations of the surface. Under
large deformation it lets a free base flare: in the fixed-point unloader
demo, the same springs nearly double the inflation, and the unloaded
base comes out wider than the loaded one.
Leaving :attr:`RobinBC.normal` unset keeps what pulse did before the option
existed, which the demos and templates were tuned with: springs act along
``current``, dashpots along ``reference``.
On a curved surface both forms register pure tangential sliding as a
change in the normal gap, of second order in the rotation. So a stiff
epicardial spring holds back ventricular twist. ``reference`` does so
more than ``current``.
"""
reference = "reference"
current = "current"
[docs]
def nanson(F: ufl.core.expr.Expr, N: ufl.core.expr.Expr):
r"""Push the unit normal ``N`` through ``F``: :math:`\mathbf{n}\, da = J \mathbf{F}^{-T}
\mathbf{N}\, dA`. Returns the unit normal :math:`\mathbf{n}` and the area ratio
:math:`da / dA`."""
cof_N = ufl.det(F) * ufl.inv(F).T * N
ratio = ufl.sqrt(ufl.dot(cof_N, cof_N))
return cof_N / ratio, ratio
[docs]
@dataclass(slots=True)
class NeumannBC:
traction: Variable
marker: int
def __post_init__(self):
if not isinstance(self.traction, Variable):
unit = "kPa"
logger.warning("Traction is not a Variable, defaulting to kPa")
self.traction = Variable(self.traction, unit)
logger.debug(f"Created NeumannBC on marker {self.marker} with traction {self.traction}")
[docs]
@dataclass(slots=True)
class RobinBC:
"""A spring (``damping=False``) or dashpot (``damping=True``) on the facets ``marker``.
It acts along the surface normal, or, with ``perpendicular=True``, in the tangent plane.
``normal`` chooses the current or the reference normal; see :class:`RobinNormal`. Left
unset, it is ``current`` for a spring and ``reference`` for a dashpot, as before the
option existed. The spring is at rest in the reference configuration.
"""
value: Variable
marker: int
damping: bool = False
perpendicular: bool = False
normal: RobinNormal | None = None
def __post_init__(self):
if not isinstance(self.value, Variable):
unit = "Pa s / m" if self.damping else "Pa / m"
logger.warning(f"Value is not a Variable, defaulting to {unit}")
self.value = Variable(self.value, unit)
if self.normal is None:
normal = RobinNormal.reference if self.damping else RobinNormal.current
else:
normal = RobinNormal(self.normal)
self.normal = normal
logger.debug(
f"Created RobinBC on marker {self.marker} with value {self.value} "
f"({'damping' if self.damping else 'stiffness'}, {normal.value} normal)",
)
[docs]
def projection(
self,
N: ufl.core.expr.Expr,
F: ufl.core.expr.Expr,
mesh_is_reference: bool = True,
):
"""Return the projector this BC acts with and the area ratio from the mesh to it.
``N`` is the unit normal of the mesh. ``F`` is the deformation gradient from the mesh
to the other configuration: the current one in a forward problem, and the reference
one in the inverse (prestress) problem, where ``mesh_is_reference=False``. Integrate
the traction against the mesh's ``ds`` times the returned ratio.
"""
if (self.normal == RobinNormal.reference) == mesh_is_reference:
n, ratio = N, 1.0
else:
n, ratio = nanson(F, N)
nn = ufl.outer(n, n)
if self.perpendicular:
return ufl.Identity(nn.ufl_shape[0]) - nn, ratio
return nn, ratio
[docs]
@dataclass(slots=True)
class PericardiumBC:
r"""Springs that hold the boundary ``marker`` at its distance from a fixed ``surface``,
and let it slide along it without friction.
The gap is the change in signed distance :math:`d` to the surface since the reference
configuration, :math:`g = d(\mathbf{x}) - d(\mathbf{X})`, with
:math:`\mathbf{x} = \mathbf{X} + \mathbf{u}`. The springs store
:math:`W = \frac{1}{2} \int k g^2 \, dA`, so the traction is
:math:`\mathbf{t} = k g \nabla d(\mathbf{x})` per reference area, and the Jacobian is
symmetric.
A :class:`RobinBC` measures the gap by projecting :math:`\mathbf{u}` onto a normal
instead. On a curved surface that counts tangential sliding as a normal gap, so stiff
springs hold back twist, or, along the current normal, let the boundary drift away from
the surface. Here a boundary that slides along the surface stays at the same distance
from it and feels no force; for example an LV rotating about the long axis of an
:class:`~pulse.surface.EllipsoidSurface`. Subtracting :math:`d(\mathbf{X})` puts the
springs at rest in the reference configuration, wherever the mesh's facets lie relative
to the surface.
``surface`` is either a :class:`~pulse.surface.PericardialSurface`, which gives the
distance as a UFL expression, like the ``EllipsoidSurface``, or a
:class:`~pulse.surface.SampledSurface`, which computes it point by point, like the
:class:`~pulse.surface.MeshSurface` through the marked facets of a mesh. With a sampled
surface the integral is a sum over the nodes of a degree 2 Lagrange space on the
boundary; see :class:`~pulse.surface.TrackedSurface`.
So the springs do not hold a rotation that maps the surface onto itself. Hold it
elsewhere, for example with springs in the plane of the base (``RobinBC`` with
``perpendicular=True``). Left free, the problem is singular: a solve may still converge,
but the :class:`~pulse.unloading.FixedPointUnloader` does not.
``damping`` adds a dashpot :math:`c \dot{g} \nabla d(\mathbf{x})`, with
:math:`\dot{g} = \nabla d(\mathbf{x}) \cdot \dot{\mathbf{u}}`, in a
:class:`~pulse.problem.DynamicProblem`; other problems ignore it. ``unilateral`` makes
the springs and dashpot act only where the boundary is outside its reference distance,
:math:`g > 0`, so it can come away from the surface freely. In systole the heart moves
inward, so a unilateral pericardium then does nothing.
The unilateral switch :math:`\max(g, 0)` has a kink at :math:`g = 0`, and a boundary that
crosses its reference distance there, as the epicardium does in early systole, can stall
Newton. ``smoothing``, a positive length :math:`\epsilon`, rounds the kink off: the
springs store :math:`W = \int k \Phi(g) \, dA` with :math:`\Phi' = r`, where
:math:`r(g) = 0` for :math:`g \le 0`, :math:`g^2 / 2\epsilon` for
:math:`0 < g < \epsilon` and
:math:`g - \epsilon / 2` beyond. So the traction :math:`k\, r(g) \nabla d` is once
differentiable, still zero in the reference configuration, and short of the unsmoothed
one by :math:`k \epsilon / 2` once the gap passes :math:`\epsilon`. The dashpot is gated
by :math:`r'(g)`, which goes from 0 to 1 across the same band. It applies only with
``unilateral=True``; ``None`` keeps the kink.
In the inverse problem (:class:`~pulse.unloading.PrestressProblem`) the mesh is the
loaded configuration, and the springs are still at rest in the unloaded one, as a
:class:`RobinBC`'s are.
"""
stiffness: Variable
marker: int
surface: PericardialSurface | SampledSurface
damping: Variable | None = None
unilateral: bool = False
smoothing: Variable | None = None
def __post_init__(self):
if not isinstance(self.stiffness, Variable):
logger.warning("Stiffness is not a Variable, defaulting to Pa / m")
self.stiffness = Variable(self.stiffness, "Pa / m")
if self.damping is not None and not isinstance(self.damping, Variable):
logger.warning("Damping is not a Variable, defaulting to Pa s / m")
self.damping = Variable(self.damping, "Pa s / m")
if self.smoothing is not None:
if not self.unilateral:
raise ValueError("smoothing applies only to a unilateral PericardiumBC")
if not isinstance(self.smoothing, Variable):
logger.warning("Smoothing is not a Variable, defaulting to m")
self.smoothing = Variable(self.smoothing, "m")
# Zero divides by zero, and a negative band makes the traction jump at g = 0
value = self.smoothing.value
if isinstance(value, dolfinx.fem.Constant):
value = value.value
if not isinstance(value, dolfinx.fem.Function) and not float(value) > 0:
raise ValueError(
f"smoothing must be a positive length, not {self.smoothing}; "
"leave it unset to keep the kink",
)
logger.debug(
f"Created PericardiumBC on marker {self.marker} with stiffness {self.stiffness}, "
f"damping {self.damping}, unilateral={self.unilateral}, "
f"smoothing {self.smoothing}, surface {self.surface}",
)
[docs]
def traction(
self,
x: ufl.core.expr.Expr,
X: ufl.core.expr.Expr,
scale: float = 1.0,
velocity: ufl.core.expr.Expr | None = None,
surface: PericardialSurface | None = None,
) -> ufl.core.expr.Expr:
"""The traction per reference area on the material point at ``X``, now at ``x``.
``scale`` converts the mesh unit to metres, as for a :class:`RobinBC`. ``velocity``
is the point's velocity, for the dashpot; without it, there is none. ``surface``
gives the distance instead of the surface of this condition: its
:class:`~pulse.surface.TrackedSurface`, if that is a sampled one (see
:func:`pericardial_surface`).
"""
if surface is None:
if not hasattr(self.surface, "signed_distance"):
raise TypeError(
f"{self.surface} gives no distance as a UFL expression; pass the "
"TrackedSurface that pericardial_surface makes of it",
)
surface = typing.cast(PericardialSurface, self.surface)
xv = ufl.variable(x)
d = surface.signed_distance(xv)
n = ufl.diff(d, xv)
g = d - surface.signed_distance(X)
k = self.stiffness.to_base_units() * scale
if self.unilateral and self.smoothing is not None:
# In mesh units, as g is
eps = self.smoothing.to_base_units() / scale
spring = ufl.conditional(
ufl.le(g, 0.0),
0.0,
ufl.conditional(ufl.ge(g, eps), g - eps / 2, g**2 / (2 * eps)),
)
gate = ufl.max_value(ufl.min_value(g / eps, 1.0), 0.0)
elif self.unilateral:
spring = ufl.max_value(g, 0.0)
gate = ufl.conditional(ufl.gt(g, 0.0), 1.0, 0.0)
else:
spring = g
gate = 1.0
t = k * spring * n
if self.damping is not None and velocity is not None:
c = self.damping.to_base_units() * scale
t += c * gate * ufl.dot(n, velocity) * n
return t
[docs]
def pericardial_surface(
bc: PericardiumBC,
mesh: dolfinx.mesh.Mesh,
facet_tags: dolfinx.mesh.MeshTags | None,
ds: ufl.Measure,
) -> tuple[PericardialSurface, ufl.Measure]:
"""What a problem on ``mesh`` builds the forms of ``bc`` with: the surface to pass to
:meth:`PericardiumBC.traction`, and the measure to integrate the traction with over
``bc.marker``.
They are the surface of ``bc`` and ``ds``, unless the surface is a sampled one. Then
they are a new :class:`~pulse.surface.TrackedSurface` of it and its measure, and the
problem has to update the tracked surface before it assembles anything.
"""
if hasattr(bc.surface, "signed_distance"):
return typing.cast(PericardialSurface, bc.surface), ds
if facet_tags is None:
raise ValueError("A PericardiumBC with a sampled surface needs facet tags")
tracked = TrackedSurface(
typing.cast(SampledSurface, bc.surface),
mesh,
facet_tags.find(bc.marker),
ds,
)
return tracked, tracked.ds
[docs]
def check_pericardium(
bc: PericardiumBC,
mesh: dolfinx.mesh.Mesh,
ds: ufl.Measure,
surface: PericardialSurface | None = None,
) -> None:
"""Warn if the surface of ``bc`` does not look like the boundary it holds: if the
boundary is far from it, or its normal points elsewhere. A surface in the wrong unit, or
around the wrong boundary, gives no error otherwise, since the springs are at rest
wherever the boundary starts. ``surface`` and ``ds`` are those that
:func:`pericardial_surface` gives."""
from mpi4py import MPI
if surface is None:
surface = typing.cast(PericardialSurface, bc.surface)
X = ufl.SpatialCoordinate(mesh)
Xv = ufl.variable(X)
d = surface.signed_distance(Xv)
N = ufl.FacetNormal(mesh)
def integrate(f):
value = dolfinx.fem.assemble_scalar(dolfinx.fem.form(f * ds(bc.marker)))
return mesh.comm.allreduce(value, op=MPI.SUM)
area = integrate(dolfinx.fem.Constant(mesh, dolfinx.default_scalar_type(1.0)))
if area == 0:
raise ValueError(f"PericardiumBC marker {bc.marker} has no facets")
rms = math.sqrt(integrate(d**2) / area)
alignment = integrate(ufl.dot(ufl.diff(d, Xv), N)) / area
logger.debug(
f"PericardiumBC on marker {bc.marker}: RMS distance to the surface {rms:.3g}, "
f"mean alignment of the normals {alignment:.3f}",
)
if rms > 0.1 * math.sqrt(area) or abs(alignment - 1) > 0.1:
logger.warning(
f"The surface of the PericardiumBC on marker {bc.marker} does not match the "
f"boundary: the RMS distance between them is {rms:.3g} mesh units (area "
f"{area:.3g}), and the mean dot product of their normals {alignment:.3f}. Is the "
"surface in mesh units, and around this boundary?",
)
[docs]
class BoundaryConditions(typing.NamedTuple):
neumann: typing.Sequence[NeumannBC] = ()
dirichlet: typing.Sequence[
typing.Callable[
[dolfinx.fem.FunctionSpace],
typing.Sequence[dolfinx.fem.bcs.DirichletBC],
]
] = ()
robin: typing.Sequence[RobinBC] = ()
body_force: typing.Sequence[float | dolfinx.fem.Constant | dolfinx.fem.Function] = ()
pericardium: typing.Sequence[PericardiumBC] = ()