From dddf84246cd74aaadf36137e8e7c3db56a4cea0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Fri, 30 Oct 2026 10:10:17 +0100 Subject: [PATCH] basal_lock, negative_insulin: update nextdev patches for next-dev 97b2cfc next-dev 97b2cfc added the setup change history (SetupChange, SetupComparison, CurrentSetupViewModel and a setupChange state in SettingsView) and a note below the algorithm experiments list, which moved the context of the Loop project, SettingsView and experiments section hunks. The changed lines are the same as before. The previous patches are archived with date 20261030. --- .../archive/nextdev_basal_lock_20261030.patch | 926 ++++++++++++++++++ basal_lock/nextdev_basal_lock.patch | 26 +- .../nextdev_negative_insulin_20261030.patch | 461 +++++++++ .../nextdev_negative_insulin.patch | 24 +- 4 files changed, 1412 insertions(+), 25 deletions(-) create mode 100644 basal_lock/archive/nextdev_basal_lock_20261030.patch create mode 100644 negative_insulin/archive/nextdev_negative_insulin_20261030.patch diff --git a/basal_lock/archive/nextdev_basal_lock_20261030.patch b/basal_lock/archive/nextdev_basal_lock_20261030.patch new file mode 100644 index 0000000..303f9de --- /dev/null +++ b/basal_lock/archive/nextdev_basal_lock_20261030.patch @@ -0,0 +1,926 @@ +Submodule Loop contains modified content +diff --git a/Loop/Loop.xcodeproj/project.pbxproj b/Loop/Loop.xcodeproj/project.pbxproj +index a2599fd2..cd43f345 100644 +--- a/Loop/Loop.xcodeproj/project.pbxproj ++++ b/Loop/Loop.xcodeproj/project.pbxproj +@@ -603,6 +603,7 @@ + CB1AF8155B1761A30ACA5591 /* Loop/Resources/Sounds/synth.caf in Resources */ = {isa = PBXBuildFile; fileRef = 83081FFB0497A4596F597830 /* Loop/Resources/Sounds/synth.caf */; }; + D6E28676C2C7751C77C7F2FB /* Loop/Resources/Sounds/spring.caf in Resources */ = {isa = PBXBuildFile; fileRef = 9471E156FC10D6D0FF219C77 /* Loop/Resources/Sounds/spring.caf */; }; + DD3DBD292A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */; }; ++ DDC065142B65871E0033FD88 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC065132B65871E0033FD88 /* Preferences.swift */; }; + A1B2C3D42E8F000100000002 /* GuardAndRunwaySelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42E8F000100000001 /* GuardAndRunwaySelectionView.swift */; }; + DDC389FC2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC389FB2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift */; }; + DDC389FE2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */; }; +@@ -1417,6 +1418,7 @@ + C9SCENED0000000000000002 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + CE102E23D8C1C1291EF66387 /* PluginPickerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PluginPickerView.swift; sourceTree = ""; }; + DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegralRetrospectiveCorrectionSelectionView.swift; sourceTree = ""; }; ++ DDC065132B65871E0033FD88 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; + A1B2C3D42E8F000100000001 /* GuardAndRunwaySelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuardAndRunwaySelectionView.swift; sourceTree = ""; }; + DDC389FB2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsView+algorithmExperimentsSection.swift"; sourceTree = ""; }; + DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseBasedApplicationFactorSelectionView.swift; sourceTree = ""; }; +@@ -1734,6 +1736,7 @@ + C1E3862428247B7100F561A4 /* StoredLoopNotRunningNotification.swift */, + 4328E0311CFC068900E199AA /* WatchContext+LoopKit.swift */, + A987CD4824A58A0100439ADC /* ZipArchive.swift */, ++ DDC065132B65871E0033FD88 /* Preferences.swift */, + C16F51182B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift */, + C4C16D18C8806FF48331134C /* InsulinDeliveryHistory.swift */, + C16F511A2B89363A00EFD7A1 /* SimpleInsulinDose.swift */, +@@ -3535,6 +3538,7 @@ + A1E47000000000000000A001 /* AlertHistoryView.swift in Sources */, + C9A47B0380AAB1FC572D3F00 /* GlucoseAlertSettingsView.swift in Sources */, + E9C00EF524C623EF00628F35 /* LoopSettings+Loop.swift in Sources */, ++ DDC065142B65871E0033FD88 /* Preferences.swift in Sources */, + 4389916B1E91B689000EEF90 /* ChartSettings+Loop.swift in Sources */, + C178249A1E1999FA00D9D25C /* CaseCountable.swift in Sources */, + C1F2CAAC2B7A980600D7F581 /* BasalRelativeDose.swift in Sources */, +diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift +index 30ab8fb2..36695026 100644 +--- a/Loop/Loop/Managers/LoopDataManager.swift ++++ b/Loop/Loop/Managers/LoopDataManager.swift +@@ -737,6 +737,16 @@ final class LoopDataManager: ObservableObject { + let scheduledBasalRate = input.basal.closestPrior(to: loopBaseTime)!.value + let activeOverride = temporaryPresetsManager.presetHistory.activeOverride(at: loopBaseTime) + ++ // Basal Lock: while glucose is above the threshold, don't let the temp basal ++ // drop below the scheduled rate. ++ let shouldApplyBasalLock = Preferences.shared.isBasalLockEnabled ++ && latestGlucose.quantity > Preferences.shared.basalLockThreshold ++ && basal.unitsPerHour < scheduledBasalRate ++ if shouldApplyBasalLock { ++ // unrounded on purpose: must equal the neutralBasalRate passed below ++ basal = TempBasalRecommendation(unitsPerHour: scheduledBasalRate, duration: LoopAlgorithm.tempBasalDuration) ++ } ++ + let basalAdjustment = basal.adjustForCurrentDelivery( + at: loopBaseTime, + neutralBasalRate: scheduledBasalRate, +@@ -747,6 +757,9 @@ final class LoopDataManager: ObservableObject { + + if let basalAdjustment { + recommendationToEnact.basalAdjustment = basalAdjustment ++ if shouldApplyBasalLock { ++ recommendationToEnact.direction = .neutral // no longer a reduction ++ } + } + + output.recommendationResult = .success(.init(automatic: recommendationToEnact)) +diff --git a/Loop/Loop/Models/Preferences.swift b/Loop/Loop/Models/Preferences.swift +new file mode 100644 +index 00000000..ab5d00ec +--- /dev/null ++++ b/Loop/Loop/Models/Preferences.swift +@@ -0,0 +1,50 @@ ++// ++// Preferences.swift ++// Loop ++// ++// Created by Jonas Björkert on 2024-02-25. ++// Copyright © 2024 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import LoopKit ++import LoopAlgorithm ++ ++struct Preferences: PreferencesProvider { ++ ++ static var shared = Preferences() ++ ++ private init() {} ++ ++ // Basal Lock Threshold ++ var basalLockThreshold: LoopQuantity { ++ get { ++ let key = "basalLockThreshold" ++ if let value = UserDefaults.standard.value(forKey: key) as? Double { ++ return LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: value) ++ } else { ++ return LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 250) ++ } ++ } ++ set { ++ let key = "basalLockThreshold" ++ let value = newValue.doubleValue(for: .milligramsPerDeciliter) ++ UserDefaults.standard.set(value, forKey: key) ++ } ++ } ++ ++ // Basal Lock Enabled ++ var isBasalLockEnabled: Bool { ++ get { ++ let key = "isBasalLockEnabled" ++ if UserDefaults.standard.object(forKey: key) == nil { ++ return false ++ } ++ return UserDefaults.standard.bool(forKey: key) ++ } ++ set { ++ let key = "isBasalLockEnabled" ++ UserDefaults.standard.set(newValue, forKey: key) ++ } ++ } ++} +diff --git a/Loop/Loop/Views/SettingsView.swift b/Loop/Loop/Views/SettingsView.swift +index 1faefbc5..86b517fd 100644 +--- a/Loop/Loop/Views/SettingsView.swift ++++ b/Loop/Loop/Views/SettingsView.swift +@@ -62,6 +62,7 @@ struct SettingsView: View { + @State private var alert: Destination.Alert? + @State private var sheet: Destination.Sheet? + @State private var pluginSelection: (() -> Void)? ++ @State private var showingPreferences = false + + var localizedAppNameAndVersion: String + +@@ -93,6 +94,9 @@ struct SettingsView: View { + if FeatureFlags.allowExperimentalFeatures { + favoriteFoodsSection + } ++ if FeatureFlags.allowExperimentalFeatures { ++ preferencesSection ++ } + if (viewModel.pumpManagerSettingsViewModel.isTestingDevice || viewModel.cgmManagerSettingsViewModel.isTestingDevice) && viewModel.showDeleteTestData { + deleteDataSection + } +@@ -499,6 +503,21 @@ extension SettingsView { + descriptiveText: "Simplify Carb Entry") + } + } ++ ++ private var preferencesSection: some View { ++ Section { ++ LargeButton(action: { showingPreferences = true }, ++ includeArrow: true, ++ imageView: Image(systemName: "gearshape.fill").font(.system(size: 30, weight: .bold)), ++ label: NSLocalizedString("Preferences", comment: "Title text for button to Preferences"), ++ descriptiveText: NSLocalizedString("Customize your Loop experience by adjusting additional settings", comment: "Descriptive text for Preferences")) ++ } ++ .sheet(isPresented: $showingPreferences) { ++ PreferencesView(viewModel: PreferencesViewModel(preferencesProvider: Preferences.shared)) ++ .environmentObject(displayGlucosePreference) ++ .environment(\.dismissAction, { showingPreferences = false }) ++ } ++ } + + @ViewBuilder + private func pluginPickerView(for picker: Destination.PluginPicker) -> some View { +Submodule LoopKit contains modified content +diff --git a/LoopKit/LoopKit.xcodeproj/project.pbxproj b/LoopKit/LoopKit.xcodeproj/project.pbxproj +index cc88ad17..60bbe37a 100644 +--- a/LoopKit/LoopKit.xcodeproj/project.pbxproj ++++ b/LoopKit/LoopKit.xcodeproj/project.pbxproj +@@ -863,6 +863,13 @@ + C1FAEC1D264AD6B400A3250B /* DeviceStatusBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FAEC1C264AD6B400A3250B /* DeviceStatusBadge.swift */; }; + C1FAEC1F264AE12700A3250B /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B47ECF8725DC20810024A54D /* UIImage.swift */; }; + C1FAEC21264AEEA300A3250B /* UIImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FAEC20264AEEA300A3250B /* UIImage.swift */; }; ++ DD13BC702C3C71A70062313B /* BasalLockEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC6F2C3C71A70062313B /* BasalLockEditor.swift */; }; ++ DD13BC722C3C74CD0062313B /* PreferencesGuardrailWarning.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD13BC712C3C74CD0062313B /* PreferencesGuardrailWarning.swift */; }; ++ DD28B3712B80C074001044D4 /* PreferencesSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD28B3702B80C074001044D4 /* PreferencesSetting.swift */; }; ++ DD545A5B2B80ECB900915F95 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD545A5A2B80ECB900915F95 /* PreferencesView.swift */; }; ++ DD545A5F2B80EFA900915F95 /* PreferencesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD545A5E2B80EFA900915F95 /* PreferencesViewModel.swift */; }; ++ DD545A652B814CA800915F95 /* PreferencesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD545A642B814CA800915F95 /* PreferencesProvider.swift */; }; ++ DD545A692B8BCF8E00915F95 /* Guardrail+Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD545A682B8BCF8E00915F95 /* Guardrail+Preferences.swift */; }; + E0E99C342C0E7AD300F9300C /* ConfirmationToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E99C332C0E7AD300F9300C /* ConfirmationToggle.swift */; }; + E0E99C392C0E80C200F9300C /* LoopCircleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0E99C382C0E80C200F9300C /* LoopCircleView.swift */; }; + E9077D2724ACD59F0066A88D /* InformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9077D2624ACD59F0066A88D /* InformationView.swift */; }; +@@ -1790,6 +1797,13 @@ + C1FAC06228C7B0A100754AE2 /* reservoir_iob_test.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = reservoir_iob_test.json; sourceTree = ""; }; + C1FAEC1C264AD6B400A3250B /* DeviceStatusBadge.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceStatusBadge.swift; sourceTree = ""; }; + C1FAEC20264AEEA300A3250B /* UIImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIImage.swift; sourceTree = ""; }; ++ DD13BC6F2C3C71A70062313B /* BasalLockEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BasalLockEditor.swift; sourceTree = ""; }; ++ DD13BC712C3C74CD0062313B /* PreferencesGuardrailWarning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesGuardrailWarning.swift; sourceTree = ""; }; ++ DD28B3702B80C074001044D4 /* PreferencesSetting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesSetting.swift; sourceTree = ""; }; ++ DD545A5A2B80ECB900915F95 /* PreferencesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = ""; }; ++ DD545A5E2B80EFA900915F95 /* PreferencesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesViewModel.swift; sourceTree = ""; }; ++ DD545A642B814CA800915F95 /* PreferencesProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesProvider.swift; sourceTree = ""; }; ++ DD545A682B8BCF8E00915F95 /* Guardrail+Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Guardrail+Preferences.swift"; sourceTree = ""; }; + E0E99C332C0E7AD300F9300C /* ConfirmationToggle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfirmationToggle.swift; sourceTree = ""; }; + E0E99C382C0E80C200F9300C /* LoopCircleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopCircleView.swift; sourceTree = ""; }; + E9077D2624ACD59F0066A88D /* InformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InformationView.swift; sourceTree = ""; }; +@@ -2076,6 +2090,7 @@ + isa = PBXGroup; + children = ( + B4C004B2241085DB00B40429 /* ActionButton.swift */, ++ DD545A592B80EC9500915F95 /* Preferences Editors */, + 89AF78C524482268002B4FCC /* ActionButtonStyle.swift */, + C1E4B307242E995200E70CCB /* ActivityIndicator.swift */, + 89BE75CA2464BC2000B145D9 /* AlertContent.swift */, +@@ -2251,6 +2266,7 @@ + C187338229B9486200519CDF /* TimeInterval.swift */, + C187339629B9488300519CDF /* TimeZone.swift */, + C187339729B9488300519CDF /* UUID.swift */, ++ DD545A682B8BCF8E00915F95 /* Guardrail+Preferences.swift */, + 842E40282F22D8C6000CCCE0 /* Character+IsEmoji.swift */, + ); + path = Extensions; +@@ -2458,6 +2474,8 @@ + C1814B8B226371DF008D2D8E /* WeakSynchronizedDelegate.swift */, + 43CACE0D2247F89100F90AF5 /* WeakSynchronizedSet.swift */, + B4A2ABEE2AAB5160007E3EC1 /* Pluggable.swift */, ++ DD28B3702B80C074001044D4 /* PreferencesSetting.swift */, ++ DD545A642B814CA800915F95 /* PreferencesProvider.swift */, + 840A2F102C0F9882003D5E90 /* LoopCompletionFreshness.swift */, + ); + path = LoopKit; +@@ -2965,6 +2983,7 @@ + isa = PBXGroup; + children = ( + C10759A12E4E3F2900EFF99A /* Environment+FeatureFlags.swift */, ++ DD545A5E2B80EFA900915F95 /* PreferencesViewModel.swift */, + B455F3A325FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift */, + B4B7C1BB2604E3FC007379F6 /* CorrectionRangeScheduleEditorViewModel.swift */, + B4D4C20C25F95A8700DA809D /* DisplayGlucosePreference.swift */, +@@ -3084,6 +3103,16 @@ + path = Resources; + sourceTree = ""; + }; ++ DD545A592B80EC9500915F95 /* Preferences Editors */ = { ++ isa = PBXGroup; ++ children = ( ++ DD545A5A2B80ECB900915F95 /* PreferencesView.swift */, ++ DD13BC6F2C3C71A70062313B /* BasalLockEditor.swift */, ++ DD13BC712C3C74CD0062313B /* PreferencesGuardrailWarning.swift */, ++ ); ++ path = "Preferences Editors"; ++ sourceTree = ""; ++ }; + E9077D2824ACD5AA0066A88D /* Information Screens */ = { + isa = PBXGroup; + children = ( +@@ -3807,6 +3836,7 @@ + 842E404B2F22EAD8000CCCE0 /* EditPresetView.swift in Sources */, + A9D3FF0B2A6C198C000C891D /* CGPoint.swift in Sources */, + B429D66E24BF7255003E1B4A /* UIImage.swift in Sources */, ++ DD13BC722C3C74CD0062313B /* PreferencesGuardrailWarning.swift in Sources */, + B46B62A923FF05F8001E69BA /* LabeledNumberInput.swift in Sources */, + 842E40502F22ED57000CCCE0 /* ExistingPresetInsulinNeedsEdit.swift in Sources */, + 892155152245C57E009112BC /* SegmentedGaugeBarLayer.swift in Sources */, +@@ -3846,6 +3876,7 @@ + A9D3FF042A6C1944000C891D /* PredictedGlucoseChart.swift in Sources */, + C1DE4C2125A253BD007065F8 /* Color.swift in Sources */, + 898E6E702241EDB70019E459 /* PercentageTextFieldTableViewController.swift in Sources */, ++ DD13BC702C3C71A70062313B /* BasalLockEditor.swift in Sources */, + 89CAB36D24C9EC98009EE3CE /* Keyboard.swift in Sources */, + 89186C0724BF7FC70003D0F3 /* Guardrail+UI.swift in Sources */, + 84EE97B32D7A59AE00D5E941 /* ManualBolusDoseChartLayer.swift in Sources */, +@@ -3904,6 +3935,7 @@ + B46B62B323FF0E62001E69BA /* SelectableLabel.swift in Sources */, + 89FC6893245A2D680075CF59 /* ScheduleItemView.swift in Sources */, + 43BA716F201E49220058961E /* FoodEmojiDataSource.swift in Sources */, ++ DD545A5F2B80EFA900915F95 /* PreferencesViewModel.swift in Sources */, + E9077D2724ACD59F0066A88D /* InformationView.swift in Sources */, + E94141D024C8F31C0096C326 /* DeliveryLimitsEditor.swift in Sources */, + 1452F4BA2A85266500F8B9E4 /* CarbQuantityRow.swift in Sources */, +@@ -3973,6 +4005,7 @@ + 43BA717D201EE7090058961E /* GlucoseRangeTableViewCell.swift in Sources */, + B455F3A425FF7FF0000ED456 /* CorrectionRangeOverridesEditorViewModel.swift in Sources */, + A9D3FF002A6C1944000C891D /* ChartConstants.swift in Sources */, ++ DD545A5B2B80ECB900915F95 /* PreferencesView.swift in Sources */, + 89BE75CB2464BC2000B145D9 /* AlertContent.swift in Sources */, + 43BA7184201EE7090058961E /* TextFieldTableViewController.swift in Sources */, + 1452F4B22A8521CD00F8B9E4 /* TextFieldRow.swift in Sources */, +@@ -4065,6 +4098,7 @@ + 89AE222F228BC68000BDFD85 /* DoseProgressTimerEstimator.swift in Sources */, + 43D8FDF61C7290350073BE78 /* DailyQuantitySchedule.swift in Sources */, + 43D8FDF51C7290350073BE78 /* CarbRatioSchedule.swift in Sources */, ++ DD545A652B814CA800915F95 /* PreferencesProvider.swift in Sources */, + 89AE222C228BC66E00BDFD85 /* Locked.swift in Sources */, + 432CF87120D76D5A0066B889 /* GlucoseDisplayable.swift in Sources */, + A919889F2355016B00B75EEE /* DosingDecisionStore.swift in Sources */, +@@ -4130,7 +4164,9 @@ + 840A2DD32E399BD400D4E245 /* Modelv5ToModelv6.xcmappingmodel in Sources */, + 433BC7A720523DB7000B1200 /* NewGlucoseSample.swift in Sources */, + 4322B78E202FA2B30002837D /* InsulinMath.swift in Sources */, ++ DD28B3712B80C074001044D4 /* PreferencesSetting.swift in Sources */, + 840A2D822E39649500D4E245 /* ModelMappingv4v5Tov6Policy.swift in Sources */, ++ DD545A692B8BCF8E00915F95 /* Guardrail+Preferences.swift in Sources */, + C17F39D023CE34B100FA1113 /* StoredDeviceLogEntry.swift in Sources */, + 43B17C89208EEC0B00AC27E9 /* HealthStoreUnitCache.swift in Sources */, + A912BE29245B9CD500CBE199 /* SettingsObject+CoreDataClass.swift in Sources */, +diff --git a/LoopKit/LoopKit/Extensions/Guardrail+Preferences.swift b/LoopKit/LoopKit/Extensions/Guardrail+Preferences.swift +new file mode 100644 +index 00000000..c0fa0dd0 +--- /dev/null ++++ b/LoopKit/LoopKit/Extensions/Guardrail+Preferences.swift +@@ -0,0 +1,18 @@ ++// ++// Guardrail+Preferences.swift ++// LoopKit ++// ++// Created by Jonas Björkert on 2024-02-25. ++// Copyright © 2024 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import LoopAlgorithm ++ ++public extension Guardrail where Value == LoopQuantity { ++ static let basalLockThreshold = Guardrail( ++ absoluteBounds: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 200)...LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 300), ++ recommendedBounds: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 220)...LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 300), ++ startingSuggestion: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 250) ++ ) ++} +diff --git a/LoopKit/LoopKit/PreferencesProvider.swift b/LoopKit/LoopKit/PreferencesProvider.swift +new file mode 100644 +index 00000000..e3d5d21e +--- /dev/null ++++ b/LoopKit/LoopKit/PreferencesProvider.swift +@@ -0,0 +1,15 @@ ++// ++// PreferencesProvider.swift ++// LoopKit ++// ++// Created by Jonas Björkert on 2024-02-25. ++// Copyright © 2024 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import LoopAlgorithm ++ ++public protocol PreferencesProvider { ++ var basalLockThreshold: LoopQuantity { get set } ++ var isBasalLockEnabled: Bool { get set } ++} +diff --git a/LoopKit/LoopKit/PreferencesSetting.swift b/LoopKit/LoopKit/PreferencesSetting.swift +new file mode 100644 +index 00000000..3cdd9129 +--- /dev/null ++++ b/LoopKit/LoopKit/PreferencesSetting.swift +@@ -0,0 +1,60 @@ ++// ++// PreferencesSetting.swift ++// LoopKit ++// ++// Created by Jonas Björkert on 2024-02-25. ++// Copyright © 2024 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++ ++public enum PreferencesSetting { ++ case basalLock ++} ++ ++extension PreferencesSetting: Equatable { } ++ ++public extension PreferencesSetting { ++ var title: String { ++ switch self { ++ case .basalLock: ++ return LocalizedString("Basal Lock", comment: "Title text for basal lock setting") ++ } ++ } ++ ++ var smallTitle: String { ++ return title ++ } ++ ++ func descriptiveText(appName: String) -> String { ++ switch self { ++ case .basalLock: ++ return String(format: LocalizedString("Basal Lock prevents the basal rate from being throttled if the blood glucose is above a certain level.", comment: "Descriptive text for basal lock (1: app name)"), appName) ++ } ++ } ++} ++ ++// MARK: Guardrails ++public extension PreferencesSetting { ++ var guardrailCaptionForLowValue: String { ++ switch self { ++ case .basalLock: ++ return LocalizedString("The value you have entered is lower than what is typically recommended.", comment: "Descriptive text for guardrail low value warning for basal lock") ++ } ++ } ++ ++ var guardrailCaptionForHighValue: String { ++ switch self { ++ case .basalLock: ++ return LocalizedString("The value you have entered is higher than what is typically recommended.", comment: "Descriptive text for guardrail high value warning for basal lock") ++ } ++ } ++ ++ var guardrailCaptionForOutsideValues: String { ++ return LocalizedString("The value you have entered for Basal Lock is outside of the recommended range.", comment: "Descriptive text for guardrail outside value warning for basal lock") ++ } ++ ++ var guardrailSaveWarningCaption: String { ++ return LocalizedString("Please note that this value is outside of the recommended range.", comment: "Descriptive text for saving settings outside the recommended range for basal lock") ++ } ++} +diff --git a/LoopKit/LoopKitUI/ViewModels/PreferencesViewModel.swift b/LoopKit/LoopKitUI/ViewModels/PreferencesViewModel.swift +new file mode 100644 +index 00000000..03e08f9a +--- /dev/null ++++ b/LoopKit/LoopKitUI/ViewModels/PreferencesViewModel.swift +@@ -0,0 +1,34 @@ ++// ++// PreferencesViewModel.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2024-02-25. ++// Copyright © 2024 LoopKit Authors. All rights reserved. ++// ++ ++import Foundation ++import LoopKit ++import LoopAlgorithm ++ ++public class PreferencesViewModel: ObservableObject { ++ @Published var basalLockThreshold: LoopQuantity ++ @Published var isBasalLockEnabled: Bool ++ ++ private var preferencesProvider: PreferencesProvider ++ ++ public init(preferencesProvider: PreferencesProvider) { ++ self.preferencesProvider = preferencesProvider ++ self.basalLockThreshold = preferencesProvider.basalLockThreshold ++ self.isBasalLockEnabled = preferencesProvider.isBasalLockEnabled ++ } ++ ++ func updateBasalLockThreshold(_ newValue: LoopQuantity) { ++ preferencesProvider.basalLockThreshold = newValue ++ self.basalLockThreshold = newValue ++ } ++ ++ func updateBasalLockEnabled(_ newValue: Bool) { ++ preferencesProvider.isBasalLockEnabled = newValue ++ self.isBasalLockEnabled = newValue ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Preferences Editors/BasalLockEditor.swift b/LoopKit/LoopKitUI/Views/Preferences Editors/BasalLockEditor.swift +new file mode 100644 +index 00000000..c0df1d94 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Preferences Editors/BasalLockEditor.swift +@@ -0,0 +1,222 @@ ++// ++// BasalLockEditor.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2024-02-25. ++// Copyright © 2024 LoopKit Authors. All rights reserved. ++// ++ ++import SwiftUI ++import LoopKit ++import LoopAlgorithm ++ ++public struct BasalLockEditor: View { ++ @EnvironmentObject private var displayGlucosePreference: DisplayGlucosePreference ++ ++ @Environment(\.dismissAction) var dismiss ++ @Environment(\.authenticate) var authenticate ++ @Environment(\.appName) private var appName ++ ++ let viewModel: PreferencesViewModel ++ let didSave: (() -> Void)? ++ ++ @State private var userDidTap: Bool = false ++ @State private var isBasalLockEnabled: Bool ++ @State private var threshold: LoopQuantity ++ @State private var isEditing = false ++ @State private var showingConfirmationAlert = false ++ ++ private var initialValue: LoopQuantity { ++ viewModel.basalLockThreshold ++ } ++ ++ public init(preferencesViewModel: PreferencesViewModel, didSave: (() -> Void)? = nil) { ++ self.viewModel = preferencesViewModel ++ self._isBasalLockEnabled = State(initialValue: preferencesViewModel.isBasalLockEnabled) ++ self._threshold = State(initialValue: preferencesViewModel.basalLockThreshold) ++ self.didSave = didSave ++ } ++ ++ public var body: some View { ++ contentWithCancel ++ .navigationBarTitle("", displayMode: .inline) ++ } ++ ++ private var contentWithCancel: some View { ++ content ++ .navigationBarBackButtonHidden(isBasalLockEnabled != viewModel.isBasalLockEnabled || threshold != viewModel.basalLockThreshold) ++ .toolbar { ++ ToolbarItem(placement: .navigationBarLeading) { ++ leadingNavigationBarItem ++ } ++ } ++ } ++ ++ @ViewBuilder ++ private var leadingNavigationBarItem: some View { ++ if isBasalLockEnabled != viewModel.isBasalLockEnabled || threshold != viewModel.basalLockThreshold { ++ cancelButton ++ } else { ++ EmptyView() ++ } ++ } ++ ++ private var cancelButton: some View { ++ Button(action: { self.dismiss() }) { ++ Text(LocalizedString("Cancel", comment: "Cancel editing settings button title")) ++ } ++ } ++ ++ private var stride: Double { ++ switch displayGlucosePreference.unit { ++ case .milligramsPerDeciliter: ++ return 1 ++ case .millimolesPerLiter: ++ return 0.1 ++ default: ++ return 1 ++ } ++ } ++ ++ private var picker: GlucoseValuePicker { ++ let bounds = Guardrail.basalLockThreshold.absoluteBounds ++ let selectableValues = bounds.selectableValues(unit: displayGlucosePreference.unit, stride: stride) ++ return GlucoseValuePicker( ++ value: self.$threshold.animation(), ++ unit: displayGlucosePreference.unit, ++ guardrail: .basalLockThreshold, ++ selectableValues: selectableValues ++ ) ++ } ++ ++ private var content: some View { ++ ConfigurationPage( ++ title: Text(LocalizedString("Basal Lock Threshold", comment: "Title for basal lock threshold setting")), ++ actionButtonTitle: Text(LocalizedString("Save", comment: "Save button title")), ++ actionButtonState: saveButtonState, ++ cards: { ++ Card { ++ description ++ .font(.callout) ++ .foregroundColor(Color(.secondaryLabel)) ++ .fixedSize(horizontal: false, vertical: true) ++ ++ Toggle(isOn: $isBasalLockEnabled) { ++ Text("Enable Basal Lock") ++ } ++ .animation(.default, value: isBasalLockEnabled) ++ ++ ExpandableSetting( ++ isEditing: $isEditing, ++ valueContent: { ++ GuardrailConstrainedQuantityView( ++ value: threshold, ++ unit: displayGlucosePreference.unit, ++ guardrail: .basalLockThreshold, ++ isEditing: isEditing, ++ // Workaround for strange animation behavior on appearance ++ forceDisableAnimations: true ++ ) ++ }, ++ expandedContent: { ++ // Prevent the picker from expanding the card's width on small devices ++ picker.frame(maxWidth: 200) ++ } ++ ) ++ .transition(.slide) ++ } ++ }, ++ actionAreaContent: { ++ instructionalContentIfNecessary ++ if isBasalLockEnabled && warningThreshold != nil && userDidTap { ++ PreferencesGuardrailWarning(preferencesSetting: .basalLock, title: basalLockTitle, threshold: warningThreshold!) ++ .transition(.slide) ++ } ++ }, ++ action: { ++ if self.warningThreshold == nil { ++ self.startSaving() ++ } else { ++ self.showingConfirmationAlert = true ++ } ++ } ++ ) ++ .alert(isPresented: $showingConfirmationAlert, content: confirmationAlert) ++ .simultaneousGesture(TapGesture().onEnded { ++ withAnimation { ++ self.userDidTap = true ++ } ++ }) ++ } ++ ++ private var basalLockTitle: Text { ++ Text(LocalizedString("Basal Lock Threshold", comment: "Title for basal lock threshold setting")) ++ } ++ ++ private var description: Text { ++ Text(LocalizedString("Basal Lock prevents the basal rate from being throttled if the blood glucose is above a certain level.", comment: "Description for basal lock threshold setting")) ++ } ++ ++ private var instructionalContentIfNecessary: some View { ++ Group { ++ if !userDidTap { ++ instructionalContent ++ } ++ } ++ } ++ ++ private var instructionalContent: some View { ++ HStack { ++ Text(LocalizedString("You can edit the setting by tapping into the line item.", comment: "Description of how to edit setting")) ++ .foregroundColor(.secondary) ++ .font(.subheadline) ++ Spacer() ++ } ++ } ++ ++ private var saveButtonState: ConfigurationPageActionButtonState { ++ let selectableValues = picker.selectableValues ++ let adjustedBounds = (selectableValues.first!)...(selectableValues.last!) ++ guard adjustedBounds.contains(threshold.doubleValue(for: displayGlucosePreference.unit)) else { ++ return .disabled ++ } ++ return (isBasalLockEnabled != viewModel.isBasalLockEnabled || threshold != viewModel.basalLockThreshold) ? .enabled : .disabled ++ } ++ ++ private var warningThreshold: SafetyClassification.Threshold? { ++ switch Guardrail.basalLockThreshold.classification(for: threshold) { ++ case .withinRecommendedRange: ++ return nil ++ case .outsideRecommendedRange(let threshold): ++ return threshold ++ } ++ } ++ ++ private func confirmationAlert() -> SwiftUI.Alert { ++ SwiftUI.Alert( ++ title: Text(LocalizedString("Save Basal Lock Threshold?", comment: "Alert title for confirming a basal lock threshold outside the recommended range")), ++ message: Text(LocalizedString("Your setting for basal lock is outside the recommended range.", comment: "Descriptive text for saving settings outside the recommended range for basal lock")), ++ primaryButton: .cancel(Text(LocalizedString("Go Back", comment: "Text for go back action on confirmation alert"))), ++ secondaryButton: .default( ++ Text(LocalizedString("Continue", comment: "Text for continue action on confirmation alert")), ++ action: startSaving ++ ) ++ ) ++ } ++ ++ private func startSaving() { ++ authenticate(LocalizedString("Authentication is required to save this setting.", comment: "Authentication challenge description for basal lock threshold")) { ++ switch $0 { ++ case .success: self.continueSaving() ++ case .failure: break ++ } ++ } ++ } ++ ++ private func continueSaving() { ++ viewModel.updateBasalLockEnabled(self.isBasalLockEnabled) ++ viewModel.updateBasalLockThreshold(self.threshold) ++ didSave?() ++ self.dismiss() ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Preferences Editors/PreferencesGuardrailWarning.swift b/LoopKit/LoopKitUI/Views/Preferences Editors/PreferencesGuardrailWarning.swift +new file mode 100644 +index 00000000..32133be0 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Preferences Editors/PreferencesGuardrailWarning.swift +@@ -0,0 +1,90 @@ ++// ++// PreferencesGuardrailWarning.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2024-02-25. ++// Copyright © 2024 LoopKit Authors. All rights reserved. ++// ++ ++import SwiftUI ++import LoopKit ++ ++public struct PreferencesGuardrailWarning: View { ++ private enum CrossedThresholds { ++ case one(SafetyClassification.Threshold) ++ case oneOrMore([SafetyClassification.Threshold]) ++ } ++ ++ private var title: Text ++ private var crossedThresholds: CrossedThresholds ++ private var captionOverride: Text? ++ private var preferencesSetting: PreferencesSetting ++ ++ public init( ++ preferencesSetting: PreferencesSetting, ++ title: Text, ++ threshold: SafetyClassification.Threshold, ++ caption: Text? = nil ++ ) { ++ self.preferencesSetting = preferencesSetting ++ self.title = title ++ self.crossedThresholds = .one(threshold) ++ self.captionOverride = caption ++ } ++ ++ public init( ++ preferencesSetting: PreferencesSetting, ++ title: Text, ++ thresholds: [SafetyClassification.Threshold], ++ caption: Text? = nil ++ ) { ++ precondition(!thresholds.isEmpty) ++ self.preferencesSetting = preferencesSetting ++ self.title = title ++ self.crossedThresholds = .oneOrMore(thresholds) ++ self.captionOverride = caption ++ } ++ ++ public var body: some View { ++ WarningView(title: title, caption: caption, severity: severity) ++ } ++ ++ private var severity: WarningSeverity { ++ switch crossedThresholds { ++ case .one(let threshold): ++ return threshold.severity ++ case .oneOrMore(let thresholds): ++ return thresholds.lazy.map({ $0.severity }).max()! ++ } ++ } ++ ++ private var caption: Text { ++ if let caption = captionOverride { ++ return caption ++ } ++ ++ switch crossedThresholds { ++ case .one(let threshold): ++ return captionForThreshold(threshold) ++ case .oneOrMore(let thresholds): ++ if thresholds.count == 1, let threshold = thresholds.first { ++ return captionForThreshold(threshold) ++ } else { ++ return captionForThresholds() ++ } ++ } ++ } ++ ++ private func captionForThreshold(_ threshold: SafetyClassification.Threshold) -> Text { ++ switch threshold { ++ case .minimum, .belowRecommended, .belowWarning: ++ return Text(preferencesSetting.guardrailCaptionForLowValue) ++ case .aboveRecommended, .aboveWarning, .maximum: ++ return Text(preferencesSetting.guardrailCaptionForHighValue) ++ } ++ } ++ ++ private func captionForThresholds() -> Text { ++ return Text(preferencesSetting.guardrailCaptionForOutsideValues) ++ } ++} +diff --git a/LoopKit/LoopKitUI/Views/Preferences Editors/PreferencesView.swift b/LoopKit/LoopKitUI/Views/Preferences Editors/PreferencesView.swift +new file mode 100644 +index 00000000..350395d5 +--- /dev/null ++++ b/LoopKit/LoopKitUI/Views/Preferences Editors/PreferencesView.swift +@@ -0,0 +1,141 @@ ++// ++// PreferencesView.swift ++// LoopKitUI ++// ++// Created by Jonas Björkert on 2024-02-25. ++// Copyright © 2024 LoopKit Authors. All rights reserved. ++// ++ ++import SwiftUI ++import LoopKit ++import LoopAlgorithm ++ ++public struct PreferencesView: View { ++ @Environment(\.dismissAction) private var dismiss ++ @Environment(\.appName) private var appName ++ @EnvironmentObject var displayGlucosePreference: DisplayGlucosePreference ++ ++ @ObservedObject var viewModel: PreferencesViewModel ++ ++ public init(viewModel: PreferencesViewModel) { ++ self.viewModel = viewModel ++ } ++ ++ private static func createFormatter(for unit: LoopUnit) -> NumberFormatter { ++ let quantityFormatter = QuantityFormatter(for: unit) ++ return quantityFormatter.numberFormatter ++ } ++ ++ public var body: some View { ++ navigationViewWrappedContent ++ } ++ ++ private var formatter: NumberFormatter { ++ let quantityFormatter = QuantityFormatter(for: displayGlucosePreference.unit) ++ return quantityFormatter.numberFormatter ++ } ++} ++ ++extension PreferencesView { ++ private var navigationViewWrappedContent: some View { ++ NavigationView { ++ ZStack { ++ Color(.systemGroupedBackground) ++ .edgesIgnoringSafeArea(.all) ++ content ++ .toolbar { ++ ToolbarItem(placement: .navigationBarTrailing) { ++ dismissButton ++ } ++ } ++ .navigationBarTitle(preferencesTitle, displayMode: .large) ++ } ++ } ++ } ++ ++ private var dismissButton: some View { ++ Button(action: dismiss) { ++ Text("Done") ++ } ++ } ++ ++ private var preferencesTitle: String { ++ return "Preferences" ++ } ++ ++ private var content: some View { ++ CardList(title: nil, style: .sectioned(cardListSections)) //, trailer: cardListTrailer ++ } ++ ++ private var cardListSections: [CardListSection] { ++ var cardListSections: [CardListSection] = [] ++ ++ cardListSections.append(preferencesCardListSection) ++ ++ return cardListSections ++ } ++ ++ private var preferencesCardListSection: CardListSection { ++ CardListSection { ++ preferencesCardStack ++ .spacing(20) ++ } ++ } ++ ++ private var preferencesCardStack: CardStack { ++ var cards: [Card] = [] ++ ++ cards.append(basalLockSection) ++ ++ return CardStack(cards: cards) ++ } ++ ++ @ViewBuilder ++ func screen(for setting: PreferencesSetting, dismiss: @escaping () -> Void) -> some View { ++ switch setting { ++ case .basalLock: ++ BasalLockEditor(preferencesViewModel: viewModel, didSave: dismiss) ++ } ++ } ++ ++ private func card(for preferencesSetting: PreferencesSetting, @ViewBuilder content: @escaping () -> Content) -> Card where Content: View { ++ Card { ++ SectionWithTapToEdit( ++ isEnabled: true, ++ title: preferencesSetting.title, ++ descriptiveText: preferencesSetting.descriptiveText(appName: appName), ++ destination: { dismiss in ++ screen(for: preferencesSetting, dismiss: dismiss) ++ .environment(\.dismissAction, dismiss) ++ }, ++ content: content ++ ) ++ } ++ } ++ ++ private var basalLockSection: Card { ++ card(for: .basalLock) { ++ SectionDivider() ++ HStack { ++ Spacer() ++ if viewModel.isBasalLockEnabled { ++ HStack(alignment: .firstTextBaseline) { ++ Text(formatter.string(for: viewModel.basalLockThreshold.doubleValue(for: displayGlucosePreference.unit)) ?? "") ++ Text(displayGlucosePreference.unit.shortLocalizedUnitString()) ++ .foregroundColor(Color(.secondaryLabel)) ++ } ++ } else { ++ Text("Off") ++ .foregroundColor(Color(.secondaryLabel)) ++ } ++ } ++ } ++ } ++} ++ ++fileprivate struct SectionDivider: View { ++ var body: some View { ++ Divider() ++ .padding(.trailing, -16) ++ } ++} +diff --git a/basal_lock.loopcustomization b/basal_lock.loopcustomization +new file mode 100644 +index 0000000..e69de29 diff --git a/basal_lock/nextdev_basal_lock.patch b/basal_lock/nextdev_basal_lock.patch index 303f9de..93b616a 100644 --- a/basal_lock/nextdev_basal_lock.patch +++ b/basal_lock/nextdev_basal_lock.patch @@ -1,9 +1,9 @@ Submodule Loop contains modified content diff --git a/Loop/Loop.xcodeproj/project.pbxproj b/Loop/Loop.xcodeproj/project.pbxproj -index a2599fd2..cd43f345 100644 +index aaaeaf19..10258366 100644 --- a/Loop/Loop.xcodeproj/project.pbxproj +++ b/Loop/Loop.xcodeproj/project.pbxproj -@@ -603,6 +603,7 @@ +@@ -651,6 +651,7 @@ CB1AF8155B1761A30ACA5591 /* Loop/Resources/Sounds/synth.caf in Resources */ = {isa = PBXBuildFile; fileRef = 83081FFB0497A4596F597830 /* Loop/Resources/Sounds/synth.caf */; }; D6E28676C2C7751C77C7F2FB /* Loop/Resources/Sounds/spring.caf in Resources */ = {isa = PBXBuildFile; fileRef = 9471E156FC10D6D0FF219C77 /* Loop/Resources/Sounds/spring.caf */; }; DD3DBD292A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */; }; @@ -11,7 +11,7 @@ index a2599fd2..cd43f345 100644 A1B2C3D42E8F000100000002 /* GuardAndRunwaySelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D42E8F000100000001 /* GuardAndRunwaySelectionView.swift */; }; DDC389FC2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC389FB2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift */; }; DDC389FE2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */; }; -@@ -1417,6 +1418,7 @@ +@@ -1513,6 +1514,7 @@ C9SCENED0000000000000002 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; CE102E23D8C1C1291EF66387 /* PluginPickerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PluginPickerView.swift; sourceTree = ""; }; DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegralRetrospectiveCorrectionSelectionView.swift; sourceTree = ""; }; @@ -19,15 +19,15 @@ index a2599fd2..cd43f345 100644 A1B2C3D42E8F000100000001 /* GuardAndRunwaySelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GuardAndRunwaySelectionView.swift; sourceTree = ""; }; DDC389FB2A2BC6670066E2E8 /* SettingsView+algorithmExperimentsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SettingsView+algorithmExperimentsSection.swift"; sourceTree = ""; }; DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseBasedApplicationFactorSelectionView.swift; sourceTree = ""; }; -@@ -1734,6 +1736,7 @@ +@@ -1831,6 +1833,7 @@ C1E3862428247B7100F561A4 /* StoredLoopNotRunningNotification.swift */, 4328E0311CFC068900E199AA /* WatchContext+LoopKit.swift */, A987CD4824A58A0100439ADC /* ZipArchive.swift */, + DDC065132B65871E0033FD88 /* Preferences.swift */, C16F51182B891DB600EFD7A1 /* StoredDataAlgorithmInput.swift */, C4C16D18C8806FF48331134C /* InsulinDeliveryHistory.swift */, - C16F511A2B89363A00EFD7A1 /* SimpleInsulinDose.swift */, -@@ -3535,6 +3538,7 @@ + 12CD655083D4816F87B13463 /* SetupChange.swift */, +@@ -3719,6 +3722,7 @@ A1E47000000000000000A001 /* AlertHistoryView.swift in Sources */, C9A47B0380AAB1FC572D3F00 /* GlucoseAlertSettingsView.swift in Sources */, E9C00EF524C623EF00628F35 /* LoopSettings+Loop.swift in Sources */, @@ -36,10 +36,10 @@ index a2599fd2..cd43f345 100644 C178249A1E1999FA00D9D25C /* CaseCountable.swift in Sources */, C1F2CAAC2B7A980600D7F581 /* BasalRelativeDose.swift in Sources */, diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift -index 30ab8fb2..36695026 100644 +index 45c48734..b3e2f28c 100644 --- a/Loop/Loop/Managers/LoopDataManager.swift +++ b/Loop/Loop/Managers/LoopDataManager.swift -@@ -737,6 +737,16 @@ final class LoopDataManager: ObservableObject { +@@ -738,6 +738,16 @@ final class LoopDataManager: ObservableObject { let scheduledBasalRate = input.basal.closestPrior(to: loopBaseTime)!.value let activeOverride = temporaryPresetsManager.presetHistory.activeOverride(at: loopBaseTime) @@ -56,7 +56,7 @@ index 30ab8fb2..36695026 100644 let basalAdjustment = basal.adjustForCurrentDelivery( at: loopBaseTime, neutralBasalRate: scheduledBasalRate, -@@ -747,6 +757,9 @@ final class LoopDataManager: ObservableObject { +@@ -748,6 +758,9 @@ final class LoopDataManager: ObservableObject { if let basalAdjustment { recommendationToEnact.basalAdjustment = basalAdjustment @@ -123,7 +123,7 @@ index 00000000..ab5d00ec + } +} diff --git a/Loop/Loop/Views/SettingsView.swift b/Loop/Loop/Views/SettingsView.swift -index 1faefbc5..86b517fd 100644 +index e02112db..5dd5839e 100644 --- a/Loop/Loop/Views/SettingsView.swift +++ b/Loop/Loop/Views/SettingsView.swift @@ -62,6 +62,7 @@ struct SettingsView: View { @@ -131,10 +131,10 @@ index 1faefbc5..86b517fd 100644 @State private var sheet: Destination.Sheet? @State private var pluginSelection: (() -> Void)? + @State private var showingPreferences = false + @State private var setupChange: SetupChange? var localizedAppNameAndVersion: String - -@@ -93,6 +94,9 @@ struct SettingsView: View { +@@ -94,6 +95,9 @@ struct SettingsView: View { if FeatureFlags.allowExperimentalFeatures { favoriteFoodsSection } @@ -144,7 +144,7 @@ index 1faefbc5..86b517fd 100644 if (viewModel.pumpManagerSettingsViewModel.isTestingDevice || viewModel.cgmManagerSettingsViewModel.isTestingDevice) && viewModel.showDeleteTestData { deleteDataSection } -@@ -499,6 +503,21 @@ extension SettingsView { +@@ -546,6 +550,21 @@ extension SettingsView { descriptiveText: "Simplify Carb Entry") } } diff --git a/negative_insulin/archive/nextdev_negative_insulin_20261030.patch b/negative_insulin/archive/nextdev_negative_insulin_20261030.patch new file mode 100644 index 0000000..7d64e92 --- /dev/null +++ b/negative_insulin/archive/nextdev_negative_insulin_20261030.patch @@ -0,0 +1,461 @@ +Submodule Loop contains modified content +diff --git a/Loop/Loop.xcodeproj/project.pbxproj b/Loop/Loop.xcodeproj/project.pbxproj +index a2599fd2..c1413f2e 100644 +--- a/Loop/Loop.xcodeproj/project.pbxproj ++++ b/Loop/Loop.xcodeproj/project.pbxproj +@@ -594,6 +594,7 @@ + C1F8B243223E73FD00DD66CF /* BolusProgressTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F8B1D122375E4200DD66CF /* BolusProgressTableViewCell.swift */; }; + C1FAD5192E7E0C3400F7FAD9 /* ChartPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FAD5182E7E0C3100F7FAD9 /* ChartPageView.swift */; }; + C1FB428C217806A400FAB378 /* StateColorPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FB428B217806A300FAB378 /* StateColorPalette.swift */; }; ++ 120490CB2CBFB25A006BDF0A /* NegativeInsulinDamperSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 120490CA2CBFB25A006BDF0A /* NegativeInsulinDamperSelectionView.swift */; }; + C1FB428F217921D600FAB378 /* PumpManagerUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FB428E217921D600FAB378 /* PumpManagerUI.swift */; }; + C9A47B0180AAB1FC572D3EFE /* CriticalAlertAudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A47B0175B13A9543390BC5 /* CriticalAlertAudioPlayer.swift */; }; + C9A47B0280AAB1FC572D3EFF /* GlucoseAlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A47B0275B13A9543390BC6 /* GlucoseAlertManager.swift */; }; +@@ -1408,6 +1409,7 @@ + C1F8B1DB223862D500DD66CF /* BolusProgressTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BolusProgressTableViewCell.xib; sourceTree = ""; }; + C1FAD5182E7E0C3100F7FAD9 /* ChartPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartPageView.swift; sourceTree = ""; }; + C1FB428B217806A300FAB378 /* StateColorPalette.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StateColorPalette.swift; sourceTree = ""; }; ++ 120490CA2CBFB25A006BDF0A /* NegativeInsulinDamperSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NegativeInsulinDamperSelectionView.swift; sourceTree = ""; }; + C1FB428E217921D600FAB378 /* PumpManagerUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpManagerUI.swift; sourceTree = ""; }; + C9A47B0175B13A9543390BC5 /* CriticalAlertAudioPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CriticalAlertAudioPlayer.swift; sourceTree = ""; }; + C9A47B0275B13A9543390BC6 /* GlucoseAlertManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseAlertManager.swift; sourceTree = ""; }; +@@ -2075,6 +2077,7 @@ + C1AF062229426300002C1B19 /* ManualGlucoseEntryRow.swift */, + DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */, + DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */, ++ 120490CA2CBFB25A006BDF0A /* NegativeInsulinDamperSelectionView.swift */, + A1B2C3D42E8F000100000001 /* GuardAndRunwaySelectionView.swift */, + 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */, + 84D1F1A62D09053A00CB271F /* StatusTableView.swift */, +@@ -3540,6 +3543,7 @@ + C1F2CAAC2B7A980600D7F581 /* BasalRelativeDose.swift in Sources */, + B4F3D25124AF890C0095CE44 /* BluetoothStateManager.swift in Sources */, + 1DDE273D24AEA4B000796622 /* SettingsViewModel.swift in Sources */, ++ 120490CB2CBFB25A006BDF0A /* NegativeInsulinDamperSelectionView.swift in Sources */, + FEEDB0000000000000000001 /* StatisticsViewModel.swift in Sources */, + DD3DBD292A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift in Sources */, + A1B2C3D42E8F000100000002 /* GuardAndRunwaySelectionView.swift in Sources */, +diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift +index 45c48734..5f4f3d55 100644 +--- a/Loop/Loop/Managers/LoopDataManager.swift ++++ b/Loop/Loop/Managers/LoopDataManager.swift +@@ -359,6 +359,82 @@ final class LoopDataManager: ObservableObject { + } + } + ++ // MARK: Negative Insulin Damper (algorithm experiment) ++ ++ 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 ++ } ++ } ++ ++ /// Computes the Negative Insulin Damper coefficient in [0,1] for the given algorithm input, or nil. ++ /// Mirrors the dev/main computation: the predicted future rise attributable to negative insulin ++ /// (delivery below scheduled basal, up to 15 minutes ago) sets the damper strength. ++ private func computeNegativeInsulinDamper(for input: StoredDataAlgorithmInput) -> Double? { ++ guard let latestGlucose = input.glucoseHistory.last else { return nil } ++ let anchorDate = latestGlucose.startDate ++ let lastDoseStartDate = anchorDate.addingTimeInterval(.minutes(-15)) ++ ++ // Predicted insulin glucose-effect of everything delivered up to 15 minutes ago ++ // (doses starting later are dropped, basal-type doses trimmed at t-15, boluses kept whole). ++ let annotatedDoses = input.doses ++ .trimmed(to: lastDoseStartDate) ++ .annotated(with: input.basal) ++ ++ // Fail safe (disable the damper) rather than trip glucoseEffects' ISF-coverage ++ // preconditionFailure, which would crash automated dosing. ++ for dose in annotatedDoses { ++ guard let isf = input.sensitivity.closestPrior(to: dose.startDate), isf.endDate >= dose.startDate else { ++ return nil ++ } ++ } ++ ++ let effects = annotatedDoses.glucoseEffects(insulinSensitivityHistory: input.sensitivity, from: anchorDate.addingTimeInterval(.minutes(-5))) ++ ++ // Sum of positive 5-min deltas — the predicted future rise from negative insulin. ++ var posDeltaSum = 0.0 ++ for (offset, effect) in effects.enumerated() where offset > 0 { ++ let delta = effect.quantity.doubleValue(for: .milligramsPerDeciliter) - effects[offset - 1].quantity.doubleValue(for: .milligramsPerDeciliter) ++ posDeltaSum += max(0, delta) ++ } ++ ++ guard let isf = input.sensitivity.closestPrior(to: anchorDate)?.value, ++ let basalRate = input.basal.closestPrior(to: anchorDate)?.value else { ++ return nil ++ } ++ ++ // anchorScale is ~1 hour for rapid-acting adult, ~44 min for ultra-rapid insulins. ++ let anchorScale: Double ++ if let expModel = input.recommendationInsulinModel as? ExponentialInsulinModel { ++ anchorScale = 0.8 * expModel.peakActivityTime.hours ++ } else if let preset = input.recommendationInsulinModel as? ExponentialInsulinModelPreset { ++ anchorScale = 0.8 * preset.peakActivity.hours ++ } else { ++ anchorScale = 1.0 ++ } ++ ++ let marginalSlope = 0.05 ++ let anchorAlpha = 0.75 ++ // anchorPoint is unaffected by overrides (the basal and ISF multipliers cancel out). ++ let anchorPoint = anchorScale * basalRate * isf.doubleValue(for: .milligramsPerDeciliter) ++ ++ // A 0 U/hr basal segment would make anchorPoint 0 → NaN → constant 95% damping; disable instead. ++ guard anchorPoint > 0 else { return nil } ++ ++ let alpha = LoopDataManager.calculateNegativeInsulinDamperAlpha(anchorAlpha, anchorPoint, marginalSlope, posDeltaSum) ++ // alpha should never be less than marginalSlope ++ return max(0, 1 - max(marginalSlope, alpha)) ++ } ++ + func fetchData( + for baseTime: Date? = nil, + presumePresetEndingNow: Bool = false, +@@ -541,7 +617,7 @@ final class LoopDataManager: ObservableObject { + let experiments = enabledAlgorithmExperiments + let volatilityBaseline = experiments.contains(.guardAndRunway) ? await currentVolatilityBaseline(at: baseTime) : nil + +- return StoredDataAlgorithmInput( ++ var input = StoredDataAlgorithmInput( + glucoseHistory: glucose, + doses: dosesWithModel, + carbEntries: carbEntries, +@@ -561,6 +637,12 @@ final class LoopDataManager: ObservableObject { + automaticBolusApplicationFactor: nil, + experiments: experiments, + volatilityBaseline: volatilityBaseline) ++ ++ if UserDefaults.standard.negativeInsulinDamperEnabled { ++ input.negativeInsulinDamper = computeNegativeInsulinDamper(for: input) ++ } ++ ++ return input + } + + private var enabledAlgorithmExperiments: Set { +@@ -1325,7 +1407,7 @@ extension StoredDataAlgorithmInput { + return rval + } + +- func predictGlucose(effectsOptions: AlgorithmEffectsOptions = .all) throws -> [PredictedGlucoseValue] { ++ func predictGlucose(effectsOptions: AlgorithmEffectsOptions = .all, applyNegativeInsulinDamper: Bool = true) throws -> [PredictedGlucoseValue] { + let prediction = LoopAlgorithm.generatePrediction( + start: predictionStart, + glucoseHistory: glucoseHistory, +@@ -1338,7 +1420,8 @@ extension StoredDataAlgorithmInput { + useIntegralRetrospectiveCorrection: self.useIntegralRetrospectiveCorrection, + useMidAbsorptionISF: true, + carbAbsorptionModel: self.carbAbsorptionModel.model, +- experiments: experiments ++ experiments: experiments, ++ negativeInsulinDamper: applyNegativeInsulinDamper ? negativeInsulinDamper : nil + ) + return prediction.glucose + } +diff --git a/Loop/Loop/Models/PredictionInputEffect.swift b/Loop/Loop/Models/PredictionInputEffect.swift +index cf918321..e077ef29 100644 +--- a/Loop/Loop/Models/PredictionInputEffect.swift ++++ b/Loop/Loop/Models/PredictionInputEffect.swift +@@ -19,8 +19,9 @@ struct PredictionInputEffect: OptionSet { + static let retrospection = PredictionInputEffect(rawValue: 1 << 3) + static let suspend = PredictionInputEffect(rawValue: 1 << 4) + static let guardAndRunway = PredictionInputEffect(rawValue: 1 << 5) ++ static let damper = PredictionInputEffect(rawValue: 1 << 6) + +- static let all: PredictionInputEffect = [.carbs, .insulin, .momentum, .retrospection, .guardAndRunway] ++ static let all: PredictionInputEffect = [.carbs, .insulin, .damper, .momentum, .retrospection, .guardAndRunway] + + var localizedTitle: String? { + switch self { +@@ -28,6 +29,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]: +@@ -47,6 +50,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 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") + 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/Loop/Models/StoredDataAlgorithmInput.swift b/Loop/Loop/Models/StoredDataAlgorithmInput.swift +index 8d8441c4..693e2086 100644 +--- a/Loop/Loop/Models/StoredDataAlgorithmInput.swift ++++ b/Loop/Loop/Models/StoredDataAlgorithmInput.swift +@@ -60,4 +60,6 @@ struct StoredDataAlgorithmInput: AlgorithmInput { + var experiments: Set = [] + + var volatilityBaseline: Double? = nil ++ ++ var negativeInsulinDamper: Double? = nil + } +diff --git a/Loop/Loop/View Controllers/PredictionTableViewController.swift b/Loop/Loop/View Controllers/PredictionTableViewController.swift +index c3ddaa7a..98b9daa7 100644 +--- a/Loop/Loop/View Controllers/PredictionTableViewController.swift ++++ b/Loop/Loop/View Controllers/PredictionTableViewController.swift +@@ -79,6 +79,8 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable + + private var guardAndRunway: GuardAndRunwayOutput? + ++ private var negativeInsulinDamper: Double? ++ + private var refreshContext = RefreshContext.all + + private var chartStartDate: Date { +@@ -129,11 +131,12 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable + self.retrospectiveGlucoseDiscrepancies = algoOutput?.effects.retrospectiveGlucoseDiscrepancies + totalRetrospectiveCorrection = algoOutput?.effects.totalRetrospectiveCorrectionEffect + self.guardAndRunway = algoOutput?.guardAndRunway ++ self.negativeInsulinDamper = algoInput?.negativeInsulinDamper + + self.glucoseChart.setPredictedGlucoseValues(algoOutput?.predictedGlucose ?? []) + + do { +- var glucose = try algoInput?.predictGlucose(effectsOptions: self.selectedInputs.algorithmEffectOptions) ?? [] ++ var glucose = try self.alternatePrediction(for: algoInput) + if self.selectedInputs.contains(.guardAndRunway), let guardAndRunway = self.guardAndRunway { + glucose = guardAndRunway.guarded(glucose) + } +@@ -205,11 +208,37 @@ class PredictionTableViewController: LoopChartsTableViewController, Identifiable + + // Removed .suspend from this list; LoopAlgorithm needs updates to support this. Also review + // for better ways to support desired use cases. https://github.com/LoopKit/Loop/pull/2026 +- private lazy var availableInputs: [PredictionInputEffect] = [.carbs, .insulin, .momentum, .retrospection] ++ private lazy var availableInputs: [PredictionInputEffect] = [.carbs, .insulin] ++ + (UserDefaults.standard.negativeInsulinDamperEnabled ? [.damper] : []) ++ + [.momentum, .retrospection] + + (UserDefaults.standard.guardAndRunwayEnabled ? [.guardAndRunway] : []) + + private var selectedInputs = PredictionInputEffect.all + ++ /// Builds the "alternate" (user-selected-effects) prediction shown on this screen, including the ++ /// Negative Insulin Damper experiment's isolated-effect display when only that row is selected. ++ private func alternatePrediction(for algoInput: StoredDataAlgorithmInput?) throws -> [PredictedGlucoseValue] { ++ guard let algoInput else { return [] } ++ if selectedInputs == [.damper], let damper = algoInput.negativeInsulinDamper { ++ // Show the damper's isolated downward effect relative to the full (undamped) prediction. ++ let baseline = try algoInput.predictGlucose(effectsOptions: PredictionInputEffect.all.algorithmEffectOptions, applyNegativeInsulinDamper: false) ++ var damped = [PredictedGlucoseValue]() ++ var value = 0.0 ++ for (offset, element) in baseline.enumerated() { ++ if offset == 0 { ++ value = element.quantity.doubleValue(for: .milligramsPerDeciliter) ++ damped.append(element) ++ continue ++ } ++ let delta = element.quantity.doubleValue(for: .milligramsPerDeciliter) - baseline[offset - 1].quantity.doubleValue(for: .milligramsPerDeciliter) ++ if delta > 0 { value -= damper * delta } ++ damped.append(PredictedGlucoseValue(startDate: element.startDate, quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: value))) ++ } ++ return damped ++ } ++ return try algoInput.predictGlucose(effectsOptions: selectedInputs.algorithmEffectOptions, applyNegativeInsulinDamper: selectedInputs.contains(.damper)) ++ } ++ + override func numberOfSections(in tableView: UITableView) -> Int { + return Section.allCases.count + } +@@ -275,6 +304,19 @@ 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: NSNumber(value: 100 * negativeInsulinDamper)) ?? "?" ++ ) ++ subtitleText = String(format: "%@\n%@", subtitleText, damper) ++ } ++ + if input == .retrospection, + let lastDiscrepancy = retrospectiveGlucoseDiscrepancies?.last, + let currentGlucose = loopDataManager.latestGlucose +diff --git a/Loop/Loop/Views/NegativeInsulinDamperSelectionView.swift b/Loop/Loop/Views/NegativeInsulinDamperSelectionView.swift +new file mode 100644 +index 00000000..f1b35973 +--- /dev/null ++++ b/Loop/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/Loop/Views/SettingsView+algorithmExperimentsSection.swift b/Loop/Loop/Views/SettingsView+algorithmExperimentsSection.swift +index 5ff430b8..7ced73ac 100644 +--- a/Loop/Loop/Views/SettingsView+algorithmExperimentsSection.swift ++++ b/Loop/Loop/Views/SettingsView+algorithmExperimentsSection.swift +@@ -41,6 +41,7 @@ public struct ExperimentRow: View { + public struct ExperimentsSettingsView: View { + @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 + @AppStorage(UserDefaults.Key.GuardAndRunwayEnabled.rawValue) private var isGuardAndRunwayEnabled = false + var automaticDosingStrategy: AutomaticDosingStrategy + +@@ -76,6 +77,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() +@@ -89,6 +95,7 @@ extension UserDefaults { + fileprivate enum Key: String { + case GlucoseBasedApplicationFactorEnabled = "com.loopkit.algorithmExperiments.glucoseBasedApplicationFactorEnabled" + case IntegralRetrospectiveCorrectionEnabled = "com.loopkit.algorithmExperiments.integralRetrospectiveCorrectionEnabled" ++ case NegativeInsulinDamperEnabled = "com.loopkit.algorithmExperiments.negativeInsulinDamperEnabled" + case GuardAndRunwayEnabled = "com.loopkit.algorithmExperiments.guardAndRunwayEnabled" + } + +@@ -119,4 +126,13 @@ extension UserDefaults { + } + } + ++ var negativeInsulinDamperEnabled: Bool { ++ get { ++ bool(forKey: Key.NegativeInsulinDamperEnabled.rawValue) as Bool ++ } ++ set { ++ set(newValue, forKey: Key.NegativeInsulinDamperEnabled.rawValue) ++ } ++ } ++ + } +Submodule LoopAlgorithm contains modified content +diff --git a/LoopAlgorithm/Sources/LoopAlgorithm/AlgorithmInput.swift b/LoopAlgorithm/Sources/LoopAlgorithm/AlgorithmInput.swift +index 42c2ded..98b1f55 100644 +--- a/LoopAlgorithm/Sources/LoopAlgorithm/AlgorithmInput.swift ++++ b/LoopAlgorithm/Sources/LoopAlgorithm/AlgorithmInput.swift +@@ -43,10 +43,12 @@ public protocol AlgorithmInput { + /// When nil it is computed from `glucoseHistory` if that holds at least a day of readings; otherwise + /// the band runs at full depth. + var volatilityBaseline: Double? { get } ++ var negativeInsulinDamper: Double? { get } // Optional negative-insulin-damper coefficient in [0,1]; nil = disabled + } + + public extension AlgorithmInput { + var emulation: AlgorithmEmulationOptions? { nil } + var experiments: Set { [] } + var volatilityBaseline: Double? { nil } ++ var negativeInsulinDamper: Double? { nil } + } +diff --git a/LoopAlgorithm/Sources/LoopAlgorithm/LoopAlgorithm.swift b/LoopAlgorithm/Sources/LoopAlgorithm/LoopAlgorithm.swift +index 378f0e5..4d76877 100644 +--- a/LoopAlgorithm/Sources/LoopAlgorithm/LoopAlgorithm.swift ++++ b/LoopAlgorithm/Sources/LoopAlgorithm/LoopAlgorithm.swift +@@ -185,7 +185,8 @@ public struct LoopAlgorithm { + gradualTransitionsThreshold: Double? = 40.0, + momentumVelocityMaximum: LoopQuantity? = nil, + emulation: AlgorithmEmulationOptions = AlgorithmEmulationOptions(), +- experiments: Set = [] ++ experiments: Set = [], ++ negativeInsulinDamper: Double? = nil + ) -> LoopPrediction where CarbType: CarbEntry, GlucoseType: GlucoseSampleValue, InsulinDoseType: InsulinDose { + + var prediction: [PredictedGlucoseValue] = [] +@@ -359,6 +360,26 @@ public struct LoopAlgorithm { + effects: effects + ) + ++ // Negative Insulin Damper: multiply positive prediction deltas by (1 - damper), ++ // leaving negative deltas untouched. The coefficient is computed by the app ++ // (LoopDataManager) and is nil (no-op) unless the experiment is enabled. ++ if let damper = negativeInsulinDamper { ++ let alpha = 1 - damper ++ var dampedPrediction = [PredictedGlucoseValue]() ++ var value = 0.0 ++ for (offset, element) in prediction.enumerated() { ++ if offset == 0 { ++ value = element.quantity.doubleValue(for: .milligramsPerDeciliter) ++ dampedPrediction.append(element) ++ continue ++ } ++ let delta = element.quantity.doubleValue(for: .milligramsPerDeciliter) - prediction[offset - 1].quantity.doubleValue(for: .milligramsPerDeciliter) ++ value += delta > 0 ? alpha * delta : delta ++ dampedPrediction.append(PredictedGlucoseValue(startDate: element.startDate, quantity: LoopQuantity(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. + let finalDate = start.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration) +@@ -815,7 +836,8 @@ public struct LoopAlgorithm { + carbAbsorptionModel: input.carbAbsorptionModel.model, + gradualTransitionsThreshold: input.gradualTransitionsThreshold, + emulation: input.emulation ?? AlgorithmEmulationOptions(), +- experiments: input.experiments ++ experiments: input.experiments, ++ negativeInsulinDamper: input.negativeInsulinDamper + ) + + // Experiment adjustments before dosing (no-ops when `experiments` is empty). The runway reserve +diff --git a/negative_insulin.loopcustomization b/negative_insulin.loopcustomization +new file mode 100644 +index 0000000..e69de29 diff --git a/negative_insulin/nextdev_negative_insulin.patch b/negative_insulin/nextdev_negative_insulin.patch index 7d64e92..18f46a4 100644 --- a/negative_insulin/nextdev_negative_insulin.patch +++ b/negative_insulin/nextdev_negative_insulin.patch @@ -1,9 +1,9 @@ Submodule Loop contains modified content diff --git a/Loop/Loop.xcodeproj/project.pbxproj b/Loop/Loop.xcodeproj/project.pbxproj -index a2599fd2..c1413f2e 100644 +index aaaeaf19..30651e1c 100644 --- a/Loop/Loop.xcodeproj/project.pbxproj +++ b/Loop/Loop.xcodeproj/project.pbxproj -@@ -594,6 +594,7 @@ +@@ -642,6 +642,7 @@ C1F8B243223E73FD00DD66CF /* BolusProgressTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F8B1D122375E4200DD66CF /* BolusProgressTableViewCell.swift */; }; C1FAD5192E7E0C3400F7FAD9 /* ChartPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FAD5182E7E0C3100F7FAD9 /* ChartPageView.swift */; }; C1FB428C217806A400FAB378 /* StateColorPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FB428B217806A300FAB378 /* StateColorPalette.swift */; }; @@ -11,7 +11,7 @@ index a2599fd2..c1413f2e 100644 C1FB428F217921D600FAB378 /* PumpManagerUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1FB428E217921D600FAB378 /* PumpManagerUI.swift */; }; C9A47B0180AAB1FC572D3EFE /* CriticalAlertAudioPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A47B0175B13A9543390BC5 /* CriticalAlertAudioPlayer.swift */; }; C9A47B0280AAB1FC572D3EFF /* GlucoseAlertManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A47B0275B13A9543390BC6 /* GlucoseAlertManager.swift */; }; -@@ -1408,6 +1409,7 @@ +@@ -1464,6 +1465,7 @@ C1F8B1DB223862D500DD66CF /* BolusProgressTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = BolusProgressTableViewCell.xib; sourceTree = ""; }; C1FAD5182E7E0C3100F7FAD9 /* ChartPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartPageView.swift; sourceTree = ""; }; C1FB428B217806A300FAB378 /* StateColorPalette.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StateColorPalette.swift; sourceTree = ""; }; @@ -19,7 +19,7 @@ index a2599fd2..c1413f2e 100644 C1FB428E217921D600FAB378 /* PumpManagerUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpManagerUI.swift; sourceTree = ""; }; C9A47B0175B13A9543390BC5 /* CriticalAlertAudioPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CriticalAlertAudioPlayer.swift; sourceTree = ""; }; C9A47B0275B13A9543390BC6 /* GlucoseAlertManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GlucoseAlertManager.swift; sourceTree = ""; }; -@@ -2075,6 +2077,7 @@ +@@ -2176,6 +2178,7 @@ C1AF062229426300002C1B19 /* ManualGlucoseEntryRow.swift */, DDC389FD2A2C4C830066E2E8 /* GlucoseBasedApplicationFactorSelectionView.swift */, DD3DBD282A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift */, @@ -27,14 +27,14 @@ index a2599fd2..c1413f2e 100644 A1B2C3D42E8F000100000001 /* GuardAndRunwaySelectionView.swift */, 84DEB10C2C18FABA00170734 /* IOSFocusModesView.swift */, 84D1F1A62D09053A00CB271F /* StatusTableView.swift */, -@@ -3540,6 +3543,7 @@ +@@ -3724,6 +3727,7 @@ C1F2CAAC2B7A980600D7F581 /* BasalRelativeDose.swift in Sources */, B4F3D25124AF890C0095CE44 /* BluetoothStateManager.swift in Sources */, 1DDE273D24AEA4B000796622 /* SettingsViewModel.swift in Sources */, + 120490CB2CBFB25A006BDF0A /* NegativeInsulinDamperSelectionView.swift in Sources */, FEEDB0000000000000000001 /* StatisticsViewModel.swift in Sources */, + 414E7082E6A6B9DA8CD11EF3 /* CurrentSetupViewModel.swift in Sources */, DD3DBD292A33AFE9000F8B5B /* IntegralRetrospectiveCorrectionSelectionView.swift in Sources */, - A1B2C3D42E8F000100000002 /* GuardAndRunwaySelectionView.swift in Sources */, diff --git a/Loop/Loop/Managers/LoopDataManager.swift b/Loop/Loop/Managers/LoopDataManager.swift index 45c48734..5f4f3d55 100644 --- a/Loop/Loop/Managers/LoopDataManager.swift @@ -342,7 +342,7 @@ index 00000000..f1b35973 + } +} diff --git a/Loop/Loop/Views/SettingsView+algorithmExperimentsSection.swift b/Loop/Loop/Views/SettingsView+algorithmExperimentsSection.swift -index 5ff430b8..7ced73ac 100644 +index 770e703a..278e09a0 100644 --- a/Loop/Loop/Views/SettingsView+algorithmExperimentsSection.swift +++ b/Loop/Loop/Views/SettingsView+algorithmExperimentsSection.swift @@ -41,6 +41,7 @@ public struct ExperimentRow: View { @@ -362,10 +362,10 @@ index 5ff430b8..7ced73ac 100644 + name: NSLocalizedString("Negative Insulin Damper", comment: "Title of negative insulin damper experiment"), + enabled: isNegativeInsulinDamperEnabled) + } - Spacer() - } - .padding() -@@ -89,6 +95,7 @@ extension UserDefaults { + Text(NSLocalizedString("About 2 weeks with the same setup is a good amount of time to see a change in outcomes such as time in range and lows. Over just a few days, ordinary day-to-day variation can hide or exaggerate the effect.", comment: "Suggested length of time to try an algorithm experiment setup.")) + .font(.footnote) + .foregroundColor(.secondary) +@@ -93,6 +99,7 @@ extension UserDefaults { fileprivate enum Key: String { case GlucoseBasedApplicationFactorEnabled = "com.loopkit.algorithmExperiments.glucoseBasedApplicationFactorEnabled" case IntegralRetrospectiveCorrectionEnabled = "com.loopkit.algorithmExperiments.integralRetrospectiveCorrectionEnabled" @@ -373,7 +373,7 @@ index 5ff430b8..7ced73ac 100644 case GuardAndRunwayEnabled = "com.loopkit.algorithmExperiments.guardAndRunwayEnabled" } -@@ -119,4 +126,13 @@ extension UserDefaults { +@@ -123,4 +130,13 @@ extension UserDefaults { } }