TDDStorage.swift 32 KB

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