diff --git a/DoseMathTests/DoseMathTests.swift b/DoseMathTests/DoseMathTests.swift index 5752a5501f..987977205f 100644 --- a/DoseMathTests/DoseMathTests.swift +++ b/DoseMathTests/DoseMathTests.swift @@ -93,6 +93,10 @@ class RecommendTempBasalTests: XCTestCase { return GlucoseThreshold(unit: HKUnit.milligramsPerDeciliter(), value: 55) } + var insulinModel: InsulinModel { + return WalshInsulinModel(actionDuration: insulinActionDuration) + } + var insulinActionDuration: TimeInterval { return TimeInterval(hours: 4) } @@ -100,15 +104,15 @@ class RecommendTempBasalTests: XCTestCase { func testNoChange() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_no_change_glucose") - let dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, + let dose = glucose.recommendedTempBasal( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration + lastTempBasal: nil ) XCTAssertNil(dose) @@ -117,15 +121,15 @@ class RecommendTempBasalTests: XCTestCase { func testStartHighEndInRange() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_start_high_end_in_range") - var dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, + var dose = glucose.recommendedTempBasal( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration + lastTempBasal: nil ) XCTAssertNil(dose) @@ -139,33 +143,33 @@ class RecommendTempBasalTests: XCTestCase { unit: .unitsPerHour ) - dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: lastTempBasal, + dose = glucose.recommendedTempBasal( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration + lastTempBasal: lastTempBasal ) - XCTAssertEqual(0, dose!.rate) + XCTAssertEqual(0, dose!.unitsPerHour) XCTAssertEqual(TimeInterval(minutes: 0), dose!.duration) } func testStartLowEndInRange() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_start_low_end_in_range") - var dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, + var dose = glucose.recommendedTempBasal( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration + lastTempBasal: nil ) XCTAssertNil(dose) @@ -178,18 +182,18 @@ class RecommendTempBasalTests: XCTestCase { unit: .unitsPerHour ) - dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: lastTempBasal, + dose = glucose.recommendedTempBasal( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration + lastTempBasal: lastTempBasal ) - XCTAssertEqual(0, dose!.rate) + XCTAssertEqual(0, dose!.unitsPerHour) XCTAssertEqual(TimeInterval(minutes: 0), dose!.duration) } @@ -197,7 +201,7 @@ class RecommendTempBasalTests: XCTestCase { let glucose = loadGlucoseValueFixture("recommend_temp_basal_correct_low_at_min") // Cancel existing dose - var lastTempBasal = DoseEntry( + let lastTempBasal = DoseEntry( type: .tempBasal, startDate: glucose.first!.startDate.addingTimeInterval(TimeInterval(minutes: -21)), endDate: glucose.first!.startDate.addingTimeInterval(TimeInterval(minutes: 9)), @@ -205,88 +209,64 @@ class RecommendTempBasalTests: XCTestCase { unit: .unitsPerHour ) - var dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: lastTempBasal, + var dose = glucose.recommendedTempBasal( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration + lastTempBasal: lastTempBasal ) - XCTAssertEqual(0, dose!.rate) + XCTAssertEqual(0, dose!.unitsPerHour) XCTAssertEqual(TimeInterval(minutes: 0), dose!.duration) - // Allow predictive temp below range - dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, + dose = glucose.recommendedTempBasal( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration + lastTempBasal: nil ) XCTAssertNil(dose) - - lastTempBasal = DoseEntry( - type: .tempBasal, - startDate: glucose.first!.startDate.addingTimeInterval(TimeInterval(minutes: -21)), - endDate: glucose.first!.startDate.addingTimeInterval(TimeInterval(minutes: 9)), - value: 0.125, - unit: .unitsPerHour - ) - - dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: lastTempBasal, - maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration - ) - - XCTAssertEqual(0, dose!.rate) - XCTAssertEqual(TimeInterval(minutes: 0), dose!.duration) } func testStartHighEndLow() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_start_high_end_low") - let dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, + let dose = glucose.recommendedTempBasal( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration + lastTempBasal: nil ) - XCTAssertEqual(0, dose!.rate) + XCTAssertEqual(0, dose!.unitsPerHour) XCTAssertEqual(TimeInterval(minutes: 30), dose!.duration) } func testStartLowEndHigh() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_start_low_end_high") - // Allow predictive temp below range - var dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, + var dose = glucose.recommendedTempBasal( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration + lastTempBasal: nil ) XCTAssertNil(dose) @@ -299,140 +279,140 @@ class RecommendTempBasalTests: XCTestCase { unit: .unitsPerHour ) - dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: lastTempBasal, + dose = glucose.recommendedTempBasal( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration + lastTempBasal: lastTempBasal ) - XCTAssertEqual(0, dose!.rate) + XCTAssertEqual(0, dose!.unitsPerHour) XCTAssertEqual(TimeInterval(minutes: 0), dose!.duration) } func testFlatAndHigh() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_flat_and_high") - let dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, + let dose = glucose.recommendedTempBasal( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration + lastTempBasal: nil ) - XCTAssertEqual(3.0, dose!.rate) + XCTAssertEqual(3.0, dose!.unitsPerHour) XCTAssertEqual(TimeInterval(minutes: 30), dose!.duration) } func testHighAndFalling() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_high_and_falling") - let dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, + let dose = glucose.recommendedTempBasal( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration + lastTempBasal: nil ) - XCTAssertEqualWithAccuracy(1.425, dose!.rate, accuracy: 1.0 / 40.0) + XCTAssertEqualWithAccuracy(1.425, dose!.unitsPerHour, accuracy: 1.0 / 40.0) XCTAssertEqual(TimeInterval(minutes: 30), dose!.duration) } func testInRangeAndRising() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_in_range_and_rising") - let dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, + let dose = glucose.recommendedTempBasal( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration + lastTempBasal: nil ) - XCTAssertEqualWithAccuracy(1.475, dose!.rate, accuracy: 1.0 / 40.0) + XCTAssertEqualWithAccuracy(1.475, dose!.unitsPerHour, accuracy: 1.0 / 40.0) XCTAssertEqual(TimeInterval(minutes: 30), dose!.duration) } func testHighAndRising() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_high_and_rising") - var dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, + var dose = glucose.recommendedTempBasal( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: self.insulinSensitivitySchedule, + model: insulinModel, + basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: self.insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration + lastTempBasal: nil ) - XCTAssertEqual(3.0, dose!.rate) + XCTAssertEqual(3.0, dose!.unitsPerHour) XCTAssertEqual(TimeInterval(minutes: 30), dose!.duration) // Use mmol sensitivity value let insulinSensitivitySchedule = InsulinSensitivitySchedule(unit: HKUnit.millimolesPerLiter(), dailyItems: [RepeatingScheduleValue(startTime: 0.0, value: 3.33)])! - dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, + dose = glucose.recommendedTempBasal( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration + lastTempBasal: nil ) - XCTAssertEqualWithAccuracy(2.975, dose!.rate, accuracy: 1.0 / 40.0) + XCTAssertEqualWithAccuracy(2.975, dose!.unitsPerHour, accuracy: 1.0 / 40.0) XCTAssertEqual(TimeInterval(minutes: 30), dose!.duration) } func testVeryLowAndRising() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_very_low_end_in_range") - let dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, - maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: self.insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration + let dose = glucose.recommendedTempBasal( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + basalRates: basalRateSchedule, + maxBasalRate: maxBasalRate, + lastTempBasal: nil ) - XCTAssertEqual(0.0, dose!.rate) + XCTAssertEqual(0.0, dose!.unitsPerHour) XCTAssertEqual(TimeInterval(minutes: 30), dose!.duration) } func testRiseAfterDIA() { let glucose = loadGlucoseValueFixture("far_future_high_bg_forecast") - let dose = DoseMath.recommendTempBasalFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, - maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: self.insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration + let dose = glucose.recommendedTempBasal( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + basalRates: basalRateSchedule, + maxBasalRate: maxBasalRate, + lastTempBasal: nil ) XCTAssertNil(dose) @@ -440,14 +420,16 @@ class RecommendTempBasalTests: XCTestCase { func testNoInputGlucose() { - let dose = DoseMath.recommendTempBasalFromPredictedGlucose([], - lastTempBasal: nil, + let glucose: [GlucoseValue] = [] + + let dose = glucose.recommendedTempBasal( + to: glucoseTargetRange, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration + lastTempBasal: nil ) XCTAssertNil(dose) @@ -497,6 +479,10 @@ class RecommendBolusTests: XCTestCase { return GlucoseThreshold(unit: HKUnit.milligramsPerDeciliter(), value: 55) } + var insulinModel: InsulinModel { + return WalshInsulinModel(actionDuration: insulinActionDuration) + } + var insulinActionDuration: TimeInterval { return TimeInterval(hours: 4) } @@ -504,15 +490,14 @@ class RecommendBolusTests: XCTestCase { func testNoChange() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_no_change_glucose") - let dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, + let dose = glucose.recommendedBolus( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, pendingInsulin: 0, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration + maxBolus: maxBolus ) XCTAssertEqual(0, dose.amount) @@ -521,16 +506,14 @@ class RecommendBolusTests: XCTestCase { func testStartHighEndInRange() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_start_high_end_in_range") - let dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, + let dose = glucose.recommendedBolus( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, pendingInsulin: 0, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration - + maxBolus: maxBolus ) XCTAssertEqual(0, dose.amount) @@ -539,16 +522,14 @@ class RecommendBolusTests: XCTestCase { func testStartLowEndInRange() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_start_low_end_in_range") - let dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, + let dose = glucose.recommendedBolus( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, pendingInsulin: 0, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration - + maxBolus: maxBolus ) XCTAssertEqual(0, dose.amount) @@ -557,16 +538,14 @@ class RecommendBolusTests: XCTestCase { func testStartHighEndLow() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_start_high_end_low") - let dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, + let dose = glucose.recommendedBolus( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, pendingInsulin: 0, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration - + maxBolus: maxBolus ) XCTAssertEqual(0, dose.amount) @@ -575,23 +554,20 @@ class RecommendBolusTests: XCTestCase { func testStartLowEndHigh() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_start_low_end_high") - let dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, + let dose = glucose.recommendedBolus( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, pendingInsulin: 0, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration - + maxBolus: maxBolus ) - XCTAssertEqual(1.325, dose.amount) + XCTAssertEqual(1.575, dose.amount) - if case BolusRecommendationNotice.currentGlucoseBelowTarget(let glucose, let units) = dose.notice! { - XCTAssertEqual(units, HKUnit.milligramsPerDeciliter()) - XCTAssertEqual(glucose.quantity.doubleValue(for: units), 60) + if case BolusRecommendationNotice.currentGlucoseBelowTarget(let glucose) = dose.notice! { + XCTAssertEqual(glucose.quantity.doubleValue(for: .milligramsPerDeciliter()), 60) } else { XCTFail("Expected currentGlucoseBelowTarget, but got \(dose.notice!)") } @@ -600,51 +576,48 @@ class RecommendBolusTests: XCTestCase { func testDroppingBelowRangeThenRising() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_dropping_then_rising") - let dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, - pendingInsulin: 0, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration + let dose = glucose.recommendedBolus( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + pendingInsulin: 0, + maxBolus: maxBolus ) - XCTAssertEqual(1.325, dose.amount) - XCTAssertEqual(BolusRecommendationNotice.predictedGlucoseBelowTarget(minGlucose: glucose[1], unit: glucoseTargetRange.unit), dose.notice!) + XCTAssertEqual(1.4, dose.amount) + XCTAssertEqual(BolusRecommendationNotice.predictedGlucoseBelowTarget(minGlucose: glucose[1]), dose.notice!) } func testStartLowEndHighWithPendingBolus() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_start_low_end_high") - let dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, - pendingInsulin: 1, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration + let dose = glucose.recommendedBolus( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + pendingInsulin: 1, + maxBolus: maxBolus ) - XCTAssertEqual(0.325, dose.amount) + XCTAssertEqual(0.575, dose.amount) } func testStartVeryLowEndHigh() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_start_very_low_end_high") - let dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, - pendingInsulin: 0, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration + let dose = glucose.recommendedBolus( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + pendingInsulin: 0, + maxBolus: maxBolus ) XCTAssertEqual(0, dose.amount) @@ -653,127 +626,107 @@ class RecommendBolusTests: XCTestCase { func testFlatAndHigh() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_flat_and_high") - let dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, + let dose = glucose.recommendedBolus( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, pendingInsulin: 0, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration + maxBolus: maxBolus ) - XCTAssertEqualWithAccuracy(1.333, dose.amount, accuracy: 1.0 / 40.0) + XCTAssertEqualWithAccuracy(1.575, dose.amount, accuracy: 1.0 / 40.0) } func testHighAndFalling() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_high_and_falling") - let dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, + let dose = glucose.recommendedBolus( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, pendingInsulin: 0, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration + maxBolus: maxBolus ) - XCTAssertEqualWithAccuracy(0.067, dose.amount, accuracy: 1.0 / 40.0) + XCTAssertEqualWithAccuracy(0.325, dose.amount, accuracy: 1.0 / 40.0) } func testInRangeAndRising() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_in_range_and_rising") - var dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, + var dose = glucose.recommendedBolus( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, pendingInsulin: 0, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration + maxBolus: maxBolus ) - XCTAssertEqualWithAccuracy(0.083, dose.amount, accuracy: 1.0 / 40.0) + XCTAssertEqualWithAccuracy(0.325, dose.amount, accuracy: 1.0 / 40.0) // Less existing temp - dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, + dose = glucose.recommendedBolus( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, pendingInsulin: 0.8, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration + maxBolus: maxBolus ) XCTAssertEqualWithAccuracy(0, dose.amount, accuracy: 1e-13) - - dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, - pendingInsulin: 0, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration - ) - - XCTAssertEqualWithAccuracy(0.083, dose.amount, accuracy: 1.0 / 40.0) } func testHighAndRising() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_high_and_rising") - var dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: self.insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, + var dose = glucose.recommendedBolus( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: self.insulinSensitivitySchedule, + model: insulinModel, pendingInsulin: 0, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration + maxBolus: maxBolus ) - XCTAssertEqual(1.0, dose.amount) + XCTAssertEqual(1.25, dose.amount) // Use mmol sensitivity value let insulinSensitivitySchedule = InsulinSensitivitySchedule(unit: HKUnit.millimolesPerLiter(), dailyItems: [RepeatingScheduleValue(startTime: 0.0, value: 10.0 / 3)])! - dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, + dose = glucose.recommendedBolus( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, pendingInsulin: 0, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration + maxBolus: maxBolus ) - XCTAssertEqualWithAccuracy(1.0, dose.amount, accuracy: 1.0 / 40.0) + XCTAssertEqualWithAccuracy(1.25, dose.amount, accuracy: 1.0 / 40.0) } func testRiseAfterDIA() { let glucose = loadGlucoseValueFixture("far_future_high_bg_forecast") - let dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: self.insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, - pendingInsulin: 0, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration + let dose = glucose.recommendedBolus( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + pendingInsulin: 0, + maxBolus: maxBolus ) XCTAssertEqual(0.0, dose.amount) @@ -781,14 +734,16 @@ class RecommendBolusTests: XCTestCase { func testNoInputGlucose() { - let dose = DoseMath.recommendBolusFromPredictedGlucose([], - maxBolus: 4, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule, + let glucose: [GlucoseValue] = [] + + let dose = glucose.recommendedBolus( + to: glucoseTargetRange, + suspendThreshold: minimumBGGuard.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, pendingInsulin: 0, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration) + maxBolus: maxBolus + ) XCTAssertEqual(0, dose.amount) } diff --git a/DoseMathTests/Fixtures/recommend_temp_basal_dropping_then_rising.json b/DoseMathTests/Fixtures/recommend_temp_basal_dropping_then_rising.json index 9643057fc0..266f5ace6f 100644 --- a/DoseMathTests/Fixtures/recommend_temp_basal_dropping_then_rising.json +++ b/DoseMathTests/Fixtures/recommend_temp_basal_dropping_then_rising.json @@ -1,7 +1,7 @@ [ {"date": "2015-07-19T18:00:00", "amount": 90}, - {"date": "2015-07-19T18:30:00", "amount": 80}, - {"date": "2015-07-19T19:00:00", "amount": 100}, - {"date": "2015-07-19T19:30:00", "amount": 160}, - {"date": "2015-07-19T20:00:00", "amount": 200} + {"date": "2015-07-19T19:00:00", "amount": 80}, + {"date": "2015-07-19T20:00:00", "amount": 100}, + {"date": "2015-07-19T21:00:00", "amount": 160}, + {"date": "2015-07-19T22:00:00", "amount": 200} ] diff --git a/DoseMathTests/Fixtures/recommend_temp_basal_flat_and_high.json b/DoseMathTests/Fixtures/recommend_temp_basal_flat_and_high.json index 92c0afadb0..9d80a4c35f 100644 --- a/DoseMathTests/Fixtures/recommend_temp_basal_flat_and_high.json +++ b/DoseMathTests/Fixtures/recommend_temp_basal_flat_and_high.json @@ -1,4 +1,4 @@ [ {"date": "2015-07-19T18:00:00", "amount": 200}, - {"date": "2015-07-19T18:30:00", "amount": 200}, -] \ No newline at end of file + {"date": "2015-07-19T22:00:00", "amount": 200}, +] diff --git a/DoseMathTests/Fixtures/recommend_temp_basal_high_and_falling.json b/DoseMathTests/Fixtures/recommend_temp_basal_high_and_falling.json index 027174487e..d72d67de0e 100644 --- a/DoseMathTests/Fixtures/recommend_temp_basal_high_and_falling.json +++ b/DoseMathTests/Fixtures/recommend_temp_basal_high_and_falling.json @@ -1,7 +1,7 @@ [ {"date": "2015-07-19T18:00:00", "amount": 240}, - {"date": "2015-07-19T18:30:00", "amount": 220}, - {"date": "2015-07-19T19:00:00", "amount": 200}, - {"date": "2015-07-19T19:30:00", "amount": 160}, - {"date": "2015-07-19T20:00:00", "amount": 124} -] \ No newline at end of file + {"date": "2015-07-19T19:00:00", "amount": 220}, + {"date": "2015-07-19T20:00:00", "amount": 200}, + {"date": "2015-07-19T21:00:00", "amount": 160}, + {"date": "2015-07-19T22:00:00", "amount": 124} +] diff --git a/DoseMathTests/Fixtures/recommend_temp_basal_high_and_rising.json b/DoseMathTests/Fixtures/recommend_temp_basal_high_and_rising.json index 0e9b4e2dfc..bde5eeb5bb 100644 --- a/DoseMathTests/Fixtures/recommend_temp_basal_high_and_rising.json +++ b/DoseMathTests/Fixtures/recommend_temp_basal_high_and_rising.json @@ -1,7 +1,7 @@ [ {"date": "2015-07-19T18:00:00", "amount": 140}, - {"date": "2015-07-19T18:30:00", "amount": 150}, - {"date": "2015-07-19T19:00:00", "amount": 160}, - {"date": "2015-07-19T19:30:00", "amount": 170}, - {"date": "2015-07-19T20:00:00", "amount": 180} -] \ No newline at end of file + {"date": "2015-07-19T19:00:00", "amount": 150}, + {"date": "2015-07-19T20:00:00", "amount": 160}, + {"date": "2015-07-19T21:00:00", "amount": 170}, + {"date": "2015-07-19T22:00:00", "amount": 180} +] diff --git a/DoseMathTests/Fixtures/recommend_temp_basal_in_range_and_rising.json b/DoseMathTests/Fixtures/recommend_temp_basal_in_range_and_rising.json index 23bfe8e576..d3ef78c894 100644 --- a/DoseMathTests/Fixtures/recommend_temp_basal_in_range_and_rising.json +++ b/DoseMathTests/Fixtures/recommend_temp_basal_in_range_and_rising.json @@ -1,7 +1,7 @@ [ {"date": "2015-07-19T18:00:00", "amount": 90}, - {"date": "2015-07-19T18:30:00", "amount": 100}, - {"date": "2015-07-19T19:00:00", "amount": 110}, - {"date": "2015-07-19T19:30:00", "amount": 120}, - {"date": "2015-07-19T20:00:00", "amount": 125} -] \ No newline at end of file + {"date": "2015-07-19T19:00:00", "amount": 100}, + {"date": "2015-07-19T20:00:00", "amount": 110}, + {"date": "2015-07-19T21:00:00", "amount": 120}, + {"date": "2015-07-19T22:00:00", "amount": 125} +] diff --git a/DoseMathTests/Fixtures/recommend_temp_basal_start_low_end_high.json b/DoseMathTests/Fixtures/recommend_temp_basal_start_low_end_high.json index 65c92e7796..c843ade393 100644 --- a/DoseMathTests/Fixtures/recommend_temp_basal_start_low_end_high.json +++ b/DoseMathTests/Fixtures/recommend_temp_basal_start_low_end_high.json @@ -1,7 +1,7 @@ [ {"date": "2015-07-19T18:00:00", "amount": 60}, - {"date": "2015-07-19T18:30:00", "amount": 80}, - {"date": "2015-07-19T19:00:00", "amount": 120}, - {"date": "2015-07-19T19:30:00", "amount": 160}, - {"date": "2015-07-19T20:00:00", "amount": 200} -] \ No newline at end of file + {"date": "2015-07-19T19:00:00", "amount": 80}, + {"date": "2015-07-19T20:00:00", "amount": 120}, + {"date": "2015-07-19T21:00:00", "amount": 160}, + {"date": "2015-07-19T22:00:00", "amount": 200} +] diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 645bc833a5..09393023ef 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -87,6 +87,7 @@ 438A95A81D8B9B24009D12E1 /* xDripG5.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 438A95A71D8B9B24009D12E1 /* xDripG5.framework */; }; 438D42F91D7C88BC003244B0 /* PredictionInputEffect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 438D42F81D7C88BC003244B0 /* PredictionInputEffect.swift */; }; 438D42FB1D7D11A4003244B0 /* PredictionInputEffectTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 438D42FA1D7D11A4003244B0 /* PredictionInputEffectTableViewCell.swift */; }; + 43947D731F529FAA00A07D31 /* GlucoseRangeSchedule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43C513181E864C4E001547C7 /* GlucoseRangeSchedule.swift */; }; 439897371CD2F80600223065 /* AnalyticsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439897361CD2F80600223065 /* AnalyticsManager.swift */; }; 4398973B1CD2FC2000223065 /* NSDateFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4398973A1CD2FC2000223065 /* NSDateFormatter.swift */; }; 439BED2A1E76093C00B0AED5 /* CGMManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 439BED291E76093C00B0AED5 /* CGMManager.swift */; }; @@ -1281,7 +1282,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0730; - LastUpgradeCheck = 0820; + LastUpgradeCheck = 0820; ORGANIZATIONNAME = "LoopKit Authors"; TargetAttributes = { 43776F8B1B8022E90074EA36 = { @@ -1502,7 +1503,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "# /usr/local/bin/carthage build --platform \"$PLATFORM_NAME\" \"$SRCROOT\"\n# Only run this script if we're not using a workspace or installing\nif [ ! -d $PROJECT_DIR/Loop.xcworkspace ] || [ \"$ACTION\" = \"install\" ]; then\n /usr/local/bin/carthage copy-frameworks\nfi"; + shellScript = "# /usr/local/bin/carthage build --platform \"$PLATFORM_NAME\" \"$SRCROOT\"\n# Only run this script if we're not using a workspace or installing\nif [ ! -d $PROJECT_DIR/Loop.xcworkspace ] || [ \"$ACTION\" = \"install\" ]; then\n /usr/local/bin/carthage copy-frameworks\nfi"; }; /* End PBXShellScriptBuildPhase section */ @@ -1663,6 +1664,7 @@ buildActionMask = 2147483647; files = ( C17824A11E19E8C200D9D25C /* GlucoseThreshold.swift in Sources */, + 43947D731F529FAA00A07D31 /* GlucoseRangeSchedule.swift in Sources */, 43E2D8DC1D20C049004DA55F /* DoseMath.swift in Sources */, 43E2D8DB1D20C03B004DA55F /* NSTimeInterval.swift in Sources */, 43E2D8D41D20BF42004DA55F /* DoseMathTests.swift in Sources */, @@ -1842,7 +1844,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Developer: loudnate@gmail.com (XZN842LDLT)"; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 39; + CURRENT_PROJECT_VERSION = 39; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -1870,7 +1872,7 @@ ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 3.0.1; + SWIFT_VERSION = 3.0.1; TARGETED_DEVICE_FAMILY = "1,2"; WARNING_CFLAGS = "-Wall"; }; @@ -1900,7 +1902,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; CODE_SIGN_IDENTITY = "iPhone Developer: loudnate@gmail.com (XZN842LDLT)"; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 39; + CURRENT_PROJECT_VERSION = 39; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -1921,7 +1923,7 @@ MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; - SWIFT_VERSION = 3.0.1; + SWIFT_VERSION = 3.0.1; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; WARNING_CFLAGS = "-Wall"; @@ -1935,7 +1937,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Loop/Loop.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Loop/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; "OTHER_SWIFT_FLAGS[sdk=iphonesimulator*]" = "-D IOS_SIMULATOR"; @@ -1952,7 +1954,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_ENTITLEMENTS = Loop/Loop.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = Loop/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER)"; @@ -1967,7 +1969,7 @@ ASSETCATALOG_COMPILER_COMPLICATION_NAME = Complication; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = "WatchApp Extension/Info.plist"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).watchkitapp.watchkitextension"; @@ -1985,7 +1987,7 @@ ASSETCATALOG_COMPILER_COMPLICATION_NAME = Complication; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = "WatchApp Extension/Info.plist"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).watchkitapp.watchkitextension"; @@ -2004,7 +2006,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = ""; IBSC_MODULE = WatchApp_Extension; INFOPLIST_FILE = WatchApp/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; @@ -2024,7 +2026,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=watchos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = ""; IBSC_MODULE = WatchApp_Extension; INFOPLIST_FILE = WatchApp/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; @@ -2042,7 +2044,7 @@ buildSettings = { CLANG_ANALYZER_NONNULL = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = DoseMathTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @loader_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.loudnate.DoseMathTests; @@ -2055,7 +2057,7 @@ buildSettings = { CLANG_ANALYZER_NONNULL = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = DoseMathTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @loader_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.loudnate.DoseMathTests; @@ -2069,7 +2071,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NONNULL = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = LoopTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.loudnate.LoopTests; @@ -2084,7 +2086,7 @@ BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NONNULL = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = LoopTests/Info.plist; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @loader_path/Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = com.loudnate.LoopTests; @@ -2102,7 +2104,7 @@ CODE_SIGN_ENTITLEMENTS = "Loop Status Extension/Loop Status Extension.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = "Loop Status Extension/Info.plist"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).statuswidget"; @@ -2121,7 +2123,7 @@ CODE_SIGN_ENTITLEMENTS = "Loop Status Extension/Loop Status Extension.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - DEVELOPMENT_TEAM = ""; + DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = "Loop Status Extension/Info.plist"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks"; PRODUCT_BUNDLE_IDENTIFIER = "$(MAIN_APP_BUNDLE_IDENTIFIER).statuswidget"; @@ -2139,11 +2141,11 @@ CLANG_WARN_SUSPICIOUS_MOVES = YES; CODE_SIGN_IDENTITY = ""; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; - CURRENT_PROJECT_VERSION = 39; + CURRENT_PROJECT_VERSION = 39; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 39; + DYLIB_CURRENT_VERSION = 39; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = LoopUI/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -2166,11 +2168,11 @@ CLANG_WARN_SUSPICIOUS_MOVES = YES; CODE_SIGN_IDENTITY = ""; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = ""; - CURRENT_PROJECT_VERSION = 39; + CURRENT_PROJECT_VERSION = 39; DEFINES_MODULE = YES; DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; - DYLIB_CURRENT_VERSION = 39; + DYLIB_CURRENT_VERSION = 39; DYLIB_INSTALL_NAME_BASE = "@rpath"; INFOPLIST_FILE = LoopUI/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; diff --git a/Loop/Extensions/GlucoseRangeSchedule.swift b/Loop/Extensions/GlucoseRangeSchedule.swift index 6c0805735f..037b375113 100644 --- a/Loop/Extensions/GlucoseRangeSchedule.swift +++ b/Loop/Extensions/GlucoseRangeSchedule.swift @@ -6,6 +6,7 @@ // import LoopKit +import HealthKit extension GlucoseRangeSchedule { @@ -21,4 +22,15 @@ extension GlucoseRangeSchedule { return override.isActive() } + + func minQuantity(at date: Date) -> HKQuantity { + return HKQuantity(unit: unit, doubleValue: value(at: date).minValue) + } +} + + +extension DoubleRange { + var averageValue: Double { + return (maxValue + minValue) / 2 + } } diff --git a/Loop/Managers/DoseMath.swift b/Loop/Managers/DoseMath.swift index aee2fd3ce2..e34380e93a 100644 --- a/Loop/Managers/DoseMath.swift +++ b/Loop/Managers/DoseMath.swift @@ -13,186 +13,436 @@ import InsulinKit import LoopKit -enum DoseMath { - /// The allowed precision - static let basalStrokes: Double = 40 - - /** - Calculates the necessary temporary basal rate to transform a glucose value to a target. - - This assumes a constant insulin sensitivity, independent of current glucose or insulin-on-board. - - - parameter currentGlucose: The current glucose - - parameter targetGlucose: The desired glucose - - parameter insulinSensitivity: The insulin sensitivity, in Units of insulin per glucose-unit - - parameter currentBasalRate: The normally-scheduled basal rate - - parameter maxBasalRate: The maximum basal rate, used to constrain the output - - parameter duration: The temporary duration to run the basal - - - returns: The determined basal rate, in Units/hour - */ - private static func calculateTempBasalRateForGlucose(_ currentGlucose: HKQuantity, toTargetGlucose targetGlucose: HKQuantity, insulinSensitivity: HKQuantity, currentBasalRate: Double, maxBasalRate: Double, duration: TimeInterval) -> Double { - let unit = HKUnit.milligramsPerDeciliter() - let doseUnits = (currentGlucose.doubleValue(for: unit) - targetGlucose.doubleValue(for: unit)) / insulinSensitivity.doubleValue(for: unit) - - let rate = min(maxBasalRate, max(0, doseUnits / (duration / TimeInterval(hours: 1)) + currentBasalRate)) - - return round(rate * basalStrokes) / basalStrokes +private enum InsulinCorrection { + case inRange + case aboveRange(min: GlucoseValue, correcting: GlucoseValue, minTarget: HKQuantity, units: Double) + case entirelyBelowRange(correcting: GlucoseValue, minTarget: HKQuantity, units: Double) + case suspend(min: GlucoseValue) +} + + +extension InsulinCorrection { + /// The delivery units for the correction + private var units: Double { + switch self { + case .aboveRange(min: _, correcting: _, minTarget: _, units: let units): + return units + case .entirelyBelowRange(correcting: _, minTarget: _, units: let units): + return units + case .inRange, .suspend: + return 0 + } + } + + /// Determines the temp basal over `duration` needed to perform the correction. + /// + /// - Parameters: + /// - scheduledBasalRate: The scheduled basal rate at the time the correction is delivered + /// - maxBasalRate: The maximum allowed basal rate + /// - 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, + duration: TimeInterval, + minimumProgrammableIncrementPerUnit: Double + ) -> TempBasalRecommendation { + var rate = units / (duration / TimeInterval(hours: 1)) // units/hour + switch self { + case .aboveRange, .inRange, .entirelyBelowRange: + rate += scheduledBasalRate + case .suspend: + break + } + + rate = Swift.min(maxBasalRate, Swift.max(0, rate)) + rate = round(rate * minimumProgrammableIncrementPerUnit) / minimumProgrammableIncrementPerUnit + + return TempBasalRecommendation( + unitsPerHour: rate, + duration: duration + ) + } + + private var bolusRecommendationNotice: BolusRecommendationNotice? { + switch self { + case .suspend(min: let minimum): + return .glucoseBelowSuspendThreshold(minGlucose: minimum) + case .inRange, .entirelyBelowRange: + return nil + case .aboveRange(min: let min, correcting: _, minTarget: let target, units: let units): + if units > 0 && min.quantity < target { + return .predictedGlucoseBelowTarget(minGlucose: min) + } else { + return nil + } + } + } + + /// Determins the bolus needed to perform the correction + /// + /// - 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 + /// - minimumProgrammableIncrementPerUnit: The smallest fraction of a unit supported in bolus delivery + /// - Returns: A bolus recommendation + fileprivate func asBolus( + pendingInsulin: Double, + maxBolus: Double, + minimumProgrammableIncrementPerUnit: Double + ) -> BolusRecommendation { + var units = self.units - pendingInsulin + units = Swift.min(maxBolus, Swift.max(0, units)) + units = round(units * minimumProgrammableIncrementPerUnit) / minimumProgrammableIncrementPerUnit + + return BolusRecommendation( + amount: units, + pendingInsulin: pendingInsulin, + notice: bolusRecommendationNotice + ) + } +} + + +struct TempBasalRecommendation { + let unitsPerHour: Double + let duration: TimeInterval + + /// A special command which cancels any existing temp basals + static var cancel: TempBasalRecommendation { + return self.init(unitsPerHour: 0, duration: 0) } +} - /** - Recommends a temporary basal rate to conform a glucose prediction timeline to a target range +extension TempBasalRecommendation: Equatable { + static func ==(lhs: TempBasalRecommendation, rhs: TempBasalRecommendation) -> Bool { + return lhs.unitsPerHour == rhs.unitsPerHour && lhs.duration == rhs.duration + } +} - Returns nil if the normal scheduled basal, or active temporary basal, is sufficient. - - parameter glucose: The ascending timeline of predicted glucose values - - parameter date: The date at which the temporary basal rate would start. Defaults to the current date. - - parameter lastTempBasal: The last-set temporary basal - - parameter maxBasalRate: The maximum basal rate, in Units/hour, used to constrain the output - - parameter glucoseTargetRange: The schedule of target glucose ranges - - parameter insulinSensitivity: The schedule of insulin sensitivities, in Units of insulin per glucose-unit - - parameter basalRateSchedule: The schedule of basal rates - - parameter minimumBGGuard: Loop will always 0 temp if minBG is less than or equal to this value. +extension TempBasalRecommendation { + /// Equates the recommended rate with another rate + /// + /// - Parameter unitsPerHour: The rate to compare + /// - Returns: Whether the rates are equal within Double precision + private func matchesRate(_ unitsPerHour: Double) -> Bool { + return abs(self.unitsPerHour - unitsPerHour) < .ulpOfOne + } - - returns: The recommended basal rate and duration - */ - static func recommendTempBasalFromPredictedGlucose(_ glucose: [GlucoseValue], - atDate date: Date = Date(), + /// Determines whether the recommendation is necessary given the current state of the pump + /// + /// - Parameters: + /// - date: The date the recommendation would be delivered + /// - scheduledBasalRate: The scheduled basal rate at `date` + /// - lastTempBasal: The previously set temp basal + /// - continuationInterval: The duration of time before an ongoing temp basal should be continued with a new command + /// - Returns: A temp basal recommendation + func ifNecessary( + at date: Date, + scheduledBasalRate: Double, lastTempBasal: DoseEntry?, - maxBasalRate: Double, - glucoseTargetRange: GlucoseRangeSchedule, - insulinSensitivity: InsulinSensitivitySchedule, - basalRateSchedule: BasalRateSchedule, - minimumBGGuard: GlucoseThreshold, - insulinActionDuration: TimeInterval - ) -> (rate: Double, duration: TimeInterval)? { - guard glucose.count > 1 else { + continuationInterval: TimeInterval + ) -> TempBasalRecommendation? { + // Adjust behavior for the currently active temp basal + if let lastTempBasal = lastTempBasal, + lastTempBasal.type == .tempBasal, + lastTempBasal.endDate > date + { + /// If the last temp basal has the same rate, and has more than `continuationInterval` of time remaining, don't set a new temp + if matchesRate(lastTempBasal.unitsPerHour), + lastTempBasal.endDate.timeIntervalSince(date) > continuationInterval { + return nil + } else if matchesRate(scheduledBasalRate) { + // If our new temp matches the scheduled rate, cancel the current temp + return .cancel + } + } else if matchesRate(scheduledBasalRate) { + // If we recommend the in-progress scheduled basal rate, do nothing return nil } - let eventualGlucose = glucose.filter { $0.startDate <= date.addingTimeInterval(insulinActionDuration) }.last! - - let minGlucose = glucose.min { $0.quantity < $1.quantity }! - - let eventualGlucoseTargets = glucoseTargetRange.value(at: eventualGlucose.startDate) - let minGlucoseTargets = glucoseTargetRange.value(at: minGlucose.startDate) - let currentSensitivity = insulinSensitivity.quantity(at: date) - let currentScheduledBasalRate = basalRateSchedule.value(at: date) - - var rate: Double? - var duration = TimeInterval(minutes: 30) - - if minGlucose.quantity <= minimumBGGuard.quantity { - rate = 0 - } else if minGlucose.quantity.doubleValue(for: glucoseTargetRange.unit) < minGlucoseTargets.minValue && eventualGlucose.quantity.doubleValue(for: glucoseTargetRange.unit) <= eventualGlucoseTargets.minValue { - let targetGlucose = HKQuantity(unit: glucoseTargetRange.unit, doubleValue: (minGlucoseTargets.minValue + minGlucoseTargets.maxValue) / 2) - rate = calculateTempBasalRateForGlucose(minGlucose.quantity, - toTargetGlucose: targetGlucose, - insulinSensitivity: currentSensitivity, - currentBasalRate: currentScheduledBasalRate, - maxBasalRate: maxBasalRate, - duration: duration - ) - } else if eventualGlucose.quantity.doubleValue(for: glucoseTargetRange.unit) > eventualGlucoseTargets.maxValue { - var adjustedMaxBasalRate = maxBasalRate - if minGlucose.quantity.doubleValue(for: glucoseTargetRange.unit) < minGlucoseTargets.minValue { - adjustedMaxBasalRate = currentScheduledBasalRate + return self + } +} + + +/// Computes a total insulin amount necessary to correct a glucose differential at a given sensitivity +/// +/// - Parameters: +/// - fromValue: The starting glucose value +/// - toValue: The desired glucose value +/// - effectedSensitivity: The sensitivity, in glucose-per-insulin-unit +/// - Returns: The insulin correction in units +private func insulinCorrectionUnits(fromValue: Double, toValue: Double, effectedSensitivity: Double) -> Double? { + guard effectedSensitivity > 0 else { + return nil + } + + let glucoseCorrection = fromValue - toValue + + return glucoseCorrection / effectedSensitivity +} + +/// Computes a target glucose value for a correction, at a given time during the insulin effect duration +/// +/// - Parameters: +/// - percentEffectDuration: The percent of time elapsed of the insulin effect duration +/// - minValue: The minimum (starting) target value +/// - maxValue: The maximum (eventual) target value +/// - Returns: A target value somewhere between the minimum and maximum +private func targetGlucoseValue(percentEffectDuration: Double, minValue: Double, maxValue: Double) -> Double { + // The inflection point in time: before it we use minValue, after it we linearly blend from minValue to maxValue + let useMinValueUntilPercent = 0.5 + + guard percentEffectDuration > useMinValueUntilPercent else { + return minValue + } + + guard percentEffectDuration < 1 else { + return maxValue + } + + let slope = (maxValue - minValue) / (1 - useMinValueUntilPercent) + return minValue + slope * (percentEffectDuration - useMinValueUntilPercent) +} + + +extension Collection where Iterator.Element == GlucoseValue { + + /// For a collection of glucose prediction, determine the least amount of insulin delivered at + /// `date` to correct the predicted glucose to the middle of `correctionRange` at the time of prediction. + /// + /// - Parameters: + /// - correctionRange: The schedule of glucose values used for correction + /// - date: The date the insulin correction is delivered + /// - suspendThreshold: The glucose value below which only suspension is returned + /// - sensitivity: The insulin sensitivity at the time of delivery + /// - model: The insulin effect model + /// - Returns: A correction value in units, if one could be calculated + private func insulinCorrection( + to correctionRange: GlucoseRangeSchedule, + at date: Date, + suspendThreshold: HKQuantity, + sensitivity: HKQuantity, + model: InsulinModel + ) -> InsulinCorrection? { + var minGlucose: GlucoseValue? + var eventualGlucose: GlucoseValue? + var correctingGlucose: GlucoseValue? + var minCorrectionUnits: Double? + + // Only consider predictions within the model's effect duration + let validDateRange = DateInterval(start: date, duration: model.effectDuration) + + let unit = correctionRange.unit + let sensitivityValue = sensitivity.doubleValue(for: unit) + let suspendThresholdValue = suspendThreshold.doubleValue(for: unit) + + // For each prediction above target, determine the amount of insulin necessary to correct glucose based on the modeled effectiveness of the insulin at that time + for prediction in self { + guard validDateRange.contains(prediction.startDate) else { + continue } - let targetGlucose = HKQuantity(unit: glucoseTargetRange.unit, doubleValue: (eventualGlucoseTargets.minValue + eventualGlucoseTargets.maxValue) / 2) - rate = calculateTempBasalRateForGlucose(eventualGlucose.quantity, - toTargetGlucose: targetGlucose, - insulinSensitivity: currentSensitivity, - currentBasalRate: currentScheduledBasalRate, - maxBasalRate: adjustedMaxBasalRate, - duration: duration - ) - } + // If any predicted value is below the suspend threshold, return immediately + guard prediction.quantity >= suspendThreshold else { + return .suspend(min: prediction) + } - if let determinedRate = rate, determinedRate == currentScheduledBasalRate { - rate = nil - } + // Update range statistics + if minGlucose == nil || prediction.quantity < minGlucose!.quantity { + minGlucose = prediction + } + eventualGlucose = prediction - if let lastTempBasal = lastTempBasal, lastTempBasal.unit == .unitsPerHour && lastTempBasal.endDate > date { - if let determinedRate = rate { - // Ignore the dose if the current dose is the same rate and has more than 10 minutes remaining - if determinedRate == lastTempBasal.unitsPerHour && lastTempBasal.endDate.timeIntervalSince(date) > TimeInterval(minutes: 11) { - rate = nil - } - } else { - // If we prefer to not have a dose, cancel the one in progress - rate = 0 - duration = TimeInterval(0) + let predictedGlucoseValue = prediction.quantity.doubleValue(for: unit) + let time = prediction.startDate.timeIntervalSince(date) + + // Compute the target value as a function of time since the dose started + let targetValue = targetGlucoseValue( + percentEffectDuration: time / model.effectDuration, + minValue: suspendThresholdValue, + maxValue: correctionRange.value(at: prediction.startDate).averageValue) + + // Compute the dose required to bring this prediction to target: + // dose = (Glucose Δ) / (% effect × sensitivity) + + let percentEffected = 1 - model.percentEffectRemaining(at: time) + let effectedSensitivity = percentEffected * sensitivityValue + guard let correctionUnits = insulinCorrectionUnits( + fromValue: predictedGlucoseValue, + toValue: targetValue, + effectedSensitivity: effectedSensitivity + ), correctionUnits > 0 else { + continue + } + + // Update the correction only if we've found a new minimum + guard minCorrectionUnits == nil || correctionUnits < minCorrectionUnits! else { + continue } + + correctingGlucose = prediction + minCorrectionUnits = correctionUnits } - if let rate = rate { - return (rate: rate, duration: duration) - } else { + guard let eventual = eventualGlucose, let min = minGlucose else { return nil } - } - /** - Recommends a bolus to conform a glucose prediction timeline to a target range - - - parameter glucose: The ascending timeline of predicted glucose values - - parameter date: The date at which the bolus would apply. Defaults to the current date. - - parameter maxBolus: The maximum bolus, used to constrain the output - - parameter glucoseTargetRange: The schedule of target glucose ranges - - parameter insulinSensitivity: The schedule of insulin sensitivities, in Units of insulin per glucose-unit - - parameter basalRateSchedule: The schedule of basal rates - - parameter pendingInsulin: The amount of insulin in any issued, but not confirmed, boluses and the amount remaining from current tempBasal - - parameter minimumBGGuard: If minBG is less than or equal to this value, no recommendation will be made - - - returns: The recommended bolus - */ - static func recommendBolusFromPredictedGlucose(_ glucose: [GlucoseValue], - atDate date: Date = Date(), - maxBolus: Double, - glucoseTargetRange: GlucoseRangeSchedule, - insulinSensitivity: InsulinSensitivitySchedule, - basalRateSchedule: BasalRateSchedule, - pendingInsulin: Double, - minimumBGGuard: GlucoseThreshold, - insulinActionDuration: TimeInterval - ) -> BolusRecommendation { - guard glucose.count > 1 else { - return BolusRecommendation(amount: 0, pendingInsulin: pendingInsulin) - } + // Choose either the minimum glucose or eventual glocse as the correction delta + let minGlucoseTargets = correctionRange.value(at: min.startDate) + let eventualGlucoseTargets = correctionRange.value(at: eventual.startDate) - let eventualGlucose = glucose.filter { $0.startDate <= date.addingTimeInterval(insulinActionDuration) }.last! + let minGlucoseValue = min.quantity.doubleValue(for: unit) + let eventualGlucoseValue = eventual.quantity.doubleValue(for: unit) - let minGlucose = glucose.min { $0.quantity < $1.quantity }! + // Treat the mininum glucose when both are below range + if minGlucoseValue < minGlucoseTargets.minValue && + eventual.quantity.doubleValue(for: unit) < eventualGlucoseTargets.minValue + { + let time = min.startDate.timeIntervalSince(date) + // For time = 0, assume a small amount effected. This will result in large (negative) unit recommendation rather than no recommendation at all. + let percentEffected = Swift.max(.ulpOfOne, 1 - model.percentEffectRemaining(at: time)) - let eventualGlucoseTargets = glucoseTargetRange.value(at: eventualGlucose.startDate) + guard let units = insulinCorrectionUnits( + fromValue: minGlucoseValue, + toValue: minGlucoseTargets.averageValue, + effectedSensitivity: sensitivityValue * percentEffected + ) else { + return nil + } - guard minGlucose.quantity >= minimumBGGuard.quantity else { - return BolusRecommendation(amount: 0, pendingInsulin: pendingInsulin, notice: .glucoseBelowMinimumGuard(minGlucose: minGlucose, unit: glucoseTargetRange.unit)) + return .entirelyBelowRange( + correcting: min, + minTarget: HKQuantity(unit: unit, doubleValue: minGlucoseTargets.minValue), + units: units + ) + } else if eventualGlucoseValue > eventualGlucoseTargets.maxValue, + let minCorrectionUnits = minCorrectionUnits, let correctingGlucose = correctingGlucose + { + return .aboveRange( + min: min, + correcting: correctingGlucose, + minTarget: HKQuantity(unit: unit, doubleValue: eventualGlucoseTargets.minValue), + units: minCorrectionUnits + ) + } else { + return .inRange } + } - let targetGlucose = eventualGlucoseTargets.maxValue - let currentSensitivity = insulinSensitivity.quantity(at: date).doubleValue(for: glucoseTargetRange.unit) + /// Recommends a temporary basal rate to conform a glucose prediction timeline to a correction range + /// + /// Returns nil if the normal scheduled basal, or active temporary basal, is sufficient. + /// + /// - Parameters: + /// - correctionRange: The schedule of correction ranges + /// - date: The date at which the temp basal would be scheduled, defaults to now + /// - suspendThreshold: A glucose value causing a recommendation of no insulin if any prediction falls below + /// - sensitivity: The schedule of insulin sensitivities + /// - model: The insulin absorption model + /// - basalRates: The schedule of basal rates + /// - maxBasalRate: The maximum allowed basal rate + /// - lastTempBasal: The previously set temp basal + /// - 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 + /// - Returns: The recommended temporary basal rate and duration + func recommendedTempBasal( + to correctionRange: GlucoseRangeSchedule, + at date: Date = Date(), + suspendThreshold: HKQuantity?, + sensitivity: InsulinSensitivitySchedule, + model: InsulinModel, + basalRates: BasalRateSchedule, + maxBasalRate: Double, + lastTempBasal: DoseEntry?, + duration: TimeInterval = .minutes(30), + minimumProgrammableIncrementPerUnit: Double = 40, + continuationInterval: TimeInterval = .minutes(11) + ) -> TempBasalRecommendation? { + let correction = self.insulinCorrection( + to: correctionRange, + at: date, + suspendThreshold: suspendThreshold ?? correctionRange.minQuantity(at: date), + sensitivity: sensitivity.quantity(at: date), + model: model + ) - let doseUnits = (eventualGlucose.quantity.doubleValue(for: glucoseTargetRange.unit) - targetGlucose) / currentSensitivity + let scheduledBasalRate = basalRates.value(at: date) + var maxBasalRate = maxBasalRate - // Round to pump accuracy increments - let roundedAmount = round(max(0, (doseUnits - pendingInsulin)) * 40) / 40 - - // Cap at max bolus amount - let cappedAmount = min(maxBolus, max(0, roundedAmount)) + // TODO: Allow `highBasalThreshold` to be a configurable setting + if case .aboveRange(min: let min, correcting: _, minTarget: let highBasalThreshold, units: _)? = correction, + min.quantity < highBasalThreshold + { + maxBasalRate = scheduledBasalRate + } - let notice: BolusRecommendationNotice? - if cappedAmount > 0 && minGlucose.quantity.doubleValue(for: glucoseTargetRange.unit) < eventualGlucoseTargets.minValue { - if minGlucose.startDate == glucose[0].startDate { - notice = .currentGlucoseBelowTarget(glucose: minGlucose, unit: glucoseTargetRange.unit) - } else { - notice = .predictedGlucoseBelowTarget(minGlucose: minGlucose, unit: glucoseTargetRange.unit) - } - } else { - notice = nil + let temp = correction?.asTempBasal( + scheduledBasalRate: scheduledBasalRate, + maxBasalRate: maxBasalRate, + duration: duration, + minimumProgrammableIncrementPerUnit: minimumProgrammableIncrementPerUnit + ) + + return temp?.ifNecessary( + at: date, + scheduledBasalRate: scheduledBasalRate, + lastTempBasal: lastTempBasal, + continuationInterval: continuationInterval + ) + } + + /// Recommends a bolus to conform a glucose prediction timeline to a correction range + /// + /// - Parameters: + /// - correctionRange: The schedule of correction ranges + /// - date: The date at which the bolus would apply, defaults to now + /// - suspendThreshold: A glucose value causing a recommendation of no insulin if any prediction falls below + /// - sensitivity: The schedule of insulin sensitivities + /// - 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 + /// - minimumProgrammableIncrementPerUnit: The smallest fraction of a unit supported in bolus delivery + /// - Returns: A bolus recommendation + func recommendedBolus( + to correctionRange: GlucoseRangeSchedule, + at date: Date = Date(), + suspendThreshold: HKQuantity?, + sensitivity: InsulinSensitivitySchedule, + model: InsulinModel, + pendingInsulin: Double, + maxBolus: Double, + minimumProgrammableIncrementPerUnit: Double = 40 + ) -> BolusRecommendation { + guard let correction = self.insulinCorrection( + to: correctionRange, + at: date, + suspendThreshold: suspendThreshold ?? correctionRange.minQuantity(at: date), + sensitivity: sensitivity.quantity(at: date), + model: model + ) else { + return BolusRecommendation(amount: 0, pendingInsulin: pendingInsulin) + } + + var bolus = correction.asBolus( + pendingInsulin: pendingInsulin, + maxBolus: maxBolus, + minimumProgrammableIncrementPerUnit: minimumProgrammableIncrementPerUnit + ) + + // Handle the "current BG below target" notice here + // TODO: Don't assume in the future that the first item in the array is current BG + if case .predictedGlucoseBelowTarget? = bolus.notice, + let first = first, first.quantity < correctionRange.minQuantity(at: first.startDate) + { + bolus.notice = .currentGlucoseBelowTarget(glucose: first) } - - return BolusRecommendation(amount: cappedAmount, pendingInsulin: pendingInsulin, notice: notice) + + return bolus } } diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 1efb3fbafc..7763e056a2 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -856,37 +856,33 @@ final class LoopDataManager { let predictedGlucose = try predictGlucose(using: settings.enabledEffects) self.predictedGlucose = predictedGlucose - guard let minimumBGGuard = settings.minimumBGGuard else { - throw LoopError.configurationError("Minimum BG Guard") - } - guard let maxBasal = settings.maximumBasalRatePerHour, let glucoseTargetRange = settings.glucoseTargetRangeSchedule, let insulinSensitivity = insulinSensitivitySchedule, let basalRates = basalRateSchedule, - let insulinActionDuration = insulinModelSettings?.model.effectDuration + let model = insulinModelSettings?.model else { throw LoopError.configurationError("Check settings") } guard lastRequestedBolus == nil, // Don't recommend changes if a bolus was just set - let tempBasal = DoseMath.recommendTempBasalFromPredictedGlucose(predictedGlucose, - lastTempBasal: lastTempBasal, + let tempBasal = predictedGlucose.recommendedTempBasal( + to: glucoseTargetRange, + suspendThreshold: settings.minimumBGGuard?.quantity, + sensitivity: insulinSensitivity, + model: model, + basalRates: basalRates, maxBasalRate: maxBasal, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivity, - basalRateSchedule: basalRates, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration + lastTempBasal: lastTempBasal ) else { recommendedTempBasal = nil return } - recommendedTempBasal = (recommendedDate: Date(), rate: tempBasal.rate, duration: tempBasal.duration) + recommendedTempBasal = (recommendedDate: Date(), rate: tempBasal.unitsPerHour, duration: tempBasal.duration) } /// - Returns: A bolus recommendation from the current data @@ -897,17 +893,12 @@ final class LoopDataManager { fileprivate func recommendBolus() throws -> BolusRecommendation { dispatchPrecondition(condition: .onQueue(dataAccessQueue)) - guard let minimumBGGuard = settings.minimumBGGuard else { - throw LoopError.configurationError("Minimum BG Guard") - } - guard let predictedGlucose = predictedGlucose, let maxBolus = settings.maximumBolus, let glucoseTargetRange = settings.glucoseTargetRangeSchedule, let insulinSensitivity = insulinSensitivitySchedule, - let basalRates = basalRateSchedule, - let insulinActionDuration = insulinModelSettings?.model.effectDuration + let model = insulinModelSettings?.model else { throw LoopError.configurationError("Check Settings") } @@ -922,14 +913,13 @@ final class LoopDataManager { let pendingInsulin = try self.getPendingInsulin() - let recommendation = DoseMath.recommendBolusFromPredictedGlucose(predictedGlucose, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivity, - basalRateSchedule: basalRates, + let recommendation = predictedGlucose.recommendedBolus( + to: glucoseTargetRange, + suspendThreshold: settings.minimumBGGuard?.quantity, + sensitivity: insulinSensitivity, + model: model, pendingInsulin: pendingInsulin, - minimumBGGuard: minimumBGGuard, - insulinActionDuration: insulinActionDuration + maxBolus: maxBolus ) return recommendation diff --git a/Loop/Models/BolusRecommendation.swift b/Loop/Models/BolusRecommendation.swift index e09eadde6c..2f44398f05 100644 --- a/Loop/Models/BolusRecommendation.swift +++ b/Loop/Models/BolusRecommendation.swift @@ -11,23 +11,25 @@ import LoopKit import HealthKit -enum BolusRecommendationNotice: CustomStringConvertible, Equatable { - case glucoseBelowMinimumGuard(minGlucose: GlucoseValue, unit: HKUnit) - case currentGlucoseBelowTarget(glucose: GlucoseValue, unit: HKUnit) - case predictedGlucoseBelowTarget(minGlucose: GlucoseValue, unit: HKUnit) +enum BolusRecommendationNotice { + case glucoseBelowSuspendThreshold(minGlucose: GlucoseValue) + case currentGlucoseBelowTarget(glucose: GlucoseValue) + case predictedGlucoseBelowTarget(minGlucose: GlucoseValue) +} - public var description: String { +extension BolusRecommendationNotice { + public func description(using unit: HKUnit) -> String { switch self { - case .glucoseBelowMinimumGuard(let minGlucose, let unit): + case .glucoseBelowSuspendThreshold(minGlucose: let minGlucose): let glucoseFormatter = NumberFormatter.glucoseFormatter(for: unit) let bgStr = glucoseFormatter.describingGlucose(minGlucose.quantity, for: unit)! - return String(format: NSLocalizedString("Predicted glucose of %1$@ is below your minimum BG Guard setting.", comment: "Notice message when recommending bolus when BG is below minimum BG guard. (1: glucose value)"), bgStr) - case .currentGlucoseBelowTarget(let glucose, let unit): + return String(format: NSLocalizedString("Predicted glucose of %1$@ is below your suspend threshold setting.", comment: "Notice message when recommending bolus when BG is below minimum BG guard. (1: glucose value)"), bgStr) + case .currentGlucoseBelowTarget(glucose: let glucose): let glucoseFormatter = NumberFormatter.glucoseFormatter(for: unit) let bgStr = glucoseFormatter.describingGlucose(glucose.quantity, for: unit)! - return String(format: NSLocalizedString("Current glucose of %1$@ is below target range.", comment: "Message when offering bolus recommendation even though bg is below range. (1: glucose value)"), bgStr) - case .predictedGlucoseBelowTarget(let minGlucose, let unit): + return String(format: NSLocalizedString("Current glucose of %1$@ is below correction range.", comment: "Message when offering bolus recommendation even though bg is below range. (1: glucose value)"), bgStr) + case .predictedGlucoseBelowTarget(minGlucose: let minGlucose): let timeFormatter = DateFormatter() timeFormatter.dateStyle = .none timeFormatter.timeStyle = .short @@ -39,23 +41,23 @@ enum BolusRecommendationNotice: CustomStringConvertible, Equatable { } } +} +extension BolusRecommendationNotice: Equatable { static func ==(lhs: BolusRecommendationNotice, rhs: BolusRecommendationNotice) -> Bool { switch (lhs, rhs) { - case (.glucoseBelowMinimumGuard, .glucoseBelowMinimumGuard): + case (.glucoseBelowSuspendThreshold, .glucoseBelowSuspendThreshold): return true case (.currentGlucoseBelowTarget, .currentGlucoseBelowTarget): return true - case (let .predictedGlucoseBelowTarget(minGlucose1, unit1), let .predictedGlucoseBelowTarget(minGlucose2, unit2)): + case (let .predictedGlucoseBelowTarget(minGlucose1), let .predictedGlucoseBelowTarget(minGlucose2)): // GlucoseValue is not equatable return minGlucose1.startDate == minGlucose2.startDate && minGlucose1.endDate == minGlucose2.endDate && - minGlucose1.quantity == minGlucose2.quantity && - unit1 == unit2 - + minGlucose1.quantity == minGlucose2.quantity default: return false } @@ -66,7 +68,7 @@ enum BolusRecommendationNotice: CustomStringConvertible, Equatable { struct BolusRecommendation { let amount: Double let pendingInsulin: Double - let notice: BolusRecommendationNotice? + var notice: BolusRecommendationNotice? init(amount: Double, pendingInsulin: Double, notice: BolusRecommendationNotice? = nil) { self.amount = amount diff --git a/Loop/View Controllers/BolusViewController.swift b/Loop/View Controllers/BolusViewController.swift index 0e9a8f1e61..58d01a89c2 100644 --- a/Loop/View Controllers/BolusViewController.swift +++ b/Loop/View Controllers/BolusViewController.swift @@ -240,7 +240,7 @@ final class BolusViewController: UITableViewController, IdentifiableClass, UITex private func updateNotice() { if let notice = bolusRecommendation?.notice { - noticeLabel?.text = "⚠ " + String(describing: notice) + noticeLabel?.text = "⚠ \(notice.description(using: glucoseUnit))" } else { noticeLabel?.text = nil } diff --git a/Loop/View Controllers/SettingsTableViewController.swift b/Loop/View Controllers/SettingsTableViewController.swift index 488eed3046..f3054fbe15 100644 --- a/Loop/View Controllers/SettingsTableViewController.swift +++ b/Loop/View Controllers/SettingsTableViewController.swift @@ -316,7 +316,7 @@ final class SettingsTableViewController: UITableViewController, DailyValueSchedu configCell.detailTextLabel?.text = TapToSetString } case .glucoseTargetRange: - configCell.textLabel?.text = NSLocalizedString("Target Range", comment: "The title text for the glucose target range schedule") + configCell.textLabel?.text = NSLocalizedString("Correction Range", comment: "The title text for the glucose target range schedule") if let glucoseTargetRangeSchedule = dataManager.loopManager.settings.glucoseTargetRangeSchedule { let unit = glucoseTargetRangeSchedule.unit @@ -570,7 +570,7 @@ final class SettingsTableViewController: UITableViewController, DailyValueSchedu let scheduleVC = GlucoseRangeScheduleTableViewController() scheduleVC.delegate = self - scheduleVC.title = NSLocalizedString("Target Range", comment: "The title of the glucose target range schedule screen") + scheduleVC.title = NSLocalizedString("Correction Range", comment: "The title of the glucose target range schedule screen") if let schedule = dataManager.loopManager.settings.glucoseTargetRangeSchedule { scheduleVC.timeZone = schedule.timeZone diff --git a/Loop/it.lproj/Localizable.strings b/Loop/it.lproj/Localizable.strings index da6ad0ecf1..26ebc1abb9 100644 --- a/Loop/it.lproj/Localizable.strings +++ b/Loop/it.lproj/Localizable.strings @@ -319,7 +319,7 @@ /* The title of the glucose target range schedule screen The title text for the glucose target range schedule */ -"Target Range" = "Intervallo Glicemico"; +"Correction Range" = "Intervallo Glicemico"; /* Body of the alert describing a maximum bolus validation error. (1: The localized max bolus value) */ "The maximum bolus amount is %@ Units" = "La quantità massima di bolo è %@ Unità";