Skip to content

Commit 581cf7a

Browse files
author
Sigrid Tofte Thiis
committed
updated how to run autotune_isf
1 parent 5a9062a commit 581cf7a

1 file changed

Lines changed: 294 additions & 59 deletions

File tree

Lines changed: 294 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
"""
2+
This module implements the ISF tuning portion of openaps/oref0 lib/autotune/index.js.
3+
The new ISF is returned and should be fed back in as
4+
`isf_current` on the next iteration.
5+
6+
oref0 ISF tuning steps (from index.js):
7+
1. ratio(i) = 1 + deviation(i) / BGI(i)
8+
# for each ISF-categorised point
9+
2. fullNewISF = isf_current * median(ratios)
10+
3. adjustedISF = adjustmentFraction * fullNewISF + (1 - adjustmentFraction) * pump_isf
11+
then cap adjustedISF to [pump_isf / autosens_max, pump_isf / autosens_min]
12+
# Since adjustmentFraction is set to 1 by default this step most often does nothing, but it is in oref0 so we include it
13+
4. newISF = 0.8 * isf_current + 0.2 * adjustedISF
14+
then cap newISF to same bounds
15+
5. If fewer than min_points ISF data points → leave ISF unchanged
16+
"""
117
from __future__ import annotations
218

319
from dataclasses import dataclass
@@ -8,94 +24,313 @@
824

925
@dataclass(frozen=True)
1026
class AutotuneISFConfig:
11-
min_points: int = 10
12-
adjustment_fraction: float = 0.2 # oref0 uses 20%
27+
"""
28+
Mirrors the oref0 profile fields used in index.js for ISF tuning.
29+
30+
adjustment_fraction : autotune_isf_adjustmentFraction in oref0.
31+
Blends fullNewISF toward pump_isf.
32+
1.0 = full adjustment (oref0 default),
33+
0.0 = no adjustment from pump ISF.
34+
autosens_max / min : safety caps as multiples of pump_isf.
35+
oref0 defaults: max=1.2, min=0.7.
36+
min_points : require at least this many ISF data points
37+
before tuning (oref0: 10).
38+
min_bgi_abs : skip points where |BGI| is too small to
39+
divide by safely.
40+
"""
41+
min_points: int = 10 # oref0 default
42+
adjustment_fraction: float = 1 # oref0 default
1343
autosens_max: float = 1.2 # oref0 default
1444
autosens_min: float = 0.7 # oref0 default
1545
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
46+
47+
#################
48+
# HELPERS #
49+
#################
50+
51+
def extract_pump_isf(loop_algorithm_input: dict) -> float:
52+
"""
53+
Extract the ISF value from a loop_algorithm_input dict.
54+
55+
loop_algorithm_input["sensitivity"] is a list of schedule entries:
56+
[{"startDate": ..., "endDate": ..., "value": <float>}]
57+
58+
For autotune we use the first entry as the pump's anchor ISF,
59+
matching how oref0 reads pumpISF from the pump profile.
60+
"""
61+
sensitivity = loop_algorithm_input.get("sensitivity", [])
62+
if not sensitivity:
63+
raise ValueError(
64+
"loop_algorithm_input has no 'sensitivity' key. "
65+
)
66+
return float(sensitivity[0]["value"])
67+
68+
69+
def extract_pump_basal(loop_algorithm_input: dict) -> float:
70+
"""
71+
Extract the basal rate value from a loop_algorithm_input dict.
72+
73+
loop_algorithm_input["basal"] is a list of schedule entries:
74+
[{"startDate": ..., "endDate": ..., "value": <float>}]
75+
76+
For autotune we use the first entry as the pump's anchor basal rate,
77+
matching how oref0 reads pumpBasal from the pump profile.
78+
"""
79+
basal = loop_algorithm_input.get("basal", [])
80+
if not basal:
81+
raise ValueError(
82+
"loop_algorithm_input has no 'basal' key. "
83+
)
84+
return float(basal[0]["value"])
85+
86+
def extract_pump_cr(loop_algorithm_input: dict) -> float:
87+
"""
88+
Extract the carbohydrate ratio (CR) value from a loop_algorithm_input dict.
89+
90+
loop_algorithm_input["carbRatio"] is a list of schedule entries:
91+
[{"startDate": ..., "endDate": ..., "value": <float>}]
92+
93+
For autotune we use the first entry as the pump's anchor carbohydrate ratio,
94+
matching how oref0 reads pumpCR from the pump profile.
95+
"""
96+
carb_ratio = loop_algorithm_input.get("carbRatio", [])
97+
if not carb_ratio:
98+
raise ValueError(
99+
"loop_algorithm_input has no 'carbRatio' key. "
100+
)
101+
return float(carb_ratio[0]["value"])
18102

19103

