Skip to content

Commit 59bd026

Browse files
committed
Optimization and dataframe api for iob and ice computation
1 parent e216ea4 commit 59bd026

7 files changed

Lines changed: 290 additions & 23 deletions

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@ dist/
1212
loop_to_python_api.egg-info/
1313
loop_to_python_api/__pycache__/
1414
python_tests/__pycache__/
15-
15+
venv/

README.md

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,42 @@ Fetches the active insulin based on the provided JSON input.
153153

154154
-------------------------
155155

156+
157+
### Add Insulin Counteraction Effect to DataFrame
158+
159+
`add_insulin_counteraction_effect_to_df(df, basal, isf, cr, insulin_type='novolog')`
160+
161+
Adds an insulin counteraction effect column to the DataFrame input.
162+
163+
- **Parameters**:
164+
- `df`: The dataframe data input, with at least the columns basal, bolus and CGM, with datetime index.
165+
- `basal`: Basal insulin rate (units/hour).
166+
- `isf`: Insulin sensitivity factor (mg/dL per unit).
167+
- `cr`: Carbohydrate ratio (grams per unit of insulin).
168+
- `insulin_type`: Type of insulin (default 'novolog').
169+
- **Returns**: The dataframe with an "ice" column.
170+
171+
172+
-------------------------
173+
174+
### Add Insulin on Board to DataFrame
175+
176+
`add_insulin_on_board_to_df(df, basal, isf, cr, insulin_type='novolog', lookback=72)`
177+
178+
Adds an insulin counteraction effect column to the DataFrame input.
179+
180+
- **Parameters**:
181+
- `df`: The dataframe data input, with at least the columns basal, bolus and CGM, with datetime index.
182+
- `basal`: Basal insulin rate (units/hour).
183+
- `isf`: Insulin sensitivity factor (mg/dL per unit).
184+
- `cr`: Carbohydrate ratio (grams per unit of insulin).
185+
- `insulin_type`: Type of insulin (default 'novolog').
186+
- `lookback`: Lookback to use for computing insulin on board (default 72).
187+
- **Returns**: The dataframe with an "ice" column.
188+
189+
-------------------------
190+
191+
156192
### Percent Absorption at Percent Time
157193

