LoopMath.swift 13 KB

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