LoopMath.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279
  1. //
  2. // LoopMath.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. public enum LoopMath {
  11. static func simulationDateRangeForSamples<T: Collection>(
  12. _ samples: T,
  13. from start: Date? = nil,
  14. to end: Date? = nil,
  15. duration: TimeInterval,
  16. delay: TimeInterval = 0,
  17. delta: TimeInterval
  18. ) -> (start: Date, end: Date)? where T.Element: TimelineValue {
  19. guard samples.count > 0 else {
  20. return nil
  21. }
  22. if let start = start, let end = end {
  23. return (start: start.dateFlooredToTimeInterval(delta), end: end.dateCeiledToTimeInterval(delta))
  24. } else {
  25. var minDate = samples.first!.startDate
  26. var maxDate = minDate
  27. for sample in samples {
  28. if sample.startDate < minDate {
  29. minDate = sample.startDate
  30. }
  31. if sample.endDate > maxDate {
  32. maxDate = sample.endDate
  33. }
  34. }
  35. return (
  36. start: (start ?? minDate).dateFlooredToTimeInterval(delta),
  37. end: (end ?? maxDate.addingTimeInterval(duration + delay)).dateCeiledToTimeInterval(delta)
  38. )
  39. }
  40. }
  41. /**
  42. Calculates a timeline of predicted glucose values from a variety of effects timelines.
  43. Each effect timeline:
  44. - Is given equal weight, with the exception of the momentum effect timeline
  45. - Can be of arbitrary size and start date
  46. - Should be in ascending order
  47. - Should have aligning dates with any overlapping timelines to ensure a smooth result
  48. - parameter startingGlucose: The starting glucose value
  49. - parameter momentum: The momentum effect timeline determined from prior glucose values
  50. - parameter effects: The glucose effect timelines to apply to the prediction.
  51. - returns: A timeline of glucose values
  52. */
  53. public static func predictGlucose(startingAt startingGlucose: GlucoseValue, momentum: [GlucoseEffect] = [], effects: [GlucoseEffect]...) -> [PredictedGlucoseValue] {
  54. return predictGlucose(startingAt: startingGlucose, momentum: momentum, effects: effects)
  55. }
  56. /**
  57. Calculates a timeline of predicted glucose values from a variety of effects timelines.
  58. Each effect timeline:
  59. - Is given equal weight, with the exception of the momentum effect timeline
  60. - Can be of arbitrary size and start date
  61. - Should be in ascending order
  62. - Should have aligning dates with any overlapping timelines to ensure a smooth result
  63. - parameter startingGlucose: The starting glucose value
  64. - parameter momentum: The momentum effect timeline determined from prior glucose values
  65. - parameter effects: The glucose effect timelines to apply to the prediction.
  66. - returns: A timeline of glucose values
  67. */
  68. public static func predictGlucose(startingAt startingGlucose: GlucoseValue, momentum: [GlucoseEffect] = [], effects: [[GlucoseEffect]]) -> [PredictedGlucoseValue] {
  69. var effectValuesAtDate: [Date: Double] = [:]
  70. let unit = HKUnit.milligramsPerDeciliter
  71. for timeline in effects {
  72. var previousEffectValue: Double = timeline.first?.quantity.doubleValue(for: unit) ?? 0
  73. for effect in timeline {
  74. let value = effect.quantity.doubleValue(for: unit)
  75. effectValuesAtDate[effect.startDate] = (effectValuesAtDate[effect.startDate] ?? 0) + value - previousEffectValue
  76. previousEffectValue = value
  77. }
  78. }
  79. // Blend the momentum effect linearly into the summed effect list
  80. if momentum.count > 1 {
  81. var previousEffectValue: Double = momentum[0].quantity.doubleValue(for: unit)
  82. // The blend begins delta minutes after after the last glucose (1.0) and ends at the last momentum point (0.0)
  83. // We're assuming the first one occurs on or before the starting glucose.
  84. let blendCount = momentum.count - 2
  85. let timeDelta = momentum[1].startDate.timeIntervalSince(momentum[0].startDate)
  86. // The difference between the first momentum value and the starting glucose value
  87. let momentumOffset = startingGlucose.startDate.timeIntervalSince(momentum[0].startDate)
  88. let blendSlope = 1.0 / Double(blendCount)
  89. let blendOffset = momentumOffset / timeDelta * blendSlope
  90. for (index, effect) in momentum.enumerated() {
  91. let value = effect.quantity.doubleValue(for: unit)
  92. let effectValueChange = value - previousEffectValue
  93. let split = min(1.0, max(0.0, Double(momentum.count - index) / Double(blendCount) - blendSlope + blendOffset))
  94. let effectBlend = (1.0 - split) * (effectValuesAtDate[effect.startDate] ?? 0)
  95. let momentumBlend = split * effectValueChange
  96. effectValuesAtDate[effect.startDate] = effectBlend + momentumBlend
  97. previousEffectValue = value
  98. }
  99. }
  100. let prediction = effectValuesAtDate.sorted { $0.0 < $1.0 }.reduce([PredictedGlucoseValue(startDate: startingGlucose.startDate, quantity: startingGlucose.quantity)]) { (prediction, effect) -> [PredictedGlucoseValue] in
  101. if effect.0 > startingGlucose.startDate, let lastValue = prediction.last {
  102. let nextValue = PredictedGlucoseValue(
  103. startDate: effect.0,
  104. quantity: HKQuantity(unit: unit, doubleValue: effect.1 + lastValue.quantity.doubleValue(for: unit))
  105. )
  106. return prediction + [nextValue]
  107. } else {
  108. return prediction
  109. }
  110. }
  111. return prediction
  112. }
  113. }
  114. extension GlucoseValue {
  115. /**
  116. Calculates a timeline of glucose effects by applying a linear decay to a rate of change.
  117. - parameter rate: The glucose velocity
  118. - parameter duration: The duration the effect should continue before ending
  119. - parameter delta: The time differential for the returned values
  120. - returns: An array of glucose effects
  121. */
  122. public func decayEffect(atRate rate: HKQuantity, for duration: TimeInterval, withDelta delta: TimeInterval = 5 * 60) -> [GlucoseEffect] {
  123. guard let (startDate, endDate) = LoopMath.simulationDateRangeForSamples([self], duration: duration, delta: delta) else {
  124. return []
  125. }
  126. let glucoseUnit = HKUnit.milligramsPerDeciliter
  127. let velocityUnit = GlucoseEffectVelocity.perSecondUnit
  128. // The starting rate, which we will decay to 0 over the specified duration
  129. let intercept = rate.doubleValue(for: velocityUnit) // mg/dL/s
  130. let decayStartDate = startDate.addingTimeInterval(delta)
  131. let slope = -intercept / (duration - delta) // mg/dL/s/s
  132. var values = [GlucoseEffect(startDate: startDate, quantity: quantity)]
  133. var date = decayStartDate
  134. var lastValue = quantity.doubleValue(for: glucoseUnit)
  135. repeat {
  136. let value = lastValue + (intercept + slope * date.timeIntervalSince(decayStartDate)) * delta
  137. values.append(GlucoseEffect(startDate: date, quantity: HKQuantity(unit: glucoseUnit, doubleValue: value)))
  138. lastValue = value
  139. date = date.addingTimeInterval(delta)
  140. } while date < endDate
  141. return values
  142. }
  143. }
  144. extension BidirectionalCollection where Element == GlucoseEffect {
  145. /// Sums adjacent glucose effects into buckets of the specified duration.
  146. ///
  147. /// Requires the receiver to be sorted chronologically by endDate
  148. ///
  149. /// - Parameter duration: The duration of each resulting summed element
  150. /// - Returns: An array of summed effects
  151. public func combinedSums(of duration: TimeInterval) -> [GlucoseChange] {
  152. var sums = [GlucoseChange]()
  153. sums.reserveCapacity(self.count)
  154. var lastValidIndex = sums.startIndex
  155. for effect in reversed() {
  156. sums.append(GlucoseChange(startDate: effect.startDate, endDate: effect.endDate, quantity: effect.quantity))
  157. for sumsIndex in lastValidIndex..<(sums.endIndex - 1) {
  158. guard sums[sumsIndex].endDate <= effect.endDate.addingTimeInterval(duration) else {
  159. lastValidIndex += 1
  160. continue
  161. }
  162. sums[sumsIndex].append(effect)
  163. }
  164. }
  165. return sums.reversed()
  166. }
  167. }
  168. extension BidirectionalCollection where Element == GlucoseEffectVelocity {
  169. /// Subtracts an array of glucose effects with uniform intervals and no gaps from the collection of effect changes, which may not have uniform intervals.
  170. ///
  171. /// - Parameters:
  172. /// - otherEffects: The array of glucose effects to subtract
  173. /// - effectInterval: The time interval between elements in the otherEffects array
  174. /// - Returns: A resulting array of glucose effects
  175. public func subtracting(_ otherEffects: [GlucoseEffect], withUniformInterval effectInterval: TimeInterval) -> [GlucoseEffect] {
  176. // Trim both collections to match
  177. let otherEffects = otherEffects.filterDateRange(self.first?.endDate, nil)
  178. let effects = self.filterDateRange(otherEffects.first?.startDate, nil)
  179. var subtracted: [GlucoseEffect] = []
  180. var previousOtherEffectValue = otherEffects.first?.quantity.doubleValue(for: .milligramsPerDeciliter) ?? 0 // mg/dL
  181. var effectIndex = effects.startIndex
  182. for otherEffect in otherEffects.dropFirst() {
  183. guard effectIndex < effects.endIndex else {
  184. break
  185. }
  186. let otherEffectValue = otherEffect.quantity.doubleValue(for: .milligramsPerDeciliter)
  187. let otherEffectChange = otherEffectValue - previousOtherEffectValue
  188. previousOtherEffectValue = otherEffectValue
  189. let effect = effects[effectIndex]
  190. // Our effect array may have gaps, or have longer segments than 5 minutes.
  191. guard effect.endDate <= otherEffect.endDate else {
  192. continue // Move on to the next other effect
  193. }
  194. effectIndex += 1
  195. let effectValue = effect.quantity.doubleValue(for: GlucoseEffectVelocity.perSecondUnit) // mg/dL/s
  196. let effectValueMatchingOtherEffectInterval = effectValue * effectInterval // mg/dL
  197. subtracted.append(GlucoseEffect(
  198. startDate: effect.endDate,
  199. quantity: HKQuantity(
  200. unit: .milligramsPerDeciliter,
  201. doubleValue: effectValueMatchingOtherEffectInterval - otherEffectChange
  202. )
  203. ))
  204. }
  205. // If we have run out of otherEffect items, we assume the otherEffectChange remains zero
  206. for effect in effects[effectIndex..<effects.endIndex] {
  207. let effectValue = effect.quantity.doubleValue(for: GlucoseEffectVelocity.perSecondUnit) // mg/dL/s
  208. let effectValueMatchingOtherEffectInterval = effectValue * effectInterval // mg/dL
  209. subtracted.append(GlucoseEffect(
  210. startDate: effect.endDate,
  211. quantity: HKQuantity(
  212. unit: .milligramsPerDeciliter,
  213. doubleValue: effectValueMatchingOtherEffectInterval
  214. )
  215. ))
  216. }
  217. return subtracted
  218. }
  219. }