CarbsStorage.swift 17 KB

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