Skip to content

Commit a3d7d9a

Browse files
bewestCopilot
andcommitted
EXP-2809: Sensor-gap controller effect estimation (3/5 PASS)
Quantifies the CAUSAL effect of AID controllers using sensor gaps: Controller keeps BG 16.9 mg/dL lower (p=0.003): Loop: +18.9 mg/dL effect (post-gap BG rises 16.5 mg/dL) Trio: +18.0 mg/dL effect (post-gap BG rises 24.1 mg/dL) OpenAPS: +3.4 mg/dL effect (post-gap barely affected) Controller differences reveal operational philosophy: - Trio: Most dependent on CGM feedback (largest gap impact) Runs at 8% actual basal, all SMBs → blind = no corrections - Loop: Moderate dependence (18.9 mg/dL effect) Runs at 15% actual basal → blind = slow rise - OpenAPS: Least dependent (3.4 mg/dL effect) Runs at 30% actual basal → blind ≈ still delivers insulin EGP estimate = 0.4 mg/dL/hr (expected 5-15) — FAIL: Residual insulin from DIA=6h still active during gaps. Even 3h gaps don't fully clear prior insulin activity. True EGP would require gaps >> DIA (impossible in practice). The 0.4 mg/dL/hr = EGP minus residual_insulin_effect. Post-gap volatility increases (std 16.5→19.1): Controller normally smooths BG variability by ~15%. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 91e95ca commit a3d7d9a

1 file changed

Lines changed: 369 additions & 0 deletions

File tree

Lines changed: 369 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,369 @@
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

Comments
 (0)