TDDStorage.swift 28 KB

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