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
iints/core/simulator.py
ADDED
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import pandas as pd
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from typing import Callable
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List, Dict, Any, Optional, Tuple, Generator
|
|
8
|
+
from iints.core.patient.models import PatientModel
|
|
9
|
+
from iints.api.base_algorithm import InsulinAlgorithm, AlgorithmInput
|
|
10
|
+
from iints.core.supervisor import IndependentSupervisor, SafetyLevel
|
|
11
|
+
from iints.core.safety import InputValidator, SafetyConfig
|
|
12
|
+
from iints.core.devices.models import SensorModel, PumpModel
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("iints")
|
|
16
|
+
|
|
17
|
+
class SimulationLimitError(RuntimeError):
|
|
18
|
+
"""Raised when a simulation violates critical safety limits."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, message: str, current_time: float, glucose_value: float, duration_minutes: float):
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.current_time = current_time
|
|
23
|
+
self.glucose_value = glucose_value
|
|
24
|
+
self.duration_minutes = duration_minutes
|
|
25
|
+
|
|
26
|
+
class StressEvent:
|
|
27
|
+
"""Represents a discrete event that can occur during a simulation for stress testing."""
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
start_time: int,
|
|
31
|
+
event_type: str,
|
|
32
|
+
value: Any = None,
|
|
33
|
+
reported_value: Any = None,
|
|
34
|
+
absorption_delay_minutes: int = 0,
|
|
35
|
+
duration: int = 0,
|
|
36
|
+
isf: Optional[float] = None,
|
|
37
|
+
icr: Optional[float] = None,
|
|
38
|
+
basal_rate: Optional[float] = None,
|
|
39
|
+
dia_minutes: Optional[float] = None,
|
|
40
|
+
) -> None:
|
|
41
|
+
"""
|
|
42
|
+
Args:
|
|
43
|
+
start_time (int): The simulation time (in minutes) when the event should occur.
|
|
44
|
+
event_type (str): Type of event (e.g., 'meal', 'missed_meal', 'sensor_error', 'exercise').
|
|
45
|
+
value (Any): Value associated with the event (e.g., carb amount, error value).
|
|
46
|
+
reported_value (Any): Value reported to the algorithm (if different from actual value, e.g., for "prul" scenarios).
|
|
47
|
+
absorption_delay_minutes (int): Delay in minutes before carbohydrates from this event start affecting glucose.
|
|
48
|
+
duration (int): The duration of the event in minutes (e.g., for exercise).
|
|
49
|
+
"""
|
|
50
|
+
self.start_time = start_time
|
|
51
|
+
self.event_type = event_type
|
|
52
|
+
self.value = value
|
|
53
|
+
self.reported_value = reported_value # New attribute
|
|
54
|
+
self.absorption_delay_minutes = absorption_delay_minutes # New attribute
|
|
55
|
+
self.duration = duration
|
|
56
|
+
self.isf = isf
|
|
57
|
+
self.icr = icr
|
|
58
|
+
self.basal_rate = basal_rate
|
|
59
|
+
self.dia_minutes = dia_minutes
|
|
60
|
+
|
|
61
|
+
def __str__(self) -> str:
|
|
62
|
+
reported_str = f", Reported: {self.reported_value}" if self.reported_value is not None else ""
|
|
63
|
+
ratio_str = ""
|
|
64
|
+
if self.event_type == "ratio_change":
|
|
65
|
+
parts = []
|
|
66
|
+
if self.isf is not None:
|
|
67
|
+
parts.append(f"ISF={self.isf}")
|
|
68
|
+
if self.icr is not None:
|
|
69
|
+
parts.append(f"ICR={self.icr}")
|
|
70
|
+
if self.basal_rate is not None:
|
|
71
|
+
parts.append(f"Basal={self.basal_rate}")
|
|
72
|
+
if self.dia_minutes is not None:
|
|
73
|
+
parts.append(f"DIA={self.dia_minutes}")
|
|
74
|
+
if parts:
|
|
75
|
+
ratio_str = ", " + ", ".join(parts)
|
|
76
|
+
delay_str = f", Delay: {self.absorption_delay_minutes}m" if self.absorption_delay_minutes > 0 else ""
|
|
77
|
+
duration_str = f", Duration: {self.duration}m" if self.duration > 0 else ""
|
|
78
|
+
return f"Event(Time: {self.start_time}m, Type: {self.event_type}, Value: {self.value}{reported_str}{ratio_str}{delay_str}{duration_str})"
|
|
79
|
+
|
|
80
|
+
class Simulator:
|
|
81
|
+
"""
|
|
82
|
+
Orchestrates the interaction between a patient model and an insulin algorithm
|
|
83
|
+
over a simulated period, including stress-test scenarios.
|
|
84
|
+
"""
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
patient_model: PatientModel,
|
|
88
|
+
algorithm: InsulinAlgorithm,
|
|
89
|
+
time_step: int = 5,
|
|
90
|
+
seed: Optional[int] = None,
|
|
91
|
+
audit_log_path: Optional[str] = None,
|
|
92
|
+
enable_profiling: bool = False,
|
|
93
|
+
sensor_model: Optional[SensorModel] = None,
|
|
94
|
+
pump_model: Optional[PumpModel] = None,
|
|
95
|
+
on_step: Optional[Callable[[Dict[str, Any]], Optional[Dict[str, Any]]]] = None,
|
|
96
|
+
on_safety_event: Optional[Callable[[Dict[str, Any]], None]] = None,
|
|
97
|
+
critical_glucose_threshold: float = 40.0,
|
|
98
|
+
critical_glucose_duration_minutes: int = 30,
|
|
99
|
+
safety_config: Optional[SafetyConfig] = None,
|
|
100
|
+
predictor: Optional[object] = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""
|
|
103
|
+
Initializes the simulator.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
patient_model (PatientModel): The patient model to simulate.
|
|
107
|
+
algorithm (InsulinAlgorithm): The insulin delivery algorithm to test.
|
|
108
|
+
time_step (int): The duration of each simulation step in minutes.
|
|
109
|
+
seed (Optional[int]): Random seed for reproducible simulations.
|
|
110
|
+
audit_log_path (Optional[str]): If provided, path to write a detailed JSON audit log.
|
|
111
|
+
"""
|
|
112
|
+
self.patient_model = patient_model
|
|
113
|
+
self.algorithm = algorithm
|
|
114
|
+
self.time_step = time_step
|
|
115
|
+
self.simulation_data: List[Any] = [] # To store results
|
|
116
|
+
self.stress_events: List[StressEvent] = []
|
|
117
|
+
self.seed = seed
|
|
118
|
+
if self.seed is not None:
|
|
119
|
+
np.random.seed(self.seed) # Set numpy seed for reproducibility
|
|
120
|
+
# Potentially set other seeds here if other random modules are used (e.g., random.seed(self.seed))
|
|
121
|
+
self.safety_config = safety_config or SafetyConfig()
|
|
122
|
+
self.supervisor = IndependentSupervisor(safety_config=self.safety_config)
|
|
123
|
+
self.input_validator = InputValidator(safety_config=self.safety_config)
|
|
124
|
+
self.sensor_model = sensor_model or SensorModel(seed=seed)
|
|
125
|
+
self.pump_model = pump_model or PumpModel(seed=seed)
|
|
126
|
+
self.predictor = predictor
|
|
127
|
+
self._predictor_history: List[Dict[str, float]] = []
|
|
128
|
+
self._predictor_feature_columns: List[str] = [
|
|
129
|
+
"glucose_actual_mgdl",
|
|
130
|
+
"patient_iob_units",
|
|
131
|
+
"patient_cob_grams",
|
|
132
|
+
"effective_isf",
|
|
133
|
+
"effective_icr",
|
|
134
|
+
"effective_basal_rate_u_per_hr",
|
|
135
|
+
"glucose_trend_mgdl_min",
|
|
136
|
+
]
|
|
137
|
+
self._predictor_history_steps = max(int(240 / float(self.time_step)), 1)
|
|
138
|
+
self._predictor_horizon_steps = max(
|
|
139
|
+
int(self.safety_config.predicted_hypoglycemia_horizon_minutes / float(self.time_step)),
|
|
140
|
+
1,
|
|
141
|
+
)
|
|
142
|
+
self._init_predictor_settings()
|
|
143
|
+
self.on_step = on_step
|
|
144
|
+
self.on_safety_event = on_safety_event
|
|
145
|
+
self.meal_queue: List[Dict[str, Any]] = [] # Initialize meal queue for delayed absorption
|
|
146
|
+
self.audit_log_path = audit_log_path
|
|
147
|
+
self.enable_profiling = enable_profiling
|
|
148
|
+
if self.safety_config is not None:
|
|
149
|
+
critical_glucose_threshold = self.safety_config.critical_glucose_threshold
|
|
150
|
+
critical_glucose_duration_minutes = self.safety_config.critical_glucose_duration_minutes
|
|
151
|
+
self.critical_glucose_threshold = critical_glucose_threshold
|
|
152
|
+
self.critical_glucose_duration_minutes = critical_glucose_duration_minutes
|
|
153
|
+
self._critical_low_minutes = 0
|
|
154
|
+
self._current_time = 0
|
|
155
|
+
self._resume_state = False
|
|
156
|
+
self._termination_info: Optional[Dict[str, Any]] = None
|
|
157
|
+
self._ratio_overrides: List[Dict[str, Any]] = []
|
|
158
|
+
self._base_ratio_state: Optional[Dict[str, float]] = None
|
|
159
|
+
self._previous_glucose_for_trend: Optional[float] = None
|
|
160
|
+
self._profiling_samples: Dict[str, List[float]] = {
|
|
161
|
+
"algorithm_latency_ms": [],
|
|
162
|
+
"supervisor_latency_ms": [],
|
|
163
|
+
"step_latency_ms": [],
|
|
164
|
+
}
|
|
165
|
+
if self.audit_log_path:
|
|
166
|
+
# Clear the log file at the beginning of a simulation run
|
|
167
|
+
try:
|
|
168
|
+
with open(self.audit_log_path, 'w') as f:
|
|
169
|
+
f.write("") # Overwrite the file
|
|
170
|
+
except IOError as e:
|
|
171
|
+
logger.warning("Could not clear audit log file at %s. Error: %s", self.audit_log_path, e)
|
|
172
|
+
|
|
173
|
+
def _init_predictor_settings(self) -> None:
|
|
174
|
+
if self.predictor is None:
|
|
175
|
+
return
|
|
176
|
+
try:
|
|
177
|
+
config = getattr(self.predictor, "config", None)
|
|
178
|
+
if isinstance(config, dict):
|
|
179
|
+
feature_columns = config.get("feature_columns")
|
|
180
|
+
history_steps = config.get("history_steps")
|
|
181
|
+
horizon_steps = config.get("horizon_steps")
|
|
182
|
+
else:
|
|
183
|
+
feature_columns = getattr(self.predictor, "feature_columns", None)
|
|
184
|
+
history_steps = getattr(self.predictor, "history_steps", None)
|
|
185
|
+
horizon_steps = getattr(self.predictor, "horizon_steps", None)
|
|
186
|
+
|
|
187
|
+
if isinstance(feature_columns, list) and feature_columns:
|
|
188
|
+
self._predictor_feature_columns = [str(col) for col in feature_columns]
|
|
189
|
+
if isinstance(history_steps, int) and history_steps > 0:
|
|
190
|
+
self._predictor_history_steps = history_steps
|
|
191
|
+
if isinstance(horizon_steps, int) and horizon_steps > 0:
|
|
192
|
+
self._predictor_horizon_steps = horizon_steps
|
|
193
|
+
except Exception:
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
def _predict_with_model(self, feature_row: Dict[str, float], fallback: float) -> Tuple[float, float]:
|
|
197
|
+
"""
|
|
198
|
+
Returns (ai_prediction, heuristic_prediction). If model fails or lacks history,
|
|
199
|
+
ai_prediction falls back to heuristic_prediction.
|
|
200
|
+
"""
|
|
201
|
+
self._predictor_history.append(feature_row)
|
|
202
|
+
if len(self._predictor_history) < self._predictor_history_steps:
|
|
203
|
+
return fallback, fallback
|
|
204
|
+
|
|
205
|
+
history_slice = self._predictor_history[-self._predictor_history_steps :]
|
|
206
|
+
try:
|
|
207
|
+
import numpy as np
|
|
208
|
+
|
|
209
|
+
X = np.zeros((1, self._predictor_history_steps, len(self._predictor_feature_columns)), dtype=float)
|
|
210
|
+
for i, row in enumerate(history_slice):
|
|
211
|
+
for j, col in enumerate(self._predictor_feature_columns):
|
|
212
|
+
X[0, i, j] = float(row.get(col, 0.0))
|
|
213
|
+
|
|
214
|
+
predict_fn = getattr(self.predictor, "predict", None)
|
|
215
|
+
if not callable(predict_fn):
|
|
216
|
+
return fallback, fallback
|
|
217
|
+
output = predict_fn(X)
|
|
218
|
+
if hasattr(output, "shape"):
|
|
219
|
+
output_arr = np.array(output, dtype=float)
|
|
220
|
+
if output_arr.ndim == 2:
|
|
221
|
+
return float(output_arr[0, -1]), fallback
|
|
222
|
+
if output_arr.ndim == 1:
|
|
223
|
+
return float(output_arr[-1]), fallback
|
|
224
|
+
if isinstance(output, (list, tuple)) and output:
|
|
225
|
+
return float(output[-1]), fallback
|
|
226
|
+
if isinstance(output, (float, int)):
|
|
227
|
+
return float(output), fallback
|
|
228
|
+
except Exception:
|
|
229
|
+
return fallback, fallback
|
|
230
|
+
return fallback, fallback
|
|
231
|
+
|
|
232
|
+
def _write_audit_log(self, data: Dict[str, Any]) -> None:
|
|
233
|
+
"""Writes a single step's audit data to the JSON log file."""
|
|
234
|
+
if self.audit_log_path:
|
|
235
|
+
try:
|
|
236
|
+
with open(self.audit_log_path, 'a') as f:
|
|
237
|
+
f.write(json.dumps(data, default=str) + '\n')
|
|
238
|
+
except IOError as e:
|
|
239
|
+
logger.warning("Could not write to audit log file at %s. Error: %s", self.audit_log_path, e)
|
|
240
|
+
|
|
241
|
+
def _emit_safety_event(self, payload: Dict[str, Any]) -> None:
|
|
242
|
+
if not self.on_safety_event:
|
|
243
|
+
return
|
|
244
|
+
try:
|
|
245
|
+
self.on_safety_event(payload)
|
|
246
|
+
except Exception as exc:
|
|
247
|
+
logger.warning("on_safety_event callback raised: %s", exc)
|
|
248
|
+
|
|
249
|
+
def _validate_glucose_fail_soft(self, glucose_value: float, current_time: float, source: str) -> float:
|
|
250
|
+
try:
|
|
251
|
+
return self.input_validator.validate_glucose(glucose_value, current_time)
|
|
252
|
+
except ValueError as exc:
|
|
253
|
+
fallback = self.input_validator.last_valid_glucose
|
|
254
|
+
if fallback is None:
|
|
255
|
+
fallback = float(
|
|
256
|
+
max(self.input_validator.min_glucose, min(glucose_value, self.input_validator.max_glucose))
|
|
257
|
+
)
|
|
258
|
+
self._write_audit_log(
|
|
259
|
+
{
|
|
260
|
+
"timestamp": current_time,
|
|
261
|
+
"event": "glucose_validation_fail_soft",
|
|
262
|
+
"source": source,
|
|
263
|
+
"input_value": glucose_value,
|
|
264
|
+
"fallback_value": fallback,
|
|
265
|
+
"error": str(exc),
|
|
266
|
+
}
|
|
267
|
+
)
|
|
268
|
+
self.input_validator.last_valid_glucose = fallback
|
|
269
|
+
self.input_validator.last_validation_time = current_time
|
|
270
|
+
return fallback
|
|
271
|
+
def _apply_ratio_overrides(self, current_time: float) -> Dict[str, float]:
|
|
272
|
+
if self._base_ratio_state is None:
|
|
273
|
+
self._base_ratio_state = self.patient_model.get_ratio_state()
|
|
274
|
+
effective = dict(self._base_ratio_state)
|
|
275
|
+
active = [
|
|
276
|
+
override
|
|
277
|
+
for override in self._ratio_overrides
|
|
278
|
+
if override["start_time"] <= current_time <= override["end_time"]
|
|
279
|
+
]
|
|
280
|
+
if active:
|
|
281
|
+
latest = active[-1]
|
|
282
|
+
if latest.get("isf") is not None:
|
|
283
|
+
effective["isf"] = latest["isf"]
|
|
284
|
+
if latest.get("icr") is not None:
|
|
285
|
+
effective["icr"] = latest["icr"]
|
|
286
|
+
if latest.get("basal_rate_u_per_hr") is not None:
|
|
287
|
+
effective["basal_rate_u_per_hr"] = latest["basal_rate_u_per_hr"]
|
|
288
|
+
if latest.get("dia_minutes") is not None:
|
|
289
|
+
effective["dia_minutes"] = latest["dia_minutes"]
|
|
290
|
+
|
|
291
|
+
self.patient_model.set_ratio_state(
|
|
292
|
+
isf=effective.get("isf"),
|
|
293
|
+
icr=effective.get("icr"),
|
|
294
|
+
basal_rate=effective.get("basal_rate_u_per_hr"),
|
|
295
|
+
dia_minutes=effective.get("dia_minutes"),
|
|
296
|
+
)
|
|
297
|
+
try:
|
|
298
|
+
if effective.get("isf") is not None:
|
|
299
|
+
self.algorithm.set_isf(float(effective["isf"]))
|
|
300
|
+
if effective.get("icr") is not None:
|
|
301
|
+
self.algorithm.set_icr(float(effective["icr"]))
|
|
302
|
+
except Exception:
|
|
303
|
+
# Algorithm may ignore dynamic ratio updates; no hard failure.
|
|
304
|
+
pass
|
|
305
|
+
return effective
|
|
306
|
+
|
|
307
|
+
def _predict_glucose(
|
|
308
|
+
self,
|
|
309
|
+
current_glucose: float,
|
|
310
|
+
trend_mgdl_min: float,
|
|
311
|
+
iob_units: float,
|
|
312
|
+
cob_grams: float,
|
|
313
|
+
isf: float,
|
|
314
|
+
icr: float,
|
|
315
|
+
dia_minutes: float,
|
|
316
|
+
horizon_minutes: int,
|
|
317
|
+
carb_absorption_minutes: float,
|
|
318
|
+
) -> float:
|
|
319
|
+
trend_component = trend_mgdl_min * horizon_minutes
|
|
320
|
+
|
|
321
|
+
insulin_component = 0.0
|
|
322
|
+
if dia_minutes > 0:
|
|
323
|
+
insulin_component = -iob_units * isf * min(horizon_minutes / dia_minutes, 1.0)
|
|
324
|
+
|
|
325
|
+
carb_component = 0.0
|
|
326
|
+
if icr > 0:
|
|
327
|
+
carb_effect_per_gram = isf / icr
|
|
328
|
+
carb_component = cob_grams * carb_effect_per_gram * min(horizon_minutes / carb_absorption_minutes, 1.0)
|
|
329
|
+
|
|
330
|
+
return current_glucose + trend_component + insulin_component + carb_component
|
|
331
|
+
|
|
332
|
+
def add_stress_event(self, event: StressEvent) -> None:
|
|
333
|
+
"""Adds a stress event to be triggered during the simulation."""
|
|
334
|
+
self.stress_events.append(event)
|
|
335
|
+
self.stress_events.sort(key=lambda e: e.start_time) # Keep events sorted by time
|
|
336
|
+
|
|
337
|
+
def run(self, duration_minutes: int) -> Tuple[pd.DataFrame, Dict[str, Any]]:
|
|
338
|
+
"""Alias for run_batch to ensure backward compatibility."""
|
|
339
|
+
return self.run_batch(duration_minutes)
|
|
340
|
+
|
|
341
|
+
def run_batch(self, duration_minutes: int) -> Tuple[pd.DataFrame, Dict[str, Any]]:
|
|
342
|
+
"""
|
|
343
|
+
Runs the entire simulation and returns the results as a single DataFrame.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
duration_minutes (int): Total simulation duration in minutes.
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
pd.DataFrame: A DataFrame containing the complete simulation results.
|
|
350
|
+
Dict[str, Any]: A dictionary containing the safety report from the supervisor.
|
|
351
|
+
"""
|
|
352
|
+
logger.info("Starting batch simulation for %d minutes...", duration_minutes)
|
|
353
|
+
all_records: List[Dict[str, Any]] = []
|
|
354
|
+
try:
|
|
355
|
+
for record in self.run_live(duration_minutes):
|
|
356
|
+
all_records.append(record)
|
|
357
|
+
except SimulationLimitError as err:
|
|
358
|
+
logger.error("Simulation terminated early: %s", err)
|
|
359
|
+
self._termination_info = {
|
|
360
|
+
"reason": str(err),
|
|
361
|
+
"current_time_minutes": err.current_time,
|
|
362
|
+
"glucose_value": err.glucose_value,
|
|
363
|
+
"duration_minutes": err.duration_minutes,
|
|
364
|
+
}
|
|
365
|
+
simulation_results_df = pd.DataFrame(all_records)
|
|
366
|
+
safety_report = self.supervisor.get_safety_report()
|
|
367
|
+
if self._termination_info:
|
|
368
|
+
safety_report["terminated_early"] = True
|
|
369
|
+
safety_report["termination_reason"] = self._termination_info
|
|
370
|
+
if self.enable_profiling:
|
|
371
|
+
safety_report["performance_report"] = self._build_performance_report()
|
|
372
|
+
logger.info("Batch simulation completed. %d records generated.", len(simulation_results_df))
|
|
373
|
+
return simulation_results_df, safety_report
|
|
374
|
+
|
|
375
|
+
def export_audit_trail(self, simulation_results_df: pd.DataFrame, output_dir: str) -> Dict[str, str]:
|
|
376
|
+
"""
|
|
377
|
+
Export audit trail as JSONL, CSV, and summary JSON.
|
|
378
|
+
"""
|
|
379
|
+
output_path = Path(output_dir)
|
|
380
|
+
output_path.mkdir(parents=True, exist_ok=True)
|
|
381
|
+
|
|
382
|
+
audit_columns = [
|
|
383
|
+
"time_minutes",
|
|
384
|
+
"glucose_actual_mgdl",
|
|
385
|
+
"glucose_to_algo_mgdl",
|
|
386
|
+
"algo_recommended_insulin_units",
|
|
387
|
+
"delivered_insulin_units",
|
|
388
|
+
"safety_reason",
|
|
389
|
+
"safety_triggered",
|
|
390
|
+
"supervisor_latency_ms",
|
|
391
|
+
"sensor_status",
|
|
392
|
+
"pump_status",
|
|
393
|
+
"pump_reason",
|
|
394
|
+
"human_intervention",
|
|
395
|
+
]
|
|
396
|
+
available_columns = [c for c in audit_columns if c in simulation_results_df.columns]
|
|
397
|
+
audit_df = simulation_results_df[available_columns].copy()
|
|
398
|
+
|
|
399
|
+
jsonl_path = output_path / "audit_trail.jsonl"
|
|
400
|
+
csv_path = output_path / "audit_trail.csv"
|
|
401
|
+
summary_path = output_path / "audit_summary.json"
|
|
402
|
+
|
|
403
|
+
audit_df.to_json(jsonl_path, orient="records", lines=True)
|
|
404
|
+
audit_df.to_csv(csv_path, index=False)
|
|
405
|
+
|
|
406
|
+
overrides = audit_df[audit_df.get("safety_triggered", False) == True] if "safety_triggered" in audit_df.columns else audit_df.iloc[0:0]
|
|
407
|
+
reasons = overrides["safety_reason"].value_counts().to_dict() if "safety_reason" in overrides.columns else {}
|
|
408
|
+
|
|
409
|
+
summary = {
|
|
410
|
+
"total_steps": int(len(audit_df)),
|
|
411
|
+
"total_overrides": int(len(overrides)),
|
|
412
|
+
"top_reasons": reasons,
|
|
413
|
+
}
|
|
414
|
+
if self._termination_info:
|
|
415
|
+
summary["terminated_early"] = True
|
|
416
|
+
summary["termination_reason"] = self._termination_info
|
|
417
|
+
with open(summary_path, "w") as f:
|
|
418
|
+
json.dump(summary, f, indent=2)
|
|
419
|
+
|
|
420
|
+
return {
|
|
421
|
+
"jsonl": str(jsonl_path),
|
|
422
|
+
"csv": str(csv_path),
|
|
423
|
+
"summary": str(summary_path),
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
def run_live(self, duration_minutes: int) -> Generator[Dict[str, Any], None, None]:
|
|
427
|
+
"""
|
|
428
|
+
Runs the simulation as a generator, yielding the record of each time step.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
duration_minutes (int): Total simulation duration in minutes.
|
|
432
|
+
|
|
433
|
+
Yields:
|
|
434
|
+
Dict[str, Any]: The data record for each simulation time step.
|
|
435
|
+
"""
|
|
436
|
+
if not self._resume_state:
|
|
437
|
+
self.patient_model.reset()
|
|
438
|
+
self.algorithm.reset()
|
|
439
|
+
self.supervisor.reset()
|
|
440
|
+
self.input_validator.reset() # Reset input validator for new run
|
|
441
|
+
self.sensor_model.reset()
|
|
442
|
+
self.pump_model.reset()
|
|
443
|
+
self.simulation_data = []
|
|
444
|
+
self.meal_queue = [] # Reset meal queue for new run
|
|
445
|
+
self._predictor_history = []
|
|
446
|
+
self._critical_low_minutes = 0
|
|
447
|
+
self._current_time = 0
|
|
448
|
+
self._termination_info = None
|
|
449
|
+
self._ratio_overrides = []
|
|
450
|
+
self._base_ratio_state = self.patient_model.get_ratio_state()
|
|
451
|
+
self._previous_glucose_for_trend = None
|
|
452
|
+
else:
|
|
453
|
+
self._resume_state = False
|
|
454
|
+
if self.enable_profiling:
|
|
455
|
+
self._profiling_samples = {
|
|
456
|
+
"algorithm_latency_ms": [],
|
|
457
|
+
"supervisor_latency_ms": [],
|
|
458
|
+
"step_latency_ms": [],
|
|
459
|
+
}
|
|
460
|
+
current_time = self._current_time
|
|
461
|
+
|
|
462
|
+
logger.debug("Starting live simulation loop.")
|
|
463
|
+
|
|
464
|
+
while current_time <= duration_minutes:
|
|
465
|
+
self._current_time = current_time
|
|
466
|
+
if self.enable_profiling:
|
|
467
|
+
step_start_time = time.perf_counter()
|
|
468
|
+
patient_carb_intake_this_step = 0.0
|
|
469
|
+
algo_carb_intake_this_step = 0.0
|
|
470
|
+
actual_glucose_reading = self.patient_model.get_current_glucose()
|
|
471
|
+
# Validate the raw sensor reading
|
|
472
|
+
actual_glucose_reading = self._validate_glucose_fail_soft(
|
|
473
|
+
actual_glucose_reading, float(current_time), "sensor_raw"
|
|
474
|
+
)
|
|
475
|
+
sensor_reading = self.sensor_model.read(actual_glucose_reading, float(current_time))
|
|
476
|
+
glucose_to_algorithm = sensor_reading.value
|
|
477
|
+
|
|
478
|
+
# Process newly triggered stress events
|
|
479
|
+
events_to_process_now = [] # Events that affect algorithm input immediately (e.g., reported carbs, sensor error)
|
|
480
|
+
events_to_queue_for_patient = [] # Meal events that affect patient after delay
|
|
481
|
+
|
|
482
|
+
for event in self.stress_events:
|
|
483
|
+
if current_time == event.start_time:
|
|
484
|
+
logger.info("[%d min] Triggering stress event: %s", current_time, event)
|
|
485
|
+
if event.event_type == 'meal':
|
|
486
|
+
events_to_queue_for_patient.append(event)
|
|
487
|
+
# Algorithm gets carb info based on reported_value if available, otherwise actual value
|
|
488
|
+
algo_carb_intake_this_step += event.reported_value if event.reported_value is not None else event.value
|
|
489
|
+
elif event.event_type == 'missed_meal':
|
|
490
|
+
# Patient consumes carbs immediately, algorithm not aware
|
|
491
|
+
patient_carb_intake_this_step += event.value
|
|
492
|
+
algo_carb_intake_this_step = 0.0 # Algorithm gets 0 carbs
|
|
493
|
+
elif event.event_type == 'sensor_error':
|
|
494
|
+
glucose_to_algorithm = event.value
|
|
495
|
+
elif event.event_type == 'exercise':
|
|
496
|
+
self.patient_model.start_exercise(event.value)
|
|
497
|
+
# Schedule the end of the exercise
|
|
498
|
+
end_event = StressEvent(start_time=current_time + event.duration, event_type='exercise_end')
|
|
499
|
+
self.add_stress_event(end_event)
|
|
500
|
+
elif event.event_type == 'exercise_end':
|
|
501
|
+
self.patient_model.stop_exercise()
|
|
502
|
+
elif event.event_type == 'ratio_change':
|
|
503
|
+
duration = event.duration if event.duration > 0 else float("inf")
|
|
504
|
+
self._ratio_overrides.append(
|
|
505
|
+
{
|
|
506
|
+
"start_time": current_time,
|
|
507
|
+
"end_time": current_time + duration,
|
|
508
|
+
"isf": event.isf,
|
|
509
|
+
"icr": event.icr,
|
|
510
|
+
"basal_rate_u_per_hr": event.basal_rate,
|
|
511
|
+
"dia_minutes": event.dia_minutes,
|
|
512
|
+
}
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
events_to_process_now.append(event) # Mark for removal from stress_events list
|
|
516
|
+
|
|
517
|
+
# Remove processed stress events that don't need to be queued for patient absorption
|
|
518
|
+
self.stress_events = [e for e in self.stress_events if e not in events_to_process_now]
|
|
519
|
+
|
|
520
|
+
# Add newly triggered meal events to the meal queue for delayed absorption
|
|
521
|
+
for event in events_to_queue_for_patient:
|
|
522
|
+
self.meal_queue.append({
|
|
523
|
+
'event': event,
|
|
524
|
+
'absorption_start_time': current_time + event.absorption_delay_minutes
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
# Process meals from the queue that are now ready for absorption
|
|
528
|
+
meals_absorbed_this_step = []
|
|
529
|
+
for meal_entry in self.meal_queue:
|
|
530
|
+
if current_time >= meal_entry['absorption_start_time']:
|
|
531
|
+
patient_carb_intake_this_step += meal_entry['event'].value # Actual carbs for the patient model
|
|
532
|
+
meals_absorbed_this_step.append(meal_entry)
|
|
533
|
+
|
|
534
|
+
# Remove absorbed meals from the queue
|
|
535
|
+
self.meal_queue = [meal_entry for meal_entry in self.meal_queue if meal_entry not in meals_absorbed_this_step]
|
|
536
|
+
|
|
537
|
+
# Validate glucose passed to algorithm (could be modified by stress events)
|
|
538
|
+
# This ensures even sensor error stress events are validated
|
|
539
|
+
glucose_to_algorithm = self._validate_glucose_fail_soft(
|
|
540
|
+
glucose_to_algorithm, float(current_time), "sensor_to_algo"
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
# Apply dynamic ratio overrides (ISF/ICR/DIA/Basal) if any
|
|
544
|
+
ratio_state = self._apply_ratio_overrides(float(current_time))
|
|
545
|
+
effective_isf = float(ratio_state.get("isf", self.patient_model.insulin_sensitivity))
|
|
546
|
+
effective_icr = float(ratio_state.get("icr", self.patient_model.carb_factor))
|
|
547
|
+
effective_dia = float(ratio_state.get("dia_minutes", self.patient_model.insulin_action_duration))
|
|
548
|
+
effective_basal = float(ratio_state.get("basal_rate_u_per_hr", self.patient_model.basal_insulin_rate))
|
|
549
|
+
|
|
550
|
+
# Glucose trend (mg/dL per minute) based on sensor value
|
|
551
|
+
glucose_trend = 0.0
|
|
552
|
+
if self._previous_glucose_for_trend is not None:
|
|
553
|
+
glucose_trend = (glucose_to_algorithm - self._previous_glucose_for_trend) / float(self.time_step)
|
|
554
|
+
self._previous_glucose_for_trend = glucose_to_algorithm
|
|
555
|
+
|
|
556
|
+
predicted_glucose_heuristic = self._predict_glucose(
|
|
557
|
+
current_glucose=glucose_to_algorithm,
|
|
558
|
+
trend_mgdl_min=glucose_trend,
|
|
559
|
+
iob_units=self.patient_model.insulin_on_board,
|
|
560
|
+
cob_grams=self.patient_model.carbs_on_board,
|
|
561
|
+
isf=effective_isf,
|
|
562
|
+
icr=effective_icr,
|
|
563
|
+
dia_minutes=effective_dia,
|
|
564
|
+
horizon_minutes=self.safety_config.predicted_hypoglycemia_horizon_minutes,
|
|
565
|
+
carb_absorption_minutes=self.patient_model.carb_absorption_duration_minutes,
|
|
566
|
+
)
|
|
567
|
+
predicted_glucose_30 = predicted_glucose_heuristic
|
|
568
|
+
predicted_glucose_ai = None
|
|
569
|
+
if self.predictor is not None:
|
|
570
|
+
feature_row = {
|
|
571
|
+
"glucose_actual_mgdl": float(glucose_to_algorithm),
|
|
572
|
+
"patient_iob_units": float(self.patient_model.insulin_on_board),
|
|
573
|
+
"patient_cob_grams": float(self.patient_model.carbs_on_board),
|
|
574
|
+
"effective_isf": float(effective_isf),
|
|
575
|
+
"effective_icr": float(effective_icr),
|
|
576
|
+
"effective_basal_rate_u_per_hr": float(effective_basal),
|
|
577
|
+
"glucose_trend_mgdl_min": float(glucose_trend),
|
|
578
|
+
}
|
|
579
|
+
predicted_glucose_ai, _ = self._predict_with_model(feature_row, predicted_glucose_heuristic)
|
|
580
|
+
predicted_glucose_30 = float(predicted_glucose_ai)
|
|
581
|
+
|
|
582
|
+
# --- Algorithm Input ---
|
|
583
|
+
algo_input = AlgorithmInput(
|
|
584
|
+
current_glucose=glucose_to_algorithm,
|
|
585
|
+
time_step=self.time_step,
|
|
586
|
+
insulin_on_board=self.patient_model.insulin_on_board,
|
|
587
|
+
carb_intake=algo_carb_intake_this_step, # Use algo's perspective of carbs
|
|
588
|
+
carbs_on_board=self.patient_model.carbs_on_board,
|
|
589
|
+
isf=effective_isf,
|
|
590
|
+
icr=effective_icr,
|
|
591
|
+
dia_minutes=effective_dia,
|
|
592
|
+
basal_rate_u_per_hr=effective_basal,
|
|
593
|
+
glucose_trend_mgdl_min=glucose_trend,
|
|
594
|
+
predicted_glucose_30min=predicted_glucose_30,
|
|
595
|
+
patient_state=self.patient_model.get_patient_state(),
|
|
596
|
+
current_time=float(current_time) # Pass current_time
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
# --- Algorithm Calculation ---
|
|
600
|
+
if self.enable_profiling:
|
|
601
|
+
algo_start_time = time.perf_counter()
|
|
602
|
+
insulin_output = self.algorithm.predict_insulin(algo_input)
|
|
603
|
+
algorithm_latency_ms = (time.perf_counter() - algo_start_time) * 1000
|
|
604
|
+
self._profiling_samples["algorithm_latency_ms"].append(algorithm_latency_ms)
|
|
605
|
+
else:
|
|
606
|
+
insulin_output = self.algorithm.predict_insulin(algo_input)
|
|
607
|
+
algo_recommended_insulin = insulin_output.get("total_insulin_delivered", 0.0)
|
|
608
|
+
# Validate the algorithm's output to prevent negative insulin requests
|
|
609
|
+
algo_recommended_insulin = self.input_validator.validate_insulin(algo_recommended_insulin)
|
|
610
|
+
|
|
611
|
+
# Get the why_log from the algorithm
|
|
612
|
+
algorithm_why_log = self.algorithm.get_why_log()
|
|
613
|
+
|
|
614
|
+
# --- Safety Supervision ---
|
|
615
|
+
start_perf_time = time.perf_counter()
|
|
616
|
+
proposed_basal_units = float(insulin_output.get("basal_insulin", 0.0))
|
|
617
|
+
basal_limit_u_per_hr = effective_basal * self.safety_config.max_basal_multiplier
|
|
618
|
+
basal_limit_units = (basal_limit_u_per_hr / 60.0) * float(self.time_step)
|
|
619
|
+
safety_result = self.supervisor.evaluate_safety(
|
|
620
|
+
current_glucose=glucose_to_algorithm,
|
|
621
|
+
proposed_insulin=algo_recommended_insulin,
|
|
622
|
+
current_time=float(current_time),
|
|
623
|
+
current_iob=self.patient_model.insulin_on_board,
|
|
624
|
+
predicted_glucose_30min=predicted_glucose_30,
|
|
625
|
+
basal_insulin_units=proposed_basal_units,
|
|
626
|
+
basal_limit_units=basal_limit_units,
|
|
627
|
+
)
|
|
628
|
+
supervisor_latency_ms = (time.perf_counter() - start_perf_time) * 1000
|
|
629
|
+
if self.enable_profiling:
|
|
630
|
+
self._profiling_samples["supervisor_latency_ms"].append(supervisor_latency_ms)
|
|
631
|
+
|
|
632
|
+
delivered_insulin = safety_result["approved_insulin"]
|
|
633
|
+
overridden = safety_result["insulin_reduction"] > 0
|
|
634
|
+
safety_level = safety_result["safety_level"]
|
|
635
|
+
safety_actions = "; ".join(safety_result["actions_taken"])
|
|
636
|
+
safety_reason = safety_result.get("safety_reason", "")
|
|
637
|
+
safety_triggered = safety_result.get("safety_triggered", False)
|
|
638
|
+
|
|
639
|
+
pump_delivery = self.pump_model.deliver(delivered_insulin, self.time_step)
|
|
640
|
+
delivered_insulin = pump_delivery.delivered_units
|
|
641
|
+
|
|
642
|
+
if safety_triggered:
|
|
643
|
+
self._emit_safety_event(
|
|
644
|
+
{
|
|
645
|
+
"source": "supervisor",
|
|
646
|
+
"time_minutes": current_time,
|
|
647
|
+
"glucose_mgdl": glucose_to_algorithm,
|
|
648
|
+
"ai_requested_units": algo_recommended_insulin,
|
|
649
|
+
"supervisor_approved_units": safety_result["approved_insulin"],
|
|
650
|
+
"pump_delivered_units": delivered_insulin,
|
|
651
|
+
"safety_level": safety_level.value,
|
|
652
|
+
"safety_reason": safety_reason,
|
|
653
|
+
"safety_actions": safety_actions,
|
|
654
|
+
"predicted_glucose_30min": predicted_glucose_30,
|
|
655
|
+
"iob_units": self.patient_model.insulin_on_board,
|
|
656
|
+
"cob_grams": self.patient_model.carbs_on_board,
|
|
657
|
+
}
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
# --- Human-in-the-loop callback ---
|
|
661
|
+
human_intervention = None
|
|
662
|
+
if self.on_step:
|
|
663
|
+
context = {
|
|
664
|
+
"time_minutes": current_time,
|
|
665
|
+
"glucose_actual_mgdl": actual_glucose_reading,
|
|
666
|
+
"glucose_to_algo_mgdl": glucose_to_algorithm,
|
|
667
|
+
"algo_recommended_insulin_units": algo_recommended_insulin,
|
|
668
|
+
"delivered_insulin_units": delivered_insulin,
|
|
669
|
+
"patient_iob_units": self.patient_model.insulin_on_board,
|
|
670
|
+
"patient_cob_grams": self.patient_model.carbs_on_board,
|
|
671
|
+
"safety_reason": safety_reason,
|
|
672
|
+
"safety_triggered": safety_triggered,
|
|
673
|
+
"sensor_status": sensor_reading.status,
|
|
674
|
+
"pump_status": pump_delivery.status,
|
|
675
|
+
}
|
|
676
|
+
human_intervention = self.on_step(context) or None
|
|
677
|
+
if isinstance(human_intervention, dict):
|
|
678
|
+
if "additional_carbs" in human_intervention:
|
|
679
|
+
patient_carb_intake_this_step += float(human_intervention["additional_carbs"])
|
|
680
|
+
if "override_delivered_insulin" in human_intervention:
|
|
681
|
+
requested_override = float(human_intervention["override_delivered_insulin"])
|
|
682
|
+
if requested_override < 0.0:
|
|
683
|
+
requested_override = 0.0
|
|
684
|
+
# Remove any same-timestamp dose to avoid double-counting in supervisor history
|
|
685
|
+
self.supervisor.dose_history = [
|
|
686
|
+
(t, d) for (t, d) in self.supervisor.dose_history if t != float(current_time)
|
|
687
|
+
]
|
|
688
|
+
override_result = self.supervisor.evaluate_safety(
|
|
689
|
+
current_glucose=glucose_to_algorithm,
|
|
690
|
+
proposed_insulin=requested_override,
|
|
691
|
+
current_time=float(current_time),
|
|
692
|
+
current_iob=self.patient_model.insulin_on_board,
|
|
693
|
+
predicted_glucose_30min=predicted_glucose_30,
|
|
694
|
+
basal_insulin_units=proposed_basal_units,
|
|
695
|
+
basal_limit_units=basal_limit_units,
|
|
696
|
+
)
|
|
697
|
+
delivered_override = override_result["approved_insulin"]
|
|
698
|
+
pump_delivery = self.pump_model.deliver(delivered_override, self.time_step)
|
|
699
|
+
delivered_insulin = pump_delivery.delivered_units
|
|
700
|
+
safety_level = override_result["safety_level"]
|
|
701
|
+
safety_actions = "; ".join(override_result["actions_taken"])
|
|
702
|
+
safety_reason = override_result.get("safety_reason", safety_reason)
|
|
703
|
+
safety_triggered = override_result.get("safety_triggered", safety_triggered)
|
|
704
|
+
if override_result.get("safety_triggered", False):
|
|
705
|
+
self._emit_safety_event(
|
|
706
|
+
{
|
|
707
|
+
"source": "human_override",
|
|
708
|
+
"time_minutes": current_time,
|
|
709
|
+
"glucose_mgdl": glucose_to_algorithm,
|
|
710
|
+
"ai_requested_units": algo_recommended_insulin,
|
|
711
|
+
"human_requested_units": requested_override,
|
|
712
|
+
"supervisor_approved_units": override_result["approved_insulin"],
|
|
713
|
+
"pump_delivered_units": delivered_insulin,
|
|
714
|
+
"safety_level": safety_level.value,
|
|
715
|
+
"safety_reason": safety_reason,
|
|
716
|
+
"safety_actions": safety_actions,
|
|
717
|
+
"predicted_glucose_30min": predicted_glucose_30,
|
|
718
|
+
"iob_units": self.patient_model.insulin_on_board,
|
|
719
|
+
"cob_grams": self.patient_model.carbs_on_board,
|
|
720
|
+
}
|
|
721
|
+
)
|
|
722
|
+
self._write_audit_log(
|
|
723
|
+
{
|
|
724
|
+
"timestamp": current_time,
|
|
725
|
+
"event": "human_override_revalidated",
|
|
726
|
+
"requested_override": requested_override,
|
|
727
|
+
"approved_override": delivered_override,
|
|
728
|
+
"final_dose": delivered_insulin,
|
|
729
|
+
"safety_reason": safety_reason,
|
|
730
|
+
}
|
|
731
|
+
)
|
|
732
|
+
if human_intervention.get("stop_simulation"):
|
|
733
|
+
logger.warning("Simulation stopped by human-in-the-loop callback.")
|
|
734
|
+
|
|
735
|
+
# --- Audit Logging ---
|
|
736
|
+
self._write_audit_log({
|
|
737
|
+
"timestamp": current_time,
|
|
738
|
+
"cgm": actual_glucose_reading,
|
|
739
|
+
"ai_suggestion": algo_recommended_insulin,
|
|
740
|
+
"supervisor_override": overridden,
|
|
741
|
+
"final_dose": delivered_insulin,
|
|
742
|
+
"safety_reason": safety_reason,
|
|
743
|
+
"hidden_state_summary": self.algorithm.get_state()
|
|
744
|
+
})
|
|
745
|
+
|
|
746
|
+
# --- Patient Model Update ---
|
|
747
|
+
self.patient_model.update(
|
|
748
|
+
time_step=self.time_step,
|
|
749
|
+
delivered_insulin=delivered_insulin,
|
|
750
|
+
carb_intake=patient_carb_intake_this_step, # Use actual carbs for patient
|
|
751
|
+
current_time=float(current_time),
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
# --- Record Data ---
|
|
755
|
+
record = {
|
|
756
|
+
"time_minutes": current_time,
|
|
757
|
+
"glucose_actual_mgdl": actual_glucose_reading,
|
|
758
|
+
"glucose_to_algo_mgdl": glucose_to_algorithm,
|
|
759
|
+
"glucose_trend_mgdl_min": glucose_trend,
|
|
760
|
+
"predicted_glucose_30min": predicted_glucose_30,
|
|
761
|
+
"predicted_glucose_heuristic_30min": predicted_glucose_heuristic,
|
|
762
|
+
"predicted_glucose_ai_30min": predicted_glucose_ai,
|
|
763
|
+
"delivered_insulin_units": delivered_insulin,
|
|
764
|
+
"algo_recommended_insulin_units": algo_recommended_insulin,
|
|
765
|
+
"sensor_status": sensor_reading.status,
|
|
766
|
+
"pump_status": pump_delivery.status,
|
|
767
|
+
"pump_reason": pump_delivery.reason,
|
|
768
|
+
"basal_insulin_units": insulin_output.get("basal_insulin", 0.0),
|
|
769
|
+
"bolus_insulin_units": insulin_output.get("bolus_insulin", 0.0) + insulin_output.get("meal_bolus", 0.0), # Combine for simplicity
|
|
770
|
+
"correction_bolus_units": insulin_output.get("correction_bolus", 0.0),
|
|
771
|
+
"carb_intake_grams": patient_carb_intake_this_step,
|
|
772
|
+
"patient_iob_units": self.patient_model.insulin_on_board,
|
|
773
|
+
"patient_cob_grams": self.patient_model.carbs_on_board,
|
|
774
|
+
"effective_isf": effective_isf,
|
|
775
|
+
"effective_icr": effective_icr,
|
|
776
|
+
"effective_basal_rate_u_per_hr": effective_basal,
|
|
777
|
+
"effective_dia_minutes": effective_dia,
|
|
778
|
+
"uncertainty": insulin_output.get("uncertainty", 0.0),
|
|
779
|
+
"fallback_triggered": insulin_output.get("fallback_triggered", False),
|
|
780
|
+
"safety_level": safety_level.value,
|
|
781
|
+
"safety_actions": safety_actions,
|
|
782
|
+
"safety_reason": safety_reason,
|
|
783
|
+
"safety_triggered": safety_triggered,
|
|
784
|
+
"supervisor_latency_ms": supervisor_latency_ms,
|
|
785
|
+
"human_intervention": bool(human_intervention),
|
|
786
|
+
"human_intervention_note": human_intervention.get("note") if isinstance(human_intervention, dict) else "",
|
|
787
|
+
"algorithm_why_log": [entry.to_dict() for entry in algorithm_why_log], # Convert WhyLogEntry to dict for serialization
|
|
788
|
+
**{f"algo_state_{k}": v for k, v in self.algorithm.get_state().items()} # Include algorithm internal state
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
if self.enable_profiling:
|
|
792
|
+
step_latency_ms = (time.perf_counter() - step_start_time) * 1000
|
|
793
|
+
self._profiling_samples["step_latency_ms"].append(step_latency_ms)
|
|
794
|
+
record["algorithm_latency_ms"] = algorithm_latency_ms
|
|
795
|
+
record["step_latency_ms"] = step_latency_ms
|
|
796
|
+
|
|
797
|
+
yield record
|
|
798
|
+
|
|
799
|
+
if isinstance(human_intervention, dict) and human_intervention.get("stop_simulation"):
|
|
800
|
+
break
|
|
801
|
+
|
|
802
|
+
# Critical failure stop: sustained severe hypoglycemia
|
|
803
|
+
if actual_glucose_reading < self.critical_glucose_threshold:
|
|
804
|
+
self._critical_low_minutes += self.time_step
|
|
805
|
+
if self._critical_low_minutes >= self.critical_glucose_duration_minutes:
|
|
806
|
+
message = (
|
|
807
|
+
f"Critical failure: glucose < {self.critical_glucose_threshold:.1f} mg/dL "
|
|
808
|
+
f"for {self._critical_low_minutes} minutes."
|
|
809
|
+
)
|
|
810
|
+
raise SimulationLimitError(
|
|
811
|
+
message=message,
|
|
812
|
+
current_time=current_time,
|
|
813
|
+
glucose_value=actual_glucose_reading,
|
|
814
|
+
duration_minutes=self._critical_low_minutes,
|
|
815
|
+
)
|
|
816
|
+
else:
|
|
817
|
+
self._critical_low_minutes = 0
|
|
818
|
+
|
|
819
|
+
current_time += self.time_step
|
|
820
|
+
|
|
821
|
+
def save_state(self) -> Dict[str, Any]:
|
|
822
|
+
"""Serialize simulator state for time-travel debugging."""
|
|
823
|
+
return {
|
|
824
|
+
"current_time": self._current_time,
|
|
825
|
+
"patient_state": self.patient_model.get_state(),
|
|
826
|
+
"algorithm_state": self.algorithm.get_state(),
|
|
827
|
+
"supervisor_state": self.supervisor.get_state(),
|
|
828
|
+
"input_validator_state": self.input_validator.get_state(),
|
|
829
|
+
"sensor_state": self.sensor_model.get_state(),
|
|
830
|
+
"pump_state": self.pump_model.get_state(),
|
|
831
|
+
"meal_queue": self.meal_queue,
|
|
832
|
+
"stress_events": [event.__dict__ for event in self.stress_events],
|
|
833
|
+
"critical_low_minutes": self._critical_low_minutes,
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
def load_state(self, state: Dict[str, Any]) -> None:
|
|
837
|
+
"""Restore simulator state from a previous save."""
|
|
838
|
+
self._current_time = state.get("current_time", 0)
|
|
839
|
+
self.patient_model.set_state(state.get("patient_state", {}))
|
|
840
|
+
self.algorithm.set_state(state.get("algorithm_state", {}))
|
|
841
|
+
self.supervisor.set_state(state.get("supervisor_state", {}))
|
|
842
|
+
self.input_validator.set_state(state.get("input_validator_state", {}))
|
|
843
|
+
self.sensor_model.set_state(state.get("sensor_state", {}))
|
|
844
|
+
self.pump_model.set_state(state.get("pump_state", {}))
|
|
845
|
+
self.meal_queue = state.get("meal_queue", [])
|
|
846
|
+
self.stress_events = [StressEvent(**payload) for payload in state.get("stress_events", [])]
|
|
847
|
+
self._critical_low_minutes = state.get("critical_low_minutes", 0)
|
|
848
|
+
self._resume_state = True
|
|
849
|
+
|
|
850
|
+
def _build_performance_report(self) -> Dict[str, Any]:
|
|
851
|
+
def summarize(samples: List[float]) -> Dict[str, float]:
|
|
852
|
+
if not samples:
|
|
853
|
+
return {}
|
|
854
|
+
values = np.array(samples, dtype=float)
|
|
855
|
+
return {
|
|
856
|
+
"mean_ms": float(np.mean(values)),
|
|
857
|
+
"median_ms": float(np.median(values)),
|
|
858
|
+
"p95_ms": float(np.percentile(values, 95)),
|
|
859
|
+
"p99_ms": float(np.percentile(values, 99)),
|
|
860
|
+
"min_ms": float(np.min(values)),
|
|
861
|
+
"max_ms": float(np.max(values)),
|
|
862
|
+
"std_ms": float(np.std(values)),
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
return {
|
|
866
|
+
"algorithm_latency_ms": summarize(self._profiling_samples["algorithm_latency_ms"]),
|
|
867
|
+
"supervisor_latency_ms": summarize(self._profiling_samples["supervisor_latency_ms"]),
|
|
868
|
+
"step_latency_ms": summarize(self._profiling_samples["step_latency_ms"]),
|
|
869
|
+
"sample_counts": {
|
|
870
|
+
"algorithm_latency_ms": len(self._profiling_samples["algorithm_latency_ms"]),
|
|
871
|
+
"supervisor_latency_ms": len(self._profiling_samples["supervisor_latency_ms"]),
|
|
872
|
+
"step_latency_ms": len(self._profiling_samples["step_latency_ms"]),
|
|
873
|
+
},
|
|
874
|
+
}
|