Browse Source

[Part 3 of 6] Remove JSON encoding for glucose

This commit removes the encoding and decoding of glucose between
CoreData and the oref algorithm. Previously we:
* CoreData -> AlgorithmGlucose -> JSON -> BloodGlucose -> oref

but with this change we now:
* CoreData -> BloodGlucose -> oref

There are a few subtleties in this conversion that were being handled
by the encode/decode phases of the previous pipeline, so I added unit
tests based on the old code to confirm that the conversion is
identical.

One semantic change is that the previous version would serialize dates
to ISO strings, but instead here we just copy the dates over directly
so the dates have sub MS precision.
Sam King 1 day ago
parent
commit
2171c41f23

+ 4 - 4
Trio.xcodeproj/project.pbxproj

@@ -793,7 +793,6 @@
 		DD9ECB712CA9A0BA00AA7C45 /* RemoteControlConfigProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB6E2CA9A0BA00AA7C45 /* RemoteControlConfigProvider.swift */; };
 		DD9ECB712CA9A0BA00AA7C45 /* RemoteControlConfigProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB6E2CA9A0BA00AA7C45 /* RemoteControlConfigProvider.swift */; };
 		DD9ECB722CA9A0BA00AA7C45 /* RemoteControlConfigDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB6F2CA9A0BA00AA7C45 /* RemoteControlConfigDataFlow.swift */; };
 		DD9ECB722CA9A0BA00AA7C45 /* RemoteControlConfigDataFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB6F2CA9A0BA00AA7C45 /* RemoteControlConfigDataFlow.swift */; };
 		DD9ECB742CA9A0C300AA7C45 /* RemoteControlConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD9ECB732CA9A0C300AA7C45 /* RemoteControlConfig.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 */; };
 		DDA6E2502D22187500C2988C /* ChartLegendView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA6E24F2D22187500C2988C /* ChartLegendView.swift */; };
 		DDA6E2852D2361F800C2988C /* LoopStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA6E2842D2361F800C2988C /* LoopStatusView.swift */; };
 		DDA6E2852D2361F800C2988C /* LoopStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA6E2842D2361F800C2988C /* LoopStatusView.swift */; };
 		DDA6E3202D258E0500C2988C /* OverrideHelpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA6E31F2D258E0500C2988C /* OverrideHelpView.swift */; };
 		DDA6E3202D258E0500C2988C /* OverrideHelpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDA6E31F2D258E0500C2988C /* OverrideHelpView.swift */; };
@@ -827,6 +826,7 @@
 		DDD7C8C12F4DB45400E5CF09 /* GlucoseStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD7C8BF2F4DB45400E5CF09 /* GlucoseStored+CoreDataClass.swift */; };
 		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 */; };
 		DDD7C8C22F4DB45400E5CF09 /* GlucoseStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD7C8C02F4DB45400E5CF09 /* GlucoseStored+CoreDataProperties.swift */; };
 		DDDD0FFB2F4E22C000F9C645 /* GlucoseSmoothingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */; };
 		DDDD0FFB2F4E22C000F9C645 /* GlucoseSmoothingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */; };
+		DDDD0FFD2F4E22C000F9C645 /* GlucoseNativeConversionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDD0FFC2F4E22C000F9C645 /* GlucoseNativeConversionTests.swift */; };
 		DDDD0FFF2F4E231B00F9C645 /* MockTDDStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDD0FFE2F4E231B00F9C645 /* MockTDDStorage.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 */; };
 		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 */; };
 		DDE179532C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179332C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift */; };
@@ -1799,7 +1799,6 @@
 		DD9ECB6E2CA9A0BA00AA7C45 /* RemoteControlConfigProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RemoteControlConfigProvider.swift; sourceTree = "<group>"; };
 		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>"; };
 		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>"; };
 		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>"; };
 		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>"; };
 		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>"; };
 		DDA6E31F2D258E0500C2988C /* OverrideHelpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OverrideHelpView.swift; sourceTree = "<group>"; };
@@ -1836,6 +1835,7 @@
 		DDD7C8BF2F4DB45400E5CF09 /* GlucoseStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "GlucoseStored+CoreDataClass.swift"; 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>"; };
 		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>"; };
 		DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseSmoothingTests.swift; sourceTree = "<group>"; };
+		DDDD0FFC2F4E22C000F9C645 /* GlucoseNativeConversionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlucoseNativeConversionTests.swift; sourceTree = "<group>"; };
 		DDDD0FFE2F4E231B00F9C645 /* MockTDDStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockTDDStorage.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>"; };
 		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>"; };
 		DDE179332C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MealPresetStored+CoreDataProperties.swift"; sourceTree = "<group>"; };
@@ -2723,7 +2723,6 @@
 			children = (
 			children = (
 				BD11795B2F4E22C100F90001 /* GlucoseAlerts */,
 				BD11795B2F4E22C100F90001 /* GlucoseAlerts */,
 				49C782A62F73D9870062B0DD /* AlertEntry.swift */,
 				49C782A62F73D9870062B0DD /* AlertEntry.swift */,
-				DDA40BB92F4DB18100257798 /* AlgorithmGlucose.swift */,
 				3E62C7812F54CC1600433237 /* BolusDisplayThreshold.swift */,
 				3E62C7812F54CC1600433237 /* BolusDisplayThreshold.swift */,
 				388E5A5F25B6F2310019842D /* Autosens.swift */,
 				388E5A5F25B6F2310019842D /* Autosens.swift */,
 				388358C725EEF6D200E024B2 /* BasalProfileEntry.swift */,
 				388358C725EEF6D200E024B2 /* BasalProfileEntry.swift */,
