Skip to content

Commit 547be87

Browse files
committed
Add comparison with non-frankstartlig isometric twitch
1 parent d9c9be7 commit 547be87

2 files changed

Lines changed: 131 additions & 30 deletions

File tree

demo/time_dependent/frank_starling_twitch.py

Lines changed: 129 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,26 +7,63 @@
77
# 1. Stretch the tissue to a specific length and lock the boundaries (isometric condition).
88
# 2. Measure the **passive** stress generated by stretching the hyperelastic collagen/myocardium matrix.
99
# 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-
10+
# 4. Compare a standard active stress model against our custom Frank-Starling model.
11+
#
12+
# ## Mathematical Formulation of Active Stress
13+
# In a standard active stress approach, the total 2nd Piola-Kirchhoff active stress acts purely along the reference fiber direction $\mathbf{f}_0$ and is independent of stretch:
14+
#
15+
# $$
16+
# \mathbf{S}_{\text{act}} = a(t) \, \mathbf{f}_0 \otimes \mathbf{f}_0
17+
# $$
18+
#
19+
# Where $a(t)$ is the time-dependent activation scalar.
20+
#
21+
# For the **Frank-Starling** mechanism, we introduce a stretch-dependent scalar multiplier $g(\lambda)$. The total active First Piola-Kirchhoff stress ($\mathbf{P}_{\text{act}}$) evaluated by the FEM solver becomes:
22+
#
23+
# $$
24+
# \mathbf{P}_{\text{act}} = \mathbf{F} \mathbf{S}_{\text{act}} = \mathbf{F} \Big( \underbrace{a(t) \cdot g(\lambda)}_{T_a} \, \mathbf{f}_0 \otimes \mathbf{f}_0 \Big)
25+
# $$
26+
#
27+
# Where $\mathbf{F}$ is the deformation gradient, and $\lambda = \sqrt{\mathbf{f}_0 \cdot (\mathbf{F}^T \mathbf{F} \mathbf{f}_0)}$ is the dynamic fiber stretch.
28+
#
29+
# The multiplier $g(\lambda)$ is defined as a piecewise function representing the ascending limb of the length-tension curve:
30+
#
31+
# $$
32+
# g(\lambda) =
33+
# \begin{cases}
34+
# a_{\min} & \text{if } \lambda \le \lambda_{\text{thres}} \\
35+
# a_{\min} + m (\lambda - \lambda_{\text{thres}}) & \text{if } \lambda_{\text{thres}} < \lambda \le \lambda_{\text{opt}} \\
36+
# a_{\max} & \text{if } \lambda > \lambda_{\text{opt}}
37+
# \end{cases}
38+
# $$
39+
#
40+
# Where the slope $m$ is calculated as:
41+
#
42+
# $$
43+
# m = \frac{a_{\max} - a_{\min}}{\lambda_{\text{opt}} - \lambda_{\text{thres}}}
44+
# $$
45+
#
1246
# %% [markdown]
1347
# ## 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.
48+
# First, we import the necessary modules. We use `pulse` for the mechanics framework, `dolfinx` for the finite element backend, `mpi4py` for parallel execution, and `matplotlib` for plotting our final results.
1549

1650
# %%
1751
from mpi4py import MPI
1852
import dolfinx
1953
import pulse
2054
import numpy as np
2155
import ufl
56+
import matplotlib.pyplot as plt
2257
from scifem import evaluate_function
2358

2459
# %% [markdown]
2560
# ## 2. Defining the Isometric Twitch Experiment
2661
# 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.
62+
#
63+
# We include a boolean flag `use_frank_starling` to easily toggle between the standard active stress model and our stretch-dependent model.
2764

