TDDStorage.swift 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. import Foundation
  2. import Swinject
  3. protocol TDDStorage {
  4. func calculateTDD(pumpHistory: [PumpHistoryEvent], basalProfile: [BasalProfileEntry], basalIncrement: Decimal) async
  5. -> TDDResult
  6. }
  7. /// Structure containing the results of TDD calculations
  8. struct TDDResult {
  9. let total: Decimal
  10. let bolus: Decimal
  11. let tempBasal: Decimal
  12. let scheduledBasal: Decimal
  13. let weightedAverage: Decimal?
  14. let hoursOfData: Double
  15. }
  16. /// Implementation of the TDD Calculator
  17. final class BaseTDDStorage: TDDStorage, Injectable {
  18. init(resolver: Resolver) {
  19. injectServices(resolver)
  20. }
  21. /// Main function to calculate TDD from pump history and basal profile
  22. /// - Parameters:
  23. /// - pumpHistory: Array of pump history events
  24. /// - basalProfile: Array of basal profile entries
  25. /// - Returns: TDDResult containing all calculated values
  26. func calculateTDD(
  27. pumpHistory: [PumpHistoryEvent],
  28. basalProfile: [BasalProfileEntry],
  29. basalIncrement: Decimal
  30. ) async -> TDDResult {
  31. debug(.apsManager, "Starting TDD calculation with \(pumpHistory.count) pump events")
  32. var bolusInsulin: Decimal = 0
  33. var tempInsulin: Decimal = 0
  34. var scheduledBasalInsulin: Decimal = 0
  35. let pumpData = calculatePumpDataHours(pumpHistory)
  36. debug(.apsManager, "Hours of pump data available: \(pumpData)")
  37. let bolusEvents = pumpHistory.filter({ $0.type == .bolus })
  38. let tempBasalEvents = pumpHistory.filter({ $0.type == .tempBasal })
  39. let gaps = findBasalGaps(in: tempBasalEvents)
  40. if !gaps.isEmpty {
  41. scheduledBasalInsulin = calculateScheduledBasalInsulin(gaps: gaps, profile: basalProfile).rounded(toPlaces: 2)
  42. }
  43. bolusInsulin = calculateBolusInsulin(bolusEvents).rounded(toPlaces: 2)
  44. debug(.apsManager, "Total bolus insulin: \(bolusInsulin)U")
  45. tempInsulin = calculateTempBasalInsulin(tempBasalEvents, basalIncrement: basalIncrement).rounded(toPlaces: 2)
  46. debug(.apsManager, "Total temp basal insulin: \(tempInsulin)U")
  47. let total = bolusInsulin + tempInsulin + scheduledBasalInsulin
  48. let weightedAverage = calculateWeightedAverage()
  49. debug(.apsManager, """
  50. TDD Summary:
  51. - Total: \(total)U
  52. - Bolus: \(bolusInsulin)U (\((bolusInsulin / total * 100).rounded(toPlaces: 1))%)
  53. - Temp Basal: \(tempInsulin)U (\((tempInsulin / total * 100).rounded(toPlaces: 1))%)
  54. - Scheduled Basal: \(scheduledBasalInsulin)U (\((scheduledBasalInsulin / total * 100).rounded(toPlaces: 1))%)
  55. - WeightedAverage: \(weightedAverage ?? 0)
  56. - Hours of Data: \(pumpData)
  57. """)
  58. return TDDResult(
  59. total: total,
  60. bolus: bolusInsulin,
  61. tempBasal: tempInsulin,
  62. scheduledBasal: scheduledBasalInsulin,
  63. weightedAverage: weightedAverage,
  64. hoursOfData: pumpData
  65. )
  66. }
  67. /// Finds gaps between tempBasal events where scheduled basal ran
  68. /// - Parameter tempBasalEvents: Array of pump history events of type tempBasal
  69. /// - Returns: Array of gaps, where each gap has a start and end time
  70. private func findBasalGaps(in tempBasalEvents: [PumpHistoryEvent]) -> [(start: Date, end: Date)] {
  71. guard !tempBasalEvents.isEmpty else {
  72. // No events = full day gap
  73. let startOfDay = Calendar.current.startOfDay(for: Date())
  74. let endOfDay = startOfDay.addingTimeInterval(24 * 60 * 60 - 1)
  75. return [(start: startOfDay, end: endOfDay)]
  76. }
  77. // Sort events by timestamp
  78. let sortedEvents = tempBasalEvents.sorted { $0.timestamp < $1.timestamp }
  79. var gaps: [(start: Date, end: Date)] = []
  80. // Track the end time of the last temp basal event
  81. var lastEndTime: Date?
  82. for (index, event) in sortedEvents.enumerated() {
  83. // Calculate the actual end time for the current event
  84. guard let duration = event.duration else { continue }
  85. var currentEndTime = event.timestamp.addingTimeInterval(TimeInterval(duration * 60))
  86. // Check for a cancellation
  87. if index < sortedEvents.count - 1 {
  88. let nextEvent = sortedEvents[index + 1]
  89. if nextEvent.timestamp < currentEndTime {
  90. // The next event cancels this one, adjust the end time
  91. currentEndTime = nextEvent.timestamp
  92. }
  93. }
  94. // If there’s a gap between the last event's end time and the current event's start time, record it
  95. if let lastEnd = lastEndTime, event.timestamp > lastEnd {
  96. gaps.append((start: lastEnd, end: event.timestamp))
  97. }
  98. // Update the last end time to the current event's (possibly adjusted) end time
  99. lastEndTime = currentEndTime
  100. }
  101. // Handle gap at the end of the dataset (if needed)
  102. if let lastEnd = lastEndTime {
  103. let endOfDay = Calendar.current.startOfDay(for: sortedEvents.first!.timestamp)
  104. .addingTimeInterval(24 * 60 * 60 - 1)
  105. if lastEnd < endOfDay {
  106. gaps.append((start: lastEnd, end: endOfDay))
  107. }
  108. }
  109. return gaps
  110. }
  111. /// Calculates the number of hours of available pump history data
  112. /// - Parameter pumpHistory: Array of pump history events
  113. /// - Returns: Number of hours of available data
  114. private func calculatePumpDataHours(_ pumpHistory: [PumpHistoryEvent]) -> Double {
  115. guard let firstEvent = pumpHistory.last, // we are fetching in a descending order
  116. let lastEvent = pumpHistory.first
  117. else {
  118. return 0
  119. }
  120. let startDate = firstEvent.timestamp
  121. var endDate = lastEvent.timestamp
  122. // If last event is a temp basal, use current time
  123. if lastEvent.type == .tempBasalDuration {
  124. endDate = Date()
  125. }
  126. return Double(endDate.timeIntervalSince(startDate)) / 3600.0
  127. }
  128. /// Calculates total bolus insulin from pump history
  129. /// - Parameter bolusEvents: Array of pump history events of type bolus
  130. /// - Returns: Total bolus insulin
  131. private func calculateBolusInsulin(_ bolusEvents: [PumpHistoryEvent]) -> Decimal {
  132. bolusEvents
  133. .reduce(Decimal(0)) { totalBolusInsulin, event in
  134. totalBolusInsulin + (event.amount ?? 0)
  135. }
  136. }
  137. /// Calculates insulin delivered via temporary basal rates, accounting for interruptions
  138. /// - Parameters:
  139. /// - tempBasalEvents: Array of pump history events of type tempBasal
  140. /// - basalIncrement: The smallest increment for basal rates
  141. /// - Returns: Total temporary basal insulin
  142. private func calculateTempBasalInsulin(_ tempBasalEvents: [PumpHistoryEvent], basalIncrement: Decimal) -> Decimal {
  143. guard !tempBasalEvents.isEmpty else { return Decimal(0) }
  144. // Sort events by timestamp to ensure proper order
  145. let sortedEvents = tempBasalEvents.sorted { $0.timestamp < $1.timestamp }
  146. return sortedEvents.enumerated().reduce(Decimal(0)) { totalInsulin, currentEvent in
  147. let (index, event) = currentEvent
  148. // Ensure the event is of type tempBasal and has valid data
  149. guard let rate = event.amount, // Rate in U/hr
  150. let durationMinutes = event.duration, // Duration in minutes
  151. rate > 0, durationMinutes > 0 else { return totalInsulin }
  152. // Calculate the actual duration in minutes the temp basal ran
  153. let actualDurationMinutes: Int
  154. if index < sortedEvents.count - 1 {
  155. // Next event exists; calculate if it interrupts the current event
  156. let nextEvent = sortedEvents[index + 1]
  157. let currentEndTime = event.timestamp.addingTimeInterval(TimeInterval(durationMinutes * 60))
  158. if nextEvent.timestamp < currentEndTime {
  159. // Interrupted; calculate the actual duration
  160. let interruptedDuration = nextEvent.timestamp.timeIntervalSince(event.timestamp) / 60
  161. actualDurationMinutes = Int(interruptedDuration)
  162. } else {
  163. // Not interrupted; use full duration
  164. actualDurationMinutes = durationMinutes
  165. }
  166. } else {
  167. // Last event in the list; use its full duration
  168. actualDurationMinutes = durationMinutes
  169. }
  170. // Convert the duration to hours and calculate insulin
  171. let durationHours = Decimal(actualDurationMinutes) / 60
  172. let insulin = accountForIncrements(rate * durationHours, basalIncrement: basalIncrement)
  173. debug(
  174. .apsManager,
  175. "Temp basal: \(rate)U/hr for \(Decimal(actualDurationMinutes) / 60)hr = \(insulin)U (adjusted for interruptions if needed)"
  176. )
  177. // Add the calculated insulin to the total
  178. return totalInsulin + insulin
  179. }
  180. }
  181. /// Calculates total scheduled basal insulin within gaps
  182. /// - Parameters:
  183. /// - tempBasalEvents: Array of pump history events of type tempBasal
  184. /// - profile: Array of basal profile entries
  185. /// - Returns: Total scheduled basal insulin
  186. private func calculateScheduledBasalInsulin(
  187. gaps: [(start: Date, end: Date)],
  188. profile: [BasalProfileEntry]
  189. ) -> Decimal {
  190. var totalInsulin: Decimal = 0
  191. for gap in gaps {
  192. var currentTime = gap.start
  193. while currentTime < gap.end {
  194. guard let rate = findBasalRate(for: getTimeString(from: currentTime), in: profile) else {
  195. debug(.apsManager, "No basal rate found for time \(currentTime)")
  196. break
  197. }
  198. // Determine the next switch time in the basal profile or the end of the gap
  199. let nextSwitchTime = getNextBasalRateSwitch(after: currentTime, in: profile) ?? gap.end
  200. let endTime = min(nextSwitchTime, gap.end)
  201. // Calculate duration in hours and insulin delivered
  202. let duration = Decimal(endTime.timeIntervalSince(currentTime)) / 3600
  203. let insulin = rate * duration
  204. totalInsulin += insulin
  205. debug(.apsManager, "Scheduled basal: \(rate)U/hr from \(currentTime) to \(endTime) = \(insulin)U")
  206. // Move to the next time block
  207. currentTime = endTime
  208. }
  209. }
  210. return totalInsulin
  211. }
  212. /// Finds the next basal profile switch after a given time
  213. /// - Parameters:
  214. /// - time: Current time
  215. /// - profile: Array of basal profile entries
  216. /// - Returns: The time of the next switch, if any
  217. private func getNextBasalRateSwitch(after time: Date, in profile: [BasalProfileEntry]) -> Date? {
  218. let calendar = Calendar.current
  219. let timeMinutes = calendar.component(.hour, from: time) * 60 + calendar.component(.minute, from: time)
  220. // Find the next entry in the profile after the current time
  221. for entry in profile {
  222. if entry.minutes > timeMinutes {
  223. let nextSwitchTime = calendar.startOfDay(for: time).addingTimeInterval(TimeInterval(entry.minutes * 60))
  224. return nextSwitchTime
  225. }
  226. }
  227. return nil // No further switches; end of day
  228. }
  229. /// Converts a Date to a time string in "HH:mm:ss" format
  230. private func getTimeString(from date: Date) -> String {
  231. let formatter = DateFormatter()
  232. formatter.dateFormat = "HH:mm:ss"
  233. return formatter.string(from: date)
  234. }
  235. /// Rounds insulin amounts according to pump increment constraints
  236. /// - Parameter insulin: Raw insulin amount
  237. /// - Returns: Rounded insulin amount
  238. private func accountForIncrements(_ insulin: Decimal, basalIncrement: Decimal) -> Decimal {
  239. let incrementsRaw = insulin / basalIncrement
  240. if incrementsRaw >= 1 {
  241. // Convert to NSDecimalNumber to use its rounding capabilities
  242. let nsIncrements = NSDecimalNumber(decimal: incrementsRaw)
  243. let roundedIncrements = nsIncrements.rounding(
  244. accordingToBehavior:
  245. NSDecimalNumberHandler(
  246. roundingMode: .down,
  247. scale: 0,
  248. raiseOnExactness: false,
  249. raiseOnOverflow: false,
  250. raiseOnUnderflow: false,
  251. raiseOnDivideByZero: false
  252. )
  253. )
  254. return (roundedIncrements.decimalValue * basalIncrement).rounded(toPlaces: 3)
  255. }
  256. return 0
  257. }
  258. /// Finds the basal rate for a specific time in the profile, considering closest increments or wide coverage.
  259. /// - Parameters:
  260. /// - timeString: Time string in "HH:mm:ss" format
  261. /// - profile: Array of basal profile entries
  262. /// - Returns: Basal rate if found
  263. private func findBasalRate(for timeString: String, in profile: [BasalProfileEntry]) -> Decimal? {
  264. // Convert the timeString to minutes since midnight
  265. let timeComponents = timeString.split(separator: ":").compactMap { Int($0) }
  266. guard timeComponents.count == 3 else { return nil }
  267. let totalMinutes = timeComponents[0] * 60 + timeComponents[1]
  268. // If only one entry exists, return its rate (covers full 24 hours)
  269. guard profile.count > 1 else {
  270. return profile.first?.rate
  271. }
  272. // Find the closest matching basal entry
  273. for (index, entry) in profile.enumerated() {
  274. // Check if the time falls within the range of the current entry
  275. let startMinutes = entry.minutes
  276. let endMinutes = (index + 1 < profile.count) ? profile[index + 1].minutes : 1440 // End of the day
  277. if totalMinutes >= startMinutes, totalMinutes < endMinutes {
  278. return entry.rate
  279. }
  280. }
  281. // Default to nil if no match found
  282. return nil
  283. }
  284. /// Calculates weighted average of TDD from historical data
  285. /// - Returns: Weighted average if available
  286. private func calculateWeightedAverage() -> Decimal? {
  287. // Implementation of weighted average calculation
  288. // Would use historical TDD data from Core Data
  289. nil
  290. }
  291. }
  292. /// Extension for rounding Decimal numbers
  293. extension Decimal {
  294. /// Rounds a decimal to specified number of places
  295. /// - Parameter places: Number of decimal places
  296. /// - Returns: Rounded decimal
  297. func rounded(toPlaces places: Int) -> Decimal {
  298. var value = self
  299. var result = Decimal()
  300. NSDecimalRound(&result, &value, places, .plain)
  301. return result
  302. }
  303. }