|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +EXP-2809: Sensor-Gap Controller Effect Estimation |
| 4 | +================================================== |
| 5 | +
|
| 6 | +Rationale: |
| 7 | + EXP-2808 showed sensor gaps are the ONE truly exogenous event in AID data. |
| 8 | + During gaps, the controller is blind (no CGM input) and runs on defaults. |
| 9 | + This creates a quasi-natural-experiment: same patient, same physiology, |
| 10 | + but controller effectiveness is reduced. |
| 11 | + |
| 12 | + By comparing glucose behavior during controller-blind periods vs normal, |
| 13 | + we can estimate the CAUSAL effect of controller intervention: |
| 14 | + - How much does the controller reduce mean BG? |
| 15 | + - How much does the controller reduce variability? |
| 16 | + - Does the effect differ by controller type? |
| 17 | + - Can we estimate true uncontrolled EGP from the drift during gaps? |
| 18 | + |
| 19 | + This also helps validate ISF: if the controller is blind and BG rises, |
| 20 | + the rate of rise reveals unopposed glucose production that the controller |
| 21 | + normally counteracts. |
| 22 | +
|
| 23 | +Success criteria: |
| 24 | + P1: Controller effect on mean BG: post-gap BG > pre-gap BG (proven) |
| 25 | + P2: Controller effect on variability: post-gap std > normal std |
| 26 | + P3: BG drift rate during gap estimates EGP (5-15 mg/dL/hr range) |
| 27 | + P4: Controller type differences in gap recovery speed |
| 28 | + P5: Gap-derived ISF estimate correlates with extracted ISF (r > 0.3) |
| 29 | +""" |
| 30 | + |
| 31 | +import json |
| 32 | +import warnings |
| 33 | +from pathlib import Path |
| 34 | +from datetime import datetime |
| 35 | + |
| 36 | +import numpy as np |
| 37 | +import pandas as pd |
| 38 | +from scipy import stats as sp_stats |
| 39 | + |
| 40 | +warnings.filterwarnings('ignore') |
| 41 | + |
| 42 | +EXP_ID = 2809 |
| 43 | +TITLE = "Sensor-Gap Controller Effect" |
| 44 | +EXCLUDE = {'odc-84181797', 'h', 'j'} |
| 45 | + |
| 46 | +grid = pd.read_parquet("externals/ns-parquet/training/grid.parquet") |
| 47 | +grid = grid[~grid['patient_id'].isin(EXCLUDE)].copy() |
| 48 | +grid = grid.sort_values(['patient_id', 'time']) |
| 49 | + |
| 50 | +def classify_controller(pid): |
| 51 | + if len(pid) == 1 and pid.isalpha(): |
| 52 | + return 'Loop' |
| 53 | + elif pid.startswith('ns-'): |
| 54 | + return 'Trio' |
| 55 | + elif pid.startswith('odc-'): |
| 56 | + return 'OpenAPS' |
| 57 | + return 'Unknown' |
| 58 | + |
| 59 | +grid['controller'] = grid['patient_id'].apply(classify_controller) |
| 60 | +patients = sorted(grid['patient_id'].unique()) |
| 61 | + |
| 62 | +def make_activity_curve(dia_hours=6, peak_min=75, step_min=5): |
| 63 | + n_steps = int(dia_hours * 60 / step_min) |
| 64 | + t = np.arange(1, n_steps + 1) * step_min |
| 65 | + curve = (t / peak_min) * np.exp(1 - t / peak_min) |
| 66 | + return curve / curve.sum() |
| 67 | + |
| 68 | +activity = make_activity_curve() |
| 69 | + |
| 70 | +print(f"Patients: {len(patients)}") |
| 71 | +print("\n" + "=" * 70) |
| 72 | +print("PART 1: Sensor Gap Detection & BG Drift Analysis") |
| 73 | +print("=" * 70) |
| 74 | + |
| 75 | +# ══════════════════════════════════════════════════════════════════════════ |
| 76 | +# DETECT SENSOR GAPS AND MEASURE DRIFT |
| 77 | +# ══════════════════════════════════════════════════════════════════════════ |
| 78 | + |
| 79 | +gap_data = [] |
| 80 | +per_patient_gaps = {} |
| 81 | + |
| 82 | +for pid in patients: |
| 83 | + pdf = grid[grid['patient_id'] == pid].reset_index(drop=True) |
| 84 | + n = len(pdf) |
| 85 | + ctrl = classify_controller(pid) |
| 86 | + gluc = pdf['glucose'].values |
| 87 | + |
| 88 | + # Detect NaN gaps ≥30min |
| 89 | + nan_mask = np.isnan(gluc) |
| 90 | + in_gap = False |
| 91 | + gap_start = 0 |
| 92 | + patient_gaps = [] |
| 93 | + |
| 94 | + for i in range(n): |
| 95 | + if nan_mask[i] and not in_gap: |
| 96 | + gap_start = i |
| 97 | + in_gap = True |
| 98 | + elif not nan_mask[i] and in_gap: |
| 99 | + gap_len = i - gap_start |
| 100 | + if gap_len >= 6: # ≥30 min |
| 101 | + # Need 2h context before and after |
| 102 | + if gap_start >= 24 and i + 24 < n: |
| 103 | + pre = gluc[gap_start-24:gap_start] |
| 104 | + post = gluc[i:i+24] |
| 105 | + |
| 106 | + if np.isnan(pre).sum() < 4 and np.isnan(post).sum() < 4: |
| 107 | + # BG at gap boundaries |
| 108 | + bg_entering = np.nanmean(pre[-6:]) # last 30min before gap |
| 109 | + bg_exiting = np.nanmean(post[:6]) # first 30min after gap |
| 110 | + |
| 111 | + # Drift during gap |
| 112 | + drift = bg_exiting - bg_entering |
| 113 | + drift_per_hour = drift / (gap_len * 5 / 60) |
| 114 | + |
| 115 | + # Normal BG stats for this patient (non-gap) |
| 116 | + normal_bg_before = np.nanmean(pre) |
| 117 | + normal_std_before = np.nanstd(pre) |
| 118 | + |
| 119 | + # Post-gap recovery: how fast does BG normalize? |
| 120 | + bg_30min_after = np.nanmean(post[:6]) |
| 121 | + bg_1h_after = np.nanmean(post[:12]) |
| 122 | + bg_2h_after = np.nanmean(post[-6:]) |
| 123 | + |
| 124 | + recovery_1h = bg_30min_after - bg_1h_after # should be positive (coming down) |
| 125 | + recovery_2h = bg_30min_after - bg_2h_after |
| 126 | + |
| 127 | + patient_gaps.append({ |
| 128 | + 'patient': pid, 'controller': ctrl, |
| 129 | + 'gap_start': gap_start, 'gap_end': i, |
| 130 | + 'gap_duration_min': gap_len * 5, |
| 131 | + 'bg_entering': bg_entering, |
| 132 | + 'bg_exiting': bg_exiting, |
| 133 | + 'drift': drift, |
| 134 | + 'drift_per_hour': drift_per_hour, |
| 135 | + 'pre_mean': normal_bg_before, |
| 136 | + 'post_mean': np.nanmean(post), |
| 137 | + 'pre_std': normal_std_before, |
| 138 | + 'post_std': np.nanstd(post), |
| 139 | + 'recovery_1h': recovery_1h, |
| 140 | + 'recovery_2h': recovery_2h, |
| 141 | + }) |
| 142 | + in_gap = False |
| 143 | + |
| 144 | + if patient_gaps: |
| 145 | + per_patient_gaps[pid] = patient_gaps |
| 146 | + gap_data.extend(patient_gaps) |
| 147 | + |
| 148 | +gdf = pd.DataFrame(gap_data) |
| 149 | +print(f"\nTotal sensor gaps with context: {len(gdf)} across {len(per_patient_gaps)} patients") |
| 150 | +print(f"Median gap duration: {gdf['gap_duration_min'].median():.0f} min") |
| 151 | + |
| 152 | +# ══════════════════════════════════════════════════════════════════════════ |
| 153 | +# PART 2: BG Drift During Gaps (EGP Estimation) |
| 154 | +# ══════════════════════════════════════════════════════════════════════════ |
| 155 | + |
| 156 | +print("\n" + "=" * 70) |
| 157 | +print("PART 2: BG Drift During Sensor Gaps (Unopposed EGP)") |
| 158 | +print("=" * 70) |
| 159 | + |
| 160 | +# During sensor gaps, the controller runs on defaults (or suspends) |
| 161 | +# BG drift = EGP - residual_insulin_effect |
| 162 | +# If gap is long enough, residual insulin has largely dissipated |
| 163 | + |
| 164 | +# Stratify by gap duration |
| 165 | +for min_dur, max_dur, label in [(30, 60, '30-60min'), (60, 120, '1-2h'), (120, 300, '2-5h'), (300, 1500, '>5h')]: |
| 166 | + sub = gdf[(gdf['gap_duration_min'] >= min_dur) & (gdf['gap_duration_min'] < max_dur)] |
| 167 | + if len(sub) > 5: |
| 168 | + drift = sub['drift_per_hour'].values |
| 169 | + drift_clean = drift[(drift > -50) & (drift < 50)] |
| 170 | + print(f" {label}: n={len(sub)}, median drift={np.median(drift_clean):+.1f} mg/dL/hr, " |
| 171 | + f"IQR=[{np.percentile(drift_clean, 25):.1f}, {np.percentile(drift_clean, 75):.1f}]") |
| 172 | + |
| 173 | +# Longer gaps should show more positive drift (insulin wears off, EGP dominates) |
| 174 | +long_gaps = gdf[gdf['gap_duration_min'] >= 120] |
| 175 | +short_gaps = gdf[(gdf['gap_duration_min'] >= 30) & (gdf['gap_duration_min'] < 120)] |
| 176 | + |
| 177 | +if len(long_gaps) > 10 and len(short_gaps) > 10: |
| 178 | + long_drift = long_gaps['drift_per_hour'].values |
| 179 | + short_drift = short_gaps['drift_per_hour'].values |
| 180 | + long_drift = long_drift[(long_drift > -50) & (long_drift < 50)] |
| 181 | + short_drift = short_drift[(short_drift > -50) & (short_drift < 50)] |
| 182 | + t_dur, p_dur = sp_stats.mannwhitneyu(long_drift, short_drift, alternative='greater') |
| 183 | + print(f"\n Long (≥2h) vs short (<2h) gap drift:") |
| 184 | + print(f" Long median: {np.median(long_drift):+.1f} mg/dL/hr") |
| 185 | + print(f" Short median: {np.median(short_drift):+.1f} mg/dL/hr") |
| 186 | + print(f" Mann-Whitney (long>short): U={t_dur:.0f}, p={p_dur:.4f}") |
| 187 | + |
| 188 | +# Best EGP estimate: from longest gaps where insulin has worn off |
| 189 | +best_egp = gdf[gdf['gap_duration_min'] >= 180]['drift_per_hour'] |
| 190 | +best_egp_clean = best_egp[(best_egp > -30) & (best_egp < 30)] |
| 191 | +if len(best_egp_clean) > 5: |
| 192 | + egp_estimate = np.median(best_egp_clean) |
| 193 | + print(f"\n Best EGP estimate (gaps ≥3h): {egp_estimate:+.1f} mg/dL/hr") |
| 194 | + print(f" N events: {len(best_egp_clean)}") |
| 195 | + egp_in_range = 5 <= egp_estimate <= 15 |
| 196 | +else: |
| 197 | + egp_estimate = np.nan |
| 198 | + egp_in_range = False |
| 199 | + |
| 200 | +# ══════════════════════════════════════════════════════════════════════════ |
| 201 | +# PART 3: Controller Effect by Type |
| 202 | +# ══════════════════════════════════════════════════════════════════════════ |
| 203 | + |
| 204 | +print("\n" + "=" * 70) |
| 205 | +print("PART 3: Controller Type Differences in Gap Behavior") |
| 206 | +print("=" * 70) |
| 207 | + |
| 208 | +for ctrl in ['Loop', 'Trio', 'OpenAPS']: |
| 209 | + sub = gdf[gdf['controller'] == ctrl] |
| 210 | + if len(sub) > 10: |
| 211 | + drift = sub['drift_per_hour'].values |
| 212 | + drift_clean = drift[(drift > -50) & (drift < 50)] |
| 213 | + recovery = sub['recovery_2h'].values |
| 214 | + recovery_clean = recovery[~np.isnan(recovery) & (np.abs(recovery) < 100)] |
| 215 | + |
| 216 | + print(f"\n {ctrl} (n={len(sub)} gaps):") |
| 217 | + print(f" Median drift: {np.median(drift_clean):+.1f} mg/dL/hr") |
| 218 | + print(f" BG rise (gap): {sub['drift'].median():+.1f} mg/dL") |
| 219 | + print(f" Post-gap recovery: {np.median(recovery_clean):+.1f} mg/dL in 2h") |
| 220 | + print(f" Pre-gap mean BG: {sub['pre_mean'].median():.1f}") |
| 221 | + print(f" Post-gap mean BG: {sub['post_mean'].median():.1f}") |
| 222 | + |
| 223 | +# ══════════════════════════════════════════════════════════════════════════ |
| 224 | +# PART 4: Controller Causal Effect Estimation |
| 225 | +# ══════════════════════════════════════════════════════════════════════════ |
| 226 | + |
| 227 | +print("\n" + "=" * 70) |
| 228 | +print("PART 4: Controller Causal Effect (Gap vs Non-Gap)") |
| 229 | +print("=" * 70) |
| 230 | + |
| 231 | +# Compare per-patient: mean BG during non-gap periods vs immediately post-gap |
| 232 | +controller_effects = [] |
| 233 | +for pid, gaps in per_patient_gaps.items(): |
| 234 | + ctrl = classify_controller(pid) |
| 235 | + pdf = grid[grid['patient_id'] == pid] |
| 236 | + |
| 237 | + # Overall mean BG for this patient |
| 238 | + overall_mean = pdf['glucose'].mean() |
| 239 | + overall_std = pdf['glucose'].std() |
| 240 | + |
| 241 | + # Post-gap mean (first 2h after each gap) |
| 242 | + post_gap_means = [g['post_mean'] for g in gaps] |
| 243 | + post_gap_mean = np.mean(post_gap_means) |
| 244 | + |
| 245 | + # Controller effect = post_gap_mean - overall_mean |
| 246 | + # Positive = gap causes BG to be higher = controller was keeping it lower |
| 247 | + effect = post_gap_mean - overall_mean |
| 248 | + |
| 249 | + controller_effects.append({ |
| 250 | + 'patient': pid, 'controller': ctrl, |
| 251 | + 'n_gaps': len(gaps), |
| 252 | + 'overall_mean': overall_mean, |
| 253 | + 'post_gap_mean': post_gap_mean, |
| 254 | + 'controller_effect': effect, |
| 255 | + }) |
| 256 | + |
| 257 | +cedf = pd.DataFrame(controller_effects) |
| 258 | +print(f"\n Controller causal effect on mean BG:") |
| 259 | +print(f" Median effect: {cedf['controller_effect'].median():+.1f} mg/dL") |
| 260 | +print(f" (Positive = controller keeps BG lower by this amount)") |
| 261 | +print(f" Positive effect: {(cedf['controller_effect'] > 0).sum()}/{len(cedf)}") |
| 262 | + |
| 263 | +for ctrl in ['Loop', 'Trio', 'OpenAPS']: |
| 264 | + sub = cedf[cedf['controller'] == ctrl] |
| 265 | + if len(sub) > 0: |
| 266 | + print(f" {ctrl}: {sub['controller_effect'].median():+.1f} mg/dL") |
| 267 | + |
| 268 | +t_effect, p_effect = sp_stats.ttest_1samp(cedf['controller_effect'].dropna(), 0) |
| 269 | +print(f" t-test (effect > 0): t={t_effect:.2f}, p={p_effect:.4f}") |
| 270 | + |
| 271 | +# ══════════════════════════════════════════════════════════════════════════ |
| 272 | +# PART 5: Gap-Derived ISF vs Extracted ISF |
| 273 | +# ══════════════════════════════════════════════════════════════════════════ |
| 274 | + |
| 275 | +print("\n" + "=" * 70) |
| 276 | +print("PART 5: Gap-Derived vs Extracted ISF Correlation") |
| 277 | +print("=" * 70) |
| 278 | + |
| 279 | +# Load EXP-2805 results for comparison |
| 280 | +try: |
| 281 | + with open("externals/experiments/exp-2805_category_settings.json") as f: |
| 282 | + exp2805 = json.load(f) |
| 283 | + isf_extracted = {k: v['isf_optimal'] for k, v in exp2805.get('isf_results', {}).items()} |
| 284 | +except: |
| 285 | + isf_extracted = {} |
| 286 | + |
| 287 | +# Gap-derived ISF: the controller effect / typical insulin delivered during gap period |
| 288 | +# This is very rough: ISF ≈ BG_rise_per_hour / insulin_reduction_per_hour |
| 289 | +# Since we don't know exact insulin during gaps, use controller_effect as proxy |
| 290 | + |
| 291 | +if isf_extracted: |
| 292 | + both = [(pid, cedf.loc[cedf['patient']==pid, 'controller_effect'].values[0], isf_extracted.get(pid)) |
| 293 | + for pid in isf_extracted.keys() |
| 294 | + if pid in cedf['patient'].values and isf_extracted.get(pid)] |
| 295 | + |
| 296 | + if len(both) > 5: |
| 297 | + effects = [b[1] for b in both] |
| 298 | + isfs = [b[2] for b in both] |
| 299 | + r_isf, p_isf = sp_stats.pearsonr(effects, isfs) |
| 300 | + print(f" Controller effect vs extracted ISF: r={r_isf:.3f}, p={p_isf:.4f}") |
| 301 | + print(f" (Positive r = more effect → higher ISF = consistent)") |
| 302 | + isf_corr = r_isf |
| 303 | + else: |
| 304 | + isf_corr = 0 |
| 305 | + print(" Insufficient overlap") |
| 306 | +else: |
| 307 | + isf_corr = 0 |
| 308 | + print(" No EXP-2805 ISF data available") |
| 309 | + |
| 310 | +# ══════════════════════════════════════════════════════════════════════════ |
| 311 | +# CRITERIA |
| 312 | +# ══════════════════════════════════════════════════════════════════════════ |
| 313 | + |
| 314 | +print("\n" + "=" * 70) |
| 315 | +print("CRITERIA EVALUATION") |
| 316 | +print("=" * 70) |
| 317 | + |
| 318 | +P1 = cedf['controller_effect'].median() > 0 |
| 319 | +p1_val = f"Median effect = {cedf['controller_effect'].median():+.1f} mg/dL" |
| 320 | + |
| 321 | +P2 = gdf['post_std'].median() > gdf['pre_std'].median() |
| 322 | +p2_val = f"Post-gap std={gdf['post_std'].median():.1f} vs pre={gdf['pre_std'].median():.1f}" |
| 323 | + |
| 324 | +P3 = egp_in_range |
| 325 | +p3_val = f"EGP estimate = {egp_estimate:+.1f} mg/dL/hr (expect 5-15)" if not np.isnan(egp_estimate) else "Insufficient data" |
| 326 | + |
| 327 | +P4_data = [] |
| 328 | +for ctrl in ['Loop', 'Trio', 'OpenAPS']: |
| 329 | + sub = cedf[cedf['controller'] == ctrl] |
| 330 | + if len(sub) > 2: |
| 331 | + P4_data.append(sub['controller_effect'].median()) |
| 332 | +P4 = len(P4_data) >= 2 and max(P4_data) - min(P4_data) > 3 |
| 333 | +p4_val = f"Range: {min(P4_data):.1f} to {max(P4_data):.1f}" if P4_data else "Insufficient" |
| 334 | + |
| 335 | +P5 = abs(isf_corr) > 0.3 |
| 336 | +p5_val = f"r={isf_corr:.3f}" if isf_corr != 0 else "No data" |
| 337 | + |
| 338 | +criteria = { |
| 339 | + 'P1_controller_reduces_bg': {'pass': P1, 'value': p1_val}, |
| 340 | + 'P2_gap_volatility_higher': {'pass': P2, 'value': p2_val}, |
| 341 | + 'P3_egp_in_range': {'pass': P3, 'value': p3_val}, |
| 342 | + 'P4_controller_differences': {'pass': P4, 'value': p4_val}, |
| 343 | + 'P5_isf_correlation': {'pass': P5, 'value': p5_val}, |
| 344 | +} |
| 345 | + |
| 346 | +pass_count = sum(1 for c in criteria.values() if c['pass']) |
| 347 | +for name, c in criteria.items(): |
| 348 | + status = "PASS ✓" if c['pass'] else "FAIL ✗" |
| 349 | + print(f" {name}: {status} — {c['value']}") |
| 350 | +print(f"\nOverall: {pass_count}/5 criteria passed") |
| 351 | + |
| 352 | +# ── Save ────────────────────────────────────────────────────────────────── |
| 353 | + |
| 354 | +output = { |
| 355 | + 'experiment_id': f'EXP-{EXP_ID}', |
| 356 | + 'title': TITLE, |
| 357 | + 'timestamp': datetime.now().isoformat(), |
| 358 | + 'n_gaps': len(gdf), |
| 359 | + 'n_patients': len(per_patient_gaps), |
| 360 | + 'criteria': criteria, |
| 361 | + 'pass_count': pass_count, |
| 362 | + 'controller_effects': controller_effects, |
| 363 | + 'egp_estimate': float(egp_estimate) if not np.isnan(egp_estimate) else None, |
| 364 | +} |
| 365 | + |
| 366 | +out_path = Path(f"externals/experiments/exp-{EXP_ID}_sensor_gap_effect.json") |
| 367 | +with open(out_path, 'w') as f: |
| 368 | + json.dump(output, f, indent=2, default=str) |
| 369 | +print(f"\nResults saved to {out_path}") |
0 commit comments