TDDSetup.swift 25 KB

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