Skip to content

Commit d9c9be7

Browse files
authored
Merge pull request #172 from finsberg/frank-starling
Add frank-starling demo and implementation
2 parents e13b90c + 75e7397 commit d9c9be7

10 files changed

Lines changed: 473 additions & 4 deletions

File tree

_toc.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ parts:
3232
- file: "demo/time_dependent/time_dependent_land_circ_lv"
3333
- file: "demo/time_dependent/time_dependent_land_circ_biv"
3434
- file: "demo/time_dependent/complete_cycle"
35+
- file: "demo/time_dependent/frank_starling_twitch"
3536
- file: "demo/postprocess_cycle"
3637

3738
- caption: Community

conf.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"sphinx_codeautolink",
3737
"sphinx_multitoc_numbering",
3838
]
39+
autodoc_typehints = "description"
3940
external_toc_exclude_missing = True
4041
external_toc_path = "_toc.yml"
4142
html_baseurl = ""
@@ -102,3 +103,10 @@
102103
suppress_warnings = ["mystnb.unknown_mime_type", "bibtex.duplicate_citation"]
103104
use_jupyterbook_latex = True
104105
use_multitoc_numbering = True
106+
nitpick_ignore = [
107+
("py:class", "T"),
108+
("py:class", "pulse.problem.DynamicProblem.Function.T"),
109+
("py:class", "pulse.problem.StaticProblem.Function.T"),
110+
("py:class", "dolfinx.fem.function.Function"),
111+
("py:class", "dolfinx.fem.petsc.NonlinearProblem"),
112+
]
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
# %% [markdown]
2+
# # Isometric Contraction and the Frank-Starling Mechanism
3+
#
4+
# In cardiac mechanics, the **Frank-Starling mechanism** describes how the heart inherently generates a greater active contraction force when it is subjected to a larger initial stretch (preload). At the cellular level, stretching the tissue moves the sarcomeres into a more optimal overlap configuration, allowing more myosin-actin cross-bridges to form.
5+
#
6+
# In this tutorial, we will demonstrate this physiological phenomenon by conducting an **isometric twitch experiment** on a slab of myocardial tissue. We will:
7+
# 1. Stretch the tissue to a specific length and lock the boundaries (isometric condition).
8+
# 2. Measure the **passive** stress generated by stretching the hyperelastic collagen/myocardium matrix.
9+
# 3. Activate the tissue and measure the additional **active** stress generated.
10+
# 4. Repeat this for different stretch lengths to reconstruct the ascending limb of the cardiac length-tension curve.
11+
12+
# %% [markdown]
13+
# ## 1. Imports and Problem Setup
14+
# First, we import the necessary modules. We use `pulse` for the mechanics framework, `dolfinx` for the finite element backend, and `mpi4py` for parallel execution.
15+
16+
# %%
17+
from mpi4py import MPI
18+
import dolfinx
19+
import pulse
20+
import numpy as np
21+
import ufl
22+
from scifem import evaluate_function
23+
24+
# %% [markdown]
25+
# ## 2. Defining the Isometric Twitch Experiment
26+
# We define a function that takes a prescribed pre-stretch (in mm) and an active twitch tension (in kPa). It will stretch a 10x1x1 mm block of tissue, lock it in place, and then apply the active tension.
27+
28+
# %%
29+
def run_isometric_twitch(pre_stretch_mm: float, twitch_activation: float):
30+
"""
31+
Runs an isometric contraction on a 10x1x1 slab.
32+
1. Stretches the right face by `pre_stretch_mm` and locks it.
33+
2. Applies `twitch_activation` (kPa) to the active model.
34+
3. Returns the average passive and active fiber stresses.
35+
"""
36+
# Create a 10 x 1 x 1 mm slab mesh
37+
L = 10.0
38+
mesh = dolfinx.mesh.create_box(
39+
MPI.COMM_WORLD,
40+
[[0.0, 0.0, 0.0], [L, 1.0, 1.0]],
41+
[10, 2, 2],
42+
)
43+
44+
# Define microstructure (fibers aligned with the X-axis)
45+
f0 = dolfinx.fem.Constant(mesh, dolfinx.default_scalar_type((1.0, 0.0, 0.0)))
46+
s0 = dolfinx.fem.Constant(mesh, dolfinx.default_scalar_type((0.0, 1.0, 0.0)))
47+
48+
# Trackers for active tension
49+
Ta = pulse.Variable(
50+
dolfinx.fem.Constant(mesh, dolfinx.default_scalar_type(0.0)), "kPa",
51+
)
52+
53+
# Set up the passive model
54+
material_params = pulse.HolzapfelOgden.transversely_isotropic_parameters()
55+
passive_model = pulse.HolzapfelOgden(
56+
f0=f0, s0=s0, **material_params,
57+
)
58+
59+
# Set up our custom stretch-dependent active model
60+
active_model = pulse.FrankStarlingActiveStress(f0=f0, activation=Ta)
61+
62+
# Combine into a fully compressible cardiac model
63+
model = pulse.CardiacModel(
64+
material=passive_model,
65+
active=active_model,
66+
compressibility=pulse.Compressible2(),
67+
)
68+
69+
# --- Geometry and Boundary Conditions ---
70+
boundaries = [
71+
pulse.Marker(name="X0", marker=1, dim=2, locator=lambda x: np.isclose(x[0], 0)),
72+
pulse.Marker(name="X1", marker=2, dim=2, locator=lambda x: np.isclose(x[0], L)),
73+
]
74+
75+
geo = pulse.Geometry(
76+
mesh=mesh,
77+
boundaries=boundaries,
78+
metadata={"quadrature_degree": 4},
79+
)
80+
81+
def dirichlet_bc(
82+
V: dolfinx.fem.FunctionSpace,
83+
) -> list[dolfinx.fem.bcs.DirichletBC]:
84+
mesh.topology.create_connectivity(mesh.topology.dim - 1, mesh.topology.dim)
85+
86+
# 1. Lock the left face
87+
facets_fixed = geo.facet_tags.find(1)
88+
dofs = dolfinx.fem.locate_dofs_topological(V, 2, facets_fixed)
89+
u_fixed = dolfinx.fem.Function(V)
90+
u_fixed.x.array[:] = 0.0
91+
92+
# 2. Stretch the right face in the X direction only
93+
facets_stretch = geo.facet_tags.find(2)
94+
V_x, _ = V.sub(0).collapse()
95+
dofs_x = dolfinx.fem.locate_dofs_topological((V.sub(0), V_x), 2, facets_stretch)
96+
97+
u_stretch_x = dolfinx.fem.Function(V_x)
98+
u_stretch_x.x.array[:] = pre_stretch_mm
99+
100+
return [
101+
dolfinx.fem.dirichletbc(u_stretch_x, dofs_x, V.sub(0)),
102+
dolfinx.fem.dirichletbc(u_fixed, dofs),
103+
]
104+
105+
bcs = pulse.BoundaryConditions(dirichlet=(dirichlet_bc,))
106+
107+
# Set up the FEniCSx-Pulse mechanics problem
108+
parameters = {"mesh_unit": "mm"}
109+
problem = pulse.StaticProblem(model=model, geometry=geo, bcs=bcs, parameters=parameters)
110+
point = np.array([[L, 0.5, 0.5]])
111+
112+
# --- Phase 1: Passive Pre-Stretch ---
113+
# Solve for the passive stretch equilibrium
114+
problem.solve()
115+
u_pre_stretch = evaluate_function(problem.u, point)[0][0] # X-displacement at the tip
116+
length_pre_stretch = L + u_pre_stretch
117+
118+
# Formulate the average fiber stress
119+
F = ufl.variable(ufl.grad(problem.u) + ufl.Identity(3))
120+
f = F * f0
121+
f_norm = f / ufl.sqrt(ufl.inner(f, f))
122+
123+
# Integrate to find the domain volume
124+
volume = mesh.comm.allreduce(
125+
dolfinx.fem.assemble_scalar(dolfinx.fem.form(ufl.det(F) * geo.dx)), op=MPI.SUM,
126+
)
127+
128+
# Integrate Cauchy stress projected onto the deformed fiber direction
129+
Tf = dolfinx.fem.form(ufl.inner(model.sigma(F) * f_norm, f_norm) * geo.dx)
130+
passive_force = mesh.comm.allreduce(dolfinx.fem.assemble_scalar(Tf), op=MPI.SUM) / volume
131+
132+
# --- Phase 2: Active Twitch ---
133+
nsteps = 10
134+
for step in range(nsteps):
135+
# Ramp up activation over nsteps steps to aid Newton convergence
136+
Ta.assign(twitch_activation * (step + 1) / nsteps)
137+
problem.solve()
138+
139+
u_twitch = evaluate_function(problem.u, point)[0][0]
140+
length_twitch = L + u_twitch
141+
142+
# Calculate total stress after activation
143+
total_force = mesh.comm.allreduce(dolfinx.fem.assemble_scalar(Tf), op=MPI.SUM) / volume
144+
145+
# Isolate the active contribution
146+
active_force = total_force - passive_force
147+
148+
return passive_force, active_force
149+
150+
151+
# %% [markdown]
152+
# ## 3. Running the Curve
153+
# To generate the length-tension curve, we loop over increasing amounts of stretch (from 0% up to 15%) and run the experiment. We can then observe the non-linear stiffening of the passive matrix against the dynamic active response of the tissue.
154+
155+
# %%
156+
if __name__ == "__main__":
157+
twitch_force = 1.0 # kPa of active baseline tension
158+
159+
# We will test stretches from 0 mm (0%) up to 1.5 mm (15%)
160+
stretch_amounts = [0.0, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5]
161+
162+
print(f"{'Stretch (mm)':>12} | {'Stretch (%)':>12} | {'Passive Stress (kPa)':>20} | {'Active Stress (kPa)':>20}")
163+
print("-" * 75)
164+
165+
for stretch in stretch_amounts:
166+
p_force, a_force = run_isometric_twitch(pre_stretch_mm=stretch, twitch_activation=twitch_force)
167+
strain_pct = (stretch / 10.0) * 100
168+
print(f"{stretch:12.2f} | {strain_pct:12.2f} | {p_force:20.4f} | {a_force:20.4f}")
169+
170+
# %% [markdown]
171+
# ### Conclusion
172+
#
173+
# The output from the script prints a table showcasing:
174+
# 1. The **Passive Stress** climbs exponentially as the tissue is stretched (capturing the non-linear stiffening of the collagen matrix).
175+
# 2. The **Active Stress** scales independently and mimics the Frank-Starling law. At 0% stretch, the tissue produces low active force. As it is stretched towards 15%, the local fibers dynamically detect the stretch and generate a much larger active contractile stress.

docs/api.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ geometry
7676

7777

7878
problem
79-
----------------
79+
-------
8080
.. automodule:: pulse.problem
8181
:members:
8282
:inherited-members:

src/pulse/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
viscoelasticity,
3030
)
3131
from .active_model import ActiveModel, Passive
32-
from .active_stress import ActiveStress
32+
from .active_stress import ActiveStress, FrankStarlingActiveStress
3333
from .boundary_conditions import BoundaryConditions, NeumannBC, RobinBC
3434
from .cardiac_model import CardiacModel
3535
from .compressibility import (
@@ -106,4 +106,5 @@
106106
"FixedPointUnloader",
107107
"PrestressProblem",
108108
"TargetPressure",
109+
"FrankStarlingActiveStress",
109110
]

src/pulse/active_model.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,9 @@ def P(self, F: ufl.core.expr.Expr, dev: bool = False) -> ufl.core.expr.Expr:
154154
Cdev = C
155155
return ufl.diff(self.strain_energy(Cdev), F)
156156

157+
def register(self, u: dolfinx.fem.Function) -> None:
158+
pass
159+
157160

158161
class Passive(ActiveModel):
159162
"""Active model with no active component.

0 commit comments

Comments
 (0)