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,162 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Dict, Any, Optional
4
+
5
+ import numpy as np
6
+
7
+ from iints.api.base_algorithm import InsulinAlgorithm, AlgorithmInput, AlgorithmMetadata
8
+
9
+
10
+ class ConstantDoseAlgorithm(InsulinAlgorithm):
11
+ """Always returns a fixed insulin dose for CI and smoke testing."""
12
+
13
+ def __init__(self, dose: float = 0.5, settings: Optional[Dict[str, Any]] = None):
14
+ super().__init__(settings)
15
+ self.dose = max(0.0, float(dose))
16
+
17
+ def get_algorithm_metadata(self) -> AlgorithmMetadata:
18
+ return AlgorithmMetadata(
19
+ name="ConstantDoseAlgorithm",
20
+ version="1.0",
21
+ author="IINTS-AF SDK",
22
+ algorithm_type="Mock",
23
+ description="Always delivers a fixed insulin dose for CI testing.",
24
+ requires_training=False,
25
+ supported_scenarios=["baseline", "testing"],
26
+ )
27
+
28
+ def predict_insulin(self, data: AlgorithmInput) -> Dict[str, Any]:
29
+ return {
30
+ "total_insulin_delivered": self.dose,
31
+ "basal_insulin": self.dose,
32
+ "bolus_insulin": 0.0,
33
+ "meal_bolus": 0.0,
34
+ "correction_bolus": 0.0,
35
+ "uncertainty": 0.0,
36
+ "fallback_triggered": False,
37
+ }
38
+
39
+
40
+ class RandomDoseAlgorithm(InsulinAlgorithm):
41
+ """Returns a random dose within a safe range for stochastic testing."""
42
+
43
+ def __init__(self, max_dose: float = 1.0, seed: Optional[int] = None, settings: Optional[Dict[str, Any]] = None):
44
+ super().__init__(settings)
45
+ self.max_dose = max(0.0, float(max_dose))
46
+ self.rng = np.random.default_rng(seed)
47
+
48
+ def get_algorithm_metadata(self) -> AlgorithmMetadata:
49
+ return AlgorithmMetadata(
50
+ name="RandomDoseAlgorithm",
51
+ version="1.0",
52
+ author="IINTS-AF SDK",
53
+ algorithm_type="Mock",
54
+ description="Produces randomized doses for stress-testing and CI.",
55
+ requires_training=False,
56
+ supported_scenarios=["baseline", "testing"],
57
+ )
58
+
59
+ def predict_insulin(self, data: AlgorithmInput) -> Dict[str, Any]:
60
+ dose = float(self.rng.uniform(0.0, self.max_dose))
61
+ return {
62
+ "total_insulin_delivered": dose,
63
+ "basal_insulin": dose,
64
+ "bolus_insulin": 0.0,
65
+ "meal_bolus": 0.0,
66
+ "correction_bolus": 0.0,
67
+ "uncertainty": 0.0,
68
+ "fallback_triggered": False,
69
+ }
70
+
71
+
72
+ class RunawayAIAlgorithm(InsulinAlgorithm):
73
+ """Delivers a maximal bolus during glucose decline to stress-test supervisor."""
74
+
75
+ def __init__(
76
+ self,
77
+ max_bolus: float = 5.0,
78
+ trigger_glucose: float = 140.0,
79
+ settings: Optional[Dict[str, Any]] = None,
80
+ ):
81
+ super().__init__(settings)
82
+ self.max_bolus = max(0.0, float(max_bolus))
83
+ self.trigger_glucose = float(trigger_glucose)
84
+
85
+ def get_algorithm_metadata(self) -> AlgorithmMetadata:
86
+ return AlgorithmMetadata(
87
+ name="RunawayAIAlgorithm",
88
+ version="1.0",
89
+ author="IINTS-AF SDK",
90
+ algorithm_type="Chaos",
91
+ description="Forces max bolus during falling glucose to test safety supervisor.",
92
+ requires_training=False,
93
+ supported_scenarios=["chaos", "runaway_ai"],
94
+ )
95
+
96
+ def predict_insulin(self, data: AlgorithmInput) -> Dict[str, Any]:
97
+ trend = data.glucose_trend_mgdl_min
98
+ if data.current_glucose <= self.trigger_glucose or (trend is not None and trend < 0):
99
+ dose = self.max_bolus
100
+ else:
101
+ dose = 0.0
102
+ return {
103
+ "total_insulin_delivered": dose,
104
+ "basal_insulin": 0.0,
105
+ "bolus_insulin": dose,
106
+ "meal_bolus": 0.0,
107
+ "correction_bolus": dose,
108
+ "uncertainty": 0.0,
109
+ "fallback_triggered": False,
110
+ }
111
+
112
+
113
+ class StackingAIAlgorithm(InsulinAlgorithm):
114
+ """Delivers repeated boluses over consecutive steps to simulate stacking."""
115
+
116
+ def __init__(
117
+ self,
118
+ bolus_units: float = 4.0,
119
+ stack_steps: int = 3,
120
+ trigger_glucose: float = 180.0,
121
+ settings: Optional[Dict[str, Any]] = None,
122
+ ):
123
+ super().__init__(settings)
124
+ self.bolus_units = max(0.0, float(bolus_units))
125
+ self.stack_steps = max(1, int(stack_steps))
126
+ self.trigger_glucose = float(trigger_glucose)
127
+ self._remaining = 0
128
+
129
+ def get_algorithm_metadata(self) -> AlgorithmMetadata:
130
+ return AlgorithmMetadata(
131
+ name="StackingAIAlgorithm",
132
+ version="1.0",
133
+ author="IINTS-AF SDK",
134
+ algorithm_type="Chaos",
135
+ description="Stacks multiple boluses across consecutive steps.",
136
+ requires_training=False,
137
+ supported_scenarios=["chaos", "stacking"],
138
+ )
139
+
140
+ def reset(self) -> None:
141
+ super().reset()
142
+ self._remaining = 0
143
+
144
+ def predict_insulin(self, data: AlgorithmInput) -> Dict[str, Any]:
145
+ if self._remaining <= 0 and data.current_glucose >= self.trigger_glucose:
146
+ self._remaining = self.stack_steps
147
+
148
+ if self._remaining > 0:
149
+ dose = self.bolus_units
150
+ self._remaining -= 1
151
+ else:
152
+ dose = 0.0
153
+
154
+ return {
155
+ "total_insulin_delivered": dose,
156
+ "basal_insulin": 0.0,
157
+ "bolus_insulin": dose,
158
+ "meal_bolus": 0.0,
159
+ "correction_bolus": dose,
160
+ "uncertainty": 0.0,
161
+ "fallback_triggered": False,
162
+ }
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Industry-Standard PID Controller - IINTS-AF
4
+ Simple PID implementation for algorithm comparison
5
+ """
6
+
7
+ import numpy as np
8
+ from iints.api.base_algorithm import InsulinAlgorithm, AlgorithmInput
9
+
10
+ class PIDController(InsulinAlgorithm):
11
+ """Industry-standard PID controller for glucose management"""
12
+
13
+ def __init__(self):
14
+ super().__init__()
15
+
16
+ # PID parameters (tuned for glucose control)
17
+ self.kp = 0.1 # Proportional gain
18
+ self.ki = 0.01 # Integral gain
19
+ self.kd = 0.05 # Derivative gain
20
+
21
+ # Controller state
22
+ self.integral = 0
23
+ self.previous_error = 0
24
+ self.target_glucose = 120 # mg/dL target
25
+
26
+ # Safety limits
27
+ self.max_insulin = 5.0 # Maximum insulin dose
28
+ self.min_insulin = 0.0 # Minimum insulin dose
29
+
30
+ def predict_insulin(self, data: AlgorithmInput):
31
+ self.why_log = [] # Clear the log for this prediction cycle
32
+
33
+ # Calculate error from target
34
+ error = data.current_glucose - self.target_glucose
35
+ self._log_reason(f"Glucose error from target ({self.target_glucose} mg/dL)", "glucose_level", error, f"Current glucose: {data.current_glucose:.0f} mg/dL")
36
+
37
+ # Integral term (accumulated error)
38
+ self.integral += error
39
+ self._log_reason("Integral term updated", "control_parameter", self.integral)
40
+
41
+ # Derivative term (rate of change)
42
+ derivative = error - self.previous_error
43
+ self._log_reason("Derivative term calculated", "control_parameter", derivative)
44
+
45
+ # PID formula
46
+ insulin_dose = (self.kp * error +
47
+ self.ki * self.integral +
48
+ self.kd * derivative)
49
+ self._log_reason("Initial insulin dose calculated by PID", "insulin_calculation", insulin_dose)
50
+
51
+ # Update previous error for next iteration
52
+ self.previous_error = error
53
+
54
+ # Apply safety constraints
55
+ original_insulin_dose = insulin_dose
56
+ insulin_dose = max(self.min_insulin, min(insulin_dose, self.max_insulin))
57
+ if insulin_dose != original_insulin_dose:
58
+ self._log_reason(f"Insulin dose adjusted due to safety limits (min: {self.min_insulin}, max: {self.max_insulin})",
59
+ "safety_constraint",
60
+ f"Original: {original_insulin_dose:.2f}, Adjusted: {insulin_dose:.2f}")
61
+ else:
62
+ self._log_reason("Insulin dose within safety limits", "safety_constraint", insulin_dose)
63
+
64
+ return {
65
+ 'total_insulin_delivered': insulin_dose,
66
+ 'bolus_insulin': insulin_dose,
67
+ 'basal_insulin': 0
68
+ }
69
+
70
+ def reset(self):
71
+ """Reset controller state for new patient"""
72
+ super().reset()
73
+ self.integral = 0
74
+ self.previous_error = 0
75
+
76
+ def get_algorithm_info(self):
77
+ """Return algorithm information"""
78
+ return {
79
+ 'name': 'Industry PID Controller',
80
+ 'type': 'Classical Control',
81
+ 'parameters': {
82
+ 'kp': self.kp,
83
+ 'ki': self.ki,
84
+ 'kd': self.kd,
85
+ 'target_glucose': self.target_glucose
86
+ },
87
+ 'description': 'Industry-standard PID controller for glucose regulation'
88
+ }
@@ -0,0 +1,64 @@
1
+ from iints import InsulinAlgorithm, AlgorithmInput, AlgorithmMetadata
2
+ from typing import Dict, Any, Optional
3
+
4
+ class StandardPumpAlgorithm(InsulinAlgorithm):
5
+ """
6
+ A simplified algorithm representing a standard insulin pump.
7
+ It delivers a fixed basal rate and a simple bolus based on carbs,
8
+ with minimal correction for high glucose.
9
+ """
10
+ def __init__(self, settings: Optional[Dict[str, Any]] = None):
11
+ super().__init__(settings)
12
+ self.set_algorithm_metadata(AlgorithmMetadata(
13
+ name="Standard Pump",
14
+ author="IINTS-AF Team",
15
+ description="A basic insulin pump algorithm with fixed basal and simple carb/correction bolus.",
16
+ algorithm_type="rule_based"
17
+ ))
18
+ # Default settings for a standard pump
19
+ self.isf = self.settings.get('isf', 50.0) # Insulin Sensitivity Factor (mg/dL per Unit)
20
+ self.icr = self.settings.get('icr', 10.0) # Insulin to Carb Ratio (grams per Unit)
21
+ self.basal_rate_per_hour = self.settings.get('basal_rate_per_hour', 0.8) # U/hr
22
+ self.target_glucose = self.settings.get('target_glucose', 100.0) # mg/dL
23
+
24
+ def predict_insulin(self, data: AlgorithmInput) -> Dict[str, Any]:
25
+ self.why_log = [] # Clear log for each prediction
26
+
27
+ total_insulin = 0.0
28
+ bolus_insulin = 0.0
29
+ basal_insulin = 0.0
30
+ correction_bolus = 0.0
31
+ meal_bolus = 0.0
32
+
33
+ # 1. Basal Insulin
34
+ # Convert hourly basal rate to dose for the current time step
35
+ basal_insulin = (self.basal_rate_per_hour / 60.0) * data.time_step
36
+ total_insulin += basal_insulin
37
+ self._log_reason("Fixed basal insulin", "basal", basal_insulin, f"Delivered {basal_insulin:.2f} units for {data.time_step} min.")
38
+
39
+ # 2. Meal Bolus
40
+ if data.carb_intake > 0:
41
+ meal_bolus = data.carb_intake / self.icr
42
+ total_insulin += meal_bolus
43
+ self._log_reason("Meal bolus for carb intake", "carb_intake", data.carb_intake, f"Delivered {meal_bolus:.2f} units for {data.carb_intake}g carbs.")
44
+
45
+ # 3. Correction Bolus (simple)
46
+ # Only correct if glucose is significantly above target and not too much IOB
47
+ if data.current_glucose > self.target_glucose + 20 and data.insulin_on_board < 1.0:
48
+ correction_bolus = (data.current_glucose - self.target_glucose) / self.isf / 2 # Correct half the difference
49
+ if correction_bolus > 0:
50
+ total_insulin += correction_bolus
51
+ self._log_reason("Correction bolus for high glucose", "glucose_level", data.current_glucose, f"Delivered {correction_bolus:.2f} units to correct {data.current_glucose} mg/dL.")
52
+
53
+ # Ensure no negative insulin delivery
54
+ total_insulin = max(0.0, total_insulin)
55
+
56
+ self._log_reason(f"Final insulin decision: {total_insulin:.2f} units", "decision", total_insulin)
57
+
58
+ return {
59
+ "total_insulin_delivered": total_insulin,
60
+ "bolus_insulin": bolus_insulin,
61
+ "basal_insulin": basal_insulin,
62
+ "correction_bolus": correction_bolus,
63
+ "meal_bolus": meal_bolus,
64
+ }
iints/core/device.py ADDED
File without changes
@@ -0,0 +1,64 @@
1
+ import sys
2
+ from typing import Optional
3
+
4
+ try:
5
+ import torch # type: ignore
6
+ _TORCH_AVAILABLE = True
7
+ except Exception: # pragma: no cover - handles missing torch in CI
8
+ torch = None # type: ignore
9
+ _TORCH_AVAILABLE = False
10
+
11
+
12
+ class _FallbackDevice:
13
+ def __init__(self, name: str = "cpu") -> None:
14
+ self.type = name
15
+
16
+ def __str__(self) -> str:
17
+ return self.type
18
+
19
+ def __repr__(self) -> str:
20
+ return f"Device({self.type})"
21
+
22
+ class DeviceManager:
23
+ """
24
+ Manages hardware device detection for cross-platform compatibility.
25
+ Detects MPS (Apple Silicon), CUDA (NVIDIA GPUs), or falls back to CPU.
26
+ """
27
+ def __init__(self):
28
+ self._device = self._detect_device()
29
+
30
+ def _detect_device(self):
31
+ if not _TORCH_AVAILABLE:
32
+ print("Torch not available. Falling back to CPU.")
33
+ return _FallbackDevice("cpu")
34
+
35
+ if sys.platform == "darwin" and torch.backends.mps.is_available():
36
+ print("Detected Apple Silicon (MPS) for accelerated computing.")
37
+ return torch.device("mps")
38
+ elif torch.cuda.is_available():
39
+ print(f"Detected NVIDIA GPU (CUDA) with {torch.cuda.device_count()} device(s) for accelerated computing.")
40
+ return torch.device("cuda")
41
+ else:
42
+ print("No GPU detected. Falling back to CPU for computing.")
43
+ return torch.device("cpu")
44
+
45
+ def get_device(self):
46
+ """
47
+ Returns the detected torch.device object.
48
+ """
49
+ return self._device
50
+
51
+ # Example usage (for testing purposes, remove in final SDK if not needed)
52
+ if __name__ == "__main__":
53
+ device_manager = DeviceManager()
54
+ device = device_manager.get_device()
55
+ print(f"Using device: {device}")
56
+
57
+ # Small test to ensure device is working
58
+ try:
59
+ x = torch.randn(10, 10, device=device)
60
+ y = torch.randn(10, 10, device=device)
61
+ z = x @ y
62
+ print(f"Successfully performed a matrix multiplication on {device}.")
63
+ except Exception as e:
64
+ print(f"Error performing test on {device}: {e}")
@@ -0,0 +1,3 @@
1
+ from .models import SensorModel, PumpModel
2
+
3
+ __all__ = ["SensorModel", "PumpModel"]
@@ -0,0 +1,160 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Optional, Dict, Any, Tuple
5
+
6
+ import numpy as np
7
+
8
+
9
+ @dataclass
10
+ class SensorReading:
11
+ value: float
12
+ status: str
13
+
14
+
15
+ class SensorModel:
16
+ """
17
+ Sensor error model for CGM readings.
18
+
19
+ Supports noise, bias, lag (minutes), and dropout (hold last value).
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ noise_std: float = 0.0,
25
+ bias: float = 0.0,
26
+ lag_minutes: int = 0,
27
+ dropout_prob: float = 0.0,
28
+ seed: Optional[int] = None,
29
+ ) -> None:
30
+ self.noise_std = noise_std
31
+ self.bias = bias
32
+ self.lag_minutes = lag_minutes
33
+ self.dropout_prob = dropout_prob
34
+ self._rng = np.random.default_rng(seed)
35
+ self._history: list[tuple[float, float]] = []
36
+ self._last_reading: Optional[float] = None
37
+
38
+ def reset(self) -> None:
39
+ self._history = []
40
+ self._last_reading = None
41
+
42
+ def read(self, true_glucose: float, current_time: float) -> SensorReading:
43
+ self._history.append((current_time, true_glucose))
44
+ # Keep history window bounded
45
+ if self.lag_minutes > 0:
46
+ cutoff = current_time - (self.lag_minutes * 2)
47
+ self._history = [(t, v) for (t, v) in self._history if t >= cutoff]
48
+
49
+ if self.lag_minutes > 0:
50
+ target_time = current_time - self.lag_minutes
51
+ candidates = [v for (t, v) in self._history if t <= target_time]
52
+ base = candidates[-1] if candidates else true_glucose
53
+ else:
54
+ base = true_glucose
55
+
56
+ reading = base + self.bias
57
+ if self.noise_std > 0:
58
+ reading += float(self._rng.normal(0, self.noise_std))
59
+
60
+ status = "ok"
61
+ if self.dropout_prob > 0 and float(self._rng.random()) < self.dropout_prob:
62
+ status = "dropout_hold"
63
+ if self._last_reading is not None:
64
+ reading = self._last_reading
65
+
66
+ self._last_reading = reading
67
+ return SensorReading(value=reading, status=status)
68
+
69
+ def get_state(self) -> Dict[str, Any]:
70
+ return {
71
+ "noise_std": self.noise_std,
72
+ "bias": self.bias,
73
+ "lag_minutes": self.lag_minutes,
74
+ "dropout_prob": self.dropout_prob,
75
+ "last_reading": self._last_reading,
76
+ "history": self._history,
77
+ }
78
+
79
+ def set_state(self, state: Dict[str, Any]) -> None:
80
+ self.noise_std = state.get("noise_std", self.noise_std)
81
+ self.bias = state.get("bias", self.bias)
82
+ self.lag_minutes = state.get("lag_minutes", self.lag_minutes)
83
+ self.dropout_prob = state.get("dropout_prob", self.dropout_prob)
84
+ self._last_reading = state.get("last_reading")
85
+ self._history = state.get("history", [])
86
+
87
+
88
+ @dataclass
89
+ class PumpDelivery:
90
+ delivered_units: float
91
+ status: str
92
+ reason: str
93
+
94
+
95
+ class PumpModel:
96
+ """
97
+ Pump error model for insulin delivery.
98
+
99
+ Supports max delivery per step, quantization, and occlusion/dropout.
100
+ """
101
+
102
+ def __init__(
103
+ self,
104
+ max_units_per_step: Optional[float] = None,
105
+ quantization_units: Optional[float] = None,
106
+ dropout_prob: float = 0.0,
107
+ delivery_noise_std: float = 0.0,
108
+ seed: Optional[int] = None,
109
+ ) -> None:
110
+ self.max_units_per_step = max_units_per_step
111
+ self.quantization_units = quantization_units
112
+ self.dropout_prob = dropout_prob
113
+ self.delivery_noise_std = delivery_noise_std
114
+ self._rng = np.random.default_rng(seed)
115
+
116
+ def reset(self) -> None:
117
+ pass
118
+
119
+ def deliver(self, requested_units: float, time_step_minutes: float) -> PumpDelivery:
120
+ delivered = requested_units
121
+ status = "ok"
122
+ reason = "approved"
123
+
124
+ if delivered < 0.0:
125
+ delivered = 0.0
126
+ status = "clamped"
127
+ reason = "negative_request"
128
+
129
+ if self.max_units_per_step is not None and delivered > self.max_units_per_step:
130
+ delivered = self.max_units_per_step
131
+ status = "capped"
132
+ reason = f"max_units_per_step {self.max_units_per_step:.2f}"
133
+
134
+ if self.quantization_units:
135
+ delivered = round(delivered / self.quantization_units) * self.quantization_units
136
+
137
+ if self.delivery_noise_std > 0:
138
+ delivered += float(self._rng.normal(0, self.delivery_noise_std))
139
+ delivered = max(0.0, delivered)
140
+
141
+ if self.dropout_prob > 0 and float(self._rng.random()) < self.dropout_prob:
142
+ delivered = 0.0
143
+ status = "occlusion"
144
+ reason = "pump_dropout"
145
+
146
+ return PumpDelivery(delivered_units=delivered, status=status, reason=reason)
147
+
148
+ def get_state(self) -> Dict[str, Any]:
149
+ return {
150
+ "max_units_per_step": self.max_units_per_step,
151
+ "quantization_units": self.quantization_units,
152
+ "dropout_prob": self.dropout_prob,
153
+ "delivery_noise_std": self.delivery_noise_std,
154
+ }
155
+
156
+ def set_state(self, state: Dict[str, Any]) -> None:
157
+ self.max_units_per_step = state.get("max_units_per_step", self.max_units_per_step)
158
+ self.quantization_units = state.get("quantization_units", self.quantization_units)
159
+ self.dropout_prob = state.get("dropout_prob", self.dropout_prob)
160
+ self.delivery_noise_std = state.get("delivery_noise_std", self.delivery_noise_std)
@@ -0,0 +1,9 @@
1
+ from .profile import PatientProfile
2
+ from .models import PatientModel
3
+
4
+ try:
5
+ from .bergman_model import BergmanPatientModel
6
+ except ImportError: # pragma: no cover - scipy may not be installed
7
+ BergmanPatientModel = None # type: ignore[assignment,misc]
8
+
9
+ __all__ = ["PatientProfile", "PatientModel", "BergmanPatientModel"]