CarbsStorage.swift 14 KB

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