Source code for ldrb.doste

r"""Rule-based fibers for biventricular geometries with outflow tracts.

This is the outflow tract extended rule-based method (OT-RBM) from

    Doste R, Soto-Iglesias D, Bernardino G, et al. A rule-based method to
    model myocardial fiber orientation in cardiac biventricular geometries
    with outflow tracts. Int J Numer Meth Biomed Engng. 2019;35:e3185.
    https://doi.org/10.1002/cnm.3185

Unlike the method of Bayer et al. in :func:`ldrb.dolfinx_ldrb`, the
longitudinal direction is defined separately in each ventricle, as a blend of
the direction from the apex to the inflow valve (mitral or tricuspid) and the
direction from the apex to the outflow valve (aortic or pulmonary). The blend
also interpolates the fiber angles between the values in the ventricle and in
the outflow tract.

The angles follow the convention of :func:`ldrb.dolfinx_ldrb`: the
longitudinal direction points from the apex towards the valves, the
transmural direction points from the endocardium to the epicardium in both
ventricles, and :math:`\alpha` is the angle from the circumferential
direction towards the longitudinal direction. In this convention the values
in the paper are, with the opposite sign of the paper's LV values,

.. math::

    \alpha_{\text{endo}} = 60, \alpha_{\text{epi}} = -60 \quad \text{(LV)}, \qquad
    \alpha_{\text{endo}} = 90, \alpha_{\text{epi}} = -25 \quad \text{(RV)}, \qquad
    \alpha_{\text{endo}} = 90, \alpha_{\text{epi}} = 0 \quad \text{(outflow tracts)}.

The transverse angle :math:`\beta` rotates the fiber about the longitudinal
direction, as in the paper, and its values here also have the opposite sign
of the paper's. The septal interpolation of the paper (Section 2.3.2) is not
implemented.
"""

from __future__ import annotations

import logging
from typing import Any, Sequence

from mpi4py import MPI

import dolfinx
import numba
import numpy as np
import ufl
from dolfinx.fem.petsc import LinearProblem
from packaging.version import Version

from . import io, utils
from .calculus import axis, normalize
from .ldrb import (
    array_to_function,
    default_petsc_options,
    dofs_from_function_space,
    process_markers,
    project_gradients,
)

_dolfinx_version = Version(dolfinx.__version__)

logger = logging.getLogger(__name__)


def _facets(ffun: dolfinx.mesh.MeshTags, markers: list[int]) -> np.ndarray:
    return np.hstack([ffun.find(marker) for marker in markers]).astype(np.int32)


def _facet_points(mesh: dolfinx.mesh.Mesh, facets: np.ndarray) -> np.ndarray:
    """The coordinates of the geometry nodes of `facets` (with duplicates)."""
    fdim = mesh.topology.dim - 1
    nodes = dolfinx.mesh.entities_to_geometry(mesh, fdim, facets)
    return mesh.geometry.x[nodes.reshape(-1)]


