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.
Files changed (118) hide show
  1. iints/__init__.py +183 -0
  2. iints/analysis/__init__.py +12 -0
  3. iints/analysis/algorithm_xray.py +387 -0
  4. iints/analysis/baseline.py +92 -0
  5. iints/analysis/clinical_benchmark.py +198 -0
  6. iints/analysis/clinical_metrics.py +551 -0
  7. iints/analysis/clinical_tir_analyzer.py +136 -0
  8. iints/analysis/diabetes_metrics.py +43 -0
  9. iints/analysis/edge_efficiency.py +33 -0
  10. iints/analysis/edge_performance_monitor.py +315 -0
  11. iints/analysis/explainability.py +94 -0
  12. iints/analysis/explainable_ai.py +232 -0
  13. iints/analysis/hardware_benchmark.py +221 -0
  14. iints/analysis/metrics.py +117 -0
  15. iints/analysis/population_report.py +188 -0
  16. iints/analysis/reporting.py +345 -0
  17. iints/analysis/safety_index.py +311 -0
  18. iints/analysis/sensor_filtering.py +54 -0
  19. iints/analysis/validator.py +273 -0
  20. iints/api/__init__.py +0 -0
  21. iints/api/base_algorithm.py +307 -0
  22. iints/api/registry.py +103 -0
  23. iints/api/template_algorithm.py +195 -0
  24. iints/assets/iints_logo.png +0 -0
  25. iints/cli/__init__.py +0 -0
  26. iints/cli/cli.py +2598 -0
  27. iints/core/__init__.py +1 -0
  28. iints/core/algorithms/__init__.py +0 -0
  29. iints/core/algorithms/battle_runner.py +138 -0
  30. iints/core/algorithms/correction_bolus.py +95 -0
  31. iints/core/algorithms/discovery.py +92 -0
  32. iints/core/algorithms/fixed_basal_bolus.py +58 -0
  33. iints/core/algorithms/hybrid_algorithm.py +92 -0
  34. iints/core/algorithms/lstm_algorithm.py +138 -0
  35. iints/core/algorithms/mock_algorithms.py +162 -0
  36. iints/core/algorithms/pid_controller.py +88 -0
  37. iints/core/algorithms/standard_pump_algo.py +64 -0
  38. iints/core/device.py +0 -0
  39. iints/core/device_manager.py +64 -0
  40. iints/core/devices/__init__.py +3 -0
  41. iints/core/devices/models.py +160 -0
  42. iints/core/patient/__init__.py +9 -0
  43. iints/core/patient/bergman_model.py +341 -0
  44. iints/core/patient/models.py +285 -0
  45. iints/core/patient/patient_factory.py +117 -0
  46. iints/core/patient/profile.py +41 -0
  47. iints/core/safety/__init__.py +12 -0
  48. iints/core/safety/config.py +37 -0
  49. iints/core/safety/input_validator.py +95 -0
  50. iints/core/safety/supervisor.py +39 -0
  51. iints/core/simulation/__init__.py +0 -0
  52. iints/core/simulation/scenario_parser.py +61 -0
  53. iints/core/simulator.py +874 -0
  54. iints/core/supervisor.py +367 -0
  55. iints/data/__init__.py +53 -0
  56. iints/data/adapter.py +142 -0
  57. iints/data/column_mapper.py +398 -0
  58. iints/data/datasets.json +132 -0
  59. iints/data/demo/__init__.py +1 -0
  60. iints/data/demo/demo_cgm.csv +289 -0
  61. iints/data/importer.py +275 -0
  62. iints/data/ingestor.py +162 -0
  63. iints/data/nightscout.py +128 -0
  64. iints/data/quality_checker.py +550 -0
  65. iints/data/registry.py +166 -0
  66. iints/data/tidepool.py +38 -0
  67. iints/data/universal_parser.py +813 -0
  68. iints/data/virtual_patients/clinic_safe_baseline.yaml +9 -0
  69. iints/data/virtual_patients/clinic_safe_hyper_challenge.yaml +9 -0
  70. iints/data/virtual_patients/clinic_safe_hypo_prone.yaml +9 -0
  71. iints/data/virtual_patients/clinic_safe_midnight.yaml +9 -0
  72. iints/data/virtual_patients/clinic_safe_pizza.yaml +9 -0
  73. iints/data/virtual_patients/clinic_safe_stress_meal.yaml +9 -0
  74. iints/data/virtual_patients/default_patient.yaml +11 -0
  75. iints/data/virtual_patients/patient_559_config.yaml +11 -0
  76. iints/emulation/__init__.py +80 -0
  77. iints/emulation/legacy_base.py +414 -0
  78. iints/emulation/medtronic_780g.py +337 -0
  79. iints/emulation/omnipod_5.py +367 -0
  80. iints/emulation/tandem_controliq.py +393 -0
  81. iints/highlevel.py +451 -0
  82. iints/learning/__init__.py +3 -0
  83. iints/learning/autonomous_optimizer.py +194 -0
  84. iints/learning/learning_system.py +122 -0
  85. iints/metrics.py +34 -0
  86. iints/population/__init__.py +11 -0
  87. iints/population/generator.py +131 -0
  88. iints/population/runner.py +327 -0
  89. iints/presets/__init__.py +28 -0
  90. iints/presets/presets.json +114 -0
  91. iints/research/__init__.py +30 -0
  92. iints/research/config.py +68 -0
  93. iints/research/dataset.py +319 -0
  94. iints/research/losses.py +73 -0
  95. iints/research/predictor.py +329 -0
  96. iints/scenarios/__init__.py +3 -0
  97. iints/scenarios/generator.py +92 -0
  98. iints/templates/__init__.py +0 -0
  99. iints/templates/default_algorithm.py +91 -0
  100. iints/templates/scenarios/__init__.py +0 -0
  101. iints/templates/scenarios/chaos_insulin_stacking.json +29 -0
  102. iints/templates/scenarios/chaos_runaway_ai.json +25 -0
  103. iints/templates/scenarios/example_scenario.json +35 -0
  104. iints/templates/scenarios/exercise_stress.json +30 -0
  105. iints/utils/__init__.py +3 -0
  106. iints/utils/plotting.py +50 -0
  107. iints/utils/run_io.py +152 -0
  108. iints/validation/__init__.py +133 -0
  109. iints/validation/schemas.py +94 -0
  110. iints/visualization/__init__.py +34 -0
  111. iints/visualization/cockpit.py +691 -0
  112. iints/visualization/uncertainty_cloud.py +612 -0
  113. iints_sdk_python35-0.0.18.dist-info/METADATA +225 -0
  114. iints_sdk_python35-0.0.18.dist-info/RECORD +118 -0
  115. iints_sdk_python35-0.0.18.dist-info/WHEEL +5 -0
  116. iints_sdk_python35-0.0.18.dist-info/entry_points.txt +10 -0
  117. iints_sdk_python35-0.0.18.dist-info/licenses/LICENSE +28 -0
  118. iints_sdk_python35-0.0.18.dist-info/top_level.txt +1 -0
@@ -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
+ }