MockGlucoseProvider.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. //
  2. // MockGlucoseProvider.swift
  3. // LoopKit
  4. //
  5. // Created by Michael Pangburn on 11/23/18.
  6. // Copyright © 2018 LoopKit Authors. All rights reserved.
  7. //
  8. import HealthKit
  9. import LoopKit
  10. /// Returns a value based on the result of a random coin flip.
  11. /// - Parameter chanceOfHeads: The chance of flipping heads. Must be a value in the range `0...1`. Defaults to `0.5`.
  12. /// - Parameter valueIfHeads: An autoclosure producing the value to return if the coin flips heads.
  13. /// - Parameter valueIfTails: An autoclosure producing the value to return if the coin flips tails.
  14. private func coinFlip<Output>(
  15. withChanceOfHeads chanceOfHeads: Double = 0.5,
  16. ifHeads valueIfHeads: @autoclosure () -> Output,
  17. ifTails valueIfTails: @autoclosure () -> Output
  18. ) -> Output {
  19. precondition((0...1).contains(chanceOfHeads))
  20. let isHeads = .random(in: 0..<100) < chanceOfHeads * 100
  21. return isHeads ? valueIfHeads() : valueIfTails()
  22. }
  23. struct MockGlucoseProvider {
  24. struct BackfillRequest {
  25. let duration: TimeInterval
  26. let dataPointFrequency: TimeInterval
  27. var dataPointCount: Int {
  28. return Int(duration / dataPointFrequency)
  29. }
  30. init(datingBack duration: TimeInterval, dataPointFrequency: TimeInterval) {
  31. self.duration = duration
  32. self.dataPointFrequency = dataPointFrequency
  33. }
  34. }
  35. /// Given a date, asynchronously produce the CGMReadingResult at that date.
  36. private let fetchDataAt: (_ date: Date, _ completion: @escaping (CGMReadingResult) -> Void) -> Void
  37. func fetchData(at date: Date, completion: @escaping (CGMReadingResult) -> Void) {
  38. fetchDataAt(date, completion)
  39. }
  40. func backfill(_ backfill: BackfillRequest, endingAt date: Date, completion: @escaping (CGMReadingResult) -> Void) {
  41. let dataPointDates = (0...backfill.dataPointCount).map { offset in
  42. return date.addingTimeInterval(-backfill.dataPointFrequency * Double(offset))
  43. }
  44. dataPointDates.asyncMap(fetchDataAt) { allResults in
  45. let allSamples = allResults.flatMap { result -> [NewGlucoseSample] in
  46. if case .newData(let samples) = result {
  47. return samples
  48. } else {
  49. return []
  50. }
  51. }
  52. let result: CGMReadingResult = allSamples.isEmpty ? .noData : .newData(allSamples.reversed())
  53. completion(result)
  54. }
  55. }
  56. }
  57. extension MockGlucoseProvider {
  58. init(model: MockCGMDataSource.Model, effects: MockCGMDataSource.Effects) {
  59. self = effects.transformations.reduce(model.glucoseProvider) { model, transform in transform(model) }
  60. }
  61. private static func glucoseSample(at date: Date, quantity: HKQuantity, condition: GlucoseCondition?, trend: GlucoseTrend?, trendRate: HKQuantity?) -> NewGlucoseSample {
  62. return NewGlucoseSample(
  63. date: date,
  64. quantity: quantity,
  65. condition: condition,
  66. trend: trend,
  67. trendRate: trendRate,
  68. isDisplayOnly: false,
  69. wasUserEntered: false,
  70. syncIdentifier: UUID().uuidString,
  71. device: MockCGMDataSource.device
  72. )
  73. }
  74. }
  75. // MARK: - Models
  76. extension MockGlucoseProvider {
  77. fileprivate static func constant(_ quantity: HKQuantity) -> MockGlucoseProvider {
  78. return MockGlucoseProvider { date, completion in
  79. let sample = glucoseSample(at: date, quantity: quantity, condition: nil, trend: .flat, trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: 0))
  80. completion(.newData([sample]))
  81. }
  82. }
  83. fileprivate static func sineCurve(parameters: MockCGMDataSource.Model.SineCurveParameters) -> MockGlucoseProvider {
  84. let (baseGlucose, amplitude, period, referenceDate) = parameters
  85. precondition(period > 0)
  86. let unit = HKUnit.milligramsPerDeciliter
  87. let trendRateUnit = unit.unitDivided(by: .minute())
  88. precondition(baseGlucose.is(compatibleWith: unit))
  89. precondition(amplitude.is(compatibleWith: unit))
  90. let baseGlucoseValue = baseGlucose.doubleValue(for: unit)
  91. let amplitudeValue = amplitude.doubleValue(for: unit)
  92. let chanceOfNilTrendRate = 1.0/20.0
  93. var prevGlucoseValue: Double?
  94. return MockGlucoseProvider { date, completion in
  95. let timeOffset = date.timeIntervalSince1970 - referenceDate.timeIntervalSince1970
  96. func sine(_ t: TimeInterval) -> Double {
  97. return Double(baseGlucoseValue + amplitudeValue * sin(2 * .pi / period * t)).rounded()
  98. }
  99. let glucoseValue = sine(timeOffset)
  100. var trend: GlucoseTrend?
  101. var trendRate: HKQuantity?
  102. if let prevGlucoseValue = prevGlucoseValue,
  103. let trendRateValue = coinFlip(withChanceOfHeads: chanceOfNilTrendRate, ifHeads: nil, ifTails: glucoseValue - prevGlucoseValue) {
  104. let smallDelta = 0.9
  105. let mediumDelta = 2.0
  106. let largeDelta = 5.0
  107. switch trendRateValue {
  108. case -smallDelta ... smallDelta:
  109. trend = .flat
  110. case -mediumDelta ..< -smallDelta:
  111. trend = .down
  112. case -largeDelta ..< -mediumDelta:
  113. trend = .downDown
  114. case -Double.greatestFiniteMagnitude ..< -largeDelta:
  115. trend = .downDownDown
  116. case smallDelta ... mediumDelta:
  117. trend = .up
  118. case mediumDelta ... largeDelta:
  119. trend = .upUp
  120. case largeDelta ... Double.greatestFiniteMagnitude:
  121. trend = .upUpUp
  122. default:
  123. break
  124. }
  125. trendRate = HKQuantity(unit: trendRateUnit, doubleValue: trendRateValue)
  126. }
  127. let sample = glucoseSample(at: date, quantity: HKQuantity(unit: unit, doubleValue: glucoseValue), condition: nil, trend: trend, trendRate: trendRate)
  128. // capture semantics lets me "stow" the previous glucose value with this static function. A little weird, but it seems to work.
  129. prevGlucoseValue = glucoseValue
  130. completion(.newData([sample]))
  131. }
  132. }
  133. fileprivate static var noData: MockGlucoseProvider {
  134. return MockGlucoseProvider { _, completion in completion(.noData) }
  135. }
  136. fileprivate static var signalLoss: MockGlucoseProvider {
  137. return MockGlucoseProvider { _, _ in }
  138. }
  139. fileprivate static var unreliableData: MockGlucoseProvider {
  140. return MockGlucoseProvider { _, completion in completion(.unreliableData) }
  141. }
  142. fileprivate static func error(_ error: Error) -> MockGlucoseProvider {
  143. return MockGlucoseProvider { _, completion in completion(.error(error)) }
  144. }
  145. }
  146. // MARK: - Effects
  147. private struct MockGlucoseProviderError: Error { }
  148. extension MockGlucoseProvider {
  149. fileprivate func withRandomNoise(upTo magnitude: HKQuantity) -> MockGlucoseProvider {
  150. let unit = HKUnit.milligramsPerDeciliter
  151. precondition(magnitude.is(compatibleWith: unit))
  152. let magnitude = magnitude.doubleValue(for: unit)
  153. return mapGlucoseQuantities { glucose in
  154. let glucoseValue = (glucose.doubleValue(for: unit) + .random(in: -magnitude...magnitude)).rounded()
  155. return HKQuantity(unit: unit, doubleValue: glucoseValue)
  156. }
  157. }
  158. fileprivate func randomlyProducingLowOutlier(withChance chanceOfOutlier: Double, outlierDelta: HKQuantity) -> MockGlucoseProvider {
  159. return randomlyProducingOutlier(withChance: chanceOfOutlier, outlierDeltaMagnitude: outlierDelta, outlierDeltaSign: -)
  160. }
  161. fileprivate func randomlyProducingHighOutlier(withChance chanceOfOutlier: Double, outlierDelta: HKQuantity) -> MockGlucoseProvider {
  162. return randomlyProducingOutlier(withChance: chanceOfOutlier, outlierDeltaMagnitude: outlierDelta, outlierDeltaSign: +)
  163. }
  164. private func randomlyProducingOutlier(
  165. withChance chanceOfOutlier: Double,
  166. outlierDeltaMagnitude: HKQuantity,
  167. outlierDeltaSign: (Double) -> Double
  168. ) -> MockGlucoseProvider {
  169. let unit = HKUnit.milligramsPerDeciliter
  170. precondition(outlierDeltaMagnitude.is(compatibleWith: unit))
  171. let outlierDelta = outlierDeltaSign(outlierDeltaMagnitude.doubleValue(for: unit))
  172. return mapGlucoseQuantities { glucose in
  173. return coinFlip(
  174. withChanceOfHeads: chanceOfOutlier,
  175. ifHeads: HKQuantity(unit: unit, doubleValue: (glucose.doubleValue(for: unit) + outlierDelta).rounded()),
  176. ifTails: glucose
  177. )
  178. }
  179. }
  180. fileprivate func randomlyErroringOnNewData(withChance chance: Double) -> MockGlucoseProvider {
  181. return mapResult { result in
  182. return coinFlip(withChanceOfHeads: chance, ifHeads: .error(MockGlucoseProviderError()), ifTails: result)
  183. }
  184. }
  185. private func mapResult(_ transform: @escaping (CGMReadingResult) -> CGMReadingResult) -> MockGlucoseProvider {
  186. return MockGlucoseProvider { date, completion in
  187. self.fetchData(at: date) { result in
  188. completion(transform(result))
  189. }
  190. }
  191. }
  192. private func mapGlucoseQuantities(_ transform: @escaping (HKQuantity) -> HKQuantity) -> MockGlucoseProvider {
  193. return mapResult { result in
  194. return result.mapGlucoseQuantities(transform)
  195. }
  196. }
  197. }
  198. private extension CGMReadingResult {
  199. func mapGlucoseQuantities(_ transform: (HKQuantity) -> HKQuantity) -> CGMReadingResult {
  200. guard case .newData(let samples) = self else {
  201. return self
  202. }
  203. return .newData(
  204. samples.map { sample in
  205. return NewGlucoseSample(
  206. date: sample.date,
  207. quantity: transform(sample.quantity),
  208. condition: sample.condition,
  209. trend: sample.trend,
  210. trendRate: sample.trendRate,
  211. isDisplayOnly: sample.isDisplayOnly,
  212. wasUserEntered: sample.wasUserEntered,
  213. syncIdentifier: sample.syncIdentifier,
  214. syncVersion: sample.syncVersion,
  215. device: sample.device
  216. )
  217. }
  218. )
  219. }
  220. }
  221. private extension MockCGMDataSource.Model {
  222. var glucoseProvider: MockGlucoseProvider {
  223. switch self {
  224. case .constant(let quantity):
  225. return .constant(quantity)
  226. case .sineCurve(parameters: let parameters):
  227. return .sineCurve(parameters: parameters)
  228. case .noData:
  229. return .noData
  230. case .signalLoss:
  231. return .signalLoss
  232. case .unreliableData:
  233. return .unreliableData
  234. }
  235. }
  236. }
  237. private extension MockCGMDataSource.Effects {
  238. var transformations: [(MockGlucoseProvider) -> MockGlucoseProvider] {
  239. // Each effect maps to a transformation on a MockGlucoseProvider
  240. return [
  241. glucoseNoise.map { maximumDeltaMagnitude in { $0.withRandomNoise(upTo: maximumDeltaMagnitude) } },
  242. randomLowOutlier.map { chance, delta in { $0.randomlyProducingLowOutlier(withChance: chance, outlierDelta: delta) } },
  243. randomHighOutlier.map { chance, delta in { $0.randomlyProducingHighOutlier(withChance: chance, outlierDelta: delta) } },
  244. randomErrorChance.map { chance in { $0.randomlyErroringOnNewData(withChance: chance) } }
  245. ].compactMap { $0 }
  246. }
  247. }