2865
# %%
29-
def run_isometric_twitch(pre_stretch_mm: float, twitch_activation: float):
66+
def run_isometric_twitch(pre_stretch_mm: float, twitch_activation: float, use_frank_starling: bool = True):
3067
"""
3168
Runs an isometric contraction on a 10x1x1 slab.
3269
1. Stretches the right face by `pre_stretch_mm` and locks it.
@@ -50,14 +87,17 @@ def run_isometric_twitch(pre_stretch_mm: float, twitch_activation: float):
5087
dolfinx.fem.Constant(mesh, dolfinx.default_scalar_type(0.0)), "kPa",
5188
)
5289

53-
# Set up the passive model
90+
# Set up the passive material model
5491
material_params = pulse.HolzapfelOgden.transversely_isotropic_parameters()
5592
passive_model = pulse.HolzapfelOgden(
5693
f0=f0, s0=s0, **material_params,
5794
)
5895

59-
# Set up our custom stretch-dependent active model
60-
active_model = pulse.FrankStarlingActiveStress(f0=f0, activation=Ta)
96+
# Toggle between the Frank-Starling model and the standard Active Stress model
97+
if not use_frank_starling:
98+
active_model = pulse.ActiveStress(f0=f0, activation=Ta)
99+
else:
100+
active_model = pulse.FrankStarlingActiveStress(f0=f0, activation=Ta)
61101

62102
# Combine into a fully compressible cardiac model
63103
model = pulse.CardiacModel(
@@ -107,13 +147,16 @@ def dirichlet_bc(
107147
# Set up the FEniCSx-Pulse mechanics problem
108148
parameters = {"mesh_unit": "mm"}
109149
problem = pulse.StaticProblem(model=model, geometry=geo, bcs=bcs, parameters=parameters)
150+
151+
# CRITICAL: Register the displacement field for the Frank-Starling stretch tracking
152+
if use_frank_starling:
153+
active_model.register(problem.u)
154+
110155
point = np.array([[L, 0.5, 0.5]])
111156

112157
# --- Phase 1: Passive Pre-Stretch ---
113158
# Solve for the passive stretch equilibrium
114159
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
117160

118161
# Formulate the average fiber stress
119162
F = ufl.variable(ufl.grad(problem.u) + ufl.Identity(3))
@@ -136,9 +179,6 @@ def dirichlet_bc(
136179
Ta.assign(twitch_activation * (step + 1) / nsteps)
137180
problem.solve()
138181

139-
u_twitch = evaluate_function(problem.u, point)[0][0]
140-
length_twitch = L + u_twitch
141-
142182
# Calculate total stress after activation
143183
total_force = mesh.comm.allreduce(dolfinx.fem.assemble_scalar(Tf), op=MPI.SUM) / volume
144184

@@ -147,29 +187,90 @@ def dirichlet_bc(
147187

148188
return passive_force, active_force
149189

150-
151190
# %% [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.
191+
# ## 3. Running the Curve and Comparison
192+
# We will loop over increasing amounts of stretch (from 0% up to 15%) and run the experiment twice per stretch: once with the standard active stress, and once with the Frank-Starling model.
154193

155194
# %%
156-
if __name__ == "__main__":
157-
twitch_force = 1.0 # kPa of active baseline tension
158195

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]
196+
twitch_force = 1.0 # kPa of active baseline tension
197+
198+
# Test stretches from 0 mm (0%) up to 1.5 mm (15%)
199+
stretch_amounts = [0.0, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5]
200+
strain_pcts = [(s / 10.0) * 100 for s in stretch_amounts]
201+
202+
passive_stresses = []
203+
active_stresses_std = []
204+
active_stresses_fs = []
205+
206+
print(f"{'Stretch (%)':>12} | {'Passive Stress':>18} | {'Std Active Stress':>20} | {'FS Active Stress':>20}")
207+
print("-" * 78)
208+
209+
for s, pct in zip(stretch_amounts, strain_pcts):
210+
# 1. Run Standard Model
211+
p_force, a_force_std = run_isometric_twitch(s, twitch_force, use_frank_starling=False)
212+
213+
# 2. Run Frank-Starling Model
214+
_, a_force_fs = run_isometric_twitch(s, twitch_force, use_frank_starling=True)
161215

162-
print(f"{'Stretch (mm)':>12} | {'Stretch (%)':>12} | {'Passive Stress (kPa)':>20} | {'Active Stress (kPa)':>20}")
163-
print("-" * 75)
216+
# Store results
217+
passive_stresses.append(p_force)
218+
active_stresses_std.append(a_force_std)
219+
active_stresses_fs.append(a_force_fs)
164220

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}")
221+
print(f"{pct:12.2f} | {p_force:18.4f} | {a_force_std:20.4f} | {a_force_fs:20.4f}")
222+
223+
# %% [markdown]
224+
# ## 4. Plotting the Results
225+
# While a table is useful, plotting the results makes the physiological phenomenon instantly clear. We will use `matplotlib` to plot the Active Stress (comparing both models) on the left axis, and the Passive hyperelastic stress on the right axis.
226+
227+
# %%
228+
fig, ax1 = plt.subplots(figsize=(9, 6))
229+
230+
# Plot Active Stresses on the primary y-axis
231+
ax1.set_xlabel('Stretch (%)', fontsize=12)
232+
ax1.set_ylabel('Active Stress (kPa)', color='tab:blue', fontsize=12)
233+
234+
ax1.plot(
235+
strain_pcts, active_stresses_fs, marker='o', linewidth=2, color='tab:blue',
236+
label='Frank-Starling Active Stress',
237+
)
238+
ax1.plot(
239+
strain_pcts, active_stresses_std, marker='s', linestyle='--', linewidth=2, color='tab:cyan',
240+
label='Standard Active Stress',
241+
)
242+
243+
ax1.tick_params(axis='y', labelcolor='tab:blue')
244+
ax1.grid(True, linestyle='--', alpha=0.6)
245+
246+
# Create a twin axis for the Passive Stress
247+
ax2 = ax1.twinx()
248+
ax2.set_ylabel('Passive Stress (kPa)', color='tab:red', fontsize=12)
249+
ax2.plot(
250+
strain_pcts, passive_stresses, marker='^', linewidth=2, color='tab:red',
251+
label='Passive Stress (Holzapfel-Ogden)',
252+
)
253+
ax2.tick_params(axis='y', labelcolor='tab:red')
254+
255+
# Add legends
256+
lines_1, labels_1 = ax1.get_legend_handles_labels()
257+
lines_2, labels_2 = ax2.get_legend_handles_labels()
258+
ax1.legend(lines_1 + lines_2, labels_1 + labels_2, loc='upper center', bbox_to_anchor=(0.5, -0.15), ncol=3)
259+
260+
plt.title('Isometric Contraction: Frank-Starling vs. Standard Active Stress', fontsize=14)
261+
fig.tight_layout()
262+
plt.show()
169263

170264
# %% [markdown]
171265
# ### Conclusion
172266
#
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.
267+
# The plot generated by the script perfectly illustrates two primary mechanics of the heart wall:
268+
# 1. **Exponential Passive Stiffening (Red Line):** As the tissue is stretched past 10%, the collagen fibers un-crimp and become highly resistant to further stretch.
269+
# 2. **Frank-Starling Law (Dark Blue vs Light Blue):** The standard model (dashed light blue) assumes a constant, 100% force capacity regardless of how compressed or stretched the sarcomeres are. The Frank-Starling model (solid dark blue) dynamically scales the force—starting lower at rest, and sharply climbing as the tissue stretches toward its optimal operating length.
270+
#
271+
# **Wait, why does the Standard Active Stress slightly increase?**
272+
# You might notice that the standard model's active stress isn't perfectly flat across stretches. This is actually physically and mathematically correct! In continuum mechanics, the active stress is defined in the *reference* configuration (2nd Piola-Kirchhoff stress). When we measure the *true* (Cauchy) stress in the *deformed* configuration, the solver applies a geometric "push-forward".
273+
#
274+
# As the tissue stretches, it physically narrows due to incompressibility (the Poisson effect). Because the cross-sectional area shrinks, the true stress (Force / Area) mechanically increases, even if the chemical contraction capability remained identical.
275+
#
276+
# Thus, the Standard model increases purely due to **kinematic/geometric stiffening**, while the Frank-Starling model increases due to that same geometric effect *plus* the **biological recruitment** of more myosin-actin cross-bridges.

src/pulse/active_stress.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,7 @@ def transversely_active_stress_strain_energy(Ta, C, f0, eta=0.0):
332332
333333
Arguments
334334
---------
335-
Ta : dolfinx.fem.Function or dolfinx.femConstant
335+
Ta : dolfinx.fem.Function or dolfinx.fem.Constant
336336
A scalar function representing the magnitude of the
337337
active stress in the reference configuration (first Piola)
338338
C : ufl.Form
@@ -364,7 +364,7 @@ def transversely_active_stress(Ta, f0, eta=0.0):
364364
365365
Arguments
366366
---------
367-
Ta : dolfinx.fem.Function or dolfinx.femConstant
367+
Ta : dolfinx.fem.Function or dolfinx.fem.Constant
368368
A scalar function representing the magnitude of the
369369
active stress in the reference configuration (first Piola)
370370
f0 : dolfinx.fem.Function

0 commit comments

Comments
 (0)