circulation 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {circulation-0.2.0/src/circulation.egg-info → circulation-0.3.0}/PKG-INFO +1 -1
- {circulation-0.2.0 → circulation-0.3.0}/pyproject.toml +2 -2
- circulation-0.3.0/src/circulation/__init__.py +11 -0
- {circulation-0.2.0 → circulation-0.3.0}/src/circulation/base.py +8 -4
- {circulation-0.2.0 → circulation-0.3.0}/src/circulation/bestel.py +4 -4
- {circulation-0.2.0 → circulation-0.3.0}/src/circulation/regazzoni2020.py +12 -12
- circulation-0.3.0/src/circulation/windkessel.py +120 -0
- {circulation-0.2.0 → circulation-0.3.0/src/circulation.egg-info}/PKG-INFO +1 -1
- {circulation-0.2.0 → circulation-0.3.0}/src/circulation.egg-info/SOURCES.txt +3 -1
- {circulation-0.2.0 → circulation-0.3.0}/tests/test_models.py +12 -0
- circulation-0.3.0/tests/test_regazzoni_steady_state.py +15 -0
- circulation-0.2.0/src/circulation/__init__.py +0 -3
- {circulation-0.2.0 → circulation-0.3.0}/LICENSE +0 -0
- {circulation-0.2.0 → circulation-0.3.0}/README.md +0 -0
- {circulation-0.2.0 → circulation-0.3.0}/setup.cfg +0 -0
- {circulation-0.2.0 → circulation-0.3.0}/src/circulation/log.py +0 -0
- {circulation-0.2.0 → circulation-0.3.0}/src/circulation/time_varying_elastance.py +0 -0
- {circulation-0.2.0 → circulation-0.3.0}/src/circulation/units.py +0 -0
- {circulation-0.2.0 → circulation-0.3.0}/src/circulation/zenker.py +0 -0
- {circulation-0.2.0 → circulation-0.3.0}/src/circulation.egg-info/dependency_links.txt +0 -0
- {circulation-0.2.0 → circulation-0.3.0}/src/circulation.egg-info/requires.txt +0 -0
- {circulation-0.2.0 → circulation-0.3.0}/src/circulation.egg-info/top_level.txt +0 -0
- {circulation-0.2.0 → circulation-0.3.0}/tests/test_bestel.py +0 -0
- {circulation-0.2.0 → circulation-0.3.0}/tests/test_initialization.py +0 -0
|
@@ -3,7 +3,7 @@ requires = ["setuptools>=61.0.0", "wheel"]
|
|
|
3
3
|
|
|
4
4
|
[project]
|
|
5
5
|
name = "circulation"
|
|
6
|
-
version = "0.
|
|
6
|
+
version = "0.3.0"
|
|
7
7
|
description = "Closed loop blood circulation model in FEniCS"
|
|
8
8
|
authors = [{ name = "Henrik Finsberg", email = "henriknf@simula.no" }]
|
|
9
9
|
license = "MIT"
|
|
@@ -116,7 +116,7 @@ tag = true
|
|
|
116
116
|
sign_tags = false
|
|
117
117
|
tag_name = "v{new_version}"
|
|
118
118
|
tag_message = "Bump version: {current_version} → {new_version}"
|
|
119
|
-
current_version = "0.
|
|
119
|
+
current_version = "0.3.0"
|
|
120
120
|
|
|
121
121
|
|
|
122
122
|
[[tool.bumpversion.files]]
|
|
@@ -267,10 +267,14 @@ class CirculationModel(ABC):
|
|
|
267
267
|
unit_R: float = 1.0,
|
|
268
268
|
unit_p: float = 1.0,
|
|
269
269
|
) -> Callable[[float, float], float]:
|
|
270
|
-
return lambda w, v:
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
270
|
+
return lambda w, v: (
|
|
271
|
+
unit_R
|
|
272
|
+
* 10.0
|
|
273
|
+
** (
|
|
274
|
+
np.log10(Rmin / unit_R)
|
|
275
|
+
+ (np.log10(Rmax / unit_R) - np.log10(Rmin / unit_R))
|
|
276
|
+
* smooth_heavyside((v - w) / unit_p)
|
|
277
|
+
)
|
|
274
278
|
)
|
|
275
279
|
|
|
276
280
|
def times_n_beats(self, dt: float, n: int = 1) -> np.ndarray:
|
|
@@ -106,8 +106,8 @@ class BestelActivation:
|
|
|
106
106
|
|
|
107
107
|
# print(f"Solving active stress model with parameters: {pprint.pformat(params)}")
|
|
108
108
|
|
|
109
|
-
f = (
|
|
110
|
-
|
|
109
|
+
f = lambda t: (
|
|
110
|
+
0.25
|
|
111
111
|
* (1 + math.tanh((t - ps["t_sys"]) / ps["gamma"]))
|
|
112
112
|
* (1 - math.tanh((t - ps["t_dias"]) / ps["gamma"]))
|
|
113
113
|
)
|
|
@@ -286,8 +286,8 @@ class BestelPressure:
|
|
|
286
286
|
|
|
287
287
|
def __call__(self, t, p):
|
|
288
288
|
ps = self.parameters
|
|
289
|
-
f = (
|
|
290
|
-
|
|
289
|
+
f = lambda t: (
|
|
290
|
+
0.25
|
|
291
291
|
* (1 + math.tanh((t - ps["t_sys_pre"]) / ps["gamma"]))
|
|
292
292
|
* (1 - math.tanh((t - ps["t_dias_pre"]) / ps["gamma"]))
|
|
293
293
|
)
|
|
@@ -229,18 +229,18 @@ class Regazzoni2020(base.CirculationModel):
|
|
|
229
229
|
@staticmethod
|
|
230
230
|
def default_initial_conditions() -> dict[str, float]:
|
|
231
231
|
return {
|
|
232
|
-
"V_LA":
|
|
233
|
-
"V_LV":
|
|
234
|
-
"V_RA":
|
|
235
|
-
"V_RV":
|
|
236
|
-
"p_AR_SYS":
|
|
237
|
-
"p_VEN_SYS":
|
|
238
|
-
"p_AR_PUL":
|
|
239
|
-
"p_VEN_PUL":
|
|
240
|
-
"Q_AR_SYS":
|
|
241
|
-
"Q_VEN_SYS":
|
|
242
|
-
"Q_AR_PUL":
|
|
243
|
-
"Q_VEN_PUL":
|
|
232
|
+
"V_LA": 87.183 * mL,
|
|
233
|
+
"V_LV": 118.520 * mL,
|
|
234
|
+
"V_RA": 86.833 * mL,
|
|
235
|
+
"V_RV": 166.177 * mL,
|
|
236
|
+
"p_AR_SYS": 87.675 * mmHg,
|
|
237
|
+
"p_VEN_SYS": 35.898 * mmHg,
|
|
238
|
+
"p_AR_PUL": 19.545 * mmHg,
|
|
239
|
+
"p_VEN_PUL": 15.004 * mmHg,
|
|
240
|
+
"Q_AR_SYS": 71.104 * mL / s,
|
|
241
|
+
"Q_VEN_SYS": 94.039 * mL / s,
|
|
242
|
+
"Q_AR_PUL": 94.084 * mL / s,
|
|
243
|
+
"Q_VEN_PUL": 473.279 * mL / s,
|
|
244
244
|
}
|
|
245
245
|
|
|
246
246
|
@staticmethod
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
from . import base
|
|
2
|
+
from . import units
|
|
3
|
+
|
|
4
|
+
mL = units.ureg("mL")
|
|
5
|
+
mmHg = units.ureg("mmHg")
|
|
6
|
+
s = units.ureg("s")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ThreeElementWindkessel(base.CirculationModel):
|
|
10
|
+
"""
|
|
11
|
+
A 0D model of the Left Ventricle coupled to a 3-Element Windkessel system.
|
|
12
|
+
|
|
13
|
+
The 3-Element Windkessel (WK3) models the systemic arterial tree using:
|
|
14
|
+
- R_c: Characteristic resistance (proximal aortic impedance)
|
|
15
|
+
- C: Arterial compliance (vessel elasticity)
|
|
16
|
+
- R_p: Peripheral resistance (microcirculation)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, parameters=None, p_LV_func=None, **kwargs):
|
|
20
|
+
super().__init__(parameters, **kwargs)
|
|
21
|
+
|
|
22
|
+
if p_LV_func is not None:
|
|
23
|
+
self.p_LV_func = p_LV_func
|
|
24
|
+
self._E_LV = lambda t: 1.0 # Dummy function, not used
|
|
25
|
+
else:
|
|
26
|
+
# Use default time-varying elastance model if no custom mechanics are provided
|
|
27
|
+
self._E_LV = self.time_varying_elastance(**self.parameters["chambers"]["LV"])
|
|
28
|
+
self.p_LV_func = lambda V, t: (
|
|
29
|
+
self._E_LV(t) * (V - self.parameters["chambers"]["LV"]["V0"])
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
self._initialize()
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def HR(self) -> float:
|
|
36
|
+
return self.parameters["HR"]
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def default_parameters():
|
|
40
|
+
return {
|
|
41
|
+
"HR": 1.25 * units.ureg("Hz"), # Heart rate in Hz (75 BPM)
|
|
42
|
+
"R_c": 0.05 * mmHg * s / mL, # Characteristic resistance (mmHg*s/mL)
|
|
43
|
+
"R_p": 1.05 * mmHg * s / mL, # Peripheral resistance (mmHg*s/mL)
|
|
44
|
+
"C": 1.3 * mL / mmHg, # Arterial compliance (mL/mmHg)
|
|
45
|
+
"P_venous": 8.0 * mmHg, # Preload / Left Atrial Pressure (mmHg)
|
|
46
|
+
"R_mitral": 0.01 * mmHg * s / mL, # Mitral valve resistance
|
|
47
|
+
"R_aortic": 0.01 * mmHg * s / mL, # Aortic valve resistance
|
|
48
|
+
"chambers": {
|
|
49
|
+
"LV": {
|
|
50
|
+
"EA": 2.5 * mmHg / mL, # Max elastance (mmHg/mL)
|
|
51
|
+
"EB": 0.08 * mmHg / mL, # Min elastance (mmHg/mL)
|
|
52
|
+
"TC": 0.25 * s, # Contraction duration (s)
|
|
53
|
+
"TR": 0.15 * s, # Relaxation duration (s)
|
|
54
|
+
"tC": 0.0 * s, # Time of contraction onset (s)
|
|
55
|
+
"V0": 15.0 * mL, # Unstressed volume (mL)
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@staticmethod
|
|
61
|
+
def default_initial_conditions() -> dict:
|
|
62
|
+
return {
|
|
63
|
+
"V_LV": 140.0 * mL, # Initial left ventricular volume
|
|
64
|
+
"p_c": 80.0 * mmHg, # Pressure in the arterial compliance capacitor
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def state_names() -> list:
|
|
69
|
+
return ["V_LV", "p_c"]
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def var_names() -> list:
|
|
73
|
+
return ["p_LV", "p_ao", "Q_in", "Q_out", "Q_p"]
|
|
74
|
+
|
|
75
|
+
def update_static_variables(self, t, y):
|
|
76
|
+
V_LV = y[0]
|
|
77
|
+
p_c = y[1]
|
|
78
|
+
|
|
79
|
+
p_LV = self.p_LV_func(V_LV, t)
|
|
80
|
+
|
|
81
|
+
P_venous = self.parameters["P_venous"]
|
|
82
|
+
R_mitral = self.parameters["R_mitral"]
|
|
83
|
+
R_aortic = self.parameters["R_aortic"]
|
|
84
|
+
R_c = self.parameters["R_c"]
|
|
85
|
+
R_p = self.parameters["R_p"]
|
|
86
|
+
|
|
87
|
+
# 1. Mitral Flow (Diastolic Filling)
|
|
88
|
+
Q_in = max(0.0, (P_venous - p_LV) / R_mitral)
|
|
89
|
+
|
|
90
|
+
# 2. Aortic Flow (Systolic Ejection)
|
|
91
|
+
# In a WK3 model, aortic pressure P_ao = p_c + Q_out * R_c
|
|
92
|
+
# If the valve is open (p_LV > p_ao), we substitute P_ao and solve for Q_out
|
|
93
|
+
if p_LV > p_c:
|
|
94
|
+
Q_out = (p_LV - p_c) / (R_aortic + R_c)
|
|
95
|
+
else:
|
|
96
|
+
Q_out = 0.0
|
|
97
|
+
|
|
98
|
+
# 3. Pressures and Peripheral Flow
|
|
99
|
+
p_ao = p_c + Q_out * R_c
|
|
100
|
+
Q_p = p_c / R_p # Discharge to venous pool (assumed 0 mmHg pressure drop reference)
|
|
101
|
+
|
|
102
|
+
var = self._get_var(t)
|
|
103
|
+
var[0] = p_LV
|
|
104
|
+
var[1] = p_ao
|
|
105
|
+
var[2] = Q_in
|
|
106
|
+
var[3] = Q_out
|
|
107
|
+
var[4] = Q_p
|
|
108
|
+
return var
|
|
109
|
+
|
|
110
|
+
def rhs(self, t, y):
|
|
111
|
+
var = self.update_static_variables(t, y)
|
|
112
|
+
Q_in = var[2]
|
|
113
|
+
Q_out = var[3]
|
|
114
|
+
Q_p = var[4]
|
|
115
|
+
C = self.parameters["C"]
|
|
116
|
+
|
|
117
|
+
self.dy[0] = Q_in - Q_out
|
|
118
|
+
self.dy[1] = (Q_out - Q_p) / C
|
|
119
|
+
|
|
120
|
+
return self.dy
|
|
@@ -8,6 +8,7 @@ src/circulation/log.py
|
|
|
8
8
|
src/circulation/regazzoni2020.py
|
|
9
9
|
src/circulation/time_varying_elastance.py
|
|
10
10
|
src/circulation/units.py
|
|
11
|
+
src/circulation/windkessel.py
|
|
11
12
|
src/circulation/zenker.py
|
|
12
13
|
src/circulation.egg-info/PKG-INFO
|
|
13
14
|
src/circulation.egg-info/SOURCES.txt
|
|
@@ -16,4 +17,5 @@ src/circulation.egg-info/requires.txt
|
|
|
16
17
|
src/circulation.egg-info/top_level.txt
|
|
17
18
|
tests/test_bestel.py
|
|
18
19
|
tests/test_initialization.py
|
|
19
|
-
tests/test_models.py
|
|
20
|
+
tests/test_models.py
|
|
21
|
+
tests/test_regazzoni_steady_state.py
|
|
@@ -24,3 +24,15 @@ def test_Regazzoni2020():
|
|
|
24
24
|
for k, v in model.default_initial_conditions().items():
|
|
25
25
|
assert k in results
|
|
26
26
|
assert results[k][0] == v.magnitude
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_ThreeElementWindkessel():
|
|
30
|
+
model = circulation.windkessel.ThreeElementWindkessel()
|
|
31
|
+
results = model.solve(T=1.0, dt=1e-3, dt_eval=0.1)
|
|
32
|
+
|
|
33
|
+
for k, v in results.items():
|
|
34
|
+
assert len(v) == len(results["time"]), k
|
|
35
|
+
|
|
36
|
+
for k, v in model.default_initial_conditions().items():
|
|
37
|
+
assert k in results
|
|
38
|
+
assert results[k][0] == v.magnitude
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from circulation import regazzoni2020
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_regazzoni_steady_state():
|
|
6
|
+
"""
|
|
7
|
+
Test that the default initial state is close to the limit cycle.
|
|
8
|
+
We run the model for ten beats and check that the state hasn't drifted much.
|
|
9
|
+
"""
|
|
10
|
+
model = regazzoni2020.Regazzoni2020()
|
|
11
|
+
initial_state = model.state.copy()
|
|
12
|
+
model.solve(num_beats=10)
|
|
13
|
+
final_state = model.state
|
|
14
|
+
|
|
15
|
+
np.testing.assert_allclose(final_state, initial_state, rtol=1e-2, verbose=True)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|