CarbsStorage.swift 16 KB

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