TDDStorage.swift 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. import Foundation
  2. import LoopKitUI
  3. import Swinject
  4. protocol TDDStorage {
  5. func calculateTDD(pumpManager: any PumpManagerUI, pumpHistory: [PumpHistoryEvent], basalProfile: [BasalProfileEntry]) async
  6. -> TDDResult
  7. func storeTDD(_ tddResult: TDDResult) async
  8. }
  9. /// Structure containing the results of TDD calculations
  10. struct TDDResult {
  11. let total: Decimal
  12. let bolus: Decimal
  13. let tempBasal: Decimal
  14. let scheduledBasal: Decimal
  15. let weightedAverage: Decimal?
  16. let hoursOfData: Double
  17. }
  18. /// Implementation of the TDD Calculator
  19. final class BaseTDDStorage: TDDStorage, Injectable {
  20. @Injected() private var storage: FileStorage!
  21. init(resolver: Resolver) {
  22. injectServices(resolver)
  23. }
  24. private let privateContext = CoreDataStack.shared.newTaskContext()
  25. /// Main function to calculate TDD from pump history and basal profile
  26. /// - Parameters:
  27. /// - pumpManager: Representation of paired pump's PumpManagerUI
  28. /// - pumpHistory: Array of pump history events
  29. /// - basalProfile: Array of basal profile entries
  30. /// - Returns: TDDResult containing all calculated values
  31. func calculateTDD(
  32. pumpManager: any PumpManagerUI,
  33. pumpHistory: [PumpHistoryEvent],
  34. basalProfile: [BasalProfileEntry]
  35. ) async -> TDDResult {
  36. debug(.apsManager, "Starting TDD calculation with \(pumpHistory.count) pump events")
  37. var bolusInsulin: Decimal = 0
  38. var tempBasalInsulin: Decimal = 0
  39. var scheduledBasalInsulin: Decimal = 0
  40. let pumpData = calculatePumpDataHours(pumpHistory)
  41. debug(.apsManager, "Hours of pump data available: \(pumpData)")
  42. let bolusEvents = pumpHistory.filter({ $0.type == .bolus })
  43. let tempBasalEvents = pumpHistory.filter({ $0.type == .tempBasal })
  44. debug(.apsManager, "Temp basal events: \(tempBasalEvents.description)")
  45. let gaps = findBasalGaps(in: tempBasalEvents)
  46. if !gaps.isEmpty {
  47. scheduledBasalInsulin = calculateScheduledBasalInsulin(
  48. gaps: gaps,
  49. profile: basalProfile,
  50. roundToSupportedBasalRate: pumpManager.roundToSupportedBasalRate
  51. )
  52. debug(.apsManager, "Total scheduled basal insulin: \(scheduledBasalInsulin)U")
  53. }
  54. bolusInsulin = calculateBolusInsulin(bolusEvents)
  55. debug(.apsManager, "Total bolus insulin: \(bolusInsulin)U")
  56. tempBasalInsulin = calculateTempBasalInsulin(
  57. tempBasalEvents,
  58. roundToSupportedBasalRate: pumpManager.roundToSupportedBasalRate
  59. )
  60. debug(.apsManager, "Total temp basal insulin: \(tempBasalInsulin)U")
  61. let total = bolusInsulin + tempBasalInsulin + scheduledBasalInsulin
  62. let weightedAverage = await calculateWeightedAverage()
  63. debug(.apsManager, """
  64. TDD Summary:
  65. - Total: \(total) U
  66. - Bolus: \(bolusInsulin) U (\((bolusInsulin / total * 100).rounded(toPlaces: 1)) %)
  67. - Temp Basal: \(tempBasalInsulin) U (\((tempBasalInsulin / total * 100).rounded(toPlaces: 1)) %)
  68. - Scheduled Basal: \(scheduledBasalInsulin) U (\((scheduledBasalInsulin / total * 100).rounded(toPlaces: 1)) %)
  69. - WeightedAverage: \(weightedAverage ?? 0) U
  70. - Hours of Data: \(pumpData)
  71. """)
  72. return TDDResult(
  73. total: total,
  74. bolus: bolusInsulin,
  75. tempBasal: tempBasalInsulin,
  76. scheduledBasal: scheduledBasalInsulin,
  77. weightedAverage: weightedAverage,
  78. hoursOfData: pumpData
  79. )
  80. }
  81. /// Finds gaps between tempBasal events where scheduled basal ran
  82. /// - Parameter tempBasalEvents: Array of pump history events of type tempBasal
  83. /// - Returns: Array of gaps, where each gap has a start and end time
  84. private func findBasalGaps(in tempBasalEvents: [PumpHistoryEvent]) -> [(start: Date, end: Date)] {
  85. guard !tempBasalEvents.isEmpty else {
  86. // No events = full day gap
  87. let startOfDay = Calendar.current.startOfDay(for: Date())
  88. let endOfDay = startOfDay.addingTimeInterval(24 * 60 * 60 - 1)
  89. return [(start: startOfDay, end: endOfDay)]
  90. }
  91. // Sort events by timestamp
  92. let sortedEvents = tempBasalEvents.sorted { $0.timestamp < $1.timestamp }
  93. var gaps: [(start: Date, end: Date)] = []
  94. // Track the end time of the last temp basal event
  95. var lastEndTime: Date?
  96. for (index, event) in sortedEvents.enumerated() {
  97. // Calculate the actual end time for the current event
  98. guard let duration = event.duration else { continue }
  99. var currentEndTime = event.timestamp.addingTimeInterval(TimeInterval(duration * 60))
  100. // Check for a cancellation
  101. if index < sortedEvents.count - 1 {
  102. let nextEvent = sortedEvents[index + 1]
  103. if nextEvent.timestamp < currentEndTime {
  104. // The next event cancels this one, adjust the end time
  105. currentEndTime = nextEvent.timestamp
  106. }
  107. }
  108. // If there’s a gap between the last event's end time and the current event's start time, record it
  109. if let lastEnd = lastEndTime, event.timestamp > lastEnd {
  110. gaps.append((start: lastEnd, end: event.timestamp))
  111. }
  112. // Update the last end time to the current event's (possibly adjusted) end time
  113. lastEndTime = currentEndTime
  114. }
  115. // Handle gap at the end of the dataset (if needed)
  116. if let lastEnd = lastEndTime {
  117. let endOfDay = Calendar.current.startOfDay(for: sortedEvents.first!.timestamp)
  118. .addingTimeInterval(24 * 60 * 60 - 1)
  119. if lastEnd < endOfDay {
  120. gaps.append((start: lastEnd, end: endOfDay))
  121. }
  122. }
  123. return gaps
  124. }
  125. /// Stores the Total Daily Dose (TDD) result in Core Data
  126. /// - Parameter tddResult: The TDD result to store, containing total insulin, bolus, temp basal, scheduled basal and weighted average
  127. func storeTDD(_ tddResult: TDDResult) async {
  128. await privateContext.perform {
  129. let tddStored = TDDStored(context: self.privateContext)
  130. tddStored.id = UUID()
  131. tddStored.date = Date()
  132. tddStored.total = NSDecimalNumber(decimal: tddResult.total)
  133. tddStored.bolus = NSDecimalNumber(decimal: tddResult.bolus)
  134. tddStored.tempBasal = NSDecimalNumber(decimal: tddResult.tempBasal)
  135. tddStored.scheduledBasal = NSDecimalNumber(decimal: tddResult.scheduledBasal)
  136. tddStored.weightedAverage = tddResult.weightedAverage.map { NSDecimalNumber(decimal: $0) }
  137. do {
  138. guard self.privateContext.hasChanges else { return }
  139. try self.privateContext.save()
  140. } catch {
  141. debug(.apsManager, "\(DebuggingIdentifiers.failed) Failed to save TDD: \(error.localizedDescription)")
  142. }
  143. }
  144. }
  145. /// Calculates the number of hours of available pump history data
  146. /// - Parameter pumpHistory: Array of pump history events
  147. /// - Returns: Number of hours of available data
  148. private func calculatePumpDataHours(_ pumpHistory: [PumpHistoryEvent]) -> Double {
  149. guard let firstEvent = pumpHistory.last, // we are fetching in a descending order
  150. let lastEvent = pumpHistory.first
  151. else {
  152. return 0
  153. }
  154. let startDate = firstEvent.timestamp
  155. var endDate = lastEvent.timestamp
  156. // If last event in the list is tempBasal, check if it is running longer than current time
  157. // If yes, set current date, else ignore
  158. if lastEvent.type == .tempBasal, lastEvent.timestamp > Date().addingTimeInterval(-1) {
  159. endDate = Date()
  160. }
  161. return Double(endDate.timeIntervalSince(startDate)) / 3600.0
  162. }
  163. /// Calculates total bolus insulin from pump history
  164. /// - Parameter bolusEvents: Array of pump history events of type bolus
  165. /// - Returns: Total bolus insulin
  166. private func calculateBolusInsulin(_ bolusEvents: [PumpHistoryEvent]) -> Decimal {
  167. bolusEvents
  168. .reduce(Decimal(0)) { totalBolusInsulin, event in
  169. totalBolusInsulin + (event.amount ?? 0)
  170. }
  171. }
  172. /// Calculates insulin delivered via temporary basal rates, accounting for interruptions
  173. /// - Parameters:
  174. /// - tempBasalEvents: Array of pump history events of type tempBasal
  175. /// - Returns: Total temporary basal insulin
  176. private func calculateTempBasalInsulin(
  177. _ tempBasalEvents: [PumpHistoryEvent],
  178. roundToSupportedBasalRate: @escaping (_ unitsPerHour: Double) -> Double
  179. ) -> Decimal {
  180. guard !tempBasalEvents.isEmpty else { return Decimal(0) }
  181. let sortedEvents = tempBasalEvents.sorted { $0.timestamp < $1.timestamp }
  182. return sortedEvents.enumerated().reduce(Decimal(0)) { totalInsulin, currentEvent in
  183. let (index, event) = currentEvent
  184. // Ensure the event has valid data
  185. guard let rate = event.amount, // Rate in U/hr
  186. let durationMinutes = event.duration, // Duration in minutes
  187. rate > 0, durationMinutes > 0 else { return totalInsulin }
  188. // Calculate the actual duration in minutes the temp basal ran
  189. let actualDurationMinutes: Int
  190. if index < sortedEvents.count - 1 {
  191. // Next event exists; calculate if it interrupts the current event
  192. let nextEvent = sortedEvents[index + 1]
  193. let currentEndTime = event.timestamp.addingTimeInterval(TimeInterval(durationMinutes * 60))
  194. // Include a small buffer for timestamp comparison
  195. if nextEvent.timestamp.addingTimeInterval(-1) < currentEndTime {
  196. // Interrupted; calculate the actual duration
  197. let interruptedDuration = nextEvent.timestamp.timeIntervalSince(event.timestamp) / 60
  198. actualDurationMinutes = max(0, Int(interruptedDuration)) // Ensure non-negative duration
  199. } else {
  200. // Not interrupted; use full duration
  201. actualDurationMinutes = durationMinutes
  202. }
  203. } else {
  204. // Last event in the list; calculate if it is running longer than current time
  205. let currentEndTime = event.timestamp.addingTimeInterval(TimeInterval(durationMinutes * 60))
  206. if currentEndTime > Date().addingTimeInterval(-1) {
  207. let interruptedDuration = Date().timeIntervalSince(event.timestamp) / 60
  208. actualDurationMinutes = max(0, Int(interruptedDuration)) // Ensure non-negative duration
  209. } else {
  210. actualDurationMinutes = durationMinutes
  211. }
  212. }
  213. // Convert the duration to hours and calculate insulin
  214. let durationHours = Decimal(actualDurationMinutes) / 60
  215. let insulin = Decimal(roundToSupportedBasalRate(Double(rate * durationHours)))
  216. debug(
  217. .apsManager,
  218. "Temp basal: \(rate) U/hr for \(Decimal(actualDurationMinutes) / 60) hr = \(insulin) U"
  219. )
  220. return totalInsulin + insulin
  221. }
  222. }
  223. /// Calculates total scheduled basal insulin within gaps
  224. /// - Parameters:
  225. /// - tempBasalEvents: Array of pump history events of type tempBasal
  226. /// - profile: Array of basal profile entries
  227. /// - Returns: Total scheduled basal insulin
  228. private func calculateScheduledBasalInsulin(
  229. gaps: [(start: Date, end: Date)],
  230. profile: [BasalProfileEntry],
  231. roundToSupportedBasalRate: @escaping (_ unitsPerHour: Double) -> Double
  232. ) -> Decimal {
  233. var totalInsulin: Decimal = 0
  234. for gap in gaps {
  235. var currentTime = gap.start
  236. while currentTime < gap.end {
  237. guard let rate = findBasalRate(for: getTimeString(from: currentTime), in: profile) else {
  238. debug(.apsManager, "No basal rate found for time \(currentTime)")
  239. break
  240. }
  241. // Determine the next switch time in the basal profile or the end of the gap
  242. let nextSwitchTime = getNextBasalRateSwitch(after: currentTime, in: profile) ?? gap.end
  243. let endTime = min(nextSwitchTime, gap.end)
  244. // Calculate duration in hours and insulin delivered
  245. let duration = Decimal(endTime.timeIntervalSince(currentTime)) / 3600
  246. let insulin = Decimal(roundToSupportedBasalRate(Double(rate * duration)))
  247. totalInsulin += insulin
  248. debug(.apsManager, "Scheduled basal: \(rate) U/hr from \(currentTime) to \(endTime) = \(insulin) U")
  249. // Move to the next time block
  250. currentTime = endTime
  251. }
  252. }
  253. return totalInsulin
  254. }
  255. /// Finds the next basal profile switch after a given time
  256. /// - Parameters:
  257. /// - time: Current time
  258. /// - profile: Array of basal profile entries
  259. /// - Returns: The time of the next switch, if any
  260. private func getNextBasalRateSwitch(after time: Date, in profile: [BasalProfileEntry]) -> Date? {
  261. let calendar = Calendar.current
  262. let timeMinutes = calendar.component(.hour, from: time) * 60 + calendar.component(.minute, from: time)
  263. // Find the next entry in the profile after the current time
  264. for entry in profile {
  265. if entry.minutes > timeMinutes {
  266. let nextSwitchTime = calendar.startOfDay(for: time).addingTimeInterval(TimeInterval(entry.minutes * 60))
  267. return nextSwitchTime
  268. }
  269. }
  270. return nil // No further switches; end of day
  271. }
  272. /// Converts a Date to a time string in "HH:mm:ss" format
  273. private func getTimeString(from date: Date) -> String {
  274. let formatter = DateFormatter()
  275. formatter.dateFormat = "HH:mm:ss"
  276. return formatter.string(from: date)
  277. }
  278. /// Finds the basal rate for a specific time in the profile, considering closest increments or wide coverage.
  279. /// - Parameters:
  280. /// - timeString: Time string in "HH:mm:ss" format
  281. /// - profile: Array of basal profile entries
  282. /// - Returns: Basal rate if found
  283. private func findBasalRate(for timeString: String, in profile: [BasalProfileEntry]) -> Decimal? {
  284. // Convert the timeString to minutes since midnight
  285. let timeComponents = timeString.split(separator: ":").compactMap { Int($0) }
  286. guard timeComponents.count == 3 else { return nil }
  287. let totalMinutes = timeComponents[0] * 60 + timeComponents[1]
  288. // If only one entry exists, return its rate (covers full 24 hours)
  289. guard profile.count > 1 else {
  290. return profile.first?.rate
  291. }
  292. // Find the closest matching basal entry
  293. for (index, entry) in profile.enumerated() {
  294. // Check if the time falls within the range of the current entry
  295. let startMinutes = entry.minutes
  296. let endMinutes = (index + 1 < profile.count) ? profile[index + 1].minutes : 1440 // End of the day
  297. if totalMinutes >= startMinutes, totalMinutes < endMinutes {
  298. return entry.rate
  299. }
  300. }
  301. // Default to nil if no match found
  302. return nil
  303. }
  304. /// Calculates a weighted average of Total Daily Dose (TDD) based on recent and historical data
  305. ///
  306. /// The weighted average is calculated using two time periods:
  307. /// - Recent: Last 2 hours of TDD data
  308. /// - Historical: Last 10 days of TDD data
  309. ///
  310. /// The formula used is:
  311. /// ```
  312. /// weightedTDD = (weightPercentage × recent_average) + ((1 - weightPercentage) × historical_average)
  313. /// ```
  314. /// where weightPercentage defaults to 0.65 if not set in preferences
  315. ///
  316. /// - Returns: A weighted average of TDD as Decimal, or nil if insufficient data
  317. /// - Note: The weight percentage can be configured in preferences. Default is 0.65 (65% recent, 35% historical)
  318. private func calculateWeightedAverage() async -> Decimal? {
  319. // Fetch data from Core Data
  320. let tenDaysAgo = Date().addingTimeInterval(-10.days.timeInterval)
  321. let twoHoursAgo = Date().addingTimeInterval(-2.hours.timeInterval)
  322. let predicate = NSPredicate(format: "date >= %@", tenDaysAgo as NSDate)
  323. let results = await CoreDataStack.shared.fetchEntitiesAsync(
  324. ofType: TDDStored.self,
  325. onContext: privateContext,
  326. predicate: predicate,
  327. key: "date",
  328. ascending: false
  329. )
  330. return await privateContext.perform { () -> Decimal? in
  331. guard let results = results as? [TDDStored], !results.isEmpty else { return 0 }
  332. // Calculate recent (2h) average
  333. let recentResults = results.filter { $0.date?.timeIntervalSince(twoHoursAgo) ?? 0 > 0 }
  334. let recentTotal = recentResults.compactMap { $0.total?.decimalValue }.reduce(0, +)
  335. let recentCount = max(Decimal(recentResults.count), 1)
  336. let averageTDDLastTwoHours = recentTotal / recentCount
  337. // Calculate 10-day average
  338. let totalTDD = results.compactMap { $0.total?.decimalValue }.reduce(0, +)
  339. let totalCount = max(Decimal(results.count), 1)
  340. let averageTDDLastTenDays = totalTDD / totalCount
  341. // Get weight percentage from preferences (default 0.65 if not set)
  342. let userPreferences = self.storage.retrieve(OpenAPS.Settings.preferences, as: Preferences.self)
  343. let weightPercentage = userPreferences?.weightPercentage ?? Decimal(0.65) // why is this 1 as default in oref2??
  344. // Calculate weighted average using the formula:
  345. // weightedTDD = (weightPercentage × recent_average) + ((1 - weightPercentage) × historical_average)
  346. let weightedTDD = weightPercentage * averageTDDLastTwoHours +
  347. (1 - weightPercentage) * averageTDDLastTenDays
  348. return weightedTDD
  349. }
  350. }
  351. }
  352. /// Extension for rounding Decimal numbers
  353. extension Decimal {
  354. /// Rounds a decimal to specified number of places
  355. /// - Parameter places: Number of decimal places
  356. /// - Returns: Rounded decimal
  357. func rounded(toPlaces places: Int) -> Decimal {
  358. var value = self
  359. var result = Decimal()
  360. NSDecimalRound(&result, &value, places, .plain)
  361. return result
  362. }
  363. }