Просмотр исходного кода

Merge pull request #485 from nightscout/determination-data-migration

[Part 4 of 6] Determination migration for importing 0.2.x JSON entries to CoreData
Sam King 1 год назад
Родитель
Сommit
65e87864f1

+ 4 - 0
Model/Helper/NSPredicates.swift

@@ -125,6 +125,10 @@ extension NSPredicate {
         NSPredicate(format: "date >= %@ AND date <= %@", start as NSDate, end as NSDate)
     }
 
+    static func predicateForDeliverAtBetween(start: Date, end: Date) -> NSPredicate {
+        NSPredicate(format: "deliverAt >= %@ AND deliverAt <= %@", start as NSDate, end as NSDate)
+    }
+
     static func predicateForTimestampBetween(start: Date, end: Date) -> NSPredicate {
         NSPredicate(format: "timestamp >= %@ AND timestamp <= %@", start as NSDate, end as NSDate)
     }

+ 329 - 0
Model/JSONImporter.swift

@@ -9,6 +9,19 @@ enum JSONImporterError: Error {
     case suspendResumePumpEventMismatch
     case duplicatePumpEvents
     case missingCarbsValueInCarbEntry
+    case missingRequiredPropertyInDetermination(String)
+    case invalidDeterminationReason
+
+    var errorDescription: String? {
+        switch self {
+        case let .missingRequiredPropertyInDetermination(field):
+            return "Missing required property: \(field)"
+        case .invalidDeterminationReason:
+            return "Determination reason cannot be empty!"
+        default:
+            return nil
+        }
+    }
 }
 
 // MARK: - JSONImporter Class
@@ -94,6 +107,25 @@ class JSONImporter {
         return Set(allCarbEntryDates.compactMap(\.date))
     }
 
+    /// Retrieves the set of dates for all oref determinations currently stored in CoreData.
+    ///
+    /// - Parameters:
+    ///   - start: the start to fetch from; inclusive
+    ///   - end: the end date to fetch to; inclusive
+    /// - Returns: A set of dates corresponding to existing determinations.
+    /// - Throws: An error if the fetch operation fails.
+    private func fetchDeterminationDates(start: Date, end: Date) async throws -> Set<Date> {
+        let determinations = try await coreDataStack.fetchEntitiesAsync(
+            ofType: OrefDetermination.self,
+            onContext: context,
+            predicate: .predicateForDeliverAtBetween(start: start, end: end),
+            key: "deliverAt",
+            ascending: false
+        ) as? [OrefDetermination] ?? []
+
+        return Set(determinations.compactMap(\.deliverAt))
+    }
+
     /// Imports glucose history from a JSON file into CoreData.
     ///
     /// The function reads glucose data from the provided JSON file and stores new entries
@@ -266,6 +298,63 @@ class JSONImporter {
             try self.context.save()
         }
     }
+
+    /// Imports oref determination from a JSON file into CoreData.
+    ///
+    /// The function reads oref determination data from the provided JSON file and stores new entries
+    /// in CoreData, skipping entries with dates that already exist in the database.
+    ///
+    /// - Parameters:
+    ///   - url: The URL of the JSON file containing determination data.
+    /// - Throws:
+    ///   - JSONImporterError.missingGlucoseValueInGlucoseEntry if a glucose entry is missing a value.
+    ///   - An error if the file cannot be read or decoded.
+    ///   - An error if the CoreData operation fails.
+    func importOrefDetermination(enactedUrl: URL, suggestedUrl: URL, now: Date) async throws {
+        let twentyFourHoursAgo = now - 24.hours.timeInterval
+        let enactedDetermination: Determination = try readJsonFile(url: enactedUrl)
+        let suggestedDetermination: Determination = try readJsonFile(url: suggestedUrl)
+        let existingDates = try await fetchDeterminationDates(start: twentyFourHoursAgo, end: now)
+
+        /// Helper function to check if entries are from within the last 24 hours that do not yet exist in Core Data
+        func checkDeterminationDate(_ date: Date) -> Bool {
+            date >= twentyFourHoursAgo && date <= now && !existingDates.contains(date)
+        }
+
+        guard let enactedDeliverAt = enactedDetermination.deliverAt,
+              let suggestedDeliverAt = suggestedDetermination.deliverAt
+        else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("deliverAt")
+        }
+
+        guard checkDeterminationDate(enactedDeliverAt), checkDeterminationDate(suggestedDeliverAt) else {
+            return
+        }
+
+        try enactedDetermination.checkForRequiredFields()
+        try suggestedDetermination.checkForRequiredFields()
+
+        // Create a background context for batch processing
+        let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
+        backgroundContext.parent = context
+
+        try await backgroundContext.perform {
+            /// We know both determination entries are from within last 24 hrs via `checkDeterminationDate()` in the earlier `guard` clause
+            /// If their `deliverAt` does not match, and if `suggestedDeliverAt` is newer, it is worth storing them both, as that represents
+            /// a more recent algorithm run that did not cause a dosing enactment, e.g., a carb entry or a manual bolus.
+            if suggestedDeliverAt > enactedDeliverAt {
+                try suggestedDetermination.store(in: backgroundContext)
+            }
+
+            try enactedDetermination.store(in: backgroundContext)
+
+            try backgroundContext.save()
+        }
+
+        try await context.perform {
+            try self.context.save()
+        }
+    }
 }
 
 // MARK: - Extension for Specific Import Functions
