Skip to content

Commit dd4c98f

Browse files
fix: D-RBM fiber generation (#1227)
Co-authored-by: pyansys-ci-bot <[email protected]>
1 parent 15d1f78 commit dd4c98f

File tree

7 files changed

+203
-118
lines changed

7 files changed

+203
-118
lines changed

doc/source/changelog/1227.fixed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
D-RBM fiber generation

examples/simulator/mechanics-simulator-leftventricle_pr.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,11 +151,31 @@
151151
# Compute fiber orientation
152152
# ~~~~~~~~~~~~~~~~~~~~~~~~~
153153
# Compute fiber orientation and plot the fibers.
154-
simulator.compute_fibers(method="D-RBM")
154+
155+
# Set rotation angles:
156+
rotation_angles = {
157+
"alpha_left": [60, -60],
158+
"alpha_right": [60, -60],
159+
"alpha_ot": None,
160+
"beta_left": [-20, 20],
161+
"beta_right": [-20, 20],
162+
"beta_ot": None,
163+
}
164+
simulator.compute_fibers(method="D-RBM", rotation_angles=rotation_angles)
155165

156166
# Plot the fiber orientation by streamlines.
157167
simulator.model.plot_fibers(n_seed_points=2000)
158168

169+
###############################################################################
170+
# .. note::
171+
# The D-RBM method is based on Doste et al. (2021) and in this implementation
172+
# the rotation of the local coordinate system follows the right hand rule, where
173+
# the transmural direction is pointing from endocardium to epicardium, the
174+
# longitudinal direction is pointing from apex to base. Consequently, the
175+
# circumferential direction is orthogonal to both and follows from the cross
176+
# product of longtiduinal and transmural directions.
177+
178+
###############################################################################
159179

160180
###############################################################################
161181
# Assign a material to the left ventricle.
@@ -181,6 +201,10 @@
181201
# Compute the stress-free configuration
182202
# ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
183203

204+
# Create a stiff region at the base of the ventricle
205+
simulator.compute_uhc()
206+
stiff_part = simulator.model.create_stiff_ventricle_base(threshold_left_ventricle=0.9)
207+
184208
# Compute the stress-free configuration and estimate initial stress.
185209
report, stress_free_coord, guess_ed_coord = simulator.compute_stress_free_configuration()
186210

src/ansys/health/heart/post/laplace_post.py

Lines changed: 119 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
from deprecated import deprecated
2828
import numpy as np
2929
import pyvista as pv
30+
from scipy.spatial.transform import Rotation as R # noqa N817
3031

3132
from ansys.health.heart import LOG as LOGGER
3233
from ansys.health.heart.exceptions import D3PlotNotSupportedError
@@ -115,43 +116,45 @@ def update_transmural_by_normal(grid: pv.UnstructuredGrid, surface: pv.PolyData)
115116
return grad_trans
116117

117118

118-
def orthogonalization(
119-
grad_trans: np.ndarray, k: np.ndarray
120-
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
121-
"""Orthogonalization.
119+
def orthogonalization(e1: np.ndarray, e2: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
120+
"""Create a orthonormal coordinate system.
122121
123122
Parameters
124123
----------
125-
grad_trans : np.ndarray
126-
Transmural vector.
127-
k : np.ndarray
128-
Bundle selection vector.
124+
e1 : np.ndarray
125+
First unit (N,M) vector of the coordinate system.
126+
e2 : np.ndarray
127+
Second unit (N,M) vector of the coordinate system.
128+
129+
Notes
130+
-----
131+
e3 is orthogonal to the plane spanned by e1 and e2 following the right hand rule.
132+
Project e2 onto e1, and subtract to ensure orthogonality.
129133
130134
Returns
131135
-------
132136
tuple[np.ndarray, np.ndarray, np.ndarray]
133-
Local coordinate system ``e_l, e_n, e_t``.
137+
Local orthonormal coordinate system ``e1, e2, e3``.
134138
"""
135-
norm = np.linalg.norm(grad_trans, axis=1)
136-
bad_cells = np.argwhere(norm == 0).ravel()
139+
e1_norm = np.linalg.norm(e1, axis=1)
140+
bad_vectors = np.argwhere(e1_norm == 0).ravel()
137141

138-
LOGGER.debug(
139-
f"{len(bad_cells)} cells have null gradient in transmural direction."
140-
f" This should only be at valve regions and can be checked from the VTK file."
141-
)
142+
LOGGER.debug(f"{len(bad_vectors)} vectors have length zero.")
143+
144+
e1_norm = np.where(e1_norm != 0, e1_norm, 1)
145+
e1 = e1 / e1_norm[:, None]
142146

143-
norm = np.where(norm != 0, norm, 1)
144-
e_t = grad_trans / norm[:, None]
147+
# Ensure e1 and e2 are orthogonal
148+
dot_prod = np.einsum("ij,ij->i", e1, e2)
149+
e2 = e2 - dot_prod[:, None] * e1
145150

146-
k_e = np.einsum("ij,ij->i", k, e_t)
147-
en = k - np.einsum("i,ij->ij", k_e, e_t)
148-
norm = np.linalg.norm(en, axis=1)
149-
norm = np.where(norm != 0, norm, 1)
150-
e_n = en / norm[:, None]
151+
# Normalize
152+
e2 /= np.linalg.norm(e2, axis=1)[:, None]
151153

152-
e_l = np.cross(e_n, e_t)
154+
# Use right hand rule to compute e3
155+
e3 = np.cross(e1, e2)
153156

154-
return e_l, e_n, e_t
157+
return e1, e2, e3
155158

156159

157160
def compute_la_fiber_cs(
@@ -224,7 +227,8 @@ def bundle_selection(grid):
224227

225228
bundle_selection(grid)
226229

227-
el, en, et = orthogonalization(grid["grad_trans"], grid["k"])
230+
et, en, _ = orthogonalization(grid["grad_trans"], grid["k"])
231+
el = np.cross(en, et)
228232

229233
grid.cell_data["e_l"] = el
230234
grid.cell_data["e_n"] = en
@@ -364,7 +368,8 @@ def bundle_selection(grid):
364368

365369
bundle_selection(grid)
366370

367-
el, en, et = orthogonalization(grid["grad_trans"], grid["k"])
371+
et, en, _ = orthogonalization(grid["grad_trans"], grid["k"])
372+
el = np.cross(en, et)
368373

369374
grid.cell_data["e_l"] = el
370375
grid.cell_data["e_n"] = en
@@ -414,6 +419,7 @@ def _sigmoid(z):
414419
return ro_endo, ro_epi
415420

416421

422+
@deprecated(reason="Use _compute_rotation_angles instead.")
417423
def compute_rotation_angle(
418424
grid: pv.UnstructuredGrid,
419425
w: np.ndarray,
@@ -445,16 +451,34 @@ def compute_rotation_angle(
445451
rot_endo, rot_epi = set_rotation_bounds(w, rotation[0], rotation[1], outflow_tracts)
446452

447453
# interpolate along transmural direction
454+
# follow definition in Doste et al:
455+
# α = α_endo(w) · (1 − d) + α_epi(w) · d
456+
448457
angle = np.zeros(grid.n_cells)
449-
angle = rot_epi * (np.ones(grid.n_cells) - grid["d"]) + rot_endo * grid["d"]
458+
# angle = rot_epi * (np.ones(grid.n_cells) - grid["d"]) + rot_endo * grid["d"]
459+
angle = rot_endo * (np.ones(grid.n_cells) - grid["d"]) + rot_epi * grid["d"]
460+
return angle
461+
462+
463+
def _compute_rotation_angle(
464+
transmural_distance: float | list | np.ndarray,
465+
rotation_endocardium: float,
466+
rotation_epicardium: float,
467+
):
468+
"""Compute the rotation angle a for a given transmural depth and weight factor."""
469+
# follow definition in Doste et al:
470+
# α = α_endo(w) · (1 − d) + α_epi(w) · d
471+
angle = (
472+
rotation_endocardium * (1 - transmural_distance) + rotation_epicardium * transmural_distance
473+
)
450474
return angle
451475

452476

453477
def compute_ventricle_fiber_by_drbm(
454478
directory: str,
455479
settings: dict = {
456-
"alpha_left": [-60, 60],
457-
"alpha_right": [-60, 60],
480+
"alpha_left": [60, -60],
481+
"alpha_right": [90, -25],
458482
"alpha_ot": None,
459483
"beta_left": [-65, 25],
460484
"beta_right": [-65, 25],
@@ -486,7 +510,7 @@ def compute_ventricle_fiber_by_drbm(
486510
"""
487511
solutions = ["trans", "ab_l", "ot_l", "w_l"]
488512
if not left_only:
489-
solutions.extend(["ab_r", "ot_r", "w_r", "lr"])
513+
solutions.extend(["ab_r", "ot_r", "w_r"])
490514

491515
data = read_laplace_solution(directory, field_list=solutions, read_heatflux=True)
492516
grid = data.point_data_to_cell_data()
@@ -495,14 +519,14 @@ def compute_ventricle_fiber_by_drbm(
495519
# label to 1 for all cells
496520
left_mask = np.ones(grid.n_cells, dtype=bool)
497521
grid.cell_data["label"] = np.ones(grid.n_cells, dtype=int)
522+
right_mask = np.invert(left_mask)
498523
else:
499524
# label to 1 for left ventricle, 2 for right ventricle
500-
left_mask = grid["lr"] >= 0
501-
right_mask = grid["lr"] < 0
502-
label = np.zeros(grid.n_cells, dtype=int)
503-
label[left_mask] = 1
504-
label[right_mask] = 2
505-
grid.cell_data["label"] = label
525+
left_mask = grid["trans"] <= 0
526+
right_mask = grid["trans"] > 0
527+
grid.cell_data["label"] = np.zeros(grid.n_cells, dtype=int)
528+
grid.cell_data["label"][left_mask] = 1
529+
grid.cell_data["label"][right_mask] = 2
506530

507531
# normal direction
508532
k = np.zeros((grid.n_cells, 3))
@@ -517,70 +541,78 @@ def compute_ventricle_fiber_by_drbm(
517541

518542
grid.cell_data["k"] = k
519543

520-
# build local coordinate system
521-
if not left_only:
522-
grid.cell_data["grad_trans"][right_mask] *= -1.0 # both LV & RV point to inside
544+
# Build local coordinate system:
545+
# The right ventricle transmural gradient is flipped to ensure
546+
# a consistent coordinate system:
547+
# e_t points from endocardium to epicardium
548+
# e_n points from apex to base
549+
# e_c = e_n x e_t
550+
grid.cell_data["grad_trans"][right_mask] *= -1.0 # both LV & RV point to inside
551+
552+
# Create orthonormal coordinate system
553+
en, et, ec = orthogonalization(k, grid["grad_trans"])
523554

524-
el, en, et = orthogonalization(grid["grad_trans"], k)
555+
# Add (unrotated) local coordinate system
556+
grid.cell_data["e_c"] = ec # circumferential direction
557+
grid.cell_data["e_n"] = en # normal/longitudinal direction
558+
grid.cell_data["e_t"] = et # transmural direction
525559

526-
# normalized transmural distance
527560
if left_only:
528561
grid["d"] = grid["trans"]
529562
else:
530-
d_l = grid["trans"] / 2
531-
d_r = np.absolute(grid["trans"])
563+
# normalize transmural distance to [0,1) in each ventricle
564+
# where 0 is endocardium, and 1 is epicardium
565+
d_l = np.absolute(grid["trans"][left_mask] / 2)
566+
d_r = np.absolute(grid["trans"][right_mask])
532567
grid["d"] = np.zeros(grid.n_cells, dtype=float)
533-
grid["d"][left_mask] = d_l[left_mask]
534-
grid["d"][right_mask] = d_r[right_mask]
568+
grid["d"][left_mask] = d_l
569+
grid["d"][right_mask] = d_r
570+
grid["d"] = grid["d"] * -1 + 1
535571

536-
# rotation angles for each cell
572+
# rotation angles alpha and beta for each cell
537573
alpha = np.zeros(grid.n_cells)
538574
beta = np.zeros(grid.n_cells)
539575

540-
alpha[left_mask] = compute_rotation_angle(
541-
grid, grid["w_l"], settings["alpha_left"], settings["alpha_ot"]
542-
)[left_mask]
543-
beta[left_mask] = compute_rotation_angle(
544-
grid, grid["w_l"], settings["beta_left"], settings["beta_ot"]
545-
)[left_mask]
576+
alpha[left_mask] = _compute_rotation_angle(
577+
grid["d"][left_mask], settings["alpha_left"][0], settings["alpha_left"][1]
578+
)
579+
alpha[right_mask] = _compute_rotation_angle(
580+
grid["d"][right_mask], settings["alpha_right"][0], settings["alpha_right"][1]
581+
)
546582

547-
if not left_only:
548-
alpha[right_mask] = compute_rotation_angle(
549-
grid, grid["w_r"], settings["alpha_right"], settings["alpha_ot"]
550-
)[right_mask]
551-
beta[right_mask] = compute_rotation_angle(
552-
grid, grid["w_r"], settings["beta_right"], settings["beta_ot"]
553-
)[right_mask]
554-
555-
# save data for inspection
583+
beta[left_mask] = _compute_rotation_angle(
584+
grid["d"][left_mask], settings["beta_left"][0], settings["beta_left"][1]
585+
)
586+
beta[right_mask] = _compute_rotation_angle(
587+
grid["d"][right_mask], settings["beta_right"][0], settings["beta_right"][1]
588+
)
589+
590+
# save rotation angles
556591
grid.cell_data["alpha"] = alpha
557592
grid.cell_data["beta"] = beta
558593

559-
#
560-
grid.cell_data["fiber"] = np.zeros((grid.n_cells, 3))
561-
562-
# use f,n,s in Quateroni, it's n, cross fiber
563-
# use FTS in Bayer, it's S, sheet normal
564-
grid.cell_data["cross-fiber"] = np.zeros((grid.n_cells, 3))
565-
566-
# use f,n,s in Quateroni, it's s, sheet
567-
# use FTS in Bayer, it's T, transverse
568-
grid.cell_data["sheet"] = np.zeros((grid.n_cells, 3))
569-
570-
# apply rotation
571-
for i in range(grid.n_cells):
572-
q = np.array([el[i], en[i], et[i]]).T
573-
# rotate alpha around e_t
574-
a = alpha[i] * np.pi / 180
575-
rot1 = np.array([[np.cos(a), -np.sin(a), 0], [np.sin(a), np.cos(a), 0], [0, 0, 1]])
576-
# rotate beta around e_l
577-
b = beta[i] * np.pi / 180
578-
rot2 = np.array([[1, 0, 0], [0, np.cos(b), np.sin(b)], [0, -np.sin(b), np.cos(b)]])
579-
# apply rotation
580-
qq = np.matmul(np.matmul(q, rot1), rot2)
581-
582-
grid.cell_data["fiber"][i] = qq[:, 0]
583-
grid.cell_data["cross-fiber"][i] = qq[:, 1]
584-
grid.cell_data["sheet"][i] = qq[:, 2]
594+
# 1) rotate vector ec counterclockwise around et by an angle alpha
595+
rot_alpha = R.from_rotvec(alpha[:, None] * et, degrees=True)
596+
597+
fibers = rot_alpha.apply(ec)
598+
cross_fibers = rot_alpha.apply(en)
599+
sheets = et
600+
601+
# 2) rotate vector ec counterclockwise around el or fibers by an angle beta
602+
rot_beta = R.from_rotvec(beta[:, None] * fibers, degrees=True)
603+
604+
cross_fibers = rot_beta.apply(cross_fibers)
605+
sheets = rot_beta.apply(sheets)
606+
607+
# NOTE Can add additional rotation in transverse direction, by specifying a
608+
# transverse angle gamma.
609+
610+
# {f,n,s} in Piersanti et al. cross-fiber is sheet normal n
611+
# {F,T,S} in Bayer et al. cross-fiber is sheet normal S
612+
grid.cell_data["fiber"] = fibers
613+
grid.cell_data["cross-fiber"] = cross_fibers
614+
grid.cell_data["sheet"] = sheets
615+
616+
grid.save("d-rbm-fibers.vtu")
585617

586618
return grid.copy()

src/ansys/health/heart/writer/laplace_writer.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -590,7 +590,7 @@ def clean_node_set(nodes: np.ndarray, exclude_nodes: np.ndarray = None) -> np.nd
590590

591591
# add case kewyords
592592
cases = [
593-
(1, "trans", [lv_endo_nodeset_id, epi_nodeset_id], [1, 0]),
593+
(1, "trans", [lv_endo_nodeset_id, epi_nodeset_id], [0, 1]),
594594
(2, "ab_l", [mv_nodeset_id, la_nodeset_id], [1, 0]),
595595
(3, "ot_l", [av_nodeset_id, la_nodeset_id], [1, 0]),
596596
# If combined MV and AV, mv_nodeset=av_nodeset=combined, solve ab_l = ot_l
@@ -611,8 +611,9 @@ def clean_node_set(nodes: np.ndarray, exclude_nodes: np.ndarray = None) -> np.nd
611611
ra_nodeset_id = self._add_nodeset(ra_node, "right apex")
612612

613613
# add case kewyords
614+
# Use values given by Doste et al.
614615
cases = [
615-
(1, "trans", [lv_endo_nodeset_id, rv_endo_nodeset_id, epi_nodeset_id], [2, -1, 0]),
616+
(1, "trans", [lv_endo_nodeset_id, rv_endo_nodeset_id, epi_nodeset_id], [-2, 1, 0]),
616617
(2, "ab_l", [mv_nodeset_id, la_nodeset_id], [1, 0]),
617618
(3, "ab_r", [tv_nodeset_id, ra_nodeset_id], [1, 0]),
618619
(4, "ot_l", [av_nodeset_id, la_nodeset_id], [1, 0]),
@@ -623,7 +624,6 @@ def clean_node_set(nodes: np.ndarray, exclude_nodes: np.ndarray = None) -> np.nd
623624
if combined_av_mv
624625
else (6, "w_l", [mv_nodeset_id, la_nodeset_id, av_nodeset_id], [1, 1, 0]),
625626
(7, "w_r", [tv_nodeset_id, ra_nodeset_id, pv_nodeset_id], [1, 1, 0]),
626-
(8, "lr", [lv_endo_nodeset_id, rv_endo_nodeset_id], [1, -1]),
627627
]
628628

629629
for case_id, job_name, set_ids, bc_values in cases:

tests/heart/assets/post/drbm/data.vtu

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)