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+ """
110from __future__ import annotations
211from dataclasses import dataclass
312from typing import Literal
716import loop_to_python_adaptive .api as api
817import 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
1221AlignMode = 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
2147def _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)
3398class BGIConfig:
3499 action_duration_minutes: int
@@ -39,10 +104,10 @@ class BGIConfig:
39104
40105
41106def 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
57186def 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
0 commit comments