TDDStorage.swift 25 KB

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