from __future__ import annotations
import datetime
import json
import math
import typing
from importlib.metadata import metadata
from pathlib import Path
from mpi4py import MPI
import cardiac_geometries_core as cgc
import dolfinx
import numpy as np
from structlog import get_logger
from . import utils
from .geometry import DEFAULT_IO_BACKEND, Geometry, geometry_path, save_geometry
meta = metadata("cardiac-geometriesx")
__version__ = meta["Version"]
logger = get_logger()
UKB_GEOMETRIES = ("biv", "biv-closed", "lv", "lv-closed")
def transform_markers(
markers: dict[str, tuple[int, int]], clipped: bool = False
) -> dict[str, list[int]]:
if "LV_ENDO" in markers:
# UKB geometries from ukb-atlas >= 2.0
epi = [markers["EPI"][0]]
if "RV_SEPTUM" in markers:
# The LV-only geometries are closed on the RV side by the RV septum
epi.append(markers["RV_SEPTUM"][0])
# The base is the basal cap (BASE, e.g. a patient mesh) and the valves, whichever exist:
# clipped UKB geometries have BASE only, the ones with outflow tracts the valves only
valves = [markers[valve][0] for valve in ("MV", "AV", "TV", "PV") if valve in markers]
base = ([markers["BASE"][0]] if "BASE" in markers else []) + valves
transformed = {"lv": [markers["LV_ENDO"][0]], "epi": epi, "base": base}
if "RV_ENDO" in markers:
transformed["rv"] = [markers["RV_ENDO"][0]]
return transformed
elif "ENDO_RV" in markers:
return {
"lv": [markers["ENDO_LV"][0]],
"rv": [markers["ENDO_RV"][0]],
"epi": [markers["EPI"][0]],
"base": [markers["BASE"][0]],
}
elif "PV" in markers:
return {
"lv": [markers["LV"][0]],
"rv": [markers["RV"][0]],
"epi": [markers["EPI"][0]],
"base": [
markers["PV"][0],
markers["TV"][0],
markers["AV"][0],
markers["MV"][0],
],
}
elif "ENDO_LV" in markers:
return {
"lv": [markers["ENDO_LV"][0]],
"epi": [markers["EPI"][0]],
"base": [markers["BASE"][0]],
}
elif "LV_ENDO_FW" in markers:
return {
"base": [markers["BASE"][0]],
"lv": [markers["LV_ENDO_FW"][0], markers["LV_SEPTUM"][0]],
"rv": [markers["RV_ENDO_FW"][0], markers["RV_SEPTUM"][0]],
"epi": [markers["LV_EPI_FW"][0], markers["RV_EPI_FW"][0]],
}
elif "ENDO" in markers:
return {
"lv": [markers["ENDO"][0]],
"epi": [markers["EPI"][0]],
"base": [markers["BASE"][0]],
}
elif "BASE" in markers:
return {
"lv": [markers["LV"][0]],
"rv": [markers["RV"][0]],
"epi": [markers["EPI"][0]],
"base": [markers["BASE"][0]],
}
else:
# Assume they are already transformed
return {k: [v[0]] for k, v in markers.items()}
[docs]
def doste_markers(markers: dict[str, tuple[int, int]]) -> dict[str, list[int]]:
"""Markers for the method of Doste et al. (`ldrb.dolfinx_doste`) from the
markers of the UKB geometries (ukb-atlas >= 2.0), which need the valves."""
missing = {"LV_ENDO", "EPI", "MV", "AV"} - set(markers)
if missing:
raise ValueError(
f"The method of Doste et al. needs the markers {sorted(missing)}, which are only "
"in the UKB geometries with outflow tracts (not clipped)"
)
epi = [markers["EPI"][0]]
if "RV_SEPTUM" in markers:
# The LV-only geometries are closed on the RV side by the RV septum
epi.append(markers["RV_SEPTUM"][0])
transformed = {
"lv": [markers["LV_ENDO"][0]],
"epi": epi,
"mv": [markers["MV"][0]],
"av": [markers["AV"][0]],
}
if "RV_ENDO" in markers:
transformed |= {
"rv": [markers["RV_ENDO"][0]],
"tv": [markers["TV"][0]],
"pv": [markers["PV"][0]],
}
return transformed
[docs]
def ukb(
outdir: str | Path,
mode: int = -1,
std: float = 1.5,
case: str = "ED",
char_length_max: float = 5.0,
char_length_min: float = 5.0,
fiber_angle_endo: float = 60,
fiber_angle_epi: float = -60,
create_fibers: bool = True,
fiber_space: str = "P_1",
clipped: bool = False,
use_burns: bool = False,
burns_path: Path | None = None,
refine: int = 0,
order: int = 1,
comm: MPI.Comm = MPI.COMM_WORLD,
ghost_mode: dolfinx.mesh.GhostMode = dolfinx.mesh.GhostMode.none,
geometry: str = "biv",
fiber_method: str | None = None,
io_backend: str = DEFAULT_IO_BACKEND,
aha: bool = False,
rv_regions: bool = False,
rv_septum_fraction: float | None = None,
) -> Geometry:
"""Create a mesh from the UK-Biobank atlas using
the ukb-atlas package.
Parameters
----------
outdir : str | Path
Directory where to save the results.
mode : int, optional
Mode for the UKB mesh, by default -1
std : float, optional
Standard deviation for the UKB mesh, by default 1.5
case : str, optional
Case for the UKB mesh (either "ED" or "ES"), by default "ED"
char_length_max : float, optional
Maximum characteristic length of the mesh, by default 2.0
char_length_min : float, optional
Minimum characteristic length of the mesh, by default 2.0
fiber_angle_endo : float, optional
Fiber angle for the endocardium, by default 60
fiber_angle_epi : float, optional
Fiber angle for the epicardium, by default -60
create_fibers : bool, optional
If True create rule-based fibers, by default True
fiber_space : str, optional
Function space for fibers of the form family_degree, by default "P_1"
clipped : bool, optional
If True create a clipped mesh, by default False
use_burns : bool
If true, use the atlas from Richard Burns to generate the surfaces.
This will override the `all` parameter and use the burns atlas instead.
burns_path : Path | None
Path to the burns atlas file. This will be a .mat file which will be loaded
using scipy.io.loadmat. This needs to be specified if `use_burns`.
refine : int, optional
Number of times to refine the surfaces of the atlas before meshing,
placing the new nodes on smooth surfaces through the nodes of the atlas.
This makes meshes of higher order more accurate, by default 0
order : int, optional
Order of the mesh elements. With order higher than one the elements
are curved to follow the boundaries, by default 1
comm : MPI.Comm, optional
MPI communicator, by default MPI.COMM_WORLD
ghost_mode : dolfinx.mesh.GhostMode, optional
Ghost mode for the dolfinx mesh, by default dolfinx.mesh.GhostMode.none
geometry : str, optional
Which geometry to create: biventricular ("biv") or LV only ("lv"), with
open valves, or with the valves closed by caps ("biv-closed", "lv-closed").
The closed geometries also mesh the cavities, which are marked as
"LV_CAVITY" and "RV_CAVITY" in the cell tags, while the myocardium is
marked as "Wall". Fibers are only created in the wall. By default "biv".
fiber_method : str | None, optional
Rule-based method for the fibers: "doste" for the method of Doste et al.
(2019), which handles the outflow tracts, or "ldrb" for the method of
Bayer et al. (2012). With "doste", `fiber_angle_endo` and
`fiber_angle_epi` are the angles in the LV, while the RV and outflow
tracts get the angles from the paper. By default "doste", or "ldrb" for
clipped meshes, which have no outflow tracts.
io_backend : str, optional
io4dolfinx backend used to write the geometry, "adios2" (``geometry.bp``)
or "h5py" (``geometry.h5``), by default "adios2"
aha : bool, optional
If True, the cell tags are the 17 AHA segments of the LV, the RV free wall
("RV") and the LV above the mitral plane ("LVOT"), see
:func:`cardiac_geometries.aha.biv_aha`. Only for the "biv" geometry (not
clipped), and it needs ukb-atlas >= 2.2 for the landmarks. By default False
rv_regions : bool, optional
If True (with `aha`), the RV is divided into the 15 regions of Zhong et al.
(2012) (tags 18-32) and the septum is split between the LV and the RV by a
transventricular Laplace solution; the LV above the mitral plane is 33. See
:func:`cardiac_geometries.aha.biv_regions`. By default False
rv_septum_fraction : float | None, optional
With `rv_regions`, the share of the septum, in the transventricular coordinate
from the RV side, that belongs to the RV, in [0, 1]: 0 gives the whole septum to
the LV, 1 to the RV, 0.5 splits it at the middle (Cobiveco) and 1/3 approximates
Doste et al. (2019). Only allowed with `rv_regions`. By default None, meaning 0.5
Returns
-------
cardiac_geometries.geometry.Geometry
A Geometry with the mesh, markers, markers functions and fibers.
"""
if geometry not in UKB_GEOMETRIES:
raise ValueError(f"Unknown geometry {geometry!r}, expected one of {UKB_GEOMETRIES}")
if clipped and geometry != "biv":
raise ValueError("Clipping is only supported for the 'biv' geometry")
if fiber_method is None:
fiber_method = "ldrb" if clipped else "doste"
if fiber_method not in ("ldrb", "doste"):
raise ValueError(f"Unknown fiber method {fiber_method!r}, expected 'ldrb' or 'doste'")
if clipped and fiber_method == "doste":
raise ValueError("The 'doste' fiber method needs the outflow tracts, use 'ldrb'")
if aha and (geometry != "biv" or clipped):
raise ValueError("AHA segments are only supported for the 'biv' geometry (not clipped)")
if rv_regions and not aha:
raise ValueError("rv_regions needs aha=True")
if rv_septum_fraction is not None and not rv_regions:
raise ValueError("rv_septum_fraction is only used with rv_regions=True")
if rv_regions:
rv_septum_fraction = 0.5 if rv_septum_fraction is None else float(rv_septum_fraction)
if not 0.0 <= rv_septum_fraction <= 1.0:
raise ValueError(f"rv_septum_fraction must be in [0, 1], got {rv_septum_fraction}")
try:
import ukb.cli
except ImportError as e:
msg = (
"To create the UKB mesh you need to install the ukb package "
"which you can install with pip install ukb-atlas"
)
raise ImportError(msg) from e
if comm.rank == 0:
surf_args = [
"surf",
str(outdir),
"--mode",
str(mode),
"--std",
str(std),
"--case",
case,
"--geometry",
geometry,
"--refine",
str(refine),
]
if use_burns:
surf_args.extend(["--use-burns", "--burns-path", str(burns_path)])
ukb.cli.main(surf_args)
mesh_args = [
"mesh",
str(outdir),
"--case",
case,
"--char_length_max",
str(char_length_max),
"--char_length_min",
str(char_length_min),
"--order",
str(order),
]
if clipped:
ukb.cli.main(["clip", str(outdir), "--case", case, "--smooth"])
mesh_args.append("--clipped")
print(comm.rank)
ukb.cli.main(mesh_args)
comm.barrier()
outdir = Path(outdir)
if clipped:
mesh_name = outdir / f"{case}_clipped.msh"
else:
mesh_name = outdir / f"{case}.msh"
gmsh_geometry = utils.gmsh2dolfin(comm=comm, msh_file=mesh_name, ghost_mode=ghost_mode)
info = {
"mode": mode,
"std": std,
"case": case,
"char_length_max": char_length_max,
"char_length_min": char_length_min,
"fiber_angle_endo": fiber_angle_endo,
"fiber_angle_epi": fiber_angle_epi,
"fiber_space": fiber_space,
"cardiac_geometry_version": __version__,
"mesh_type": "ukb",
"order": order,
"geometry": geometry,
"fiber_method": fiber_method,
"refine": refine,
"clipped": clipped,
"timestamp": datetime.datetime.now().isoformat(),
"use_burns": use_burns,
"ghost_mode": ghost_mode.name,
"aha": aha,
"rv_regions": rv_regions,
"rv_septum_fraction": rv_septum_fraction,
}
cfun, markers = gmsh_geometry.cfun, gmsh_geometry.markers
if aha:
from .aha import biv_aha
if gmsh_geometry.ffun is None:
raise ValueError("AHA segments need the facet tags of the mesh")
landmarks_file = outdir / f"landmarks_{case}.json"
if not landmarks_file.exists():
raise ValueError(
f"AHA segments need the landmarks in {landmarks_file}, "
"written by ukb-atlas >= 2.2 (ukb-atlas surf)"
)
landmarks = json.loads(landmarks_file.read_text())
if rv_regions:
from .aha import biv_regions
assert rv_septum_fraction is not None
cfun, aha_markers = biv_regions(
gmsh_geometry.mesh,
gmsh_geometry.ffun,
gmsh_geometry.markers,
landmarks,
rv_septum_fraction=rv_septum_fraction,
)
else:
cfun, aha_markers = biv_aha(
gmsh_geometry.mesh, gmsh_geometry.ffun, gmsh_geometry.markers, landmarks
)
# The AHA tags replace the cell tags
markers = {k: v for k, v in markers.items() if v[1] != 3} | aha_markers
if comm.rank == 0:
(outdir / "markers.json").write_text(json.dumps(markers, default=utils.json_serial))
(outdir / "info.json").write_text(json.dumps(info, default=utils.json_serial))
fibers = {}
if create_fibers:
from .fibers import ldrb_fibers
angles = {"alpha_endo_lv": fiber_angle_endo, "alpha_epi_lv": fiber_angle_epi}
if fiber_method == "ldrb":
angles |= {"beta_endo_lv": 0, "beta_epi_lv": 0}
system, microstructure = ldrb_fibers(
mesh=gmsh_geometry.mesh,
ffun=gmsh_geometry.ffun,
cfun=gmsh_geometry.cfun,
markers=gmsh_geometry.markers,
clipped=clipped,
fiber_method=fiber_method,
fiber_space=fiber_space,
**angles,
)
fibers = microstructure._asdict()
for k, v in system._asdict().items():
if v is None:
continue
if fiber_space.startswith("Q"):
# Cannot visualize Quadrature spaces yet
continue
logger.debug(f"Write {k}: {v}")
with dolfinx.io.VTXWriter(comm, outdir / f"{k}-viz.bp", [v], engine="BP4") as vtx:
vtx.write(0.0)
save_geometry(
path=geometry_path(outdir, io_backend),
mesh=gmsh_geometry.mesh,
markers=markers,
info=info,
cfun=cfun,
ffun=gmsh_geometry.ffun,
efun=gmsh_geometry.efun,
vfun=gmsh_geometry.vfun,
f0=fibers.get("f0", None),
s0=fibers.get("s0", None),
n0=fibers.get("n0", None),
)
# geo = Geometry.from_folder(comm=comm, folder=outdir, ghost_mode=ghost_mode)
# return geo
return Geometry(
mesh=gmsh_geometry.mesh,
markers=markers,
cfun=cfun,
ffun=gmsh_geometry.ffun,
efun=gmsh_geometry.efun,
vfun=gmsh_geometry.vfun,
f0=fibers.get("f0", None),
s0=fibers.get("s0", None),
n0=fibers.get("n0", None),
info=info,
)
[docs]
def biv_ellipsoid(
outdir: str | Path,
char_length: float = 0.5,
base_cut_z: float = 1.0,
box_size: float = 20.0, # Size of the cutting box
rv_wall_thickness: float = 0.4, # cm
lv_wall_thickness: float = 0.5, # cm
rv_offset_x: float = 1.0,
lv_radius_x: float = 2.2,
lv_radius_y: float = 2.2,
lv_radius_z: float = 4.5,
rv_radius_x: float = 3.2,
rv_radius_y: float = 2.3,
rv_radius_z: float = 4.3,
create_fibers: bool = False,
fiber_angle_endo: float = 60,
fiber_angle_epi: float = -60,
fiber_space: str = "P_1",
verbose: bool = False,
order: int = 1,
comm: MPI.Comm = MPI.COMM_WORLD,
ghost_mode: dolfinx.mesh.GhostMode = dolfinx.mesh.GhostMode.none,
io_backend: str = DEFAULT_IO_BACKEND,
) -> Geometry:
"""Create BiV ellipsoidal geometry
Parameters
----------
outdir : str | Path
Directory where to save the results.
char_length : float, optional
Characteristic length of mesh, by default 0.5
box_size : float, optional
Size of the cutting box, by default 20.0
lv_radius_x : float, optional
Radius of the left ventricle in the x-direction, by default 2.2
lv_radius_y : float, optional
Radius of the left ventricle in the y-direction, by default 2.2
lv_radius_z : float, optional
Radius of the left ventricle in the z-direction, by default 4.5
rv_radius_x : float, optional
Radius of the right ventricle in the x-direction, by default 3.2
rv_radius_y : float, optional
Radius of the right ventricle in the y-direction, by default 2.3
rv_radius_z : float, optional
Radius of the right ventricle in the z-direction, by default 4.3
create_fibers : bool, optional
If True create analytic fibers, by default False
fiber_angle_endo : float, optional
Angle for the endocardium, by default 60
fiber_angle_epi : float, optional
Angle for the epicardium, by default -60
fiber_space : str, optional
Function space for fibers of the form family_degree, by default "P_1"
verbose : bool, optional
If True print information from gmsh, by default False
order : int, optional
Order of the mesh elements. With order higher than one the elements
are curved to follow the boundaries, by default 1
comm : MPI.Comm, optional
MPI communicator, by default MPI.COMM_WORLD
ghost_mode : dolfinx.mesh.GhostMode, optional
Ghost mode for the dolfinx mesh, by default dolfinx.mesh.GhostMode.none
io_backend : str, optional
io4dolfinx backend used to write the geometry, "adios2" (``geometry.bp``)
or "h5py" (``geometry.h5``), by default "adios2"
Returns
-------
cardiac_geometries.geometry.Geometry
A Geometry with the mesh, markers, markers functions and fibers.
"""
outdir = Path(outdir)
mesh_name = outdir / "biv_ellipsoid.msh"
info = {
"char_length": char_length,
"base_cut_z": base_cut_z,
"box_size": box_size,
"rv_wall_thickness": rv_wall_thickness,
"lv_wall_thickness": lv_wall_thickness,
"rv_offset_x": rv_offset_x,
"lv_radius_x": lv_radius_x,
"lv_radius_y": lv_radius_y,
"lv_radius_z": lv_radius_z,
"rv_radius_x": rv_radius_x,
"rv_radius_y": rv_radius_y,
"rv_radius_z": rv_radius_z,
"create_fibers": create_fibers,
"fiber_angle_endo": fiber_angle_endo,
"fiber_angle_epi": fiber_angle_epi,
"fiber_space": fiber_space,
"mesh_type": "biv_ellipsoid",
"order": order,
"cardiac_geometry_version": __version__,
"timestamp": datetime.datetime.now().isoformat(),
"ghost_mode": ghost_mode.name,
}
if comm.rank == 0:
outdir.mkdir(exist_ok=True, parents=True)
with open(outdir / "info.json", "w") as f:
json.dump(
info,
f,
indent=2,
default=utils.json_serial,
)
cgc.biv_ellipsoid(
mesh_name=mesh_name.as_posix(),
char_length=char_length,
base_cut_z=base_cut_z,
box_size=box_size,
rv_wall_thickness=rv_wall_thickness,
lv_wall_thickness=lv_wall_thickness,
rv_offset_x=rv_offset_x,
lv_radius_x=lv_radius_x,
lv_radius_y=lv_radius_y,
lv_radius_z=lv_radius_z,
rv_radius_x=rv_radius_x,
rv_radius_y=rv_radius_y,
rv_radius_z=rv_radius_z,
verbose=verbose,
order=order,
)
comm.barrier()
geometry = utils.gmsh2dolfin(comm=comm, msh_file=mesh_name, ghost_mode=ghost_mode)
if comm.rank == 0:
with open(outdir / "markers.json", "w") as f:
json.dump(geometry.markers, f, default=utils.json_serial)
comm.barrier()
fibers = {}
if create_fibers:
try:
import ldrb
except ImportError:
msg = (
"To create fibers you need to install the ldrb package "
"which you can install with pip install fenicsx-ldrb"
)
raise ImportError(msg)
ldrb_markers = transform_markers(geometry.markers)
system = ldrb.dolfinx_ldrb(
mesh=geometry.mesh,
ffun=geometry.ffun,
markers=ldrb_markers,
alpha_endo_lv=fiber_angle_endo,
alpha_epi_lv=fiber_angle_epi,
beta_endo_lv=0,
beta_epi_lv=0,
fiber_space=fiber_space,
)
fibers = {"f0": system.f0, "s0": system.s0, "n0": system.n0}
save_geometry(
path=geometry_path(outdir, io_backend),
mesh=geometry.mesh,
info=info,
markers=geometry.markers,
cfun=geometry.cfun,
ffun=geometry.ffun,
efun=geometry.efun,
vfun=geometry.vfun,
f0=fibers.get("f0", None),
s0=fibers.get("s0", None),
n0=fibers.get("n0", None),
)
return Geometry(
mesh=geometry.mesh,
markers=geometry.markers,
cfun=geometry.cfun,
ffun=geometry.ffun,
efun=geometry.efun,
vfun=geometry.vfun,
f0=fibers.get("f0", None),
s0=fibers.get("s0", None),
n0=fibers.get("n0", None),
info=info,
)
[docs]
def lv_ellipsoid(
outdir: Path | str,
r_short_endo: float = 7.0,
r_short_epi: float = 10.0,
r_long_endo: float = 17.0,
r_long_epi: float = 20.0,
psize_ref: float = 3,
mu_apex_endo: float = -math.pi,
mu_base_endo: float = -math.acos(5 / 17),
mu_apex_epi: float = -math.pi,
mu_base_epi: float = -math.acos(5 / 20),
create_fibers: bool = False,
fiber_angle_endo: float = 60,
fiber_angle_epi: float = -60,
fiber_space: str = "P_1",
aha: bool = False,
dmu_factor: float = 1 / 4,
verbose: bool = False,
order: int = 1,
comm: MPI.Comm = MPI.COMM_WORLD,
ghost_mode: dolfinx.mesh.GhostMode = dolfinx.mesh.GhostMode.none,
io_backend: str = DEFAULT_IO_BACKEND,
) -> Geometry:
"""Create an LV ellipsoidal geometry
Parameters
----------
outdir : Optional[Path], optional
Directory where to save the results.
r_short_endo : float, optional
Shortest radius on the endocardium layer, by default 7.0
r_short_epi : float, optional
Shortest radius on the epicardium layer, by default 10.0
r_long_endo : float, optional
Longest radius on the endocardium layer, by default 17.0
r_long_epi : float, optional
Longest radius on the epicardium layer, by default 20.0
psize_ref : float, optional
The reference point size (smaller values yield as finer mesh, by default 3
mu_apex_endo : float, optional
Angle for the endocardial apex, by default -math.pi
mu_base_endo : float, optional
Angle for the endocardial base, by default -math.acos(5 / 17)
mu_apex_epi : float, optional
Angle for the epicardial apex, by default -math.pi
mu_base_epi : float, optional
Angle for the epicardial apex, by default -math.acos(5 / 20)
create_fibers : bool, optional
If True create analytic fibers, by default False
fiber_angle_endo : float, optional
Angle for the endocardium, by default 60
fiber_angle_epi : float, optional
Angle for the epicardium, by default -60
fiber_space : str, optional
Function space for fibers of the form family_degree, by default "P_1"
aha : bool, optional
If True create 17-segment AHA regions
dmu_factor : float, optional
Factor to determine the aha segmentation width, by default 1/4
verbose : bool, optional
If True print information from gmsh, by default False
order : int, optional
Order of the mesh elements. With order higher than one the elements
are curved to follow the boundaries, by default 1
comm : MPI.Comm, optional
MPI communicator, by default MPI.COMM_WORLD
ghost_mode : dolfinx.mesh.GhostMode, optional
Ghost mode for the dolfinx mesh, by default dolfinx.mesh.GhostMode.none
io_backend : str, optional
io4dolfinx backend used to write the geometry, "adios2" (``geometry.bp``)
or "h5py" (``geometry.h5``), by default "adios2"
Returns
-------
cardiac_geometries.geometry.Geometry
A Geometry with the mesh, markers, markers functions and fibers.
"""
outdir = Path(outdir)
mesh_name = outdir / "lv_ellipsoid.msh"
info = {
"r_short_endo": r_short_endo,
"r_short_epi": r_short_epi,
"r_long_endo": r_long_endo,
"r_long_epi": r_long_epi,
"psize_ref": psize_ref,
"mu_apex_endo": mu_apex_endo,
"mu_base_endo": mu_base_endo,
"mu_apex_epi": mu_apex_epi,
"mu_base_epi": mu_base_epi,
"create_fibers": create_fibers,
"fiber_angle_endo": fiber_angle_endo,
"fiber_angle_epi": fiber_angle_epi,
"fiber_space": fiber_space,
"aha": aha,
"dmu_factor": dmu_factor,
"mesh_type": "lv_ellipsoid",
"order": order,
"cardiac_geometry_version": __version__,
"timestamp": datetime.datetime.now().isoformat(),
"ghost_mode": ghost_mode.name,
}
if comm.rank == 0:
outdir.mkdir(exist_ok=True, parents=True)
with open(outdir / "info.json", "w") as f:
json.dump(
info,
f,
indent=2,
default=utils.json_serial,
)
cgc.lv_ellipsoid(
mesh_name=mesh_name.as_posix(),
r_short_endo=r_short_endo,
r_short_epi=r_short_epi,
r_long_endo=r_long_endo,
r_long_epi=r_long_epi,
mu_base_endo=mu_base_endo,
mu_base_epi=mu_base_epi,
mu_apex_endo=mu_apex_endo,
mu_apex_epi=mu_apex_epi,
psize_ref=psize_ref,
verbose=verbose,
order=order,
)
comm.barrier()
geometry = utils.gmsh2dolfin(comm=comm, msh_file=mesh_name, ghost_mode=ghost_mode)
kwargs: dict[str, dolfinx.mesh.MeshTags | dolfinx.fem.Function | None] = {"cfun": geometry.cfun}
if aha:
from .aha import lv_aha
aha_func, markers = lv_aha(
mesh=geometry.mesh,
r_long_endo=r_long_endo,
r_short_endo=r_short_endo,
mu_base=mu_base_endo,
dmu_factor=dmu_factor,
)
aha_func.name = "Cell tags"
fname = outdir / "mesh.xdmf"
fname.unlink(missing_ok=True)
fname.with_suffix(".h5").unlink(missing_ok=True)
comm.barrier()
from .utils import save_mesh_to_xdmf
save_mesh_to_xdmf(
comm=comm,
fname=fname,
mesh=geometry.mesh,
ct=aha_func,
ft=geometry.ffun,
et=geometry.efun,
vt=geometry.vfun,
)
kwargs["cfun"] = aha_func
for k, v in geometry.markers.items():
# Add all markers except the volume markers
if v[0] != 3:
markers[k] = v
else:
markers = geometry.markers
if comm.rank == 0:
with open(outdir / "markers.json", "w") as f:
json.dump(markers, f, default=utils.json_serial)
if create_fibers:
from .fibers.lv_ellipsoid import create_microstructure
system = create_microstructure(
mesh=geometry.mesh,
ffun=geometry.ffun,
markers=geometry.markers,
function_space=fiber_space,
r_short_endo=r_short_endo,
r_short_epi=r_short_epi,
r_long_endo=r_long_endo,
r_long_epi=r_long_epi,
alpha_endo=fiber_angle_endo,
alpha_epi=fiber_angle_epi,
outdir=outdir,
)
kwargs["f0"] = system.f0
kwargs["s0"] = system.s0
kwargs["n0"] = system.n0
cfun = kwargs.get("cfun", None)
assert cfun is None or isinstance(cfun, dolfinx.mesh.MeshTags)
f0 = kwargs.get("f0", None)
assert f0 is None or isinstance(f0, dolfinx.fem.Function)
s0 = kwargs.get("s0", None)
assert s0 is None or isinstance(s0, dolfinx.fem.Function)
n0 = kwargs.get("n0", None)
assert n0 is None or isinstance(n0, dolfinx.fem.Function)
save_geometry(
path=geometry_path(outdir, io_backend),
mesh=geometry.mesh,
markers=markers,
info=info,
cfun=cfun,
ffun=geometry.ffun,
efun=geometry.efun,
vfun=geometry.vfun,
f0=f0,
s0=s0,
n0=n0,
)
return Geometry(
mesh=geometry.mesh,
markers=geometry.markers,
cfun=cfun,
ffun=geometry.ffun,
efun=geometry.efun,
vfun=geometry.vfun,
f0=f0,
s0=s0,
n0=n0,
info=info,
)
[docs]
def lv_ellipsoid_2D(
outdir: Path | str,
r_short_endo: float = 7.0,
r_short_epi: float = 10.0,
r_long_endo: float = 17.0,
r_long_epi: float = 20.0,
psize_ref: float = 3,
mu_apex_endo: float = math.pi,
mu_base_endo: float = math.acos(5 / 17),
mu_apex_epi: float = math.pi,
mu_base_epi: float = math.acos(5 / 20),
create_fibers: bool = False,
fiber_angle_endo: float = 60,
fiber_angle_epi: float = -60,
fiber_space: str = "P_1",
verbose: bool = False,
order: int = 1,
comm: MPI.Comm = MPI.COMM_WORLD,
ghost_mode: dolfinx.mesh.GhostMode = dolfinx.mesh.GhostMode.none,
io_backend: str = DEFAULT_IO_BACKEND,
) -> Geometry:
"""Create the meridian of an LV ellipsoid, for axisymmetric problems
The mesh lies in the plane z = 0, with the long axis along x and the radial
direction along y, and has geometric dimension 2. The angles mu must be in
[0, pi] so that y = r_short sin(mu) >= 0: unlike `lv_ellipsoid`, whose
default angles are negative, the defaults here give the same ellipsoid
with the meridian in the upper half plane. ``APEX`` marks
the segment of the axis between the two apices, where an axisymmetric
problem needs its symmetry condition.
The fibers have three components: the axial, the radial and the
circumferential one. The fiber angle varies linearly in the transmural
coordinate of the ellipsoid itself (see
`cardiac_geometries.fibers.lv_ellipsoid.transmural_coordinate`), as a
planar Laplace problem on the meridian is not the axisymmetric one.
Parameters
----------
outdir : Optional[Path], optional
Directory where to save the results.
r_short_endo : float, optional
Shortest radius on the endocardium layer, by default 7.0
r_short_epi : float, optional
Shortest radius on the epicardium layer, by default 10.0
r_long_endo : float, optional
Longest radius on the endocardium layer, by default 17.0
r_long_epi : float, optional
Longest radius on the epicardium layer, by default 20.0
psize_ref : float, optional
The reference point size (smaller values yield as finer mesh, by default 3
mu_apex_endo : float, optional
Angle for the endocardial apex, by default math.pi
mu_base_endo : float, optional
Angle for the endocardial base, by default math.acos(5 / 17)
mu_apex_epi : float, optional
Angle for the epicardial apex, by default math.pi
mu_base_epi : float, optional
Angle for the epicardial base, by default math.acos(5 / 20)
create_fibers : bool, optional
If True create analytic fibers, by default False
fiber_angle_endo : float, optional
Angle for the endocardium, by default 60
fiber_angle_epi : float, optional
Angle for the epicardium, by default -60
fiber_space : str, optional
Function space for fibers of the form family_degree, by default "P_1"
verbose : bool, optional
If True print information from gmsh, by default False
order : int, optional
Order of the mesh elements. With order higher than one the elements
are curved to follow the boundaries, by default 1
comm : MPI.Comm, optional
MPI communicator, by default MPI.COMM_WORLD
ghost_mode : dolfinx.mesh.GhostMode, optional
Ghost mode for the dolfinx mesh, by default dolfinx.mesh.GhostMode.none
io_backend : str, optional
io4dolfinx backend used to write the geometry, "adios2" (``geometry.bp``)
or "h5py" (``geometry.h5``), by default "adios2"
Returns
-------
cardiac_geometries.geometry.Geometry
A Geometry with the mesh, markers, markers functions and fibers.
"""
for name, mu in [
("mu_apex_endo", mu_apex_endo),
("mu_base_endo", mu_base_endo),
("mu_apex_epi", mu_apex_epi),
("mu_base_epi", mu_base_epi),
]:
if not 0.0 <= mu <= math.pi:
raise ValueError(
f"{name} = {mu} is not in [0, pi], so the meridian would not be at y >= 0"
)
outdir = Path(outdir)
mesh_name = outdir / "lv_ellipsoid_2D.msh"
info = {
"r_short_endo": r_short_endo,
"r_short_epi": r_short_epi,
"r_long_endo": r_long_endo,
"r_long_epi": r_long_epi,
"psize_ref": psize_ref,
"mu_apex_endo": mu_apex_endo,
"mu_base_endo": mu_base_endo,
"mu_apex_epi": mu_apex_epi,
"mu_base_epi": mu_base_epi,
"create_fibers": create_fibers,
"fiber_angle_endo": fiber_angle_endo,
"fiber_angle_epi": fiber_angle_epi,
"fiber_space": fiber_space,
"mesh_type": "lv_ellipsoid_2D",
"order": order,
"cardiac_geometry_version": __version__,
"timestamp": datetime.datetime.now().isoformat(),
"ghost_mode": ghost_mode.name,
}
if comm.rank == 0:
outdir.mkdir(exist_ok=True, parents=True)
with open(outdir / "info.json", "w") as f:
json.dump(
info,
f,
indent=2,
default=utils.json_serial,
)
cgc.lv_ellipsoid_2D(
mesh_name=mesh_name.as_posix(),
r_short_endo=r_short_endo,
r_short_epi=r_short_epi,
r_long_endo=r_long_endo,
r_long_epi=r_long_epi,
mu_base_endo=mu_base_endo,
mu_base_epi=mu_base_epi,
mu_apex_endo=mu_apex_endo,
mu_apex_epi=mu_apex_epi,
psize_ref=psize_ref,
verbose=verbose,
order=order,
)
comm.barrier()
geometry = utils.gmsh2dolfin(comm=comm, msh_file=mesh_name, ghost_mode=ghost_mode, gdim=2)
if comm.rank == 0:
with open(outdir / "markers.json", "w") as f:
json.dump(geometry.markers, f, default=utils.json_serial)
f0 = s0 = n0 = None
if create_fibers:
from .fibers.lv_ellipsoid import create_microstructure
system = create_microstructure(
mesh=geometry.mesh,
ffun=geometry.ffun,
markers=geometry.markers,
function_space=fiber_space,
r_short_endo=r_short_endo,
r_short_epi=r_short_epi,
r_long_endo=r_long_endo,
r_long_epi=r_long_epi,
alpha_endo=fiber_angle_endo,
alpha_epi=fiber_angle_epi,
outdir=outdir,
transmural="analytic",
)
f0, s0, n0 = system.f0, system.s0, system.n0
save_geometry(
path=geometry_path(outdir, io_backend),
mesh=geometry.mesh,
markers=geometry.markers,
info=info,
cfun=geometry.cfun,
ffun=geometry.ffun,
vfun=geometry.vfun,
f0=f0,
s0=s0,
n0=n0,
)
return Geometry(
mesh=geometry.mesh,
markers=geometry.markers,
cfun=geometry.cfun,
ffun=geometry.ffun,
vfun=geometry.vfun,
f0=f0,
s0=s0,
n0=n0,
info=info,
)
def slab_dolfinx(
comm,
outdir: Path,
lx: float = 20.0,
ly: float = 7.0,
lz: float = 3.0,
dx: float = 1.0,
ghost_mode: dolfinx.mesh.GhostMode = dolfinx.mesh.GhostMode.none,
io_backend: str = DEFAULT_IO_BACKEND,
) -> utils.GMshGeometry:
mesh = dolfinx.mesh.create_box(
comm,
[[0.0, 0.0, 0.0], [lx, ly, lz]],
[int(lx / dx), int(ly / dx), int(lz / dx)],
dolfinx.mesh.CellType.tetrahedron,
ghost_mode=ghost_mode,
)
mesh.name = "Mesh"
fdim = mesh.topology.dim - 1
x0_facets = dolfinx.mesh.locate_entities_boundary(mesh, fdim, lambda x: np.isclose(x[0], 0))
x1_facets = dolfinx.mesh.locate_entities_boundary(mesh, fdim, lambda x: np.isclose(x[0], lx))
y0_facets = dolfinx.mesh.locate_entities_boundary(mesh, fdim, lambda x: np.isclose(x[1], 0))
y1_facets = dolfinx.mesh.locate_entities_boundary(mesh, fdim, lambda x: np.isclose(x[1], ly))
z0_facets = dolfinx.mesh.locate_entities_boundary(mesh, fdim, lambda x: np.isclose(x[2], 0))
z1_facets = dolfinx.mesh.locate_entities_boundary(mesh, fdim, lambda x: np.isclose(x[2], lz))
# Concatenate and sort the arrays based on facet indices.
# Left facets marked with 1, right facets with two
marked_facets = np.hstack([x0_facets, x1_facets, y0_facets, y1_facets, z0_facets, z1_facets])
marked_values = np.hstack(
[
np.full_like(x0_facets, 1),
np.full_like(x1_facets, 2),
np.full_like(y0_facets, 3),
np.full_like(y1_facets, 4),
np.full_like(z0_facets, 5),
np.full_like(z1_facets, 6),
],
)
sorted_facets = np.argsort(marked_facets)
ft = dolfinx.mesh.meshtags(
mesh,
fdim,
marked_facets[sorted_facets],
marked_values[sorted_facets],
)
ft.name = "Facet tags"
markers = {
"X0": (1, 2),
"X1": (2, 2),
"Y0": (3, 2),
"Y1": (4, 2),
"Z0": (5, 2),
"Z1": (6, 2),
}
with dolfinx.io.XDMFFile(comm, outdir / "mesh.xdmf", "w") as xdmf:
xdmf.write_mesh(mesh)
xdmf.write_meshtags(ft, mesh.geometry)
save_geometry(
path=geometry_path(outdir, io_backend),
mesh=mesh,
markers=markers,
cfun=None,
ffun=ft,
efun=None,
vfun=None,
)
return utils.GMshGeometry(
mesh=mesh,
markers=markers,
cfun=None,
ffun=ft,
efun=None,
vfun=None,
)
[docs]
def slab(
outdir: Path | str,
lx: float = 20.0,
ly: float = 7.0,
lz: float = 3.0,
dx: float = 1.0,
create_fibers: bool = True,
fiber_angle_endo: float = 60,
fiber_angle_epi: float = -60,
fiber_space: str = "P_1",
verbose: bool = False,
use_dolfinx: bool = False,
order: int = 1,
comm: MPI.Comm = MPI.COMM_WORLD,
ghost_mode: dolfinx.mesh.GhostMode = dolfinx.mesh.GhostMode.none,
io_backend: str = DEFAULT_IO_BACKEND,
) -> Geometry:
"""Create slab geometry
Parameters
----------
outdir : Optional[Path], optional
Directory where to save the results.
lx : float, optional
Length of slab the x-direction, by default 20.0
ly : float, optional
Length of slab the x-direction, by default 7.0
lz : float, optional
Length of slab the z-direction, by default 3.0
dx : float, optional
Element size, by default 1.0
create_fibers : bool, optional
If True create analytic fibers, by default True
fiber_angle_endo : float, optional
Angle for the endocardium, by default 60
fiber_angle_epi : float, optional
Angle for the epicardium, by default -60
fiber_space : str, optional
Function space for fibers of the form family_degree, by default "P_1"
verbose : bool, optional
If True print information from gmsh, by default False
use_dolfinx : bool, optional
If True use dolfinx to create the mesh, by default False (gmsh)
order : int, optional
Order of the mesh elements. With order higher than one the elements
are curved to follow the boundaries, by default 1
comm : MPI.Comm, optional
MPI communicator, by default MPI.COMM_WORLD
ghost_mode : dolfinx.mesh.GhostMode, optional
Ghost mode for the dolfinx mesh, by default dolfinx.mesh.GhostMode.none
io_backend : str, optional
io4dolfinx backend used to write the geometry, "adios2" (``geometry.bp``)
or "h5py" (``geometry.h5``), by default "adios2"
Returns
-------
cardiac_geometries.geometry.Geometry
A Geometry with the mesh, markers, markers functions and fibers.
"""
if use_dolfinx and order != 1:
raise ValueError("Meshes of higher order are only supported with gmsh (use_dolfinx=False)")
outdir = Path(outdir)
mesh_name = outdir / "slab.msh"
info = {
"Lx": lx,
"Ly": ly,
"Lz": lz,
"dx": dx,
"create_fibers": create_fibers,
"fiber_angle_endo": fiber_angle_endo,
"fiber_angle_epi": fiber_angle_epi,
"fiber_space": fiber_space,
"mesh_type": "slab",
"order": order,
"cardiac_geometry_version": __version__,
"timestamp": datetime.datetime.now().isoformat(),
"ghost_mode": ghost_mode.name,
}
if comm.rank == 0:
outdir.mkdir(exist_ok=True, parents=True)
with open(outdir / "info.json", "w") as f:
json.dump(
info,
f,
indent=2,
default=utils.json_serial,
)
if not use_dolfinx:
cgc.slab(
mesh_name=mesh_name.as_posix(),
lx=lx,
ly=ly,
lz=lz,
dx=dx,
verbose=verbose,
order=order,
)
comm.barrier()
if use_dolfinx:
geometry = slab_dolfinx(
comm=comm,
outdir=outdir,
lx=lx,
ly=ly,
lz=lz,
dx=dx,
ghost_mode=ghost_mode,
io_backend=io_backend,
)
else:
geometry = utils.gmsh2dolfin(comm=comm, msh_file=mesh_name, ghost_mode=ghost_mode)
if comm.rank == 0:
with open(outdir / "markers.json", "w") as f:
json.dump(geometry.markers, f, default=utils.json_serial)
fibers = {}
if create_fibers:
from .fibers.slab import create_microstructure
assert geometry.ffun is not None
system = create_microstructure(
mesh=geometry.mesh,
ffun=geometry.ffun,
markers=geometry.markers,
function_space=fiber_space,
alpha_endo=fiber_angle_endo,
alpha_epi=fiber_angle_epi,
outdir=outdir,
)
fibers = {"f0": system.f0, "s0": system.s0, "n0": system.n0}
save_geometry(
path=geometry_path(outdir, io_backend),
mesh=geometry.mesh,
markers=geometry.markers,
info=info,
ffun=geometry.ffun,
efun=geometry.efun,
vfun=geometry.vfun,
f0=fibers.get("f0", None),
s0=fibers.get("s0", None),
n0=fibers.get("n0", None),
)
return Geometry(
mesh=geometry.mesh,
markers=geometry.markers,
cfun=geometry.cfun,
ffun=geometry.ffun,
efun=geometry.efun,
vfun=geometry.vfun,
f0=fibers.get("f0", None),
s0=fibers.get("s0", None),
n0=fibers.get("n0", None),
info=info,
)
[docs]
def slab_in_bath(
outdir: Path | str,
lx: float = 1.0,
ly: float = 0.01,
lz: float = 0.5,
bx: float = 0.0,
by: float = 0.0,
bz: float = 0.1,
dx: float = 0.001,
verbose: bool = False,
order: int = 1,
comm: MPI.Comm = MPI.COMM_WORLD,
ghost_mode: dolfinx.mesh.GhostMode = dolfinx.mesh.GhostMode.none,
io_backend: str = DEFAULT_IO_BACKEND,
) -> Geometry:
"""Create slab geometry
Parameters
----------
outdir : Path
Directory where to save the results.
lx : float, optional
Length of slab the x-direction, by default 1.0
ly : float, optional
Length of slab the x-direction, by default 0.5
lz : float, optional
Length of slab the z-direction, by default 0.01
bx : float, optional
Thickness of bath the x-direction, by default 0.0
by : float, optional
Thickness of bath the x-direction, by default 0.0
bz : float, optional
Thickness of bath the z-direction, by default 0.1
dx : float, optional
Element size, by default 0.001
verbose : bool, optional
If True print information from gmsh, by default False
order : int, optional
Order of the mesh elements. With order higher than one the elements
are curved to follow the boundaries, by default 1
comm : MPI.Comm, optional
MPI communicator, by default MPI.COMM_WORLD
ghost_mode : dolfinx.mesh.GhostMode, optional
Ghost mode for the dolfinx mesh, by default dolfinx.mesh.GhostMode.none
io_backend : str, optional
io4dolfinx backend used to write the geometry, "adios2" (``geometry.bp``)
or "h5py" (``geometry.h5``), by default "adios2"
Returns
-------
cardiac_geometries.geometry.Geometry
A Geometry with the mesh, markers, markers functions and fibers.
"""
outdir = Path(outdir)
mesh_name = outdir / "slab_in_bath.msh"
info = {
"lx": lx,
"ly": ly,
"lz": lz,
"bx": bx,
"by": by,
"bz": bz,
"dx": dx,
"mesh_type": "slab-bath",
"order": order,
"cardiac_geometry_version": __version__,
"timestamp": datetime.datetime.now().isoformat(),
"ghost_mode": ghost_mode.name,
}
if comm.rank == 0:
outdir.mkdir(exist_ok=True, parents=True)
with open(outdir / "info.json", "w") as f:
json.dump(
info,
f,
indent=2,
default=utils.json_serial,
)
cgc.slab_in_bath(
mesh_name=mesh_name.as_posix(),
lx=lx,
ly=ly,
lz=lz,
bx=bx,
by=by,
bz=bz,
dx=dx,
verbose=verbose,
order=order,
)
geometry = utils.gmsh2dolfin(comm=comm, msh_file=mesh_name, ghost_mode=ghost_mode)
if comm.rank == 0:
with open(outdir / "markers.json", "w") as f:
json.dump(geometry.markers, f, default=utils.json_serial)
comm.barrier()
save_geometry(
path=geometry_path(outdir, io_backend),
mesh=geometry.mesh,
markers=geometry.markers,
info=info,
cfun=geometry.cfun,
ffun=geometry.ffun,
efun=geometry.efun,
vfun=geometry.vfun,
)
return Geometry(
mesh=geometry.mesh,
markers=geometry.markers,
cfun=geometry.cfun,
ffun=geometry.ffun,
efun=geometry.efun,
vfun=geometry.vfun,
info=info,
)
[docs]
def cylinder(
outdir: Path | str,
r_inner: float = 10.0,
r_outer: float = 15.0,
height: float = 40.0,
floor_thickness: float = 0.0,
roof_thickness: float = 0.0,
char_length: float = 5.0,
create_fibers: bool = False,
fiber_angle_endo: float = 60,
fiber_angle_epi: float = -60,
fiber_space: str = "P_1",
aha: bool = False,
verbose: bool = False,
order: int = 1,
comm: MPI.Comm = MPI.COMM_WORLD,
ghost_mode: dolfinx.mesh.GhostMode = dolfinx.mesh.GhostMode.none,
io_backend: str = DEFAULT_IO_BACKEND,
) -> Geometry:
"""Create a cylindrical geometry
Parameters
----------
outdir : Optional[Path], optional
Directory where to save the results.
r_inner : float, optional
Radius on the endocardium layer, by default 10.0
r_outer : float, optional
Radius on the epicardium layer, by default 15.0
height : float, optional
Longest radius on the endocardium layer, by default 40.0
floor_thickness : float, optional
Thickness of the floor, by default 0.0
roof_thickness : float, optional
Thickness of the roof, by default 0.0
char_length : float, optional
Characteristic length of mesh, by default 5.0
create_fibers : bool, optional
If True create analytic fibers, by default False
fiber_angle_endo : float, optional
Angle for the endocardium, by default 60
fiber_angle_epi : float, optional
Angle for the epicardium, by default -60
fiber_space : str, optional
Function space for fibers of the form family_degree, by default "P_1"
aha : bool, optional
If True create 17-segment AHA regions
verbose : bool, optional
If True print information from gmsh, by default False
order : int, optional
Order of the mesh elements. With order higher than one the elements
are curved to follow the boundaries, by default 1
comm : MPI.Comm, optional
MPI communicator, by default MPI.COMM_WORLD
ghost_mode : dolfinx.mesh.GhostMode, optional
Ghost mode for the dolfinx mesh, by default dolfinx.mesh.GhostMode.none
io_backend : str, optional
io4dolfinx backend used to write the geometry, "adios2" (``geometry.bp``)
or "h5py" (``geometry.h5``), by default "adios2"
Returns
-------
cardiac_geometries.geometry.Geometry
A Geometry with the mesh, markers, markers functions and fibers.
"""
outdir = Path(outdir)
mesh_name = outdir / "cylinder.msh"
info = {
"r_inner": r_inner,
"r_outer": r_outer,
"height": height,
"floor_thickness": floor_thickness,
"roof_thickness": roof_thickness,
"char_length": char_length,
"create_fibers": create_fibers,
"fiber_angle_endo": fiber_angle_endo,
"fiber_angle_epi": fiber_angle_epi,
"fiber_space": fiber_space,
"aha": aha,
"mesh_type": "cylinder",
"order": order,
"cardiac_geometry_version": __version__,
"timestamp": datetime.datetime.now().isoformat(),
"ghost_mode": ghost_mode.name,
}
if comm.rank == 0:
outdir.mkdir(exist_ok=True, parents=True)
with open(outdir / "info.json", "w") as f:
json.dump(
info,
f,
indent=2,
default=utils.json_serial,
)
cgc.cylinder(
inner_radius=r_inner,
outer_radius=r_outer,
height=height,
floor_thickness=floor_thickness,
roof_thickness=roof_thickness,
mesh_name=mesh_name.as_posix(),
char_length=char_length,
verbose=verbose,
order=order,
)
comm.barrier()
geometry = utils.gmsh2dolfin(comm=comm, msh_file=mesh_name, ghost_mode=ghost_mode)
if comm.rank == 0:
with open(outdir / "markers.json", "w") as f:
json.dump(geometry.markers, f, default=utils.json_serial)
fibers = {}
if create_fibers:
from .fibers.cylinder import create_microstructure
system = create_microstructure(
mesh=geometry.mesh,
function_space=fiber_space,
r_inner=r_inner,
r_outer=r_outer,
alpha_endo=fiber_angle_endo,
alpha_epi=fiber_angle_epi,
outdir=outdir,
)
fibers = {"f0": system.f0, "s0": system.s0, "n0": system.n0}
save_geometry(
path=geometry_path(outdir, io_backend),
mesh=geometry.mesh,
markers=geometry.markers,
info=info,
ffun=geometry.ffun,
efun=geometry.efun,
vfun=geometry.vfun,
f0=fibers.get("f0", None),
s0=fibers.get("s0", None),
n0=fibers.get("n0", None),
)
return Geometry(
mesh=geometry.mesh,
markers=geometry.markers,
cfun=geometry.cfun,
ffun=geometry.ffun,
efun=geometry.efun,
vfun=geometry.vfun,
f0=fibers.get("f0", None),
s0=fibers.get("s0", None),
n0=fibers.get("n0", None),
info=info,
)
[docs]
def cylinder_elliptical(
outdir: Path | str,
r_inner_x: float = 10.0,
r_inner_y: float = 10.0,
r_outer_x: float = 20.0,
r_outer_y: float = 20.0,
height: float = 40.0,
char_length: float = 10.0,
create_fibers: bool = False,
fiber_angle_endo: float = 60,
fiber_angle_epi: float = -60,
fiber_space: str = "P_1",
aha: bool = False,
verbose: bool = False,
order: int = 1,
comm: MPI.Comm = MPI.COMM_WORLD,
io_backend: str = DEFAULT_IO_BACKEND,
) -> Geometry:
"""Create a cylindrical geometry
Parameters
----------
outdir : Optional[Path], optional
Directory where to save the results.
r_inner_x : float, optional
Radius on the endocardium layer along the x-axis, by default 10.0
r_inner_y : float, optional
Radius on the endocardium layer along the y-axis, by default 10.0
r_outer_x : float, optional
Radius on the epicardium layer along the x-axis, by default 20.0
r_outer_y : float, optional
Radius on the epicardium layer along the y-axis, by default 20.0
height : float, optional
Longest radius on the endocardium layer, by default 10.0
char_length : float, optional
Characteristic length of mesh, by default 10.0
create_fibers : bool, optional
If True create analytic fibers, by default False
fiber_angle_endo : float, optional
Angle for the endocardium, by default 60
fiber_angle_epi : float, optional
Angle for the epicardium, by default -60
fiber_space : str, optional
Function space for fibers of the form family_degree, by default "P_1"
aha : bool, optional
If True create 17-segment AHA regions
verbose : bool, optional
If True print information from gmsh, by default False
order : int, optional
Order of the mesh elements. With order higher than one the elements
are curved to follow the boundaries, by default 1
comm : MPI.Comm, optional
MPI communicator, by default MPI.COMM_WORLD
io_backend : str, optional
io4dolfinx backend used to write the geometry, "adios2" (``geometry.bp``)
or "h5py" (``geometry.h5``), by default "adios2"
Returns
-------
cardiac_geometries.geometry.Geometry
A Geometry with the mesh, markers, markers functions and fibers.
"""
outdir = Path(outdir)
mesh_name = outdir / "cylinder.msh"
info = {
"r_inner_x": r_inner_x,
"r_inner_y": r_inner_y,
"r_outer_x": r_outer_x,
"r_outer_y": r_outer_y,
"height": height,
"char_length": char_length,
"create_fibers": create_fibers,
"fiber_angle_endo": fiber_angle_endo,
"fiber_angle_epi": fiber_angle_epi,
"fiber_space": fiber_space,
"aha": aha,
"mesh_type": "cylinder_elliptical",
"order": order,
"cardiac_geometry_version": __version__,
"timestamp": datetime.datetime.now().isoformat(),
}
if comm.rank == 0:
outdir.mkdir(exist_ok=True, parents=True)
with open(outdir / "info.json", "w") as f:
json.dump(
info,
f,
indent=2,
default=utils.json_serial,
)
cgc.cylinder_elliptical(
inner_radius_x=r_inner_x,
inner_radius_y=r_inner_y,
outer_radius_x=r_outer_x,
outer_radius_y=r_outer_y,
height=height,
mesh_name=mesh_name.as_posix(),
char_length=char_length,
verbose=verbose,
order=order,
)
comm.barrier()
geometry = utils.gmsh2dolfin(comm=comm, msh_file=mesh_name)
if comm.rank == 0:
with open(outdir / "markers.json", "w") as f:
json.dump(geometry.markers, f, default=utils.json_serial)
fibers = {}
if create_fibers:
from .fibers.cylinder_elliptical import create_microstructure
system = create_microstructure(
mesh=geometry.mesh,
function_space=fiber_space,
r_inner_x=r_inner_x,
r_inner_y=r_inner_y,
r_outer_x=r_outer_x,
r_outer_y=r_outer_y,
alpha_endo=fiber_angle_endo,
alpha_epi=fiber_angle_epi,
outdir=outdir,
)
fibers = {"f0": system.f0, "s0": system.s0, "n0": system.n0}
save_geometry(
path=geometry_path(outdir, io_backend),
mesh=geometry.mesh,
markers=geometry.markers,
info=info,
ffun=geometry.ffun,
efun=geometry.efun,
vfun=geometry.vfun,
f0=fibers.get("f0", None),
s0=fibers.get("s0", None),
n0=fibers.get("n0", None),
)
geo = Geometry.from_folder(comm=comm, folder=outdir)
return geo
[docs]
def cylinder_racetrack(
outdir: Path | str,
r_inner: float = 13.0,
r_outer: float = 20.0,
height: float = 40.0,
inner_flat_face_distance: float = 10.0,
outer_flat_face_distance: float = 17.0,
char_length: float = 10.0,
create_fibers: bool = False,
fiber_angle_endo: float = 60,
fiber_angle_epi: float = -60,
fiber_space: str = "P_1",
aha: bool = False,
fillet_radius: float | None = None,
verbose: bool = False,
order: int = 1,
comm: MPI.Comm = MPI.COMM_WORLD,
ghost_mode: dolfinx.mesh.GhostMode = dolfinx.mesh.GhostMode.none,
io_backend: str = DEFAULT_IO_BACKEND,
) -> Geometry:
"""Create a racetrack-shaped thick cylindrical shell mesh using GMSH.
Both the inner and outer surfaces have two flat faces on opposite sides.
Parameters
----------
outdir : Optional[Path], optional
Directory where to save the results.
r_inner : float, optional
Radius on the endocardium layer, by default 13.0
r_outer : float, optional
Radius on the epicardium layer, by default 20.0
height : float, optional
Longest radius on the endocardium layer, by default 10.0
inner_flat_face_distance : float
The distance of the inner flat face from the center (along the x-axis).
This value must be less than inner_radius. Default is 10.0.
outer_flat_face_distance : float
The distance of the outer flat face from the center (along the x-axis).
This value must be less than outer_radius. Default is 17.0.
char_length : float, optional
Characteristic length of mesh, by default 10.0
create_fibers : bool, optional
If True create analytic fibers, by default False
fiber_angle_endo : float, optional
Angle for the endocardium, by default 60
fiber_angle_epi : float, optional
Angle for the epicardium, by default -60
fiber_space : str, optional
Function space for fibers of the form family_degree, by default "P_1"
aha : bool, optional
If True create 17-segment AHA regions
fillet_radius : float | None, optional
Radius for the fillet on the flat faces. If None no fillet is created.
By default None.
verbose : bool, optional
If True print information from gmsh, by default False
order : int, optional
Order of the mesh elements. With order higher than one the elements
are curved to follow the boundaries, by default 1
comm : MPI.Comm, optional
MPI communicator, by default MPI.COMM_WORLD
ghost_mode : dolfinx.mesh.GhostMode, optional
Ghost mode for the dolfinx mesh, by default dolfinx.mesh.GhostMode.none
io_backend : str, optional
io4dolfinx backend used to write the geometry, "adios2" (``geometry.bp``)
or "h5py" (``geometry.h5``), by default "adios2"
Returns
-------
cardiac_geometries.geometry.Geometry
A Geometry with the mesh, markers, markers functions and fibers.
"""
outdir = Path(outdir)
mesh_name = outdir / "cylinder_racetrack.msh"
info = {
"r_inner": r_inner,
"r_outer": r_outer,
"height": height,
"inner_flat_face_distance": inner_flat_face_distance,
"outer_flat_face_distance": outer_flat_face_distance,
"char_length": char_length,
"create_fibers": create_fibers,
"fiber_angle_endo": fiber_angle_endo,
"fiber_angle_epi": fiber_angle_epi,
"fiber_space": fiber_space,
"aha": aha,
"fillet_radius": fillet_radius,
"mesh_type": "cylinder_racetrack",
"order": order,
"cardiac_geometry_version": __version__,
"timestamp": datetime.datetime.now().isoformat(),
"ghost_mode": ghost_mode.name,
}
if comm.rank == 0:
outdir.mkdir(exist_ok=True, parents=True)
with open(outdir / "info.json", "w") as f:
json.dump(
info,
f,
indent=2,
default=utils.json_serial,
)
cgc.cylinder_cut(
inner_radius=r_inner,
outer_radius=r_outer,
inner_flat_face_distance=inner_flat_face_distance,
outer_flat_face_distance=outer_flat_face_distance,
height=height,
mesh_name=mesh_name.as_posix(),
char_length=char_length,
verbose=verbose,
order=order,
mode="racetrack",
fillet_radius=fillet_radius,
)
comm.barrier()
geometry = utils.gmsh2dolfin(comm=comm, msh_file=mesh_name, ghost_mode=ghost_mode)
if comm.rank == 0:
with open(outdir / "markers.json", "w") as f:
json.dump(geometry.markers, f, default=utils.json_serial)
fibers = {}
if create_fibers:
from .fibers.cylinder import create_microstructure
system = create_microstructure(
mesh=geometry.mesh,
function_space=fiber_space,
r_inner=r_inner,
r_outer=r_outer,
alpha_endo=fiber_angle_endo,
alpha_epi=fiber_angle_epi,
outdir=outdir,
)
fibers = {"f0": system.f0, "s0": system.s0, "n0": system.n0}
save_geometry(
path=geometry_path(outdir, io_backend),
mesh=geometry.mesh,
markers=geometry.markers,
info=info,
ffun=geometry.ffun,
efun=geometry.efun,
vfun=geometry.vfun,
f0=fibers.get("f0", None),
s0=fibers.get("s0", None),
n0=fibers.get("n0", None),
)
return Geometry(
mesh=geometry.mesh,
markers=geometry.markers,
cfun=geometry.cfun,
ffun=geometry.ffun,
efun=geometry.efun,
vfun=geometry.vfun,
f0=fibers.get("f0", None),
s0=fibers.get("s0", None),
n0=fibers.get("n0", None),
info=info,
)
[docs]
def cylinder_D_shaped(
outdir: Path | str,
r_inner: float = 13.0,
r_outer: float = 20.0,
height: float = 40.0,
inner_flat_face_distance: float = 10.0,
outer_flat_face_distance: float = 17.0,
floor_thickness: float = 0.0,
roof_thickness: float = 0.0,
char_length: float = 10.0,
create_fibers: bool = False,
fiber_angle_endo: float = 60,
fiber_angle_epi: float = -60,
fiber_space: str = "P_1",
aha: bool = False,
fillet_radius: float | None = None,
verbose: bool = False,
order: int = 1,
comm: MPI.Comm = MPI.COMM_WORLD,
ghost_mode: dolfinx.mesh.GhostMode = dolfinx.mesh.GhostMode.none,
io_backend: str = DEFAULT_IO_BACKEND,
) -> Geometry:
"""Create a D-shaped thick cylindrical shell mesh using GMSH.
Both the inner and outer surfaces have two flat faces on opposite sides.
Parameters
----------
outdir : Optional[Path], optional
Directory where to save the results.
r_inner : float, optional
Radius on the endocardium layer, by default 13.0
r_outer : float, optional
Radius on the epicardium layer, by default 20.0
height : float, optional
Longest radius on the endocardium layer, by default 10.0
inner_flat_face_distance : float
The distance of the inner flat face from the center (along the x-axis).
This value must be less than inner_radius. Default is 10.0.
outer_flat_face_distance : float
The distance of the outer flat face from the center (along the x-axis).
This value must be less than outer_radius. Default is 17.0.
floor_thickness : float, optional
Thickness of the floor, by default 0.0
roof_thickness : float, optional
Thickness of the roof, by default 0.0
char_length : float, optional
Characteristic length of mesh, by default 10.0
create_fibers : bool, optional
If True create analytic fibers, by default False
fiber_angle_endo : float, optional
Angle for the endocardium, by default 60
fiber_angle_epi : float, optional
Angle for the epicardium, by default -60
fiber_space : str, optional
Function space for fibers of the form family_degree, by default "P_1"
aha : bool, optional
If True create 17-segment AHA regions
fillet_radius : float | None, optional
Radius for the fillet on the flat faces. If None no fillet is created.
By default None.
verbose : bool, optional
If True print information from gmsh, by default False
order : int, optional
Order of the mesh elements. With order higher than one the elements
are curved to follow the boundaries, by default 1
comm : MPI.Comm, optional
MPI communicator, by default MPI.COMM_WORLD
ghost_mode : dolfinx.mesh.GhostMode, optional
Ghost mode for the dolfinx mesh, by default dolfinx.mesh.GhostMode.none
io_backend : str, optional
io4dolfinx backend used to write the geometry, "adios2" (``geometry.bp``)
or "h5py" (``geometry.h5``), by default "adios2"
Returns
-------
cardiac_geometries.geometry.Geometry
A Geometry with the mesh, markers, markers functions and fibers.
"""
outdir = Path(outdir)
mesh_name = outdir / "cylinder_D_shaped.msh"
info = {
"r_inner": r_inner,
"r_outer": r_outer,
"height": height,
"inner_flat_face_distance": inner_flat_face_distance,
"outer_flat_face_distance": outer_flat_face_distance,
"floor_thickness": floor_thickness,
"roof_thickness": roof_thickness,
"char_length": char_length,
"create_fibers": create_fibers,
"fiber_angle_endo": fiber_angle_endo,
"fiber_angle_epi": fiber_angle_epi,
"fiber_space": fiber_space,
"aha": aha,
"fillet_radius": fillet_radius,
"mesh_type": "cylinder_D_shaped",
"order": order,
"cardiac_geometry_version": __version__,
"timestamp": datetime.datetime.now().isoformat(),
"ghost_mode": ghost_mode.name,
}
if comm.rank == 0:
outdir.mkdir(exist_ok=True, parents=True)
with open(outdir / "info.json", "w") as f:
json.dump(
info,
f,
indent=2,
default=utils.json_serial,
)
if fillet_radius is not None and np.isclose(fillet_radius, 0.0):
cgc.cylinder_D_shaped(
inner_radius=r_inner,
outer_radius=r_outer,
inner_flat_face_distance=inner_flat_face_distance,
outer_flat_face_distance=outer_flat_face_distance,
floor_thickness=floor_thickness,
roof_thickness=roof_thickness,
height=height,
mesh_name=mesh_name.as_posix(),
char_length=char_length,
verbose=verbose,
order=order,
)
else:
cgc.cylinder_cut(
inner_radius=r_inner,
outer_radius=r_outer,
inner_flat_face_distance=inner_flat_face_distance,
outer_flat_face_distance=outer_flat_face_distance,
floor_thickness=floor_thickness,
roof_thickness=roof_thickness,
height=height,
mesh_name=mesh_name.as_posix(),
char_length=char_length,
verbose=verbose,
order=order,
mode="d_shaped",
fillet_radius=fillet_radius,
)
comm.barrier()
geometry = utils.gmsh2dolfin(comm=comm, msh_file=mesh_name, ghost_mode=ghost_mode)
if comm.rank == 0:
with open(outdir / "markers.json", "w") as f:
json.dump(geometry.markers, f, default=utils.json_serial)
fibers = {}
if create_fibers:
from .fibers.cylinder import create_microstructure
system = create_microstructure(
mesh=geometry.mesh,
function_space=fiber_space,
r_inner=r_inner,
r_outer=r_outer,
alpha_endo=fiber_angle_endo,
alpha_epi=fiber_angle_epi,
outdir=outdir,
)
fibers = {"f0": system.f0, "s0": system.s0, "n0": system.n0}
save_geometry(
path=geometry_path(outdir, io_backend),
mesh=geometry.mesh,
markers=geometry.markers,
info=info,
ffun=geometry.ffun,
efun=geometry.efun,
vfun=geometry.vfun,
f0=fibers.get("f0", None),
s0=fibers.get("s0", None),
n0=fibers.get("n0", None),
)
return Geometry(
mesh=geometry.mesh,
markers=geometry.markers,
cfun=geometry.cfun,
ffun=geometry.ffun,
efun=geometry.efun,
vfun=geometry.vfun,
f0=fibers.get("f0", None),
s0=fibers.get("s0", None),
n0=fibers.get("n0", None),
info=info,
)
def _read_surfaces(surfaces: dict, scale: float) -> dict[str, tuple[np.ndarray, np.ndarray]]:
"""Read triangle surfaces (any format pyvista reads, e.g. PLY, VTP, STL) as (points,
triangles)."""
try:
import pyvista
except ImportError as e:
raise ImportError(
"Reading surfaces needs pyvista: pip install cardiac-geometriesx[pyvista]"
) from e
out = {}
for name, source in surfaces.items():
surface = pyvista.read(source)
if not isinstance(surface, pyvista.PolyData):
surface = surface.extract_surface()
if not surface.is_all_triangles:
raise ValueError(f"Surface {name!r} ({source}) is not a triangle surface")
out[name] = (
np.asarray(surface.points, dtype=float) * scale,
np.asarray(surface.regular_faces, dtype=np.int64),
)
return out
[docs]
def from_volume(
outdir: str | Path,
volume,
surfaces: dict | None = None,
rule=None,
rule_data: list[str] | None = None,
scale: float = 1.0,
tol: float | None = None,
comm: MPI.Comm = MPI.COMM_WORLD,
create_fibers: bool = False,
fiber_method: str = "ldrb",
fiber_space: str = "P_1",
fiber_angle_endo: float = 60.0,
fiber_angle_epi: float = -60.0,
io_backend: str = DEFAULT_IO_BACKEND,
) -> Geometry:
"""A geometry from a tetrahedral volume mesh and labelled boundary surfaces, e.g. a patient
mesh made with another tool.
The volume is read with io4dolfinx's pyvista backend, already distributed, and every boundary
facet gets exactly one marker, either from the surface meshes in ``surfaces`` (marker name to a
file pyvista reads, e.g. PLY; a facet gets the name of the surface with a triangle on its three
vertices) or from ``rule`` (see :func:`cardiac_geometries.labelled.label_facets`). Use the
marker
names of the UKB geometries (``LV_ENDO``, ``RV_ENDO``, ``EPI``, ``BASE``, ``MV``, ``AV``,
``TV``, ``PV``) so the fiber methods find their boundaries: Bayer's base is ``BASE`` plus the
valves, and the method of Doste et al. needs the valves.
Parameters
----------
outdir : str | Path
Folder for the markers and the saved geometry
volume : str | Path
The volume mesh (linear tetrahedra), a file io4dolfinx's pyvista backend reads (e.g. VTU,
VTK)
surfaces : dict | None
Marker name to a triangle surface file
rule : Callable | None
``rule(coords, data)`` returns the marker name of each boundary facet, from its vertex
coordinates (F, 3, 3, scaled) and ``data``: the point data named in ``rule_data`` at those
vertices, each (F, 3)
rule_data : list[str] | None
Names of point data in the volume file that the rule needs
scale : float
Multiplies all coordinates (volume and surfaces), e.g. 1e-3 from micrometres to mm.
Never guessed
tol : float | None
Distance within which a surface point matches a facet vertex (after scaling); by default
round-off (1e-6 of the surfaces' size)
comm : MPI.Comm
The communicator
create_fibers, fiber_method, fiber_space, fiber_angle_endo, fiber_angle_epi
As in :func:`ukb`: fibers by the method of Bayer et al. ("ldrb") or Doste et al. ("doste")
io_backend : str
io4dolfinx backend for the saved geometry
Returns
-------
cardiac_geometries.geometry.Geometry
The mesh, markers, facet tags and (optionally) fibers
"""
import io4dolfinx
from .labelled import check_labels, label_facets, marker_ids
if (surfaces is None) == (rule is None):
raise ValueError("Give exactly one of surfaces and rule")
outdir = Path(outdir)
if comm.rank == 0:
outdir.mkdir(parents=True, exist_ok=True)
# Surfaces are read on rank 0 (so a reading error is raised on every rank) and sent to all
surface_data: dict | None = None
error: Exception | None = None
if surfaces is not None and comm.rank == 0:
try:
surface_data = _read_surfaces(surfaces, scale)
except Exception as e:
error = e
error = comm.bcast(error, root=0)
if error is not None:
raise error
surface_data = comm.bcast(surface_data, root=0)
try:
import pyvista # noqa: F401 (io4dolfinx's pyvista backend needs it)
except ImportError as e:
raise ImportError(
"Reading the volume needs pyvista: pip install cardiac-geometriesx[pyvista]"
) from e
# Double precision whatever the file stores (e.g. legacy VTK in float32): the fiber methods
# need it
mesh = io4dolfinx.read_mesh(
Path(volume), comm, backend="pyvista", backend_args={"dtype": np.float64}
)
if mesh.topology.cell_name() != "tetrahedron":
raise ValueError(f"The volume must be a tetrahedral mesh, got {mesh.topology.cell_name()}")
mesh.geometry.x[:] *= scale
tdim = mesh.topology.dim
mesh.topology.create_connectivity(tdim - 1, tdim)
num_owned = mesh.topology.index_map(tdim - 1).size_local
facets = dolfinx.mesh.exterior_facet_indices(mesh.topology)
facets = facets[facets < num_owned].astype(np.int32)
geometry_dofs = dolfinx.mesh.entities_to_geometry(mesh, tdim - 1, facets)
coords = mesh.geometry.x[geometry_dofs]
data: dict[str, np.ndarray] = {}
for name in rule_data or []:
f = io4dolfinx.read_point_data(Path(volume), name, mesh, backend="pyvista")
from scipy.spatial import cKDTree
x = f.function_space.tabulate_dof_coordinates()
_, dof = cKDTree(x).query(coords.reshape(-1, 3))
data[name] = np.asarray(f.x.array, dtype=float)[dof].reshape(-1, 3)
names, matched = label_facets(coords, surfaces=surface_data, rule=rule, data=data, tol=tol)
sizes = {k: len(t) for k, (_, t) in surface_data.items()} if surface_data else {}
check_labels(names, matched, sizes, comm=comm)
all_names = set().union(*comm.allgather(set(names.tolist())))
markers = marker_ids(all_names)
values = np.array([markers[n][0] for n in names], dtype=np.int32)
order = np.argsort(facets)
ffun = dolfinx.mesh.meshtags(mesh, tdim - 1, facets[order], values[order])
cells = np.arange(mesh.topology.index_map(tdim).size_local, dtype=np.int32)
cfun = dolfinx.mesh.meshtags(
mesh, tdim, cells, np.full(len(cells), markers["Wall"][0], np.int32)
)
ffun.name, cfun.name = "Facet tags", "Cell tags"
info = {
"mesh_type": "from_volume",
"volume": str(volume),
"rule_data": list(rule_data or []),
"scale": scale,
"tol": tol,
"fiber_method": fiber_method if create_fibers else None,
"fiber_space": fiber_space,
"fiber_angle_endo": fiber_angle_endo,
"fiber_angle_epi": fiber_angle_epi,
"cardiac_geometry_version": __version__,
"timestamp": datetime.datetime.now().isoformat(),
}
if comm.rank == 0:
(outdir / "markers.json").write_text(json.dumps(markers, default=utils.json_serial))
(outdir / "info.json").write_text(json.dumps(info, default=utils.json_serial))
fibers: dict = {}
if create_fibers:
from .fibers import ldrb_fibers
if fiber_method == "doste":
doste_markers(markers) # a clear error without the valves
angles: dict[str, typing.Any] = {
"alpha_endo_lv": fiber_angle_endo,
"alpha_epi_lv": fiber_angle_epi,
}
if fiber_method == "ldrb":
angles |= {"beta_endo_lv": 0, "beta_epi_lv": 0}
_, microstructure = ldrb_fibers(
mesh=mesh,
ffun=ffun,
cfun=cfun,
markers=markers,
fiber_method=fiber_method,
fiber_space=fiber_space,
**angles,
)
fibers = microstructure._asdict()
save_geometry(
path=geometry_path(outdir, io_backend),
mesh=mesh,
markers=markers,
info=info,
cfun=cfun,
ffun=ffun,
efun=None,
vfun=None,
f0=fibers.get("f0", None),
s0=fibers.get("s0", None),
n0=fibers.get("n0", None),
)
return Geometry(
mesh=mesh,
markers=markers,
cfun=cfun,
ffun=ffun,
efun=None,
vfun=None,
f0=fibers.get("f0", None),
s0=fibers.get("s0", None),
n0=fibers.get("n0", None),
info=info,
)