فهرست منبع

Merge pull request #1311 from nightscout/remove-pump-event-json

[Part 5 of 6] Remove JSON encoding for pump events
Sam King 1 روز پیش
والد
کامیت
95b1c0179f

+ 50 - 149
Model/Helper/PumpEvent+helper.swift

@@ -134,189 +134,90 @@ extension NSPredicate {
     }
 }
 
-// Declare helper structs ("data transfer objects" = DTO) to utilize parsing a flattened pump history
-struct BolusDTO: Codable {
-    var id: String
-    var timestamp: String
-    var amount: Double
-    var isExternal: Bool
-    var isSMB: Bool
-    var duration: Int
-    var _type: String = EventType.bolus.rawValue
-}
-
-struct TempBasalDTO: Codable {
-    var id: String
-    var timestamp: String
-    var temp: String
-    var rate: Double
-    var _type: String = EventType.tempBasal.rawValue
-}
-
-struct TempBasalDurationDTO: Codable {
-    var id: String
-    var timestamp: String
-    var duration: Int
-    var _type: String = EventType.tempBasalDuration.rawValue
-
-    private enum CodingKeys: String, CodingKey {
-        case id
-        case timestamp
-        case duration = "duration (min)"
-        case _type
-    }
-}
-
-struct SuspendDTO: Codable {
-    var id: String
-    var timestamp: String
-    var _type: String = EventType.pumpSuspend.rawValue
-}
+// MARK: - Native mapping
 