@@ -412,8 +501,248 @@ extension CarbsEntry: Codable {
     }
 }
 
+/// Extension to support decoding `Determination` entries with misspelled keys from external JSON sources.
+///
+/// Some legacy or third-party tools occasionally serialize the `received` property as `"recieved"`
+/// (misspelled) instead of the correct `"received"`. To prevent decoding failures or data loss,
+/// this custom decoder attempts to decode from `"received"` first, then falls back to `"recieved"`
+/// if necessary.
+///
+/// Encoding always uses the correct `"received"` key to ensure consistent, standards-compliant output.
+///
+/// This improves resilience and ensures compatibility with imported loop history, simulations,
+/// or devicestatus artifacts that may contain typos in their keys.
+extension Determination: Codable {
+    private enum CodingKeys: String, CodingKey {
+        case id
+        case reason
+        case units
+        case insulinReq
+        case eventualBG
+        case sensitivityRatio
+        case rate
+        case duration
+        case iob = "IOB"
+        case cob = "COB"
+        case predictions = "predBGs"
+        case deliverAt
+        case carbsReq
+        case temp
+        case bg
+        case reservoir
+        case timestamp
+        case isf = "ISF"
+        case current_target
+        case tdd = "TDD"
+        case insulinForManualBolus
+        case manualBolusErrorString
+        case minDelta
+        case expectedDelta
+        case minGuardBG
+        case minPredBG
+        case threshold
+        case carbRatio = "CR"
+        case received
+        case receivedAlt = "recieved"
+    }
+
+    init(from decoder: Decoder) throws {
+        let container = try decoder.container(keyedBy: CodingKeys.self)
+
+        id = try container.decodeIfPresent(UUID.self, forKey: .id)
+        reason = try container.decode(String.self, forKey: .reason)
+        units = try container.decodeIfPresent(Decimal.self, forKey: .units)
+        insulinReq = try container.decodeIfPresent(Decimal.self, forKey: .insulinReq)
+        eventualBG = try container.decodeIfPresent(Int.self, forKey: .eventualBG)
+        sensitivityRatio = try container.decodeIfPresent(Decimal.self, forKey: .sensitivityRatio)
+        rate = try container.decodeIfPresent(Decimal.self, forKey: .rate)
+        duration = try container.decodeIfPresent(Decimal.self, forKey: .duration)
+        iob = try container.decodeIfPresent(Decimal.self, forKey: .iob)
+        cob = try container.decodeIfPresent(Decimal.self, forKey: .cob)
+        predictions = try container.decodeIfPresent(Predictions.self, forKey: .predictions)
+        deliverAt = try container.decodeIfPresent(Date.self, forKey: .deliverAt)
+        carbsReq = try container.decodeIfPresent(Decimal.self, forKey: .carbsReq)
+        temp = try container.decodeIfPresent(TempType.self, forKey: .temp)
+        bg = try container.decodeIfPresent(Decimal.self, forKey: .bg)
+        reservoir = try container.decodeIfPresent(Decimal.self, forKey: .reservoir)
+        timestamp = try container.decodeIfPresent(Date.self, forKey: .timestamp)
+        isf = try container.decodeIfPresent(Decimal.self, forKey: .isf)
+        current_target = try container.decodeIfPresent(Decimal.self, forKey: .current_target)
+        tdd = try container.decodeIfPresent(Decimal.self, forKey: .tdd)
+        insulinForManualBolus = try container.decodeIfPresent(Decimal.self, forKey: .insulinForManualBolus)
+        manualBolusErrorString = try container.decodeIfPresent(Decimal.self, forKey: .manualBolusErrorString)
+        minDelta = try container.decodeIfPresent(Decimal.self, forKey: .minDelta)
+        expectedDelta = try container.decodeIfPresent(Decimal.self, forKey: .expectedDelta)
+        minGuardBG = try container.decodeIfPresent(Decimal.self, forKey: .minGuardBG)
+        minPredBG = try container.decodeIfPresent(Decimal.self, forKey: .minPredBG)
+        threshold = try container.decodeIfPresent(Decimal.self, forKey: .threshold)
+        carbRatio = try container.decodeIfPresent(Decimal.self, forKey: .carbRatio)
+
+        // Handle both spellings of "received"
+        if let value = try container.decodeIfPresent(Bool.self, forKey: .received) {
+            received = value
+        } else if let fallback = try container.decodeIfPresent(Bool.self, forKey: .receivedAlt) {
+            received = fallback
+        } else {
+            received = nil
+        }
+    }
+
+    func encode(to encoder: Encoder) throws {
+        var container = encoder.container(keyedBy: CodingKeys.self)
+
+        try container.encodeIfPresent(id, forKey: .id)
+        try container.encode(reason, forKey: .reason)
+        try container.encodeIfPresent(units, forKey: .units)
+        try container.encodeIfPresent(insulinReq, forKey: .insulinReq)
+        try container.encodeIfPresent(eventualBG, forKey: .eventualBG)
+        try container.encodeIfPresent(sensitivityRatio, forKey: .sensitivityRatio)
+        try container.encodeIfPresent(rate, forKey: .rate)
+        try container.encodeIfPresent(duration, forKey: .duration)
+        try container.encodeIfPresent(iob, forKey: .iob)
+        try container.encodeIfPresent(cob, forKey: .cob)
+        try container.encodeIfPresent(predictions, forKey: .predictions)
+        try container.encodeIfPresent(deliverAt, forKey: .deliverAt)
+        try container.encodeIfPresent(carbsReq, forKey: .carbsReq)
+        try container.encodeIfPresent(temp, forKey: .temp)
+        try container.encodeIfPresent(bg, forKey: .bg)
+        try container.encodeIfPresent(reservoir, forKey: .reservoir)
+        try container.encodeIfPresent(timestamp, forKey: .timestamp)
+        try container.encodeIfPresent(isf, forKey: .isf)
+        try container.encodeIfPresent(current_target, forKey: .current_target)
+        try container.encodeIfPresent(tdd, forKey: .tdd)
+        try container.encodeIfPresent(insulinForManualBolus, forKey: .insulinForManualBolus)
+        try container.encodeIfPresent(manualBolusErrorString, forKey: .manualBolusErrorString)
+        try container.encodeIfPresent(minDelta, forKey: .minDelta)
+        try container.encodeIfPresent(expectedDelta, forKey: .expectedDelta)
+        try container.encodeIfPresent(minGuardBG, forKey: .minGuardBG)
+        try container.encodeIfPresent(minPredBG, forKey: .minPredBG)
+        try container.encodeIfPresent(threshold, forKey: .threshold)
+        try container.encodeIfPresent(carbRatio, forKey: .carbRatio)
+        try container.encodeIfPresent(received, forKey: .received) // always encode the correct spelling
+    }
+
+    func checkForRequiredFields() throws {
+        guard let deliverAt = deliverAt else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("deliverAt")
+        }
+        guard let timestamp = timestamp else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("timestamp")
+        }
+        guard reason.isNotEmpty else {
+            throw JSONImporterError.invalidDeterminationReason
+        }
+        guard let insulinReq = insulinReq else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("insulinReq")
+        }
+        guard let currentTarget = current_target else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("current_target")
+        }
+        guard let reservoir = reservoir else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("reservoir")
+        }
+        guard let threshold = threshold else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("threshold")
+        }
+        guard let iob = iob else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("IOB")
+        }
+        guard let isf = isf else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("ISF")
+        }
+        guard let manualBolusErrorString = manualBolusErrorString else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("manualBolusErrorString")
+        }
+        guard let insulinForManualBolus = insulinForManualBolus else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("insulinForManualBolus")
+        }
+        guard let cob = cob else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("COB")
+        }
+        guard let tdd = tdd else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("TDD")
+        }
+        guard let bg = bg else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("bg")
+        }
+        guard let minDelta = minDelta else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("minDelta")
+        }
+        guard let eventualBG = eventualBG else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("eventualBG")
+        }
+        guard let sensitivityRatio = sensitivityRatio else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("sensitivityRatio")
+        }
+        guard let temp = temp else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("temp")
+        }
+        guard let expectedDelta = expectedDelta else {
+            throw JSONImporterError.missingRequiredPropertyInDetermination("expectedDelta")
+        }
+    }
+
+    /// Helper function to convert `Determination` to `OrefDetermination` while importing JSON glucose entries
+    func store(in context: NSManagedObjectContext) throws {
+        let newOrefDetermination = OrefDetermination(context: context)
+        newOrefDetermination.id = UUID()
+        newOrefDetermination.insulinSensitivity = decimalToNSDecimalNumber(isf)
+        newOrefDetermination.currentTarget = decimalToNSDecimalNumber(current_target)
+        newOrefDetermination.eventualBG = eventualBG.map(NSDecimalNumber.init)
+        newOrefDetermination.deliverAt = deliverAt
+        newOrefDetermination.timestamp = timestamp
+        newOrefDetermination.enacted = received ?? false
+        newOrefDetermination.insulinForManualBolus = decimalToNSDecimalNumber(insulinForManualBolus)
+        newOrefDetermination.carbRatio = decimalToNSDecimalNumber(carbRatio)
+        newOrefDetermination.glucose = decimalToNSDecimalNumber(bg)
+        newOrefDetermination.reservoir = decimalToNSDecimalNumber(reservoir)
+        newOrefDetermination.insulinReq = decimalToNSDecimalNumber(insulinReq)
+        newOrefDetermination.temp = temp?.rawValue ?? "absolute"
+        newOrefDetermination.rate = decimalToNSDecimalNumber(rate)
+        newOrefDetermination.reason = reason
+        newOrefDetermination.duration = decimalToNSDecimalNumber(duration)
+        newOrefDetermination.iob = decimalToNSDecimalNumber(iob)
+        newOrefDetermination.threshold = decimalToNSDecimalNumber(threshold)
+        newOrefDetermination.minDelta = decimalToNSDecimalNumber(minDelta)
+        newOrefDetermination.sensitivityRatio = decimalToNSDecimalNumber(sensitivityRatio)
+        newOrefDetermination.expectedDelta = decimalToNSDecimalNumber(expectedDelta)
+        newOrefDetermination.cob = Int16(Int(cob ?? 0))
+        newOrefDetermination.manualBolusErrorString = decimalToNSDecimalNumber(manualBolusErrorString)
+        newOrefDetermination.smbToDeliver = units.map { NSDecimalNumber(decimal: $0) }
+        newOrefDetermination.carbsRequired = Int16(Int(carbsReq ?? 0))
+        newOrefDetermination.isUploadedToNS = true
+
+        if let predictions = predictions {
+            ["iob": predictions.iob, "zt": predictions.zt, "cob": predictions.cob, "uam": predictions.uam]
+                .forEach { type, values in
+                    if let values = values {
+                        let forecast = Forecast(context: context)
+                        forecast.id = UUID()
+                        forecast.type = type
+                        forecast.date = Date()
+                        forecast.orefDetermination = newOrefDetermination
+
+                        for (index, value) in values.enumerated() {
+                            let forecastValue = ForecastValue(context: context)
+                            forecastValue.index = Int32(index)
+                            forecastValue.value = Int32(value)
+                            forecast.addToForecastValues(forecastValue)
+                        }
+                        newOrefDetermination.addToForecasts(forecast)
+                    }
+                }
+        }
+    }
+
+    func decimalToNSDecimalNumber(_ value: Decimal?) -> NSDecimalNumber? {
+        guard let value = value else { return nil }
+        return NSDecimalNumber(decimal: value)
+    }
+}
+
 extension JSONImporter {
     func importGlucoseHistoryIfNeeded() async {}
     func importPumpHistoryIfNeeded() async {}
     func importCarbHistoryIfNeeded() async {}
+    func importDeterminationIfNeeded() async {}
 }

