diff --git a/Common/Extensions/NSUserDefaults.swift b/Common/Extensions/NSUserDefaults.swift index 4ba5a594f5..8f17af2373 100644 --- a/Common/Extensions/NSUserDefaults.swift +++ b/Common/Extensions/NSUserDefaults.swift @@ -122,9 +122,11 @@ extension UserDefaults { let settings = LoopSettings( dosingEnabled: bool(forKey: "com.loudnate.Naterade.DosingEnabled"), + bolusEnabled: false, glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, maximumBasalRatePerHour: maximumBasalRatePerHour, maximumBolus: maximumBolus, + maximumInsulinOnBoard: nil, suspendThreshold: suspendThreshold, retrospectiveCorrectionEnabled: bool(forKey: "com.loudnate.Loop.RetrospectiveCorrectionEnabled") ) diff --git a/Common/Models/LoopSettings.swift b/Common/Models/LoopSettings.swift index fbab2c1656..48daaef017 100644 --- a/Common/Models/LoopSettings.swift +++ b/Common/Models/LoopSettings.swift @@ -12,6 +12,8 @@ import RileyLinkBLEKit struct LoopSettings { var dosingEnabled = false + var bolusEnabled = false + let dynamicCarbAbsorptionEnabled = true var glucoseTargetRangeSchedule: GlucoseRangeSchedule? @@ -19,11 +21,18 @@ struct LoopSettings { var maximumBasalRatePerHour: Double? var maximumBolus: Double? + + var maximumInsulinOnBoard: Double? var suspendThreshold: GlucoseThreshold? = nil var retrospectiveCorrectionEnabled = true + // For the automated Bolus functionality. + let automatedBolusThreshold: Double = 0.1 + let automatedBolusRatio: Double = 0.7 + let automaticBolusInterval: TimeInterval = TimeInterval(minutes: 7) + let retrospectiveCorrectionInterval = TimeInterval(minutes: 30) /// The amount of time since a given date that data should be considered valid @@ -52,6 +61,10 @@ extension LoopSettings: RawRepresentable { if let dosingEnabled = rawValue["dosingEnabled"] as? Bool { self.dosingEnabled = dosingEnabled } + + if let bolusEnabled = rawValue["bolusEnabled"] as? Bool { + self.bolusEnabled = bolusEnabled + } if let rawValue = rawValue["glucoseTargetRangeSchedule"] as? GlucoseRangeSchedule.RawValue { self.glucoseTargetRangeSchedule = GlucoseRangeSchedule(rawValue: rawValue) @@ -59,6 +72,7 @@ extension LoopSettings: RawRepresentable { self.maximumBasalRatePerHour = rawValue["maximumBasalRatePerHour"] as? Double + self.maximumInsulinOnBoard = rawValue["maximumInsulinOnBoard"] as? Double self.maximumBolus = rawValue["maximumBolus"] as? Double if let rawThreshold = rawValue["minimumBGGuard"] as? GlucoseThreshold.RawValue { @@ -74,11 +88,13 @@ extension LoopSettings: RawRepresentable { var raw: RawValue = [ "version": LoopSettings.version, "dosingEnabled": dosingEnabled, + "bolusEnabled": bolusEnabled, "retrospectiveCorrectionEnabled": retrospectiveCorrectionEnabled ] raw["glucoseTargetRangeSchedule"] = glucoseTargetRangeSchedule?.rawValue raw["maximumBasalRatePerHour"] = maximumBasalRatePerHour + raw["maximumInsulinOnBoard"] = maximumInsulinOnBoard raw["maximumBolus"] = maximumBolus raw["minimumBGGuard"] = suspendThreshold?.rawValue diff --git a/DoseMathTests/DoseMathTests.swift b/DoseMathTests/DoseMathTests.swift index 9b2718701a..9d807c9df5 100644 --- a/DoseMathTests/DoseMathTests.swift +++ b/DoseMathTests/DoseMathTests.swift @@ -100,6 +100,14 @@ class RecommendTempBasalTests: XCTestCase { return TimeInterval(hours: 4) } + var insulinOnBoard: Double { + return 0 + } + + var maxInsulinOnBoard: Double { + return 25 + } + func testNoChange() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_no_change_glucose") @@ -111,6 +119,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -128,6 +138,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -150,6 +162,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: lastTempBasal ) @@ -168,6 +182,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -189,6 +205,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: lastTempBasal ) @@ -216,6 +234,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: lastTempBasal ) @@ -230,6 +250,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -247,6 +269,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -265,6 +289,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -286,6 +312,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: lastTempBasal ) @@ -304,13 +332,75 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) - + // Basal 0.8, 1.1 units required -> 2.2 units extra for 30 minutes XCTAssertEqual(3.0, dose!.unitsPerHour) XCTAssertEqual(TimeInterval(minutes: 30), dose!.duration) } + func testFlatAndHighLimitIob() { + let glucose = loadGlucoseValueFixture("recommend_temp_basal_flat_and_high") + + let dose = glucose.recommendedTempBasal( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: suspendThreshold.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + basalRates: basalRateSchedule, + maxBasalRate: maxBasalRate, + insulinOnBoard: 0, + maxInsulinOnBoard: 1, + lastTempBasal: nil + ) + // Basal 0.8, 1.1 units required, limited to 1 -> 2 units extra for 30 minutes = 2.8 + XCTAssertEqual(2.8, dose!.unitsPerHour) + XCTAssertEqual(TimeInterval(minutes: 30), dose!.duration) + } + + func testFlatAndHighLimitIobWithOnboard() { + let glucose = loadGlucoseValueFixture("recommend_temp_basal_flat_and_high") + + let dose = glucose.recommendedTempBasal( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: suspendThreshold.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + basalRates: basalRateSchedule, + maxBasalRate: maxBasalRate, + insulinOnBoard: 1.5, + maxInsulinOnBoard: 2, + lastTempBasal: nil + ) + // Basal 0.8, 1.1 units required, limited to 0.5 -> 1 units extra for 30 minutes = 1.8 + XCTAssertEqual(1.8, dose!.unitsPerHour) + XCTAssertEqual(TimeInterval(minutes: 30), dose!.duration) + } + + func testFlatAndHighLimitIobExceeded() { + let glucose = loadGlucoseValueFixture("recommend_temp_basal_flat_and_high") + + let dose = glucose.recommendedTempBasal( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: suspendThreshold.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + basalRates: basalRateSchedule, + maxBasalRate: maxBasalRate, + insulinOnBoard: 2.5, + maxInsulinOnBoard: 2, + lastTempBasal: nil + ) + // If the IOB is exceeded the rate is limited to the default + // basal rate. + XCTAssertNil(dose) + } + func testHighAndFalling() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_high_and_falling") @@ -322,6 +412,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -340,6 +432,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -358,6 +452,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -375,6 +471,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -393,6 +491,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -411,6 +511,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -428,6 +530,8 @@ class RecommendTempBasalTests: XCTestCase { model: insulinModel, basalRates: basalRateSchedule, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, lastTempBasal: nil ) @@ -486,6 +590,14 @@ class RecommendBolusTests: XCTestCase { return TimeInterval(hours: 4) } + var insulinOnBoard: Double { + return 0 + } + + var maxInsulinOnBoard: Double { + return 25 + } + func testNoChange() { let glucose = loadGlucoseValueFixture("recommend_temp_basal_no_change_glucose") @@ -496,7 +608,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0, dose.amount) @@ -512,7 +626,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0, dose.amount) @@ -528,7 +644,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0, dose.amount) @@ -544,7 +662,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0, dose.amount) @@ -560,7 +680,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(1.575, dose.amount) @@ -572,6 +694,78 @@ class RecommendBolusTests: XCTestCase { } } + func testStartLowEndHighLimitIob() { + let glucose = loadGlucoseValueFixture("recommend_temp_basal_start_low_end_high") + + let dose = glucose.recommendedBolus( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: suspendThreshold.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + pendingInsulin: 0, + maxBolus: maxBolus, + insulinOnBoard: 0, + maxInsulinOnBoard: 1.3 + ) + + XCTAssertEqual(1.3, dose.amount) + + if case BolusRecommendationNotice.currentGlucoseBelowTarget(let glucose) = dose.notice! { + XCTAssertEqual(glucose.quantity.doubleValue(for: .milligramsPerDeciliter), 60) + } else { + XCTFail("Expected currentGlucoseBelowTarget, but got \(dose.notice!)") + } + } + + func testStartLowEndHighLimitIobWithOnboard() { + let glucose = loadGlucoseValueFixture("recommend_temp_basal_start_low_end_high") + + let dose = glucose.recommendedBolus( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: suspendThreshold.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + pendingInsulin: 0, + maxBolus: maxBolus, + insulinOnBoard: 1.0, + maxInsulinOnBoard: 1.3 + ) + + XCTAssertEqual(0.3, dose.amount) + + if case BolusRecommendationNotice.currentGlucoseBelowTarget(let glucose) = dose.notice! { + XCTAssertEqual(glucose.quantity.doubleValue(for: .milligramsPerDeciliter), 60) + } else { + XCTFail("Expected currentGlucoseBelowTarget, but got \(dose.notice!)") + } + } + + func testStartLowEndHighLimitIobExceeded() { + let glucose = loadGlucoseValueFixture("recommend_temp_basal_start_low_end_high") + + let dose = glucose.recommendedBolus( + to: glucoseTargetRange, + at: glucose.first!.startDate, + suspendThreshold: suspendThreshold.quantity, + sensitivity: insulinSensitivitySchedule, + model: insulinModel, + pendingInsulin: 0, + maxBolus: maxBolus, + insulinOnBoard: 2.0, + maxInsulinOnBoard: 1.3 + ) + + XCTAssertEqual(0, dose.amount) + + if case BolusRecommendationNotice.currentGlucoseBelowTarget(let glucose) = dose.notice! { + XCTAssertEqual(glucose.quantity.doubleValue(for: .milligramsPerDeciliter), 60) + } else { + XCTFail("Expected currentGlucoseBelowTarget, but got \(dose.notice!)") + } + } + func testStartBelowSuspendThresholdEndHigh() { // 60 - 200 mg/dL let glucose = loadGlucoseValueFixture("recommend_temp_basal_start_low_end_high") @@ -583,7 +777,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0, dose.amount) @@ -606,7 +802,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0, dose.amount) @@ -628,7 +826,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(1.4, dose.amount) @@ -646,7 +846,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 1, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0.575, dose.amount) @@ -662,7 +864,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0, dose.amount) @@ -678,7 +882,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(1.575, dose.amount, accuracy: 1.0 / 40.0) @@ -694,7 +900,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0.325, dose.amount, accuracy: 1.0 / 40.0) @@ -710,7 +918,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0.325, dose.amount, accuracy: 1.0 / 40.0) @@ -724,7 +934,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0.8, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0, dose.amount, accuracy: .ulpOfOne) @@ -740,7 +952,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: ExponentialInsulinModel(actionDuration: 21600.0, peakActivityTime: 4500.0), pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0.275, dose.amount) @@ -756,7 +970,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: self.insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(1.25, dose.amount) @@ -771,7 +987,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(1.25, dose.amount, accuracy: 1.0 / 40.0) @@ -787,7 +1005,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0.0, dose.amount) @@ -803,7 +1023,9 @@ class RecommendBolusTests: XCTestCase { sensitivity: insulinSensitivitySchedule, model: insulinModel, pendingInsulin: 0, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard ) XCTAssertEqual(0, dose.amount) diff --git a/Loop/Managers/DeviceDataManager.swift b/Loop/Managers/DeviceDataManager.swift index d4a6d294a1..0b61ea0a0b 100644 --- a/Loop/Managers/DeviceDataManager.swift +++ b/Loop/Managers/DeviceDataManager.swift @@ -417,6 +417,7 @@ final class DeviceDataManager { return } + ops.runSession(withName: "Fetch Pump History", using: device) { (session) in do { let startDate = self.loopManager.doseStore.pumpEventQueryAfterDate @@ -536,17 +537,21 @@ final class DeviceDataManager { /// Send a bolus command and handle the result /// /// - parameter units: The number of units to deliver + /// - parameter startDate: The start of the bolus. + /// - parameter quiet: Do not produce a notification of failure to the user. /// - parameter completion: A clsure called after the command is complete. This closure takes a single argument: /// - error: An error describing why the command failed - func enactBolus(units: Double, at startDate: Date = Date(), completion: @escaping (_ error: Error?) -> Void) { + func enactBolus(units: Double, at startDate: Date = Date(), quiet : Bool = false, completion: @escaping (_ error: Error?) -> Void) { + let notify = { (error: Error?) -> Void in if let error = error { - NotificationManager.sendBolusFailureNotification(for: error, units: units, at: startDate) + if !quiet { + NotificationManager.sendBolusFailureNotification(for: error, units: units, at: startDate) + } } - completion(error) } - + guard units > 0 else { notify(nil) return @@ -557,8 +562,21 @@ final class DeviceDataManager { return } + guard let recencyInterval = loopManager?.settings.recencyInterval else { + notify(LoopError.configurationError("LoopManager")) + return + } + // If we don't have recent pump data, or the pump was recently rewound, read new pump data before bolusing. - let shouldReadReservoir = isReservoirDataOlderThan(timeIntervalSinceNow: .minutes(-6)) + var shouldReadReservoir = isReservoirDataOlderThan(timeIntervalSinceNow: .minutes(-10)) + if let reservoir = loopManager.doseStore.lastReservoirValue, reservoir.startDate.timeIntervalSinceNow <= + -recencyInterval { + notify(LoopError.pumpDataTooOld(date: reservoir.startDate)) + shouldReadReservoir = true + } else if loopManager.doseStore.lastReservoirValue == nil { + notify(LoopError.missingDataError(details: "Reservoir Value missing", recovery: "Keep phone close.")) + shouldReadReservoir = true + } ops.runSession(withName: "Bolus", using: rileyLinkManager.firstConnectedDevice) { (session) in guard let session = session else { @@ -567,6 +585,9 @@ final class DeviceDataManager { } if shouldReadReservoir { + // TODO it might be safer to return here and not give a Bolus + // forcing the recalculation of recommendedBolus. The new + // data might have invalidated the old recommendation. do { let reservoir = try session.getRemainingInsulin() @@ -911,6 +932,25 @@ extension DeviceDataManager: LoopDataManagerDelegate { } } } + + func loopDataManager(_ manager: LoopDataManager, didRecommendBolus bolus: (recommendation: BolusRecommendation, date: Date), completion: @escaping (_ result: Result) -> Void) { + + enactBolus(units: bolus.recommendation.amount, quiet: true) { (error) in + if let error = error { + completion(.failure(error)) + } else { + let now = Date() + completion(.success(DoseEntry( + type: .bolus, + startDate: now, + endDate: now, + value: bolus.recommendation.amount, + unit: .units + ))) + } + + } + } } diff --git a/Loop/Managers/DoseMath.swift b/Loop/Managers/DoseMath.swift index a7c26ef130..c0e3117a70 100644 --- a/Loop/Managers/DoseMath.swift +++ b/Loop/Managers/DoseMath.swift @@ -37,15 +37,20 @@ extension InsulinCorrection { /// - Parameters: /// - scheduledBasalRate: The scheduled basal rate at the time the correction is delivered /// - maxBasalRate: The maximum allowed basal rate + /// - insulinOnBoard: The current insulin on board + /// - maxInsulinOnBoard: The maximum insulin allowed /// - duration: The duration of the temporary basal /// - minimumProgrammableIncrementPerUnit: The smallest fraction of a unit supported in basal delivery /// - Returns: A temp basal recommendation fileprivate func asTempBasal( scheduledBasalRate: Double, maxBasalRate: Double, + insulinOnBoard: Double, + maxInsulinOnBoard: Double, duration: TimeInterval, minimumProgrammableIncrementPerUnit: Double ) -> TempBasalRecommendation { + let units = Swift.min(self.units, Swift.max(0, maxInsulinOnBoard - insulinOnBoard)) var rate = units / (duration / TimeInterval(hours: 1)) // units/hour switch self { case .aboveRange, .inRange, .entirelyBelowRange: @@ -83,15 +88,20 @@ extension InsulinCorrection { /// - Parameters: /// - pendingInsulin: The number of units expected to be delivered, but not yet reflected in the correction /// - maxBolus: The maximum allowable bolus value in units + /// - insulinOnBoard: The current insulin on board + /// - maxInsulinOnBoard: The maximum insulin allowed /// - minimumProgrammableIncrementPerUnit: The smallest fraction of a unit supported in bolus delivery /// - Returns: A bolus recommendation fileprivate func asBolus( pendingInsulin: Double, maxBolus: Double, + insulinOnBoard: Double, + maxInsulinOnBoard: Double, minimumProgrammableIncrementPerUnit: Double ) -> BolusRecommendation { - var units = self.units - pendingInsulin - units = Swift.min(maxBolus, Swift.max(0, units)) + let netUnits = self.units - pendingInsulin + var units = Swift.min(maxBolus, Swift.max(0, netUnits)) + units = Swift.min(units, Swift.max(0, maxInsulinOnBoard - insulinOnBoard)) units = round(units * minimumProgrammableIncrementPerUnit) / minimumProgrammableIncrementPerUnit return BolusRecommendation( @@ -345,7 +355,10 @@ extension Collection where Iterator.Element == GlucoseValue { /// - model: The insulin absorption model /// - basalRates: The schedule of basal rates /// - maxBasalRate: The maximum allowed basal rate + /// - insulinOnBoard: The current insulin on board + /// - maxInsulinOnBoard: The maximum insulin allowed /// - lastTempBasal: The previously set temp basal + /// - lowerOnly: Only return lower basal rates, never higher /// - duration: The duration of the temporary basal /// - minimumProgrammableIncrementPerUnit: The smallest fraction of a unit supported in basal delivery /// - continuationInterval: The duration of time before an ongoing temp basal should be continued with a new command @@ -358,7 +371,10 @@ extension Collection where Iterator.Element == GlucoseValue { model: InsulinModel, basalRates: BasalRateSchedule, maxBasalRate: Double, + insulinOnBoard: Double, + maxInsulinOnBoard: Double, lastTempBasal: DoseEntry?, + lowerOnly: Bool = false, duration: TimeInterval = .minutes(30), minimumProgrammableIncrementPerUnit: Double = 40, continuationInterval: TimeInterval = .minutes(11) @@ -381,9 +397,15 @@ extension Collection where Iterator.Element == GlucoseValue { maxBasalRate = scheduledBasalRate } + if lowerOnly { + maxBasalRate = scheduledBasalRate + } + let temp = correction?.asTempBasal( scheduledBasalRate: scheduledBasalRate, maxBasalRate: maxBasalRate, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, duration: duration, minimumProgrammableIncrementPerUnit: minimumProgrammableIncrementPerUnit ) @@ -406,6 +428,8 @@ extension Collection where Iterator.Element == GlucoseValue { /// - model: The insulin absorption model /// - pendingInsulin: The number of units expected to be delivered, but not yet reflected in the correction /// - maxBolus: The maximum bolus to return + /// - insulinOnBoard: The current insulin on board + /// - maxInsulinOnBoard: The maximum insulin allowed /// - minimumProgrammableIncrementPerUnit: The smallest fraction of a unit supported in bolus delivery /// - Returns: A bolus recommendation func recommendedBolus( @@ -416,6 +440,8 @@ extension Collection where Iterator.Element == GlucoseValue { model: InsulinModel, pendingInsulin: Double, maxBolus: Double, + insulinOnBoard: Double, + maxInsulinOnBoard: Double, minimumProgrammableIncrementPerUnit: Double = 40 ) -> BolusRecommendation { guard let correction = self.insulinCorrection( @@ -431,6 +457,8 @@ extension Collection where Iterator.Element == GlucoseValue { var bolus = correction.asBolus( pendingInsulin: pendingInsulin, maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard, + maxInsulinOnBoard: maxInsulinOnBoard, minimumProgrammableIncrementPerUnit: minimumProgrammableIncrementPerUnit ) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 2699c3881c..512b04f624 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -144,6 +144,11 @@ final class LoopDataManager { predictedGlucose = nil } } + private var insulinOnBoard: InsulinValue? { + didSet { + predictedGlucose = nil + } + } private var glucoseMomentumEffect: [GlucoseEffect]? { didSet { predictedGlucose = nil @@ -165,6 +170,7 @@ final class LoopDataManager { fileprivate var predictedGlucose: [GlucoseValue]? { didSet { recommendedTempBasal = nil + recommendedBolus = nil } } fileprivate var retrospectivePredictedGlucose: [GlucoseValue]? { @@ -178,7 +184,13 @@ final class LoopDataManager { fileprivate var carbsOnBoard: CarbValue? fileprivate var lastTempBasal: DoseEntry? - fileprivate var lastRequestedBolus: (units: Double, date: Date)? + fileprivate var lastRequestedBolus: (units: Double, date: Date, reservoir: ReservoirValue?)? + + // Keep track of last automatic bolus to space them out + fileprivate var lastAutomaticBolus : Date? = nil + + // Keeps track of last carb change to prevent automatic bolus immediately afterwards. + fileprivate var lastCarbChange : Date? = nil /// The last date at which a loop completed, from prediction to dose (if dosing is enabled) var lastLoopCompleted: Date? { @@ -456,7 +468,9 @@ extension LoopDataManager { /// - date: The date the bolus was requested func addRequestedBolus(units: Double, at date: Date, completion: (() -> Void)?) { dataAccessQueue.async { - self.lastRequestedBolus = (units: units, date: date) + NSLog("addRequestedBolus: \(units) \(date)") + self.recommendedBolus = nil + self.lastRequestedBolus = (units: units, date: date, reservoir: self.doseStore.lastReservoirValue) self.notify(forChange: .bolus) completion?() @@ -469,12 +483,21 @@ extension LoopDataManager { /// - units: The bolus amount, in units /// - date: The date the bolus was enacted func addConfirmedBolus(units: Double, at date: Date, completion: (() -> Void)?) { - self.doseStore.addPendingPumpEvent(.enactedBolus(units: units, at: date)) { + let event = NewPumpEvent.enactedBolus(units: units, at: date) + NSLog("addConfirmedBolus: \(units) \(date)") + self.doseStore.addPendingPumpEvent(event) { self.dataAccessQueue.async { self.lastRequestedBolus = nil + self.lastAutomaticBolus = date // keep this as a date, irrespective of automatic or not + self.recommendedBolus = nil self.insulinEffect = nil - self.notify(forChange: .bolus) + self.notify(forChange: .bolus) + do { + try self.update() + } catch let error { + NSLog("addConfirmedBolus: Update after confirmed bolus failed \(error)") + } completion?() } } @@ -569,11 +592,23 @@ extension LoopDataManager { if let error = error { self.logger.error(error) } else { - self.lastLoopCompleted = Date() + if self.settings.bolusEnabled { + // Have to do a bolus first. + self.setAutomatedBolus { (error) -> Void in + if let error = error { + self.logger.error(error) + } else { + self.lastLoopCompleted = Date() + } + } + } else { + // No automatic Bolus, we are done. + self.lastLoopCompleted = Date() + } } self.notify(forChange: .tempBasal) } - + // Delay the notification until we know the result of the temp basal return } else { @@ -691,6 +726,25 @@ extension LoopDataManager { updateGroup.leave() } } + + if insulinOnBoard == nil { + updateGroup.enter() + let now = Date() + doseStore.getInsulinOnBoardValues(start: retrospectiveStart, end: now) { (result) in + switch result { + case .success(let value): + if let recentValue = value.closestPriorToDate(now) { + self.insulinOnBoard = recentValue + } else { + self.insulinOnBoard = InsulinValue(startDate: now, value: 0.0) + } + case .failure(let error): + self.logger.error(error) + self.insulinOnBoard = nil + } + updateGroup.leave() + } + } _ = updateGroup.wait(timeout: .distantFuture) @@ -892,6 +946,7 @@ extension LoopDataManager { guard let maxBasal = settings.maximumBasalRatePerHour, + let maximumInsulinOnBoard = settings.maximumInsulinOnBoard, let glucoseTargetRange = settings.glucoseTargetRangeSchedule, let insulinSensitivity = insulinSensitivitySchedule, let basalRates = basalRateSchedule, @@ -901,6 +956,14 @@ extension LoopDataManager { throw LoopError.configurationError("Check settings") } + let pendingInsulin = try self.getPendingInsulin() + + guard let + insulinOnBoard = insulinOnBoard + else { + throw LoopError.missingDataError(details: "Insulin on Board not available", recovery: "Check pump") + } + guard lastRequestedBolus == nil else { // Don't recommend changes if a bolus was just requested. @@ -910,7 +973,7 @@ extension LoopDataManager { recommendedTempBasal = nil return } - + let tempBasal = predictedGlucose.recommendedTempBasal( to: glucoseTargetRange, suspendThreshold: settings.suspendThreshold?.quantity, @@ -918,24 +981,27 @@ extension LoopDataManager { model: model, basalRates: basalRates, maxBasalRate: maxBasal, - lastTempBasal: lastTempBasal + insulinOnBoard: insulinOnBoard.value, + maxInsulinOnBoard: maximumInsulinOnBoard, + lastTempBasal: lastTempBasal, + lowerOnly: settings.bolusEnabled ) - + if let temp = tempBasal { recommendedTempBasal = (recommendation: temp, date: startDate) } else { recommendedTempBasal = nil } - let pendingInsulin = try self.getPendingInsulin() - let recommendation = predictedGlucose.recommendedBolus( to: glucoseTargetRange, suspendThreshold: settings.suspendThreshold?.quantity, sensitivity: insulinSensitivity, model: model, pendingInsulin: pendingInsulin, - maxBolus: maxBolus + maxBolus: maxBolus, + insulinOnBoard: insulinOnBoard.value, + maxInsulinOnBoard: maximumInsulinOnBoard ) recommendedBolus = (recommendation: recommendation, date: startDate) } @@ -968,6 +1034,71 @@ extension LoopDataManager { } } } + + /// *This method should only be called from the `dataAccessQueue`* + private func setAutomatedBolus(_ completion: @escaping (_ error: Error?) -> Void) { + dispatchPrecondition(condition: .onQueue(dataAccessQueue)) + + guard let recommendedBolus = self.recommendedBolus else { + completion(nil) + NSLog("setAutomatedBolus - recommendation not available") + return + } + + let safeAmount = round(recommendedBolus.recommendation.amount * settings.automatedBolusRatio * 10) / 10 + if safeAmount < settings.automatedBolusThreshold { + completion(nil) + NSLog("setAutomatedBolus - recommendation below threshold") + return + } + + guard abs(recommendedBolus.date.timeIntervalSinceNow) < TimeInterval(minutes: 5) else { + completion(LoopError.recommendationExpired(date: recommendedBolus.date)) + NSLog("setAutomatedBolus - recommendation too old") + return + } + + if let lastAutomaticBolus = self.lastAutomaticBolus, abs(lastAutomaticBolus.timeIntervalSinceNow) < settings.automaticBolusInterval { + NSLog("setAutomatedBolus - last automatic bolus too close") + completion(nil) + return + } + + if let carbChange = lastCarbChange { + guard abs(carbChange.timeIntervalSinceNow) > TimeInterval(minutes: 2) else { + NSLog("setAutomatedBolus - last carbchange too close") + completion(nil) + return + } + } + + // TODO lastRequestedBolus might not ever get cleared, thus we need to check for the date here. + if lastRequestedBolus != nil { + NSLog("setAutomatedBolus - lastRequestedBolus still in progress \(String(describing: lastRequestedBolus))") + completion(nil) + return + } + + // Copy bolus with "safe" ratio + let automatedBolus = (recommendation: BolusRecommendation(amount: safeAmount , pendingInsulin: recommendedBolus.recommendation.pendingInsulin, notice: recommendedBolus.recommendation.notice ), date: recommendedBolus.date) + self.recommendedBolus = nil + lastAutomaticBolus = Date() + + delegate?.loopDataManager(self, didRecommendBolus: automatedBolus) { (result) in + self.dataAccessQueue.async { + switch result { + case .success(let bolus): + NSLog("setAutomatedBolus - success: \(bolus)") + self.recommendedBolus = nil + completion(nil) + case .failure(let error): + completion(error) + } + } + } + } + + } @@ -976,6 +1107,8 @@ protocol LoopState { /// The last-calculated carbs on board var carbsOnBoard: CarbValue? { get } + var insulinOnBoard: InsulinValue? { get } + /// An error in the current state of the loop, or one that happened during the last attempt to loop. var error: Error? { get } @@ -1021,6 +1154,11 @@ extension LoopDataManager { dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) return loopDataManager.carbsOnBoard } + + var insulinOnBoard: InsulinValue? { + dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) + return loopDataManager.insulinOnBoard + } var error: Error? { dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) @@ -1115,6 +1253,7 @@ extension LoopDataManager { "retrospectivePredictedGlucose: \(state.retrospectivePredictedGlucose ?? [])", "glucoseMomentumEffect: \(manager.glucoseMomentumEffect ?? [])", "retrospectiveGlucoseEffect: \(manager.retrospectiveGlucoseEffect)", + "insulinOnBoard: \(String(describing: state.insulinOnBoard))", "recommendedTempBasal: \(String(describing: state.recommendedTempBasal))", "recommendedBolus: \(String(describing: state.recommendedBolus))", "lastBolus: \(String(describing: manager.lastRequestedBolus))", @@ -1144,6 +1283,7 @@ extension LoopDataManager { } } } + } } } @@ -1166,4 +1306,13 @@ protocol LoopDataManagerDelegate: class { /// - completion: A closure called once on completion /// - result: The enacted basal func loopDataManager(_ manager: LoopDataManager, didRecommendBasalChange basal: (recommendation: TempBasalRecommendation, date: Date), completion: @escaping (_ result: Result) -> Void) -> Void + + /// Informs the delegate that an immediate bolus is recommended + /// + /// - Parameters: + /// - manager: The manager + /// - bolus: The recommended bolus + /// - completion: A closure called once on completion + /// - result: The enacted bolus + func loopDataManager(_ manager: LoopDataManager, didRecommendBolus bolus: (recommendation: BolusRecommendation, date: Date), completion: @escaping (_ result: Result) -> Void) -> Void } diff --git a/Loop/View Controllers/BolusViewController+LoopDataManager.swift b/Loop/View Controllers/BolusViewController+LoopDataManager.swift index 84246ef882..e215aa2882 100644 --- a/Loop/View Controllers/BolusViewController+LoopDataManager.swift +++ b/Loop/View Controllers/BolusViewController+LoopDataManager.swift @@ -13,6 +13,7 @@ extension BolusViewController { func configureWithLoopManager(_ manager: LoopDataManager, recommendation: BolusRecommendation?, glucoseUnit: HKUnit) { manager.getLoopState { (manager, state) in let maximumBolus = manager.settings.maximumBolus + let maximumInsulinOnBoard = manager.settings.maximumInsulinOnBoard let activeCarbohydrates = state.carbsOnBoard?.quantity.doubleValue(for: .gram()) let bolusRecommendation: BolusRecommendation? @@ -38,6 +39,10 @@ extension BolusViewController { self.maxBolus = maxBolus } + if let maxInsulinOnBoard = maximumInsulinOnBoard { + self.maxInsulinOnBoard = maxInsulinOnBoard + } + self.glucoseUnit = glucoseUnit self.activeInsulin = activeInsulin self.activeCarbohydrates = activeCarbohydrates diff --git a/Loop/View Controllers/BolusViewController.swift b/Loop/View Controllers/BolusViewController.swift index 1d74917dd8..011c0191f0 100644 --- a/Loop/View Controllers/BolusViewController.swift +++ b/Loop/View Controllers/BolusViewController.swift @@ -116,6 +116,7 @@ final class BolusViewController: UITableViewController, IdentifiableClass, UITex var maxBolus: Double = 25 + var maxInsulinOnBoard: Double = 0 private(set) var bolus: Double? @@ -186,6 +187,20 @@ final class BolusViewController: UITableViewController, IdentifiableClass, UITex return } + let iob = activeInsulin ?? 0 + if maxInsulinOnBoard > 0 { + guard bolus + iob <= maxInsulinOnBoard else { + NSLog("BolusViewController - maxIOB") + presentAlertController(withTitle: NSLocalizedString("Exceeds Maximum Insulin on Board", comment: "The title of the alert describing a maximum insulin on board validation error"), message: String(format: NSLocalizedString("The insulin on board amount is %@ Units. Together with the entered value of %@ Units it exceeds the configured maximum of %@ Units.", comment: "Body of the alert describing a maximum iob validation error. (1: The bolus value, 2: The IOB value, 3: The maximum IOB permitted)"), + bolusUnitsFormatter.string(from: NSNumber(value: iob)) ?? "", + bolusUnitsFormatter.string(from: NSNumber(value: bolus)) ?? "", + bolusUnitsFormatter.string(from: NSNumber(value: maxInsulinOnBoard)) ?? "" + )) + + return + } + } + let context = LAContext() if context.canEvaluatePolicy(.deviceOwnerAuthentication, error: nil) { diff --git a/Loop/View Controllers/SettingsTableViewController.swift b/Loop/View Controllers/SettingsTableViewController.swift index c81e66d99b..dd4ad3adc2 100644 --- a/Loop/View Controllers/SettingsTableViewController.swift +++ b/Loop/View Controllers/SettingsTableViewController.swift @@ -80,6 +80,7 @@ final class SettingsTableViewController: UITableViewController, DailyValueSchedu fileprivate enum LoopRow: Int, CaseCountable { case dosing = 0 + case bolus case preferredInsulinDataSource case diagnostic } @@ -106,6 +107,7 @@ final class SettingsTableViewController: UITableViewController, DailyValueSchedu case insulinSensitivity case maxBasal case maxBolus + case maxInsulinOnBoard } fileprivate enum ServiceRow: Int, CaseCountable { @@ -184,6 +186,15 @@ final class SettingsTableViewController: UITableViewController, DailyValueSchedu switchCell.switch?.addTarget(self, action: #selector(dosingEnabledChanged(_:)), for: .valueChanged) + return switchCell + case .bolus: + let switchCell = tableView.dequeueReusableCell(withIdentifier: SwitchTableViewCell.className, for: indexPath) as! SwitchTableViewCell + + switchCell.switch?.isOn = dataManager.loopManager.settings.bolusEnabled + switchCell.textLabel?.text = NSLocalizedString("Automated Bolus", comment: "The title text for the automated bolus enabled switch cell") + + switchCell.switch?.addTarget(self, action: #selector(bolusEnabledChanged(_:)), for: .valueChanged) + return switchCell case .preferredInsulinDataSource: let cell = tableView.dequeueReusableCell(withIdentifier: ConfigCellIdentifier, for: indexPath) @@ -340,6 +351,14 @@ final class SettingsTableViewController: UITableViewController, DailyValueSchedu } else { configCell.detailTextLabel?.text = TapToSetString } + case .maxInsulinOnBoard: + configCell.textLabel?.text = NSLocalizedString("Maximum IOB", comment: "The title text for the maximum insulin on board value") + + if let maxInsulinOnBoard = dataManager.loopManager.settings.maximumInsulinOnBoard { + configCell.detailTextLabel?.text = "\(valueNumberFormatter.string(from: NSNumber(value: maxInsulinOnBoard))!) U" + } else { + configCell.detailTextLabel?.text = TapToSetString + } } return configCell @@ -474,14 +493,15 @@ final class SettingsTableViewController: UITableViewController, DailyValueSchedu case .configuration: let row = ConfigurationRow(rawValue: indexPath.row)! switch row { - case .maxBasal, .maxBolus: + case .maxBasal, .maxBolus, .maxInsulinOnBoard: let vc: LoopKitUI.TextFieldTableViewController - switch row { case .maxBasal: vc = .maxBasal(dataManager.loopManager.settings.maximumBasalRatePerHour) case .maxBolus: vc = .maxBolus(dataManager.loopManager.settings.maximumBolus) + case .maxInsulinOnBoard: + vc = .maxInsulinOnBoard(dataManager.loopManager.settings.maximumInsulinOnBoard) default: fatalError() } @@ -609,7 +629,7 @@ final class SettingsTableViewController: UITableViewController, DailyValueSchedu vc.title = sender?.textLabel?.text show(vc, sender: sender) - case .dosing: + case .dosing, .bolus: break } case .services: @@ -662,6 +682,10 @@ final class SettingsTableViewController: UITableViewController, DailyValueSchedu @objc private func dosingEnabledChanged(_ sender: UISwitch) { dataManager.loopManager.settings.dosingEnabled = sender.isOn } + + @objc private func bolusEnabledChanged(_ sender: UISwitch) { + dataManager.loopManager.settings.bolusEnabled = sender.isOn + } @objc private func reloadDevices() { self.dataManager.rileyLinkManager.getDevices { (devices) in @@ -981,6 +1005,12 @@ extension SettingsTableViewController: LoopKitUI.TextFieldTableViewControllerDel } else { dataManager.loopManager.settings.maximumBolus = nil } + case .maxInsulinOnBoard: + if let value = controller.value, let units = valueNumberFormatter.number(from: value)?.doubleValue { + dataManager.loopManager.settings.maximumInsulinOnBoard = units + } else { + dataManager.loopManager.settings.maximumInsulinOnBoard = nil + } default: assertionFailure() } diff --git a/Loop/View Controllers/TextFieldTableViewController.swift b/Loop/View Controllers/TextFieldTableViewController.swift index fe7b8378dc..85f57173c0 100644 --- a/Loop/View Controllers/TextFieldTableViewController.swift +++ b/Loop/View Controllers/TextFieldTableViewController.swift @@ -72,5 +72,19 @@ extension TextFieldTableViewController { } return vc - } + } + + static func maxInsulinOnBoard(_ value: Double?) -> T { + let vc = T() + + vc.placeholder = NSLocalizedString("Enter a number of units", comment: "The placeholder text instructing users how to enter a maximum iob") + vc.keyboardType = .decimalPad + vc.unit = NSLocalizedString("Units", comment: "The unit string for units") + + if let maxIOB = value { + vc.value = valueNumberFormatter.string(from: NSNumber(value: maxIOB)) + } + + return vc + } }