|
| 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. |
0 commit comments