[docs] def find_apex( mesh: dolfinx.mesh.Mesh, ffun: dolfinx.mesh.MeshTags, endo_markers: list[int], valve_markers: list[int], ) -> np.ndarray: """Find the apex as the point on the endocardium farthest from the centre of the valve. Arguments --------- mesh : dolfinx.mesh.Mesh The mesh ffun : dolfinx.mesh.MeshTags A facet function containing markers for the boundaries. endo_markers : list[int] The markers of the endocardium of the ventricle valve_markers : list[int] The markers of the inflow valve of the ventricle (mitral or tricuspid) Returns ------- np.ndarray The coordinates of the apex """ comm = mesh.comm mesh.topology.create_connectivity(mesh.topology.dim - 1, mesh.topology.dim) valve_points = _facet_points(mesh, _facets(ffun, valve_markers)) total = comm.allreduce(valve_points.sum(axis=0), op=MPI.SUM) count = comm.allreduce(len(valve_points), op=MPI.SUM) if count == 0: raise ValueError(f"No facets marked with {valve_markers}") valve_centre = total / count endo_points = _facet_points(mesh, _facets(ffun, endo_markers)) if len(endo_points) > 0: distance = np.linalg.norm(endo_points - valve_centre, axis=1) local = (float(distance.max()), comm.rank) else: local = (-np.inf, comm.rank) max_distance, owner = comm.allreduce(local, op=MPI.MAXLOC) if max_distance == -np.inf: raise ValueError(f"No facets marked with {endo_markers}") apex = endo_points[distance.argmax()] if comm.rank == owner else None return np.asarray(comm.bcast(apex, root=owner), dtype=np.float64)
def _solve_laplace( V: dolfinx.fem.FunctionSpace, conditions: list[tuple[np.ndarray, float]], petsc_options: dict[str, Any], name: str, ) -> dolfinx.fem.Function: """Solve the Laplace equation with the Dirichlet conditions given as a list of (dofs, value). The rest of the boundary has zero Neumann conditions.""" mesh = V.mesh u = ufl.TrialFunction(V) v = ufl.TestFunction(V) a = ufl.dot(ufl.grad(u), ufl.grad(v)) * ufl.dx zero = dolfinx.fem.Constant(mesh, dolfinx.default_scalar_type(0.0)) L = v * zero * ufl.dx bcs = [ dolfinx.fem.dirichletbc(dolfinx.default_scalar_type(value), dofs, V) for dofs, value in conditions ] kwargs = {} if _dolfinx_version >= Version("0.10"): kwargs["petsc_options_prefix"] = f"ldrb_doste_{name}" problem = LinearProblem( # type: ignore[call-overload] a, L, bcs=bcs, petsc_options=petsc_options, **kwargs, # type: ignore[arg-type] ) uh = problem.solve() assert isinstance(uh, dolfinx.fem.Function) uh.name = name return uh
[docs] def scalar_laplacians( mesh: dolfinx.mesh.Mesh, ffun: dolfinx.mesh.MeshTags, markers: dict[str, list[int]], apex_lv: Sequence[float] | np.ndarray, apex_rv: Sequence[float] | np.ndarray | None = None, petsc_options: dict[str, Any] | None = None, ) -> dict[str, dolfinx.fem.Function]: """Solve the Laplace problems of the method, with Lagrange elements of the same degree as the geometry. Returns the transmural solution ``phi`` (-2 on the LV endocardium, 1 on the RV endocardium and 0 on the epicardium), and for each ventricle (``lv`` and, if there is an RV, ``rv``) the solutions ``psi_{ventricle}_basal`` and ``psi_{ventricle}_ot`` (0 at the apex and 1 at the inflow and outflow valve respectively) and the interpolation function ``w_{ventricle}`` (1 at the apex and the inflow valve, 0 at the outflow valve). For biventricular geometries it also returns ``lv_rv`` (1 on the LV endocardium and 0 on the RV endocardium), which decides the ventricle where ``phi`` is zero, i.e. on the epicardium. """ if petsc_options is None: petsc_options = default_petsc_options() V = utils.laplace_space(mesh) fdim = mesh.topology.dim - 1 mesh.topology.create_connectivity(fdim, mesh.topology.dim) def dofs(name: str) -> np.ndarray: return dolfinx.fem.locate_dofs_topological(V, fdim, _facets(ffun, markers[name])) transmural = [(dofs("lv"), -2.0), (dofs("epi"), 0.0)] ventricles = {"lv": ("mv", "av", apex_lv)} if "rv" in markers: assert apex_rv is not None transmural.append((dofs("rv"), 1.0)) ventricles["rv"] = ("tv", "pv", apex_rv) solutions = {"phi": _solve_laplace(V, transmural, petsc_options, "phi")} if "rv" in markers: solutions["lv_rv"] = _solve_laplace( V, [(dofs("lv"), 1.0), (dofs("rv"), 0.0)], petsc_options, "lv_rv" ) for ventricle, (inflow, outflow, apex) in ventricles.items(): apex_dofs = utils.closest_dofs(V, apex) inflow_dofs = dofs(inflow) outflow_dofs = dofs(outflow) solutions[f"psi_{ventricle}_basal"] = _solve_laplace( V, [(apex_dofs, 0.0), (inflow_dofs, 1.0)], petsc_options, f"psi_{ventricle}_basal", ) solutions[f"psi_{ventricle}_ot"] = _solve_laplace( V, [(apex_dofs, 0.0), (outflow_dofs, 1.0)], petsc_options, f"psi_{ventricle}_ot", ) solutions[f"w_{ventricle}"] = _solve_laplace( V, [(apex_dofs, 1.0), (inflow_dofs, 1.0), (outflow_dofs, 0.0)], petsc_options, f"w_{ventricle}", ) return solutions
@numba.njit def _rotate(u: np.ndarray, k: np.ndarray, angle: float) -> np.ndarray: """Rotate `u` counterclockwise about the unit vector `k` by `angle` (degrees).""" c = np.cos(np.radians(angle)) s = np.sin(np.radians(angle)) return c * u + s * np.cross(k, u) + (1 - c) * k.dot(u) * k
[docs] @numba.njit def compute_fiber_sheet_system( f0, s0, n0, marker_scalar, xdofs, ydofs, zdofs, sdofs, phi_scalar, phi_gradient, lv_rv_scalar, w_lv_scalar, psi_lv_basal_gradient, psi_lv_ot_gradient, w_rv_scalar, psi_rv_basal_gradient, psi_rv_ot_gradient, angles, has_rv, ): """Compute the fiber, sheet and sheet-normal directions at each dof. `angles` has shape (2, 2, 4): ventricle (LV, RV), region (ventricle, outflow tract) and angle (alpha endo, alpha epi, beta endo, beta epi). """ grad_phi = np.zeros(3) grad_basal = np.zeros(3) grad_ot = np.zeros(3) for i in range(len(xdofs)): phi = phi_scalar[sdofs[i]] grad_phi[0] = phi_gradient[xdofs[i]] grad_phi[1] = phi_gradient[ydofs[i]] grad_phi[2] = phi_gradient[zdofs[i]] if has_rv and abs(phi) < 1e-10: # On the epicardium, where phi is zero in both ventricles in_lv = lv_rv_scalar[sdofs[i]] >= 0.5 else: in_lv = phi < 0 or not has_rv if in_lv: # LV, where phi goes from -2 on the endocardium to 0 ventricle = 0 depth = 1 + phi / 2 w = w_lv_scalar[sdofs[i]] grad_basal[0] = psi_lv_basal_gradient[xdofs[i]] grad_basal[1] = psi_lv_basal_gradient[ydofs[i]] grad_basal[2] = psi_lv_basal_gradient[zdofs[i]] grad_ot[0] = psi_lv_ot_gradient[xdofs[i]] grad_ot[1] = psi_lv_ot_gradient[ydofs[i]] grad_ot[2] = psi_lv_ot_gradient[zdofs[i]] transmural = grad_phi.copy() else: # RV, where phi goes from 1 on the endocardium to 0 ventricle = 1 depth = 1 - phi w = w_rv_scalar[sdofs[i]] grad_basal[0] = psi_rv_basal_gradient[xdofs[i]] grad_basal[1] = psi_rv_basal_gradient[ydofs[i]] grad_basal[2] = psi_rv_basal_gradient[zdofs[i]] grad_ot[0] = psi_rv_ot_gradient[xdofs[i]] grad_ot[1] = psi_rv_ot_gradient[ydofs[i]] grad_ot[2] = psi_rv_ot_gradient[zdofs[i]] transmural = -grad_phi depth = min(max(depth, 0.0), 1.0) w = min(max(w, 0.0), 1.0) marker_scalar[sdofs[i]] = ventricle + 1 # Angles at the endocardium and epicardium, interpolated between the # ventricle (w = 1) and the outflow tract (w = 0), and then transmurally a = w * angles[ventricle, 0] + (1 - w) * angles[ventricle, 1] alpha = a[0] * (1 - depth) + a[1] * depth beta = a[2] * (1 - depth) + a[3] * depth longitudinal = w * grad_basal + (1 - w) * grad_ot # Columns: circumferential, longitudinal and transmural directions Q = axis(longitudinal, transmural) e_c = Q[:, 0].copy() e_l = Q[:, 1].copy() e_t = Q[:, 2].copy() fiber = np.cos(np.radians(alpha)) * e_c + np.sin(np.radians(alpha)) * e_l fiber = normalize(_rotate(fiber, e_l, beta)) sheet = normalize(e_t - e_t.dot(fiber) * fiber) normal = np.cross(fiber, sheet) f0[xdofs[i]] = fiber[0] f0[ydofs[i]] = fiber[1] f0[zdofs[i]] = fiber[2] s0[xdofs[i]] = sheet[0] s0[ydofs[i]] = sheet[1] s0[zdofs[i]] = sheet[2] n0[xdofs[i]] = normal[0] n0[ydofs[i]] = normal[1] n0[zdofs[i]] = normal[2]
[docs] def dolfinx_doste( mesh: dolfinx.mesh.Mesh, ffun: dolfinx.mesh.MeshTags, markers: dict[str, list[int]] | dict[str, tuple[int, ...]] | dict[str, int], fiber_space: str = "P_1", apex_lv: Sequence[float] | None = None, apex_rv: Sequence[float] | None = None, alpha_endo_lv: float = 60, alpha_epi_lv: float = -60, beta_endo_lv: float = 20, beta_epi_lv: float = -20, alpha_endo_rv: float = 90, alpha_epi_rv: float = -25, beta_endo_rv: float = 0, beta_epi_rv: float = -20, alpha_endo_ot: float = 90, alpha_epi_ot: float = 0, beta_endo_ot: float = 0, beta_epi_ot: float = 0, petsc_options: dict[str, Any] | None = None, ) -> io.DosteOutput: r""" Create fiber, sheet and sheet-normal directions with the rule-based method for geometries with outflow tracts of Doste et al. (2019). See :mod:`ldrb.doste` for the method and the convention of the angles. Arguments --------- mesh : dolfinx.mesh.Mesh The mesh ffun : dolfinx.mesh.MeshTags A facet function containing markers for the boundaries. markers : dict[str, int | list[int]] The markers of the boundaries in `ffun`: 'lv' (LV endocardium), 'epi' (epicardium), 'mv' (mitral valve) and 'av' (aortic valve), and for biventricular geometries also 'rv' (RV endocardium), 'tv' (tricuspid valve) and 'pv' (pulmonary valve). Other boundaries get zero Neumann conditions. fiber_space : str A string on the form {family}_{degree} which determines the space of the fibers, by default "P_1". apex_lv : Sequence[float] (optional) The coordinates of the LV endocardial apex. The closest dof is used. If not provided, the point on the LV endocardium farthest from the centre of the mitral valve is used, see :func:`find_apex`. apex_rv : Sequence[float] (optional) The coordinates of the RV endocardial apex. If not provided, the point on the RV endocardium farthest from the centre of the tricuspid valve is used. alpha_endo_lv, alpha_epi_lv, beta_endo_lv, beta_epi_lv : float Fiber and transverse angles at the LV endocardium and epicardium. alpha_endo_rv, alpha_epi_rv, beta_endo_rv, beta_epi_rv : float Fiber and transverse angles at the RV endocardium and epicardium. alpha_endo_ot, alpha_epi_ot, beta_endo_ot, beta_epi_ot : float Fiber and transverse angles at the endocardium and epicardium of the outflow tracts. petsc_options : dict (optional) A dictionary with petsc options to pass to the linear solvers. If not provided, the default options will be used, which are ksp_type = preonly and pc_type = lu. """ processed_markers = process_markers(markers) required = ["lv", "epi", "mv", "av"] has_rv = "rv" in processed_markers if has_rv: required += ["rv", "tv", "pv"] missing = [name for name in required if name not in processed_markers] if missing: raise ValueError(f"Missing markers {missing}, got {list(processed_markers)}") if apex_lv is None: lv_apex = find_apex(mesh, ffun, processed_markers["lv"], processed_markers["mv"]) else: lv_apex = np.asarray(apex_lv, dtype=np.float64) logger.info(f"LV apex: {lv_apex}") rv_apex = None if has_rv: if apex_rv is None: rv_apex = find_apex(mesh, ffun, processed_markers["rv"], processed_markers["tv"]) else: rv_apex = np.asarray(apex_rv, dtype=np.float64) logger.info(f"RV apex: {rv_apex}") logger.info("Calculating scalar fields") solutions = scalar_laplacians( mesh=mesh, ffun=ffun, markers=processed_markers, apex_lv=lv_apex, apex_rv=rv_apex, petsc_options=petsc_options, ) logger.info("Calculating gradients") data, _ = project_gradients(mesh=mesh, scalar_solutions=solutions, fiber_space=fiber_space) if not has_rv: for name in ["lv_rv_scalar", "w_rv_scalar", "psi_rv_basal_gradient", "psi_rv_ot_gradient"]: reference = "w_lv_scalar" if name.endswith("scalar") else "psi_lv_basal_gradient" data[name] = np.zeros_like(data[reference]) angles = np.array( [ [ [alpha_endo_lv, alpha_epi_lv, beta_endo_lv, beta_epi_lv], [alpha_endo_ot, alpha_epi_ot, beta_endo_ot, beta_epi_ot], ], [ [alpha_endo_rv, alpha_epi_rv, beta_endo_rv, beta_epi_rv], [alpha_endo_ot, alpha_epi_ot, beta_endo_ot, beta_epi_ot], ], ], dtype=np.float64, ) dofs = dofs_from_function_space(mesh, fiber_space) f0 = np.zeros_like(data["phi_gradient"]) s0 = np.zeros_like(f0) n0 = np.zeros_like(f0) marker_scalar = np.zeros_like(data["phi_scalar"]) compute_fiber_sheet_system( f0, s0, n0, marker_scalar, dofs[:, 0], dofs[:, 1], dofs[:, 2], dofs[:, 3], data["phi_scalar"], data["phi_gradient"], data["lv_rv_scalar"], data["w_lv_scalar"], data["psi_lv_basal_gradient"], data["psi_lv_ot_gradient"], data["w_rv_scalar"], data["psi_rv_basal_gradient"], data["psi_rv_ot_gradient"], angles, has_rv, ) Vv = utils.space_from_string(fiber_space, mesh, dim=3) V = utils.space_from_string(fiber_space, mesh, dim=1) functions = { "f0": array_to_function(Vv, f0, "f0"), "s0": array_to_function(Vv, s0, "s0"), "n0": array_to_function(Vv, n0, "n0"), "markers_scalar": array_to_function(V, marker_scalar, "markers_scalar"), } # Only the owned dofs are computed above for f in functions.values(): f.x.scatter_forward() return io.DosteOutput(**functions, **solutions)