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,188 @@
1
+ """
2
+ Population Report Generator — IINTS-AF
3
+ ========================================
4
+ Generates a PDF report with aggregate statistics and visualisations
5
+ for a Monte Carlo population evaluation run.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from pathlib import Path
11
+ from typing import Any, Dict, List
12
+
13
+ os.environ.setdefault("MPLBACKEND", "Agg")
14
+
15
+ import matplotlib.pyplot as plt
16
+ import numpy as np
17
+ import pandas as pd
18
+ from fpdf import FPDF
19
+
20
+ from iints.utils.plotting import apply_plot_style, IINTS_BLUE, IINTS_RED, IINTS_TEAL
21
+
22
+
23
+ class PopulationReportGenerator:
24
+ """Generate a PDF report for population simulation results."""
25
+
26
+ def generate_pdf(
27
+ self,
28
+ summary_df: pd.DataFrame,
29
+ aggregate_metrics: Dict[str, Any],
30
+ aggregate_safety: Dict[str, Any],
31
+ output_path: str,
32
+ title: str = "IINTS-AF Population Evaluation Report",
33
+ ) -> str:
34
+ output_dir = Path(output_path).parent
35
+ output_dir.mkdir(parents=True, exist_ok=True)
36
+
37
+ plots = self._generate_plots(summary_df, output_dir)
38
+
39
+ pdf = FPDF()
40
+ pdf.set_auto_page_break(auto=True, margin=15)
41
+
42
+ # --- Title page ---
43
+ pdf.add_page()
44
+ pdf.set_font("Helvetica", "B", 18)
45
+ pdf.cell(0, 12, title, new_x="LMARGIN", new_y="NEXT")
46
+ pdf.ln(4)
47
+
48
+ pdf.set_font("Helvetica", "", 11)
49
+ pdf.cell(0, 7, f"Population size: {len(summary_df)}", new_x="LMARGIN", new_y="NEXT")
50
+
51
+ # --- Aggregate clinical metrics ---
52
+ pdf.ln(4)
53
+ pdf.set_font("Helvetica", "B", 13)
54
+ pdf.cell(0, 9, "Aggregate Clinical Metrics (95% CI)", new_x="LMARGIN", new_y="NEXT")
55
+ pdf.set_font("Helvetica", "", 9)
56
+
57
+ _METRIC_LABELS = {
58
+ "tir_70_180": "TIR 70-180 mg/dL (%)",
59
+ "tir_below_70": "Time <70 mg/dL (%)",
60
+ "tir_below_54": "Time <54 mg/dL (%)",
61
+ "tir_above_180": "Time >180 mg/dL (%)",
62
+ "mean_glucose": "Mean Glucose (mg/dL)",
63
+ "cv": "Coefficient of Variation (%)",
64
+ "gmi": "Glucose Management Indicator (%)",
65
+ }
66
+
67
+ for key, stats in aggregate_metrics.items():
68
+ label = _METRIC_LABELS.get(key, key)
69
+ line = f" {label}: {stats['mean']:.1f} [{stats['ci_lower']:.1f}, {stats['ci_upper']:.1f}]"
70
+ pdf.cell(0, 6, line, new_x="LMARGIN", new_y="NEXT")
71
+
72
+ # --- Safety summary ---
73
+ pdf.ln(4)
74
+ pdf.set_font("Helvetica", "B", 13)
75
+ pdf.cell(0, 9, "Population Safety Summary", new_x="LMARGIN", new_y="NEXT")
76
+ pdf.set_font("Helvetica", "", 9)
77
+
78
+ si = aggregate_safety.get("safety_index", {})
79
+ if si:
80
+ pdf.cell(
81
+ 0, 6,
82
+ f" Safety Index: {si['mean']:.1f} [{si['ci_lower']:.1f}, {si['ci_upper']:.1f}]",
83
+ new_x="LMARGIN", new_y="NEXT",
84
+ )
85
+
86
+ grade_dist = aggregate_safety.get("grade_distribution", {})
87
+ if grade_dist:
88
+ n = len(summary_df)
89
+ for grade in sorted(grade_dist):
90
+ count = grade_dist[grade]
91
+ pct = count / n * 100 if n else 0
92
+ pdf.cell(0, 6, f" Grade {grade}: {count} ({pct:.1f}%)", new_x="LMARGIN", new_y="NEXT")
93
+
94
+ etr = aggregate_safety.get("early_termination_rate")
95
+ if etr is not None:
96
+ pdf.cell(0, 6, f" Early termination rate: {etr * 100:.1f}%", new_x="LMARGIN", new_y="NEXT")
97
+
98
+ # --- Plots ---
99
+ for plot_label, plot_path in plots.items():
100
+ if Path(plot_path).exists():
101
+ pdf.add_page()
102
+ pdf.set_font("Helvetica", "B", 12)
103
+ pdf.cell(0, 9, plot_label, new_x="LMARGIN", new_y="NEXT")
104
+ pdf.image(plot_path, x=10, w=190)
105
+
106
+ pdf.output(output_path)
107
+ return output_path
108
+
109
+ # ------------------------------------------------------------------
110
+ def _generate_plots(self, df: pd.DataFrame, output_dir: Path) -> Dict[str, str]:
111
+ try:
112
+ apply_plot_style()
113
+ except ImportError:
114
+ pass
115
+
116
+ plots: Dict[str, str] = {}
117
+
118
+ # 1. TIR distribution
119
+ if "tir_70_180" in df.columns:
120
+ path = str(output_dir / "_plot_tir_distribution.png")
121
+ fig, ax = plt.subplots(figsize=(8, 4))
122
+ ax.hist(df["tir_70_180"].dropna(), bins=30, color=IINTS_BLUE, edgecolor="white", alpha=0.85)
123
+ ax.axvline(70, color=IINTS_RED, linestyle="--", linewidth=1.4, label="Target: 70 %")
124
+ mean_tir = df["tir_70_180"].mean()
125
+ ax.axvline(mean_tir, color=IINTS_TEAL, linestyle="-", linewidth=1.4, label=f"Mean: {mean_tir:.1f} %")
126
+ ax.set_xlabel("TIR 70-180 (%)")
127
+ ax.set_ylabel("Patient count")
128
+ ax.set_title("Time-in-Range Distribution Across Population")
129
+ ax.legend()
130
+ fig.tight_layout()
131
+ fig.savefig(path, dpi=150)
132
+ plt.close(fig)
133
+ plots["TIR Distribution"] = path
134
+
135
+ # 2. Safety index distribution
136
+ if "safety_index_score" in df.columns:
137
+ path = str(output_dir / "_plot_safety_index.png")
138
+ scores = df["safety_index_score"].dropna()
139
+ if len(scores) > 0:
140
+ fig, ax = plt.subplots(figsize=(8, 4))
141
+ ax.hist(scores, bins=30, color=IINTS_BLUE, edgecolor="white", alpha=0.85)
142
+ ax.axvline(75, color="#f4a261", linestyle="--", linewidth=1.4, label="Grade B (75)")
143
+ ax.axvline(90, color=IINTS_TEAL, linestyle="--", linewidth=1.4, label="Grade A (90)")
144
+ ax.set_xlabel("Safety Index Score")
145
+ ax.set_ylabel("Patient count")
146
+ ax.set_title("Safety Index Distribution Across Population")
147
+ ax.legend()
148
+ fig.tight_layout()
149
+ fig.savefig(path, dpi=150)
150
+ plt.close(fig)
151
+ plots["Safety Index Distribution"] = path
152
+
153
+ # 3. ISF vs TIR scatter
154
+ if "insulin_sensitivity" in df.columns and "tir_70_180" in df.columns:
155
+ path = str(output_dir / "_plot_isf_vs_tir.png")
156
+ fig, ax = plt.subplots(figsize=(8, 4))
157
+ ax.scatter(
158
+ df["insulin_sensitivity"], df["tir_70_180"],
159
+ alpha=0.45, s=18, color=IINTS_BLUE, edgecolors="none",
160
+ )
161
+ ax.set_xlabel("ISF (mg/dL per Unit)")
162
+ ax.set_ylabel("TIR 70-180 (%)")
163
+ ax.set_title("Insulin Sensitivity vs Time-in-Range")
164
+ fig.tight_layout()
165
+ fig.savefig(path, dpi=150)
166
+ plt.close(fig)
167
+ plots["ISF vs TIR"] = path
168
+
169
+ # 4. Hypo-risk box plot
170
+ hypo_cols = [c for c in ["tir_below_70", "tir_below_54"] if c in df.columns]
171
+ if hypo_cols:
172
+ path = str(output_dir / "_plot_hypo_risk.png")
173
+ fig, ax = plt.subplots(figsize=(6, 4))
174
+ data = [df[c].dropna().astype(float).to_numpy() for c in hypo_cols]
175
+ labels = ["TBR <70 mg/dL (%)", "TBR <54 mg/dL (%)"][:len(data)]
176
+ bp = ax.boxplot(data, patch_artist=True)
177
+ ax.set_xticklabels(labels)
178
+ for patch, color in zip(bp["boxes"], [IINTS_BLUE, IINTS_RED]):
179
+ patch.set_facecolor(color)
180
+ patch.set_alpha(0.6)
181
+ ax.set_ylabel("Percentage of time")
182
+ ax.set_title("Hypoglycaemia Risk Distribution")
183
+ fig.tight_layout()
184
+ fig.savefig(path, dpi=150)
185
+ plt.close(fig)
186
+ plots["Hypoglycaemia Risk"] = path
187
+
188
+ return plots
@@ -0,0 +1,345 @@
1
+ import os
2
+ import tempfile
3
+ from pathlib import Path
4
+ from typing import Any, Dict, Optional
5
+
6
+ os.environ.setdefault("MPLBACKEND", "Agg")
7
+
8
+ import matplotlib.pyplot as plt
9
+ import numpy as np
10
+ import pandas as pd
11
+ from fpdf import FPDF
12
+ from fpdf.enums import XPos, YPos
13
+
14
+ from iints.analysis.clinical_metrics import ClinicalMetricsCalculator
15
+ from iints.utils.plotting import apply_plot_style
16
+
17
+
18
+ class ClinicalReportGenerator:
19
+ """Generate a clean, publication-ready PDF report."""
20
+
21
+ def __init__(self) -> None:
22
+ self.metrics_calculator = ClinicalMetricsCalculator()
23
+
24
+ def _resolve_logo_path(self) -> Optional[Path]:
25
+ candidates = []
26
+ # Package asset (installed)
27
+ candidates.append(Path(__file__).resolve().parent.parent / "assets" / "iints_logo.png")
28
+ # Repo root img/ (dev)
29
+ candidates.append(Path(__file__).resolve().parents[3] / "img" / "iints_logo.png")
30
+ for path in candidates:
31
+ if path.exists():
32
+ return path
33
+ return None
34
+
35
+ def _render_logo(self, pdf: FPDF) -> None:
36
+ logo_path = self._resolve_logo_path()
37
+ if not logo_path:
38
+ return
39
+ try:
40
+ logo_width = 36
41
+ x_pos = pdf.w - pdf.r_margin - logo_width
42
+ y_pos = 6
43
+ pdf.image(str(logo_path), x=x_pos, y=y_pos, w=logo_width)
44
+ except Exception:
45
+ # Fallback silently if image fails to load
46
+ return
47
+
48
+ def _plot_glucose(self, df: pd.DataFrame, output_path: Path) -> None:
49
+ apply_plot_style()
50
+ plt.figure(figsize=(10, 4))
51
+ plt.plot(df["time_minutes"], df["glucose_actual_mgdl"], color="#2e7d32", linewidth=1.8)
52
+ plt.axhspan(70, 180, alpha=0.12, color="#4caf50", label="Target 70-180")
53
+ plt.axhline(70, color="#d32f2f", linestyle="--", linewidth=1)
54
+ plt.axhline(180, color="#f57c00", linestyle="--", linewidth=1)
55
+ plt.xlabel("Time (minutes)")
56
+ plt.ylabel("Glucose (mg/dL)")
57
+ plt.title("Glucose Trace")
58
+ plt.tight_layout()
59
+ plt.savefig(output_path, dpi=160)
60
+ plt.close()
61
+
62
+ def _plot_insulin(self, df: pd.DataFrame, output_path: Path) -> None:
63
+ apply_plot_style()
64
+ plt.figure(figsize=(10, 3))
65
+ plt.bar(df["time_minutes"], df["delivered_insulin_units"], width=4, color="#1976d2", alpha=0.7)
66
+ plt.ylim(bottom=0)
67
+ plt.xlabel("Time (minutes)")
68
+ plt.ylabel("Insulin (U)")
69
+ plt.title("Delivered Insulin")
70
+ plt.tight_layout()
71
+ plt.savefig(output_path, dpi=160)
72
+ plt.close()
73
+
74
+ def export_plots(self, simulation_data: pd.DataFrame, output_dir: str) -> Dict[str, str]:
75
+ output_path = Path(output_dir)
76
+ output_path.mkdir(parents=True, exist_ok=True)
77
+ glucose_plot = output_path / "glucose.png"
78
+ insulin_plot = output_path / "insulin.png"
79
+ self._plot_glucose(simulation_data, glucose_plot)
80
+ self._plot_insulin(simulation_data, insulin_plot)
81
+ return {
82
+ "glucose_plot": str(glucose_plot),
83
+ "insulin_plot": str(insulin_plot),
84
+ }
85
+
86
+ def _top_safety_reasons(self, df: pd.DataFrame, limit: int = 3) -> Dict[str, int]:
87
+ if "safety_reason" not in df.columns:
88
+ return {}
89
+ if "safety_triggered" in df.columns:
90
+ filtered = df[df["safety_triggered"] == True]
91
+ else:
92
+ filtered = df
93
+
94
+ reasons: Dict[str, int] = {}
95
+ for reason in filtered["safety_reason"].dropna():
96
+ if not reason:
97
+ continue
98
+ for entry in str(reason).split(";"):
99
+ label = entry.strip().split(":")[0].strip()
100
+ if not label:
101
+ continue
102
+ reasons[label] = reasons.get(label, 0) + 1
103
+
104
+ if not reasons:
105
+ return {}
106
+
107
+ sorted_reasons = sorted(reasons.items(), key=lambda item: item[1], reverse=True)
108
+ return dict(sorted_reasons[:limit])
109
+
110
+ def generate_pdf(
111
+ self,
112
+ simulation_data: pd.DataFrame,
113
+ safety_report: Dict[str, Any],
114
+ output_path: str,
115
+ title: str = "IINTS-AF Clinical Report",
116
+ ) -> str:
117
+ output_file = Path(output_path)
118
+ output_file.parent.mkdir(parents=True, exist_ok=True)
119
+
120
+ metrics = self.metrics_calculator.calculate(
121
+ glucose=simulation_data["glucose_actual_mgdl"],
122
+ duration_hours=(simulation_data["time_minutes"].max() / 60.0),
123
+ ).to_dict()
124
+
125
+ with tempfile.TemporaryDirectory() as tmp_dir:
126
+ tmp_dir_path = Path(tmp_dir)
127
+ glucose_plot = tmp_dir_path / "glucose.png"
128
+ insulin_plot = tmp_dir_path / "insulin.png"
129
+ self._plot_glucose(simulation_data, glucose_plot)
130
+ self._plot_insulin(simulation_data, insulin_plot)
131
+
132
+ pdf = FPDF()
133
+ pdf.set_auto_page_break(auto=True, margin=12)
134
+ pdf.add_page()
135
+ self._render_logo(pdf)
136
+
137
+ pdf.set_font("Helvetica", "B", 16)
138
+ pdf.cell(0, 10, title, new_x=XPos.LMARGIN, new_y=YPos.NEXT)
139
+
140
+ pdf.set_font("Helvetica", "", 11)
141
+ pdf.cell(
142
+ 0,
143
+ 7,
144
+ f"Duration: {simulation_data['time_minutes'].max()/60:.1f} hours",
145
+ new_x=XPos.LMARGIN,
146
+ new_y=YPos.NEXT,
147
+ )
148
+ pdf.cell(0, 7, f"Data points: {len(simulation_data)}", new_x=XPos.LMARGIN, new_y=YPos.NEXT)
149
+
150
+ pdf.ln(3)
151
+ pdf.set_font("Helvetica", "B", 12)
152
+ pdf.cell(0, 8, "Clinical Metrics", new_x=XPos.LMARGIN, new_y=YPos.NEXT)
153
+ pdf.set_font("Helvetica", "", 10)
154
+ pdf.cell(
155
+ 0,
156
+ 6,
157
+ f"TIR (70-180): {metrics.get('tir_70_180', 0):.1f}%",
158
+ new_x=XPos.LMARGIN,
159
+ new_y=YPos.NEXT,
160
+ )
161
+ pdf.cell(
162
+ 0,
163
+ 6,
164
+ f"Time <70: {metrics.get('tir_below_70', 0):.1f}%",
165
+ new_x=XPos.LMARGIN,
166
+ new_y=YPos.NEXT,
167
+ )
168
+ pdf.cell(
169
+ 0,
170
+ 6,
171
+ f"Time >180: {metrics.get('tir_above_180', 0):.1f}%",
172
+ new_x=XPos.LMARGIN,
173
+ new_y=YPos.NEXT,
174
+ )
175
+ pdf.cell(
176
+ 0,
177
+ 6,
178
+ f"CV: {metrics.get('cv', 0):.1f}%",
179
+ new_x=XPos.LMARGIN,
180
+ new_y=YPos.NEXT,
181
+ )
182
+ pdf.cell(
183
+ 0,
184
+ 6,
185
+ f"GMI: {metrics.get('gmi', 0):.1f}%",
186
+ new_x=XPos.LMARGIN,
187
+ new_y=YPos.NEXT,
188
+ )
189
+
190
+ pdf.ln(2)
191
+ pdf.set_font("Helvetica", "B", 12)
192
+ pdf.cell(0, 8, "Safety Summary", new_x=XPos.LMARGIN, new_y=YPos.NEXT)
193
+ pdf.set_font("Helvetica", "", 10)
194
+ pdf.cell(
195
+ 0,
196
+ 6,
197
+ f"Total violations: {safety_report.get('total_violations', 0)}",
198
+ new_x=XPos.LMARGIN,
199
+ new_y=YPos.NEXT,
200
+ )
201
+ pdf.cell(
202
+ 0,
203
+ 6,
204
+ f"Bolus interventions: {safety_report.get('bolus_interventions_count', 0)}",
205
+ new_x=XPos.LMARGIN,
206
+ new_y=YPos.NEXT,
207
+ )
208
+ top_reasons = self._top_safety_reasons(simulation_data)
209
+ if top_reasons:
210
+ pdf.ln(1)
211
+ pdf.set_font("Helvetica", "B", 10)
212
+ pdf.cell(0, 6, "Top intervention reasons:", new_x=XPos.LMARGIN, new_y=YPos.NEXT)
213
+ pdf.set_font("Helvetica", "", 10)
214
+ for reason, count in top_reasons.items():
215
+ pdf.cell(0, 5, f"- {reason}: {count}", new_x=XPos.LMARGIN, new_y=YPos.NEXT)
216
+
217
+ baseline = safety_report.get("baseline_comparison")
218
+ if baseline and baseline.get("rows"):
219
+ pdf.ln(3)
220
+ pdf.set_font("Helvetica", "B", 12)
221
+ pdf.cell(0, 7, "Head-to-Head Comparison", new_x=XPos.LMARGIN, new_y=YPos.NEXT)
222
+ pdf.set_font("Helvetica", "B", 9)
223
+ col_widths = [52, 26, 26, 26, 30]
224
+ headers = ["Algorithm", "TIR 70-180", "Time <70", "Time >180", "Safety Overrides"]
225
+ for idx, header in enumerate(headers):
226
+ pdf.cell(col_widths[idx], 6, header, border=1, align="C")
227
+ pdf.ln()
228
+
229
+ pdf.set_font("Helvetica", "", 9)
230
+ for row in baseline["rows"]:
231
+ pdf.cell(col_widths[0], 6, str(row.get("algorithm", ""))[:24], border=1)
232
+ pdf.cell(col_widths[1], 6, f"{row.get('tir_70_180', 0):.1f}%", border=1, align="C")
233
+ pdf.cell(col_widths[2], 6, f"{row.get('tir_below_70', 0):.1f}%", border=1, align="C")
234
+ pdf.cell(col_widths[3], 6, f"{row.get('tir_above_180', 0):.1f}%", border=1, align="C")
235
+ pdf.cell(col_widths[4], 6, str(row.get("bolus_interventions", 0)), border=1, align="C")
236
+ pdf.ln()
237
+
238
+ pdf.ln(4)
239
+ pdf.set_font("Helvetica", "B", 12)
240
+ pdf.cell(0, 8, "Glucose Trace", new_x=XPos.LMARGIN, new_y=YPos.NEXT)
241
+ pdf.image(str(glucose_plot), w=180)
242
+
243
+ pdf.ln(4)
244
+ pdf.set_font("Helvetica", "B", 12)
245
+ pdf.cell(0, 8, "Insulin Delivery", new_x=XPos.LMARGIN, new_y=YPos.NEXT)
246
+ pdf.image(str(insulin_plot), w=180)
247
+
248
+ pdf.output(str(output_file))
249
+
250
+ return str(output_file)
251
+
252
+ def generate_demo_pdf(
253
+ self,
254
+ simulation_data: pd.DataFrame,
255
+ safety_report: Dict[str, Any],
256
+ output_path: str,
257
+ title: str = "IINTS-AF Demo Report",
258
+ ) -> str:
259
+ """
260
+ Generate a Maker Faire / demo-friendly PDF with bold visuals and minimal text.
261
+ """
262
+ output_file = Path(output_path)
263
+ output_file.parent.mkdir(parents=True, exist_ok=True)
264
+
265
+ metrics = self.metrics_calculator.calculate(
266
+ glucose=simulation_data["glucose_actual_mgdl"],
267
+ duration_hours=(simulation_data["time_minutes"].max() / 60.0),
268
+ ).to_dict()
269
+
270
+ with tempfile.TemporaryDirectory() as tmp_dir:
271
+ tmp_dir_path = Path(tmp_dir)
272
+ glucose_plot = tmp_dir_path / "glucose.png"
273
+ insulin_plot = tmp_dir_path / "insulin.png"
274
+ self._plot_glucose(simulation_data, glucose_plot)
275
+ self._plot_insulin(simulation_data, insulin_plot)
276
+
277
+ pdf = FPDF()
278
+ pdf.set_auto_page_break(auto=True, margin=12)
279
+ pdf.add_page()
280
+ self._render_logo(pdf)
281
+
282
+ pdf.set_font("Helvetica", "B", 18)
283
+ pdf.cell(0, 12, title, new_x=XPos.LMARGIN, new_y=YPos.NEXT)
284
+ pdf.set_font("Helvetica", "", 11)
285
+ pdf.cell(
286
+ 0,
287
+ 7,
288
+ f"Duration: {simulation_data['time_minutes'].max()/60:.1f} hours",
289
+ new_x=XPos.LMARGIN,
290
+ new_y=YPos.NEXT,
291
+ )
292
+ pdf.ln(2)
293
+
294
+ # Metric tiles
295
+ tiles = [
296
+ ("TIR 70-180", f"{metrics.get('tir_70_180', 0):.1f}%"),
297
+ ("Time <70", f"{metrics.get('tir_below_70', 0):.1f}%"),
298
+ ("GMI", f"{metrics.get('gmi', 0):.1f}%"),
299
+ ("CV", f"{metrics.get('cv', 0):.1f}%"),
300
+ ("Overrides", str(safety_report.get("bolus_interventions_count", 0))),
301
+ ("Violations", str(safety_report.get("total_violations", 0))),
302
+ ]
303
+
304
+ tile_w = 60
305
+ tile_h = 20
306
+ start_x = pdf.l_margin
307
+ start_y = pdf.get_y() + 2
308
+ pdf.set_font("Helvetica", "B", 10)
309
+
310
+ for idx, (label, value) in enumerate(tiles):
311
+ row = idx // 3
312
+ col = idx % 3
313
+ x = start_x + col * (tile_w + 4)
314
+ y = start_y + row * (tile_h + 6)
315
+ pdf.set_fill_color(230, 244, 246)
316
+ pdf.rect(x, y, tile_w, tile_h, style="F")
317
+ pdf.set_xy(x + 2, y + 3)
318
+ pdf.cell(tile_w - 4, 5, label, new_x=XPos.LEFT, new_y=YPos.NEXT)
319
+ pdf.set_font("Helvetica", "B", 13)
320
+ pdf.set_xy(x + 2, y + 9)
321
+ pdf.cell(tile_w - 4, 8, value, new_x=XPos.LEFT, new_y=YPos.NEXT)
322
+ pdf.set_font("Helvetica", "B", 10)
323
+
324
+ pdf.ln(36)
325
+ pdf.set_font("Helvetica", "B", 12)
326
+ pdf.cell(0, 8, "Glucose Trace", new_x=XPos.LMARGIN, new_y=YPos.NEXT)
327
+ pdf.image(str(glucose_plot), w=180)
328
+
329
+ pdf.ln(4)
330
+ pdf.set_font("Helvetica", "B", 12)
331
+ pdf.cell(0, 8, "Insulin Delivery", new_x=XPos.LMARGIN, new_y=YPos.NEXT)
332
+ pdf.image(str(insulin_plot), w=180)
333
+
334
+ top_reasons = self._top_safety_reasons(simulation_data)
335
+ if top_reasons:
336
+ pdf.ln(4)
337
+ pdf.set_font("Helvetica", "B", 11)
338
+ pdf.cell(0, 7, "Top Safety Interventions", new_x=XPos.LMARGIN, new_y=YPos.NEXT)
339
+ pdf.set_font("Helvetica", "", 10)
340
+ for reason, count in top_reasons.items():
341
+ pdf.cell(0, 5, f"- {reason}: {count}", new_x=XPos.LMARGIN, new_y=YPos.NEXT)
342
+
343
+ pdf.output(str(output_file))
344
+
345
+ return str(output_file)