Browse Source

Merge branch 'dev' into fix-negative-iob-after-onboarding

Sam King 2 months ago
parent
commit
800b771aa0

+ 1 - 1
Config.xcconfig

@@ -19,7 +19,7 @@ TRIO_APP_GROUP_ID = group.org.nightscout.$(DEVELOPMENT_TEAM).trio.trio-app-group
 
 // The developers set the version numbers, please leave them alone
 APP_VERSION = 0.6.0
-APP_DEV_VERSION = 0.6.0.57
+APP_DEV_VERSION = 0.6.0.59
 APP_BUILD_NUMBER = 1
 COPYRIGHT_NOTICE =
 

+ 2 - 1
Model/Classes+Properties/GlucoseStored+CoreDataProperties.swift

@@ -11,9 +11,10 @@ public extension GlucoseStored {
     @NSManaged var glucose: Int16
     @NSManaged var id: UUID?
     @NSManaged var isManual: Bool
-    @NSManaged var isUploadedToNS: Bool
     @NSManaged var isUploadedToHealth: Bool
+    @NSManaged var isUploadedToNS: Bool
     @NSManaged var isUploadedToTidepool: Bool
+    @NSManaged var smoothedGlucose: NSDecimalNumber?
 }
 
 extension GlucoseStored: Identifiable {}

+ 2 - 1
Model/TrioCoreDataPersistentContainer.xcdatamodeld/TrioCoreDataPersistentContainer.xcdatamodel/contents

@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" standalone="yes"?>
-<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="24512" systemVersion="25B78" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
+<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23788.4" systemVersion="25B78" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
     <entity name="BolusStored" representedClassName="BolusStored" syncable="YES">
         <attribute name="amount" optional="YES" attributeType="Decimal" defaultValueString="0"/>
         <attribute name="isExternal" optional="YES" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
@@ -78,6 +78,7 @@
         <attribute name="isUploadedToHealth" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
         <attribute name="isUploadedToNS" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
         <attribute name="isUploadedToTidepool" optional="YES" attributeType="Boolean" usesScalarValueType="YES"/>
+        <attribute name="smoothedGlucose" optional="YES" attributeType="Decimal" defaultValueString="0.0"/>
         <fetchIndex name="byDate">
             <fetchIndexElement property="date" type="Binary" order="ascending"/>
         </fetchIndex>

+ 28 - 12
Trio.xcodeproj/project.pbxproj

@@ -504,7 +504,6 @@
 		CE95BF622BA7715900DC3DE3 /* MockKitUI.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3818AA4F274C26A300843DB3 /* MockKitUI.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
 		CE95BF632BA771BE00DC3DE3 /* LoopTestingKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3818AA70274C278200843DB3 /* LoopTestingKit.framework */; };
 		CE95BF642BA771BE00DC3DE3 /* LoopTestingKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3818AA70274C278200843DB3 /* LoopTestingKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
-		CEA4F62329BE10F70011ADF7 /* SavitzkyGolayFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEA4F62229BE10F70011ADF7 /* SavitzkyGolayFilter.swift */; };
 		CEB434E328B8F9DB00B70274 /* BluetoothStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB434E228B8F9DB00B70274 /* BluetoothStateManager.swift */; };
 		CEB434E528B8FF5D00B70274 /* UIColor.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB434E428B8FF5D00B70274 /* UIColor.swift */; };
 		CEB434E728B9053300B70274 /* LoopUIColorPalette+Default.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB434E628B9053300B70274 /* LoopUIColorPalette+Default.swift */; };
@@ -624,6 +623,7 @@
 		DD9ECB712CA9A0BA00AA7C45 /* RemoteControlConfigProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB6E2CA9A0BA00AA7C45 /* RemoteControlConfigProvider.swift */; };
 		DD9ECB722CA9A0BA00AA7C45 /* RemoteControlConfigDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB6F2CA9A0BA00AA7C45 /* RemoteControlConfigDataFlow.swift */; };
 		DD9ECB742CA9A0C300AA7C45 /* RemoteControlConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB732CA9A0C300AA7C45 /* RemoteControlConfig.swift */; };
+		DDA40BBA2F4DB18800257798 /* AlgorithmGlucose.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA40BB92F4DB18100257798 /* AlgorithmGlucose.swift */; };
 		DDA6E2502D22187500C2988C /* ChartLegendView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA6E24F2D22187500C2988C /* ChartLegendView.swift */; };
 		DDA6E2852D2361F800C2988C /* LoopStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA6E2842D2361F800C2988C /* LoopStatusView.swift */; };
 		DDA6E3202D258E0500C2988C /* OverrideHelpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA6E31F2D258E0500C2988C /* OverrideHelpView.swift */; };
@@ -653,6 +653,10 @@
 		DDD78A912DC4064800AC63F3 /* carbhistory.json in Resources */ = {isa = PBXBuildFile; fileRef = DDD78A902DC4064800AC63F3 /* carbhistory.json */; };
 		DDD78AD92DC421B500AC63F3 /* enacted.json in Resources */ = {isa = PBXBuildFile; fileRef = DDD78AD72DC421B500AC63F3 /* enacted.json */; };
 		DDD78ADA2DC421B500AC63F3 /* suggested.json in Resources */ = {isa = PBXBuildFile; fileRef = DDD78AD82DC421B500AC63F3 /* suggested.json */; };
+		DDD7C8C12F4DB45400E5CF09 /* GlucoseStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD7C8BF2F4DB45400E5CF09 /* GlucoseStored+CoreDataClass.swift */; };
+		DDD7C8C22F4DB45400E5CF09 /* GlucoseStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD7C8C02F4DB45400E5CF09 /* GlucoseStored+CoreDataProperties.swift */; };
+		DDDD0FFB2F4E22C000F9C645 /* GlucoseSmoothingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */; };
+		DDDD0FFF2F4E231B00F9C645 /* MockTDDStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDD0FFE2F4E231B00F9C645 /* MockTDDStorage.swift */; };
 		DDE179522C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179322C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift */; };
 		DDE179532C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179332C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift */; };
 		DDE179542C910127003CDDB7 /* LoopStatRecord+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179342C910127003CDDB7 /* LoopStatRecord+CoreDataClass.swift */; };
@@ -669,8 +673,6 @@
 		DDE179612C910127003CDDB7 /* StatsData+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179412C910127003CDDB7 /* StatsData+CoreDataProperties.swift */; };
 		DDE179622C910127003CDDB7 /* Forecast+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179422C910127003CDDB7 /* Forecast+CoreDataClass.swift */; };
 		DDE179632C910127003CDDB7 /* Forecast+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179432C910127003CDDB7 /* Forecast+CoreDataProperties.swift */; };
-		DDE179642C910127003CDDB7 /* GlucoseStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179442C910127003CDDB7 /* GlucoseStored+CoreDataClass.swift */; };
-		DDE179652C910127003CDDB7 /* GlucoseStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179452C910127003CDDB7 /* GlucoseStored+CoreDataProperties.swift */; };
 		DDE179662C910127003CDDB7 /* OpenAPS_Battery+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179462C910127003CDDB7 /* OpenAPS_Battery+CoreDataClass.swift */; };
 		DDE179672C910127003CDDB7 /* OpenAPS_Battery+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179472C910127003CDDB7 /* OpenAPS_Battery+CoreDataProperties.swift */; };
 		DDE179682C910127003CDDB7 /* TempBasalStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179482C910127003CDDB7 /* TempBasalStored+CoreDataClass.swift */; };
