diff --git a/Loop.xcodeproj/project.pbxproj b/Loop.xcodeproj/project.pbxproj index 1181951609..4ff92544ad 100644 --- a/Loop.xcodeproj/project.pbxproj +++ b/Loop.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 120490CB2CBFB25A006BDF0A /* NegativeInsulinDamperSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 120490CA2CBFB25A006BDF0A /* NegativeInsulinDamperSelectionView.swift */; }; 1419606428D9550400BA86E0 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 437AFEE6203688CF008C4892 /* LoopKitUI.framework */; }; 1419606928D9554E00BA86E0 /* LoopKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 437AFEE6203688CF008C4892 /* LoopKitUI.framework */; }; 1419606A28D955BC00BA86E0 /* MockKitUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C101947127DD473C004E7EB8 /* MockKitUI.framework */; }; @@ -743,6 +744,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 120490CA2CBFB25A006BDF0A /* NegativeInsulinDamperSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NegativeInsulinDamperSelectionView.swift; sourceTree = "<group>"; }; 142CB7582A60BF2E0075748A /* EditMode.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EditMode.swift; sourceTree = "<group>"; }; 142CB75A2A60BFC30075748A /* FavoriteFoodsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FavoriteFoodsView.swift; sourceTree = "<group>"; }; 1452F4A82A851C9400F8B9E4 /* AddEditFavoriteFoodViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddEditFavoriteFoodViewModel.swift; sourceTree = "<group>"; }; @@ -2276,6 +2278,7 @@ C1AF062229426300002C1B19 /* ManualGlucoseEntryRow.swift */, DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */, DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */, + 120490CA2CBFB25A006BDF0A /* NegativeInsulinDamperSelectionView.swift */, ); path = Views; sourceTree = "<group>"; @@ -3832,6 +3835,7 @@ 895FE0952201234000FCF18A /* OverrideSelectionViewController.swift in Sources */, C1EF747228D6A44A00C8C083 /* CrashRecoveryManager.swift in Sources */, A9F66FC3247F451500096EA7 /* UIDevice+Loop.swift in Sources */, + 120490CB2CBFB25A006BDF0A /* NegativeInsulinDamperSelectionView.swift in Sources */, 439706E622D2E84900C81566 /* PredictionSettingTableViewCell.swift in Sources */, 430D85891F44037000AF2D4F /* HUDViewTableViewCell.swift in Sources */, 43A51E211EB6DBDD000736CC /* LoopChartsTableViewController.swift in Sources */, diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 2319f4eceb..5e47cbc3ee 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -358,6 +358,13 @@ final class LoopDataManager { predictedGlucose = nil } } + + private var negativeInsulinDamper: Double? { + didSet { + predictedGlucose = nil + } + } + private var negativeInsulinDamperCachedBaseDate: Date = .distantPast /// When combining retrospective glucose discrepancies, extend the window slightly as a buffer. private let retrospectiveCorrectionGroupingIntervalMultiplier = 1.01 @@ -454,6 +461,7 @@ final class LoopDataManager { insulinEffect = nil insulinEffectIncludingPendingInsulin = nil predictedGlucose = nil + negativeInsulinDamper = nil } // MARK: - Background task management @@ -995,6 +1003,64 @@ extension LoopDataManager { let earliestEffectDate = Date(timeInterval: .hours(-24), since: now()) let nextCounteractionEffectDate = insulinCounteractionEffects.last?.endDate ?? earliestEffectDate let insulinEffectStartDate = nextCounteractionEffectDate.addingTimeInterval(.minutes(-5)) + + if negativeInsulinDamper == nil || nextCounteractionEffectDate != negativeInsulinDamperCachedBaseDate { + self.logger.debug("Recomputing negative insulin damper") + updateGroup.enter() + let lastDoseStartDate = nextCounteractionEffectDate.addingTimeInterval(.minutes(-15)) + doseStore.getGlucoseEffects(start: insulinEffectStartDate, end: nil, doseEnd: lastDoseStartDate, basalDosingEnd: lastDoseStartDate) { (result) -> Void in + switch result { + case .failure(let error): + self.logger.error("Could not fetch insulin effects for damper: %{public}@", error.localizedDescription) + self.negativeInsulinDamper = nil + self.negativeInsulinDamperCachedBaseDate = .distantPast + warnings.append(.fetchDataWarning(.negativeInsulinDamper(error: error))) + case .success(let effects): + var posDeltaSum = 0.0 + effects.enumerated().forEach{ + if $0.offset > 0 { + let delta = $0.element.quantity.doubleValue(for: .milligramsPerDeciliter) - effects[$0.offset - 1].quantity.doubleValue(for: .milligramsPerDeciliter) + posDeltaSum += max(0, delta) + } + } + + guard let insulinSensitivity = latestSettings.insulinSensitivitySchedule?.quantity(at: lastGlucoseDate), let basalRate = latestSettings.basalRateSchedule?.value(at: lastGlucoseDate) else { + + self.logger.error("Could not fetch ISF and/or basal rates for damper") + self.negativeInsulinDamper = nil + self.negativeInsulinDamperCachedBaseDate = .distantPast + + break + } + let model = self.doseStore.insulinModelProvider.model(for: self.pumpInsulinType) + + // anchorScale is set to 1 hour for rapid acting adult, and 44 minutes for ultra-rapid insulins + let anchorScale: Double + if let expModel = model as? ExponentialInsulinModel { + anchorScale = 0.8 * expModel.peakActivityTime.hours + } else { + anchorScale = 1.0 + } + + // NID will change the final prediction so that positive changes will be multiplied by weight alpha + // the long term slope will be marginalSlope + // in the initial linear scaling region alpha will be anchorAlpha at anchorPoint + // note that anchorPoint is unaffected by overrides (the changes cancel out) + let marginalSlope = 0.05 + let anchorPoint = anchorScale * basalRate * insulinSensitivity.doubleValue(for: .milligramsPerDeciliter) + let anchorAlpha = 0.75 + + let alpha = LoopDataManager.calculateNegativeInsulinDamperAlpha(anchorAlpha, anchorPoint, marginalSlope, posDeltaSum) + + // alpha should never be less than marginalSlope + self.negativeInsulinDamper = max(0, 1 - max(marginalSlope, alpha)) + self.negativeInsulinDamperCachedBaseDate = nextCounteractionEffectDate + } + + updateGroup.leave() + } + + } if glucoseMomentumEffect == nil { updateGroup.enter() @@ -1014,7 +1080,8 @@ extension LoopDataManager { if insulinEffect == nil || insulinEffect?.first?.startDate ?? .distantFuture > insulinEffectStartDate { self.logger.debug("Recomputing insulin effects") updateGroup.enter() - doseStore.getGlucoseEffects(start: insulinEffectStartDate, end: nil, basalDosingEnd: now()) { (result) -> Void in + let basalDosingEnd = now() + doseStore.getGlucoseEffects(start: insulinEffectStartDate, end: nil, doseEnd: nil, basalDosingEnd: basalDosingEnd) { (result) -> Void in switch result { case .failure(let error): self.logger.error("Could not fetch insulin effects: %{public}@", error.localizedDescription) @@ -1030,7 +1097,7 @@ extension LoopDataManager { if insulinEffectIncludingPendingInsulin == nil { updateGroup.enter() - doseStore.getGlucoseEffects(start: insulinEffectStartDate, end: nil, basalDosingEnd: nil) { (result) -> Void in + doseStore.getGlucoseEffects(start: insulinEffectStartDate, end: nil, doseEnd: nil, basalDosingEnd: nil) { (result) -> Void in switch result { case .failure(let error): self.logger.error("Could not fetch insulin effects including pending insulin: %{public}@", error.localizedDescription) @@ -1158,6 +1225,21 @@ extension LoopDataManager { return updatePredictedGlucoseAndRecommendedDose(with: dosingDecision) } + + static func calculateNegativeInsulinDamperAlpha(_ anchorAlpha: Double, _ anchorPoint: Double, _ marginalSlope: Double, _ posDeltaSum: Double) -> Double { + let linearScaleSlope = (1.0 - anchorAlpha)/anchorPoint // how alpha scales down in the linear scale region + + // the slope in the linear scale region of alpha * posDeltaSum is 1 - 2*linearScaleSlope*posDeltaSum. + // the transitionPoint is where we transition from linear scale region to marginalSlope. The slope is continuous at this point + let transitionPoint = (1 - marginalSlope) / (2 * linearScaleSlope) + + if posDeltaSum < transitionPoint { // linear scaling region + return 1 - linearScaleSlope * posDeltaSum + } else { // marginal slope region + let transitionValue = (1 - linearScaleSlope * transitionPoint) * transitionPoint + return (transitionValue + marginalSlope * (posDeltaSum - transitionPoint)) / posDeltaSum + } + } private func notify(forChange context: LoopUpdateContext) { NotificationCenter.default.post(name: .LoopDataUpdated, @@ -1199,7 +1281,7 @@ extension LoopDataManager { // All outstanding potential insulin delivery return pendingTempBasalInsulin + pendingBolusAmount } - + /// - Throws: /// - LoopError.missingDataError /// - LoopError.configurationError @@ -1337,6 +1419,51 @@ extension LoopDataManager { } var prediction = LoopMath.predictGlucose(startingAt: glucose, momentum: momentum, effects: effects) + + if inputs.contains(.damper), let damper = negativeInsulinDamper { + let damperOnly = inputs.isSubset(of: [.damper]) + if damperOnly { + prediction = try predictGlucose( + startingAt: startingGlucoseOverride, + using: settings.enabledEffects.subtracting(.damper), + historicalInsulinEffect: insulinEffectOverride, + insulinCounteractionEffects: insulinCounteractionEffectsOverride, + historicalCarbEffect: carbEffectOverride, + potentialBolus: potentialBolus, + potentialCarbEntry: potentialCarbEntry, + replacingCarbEntry: replacedCarbEntry, + includingPendingInsulin: includingPendingInsulin, + includingPositiveVelocityAndRC: includingPositiveVelocityAndRC) + } + + let alpha = 1 - damper + var dampedPrediction = [PredictedGlucoseValue]() + var value = 0.0 + prediction.enumerated().forEach{ + + if $0.offset == 0 { + value = $0.element.quantity.doubleValue(for: .milligramsPerDeciliter) + dampedPrediction.append($0.element) + return + } + let delta = $0.element.quantity.doubleValue(for: .milligramsPerDeciliter) - prediction[$0.offset - 1].quantity.doubleValue(for: .milligramsPerDeciliter) + + if damperOnly { + // we just want to display the effects of damper relative to everything else + if delta > 0 { + value -= damper * delta + } + } else if delta > 0 { + value += alpha * delta + } else { + value += delta + } + dampedPrediction.append(PredictedGlucoseValue(startDate: $0.element.startDate, quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: value))) + } + + prediction = dampedPrediction + } + // Dosing requires prediction entries at least as long as the insulin model duration. // If our prediction is shorter than that, then extend it here. @@ -1367,7 +1494,7 @@ extension LoopDataManager { var insulinEffect: [GlucoseEffect]? let basalDosingEnd = includingPendingInsulin ? nil : now() updateGroup.enter() - doseStore.getGlucoseEffects(start: insulinEffectStartDate, end: nil, basalDosingEnd: basalDosingEnd) { result in + doseStore.getGlucoseEffects(start: insulinEffectStartDate, end: nil, doseEnd: nil, basalDosingEnd: basalDosingEnd) { result in switch result { case .failure(let error): effectCalculationError.mutate { $0 = error } @@ -1955,6 +2082,9 @@ protocol LoopState { /// The total corrective glucose effect from retrospective correction var totalRetrospectiveCorrection: HKQuantity? { get } + + /// The negative insulin damper - if present then is in the range [0,1] + var negativeInsulinDamper: Double? { get} /// Calculates a new prediction from the current data using the specified effect inputs /// @@ -2079,6 +2209,11 @@ extension LoopDataManager { dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) return loopDataManager.retrospectiveCorrection.totalGlucoseCorrectionEffect } + + var negativeInsulinDamper: Double? { + dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) + return loopDataManager.negativeInsulinDamper + } func predictGlucose(using inputs: PredictionInputEffect, potentialBolus: DoseEntry?, potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, includingPendingInsulin: Bool, considerPositiveVelocityAndRC: Bool) throws -> [PredictedGlucoseValue] { dispatchPrecondition(condition: .onQueue(loopDataManager.dataAccessQueue)) diff --git a/Loop/Managers/Store Protocols/DoseStoreProtocol.swift b/Loop/Managers/Store Protocols/DoseStoreProtocol.swift index dd21ea2a1f..2887d14c05 100644 --- a/Loop/Managers/Store Protocols/DoseStoreProtocol.swift +++ b/Loop/Managers/Store Protocols/DoseStoreProtocol.swift @@ -50,7 +50,7 @@ protocol DoseStoreProtocol: AnyObject { // MARK: IOB and insulin effect func insulinOnBoard(at date: Date, completion: @escaping (_ result: DoseStoreResult<InsulinValue>) -> Void) - func getGlucoseEffects(start: Date, end: Date?, basalDosingEnd: Date?, completion: @escaping (_ result: DoseStoreResult<[GlucoseEffect]>) -> Void) + func getGlucoseEffects(start: Date, end: Date?, doseEnd: Date?, basalDosingEnd: Date?, completion: @escaping (_ result: DoseStoreResult<[GlucoseEffect]>) -> Void) func getInsulinOnBoardValues(start: Date, end: Date? , basalDosingEnd: Date?, completion: @escaping (_ result: DoseStoreResult<[InsulinValue]>) -> Void) diff --git a/Loop/Models/LoopSettings+Loop.swift b/Loop/Models/LoopSettings+Loop.swift index e4952934cb..ba296f1d3a 100644 --- a/Loop/Models/LoopSettings+Loop.swift +++ b/Loop/Models/LoopSettings+Loop.swift @@ -15,6 +15,9 @@ extension LoopSettings { if !LoopConstants.retrospectiveCorrectionEnabled { inputs.remove(.retrospection) } + if !UserDefaults.standard.negativeInsulinDamperEnabled { + inputs.remove(.damper) + } return inputs } } diff --git a/Loop/Models/LoopWarning.swift b/Loop/Models/LoopWarning.swift index 45439b3c55..d43a7d8099 100644 --- a/Loop/Models/LoopWarning.swift +++ b/Loop/Models/LoopWarning.swift @@ -14,6 +14,7 @@ enum FetchDataWarningDetail { case glucoseMomentumEffect(error: Error) case insulinEffect(error: Error) case insulinEffectIncludingPendingInsulin(error: Error) + case negativeInsulinDamper(error: Error) case insulinCounteractionEffect(error: Error) case carbEffect(error: Error) case carbsOnBoard(error: Error) @@ -32,6 +33,8 @@ extension FetchDataWarningDetail { return "insulinEffect" case .insulinEffectIncludingPendingInsulin: return "insulinEffectIncludingPendingInsulin" + case .negativeInsulinDamper: + return "negativeInsulinDamper" case .insulinCounteractionEffect: return "insulinCounteractionEffect" case .carbEffect: @@ -53,6 +56,7 @@ extension FetchDataWarningDetail { .insulinEffect(let error), .insulinEffectIncludingPendingInsulin(let error), .insulinCounteractionEffect(let error), + .negativeInsulinDamper(let error), .carbEffect(let error), .carbsOnBoard(let error), .insulinOnBoard(let error), diff --git a/Loop/Models/PredictionInputEffect.swift b/Loop/Models/PredictionInputEffect.swift index 45fb5ea0c7..c80cde1d3a 100644 --- a/Loop/Models/PredictionInputEffect.swift +++ b/Loop/Models/PredictionInputEffect.swift @@ -18,8 +18,9 @@ struct PredictionInputEffect: OptionSet { static let momentum = PredictionInputEffect(rawValue: 1 << 2) static let retrospection = PredictionInputEffect(rawValue: 1 << 3) static let suspend = PredictionInputEffect(rawValue: 1 << 4) + static let damper = PredictionInputEffect(rawValue: 1 << 5) - static let all: PredictionInputEffect = [.carbs, .insulin, .momentum, .retrospection] + static let all: PredictionInputEffect = [.carbs, .insulin, .damper, .momentum, .retrospection] var localizedTitle: String? { switch self { @@ -27,6 +28,8 @@ struct PredictionInputEffect: OptionSet { return NSLocalizedString("Carbohydrates", comment: "Title of the prediction input effect for carbohydrates") case [.insulin]: return NSLocalizedString("Insulin", comment: "Title of the prediction input effect for insulin") + case [.damper]: + return NSLocalizedString("Negative Insulin Damper", comment: "Title of the prediction input effect for negative insulin damper") case [.momentum]: return NSLocalizedString("Glucose Momentum", comment: "Title of the prediction input effect for glucose momentum") case [.retrospection]: @@ -44,6 +47,8 @@ struct PredictionInputEffect: OptionSet { return String(format: NSLocalizedString("Carbs Absorbed (g) ÷ Carb Ratio (g/U) × Insulin Sensitivity (%1$@/U)", comment: "Description of the prediction input effect for carbohydrates. (1: The glucose unit string)"), unit.localizedShortUnitString) case [.insulin]: return String(format: NSLocalizedString("Insulin Absorbed (U) × Insulin Sensitivity (%1$@/U)", comment: "Description of the prediction input effect for insulin"), unit.localizedShortUnitString) + case [.damper]: + return String(format: NSLocalizedString("Reduces increases in glucose. The damper is stronger when there is more negative insulin", comment: "Description of the prediction input effect for negative insulin damper"), unit.localizedShortUnitString) case [.momentum]: return NSLocalizedString("15 min glucose regression coefficient (b₁), continued with decay over 30 min", comment: "Description of the prediction input effect for glucose momentum") case [.retrospection]: diff --git a/Loop/View Controllers/PredictionTableViewController.swift b/Loop/View Controllers/PredictionTableViewController.swift index a460e52aaf..fc477e7e95 100644 --- a/Loop/View Controllers/PredictionTableViewController.swift +++ b/Loop/View Controllers/PredictionTableViewController.swift @@ -71,6 +71,8 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable private var retrospectiveGlucoseDiscrepancies: [GlucoseChange]? private var totalRetrospectiveCorrection: HKQuantity? + + private var negativeInsulinDamper: Double? private var refreshContext = RefreshContext.all @@ -111,6 +113,7 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable let reloadGroup = DispatchGroup() var glucoseSamples: [StoredGlucoseSample]? var totalRetrospectiveCorrection: HKQuantity? + var negativeInsulinDamper: Double? if self.refreshContext.remove(.glucose) != nil { reloadGroup.enter() @@ -132,6 +135,7 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable deviceManager.loopManager.getLoopState { (manager, state) in self.retrospectiveGlucoseDiscrepancies = state.retrospectiveGlucoseDiscrepancies totalRetrospectiveCorrection = state.totalRetrospectiveCorrection + negativeInsulinDamper = state.negativeInsulinDamper self.glucoseChart.setPredictedGlucoseValues(state.predictedGlucoseIncludingPendingInsulin ?? []) do { @@ -164,6 +168,9 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable if let totalRetrospectiveCorrection = totalRetrospectiveCorrection { self.totalRetrospectiveCorrection = totalRetrospectiveCorrection } + if let negativeInsulinDamper = negativeInsulinDamper { + self.negativeInsulinDamper = negativeInsulinDamper + } self.charts.prerender() @@ -197,9 +204,16 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable private var eventualGlucoseDescription: String? - private var availableInputs: [PredictionInputEffect] = [.carbs, .insulin, .momentum, .retrospection, .suspend] + private var availableInputs: [PredictionInputEffect] = getAvailableInputs() private var selectedInputs = PredictionInputEffect.all + + private static func getAvailableInputs() -> [PredictionInputEffect] { + if UserDefaults.standard.negativeInsulinDamperEnabled { + return [.carbs, .insulin, .damper, .momentum, .retrospection, .suspend] + } + return [.carbs, .insulin, .momentum, .retrospection, .suspend] + } override func numberOfSections(in tableView: UITableView) -> Int { return Section.allCases.count @@ -261,6 +275,20 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable var subtitleText = input.localizedDescription(forGlucoseUnit: glucoseChart.glucoseUnit) ?? "" + if input == .damper, let negativeInsulinDamper = negativeInsulinDamper { + let formatter = NumberFormatter() + formatter.minimumIntegerDigits = 1 + formatter.maximumFractionDigits = 1 + formatter.maximumSignificantDigits = 2 + + let damper = String( + format: NSLocalizedString("Damper Strength: %1$@%%", comment: "Format string describing damper strength. (1: damper strength percentage)"), + formatter.string(from: 100 * negativeInsulinDamper) ?? "?" + ) + + subtitleText = String(format: "%@\n%@", subtitleText, damper) + + } if input == .retrospection, let lastDiscrepancy = retrospectiveGlucoseDiscrepancies?.last, let currentGlucose = deviceManager.glucoseStore.latestGlucose 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/NegativeInsulinDamperSelectionView.swift b/Loop/Views/NegativeInsulinDamperSelectionView.swift new file mode 100644 index 0000000000..4f14233c81 --- /dev/null +++ b/Loop/Views/NegativeInsulinDamperSelectionView.swift @@ -0,0 +1,42 @@ +// +// NegativeInsulinDamperSelectionView.swift +// Loop +// +// Created by Moti Nisenson-Ken on 16/10/2024. +// Copyright © 2024 LoopKit Authors. All rights reserved. +// +import Foundation +import SwiftUI +import LoopKit +import LoopKitUI + +struct NegativeInsulinDamperSelectionView: View { + @Binding var isNegativeInsulinDamperEnabled: Bool + + public var body: some View { + ScrollView { + VStack(spacing: 10) { + Text(NSLocalizedString("Negative Insulin Damper", comment: "Title for negative insulin damper experiment description")) + .font(.headline) + .padding(.bottom, 20) + + Divider() + + Text(NSLocalizedString("Negative Insulin Damper (NID) is used to mitigate the effects of temporarily increased insulin sensitivity. Such increases can result in spending significant times beneath target and eventually going low. Loop may erroneously predict glucose going too high, resulting in excess insulin being delivered. To counteract this, NID acts as a dynamic damper on increases to predicted glucose. The strength of this damper is controlled by the total predicted rise in glucose due to negative insulin. The greater the amount of negative insulin, the stronger the damper and the bigger the reductions. The calculation is done with a 15 minute lag.", comment: "Description of Negative Insulin Damper toggle.")) + .foregroundColor(.secondary) + Divider() + + Toggle(NSLocalizedString("Enable Negative Insulin Damper", comment: "Title for Negative Insulin Damper toggle"), isOn: $isNegativeInsulinDamperEnabled) + .padding(.top, 20) + } + .padding() + } + .navigationBarTitleDisplayMode(.inline) + } + + struct NegativeInsulinDamperSelectionView_Previews: PreviewProvider { + static var previews: some View { + NegativeInsulinDamperSelectionView(isNegativeInsulinDamperEnabled: .constant(true)) + } + } +} diff --git a/Loop/Views/SettingsView+algorithmExperimentsSection.swift b/Loop/Views/SettingsView+algorithmExperimentsSection.swift index 54bd2c71a0..4ed384564a 100644 --- a/Loop/Views/SettingsView+algorithmExperimentsSection.swift +++ b/Loop/Views/SettingsView+algorithmExperimentsSection.swift @@ -39,8 +39,10 @@ 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.NegativeInsulinDamperEnabled.rawValue) private var isNegativeInsulinDamperEnabled = false + var automaticDosingStrategy: AutomaticDosingStrategy public var body: some View { @@ -70,6 +72,11 @@ public struct ExperimentsSettingsView: View { name: NSLocalizedString("Integral Retrospective Correction", comment: "Title of integral retrospective correction experiment"), enabled: isIntegralRetrospectiveCorrectionEnabled) } + NavigationLink(destination: NegativeInsulinDamperSelectionView(isNegativeInsulinDamperEnabled: $isNegativeInsulinDamperEnabled)) { + ExperimentRow( + name: NSLocalizedString("Negative Insulin Damper", comment: "Title of negative insulin damper experiment"), + enabled: isNegativeInsulinDamperEnabled) + } Spacer() } .padding() @@ -80,9 +87,10 @@ public struct ExperimentsSettingsView: View { extension UserDefaults { - private enum Key: String { + fileprivate enum Key: String { case GlucoseBasedApplicationFactorEnabled = "com.loopkit.algorithmExperiments.glucoseBasedApplicationFactorEnabled" case IntegralRetrospectiveCorrectionEnabled = "com.loopkit.algorithmExperiments.integralRetrospectiveCorrectionEnabled" + case NegativeInsulinDamperEnabled = "com.loopkit.algorithmExperiments.negativeInsulinDamperEnabled" } var glucoseBasedApplicationFactorEnabled: Bool { @@ -103,4 +111,12 @@ extension UserDefaults { } } + var negativeInsulinDamperEnabled: Bool { + get { + bool(forKey: Key.NegativeInsulinDamperEnabled.rawValue) as Bool + } + set { + set(newValue, forKey: Key.NegativeInsulinDamperEnabled.rawValue) + } + } } diff --git a/LoopTests/Managers/LoopDataManagerDosingTests.swift b/LoopTests/Managers/LoopDataManagerDosingTests.swift index a1f26a0e92..000d2e7ed9 100644 --- a/LoopTests/Managers/LoopDataManagerDosingTests.swift +++ b/LoopTests/Managers/LoopDataManagerDosingTests.swift @@ -52,6 +52,38 @@ class LoopDataManagerDosingTests: LoopDataManagerTests { let url = bundle.url(forResource: name, withExtension: "json")! return try! decoder.decode([PredictedGlucoseValue].self, from: try! Data(contentsOf: url)) } + + func testNegativeInsulinDamper() { + let marginalSlope = 0.05 + let anchorAlpha = 0.75 + let anchorPoint = 50.0 + + XCTAssertEqual(1.0, LoopDataManager.calculateNegativeInsulinDamperAlpha(anchorAlpha, anchorPoint, marginalSlope, 0)) + + XCTAssertEqual(anchorAlpha, LoopDataManager.calculateNegativeInsulinDamperAlpha(anchorAlpha, anchorPoint, marginalSlope, anchorPoint), accuracy: 1E-6) + + let linearScaleSlope = (1 - anchorAlpha)/anchorPoint + let transitionPoint = (1 - marginalSlope) / (2 * linearScaleSlope) + let transitionValue = (1 - linearScaleSlope * transitionPoint) * transitionPoint + + XCTAssertEqual(marginalSlope, LoopDataManager.calculateNegativeInsulinDamperAlpha(anchorAlpha, anchorPoint, marginalSlope, 1E12), accuracy: 1E-6) + + var prevAlpha = 1.1 + for i in 0...1_000_000 { + let iVal = Double(i) + let alpha = LoopDataManager.calculateNegativeInsulinDamperAlpha(anchorAlpha, anchorPoint, marginalSlope, iVal) + + XCTAssertLessThan(alpha, prevAlpha) + XCTAssertGreaterThan(alpha, marginalSlope) + + if Double(i) <= transitionPoint { + XCTAssertEqual(alpha, 1.0 - iVal * linearScaleSlope, accuracy: 1E-6) + } else { + XCTAssertEqual(alpha * iVal, transitionValue + marginalSlope * (iVal - transitionPoint), accuracy: 1E-6) + } + prevAlpha = alpha + } + } // MARK: Tests func testForecastFromLiveCaptureInputData() { diff --git a/LoopTests/Mock Stores/MockDoseStore.swift b/LoopTests/Mock Stores/MockDoseStore.swift index 207596f31b..4c5d945a26 100644 --- a/LoopTests/Mock Stores/MockDoseStore.swift +++ b/LoopTests/Mock Stores/MockDoseStore.swift @@ -90,11 +90,11 @@ class MockDoseStore: DoseStoreProtocol { completion(.failure(.configurationError)) } - func getGlucoseEffects(start: Date, end: Date? = nil, basalDosingEnd: Date? = Date(), completion: @escaping (_ result: DoseStoreResult<[GlucoseEffect]>) -> Void) { + func getGlucoseEffects(start: Date, end: Date? = nil, doseEnd: Date? = nil, basalDosingEnd: Date? = Date(), completion: @escaping (_ result: DoseStoreResult<[GlucoseEffect]>) -> Void) { if let doseHistory, let sensitivitySchedule, let basalProfile = basalProfileApplyingOverrideHistory { // To properly know glucose effects at startDate, we need to go back another DIA hours let doseStart = start.addingTimeInterval(-longestEffectDuration) - let doses = doseHistory.filterDateRange(doseStart, end) + let doses = doseHistory.filterDateRange(doseStart, doseEnd ?? end) let trimmedDoses = doses.map { (dose) -> DoseEntry in guard dose.type != .bolus else { return dose diff --git a/LoopTests/ViewModels/BolusEntryViewModelTests.swift b/LoopTests/ViewModels/BolusEntryViewModelTests.swift index 7f2c421ebf..32cb63ca67 100644 --- a/LoopTests/ViewModels/BolusEntryViewModelTests.swift +++ b/LoopTests/ViewModels/BolusEntryViewModelTests.swift @@ -822,6 +822,8 @@ fileprivate class MockLoopState: LoopState { var totalRetrospectiveCorrection: HKQuantity? + var negativeInsulinDamper: Double? + var predictGlucoseValueResult: [PredictedGlucoseValue] = [] func predictGlucose(using inputs: PredictionInputEffect, potentialBolus: DoseEntry?, potentialCarbEntry: NewCarbEntry?, replacingCarbEntry replacedCarbEntry: StoredCarbEntry?, includingPendingInsulin: Bool, considerPositiveVelocityAndRC: Bool) throws -> [PredictedGlucoseValue] { return predictGlucoseValueResult