+ 12 - 0
Trio.xcodeproj/project.pbxproj

@@ -556,6 +556,7 @@
 		DD32CFA22CC824E2003686D6 /* TrioRemoteControl+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD32CFA12CC824E1003686D6 /* TrioRemoteControl+Helpers.swift */; };
 		DD3A3CE72D29C93F00AE478E /* Helper+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3A3CE62D29C93F00AE478E /* Helper+Extensions.swift */; };
 		DD3A3CE92D29C97800AE478E /* Helper+ButtonStyles.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3A3CE82D29C97800AE478E /* Helper+ButtonStyles.swift */; };
+		DD3C47B32DC5608A003DD20D /* newerSuggested.json in Resources */ = {isa = PBXBuildFile; fileRef = DD3C47B22DC5608A003DD20D /* newerSuggested.json */; };
 		DD3F1F832D9DC78800DCE7B3 /* UnitSelectionStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3F1F822D9DC78300DCE7B3 /* UnitSelectionStepView.swift */; };
 		DD3F1F852D9DD84000DCE7B3 /* DeliveryLimitsStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3F1F842D9DD83B00DCE7B3 /* DeliveryLimitsStepView.swift */; };
 		DD3F1F892D9E078D00DCE7B3 /* TherapySettingEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD3F1F882D9E078300DCE7B3 /* TherapySettingEditorView.swift */; };
