JSONImporter.swift 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373
  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. date >= twentyFourHoursAgo && date <= now && !existingDates.contains(date)
  117. }
  118. guard let enactedDeliverAt = enactedDetermination.deliverAt,
  119. let suggestedDeliverAt = suggestedDetermination.deliverAt
  120. else {
  121. throw JSONImporterError.missingGlucoseValueInGlucoseEntry // TODO: adjust error
  122. }
  123. guard checkDeterminationDate(enactedDeliverAt), checkDeterminationDate(suggestedDeliverAt) else {
  124. return
  125. }
  126. // Create a background context for batch processing
  127. let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
  128. backgroundContext.parent = context
  129. try await backgroundContext.perform {
  130. /// We know both determination entries are from within last 24 hrs via the check in line 140
  131. /// If their `deliverAt` does not match, it is worth storing them both
  132. if suggestedDeliverAt != enactedDeliverAt {
  133. try suggestedDetermination.store(in: backgroundContext)
  134. }
  135. try enactedDetermination.store(in: backgroundContext)
  136. try backgroundContext.save()
  137. }
  138. try await context.perform {
  139. try self.context.save()
  140. }
  141. }
  142. }
  143. // MARK: - Extension for Specific Import Functions
  144. extension BloodGlucose {
  145. /// Helper function to convert `BloodGlucose` to `GlucoseStored` while importing JSON glucose entries
  146. func store(in context: NSManagedObjectContext) throws {
  147. guard let glucoseValue = glucose ?? sgv else {
  148. throw JSONImporterError.missingGlucoseValueInGlucoseEntry
  149. }
  150. let glucoseEntry = GlucoseStored(context: context)
  151. glucoseEntry.id = _id.flatMap({ UUID(uuidString: $0) }) ?? UUID()
  152. glucoseEntry.date = dateString
  153. glucoseEntry.glucose = Int16(glucoseValue)
  154. glucoseEntry.direction = direction?.rawValue
  155. glucoseEntry.isManual = type == "Manual"
  156. glucoseEntry.isUploadedToNS = true
  157. glucoseEntry.isUploadedToHealth = true
  158. glucoseEntry.isUploadedToTidepool = true
  159. }
  160. }
  161. /// Extension to support decoding `Determination` entries with misspelled keys from external JSON sources.
  162. ///
  163. /// Some legacy or third-party tools occasionally serialize the `received` property as `"recieved"`
  164. /// (misspelled) instead of the correct `"received"`. To prevent decoding failures or data loss,
  165. /// this custom decoder attempts to decode from `"received"` first, then falls back to `"recieved"`
  166. /// if necessary.
  167. ///
  168. /// Encoding always uses the correct `"received"` key to ensure consistent, standards-compliant output.
  169. ///
  170. /// This improves resilience and ensures compatibility with imported loop history, simulations,
  171. /// or devicestatus artifacts that may contain typos in their keys.
  172. extension Determination: Codable {
  173. private enum CodingKeys: String, CodingKey {
  174. case id
  175. case reason
  176. case units
  177. case insulinReq
  178. case eventualBG
  179. case sensitivityRatio
  180. case rate
  181. case duration
  182. case iob = "IOB"
  183. case cob = "COB"
  184. case predictions = "predBGs"
  185. case deliverAt
  186. case carbsReq
  187. case temp
  188. case bg
  189. case reservoir
  190. case timestamp
  191. case isf = "ISF"
  192. case current_target
  193. case tdd = "TDD"
  194. case insulinForManualBolus
  195. case manualBolusErrorString
  196. case minDelta
  197. case expectedDelta
  198. case minGuardBG
  199. case minPredBG
  200. case threshold
  201. case carbRatio = "CR"
  202. case received
  203. case receivedAlt = "recieved"
  204. }
  205. init(from decoder: Decoder) throws {
  206. let container = try decoder.container(keyedBy: CodingKeys.self)
  207. id = try container.decodeIfPresent(UUID.self, forKey: .id)
  208. reason = try container.decode(String.self, forKey: .reason)
  209. units = try container.decodeIfPresent(Decimal.self, forKey: .units)
  210. insulinReq = try container.decodeIfPresent(Decimal.self, forKey: .insulinReq)
  211. eventualBG = try container.decodeIfPresent(Int.self, forKey: .eventualBG)
  212. sensitivityRatio = try container.decodeIfPresent(Decimal.self, forKey: .sensitivityRatio)
  213. rate = try container.decodeIfPresent(Decimal.self, forKey: .rate)
  214. duration = try container.decodeIfPresent(Decimal.self, forKey: .duration)
  215. iob = try container.decodeIfPresent(Decimal.self, forKey: .iob)
  216. cob = try container.decodeIfPresent(Decimal.self, forKey: .cob)
  217. predictions = try container.decodeIfPresent(Predictions.self, forKey: .predictions)
  218. deliverAt = try container.decodeIfPresent(Date.self, forKey: .deliverAt)
  219. carbsReq = try container.decodeIfPresent(Decimal.self, forKey: .carbsReq)
  220. temp = try container.decodeIfPresent(TempType.self, forKey: .temp)
  221. bg = try container.decodeIfPresent(Decimal.self, forKey: .bg)
  222. reservoir = try container.decodeIfPresent(Decimal.self, forKey: .reservoir)
  223. timestamp = try container.decodeIfPresent(Date.self, forKey: .timestamp)
  224. isf = try container.decodeIfPresent(Decimal.self, forKey: .isf)
  225. current_target = try container.decodeIfPresent(Decimal.self, forKey: .current_target)
  226. tdd = try container.decodeIfPresent(Decimal.self, forKey: .tdd)
  227. insulinForManualBolus = try container.decodeIfPresent(Decimal.self, forKey: .insulinForManualBolus)
  228. manualBolusErrorString = try container.decodeIfPresent(Decimal.self, forKey: .manualBolusErrorString)
  229. minDelta = try container.decodeIfPresent(Decimal.self, forKey: .minDelta)
  230. expectedDelta = try container.decodeIfPresent(Decimal.self, forKey: .expectedDelta)
  231. minGuardBG = try container.decodeIfPresent(Decimal.self, forKey: .minGuardBG)
  232. minPredBG = try container.decodeIfPresent(Decimal.self, forKey: .minPredBG)
  233. threshold = try container.decodeIfPresent(Decimal.self, forKey: .threshold)
  234. carbRatio = try container.decodeIfPresent(Decimal.self, forKey: .carbRatio)
  235. // Handle both spellings of "received"
  236. if let value = try container.decodeIfPresent(Bool.self, forKey: .received) {
  237. received = value
  238. } else if let fallback = try container.decodeIfPresent(Bool.self, forKey: .receivedAlt) {
  239. received = fallback
  240. } else {
  241. received = nil
  242. }
  243. }
  244. func encode(to encoder: Encoder) throws {
  245. var container = encoder.container(keyedBy: CodingKeys.self)
  246. try container.encodeIfPresent(id, forKey: .id)
  247. try container.encode(reason, forKey: .reason)
  248. try container.encodeIfPresent(units, forKey: .units)
  249. try container.encodeIfPresent(insulinReq, forKey: .insulinReq)
  250. try container.encodeIfPresent(eventualBG, forKey: .eventualBG)
  251. try container.encodeIfPresent(sensitivityRatio, forKey: .sensitivityRatio)
  252. try container.encodeIfPresent(rate, forKey: .rate)
  253. try container.encodeIfPresent(duration, forKey: .duration)
  254. try container.encodeIfPresent(iob, forKey: .iob)
  255. try container.encodeIfPresent(cob, forKey: .cob)
  256. try container.encodeIfPresent(predictions, forKey: .predictions)
  257. try container.encodeIfPresent(deliverAt, forKey: .deliverAt)
  258. try container.encodeIfPresent(carbsReq, forKey: .carbsReq)
  259. try container.encodeIfPresent(temp, forKey: .temp)
  260. try container.encodeIfPresent(bg, forKey: .bg)
  261. try container.encodeIfPresent(reservoir, forKey: .reservoir)
  262. try container.encodeIfPresent(timestamp, forKey: .timestamp)
  263. try container.encodeIfPresent(isf, forKey: .isf)
  264. try container.encodeIfPresent(current_target, forKey: .current_target)
  265. try container.encodeIfPresent(tdd, forKey: .tdd)
  266. try container.encodeIfPresent(insulinForManualBolus, forKey: .insulinForManualBolus)
  267. try container.encodeIfPresent(manualBolusErrorString, forKey: .manualBolusErrorString)
  268. try container.encodeIfPresent(minDelta, forKey: .minDelta)
  269. try container.encodeIfPresent(expectedDelta, forKey: .expectedDelta)
  270. try container.encodeIfPresent(minGuardBG, forKey: .minGuardBG)
  271. try container.encodeIfPresent(minPredBG, forKey: .minPredBG)
  272. try container.encodeIfPresent(threshold, forKey: .threshold)
  273. try container.encodeIfPresent(carbRatio, forKey: .carbRatio)
  274. try container.encodeIfPresent(received, forKey: .received) // always encode the correct spelling
  275. }
  276. /// Helper function to convert `Determination` to `OrefDetermination` while importing JSON glucose entries
  277. func store(in context: NSManagedObjectContext) throws {
  278. // TODO: some guards here ?!
  279. let newOrefDetermination = OrefDetermination(context: context)
  280. newOrefDetermination.id = UUID()
  281. newOrefDetermination.insulinSensitivity = decimalToNSDecimalNumber(isf)
  282. newOrefDetermination.currentTarget = decimalToNSDecimalNumber(current_target)
  283. newOrefDetermination.eventualBG = eventualBG.map(NSDecimalNumber.init)
  284. newOrefDetermination.deliverAt = deliverAt
  285. newOrefDetermination.timestamp = timestamp
  286. newOrefDetermination.enacted = received ?? false
  287. newOrefDetermination.insulinForManualBolus = decimalToNSDecimalNumber(insulinForManualBolus)
  288. newOrefDetermination.carbRatio = decimalToNSDecimalNumber(carbRatio)
  289. newOrefDetermination.glucose = decimalToNSDecimalNumber(bg)
  290. newOrefDetermination.reservoir = decimalToNSDecimalNumber(reservoir)
  291. newOrefDetermination.insulinReq = decimalToNSDecimalNumber(insulinReq)
  292. newOrefDetermination.temp = temp?.rawValue ?? "absolute"
  293. newOrefDetermination.rate = decimalToNSDecimalNumber(rate)
  294. newOrefDetermination.reason = reason
  295. newOrefDetermination.duration = decimalToNSDecimalNumber(duration)
  296. newOrefDetermination.iob = decimalToNSDecimalNumber(iob)
  297. newOrefDetermination.threshold = decimalToNSDecimalNumber(threshold)
  298. newOrefDetermination.minDelta = decimalToNSDecimalNumber(minDelta)
  299. newOrefDetermination.sensitivityRatio = decimalToNSDecimalNumber(sensitivityRatio)
  300. newOrefDetermination.expectedDelta = decimalToNSDecimalNumber(expectedDelta)
  301. newOrefDetermination.cob = Int16(Int(cob ?? 0))
  302. newOrefDetermination.manualBolusErrorString = decimalToNSDecimalNumber(manualBolusErrorString)
  303. newOrefDetermination.smbToDeliver = units.map { NSDecimalNumber(decimal: $0) }
  304. newOrefDetermination.carbsRequired = Int16(Int(carbsReq ?? 0))
  305. newOrefDetermination.isUploadedToNS = true
  306. if let predictions = predictions {
  307. ["iob": predictions.iob, "zt": predictions.zt, "cob": predictions.cob, "uam": predictions.uam]
  308. .forEach { type, values in
  309. if let values = values {
  310. let forecast = Forecast(context: context)
  311. forecast.id = UUID()
  312. forecast.type = type
  313. forecast.date = Date()
  314. forecast.orefDetermination = newOrefDetermination
  315. for (index, value) in values.enumerated() {
  316. let forecastValue = ForecastValue(context: context)
  317. forecastValue.index = Int32(index)
  318. forecastValue.value = Int32(value)
  319. forecast.addToForecastValues(forecastValue)
  320. }
  321. newOrefDetermination.addToForecasts(forecast)
  322. }
  323. }
  324. }
  325. }
  326. func decimalToNSDecimalNumber(_ value: Decimal?) -> NSDecimalNumber? {
  327. guard let value = value else { return nil }
  328. return NSDecimalNumber(decimal: value)
  329. }
  330. }
  331. extension JSONImporter {
  332. func importGlucoseHistoryIfNeeded() async {}
  333. func importDeterminationIfNeeded() async {}
  334. }