158194
`percent_absorption_at_percent_time(percent_time)`
@@ -216,12 +252,14 @@ After making changes in the Swift code, rebuild the dynamic library by running `
216252
Run command `pytest`.
217253

218254

219-
## Debugging Advice
255+
## Debugging Advice and Disclaimers
256+
257+
This library does currently only work on Mac, but work is in progress to support other operating systems.
220258

221259
Debugging with this pipeline can be a pain... Calling functions with python does not give informative error messages, even though the `initialize_exception_handlers()` helps a little bit.
222260

223261
What I found the most useful is to go into LoopAlgorithm repository and run existing tests, but changing the input json files to the input json file that I am trying to use with this repository.
224262

225-
263+
Getting `zsh: killed` error in the terminal indicates that there are too many processes running, and you have to make sure to stop them - so be aware that this error does not necessarily mean that the dynamic library is corrupted. It is a to do to make the `api.py` a class that automatically makes sure that processes are properly closed after running.
226264

227265

Sources/LoopAlgorithmToPython/LoopAlgorithmToPython.swift

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ public func getGlucoseEffectVelocity(jsonData: UnsafePointer<Int8>?) -> UnsafeMu
169169
useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection
170170
)
171171
var glucoseEffectVelocities: [Double] = []
172-
172+
173173
for val in prediction.effects.insulinCounteraction {
174174
glucoseEffectVelocities.append(val.quantity.doubleValue(for: HKUnit(from: "mg/dL·s")))
175175
}
@@ -188,7 +188,7 @@ public func getGlucoseEffectVelocityDates(jsonData: UnsafePointer<Int8>?) -> Uns
188188

189189
do {
190190
let input = try getDecoder().decode(LoopPredictionInput.self, from: data)
191-
191+
192192
let prediction = LoopAlgorithm.generatePrediction(
193193
start: input.glucoseHistory.last?.startDate ?? Date(),
194194
glucoseHistory: input.glucoseHistory,
@@ -214,14 +214,36 @@ public func getGlucoseEffectVelocityDates(jsonData: UnsafePointer<Int8>?) -> Uns
214214
}
215215
}
216216

217+
@_cdecl("getGlucoseEffectVelocityAndDates")
218+
public func getGlucoseEffectVelocityAndDates(jsonData: UnsafePointer<Int8>?) -> UnsafePointer<CChar> {
219+
let data = getDataFromJson(jsonData: jsonData)
220+
221+
do {
222+
let input = try getDecoder().decode(AlgorithmInputFixture.self, from: data)
223+
let output = LoopAlgorithm.run(input: input)
224+
225+
// Prepare prediction dates as a comma-separated string
226+
var predictionsAndDates: String = ""
227+
for val in output.effects.insulinCounteraction {
228+
predictionsAndDates += val.startDate.ISO8601Format() + ","
229+
predictionsAndDates += val.quantity.doubleValue(for: HKUnit(from: "mg/dL·s")).description + " "
230+
}
231+
let cString = strdup(predictionsAndDates)!
232+
233+
return UnsafePointer<CChar>(cString)
234+
235+
} catch {
236+
fatalError("Error reading or decoding JSON file: \(error)")
237+
}
238+
}
239+
217240
@_cdecl("getActiveCarbs")
218241
public func getActiveCarbs(jsonData: UnsafePointer<Int8>?) -> Double {
219242
let data = getDataFromJson(jsonData: jsonData)
220243

221244
do {
222245
let input = try getDecoder().decode(AlgorithmInputFixture.self, from: data)
223246
let output = LoopAlgorithm.run(input: input)
224-
225247
return output.activeCarbs!
226248
} catch {
227249
fatalError("Error reading or decoding JSON file: \(error)")
@@ -235,9 +257,9 @@ public func getActiveInsulin(jsonData: UnsafePointer<Int8>?) -> Double {
235257

236258
do {
237259
let input = try getDecoder().decode(AlgorithmInputFixture.self, from: data)
238-
let output = LoopAlgorithm.run(input: input)
239-
240-
return output.activeInsulin!
260+
let dosesRelativeToBasal = input.doses.annotated(with: input.basal)
261+
let activeInsulin = dosesRelativeToBasal.insulinOnBoard(at: input.predictionStart)
262+
return activeInsulin
241263
} catch {
242264
fatalError("Error reading or decoding JSON file: \(error)")
243265
}

examples/main.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import pandas as pd
2+
import loop_to_python_api.api as api
3+
4+
# Read from csv file
5+
# file_path = 'examples/EXAMPLE.csv'
6+
# data = pd.read_csv(file_path, parse_dates=['date'], index_col='date', low_memory=False)
7+
8+
# Generate datetime index with 5-minute intervals
9+
index = pd.date_range(start="2024-02-28 00:00", periods=60, freq="5T")
10+
11+
# Create DataFrame
12+
df = pd.DataFrame({
13+
"bolus": [10] + [0] * 59, # First row is 10, rest are 0
14+
"basal": [1] * 60, # Always 1
15+
"CGM": 100 * 60
16+
}, index=index)
17+
18+
insulin_type = "afrezza"
19+
df = api.add_insulin_on_board_to_df(df, 1, 45, 12, insulin_type=insulin_type)
20+
df = api.add_insulin_counteraction_effect_to_df(df, 1, 45, 12, insulin_type=insulin_type)
21+
22+
print("Dataframe with iob and ice:", df)

loop_to_python_api/api.py

Lines changed: 84 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22
This file provides an API for calling the functions in the dynamic library. These functions are c-embeddings
33
for swift functions, found in Sources/LoopAlgorithmToPython/LoopAlgorithmToPython.swift.
44
"""
5-
from loop_to_python_api.helpers import get_bytes_from_json
5+
import numpy as np
6+
import pandas as pd
67

8+
import loop_to_python_api.helpers as helpers
79
import ctypes
810
import os
911
import ast
10-
12+
import time
1113

1214
# swift_lib = ctypes.CDLL('python_api/libLoopAlgorithmToPython.dylib')
1315

@@ -31,7 +33,7 @@ def initialize_exception_handlers():
3133

3234

3335
def generate_prediction(json_file, len=72):
34-
json_bytes = get_bytes_from_json(json_file)
36+
json_bytes = helpers.get_bytes_from_json(json_file)
3537

3638
swift_lib.generatePrediction.argtypes = [ctypes.c_char_p]
3739
swift_lib.generatePrediction.restype = ctypes.POINTER(ctypes.c_double)
@@ -41,7 +43,7 @@ def generate_prediction(json_file, len=72):
4143

4244

4345
def get_prediction_dates(json_file):
44-
json_bytes = get_bytes_from_json(json_file)
46+
json_bytes = helpers.get_bytes_from_json(json_file)
4547

4648
swift_lib.getPredictionDates.argtypes = [ctypes.c_char_p]
4749
swift_lib.getPredictionDates.restype = ctypes.c_char_p
@@ -60,7 +62,7 @@ def get_prediction_values_and_dates(json_file):
6062

6163

6264
def get_dose_recommendations(json_file):
63-
json_bytes = get_bytes_from_json(json_file)
65+
json_bytes = helpers.get_bytes_from_json(json_file)
6466

6567
swift_lib.getDoseRecommendations.argtypes = [ctypes.c_char_p]
6668
swift_lib.getDoseRecommendations.restype = ctypes.c_char_p
@@ -72,7 +74,7 @@ def get_dose_recommendations(json_file):
7274

7375
# "Glucose effect velocity" is equivalent to insulin counteraction effect (ICE)
7476
def get_glucose_effect_velocity(json_file, len=72):
75-
json_bytes = get_bytes_from_json(json_file)
77+
json_bytes = helpers.get_bytes_from_json(json_file)
7678

7779
swift_lib.getGlucoseEffectVelocity.argtypes = [ctypes.c_char_p]
7880
swift_lib.getGlucoseEffectVelocity.restype = ctypes.POINTER(ctypes.c_double)
@@ -82,7 +84,7 @@ def get_glucose_effect_velocity(json_file, len=72):
8284

8385

8486
def get_glucose_effect_velocity_dates(json_file):
85-
json_bytes = get_bytes_from_json(json_file)
87+
json_bytes = helpers.get_bytes_from_json(json_file)
8688

8789
swift_lib.getGlucoseEffectVelocityDates.argtypes = [ctypes.c_char_p]
8890
swift_lib.getGlucoseEffectVelocityDates.restype = ctypes.c_char_p
@@ -94,16 +96,27 @@ def get_glucose_effect_velocity_dates(json_file):
9496
return date_list
9597

9698

97-
def get_glucose_velocity_values_and_dates(json_file):
98-
# TODO: Add validation of json dates here to be more flexible?
99+
def get_glucose_effect_velocity_and_dates(json_file):
100+
json_bytes = helpers.get_bytes_from_json(json_file)
101+
102+
swift_lib.getGlucoseEffectVelocityAndDates.argtypes = [ctypes.c_char_p]
103+
swift_lib.getGlucoseEffectVelocityAndDates.restype = ctypes.c_char_p
104+
105+
result = swift_lib.getGlucoseEffectVelocityAndDates(json_bytes).decode('utf-8')
106+
107+
values = []
108+
dates = []
109+
# Parse the string that contains both dates and values
110+
for item in result.split():
111+
date_str, float_str = item.split(',')
112+
dates.append(pd.to_datetime(date_str))
113+
values.append(float(float_str))
99114

100-
dates = get_glucose_effect_velocity_dates(json_file)
101-
values = get_glucose_effect_velocity(json_file, len(dates))
102115
return values, dates
103116

104117

105118
def get_active_carbs(json_file):
106-
json_bytes = get_bytes_from_json(json_file)
119+
json_bytes = helpers.get_bytes_from_json(json_file)
107120

108121
swift_lib.getActiveCarbs.argtypes = [ctypes.c_char_p]
109122
swift_lib.getActiveCarbs.restype = ctypes.c_double
@@ -112,14 +125,71 @@ def get_active_carbs(json_file):
112125

113126

114127
def get_active_insulin(json_file):
115-
json_bytes = get_bytes_from_json(json_file)
128+
json_bytes = helpers.get_bytes_from_json(json_file)
116129

117130
swift_lib.getActiveInsulin.argtypes = [ctypes.c_char_p]
118131
swift_lib.getActiveInsulin.restype = ctypes.c_double
119132

120133
return swift_lib.getActiveInsulin(json_bytes)
121134

122135

136+
def add_insulin_counteraction_effect_to_df(df, basal, isf, cr, insulin_type='novolog'):
137+
"""
138+
Takes a dataframe with at least the columns CGM, bolus, and basal.
139+
Important note: this function assumes you only give data for a single subject at a time.
140+
# TODO: The therapy settings should be columns in the dataframe so we can support schedules.
141+
142+
:param df: Dataframe with at least a "basal" and a "bolus" column, and a datetime index.
143+
:param basal: Basal rate
144+
:param isf: Insulin sensitivity factor
145+
:param cr: Carbohydrate ratio (will not impact the results)
146+
:param insulin_type: Which insulin profile to use to compute the insulin on board
147+
:return: The input df with columns for insulin on board and insulin counteraction effects.
148+
"""
149+
data = df[['basal', 'bolus', 'CGM']].copy() # Extract only necessary data to improve performance
150+
data.loc[:, 'bolus'] = data['bolus'].replace(0.0, np.nan)
151+
json_data = helpers.get_json_loop_prediction_input_from_df(data, basal, isf, cr, data.index[-1], insulin_type)
152+
153+
ice_values, dates = get_glucose_effect_velocity_and_dates(json_file=json_data)
154+
dates = [date.tz_localize(None) for date in dates] # If you want to align to UTC
155+
156+
df.loc[:, "ice"] = np.nan
157+
df.loc[dates, "ice"] = ice_values
158+
return df
159+
160+
161+
def add_insulin_on_board_to_df(df, basal, isf, cr, insulin_type='novolog', lookback=72):
162+
"""
163+
Adding insulin on board to a dataframe to each row, by using data from the previous rows given by lookback.
164+
IMPORTANT NOTE: This function does not handle separate subjects within a single dataframe. A subject's data should
165+
be passed individually.
166+
167+
:param df: Dataframe with at least a "basal" and a "bolus" column, and a datetime index.
168+
:param basal: Basal rate
169+
:param isf: Insulin sensitivity factor
170+
:param cr: Carbohydrate ratio (will not impact the results)
171+
:param insulin_type: Which insulin profile to use to compute the insulin on board
172+
:param lookback: Lookback used to compute each iob value. Increasing lookback will lower performance, but will be
173+
necessary for insulin types that are long-lasting, or for high datetime frequencies. The default of 72 is based on
174+
6 hours duration of 5-minute intervals.
175+
:return: The original dataframe with a new column "iob"
176+
"""
177+
data = df[['basal', 'bolus']].copy() # Extract only necessary data to improve performance
178+
data.loc[:, 'bolus'] = data['bolus'].replace(0.0, np.nan)
179+
180+
iobs = []
181+
for i, date in enumerate(data.index[1:]):
182+
start_index = max(0, i - lookback + 1)
183+
json_data = helpers.get_json_loop_prediction_input_from_df(data.iloc[start_index:i+1], basal, isf, cr,
184+
data.index[i+1], insulin_type=insulin_type)
185+
iob = get_active_insulin(json_data)
186+
iobs += [iob]
187+
188+
df.loc[:, "iob"] = np.nan
189+
df.loc[df.index[1:], "iob"] = iobs
190+
return df
191+
192+
123193
# Calculating the percentage of carbohydrate absorption at the percent time, with piecewise linear model
124194
# Input is percent as fraction
125195
def percent_absorption_at_percent_time(percent_time):
@@ -147,7 +217,7 @@ def linear_percent_rate_at_percent_time(percent_time):
147217

148218

149219
def get_dynamic_carbs_on_board(json_file):
150-
json_bytes = get_bytes_from_json(json_file)
220+
json_bytes = helpers.get_bytes_from_json(json_file)
151221

152222
swift_lib.getDynamicCarbsOnBoard.argtypes = [ctypes.c_char_p]
153223
swift_lib.getDynamicCarbsOnBoard.restype = ctypes.c_double

0 commit comments

Comments
 (0)