@@ -623,6 +624,8 @@
 		DDD1631F2C4C6F6900CD525A /* TrioCoreDataPersistentContainer.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = DDD1631D2C4C6F6900CD525A /* TrioCoreDataPersistentContainer.xcdatamodeld */; };
 		DDD6D4D32CDE90720029439A /* EstimatedA1cDisplayUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDD6D4D22CDE90720029439A /* EstimatedA1cDisplayUnit.swift */; };
 		DDD78A912DC4064800AC63F3 /* carbhistory.json in Resources */ = {isa = PBXBuildFile; fileRef = DDD78A902DC4064800AC63F3 /* carbhistory.json */; };
+		DDD78AD92DC421B500AC63F3 /* enacted.json in Resources */ = {isa = PBXBuildFile; fileRef = DDD78AD72DC421B500AC63F3 /* enacted.json */; };
+		DDD78ADA2DC421B500AC63F3 /* suggested.json in Resources */ = {isa = PBXBuildFile; fileRef = DDD78AD82DC421B500AC63F3 /* suggested.json */; };
 		DDE179522C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179322C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift */; };
 		DDE179532C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179332C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift */; };
 		DDE179542C910127003CDDB7 /* LoopStatRecord+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE179342C910127003CDDB7 /* LoopStatRecord+CoreDataClass.swift */; };
