CarbsStorage.swift 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  1. import Combine
  2. import CoreData
  3. import Foundation
  4. import SwiftDate
  5. import Swinject
  6. protocol CarbsObserver {
  7. func carbsDidUpdate(_ carbs: [CarbsEntry])
  8. }
  9. protocol CarbsStorage {
  10. var updatePublisher: AnyPublisher<Void, Never> { get }
  11. func storeCarbs(_ carbs: [CarbsEntry], areFetchedFromRemote: Bool) async
  12. func deleteCarbsEntryStored(_ treatmentObjectID: NSManagedObjectID) async
  13. func syncDate() -> Date
  14. func recent() -> [CarbsEntry]
  15. func getCarbsNotYetUploadedToNightscout() async -> [NightscoutTreatment]
  16. func getFPUsNotYetUploadedToNightscout() async -> [NightscoutTreatment]
  17. func getCarbsNotYetUploadedToHealth() async -> [CarbsEntry]
  18. func getCarbsNotYetUploadedToTidepool() async -> [CarbsEntry]
  19. }
  20. final class BaseCarbsStorage: CarbsStorage, Injectable {
  21. private let processQueue = DispatchQueue(label: "BaseCarbsStorage.processQueue")
  22. @Injected() private var storage: FileStorage!
  23. @Injected() private var broadcaster: Broadcaster!
  24. @Injected() private var settings: SettingsManager!
  25. let coredataContext = CoreDataStack.shared.newTaskContext()
  26. private let updateSubject = PassthroughSubject<Void, Never>()
  27. var updatePublisher: AnyPublisher<Void, Never> {
  28. updateSubject.eraseToAnyPublisher()
  29. }
  30. init(resolver: Resolver) {
  31. injectServices(resolver)
  32. }
  33. func storeCarbs(_ entries: [CarbsEntry], areFetchedFromRemote: Bool) async {
  34. var entriesToStore = entries
  35. if areFetchedFromRemote {
  36. entriesToStore = await filterRemoteEntries(entries: entriesToStore)
  37. }
  38. // Check for FPU-only entries (fat/protein without carbs)
  39. let fpuOnlyEntries = entriesToStore.filter { entry in
  40. entry.carbs == 0 && (entry.fat ?? 0 > 0 || entry.protein ?? 0 > 0)
  41. }
  42. // Create additional Carb (non-FPU) entries with fat/protein amounts and carbs == 0
  43. for entry in fpuOnlyEntries {
  44. let additionalEntry = CarbsEntry(
  45. id: entry.id,
  46. createdAt: entry.createdAt,
  47. actualDate: entry.actualDate,
  48. carbs: Decimal(0),
  49. fat: entry.fat,
  50. protein: entry.protein,
  51. note: entry.note,
  52. enteredBy: entry.enteredBy,
  53. isFPU: false, // it should be a Carb entry
  54. fpuID: entry.fpuID
  55. )
  56. entriesToStore.append(additionalEntry)
  57. }
  58. await saveCarbsToCoreData(entries: entriesToStore, areFetchedFromRemote: areFetchedFromRemote)
  59. await saveCarbEquivalents(entries: entriesToStore, areFetchedFromRemote: areFetchedFromRemote)
  60. }
  61. private func filterRemoteEntries(entries: [CarbsEntry]) async -> [CarbsEntry] {
  62. // Fetch only the date property from Core Data
  63. guard let existing24hCarbEntries = await CoreDataStack.shared.fetchEntitiesAsync(
  64. ofType: CarbEntryStored.self,
  65. onContext: coredataContext,
  66. predicate: NSPredicate.predicateForOneDayAgo,
  67. key: "date",
  68. ascending: false,
  69. batchSize: 50,
  70. propertiesToFetch: ["date", "objectID"]
  71. ) as? [[String: Any]] else {
  72. return entries
  73. }
  74. // Extract dates into a set for efficient lookup
  75. // Since we are not dealing with NSManagedObjects directly it is safe to pass properties between threads
  76. let existingTimestamps = Set(existing24hCarbEntries.compactMap { $0["date"] as? Date })
  77. // Remove all entries that have a matching date in existingTimestamps
  78. var filteredEntries = entries
  79. filteredEntries.removeAll { entry in
  80. let entryDate = entry.actualDate ?? entry.createdAt
  81. return existingTimestamps.contains(entryDate)
  82. }
  83. return filteredEntries
  84. }
  85. /**
  86. Calculates the duration for processing FPUs (fat and protein units) based on the FPUs and the time cap.
  87. - The function uses predefined rules to determine the duration based on the number of FPUs.
  88. - Ensures that the duration does not exceed the time cap.
  89. - Parameters:
  90. - fpus: The number of FPUs calculated from fat and protein.
  91. - timeCap: The maximum allowed duration.
  92. - Returns: The computed duration in hours.
  93. */
  94. private func calculateComputedDuration(fpus: Decimal, timeCap: Int) -> Int {
  95. switch fpus {
  96. case ..<2:
  97. return 3
  98. case 2 ..< 3:
  99. return 4
  100. case 3 ..< 4:
  101. return 5
  102. default:
  103. return timeCap
  104. }
  105. }
  106. /**
  107. Processes fat and protein entries to generate future carb equivalents, ensuring each equivalent is at least 1.0 grams.
  108. - The function calculates the equivalent carb dosage size and adjusts the interval to ensure each equivalent is at least 1.0 grams.
  109. - Creates future carb entries based on the adjusted carb equivalent size and interval.
  110. - Parameters:
  111. - entries: An array of `CarbsEntry` objects representing the carbohydrate entries to be processed.
  112. - fat: The amount of fat in the last entry.
  113. - protein: The amount of protein in the last entry.
  114. - createdAt: The creation date of the last entry.
  115. - Returns: A tuple containing the array of future carb entries and the total carb equivalents.
  116. */
  117. private func processFPU(
  118. entries: [CarbsEntry],
  119. fat: Decimal,
  120. protein: Decimal,
  121. createdAt: Date,
  122. actualDate: Date?
  123. ) -> ([CarbsEntry], Decimal) {
  124. let interval = settings.settings.minuteInterval
  125. let timeCap = settings.settings.timeCap
  126. let adjustment = settings.settings.individualAdjustmentFactor
  127. let delay = settings.settings.delay
  128. let kcal = protein * 4 + fat * 9
  129. let carbEquivalents = (kcal / 10) * adjustment
  130. let fpus = carbEquivalents / 10
  131. var computedDuration = calculateComputedDuration(fpus: fpus, timeCap: timeCap)
  132. var carbEquivalentSize: Decimal = carbEquivalents / Decimal(computedDuration)
  133. carbEquivalentSize /= Decimal(60 / interval)
  134. if carbEquivalentSize < 1.0 {
  135. carbEquivalentSize = 1.0
  136. computedDuration = Int(carbEquivalents / carbEquivalentSize)
  137. }
  138. let roundedEquivalent: Double = round(Double(carbEquivalentSize * 10)) / 10
  139. carbEquivalentSize = Decimal(roundedEquivalent)
  140. var numberOfEquivalents = carbEquivalents / carbEquivalentSize
  141. var useDate = actualDate ?? createdAt
  142. let fpuID = entries.first?.fpuID ?? UUID().uuidString
  143. var futureCarbArray = [CarbsEntry]()
  144. var firstIndex = true
  145. while carbEquivalents > 0, numberOfEquivalents > 0 {
  146. useDate = firstIndex ? useDate.addingTimeInterval(delay.minutes.timeInterval) : useDate
  147. .addingTimeInterval(interval.minutes.timeInterval)
  148. firstIndex = false
  149. let eachCarbEntry = CarbsEntry(
  150. id: UUID().uuidString,
  151. createdAt: createdAt,
  152. actualDate: useDate,
  153. carbs: carbEquivalentSize,
  154. fat: 0,
  155. protein: 0,
  156. note: nil,
  157. enteredBy: CarbsEntry.local,
  158. isFPU: true,
  159. fpuID: fpuID
  160. )
  161. futureCarbArray.append(eachCarbEntry)
  162. numberOfEquivalents -= 1
  163. }
  164. return (futureCarbArray, carbEquivalents)
  165. }
  166. private func saveCarbEquivalents(entries: [CarbsEntry], areFetchedFromRemote: Bool) async {
  167. guard let lastEntry = entries.last else { return }
  168. if let fat = lastEntry.fat, let protein = lastEntry.protein, fat > 0 || protein > 0 {
  169. let (futureCarbEquivalents, carbEquivalentCount) = processFPU(
  170. entries: entries,
  171. fat: fat,
  172. protein: protein,
  173. createdAt: lastEntry.createdAt,
  174. actualDate: lastEntry.actualDate
  175. )
  176. if carbEquivalentCount > 0 {
  177. await saveFPUToCoreDataAsBatchInsert(entries: futureCarbEquivalents, areFetchedFromRemote: areFetchedFromRemote)
  178. }
  179. }
  180. }
  181. private func saveCarbsToCoreData(entries: [CarbsEntry], areFetchedFromRemote: Bool) async {
  182. guard let entry = entries.last else { return }
  183. await coredataContext.perform {
  184. let newItem = CarbEntryStored(context: self.coredataContext)
  185. newItem.date = entry.actualDate ?? entry.createdAt
  186. newItem.carbs = Double(truncating: NSDecimalNumber(decimal: entry.carbs))
  187. newItem.fat = Double(truncating: NSDecimalNumber(decimal: entry.fat ?? 0))
  188. newItem.protein = Double(truncating: NSDecimalNumber(decimal: entry.protein ?? 0))
  189. newItem.note = entry.note
  190. newItem.id = UUID()
  191. newItem.isFPU = false
  192. newItem.isUploadedToNS = areFetchedFromRemote ? true : false
  193. newItem.isUploadedToHealth = false
  194. newItem.isUploadedToTidepool = false
  195. if entry.fat != nil, entry.protein != nil, let fpuId = entry.fpuID {
  196. newItem.fpuID = UUID(uuidString: fpuId)
  197. }
  198. do {
  199. guard self.coredataContext.hasChanges else { return }
  200. try self.coredataContext.save()
  201. } catch {
  202. print(error.localizedDescription)
  203. }
  204. }
  205. }
  206. private func saveFPUToCoreDataAsBatchInsert(entries: [CarbsEntry], areFetchedFromRemote: Bool) async {
  207. let commonFPUID = UUID(
  208. uuidString: entries.first?.fpuID ?? UUID()
  209. .uuidString
  210. ) // all fpus should only get ONE id per batch insert to be able to delete them referencing the fpuID
  211. var entrySlice = ArraySlice(entries) // convert to ArraySlice
  212. let batchInsert = NSBatchInsertRequest(entity: CarbEntryStored.entity()) { (managedObject: NSManagedObject) -> Bool in
  213. guard let carbEntry = managedObject as? CarbEntryStored, let entry = entrySlice.popFirst(),
  214. let entryId = entry.id
  215. else {
  216. return true // return true to stop
  217. }
  218. carbEntry.date = entry.actualDate
  219. carbEntry.carbs = Double(truncating: NSDecimalNumber(decimal: entry.carbs))
  220. carbEntry.id = UUID.init(uuidString: entryId)
  221. carbEntry.fpuID = commonFPUID
  222. carbEntry.isFPU = true
  223. carbEntry.isUploadedToNS = areFetchedFromRemote ? true : false
  224. // do NOT set Health and Tidepool flags to ensure they will NOT be uploaded
  225. return false // return false to continue
  226. }
  227. await coredataContext.perform {
  228. do {
  229. try self.coredataContext.execute(batchInsert)
  230. debugPrint("Carbs Storage: \(DebuggingIdentifiers.succeeded) saved fpus to core data")
  231. // Notify subscriber in Home State Model to update the FPU Array
  232. self.updateSubject.send(())
  233. } catch {
  234. debugPrint("Carbs Storage: \(DebuggingIdentifiers.failed) error while saving fpus to core data")
  235. }
  236. }
  237. }
  238. func syncDate() -> Date {
  239. Date().addingTimeInterval(-1.days.timeInterval)
  240. }
  241. func recent() -> [CarbsEntry] {
  242. storage.retrieve(OpenAPS.Monitor.carbHistory, as: [CarbsEntry].self)?.reversed() ?? []
  243. }
  244. func deleteCarbsEntryStored(_ treatmentObjectID: NSManagedObjectID) async {
  245. let taskContext = CoreDataStack.shared.newTaskContext()
  246. taskContext.name = "deleteContext"
  247. taskContext.transactionAuthor = "deleteCarbs"
  248. var carbEntryFromCoreData: CarbEntryStored?
  249. await taskContext.perform {
  250. do {
  251. carbEntryFromCoreData = try taskContext.existingObject(with: treatmentObjectID) as? CarbEntryStored
  252. guard let carbEntry = carbEntryFromCoreData else {
  253. debugPrint("Carb entry for batch delete not found. \(DebuggingIdentifiers.failed)")
  254. return
  255. }
  256. // entry has fpuID
  257. // case 1: carb equivalent entry
  258. // case 2: "parent" entry, but containing fat and/or protein, and possibly carbs
  259. // => use fpuID ID to delete all corresponding entries via batch delete
  260. if let fpuID = carbEntry.fpuID {
  261. // fetch request for all carb entries with the same id
  262. let fetchRequest: NSFetchRequest<NSFetchRequestResult> = CarbEntryStored.fetchRequest()
  263. fetchRequest.predicate = NSPredicate(format: "fpuID == %@", fpuID as CVarArg)
  264. // NSBatchDeleteRequest
  265. let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest)
  266. deleteRequest.resultType = .resultTypeCount
  267. // execute the batch delete request
  268. let result = try taskContext.execute(deleteRequest) as? NSBatchDeleteResult
  269. debugPrint("\(DebuggingIdentifiers.succeeded) Deleted \(result?.result ?? 0) items with FpuID \(fpuID)")
  270. // Notifiy subscribers of the batch delete
  271. self.updateSubject.send(())
  272. }
  273. // entry has no fpuID
  274. // => it's a carb-only entry. use its ID to for deletion
  275. else {
  276. taskContext.delete(carbEntry)
  277. guard taskContext.hasChanges else { return }
  278. try taskContext.save()
  279. debugPrint(
  280. "CarbsStorage: \(#function) \(DebuggingIdentifiers.succeeded) deleted carb entry from core data"
  281. )
  282. }
  283. } catch {
  284. debugPrint("\(DebuggingIdentifiers.failed) Error deleting carb entry: \(error.localizedDescription)")
  285. }
  286. }
  287. }
  288. func getCarbsNotYetUploadedToNightscout() async -> [NightscoutTreatment] {
  289. let results = await CoreDataStack.shared.fetchEntitiesAsync(
  290. ofType: CarbEntryStored.self,
  291. onContext: coredataContext,
  292. predicate: NSPredicate.carbsNotYetUploadedToNightscout,
  293. key: "date",
  294. ascending: false
  295. )
  296. return await coredataContext.perform {
  297. guard let carbEntries = results as? [CarbEntryStored] else {
  298. return []
  299. }
  300. return carbEntries.map { result in
  301. NightscoutTreatment(
  302. duration: nil,
  303. rawDuration: nil,
  304. rawRate: nil,
  305. absolute: nil,
  306. rate: nil,
  307. eventType: .nsCarbCorrection,
  308. createdAt: result.date,
  309. enteredBy: CarbsEntry.local,
  310. bolus: nil,
  311. insulin: nil,
  312. notes: result.note,
  313. carbs: Decimal(result.carbs),
  314. fat: Decimal(result.fat),
  315. protein: Decimal(result.protein),
  316. foodType: result.note,
  317. targetTop: nil,
  318. targetBottom: nil,
  319. id: result.id?.uuidString
  320. )
  321. }
  322. }
  323. }
  324. func getFPUsNotYetUploadedToNightscout() async -> [NightscoutTreatment] {
  325. let results = await CoreDataStack.shared.fetchEntitiesAsync(
  326. ofType: CarbEntryStored.self,
  327. onContext: coredataContext,
  328. predicate: NSPredicate.fpusNotYetUploadedToNightscout,
  329. key: "date",
  330. ascending: false
  331. )
  332. return await coredataContext.perform {
  333. guard let fpuEntries = results as? [CarbEntryStored] else { return [] }
  334. return fpuEntries.map { result in
  335. NightscoutTreatment(
  336. duration: nil,
  337. rawDuration: nil,
  338. rawRate: nil,
  339. absolute: nil,
  340. rate: nil,
  341. eventType: .nsCarbCorrection,
  342. createdAt: result.date,
  343. enteredBy: CarbsEntry.local,
  344. bolus: nil,
  345. insulin: nil,
  346. notes: result.note,
  347. carbs: Decimal(result.carbs),
  348. fat: Decimal(result.fat),
  349. protein: Decimal(result.protein),
  350. foodType: result.note,
  351. targetTop: nil,
  352. targetBottom: nil,
  353. id: result.fpuID?.uuidString
  354. )
  355. }
  356. }
  357. }
  358. func getCarbsNotYetUploadedToHealth() async -> [CarbsEntry] {
  359. let results = await CoreDataStack.shared.fetchEntitiesAsync(
  360. ofType: CarbEntryStored.self,
  361. onContext: coredataContext,
  362. predicate: NSPredicate.carbsNotYetUploadedToHealth,
  363. key: "date",
  364. ascending: false
  365. )
  366. guard let carbEntries = results as? [CarbEntryStored] else {
  367. return []
  368. }
  369. return await coredataContext.perform {
  370. return carbEntries.map { result in
  371. CarbsEntry(
  372. id: result.id?.uuidString,
  373. createdAt: result.date ?? Date(),
  374. actualDate: result.date,
  375. carbs: Decimal(result.carbs),
  376. fat: Decimal(result.fat),
  377. protein: Decimal(result.protein),
  378. note: result.note,
  379. enteredBy: CarbsEntry.local,
  380. isFPU: result.isFPU,
  381. fpuID: result.fpuID?.uuidString
  382. )
  383. }
  384. }
  385. }
  386. func getCarbsNotYetUploadedToTidepool() async -> [CarbsEntry] {
  387. let results = await CoreDataStack.shared.fetchEntitiesAsync(
  388. ofType: CarbEntryStored.self,
  389. onContext: coredataContext,
  390. predicate: NSPredicate.carbsNotYetUploadedToTidepool,
  391. key: "date",
  392. ascending: false
  393. )
  394. guard let carbEntries = results as? [CarbEntryStored] else {
  395. return []
  396. }
  397. return await coredataContext.perform {
  398. return carbEntries.map { result in
  399. CarbsEntry(
  400. id: result.id?.uuidString,
  401. createdAt: result.date ?? Date(),
  402. actualDate: result.date,
  403. carbs: Decimal(result.carbs),
  404. fat: nil,
  405. protein: nil,
  406. note: result.note,
  407. enteredBy: CarbsEntry.local,
  408. isFPU: nil,
  409. fpuID: nil
  410. )
  411. }
  412. }
  413. }
  414. }