1212FIVE_MINUTES = 5
1313
1414
15- # -------------------------
16- # Time + schedule utilities
17- # -------------------------
18-
1915def parse_loop_timestamp (iso8601 : str ) -> datetime :
20- """Parse Loop ISO8601 timestamps like '2023-10-17T20:59:03Z' into tz-aware datetime."""
2116 return datetime .fromisoformat (iso8601 .replace ("Z" , "+00:00" ))
2217
2318
2419def _lookup_schedule_value (at_time : datetime , schedule : list [dict ]) -> float :
25- """
26- Look up schedule value at time from Loop schedule segments:
27- [{"startDate","endDate","value"}...]
28- Includes closest-prior fallback.
29- """
30- # exact segment match
3120 for seg in schedule :
3221 start = parse_loop_timestamp (seg ["startDate" ])
3322 end = parse_loop_timestamp (seg ["endDate" ])
3423 if start <= at_time < end :
3524 return float (seg ["value" ])
3625
37- # closest prior segment
3826 closest = None
3927 closest_start = None
4028 for seg in schedule :
@@ -50,30 +38,18 @@ def _lookup_schedule_value(at_time: datetime, schedule: list[dict]) -> float:
5038
5139
5240def lookup_isf_mgdl_per_u (at_time : datetime , sensitivity_schedule : list [dict ]) -> float :
53- """ISF (mg/dL/U) at time."""
5441 return _lookup_schedule_value (at_time , sensitivity_schedule )
5542
5643
5744def lookup_basal_rate_u_per_hour (at_time : datetime , basal_schedule : list [dict ]) -> float :
58- """Basal rate (U/hr) at time."""
5945 return _lookup_schedule_value (at_time , basal_schedule )
6046
6147
6248def convert_basal_rate_to_microbolus_units (basal_rate_u_per_hour : float , step_minutes : int = FIVE_MINUTES ) -> float :
63- """Convert U/hr to U delivered over one timestep."""
6449 return basal_rate_u_per_hour * (step_minutes / 60.0 )
6550
6651
67- # -------------------------
68- # Dose event extraction
69- # -------------------------
70-
7152def extract_bolus_events (loop_algorithm_input : dict ) -> list [tuple [datetime , float ]]:
72- """
73- Extract bolus events from LoopAlgorithmInput doses[]:
74- {"type":"bolus","startDate":...,"volume":...}
75- Returns list[(time, units)].
76- """
7753 out : list [tuple [datetime , float ]] = []
7854 for dose in loop_algorithm_input .get ("doses" , []):
7955 if dose .get ("type" ) != "bolus" :
@@ -92,16 +68,12 @@ def build_microbasal_events(
9268 end_time : datetime ,
9369 step_minutes : int = FIVE_MINUTES ,
9470) -> list [tuple [datetime , float ]]:
95- """
96- Discretize scheduled basal into micro-doses: one event every step_minutes.
97- Returns list[(time, units_over_step)].
98- """
9971 basal_schedule = loop_algorithm_input ["basal" ]
10072 events : list [tuple [datetime , float ]] = []
10173
10274 t = start_time
10375 while t < end_time :
104- rate = lookup_basal_rate_u_per_hour (t , basal_schedule ) # U/hr
76+ rate = lookup_basal_rate_u_per_hour (t , basal_schedule )
10577 u = convert_basal_rate_to_microbolus_units (rate , step_minutes = step_minutes )
10678 if u > 0 :
10779 events .append ((t , u ))
@@ -110,20 +82,14 @@ def build_microbasal_events(
11082 return events
11183
11284
113- # -------------------------
114- # BGI computation
115- # -------------------------
116-
11785@dataclass (frozen = True )
11886class BGIConfig :
11987 action_duration_minutes : int
12088 peak_activity_minutes : int
12189 delay_minutes : int
12290 step_minutes : int = FIVE_MINUTES
12391 history_hours : int = 16
124-
125- # numeric safety
126- min_effect_fraction : float = 0.0 # tune later if needed
92+ min_effect_fraction : float = 0.0
12793
12894
12995def _batch_percent_remaining (
@@ -134,9 +100,6 @@ def _batch_percent_remaining(
134100 peak_activity_minutes : int ,
135101 delay_minutes : int ,
136102) -> np .ndarray :
137- """
138- Batch wrapper around insulin_percent_effect_remaining (scalar API).
139- """
140103 return np .fromiter (
141104 (
142105 fn (
@@ -161,18 +124,6 @@ def add_bgi_to_history_df(
161124 cgm_col : str = "CGM" ,
162125 bgi_col : str = "BGI" ,
163126) -> pd .DataFrame :
164- """
165- Add BGI (mg/dL per step_minutes) aligned to df.index.
166-
167- BGI(t) computed oref0-style:
168- units_effect_next_step = sum(dose_units * (R(age) - R(age+step)))
169- BGI = - units_effect_next_step * ISF(t)
170-
171- Upstream call policy:
172- - If insulin_percent_effect_remaining is not supplied, this function will
173- use loop_to_python_adaptive.api.insulin_percent_effect_remaining internally.
174- - This keeps upstream usage contained inside loop_bgi.py.
175- """
176127 if insulin_percent_effect_remaining is None :
177128 insulin_percent_effect_remaining = api .insulin_percent_effect_remaining
178129
@@ -181,6 +132,7 @@ def add_bgi_to_history_df(
181132 if cgm_col not in df .columns :
182133 raise ValueError (f"df missing required column { cgm_col !r} ." )
183134
135+ # Normalize to tz-aware UTC index
184136 idx = df .index
185137 if idx .tz is None :
186138 idx = idx .tz_localize ("UTC" )
@@ -191,7 +143,7 @@ def add_bgi_to_history_df(
191143 out .index = idx
192144 out [bgi_col ] = np .nan
193145
194- # Build event window: include enough history so older doses are negligible
146+ # Build event window
195147 end_time = idx .max ().to_pydatetime ()
196148 start_time = (idx .min () - timedelta (hours = config .history_hours )).to_pydatetime ()
197149
@@ -208,33 +160,37 @@ def add_bgi_to_history_df(
208160 out [bgi_col ] = 0.0
209161 return out
210162
211- event_times = np .array ([t for (t , _ ) in events ], dtype = "datetime64[ns]" )
163+ # Convert event times to tz-naive UTC datetime64[ns] (numpy has no tz support)
164+ event_times = np .array (
165+ [np .datetime64 (pd .Timestamp (t ).tz_convert ("UTC" ).tz_localize (None ).to_datetime64 ()) for (t , _ ) in events ],
166+ dtype = "datetime64[ns]" ,
167+ )
212168 event_units = np .array ([u for (_ , u ) in events ], dtype = float )
213169
214170 left = 0
215171 right = 0
216172 action_td = np .timedelta64 (config .action_duration_minutes , "m" )
217173 step = float (config .step_minutes )
218174
219- # main loop
220- for t in out .index .to_pydatetime ():
221- t64 = np .datetime64 (t )
175+ # Iterate over pandas timestamps directly (no pd.Timestamp(t, tz=...) needed)
176+ for ts in out .index :
177+ # ts is tz-aware UTC pandas Timestamp
178+ t64 = ts .tz_convert ("UTC" ).tz_localize (None ).to_datetime64 ()
222179
223- # include events <= t
224180 while right < len (event_times ) and event_times [right ] <= t64 :
225181 right += 1
226182
227- # exclude events < t - action_duration
228183 cutoff = t64 - action_td
229184 while left < right and event_times [left ] < cutoff :
230185 left += 1
231186
232187 if left >= right :
233- out .at [pd . Timestamp ( t , tz = "UTC" ) , bgi_col ] = 0.0
188+ out .at [ts , bgi_col ] = 0.0
234189 continue
235190
236191 active_times = event_times [left :right ]
237192 active_units = event_units [left :right ]
193+
238194 ages = ((t64 - active_times ) / np .timedelta64 (1 , "m" )).astype (float )
239195
240196 r_now = _batch_percent_remaining (
@@ -255,8 +211,9 @@ def add_bgi_to_history_df(
255211 frac = np .maximum (config .min_effect_fraction , r_now - r_later )
256212 units_effect_next = float (np .sum (active_units * frac ))
257213
258- sens = lookup_isf_mgdl_per_u (t , loop_algorithm_input ["sensitivity" ])
259- out .at [pd .Timestamp (t , tz = "UTC" ), bgi_col ] = - units_effect_next * sens
214+ # schedule lookup uses python datetime (tz-aware)
215+ sens = lookup_isf_mgdl_per_u (ts .to_pydatetime (), loop_algorithm_input ["sensitivity" ])
216+ out .at [ts , bgi_col ] = - units_effect_next * sens
260217
261218 return out
262219
@@ -267,15 +224,6 @@ def build_isf_glucose_data_from_df(
267224 cgm_col : str = "CGM" ,
268225 bgi_col : str = "BGI" ,
269226) -> list [dict ]:
270- """
271- Build ISF tuning points for tune_isf_like_oref0().
272-
273- Align BGI as "effect over next timestep":
274- avgDelta[i] = CGM[i] - CGM[i-1]
275- deviation[i] = avgDelta[i] - BGI[i-1]
276-
277- Returns list of dicts with keys: date, avgDelta, BGI, deviation.
278- """
279227 if not isinstance (df .index , pd .DatetimeIndex ):
280228 raise ValueError ("df must have a DatetimeIndex." )
281229 if cgm_col not in df .columns :
@@ -299,8 +247,8 @@ def build_isf_glucose_data_from_df(
299247 if pd .isna (bgi .iat [i - 1 ]):
300248 continue
301249
302- avg_delta = float (cgm .iat [i ] - cgm .iat [i - 1 ]) # mg/dL per step
303- bgi_step = float (bgi .iat [i - 1 ]) # mg/dL per step
250+ avg_delta = float (cgm .iat [i ] - cgm .iat [i - 1 ])
251+ bgi_step = float (bgi .iat [i - 1 ])
304252 deviation = float (avg_delta - bgi_step )
305253
306254 ts = idx [i ]
@@ -317,14 +265,6 @@ def prepare_isf_glucose_data(
317265 cgm_col : str = "CGM" ,
318266 bgi_col : str = "BGI" ,
319267) -> tuple [pd .DataFrame , list [dict ]]:
320- """
321- Convenience wrapper for autotune_prep:
322- - compute df_with_BGI
323- - build isf_glucose_data list
324-
325- Returns:
326- (df_with_BGI, isf_glucose_data)
327- """
328268 df2 = add_bgi_to_history_df (
329269 df ,
330270 loop_algorithm_input = loop_algorithm_input ,
0 commit comments