TDDStorage.swift 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662
  1. import Foundation
  2. import LoopKitUI
  3. import Swinject
  4. protocol TDDStorage {
  5. func calculateTDD(
  6. pumpManager: any PumpManagerUI,
  7. pumpHistory: [PumpHistoryEvent],
  8. basalProfile: [BasalProfileEntry]
  9. ) async throws
  10. -> TDDResult
  11. func storeTDD(_ tddResult: TDDResult) async
  12. }
  13. /// Structure containing the results of TDD calculations
  14. struct TDDResult {
  15. let total: Decimal
  16. let bolus: Decimal
  17. let tempBasal: Decimal
  18. let scheduledBasal: Decimal
  19. let weightedAverage: Decimal?
  20. let hoursOfData: Double
  21. }
  22. /// Implementation of the TDD Calculator
  23. final class BaseTDDStorage: TDDStorage, Injectable {
  24. @Injected() private var storage: FileStorage!
  25. init(resolver: Resolver) {
  26. injectServices(resolver)
  27. }
  28. private let privateContext = CoreDataStack.shared.newTaskContext()
  29. /// Main function to calculate TDD from pump history and basal profile
  30. /// - Parameters:
  31. /// - pumpManager: Representation of paired pump's PumpManagerUI
  32. /// - pumpHistory: Array of pump history events
  33. /// - basalProfile: Array of basal profile entries
  34. /// - Returns: TDDResult containing all calculated values
  35. func calculateTDD(
  36. pumpManager: any PumpManagerUI,
  37. pumpHistory: [PumpHistoryEvent],
  38. basalProfile: [BasalProfileEntry]
  39. ) async throws -> TDDResult {
  40. debug(.apsManager, "Starting TDD calculation with \(pumpHistory.count) pump events")
  41. // Log the first and last pump history events if available
  42. let earliestEvent: String
  43. let latestEvent: String
  44. // We fetch descending, so invert logic
  45. if let firstEvent = pumpHistory.last, let lastEvent = pumpHistory.first {
  46. earliestEvent = "Type: \(firstEvent.type), Timestamp: \(firstEvent.timestamp.ISO8601Format())"
  47. latestEvent = "Type: \(lastEvent.type), Timestamp: \(lastEvent.timestamp.ISO8601Format())"
  48. } else {
  49. earliestEvent = "No events available"
  50. latestEvent = "No events available"
  51. debug(.apsManager, "No pump history events available for logging.")
  52. }
  53. // Group events by type once to avoid multiple filters
  54. let groupedEvents = Dictionary(grouping: pumpHistory, by: { $0.type })
  55. let bolusEvents = groupedEvents[.bolus] ?? []
  56. let tempBasalEvents = groupedEvents[.tempBasal] ?? []
  57. let pumpSuspendEvents = groupedEvents[.pumpSuspend] ?? []
  58. let pumpResumeEvents = groupedEvents[.pumpResume] ?? []
  59. // Create pairs of suspend + resume events
  60. let suspendResumePairs = zip(pumpSuspendEvents, pumpResumeEvents).filter { suspend, resume in
  61. resume.timestamp > suspend.timestamp
  62. }
  63. // Calculate all components concurrently
  64. async let pumpDataHours = calculatePumpDataHours(pumpHistory)
  65. async let bolusInsulin = calculateBolusInsulin(bolusEvents)
  66. let gaps = findBasalGaps(in: tempBasalEvents)
  67. async let scheduledBasalInsulin = !gaps.isEmpty ? calculateScheduledBasalInsulin(
  68. gaps: gaps,
  69. profile: basalProfile,
  70. roundToSupportedBasalRate: pumpManager.roundToSupportedBasalRate
  71. ) : 0
  72. async let tempBasalInsulin = calculateTempBasalInsulin(
  73. tempBasalEvents, suspendResumePairs: suspendResumePairs,
  74. roundToSupportedBasalRate: pumpManager.roundToSupportedBasalRate
  75. )
  76. async let weightedAverage = calculateWeightedAverage()
  77. // Await all concurrent calculations
  78. let (hours, bolus, scheduled, temp, weighted) = try await (
  79. pumpDataHours,
  80. bolusInsulin,
  81. scheduledBasalInsulin,
  82. tempBasalInsulin,
  83. weightedAverage
  84. )
  85. // Total insulin calculation
  86. let total = bolus + temp + scheduled
  87. // Safeguard against division by zero
  88. let percentage: (Decimal, Decimal) -> String = { part, total in
  89. total > 0 ? String(format: "%.2f", NSDecimalNumber(decimal: (part / total * 100).rounded(toPlaces: 2)).doubleValue) :
  90. "0.00"
  91. }
  92. // Store log strings in variables to avoid Xcode auto formatter from breaking up the lines in log statement
  93. let totalString = String(format: "%.2f", NSDecimalNumber(decimal: total.rounded(toPlaces: 2)).doubleValue)
  94. let bolusString = String(format: "%.2f", NSDecimalNumber(decimal: bolus.rounded(toPlaces: 2)).doubleValue)
  95. let tempBasalString = String(format: "%.2f", NSDecimalNumber(decimal: temp.rounded(toPlaces: 2)).doubleValue)
  96. let scheduledBasalString = String(format: "%.2f", NSDecimalNumber(decimal: scheduled.rounded(toPlaces: 2)).doubleValue)
  97. let weightedAvgString = String(format: "%.2f", NSDecimalNumber(decimal: weighted?.rounded(toPlaces: 2) ?? 0).doubleValue)
  98. let hoursString = String(format: "%.5f", NSDecimalNumber(decimal: Decimal(hours).truncated(toPlaces: 5)).doubleValue)
  99. debug(.apsManager, """
  100. TDD Summary:
  101. +-------------------+-----------+-----------+
  102. | Type\t\t\t\t| Amount U\t| Percent %\t|
  103. +-------------------+-----------+-----------+
  104. | Total\t\t\t\t| \(totalString)\t\t| \t\t\t|
  105. | Bolus\t\t\t\t| \(bolusString)\t\t| \(percentage(bolus, total))\t\t|
  106. | Temp Basal\t\t| \(tempBasalString)\t\t| \(percentage(temp, total))\t\t|
  107. | Scheduled Basal\t| \(scheduledBasalString)\t\t| \(percentage(scheduled, total))\t\t|
  108. | Weighted Average\t| \(weightedAvgString)\t\t| \t\t\t|
  109. +-------------------+-----------+-----------+
  110. - Hours of Data: \(hoursString)
  111. - Earliest Event: \(earliestEvent)
  112. - Latest Event: \(latestEvent)
  113. """)
  114. // Return calculated TDDResult
  115. return TDDResult(
  116. total: total,
  117. bolus: bolus,
  118. tempBasal: temp,
  119. scheduledBasal: scheduled,
  120. weightedAverage: weighted,
  121. hoursOfData: hours
  122. )
  123. }
  124. /// Stores the Total Daily Dose (TDD) result in Core Data
  125. /// - Parameter tddResult: The TDD result to store, containing total insulin, bolus, temp basal, scheduled basal and weighted average
  126. func storeTDD(_ tddResult: TDDResult) async {
  127. await privateContext.perform {
  128. let tddStored = TDDStored(context: self.privateContext)
  129. tddStored.id = UUID()
  130. tddStored.date = Date()
  131. tddStored.total = NSDecimalNumber(decimal: tddResult.total)
  132. tddStored.bolus = NSDecimalNumber(decimal: tddResult.bolus)
  133. tddStored.tempBasal = NSDecimalNumber(decimal: tddResult.tempBasal)
  134. tddStored.scheduledBasal = NSDecimalNumber(decimal: tddResult.scheduledBasal)
  135. tddStored.weightedAverage = tddResult.weightedAverage.map { NSDecimalNumber(decimal: $0) }
  136. do {
  137. guard self.privateContext.hasChanges else { return }
  138. try self.privateContext.save()
  139. } catch {
  140. debug(.apsManager, "\(DebuggingIdentifiers.failed) Failed to save TDD: \(error.localizedDescription)")
  141. }
  142. }
  143. }
  144. /// Calculates the number of hours of available pump history data
  145. /// - Parameter pumpHistory: Array of pump history events
  146. /// - Returns: Number of hours of available data
  147. private func calculatePumpDataHours(_ pumpHistory: [PumpHistoryEvent]) -> Double {
  148. guard let firstEvent = pumpHistory.last, // we are fetching in a descending order
  149. let lastEvent = pumpHistory.first
  150. else {
  151. return 0
  152. }
  153. let startDate = firstEvent.timestamp
  154. var endDate = lastEvent.timestamp
  155. // If last event in the list is tempBasal, check if it is running longer than current time
  156. // If yes, set current date, else ignore
  157. if lastEvent.type == .tempBasal, lastEvent.timestamp > Date().addingTimeInterval(-1) {
  158. endDate = Date()
  159. }
  160. return Double(endDate.timeIntervalSince(startDate)) / 3600.0
  161. }
  162. /// Calculates total bolus insulin from pump history
  163. /// - Parameter bolusEvents: Array of pump history events of type bolus
  164. /// - Returns: Total bolus insulin
  165. private func calculateBolusInsulin(_ bolusEvents: [PumpHistoryEvent]) -> Decimal {
  166. bolusEvents
  167. .reduce(Decimal(0)) { totalBolusInsulin, event in
  168. // let newTotalBolusInsulin =
  169. totalBolusInsulin + (event.amount as Decimal? ?? 0)
  170. // debug(
  171. // .apsManager,
  172. // "Bolus \(event.amount ?? 0) U dosed at \(event.timestamp.ISO8601Format()) added. New total bolus = \(newTotalBolusInsulin) U"
  173. // )
  174. // return newTotalBolusInsulin
  175. }
  176. }
  177. /// Calculates temporary basal insulin delivery for a given time period, accounting for interruptions and suspensions
  178. /// - Parameters:
  179. /// - tempBasalEvents: Array of temporary basal events
  180. /// - suspendResumePairs: Array of suspend and resume event pairs
  181. /// - roundToSupportedBasalRate: Closure to round rates to pump-supported values
  182. /// - Returns: Total insulin delivered via temporary basal rates in units
  183. private func calculateTempBasalInsulin(
  184. _ tempBasalEvents: [PumpHistoryEvent],
  185. suspendResumePairs: [(suspend: PumpHistoryEvent, resume: PumpHistoryEvent)],
  186. roundToSupportedBasalRate: @escaping (_ unitsPerHour: Double) -> Double
  187. ) -> Decimal {
  188. guard !tempBasalEvents.isEmpty else { return 0 }
  189. // Merge temp basal events and suspend-resume pairs into a single timeline
  190. var timeline = [(start: Date, end: Date, type: EventType, rate: Decimal?)]()
  191. // Add temp basal events to the timeline
  192. for event in tempBasalEvents {
  193. guard let duration = event.duration, let rate = event.amount else { continue }
  194. let end = event.timestamp.addingTimeInterval(TimeInterval(duration * 60))
  195. timeline.append((start: event.timestamp, end: end, type: .tempBasal, rate: rate))
  196. }
  197. // Add suspend-resume events to the timeline
  198. for suspendResume in suspendResumePairs {
  199. timeline.append((
  200. start: suspendResume.suspend.timestamp,
  201. end: suspendResume.resume.timestamp,
  202. type: .pumpSuspend,
  203. rate: nil
  204. ))
  205. }
  206. // Sort the timeline by start time
  207. timeline.sort { $0.start < $1.start }
  208. // Calculate insulin delivery while accounting for suspensions and premature interruptions
  209. var totalInsulin: Decimal = 0
  210. let currentDate = Date()
  211. var lastEndTime: Date?
  212. for (index, event) in timeline.enumerated() {
  213. if event.type == .tempBasal {
  214. let effectiveEnd = min(event.end, currentDate) // Adjust for ongoing temp basals
  215. var actualStart = event.start
  216. var actualEnd = effectiveEnd
  217. // Check for interruption by the next event
  218. if index < timeline.count - 1 {
  219. let nextEvent = timeline[index + 1]
  220. if nextEvent.start < actualEnd, nextEvent.type != .pumpSuspend {
  221. actualEnd = nextEvent.start
  222. }
  223. }
  224. // Adjust for overlapping suspensions
  225. if let lastSuspendEnd = lastEndTime, lastSuspendEnd > actualStart {
  226. actualStart = lastSuspendEnd
  227. }
  228. // Calculate insulin if the duration is valid
  229. let durationMinutes = max(0, actualEnd.timeIntervalSince(actualStart) / 60)
  230. if durationMinutes > 0, let rate = event.rate {
  231. let durationHours = (Decimal(durationMinutes) / 60).truncated(toPlaces: 5)
  232. let insulin = Decimal(roundToSupportedBasalRate(Double(rate * durationHours)))
  233. if insulin > 0 {
  234. totalInsulin += insulin
  235. // debug(
  236. // .apsManager,
  237. // "Temp basal: \(rate) U/hr for \(durationHours) hr (Start: \(actualStart.ISO8601Format()), End: \(actualEnd.ISO8601Format())) = \(insulin) U"
  238. // )
  239. }
  240. }
  241. } else if event.type == .pumpSuspend {
  242. // Update the last suspend end time to adjust future temp basal durations
  243. lastEndTime = event.end
  244. }
  245. }
  246. return totalInsulin
  247. }
  248. /// Calculates scheduled basal insulin delivery during gaps between temporary basals
  249. /// - Parameters:
  250. /// - gaps: Array of time periods where scheduled basal was active
  251. /// - profile: Basal profile entries defining rates throughout the day
  252. /// - roundToSupportedBasalRate: Closure to round rates to pump-supported values
  253. /// - Returns: Total insulin delivered via scheduled basal in units
  254. private func calculateScheduledBasalInsulin(
  255. gaps: [(start: Date, end: Date)],
  256. profile: [BasalProfileEntry],
  257. roundToSupportedBasalRate: @escaping (_ unitsPerHour: Double) -> Double
  258. ) -> Decimal {
  259. // Initialize cached formatter for time string conversion
  260. let timeFormatter: DateFormatter = {
  261. let formatter = DateFormatter()
  262. formatter.dateFormat = "HH:mm:ss"
  263. return formatter
  264. }()
  265. // Pre-calculate profile switch times for efficient lookup
  266. let profileSwitches = profile.map(\.minutes)
  267. return gaps.reduce(into: Decimal(0)) { totalInsulin, gap in
  268. var currentTime = gap.start
  269. let now = Date()
  270. while currentTime < gap.end {
  271. // Find applicable basal rate for current time
  272. guard let rate = findBasalRate(
  273. for: timeFormatter.string(from: currentTime),
  274. in: profile
  275. ) else { break }
  276. // Determine when rate changes (either profile switch or gap end)
  277. let nextSwitchTime = getNextBasalRateSwitch(
  278. after: currentTime,
  279. switches: profileSwitches,
  280. calendar: Calendar.current
  281. ) ?? gap.end
  282. // Ensure endTime does not exceed current time or gap end
  283. let endTime = min(min(nextSwitchTime, gap.end), now)
  284. // Only proceed if we have a valid time interval
  285. guard endTime > currentTime else { break }
  286. let durationHours = (Decimal(endTime.timeIntervalSince(currentTime)) / 3600).truncated(toPlaces: 5)
  287. let insulin = Decimal(roundToSupportedBasalRate(Double(rate * durationHours)))
  288. if insulin > 0 {
  289. totalInsulin += insulin
  290. // debug(
  291. // .apsManager,
  292. // "Scheduled Insulin added: \(insulin) U. Duration: \(durationHours) hrs (Start: \(currentTime.ISO8601Format()), End: \(endTime.ISO8601Format()))"
  293. // )
  294. }
  295. currentTime = endTime
  296. }
  297. }
  298. }
  299. /// Finds gaps between tempBasal events where scheduled basal ran
  300. /// - Parameter tempBasalEvents: Array of pump history events of type tempBasal
  301. /// - Returns: Array of gaps, where each gap has a start and end time
  302. private func findBasalGaps(in tempBasalEvents: [PumpHistoryEvent]) -> [(start: Date, end: Date)] {
  303. guard !tempBasalEvents.isEmpty else {
  304. let startOfDay = Calendar.current.startOfDay(for: Date())
  305. return [(start: startOfDay, end: startOfDay.addingTimeInterval(24 * 60 * 60 - 1))]
  306. }
  307. // Pre-sort events and create array with capacity
  308. let sortedEvents = tempBasalEvents.sorted { $0.timestamp < $1.timestamp }
  309. var gaps = [(start: Date, end: Date)]()
  310. gaps.reserveCapacity(sortedEvents.count + 1)
  311. // Use first event's date for calendar operations
  312. let startOfDay = Calendar.current.startOfDay(for: sortedEvents.first!.timestamp)
  313. let endOfDay = startOfDay.addingTimeInterval(24 * 60 * 60 - 1)
  314. // Process events in a single pass
  315. var lastEndTime = sortedEvents.first!.timestamp
  316. for i in 0 ..< sortedEvents.count {
  317. let event = sortedEvents[i]
  318. guard let duration = event.duration else { continue }
  319. // Calculate end time for current event
  320. var currentEndTime = event.timestamp.addingTimeInterval(TimeInterval(duration * 60))
  321. // Check for cancellation by next event
  322. if i < sortedEvents.count - 1 {
  323. let nextEvent = sortedEvents[i + 1]
  324. if nextEvent.timestamp < currentEndTime {
  325. currentEndTime = nextEvent.timestamp
  326. }
  327. }
  328. // Record gap if exists
  329. if event.timestamp > lastEndTime {
  330. gaps.append((start: lastEndTime, end: event.timestamp))
  331. }
  332. lastEndTime = currentEndTime
  333. }
  334. // Add final gap if needed
  335. if lastEndTime < endOfDay {
  336. gaps.append((start: lastEndTime, end: endOfDay))
  337. }
  338. return gaps
  339. }
  340. // /// Finds gaps between tempBasal events where scheduled basal ran, excluding suspend-resume periods
  341. // /// - Parameters:
  342. // /// - tempBasalEvents: Array of pump history events of type tempBasal
  343. // /// - suspendResumePairs: Array of suspend and resume event pairs
  344. // /// - Returns: Array of gaps, where each gap has a start and end time
  345. // private func findBasalGaps(
  346. // in tempBasalEvents: [PumpHistoryEvent],
  347. // excluding suspendResumePairs: [(suspend: PumpHistoryEvent, resume: PumpHistoryEvent)]
  348. // ) -> [(start: Date, end: Date)] {
  349. // guard !tempBasalEvents.isEmpty else {
  350. // let startOfDay = Calendar.current.startOfDay(for: Date())
  351. // return [(start: startOfDay, end: startOfDay.addingTimeInterval(24 * 60 * 60 - 1))]
  352. // }
  353. //
  354. // // Merge temp basal and suspend-resume events into a unified timeline
  355. // var timeline = [(start: Date, end: Date, type: EventType)]()
  356. //
  357. // for event in tempBasalEvents {
  358. // guard let duration = event.duration else { continue }
  359. // let eventEnd = event.timestamp.addingTimeInterval(TimeInterval(duration * 60))
  360. // timeline.append((start: event.timestamp, end: eventEnd, type: .tempBasal))
  361. // }
  362. //
  363. // for suspendResume in suspendResumePairs {
  364. // timeline.append((start: suspendResume.suspend.timestamp, end: suspendResume.resume.timestamp, type: .pumpSuspend))
  365. // }
  366. //
  367. // // Sort the timeline by start time
  368. // timeline.sort { $0.start < $1.start }
  369. //
  370. // // Process the timeline to calculate gaps
  371. // var gaps = [(start: Date, end: Date)]()
  372. // var lastEndTime = Calendar.current.startOfDay(for: timeline.first!.start)
  373. // let endOfDay = lastEndTime.addingTimeInterval(24 * 60 * 60 - 1)
  374. //
  375. // for interval in timeline {
  376. // if interval.type == .pumpSuspend {
  377. // // Extend lastEndTime for suspend periods
  378. // lastEndTime = max(lastEndTime, interval.end)
  379. // continue
  380. // }
  381. //
  382. // if interval.start > lastEndTime {
  383. // // Add a gap if there is a gap between lastEndTime and interval.start
  384. // gaps.append((start: lastEndTime, end: interval.start))
  385. // }
  386. //
  387. // // Update lastEndTime to the maximum end time encountered
  388. // lastEndTime = max(lastEndTime, interval.end)
  389. // }
  390. //
  391. // if lastEndTime < endOfDay {
  392. // // Add a final gap if the lastEndTime is before the end of the day
  393. // gaps.append((start: lastEndTime, end: endOfDay))
  394. // }
  395. //
  396. // return gaps
  397. // }
  398. // /// Calculates scheduled basal insulin delivery during gaps between temporary basals
  399. // /// - Parameters:
  400. // /// - gaps: Array of time periods where scheduled basal was active
  401. // /// - profile: Basal profile entries defining rates throughout the day
  402. // /// - roundToSupportedBasalRate: Closure to round rates to pump-supported values
  403. // /// - Returns: Total insulin delivered via scheduled basal in units
  404. // private func calculateScheduledBasalInsulin(
  405. // gaps: [(start: Date, end: Date)],
  406. // profile: [BasalProfileEntry],
  407. // roundToSupportedBasalRate: @escaping (_ unitsPerHour: Double) -> Double
  408. // ) -> Decimal {
  409. // // Initialize cached formatter for time string conversion
  410. // let timeFormatter: DateFormatter = {
  411. // let formatter = DateFormatter()
  412. // formatter.dateFormat = "HH:mm:ss"
  413. // return formatter
  414. // }()
  415. //
  416. // // Pre-calculate profile switch times for efficient lookup
  417. // let profileSwitches = profile.map(\.minutes)
  418. //
  419. // return gaps.reduce(into: Decimal(0)) { totalInsulin, gap in
  420. // var currentTime = gap.start
  421. //
  422. // while currentTime < gap.end {
  423. // // Find applicable basal rate for the current time
  424. // guard let rate = findBasalRate(
  425. // for: timeFormatter.string(from: currentTime),
  426. // in: profile
  427. // ) else { break }
  428. //
  429. // // Determine when the rate changes (profile switch or gap end)
  430. // let nextSwitchTime = getNextBasalRateSwitch(
  431. // after: currentTime,
  432. // switches: profileSwitches,
  433. // calendar: Calendar.current
  434. // ) ?? gap.end
  435. // let endTime = min(nextSwitchTime, gap.end)
  436. // let durationHours = Decimal(endTime.timeIntervalSince(currentTime)) / 3600
  437. //
  438. // let insulin = Decimal(roundToSupportedBasalRate(Double(rate * durationHours)))
  439. // totalInsulin += insulin
  440. //
  441. // debug(
  442. // .apsManager,
  443. // "Scheduled Insulin added: \(insulin) U. Duration: \(durationHours) hrs (\(currentTime)-\(endTime))"
  444. // )
  445. //
  446. // currentTime = endTime
  447. // }
  448. // }
  449. // }
  450. /// Finds the next basal rate switch time after a given time
  451. /// - Parameters:
  452. /// - time: Reference time to find next switch after
  453. /// - switches: Pre-calculated array of minutes when profile rates change
  454. /// - calendar: Calendar instance for date calculations
  455. /// - Returns: Date of next basal rate switch, or nil if none found
  456. private func getNextBasalRateSwitch(
  457. after time: Date,
  458. switches: [Int],
  459. calendar: Calendar
  460. ) -> Date? {
  461. let timeMinutes = calendar.component(.hour, from: time) * 60 + calendar.component(.minute, from: time)
  462. // Find first switch time after current time
  463. guard let nextSwitch = switches.first(where: { $0 > timeMinutes }) else {
  464. return nil
  465. }
  466. // Convert switch time to absolute date
  467. return calendar.startOfDay(for: time).addingTimeInterval(TimeInterval(nextSwitch * 60))
  468. }
  469. /// Finds the basal rate for a specific time using binary search
  470. /// - Parameters:
  471. /// - timeString: Time in format "HH:mm:ss"
  472. /// - profile: Array of basal profile entries sorted by time
  473. /// - Returns: Basal rate in units per hour, or nil if not found
  474. private func findBasalRate(for timeString: String, in profile: [BasalProfileEntry]) -> Decimal? {
  475. // Parse time string in "HH:mm:ss" format into hours and minutes components
  476. let timeComponents = timeString.split(separator: ":")
  477. guard timeComponents.count == 3,
  478. let hours = Int(timeComponents[0]),
  479. let minutes = Int(timeComponents[1])
  480. else { return nil }
  481. // Convert time to total minutes since midnight for easier comparison
  482. let totalMinutes = hours * 60 + minutes
  483. // Special case: If profile has only one entry, it applies for full 24 hours
  484. // Return its rate immediately without searching
  485. if profile.count == 1 {
  486. return profile[0].rate
  487. }
  488. // Use binary search to efficiently find the applicable basal rate
  489. // Profile entries are sorted by minutes, so we can divide and conquer
  490. var left = 0
  491. var right = profile.count - 1
  492. while left <= right {
  493. let mid = (left + right) / 2
  494. let entry = profile[mid]
  495. // Get end time for current entry - either next entry's start time or end of day (1440 mins)
  496. let nextMinutes = mid + 1 < profile.count ? profile[mid + 1].minutes : 1440
  497. // Check if target time falls within current entry's time range
  498. if totalMinutes >= entry.minutes, totalMinutes < nextMinutes {
  499. return entry.rate
  500. }
  501. // Adjust search range based on comparison
  502. if totalMinutes < entry.minutes {
  503. right = mid - 1 // Search in left half if target time is earlier
  504. } else {
  505. left = mid + 1 // Search in right half if target time is later
  506. }
  507. }
  508. // No applicable rate found for the given time
  509. return nil
  510. }
  511. /// Calculates a weighted average of Total Daily Dose (TDD) based on recent and historical data
  512. ///
  513. /// The weighted average is calculated using two time periods:
  514. /// - Recent: Last 2 hours of TDD data
  515. /// - Historical: Last 10 days of TDD data
  516. ///
  517. /// The formula used is:
  518. /// ```
  519. /// weightedTDD = (weightPercentage × recent_average) + ((1 - weightPercentage) × historical_average)
  520. /// ```
  521. /// where weightPercentage defaults to 0.65 if not set in preferences
  522. ///
  523. /// - Returns: A weighted average of TDD as Decimal, or nil if insufficient data
  524. /// - Note: The weight percentage can be configured in preferences. Default is 0.65 (65% recent, 35% historical)
  525. private func calculateWeightedAverage() async throws -> Decimal? {
  526. // Fetch data from Core Data
  527. let tenDaysAgo = Date().addingTimeInterval(-10.days.timeInterval)
  528. let twoHoursAgo = Date().addingTimeInterval(-2.hours.timeInterval)
  529. let predicate = NSPredicate(format: "date >= %@", tenDaysAgo as NSDate)
  530. let results = try await CoreDataStack.shared.fetchEntitiesAsync(
  531. ofType: TDDStored.self,
  532. onContext: privateContext,
  533. predicate: predicate,
  534. key: "date",
  535. ascending: false
  536. )
  537. return await privateContext.perform { () -> Decimal? in
  538. guard let results = results as? [TDDStored], !results.isEmpty else { return 0 }
  539. // Calculate recent (2h) average
  540. let recentResults = results.filter { $0.date?.timeIntervalSince(twoHoursAgo) ?? 0 > 0 }
  541. let recentTotal = recentResults.compactMap { $0.total?.decimalValue }.reduce(0, +)
  542. let recentCount = max(Decimal(recentResults.count), 1)
  543. let averageTDDLastTwoHours = recentTotal / recentCount
  544. // Calculate 10-day average
  545. let totalTDD = results.compactMap { $0.total?.decimalValue }.reduce(0, +)
  546. let totalCount = max(Decimal(results.count), 1)
  547. let averageTDDLastTenDays = totalTDD / totalCount
  548. // Get weight percentage from preferences (default 0.65 if not set)
  549. let userPreferences = self.storage.retrieve(OpenAPS.Settings.preferences, as: Preferences.self)
  550. let weightPercentage = userPreferences?.weightPercentage ?? Decimal(0.65) // why is this 1 as default in oref2??
  551. // Calculate weighted average using the formula:
  552. // weightedTDD = (weightPercentage × recent_average) + ((1 - weightPercentage) × historical_average)
  553. let weightedTDD = weightPercentage * averageTDDLastTwoHours +
  554. (1 - weightPercentage) * averageTDDLastTenDays
  555. return weightedTDD.truncated(toPlaces: 3)
  556. }
  557. }
  558. }
  559. /// Extension for rounding Decimal numbers
  560. extension Decimal {
  561. /// Rounds a decimal to specified number of places
  562. /// - Parameter places: Number of decimal places
  563. /// - Returns: Rounded decimal
  564. func rounded(toPlaces places: Int) -> Decimal {
  565. var value = self
  566. var result = Decimal()
  567. NSDecimalRound(&result, &value, places, .plain)
  568. return result
  569. }
  570. /// Truncates the `Decimal` to the specified number of decimal places without rounding.
  571. ///
  572. /// - Parameter places: The number of decimal places to retain.
  573. /// - Returns: A `Decimal` truncated to the specified precision.
  574. func truncated(toPlaces places: Int) -> Decimal {
  575. var original = self
  576. var result = Decimal()
  577. NSDecimalRound(&result, &original, places, .down)
  578. return result
  579. }
  580. }