TDDSetup.swift 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
  1. import CoreData
  2. import Foundation
  3. /// Represents statistical data about Total Daily Dose for a specific time period
  4. struct TDDStats: Identifiable {
  5. let id = UUID()
  6. /// The date representing this time period
  7. let date: Date
  8. /// Total insulin in units
  9. let amount: Double
  10. }
  11. extension Stat.StateModel {
  12. /// Sets up TDD statistics by fetching and processing insulin data
  13. func setupTDDStats() {
  14. Task {
  15. do {
  16. let (hourly, daily) = try await fetchTDDStats()
  17. await MainActor.run {
  18. self.hourlyTDDStats = hourly
  19. self.dailyTDDStats = daily
  20. }
  21. // Initially calculate and cache daily averages
  22. await calculateAndCacheTDDAverages()
  23. } catch {
  24. debug(.default, "\(DebuggingIdentifiers.failed) failed fetching TDD stats: \(error)")
  25. }
  26. }
  27. }
  28. /// Fetches and processes Total Daily Dose (TDD) statistics from CoreData
  29. /// - Returns: A tuple containing hourly and daily TDD statistics arrays
  30. /// - Note: Processes both hourly statistics for the last 10 days and complete daily statistics
  31. private func fetchTDDStats() async throws -> (hourly: [TDDStats], daily: [TDDStats]) {
  32. // MARK: - Fetch Required Data
  33. // Fetch data for daily statistics (TDDStored for week, month, total views)
  34. let tddResults = try await fetchTDDStoredRecords()
  35. // Fetch data for hourly statistics (BolusStored and TempBasalStored for day view)
  36. let (bolusResults, tempBasalResults, suspendEvents, resumeEvents) = try await fetchHourlyInsulinRecords()
  37. // MARK: - Process Data on Background Context
  38. var hourlyStats: [TDDStats] = []
  39. var dailyStats: [TDDStats] = []
  40. await tddTaskContext.perform {
  41. let calendar = Calendar.current
  42. // Process daily statistics from TDDStored
  43. if let fetchedTDDs = tddResults as? [TDDStored] {
  44. dailyStats = self.processDailyTDDs(fetchedTDDs, calendar: calendar)
  45. }
  46. // Process hourly statistics from BolusStored and TempBasalStored
  47. if let fetchedBoluses = bolusResults as? [BolusStored],
  48. let fetchedTempBasals = tempBasalResults as? [TempBasalStored],
  49. let fetchedSuspendEvents = suspendEvents as? [PumpEventStored],
  50. let fetchedResumeEvents = resumeEvents as? [PumpEventStored]
  51. {
  52. hourlyStats = self.processHourlyInsulinData(
  53. boluses: fetchedBoluses,
  54. tempBasals: fetchedTempBasals,
  55. suspendEvents: fetchedSuspendEvents,
  56. resumeEvents: fetchedResumeEvents,
  57. calendar: calendar
  58. )
  59. }
  60. }
  61. return (hourlyStats, dailyStats)
  62. }
  63. /// Fetches TDDStored records from CoreData for daily statistics
  64. /// - Returns: The results of the fetch request containing TDDStored records
  65. /// - Note: Fetches records from the last 3 months for week, month, and total views
  66. private func fetchTDDStoredRecords() async throws -> Any {
  67. // Create a predicate to fetch TDD records from the last 3 months
  68. let threeMonthsAgo = Date().addingTimeInterval(-3.months.timeInterval)
  69. let predicate = NSPredicate(format: "date >= %@", threeMonthsAgo as NSDate)
  70. // Fetch TDD records from CoreData
  71. return try await CoreDataStack.shared.fetchEntitiesAsync(
  72. ofType: TDDStored.self,
  73. onContext: tddTaskContext,
  74. predicate: predicate,
  75. key: "date",
  76. ascending: true,
  77. batchSize: 100
  78. )
  79. }
  80. /// Fetches BolusStored and TempBasalStored records from CoreData for hourly statistics
  81. /// - Returns: A tuple containing the results of both fetch requests
  82. /// - Note: Fetches records from the last 20 days for detailed hourly view
  83. private func fetchHourlyInsulinRecords() async throws -> (bolus: Any, tempBasal: Any, suspendEvents: Any, resumeEvents: Any) {
  84. // Calculate date range for hourly statistics (last 20 days)
  85. let now = Date()
  86. let twentyDaysAgo = Calendar.current.date(byAdding: .day, value: -20, to: now) ?? now
  87. // Create a predicate for the date range
  88. let datePredicate = NSPredicate(
  89. format: "pumpEvent.timestamp >= %@ AND pumpEvent.timestamp <= %@",
  90. twentyDaysAgo as NSDate,
  91. now as NSDate
  92. )
  93. // Fetch bolus records for hourly stats
  94. let bolusResults = try await CoreDataStack.shared.fetchEntitiesAsync(
  95. ofType: BolusStored.self,
  96. onContext: tddTaskContext,
  97. predicate: datePredicate,
  98. key: "pumpEvent.timestamp",
  99. ascending: true,
  100. batchSize: 100
  101. )
  102. // Fetch temp basal records for hourly stats
  103. let tempBasalResults = try await CoreDataStack.shared.fetchEntitiesAsync(
  104. ofType: TempBasalStored.self,
  105. onContext: tddTaskContext,
  106. predicate: datePredicate,
  107. key: "pumpEvent.timestamp",
  108. ascending: true,
  109. batchSize: 100
  110. )
  111. // Create a combined predicate for suspension and resume events
  112. let suspendResumeTypes = [
  113. PumpEventStored.EventType.pumpSuspend.rawValue,
  114. PumpEventStored.EventType.pumpResume.rawValue
  115. ]
  116. let suspendResumePredicate = NSPredicate(
  117. format: "timestamp >= %@ AND timestamp <= %@ AND type IN %@",
  118. twentyDaysAgo as NSDate,
  119. now as NSDate,
  120. suspendResumeTypes
  121. )
  122. // Fetch both suspension and resume events in a single query
  123. let suspendResumeResults = try await CoreDataStack.shared.fetchEntitiesAsync(
  124. ofType: PumpEventStored.self,
  125. onContext: tddTaskContext,
  126. predicate: suspendResumePredicate,
  127. key: "timestamp",
  128. ascending: true,
  129. batchSize: 100
  130. )
  131. // Filter the results within the context's perform closure to ensure thread safety
  132. let (suspendEvents, resumeEvents) = await tddTaskContext.perform {
  133. var suspendEventsArray: [PumpEventStored] = []
  134. var resumeEventsArray: [PumpEventStored] = []
  135. if let pumpEvents = suspendResumeResults as? [PumpEventStored] {
  136. for event in pumpEvents {
  137. if event.type == PumpEventStored.EventType.pumpSuspend.rawValue {
  138. suspendEventsArray.append(event)
  139. } else if event.type == PumpEventStored.EventType.pumpResume.rawValue {
  140. resumeEventsArray.append(event)
  141. }
  142. }
  143. }
  144. return (suspendEventsArray, resumeEventsArray)
  145. }
  146. return (bolusResults, tempBasalResults, suspendEvents, resumeEvents)
  147. }
  148. /// Processes bolus and temporary basal data to create hourly insulin statistics
  149. /// - Parameters:
  150. /// - boluses: Array of BolusStored objects containing bolus insulin data
  151. /// - tempBasals: Array of TempBasalStored objects containing temporary basal rate data
  152. /// - suspendEvents: Array of PumpEventStored objects with type pumpSuspend
  153. /// - resumeEvents: Array of PumpEventStored objects with type pumpResume
  154. /// - calendar: Calendar instance used for date calculations and grouping
  155. /// - Returns: Array of TDDStats objects representing hourly insulin amounts
  156. /// - Note: This method calculates the actual duration of temporary basal rates by using the time
  157. /// difference between consecutive events, rather than relying on the planned duration.
  158. /// It also properly distributes insulin amounts across hour boundaries for accurate hourly statistics.
  159. /// Suspension events are taken into account to prevent counting insulin during pump suspensions.
  160. private func processHourlyInsulinData(
  161. boluses: [BolusStored],
  162. tempBasals: [TempBasalStored],
  163. suspendEvents: [PumpEventStored],
  164. resumeEvents: [PumpEventStored],
  165. calendar: Calendar
  166. ) -> [TDDStats] {
  167. // Dictionary to store insulin amounts indexed by hour
  168. var insulinByHour: [Date: Double] = [:]
  169. // MARK: - Process Bolus Insulin
  170. // Iterate through all bolus records and add their amounts to the appropriate hourly totals
  171. for bolus in boluses {
  172. guard let timestamp = bolus.pumpEvent?.timestamp,
  173. let amount = bolus.amount?.doubleValue
  174. else {
  175. continue // Skip entries with missing timestamp or amount
  176. }
  177. // Create a date representing the hour of this bolus (truncating minutes/seconds)
  178. let components = calendar.dateComponents([.year, .month, .day, .hour], from: timestamp)
  179. guard let hourDate = calendar.date(from: components) else { continue }
  180. // Add this bolus amount to the running total for this hour
  181. insulinByHour[hourDate, default: 0] += amount
  182. }
  183. // MARK: - Create Suspend-Resume Pairs
  184. // Create pairs of suspend and resume events
  185. let suspendResumePairs = createSuspendResumePairs(suspendEvents: suspendEvents, resumeEvents: resumeEvents)
  186. // MARK: - Process Temporary Basal Insulin
  187. // Sort temp basals chronologically for accurate duration calculation
  188. let sortedTempBasals = tempBasals.sorted {
  189. ($0.pumpEvent?.timestamp ?? Date.distantPast) < ($1.pumpEvent?.timestamp ?? Date.distantPast)
  190. }
  191. // Process each temporary basal event
  192. for (index, tempBasal) in sortedTempBasals.enumerated() {
  193. guard let timestamp = tempBasal.pumpEvent?.timestamp,
  194. let rate = tempBasal.rate?.doubleValue
  195. else {
  196. continue // Skip entries with missing timestamp or rate
  197. }
  198. // MARK: Calculate Actual Duration
  199. // Determine the actual duration based on the time until the next temp basal event
  200. var actualDurationInMinutes: Double
  201. if index < sortedTempBasals.count - 1 {
  202. // For all but the last event, calculate duration as time until next event
  203. if let nextTimestamp = sortedTempBasals[index + 1].pumpEvent?.timestamp {
  204. // Calculate time difference in minutes between this event and the next
  205. actualDurationInMinutes = nextTimestamp.timeIntervalSince(timestamp) / 60.0
  206. } else {
  207. // Fallback to planned duration if next timestamp is missing (unlikely)
  208. actualDurationInMinutes = Double(tempBasal.duration)
  209. }
  210. } else {
  211. // For the last event, use the planned duration as there's no next event
  212. actualDurationInMinutes = Double(tempBasal.duration)
  213. }
  214. // Convert duration from minutes to hours for insulin calculation
  215. let durationInHours = actualDurationInMinutes / 60.0
  216. // MARK: Distribute Insulin Across Hours
  217. // Handle temp basals that span multiple hours by distributing insulin appropriately
  218. // taking into account suspension periods
  219. distributeInsulinAcrossHours(
  220. startTime: timestamp,
  221. durationInHours: durationInHours,
  222. rate: rate,
  223. suspendResumePairs: suspendResumePairs,
  224. insulinByHour: &insulinByHour,
  225. calendar: calendar
  226. )
  227. }
  228. // MARK: - Convert Results to TDDStats Array
  229. // Transform the dictionary into a sorted array of TDDStats objects
  230. return insulinByHour.keys.sorted().map { hourDate in
  231. TDDStats(
  232. date: hourDate,
  233. amount: insulinByHour[hourDate, default: 0]
  234. )
  235. }
  236. }
  237. /// Creates pairs of suspend and resume events
  238. /// - Parameters:
  239. /// - suspendEvents: Array of PumpEventStored objects with type pumpSuspend
  240. /// - resumeEvents: Array of PumpEventStored objects with type pumpResume
  241. /// - Returns: Array of tuples containing suspend and resume event pairs
  242. /// - Note: This method pairs suspend events with the next resume event chronologically
  243. private func createSuspendResumePairs(
  244. suspendEvents: [PumpEventStored],
  245. resumeEvents: [PumpEventStored]
  246. ) -> [(suspend: PumpEventStored, resume: PumpEventStored)] {
  247. // Sort events chronologically
  248. let sortedSuspendEvents = suspendEvents.sorted { ($0.timestamp ?? Date.distantPast) < ($1.timestamp ?? Date.distantPast) }
  249. let sortedResumeEvents = resumeEvents.sorted { ($0.timestamp ?? Date.distantPast) < ($1.timestamp ?? Date.distantPast) }
  250. // Create pairs of suspend + resume events
  251. var pairs: [(suspend: PumpEventStored, resume: PumpEventStored)] = []
  252. // Iterate through suspend events and find matching resume events
  253. for suspendEvent in sortedSuspendEvents {
  254. guard let suspendTime = suspendEvent.timestamp else { continue }
  255. // Find the first resume event that occurs after this suspend event
  256. if let resumeEvent = sortedResumeEvents.first(where: {
  257. guard let resumeTime = $0.timestamp else { return false }
  258. return resumeTime > suspendTime
  259. }) {
  260. // Create a pair and add it to the array
  261. pairs.append((suspend: suspendEvent, resume: resumeEvent))
  262. }
  263. }
  264. return pairs
  265. }
  266. /// Distributes insulin from a temporary basal rate across multiple hours
  267. /// - Parameters:
  268. /// - startTime: The start time of the temporary basal rate
  269. /// - durationInHours: The duration of the temporary basal rate in hours
  270. /// - rate: The insulin rate in units per hour (U/h)
  271. /// - suspendResumePairs: Array of suspend-resume event pairs to account for suspension periods
  272. /// - insulinByHour: Dictionary to store insulin amounts by hour (modified in-place)
  273. /// - calendar: Calendar instance used for date calculations
  274. /// - Note: This method handles the case where a temporary basal spans multiple hours by
  275. /// calculating the exact amount of insulin delivered in each hour. It accounts for
  276. /// partial hours at the beginning and end of the temporary basal period, as well as
  277. /// suspension periods where no insulin is delivered.
  278. private func distributeInsulinAcrossHours(
  279. startTime: Date,
  280. durationInHours: Double,
  281. rate: Double,
  282. suspendResumePairs: [(suspend: PumpEventStored, resume: PumpEventStored)],
  283. insulinByHour: inout [Date: Double],
  284. calendar: Calendar
  285. ) {
  286. // Extract time components to calculate partial hours
  287. let startComponents = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: startTime)
  288. // Create a date representing just the hour of the start time (truncating minutes/seconds)
  289. guard let startHourDate = calendar
  290. .date(from: Calendar.current.dateComponents([.year, .month, .day, .hour], from: startTime))
  291. else {
  292. return // Exit if we can't create a valid hour date
  293. }
  294. // Calculate end time of the temp basal
  295. let endTime = startTime.addingTimeInterval(durationInHours * 3600)
  296. // MARK: - Handle First Hour (Partial)
  297. // Calculate how many minutes remain in the first hour after the start time
  298. let minutesInFirstHour = 60.0 - Double(startComponents.minute ?? 0) - (Double(startComponents.second ?? 0) / 60.0)
  299. // Calculate how many hours of the temp basal occur in the first hour (capped at remaining time)
  300. let hoursInFirstHour = min(durationInHours, minutesInFirstHour / 60.0)
  301. // Add insulin for the first partial hour, accounting for any suspensions
  302. if hoursInFirstHour > 0 {
  303. // Calculate the end time of the first hour segment
  304. let firstHourEndTime = startTime.addingTimeInterval(hoursInFirstHour * 3600)
  305. // Calculate effective duration excluding suspension periods
  306. let effectiveDuration = calculateEffectiveDuration(
  307. from: startTime,
  308. to: firstHourEndTime,
  309. suspendResumePairs: suspendResumePairs
  310. )
  311. // Insulin = rate (U/h) * effective duration (h)
  312. insulinByHour[startHourDate, default: 0] += rate * effectiveDuration
  313. }
  314. // MARK: - Handle Subsequent Hours
  315. // Calculate remaining duration after the first hour
  316. var remainingDuration = durationInHours - hoursInFirstHour
  317. // Start with the next hour
  318. var currentHourDate = calendar.date(byAdding: .hour, value: 1, to: startHourDate) ?? startHourDate
  319. // Distribute remaining insulin across subsequent hours
  320. while remainingDuration > 0 {
  321. // Calculate how much of this hour is covered (max 1 hour)
  322. let hoursToAdd = min(remainingDuration, 1.0)
  323. // Calculate the start and end times for this hour segment
  324. let hourStartTime = calendar
  325. .date(from: calendar.dateComponents([.year, .month, .day, .hour], from: currentHourDate)) ?? currentHourDate
  326. let hourEndTime = hourStartTime.addingTimeInterval(hoursToAdd * 3600)
  327. // Calculate effective duration excluding suspension periods
  328. let effectiveDuration = calculateEffectiveDuration(
  329. from: hourStartTime,
  330. to: hourEndTime,
  331. suspendResumePairs: suspendResumePairs
  332. )
  333. // Add insulin for this hour: rate (U/h) * effective duration (h)
  334. insulinByHour[currentHourDate, default: 0] += rate * effectiveDuration
  335. // Reduce remaining duration and move to next hour
  336. remainingDuration -= hoursToAdd
  337. currentHourDate = calendar.date(byAdding: .hour, value: 1, to: currentHourDate) ?? currentHourDate
  338. }
  339. }
  340. /// Calculates the effective duration of insulin delivery, excluding suspension periods
  341. /// - Parameters:
  342. /// - startTime: The start time of the period
  343. /// - endTime: The end time of the period
  344. /// - suspendResumePairs: Array of suspend-resume event pairs
  345. /// - Returns: The effective duration in hours, excluding suspension periods
  346. /// - Note: This method calculates how much of a time period was not affected by pump suspensions
  347. private func calculateEffectiveDuration(
  348. from startTime: Date,
  349. to endTime: Date,
  350. suspendResumePairs: [(suspend: PumpEventStored, resume: PumpEventStored)]
  351. ) -> Double {
  352. // Total duration in hours
  353. let totalDuration = endTime.timeIntervalSince(startTime) / 3600.0
  354. // Calculate total suspended time within this period
  355. var suspendedDuration = 0.0
  356. for pair in suspendResumePairs {
  357. guard let suspendTime = pair.suspend.timestamp,
  358. let resumeTime = pair.resume.timestamp
  359. else {
  360. continue
  361. }
  362. // Check if this suspension overlaps with our period
  363. if suspendTime < endTime, resumeTime > startTime {
  364. // Calculate overlap start and end
  365. let overlapStart = max(startTime, suspendTime)
  366. let overlapEnd = min(endTime, resumeTime)
  367. // Add the overlapping duration to our suspended time
  368. suspendedDuration += overlapEnd.timeIntervalSince(overlapStart) / 3600.0
  369. }
  370. }
  371. // Return effective duration (total minus suspended)
  372. return max(0.0, totalDuration - suspendedDuration)
  373. }
  374. /// Processes TDDStored records to create daily Total Daily Dose statistics
  375. /// - Parameters:
  376. /// - tdds: Array of TDDStored objects containing daily insulin data
  377. /// - calendar: Calendar instance used for date calculations and grouping
  378. /// - Returns: Array of TDDStats objects representing daily insulin amounts
  379. /// - Note: This method groups TDD records by day and uses only the last (most recent) entry
  380. /// for each day, as this represents the complete TDD value for that day. This approach
  381. /// is appropriate for week, month, and total views where we want the final daily totals.
  382. private func processDailyTDDs(_ tdds: [TDDStored], calendar: Calendar) -> [TDDStats] {
  383. // MARK: - Group TDDs by Calendar Day
  384. // Create a dictionary where keys are start-of-day dates and values are arrays of TDD entries for that day
  385. let dailyGrouped = Dictionary(grouping: tdds) { tdd in
  386. guard let timestamp = tdd.date else { return Date() }
  387. // Use start of day (midnight) as the key for grouping
  388. return calendar.startOfDay(for: timestamp)
  389. }
  390. // MARK: - Process Each Day's Entries
  391. // Create a TDDStats object for each day using the most recent TDD entry
  392. return dailyGrouped.keys.sorted().map { dayDate in
  393. // Get all TDD entries for this day
  394. let entries = dailyGrouped[dayDate, default: []]
  395. // MARK: - Sort and Select Most Recent Entry
  396. // Sort entries chronologically to find the most recent one for the day
  397. let sortedEntries = entries.sorted {
  398. ($0.date ?? Date.distantPast) < ($1.date ?? Date.distantPast)
  399. }
  400. // MARK: - Create TDDStats from Most Recent Entry
  401. // The last entry in the sorted array contains the complete TDD for the day
  402. if let lastEntry = sortedEntries.last, let total = lastEntry.total?.doubleValue {
  403. // Create TDDStats with the day's date and the total insulin amount
  404. return TDDStats(
  405. date: dayDate,
  406. amount: total
  407. )
  408. } else {
  409. // Fallback if no valid entry exists for this day
  410. return TDDStats(
  411. date: dayDate,
  412. amount: 0.0
  413. )
  414. }
  415. }
  416. }
  417. /// Calculates and caches the daily averages of Total Daily Dose (TDD) insulin values
  418. /// - Note: This function runs asynchronously and updates the tddAveragesCache on the main actor
  419. private func calculateAndCacheTDDAverages() async {
  420. // Get calendar for date calculations
  421. let calendar = Calendar.current
  422. // Calculate daily averages on background context
  423. let dailyAverages = await tddTaskContext.perform { [dailyTDDStats] in
  424. // Group TDD stats by calendar day
  425. let groupedByDay = Dictionary(grouping: dailyTDDStats) { stat in
  426. calendar.startOfDay(for: stat.date)
  427. }
  428. // Calculate average TDD for each day
  429. var averages: [Date: Double] = [:]
  430. for (day, stats) in groupedByDay {
  431. // Sum up all TDD values for the day
  432. let total = stats.reduce(0.0) { $0 + $1.amount }
  433. let count = Double(stats.count)
  434. // Store average in dictionary
  435. averages[day] = total / count
  436. }
  437. return averages
  438. }
  439. // Update cache on main actor
  440. await MainActor.run {
  441. self.tddAveragesCache = dailyAverages
  442. }
  443. }
  444. /// Gets the cached average Total Daily Dose (TDD) of insulin for a specified date range
  445. /// - Parameter range: A tuple containing the start and end dates to get averages for
  446. /// - Returns: The average TDD in units for the specified date range
  447. func getCachedTDDAverages(for range: (start: Date, end: Date)) -> Double {
  448. // Calculate and return the TDD averages for the given date range using cached values
  449. calculateTDDAveragesForDateRange(from: range.start, to: range.end)
  450. }
  451. /// Calculates the average Total Daily Dose (TDD) of insulin for a specified date range
  452. /// - Parameters:
  453. /// - startDate: The start date of the range to calculate averages for
  454. /// - endDate: The end date of the range to calculate averages for
  455. /// - Returns: The average TDD in units for the specified date range. Returns 0.0 if no data exists.
  456. private func calculateTDDAveragesForDateRange(from startDate: Date, to endDate: Date) -> Double {
  457. // Filter cached TDD values to only include those within the date range
  458. let relevantStats = tddAveragesCache.filter { date, _ in
  459. date >= startDate && date <= endDate
  460. }
  461. // Return 0 if no data exists for the specified range
  462. guard !relevantStats.isEmpty else { return 0.0 }
  463. // Calculate total TDD by summing all values
  464. let total = relevantStats.values.reduce(0.0, +)
  465. // Convert count to Double for floating point division
  466. let count = Double(relevantStats.count)
  467. // Return average TDD
  468. return total / count
  469. }
  470. }