@@ -2968,6 +2967,7 @@
 			children = (
 			children = (
 				DDDD0FFD2F4E231600F9C645 /* Mocks */,
 				DDDD0FFD2F4E231600F9C645 /* Mocks */,
 				DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */,
 				DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */,
+				DDDD0FFC2F4E22C000F9C645 /* GlucoseNativeConversionTests.swift */,
 				DDC6CA6C2DD90A2A0060EE25 /* LocalizationTests.swift */,
 				DDC6CA6C2DD90A2A0060EE25 /* LocalizationTests.swift */,
 				3B997DD22DC02AEF006B6BB2 /* JSONImporterData */,
 				3B997DD22DC02AEF006B6BB2 /* JSONImporterData */,
 				BD8FC05C2D6618BE00B95AED /* BolusCalculatorTests */,
 				BD8FC05C2D6618BE00B95AED /* BolusCalculatorTests */,
@@ -4890,7 +4890,6 @@
 			isa = PBXSourcesBuildPhase;
 			isa = PBXSourcesBuildPhase;
 			buildActionMask = 2147483647;
 			buildActionMask = 2147483647;
 			files = (
 			files = (
-				DDA40BBA2F4DB18800257798 /* AlgorithmGlucose.swift in Sources */,
 				DD5DC9F12CF3D97C00AB8703 /* AdjustmentsStateModel+Overrides.swift in Sources */,
 				DD5DC9F12CF3D97C00AB8703 /* AdjustmentsStateModel+Overrides.swift in Sources */,
 				3811DE2325C9D48300A708ED /* MainDataFlow.swift in Sources */,
 				3811DE2325C9D48300A708ED /* MainDataFlow.swift in Sources */,
 				C2A0A42F2CE03131003B98E8 /* ConstantValues.swift in Sources */,
 				C2A0A42F2CE03131003B98E8 /* ConstantValues.swift in Sources */,
@@ -5631,6 +5630,7 @@
 				3BBC22632DF5B94100169236 /* AutosensTests.swift in Sources */,
 				3BBC22632DF5B94100169236 /* AutosensTests.swift in Sources */,
 				BD8FC0542D66186000B95AED /* TestError.swift in Sources */,
 				BD8FC0542D66186000B95AED /* TestError.swift in Sources */,
 				DDDD0FFB2F4E22C000F9C645 /* GlucoseSmoothingTests.swift in Sources */,
 				DDDD0FFB2F4E22C000F9C645 /* GlucoseSmoothingTests.swift in Sources */,
+				DDDD0FFD2F4E22C000F9C645 /* GlucoseNativeConversionTests.swift in Sources */,
 				CEE9A65E2BBC9F6500EB5194 /* CalibrationsTests.swift in Sources */,
 				CEE9A65E2BBC9F6500EB5194 /* CalibrationsTests.swift in Sources */,
 				CA02000000000000000010C2 /* DeliveryLimitsSyncTests.swift in Sources */,
 				CA02000000000000000010C2 /* DeliveryLimitsSyncTests.swift in Sources */,
 				BD8FC0622D6619E600B95AED /* OverrideStorageTests.swift in Sources */,
 				BD8FC0622D6619E600B95AED /* OverrideStorageTests.swift in Sources */,

+ 75 - 36
Trio/Sources/APS/OpenAPS/OpenAPS.swift

@@ -99,7 +99,7 @@ final class OpenAPS {
         shouldSmoothGlucose: Bool,
         shouldSmoothGlucose: Bool,
         fetchLimit: Int?,
         fetchLimit: Int?,
         fetchHours: Decimal = 24
         fetchHours: Decimal = 24
-    ) async throws -> String {
+    ) async throws -> [BloodGlucose] {
         // Time window from `fetchHours` hours ago up to now. determineBasal feeds
         // Time window from `fetchHours` hours ago up to now. determineBasal feeds
         // `maxMealAbsorptionTime + 0.5h` (just enough glucose to cover the longest
         // `maxMealAbsorptionTime + 0.5h` (just enough glucose to cover the longest
         // tracked meal absorption plus a small lead-in); Autosens uses the default
         // tracked meal absorption plus a small lead-in); Autosens uses the default
@@ -117,8 +117,7 @@ final class OpenAPS {
             batchSize: 48
             batchSize: 48
         )
         )
 
 
-        // mapping within the context closure, JSON conversion outside
-        let algorithmGlucose = try await context.perform {
+        return try await context.perform {
             guard let glucoseResults = results as? [GlucoseStored] else {
             guard let glucoseResults = results as? [GlucoseStored] else {
                 throw CoreDataError.fetchError(function: #function, file: #file)
                 throw CoreDataError.fetchError(function: #function, file: #file)
             }
             }
@@ -133,30 +132,66 @@ final class OpenAPS {
                 raiseOnDivideByZero: false
                 raiseOnDivideByZero: false
             )
             )
 
 
-            return glucoseResults.map { glucose -> AlgorithmGlucose in
-                let glucoseValue: Int16
-                if shouldSmoothGlucose {
-                    if !glucose.isManual, let smoothedGlucose = glucose.smoothedGlucose, smoothedGlucose != 0 {
-                        glucoseValue = smoothedGlucose.rounding(accordingToBehavior: roundingBehavior).int16Value
-                    } else {
-                        // use the raw value = finger prick, so manual readings are always included for algorithm decision making
-                        // cf. https://github.com/nightscout/Trio/issues/1054
-                        glucoseValue = glucose.glucose
-                    }
-                } else {
-                    glucoseValue = glucose.glucose
-                }
-                return AlgorithmGlucose(
-                    date: glucose.date,
-                    direction: glucose.direction,
-                    glucose: glucoseValue,
-                    id: glucose.id,
-                    isManual: glucose.isManual
-                )
+            return glucoseResults.map {
+                OpenAPS.mapToBloodGlucose($0, shouldSmoothGlucose: shouldSmoothGlucose, roundingBehavior: roundingBehavior)
             }
             }
         }
         }
+    }
 
 
-        return jsonConverter.convertToJSON(algorithmGlucose)
+    /// The glucose value the algorithm consumes for a stored reading: the smoothed value when
+    /// smoothing is on and a valid non-zero smoothed CGM value exists, otherwise the raw value
+    /// (finger pricks/manual entries always use the raw value — cf.
+    /// https://github.com/nightscout/Trio/issues/1054).
+    static func algorithmGlucoseValue(
+        for glucose: GlucoseStored,
+        shouldSmoothGlucose: Bool,
+        roundingBehavior: NSDecimalNumberHandler
+    ) -> Int16 {
+        if shouldSmoothGlucose {
+            if !glucose.isManual, let smoothedGlucose = glucose.smoothedGlucose, smoothedGlucose != 0 {
+                return smoothedGlucose.rounding(accordingToBehavior: roundingBehavior).int16Value
+            } else {
+                return glucose.glucose
+            }
+        } else {
+            return glucose.glucose
+        }
+    }
+
+    /// Maps a `GlucoseStored` to the `BloodGlucose` the algorithm consumes, reproducing exactly
+    /// the coercions the old `AlgorithmGlucose` → JSON → `JSONBridge.glucose` round-trip applied:
+    /// - CGM readings populate `sgv`, manual readings populate `glucose` (driven by `isManual`).
+    /// - `dateString` is the stored reading date. The old path round-tripped it through an ISO8601
+    ///   fractional-seconds string, truncating it to millisecond precision; we keep the full-precision
+    ///   `Date` instead, since the extra sub-millisecond precision is harmless to the algorithm and
+    ///   the JSON representation is unaffected (`dateString` still encodes to 3-digit-ms ISO8601).
+    /// - `date` is the unix-millisecond timestamp as a `Decimal`.
+    /// - `type` is always "sgv" (matching the old hardcoded encode).
+    static func mapToBloodGlucose(
+        _ glucose: GlucoseStored,
+        shouldSmoothGlucose: Bool,
+        roundingBehavior: NSDecimalNumberHandler
+    ) -> BloodGlucose {
+        let glucoseValue = algorithmGlucoseValue(
+            for: glucose,
+            shouldSmoothGlucose: shouldSmoothGlucose,
+            roundingBehavior: roundingBehavior
+        )
+        let isManual = glucose.isManual
+        // The ?? Date() is problematic, but this is how it worked previously
+        // so we'll retain it
+        let dateString = glucose.date ?? Date()
+        let dateMilliseconds = Decimal(Int64(dateString.timeIntervalSince1970 * 1000))
+
+        return BloodGlucose(
+            id: glucose.id?.uuidString ?? UUID().uuidString,
+            sgv: isManual ? nil : Int(glucoseValue),
+            direction: glucose.direction.flatMap { BloodGlucose.Direction(rawValue: $0) },
+            date: dateMilliseconds,
+            dateString: dateString,
+            glucose: isManual ? Int(glucoseValue) : nil,
+            type: "sgv"
+        )
     }
     }
 
 
     private func fetchAndProcessCarbs(additionalCarbs: Decimal? = nil, carbsDate: Date? = nil) async throws -> String {
     private func fetchAndProcessCarbs(additionalCarbs: Decimal? = nil, carbsDate: Date? = nil) async throws -> String {
@@ -400,7 +435,7 @@ final class OpenAPS {
 
 
         var preferences = await storage.retrieveAsync(OpenAPS.Settings.preferences, as: Preferences.self) ?? Preferences()
         var preferences = await storage.retrieveAsync(OpenAPS.Settings.preferences, as: Preferences.self) ?? Preferences()
         let glucoseFetchHours = preferences.maxMealAbsorptionTime + 0.5 // MMAT + half hour buffer
         let glucoseFetchHours = preferences.maxMealAbsorptionTime + 0.5 // MMAT + half hour buffer
-        async let glucose = fetchAndProcessGlucose(
+        async let glucoseFetch = fetchAndProcessGlucose(
             context: context,
             context: context,
             shouldSmoothGlucose: shouldSmoothGlucose,
             shouldSmoothGlucose: shouldSmoothGlucose,
             fetchLimit: nil,
             fetchLimit: nil,
@@ -418,7 +453,7 @@ final class OpenAPS {
         let (
         let (
             pumpHistoryJSON,
             pumpHistoryJSON,
             carbsAsJSON,
             carbsAsJSON,
-            glucoseAsJSON,
+            glucose,
             trioCustomOrefVariables,
             trioCustomOrefVariables,
             profile,
             profile,
             basalProfile,
             basalProfile,
@@ -428,7 +463,7 @@ final class OpenAPS {
         ) = await (
         ) = await (
             try parsePumpHistory(await pumpHistoryObjectIDs, simulatedBolusAmount: simulatedBolusAmount),
             try parsePumpHistory(await pumpHistoryObjectIDs, simulatedBolusAmount: simulatedBolusAmount),
             try carbs,
             try carbs,
-            try glucose,
+            try glucoseFetch,
             try prepareTrioCustomOrefVariables,
             try prepareTrioCustomOrefVariables,
             profileAsync,
             profileAsync,
             basalAsync,
             basalAsync,
@@ -444,7 +479,7 @@ final class OpenAPS {
             basalProfile: basalProfile,
             basalProfile: basalProfile,
             clock: clock,
             clock: clock,
             carbs: carbsAsJSON,
             carbs: carbsAsJSON,
-            glucose: glucoseAsJSON
+            glucose: glucose
         )
         )
 
 
         // IOB calculation
         // IOB calculation
@@ -468,7 +503,7 @@ final class OpenAPS {
 
 
         // Determine basal
         // Determine basal
         let orefDetermination = try determineBasal(
         let orefDetermination = try determineBasal(
-            glucose: glucoseAsJSON,
+            glucose: glucose,
             currentTemp: tempBasal,
             currentTemp: tempBasal,
             iob: iob,
             iob: iob,
             profile: profile,
             profile: profile,
@@ -578,16 +613,20 @@ final class OpenAPS {
         // Perform asynchronous calls in parallel
         // Perform asynchronous calls in parallel
         async let pumpHistoryObjectIDs = fetchPumpHistoryObjectIDs() ?? []
         async let pumpHistoryObjectIDs = fetchPumpHistoryObjectIDs() ?? []
         async let carbs = fetchAndProcessCarbs()
         async let carbs = fetchAndProcessCarbs()
-        async let glucose = fetchAndProcessGlucose(context: context, shouldSmoothGlucose: shouldSmoothGlucose, fetchLimit: nil)
+        async let glucoseFetch = fetchAndProcessGlucose(
+            context: context,
+            shouldSmoothGlucose: shouldSmoothGlucose,
+            fetchLimit: nil
+        )
         async let getProfile = loadFileFromStorageAsync(name: Settings.profile)
         async let getProfile = loadFileFromStorageAsync(name: Settings.profile)
         async let getBasalProfile = loadFileFromStorageAsync(name: Settings.basalProfile)
         async let getBasalProfile = loadFileFromStorageAsync(name: Settings.basalProfile)
         async let getTempTargets = loadFileFromStorageAsync(name: Settings.tempTargets)
         async let getTempTargets = loadFileFromStorageAsync(name: Settings.tempTargets)
 
 
         // Await the results of asynchronous tasks
         // Await the results of asynchronous tasks
-        let (pumpHistoryJSON, carbsAsJSON, glucoseAsJSON, profile, basalProfile, tempTargets) = await (
+        let (pumpHistoryJSON, carbsAsJSON, glucose, profile, basalProfile, tempTargets) = await (
             try parsePumpHistory(await pumpHistoryObjectIDs),
             try parsePumpHistory(await pumpHistoryObjectIDs),
             try carbs,
             try carbs,
-            try glucose,
+            try glucoseFetch,
             getProfile,
             getProfile,
             getBasalProfile,
             getBasalProfile,
             getTempTargets
             getTempTargets
@@ -595,7 +634,7 @@ final class OpenAPS {
 
 
         // Autosense
         // Autosense
         let autosenseResult = try autosense(
         let autosenseResult = try autosense(
-            glucose: glucoseAsJSON,
+            glucose: glucose,
             pumpHistory: pumpHistoryJSON,
             pumpHistory: pumpHistoryJSON,
             basalprofile: basalProfile,
             basalprofile: basalProfile,
             profile: profile,
             profile: profile,
@@ -747,7 +786,7 @@ final class OpenAPS {
         basalProfile: JSON,
         basalProfile: JSON,
         clock: JSON,
         clock: JSON,
         carbs: JSON,
         carbs: JSON,
-        glucose: JSON
+        glucose: [BloodGlucose]
     ) throws -> RawJSON {
     ) throws -> RawJSON {
         let swiftResult = OpenAPSSwift
         let swiftResult = OpenAPSSwift
             .meal(
             .meal(
@@ -762,7 +801,7 @@ final class OpenAPS {
     }
     }
 
 
     private func autosense(
     private func autosense(
-        glucose: JSON,
+        glucose: [BloodGlucose],
         pumpHistory: JSON,
         pumpHistory: JSON,
         basalprofile: JSON,
         basalprofile: JSON,
         profile: JSON,
         profile: JSON,
@@ -783,7 +822,7 @@ final class OpenAPS {
     }
     }
 
 
     private func determineBasal(
     private func determineBasal(
-        glucose: JSON,
+        glucose: [BloodGlucose],
         currentTemp: JSON,
         currentTemp: JSON,
         iob: JSON,
         iob: JSON,
         profile: JSON,
         profile: JSON,

+ 0 - 4
Trio/Sources/APS/OpenAPSSwift/JSONBridge.swift

@@ -40,10 +40,6 @@ enum JSONBridge {
         try JSONBridge.from(string: from.rawJSON)
         try JSONBridge.from(string: from.rawJSON)
     }
     }
 
 
-    static func glucose(from: JSON) throws -> [BloodGlucose] {
-        try JSONBridge.from(string: from.rawJSON)
-    }
-
     static func currentTemp(from: JSON) throws -> TempBasal {
     static func currentTemp(from: JSON) throws -> TempBasal {
         try JSONBridge.from(string: from.rawJSON)
         try JSONBridge.from(string: from.rawJSON)
     }
     }

+ 3 - 6
Trio/Sources/APS/OpenAPSSwift/OpenAPSSwift.swift

@@ -26,7 +26,7 @@ struct OpenAPSSwift {
     }
     }
 
 
     static func determineBasal(
     static func determineBasal(
-        glucose: JSON,
+        glucose: [BloodGlucose],
         currentTemp: JSON,
         currentTemp: JSON,
         iob: JSON,
         iob: JSON,
         profile: JSON,
         profile: JSON,
@@ -41,7 +41,6 @@ struct OpenAPSSwift {
         clock: Date
         clock: Date
     ) -> (OrefFunctionResult) {
     ) -> (OrefFunctionResult) {
         do {
         do {
-            let glucose = try JSONBridge.glucose(from: glucose)
             let currentTemp = try JSONBridge.currentTemp(from: currentTemp)
             let currentTemp = try JSONBridge.currentTemp(from: currentTemp)
             let iob = try JSONBridge.iobResult(from: iob)
             let iob = try JSONBridge.iobResult(from: iob)
             let profile = try JSONBridge.profile(from: profile)
             let profile = try JSONBridge.profile(from: profile)
@@ -94,7 +93,7 @@ struct OpenAPSSwift {
         basalProfile: JSON,
         basalProfile: JSON,
         clock: JSON,
         clock: JSON,
         carbs: JSON,
         carbs: JSON,
-        glucose: JSON
+        glucose: [BloodGlucose]
     ) -> (OrefFunctionResult) {
     ) -> (OrefFunctionResult) {
         do {
         do {
             let pumpHistory = try JSONBridge.pumpHistory(from: pumphistory)
             let pumpHistory = try JSONBridge.pumpHistory(from: pumphistory)
@@ -102,7 +101,6 @@ struct OpenAPSSwift {
             let basalProfile = try JSONBridge.basalProfile(from: basalProfile)
             let basalProfile = try JSONBridge.basalProfile(from: basalProfile)
             let clock = try JSONBridge.clock(from: clock)
             let clock = try JSONBridge.clock(from: clock)
             let carbs = try JSONBridge.carbs(from: carbs)
             let carbs = try JSONBridge.carbs(from: carbs)
-            let glucose = try JSONBridge.glucose(from: glucose)
 
 
             let mealResult = try MealGenerator.generate(
             let mealResult = try MealGenerator.generate(
                 pumpHistory: pumpHistory,
                 pumpHistory: pumpHistory,
@@ -140,7 +138,7 @@ struct OpenAPSSwift {
     }
     }
 
 
     static func autosense(
     static func autosense(
-        glucose: JSON,
+        glucose: [BloodGlucose],
         pumpHistory: JSON,
         pumpHistory: JSON,
         basalProfile: JSON,
         basalProfile: JSON,
         profile: JSON,
         profile: JSON,
@@ -150,7 +148,6 @@ struct OpenAPSSwift {
         includeDeviationsForTesting: Bool = false
         includeDeviationsForTesting: Bool = false
     ) -> (OrefFunctionResult) {
     ) -> (OrefFunctionResult) {
         do {
         do {
-            let glucose = try JSONBridge.glucose(from: glucose)
             let pumpHistory = try JSONBridge.pumpHistory(from: pumpHistory)
             let pumpHistory = try JSONBridge.pumpHistory(from: pumpHistory)
             let basalProfile = try JSONBridge.basalProfile(from: basalProfile)
             let basalProfile = try JSONBridge.basalProfile(from: basalProfile)
             let profile = try JSONBridge.profile(from: profile)
             let profile = try JSONBridge.profile(from: profile)

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

@@ -1,86 +0,0 @@
-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 = Int64((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)
-        }
-    }
-}

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

@@ -173,6 +173,11 @@ struct BloodGlucose: JSON, Identifiable, Hashable, Codable {
     var transmitterID: String? = nil
     var transmitterID: String? = nil
     var isStateValid: Bool { sgv ?? 0 >= 39 && noise ?? 1 != 4 }
     var isStateValid: Bool { sgv ?? 0 >= 39 && noise ?? 1 != 4 }
 
 
+    // TODO: remove this custom Equatable/Hashable. Keying identity on `dateString` is a footgun:
+    // two distinct readings at the same instant collide, and the same reading at different date
+    // precision compares unequal. `id` (the sync identifier) is the natural key. It's currently
+    // safe to leave — nothing live compares `BloodGlucose` via ==/hash (only the unused
+    // `History.Glucose` reaches it) — but it should be removed in its own change.
     static func == (lhs: BloodGlucose, rhs: BloodGlucose) -> Bool {
     static func == (lhs: BloodGlucose, rhs: BloodGlucose) -> Bool {
         lhs.dateString == rhs.dateString
         lhs.dateString == rhs.dateString
     }
     }

+ 457 - 0
TrioTests/GlucoseNativeConversionTests.swift

@@ -0,0 +1,457 @@
+import CoreData
+import Foundation
+import Testing
+
+@testable import Trio
+
+/// Golden tests certifying that the native `GlucoseStored` → `BloodGlucose` mapping
+/// (`OpenAPS.mapToBloodGlucose`) reproduces, byte for byte, the glucose the algorithm used to
+/// receive through the old JSON round-trip (`GlucoseStored` → `AlgorithmGlucose` → JSON →
+/// `JSONBridge.glucose`). The golden literals below were captured from that old path while it
+/// still existed (via a temporary differential run), so a match proves the algorithm still sees
+/// identical inputs now that the JSON round-trip is gone.
+///
+/// The comparison is field by field (see `expectFieldsEqual`), NOT `==`: `BloodGlucose.==` only
+/// compares `dateString`, so a plain array comparison would pass even if
+/// `glucose`/`sgv`/`direction`/`id`/`type` differed — exactly the coerced fields this migration
+/// must preserve.
+///
+/// Fixtures use fixed dates and ids so the mapping is fully deterministic and independent of the
+/// wall-clock-relative time-window fetch (which is unchanged by this migration and stays covered
+/// by `GlucoseSmoothingTests`).
+@Suite("Glucose Native Conversion Tests", .serialized) struct GlucoseNativeConversionTests {
+    var coreDataStack: CoreDataStack!
+    var testContext: NSManagedObjectContext!
+
+    init() async throws {
+        coreDataStack = try await CoreDataStack.createForTests()
+        testContext = coreDataStack.newTaskContext()
+    }
+
+    // MARK: - Golden tests (native mapping vs frozen old-path output)
+
+    @Test("CGM readings without smoothing map identically") func testCGMNoSmoothing() async throws {
+        await insertGlucose(glucose: 120, isManual: false, date: fixedDate(minutesAgo: 0), direction: "Flat", id: uuid(1))
+        await insertGlucose(glucose: 95, isManual: false, date: fixedDate(minutesAgo: 5), direction: "FortyFiveDown", id: uuid(2))
+        await insertGlucose(glucose: 150, isManual: true, date: fixedDate(minutesAgo: 10), direction: nil, id: uuid(3))
+
+        try await assertNativeMatchesGolden(shouldSmoothGlucose: false, [
+            BloodGlucose(
+                id: uuid(1).uuidString,
+                sgv: 120,
+                direction: .flat,
+                date: 1_700_000_000_000,
+                dateString: Date(timeIntervalSince1970: 1_700_000_000),
+                type: "sgv"
+            ),
+            BloodGlucose(
+                id: uuid(2).uuidString,
+                sgv: 95,
+                direction: .fortyFiveDown,
+                date: 1_699_999_700_000,
+                dateString: Date(timeIntervalSince1970: 1_699_999_700),
+                type: "sgv"
+            ),
+            BloodGlucose(
+                id: uuid(3).uuidString,
+                date: 1_699_999_400_000,
+                dateString: Date(timeIntervalSince1970: 1_699_999_400),
+                glucose: 150,
+                type: "sgv"
+            )
+        ])
+    }
+
+    @Test("Smoothed CGM value is used and rounded identically") func testSmoothedValueUsed() async throws {
+        await insertGlucose(
+            glucose: 120,
+            isManual: false,
+            date: fixedDate(minutesAgo: 0),
+            smoothed: Decimal(string: "118.6"),
+            direction: "Flat",
+            id: uuid(1)
+        )
+
+        try await assertNativeMatchesGolden(shouldSmoothGlucose: true, [
+            BloodGlucose(
+                id: uuid(1).uuidString,
+                sgv: 119,
+                direction: .flat,
+                date: 1_700_000_000_000,
+                dateString: Date(timeIntervalSince1970: 1_700_000_000),
+                type: "sgv"
+            )
+        ])
+
+        let native = try await nativeBloodGlucose(shouldSmoothGlucose: true)
+        #expect(native.first?.sgv == 119, "118.6 should round to 119 in the sgv field")
+        #expect(native.first?.glucose == nil, "CGM readings must not populate the manual `glucose` field")
+    }
+
+    @Test("Zero smoothed value falls back to the raw value") func testSmoothedZeroFallsBackToRaw() async throws {
+        await insertGlucose(
+            glucose: 120,
+            isManual: false,
+            date: fixedDate(minutesAgo: 0),
+            smoothed: 0,
+            direction: "Flat",
+            id: uuid(1)
+        )
+
+        try await assertNativeMatchesGolden(shouldSmoothGlucose: true, [
+            BloodGlucose(
+                id: uuid(1).uuidString,
+                sgv: 120,
+                direction: .flat,
+                date: 1_700_000_000_000,
+                dateString: Date(timeIntervalSince1970: 1_700_000_000),
+                type: "sgv"
+            )
+        ])
+
+        let native = try await nativeBloodGlucose(shouldSmoothGlucose: true)
+        #expect(native.first?.sgv == 120, "A zero smoothed value must fall back to the raw 120")
+    }
+
+    @Test("Nil smoothed value falls back to the raw value") func testSmoothedNilFallsBackToRaw() async throws {
+        await insertGlucose(
+            glucose: 120,
+            isManual: false,
+            date: fixedDate(minutesAgo: 0),
+            smoothed: nil,
+            direction: "Flat",
+            id: uuid(1)
+        )
+
+        try await assertNativeMatchesGolden(shouldSmoothGlucose: true, [
+            BloodGlucose(
+                id: uuid(1).uuidString,
+                sgv: 120,
+                direction: .flat,
+                date: 1_700_000_000_000,
+                dateString: Date(timeIntervalSince1970: 1_700_000_000),
+                type: "sgv"
+            )
+        ])
+
+        let native = try await nativeBloodGlucose(shouldSmoothGlucose: true)
+        #expect(native.first?.sgv == 120, "A nil smoothed value must fall back to the raw 120")
+    }
+
+    @Test("Manual entries ignore smoothing and populate the glucose field") func testManualIgnoresSmoothing() async throws {
+        await insertGlucose(
+            glucose: 150,
+            isManual: true,
+            date: fixedDate(minutesAgo: 0),
+            smoothed: 140,
+            direction: nil,
+            id: uuid(1)
+        )
+
+        try await assertNativeMatchesGolden(shouldSmoothGlucose: true, [
+            BloodGlucose(
+                id: uuid(1).uuidString,
+                date: 1_700_000_000_000,
+                dateString: Date(timeIntervalSince1970: 1_700_000_000),
+                glucose: 150,
+                type: "sgv"
+            )
+        ])
+
+        let native = try await nativeBloodGlucose(shouldSmoothGlucose: true)
+        #expect(native.first?.glucose == 150, "Manual entries must use the raw value in the `glucose` field")
+        #expect(native.first?.sgv == nil, "Manual entries must not populate the `sgv` field")
+    }
+
+    @Test("Direction variants and nil map identically") func testDirectionVariants() async throws {
+        await insertGlucose(glucose: 110, isManual: false, date: fixedDate(minutesAgo: 0), direction: "TripleUp", id: uuid(1))
+        await insertGlucose(glucose: 111, isManual: false, date: fixedDate(minutesAgo: 5), direction: "FortyFiveUp", id: uuid(2))
+        await insertGlucose(glucose: 112, isManual: false, date: fixedDate(minutesAgo: 10), direction: "NONE", id: uuid(3))
+        await insertGlucose(glucose: 113, isManual: false, date: fixedDate(minutesAgo: 15), direction: nil, id: uuid(4))
+
+        try await assertNativeMatchesGolden(shouldSmoothGlucose: false, [
+            BloodGlucose(
+                id: uuid(1).uuidString,
+                sgv: 110,
+                direction: .tripleUp,
+                date: 1_700_000_000_000,
+                dateString: Date(timeIntervalSince1970: 1_700_000_000),
+                type: "sgv"
+            ),
+            BloodGlucose(
+                id: uuid(2).uuidString,
+                sgv: 111,
+                direction: .fortyFiveUp,
+                date: 1_699_999_700_000,
+                dateString: Date(timeIntervalSince1970: 1_699_999_700),
+                type: "sgv"
+            ),
+            // `.none` here is BloodGlucose.Direction.none ("NONE"), not Optional.none — spell it out.
+            BloodGlucose(
+                id: uuid(3).uuidString,
+                sgv: 112,
+                direction: BloodGlucose.Direction.none,
+                date: 1_699_999_400_000,
+                dateString: Date(timeIntervalSince1970: 1_699_999_400),
+                type: "sgv"
+            ),
+            BloodGlucose(
+                id: uuid(4).uuidString,
+                sgv: 113,
+                date: 1_699_999_100_000,
+                dateString: Date(timeIntervalSince1970: 1_699_999_100),
+                type: "sgv"
+            )
+        ])
+    }
+
+    @Test("Sub-millisecond dates are truncated identically") func testMillisecondTruncation() async throws {
+        // A date with sub-millisecond precision: the old path round-trips it through an ISO8601
+        // fractional-seconds string (millisecond precision), so both fields must be ms-truncated.
+        await insertGlucose(
+            glucose: 100,
+            isManual: false,
+            date: fixedDate(minutesAgo: 0, plusSeconds: 0.123_456),
+            direction: "Flat",
+            id: uuid(1)
+        )
+
+        try await assertNativeMatchesGolden(shouldSmoothGlucose: false, [
+            BloodGlucose(
+                id: uuid(1).uuidString,
+                sgv: 100,
+                direction: .flat,
+                date: 1_700_000_000_123,
+                dateString: Date(timeIntervalSince1970: 1_700_000_000.123),
+                type: "sgv"
+            )
+        ])
+    }
+
+    @Test("A descending multi-entry sequence maps identically") func testDescendingSequence() async throws {
+        for i in 0 ..< 6 {
+            await insertGlucose(
+                glucose: Int16(100 + i),
+                isManual: false,
+                date: fixedDate(minutesAgo: Double(i) * 5),
+                direction: "Flat",
+                id: uuid(i + 1)
+            )
+        }
+
+        try await assertNativeMatchesGolden(shouldSmoothGlucose: false, [
+            BloodGlucose(
+                id: uuid(1).uuidString,
+                sgv: 100,
+                direction: .flat,
+                date: 1_700_000_000_000,
+                dateString: Date(timeIntervalSince1970: 1_700_000_000),
+                type: "sgv"
+            ),
+            BloodGlucose(
+                id: uuid(2).uuidString,
+                sgv: 101,
+                direction: .flat,
+                date: 1_699_999_700_000,
+                dateString: Date(timeIntervalSince1970: 1_699_999_700),
+                type: "sgv"
+            ),
+            BloodGlucose(
+                id: uuid(3).uuidString,
+                sgv: 102,
+                direction: .flat,
+                date: 1_699_999_400_000,
+                dateString: Date(timeIntervalSince1970: 1_699_999_400),
+                type: "sgv"
+            ),
+            BloodGlucose(
+                id: uuid(4).uuidString,
+                sgv: 103,
+                direction: .flat,
+                date: 1_699_999_100_000,
+                dateString: Date(timeIntervalSince1970: 1_699_999_100),
+                type: "sgv"
+            ),
+            BloodGlucose(
+                id: uuid(5).uuidString,
+                sgv: 104,
+                direction: .flat,
+                date: 1_699_998_800_000,
+                dateString: Date(timeIntervalSince1970: 1_699_998_800),
+                type: "sgv"
+            ),
+            BloodGlucose(
+                id: uuid(6).uuidString,
+                sgv: 105,
+                direction: .flat,
+                date: 1_699_998_500_000,
+                dateString: Date(timeIntervalSince1970: 1_699_998_500),
+                type: "sgv"
+            )
+        ])
+    }
+
+    @Test("Smoothing rounding boundaries map identically") func testRoundingBoundaries() async throws {
+        await insertGlucose(
+            glucose: 100,
+            isManual: false,
+            date: fixedDate(minutesAgo: 0),
+            smoothed: Decimal(string: "118.5"),
+            id: uuid(1)
+        )
+        await insertGlucose(
+            glucose: 100,
+            isManual: false,
+            date: fixedDate(minutesAgo: 5),
+            smoothed: Decimal(string: "118.4"),
+            id: uuid(2)
+        )
+        await insertGlucose(
+            glucose: 100,
+            isManual: false,
+            date: fixedDate(minutesAgo: 10),
+            smoothed: Decimal(string: "119.5"),
+            id: uuid(3)
+        )
+
+        try await assertNativeMatchesGolden(shouldSmoothGlucose: true, [
+            BloodGlucose(
+                id: uuid(1).uuidString,
+                sgv: 119,
+                date: 1_700_000_000_000,
+                dateString: Date(timeIntervalSince1970: 1_700_000_000),
+                type: "sgv"
+            ),
+            BloodGlucose(
+                id: uuid(2).uuidString,
+                sgv: 118,
+                date: 1_699_999_700_000,
+                dateString: Date(timeIntervalSince1970: 1_699_999_700),
+                type: "sgv"
+            ),
+            BloodGlucose(
+                id: uuid(3).uuidString,
+                sgv: 120,
+                date: 1_699_999_400_000,
+                dateString: Date(timeIntervalSince1970: 1_699_999_400),
+                type: "sgv"
+            )
+        ])
+
+        let native = try await nativeBloodGlucose(shouldSmoothGlucose: true)
+        #expect(native.map(\.sgv) == [119, 118, 120], ".plain scale-0 rounding: 118.5→119, 118.4→118, 119.5→120")
+    }
+
+    // MARK: - Comparison helpers
+
+    /// Asserts the native mapping reproduces the frozen golden `BloodGlucose` values. The goldens
+    /// were captured from the old `AlgorithmGlucose` → JSON → `JSONBridge.glucose` path (see the
+    /// differential run in this migration's history), so a match proves the algorithm still receives
+    /// identical glucose after the JSON round-trip was removed.
+    private func assertNativeMatchesGolden(shouldSmoothGlucose: Bool, _ golden: [BloodGlucose]) async throws {
+        let native = try await nativeBloodGlucose(shouldSmoothGlucose: shouldSmoothGlucose)
+
+        #expect(native.count == golden.count, "native produced \(native.count) entries, golden has \(golden.count)")
+
+        for (index, pair) in zip(native, golden).enumerated() {
+            expectFieldsEqual(pair.0, pair.1, entry: index)
+        }
+    }
+
+    /// Field-by-field comparison. We can't use `==`: `BloodGlucose.==` only compares `dateString`,
+    /// so a direct comparison would pass even if `sgv`/`glucose`/`direction`/`id`/`type` differed —
+    /// exactly the coerced fields we must pin.
+    ///
+    /// `dateString` is compared at millisecond resolution rather than as an exact `Date`. The mapping
+    /// keeps the reading's full-precision date in memory, but only millisecond precision is ever
+    /// observable — it's what `date` pins and what the value serializes to — and Core Data's `Double`
+    /// round-trip perturbs sub-millisecond bits anyway, so an exact `Date` comparison would be flaky.
+    private func expectFieldsEqual(_ actual: BloodGlucose, _ expected: BloodGlucose, entry index: Int) {
+        #expect(actual.id == expected.id, "entry \(index): id \(actual.id) != \(expected.id)")
+        #expect(actual.legacyId == expected.legacyId, "entry \(index): legacyId")
+        #expect(
+            actual.sgv == expected.sgv,
+            "entry \(index): sgv \(actual.sgv.map(String.init) ?? "nil") != \(expected.sgv.map(String.init) ?? "nil")"
+        )
+        #expect(
+            actual.glucose == expected.glucose,
+            "entry \(index): glucose \(actual.glucose.map(String.init) ?? "nil") != \(expected.glucose.map(String.init) ?? "nil")"
+        )
+        #expect(actual.mbg == expected.mbg, "entry \(index): mbg")
+        #expect(
+            actual.direction == expected.direction,
+            "entry \(index): direction \(actual.direction?.rawValue ?? "nil") != \(expected.direction?.rawValue ?? "nil")"
+        )
+        #expect(actual.date == expected.date, "entry \(index): date \(actual.date) != \(expected.date)")
+        #expect(
+            Self.millisecondString(actual.dateString) == Self.millisecondString(expected.dateString),
+            "entry \(index): dateString \(Self.millisecondString(actual.dateString)) != \(Self.millisecondString(expected.dateString))"
+        )
+        #expect(actual.type == expected.type, "entry \(index): type \(actual.type ?? "nil") != \(expected.type ?? "nil")")
+        #expect(actual.unfiltered == expected.unfiltered, "entry \(index): unfiltered")
+        #expect(actual.filtered == expected.filtered, "entry \(index): filtered")
+        #expect(actual.noise == expected.noise, "entry \(index): noise")
+        #expect(actual.activationDate == expected.activationDate, "entry \(index): activationDate")
+        #expect(actual.sessionStartDate == expected.sessionStartDate, "entry \(index): sessionStartDate")
+        #expect(actual.transmitterID == expected.transmitterID, "entry \(index): transmitterID")
+    }
+
+    private static func millisecondString(_ date: Date) -> String {
+        Formatter.iso8601withFractionalSeconds.string(from: date)
+    }
+
+    private func nativeBloodGlucose(shouldSmoothGlucose: Bool) async throws -> [BloodGlucose] {
+        try await testContext.perform {
+            try self.fetchRowsNewestFirst().map {
+                OpenAPS.mapToBloodGlucose($0, shouldSmoothGlucose: shouldSmoothGlucose, roundingBehavior: Self.roundingBehavior)
+            }
+        }
+    }
+
+    /// Must be called from within `testContext.perform`.
+    private func fetchRowsNewestFirst() throws -> [GlucoseStored] {
+        let request = GlucoseStored.fetchRequest()
+        request.sortDescriptors = [NSSortDescriptor(key: "date", ascending: false)]
+        return try testContext.fetch(request)
+    }
+
+    private static let roundingBehavior = NSDecimalNumberHandler(
+        roundingMode: .plain,
+        scale: 0,
+        raiseOnExactness: false,
+        raiseOnOverflow: false,
+        raiseOnUnderflow: false,
+        raiseOnDivideByZero: false
+    )
+
+    // MARK: - Fixture helpers
+
+    private func insertGlucose(
+        glucose: Int16,
+        isManual: Bool,
+        date: Date,
+        smoothed: Decimal? = nil,
+        direction: String? = nil,
+        id: UUID
+    ) async {
+        await testContext.perform {
+            let object = GlucoseStored(context: self.testContext)
+            object.glucose = glucose
+            object.isManual = isManual
+            object.date = date
+            object.smoothedGlucose = smoothed.map { NSDecimalNumber(decimal: $0) }
+            object.direction = direction
+            object.id = id
+            try! self.testContext.save()
+        }
+    }
+
+    /// A fixed base timestamp (2023-11-14T22:13:20Z) so fixtures are deterministic and reproducible.
+    private func fixedDate(minutesAgo: Double, plusSeconds: Double = 0) -> Date {
+        Date(timeIntervalSince1970: 1_700_000_000 + plusSeconds - minutesAgo * 60)
+    }
+
+    private func uuid(_ n: Int) -> UUID {
+        UUID(uuidString: String(format: "00000000-0000-0000-0000-%012d", n))!
+    }
+}

+ 8 - 18
TrioTests/GlucoseSmoothingTests.swift

@@ -293,8 +293,8 @@ import Testing
 
 
         #expect(algorithmInput.count == 1, "Expected to process one glucose entry.")
         #expect(algorithmInput.count == 1, "Expected to process one glucose entry.")
         #expect(
         #expect(
-            algorithmInput.first?.glucose == 140,
-            "Algorithm should have used the smoothed glucose value (140), but used \(algorithmInput.first?.glucose ?? 0)."
+            algorithmInput.first?.sgv == 140,
+            "Algorithm should have used the smoothed glucose value (140), but used \(algorithmInput.first?.sgv ?? 0)."
         )
         )
     }
     }
 
 
@@ -305,8 +305,8 @@ import Testing
 
 
         #expect(algorithmInput.count == 1, "Expected to process one glucose entry.")
         #expect(algorithmInput.count == 1, "Expected to process one glucose entry.")
         #expect(
         #expect(
-            algorithmInput.first?.glucose == 150,
-            "Algorithm should have used the raw glucose value (150), but used \(algorithmInput.first?.glucose ?? 0)."
+            algorithmInput.first?.sgv == 150,
+            "Algorithm should have used the raw glucose value (150), but used \(algorithmInput.first?.sgv ?? 0)."
         )
         )
     }
     }
 
 
@@ -317,8 +317,8 @@ import Testing
 
 
         #expect(algorithmInput.count == 1, "Expected to process one glucose entry.")
         #expect(algorithmInput.count == 1, "Expected to process one glucose entry.")
         #expect(
         #expect(
-            algorithmInput.first?.glucose == 150,
-            "Algorithm should have fallen back to the raw glucose value (150), but used \(algorithmInput.first?.glucose ?? 0)."
+            algorithmInput.first?.sgv == 150,
+            "Algorithm should have fallen back to the raw glucose value (150), but used \(algorithmInput.first?.sgv ?? 0)."
         )
         )
     }
     }
 
 
@@ -336,22 +336,12 @@ import Testing
 
 
     // MARK: - Helpers
     // MARK: - Helpers
 
 
-    private func runFetchAndProcessGlucose(smoothGlucose: Bool) async throws -> [AlgorithmGlucose] {
-        let jsonString = try await openAPS.fetchAndProcessGlucose(
+    private func runFetchAndProcessGlucose(smoothGlucose: Bool) async throws -> [BloodGlucose] {
+        try await openAPS.fetchAndProcessGlucose(
             context: testContext,
             context: testContext,
             shouldSmoothGlucose: smoothGlucose,
             shouldSmoothGlucose: smoothGlucose,
             fetchLimit: 10
             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 {
     private func createGlucose(glucose: Int16, smoothed: Decimal?, isManual: Bool, date: Date) async {