Skip to content

Commit 5a4d4dc

Browse files
author
Sigrid Tofte Thiis
committed
autosense included
1 parent 400577a commit 5a4d4dc

1 file changed

Lines changed: 372 additions & 0 deletions

File tree

Lines changed: 372 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,372 @@
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

Comments
 (0)