diff --git a/Common/Extensions/NSUserDefaults.swift b/Common/Extensions/NSUserDefaults.swift index 4ba5a594f5..ffa20cb190 100644 --- a/Common/Extensions/NSUserDefaults.swift +++ b/Common/Extensions/NSUserDefaults.swift @@ -125,6 +125,7 @@ extension UserDefaults { glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, maximumBasalRatePerHour: maximumBasalRatePerHour, maximumBolus: maximumBolus, + maximumInsulinOnBoard: nil, suspendThreshold: suspendThreshold, retrospectiveCorrectionEnabled: bool(forKey: "com.loudnate.Loop.RetrospectiveCorrectionEnabled") ) diff --git a/Common/Models/LoopSettings.swift b/Common/Models/LoopSettings.swift index fbab2c1656..e0eb5da2a4 100644 --- a/Common/Models/LoopSettings.swift +++ b/Common/Models/LoopSettings.swift @@ -19,6 +19,8 @@ struct LoopSettings { var maximumBasalRatePerHour: Double? var maximumBolus: Double? + + var maximumInsulinOnBoard: Double? var suspendThreshold: GlucoseThreshold? = nil @@ -59,6 +61,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 { @@ -79,6 +82,7 @@ extension LoopSettings: RawRepresentable { raw["glucoseTargetRangeSchedule"] = glucoseTargetRangeSchedule?.rawValue raw["maximumBasalRatePerHour"] = maximumBasalRatePerHour + raw["maximumInsulinOnBoard"] = maximumInsulinOnBoard raw["maximumBolus"] = maximumBolus raw["minimumBGGuard"] = suspendThreshold?.rawValue diff --git a/DoseMathTests/DoseMathTests.swift b/DoseMathTests/DoseMathTests.swift index 9b2718701a..9d807c9df5 100644 --- a/DoseMathTests/DoseMathTests.swift +++ b/DoseMathTests/DoseMathTests.swift @@ -100,6 +100,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") @@ -111,6 +119,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -128,6 +138,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -150,6 +162,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: lastTempBasal ) @@ -168,6 +182,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -189,6 +205,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: lastTempBasal ) @@ -216,6 +234,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: lastTempBasal ) @@ -230,6 +250,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -247,6 +269,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -265,6 +289,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -286,6 +312,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: lastTempBasal ) @@ -304,13 +332,75 @@ 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 + ) + // If the IOB is exceeded the rate is limited to the default + // basal rate. + XCTAssertNil(dose) + } + func testHighAndFalling() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_high_and_falling") @@ -322,6 +412,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -340,6 +432,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -358,6 +452,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -375,6 +471,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -393,6 +491,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -411,6 +511,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -428,6 +530,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -486,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") @@ -496,7 +608,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0, dose.amount) @@ -512,7 +626,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0, dose.amount) @@ -528,7 +644,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0, dose.amount) @@ -544,7 +662,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0, dose.amount) @@ -560,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) @@ -572,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") @@ -583,7 +777,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0, dose.amount) @@ -606,7 +802,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0, dose.amount) @@ -628,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) @@ -646,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) @@ -662,7 +864,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0, dose.amount) @@ -678,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) @@ -694,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) @@ -710,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) @@ -724,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) @@ -740,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) @@ -756,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) @@ -771,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) @@ -787,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) @@ -803,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/Managers/DoseMath.swift b/Loop/Managers/DoseMath.swift index a7c26ef130..a70db95ce9 100644 --- a/Loop/Managers/DoseMath.swift +++ b/Loop/Managers/DoseMath.swift @@ -37,15 +37,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: @@ -83,15 +88,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( @@ -345,6 +355,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 @@ -358,6 +370,8 @@ extension Collection where Iterator.Element == GlucoseValue { model: InsulinModel, basalRates: BasalRateSchedule, maxBasalRate: Double, + insulinOnBoard: Double, + maxInsulinOnBoard: Double, lastTempBasal: DoseEntry?, duration: TimeInterval = .minutes(30), minimumProgrammableIncrementPerUnit: Double = 40, @@ -384,6 +398,8 @@ extension Collection where Iterator.Element == GlucoseValue { let temp = correction?.asTempBasal( scheduledBasalRate: scheduledBasalRate, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, duration: duration, minimumProgrammableIncrementPerUnit: minimumProgrammableIncrementPerUnit ) @@ -406,6 +422,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( @@ -416,6 +434,8 @@ extension Collection where Iterator.Element == GlucoseValue { model: InsulinModel, pendingInsulin: Double, maxBolus: Double, + insulinOnBoard: Double, + maxInsulinOnBoard: Double, minimumProgrammableIncrementPerUnit: Double = 40 ) -> BolusRecommendation { guard let correction = self.insulinCorrection( @@ -431,6 +451,8 @@ extension Collection where Iterator.Element == GlucoseValue { var bolus = correction.asBolus( pendingInsulin: pendingInsulin, maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, minimumProgrammableIncrementPerUnit: minimumProgrammableIncrementPerUnit ) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 2699c3881c..b7218bbd84 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -144,6 +144,11 @@ final class LoopDataManager { predictedGlucose = nil } } + private var insulinOnBoard: InsulinValue? { + didSet { + predictedGlucose = nil + } + } private var glucoseMomentumEffect: [GlucoseEffect]? { didSet { predictedGlucose = nil @@ -691,6 +696,25 @@ extension 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): + self.logger.error(error) + self.insulinOnBoard = nil + } + updateGroup.leave() + } + } _ = updateGroup.wait(timeout: .distantFuture) @@ -892,6 +916,7 @@ extension LoopDataManager { guard let maxBasal = settings.maximumBasalRatePerHour, + let maximumInsulinOnBoard = settings.maximumInsulinOnBoard, let glucoseTargetRange = settings.glucoseTargetRangeSchedule, let insulinSensitivity = insulinSensitivitySchedule, let basalRates = basalRateSchedule, @@ -901,6 +926,12 @@ extension LoopDataManager { throw LoopError.configurationError("Check settings") } + guard let + insulinOnBoard = insulinOnBoard + else { + throw LoopError.missingDataError(details: "Insulin on Board not available", recovery: "Check pump") + } + guard lastRequestedBolus == nil else { // Don't recommend changes if a bolus was just requested. @@ -910,7 +941,7 @@ extension LoopDataManager { recommendedTempBasal = nil return } - + let tempBasal = predictedGlucose.recommendedTempBasal( to: glucoseTargetRange, suspendThreshold: settings.suspendThreshold?.quantity, @@ -918,9 +949,11 @@ extension LoopDataManager { model: model, basalRates: basalRates, maxBasalRate: maxBasal, + insulinOnBoard: insulinOnBoard.value, + maxInsulinOnBoard: maximumInsulinOnBoard, lastTempBasal: lastTempBasal ) - + if let temp = tempBasal { recommendedTempBasal = (recommendation: temp, date: startDate) } else { @@ -935,7 +968,9 @@ extension LoopDataManager { sensitivity: insulinSensitivity, model: model, pendingInsulin: pendingInsulin, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard.value, + maxInsulinOnBoard: maximumInsulinOnBoard ) recommendedBolus = (recommendation: recommendation, date: startDate) } @@ -976,6 +1011,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 } @@ -1021,6 +1058,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 +1157,7 @@ extension LoopDataManager { "retrospectivePredictedGlucose: \(state.retrospectivePredictedGlucose ?? [])", "glucoseMomentumEffect: \(manager.glucoseMomentumEffect ?? [])", "retrospectiveGlucoseEffect: \(manager.retrospectiveGlucoseEffect)", + "insulinOnBoard: \(String(describing: state.insulinOnBoard))", "recommendedTempBasal: \(String(describing: state.recommendedTempBasal))", "recommendedBolus: \(String(describing: state.recommendedBolus))", "lastBolus: \(String(describing: manager.lastRequestedBolus))", @@ -1144,6 +1187,7 @@ extension LoopDataManager { } } } + } } } diff --git a/Loop/View Controllers/BolusViewController+LoopDataManager.swift b/Loop/View Controllers/BolusViewController+LoopDataManager.swift index 84246ef882..e215aa2882 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 1d74917dd8..011c0191f0 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,20 @@ final class BolusViewController: UITableViewController, IdentifiableClass, UITex return } + let iob = activeInsulin ?? 0 + if maxInsulinOnBoard > 0 { + guard bolus + iob <= maxInsulinOnBoard else { + NSLog("BolusViewController - maxIOB") + 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 + } + } + let context = LAContext() if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: nil) { diff --git a/Loop/View Controllers/SettingsTableViewController.swift b/Loop/View Controllers/SettingsTableViewController.swift index c81e66d99b..5e59e39a51 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 { @@ -340,6 +341,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 @@ -474,14 +483,15 @@ final class SettingsTableViewController: UITableViewController, DailyValueSchedu case .configuration: let row = ConfigurationRow(rawValue: indexPath.row)! switch row { - case .maxBasal, .maxBolus: + case .maxBasal, .maxBolus, .maxInsulinOnBoard: let vc: LoopKitUI.TextFieldTableViewController - switch row { case .maxBasal: vc = .maxBasal(dataManager.loopManager.settings.maximumBasalRatePerHour) case .maxBolus: vc = .maxBolus(dataManager.loopManager.settings.maximumBolus) + case .maxInsulinOnBoard: + vc = .maxInsulinOnBoard(dataManager.loopManager.settings.maximumInsulinOnBoard) default: fatalError() } @@ -981,6 +991,12 @@ extension SettingsTableViewController: LoopKitUI.TextFieldTableViewControllerDel } 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 fe7b8378dc..85f57173c0 100644 --- a/Loop/View Controllers/TextFieldTableViewController.swift +++ b/Loop/View Controllers/TextFieldTableViewController.swift @@ -72,5 +72,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 + } }