1+ """
2+ autosens.py — Rolling insulin sensitivity ratio (autosens) for AdaptiveLoopController.
3+
4+ oref0 reference: lib/autosens.js
5+ https://github.com/openaps/oref0/blob/master/lib/autosens.js
6+
7+ ────────────────────────────────────────────────────────────────────────────────
8+ RELATIONSHIP TO oref0
9+ ────────────────────────────────────────────────────────────────────────────────
10+
11+ In oref0, autosens runs every 5 minutes alongside the main loop. It looks at the
12+ last 8 hours (and separately 24 hours) of CGM data and asks:
13+
14+ "Has BG been moving more or less than insulin alone predicts?"
15+
16+ It answers this with a single number — the autosens ratio — computed as the
17+ median of per-point sensitivity ratios:
18+
19+ ratio(t) = deviation(t) / BGI(t) [oref0: lib/autosens.js line ~120]
20+
21+ where:
22+ deviation = avgDelta - BGI
23+ BGI = expected BG change per 5 min from insulin alone (negative during action)
24+
25+ Substituting:
26+ ratio(t) = (avgDelta - BGI) / BGI = avgDelta/BGI - 1
27+
28+ A ratio of 0.0 means BG moved exactly as insulin predicted → sensitivity unchanged
29+ A ratio of +0.2 means BG moved 20% more than predicted → patient is more sensitive
30+ A ratio of -0.2 means BG moved 20% less than predicted → patient is more resistant
31+
32+ oref0 then computes:
33+ autosens_ratio = 1 + median(ratios)
34+
35+ and clips it to [autosens_min, autosens_max] = [0.7, 1.2] by default.
36+
37+ The ratio is applied temporarily to the current ISF and basal for the next dose:
38+ effective_isf = pump_isf / autosens_ratio (higher ratio → lower ISF → more insulin)
39+ effective_basal = pump_basal * autosens_ratio
40+
41+ Note the asymmetry:
42+ - ISF is DIVIDED by the ratio (more sensitive → lower ISF → smaller correction bolus)
43+ - Basal is multiplied by the ratio (more sensitive → higher basal to cover background need)
44+
45+ This is different from autotune:
46+ - autotune permanently updates the pump ISF over days
47+ - autosens temporarily scales the current effective ISF every 5 minutes
48+
49+ ────────────────────────────────────────────────────────────────────────────────
50+ KEY DESIGN DECISIONS vs oref0
51+ ────────────────────────────────────────────────────────────────────────────────
52+
53+ 1. oref0 excludes points where COB > 0 or UAM is active from the autosens
54+ calculation, to avoid meal noise contaminating the ratio. We mirror this
55+ by accepting a list of deviation/BGI pairs that have already been filtered
56+ by the caller (AdaptiveLoopController) to exclude meal periods.
57+ The caller uses a simple heuristic: skip points where COB > 0 or
58+ |deviation| > deviation_threshold (default 6 mg/dL/5min, matching oref0).
59+
60+ 2. oref0 computes autosens over both 8h and 24h windows and takes the more
61+ conservative (closer to 1.0) of the two. We do the same.
62+
63+ 3. oref0 skips the autosens update if fewer than min_points valid ratios are
64+ available. We use min_points=10 as the default, matching oref0.
65+
66+ 4. The autosens ratio is NOT applied to the pump ISF directly — it scales
67+ the autotune ISF (which may already differ from pump ISF). This gives a
68+ two-layer architecture:
69+ effective_isf = autotune_isf / autosens_ratio
70+ where autotune_isf is the daily-updated baseline and autosens_ratio is
71+ the short-term correction.
72+ """
73+
74+ from __future__ import annotations
75+
76+ from collections import deque
77+ from dataclasses import dataclass , field
78+ from typing import Optional
79+
80+ import numpy as np
81+
82+
83+ # ─────────────────────────────────────────────────────────────────────────────
84+ # Configuration
85+ # ─────────────────────────────────────────────────────────────────────────────
86+
87+ @dataclass (frozen = True )
88+ class AutosensConfig :
89+ """
90+ Parameters controlling the autosens computation.
91+
92+ Corresponds to oref0 profile fields:
93+ autosens_max → profile.autosens.max (default 1.2)
94+ autosens_min → profile.autosens.min (default 0.7)
95+ window_8h_points → 8 * 60 / 5 = 96 CGM points
96+ window_24h_points→ 24 * 60 / 5 = 288 CGM points
97+ min_points → minimum valid ratios needed (oref0 uses ~10)
98+ deviation_threshold → exclude points where |deviation| > this value
99+ (oref0 uses 6 mg/dL/5min as the UAM threshold)
100+ min_bgi_abs → skip points where |BGI| is too small to divide by
101+ """
102+ autosens_max : float = 1.2 # oref0 default: profile.autosens.max
103+ autosens_min : float = 0.7 # oref0 default: profile.autosens.min
104+ window_8h_points : int = 96 # 8h × 60min / 5min
105+ window_24h_points : int = 288 # 24h × 60min / 5min
106+ min_points : int = 10 # minimum valid ratios before updating
107+ deviation_threshold : float = 6.0 # mg/dL/5min — exclude UAM/meal spikes
108+ min_bgi_abs : float = 1e-6 # avoid division by near-zero BGI
109+
110+
111+ # ─────────────────────────────────────────────────────────────────────────────
112+ # Per-point data record
113+ # ─────────────────────────────────────────────────────────────────────────────
114+
115+ @dataclass
116+ class AutosensPoint :
117+ """
118+ One CGM data point as seen by autosens.
119+
120+ oref0 autosens.js collects these fields for each 5-min bucket:
121+ deviation : avgDelta - BGI (mg/dL per 5 min)
122+ bgi : expected BG change from insulin (mg/dL per 5 min, usually negative)
123+ cob : carbs on board at this time (g) — used to exclude meal periods
124+ glucose : raw CGM value (mg/dL) — used for the BG<80 rule
125+
126+ All of these are already computed by loop_oref_mapping.py and stored
127+ in the per-step json/log by AdaptiveLoopController, so no extra work
128+ is needed to populate AutosensPoint.
129+ """
130+ deviation : float
131+ bgi : float
132+ cob : float = 0.0 # 0 if not tracked
133+ glucose : float = 100.0 # fallback if not available
134+
135+
136+ # ─────────────────────────────────────────────────────────────────────────────
137+ # Rolling buffer
138+ # ─────────────────────────────────────────────────────────────────────────────
139+
140+ class AutosensBuffer :
141+ """
142+ A fixed-length rolling buffer of AutosensPoints.
143+
144+ oref0 autosens.js maintains the last 24 hours of data (288 points at
145+ 5-min resolution) and computes autosens over two sub-windows: 8h and 24h.
146+
147+ We use a deque with maxlen=window_24h_points so old data is automatically
148+ discarded. The caller (AdaptiveLoopController) appends one point per
149+ 5-min CGM step via push().
150+
151+ oref0 reference: lib/autosens.js — the outer loop over glucose history
152+ """
153+
154+ def __init__ (self , cfg : AutosensConfig ):
155+ self .cfg = cfg
156+ # maxlen ensures oldest points are dropped automatically
157+ self ._buffer : deque [AutosensPoint ] = deque (maxlen = cfg .window_24h_points )
158+
159+ def push (self , point : AutosensPoint ) -> None :
160+ """
161+ Append one new CGM step to the buffer.
162+
163+ Called every 5 minutes by AdaptiveLoopController._loop_policy(),
164+ mirroring oref0's per-step autosens data collection.
165+ """
166+ self ._buffer .append (point )
167+
168+ def points_8h (self ) -> list [AutosensPoint ]:
169+ """Return the most recent 8 hours of points (up to 96)."""
170+ n = min (len (self ._buffer ), self .cfg .window_8h_points )
171+ return list (self ._buffer )[- n :]
172+
173+ def points_24h (self ) -> list [AutosensPoint ]:
174+ """Return up to 24 hours of points (up to 288)."""
175+ return list (self ._buffer )
176+
177+
178+ # ─────────────────────────────────────────────────────────────────────────────
179+ # Core ratio computation
180+ # ─────────────────────────────────────────────────────────────────────────────
181+
182+ def _compute_ratio_from_points (
183+ points : list [AutosensPoint ],
184+ cfg : AutosensConfig ,
185+ ) -> Optional [float ]:
186+ """
187+ Compute the autosens ratio from a list of AutosensPoints.
188+
189+ oref0 lib/autosens.js (line ~115):
190+
191+ ratio = deviation / BGI
192+
193+ A positive ratio means BG rose more than insulin predicted (more sensitive).
194+ A negative ratio means BG fell less than predicted (more resistant).
195+
196+ oref0 then computes:
197+ autosens_ratio = 1 + median(ratios)
198+
199+ and clips to [autosens_min, autosens_max].
200+
201+ oref0 excludes points where:
202+ - COB > 0 (carbs actively absorbing — meal noise)
203+ - |deviation| > 6 (UAM spike — unannounced meal)
204+ - glucose < 80 and deviation > 0 (low-BG rule: positive deviations
205+ at low BG are suppressed, matching
206+ autotune_prep's same rule)
207+ - |BGI| is too small to divide by reliably
208+
209+ Returns None if fewer than min_points valid ratios are available,
210+ which tells the caller to leave the ratio unchanged (oref0 behaviour).
211+ """
212+ ratios : list [float ] = []
213+
214+ for p in points :
215+ # Exclude meal / UAM periods — same logic as oref0 autosens.js
216+ if p .cob > 0 :
217+ continue
218+ if abs (p .deviation ) > cfg .deviation_threshold :
219+ continue
220+
221+ # Low-BG rule: oref0 zeroes positive deviations below 80 mg/dL
222+ # (lib/autosens.js mirrors autotune's categorize.js rule)
223+ deviation = p .deviation
224+ if p .glucose < 80 and deviation > 0 :
225+ deviation = 0.0
226+
227+ # Skip near-zero BGI to avoid numerical instability
228+ if abs (p .bgi ) < cfg .min_bgi_abs :
229+ continue
230+
231+ ratio = deviation / p .bgi
232+ if not np .isfinite (ratio ):
233+ continue
234+
235+ ratios .append (ratio )
236+
237+ if len (ratios ) < cfg .min_points :
238+ return None # not enough data — leave ratio unchanged
239+
240+ # oref0: autosens_ratio = 1 + median(ratios)
241+ return 1.0 + float (np .median (ratios ))
242+
243+
244+ # ─────────────────────────────────────────────────────────────────────────────
245+ # Main compute function
246+ # ─────────────────────────────────────────────────────────────────────────────
247+
248+ def compute_autosens (
249+ buffer : AutosensBuffer ,
250+ cfg : AutosensConfig ,
251+ ) -> dict :
252+ """
253+ Compute the autosens ratio from the rolling buffer.
254+
255+ oref0 lib/autosens.js computes two candidate ratios:
256+ ratio_8h — from the last 8 hours of data
257+ ratio_24h — from the last 24 hours of data
258+
259+ and returns the one that is CLOSER TO 1.0 (i.e. more conservative).
260+ This prevents a single unusual period from driving a large correction.
261+
262+ oref0 reference (lib/autosens.js line ~200):
263+ "use the smaller of the two autosens values"
264+
265+ Both are clipped to [autosens_min, autosens_max] before comparison.
266+
267+ Returns a dict with:
268+ ratio : the final autosens ratio to apply (1.0 = no change)
269+ ratio_8h : raw 8h candidate (before conservatism selection)
270+ ratio_24h : raw 24h candidate (before conservatism selection)
271+ n_points_8h : number of valid ratio points in 8h window
272+ n_points_24h : number of valid ratio points in 24h window
273+ reason : human-readable status string
274+ """
275+ ratio_8h = _compute_ratio_from_points (buffer .points_8h (), cfg )
276+ ratio_24h = _compute_ratio_from_points (buffer .points_24h (), cfg )
277+
278+ def _clip (r : float ) -> float :
279+ """Clip ratio to [autosens_min, autosens_max]."""
280+ return max (cfg .autosens_min , min (cfg .autosens_max , r ))
281+
282+ # Count valid points for diagnostics
283+ n_8h = len ([p for p in buffer .points_8h ()
284+ if p .cob == 0 and abs (p .deviation ) <= cfg .deviation_threshold
285+ and abs (p .bgi ) >= cfg .min_bgi_abs ])
286+ n_24h = len ([p for p in buffer .points_24h ()
287+ if p .cob == 0 and abs (p .deviation ) <= cfg .deviation_threshold
288+ and abs (p .bgi ) >= cfg .min_bgi_abs ])
289+
290+ # Neither window has enough data
291+ if ratio_8h is None and ratio_24h is None :
292+ return {
293+ "ratio" : 1.0 ,
294+ "ratio_8h" : None ,
295+ "ratio_24h" : None ,
296+ "n_points_8h" : n_8h ,
297+ "n_points_24h" : n_24h ,
298+ "reason" : f"Insufficient data (8h={ n_8h } , 24h={ n_24h } points); ratio=1.0" ,
299+ }
300+
301+ # Clip available ratios
302+ clipped_8h = _clip (ratio_8h ) if ratio_8h is not None else None
303+ clipped_24h = _clip (ratio_24h ) if ratio_24h is not None else None
304+
305+ # oref0: pick the more conservative (closer to 1.0) of the two
306+ # If only one is available, use that one
307+ if clipped_8h is None :
308+ final_ratio = clipped_24h
309+ reason = f"Only 24h window valid; ratio={ final_ratio :.3f} "
310+ elif clipped_24h is None :
311+ final_ratio = clipped_8h
312+ reason = f"Only 8h window valid; ratio={ final_ratio :.3f} "
313+ else :
314+ # Both available — pick the one closer to 1.0
315+ if abs (clipped_8h - 1.0 ) < abs (clipped_24h - 1.0 ):
316+ final_ratio = clipped_8h
317+ reason = f"8h more conservative; ratio={ final_ratio :.3f} "
318+ else :
319+ final_ratio = clipped_24h
320+ reason = f"24h more conservative; ratio={ final_ratio :.3f} "
321+
322+ return {
323+ "ratio" : round (final_ratio , 4 ),
324+ "ratio_8h" : round (clipped_8h , 4 ) if clipped_8h is not None else None ,
325+ "ratio_24h" : round (clipped_24h , 4 ) if clipped_24h is not None else None ,
326+ "n_points_8h" : n_8h ,
327+ "n_points_24h" : n_24h ,
328+ "reason" : reason ,
329+ }
330+
331+
332+ # ─────────────────────────────────────────────────────────────────────────────
333+ # Convenience: apply ratio to therapy settings
334+ # ─────────────────────────────────────────────────────────────────────────────
335+
336+ def apply_autosens_to_isf (autotune_isf : float , autosens_ratio : float ) -> float :
337+ """
338+ Return the effective ISF after applying the autosens ratio.
339+
340+ oref0 lib/autosens.js (and determine-basal):
341+ effective_isf = autotune_isf / autosens_ratio
342+
343+ A ratio > 1.0 means the patient is currently MORE sensitive than baseline
344+ → divide by a number > 1 → ISF goes DOWN → corrections are larger.
345+
346+ A ratio < 1.0 means the patient is currently LESS sensitive (more resistant)
347+ → divide by a number < 1 → ISF goes UP → corrections are smaller.
348+
349+ This is the ONLY place the autosens ratio is applied to ISF.
350+ The autotune_isf (daily baseline) is never modified by autosens.
351+ """
352+ if autosens_ratio <= 0 :
353+ return autotune_isf
354+ return round (autotune_isf / autosens_ratio , 3 )
355+
356+
357+ def apply_autosens_to_basal (pump_basal : float , autosens_ratio : float ) -> float :
358+ """
359+ Return the effective basal rate after applying the autosens ratio.
360+
361+ oref0:
362+ effective_basal = pump_basal * autosens_ratio
363+
364+ A ratio > 1.0 → multiply → basal goes UP (more insulin for sensitive patient).
365+ A ratio < 1.0 → multiply → basal goes DOWN (less insulin for resistant patient).
366+
367+ Note the asymmetry with ISF:
368+ ISF is DIVIDED by the ratio
369+ basal is MULTIPLIED by the ratio
370+ Both corrections push in the same clinical direction (more sensitive → more insulin).
371+ """
372+ return round (pump_basal * autosens_ratio , 4 )
0 commit comments