| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281 |
- //
- // MockGlucoseProvider.swift
- // LoopKit
- //
- // Created by Michael Pangburn on 11/23/18.
- // Copyright © 2018 LoopKit Authors. All rights reserved.
- //
- import HealthKit
- import LoopKit
- /// Returns a value based on the result of a random coin flip.
- /// - Parameter chanceOfHeads: The chance of flipping heads. Must be a value in the range `0...1`. Defaults to `0.5`.
- /// - Parameter valueIfHeads: An autoclosure producing the value to return if the coin flips heads.
- /// - Parameter valueIfTails: An autoclosure producing the value to return if the coin flips tails.
- private func coinFlip<Output>(
- withChanceOfHeads chanceOfHeads: Double = 0.5,
- ifHeads valueIfHeads: @autoclosure () -> Output,
- ifTails valueIfTails: @autoclosure () -> Output
- ) -> Output {
- precondition((0...1).contains(chanceOfHeads))
- let isHeads = .random(in: 0..<100) < chanceOfHeads * 100
- return isHeads ? valueIfHeads() : valueIfTails()
- }
- struct MockGlucoseProvider {
- struct BackfillRequest {
- let duration: TimeInterval
- let dataPointFrequency: TimeInterval
- var dataPointCount: Int {
- return Int(duration / dataPointFrequency)
- }
- init(datingBack duration: TimeInterval, dataPointFrequency: TimeInterval) {
- self.duration = duration
- self.dataPointFrequency = dataPointFrequency
- }
- }
- /// Given a date, asynchronously produce the CGMReadingResult at that date.
- private let fetchDataAt: (_ date: Date, _ completion: @escaping (CGMReadingResult) -> Void) -> Void
- func fetchData(at date: Date, completion: @escaping (CGMReadingResult) -> Void) {
- fetchDataAt(date, completion)
- }
- func backfill(_ backfill: BackfillRequest, endingAt date: Date, completion: @escaping (CGMReadingResult) -> Void) {
- let dataPointDates = (0...backfill.dataPointCount).map { offset in
- return date.addingTimeInterval(-backfill.dataPointFrequency * Double(offset))
- }
- dataPointDates.asyncMap(fetchDataAt) { allResults in
- let allSamples = allResults.flatMap { result -> [NewGlucoseSample] in
- if case .newData(let samples) = result {
- return samples
- } else {
- return []
- }
- }
- let result: CGMReadingResult = allSamples.isEmpty ? .noData : .newData(allSamples.reversed())
- completion(result)
- }
- }
- }
- extension MockGlucoseProvider {
- init(model: MockCGMDataSource.Model, effects: MockCGMDataSource.Effects) {
- self = effects.transformations.reduce(model.glucoseProvider) { model, transform in transform(model) }
- }
- private static func glucoseSample(at date: Date, quantity: HKQuantity, condition: GlucoseCondition?, trend: GlucoseTrend?, trendRate: HKQuantity?) -> NewGlucoseSample {
- return NewGlucoseSample(
- date: date,
- quantity: quantity,
- condition: condition,
- trend: trend,
- trendRate: trendRate,
- isDisplayOnly: false,
- wasUserEntered: false,
- syncIdentifier: UUID().uuidString,
- device: MockCGMDataSource.device
- )
- }
- }
- // MARK: - Models
- extension MockGlucoseProvider {
- fileprivate static func constant(_ quantity: HKQuantity) -> MockGlucoseProvider {
- return MockGlucoseProvider { date, completion in
- let sample = glucoseSample(at: date, quantity: quantity, condition: nil, trend: .flat, trendRate: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: 0))
- completion(.newData([sample]))
- }
- }
- fileprivate static func sineCurve(parameters: MockCGMDataSource.Model.SineCurveParameters) -> MockGlucoseProvider {
- let (baseGlucose, amplitude, period, referenceDate) = parameters
- precondition(period > 0)
- let unit = HKUnit.milligramsPerDeciliter
- let trendRateUnit = unit.unitDivided(by: .minute())
- precondition(baseGlucose.is(compatibleWith: unit))
- precondition(amplitude.is(compatibleWith: unit))
- let baseGlucoseValue = baseGlucose.doubleValue(for: unit)
- let amplitudeValue = amplitude.doubleValue(for: unit)
- let chanceOfNilTrendRate = 1.0/20.0
- var prevGlucoseValue: Double?
-
- return MockGlucoseProvider { date, completion in
- let timeOffset = date.timeIntervalSince1970 - referenceDate.timeIntervalSince1970
- func sine(_ t: TimeInterval) -> Double {
- return Double(baseGlucoseValue + amplitudeValue * sin(2 * .pi / period * t)).rounded()
- }
- let glucoseValue = sine(timeOffset)
- var trend: GlucoseTrend?
- var trendRate: HKQuantity?
- if let prevGlucoseValue = prevGlucoseValue,
- let trendRateValue = coinFlip(withChanceOfHeads: chanceOfNilTrendRate, ifHeads: nil, ifTails: glucoseValue - prevGlucoseValue) {
- let smallDelta = 0.9
- let mediumDelta = 2.0
- let largeDelta = 5.0
- switch trendRateValue {
- case -smallDelta ... smallDelta:
- trend = .flat
- case -mediumDelta ..< -smallDelta:
- trend = .down
- case -largeDelta ..< -mediumDelta:
- trend = .downDown
- case -Double.greatestFiniteMagnitude ..< -largeDelta:
- trend = .downDownDown
- case smallDelta ... mediumDelta:
- trend = .up
- case mediumDelta ... largeDelta:
- trend = .upUp
- case largeDelta ... Double.greatestFiniteMagnitude:
- trend = .upUpUp
- default:
- break
- }
- trendRate = HKQuantity(unit: trendRateUnit, doubleValue: trendRateValue)
- }
- let sample = glucoseSample(at: date, quantity: HKQuantity(unit: unit, doubleValue: glucoseValue), condition: nil, trend: trend, trendRate: trendRate)
- // capture semantics lets me "stow" the previous glucose value with this static function. A little weird, but it seems to work.
- prevGlucoseValue = glucoseValue
- completion(.newData([sample]))
- }
- }
- fileprivate static var noData: MockGlucoseProvider {
- return MockGlucoseProvider { _, completion in completion(.noData) }
- }
-
- fileprivate static var signalLoss: MockGlucoseProvider {
- return MockGlucoseProvider { _, _ in }
- }
-
- fileprivate static var unreliableData: MockGlucoseProvider {
- return MockGlucoseProvider { _, completion in completion(.unreliableData) }
- }
- fileprivate static func error(_ error: Error) -> MockGlucoseProvider {
- return MockGlucoseProvider { _, completion in completion(.error(error)) }
- }
- }
- // MARK: - Effects
- private struct MockGlucoseProviderError: Error { }
- extension MockGlucoseProvider {
- fileprivate func withRandomNoise(upTo magnitude: HKQuantity) -> MockGlucoseProvider {
- let unit = HKUnit.milligramsPerDeciliter
- precondition(magnitude.is(compatibleWith: unit))
- let magnitude = magnitude.doubleValue(for: unit)
- return mapGlucoseQuantities { glucose in
- let glucoseValue = (glucose.doubleValue(for: unit) + .random(in: -magnitude...magnitude)).rounded()
- return HKQuantity(unit: unit, doubleValue: glucoseValue)
- }
- }
- fileprivate func randomlyProducingLowOutlier(withChance chanceOfOutlier: Double, outlierDelta: HKQuantity) -> MockGlucoseProvider {
- return randomlyProducingOutlier(withChance: chanceOfOutlier, outlierDeltaMagnitude: outlierDelta, outlierDeltaSign: -)
- }
- fileprivate func randomlyProducingHighOutlier(withChance chanceOfOutlier: Double, outlierDelta: HKQuantity) -> MockGlucoseProvider {
- return randomlyProducingOutlier(withChance: chanceOfOutlier, outlierDeltaMagnitude: outlierDelta, outlierDeltaSign: +)
- }
- private func randomlyProducingOutlier(
- withChance chanceOfOutlier: Double,
- outlierDeltaMagnitude: HKQuantity,
- outlierDeltaSign: (Double) -> Double
- ) -> MockGlucoseProvider {
- let unit = HKUnit.milligramsPerDeciliter
- precondition(outlierDeltaMagnitude.is(compatibleWith: unit))
- let outlierDelta = outlierDeltaSign(outlierDeltaMagnitude.doubleValue(for: unit))
- return mapGlucoseQuantities { glucose in
- return coinFlip(
- withChanceOfHeads: chanceOfOutlier,
- ifHeads: HKQuantity(unit: unit, doubleValue: (glucose.doubleValue(for: unit) + outlierDelta).rounded()),
- ifTails: glucose
- )
- }
- }
- fileprivate func randomlyErroringOnNewData(withChance chance: Double) -> MockGlucoseProvider {
- return mapResult { result in
- return coinFlip(withChanceOfHeads: chance, ifHeads: .error(MockGlucoseProviderError()), ifTails: result)
- }
- }
- private func mapResult(_ transform: @escaping (CGMReadingResult) -> CGMReadingResult) -> MockGlucoseProvider {
- return MockGlucoseProvider { date, completion in
- self.fetchData(at: date) { result in
- completion(transform(result))
- }
- }
- }
- private func mapGlucoseQuantities(_ transform: @escaping (HKQuantity) -> HKQuantity) -> MockGlucoseProvider {
- return mapResult { result in
- return result.mapGlucoseQuantities(transform)
- }
- }
- }
- private extension CGMReadingResult {
- func mapGlucoseQuantities(_ transform: (HKQuantity) -> HKQuantity) -> CGMReadingResult {
- guard case .newData(let samples) = self else {
- return self
- }
- return .newData(
- samples.map { sample in
- return NewGlucoseSample(
- date: sample.date,
- quantity: transform(sample.quantity),
- condition: sample.condition,
- trend: sample.trend,
- trendRate: sample.trendRate,
- isDisplayOnly: sample.isDisplayOnly,
- wasUserEntered: sample.wasUserEntered,
- syncIdentifier: sample.syncIdentifier,
- syncVersion: sample.syncVersion,
- device: sample.device
- )
- }
- )
- }
- }
- private extension MockCGMDataSource.Model {
- var glucoseProvider: MockGlucoseProvider {
- switch self {
- case .constant(let quantity):
- return .constant(quantity)
- case .sineCurve(parameters: let parameters):
- return .sineCurve(parameters: parameters)
- case .noData:
- return .noData
- case .signalLoss:
- return .signalLoss
- case .unreliableData:
- return .unreliableData
- }
- }
- }
- private extension MockCGMDataSource.Effects {
- var transformations: [(MockGlucoseProvider) -> MockGlucoseProvider] {
- // Each effect maps to a transformation on a MockGlucoseProvider
- return [
- glucoseNoise.map { maximumDeltaMagnitude in { $0.withRandomNoise(upTo: maximumDeltaMagnitude) } },
- randomLowOutlier.map { chance, delta in { $0.randomlyProducingLowOutlier(withChance: chance, outlierDelta: delta) } },
- randomHighOutlier.map { chance, delta in { $0.randomlyProducingHighOutlier(withChance: chance, outlierDelta: delta) } },
- randomErrorChance.map { chance in { $0.randomlyErroringOnNewData(withChance: chance) } }
- ].compactMap { $0 }
- }
- }
|