@@ -1365,6 +1368,7 @@
 		DD32CFA12CC824E1003686D6 /* TrioRemoteControl+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TrioRemoteControl+Helpers.swift"; sourceTree = "<group>"; };
 		DD3A3CE62D29C93F00AE478E /* Helper+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Helper+Extensions.swift"; sourceTree = "<group>"; };
 		DD3A3CE82D29C97800AE478E /* Helper+ButtonStyles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Helper+ButtonStyles.swift"; sourceTree = "<group>"; };
+		DD3C47B22DC5608A003DD20D /* newerSuggested.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = newerSuggested.json; sourceTree = "<group>"; };
 		DD3F1F822D9DC78300DCE7B3 /* UnitSelectionStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitSelectionStepView.swift; sourceTree = "<group>"; };
 		DD3F1F842D9DD83B00DCE7B3 /* DeliveryLimitsStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeliveryLimitsStepView.swift; sourceTree = "<group>"; };
 		DD3F1F882D9E078300DCE7B3 /* TherapySettingEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TherapySettingEditorView.swift; sourceTree = "<group>"; };
@@ -1432,6 +1436,8 @@
 		DDD1631E2C4C6F6900CD525A /* TrioCoreDataPersistentContainer.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = TrioCoreDataPersistentContainer.xcdatamodel; sourceTree = "<group>"; };
 		DDD6D4D22CDE90720029439A /* EstimatedA1cDisplayUnit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EstimatedA1cDisplayUnit.swift; sourceTree = "<group>"; };
 		DDD78A902DC4064800AC63F3 /* carbhistory.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = carbhistory.json; sourceTree = "<group>"; };
+		DDD78AD72DC421B500AC63F3 /* enacted.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = enacted.json; sourceTree = "<group>"; };
+		DDD78AD82DC421B500AC63F3 /* suggested.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = suggested.json; sourceTree = "<group>"; };
 		DDE179322C910127003CDDB7 /* MealPresetStored+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MealPresetStored+CoreDataClass.swift"; sourceTree = "<group>"; };
 		DDE179332C910127003CDDB7 /* MealPresetStored+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MealPresetStored+CoreDataProperties.swift"; sourceTree = "<group>"; };
 		DDE179342C910127003CDDB7 /* LoopStatRecord+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LoopStatRecord+CoreDataClass.swift"; sourceTree = "<group>"; };
@@ -2558,6 +2564,9 @@
 		3B997DD22DC02AEF006B6BB2 /* JSONImporterData */ = {
 			isa = PBXGroup;
 			children = (
+				DD3C47B22DC5608A003DD20D /* newerSuggested.json */,
+				DDD78AD72DC421B500AC63F3 /* enacted.json */,
+				DDD78AD82DC421B500AC63F3 /* suggested.json */,
 				DDD78A902DC4064800AC63F3 /* carbhistory.json */,
 				3B997DD12DC02AEF006B6BB2 /* glucose.json */,
 				3BD6CE252DC24CFD00FA0472 /* pumphistory-24h-zoned.json */,
@@ -3912,6 +3921,9 @@
 				3BD6CE262DC24CFD00FA0472 /* pumphistory-24h-zoned.json in Resources */,
 				DDD78A912DC4064800AC63F3 /* carbhistory.json in Resources */,
 				3B997DD32DC02AEF006B6BB2 /* glucose.json in Resources */,
+				DDD78AD92DC421B500AC63F3 /* enacted.json in Resources */,
+				DD3C47B32DC5608A003DD20D /* newerSuggested.json in Resources */,
+				DDD78ADA2DC421B500AC63F3 /* suggested.json in Resources */,
 			);
 			runOnlyForDeploymentPostprocessing = 0;
 		};

+ 224 - 0
TrioTests/JSONImporterData/enacted.json

@@ -0,0 +1,224 @@
+{
+  "ISF" : 4.6,
+  "recieved" : true,
+  "reason" : "Autosens ratio: 0.99, ISF: 4.5→4.6, COB: 34, Dev: 4.2, BGI: -0.4, CR: 15, Target: 5.2, minPredBG 6.5, minGuardBG 4.3, IOBpredBG 2.7, COBpredBG 8.9, UAMpredBG 3.4, TDD: 26.95 U, 83% Bolus 17% Basal, Dynamic ISF/CR: On/Off, Sigmoid function, AF: 0.14, Basal ratio: 0.93; Eventual BG 8.9 >= 5.2,  insulinReq 0.29; setting 60m low temp of 0U/h. Microbolusing 0.1U. ",
+  "current_target" : 94,
+  "expectedDelta" : -5.9,
+  "insulinReq" : 0.29,
+  "predBGs" : {
+    "ZT" : [
+      85,
+      78,
+      71,
+      64,
+      58,
+      52,
+      47,
+      42,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      42,
+      44,
+      47,
+      49,
+      52,
+      54,
+      57,
+      59,
+      62,
+      65
+    ],
+    "IOB" : [
+      85,
+      89,
+      92,
+      95,
+      97,
+      99,
+      99,
+      99,
+      99,
+      97,
+      95,
+      92,
+      89,
+      85,
+      82,
+      79,
+      77,
+      74,
+      72,
+      70,
+      68,
+      66,
+      65,
+      63,
+      62,
+      60,
+      59,
+      58,
+      57,
+      56,
+      56,
+      55,
+      54,
+      53,
+      53,
+      52,
+      52,
+      51,
+      51,
+      51,
+      50,
+      50,
+      50,
+      50,
+      49
+    ],
+    "UAM" : [
+      85,
+      89,
+      93,
+      96,
+      99,
+      101,
+      102,
+      103,
+      104,
+      104,
+      103,
+      102,
+      100,
+      98,
+      95,
+      92,
+      89,
+      87,
+      84,
+      82,
+      80,
+      78,
+      77,
+      75,
+      74,
+      73,
+      71,
+      70,
+      69,
+      69,
+      68,
+      67,
+      66,
+      66,
+      65,
+      65,
+      64,
+      64,
+      63,
+      63,
+      63,
+      62,
+      62,
+      62,
+      61
+    ],
+    "COB" : [
+      85,
+      90,
+      94,
+      99,
+      103,
+      108,
+      112,
+      117,
+      121,
+      125,
+      130,
+      134,
+      137,
+      141,
+      145,
+      148,
+      151,
+      154,
+      157,
+      159,
+      161,
+      163,
+      165,
+      166,
+      167,
+      168,
+      168,
+      168,
+      168,
+      168,
+      167,
+      166,
+      165,
+      165,
+      164,
+      164,
+      163,
+      163,
+      162,
+      162,
+      161,
+      161,
+      161,
+      161,
+      160
+    ]
+  },
+  "reservoir" : 3735928559,
+  "IOB" : 1.249,
+  "eventualBG" : 160,
+  "units" : 0.1,
+  "TDD" : 26.95,
+  "bg" : 85,
+  "duration" : 60,
+  "deliverAt" : "2025-04-28T19:41:43.564Z",
+  "manualBolusErrorString" : 0,
+  "rate" : 0,
+  "temp" : "absolute",
+  "minDelta" : 5,
+  "COB" : 34,
+  "insulin" : {
+    "scheduled_basal" : 0.25,
+    "bolus" : 22.4,
+    "TDD" : 26.95,
+    "temp_basal" : 4.3
+  },
+  "insulinForManualBolus" : 0.8,
+  "timestamp" : "2025-04-28T19:41:48.453Z",
+  "sensitivityRatio" : 0.9863849810728643,
+  "threshold" : 3.7
+}

