Skip to content

Commit

Permalink
LOOP-4665: Dosing Recommendations from Stateless LoopAlgorithm (#602)
Browse files Browse the repository at this point in the history
* Changes for functional algorithm recommendations

* Remove limits from IRC

* Simplify prediction input to only need those elements necessary for prediction

* LoopAlgorithm recommendations compiling

* LoopAlgorithm.generatePrediction parameters are extracted from LoopPredictionInput struct

* Comparable implementation for ManualBolusRecommendation has moved to LoopKit
  • Loading branch information
ps2 authored Oct 22, 2023
1 parent fe1b0f9 commit 2735876
Show file tree
Hide file tree
Showing 22 changed files with 81 additions and 126 deletions.
2 changes: 1 addition & 1 deletion Loop Status Extension/StatusViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ class StatusViewController: UIViewController, NCWidgetProviding {
lastGlucose.quantity.doubleValue(for: unit),
at: lastGlucose.startDate,
unit: unit,
staleGlucoseAge: LoopCoreConstants.inputDataRecencyInterval,
staleGlucoseAge: LoopAlgorithm.inputDataRecencyInterval,
glucoseDisplay: context.glucoseDisplay,
wasUserEntered: lastGlucose.wasUserEntered,
isDisplayOnly: lastGlucose.isDisplayOnly
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,6 @@ struct StatusWidgetTimelimeEntry: TimelineEntry {
}
let glucoseAge = date - glucoseDate

return glucoseAge >= LoopCoreConstants.inputDataRecencyInterval
return glucoseAge >= LoopAlgorithm.inputDataRecencyInterval
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ class StatusWidgetTimelineProvider: TimelineProvider {

// Date glucose staleness changes
if let lastBGTime = newEntry.currentGlucose?.startDate {
let staleBgRefreshTime = lastBGTime.addingTimeInterval(LoopCoreConstants.inputDataRecencyInterval+1)
let staleBgRefreshTime = lastBGTime.addingTimeInterval(LoopAlgorithm.inputDataRecencyInterval+1)
datesToRefreshWidget.append(staleBgRefreshTime)
}

Expand All @@ -93,7 +93,7 @@ class StatusWidgetTimelineProvider: TimelineProvider {

var glucose: [StoredGlucoseSample] = []

let startDate = Date(timeIntervalSinceNow: -LoopCoreConstants.inputDataRecencyInterval)
let startDate = Date(timeIntervalSinceNow: -LoopAlgorithm.inputDataRecencyInterval)

group.enter()
glucoseStore.getGlucoseSamples(start: startDate) { (result) in
Expand Down
2 changes: 1 addition & 1 deletion Loop/Extensions/DeviceDataManager+DeviceStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ extension DeviceDataManager {
var isGlucoseValueStale: Bool {
guard let latestGlucoseDataDate = glucoseStore.latestGlucose?.startDate else { return true }

return Date().timeIntervalSince(latestGlucoseDataDate) > LoopCoreConstants.inputDataRecencyInterval
return Date().timeIntervalSince(latestGlucoseDataDate) > LoopAlgorithm.inputDataRecencyInterval
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,6 @@ fileprivate extension StoredDosingDecision {
duration: .minutes(30)),
bolusUnits: 1.25)
let manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: 0.2,
pendingInsulin: 0.75,
notice: .predictedGlucoseBelowTarget(minGlucose: PredictedGlucoseValue(startDate: date.addingTimeInterval(.minutes(30)),
quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 95.0)))),
date: date.addingTimeInterval(-.minutes(1)))
Expand Down
8 changes: 4 additions & 4 deletions Loop/Managers/CGMStalenessMonitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@ class CGMStalenessMonitor {

let mostRecentGlucose = samples.map { $0.date }.max()!
let cgmDataAge = -mostRecentGlucose.timeIntervalSinceNow
if cgmDataAge < LoopCoreConstants.inputDataRecencyInterval {
if cgmDataAge < LoopAlgorithm.inputDataRecencyInterval {
self.cgmDataIsStale = false
self.updateCGMStalenessTimer(expiration: mostRecentGlucose.addingTimeInterval(LoopCoreConstants.inputDataRecencyInterval))
self.updateCGMStalenessTimer(expiration: mostRecentGlucose.addingTimeInterval(LoopAlgorithm.inputDataRecencyInterval))
} else {
self.cgmDataIsStale = true
}
Expand All @@ -62,14 +62,14 @@ class CGMStalenessMonitor {
}

private func checkCGMStaleness() {
delegate?.getLatestCGMGlucose(since: Date(timeIntervalSinceNow: -LoopCoreConstants.inputDataRecencyInterval)) { (result) in
delegate?.getLatestCGMGlucose(since: Date(timeIntervalSinceNow: -LoopAlgorithm.inputDataRecencyInterval)) { (result) in
DispatchQueue.main.async {
self.log.debug("Fetched latest CGM Glucose for checkCGMStaleness: %{public}@", String(describing: result))
switch result {
case .success(let sample):
if let sample = sample {
self.cgmDataIsStale = false
self.updateCGMStalenessTimer(expiration: sample.startDate.addingTimeInterval(LoopCoreConstants.inputDataRecencyInterval + CGMStalenessMonitor.cgmStalenessTimerTolerance))
self.updateCGMStalenessTimer(expiration: sample.startDate.addingTimeInterval(LoopAlgorithm.inputDataRecencyInterval + CGMStalenessMonitor.cgmStalenessTimerTolerance))
} else {
self.cgmDataIsStale = true
}
Expand Down
49 changes: 18 additions & 31 deletions Loop/Managers/LoopDataManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -964,7 +964,7 @@ extension LoopDataManager {
let updateGroup = DispatchGroup()

let historicalGlucoseStartDate = Date(timeInterval: -LoopCoreConstants.dosingDecisionHistoricalGlucoseInterval, since: now())
let inputDataRecencyStartDate = Date(timeInterval: -LoopCoreConstants.inputDataRecencyInterval, since: now())
let inputDataRecencyStartDate = Date(timeInterval: -LoopAlgorithm.inputDataRecencyInterval, since: now())

// Fetch glucose effects as far back as we want to make retroactive analysis and historical glucose for dosing decision
var historicalGlucose: [HistoricalGlucoseValue]?
Expand Down Expand Up @@ -1227,15 +1227,15 @@ extension LoopDataManager {
let pumpStatusDate = doseStore.lastAddedPumpData
let lastGlucoseDate = glucose.startDate

guard now().timeIntervalSince(lastGlucoseDate) <= LoopCoreConstants.inputDataRecencyInterval else {
guard now().timeIntervalSince(lastGlucoseDate) <= LoopAlgorithm.inputDataRecencyInterval else {
throw LoopError.glucoseTooOld(date: glucose.startDate)
}

guard lastGlucoseDate.timeIntervalSince(now()) <= LoopCoreConstants.futureGlucoseDataInterval else {
throw LoopError.invalidFutureGlucose(date: lastGlucoseDate)
}

guard now().timeIntervalSince(pumpStatusDate) <= LoopCoreConstants.inputDataRecencyInterval else {
guard now().timeIntervalSince(pumpStatusDate) <= LoopAlgorithm.inputDataRecencyInterval else {
throw LoopError.pumpDataTooOld(date: pumpStatusDate)
}

Expand Down Expand Up @@ -1487,15 +1487,15 @@ extension LoopDataManager {
let pumpStatusDate = doseStore.lastAddedPumpData
let lastGlucoseDate = glucose.startDate

guard now().timeIntervalSince(lastGlucoseDate) <= LoopCoreConstants.inputDataRecencyInterval else {
guard now().timeIntervalSince(lastGlucoseDate) <= LoopAlgorithm.inputDataRecencyInterval else {
throw LoopError.glucoseTooOld(date: glucose.startDate)
}

guard lastGlucoseDate.timeIntervalSince(now()) <= LoopCoreConstants.inputDataRecencyInterval else {
guard lastGlucoseDate.timeIntervalSince(now()) <= LoopAlgorithm.inputDataRecencyInterval else {
throw LoopError.invalidFutureGlucose(date: lastGlucoseDate)
}

guard now().timeIntervalSince(pumpStatusDate) <= LoopCoreConstants.inputDataRecencyInterval else {
guard now().timeIntervalSince(pumpStatusDate) <= LoopAlgorithm.inputDataRecencyInterval else {
throw LoopError.pumpDataTooOld(date: pumpStatusDate)
}

Expand Down Expand Up @@ -1541,16 +1541,18 @@ extension LoopDataManager {

let model = doseStore.insulinModelProvider.model(for: pumpInsulinType)

return predictedGlucose.recommendedManualBolus(
var recommendation = predictedGlucose.recommendedManualBolus(
to: glucoseTargetRange,
at: now(),
suspendThreshold: settings.suspendThreshold?.quantity,
sensitivity: insulinSensitivity,
model: model,
pendingInsulin: 0, // Pending insulin is already reflected in the prediction
maxBolus: maxBolus,
volumeRounder: volumeRounder
maxBolus: maxBolus
)

// Round to pump precision
recommendation.amount = volumeRounder(recommendation.amount)
return recommendation
}

/// Generates a correction effect based on how large the discrepancy is between the current glucose and its model predicted value.
Expand All @@ -1575,37 +1577,22 @@ extension LoopDataManager {
// Get timeline of glucose discrepancies
retrospectiveGlucoseDiscrepancies = insulinCounteractionEffects.subtracting(carbEffects, withUniformInterval: carbStore.delta)

// Calculate retrospective correction
let insulinSensitivity = settings.insulinSensitivitySchedule!.quantity(at: glucose.startDate)
let basalRate = settings.basalRateSchedule!.value(at: glucose.startDate)
let correctionRange = settings.glucoseTargetRangeSchedule!.quantityRange(at: glucose.startDate)

retrospectiveGlucoseEffect = retrospectiveCorrection.computeEffect(
startingAt: glucose,
retrospectiveGlucoseDiscrepanciesSummed: retrospectiveGlucoseDiscrepanciesSummed,
recencyInterval: LoopCoreConstants.inputDataRecencyInterval,
insulinSensitivity: insulinSensitivity,
basalRate: basalRate,
correctionRange: correctionRange,
recencyInterval: LoopAlgorithm.inputDataRecencyInterval,
retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval
)
}

private func computeRetrospectiveGlucoseEffect(startingAt glucose: GlucoseValue, carbEffects: [GlucoseEffect]) -> [GlucoseEffect] {

let insulinSensitivity = settings.insulinSensitivitySchedule!.quantity(at: glucose.startDate)
let basalRate = settings.basalRateSchedule!.value(at: glucose.startDate)
let correctionRange = settings.glucoseTargetRangeSchedule!.quantityRange(at: glucose.startDate)

let retrospectiveGlucoseDiscrepancies = insulinCounteractionEffects.subtracting(carbEffects, withUniformInterval: carbStore.delta)
let retrospectiveGlucoseDiscrepanciesSummed = retrospectiveGlucoseDiscrepancies.combinedSums(of: LoopMath.retrospectiveCorrectionGroupingInterval * retrospectiveCorrectionGroupingIntervalMultiplier)
return retrospectiveCorrection.computeEffect(
startingAt: glucose,
retrospectiveGlucoseDiscrepanciesSummed: retrospectiveGlucoseDiscrepanciesSummed,
recencyInterval: LoopCoreConstants.inputDataRecencyInterval,
insulinSensitivity: insulinSensitivity,
basalRate: basalRate,
correctionRange: correctionRange,
recencyInterval: LoopAlgorithm.inputDataRecencyInterval,
retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval
)
}
Expand Down Expand Up @@ -1690,17 +1677,17 @@ extension LoopDataManager {

var errors = [LoopError]()

if startDate.timeIntervalSince(glucose.startDate) > LoopCoreConstants.inputDataRecencyInterval {
if startDate.timeIntervalSince(glucose.startDate) > LoopAlgorithm.inputDataRecencyInterval {
errors.append(.glucoseTooOld(date: glucose.startDate))
}

if glucose.startDate.timeIntervalSince(startDate) > LoopCoreConstants.inputDataRecencyInterval {
if glucose.startDate.timeIntervalSince(startDate) > LoopAlgorithm.inputDataRecencyInterval {
errors.append(.invalidFutureGlucose(date: glucose.startDate))
}

let pumpStatusDate = doseStore.lastAddedPumpData

if startDate.timeIntervalSince(pumpStatusDate) > LoopCoreConstants.inputDataRecencyInterval {
if startDate.timeIntervalSince(pumpStatusDate) > LoopAlgorithm.inputDataRecencyInterval {
errors.append(.pumpDataTooOld(date: pumpStatusDate))
}

Expand Down Expand Up @@ -2176,7 +2163,7 @@ extension LoopDataManager {
sensitivitySchedule: sensitivitySchedule,
at: date)

dosingDecision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: bolusAmount.doubleValue(for: .internationalUnit()), pendingInsulin: 0, notice: notice),
dosingDecision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: bolusAmount.doubleValue(for: .internationalUnit()), notice: notice),
date: Date())

return dosingDecision
Expand Down
2 changes: 1 addition & 1 deletion Loop/Models/ConstantApplicationFactorStrategy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,6 @@ struct ConstantApplicationFactorStrategy: ApplicationFactorStrategy {
settings: LoopSettings
) -> Double {
// The original strategy uses a constant dosing factor.
return LoopConstants.bolusPartialApplicationFactor
return LoopAlgorithm.bolusPartialApplicationFactor
}
}
3 changes: 0 additions & 3 deletions Loop/Models/LoopConstants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,6 @@ enum LoopConstants {

static let retrospectiveCorrectionEnabled = true

// Percentage of recommended dose to apply as bolus when using automatic bolus dosing strategy
static let bolusPartialApplicationFactor = 0.4

/// Loop completion aging category limits
static let completionFreshLimit = TimeInterval(minutes: 6)
static let completionAgingLimit = TimeInterval(minutes: 16)
Expand Down
11 changes: 0 additions & 11 deletions Loop/Models/ManualBolusRecommendation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,3 @@ extension BolusRecommendationNotice: Equatable {
}
}


extension ManualBolusRecommendation: Comparable {
public static func ==(lhs: ManualBolusRecommendation, rhs: ManualBolusRecommendation) -> Bool {
return lhs.amount == rhs.amount
}

public static func <(lhs: ManualBolusRecommendation, rhs: ManualBolusRecommendation) -> Bool {
return lhs.amount < rhs.amount
}
}

2 changes: 1 addition & 1 deletion Loop/View Controllers/StatusTableViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -610,7 +610,7 @@ final class StatusTableViewController: LoopChartsTableViewController {
hudView.cgmStatusHUD.setGlucoseQuantity(glucose.quantity.doubleValue(for: unit),
at: glucose.startDate,
unit: unit,
staleGlucoseAge: LoopCoreConstants.inputDataRecencyInterval,
staleGlucoseAge: LoopAlgorithm.inputDataRecencyInterval,
glucoseDisplay: self.deviceManager.glucoseDisplay(for: glucose),
wasUserEntered: glucose.wasUserEntered,
isDisplayOnly: glucose.isDisplayOnly)
Expand Down
4 changes: 2 additions & 2 deletions Loop/View Models/BolusEntryViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -818,12 +818,12 @@ extension BolusEntryViewModel {

var isGlucoseDataStale: Bool {
guard let latestGlucoseDataDate = delegate?.mostRecentGlucoseDataDate else { return true }
return now().timeIntervalSince(latestGlucoseDataDate) > LoopCoreConstants.inputDataRecencyInterval
return now().timeIntervalSince(latestGlucoseDataDate) > LoopAlgorithm.inputDataRecencyInterval
}

var isPumpDataStale: Bool {
guard let latestPumpDataDate = delegate?.mostRecentPumpDataDate else { return true }
return now().timeIntervalSince(latestPumpDataDate) > LoopCoreConstants.inputDataRecencyInterval
return now().timeIntervalSince(latestPumpDataDate) > LoopAlgorithm.inputDataRecencyInterval
}

var isManualGlucosePromptVisible: Bool {
Expand Down
2 changes: 1 addition & 1 deletion Loop/Views/SimpleBolusView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -392,7 +392,7 @@ struct SimpleBolusCalculatorView_Previews: PreviewProvider {

func computeSimpleBolusRecommendation(at date: Date, mealCarbs: HKQuantity?, manualGlucose: HKQuantity?) -> BolusDosingDecision? {
var decision = BolusDosingDecision(for: .simpleBolus)
decision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: 3, pendingInsulin: 0),
decision.manualBolusRecommendation = ManualBolusRecommendationWithDate(recommendation: ManualBolusRecommendation(amount: 3),
date: Date())
return decision
}
Expand Down
3 changes: 0 additions & 3 deletions LoopCore/LoopCoreConstants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,6 @@ import Foundation
import LoopKit

public enum LoopCoreConstants {
/// The amount of time since a given date that input data should be considered valid
public static let inputDataRecencyInterval = TimeInterval(minutes: 15)

/// The amount of time in the future a glucose value should be considered valid
public static let futureGlucoseDataInterval = TimeInterval(minutes: 5)

Expand Down
65 changes: 21 additions & 44 deletions LoopTests/Fixtures/live_capture/live_capture_input.json
Original file line number Diff line number Diff line change
Expand Up @@ -962,48 +962,25 @@
"startDate" : "2023-06-23T02:37:35Z"
}
],
"settings" : {
"basal" : [
{
"endDate" : "2023-06-23T05:00:00Z",
"startDate" : "2023-06-22T10:00:00Z",
"value" : 0.45000000000000001
}
],
"carbRatio" : [
{
"endDate" : "2023-06-23T07:00:00Z",
"startDate" : "2023-06-22T07:00:00Z",
"value" : 11
}
],
"maximumBasalRatePerHour" : null,
"maximumBolus" : null,
"sensitivity" : [
{
"endDate" : "2023-06-23T05:00:00Z",
"startDate" : "2023-06-22T10:00:00Z",
"value" : 60
}
],
"suspendThreshold" : null,
"target" : [
{
"endDate" : "2023-06-23T07:00:00Z",
"startDate" : "2023-06-22T20:25:00Z",
"value" : {
"maxValue" : 115,
"minValue" : 100
}
},
{
"endDate" : "2023-06-23T08:50:00Z",
"startDate" : "2023-06-23T07:00:00Z",
"value" : {
"maxValue" : 115,
"minValue" : 100
}
}
]
}
"basal" : [
{
"endDate" : "2023-06-23T05:00:00Z",
"startDate" : "2023-06-22T10:00:00Z",
"value" : 0.45000000000000001
}
],
"carbRatio" : [
{
"endDate" : "2023-06-23T07:00:00Z",
"startDate" : "2023-06-22T07:00:00Z",
"value" : 11
}
],
"sensitivity" : [
{
"endDate" : "2023-06-23T05:00:00Z",
"startDate" : "2023-06-22T10:00:00Z",
"value" : 60
}
],
}
16 changes: 12 additions & 4 deletions LoopTests/Managers/LoopAlgorithmTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,25 @@ final class LoopAlgorithmTests: XCTestCase {
}


func testLiveCaptureWithFunctionalAlgorithm() throws {
func testLiveCaptureWithFunctionalAlgorithm() {
// This matches the "testForecastFromLiveCaptureInputData" test of LoopDataManagerDosingTests,
// Using the same input data, but generating the forecast using the LoopAlgorithm.generatePrediction()
// function.

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let url = bundle.url(forResource: "live_capture_input", withExtension: "json")!
let predictionInput = try! decoder.decode(LoopPredictionInput.self, from: try! Data(contentsOf: url))

let prediction = try LoopAlgorithm.generatePrediction(input: predictionInput)
let input = try! decoder.decode(LoopPredictionInput.self, from: try! Data(contentsOf: url))

let prediction = LoopAlgorithm.generatePrediction(
glucoseHistory: input.glucoseHistory,
doses: input.doses,
carbEntries: input.carbEntries,
basal: input.basal,
sensitivity: input.sensitivity,
carbRatio: input.carbRatio,
useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection
)

let expectedPredictedGlucose = loadPredictedGlucoseFixture("live_capture_predicted_glucose")

Expand Down
Loading

0 comments on commit 2735876

Please sign in to comment.