diff --git a/Common/FeatureFlags.swift b/Common/FeatureFlags.swift index 0c6440b564..a24ef1c453 100644 --- a/Common/FeatureFlags.swift +++ b/Common/FeatureFlags.swift @@ -40,7 +40,6 @@ struct FeatureFlagConfiguration: Decodable { let missedMealNotifications: Bool let allowAlgorithmExperiments: Bool - fileprivate init() { // Swift compiler config is inverse, since the default state is enabled. #if AUTOMATIC_BOLUS_DISABLED 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..f01090fca7 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -7,6 +7,9 @@ objects = { /* Begin PBXBuildFile section */ + 12F04F012D19791B002B2121 /* AutoBolusCarbsSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12F04F002D197907002B2121 /* AutoBolusCarbsSelectionView.swift */; }; + 12F4D9C82D4017D400FAEF5F /* SettingsView+underDevelopmentSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12F4D9C72D4017D400FAEF5F /* SettingsView+underDevelopmentSection.swift */; }; + 12F4D9C92D4017D400FAEF5F /* CarbBolusSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12F4D9C62D4017D400FAEF5F /* CarbBolusSelectionView.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 +746,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 12F04F002D197907002B2121 /* AutoBolusCarbsSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoBolusCarbsSelectionView.swift; sourceTree = ""; }; + 12F4D9C62D4017D400FAEF5F /* CarbBolusSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbBolusSelectionView.swift; sourceTree = ""; }; + 12F4D9C72D4017D400FAEF5F /* SettingsView+underDevelopmentSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsView+underDevelopmentSection.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 = ""; }; @@ -2270,12 +2276,15 @@ 439706E522D2E84900C81566 /* PredictionSettingTableViewCell.swift */, 1DE09BA824A3E23F009EE9F9 /* SettingsView.swift */, DDC389FB2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift */, + 12F4D9C72D4017D400FAEF5F /* SettingsView+underDevelopmentSection.swift */, C1DE5D22251BFC4D00439E49 /* SimpleBolusView.swift */, 43F64DD81D9C92C900D24DC6 /* TitleSubtitleTableViewCell.swift */, 4311FB9A1F37FE1B00D4C0A7 /* TitleSubtitleTextFieldTableViewCell.swift */, C1AF062229426300002C1B19 /* ManualGlucoseEntryRow.swift */, DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */, DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */, + 12F04F002D197907002B2121 /* AutoBolusCarbsSelectionView.swift */, + 12F4D9C62D4017D400FAEF5F /* CarbBolusSelectionView.swift */, ); path = Views; sourceTree = ""; @@ -3736,6 +3745,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 */, @@ -3827,6 +3837,8 @@ 1D05219D2469F1F5000EBBDE /* AlertStore.swift in Sources */, 439897371CD2F80600223065 /* AnalyticsServicesManager.swift in Sources */, 14D906F42A846510006EB79A /* FavoriteFoodsViewModel.swift in Sources */, + 12F4D9C82D4017D400FAEF5F /* SettingsView+underDevelopmentSection.swift in Sources */, + 12F4D9C92D4017D400FAEF5F /* CarbBolusSelectionView.swift in Sources */, DDC389FE2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift in Sources */, A9C62D842331700E00535612 /* DiagnosticLog+Subsystem.swift in Sources */, 895FE0952201234000FCF18A /* OverrideSelectionViewController.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..ecdbf65f6d 100644 --- a/Loop/View Controllers/StatusTableViewController.swift +++ b/Loop/View Controllers/StatusTableViewController.swift @@ -42,9 +42,9 @@ final class StatusTableViewController: LoopChartsTableViewController { var alertMuter: AlertMuter! var supportManager: SupportManager! - + lazy private var cancellables = Set() - + override func viewDidLoad() { super.viewDidLoad() @@ -116,6 +116,13 @@ final class StatusTableViewController: LoopChartsTableViewController { self?.reloadData(animated: true) } }, + + notificationCenter.addObserver(forName: .AlgorithmExperimentsChanged, object: UserDefaults.standard, queue: nil) { [weak self] (notification: Notification) in + DispatchQueue.main.async { + self?.refreshContext.update(with: .status) + self?.reloadData() + } + }, ] automaticDosingStatus.$automaticDosingEnabled @@ -601,6 +608,8 @@ final class StatusTableViewController: LoopChartsTableViewController { } else { self.currentCOBDescription = nil } + + self.currentAutoBolusCarbsActive = self.deviceManager.loopManager.autoBolusCarbsEnabledAndActive self.tableView.beginUpdates() if let hudView = self.hudView { @@ -677,6 +686,8 @@ final class StatusTableViewController: LoopChartsTableViewController { // MARK: COB private var currentCOBDescription: String? + + private var currentAutoBolusCarbsActive = false // MARK: - Loop Status Section Data @@ -987,7 +998,8 @@ final class StatusTableViewController: LoopChartsTableViewController { cell.setChartGenerator(generator: { [weak self] (frame) in return self?.statusCharts.glucoseChart(withFrame: frame)?.view }) - cell.setTitleLabelText(label: NSLocalizedString("Glucose", comment: "The title of the glucose and prediction graph")) + cell.setTitleLabelText(label: NSLocalizedString("Glucose", comment: "The title of the glucose and prediction graph")) + cell.doesNavigate = automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled case .iob: cell.setChartGenerator(generator: { [weak self] (frame) in @@ -1003,7 +1015,14 @@ 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") + + if currentAutoBolusCarbsActive { + cell.setTitleLabelText(label: String(format: "%@ %@", label, "🔸")) + } else { + cell.setTitleLabelText(label: label) + } } self.tableView(tableView, updateSubtitleFor: cell, at: indexPath) @@ -1142,6 +1161,7 @@ final class StatusTableViewController: LoopChartsTableViewController { } else { cell.setSubtitleLabel(label: nil) } + cell.doesNavigate = automaticDosingStatus.automaticDosingEnabled || !FeatureFlags.simpleBolusCalculatorEnabled case .iob: if let currentIOB = currentIOBDescription { @@ -1165,6 +1185,14 @@ final class StatusTableViewController: LoopChartsTableViewController { } else { cell.setSubtitleLabel(label: nil) } + + let label = NSLocalizedString("Active Carbohydrates", comment: "The title of the Carbs On-Board graph"); + + if currentAutoBolusCarbsActive { + cell.setTitleLabelText(label: String(format: "%@ %@", label, "🔸")) + } else { + cell.setTitleLabelText(label: label) + } } case .hud, .status, .alertWarning: break @@ -1233,7 +1261,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 +1370,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..0a371ee706 100644 --- a/Loop/View Models/BolusEntryViewModel.swift +++ b/Loop/View Models/BolusEntryViewModel.swift @@ -92,6 +92,8 @@ final class BolusEntryViewModel: ObservableObject { } } } + + final let MIN_ABS_BOLUS_AMOUNT_FOR_DISPLAY = 0.005 // MARK: - State @@ -113,6 +115,36 @@ 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 + var cobCorrectionBolusAmount: Double? { + cobCorrectionBolus?.doubleValue(for: .internationalUnit()) + } + @Published var bgCorrectionBolus: HKQuantity? + @Published var bgCorrectionBolusIncluded = true + 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 exclusionsBolus: HKQuantity? + @Published var exclusionsBolusIncluded = true + var exclusionsBolusAmount: Double? { + exclusionsBolus?.doubleValue(for: .internationalUnit()) + } @Published var recommendedBolus: HKQuantity? var recommendedBolusAmount: Double? { recommendedBolus?.doubleValue(for: .internationalUnit()) @@ -206,7 +238,7 @@ final class BolusEntryViewModel: ObservableObject { self.observeElapsedTime() self.observeEnteredManualGlucoseChanges() self.observeEnteredBolusChanges() - + self.observeBolusBreakdownChanges() } private func observeLoopUpdates() { @@ -239,6 +271,63 @@ 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) + $exclusionsBolusIncluded + .sink { [weak self] newValue in + if self?.exclusionsBolusIncluded != newValue { + self?.delegate?.withLoopState { [weak self] _ in + self?.updateRecommendedBolusAndNoticeForBolusBreakdownChange() + } + } + } + .store(in: &cancellables) + } private func observeEnteredManualGlucoseChanges() { $manualGlucoseQuantity @@ -444,6 +533,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 +741,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 +760,98 @@ final class BolusEntryViewModel: ObservableObject { let now = Date() var recommendation: ManualBolusRecommendation? + let carbBolus: HKQuantity? + let cobCorrectionBolus: HKQuantity? + let bgCorrectionBolus: HKQuantity? let recommendedBolus: HKQuantity? + var maxExcessBolus: HKQuantity? = nil + var safetyLimitBolus: HKQuantity? = nil + var exclusionsBolus: 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 + + let breakdown = recommendation.bolusBreakdown + + if let carbsAmount = breakdown?.carbsAmount, abs(carbsAmount) >= MIN_ABS_BOLUS_AMOUNT_FOR_DISPLAY{ + carbBolus = HKQuantity(unit: .internationalUnit(), doubleValue: carbsAmount) + totalRecommendation += carbBolusIncluded ? carbsAmount : 0 + } else { + carbBolus = nil + } + + if potentialCarbEntry != nil, UserDefaults.standard.carbBolusCarbEntryExcluded || UserDefaults.standard.carbBolusCobCorrectionExcluded || UserDefaults.standard.carbBolusBgCorrectionExcluded { + + var exclusionsAmount = -(recommendation.missingAmount ?? 0.0) + + if UserDefaults.standard.carbBolusCarbEntryExcluded { + exclusionsAmount += breakdown?.carbsAmount ?? 0.0 + } + if UserDefaults.standard.carbBolusCobCorrectionExcluded { + exclusionsAmount += breakdown?.cobCorrectionAmount ?? 0.0 + } + if UserDefaults.standard.carbBolusBgCorrectionExcluded { + exclusionsAmount += breakdown?.bgCorrectionAmount ?? 0.0 + } + + if exclusionsAmount >= MIN_ABS_BOLUS_AMOUNT_FOR_DISPLAY { + exclusionsBolus = HKQuantity(unit: .internationalUnit(), doubleValue: exclusionsAmount) + totalRecommendation -= exclusionsBolusIncluded ? exclusionsAmount : 0 + } + } + + if let cobCorrectionAmount = breakdown?.cobCorrectionAmount, abs(cobCorrectionAmount) >= MIN_ABS_BOLUS_AMOUNT_FOR_DISPLAY { + cobCorrectionBolus = HKQuantity(unit: .internationalUnit(), doubleValue: cobCorrectionAmount) + totalRecommendation += cobCorrectionBolusIncluded ? cobCorrectionAmount : 0 + } else { + cobCorrectionBolus = nil + } + + if let bgCorrectionAmount = breakdown?.bgCorrectionAmount, abs(bgCorrectionAmount) >= MIN_ABS_BOLUS_AMOUNT_FOR_DISPLAY { + bgCorrectionBolus = HKQuantity(unit: .internationalUnit(), doubleValue: bgCorrectionAmount) + totalRecommendation += bgCorrectionBolusIncluded ? bgCorrectionAmount : 0 + } else { + bgCorrectionBolus = nil + } + + if let missingAmount = recommendation.missingAmount, missingAmount >= MIN_ABS_BOLUS_AMOUNT_FOR_DISPLAY { + 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, !exclusionsBolusIncluded || exclusionsBolus == nil { + 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 +868,22 @@ final class BolusEntryViewModel: ObservableObject { notice = nil } } else { + carbBolus = nil + cobCorrectionBolus = nil + bgCorrectionBolus = nil + maxExcessBolus = nil + safetyLimitBolus = nil + exclusionsBolus = nil recommendedBolus = HKQuantity(unit: .internationalUnit(), doubleValue: 0) notice = nil } } catch { + carbBolus = nil + cobCorrectionBolus = nil + bgCorrectionBolus = nil + maxExcessBolus = nil + safetyLimitBolus = nil + exclusionsBolus = nil recommendedBolus = nil switch error { @@ -700,10 +900,16 @@ final class BolusEntryViewModel: ObservableObject { DispatchQueue.main.async { let priorRecommendedBolus = self.recommendedBolus + self.carbBolus = carbBolus + self.cobCorrectionBolus = cobCorrectionBolus + self.bgCorrectionBolus = bgCorrectionBolus + self.maxExcessBolus = maxExcessBolus + self.safetyLimitBolus = safetyLimitBolus + self.exclusionsBolus = exclusionsBolus 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 +935,7 @@ final class BolusEntryViewModel: ObservableObject { return try state.recommendBolus( consideringPotentialCarbEntry: potentialCarbEntry, replacingCarbEntry: originalCarbEntry, - considerPositiveVelocityAndRC: FeatureFlags.usePositiveMomentumAndRCForManualBoluses + considerPositiveVelocityAndRC: FeatureFlags.usePositiveMomentumAndRCForManualBoluses ) } } @@ -789,15 +995,46 @@ 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) + } + var negativeCorrectLimitString: String { + negativeBolusString(amount: exclusionsBolusAmount) + } + + 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..01c4a05fa9 --- /dev/null +++ b/Loop/Views/AutoBolusCarbsSelectionView.swift @@ -0,0 +1,50 @@ +// +// 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) + .padding(.top, 20) + + Toggle(NSLocalizedString("Auto-Bolus Carbs Active by Default", comment: "Title for Auto-Bolus Carbs Active by Default toggle"), isOn: $autoBolusCarbsActiveByDefault) + .padding(.top, 20) + .disabled(!isAutoBolusCarbsEnabled) + } + .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..c7d292bedf 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,204 @@ 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 exclusionsApply = viewModel.exclusionsBolus != nil && viewModel.exclusionsBolusIncluded + 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 { + let excluded = exclusionsApply && UserDefaults.standard.carbBolusCarbEntryExcluded + 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) + .foregroundStyle(excluded ? .secondary : .primary) + Spacer() + HStack(alignment: .firstTextBaseline) { + Text(viewModel.carbBolusString) + .font(.subheadline) + .foregroundColor(Color(excluded ? .secondaryLabel : .label)) + breakdownBolusUnitsLabel + } + } + .accessibilityElement(children: .combine) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.carbBolusIncluded.toggle() + } + } + if viewModel.cobCorrectionBolus != nil { + let excluded = exclusionsApply && UserDefaults.standard.carbBolusCobCorrectionExcluded + 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) + .foregroundStyle(excluded ? .secondary : .primary) + Spacer() + HStack(alignment: .firstTextBaseline) { + Text(viewModel.cobCorrectionBolusString) + .font(breakdownFont) + .foregroundColor(Color(excluded ? .secondaryLabel : .label)) + breakdownBolusUnitsLabel + } + } + .accessibilityElement(children: .combine) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.cobCorrectionBolusIncluded.toggle() + } + } + if viewModel.bgCorrectionBolus != nil { + let excluded = exclusionsApply && UserDefaults.standard.carbBolusBgCorrectionExcluded + 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) + .foregroundStyle(excluded ? .secondary : .primary) + Spacer() + HStack(alignment: .firstTextBaseline) { + Text(viewModel.bgCorrectionBolusString) + .font(breakdownFont) + .foregroundColor(Color(excluded ? .secondaryLabel : .label)) + breakdownBolusUnitsLabel + } + } + .accessibilityElement(children: .combine) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.bgCorrectionBolusIncluded.toggle() + } + } + 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) + .foregroundStyle(exclusionsApply ? .secondary : .primary) + 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) + .foregroundStyle(exclusionsApply ? .secondary : .primary) + Spacer() + HStack(alignment: .firstTextBaseline) { + Text(viewModel.negativeSafetyLimitString) + .font(breakdownFont) + .foregroundColor(Color(.label)) + breakdownBolusUnitsLabel + } + } + .accessibilityElement(children: .combine) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.safetyLimitBolusIncluded.toggle() + } + } + if viewModel.exclusionsBolus != nil { + HStack { + Text(" ") + Image(systemName: "checkmark") + .imageScale(.small) + .foregroundColor(.accentColor) + .opacity(viewModel.exclusionsBolusIncluded ? 1 : 0) + Text("Exclusions", comment: "Label for exclusions row on bolus screen") + .font(breakdownFont) + Spacer() + HStack(alignment: .firstTextBaseline) { + Text(viewModel.negativeCorrectLimitString) + .font(breakdownFont) + .foregroundColor(Color(.label)) + breakdownBolusUnitsLabel + } + } + .accessibilityElement(children: .combine) + .contentShape(Rectangle()) + .onTapGesture { + viewModel.exclusionsBolusIncluded.toggle() + } + } + } + .accessibilityElement(children: .combine) + .transition(.slide) + .animation(.smooth, value: recommendationBreakdownExpanded) } } - .accessibilityElement(children: .combine) + } + private func didBeginEditing() { if !editedBolusAmount { @@ -275,6 +459,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/CarbBolusSelectionView.swift b/Loop/Views/CarbBolusSelectionView.swift new file mode 100644 index 0000000000..fda501b2a0 --- /dev/null +++ b/Loop/Views/CarbBolusSelectionView.swift @@ -0,0 +1,90 @@ +// +// CarbBolusSelectionView.swift +// Loop +// +// Created by Moti Nisenson-Ken on 14/01/2025. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + + +import Foundation +import SwiftUI +import LoopKit +import LoopKitUI + +public struct CarbBolusSelectionView: View { + @AppStorage(UserDefaults.Key.CarbEntryExcluded.rawValue) var isCarbEntryExcluded = false + @AppStorage(UserDefaults.Key.CobCorrectionExcluded.rawValue) var isCobCorrectionExcluded = false + @AppStorage(UserDefaults.Key.BgCorrectionExcluded.rawValue) var isBgCorrectionExcluded = false + + public var body: some View { + + ScrollView { + VStack(spacing: 10) { + Text(NSLocalizedString("Carb Bolus Recommendation", comment: "Title for carb bolus recommendation description")) + .font(.headline) + .padding(.bottom, 20) + + Divider() + + Text(NSLocalizedString("When bolusing for carbs one can decide which elements to exclude. The toggles below enable one to not bolus for the Carb Entry, or to not give COB or BG corrections. When these are relevant an extra Exclusions row will appear in the Recommendation Breakdown reducing the overall bolus. Rows included in the Exclusions calculated are grayed out. The excluded amount may be smaller than expected, as negative insulin from other rows can still apply.", comment: "carb bolus recommendation options description")) + .foregroundColor(.secondary) + Divider() + + Toggle(NSLocalizedString("Carb Entry Excluded", comment: "Title for Carb Entry Excluded toggle"), isOn: $isCarbEntryExcluded) + .padding(.top, 20) + + Toggle(NSLocalizedString("COB Correction Excluded", comment: "Title for COB Correction Excluded toggle"), isOn: $isCobCorrectionExcluded) + .padding(.top, 20) + + Toggle(NSLocalizedString("BG Correction Excluded", comment: "Title for BG Correction Excluded toggle"), isOn: $isBgCorrectionExcluded) + .padding(.top, 20) + } + .padding() + } + .navigationBarTitleDisplayMode(.inline) + } + +} + +extension UserDefaults { + fileprivate enum Key: String { + case CarbEntryExcluded = "com.loopkit.underDevelopment.carbBolus.carbyEntryExcluded" + case CobCorrectionExcluded = "com.loopkit.underDevelopment.carbBolus.correctionExcluded" + case BgCorrectionExcluded = "com.loopkit.underDevelopment.carbBolus.bgCorrectionExcluded" + } + + var carbBolusCarbEntryExcluded : Bool { + get { + bool(forKey: Key.CarbEntryExcluded.rawValue) as Bool + } + set { + set(newValue, forKey: Key.CarbEntryExcluded.rawValue) + } + } + + var carbBolusCobCorrectionExcluded : Bool { + get { + bool(forKey: Key.CobCorrectionExcluded.rawValue) as Bool + } + set { + set(newValue, forKey: Key.CobCorrectionExcluded.rawValue) + } + } + + var carbBolusBgCorrectionExcluded: Bool { + get { + bool(forKey: Key.BgCorrectionExcluded.rawValue) as Bool + } + set { + set(newValue, forKey: Key.BgCorrectionExcluded.rawValue) + } + } +} + +struct CarbBolusSelectionView_Previews: PreviewProvider { + static var previews: some View { + CarbBolusSelectionView() + } +} + diff --git a/Loop/Views/GlucoseBasedApplicationFactorSelectionView.swift b/Loop/Views/GlucoseBasedApplicationFactorSelectionView.swift index 09a68d58c1..0d88e65dfa 100644 --- a/Loop/Views/GlucoseBasedApplicationFactorSelectionView.swift +++ b/Loop/Views/GlucoseBasedApplicationFactorSelectionView.swift @@ -44,9 +44,6 @@ public struct GlucoseBasedApplicationFactorSelectionView: View { } } .padding() - .onChange(of: isGlucoseBasedApplicationFactorEnabled) { newValue in - UserDefaults.standard.glucoseBasedApplicationFactorEnabled = newValue - } } .navigationBarTitleDisplayMode(.inline) } diff --git a/Loop/Views/IntegralRetrospectiveCorrectionSelectionView.swift b/Loop/Views/IntegralRetrospectiveCorrectionSelectionView.swift index 9af84adf4f..8556e4fed6 100644 --- a/Loop/Views/IntegralRetrospectiveCorrectionSelectionView.swift +++ b/Loop/Views/IntegralRetrospectiveCorrectionSelectionView.swift @@ -27,9 +27,6 @@ public struct IntegralRetrospectiveCorrectionSelectionView: View { Divider() Toggle(NSLocalizedString("Enable Integral Retrospective Correction", comment: "Title for Integral Retrospective Correction toggle"), isOn: $isIntegralRetrospectiveCorrectionEnabled) - .onChange(of: isIntegralRetrospectiveCorrectionEnabled) { newValue in - UserDefaults.standard.integralRetrospectiveCorrectionEnabled = newValue - } .padding(.top, 20) } .padding() 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..da86c59848 100644 --- a/Loop/Views/SettingsView+algorithmExperimentsSection.swift +++ b/Loop/Views/SettingsView+algorithmExperimentsSection.swift @@ -21,15 +21,17 @@ extension SettingsView { public struct ExperimentRow: View { var name: String - var enabled: Bool + var enabled: Bool? public var body: some View { HStack { Text(name) .foregroundColor(.primary) Spacer() - Text(enabled ? "On" : "Off") - .foregroundColor(enabled ? .red : .secondary) + if let enabled = enabled { + Text(enabled ? "On" : "Off") + .foregroundColor(enabled ? .red : .secondary) + } } .padding() .background(Color(UIColor.secondarySystemBackground)) @@ -39,8 +41,11 @@ public struct ExperimentRow: View { } public struct ExperimentsSettingsView: View { - @State private var isGlucoseBasedApplicationFactorEnabled = UserDefaults.standard.glucoseBasedApplicationFactorEnabled - @State private var isIntegralRetrospectiveCorrectionEnabled = UserDefaults.standard.integralRetrospectiveCorrectionEnabled + @AppStorage(UserDefaults.Key.GlucoseBasedApplicationFactorEnabled.rawValue) private var isGlucoseBasedApplicationFactorEnabled = false + @AppStorage(UserDefaults.Key.IntegralRetrospectiveCorrectionEnabled.rawValue) private var isIntegralRetrospectiveCorrectionEnabled = false + @AppStorage(UserDefaults.Key.AutoBolusCarbsEnabled.rawValue) private var isAutoBolusCarbsEnabled = false + @AppStorage(UserDefaults.Key.AutoBolusCarbsActiveByDefault.rawValue) private var autoBolusCarbsActiveByDefault = false + var automaticDosingStrategy: AutomaticDosingStrategy public var body: some View { @@ -70,19 +75,41 @@ public struct ExperimentsSettingsView: View { name: NSLocalizedString("Integral Retrospective Correction", comment: "Title of integral retrospective correction experiment"), enabled: isIntegralRetrospectiveCorrectionEnabled) } + NavigationLink(destination: AutoBolusCarbsSelectionView(isAutoBolusCarbsEnabled: $isAutoBolusCarbsEnabled, autoBolusCarbsActiveByDefault: $autoBolusCarbsActiveByDefault)) { + ExperimentRow( + name: NSLocalizedString("Auto-Bolus Carbs", comment: "Title of auto-bolus carbs experiment"), + enabled: isAutoBolusCarbsEnabled) + } Spacer() } .padding() } .navigationBarTitleDisplayMode(.inline) + .onChange(of: isGlucoseBasedApplicationFactorEnabled) { _ in + NotificationCenter.default.post(name: .AlgorithmExperimentsChanged, object: UserDefaults.standard, userInfo: nil) + } + .onChange(of: isIntegralRetrospectiveCorrectionEnabled) { _ in + NotificationCenter.default.post(name: .AlgorithmExperimentsChanged, object: UserDefaults.standard, userInfo: nil) + } + .onChange(of: isAutoBolusCarbsEnabled) { _ in + NotificationCenter.default.post(name: .AlgorithmExperimentsChanged, object: UserDefaults.standard, userInfo: nil) + } + .onChange(of: autoBolusCarbsActiveByDefault) { _ in + NotificationCenter.default.post(name: .AlgorithmExperimentsChanged, object: UserDefaults.standard, userInfo: nil) + } } } +extension Notification.Name { + static let AlgorithmExperimentsChanged = Notification.Name(rawValue: "com.loopKit.notification.AlgorithmExperimentsChanged") +} extension UserDefaults { - private enum Key: String { + fileprivate 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 +129,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/Loop/Views/SettingsView+underDevelopmentSection.swift b/Loop/Views/SettingsView+underDevelopmentSection.swift new file mode 100644 index 0000000000..c4ae6334bd --- /dev/null +++ b/Loop/Views/SettingsView+underDevelopmentSection.swift @@ -0,0 +1,50 @@ +// +// SettingsView+underDevelopmentSection.swift +// Loop +// +// Created by Moti Nisenson-Ken on 14/01/2025. +// Copyright © 2025 LoopKit Authors. All rights reserved. +// + +import Foundation +import SwiftUI +import LoopKit +import LoopKitUI + +extension SettingsView { + internal var underDevelopmentSection: some View { + NavigationLink(NSLocalizedString("🚧 Under Development 🚧", comment: "The title of the Under Development section in settings")) { + UnderDevelopmentSettingsView() + } + } +} + +public struct UnderDevelopmentSettingsView: View { + + public var body: some View { + ScrollView { + VStack(alignment: .center, spacing: 12) { + Text(NSLocalizedString("🚧 Under Development 🚧", comment: "Navigation title for under development screen")) + .font(.headline) + VStack { + Text("⚠️").font(.largeTitle) + Text("Caution") + } + Divider() + VStack(alignment: .leading, spacing: 12) { + Text(NSLocalizedString("These features are under development. They may not have been tested thorougly.", comment: "Under Development description.")) + Text(NSLocalizedString("In future versions of Loop these features may change, end up as standard parts of Loop, or be removed from Loop entirely. Please follow along in the Loop Zulip chat to stay informed of possible changes to these features.", comment: "Under development description second paragraph.")) + } + .foregroundColor(.secondary) + + Divider() + NavigationLink(destination: CarbBolusSelectionView()) { + ExperimentRow(name: NSLocalizedString("Carb Bolus Recommendation", comment: "Title of carb bolus recommendation feature"), enabled: nil) + } + Spacer() + } + .padding() + } + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/Loop/Views/SettingsView.swift b/Loop/Views/SettingsView.swift index c3ec98b8dd..edcc56801f 100644 --- a/Loop/Views/SettingsView.swift +++ b/Loop/Views/SettingsView.swift @@ -302,6 +302,9 @@ extension SettingsView { if FeatureFlags.allowAlgorithmExperiments { algorithmExperimentsSection } + if FeatureFlags.allowExperimentalFeatures { + underDevelopmentSection + } } } 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