+ 173 - 0
TrioTests/JSONImporterData/newerSuggested.json

@@ -0,0 +1,173 @@
+{
+  "deliverAt" : "2025-04-28T19:51:48.453Z",
+  "insulinReq" : 0,
+  "current_target" : 178,
+  "reservoir" : 3735928559,
+  "threshold" : 6,
+  "IOB" : -0.1,
+  "ISF" : 10.5,
+  "reason" : "Autosens ratio: 0.94, ISF: 9.9→10.5, COB: 0, Dev: 0.1, BGI: 0, CR: 13, Target: 9.9, minPredBG 6.7, minGuardBG 6.2, IOBpredBG 7.3, UAMpredBG 7.2; Eventual BG 7.3 < 9.9, setting 30m zero temp.  24m left and 0 ~ req 0U/hr: no temp required",
+  "manualBolusErrorString" : 0,
+  "insulinForManualBolus" : 0,
+  "COB" : 0,
+  "TDD" : 0,
+  "bg" : 111,
+  "minDelta" : 0.5,
+  "eventualBG" : 131,
+  "recieved" : false,
+  "sensitivityRatio" : 0.94,
+  "temp" : "absolute",
+  "expectedDelta" : 2,
+  "timestamp" : "2025-04-28T19:51:48.453Z",
+  "predBGs" : {
+    "UAM" : [
+      111,
+      111,
+      111,
+      111,
+      112,
+      112,
+      113,
+      113,
+      113,
+      114,
+      114,
+      115,
+      115,
+      116,
+      116,
+      117,
+      118,
+      118,
+      119,
+      119,
+      120,
+      120,
+      121,
+      121,
+      122,
+      122,
+      123,
+      123,
+      123,
+      124,
+      124,
+      125,
+      125,
+      125,
+      126,
+      126,
+      126,
+      126,
+      127,
+      127,
+      127,
+      128,
+      128,
+      128,
+      128,
+      128,
+      128,
+      129
+    ],
+    "IOB" : [
+      111,
+      111,
+      112,
+      112,
+      113,
+      114,
+      114,
+      115,
+      115,
+      116,
+      116,
+      117,
+      118,
+      118,
+      119,
+      119,
+      120,
+      120,
+      121,
+      122,
+      122,
+      122,
+      123,
+      123,
+      124,
+      124,
+      125,
+      125,
+      126,
+      126,
+      126,
+      127,
+      127,
+      128,
+      128,
+      128,
+      128,
+      129,
+      129,
+      129,
+      130,
+      130,
+      130,
+      130,
+      130,
+      130,
+      131
+    ],
+    "ZT" : [
+      111,
+      111,
+      111,
+      112,
+      112,
+      113,
+      114,
+      115,
+      117,
+      119,
+      121,
+      124,
+      127,
+      131,
+      134,
+      138,
+      143,
+      148,
+      153,
+      158,
+      164,
+      171,
+      177,
+      184,
+      191,
+      199,
+      206,
+      214,
+      223,
+      231,
+      240,
+      249,
+      259,
+      268,
+      278,
+      288,
+      298,
+      308,
+      319,
+      329,
+      340,
+      351,
+      362,
+      374,
+      385,
+      397,
+      401,
+      401
+    ]
+  }
+}

+ 223 - 0
TrioTests/JSONImporterData/suggested.json

