iints-sdk-python35 0.0.18__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iints/__init__.py +183 -0
- iints/analysis/__init__.py +12 -0
- iints/analysis/algorithm_xray.py +387 -0
- iints/analysis/baseline.py +92 -0
- iints/analysis/clinical_benchmark.py +198 -0
- iints/analysis/clinical_metrics.py +551 -0
- iints/analysis/clinical_tir_analyzer.py +136 -0
- iints/analysis/diabetes_metrics.py +43 -0
- iints/analysis/edge_efficiency.py +33 -0
- iints/analysis/edge_performance_monitor.py +315 -0
- iints/analysis/explainability.py +94 -0
- iints/analysis/explainable_ai.py +232 -0
- iints/analysis/hardware_benchmark.py +221 -0
- iints/analysis/metrics.py +117 -0
- iints/analysis/population_report.py +188 -0
- iints/analysis/reporting.py +345 -0
- iints/analysis/safety_index.py +311 -0
- iints/analysis/sensor_filtering.py +54 -0
- iints/analysis/validator.py +273 -0
- iints/api/__init__.py +0 -0
- iints/api/base_algorithm.py +307 -0
- iints/api/registry.py +103 -0
- iints/api/template_algorithm.py +195 -0
- iints/assets/iints_logo.png +0 -0
- iints/cli/__init__.py +0 -0
- iints/cli/cli.py +2598 -0
- iints/core/__init__.py +1 -0
- iints/core/algorithms/__init__.py +0 -0
- iints/core/algorithms/battle_runner.py +138 -0
- iints/core/algorithms/correction_bolus.py +95 -0
- iints/core/algorithms/discovery.py +92 -0
- iints/core/algorithms/fixed_basal_bolus.py +58 -0
- iints/core/algorithms/hybrid_algorithm.py +92 -0
- iints/core/algorithms/lstm_algorithm.py +138 -0
- iints/core/algorithms/mock_algorithms.py +162 -0
- iints/core/algorithms/pid_controller.py +88 -0
- iints/core/algorithms/standard_pump_algo.py +64 -0
- iints/core/device.py +0 -0
- iints/core/device_manager.py +64 -0
- iints/core/devices/__init__.py +3 -0
- iints/core/devices/models.py +160 -0
- iints/core/patient/__init__.py +9 -0
- iints/core/patient/bergman_model.py +341 -0
- iints/core/patient/models.py +285 -0
- iints/core/patient/patient_factory.py +117 -0
- iints/core/patient/profile.py +41 -0
- iints/core/safety/__init__.py +12 -0
- iints/core/safety/config.py +37 -0
- iints/core/safety/input_validator.py +95 -0
- iints/core/safety/supervisor.py +39 -0
- iints/core/simulation/__init__.py +0 -0
- iints/core/simulation/scenario_parser.py +61 -0
- iints/core/simulator.py +874 -0
- iints/core/supervisor.py +367 -0
- iints/data/__init__.py +53 -0
- iints/data/adapter.py +142 -0
- iints/data/column_mapper.py +398 -0
- iints/data/datasets.json +132 -0
- iints/data/demo/__init__.py +1 -0
- iints/data/demo/demo_cgm.csv +289 -0
- iints/data/importer.py +275 -0
- iints/data/ingestor.py +162 -0
- iints/data/nightscout.py +128 -0
- iints/data/quality_checker.py +550 -0
- iints/data/registry.py +166 -0
- iints/data/tidepool.py +38 -0
- iints/data/universal_parser.py +813 -0
- iints/data/virtual_patients/clinic_safe_baseline.yaml +9 -0
- iints/data/virtual_patients/clinic_safe_hyper_challenge.yaml +9 -0
- iints/data/virtual_patients/clinic_safe_hypo_prone.yaml +9 -0
- iints/data/virtual_patients/clinic_safe_midnight.yaml +9 -0
- iints/data/virtual_patients/clinic_safe_pizza.yaml +9 -0
- iints/data/virtual_patients/clinic_safe_stress_meal.yaml +9 -0
- iints/data/virtual_patients/default_patient.yaml +11 -0
- iints/data/virtual_patients/patient_559_config.yaml +11 -0
- iints/emulation/__init__.py +80 -0
- iints/emulation/legacy_base.py +414 -0
- iints/emulation/medtronic_780g.py +337 -0
- iints/emulation/omnipod_5.py +367 -0
- iints/emulation/tandem_controliq.py +393 -0
- iints/highlevel.py +451 -0
- iints/learning/__init__.py +3 -0
- iints/learning/autonomous_optimizer.py +194 -0
- iints/learning/learning_system.py +122 -0
- iints/metrics.py +34 -0
- iints/population/__init__.py +11 -0
- iints/population/generator.py +131 -0
- iints/population/runner.py +327 -0
- iints/presets/__init__.py +28 -0
- iints/presets/presets.json +114 -0
- iints/research/__init__.py +30 -0
- iints/research/config.py +68 -0
- iints/research/dataset.py +319 -0
- iints/research/losses.py +73 -0
- iints/research/predictor.py +329 -0
- iints/scenarios/__init__.py +3 -0
- iints/scenarios/generator.py +92 -0
- iints/templates/__init__.py +0 -0
- iints/templates/default_algorithm.py +91 -0
- iints/templates/scenarios/__init__.py +0 -0
- iints/templates/scenarios/chaos_insulin_stacking.json +29 -0
- iints/templates/scenarios/chaos_runaway_ai.json +25 -0
- iints/templates/scenarios/example_scenario.json +35 -0
- iints/templates/scenarios/exercise_stress.json +30 -0
- iints/utils/__init__.py +3 -0
- iints/utils/plotting.py +50 -0
- iints/utils/run_io.py +152 -0
- iints/validation/__init__.py +133 -0
- iints/validation/schemas.py +94 -0
- iints/visualization/__init__.py +34 -0
- iints/visualization/cockpit.py +691 -0
- iints/visualization/uncertainty_cloud.py +612 -0
- iints_sdk_python35-0.0.18.dist-info/METADATA +225 -0
- iints_sdk_python35-0.0.18.dist-info/RECORD +118 -0
- iints_sdk_python35-0.0.18.dist-info/WHEEL +5 -0
- iints_sdk_python35-0.0.18.dist-info/entry_points.txt +10 -0
- iints_sdk_python35-0.0.18.dist-info/licenses/LICENSE +28 -0
- iints_sdk_python35-0.0.18.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""
|
|
2
|
+
IINTS-AF Safety Index
|
|
3
|
+
=====================
|
|
4
|
+
A single composite metric (0–100) that summarises the clinical safety of an
|
|
5
|
+
insulin dosing algorithm's output. Higher is safer.
|
|
6
|
+
|
|
7
|
+
The index is designed to be:
|
|
8
|
+
* **Clinically meaningful** — components map directly to FDA SaMD guidance
|
|
9
|
+
and ATTD/ADA consensus recommendations.
|
|
10
|
+
* **Configurable** — researchers can adjust component weights via CLI flags
|
|
11
|
+
or direct API arguments to match the emphasis of their study protocol.
|
|
12
|
+
* **Transparent** — every component and its contribution is reported
|
|
13
|
+
separately so the index can be audited and reproduced.
|
|
14
|
+
|
|
15
|
+
Grades
|
|
16
|
+
------
|
|
17
|
+
A ≥ 90 Excellent — suitable for well-controlled clinical setting
|
|
18
|
+
B ≥ 75 Good — minor improvement recommended
|
|
19
|
+
C ≥ 60 Acceptable — targeted optimisation required
|
|
20
|
+
D ≥ 40 Poor — significant safety concerns
|
|
21
|
+
F < 40 Fail — unsafe; do not proceed to clinical evaluation
|
|
22
|
+
|
|
23
|
+
References
|
|
24
|
+
----------
|
|
25
|
+
* Battelino et al. (2019) ATTD International Consensus on CGM Metrics.
|
|
26
|
+
* Kovatchev et al. (2006) Symmetrisation of the Blood Glucose Measurement Scale.
|
|
27
|
+
* FDA Guidance: Software as a Medical Device (SaMD).
|
|
28
|
+
"""
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from typing import Dict, Optional, Tuple
|
|
33
|
+
|
|
34
|
+
import numpy as np
|
|
35
|
+
import pandas as pd
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
# Default component weights
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
DEFAULT_WEIGHTS: Dict[str, float] = {
|
|
43
|
+
"w_below54": 0.40, # time-below-range critical (<54 mg/dL) — most dangerous
|
|
44
|
+
"w_below70": 0.25, # time-below-range mild (<70 mg/dL)
|
|
45
|
+
"w_supervisor": 0.20, # safety supervisor trigger rate (interventions/hr)
|
|
46
|
+
"w_recovery": 0.10, # mean hypoglycaemia episode duration
|
|
47
|
+
"w_tail": 0.05, # tail-risk binary (any glucose ever < 54 mg/dL)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# Normalisation scales — the raw value at which the component contributes
|
|
51
|
+
# its FULL 100-point penalty. Values above the scale are clamped to 100.
|
|
52
|
+
NORM_SCALES: Dict[str, float] = {
|
|
53
|
+
"w_below54": 5.0, # 5 % TBR critical → full penalty
|
|
54
|
+
"w_below70": 20.0, # 20 % TBR low → full penalty
|
|
55
|
+
"w_supervisor": 12.0, # 12 supervisor triggers/hr → full penalty
|
|
56
|
+
"w_recovery": 1.0, # 1 hour mean episode duration → full penalty
|
|
57
|
+
"w_tail": 1.0, # binary 0 or 1
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _validate_weights(weights: Dict[str, float]) -> None:
|
|
62
|
+
for key in DEFAULT_WEIGHTS:
|
|
63
|
+
if key not in weights:
|
|
64
|
+
raise ValueError(f"Missing weight key: {key!r}")
|
|
65
|
+
if weights[key] < 0:
|
|
66
|
+
raise ValueError(f"Weight {key!r} must be >= 0, got {weights[key]}")
|
|
67
|
+
total = sum(weights.values())
|
|
68
|
+
if total <= 0:
|
|
69
|
+
raise ValueError("Sum of all weights must be > 0")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _norm(value: float, scale: float) -> float:
|
|
73
|
+
"""Normalise raw value to [0, 100] given the full-penalty scale."""
|
|
74
|
+
if scale <= 0:
|
|
75
|
+
return 0.0
|
|
76
|
+
return min(value / scale, 1.0) * 100.0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _grade(score: float) -> str:
|
|
80
|
+
if score >= 90:
|
|
81
|
+
return "A"
|
|
82
|
+
if score >= 75:
|
|
83
|
+
return "B"
|
|
84
|
+
if score >= 60:
|
|
85
|
+
return "C"
|
|
86
|
+
if score >= 40:
|
|
87
|
+
return "D"
|
|
88
|
+
return "F"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _interpret(score: float, penalties: Dict[str, float], weights: Dict[str, float]) -> str:
|
|
92
|
+
"""Generate a one-sentence human-readable interpretation."""
|
|
93
|
+
grade = _grade(score)
|
|
94
|
+
grade_labels = {"A": "Excellent", "B": "Good", "C": "Acceptable", "D": "Poor", "F": "Fail"}
|
|
95
|
+
label = grade_labels[grade]
|
|
96
|
+
|
|
97
|
+
# Identify the top penalty contributor
|
|
98
|
+
weighted = {k: weights[k] * _norm(v, NORM_SCALES[k]) for k, v in penalties.items()}
|
|
99
|
+
top_key = max(weighted, key=lambda k: weighted[k])
|
|
100
|
+
key_labels = {
|
|
101
|
+
"w_below54": "time critically below 54 mg/dL",
|
|
102
|
+
"w_below70": "time below 70 mg/dL",
|
|
103
|
+
"w_supervisor": "safety supervisor trigger rate",
|
|
104
|
+
"w_recovery": "hypoglycaemia episode duration",
|
|
105
|
+
"w_tail": "occurrence of critical hypoglycaemia",
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if score >= 90:
|
|
109
|
+
return f"{label}: Minimal safety concerns across all components."
|
|
110
|
+
dominant = key_labels[top_key]
|
|
111
|
+
return f"{label}: Primary concern is {dominant} (score={score:.1f}/100)."
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# Main result dataclass
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class SafetyIndexResult:
|
|
120
|
+
"""Result of the IINTS Safety Index computation."""
|
|
121
|
+
|
|
122
|
+
score: float
|
|
123
|
+
"""Composite safety score in [0, 100]. Higher is safer."""
|
|
124
|
+
|
|
125
|
+
grade: str
|
|
126
|
+
"""Letter grade: A, B, C, D, or F."""
|
|
127
|
+
|
|
128
|
+
components: Dict[str, float]
|
|
129
|
+
"""Normalised (0–100) per-component penalty before weighting."""
|
|
130
|
+
|
|
131
|
+
penalties: Dict[str, float]
|
|
132
|
+
"""Raw penalty values (before normalisation and weighting)."""
|
|
133
|
+
|
|
134
|
+
weights: Dict[str, float]
|
|
135
|
+
"""Component weights used in this computation."""
|
|
136
|
+
|
|
137
|
+
interpretation: str
|
|
138
|
+
"""Human-readable one-sentence summary."""
|
|
139
|
+
|
|
140
|
+
def to_dict(self) -> dict:
|
|
141
|
+
return {
|
|
142
|
+
"safety_index": self.score,
|
|
143
|
+
"grade": self.grade,
|
|
144
|
+
"interpretation": self.interpretation,
|
|
145
|
+
"components": self.components,
|
|
146
|
+
"penalties": self.penalties,
|
|
147
|
+
"weights": self.weights,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
def __str__(self) -> str:
|
|
151
|
+
return (
|
|
152
|
+
f"Safety Index: {self.score:.1f}/100 (Grade: {self.grade})\n"
|
|
153
|
+
f" {self.interpretation}\n"
|
|
154
|
+
f" Components: "
|
|
155
|
+
+ ", ".join(f"{k.replace('w_', '')}={v:.1f}" for k, v in self.components.items())
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
# Core computation
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
def _compute_hypo_episode_duration(glucose: pd.Series, time_step_minutes: float) -> float:
|
|
164
|
+
"""
|
|
165
|
+
Compute mean hypoglycaemia episode duration in minutes.
|
|
166
|
+
|
|
167
|
+
An episode starts when glucose drops below 70 mg/dL and ends when it
|
|
168
|
+
rises back above 70 mg/dL. Returns 0.0 if no episodes.
|
|
169
|
+
"""
|
|
170
|
+
below = (glucose < 70.0).to_numpy(dtype=bool)
|
|
171
|
+
if not np.any(below):
|
|
172
|
+
return 0.0
|
|
173
|
+
|
|
174
|
+
episode_lengths: list[int] = []
|
|
175
|
+
in_episode = False
|
|
176
|
+
length = 0
|
|
177
|
+
for b in below:
|
|
178
|
+
if b:
|
|
179
|
+
in_episode = True
|
|
180
|
+
length += 1
|
|
181
|
+
else:
|
|
182
|
+
if in_episode:
|
|
183
|
+
episode_lengths.append(length)
|
|
184
|
+
in_episode = False
|
|
185
|
+
length = 0
|
|
186
|
+
if in_episode and length > 0:
|
|
187
|
+
episode_lengths.append(length)
|
|
188
|
+
|
|
189
|
+
if not episode_lengths:
|
|
190
|
+
return 0.0
|
|
191
|
+
return float(np.mean(episode_lengths)) * time_step_minutes
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def compute_safety_index(
|
|
195
|
+
results_df: pd.DataFrame,
|
|
196
|
+
safety_report: Dict,
|
|
197
|
+
duration_minutes: int,
|
|
198
|
+
weights: Optional[Dict[str, float]] = None,
|
|
199
|
+
time_step_minutes: float = 5.0,
|
|
200
|
+
) -> SafetyIndexResult:
|
|
201
|
+
"""
|
|
202
|
+
Compute the IINTS Safety Index from a simulation run.
|
|
203
|
+
|
|
204
|
+
Parameters
|
|
205
|
+
----------
|
|
206
|
+
results_df : pd.DataFrame
|
|
207
|
+
Output of ``Simulator.run_batch()``. Must contain
|
|
208
|
+
``glucose_actual_mgdl`` and ``safety_triggered`` columns.
|
|
209
|
+
safety_report : dict
|
|
210
|
+
Safety report dict returned by ``Simulator.run_batch()``.
|
|
211
|
+
Must contain ``bolus_interventions_count`` (int).
|
|
212
|
+
duration_minutes : int
|
|
213
|
+
Total simulation duration in minutes.
|
|
214
|
+
weights : dict, optional
|
|
215
|
+
Component weights. If None, ``DEFAULT_WEIGHTS`` are used.
|
|
216
|
+
Must contain keys: ``w_below54``, ``w_below70``, ``w_supervisor``,
|
|
217
|
+
``w_recovery``, ``w_tail``.
|
|
218
|
+
time_step_minutes : float
|
|
219
|
+
Simulator time step in minutes (default 5).
|
|
220
|
+
|
|
221
|
+
Returns
|
|
222
|
+
-------
|
|
223
|
+
SafetyIndexResult
|
|
224
|
+
"""
|
|
225
|
+
if weights is None:
|
|
226
|
+
weights = dict(DEFAULT_WEIGHTS)
|
|
227
|
+
else:
|
|
228
|
+
weights = dict(weights) # defensive copy
|
|
229
|
+
_validate_weights(weights)
|
|
230
|
+
|
|
231
|
+
glucose_series = results_df["glucose_actual_mgdl"].astype(float)
|
|
232
|
+
glucose = glucose_series.to_numpy(dtype=float)
|
|
233
|
+
n = max(len(glucose), 1)
|
|
234
|
+
|
|
235
|
+
# --- Component 1: TBR critical < 54 mg/dL (%) ---
|
|
236
|
+
tbr_critical = float((glucose < 54.0).sum()) / n * 100.0
|
|
237
|
+
|
|
238
|
+
# --- Component 2: TBR low < 70 mg/dL (%) ---
|
|
239
|
+
tbr_low = float((glucose < 70.0).sum()) / n * 100.0
|
|
240
|
+
|
|
241
|
+
# --- Component 3: Supervisor trigger rate (triggers / hour) ---
|
|
242
|
+
duration_hours = max(duration_minutes / 60.0, 1e-9)
|
|
243
|
+
interventions = int(safety_report.get("bolus_interventions_count", 0))
|
|
244
|
+
supervisor_rate = interventions / duration_hours
|
|
245
|
+
|
|
246
|
+
# --- Component 4: Mean hypo episode duration (hours) ---
|
|
247
|
+
mean_episode_hr = _compute_hypo_episode_duration(glucose_series, time_step_minutes) / 60.0
|
|
248
|
+
|
|
249
|
+
# --- Component 5: Tail-risk binary (1 if any glucose < 54, else 0) ---
|
|
250
|
+
tail_risk = 1.0 if np.any(glucose < 54.0) else 0.0
|
|
251
|
+
|
|
252
|
+
penalties: Dict[str, float] = {
|
|
253
|
+
"w_below54": tbr_critical,
|
|
254
|
+
"w_below70": tbr_low,
|
|
255
|
+
"w_supervisor": supervisor_rate,
|
|
256
|
+
"w_recovery": mean_episode_hr,
|
|
257
|
+
"w_tail": tail_risk,
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
components: Dict[str, float] = {
|
|
261
|
+
k: _norm(penalties[k], NORM_SCALES[k]) for k in penalties
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
# --- Weighted sum of penalties ---
|
|
265
|
+
total_weight = sum(weights.values())
|
|
266
|
+
weighted_penalty = sum(
|
|
267
|
+
weights[k] * components[k] for k in weights
|
|
268
|
+
) / total_weight
|
|
269
|
+
|
|
270
|
+
score = float(np.clip(100.0 - weighted_penalty, 0.0, 100.0))
|
|
271
|
+
grade = _grade(score)
|
|
272
|
+
interpretation = _interpret(score, penalties, weights)
|
|
273
|
+
|
|
274
|
+
return SafetyIndexResult(
|
|
275
|
+
score=score,
|
|
276
|
+
grade=grade,
|
|
277
|
+
components=components,
|
|
278
|
+
penalties=penalties,
|
|
279
|
+
weights=dict(weights),
|
|
280
|
+
interpretation=interpretation,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def safety_weights_from_cli(
|
|
285
|
+
w_below54: Optional[float] = None,
|
|
286
|
+
w_below70: Optional[float] = None,
|
|
287
|
+
w_supervisor: Optional[float] = None,
|
|
288
|
+
w_recovery: Optional[float] = None,
|
|
289
|
+
w_tail: Optional[float] = None,
|
|
290
|
+
) -> Dict[str, float]:
|
|
291
|
+
"""
|
|
292
|
+
Build a weights dict from CLI arguments. Any ``None`` values fall back
|
|
293
|
+
to the corresponding default weight.
|
|
294
|
+
|
|
295
|
+
Intended usage in CLI commands::
|
|
296
|
+
|
|
297
|
+
weights = safety_weights_from_cli(
|
|
298
|
+
w_below54=args.safety_w_below54,
|
|
299
|
+
w_below70=args.safety_w_below70,
|
|
300
|
+
...
|
|
301
|
+
)
|
|
302
|
+
result = compute_safety_index(df, report, duration, weights=weights)
|
|
303
|
+
"""
|
|
304
|
+
defaults = dict(DEFAULT_WEIGHTS)
|
|
305
|
+
return {
|
|
306
|
+
"w_below54": w_below54 if w_below54 is not None else defaults["w_below54"],
|
|
307
|
+
"w_below70": w_below70 if w_below70 is not None else defaults["w_below70"],
|
|
308
|
+
"w_supervisor": w_supervisor if w_supervisor is not None else defaults["w_supervisor"],
|
|
309
|
+
"w_recovery": w_recovery if w_recovery is not None else defaults["w_recovery"],
|
|
310
|
+
"w_tail": w_tail if w_tail is not None else defaults["w_tail"],
|
|
311
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from collections import deque
|
|
3
|
+
|
|
4
|
+
class SensorNoiseModel:
|
|
5
|
+
"""Realistic sensor noise with drift and filtering."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, white_noise_std=15, drift_rate=0.1, drift_amplitude=10):
|
|
8
|
+
self.white_noise_std = white_noise_std
|
|
9
|
+
self.drift_rate = drift_rate
|
|
10
|
+
self.drift_amplitude = drift_amplitude
|
|
11
|
+
self.drift_phase = 0
|
|
12
|
+
|
|
13
|
+
def add_noise(self, true_glucose, time_step):
|
|
14
|
+
"""Add realistic sensor noise with drift."""
|
|
15
|
+
white_noise = np.random.normal(0, self.white_noise_std)
|
|
16
|
+
drift = self.drift_amplitude * np.sin(self.drift_phase)
|
|
17
|
+
self.drift_phase += self.drift_rate * time_step / 60 # Convert to hours
|
|
18
|
+
|
|
19
|
+
return true_glucose + white_noise + drift
|
|
20
|
+
|
|
21
|
+
class KalmanFilter:
|
|
22
|
+
"""Simple Kalman filter for glucose smoothing."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, process_variance=1, measurement_variance=225):
|
|
25
|
+
self.process_variance = process_variance
|
|
26
|
+
self.measurement_variance = measurement_variance
|
|
27
|
+
self.estimate = None
|
|
28
|
+
self.error_estimate = 1000
|
|
29
|
+
|
|
30
|
+
def update(self, measurement):
|
|
31
|
+
if self.estimate is None:
|
|
32
|
+
self.estimate = measurement
|
|
33
|
+
return measurement
|
|
34
|
+
|
|
35
|
+
# Prediction
|
|
36
|
+
prediction = self.estimate
|
|
37
|
+
prediction_error = self.error_estimate + self.process_variance
|
|
38
|
+
|
|
39
|
+
# Update
|
|
40
|
+
kalman_gain = prediction_error / (prediction_error + self.measurement_variance)
|
|
41
|
+
self.estimate = prediction + kalman_gain * (measurement - prediction)
|
|
42
|
+
self.error_estimate = (1 - kalman_gain) * prediction_error
|
|
43
|
+
|
|
44
|
+
return self.estimate
|
|
45
|
+
|
|
46
|
+
class MovingAverageFilter:
|
|
47
|
+
"""Simple moving average filter."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, window_size=3):
|
|
50
|
+
self.window = deque(maxlen=window_size)
|
|
51
|
+
|
|
52
|
+
def update(self, value):
|
|
53
|
+
self.window.append(value)
|
|
54
|
+
return sum(self.window) / len(self.window)
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from typing import Dict, List, Tuple, Optional, Any
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
class ReliabilityLevel(Enum):
|
|
8
|
+
HIGH = "high"
|
|
9
|
+
MEDIUM = "medium"
|
|
10
|
+
LOW = "low"
|
|
11
|
+
CRITICAL = "critical"
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ValidationResult:
|
|
15
|
+
passed: bool
|
|
16
|
+
reliability_score: float # 0-100%
|
|
17
|
+
level: ReliabilityLevel
|
|
18
|
+
issues: List[str]
|
|
19
|
+
warnings: List[str]
|
|
20
|
+
|
|
21
|
+
class DataIntegrityValidator:
|
|
22
|
+
"""Validates data integrity for reverse engineering analysis."""
|
|
23
|
+
|
|
24
|
+
def __init__(self):
|
|
25
|
+
# Physiological limits
|
|
26
|
+
self.max_glucose_rate = 10 # mg/dL per minute
|
|
27
|
+
self.glucose_range = (20, 600) # Physiologically possible range
|
|
28
|
+
self.max_insulin_per_step = 5.0 # Units per 5-min step
|
|
29
|
+
|
|
30
|
+
def validate_glucose_data(self, glucose_values: List[float], timestamps: List[float]) -> ValidationResult:
|
|
31
|
+
"""Validate glucose data for physiological plausibility."""
|
|
32
|
+
issues = []
|
|
33
|
+
warnings: List[str] = []
|
|
34
|
+
score = 100.0
|
|
35
|
+
|
|
36
|
+
# Check range
|
|
37
|
+
for i, glucose in enumerate(glucose_values):
|
|
38
|
+
if not (self.glucose_range[0] <= glucose <= self.glucose_range[1]):
|
|
39
|
+
issues.append(f"Glucose {glucose} at step {i} outside physiological range")
|
|
40
|
+
score -= 10
|
|
41
|
+
|
|
42
|
+
# Check rate of change
|
|
43
|
+
for i in range(1, len(glucose_values)):
|
|
44
|
+
if len(timestamps) > i:
|
|
45
|
+
time_diff = timestamps[i] - timestamps[i-1]
|
|
46
|
+
glucose_diff = abs(glucose_values[i] - glucose_values[i-1])
|
|
47
|
+
rate = glucose_diff / time_diff if time_diff > 0 else float('inf')
|
|
48
|
+
|
|
49
|
+
if rate > self.max_glucose_rate:
|
|
50
|
+
issues.append(f"Impossible glucose rate: {rate:.1f} mg/dL/min at step {i}")
|
|
51
|
+
score -= 15
|
|
52
|
+
|
|
53
|
+
# Check for missing values (NaN)
|
|
54
|
+
nan_count = sum(1 for g in glucose_values if pd.isna(g))
|
|
55
|
+
if nan_count > 0:
|
|
56
|
+
warnings.append(f"{nan_count} missing glucose values detected")
|
|
57
|
+
score -= nan_count * 5
|
|
58
|
+
|
|
59
|
+
# Determine reliability level
|
|
60
|
+
if score >= 90:
|
|
61
|
+
level = ReliabilityLevel.HIGH
|
|
62
|
+
elif score >= 70:
|
|
63
|
+
level = ReliabilityLevel.MEDIUM
|
|
64
|
+
elif score >= 50:
|
|
65
|
+
level = ReliabilityLevel.LOW
|
|
66
|
+
else:
|
|
67
|
+
level = ReliabilityLevel.CRITICAL
|
|
68
|
+
|
|
69
|
+
return ValidationResult(
|
|
70
|
+
passed=len(issues) == 0,
|
|
71
|
+
reliability_score=max(0, score),
|
|
72
|
+
level=level,
|
|
73
|
+
issues=issues,
|
|
74
|
+
warnings=warnings
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def validate_insulin_data(self, insulin_values: List[float]) -> ValidationResult:
|
|
78
|
+
"""Validate insulin delivery data."""
|
|
79
|
+
issues = []
|
|
80
|
+
warnings: List[str] = []
|
|
81
|
+
score = 100.0
|
|
82
|
+
|
|
83
|
+
for i, insulin in enumerate(insulin_values):
|
|
84
|
+
if insulin < 0:
|
|
85
|
+
issues.append(f"Negative insulin {insulin} at step {i}")
|
|
86
|
+
score -= 20
|
|
87
|
+
elif insulin > self.max_insulin_per_step:
|
|
88
|
+
warnings.append(f"High insulin dose {insulin} at step {i}")
|
|
89
|
+
score -= 5
|
|
90
|
+
|
|
91
|
+
level = ReliabilityLevel.HIGH if score >= 90 else ReliabilityLevel.MEDIUM if score >= 70 else ReliabilityLevel.LOW
|
|
92
|
+
|
|
93
|
+
return ValidationResult(
|
|
94
|
+
passed=len(issues) == 0,
|
|
95
|
+
reliability_score=max(0, score),
|
|
96
|
+
level=level,
|
|
97
|
+
issues=issues,
|
|
98
|
+
warnings=warnings
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
class AlgorithmicDriftDetector:
|
|
102
|
+
"""Detects when AI algorithms drift from safe baseline behavior."""
|
|
103
|
+
|
|
104
|
+
def __init__(self, drift_threshold=0.5): # 50% difference threshold
|
|
105
|
+
self.drift_threshold = drift_threshold
|
|
106
|
+
|
|
107
|
+
def detect_drift(self, ai_outputs: List[float], baseline_outputs: List[float]) -> ValidationResult:
|
|
108
|
+
"""Compare AI outputs against rule-based baseline."""
|
|
109
|
+
issues = []
|
|
110
|
+
warnings: List[str] = []
|
|
111
|
+
score = 100.0
|
|
112
|
+
|
|
113
|
+
if len(ai_outputs) != len(baseline_outputs):
|
|
114
|
+
issues.append("AI and baseline output lengths don't match")
|
|
115
|
+
return ValidationResult(False, 0, ReliabilityLevel.CRITICAL, issues, warnings)
|
|
116
|
+
|
|
117
|
+
drift_count = 0
|
|
118
|
+
extreme_drift_count = 0
|
|
119
|
+
|
|
120
|
+
for i, (ai_val, baseline_val) in enumerate(zip(ai_outputs, baseline_outputs)):
|
|
121
|
+
if baseline_val == 0:
|
|
122
|
+
if ai_val > 0.1: # AI gives insulin when baseline gives none
|
|
123
|
+
drift_count += 1
|
|
124
|
+
warnings.append(f"AI delivers insulin ({ai_val:.2f}) when baseline gives none at step {i}")
|
|
125
|
+
else:
|
|
126
|
+
relative_diff = abs(ai_val - baseline_val) / baseline_val
|
|
127
|
+
if relative_diff > self.drift_threshold:
|
|
128
|
+
drift_count += 1
|
|
129
|
+
if relative_diff > 1.0: # 100% difference
|
|
130
|
+
extreme_drift_count += 1
|
|
131
|
+
issues.append(f"Extreme drift: AI={ai_val:.2f}, Baseline={baseline_val:.2f} at step {i}")
|
|
132
|
+
else:
|
|
133
|
+
warnings.append(f"Drift detected: {relative_diff:.1%} difference at step {i}")
|
|
134
|
+
|
|
135
|
+
# Calculate score based on drift frequency
|
|
136
|
+
drift_rate = drift_count / len(ai_outputs)
|
|
137
|
+
extreme_drift_rate = extreme_drift_count / len(ai_outputs)
|
|
138
|
+
|
|
139
|
+
score -= drift_rate * 50 # Penalize drift
|
|
140
|
+
score -= extreme_drift_rate * 30 # Extra penalty for extreme drift
|
|
141
|
+
|
|
142
|
+
if extreme_drift_count > 0:
|
|
143
|
+
level = ReliabilityLevel.CRITICAL
|
|
144
|
+
elif drift_rate > 0.3:
|
|
145
|
+
level = ReliabilityLevel.LOW
|
|
146
|
+
elif drift_rate > 0.1:
|
|
147
|
+
level = ReliabilityLevel.MEDIUM
|
|
148
|
+
else:
|
|
149
|
+
level = ReliabilityLevel.HIGH
|
|
150
|
+
|
|
151
|
+
return ValidationResult(
|
|
152
|
+
passed=extreme_drift_count == 0,
|
|
153
|
+
reliability_score=max(0, score),
|
|
154
|
+
level=level,
|
|
155
|
+
issues=issues,
|
|
156
|
+
warnings=warnings
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
class StatisticalReliabilityChecker:
|
|
160
|
+
"""Checks statistical reliability of Monte Carlo results."""
|
|
161
|
+
|
|
162
|
+
def __init__(self, min_runs=10, max_cv=0.3): # Max 30% coefficient of variation
|
|
163
|
+
self.min_runs = min_runs
|
|
164
|
+
self.max_cv = max_cv
|
|
165
|
+
|
|
166
|
+
def check_monte_carlo_reliability(self, results: List[List[float]]) -> ValidationResult:
|
|
167
|
+
"""Check if Monte Carlo results are statistically reliable."""
|
|
168
|
+
issues = []
|
|
169
|
+
warnings: List[str] = []
|
|
170
|
+
score = 100.0
|
|
171
|
+
|
|
172
|
+
if len(results) < self.min_runs:
|
|
173
|
+
issues.append(f"Insufficient runs: {len(results)} < {self.min_runs}")
|
|
174
|
+
score -= 30
|
|
175
|
+
|
|
176
|
+
# Calculate coefficient of variation for each time step
|
|
177
|
+
if len(results) > 1:
|
|
178
|
+
results_array = np.array(results)
|
|
179
|
+
means = np.mean(results_array, axis=0)
|
|
180
|
+
stds = np.std(results_array, axis=0)
|
|
181
|
+
|
|
182
|
+
# Avoid division by zero
|
|
183
|
+
cvs = np.divide(stds, means, out=np.zeros_like(stds), where=means!=0)
|
|
184
|
+
|
|
185
|
+
high_variance_steps = np.sum(cvs > self.max_cv)
|
|
186
|
+
if high_variance_steps > 0:
|
|
187
|
+
variance_rate = high_variance_steps / len(cvs)
|
|
188
|
+
if variance_rate > 0.5:
|
|
189
|
+
issues.append(f"High variance in {variance_rate:.1%} of time steps")
|
|
190
|
+
score -= 40
|
|
191
|
+
else:
|
|
192
|
+
warnings.append(f"Moderate variance in {variance_rate:.1%} of time steps")
|
|
193
|
+
score -= 20
|
|
194
|
+
|
|
195
|
+
level = ReliabilityLevel.HIGH if score >= 90 else ReliabilityLevel.MEDIUM if score >= 70 else ReliabilityLevel.LOW
|
|
196
|
+
|
|
197
|
+
return ValidationResult(
|
|
198
|
+
passed=len(issues) == 0,
|
|
199
|
+
reliability_score=max(0, score),
|
|
200
|
+
level=level,
|
|
201
|
+
issues=issues,
|
|
202
|
+
warnings=warnings
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
class ReverseEngineeringValidator:
|
|
206
|
+
"""Main validator for reverse engineering analysis."""
|
|
207
|
+
|
|
208
|
+
def __init__(self):
|
|
209
|
+
self.data_validator = DataIntegrityValidator()
|
|
210
|
+
self.drift_detector = AlgorithmicDriftDetector()
|
|
211
|
+
self.reliability_checker = StatisticalReliabilityChecker()
|
|
212
|
+
|
|
213
|
+
def validate_simulation_results(self, simulation_df: pd.DataFrame,
|
|
214
|
+
baseline_results: Optional[List[float]] = None,
|
|
215
|
+
monte_carlo_results: Optional[List[List[float]]] = None) -> Dict[str, ValidationResult]:
|
|
216
|
+
"""Comprehensive validation of simulation results."""
|
|
217
|
+
|
|
218
|
+
results = {}
|
|
219
|
+
|
|
220
|
+
# 1. Data integrity validation
|
|
221
|
+
glucose_values = simulation_df['glucose_actual_mgdl'].tolist()
|
|
222
|
+
timestamps = simulation_df['time_minutes'].tolist()
|
|
223
|
+
insulin_values = simulation_df['delivered_insulin_units'].tolist()
|
|
224
|
+
|
|
225
|
+
results['glucose_integrity'] = self.data_validator.validate_glucose_data(glucose_values, timestamps)
|
|
226
|
+
results['insulin_integrity'] = self.data_validator.validate_insulin_data(insulin_values)
|
|
227
|
+
|
|
228
|
+
# 2. Algorithmic drift detection
|
|
229
|
+
if baseline_results:
|
|
230
|
+
results['algorithmic_drift'] = self.drift_detector.detect_drift(insulin_values, baseline_results)
|
|
231
|
+
|
|
232
|
+
# 3. Statistical reliability
|
|
233
|
+
if monte_carlo_results:
|
|
234
|
+
results['statistical_reliability'] = self.reliability_checker.check_monte_carlo_reliability(monte_carlo_results)
|
|
235
|
+
|
|
236
|
+
return results
|
|
237
|
+
|
|
238
|
+
def generate_reliability_report(self, validation_results: Dict[str, ValidationResult]) -> Dict[str, Any]:
|
|
239
|
+
"""Generate comprehensive reliability report."""
|
|
240
|
+
|
|
241
|
+
overall_score = np.mean([result.reliability_score for result in validation_results.values()])
|
|
242
|
+
|
|
243
|
+
all_issues = []
|
|
244
|
+
all_warnings = []
|
|
245
|
+
|
|
246
|
+
for category, result in validation_results.items():
|
|
247
|
+
all_issues.extend([f"{category}: {issue}" for issue in result.issues])
|
|
248
|
+
all_warnings.extend([f"{category}: {warning}" for warning in result.warnings])
|
|
249
|
+
|
|
250
|
+
# Determine overall reliability
|
|
251
|
+
if overall_score >= 90:
|
|
252
|
+
overall_level = ReliabilityLevel.HIGH
|
|
253
|
+
recommendation = "Results are highly reliable for reverse engineering analysis"
|
|
254
|
+
elif overall_score >= 70:
|
|
255
|
+
overall_level = ReliabilityLevel.MEDIUM
|
|
256
|
+
recommendation = "Results are moderately reliable - consider additional validation"
|
|
257
|
+
elif overall_score >= 50:
|
|
258
|
+
overall_level = ReliabilityLevel.LOW
|
|
259
|
+
recommendation = "Results have low reliability - use with caution"
|
|
260
|
+
else:
|
|
261
|
+
overall_level = ReliabilityLevel.CRITICAL
|
|
262
|
+
recommendation = "Results are unreliable - do not use for analysis"
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
"overall_reliability_score": overall_score,
|
|
266
|
+
"overall_level": overall_level.value,
|
|
267
|
+
"recommendation": recommendation,
|
|
268
|
+
"total_issues": len(all_issues),
|
|
269
|
+
"total_warnings": len(all_warnings),
|
|
270
|
+
"issues": all_issues,
|
|
271
|
+
"warnings": all_warnings,
|
|
272
|
+
"category_scores": {category: result.reliability_score for category, result in validation_results.items()}
|
|
273
|
+
}
|
iints/api/__init__.py
ADDED
|
File without changes
|