Skip to content

Commit ba178fa

Browse files
author
Sigrid Tofte Thiis
committed
added test files
1 parent 6ee0cb4 commit ba178fa

11 files changed

Lines changed: 2212 additions & 84 deletions
0 Bytes
Binary file not shown.
0 Bytes
Binary file not shown.
2.59 KB
Binary file not shown.
Binary file not shown.
6.93 KB
Binary file not shown.

loop_to_python_adaptive/loop_bgi.py

Lines changed: 20 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -12,29 +12,17 @@
1212
FIVE_MINUTES = 5
1313

1414

15-
# -------------------------
16-
# Time + schedule utilities
17-
# -------------------------
18-
1915
def 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

2419
def _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

5240
def 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

5744
def 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

6248
def 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-
7152
def 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)
11886
class 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

12995
def _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,

loop_to_python_adaptive/sandbox.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
import loop_to_python_adaptive.api as api # replace with your real import
1+
from pathlib import Path
2+
import json
23

3-
print("has get_glucose_effect_velocity:", hasattr(api, "get_glucose_effect_velocity"))
4-
print("callable:", callable(getattr(api, "get_glucose_effect_velocity", None)))
5-
print("module file:", api.__file__)
4+
HERE = Path(__file__).resolve()
5+
REPO_ROOT = HERE.parent.parent # loop_to_python_adaptive/ -> repo root
6+
7+
FIXTURE = REPO_ROOT / "python_tests" / "test_files" / "loop_algorithm_input.json"
8+
loop_input = json.loads(FIXTURE.read_text(encoding="utf-8"))
9+
print("Loaded fixture:", FIXTURE)
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
{
2+
"inputICE": [
3+
{
4+
"velocity": 0.0,
5+
"start_at": "2015-10-15T21:30:12",
6+
"end_at": "2015-10-15T21:35:12"
7+
},
8+
{
9+
"velocity": 5.0,
10+
"start_at": "2015-10-15T21:35:12",
11+
"end_at": "2015-10-15T21:40:12"
12+
},
13+
{
14+
"velocity": 3.0,
15+
"start_at": "2015-10-15T21:40:12",
16+
"end_at": "2015-10-15T21:45:12"
17+
},
18+
{
19+
"velocity": 2.0,
20+
"start_at": "2015-10-15T21:45:12",
21+
"end_at": "2015-10-15T21:50:12"
22+
},
23+
{
24+
"velocity": 3.0,
25+
"start_at": "2015-10-15T21:50:12",
26+
"end_at": "2015-10-15T21:55:12"
27+
},
28+
{
29+
"velocity": 5.0,
30+
"start_at": "2015-10-15T21:55:12",
31+
"end_at": "2015-10-15T22:00:12"
32+
},
33+
{
34+
"velocity": -2.0,
35+
"start_at": "2015-10-15T22:00:12",
36+
"end_at": "2015-10-15T22:05:12"
37+
}
38+
],
39+
"carbEntries": [
40+
{
41+
"grams": 44,
42+
"date": "2015-10-15T21:35:12Z",
43+
"absorptionTime": 120,
44+
"unit": "g"
45+
},
46+
{
47+
"grams": 30,
48+
"date": "2015-10-15T21:55:00Z",
49+
"absorptionTime": 120,
50+
"unit": "g"
51+
},
52+
{
53+
"grams": 30,
54+
"date": "2015-10-15T23:00:00Z",
55+
"absorptionTime": 120,
56+
"unit": "g"
57+
}
58+
],
59+
"sensitivity": 63,
60+
"carbRatio": 10.0
61+
}

0 commit comments

Comments
 (0)