@@ -1337,7 +1339,6 @@
 		CE95BF4A2BA5CED700DC3DE3 /* LoopKitUI.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LoopKitUI.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		CE95BF562BA5F5FE00DC3DE3 /* PluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginManager.swift; sourceTree = "<group>"; };
 		CE95BF592BA62E4A00DC3DE3 /* PluginSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginSource.swift; sourceTree = "<group>"; };
-		CEA4F62229BE10F70011ADF7 /* SavitzkyGolayFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavitzkyGolayFilter.swift; sourceTree = "<group>"; };
 		CEB434DB28B8F5B900B70274 /* MKRingProgressView.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MKRingProgressView.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		CEB434DE28B8F5C400B70274 /* OmniBLE.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = OmniBLE.framework; sourceTree = BUILT_PRODUCTS_DIR; };
 		CEB434E228B8F9DB00B70274 /* BluetoothStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothStateManager.swift; sourceTree = "<group>"; };
@@ -1459,6 +1460,7 @@
 		DD9ECB6E2CA9A0BA00AA7C45 /* RemoteControlConfigProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteControlConfigProvider.swift; sourceTree = "<group>"; };
 		DD9ECB6F2CA9A0BA00AA7C45 /* RemoteControlConfigDataFlow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteControlConfigDataFlow.swift; sourceTree = "<group>"; };
 		DD9ECB732CA9A0C300AA7C45 /* RemoteControlConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteControlConfig.swift; sourceTree = "<group>"; };
+		DDA40BB92F4DB18100257798 /* AlgorithmGlucose.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlgorithmGlucose.swift; sourceTree = "<group>"; };
 		DDA6E24F2D22187500C2988C /* ChartLegendView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartLegendView.swift; sourceTree = "<group>"; };
 		DDA6E2842D2361F800C2988C /* LoopStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoopStatusView.swift; sourceTree = "<group>"; };
 		DDA6E31F2D258E0500C2988C /* OverrideHelpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideHelpView.swift; sourceTree = "<group>"; };
@@ -1491,6 +1493,10 @@
 		DDD78A902DC4064800AC63F3 /* carbhistory.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = carbhistory.json; sourceTree = "<group>"; };
 		DDD78AD72DC421B500AC63F3 /* enacted.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = enacted.json; sourceTree = "<group>"; };
 		DDD78AD82DC421B500AC63F3 /* suggested.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = suggested.json; sourceTree = "<group>"; };
+		DDD7C8BF2F4DB45400E5CF09 /* GlucoseStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GlucoseStored+CoreDataClass.swift"; sourceTree = "<group>"; };
+		DDD7C8C02F4DB45400E5CF09 /* GlucoseStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GlucoseStored+CoreDataProperties.swift"; sourceTree = "<group>"; };
+		DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSmoothingTests.swift; sourceTree = "<group>"; };
+		DDDD0FFE2F4E231B00F9C645 /* MockTDDStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTDDStorage.swift; sourceTree = "<group>"; };
 		DDE179322C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MealPresetStored+CoreDataClass.swift"; sourceTree = "<group>"; };
 		DDE179332C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MealPresetStored+CoreDataProperties.swift"; sourceTree = "<group>"; };
 		DDE179342C910127003CDDB7 /* LoopStatRecord+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LoopStatRecord+CoreDataClass.swift"; sourceTree = "<group>"; };
@@ -1507,8 +1513,6 @@
 		DDE179412C910127003CDDB7 /* StatsData+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StatsData+CoreDataProperties.swift"; sourceTree = "<group>"; };
 		DDE179422C910127003CDDB7 /* Forecast+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Forecast+CoreDataClass.swift"; sourceTree = "<group>"; };
 		DDE179432C910127003CDDB7 /* Forecast+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Forecast+CoreDataProperties.swift"; sourceTree = "<group>"; };
-		DDE179442C910127003CDDB7 /* GlucoseStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GlucoseStored+CoreDataClass.swift"; sourceTree = "<group>"; };
-		DDE179452C910127003CDDB7 /* GlucoseStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GlucoseStored+CoreDataProperties.swift"; sourceTree = "<group>"; };
 		DDE179462C910127003CDDB7 /* OpenAPS_Battery+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OpenAPS_Battery+CoreDataClass.swift"; sourceTree = "<group>"; };
 		DDE179472C910127003CDDB7 /* OpenAPS_Battery+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OpenAPS_Battery+CoreDataProperties.swift"; sourceTree = "<group>"; };
 		DDE179482C910127003CDDB7 /* TempBasalStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TempBasalStored+CoreDataClass.swift"; sourceTree = "<group>"; };
@@ -2371,6 +2375,7 @@
 		388E5A5925B6F0250019842D /* Models */ = {
 			isa = PBXGroup;
 			children = (
+				DDA40BB92F4DB18100257798 /* AlgorithmGlucose.swift */,
 				3E62C7812F54CC1600433237 /* BolusDisplayThreshold.swift */,
 				DD3D60302F0377350021A33B /* ExportSetting.swift */,
 				DDFF204F2DB2C11900AB8A96 /* WatchStateSnapshot.swift */,
@@ -2466,7 +2471,6 @@
 				3811DEE325CA063400A708ED /* PropertyWrappers */,
 				3811DE5525C9D4D500A708ED /* Publisher.swift */,
 				DD6B7CB12C7B6F0800B75029 /* Rounding.swift */,
-				CEA4F62229BE10F70011ADF7 /* SavitzkyGolayFilter.swift */,
 				38E98A3625F5509500C0CED0 /* String+Extensions.swift */,
 				49239B422EEA27AD00469145 /* TempTargetCalculations.swift */,
 				DDCAE8322D78D49C00B1BB51 /* TherapySettingsUtil.swift */,
@@ -2611,6 +2615,8 @@
 		38FCF3EE25E9028E0078B0D1 /* TrioTests */ = {
 			isa = PBXGroup;
 			children = (
+				DDDD0FFD2F4E231600F9C645 /* Mocks */,
+				DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */,
 				DDC6CA6C2DD90A2A0060EE25 /* LocalizationTests.swift */,
 				3B997DD22DC02AEF006B6BB2 /* JSONImporterData */,
 				BD8FC05C2D6618BE00B95AED /* BolusCalculatorTests */,
@@ -3580,9 +3586,19 @@
 			path = View;
 			sourceTree = "<group>";
 		};
+		DDDD0FFD2F4E231600F9C645 /* Mocks */ = {
+			isa = PBXGroup;
+			children = (
+				DDDD0FFE2F4E231B00F9C645 /* MockTDDStorage.swift */,
+			);
+			path = Mocks;
+			sourceTree = "<group>";
+		};
 		DDE179112C9100FA003CDDB7 /* Classes+Properties */ = {
 			isa = PBXGroup;
 			children = (
+				DDD7C8BF2F4DB45400E5CF09 /* GlucoseStored+CoreDataClass.swift */,
+				DDD7C8C02F4DB45400E5CF09 /* GlucoseStored+CoreDataProperties.swift */,
 				BD4D738B2D15A4080052227B /* TDDStored+CoreDataClass.swift */,
 				BD4D738C2D15A4080052227B /* TDDStored+CoreDataProperties.swift */,
 				DDE179362C910127003CDDB7 /* BolusStored+CoreDataClass.swift */,
@@ -3597,8 +3613,6 @@
 				DDE179432C910127003CDDB7 /* Forecast+CoreDataProperties.swift */,
 				DDE179382C910127003CDDB7 /* ForecastValue+CoreDataClass.swift */,
 				DDE179392C910127003CDDB7 /* ForecastValue+CoreDataProperties.swift */,
-				DDE179442C910127003CDDB7 /* GlucoseStored+CoreDataClass.swift */,
-				DDE179452C910127003CDDB7 /* GlucoseStored+CoreDataProperties.swift */,
 				DDE179342C910127003CDDB7 /* LoopStatRecord+CoreDataClass.swift */,
 				DDE179352C910127003CDDB7 /* LoopStatRecord+CoreDataProperties.swift */,
 				DDE179322C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift */,
@@ -4150,6 +4164,7 @@
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				DDA40BBA2F4DB18800257798 /* AlgorithmGlucose.swift in Sources */,
 				DD5DC9F12CF3D97C00AB8703 /* AdjustmentsStateModel+Overrides.swift in Sources */,
 				3811DE2325C9D48300A708ED /* MainDataFlow.swift in Sources */,
 				C2A0A42F2CE03131003B98E8 /* ConstantValues.swift in Sources */,
@@ -4178,7 +4193,6 @@
 				DD98ACC02D71013200C0778F /* StatChartUtils.swift in Sources */,
 				BD47FDD92D8B657D0043966B /* InsulinSensitivityStepView.swift in Sources */,
 				3862CC2E2743F9F700BF832C /* CalendarManager.swift in Sources */,
-				CEA4F62329BE10F70011ADF7 /* SavitzkyGolayFilter.swift in Sources */,
 				38B4F3C325E2A20B00E76A18 /* PumpSetupView.swift in Sources */,
 				38E4453C274E411700EC9A94 /* Disk+Codable.swift in Sources */,
 				58D08B322C8DF88900AA37D3 /* DummyCharts.swift in Sources */,
@@ -4520,6 +4534,8 @@
 				FA630397F76B582C8D8681A7 /* BasalProfileEditorProvider.swift in Sources */,
 				DD1745172C54389F00211FAC /* FeatureSettingsView.swift in Sources */,
 				DD3D60312F0377350021A33B /* ExportSetting.swift in Sources */,
+				DDD7C8C12F4DB45400E5CF09 /* GlucoseStored+CoreDataClass.swift in Sources */,
+				DDD7C8C22F4DB45400E5CF09 /* GlucoseStored+CoreDataProperties.swift in Sources */,
 				DD9ECB712CA9A0BA00AA7C45 /* RemoteControlConfigProvider.swift in Sources */,
 				63E890B4D951EAA91C071D5C /* BasalProfileEditorStateModel.swift in Sources */,
 				38FEF3FA2737E42000574A46 /* BaseStateModel.swift in Sources */,
@@ -4715,8 +4731,6 @@
 				DDE179612C910127003CDDB7 /* StatsData+CoreDataProperties.swift in Sources */,
 				DDE179622C910127003CDDB7 /* Forecast+CoreDataClass.swift in Sources */,
 				DDE179632C910127003CDDB7 /* Forecast+CoreDataProperties.swift in Sources */,
-				DDE179642C910127003CDDB7 /* GlucoseStored+CoreDataClass.swift in Sources */,
-				DDE179652C910127003CDDB7 /* GlucoseStored+CoreDataProperties.swift in Sources */,
 				BDC531142D10611D00088832 /* AddContactImageSheet.swift in Sources */,
 				DDE179662C910127003CDDB7 /* OpenAPS_Battery+CoreDataClass.swift in Sources */,
 				DDE179672C910127003CDDB7 /* OpenAPS_Battery+CoreDataProperties.swift in Sources */,
@@ -4751,12 +4765,14 @@
 				BD8FC0572D66188700B95AED /* PumpHistoryStorageTests.swift in Sources */,
 				BD8FC0642D6619EF00B95AED /* TempTargetStorageTests.swift in Sources */,
 				BD8FC0542D66186000B95AED /* TestError.swift in Sources */,
+				DDDD0FFB2F4E22C000F9C645 /* GlucoseSmoothingTests.swift in Sources */,
 				CEE9A65E2BBC9F6500EB5194 /* CalibrationsTests.swift in Sources */,
 				BD8FC0622D6619E600B95AED /* OverrideStorageTests.swift in Sources */,
 				BD8FC0592D66189700B95AED /* TestAssembly.swift in Sources */,
 				DDC6CA6D2DD90A2A0060EE25 /* LocalizationTests.swift in Sources */,
 				3B997DCF2DC00A3A006B6BB2 /* JSONImporterTests.swift in Sources */,
 				BD8FC0662D661A0000B95AED /* GlucoseStorageTests.swift in Sources */,
+				DDDD0FFF2F4E231B00F9C645 /* MockTDDStorage.swift in Sources */,
 				BD8FC05B2D6618AF00B95AED /* DeterminationStorageTests.swift in Sources */,
 				3BAAE60C2DE7766C0049589B /* DynamicISFEnableTests.swift in Sources */,
 				CE1F6DD92BADF4620064EB8D /* PluginManagerTests.swift in Sources */,

+ 0 - 12
Trio/Resources/InfoPlist.xcstrings

@@ -457,18 +457,6 @@
         }
       }
     },
-    "NSCalendarsFullAccessUsageDescription" : {
-      "comment" : "Privacy - Calendars Full Access Usage Description",
-      "extractionState" : "extracted_with_value",
-      "localizations" : {
-        "en" : {
-          "stringUnit" : {
-            "state" : "new",
-            "value" : "To create events with BG reading values, so that they can be viewed on Apple Watch and CarPlay"
-          }
-        }
-      }
-    },
     "NSCalendarsUsageDescription" : {
       "comment" : "Privacy - Calendars Usage Description",
       "extractionState" : "extracted_with_value",

+ 7 - 2
Trio/Sources/APS/APSManager.swift

@@ -405,7 +405,7 @@ final class BaseAPSManager: APSManager, Injectable {
         guard let autosense = await storage.retrieveAsync(OpenAPS.Settings.autosense, as: Autosens.self),
               (autosense.timestamp ?? .distantPast).addingTimeInterval(30.minutes.timeInterval) > Date()
         else {
-            let result = try await openAPS.autosense()
+            let result = try await openAPS.autosense(shouldSmoothGlucose: settingsManager.settings.smoothGlucose)
             return result != nil
         }
 
@@ -476,7 +476,11 @@ final class BaseAPSManager: APSManager, Injectable {
 
             _ = try await autosenseResult
             try await openAPS.createProfiles()
-            let determination = try await openAPS.determineBasal(currentTemp: await currentTemp, clock: now)
+            let determination = try await openAPS.determineBasal(
+                currentTemp: await currentTemp,
+                shouldSmoothGlucose: settingsManager.settings.smoothGlucose,
+                clock: now
+            )
             iobFileDidUpdate.send(())
 
             guard isValidGlucoseData else {
@@ -520,6 +524,7 @@ final class BaseAPSManager: APSManager, Injectable {
             let temp = try await fetchCurrentTempBasal(date: Date.now)
             return try await openAPS.determineBasal(
                 currentTemp: temp,
+                shouldSmoothGlucose: settingsManager.settings.smoothGlucose,
                 clock: Date(),
                 simulatedCarbsAmount: simulatedCarbsAmount,
                 simulatedBolusAmount: simulatedBolusAmount,

+ 212 - 40
Trio/Sources/APS/FetchGlucoseManager.swift

@@ -1,4 +1,5 @@
 import Combine
+import CoreData
 import Foundation
 import HealthKit
 import LoopKit
@@ -29,6 +30,7 @@ extension FetchGlucoseManager {
 final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
     private let processQueue = DispatchQueue(label: "BaseGlucoseManager.processQueue")
 
+    @Injected() var broadcaster: Broadcaster!
     @Injected() var glucoseStorage: GlucoseStorage!
     @Injected() var nightscoutManager: NightscoutManager!
     @Injected() var tidepoolService: TidepoolManager!
@@ -66,6 +68,8 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         return cgmManager.shouldSyncToRemoteService
     }
 
+    var shouldSmoothGlucose: Bool = false
+
     init(resolver: Resolver) {
         injectServices(resolver)
         // init at the start of the app
@@ -76,6 +80,7 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
             cgmGlucoseSourceType: settingsManager.settings.cgm,
             cgmGlucosePluginId: settingsManager.settings.cgmPluginIdentifier
         )
+        shouldSmoothGlucose = settingsManager.settings.smoothGlucose
         subscribe()
     }
 
@@ -117,6 +122,8 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
             .store(in: &lifetime)
         timer.fire()
         timer.resume()
+
+        broadcaster.register(SettingsObserver.self, observer: self)
     }
 
     /// Store new glucose readings from the CGM manager
@@ -197,7 +204,6 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         if let manager = newManager {
             cgmManager = manager
             removeCalibrations()
-//            glucoseSource = nil
         } else if self.cgmGlucoseSourceType == .plugin, cgmManager == nil, let rawCGMManager = rawCGMManager {
             cgmManager = cgmManagerFromRawValue(rawCGMManager)
             updateManagerUnits(cgmManager)
@@ -234,38 +240,35 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         return Manager.init(rawState: rawState)
     }
 
-    private func fetchGlucose() async throws -> [GlucoseStored]? {
-        try await CoreDataStack.shared.fetchEntitiesAsync(
+    func fetchGlucose(context: NSManagedObjectContext) async throws -> [NSManagedObjectID] {
+        // Compound predicate: time window + non-manual + valid date
+        let timePredicate = NSPredicate.predicateForOneDayAgoInMinutes
+        let manualPredicate = NSPredicate(format: "isManual == NO")
+        let datePredicate = NSPredicate(format: "date != nil")
+
+        let compoundPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
+            timePredicate,
+            manualPredicate,
+            datePredicate
+        ])
+
+        let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             onContext: context,
-            predicate: NSPredicate.predicateFor30MinAgo,
+            // Predicate must cover at least the full glucose horizon used by downstream algorithm consumers.
+            // If autosens / oref / smoothing logic ever starts looking back further (e.g. 36h),
+            // this fetch window must be expanded accordingly.
+            predicate: compoundPredicate,
             key: "date",
-            ascending: false,
-            fetchLimit: 6
-        ) as? [GlucoseStored]
-    }
-
-    private func processGlucose() async throws -> [BloodGlucose] {
-        let results = try await fetchGlucose()
+            ascending: true, // the first element is the oldest
+            fetchLimit: 350
+        )
 
-        return try await context.perform {
-            guard let results else {
-                throw CoreDataError.fetchError(function: #function, file: #file)
-            }
-            return results.map { result in
-                BloodGlucose(
-                    sgv: Int(result.glucose),
-                    direction: BloodGlucose.Direction(from: result.direction ?? ""),
-                    date: Decimal(result.date?.timeIntervalSince1970 ?? Date().timeIntervalSince1970) * 1000,
-                    dateString: result.date ?? Date(),
-                    unfiltered: Decimal(result.glucose),
-                    filtered: Decimal(result.glucose),
-                    noise: nil,
-                    glucose: Int(result.glucose),
-                    type: "sgv"
-                )
-            }
+        guard let glucoseArray = results as? [GlucoseStored] else {
+            throw CoreDataError.fetchError(function: #function, file: #file)
         }
+
+        return glucoseArray.map(\.objectID)
     }
 
     private func glucoseStoreAndHeartDecision(syncDate: Date, glucose: [BloodGlucose]) async throws {
@@ -303,21 +306,12 @@ final class BaseFetchGlucoseManager: FetchGlucoseManager, Injectable {
         }
         debug(.deviceManager, "New glucose found")
 
-        // filter the data if it is the case
-        if settingsManager.settings.smoothGlucose {
-            // limited to 30 min of old glucose data
-            let oldGlucoseValues = try await processGlucose()
+        try await glucoseStorage.storeGlucose(filtered)
 
-            var smoothedValues = oldGlucoseValues + filtered
-            // smooth with 3 repeats
-            for _ in 1 ... 3 {
-                smoothedValues.smoothSavitzkyGolayQuaDratic(withFilterWidth: 3)
-            }
-            // find the new values only
-            filtered = smoothedValues.filter { $0.dateString > syncDate }
+        if settingsManager.settings.smoothGlucose {
+            await exponentialSmoothingGlucose(context: context)
         }
 
-        try await glucoseStorage.storeGlucose(filtered)
         deviceDataManager.heartbeat(date: Date())
 
         endBackgroundTaskSafely(&backgroundTaskID, taskName: "Glucose Store and Heartbeat Decision")
@@ -377,3 +371,181 @@ extension CGMManager {
         ]
     }
 }
+
+extension BaseFetchGlucoseManager: SettingsObserver {
+    /// Smooth glucose data when smoothing is turned on.
+    func settingsDidChange(_: TrioSettings) {
+        let smoothingWasEnabled = shouldSmoothGlucose
+        let smoothingIsEnabled = settingsManager.settings.smoothGlucose
+        shouldSmoothGlucose = smoothingIsEnabled
+
+        guard smoothingIsEnabled, !smoothingWasEnabled else { return }
+
+        processQueue.async { [weak self] in
+            guard let self else { return }
+
+            self.glucoseStoreAndHeartLock.wait()
+            Task {
+                await self.exponentialSmoothingGlucose(context: self.context)
+                self.glucoseStoreAndHeartLock.signal()
+            }
+        }
+    }
+}
+
+extension BaseFetchGlucoseManager {
+    /// CoreData-friendly AAPS exponential smoothing + storage.
+    /// - Important: Only stores `smoothedGlucose`. UI/alerts should still use `glucose`.
+    ///
+    func exponentialSmoothingGlucose(context: NSManagedObjectContext) async {
+        let startTime = Date()
+
+        do {
+            // get objectIDs
+            let objectIDs = try await fetchGlucose(context: context)
+
+            try await context.perform {
+                // Load managed objects from object IDs
+                // Filtering (isManual, date) already done at DB level in fetchGlucose
+                let glucoseReadings = objectIDs.compactMap {
+                    context.object(with: $0) as? GlucoseStored
+                }
+
+                guard !glucoseReadings.isEmpty else { return }
+
+                // Static method call to avoid self-capture
+                Self.applyExponentialSmoothingAndStore(
+                    glucoseReadings: glucoseReadings,
+                    minimumWindowSize: 4,
+                    maximumAllowedGapMinutes: 12,
+                    xDripErrorGlucose: 38,
+                    minimumSmoothedGlucose: 39,
+                    firstOrderWeight: 0.4,
+                    firstOrderAlpha: 0.5,
+                    secondOrderAlpha: 0.4,
+                    secondOrderBeta: 1.0
+                )
+
+                try context.save()
+            }
+
+            let duration = Date().timeIntervalSince(startTime)
+            debugPrint(String(format: "Exponential smoothing duration: %0.04fs", duration))
+        } catch {
+            debug(.deviceManager, "Failed to smooth glucose: \(error)")
+        }
+    }
+
+    private static func applyExponentialSmoothingAndStore(
+        glucoseReadings data: [GlucoseStored],
+        minimumWindowSize: Int,
+        maximumAllowedGapMinutes: Int,
+        xDripErrorGlucose: Int,
+        minimumSmoothedGlucose: Decimal,
+        firstOrderWeight: Decimal,
+        firstOrderAlpha: Decimal,
+        secondOrderAlpha: Decimal,
+        secondOrderBeta: Decimal
+    ) {
+        guard !data.isEmpty else { return }
+
+        // Determine the size of the valid most-recent smoothing window.
+        // We walk adjacent pairs from newest -> oldest to preserve the same window semantics
+        // as the original implementation, but avoid manual reverse indexing.
+        var validWindowCount = max(data.count - 1, 0)
+
+        for (recentOffset, pair) in zip(data.dropFirst().reversed(), data.dropLast().reversed()).enumerated() {
+            let (newer, older) = pair
+
+            guard let newerDate = newer.date, let olderDate = older.date else { continue }
+
+            let gapSeconds = newerDate.timeIntervalSince(olderDate)
+            let gapMinutesRounded = Int((gapSeconds / 60.0).rounded())
+
+            if gapMinutesRounded >= maximumAllowedGapMinutes {
+                validWindowCount = recentOffset + 1 // include the more recent reading
+                break
+            }
+
+            // Ported from AAPS: 38 mg/dL may represent an xDrip error state.
+            if Int(newer.glucose) == xDripErrorGlucose {
+                validWindowCount = recentOffset // exclude this 38 value
+                break
+            }
+        }
+
+        // If insufficient valid readings: copy raw into smoothed (clamped) for all passed entries.
+        guard validWindowCount >= minimumWindowSize else {
+            for object in data {
+                let raw = Decimal(Int(object.glucose))
+                object.smoothedGlucose = max(raw, minimumSmoothedGlucose) as NSDecimalNumber
+            }
+            return
+        }
+
+        // Restrict smoothing to the valid most-recent window, still in chronological order.
+        let validWindow = data.suffix(validWindowCount)
+
+        guard let oldest = validWindow.first else { return }
+
+        // ---- 1st order smoothing ----
+        var firstOrderSmoothed: [Decimal] = []
+        firstOrderSmoothed.reserveCapacity(validWindow.count)
+
+        var firstOrderCurrent = Decimal(Int(oldest.glucose))
+        firstOrderSmoothed.append(firstOrderCurrent)
+
+        for sample in validWindow.dropFirst() {
+            let raw = Decimal(Int(sample.glucose))
+            firstOrderCurrent = firstOrderCurrent + firstOrderAlpha * (raw - firstOrderCurrent)
+            firstOrderSmoothed.append(firstOrderCurrent)
+        }
+
+        // ---- 2nd order smoothing ----
+        let secondOrderInput = Array(validWindow)
+        guard secondOrderInput.count >= 2 else { return }
+
+        var secondOrderSmoothed: [Decimal] = []
+        secondOrderSmoothed.reserveCapacity(secondOrderInput.count)
+
+        var secondOrderDeltas: [Decimal] = []
+        secondOrderDeltas.reserveCapacity(secondOrderInput.count)
+
+        var previousSecondOrderSmoothed = Decimal(Int(secondOrderInput[0].glucose))
+        var previousSecondOrderDelta =
+            Decimal(Int(secondOrderInput[1].glucose) - Int(secondOrderInput[0].glucose))
+
+        secondOrderSmoothed.append(previousSecondOrderSmoothed)
+        secondOrderDeltas.append(previousSecondOrderDelta)
+
+        for sample in secondOrderInput.dropFirst() {
+            let raw = Decimal(Int(sample.glucose))
+
+            let nextSmoothed =
+                secondOrderAlpha * raw
+                    + (1 - secondOrderAlpha) * (previousSecondOrderSmoothed + previousSecondOrderDelta)
+
+            let nextDelta =
+                secondOrderBeta * (nextSmoothed - previousSecondOrderSmoothed)
+                    + (1 - secondOrderBeta) * previousSecondOrderDelta
+
+            previousSecondOrderSmoothed = nextSmoothed
+            previousSecondOrderDelta = nextDelta
+
+            secondOrderSmoothed.append(nextSmoothed)
+            secondOrderDeltas.append(nextDelta)
+        }
+
+        // ---- Weighted blend ----
+        let blended = zip(firstOrderSmoothed, secondOrderSmoothed).map { firstOrder, secondOrder in
+            firstOrderWeight * firstOrder + (1 - firstOrderWeight) * secondOrder
+        }
+
+        // Apply to the most recent valid-window readings.
+        for (object, blendedValue) in zip(validWindow, blended) {
+            let rounded = blendedValue.rounded(toPlaces: 0) // nearest integer, ties away from zero
+            let clamped = max(rounded, minimumSmoothedGlucose)
+            object.smoothedGlucose = clamped as NSDecimalNumber
+        }
+    }
+}

+ 39 - 7
Trio/Sources/APS/OpenAPS/OpenAPS.swift

@@ -98,7 +98,12 @@ final class OpenAPS {
     }
 
     // fetch glucose to pass it to the meal function and to determine basal
-    private func fetchAndProcessGlucose(fetchLimit: Int?) async throws -> String {
+    func fetchAndProcessGlucose(
+        context: NSManagedObjectContext,
+        shouldSmoothGlucose: Bool,
+        fetchLimit: Int?
+    ) async throws -> String {
+        // make it async and await it
         let results = try await CoreDataStack.shared.fetchEntitiesAsync(
             ofType: GlucoseStored.self,
             onContext: context,
@@ -109,14 +114,40 @@ final class OpenAPS {
             batchSize: 48
         )
 
-        return try await context.perform {
+        // mapping within the context closure, JSON conversion outside
+        let algorithmGlucose = try await context.perform {
             guard let glucoseResults = results as? [GlucoseStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
 
-            // convert to JSON
-            return self.jsonConverter.convertToJSON(glucoseResults)
+            // extracting handler to only create it 1x
+            let roundingBehavior = NSDecimalNumberHandler(
+                roundingMode: .plain,
+                scale: 0,
+                raiseOnExactness: false,
+                raiseOnOverflow: false,
+                raiseOnUnderflow: false,
+                raiseOnDivideByZero: false
+            )
+
+            return glucoseResults.map { glucose -> AlgorithmGlucose in
+                let glucoseValue: Int16
+                if shouldSmoothGlucose, !glucose.isManual, let smoothedGlucose = glucose.smoothedGlucose {
+                    glucoseValue = smoothedGlucose.rounding(accordingToBehavior: roundingBehavior).int16Value
+                } else {
+                    glucoseValue = glucose.glucose
+                }
+                return AlgorithmGlucose(
+                    date: glucose.date,
+                    direction: glucose.direction,
+                    glucose: glucoseValue,
+                    id: glucose.id,
+                    isManual: glucose.isManual
+                )
+            }
         }
+
+        return jsonConverter.convertToJSON(algorithmGlucose)
     }
 
     private func fetchAndProcessCarbs(additionalCarbs: Decimal? = nil, carbsDate: Date? = nil) async throws -> String {
@@ -342,6 +373,7 @@ final class OpenAPS {
 
     func determineBasal(
         currentTemp: TempBasal,
+        shouldSmoothGlucose: Bool,
         clock: Date = Date(),
         simulatedCarbsAmount: Decimal? = nil,
         simulatedBolusAmount: Decimal? = nil,
@@ -356,7 +388,7 @@ final class OpenAPS {
         // Perform asynchronous calls in parallel
         async let pumpHistoryObjectIDs = fetchPumpHistoryObjectIDs() ?? []
         async let carbs = fetchAndProcessCarbs(additionalCarbs: simulatedCarbsAmount ?? 0, carbsDate: simulatedCarbsDate)
-        async let glucose = fetchAndProcessGlucose(fetchLimit: 72)
+        async let glucose = fetchAndProcessGlucose(context: context, shouldSmoothGlucose: shouldSmoothGlucose, fetchLimit: 72)
         async let prepareTrioCustomOrefVariables = prepareTrioCustomOrefVariables()
         async let profileAsync = loadFileFromStorageAsync(name: Settings.profile)
         async let basalAsync = loadFileFromStorageAsync(name: Settings.basalProfile)
@@ -525,13 +557,13 @@ final class OpenAPS {
         }
     }
 
-    func autosense() async throws -> Autosens? {
+    func autosense(shouldSmoothGlucose: Bool) async throws -> Autosens? {
         debug(.openAPS, "Start autosens")
 
         // Perform asynchronous calls in parallel
         async let pumpHistoryObjectIDs = fetchPumpHistoryObjectIDs() ?? []
         async let carbs = fetchAndProcessCarbs()
-        async let glucose = fetchAndProcessGlucose(fetchLimit: nil)
+        async let glucose = fetchAndProcessGlucose(context: context, shouldSmoothGlucose: shouldSmoothGlucose, fetchLimit: nil)
         async let getProfile = loadFileFromStorageAsync(name: Settings.profile)
         async let getBasalProfile = loadFileFromStorageAsync(name: Settings.basalProfile)
         async let getTempTargets = loadFileFromStorageAsync(name: Settings.tempTargets)

+ 0 - 172
Trio/Sources/Helpers/SavitzkyGolayFilter.swift

@@ -1,172 +0,0 @@
-import Foundation
-
-/// allowed values are 0, 1, 2 or 3. It's the index in coefficients
-private var coefficientsRowToUse = 3
-
-/// Savitzky Golay coefficients
-private let coefficients = [
-    [-3.0, 12.0, 17.0, 12.0, -3.0],
-    [-2.0, 3.0, 6.0, 7.0, 6.0, 3.0, -2.0],
-    [-21.0, 14.0, 39.0, 54.0, 59.0, 54.0, 39.0, 14.0, -21.0],
-    [-36.0, 9.0, 44.0, 69.0, 84.0, 89.0, 84.0, 69.0, 44.0, 9.0, -36.0]
-]
-
-/// an array with elements of a type that conforms to Smoothable, can be filtered using  the Savitzky Golay algorithm
-protocol SavitzkyGolaySmoothable {
-    /// value to be smoothed
-    var value: Double { get set }
-}
-
-/// local help class
-private class IsSmoothable: SavitzkyGolaySmoothable {
-    var value: Double = 0.0
-
-    init(withValue value: Double = 0.0) {
-        self.value = value
-    }
-}
-
-extension Array where Element: SavitzkyGolaySmoothable {
-    /// - apply Savitzky Golay filter
-    /// - before applying the filter, the array will be prepended and append with a number of elements equal to the filterwidth, filterWidth default 5. Allowed values are 5, 4, 3, 2. If any other value is assigned, then 5 will be used
-    /// - ...continue with 5 here in the explanation ...
-    /// - for the 5 last elements and 5 first elements, a regression is done. This regression is done used to give values to the 5 prepended and appended values. Which means it's as if we draw a line through the first 5 and 5 last original values, and use this line to give values to the 5 prepended and appended values
-    /// - the 5 prepended and appended values are then used in the filter algorithm, which means we can also filter the original 5 first and last elements
-    /// see also example https://github.com/JohanDegraeve/xdripswift/wiki/Libre-value-smoothing
-    mutating func smoothSavitzkyGolayQuaDratic(withFilterWidth filterWidth: Int = 5) {
-        // filterWidthToUse is the value of filterWidth to use in the algorithm. By default filterWidthToUse = parameter value filterWidth
-        var filterWidthToUse = filterWidth
-
-        // calculate coefficientsRowToUse based on filterWdith
-        switch filterWidth {
-        case 5:
-            coefficientsRowToUse = 3
-
-        case 4:
-            coefficientsRowToUse = 2
-
-        case 3:
-            coefficientsRowToUse = 1
-
-        case 2:
-            coefficientsRowToUse = 0
-
-        default:
-            // invalid filterWidth was given in parameterList, use default value
-            coefficientsRowToUse = 3
-
-            filterWidthToUse = 5
-        }
-
-        // using 5 here in the comments as value for filterWidthToUse
-
-        // the amount of elements must be at least 5. If that's not the case then don't apply any smoothing
-        guard count >= filterWidthToUse else { return }
-
-        // create a new array, to which we will prepend and append 5 elements so that we can do also smoothing for the 5 last and 5 first values of the input array (which is self)
-        // the 5 elements will be estimated by doing linear regression of the first 5 and last 5 elements of the original input array respectively
-        // this is only a temporary array, but it will hold the elements of the original array, those elements will get a new value when doing the smoothing
-        var tempArray = [SavitzkyGolaySmoothable]()
-        for element in self {
-            tempArray.append(element)
-        }
-
-        // now prepend and append with 5 elements, each with a default value 0.0
-        for _ in 0 ..< filterWidthToUse {
-            tempArray.insert(IsSmoothable(), at: 0)
-            tempArray.append(IsSmoothable())
-        }
-
-        // so now we have tempArray, of length size of original array + 2 * 5
-        // the first 5 and the last 5 elements are of type IsSmoothable with value 0
-
-        // - indicesArray is a help array needed for the function linearRegressionCreator
-        // - this will be the first parameter in the call to the linearRegression function, in fact it's an array of IsSmoothable with length = length of tempArray
-        // - we give each IsSmoothable the value of the index, meaning from 0 up to (length of tempArray) - 1
-        // - in fact it's not really smoothable, it's just because we use isSmoothable in function linearRegressionCreator
-        var indicesArray = [SavitzkyGolaySmoothable]()
-        for index in 0 ..< (count + (filterWidthToUse * 2)) {
-            indicesArray.append(IsSmoothable(withValue: Double(index)))
-        }
-
-        /// - this is a piece of code that we will execute two times, once for the firs 5 elements, then for the last 5, so we put it in a closure variable
-        /// - it calculates the regression function (which is nothing else but doing y = intercept + slope*x) for range defined by predictorRange in tempArray. It will be used for the 5 first and 5 last real values, ie the 5 first and 5 last real glucose values
-        /// - then executes the regression for every element in the range defined by targetRange, again in tempArray
-        let doRegression = { (predictorRange: Range<Int>, targetRange: Range<Int>) in
-
-            // calculate the linearRegression function
-            let linearRegression = linearRegressionCreator(indicesArray[predictorRange], tempArray[predictorRange])
-
-            // ready to do the linear regression for the targetRange in tempArray
-            for index in targetRange {
-                tempArray[index].value = linearRegression(indicesArray[index].value)
-            }
-        }
-
-        // now do the regression for the 5 first elements
-        doRegression(filterWidthToUse ..< (filterWidthToUse * 2), 0 ..< filterWidthToUse)
-
-        // now do the regression for the 5 last elements
-        doRegression(
-            (tempArray.count - filterWidthToUse * 2) ..< (tempArray.count - filterWidthToUse),
-            (tempArray.count - filterWidthToUse) ..< tempArray.count
-        )
-
-        // now start filtering
-
-        // initialize array that will hold the resulting filtered values
-        var filteredValues = [Double]()
-
-        // calculate divider
-        let divider = coefficients[coefficientsRowToUse].reduce(0, { x, y in
-            x + y
-        })
-
-        // filter each original value
-        for _ in 0 ..< count {
-            // add a new element to filteredValues, start value is 0.0
-            // this new value will be the last element, so we access it with index filteredValues.count - 1
-            filteredValues.append(0.0)
-
-            // iterate through the coefficients
-            for (index, coefficient) in coefficients[coefficientsRowToUse].enumerated() {
-                filteredValues[filteredValues.count - 1] = filteredValues[filteredValues.count - 1] + coefficient *
-                    tempArray[index + filteredValues.count - 1].value
-            }
-
-            filteredValues[filteredValues.count - 1] = filteredValues[filteredValues.count - 1] / divider
-        }
-
-        // now assign the new values to the original objects
-        for (index, _) in enumerated() {
-            self[index].value = filteredValues[index]
-        }
-    }
-}
-
-/// source https://github.com/raywenderlich/swift-algorithm-club/tree/master/Linear%20Regression
-private func multiply(
-    _ a: ArraySlice<SavitzkyGolaySmoothable>,
-    _ b: ArraySlice<SavitzkyGolaySmoothable>
-) -> ArraySlice<SavitzkyGolaySmoothable> {
-    zip(a, b).map({ IsSmoothable(withValue: $0.value * $1.value) })[0 ..< a.count]
-}
-
-/// source https://github.com/raywenderlich/swift-algorithm-club/tree/master/Linear%20Regression
-private func average(_ input: ArraySlice<SavitzkyGolaySmoothable>) -> Double {
-    (input.reduce(IsSmoothable(), { (x: SavitzkyGolaySmoothable, y: SavitzkyGolaySmoothable) in
-        IsSmoothable(withValue: x.value + y.value) })).value / Double(input.count)
-}
-
-/// source https://github.com/raywenderlich/swift-algorithm-club/tree/master/Linear%20Regression
-private func linearRegressionCreator(
-    _ xs: ArraySlice<SavitzkyGolaySmoothable>,
-    _ ys: ArraySlice<SavitzkyGolaySmoothable>
-) -> (Double) -> Double {
-    let sum1 = average(multiply(ys, xs)) - average(xs) * average(ys)
-    let sum2 = average(multiply(xs, xs)) - pow(average(xs), 2)
-    let slope = sum1 / sum2
-    let intercept = average(ys) - slope * average(xs)
-
-    return { x in intercept + slope * x }
-}

File diff suppressed because it is too large
+ 7201 - 1036
Trio/Sources/Localizations/Main/Localizable.xcstrings


+ 86 - 0
Trio/Sources/Models/AlgorithmGlucose.swift

@@ -0,0 +1,86 @@
+import Foundation
+
+/// Helper class so that we can have a plain Swift object to serialize GlucoseStorage
+struct AlgorithmGlucose: Codable {
+    var date: Date?
+    var direction: String?
+    var glucose: Int16
+    var id: UUID?
+    var isManual: Bool
+
+    enum CodingKeys: String, CodingKey {
+        case date
+        case dateString
+        case sgv
+        case glucose
+        case direction
+        case id
+        case type
+    }
+
+    init(date: Date?, direction: String?, glucose: Int16, id: UUID?, isManual: Bool) {
+        self.date = date
+        self.direction = direction
+        self.glucose = glucose
+        self.id = id
+        self.isManual = isManual
+    }
+
+    // this constructor is just for testing
+    public init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+
+        if let dateString = try container.decodeIfPresent(String.self, forKey: .dateString) {
+            let dateFormatter = ISO8601DateFormatter()
+            dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+            date = dateFormatter.date(from: dateString)
+        } else if let dateStringTimestamp = try container.decodeIfPresent(String.self, forKey: .date),
+                  let dateTimestamp = TimeInterval(dateStringTimestamp)
+        {
+            date = Date(timeIntervalSince1970: dateTimestamp / 1000)
+        } else {
+            date = nil
+        }
+
+        direction = try container.decodeIfPresent(String.self, forKey: .direction)
+        id = try container.decodeIfPresent(UUID.self, forKey: .id)
+
+        if let glucoseValue = try container.decodeIfPresent(Int16.self, forKey: .glucose) {
+            glucose = glucoseValue
+            isManual = true
+        } else if let sgvValue = try container.decodeIfPresent(Int16.self, forKey: .sgv) {
+            glucose = sgvValue
+            isManual = false
+        } else {
+            throw DecodingError.dataCorruptedError(
+                forKey: .sgv,
+                in: container,
+                debugDescription: "Neither 'glucose' nor 'sgv' key found or value is not Int16"
+            )
+        }
+    }
+
+    public func encode(to encoder: Encoder) throws {
+        var container = encoder.container(keyedBy: CodingKeys.self)
+
+        let dateFormatter = ISO8601DateFormatter()
+        dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+
+        try container.encode(dateFormatter.string(from: date ?? Date()), forKey: .dateString)
+
+        let dateAsUnixTimestamp = String(format: "%.0f", (date?.timeIntervalSince1970 ?? Date().timeIntervalSince1970) * 1000)
+        try container.encode(dateAsUnixTimestamp, forKey: .date)
+
+        try container.encode(direction, forKey: .direction)
+        try container.encode(id, forKey: .id)
+
+        // TODO: Handle the type of the glucose entry conditionally not hardcoded
+        try container.encode("sgv", forKey: .type)
+
+        if isManual {
+            try container.encode(glucose, forKey: .glucose)
+        } else {
+            try container.encode(glucose, forKey: .sgv)
+        }
+    }
+}

+ 0 - 12
Trio/Sources/Models/BloodGlucose.swift

@@ -249,18 +249,6 @@ extension NumberFormatter {
     }()
 }
 
-extension BloodGlucose: SavitzkyGolaySmoothable {
-    var value: Double {
-        get {
-            Double(glucose ?? 0)
-        }
-        set {
-            glucose = Int(newValue)
-            sgv = Int(newValue)
-        }
-    }
-}
-
 extension BloodGlucose {
     func convertStoredGlucoseSample(isManualGlucose: Bool) -> StoredGlucoseSample {
         StoredGlucoseSample(

+ 24 - 5
Trio/Sources/Modules/CGMSettings/View/CGMRootView.swift

@@ -113,17 +113,36 @@ extension CGMSettings {
                         units: state.units,
                         type: .boolean,
                         label: String(localized: "Smooth Glucose Value"),
-                        miniHint: String(localized: "Smooth CGM readings using Savitzky-Golay filtering."),
+                        miniHint: String(localized: "Smooth CGM readings using exponential smoothing."),
                         verboseHint: VStack(alignment: .leading, spacing: 10) {
                             Text("Default: OFF").bold()
+
                             Text(
-                                "This filter looks at small groups of nearby readings and fits them to a simple mathematical curve. This process doesn't change the overall pattern of your glucose data but helps smooth out the \"noise\" or irregular fluctuations that could lead to false highs or lows."
+                                "This feature smooths your CGM readings to reduce noise and make them easier to read. It is based on a method used in AndroidAPS (AAPS). It uses two approaches: one that reacts quickly to recent changes, and one that looks at longer trends. These are combined to give a balanced result."
                             )
+
                             Text(
-                                "It's designed to keep the important trends in your data while minimizing those small, misleading variations, giving you and Trio a clearer sense of where your blood sugar is really headed. This type of filtering is useful in Trio, as it can help prevent over-corrections based on inaccurate glucose readings. This can help reduce the impact of sudden spikes or dips that might not reflect your true blood glucose levels."
+                                "Trio will always display values based on your actual (raw) CGM readings. Smoothing does not change your real values or alerts."
                             )
+
+                            Text("When this feature is enabled:")
+
+                            VStack(alignment: .leading) {
+                                Text(
+                                    "• The main chart and treatment chart show a light gray trend line for the smoothed values. The glucose dots always show your original CGM readings."
+                                )
+
+                                Text("• In Trio history, you will see the smoothed value next to the original reading.")
+
+                                Text("• When you long-press a chart, the pop-up will show both the original and smoothed values.")
+                            }
+
+                            Text(
+                                "It can handle small gaps in data and ignores sensor error values. It needs at least 4 readings within 12 minutes to work properly. Only CGM readings are smoothed—manual entries are not changed."
+                            )
+
                             Text(
-                                "Note: If enabled, the smoothed values you see in Trio may differ from what is shown in your CGM app."
+                                "This helps Trio make more stable dosing decisions by avoiding over-reactions to small or short-term changes. Important trends are kept, while unreliable fluctuations are filtered out."
                             )
                         }
                     )
@@ -181,7 +200,7 @@ extension CGMSettings {
                         hintDetent: $hintDetent,
                         shouldDisplayHint: $shouldDisplayHint,
                         hintLabel: hintLabel ?? "",
-                        hintText: AnyView(
+                        hintText: selectedVerboseHint ?? AnyView(
                             VStack(alignment: .leading, spacing: 10) {
                                 Text(
                                     "Current CGM Models Supported:"

+ 21 - 3
Trio/Sources/Modules/History/View/HistoryRootView.swift

@@ -469,10 +469,11 @@ extension History {
         private var glucoseList: some View {
             List {
                 HStack {
-                    Text("Values").foregroundStyle(.secondary)
+                    Text("Values")
                     Spacer()
-                    Text("Time").foregroundStyle(.secondary)
-                }
+                    Text("Time")
+                }.foregroundStyle(.secondary)
+
                 if !glucoseStored.isEmpty {
                     ForEach(glucoseStored) { glucose in
                         HStack {
@@ -485,6 +486,23 @@ extension History {
                                 Text("\(glucose.directionEnum?.symbol ?? "--")")
                             }
 
+                            if state.settingsManager.settings.smoothGlucose, !glucose.isManual,
+                               let smoothedGlucose = glucose.smoothedGlucose, smoothedGlucose != 0
+                            {
+                                let smoothedGlucoseForDisplay = state.units == .mgdL ? smoothedGlucose
+                                    .description : smoothedGlucose.decimalValue
+                                    .formattedAsMmolL
+
+                                (
+                                    Text("(") +
+                                        Text(Image(systemName: "sparkles")) +
+                                        Text(" ") +
+                                        Text("\(smoothedGlucoseForDisplay)") +
+                                        Text(")")
+                                ).foregroundStyle(.secondary)
+                                    .padding(.leading, 10)
+                            }
+
                             Spacer()
 
                             Text(Formatter.dateFormatter.string(from: glucose.date ?? Date()))

+ 27 - 37
Trio/Sources/Modules/Home/View/Chart/ChartElements/GlucoseChartView.swift

@@ -32,46 +32,36 @@ struct GlucoseChartView: ChartContent {
                 glucoseColorScheme: glucoseColorScheme
             )
 
-            if !isSmoothingEnabled {
-                PointMark(
-                    x: .value("Time", item.date ?? Date(), unit: .second),
-                    y: .value("Value", glucoseToDisplay)
-                )
-                .foregroundStyle(pointMarkColor)
-                .symbolSize(20)
-                .symbol {
-                    if item.isManual {
-                        Image(systemName: "drop.fill")
-                            .font(.caption2)
-                            .symbolRenderingMode(.monochrome)
-                            .bold()
-                            .foregroundStyle(.red)
-                    } else {
-                        Image(systemName: "circle.fill")
-                            .font(.system(size: 5))
-                            .bold()
-                            .foregroundStyle(pointMarkColor)
-                    }
+            PointMark(
+                x: .value("Time", item.date ?? Date(), unit: .second),
+                y: .value("Value", glucoseToDisplay)
+            )
+            .foregroundStyle(pointMarkColor)
+            .symbolSize(20)
+            .symbol {
+                if item.isManual {
+                    Image(systemName: "drop.fill")
+                        .font(.caption2)
+                        .symbolRenderingMode(.monochrome)
+                        .bold()
+                        .foregroundStyle(.red)
+                } else {
+                    Image(systemName: "circle.fill")
+                        .font(.system(size: 5))
+                        .bold()
+                        .foregroundStyle(pointMarkColor)
                 }
-            } else {
-                PointMark(
+            }
+
+            if isSmoothingEnabled, let smoothedGlucose = item.smoothedGlucose, smoothedGlucose != 0 {
+                let smoothedGlucoseForDisplay: Decimal = units == .mgdL ? smoothedGlucose.decimalValue : smoothedGlucose
+                    .decimalValue.asMmolL
+                LineMark(
                     x: .value("Time", item.date ?? Date(), unit: .second),
-                    y: .value("Value", glucoseToDisplay)
+                    y: .value("Value", smoothedGlucoseForDisplay),
+                    series: .value("Type", "Smoothed")
                 )
-                .symbol {
-                    if item.isManual {
-                        Image(systemName: "drop.fill")
-                            .font(.caption2)
-                            .symbolRenderingMode(.monochrome)
-                            .bold()
-                            .foregroundStyle(.red)
-                    } else {
-                        Image(systemName: "record.circle.fill")
-                            .font(.system(size: 8))
-                            .bold()
-                            .foregroundStyle(pointMarkColor)
-                    }
-                }
+                .foregroundStyle(Color.secondary)
             }
         }
     }

+ 14 - 3
Trio/Sources/Modules/Home/View/Chart/ChartElements/SelectionPopoverView.swift

@@ -11,6 +11,7 @@ struct SelectionPopoverView: ChartContent {
     let lowGlucose: Decimal
     let currentGlucoseTarget: Decimal
     let glucoseColorScheme: GlucoseColorScheme
+    let isSmoothingEnabled: Bool
 
     private var glucoseToDisplay: Decimal {
         units == .mgdL ? Decimal(selectedGlucose.glucose) : Decimal(selectedGlucose.glucose).asMmolL
@@ -33,7 +34,7 @@ struct SelectionPopoverView: ChartContent {
     var body: some ChartContent {
         RuleMark(x: .value("Selection", selectedGlucose.date ?? Date.now, unit: .minute))
             .foregroundStyle(Color.tabBar)
-            .offset(yStart: 70)
+            .offset(yStart: isSmoothingEnabled ? 90 : 70)
             .lineStyle(.init(lineWidth: 2))
             .annotation(
                 position: .top,
@@ -70,15 +71,25 @@ struct SelectionPopoverView: ChartContent {
             .font(.body).padding(.bottom, 2)
 
             HStack {
-                Text(glucoseToDisplay.description).bold() + Text(" \(units.rawValue)")
+                Text("CGM: ") + Text(glucoseToDisplay.description).bold() + Text(" \(units.rawValue)")
             }
             .foregroundStyle(pointMarkColor)
             .font(.body)
 
+            if isSmoothingEnabled, let smoothedGlucose = selectedGlucose.smoothedGlucose {
+                var smoothedGlucoseToDisplay: Decimal {
+                    units == .mgdL ? smoothedGlucose.decimalValue : smoothedGlucose.decimalValue.asMmolL
+                }
+                HStack {
+                    Image(systemName: "sparkles")
+                    Text(smoothedGlucoseToDisplay.description) + Text(" \(units.rawValue)")
+                }.font(.body)
+            }
+
             if let selectedIOBValue, let iob = selectedIOBValue.iob {
                 HStack {
                     Image(systemName: "syringe.fill").frame(width: 15)
-                    Text(Formatter.bolusFormatter.string(from: iob) ?? "")
+                    Text(Formatter.decimalFormatterWithTwoFractionDigits.string(from: iob) ?? "")
                         .bold()
                         + Text(String(localized: " U", comment: "Insulin unit"))
                 }

+ 2 - 1
Trio/Sources/Modules/Home/View/Chart/MainChartView.swift

@@ -182,7 +182,8 @@ extension MainChartView {
                         highGlucose: highGlucose,
                         lowGlucose: lowGlucose,
                         currentGlucoseTarget: currentGlucoseTarget,
-                        glucoseColorScheme: glucoseColorScheme
+                        glucoseColorScheme: glucoseColorScheme,
+                        isSmoothingEnabled: state.settingsManager.settings.smoothGlucose
                     )
                 }
             }

+ 39 - 17
Trio/Sources/Modules/Treatments/View/ForecastChart.swift

@@ -135,7 +135,7 @@ struct ForecastChart: View {
                     .lineStyle(.init(lineWidth: 2))
                     .annotation(
                         position: .top,
-                        overflowResolution: .init(x: .fit(to: .chart), y: .disabled)
+                        overflowResolution: .init(x: .fit(to: .chart), y: .fit(to: .chart))
                     ) {
                         selectionPopover
                     }
@@ -221,12 +221,22 @@ struct ForecastChart: View {
                     glucoseColorScheme: state.glucoseColorScheme
                 )
                 HStack {
-                    Text(state.units == .mgdL ? Decimal(sgv).description : Decimal(sgv).formattedAsMmolL)
+                    Text("CGM: ") + Text(state.units == .mgdL ? Decimal(sgv).description : Decimal(sgv).formattedAsMmolL)
                         .bold()
                         + Text(" \(state.units.rawValue)")
                 }.foregroundStyle(
                     Color(glucoseColor)
                 ).font(.footnote)
+
+                if state.isSmoothingEnabled, let smoothedGlucose = selectedGlucose?.smoothedGlucose {
+                    let smoothedGlucoseToDisplay: Decimal = state.units == .mgdL
+                        ? smoothedGlucose.decimalValue
+                        : smoothedGlucose.decimalValue.asMmolL
+                    HStack {
+                        Image(systemName: "sparkles")
+                        Text(smoothedGlucoseToDisplay.description) + Text(" \(state.units.rawValue)")
+                    }.font(.footnote)
+                }
             }
             .padding(7)
             .background {
@@ -259,25 +269,37 @@ struct ForecastChart: View {
                 glucoseColorScheme: state.glucoseColorScheme
             )
 
-            if !state.isSmoothingEnabled {
-                PointMark(
-                    x: .value("Time", item.date ?? Date(), unit: .second),
-                    y: .value("Value", glucoseToDisplay)
-                )
-                .foregroundStyle(pointMarkColor)
-                .symbolSize(18)
-            } else {
-                PointMark(
-                    x: .value("Time", item.date ?? Date(), unit: .second),
-                    y: .value("Value", glucoseToDisplay)
-                )
-                .symbol {
-                    Image(systemName: "record.circle.fill")
-                        .font(.system(size: 6))
+            PointMark(
+                x: .value("Time", item.date ?? Date(), unit: .second),
+                y: .value("Value", glucoseToDisplay)
+            )
+            .foregroundStyle(pointMarkColor)
+            .symbol {
+                if item.isManual {
+                    Image(systemName: "drop.fill")
+                        .font(.caption2)
+                        .symbolRenderingMode(.monochrome)
+                        .bold()
+                        .foregroundStyle(.red)
+                } else {
+                    Image(systemName: "circle.fill")
+                        .font(.system(size: 4))
                         .bold()
                         .foregroundStyle(pointMarkColor)
                 }
             }
+
+            if state.isSmoothingEnabled, let smoothedGlucose = item.smoothedGlucose, smoothedGlucose != 0 {
+                let smoothedGlucoseForDisplay: Decimal = state.units == .mgdL
+                    ? smoothedGlucose.decimalValue
+                    : smoothedGlucose.decimalValue.asMmolL
+                LineMark(
+                    x: .value("Time", item.date ?? Date(), unit: .second),
+                    y: .value("Value", smoothedGlucoseForDisplay),
+                    series: .value("Type", "Smoothed")
+                )
+                .foregroundStyle(Color.secondary)
+            }
         }
     }
 

+ 315 - 0
TrioTests/GlucoseSmoothingTests.swift

@@ -0,0 +1,315 @@
+import CoreData
+import Foundation
+import LoopKitUI
+import Swinject
+import Testing
+
+@testable import Trio
+
+@Suite("Glucose Smoothing Tests", .serialized) struct GlucoseSmoothingTests: Injectable {
+    let resolver: Resolver
+    var coreDataStack: CoreDataStack!
+    var testContext: NSManagedObjectContext!
+    var fetchGlucoseManager: BaseFetchGlucoseManager!
+    var openAPS: OpenAPS!
+
+    init() async throws {
+        coreDataStack = try await CoreDataStack.createForTests()
+        testContext = coreDataStack.newTaskContext()
+
+        let assembler = Assembler([
+            StorageAssembly(),
+            ServiceAssembly(),
+            APSAssembly(),
+            NetworkAssembly(),
+            UIAssembly(),
+            SecurityAssembly(),
+            TestAssembly(testContext: testContext)
+        ])
+
+        resolver = assembler.resolver
+        injectServices(resolver)
+
+        fetchGlucoseManager = resolver.resolve(FetchGlucoseManager.self)! as? BaseFetchGlucoseManager
+
+        let fileStorage = resolver.resolve(FileStorage.self)!
+        openAPS = OpenAPS(storage: fileStorage, tddStorage: MockTDDStorage())
+    }
+
+    // MARK: - Exponential Smoothing Tests
+
+    @Test(
+        "Exponential smoothing writes smoothed glucose for CGM values when enough data exists"
+    ) func testExponentialSmoothingStoresSmoothedValues() async throws {
+        let glucoseValues: [Int16] = [100, 105, 110, 115, 120, 125]
+        await createGlucoseSequence(values: glucoseValues, interval: 5 * 60, isManual: false)
+
+        await fetchGlucoseManager.exponentialSmoothingGlucose(context: testContext)
+
+        let fetchedAscending = try await fetchAndSortGlucose()
+
+        // We expect at least the most recent few values to get smoothed values written.
+        // The Kotlin/port writes to data[i] for i in 0..<limit, where data is newest-first.
+        // With 6 values:
+        // - recordCount = 6
+        // - validWindowCount starts at 5, no gap => remains 5
+        // - smoothing produces blended.count == 5
+        // - apply limit = min(5, 6) = 5 => most recent 5 entries get smoothedGlucose
+        //
+        // In ascending order, "most recent 5" are indices 1...5. Oldest (index 0) is not guaranteed to be updated.
+        #expect(fetchedAscending.count == 6)
+
+        let smoothedValues = fetchedAscending.compactMap { $0.smoothedGlucose?.decimalValue }
+        #expect(smoothedValues.count >= 5, "Expected at least 5 smoothed values to be stored.")
+
+        for (i, value) in smoothedValues.enumerated() {
+            #expect(value >= 39, "Smoothed glucose at index \(i) should be clamped to at least 39, got \(value).")
+            #expect(
+                value == value.rounded(toPlaces: 0),
+                "Smoothed glucose at index \(i) should be rounded to an integer, got \(value)."
+            )
+        }
+    }
+
+    @Test("Exponential smoothing does not smooth manual glucose entries") func testExponentialSmoothingIgnoresManual() async throws {
+        // GIVEN: Mixed manual + CGM values
+        await createGlucoseSequence(values: [100, 105, 110, 115, 120].map(Int16.init), interval: 5 * 60, isManual: false)
+        await createGlucose(glucose: 130, smoothed: nil, isManual: true, date: Date().addingTimeInterval(6 * 5 * 60))
+
+        // WHEN
+        await fetchGlucoseManager.exponentialSmoothingGlucose(context: testContext)
+
+        // THEN
+        let allAscending = try await fetchAndSortGlucose()
+        let manual = allAscending.first(where: { $0.isManual })
+
+        #expect(manual != nil, "Expected a manual glucose entry.")
+        #expect(manual?.smoothedGlucose == nil, "Manual entries must not be smoothed/stored.")
+    }
+
+    @Test(
+        "Exponential smoothing clamps smoothed glucose to >= 39 and rounds to integer"
+    ) func testExponentialSmoothingClampAndRounding() async throws {
+        // GIVEN
+        let glucoseValues: [Int16] = [40, 39, 41, 42, 43, 44]
+        await createGlucoseSequence(values: glucoseValues, interval: 5 * 60, isManual: false)
+
+        // WHEN
+        await fetchGlucoseManager.exponentialSmoothingGlucose(context: testContext)
+
+        // THEN
+        let fetchedAscending = try await fetchAndSortGlucose()
+
+        let smoothedValues = fetchedAscending
+            .compactMap { $0.smoothedGlucose?.decimalValue }
+            .filter { $0 > 0 }
+
+        #expect(!smoothedValues.isEmpty, "Expected at least one smoothed glucose value to be stored.")
+
+        for (index, smoothed) in smoothedValues.enumerated() {
+            #expect(
+                smoothed >= 39,
+                "Smoothed glucose must be clamped to >= 39, got \(smoothed) at index \(index)."
+            )
+
+            #expect(
+                smoothed == smoothed.rounded(toPlaces: 0),
+                "Smoothed glucose must be an integer value, got \(smoothed) at index \(index)."
+            )
+        }
+    }
+
+    @Test(
+        "Exponential smoothing stops window at gaps >= 12 minutes; fallback fills smoothed glucose"
+    ) func testExponentialSmoothingGapStopsWindow() async throws {
+        // GIVEN:
+        let now = Date()
+        let dates: [Date] = [
+            now.addingTimeInterval(0), // oldest
+            now.addingTimeInterval(5 * 60),
+            now.addingTimeInterval(10 * 60),
+            now.addingTimeInterval(25 * 60), // gap of 15 minutes
+            now.addingTimeInterval(30 * 60),
+            now.addingTimeInterval(35 * 60) // newest
+        ]
+        let values: [Int16] = [100, 105, 110, 115, 120, 125]
+        await createGlucoseSequence(values: values, dates: dates, isManual: false)
+
+        // WHEN
+        await fetchGlucoseManager.exponentialSmoothingGlucose(context: testContext)
+
+        // THEN
+        let ascending = try await fetchAndSortGlucose()
+        #expect(ascending.count == 6)
+
+        let smoothedValues = ascending
+            .filter { !$0.isManual }
+            .compactMap { $0.smoothedGlucose?.decimalValue }
+            .filter { $0 > 0 }
+
+        #expect(
+            smoothedValues.count == 6,
+            "Fallback path should fill smoothedGlucose for all CGM entries when the gap reduces the window below minimum size."
+        )
+
+        for (index, smoothed) in smoothedValues.enumerated() {
+            #expect(
+                smoothed >= 39,
+                "Fallback smoothed glucose must be clamped to >= 39, got \(smoothed) at index \(index)."
+            )
+            #expect(
+                smoothed == smoothed.rounded(toPlaces: 0),
+                "Fallback smoothed glucose must be rounded to an integer, got \(smoothed) at index \(index)."
+            )
+        }
+    }
+
+    @Test(
+        "Exponential smoothing treats 38 mg/dL as xDrip error and clamps stored smoothed glucose"
+    ) func testExponentialSmoothingXDrip38StopsWindow() async throws {
+        // GIVEN
+        let values: [Int16] = [100, 105, 110, 38, 120, 125]
+        await createGlucoseSequence(values: values, interval: 5 * 60, isManual: false)
+
+        // WHEN
+        await fetchGlucoseManager.exponentialSmoothingGlucose(context: testContext)
+
+        // THEN
+        let ascending = try await fetchAndSortGlucose()
+        #expect(ascending.count == 6)
+
+        let smoothedValues = ascending
+            .compactMap { $0.smoothedGlucose?.decimalValue }
+            .filter { $0 > 0 }
+
+        #expect(
+            !smoothedValues.isEmpty,
+            "Expected at least one smoothed glucose value to be stored."
+        )
+
+        for (index, smoothed) in smoothedValues.enumerated() {
+            #expect(
+                smoothed >= 39,
+                "Smoothed glucose must be clamped to >= 39 even around xDrip 38, got \(smoothed) at index \(index)."
+            )
+            #expect(
+                smoothed == smoothed.rounded(toPlaces: 0),
+                "Smoothed glucose must be rounded to an integer, got \(smoothed) at index \(index)."
+            )
+        }
+    }
+
+    // MARK: - OpenAPS Glucose Selection Tests
+
+    @Test("Algorithm uses smoothed glucose when enabled") func testAlgorithmUsesSmoothedGlucose() async throws {
+        await createGlucose(glucose: 150, smoothed: 140, isManual: false, date: Date())
+
+        let algorithmInput = try await runFetchAndProcessGlucose(smoothGlucose: true)
+
+        #expect(algorithmInput.count == 1, "Expected to process one glucose entry.")
+        #expect(
+            algorithmInput.first?.glucose == 140,
+            "Algorithm should have used the smoothed glucose value (140), but used \(algorithmInput.first?.glucose ?? 0)."
+        )
+    }
+
+    @Test("Algorithm uses raw glucose when smoothing is disabled") func testAlgorithmUsesRawGlucose() async throws {
+        await createGlucose(glucose: 150, smoothed: 140, isManual: false, date: Date())
+
+        let algorithmInput = try await runFetchAndProcessGlucose(smoothGlucose: false)
+
+        #expect(algorithmInput.count == 1, "Expected to process one glucose entry.")
+        #expect(
+            algorithmInput.first?.glucose == 150,
+            "Algorithm should have used the raw glucose value (150), but used \(algorithmInput.first?.glucose ?? 0)."
+        )
+    }
+
+    @Test("Algorithm falls back to raw glucose if smoothed value is missing") func testAlgorithmFallbackToRawGlucose() async throws {
+        await createGlucose(glucose: 150, smoothed: nil, isManual: false, date: Date())
+
+        let algorithmInput = try await runFetchAndProcessGlucose(smoothGlucose: true)
+
+        #expect(algorithmInput.count == 1, "Expected to process one glucose entry.")
+        #expect(
+            algorithmInput.first?.glucose == 150,
+            "Algorithm should have fallen back to the raw glucose value (150), but used \(algorithmInput.first?.glucose ?? 0)."
+        )
+    }
+
+    @Test("Algorithm ignores smoothed value for manual glucose entries") func testAlgorithmIgnoresSmoothedManualGlucose() async throws {
+        await createGlucose(glucose: 150, smoothed: 140, isManual: true, date: Date())
+
+        let algorithmInput = try await runFetchAndProcessGlucose(smoothGlucose: true)
+
+        #expect(algorithmInput.count == 1, "Expected to process one glucose entry.")
+        #expect(
+            algorithmInput.first?.glucose == 150,
+            "Algorithm should have ignored smoothing for a manual entry and used the raw value (150), but used \(algorithmInput.first?.glucose ?? 0)."
+        )
+    }
+
+    // MARK: - Helpers
+
+    private func runFetchAndProcessGlucose(smoothGlucose: Bool) async throws -> [AlgorithmGlucose] {
+        let jsonString = try await openAPS.fetchAndProcessGlucose(
+            context: testContext,
+            shouldSmoothGlucose: smoothGlucose,
+            fetchLimit: 10
+        )
+
+        let data = jsonString.data(using: .utf8)!
+        let decoder = JSONDecoder()
+        decoder.dateDecodingStrategy = .custom { decoder in
+            let container = try decoder.singleValueContainer()
+            let dateDouble = try container.decode(Double.self)
+            return Date(timeIntervalSince1970: dateDouble / 1000)
+        }
+
+        return try decoder.decode([AlgorithmGlucose].self, from: data)
+    }
+
+    private func createGlucose(glucose: Int16, smoothed: Decimal?, isManual: Bool, date: Date) async {
+        await testContext.perform {
+            let object = GlucoseStored(context: self.testContext)
+            object.date = date
+            object.glucose = glucose
+            object.smoothedGlucose = smoothed as NSDecimalNumber?
+            object.isManual = isManual
+            object.id = UUID()
+            try! self.testContext.save()
+        }
+    }
+
+    private func createGlucoseSequence(values: [Int16], dates: [Date], isManual: Bool) async {
+        precondition(values.count == dates.count)
+
+        await testContext.perform {
+            for (i, value) in values.enumerated() {
+                let object = GlucoseStored(context: self.testContext)
+                object.date = dates[i]
+                object.glucose = value
+                object.smoothedGlucose = nil
+                object.isManual = isManual
+                object.id = UUID()
+            }
+            try! self.testContext.save()
+        }
+    }
+
+    private func createGlucoseSequence(values: [Int16], interval: TimeInterval, isManual: Bool) async {
+        let now = Date()
+        let dates = values.indices.map { now.addingTimeInterval(Double($0) * interval) }
+        await createGlucoseSequence(values: values, dates: dates, isManual: isManual)
+    }
+
+    private func fetchAndSortGlucose() async throws -> [GlucoseStored] {
+        try await coreDataStack.fetchEntitiesAsync(
+            ofType: GlucoseStored.self,
+            onContext: testContext,
+            predicate: .all,
+            key: "date",
+            ascending: true
+        ) as? [GlucoseStored] ?? []
+    }
+}

+ 15 - 0
TrioTests/Mocks/MockTDDStorage.swift

@@ -0,0 +1,15 @@
+import LoopKitUI
+@testable import Trio
+
+struct MockTDDStorage: TDDStorage {
+    func calculateTDD(
+        pumpManager _: any LoopKitUI.PumpManagerUI,
+        pumpHistory _: [Trio.PumpHistoryEvent],
+        basalProfile _: [Trio.BasalProfileEntry]
+    ) async throws -> Trio.TDDResult {
+        TDDResult(total: 0, bolus: 0, tempBasal: 0, scheduledBasal: 0, weightedAverage: 0, hoursOfData: 0)
+    }
+
+    func storeTDD(_: Trio.TDDResult) async { /* skip */ }
+    func hasSufficientTDD() async throws -> Bool { true }
+}