diff --git a/Common/FeatureFlags.swift b/Common/FeatureFlags.swift index 0c6440b564..5d0ae4acd8 100644 --- a/Common/FeatureFlags.swift +++ b/Common/FeatureFlags.swift @@ -39,7 +39,8 @@ struct FeatureFlagConfiguration: Decodable { let profileExpirationSettingsViewEnabled: Bool let missedMealNotifications: Bool let allowAlgorithmExperiments: Bool - + let correctionWithCarbBolus: Bool + let bgCorrectionWithCarbBolus: Bool fileprivate init() { // Swift compiler config is inverse, since the default state is enabled. @@ -232,6 +233,18 @@ struct FeatureFlagConfiguration: Decodable { #else self.allowAlgorithmExperiments = false #endif + + #if DISABLE_CORRECTION_WITH_CARB_BOLUS + self.correctionWithCarbBolus = false + #else + self.correctionWithCarbBolus = true + #endif + + #if DISABLE_BG_CORRECTION_WITH_CARB_BOLUS + self.bgCorrectionWithCarbBolus = false + #else + self.bgCorrectionWithCarbBolus = true + #endif } } diff --git a/Common/Models/BuildDetails.swift b/Common/Models/BuildDetails.swift index 63517e7e79..4a1a1894fc 100644 --- a/Common/Models/BuildDetails.swift +++ b/Common/Models/BuildDetails.swift @@ -16,8 +16,8 @@ class BuildDetails { init() { guard let url = Bundle.main.url(forResource: "BuildDetails", withExtension: ".plist"), - let data = try? Data(contentsOf: url), - let parsed = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else + let data = try? Data(contentsOf: url), + let parsed = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { dict = [:] return @@ -63,7 +63,7 @@ class BuildDetails { } var workspaceGitBranch: String? { - return dict["com-loopkit-LoopWorkspace-git-branch"] as? String - } + return dict["com-loopkit-LoopWorkspace-git-branch"] as? String + } } diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 1181951609..5afe5cc9e7 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 12F04F012D19791B002B2121 /* AutoBolusCarbsSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12F04F002D197907002B2121 /* AutoBolusCarbsSelectionView.swift */; }; 1419606428D9550400BA86E0 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 437AFEE6203688CF008C4892 /* LoopKitUI.framework */; }; 1419606928D9554E00BA86E0 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 437AFEE6203688CF008C4892 /* LoopKitUI.framework */; }; 1419606A28D955BC00BA86E0 /* MockKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C101947127DD473C004E7EB8 /* MockKitUI.framework */; }; @@ -743,6 +744,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 12F04F002D197907002B2121 /* AutoBolusCarbsSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoBolusCarbsSelectionView.swift; sourceTree = ""; }; 142CB7582A60BF2E0075748A /* EditMode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EditMode.swift; sourceTree = ""; }; 142CB75A2A60BFC30075748A /* FavoriteFoodsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FavoriteFoodsView.swift; sourceTree = ""; }; 1452F4A82A851C9400F8B9E4 /* AddEditFavoriteFoodViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditFavoriteFoodViewModel.swift; sourceTree = ""; }; @@ -2244,6 +2246,7 @@ 43F5C2CF1B92A2ED003EB13D /* Views */ = { isa = PBXGroup; children = ( + 12F04F002D197907002B2121 /* AutoBolusCarbsSelectionView.swift */, 1452F4AA2A851EDF00F8B9E4 /* AddEditFavoriteFoodView.swift */, B4001CED28CBBC82002FB414 /* AlertManagementView.swift */, 897A5A9524C2175B00C4E71D /* BolusEntryView.swift */, @@ -3736,6 +3739,7 @@ 149A28E42A8A63A700052EDF /* FavoriteFoodDetailView.swift in Sources */, 1DDE274024AEA4F200796622 /* NotificationsCriticalAlertPermissionsView.swift in Sources */, A9A056B524B94123007CF06D /* CriticalEventLogExportViewModel.swift in Sources */, + 12F04F012D19791B002B2121 /* AutoBolusCarbsSelectionView.swift in Sources */, 434FF1EE1CF27EEF000DB779 /* UITableViewCell.swift in Sources */, 439BED2A1E76093C00B0AED5 /* CGMManager.swift in Sources */, C16B983E26B4893300256B05 /* DoseEnactor.swift in Sources */, diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 2319f4eceb..1afc646da9 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -420,6 +420,18 @@ final class LoopDataManager { } } private let lockedLastLoopCompleted: Locked + + var autoBolusCarbsEnabledAndActive: Bool { + guard UserDefaults.standard.autoBolusCarbsEnabled else { + return false + } + + guard let override = lockedSettings.value.scheduleOverride, override.isActive() else { + return UserDefaults.standard.autoBolusCarbsActiveByDefault + } + + return override.settings.autoBolusCarbsActive ?? UserDefaults.standard.autoBolusCarbsActiveByDefault + } fileprivate var lastLoopError: LoopError? @@ -1456,22 +1468,194 @@ extension LoopDataManager { let prediction = try predictGlucoseFromManualGlucose(glucose, potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, considerPositiveVelocityAndRC: considerPositiveVelocityAndRC) return try recommendManualBolus(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry) } + + + fileprivate func getTotalCobCorrectionAmount(consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry? = nil, replacingCarbEntry replacedCarbEntry: StoredCarbEntry? = nil, considerPositiveVelocityAndRC: Bool, pendingInsulin: Double) throws -> Double? { + + let shouldIncludePendingInsulin = pendingInsulin > 0 + + let carbAndInsulinPrediction = try predictGlucose(using: [.carbs, .insulin], potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: false) + + guard !carbAndInsulinPrediction.isEmpty else { + return nil + } + + let carbOnlyPrediction = try predictGlucose(using: [.carbs], potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: false) + + guard !carbOnlyPrediction.isEmpty else { + return nil + } + + // cobCorrection includes insulin when its effects are to reduce BG, but doesn't include it if it raises it (e.g., negative IOB) + let cobPrediction = carbAndInsulinPrediction.last!.quantity < carbOnlyPrediction.last!.quantity ? carbAndInsulinPrediction : carbOnlyPrediction + + let flatCobPrediction = cobPrediction.map{PredictedGlucoseValue(startDate: $0.startDate, quantity: cobPrediction.last!.quantity)} + + return try recommendBolusValidatingDataRecency(forPrediction: flatCobPrediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .cobBreakdown)?.amount + } + /// - Throws: LoopError.missingDataError - fileprivate func recommendBolus(consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? { + fileprivate func recommendBolus(consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry? = nil, replacingCarbEntry replacedCarbEntry: StoredCarbEntry? = nil, considerPositiveVelocityAndRC: Bool, pendingInsulin: Double, provideBreakdown: Bool) throws -> ManualBolusRecommendation? { guard lastRequestedBolus == nil else { // Don't recommend changes if a bolus was just requested. // Sending additional pump commands is not going to be // successful in any case. return nil } - - let pendingInsulin = try getPendingInsulin() + let shouldIncludePendingInsulin = pendingInsulin > 0 let prediction = try predictGlucose(using: .all, potentialBolus: nil, potentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: considerPositiveVelocityAndRC) - return try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry) + let recommendation = try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry) + + guard recommendation != nil else { + return nil + } + + guard provideBreakdown else { + return recommendation + } + + guard !prediction.isEmpty else { + return recommendation // unable to differentiate between correction amounts, + } + + guard let totalCobAmount = try getTotalCobCorrectionAmount(consideringPotentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, considerPositiveVelocityAndRC: considerPositiveVelocityAndRC, pendingInsulin: pendingInsulin) else { + + return recommendation // unable to differentiate between correction amounts + } + + var carbsAmount = 0.0 + + if potentialCarbEntry != nil { + guard let carbBreakdownRecommendation = try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .carbBreakdown) else { + + return recommendation // unable to differentiate between correction amounts + } + + // the insulin needed to cover the zeroCarbEntry will underflow to 0 once added/subtracted + let zeroCarbEntry = replacedCarbEntry == nil ? nil : NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 1E-50), startDate: potentialCarbEntry!.startDate, foodType: nil, absorptionTime: potentialCarbEntry!.absorptionTime) + + let predictionWithZeroCarbEntry = try predictGlucose(using: .all, potentialBolus: nil, potentialCarbEntry: zeroCarbEntry, replacingCarbEntry: replacedCarbEntry, includingPendingInsulin: shouldIncludePendingInsulin, includingPositiveVelocityAndRC: considerPositiveVelocityAndRC) + + guard let carbBreakdownRecommendationWithZeroCarbEntry = try recommendBolusValidatingDataRecency(forPrediction: predictionWithZeroCarbEntry, consideringPotentialCarbEntry: zeroCarbEntry, usage: .carbBreakdown) else { + + return recommendation // unable to directly calculate carbsAmount + } + + carbsAmount = carbBreakdownRecommendation.amount - carbBreakdownRecommendationWithZeroCarbEntry.amount + } + + var missingAmount = recommendation!.missingAmount + let extra = Swift.max(missingAmount ?? 0, 0) + var correctionAmount = recommendation!.amount + extra - carbsAmount + + if let calcAmount = try calcCorrectionAmount(carbsAmount: carbsAmount, prediction: prediction, potentialCarbEntry: potentialCarbEntry) { + + if recommendation!.notice == .predictedGlucoseInRange { + correctionAmount = Swift.min(correctionAmount, calcAmount, 0) // ensure 0 if in range but above the mid-point + missingAmount = carbsAmount + correctionAmount - recommendation!.amount + if missingAmount! <= 0 || volumeRounder()(missingAmount!) == 0 { + missingAmount = nil // this MUST be the case since extra should be 0, and missingAmount <= extra by construction + } + } else { + let totalMissingAmount = carbsAmount + calcAmount - recommendation!.amount + if totalMissingAmount > extra, volumeRounder()(totalMissingAmount - extra) > 0 { + correctionAmount = calcAmount + missingAmount = totalMissingAmount + } else if recommendation!.amount == 0 && calcAmount < 0 { + correctionAmount = calcAmount + missingAmount = nil + } + } + } + + return ManualBolusRecommendation(amount: recommendation!.amount, pendingInsulin: recommendation!.pendingInsulin, notice: recommendation!.notice, missingAmount: missingAmount, bolusBreakdown: BolusBreakdown(fullCarbsAmount: carbsAmount, fullCobCorrectionAmount: totalCobAmount, fullCorrectionAmount: correctionAmount)) } + + fileprivate func calcCorrectionAmount(carbsAmount: Double, + prediction: [PredictedGlucoseValue], + potentialCarbEntry: NewCarbEntry?) throws -> Double? { + + let recommendationAmountForCarbs = try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .carbBreakdown)?.amount + + guard recommendationAmountForCarbs != nil else { + return nil + } + + let recommendationAmountForCorrection = try recommendBolusValidatingDataRecency(forPrediction: prediction, consideringPotentialCarbEntry: potentialCarbEntry, usage: .correctionBreakdown)?.amount + + guard recommendationAmountForCorrection != nil else { + return nil + } + // carbs + correction + y = a + // carbs + correction + ratio*y = b + // --> y = (b-a)/(ratio - 1) + // --> correction = a - y - carbs + + let ratio = ManualBolusRecommendationUsage.correctionBreakdown.targetsAdjustment / ManualBolusRecommendationUsage.carbBreakdown.targetsAdjustment + let y = (recommendationAmountForCorrection! - recommendationAmountForCarbs!) / (ratio - 1) + + return recommendationAmountForCarbs! - y - carbsAmount + } + + fileprivate enum ManualBolusRecommendationUsage { + case standard, cobBreakdown, carbBreakdown, correctionBreakdown + + func suspendThresholdOverride(_ suspendThreshold: HKQuantity?) -> HKQuantity? { + switch self { + case .standard: return suspendThreshold + default: return nil + } + } + + func maxBolusOverride(_ maxBolus: Double) -> Double { + switch self { + case .standard: return maxBolus + default: return 1E15 + } + } + + func volumeRounderOverride(_ volumeRounder: @escaping (Double) -> Double) -> ((Double) -> Double)? { + switch self { + case .standard: return volumeRounder + default: return nil + } + } + + var targetsAdjustment : Double { + switch self { + case .standard: return 0.0 + case .cobBreakdown: return 0.0 + case .carbBreakdown: return -1E5 + case .correctionBreakdown: return -2E5 + } + } + + func glucoseTargetsOverride(_ schedule: GlucoseRangeSchedule, _ startingGlucose: HKQuantity) -> GlucoseRangeSchedule{ + switch self { + case .standard: return schedule + case .cobBreakdown: + let target = startingGlucose.doubleValue(for: .milligramsPerDeciliter) + return GlucoseRangeSchedule(unit: .milligramsPerDeciliter, + dailyItems: [RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: target, maxValue: target))])! + default: return adjustSchedule(schedule, amount: self.targetsAdjustment) + } + } + + private func adjustSchedule(_ schedule: GlucoseRangeSchedule, amount: Double) -> GlucoseRangeSchedule { + return GlucoseRangeSchedule(unit: schedule.unit, + dailyItems: schedule.items.map{scheduleValue in + scheduleValue.map{range in + DoubleRange(minValue: range.minValue + amount, maxValue: range.maxValue + amount)}}, + timeZone: schedule.timeZone)! + } + + + } + + /// - Throws: /// - LoopError.missingDataError /// - LoopError.glucoseTooOld @@ -1479,7 +1663,8 @@ extension LoopDataManager { /// - LoopError.pumpDataTooOld /// - LoopError.configurationError fileprivate func recommendBolusValidatingDataRecency(forPrediction predictedGlucose: [Sample], - consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?) throws -> ManualBolusRecommendation? { + consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, + usage: ManualBolusRecommendationUsage = .standard) throws -> ManualBolusRecommendation? { guard let glucose = glucoseStore.latestGlucose else { throw LoopError.missingDataError(.glucose) } @@ -1511,21 +1696,34 @@ extension LoopDataManager { throw LoopError.missingDataError(.insulinEffect) } - return try recommendManualBolus(forPrediction: predictedGlucose, consideringPotentialCarbEntry: potentialCarbEntry) + return try recommendManualBolus(forPrediction: predictedGlucose, consideringPotentialCarbEntry: potentialCarbEntry, usage: usage) + } + + private func volumeRounder() -> ((Double) -> Double) { + let result = { (_ units: Double) in + return self.delegate?.roundBolusVolume(units: units) ?? units + } + return result } /// - Throws: LoopError.configurationError private func recommendManualBolus(forPrediction predictedGlucose: [Sample], - consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?) throws -> ManualBolusRecommendation? { - guard let glucoseTargetRange = settings.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: potentialCarbEntry != nil) else { - throw LoopError.configurationError(.glucoseTargetRangeSchedule) - } + consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, + usage: ManualBolusRecommendationUsage = .standard) throws -> ManualBolusRecommendation? { guard let insulinSensitivity = insulinSensitivityScheduleApplyingOverrideHistory else { throw LoopError.configurationError(.insulinSensitivitySchedule) } + + guard let glucoseTargetRange = settings.effectiveGlucoseTargetRangeSchedule(presumingMealEntry: potentialCarbEntry != nil) else { + throw LoopError.configurationError(.glucoseTargetRangeSchedule) + } guard let maxBolus = settings.maximumBolus else { throw LoopError.configurationError(.maximumBolus) } + + guard let startingGlucose = self.glucoseStore.latestGlucose?.quantity else { + throw LoopError.missingDataError(.glucose) + } guard lastRequestedBolus == nil else { @@ -1534,22 +1732,18 @@ extension LoopDataManager { // successful in any case. return nil } - - let volumeRounder = { (_ units: Double) in - return self.delegate?.roundBolusVolume(units: units) ?? units - } let model = doseStore.insulinModelProvider.model(for: pumpInsulinType) - + return predictedGlucose.recommendedManualBolus( - to: glucoseTargetRange, + to: usage.glucoseTargetsOverride(glucoseTargetRange, startingGlucose), at: now(), - suspendThreshold: settings.suspendThreshold?.quantity, + suspendThreshold: usage.suspendThresholdOverride(settings.suspendThreshold?.quantity), sensitivity: insulinSensitivity, model: model, pendingInsulin: 0, // Pending insulin is already reflected in the prediction - maxBolus: maxBolus, - volumeRounder: volumeRounder + maxBolus: usage.maxBolusOverride(maxBolus), + volumeRounder: usage.volumeRounderOverride(volumeRounder()) ) } @@ -1665,6 +1859,82 @@ extension LoopDataManager { suspendInsulinDeliveryEffect = suspendDoses.glucoseEffects(insulinModelProvider: doseStore.insulinModelProvider, longestEffectDuration: doseStore.longestEffectDuration, insulinSensitivity: insulinSensitivity).filterDateRange(startSuspend, endSuspend) } + fileprivate func getDosingRecommendation(dosingStrategy: AutomaticDosingStrategy, glucose: any GlucoseSampleValue, predictedGlucose: [PredictedGlucoseValue], iobHeadroom: Double, glucoseTargetRange: GlucoseRangeSchedule?, insulinSensitivity: InsulinSensitivitySchedule?, basalRateSchedule: BasalRateSchedule?, startDate: Date, bolusApplicationFactor: Double? = nil, volumeRounder: ((Double) -> Double)? = nil) -> AutomaticDoseRecommendation? { + + let rateRounder = { (_ rate: Double) in + return self.delegate?.roundBasalRate(unitsPerHour: rate) ?? rate + } + + let lastTempBasal: DoseEntry? + + if case .some(.tempBasal(let dose)) = basalDeliveryState { + lastTempBasal = dose + } else { + lastTempBasal = nil + } + + let maxBolus = settings.maximumBolus! + let maxBasal = settings.maximumBasalRatePerHour! + + switch dosingStrategy { + case .automaticBolus: + let correctionRangeSchedule = settings.effectiveGlucoseTargetRangeSchedule() + + let effectiveBolusApplicationFactor: Double + + if bolusApplicationFactor != nil { + effectiveBolusApplicationFactor = bolusApplicationFactor! + } else { + // Create dosing strategy based on user setting + let applicationFactorStrategy: ApplicationFactorStrategy = UserDefaults.standard.glucoseBasedApplicationFactorEnabled + ? GlucoseBasedApplicationFactorStrategy() + : ConstantApplicationFactorStrategy() + + effectiveBolusApplicationFactor = applicationFactorStrategy.calculateDosingFactor( + for: glucose.quantity, + correctionRangeSchedule: correctionRangeSchedule!, + settings: settings + ) + } + + self.logger.debug(" *** Glucose: %{public}@, effectiveBolusApplicationFactor: %.2f", glucose.quantity.description, effectiveBolusApplicationFactor) + + // If a user customizes maxPartialApplicationFactor > 1; this respects maxBolus + let maxAutomaticBolus = min(iobHeadroom, maxBolus * min(effectiveBolusApplicationFactor, 1.0)) + + return predictedGlucose.recommendedAutomaticDose( + to: glucoseTargetRange!, + at: predictedGlucose[0].startDate, + suspendThreshold: settings.suspendThreshold?.quantity, + sensitivity: insulinSensitivity!, + model: doseStore.insulinModelProvider.model(for: pumpInsulinType), + basalRates: basalRateSchedule!, + maxAutomaticBolus: maxAutomaticBolus, + partialApplicationFactor: effectiveBolusApplicationFactor * self.timeBasedDoseApplicationFactor, + lastTempBasal: lastTempBasal, + volumeRounder: volumeRounder ?? self.volumeRounder(), + rateRounder: rateRounder, + isBasalRateScheduleOverrideActive: settings.scheduleOverride?.isBasalRateScheduleOverriden(at: startDate) == true + ) + case .tempBasalOnly: + + let temp = predictedGlucose.recommendedTempBasal( + to: glucoseTargetRange!, + at: predictedGlucose[0].startDate, + suspendThreshold: settings.suspendThreshold?.quantity, + sensitivity: insulinSensitivity!, + model: doseStore.insulinModelProvider.model(for: pumpInsulinType), + basalRates: basalRateSchedule!, + maxBasalRate: maxBasal, + additionalActiveInsulinClamp: iobHeadroom, + lastTempBasal: lastTempBasal, + rateRounder: rateRounder, + isBasalRateScheduleOverrideActive: settings.scheduleOverride?.isBasalRateScheduleOverriden(at: startDate) == true + ) + return AutomaticDoseRecommendation(basalAdjustment: temp) + } + } + /// Runs the glucose prediction on the latest effect data. /// /// - Throws: @@ -1777,81 +2047,71 @@ extension LoopDataManager { dosingDecision.appendWarning(.bolusInProgress) return (dosingDecision, nil) } + + var dosingRecommendation: AutomaticDoseRecommendation? - let rateRounder = { (_ rate: Double) in - return self.delegate?.roundBasalRate(unitsPerHour: rate) ?? rate - } - - let lastTempBasal: DoseEntry? - - if case .some(.tempBasal(let dose)) = basalDeliveryState { - lastTempBasal = dose - } else { - lastTempBasal = nil - } - - let dosingRecommendation: AutomaticDoseRecommendation? - + var autoBolusCarbsAmount = -Double.infinity + // automaticDosingIOBLimit calculated from the user entered maxBolus let automaticDosingIOBLimit = maxBolus! * 2.0 let iobHeadroom = automaticDosingIOBLimit - self.insulinOnBoard!.value - - switch settings.automaticDosingStrategy { - case .automaticBolus: - let volumeRounder = { (_ units: Double) in - return self.delegate?.roundBolusVolume(units: units) ?? units + + if autoBolusCarbsEnabledAndActive { + do { + let posVelocityAndRC = FeatureFlags.usePositiveMomentumAndRCForManualBoluses + let pendingInsulin = try getPendingInsulin() + if let recommendation = try recommendBolus(considerPositiveVelocityAndRC: posVelocityAndRC, pendingInsulin: pendingInsulin, provideBreakdown: false), let totalCobAmount = try getTotalCobCorrectionAmount(considerPositiveVelocityAndRC: posVelocityAndRC, pendingInsulin: pendingInsulin) { + + let amount = min(recommendation.amount, volumeRounder()(min(iobHeadroom, totalCobAmount))) + + if amount > 0 { + autoBolusCarbsAmount = amount + } + } + } catch { + logger.error("Unexpected error, won't auto-bolus carbs: %{public}@", String(describing: error)) } - - // Create dosing strategy based on user setting - let applicationFactorStrategy: ApplicationFactorStrategy = UserDefaults.standard.glucoseBasedApplicationFactorEnabled - ? GlucoseBasedApplicationFactorStrategy() - : ConstantApplicationFactorStrategy() - - let correctionRangeSchedule = settings.effectiveGlucoseTargetRangeSchedule() - - let effectiveBolusApplicationFactor = applicationFactorStrategy.calculateDosingFactor( - for: glucose.quantity, - correctionRangeSchedule: correctionRangeSchedule!, - settings: settings - ) - - self.logger.debug(" *** Glucose: %{public}@, effectiveBolusApplicationFactor: %.2f", glucose.quantity.description, effectiveBolusApplicationFactor) + } + + let bolusApplicationFactor: Double? + let volumeRounder: ((Double) -> Double)? + + if autoBolusCarbsAmount > 0 { + switch settings.automaticDosingStrategy { + case .automaticBolus: + bolusApplicationFactor = nil + volumeRounder = nil + case .tempBasalOnly: + // instead of temp basal, compare with automaticBolus with an adjusted bolusApplicationFactor reflecting 5 minutes of temp basal + // we avoid rounding this value so we can accurately know whether the temp basal would give more or less insulin over 5 minutes + bolusApplicationFactor = 5.0/30.0 + volumeRounder = {$0} + } + } else { + bolusApplicationFactor = nil + volumeRounder = nil + } - // If a user customizes maxPartialApplicationFactor > 1; this respects maxBolus - let maxAutomaticBolus = min(iobHeadroom, maxBolus! * min(effectiveBolusApplicationFactor, 1.0)) - - dosingRecommendation = predictedGlucose.recommendedAutomaticDose( - to: glucoseTargetRange!, - at: predictedGlucose[0].startDate, - suspendThreshold: settings.suspendThreshold?.quantity, - sensitivity: insulinSensitivity!, - model: doseStore.insulinModelProvider.model(for: pumpInsulinType), - basalRates: basalRateSchedule!, - maxAutomaticBolus: maxAutomaticBolus, - partialApplicationFactor: effectiveBolusApplicationFactor * self.timeBasedDoseApplicationFactor, - lastTempBasal: lastTempBasal, - volumeRounder: volumeRounder, - rateRounder: rateRounder, - isBasalRateScheduleOverrideActive: settings.scheduleOverride?.isBasalRateScheduleOverriden(at: startDate) == true - ) - case .tempBasalOnly: - - let temp = predictedGlucose.recommendedTempBasal( - to: glucoseTargetRange!, - at: predictedGlucose[0].startDate, - suspendThreshold: settings.suspendThreshold?.quantity, - sensitivity: insulinSensitivity!, - model: doseStore.insulinModelProvider.model(for: pumpInsulinType), - basalRates: basalRateSchedule!, - maxBasalRate: maxBasal!, - additionalActiveInsulinClamp: iobHeadroom, - lastTempBasal: lastTempBasal, - rateRounder: rateRounder, - isBasalRateScheduleOverrideActive: settings.scheduleOverride?.isBasalRateScheduleOverriden(at: startDate) == true - ) - dosingRecommendation = AutomaticDoseRecommendation(basalAdjustment: temp) + let dosingStrategty = autoBolusCarbsAmount > 0 ? .automaticBolus : settings.automaticDosingStrategy + + dosingRecommendation = getDosingRecommendation(dosingStrategy: dosingStrategty, glucose: glucose, predictedGlucose: predictedGlucose, iobHeadroom: iobHeadroom, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivity, basalRateSchedule: basalRateSchedule, startDate: startDate, bolusApplicationFactor: bolusApplicationFactor, volumeRounder: volumeRounder) + + if autoBolusCarbsAmount > dosingRecommendation?.bolusUnits ?? 0.0 { + logger.info("Recommendation is to auto-bolus carbs as it will give more insulin") + dosingRecommendation = AutomaticDoseRecommendation(basalAdjustment: dosingRecommendation?.basalAdjustment, bolusUnits: autoBolusCarbsAmount) + } else { + switch settings.automaticDosingStrategy { + case .tempBasalOnly: + if autoBolusCarbsAmount > 0 { + // we used automaticBolus before so now we need to switch over to the standard tempBasal recommendation + dosingRecommendation = getDosingRecommendation(dosingStrategy: .tempBasalOnly, glucose: glucose, predictedGlucose: predictedGlucose, iobHeadroom: iobHeadroom, glucoseTargetRange: glucoseTargetRange, insulinSensitivity: insulinSensitivity, basalRateSchedule: basalRateSchedule, startDate: startDate) + } + default: + break + } } - + + if let dosingRecommendation = dosingRecommendation { self.logger.default("Recommending dose: %{public}@ at %{public}@", String(describing: dosingRecommendation), String(describing: startDate)) recommendedAutomaticDose = (recommendation: dosingRecommendation, date: startDate) @@ -1991,7 +2251,7 @@ protocol LoopState { /// Computes the recommended bolus for correcting a glucose prediction, optionally considering a potential carb entry. /// - Parameter potentialCarbEntry: A carb entry under consideration for which to include effects in the prediction /// - Parameter replacedCarbEntry: An existing carb entry replaced by `potentialCarbEntry` - /// - Parameter considerPositiveVelocityAndRC: Positive velocity and positive retrospective correction will not be used if this is false. + /// - Parameter considerPositiveVelocityAndRC: Positive velocity and positive retrospective correction will not be used if this is false. /// - Returns: A bolus recommendation, or `nil` if not applicable /// - Throws: LoopError.missingDataError if recommendation cannot be computed func recommendBolus(consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? @@ -2099,7 +2359,7 @@ extension LoopDataManager { func recommendBolus(consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? { dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) - return try loopDataManager.recommendBolus(consideringPotentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, considerPositiveVelocityAndRC: considerPositiveVelocityAndRC) + return try loopDataManager.recommendBolus(consideringPotentialCarbEntry: potentialCarbEntry, replacingCarbEntry: replacedCarbEntry, considerPositiveVelocityAndRC: considerPositiveVelocityAndRC, pendingInsulin: loopDataManager.getPendingInsulin(), provideBreakdown: true) } func recommendBolusForManualGlucose(_ glucose: NewGlucoseSample, consideringPotentialCarbEntry potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, considerPositiveVelocityAndRC: Bool) throws -> ManualBolusRecommendation? { diff --git a/Loop/View Controllers/StatusTableViewController.swift b/Loop/View Controllers/StatusTableViewController.swift index 6a4aadfcdd..1acf265911 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -601,6 +601,8 @@ final class StatusTableViewController: LoopChartsTableViewController { } else { self.currentCOBDescription = nil } + // FIXME need to trigger an update of this value when the UserDefaults are changed + self.currentAutoBolusCarbsActive = self.deviceManager.loopManager.autoBolusCarbsEnabledAndActive self.tableView.beginUpdates() if let hudView = self.hudView { @@ -677,6 +679,8 @@ final class StatusTableViewController: LoopChartsTableViewController { // MARK: COB private var currentCOBDescription: String? + + private var currentAutoBolusCarbsActive = false // MARK: - Loop Status Section Data @@ -1003,7 +1007,15 @@ final class StatusTableViewController: LoopChartsTableViewController { cell.setChartGenerator(generator: { [weak self] (frame) in return self?.statusCharts.cobChart(withFrame: frame)?.view }) - cell.setTitleLabelText(label: NSLocalizedString("Active Carbohydrates", comment: "The title of the Carbs On-Board graph")) + + let label = NSLocalizedString("Active Carbohydrates", comment: "The title of the Carbs On-Board graph"); + + // FIXME need to put in place a proper image indicating AutoBolusCarbs + if currentAutoBolusCarbsActive { + cell.setTitleLabelText(label: String(format: "%@ %@", label, "🔸")) + } else { + cell.setTitleLabelText(label: label) + } } self.tableView(tableView, updateSubtitleFor: cell, at: indexPath) @@ -1165,6 +1177,15 @@ final class StatusTableViewController: LoopChartsTableViewController { } else { cell.setSubtitleLabel(label: nil) } + + let label = NSLocalizedString("Active Carbohydrates", comment: "The title of the Carbs On-Board graph"); + + // FIXME need to put in place a proper image indicating AutoBolusCarbs + if currentAutoBolusCarbsActive { + cell.setTitleLabelText(label: String(format: "%@ %@", label, "🔸")) + } else { + cell.setTitleLabelText(label: label) + } } case .hud, .status, .alertWarning: break @@ -1233,7 +1254,7 @@ final class StatusTableViewController: LoopChartsTableViewController { case .preMeal, .legacyWorkout: break default: - let vc = AddEditOverrideTableViewController(glucoseUnit: statusCharts.glucose.glucoseUnit) + let vc = AddEditOverrideTableViewController(glucoseUnit: statusCharts.glucose.glucoseUnit, autoBolusCarbsEnabled: UserDefaults.standard.autoBolusCarbsEnabled) vc.inputMode = .editOverride(override) vc.delegate = self show(vc, sender: tableView.cellForRow(at: indexPath)) @@ -1342,6 +1363,7 @@ final class StatusTableViewController: LoopChartsTableViewController { vc.glucoseUnit = statusCharts.glucose.glucoseUnit vc.overrideHistory = deviceManager.loopManager.overrideHistory.getEvents() vc.delegate = self + vc.autoBolusCarbsEnabled = UserDefaults.standard.autoBolusCarbsEnabled case let vc as PredictionTableViewController: vc.deviceManager = deviceManager default: diff --git a/Loop/View Models/BolusEntryViewModel.swift b/Loop/View Models/BolusEntryViewModel.swift index a86f20e0cc..10d719e33f 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -113,6 +113,33 @@ final class BolusEntryViewModel: ObservableObject { let potentialCarbEntry: NewCarbEntry? let selectedCarbAbsorptionTimeEmoji: String? + @Published var carbBolus: HKQuantity? + @Published var carbBolusIncluded = true + var carbBolusAmount: Double? { + carbBolus?.doubleValue(for: .internationalUnit()) + } + @Published var cobCorrectionBolus: HKQuantity? + @Published var cobCorrectionBolusIncluded = true + @Published var userChangedCobCorrectionBolusIncluded = false + var cobCorrectionBolusAmount: Double? { + cobCorrectionBolus?.doubleValue(for: .internationalUnit()) + } + @Published var bgCorrectionBolus: HKQuantity? + @Published var bgCorrectionBolusIncluded = true + @Published var userChangedBgCorrectionBolusIncluded = false + var bgCorrectionBolusAmount: Double? { + bgCorrectionBolus?.doubleValue(for: .internationalUnit()) + } + @Published var maxExcessBolus: HKQuantity? + @Published var maxExcessBolusIncluded = true + var maxExcessBolusAmount: Double? { + maxExcessBolus?.doubleValue(for: .internationalUnit()) + } + @Published var safetyLimitBolus: HKQuantity? + @Published var safetyLimitBolusIncluded = true + var safetyLimitBolusAmount: Double? { + safetyLimitBolus?.doubleValue(for: .internationalUnit()) + } @Published var recommendedBolus: HKQuantity? var recommendedBolusAmount: Double? { recommendedBolus?.doubleValue(for: .internationalUnit()) @@ -206,7 +233,7 @@ final class BolusEntryViewModel: ObservableObject { self.observeElapsedTime() self.observeEnteredManualGlucoseChanges() self.observeEnteredBolusChanges() - + self.observeBolusBreakdownChanges() } private func observeLoopUpdates() { @@ -239,6 +266,54 @@ final class BolusEntryViewModel: ObservableObject { } .store(in: &cancellables) } + + private func observeBolusBreakdownChanges() { + $carbBolusIncluded + .sink { [weak self] newValue in + if self?.carbBolusIncluded != newValue { + self?.delegate?.withLoopState { [weak self] _ in + self?.updateRecommendedBolusAndNoticeForBolusBreakdownChange() + } + } + } + .store(in: &cancellables) + $cobCorrectionBolusIncluded + .sink { [weak self] newValue in + if self?.cobCorrectionBolusIncluded != newValue { + self?.delegate?.withLoopState { [weak self] _ in + self?.updateRecommendedBolusAndNoticeForBolusBreakdownChange() + } + } + } + .store(in: &cancellables) + $bgCorrectionBolusIncluded + .sink { [weak self] newValue in + if self?.bgCorrectionBolusIncluded != newValue { + self?.delegate?.withLoopState { [weak self] _ in + self?.updateRecommendedBolusAndNoticeForBolusBreakdownChange() + } + } + } + .store(in: &cancellables) + $maxExcessBolusIncluded + .sink { [weak self] newValue in + if self?.maxExcessBolusIncluded != newValue { + self?.delegate?.withLoopState { [weak self] _ in + self?.updateRecommendedBolusAndNoticeForBolusBreakdownChange() + } + } + } + .store(in: &cancellables) + $safetyLimitBolusIncluded + .sink { [weak self] newValue in + if self?.safetyLimitBolusIncluded != newValue { + self?.delegate?.withLoopState { [weak self] _ in + self?.updateRecommendedBolusAndNoticeForBolusBreakdownChange() + } + } + } + .store(in: &cancellables) + } private func observeEnteredManualGlucoseChanges() { $manualGlucoseQuantity @@ -444,6 +519,13 @@ final class BolusEntryViewModel: ObservableObject { formatter.numberFormatter.roundingMode = .down return formatter.numberFormatter }() + + private lazy var breakdownBolusAmountFormatter: NumberFormatter = { + let formatter = QuantityFormatter(for: .internationalUnit()) + formatter.numberFormatter.roundingMode = .down // round towards 0 + formatter.numberFormatter.maximumFractionDigits = 2 + return formatter.numberFormatter + }() private lazy var absorptionTimeFormatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() @@ -645,8 +727,16 @@ final class BolusEntryViewModel: ObservableObject { } } } - + private func updateRecommendedBolusAndNotice(from state: LoopState, isUpdatingFromUserInput: Bool) { + updateRecommendedBolusAndNotice(recommendationSupplier: {try computeBolusRecommendation(from: state)}, isUpdatingFromUserInput: isUpdatingFromUserInput) + } + + private func updateRecommendedBolusAndNoticeForBolusBreakdownChange() { + updateRecommendedBolusAndNotice(recommendationSupplier: {self.dosingDecision.manualBolusRecommendation?.recommendation}, isUpdatingFromUserInput: true) + } + + private func updateRecommendedBolusAndNotice(recommendationSupplier: () throws -> ManualBolusRecommendation?, isUpdatingFromUserInput: Bool) { dispatchPrecondition(condition: .notOnQueue(.main)) guard let delegate = delegate else { @@ -656,14 +746,95 @@ final class BolusEntryViewModel: ObservableObject { let now = Date() var recommendation: ManualBolusRecommendation? + let carbBolus: HKQuantity? + let cobCorrectionBolus: HKQuantity? + var cobCorrectionBolusIncluded: Bool + let bgCorrectionBolus: HKQuantity? + var bgCorrectionBolusIncluded: Bool let recommendedBolus: HKQuantity? + var maxExcessBolus: HKQuantity? = nil + var safetyLimitBolus: HKQuantity? = nil let notice: Notice? do { - recommendation = try computeBolusRecommendation(from: state) + recommendation = try recommendationSupplier() + + bgCorrectionBolusIncluded = self.bgCorrectionBolusIncluded + cobCorrectionBolusIncluded = self.cobCorrectionBolusIncluded if let recommendation = recommendation { - recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: delegate.roundBolusVolume(units: recommendation.amount)) - //recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: recommendation.amount) + var totalRecommendation = 0.0 + + if let carbsAmount = recommendation.bolusBreakdown?.carbsAmount { + carbBolus = HKQuantity(unit: .internationalUnit(), doubleValue: carbsAmount) + totalRecommendation += carbBolusIncluded ? carbsAmount : 0 + } else { + carbBolus = nil + } + + if !FeatureFlags.correctionWithCarbBolus, potentialCarbEntry != nil, !userChangedBgCorrectionBolusIncluded, !userChangedCobCorrectionBolusIncluded { + let cobCorrectionAmount = recommendation.bolusBreakdown?.cobCorrectionAmount ?? 0.0 + let bgCorrectionAmount = recommendation.bolusBreakdown?.bgCorrectionAmount ?? 0.0 + + if cobCorrectionAmount + bgCorrectionAmount > 0 { + cobCorrectionBolusIncluded = false + bgCorrectionBolusIncluded = false + } + } + + if !FeatureFlags.bgCorrectionWithCarbBolus, potentialCarbEntry != nil, !userChangedBgCorrectionBolusIncluded { + let bgCorrectionAmount = recommendation.bolusBreakdown?.bgCorrectionAmount ?? 0.0 + + if bgCorrectionAmount > 0 { + bgCorrectionBolusIncluded = false + } + } + + if let cobCorrectionAmount = recommendation.bolusBreakdown?.cobCorrectionAmount { + cobCorrectionBolus = HKQuantity(unit: .internationalUnit(), doubleValue: cobCorrectionAmount) + totalRecommendation += cobCorrectionBolusIncluded ? cobCorrectionAmount : 0 + } else { + cobCorrectionBolus = nil + } + + if let bgCorrectionAmount = recommendation.bolusBreakdown?.bgCorrectionAmount { + bgCorrectionBolus = HKQuantity(unit: .internationalUnit(), doubleValue: bgCorrectionAmount) + totalRecommendation += bgCorrectionBolusIncluded ? bgCorrectionAmount : 0 + } else { + bgCorrectionBolus = nil + } + + if let missingAmount = recommendation.missingAmount, missingAmount > 0 { + if let maxBolus = maximumBolus?.doubleValue(for: .internationalUnit()) { + if recommendation.amount >= maxBolus { + // while it is technically possible for some safetyLimitBolus too, this isn't identifiable, nor parituclarly relevant + maxExcessBolus = HKQuantity(unit: .internationalUnit(), doubleValue: missingAmount) + } else if recommendation.amount + missingAmount > maxBolus { + safetyLimitBolus = HKQuantity(unit: .internationalUnit(), doubleValue: maxBolus - recommendation.amount) + maxExcessBolus = HKQuantity(unit: .internationalUnit(), doubleValue: recommendation.amount + missingAmount - maxBolus) + } else { + safetyLimitBolus = HKQuantity(unit: .internationalUnit(), doubleValue: missingAmount) + } + } else { + // generally we shouldn't be here, but if we don't know maxBolus we have to treat it all as safety limit + safetyLimitBolus = HKQuantity(unit: .internationalUnit(), doubleValue: missingAmount) + } + + if let maxExcessAmount = maxExcessBolus?.doubleValue(for: .internationalUnit()) { + totalRecommendation -= maxExcessBolusIncluded ? maxExcessAmount : 0 + } + + if let safetyLimitAmount = safetyLimitBolus?.doubleValue(for: .internationalUnit()) { + totalRecommendation -= safetyLimitBolusIncluded ? safetyLimitAmount : 0 + } + } + + if carbBolusIncluded, cobCorrectionBolusIncluded, bgCorrectionBolusIncluded, maxExcessBolusIncluded, safetyLimitBolusIncluded { + totalRecommendation = recommendation.amount // avoid possible rounding issues + } else { + totalRecommendation = round(1000 * totalRecommendation) / 1000 + } + + recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: delegate.roundBolusVolume(units: max(0, totalRecommendation))) switch recommendation.notice { case .glucoseBelowSuspendThreshold: @@ -680,10 +851,24 @@ final class BolusEntryViewModel: ObservableObject { notice = nil } } else { + carbBolus = nil + cobCorrectionBolus = nil + cobCorrectionBolusIncluded = self.cobCorrectionBolusIncluded + bgCorrectionBolus = nil + bgCorrectionBolusIncluded = self.bgCorrectionBolusIncluded + maxExcessBolus = nil + safetyLimitBolus = nil recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0) notice = nil } } catch { + carbBolus = nil + cobCorrectionBolus = nil + cobCorrectionBolusIncluded = self.cobCorrectionBolusIncluded + bgCorrectionBolus = nil + bgCorrectionBolusIncluded = self.bgCorrectionBolusIncluded + maxExcessBolus = nil + safetyLimitBolus = nil recommendedBolus = nil switch error { @@ -700,10 +885,17 @@ final class BolusEntryViewModel: ObservableObject { DispatchQueue.main.async { let priorRecommendedBolus = self.recommendedBolus + self.carbBolus = carbBolus + self.cobCorrectionBolus = cobCorrectionBolus + self.cobCorrectionBolusIncluded = cobCorrectionBolusIncluded + self.bgCorrectionBolus = bgCorrectionBolus + self.bgCorrectionBolusIncluded = bgCorrectionBolusIncluded + self.maxExcessBolus = maxExcessBolus + self.safetyLimitBolus = safetyLimitBolus self.recommendedBolus = recommendedBolus self.dosingDecision.manualBolusRecommendation = recommendation.map { ManualBolusRecommendationWithDate(recommendation: $0, date: now) } self.activeNotice = notice - + if priorRecommendedBolus != nil, priorRecommendedBolus != recommendedBolus, !self.enacting, @@ -729,7 +921,7 @@ final class BolusEntryViewModel: ObservableObject { return try state.recommendBolus( consideringPotentialCarbEntry: potentialCarbEntry, replacingCarbEntry: originalCarbEntry, - considerPositiveVelocityAndRC: FeatureFlags.usePositiveMomentumAndRCForManualBoluses + considerPositiveVelocityAndRC: FeatureFlags.usePositiveMomentumAndRCForManualBoluses ) } } @@ -789,15 +981,43 @@ final class BolusEntryViewModel: ObservableObject { chartDateInterval = DateInterval(start: chartStartDate, duration: .hours(totalHours)) } - func formatBolusAmount(_ bolusAmount: Double) -> String { - bolusAmountFormatter.string(from: bolusAmount) ?? String(bolusAmount) + func formatBolusAmount(_ bolusAmount: Double, forBreakdown: Bool = false) -> String { + let formatter = forBreakdown ? breakdownBolusAmountFormatter : bolusAmountFormatter + return formatter.string(from: bolusAmount) ?? String(bolusAmount) } + var carbBolusString: String { + return bolusString(carbBolusAmount, forBreakdown: true) + } + var cobCorrectionBolusString: String { + return bolusString(cobCorrectionBolusAmount, forBreakdown: true) + } + var bgCorrectionBolusString: String { + return bolusString(bgCorrectionBolusAmount, forBreakdown: true) + } + var negativeMaxExcessBolusString: String { + negativeBolusString(amount: maxExcessBolusAmount) + } + var negativeSafetyLimitString: String { + negativeBolusString(amount: safetyLimitBolusAmount) + } + + func negativeBolusString(amount: Double?) -> String { + guard amount != nil else { + return bolusString(nil, forBreakdown: true) + } + return bolusString(-amount!, forBreakdown: true) + } + var recommendedBolusString: String { - guard let amount = recommendedBolusAmount else { + return bolusString(recommendedBolusAmount, forBreakdown: false) + } + + func bolusString(_ bolusAmount: Double?, forBreakdown: Bool) -> String { + guard let amount = bolusAmount else { return "–" } - return formatBolusAmount(amount) + return formatBolusAmount(amount, forBreakdown: forBreakdown) } func updateEnteredBolus(_ enteredBolusString: String) { diff --git a/Loop/Views/AutoBolusCarbsSelectionView.swift b/Loop/Views/AutoBolusCarbsSelectionView.swift new file mode 100644 index 0000000000..bea4685889 --- /dev/null +++ b/Loop/Views/AutoBolusCarbsSelectionView.swift @@ -0,0 +1,57 @@ +// +// AutoBolusCarbsSelectionView.swift +// Loop +// +// Created by Moti Nisenson-Ken on 23/12/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// + +import Foundation +import SwiftUI +import LoopKit +import LoopKitUI + +public struct AutoBolusCarbsSelectionView: View { + @Binding var isAutoBolusCarbsEnabled: Bool + @Binding var autoBolusCarbsActiveByDefault: Bool + + public var body: some View { + + ScrollView { + VStack(spacing: 10) { + Text(NSLocalizedString("Auto-Bolus Carbs", comment: "Title for auto-bolus carbs experiment description")) + .font(.headline) + .padding(.bottom, 20) + + Divider() + + Text(String(format: NSLocalizedString("Auto-Bolus Carbs (ABC) is a modification of how Loop corrects each loop cycle. When enabled and active, Loop will check how much insulin is needed to cover COB (similar to doing a manual bolus but without correcting for BG). If this amount is greater than the usual correction, a bolus for that amount will be given. Overrides can also be used to activate or deactivate. When ABC is enabled and active a %@ will appear beside Active Carbohydrates on the status screen.", comment: "Description of Auto-Bolus Carbs toggles."), "🔸")) + .foregroundColor(.secondary) + Divider() + + Toggle(NSLocalizedString("Auto-Bolus Carbs Enabled", comment: "Title for Auto-Bolus Carbs Enabled toggle"), isOn: $isAutoBolusCarbsEnabled) + .onChange(of: isAutoBolusCarbsEnabled) { newValue in + UserDefaults.standard.autoBolusCarbsEnabled = newValue + } + .padding(.top, 20) + + Toggle(NSLocalizedString("Auto-Bolus Carbs Active by Default", comment: "Title for Auto-Bolus Carbs Active by Default toggle"), isOn: $autoBolusCarbsActiveByDefault) + .onChange(of: autoBolusCarbsActiveByDefault) { newValue in + UserDefaults.standard.autoBolusCarbsActiveByDefault = newValue + } + .padding(.top, 20) + // in the future consider disabling unless available +// .disabled(isAutoBolusCarbsAvailable) + } + .padding() + } + .navigationBarTitleDisplayMode(.inline) + } + +} + +struct AutoBolusCarbsSelectionView_Previews: PreviewProvider { + static var previews: some View { + AutoBolusCarbsSelectionView(isAutoBolusCarbsEnabled: .constant(true), autoBolusCarbsActiveByDefault: .constant(false)) + } +} diff --git a/Loop/Views/BolusEntryView.swift b/Loop/Views/BolusEntryView.swift index 4dd0c11a52..78d23cf6fa 100644 --- a/Loop/Views/BolusEntryView.swift +++ b/Loop/Views/BolusEntryView.swift @@ -191,7 +191,7 @@ struct BolusEntryView: View { if viewModel.isManualGlucoseEntryEnabled && viewModel.potentialCarbEntry != nil { potentialCarbEntryRow } - + if viewModel.isManualGlucoseEntryEnabled || viewModel.potentialCarbEntry != nil { recommendedBolusRow } @@ -226,20 +226,174 @@ struct BolusEntryView: View { } } } - + + private func displayRecommendationBreakdown() -> Bool { + if viewModel.potentialCarbEntry != nil { + return viewModel.bgCorrectionBolus != nil && (viewModel.carbBolus != nil || viewModel.cobCorrectionBolus != nil) + } else { + return viewModel.bgCorrectionBolus != nil + } + } + + @State + private var recommendationBreakdownExpanded = false + + @ViewBuilder private var recommendedBolusRow: some View { - HStack { - Text("Recommended Bolus", comment: "Label for recommended bolus row on bolus screen") - Spacer() + let breakdownFont = Font.subheadline + Section { HStack(alignment: .firstTextBaseline) { - Text(viewModel.recommendedBolusString) - .font(.title) - .foregroundColor(Color(.label)) - bolusUnitsLabel + Text("Recommended Bolus", comment: "Label for recommended bolus row on bolus screen") + if displayRecommendationBreakdown() { + Image(systemName: "chevron.forward.circle") + .imageScale(.small) + .foregroundColor(.accentColor) + .rotationEffect(.degrees(recommendationBreakdownExpanded ? 90 : 0)) + } + Spacer() + HStack(alignment: .firstTextBaseline) { + Text(viewModel.recommendedBolusString) + .font(.title) + .foregroundColor(Color(.label)) + bolusUnitsLabel + } + } + .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + .onTapGesture { + if displayRecommendationBreakdown() { + recommendationBreakdownExpanded.toggle() + } + } + if recommendationBreakdownExpanded { + VStack { + if viewModel.potentialCarbEntry != nil, viewModel.carbBolus != nil { + HStack { + Text(" ") + Image(systemName: "checkmark") + .imageScale(.small) + .foregroundColor(.accentColor) + .opacity(viewModel.carbBolusIncluded ? 1 : 0) + Text("Carb Entry", comment: "Label for carb bolus row on bolus screen") + .font(breakdownFont) + Spacer() + HStack(alignment: .firstTextBaseline) { + Text(viewModel.carbBolusString) + .font(.subheadline) + .foregroundColor(Color(.label)) + breakdownBolusUnitsLabel + } + } + .accessibilityElement(children: .combine) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.carbBolusIncluded.toggle() + } + } + if viewModel.cobCorrectionBolus != nil { + HStack { + Text(" ") + Image(systemName: "checkmark") + .imageScale(.small) + .foregroundColor(.accentColor) + .opacity(viewModel.cobCorrectionBolusIncluded ? 1 : 0) + Text("COB Correction", comment: "Label for COB correction bolus row on bolus screen") + .font(breakdownFont) + Spacer() + HStack(alignment: .firstTextBaseline) { + Text(viewModel.cobCorrectionBolusString) + .font(breakdownFont) + .foregroundColor(Color(.label)) + breakdownBolusUnitsLabel + } + } + .accessibilityElement(children: .combine) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.cobCorrectionBolusIncluded.toggle() + viewModel.userChangedCobCorrectionBolusIncluded = true + } + + } + if viewModel.bgCorrectionBolus != nil { + HStack { + Text(" ") + Image(systemName: "checkmark") + .imageScale(.small) + .foregroundColor(.accentColor) + .opacity(viewModel.bgCorrectionBolusIncluded ? 1 : 0) + Text("BG Correction", comment: "Label for BG correction bolus row on bolus screen") + .font(breakdownFont) + Spacer() + HStack(alignment: .firstTextBaseline) { + Text(viewModel.bgCorrectionBolusString) + .font(breakdownFont) + .foregroundColor(Color(.label)) + breakdownBolusUnitsLabel + } + } + .accessibilityElement(children: .combine) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.bgCorrectionBolusIncluded.toggle() + viewModel.userChangedBgCorrectionBolusIncluded = true + } + } + if viewModel.maxExcessBolus != nil { + HStack { + Text(" ") + Image(systemName: "checkmark") + .imageScale(.small) + .foregroundColor(.accentColor) + .opacity(viewModel.maxExcessBolusIncluded ? 1 : 0) + Text("Max Bolus Limit", comment: "Label for max bolus row on bolus screen") + .font(breakdownFont) + Spacer() + HStack(alignment: .firstTextBaseline) { + Text(viewModel.negativeMaxExcessBolusString) + .font(breakdownFont) + .foregroundColor(Color(.label)) + breakdownBolusUnitsLabel + } + } + .accessibilityElement(children: .combine) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.maxExcessBolusIncluded.toggle() + } + } + if viewModel.safetyLimitBolus != nil { + HStack { + Text(" ") + Image(systemName: "checkmark") + .imageScale(.small) + .foregroundColor(.accentColor) + .opacity(viewModel.safetyLimitBolusIncluded ? 1 : 0) + Text("Glucose Safety Limit", comment: "Label for glucose safety limit row on bolus screen") + .font(breakdownFont) + Spacer() + HStack(alignment: .firstTextBaseline) { + Text(viewModel.negativeSafetyLimitString) + .font(breakdownFont) + .foregroundColor(Color(.label)) + breakdownBolusUnitsLabel + } + } + .accessibilityElement(children: .combine) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.safetyLimitBolusIncluded.toggle() + } + } + } + .accessibilityElement(children: .combine) + .transition(.slide) + .animation(.smooth, value: recommendationBreakdownExpanded) } } - .accessibilityElement(children: .combine) + } + private func didBeginEditing() { if !editedBolusAmount { @@ -275,6 +429,12 @@ struct BolusEntryView: View { Text(QuantityFormatter(for: .internationalUnit()).localizedUnitStringWithPlurality()) .foregroundColor(Color(.secondaryLabel)) } + + private var breakdownBolusUnitsLabel: some View { + Text(QuantityFormatter(for: .internationalUnit()).localizedUnitStringWithPlurality()) + .font(.footnote) + .foregroundColor(Color(.secondaryLabel)) + } private var enteredBolusStringBinding: Binding { Binding( diff --git a/Loop/Views/ManualEntryDoseView.swift b/Loop/Views/ManualEntryDoseView.swift index e81dccdabb..04b95a6cd7 100644 --- a/Loop/Views/ManualEntryDoseView.swift +++ b/Loop/Views/ManualEntryDoseView.swift @@ -164,7 +164,7 @@ struct ManualEntryDoseView: View { private var insulinTypePicker: some View { ExpandablePicker( with: viewModel.insulinTypePickerOptions, - selectedValue: $viewModel.selectedInsulinType, + selectedValue: $viewModel.selectedInsulinType, label: NSLocalizedString("Insulin Type", comment: "Insulin type label") ) } diff --git a/Loop/Views/SettingsView+algorithmExperimentsSection.swift b/Loop/Views/SettingsView+algorithmExperimentsSection.swift index 54bd2c71a0..bbdeca71d3 100644 --- a/Loop/Views/SettingsView+algorithmExperimentsSection.swift +++ b/Loop/Views/SettingsView+algorithmExperimentsSection.swift @@ -41,6 +41,9 @@ public struct ExperimentRow: View { public struct ExperimentsSettingsView: View { @State private var isGlucoseBasedApplicationFactorEnabled = UserDefaults.standard.glucoseBasedApplicationFactorEnabled @State private var isIntegralRetrospectiveCorrectionEnabled = UserDefaults.standard.integralRetrospectiveCorrectionEnabled + @State private var isAutoBolusCarbsAvailable = UserDefaults.standard.autoBolusCarbsEnabled + @State private var autoBolusCarbsActiveByDefault = UserDefaults.standard.autoBolusCarbsActiveByDefault + var automaticDosingStrategy: AutomaticDosingStrategy public var body: some View { @@ -70,6 +73,11 @@ public struct ExperimentsSettingsView: View { name: NSLocalizedString("Integral Retrospective Correction", comment: "Title of integral retrospective correction experiment"), enabled: isIntegralRetrospectiveCorrectionEnabled) } + NavigationLink(destination: AutoBolusCarbsSelectionView(isAutoBolusCarbsEnabled: $isAutoBolusCarbsAvailable, autoBolusCarbsActiveByDefault: $autoBolusCarbsActiveByDefault)) { + ExperimentRow( + name: NSLocalizedString("Auto-Bolus Carbs", comment: "Title of auto-bolus carbs experiment"), + enabled: isAutoBolusCarbsAvailable) + } Spacer() } .padding() @@ -83,6 +91,8 @@ extension UserDefaults { private enum Key: String { case GlucoseBasedApplicationFactorEnabled = "com.loopkit.algorithmExperiments.glucoseBasedApplicationFactorEnabled" case IntegralRetrospectiveCorrectionEnabled = "com.loopkit.algorithmExperiments.integralRetrospectiveCorrectionEnabled" + case AutoBolusCarbsEnabled = "com.loopkit.algorithmExperiments.autoBolusCarbsEnabled" + case AutoBolusCarbsActiveByDefault = "com.loopkit.algorithmExperiments.autoBolusCarbsActiveByDefault" } var glucoseBasedApplicationFactorEnabled: Bool { @@ -102,5 +112,22 @@ extension UserDefaults { set(newValue, forKey: Key.IntegralRetrospectiveCorrectionEnabled.rawValue) } } + + var autoBolusCarbsEnabled: Bool { + get { + bool(forKey: Key.AutoBolusCarbsEnabled.rawValue) as Bool + } + set { + set(newValue, forKey: Key.AutoBolusCarbsEnabled.rawValue) + } + } + var autoBolusCarbsActiveByDefault: Bool { + get { + bool(forKey: Key.AutoBolusCarbsActiveByDefault.rawValue) as Bool + } + set { + set(newValue, forKey: Key.AutoBolusCarbsActiveByDefault.rawValue) + } + } } diff --git a/LoopTests/Fixtures/high_and_stable/high_and_stable_carb_effect.json b/LoopTests/Fixtures/high_and_stable/high_and_stable_carb_effect.json index 64848ef5a2..aa78c823b5 100644 --- a/LoopTests/Fixtures/high_and_stable/high_and_stable_carb_effect.json +++ b/LoopTests/Fixtures/high_and_stable/high_and_stable_carb_effect.json @@ -3,8 +3,7 @@ "date": "2020-08-12T12:05:00", "unit": "mg/dL", "amount": 0.0 - }, - { + }, { "date": "2020-08-12T12:10:00", "unit": "mg/dL", "amount": 0.0 @@ -319,4 +318,4 @@ "unit": "mg/dL", "amount": 22.5 } -] \ No newline at end of file +] diff --git a/LoopTests/Managers/LoopDataManagerDosingTests.swift b/LoopTests/Managers/LoopDataManagerDosingTests.swift index a1f26a0e92..d6ca0bc7d3 100644 --- a/LoopTests/Managers/LoopDataManagerDosingTests.swift +++ b/LoopTests/Managers/LoopDataManagerDosingTests.swift @@ -213,6 +213,171 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { XCTAssertEqual(4.63, recommendedBasal!.unitsPerHour, accuracy: defaultAccuracy) } + func testBeneathRangeForAutoBolusCarbs() { + // this scenario starts beneath the correction range + setUp(for: .highAndRisingWithCOB, correctionRanges: correctionRange(150.0), autoBolusCarbs: true) + let updateGroup = DispatchGroup() + updateGroup.enter() + var recommendedBolus: Double? + var manualBolusRecommendation: ManualBolusRecommendation? + self.loopDataManager.getLoopState { _, state in + recommendedBolus = state.recommendedAutomaticDose?.recommendation.bolusUnits + manualBolusRecommendation = try? state.recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: FeatureFlags.usePositiveMomentumAndRCForManualBoluses) + updateGroup.leave() + } + // We need to wait until the task completes to get outputs + updateGroup.wait() + + XCTAssertNotNil(recommendedBolus) + XCTAssertEqual(min(manualBolusRecommendation!.amount, manualBolusRecommendation!.bolusBreakdown!.cobCorrectionAmount), recommendedBolus!, accuracy: defaultAccuracy) + } + + func testBeneathRangeForAutoBolusCarbsAutoIobMax() { + // this scenario starts beneath the correction range + // autoIobMax = 2*maxBolus and mockDoseStore has IOB of 9.5 - so headroom is 0.1 + setUp(for: .highAndRisingWithCOB, maxBolus: 4.8, correctionRanges: correctionRange(150.0), autoBolusCarbs: true) + let updateGroup = DispatchGroup() + updateGroup.enter() + var recommendedBolus: Double? + self.loopDataManager.getLoopState { _, state in + recommendedBolus = state.recommendedAutomaticDose?.recommendation.bolusUnits + updateGroup.leave() + } + // We need to wait until the task completes to get outputs + updateGroup.wait() + + XCTAssertNotNil(recommendedBolus) + XCTAssertEqual(0.1, recommendedBolus!, accuracy: defaultAccuracy) + } + + func testHighAndStableWithAutoBolusCarbsForAutoBolusResult() { + // note that the default setup for .highAndStable has a _carb_effect file reflecting a 5g carb effect (with 45 ISF) + // predicted glucose starts from 200 and goes down to 176.21882841682697 (taking into account _insulin_effect) + let isf = 45.0 + let cir = 10.0 + + let expectedBgCorrectionAmount = 1.82 + (200 - 176.21882841682697) / isf // COB correction is to 200. BG correction is the rest + + // 0.4*(cobCorrection + bgCorrection) will be the dosage compared against + // for this test we want cobCorrection < 0.4*(cobCorrection + bgCorrection) + // in other words we need cobCorrection < 2/3 * bgCorrection + let expectedCobCorrectionAmount = 0.6 * expectedBgCorrectionAmount + + let carbValue = 5.0 + cir * ((200 - 176.21882841682697) / isf + expectedCobCorrectionAmount) + + setUp(for: .highAndStable, dosingStrategy: .automaticBolus, predictCarbGlucoseEffects: true, + carbHistorySupplier: {[StoredCarbEntry(startDate: $0, quantity: HKQuantity(unit: .gram(), doubleValue: carbValue))]}, autoBolusCarbs: true) + + let updateGroup = DispatchGroup() + updateGroup.enter() + var recommendedBolus: Double? + self.loopDataManager.getLoopState { _, state in + recommendedBolus = state.recommendedAutomaticDose?.recommendation.bolusUnits + updateGroup.leave() + } + // We need to wait until the task completes to get outputs + updateGroup.wait() + + XCTAssertNotNil(recommendedBolus) + XCTAssertEqual(0.4 * (expectedCobCorrectionAmount + expectedBgCorrectionAmount), recommendedBolus!, accuracy: defaultAccuracy) + } + + func testHighAndStableWithAutoBolusCarbsForABCResultVsAutoBolus() { + // note that the default setup for .highAndStable has a _carb_effect file reflecting a 5g carb effect (with 45 ISF) + // predicted glucose starts from 200 and goes down to 176.21882841682697 (taking into account _insulin_effect) + let isf = 45.0 + let cir = 10.0 + + let expectedBgCorrectionAmount = 1.82 + (200 - 176.21882841682697) / isf // COB correction is to 200. BG correction is the rest + + // 0.4*(cobCorrection + bgCorrection) will be the dosage compared against + // for this test we want cobCorrection < 0.4*(cobCorrection + bgCorrection) + // in other words we need cobCorrection < 2/3 * bgCorrection + let expectedCobCorrectionAmount = 0.7 * expectedBgCorrectionAmount + + let carbValue = 5.0 + cir * ((200 - 176.21882841682697) / isf + expectedCobCorrectionAmount) + + setUp(for: .highAndStable, dosingStrategy: .automaticBolus, predictCarbGlucoseEffects: true, + carbHistorySupplier: {[StoredCarbEntry(startDate: $0, quantity: HKQuantity(unit: .gram(), doubleValue: carbValue))]}, autoBolusCarbs: true) + + let updateGroup = DispatchGroup() + updateGroup.enter() + var recommendedBolus: Double? + self.loopDataManager.getLoopState { _, state in + recommendedBolus = state.recommendedAutomaticDose?.recommendation.bolusUnits + updateGroup.leave() + } + // We need to wait until the task completes to get outputs + updateGroup.wait() + + XCTAssertNotNil(recommendedBolus) + XCTAssertEqual(expectedCobCorrectionAmount, recommendedBolus!, accuracy: defaultAccuracy) + } + + func testHighAndStableWithAutoBolusCarbsForTempBasalResult() { + // note that the default setup for .highAndStable has a _carb_effect file reflecting a 5g carb effect (with 45 ISF) + // predicted glucose starts from 200 and goes down to 176.21882841682697 (taking into account _insulin_effect) + let isf = 45.0 + let cir = 10.0 + + let expectedBgCorrectionAmount = 1.82 + (200 - 176.21882841682697) / isf // COB correction is to 200. BG correction is the rest + + // (cobCorrection + bgCorrection)/6 will be the dosage compared against + // for this test we want cobCorrection < (cobCorrection + bgCorrection)/6 + // in other words we need cobCorrection < bgCorrection / 5 + let expectedCobCorrectionAmount = expectedBgCorrectionAmount / 6 + + let carbValue = 5.0 + cir * ((200 - 176.21882841682697) / isf + expectedCobCorrectionAmount) + + setUp(for: .highAndStable, maxBasalRate: 7.0, predictCarbGlucoseEffects: true, + carbHistorySupplier: {[StoredCarbEntry(startDate: $0, quantity: HKQuantity(unit: .gram(), doubleValue: carbValue))]}, autoBolusCarbs: true) + + let updateGroup = DispatchGroup() + updateGroup.enter() + var recommendedBasal: TempBasalRecommendation? + self.loopDataManager.getLoopState { _, state in + recommendedBasal = state.recommendedAutomaticDose?.recommendation.basalAdjustment + updateGroup.leave() + } + // We need to wait until the task completes to get outputs + updateGroup.wait() + + // unadjusted basal rate is 1.0 + XCTAssertEqual(1.0 + 2*(expectedBgCorrectionAmount + expectedCobCorrectionAmount), recommendedBasal!.unitsPerHour, accuracy: defaultAccuracy) + } + + func testHighAndStableWithAutoBolusCarbsForABCResultvsTempBasal() { + // note that the default setup for .highAndStable has a _carb_effect file reflecting a 5g carb effect (with 45 ISF) + // predicted glucose starts from 200 and goes down to 176.21882841682697 (taking into account _insulin_effect) + let isf = 45.0 + let cir = 10.0 + + let expectedBgCorrectionAmount = 1.82 + (200 - 176.21882841682697) / isf // COB correction is to 200. BG correction is the rest + + // (cobCorrection + bgCorrection)/6 will be the dosage compared against + // for this test we want cobCorrection > (cobCorrection + bgCorrection)/6 + // in other words we need cobCorrection > bgCorrection / 5 + let expectedCobCorrectionAmount = expectedBgCorrectionAmount / 4 + + let carbValue = 5.0 + cir * ((200 - 176.21882841682697) / isf + expectedCobCorrectionAmount) + + setUp(for: .highAndStable, predictCarbGlucoseEffects: true, + carbHistorySupplier: {[StoredCarbEntry(startDate: $0, quantity: HKQuantity(unit: .gram(), doubleValue: carbValue))]}, autoBolusCarbs: true) + + let updateGroup = DispatchGroup() + updateGroup.enter() + var recommendedBolus: Double? + self.loopDataManager.getLoopState { _, state in + recommendedBolus = state.recommendedAutomaticDose?.recommendation.bolusUnits + updateGroup.leave() + } + // We need to wait until the task completes to get outputs + updateGroup.wait() + + XCTAssertNotNil(recommendedBolus) + XCTAssertEqual(expectedCobCorrectionAmount, recommendedBolus!, accuracy: defaultAccuracy) + } + func testHighAndFalling() { setUp(for: .highAndFalling) let predictedGlucoseOutput = loadLocalDateGlucoseEffect("high_and_falling_predicted_glucose") @@ -475,9 +640,26 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { XCTAssertNil(dosingDecisionStore.dosingDecisions[0].manualBolusRequested) NotificationCenter.default.removeObserver(observer) } + + func dummyReplacementEntry() -> StoredCarbEntry{ + StoredCarbEntry(startDate: now, quantity: HKQuantity(unit: .gram(), doubleValue: -1)) + } + + func dummyCarbEntry() -> NewCarbEntry { + NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 1E-50), startDate: now.addingTimeInterval(TimeInterval(days: -2)), + foodType: nil, absorptionTime: TimeInterval(hours: 3)) + } + + func correctionRange(_ value: Double) -> GlucoseRangeSchedule { + correctionRange(value, value) + } + + func correctionRange(_ minValue: Double, _ maxValue: Double) -> GlucoseRangeSchedule { + GlucoseRangeSchedule(unit: HKUnit.milligramsPerDeciliter, dailyItems: [RepeatingScheduleValue(startTime: TimeInterval(0), value: DoubleRange(minValue: minValue, maxValue: maxValue))])! + } func testLoopGetStateRecommendsManualBolus() { - setUp(for: .highAndStable) + setUp(for: .flatAndStable, correctionRanges: correctionRange(106.26136802382213 - 1.82 * 55), suspendThresholdValue: 0.0) let exp = expectation(description: #function) var recommendedBolus: ManualBolusRecommendation? loopDataManager.getLoopState { (_, loopState) in @@ -486,6 +668,322 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { } wait(for: [exp], timeout: 100000.0) XCTAssertEqual(recommendedBolus!.amount, 1.82, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.bgCorrectionAmount, 1.82, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.cobCorrectionAmount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.carbsAmount!, 0, accuracy: 0.01) + XCTAssertNil(recommendedBolus!.missingAmount) + } + + func testLoopGetStateRecommendsManualBolusMaxBolusClamping() { + setUp(for: .flatAndStable, maxBolus: 1, correctionRanges: correctionRange(106.26136802382213 - 1.82 * 55), suspendThresholdValue: 0.0) + let exp = expectation(description: #function) + var recommendedBolus: ManualBolusRecommendation? + loopDataManager.getLoopState { (_, loopState) in + recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: true) + exp.fulfill() + } + wait(for: [exp], timeout: 100000.0) + XCTAssertEqual(recommendedBolus!.amount, 1, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.bgCorrectionAmount, 1.82, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.cobCorrectionAmount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.carbsAmount!, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.missingAmount!, 0.82, accuracy: 0.01) + } + + func testLoopGetStateRecommendsManualBolusForCob() { + // note that the default setup for .highAndStable has a _carb_effect file reflecting a 5g carb effect (with 45 ISF) + // predicted glucose starts from 200 and goes down to 176.21882841682697 (taking into account _insulin_effect) + let isf = 45.0 + let cir = 10.0 + + let expectedCobCorrectionAmount = 0.5 + let expectedBgCorrectionAmount = 1.82 + (200 - 176.21882841682697) / isf // COB correction is to 200. BG correction is the rest + + let carbValue = 5.0 + cir * ((200 - 176.21882841682697) / isf + expectedCobCorrectionAmount) + + setUp(for: .highAndStable, predictCarbGlucoseEffects: true, + carbHistorySupplier: {[StoredCarbEntry(startDate: $0, quantity: HKQuantity(unit: .gram(), doubleValue: carbValue))]}) + + let exp = expectation(description: #function) + + var recommendedBolus: ManualBolusRecommendation? + + loopDataManager.getLoopState { (_, loopState) in + recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: self.dummyCarbEntry(), replacingCarbEntry: self.dummyReplacementEntry(), considerPositiveVelocityAndRC: false) + exp.fulfill() + } + wait(for: [exp], timeout: 100000.0) + XCTAssertEqual(recommendedBolus!.amount, expectedBgCorrectionAmount + expectedCobCorrectionAmount, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.bgCorrectionAmount, expectedBgCorrectionAmount, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.cobCorrectionAmount, expectedCobCorrectionAmount, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.carbsAmount!, 0, accuracy: 0.01) + XCTAssertNil(recommendedBolus!.missingAmount) + } + + func testLoopGetStateRecommendsManualBolusForCobAndReducingCarbEntry() { + // note that the default setup for .highAndStable has a _carb_effect file reflecting a 5g carb effect (with 45 ISF) + // predicted glucose starts from 200 and goes down to 176.21882841682697 (taking into account _insulin_effect) + let isf = 45.0 + let cir = 10.0 + + let expectedCobCorrectionAmount = 0.6 + let expectedBgCorrectionAmount = 1.82 + (200 - 176.21882841682697) / isf // COB correction is to 200. BG correction is the rest + let expectedCarbsAmount = 0.5 + + let carbValue = 5.0 + cir * ((200 - 176.21882841682697) / isf + expectedCobCorrectionAmount) + + setUp(for: .highAndStable, predictCarbGlucoseEffects: true, + carbHistorySupplier: {[ + StoredCarbEntry(startDate: $0, quantity: HKQuantity(unit: .gram(), doubleValue: carbValue)), + StoredCarbEntry(startDate: $0, quantity: HKQuantity(unit: .gram(), doubleValue: 10)) + ]}) + + let exp = expectation(description: #function) + + let carbEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: expectedCarbsAmount * cir), startDate: now, foodType: nil, absorptionTime: TimeInterval(hours: 1)) + + var recommendedBolus: ManualBolusRecommendation? + + loopDataManager.getLoopState { (_, loopState) in + + recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: carbEntry, replacingCarbEntry: StoredCarbEntry(startDate: self.now, quantity: HKQuantity(unit: .gram(), doubleValue: 10)), considerPositiveVelocityAndRC: false) + exp.fulfill() + } + wait(for: [exp], timeout: 100000.0) + XCTAssertEqual(recommendedBolus!.amount, expectedCarbsAmount + expectedBgCorrectionAmount + expectedCobCorrectionAmount, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.bgCorrectionAmount, expectedBgCorrectionAmount, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.cobCorrectionAmount, expectedCobCorrectionAmount, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.carbsAmount!, expectedCarbsAmount, accuracy: 0.01) + XCTAssertNil(recommendedBolus!.missingAmount) + } + + func testLoopGetStateRecommendsManualBolusForZeroCorrectionCobAndCarbEntry() { + // note that the default setup for .highAndStable has a _carb_effect file reflecting a 5g carb effect (with 45 ISF) + // predicted glucose starts from 200 and goes down to 176.21882841682697 (taking into account _insulin_effect) + let isf = 45.0 + let cir = 10.0 + + let expectedCobCorrectionAmount = 0.0 + let expectedCarbsAmount = 0.5 + let expectedBgOffset = -0.2 + let expectedBgCorrectionAmount = 1.82 + (200 - 176.21882841682697) / isf + expectedBgOffset + + + let carbValue = 5.0 + cir * ((200 - 176.21882841682697) / isf + expectedBgOffset) + + setUp(for: .highAndStable, predictCarbGlucoseEffects: true, + carbHistorySupplier: {[ + StoredCarbEntry(startDate: $0, quantity: HKQuantity(unit: .gram(), doubleValue: carbValue)) + ]}) + + let exp = expectation(description: #function) + + let carbEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: expectedCarbsAmount * cir), startDate: now, foodType: nil, absorptionTime: TimeInterval(hours: 1)) + + var recommendedBolus: ManualBolusRecommendation? + + loopDataManager.getLoopState { (_, loopState) in + + recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: carbEntry, replacingCarbEntry: self.dummyReplacementEntry(), considerPositiveVelocityAndRC: false) + exp.fulfill() + } + wait(for: [exp], timeout: 100000.0) + XCTAssertEqual(recommendedBolus!.amount, expectedCarbsAmount + expectedBgCorrectionAmount + expectedCobCorrectionAmount, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.bgCorrectionAmount, expectedBgCorrectionAmount, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.cobCorrectionAmount, expectedCobCorrectionAmount, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.carbsAmount!, expectedCarbsAmount, accuracy: 0.01) + XCTAssertNil(recommendedBolus!.missingAmount) + } + + func testLoopGetStateRecommendsManualBolusForCarbEntry() { + setUp(for: .highAndStable, predictCarbGlucoseEffects: true) + let exp = expectation(description: #function) + + var recommendedBolus: ManualBolusRecommendation? + + let carbEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 5.0), startDate: now, foodType: nil, absorptionTime: TimeInterval(hours: 1.0)) + loopDataManager.getLoopState { (_, loopState) in + recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: carbEntry, replacingCarbEntry: nil, considerPositiveVelocityAndRC: false) + exp.fulfill() + } + wait(for: [exp], timeout: 100000.0) + XCTAssertEqual(recommendedBolus!.amount, 2.32, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.bgCorrectionAmount, 1.82, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.cobCorrectionAmount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.carbsAmount!, 0.5, accuracy: 0.01) + XCTAssertNil(recommendedBolus!.missingAmount) + } + + func testLoopGetStateRecommendsManualBolusForCarbEntryMaxBolusClamping() { + setUp(for: .highAndStable, maxBolus: 1, predictCarbGlucoseEffects: true) + let exp = expectation(description: #function) + + var recommendedBolus: ManualBolusRecommendation? + + let carbEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 5.0), startDate: now, foodType: nil, absorptionTime: TimeInterval(hours: 1.0)) + loopDataManager.getLoopState { (_, loopState) in + recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: carbEntry, replacingCarbEntry: nil, considerPositiveVelocityAndRC: false) + exp.fulfill() + } + wait(for: [exp], timeout: 100000.0) + XCTAssertEqual(recommendedBolus!.amount, 1, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.bgCorrectionAmount, 1.82, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.cobCorrectionAmount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.carbsAmount!, 0.5, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.missingAmount!, 1.32, accuracy: 0.01) + } + + func testLoopGetStateRecommendsManualBolusForBeneathRange() { + setUp(for: .flatAndStable, correctionRanges: correctionRange(160)) + + let exp = expectation(description: #function) + var recommendedBolus: ManualBolusRecommendation? + loopDataManager.getLoopState { (_, loopState) in + recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: true) + exp.fulfill() + } + wait(for: [exp], timeout: 100000.0) + XCTAssertEqual(recommendedBolus!.amount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.bgCorrectionAmount, (106.21882841682697 - 160) / 55, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.cobCorrectionAmount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.carbsAmount!, 0, accuracy: 0.01) + XCTAssertNil(recommendedBolus!.missingAmount) + } + + func testLoopGetStateRecommendsManualBolusForInRangeAboveMidPoint() { + setUp(for: .flatAndStable, correctionRanges: correctionRange(80, 110)) + + let exp = expectation(description: #function) + var recommendedBolus: ManualBolusRecommendation? + loopDataManager.getLoopState { (_, loopState) in + recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: true) + exp.fulfill() + } + wait(for: [exp], timeout: 100000.0) + XCTAssertEqual(recommendedBolus!.amount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.bgCorrectionAmount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.cobCorrectionAmount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.carbsAmount!, 0, accuracy: 0.01) + XCTAssertNil(recommendedBolus!.missingAmount) + } + + func testLoopGetStateRecommendsManualBolusForSuspendForCarbEntry() { + setUp(for: .highAndStable, predictCarbGlucoseEffects: true, correctionRanges: correctionRange(230), suspendThresholdValue: 220) + + let exp = expectation(description: #function) + var recommendedBolus: ManualBolusRecommendation? + + let carbEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 15.0), startDate: now, foodType: nil, absorptionTime: TimeInterval(hours: 1.0)) + loopDataManager.getLoopState { (_, loopState) in + recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: carbEntry, replacingCarbEntry: nil, considerPositiveVelocityAndRC: false) + exp.fulfill() + } + + wait(for: [exp], timeout: 100000.0) + XCTAssertEqual(recommendedBolus!.amount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.bgCorrectionAmount, (176.21882841682697 - 230) / 45, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.cobCorrectionAmount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.carbsAmount!, 1.5, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.missingAmount!, 1.5 + (176.21882841682697 - 230) / 45, accuracy: 0.01) + } + + func testLoopGetStateRecommendsManualBolusForBigAndSlowCarbEntry() { + setUp(for: .highAndStable, predictCarbGlucoseEffects: true, correctionRanges: correctionRange(176.2188), suspendThresholdValue: 176.218) + + let exp = expectation(description: #function) + var recommendedBolus: ManualBolusRecommendation? + + let carbEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 100.0), startDate: now, foodType: nil, absorptionTime: TimeInterval(hours: 4.0)) + loopDataManager.getLoopState { (_, loopState) in + recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: carbEntry, replacingCarbEntry: nil, considerPositiveVelocityAndRC: false) + exp.fulfill() + } + + wait(for: [exp], timeout: 100000.0) + XCTAssertEqual(recommendedBolus!.amount, 7.27, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.bgCorrectionAmount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.cobCorrectionAmount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.carbsAmount!, 9.99, accuracy: 0.01) // 9.99 and not 10 since there is 10 minute delay, leaving 0.01 remaining + XCTAssertEqual(recommendedBolus!.missingAmount!, 9.99 - 7.27, accuracy: 0.01) + } + + + func testLoopGetStateRecommendsManualBolusNoMissingForSuspendForCarbEntry() { + setUp(for: .highAndStable, predictCarbGlucoseEffects: true, correctionRanges: correctionRange(230), suspendThresholdValue: 220) + + let exp = expectation(description: #function) + var recommendedBolus: ManualBolusRecommendation? + + let carbEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 5.0), startDate: now, foodType: nil, absorptionTime: TimeInterval(hours: 1.0)) + loopDataManager.getLoopState { (_, loopState) in + recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: carbEntry, replacingCarbEntry: nil, considerPositiveVelocityAndRC: false) + exp.fulfill() + } + + wait(for: [exp], timeout: 100000.0) + XCTAssertEqual(recommendedBolus!.amount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.bgCorrectionAmount, (176.21882841682697 - 230) / 45, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.cobCorrectionAmount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.carbsAmount!, 0.5, accuracy: 0.01) + XCTAssertNil(recommendedBolus!.missingAmount) // carbsAmount + bgCorrectionAmount < 0, so nothing is missing + } + + func testLoopGetStateRecommendsManualBolusForSuspendNoCarbEntry() { + setUp(for: .highAndStable, predictCarbGlucoseEffects: true, correctionRanges: correctionRange(230), suspendThresholdValue: 180) + + let exp = expectation(description: #function) + var recommendedBolus: ManualBolusRecommendation? + + let carbEntry = NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 5.0), startDate: now, foodType: nil, absorptionTime: TimeInterval(hours: 1.0)) + loopDataManager.getLoopState { (_, loopState) in + recommendedBolus = try? loopState.recommendBolus(consideringPotentialCarbEntry: nil, replacingCarbEntry: nil, considerPositiveVelocityAndRC: false) + exp.fulfill() + } + + wait(for: [exp], timeout: 100000.0) + XCTAssertEqual(recommendedBolus!.amount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.bgCorrectionAmount, (176.21882841682697 - 230) / 45, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.cobCorrectionAmount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!.bolusBreakdown!.carbsAmount!, 0.0, accuracy: 0.01) + XCTAssertNil(recommendedBolus!.missingAmount) + } + + func testLoopGetStateRecommendsManualBolusForInRangeCarbEntry() { + setUp(for: .highAndStable, predictCarbGlucoseEffects: true, correctionRanges: correctionRange(170, 210)) + + let exp1 = expectation(description: #function) + var recommendedBolus1: ManualBolusRecommendation? + + let exp2 = expectation(description: #function) + var recommendedBolus2: ManualBolusRecommendation? + + // note that 176.218 + 5/10*45 < 210 + let carbEntry1 = NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 5), startDate: now, foodType: nil, absorptionTime: TimeInterval(hours: 1.0)) + loopDataManager.getLoopState { (_, loopState) in + recommendedBolus1 = try? loopState.recommendBolus(consideringPotentialCarbEntry: carbEntry1, replacingCarbEntry: nil, considerPositiveVelocityAndRC: false) + exp1.fulfill() + } + wait(for: [exp1], timeout: 100000.0) + + let carbEntry2 = NewCarbEntry(quantity: HKQuantity(unit: .gram(), doubleValue: 4.8), startDate: now, foodType: nil, absorptionTime: TimeInterval(hours: 1.0)) + loopDataManager.getLoopState { (_, loopState) in + recommendedBolus2 = try? loopState.recommendBolus(consideringPotentialCarbEntry: carbEntry2, replacingCarbEntry: nil, considerPositiveVelocityAndRC: false) + exp2.fulfill() + } + wait(for: [exp2], timeout: 100000.0) + + + XCTAssertEqual(recommendedBolus1!.amount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus1!.bolusBreakdown!.bgCorrectionAmount, -0.5, accuracy: 0.01) + XCTAssertEqual(recommendedBolus1!.bolusBreakdown!.cobCorrectionAmount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus1!.bolusBreakdown!.carbsAmount!, 0.5, accuracy: 0.01) + XCTAssertNil(recommendedBolus1!.missingAmount) + + XCTAssertEqual(recommendedBolus2!.amount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus2!.bolusBreakdown!.bgCorrectionAmount, -0.48, accuracy: 0.01) + XCTAssertEqual(recommendedBolus2!.bolusBreakdown!.cobCorrectionAmount, 0, accuracy: 0.01) + XCTAssertEqual(recommendedBolus2!.bolusBreakdown!.carbsAmount!, 0.48, accuracy: 0.01) + XCTAssertNil(recommendedBolus2!.missingAmount) } func testLoopGetStateRecommendsManualBolusWithMomentum() { diff --git a/LoopTests/Managers/LoopDataManagerTests.swift b/LoopTests/Managers/LoopDataManagerTests.swift index 32c7d66f19..fcaa1d50ad 100644 --- a/LoopTests/Managers/LoopDataManagerTests.swift +++ b/LoopTests/Managers/LoopDataManagerTests.swift @@ -116,7 +116,7 @@ class LoopDataManagerTests: XCTestCase { RepeatingScheduleValue(startTime: TimeInterval(75600), value: DoubleRange(minValue: 100, maxValue: 110)) ], timeZone: .utcTimeZone)! } - + // MARK: Mock stores var now: Date! var dosingDecisionStore: MockDosingDecisionStore! @@ -127,7 +127,15 @@ class LoopDataManagerTests: XCTestCase { basalDeliveryState: PumpManagerStatus.BasalDeliveryState? = nil, maxBolus: Double = 10, maxBasalRate: Double = 5.0, - dosingStrategy: AutomaticDosingStrategy = .tempBasalOnly) + dosingStrategy: AutomaticDosingStrategy = .tempBasalOnly, + predictCarbGlucoseEffects: Bool = false, + correctionRanges: GlucoseRangeSchedule? = nil, + suspendThresholdValue: Double? = nil, + // note that carbHistory is independent from carb effects; + // one can use dummy replacement carb entry to force recalculation when getting a manual bolus recommendation + carbHistorySupplier: ((Date) -> [StoredCarbEntry]?)? = nil, + autoBolusCarbs: Bool = false + ) { let basalRateSchedule = loadBasalRateScheduleFixture("basal_profile") let insulinSensitivitySchedule = InsulinSensitivitySchedule( @@ -145,10 +153,13 @@ class LoopDataManagerTests: XCTestCase { ], timeZone: .utcTimeZone )! + let glucoseTargets = correctionRanges ?? glucoseTargetRangeSchedule + + let suspendThreshold = suspendThresholdValue == nil ? suspendThreshold : GlucoseThreshold(unit: .milligramsPerDeciliter, value: suspendThresholdValue!) let settings = LoopSettings( dosingEnabled: false, - glucoseTargetRangeSchedule: glucoseTargetRangeSchedule, + glucoseTargetRangeSchedule: glucoseTargets, insulinSensitivitySchedule: insulinSensitivitySchedule, basalRateSchedule: basalRateSchedule, carbRatioSchedule: carbRatioSchedule, @@ -163,13 +174,15 @@ class LoopDataManagerTests: XCTestCase { doseStore.basalProfileApplyingOverrideHistory = doseStore.basalProfile doseStore.sensitivitySchedule = insulinSensitivitySchedule let glucoseStore = MockGlucoseStore(for: test) - let carbStore = MockCarbStore(for: test) - carbStore.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivitySchedule - carbStore.carbRatioSchedule = carbRatioSchedule let currentDate = glucoseStore.latestGlucose!.startDate now = currentDate + let carbStore = MockCarbStore(for: test, predictGlucose: predictCarbGlucoseEffects, carbHistory: carbHistorySupplier?(now)) + carbStore.insulinSensitivityScheduleApplyingOverrideHistory = insulinSensitivitySchedule + carbStore.carbRatioSchedule = carbRatioSchedule + + dosingDecisionStore = MockDosingDecisionStore() automaticDosingStatus = AutomaticDosingStatus(automaticDosingEnabled: true, isAutomaticDosingAllowed: true) loopDataManager = LoopDataManager( @@ -189,10 +202,17 @@ class LoopDataManagerTests: XCTestCase { automaticDosingStatus: automaticDosingStatus, trustedTimeOffset: { 0 } ) + + if autoBolusCarbs { + UserDefaults.standard.autoBolusCarbsEnabled = true + UserDefaults.standard.autoBolusCarbsActiveByDefault = true + } } override func tearDownWithError() throws { loopDataManager = nil + UserDefaults.standard.autoBolusCarbsEnabled = false + UserDefaults.standard.autoBolusCarbsActiveByDefault = false } } diff --git a/LoopTests/Mock Stores/MockCarbStore.swift b/LoopTests/Mock Stores/MockCarbStore.swift index 4a5c016eb5..15d27b4d7e 100644 --- a/LoopTests/Mock Stores/MockCarbStore.swift +++ b/LoopTests/Mock Stores/MockCarbStore.swift @@ -11,11 +11,13 @@ import LoopKit @testable import Loop class MockCarbStore: CarbStoreProtocol { + var predictGlucose: Bool var carbHistory: [StoredCarbEntry]? - init(for scenario: DosingTestScenario = .flatAndStable) { + init(for scenario: DosingTestScenario = .flatAndStable, predictGlucose: Bool = false, carbHistory: [StoredCarbEntry]? = nil) { self.scenario = scenario // The store returns different effect values based on the scenario - self.carbHistory = loadHistoricCarbEntries(scenario: scenario) + self.predictGlucose = predictGlucose + self.carbHistory = carbHistory ?? loadHistoricCarbEntries(scenario: scenario) } var scenario: DosingTestScenario @@ -52,14 +54,23 @@ class MockCarbStore: CarbStoreProtocol { return defaultAbsorptionTimes.slow * 2 } + var delay: TimeInterval = .minutes(10) var delta: TimeInterval = .minutes(5) var defaultAbsorptionTimes: CarbStore.DefaultAbsorptionTimes = (fast: .minutes(30), medium: .hours(3), slow: .hours(5)) + var absorptionTimeOverrun = CarbMath.defaultAbsorptionTimeOverrun + + // copied from CarbStore.CarbModelSettings defaults + var absorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption() + var initialAbsorptionTimeOverrun: Double = 1.5 + var adaptiveAbsorptionRateEnabled: Bool = false + var adaptiveRateStandbyIntervalFraction: Double = 0.2 + var authorizationRequired: Bool = false var sharingDenied: Bool = false - + func authorize(toShare: Bool, read: Bool, _ completion: @escaping (HealthKitSampleStoreResult) -> Void) { completion(.success(true)) } @@ -81,7 +92,44 @@ class MockCarbStore: CarbStoreProtocol { } func glucoseEffects(of samples: [Sample], startingAt start: Date, endingAt end: Date?, effectVelocities: [LoopKit.GlucoseEffectVelocity]) throws -> [LoopKit.GlucoseEffect] where Sample : LoopKit.CarbEntry { - return [] + + guard predictGlucose && samples.count > 0 else { + return [] + } + + // this is basically copied over from CarbStore + + let carbDates = samples.map { $0.startDate } + let maxCarbDate = carbDates.max()! + let minCarbDate = carbDates.min()! + + guard let carbRatio = self.carbRatioScheduleApplyingOverrideHistory?.between(start: minCarbDate, end: maxCarbDate), + let insulinSensitivity = self.insulinSensitivityScheduleApplyingOverrideHistory?.quantitiesBetween(start: minCarbDate, end: maxCarbDate) else + { + return [] + } + + return samples.map( + to: effectVelocities, + carbRatio: carbRatio, + insulinSensitivity: insulinSensitivity, + absorptionTimeOverrun: absorptionTimeOverrun, + defaultAbsorptionTime: defaultAbsorptionTimes.medium, + delay: delay, + initialAbsorptionTimeOverrun: initialAbsorptionTimeOverrun, + absorptionModel: absorptionModel, + adaptiveAbsorptionRateEnabled: adaptiveAbsorptionRateEnabled, + adaptiveRateStandbyIntervalFraction: adaptiveRateStandbyIntervalFraction + ).dynamicGlucoseEffects( + from: start, + to: end, + carbRatios: carbRatio, + insulinSensitivities: insulinSensitivity, + defaultAbsorptionTime: defaultAbsorptionTimes.medium, + absorptionModel: absorptionModel, + delay: delay, + delta: delta + ) } func getCarbsOnBoardValues(start: Date, end: Date?, effectVelocities: [GlucoseEffectVelocity]?, completion: @escaping (CarbStoreResult<[CarbValue]>) -> Void) { diff --git a/LoopTests/ViewModels/BolusEntryViewModelTests.swift b/LoopTests/ViewModels/BolusEntryViewModelTests.swift index 7f2c421ebf..43c0c5fcf0 100644 --- a/LoopTests/ViewModels/BolusEntryViewModelTests.swift +++ b/LoopTests/ViewModels/BolusEntryViewModelTests.swift @@ -699,17 +699,31 @@ class BolusEntryViewModelTests: XCTestCase { XCTAssertNil(bolusEntryViewModel.carbEntryDateAndAbsorptionTimeString) } + func is24Hour() -> Bool { + let dateFormat = DateFormatter.dateFormat(fromTemplate: "j", options: 0, locale: Locale.current)! + + return dateFormat.firstIndex(of: "a") == nil + } + func testCarbEntryDateAndAbsorptionTimeString() async throws { await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: mockPotentialCarbEntry) - XCTAssertEqual("12:00 PM + 0m", bolusEntryViewModel.carbEntryDateAndAbsorptionTimeString) + if is24Hour() { + XCTAssertEqual("12:00 + 0m", bolusEntryViewModel.carbEntryDateAndAbsorptionTimeString) + } else { + XCTAssertEqual("12:00 PM + 0m", bolusEntryViewModel.carbEntryDateAndAbsorptionTimeString) + } } func testCarbEntryDateAndAbsorptionTimeString2() async throws { let potentialCarbEntry = NewCarbEntry(quantity: BolusEntryViewModelTests.exampleCarbQuantity, startDate: Self.exampleStartDate, foodType: nil, absorptionTime: nil) await setUpViewModel(originalCarbEntry: mockOriginalCarbEntry, potentialCarbEntry: potentialCarbEntry) - XCTAssertEqual("12:00 PM", bolusEntryViewModel.carbEntryDateAndAbsorptionTimeString) + if is24Hour() { + XCTAssertEqual("12:00", bolusEntryViewModel.carbEntryDateAndAbsorptionTimeString) + } else { + XCTAssertEqual("12:00 PM", bolusEntryViewModel.carbEntryDateAndAbsorptionTimeString) + } } func testIsManualGlucosePromptVisible() throws { diff --git a/Scripts/capture-build-details.sh b/Scripts/capture-build-details.sh index 6122592374..07aab04a17 100755 --- a/Scripts/capture-build-details.sh +++ b/Scripts/capture-build-details.sh @@ -25,8 +25,8 @@ info() { } info_plist_path="${BUILT_PRODUCTS_DIR}/${CONTENTS_FOLDER_PATH}/BuildDetails.plist" -provisioning_profile_path="${HOME}/Library/MobileDevice/Provisioning Profiles/${EXPANDED_PROVISIONING_PROFILE}.mobileprovision" xcode_build_version=${XCODE_PRODUCT_BUILD_VERSION:-$(xcodebuild -version | grep version | cut -d ' ' -f 3)} + while [[ $# -gt 0 ]] do case $1 in @@ -47,7 +47,6 @@ fi if [ "${info_plist_path}" == "/" -o ! -e "${info_plist_path}" ]; then error "File does not exist: ${info_plist_path}" - #error "Must provide valid --info-plist-path, or have valid \${BUILT_PRODUCTS_DIR} and \${INFOPLIST_PATH} set." fi info "Gathering build details in ${PWD}" @@ -67,6 +66,17 @@ plutil -replace com-loopkit-Loop-srcroot -string "${PWD}" "${info_plist_path}" plutil -replace com-loopkit-Loop-build-date -string "$(date)" "${info_plist_path}" plutil -replace com-loopkit-Loop-xcode-version -string "${xcode_build_version}" "${info_plist_path}" +# Determine the provisioning profile path +if [ -z "${provisioning_profile_path}" ]; then + if [ -e "${HOME}/Library/MobileDevice/Provisioning Profiles/${EXPANDED_PROVISIONING_PROFILE}.mobileprovision" ]; then + provisioning_profile_path="${HOME}/Library/MobileDevice/Provisioning Profiles/${EXPANDED_PROVISIONING_PROFILE}.mobileprovision" + elif [ -e "${HOME}/Library/Developer/Xcode/UserData/Provisioning Profiles/${EXPANDED_PROVISIONING_PROFILE}.mobileprovision" ]; then + provisioning_profile_path="${HOME}/Library/Developer/Xcode/UserData/Provisioning Profiles/${EXPANDED_PROVISIONING_PROFILE}.mobileprovision" + else + warn "Provisioning profile not found in expected locations" + fi +fi + if [ -e "${provisioning_profile_path}" ]; then profile_expire_date=$(security cms -D -i "${provisioning_profile_path}" | plutil -p - | grep ExpirationDate | cut -b 23-) # Convert to plutil format diff --git a/Version.xcconfig b/Version.xcconfig index a7c7fe29d1..4634a20a76 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -7,7 +7,8 @@ // // Version [DEFAULT] -LOOP_MARKETING_VERSION = 3.5.0 +LOOP_MARKETING_VERSION = 3.4.4 + CURRENT_PROJECT_VERSION = 57 // Optional override