From e14f9da6edc1f6710a0ab5c96a4a2ce52bc80432 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 26 Apr 2018 22:01:22 +0200 Subject: [PATCH 01/16] Add a setting to limit maximum Insulin on Board This is useful to prevent multiple bolus' to create accidentally very high insulin on board values. It is primarly a safety feature, e.g. if someone enters too many carbs by using multiple apps. --- DoseMathTests/DoseMathTests.swift | 261 ++++++++++++++++-- Loop/Extensions/NSUserDefaults.swift | 1 + Loop/Managers/DoseMath.swift | 30 +- Loop/Managers/LoopDataManager.swift | 93 +++++-- Loop/Models/LoopSettings.swift | 4 + .../SettingsTableViewController.swift | 19 +- .../TextFieldTableViewController.swift | 16 +- 7 files changed, 369 insertions(+), 55 deletions(-) diff --git a/DoseMathTests/DoseMathTests.swift b/DoseMathTests/DoseMathTests.swift index f54990834..988d84ae1 100644 --- a/DoseMathTests/DoseMathTests.swift +++ b/DoseMathTests/DoseMathTests.swift @@ -101,6 +101,14 @@ class RecommendTempBasalTests: XCTestCase { return TimeInterval(hours: 4) } + var insulinOnBoard: Double { + return 0 + } + + var maxInsulinOnBoard: Double { + return 25 + } + func testNoChange() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_no_change_glucose") @@ -112,6 +120,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -129,6 +139,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -151,6 +163,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: lastTempBasal ) @@ -169,6 +183,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -190,6 +206,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: lastTempBasal ) @@ -217,6 +235,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: lastTempBasal ) @@ -231,6 +251,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -248,6 +270,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -266,6 +290,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -287,6 +313,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: lastTempBasal ) @@ -305,13 +333,74 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) - + // Basal 0.8, 1.1 units required -> 2.2 units extra for 30 minutes XCTAssertEqual(3.0, dose!.unitsPerHour) XCTAssertEqual(TimeInterval(minutes: 30), dose!.duration) } + func testFlatAndHighLimitIob() { + let glucose = loadGlucoseValueFixture("recommend_temp_basal_flat_and_high") + + let dose = glucose.recommendedTempBasal( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: suspendThreshold.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + basalRates: basalRateSchedule, + maxBasalRate: maxBasalRate, + insulinOnBoard: 0, + maxInsulinOnBoard: 1, + lastTempBasal: nil + ) + // Basal 0.8, 1.1 units required, limited to 1 -> 2 units extra for 30 minutes = 2.8 + XCTAssertEqual(2.8, dose!.unitsPerHour) + XCTAssertEqual(TimeInterval(minutes: 30), dose!.duration) + } + + func testFlatAndHighLimitIobWithOnboard() { + let glucose = loadGlucoseValueFixture("recommend_temp_basal_flat_and_high") + + let dose = glucose.recommendedTempBasal( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: suspendThreshold.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + basalRates: basalRateSchedule, + maxBasalRate: maxBasalRate, + insulinOnBoard: 1.5, + maxInsulinOnBoard: 2, + lastTempBasal: nil + ) + // Basal 0.8, 1.1 units required, limited to 0.5 -> 1 units extra for 30 minutes = 1.8 + XCTAssertEqual(1.8, dose!.unitsPerHour) + XCTAssertEqual(TimeInterval(minutes: 30), dose!.duration) + } + + func testFlatAndHighLimitIobExceeded() { + let glucose = loadGlucoseValueFixture("recommend_temp_basal_flat_and_high") + + let dose = glucose.recommendedTempBasal( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: suspendThreshold.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + basalRates: basalRateSchedule, + maxBasalRate: maxBasalRate, + insulinOnBoard: 2.5, + maxInsulinOnBoard: 2, + lastTempBasal: nil + ) + + XCTAssertNil(dose) + } + func testHighAndFalling() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_high_and_falling") @@ -323,6 +412,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -341,6 +432,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -359,6 +452,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -376,6 +471,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -394,6 +491,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -412,6 +511,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -429,6 +530,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -487,6 +590,14 @@ class RecommendBolusTests: XCTestCase { return TimeInterval(hours: 4) } + var insulinOnBoard: Double { + return 0 + } + + var maxInsulinOnBoard: Double { + return 25 + } + func testNoChange() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_no_change_glucose") @@ -497,7 +608,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0, dose.amount) @@ -513,7 +626,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0, dose.amount) @@ -529,7 +644,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0, dose.amount) @@ -545,7 +662,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0, dose.amount) @@ -561,7 +680,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(1.575, dose.amount) @@ -573,6 +694,78 @@ class RecommendBolusTests: XCTestCase { } } + func testStartLowEndHighLimitIob() { + let glucose = loadGlucoseValueFixture("recommend_temp_basal_start_low_end_high") + + let dose = glucose.recommendedBolus( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: suspendThreshold.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + pendingInsulin: 0, + maxBolus: maxBolus, + insulinOnBoard: 0, + maxInsulinOnBoard: 1.3 + ) + + XCTAssertEqual(1.3, dose.amount) + + if case BolusRecommendationNotice.currentGlucoseBelowTarget(let glucose) = dose.notice! { + XCTAssertEqual(glucose.quantity.doubleValue(for: .milligramsPerDeciliter()), 60) + } else { + XCTFail("Expected currentGlucoseBelowTarget, but got \(dose.notice!)") + } + } + + func testStartLowEndHighLimitIobWithOnboard() { + let glucose = loadGlucoseValueFixture("recommend_temp_basal_start_low_end_high") + + let dose = glucose.recommendedBolus( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: suspendThreshold.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + pendingInsulin: 0, + maxBolus: maxBolus, + insulinOnBoard: 1.0, + maxInsulinOnBoard: 1.3 + ) + + XCTAssertEqual(0.3, dose.amount) + + if case BolusRecommendationNotice.currentGlucoseBelowTarget(let glucose) = dose.notice! { + XCTAssertEqual(glucose.quantity.doubleValue(for: .milligramsPerDeciliter()), 60) + } else { + XCTFail("Expected currentGlucoseBelowTarget, but got \(dose.notice!)") + } + } + + func testStartLowEndHighLimitIobExceeded() { + let glucose = loadGlucoseValueFixture("recommend_temp_basal_start_low_end_high") + + let dose = glucose.recommendedBolus( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: suspendThreshold.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + pendingInsulin: 0, + maxBolus: maxBolus, + insulinOnBoard: 2.0, + maxInsulinOnBoard: 1.3 + ) + + XCTAssertEqual(0, dose.amount) + + if case BolusRecommendationNotice.currentGlucoseBelowTarget(let glucose) = dose.notice! { + XCTAssertEqual(glucose.quantity.doubleValue(for: .milligramsPerDeciliter()), 60) + } else { + XCTFail("Expected currentGlucoseBelowTarget, but got \(dose.notice!)") + } + } + func testStartBelowSuspendThresholdEndHigh() { // 60 - 200 mg/dL let glucose = loadGlucoseValueFixture("recommend_temp_basal_start_low_end_high") @@ -584,7 +777,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0, dose.amount) @@ -607,7 +802,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0, dose.amount) @@ -629,7 +826,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(1.4, dose.amount) @@ -647,7 +846,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 1, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0.575, dose.amount) @@ -663,7 +864,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0, dose.amount) @@ -679,7 +882,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(1.575, dose.amount, accuracy: 1.0 / 40.0) @@ -695,7 +900,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0.325, dose.amount, accuracy: 1.0 / 40.0) @@ -711,7 +918,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0.325, dose.amount, accuracy: 1.0 / 40.0) @@ -725,7 +934,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0.8, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0, dose.amount, accuracy: .ulpOfOne) @@ -741,7 +952,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: ExponentialInsulinModel(actionDuration: 21600.0, peakActivityTime: 4500.0), pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0.275, dose.amount) @@ -757,7 +970,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: self.insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(1.25, dose.amount) @@ -772,7 +987,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(1.25, dose.amount, accuracy: 1.0 / 40.0) @@ -788,7 +1005,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0.0, dose.amount) @@ -804,7 +1023,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0, dose.amount) diff --git a/Loop/Extensions/NSUserDefaults.swift b/Loop/Extensions/NSUserDefaults.swift index 88101cb1b..a1a9fcde9 100644 --- a/Loop/Extensions/NSUserDefaults.swift +++ b/Loop/Extensions/NSUserDefaults.swift @@ -168,6 +168,7 @@ extension UserDefaults { glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, maximumBasalRatePerHour: maximumBasalRatePerHour, maximumBolus: maximumBolus, + maximumInsulinOnBoard: nil, suspendThreshold: suspendThreshold, retrospectiveCorrectionEnabled: bool(forKey: "com.loudnate.Loop.RetrospectiveCorrectionEnabled") ) diff --git a/Loop/Managers/DoseMath.swift b/Loop/Managers/DoseMath.swift index 10c0872e8..400f68af2 100644 --- a/Loop/Managers/DoseMath.swift +++ b/Loop/Managers/DoseMath.swift @@ -39,15 +39,20 @@ extension InsulinCorrection { /// - Parameters: /// - scheduledBasalRate: The scheduled basal rate at the time the correction is delivered /// - maxBasalRate: The maximum allowed basal rate + /// - insulinOnBoard: The current insulin on board + /// - maxInsulinOnBoard: The maximum insulin allowed /// - duration: The duration of the temporary basal /// - minimumProgrammableIncrementPerUnit: The smallest fraction of a unit supported in basal delivery /// - Returns: A temp basal recommendation fileprivate func asTempBasal( scheduledBasalRate: Double, maxBasalRate: Double, + insulinOnBoard: Double, + maxInsulinOnBoard: Double, duration: TimeInterval, minimumProgrammableIncrementPerUnit: Double ) -> TempBasalRecommendation { + let units = Swift.min(self.units, Swift.max(0, maxInsulinOnBoard - insulinOnBoard)) var rate = units / (duration / TimeInterval(hours: 1)) // units/hour switch self { case .aboveRange, .inRange, .entirelyBelowRange: @@ -85,15 +90,20 @@ extension InsulinCorrection { /// - Parameters: /// - pendingInsulin: The number of units expected to be delivered, but not yet reflected in the correction /// - maxBolus: The maximum allowable bolus value in units + /// - insulinOnBoard: The current insulin on board + /// - maxInsulinOnBoard: The maximum insulin allowed /// - minimumProgrammableIncrementPerUnit: The smallest fraction of a unit supported in bolus delivery /// - Returns: A bolus recommendation fileprivate func asBolus( pendingInsulin: Double, maxBolus: Double, + insulinOnBoard: Double, + maxInsulinOnBoard: Double, minimumProgrammableIncrementPerUnit: Double ) -> BolusRecommendation { - var units = self.units - pendingInsulin - units = Swift.min(maxBolus, Swift.max(0, units)) + let netUnits = self.units - pendingInsulin + var units = Swift.min(maxBolus, Swift.max(0, netUnits)) + units = Swift.min(units, Swift.max(0, maxInsulinOnBoard - insulinOnBoard)) units = round(units * minimumProgrammableIncrementPerUnit) / minimumProgrammableIncrementPerUnit return BolusRecommendation( @@ -347,6 +357,8 @@ extension Collection where Iterator.Element == GlucoseValue { /// - model: The insulin absorption model /// - basalRates: The schedule of basal rates /// - maxBasalRate: The maximum allowed basal rate + /// - insulinOnBoard: The current insulin on board + /// - maxInsulinOnBoard: The maximum insulin allowed /// - lastTempBasal: The previously set temp basal /// - duration: The duration of the temporary basal /// - minimumProgrammableIncrementPerUnit: The smallest fraction of a unit supported in basal delivery @@ -360,7 +372,10 @@ extension Collection where Iterator.Element == GlucoseValue { model: InsulinModel, basalRates: BasalRateSchedule, maxBasalRate: Double, + insulinOnBoard: Double, + maxInsulinOnBoard: Double, lastTempBasal: DoseEntry?, + lowerOnly: Bool = false, // only lower the basal, never raise duration: TimeInterval = .minutes(30), minimumProgrammableIncrementPerUnit: Double = 40, continuationInterval: TimeInterval = .minutes(11) @@ -386,6 +401,8 @@ extension Collection where Iterator.Element == GlucoseValue { let temp = correction?.asTempBasal( scheduledBasalRate: scheduledBasalRate, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, duration: duration, minimumProgrammableIncrementPerUnit: minimumProgrammableIncrementPerUnit ) @@ -408,6 +425,8 @@ extension Collection where Iterator.Element == GlucoseValue { /// - model: The insulin absorption model /// - pendingInsulin: The number of units expected to be delivered, but not yet reflected in the correction /// - maxBolus: The maximum bolus to return + /// - insulinOnBoard: The current insulin on board + /// - maxInsulinOnBoard: The maximum insulin allowed /// - minimumProgrammableIncrementPerUnit: The smallest fraction of a unit supported in bolus delivery /// - Returns: A bolus recommendation func recommendedBolus( @@ -418,7 +437,8 @@ extension Collection where Iterator.Element == GlucoseValue { model: InsulinModel, pendingInsulin: Double, maxBolus: Double, - minimumProgrammableIncrementPerUnit: Double = 40 + insulinOnBoard: Double, + maxInsulinOnBoard: Double ) -> BolusRecommendation { guard let correction = self.insulinCorrection( to: correctionRange, @@ -433,7 +453,9 @@ extension Collection where Iterator.Element == GlucoseValue { var bolus = correction.asBolus( pendingInsulin: pendingInsulin, maxBolus: maxBolus, - minimumProgrammableIncrementPerUnit: minimumProgrammableIncrementPerUnit + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, + minimumProgrammableIncrementPerUnit: 40 ) // Handle the "current BG below target" notice here diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 75abd492c..c4661049e 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -573,6 +573,26 @@ final class LoopDataManager { updateGroup.leave() } } + + if insulinOnBoard == nil { + updateGroup.enter() + let now = Date() + doseStore.getInsulinOnBoardValues(start: retrospectiveStart, end: now) { (result) in + switch result { + case .success(let value): + if let recentValue = value.closestPriorToDate(now) { + self.insulinOnBoard = recentValue + } else { + self.insulinOnBoard = InsulinValue(startDate: now, value: 0.0) + } + case .failure(let error): + NSLog("getInsulinOnBoardValues - error: \(error)") + self.logger.error(error) + self.insulinOnBoard = nil + } + updateGroup.leave() + } + } _ = updateGroup.wait(timeout: .distantFuture) @@ -690,10 +710,18 @@ final class LoopDataManager { } } private var insulinEffect: [GlucoseEffect]? { + didSet { + predictedGlucose = nil + insulinOnBoard = nil + } + } + + fileprivate var insulinOnBoard: InsulinValue? { didSet { predictedGlucose = nil } } + private var glucoseMomentumEffect: [GlucoseEffect]? { didSet { predictedGlucose = nil @@ -876,6 +904,7 @@ final class LoopDataManager { guard let maxBasal = settings.maximumBasalRatePerHour, + let maximumInsulinOnBoard = settings.maximumInsulinOnBoard, let glucoseTargetRange = settings.glucoseTargetRangeSchedule, let insulinSensitivity = insulinSensitivitySchedule, let basalRates = basalRateSchedule, @@ -883,6 +912,15 @@ final class LoopDataManager { else { throw LoopError.configurationError("Check settings") } + + guard let insulinOnBoard = insulinOnBoard + else { + throw LoopError.missingDataError(details: "Insulin on Board not available (updatePredictedGlucoseAndRecommendedBasal)", recovery: "Pump data up to date?") + } + +// guard cgmCalibrated else { +// throw LoopError.missingDataError(details: "CGM", recovery: "CGM Recently calibrated") +// } guard lastRequestedBolus == nil, // Don't recommend changes if a bolus was just set @@ -893,6 +931,8 @@ final class LoopDataManager { model: model, basalRates: basalRates, maxBasalRate: maxBasal, + insulinOnBoard: insulinOnBoard.value, + maxInsulinOnBoard: maximumInsulinOnBoard, lastTempBasal: lastTempBasal ) else { @@ -914,13 +954,19 @@ final class LoopDataManager { guard let predictedGlucose = predictedGlucose, let maxBolus = settings.maximumBolus, + let maximumInsulinOnBoard = settings.maximumInsulinOnBoard, let glucoseTargetRange = settings.glucoseTargetRangeSchedule, let insulinSensitivity = insulinSensitivitySchedule, let model = insulinModelSettings?.model else { throw LoopError.configurationError("Check Settings") } - + + guard let insulinOnBoard = insulinOnBoard + else { + throw LoopError.missingDataError(details: "Insulin on Board not available (recommendBolus)", recovery: "Pump data up to date?") + } + guard let glucoseDate = predictedGlucose.first?.startDate else { throw LoopError.missingDataError(details: "No glucose data found", recovery: "Check your CGM source") } @@ -937,7 +983,9 @@ final class LoopDataManager { sensitivity: insulinSensitivity, model: model, pendingInsulin: pendingInsulin, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard.value, + maxInsulinOnBoard: maximumInsulinOnBoard ) return recommendation @@ -979,6 +1027,8 @@ protocol LoopState { /// The last-calculated carbs on board var carbsOnBoard: CarbValue? { get } + var insulinOnBoard: InsulinValue? { get } + /// An error in the current state of the loop, or one that happened during the last attempt to loop. var error: Error? { get } @@ -1034,6 +1084,11 @@ extension LoopDataManager { dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) return loopDataManager.carbsOnBoard } + + var insulinOnBoard: InsulinValue? { + dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) + return loopDataManager.insulinOnBoard + } var error: Error? { dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) @@ -1115,6 +1170,7 @@ extension LoopDataManager { "## LoopDataManager", "settings: \(String(reflecting: manager.settings))", "insulinCounteractionEffects: \(String(reflecting: manager.insulinCounteractionEffects))", + "insulinOnBoard: \(String(describing: state.insulinOnBoard))", "predictedGlucose: \(state.predictedGlucose ?? [])", "retrospectivePredictedGlucose: \(state.retrospectivePredictedGlucose ?? [])", "recommendedTempBasal: \(String(describing: state.recommendedTempBasal))", @@ -1125,45 +1181,24 @@ extension LoopDataManager { "lastTempBasal: \(String(describing: state.lastTempBasal))", "carbsOnBoard: \(String(describing: state.carbsOnBoard))" ] - var loopError = state.error - - // TODO: this should be moved to doseStore.generateDiagnosticReport - self.doseStore.insulinOnBoard(at: Date()) { (result) in - let insulinOnBoard: InsulinValue? - - switch result { - case .success(let value): - insulinOnBoard = value - case .failure(let error): - insulinOnBoard = nil - - if loopError == nil { - loopError = error - } - } - - entries.append("insulinOnBoard: \(String(describing: insulinOnBoard))") - entries.append("error: \(String(describing: loopError))") + self.glucoseStore.generateDiagnosticReport { (report) in + entries.append(report) entries.append("") - self.glucoseStore.generateDiagnosticReport { (report) in + self.carbStore.generateDiagnosticReport { (report) in entries.append(report) entries.append("") - self.carbStore.generateDiagnosticReport { (report) in + self.doseStore.generateDiagnosticReport { (report) in entries.append(report) entries.append("") - self.doseStore.generateDiagnosticReport { (report) in - entries.append(report) - entries.append("") - - completion(entries.joined(separator: "\n")) - } + completion(entries.joined(separator: "\n")) } } } + } } } diff --git a/Loop/Models/LoopSettings.swift b/Loop/Models/LoopSettings.swift index 24d374a15..d4b0b2ee0 100644 --- a/Loop/Models/LoopSettings.swift +++ b/Loop/Models/LoopSettings.swift @@ -19,6 +19,8 @@ struct LoopSettings { var maximumBasalRatePerHour: Double? var maximumBolus: Double? + + var maximumInsulinOnBoard: Double? var suspendThreshold: GlucoseThreshold? = nil @@ -65,6 +67,7 @@ extension LoopSettings: RawRepresentable { self.maximumBasalRatePerHour = rawValue["maximumBasalRatePerHour"] as? Double + self.maximumInsulinOnBoard = rawValue["maximumInsulinOnBoard"] as? Double self.maximumBolus = rawValue["maximumBolus"] as? Double if let rawThreshold = rawValue["minimumBGGuard"] as? GlucoseThreshold.RawValue { @@ -85,6 +88,7 @@ extension LoopSettings: RawRepresentable { raw["glucoseTargetRangeSchedule"] = glucoseTargetRangeSchedule?.rawValue raw["maximumBasalRatePerHour"] = maximumBasalRatePerHour + raw["maximumInsulinOnBoard"] = maximumInsulinOnBoard raw["maximumBolus"] = maximumBolus raw["minimumBGGuard"] = suspendThreshold?.rawValue diff --git a/Loop/View Controllers/SettingsTableViewController.swift b/Loop/View Controllers/SettingsTableViewController.swift index 660b64fe1..da5ceae27 100644 --- a/Loop/View Controllers/SettingsTableViewController.swift +++ b/Loop/View Controllers/SettingsTableViewController.swift @@ -106,6 +106,7 @@ final class SettingsTableViewController: UITableViewController, DailyValueSchedu case insulinSensitivity case maxBasal case maxBolus + case maxInsulinOnBoard } fileprivate enum ServiceRow: Int, CaseCountable { @@ -345,6 +346,14 @@ final class SettingsTableViewController: UITableViewController, DailyValueSchedu } else { configCell.detailTextLabel?.text = TapToSetString } + case .maxInsulinOnBoard: + configCell.textLabel?.text = NSLocalizedString("Maximum IOB", comment: "The title text for the maximum insulin on board value") + + if let maxInsulinOnBoard = dataManager.loopManager.settings.maximumInsulinOnBoard { + configCell.detailTextLabel?.text = "\(valueNumberFormatter.string(from: NSNumber(value: maxInsulinOnBoard))!) U" + } else { + configCell.detailTextLabel?.text = TapToSetString + } } return configCell @@ -484,7 +493,7 @@ final class SettingsTableViewController: UITableViewController, DailyValueSchedu case .configuration: let row = ConfigurationRow(rawValue: indexPath.row)! switch row { - case .maxBasal, .maxBolus: + case .maxBasal, .maxBolus, .maxInsulinOnBoard: let vc: LoopKit.TextFieldTableViewController switch row { @@ -492,6 +501,8 @@ final class SettingsTableViewController: UITableViewController, DailyValueSchedu vc = .maxBasal(dataManager.loopManager.settings.maximumBasalRatePerHour) case .maxBolus: vc = .maxBolus(dataManager.loopManager.settings.maximumBolus) + case .maxInsulinOnBoard: + vc = .maxInsulinOnBoard(dataManager.loopManager.settings.maximumInsulinOnBoard) default: fatalError() } @@ -1012,6 +1023,12 @@ extension SettingsTableViewController: LoopKit.TextFieldTableViewControllerDeleg } else { dataManager.loopManager.settings.maximumBolus = nil } + case .maxInsulinOnBoard: + if let value = controller.value, let units = valueNumberFormatter.number(from: value)?.doubleValue { + dataManager.loopManager.settings.maximumInsulinOnBoard = units + } else { + dataManager.loopManager.settings.maximumInsulinOnBoard = nil + } default: assertionFailure() } diff --git a/Loop/View Controllers/TextFieldTableViewController.swift b/Loop/View Controllers/TextFieldTableViewController.swift index c343def6b..08afe249f 100644 --- a/Loop/View Controllers/TextFieldTableViewController.swift +++ b/Loop/View Controllers/TextFieldTableViewController.swift @@ -71,5 +71,19 @@ extension TextFieldTableViewController { } return vc - } + } + + static func maxInsulinOnBoard(_ value: Double?) -> T { + let vc = T() + + vc.placeholder = NSLocalizedString("Enter a number of units", comment: "The placeholder text instructing users how to enter a maximum iob") + vc.keyboardType = .decimalPad + vc.unit = NSLocalizedString("Units", comment: "The unit string for units") + + if let maxIOB = value { + vc.value = valueNumberFormatter.string(from: NSNumber(value: maxIOB)) + } + + return vc + } } From fb22de4382c6afe57d86dd12df305b0752336879 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 26 Apr 2018 22:18:53 +0200 Subject: [PATCH 02/16] Cosmetics - Revert accidental change - Clarify the exceed IOB intention - Remove some merge artifacts --- DoseMathTests/DoseMathTests.swift | 3 ++- Loop/Managers/DoseMath.swift | 6 +++--- Loop/Managers/LoopDataManager.swift | 9 +++------ 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/DoseMathTests/DoseMathTests.swift b/DoseMathTests/DoseMathTests.swift index 988d84ae1..73e0d7e31 100644 --- a/DoseMathTests/DoseMathTests.swift +++ b/DoseMathTests/DoseMathTests.swift @@ -397,7 +397,8 @@ class RecommendTempBasalTests: XCTestCase { maxInsulinOnBoard: 2, lastTempBasal: nil ) - + // If the IOB is exceeded the rate is limited to the default + // basal rate. XCTAssertNil(dose) } diff --git a/Loop/Managers/DoseMath.swift b/Loop/Managers/DoseMath.swift index 400f68af2..e8331c59c 100644 --- a/Loop/Managers/DoseMath.swift +++ b/Loop/Managers/DoseMath.swift @@ -375,7 +375,6 @@ extension Collection where Iterator.Element == GlucoseValue { insulinOnBoard: Double, maxInsulinOnBoard: Double, lastTempBasal: DoseEntry?, - lowerOnly: Bool = false, // only lower the basal, never raise duration: TimeInterval = .minutes(30), minimumProgrammableIncrementPerUnit: Double = 40, continuationInterval: TimeInterval = .minutes(11) @@ -438,7 +437,8 @@ extension Collection where Iterator.Element == GlucoseValue { pendingInsulin: Double, maxBolus: Double, insulinOnBoard: Double, - maxInsulinOnBoard: Double + maxInsulinOnBoard: Double, + minimumProgrammableIncrementPerUnit: Double = 40 ) -> BolusRecommendation { guard let correction = self.insulinCorrection( to: correctionRange, @@ -455,7 +455,7 @@ extension Collection where Iterator.Element == GlucoseValue { maxBolus: maxBolus, insulinOnBoard: insulinOnBoard, maxInsulinOnBoard: maxInsulinOnBoard, - minimumProgrammableIncrementPerUnit: 40 + minimumProgrammableIncrementPerUnit: minimumProgrammableIncrementPerUnit ) // Handle the "current BG below target" notice here diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index c4661049e..2cb9fc116 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -913,14 +913,11 @@ final class LoopDataManager { throw LoopError.configurationError("Check settings") } - guard let insulinOnBoard = insulinOnBoard - else { + guard let + insulinOnBoard = insulinOnBoard + else { throw LoopError.missingDataError(details: "Insulin on Board not available (updatePredictedGlucoseAndRecommendedBasal)", recovery: "Pump data up to date?") } - -// guard cgmCalibrated else { -// throw LoopError.missingDataError(details: "CGM", recovery: "CGM Recently calibrated") -// } guard lastRequestedBolus == nil, // Don't recommend changes if a bolus was just set From 800215a85e402faa41ff825b8caab27a469d0486 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 26 Apr 2018 22:28:29 +0200 Subject: [PATCH 03/16] Two more cosmetic fixes. --- Loop/Managers/LoopDataManager.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 2cb9fc116..c050a51f8 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -586,7 +586,6 @@ final class LoopDataManager { self.insulinOnBoard = InsulinValue(startDate: now, value: 0.0) } case .failure(let error): - NSLog("getInsulinOnBoardValues - error: \(error)") self.logger.error(error) self.insulinOnBoard = nil } @@ -916,7 +915,7 @@ final class LoopDataManager { guard let insulinOnBoard = insulinOnBoard else { - throw LoopError.missingDataError(details: "Insulin on Board not available (updatePredictedGlucoseAndRecommendedBasal)", recovery: "Pump data up to date?") + throw LoopError.missingDataError(details: "Insulin on Board not available (updatePredictedGlucoseAndRecommendedBasal)", recovery: "Pump data up to date?") } guard From 77d501c622658e08709a138b921d6f5ef8fae64b Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 26 Apr 2018 22:53:28 +0200 Subject: [PATCH 04/16] Refactor Bolus Recommendation Make Bolus recommendation part of Loop update and don't allow external calls to it. The data doesn't change in any case and update() is called in all places where we want a Bolus recommendation. This is in preparation of automated Bolus code, which needs consistent Bolus and Basal data. --- Loop/Managers/LoopDataManager.swift | 81 ++++++++++++++--------------- 1 file changed, 38 insertions(+), 43 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 75abd492c..a80f70dee 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -299,7 +299,7 @@ final class LoopDataManager { do { try self.update() - completion(.success(try self.recommendBolus())) + completion(.success(self.recommendedBolus?.recommendation)) } catch let error { completion(.failure(error)) } @@ -586,7 +586,7 @@ final class LoopDataManager { if predictedGlucose == nil { do { - try updatePredictedGlucoseAndRecommendedBasal() + try updatePredictedGlucoseAndRecommendedBasalAndBolus() } catch let error { logger.error(error) @@ -717,6 +717,7 @@ final class LoopDataManager { fileprivate var predictedGlucose: [GlucoseValue]? { didSet { recommendedTempBasal = nil + recommendedBolus = nil } } fileprivate var retrospectivePredictedGlucose: [GlucoseValue]? { @@ -726,6 +727,8 @@ final class LoopDataManager { } fileprivate var recommendedTempBasal: (recommendation: TempBasalRecommendation, date: Date)? + fileprivate var recommendedBolus: (recommendation: BolusRecommendation, date: Date)? + fileprivate var carbsOnBoard: CarbValue? fileprivate var lastTempBasal: DoseEntry? @@ -841,7 +844,7 @@ final class LoopDataManager { /// - LoopError.glucoseTooOld /// - LoopError.missingDataError /// - LoopError.pumpDataTooOld - private func updatePredictedGlucoseAndRecommendedBasal() throws { + private func updatePredictedGlucoseAndRecommendedBasalAndBolus() throws { dispatchPrecondition(condition: .onQueue(dataAccessQueue)) guard let glucose = glucoseStore.latestGlucose else { @@ -879,14 +882,25 @@ final class LoopDataManager { let glucoseTargetRange = settings.glucoseTargetRangeSchedule, let insulinSensitivity = insulinSensitivitySchedule, let basalRates = basalRateSchedule, + let maxBolus = settings.maximumBolus, let model = insulinModelSettings?.model else { throw LoopError.configurationError("Check settings") } + + let pendingInsulin = try self.getPendingInsulin() - guard - lastRequestedBolus == nil, // Don't recommend changes if a bolus was just set - let tempBasal = predictedGlucose.recommendedTempBasal( + guard lastRequestedBolus == nil + else { + // Don't recommend changes if a bolus was just requested. + // Sending additional pump commands is not going to be + // successful in any case. + recommendedBolus = nil + recommendedTempBasal = nil + return + } + + let tempBasal = predictedGlucose.recommendedTempBasal( to: glucoseTargetRange, suspendThreshold: settings.suspendThreshold?.quantity, sensitivity: insulinSensitivity, @@ -895,42 +909,13 @@ final class LoopDataManager { maxBasalRate: maxBasal, lastTempBasal: lastTempBasal ) - else { + + if let temp = tempBasal { + recommendedTempBasal = (recommendation: temp, date: startDate) + } else { recommendedTempBasal = nil - return - } - - recommendedTempBasal = (recommendation: tempBasal, date: Date()) - } - - /// - Returns: A bolus recommendation from the current data - /// - Throws: - /// - LoopError.configurationError - /// - LoopError.glucoseTooOld - /// - LoopError.missingDataError - fileprivate func recommendBolus() throws -> BolusRecommendation { - dispatchPrecondition(condition: .onQueue(dataAccessQueue)) - - guard - let predictedGlucose = predictedGlucose, - let maxBolus = settings.maximumBolus, - let glucoseTargetRange = settings.glucoseTargetRangeSchedule, - let insulinSensitivity = insulinSensitivitySchedule, - let model = insulinModelSettings?.model - else { - throw LoopError.configurationError("Check Settings") - } - - guard let glucoseDate = predictedGlucose.first?.startDate else { - throw LoopError.missingDataError(details: "No glucose data found", recovery: "Check your CGM source") } - - guard abs(glucoseDate.timeIntervalSinceNow) <= recencyInterval else { - throw LoopError.glucoseTooOld(date: glucoseDate) - } - - let pendingInsulin = try self.getPendingInsulin() - + let recommendation = predictedGlucose.recommendedBolus( to: glucoseTargetRange, suspendThreshold: settings.suspendThreshold?.quantity, @@ -939,8 +924,7 @@ final class LoopDataManager { pendingInsulin: pendingInsulin, maxBolus: maxBolus ) - - return recommendation + recommendedBolus = (recommendation: recommendation, date: startDate) } /// *This method should only be called from the `dataAccessQueue`* @@ -997,6 +981,8 @@ protocol LoopState { /// The recommended temp basal based on predicted glucose var recommendedTempBasal: (recommendation: TempBasalRecommendation, date: Date)? { get } + var recommendedBolus: (recommendation: BolusRecommendation, date: Date)? { get } + /// The retrospective prediction over a recent period of glucose samples var retrospectivePredictedGlucose: [GlucoseValue]? { get } @@ -1064,6 +1050,11 @@ extension LoopDataManager { dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) return loopDataManager.recommendedTempBasal } + + var recommendedBolus: (recommendation: BolusRecommendation, date: Date)? { + dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) + return loopDataManager.recommendedBolus + } var retrospectivePredictedGlucose: [GlucoseValue]? { dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) @@ -1075,7 +1066,10 @@ extension LoopDataManager { } func recommendBolus() throws -> BolusRecommendation { - return try loopDataManager.recommendBolus() + if let bolus = loopDataManager.recommendedBolus { + return bolus.recommendation + } + throw LoopError.missingDataError(details: "Recommended Bolus data not available.", recovery: "Check you loop state.") } } @@ -1118,6 +1112,7 @@ extension LoopDataManager { "predictedGlucose: \(state.predictedGlucose ?? [])", "retrospectivePredictedGlucose: \(state.retrospectivePredictedGlucose ?? [])", "recommendedTempBasal: \(String(describing: state.recommendedTempBasal))", + "recommendedBolus: \(String(describing: state.recommendedBolus))", "lastBolus: \(String(describing: manager.lastRequestedBolus))", "lastGlucoseChange: \(String(describing: manager.lastGlucoseChange))", "retrospectiveGlucoseChange: \(String(describing: manager.retrospectiveGlucoseChange))", From e32732798fd756943ea10320dd346ca8713094d4 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 26 Apr 2018 23:00:18 +0200 Subject: [PATCH 05/16] Remove recommendBolus function from LoopState The recommendedBolus variable contains the same information. --- Loop/Managers/LoopDataManager.swift | 16 ---------------- Loop/Managers/NightscoutDataManager.swift | 10 +--------- Loop/Managers/WatchDataManager.swift | 2 +- .../BolusViewController+LoopDataManager.swift | 2 +- 4 files changed, 3 insertions(+), 27 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index a80f70dee..da836e102 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -994,15 +994,6 @@ protocol LoopState { /// - Returns: An timeline of predicted glucose values /// - Throws: LoopError.missingDataError if prediction cannot be computed func predictGlucose(using inputs: PredictionInputEffect) throws -> [GlucoseValue] - - /// Calculates a recommended bolus based on predicted glucose - /// - /// - Returns: A bolus recommendation - /// - Throws: An error describing why a bolus couldnʼt be computed - /// - LoopError.configurationError - /// - LoopError.glucoseTooOld - /// - LoopError.missingDataError - func recommendBolus() throws -> BolusRecommendation } @@ -1064,13 +1055,6 @@ extension LoopDataManager { func predictGlucose(using inputs: PredictionInputEffect) throws -> [GlucoseValue] { return try loopDataManager.predictGlucose(using: inputs) } - - func recommendBolus() throws -> BolusRecommendation { - if let bolus = loopDataManager.recommendedBolus { - return bolus.recommendation - } - throw LoopError.missingDataError(details: "Recommended Bolus data not available.", recovery: "Check you loop state.") - } } /// Executes a closure with access to the current state of the loop. diff --git a/Loop/Managers/NightscoutDataManager.swift b/Loop/Managers/NightscoutDataManager.swift index 5e7706cce..a773507f8 100644 --- a/Loop/Managers/NightscoutDataManager.swift +++ b/Loop/Managers/NightscoutDataManager.swift @@ -43,15 +43,7 @@ final class NightscoutDataManager { var loopError = state.error let recommendedBolus: Double? - do { - recommendedBolus = try state.recommendBolus().amount - } catch let error { - recommendedBolus = nil - - if loopError == nil { - loopError = error - } - } + recommendedBolus = state.recommendedBolus?.recommendation.amount let carbsOnBoard = state.carbsOnBoard let predictedGlucose = state.predictedGlucose diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index 44255cd80..a20a32605 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -125,7 +125,7 @@ final class WatchDataManager: NSObject, WCSessionDelegate { context.reservoir = reservoir?.unitVolume context.loopLastRunDate = state.lastLoopCompleted - context.recommendedBolusDose = try? state.recommendBolus().amount + context.recommendedBolusDose = state.recommendedBolus?.recommendation.amount context.maxBolus = manager.settings.maximumBolus if let glucoseTargetRangeSchedule = manager.settings.glucoseTargetRangeSchedule { diff --git a/Loop/View Controllers/BolusViewController+LoopDataManager.swift b/Loop/View Controllers/BolusViewController+LoopDataManager.swift index 280d33353..84246ef88 100644 --- a/Loop/View Controllers/BolusViewController+LoopDataManager.swift +++ b/Loop/View Controllers/BolusViewController+LoopDataManager.swift @@ -20,7 +20,7 @@ extension BolusViewController { if let recommendation = recommendation { bolusRecommendation = recommendation } else { - bolusRecommendation = try? state.recommendBolus() + bolusRecommendation = state.recommendedBolus?.recommendation } manager.doseStore.insulinOnBoard(at: Date()) { (result) in From 25817d9915bbb2d5f70b8dda0fe83e75028c693e Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 4 May 2018 21:18:25 +0200 Subject: [PATCH 06/16] Factor out automated Bolus code Depends on wip/bolus and wip/iob branch. Still needs cleanup and testing. --- Loop/Extensions/NSUserDefaults.swift | 1 + Loop/Managers/DeviceDataManager.swift | 89 ++++++++++-- Loop/Managers/LoopDataManager.swift | 137 +++++++++++++++++- Loop/Models/LoopSettings.swift | 19 +++ .../SettingsTableViewController.swift | 16 +- 5 files changed, 242 insertions(+), 20 deletions(-) diff --git a/Loop/Extensions/NSUserDefaults.swift b/Loop/Extensions/NSUserDefaults.swift index a1a9fcde9..c64f587c0 100644 --- a/Loop/Extensions/NSUserDefaults.swift +++ b/Loop/Extensions/NSUserDefaults.swift @@ -165,6 +165,7 @@ extension UserDefaults { let settings = LoopSettings( dosingEnabled: bool(forKey: "com.loudnate.Naterade.DosingEnabled"), + bolusEnabled: false, glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, maximumBasalRatePerHour: maximumBasalRatePerHour, maximumBolus: maximumBolus, diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 146ecc5d0..610193cef 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -407,12 +407,10 @@ final class DeviceDataManager { return } + ops.runSession(withName: "Fetch Pump History", using: device) { (session) in do { - // TODO: This should isn't safe to access synchronously - let startDate = self.loopManager.doseStore.pumpEventQueryAfterDate - - let (events, model) = try session.getHistoryEvents(since: startDate) + let (events, model) = try session.getHistoryEvents(since: self.loopManager.doseStore.pumpEventQueryAfterDate) self.loopManager.addPumpEvents(events, from: model) { (error) in if let error = error { self.logger.addError("Failed to store history: \(error)", fromSource: "DoseStore") @@ -512,21 +510,26 @@ final class DeviceDataManager { } } + private var bolusInProgress = false + /// TODO: Isolate to queue /// Send a bolus command and handle the result /// /// - parameter units: The number of units to deliver /// - parameter completion: A clsure called after the command is complete. This closure takes a single argument: /// - error: An error describing why the command failed - func enactBolus(units: Double, at startDate: Date = Date(), completion: @escaping (_ error: Error?) -> Void) { + func enactBolus(units: Double, at startDate: Date = Date(), quiet : Bool = false, completion: @escaping (_ error: Error?) -> Void) { + let notify = { (error: Error?) -> Void in if let error = error { - NotificationManager.sendBolusFailureNotification(for: error, units: units, at: startDate) + if !quiet { + NotificationManager.sendBolusFailureNotification(for: error, units: units, at: startDate) + } } - + self.bolusInProgress = false completion(error) } - + guard units > 0 else { notify(nil) return @@ -536,9 +539,27 @@ final class DeviceDataManager { notify(LoopError.configurationError("Pump ID")) return } - + + guard !bolusInProgress else { + notify(LoopError.invalidData(details: "Bolus already in progress")) + bolusInProgress = true // notify alwasy set this to false, so reset to true... + return + } + bolusInProgress = true + // If we don't have recent pump data, or the pump was recently rewound, read new pump data before bolusing. - let shouldReadReservoir = isReservoirDataOlderThan(timeIntervalSinceNow: .minutes(-6)) + var shouldReadReservoir = isReservoirDataOlderThan(timeIntervalSinceNow: .minutes(-10)) + if loopManager.doseStore.lastReservoirVolumeDrop < 0 { + notify(LoopError.invalidData(details: "Last Reservoir drop negative.")) + shouldReadReservoir = true + } else if let reservoir = loopManager.doseStore.lastReservoirValue, reservoir.startDate.timeIntervalSinceNow <= + -loopManager.recencyInterval { + notify(LoopError.pumpDataTooOld(date: reservoir.startDate)) + shouldReadReservoir = true + } else if loopManager.doseStore.lastReservoirValue == nil { + notify(LoopError.missingDataError(details: "Reservoir Value missing", recovery: "Keep phone close.")) + shouldReadReservoir = true + } ops.runSession(withName: "Bolus", using: rileyLinkManager.firstConnectedDevice) { (session) in guard let session = session else { @@ -831,9 +852,9 @@ extension DeviceDataManager: DoseStoreDelegate { extension DeviceDataManager: LoopDataManagerDelegate { func loopDataManager( - _ manager: LoopDataManager, - didRecommendBasalChange basal: (recommendation: TempBasalRecommendation, date: Date), - completion: @escaping (_ result: Result) -> Void + _ manager: LoopDataManager, + didRecommendBasalChange basal: (recommendation: TempBasalRecommendation, date: Date), + completion: @escaping (_ result: Result) -> Void ) { guard let pumpOps = pumpOps else { completion(.failure(LoopError.configurationError("Pump ID"))) @@ -871,11 +892,53 @@ extension DeviceDataManager: LoopDataManagerDelegate { value: response.rate, unit: .unitsPerHour ))) + } catch let error { notify(.failure(error)) } } } + + func loopDataManager(_ manager: LoopDataManager, didRecommendBolus bolus: (recommendation: BolusRecommendation, date: Date), completion: @escaping (_ result: Result) -> Void) { + + enactBolus(units: bolus.recommendation.amount, quiet: true) { (error) in + if let error = error { + completion(.failure(error)) + } else { + let now = Date() + completion(.success(DoseEntry( + type: .bolus, + startDate: now, + endDate: now, + value: bolus.recommendation.amount, + unit: .units + ))) + } + + } + } + + func loopDataManager(_ manager: LoopDataManager, uploadTreatments treatments: [NightscoutTreatment], completion: @escaping (Result<[String]>) -> Void) { + + guard let uploader = remoteDataManager.nightscoutService.uploader else { + completion(.failure(LoopError.configurationError("Nightscout not configured"))) + return + } + + uploader.upload(treatments) { (result) in + switch result { + case .success(let objects): + completion(.success(objects)) + case .failure(let error): + let logger = DiagnosticLogger.shared!.forCategory("NightscoutUploader") + logger.error(error) + NSLog("UPLOADING delegate failed \(error)") + completion(.failure(error)) + + } + } + + } } diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 0dd069d20..aa0087a41 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -86,6 +86,7 @@ final class LoopDataManager { self.dataAccessQueue.async { self.carbEffect = nil self.carbsOnBoard = nil + self.lastCarbChange = Date() self.notify(forChange: .carbs) } } @@ -325,7 +326,11 @@ final class LoopDataManager { /// - date: The date the bolus was requested func addRequestedBolus(units: Double, at date: Date, completion: (() -> Void)?) { dataAccessQueue.async { - self.lastRequestedBolus = (units: units, date: date) + NSLog("addRequestedBolus: \(units) \(date)") + self.recommendedBolus = nil + self.lastPendingBolus = nil + self.lastFailedBolus = nil + self.lastRequestedBolus = (units: units, date: date, reservoir: self.doseStore.lastReservoirValue) self.notify(forChange: .bolus) completion?() @@ -338,17 +343,40 @@ final class LoopDataManager { /// - units: The bolus amount, in units /// - date: The date the bolus was enacted func addConfirmedBolus(units: Double, at date: Date, completion: (() -> Void)?) { - self.doseStore.addPendingPumpEvent(.enactedBolus(units: units, at: date)) { + let event = NewPumpEvent.enactedBolus(units: units, at: date) + NSLog("addConfirmedBolus: \(units) \(date)") + self.doseStore.addPendingPumpEvent(event) { self.dataAccessQueue.async { + let requestDate = self.lastRequestedBolus?.date ?? date + self.lastPendingBolus = (units: units, date: requestDate, reservoir: self.doseStore.lastReservoirValue, event: event) self.lastRequestedBolus = nil + self.lastFailedBolus = nil + self.lastAutomaticBolus = date // keep this as a date, irrespective of automatic or not + self.recommendedBolus = nil self.insulinEffect = nil - self.notify(forChange: .bolus) + self.notify(forChange: .bolus) + do { + try self.update() + } catch let error { + NSLog("addConfirmedBolus: Update after confirmed bolus failed \(error)") + } completion?() } } } + func addFailedBolus(units: Double, at date: Date, error: Error, completion: (() -> Void)?) { + dataAccessQueue.async { + NSLog("addFailedBolus: \(units) \(date) \(error)") + self.lastFailedBolus = (units: units, date: date, error: error) + self.lastPendingBolus = nil + self.recommendedBolus = nil + self.notify(forChange: .bolus) + completion?() + } + } + /// Adds and stores new pump events /// /// - Parameters: @@ -433,11 +461,23 @@ final class LoopDataManager { if let error = error { self.logger.error(error) } else { - self.lastLoopCompleted = Date() + if self.settings.bolusEnabled { + // Have to do a bolus first. + self.setAutomatedBolus { (error) -> Void in + if let error = error { + self.logger.error(error) + } else { + self.lastLoopCompleted = Date() + } + } + } else { + // No automatic Bolus, we are done. + self.lastLoopCompleted = Date() + } } self.notify(forChange: .tempBasal) } - + // Delay the notification until we know the result of the temp basal return } else { @@ -759,7 +799,11 @@ final class LoopDataManager { fileprivate var carbsOnBoard: CarbValue? fileprivate var lastTempBasal: DoseEntry? - fileprivate var lastRequestedBolus: (units: Double, date: Date)? + + fileprivate var lastRequestedBolus: (units: Double, date: Date, reservoir: ReservoirValue?)? + fileprivate var lastPendingBolus: (units: Double, date: Date, reservoir: ReservoirValue?, event: NewPumpEvent)? + fileprivate var lastFailedBolus: (units: Double, date: Date, error: Error)? + fileprivate var lastLoopCompleted: Date? { didSet { NotificationManager.scheduleLoopNotRunningNotifications() @@ -994,6 +1038,78 @@ final class LoopDataManager { } } } + + /// *This method should only be called from the `dataAccessQueue`* + private var lastAutomaticBolus : Date? = nil + private var lastCarbChange : Date? = nil + + private func roundInsulinUnits(_ units: Double) -> Double { + return round(units * settings.insulinIncrementPerUnit)/settings.insulinIncrementPerUnit + } + + private func setAutomatedBolus(_ completion: @escaping (_ error: Error?) -> Void) { + dispatchPrecondition(condition: .onQueue(dataAccessQueue)) + + guard let recommendedBolus = self.recommendedBolus else { + completion(nil) + NSLog("setAutomatedBolus - recommendation not available") + return + } + + let safeAmount = roundInsulinUnits(recommendedBolus.recommendation.amount * settings.automatedBolusRatio) + if safeAmount < settings.automatedBolusThreshold { + completion(nil) + NSLog("setAutomatedBolus - recommendation below threshold") + return + } + + guard abs(recommendedBolus.date.timeIntervalSinceNow) < TimeInterval(minutes: 5) else { + completion(LoopError.recommendationExpired(date: recommendedBolus.date)) + NSLog("setAutomatedBolus - recommendation too old") + return + } + + if let lastAutomaticBolus = self.lastAutomaticBolus, abs(lastAutomaticBolus.timeIntervalSinceNow) < settings.automaticBolusInterval { + NSLog("setAutomatedBolus - last automatic bolus too close") + completion(nil) + return + } + + if let carbChange = lastCarbChange { + guard abs(carbChange.timeIntervalSinceNow) > TimeInterval(minutes: 2) else { + NSLog("setAutomatedBolus - last carbchange too close") + completion(nil) + return + } + } + + // TODO lastPendingBolus is never cleared, thus we need to check for the date here. + if lastRequestedBolus != nil { + NSLog("setAutomatedBolus - lastRequestedBolus or lastPendingBolus still in progress \(String(describing: lastRequestedBolus)) \(String(describing: lastPendingBolus))") + completion(nil) + return + } + + // Copy bolus with "safe" ratio + let automatedBolus = (recommendation: BolusRecommendation(amount: safeAmount , pendingInsulin: recommendedBolus.recommendation.pendingInsulin, notice: recommendedBolus.recommendation.notice ), date: recommendedBolus.date) + self.recommendedBolus = nil + lastAutomaticBolus = Date() + + delegate.loopDataManager(self, didRecommendBolus: automatedBolus) { (result) in + self.dataAccessQueue.async { + switch result { + case .success(let bolus): + NSLog("setAutomatedBolus - success: \(bolus)") + self.recommendedBolus = nil + completion(nil) + case .failure(let error): + completion(error) + } + } + } + } + + } @@ -1191,4 +1307,13 @@ protocol LoopDataManagerDelegate: class { /// - completion: A closure called once on completion /// - result: The enacted basal func loopDataManager(_ manager: LoopDataManager, didRecommendBasalChange basal: (recommendation: TempBasalRecommendation, date: Date), completion: @escaping (_ result: Result) -> Void) -> Void + + /// Informs the delegate that an immediate bolus is recommended + /// + /// - Parameters: + /// - manager: The manager + /// - bolus: The recommended bolus + /// - completion: A closure called once on completion + /// - result: The enacted bolus + func loopDataManager(_ manager: LoopDataManager, didRecommendBolus bolus: (recommendation: BolusRecommendation, date: Date), completion: @escaping (_ result: Result) -> Void) -> Void } diff --git a/Loop/Models/LoopSettings.swift b/Loop/Models/LoopSettings.swift index d4b0b2ee0..b9e7f8bb6 100644 --- a/Loop/Models/LoopSettings.swift +++ b/Loop/Models/LoopSettings.swift @@ -12,6 +12,8 @@ import RileyLinkBLEKit struct LoopSettings { var dosingEnabled = false + var bolusEnabled = false + let dynamicCarbAbsorptionEnabled = true var glucoseTargetRangeSchedule: GlucoseRangeSchedule? @@ -25,6 +27,18 @@ struct LoopSettings { var suspendThreshold: GlucoseThreshold? = nil var retrospectiveCorrectionEnabled = true + + // Not configurable through UI + let automatedBolusThreshold: Double = 0.2 + let automatedBolusRatio: Double = 0.7 + let automaticBolusInterval: TimeInterval = TimeInterval(minutes: 3) + let absorptionRate: Double = 20 + + let minimumRecommendedBolus: Double = 0.2 + let insulinIncrementPerUnit: Double = 10 // 0.1 steps in basal and bolus + + let absorptionTimeOverrun = 1.0 + } @@ -60,6 +74,10 @@ extension LoopSettings: RawRepresentable { if let dosingEnabled = rawValue["dosingEnabled"] as? Bool { self.dosingEnabled = dosingEnabled } + + if let bolusEnabled = rawValue["bolusEnabled"] as? Bool { + self.bolusEnabled = bolusEnabled + } if let rawValue = rawValue["glucoseTargetRangeSchedule"] as? GlucoseRangeSchedule.RawValue { self.glucoseTargetRangeSchedule = GlucoseRangeSchedule(rawValue: rawValue) @@ -83,6 +101,7 @@ extension LoopSettings: RawRepresentable { var raw: RawValue = [ "version": LoopSettings.version, "dosingEnabled": dosingEnabled, + "bolusEnabled": bolusEnabled, "retrospectiveCorrectionEnabled": retrospectiveCorrectionEnabled ] diff --git a/Loop/View Controllers/SettingsTableViewController.swift b/Loop/View Controllers/SettingsTableViewController.swift index da5ceae27..c7050c63c 100644 --- a/Loop/View Controllers/SettingsTableViewController.swift +++ b/Loop/View Controllers/SettingsTableViewController.swift @@ -80,6 +80,7 @@ final class SettingsTableViewController: UITableViewController, DailyValueSchedu fileprivate enum LoopRow: Int, CaseCountable { case dosing = 0 + case bolus case preferredInsulinDataSource case diagnostic } @@ -191,6 +192,15 @@ final class SettingsTableViewController: UITableViewController, DailyValueSchedu switchCell.switch?.addTarget(self, action: #selector(dosingEnabledChanged(_:)), for: .valueChanged) + return switchCell + case .bolus: + let switchCell = tableView.dequeueReusableCell(withIdentifier: SwitchTableViewCell.className, for: indexPath) as! SwitchTableViewCell + + switchCell.switch?.isOn = dataManager.loopManager.settings.bolusEnabled + switchCell.textLabel?.text = NSLocalizedString("Automated Bolus", comment: "The title text for the automated bolus enabled switch cell") + + switchCell.switch?.addTarget(self, action: #selector(bolusEnabledChanged(_:)), for: .valueChanged) + return switchCell case .preferredInsulinDataSource: let cell = tableView.dequeueReusableCell(withIdentifier: ConfigCellIdentifier, for: indexPath) @@ -640,7 +650,7 @@ final class SettingsTableViewController: UITableViewController, DailyValueSchedu vc.title = sender?.textLabel?.text show(vc, sender: sender) - case .dosing: + case .dosing, .bolus: break } case .services: @@ -703,6 +713,10 @@ final class SettingsTableViewController: UITableViewController, DailyValueSchedu @objc private func dosingEnabledChanged(_ sender: UISwitch) { dataManager.loopManager.settings.dosingEnabled = sender.isOn } + + @objc private func bolusEnabledChanged(_ sender: UISwitch) { + dataManager.loopManager.settings.bolusEnabled = sender.isOn + } @objc private func reloadDevices() { self.dataManager.rileyLinkManager.getDevices { (devices) in From 1107c6ec759631b9e6684c930a89fc770cd68471 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 4 May 2018 21:27:09 +0200 Subject: [PATCH 07/16] Remove merge artifacts. --- Loop/Managers/DeviceDataManager.swift | 41 +++------------------------ Loop/Models/LoopSettings.swift | 10 ++----- 2 files changed, 6 insertions(+), 45 deletions(-) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 610193cef..877f8f8b3 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -510,8 +510,6 @@ final class DeviceDataManager { } } - private var bolusInProgress = false - /// TODO: Isolate to queue /// Send a bolus command and handle the result /// @@ -526,7 +524,6 @@ final class DeviceDataManager { NotificationManager.sendBolusFailureNotification(for: error, units: units, at: startDate) } } - self.bolusInProgress = false completion(error) } @@ -539,14 +536,7 @@ final class DeviceDataManager { notify(LoopError.configurationError("Pump ID")) return } - - guard !bolusInProgress else { - notify(LoopError.invalidData(details: "Bolus already in progress")) - bolusInProgress = true // notify alwasy set this to false, so reset to true... - return - } - bolusInProgress = true - + // If we don't have recent pump data, or the pump was recently rewound, read new pump data before bolusing. var shouldReadReservoir = isReservoirDataOlderThan(timeIntervalSinceNow: .minutes(-10)) if loopManager.doseStore.lastReservoirVolumeDrop < 0 { @@ -852,9 +842,9 @@ extension DeviceDataManager: DoseStoreDelegate { extension DeviceDataManager: LoopDataManagerDelegate { func loopDataManager( - _ manager: LoopDataManager, - didRecommendBasalChange basal: (recommendation: TempBasalRecommendation, date: Date), - completion: @escaping (_ result: Result) -> Void + _ manager: LoopDataManager, + didRecommendBasalChange basal: (recommendation: TempBasalRecommendation, date: Date), + completion: @escaping (_ result: Result) -> Void ) { guard let pumpOps = pumpOps else { completion(.failure(LoopError.configurationError("Pump ID"))) @@ -892,7 +882,6 @@ extension DeviceDataManager: LoopDataManagerDelegate { value: response.rate, unit: .unitsPerHour ))) - } catch let error { notify(.failure(error)) } @@ -917,28 +906,6 @@ extension DeviceDataManager: LoopDataManagerDelegate { } } - - func loopDataManager(_ manager: LoopDataManager, uploadTreatments treatments: [NightscoutTreatment], completion: @escaping (Result<[String]>) -> Void) { - - guard let uploader = remoteDataManager.nightscoutService.uploader else { - completion(.failure(LoopError.configurationError("Nightscout not configured"))) - return - } - - uploader.upload(treatments) { (result) in - switch result { - case .success(let objects): - completion(.success(objects)) - case .failure(let error): - let logger = DiagnosticLogger.shared!.forCategory("NightscoutUploader") - logger.error(error) - NSLog("UPLOADING delegate failed \(error)") - completion(.failure(error)) - - } - } - - } } diff --git a/Loop/Models/LoopSettings.swift b/Loop/Models/LoopSettings.swift index b9e7f8bb6..c9f324c8c 100644 --- a/Loop/Models/LoopSettings.swift +++ b/Loop/Models/LoopSettings.swift @@ -28,17 +28,11 @@ struct LoopSettings { var retrospectiveCorrectionEnabled = true - // Not configurable through UI + // Not configurable through UI, but might be nice. let automatedBolusThreshold: Double = 0.2 let automatedBolusRatio: Double = 0.7 - let automaticBolusInterval: TimeInterval = TimeInterval(minutes: 3) - let absorptionRate: Double = 20 - + let automaticBolusInterval: TimeInterval = TimeInterval(minutes: 7) let minimumRecommendedBolus: Double = 0.2 - let insulinIncrementPerUnit: Double = 10 // 0.1 steps in basal and bolus - - let absorptionTimeOverrun = 1.0 - } From 634cf143c0c89e6e2887a2f552649f6aef63c2a3 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 4 May 2018 22:09:42 +0200 Subject: [PATCH 08/16] Fix merged data to actually give a bolus recommendation and deliver the bolus. --- Loop/Managers/DeviceDataManager.swift | 5 ++- Loop/Managers/DoseMath.swift | 5 +++ Loop/Managers/LoopDataManager.swift | 50 +++++++++------------------ 3 files changed, 25 insertions(+), 35 deletions(-) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 877f8f8b3..60348ab51 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -410,7 +410,10 @@ final class DeviceDataManager { ops.runSession(withName: "Fetch Pump History", using: device) { (session) in do { - let (events, model) = try session.getHistoryEvents(since: self.loopManager.doseStore.pumpEventQueryAfterDate) + // TODO: This should isn't safe to access synchronously + let startDate = self.loopManager.doseStore.pumpEventQueryAfterDate + + let (events, model) = try session.getHistoryEvents(since: startDate) self.loopManager.addPumpEvents(events, from: model) { (error) in if let error = error { self.logger.addError("Failed to store history: \(error)", fromSource: "DoseStore") diff --git a/Loop/Managers/DoseMath.swift b/Loop/Managers/DoseMath.swift index e8331c59c..3afbdffdb 100644 --- a/Loop/Managers/DoseMath.swift +++ b/Loop/Managers/DoseMath.swift @@ -375,6 +375,7 @@ extension Collection where Iterator.Element == GlucoseValue { insulinOnBoard: Double, maxInsulinOnBoard: Double, lastTempBasal: DoseEntry?, + lowerOnly: Bool = false, duration: TimeInterval = .minutes(30), minimumProgrammableIncrementPerUnit: Double = 40, continuationInterval: TimeInterval = .minutes(11) @@ -397,6 +398,10 @@ extension Collection where Iterator.Element == GlucoseValue { maxBasalRate = scheduledBasalRate } + if lowerOnly { + maxBasalRate = scheduledBasalRate + } + let temp = correction?.asTempBasal( scheduledBasalRate: scheduledBasalRate, maxBasalRate: maxBasalRate, diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index aa0087a41..67a84e510 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -328,8 +328,6 @@ final class LoopDataManager { dataAccessQueue.async { NSLog("addRequestedBolus: \(units) \(date)") self.recommendedBolus = nil - self.lastPendingBolus = nil - self.lastFailedBolus = nil self.lastRequestedBolus = (units: units, date: date, reservoir: self.doseStore.lastReservoirValue) self.notify(forChange: .bolus) @@ -347,10 +345,7 @@ final class LoopDataManager { NSLog("addConfirmedBolus: \(units) \(date)") self.doseStore.addPendingPumpEvent(event) { self.dataAccessQueue.async { - let requestDate = self.lastRequestedBolus?.date ?? date - self.lastPendingBolus = (units: units, date: requestDate, reservoir: self.doseStore.lastReservoirValue, event: event) self.lastRequestedBolus = nil - self.lastFailedBolus = nil self.lastAutomaticBolus = date // keep this as a date, irrespective of automatic or not self.recommendedBolus = nil self.insulinEffect = nil @@ -366,17 +361,6 @@ final class LoopDataManager { } } - func addFailedBolus(units: Double, at date: Date, error: Error, completion: (() -> Void)?) { - dataAccessQueue.async { - NSLog("addFailedBolus: \(units) \(date) \(error)") - self.lastFailedBolus = (units: units, date: date, error: error) - self.lastPendingBolus = nil - self.recommendedBolus = nil - self.notify(forChange: .bolus) - completion?() - } - } - /// Adds and stores new pump events /// /// - Parameters: @@ -795,15 +779,13 @@ final class LoopDataManager { fileprivate var recommendedTempBasal: (recommendation: TempBasalRecommendation, date: Date)? fileprivate var recommendedBolus: (recommendation: BolusRecommendation, date: Date)? - + fileprivate var carbsOnBoard: CarbValue? fileprivate var lastTempBasal: DoseEntry? - + fileprivate var lastRequestedBolus: (units: Double, date: Date, reservoir: ReservoirValue?)? - fileprivate var lastPendingBolus: (units: Double, date: Date, reservoir: ReservoirValue?, event: NewPumpEvent)? - fileprivate var lastFailedBolus: (units: Double, date: Date, error: Error)? - + fileprivate var lastLoopCompleted: Date? { didSet { NotificationManager.scheduleLoopNotRunningNotifications() @@ -973,6 +955,7 @@ final class LoopDataManager { // Don't recommend changes if a bolus was just requested. // Sending additional pump commands is not going to be // successful in any case. + NSLog("updatePredictedGlucoseAndRecommendedBasalAndBolus - previous Bolus still in progress") recommendedBolus = nil recommendedTempBasal = nil return @@ -987,16 +970,16 @@ final class LoopDataManager { maxBasalRate: maxBasal, insulinOnBoard: insulinOnBoard.value, maxInsulinOnBoard: maximumInsulinOnBoard, - lastTempBasal: lastTempBasal + lastTempBasal: lastTempBasal, + lowerOnly: settings.bolusEnabled ) if let temp = tempBasal { recommendedTempBasal = (recommendation: temp, date: startDate) } else { recommendedTempBasal = nil - return } - + let recommendation = predictedGlucose.recommendedBolus( to: glucoseTargetRange, suspendThreshold: settings.suspendThreshold?.quantity, @@ -1038,15 +1021,14 @@ final class LoopDataManager { } } } - - /// *This method should only be called from the `dataAccessQueue`* + + // Keep track of last automatic bolus to space them out private var lastAutomaticBolus : Date? = nil + + // Keeps track of last carb change to prevent bolus immediately afterwards. private var lastCarbChange : Date? = nil - - private func roundInsulinUnits(_ units: Double) -> Double { - return round(units * settings.insulinIncrementPerUnit)/settings.insulinIncrementPerUnit - } - + + /// *This method should only be called from the `dataAccessQueue`* private func setAutomatedBolus(_ completion: @escaping (_ error: Error?) -> Void) { dispatchPrecondition(condition: .onQueue(dataAccessQueue)) @@ -1056,7 +1038,7 @@ final class LoopDataManager { return } - let safeAmount = roundInsulinUnits(recommendedBolus.recommendation.amount * settings.automatedBolusRatio) + let safeAmount = round(recommendedBolus.recommendation.amount * settings.automatedBolusRatio * 10) / 10 if safeAmount < settings.automatedBolusThreshold { completion(nil) NSLog("setAutomatedBolus - recommendation below threshold") @@ -1083,9 +1065,9 @@ final class LoopDataManager { } } - // TODO lastPendingBolus is never cleared, thus we need to check for the date here. + // TODO lastRequestedBolus might not ever get cleared, thus we need to check for the date here. if lastRequestedBolus != nil { - NSLog("setAutomatedBolus - lastRequestedBolus or lastPendingBolus still in progress \(String(describing: lastRequestedBolus)) \(String(describing: lastPendingBolus))") + NSLog("setAutomatedBolus - lastRequestedBolus still in progress \(String(describing: lastRequestedBolus))") completion(nil) return } From a8ec1ff69c731005857e4684030784fda16c8998 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 4 May 2018 22:28:30 +0200 Subject: [PATCH 09/16] Document new lowerOnly parameter --- Loop/Managers/DoseMath.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Loop/Managers/DoseMath.swift b/Loop/Managers/DoseMath.swift index 3afbdffdb..c1902d36d 100644 --- a/Loop/Managers/DoseMath.swift +++ b/Loop/Managers/DoseMath.swift @@ -360,6 +360,7 @@ extension Collection where Iterator.Element == GlucoseValue { /// - insulinOnBoard: The current insulin on board /// - maxInsulinOnBoard: The maximum insulin allowed /// - lastTempBasal: The previously set temp basal + /// - lowerOnly: Only return lower basal rates, never higher /// - duration: The duration of the temporary basal /// - minimumProgrammableIncrementPerUnit: The smallest fraction of a unit supported in basal delivery /// - continuationInterval: The duration of time before an ongoing temp basal should be continued with a new command From 9ae7b9ccd5cac1c95b68ca9a23c05fb99299724c Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 4 May 2018 22:32:02 +0200 Subject: [PATCH 10/16] Document parameters to enactBolus and add a thought about a critical section. --- Loop/Managers/DeviceDataManager.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 60348ab51..f90764462 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -517,6 +517,8 @@ final class DeviceDataManager { /// Send a bolus command and handle the result /// /// - parameter units: The number of units to deliver + /// - parameter startDate: The start of the bolus. + /// - parameter quiet: Do not produce a notification of failure to the user. /// - parameter completion: A clsure called after the command is complete. This closure takes a single argument: /// - error: An error describing why the command failed func enactBolus(units: Double, at startDate: Date = Date(), quiet : Bool = false, completion: @escaping (_ error: Error?) -> Void) { @@ -561,6 +563,9 @@ final class DeviceDataManager { } if shouldReadReservoir { + // TODO it might be safer to return here and not give a Bolus + // forcing the recalculation of recommendedBolus. The new + // data might have invalidated the old recommendation. do { let reservoir = try session.getRemainingInsulin() From 9e0af1c4bc537b816639855156e5a13f2db3c6fb Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 4 May 2018 22:49:44 +0200 Subject: [PATCH 11/16] Remove unused setting. --- Loop/Models/LoopSettings.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Loop/Models/LoopSettings.swift b/Loop/Models/LoopSettings.swift index c9f324c8c..34cb61a9d 100644 --- a/Loop/Models/LoopSettings.swift +++ b/Loop/Models/LoopSettings.swift @@ -32,7 +32,6 @@ struct LoopSettings { let automatedBolusThreshold: Double = 0.2 let automatedBolusRatio: Double = 0.7 let automaticBolusInterval: TimeInterval = TimeInterval(minutes: 7) - let minimumRecommendedBolus: Double = 0.2 } From 7540620b7e7418c7baee22f4ffef5f78482ac668 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 15 Jun 2018 12:01:34 +0200 Subject: [PATCH 12/16] Add maximum Insulin on board protection to Bolus dialog as well. --- .../BolusViewController+LoopDataManager.swift | 5 +++++ Loop/View Controllers/BolusViewController.swift | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/Loop/View Controllers/BolusViewController+LoopDataManager.swift b/Loop/View Controllers/BolusViewController+LoopDataManager.swift index 84246ef88..e215aa288 100644 --- a/Loop/View Controllers/BolusViewController+LoopDataManager.swift +++ b/Loop/View Controllers/BolusViewController+LoopDataManager.swift @@ -13,6 +13,7 @@ extension BolusViewController { func configureWithLoopManager(_ manager: LoopDataManager, recommendation: BolusRecommendation?, glucoseUnit: HKUnit) { manager.getLoopState { (manager, state) in let maximumBolus = manager.settings.maximumBolus + let maximumInsulinOnBoard = manager.settings.maximumInsulinOnBoard let activeCarbohydrates = state.carbsOnBoard?.quantity.doubleValue(for: .gram()) let bolusRecommendation: BolusRecommendation? @@ -38,6 +39,10 @@ extension BolusViewController { self.maxBolus = maxBolus } + if let maxInsulinOnBoard = maximumInsulinOnBoard { + self.maxInsulinOnBoard = maxInsulinOnBoard + } + self.glucoseUnit = glucoseUnit self.activeInsulin = activeInsulin self.activeCarbohydrates = activeCarbohydrates diff --git a/Loop/View Controllers/BolusViewController.swift b/Loop/View Controllers/BolusViewController.swift index 58d01a89c..f3d0a7744 100644 --- a/Loop/View Controllers/BolusViewController.swift +++ b/Loop/View Controllers/BolusViewController.swift @@ -116,6 +116,7 @@ final class BolusViewController: UITableViewController, IdentifiableClass, UITex var maxBolus: Double = 25 + var maxInsulinOnBoard: Double = 0 private(set) var bolus: Double? @@ -186,6 +187,15 @@ final class BolusViewController: UITableViewController, IdentifiableClass, UITex return } + if maxInsulinOnBoard > 0 { + guard bolus + (activeInsulin ?? 0) <= maxInsulinOnBoard else { + NSLog("BolusViewController - maxIOB") + presentAlertController(withTitle: NSLocalizedString("Would exceed Maximum Insulin on Board", comment: "The title of the alert describing a maximum insulin on board validation error"), message: String(format: NSLocalizedString("The insulin on board amount is %@ Units", comment: "Body of the alert describing a maximum iob validation error. (1: The localized max iob value)"), + bolusUnitsFormatter.string(from: NSNumber(value: maxInsulinOnBoard)) ?? "")) + return + } + } + let context = LAContext() if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: nil) { From f2a66b6e41ccc4998a100557d07a548bae6e1836 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 15 Jun 2018 12:15:16 +0200 Subject: [PATCH 13/16] Improve error message for IOB validation. --- Loop/View Controllers/BolusViewController.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Loop/View Controllers/BolusViewController.swift b/Loop/View Controllers/BolusViewController.swift index f3d0a7744..ce0dd173a 100644 --- a/Loop/View Controllers/BolusViewController.swift +++ b/Loop/View Controllers/BolusViewController.swift @@ -187,11 +187,16 @@ final class BolusViewController: UITableViewController, IdentifiableClass, UITex return } + let iob = activeInsulin ?? 0 if maxInsulinOnBoard > 0 { - guard bolus + (activeInsulin ?? 0) <= maxInsulinOnBoard else { + guard bolus + iob <= maxInsulinOnBoard else { NSLog("BolusViewController - maxIOB") - presentAlertController(withTitle: NSLocalizedString("Would exceed Maximum Insulin on Board", comment: "The title of the alert describing a maximum insulin on board validation error"), message: String(format: NSLocalizedString("The insulin on board amount is %@ Units", comment: "Body of the alert describing a maximum iob validation error. (1: The localized max iob value)"), - bolusUnitsFormatter.string(from: NSNumber(value: maxInsulinOnBoard)) ?? "")) + presentAlertController(withTitle: NSLocalizedString("Exceeds Maximum Insulin on Board", comment: "The title of the alert describing a maximum insulin on board validation error"), message: String(format: NSLocalizedString("The insulin on board amount is %@ Units. Together with the entered value of %@ Units it exceeds the configured maximum of %@ Units.", comment: "Body of the alert describing a maximum iob validation error. (1: The bolus value, 2: The IOB value, 3: The maximum IOB permitted)"), + bolusUnitsFormatter.string(from: NSNumber(value: iob)) ?? "", + bolusUnitsFormatter.string(from: NSNumber(value: bolus)) ?? "", + bolusUnitsFormatter.string(from: NSNumber(value: maxInsulinOnBoard)) ?? "" + )) + return } } From 353e9d593e0977af4a909bb3501a764872eed149 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 23 Jun 2018 23:40:46 +0200 Subject: [PATCH 14/16] Fix test code after merge --- DoseMathTests/DoseMathTests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/DoseMathTests/DoseMathTests.swift b/DoseMathTests/DoseMathTests.swift index 7df9cd335..9d807c9df 100644 --- a/DoseMathTests/DoseMathTests.swift +++ b/DoseMathTests/DoseMathTests.swift @@ -712,7 +712,7 @@ class RecommendBolusTests: XCTestCase { XCTAssertEqual(1.3, dose.amount) if case BolusRecommendationNotice.currentGlucoseBelowTarget(let glucose) = dose.notice! { - XCTAssertEqual(glucose.quantity.doubleValue(for: .milligramsPerDeciliter()), 60) + XCTAssertEqual(glucose.quantity.doubleValue(for: .milligramsPerDeciliter), 60) } else { XCTFail("Expected currentGlucoseBelowTarget, but got \(dose.notice!)") } @@ -736,7 +736,7 @@ class RecommendBolusTests: XCTestCase { XCTAssertEqual(0.3, dose.amount) if case BolusRecommendationNotice.currentGlucoseBelowTarget(let glucose) = dose.notice! { - XCTAssertEqual(glucose.quantity.doubleValue(for: .milligramsPerDeciliter()), 60) + XCTAssertEqual(glucose.quantity.doubleValue(for: .milligramsPerDeciliter), 60) } else { XCTFail("Expected currentGlucoseBelowTarget, but got \(dose.notice!)") } @@ -760,7 +760,7 @@ class RecommendBolusTests: XCTestCase { XCTAssertEqual(0, dose.amount) if case BolusRecommendationNotice.currentGlucoseBelowTarget(let glucose) = dose.notice! { - XCTAssertEqual(glucose.quantity.doubleValue(for: .milligramsPerDeciliter()), 60) + XCTAssertEqual(glucose.quantity.doubleValue(for: .milligramsPerDeciliter), 60) } else { XCTFail("Expected currentGlucoseBelowTarget, but got \(dose.notice!)") } From b0fb27e646b3d43e279ad812bcf8ab27bba98227 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 23 Jun 2018 23:44:15 +0200 Subject: [PATCH 15/16] Fix merge with latest upstream/dev --- Loop/Managers/DeviceDataManager.swift | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index 9343ef67c..0b61ea0a0 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -562,13 +562,15 @@ final class DeviceDataManager { return } + guard let recencyInterval = loopManager?.settings.recencyInterval else { + notify(LoopError.configurationError("LoopManager")) + return + } + // If we don't have recent pump data, or the pump was recently rewound, read new pump data before bolusing. var shouldReadReservoir = isReservoirDataOlderThan(timeIntervalSinceNow: .minutes(-10)) - if loopManager.doseStore.lastReservoirVolumeDrop < 0 { - notify(LoopError.invalidData(details: "Last Reservoir drop negative.")) - shouldReadReservoir = true - } else if let reservoir = loopManager.doseStore.lastReservoirValue, reservoir.startDate.timeIntervalSinceNow <= - -loopManager.recencyInterval { + if let reservoir = loopManager.doseStore.lastReservoirValue, reservoir.startDate.timeIntervalSinceNow <= + -recencyInterval { notify(LoopError.pumpDataTooOld(date: reservoir.startDate)) shouldReadReservoir = true } else if loopManager.doseStore.lastReservoirValue == nil { From d60744d9224697244117ac682074e8a9acb41dff Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 24 Jun 2018 21:03:28 +0200 Subject: [PATCH 16/16] Re-add diagnostic report entries which were accidentally removed during the merge --- Loop/Managers/LoopDataManager.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 376ccbf74..512b04f62 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1235,6 +1235,21 @@ extension LoopDataManager { var entries = [ "## LoopDataManager", "settings: \(String(reflecting: manager.settings))", + + "insulinCounteractionEffects: [", + "* GlucoseEffectVelocity(start, end, mg/dL/min)", + manager.insulinCounteractionEffects.reduce(into: "", { (entries, entry) in + entries.append("* \(entry.startDate), \(entry.endDate), \(entry.quantity.doubleValue(for: GlucoseEffectVelocity.unit))\n") + }), + "]", + + "predictedGlucose: [", + "* PredictedGlucoseValue(start, mg/dL)", + (state.predictedGlucose ?? []).reduce(into: "", { (entries, entry) in + entries.append("* \(entry.startDate), \(entry.quantity.doubleValue(for: .milligramsPerDeciliter))\n") + }), + "]", + "retrospectivePredictedGlucose: \(state.retrospectivePredictedGlucose ?? [])", "glucoseMomentumEffect: \(manager.glucoseMomentumEffect ?? [])", "retrospectiveGlucoseEffect: \(manager.retrospectiveGlucoseEffect)",