20-
def tune_isf_like_oref0(
104+
def tune_isf(
21105
*,
22106
isf_current: float,
23107
isf_glucose_data: list[dict[str, Any]],
24-
pump_isf: Optional[float] = None,
25-
config: AutotuneISFConfig | None = None,
108+
pump_isf: float,
109+
cfg: AutotuneISFConfig = AutotuneISFConfig(),
26110
) -> dict[str, Any]:
27111
"""
28-
oref0-like ISF tuning:
29-
ratio = 1 + deviation / BGI
30-
fullNewISF = ISF * median(ratio)
31-
newISF = (1-adjustment_fraction)*ISF + adjustment_fraction*adjustedISF
112+
Tune ISF for one iteration, exactly following oref0 index.js.
113+
114+
Parameters
115+
----------
116+
isf_current : ISF used this iteration (mg/dL per U). On the first
117+
call this is the user's pump ISF. On later calls
118+
pass in the previous iteration's `newISF`.
119+
isf_glucose_data : List of dicts with at least keys "deviation" and "BGI".
120+
These are the ISF-categorised points from autotune_prep.
121+
pump_isf : The user's original pump ISF, used as the safety anchor.
122+
In oref0 this never changes across iterations.
123+
cfg : AutotuneISFConfig.
124+
125+
Returns
126+
-------
127+
dict with keys:
128+
newISF : ISF to use next iteration.
129+
fullNewISF : Raw ISF implied by the data (before blending/capping).
130+
adjustedISF : After adjustmentFraction blend and first cap.
131+
p50_ratio : Median of per-point ratios.
132+
n_points : Number of usable ISF data points.
133+
reason : Human-readable status string.
32134
"""
33-
cfg = config or AutotuneISFConfig()
34135

136+
# Step 1: compute per-point ratios
137+
35138
ratios: list[float] = []
36139
for p in isf_glucose_data:
37-
dev = float(p["deviation"])
38140
bgi = float(p["BGI"])
39141
if abs(bgi) < cfg.min_bgi_abs:
40142
continue
143+
dev = float(p["deviation"])
41144
r = 1.0 + dev / bgi
42145
if not np.isfinite(r):
43146
continue
44-
r = float(np.clip(r, cfg.ratio_clip_low, cfg.ratio_clip_high))
45147
ratios.append(r)
46148

149+
150+
# Step 2: require minimum data
151+
# oref0: "leave ISF unchanged if fewer than 10 ISF data points"
152+
47153
if len(ratios) < cfg.min_points:
154+
print(
155+
f"Only found {len(ratios)} ISF data points, "
156+
f"leaving ISF unchanged at {isf_current}"
157+
)
48158
return {
49-
"newISF": float(isf_current),
50-
"p50_ratio": None,
51-
"fullNewISF": None,
159+
"newISF": isf_current,
160+
"fullNewISF": None,
52161
"adjustedISF": None,
53-
"n_points": len(ratios),
54-
"reason": f"Only {len(ratios)} usable ISF points (<{cfg.min_points}); leaving ISF unchanged.",
162+
"p50_ratio": None,
163+
"n_points": len(ratios),
164+
"reason": f"Only {len(ratios)} ISF points (<{cfg.min_points}); ISF unchanged.",
55165
}
56166

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}, ")
167+
168+
# Step 3: fullNewISF = isf_current * median(ratios)
169+
170+
p50_ratio = float(np.median(ratios))
171+
full_new_isf = round(isf_current * p50_ratio, 3)
172+
173+
174+
# Step 4: adjustedISF = blend fullNewISF toward pump_isf
175+
# oref0: adjustedISF = adjustmentFraction*fullNewISF + (1 - adjustmentFraction)*pumpISF
176+
# Then cap to [pump_isf/autosens_max, pump_isf/autosens_min]
177+
178+
# low autosens ratio = high ISF → maxISF = pumpISF / autosens_min
179+
# high autosens ratio = low ISF → minISF = pumpISF / autosens_max
180+
max_isf = pump_isf / cfg.autosens_min
181+
min_isf = pump_isf / cfg.autosens_max
182+
183+
if full_new_isf < 0:
184+
# oref0: "if fullNewISF < 0, adjustedISF = ISF" (leave unchanged)
185+
adjusted_isf = isf_current
186+
else:
187+
adjusted_isf = (
188+
cfg.adjustment_fraction * full_new_isf
189+
+ (1.0 - cfg.adjustment_fraction) * pump_isf
190+
)
191+
192+
# first cap
193+
if adjusted_isf > max_isf:
194+
print(
195+
f"Limiting adjusted ISF of {adjusted_isf:.2f} to {max_isf:.2f} "
196+
f"(pump ISF {pump_isf} / autosens_min {cfg.autosens_min})"
197+
)
198+
adjusted_isf = max_isf
199+
elif adjusted_isf < min_isf:
200+
print(
201+
f"Limiting adjusted ISF of {adjusted_isf:.2f} to {min_isf:.2f} "
202+
f"(pump ISF {pump_isf} / autosens_max {cfg.autosens_max})"
203+
)
204+
adjusted_isf = min_isf
205+
206+
207+
# Step 5: slow 20% update toward adjustedISF
208+
209+
new_isf = 0.8 * isf_current + 0.2 * adjusted_isf
210+
211+
# second cap (same bounds)
212+
if new_isf > max_isf:
213+
print(
214+
f"Limiting ISF of {new_isf:.2f} to {max_isf:.2f} "
215+
f"(pump ISF {pump_isf} / autosens_min {cfg.autosens_min})"
216+
)
217+
new_isf = max_isf
218+
elif new_isf < min_isf:
219+
print(
220+
f"Limiting ISF of {new_isf:.2f} to {min_isf:.2f} "
221+
f"(pump ISF {pump_isf} / autosens_max {cfg.autosens_max})"
222+
)
223+
new_isf = min_isf
224+
225+
new_isf = round(new_isf, 3)
226+
adjusted_isf = round(adjusted_isf, 3)
227+
p50_ratio = round(p50_ratio, 3)
228+
229+
print(
230+
f"p50_ratio: {p50_ratio} "
231+
f"Old ISF: {isf_current} fullNewISF: {full_new_isf} "
232+
f"adjustedISF: {adjusted_isf} newISF: {new_isf}"
233+
)
234+
72235
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",
236+
"newISF": new_isf,
237+
"fullNewISF": full_new_isf,
238+
"adjustedISF": adjusted_isf,
239+
"p50_ratio": p50_ratio,
240+
"n_points": len(ratios),
241+
"reason": "OK",
79242
}
80243

