crossbridge 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- crossbridge/__init__.py +56 -0
- crossbridge/base.py +80 -0
- crossbridge/land17.py +446 -0
- crossbridge/lewalle2024.py +534 -0
- crossbridge/rdq18.py +339 -0
- crossbridge/rdq20mf.py +541 -0
- crossbridge/utils.py +95 -0
- crossbridge-0.1.0.dist-info/METADATA +147 -0
- crossbridge-0.1.0.dist-info/RECORD +12 -0
- crossbridge-0.1.0.dist-info/WHEEL +5 -0
- crossbridge-0.1.0.dist-info/licenses/LICENSE +21 -0
- crossbridge-0.1.0.dist-info/top_level.txt +1 -0
crossbridge/__init__.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from .base import CardiacActivationModel
|
|
2
|
+
from .rdq18 import RDQ18
|
|
3
|
+
from .rdq20mf import RDQ20MF
|
|
4
|
+
from .lewalle2024 import Lewalle2024
|
|
5
|
+
from .land17 import Land2017
|
|
6
|
+
from . import utils
|
|
7
|
+
from .utils import calcium_trace, sl_trace
|
|
8
|
+
|
|
9
|
+
#: Maps a short model name to its class, so a model can be selected by name
|
|
10
|
+
#: (e.g. from a config file) instead of importing the class directly. Every
|
|
11
|
+
#: model here is a `CardiacActivationModel` and can be constructed uniformly
|
|
12
|
+
#: as `ModelClass(num_cells, Ta_max, params)`, so switching between them
|
|
13
|
+
#: requires no other code changes beyond the name/class used.
|
|
14
|
+
MODEL_REGISTRY: dict[str, type[CardiacActivationModel]] = {
|
|
15
|
+
"RDQ18": RDQ18,
|
|
16
|
+
"RDQ20MF": RDQ20MF,
|
|
17
|
+
"Lewalle2024": Lewalle2024,
|
|
18
|
+
"Land2017": Land2017,
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_model(name: str) -> type[CardiacActivationModel]:
|
|
23
|
+
"""
|
|
24
|
+
Look up a `CardiacActivationModel` subclass by name.
|
|
25
|
+
|
|
26
|
+
Parameters
|
|
27
|
+
----------
|
|
28
|
+
name : str
|
|
29
|
+
One of the keys in `MODEL_REGISTRY` (e.g. "RDQ18", "RDQ20MF",
|
|
30
|
+
"Lewalle2024").
|
|
31
|
+
|
|
32
|
+
Returns
|
|
33
|
+
-------
|
|
34
|
+
type[CardiacActivationModel]
|
|
35
|
+
The model class, ready to be instantiated as
|
|
36
|
+
`get_model(name)(num_cells, Ta_max, params)`.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
return MODEL_REGISTRY[name]
|
|
40
|
+
except KeyError:
|
|
41
|
+
available = ", ".join(sorted(MODEL_REGISTRY))
|
|
42
|
+
raise KeyError(f"Unknown model {name!r}. Available models: {available}") from None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"CardiacActivationModel",
|
|
47
|
+
"RDQ18",
|
|
48
|
+
"RDQ20MF",
|
|
49
|
+
"Lewalle2024",
|
|
50
|
+
"Land2017",
|
|
51
|
+
"MODEL_REGISTRY",
|
|
52
|
+
"get_model",
|
|
53
|
+
"utils",
|
|
54
|
+
"calcium_trace",
|
|
55
|
+
"sl_trace",
|
|
56
|
+
]
|
crossbridge/base.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
import numpy as np
|
|
3
|
+
import numpy.typing as npt
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CardiacActivationModel(ABC):
|
|
7
|
+
"""
|
|
8
|
+
Abstract base class for all reduced-order cardiac activation models.
|
|
9
|
+
Designed to support vectorized execution across multiple cells or
|
|
10
|
+
integration points.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def __init__(self, num_cells: int, Ta_max: float, params: dict | None = None):
|
|
15
|
+
"""
|
|
16
|
+
Initialize the model state and precompute necessary constants.
|
|
17
|
+
|
|
18
|
+
Parameters:
|
|
19
|
+
-----------
|
|
20
|
+
num_cells : int
|
|
21
|
+
The number of independent spatial units to simulate simultaneously.
|
|
22
|
+
Ta_max : float
|
|
23
|
+
The maximum active tension scaling factor.
|
|
24
|
+
params : dict, optional
|
|
25
|
+
Model-specific parameters to override defaults.
|
|
26
|
+
"""
|
|
27
|
+
self.num_cells = num_cells
|
|
28
|
+
self.Ta_max = Ta_max
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def default_parameters(cls) -> dict:
|
|
33
|
+
"""
|
|
34
|
+
Return a dictionary of the default physiological parameters for the model.
|
|
35
|
+
"""
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def advance_step(
|
|
40
|
+
self,
|
|
41
|
+
dt: float,
|
|
42
|
+
Ca_val: float | npt.NDArray[np.float64],
|
|
43
|
+
SL_vals: float | npt.NDArray[np.float64],
|
|
44
|
+
dSL_vals: float | npt.NDArray[np.float64] | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
"""
|
|
47
|
+
Integrate the model's internal states forward by a single time step.
|
|
48
|
+
|
|
49
|
+
Parameters:
|
|
50
|
+
-----------
|
|
51
|
+
dt : float
|
|
52
|
+
The time step size in seconds.
|
|
53
|
+
Ca_val : float or np.ndarray
|
|
54
|
+
Intracellular calcium concentration.
|
|
55
|
+
SL_vals : float or np.ndarray
|
|
56
|
+
Current sarcomere length(s).
|
|
57
|
+
dSL_vals : float or np.ndarray, optional
|
|
58
|
+
Current sarcomere shortening velocity. Defaults to 0 if not provided.
|
|
59
|
+
"""
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
@abstractmethod
|
|
63
|
+
def get_active_tension(self) -> npt.NDArray[np.float64]:
|
|
64
|
+
"""
|
|
65
|
+
Compute and return the macroscopic active tension (Ta) generated.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
--------
|
|
69
|
+
np.ndarray
|
|
70
|
+
The active tension for each cell/integration point (shape: `num_cells`).
|
|
71
|
+
"""
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
@abstractmethod
|
|
75
|
+
def reset(self) -> None:
|
|
76
|
+
"""
|
|
77
|
+
Reset the model's internal state to its initial condition (as set by
|
|
78
|
+
`__init__`), without re-allocating precomputed constants.
|
|
79
|
+
"""
|
|
80
|
+
pass
|
crossbridge/land17.py
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Land et al. (2017) human cardiac contraction model.
|
|
3
|
+
|
|
4
|
+
Based on: Land, S., Park-Holohan, S.J., Smith, N.P., dos Remedios, C.G.,
|
|
5
|
+
Kentish, J.C., & Niederer, S.A. (2017). A model of cardiac contraction based
|
|
6
|
+
on novel measurements of tension development in human cardiomyocytes.
|
|
7
|
+
Journal of Molecular and Cellular Cardiology, 106, 68-83.
|
|
8
|
+
https://doi.org/10.1016/j.yjmcc.2017.03.008
|
|
9
|
+
|
|
10
|
+
This is the original human-ventricular contraction model: troponin C /
|
|
11
|
+
CaTRPN kinetics driving a tropomyosin-blocking state B, a three-state
|
|
12
|
+
crossbridge cycle (unbound U, pre-powerstroke W, post-powerstroke S) with a
|
|
13
|
+
distortion-decay model of crossbridge strain (Zw, Zs), a three-element
|
|
14
|
+
spring-dashpot passive element (Cd), and *ad hoc* phenomenological
|
|
15
|
+
length-dependent activation (LDA) via two parameters: beta0 shifts maximum
|
|
16
|
+
active tension with sarcomere length, and beta1 shifts calcium sensitivity
|
|
17
|
+
(pCa50) with sarcomere length.
|
|
18
|
+
|
|
19
|
+
`Lewalle2024` in this package amends this exact model by replacing beta0/
|
|
20
|
+
beta1 with an explicit myosin OFF-state feedback subsystem; the state
|
|
21
|
+
variables and equations shared between the two models (CaTRPN, B, S, W, Zs,
|
|
22
|
+
Zw, Cd, the passive spring-dashpot, h(lambda)/Ca50(lambda)) are intentionally
|
|
23
|
+
mirrored here.
|
|
24
|
+
|
|
25
|
+
**Numerical scheme.** Identical rationale and technique as `Lewalle2024`
|
|
26
|
+
(see that module's docstring for the full discussion of why a naive
|
|
27
|
+
fixed-step explicit scheme is unstable here, given the CaTRPN**(-nTm/2)
|
|
28
|
+
singularity as CaTRPN -> 0): `CaTRPN`, `Zw`, `Zs` are each solved exactly in
|
|
29
|
+
closed form for the whole step (given Ca, SL, dSL held fixed); `Cd` is
|
|
30
|
+
piecewise-linear with a fixed sign for the whole step and is also solved
|
|
31
|
+
exactly; `(B, S, W)` are coupled through a linear 3x3 system once the
|
|
32
|
+
CaTRPN-dependent coefficients are frozen at each sub-step's midpoint, solved
|
|
33
|
+
exactly per sub-step via a matrix exponential. This model has no OFF states
|
|
34
|
+
and no force-feedback rates, so its linear system is a 3x3 subset of
|
|
35
|
+
`Lewalle2024`'s 5x5 -- cheaper, but kept as the same per-cell
|
|
36
|
+
`scipy.linalg.expm` loop (rather than a batched call) for the same reason:
|
|
37
|
+
CaTRPN can differ by orders of magnitude across cells in one batch, and
|
|
38
|
+
`expm`'s batched mode was found unreliable in that regime.
|
|
39
|
+
|
|
40
|
+
Examples
|
|
41
|
+
--------
|
|
42
|
+
>>> import numpy as np
|
|
43
|
+
>>> from crossbridge import Land2017
|
|
44
|
+
>>>
|
|
45
|
+
>>> model = Land2017(num_cells=10)
|
|
46
|
+
>>> dt = 1e-3
|
|
47
|
+
>>> Ca = np.full(10, 1.0) # 1.0 uM calcium
|
|
48
|
+
>>> SL = np.full(10, 2.0) # 2.0 um sarcomere length
|
|
49
|
+
>>>
|
|
50
|
+
>>> model.advance_step(dt, Ca, SL)
|
|
51
|
+
>>> Ta = model.get_active_tension() # shape (10,), kPa
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
import numpy as np
|
|
55
|
+
import numpy.typing as npt
|
|
56
|
+
from scipy.linalg import expm
|
|
57
|
+
|
|
58
|
+
from .base import CardiacActivationModel
|
|
59
|
+
|
|
60
|
+
#: Target sub-step size [s] used to refresh the frozen CaTRPN-dependent
|
|
61
|
+
#: coefficients of the (B, S, W) linear system. Sub-stepping here is an
|
|
62
|
+
#: accuracy knob, not a stability requirement (each sub-step is solved
|
|
63
|
+
#: exactly via matrix exponential).
|
|
64
|
+
_TARGET_SUBSTEP = 2e-4 # [s]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Land2017(CardiacActivationModel):
|
|
68
|
+
"""
|
|
69
|
+
Vectorized Land et al. (2017) human cardiac contraction model.
|
|
70
|
+
|
|
71
|
+
Attributes
|
|
72
|
+
----------
|
|
73
|
+
CaTRPN : ndarray, shape (num_cells,)
|
|
74
|
+
Fraction of troponin C units with Ca2+ bound.
|
|
75
|
+
B, S, W : ndarray, shape (num_cells,)
|
|
76
|
+
Thin/thick-filament populations (blocked, strongly bound
|
|
77
|
+
"post-stroke", weakly bound "pre-stroke").
|
|
78
|
+
Zs, Zw : ndarray, shape (num_cells,)
|
|
79
|
+
Cross-bridge distortions associated with the S and W states.
|
|
80
|
+
Cd : ndarray, shape (num_cells,)
|
|
81
|
+
Dashpot strain of the passive spring-dashpot element.
|
|
82
|
+
|
|
83
|
+
`U = 1 - B - S - W` (thin-filament-unblocked) is derived, not
|
|
84
|
+
integrated, mirroring the conservation constraint in the paper.
|
|
85
|
+
|
|
86
|
+
Sarcomere length enters only algebraically (`Lambda = SL / SL0`, held
|
|
87
|
+
fixed over each `advance_step` sub-interval) -- there is no separate
|
|
88
|
+
"Lambda" ODE state, since length is imposed by the caller exactly like
|
|
89
|
+
the other models in this package.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self, num_cells: int, Ta_max: float = 1.0, params: dict | None = None):
|
|
93
|
+
"""
|
|
94
|
+
Initialize the Land2017 model.
|
|
95
|
+
|
|
96
|
+
Parameters
|
|
97
|
+
----------
|
|
98
|
+
num_cells : int
|
|
99
|
+
Number of independent cells / FEM integration points.
|
|
100
|
+
Ta_max : float, optional
|
|
101
|
+
Unused. This model computes active tension intrinsically from
|
|
102
|
+
`Tref` (like RDQ20MF's `a_XB` and Lewalle2024's `Tref`). Kept
|
|
103
|
+
for API compatibility with `CardiacActivationModel`. Default
|
|
104
|
+
is 1.0.
|
|
105
|
+
params : dict, optional
|
|
106
|
+
Parameter overrides. Keys should match those returned by
|
|
107
|
+
`default_parameters()`.
|
|
108
|
+
"""
|
|
109
|
+
super().__init__(int(num_cells), Ta_max, params)
|
|
110
|
+
|
|
111
|
+
self.p = type(self).default_parameters()
|
|
112
|
+
if params:
|
|
113
|
+
self.p.update(params)
|
|
114
|
+
|
|
115
|
+
p = self.p
|
|
116
|
+
self.dt = p["dt"]
|
|
117
|
+
|
|
118
|
+
# Time-invariant rate constants (Land 2017 eqs. 23-28 / 59-63),
|
|
119
|
+
# computed once. kb uses the fixed calibration constant TRPN50, not
|
|
120
|
+
# the dynamic CaTRPN state -- Eq. 25/61 as literally printed reads
|
|
121
|
+
# CaTRPN there, but this is a documented typo in the paper (the
|
|
122
|
+
# steady-state derivation of kb, TRPN50 by definition is the CaTRPN
|
|
123
|
+
# value at which B=0.5, so using dynamic CaTRPN would make kb
|
|
124
|
+
# circular/state-dependent in a way inconsistent with the rest of
|
|
125
|
+
# the derivation); the reference OFF-state extension of this model
|
|
126
|
+
# (Lewalle et al. 2024) also uses the fixed TRPN50 constant.
|
|
127
|
+
self._kb = p["ku"] * p["trpn50"] ** p["nTm"] / (1 - p["rs"] - (1 - p["rs"]) * p["rw"])
|
|
128
|
+
self._kwu = p["kuw"] * (1 / p["rw"] - 1) - p["kws"]
|
|
129
|
+
self._ksu = p["kws"] * p["rw"] * (1 / p["rs"] - 1)
|
|
130
|
+
self._Aw = p["Aeff"] * p["rs"] / ((1 - p["rs"]) * p["rw"] + p["rs"])
|
|
131
|
+
self._As = self._Aw
|
|
132
|
+
self._cw = p["phi"] * p["kuw"] * ((1 - p["rs"]) * (1 - p["rw"])) / ((1 - p["rs"]) * p["rw"])
|
|
133
|
+
self._cs = p["phi"] * p["kws"] * ((1 - p["rs"]) * p["rw"]) / p["rs"]
|
|
134
|
+
|
|
135
|
+
self.reset()
|
|
136
|
+
|
|
137
|
+
def reset(self) -> None:
|
|
138
|
+
"""
|
|
139
|
+
Reset model state: all populations to zero except CaTRPN, which is
|
|
140
|
+
initialized at its own steady state for the diastolic calcium level
|
|
141
|
+
`params["Ca0"]`. This avoids starting exactly at CaTRPN=0, where the
|
|
142
|
+
CaTRPN**(-nTm/2) term in dB/dt is singular.
|
|
143
|
+
"""
|
|
144
|
+
p = self.p
|
|
145
|
+
n = self.num_cells
|
|
146
|
+
|
|
147
|
+
Ca0 = max(p["Ca0"], 0.0)
|
|
148
|
+
CaTRPN0 = 1.0 / (1.0 + (p["ca50_ref"] / max(Ca0, 1e-6)) ** p["ntrpn"])
|
|
149
|
+
|
|
150
|
+
self.CaTRPN = np.full(n, CaTRPN0)
|
|
151
|
+
self.B = np.zeros(n)
|
|
152
|
+
self.S = np.zeros(n)
|
|
153
|
+
self.W = np.zeros(n)
|
|
154
|
+
self.Zs = np.zeros(n)
|
|
155
|
+
self.Zw = np.zeros(n)
|
|
156
|
+
self.Cd = np.zeros(n)
|
|
157
|
+
|
|
158
|
+
self._Lambda_prev = np.ones(n)
|
|
159
|
+
self._Lambda_curr = np.ones(n)
|
|
160
|
+
self._has_prev_step = False
|
|
161
|
+
|
|
162
|
+
@classmethod
|
|
163
|
+
def default_parameters(cls) -> dict:
|
|
164
|
+
"""
|
|
165
|
+
Return default parameters, taken from Table B ("Skinned model
|
|
166
|
+
value") of Land et al. (2017). Rate constants given in the paper as
|
|
167
|
+
ms^-1 are converted to this package's s^-1 convention (x1000);
|
|
168
|
+
[Ca2+] parameters are in uM, matching this package's convention for
|
|
169
|
+
Ca_val elsewhere (RDQ18, RDQ20MF).
|
|
170
|
+
|
|
171
|
+
The paper also reports a "Whole organ model value" column for
|
|
172
|
+
`ca50_ref` (0.805 uM), `nTm` (5), `kuw` (0.182/ms), `kws`
|
|
173
|
+
(0.012/ms), and `Tref` (120 kPa), used in Sec. 3.5-3.6 to represent
|
|
174
|
+
intact rather than skinned myocytes; pass these as `params` to
|
|
175
|
+
reproduce that calibration (see `demo/reproduce_figures_land2017.py`).
|
|
176
|
+
"""
|
|
177
|
+
p: dict = {}
|
|
178
|
+
# Numerical: a *suggested* coupling dt. The ODE integration itself
|
|
179
|
+
# sub-steps internally regardless of this value (see module docstring).
|
|
180
|
+
p["dt"] = 1e-3 # [s]
|
|
181
|
+
|
|
182
|
+
# Kinematics. SL0 is not given as an explicit table parameter in
|
|
183
|
+
# the paper; 1.8 um is used here for consistency with Lewalle et al.
|
|
184
|
+
# (2024)'s OFF-state extension of this exact model, which inherited
|
|
185
|
+
# it unchanged from the original Land 2017 code.
|
|
186
|
+
p["SL0"] = 1.8 # [um]
|
|
187
|
+
|
|
188
|
+
# Passive tension (spring-dashpot)
|
|
189
|
+
p["a"] = 2100.0 # [Pa] (2.1 kPa)
|
|
190
|
+
p["b"] = 9.1 # [-]
|
|
191
|
+
p["k"] = 7.0 # [-]
|
|
192
|
+
p["eta_l"] = 0.2 # [s] (200 ms)
|
|
193
|
+
p["eta_s"] = 0.02 # [s] (20 ms)
|
|
194
|
+
|
|
195
|
+
# Troponin / Ca2+ binding
|
|
196
|
+
p["k_trpn"] = 100.0 # [s^-1] (0.1/ms)
|
|
197
|
+
p["ntrpn"] = 2.0 # [-] (n_TRPN)
|
|
198
|
+
p["ca50_ref"] = 2.5 # [uM] ([Ca2+]_T50^ref, skinned)
|
|
199
|
+
|
|
200
|
+
# Thick/thin-filament cycling
|
|
201
|
+
p["ku"] = 1000.0 # [s^-1] (1/ms)
|
|
202
|
+
p["nTm"] = 2.2 # [-] (n_Tm, skinned)
|
|
203
|
+
p["trpn50"] = 0.35 # [-]
|
|
204
|
+
p["kuw"] = 26.0 # [s^-1] (0.026/ms, skinned)
|
|
205
|
+
p["kws"] = 4.0 # [s^-1] (0.004/ms, skinned)
|
|
206
|
+
p["rw"] = 0.5 # [-]
|
|
207
|
+
p["rs"] = 0.25 # [-]
|
|
208
|
+
p["gs"] = 8.5 # [s^-1 per unit distortion] (0.0085/ms)
|
|
209
|
+
p["gw"] = 615.0 # [s^-1 per unit distortion] (0.615/ms)
|
|
210
|
+
p["phi"] = 2.23 # [-]
|
|
211
|
+
p["Aeff"] = 25.0 # [-]
|
|
212
|
+
|
|
213
|
+
# Ad hoc length-dependent activation (this model's defining
|
|
214
|
+
# feature -- Lewalle2024 replaces these with OFF-state feedback)
|
|
215
|
+
p["beta0"] = 2.3 # [-] max-force LDA gradient
|
|
216
|
+
p["beta1"] = -2.4 # [uM] calcium-sensitivity LDA gradient
|
|
217
|
+
p["Tref"] = 40500.0 # [Pa] (40.5 kPa, skinned)
|
|
218
|
+
|
|
219
|
+
# Initialization helper (not a physical model parameter): diastolic
|
|
220
|
+
# Ca used only to set a numerically-safe CaTRPN(0) (see reset()).
|
|
221
|
+
p["Ca0"] = 0.1 # [uM]
|
|
222
|
+
|
|
223
|
+
return p
|
|
224
|
+
|
|
225
|
+
# ------------------------------------------------------------------
|
|
226
|
+
# Force / length-dependence helpers
|
|
227
|
+
# ------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
def _forces(
|
|
230
|
+
self,
|
|
231
|
+
Lambda: npt.NDArray[np.float64],
|
|
232
|
+
Cd: npt.NDArray[np.float64],
|
|
233
|
+
S: npt.NDArray[np.float64],
|
|
234
|
+
W: npt.NDArray[np.float64],
|
|
235
|
+
Zs: npt.NDArray[np.float64],
|
|
236
|
+
Zw: npt.NDArray[np.float64],
|
|
237
|
+
) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64], npt.NDArray[np.float64]]:
|
|
238
|
+
"""Return (Ta_active, Tp, Ttotal) in Pa for the given state."""
|
|
239
|
+
p = self.p
|
|
240
|
+
F1 = p["a"] * (np.exp(p["b"] * (Lambda - 1.0)) - 1.0)
|
|
241
|
+
F2 = p["a"] * p["k"] * ((Lambda - 1.0) - Cd)
|
|
242
|
+
Tp = F1 + F2
|
|
243
|
+
Lambda_clamped = np.minimum(Lambda, 1.2)
|
|
244
|
+
h = np.maximum(
|
|
245
|
+
0.0, 1.0 + p["beta0"] * (Lambda_clamped + np.minimum(Lambda_clamped, 0.87) - 1.87)
|
|
246
|
+
)
|
|
247
|
+
Ta_active = h * p["Tref"] / p["rs"] * (S * (Zs + 1.0) + W * Zw)
|
|
248
|
+
return Ta_active, Tp, Ta_active + Tp
|
|
249
|
+
|
|
250
|
+
# ------------------------------------------------------------------
|
|
251
|
+
# Public interface (CardiacActivationModel)
|
|
252
|
+
# ------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
def advance_step(
|
|
255
|
+
self,
|
|
256
|
+
dt: float,
|
|
257
|
+
Ca_val: float | npt.NDArray[np.float64],
|
|
258
|
+
SL_vals: float | npt.NDArray[np.float64],
|
|
259
|
+
dSL_vals: float | npt.NDArray[np.float64] | None = None,
|
|
260
|
+
) -> None:
|
|
261
|
+
"""
|
|
262
|
+
Advance the model by one time step.
|
|
263
|
+
|
|
264
|
+
Parameters
|
|
265
|
+
----------
|
|
266
|
+
dt : float
|
|
267
|
+
Time step [s]. Ca_val, SL_vals, dSL_vals are held fixed
|
|
268
|
+
(zero-order hold) across the sub-interval, matching the
|
|
269
|
+
convention used by the other models in this package.
|
|
270
|
+
Ca_val : float or np.ndarray, shape (num_cells,)
|
|
271
|
+
Intracellular calcium concentration [uM].
|
|
272
|
+
SL_vals : float or np.ndarray, shape (num_cells,)
|
|
273
|
+
Current sarcomere lengths [um].
|
|
274
|
+
dSL_vals : float or np.ndarray, shape (num_cells,) or None
|
|
275
|
+
Sarcomere length rate of change [um/s]. If None, estimated from
|
|
276
|
+
the sarcomere length recorded on the previous call.
|
|
277
|
+
"""
|
|
278
|
+
n = self.num_cells
|
|
279
|
+
p = self.p
|
|
280
|
+
|
|
281
|
+
if np.isscalar(SL_vals):
|
|
282
|
+
SL_arr = np.full(n, float(SL_vals))
|
|
283
|
+
else:
|
|
284
|
+
SL_arr = np.asarray(SL_vals, dtype=float)
|
|
285
|
+
assert SL_arr.shape == (n,), f"SL_vals shape {SL_arr.shape} must be ({n},)"
|
|
286
|
+
|
|
287
|
+
if np.isscalar(Ca_val):
|
|
288
|
+
Ca_arr = np.full(n, float(Ca_val))
|
|
289
|
+
else:
|
|
290
|
+
Ca_arr = np.asarray(Ca_val, dtype=float)
|
|
291
|
+
|
|
292
|
+
Lambda = SL_arr / p["SL0"]
|
|
293
|
+
|
|
294
|
+
if dSL_vals is not None:
|
|
295
|
+
if np.isscalar(dSL_vals):
|
|
296
|
+
dSL_arr = np.full(n, float(dSL_vals))
|
|
297
|
+
else:
|
|
298
|
+
dSL_arr = np.asarray(dSL_vals, dtype=float)
|
|
299
|
+
dLambdadt = dSL_arr / p["SL0"]
|
|
300
|
+
elif self._has_prev_step:
|
|
301
|
+
dLambdadt = (Lambda - self._Lambda_prev) / dt
|
|
302
|
+
else:
|
|
303
|
+
# No prior call to estimate a velocity from, and no dSL_vals
|
|
304
|
+
# given: assume zero rather than finite-differencing against
|
|
305
|
+
# the arbitrary post-reset _Lambda_prev (which would otherwise
|
|
306
|
+
# inject a spurious velocity "kick" on the very first call
|
|
307
|
+
# whenever the caller's first SL differs from SL0).
|
|
308
|
+
dLambdadt = np.zeros(n)
|
|
309
|
+
|
|
310
|
+
# --- CaTRPN: exact solution of a linear ODE (Ca held fixed over dt) ---
|
|
311
|
+
Ca_safe = np.maximum(Ca_arr, 0.0)
|
|
312
|
+
Ca50 = np.maximum(p["ca50_ref"] + p["beta1"] * (np.minimum(Lambda, 1.2) - 1.0), 1e-6)
|
|
313
|
+
kon = p["k_trpn"] * (Ca_safe / Ca50) ** p["ntrpn"]
|
|
314
|
+
koff = p["k_trpn"]
|
|
315
|
+
CaTRPN_ss = kon / (kon + koff)
|
|
316
|
+
CaTRPN_rate = kon + koff
|
|
317
|
+
|
|
318
|
+
def CaTRPN_at(t: npt.NDArray[np.float64] | float) -> npt.NDArray[np.float64]:
|
|
319
|
+
return CaTRPN_ss + (self.CaTRPN - CaTRPN_ss) * np.exp(-CaTRPN_rate * t)
|
|
320
|
+
|
|
321
|
+
# --- Zw, Zs: exact solutions of linear ODEs decoupled from everything
|
|
322
|
+
# else (only driven by the externally imposed dLambdadt) ---
|
|
323
|
+
def _relax(x0, rate, target, t):
|
|
324
|
+
safe_rate = np.where(rate > 0, rate, 1.0)
|
|
325
|
+
steady = np.where(rate > 0, target / safe_rate, x0 + target * t)
|
|
326
|
+
return np.where(
|
|
327
|
+
rate > 0, steady + (x0 - steady) * np.exp(-safe_rate * t), x0 + target * t
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
def Zw_at(t):
|
|
331
|
+
return _relax(self.Zw, np.full(n, self._cw), self._Aw * dLambdadt, t)
|
|
332
|
+
|
|
333
|
+
def Zs_at(t):
|
|
334
|
+
return _relax(self.Zs, np.full(n, self._cs), self._As * dLambdadt, t)
|
|
335
|
+
|
|
336
|
+
Zw_final = Zw_at(dt)
|
|
337
|
+
Zs_final = Zs_at(dt)
|
|
338
|
+
|
|
339
|
+
# --- Cd: piecewise-linear, but the sign of (Lambda - 1 - Cd) cannot
|
|
340
|
+
# flip within the step (Cd relaxes monotonically towards Lambda - 1),
|
|
341
|
+
# so a single regime, fixed from the start-of-step sign, is exact. ---
|
|
342
|
+
target_Cd = Lambda - 1.0
|
|
343
|
+
rate_Cd = np.where((target_Cd - self.Cd) > 0.0, p["k"] / p["eta_l"], p["k"] / p["eta_s"])
|
|
344
|
+
Cd_final = target_Cd + (self.Cd - target_Cd) * np.exp(-rate_Cd * dt)
|
|
345
|
+
|
|
346
|
+
# --- (B, S, W): linear once CaTRPN-dependent coefficients are
|
|
347
|
+
# frozen per sub-step; each sub-step solved exactly via a batched
|
|
348
|
+
# (per-cell) matrix exponential. ---
|
|
349
|
+
n_sub = max(1, int(np.ceil(dt / _TARGET_SUBSTEP)))
|
|
350
|
+
h = dt / n_sub
|
|
351
|
+
|
|
352
|
+
B, S, W = self.B, self.S, self.W
|
|
353
|
+
t0 = 0.0
|
|
354
|
+
for _ in range(n_sub):
|
|
355
|
+
t_mid = t0 + 0.5 * h
|
|
356
|
+
CaTRPN_mid = np.maximum(CaTRPN_at(t_mid), 1e-12)
|
|
357
|
+
ca_pow_pos = CaTRPN_mid ** (p["nTm"] / 2.0)
|
|
358
|
+
ca_pow_neg = CaTRPN_mid ** (-p["nTm"] / 2.0)
|
|
359
|
+
|
|
360
|
+
Zw_mid = Zw_at(t_mid)
|
|
361
|
+
Zs_mid = Zs_at(t_mid)
|
|
362
|
+
gwu = p["gw"] * np.abs(Zw_mid)
|
|
363
|
+
gsu = np.where(
|
|
364
|
+
Zs_mid < -1.0,
|
|
365
|
+
-p["gs"] * (Zs_mid + 1.0),
|
|
366
|
+
np.where(Zs_mid > 0.0, p["gs"] * Zs_mid, 0.0),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
M = np.zeros((n, 3, 3))
|
|
370
|
+
c = np.zeros((n, 3))
|
|
371
|
+
kb_neg = self._kb * ca_pow_neg
|
|
372
|
+
ku_pos = p["ku"] * ca_pow_pos
|
|
373
|
+
|
|
374
|
+
# Row 0: dB/dt = kb_neg*(1-B-S-W) - ku_pos*B
|
|
375
|
+
M[:, 0, 0] = -kb_neg - ku_pos
|
|
376
|
+
M[:, 0, 1] = -kb_neg
|
|
377
|
+
M[:, 0, 2] = -kb_neg
|
|
378
|
+
c[:, 0] = kb_neg
|
|
379
|
+
# Row 1: dS/dt = kws*W - (ksu+gsu)*S
|
|
380
|
+
M[:, 1, 1] = -self._ksu - gsu
|
|
381
|
+
M[:, 1, 2] = p["kws"]
|
|
382
|
+
# Row 2: dW/dt = kuw*(1-B-S-W) - (kwu+kws+gwu)*W
|
|
383
|
+
M[:, 2, 0] = -p["kuw"]
|
|
384
|
+
M[:, 2, 1] = -p["kuw"]
|
|
385
|
+
M[:, 2, 2] = -p["kuw"] - self._kwu - p["kws"] - gwu
|
|
386
|
+
c[:, 2] = p["kuw"]
|
|
387
|
+
|
|
388
|
+
x0 = np.stack([B, S, W], axis=-1) # (n, 3)
|
|
389
|
+
try:
|
|
390
|
+
x_ss = -np.linalg.solve(M, c[:, :, np.newaxis])[:, :, 0]
|
|
391
|
+
except np.linalg.LinAlgError:
|
|
392
|
+
x_ss = x0.copy()
|
|
393
|
+
delta = x0 - x_ss
|
|
394
|
+
# NOTE: as in Lewalle2024, scipy.linalg.expm's batched (stacked)
|
|
395
|
+
# mode silently returns wrong results for some entries when the
|
|
396
|
+
# matrices in the stack have very different norms -- which
|
|
397
|
+
# happens here too, since CaTRPN**(-nTm/2) can differ by orders
|
|
398
|
+
# of magnitude across cells with different Ca. So this is
|
|
399
|
+
# looped per cell rather than batched; each 3x3 exponential is
|
|
400
|
+
# cheap and this is not the model's performance bottleneck.
|
|
401
|
+
expM = np.stack([expm(M[i] * h) for i in range(n)])
|
|
402
|
+
x_new = x_ss + np.einsum("nij,nj->ni", expM, delta)
|
|
403
|
+
x_new = np.clip(x_new, 0.0, 1.0)
|
|
404
|
+
|
|
405
|
+
B, S, W = (x_new[:, i] for i in range(3))
|
|
406
|
+
t0 += h
|
|
407
|
+
|
|
408
|
+
self.CaTRPN = np.maximum(CaTRPN_at(dt), 0.0)
|
|
409
|
+
self.B, self.S, self.W = B, S, W
|
|
410
|
+
self.Zw = Zw_final
|
|
411
|
+
self.Zs = Zs_final
|
|
412
|
+
self.Cd = Cd_final
|
|
413
|
+
|
|
414
|
+
self._Lambda_prev = Lambda
|
|
415
|
+
self._Lambda_curr = Lambda
|
|
416
|
+
self._has_prev_step = True
|
|
417
|
+
|
|
418
|
+
def get_active_tension(self) -> npt.NDArray[np.float64]:
|
|
419
|
+
"""
|
|
420
|
+
Compute active tension (kPa) from the current state.
|
|
421
|
+
|
|
422
|
+
Ta_active = h(Lambda) * Tref / rs * (S * (Zs + 1) + W * Zw)
|
|
423
|
+
|
|
424
|
+
The reference formula is in Pa; the result is converted to kPa here
|
|
425
|
+
for consistency with the rest of this package.
|
|
426
|
+
"""
|
|
427
|
+
Ta_active, _, _ = self._forces(self._Lambda_curr, self.Cd, self.S, self.W, self.Zs, self.Zw)
|
|
428
|
+
return Ta_active / 1000.0
|
|
429
|
+
|
|
430
|
+
def get_passive_tension(self) -> npt.NDArray[np.float64]:
|
|
431
|
+
"""Compute passive (spring-dashpot) tension (kPa) from the current state."""
|
|
432
|
+
_, Tp, _ = self._forces(self._Lambda_curr, self.Cd, self.S, self.W, self.Zs, self.Zw)
|
|
433
|
+
return Tp / 1000.0
|
|
434
|
+
|
|
435
|
+
def get_total_tension(self) -> npt.NDArray[np.float64]:
|
|
436
|
+
"""Compute total (active + passive) tension (kPa)."""
|
|
437
|
+
_, _, Ttotal = self._forces(self._Lambda_curr, self.Cd, self.S, self.W, self.Zs, self.Zw)
|
|
438
|
+
return Ttotal / 1000.0
|
|
439
|
+
|
|
440
|
+
def compute_attached_fraction(self) -> npt.NDArray[np.float64]:
|
|
441
|
+
"""
|
|
442
|
+
Fraction of crossbridges in force-generating states (S + W).
|
|
443
|
+
|
|
444
|
+
This model's analog of `compute_permissivity()` on the RDQ models.
|
|
445
|
+
"""
|
|
446
|
+
return self.S + self.W
|