diff --git a/DoseMathTests/DoseMathTests.swift b/DoseMathTests/DoseMathTests.swift index 4a52f5b772..5c9e22d992 100644 --- a/DoseMathTests/DoseMathTests.swift +++ b/DoseMathTests/DoseMathTests.swift @@ -87,6 +87,10 @@ class RecommendTempBasalTests: XCTestCase { var insulinSensitivitySchedule: InsulinSensitivitySchedule { return InsulinSensitivitySchedule(unit: HKUnit.milligramsPerDeciliterUnit(), dailyItems: [RepeatingScheduleValue(startTime: 0.0, value: 60.0)])! } + + var minimumBGGuard: GlucoseThreshold { + return GlucoseThreshold(unit: HKUnit.milligramsPerDeciliterUnit(), value: 55) + } func testNoChange() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_no_change_glucose") @@ -97,7 +101,8 @@ class RecommendTempBasalTests: XCTestCase { maxBasalRate: maxBasalRate, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule + basalRateSchedule: basalRateSchedule, + minimumBGGuard: minimumBGGuard ) XCTAssertNil(dose) @@ -112,7 +117,8 @@ class RecommendTempBasalTests: XCTestCase { maxBasalRate: maxBasalRate, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule + basalRateSchedule: basalRateSchedule, + minimumBGGuard: minimumBGGuard ) XCTAssertNil(dose) @@ -132,7 +138,8 @@ class RecommendTempBasalTests: XCTestCase { maxBasalRate: maxBasalRate, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule + basalRateSchedule: basalRateSchedule, + minimumBGGuard: minimumBGGuard ) XCTAssertEqual(0, dose!.rate) @@ -148,7 +155,8 @@ class RecommendTempBasalTests: XCTestCase { maxBasalRate: maxBasalRate, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule + basalRateSchedule: basalRateSchedule, + minimumBGGuard: minimumBGGuard ) XCTAssertNil(dose) @@ -167,7 +175,8 @@ class RecommendTempBasalTests: XCTestCase { maxBasalRate: maxBasalRate, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule + basalRateSchedule: basalRateSchedule, + minimumBGGuard: minimumBGGuard ) XCTAssertEqual(0, dose!.rate) @@ -192,7 +201,8 @@ class RecommendTempBasalTests: XCTestCase { maxBasalRate: maxBasalRate, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule + basalRateSchedule: basalRateSchedule, + minimumBGGuard: minimumBGGuard ) XCTAssertEqual(0, dose!.rate) @@ -205,7 +215,8 @@ class RecommendTempBasalTests: XCTestCase { maxBasalRate: maxBasalRate, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule + basalRateSchedule: basalRateSchedule, + minimumBGGuard: minimumBGGuard ) XCTAssertNil(dose) @@ -224,7 +235,8 @@ class RecommendTempBasalTests: XCTestCase { maxBasalRate: maxBasalRate, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule + basalRateSchedule: basalRateSchedule, + minimumBGGuard: minimumBGGuard ) XCTAssertEqual(0, dose!.rate) @@ -240,7 +252,8 @@ class RecommendTempBasalTests: XCTestCase { maxBasalRate: maxBasalRate, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule + basalRateSchedule: basalRateSchedule, + minimumBGGuard: minimumBGGuard ) XCTAssertEqual(0, dose!.rate) @@ -257,7 +270,8 @@ class RecommendTempBasalTests: XCTestCase { maxBasalRate: maxBasalRate, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule + basalRateSchedule: basalRateSchedule, + minimumBGGuard: minimumBGGuard ) XCTAssertNil(dose) @@ -276,7 +290,8 @@ class RecommendTempBasalTests: XCTestCase { maxBasalRate: maxBasalRate, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule + basalRateSchedule: basalRateSchedule, + minimumBGGuard: minimumBGGuard ) XCTAssertEqual(0, dose!.rate) @@ -292,7 +307,8 @@ class RecommendTempBasalTests: XCTestCase { maxBasalRate: maxBasalRate, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule + basalRateSchedule: basalRateSchedule, + minimumBGGuard: minimumBGGuard ) XCTAssertEqual(3.0, dose!.rate) @@ -308,7 +324,8 @@ class RecommendTempBasalTests: XCTestCase { maxBasalRate: maxBasalRate, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule + basalRateSchedule: basalRateSchedule, + minimumBGGuard: minimumBGGuard ) XCTAssertEqualWithAccuracy(1.425, dose!.rate, accuracy: 1.0 / 40.0) @@ -324,7 +341,8 @@ class RecommendTempBasalTests: XCTestCase { maxBasalRate: maxBasalRate, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule + basalRateSchedule: basalRateSchedule, + minimumBGGuard: minimumBGGuard ) XCTAssertEqualWithAccuracy(1.475, dose!.rate, accuracy: 1.0 / 40.0) @@ -340,7 +358,8 @@ class RecommendTempBasalTests: XCTestCase { maxBasalRate: maxBasalRate, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: self.insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule + basalRateSchedule: basalRateSchedule, + minimumBGGuard: minimumBGGuard ) XCTAssertEqual(3.0, dose!.rate) @@ -355,7 +374,8 @@ class RecommendTempBasalTests: XCTestCase { maxBasalRate: maxBasalRate, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule + basalRateSchedule: basalRateSchedule, + minimumBGGuard: minimumBGGuard ) XCTAssertEqualWithAccuracy(2.975, dose!.rate, accuracy: 1.0 / 40.0) @@ -371,7 +391,8 @@ class RecommendTempBasalTests: XCTestCase { maxBasalRate: maxBasalRate, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: self.insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule + basalRateSchedule: basalRateSchedule, + minimumBGGuard: minimumBGGuard ) XCTAssertEqual(0.0, dose!.rate) @@ -385,7 +406,8 @@ class RecommendTempBasalTests: XCTestCase { maxBasalRate: maxBasalRate, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule + basalRateSchedule: basalRateSchedule, + minimumBGGuard: minimumBGGuard ) XCTAssertNil(dose) @@ -430,55 +452,41 @@ class RecommendBolusTests: XCTestCase { var insulinSensitivitySchedule: InsulinSensitivitySchedule { return InsulinSensitivitySchedule(unit: HKUnit.milligramsPerDeciliterUnit(), dailyItems: [RepeatingScheduleValue(startTime: 0.0, value: 60.0)])! } + + var minimumBGGuard: GlucoseThreshold { + return GlucoseThreshold(unit: HKUnit.milligramsPerDeciliterUnit(), value: 55) + } func testNoChange() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_no_change_glucose") let dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, atDate: glucose.first!.startDate, - lastTempBasal: nil, maxBolus: maxBolus, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule + basalRateSchedule: basalRateSchedule, + pendingInsulin: 0, + minimumBGGuard: minimumBGGuard ) - XCTAssertEqual(0, dose) + XCTAssertEqual(0, dose.amount) } func testStartHighEndInRange() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_start_high_end_in_range") - var dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, - atDate: glucose.first!.startDate, - lastTempBasal: nil, - maxBolus: maxBolus, - glucoseTargetRange: glucoseTargetRange, - insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule - ) - - XCTAssertEqual(0, dose) - - // Don't consider net-negative temp basal - let lastTempBasal = DoseEntry( - type: .tempBasal, - startDate: glucose.first!.startDate.addingTimeInterval(TimeInterval(minutes: -11)), - endDate: glucose.first!.startDate.addingTimeInterval(TimeInterval(minutes: 19)), - value: 0.01, - unit: .unitsPerHour - ) - - dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, + let dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, atDate: glucose.first!.startDate, - lastTempBasal: lastTempBasal, maxBolus: maxBolus, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule + basalRateSchedule: basalRateSchedule, + pendingInsulin: 0, + minimumBGGuard: minimumBGGuard ) - XCTAssertEqual(0, dose) + XCTAssertEqual(0, dose.amount) } func testStartLowEndInRange() { @@ -486,14 +494,15 @@ class RecommendBolusTests: XCTestCase { let dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, atDate: glucose.first!.startDate, - lastTempBasal: nil, maxBolus: maxBolus, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule + basalRateSchedule: basalRateSchedule, + pendingInsulin: 0, + minimumBGGuard: minimumBGGuard ) - XCTAssertEqual(0, dose) + XCTAssertEqual(0, dose.amount) } func testStartHighEndLow() { @@ -501,14 +510,15 @@ class RecommendBolusTests: XCTestCase { let dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, atDate: glucose.first!.startDate, - lastTempBasal: nil, maxBolus: maxBolus, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule + basalRateSchedule: basalRateSchedule, + pendingInsulin: 0, + minimumBGGuard: minimumBGGuard ) - XCTAssertEqual(0, dose) + XCTAssertEqual(0, dose.amount) } func testStartLowEndHigh() { @@ -516,14 +526,66 @@ class RecommendBolusTests: XCTestCase { let dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, atDate: glucose.first!.startDate, - lastTempBasal: nil, maxBolus: maxBolus, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule + basalRateSchedule: basalRateSchedule, + pendingInsulin: 0, + minimumBGGuard: minimumBGGuard ) - XCTAssertEqual(0, dose) + XCTAssertEqual(1.325, dose.amount) + XCTAssertEqual(BolusRecommendationNotice.currentGlucoseBelowTarget, dose.notice!) + } + + 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 + ) + + XCTAssertEqual(1.325, dose.amount) + XCTAssertEqual(BolusRecommendationNotice.predictedGlucoseBelowTarget(minGlucose: glucose[1], unit: glucoseTargetRange.unit), 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 + ) + + XCTAssertEqual(0.325, 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 + ) + + XCTAssertEqual(0, dose.amount) } func testFlatAndHigh() { @@ -531,14 +593,15 @@ class RecommendBolusTests: XCTestCase { let dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, atDate: glucose.first!.startDate, - lastTempBasal: nil, maxBolus: maxBolus, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule + basalRateSchedule: basalRateSchedule, + pendingInsulin: 0, + minimumBGGuard: minimumBGGuard ) - XCTAssertEqualWithAccuracy(1.333, dose, accuracy: 1.0 / 40.0) + XCTAssertEqualWithAccuracy(1.333, dose.amount, accuracy: 1.0 / 40.0) } func testHighAndFalling() { @@ -546,14 +609,15 @@ class RecommendBolusTests: XCTestCase { let dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, atDate: glucose.first!.startDate, - lastTempBasal: nil, maxBolus: maxBolus, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule + basalRateSchedule: basalRateSchedule, + pendingInsulin: 0, + minimumBGGuard: minimumBGGuard ) - XCTAssertEqualWithAccuracy(0.067, dose, accuracy: 1.0 / 40.0) + XCTAssertEqualWithAccuracy(0.067, dose.amount, accuracy: 1.0 / 40.0) } func testInRangeAndRising() { @@ -561,54 +625,41 @@ class RecommendBolusTests: XCTestCase { var dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, atDate: glucose.first!.startDate, - lastTempBasal: nil, maxBolus: maxBolus, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule + basalRateSchedule: basalRateSchedule, + pendingInsulin: 0, + minimumBGGuard: minimumBGGuard ) - XCTAssertEqualWithAccuracy(0.083, dose, accuracy: 1.0 / 40.0) + XCTAssertEqualWithAccuracy(0.083, dose.amount, accuracy: 1.0 / 40.0) // Less existing temp - var lastTempBasal = DoseEntry( - type: .tempBasal, - startDate: glucose.first!.startDate.addingTimeInterval(TimeInterval(minutes: -11)), - endDate: glucose.first!.startDate.addingTimeInterval(TimeInterval(minutes: 19)), - value: 1.225, - unit: .unitsPerHour - ) dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, atDate: glucose.first!.startDate, - lastTempBasal: lastTempBasal, maxBolus: maxBolus, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule + basalRateSchedule: basalRateSchedule, + pendingInsulin: 0.8, + minimumBGGuard: minimumBGGuard ) - XCTAssertEqualWithAccuracy(0, dose, accuracy: 1e-13) - - // But not a finished temp - lastTempBasal = DoseEntry( - type: .tempBasal, - startDate: glucose.first!.startDate.addingTimeInterval(TimeInterval(minutes: -35)), - endDate: glucose.first!.startDate.addingTimeInterval(TimeInterval(minutes: -5)), - value: 1.225, - unit: .unitsPerHour - ) + XCTAssertEqualWithAccuracy(0, dose.amount, accuracy: 1e-13) dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, atDate: glucose.first!.startDate, - lastTempBasal: lastTempBasal, maxBolus: maxBolus, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule + basalRateSchedule: basalRateSchedule, + pendingInsulin: 0, + minimumBGGuard: minimumBGGuard ) - XCTAssertEqualWithAccuracy(0.083, dose, accuracy: 1.0 / 40.0) + XCTAssertEqualWithAccuracy(0.083, dose.amount, accuracy: 1.0 / 40.0) } func testHighAndRising() { @@ -616,35 +667,42 @@ class RecommendBolusTests: XCTestCase { var dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, atDate: glucose.first!.startDate, - lastTempBasal: nil, maxBolus: maxBolus, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: self.insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule + basalRateSchedule: basalRateSchedule, + pendingInsulin: 0, + minimumBGGuard: minimumBGGuard ) - XCTAssertEqual(1.0, dose) + XCTAssertEqual(1.0, dose.amount) // Use mmol sensitivity value let insulinSensitivitySchedule = InsulinSensitivitySchedule(unit: HKUnit.millimolesPerLiterUnit(), dailyItems: [RepeatingScheduleValue(startTime: 0.0, value: 10.0 / 3)])! dose = DoseMath.recommendBolusFromPredictedGlucose(glucose, atDate: glucose.first!.startDate, - lastTempBasal: nil, maxBolus: maxBolus, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule + basalRateSchedule: basalRateSchedule, + pendingInsulin: 0, + minimumBGGuard: minimumBGGuard ) - XCTAssertEqualWithAccuracy(1.0, dose, accuracy: 1.0 / 40.0) + XCTAssertEqualWithAccuracy(1.0, dose.amount, accuracy: 1.0 / 40.0) } func testNoInputGlucose() { - let dose = DoseMath.recommendBolusFromPredictedGlucose([], lastTempBasal: nil, maxBolus: 4, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivitySchedule, - basalRateSchedule: basalRateSchedule) + let dose = DoseMath.recommendBolusFromPredictedGlucose([], + maxBolus: 4, + glucoseTargetRange: glucoseTargetRange, + insulinSensitivity: insulinSensitivitySchedule, + basalRateSchedule: basalRateSchedule, + pendingInsulin: 0, + minimumBGGuard: minimumBGGuard) - XCTAssertEqual(0, dose) + 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 new file mode 100644 index 0000000000..9643057fc0 --- /dev/null +++ b/DoseMathTests/Fixtures/recommend_temp_basal_dropping_then_rising.json @@ -0,0 +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} + ] diff --git a/DoseMathTests/Fixtures/recommend_temp_basal_start_very_low_end_high.json b/DoseMathTests/Fixtures/recommend_temp_basal_start_very_low_end_high.json new file mode 100644 index 0000000000..fed8e6c7b1 --- /dev/null +++ b/DoseMathTests/Fixtures/recommend_temp_basal_start_very_low_end_high.json @@ -0,0 +1,7 @@ + [ + {"date": "2015-07-19T18:00:00", "amount": 40}, + {"date": "2015-07-19T18:30:00", "amount": 50}, + {"date": "2015-07-19T19:00:00", "amount": 80}, + {"date": "2015-07-19T19:30:00", "amount": 160}, + {"date": "2015-07-19T20:00:00", "amount": 200} + ] diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 2bed0295a9..576f8af5f3 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -182,10 +182,21 @@ 4FF4D1011E18375000846527 /* WatchContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FF4D0FF1E18374700846527 /* WatchContext.swift */; }; 540DED971E14C75F002B2491 /* EnliteSensorDisplayable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 540DED961E14C75F002B2491 /* EnliteSensorDisplayable.swift */; }; C10428971D17BAD400DD539A /* NightscoutUploadKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C10428961D17BAD400DD539A /* NightscoutUploadKit.framework */; }; + C11C87DD1E21E53500BB71D3 /* GlucoseThreshold.swift in Sources */ = {isa = PBXBuildFile; fileRef = C178249D1E19B62300D9D25C /* GlucoseThreshold.swift */; }; + C11C87DE1E21EAAD00BB71D3 /* HKUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F526D5E1DF2459000A04910 /* HKUnit.swift */; }; C12F21A71DFA79CB00748193 /* recommend_tamp_basal_very_low_end_in_range.json in Resources */ = {isa = PBXBuildFile; fileRef = C12F21A61DFA79CB00748193 /* recommend_tamp_basal_very_low_end_in_range.json */; }; C15713821DAC6983005BC4D2 /* MealBolusNightscoutTreatment.swift in Sources */ = {isa = PBXBuildFile; fileRef = C15713811DAC6983005BC4D2 /* MealBolusNightscoutTreatment.swift */; }; + C178249A1E1999FA00D9D25C /* CaseCountable.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17824991E1999FA00D9D25C /* CaseCountable.swift */; }; + C17824A01E19CF9800D9D25C /* GlucoseThresholdTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C178249F1E19CF9800D9D25C /* GlucoseThresholdTableViewController.swift */; }; + C17824A11E19E8C200D9D25C /* GlucoseThreshold.swift in Sources */ = {isa = PBXBuildFile; fileRef = C178249D1E19B62300D9D25C /* GlucoseThreshold.swift */; }; + C17824A31E19EAB600D9D25C /* recommend_temp_basal_start_very_low_end_high.json in Resources */ = {isa = PBXBuildFile; fileRef = C17824A21E19EAB600D9D25C /* recommend_temp_basal_start_very_low_end_high.json */; }; + C17824A51E1AD4D100D9D25C /* BolusRecommendation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17824A41E1AD4D100D9D25C /* BolusRecommendation.swift */; }; + C17824A61E1AF91F00D9D25C /* BolusRecommendation.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17824A41E1AD4D100D9D25C /* BolusRecommendation.swift */; }; C17884631D51A7A400405663 /* BatteryIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C17884621D51A7A400405663 /* BatteryIndicator.swift */; }; C18C8C511D5A351900E043FB /* NightscoutDataManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C18C8C501D5A351900E043FB /* NightscoutDataManager.swift */; }; + C1C6591A1E1B1F430025CC58 /* NSNumberFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 436A0E7A1D7DE13400D6475D /* NSNumberFormatter.swift */; }; + C1C6591C1E1B1FDA0025CC58 /* recommend_temp_basal_dropping_then_rising.json in Resources */ = {isa = PBXBuildFile; fileRef = C1C6591B1E1B1FDA0025CC58 /* recommend_temp_basal_dropping_then_rising.json */; }; + C1C73EF71DE3D0230022FC89 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1C73EF91DE3D0230022FC89 /* InfoPlist.strings */; }; C1C73F021DE3D0250022FC89 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1C73F041DE3D0250022FC89 /* Localizable.strings */; }; C1C73F081DE3D0260022FC89 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1C73F0A1DE3D0260022FC89 /* InfoPlist.strings */; }; C1C73F0D1DE3D0270022FC89 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = C1C73F0F1DE3D0270022FC89 /* InfoPlist.strings */; }; @@ -476,8 +487,15 @@ C10428961D17BAD400DD539A /* NightscoutUploadKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NightscoutUploadKit.framework; path = Carthage/Build/iOS/NightscoutUploadKit.framework; sourceTree = ""; }; C12F21A61DFA79CB00748193 /* recommend_tamp_basal_very_low_end_in_range.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = recommend_tamp_basal_very_low_end_in_range.json; sourceTree = ""; }; C15713811DAC6983005BC4D2 /* MealBolusNightscoutTreatment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MealBolusNightscoutTreatment.swift; sourceTree = ""; }; + C17824991E1999FA00D9D25C /* CaseCountable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CaseCountable.swift; sourceTree = ""; }; + C178249D1E19B62300D9D25C /* GlucoseThreshold.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseThreshold.swift; sourceTree = ""; }; + C178249F1E19CF9800D9D25C /* GlucoseThresholdTableViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseThresholdTableViewController.swift; sourceTree = ""; }; + C17824A21E19EAB600D9D25C /* recommend_temp_basal_start_very_low_end_high.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = recommend_temp_basal_start_very_low_end_high.json; sourceTree = ""; }; + C17824A41E1AD4D100D9D25C /* BolusRecommendation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BolusRecommendation.swift; sourceTree = ""; }; C17884621D51A7A400405663 /* BatteryIndicator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BatteryIndicator.swift; sourceTree = ""; }; C18C8C501D5A351900E043FB /* NightscoutDataManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NightscoutDataManager.swift; sourceTree = ""; }; + C1C6591B1E1B1FDA0025CC58 /* recommend_temp_basal_dropping_then_rising.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = recommend_temp_basal_dropping_then_rising.json; sourceTree = ""; }; + C1C73EF81DE3D0230022FC89 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; C1C73F031DE3D0250022FC89 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; C1C73F091DE3D0260022FC89 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; C1C73F0E1DE3D0270022FC89 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -597,6 +615,8 @@ 438D42F81D7C88BC003244B0 /* PredictionInputEffect.swift */, 43C418B41CE0575200405B6A /* ShareGlucose+GlucoseKit.swift */, 4328E0311CFC068900E199AA /* WatchContext+LoopKit.swift */, + C178249D1E19B62300D9D25C /* GlucoseThreshold.swift */, + C17824A41E1AD4D100D9D25C /* BolusRecommendation.swift */, 540DED961E14C75F002B2491 /* EnliteSensorDisplayable.swift */, ); path = Models; @@ -728,6 +748,8 @@ 43E2D8EA1D20C0DB004DA55F /* recommend_temp_basal_start_low_end_high.json */, 43E2D8EB1D20C0DB004DA55F /* recommend_temp_basal_start_low_end_in_range.json */, C12F21A61DFA79CB00748193 /* recommend_tamp_basal_very_low_end_in_range.json */, + C17824A21E19EAB600D9D25C /* recommend_temp_basal_start_very_low_end_high.json */, + C1C6591B1E1B1FDA0025CC58 /* recommend_temp_basal_dropping_then_rising.json */, ); path = Fixtures; sourceTree = ""; @@ -747,6 +769,7 @@ 43F41C361D3BF32400C11ED6 /* UIAlertController.swift */, 437CEEE31CDE5C0A003C8C80 /* UIImage.swift */, 434FF1ED1CF27EEF000DB779 /* UITableViewCell.swift */, + C17824991E1999FA00D9D25C /* CaseCountable.swift */, ); path = Extensions; sourceTree = ""; @@ -766,6 +789,7 @@ 43F5C2DA1B92A5E1003EB13D /* SettingsTableViewController.swift */, 43E3449E1B9D68E900C85C07 /* StatusTableViewController.swift */, 4302F4E01D4E9C8900F0FCAF /* TextFieldTableViewController.swift */, + C178249F1E19CF9800D9D25C /* GlucoseThresholdTableViewController.swift */, ); path = "View Controllers"; sourceTree = ""; @@ -1226,6 +1250,7 @@ files = ( 43E2D8F21D20C0DB004DA55F /* recommend_temp_basal_no_change_glucose.json in Resources */, 43E2D8F61D20C0DB004DA55F /* recommend_temp_basal_start_low_end_in_range.json in Resources */, + C17824A31E19EAB600D9D25C /* recommend_temp_basal_start_very_low_end_high.json in Resources */, 43E2D8F41D20C0DB004DA55F /* recommend_temp_basal_start_high_end_low.json in Resources */, 43E2D8EF1D20C0DB004DA55F /* recommend_temp_basal_high_and_falling.json in Resources */, 43E2D8ED1D20C0DB004DA55F /* recommend_temp_basal_correct_low_at_min.json in Resources */, @@ -1233,6 +1258,7 @@ C12F21A71DFA79CB00748193 /* recommend_tamp_basal_very_low_end_in_range.json in Resources */, 43E2D8F11D20C0DB004DA55F /* recommend_temp_basal_in_range_and_rising.json in Resources */, 43E2D8EE1D20C0DB004DA55F /* recommend_temp_basal_flat_and_high.json in Resources */, + C1C6591C1E1B1FDA0025CC58 /* recommend_temp_basal_dropping_then_rising.json in Resources */, 43E2D8F31D20C0DB004DA55F /* recommend_temp_basal_start_high_end_in_range.json in Resources */, 43E2D8F51D20C0DB004DA55F /* recommend_temp_basal_start_low_end_high.json in Resources */, 43E2D8EC1D20C0DB004DA55F /* read_selected_basal_profile.json in Resources */, @@ -1301,6 +1327,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C17824A51E1AD4D100D9D25C /* BolusRecommendation.swift in Sources */, 4F70C2131DE90339006380B7 /* StatusExtensionContext.swift in Sources */, 434F54571D287FDB002A9274 /* NibLoadable.swift in Sources */, 4FF4D1001E18374700846527 /* WatchContext.swift in Sources */, @@ -1313,6 +1340,7 @@ 43CE7CDE1CA8B63E003CC1B0 /* Data.swift in Sources */, 43F41C331D3A17AA00C11ED6 /* ChartAxisValueDoubleUnit.swift in Sources */, 43F5C2DB1B92A5E1003EB13D /* SettingsTableViewController.swift in Sources */, + C11C87DD1E21E53500BB71D3 /* GlucoseThreshold.swift in Sources */, 4313EDE01D8A6BF90060FA79 /* ChartContentView.swift in Sources */, 434FF1EA1CF26C29000DB779 /* IdentifiableClass.swift in Sources */, 437CCADE1D2858FD0075D2C3 /* AuthenticationViewController.swift in Sources */, @@ -1328,6 +1356,7 @@ 43E3449F1B9D68E900C85C07 /* StatusTableViewController.swift in Sources */, 43DBF0531C93EC8200B3C386 /* DeviceDataManager.swift in Sources */, 43E2D8C81D208D5B004DA55F /* KeychainManager+Loop.swift in Sources */, + C17824A01E19CF9800D9D25C /* GlucoseThresholdTableViewController.swift in Sources */, 4346D1E71C77F5FE00ABAFE3 /* ChartTableViewCell.swift in Sources */, 437CEEE41CDE5C0A003C8C80 /* UIImage.swift in Sources */, 43DBF0591C93F73800B3C386 /* CarbEntryTableViewController.swift in Sources */, @@ -1342,6 +1371,7 @@ 4315D2871CA5CC3B00589052 /* CarbEntryEditTableViewController.swift in Sources */, 43F5173D1D713DB0000FA422 /* RadioSelectionTableViewController.swift in Sources */, 4331E0781C85302200FBE832 /* CGPoint.swift in Sources */, + C178249A1E1999FA00D9D25C /* CaseCountable.swift in Sources */, 43DBF04C1C93B8D700B3C386 /* BolusViewController.swift in Sources */, 4328E0351CFC0AE100E199AA /* WatchDataManager.swift in Sources */, 4302F4E31D4EA54200F0FCAF /* InsulinDeliveryTableViewController.swift in Sources */, @@ -1416,9 +1446,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + C17824A11E19E8C200D9D25C /* GlucoseThreshold.swift in Sources */, 43E2D8DC1D20C049004DA55F /* DoseMath.swift in Sources */, 43E2D8DB1D20C03B004DA55F /* NSTimeInterval.swift in Sources */, 43E2D8D41D20BF42004DA55F /* DoseMathTests.swift in Sources */, + C11C87DE1E21EAAD00BB71D3 /* HKUnit.swift in Sources */, + C1C6591A1E1B1F430025CC58 /* NSNumberFormatter.swift in Sources */, + C17824A61E1AF91F00D9D25C /* BolusRecommendation.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Loop/Base.lproj/Main.storyboard b/Loop/Base.lproj/Main.storyboard index 09ca7666af..042725e30c 100644 --- a/Loop/Base.lproj/Main.storyboard +++ b/Loop/Base.lproj/Main.storyboard @@ -1,11 +1,11 @@ - - + + - + @@ -286,7 +286,7 @@ - + @@ -440,12 +440,62 @@ - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + @@ -490,7 +540,7 @@ - + @@ -534,7 +584,7 @@ - + @@ -573,14 +623,17 @@ + + + - + diff --git a/Loop/Extensions/CaseCountable.swift b/Loop/Extensions/CaseCountable.swift new file mode 100644 index 0000000000..1c8c494893 --- /dev/null +++ b/Loop/Extensions/CaseCountable.swift @@ -0,0 +1,19 @@ +// +// CaseCountable.swift +// Loop +// +// Created by Pete Schwamb on 1/1/17. +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import Foundation + +public protocol CaseCountable: RawRepresentable {} + +public extension CaseCountable where RawValue: Integer { + static var count: Int { + var i: RawValue = 0 + while let new = Self(rawValue: i) { i = new.rawValue.advanced(by: 1) } + return Int(i.toIntMax()) + } +} diff --git a/Loop/Extensions/NSUserDefaults.swift b/Loop/Extensions/NSUserDefaults.swift index a9ecdeb010..c1854b60f6 100644 --- a/Loop/Extensions/NSUserDefaults.swift +++ b/Loop/Extensions/NSUserDefaults.swift @@ -9,6 +9,7 @@ import Foundation import LoopKit import MinimedKit +import HealthKit extension UserDefaults { @@ -33,6 +34,7 @@ extension UserDefaults { case PumpTimeZone = "com.loudnate.Naterade.PumpTimeZone" case RetrospectiveCorrectionEnabled = "com.loudnate.Loop.RetrospectiveCorrectionEnabled" case BatteryChemistry = "com.loopkit.Loop.BatteryChemistry" + case MinimumBGGuard = "com.loopkit.Loop.MinimumBGGuard" } var basalRateSchedule: BasalRateSchedule? { @@ -271,5 +273,18 @@ extension UserDefaults { } } } + + var minimumBGGuard: GlucoseThreshold? { + get { + if let rawValue = dictionary(forKey: Key.MinimumBGGuard.rawValue) { + return GlucoseThreshold(rawValue: rawValue) + } else { + return nil + } + } + set { + set(newValue?.rawValue, forKey: Key.MinimumBGGuard.rawValue) + } + } } diff --git a/Loop/Managers/AnalyticsManager.swift b/Loop/Managers/AnalyticsManager.swift index 51ef938d64..046e64a100 100644 --- a/Loop/Managers/AnalyticsManager.swift +++ b/Loop/Managers/AnalyticsManager.swift @@ -106,6 +106,10 @@ final class AnalyticsManager { func didChangeMaximumBolus() { logEvent("Maximum bolus change") } + + func didChangeMinimumBGGuard() { + logEvent("Minimum BG Guard change") + } // MARK: - Loop Events diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index bf9fa8518b..ff8288435a 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -899,6 +899,13 @@ final class DeviceDataManager: CarbStoreDelegate, CarbStoreSyncDelegate, DoseSto AnalyticsManager.sharedManager.didChangeGlucoseTargetRangeSchedule() } } + + var minimumBGGuard: GlucoseThreshold? = UserDefaults.standard.minimumBGGuard { + didSet { + UserDefaults.standard.minimumBGGuard = minimumBGGuard + AnalyticsManager.sharedManager.didChangeMinimumBGGuard() + } + } var workoutModeEnabled: Bool? { guard let range = glucoseTargetRangeSchedule else { @@ -1078,7 +1085,7 @@ final class DeviceDataManager: CarbStoreDelegate, CarbStoreSyncDelegate, DoseSto insulinSensitivitySchedule: insulinSensitivitySchedule ) - carbStore = CarbStore( + carbStore = CarbStore( defaultAbsorptionTimes: (fast: TimeInterval(hours: 2), medium: TimeInterval(hours: 3), slow: TimeInterval(hours: 4)), carbRatioSchedule: carbRatioSchedule, insulinSensitivitySchedule: insulinSensitivitySchedule diff --git a/Loop/Managers/DoseMath.swift b/Loop/Managers/DoseMath.swift index a7eec3743e..9f6fe51f3a 100644 --- a/Loop/Managers/DoseMath.swift +++ b/Loop/Managers/DoseMath.swift @@ -51,6 +51,7 @@ struct DoseMath { - 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. - returns: The recommended basal rate and duration */ @@ -60,7 +61,8 @@ struct DoseMath { maxBasalRate: Double, glucoseTargetRange: GlucoseRangeSchedule, insulinSensitivity: InsulinSensitivitySchedule, - basalRateSchedule: BasalRateSchedule + basalRateSchedule: BasalRateSchedule, + minimumBGGuard: GlucoseThreshold ) -> (rate: Double, duration: TimeInterval)? { guard glucose.count > 1 else { return nil @@ -77,9 +79,7 @@ struct DoseMath { var rate: Double? var duration = TimeInterval(minutes: 30) - let alwaysLowTempBGThreshold: Double = 55 // mg/dL - - if minGlucose.quantity.doubleValue(for: HKUnit.milligramsPerDeciliterUnit()) <= alwaysLowTempBGThreshold { + 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) @@ -135,52 +135,59 @@ struct DoseMath { - 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 lastTempBasal: The last-set temporary basal - 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(), - lastTempBasal: DoseEntry?, maxBolus: Double, glucoseTargetRange: GlucoseRangeSchedule, insulinSensitivity: InsulinSensitivitySchedule, - basalRateSchedule: BasalRateSchedule - ) -> Double { + basalRateSchedule: BasalRateSchedule, + pendingInsulin: Double, + minimumBGGuard: GlucoseThreshold + ) -> BolusRecommendation { guard glucose.count > 1 else { - return 0 + return BolusRecommendation(amount: 0, pendingInsulin: pendingInsulin) } let eventualGlucose = glucose.last! let minGlucose = glucose.min { $0.quantity < $1.quantity }! let eventualGlucoseTargets = glucoseTargetRange.value(at: eventualGlucose.startDate) - // Use between to opt-out of the override. - let minGlucoseTargets = glucoseTargetRange.between(start: minGlucose.startDate, end: minGlucose.startDate).first!.value - guard minGlucose.quantity.doubleValue(for: glucoseTargetRange.unit) >= minGlucoseTargets.minValue else { - return 0 + guard minGlucose.quantity >= minimumBGGuard.quantity else { + return BolusRecommendation(amount: 0, pendingInsulin: pendingInsulin, notice: .glucoseBelowMinimumGuard) } let targetGlucose = eventualGlucoseTargets.maxValue let currentSensitivity = insulinSensitivity.quantity(at: date).doubleValue(for: glucoseTargetRange.unit) - var doseUnits = (eventualGlucose.quantity.doubleValue(for: glucoseTargetRange.unit) - targetGlucose) / currentSensitivity + let doseUnits = (eventualGlucose.quantity.doubleValue(for: glucoseTargetRange.unit) - targetGlucose) / currentSensitivity - if let lastTempBasal = lastTempBasal, lastTempBasal.unit == .unitsPerHour && lastTempBasal.endDate > date { - let normalBasalRate = basalRateSchedule.value(at: date) - let remainingTime = lastTempBasal.endDate.timeIntervalSince(date) - let remainingUnits = (lastTempBasal.value - normalBasalRate) * remainingTime / TimeInterval(hours: 1) + // 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)) - doseUnits -= max(0, remainingUnits) + let notice: BolusRecommendationNotice? + if cappedAmount > 0 && minGlucose.quantity.doubleValue(for: glucoseTargetRange.unit) < eventualGlucoseTargets.minValue { + if minGlucose.startDate == glucose[0].startDate { + notice = .currentGlucoseBelowTarget + } else { + notice = .predictedGlucoseBelowTarget(minGlucose: minGlucose, unit: glucoseTargetRange.unit) + } + } else { + notice = nil } - - doseUnits = round(doseUnits * 40) / 40 - - return min(maxBolus, max(0, doseUnits)) + + return BolusRecommendation(amount: cappedAmount, pendingInsulin: pendingInsulin, notice: notice) } } diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 25df46f8bc..83b6735017 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -261,6 +261,41 @@ final class LoopDataManager { } } + + + /** + Computes amount of insulin from boluses that have been issued and not confirmed, and + remaining insulin delivery from temporary basal rate adjustments above scheduled rate + that are still in progress. + + *This method should only be called from the `dataAccessQueue`* + + **/ + private func getPendingInsulin() throws -> Double { + + guard let basalRates = deviceDataManager.basalRateSchedule else { + throw LoopError.configurationError + } + + let pendingTempBasalInsulin: Double + let date = Date() + + if let lastTempBasal = lastTempBasal, lastTempBasal.unit == .unitsPerHour && lastTempBasal.endDate > date { + let normalBasalRate = basalRates.value(at: date) + let remainingTime = lastTempBasal.endDate.timeIntervalSince(date) + let remainingUnits = (lastTempBasal.value - normalBasalRate) * remainingTime / TimeInterval(hours: 1) + + pendingTempBasalInsulin = max(0, remainingUnits) + } else { + pendingTempBasalInsulin = 0 + } + + let pendingBolusAmount: Double = lastBolus?.units ?? 0 + + // All outstanding potential insulin delivery + return pendingTempBasalInsulin + pendingBolusAmount + } + func modelPredictedGlucose(using inputs: [PredictionInputEffect], resultsHandler: @escaping (_ predictedGlucose: [GlucoseValue]?, _ error: Error?) -> Void) { dataAccessQueue.async { guard let @@ -482,21 +517,32 @@ final class LoopDataManager { */ private func updatePredictedGlucoseAndRecommendedBasal() throws { guard let - glucose = self.deviceDataManager.glucoseStore?.latestGlucose, - let pumpStatusDate = self.deviceDataManager.doseStore.lastReservoirValue?.startDate + glucose = self.deviceDataManager.glucoseStore?.latestGlucose + else { + self.predictedGlucose = nil + throw LoopError.missingDataError("Glucose") + } + + guard let + pumpStatusDate = self.deviceDataManager.doseStore.lastReservoirValue?.startDate else { self.predictedGlucose = nil - throw LoopError.missingDataError("Cannot predict glucose due to missing input data") + throw LoopError.missingDataError("Reservoir") } let startDate = Date() let recencyInterval = TimeInterval(minutes: 15) - guard startDate.timeIntervalSince(glucose.startDate) <= recencyInterval && - startDate.timeIntervalSince(pumpStatusDate) <= recencyInterval + guard startDate.timeIntervalSince(glucose.startDate) <= recencyInterval + else { + self.predictedGlucose = nil + throw LoopError.glucoseTooOld(date: glucose.startDate) + } + + guard startDate.timeIntervalSince(pumpStatusDate) <= recencyInterval else { self.predictedGlucose = nil - throw LoopError.staleDataError("Glucose Date: \(glucose.startDate) or Pump status date: \(pumpStatusDate) older than \(recencyInterval.minutes) min") + throw LoopError.pumpDataTooOld(date: pumpStatusDate) } guard let @@ -505,7 +551,7 @@ final class LoopDataManager { let insulinEffect = self.insulinEffect else { self.predictedGlucose = nil - throw LoopError.missingDataError("Cannot predict glucose due to missing effect data") + throw LoopError.missingDataError("Glucose effects") } var error: Error? @@ -557,9 +603,10 @@ final class LoopDataManager { maxBasal = deviceDataManager.maximumBasalRatePerHour, let glucoseTargetRange = deviceDataManager.glucoseTargetRangeSchedule, let insulinSensitivity = deviceDataManager.insulinSensitivitySchedule, - let basalRates = deviceDataManager.basalRateSchedule + let basalRates = deviceDataManager.basalRateSchedule, + let minimumBGGuard = deviceDataManager.minimumBGGuard else { - error = LoopError.missingDataError("Loop configuration data not set") + error = LoopError.configurationError throw error! } @@ -571,7 +618,8 @@ final class LoopDataManager { maxBasalRate: maxBasal, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivity, - basalRateSchedule: basalRates + basalRateSchedule: basalRates, + minimumBGGuard: minimumBGGuard ) else { recommendedTempBasal = nil @@ -581,7 +629,7 @@ final class LoopDataManager { recommendedTempBasal = (recommendedDate: Date(), rate: tempBasal.rate, duration: tempBasal.duration) } - func addCarbEntryAndRecommendBolus(_ carbEntry: CarbEntry, resultsHandler: @escaping (_ units: Double?, _ error: Error?) -> Void) { + func addCarbEntryAndRecommendBolus(_ carbEntry: CarbEntry, resultsHandler: @escaping (_ recommendation: BolusRecommendation?, _ error: Error?) -> Void) { if let carbStore = deviceDataManager.carbStore { carbStore.addCarbEntry(carbEntry) { (success, _, error) in self.dataAccessQueue.async { @@ -602,58 +650,65 @@ final class LoopDataManager { } } } else { - resultsHandler(nil, LoopError.missingDataError("CarbStore not configured")) + resultsHandler(nil, LoopError.configurationError) } } - private func recommendBolus() throws -> Double { + private func recommendBolus() throws -> BolusRecommendation { guard let glucose = self.predictedGlucose, let glucoseWithoutMomentum = self.predictedGlucoseWithoutMomentum, let maxBolus = self.deviceDataManager.maximumBolus, let glucoseTargetRange = self.deviceDataManager.glucoseTargetRangeSchedule, let insulinSensitivity = self.deviceDataManager.insulinSensitivitySchedule, - let basalRates = self.deviceDataManager.basalRateSchedule + let basalRates = self.deviceDataManager.basalRateSchedule, + let minimumBGGuard = self.deviceDataManager.minimumBGGuard else { - throw LoopError.missingDataError("Bolus prediction and configuration data not found") + throw LoopError.configurationError } let recencyInterval = TimeInterval(minutes: 15) - - guard let predictedInterval = glucose.first?.startDate.timeIntervalSinceNow else { + + guard let glucoseDate = glucose.first?.startDate else { throw LoopError.missingDataError("No glucose data found") } - guard abs(predictedInterval) <= recencyInterval else { - throw LoopError.staleDataError("Glucose is \(predictedInterval.minutes) min old") + guard abs(glucoseDate.timeIntervalSinceNow) <= recencyInterval else { + throw LoopError.glucoseTooOld(date: glucoseDate) } - let pendingBolusAmount: Double = lastBolus?.units ?? 0 + let pendingInsulin = try self.getPendingInsulin() - let recommendedBolusWithMomentum = max(0, DoseMath.recommendBolusFromPredictedGlucose(glucose, - lastTempBasal: self.lastTempBasal, + let recommendationWithMomentum = DoseMath.recommendBolusFromPredictedGlucose(glucose, maxBolus: maxBolus, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivity, - basalRateSchedule: basalRates - ) - pendingBolusAmount) + basalRateSchedule: basalRates, + pendingInsulin: pendingInsulin, + minimumBGGuard: minimumBGGuard + ) - let recommendedBolusWithoutMomentum = max(0, DoseMath.recommendBolusFromPredictedGlucose(glucoseWithoutMomentum, - lastTempBasal: self.lastTempBasal, + let recommendationWithoutMomentum = DoseMath.recommendBolusFromPredictedGlucose(glucoseWithoutMomentum, maxBolus: maxBolus, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivity, - basalRateSchedule: basalRates - ) - pendingBolusAmount) - - return min(recommendedBolusWithMomentum, recommendedBolusWithoutMomentum) + basalRateSchedule: basalRates, + pendingInsulin: pendingInsulin, + minimumBGGuard: minimumBGGuard + ) + + if (recommendationWithMomentum.amount > recommendationWithoutMomentum.amount) { + return recommendationWithoutMomentum + } else { + return recommendationWithMomentum + } } - func getRecommendedBolus(_ resultsHandler: @escaping (_ units: Double?, _ error: Error?) -> Void) { + func getRecommendedBolus(_ resultsHandler: @escaping (_ units: BolusRecommendation?, _ error: Error?) -> Void) { dataAccessQueue.async { do { - let units = try self.recommendBolus() - resultsHandler(units, nil) + let recommendation = try self.recommendBolus() + resultsHandler(recommendation, nil) } catch let error { resultsHandler(nil, error) } @@ -666,8 +721,8 @@ final class LoopDataManager { return } - guard recommendedTempBasal.recommendedDate.timeIntervalSinceNow < TimeInterval(minutes: 5) else { - resultsHandler(false, LoopError.staleDataError("Recommended temp basal is \(recommendedTempBasal.recommendedDate.timeIntervalSinceNow.minutes) min old")) + guard abs(recommendedTempBasal.recommendedDate.timeIntervalSinceNow) < TimeInterval(minutes: 5) else { + resultsHandler(false, LoopError.recommendationExpired(date: recommendedTempBasal.recommendedDate)) return } diff --git a/Loop/Managers/NightscoutDataManager.swift b/Loop/Managers/NightscoutDataManager.swift index cedb694039..fdc0142b73 100644 --- a/Loop/Managers/NightscoutDataManager.swift +++ b/Loop/Managers/NightscoutDataManager.swift @@ -37,11 +37,11 @@ class NightscoutDataManager { deviceDataManager.loopManager.getLoopStatus { (predictedGlucose, _, recommendedTempBasal, lastTempBasal, _, insulinOnBoard, carbsOnBoard, loopError) in - self.deviceDataManager.loopManager.getRecommendedBolus { (bolusUnits, getBolusError) in + self.deviceDataManager.loopManager.getRecommendedBolus { (recommendation, getBolusError) in if let getBolusError = getBolusError { self.deviceDataManager.logger.addError(getBolusError, fromSource: "NightscoutDataManager") } - self.uploadLoopStatus(insulinOnBoard, carbsOnBoard: carbsOnBoard, predictedGlucose: predictedGlucose, recommendedTempBasal: recommendedTempBasal, recommendedBolus: bolusUnits, lastTempBasal: lastTempBasal, loopError: loopError ?? getBolusError) + self.uploadLoopStatus(insulinOnBoard, carbsOnBoard: carbsOnBoard, predictedGlucose: predictedGlucose, recommendedTempBasal: recommendedTempBasal, recommendedBolus: recommendation?.amount, lastTempBasal: lastTempBasal, loopError: loopError ?? getBolusError) } } } diff --git a/Loop/Managers/WatchDataManager.swift b/Loop/Managers/WatchDataManager.swift index b00c038fb0..059a1d396c 100644 --- a/Loop/Managers/WatchDataManager.swift +++ b/Loop/Managers/WatchDataManager.swift @@ -108,13 +108,13 @@ final class WatchDataManager: NSObject, WCSessionDelegate { deviceDataManager.loopManager.getLoopStatus { (predictedGlucose, _, recommendedTempBasal, lastTempBasal, lastLoopCompleted, _, _, error) in let eventualGlucose = predictedGlucose?.last - self.deviceDataManager.loopManager.getRecommendedBolus { (units, error) in + self.deviceDataManager.loopManager.getRecommendedBolus { (recommendation, error) in glucoseStore.preferredUnit { (unit, error) in let context = WatchContext(glucose: glucose, eventualGlucose: eventualGlucose, glucoseUnit: unit) context.reservoir = reservoir?.unitVolume context.loopLastRunDate = lastLoopCompleted - context.recommendedBolusDose = units + context.recommendedBolusDose = recommendation?.amount context.maxBolus = maxBolus if let trend = self.deviceDataManager.sensorInfo?.trendType { @@ -136,14 +136,14 @@ final class WatchDataManager: NSObject, WCSessionDelegate { absorptionTime: carbEntry.absorptionTimeType.absorptionTimeFromDefaults(carbStore.defaultAbsorptionTimes) ) - deviceDataManager.loopManager.addCarbEntryAndRecommendBolus(newEntry) { (units, error) in + deviceDataManager.loopManager.addCarbEntryAndRecommendBolus(newEntry) { (recommendation, error) in if let error = error { self.deviceDataManager.logger.addError(error, fromSource: error is CarbStore.CarbStoreError ? "CarbStore" : "Bolus") } else { AnalyticsManager.sharedManager.didAddCarbsFromWatch(carbEntry.value) } - completionHandler?(units) + completionHandler?(recommendation?.amount) } } else { completionHandler?(nil) diff --git a/Loop/Models/BolusRecommendation.swift b/Loop/Models/BolusRecommendation.swift new file mode 100644 index 0000000000..80951c68d1 --- /dev/null +++ b/Loop/Models/BolusRecommendation.swift @@ -0,0 +1,72 @@ +// +// BolusRecommendation.swift +// Loop +// +// Created by Pete Schwamb on 1/2/17. +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import Foundation +import LoopKit +import HealthKit + +enum BolusRecommendationNotice: CustomStringConvertible, Equatable { + case glucoseBelowMinimumGuard + case currentGlucoseBelowTarget + case predictedGlucoseBelowTarget(minGlucose: GlucoseValue, unit: HKUnit) + + public var description: String { + switch self { + case .glucoseBelowMinimumGuard: + return NSLocalizedString("Predicted glucose is below your minimum BG Guard setting.", comment: "Notice message when recommending bolus when BG is below minimum BG guard.") + case .currentGlucoseBelowTarget: + return NSLocalizedString("Glucose is below target range.", comment: "Message when offering bolus prediction even though bg is below range.") + case .predictedGlucoseBelowTarget(let minGlucose, let unit): + let timeFormatter = DateFormatter() + timeFormatter.dateStyle = .none + timeFormatter.timeStyle = .short + let time = timeFormatter.string(from: minGlucose.startDate) + + let numberFormatter = NumberFormatter.glucoseFormatter(for: unit) + + let minBGStr = numberFormatter.describingGlucose(minGlucose.quantity, for: unit)! + + return String(format: NSLocalizedString("Predicted glucose at %1$@ is %2$@.", comment: "Message when offering bolus prediction even though bg is below range and minBG is in future. (1: glucose time)(2: glucose number)"), time, minBGStr) + + } + } + + static func ==(lhs: BolusRecommendationNotice, rhs: BolusRecommendationNotice) -> Bool { + switch (lhs, rhs) { + case (.glucoseBelowMinimumGuard, .glucoseBelowMinimumGuard): + return true + + case (.currentGlucoseBelowTarget, .currentGlucoseBelowTarget): + return true + + case (let .predictedGlucoseBelowTarget(minGlucose1, unit1), let .predictedGlucoseBelowTarget(minGlucose2, unit2)): + // GlucoseValue is not equatable + return + minGlucose1.startDate == minGlucose2.startDate && + minGlucose1.endDate == minGlucose2.endDate && + minGlucose1.quantity == minGlucose2.quantity && + unit1 == unit2 + + default: + return false + } + } +} + + +struct BolusRecommendation { + let amount: Double + let pendingInsulin: Double + let notice: BolusRecommendationNotice? + + init(amount: Double, pendingInsulin: Double, notice: BolusRecommendationNotice? = nil) { + self.amount = amount + self.pendingInsulin = pendingInsulin + self.notice = notice + } +} diff --git a/Loop/Models/GlucoseThreshold.swift b/Loop/Models/GlucoseThreshold.swift new file mode 100644 index 0000000000..19607343ed --- /dev/null +++ b/Loop/Models/GlucoseThreshold.swift @@ -0,0 +1,41 @@ +// +// GlucoseThreshold.swift +// Loop +// +// Created by Pete Schwamb on 1/1/17. +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit + +struct GlucoseThreshold: RawRepresentable { + typealias RawValue = [String: Any] + + let value: Double + let unit: HKUnit + + public var quantity: HKQuantity { + return HKQuantity(unit: unit, doubleValue: value) + } + + public init(unit: HKUnit, value: Double) { + self.value = value + self.unit = unit + } + + init?(rawValue: RawValue) { + guard let unitsStr = rawValue["units"] as? String, let value = rawValue["value"] as? Double else { + return nil + } + self.unit = HKUnit(from: unitsStr) + self.value = value + } + + var rawValue: RawValue { + return [ + "value": value, + "units": unit.unitString + ] + } +} diff --git a/Loop/Models/LoopError.swift b/Loop/Models/LoopError.swift index 2bfc990403..623549a89a 100644 --- a/Loop/Models/LoopError.swift +++ b/Loop/Models/LoopError.swift @@ -6,6 +6,7 @@ // Copyright © 2016 Nathan Racklyeft. All rights reserved. // +import Foundation enum LoopError: Error { // Failure during device communication @@ -20,6 +21,43 @@ enum LoopError: Error { // Missing required data to perform an action case missingDataError(String) - // Out-of-date required data to perform an action - case staleDataError(String) + // Glucose data is too old to perform action + case glucoseTooOld(date: Date) + + // Pump data is too old to perform action + case pumpDataTooOld(date: Date) + + // Recommendation Expired + case recommendationExpired(date: Date) +} + +extension LoopError: LocalizedError { + + public var errorDescription: String? { + + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.minute] + formatter.unitsStyle = .full + + switch self { + case .communicationError: + return NSLocalizedString("Communication Error", comment: "The error message displayed after a communication error.") + case .configurationError: + return NSLocalizedString("Configuration Error", comment: "The error message displayed for configuration errors.") + case .connectionError: + return NSLocalizedString("No connected devices, or failure during device connection", comment: "The error message displayed for device connection errors.") + case .missingDataError(let details): + return String(format: NSLocalizedString("Missing data: %1$@", comment: "The error message for missing data. (1: missing data details)"), details) + case .glucoseTooOld(let date): + let minutes = formatter.string(from: -date.timeIntervalSinceNow) ?? "" + return String(format: NSLocalizedString("Glucose data is %1$@ old", comment: "The error message when glucose data is too old to be used. (1: glucose data age in minutes)"), minutes) + case .pumpDataTooOld(let date): + let minutes = formatter.string(from: -date.timeIntervalSinceNow) ?? "" + return String(format: NSLocalizedString("Pump data is %1$@ old", comment: "The error message when pump data is too old to be used. (1: pump data age in minutes)"), minutes) + case .recommendationExpired(let date): + let minutes = formatter.string(from: -date.timeIntervalSinceNow) ?? "" + return String(format: NSLocalizedString("Recommendation expired: %1$@ old", comment: "The error message when a recommendation has expired. (1: age of recommendation in minutes)"), minutes) + } + } } + diff --git a/Loop/View Controllers/BolusViewController.swift b/Loop/View Controllers/BolusViewController.swift index 2e80ff9da3..e59e7952d2 100644 --- a/Loop/View Controllers/BolusViewController.swift +++ b/Loop/View Controllers/BolusViewController.swift @@ -9,53 +9,166 @@ import UIKit import LocalAuthentication import LoopKit +import HealthKit final class BolusViewController: UITableViewController, IdentifiableClass, UITextFieldDelegate { + fileprivate enum Rows: Int, CaseCountable { + case notice = 0 + case active + case recommended + case entry + case deliver + } + + override func viewDidLoad() { + super.viewDidLoad() + // This gets rid of the empty space at the top. + tableView.tableHeaderView = UIView(frame: CGRect(x: 0, y: 0, width: tableView.bounds.size.width, height: 0.01)) + } + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) let spellOutFormatter = NumberFormatter() spellOutFormatter.numberStyle = .spellOut - bolusAmountTextField.accessibilityHint = String(format: NSLocalizedString("Recommended Bolus: %@ Units", comment: "Accessibility hint describing recommended bolus units"), spellOutFormatter.string(from: NSNumber(value: recommendedBolus)) ?? "0") + let amount = bolusRecommendation?.amount ?? 0 + bolusAmountTextField.accessibilityHint = String(format: NSLocalizedString("Recommended Bolus: %@ Units", comment: "Accessibility hint describing recommended bolus units"), spellOutFormatter.string(from: NSNumber(value: amount)) ?? "0") bolusAmountTextField.becomeFirstResponder() - + AnalyticsManager.sharedManager.didDisplayBolusScreen() } - var recommendedBolus: Double = 0 { + func generateActiveInsulinDescription(activeInsulin: Double?, pendingInsulin: Double?) -> String + { + let iobStr: String + if let iob = activeInsulin, let valueStr = insulinFormatter.string(from: NSNumber(value: iob)) + { + iobStr = valueStr + " U" + } else { + iobStr = "-" + } + + var rval = String(format: NSLocalizedString("Active Insulin: %@", comment: "The string format describing active insulin. (1: localized insulin value description)"), iobStr) + + if let pending = pendingInsulin, pending > 0, let pendingStr = insulinFormatter.string(from: NSNumber(value: pending)) + { + rval += String(format: NSLocalizedString(" (pending: %@)", comment: "The string format appended to active insulin that describes pending insulin. (1: pending insulin)"), pendingStr + " U") + } + return rval + } + + // MARK: - State + + var glucoseUnit: HKUnit = HKUnit.milligramsPerDeciliterUnit() + + var loopError: Error? = nil { + didSet { + updateNotice(); + } + } + + var bolusRecommendation: BolusRecommendation? = nil { + didSet { + let amount = bolusRecommendation?.amount ?? 0 + recommendedBolusAmountLabel?.text = bolusUnitsFormatter.string(from: NSNumber(value: amount)) + updateNotice(); + if let pendingInsulin = bolusRecommendation?.pendingInsulin { + self.pendingInsulin = pendingInsulin + } + } + } + + var activeCarbohydratesDescription: String? = nil { + didSet { + activeCarbohydratesLabel?.text = activeCarbohydratesDescription + } + } + + var activeCarbohydrates: Double? = nil { + didSet { + + let cobStr: String + if let cob = activeCarbohydrates, let str = integerFormatter.string(from: NSNumber(value: cob)) { + cobStr = str + " g" + } else { + cobStr = "-" + + } + activeCarbohydratesDescription = String(format: NSLocalizedString("Active Carbohydrates: %@", comment: "The string format describing active carbohydrates. (1: localized glucose value description)"), cobStr) + } + } + + var activeInsulinDescription: String? = nil { + didSet { + activeInsulinLabel?.text = activeInsulinDescription + } + } + + var activeInsulin: Double? = nil { + didSet { + activeInsulinDescription = generateActiveInsulinDescription(activeInsulin: activeInsulin, pendingInsulin: pendingInsulin) + } + } + + var pendingInsulin: Double? = nil { didSet { - recommendedBolusAmountLabel?.text = decimalFormatter.string(from: NSNumber(value: recommendedBolus)) + activeInsulinDescription = generateActiveInsulinDescription(activeInsulin: activeInsulin, pendingInsulin: pendingInsulin) } } + var maxBolus: Double = 25 private(set) var bolus: Double? + + // MARK: - IBOutlets + @IBOutlet weak var recommendedBolusAmountLabel: UILabel? { didSet { - recommendedBolusAmountLabel?.text = decimalFormatter.string(from: NSNumber(value: recommendedBolus)) + let amount = bolusRecommendation?.amount ?? 0 + recommendedBolusAmountLabel?.text = bolusUnitsFormatter.string(from: NSNumber(value: amount)) + } + } + + @IBOutlet weak var noticeLabel: UILabel? { + didSet { + updateNotice(); + } + } + + @IBOutlet weak var activeCarbohydratesLabel: UILabel? { + didSet { + activeCarbohydratesLabel?.text = activeCarbohydratesDescription + } + } + + @IBOutlet weak var activeInsulinLabel: UILabel? { + didSet { + activeInsulinLabel?.text = activeInsulinDescription } } + // MARK: - TableView Delegate + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - if (indexPath.row == 0) { + if case .recommended = Rows(rawValue: indexPath.row)! { acceptRecommendedBolus(); } } override func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { - if (indexPath.row == 0) { + if case .recommended = Rows(rawValue: indexPath.row)! { cell.accessibilityCustomActions = [ UIAccessibilityCustomAction(name: NSLocalizedString("AcceptRecommendedBolus", comment: "Action to copy the recommended Bolus value to the actual Bolus Field"), target: self, selector: #selector(BolusViewController.acceptRecommendedBolus)) ] } } - + @objc func acceptRecommendedBolus() { bolusAmountTextField?.text = recommendedBolusAmountLabel?.text @@ -69,13 +182,13 @@ final class BolusViewController: UITableViewController, IdentifiableClass, UITex @IBAction func authenticateBolus(_ sender: Any) { bolusAmountTextField.resignFirstResponder() - guard let text = bolusAmountTextField?.text, let bolus = decimalFormatter.number(from: text)?.doubleValue, - let amountString = decimalFormatter.string(from: NSNumber(value: bolus)) else { + guard let text = bolusAmountTextField?.text, let bolus = bolusUnitsFormatter.number(from: text)?.doubleValue, + let amountString = bolusUnitsFormatter.string(from: NSNumber(value: bolus)) else { return } guard bolus <= maxBolus else { - presentAlertController(withTitle: NSLocalizedString("Exceeds Maximum Bolus", comment: "The title of the alert describing a maximum bolus validation error"), message: String(format: NSLocalizedString("The maximum bolus amount is %@ Units", comment: "Body of the alert describing a maximum bolus validation error. (1: The localized max bolus value)"), decimalFormatter.string(from: NSNumber(value: maxBolus)) ?? "")) + presentAlertController(withTitle: NSLocalizedString("Exceeds Maximum Bolus", comment: "The title of the alert describing a maximum bolus validation error"), message: String(format: NSLocalizedString("The maximum bolus amount is %@ Units", comment: "Body of the alert describing a maximum bolus validation error. (1: The localized max bolus value)"), bolusUnitsFormatter.string(from: NSNumber(value: maxBolus)) ?? "")) return } @@ -100,7 +213,7 @@ final class BolusViewController: UITableViewController, IdentifiableClass, UITex self.performSegue(withIdentifier: "close", sender: nil) } - private lazy var decimalFormatter: NumberFormatter = { + private lazy var bolusUnitsFormatter: NumberFormatter = { let numberFormatter = NumberFormatter() numberFormatter.maximumSignificantDigits = 3 @@ -109,6 +222,38 @@ final class BolusViewController: UITableViewController, IdentifiableClass, UITex return numberFormatter }() + + private lazy var insulinFormatter: NumberFormatter = { + let numberFormatter = NumberFormatter() + + numberFormatter.numberStyle = .decimal + numberFormatter.minimumFractionDigits = 2 + numberFormatter.maximumFractionDigits = 2 + + return numberFormatter + }() + + private lazy var integerFormatter: NumberFormatter = { + let numberFormatter = NumberFormatter() + + numberFormatter.numberStyle = .none + numberFormatter.maximumFractionDigits = 0 + + return numberFormatter + }() + + private func updateNotice() { + if let error = loopError { + noticeLabel?.text = "⚠ " + error.localizedDescription + } else if let notice = bolusRecommendation?.notice { + noticeLabel?.text = "⚠ " + String(describing: notice) + } else { + noticeLabel?.text = nil + } + } + + + override func prepare(for segue: UIStoryboardSegue, sender: Any?) { super.prepare(for: segue, sender: sender) diff --git a/Loop/View Controllers/GlucoseThresholdTableViewController.swift b/Loop/View Controllers/GlucoseThresholdTableViewController.swift new file mode 100644 index 0000000000..e380d8c721 --- /dev/null +++ b/Loop/View Controllers/GlucoseThresholdTableViewController.swift @@ -0,0 +1,41 @@ +// +// GlucoseThresholdTableViewController.swift +// Loop +// +// Created by Pete Schwamb on 1/1/17. +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import Foundation + +import UIKit +import LoopKit +import HealthKit + + +final class GlucoseThresholdTableViewController: TextFieldTableViewController { + + public let glucoseUnits: HKUnit + + init(threshold: Double?, glucoseUnits: HKUnit) { + self.glucoseUnits = glucoseUnits + + super.init(style: .grouped) + + placeholder = NSLocalizedString("Enter minimum BG guard", comment: "The placeholder text instructing users to enter a minimum BG guard") + keyboardType = .decimalPad + contextHelp = NSLocalizedString("When current or forecasted BG is below miminum BG guard, Loop will not recommend a bolus, and will issue temporary basal rates of 0U/hr.", comment: "Instructions on entering minimum BG threshold") + + unit = glucoseUnits.glucoseUnitDisplayString + + if let threshold = threshold { + value = NumberFormatter.glucoseFormatter(for: glucoseUnits).string(from: NSNumber(value: threshold)) + } + + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + +} diff --git a/Loop/View Controllers/SettingsTableViewController.swift b/Loop/View Controllers/SettingsTableViewController.swift index 765561b359..1a033d76fc 100644 --- a/Loop/View Controllers/SettingsTableViewController.swift +++ b/Loop/View Controllers/SettingsTableViewController.swift @@ -76,60 +76,49 @@ final class SettingsTableViewController: UITableViewController, DailyValueSchedu } } - fileprivate enum Section: Int { + fileprivate enum Section: Int, CaseCountable { case loop = 0 case devices case pump case cgm case configuration case services - - static let count = 6 } - fileprivate enum LoopRow: Int { + fileprivate enum LoopRow: Int, CaseCountable { case dosing = 0 case preferredInsulinDataSource case diagnostic - - static let count = 3 } - fileprivate enum PumpRow: Int { + fileprivate enum PumpRow: Int, CaseCountable { case pumpID = 0 case batteryChemistry - - static let count = 2 } - fileprivate enum CGMRow: Int { + fileprivate enum CGMRow: Int, CaseCountable { case fetchEnlite = 0 case receiverEnabled case transmitterEnabled case transmitterID // optional, only displayed if transmitterEnabled - - static let count = 4 } - fileprivate enum ConfigurationRow: Int { + fileprivate enum ConfigurationRow: Int, CaseCountable { case glucoseTargetRange = 0 + case minimumBGGuard case insulinActionDuration case basalRate case carbRatio case insulinSensitivity case maxBasal case maxBolus - - static let count = 7 } - fileprivate enum ServiceRow: Int { + fileprivate enum ServiceRow: Int, CaseCountable { case share = 0 case nightscout case mLab case amplitude - - static let count = 4 } fileprivate lazy var valueNumberFormatter: NumberFormatter = { @@ -141,7 +130,7 @@ final class SettingsTableViewController: UITableViewController, DailyValueSchedu return formatter }() - + // MARK: - UITableViewDataSource override func numberOfSections(in tableView: UITableView) -> Int { @@ -305,6 +294,15 @@ final class SettingsTableViewController: UITableViewController, DailyValueSchedu } else { configCell.detailTextLabel?.text = TapToSetString } + case .minimumBGGuard: + configCell.textLabel?.text = NSLocalizedString("Minimum BG Guard", comment: "The title text for the minimum bg guard setting") + + if let minimumBGGuard = dataManager.minimumBGGuard { + let value = valueNumberFormatter.string(from: NSNumber(value: minimumBGGuard.value)) ?? "-" + configCell.detailTextLabel?.text = String(format: NSLocalizedString("%1$@ %2$@", comment: "Format string for minimum bg guard. (1: value)(2: bg unit)"), value, minimumBGGuard.unit.glucoseUnitDisplayString) + } else { + configCell.detailTextLabel?.text = TapToSetString + } case .insulinActionDuration: configCell.textLabel?.text = NSLocalizedString("Insulin Action Duration", comment: "The title text for the insulin action duration value") @@ -566,6 +564,28 @@ final class SettingsTableViewController: UITableViewController, DailyValueSchedu } else { show(scheduleVC, sender: sender) } + case .minimumBGGuard: + if let minBGGuard = dataManager.minimumBGGuard { + let vc = GlucoseThresholdTableViewController(threshold: minBGGuard.value, glucoseUnits: minBGGuard.unit) + vc.delegate = self + vc.indexPath = indexPath + vc.title = sender?.textLabel?.text + self.show(vc, sender: sender) + } else if let glucoseStore = dataManager.glucoseStore { + glucoseStore.preferredUnit({ (unit, error) -> Void in + DispatchQueue.main.async { + if let error = error { + self.presentAlertController(with: error) + } else if let unit = unit { + let vc = GlucoseThresholdTableViewController(threshold: nil, glucoseUnits: unit) + vc.delegate = self + vc.indexPath = indexPath + vc.title = sender?.textLabel?.text + self.show(vc, sender: sender) + } + } + }) + } } case .devices: let vc = RileyLinkDeviceTableViewController() @@ -817,6 +837,13 @@ extension SettingsTableViewController: TextFieldTableViewControllerDelegate { } case .configuration: switch ConfigurationRow(rawValue: indexPath.row)! { + case .minimumBGGuard: + if let controller = controller as? GlucoseThresholdTableViewController, + let value = controller.value, let minBGGuard = valueNumberFormatter.number(from: value)?.doubleValue { + dataManager.minimumBGGuard = GlucoseThreshold(unit: controller.glucoseUnits, value: minBGGuard) + } else { + dataManager.minimumBGGuard = nil + } case .insulinActionDuration: if let value = controller.value, let duration = valueNumberFormatter.number(from: value)?.doubleValue { dataManager.insulinActionDuration = TimeInterval(hours: duration) diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 94c3cb4ff8..e970fec0ba 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -663,19 +663,28 @@ final class StatusTableViewController: UITableViewController, UIGestureRecognize vc.maxBolus = maxBolus } - if let bolus = sender as? Double { - vc.recommendedBolus = bolus + if let recommendation = sender as? BolusRecommendation { + vc.bolusRecommendation = recommendation } else { - self.dataManager.loopManager.getRecommendedBolus { (units, error) -> Void in + self.dataManager.loopManager.getRecommendedBolus { (recommendation, error) -> Void in if let error = error { self.dataManager.logger.addError(error, fromSource: "Bolus") - } else if let bolus = units { + } else if let recommendation = recommendation { DispatchQueue.main.async { - vc.recommendedBolus = bolus + vc.bolusRecommendation = recommendation } } } } + self.dataManager.loopManager.getLoopStatus({ (_, _, _, _, _, iob, cob, error) in + DispatchQueue.main.async { + vc.glucoseUnit = self.charts.glucoseUnit + vc.activeInsulin = iob?.value + vc.activeCarbohydrates = cob?.quantity.doubleValue(for: HKUnit.gram()) + vc.loopError = error + } + }) + case let vc as PredictionTableViewController: vc.dataManager = dataManager case let vc as SettingsTableViewController: @@ -691,7 +700,7 @@ final class StatusTableViewController: UITableViewController, UIGestureRecognize @IBAction func unwindFromEditing(_ segue: UIStoryboardSegue) { if let carbVC = segue.source as? CarbEntryEditViewController, let updatedEntry = carbVC.updatedCarbEntry { - dataManager.loopManager.addCarbEntryAndRecommendBolus(updatedEntry) { (units, error) -> Void in + dataManager.loopManager.addCarbEntryAndRecommendBolus(updatedEntry) { (recommendation, error) -> Void in DispatchQueue.main.async { if let error = error { // Ignore bolus wizard errors @@ -702,8 +711,8 @@ final class StatusTableViewController: UITableViewController, UIGestureRecognize self.needsRefresh = true self.reloadData() } - } else if self.active && self.visible, let bolus = units, bolus > 0 { - self.performSegue(withIdentifier: BolusViewController.className, sender: bolus) + } else if self.active && self.visible, let bolus = recommendation?.amount, bolus > 0 { + self.performSegue(withIdentifier: BolusViewController.className, sender: recommendation) self.needsRefresh = true } else { self.needsRefresh = true diff --git a/Loop/View Controllers/TextFieldTableViewController.swift b/Loop/View Controllers/TextFieldTableViewController.swift index 501add03b0..43d28c9087 100644 --- a/Loop/View Controllers/TextFieldTableViewController.swift +++ b/Loop/View Controllers/TextFieldTableViewController.swift @@ -7,12 +7,13 @@ // import LoopKit +import HealthKit /// Convenience static constructors used to contain common configuration extension TextFieldTableViewController { typealias T = TextFieldTableViewController - + private static let valueNumberFormatter: NumberFormatter = { let formatter = NumberFormatter() @@ -84,5 +85,5 @@ extension TextFieldTableViewController { } return vc - } + } } diff --git a/LoopUI/Extensions/NSNumberFormatter.swift b/LoopUI/Extensions/NSNumberFormatter.swift index 155738f3da..cab0eaf927 100644 --- a/LoopUI/Extensions/NSNumberFormatter.swift +++ b/LoopUI/Extensions/NSNumberFormatter.swift @@ -13,9 +13,30 @@ import HealthKit extension NumberFormatter { public static func glucoseFormatter(for unit: HKUnit) -> NumberFormatter { let numberFormatter = NumberFormatter() + numberFormatter.numberStyle = .decimal numberFormatter.minimumFractionDigits = unit.preferredFractionDigits numberFormatter.maximumFractionDigits = unit.preferredFractionDigits return numberFormatter } + + public func describingGlucose(_ value: Double, for unit: HKUnit) -> String? { + guard let stringValue = string(from: NSNumber(value: value)) else { + return nil + } + + return String( + format: NSLocalizedString("GLUCOSE_VALUE_AND_UNIT", + value: "%1$@ %2$@", + comment: "Format string for combining localized glucose value and unit. (1: glucose value)(2: unit)" + ), + stringValue, + unit.glucoseUnitDisplayString + ) + } + + @nonobjc public func describingGlucose(_ value: HKQuantity, for unit: HKUnit) -> String? { + return describingGlucose(value.doubleValue(for: unit), for: unit) + } + }