-struct ResumeDTO: Codable {
-    var id: String
-    var timestamp: String
-    var _type: String = EventType.pumpResume.rawValue
-}
-
-struct RewindDTO: Codable {
-    var id: String
-    var timestamp: String
-    var _type: String = EventType.rewind.rawValue
-}
-
-struct PrimeDTO: Codable {
-    var id: String
-    var timestamp: String
-    var _type: String = EventType.prime.rawValue
-}
-
-// Mask distinct DTO subtypes with a common enum that conforms to Encodable
-enum PumpEventDTO: Encodable {
-    case bolus(BolusDTO)
-    case tempBasal(TempBasalDTO)
-    case tempBasalDuration(TempBasalDurationDTO)
-    case suspend(SuspendDTO)
-    case resume(ResumeDTO)
-    case rewind(RewindDTO)
-    case prime(PrimeDTO)
-
-    func encode(to encoder: Encoder) throws {
-        switch self {
-        case let .bolus(bolus):
-            try bolus.encode(to: encoder)
-        case let .tempBasal(tempBasal):
-            try tempBasal.encode(to: encoder)
-        case let .tempBasalDuration(tempBasalDuration):
-            try tempBasalDuration.encode(to: encoder)
-        case let .suspend(suspend):
-            try suspend.encode(to: encoder)
-        case let .resume(resume):
-            try resume.encode(to: encoder)
-        case let .rewind(rewind):
-            try rewind.encode(to: encoder)
-        case let .prime(prime):
-            try prime.encode(to: encoder)
-        }
-    }
-}
-
-// Extension with helper functions to map pump events to DTO objects via uniform masking enum
 extension PumpEventStored {
-    static let dateFormatter: ISO8601DateFormatter = {
-        let formatter = ISO8601DateFormatter()
-        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
-        return formatter
-    }()
+    /// Converts a stored pump event into the `PumpHistoryEvent`s the oref algorithm can use.
+    /// A temp basal yields a duration entry followed by a rate entry.
+    func toPumpHistoryEvents() -> [PumpHistoryEvent] {
+        var events: [PumpHistoryEvent] = []
+        if let bolus = toBolusPumpHistoryEvent() { events.append(bolus) }
+        if let duration = toTempBasalDurationPumpHistoryEvent() { events.append(duration) }
+        if let tempBasal = toTempBasalPumpHistoryEvent() { events.append(tempBasal) }
+        if let suspend = toSuspendPumpHistoryEvent() { events.append(suspend) }
+        if let resume = toResumePumpHistoryEvent() { events.append(resume) }
+        if let rewind = toRewindPumpHistoryEvent() { events.append(rewind) }
+        if let prime = toPrimePumpHistoryEvent() { events.append(prime) }
+        return events
+    }
 
-    func toBolusDTOEnum() -> PumpEventDTO? {
+    private func toBolusPumpHistoryEvent() -> PumpHistoryEvent? {
         guard let timestamp = timestamp, let bolus = bolus, let amount = bolus.amount else {
             return nil
         }
-
-        let bolusDTO = BolusDTO(
+        return PumpHistoryEvent(
             id: id ?? UUID().uuidString,
-            timestamp: PumpEventStored.dateFormatter.string(from: timestamp),
-            amount: amount.doubleValue,
-            isExternal: bolus.isExternal,
+            type: .bolus,
+            timestamp: timestamp,
+            amount: Decimal(algorithmValue: amount.doubleValue),
+            duration: 0,
             isSMB: bolus.isSMB,
-            duration: 0
+            isExternal: bolus.isExternal
         )
-        return .bolus(bolusDTO)
     }
 
-    func toTempBasalDTOEnum() -> PumpEventDTO? {
-        guard let id = id, let timestamp = timestamp, let tempBasal = tempBasal, let rate = tempBasal.rate else {
+    // The temp basal duration populates `durationMin`, not `duration`.
+    private func toTempBasalDurationPumpHistoryEvent() -> PumpHistoryEvent? {
+        guard let id = id, let timestamp = timestamp, let tempBasal = tempBasal else {
             return nil
         }
-
-        let tempBasalDTO = TempBasalDTO(
-            id: "_\(id)",
-            timestamp: PumpEventStored.dateFormatter.string(from: timestamp),
-            temp: tempBasal.tempType ?? "unknown",
-            rate: rate.doubleValue
+        return PumpHistoryEvent(
+            id: id,
+            type: .tempBasalDuration,
+            timestamp: timestamp,
+            durationMin: Int(tempBasal.duration)
         )
-        return .tempBasal(tempBasalDTO)
     }
 
-    func toTempBasalDurationDTOEnum() -> PumpEventDTO? {
-        guard let id = id, let timestamp = timestamp, let tempBasal = tempBasal else {
+    // The temp basal rate entry id is prefixed with "_". An unrecognized `tempType` maps to nil.
+    private func toTempBasalPumpHistoryEvent() -> PumpHistoryEvent? {
+        guard let id = id, let timestamp = timestamp, let tempBasal = tempBasal, let rate = tempBasal.rate else {
             return nil
         }
-
-        let tempBasalDurationDTO = TempBasalDurationDTO(
-            id: id,
-            timestamp: PumpEventStored.dateFormatter.string(from: timestamp),
-            duration: Int(tempBasal.duration)
+        return PumpHistoryEvent(
+            id: "_\(id)",
+            type: .tempBasal,
+            timestamp: timestamp,
+            rate: Decimal(algorithmValue: rate.doubleValue),
+            temp: tempBasal.tempType.flatMap { Trio.TempType(rawValue: $0) }
         )
-        return .tempBasalDuration(tempBasalDurationDTO)
     }
 
-    func toPumpSuspendDTO() -> PumpEventDTO? {
-        guard let id = id, let timestamp = timestamp, let type = type, type == EventType.pumpSuspend.rawValue else {
+    private func toSuspendPumpHistoryEvent() -> PumpHistoryEvent? {
+        guard let id = id, let timestamp = timestamp, type == EventType.pumpSuspend.rawValue else {
             return nil
         }
-
-        let suspendDTO = SuspendDTO(
-            id: id,
-            timestamp: PumpEventStored.dateFormatter.string(from: timestamp)
-        )
-        return .suspend(suspendDTO)
+        return PumpHistoryEvent(id: id, type: .pumpSuspend, timestamp: timestamp)
     }
 
-    func toPumpResumeDTO() -> PumpEventDTO? {
-        guard let id = id, let timestamp = timestamp, let type = type, type == EventType.pumpResume.rawValue else {
+    private func toResumePumpHistoryEvent() -> PumpHistoryEvent? {
+        guard let id = id, let timestamp = timestamp, type == EventType.pumpResume.rawValue else {
             return nil
         }
-
-        let resumeDTO = ResumeDTO(
-            id: id,
-            timestamp: PumpEventStored.dateFormatter.string(from: timestamp)
-        )
-        return .resume(resumeDTO)
+        return PumpHistoryEvent(id: id, type: .pumpResume, timestamp: timestamp)
     }
 
-    func toRewindDTO() -> PumpEventDTO? {
-        guard let id = id, let timestamp = timestamp, let type = type, type == EventType.rewind.rawValue else {
+    private func toRewindPumpHistoryEvent() -> PumpHistoryEvent? {
+        guard let id = id, let timestamp = timestamp, type == EventType.rewind.rawValue else {
             return nil
         }
-
-        let rewindDTO = RewindDTO(
-            id: id,
-            timestamp: PumpEventStored.dateFormatter.string(from: timestamp)
-        )
-        return .rewind(rewindDTO)
+        return PumpHistoryEvent(id: id, type: .rewind, timestamp: timestamp)
     }
 
-    func toPrimeDTO() -> PumpEventDTO? {
-        guard let id = id, let timestamp = timestamp, let type = type, type == EventType.prime.rawValue else {
+    private func toPrimePumpHistoryEvent() -> PumpHistoryEvent? {
+        guard let id = id, let timestamp = timestamp, type == EventType.prime.rawValue else {
             return nil
         }
-
-        let primeDTO = PrimeDTO(
-            id: id,
-            timestamp: PumpEventStored.dateFormatter.string(from: timestamp)
-        )
-        return .prime(primeDTO)
+        return PumpHistoryEvent(id: id, type: .prime, timestamp: timestamp)
     }
 }

+ 4 - 0
Trio.xcodeproj/project.pbxproj

@@ -831,6 +831,7 @@
 		DDDD0FFB2F4E22C000F9C645 /* GlucoseSmoothingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */; };
 		DDDD0FFD2F4E22C000F9C645 /* GlucoseNativeConversionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDD0FFC2F4E22C000F9C645 /* GlucoseNativeConversionTests.swift */; };
 		DDDD10A12F4E22C000F9C645 /* CarbsNativeConversionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDD10A02F4E22C000F9C645 /* CarbsNativeConversionTests.swift */; };
+		DDDD10B12F4E22C000F9C645 /* PumpHistoryNativeConversionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDDD10B02F4E22C000F9C645 /* PumpHistoryNativeConversionTests.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 */; };
@@ -1844,6 +1845,7 @@
 		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>"; };
 		DDDD10A02F4E22C000F9C645 /* CarbsNativeConversionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CarbsNativeConversionTests.swift; sourceTree = "<group>"; };
+		DDDD10B02F4E22C000F9C645 /* PumpHistoryNativeConversionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PumpHistoryNativeConversionTests.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>"; };
@@ -2980,6 +2982,7 @@
 				DDDD0FFA2F4E22C000F9C645 /* GlucoseSmoothingTests.swift */,
 				DDDD0FFC2F4E22C000F9C645 /* GlucoseNativeConversionTests.swift */,
 				DDDD10A02F4E22C000F9C645 /* CarbsNativeConversionTests.swift */,
+				DDDD10B02F4E22C000F9C645 /* PumpHistoryNativeConversionTests.swift */,
 				DDC6CA6C2DD90A2A0060EE25 /* LocalizationTests.swift */,
 				3B997DD22DC02AEF006B6BB2 /* JSONImporterData */,
 				BD8FC05C2D6618BE00B95AED /* BolusCalculatorTests */,
@@ -5645,6 +5648,7 @@
 				DDDD0FFB2F4E22C000F9C645 /* GlucoseSmoothingTests.swift in Sources */,
 				DDDD0FFD2F4E22C000F9C645 /* GlucoseNativeConversionTests.swift in Sources */,
 				DDDD10A12F4E22C000F9C645 /* CarbsNativeConversionTests.swift in Sources */,
+				DDDD10B12F4E22C000F9C645 /* PumpHistoryNativeConversionTests.swift in Sources */,
 				CEE9A65E2BBC9F6500EB5194 /* CalibrationsTests.swift in Sources */,
 				CA02000000000000000010C2 /* DeliveryLimitsSyncTests.swift in Sources */,
 				BD8FC0622D6619E600B95AED /* OverrideStorageTests.swift in Sources */,

+ 5 - 0
Trio/Sources/APS/Extensions/DecimalExtensions.swift

@@ -4,6 +4,11 @@ extension Decimal {
     func clamp(to pickerSetting: PickerSetting) -> Decimal {
         max(min(self, pickerSetting.max), pickerSetting.min)
     }
+
+    /// Converts a `Double` to a `Decimal` using JSON style conversion
+    init(algorithmValue value: Double) {
+        self = Decimal(string: value.description) ?? Decimal(value)
+    }
 }
 
 extension Collection where Element == Decimal {

+ 34 - 78
Trio/Sources/APS/OpenAPS/OpenAPS.swift

@@ -119,9 +119,9 @@ final class OpenAPS {
     private func parsePumpHistory(
         _ pumpHistoryObjectIDs: [NSManagedObjectID],
         simulatedBolusAmount: Decimal? = nil
-    ) async throws -> String {
-        // Return an empty JSON object if the list of object IDs is empty
-        guard !pumpHistoryObjectIDs.isEmpty else { return "{}" }
+    ) async throws -> [PumpHistoryEvent] {
+        // Empty history returns an empty array, which also drops any simulated bolus.
+        guard !pumpHistoryObjectIDs.isEmpty else { return [] }
 
         // Addresses https://github.com/nightscout/Trio/issues/898
         //
@@ -133,87 +133,49 @@ final class OpenAPS {
 
         // Execute all operations on the background context
         return await context.perform {
-            // Load and map pump events to DTOs
-            var dtos = self.loadAndMapPumpEvents(pumpHistoryObjectIDs, orphanedResumes: orphanedResumes)
+            // Load and map pump events to native algorithm models
+            var events = OpenAPS.nativePumpHistory(
+                pumpHistoryObjectIDs,
+                orphanedResumes: orphanedResumes,
+                from: self.context
+            )
 
-            // Optionally add the IOB as a DTO
+            // Optionally add the simulated bolus for the bolus-preview simulation
             if let simulatedBolusAmount = simulatedBolusAmount {
-                let simulatedBolusDTO = self.createSimulatedBolusDTO(simulatedBolusAmount: simulatedBolusAmount)
-                dtos.insert(simulatedBolusDTO, at: 0)
+                events.insert(self.createSimulatedBolusEvent(simulatedBolusAmount: simulatedBolusAmount), at: 0)
             }
 
-            // Convert the DTOs to JSON
-            return self.jsonConverter.convertToJSON(dtos)
+            return events
         }
     }
 
-    private func loadAndMapPumpEvents(
-        _ pumpHistoryObjectIDs: [NSManagedObjectID],
-        orphanedResumes: [NSManagedObjectID]
-    ) -> [PumpEventDTO] {
-        OpenAPS.loadAndMapPumpEvents(pumpHistoryObjectIDs, orphanedResumes: orphanedResumes, from: context)
-    }
-
-    /// Fetches and parses pump events, expose this as static and not private for testing
-    static func loadAndMapPumpEvents(
+    /// Fetches and maps pump events into `[PumpHistoryEvent]`, expose this as static and not private for testing
+    static func nativePumpHistory(
         _ pumpHistoryObjectIDs: [NSManagedObjectID],
         orphanedResumes: [NSManagedObjectID],
         from context: NSManagedObjectContext
-    ) -> [PumpEventDTO] {
+    ) -> [PumpHistoryEvent] {
         let orphanedSet = Set(orphanedResumes)
         let filteredObjectIds = pumpHistoryObjectIDs.filter { !orphanedSet.contains($0) }
-        // Load the pump events from the object IDs
         let pumpHistory: [PumpEventStored] = filteredObjectIds
             .compactMap { context.object(with: $0) as? PumpEventStored }
 
-        // Create the DTOs
-        let dtos: [PumpEventDTO] = pumpHistory.flatMap { event -> [PumpEventDTO] in
-            var eventDTOs: [PumpEventDTO] = []
-            if let bolusDTO = event.toBolusDTOEnum() {
-                eventDTOs.append(bolusDTO)
-            }
-            if let tempBasalDurationDTO = event.toTempBasalDurationDTOEnum() {
-                eventDTOs.append(tempBasalDurationDTO)
-            }
-            if let tempBasalDTO = event.toTempBasalDTOEnum() {
-                eventDTOs.append(tempBasalDTO)
-            }
-            if let pumpSuspendDTO = event.toPumpSuspendDTO() {
-                eventDTOs.append(pumpSuspendDTO)
-            }
-            if let pumpResumeDTO = event.toPumpResumeDTO() {
-                eventDTOs.append(pumpResumeDTO)
-            }
-            if let rewindDTO = event.toRewindDTO() {
-                eventDTOs.append(rewindDTO)
-            }
-            if let primeDTO = event.toPrimeDTO() {
-                eventDTOs.append(primeDTO)
-            }
-            return eventDTOs
-        }
-        return dtos
+        return pumpHistory.flatMap { $0.toPumpHistoryEvents() }
     }
 
-    private func createSimulatedBolusDTO(simulatedBolusAmount: Decimal) -> PumpEventDTO {
-        let oneSecondAgo = Calendar.current
-            .date(
-                byAdding: .second,
-                value: -1,
-                to: Date()
-            )! // adding -1s to the current Date ensures that oref actually uses the mock entry to calculate iob and not guard it away
-        let dateFormatted = PumpEventStored.dateFormatter.string(from: oneSecondAgo)
+    private func createSimulatedBolusEvent(simulatedBolusAmount: Decimal) -> PumpHistoryEvent {
 
-        let bolusDTO = BolusDTO(
+        // for the timestamp, subtract 1 second from now to ensure
+        // that the algorithm take this simulated bolus into account
+        return PumpHistoryEvent(
             id: UUID().uuidString,
-            timestamp: dateFormatted,
-            amount: Double(simulatedBolusAmount),
-            isExternal: false,
-            isSMB: true,
+            type: .bolus,
+            timestamp: Date().addingTimeInterval(-1),
+            amount: simulatedBolusAmount,
             duration: 0,
-            _type: "Bolus"
+            isSMB: true,
+            isExternal: false
         )
-        return .bolus(bolusDTO)
     }
 
     /// Detects a cold-start orphaned resume: returns the resume's object ID if it's an orphaned resume
@@ -303,7 +265,7 @@ final class OpenAPS {
 
         // Await the results of asynchronous tasks
         let (
-            pumpHistoryJSON,
+            pumpHistory,
             carbs,
             glucose,
             trioCustomOrefVariables,
@@ -326,7 +288,7 @@ final class OpenAPS {
 
         // Meal calculation
         let meal = try self.meal(
-            pumphistory: pumpHistoryJSON,
+            pumphistory: pumpHistory,
             profile: profile,
             basalProfile: basalProfile,
             clock: clock,
@@ -336,7 +298,7 @@ final class OpenAPS {
 
         // IOB calculation
         let iob = try self.iob(
-            pumphistory: pumpHistoryJSON,
+            pumphistory: pumpHistory,
             profile: profile,
             clock: clock,
             autosens: autosens.isEmpty ? .null : autosens
@@ -363,7 +325,6 @@ final class OpenAPS {
             meal: meal,
             microBolusAllowed: true,
             reservoir: reservoir,
-            pumpHistory: pumpHistoryJSON,
             preferences: preferences,
             basalProfile: basalProfile,
             trioCustomOrefVariables: trioCustomOrefVariables
@@ -475,7 +436,7 @@ final class OpenAPS {
         async let getTempTargets = loadFileFromStorageAsync(name: Settings.tempTargets)
 
         // Await the results of asynchronous tasks
-        let (pumpHistoryJSON, carbs, glucose, profile, basalProfile, tempTargets) = await (
+        let (pumpHistory, carbs, glucose, profile, basalProfile, tempTargets) = await (
             try parsePumpHistory(await pumpHistoryObjectIDs),
             try carbsFetch,
             try glucoseFetch,
@@ -487,7 +448,7 @@ final class OpenAPS {
         // Autosense
         let autosenseResult = try autosense(
             glucose: glucose,
-            pumpHistory: pumpHistoryJSON,
+            pumpHistory: pumpHistory,
             basalprofile: basalProfile,
             profile: profile,
             carbs: carbs,
@@ -616,16 +577,13 @@ final class OpenAPS {
     }
 
     private func iob(
-        pumphistory: JSON,
+        pumphistory: [PumpHistoryEvent],
         profile: JSON,
         clock: JSON,
         autosens: JSON
     ) throws -> RawJSON {
         // FIXME: For now we'll just remove duplicate suspends here (ISSUE-399)
-        var pumphistory = pumphistory
-        if let pumpHistoryArray = try? JSONBridge.pumpHistory(from: pumphistory) {
-            pumphistory = pumpHistoryArray.removingDuplicateSuspendResumeEvents().rawJSON
-        }
+        let pumphistory = pumphistory.removingDuplicateSuspendResumeEvents()
 
         let swiftResult = OpenAPSSwift
             .iob(pumphistory: pumphistory, profile: profile, clock: clock, autosens: autosens)
@@ -633,7 +591,7 @@ final class OpenAPS {
     }
 
     private func meal(
-        pumphistory: JSON,
+        pumphistory: [PumpHistoryEvent],
         profile: JSON,
         basalProfile: JSON,
         clock: JSON,
@@ -654,7 +612,7 @@ final class OpenAPS {
 
     private func autosense(
         glucose: [BloodGlucose],
-        pumpHistory: JSON,
+        pumpHistory: [PumpHistoryEvent],
         basalprofile: JSON,
         profile: JSON,
         carbs: [CarbsEntry],
@@ -682,7 +640,6 @@ final class OpenAPS {
         meal: JSON,
         microBolusAllowed: Bool,
         reservoir: JSON,
-        pumpHistory: JSON,
         preferences: JSON,
         basalProfile: JSON,
         trioCustomOrefVariables: JSON
@@ -697,7 +654,6 @@ final class OpenAPS {
             meal: meal,
             microBolusAllowed: microBolusAllowed,
             reservoir: reservoir,
-            pumpHistory: pumpHistory,
             preferences: preferences,
             basalProfile: basalProfile,
             trioCustomOrefVariables: trioCustomOrefVariables,

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

@@ -48,25 +48,6 @@ enum JSONBridge {
         try JSONBridge.from(string: from.rawJSON)
     }
 
-    static func pumpHistory(from: JSON) throws -> [PumpHistoryEvent] {
-        do {
-            return try JSONBridge.from(string: from.rawJSON)
-        } catch {
-            // see if we got an empty object "{}"
-            guard let data = from.rawJSON.data(using: .utf8) else {
-                throw error
-            }
-
-            if let parsedObject = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
-               parsedObject.isEmpty
-            {
-                return []
-            }
-
-            throw error
-        }
-    }
-
     static func profile(from: JSON) throws -> Profile {
         try JSONBridge.from(string: from.rawJSON)
     }

+ 5 - 10
Trio/Sources/APS/OpenAPSSwift/OpenAPSSwift.swift

@@ -34,7 +34,6 @@ struct OpenAPSSwift {
         meal: JSON,
         microBolusAllowed: Bool,
         reservoir: JSON,
-        pumpHistory: JSON,
         preferences: JSON,
         basalProfile: JSON,
         trioCustomOrefVariables: JSON,
@@ -48,7 +47,6 @@ struct OpenAPSSwift {
             let meal = try JSONBridge.computedCarbs(from: meal)
             let microBolusAllowed = microBolusAllowed
             let reservoir = Decimal(string: reservoir.rawJSON)
-            let pumpHistory = try JSONBridge.pumpHistory(from: pumpHistory)
             let preferences = try JSONBridge.preferences(from: preferences)
             let basalProfile = try JSONBridge.basalProfile(from: basalProfile)
             let trioCustomOrefVariables = try JSONBridge.trioCustomOrefVariables(from: trioCustomOrefVariables)
@@ -88,7 +86,7 @@ struct OpenAPSSwift {
     }
 
     static func meal(
-        pumphistory: JSON,
+        pumphistory: [PumpHistoryEvent],
         profile: JSON,
         basalProfile: JSON,
         clock: JSON,
@@ -96,13 +94,12 @@ struct OpenAPSSwift {
         glucose: [BloodGlucose]
     ) -> (OrefFunctionResult) {
         do {
-            let pumpHistory = try JSONBridge.pumpHistory(from: pumphistory)
             let profile = try JSONBridge.profile(from: profile)
             let basalProfile = try JSONBridge.basalProfile(from: basalProfile)
             let clock = try JSONBridge.clock(from: clock)
 
             let mealResult = try MealGenerator.generate(
-                pumpHistory: pumpHistory,
+                pumpHistory: pumphistory,
                 profile: profile,
                 basalProfile: basalProfile,
                 clock: clock,
@@ -116,15 +113,14 @@ struct OpenAPSSwift {
         }
     }
 
-    static func iob(pumphistory: JSON, profile: JSON, clock: JSON, autosens: JSON) -> (OrefFunctionResult) {
+    static func iob(pumphistory: [PumpHistoryEvent], profile: JSON, clock: JSON, autosens: JSON) -> (OrefFunctionResult) {
         do {
-            let pumpHistory = try JSONBridge.pumpHistory(from: pumphistory)
             let profile = try JSONBridge.profile(from: profile)
             let clock = try JSONBridge.clock(from: clock)
             let autosens = try JSONBridge.autosens(from: autosens)
 
             let iobResult = try IobGenerator.generate(
-                history: pumpHistory,
+                history: pumphistory,
                 profile: profile,
                 clock: clock,
                 autosens: autosens
@@ -138,7 +134,7 @@ struct OpenAPSSwift {
 
     static func autosense(
         glucose: [BloodGlucose],
-        pumpHistory: JSON,
+        pumpHistory: [PumpHistoryEvent],
         basalProfile: JSON,
         profile: JSON,
         carbs: [CarbsEntry],
@@ -147,7 +143,6 @@ struct OpenAPSSwift {
         includeDeviationsForTesting: Bool = false
     ) -> (OrefFunctionResult) {
         do {
-            let pumpHistory = try JSONBridge.pumpHistory(from: pumpHistory)
             let basalProfile = try JSONBridge.basalProfile(from: basalProfile)
             let profile = try JSONBridge.profile(from: profile)
             let tempTargets = try JSONBridge.tempTargets(from: tempTargets)

+ 3 - 8
Trio/Sources/APS/Storage/CarbsStorage.swift

@@ -371,11 +371,6 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
         }
     }
 
-    /// Converts a `Double` to a `Decimal` using JSON style conversion
-    static func algorithmDecimal(_ value: Double) -> Decimal {
-        Decimal(string: value.description) ?? Decimal(value)
-    }
-
     /// Converts CoreData stored carb entries into a struct that the oref algorithm can use
     static func mapToCarbsEntry(_ carbEntry: CarbEntryStored) -> CarbsEntry {
         // The old encode used `date ?? Date()` for both created_at and actualDate.
@@ -384,9 +379,9 @@ final class BaseCarbsStorage: CarbsStorage, Injectable {
             id: carbEntry.id?.uuidString,
             createdAt: date,
             actualDate: date,
-            carbs: algorithmDecimal(carbEntry.carbs),
-            fat: algorithmDecimal(carbEntry.fat),
-            protein: algorithmDecimal(carbEntry.protein),
+            carbs: Decimal(algorithmValue: carbEntry.carbs),
+            fat: Decimal(algorithmValue: carbEntry.fat),
+            protein: Decimal(algorithmValue: carbEntry.protein),
             note: nil,
             enteredBy: CarbsEntry.local,
             isFPU: carbEntry.isFPU,

+ 1 - 1
TrioTests/CarbsNativeConversionTests.swift

@@ -41,7 +41,7 @@ import Testing
     @Test("Fractional carbs/fat/protein keep clean decimal values") func testFractionalDecimals() async throws {
         // 33.33 as a Double is 33.32999999999999488; the old JSON round-trip recovered the clean
         // 33.33 (JSONEncoder writes the shortest round-trippable string). `Decimal(Double)` would
-        // leak the binary expansion, so `algorithmDecimal` must reproduce the clean value.
+        // leak the binary expansion, so `Decimal(algorithmValue:)` must reproduce the clean value.
         await insertCarb(carbs: 33.33, isFPU: true, date: fixedDate(minutesAgo: 0), fat: 5.5, protein: 3.2, id: uuid(1))
         await insertCarb(carbs: 12.5, isFPU: false, date: fixedDate(minutesAgo: 5), id: uuid(2))
 

+ 17 - 24
TrioTests/JSONImporterTests.swift

@@ -92,7 +92,7 @@ class BundleReference {}
         ) as? [PumpEventStored] ?? []
 
         let objectIds = allReadings.map(\.objectID)
-        let parsedHistory = OpenAPS.loadAndMapPumpEvents(objectIds, orphanedResumes: [], from: context)
+        let parsedHistory = OpenAPS.nativePumpHistory(objectIds, orphanedResumes: [], from: context)
 
         var bolusTotal = 0.0
         var bolusCount = 0
@@ -103,21 +103,21 @@ class BundleReference {}
         var suspendCount = 0
         var resumeCount = 0
         for event in parsedHistory {
-            switch event {
-            case let .bolus(bolus):
-                bolusTotal += bolus.amount
+            switch event.type {
+            case .bolus:
+                bolusTotal += event.amount.map { ($0 as NSDecimalNumber).doubleValue } ?? 0
                 bolusCount += 1
-                if bolus.isSMB {
+                if event.isSMB == true {
                     smbCount += 1
                 }
-            case let .tempBasal(tempBasal):
-                rateTotal += tempBasal.rate
+            case .tempBasal:
+                rateTotal += event.rate.map { ($0 as NSDecimalNumber).doubleValue } ?? 0
                 tempBasalCount += 1
-            case let .tempBasalDuration(tempBasalDuration):
-                durationTotal += tempBasalDuration.duration
-            case .suspend:
+            case .tempBasalDuration:
+                durationTotal += event.durationMin ?? 0
+            case .pumpSuspend:
                 suspendCount += 1
-            case .resume:
+            case .pumpResume:
                 resumeCount += 1
             default:
                 fatalError("unhandled pump event")
@@ -172,22 +172,15 @@ class BundleReference {}
         ) as? [PumpEventStored] ?? []
 
         let objectIds = allReadings.map(\.objectID)
-        let parsedHistory = OpenAPS.loadAndMapPumpEvents(objectIds, orphanedResumes: [], from: context)
+        let parsedHistory = OpenAPS.nativePumpHistory(objectIds, orphanedResumes: [], from: context)
 
         #expect(parsedHistory.count == 1)
 
-        let bolus: BolusDTO? = {
-            switch parsedHistory.first! {
-            case let .bolus(bolus):
-                return bolus
-            default:
-                return nil
-            }
-        }()
-
-        #expect(bolus != nil)
-        #expect(bolus!.isExternal)
-        #expect(bolus!.amount.isApproximatelyEqual(to: 0.88, epsilon: 0.01))
+        let bolus = parsedHistory.first
+        #expect(bolus?.type == .bolus)
+        #expect(bolus?.isExternal == true)
+        let amount = bolus?.amount.map { ($0 as NSDecimalNumber).doubleValue }
+        #expect(amount?.isApproximatelyEqual(to: 0.88, epsilon: 0.01) == true)
     }
 
     @Test("Import carb history with value checks") func testImportCarbHistoryDetails() async throws {

+ 316 - 0
TrioTests/PumpHistoryNativeConversionTests.swift

@@ -0,0 +1,316 @@
+import CoreData
+import Foundation
+import Testing
+
+@testable import Trio
+
+/// Golden tests certifying that the native `PumpEventStored` → `[PumpHistoryEvent]` mapping
+/// (`OpenAPS.nativePumpHistory` / `PumpEventStored.toPumpHistoryEvents`) reproduces, field for
+/// field, the pump history the algorithm used to receive through the old JSON round-trip
+/// (`PumpEventStored` → `[PumpEventDTO]` → JSON → `JSONBridge.pumpHistory`) — with one deliberate
+/// change: a nil `tempType` becomes `temp = nil` instead of throwing during decode.
+@Suite("Pump History Native Conversion Tests", .serialized) struct PumpHistoryNativeConversionTests {
+    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("A bolus maps identically") func testBolus() async throws {
+        let ids = [
+            try await insertBolus(id: uuid(1), date: fixedDate(0), amount: 2.5, isSMB: true, isExternal: false)
+        ]
+        try await assertNativeMatchesGolden(ids, golden: [
+            PumpHistoryEvent(
+                id: uuid(1),
+                type: .bolus,
+                timestamp: fixedDate(0),
+                amount: dec("2.5"),
+                duration: 0,
+                isSMB: true,
+                isExternal: false
+            )
+        ])
+    }
+
+    @Test("An external, non-SMB bolus maps identically") func testExternalBolus() async throws {
+        let ids = [
+            try await insertBolus(id: uuid(1), date: fixedDate(0), amount: 0.88, isSMB: false, isExternal: true)
+        ]
+        // 0.88 stored as a Core Data Decimal round-trips through the lossy Double hop to
+        // 0.8800000000000001; the golden freezes that coerced value.
+        try await assertNativeMatchesGolden(ids, golden: [
+            PumpHistoryEvent(
+                id: uuid(1),
+                type: .bolus,
+                timestamp: fixedDate(0),
+                amount: dec("0.8800000000000001"),
+                duration: 0,
+                isSMB: false,
+                isExternal: true
+            )
+        ])
+    }
+
+    @Test("A temp basal emits a duration entry then a rate entry, both identical") func testTempBasal() async throws {
+        let ids = [
+            try await insertTempBasal(id: uuid(1), date: fixedDate(0), rate: 0.85, durationMinutes: 30, tempType: "absolute")
+        ]
+        let native = try await nativeEvents(ids)
+        // Exactly two entries, ordered duration-then-rate.
+        #expect(native.count == 2)
+        #expect(native.first?.type == .tempBasalDuration)
+        #expect(native.first?.durationMin == 30)
+        #expect(native.first?.duration == nil, "the duration entry uses durationMin, never duration")
+        #expect(native.last?.type == .tempBasal)
+        #expect(native.last?.id == "_\(uuid(1))", "the rate entry id is prefixed with an underscore")
+        #expect(native.last?.temp == .absolute)
+        try await assertNativeMatchesGolden(ids, golden: [
+            PumpHistoryEvent(id: uuid(1), type: .tempBasalDuration, timestamp: fixedDate(0), durationMin: 30),
+            PumpHistoryEvent(id: "_\(uuid(1))", type: .tempBasal, timestamp: fixedDate(0), rate: dec("0.85"), temp: .absolute)
+        ])
+    }
+
+    @Test("A percent temp basal maps identically") func testPercentTempBasal() async throws {
+        let ids = [
+            try await insertTempBasal(id: uuid(1), date: fixedDate(0), rate: 1.5, durationMinutes: 45, tempType: "percent")
+        ]
+        try await assertNativeMatchesGolden(ids, golden: [
+            PumpHistoryEvent(id: uuid(1), type: .tempBasalDuration, timestamp: fixedDate(0), durationMin: 45),
+            PumpHistoryEvent(id: "_\(uuid(1))", type: .tempBasal, timestamp: fixedDate(0), rate: dec("1.5"), temp: .percent)
+        ])
+    }
+
+    @Test("A temp basal with a nil rate emits only the duration entry") func testTempBasalNilRate() async throws {
+        let ids = [
+            try await insertTempBasal(id: uuid(1), date: fixedDate(0), rate: nil, durationMinutes: 30, tempType: "absolute")
+        ]
+        try await assertNativeMatchesGolden(ids, golden: [
+            PumpHistoryEvent(id: uuid(1), type: .tempBasalDuration, timestamp: fixedDate(0), durationMin: 30)
+        ])
+    }
+
+    @Test("Suspend, resume, rewind and prime map identically") func testStatusEvents() async throws {
+        let ids = [
+            try await insertStatusEvent(id: uuid(1), date: fixedDate(0), type: .pumpSuspend),
+            try await insertStatusEvent(id: uuid(2), date: fixedDate(1), type: .pumpResume),
+            try await insertStatusEvent(id: uuid(3), date: fixedDate(2), type: .rewind),
+            try await insertStatusEvent(id: uuid(4), date: fixedDate(3), type: .prime)
+        ]
+        try await assertNativeMatchesGolden(ids, golden: [
+            PumpHistoryEvent(id: uuid(1), type: .pumpSuspend, timestamp: fixedDate(0)),
+            PumpHistoryEvent(id: uuid(2), type: .pumpResume, timestamp: fixedDate(1)),
+            PumpHistoryEvent(id: uuid(3), type: .rewind, timestamp: fixedDate(2)),
+            PumpHistoryEvent(id: uuid(4), type: .prime, timestamp: fixedDate(3))
+        ])
+    }
+
+    @Test("A mixed sequence maps identically") func testMixedSequence() async throws {
+        let ids = [
+            try await insertBolus(id: uuid(1), date: fixedDate(0), amount: 1.0, isSMB: true, isExternal: false),
+            try await insertTempBasal(id: uuid(2), date: fixedDate(1), rate: 0.7, durationMinutes: 30, tempType: "absolute"),
+            try await insertStatusEvent(id: uuid(3), date: fixedDate(2), type: .pumpSuspend),
+            try await insertStatusEvent(id: uuid(4), date: fixedDate(3), type: .pumpResume),
+            try await insertBolus(id: uuid(5), date: fixedDate(4), amount: 0.05, isSMB: true, isExternal: false)
+        ]
+        try await assertNativeMatchesGolden(ids, golden: [
+            PumpHistoryEvent(
+                id: uuid(1),
+                type: .bolus,
+                timestamp: fixedDate(0),
+                amount: dec("1.0"),
+                duration: 0,
+                isSMB: true,
+                isExternal: false
+            ),
+            PumpHistoryEvent(id: uuid(2), type: .tempBasalDuration, timestamp: fixedDate(1), durationMin: 30),
+            PumpHistoryEvent(id: "_\(uuid(2))", type: .tempBasal, timestamp: fixedDate(1), rate: dec("0.7"), temp: .absolute),
+            PumpHistoryEvent(id: uuid(3), type: .pumpSuspend, timestamp: fixedDate(2)),
+            PumpHistoryEvent(id: uuid(4), type: .pumpResume, timestamp: fixedDate(3)),
+            PumpHistoryEvent(
+                id: uuid(5),
+                type: .bolus,
+                timestamp: fixedDate(4),
+                amount: dec("0.05"),
+                duration: 0,
+                isSMB: true,
+                isExternal: false
+            )
+        ])
+    }
+
+    @Test("Orphaned resumes are filtered") func testOrphanedResumeFiltering() async throws {
+        let bolus = try await insertBolus(id: uuid(1), date: fixedDate(0), amount: 1.0, isSMB: true, isExternal: false)
+        let orphanedResume = try await insertStatusEvent(id: uuid(2), date: fixedDate(1), type: .pumpResume)
+        let ids = [bolus, orphanedResume]
+
+        try await assertNativeMatchesGolden(ids, orphanedResumes: [orphanedResume], golden: [
+            PumpHistoryEvent(
+                id: uuid(1),
+                type: .bolus,
+                timestamp: fixedDate(0),
+                amount: dec("1.0"),
+                duration: 0,
+                isSMB: true,
+                isExternal: false
+            )
+        ])
+    }
+
+    @Test("Empty history maps to an empty array") func testEmpty() async throws {
+        try await assertNativeMatchesGolden([], golden: [])
+    }
+
+    // MARK: - The one deliberate behavioral change
+
+    @Test("A nil tempType becomes temp = nil instead of throwing") func testNilTempTypeIsRobust() async throws {
+        let ids = [
+            try await insertTempBasal(id: uuid(1), date: fixedDate(0), rate: 0.85, durationMinutes: 30, tempType: nil)
+        ]
+        // The old JSON path emitted the string "unknown" for a nil tempType, which threw while
+        // decoding. Native emits both entries and leaves temp nil instead.
+        try await assertNativeMatchesGolden(ids, golden: [
+            PumpHistoryEvent(id: uuid(1), type: .tempBasalDuration, timestamp: fixedDate(0), durationMin: 30),
+            PumpHistoryEvent(id: "_\(uuid(1))", type: .tempBasal, timestamp: fixedDate(0), rate: dec("0.85"), temp: nil)
+        ])
+    }
+
+    // MARK: - Comparison helpers
+
+    /// Asserts the native mapping reproduces the frozen golden `[PumpHistoryEvent]`.
+    private func assertNativeMatchesGolden(
+        _ objectIDs: [NSManagedObjectID],
+        orphanedResumes: [NSManagedObjectID] = [],
+        golden: [PumpHistoryEvent]
+    ) async throws {
+        let native = try await nativeEvents(objectIDs, orphanedResumes: orphanedResumes)
+
+        #expect(native.count == golden.count, "native produced \(native.count) events, golden has \(golden.count)")
+
+        for (index, pair) in zip(native, golden).enumerated() {
+            expectFieldsEqual(pair.0, pair.1, event: index)
+        }
+    }
+
+    private func nativeEvents(
+        _ objectIDs: [NSManagedObjectID],
+        orphanedResumes: [NSManagedObjectID] = []
+    ) async throws -> [PumpHistoryEvent] {
+        try await testContext.perform {
+            OpenAPS.nativePumpHistory(objectIDs, orphanedResumes: orphanedResumes, from: self.testContext)
+        }
+    }
+
+    /// Field-by-field comparison, comparing `timestamp` at millisecond resolution.
+    private func expectFieldsEqual(_ actual: PumpHistoryEvent, _ expected: PumpHistoryEvent, event index: Int) {
+        #expect(actual.id == expected.id, "event \(index): id \(actual.id) != \(expected.id)")
+        #expect(actual.type == expected.type, "event \(index): type \(actual.type) != \(expected.type)")
+        #expect(
+            Self.millisecondString(actual.timestamp) == Self.millisecondString(expected.timestamp),
+            "event \(index): timestamp mismatch"
+        )
+        #expect(actual.amount == expected.amount, "event \(index): amount \(desc(actual.amount)) != \(desc(expected.amount))")
+        #expect(
+            actual.duration == expected.duration,
+            "event \(index): duration \(desc(actual.duration)) != \(desc(expected.duration))"
+        )
+        #expect(
+            actual.durationMin == expected.durationMin,
+            "event \(index): durationMin \(desc(actual.durationMin)) != \(desc(expected.durationMin))"
+        )
+        #expect(actual.rate == expected.rate, "event \(index): rate \(desc(actual.rate)) != \(desc(expected.rate))")
+        #expect(actual.temp == expected.temp, "event \(index): temp \(desc(actual.temp)) != \(desc(expected.temp))")
+        #expect(actual.isSMB == expected.isSMB, "event \(index): isSMB \(desc(actual.isSMB)) != \(desc(expected.isSMB))")
+        #expect(
+            actual.isExternal == expected.isExternal,
+            "event \(index): isExternal \(desc(actual.isExternal)) != \(desc(expected.isExternal))"
+        )
+    }
+
+    private func desc<T>(_ value: T?) -> String { value.map { "\($0)" } ?? "nil" }
+
+    /// Builds a `Decimal` from its string form, matching `Decimal(algorithmValue:)`.
+    private func dec(_ string: String) -> Decimal { Decimal(string: string) ?? .zero }
+
+    private static func millisecondString(_ date: Date) -> String {
+        Formatter.iso8601withFractionalSeconds.string(from: date)
+    }
+
+    // MARK: - Fixture helpers
+
+    private func insertBolus(
+        id: String,
+        date: Date,
+        amount: Double,
+        isSMB: Bool,
+        isExternal: Bool
+    ) async throws -> NSManagedObjectID {
+        try await testContext.perform {
+            let event = PumpEventStored(context: self.testContext)
+            event.id = id
+            event.timestamp = date
+            event.type = PumpEventStored.EventType.bolus.rawValue
+
+            let bolus = BolusStored(context: self.testContext)
+            bolus.amount = NSDecimalNumber(value: amount)
+            bolus.isSMB = isSMB
+            bolus.isExternal = isExternal
+            bolus.pumpEvent = event
+
+            try self.testContext.save()
+            return event.objectID
+        }
+    }
+
+    private func insertTempBasal(
+        id: String,
+        date: Date,
+        rate: Double?,
+        durationMinutes: Int,
+        tempType: String?
+    ) async throws -> NSManagedObjectID {
+        try await testContext.perform {
+            let event = PumpEventStored(context: self.testContext)
+            event.id = id
+            event.timestamp = date
+            event.type = PumpEventStored.EventType.tempBasal.rawValue
+
+            let tempBasal = TempBasalStored(context: self.testContext)
+            tempBasal.rate = rate.map { NSDecimalNumber(value: $0) }
+            tempBasal.duration = Int16(durationMinutes)
+            tempBasal.tempType = tempType
+            tempBasal.pumpEvent = event
+
+            try self.testContext.save()
+            return event.objectID
+        }
+    }
+
+    private func insertStatusEvent(
+        id: String,
+        date: Date,
+        type: PumpEventStored.EventType
+    ) async throws -> NSManagedObjectID {
+        try await testContext.perform {
+            let event = PumpEventStored(context: self.testContext)
+            event.id = id
+            event.timestamp = date
+            event.type = type.rawValue
+            try self.testContext.save()
+            return event.objectID
+        }
+    }
+
+    /// A fixed base timestamp on whole seconds so fixtures are deterministic.
+    private func fixedDate(_ minutesAgo: Double) -> Date {
+        Date(timeIntervalSince1970: 1_700_000_000 - minutesAgo * 60)
+    }
+
+    private func uuid(_ n: Int) -> String {
+        String(format: "00000000-0000-0000-0000-%012d", n)
+    }
+}