81244

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,
245+
###############
246+
# ENTRY #
247+
###############
248+
249+
250+
def run_autotune_isf_iterations(
251+
df_windows: list, # list of pd.DataFrames, one per day
252+
*,
253+
loop_algorithm_inputs: list[dict], # one per window, aligned with df_windows
254+
n_iterations: int = 1, # number of passes
255+
cfg: AutotuneISFConfig = AutotuneISFConfig(),
256+
) -> dict[str, Any]:
257+
"""
258+
Run ISF autotune for `n_iterations` passes over the data windows.
259+
260+
On each iteration the tuned ISF from the previous pass is fed back in
261+
as `isf_current` for the next pass — exactly as oref0 re-runs daily.
262+
263+
Parameters
264+
----------
265+
df_windows : List of DataFrames (e.g. one per 24h window).
266+
loop_algorithm_inputs: Matching list of LoopAlgorithm JSON input dicts,
267+
one per window (used for BGI prediction and carb entries).
268+
pump_isf : User's pump ISF — fixed anchor for safety caps.
269+
pump_basal : User's pump basal rate (U/hr).
270+
pump_cr : User's pump carb ratio (g/U).
271+
n_iterations : How many full passes over the windows to run.
272+
cfg : AutotuneISFConfig.
273+
274+
Returns
275+
-------
276+
dict with keys:
277+
finalISF : The ISF after all iterations.
278+
isf_history : List of ISF values after each iteration (length = n_iterations).
279+
last_result : Full result dict from the final tune_isf() call.
280+
"""
281+
from loop_to_python_adaptive.autotune_prep import (
282+
AutotunePrepConfig,
283+
prepare_for_autotune_isf,
100284
)
101-
print(result)
285+
pump_isf = extract_pump_isf(loop_algorithm_inputs[0])
286+
pump_basal = extract_pump_basal(loop_algorithm_inputs[0])
287+
pump_cr = extract_pump_cr(loop_algorithm_inputs[0])
288+
289+
isf_current = pump_isf
290+
isf_history: list[float] = []
291+
last_result: dict[str, Any] = {}
292+
293+
for iteration in range(n_iterations):
294+
print(f"\n=== Autotune ISF iteration {iteration + 1}/{n_iterations} "
295+
f"(current ISF: {isf_current:.3f}) ===")
296+
297+
# Accumulate ISF glucose data across all windows for this iteration
298+
all_isf_points: list[dict[str, Any]] = []
299+
300+
prep_cfg = AutotunePrepConfig(
301+
basal_rate=pump_basal,
302+
isf=isf_current, # ← updated each iteration
303+
carb_ratio=pump_cr,
304+
)
305+
306+
for i, (df_window, loop_input) in enumerate(
307+
zip(df_windows, loop_algorithm_inputs)
308+
):
309+
print(f" Window {i + 1}/{len(df_windows)} ...", end=" ", flush=True)
310+
result = prepare_for_autotune_isf(
311+
df_window,
312+
loop_algorithm_input=loop_input,
313+
cfg=prep_cfg,
314+
)
315+
window_isf_points = result["ISFGlucoseData"]
316+
all_isf_points.extend(window_isf_points)
317+
print(f"{len(window_isf_points)} ISF points")
318+
319+
print(f" Total ISF points this iteration: {len(all_isf_points)}")
320+
321+
# Tune ISF on the accumulated points
322+
last_result = tune_isf(
323+
isf_current=isf_current,
324+
isf_glucose_data=all_isf_points,
325+
pump_isf=pump_isf,
326+
cfg=cfg,
327+
)
328+
329+
isf_current = last_result["newISF"]
330+
isf_history.append(isf_current)
331+
332+
return {
333+
"finalISF": isf_current,
334+
"isf_history": isf_history,
335+
"last_result": last_result,
336+
}

0 commit comments

Comments
 (0)