Skip to content

Commit 3dd2257

Browse files
author
Sigrid Tofte Thiis
committed
changed bgi to be calculated from percent effect remaining rather than prediction to make it less computationally heavy and repetative
1 parent 7412bd9 commit 3dd2257

13 files changed

Lines changed: 322 additions & 134 deletions
Binary file not shown.
0 Bytes
Binary file not shown.
0 Bytes
Binary file not shown.
7.37 KB
Binary file not shown.
Binary file not shown.
Binary file not shown.

loop_to_python_adaptive/autotune_prep.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ class AutotunePrepConfig:
4646
min_5m_carbimpact: float = 8.0 # mg/dL per 5m (8 mg/dL/5m corresponds to 24g/hr at a CSF of 4 mg/dL/g (x/5*60/4)) (lib/profile/index.js line 35 in oref0)
4747

4848
# UAM post-processing flag
49-
categorize_uam_as_basal: bool = False
49+
categorize_uam_as_basal: bool = True # <- testing
5050

5151
# Column / field names
5252
cgm_col: str = "CGM"
@@ -330,6 +330,6 @@ def prepare_for_autotune_isf(
330330

331331
return {
332332
"df": df2,
333-
"isf_glucose_data": buckets["ISFGlucoseData"],
333+
"ISFGlucoseData": buckets["ISFGlucoseData"],
334334
**buckets,
335335
}

loop_to_python_adaptive/loop_oref_mapping.py

Lines changed: 181 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
"""
2+
This module maps loop data to fit oref0 autotune format.
3+
It takes predictions from Loop Algorithm and computes BGI equivalent
4+
the dataframe returned has bgi, deviation, avgDelta
5+
BGI from ExponentialInsulinModel (LoopAlgorithm)
6+
via insulin_percent_effect_remaining, mirroring oref0's:
7+
BGI = -iob.activity * sens * 5
8+
where iob.activity is the instantaneous insulin activity (U/min).
9+
"""
110
from __future__ import annotations
211
from dataclasses import dataclass
312
from typing import Literal
@@ -7,15 +16,32 @@
716
import loop_to_python_adaptive.api as api
817
import loop_to_python_api.helpers as helpers
918

10-
from loop_to_python_api.api import get_prediction_values_and_dates, get_active_insulin, get_active_carbs
19+
from loop_to_python_api.api import get_prediction_values_and_dates, get_active_insulin, get_active_carbs, insulin_percent_effect_remaining
1120

1221
AlignMode = Literal["ffill", "nearest", "strict"]
1322

14-
"""
15-
This module maps loop data to fit oref0 autotune format.
16-
It takes predictions from Loop Algorithm and computes BGI equivalent
17-
the dataframe returned has bgi, deviation, avgDelta
18-
"""
23+
# ---------------------------------------------------------------------------
24+
# Insulin model parameters
25+
#
26+
# Source: LoopKit ExponentialInsulinModel defaults
27+
# These match the values used internally by loop_to_python_api when
28+
# insulin_type is passed to get_active_insulin / get_prediction_values_and_dates.
29+
# loop_to_python_api does not expose these parameters — they are used
30+
# inside the Swift layer only. They are mirrored here so we can call
31+
# insulin_percent_effect_remaining with the correct values for each type.
32+
#
33+
# Reference: ExponentialInsulinModel.swift (https://github.com/tidepool-org/LoopAlgorithm/blob/main/Sources/LoopAlgorithm/Insulin/ExponentialInsulinModel.swift)
34+
# action_duration and peak_activity_time in minutes, delay in minutes.
35+
# ---------------------------------------------------------------------------
36+
37+
INSULIN_MODEL_PARAMS: dict[str, dict] = {
38+
"novolog": {"action_duration": 360, "peak_activity_time": 75, "delay": 10},
39+
"humalog": {"action_duration": 360, "peak_activity_time": 75, "delay": 10},
40+
"apidra": {"action_duration": 360, "peak_activity_time": 75, "delay": 10},
41+
"fiasp": {"action_duration": 360, "peak_activity_time": 55, "delay": 10},
42+
"lyumjev": {"action_duration": 360, "peak_activity_time": 45, "delay": 10},
43+
"afrezza": {"action_duration": 300, "peak_activity_time": 29, "delay": 10},
44+
}
1945

2046
#Timezone fix
2147
def _to_utc_index(df: pd.DataFrame) -> pd.DataFrame:
@@ -29,6 +55,45 @@ def _to_utc_index(df: pd.DataFrame) -> pd.DataFrame:
2955
##########################
3056
# GENERATE BGI SERIES #
3157
##########################
58+
def _get_model_params(insulin_type: str) -> dict:
59+
"""Return model params for the given insulin type, defaulting to novolog."""
60+
return INSULIN_MODEL_PARAMS.get(
61+
insulin_type.lower(),
62+
INSULIN_MODEL_PARAMS["novolog"],
63+
)
64+
65+
def insulin_activity_at(
66+
mins_ago: float,
67+
action_duration: float,
68+
peak_activity_time: float,
69+
delay: float,
70+
dt: float = 0.5,
71+
) -> float:
72+
"""
73+
Instantaneous insulin activity (fraction of dose / min) at `mins_ago`
74+
minutes after delivery.
75+
76+
Computed as the central-difference numerical derivative of
77+
percentEffectRemaining, using step dt minutes.
78+
79+
activity = -d/dt[percentEffectRemaining(t)]
80+
81+
dt=0.5 min is small enough for accuracy on the exponential curve
82+
and large enough to avoid floating-point noise?
83+
84+
Returns U_fraction/min — multiply by dose (U) to get U/min.
85+
"""
86+
per_before = insulin_percent_effect_remaining(
87+
mins_ago - dt, action_duration, peak_activity_time, delay
88+
)
89+
per_after = insulin_percent_effect_remaining(
90+
mins_ago + dt, action_duration, peak_activity_time, delay
91+
)
92+
# Negative because percentEffectRemaining decreases over time
93+
return -(per_after - per_before) / (2 * dt)
94+
95+
"""
96+
calculating BGI from prediction is computationally heavy as a prediction is needed for each row of history df
3297
@dataclass(frozen=True)
3398
class BGIConfig:
3499
action_duration_minutes: int
@@ -39,10 +104,10 @@ class BGIConfig:
39104
40105
41106
def generate_bgi_series_from_insulin_prediction(loop_algorithm_input: dict) -> pd.Series:
42-
"""
43-
Calls LoopAlgorithm via loop_to_python_api to get insulin-only prediction values+dates,
44-
then returns BGI(t) = pred(t+5m) - pred(t).
45-
"""
107+
108+
#Calls LoopAlgorithm via loop_to_python_api to get insulin-only prediction values+dates,
109+
#then returns BGI(t) = pred(t+5m) - pred(t).
110+
46111
values, dates = get_prediction_values_and_dates(loop_algorithm_input)
47112
48113
p_idx = pd.to_datetime(dates, utc=True)
@@ -52,13 +117,79 @@ def generate_bgi_series_from_insulin_prediction(loop_algorithm_input: dict) -> p
52117
# Typically negative during insulin action because predicted glucose is descending.
53118
bgi = pred.shift(-1) - pred
54119
return bgi
120+
"""
121+
def generate_bgi_series_from_activity(
122+
df: pd.DataFrame,
123+
*,
124+
loop_algorithm_input: dict,
125+
isf: float,
126+
insulin_type: str = "novolog",
127+
) -> pd.Series:
128+
"""
129+
Compute BGI(t) as oref0's categorize.js:
130+
131+
BGI = -iob.activity * sens * 5
132+
133+
where iob.activity = sum of activityContrib across all doses (U/min).
134+
135+
Key properties vs alternatives:
136+
- No future CGM data needed (pure insulin model, analytical)
137+
- No nonlinearity error (exact derivative, not finite IOB difference)
138+
- Covers the full CGM history window (not limited to prediction horizon)
139+
- insulin_type resolved from loop_algorithm_input["insulinType"] if present
140+
141+
"""
142+
resolved_type = loop_algorithm_input.get("insulinType", insulin_type).lower()
143+
params = _get_model_params(resolved_type)
144+
145+
action_duration = params["action_duration"]
146+
peak_activity_time = params["peak_activity_time"]
147+
delay = params["delay"]
148+
149+
doses = loop_algorithm_input.get("doses", []) or []
150+
151+
out = _to_utc_index(df)
152+
bgis: list[float] = []
153+
154+
for ts in out.index:
155+
total_activity = 0.0 # U/min
156+
157+
for dose in doses:
158+
dose_type = dose.get("type", "")
159+
if dose_type not in ("bolus", "basal"):
160+
continue
161+
162+
volume = float(dose.get("volume", 0) or 0)
163+
if volume <= 0:
164+
continue
165+
166+
dose_time = pd.to_datetime(dose["startDate"], utc=True)
167+
mins_ago = (ts - dose_time).total_seconds() / 60.0
168+
169+
# Only doses within the insulin action window
170+
if mins_ago < 0 or mins_ago > action_duration + delay:
171+
continue
172+
173+
activity = insulin_activity_at(
174+
mins_ago, action_duration, peak_activity_time, delay
175+
) # fraction/min
176+
total_activity += activity * volume # U/min
177+
178+
# BGI = -activity * ISF * 5 → mg/dL per 5 min
179+
bgi = -total_activity * isf * 5
180+
bgis.append(round(bgi, 3))
181+
182+
return pd.Series(bgis, index=out.index, dtype="float64")
183+
55184

56185

57186
def add_bgi_to_history_df(
58187
df: pd.DataFrame,
188+
isf: float,
59189
bgi_col: str = "BGI",
60190
align: AlignMode = "ffill",
61191
loop_algorithm_input: dict | None = None,
192+
62193
) -> pd.DataFrame:
63194
"""
64195
Adds a BGI column to the given history dataframe by generating a BGI series from predictions.
@@ -67,19 +198,26 @@ def add_bgi_to_history_df(
67198

68199
if loop_algorithm_input is None:
69200
loop_algorithm_input = api.get_loop_algorithm_input()
70-
71-
bgi_pred = generate_bgi_series_from_insulin_prediction(loop_algorithm_input)
72-
73-
# Aligning timestamps
74-
if align == "ffill":
75-
out[bgi_col] = bgi_pred.reindex(out.index, method="ffill")
76-
elif align == "nearest":
77-
out[bgi_col] = bgi_pred.reindex(out.index, method="nearest")
78-
elif align == "strict":
79-
out[bgi_col] = bgi_pred.reindex(out.index)
80-
else:
81-
raise ValueError("align must be one of: 'ffill', 'nearest', 'strict'.")
82-
201+
insulin_type = loop_algorithm_input.get("insulinType", "novolog")
202+
203+
#bgi_pred = generate_bgi_series_from_insulin_prediction(loop_algorithm_input)
204+
205+
206+
# # Aligning timestamps needed for bgi from predictions as they are "in the future"
207+
# if align == "ffill":
208+
# out[bgi_col] = bgi_pred.reindex(out.index, method="ffill")
209+
# elif align == "nearest":
210+
# out[bgi_col] = bgi_pred.reindex(out.index, method="nearest")
211+
# elif align == "strict":
212+
# out[bgi_col] = bgi_pred.reindex(out.index)
213+
# else:
214+
# raise ValueError("align must be one of: 'ffill', 'nearest', 'strict'.")
215+
out[bgi_col] = generate_bgi_series_from_activity(
216+
out,
217+
loop_algorithm_input=loop_algorithm_input,
218+
isf=isf,
219+
insulin_type=insulin_type,
220+
)
83221
return out
84222

85223

@@ -106,22 +244,10 @@ def add_iob_to_history_df(
106244
from loop_algorithm_input if present, otherwise falls back to the
107245
`insulin_type` parameter.
108246
109-
:param df: DatetimeIndex dataframe with at least 'basal' and 'bolus' columns.
110-
If missing, basal is filled from the `basal` parameter and bolus
111-
is set to NaN.
112-
:param loop_algorithm_input: Full LoopAlgorithm JSON input dict. Used to
113-
read insulinType if present.
114-
:param basal: Basal rate (U/hr) — used if df has no 'basal' column.
115-
:param isf: Insulin sensitivity factor (mg/dL per U).
116-
:param cr: Carbohydrate ratio (g per U).
117-
:param iob_col: Name of the output column. Default: 'IOB'.
118-
:param insulin_type: Insulin model to use. Default: 'novolog'.
119247
:param lookback: Number of rows (5-min intervals) to include in each IOB
120248
calculation. Default 72 = 6 hours.
121-
:return: Copy of df with the IOB column added.
122249
"""
123250

124-
125251
resolved_insulin_type = loop_algorithm_input.get("insulinType", insulin_type)
126252

127253
out = _to_utc_index(df)
@@ -164,21 +290,10 @@ def add_cob_to_history_df(
164290
Each row's COB is computed from the `lookback` preceding rows using
165291
get_active_carbs from loop_to_python_api.
166292
167-
:param df: DatetimeIndex dataframe. Should contain a 'carbs' column with
168-
meal entries (NaN between meals). If missing, COB will always
169-
be 0.
170-
:param loop_algorithm_input: Full LoopAlgorithm JSON input dict. Used to
171-
read insulinType if present.
172-
:param basal: Basal rate (U/hr).
173-
:param isf: Insulin sensitivity factor (mg/dL per U).
174-
:param cr: Carbohydrate ratio (g per U).
175-
:param cob_col: Name of the output column. Default: 'COB'.
176-
:param insulin_type: Insulin model to use. Default: 'novolog'.
177293
:param lookback: Number of rows (5-min intervals) to look back. Default 72 = 6 hours.
178-
:return: Copy of df with the COB column added.
294+
179295
"""
180296

181-
182297
resolved_insulin_type = loop_algorithm_input.get("insulinType", insulin_type)
183298

184299
out = _to_utc_index(df)
@@ -213,7 +328,7 @@ def add_avg_delta_to_history_df(
213328
Adds avgDelta as a recent-past slope estimate.
214329
215330
oref0 computes avgDelta over the last 4 CGM datapoints (i.e., ~15 minutes of history),
216-
so we default window_points=4.
331+
so default window_points=4.
217332
218333
Units: mg/dL per 5 minutes.
219334
"""
@@ -241,6 +356,9 @@ def add_deviation_to_history_df(
241356
) -> pd.DataFrame:
242357
"""
243358
Adds deviation = avgDelta - BGI as a column.
359+
deviation > 0 : BG rising more than insulin predicts (carbs / UAM)
360+
deviation < 0 : BG falling more than insulin predicts (ISF too weak)
361+
deviation ≈ 0 : insulin model explains BG movement well
244362
"""
245363
out = _to_utc_index(df)
246364
if avg_delta_col not in out.columns:
@@ -254,7 +372,7 @@ def add_deviation_to_history_df(
254372

255373

256374
###########################
257-
# PREP For PIPELINE #
375+
# BUILDING FOR PREP #
258376
###########################
259377

260378

@@ -269,7 +387,11 @@ def build_isf_glucose_data_from_df(
269387
cob_col: str = "COB", # passed through if present
270388
) -> list[dict]:
271389
"""
272-
Builds list of dicts with keys "date", "avgDelta", "BGI", "deviation" for ISF tuning.
390+
Converts the enriched df into a list of point dicts for autotune_prep.
391+
392+
Points where avgDelta, BGI, or deviation are NaN are skipped —
393+
these are the warmup rows at the start of the window where
394+
avgDelta needs 4 prior points to be valid.
273395
"""
274396
if not isinstance(df.index, pd.DatetimeIndex):
275397
raise ValueError("df must have a DatetimeIndex.")
@@ -330,8 +452,16 @@ def prepare_isf_glucose_data(
330452
) -> tuple[pd.DataFrame, list[dict]]:
331453
"""
332454
Add BGI/avgDelta/deviation to df and prepare points for autotune_prep.
455+
456+
Call order and reasoning:
457+
1. BGI — analytical, from insulin model, no other columns needed
458+
2. avgDelta — from CGM only
459+
3. deviation — requires BGI and avgDelta
460+
4. IOB — independent; needed by categoriser later, not by BGI
461+
5. COB — optional, for debugging only
462+
333463
"""
334-
df2 = add_bgi_to_history_df(df, loop_algorithm_input=loop_algorithm_input, bgi_col=bgi_col, align="ffill",)
464+
df2 = add_bgi_to_history_df(df, isf, bgi_col=bgi_col, align="ffill",loop_algorithm_input=loop_algorithm_input,)
335465
df2 = add_avg_delta_to_history_df(df2, cgm_col=cgm_col, avg_delta_col="avgDelta", window_points=4)
336466
df2 = add_deviation_to_history_df(df2, avg_delta_col="avgDelta", bgi_col=bgi_col, deviation_col="deviation")
337467

21 Bytes
Binary file not shown.
11 KB
Binary file not shown.

0 commit comments

Comments
 (0)