Skip to content

Commit 9805a7f

Browse files
author
Sigrid Tofte Thiis
committed
Add runnable entry point for autotune ISF module
1 parent 96ec161 commit 9805a7f

6 files changed

Lines changed: 269 additions & 0 deletions

File tree

loop_to_python_adaptive/autotune/autotune.py

Whitespace-only changes.

loop_to_python_adaptive/autotunePrep/categorize.py

Whitespace-only changes.

loop_to_python_adaptive/autotunePrep/prep.py

Whitespace-only changes.
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from typing import Any, Optional
5+
6+
import numpy as np
7+
8+
9+
@dataclass(frozen=True)
10+
class AutotuneISFConfig:
11+
min_points: int = 10
12+
adjustment_fraction: float = 0.2 # oref0 uses 20%
13+
autosens_max: float = 1.2 # oref0 default
14+
autosens_min: float = 0.7 # oref0 default
15+
min_bgi_abs: float = 1e-6 # avoid divide by tiny numbers
16+
ratio_clip_low: float = 0.3 # hard clip to avoid insane outliers
17+
ratio_clip_high: float = 3.0
18+
19+
20+
def tune_isf_like_oref0(
21+
*,
22+
isf_current: float,
23+
isf_glucose_data: list[dict[str, Any]],
24+
pump_isf: Optional[float] = None,
25+
config: AutotuneISFConfig | None = None,
26+
) -> dict[str, Any]:
27+
"""
28+
oref0-like ISF tuning:
29+
ratio = 1 + deviation / BGI
30+
fullNewISF = ISF * median(ratio)
31+
newISF = (1-adjustment_fraction)*ISF + adjustment_fraction*adjustedISF
32+
"""
33+
cfg = config or AutotuneISFConfig()
34+
35+
ratios: list[float] = []
36+
for p in isf_glucose_data:
37+
dev = float(p["deviation"])
38+
bgi = float(p["BGI"])
39+
if abs(bgi) < cfg.min_bgi_abs:
40+
continue
41+
r = 1.0 + dev / bgi
42+
if not np.isfinite(r):
43+
continue
44+
r = float(np.clip(r, cfg.ratio_clip_low, cfg.ratio_clip_high))
45+
ratios.append(r)
46+
47+
if len(ratios) < cfg.min_points:
48+
return {
49+
"newISF": float(isf_current),
50+
"p50_ratio": None,
51+
"fullNewISF": None,
52+
"adjustedISF": None,
53+
"n_points": len(ratios),
54+
"reason": f"Only {len(ratios)} usable ISF points (<{cfg.min_points}); leaving ISF unchanged.",
55+
}
56+
57+
p50_ratio = float(np.median(ratios))
58+
full_new_isf = float(isf_current * p50_ratio)
59+
60+
adjusted_isf = full_new_isf
61+
if pump_isf is not None and pump_isf > 0:
62+
# Match oref0 bounds:
63+
# low autosens ratio = high ISF => maxISF = pumpISF / autosens_min
64+
# high autosens ratio = low ISF => minISF = pumpISF / autosens_max
65+
max_isf = pump_isf / cfg.autosens_min
66+
min_isf = pump_isf / cfg.autosens_max
67+
adjusted_isf = float(np.clip(adjusted_isf, min_isf, max_isf))
68+
69+
# Slow update like oref0
70+
new_isf = (1.0 - cfg.adjustment_fraction) * float(isf_current) + cfg.adjustment_fraction * float(adjusted_isf)
71+
print(f"tune_isf_like_oref0: current ISF={isf_current}, p50_ratio={p50_ratio}, full_new_isf={full_new_isf}, ")
72+
return {
73+
"newISF": float(new_isf),
74+
"p50_ratio": p50_ratio,
75+
"fullNewISF": full_new_isf,
76+
"adjustedISF": float(adjusted_isf),
77+
"n_points": len(ratios),
78+
"reason": "OK",
79+
}
80+
81+
82+
if __name__ == "__main__":
83+
sample_points = [
84+
{"deviation": 3.0, "BGI": 10.0},
85+
{"deviation": -2.0, "BGI": 9.5},
86+
{"deviation": 1.2, "BGI": 8.0},
87+
{"deviation": -0.5, "BGI": 7.5},
88+
{"deviation": 2.2, "BGI": 10.2},
89+
{"deviation": -1.0, "BGI": 9.0},
90+
{"deviation": 0.8, "BGI": 8.8},
91+
{"deviation": 1.5, "BGI": 9.7},
92+
{"deviation": -0.9, "BGI": 8.6},
93+
{"deviation": 2.0, "BGI": 10.1},
94+
]
95+
96+
result = tune_isf_like_oref0(
97+
isf_current=50.0,
98+
isf_glucose_data=sample_points,
99+
pump_isf=50.0,
100+
)
101+
print(result)
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from typing import Any, Optional
5+
6+
import numpy as np
7+
8+
9+
@dataclass(frozen=True)
10+
class AutotuneISFConfig:
11+
min_points: int = 10
12+
adjustment_fraction: float = 0.2 # oref0 uses 20%
13+
autosens_max: float = 1.2 # oref0 default
14+
autosens_min: float = 0.7 # oref0 default
15+
min_bgi_abs: float = 1e-6 # avoid divide by tiny numbers
16+
ratio_clip_low: float = 0.3 # hard clip to avoid insane outliers
17+
ratio_clip_high: float = 3.0
18+
19+
20+
def tune_isf_like_oref0(
21+
*,
22+
isf_current: float,
23+
isf_glucose_data: list[dict[str, Any]],
24+
pump_isf: Optional[float] = None,
25+
config: AutotuneISFConfig | None = None,
26+
) -> dict[str, Any]:
27+
"""
28+
oref0-like ISF tuning:
29+
ratio = 1 + deviation / BGI
30+
fullNewISF = ISF * median(ratio)
31+
newISF = (1-adjustment_fraction)*ISF + adjustment_fraction*adjustedISF
32+
"""
33+
cfg = config or AutotuneISFConfig()
34+
35+
ratios: list[float] = []
36+
for p in isf_glucose_data:
37+
dev = float(p["deviation"])
38+
bgi = float(p["BGI"])
39+
if abs(bgi) < cfg.min_bgi_abs:
40+
continue
41+
r = 1.0 + dev / bgi
42+
if not np.isfinite(r):
43+
continue
44+
r = float(np.clip(r, cfg.ratio_clip_low, cfg.ratio_clip_high))
45+
ratios.append(r)
46+
47+
if len(ratios) < cfg.min_points:
48+
return {
49+
"newISF": float(isf_current),
50+
"p50_ratio": None,
51+
"fullNewISF": None,
52+
"adjustedISF": None,
53+
"n_points": len(ratios),
54+
"reason": f"Only {len(ratios)} usable ISF points (<{cfg.min_points}); leaving ISF unchanged.",
55+
}
56+
57+
p50_ratio = float(np.median(ratios))
58+
full_new_isf = float(isf_current * p50_ratio)
59+
60+
adjusted_isf = full_new_isf
61+
if pump_isf is not None and pump_isf > 0:
62+
# Match oref0 bounds:
63+
# low autosens ratio = high ISF => maxISF = pumpISF / autosens_min
64+
# high autosens ratio = low ISF => minISF = pumpISF / autosens_max
65+
max_isf = pump_isf / cfg.autosens_min
66+
min_isf = pump_isf / cfg.autosens_max
67+
adjusted_isf = float(np.clip(adjusted_isf, min_isf, max_isf))
68+
69+
# Slow update like oref0
70+
new_isf = (1.0 - cfg.adjustment_fraction) * float(isf_current) + cfg.adjustment_fraction * float(adjusted_isf)
71+
72+
return {
73+
"newISF": float(new_isf),
74+
"p50_ratio": p50_ratio,
75+
"fullNewISF": full_new_isf,
76+
"adjustedISF": float(adjusted_isf),
77+
"n_points": len(ratios),
78+
"reason": "OK",
79+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from typing import Optional
5+
6+
import pandas as pd
7+
8+
from . import api
9+
10+
11+
@dataclass(frozen=True)
12+
class ICEFeatureConfig:
13+
insulin_type: str = "novolog"
14+
batch_size: int = 300
15+
overlap: int = 72 # 72 * 5 min = 6 hours; matches upstream default
16+
17+
18+
def add_ice_to_history_df(
19+
df: pd.DataFrame,
20+
*,
21+
basal_scheduled_u_per_hr: float,
22+
isf_mgdl_per_u: float,
23+
cr_g_per_u: float,
24+
config: Optional[ICEFeatureConfig] = None,
25+
) -> pd.DataFrame:
26+
"""
27+
Compute insulin counteraction effect (ICE) time series using the Loop wrapper
28+
and add it to a copy of df as df['ice'].
29+
30+
Expected df format (5-min index recommended):
31+
- DatetimeIndex
32+
- columns required by upstream:
33+
'CGM' (mg/dL)
34+
'basal' (U/hr) (delivered basal rate per step)
35+
'bolus' (U) (bolus delivered at that timestamp; 0 otherwise)
36+
- optional:
37+
'carbs' (g) (not needed for ICE computation itself)
38+
39+
Returns:
40+
A copy of df with 'ice' column (float). Some leading rows may be NaN due
41+
to overlap/warmup requirements.
42+
"""
43+
cfg = config or ICEFeatureConfig()
44+
45+
if not isinstance(df.index, pd.DatetimeIndex):
46+
raise ValueError("df must have a DatetimeIndex (timestamps).")
47+
required = {"CGM", "basal", "bolus"}
48+
missing = required - set(df.columns)
49+
if missing:
50+
raise ValueError(f"df missing required columns: {sorted(missing)}")
51+
52+
# Upstream helper mutates the df in-place, so operate on a copy.
53+
out = df.copy()
54+
55+
# This is provided by loop_to_python_api.api (re-exported via our api.py)
56+
# It writes an 'ice' column aligned to timestamps.
57+
out = api.add_insulin_counteraction_effect_to_df(
58+
out,
59+
basal_scheduled_u_per_hr,
60+
isf_mgdl_per_u,
61+
cr_g_per_u,
62+
insulin_type=cfg.insulin_type,
63+
batch_size=cfg.batch_size,
64+
overlap=cfg.overlap,
65+
)
66+
return out
67+
68+
69+
def ensure_ice_column(
70+
df: pd.DataFrame,
71+
*,
72+
basal_scheduled_u_per_hr: float,
73+
isf_mgdl_per_u: float,
74+
cr_g_per_u: float,
75+
config: Optional[ICEFeatureConfig] = None,
76+
) -> pd.DataFrame:
77+
"""
78+
Convenience: if df already has non-null 'ice' values, return df unchanged,
79+
otherwise compute ICE and return df with 'ice'.
80+
"""
81+
if "ice" in df.columns and df["ice"].notna().any():
82+
return df
83+
return add_ice_to_history_df(
84+
df,
85+
basal_scheduled_u_per_hr=basal_scheduled_u_per_hr,
86+
isf_mgdl_per_u=isf_mgdl_per_u,
87+
cr_g_per_u=cr_g_per_u,
88+
config=config,
89+
)

0 commit comments

Comments
 (0)