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,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)
|