@@ -0,0 +1,223 @@
+{
+  "sensitivityRatio" : 0.9863849810728643,
+  "timestamp" : "2025-04-28T19:41:43.564Z",
+  "COB" : 34,
+  "IOB" : 1.249,
+  "reason" : "Autosens ratio: 0.99, ISF: 4.5→4.6, COB: 34, Dev: 4.2, BGI: -0.4, CR: 15, Target: 5.2, minPredBG 6.5, minGuardBG 4.3, IOBpredBG 2.7, COBpredBG 8.9, UAMpredBG 3.4, TDD: 26.95 U, 83% Bolus 17% Basal, Dynamic ISF/CR: On/Off, Sigmoid function, AF: 0.14, Basal ratio: 0.93; Eventual BG 8.9 >= 5.2,  insulinReq 0.29; setting 60m low temp of 0U/h. Microbolusing 0.1U. ",
+  "eventualBG" : 160,
+  "reservoir" : 3735928559,
+  "insulinReq" : 0.29,
+  "TDD" : 26.95,
+  "insulin" : {
+    "temp_basal" : 4.3,
+    "scheduled_basal" : 0.25,
+    "TDD" : 26.95,
+    "bolus" : 22.4
+  },
+  "predBGs" : {
+    "IOB" : [
+      85,
+      89,
+      92,
+      95,
+      97,
+      99,
+      99,
+      99,
+      99,
+      97,
+      95,
+      92,
+      89,
+      85,
+      82,
+      79,
+      77,
+      74,
+      72,
+      70,
+      68,
+      66,
+      65,
+      63,
+      62,
+      60,
+      59,
+      58,
+      57,
+      56,
+      56,
+      55,
+      54,
+      53,
+      53,
+      52,
+      52,
+      51,
+      51,
+      51,
+      50,
+      50,
+      50,
+      50,
+      49
+    ],
+    "ZT" : [
+      85,
+      78,
+      71,
+      64,
+      58,
+      52,
+      47,
+      42,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      39,
+      42,
+      44,
+      47,
+      49,
+      52,
+      54,
+      57,
+      59,
+      62,
+      65
+    ],
+    "UAM" : [
+      85,
+      89,
+      93,
+      96,
+      99,
+      101,
+      102,
+      103,
+      104,
+      104,
+      103,
+      102,
+      100,
+      98,
+      95,
+      92,
+      89,
+      87,
+      84,
+      82,
+      80,
+      78,
+      77,
+      75,
+      74,
+      73,
+      71,
+      70,
+      69,
+      69,
+      68,
+      67,
+      66,
+      66,
+      65,
+      65,
+      64,
+      64,
+      63,
+      63,
+      63,
+      62,
+      62,
+      62,
+      61
+    ],
+    "COB" : [
+      85,
+      90,
+      94,
+      99,
+      103,
+      108,
+      112,
+      117,
+      121,
+      125,
+      130,
+      134,
+      137,
+      141,
+      145,
+      148,
+      151,
+      154,
+      157,
+      159,
+      161,
+      163,
+      165,
+      166,
+      167,
+      168,
+      168,
+      168,
+      168,
+      168,
+      167,
+      166,
+      165,
+      165,
+      164,
+      164,
+      163,
+      163,
+      162,
+      162,
+      161,
+      161,
+      161,
+      161,
+      160
+    ]
+  },
+  "ISF" : 4.6,
+  "rate" : 0,
+  "minDelta" : 5,
+  "expectedDelta" : -5.9,
+  "threshold" : 3.7,
+  "insulinForManualBolus" : 0.8,
+  "duration" : 60,
+  "temp" : "absolute",
+  "manualBolusErrorString" : 0,
+  "deliverAt" : "2025-04-28T19:41:43.564Z",
+  "current_target" : 94,
+  "units" : 0.1,
+  "bg" : 85
+}

+ 170 - 0
TrioTests/JSONImporterTests.swift

@@ -200,6 +200,176 @@ class BundleReference {}
 
         #expect(allCarbEntries.isEmpty)
     }
