GlucoseMath.swift 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220
  1. //
  2. // GlucoseMath.swift
  3. // Naterade
  4. //
  5. // Created by Nathan Racklyeft on 1/24/16.
  6. // Copyright © 2016 Nathan Racklyeft. All rights reserved.
  7. //
  8. import Foundation
  9. import HealthKit
  10. fileprivate extension Collection where Element == (x: Double, y: Double) {
  11. /**
  12. Calculates slope and intercept using linear regression
  13. This implementation is not suited for large datasets.
  14. - parameter points: An array of tuples containing x and y values
  15. - returns: A tuple of slope and intercept values
  16. */
  17. func linearRegression() -> (slope: Double, intercept: Double) {
  18. var sumX = 0.0
  19. var sumY = 0.0
  20. var sumXY = 0.0
  21. var sumX² = 0.0
  22. var sumY² = 0.0
  23. let count = Double(self.count)
  24. for point in self {
  25. sumX += point.x
  26. sumY += point.y
  27. sumXY += (point.x * point.y)
  28. sumX² += (point.x * point.x)
  29. sumY² += (point.y * point.y)
  30. }
  31. let slope = ((count * sumXY) - (sumX * sumY)) / ((count * sumX²) - (sumX * sumX))
  32. let intercept = (sumY * sumX² - (sumX * sumXY)) / (count * sumX² - (sumX * sumX))
  33. return (slope: slope, intercept: intercept)
  34. }
  35. }
  36. extension BidirectionalCollection where Element: GlucoseSampleValue, Index == Int {
  37. /// Whether the collection contains no calibration entries
  38. /// Runtime: O(n)
  39. var isCalibrated: Bool {
  40. return filter({ $0.isDisplayOnly }).count == 0
  41. }
  42. /// Filters a timeline of glucose samples to only include those after the last calibration.
  43. func filterAfterCalibration() -> [Element] {
  44. var postCalibration = true
  45. return reversed().filter({ (sample) in
  46. if sample.isDisplayOnly {
  47. postCalibration = false
  48. }
  49. return postCalibration
  50. }).reversed()
  51. }
  52. /// Whether the collection can be considered continuous
  53. ///
  54. /// - Parameters:
  55. /// - interval: The interval between readings, on average, used to determine if we have a contiguous set of values
  56. /// - Returns: True if the samples are continuous
  57. func isContinuous(within interval: TimeInterval = TimeInterval(minutes: 5)) -> Bool {
  58. if let first = first,
  59. let last = last,
  60. // Ensure that the entries are contiguous
  61. abs(first.startDate.timeIntervalSince(last.startDate)) < interval * TimeInterval(count)
  62. {
  63. return true
  64. }
  65. return false
  66. }
  67. /// Calculates the short-term predicted momentum effect using linear regression
  68. ///
  69. /// - Parameters:
  70. /// - duration: The duration of the effects
  71. /// - delta: The time differential for the returned values
  72. /// - velocityMaximum: The limit on how fast the momentum effect can rise. Defaults to 4 mg/dL/min based on physiological rates
  73. /// - Returns: An array of glucose effects
  74. func linearMomentumEffect(
  75. duration: TimeInterval = TimeInterval(minutes: 30),
  76. delta: TimeInterval = TimeInterval(minutes: 5),
  77. velocityMaximum: HKQuantity = HKQuantity(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: .minute()), doubleValue: 4.0)
  78. ) -> [GlucoseEffect] {
  79. guard
  80. self.count > 2, // Linear regression isn't much use without 3 or more entries.
  81. isContinuous() && isCalibrated && hasSingleProvenance,
  82. let firstSample = self.first,
  83. let lastSample = self.last,
  84. let (startDate, endDate) = LoopMath.simulationDateRangeForSamples([lastSample], duration: duration, delta: delta)
  85. else {
  86. return []
  87. }
  88. /// Choose a unit to use during raw value calculation
  89. let unit = HKUnit.milligramsPerDeciliter
  90. let (slope: slope, intercept: _) = self.map { (
  91. x: $0.startDate.timeIntervalSince(firstSample.startDate),
  92. y: $0.quantity.doubleValue(for: unit)
  93. ) }.linearRegression()
  94. guard slope.isFinite else {
  95. return []
  96. }
  97. let limitedSlope = Swift.min(slope, velocityMaximum.doubleValue(for: unit.unitDivided(by: .second())))
  98. var date = startDate
  99. var values = [GlucoseEffect]()
  100. repeat {
  101. let value = Swift.max(0, date.timeIntervalSince(lastSample.startDate)) * limitedSlope
  102. let momentumEffect = GlucoseEffect(startDate: date, quantity: HKQuantity(unit: unit, doubleValue: value))
  103. values.append(momentumEffect)
  104. date = date.addingTimeInterval(delta)
  105. } while date <= endDate
  106. return values
  107. }
  108. }
  109. extension Collection where Element: GlucoseSampleValue, Index == Int {
  110. /// Whether the collection is all from the same source.
  111. /// Runtime: O(n)
  112. var hasSingleProvenance: Bool {
  113. let firstProvenance = self.first?.provenanceIdentifier
  114. for sample in self {
  115. if sample.provenanceIdentifier != firstProvenance {
  116. return false
  117. }
  118. }
  119. return true
  120. }
  121. /// Calculates a timeline of effect velocity (glucose/time) observed in glucose readings that counteract the specified effects.
  122. ///
  123. /// - Parameter effects: Glucose effects to be countered, in chronological order
  124. /// - Returns: An array of velocities describing the change in glucose samples compared to the specified effects
  125. func counteractionEffects(to effects: [GlucoseEffect]) -> [GlucoseEffectVelocity] {
  126. let mgdL = HKUnit.milligramsPerDeciliter
  127. let velocityUnit = GlucoseEffectVelocity.perSecondUnit
  128. var velocities = [GlucoseEffectVelocity]()
  129. var effectIndex = 0
  130. var startGlucose: Element! = self.first
  131. for endGlucose in self.dropFirst() {
  132. // Find a valid change in glucose, requiring identical provenance and no calibration
  133. let glucoseChange = endGlucose.quantity.doubleValue(for: mgdL) - startGlucose.quantity.doubleValue(for: mgdL)
  134. let timeInterval = endGlucose.startDate.timeIntervalSince(startGlucose.startDate)
  135. guard timeInterval > .minutes(4) else {
  136. continue
  137. }
  138. defer {
  139. startGlucose = endGlucose
  140. }
  141. guard startGlucose.provenanceIdentifier == endGlucose.provenanceIdentifier,
  142. !startGlucose.isDisplayOnly, !endGlucose.isDisplayOnly
  143. else {
  144. continue
  145. }
  146. // Compare that to a change in insulin effects
  147. guard effects.count > effectIndex else {
  148. break
  149. }
  150. var startEffect: GlucoseEffect?
  151. var endEffect: GlucoseEffect?
  152. for effect in effects[effectIndex..<effects.count] {
  153. if startEffect == nil && effect.startDate >= startGlucose.startDate {
  154. startEffect = effect
  155. } else if endEffect == nil && effect.startDate >= endGlucose.startDate {
  156. endEffect = effect
  157. break
  158. }
  159. effectIndex += 1
  160. }
  161. guard let startEffectValue = startEffect?.quantity.doubleValue(for: mgdL),
  162. let endEffectValue = endEffect?.quantity.doubleValue(for: mgdL)
  163. else {
  164. break
  165. }
  166. let effectChange = endEffectValue - startEffectValue
  167. let discrepancy = glucoseChange - effectChange
  168. let averageVelocity = HKQuantity(unit: velocityUnit, doubleValue: discrepancy / timeInterval)
  169. let effect = GlucoseEffectVelocity(startDate: startGlucose.startDate, endDate: endGlucose.startDate, quantity: averageVelocity)
  170. velocities.append(effect)
  171. }
  172. return velocities
  173. }
  174. }