From a05a5aabac3d4889defb0ba522d2cedf88d070b8 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Thu, 17 Oct 2024 21:12:20 +0300 Subject: [PATCH 01/18] negative insulin damper --- Loop.xcodeproj/project.pbxproj | 4 ++ Loop/Managers/LoopDataManager.swift | 54 +++++++++++++++++++ Loop/Models/LoopSettings+Loop.swift | 3 ++ Loop/Models/PredictionInputEffect.swift | 7 ++- .../PredictionTableViewController.swift | 9 +++- .../NegativeInsulinDamperSelectionView.swift | 45 ++++++++++++++++ ...ingsView+algorithmExperimentsSection.swift | 16 ++++++ 7 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 Loop/Views/NegativeInsulinDamperSelectionView.swift 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 = ""; }; 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 = ""; }; @@ -2276,6 +2278,7 @@ C1AF062229426300002C1B19 /* ManualGlucoseEntryRow.swift */, DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */, DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */, + 120490CA2CBFB25A006BDF0A /* NegativeInsulinDamperSelectionView.swift */, ); path = Views; sourceTree = ""; @@ -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..6862244157 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1314,6 +1314,60 @@ extension LoopDataManager { effects.append(bolusEffect) } } + + if inputs.contains(.damper) { + let computationInsulinEffect: [GlucoseEffect]? + if insulinEffectOverride != nil { + computationInsulinEffect = insulinEffectOverride + } else { + computationInsulinEffect = includingPendingInsulin ? self.insulinEffectIncludingPendingInsulin : self.insulinEffect + } + + if let insulinEffect = computationInsulinEffect { + var posDeltas = [Double]() + var posDeltaSum = 0.0 + insulinEffect.enumerated().forEach{ + let delta : Double + if $0.offset == 0 { + delta = 0 + } else { + delta = $0.element.quantity.doubleValue(for: .milligramsPerDeciliter) - insulinEffect[$0.offset - 1].quantity.doubleValue(for: .milligramsPerDeciliter) + } + posDeltaSum += max(0, delta) + posDeltas.append(max(0, delta)) + } + + // when NID is added to insulinEffect, the end result is alpha * insulinEffect, where alpha is dependent on posDeltaSum + // the long term slope will be marginalSlope + // in the initial linear scaling region alpha will be anchorAlpha at anchorPoint + let marginalSlope = 0.1 + let anchorPoint = 50.0 + let anchorAlpha = 0.75 + + 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) + + let alpha : Double + if posDeltaSum < transitionPoint { // linear scaling region + alpha = 1 - linearScaleSlope * posDeltaSum + } else { // marginal slope region + let transitionValue = (1 - linearScaleSlope * transitionPoint) * transitionPoint + alpha = (transitionValue + marginalSlope * (posDeltaSum - transitionPoint)) / posDeltaSum + } + + var damperEffects = [GlucoseEffect]() + var value = 0.0 + insulinEffect.enumerated().forEach{ + value += (alpha - 1) * posDeltas[$0.offset] + damperEffects.append(GlucoseEffect(startDate: $0.element.startDate, quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: value))) + } + + effects.append(damperEffects) + } + } if inputs.contains(.momentum), let momentumEffect = self.glucoseMomentumEffect { if !includingPositiveVelocityAndRC, let netMomentum = momentumEffect.netEffect(), netMomentum.quantity.doubleValue(for: .milligramsPerDeciliter) > 0 { 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/PredictionInputEffect.swift b/Loop/Models/PredictionInputEffect.swift index 45fb5ea0c7..ccc2b621fa 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("Glucose effect of applying a damper to reduce increases in glucose due to 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..891373b0c7 100644 --- a/Loop/View Controllers/PredictionTableViewController.swift +++ b/Loop/View Controllers/PredictionTableViewController.swift @@ -197,9 +197,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 diff --git a/Loop/Views/NegativeInsulinDamperSelectionView.swift b/Loop/Views/NegativeInsulinDamperSelectionView.swift new file mode 100644 index 0000000000..10cac84d7c --- /dev/null +++ b/Loop/Views/NegativeInsulinDamperSelectionView.swift @@ -0,0 +1,45 @@ +// +// 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 negative insulin have on predicted glucose levels. After spending significant time beneath the correction range, there may be a build up of negative insulin which will result in larger predicted glucose values, and subsequently may result in too much insulin being given by Loop. NID reduces the magnitude of these predictions. The larger the total predicted rise in glucose due to negative insulin, the greater the reduction will be.", 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) + .onChange(of: isNegativeInsulinDamperEnabled) { newValue in + UserDefaults.standard.negativeInsulinDamperEnabled = newValue + } + .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..2389080a58 100644 --- a/Loop/Views/SettingsView+algorithmExperimentsSection.swift +++ b/Loop/Views/SettingsView+algorithmExperimentsSection.swift @@ -41,6 +41,8 @@ public struct ExperimentRow: View { public struct ExperimentsSettingsView: View { @State private var isGlucoseBasedApplicationFactorEnabled = UserDefaults.standard.glucoseBasedApplicationFactorEnabled @State private var isIntegralRetrospectiveCorrectionEnabled = UserDefaults.standard.integralRetrospectiveCorrectionEnabled + @State private var isNegativeInsulinDamperEnabled = UserDefaults.standard.negativeInsulinDamperEnabled + 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() @@ -83,6 +90,7 @@ extension UserDefaults { private 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) + } + } } From c07841bc6bd63486f21b30f4d8a141c88a3d9754 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Tue, 22 Oct 2024 14:45:43 +0300 Subject: [PATCH 02/18] Make NID reduce the whole prediction not just insulin --- Loop/Managers/LoopDataManager.swift | 99 +++++++++++++------ .../NegativeInsulinDamperSelectionView.swift | 2 +- 2 files changed, 68 insertions(+), 33 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 6862244157..3b0dad8369 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1199,7 +1199,7 @@ extension LoopDataManager { // All outstanding potential insulin delivery return pendingTempBasalInsulin + pendingBolusAmount } - + /// - Throws: /// - LoopError.missingDataError /// - LoopError.configurationError @@ -1314,6 +1314,29 @@ extension LoopDataManager { effects.append(bolusEffect) } } + + if inputs.contains(.momentum), let momentumEffect = self.glucoseMomentumEffect { + if !includingPositiveVelocityAndRC, let netMomentum = momentumEffect.netEffect(), netMomentum.quantity.doubleValue(for: .milligramsPerDeciliter) > 0 { + momentum = [] + } else { + momentum = momentumEffect + } + } + + if inputs.contains(.retrospection) { + if !includingPositiveVelocityAndRC, let netRC = retrospectiveGlucoseEffect.netEffect(), netRC.quantity.doubleValue(for: .milligramsPerDeciliter) > 0 { + // positive RC is turned off + } else { + effects.append(retrospectiveGlucoseEffect) + } + } + + // Append effect of suspending insulin delivery when selected by the user on the Predicted Glucose screen (for information purposes only) + if inputs.contains(.suspend) { + effects.append(suspendInsulinDeliveryEffect) + } + + var prediction = LoopMath.predictGlucose(startingAt: glucose, momentum: momentum, effects: effects) if inputs.contains(.damper) { let computationInsulinEffect: [GlucoseEffect]? @@ -1324,7 +1347,6 @@ extension LoopDataManager { } if let insulinEffect = computationInsulinEffect { - var posDeltas = [Double]() var posDeltaSum = 0.0 insulinEffect.enumerated().forEach{ let delta : Double @@ -1334,14 +1356,17 @@ extension LoopDataManager { delta = $0.element.quantity.doubleValue(for: .milligramsPerDeciliter) - insulinEffect[$0.offset - 1].quantity.doubleValue(for: .milligramsPerDeciliter) } posDeltaSum += max(0, delta) - posDeltas.append(max(0, delta)) } - // when NID is added to insulinEffect, the end result is alpha * insulinEffect, where alpha is dependent on posDeltaSum + let insulinSensitivity = settings.insulinSensitivitySchedule!.quantity(at: glucose.startDate) + let basalRate = settings.basalRateSchedule!.value(at: glucose.startDate) + + // 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.1 - let anchorPoint = 50.0 + let anchorPoint = basalRate * insulinSensitivity.doubleValue(for: .milligramsPerDeciliter) let anchorAlpha = 0.75 let linearScaleSlope = (1.0 - anchorAlpha)/anchorPoint // how alpha scales down in the linear scale region @@ -1358,39 +1383,49 @@ extension LoopDataManager { alpha = (transitionValue + marginalSlope * (posDeltaSum - transitionPoint)) / posDeltaSum } - var damperEffects = [GlucoseEffect]() - var value = 0.0 - insulinEffect.enumerated().forEach{ - value += (alpha - 1) * posDeltas[$0.offset] - damperEffects.append(GlucoseEffect(startDate: $0.element.startDate, quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: value))) + 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) } - effects.append(damperEffects) - } - } - - if inputs.contains(.momentum), let momentumEffect = self.glucoseMomentumEffect { - if !includingPositiveVelocityAndRC, let netMomentum = momentumEffect.netEffect(), netMomentum.quantity.doubleValue(for: .milligramsPerDeciliter) > 0 { - momentum = [] - } else { - momentum = momentumEffect - } - } + 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 inputs.contains(.retrospection) { - if !includingPositiveVelocityAndRC, let netRC = retrospectiveGlucoseEffect.netEffect(), netRC.quantity.doubleValue(for: .milligramsPerDeciliter) > 0 { - // positive RC is turned off - } else { - effects.append(retrospectiveGlucoseEffect) + if damperOnly { + // we just want to display the effects of damper relative to everything else + if delta > 0 { + value += (alpha - 1) * 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 } } - - // Append effect of suspending insulin delivery when selected by the user on the Predicted Glucose screen (for information purposes only) - if inputs.contains(.suspend) { - effects.append(suspendInsulinDeliveryEffect) - } - var prediction = LoopMath.predictGlucose(startingAt: glucose, momentum: momentum, effects: effects) // Dosing requires prediction entries at least as long as the insulin model duration. // If our prediction is shorter than that, then extend it here. diff --git a/Loop/Views/NegativeInsulinDamperSelectionView.swift b/Loop/Views/NegativeInsulinDamperSelectionView.swift index 10cac84d7c..c2373cd3be 100644 --- a/Loop/Views/NegativeInsulinDamperSelectionView.swift +++ b/Loop/Views/NegativeInsulinDamperSelectionView.swift @@ -22,7 +22,7 @@ struct NegativeInsulinDamperSelectionView: View { Divider() - Text(NSLocalizedString("Negative Insulin Damper (NID) is used to mitigate the effects negative insulin have on predicted glucose levels. After spending significant time beneath the correction range, there may be a build up of negative insulin which will result in larger predicted glucose values, and subsequently may result in too much insulin being given by Loop. NID reduces the magnitude of these predictions. The larger the total predicted rise in glucose due to negative insulin, the greater the reduction will be.", comment: "Description of Negative Insulin Damper toggle.")) + 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 positive prediced glucose changes. 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 reduction in positive predicted glucose changes.", comment: "Description of Negative Insulin Damper toggle.")) .foregroundColor(.secondary) Divider() From d601acec2700d3004baeda54d1253a671b2e5cdf Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Tue, 29 Oct 2024 15:53:28 +0200 Subject: [PATCH 03/18] NID - refactor. Support insulin models. Add 10 minute lag --- Loop/Managers/LoopDataManager.swift | 195 +++++++++++------- Loop/Models/PredictionInputEffect.swift | 2 +- .../PredictionTableViewController.swift | 21 ++ 3 files changed, 137 insertions(+), 81 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 3b0dad8369..a907031335 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -358,6 +358,12 @@ final class LoopDataManager { predictedGlucose = nil } } + + private var negativeInsulinDamper: Double? { + didSet { + predictedGlucose = nil + } + } /// When combining retrospective glucose discrepancies, extend the window slightly as a buffer. private let retrospectiveCorrectionGroupingIntervalMultiplier = 1.01 @@ -995,6 +1001,71 @@ extension LoopDataManager { let earliestEffectDate = Date(timeInterval: .hours(-24), since: now()) let nextCounteractionEffectDate = insulinCounteractionEffects.last?.endDate ?? earliestEffectDate let insulinEffectStartDate = nextCounteractionEffectDate.addingTimeInterval(.minutes(-5)) + let updateInsulinEffectNeeded = insulinEffect == nil || insulinEffect?.first?.startDate ?? .distantFuture > insulinEffectStartDate + + if negativeInsulinDamper == nil || updateInsulinEffectNeeded { + self.logger.debug("Recomputing negative insulin damper") + updateGroup.enter() + let lastInsulinStartDate = nextCounteractionEffectDate.addingTimeInterval(.minutes(-10)) + doseStore.getGlucoseEffects(start: insulinEffectStartDate, end: nil, basalDosingEnd: lastInsulinStartDate) { (result) -> Void in + switch result { + case .failure(let error): + self.logger.error("Could not fetch insulin effects for damper: %{public}@", error.localizedDescription) + self.insulinEffect = nil + warnings.append(.fetchDataWarning(.insulinEffect(error: error))) + case .success(let effects): + var posDeltaSum = 0.0 + effects.enumerated().forEach{ + let delta : Double + if $0.offset == 0 { + delta = 0 + } else { + delta = $0.element.quantity.doubleValue(for: .milligramsPerDeciliter) - effects[$0.offset - 1].quantity.doubleValue(for: .milligramsPerDeciliter) + } + posDeltaSum += max(0, delta) + } + + let insulinSensitivity = latestSettings.insulinSensitivitySchedule!.quantity(at: lastGlucoseDate) + let basalRate = latestSettings.basalRateSchedule!.value(at: lastGlucoseDate) + 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 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) + + let alpha : Double + if posDeltaSum < transitionPoint { // linear scaling region + alpha = 1 - linearScaleSlope * posDeltaSum + } else { // marginal slope region + let transitionValue = (1 - linearScaleSlope * transitionPoint) * transitionPoint + alpha = (transitionValue + marginalSlope * (posDeltaSum - transitionPoint)) / posDeltaSum + } + + self.negativeInsulinDamper = 1 - alpha + } + + updateGroup.leave() + } + + } if glucoseMomentumEffect == nil { updateGroup.enter() @@ -1011,7 +1082,7 @@ extension LoopDataManager { } } - if insulinEffect == nil || insulinEffect?.first?.startDate ?? .distantFuture > insulinEffectStartDate { + if updateInsulinEffectNeeded { self.logger.debug("Recomputing insulin effects") updateGroup.enter() doseStore.getGlucoseEffects(start: insulinEffectStartDate, end: nil, basalDosingEnd: now()) { (result) -> Void in @@ -1338,92 +1409,48 @@ extension LoopDataManager { var prediction = LoopMath.predictGlucose(startingAt: glucose, momentum: momentum, effects: effects) - if inputs.contains(.damper) { - let computationInsulinEffect: [GlucoseEffect]? - if insulinEffectOverride != nil { - computationInsulinEffect = insulinEffectOverride - } else { - computationInsulinEffect = includingPendingInsulin ? self.insulinEffectIncludingPendingInsulin : self.insulinEffect + 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) } - - if let insulinEffect = computationInsulinEffect { - var posDeltaSum = 0.0 - insulinEffect.enumerated().forEach{ - let delta : Double - if $0.offset == 0 { - delta = 0 - } else { - delta = $0.element.quantity.doubleValue(for: .milligramsPerDeciliter) - insulinEffect[$0.offset - 1].quantity.doubleValue(for: .milligramsPerDeciliter) - } - posDeltaSum += max(0, delta) - } - - let insulinSensitivity = settings.insulinSensitivitySchedule!.quantity(at: glucose.startDate) - let basalRate = settings.basalRateSchedule!.value(at: glucose.startDate) - - // 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.1 - let anchorPoint = basalRate * insulinSensitivity.doubleValue(for: .milligramsPerDeciliter) - let anchorAlpha = 0.75 - - let linearScaleSlope = (1.0 - anchorAlpha)/anchorPoint // how alpha scales down in the linear scale region + + let alpha = 1 - damper + var dampedPrediction = [PredictedGlucoseValue]() + var value = 0.0 + prediction.enumerated().forEach{ - // 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) - - let alpha : Double - if posDeltaSum < transitionPoint { // linear scaling region - alpha = 1 - linearScaleSlope * posDeltaSum - } else { // marginal slope region - let transitionValue = (1 - linearScaleSlope * transitionPoint) * transitionPoint - alpha = (transitionValue + marginalSlope * (posDeltaSum - transitionPoint)) / posDeltaSum - } - - 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) + if $0.offset == 0 { + value = $0.element.quantity.doubleValue(for: .milligramsPerDeciliter) + dampedPrediction.append($0.element) + return } - - 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) + 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 += (alpha - 1) * delta - } - } else if delta > 0 { - value += alpha * delta - } else { - value += delta + if damperOnly { + // we just want to display the effects of damper relative to everything else + if delta > 0 { + value -= damper * delta } - dampedPrediction.append(PredictedGlucoseValue(startDate: $0.element.startDate, quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: value))) + } else if delta > 0 { + value += alpha * delta + } else { + value += delta } - - prediction = dampedPrediction + dampedPrediction.append(PredictedGlucoseValue(startDate: $0.element.startDate, quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: value))) } + + prediction = dampedPrediction } @@ -2044,6 +2071,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 /// @@ -2168,6 +2198,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/Models/PredictionInputEffect.swift b/Loop/Models/PredictionInputEffect.swift index ccc2b621fa..f0c325b4a2 100644 --- a/Loop/Models/PredictionInputEffect.swift +++ b/Loop/Models/PredictionInputEffect.swift @@ -48,7 +48,7 @@ struct PredictionInputEffect: OptionSet { 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("Glucose effect of applying a damper to reduce increases in glucose due to negative insulin", comment: "Description of the prediction input effect for negative insulin damper"), unit.localizedShortUnitString) + return String(format: NSLocalizedString("A damper which 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 891373b0c7..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() @@ -268,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 From 223920467791419a26b2a28cc8943870e8089696 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Wed, 30 Oct 2024 18:30:01 +0200 Subject: [PATCH 04/18] Fix damper lag --- Loop/Managers/LoopDataManager.swift | 11 +++++++---- Loop/Managers/Store Protocols/DoseStoreProtocol.swift | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index a907031335..9aaa19d3cc 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1007,7 +1007,7 @@ extension LoopDataManager { self.logger.debug("Recomputing negative insulin damper") updateGroup.enter() let lastInsulinStartDate = nextCounteractionEffectDate.addingTimeInterval(.minutes(-10)) - doseStore.getGlucoseEffects(start: insulinEffectStartDate, end: nil, basalDosingEnd: lastInsulinStartDate) { (result) -> Void in + doseStore.getGlucoseEffects(start: lastInsulinStartDate, end: nil, doseEnd: lastInsulinStartDate, basalDosingEnd: now()) { (result) -> Void in switch result { case .failure(let error): self.logger.error("Could not fetch insulin effects for damper: %{public}@", error.localizedDescription) @@ -1016,6 +1016,9 @@ extension LoopDataManager { case .success(let effects): var posDeltaSum = 0.0 effects.enumerated().forEach{ + guard $0.element.startDate >= insulinEffectStartDate else { + return + } let delta : Double if $0.offset == 0 { delta = 0 @@ -1085,7 +1088,7 @@ extension LoopDataManager { if updateInsulinEffectNeeded { self.logger.debug("Recomputing insulin effects") updateGroup.enter() - doseStore.getGlucoseEffects(start: insulinEffectStartDate, end: nil, basalDosingEnd: now()) { (result) -> Void in + doseStore.getGlucoseEffects(start: insulinEffectStartDate, end: nil, doseEnd: nil, basalDosingEnd: now()) { (result) -> Void in switch result { case .failure(let error): self.logger.error("Could not fetch insulin effects: %{public}@", error.localizedDescription) @@ -1101,7 +1104,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) @@ -1483,7 +1486,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 } 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) -> 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) From e53fa48862638f02f5f5bf2c3ea2a1ae281d6c10 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Thu, 31 Oct 2024 09:56:28 +0200 Subject: [PATCH 05/18] Minor fix regarding effectStart. Minor refactor --- Loop/Managers/LoopDataManager.swift | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 9aaa19d3cc..4d56fc0618 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1006,8 +1006,8 @@ extension LoopDataManager { if negativeInsulinDamper == nil || updateInsulinEffectNeeded { self.logger.debug("Recomputing negative insulin damper") updateGroup.enter() - let lastInsulinStartDate = nextCounteractionEffectDate.addingTimeInterval(.minutes(-10)) - doseStore.getGlucoseEffects(start: lastInsulinStartDate, end: nil, doseEnd: lastInsulinStartDate, basalDosingEnd: now()) { (result) -> Void in + let lastDoseStartDate = nextCounteractionEffectDate.addingTimeInterval(.minutes(-10)) + doseStore.getGlucoseEffects(start: insulinEffectStartDate, end: nil, doseEnd: lastDoseStartDate, basalDosingEnd: now()) { (result) -> Void in switch result { case .failure(let error): self.logger.error("Could not fetch insulin effects for damper: %{public}@", error.localizedDescription) @@ -1016,16 +1016,11 @@ extension LoopDataManager { case .success(let effects): var posDeltaSum = 0.0 effects.enumerated().forEach{ - guard $0.element.startDate >= insulinEffectStartDate else { - return - } let delta : Double - if $0.offset == 0 { - delta = 0 - } else { + if $0.offset > 0 { delta = $0.element.quantity.doubleValue(for: .milligramsPerDeciliter) - effects[$0.offset - 1].quantity.doubleValue(for: .milligramsPerDeciliter) + posDeltaSum += max(0, delta) } - posDeltaSum += max(0, delta) } let insulinSensitivity = latestSettings.insulinSensitivitySchedule!.quantity(at: lastGlucoseDate) From 57fb59b88e001aace21a91cfb4bb245a7fb1ff3f Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Thu, 31 Oct 2024 09:57:15 +0200 Subject: [PATCH 06/18] Minor refactor --- Loop/Managers/LoopDataManager.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 4d56fc0618..95fb842ec5 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1016,9 +1016,8 @@ extension LoopDataManager { case .success(let effects): var posDeltaSum = 0.0 effects.enumerated().forEach{ - let delta : Double if $0.offset > 0 { - delta = $0.element.quantity.doubleValue(for: .milligramsPerDeciliter) - effects[$0.offset - 1].quantity.doubleValue(for: .milligramsPerDeciliter) + let delta = $0.element.quantity.doubleValue(for: .milligramsPerDeciliter) - effects[$0.offset - 1].quantity.doubleValue(for: .milligramsPerDeciliter) posDeltaSum += max(0, delta) } } From 4b6cc91806935a6689c277fb3ff063ad027d1081 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Sun, 3 Nov 2024 16:00:26 +0200 Subject: [PATCH 07/18] Text fixes. Consistent lag for basal too --- Loop/Managers/LoopDataManager.swift | 4 ++-- Loop/Models/PredictionInputEffect.swift | 2 +- Loop/Views/NegativeInsulinDamperSelectionView.swift | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 95fb842ec5..94024482bb 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1007,7 +1007,7 @@ extension LoopDataManager { self.logger.debug("Recomputing negative insulin damper") updateGroup.enter() let lastDoseStartDate = nextCounteractionEffectDate.addingTimeInterval(.minutes(-10)) - doseStore.getGlucoseEffects(start: insulinEffectStartDate, end: nil, doseEnd: lastDoseStartDate, basalDosingEnd: now()) { (result) -> Void in + 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) @@ -1056,7 +1056,7 @@ extension LoopDataManager { alpha = (transitionValue + marginalSlope * (posDeltaSum - transitionPoint)) / posDeltaSum } - self.negativeInsulinDamper = 1 - alpha + self.negativeInsulinDamper = max(0, min(1, 1 - alpha)) } updateGroup.leave() diff --git a/Loop/Models/PredictionInputEffect.swift b/Loop/Models/PredictionInputEffect.swift index f0c325b4a2..c80cde1d3a 100644 --- a/Loop/Models/PredictionInputEffect.swift +++ b/Loop/Models/PredictionInputEffect.swift @@ -48,7 +48,7 @@ struct PredictionInputEffect: OptionSet { 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("A damper which 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) + 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/Views/NegativeInsulinDamperSelectionView.swift b/Loop/Views/NegativeInsulinDamperSelectionView.swift index c2373cd3be..62702d1215 100644 --- a/Loop/Views/NegativeInsulinDamperSelectionView.swift +++ b/Loop/Views/NegativeInsulinDamperSelectionView.swift @@ -22,7 +22,7 @@ struct NegativeInsulinDamperSelectionView: View { 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 positive prediced glucose changes. 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 reduction in positive predicted glucose changes.", comment: "Description of Negative Insulin Damper toggle.")) + 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.", comment: "Description of Negative Insulin Damper toggle.")) .foregroundColor(.secondary) Divider() From e3399b3d8321245005e79ab5f7a765a38f663302 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Fri, 8 Nov 2024 16:06:54 +0200 Subject: [PATCH 08/18] Change lag from 10 to 15 minutes --- Loop/Managers/LoopDataManager.swift | 2 +- Loop/Views/NegativeInsulinDamperSelectionView.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 94024482bb..4f3c98f06a 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1006,7 +1006,7 @@ extension LoopDataManager { if negativeInsulinDamper == nil || updateInsulinEffectNeeded { self.logger.debug("Recomputing negative insulin damper") updateGroup.enter() - let lastDoseStartDate = nextCounteractionEffectDate.addingTimeInterval(.minutes(-10)) + 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): diff --git a/Loop/Views/NegativeInsulinDamperSelectionView.swift b/Loop/Views/NegativeInsulinDamperSelectionView.swift index 62702d1215..97929ca867 100644 --- a/Loop/Views/NegativeInsulinDamperSelectionView.swift +++ b/Loop/Views/NegativeInsulinDamperSelectionView.swift @@ -22,7 +22,7 @@ struct NegativeInsulinDamperSelectionView: View { 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.", comment: "Description of Negative Insulin Damper toggle.")) + 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() From 095f428684143566e0f147ece6bfe944c693004e Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Wed, 11 Dec 2024 17:34:38 +0200 Subject: [PATCH 09/18] Support ExponentialInsulinModelPreset explicitly --- Loop/Managers/LoopDataManager.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 4f3c98f06a..26110cef98 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1028,7 +1028,9 @@ extension LoopDataManager { // 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 { + if let presetModel = model as? ExponentialInsulinModelPreset { + anchorScale = 0.8 * presetModel.peakActivity.hours + } else if let expModel = model as? ExponentialInsulinModel { anchorScale = 0.8 * expModel.peakActivityTime.hours } else { anchorScale = 1.0 From ab36da8cf1affbaa29cf48b34c53d356bae3d0a0 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Thu, 12 Dec 2024 12:53:56 +0200 Subject: [PATCH 10/18] Cleanup ExponentialInsulinModelPreset --- Loop/Managers/LoopDataManager.swift | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 26110cef98..4f3c98f06a 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1028,9 +1028,7 @@ extension LoopDataManager { // anchorScale is set to 1 hour for rapid acting adult, and 44 minutes for ultra-rapid insulins let anchorScale: Double - if let presetModel = model as? ExponentialInsulinModelPreset { - anchorScale = 0.8 * presetModel.peakActivity.hours - } else if let expModel = model as? ExponentialInsulinModel { + if let expModel = model as? ExponentialInsulinModel { anchorScale = 0.8 * expModel.peakActivityTime.hours } else { anchorScale = 1.0 From bafb33f3fdd409adfc42080ec44fd82e27992fa8 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Thu, 12 Dec 2024 12:54:13 +0200 Subject: [PATCH 11/18] Fix tests for negative insulin damper --- LoopTests/Mock Stores/MockDoseStore.swift | 4 ++-- LoopTests/ViewModels/BolusEntryViewModelTests.swift | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) 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 From b6f180cdcaf56fc1efc8fff4e9061ddc5ab6c7b1 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Mon, 16 Dec 2024 14:29:26 +0200 Subject: [PATCH 12/18] Update safeguard to ensure damper <= 95% By construction it should never occur, but safeguards should enforce this. --- Loop/Managers/LoopDataManager.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 4f3c98f06a..a277690e87 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -1056,7 +1056,8 @@ extension LoopDataManager { alpha = (transitionValue + marginalSlope * (posDeltaSum - transitionPoint)) / posDeltaSum } - self.negativeInsulinDamper = max(0, min(1, 1 - alpha)) + // alpha should never be less than marginalSlope + self.negativeInsulinDamper = max(0, 1 - max(marginalSlope, alpha)) } updateGroup.leave() From 0686a46c32524acdd7afc4449c5cdef295b69e55 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Sun, 22 Dec 2024 10:14:08 +0200 Subject: [PATCH 13/18] Prevent recalculating insulinEffect and NID needlessly --- Loop/Managers/LoopDataManager.swift | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index a277690e87..2365e12a4d 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -340,6 +340,8 @@ final class LoopDataManager { } private var insulinEffect: [GlucoseEffect]? + private var insulinEffectCachedBaseDate: Date = .distantPast + private var insulinEffectCachedBasalDosingEnd: Date = .distantPast private var insulinEffectIncludingPendingInsulin: [GlucoseEffect]? { didSet { @@ -364,6 +366,7 @@ final class LoopDataManager { predictedGlucose = nil } } + private var negativeInsulinDamperCachedBaseDate: Date = .distantPast /// When combining retrospective glucose discrepancies, extend the window slightly as a buffer. private let retrospectiveCorrectionGroupingIntervalMultiplier = 1.01 @@ -1003,7 +1006,7 @@ extension LoopDataManager { let insulinEffectStartDate = nextCounteractionEffectDate.addingTimeInterval(.minutes(-5)) let updateInsulinEffectNeeded = insulinEffect == nil || insulinEffect?.first?.startDate ?? .distantFuture > insulinEffectStartDate - if negativeInsulinDamper == nil || updateInsulinEffectNeeded { + if negativeInsulinDamper == nil || updateInsulinEffectNeeded, nextCounteractionEffectDate != negativeInsulinDamperCachedBaseDate { self.logger.debug("Recomputing negative insulin damper") updateGroup.enter() let lastDoseStartDate = nextCounteractionEffectDate.addingTimeInterval(.minutes(-15)) @@ -1012,6 +1015,7 @@ extension LoopDataManager { case .failure(let error): self.logger.error("Could not fetch insulin effects for damper: %{public}@", error.localizedDescription) self.insulinEffect = nil + self.negativeInsulinDamperCachedBaseDate = .distantPast warnings.append(.fetchDataWarning(.insulinEffect(error: error))) case .success(let effects): var posDeltaSum = 0.0 @@ -1058,6 +1062,7 @@ extension LoopDataManager { // alpha should never be less than marginalSlope self.negativeInsulinDamper = max(0, 1 - max(marginalSlope, alpha)) + self.negativeInsulinDamperCachedBaseDate = nextCounteractionEffectDate } updateGroup.leave() @@ -1080,17 +1085,22 @@ extension LoopDataManager { } } - if updateInsulinEffectNeeded { + if updateInsulinEffectNeeded, (insulinEffectStartDate != insulinEffectCachedBaseDate || now().addingTimeInterval(.minutes(-1)) > insulinEffectCachedBasalDosingEnd) { self.logger.debug("Recomputing insulin effects") updateGroup.enter() - doseStore.getGlucoseEffects(start: insulinEffectStartDate, end: nil, doseEnd: 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) self.insulinEffect = nil + self.insulinEffectCachedBaseDate = .distantPast + self.insulinEffectCachedBasalDosingEnd = .distantPast warnings.append(.fetchDataWarning(.insulinEffect(error: error))) case .success(let effects): self.insulinEffect = effects + self.insulinEffectCachedBaseDate = insulinEffectStartDate + self.insulinEffectCachedBasalDosingEnd = basalDosingEnd } updateGroup.leave() From b06cdfecc834fc76a2ad95ea7a930d7eed8a7a44 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Sun, 22 Dec 2024 10:42:19 +0200 Subject: [PATCH 14/18] Undo changes to insulinEffect caching Control NID recalculation independently --- Loop/Managers/LoopDataManager.swift | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index 2365e12a4d..dc0598d390 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -340,8 +340,6 @@ final class LoopDataManager { } private var insulinEffect: [GlucoseEffect]? - private var insulinEffectCachedBaseDate: Date = .distantPast - private var insulinEffectCachedBasalDosingEnd: Date = .distantPast private var insulinEffectIncludingPendingInsulin: [GlucoseEffect]? { didSet { @@ -1004,9 +1002,8 @@ extension LoopDataManager { let earliestEffectDate = Date(timeInterval: .hours(-24), since: now()) let nextCounteractionEffectDate = insulinCounteractionEffects.last?.endDate ?? earliestEffectDate let insulinEffectStartDate = nextCounteractionEffectDate.addingTimeInterval(.minutes(-5)) - let updateInsulinEffectNeeded = insulinEffect == nil || insulinEffect?.first?.startDate ?? .distantFuture > insulinEffectStartDate - if negativeInsulinDamper == nil || updateInsulinEffectNeeded, nextCounteractionEffectDate != negativeInsulinDamperCachedBaseDate { + if negativeInsulinDamper == nil || nextCounteractionEffectDate != negativeInsulinDamperCachedBaseDate { self.logger.debug("Recomputing negative insulin damper") updateGroup.enter() let lastDoseStartDate = nextCounteractionEffectDate.addingTimeInterval(.minutes(-15)) @@ -1085,7 +1082,7 @@ extension LoopDataManager { } } - if updateInsulinEffectNeeded, (insulinEffectStartDate != insulinEffectCachedBaseDate || now().addingTimeInterval(.minutes(-1)) > insulinEffectCachedBasalDosingEnd) { + if insulinEffect == nil || insulinEffect?.first?.startDate ?? .distantFuture > insulinEffectStartDate { self.logger.debug("Recomputing insulin effects") updateGroup.enter() let basalDosingEnd = now() @@ -1094,13 +1091,9 @@ extension LoopDataManager { case .failure(let error): self.logger.error("Could not fetch insulin effects: %{public}@", error.localizedDescription) self.insulinEffect = nil - self.insulinEffectCachedBaseDate = .distantPast - self.insulinEffectCachedBasalDosingEnd = .distantPast warnings.append(.fetchDataWarning(.insulinEffect(error: error))) case .success(let effects): self.insulinEffect = effects - self.insulinEffectCachedBaseDate = insulinEffectStartDate - self.insulinEffectCachedBasalDosingEnd = basalDosingEnd } updateGroup.leave() From d360b5bdda2df3d941a50cb386a17564b86c3b26 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Sun, 29 Dec 2024 15:33:26 +0200 Subject: [PATCH 15/18] Refactor for tests, and improve logging Also clear damper alongside cached insulin effects - this helps e.g. if non-pump insulin is added --- Loop/Managers/LoopDataManager.swift | 44 ++++++++++++------- Loop/Models/LoopWarning.swift | 4 ++ .../Managers/LoopDataManagerDosingTests.swift | 32 ++++++++++++++ 3 files changed, 63 insertions(+), 17 deletions(-) diff --git a/Loop/Managers/LoopDataManager.swift b/Loop/Managers/LoopDataManager.swift index dc0598d390..5e47cbc3ee 100644 --- a/Loop/Managers/LoopDataManager.swift +++ b/Loop/Managers/LoopDataManager.swift @@ -461,6 +461,7 @@ final class LoopDataManager { insulinEffect = nil insulinEffectIncludingPendingInsulin = nil predictedGlucose = nil + negativeInsulinDamper = nil } // MARK: - Background task management @@ -1011,9 +1012,9 @@ extension LoopDataManager { switch result { case .failure(let error): self.logger.error("Could not fetch insulin effects for damper: %{public}@", error.localizedDescription) - self.insulinEffect = nil + self.negativeInsulinDamper = nil self.negativeInsulinDamperCachedBaseDate = .distantPast - warnings.append(.fetchDataWarning(.insulinEffect(error: error))) + warnings.append(.fetchDataWarning(.negativeInsulinDamper(error: error))) case .success(let effects): var posDeltaSum = 0.0 effects.enumerated().forEach{ @@ -1023,8 +1024,14 @@ extension LoopDataManager { } } - let insulinSensitivity = latestSettings.insulinSensitivitySchedule!.quantity(at: lastGlucoseDate) - let basalRate = latestSettings.basalRateSchedule!.value(at: lastGlucoseDate) + 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 @@ -1043,19 +1050,7 @@ extension LoopDataManager { let anchorPoint = anchorScale * basalRate * insulinSensitivity.doubleValue(for: .milligramsPerDeciliter) let anchorAlpha = 0.75 - 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) - - let alpha : Double - if posDeltaSum < transitionPoint { // linear scaling region - alpha = 1 - linearScaleSlope * posDeltaSum - } else { // marginal slope region - let transitionValue = (1 - linearScaleSlope * transitionPoint) * transitionPoint - alpha = (transitionValue + marginalSlope * (posDeltaSum - transitionPoint)) / posDeltaSum - } + let alpha = LoopDataManager.calculateNegativeInsulinDamperAlpha(anchorAlpha, anchorPoint, marginalSlope, posDeltaSum) // alpha should never be less than marginalSlope self.negativeInsulinDamper = max(0, 1 - max(marginalSlope, alpha)) @@ -1230,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, 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/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() { From ce3843af3c580a3103d99a3a6ebacfcc2fa6f127 Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Tue, 31 Dec 2024 15:23:37 +0200 Subject: [PATCH 16/18] Fix display issue when re-entering ExperimentSettings --- ...ttingsView+algorithmExperimentsSection.swift | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/Loop/Views/SettingsView+algorithmExperimentsSection.swift b/Loop/Views/SettingsView+algorithmExperimentsSection.swift index 2389080a58..f26ac49eaa 100644 --- a/Loop/Views/SettingsView+algorithmExperimentsSection.swift +++ b/Loop/Views/SettingsView+algorithmExperimentsSection.swift @@ -39,9 +39,9 @@ public struct ExperimentRow: View { } public struct ExperimentsSettingsView: View { - @State private var isGlucoseBasedApplicationFactorEnabled = UserDefaults.standard.glucoseBasedApplicationFactorEnabled - @State private var isIntegralRetrospectiveCorrectionEnabled = UserDefaults.standard.integralRetrospectiveCorrectionEnabled - @State private var isNegativeInsulinDamperEnabled = UserDefaults.standard.negativeInsulinDamperEnabled + @State private var isGlucoseBasedApplicationFactorEnabled = false + @State private var isIntegralRetrospectiveCorrectionEnabled = false + @State private var isNegativeInsulinDamperEnabled = false var automaticDosingStrategy: AutomaticDosingStrategy @@ -82,6 +82,17 @@ public struct ExperimentsSettingsView: View { .padding() } .navigationBarTitleDisplayMode(.inline) + .onAppear { // force loading the values from UserDefaults + if isGlucoseBasedApplicationFactorEnabled != UserDefaults.standard.glucoseBasedApplicationFactorEnabled { + isGlucoseBasedApplicationFactorEnabled = UserDefaults.standard.glucoseBasedApplicationFactorEnabled + } + if isIntegralRetrospectiveCorrectionEnabled != UserDefaults.standard.integralRetrospectiveCorrectionEnabled { + isIntegralRetrospectiveCorrectionEnabled = UserDefaults.standard.integralRetrospectiveCorrectionEnabled + } + if isNegativeInsulinDamperEnabled != UserDefaults.standard.negativeInsulinDamperEnabled { + isNegativeInsulinDamperEnabled = UserDefaults.standard.negativeInsulinDamperEnabled + } + } } } From c28d0b62d9f43987ebbd4c36be25fded1f27e8be Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Wed, 1 Jan 2025 17:27:57 +0200 Subject: [PATCH 17/18] Remove unnecessary inequality checks --- .../SettingsView+algorithmExperimentsSection.swift | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/Loop/Views/SettingsView+algorithmExperimentsSection.swift b/Loop/Views/SettingsView+algorithmExperimentsSection.swift index f26ac49eaa..081d2d0862 100644 --- a/Loop/Views/SettingsView+algorithmExperimentsSection.swift +++ b/Loop/Views/SettingsView+algorithmExperimentsSection.swift @@ -83,15 +83,9 @@ public struct ExperimentsSettingsView: View { } .navigationBarTitleDisplayMode(.inline) .onAppear { // force loading the values from UserDefaults - if isGlucoseBasedApplicationFactorEnabled != UserDefaults.standard.glucoseBasedApplicationFactorEnabled { - isGlucoseBasedApplicationFactorEnabled = UserDefaults.standard.glucoseBasedApplicationFactorEnabled - } - if isIntegralRetrospectiveCorrectionEnabled != UserDefaults.standard.integralRetrospectiveCorrectionEnabled { - isIntegralRetrospectiveCorrectionEnabled = UserDefaults.standard.integralRetrospectiveCorrectionEnabled - } - if isNegativeInsulinDamperEnabled != UserDefaults.standard.negativeInsulinDamperEnabled { - isNegativeInsulinDamperEnabled = UserDefaults.standard.negativeInsulinDamperEnabled - } + isGlucoseBasedApplicationFactorEnabled = UserDefaults.standard.glucoseBasedApplicationFactorEnabled + isIntegralRetrospectiveCorrectionEnabled = UserDefaults.standard.integralRetrospectiveCorrectionEnabled + isNegativeInsulinDamperEnabled = UserDefaults.standard.negativeInsulinDamperEnabled } } } From 2670c2abcda4274746aedf2f374d2cf3f30b036a Mon Sep 17 00:00:00 2001 From: Moti Nisenson-Ken Date: Tue, 7 Jan 2025 16:26:15 +0200 Subject: [PATCH 18/18] Use AppStorage instead of onAppear --- ...GlucoseBasedApplicationFactorSelectionView.swift | 3 --- ...tegralRetrospectiveCorrectionSelectionView.swift | 3 --- Loop/Views/NegativeInsulinDamperSelectionView.swift | 3 --- .../SettingsView+algorithmExperimentsSection.swift | 13 ++++--------- 4 files changed, 4 insertions(+), 18 deletions(-) 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 index 97929ca867..4f14233c81 100644 --- a/Loop/Views/NegativeInsulinDamperSelectionView.swift +++ b/Loop/Views/NegativeInsulinDamperSelectionView.swift @@ -27,9 +27,6 @@ struct NegativeInsulinDamperSelectionView: View { Divider() Toggle(NSLocalizedString("Enable Negative Insulin Damper", comment: "Title for Negative Insulin Damper toggle"), isOn: $isNegativeInsulinDamperEnabled) - .onChange(of: isNegativeInsulinDamperEnabled) { newValue in - UserDefaults.standard.negativeInsulinDamperEnabled = newValue - } .padding(.top, 20) } .padding() diff --git a/Loop/Views/SettingsView+algorithmExperimentsSection.swift b/Loop/Views/SettingsView+algorithmExperimentsSection.swift index 081d2d0862..4ed384564a 100644 --- a/Loop/Views/SettingsView+algorithmExperimentsSection.swift +++ b/Loop/Views/SettingsView+algorithmExperimentsSection.swift @@ -39,9 +39,9 @@ public struct ExperimentRow: View { } public struct ExperimentsSettingsView: View { - @State private var isGlucoseBasedApplicationFactorEnabled = false - @State private var isIntegralRetrospectiveCorrectionEnabled = false - @State private var isNegativeInsulinDamperEnabled = false + @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 @@ -82,17 +82,12 @@ public struct ExperimentsSettingsView: View { .padding() } .navigationBarTitleDisplayMode(.inline) - .onAppear { // force loading the values from UserDefaults - isGlucoseBasedApplicationFactorEnabled = UserDefaults.standard.glucoseBasedApplicationFactorEnabled - isIntegralRetrospectiveCorrectionEnabled = UserDefaults.standard.integralRetrospectiveCorrectionEnabled - isNegativeInsulinDamperEnabled = UserDefaults.standard.negativeInsulinDamperEnabled - } } } 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"