+
+    @Test("Import determination data with value checks") func testImportDeterminationDetails() async throws {
+        let testBundle = Bundle(for: BundleReference.self)
+        let enactedPath = testBundle.path(forResource: "enacted", ofType: "json")!
+        let enactedUrl = URL(filePath: enactedPath)
+        let suggestedPath = testBundle.path(forResource: "suggested", ofType: "json")!
+        let suggestedUrl = URL(filePath: suggestedPath)
+
+        let now = Date("2025-04-28T20:50:00.000Z")!
+        try await importer.importOrefDetermination(enactedUrl: enactedUrl, suggestedUrl: suggestedUrl, now: now)
+        // run the import againt to check our deduplication logic
+        try await importer.importOrefDetermination(enactedUrl: enactedUrl, suggestedUrl: suggestedUrl, now: now)
+
+        let determinations = try await coreDataStack.fetchEntitiesAsync(
+            ofType: OrefDetermination.self,
+            onContext: context,
+            predicate: NSPredicate(format: "TRUEPREDICATE"),
+            key: "deliverAt",
+            ascending: false
+        ) as? [OrefDetermination] ?? []
+
+        #expect(determinations.count == 1) // single determination, as enacted.deliverAt and suggested.deliverAt match
+
+        let determination = determinations.first!
+
+        #expect(determination.deliverAt == Date("2025-04-28T19:41:43.564Z"))
+        #expect(determination.timestamp == Date("2025-04-28T19:41:48.453Z"))
+        #expect(determination.enacted == true)
+        #expect(determination.reason?.starts(with: "Autosens ratio: 0.99") == true)
+        #expect(determination.insulinReq == Decimal(string: "0.29").map(NSDecimalNumber.init))
+        #expect(determination.eventualBG! == NSDecimalNumber(160))
+        #expect(determination.sensitivityRatio == Decimal(string: "0.9863849810728643").map(NSDecimalNumber.init))
+        #expect(determination.rate == Decimal(string: "0").map(NSDecimalNumber.init))
+        #expect(determination.duration == NSDecimalNumber(60))
+        #expect(determination.iob == Decimal(string: "1.249").map(NSDecimalNumber.init))
+        #expect(determination.cob == 34)
+        #expect(determination.temp == "absolute")
+        #expect(determination.glucose == NSDecimalNumber(85))
+        #expect(determination.reservoir == Decimal(string: "3735928559").map(NSDecimalNumber.init))
+        #expect(determination.insulinSensitivity == Decimal(string: "4.6").map(NSDecimalNumber.init))
+        #expect(determination.currentTarget == Decimal(string: "94").map(NSDecimalNumber.init))
+        #expect(determination.insulinForManualBolus == Decimal(string: "0.8").map(NSDecimalNumber.init))
+        #expect(determination.manualBolusErrorString == Decimal(string: "0").map(NSDecimalNumber.init))
+        #expect(determination.minDelta == NSDecimalNumber(5))
+        #expect(determination.expectedDelta == Decimal(string: "-5.9").map(NSDecimalNumber.init))
+        #expect(determination.threshold == Decimal(string: "3.7").map(NSDecimalNumber.init))
+        #expect(determination.carbRatio == nil) // not present in JSON
+
+        let forecasts = try await coreDataStack.fetchEntitiesAsync(
+            ofType: Forecast.self,
+            onContext: context,
+            predicate: NSPredicate(format: "orefDetermination = %@", determination.objectID),
+            key: "type",
+            ascending: true,
+            relationshipKeyPathsForPrefetching: ["forecastValues"]
+        )
+
+        var forecastHierarchy: [(forecastID: NSManagedObjectID, forecastValueIDs: [NSManagedObjectID])] = []
+
+        await context.perform {
+            if let forecasts = forecasts as? [Forecast] {
+                for forecast in forecasts {
+                    // Use the helper property that already sorts by index
+                    let sortedValues = forecast.forecastValuesArray
+                    forecastHierarchy.append((
+                        forecastID: forecast.objectID,
+                        forecastValueIDs: sortedValues.map(\.objectID)
+                    ))
+                }
+            }
+
+            for entry in forecastHierarchy {
+                var forecastValueTuple: (Forecast?, [ForecastValue]) = (nil, [])
+
+                var forecast: Forecast?
+                var forecastValues: [ForecastValue] = []
+
+                do {
+                    // Fetch the forecast object
+                    forecast = try context.existingObject(with: entry.forecastID) as? Forecast
+
+                    // Fetch the first 3h of forecast values
+                    for forecastValueID in entry.forecastValueIDs.prefix(36) {
+                        if let forecastValue = try context.existingObject(with: forecastValueID) as? ForecastValue {
+                            forecastValues.append(forecastValue)
+                        }
+                    }
+                } catch {
+                    debugPrint(
+                        "\(DebuggingIdentifiers.failed) \(#file) \(#function) Failed to fetch forecast Values with error: \(error.localizedDescription)"
+                    )
+                }
+                forecastValueTuple = (forecast, forecastValues)
+
+                // Basic checks
+                #expect(forecastValueTuple.0 != nil)
+                #expect(forecastValueTuple.1.isNotEmpty == true)
+
+                if let forecast = forecastValueTuple.0 {
+                    let sortedValues = forecastValueTuple.1.sorted { $0.index < $1.index }
+                    let prefix = sortedValues.prefix(5).compactMap(\.value)
+                    let type = forecast.type?.lowercased()
+
+                    switch type {
+                    case "zt":
+                        #expect(prefix == [85, 78, 71, 64, 58])
+                    case "iob":
+                        #expect(prefix == [85, 89, 92, 95, 97])
+                    case "uam":
+                        #expect(prefix == [85, 89, 93, 96, 99])
+                    case "cob":
+                        #expect(prefix == [85, 90, 94, 99, 103])
+                    default:
+                        break // Skip unknown forecast types silently
+                    }
+                }
+            }
+        }
+    }
+
+    @Test("Skip importing old determinations") func testSkipImportOldDeterminationData() async throws {
+        let testBundle = Bundle(for: BundleReference.self)
+        let enactedPath = testBundle.path(forResource: "enacted", ofType: "json")!
+        let enactedUrl = URL(filePath: enactedPath)
+        let suggestedPath = testBundle.path(forResource: "suggested", ofType: "json")!
+        let suggestedUrl = URL(filePath: suggestedPath)
+
+        // more than 24 hours in the future from the most recent entry
+        let now = Date("2025-04-29T22:00:00.000Z")!
+
+        try await importer.importOrefDetermination(enactedUrl: enactedUrl, suggestedUrl: suggestedUrl, now: now)
+
+        let determinations = try await coreDataStack.fetchEntitiesAsync(
+            ofType: OrefDetermination.self,
+            onContext: context,
+            predicate: NSPredicate(format: "TRUEPREDICATE"),
+            key: "deliverAt",
+            ascending: false
+        ) as? [OrefDetermination] ?? []
+
+        #expect(determinations.isEmpty)
+    }
+
+    @Test("Import determination data with suggested newer than enacted") func testImportDeterminationDetailsWithNewerSuggested(
+    ) async throws {
+        let testBundle = Bundle(for: BundleReference.self)
+        let enactedPath = testBundle.path(forResource: "enacted", ofType: "json")!
+        let enactedUrl = URL(filePath: enactedPath)
+        let suggestedPath = testBundle.path(forResource: "newerSuggested", ofType: "json")!
+        let suggestedUrl = URL(filePath: suggestedPath)
+
+        let now = Date("2025-04-28T20:50:00.000Z")!
+        try await importer.importOrefDetermination(enactedUrl: enactedUrl, suggestedUrl: suggestedUrl, now: now)
+
+        let determinations = try await coreDataStack.fetchEntitiesAsync(
+            ofType: OrefDetermination.self,
+            onContext: context,
+            predicate: NSPredicate(format: "TRUEPREDICATE"),
+            key: "deliverAt",
+            ascending: false
+        ) as? [OrefDetermination] ?? []
+
+        #expect(determinations.count == 2) // two determinations, suggested is more recent than enacted
+
+        let suggested = determinations.first(where: { !$0.enacted && $0.deliverAt == $0.timestamp })!
+        let enacted = determinations.first(where: { $0.enacted })!
+
+        #expect(suggested.deliverAt == Date("2025-04-28T19:51:48.453Z"))
+        #expect(enacted.timestamp == Date("2025-04-28T19:41:48.453Z"))
+    }
 }
 
 extension Double {