iints-sdk-python35 0.0.18__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.
- iints/__init__.py +183 -0
- iints/analysis/__init__.py +12 -0
- iints/analysis/algorithm_xray.py +387 -0
- iints/analysis/baseline.py +92 -0
- iints/analysis/clinical_benchmark.py +198 -0
- iints/analysis/clinical_metrics.py +551 -0
- iints/analysis/clinical_tir_analyzer.py +136 -0
- iints/analysis/diabetes_metrics.py +43 -0
- iints/analysis/edge_efficiency.py +33 -0
- iints/analysis/edge_performance_monitor.py +315 -0
- iints/analysis/explainability.py +94 -0
- iints/analysis/explainable_ai.py +232 -0
- iints/analysis/hardware_benchmark.py +221 -0
- iints/analysis/metrics.py +117 -0
- iints/analysis/population_report.py +188 -0
- iints/analysis/reporting.py +345 -0
- iints/analysis/safety_index.py +311 -0
- iints/analysis/sensor_filtering.py +54 -0
- iints/analysis/validator.py +273 -0
- iints/api/__init__.py +0 -0
- iints/api/base_algorithm.py +307 -0
- iints/api/registry.py +103 -0
- iints/api/template_algorithm.py +195 -0
- iints/assets/iints_logo.png +0 -0
- iints/cli/__init__.py +0 -0
- iints/cli/cli.py +2598 -0
- iints/core/__init__.py +1 -0
- iints/core/algorithms/__init__.py +0 -0
- iints/core/algorithms/battle_runner.py +138 -0
- iints/core/algorithms/correction_bolus.py +95 -0
- iints/core/algorithms/discovery.py +92 -0
- iints/core/algorithms/fixed_basal_bolus.py +58 -0
- iints/core/algorithms/hybrid_algorithm.py +92 -0
- iints/core/algorithms/lstm_algorithm.py +138 -0
- iints/core/algorithms/mock_algorithms.py +162 -0
- iints/core/algorithms/pid_controller.py +88 -0
- iints/core/algorithms/standard_pump_algo.py +64 -0
- iints/core/device.py +0 -0
- iints/core/device_manager.py +64 -0
- iints/core/devices/__init__.py +3 -0
- iints/core/devices/models.py +160 -0
- iints/core/patient/__init__.py +9 -0
- iints/core/patient/bergman_model.py +341 -0
- iints/core/patient/models.py +285 -0
- iints/core/patient/patient_factory.py +117 -0
- iints/core/patient/profile.py +41 -0
- iints/core/safety/__init__.py +12 -0
- iints/core/safety/config.py +37 -0
- iints/core/safety/input_validator.py +95 -0
- iints/core/safety/supervisor.py +39 -0
- iints/core/simulation/__init__.py +0 -0
- iints/core/simulation/scenario_parser.py +61 -0
- iints/core/simulator.py +874 -0
- iints/core/supervisor.py +367 -0
- iints/data/__init__.py +53 -0
- iints/data/adapter.py +142 -0
- iints/data/column_mapper.py +398 -0
- iints/data/datasets.json +132 -0
- iints/data/demo/__init__.py +1 -0
- iints/data/demo/demo_cgm.csv +289 -0
- iints/data/importer.py +275 -0
- iints/data/ingestor.py +162 -0
- iints/data/nightscout.py +128 -0
- iints/data/quality_checker.py +550 -0
- iints/data/registry.py +166 -0
- iints/data/tidepool.py +38 -0
- iints/data/universal_parser.py +813 -0
- iints/data/virtual_patients/clinic_safe_baseline.yaml +9 -0
- iints/data/virtual_patients/clinic_safe_hyper_challenge.yaml +9 -0
- iints/data/virtual_patients/clinic_safe_hypo_prone.yaml +9 -0
- iints/data/virtual_patients/clinic_safe_midnight.yaml +9 -0
- iints/data/virtual_patients/clinic_safe_pizza.yaml +9 -0
- iints/data/virtual_patients/clinic_safe_stress_meal.yaml +9 -0
- iints/data/virtual_patients/default_patient.yaml +11 -0
- iints/data/virtual_patients/patient_559_config.yaml +11 -0
- iints/emulation/__init__.py +80 -0
- iints/emulation/legacy_base.py +414 -0
- iints/emulation/medtronic_780g.py +337 -0
- iints/emulation/omnipod_5.py +367 -0
- iints/emulation/tandem_controliq.py +393 -0
- iints/highlevel.py +451 -0
- iints/learning/__init__.py +3 -0
- iints/learning/autonomous_optimizer.py +194 -0
- iints/learning/learning_system.py +122 -0
- iints/metrics.py +34 -0
- iints/population/__init__.py +11 -0
- iints/population/generator.py +131 -0
- iints/population/runner.py +327 -0
- iints/presets/__init__.py +28 -0
- iints/presets/presets.json +114 -0
- iints/research/__init__.py +30 -0
- iints/research/config.py +68 -0
- iints/research/dataset.py +319 -0
- iints/research/losses.py +73 -0
- iints/research/predictor.py +329 -0
- iints/scenarios/__init__.py +3 -0
- iints/scenarios/generator.py +92 -0
- iints/templates/__init__.py +0 -0
- iints/templates/default_algorithm.py +91 -0
- iints/templates/scenarios/__init__.py +0 -0
- iints/templates/scenarios/chaos_insulin_stacking.json +29 -0
- iints/templates/scenarios/chaos_runaway_ai.json +25 -0
- iints/templates/scenarios/example_scenario.json +35 -0
- iints/templates/scenarios/exercise_stress.json +30 -0
- iints/utils/__init__.py +3 -0
- iints/utils/plotting.py +50 -0
- iints/utils/run_io.py +152 -0
- iints/validation/__init__.py +133 -0
- iints/validation/schemas.py +94 -0
- iints/visualization/__init__.py +34 -0
- iints/visualization/cockpit.py +691 -0
- iints/visualization/uncertainty_cloud.py +612 -0
- iints_sdk_python35-0.0.18.dist-info/METADATA +225 -0
- iints_sdk_python35-0.0.18.dist-info/RECORD +118 -0
- iints_sdk_python35-0.0.18.dist-info/WHEEL +5 -0
- iints_sdk_python35-0.0.18.dist-info/entry_points.txt +10 -0
- iints_sdk_python35-0.0.18.dist-info/licenses/LICENSE +28 -0
- iints_sdk_python35-0.0.18.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bergman Minimal Model — IINTS-AF
|
|
3
|
+
==================================
|
|
4
|
+
ODE-based patient model inspired by the Bergman Minimal Model with an
|
|
5
|
+
additional gut absorption compartment for realistic carbohydrate dynamics.
|
|
6
|
+
|
|
7
|
+
This provides a more physiologically accurate glucose simulation than the
|
|
8
|
+
default ``CustomPatientModel``, at the cost of higher computational load
|
|
9
|
+
(uses ``scipy.integrate.solve_ivp``).
|
|
10
|
+
|
|
11
|
+
The model tracks four state variables:
|
|
12
|
+
|
|
13
|
+
* **G** — plasma glucose concentration (mg/dL)
|
|
14
|
+
* **X** — remote insulin action (1/min)
|
|
15
|
+
* **I** — plasma insulin concentration (mU/L)
|
|
16
|
+
* **Q_gut** — gut glucose mass (mg)
|
|
17
|
+
|
|
18
|
+
References
|
|
19
|
+
----------
|
|
20
|
+
* Bergman, R. N. et al. (1979). Quantitative estimation of insulin
|
|
21
|
+
sensitivity. *Am J Physiol*, 236(6), E667–E677.
|
|
22
|
+
* Dalla Man, C. et al. (2007). Meal Simulation Model of the Glucose-
|
|
23
|
+
Insulin System. *IEEE Trans Biomed Eng*, 54(10), 1740–1749.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import Any, Dict, List, Optional
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
from scipy.integrate import solve_ivp
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class BergmanParameters:
|
|
36
|
+
"""Physiological parameters for the Bergman Minimal Model."""
|
|
37
|
+
|
|
38
|
+
# --- Glucose sub-system ---
|
|
39
|
+
p1: float = 0.028 # 1/min — insulin-independent glucose uptake
|
|
40
|
+
p2: float = 0.025 # 1/min — rate of remote insulin degradation
|
|
41
|
+
p3: float = 5.0e-6 # (mU/L)^-1 min^-2 — insulin action gain
|
|
42
|
+
Gb: float = 120.0 # mg/dL — basal glucose concentration
|
|
43
|
+
Vg: float = 1.569 # dL/kg — glucose distribution volume
|
|
44
|
+
|
|
45
|
+
# --- Insulin sub-system ---
|
|
46
|
+
n: float = 0.23 # 1/min — fractional insulin degradation
|
|
47
|
+
Ib: float = 7.0 # mU/L — basal plasma insulin
|
|
48
|
+
Vi: float = 0.05 # L/kg — insulin distribution volume
|
|
49
|
+
gamma: float = 0.004 # (mU/L)/(mg/dL)/min — endogenous secretion gain
|
|
50
|
+
h: float = 80.0 # mg/dL — secretion glucose threshold
|
|
51
|
+
|
|
52
|
+
# --- Gut absorption ---
|
|
53
|
+
tau_meal: float = 40.0 # min — gastric emptying time constant
|
|
54
|
+
k_abs: float = 0.05 # 1/min — intestinal absorption rate constant
|
|
55
|
+
f_bio: float = 0.90 # — — bioavailability (fraction absorbed)
|
|
56
|
+
|
|
57
|
+
# --- Patient physical ---
|
|
58
|
+
body_weight_kg: float = 70.0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class BergmanPatientModel:
|
|
62
|
+
"""
|
|
63
|
+
ODE-based patient model providing the same interface as
|
|
64
|
+
``CustomPatientModel`` for drop-in use with the IINTS Simulator.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
def __init__(
|
|
68
|
+
self,
|
|
69
|
+
basal_insulin_rate: float = 0.8,
|
|
70
|
+
insulin_sensitivity: float = 50.0,
|
|
71
|
+
carb_factor: float = 10.0,
|
|
72
|
+
initial_glucose: float = 120.0,
|
|
73
|
+
glucose_decay_rate: float = 0.05,
|
|
74
|
+
glucose_absorption_rate: float = 0.03,
|
|
75
|
+
insulin_action_duration: float = 300.0,
|
|
76
|
+
insulin_peak_time: float = 75.0,
|
|
77
|
+
meal_mismatch_epsilon: float = 1.0,
|
|
78
|
+
dawn_phenomenon_strength: float = 0.0,
|
|
79
|
+
dawn_start_hour: float = 4.0,
|
|
80
|
+
dawn_end_hour: float = 8.0,
|
|
81
|
+
carb_absorption_duration_minutes: float = 240.0,
|
|
82
|
+
bergman_params: Optional[BergmanParameters] = None,
|
|
83
|
+
) -> None:
|
|
84
|
+
# Store clinical knobs (for ratio queries and compatibility)
|
|
85
|
+
self.basal_insulin_rate = basal_insulin_rate
|
|
86
|
+
self.insulin_sensitivity = insulin_sensitivity
|
|
87
|
+
self.carb_factor = carb_factor
|
|
88
|
+
self.initial_glucose = initial_glucose
|
|
89
|
+
self.glucose_decay_rate = glucose_decay_rate
|
|
90
|
+
self.glucose_absorption_rate = glucose_absorption_rate
|
|
91
|
+
self.insulin_action_duration = insulin_action_duration
|
|
92
|
+
self.insulin_peak_time = insulin_peak_time
|
|
93
|
+
self.meal_mismatch_epsilon = meal_mismatch_epsilon
|
|
94
|
+
self.dawn_phenomenon_strength = dawn_phenomenon_strength
|
|
95
|
+
self.dawn_start_hour = dawn_start_hour
|
|
96
|
+
self.dawn_end_hour = dawn_end_hour
|
|
97
|
+
self.carb_absorption_duration_minutes = carb_absorption_duration_minutes
|
|
98
|
+
|
|
99
|
+
# Bergman ODE parameters
|
|
100
|
+
self.params = bergman_params if bergman_params else BergmanParameters(Gb=initial_glucose)
|
|
101
|
+
|
|
102
|
+
# Exercise book-keeping
|
|
103
|
+
self.is_exercising = False
|
|
104
|
+
self.exercise_intensity = 0.0
|
|
105
|
+
self.exercise_glucose_consumption_rate = 1.5 # mg/dL per min at max
|
|
106
|
+
|
|
107
|
+
# Dose/carb trackers for IOB/COB (same format as CustomPatientModel)
|
|
108
|
+
self.active_insulin_doses: List[Dict[str, float]] = []
|
|
109
|
+
self.active_carb_intakes: List[Dict[str, float]] = []
|
|
110
|
+
|
|
111
|
+
# Derived scalar state
|
|
112
|
+
self.current_glucose = initial_glucose
|
|
113
|
+
self.insulin_on_board = 0.0
|
|
114
|
+
self.carbs_on_board = 0.0
|
|
115
|
+
self.meal_effect_delay = 30 # kept for API compat
|
|
116
|
+
|
|
117
|
+
# ODE state vector: [G, X, I, Q_gut]
|
|
118
|
+
self._state = np.array([
|
|
119
|
+
initial_glucose, # G (mg/dL)
|
|
120
|
+
0.0, # X (1/min)
|
|
121
|
+
self.params.Ib, # I (mU/L)
|
|
122
|
+
0.0, # Q_gut (mg)
|
|
123
|
+
], dtype=np.float64)
|
|
124
|
+
|
|
125
|
+
self.reset()
|
|
126
|
+
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
# Public interface (mirrors CustomPatientModel exactly)
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
def reset(self) -> None:
|
|
132
|
+
"""Reset to initial conditions."""
|
|
133
|
+
self._state = np.array([
|
|
134
|
+
self.initial_glucose, 0.0, self.params.Ib, 0.0,
|
|
135
|
+
], dtype=np.float64)
|
|
136
|
+
self.current_glucose = self.initial_glucose
|
|
137
|
+
self.insulin_on_board = 0.0
|
|
138
|
+
self.carbs_on_board = 0.0
|
|
139
|
+
self.active_insulin_doses = []
|
|
140
|
+
self.active_carb_intakes = []
|
|
141
|
+
self.is_exercising = False
|
|
142
|
+
self.exercise_intensity = 0.0
|
|
143
|
+
|
|
144
|
+
def start_exercise(self, intensity: float) -> None:
|
|
145
|
+
if not (0.0 <= intensity <= 1.0):
|
|
146
|
+
raise ValueError("Exercise intensity must be between 0.0 and 1.0")
|
|
147
|
+
self.is_exercising = True
|
|
148
|
+
self.exercise_intensity = intensity
|
|
149
|
+
|
|
150
|
+
def stop_exercise(self) -> None:
|
|
151
|
+
self.is_exercising = False
|
|
152
|
+
self.exercise_intensity = 0.0
|
|
153
|
+
|
|
154
|
+
def update(
|
|
155
|
+
self,
|
|
156
|
+
time_step: float,
|
|
157
|
+
delivered_insulin: float,
|
|
158
|
+
carb_intake: float = 0.0,
|
|
159
|
+
current_time: Optional[float] = None,
|
|
160
|
+
**kwargs,
|
|
161
|
+
) -> float:
|
|
162
|
+
"""Advance the model by *time_step* minutes and return new glucose."""
|
|
163
|
+
true_carbs = carb_intake * self.meal_mismatch_epsilon
|
|
164
|
+
|
|
165
|
+
# --- Track IOB (same bookkeeping as CustomPatientModel) ---
|
|
166
|
+
if delivered_insulin > 0.001:
|
|
167
|
+
self.active_insulin_doses.append({"amount": delivered_insulin, "age": 0.0})
|
|
168
|
+
for d in self.active_insulin_doses:
|
|
169
|
+
d["age"] += time_step
|
|
170
|
+
self.active_insulin_doses = [
|
|
171
|
+
d for d in self.active_insulin_doses
|
|
172
|
+
if d["age"] <= self.insulin_action_duration
|
|
173
|
+
]
|
|
174
|
+
self.insulin_on_board = sum(
|
|
175
|
+
d["amount"] * max(0.0, (self.insulin_action_duration - d["age"]) / self.insulin_action_duration)
|
|
176
|
+
for d in self.active_insulin_doses
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# --- Track COB ---
|
|
180
|
+
if true_carbs > 0:
|
|
181
|
+
self.active_carb_intakes.append({"amount": true_carbs, "time_since_intake": 0.0})
|
|
182
|
+
for c in self.active_carb_intakes:
|
|
183
|
+
c["time_since_intake"] += time_step
|
|
184
|
+
self.active_carb_intakes = [
|
|
185
|
+
c for c in self.active_carb_intakes
|
|
186
|
+
if c["time_since_intake"] <= self.carb_absorption_duration_minutes
|
|
187
|
+
]
|
|
188
|
+
self.carbs_on_board = sum(
|
|
189
|
+
c["amount"] * max(0.0, 1.0 - c["time_since_intake"] / self.carb_absorption_duration_minutes)
|
|
190
|
+
for c in self.active_carb_intakes
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# --- Inject carbs into gut compartment ---
|
|
194
|
+
# Add carbs as glucose mass (mg) into Q_gut
|
|
195
|
+
if true_carbs > 0:
|
|
196
|
+
self._state[3] += true_carbs * 1000.0 # g -> mg
|
|
197
|
+
|
|
198
|
+
# --- Prepare exogenous insulin rate ---
|
|
199
|
+
# Convert Units to mU, spread evenly over time_step (mU/min)
|
|
200
|
+
insulin_rate = (delivered_insulin * 1000.0) / max(time_step, 0.001)
|
|
201
|
+
|
|
202
|
+
# --- Solve ODE ---
|
|
203
|
+
ct = current_time if current_time is not None else 0.0
|
|
204
|
+
sol = solve_ivp(
|
|
205
|
+
fun=lambda t, y: self._ode(t, y, insulin_rate, ct),
|
|
206
|
+
t_span=(0.0, time_step),
|
|
207
|
+
y0=self._state,
|
|
208
|
+
method="RK45",
|
|
209
|
+
max_step=1.0,
|
|
210
|
+
rtol=1e-6,
|
|
211
|
+
atol=1e-8,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
self._state = sol.y[:, -1].copy()
|
|
215
|
+
# Floor glucose at 20 mg/dL (physiological minimum)
|
|
216
|
+
self._state[0] = max(20.0, self._state[0])
|
|
217
|
+
# Clamp non-negative for other compartments
|
|
218
|
+
self._state[1] = max(0.0, self._state[1])
|
|
219
|
+
self._state[2] = max(0.0, self._state[2])
|
|
220
|
+
self._state[3] = max(0.0, self._state[3])
|
|
221
|
+
|
|
222
|
+
self.current_glucose = float(self._state[0])
|
|
223
|
+
return self.current_glucose
|
|
224
|
+
|
|
225
|
+
def get_current_glucose(self) -> float:
|
|
226
|
+
return self.current_glucose
|
|
227
|
+
|
|
228
|
+
def trigger_event(self, event_type: str, value: Any) -> None:
|
|
229
|
+
pass # handled by the simulator
|
|
230
|
+
|
|
231
|
+
def get_patient_state(self) -> Dict[str, float]:
|
|
232
|
+
return {
|
|
233
|
+
"current_glucose": self.current_glucose,
|
|
234
|
+
"insulin_on_board": self.insulin_on_board,
|
|
235
|
+
"carbs_on_board": self.carbs_on_board,
|
|
236
|
+
"basal_rate_u_per_hr": self.basal_insulin_rate,
|
|
237
|
+
"isf": self.insulin_sensitivity,
|
|
238
|
+
"icr": self.carb_factor,
|
|
239
|
+
"dia_minutes": self.insulin_action_duration,
|
|
240
|
+
"plasma_insulin_mU_L": float(self._state[2]),
|
|
241
|
+
"remote_insulin_action": float(self._state[1]),
|
|
242
|
+
"gut_glucose_mg": float(self._state[3]),
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
def get_ratio_state(self) -> Dict[str, float]:
|
|
246
|
+
return {
|
|
247
|
+
"basal_rate_u_per_hr": self.basal_insulin_rate,
|
|
248
|
+
"isf": self.insulin_sensitivity,
|
|
249
|
+
"icr": self.carb_factor,
|
|
250
|
+
"dia_minutes": self.insulin_action_duration,
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
def set_ratio_state(
|
|
254
|
+
self,
|
|
255
|
+
isf: Optional[float] = None,
|
|
256
|
+
icr: Optional[float] = None,
|
|
257
|
+
basal_rate: Optional[float] = None,
|
|
258
|
+
dia_minutes: Optional[float] = None,
|
|
259
|
+
) -> None:
|
|
260
|
+
if isf is not None:
|
|
261
|
+
self.insulin_sensitivity = float(isf)
|
|
262
|
+
if icr is not None:
|
|
263
|
+
self.carb_factor = float(icr)
|
|
264
|
+
if basal_rate is not None:
|
|
265
|
+
self.basal_insulin_rate = float(basal_rate)
|
|
266
|
+
if dia_minutes is not None:
|
|
267
|
+
self.insulin_action_duration = float(dia_minutes)
|
|
268
|
+
|
|
269
|
+
def get_state(self) -> Dict[str, Any]:
|
|
270
|
+
return {
|
|
271
|
+
"ode_state": self._state.tolist(),
|
|
272
|
+
"current_glucose": self.current_glucose,
|
|
273
|
+
"insulin_on_board": self.insulin_on_board,
|
|
274
|
+
"carbs_on_board": self.carbs_on_board,
|
|
275
|
+
"active_insulin_doses": self.active_insulin_doses,
|
|
276
|
+
"active_carb_intakes": self.active_carb_intakes,
|
|
277
|
+
"is_exercising": self.is_exercising,
|
|
278
|
+
"exercise_intensity": self.exercise_intensity,
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
def set_state(self, state: Dict[str, Any]) -> None:
|
|
282
|
+
if "ode_state" in state:
|
|
283
|
+
self._state = np.array(state["ode_state"], dtype=np.float64)
|
|
284
|
+
self.current_glucose = state.get("current_glucose", self.current_glucose)
|
|
285
|
+
self.insulin_on_board = state.get("insulin_on_board", self.insulin_on_board)
|
|
286
|
+
self.carbs_on_board = state.get("carbs_on_board", self.carbs_on_board)
|
|
287
|
+
self.active_insulin_doses = state.get("active_insulin_doses", [])
|
|
288
|
+
self.active_carb_intakes = state.get("active_carb_intakes", [])
|
|
289
|
+
self.is_exercising = state.get("is_exercising", False)
|
|
290
|
+
self.exercise_intensity = state.get("exercise_intensity", 0.0)
|
|
291
|
+
|
|
292
|
+
# ------------------------------------------------------------------
|
|
293
|
+
# ODE right-hand-side
|
|
294
|
+
# ------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
def _ode(
|
|
297
|
+
self,
|
|
298
|
+
t: float,
|
|
299
|
+
y: np.ndarray,
|
|
300
|
+
u_insulin_mu_per_min: float,
|
|
301
|
+
current_time: float,
|
|
302
|
+
) -> np.ndarray:
|
|
303
|
+
G, X, I, Q_gut = y
|
|
304
|
+
p = self.params
|
|
305
|
+
|
|
306
|
+
Vg_abs = p.Vg * p.body_weight_kg # dL
|
|
307
|
+
Vi_abs = p.Vi * p.body_weight_kg # L
|
|
308
|
+
|
|
309
|
+
# --- Glucose rate of appearance from gut ---
|
|
310
|
+
Ra = (p.k_abs * Q_gut) / Vg_abs # mg/dL/min
|
|
311
|
+
|
|
312
|
+
# --- Dawn phenomenon ---
|
|
313
|
+
dawn = 0.0
|
|
314
|
+
if self.dawn_phenomenon_strength > 0:
|
|
315
|
+
minutes_in_day = current_time % 1440
|
|
316
|
+
ds = self.dawn_start_hour * 60
|
|
317
|
+
de = self.dawn_end_hour * 60
|
|
318
|
+
if ds <= minutes_in_day <= de:
|
|
319
|
+
dawn = self.dawn_phenomenon_strength / 60.0 # mg/dL/min
|
|
320
|
+
|
|
321
|
+
# --- Exercise ---
|
|
322
|
+
exercise = 0.0
|
|
323
|
+
if self.is_exercising:
|
|
324
|
+
exercise = self.exercise_intensity * self.exercise_glucose_consumption_rate
|
|
325
|
+
|
|
326
|
+
# --- dG/dt ---
|
|
327
|
+
dGdt = -(p.p1 + X) * G + p.p1 * p.Gb + Ra + dawn - exercise
|
|
328
|
+
|
|
329
|
+
# --- dX/dt ---
|
|
330
|
+
dXdt = -p.p2 * X + p.p3 * max(I - p.Ib, 0.0)
|
|
331
|
+
|
|
332
|
+
# --- dI/dt ---
|
|
333
|
+
# Endogenous pancreatic secretion (blunted in T1D, but kept for generality)
|
|
334
|
+
secretion = p.gamma * max(G - p.h, 0.0)
|
|
335
|
+
dIdt = -p.n * (I - p.Ib) + secretion + u_insulin_mu_per_min / Vi_abs
|
|
336
|
+
|
|
337
|
+
# --- dQ_gut/dt ---
|
|
338
|
+
# Q_gut decays as glucose is absorbed into plasma
|
|
339
|
+
dQ_gut_dt = -p.k_abs * Q_gut
|
|
340
|
+
|
|
341
|
+
return np.array([dGdt, dXdt, dIdt, dQ_gut_dt])
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from typing import Dict, Any, Optional
|
|
3
|
+
|
|
4
|
+
# Use custom patient model as default to avoid simglucose dependency issues
|
|
5
|
+
# from simglucose.simulation.env import T1DSimEnv
|
|
6
|
+
# from simglucose.patient.t1dpatient import T1DPatient
|
|
7
|
+
# from simglucose.sensor.cgm import CGMSensor
|
|
8
|
+
# from simglucose.actuator.pump import InsulinPump
|
|
9
|
+
# from simglucose.controller.base import Action
|
|
10
|
+
|
|
11
|
+
class CustomPatientModel:
|
|
12
|
+
"""
|
|
13
|
+
A simplified patient model for simulating blood glucose dynamics.
|
|
14
|
+
This model is intended for educational and stress-testing purposes, not for clinical accuracy.
|
|
15
|
+
"""
|
|
16
|
+
def __init__(self, basal_insulin_rate: float = 0.8, insulin_sensitivity: float = 50.0,
|
|
17
|
+
carb_factor: float = 10.0, glucose_decay_rate: float = 0.002,
|
|
18
|
+
initial_glucose: float = 120.0, glucose_absorption_rate: float = 0.03,
|
|
19
|
+
insulin_action_duration: float = 300.0, # minutes, e.g., 5 hours
|
|
20
|
+
insulin_peak_time: float = 75.0, # minutes
|
|
21
|
+
meal_mismatch_epsilon: float = 1.0, # Factor for meal mismatch
|
|
22
|
+
dawn_phenomenon_strength: float = 0.0, # mg/dL per hour
|
|
23
|
+
dawn_start_hour: float = 4.0,
|
|
24
|
+
dawn_end_hour: float = 8.0,
|
|
25
|
+
carb_absorption_duration_minutes: float = 240.0):
|
|
26
|
+
"""
|
|
27
|
+
Initializes the patient model with simplified parameters.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
basal_insulin_rate (float): Basal insulin rate in U/hr.
|
|
31
|
+
insulin_sensitivity (float): How much 1 unit of insulin lowers glucose (mg/dL per Unit).
|
|
32
|
+
carb_factor (float): How many carbs (g) are covered by 1 unit of insulin.
|
|
33
|
+
glucose_decay_rate (float): Rate at which glucose naturally decreases (e.g., due to metabolism).
|
|
34
|
+
initial_glucose (float): Starting blood glucose level (mg/dL).
|
|
35
|
+
glucose_absorption_rate (float): Rate at which carbs are absorbed into glucose.
|
|
36
|
+
insulin_action_duration (float): Duration of insulin action (DIA) in minutes.
|
|
37
|
+
insulin_peak_time (float): Time to peak insulin activity in minutes.
|
|
38
|
+
meal_mismatch_epsilon (float): The multiplier for carb intake to simulate meal size errors.
|
|
39
|
+
`true_carbs = announced_carbs * meal_mismatch_epsilon`. Defaults to 1.0.
|
|
40
|
+
"""
|
|
41
|
+
self.basal_insulin_rate = basal_insulin_rate
|
|
42
|
+
self.insulin_sensitivity = insulin_sensitivity
|
|
43
|
+
self.carb_factor = carb_factor
|
|
44
|
+
self.glucose_decay_rate = glucose_decay_rate
|
|
45
|
+
self.glucose_absorption_rate = glucose_absorption_rate
|
|
46
|
+
self.insulin_action_duration = insulin_action_duration
|
|
47
|
+
self.insulin_peak_time = insulin_peak_time
|
|
48
|
+
self.meal_mismatch_epsilon = meal_mismatch_epsilon
|
|
49
|
+
self.dawn_phenomenon_strength = dawn_phenomenon_strength
|
|
50
|
+
self.dawn_start_hour = dawn_start_hour
|
|
51
|
+
self.dawn_end_hour = dawn_end_hour
|
|
52
|
+
self.carb_absorption_duration_minutes = carb_absorption_duration_minutes
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
self.initial_glucose = initial_glucose
|
|
56
|
+
self.current_glucose = initial_glucose
|
|
57
|
+
self.insulin_on_board = 0.0 # Units of insulin still active
|
|
58
|
+
self.carbs_on_board = 0.0 # Grams of carbs still being absorbed
|
|
59
|
+
self.meal_effect_delay = 30 # minutes for carb absorption to peak
|
|
60
|
+
|
|
61
|
+
# Exercise state
|
|
62
|
+
self.is_exercising = False
|
|
63
|
+
self.exercise_intensity = 0.0 # 0.0 to 1.0
|
|
64
|
+
self.exercise_glucose_consumption_rate = 1.5 # mg/dL per minute at max intensity
|
|
65
|
+
|
|
66
|
+
self.reset() # Call reset to ensure initial state consistency
|
|
67
|
+
|
|
68
|
+
def reset(self):
|
|
69
|
+
"""Resets the patient's state to initial conditions."""
|
|
70
|
+
self.current_glucose = self.initial_glucose
|
|
71
|
+
self.insulin_on_board = 0.0
|
|
72
|
+
self.carbs_on_board = 0.0
|
|
73
|
+
self.active_insulin_doses = [] # List of {'amount': float, 'age': float}
|
|
74
|
+
self.active_carb_intakes = [] # (carb_amount, time_since_intake)
|
|
75
|
+
self.is_exercising = False
|
|
76
|
+
self.exercise_intensity = 0.0
|
|
77
|
+
|
|
78
|
+
def start_exercise(self, intensity: float):
|
|
79
|
+
"""Starts an exercise session."""
|
|
80
|
+
if not (0.0 <= intensity <= 1.0):
|
|
81
|
+
raise ValueError("Exercise intensity must be between 0.0 and 1.0")
|
|
82
|
+
self.is_exercising = True
|
|
83
|
+
self.exercise_intensity = intensity
|
|
84
|
+
print(f"INFO: Patient started exercise with intensity {intensity:.2f}")
|
|
85
|
+
|
|
86
|
+
def stop_exercise(self):
|
|
87
|
+
"""Stops an exercise session."""
|
|
88
|
+
self.is_exercising = False
|
|
89
|
+
self.exercise_intensity = 0.0
|
|
90
|
+
print("INFO: Patient stopped exercise.")
|
|
91
|
+
|
|
92
|
+
def update(self, time_step: float, delivered_insulin: float, carb_intake: float = 0.0, current_time: Optional[float] = None, **kwargs) -> float:
|
|
93
|
+
"""
|
|
94
|
+
Updates the patient's glucose level over a given time step.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
time_step (float): The duration of the simulation step in minutes.
|
|
98
|
+
delivered_insulin (float): Total insulin delivered in this time step (e.g., bolus + basal).
|
|
99
|
+
carb_intake (float): Carbohydrates consumed in this time step (grams).
|
|
100
|
+
**kwargs: Additional factors like exercise, stress (not yet implemented in detail).
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
float: The new current blood glucose level.
|
|
104
|
+
"""
|
|
105
|
+
# Convert time_step to hours for basal rate
|
|
106
|
+
time_step_hours = time_step / 60.0
|
|
107
|
+
|
|
108
|
+
# --- Insulin effect ---
|
|
109
|
+
# Add new insulin dose
|
|
110
|
+
if delivered_insulin > 0.001:
|
|
111
|
+
self.active_insulin_doses.append({'amount': delivered_insulin, 'age': 0.0})
|
|
112
|
+
|
|
113
|
+
# Update ages and remove old doses
|
|
114
|
+
for dose in self.active_insulin_doses:
|
|
115
|
+
dose['age'] += time_step
|
|
116
|
+
|
|
117
|
+
self.active_insulin_doses = [d for d in self.active_insulin_doses if d['age'] <= self.insulin_action_duration]
|
|
118
|
+
|
|
119
|
+
# Calculate IOB (Insulin on Board) using a linear decay model for remaining insulin
|
|
120
|
+
iob = 0.0
|
|
121
|
+
for dose in self.active_insulin_doses:
|
|
122
|
+
remaining_fraction = (self.insulin_action_duration - dose['age']) / self.insulin_action_duration
|
|
123
|
+
iob += dose['amount'] * remaining_fraction
|
|
124
|
+
self.insulin_on_board = iob
|
|
125
|
+
|
|
126
|
+
# Calculate Insulin Action for this time step using a bilinear activity curve
|
|
127
|
+
total_insulin_action = 0.0
|
|
128
|
+
for dose in self.active_insulin_doses:
|
|
129
|
+
if dose['age'] < self.insulin_peak_time:
|
|
130
|
+
# Action ramps up
|
|
131
|
+
action_factor = dose['age'] / self.insulin_peak_time
|
|
132
|
+
else:
|
|
133
|
+
# Action ramps down
|
|
134
|
+
action_factor = (self.insulin_action_duration - dose['age']) / (self.insulin_action_duration - self.insulin_peak_time)
|
|
135
|
+
|
|
136
|
+
# Normalize the action so the total effect of 1U of insulin equals 1U * insulin_sensitivity
|
|
137
|
+
# The area under the bilinear activity curve is 0.5 * peak_time + 0.5 * (duration - peak_time) = 0.5 * duration
|
|
138
|
+
# The action for this step is a fraction of the total dose effect.
|
|
139
|
+
dose_action_this_step = dose['amount'] * action_factor * (time_step / (0.5 * self.insulin_action_duration))
|
|
140
|
+
total_insulin_action += dose_action_this_step
|
|
141
|
+
|
|
142
|
+
insulin_effect = total_insulin_action * self.insulin_sensitivity
|
|
143
|
+
|
|
144
|
+
# --- Carb effect ---
|
|
145
|
+
# The 'carb_intake' parameter represents the "announced carbs" by the AI.
|
|
146
|
+
# The 'true_carbs' are the actual carbs the patient's body processes,
|
|
147
|
+
# simulated using the meal_mismatch_epsilon parameter.
|
|
148
|
+
true_carbs = carb_intake * self.meal_mismatch_epsilon
|
|
149
|
+
# Add new carbs
|
|
150
|
+
if true_carbs > 0:
|
|
151
|
+
self.active_carb_intakes.append({'amount': true_carbs, 'time_since_intake': 0.0})
|
|
152
|
+
|
|
153
|
+
# Process active carbs
|
|
154
|
+
carb_effect = 0.0
|
|
155
|
+
new_active_carb_intakes = []
|
|
156
|
+
for carb_event in self.active_carb_intakes:
|
|
157
|
+
carb_event['time_since_intake'] += time_step
|
|
158
|
+
# Simple model: carbs absorb over time, peaking around meal_effect_delay
|
|
159
|
+
# This is a very rough approximation
|
|
160
|
+
absorption_factor = 0.0
|
|
161
|
+
if carb_event['time_since_intake'] <= self.carb_absorption_duration_minutes: # Carbs absorb for ~4 hours
|
|
162
|
+
absorption_factor = self.glucose_absorption_rate * (np.exp(-carb_event['time_since_intake'] / self.meal_effect_delay) - np.exp(-carb_event['time_since_intake'] / (self.meal_effect_delay * 0.5)))
|
|
163
|
+
carb_effect += carb_event['amount'] * absorption_factor
|
|
164
|
+
new_active_carb_intakes.append(carb_event)
|
|
165
|
+
# Carbs are "gone" after a while, or their effect is negligible
|
|
166
|
+
self.active_carb_intakes = new_active_carb_intakes
|
|
167
|
+
# Estimate carbs on board based on remaining absorption window
|
|
168
|
+
carb_remaining = 0.0
|
|
169
|
+
for carb_event in self.active_carb_intakes:
|
|
170
|
+
remaining_fraction = max(
|
|
171
|
+
0.0,
|
|
172
|
+
1.0 - (carb_event['time_since_intake'] / self.carb_absorption_duration_minutes),
|
|
173
|
+
)
|
|
174
|
+
carb_remaining += carb_event['amount'] * remaining_fraction
|
|
175
|
+
self.carbs_on_board = carb_remaining
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# --- Exercise Effect ---
|
|
179
|
+
exercise_effect = 0.0
|
|
180
|
+
if self.is_exercising:
|
|
181
|
+
exercise_effect = self.exercise_intensity * self.exercise_glucose_consumption_rate * time_step
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# --- Basal metabolic glucose production/consumption (simplified) ---
|
|
185
|
+
basal_glucose_change = -self.glucose_decay_rate * self.current_glucose * time_step
|
|
186
|
+
|
|
187
|
+
# --- Dawn phenomenon effect ---
|
|
188
|
+
dawn_effect = 0.0
|
|
189
|
+
if current_time is not None and self.dawn_phenomenon_strength > 0:
|
|
190
|
+
minutes_in_day = current_time % 1440
|
|
191
|
+
dawn_start_min = self.dawn_start_hour * 60
|
|
192
|
+
dawn_end_min = self.dawn_end_hour * 60
|
|
193
|
+
if dawn_start_min <= minutes_in_day <= dawn_end_min:
|
|
194
|
+
dawn_effect = (self.dawn_phenomenon_strength / 60.0) * time_step
|
|
195
|
+
|
|
196
|
+
# --- Update glucose ---
|
|
197
|
+
delta_glucose = carb_effect - insulin_effect - exercise_effect + basal_glucose_change + dawn_effect
|
|
198
|
+
self.current_glucose = max(20, self.current_glucose + delta_glucose) # Prevent glucose from going too low (hypoglycemia)
|
|
199
|
+
|
|
200
|
+
return self.current_glucose
|
|
201
|
+
|
|
202
|
+
def get_current_glucose(self) -> float:
|
|
203
|
+
"""Returns the current blood glucose level."""
|
|
204
|
+
return self.current_glucose
|
|
205
|
+
|
|
206
|
+
def trigger_event(self, event_type: str, value: Any):
|
|
207
|
+
"""
|
|
208
|
+
Triggers a specific event for stress testing (e.g., missed meal, sensor error).
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
event_type (str): Type of event ('missed_meal', 'sensor_error', 'exercise', etc.).
|
|
212
|
+
value (Any): Value associated with the event (e.g., carb amount for missed meal).
|
|
213
|
+
"""
|
|
214
|
+
if event_type == 'missed_meal':
|
|
215
|
+
print(f"STRESS EVENT: Missed meal of {value}g carbs!")
|
|
216
|
+
# This event primarily affects the *simulator's* input to the algorithm,
|
|
217
|
+
# but the patient model needs to know if carbs are actually consumed.
|
|
218
|
+
# For now, it's a print statement. Actual effect handled in simulator.
|
|
219
|
+
elif event_type == 'sensor_error':
|
|
220
|
+
print(f"STRESS EVENT: Sensor error - returning {value} as glucose reading.")
|
|
221
|
+
# This event will be handled by the simulator intercepting glucose readings.
|
|
222
|
+
else:
|
|
223
|
+
print(f"Unknown stress event: {event_type}")
|
|
224
|
+
|
|
225
|
+
# Helper function for visualization/logging
|
|
226
|
+
def get_patient_state(self) -> Dict[str, float]:
|
|
227
|
+
return {
|
|
228
|
+
"current_glucose": self.current_glucose,
|
|
229
|
+
"insulin_on_board": self.insulin_on_board,
|
|
230
|
+
"carbs_on_board": self.carbs_on_board,
|
|
231
|
+
"basal_rate_u_per_hr": self.basal_insulin_rate,
|
|
232
|
+
"isf": self.insulin_sensitivity,
|
|
233
|
+
"icr": self.carb_factor,
|
|
234
|
+
"dia_minutes": self.insulin_action_duration,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
def get_ratio_state(self) -> Dict[str, float]:
|
|
238
|
+
return {
|
|
239
|
+
"basal_rate_u_per_hr": self.basal_insulin_rate,
|
|
240
|
+
"isf": self.insulin_sensitivity,
|
|
241
|
+
"icr": self.carb_factor,
|
|
242
|
+
"dia_minutes": self.insulin_action_duration,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
def set_ratio_state(
|
|
246
|
+
self,
|
|
247
|
+
isf: Optional[float] = None,
|
|
248
|
+
icr: Optional[float] = None,
|
|
249
|
+
basal_rate: Optional[float] = None,
|
|
250
|
+
dia_minutes: Optional[float] = None,
|
|
251
|
+
) -> None:
|
|
252
|
+
if isf is not None:
|
|
253
|
+
self.insulin_sensitivity = float(isf)
|
|
254
|
+
if icr is not None:
|
|
255
|
+
self.carb_factor = float(icr)
|
|
256
|
+
if basal_rate is not None:
|
|
257
|
+
self.basal_insulin_rate = float(basal_rate)
|
|
258
|
+
if dia_minutes is not None:
|
|
259
|
+
self.insulin_action_duration = float(dia_minutes)
|
|
260
|
+
|
|
261
|
+
def get_state(self) -> Dict[str, Any]:
|
|
262
|
+
return {
|
|
263
|
+
"current_glucose": self.current_glucose,
|
|
264
|
+
"insulin_on_board": self.insulin_on_board,
|
|
265
|
+
"carbs_on_board": self.carbs_on_board,
|
|
266
|
+
"active_insulin_doses": self.active_insulin_doses,
|
|
267
|
+
"active_carb_intakes": self.active_carb_intakes,
|
|
268
|
+
"is_exercising": self.is_exercising,
|
|
269
|
+
"exercise_intensity": self.exercise_intensity,
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
def set_state(self, state: Dict[str, Any]) -> None:
|
|
273
|
+
self.current_glucose = state.get("current_glucose", self.current_glucose)
|
|
274
|
+
self.insulin_on_board = state.get("insulin_on_board", self.insulin_on_board)
|
|
275
|
+
self.carbs_on_board = state.get("carbs_on_board", self.carbs_on_board)
|
|
276
|
+
self.active_insulin_doses = state.get("active_insulin_doses", [])
|
|
277
|
+
self.active_carb_intakes = state.get("active_carb_intakes", [])
|
|
278
|
+
self.is_exercising = state.get("is_exercising", False)
|
|
279
|
+
self.exercise_intensity = state.get("exercise_intensity", 0.0)
|
|
280
|
+
|
|
281
|
+
# Alias for easy import
|
|
282
|
+
PatientModel = CustomPatientModel
|
|
283
|
+
|
|
284
|
+
# SimglucosePatientModel commented out due to dependency issues
|
|
285
|
+
# Uncomment and install simglucose for FDA-approved virtual patients
|