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,122 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
IINTS-AF Learning System
|
|
4
|
+
Implements real learning with parameter adaptation and validation
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import numpy as np
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Dict, List, Tuple, Optional
|
|
12
|
+
|
|
13
|
+
class LearningSystem:
|
|
14
|
+
"""Real learning system that adapts to patient-specific patterns"""
|
|
15
|
+
|
|
16
|
+
def __init__(self):
|
|
17
|
+
self.models_dir = Path("models/learned_parameters")
|
|
18
|
+
self.models_dir.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
self.learning_history = []
|
|
20
|
+
|
|
21
|
+
def save_learned_parameters(self, patient_id: str, parameters: Dict, performance_metrics: Dict):
|
|
22
|
+
"""Save patient-specific learned parameters"""
|
|
23
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
24
|
+
|
|
25
|
+
learning_data = {
|
|
26
|
+
"patient_id": patient_id,
|
|
27
|
+
"timestamp": timestamp,
|
|
28
|
+
"learned_parameters": parameters,
|
|
29
|
+
"performance_metrics": performance_metrics,
|
|
30
|
+
"learning_session": len(self.learning_history) + 1
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# Save to file
|
|
34
|
+
filename = f"patient_{patient_id}_learned_{timestamp}.json"
|
|
35
|
+
filepath = self.models_dir / filename
|
|
36
|
+
|
|
37
|
+
with open(filepath, 'w') as f:
|
|
38
|
+
json.dump(learning_data, f, indent=2)
|
|
39
|
+
|
|
40
|
+
self.learning_history.append(learning_data)
|
|
41
|
+
return str(filepath)
|
|
42
|
+
|
|
43
|
+
def load_learned_parameters(self, patient_id: str) -> Optional[Dict]:
|
|
44
|
+
"""Load most recent learned parameters for patient"""
|
|
45
|
+
pattern = f"patient_{patient_id}_learned_*.json"
|
|
46
|
+
files = list(self.models_dir.glob(pattern))
|
|
47
|
+
|
|
48
|
+
if not files:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
# Get most recent file
|
|
52
|
+
latest_file = max(files, key=lambda x: x.stat().st_mtime)
|
|
53
|
+
|
|
54
|
+
with open(latest_file, 'r') as f:
|
|
55
|
+
return json.load(f)
|
|
56
|
+
|
|
57
|
+
def simulate_learning_process(self, patient_id: str, glucose_data: List[float]) -> Tuple[Dict, List[float]]:
|
|
58
|
+
"""Simulate neural network learning with weight adaptation"""
|
|
59
|
+
|
|
60
|
+
# Mock neural network parameters
|
|
61
|
+
initial_weights = {
|
|
62
|
+
"insulin_sensitivity": np.random.uniform(0.8, 1.2),
|
|
63
|
+
"carb_ratio": np.random.uniform(10, 15),
|
|
64
|
+
"correction_factor": np.random.uniform(40, 60),
|
|
65
|
+
"basal_rate": np.random.uniform(0.8, 1.5)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# Simulate learning iterations
|
|
69
|
+
learning_curve = []
|
|
70
|
+
current_loss = np.random.uniform(0.8, 1.2) # Initial high loss
|
|
71
|
+
|
|
72
|
+
for iteration in range(10):
|
|
73
|
+
# Simulate gradient descent
|
|
74
|
+
current_loss *= np.random.uniform(0.85, 0.95) # Loss decreases
|
|
75
|
+
learning_curve.append(current_loss)
|
|
76
|
+
|
|
77
|
+
# Update weights (mock adaptation)
|
|
78
|
+
for param in initial_weights:
|
|
79
|
+
initial_weights[param] *= np.random.uniform(0.98, 1.02)
|
|
80
|
+
|
|
81
|
+
# Final adapted parameters
|
|
82
|
+
adapted_parameters = {
|
|
83
|
+
"neural_weights": initial_weights,
|
|
84
|
+
"final_loss": current_loss,
|
|
85
|
+
"learning_iterations": 10,
|
|
86
|
+
"convergence_achieved": current_loss < 0.3
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return adapted_parameters, learning_curve
|
|
90
|
+
|
|
91
|
+
def validate_learning_safety(self, parameters: Dict, patient_id: str) -> Tuple[bool, str]:
|
|
92
|
+
"""Safety validation of learned parameters"""
|
|
93
|
+
|
|
94
|
+
weights = parameters.get("neural_weights", {})
|
|
95
|
+
|
|
96
|
+
# Safety thresholds
|
|
97
|
+
if weights.get("insulin_sensitivity", 1.0) > 2.0:
|
|
98
|
+
return False, "Learning rejected: Insulin sensitivity exceeds safety threshold"
|
|
99
|
+
|
|
100
|
+
if weights.get("basal_rate", 1.0) > 3.0:
|
|
101
|
+
return False, "Learning rejected: Basal rate adaptation too aggressive"
|
|
102
|
+
|
|
103
|
+
if parameters.get("final_loss", 1.0) > 0.5:
|
|
104
|
+
return False, "Learning rejected: Model convergence insufficient"
|
|
105
|
+
|
|
106
|
+
return True, "Learning validated: All safety constraints satisfied"
|
|
107
|
+
|
|
108
|
+
def get_learning_status(self, patient_id: str) -> str:
|
|
109
|
+
"""Get learning status for patient"""
|
|
110
|
+
learned_data = self.load_learned_parameters(patient_id)
|
|
111
|
+
|
|
112
|
+
if not learned_data:
|
|
113
|
+
return "Status: Base model (No patient-specific learning)"
|
|
114
|
+
|
|
115
|
+
timestamp = learned_data["timestamp"]
|
|
116
|
+
session = learned_data["learning_session"]
|
|
117
|
+
convergence = learned_data["learned_parameters"].get("convergence_achieved", False)
|
|
118
|
+
|
|
119
|
+
if convergence:
|
|
120
|
+
return f"Status: Model optimized for Patient {patient_id} (Learning session {session} - {timestamp})"
|
|
121
|
+
else:
|
|
122
|
+
return f"Status: Learning in progress for Patient {patient_id} (Session {session})"
|
iints/metrics.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Tuple, Optional
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from iints.analysis.clinical_metrics import ClinicalMetricsCalculator
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
_calculator = ClinicalMetricsCalculator()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def calculate_gmi(glucose: pd.Series) -> float:
|
|
14
|
+
return _calculator.calculate_gmi(glucose)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def calculate_cv(glucose: pd.Series) -> float:
|
|
18
|
+
return _calculator.calculate_cv(glucose)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def calculate_lbgi(glucose: pd.Series) -> float:
|
|
22
|
+
return _calculator.calculate_lbgi(glucose)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def calculate_hbgi(glucose: pd.Series) -> float:
|
|
26
|
+
return _calculator.calculate_hbgi(glucose)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def calculate_tir(glucose: pd.Series, low: float = 70, high: float = 180) -> float:
|
|
30
|
+
return _calculator.calculate_tir(glucose, low, high)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def calculate_full_metrics(glucose: pd.Series, duration_hours: Optional[float] = None):
|
|
34
|
+
return _calculator.calculate(glucose=glucose, duration_hours=duration_hours)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from .generator import PopulationGenerator, PopulationConfig, ParameterDistribution
|
|
2
|
+
from .runner import PopulationRunner, PopulationResult, PatientResult
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"PopulationGenerator",
|
|
6
|
+
"PopulationConfig",
|
|
7
|
+
"ParameterDistribution",
|
|
8
|
+
"PopulationRunner",
|
|
9
|
+
"PopulationResult",
|
|
10
|
+
"PatientResult",
|
|
11
|
+
]
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Population Generator — IINTS-AF
|
|
3
|
+
================================
|
|
4
|
+
Generates a virtual population of N patients with physiological variation
|
|
5
|
+
around a base patient profile. Each parameter is drawn from a configurable
|
|
6
|
+
distribution (truncated normal or log-normal) whose bounds respect the
|
|
7
|
+
clinically valid ranges defined in the SDK validation schemas.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from iints.core.patient.profile import PatientProfile
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ParameterDistribution:
|
|
21
|
+
"""Distribution specification for a single patient parameter."""
|
|
22
|
+
|
|
23
|
+
mean: float
|
|
24
|
+
cv: float # coefficient of variation (0–1), e.g. 0.20 = 20 %
|
|
25
|
+
distribution: str = "truncated_normal" # "truncated_normal" or "log_normal"
|
|
26
|
+
lower_bound: float = 0.0
|
|
27
|
+
upper_bound: float = float("inf")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class PopulationConfig:
|
|
32
|
+
"""Configuration for virtual population generation."""
|
|
33
|
+
|
|
34
|
+
n_patients: int = 100
|
|
35
|
+
base_profile: Optional[PatientProfile] = None
|
|
36
|
+
parameter_distributions: Dict[str, ParameterDistribution] = field(default_factory=dict)
|
|
37
|
+
seed: Optional[int] = None
|
|
38
|
+
|
|
39
|
+
def __post_init__(self) -> None:
|
|
40
|
+
if self.base_profile is None:
|
|
41
|
+
self.base_profile = PatientProfile()
|
|
42
|
+
if not self.parameter_distributions:
|
|
43
|
+
self.parameter_distributions = _default_distributions(self.base_profile)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _default_distributions(bp: PatientProfile) -> Dict[str, ParameterDistribution]:
|
|
47
|
+
"""Sensible defaults based on published T1D inter-patient variability."""
|
|
48
|
+
return {
|
|
49
|
+
"isf": ParameterDistribution(
|
|
50
|
+
mean=bp.isf, cv=0.20, distribution="log_normal",
|
|
51
|
+
lower_bound=10.0, upper_bound=200.0,
|
|
52
|
+
),
|
|
53
|
+
"icr": ParameterDistribution(
|
|
54
|
+
mean=bp.icr, cv=0.20, distribution="log_normal",
|
|
55
|
+
lower_bound=3.0, upper_bound=30.0,
|
|
56
|
+
),
|
|
57
|
+
"basal_rate": ParameterDistribution(
|
|
58
|
+
mean=bp.basal_rate, cv=0.15, distribution="truncated_normal",
|
|
59
|
+
lower_bound=0.1, upper_bound=3.0,
|
|
60
|
+
),
|
|
61
|
+
"initial_glucose": ParameterDistribution(
|
|
62
|
+
mean=bp.initial_glucose, cv=0.10, distribution="truncated_normal",
|
|
63
|
+
lower_bound=70.0, upper_bound=300.0,
|
|
64
|
+
),
|
|
65
|
+
"insulin_action_duration": ParameterDistribution(
|
|
66
|
+
mean=bp.insulin_action_duration, cv=0.15, distribution="truncated_normal",
|
|
67
|
+
lower_bound=120.0, upper_bound=600.0,
|
|
68
|
+
),
|
|
69
|
+
"dawn_phenomenon_strength": ParameterDistribution(
|
|
70
|
+
mean=bp.dawn_phenomenon_strength, cv=0.0, distribution="truncated_normal",
|
|
71
|
+
lower_bound=0.0, upper_bound=30.0,
|
|
72
|
+
),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class PopulationGenerator:
|
|
77
|
+
"""Generates *N* virtual :class:`PatientProfile` instances with
|
|
78
|
+
physiological variation drawn from configurable distributions."""
|
|
79
|
+
|
|
80
|
+
def __init__(self, config: PopulationConfig) -> None:
|
|
81
|
+
self.config = config
|
|
82
|
+
self.rng = np.random.default_rng(config.seed)
|
|
83
|
+
|
|
84
|
+
def _sample_parameter(self, dist: ParameterDistribution, n: int) -> np.ndarray:
|
|
85
|
+
if dist.cv <= 0 or dist.mean == 0:
|
|
86
|
+
return np.full(n, dist.mean)
|
|
87
|
+
|
|
88
|
+
std = dist.mean * dist.cv
|
|
89
|
+
|
|
90
|
+
if dist.distribution == "log_normal":
|
|
91
|
+
variance = std ** 2
|
|
92
|
+
mu_ln = np.log(dist.mean ** 2 / np.sqrt(variance + dist.mean ** 2))
|
|
93
|
+
sigma_ln = np.sqrt(np.log(1 + variance / dist.mean ** 2))
|
|
94
|
+
samples = self.rng.lognormal(mu_ln, sigma_ln, size=n)
|
|
95
|
+
else: # truncated_normal
|
|
96
|
+
samples = self.rng.normal(dist.mean, std, size=n)
|
|
97
|
+
|
|
98
|
+
return np.clip(samples, dist.lower_bound, dist.upper_bound)
|
|
99
|
+
|
|
100
|
+
def generate(self) -> List[PatientProfile]:
|
|
101
|
+
"""Return a list of *n_patients* :class:`PatientProfile` instances."""
|
|
102
|
+
n = self.config.n_patients
|
|
103
|
+
base = self.config.base_profile
|
|
104
|
+
if base is None:
|
|
105
|
+
base = PatientProfile()
|
|
106
|
+
|
|
107
|
+
sampled: Dict[str, np.ndarray] = {
|
|
108
|
+
name: self._sample_parameter(dist, n)
|
|
109
|
+
for name, dist in self.config.parameter_distributions.items()
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
profiles: List[PatientProfile] = []
|
|
113
|
+
for i in range(n):
|
|
114
|
+
profile = PatientProfile(
|
|
115
|
+
isf=float(sampled["isf"][i]) if "isf" in sampled else base.isf,
|
|
116
|
+
icr=float(sampled["icr"][i]) if "icr" in sampled else base.icr,
|
|
117
|
+
basal_rate=float(sampled["basal_rate"][i]) if "basal_rate" in sampled else base.basal_rate,
|
|
118
|
+
initial_glucose=float(sampled["initial_glucose"][i]) if "initial_glucose" in sampled else base.initial_glucose,
|
|
119
|
+
insulin_action_duration=float(sampled["insulin_action_duration"][i]) if "insulin_action_duration" in sampled else base.insulin_action_duration,
|
|
120
|
+
dawn_phenomenon_strength=float(sampled["dawn_phenomenon_strength"][i]) if "dawn_phenomenon_strength" in sampled else base.dawn_phenomenon_strength,
|
|
121
|
+
# Non-varied parameters carry forward from the base profile
|
|
122
|
+
dawn_start_hour=base.dawn_start_hour,
|
|
123
|
+
dawn_end_hour=base.dawn_end_hour,
|
|
124
|
+
glucose_decay_rate=base.glucose_decay_rate,
|
|
125
|
+
glucose_absorption_rate=base.glucose_absorption_rate,
|
|
126
|
+
insulin_peak_time=base.insulin_peak_time,
|
|
127
|
+
meal_mismatch_epsilon=base.meal_mismatch_epsilon,
|
|
128
|
+
)
|
|
129
|
+
profiles.append(profile)
|
|
130
|
+
|
|
131
|
+
return profiles
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Population Runner — IINTS-AF
|
|
3
|
+
==============================
|
|
4
|
+
Runs N virtual patients through the simulator in parallel using
|
|
5
|
+
``concurrent.futures.ProcessPoolExecutor``. Each worker loads
|
|
6
|
+
the algorithm from a file path (same pattern as ``run-parallel``
|
|
7
|
+
in the CLI) so that all algorithm classes are safely picklable.
|
|
8
|
+
|
|
9
|
+
After all patients complete, aggregate clinical metrics and safety
|
|
10
|
+
indices are computed with 95 % confidence intervals.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import importlib
|
|
15
|
+
import importlib.util
|
|
16
|
+
import logging
|
|
17
|
+
import sys
|
|
18
|
+
import concurrent.futures
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Dict, List, Optional, Union
|
|
22
|
+
|
|
23
|
+
import numpy as np
|
|
24
|
+
import pandas as pd
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("iints.population")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Result dataclasses
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class PatientResult:
|
|
35
|
+
"""Result for a single patient in the population."""
|
|
36
|
+
patient_index: int
|
|
37
|
+
profile: Dict[str, Any]
|
|
38
|
+
clinical_metrics: Dict[str, float]
|
|
39
|
+
safety_index: Dict[str, Any]
|
|
40
|
+
safety_report: Dict[str, Any]
|
|
41
|
+
terminated_early: bool = False
|
|
42
|
+
error: Optional[str] = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class PopulationResult:
|
|
47
|
+
"""Aggregate result for the entire population run."""
|
|
48
|
+
n_patients: int
|
|
49
|
+
patient_results: List[PatientResult]
|
|
50
|
+
aggregate_metrics: Dict[str, Any]
|
|
51
|
+
aggregate_safety: Dict[str, Any]
|
|
52
|
+
summary_df: pd.DataFrame
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Worker function (must be top-level for pickling)
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def _load_algorithm_from_path(algo_path: Path):
|
|
60
|
+
"""Load an InsulinAlgorithm subclass from a .py file (worker-safe)."""
|
|
61
|
+
import iints
|
|
62
|
+
module_name = algo_path.stem
|
|
63
|
+
spec = importlib.util.spec_from_file_location(module_name, algo_path)
|
|
64
|
+
if spec is None:
|
|
65
|
+
raise ImportError(f"Could not load module spec for {algo_path}")
|
|
66
|
+
module = importlib.util.module_from_spec(spec)
|
|
67
|
+
module.iints = iints # type: ignore[attr-defined]
|
|
68
|
+
sys.modules[module_name] = module
|
|
69
|
+
if spec.loader:
|
|
70
|
+
spec.loader.exec_module(module)
|
|
71
|
+
else:
|
|
72
|
+
raise ImportError(f"Could not load module loader for {algo_path}")
|
|
73
|
+
for _, obj in module.__dict__.items():
|
|
74
|
+
if isinstance(obj, type) and issubclass(obj, iints.InsulinAlgorithm) and obj is not iints.InsulinAlgorithm:
|
|
75
|
+
return obj()
|
|
76
|
+
raise ImportError(f"No subclass of InsulinAlgorithm found in {algo_path}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _run_single_patient(job: Dict[str, Any]) -> Dict[str, Any]:
|
|
80
|
+
"""Run one patient simulation. Must be picklable (top-level function)."""
|
|
81
|
+
import iints
|
|
82
|
+
from iints.core.patient.models import PatientModel
|
|
83
|
+
from iints.core.simulator import Simulator
|
|
84
|
+
from iints.core.safety import SafetyConfig
|
|
85
|
+
from iints.analysis.clinical_metrics import ClinicalMetricsCalculator
|
|
86
|
+
from iints.analysis.safety_index import compute_safety_index
|
|
87
|
+
from iints.validation import build_stress_events
|
|
88
|
+
|
|
89
|
+
patient_index: int = job["patient_index"]
|
|
90
|
+
patient_config: Dict[str, Any] = job["patient_config"]
|
|
91
|
+
algo_path: Optional[str] = job.get("algo_path")
|
|
92
|
+
algo_class_name: Optional[str] = job.get("algo_class_name")
|
|
93
|
+
stress_event_payloads: List[Dict] = job.get("stress_event_payloads", [])
|
|
94
|
+
duration_minutes: int = job["duration_minutes"]
|
|
95
|
+
time_step: int = job["time_step"]
|
|
96
|
+
seed: int = job["seed"]
|
|
97
|
+
safety_config_dict: Optional[Dict] = job.get("safety_config_dict")
|
|
98
|
+
safety_weights: Optional[Dict[str, float]] = job.get("safety_weights")
|
|
99
|
+
patient_model_type: str = job.get("patient_model_type", "custom")
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
patient_model: Any
|
|
103
|
+
# --- Instantiate patient model ---
|
|
104
|
+
if patient_model_type == "bergman":
|
|
105
|
+
from iints.core.patient.bergman_model import BergmanPatientModel
|
|
106
|
+
patient_model = BergmanPatientModel(**patient_config)
|
|
107
|
+
else:
|
|
108
|
+
patient_model = PatientModel(**patient_config)
|
|
109
|
+
|
|
110
|
+
# --- Load algorithm ---
|
|
111
|
+
if algo_path:
|
|
112
|
+
algorithm_instance = _load_algorithm_from_path(Path(algo_path))
|
|
113
|
+
elif algo_class_name:
|
|
114
|
+
# Built-in algorithm by qualified class name
|
|
115
|
+
module_path, class_name = algo_class_name.rsplit(".", 1)
|
|
116
|
+
mod = importlib.import_module(module_path)
|
|
117
|
+
algo_cls = getattr(mod, class_name)
|
|
118
|
+
algorithm_instance = algo_cls()
|
|
119
|
+
else:
|
|
120
|
+
raise ValueError("Either algo_path or algo_class_name must be provided")
|
|
121
|
+
|
|
122
|
+
safety_config = SafetyConfig(**safety_config_dict) if safety_config_dict else SafetyConfig()
|
|
123
|
+
|
|
124
|
+
simulator = Simulator(
|
|
125
|
+
patient_model=patient_model,
|
|
126
|
+
algorithm=algorithm_instance,
|
|
127
|
+
time_step=time_step,
|
|
128
|
+
seed=seed,
|
|
129
|
+
safety_config=safety_config,
|
|
130
|
+
)
|
|
131
|
+
for event in build_stress_events(stress_event_payloads):
|
|
132
|
+
simulator.add_stress_event(event)
|
|
133
|
+
|
|
134
|
+
results_df, safety_report = simulator.run_batch(duration_minutes)
|
|
135
|
+
|
|
136
|
+
# --- Clinical metrics ---
|
|
137
|
+
calculator = ClinicalMetricsCalculator()
|
|
138
|
+
clinical = calculator.calculate(
|
|
139
|
+
glucose=results_df["glucose_actual_mgdl"],
|
|
140
|
+
duration_hours=duration_minutes / 60.0,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# --- Safety index ---
|
|
144
|
+
safety_idx = compute_safety_index(
|
|
145
|
+
results_df=results_df,
|
|
146
|
+
safety_report=safety_report,
|
|
147
|
+
duration_minutes=duration_minutes,
|
|
148
|
+
weights=safety_weights,
|
|
149
|
+
time_step_minutes=float(time_step),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
"patient_index": patient_index,
|
|
154
|
+
"profile": patient_config,
|
|
155
|
+
"clinical_metrics": clinical.to_dict(),
|
|
156
|
+
"safety_index": safety_idx.to_dict(),
|
|
157
|
+
"safety_report_summary": {
|
|
158
|
+
"total_violations": safety_report.get("total_violations", 0),
|
|
159
|
+
"bolus_interventions_count": safety_report.get("bolus_interventions_count", 0),
|
|
160
|
+
},
|
|
161
|
+
"terminated_early": safety_report.get("terminated_early", False),
|
|
162
|
+
"error": None,
|
|
163
|
+
}
|
|
164
|
+
except Exception as exc:
|
|
165
|
+
logger.warning("Patient %d failed: %s", patient_index, exc)
|
|
166
|
+
return {
|
|
167
|
+
"patient_index": patient_index,
|
|
168
|
+
"profile": patient_config,
|
|
169
|
+
"clinical_metrics": {},
|
|
170
|
+
"safety_index": {},
|
|
171
|
+
"safety_report_summary": {},
|
|
172
|
+
"terminated_early": False,
|
|
173
|
+
"error": str(exc),
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
# PopulationRunner
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
class PopulationRunner:
|
|
182
|
+
"""Runs a virtual patient population through the simulator in parallel."""
|
|
183
|
+
|
|
184
|
+
def __init__(
|
|
185
|
+
self,
|
|
186
|
+
algo_path: Optional[Union[str, Path]] = None,
|
|
187
|
+
algo_class_name: Optional[str] = None,
|
|
188
|
+
scenario_payloads: Optional[List[Dict[str, Any]]] = None,
|
|
189
|
+
duration_minutes: int = 720,
|
|
190
|
+
time_step: int = 5,
|
|
191
|
+
base_seed: int = 42,
|
|
192
|
+
max_workers: Optional[int] = None,
|
|
193
|
+
safety_config: Optional[Any] = None,
|
|
194
|
+
safety_weights: Optional[Dict[str, float]] = None,
|
|
195
|
+
patient_model_type: str = "custom",
|
|
196
|
+
):
|
|
197
|
+
if algo_path is None and algo_class_name is None:
|
|
198
|
+
raise ValueError("Provide either algo_path or algo_class_name")
|
|
199
|
+
self.algo_path = str(algo_path) if algo_path else None
|
|
200
|
+
self.algo_class_name = algo_class_name
|
|
201
|
+
self.scenario_payloads = scenario_payloads or []
|
|
202
|
+
self.duration_minutes = duration_minutes
|
|
203
|
+
self.time_step = time_step
|
|
204
|
+
self.base_seed = base_seed
|
|
205
|
+
self.max_workers = max_workers
|
|
206
|
+
self.safety_config = safety_config
|
|
207
|
+
self.safety_weights = safety_weights
|
|
208
|
+
self.patient_model_type = patient_model_type
|
|
209
|
+
|
|
210
|
+
def run(self, profiles) -> PopulationResult:
|
|
211
|
+
"""Run all patients and return aggregated results."""
|
|
212
|
+
from dataclasses import asdict
|
|
213
|
+
from iints.core.safety import SafetyConfig
|
|
214
|
+
|
|
215
|
+
safety_config_dict = asdict(self.safety_config) if self.safety_config else None
|
|
216
|
+
|
|
217
|
+
jobs = []
|
|
218
|
+
for i, profile in enumerate(profiles):
|
|
219
|
+
jobs.append({
|
|
220
|
+
"patient_index": i,
|
|
221
|
+
"patient_config": profile.to_patient_config(),
|
|
222
|
+
"algo_path": self.algo_path,
|
|
223
|
+
"algo_class_name": self.algo_class_name,
|
|
224
|
+
"stress_event_payloads": self.scenario_payloads,
|
|
225
|
+
"duration_minutes": self.duration_minutes,
|
|
226
|
+
"time_step": self.time_step,
|
|
227
|
+
"seed": self.base_seed + i,
|
|
228
|
+
"safety_config_dict": safety_config_dict,
|
|
229
|
+
"safety_weights": self.safety_weights,
|
|
230
|
+
"patient_model_type": self.patient_model_type,
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
raw_results: List[Dict[str, Any]] = []
|
|
234
|
+
|
|
235
|
+
with concurrent.futures.ProcessPoolExecutor(max_workers=self.max_workers) as executor:
|
|
236
|
+
future_map = {executor.submit(_run_single_patient, job): job for job in jobs}
|
|
237
|
+
for future in concurrent.futures.as_completed(future_map):
|
|
238
|
+
raw_results.append(future.result())
|
|
239
|
+
|
|
240
|
+
raw_results.sort(key=lambda r: r["patient_index"])
|
|
241
|
+
|
|
242
|
+
patient_results: List[PatientResult] = []
|
|
243
|
+
summary_rows: List[Dict[str, Any]] = []
|
|
244
|
+
|
|
245
|
+
for r in raw_results:
|
|
246
|
+
patient_results.append(PatientResult(
|
|
247
|
+
patient_index=r["patient_index"],
|
|
248
|
+
profile=r["profile"],
|
|
249
|
+
clinical_metrics=r.get("clinical_metrics", {}),
|
|
250
|
+
safety_index=r.get("safety_index", {}),
|
|
251
|
+
safety_report=r.get("safety_report_summary", {}),
|
|
252
|
+
terminated_early=r.get("terminated_early", False),
|
|
253
|
+
error=r.get("error"),
|
|
254
|
+
))
|
|
255
|
+
|
|
256
|
+
row: Dict[str, Any] = {"patient_index": r["patient_index"]}
|
|
257
|
+
row.update(r.get("profile", {}))
|
|
258
|
+
row.update(r.get("clinical_metrics", {}))
|
|
259
|
+
si = r.get("safety_index", {})
|
|
260
|
+
if si:
|
|
261
|
+
row["safety_index_score"] = si.get("safety_index")
|
|
262
|
+
row["safety_grade"] = si.get("grade")
|
|
263
|
+
row["terminated_early"] = r.get("terminated_early", False)
|
|
264
|
+
row["error"] = r.get("error", "")
|
|
265
|
+
summary_rows.append(row)
|
|
266
|
+
|
|
267
|
+
summary_df = pd.DataFrame(summary_rows)
|
|
268
|
+
|
|
269
|
+
return PopulationResult(
|
|
270
|
+
n_patients=len(profiles),
|
|
271
|
+
patient_results=patient_results,
|
|
272
|
+
aggregate_metrics=_compute_aggregate_metrics(summary_df),
|
|
273
|
+
aggregate_safety=_compute_aggregate_safety(summary_df),
|
|
274
|
+
summary_df=summary_df,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
# Aggregation helpers
|
|
280
|
+
# ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
_METRICS_OF_INTEREST = [
|
|
283
|
+
"tir_70_180", "tir_below_70", "tir_below_54",
|
|
284
|
+
"tir_above_180", "mean_glucose", "cv", "gmi",
|
|
285
|
+
]
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _compute_aggregate_metrics(df: pd.DataFrame) -> Dict[str, Any]:
|
|
289
|
+
"""Population-level stats with 95 % CI (percentile method)."""
|
|
290
|
+
agg: Dict[str, Any] = {}
|
|
291
|
+
for metric in _METRICS_OF_INTEREST:
|
|
292
|
+
if metric not in df.columns:
|
|
293
|
+
continue
|
|
294
|
+
values = df[metric].dropna().to_numpy(dtype=float)
|
|
295
|
+
if len(values) == 0:
|
|
296
|
+
continue
|
|
297
|
+
agg[metric] = {
|
|
298
|
+
"mean": float(np.mean(values)),
|
|
299
|
+
"median": float(np.median(values)),
|
|
300
|
+
"std": float(np.std(values, ddof=1)) if len(values) > 1 else 0.0,
|
|
301
|
+
"ci_lower": float(np.percentile(values, 2.5)),
|
|
302
|
+
"ci_upper": float(np.percentile(values, 97.5)),
|
|
303
|
+
"min": float(np.min(values)),
|
|
304
|
+
"max": float(np.max(values)),
|
|
305
|
+
}
|
|
306
|
+
return agg
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _compute_aggregate_safety(df: pd.DataFrame) -> Dict[str, Any]:
|
|
310
|
+
"""Population-level safety aggregates."""
|
|
311
|
+
agg: Dict[str, Any] = {}
|
|
312
|
+
if "safety_index_score" in df.columns:
|
|
313
|
+
scores = df["safety_index_score"].dropna().to_numpy(dtype=float)
|
|
314
|
+
if len(scores) > 0:
|
|
315
|
+
agg["safety_index"] = {
|
|
316
|
+
"mean": float(np.mean(scores)),
|
|
317
|
+
"median": float(np.median(scores)),
|
|
318
|
+
"std": float(np.std(scores, ddof=1)) if len(scores) > 1 else 0.0,
|
|
319
|
+
"ci_lower": float(np.percentile(scores, 2.5)),
|
|
320
|
+
"ci_upper": float(np.percentile(scores, 97.5)),
|
|
321
|
+
}
|
|
322
|
+
if "safety_grade" in df.columns:
|
|
323
|
+
grade_counts = df["safety_grade"].value_counts().to_dict()
|
|
324
|
+
agg["grade_distribution"] = {str(k): int(v) for k, v in grade_counts.items()}
|
|
325
|
+
if "terminated_early" in df.columns:
|
|
326
|
+
agg["early_termination_rate"] = float(df["terminated_early"].astype(float).mean())
|
|
327
|
+
return agg
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Built-in clinic-safe presets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any, Dict, List
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_presets() -> List[Dict[str, Any]]:
|
|
11
|
+
if sys.version_info >= (3, 9):
|
|
12
|
+
from importlib.resources import files
|
|
13
|
+
content = files("iints.presets").joinpath("presets.json").read_text()
|
|
14
|
+
else:
|
|
15
|
+
from importlib import resources
|
|
16
|
+
content = resources.read_text("iints.presets", "presets.json")
|
|
17
|
+
return json.loads(content)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_preset(name: str) -> Dict[str, Any]:
|
|
21
|
+
presets = load_presets()
|
|
22
|
+
for preset in presets:
|
|
23
|
+
if preset.get("name") == name:
|
|
24
|
+
return preset
|
|
25
|
+
raise KeyError(name)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
__all__ = ["load_presets", "get_preset"]
|