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+ """
117from __future__ import annotations
218
319from dataclasses import dataclass
824
925@dataclass (frozen = True )
1026class 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