JSONImporter.swift 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371
  1. import CoreData
  2. import Foundation
  3. /// Migration-specific errors that might happen during migration
  4. enum JSONImporterError: Error {
  5. case missingGlucoseValueInGlucoseEntry
  6. }
  7. // MARK: - JSONImporter Class
  8. /// Responsible for importing JSON data into Core Data.
  9. ///
  10. /// The importer handles two important states:
  11. /// - JSON files stored in the file system that contain data to import
  12. /// - Existing entries in CoreData that should not be duplicated
  13. ///
  14. /// Imports are performed when a JSON file exists. The importer checks
  15. /// CoreData for existing entries to avoid duplicating records from partial imports.
  16. class JSONImporter {
  17. private let context: NSManagedObjectContext
  18. private let coreDataStack: CoreDataStack
  19. /// Initializes the importer with a Core Data context.
  20. init(context: NSManagedObjectContext, coreDataStack: CoreDataStack) {
  21. self.context = context
  22. self.coreDataStack = coreDataStack
  23. }
  24. /// Reads and parses a JSON file from the file system.
  25. ///
  26. /// - Parameters:
  27. /// - url: The URL of the JSON file to read.
  28. /// - Returns: A decoded object of the specified type.
  29. /// - Throws: An error if the file cannot be read or decoded.
  30. private func readJsonFile<T: Decodable>(url: URL) throws -> T {
  31. let data = try Data(contentsOf: url)
  32. let decoder = JSONCoding.decoder
  33. return try decoder.decode(T.self, from: data)
  34. }
  35. /// Retrieves the set of dates for all glucose values currently stored in CoreData.
  36. ///
  37. /// - Parameters: the start and end dates to fetch glucose values, inclusive
  38. /// - Returns: A set of dates corresponding to existing glucose readings.
  39. /// - Throws: An error if the fetch operation fails.
  40. private func fetchGlucoseDates(start: Date, end: Date) async throws -> Set<Date> {
  41. let allReadings = try await coreDataStack.fetchEntitiesAsync(
  42. ofType: GlucoseStored.self,
  43. onContext: context,
  44. predicate: .predicateForDateBetween(start: start, end: end),
  45. key: "date",
  46. ascending: false
  47. ) as? [GlucoseStored] ?? []
  48. return Set(allReadings.compactMap(\.date))
  49. }
  50. /// Retrieves the set of dates for all oref determinations currently stored in CoreData.
  51. ///
  52. /// - Parameters:
  53. /// - start: the start to fetch from; inclusive
  54. /// - end: the end date to fetch to; inclusive
  55. /// - Returns: A set of dates corresponding to existing determinations.
  56. /// - Throws: An error if the fetch operation fails.
  57. private func fetchDeterminationDates(start: Date, end: Date) async throws -> Set<Date> {
  58. let determinations = try await coreDataStack.fetchEntitiesAsync(
  59. ofType: OrefDetermination.self,
  60. onContext: context,
  61. predicate: .predicateForDeliverAtBetween(start: start, end: end),
  62. key: "deliverAt",
  63. ascending: false
  64. ) as? [OrefDetermination] ?? []
  65. return Set(determinations.compactMap(\.deliverAt))
  66. }
  67. /// Imports glucose history from a JSON file into CoreData.
  68. ///
  69. /// The function reads glucose data from the provided JSON file and stores new entries
  70. /// in CoreData, skipping entries with dates that already exist in the database.
  71. ///
  72. /// - Parameters:
  73. /// - url: The URL of the JSON file containing glucose history.
  74. /// - Throws:
  75. /// - JSONImporterError.missingGlucoseValueInGlucoseEntry if a glucose entry is missing a value.
  76. /// - An error if the file cannot be read or decoded.
  77. /// - An error if the CoreData operation fails.
  78. func importGlucoseHistory(url: URL, now: Date) async throws {
  79. let twentyFourHoursAgo = now - 24.hours.timeInterval
  80. let glucoseHistoryFull: [BloodGlucose] = try readJsonFile(url: url)
  81. let existingDates = try await fetchGlucoseDates(start: twentyFourHoursAgo, end: now)
  82. // only import glucose values from the last 24 hours that don't exist
  83. let glucoseHistory = glucoseHistoryFull
  84. .filter { $0.dateString >= twentyFourHoursAgo && $0.dateString <= now && !existingDates.contains($0.dateString) }
  85. // Create a background context for batch processing
  86. let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
  87. backgroundContext.parent = context
  88. try await backgroundContext.perform {
  89. for glucoseEntry in glucoseHistory {
  90. try glucoseEntry.store(in: backgroundContext)
  91. }
  92. try backgroundContext.save()
  93. }
  94. try await context.perform {
  95. try self.context.save()
  96. }
  97. }
  98. /// Imports oref determination from a JSON file into CoreData.
  99. ///
  100. /// The function reads oref determination data from the provided JSON file and stores new entries
  101. /// in CoreData, skipping entries with dates that already exist in the database.
  102. ///
  103. /// - Parameters:
  104. /// - url: The URL of the JSON file containing determination data.
  105. /// - Throws:
  106. /// - JSONImporterError.missingGlucoseValueInGlucoseEntry if a glucose entry is missing a value.
  107. /// - An error if the file cannot be read or decoded.
  108. /// - An error if the CoreData operation fails.
  109. func importOrefDetermination(enactedUrl: URL, suggestedUrl: URL, now: Date) async throws {
  110. let twentyFourHoursAgo = now - 24.hours.timeInterval
  111. let enactedDetermination: Determination = try readJsonFile(url: enactedUrl)
  112. let suggestedDetermination: Determination = try readJsonFile(url: suggestedUrl)
  113. let existingDates = try await fetchDeterminationDates(start: twentyFourHoursAgo, end: now)
  114. /// Helper function to check if entries are from within the last 24 hours that do not yet exist in Core Data
  115. func checkDeterminationDate(_ date: Date) -> Bool {
  116. return date >= twentyFourHoursAgo && date <= now && !existingDates.contains(date)
  117. }
  118. guard let enactedDeliverAt = enactedDetermination.deliverAt, let suggestedDeliverAt = suggestedDetermination.deliverAt else {
  119. throw JSONImporterError.missingGlucoseValueInGlucoseEntry // TODO: adjust error
  120. }
  121. guard checkDeterminationDate(enactedDeliverAt), checkDeterminationDate(suggestedDeliverAt) else {
  122. return
  123. }
  124. // Create a background context for batch processing
  125. let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
  126. backgroundContext.parent = context
  127. try await backgroundContext.perform {
  128. /// We know both determination entries are from within last 24 hrs via the check in line 140
  129. /// If their `deliverAt` does not match, it is worth storing them both
  130. if suggestedDeliverAt != enactedDeliverAt {
  131. try suggestedDetermination.store(in: backgroundContext)
  132. }
  133. try enactedDetermination.store(in: backgroundContext)
  134. try backgroundContext.save()
  135. }
  136. try await context.perform {
  137. try self.context.save()
  138. }
  139. }
  140. }
  141. // MARK: - Extension for Specific Import Functions
  142. extension BloodGlucose {
  143. /// Helper function to convert `BloodGlucose` to `GlucoseStored` while importing JSON glucose entries
  144. func store(in context: NSManagedObjectContext) throws {
  145. guard let glucoseValue = glucose ?? sgv else {
  146. throw JSONImporterError.missingGlucoseValueInGlucoseEntry
  147. }
  148. let glucoseEntry = GlucoseStored(context: context)
  149. glucoseEntry.id = _id.flatMap({ UUID(uuidString: $0) }) ?? UUID()
  150. glucoseEntry.date = dateString
  151. glucoseEntry.glucose = Int16(glucoseValue)
  152. glucoseEntry.direction = direction?.rawValue
  153. glucoseEntry.isManual = type == "Manual"
  154. glucoseEntry.isUploadedToNS = true
  155. glucoseEntry.isUploadedToHealth = true
  156. glucoseEntry.isUploadedToTidepool = true
  157. }
  158. }
  159. /// Extension to support decoding `Determination` entries with misspelled keys from external JSON sources.
  160. ///
  161. /// Some legacy or third-party tools occasionally serialize the `received` property as `"recieved"`
  162. /// (misspelled) instead of the correct `"received"`. To prevent decoding failures or data loss,
  163. /// this custom decoder attempts to decode from `"received"` first, then falls back to `"recieved"`
  164. /// if necessary.
  165. ///
  166. /// Encoding always uses the correct `"received"` key to ensure consistent, standards-compliant output.
  167. ///
  168. /// This improves resilience and ensures compatibility with imported loop history, simulations,
  169. /// or devicestatus artifacts that may contain typos in their keys.
  170. extension Determination: Codable {
  171. private enum CodingKeys: String, CodingKey {
  172. case id
  173. case reason
  174. case units
  175. case insulinReq
  176. case eventualBG
  177. case sensitivityRatio
  178. case rate
  179. case duration
  180. case iob = "IOB"
  181. case cob = "COB"
  182. case predictions = "predBGs"
  183. case deliverAt
  184. case carbsReq
  185. case temp
  186. case bg
  187. case reservoir
  188. case timestamp
  189. case isf = "ISF"
  190. case current_target
  191. case tdd = "TDD"
  192. case insulinForManualBolus
  193. case manualBolusErrorString
  194. case minDelta
  195. case expectedDelta
  196. case minGuardBG
  197. case minPredBG
  198. case threshold
  199. case carbRatio = "CR"
  200. case received
  201. case receivedAlt = "recieved"
  202. }
  203. init(from decoder: Decoder) throws {
  204. let container = try decoder.container(keyedBy: CodingKeys.self)
  205. id = try container.decodeIfPresent(UUID.self, forKey: .id)
  206. reason = try container.decode(String.self, forKey: .reason)
  207. units = try container.decodeIfPresent(Decimal.self, forKey: .units)
  208. insulinReq = try container.decodeIfPresent(Decimal.self, forKey: .insulinReq)
  209. eventualBG = try container.decodeIfPresent(Int.self, forKey: .eventualBG)
  210. sensitivityRatio = try container.decodeIfPresent(Decimal.self, forKey: .sensitivityRatio)
  211. rate = try container.decodeIfPresent(Decimal.self, forKey: .rate)
  212. duration = try container.decodeIfPresent(Decimal.self, forKey: .duration)
  213. iob = try container.decodeIfPresent(Decimal.self, forKey: .iob)
  214. cob = try container.decodeIfPresent(Decimal.self, forKey: .cob)
  215. predictions = try container.decodeIfPresent(Predictions.self, forKey: .predictions)
  216. deliverAt = try container.decodeIfPresent(Date.self, forKey: .deliverAt)
  217. carbsReq = try container.decodeIfPresent(Decimal.self, forKey: .carbsReq)
  218. temp = try container.decodeIfPresent(TempType.self, forKey: .temp)
  219. bg = try container.decodeIfPresent(Decimal.self, forKey: .bg)
  220. reservoir = try container.decodeIfPresent(Decimal.self, forKey: .reservoir)
  221. timestamp = try container.decodeIfPresent(Date.self, forKey: .timestamp)
  222. isf = try container.decodeIfPresent(Decimal.self, forKey: .isf)
  223. current_target = try container.decodeIfPresent(Decimal.self, forKey: .current_target)
  224. tdd = try container.decodeIfPresent(Decimal.self, forKey: .tdd)
  225. insulinForManualBolus = try container.decodeIfPresent(Decimal.self, forKey: .insulinForManualBolus)
  226. manualBolusErrorString = try container.decodeIfPresent(Decimal.self, forKey: .manualBolusErrorString)
  227. minDelta = try container.decodeIfPresent(Decimal.self, forKey: .minDelta)
  228. expectedDelta = try container.decodeIfPresent(Decimal.self, forKey: .expectedDelta)
  229. minGuardBG = try container.decodeIfPresent(Decimal.self, forKey: .minGuardBG)
  230. minPredBG = try container.decodeIfPresent(Decimal.self, forKey: .minPredBG)
  231. threshold = try container.decodeIfPresent(Decimal.self, forKey: .threshold)
  232. carbRatio = try container.decodeIfPresent(Decimal.self, forKey: .carbRatio)
  233. // Handle both spellings of "received"
  234. if let value = try container.decodeIfPresent(Bool.self, forKey: .received) {
  235. received = value
  236. } else if let fallback = try container.decodeIfPresent(Bool.self, forKey: .receivedAlt) {
  237. received = fallback
  238. } else {
  239. received = nil
  240. }
  241. }
  242. func encode(to encoder: Encoder) throws {
  243. var container = encoder.container(keyedBy: CodingKeys.self)
  244. try container.encodeIfPresent(id, forKey: .id)
  245. try container.encode(reason, forKey: .reason)
  246. try container.encodeIfPresent(units, forKey: .units)
  247. try container.encodeIfPresent(insulinReq, forKey: .insulinReq)
  248. try container.encodeIfPresent(eventualBG, forKey: .eventualBG)
  249. try container.encodeIfPresent(sensitivityRatio, forKey: .sensitivityRatio)
  250. try container.encodeIfPresent(rate, forKey: .rate)
  251. try container.encodeIfPresent(duration, forKey: .duration)
  252. try container.encodeIfPresent(iob, forKey: .iob)
  253. try container.encodeIfPresent(cob, forKey: .cob)
  254. try container.encodeIfPresent(predictions, forKey: .predictions)
  255. try container.encodeIfPresent(deliverAt, forKey: .deliverAt)
  256. try container.encodeIfPresent(carbsReq, forKey: .carbsReq)
  257. try container.encodeIfPresent(temp, forKey: .temp)
  258. try container.encodeIfPresent(bg, forKey: .bg)
  259. try container.encodeIfPresent(reservoir, forKey: .reservoir)
  260. try container.encodeIfPresent(timestamp, forKey: .timestamp)
  261. try container.encodeIfPresent(isf, forKey: .isf)
  262. try container.encodeIfPresent(current_target, forKey: .current_target)
  263. try container.encodeIfPresent(tdd, forKey: .tdd)
  264. try container.encodeIfPresent(insulinForManualBolus, forKey: .insulinForManualBolus)
  265. try container.encodeIfPresent(manualBolusErrorString, forKey: .manualBolusErrorString)
  266. try container.encodeIfPresent(minDelta, forKey: .minDelta)
  267. try container.encodeIfPresent(expectedDelta, forKey: .expectedDelta)
  268. try container.encodeIfPresent(minGuardBG, forKey: .minGuardBG)
  269. try container.encodeIfPresent(minPredBG, forKey: .minPredBG)
  270. try container.encodeIfPresent(threshold, forKey: .threshold)
  271. try container.encodeIfPresent(carbRatio, forKey: .carbRatio)
  272. try container.encodeIfPresent(received, forKey: .received) // always encode the correct spelling
  273. }
  274. /// Helper function to convert `Determination` to `OrefDetermination` while importing JSON glucose entries
  275. func store(in context: NSManagedObjectContext) throws {
  276. // TODO: some guards here ?!
  277. let newOrefDetermination = OrefDetermination(context: context)
  278. newOrefDetermination.id = UUID()
  279. newOrefDetermination.insulinSensitivity = decimalToNSDecimalNumber(isf)
  280. newOrefDetermination.currentTarget = decimalToNSDecimalNumber(current_target)
  281. newOrefDetermination.eventualBG = eventualBG.map(NSDecimalNumber.init)
  282. newOrefDetermination.deliverAt = deliverAt
  283. newOrefDetermination.timestamp = timestamp
  284. newOrefDetermination.enacted = received ?? false
  285. newOrefDetermination.insulinForManualBolus = decimalToNSDecimalNumber(insulinForManualBolus)
  286. newOrefDetermination.carbRatio = decimalToNSDecimalNumber(carbRatio)
  287. newOrefDetermination.glucose = decimalToNSDecimalNumber(bg)
  288. newOrefDetermination.reservoir = decimalToNSDecimalNumber(reservoir)
  289. newOrefDetermination.insulinReq = decimalToNSDecimalNumber(insulinReq)
  290. newOrefDetermination.temp = temp?.rawValue ?? "absolute"
  291. newOrefDetermination.rate = decimalToNSDecimalNumber(rate)
  292. newOrefDetermination.reason = reason
  293. newOrefDetermination.duration = decimalToNSDecimalNumber(duration)
  294. newOrefDetermination.iob = decimalToNSDecimalNumber(iob)
  295. newOrefDetermination.threshold = decimalToNSDecimalNumber(threshold)
  296. newOrefDetermination.minDelta = decimalToNSDecimalNumber(minDelta)
  297. newOrefDetermination.sensitivityRatio = decimalToNSDecimalNumber(sensitivityRatio)
  298. newOrefDetermination.expectedDelta = decimalToNSDecimalNumber(expectedDelta)
  299. newOrefDetermination.cob = Int16(Int(cob ?? 0))
  300. newOrefDetermination.manualBolusErrorString = decimalToNSDecimalNumber(manualBolusErrorString)
  301. newOrefDetermination.smbToDeliver = units.map { NSDecimalNumber(decimal: $0) }
  302. newOrefDetermination.carbsRequired = Int16(Int(carbsReq ?? 0))
  303. newOrefDetermination.isUploadedToNS = true
  304. if let predictions = predictions {
  305. ["iob": predictions.iob, "zt": predictions.zt, "cob": predictions.cob, "uam": predictions.uam]
  306. .forEach { type, values in
  307. if let values = values {
  308. let forecast = Forecast(context: context)
  309. forecast.id = UUID()
  310. forecast.type = type
  311. forecast.date = Date()
  312. forecast.orefDetermination = newOrefDetermination
  313. for (index, value) in values.enumerated() {
  314. let forecastValue = ForecastValue(context: context)
  315. forecastValue.index = Int32(index)
  316. forecastValue.value = Int32(value)
  317. forecast.addToForecastValues(forecastValue)
  318. }
  319. newOrefDetermination.addToForecasts(forecast)
  320. }
  321. }
  322. }
  323. }
  324. func decimalToNSDecimalNumber(_ value: Decimal?) -> NSDecimalNumber? {
  325. guard let value = value else { return nil }
  326. return NSDecimalNumber(decimal: value)
  327. }
  328. }
  329. extension JSONImporter {
  330. func importGlucoseHistoryIfNeeded() async {}
  331. func importDeterminationIfNeeded() async {}
  332. }