Jelajahi Sumber

Add determination import

Deniz Cengiz 1 tahun lalu
induk
melakukan
cd20fdc3b0
2 mengubah file dengan 255 tambahan dan 0 penghapusan
  1. 4 0
      Model/Helper/NSPredicates.swift
  2. 251 0
      Model/JSONImporter.swift

+ 4 - 0
Model/Helper/NSPredicates.swift

@@ -124,4 +124,8 @@ extension NSPredicate {
     static func predicateForDateBetween(start: Date, end: Date) -> 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)
+    }
 }

+ 251 - 0
Model/JSONImporter.swift

@@ -54,6 +54,25 @@ class JSONImporter {
 
         return Set(allReadings.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.
     ///
@@ -91,6 +110,57 @@ 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 {
+            return date >= twentyFourHoursAgo && date <= now && !existingDates.contains(date)
+        }
+        
+        guard let enactedDeliverAt = enactedDetermination.deliverAt, let suggestedDeliverAt = suggestedDetermination.deliverAt else {
+            throw JSONImporterError.missingGlucoseValueInGlucoseEntry // TODO: adjust error
+        }
+        
+        guard checkDeterminationDate(enactedDeliverAt), checkDeterminationDate(suggestedDeliverAt) else {
+            return
+        }
+
+        // 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 the check in line 140
+            /// If their `deliverAt` does not match, it is worth storing them both
+            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
@@ -114,6 +184,187 @@ extension BloodGlucose {
     }
 }
 
+/// 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
+    }
+
+    /// Helper function to convert `Determination` to `OrefDetermination` while importing JSON glucose entries
+    func store(in context: NSManagedObjectContext) throws {
+        // TODO: some guards here ?! 
+        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 importDeterminationIfNeeded() async {}
 }