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.
@@ -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