diff --git a/README.md b/README.md index 04aac8f..02e2944 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,23 @@ Please understand that this project: * Is highly experimental * Is not approved for therapy +## Usage + +The entry point is `Algorithm.run(input:)`, which takes any type conforming to `AlgorithmInput` and returns the +glucose forecast, the effects behind it, and a dose recommendation. The API takes plain numbers in fixed units: + +| Quantity | Unit | +|---|---| +| Glucose, targets, suspend threshold | mg/dL | +| Insulin sensitivity | mg/dL per unit | +| Carbs | grams | +| Carb ratio | grams per unit | +| Insulin | units | +| Basal rates | U/h | + +Apps that store values in other units (mmol/L) convert before calling. `Algorithm.generatePrediction` produces a +forecast without a recommendation. + ## Command Line Runner There is a command line utility that will run the algorithm with a scenario input file: diff --git a/Sources/LoopAlgorithm/AlgorithmInput.swift b/Sources/LoopAlgorithm/AlgorithmInput.swift index 42c2ded..4a88b4c 100644 --- a/Sources/LoopAlgorithm/AlgorithmInput.swift +++ b/Sources/LoopAlgorithm/AlgorithmInput.swift @@ -9,6 +9,7 @@ import Foundation +/// The inputs to `Algorithm.run(input:)`. Glucose values are in mg/dL, carbs in grams and insulin in units. public protocol AlgorithmInput { associatedtype CarbType: CarbEntry associatedtype GlucoseType: GlucoseSampleValue @@ -18,13 +19,21 @@ public protocol AlgorithmInput { var glucoseHistory: [GlucoseType] { get } var doses: [InsulinDoseType] { get } var carbEntries: [CarbType] { get } + /// Scheduled basal rate in U/h var basal: [AbsoluteScheduleValue] { get } - var sensitivity: [AbsoluteScheduleValue] { get } + /// Insulin sensitivity in mg/dL per unit + var sensitivityMgdlPerUnit: [AbsoluteScheduleValue] { get } + /// Carb ratio in grams per unit var carbRatio: [AbsoluteScheduleValue] { get } - var target: GlucoseRangeTimeline { get } - var suspendThreshold: LoopQuantity? { get } + /// Correction range in mg/dL + var targetMgdl: GlucoseRangeTimeline { get } + /// Suspend threshold in mg/dL + var suspendThresholdMgdl: Double? { get } + /// Maximum bolus in units var maxBolus: Double { get } - var maxActiveInsulinMultiplier: Double? { get } // Defaults to 2 (2x maxBolus) + /// Limit on active insulin, as a multiple of `maxBolus`. Defaults to 2. + var maxActiveInsulinMultiplier: Double? { get } + /// Maximum basal rate in U/h var maxBasalRate: Double { get } var useIntegralRetrospectiveCorrection: Bool { get } var includePositiveVelocityAndRC: Bool { get } @@ -32,9 +41,13 @@ public protocol AlgorithmInput { var carbAbsorptionModel: CarbAbsorptionModel { get } var recommendationInsulinModel: InsulinModel { get } var recommendationType: DoseRecommendationType { get } - var automaticBolusApplicationFactor: Double? { get } // Defaults to 0.4 + /// Fraction of the correction given as an automatic bolus. Defaults to 0.4. + var automaticBolusApplicationFactor: Double? { get } + /// Largest jump in mg/dL between consecutive glucose readings for retrospective correction to apply. + /// Defaults to 40. var gradualTransitionsThreshold: Double? { get } - var emulation: AlgorithmEmulationOptions? { get } // Defaults to nil (no emulation) + /// Options for reproducing earlier deployed algorithm behavior. Defaults to nil (no emulation). + var emulation: AlgorithmEmulationOptions? { get } /// Optional algorithm experiments; the empty set is standard LoopAlgorithm. Conforming types that do /// not provide it get the empty set. var experiments: Set { get } @@ -50,3 +63,9 @@ public extension AlgorithmInput { var experiments: Set { [] } var volatilityBaseline: Double? { nil } } + +extension AlgorithmInput { + var sensitivityQuantities: [AbsoluteScheduleValue] { sensitivityMgdlPerUnit.mgdlQuantities } + var targetQuantities: QuantityRangeTimeline { targetMgdl.quantities } + var suspendThresholdQuantity: LoopQuantity? { suspendThresholdMgdl.map(LoopQuantity.mgdl) } +} diff --git a/Sources/LoopAlgorithm/AlgorithmInputFixture.swift b/Sources/LoopAlgorithm/AlgorithmInputFixture.swift index 9f26e39..1c6ecc8 100644 --- a/Sources/LoopAlgorithm/AlgorithmInputFixture.swift +++ b/Sources/LoopAlgorithm/AlgorithmInputFixture.swift @@ -20,10 +20,10 @@ public struct AlgorithmInputFixture: AlgorithmInput { public var doses: [FixtureInsulinDose] public var carbEntries: [FixtureCarbEntry] public var basal: [AbsoluteScheduleValue] - public var sensitivity: [AbsoluteScheduleValue] + public var sensitivityMgdlPerUnit: [AbsoluteScheduleValue] public var carbRatio: [AbsoluteScheduleValue] - public var target: GlucoseRangeTimeline - public var suspendThreshold: LoopQuantity? + public var targetMgdl: GlucoseRangeTimeline + public var suspendThresholdMgdl: Double? public var maxBolus: Double public var maxActiveInsulinMultiplier: Double? public var maxBasalRate: Double @@ -63,10 +63,10 @@ public struct AlgorithmInputFixture: AlgorithmInput { doses: [FixtureInsulinDose], carbEntries: [FixtureCarbEntry], basal: [AbsoluteScheduleValue], - sensitivity: [AbsoluteScheduleValue], + sensitivityMgdlPerUnit: [AbsoluteScheduleValue], carbRatio: [AbsoluteScheduleValue], - target: GlucoseRangeTimeline, - suspendThreshold: LoopQuantity?, + targetMgdl: GlucoseRangeTimeline, + suspendThresholdMgdl: Double?, maxBolus: Double, maxActiveInsulinMultiplier: Double? = nil, maxBasalRate: Double, @@ -85,10 +85,10 @@ public struct AlgorithmInputFixture: AlgorithmInput { self.doses = doses self.carbEntries = carbEntries self.basal = basal - self.sensitivity = sensitivity + self.sensitivityMgdlPerUnit = sensitivityMgdlPerUnit self.carbRatio = carbRatio - self.target = target - self.suspendThreshold = suspendThreshold + self.targetMgdl = targetMgdl + self.suspendThresholdMgdl = suspendThresholdMgdl self.maxBolus = maxBolus self.maxActiveInsulinMultiplier = maxActiveInsulinMultiplier self.maxBasalRate = maxBasalRate @@ -114,19 +114,14 @@ extension AlgorithmInputFixture: Codable { self.doses = try container.decode([FixtureInsulinDose].self, forKey: .doses) self.carbEntries = try container.decode([FixtureCarbEntry].self, forKey: .carbEntries) self.basal = try container.decode([AbsoluteScheduleValue].self, forKey: .basal) - let sensitivityMgdl = try container.decode([AbsoluteScheduleValue].self, forKey: .sensitivity) - self.sensitivity = sensitivityMgdl.map { AbsoluteScheduleValue(startDate: $0.startDate, endDate: $0.endDate, value: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: $0.value))} + self.sensitivityMgdlPerUnit = try container.decode([AbsoluteScheduleValue].self, forKey: .sensitivity) self.carbRatio = try container.decode([AbsoluteScheduleValue].self, forKey: .carbRatio) - let targetMgdl = try container.decode([TargetEntry].self, forKey: .target) - self.target = targetMgdl.map { - let lower = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: $0.lowerBound) - let upper = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: $0.upperBound) - let range = ClosedRange(uncheckedBounds: (lower: lower, upper: upper)) + let targetEntries = try container.decode([TargetEntry].self, forKey: .target) + self.targetMgdl = targetEntries.map { + let range = ClosedRange(uncheckedBounds: (lower: $0.lowerBound, upper: $0.upperBound)) return AbsoluteScheduleValue(startDate: $0.startDate, endDate: $0.endDate, value: range) } - if let suspendThresholdMgdl = try container.decodeIfPresent(Double.self, forKey: .suspendThreshold) { - self.suspendThreshold = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: suspendThresholdMgdl) - } + self.suspendThresholdMgdl = try container.decodeIfPresent(Double.self, forKey: .suspendThreshold) self.maxBolus = try container.decode(Double.self, forKey: .maxBolus) self.maxActiveInsulinMultiplier = try container.decodeIfPresent(Double.self, forKey: .maxActiveInsulinMultiplier) self.maxBasalRate = try container.decode(Double.self, forKey: .maxBasalRate) @@ -168,16 +163,13 @@ extension AlgorithmInputFixture: Codable { try container.encode(doses, forKey: .doses) try container.encode(carbEntries, forKey: .carbEntries) try container.encode(basal, forKey: .basal) - let sensitivityMgdl = sensitivity.map { AbsoluteScheduleValue(startDate: $0.startDate, endDate: $0.endDate, value: $0.value.doubleValue(for: .milligramsPerDeciliter)) } - try container.encode(sensitivityMgdl, forKey: .sensitivity) + try container.encode(sensitivityMgdlPerUnit, forKey: .sensitivity) try container.encode(carbRatio, forKey: .carbRatio) - let targetMgdl = target.map { - let lower = $0.value.lowerBound.doubleValue(for: .milligramsPerDeciliter) - let upper = $0.value.upperBound.doubleValue(for: .milligramsPerDeciliter) - return TargetEntry(startDate: $0.startDate, endDate: $0.endDate, lowerBound: lower, upperBound: upper) + let targetEntries = targetMgdl.map { + TargetEntry(startDate: $0.startDate, endDate: $0.endDate, lowerBound: $0.value.lowerBound, upperBound: $0.value.upperBound) } - try container.encode(targetMgdl, forKey: .target) - try container.encode(suspendThreshold?.doubleValue(for: .milligramsPerDeciliter), forKey: .suspendThreshold) + try container.encode(targetEntries, forKey: .target) + try container.encode(suspendThresholdMgdl, forKey: .suspendThreshold) try container.encode(maxBolus, forKey: .maxBolus) try container.encode(maxActiveInsulinMultiplier, forKey: .maxActiveInsulinMultiplier) try container.encode(maxBasalRate, forKey: .maxBasalRate) @@ -235,10 +227,10 @@ extension AlgorithmInputFixture { doses: input.doses.map(\.asFixtureInsulinDose), carbEntries: input.carbEntries.map(\.asFixtureCarbEntry), basal: input.basal, - sensitivity: input.sensitivity, + sensitivityMgdlPerUnit: input.sensitivityMgdlPerUnit, carbRatio: input.carbRatio, - target: input.target, - suspendThreshold: input.suspendThreshold, + targetMgdl: input.targetMgdl, + suspendThresholdMgdl: input.suspendThresholdMgdl, maxBolus: input.maxBolus, maxBasalRate: input.maxBasalRate, useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection, diff --git a/Sources/LoopAlgorithm/Carbs/AbsorbedCarbValue.swift b/Sources/LoopAlgorithm/Carbs/AbsorbedCarbValue.swift index 6783b3c..ecf51fd 100644 --- a/Sources/LoopAlgorithm/Carbs/AbsorbedCarbValue.swift +++ b/Sources/LoopAlgorithm/Carbs/AbsorbedCarbValue.swift @@ -10,14 +10,19 @@ import Foundation /// A quantity of carbs absorbed over a given date interval public struct AbsorbedCarbValue: SampleValue { - /// The quantity of carbs absorbed - public let observed: LoopQuantity - /// The quantity of carbs absorbed, clamped to the original prediction - public let clamped: LoopQuantity - /// The quantity of carbs entered as eaten - public let total: LoopQuantity - /// The quantity of carbs expected to still absorb - public let remaining: LoopQuantity + let observed: LoopQuantity + let clamped: LoopQuantity + let total: LoopQuantity + let remaining: LoopQuantity + + /// Grams of carbs absorbed + public var observedGrams: Double { observed.doubleValue(for: .gram) } + /// Grams of carbs absorbed, clamped to the original prediction + public var clampedGrams: Double { clamped.doubleValue(for: .gram) } + /// Grams of carbs entered as eaten + public var totalGrams: Double { total.doubleValue(for: .gram) } + /// Grams of carbs expected to still absorb + public var remainingGrams: Double { remaining.doubleValue(for: .gram) } /// The dates over which absorption was observed public let observedDate: DateInterval @@ -37,7 +42,12 @@ public struct AbsorbedCarbValue: SampleValue { return estimatedTimeRemaining > 0 } - public var observedProgress: LoopQuantity { + /// Observed absorption as a fraction of the total entered + public var observedFraction: Double { + observedProgress.doubleValue(for: .percent) + } + + var observedProgress: LoopQuantity { let gram = LoopUnit.gram let totalGrams = total.doubleValue(for: gram) let percent = LoopUnit.percent @@ -54,7 +64,7 @@ public struct AbsorbedCarbValue: SampleValue { // MARK: SampleValue - public var quantity: LoopQuantity { + var quantity: LoopQuantity { return clamped } diff --git a/Sources/LoopAlgorithm/Carbs/CarbEntry.swift b/Sources/LoopAlgorithm/Carbs/CarbEntry.swift index 9c6d39a..66d5681 100644 --- a/Sources/LoopAlgorithm/Carbs/CarbEntry.swift +++ b/Sources/LoopAlgorithm/Carbs/CarbEntry.swift @@ -9,6 +9,14 @@ import Foundation public protocol CarbEntry: SampleValue { + /// The carbohydrate amount in grams + var grams: Double { get } var absorptionTime: TimeInterval? { get } } +extension CarbEntry { + var quantity: LoopQuantity { + LoopQuantity(unit: .gram, doubleValue: grams) + } +} + diff --git a/Sources/LoopAlgorithm/Carbs/CarbMath.swift b/Sources/LoopAlgorithm/Carbs/CarbMath.swift index 53b5779..8776509 100644 --- a/Sources/LoopAlgorithm/Carbs/CarbMath.swift +++ b/Sources/LoopAlgorithm/Carbs/CarbMath.swift @@ -360,7 +360,7 @@ extension Collection { return values } - public func dynamicGlucoseEffects( + func dynamicGlucoseEffects( from start: Date? = nil, to end: Date? = nil, carbRatios: [AbsoluteScheduleValue], @@ -688,7 +688,7 @@ extension Collection where Element: CarbEntry { /// - defaultAbsorptionTime: The absorption time to use for unspecified carb entries /// - delay: The time to delay the dose effect /// - Returns: A new array of `CarbStatus` values describing the absorbed carb quantities - public func map( + func map( to effectVelocities: [GlucoseEffectVelocity], carbRatio: [AbsoluteScheduleValue], insulinSensitivity: [AbsoluteScheduleValue], @@ -790,3 +790,41 @@ extension Collection where Element: CarbEntry { return builders.map { $0.result } } } + +public extension Collection { + /// Carb effects from absorption status. Sensitivity is in mg/dL per unit. + func dynamicGlucoseEffects( + from start: Date? = nil, + to end: Date? = nil, + carbRatios: [AbsoluteScheduleValue], + insulinSensitivitiesMgdlPerUnit: [AbsoluteScheduleValue], + defaultAbsorptionTime: TimeInterval = CarbMath.defaultAbsorptionTime, + absorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption(), + delay: TimeInterval = CarbMath.defaultEffectDelay, + delta: TimeInterval = GlucoseMath.defaultDelta + ) -> [GlucoseEffect] where Element == CarbStatus { + dynamicGlucoseEffects(from: start, to: end, carbRatios: carbRatios, insulinSensitivities: insulinSensitivitiesMgdlPerUnit.mgdlQuantities, + defaultAbsorptionTime: defaultAbsorptionTime, absorptionModel: absorptionModel, delay: delay, delta: delta) + } +} + +public extension Collection where Element: CarbEntry { + /// Maps carb entries to observed absorption. Sensitivity is in mg/dL per unit. + func map( + to effectVelocities: [GlucoseEffectVelocity], + carbRatio: [AbsoluteScheduleValue], + insulinSensitivityMgdlPerUnit: [AbsoluteScheduleValue], + absorptionTimeOverrun: Double = CarbMath.defaultAbsorptionTimeOverrun, + defaultAbsorptionTime: TimeInterval = CarbMath.defaultAbsorptionTime, + delay: TimeInterval = CarbMath.defaultEffectDelay, + initialAbsorptionTimeOverrun: Double = CarbMath.defaultAbsorptionTimeOverrun, + absorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption(), + adaptiveAbsorptionRateEnabled: Bool = false, + adaptiveRateStandbyIntervalFraction: Double = 0.2 + ) -> [CarbStatus] { + map(to: effectVelocities, carbRatio: carbRatio, insulinSensitivity: insulinSensitivityMgdlPerUnit.mgdlQuantities, + absorptionTimeOverrun: absorptionTimeOverrun, defaultAbsorptionTime: defaultAbsorptionTime, delay: delay, + initialAbsorptionTimeOverrun: initialAbsorptionTimeOverrun, absorptionModel: absorptionModel, + adaptiveAbsorptionRateEnabled: adaptiveAbsorptionRateEnabled, adaptiveRateStandbyIntervalFraction: adaptiveRateStandbyIntervalFraction) + } +} diff --git a/Sources/LoopAlgorithm/Carbs/CarbStatus.swift b/Sources/LoopAlgorithm/Carbs/CarbStatus.swift index 219624b..6a3f215 100644 --- a/Sources/LoopAlgorithm/Carbs/CarbStatus.swift +++ b/Sources/LoopAlgorithm/Carbs/CarbStatus.swift @@ -22,8 +22,8 @@ public struct CarbStatus { // Masquerade as a carb entry, substituting AbsorbedCarbValue's interpretation of absorption time extension CarbStatus: SampleValue { - public var quantity: LoopQuantity { - return entry.quantity + public var grams: Double { + return entry.grams } public var startDate: Date { diff --git a/Sources/LoopAlgorithm/Carbs/CarbValue.swift b/Sources/LoopAlgorithm/Carbs/CarbValue.swift index 569cc9e..ce0fe7a 100644 --- a/Sources/LoopAlgorithm/Carbs/CarbValue.swift +++ b/Sources/LoopAlgorithm/Carbs/CarbValue.swift @@ -12,7 +12,7 @@ public struct CarbValue: SampleValue { public let endDate: Date public var value: Double - public var quantity: LoopQuantity { + var quantity: LoopQuantity { return LoopQuantity(unit: .gram, doubleValue: value) } diff --git a/Sources/LoopAlgorithm/Carbs/FixtureCarbEntry.swift b/Sources/LoopAlgorithm/Carbs/FixtureCarbEntry.swift index 451fff6..b7aefa9 100644 --- a/Sources/LoopAlgorithm/Carbs/FixtureCarbEntry.swift +++ b/Sources/LoopAlgorithm/Carbs/FixtureCarbEntry.swift @@ -10,20 +10,29 @@ import Foundation public struct FixtureCarbEntry: CarbEntry { public var absorptionTime: TimeInterval? public var startDate: Date - public var quantity: LoopQuantity + public var grams: Double public var foodType: String? public init( absorptionTime: TimeInterval? = nil, startDate: Date, - quantity: LoopQuantity, + grams: Double, foodType: String? = nil ) { self.absorptionTime = absorptionTime self.startDate = startDate - self.quantity = quantity + self.grams = grams self.foodType = foodType } + + init( + absorptionTime: TimeInterval? = nil, + startDate: Date, + quantity: LoopQuantity, + foodType: String? = nil + ) { + self.init(absorptionTime: absorptionTime, startDate: startDate, grams: quantity.doubleValue(for: .gram), foodType: foodType) + } } extension FixtureCarbEntry: Codable { @@ -32,7 +41,7 @@ extension FixtureCarbEntry: Codable { self.init( absorptionTime: try container.decodeIfPresent(TimeInterval.self, forKey: .absorptionTime), startDate: try container.decode(Date.self, forKey: .date), - quantity: LoopQuantity(unit: .gram, doubleValue: try container.decode(Double.self, forKey: .grams)) + grams: try container.decode(Double.self, forKey: .grams) ) } @@ -40,7 +49,7 @@ extension FixtureCarbEntry: Codable { var container = encoder.container(keyedBy: CodingKeys.self) try container.encodeIfPresent(absorptionTime, forKey: .absorptionTime) try container.encode(startDate, forKey: .date) - try container.encode(quantity.doubleValue(for: .gram), forKey: .grams) + try container.encode(grams, forKey: .grams) } private enum CodingKeys: String, CodingKey { diff --git a/Sources/LoopAlgorithm/Experiments/ExperimentalForecastAdjustments.swift b/Sources/LoopAlgorithm/Experiments/ExperimentalForecastAdjustments.swift index 98e244c..671925c 100644 --- a/Sources/LoopAlgorithm/Experiments/ExperimentalForecastAdjustments.swift +++ b/Sources/LoopAlgorithm/Experiments/ExperimentalForecastAdjustments.swift @@ -22,7 +22,7 @@ extension ExperimentParameters.PostLowRiseCut { } } -extension LoopAlgorithm { +extension Algorithm { /// The volatility band: each predicted point τ minutes ahead is lowered by /// `gain × σ5 × (τ/5)^exponent` up to `horizon`, tapering to zero by `taperEnd`. @@ -164,7 +164,7 @@ extension LoopAlgorithm { } /// The target range lowered by `shift` mg/dL, floored at 70–80. - static func targetLowered(_ target: GlucoseRangeTimeline, by shift: Double) -> GlucoseRangeTimeline { + static func targetLowered(_ target: QuantityRangeTimeline, by shift: Double) -> QuantityRangeTimeline { let unit = LoopUnit.milligramsPerDeciliter return target.map { AbsoluteScheduleValue( diff --git a/Sources/LoopAlgorithm/Experiments/GuardAndRunwayOutput.swift b/Sources/LoopAlgorithm/Experiments/GuardAndRunwayOutput.swift index 77bcad8..90422a9 100644 --- a/Sources/LoopAlgorithm/Experiments/GuardAndRunwayOutput.swift +++ b/Sources/LoopAlgorithm/Experiments/GuardAndRunwayOutput.swift @@ -29,7 +29,7 @@ public struct GuardAndRunwayOutput: Equatable { /// `glucose` lowered by the band as the low guard saw it this run; unchanged when the band was off. /// `AlgorithmOutput.predictedGlucose` is the forecast without the band. public func guarded(_ glucose: [PredictedGlucoseValue]) -> [PredictedGlucoseValue] { - bandApplied ? LoopAlgorithm.volatilityBanded(glucose, band: band, volatility: volatility) : glucose + bandApplied ? Algorithm.volatilityBanded(glucose, band: band, volatility: volatility) : glucose } } diff --git a/Sources/LoopAlgorithm/Extensions/ClosedRange.swift b/Sources/LoopAlgorithm/Extensions/ClosedRange.swift index 77befd9..8d04f76 100644 --- a/Sources/LoopAlgorithm/Extensions/ClosedRange.swift +++ b/Sources/LoopAlgorithm/Extensions/ClosedRange.swift @@ -7,7 +7,7 @@ // extension ClosedRange where Bound == LoopQuantity { - public func averageValue(for unit: LoopUnit) -> Double { + func averageValue(for unit: LoopUnit) -> Double { let minValue = lowerBound.doubleValue(for: unit) let maxValue = upperBound.doubleValue(for: unit) return (maxValue + minValue) / 2 diff --git a/Sources/LoopAlgorithm/Glucose/FixtureGlucoseSample.swift b/Sources/LoopAlgorithm/Glucose/FixtureGlucoseSample.swift index 9d26171..7af18f3 100644 --- a/Sources/LoopAlgorithm/Glucose/FixtureGlucoseSample.swift +++ b/Sources/LoopAlgorithm/Glucose/FixtureGlucoseSample.swift @@ -12,25 +12,34 @@ public struct FixtureGlucoseSample: GlucoseSampleValue, Equatable { public let provenanceIdentifier: String public let startDate: Date - public let quantity: LoopQuantity + public let mgdl: Double public let isDisplayOnly: Bool public let wasUserEntered: Bool - public var condition: GlucoseCondition? - public var trendRate: LoopQuantity? public init( provenanceIdentifier: String = Self.defaultProvenanceIdentifier, startDate: Date, - quantity: LoopQuantity, + mgdl: Double, isDisplayOnly: Bool = false, wasUserEntered: Bool = false ) { self.provenanceIdentifier = provenanceIdentifier self.startDate = startDate - self.quantity = quantity + self.mgdl = mgdl self.isDisplayOnly = isDisplayOnly self.wasUserEntered = wasUserEntered } + + init( + provenanceIdentifier: String = Self.defaultProvenanceIdentifier, + startDate: Date, + quantity: LoopQuantity, + isDisplayOnly: Bool = false, + wasUserEntered: Bool = false + ) { + self.init(provenanceIdentifier: provenanceIdentifier, startDate: startDate, mgdl: quantity.doubleValue(for: .milligramsPerDeciliter), + isDisplayOnly: isDisplayOnly, wasUserEntered: wasUserEntered) + } } extension FixtureGlucoseSample: Codable { @@ -43,7 +52,7 @@ extension FixtureGlucoseSample: Codable { self.init(provenanceIdentifier: provenanceIdentifier, startDate: try container.decode(Date.self, forKey: .date), - quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: try container.decode(Double.self, forKey: .value)), + mgdl: try container.decode(Double.self, forKey: .value), isDisplayOnly: isDisplayOnly, wasUserEntered: wasUserEntered ) @@ -55,7 +64,7 @@ extension FixtureGlucoseSample: Codable { try container.encode(provenanceIdentifier, forKey: .provenanceIdentifier) } try container.encode(startDate, forKey: .date) - try container.encode(quantity.doubleValue(for: .milligramsPerDeciliter), forKey: .value) + try container.encode(mgdl, forKey: .value) if isDisplayOnly { try container.encode(isDisplayOnly, forKey: .isDisplayOnly) } diff --git a/Sources/LoopAlgorithm/Glucose/GlucoseChange.swift b/Sources/LoopAlgorithm/Glucose/GlucoseChange.swift index 504ebd3..2cf84a5 100644 --- a/Sources/LoopAlgorithm/Glucose/GlucoseChange.swift +++ b/Sources/LoopAlgorithm/Glucose/GlucoseChange.swift @@ -11,12 +11,20 @@ import Foundation public struct GlucoseChange: SampleValue, Equatable { public var startDate: Date public var endDate: Date - public var quantity: LoopQuantity + public var mgdl: Double - public init(startDate: Date, endDate: Date, quantity: LoopQuantity) { + public init(startDate: Date, endDate: Date, mgdl: Double) { self.startDate = startDate self.endDate = endDate - self.quantity = quantity + self.mgdl = mgdl + } + + init(startDate: Date, endDate: Date, quantity: LoopQuantity) { + self.init(startDate: startDate, endDate: endDate, mgdl: quantity.doubleValue(for: .milligramsPerDeciliter)) + } + + var quantity: LoopQuantity { + LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: mgdl) } } @@ -25,10 +33,7 @@ extension GlucoseChange { mutating public func append(_ effect: GlucoseEffect) { startDate = min(effect.startDate, startDate) endDate = max(effect.endDate, endDate) - quantity = LoopQuantity( - unit: .milligramsPerDeciliter, - doubleValue: quantity.doubleValue(for: .milligramsPerDeciliter) + effect.quantity.doubleValue(for: .milligramsPerDeciliter) - ) + mgdl += effect.mgdl } } @@ -37,17 +42,14 @@ extension GlucoseChange: Codable { let container = try decoder.container(keyedBy: CodingKeys.self) self.startDate = try container.decode(Date.self, forKey: .startDate) self.endDate = try container.decode(Date.self, forKey: .endDate) - self.quantity = LoopQuantity( - unit: .milligramsPerDeciliter, - doubleValue: try container.decode(Double.self, forKey: .mgdl) - ) + self.mgdl = try container.decode(Double.self, forKey: .mgdl) } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(startDate, forKey: .startDate) try container.encode(endDate, forKey: .endDate) - try container.encode(quantity.doubleValue(for: .milligramsPerDeciliter), forKey: .mgdl) + try container.encode(mgdl, forKey: .mgdl) } private enum CodingKeys: String, CodingKey { diff --git a/Sources/LoopAlgorithm/Glucose/GlucoseCondition.swift b/Sources/LoopAlgorithm/Glucose/GlucoseCondition.swift deleted file mode 100644 index 2ff6815..0000000 --- a/Sources/LoopAlgorithm/Glucose/GlucoseCondition.swift +++ /dev/null @@ -1,12 +0,0 @@ -// -// GlucoseCondition.swift -// LoopAlgorithm -// -// Created by Darin Krauss on 9/3/21. -// Copyright © 2021 LoopKit Authors. All rights reserved. -// - -public enum GlucoseCondition: String, Codable { - case belowRange - case aboveRange -} diff --git a/Sources/LoopAlgorithm/Glucose/GlucoseEffect.swift b/Sources/LoopAlgorithm/Glucose/GlucoseEffect.swift index 9572f80..c1790d6 100644 --- a/Sources/LoopAlgorithm/Glucose/GlucoseEffect.swift +++ b/Sources/LoopAlgorithm/Glucose/GlucoseEffect.swift @@ -10,11 +10,15 @@ import Foundation public struct GlucoseEffect: GlucoseValue, Equatable, Sendable { public let startDate: Date - public let quantity: LoopQuantity + public let mgdl: Double - public init(startDate: Date, quantity: LoopQuantity) { + public init(startDate: Date, mgdl: Double) { self.startDate = startDate - self.quantity = quantity + self.mgdl = mgdl + } + + init(startDate: Date, quantity: LoopQuantity) { + self.init(startDate: startDate, mgdl: quantity.doubleValue(for: .milligramsPerDeciliter)) } } @@ -22,13 +26,13 @@ extension GlucoseEffect: Codable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.init(startDate: try container.decode(Date.self, forKey: .startDate), - quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: try container.decode(Double.self, forKey: .quantity))) + mgdl: try container.decode(Double.self, forKey: .quantity)) } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(startDate, forKey: .startDate) - try container.encode(quantity.doubleValue(for: .milligramsPerDeciliter), forKey: .quantity) + try container.encode(mgdl, forKey: .quantity) } private enum CodingKeys: String, CodingKey { diff --git a/Sources/LoopAlgorithm/Glucose/GlucoseEffectVelocity.swift b/Sources/LoopAlgorithm/Glucose/GlucoseEffectVelocity.swift index 5df2542..af4fcc9 100644 --- a/Sources/LoopAlgorithm/Glucose/GlucoseEffectVelocity.swift +++ b/Sources/LoopAlgorithm/Glucose/GlucoseEffectVelocity.swift @@ -11,31 +11,31 @@ import Foundation public struct GlucoseEffectVelocity: SampleValue { public let startDate: Date public let endDate: Date - public let quantity: LoopQuantity + public let mgdlPerSecond: Double - public init(startDate: Date, endDate: Date, quantity: LoopQuantity) { + public init(startDate: Date, endDate: Date, mgdlPerSecond: Double) { self.startDate = startDate self.endDate = endDate - self.quantity = quantity + self.mgdlPerSecond = mgdlPerSecond + } + + init(startDate: Date, endDate: Date, quantity: LoopQuantity) { + self.init(startDate: startDate, endDate: endDate, mgdlPerSecond: quantity.doubleValue(for: GlucoseEffectVelocity.perSecondUnit)) + } + + var quantity: LoopQuantity { + LoopQuantity(unit: GlucoseEffectVelocity.perSecondUnit, doubleValue: mgdlPerSecond) } } extension GlucoseEffectVelocity { - public static let perSecondUnit = LoopUnit.milligramsPerDeciliterPerSecond + static let perSecondUnit = LoopUnit.milligramsPerDeciliterPerSecond /// The integration of the velocity span public var effect: GlucoseEffect { let duration = endDate.timeIntervalSince(startDate) - let velocityPerSecond = quantity.doubleValue(for: GlucoseEffectVelocity.perSecondUnit) - - return GlucoseEffect( - startDate: endDate, - quantity: LoopQuantity( - unit: .milligramsPerDeciliter, - doubleValue: velocityPerSecond * duration - ) - ) + return GlucoseEffect(startDate: endDate, mgdl: mgdlPerSecond * duration) } } @@ -44,17 +44,14 @@ extension GlucoseEffectVelocity: Codable { let container = try decoder.container(keyedBy: CodingKeys.self) self.startDate = try container.decode(Date.self, forKey: .startDate) self.endDate = try container.decode(Date.self, forKey: .endDate) - self.quantity = LoopQuantity( - unit: GlucoseEffectVelocity.perSecondUnit, - doubleValue: try container.decode(Double.self, forKey: .mgdlPerSecond) - ) + self.mgdlPerSecond = try container.decode(Double.self, forKey: .mgdlPerSecond) } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(startDate, forKey: .startDate) try container.encode(endDate, forKey: .endDate) - try container.encode(quantity.doubleValue(for: GlucoseEffectVelocity.perSecondUnit), forKey: .mgdlPerSecond) + try container.encode(mgdlPerSecond, forKey: .mgdlPerSecond) } private enum CodingKeys: String, CodingKey { diff --git a/Sources/LoopAlgorithm/Glucose/GlucoseMath.swift b/Sources/LoopAlgorithm/Glucose/GlucoseMath.swift index 444e977..9ffa815 100644 --- a/Sources/LoopAlgorithm/Glucose/GlucoseMath.swift +++ b/Sources/LoopAlgorithm/Glucose/GlucoseMath.swift @@ -112,7 +112,7 @@ extension BidirectionalCollection where Element: GlucoseSampleValue, Index == In /// - delta: The time differential for the returned values /// - velocityMaximum: The limit on how fast the momentum effect can be. Defaults to 4 mg/dL/min based on physiological rates, if nil passed. /// - Returns: An array of glucose effects - public func linearMomentumEffect( + func linearMomentumEffect( duration: TimeInterval = GlucoseMath.momentumDuration, delta: TimeInterval = GlucoseMath.defaultDelta, velocityMaximum: LoopQuantity? = nil diff --git a/Sources/LoopAlgorithm/Glucose/GlucoseSampleValue.swift b/Sources/LoopAlgorithm/Glucose/GlucoseSampleValue.swift index 3de0477..209482e 100644 --- a/Sources/LoopAlgorithm/Glucose/GlucoseSampleValue.swift +++ b/Sources/LoopAlgorithm/Glucose/GlucoseSampleValue.swift @@ -17,10 +17,4 @@ public protocol GlucoseSampleValue: GlucoseValue { /// Whether the glucose value is user entered, as opposed to a CGM value. var wasUserEntered: Bool { get } - - /// Any condition applied to the sample. - var condition: GlucoseCondition? { get } - - /// The trend rate of the sample. - var trendRate: LoopQuantity? { get } } diff --git a/Sources/LoopAlgorithm/Glucose/GlucoseValue.swift b/Sources/LoopAlgorithm/Glucose/GlucoseValue.swift index de74f1a..6db0e93 100644 --- a/Sources/LoopAlgorithm/Glucose/GlucoseValue.swift +++ b/Sources/LoopAlgorithm/Glucose/GlucoseValue.swift @@ -8,23 +8,35 @@ import Foundation public protocol GlucoseValue: SampleValue { + /// The glucose value in mg/dL + var mgdl: Double { get } +} + +extension GlucoseValue { + var quantity: LoopQuantity { + LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: mgdl) + } } public struct SimpleGlucoseValue: Equatable, GlucoseValue { public let startDate: Date public let endDate: Date - public let quantity: LoopQuantity + public let mgdl: Double - public init(startDate: Date, endDate: Date? = nil, quantity: LoopQuantity) { + public init(startDate: Date, endDate: Date? = nil, mgdl: Double) { self.startDate = startDate self.endDate = endDate ?? startDate - self.quantity = quantity + self.mgdl = mgdl + } + + init(startDate: Date, endDate: Date? = nil, quantity: LoopQuantity) { + self.init(startDate: startDate, endDate: endDate, mgdl: quantity.doubleValue(for: .milligramsPerDeciliter)) } public init(_ glucoseValue: GlucoseValue) { self.startDate = glucoseValue.startDate self.endDate = glucoseValue.endDate - self.quantity = glucoseValue.quantity + self.mgdl = glucoseValue.mgdl } } @@ -33,8 +45,8 @@ extension SimpleGlucoseValue: Codable { let container = try decoder.container(keyedBy: CodingKeys.self) self.startDate = try container.decode(Date.self, forKey: .startDate) self.endDate = try container.decodeIfPresent(Date.self, forKey: .endDate) ?? self.startDate - self.quantity = LoopQuantity(unit: LoopUnit(from: try container.decode(String.self, forKey: .quantityUnit)), - doubleValue: try container.decode(Double.self, forKey: .quantity)) + self.mgdl = LoopQuantity(unit: LoopUnit(from: try container.decode(String.self, forKey: .quantityUnit)), + doubleValue: try container.decode(Double.self, forKey: .quantity)).doubleValue(for: .milligramsPerDeciliter) } public func encode(to encoder: Encoder) throws { @@ -43,7 +55,7 @@ extension SimpleGlucoseValue: Codable { if endDate != startDate { try container.encode(endDate, forKey: .endDate) } - try container.encode(quantity.doubleValue(for: .milligramsPerDeciliter), forKey: .quantity) + try container.encode(mgdl, forKey: .quantity) try container.encode(LoopUnit.milligramsPerDeciliter.unitString, forKey: .quantityUnit) } @@ -57,11 +69,15 @@ extension SimpleGlucoseValue: Codable { public struct PredictedGlucoseValue: Equatable, GlucoseValue { public let startDate: Date - public let quantity: LoopQuantity + public let mgdl: Double - public init(startDate: Date, quantity: LoopQuantity) { + public init(startDate: Date, mgdl: Double) { self.startDate = startDate - self.quantity = quantity + self.mgdl = mgdl + } + + init(startDate: Date, quantity: LoopQuantity) { + self.init(startDate: startDate, mgdl: quantity.doubleValue(for: .milligramsPerDeciliter)) } } @@ -69,14 +85,14 @@ extension PredictedGlucoseValue: Codable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.startDate = try container.decode(Date.self, forKey: .startDate) - self.quantity = LoopQuantity(unit: LoopUnit(from: try container.decode(String.self, forKey: .quantityUnit)), - doubleValue: try container.decode(Double.self, forKey: .quantity)) + self.mgdl = LoopQuantity(unit: LoopUnit(from: try container.decode(String.self, forKey: .quantityUnit)), + doubleValue: try container.decode(Double.self, forKey: .quantity)).doubleValue(for: .milligramsPerDeciliter) } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(startDate, forKey: .startDate) - try container.encode(quantity.doubleValue(for: .milligramsPerDeciliter), forKey: .quantity) + try container.encode(mgdl, forKey: .quantity) try container.encode(LoopUnit.milligramsPerDeciliter.unitString, forKey: .quantityUnit) } diff --git a/Sources/LoopAlgorithm/Insulin/DoseMath.swift b/Sources/LoopAlgorithm/Insulin/DoseMath.swift index 010fad9..465673a 100644 --- a/Sources/LoopAlgorithm/Insulin/DoseMath.swift +++ b/Sources/LoopAlgorithm/Insulin/DoseMath.swift @@ -8,7 +8,7 @@ import Foundation -public enum InsulinCorrection { +enum InsulinCorrection { case inRange case aboveRange(min: GlucoseValue, correcting: GlucoseValue, minTarget: LoopQuantity, units: Double) case entirelyBelowRange(min: GlucoseValue, minTarget: LoopQuantity, units: Double) @@ -149,7 +149,35 @@ private func targetGlucoseValue(percentEffectDuration: Double, minValue: Double, return minValue + slope * (percentEffectDuration - useMinValueUntilPercent) } -public typealias GlucoseRangeTimeline = [AbsoluteScheduleValue>] +/// Glucose target ranges over time, in mg/dL +public typealias GlucoseRangeTimeline = [AbsoluteScheduleValue>] + +typealias QuantityRangeTimeline = [AbsoluteScheduleValue>] + +extension Array where Element == AbsoluteScheduleValue> { + var quantities: QuantityRangeTimeline { + map { AbsoluteScheduleValue(startDate: $0.startDate, endDate: $0.endDate, value: LoopQuantity.mgdl($0.value.lowerBound)...LoopQuantity.mgdl($0.value.upperBound)) } + } +} + +extension Array where Element == AbsoluteScheduleValue> { + var mgdl: GlucoseRangeTimeline { + map { AbsoluteScheduleValue(startDate: $0.startDate, endDate: $0.endDate, value: $0.value.lowerBound.doubleValue(for: .milligramsPerDeciliter)...$0.value.upperBound.doubleValue(for: .milligramsPerDeciliter)) } + } +} + +extension Array where Element == AbsoluteScheduleValue { + /// Treats the values as mg/dL (for insulin sensitivity, mg/dL per unit) + var mgdlQuantities: [AbsoluteScheduleValue] { + map { AbsoluteScheduleValue(startDate: $0.startDate, endDate: $0.endDate, value: LoopQuantity.mgdl($0.value)) } + } +} + +extension Array where Element == AbsoluteScheduleValue { + var mgdl: [AbsoluteScheduleValue] { + map { AbsoluteScheduleValue(startDate: $0.startDate, endDate: $0.endDate, value: $0.value.doubleValue(for: .milligramsPerDeciliter)) } + } +} extension Array where Element: GlucoseValue { @@ -164,7 +192,7 @@ extension Array where Element: GlucoseValue { /// - model: The insulin effect model /// - Returns: A correction value in units, or nil if no correction needed func insulinCorrection( - to correctionRange: GlucoseRangeTimeline, + to correctionRange: QuantityRangeTimeline, at date: Date, suspendThreshold: LoopQuantity, insulinSensitivity: [AbsoluteScheduleValue], diff --git a/Sources/LoopAlgorithm/Insulin/InsulinMath.swift b/Sources/LoopAlgorithm/Insulin/InsulinMath.swift index a21a046..1cd9fcf 100644 --- a/Sources/LoopAlgorithm/Insulin/InsulinMath.swift +++ b/Sources/LoopAlgorithm/Insulin/InsulinMath.swift @@ -224,7 +224,7 @@ extension Collection where Element: InsulinDose { /// /// Doses which cross time boundaries in the basal rate schedule are split into multiple entries. /// - /// - Parameter basalSchedule: A history of basal rates covering the timespan of these doses. + /// - Parameter basalHistory: A history of basal rates covering the timespan of these doses. /// - Parameter fillBasalGaps: If true, the returned array will interpolate doses from basal schedule for those parts of the /// timeline that this array does not cover. /// - Returns: An array of annotated dose entries @@ -286,7 +286,7 @@ extension Collection where Element: InsulinDose { /// /// Doses which cross time boundaries in the basal rate schedule are split into multiple entries. /// - /// - Parameter basalSchedule: A history of basal rates covering the timespan of these doses. + /// - Parameter basalHistory: A history of basal rates covering the timespan of these doses. /// - Returns: An array of annotated dose entries public func annotated(with basalHistory: [AbsoluteScheduleValue]) -> [BasalRelativeDose] { var annotatedDoses: [BasalRelativeDose] = [] @@ -314,6 +314,8 @@ extension Collection where Element == BasalRelativeDose { - parameter start: The date to start the timeline - parameter end: The date to end the timeline - parameter delta: The differential between timeline entries, Defaults to 5 minutes. + - parameter useLegacyIntegration: If true, integrate basal doses on the delta grid as deployed Loop did before + the continuous integration, which ripples by up to one delta. - returns: A sequence of insulin amount remaining */ @@ -347,6 +349,8 @@ extension Collection where Element == BasalRelativeDose { Calculates insulin remaining at a given point in time for a collection of doses - parameter date: The date at which to calculate remaining insulin. If nil, current date is used. + - parameter useLegacyIntegration: If true, integrate basal doses on the delta grid as deployed Loop did before + the continuous integration, which ripples by up to one delta. - returns: Insulin amount remaining at specified time */ @@ -368,7 +372,7 @@ extension Collection where Element == BasalRelativeDose { /// - end: The latest date of effects to return. If nil is passed, it will be calculated from the last sample end date plus the longestEffectDuration. /// - delta: The interval between returned effects /// - Returns: An array of glucose effects for the duration of the doses - public func glucoseEffects( + func glucoseEffects( insulinSensitivityHistory: [AbsoluteScheduleValue], from start: Date? = nil, to end: Date? = nil, @@ -416,7 +420,7 @@ extension Collection where Element == BasalRelativeDose { /// - end: The latest date of effects to return /// - delta: The interval between returned effects /// - Returns: An array of glucose effects for the duration of the doses - public func glucoseEffectsMidAbsorptionISF( + func glucoseEffectsMidAbsorptionISF( longestEffectDuration: TimeInterval = InsulinMath.defaultInsulinActivityDuration, insulinSensitivityHistory: [AbsoluteScheduleValue], from start: Date? = nil, @@ -508,7 +512,7 @@ extension Collection where Element == BasalRelativeDose { /// - effectDates: The dates at which to calculate glucose effects /// - delta: The interval below which to consider doses as momentary /// - Returns: An array of glucose effects for the duration of the doses - public func glucoseEffects( + func glucoseEffects( longestEffectDuration: TimeInterval = InsulinMath.defaultInsulinActivityDuration, insulinSensitivityTimeline: [AbsoluteScheduleValue], effectDates: [Date], @@ -543,3 +547,38 @@ extension Collection where Element == BasalRelativeDose { return values } } + +public extension Collection where Element == BasalRelativeDose { + /// Glucose effects of the doses. Sensitivity is in mg/dL per unit. + func glucoseEffects( + insulinSensitivityHistoryMgdlPerUnit: [AbsoluteScheduleValue], + from start: Date? = nil, + to end: Date? = nil, + delta: TimeInterval = TimeInterval(/* minutes: */60 * 5) + ) -> [GlucoseEffect] { + glucoseEffects(insulinSensitivityHistory: insulinSensitivityHistoryMgdlPerUnit.mgdlQuantities, from: start, to: end, delta: delta) + } + + /// Glucose effects of the doses using mid-absorption ISF. Sensitivity is in mg/dL per unit. + func glucoseEffectsMidAbsorptionISF( + longestEffectDuration: TimeInterval = InsulinMath.defaultInsulinActivityDuration, + insulinSensitivityHistoryMgdlPerUnit: [AbsoluteScheduleValue], + from start: Date? = nil, + to end: Date? = nil, + delta: TimeInterval = TimeInterval(/* minutes: */60 * 5) + ) -> [GlucoseEffect] { + glucoseEffectsMidAbsorptionISF(longestEffectDuration: longestEffectDuration, insulinSensitivityHistory: insulinSensitivityHistoryMgdlPerUnit.mgdlQuantities, + from: start, to: end, delta: delta) + } + + /// Glucose effects of the doses at the given dates. Sensitivity is in mg/dL per unit. + func glucoseEffects( + longestEffectDuration: TimeInterval = InsulinMath.defaultInsulinActivityDuration, + insulinSensitivityTimelineMgdlPerUnit: [AbsoluteScheduleValue], + effectDates: [Date], + delta: TimeInterval = TimeInterval(/* minutes: */60 * 5) + ) -> [GlucoseEffect] { + glucoseEffects(longestEffectDuration: longestEffectDuration, insulinSensitivityTimeline: insulinSensitivityTimelineMgdlPerUnit.mgdlQuantities, + effectDates: effectDates, delta: delta) + } +} diff --git a/Sources/LoopAlgorithm/Insulin/InsulinValue.swift b/Sources/LoopAlgorithm/Insulin/InsulinValue.swift index dd34f40..871ed02 100644 --- a/Sources/LoopAlgorithm/Insulin/InsulinValue.swift +++ b/Sources/LoopAlgorithm/Insulin/InsulinValue.swift @@ -17,7 +17,7 @@ public struct InsulinValue: TimelineValue, Equatable { self.value = value } - public var quantity: LoopQuantity { + var quantity: LoopQuantity { LoopQuantity(unit: .internationalUnit, doubleValue: value) } } diff --git a/Sources/LoopAlgorithm/Insulin/PrecomputedInsulinInput.swift b/Sources/LoopAlgorithm/Insulin/PrecomputedInsulinInput.swift index 23894cf..1c83ad1 100644 --- a/Sources/LoopAlgorithm/Insulin/PrecomputedInsulinInput.swift +++ b/Sources/LoopAlgorithm/Insulin/PrecomputedInsulinInput.swift @@ -20,9 +20,10 @@ // │ let base = PrecomputedInsulinInput.annotate(doses: doses, basal: basal)│ // │ for multiplier in [0.7, 0.8, 0.9, 1.0, 1.1, ...] { │ // │ let scaled = scaledSensitivity(sensitivity, by: multiplier) │ -// │ let input = base.withEffects(sensitivity: scaled) // O(D×T) once │ +// │ let input = base.withEffects(sensitivityMgdlPerUnit: scaled) │ +// │ // O(D×T) once │ // │ for t in sweepSteps { │ -// │ let result = LoopAlgorithm.generatePrediction( │ +// │ let result = Algorithm.generatePrediction( │ // │ start: t, ..., precomputedInsulin: input, ...) // no annotation │ // │ } │ // │ } │ @@ -58,10 +59,10 @@ private extension Array { /// /// // 2. For each ISF value: compute effects once, sweep all time steps /// for multiplier in isfMultipliers { -/// let input = base.withEffects(sensitivity: scale(sensitivity, by: multiplier), -/// from: sweepStart, to: sweepEnd + activityDuration) +/// let input = base.withEffects(sensitivityMgdlPerUnit: scale(sensitivity, by: multiplier), +/// from: sweepStart, to: sweepEnd + activityDuration) /// for t in sweepSteps { -/// let prediction = LoopAlgorithm.generatePrediction( +/// let prediction = Algorithm.generatePrediction( /// start: t, glucoseHistory: cgm[t], precomputedInsulin: input, ...) /// } /// } @@ -86,13 +87,13 @@ public struct PrecomputedInsulinInput { /// When non-nil, `generatePrediction` uses this directly instead of /// calling `glucoseEffects(insulinSensitivityHistory:from:to:)`. /// - /// **ISF sweeps:** rebuild this once per multiplier using `withEffects(sensitivity:)`. + /// **ISF sweeps:** rebuild this once per multiplier using `withEffects(sensitivityMgdlPerUnit:)`. /// The `annotatedDoses` array is unchanged and does not need to be rebuilt. /// /// **Timeline coverage:** must cover /// `[glucoseHistory.first.startDate, sweepEnd + defaultInsulinActivityDuration]` /// for all steps in the sweep. Pass a generous `to:` date when calling - /// `withEffects(sensitivity:from:to:)`. + /// `withEffects(sensitivityMgdlPerUnit:from:to:)`. public var insulinEffects: [GlucoseEffect]? // MARK: - Init @@ -117,7 +118,7 @@ extension PrecomputedInsulinInput { /// - doses: All insulin doses for the sweep window, sorted by startDate. /// - basal: Scheduled basal timeline covering the same window. /// - Returns: A `PrecomputedInsulinInput` with `insulinEffects == nil`. - /// Call `withEffects(sensitivity:from:to:)` before passing to + /// Call `withEffects(sensitivityMgdlPerUnit:from:to:)` before passing to /// `generatePrediction`. public static func annotate( doses: [DoseType], @@ -133,7 +134,7 @@ extension PrecomputedInsulinInput { /// pass the result into every `generatePrediction` call for that multiplier. /// /// - Parameters: - /// - sensitivity: The (possibly scaled) ISF timeline for this sweep config. + /// - sensitivityMgdlPerUnit: The (possibly scaled) ISF timeline for this sweep config, in mg/dL per unit. /// - from: Start of the effect timeline. Defaults to earliest dose start. /// Should be <= `glucoseHistory.first.startDate` for the first eval step. /// - to: End of the effect timeline. Should cover @@ -142,11 +143,12 @@ extension PrecomputedInsulinInput { /// - useMidAbsorptionISF: Use mid-absorption ISF computation. /// - Returns: A new `PrecomputedInsulinInput` with `insulinEffects` populated. public func withEffects( - sensitivity: [AbsoluteScheduleValue], + sensitivityMgdlPerUnit: [AbsoluteScheduleValue], from: Date? = nil, to: Date? = nil, useMidAbsorptionISF: Bool = false ) -> PrecomputedInsulinInput { + let sensitivity = sensitivityMgdlPerUnit.mgdlQuantities let effects: [GlucoseEffect] if useMidAbsorptionISF { effects = annotatedDoses.glucoseEffectsMidAbsorptionISF( @@ -193,20 +195,20 @@ extension PrecomputedInsulinInput { /// Convenience: annotate and compute effects in one call. /// /// Use when running a single config (no ISF sweep). For ISF sweeps, - /// prefer `annotate(doses:basal:)` + `withEffects(sensitivity:from:to:)` + /// prefer `annotate(doses:basal:)` + `withEffects(sensitivityMgdlPerUnit:from:to:)` /// so annotation cost is paid only once. public static func build( doses: [DoseType], basal: [AbsoluteScheduleValue], - sensitivity: [AbsoluteScheduleValue]? = nil, + sensitivityMgdlPerUnit: [AbsoluteScheduleValue]? = nil, effectsFrom: Date? = nil, effectsTo: Date? = nil, useMidAbsorptionISF: Bool = false ) -> PrecomputedInsulinInput { let base = annotate(doses: doses, basal: basal) - guard let sensitivity else { return base } + guard let sensitivityMgdlPerUnit else { return base } return base.withEffects( - sensitivity: sensitivity, + sensitivityMgdlPerUnit: sensitivityMgdlPerUnit, from: effectsFrom, to: effectsTo, useMidAbsorptionISF: useMidAbsorptionISF diff --git a/Sources/LoopAlgorithm/LoopAlgorithm.swift b/Sources/LoopAlgorithm/LoopAlgorithm.swift index ba338bf..8d84b1e 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithm.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithm.swift @@ -27,7 +27,8 @@ public struct LoopAlgorithmEffects { public var momentum: [GlucoseEffect] public var insulinCounteraction: [GlucoseEffectVelocity] public var retrospectiveGlucoseDiscrepancies: [GlucoseChange] - public var totalRetrospectiveCorrectionEffect: LoopQuantity? + /// In mg/dL + public var totalRetrospectiveCorrectionEffect: Double? public init( insulin: [GlucoseEffect], @@ -37,7 +38,7 @@ public struct LoopAlgorithmEffects { momentum: [GlucoseEffect], insulinCounteraction: [GlucoseEffectVelocity], retrospectiveGlucoseDiscrepancies: [GlucoseChange], - totalRetrospectiveCorrectionEffect: LoopQuantity? = nil + totalRetrospectiveCorrectionEffect: Double? = nil ) { self.insulin = insulin self.carbs = carbs @@ -61,12 +62,7 @@ extension LoopAlgorithmEffects: Codable { self.insulinCounteraction = try container.decode([GlucoseEffectVelocity].self, forKey: .insulinCounteraction) self.retrospectiveGlucoseDiscrepancies = try container.decode([GlucoseChange].self, forKey: .retrospectiveGlucoseDiscrepancies) - if let totalRetrospectiveCorrectionEffectValue = try container.decodeIfPresent(Double.self, forKey: .totalRetrospectiveCorrectionEffect) { - self.totalRetrospectiveCorrectionEffect = LoopQuantity( - unit: .milligramsPerDeciliter, - doubleValue: totalRetrospectiveCorrectionEffectValue - ) - } + self.totalRetrospectiveCorrectionEffect = try container.decodeIfPresent(Double.self, forKey: .totalRetrospectiveCorrectionEffect) } public func encode(to encoder: Encoder) throws { @@ -78,12 +74,7 @@ extension LoopAlgorithmEffects: Codable { try container.encode(momentum, forKey: .momentum) try container.encode(insulinCounteraction, forKey: .insulinCounteraction) try container.encode(retrospectiveGlucoseDiscrepancies, forKey: .retrospectiveGlucoseDiscrepancies) - if let totalRetrospectiveCorrectionEffect { - try container.encode( - totalRetrospectiveCorrectionEffect.doubleValue(for: .milligramsPerDeciliter), - forKey: .totalRetrospectiveCorrectionEffect - ) - } + try container.encodeIfPresent(totalRetrospectiveCorrectionEffect, forKey: .totalRetrospectiveCorrectionEffect) } private enum CodingKeys: String, CodingKey { @@ -122,7 +113,7 @@ public struct LoopPrediction { public var activeCarbs: Double? } -public struct LoopAlgorithm { +public struct Algorithm { /// Percentage of recommended dose to apply as bolus when using automatic bolus dosing strategy static public let defaultBolusPartialApplicationFactor = 0.4 @@ -151,26 +142,67 @@ public struct LoopAlgorithm { /// Generates a forecast predicting glucose. /// Outputs may be incomplete, if there are issues with the provided data, but as many intermediate derived fields as can be computed, will be computed. /// - /// Returns nil if the normal scheduled basal, or active temporary basal, is sufficient. - /// - /// /// - Parameters: /// - start: The starting time of the glucose prediction. /// - glucoseHistory: History of glucose values: t-10h to t. Must include at least one value. /// - doses: History of insulin doses: t-16h to t /// - carbEntries: History of carb entries: t-10h to t /// - basal: Scheduled basal rate timeline: t-16h to t - /// - sensitivity: Insulin sensitivity timeline: t-16h to t (eventually with mid-absorption isf changes, it will be t-10h to t) - /// - carbRatio: Carb ratio timeline: t-10h to t+6h - /// - target: Correction range timeline. Used only for the integral-correction clamp when `emulation.integralRCClamp` is enabled; may be empty otherwise. + /// - sensitivityMgdlPerUnit: Insulin sensitivity timeline in mg/dL per unit: t-16h to t + /// - carbRatio: Carb ratio timeline in grams per unit: t-10h to t+6h + /// - targetMgdl: Correction range timeline in mg/dL. Used only for the integral-correction clamp when `emulation.integralRCClamp` is enabled; may be empty otherwise. /// - algorithmEffectsOptions: Which effects to include when combining effects to generate glucose prediction /// - useIntegralRetrospectiveCorrection: If true, the prediction will use Integral Retrospection. If false, will use traditional Retrospective Correction /// - includingPositiveVelocityAndRC: If false, only net negative momentum and RC effects will used. + /// - useMidAbsorptionISF: If true, each dose's effect follows the sensitivity timeline over its absorption instead of using the sensitivity at delivery. /// - carbAbsorptionModel: A model conforming to CarbAbsorptionComputable that is used for computing carb absorption over time. + /// - gradualTransitionsThreshold: Largest jump in mg/dL between consecutive glucose readings for retrospective correction to apply. + /// - momentumVelocityMaximumMgdlPerMinute: Limit on the momentum effect, in mg/dL per minute /// - emulation: Options for reproducing earlier deployed algorithm behavior; the default emulates nothing. + /// - experiments: Algorithm experiments to apply; the empty set is standard LoopAlgorithm. /// - Returns: A LoopPrediction struct containing the predicted glucose and the computed intermediate effects used to make the prediction - + /// public static func generatePrediction( + start: Date, + glucoseHistory: [GlucoseType], + doses: [InsulinDoseType], + carbEntries: [CarbType], + basal: [AbsoluteScheduleValue], + sensitivityMgdlPerUnit: [AbsoluteScheduleValue], + carbRatio: [AbsoluteScheduleValue], + targetMgdl: GlucoseRangeTimeline = [], + algorithmEffectsOptions: AlgorithmEffectsOptions = .all, + useIntegralRetrospectiveCorrection: Bool = false, + includingPositiveVelocityAndRC: Bool = true, + useMidAbsorptionISF: Bool = false, + carbAbsorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption(), + gradualTransitionsThreshold: Double? = 40.0, + momentumVelocityMaximumMgdlPerMinute: Double? = nil, + emulation: AlgorithmEmulationOptions = AlgorithmEmulationOptions(), + experiments: Set = [] + ) -> LoopPrediction where CarbType: CarbEntry, GlucoseType: GlucoseSampleValue, InsulinDoseType: InsulinDose { + generatePrediction( + start: start, + glucoseHistory: glucoseHistory, + doses: doses, + carbEntries: carbEntries, + basal: basal, + sensitivity: sensitivityMgdlPerUnit.mgdlQuantities, + carbRatio: carbRatio, + target: targetMgdl.quantities, + algorithmEffectsOptions: algorithmEffectsOptions, + useIntegralRetrospectiveCorrection: useIntegralRetrospectiveCorrection, + includingPositiveVelocityAndRC: includingPositiveVelocityAndRC, + useMidAbsorptionISF: useMidAbsorptionISF, + carbAbsorptionModel: carbAbsorptionModel, + gradualTransitionsThreshold: gradualTransitionsThreshold, + momentumVelocityMaximum: momentumVelocityMaximumMgdlPerMinute.map { LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: $0) }, + emulation: emulation, + experiments: experiments + ) + } + + static func generatePrediction( start: Date, glucoseHistory: [GlucoseType], doses: [InsulinDoseType], @@ -178,7 +210,7 @@ public struct LoopAlgorithm { basal: [AbsoluteScheduleValue], sensitivity: [AbsoluteScheduleValue], carbRatio: [AbsoluteScheduleValue], - target: GlucoseRangeTimeline = [], + target: QuantityRangeTimeline = [], algorithmEffectsOptions: AlgorithmEffectsOptions = .all, useIntegralRetrospectiveCorrection: Bool = false, includingPositiveVelocityAndRC: Bool = true, @@ -379,7 +411,7 @@ public struct LoopAlgorithm { momentum: momentumEffects, insulinCounteraction: insulinCounteractionEffects, retrospectiveGlucoseDiscrepancies: retrospectiveGlucoseDiscrepanciesSummed, - totalRetrospectiveCorrectionEffect: totalRetrospectiveCorrectionEffect + totalRetrospectiveCorrectionEffect: totalRetrospectiveCorrectionEffect?.doubleValue(for: .milligramsPerDeciliter) ), dosesRelativeToBasal: dosesRelativeToBasal, activeInsulin: activeInsulin, @@ -413,8 +445,10 @@ public struct LoopAlgorithm { /// must slice `annotatedDoses` to `[t - insulinLookback, t]` (or /// `[t - lookback, t + 6h]` for future-insulin mode). /// - carbEntries: History of carb entries. - /// - sensitivity: ISF timeline — still required for carb + RC effects. - /// - carbRatio: Carb ratio timeline. + /// - sensitivityMgdlPerUnit: ISF timeline in mg/dL per unit — still required for carb + RC effects. + /// - carbRatio: Carb ratio timeline in grams per unit. + /// - targetMgdl: Correction range timeline in mg/dL. Used only for the integral-correction clamp when `emulation.integralRCClamp` is enabled; may be empty otherwise. + /// - scheduledBasalRate: Scheduled basal rate in U/h at `start`, for the integral-correction clamp. /// - algorithmEffectsOptions: Which effects to include. /// - useIntegralRetrospectiveCorrection: Use integral RC. /// - includingPositiveVelocityAndRC: Include positive velocity/RC. @@ -422,16 +456,56 @@ public struct LoopAlgorithm { /// `precomputedInsulin.insulinEffects` is non-nil). /// - carbAbsorptionModel: Carb absorption model. /// - gradualTransitionsThreshold: RC smoothness gate (default 40 mg/dL). + /// - momentumVelocityMaximumMgdlPerMinute: Limit on the momentum effect, in mg/dL per minute. + /// - emulation: Options for reproducing earlier deployed algorithm behavior; the default emulates nothing. /// - Returns: A `LoopPrediction` struct. `dosesRelativeToBasal` is /// populated from `precomputedInsulin.annotatedDoses`. public static func generatePrediction( + start: Date, + glucoseHistory: [GlucoseType], + precomputedInsulin: PrecomputedInsulinInput, + carbEntries: [CarbType], + sensitivityMgdlPerUnit: [AbsoluteScheduleValue], + carbRatio: [AbsoluteScheduleValue], + targetMgdl: GlucoseRangeTimeline = [], + scheduledBasalRate: Double? = nil, + algorithmEffectsOptions: AlgorithmEffectsOptions = .all, + useIntegralRetrospectiveCorrection: Bool = false, + includingPositiveVelocityAndRC: Bool = true, + useMidAbsorptionISF: Bool = false, + carbAbsorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption(), + gradualTransitionsThreshold: Double? = 40.0, + momentumVelocityMaximumMgdlPerMinute: Double? = nil, + emulation: AlgorithmEmulationOptions = AlgorithmEmulationOptions() + ) -> LoopPrediction where CarbType: CarbEntry, GlucoseType: GlucoseSampleValue { + generatePrediction( + start: start, + glucoseHistory: glucoseHistory, + precomputedInsulin: precomputedInsulin, + carbEntries: carbEntries, + sensitivity: sensitivityMgdlPerUnit.mgdlQuantities, + carbRatio: carbRatio, + target: targetMgdl.quantities, + scheduledBasalRate: scheduledBasalRate, + algorithmEffectsOptions: algorithmEffectsOptions, + useIntegralRetrospectiveCorrection: useIntegralRetrospectiveCorrection, + includingPositiveVelocityAndRC: includingPositiveVelocityAndRC, + useMidAbsorptionISF: useMidAbsorptionISF, + carbAbsorptionModel: carbAbsorptionModel, + gradualTransitionsThreshold: gradualTransitionsThreshold, + momentumVelocityMaximum: momentumVelocityMaximumMgdlPerMinute.map { LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: $0) }, + emulation: emulation + ) + } + + static func generatePrediction( start: Date, glucoseHistory: [GlucoseType], precomputedInsulin: PrecomputedInsulinInput, carbEntries: [CarbType], sensitivity: [AbsoluteScheduleValue], carbRatio: [AbsoluteScheduleValue], - target: GlucoseRangeTimeline = [], + target: QuantityRangeTimeline = [], scheduledBasalRate: Double? = nil, algorithmEffectsOptions: AlgorithmEffectsOptions = .all, useIntegralRetrospectiveCorrection: Bool = false, @@ -592,7 +666,7 @@ public struct LoopAlgorithm { momentum: momentumEffects, insulinCounteraction: insulinCounteractionEffects, retrospectiveGlucoseDiscrepancies: retrospectiveGlucoseDiscrepanciesSummed, - totalRetrospectiveCorrectionEffect: totalRetrospectiveCorrectionEffect + totalRetrospectiveCorrectionEffect: totalRetrospectiveCorrectionEffect?.doubleValue(for: .milligramsPerDeciliter) ), dosesRelativeToBasal: dosesRelativeToBasal, activeInsulin: activeInsulin, @@ -609,9 +683,9 @@ public struct LoopAlgorithm { doses: input.doses, carbEntries: input.carbEntries, basal: input.basal, - sensitivity: input.sensitivity, + sensitivity: input.sensitivityQuantities, carbRatio: input.carbRatio, - target: input.target, + target: input.targetQuantities, algorithmEffectsOptions: input.algorithmEffectsOptions, useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection, carbAbsorptionModel: input.carbAbsorptionModel.model, @@ -624,7 +698,7 @@ public struct LoopAlgorithm { /// current glucose and the lower correction target in effect now), else the input's constant. static func automaticBolusApplicationFactor(input: any AlgorithmInput, currentGlucose: some GlucoseSampleValue) -> Double { if input.experiments.contains(.glucoseBasedApplicationFactor), - let correctionRange = input.target.closestPrior(to: input.predictionStart)?.value + let correctionRange = input.targetQuantities.closestPrior(to: input.predictionStart)?.value { return GlucoseBasedApplicationFactor.factor( glucose: currentGlucose.quantity.doubleValue(for: .milligramsPerDeciliter), @@ -634,10 +708,10 @@ public struct LoopAlgorithm { } // Computes an amount of insulin to correct the given prediction - public static func insulinCorrection( + static func insulinCorrection( prediction: [PredictedGlucoseValue], at deliveryDate: Date, - target: GlucoseRangeTimeline, + target: QuantityRangeTimeline, suspendThreshold: LoopQuantity, sensitivity: [AbsoluteScheduleValue], insulinModel: InsulinModel @@ -651,7 +725,7 @@ public struct LoopAlgorithm { } // Computes a 30 minute temp basal dose to correct the given prediction - public static func recommendTempBasal( + static func recommendTempBasal( for correction: InsulinCorrection, neutralBasalRate: Double, activeInsulin: Double, @@ -683,7 +757,7 @@ public struct LoopAlgorithm { } // Computes a bolus or low-temp basal dose to correct the given prediction - public static func recommendAutomaticDose( + static func recommendAutomaticDose( for correction: InsulinCorrection, applicationFactor: Double, neutralBasalRate: Double, @@ -719,11 +793,11 @@ public struct LoopAlgorithm { } // Computes a manual bolus to correct the given prediction - public static func recommendManualBolus( + static func recommendManualBolus( for correction: InsulinCorrection, maxBolus: Double, currentGlucose: GlucoseSampleValue, - target: GlucoseRangeTimeline + target: QuantityRangeTimeline ) -> ManualBolusRecommendation { var bolus = correction.asManualBolus(maxBolus: maxBolus) @@ -736,8 +810,19 @@ public struct LoopAlgorithm { return bolus } + /// Runs the algorithm: forecasts glucose and recommends a dose. + /// + /// Inputs are plain numbers in fixed units: glucose and targets in mg/dL, sensitivity in mg/dL per unit, + /// carbs in grams, carb ratio in grams per unit, insulin in units and rates in U/h. See `AlgorithmInput`. + /// + /// - Parameter input: The glucose, dose and carb history, therapy settings and options. + /// - Returns: The forecast, the effects behind it, and the recommendation or the error that prevented one. public static func run(input: LoopAlgorithmInputType) -> AlgorithmOutput { + let scheduledSensitivity = input.sensitivityQuantities + let correctionTargets = input.targetQuantities + let suspendThresholdSetting = input.suspendThresholdQuantity + var prediction = LoopPrediction( glucose: [], effects: LoopAlgorithmEffects( @@ -786,10 +871,10 @@ public struct LoopAlgorithm { glucoseHistoryStart: glucoseStart, recommendationEffectInterval: recommendationEffectInterval ) - guard let sensitivityStartDate = input.sensitivity.first?.startDate, sensitivityStartDate <= neededISFInterval.start else { + guard let sensitivityStartDate = scheduledSensitivity.first?.startDate, sensitivityStartDate <= neededISFInterval.start else { throw AlgorithmError.sensitivityTimelineStartsTooLate } - guard let sensitivityEndDate = input.sensitivity.last?.endDate, sensitivityEndDate >= neededISFInterval.end else { + guard let sensitivityEndDate = scheduledSensitivity.last?.endDate, sensitivityEndDate >= neededISFInterval.end else { throw AlgorithmError.sensitivityTimelineEndsTooEarly } @@ -797,7 +882,7 @@ public struct LoopAlgorithm { if let oldestCarbEntry = input.carbEntries.map({ $0.startDate }).min() { guard let carbRatioStart = input.carbRatio.first?.startDate, carbRatioStart <= oldestCarbEntry, - let sensitivityStart = input.sensitivity.first?.startDate, sensitivityStart <= oldestCarbEntry + let sensitivityStart = input.sensitivityMgdlPerUnit.first?.startDate, sensitivityStart <= oldestCarbEntry else { throw AlgorithmError.carbRatioOrSensitivityStartsAfterCarbEntry } @@ -808,7 +893,7 @@ public struct LoopAlgorithm { throw AlgorithmError.basalTimelineIncomplete } - guard let suspendThreshold = input.suspendThreshold ?? input.target.closestPrior(to: input.predictionStart)?.value.lowerBound else { + guard let suspendThreshold = suspendThresholdSetting ?? correctionTargets.closestPrior(to: input.predictionStart)?.value.lowerBound else { throw AlgorithmError.missingSuspendThreshold } @@ -821,7 +906,7 @@ public struct LoopAlgorithm { basal: input.basal, sensitivity: sensitivity, carbRatio: input.carbRatio, - target: input.target, + target: correctionTargets, algorithmEffectsOptions: .all, useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection, includingPositiveVelocityAndRC: input.includePositiveVelocityAndRC, @@ -832,7 +917,7 @@ public struct LoopAlgorithm { experiments: input.experiments ) } - prediction = predict(sensitivity: input.sensitivity) + prediction = predict(sensitivity: scheduledSensitivity) // Experiment adjustments before dosing (no-ops when `experiments` is empty). let experimentParameters = ExperimentParameters( @@ -842,13 +927,13 @@ public struct LoopAlgorithm { // Slow retrospective correction: the level comes from the forecast at the scheduled ISF; the run // then uses ISF × multiplier throughout. - var sensitivity = input.sensitivity + var sensitivity = scheduledSensitivity if let slow = experimentParameters.slowRetrospectiveCorrection { let windowStart = input.predictionStart.addingTimeInterval(-slow.window) let carbEffects = prediction.effects.carbStatus.dynamicGlucoseEffects( from: windowStart.addingTimeInterval(-GlucoseMath.defaultDelta), carbRatios: input.carbRatio, - insulinSensitivities: input.sensitivity, + insulinSensitivities: scheduledSensitivity, absorptionModel: input.carbAbsorptionModel.model) let level = slowRetrospectiveCorrectionLevel( counteraction: prediction.effects.insulinCounteraction, carbEffects: carbEffects, @@ -856,7 +941,7 @@ public struct LoopAlgorithm { let multiplier = slowRetrospectiveCorrectionMultiplier(level: level, parameters: slow) slowRetrospectiveCorrection = SlowRetrospectiveCorrectionOutput(level: level, multiplier: multiplier) if multiplier != 1 { - sensitivity = sensitivityScaled(input.sensitivity, by: multiplier) + sensitivity = sensitivityScaled(scheduledSensitivity, by: multiplier) prediction = predict(sensitivity: sensitivity) } } @@ -866,7 +951,7 @@ public struct LoopAlgorithm { var runwayShift = 0.0 if let reserve = experimentParameters.runwayReserve, let volatility, let sensitivity = sensitivity.closestPrior(to: input.predictionStart)?.value, - let target = input.target.closestPrior(to: input.predictionStart)?.value + let target = correctionTargets.closestPrior(to: input.predictionStart)?.value { let unit = LoopUnit.milligramsPerDeciliter runwayShift = runwayAimShift( @@ -914,7 +999,7 @@ public struct LoopAlgorithm { // The runway shift lowers the aim of automatic dosing only; the manual bolus recommendation // keeps the user's target. let correctionTarget = runwayShift > 0 && input.recommendationType != .manualBolus - ? targetLowered(input.target, by: runwayShift) : input.target + ? targetLowered(correctionTargets, by: runwayShift) : correctionTargets var correction = insulinCorrection( prediction: prediction.glucose, at: input.predictionStart, @@ -940,7 +1025,7 @@ public struct LoopAlgorithm { for: correction, maxBolus: input.maxBolus, currentGlucose: latestGlucose, - target: input.target) + target: correctionTargets) result = .success(.init(manual: recommendation)) case .automaticBolus: let recommendation = recommendAutomaticDose( diff --git a/Sources/LoopAlgorithm/LoopMath.swift b/Sources/LoopAlgorithm/LoopMath.swift index cc09a30..cad7442 100644 --- a/Sources/LoopAlgorithm/LoopMath.swift +++ b/Sources/LoopAlgorithm/LoopMath.swift @@ -184,7 +184,7 @@ extension GlucoseValue { - returns: An array of glucose effects */ - public func decayEffect(atRate rate: LoopQuantity, for duration: TimeInterval, withDelta delta: TimeInterval = 5 * 60, useLegacyDecay: Bool = false) -> [GlucoseEffect] { + func decayEffect(atRate rate: LoopQuantity, for duration: TimeInterval, withDelta delta: TimeInterval = 5 * 60, useLegacyDecay: Bool = false) -> [GlucoseEffect] { guard let (startDate, endDate) = LoopMath.simulationDateRangeForSamples([self], duration: duration, delta: delta) else { return [] } diff --git a/Sources/LoopAlgorithm/LoopPredictionInput.swift b/Sources/LoopAlgorithm/LoopPredictionInput.swift index 9ac314c..579a7ce 100644 --- a/Sources/LoopAlgorithm/LoopPredictionInput.swift +++ b/Sources/LoopAlgorithm/LoopPredictionInput.swift @@ -21,15 +21,16 @@ public struct LoopPredictionInput] - // Expected time range coverage: t-16h to t (eventually with mid-absorption isf changes, it will be t-10h to t) - public var sensitivity: [AbsoluteScheduleValue] + // Expected time range coverage: t-16h to t + // Insulin sensitivity in mg/dL per unit + public var sensitivityMgdlPerUnit: [AbsoluteScheduleValue] // Expected time range coverage: t-10h to t+6h public var carbRatio: [AbsoluteScheduleValue] // Correction-range timeline. Used only for the IntegralRC integral-correction // clamp when `emulation.integralRCClamp` is enabled; may be empty otherwise. - public var target: GlucoseRangeTimeline = [] + public var targetMgdl: GlucoseRangeTimeline = [] public var algorithmEffectsOptions: AlgorithmEffectsOptions @@ -48,9 +49,9 @@ public struct LoopPredictionInput], - sensitivity: [AbsoluteScheduleValue], + sensitivityMgdlPerUnit: [AbsoluteScheduleValue], carbRatio: [AbsoluteScheduleValue], - target: GlucoseRangeTimeline = [], + targetMgdl: GlucoseRangeTimeline = [], algorithmEffectsOptions: AlgorithmEffectsOptions, useIntegralRetrospectiveCorrection: Bool, includePositiveVelocityAndRC: Bool, @@ -63,9 +64,9 @@ public struct LoopPredictionInput] { sensitivityMgdlPerUnit.mgdlQuantities } + var targetQuantities: QuantityRangeTimeline { targetMgdl.quantities } +} + extension LoopPredictionInput: Codable where CarbType == FixtureCarbEntry, GlucoseType == FixtureGlucoseSample, InsulinDoseType == FixtureInsulinDose { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -84,17 +90,14 @@ extension LoopPredictionInput: Codable where CarbType == FixtureCarbEntry, Gluco self.doses = try container.decode([FixtureInsulinDose].self, forKey: .doses) self.carbEntries = try container.decode([FixtureCarbEntry].self, forKey: .carbEntries) self.basal = try container.decode([AbsoluteScheduleValue].self, forKey: .basal) - let sensitivityMgdl = try container.decode([AbsoluteScheduleValue].self, forKey: .sensitivity) - self.sensitivity = sensitivityMgdl.map { AbsoluteScheduleValue(startDate: $0.startDate, endDate: $0.endDate, value: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: $0.value))} + self.sensitivityMgdlPerUnit = try container.decode([AbsoluteScheduleValue].self, forKey: .sensitivity) self.carbRatio = try container.decode([AbsoluteScheduleValue].self, forKey: .carbRatio) - if let targetMgdl = try container.decodeIfPresent([TargetEntry].self, forKey: .target) { - self.target = targetMgdl.map { - let lower = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: $0.lowerBound) - let upper = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: $0.upperBound) - return AbsoluteScheduleValue(startDate: $0.startDate, endDate: $0.endDate, value: lower...upper) + if let targetEntries = try container.decodeIfPresent([TargetEntry].self, forKey: .target) { + self.targetMgdl = targetEntries.map { + AbsoluteScheduleValue(startDate: $0.startDate, endDate: $0.endDate, value: $0.lowerBound...$0.upperBound) } } else { - self.target = [] + self.targetMgdl = [] } if let algorithmEffectsOptionsRaw = try container.decodeIfPresent(AlgorithmEffectsOptions.RawValue.self, forKey: .algorithmEffectsOptions) { self.algorithmEffectsOptions = AlgorithmEffectsOptions(rawValue: algorithmEffectsOptionsRaw) @@ -114,18 +117,17 @@ extension LoopPredictionInput: Codable where CarbType == FixtureCarbEntry, Gluco try container.encode(doses, forKey: .doses) try container.encode(carbEntries, forKey: .carbEntries) try container.encode(basal, forKey: .basal) - let sensitivityMgdl = sensitivity.map { AbsoluteScheduleValue(startDate: $0.startDate, endDate: $0.endDate, value: $0.value.doubleValue(for: .milligramsPerDeciliter)) } - try container.encode(sensitivityMgdl, forKey: .sensitivity) + try container.encode(sensitivityMgdlPerUnit, forKey: .sensitivity) try container.encode(carbRatio, forKey: .carbRatio) - if !target.isEmpty { - let targetMgdl = target.map { + if !targetMgdl.isEmpty { + let targetEntries = targetMgdl.map { TargetEntry( startDate: $0.startDate, endDate: $0.endDate, - lowerBound: $0.value.lowerBound.doubleValue(for: .milligramsPerDeciliter), - upperBound: $0.value.upperBound.doubleValue(for: .milligramsPerDeciliter)) + lowerBound: $0.value.lowerBound, + upperBound: $0.value.upperBound) } - try container.encode(targetMgdl, forKey: .target) + try container.encode(targetEntries, forKey: .target) } if algorithmEffectsOptions != .all { try container.encode(algorithmEffectsOptions.rawValue, forKey: .algorithmEffectsOptions) diff --git a/Sources/LoopAlgorithm/LoopQuantity.swift b/Sources/LoopAlgorithm/LoopQuantity.swift index 34a063b..504854e 100644 --- a/Sources/LoopAlgorithm/LoopQuantity.swift +++ b/Sources/LoopAlgorithm/LoopQuantity.swift @@ -7,17 +7,21 @@ import Foundation -public struct LoopQuantity: Hashable, Equatable, Comparable, Sendable { +struct LoopQuantity: Hashable, Equatable, Comparable, Sendable { - public let unit: LoopUnit + let unit: LoopUnit private let value: Double - public init(unit: LoopUnit, doubleValue value: Double) { + init(unit: LoopUnit, doubleValue value: Double) { self.unit = unit self.value = value } + + static func mgdl(_ value: Double) -> LoopQuantity { + LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: value) + } - public func `is`(compatibleWith unit: LoopUnit) -> Bool { + func `is`(compatibleWith unit: LoopUnit) -> Bool { self.unit.conversionFactor(toUnit: unit) != nil } @@ -26,7 +30,7 @@ public struct LoopQuantity: Hashable, Equatable, Comparable, Sendable { @abstract Returns the quantity value converted to the given unit. @discussion Throws an exception if the receiver's value cannot be converted to one of the requested unit. */ - public func doubleValue(for unit: LoopUnit) -> Double { + func doubleValue(for unit: LoopUnit) -> Double { guard let conversionFactor = self.unit.conversionFactor(toUnit: unit) else { fatalError("Conversion Error: \(self.unit.unitString) is not compatible with \(unit.unitString).") } @@ -44,7 +48,7 @@ public struct LoopQuantity: Hashable, Equatable, Comparable, Sendable { less than a given quantity. @discussion Throws an exception if the unit of the given quantity is not compatible with the receiver's unit. */ - public func compare(_ quantity: LoopQuantity) -> ComparisonResult { + func compare(_ quantity: LoopQuantity) -> ComparisonResult { if value == quantity.doubleValue(for: unit) { return .orderedSame } else if value > quantity.doubleValue(for: unit) { @@ -54,11 +58,11 @@ public struct LoopQuantity: Hashable, Equatable, Comparable, Sendable { } } - public static func <(lhs: LoopQuantity, rhs: LoopQuantity) -> Bool { + static func <(lhs: LoopQuantity, rhs: LoopQuantity) -> Bool { return lhs.compare(rhs) == .orderedAscending } - public static func == (lhs: LoopQuantity, rhs: LoopQuantity) -> Bool { + static func == (lhs: LoopQuantity, rhs: LoopQuantity) -> Bool { guard lhs.unit != rhs.unit else { return lhs.value == rhs.value } diff --git a/Sources/LoopAlgorithm/LoopUnit.swift b/Sources/LoopAlgorithm/LoopUnit.swift index 370334b..6a8c44a 100644 --- a/Sources/LoopAlgorithm/LoopUnit.swift +++ b/Sources/LoopAlgorithm/LoopUnit.swift @@ -10,7 +10,7 @@ import Foundation let UnitMolarMassBloodGlucose = 180.1558800000541 let UnitMolarMassBloodGlucoseDivisible = UnitMolarMassBloodGlucose / 10 -public enum LoopUnit: Sendable, CaseIterable { +enum LoopUnit: Sendable, CaseIterable { case gram case gramsPerUnit case internationalUnit @@ -28,11 +28,11 @@ public enum LoopUnit: Sendable, CaseIterable { case minute case second - public init(from string: String) { + init(from string: String) { self = LoopUnit.allCases.first(where: { $0.unitString == string }) ?? .gram } - public func conversionFactor(toUnit: LoopUnit) -> Double? { + func conversionFactor(toUnit: LoopUnit) -> Double? { switch (self, toUnit) { case (.gram, .gram), (.gramsPerUnit, .gramsPerUnit), @@ -108,7 +108,7 @@ public enum LoopUnit: Sendable, CaseIterable { } } - public var unitString: String { + var unitString: String { switch self { case .gram: return "g" @@ -145,7 +145,7 @@ public enum LoopUnit: Sendable, CaseIterable { } } - public var localizedShortUnitString: String { + var localizedShortUnitString: String { switch self { case .millimolesPerLiter: return NSLocalizedString("mmol/L", comment: "The short unit display string for millimoles of glucose per liter") case .milligramsPerDeciliter: return NSLocalizedString("mg/dL", comment: "The short unit display string for milligrams of glucose per decilter") @@ -155,7 +155,7 @@ public enum LoopUnit: Sendable, CaseIterable { } } - public func unitDivided(by unit: LoopUnit) -> LoopUnit { + func unitDivided(by unit: LoopUnit) -> LoopUnit { switch self { case .milligramsPerDeciliter: switch unit { diff --git a/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift b/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift index 3e7b95f..2122505 100644 --- a/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift +++ b/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift @@ -14,14 +14,14 @@ import Foundation In the above summary, "discrepancy" is a difference between the actual glucose and the model predicted glucose over retrospective correction grouping interval (set to 30 min in LoopSettings), whereas "past discrepancies" refers to a timeline of discrepancies computed over retrospective correction integration interval (set to 180 min in Loop Settings). */ -public class IntegralRetrospectiveCorrection: RetrospectiveCorrection { - public static let retrospectionInterval = TimeInterval(minutes: 180) +class IntegralRetrospectiveCorrection: RetrospectiveCorrection { + static let retrospectionInterval = TimeInterval(minutes: 180) /// RetrospectiveCorrection protocol variables /// Standard effect duration let effectDuration: TimeInterval /// Overall retrospective correction effect - public var totalGlucoseCorrectionEffect: LoopQuantity? + var totalGlucoseCorrectionEffect: LoopQuantity? /** Integral retrospective correction parameters: @@ -52,11 +52,11 @@ public class IntegralRetrospectiveCorrection: RetrospectiveCorrection { /// unmodeled glucose velocity (~4 mg/dL/min); unlike a bound scaled by ISF/basal/target, /// it does not depend on the user's dosing settings — the plausible velocity of unmodeled /// physiology is the same regardless of a person's insulin needs or target range. - public static let defaultMaxCorrectionVelocity = LoopQuantity( + static let defaultMaxCorrectionVelocity = LoopQuantity( unit: .milligramsPerDeciliterPerSecond, doubleValue: 4.0 / 60.0) /// Ceiling applied to the correction rate (see `defaultMaxCorrectionVelocity`); nil disables it. - public let maxCorrectionVelocity: LoopQuantity? + let maxCorrectionVelocity: LoopQuantity? /// Emulate pre-#33 (deployed Loop) step-by-step decay for the RC effect timeline. let useLegacyDecay: Bool @@ -78,7 +78,7 @@ public class IntegralRetrospectiveCorrection: RetrospectiveCorrection { /// the post-low rise-cut (see `ExperimentOption`). 1.0 is standard IRC. let riseScale: Double - public init(effectDuration: TimeInterval, + init(effectDuration: TimeInterval, maxCorrectionVelocity: LoopQuantity? = IntegralRetrospectiveCorrection.defaultMaxCorrectionVelocity, useLegacyDecay: Bool = false, riseScale: Double = 1.0) { @@ -98,7 +98,7 @@ public class IntegralRetrospectiveCorrection: RetrospectiveCorrection { - Returns: - totalRetrospectiveCorrection: Overall glucose effect */ - public func computeEffect( + func computeEffect( startingAt startingGlucose: GlucoseValue, retrospectiveGlucoseDiscrepanciesSummed: [GlucoseChange]?, recencyInterval: TimeInterval, diff --git a/Sources/LoopAlgorithm/RetrospectiveCorrection/RetrospectiveCorrection.swift b/Sources/LoopAlgorithm/RetrospectiveCorrection/RetrospectiveCorrection.swift index d13f560..7cd481f 100644 --- a/Sources/LoopAlgorithm/RetrospectiveCorrection/RetrospectiveCorrection.swift +++ b/Sources/LoopAlgorithm/RetrospectiveCorrection/RetrospectiveCorrection.swift @@ -12,15 +12,15 @@ import Foundation /// LoopKit's IntegralRetrospectiveCorrection applies: the wound-up integral term /// is bounded by an ISF×basal-scaled, correction-range-relative window. All three /// values are required for the bound to be well defined, so they travel together. -public struct IntegralRCClampSettings { +struct IntegralRCClampSettings { /// Insulin sensitivity at decision time - public var insulinSensitivity: LoopQuantity + var insulinSensitivity: LoopQuantity /// Scheduled basal rate at decision time, in U/hr - public var basalRate: Double + var basalRate: Double /// Correction range at decision time - public var correctionRange: ClosedRange + var correctionRange: ClosedRange - public init(insulinSensitivity: LoopQuantity, basalRate: Double, correctionRange: ClosedRange) { + init(insulinSensitivity: LoopQuantity, basalRate: Double, correctionRange: ClosedRange) { self.insulinSensitivity = insulinSensitivity self.basalRate = basalRate self.correctionRange = correctionRange @@ -28,7 +28,7 @@ public struct IntegralRCClampSettings { } /// Derives a continued glucose effect from recent prediction discrepancies -public protocol RetrospectiveCorrection { +protocol RetrospectiveCorrection { /// Overall retrospective correction effect var totalGlucoseCorrectionEffect: LoopQuantity? { get } diff --git a/Sources/LoopAlgorithm/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift b/Sources/LoopAlgorithm/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift index 6d9220f..7178b4f 100644 --- a/Sources/LoopAlgorithm/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift +++ b/Sources/LoopAlgorithm/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift @@ -13,14 +13,14 @@ import Foundation In the above summary, "discrepancy" is a difference between the actual glucose and the model predicted glucose over retrospective correction grouping interval (set to 30 min in LoopSettings) */ -public class StandardRetrospectiveCorrection: RetrospectiveCorrection { +class StandardRetrospectiveCorrection: RetrospectiveCorrection { /// RetrospectiveCorrection protocol variables /// Standard effect duration let effectDuration: TimeInterval /// Emulate pre-#33 (deployed Loop) step-by-step decay for the RC effect timeline. let useLegacyDecay: Bool /// Overall retrospective correction effect - public var totalGlucoseCorrectionEffect: LoopQuantity? + var totalGlucoseCorrectionEffect: LoopQuantity? /// All math is performed with glucose expressed in mg/dL private let unit = LoopUnit.milligramsPerDeciliter @@ -29,13 +29,13 @@ public class StandardRetrospectiveCorrection: RetrospectiveCorrection { /// `ExperimentOption`). 1.0 is standard RC. let riseScale: Double - public init(effectDuration: TimeInterval, useLegacyDecay: Bool = false, riseScale: Double = 1.0) { + init(effectDuration: TimeInterval, useLegacyDecay: Bool = false, riseScale: Double = 1.0) { self.effectDuration = effectDuration self.useLegacyDecay = useLegacyDecay self.riseScale = riseScale } - public func computeEffect( + func computeEffect( startingAt startingGlucose: GlucoseValue, retrospectiveGlucoseDiscrepanciesSummed: [GlucoseChange]?, recencyInterval: TimeInterval, diff --git a/Sources/LoopAlgorithm/SampleValue.swift b/Sources/LoopAlgorithm/SampleValue.swift index 28929e2..1085ecd 100644 --- a/Sources/LoopAlgorithm/SampleValue.swift +++ b/Sources/LoopAlgorithm/SampleValue.swift @@ -21,8 +21,9 @@ public extension TimelineValue { } +/// A timeline value with an amount. Each refinement names its amount in a fixed unit, such as +/// `GlucoseValue.mgdl` or `CarbEntry.grams`. public protocol SampleValue: TimelineValue { - var quantity: LoopQuantity { get } } @@ -89,8 +90,7 @@ public extension Sequence where Element: TimelineValue { This behavior mimics HKQueryOptionNone, where the value must merely overlap the specified range, not strictly exist inside of it. - - parameter startDate: The earliest date of elements to return - - parameter endDate: The latest date of elements to return + - parameter interval: The date interval elements must overlap - returns: A new array of elements */ diff --git a/Sources/LoopAlgorithmRunner/main.swift b/Sources/LoopAlgorithmRunner/main.swift index 7eff454..03db067 100644 --- a/Sources/LoopAlgorithmRunner/main.swift +++ b/Sources/LoopAlgorithmRunner/main.swift @@ -28,7 +28,7 @@ func main() { do { let input = try readInputFile(inputFilePath) - let output = LoopAlgorithm.run(input: input) + let output = Algorithm.run(input: input) let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] diff --git a/Tests/LoopAlgorithmTests/AlgorithmEmulationTests.swift b/Tests/LoopAlgorithmTests/AlgorithmEmulationTests.swift index d77fdfc..3d0df0f 100644 --- a/Tests/LoopAlgorithmTests/AlgorithmEmulationTests.swift +++ b/Tests/LoopAlgorithmTests/AlgorithmEmulationTests.swift @@ -187,8 +187,8 @@ final class AlgorithmEmulationTests: XCTestCase { // a recommendation, and their forecasts should differ. var plain = fixture plain.emulation = nil - let emulated = LoopAlgorithm.run(input: fixture) - let current = LoopAlgorithm.run(input: plain) + let emulated = Algorithm.run(input: fixture) + let current = Algorithm.run(input: plain) XCTAssertNotNil(try? emulated.recommendationResult.get()) XCTAssertNotNil(try? current.recommendationResult.get()) } diff --git a/Tests/LoopAlgorithmTests/AlgorithmExperimentsTests.swift b/Tests/LoopAlgorithmTests/AlgorithmExperimentsTests.swift index b5bc2bc..4b22c82 100644 --- a/Tests/LoopAlgorithmTests/AlgorithmExperimentsTests.swift +++ b/Tests/LoopAlgorithmTests/AlgorithmExperimentsTests.swift @@ -46,10 +46,10 @@ final class AlgorithmExperimentsTests: XCTestCase { var input = AlgorithmInputFixture.mock(for: now) input.recommendationType = .automaticBolus input.carbEntries = [FixtureCarbEntry(absorptionTime: .hours(3), startDate: now.addingTimeInterval(.minutes(-30)), quantity: .carbs(30))] - let standard = LoopAlgorithm.run(input: input) + let standard = Algorithm.run(input: input) input.experiments = [.guardAndRunway] input.volatilityBaseline = 5 - let gated = LoopAlgorithm.run(input: input) + let gated = Algorithm.run(input: input) assertSameOutput(standard, gated) XCTAssertNil(standard.guardAndRunway) let output = gated.guardAndRunway! @@ -79,7 +79,7 @@ final class AlgorithmExperimentsTests: XCTestCase { let dense = flatForecast(200, from: now) let sparse = Array(dense.prefix(12)) + [dense.last!] func reserve(_ p: [PredictedGlucoseValue], _ volatility: Double) -> Double { - LoopAlgorithm.runwayAimShift(prediction: p, reserve: ExperimentParameters.RunwayReserve(), currentGlucose: 200, + Algorithm.runwayAimShift(prediction: p, reserve: ExperimentParameters.RunwayReserve(), currentGlucose: 200, activeCarbs: 0, volatility: volatility, sensitivity: 50, basalRate: 1.0, targetMidpoint: 105, suspendThreshold: 70, insulinModel: ExponentialInsulinModelPreset.rapidActingAdult) @@ -137,9 +137,9 @@ final class AlgorithmExperimentsTests: XCTestCase { let now = ISO8601DateFormatter().date(from: "2024-01-03T12:00:00+0000")! var input = AlgorithmInputFixture.mock(for: now) input.glucoseHistory = glucoseSeries([62, 66, 75, 90, 110, 130], endingAt: now.addingTimeInterval(.minutes(-1))) - let standard = LoopAlgorithm.run(input: input) + let standard = Algorithm.run(input: input) input.experiments = [.guardAndRunway] - let cut = LoopAlgorithm.run(input: input) + let cut = Algorithm.run(input: input) XCTAssertLessThan(cut.predictedGlucose.last!.quantity.doubleValue(for: unit), standard.predictedGlucose.last!.quantity.doubleValue(for: unit)) XCTAssertTrue(cut.guardAndRunway!.postLowRiseCutActive) @@ -180,7 +180,7 @@ final class AlgorithmExperimentsTests: XCTestCase { let now = Date() let glucose = (0...48).map { PredictedGlucoseValue(startDate: now.addingTimeInterval(.minutes(5 * Double($0))), quantity: .glucose(120)) } let band = ExperimentParameters.VolatilityBand(gain: 1.0) - let banded = LoopAlgorithm.volatilityBanded(glucose, band: band, volatility: 4.0) + let banded = Algorithm.volatilityBanded(glucose, band: band, volatility: 4.0) let offset: (Int) -> Double = { banded[$0].quantity.doubleValue(for: self.unit) - 120 } XCTAssertEqual(offset(0), 0, accuracy: 1e-9) XCTAssertEqual(offset(12), -4.0 * pow(12.0, 0.71), accuracy: 1e-9) // 60 min: full depth @@ -197,7 +197,7 @@ final class AlgorithmExperimentsTests: XCTestCase { private func shift(_ forecast: Double, current: Double? = nil, carbs: Double = 0, volatility: Double) -> Double { let now = Date() - return LoopAlgorithm.runwayAimShift( + return Algorithm.runwayAimShift( prediction: flatForecast(forecast, from: now), reserve: ExperimentParameters.RunwayReserve(), currentGlucose: current ?? forecast, activeCarbs: carbs, volatility: volatility, sensitivity: 50, basalRate: 1.0, targetMidpoint: 105, suspendThreshold: 70, @@ -211,7 +211,7 @@ final class AlgorithmExperimentsTests: XCTestCase { XCTAssertGreaterThan(shift(200, volatility: 2), 0) let now = Date() let short = Array(flatForecast(200, from: now).prefix(30)) // 145 min, short of 180 - XCTAssertEqual(LoopAlgorithm.runwayAimShift( + XCTAssertEqual(Algorithm.runwayAimShift( prediction: short, reserve: ExperimentParameters.RunwayReserve(), currentGlucose: 200, activeCarbs: 0, volatility: 2, sensitivity: 50, basalRate: 1.0, targetMidpoint: 105, suspendThreshold: 70, insulinModel: ExponentialInsulinModelPreset.rapidActingAdult), 0, "forecast shorter than the horizon") @@ -235,9 +235,9 @@ final class AlgorithmExperimentsTests: XCTestCase { for type in [DoseRecommendationType.tempBasal, .automaticBolus] { input.recommendationType = type input.experiments = [] - let standard = LoopAlgorithm.run(input: input) + let standard = Algorithm.run(input: input) input.experiments = [.guardAndRunway] - let runway = LoopAlgorithm.run(input: input) + let runway = Algorithm.run(input: input) XCTAssertEqual(runway.predictedGlucose, standard.predictedGlucose, "the forecast shown is not banded") let output = runway.guardAndRunway! let banded = output.guarded(runway.predictedGlucose).map { $0.quantity.doubleValue(for: unit) } @@ -260,9 +260,9 @@ final class AlgorithmExperimentsTests: XCTestCase { // The runway does not add to the manual recommendation; the band can only lower it. input.recommendationType = .manualBolus input.experiments = [] - let standard = LoopAlgorithm.run(input: input) + let standard = Algorithm.run(input: input) input.experiments = [.guardAndRunway] - let manual = LoopAlgorithm.run(input: input) + let manual = Algorithm.run(input: input) XCTAssertLessThanOrEqual(manual.recommendation?.manual?.amount ?? 0, standard.recommendation?.manual?.amount ?? 0) } @@ -280,14 +280,14 @@ final class AlgorithmExperimentsTests: XCTestCase { } let point = InsulinCorrection.aboveRange(min: g(120), correcting: g(200), minTarget: .glucose(100), units: 2) // A banded point below the suspend threshold suspends. - XCTAssertEqual(d(LoopAlgorithm.guarded(point, by: .suspend(min: g(65)))), "suspend 65") + XCTAssertEqual(d(Algorithm.guarded(point, by: .suspend(min: g(65)))), "suspend 65") // Otherwise the banded minimum is the one the guard checks; the units stay the forecast's. - XCTAssertEqual(d(LoopAlgorithm.guarded(point, by: .aboveRange(min: g(90), correcting: g(170), minTarget: .glucose(100), units: 1))), + XCTAssertEqual(d(Algorithm.guarded(point, by: .aboveRange(min: g(90), correcting: g(170), minTarget: .glucose(100), units: 1))), "above 90 200 2.0") - XCTAssertEqual(d(LoopAlgorithm.guarded(point, by: .entirelyBelowRange(min: g(85), minTarget: .glucose(100), units: -1))), + XCTAssertEqual(d(Algorithm.guarded(point, by: .entirelyBelowRange(min: g(85), minTarget: .glucose(100), units: -1))), "above 85 200 2.0") // A forecast that needs no correction is left as it is. - XCTAssertEqual(d(LoopAlgorithm.guarded(.inRange, by: .entirelyBelowRange(min: g(85), minTarget: .glucose(100), units: -1))), "in range") + XCTAssertEqual(d(Algorithm.guarded(.inRange, by: .entirelyBelowRange(min: g(85), minTarget: .glucose(100), units: -1))), "in range") } // MARK: - Slow retrospective correction @@ -304,7 +304,7 @@ final class AlgorithmExperimentsTests: XCTestCase { func testSlowRCLevelIsTheWindowedMeanResidual() { let now = Date(), p = ExperimentParameters.SlowRetrospectiveCorrection() // A constant 2 mg/dL per 5 min for 100 h, no carbs: only the last 72 h count, from zero at their start. - let level = LoopAlgorithm.slowRetrospectiveCorrectionLevel( + let level = Algorithm.slowRetrospectiveCorrectionLevel( counteraction: counteraction(2, hours: 100, endingAt: now), carbEffects: [], now: now, parameters: p) XCTAssertEqual(level, 2 * (1 - exp(-3.0)), accuracy: 0.01) // Carb effect is subtracted: a carb curve rising 2 mg/dL per 5 min explains all of it. @@ -313,17 +313,17 @@ final class AlgorithmExperimentsTests: XCTestCase { let date = now.addingTimeInterval(-Double(steps - i) * 300) return GlucoseEffect(startDate: date, quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: 2 * Double(i))) } - let explained = LoopAlgorithm.slowRetrospectiveCorrectionLevel( + let explained = Algorithm.slowRetrospectiveCorrectionLevel( counteraction: counteraction(2, hours: 100, endingAt: now), carbEffects: carbs, now: now, parameters: p) XCTAssertEqual(explained, 0, accuracy: 1e-9) } func testSlowRCMultiplierClamps() { let p = ExperimentParameters.SlowRetrospectiveCorrection() - XCTAssertEqual(LoopAlgorithm.slowRetrospectiveCorrectionMultiplier(level: 0, parameters: p), 1) - XCTAssertEqual(LoopAlgorithm.slowRetrospectiveCorrectionMultiplier(level: 2, parameters: p), 0.9, accuracy: 1e-12) - XCTAssertEqual(LoopAlgorithm.slowRetrospectiveCorrectionMultiplier(level: 20, parameters: p), 0.7) - XCTAssertEqual(LoopAlgorithm.slowRetrospectiveCorrectionMultiplier(level: -20, parameters: p), 1.3) + XCTAssertEqual(Algorithm.slowRetrospectiveCorrectionMultiplier(level: 0, parameters: p), 1) + XCTAssertEqual(Algorithm.slowRetrospectiveCorrectionMultiplier(level: 2, parameters: p), 0.9, accuracy: 1e-12) + XCTAssertEqual(Algorithm.slowRetrospectiveCorrectionMultiplier(level: 20, parameters: p), 0.7) + XCTAssertEqual(Algorithm.slowRetrospectiveCorrectionMultiplier(level: -20, parameters: p), 1.3) } func testSlowRCDosesMoreWhenGlucoseKeepsRisingUnexplained() { @@ -335,12 +335,12 @@ final class AlgorithmExperimentsTests: XCTestCase { input.glucoseHistory = glucoseSeries(values, endingAt: now.addingTimeInterval(.minutes(-1))) input.doses = [] input.carbEntries = [] - input.sensitivity = [AbsoluteScheduleValue(startDate: now.addingTimeInterval(.hours(-96)), endDate: now.addingTimeInterval(.hours(12)), value: .glucose(50))] + input.sensitivityMgdlPerUnit = [AbsoluteScheduleValue(startDate: now.addingTimeInterval(.hours(-96)), endDate: now.addingTimeInterval(.hours(12)), value: 50)] input.basal = [AbsoluteScheduleValue(startDate: now.addingTimeInterval(.hours(-96)), endDate: now.addingTimeInterval(.hours(12)), value: 1.0)] input.carbRatio = [AbsoluteScheduleValue(startDate: now.addingTimeInterval(.hours(-96)), endDate: now.addingTimeInterval(.hours(12)), value: 10)] - let standard = LoopAlgorithm.run(input: input) + let standard = Algorithm.run(input: input) input.experiments = [.slowRetrospectiveCorrection] - let slow = LoopAlgorithm.run(input: input) + let slow = Algorithm.run(input: input) XCTAssertNil(standard.slowRetrospectiveCorrection) let output = slow.slowRetrospectiveCorrection! XCTAssertGreaterThan(output.level, 0) @@ -350,12 +350,12 @@ final class AlgorithmExperimentsTests: XCTestCase { func testTargetLoweredFloors() { let now = Date() - let target: GlucoseRangeTimeline = [AbsoluteScheduleValue(startDate: now, endDate: now.addingTimeInterval(.hours(1)), + let target: QuantityRangeTimeline = [AbsoluteScheduleValue(startDate: now, endDate: now.addingTimeInterval(.hours(1)), value: ClosedRange(uncheckedBounds: (lower: .glucose(100), upper: .glucose(110))))] - let lowered = LoopAlgorithm.targetLowered(target, by: 25).first!.value + let lowered = Algorithm.targetLowered(target, by: 25).first!.value XCTAssertEqual(lowered.lowerBound.doubleValue(for: unit), 75) XCTAssertEqual(lowered.upperBound.doubleValue(for: unit), 85) - let floored = LoopAlgorithm.targetLowered(target, by: 60).first!.value + let floored = Algorithm.targetLowered(target, by: 60).first!.value XCTAssertEqual(floored.lowerBound.doubleValue(for: unit), 70) XCTAssertEqual(floored.upperBound.doubleValue(for: unit), 80) } @@ -394,9 +394,9 @@ final class AlgorithmExperimentsTests: XCTestCase { flag.useIntegralRetrospectiveCorrection = true var experiment = loadScenario(name) experiment.experiments = [.integralRetrospectiveCorrection] - assertSameOutput(LoopAlgorithm.run(input: flag), LoopAlgorithm.run(input: experiment)) + assertSameOutput(Algorithm.run(input: flag), Algorithm.run(input: experiment)) flag.experiments = [.integralRetrospectiveCorrection] // both: still integral RC, once - assertSameOutput(LoopAlgorithm.run(input: flag), LoopAlgorithm.run(input: experiment)) + assertSameOutput(Algorithm.run(input: flag), Algorithm.run(input: experiment)) } } @@ -423,16 +423,16 @@ final class AlgorithmExperimentsTests: XCTestCase { constant.automaticBolusApplicationFactor = 0.5 input.automaticBolusApplicationFactor = 0.4 input.experiments = [.glucoseBasedApplicationFactor] - let gbaf = LoopAlgorithm.run(input: input) - assertSameOutput(gbaf, LoopAlgorithm.run(input: constant)) + let gbaf = Algorithm.run(input: input) + assertSameOutput(gbaf, Algorithm.run(input: constant)) input.experiments = [] - XCTAssertGreaterThan(gbaf.recommendation!.automatic!.bolusUnits!, LoopAlgorithm.run(input: input).recommendation!.automatic!.bolusUnits!) + XCTAssertGreaterThan(gbaf.recommendation!.automatic!.bolusUnits!, Algorithm.run(input: input).recommendation!.automatic!.bolusUnits!) for type in [DoseRecommendationType.tempBasal, .manualBolus] { input.recommendationType = type input.experiments = [] - let standard = LoopAlgorithm.run(input: input) + let standard = Algorithm.run(input: input) input.experiments = [.glucoseBasedApplicationFactor] - assertSameOutput(standard, LoopAlgorithm.run(input: input)) + assertSameOutput(standard, Algorithm.run(input: input)) } } } diff --git a/Tests/LoopAlgorithmTests/CorrectionDosingTests.swift b/Tests/LoopAlgorithmTests/CorrectionDosingTests.swift index a1f8c3a..ace7cd4 100644 --- a/Tests/LoopAlgorithmTests/CorrectionDosingTests.swift +++ b/Tests/LoopAlgorithmTests/CorrectionDosingTests.swift @@ -18,7 +18,7 @@ class CorrectionDosingTests: XCTestCase { let suspendThreshold: LoopQuantity = .glucose(55) let maxBasalRate = 3.0 - var target: GlucoseRangeTimeline! + var target: QuantityRangeTimeline! var sensitivity: [AbsoluteScheduleValue]! let basalRate = 1.0 let insulinModel = ExponentialInsulinModelPreset.rapidActingAdult @@ -43,7 +43,7 @@ class CorrectionDosingTests: XCTestCase { func testNoChange() { - let correction = LoopAlgorithm.insulinCorrection( + let correction = Algorithm.insulinCorrection( prediction: PredictedGlucoseMocks.noChangePrediction(), at: testDate, target: target, @@ -52,7 +52,7 @@ class CorrectionDosingTests: XCTestCase { insulinModel: insulinModel ) - let recommendation = LoopAlgorithm.recommendTempBasal( + let recommendation = Algorithm.recommendTempBasal( for: correction, neutralBasalRate: basalRate, activeInsulin: 0, @@ -64,7 +64,7 @@ class CorrectionDosingTests: XCTestCase { XCTAssertEqual(recommendation.unitsPerHour, basalRate) XCTAssertEqual(recommendation.duration, .minutes(30)) - let automaticDose = LoopAlgorithm.recommendAutomaticDose( + let automaticDose = Algorithm.recommendAutomaticDose( for: correction, applicationFactor: 0.5, neutralBasalRate: basalRate, @@ -77,7 +77,7 @@ class CorrectionDosingTests: XCTestCase { XCTAssertEqual(automaticDose.bolusUnits, 0) XCTAssertEqual(automaticDose.basalAdjustment.unitsPerHour, basalRate) - let manualDose = LoopAlgorithm.recommendManualBolus( + let manualDose = Algorithm.recommendManualBolus( for: correction, maxBolus: 6, currentGlucose: FixtureGlucoseSample(startDate: PredictedGlucoseMocks.testDate, quantity: .glucose(120)), @@ -89,7 +89,7 @@ class CorrectionDosingTests: XCTestCase { } func testStartHighEndInRange() { - let correction = LoopAlgorithm.insulinCorrection( + let correction = Algorithm.insulinCorrection( prediction: PredictedGlucoseMocks.startHighEndInRangePrediction(), at: testDate, target: target, @@ -98,7 +98,7 @@ class CorrectionDosingTests: XCTestCase { insulinModel: insulinModel ) - let recommendation = LoopAlgorithm.recommendTempBasal( + let recommendation = Algorithm.recommendTempBasal( for: correction, neutralBasalRate: basalRate, activeInsulin: 0, @@ -110,7 +110,7 @@ class CorrectionDosingTests: XCTestCase { XCTAssertEqual(recommendation.unitsPerHour, basalRate) XCTAssertEqual(recommendation.duration, .minutes(30)) - let automaticDose = LoopAlgorithm.recommendAutomaticDose( + let automaticDose = Algorithm.recommendAutomaticDose( for: correction, applicationFactor: 0.5, neutralBasalRate: basalRate, @@ -123,7 +123,7 @@ class CorrectionDosingTests: XCTestCase { XCTAssertEqual(automaticDose.bolusUnits, 0) XCTAssertEqual(automaticDose.basalAdjustment.unitsPerHour, basalRate) - let manualDose = LoopAlgorithm.recommendManualBolus( + let manualDose = Algorithm.recommendManualBolus( for: correction, maxBolus: 6, currentGlucose: FixtureGlucoseSample(startDate: PredictedGlucoseMocks.testDate, quantity: .glucose(120)), @@ -135,7 +135,7 @@ class CorrectionDosingTests: XCTestCase { } func testStartLowEndInRange() { - let correction = LoopAlgorithm.insulinCorrection( + let correction = Algorithm.insulinCorrection( prediction: PredictedGlucoseMocks.startLowEndInRangePrediction(), at: testDate, target: target, @@ -144,7 +144,7 @@ class CorrectionDosingTests: XCTestCase { insulinModel: insulinModel ) - let recommendation = LoopAlgorithm.recommendTempBasal( + let recommendation = Algorithm.recommendTempBasal( for: correction, neutralBasalRate: basalRate, activeInsulin: 0, @@ -156,7 +156,7 @@ class CorrectionDosingTests: XCTestCase { XCTAssertEqual(recommendation.unitsPerHour, 1) XCTAssertEqual(recommendation.duration, .minutes(30)) - let automaticDose = LoopAlgorithm.recommendAutomaticDose( + let automaticDose = Algorithm.recommendAutomaticDose( for: correction, applicationFactor: 0.4, neutralBasalRate: basalRate, @@ -170,7 +170,7 @@ class CorrectionDosingTests: XCTestCase { XCTAssertEqual(automaticDose.basalAdjustment.unitsPerHour, basalRate) XCTAssertEqual(automaticDose.basalAdjustment.duration, .minutes(30)) - let manualDose = LoopAlgorithm.recommendManualBolus( + let manualDose = Algorithm.recommendManualBolus( for: correction, maxBolus: 6, currentGlucose: FixtureGlucoseSample(startDate: PredictedGlucoseMocks.testDate, quantity: .glucose(120)), @@ -182,7 +182,7 @@ class CorrectionDosingTests: XCTestCase { } func testCorrectLowAtMin() { - let correction = LoopAlgorithm.insulinCorrection( + let correction = Algorithm.insulinCorrection( prediction: PredictedGlucoseMocks.correctLowAtMin(), at: testDate, target: target, @@ -191,7 +191,7 @@ class CorrectionDosingTests: XCTestCase { insulinModel: insulinModel ) - let recommendation = LoopAlgorithm.recommendTempBasal( + let recommendation = Algorithm.recommendTempBasal( for: correction, neutralBasalRate: basalRate, activeInsulin: 0, @@ -203,7 +203,7 @@ class CorrectionDosingTests: XCTestCase { XCTAssertEqual(recommendation.unitsPerHour, 1) XCTAssertEqual(recommendation.duration, .minutes(30)) - let automaticDose = LoopAlgorithm.recommendAutomaticDose( + let automaticDose = Algorithm.recommendAutomaticDose( for: correction, applicationFactor: 0.4, neutralBasalRate: basalRate, @@ -217,7 +217,7 @@ class CorrectionDosingTests: XCTestCase { XCTAssertEqual(automaticDose.basalAdjustment.unitsPerHour, basalRate) XCTAssertEqual(automaticDose.basalAdjustment.duration, .minutes(30)) - let manualDose = LoopAlgorithm.recommendManualBolus( + let manualDose = Algorithm.recommendManualBolus( for: correction, maxBolus: 6, currentGlucose: FixtureGlucoseSample(startDate: PredictedGlucoseMocks.testDate, quantity: .glucose(120)), @@ -230,7 +230,7 @@ class CorrectionDosingTests: XCTestCase { func testStartHighEndLow() { - let correction = LoopAlgorithm.insulinCorrection( + let correction = Algorithm.insulinCorrection( prediction: PredictedGlucoseMocks.startHighEndLow(), at: testDate, target: target, @@ -239,7 +239,7 @@ class CorrectionDosingTests: XCTestCase { insulinModel: insulinModel ) - let recommendation = LoopAlgorithm.recommendTempBasal( + let recommendation = Algorithm.recommendTempBasal( for: correction, neutralBasalRate: basalRate, activeInsulin: 0, @@ -251,7 +251,7 @@ class CorrectionDosingTests: XCTestCase { XCTAssertEqual(recommendation.unitsPerHour, 0) XCTAssertEqual(recommendation.duration, .minutes(30)) - let automaticDose = LoopAlgorithm.recommendAutomaticDose( + let automaticDose = Algorithm.recommendAutomaticDose( for: correction, applicationFactor: 0.4, neutralBasalRate: basalRate, @@ -265,7 +265,7 @@ class CorrectionDosingTests: XCTestCase { XCTAssertEqual(automaticDose.basalAdjustment.unitsPerHour, 0) XCTAssertEqual(automaticDose.basalAdjustment.duration, .minutes(30)) - let manualDose = LoopAlgorithm.recommendManualBolus( + let manualDose = Algorithm.recommendManualBolus( for: correction, maxBolus: 6, currentGlucose: FixtureGlucoseSample(startDate: PredictedGlucoseMocks.testDate, quantity: .glucose(120)), @@ -281,7 +281,7 @@ class CorrectionDosingTests: XCTestCase { } func testStartLowEndHigh() { - let correction = LoopAlgorithm.insulinCorrection( + let correction = Algorithm.insulinCorrection( prediction: PredictedGlucoseMocks.startLowEndHigh(), at: testDate, target: target, @@ -290,7 +290,7 @@ class CorrectionDosingTests: XCTestCase { insulinModel: insulinModel ) - let recommendation = LoopAlgorithm.recommendTempBasal( + let recommendation = Algorithm.recommendTempBasal( for: correction, neutralBasalRate: basalRate, activeInsulin: 0, @@ -302,7 +302,7 @@ class CorrectionDosingTests: XCTestCase { XCTAssertEqual(recommendation.unitsPerHour, 1.0) XCTAssertEqual(recommendation.duration, .minutes(30)) - let automaticDose = LoopAlgorithm.recommendAutomaticDose( + let automaticDose = Algorithm.recommendAutomaticDose( for: correction, applicationFactor: 0.4, neutralBasalRate: basalRate, @@ -316,7 +316,7 @@ class CorrectionDosingTests: XCTestCase { XCTAssertEqual(automaticDose.basalAdjustment.unitsPerHour, basalRate) XCTAssertEqual(automaticDose.basalAdjustment.duration, .minutes(30)) - let manualDose = LoopAlgorithm.recommendManualBolus( + let manualDose = Algorithm.recommendManualBolus( for: correction, maxBolus: 6, currentGlucose: FixtureGlucoseSample(startDate: PredictedGlucoseMocks.testDate, quantity: .glucose(120)), @@ -332,7 +332,7 @@ class CorrectionDosingTests: XCTestCase { } func testFlatAndHigh() { - let correction = LoopAlgorithm.insulinCorrection( + let correction = Algorithm.insulinCorrection( prediction: PredictedGlucoseMocks.flatAndHigh(), at: testDate, target: target, @@ -341,7 +341,7 @@ class CorrectionDosingTests: XCTestCase { insulinModel: insulinModel ) - let recommendation = LoopAlgorithm.recommendTempBasal( + let recommendation = Algorithm.recommendTempBasal( for: correction, neutralBasalRate: basalRate, activeInsulin: 0, @@ -353,7 +353,7 @@ class CorrectionDosingTests: XCTestCase { XCTAssertEqual(recommendation.unitsPerHour, 3.0) XCTAssertEqual(recommendation.duration, .minutes(30)) - let automaticDose = LoopAlgorithm.recommendAutomaticDose( + let automaticDose = Algorithm.recommendAutomaticDose( for: correction, applicationFactor: 0.4, neutralBasalRate: basalRate, @@ -367,7 +367,7 @@ class CorrectionDosingTests: XCTestCase { XCTAssertEqual(automaticDose.basalAdjustment.unitsPerHour, basalRate) XCTAssertEqual(automaticDose.basalAdjustment.duration, .minutes(30)) - let manualDose = LoopAlgorithm.recommendManualBolus( + let manualDose = Algorithm.recommendManualBolus( for: correction, maxBolus: 6, currentGlucose: FixtureGlucoseSample(startDate: PredictedGlucoseMocks.testDate, quantity: .glucose(120)), @@ -380,7 +380,7 @@ class CorrectionDosingTests: XCTestCase { func testHighAndFalling() { - let correction = LoopAlgorithm.insulinCorrection( + let correction = Algorithm.insulinCorrection( prediction: PredictedGlucoseMocks.highAndFalling(), at: testDate, target: target, @@ -389,7 +389,7 @@ class CorrectionDosingTests: XCTestCase { insulinModel: insulinModel ) - let recommendation = LoopAlgorithm.recommendTempBasal( + let recommendation = Algorithm.recommendTempBasal( for: correction, neutralBasalRate: basalRate, activeInsulin: 0, @@ -401,7 +401,7 @@ class CorrectionDosingTests: XCTestCase { XCTAssertEqual(recommendation.unitsPerHour, 1.63, accuracy: 0.05) XCTAssertEqual(recommendation.duration, .minutes(30)) - let automaticDose = LoopAlgorithm.recommendAutomaticDose( + let automaticDose = Algorithm.recommendAutomaticDose( for: correction, applicationFactor: 0.4, neutralBasalRate: basalRate, @@ -415,7 +415,7 @@ class CorrectionDosingTests: XCTestCase { XCTAssertEqual(automaticDose.basalAdjustment.unitsPerHour, basalRate) XCTAssertEqual(automaticDose.basalAdjustment.duration, .minutes(30)) - let manualDose = LoopAlgorithm.recommendManualBolus( + let manualDose = Algorithm.recommendManualBolus( for: correction, maxBolus: 6, currentGlucose: FixtureGlucoseSample(startDate: PredictedGlucoseMocks.testDate, quantity: .glucose(120)), @@ -427,7 +427,7 @@ class CorrectionDosingTests: XCTestCase { } func testInRangeAndRising() { - let correction = LoopAlgorithm.insulinCorrection( + let correction = Algorithm.insulinCorrection( prediction: PredictedGlucoseMocks.inRangeAndRising(), at: testDate, target: target, @@ -436,7 +436,7 @@ class CorrectionDosingTests: XCTestCase { insulinModel: insulinModel ) - let recommendation = LoopAlgorithm.recommendTempBasal( + let recommendation = Algorithm.recommendTempBasal( for: correction, neutralBasalRate: basalRate, activeInsulin: 0, @@ -448,7 +448,7 @@ class CorrectionDosingTests: XCTestCase { XCTAssertEqual(recommendation.unitsPerHour, 1.63, accuracy: 0.05) XCTAssertEqual(recommendation.duration, .minutes(30)) - let automaticDose = LoopAlgorithm.recommendAutomaticDose( + let automaticDose = Algorithm.recommendAutomaticDose( for: correction, applicationFactor: 0.4, neutralBasalRate: basalRate, @@ -462,7 +462,7 @@ class CorrectionDosingTests: XCTestCase { XCTAssertEqual(automaticDose.basalAdjustment.unitsPerHour, basalRate) XCTAssertEqual(automaticDose.basalAdjustment.duration, .minutes(30)) - let manualDose = LoopAlgorithm.recommendManualBolus( + let manualDose = Algorithm.recommendManualBolus( for: correction, maxBolus: 6, currentGlucose: FixtureGlucoseSample(startDate: PredictedGlucoseMocks.testDate, quantity: .glucose(120)), @@ -474,7 +474,7 @@ class CorrectionDosingTests: XCTestCase { } func testHighAndRising() { - let correction = LoopAlgorithm.insulinCorrection( + let correction = Algorithm.insulinCorrection( prediction: PredictedGlucoseMocks.highAndRising(), at: testDate, target: target, @@ -483,7 +483,7 @@ class CorrectionDosingTests: XCTestCase { insulinModel: insulinModel ) - let recommendation = LoopAlgorithm.recommendTempBasal( + let recommendation = Algorithm.recommendTempBasal( for: correction, neutralBasalRate: basalRate, activeInsulin: 0, @@ -495,7 +495,7 @@ class CorrectionDosingTests: XCTestCase { XCTAssertEqual(recommendation.unitsPerHour, 3.0, accuracy: 0.05) XCTAssertEqual(recommendation.duration, .minutes(30)) - let automaticDose = LoopAlgorithm.recommendAutomaticDose( + let automaticDose = Algorithm.recommendAutomaticDose( for: correction, applicationFactor: 0.4, neutralBasalRate: basalRate, @@ -509,7 +509,7 @@ class CorrectionDosingTests: XCTestCase { XCTAssertEqual(automaticDose.basalAdjustment.unitsPerHour, basalRate) XCTAssertEqual(automaticDose.basalAdjustment.duration, .minutes(30)) - let manualDose = LoopAlgorithm.recommendManualBolus( + let manualDose = Algorithm.recommendManualBolus( for: correction, maxBolus: 6, currentGlucose: FixtureGlucoseSample(startDate: PredictedGlucoseMocks.testDate, quantity: .glucose(120)), @@ -521,7 +521,7 @@ class CorrectionDosingTests: XCTestCase { } func testVeryLowAndRising() { - let correction = LoopAlgorithm.insulinCorrection( + let correction = Algorithm.insulinCorrection( prediction: PredictedGlucoseMocks.veryLowAndRising(), at: testDate, target: target, @@ -530,7 +530,7 @@ class CorrectionDosingTests: XCTestCase { insulinModel: insulinModel ) - let recommendation = LoopAlgorithm.recommendTempBasal( + let recommendation = Algorithm.recommendTempBasal( for: correction, neutralBasalRate: basalRate, activeInsulin: 0, @@ -542,7 +542,7 @@ class CorrectionDosingTests: XCTestCase { XCTAssertEqual(recommendation.unitsPerHour, 0, accuracy: 0.05) XCTAssertEqual(recommendation.duration, .minutes(30)) - let automaticDose = LoopAlgorithm.recommendAutomaticDose( + let automaticDose = Algorithm.recommendAutomaticDose( for: correction, applicationFactor: 0.4, neutralBasalRate: basalRate, @@ -556,7 +556,7 @@ class CorrectionDosingTests: XCTestCase { XCTAssertEqual(automaticDose.basalAdjustment.unitsPerHour, 0) XCTAssertEqual(automaticDose.basalAdjustment.duration, .minutes(30)) - let manualDose = LoopAlgorithm.recommendManualBolus( + let manualDose = Algorithm.recommendManualBolus( for: correction, maxBolus: 6, currentGlucose: FixtureGlucoseSample(startDate: PredictedGlucoseMocks.testDate, quantity: .glucose(120)), @@ -580,7 +580,7 @@ class CorrectionDosingTests: XCTestCase { // Target switches from 90-120 to 150-170 at t+2h; predicted points beyond // the boundary are already in range, so they should require no correction. - let steppedTarget: GlucoseRangeTimeline = [ + let steppedTarget: QuantityRangeTimeline = [ AbsoluteScheduleValue( startDate: testDate.addingTimeInterval(.hours(-24)), endDate: testDate.addingTimeInterval(.hours(2)), @@ -593,7 +593,7 @@ class CorrectionDosingTests: XCTestCase { ) ] - let uniformCorrection = LoopAlgorithm.insulinCorrection( + let uniformCorrection = Algorithm.insulinCorrection( prediction: prediction, at: testDate, target: target, @@ -602,7 +602,7 @@ class CorrectionDosingTests: XCTestCase { insulinModel: insulinModel ) - let steppedCorrection = LoopAlgorithm.insulinCorrection( + let steppedCorrection = Algorithm.insulinCorrection( prediction: prediction, at: testDate, target: steppedTarget, @@ -611,14 +611,14 @@ class CorrectionDosingTests: XCTestCase { insulinModel: insulinModel ) - let uniformDose = LoopAlgorithm.recommendManualBolus( + let uniformDose = Algorithm.recommendManualBolus( for: uniformCorrection, maxBolus: 6, currentGlucose: FixtureGlucoseSample(startDate: testDate, quantity: .glucose(160)), target: target ) - let steppedDose = LoopAlgorithm.recommendManualBolus( + let steppedDose = Algorithm.recommendManualBolus( for: steppedCorrection, maxBolus: 6, currentGlucose: FixtureGlucoseSample(startDate: testDate, quantity: .glucose(160)), diff --git a/Tests/LoopAlgorithmTests/DateRoundingTests.swift b/Tests/LoopAlgorithmTests/DateRoundingTests.swift new file mode 100644 index 0000000..cfee3cf --- /dev/null +++ b/Tests/LoopAlgorithmTests/DateRoundingTests.swift @@ -0,0 +1,66 @@ +// +// DateRoundingTests.swift +// LoopAlgorithm +// +// Created by Nathan Racklyeft on 1/18/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import XCTest +@testable import LoopAlgorithm + +class DateRoundingTests: XCTestCase { + + func testDateCeiledToInterval() { + let calendar = Calendar.current + + let five01 = calendar.nextDate(after: Date(), matching: DateComponents(hour: 5, minute: 0, second: 1), matchingPolicy: .nextTime)! + + let five05 = calendar.nextDate(after: five01, matching: DateComponents(hour: 5, minute: 5, second: 0), matchingPolicy: .nextTime)! + + XCTAssertEqual(five05, five01.dateCeiledToTimeInterval(TimeInterval(minutes: 5))) + + let six = calendar.nextDate(after: five01, matching: DateComponents(hour: 6, minute: 0, second: 0), matchingPolicy: .nextTime)! + + XCTAssertEqual(six, five01.dateCeiledToTimeInterval(TimeInterval(minutes: 60))) + + XCTAssertEqual(five05, five05.dateCeiledToTimeInterval(TimeInterval(minutes: 5))) + + let five47 = calendar.nextDate(after: five01, matching: DateComponents(hour: 5, minute: 47, second: 58), matchingPolicy: .nextTime)! + + let five50 = calendar.nextDate(after: five01, matching: DateComponents(hour: 5, minute: 50, second: 0), matchingPolicy: .nextTime)! + + XCTAssertEqual(five50, five47.dateCeiledToTimeInterval(TimeInterval(minutes: 5))) + + let twentyThree59 = calendar.nextDate(after: five01, matching: DateComponents(hour: 23, minute: 59, second: 0), matchingPolicy: .nextTime)! + + let tomorrowMidnight = calendar.nextDate(after: five01, matching: DateComponents(hour: 0, minute: 0, second: 0), matchingPolicy: .nextTime)! + + XCTAssertEqual(tomorrowMidnight, twentyThree59.dateCeiledToTimeInterval(TimeInterval(minutes: 5))) + + XCTAssertEqual(five01, five01.dateCeiledToTimeInterval(TimeInterval(0))) + } + + func testDateFlooredToInterval() { + let calendar = Calendar.current + + let five01 = calendar.nextDate(after: Date(), matching: DateComponents(hour: 5, minute: 0, second: 1), matchingPolicy: .nextTime)! + + let five = calendar.nextDate(after: five01, matching: DateComponents(hour: 5, minute: 0, second: 0), matchingPolicy: .nextTime, direction: .backward)! + + XCTAssertEqual(five, five01.dateFlooredToTimeInterval(TimeInterval(minutes: 5))) + + let five59 = calendar.nextDate(after: five01, matching: DateComponents(hour: 5, minute: 59, second: 0), matchingPolicy: .nextTime)! + + XCTAssertEqual(five, five59.dateFlooredToTimeInterval(TimeInterval(minutes: 60))) + + let five55 = calendar.nextDate(after: five01, matching: DateComponents(hour: 5, minute: 55, second: 0), matchingPolicy: .nextTime)! + + XCTAssertEqual(five55, five59.dateFlooredToTimeInterval(TimeInterval(minutes: 5))) + + + XCTAssertEqual(five, five.dateFlooredToTimeInterval(TimeInterval(minutes: 5))) + + XCTAssertEqual(five01, five01.dateFlooredToTimeInterval(TimeInterval(0))) + } +} diff --git a/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_carb_effect_input.json b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_carb_effect_input.json new file mode 100644 index 0000000..c72bb99 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_carb_effect_input.json @@ -0,0 +1,467 @@ +[ + { + "date": "2015-10-16T05:55:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T06:00:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T06:05:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T06:10:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T06:15:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T06:20:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T06:25:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T06:30:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T06:35:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T06:40:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T06:45:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T06:50:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T06:55:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T07:00:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T07:05:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T07:10:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T07:15:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T07:20:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T07:25:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T07:30:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T07:35:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T07:40:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T07:45:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T07:50:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T07:55:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T08:00:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T08:05:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T08:10:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T08:15:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T08:20:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T08:25:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T08:30:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T08:35:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T08:40:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T08:45:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T08:50:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T08:55:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T09:00:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T09:05:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T09:10:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T09:15:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T09:20:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T09:25:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T09:30:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T09:35:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T09:40:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T09:45:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T09:50:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T09:55:00", + "amount": 0.08920096021947878, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:00:00", + "amount": 0.6130692729766802, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:05:00", + "amount": 1.6060733882030176, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:10:00", + "amount": 3.0682133058984906, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:15:00", + "amount": 4.999489026063102, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:20:00", + "amount": 7.399900548696846, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:25:00", + "amount": 10.269447873799727, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:30:00", + "amount": 13.608131001371746, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:35:00", + "amount": 17.415949931412896, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:40:00", + "amount": 21.692904663923187, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:45:00", + "amount": 26.438995198902607, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:50:00", + "amount": 31.65422153635116, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:55:00", + "amount": 37.33858367626886, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T11:00:00", + "amount": 43.49208161865569, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T11:05:00", + "amount": 50.11471536351166, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T11:10:00", + "amount": 57.20648491083676, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T11:15:00", + "amount": 64.767390260631, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T11:20:00", + "amount": 72.79743141289437, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T11:25:00", + "amount": 81.11820644718794, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T11:30:00", + "amount": 89.03878257887516, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T11:35:00", + "amount": 96.49022290809329, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T11:40:00", + "amount": 103.47252743484226, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T11:45:00", + "amount": 109.98569615912207, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T11:50:00", + "amount": 116.02972908093281, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T11:55:00", + "amount": 121.60462620027438, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T12:00:00", + "amount": 126.71038751714674, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T12:05:00", + "amount": 131.34701303155006, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T12:10:00", + "amount": 135.51450274348426, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T12:15:00", + "amount": 139.21285665294928, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T12:20:00", + "amount": 142.44207475994514, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T12:25:00", + "amount": 145.20215706447186, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T12:30:00", + "amount": 147.4931035665295, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T12:35:00", + "amount": 149.31491426611802, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T12:40:00", + "amount": 150.66758916323732, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T12:45:00", + "amount": 151.5511282578875, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T12:50:00", + "amount": 151.9655315500686, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T12:55:00", + "amount": 152.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T13:00:00", + "amount": 152.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T13:05:00", + "amount": 152.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T13:10:00", + "amount": 152.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T13:15:00", + "amount": 152.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T13:20:00", + "amount": 152.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T13:25:00", + "amount": 152.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T13:30:00", + "amount": 152.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T13:35:00", + "amount": 152.0, + "unit": "mg/dL" + } +] \ No newline at end of file diff --git a/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_glucose_input.json b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_glucose_input.json new file mode 100644 index 0000000..bc74b56 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_glucose_input.json @@ -0,0 +1,6 @@ +[{ + "trend_arrow": "FLAT", + "system_time": "2015-10-16T16:51:46", + "display_time": "2015-10-16T09:51:08", + "glucose": 147 +}] diff --git a/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_insulin_effect_input.json b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_insulin_effect_input.json new file mode 100644 index 0000000..244bb03 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_insulin_effect_input.json @@ -0,0 +1,527 @@ +[ + { + "date": "2015-10-16T05:55:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T06:00:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T06:05:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T06:10:00", + "amount": -0.05116973249986715, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T06:15:00", + "amount": -0.05789217482631093, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T06:20:00", + "amount": -0.07771134754398137, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T06:25:00", + "amount": -0.09750451603513274, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T06:30:00", + "amount": -0.12203773711068157, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T06:35:00", + "amount": -0.15095055115286835, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T06:40:00", + "amount": -0.1838927275472713, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T06:45:00", + "amount": -0.22052426468279615, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T06:50:00", + "amount": -0.24717183578216545, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T06:55:00", + "amount": -0.2893144242175132, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T07:00:00", + "amount": -0.2589871828832556, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T07:05:00", + "amount": -0.29857264308450326, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T07:10:00", + "amount": -0.22330600448031762, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T07:15:00", + "amount": -0.24340112507542233, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T07:20:00", + "amount": -0.25058050652147246, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T07:25:00", + "amount": -0.24254143594819094, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T07:30:00", + "amount": -0.21749871628061584, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T07:35:00", + "amount": -0.1772099748720401, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T07:40:00", + "amount": -0.12373296368553532, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T07:45:00", + "amount": -0.05800053902807922, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T07:50:00", + "amount": 0.019065916806689343, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T07:55:00", + "amount": 0.10656982887179145, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T08:00:00", + "amount": 0.5814672957555429, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T08:05:00", + "amount": 0.6964983023063709, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T08:10:00", + "amount": 0.839841951080192, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T08:15:00", + "amount": 1.017714433545975, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T08:20:00", + "amount": 1.2358473523026858, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T08:25:00", + "amount": 1.4990738388257407, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T08:30:00", + "amount": 2.4424187692212054, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T08:35:00", + "amount": 2.803912423996154, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T08:40:00", + "amount": 3.2220002596052173, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T08:45:00", + "amount": 3.700352667321728, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T08:50:00", + "amount": 4.242235634301219, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T08:55:00", + "amount": 4.850524811081413, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T09:00:00", + "amount": 5.5277195790822375, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T09:05:00", + "amount": 6.275957118105808, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T09:10:00", + "amount": 7.092719849613544, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T09:15:00", + "amount": 7.966122324754094, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T09:20:00", + "amount": 8.889869513880269, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T09:25:00", + "amount": 9.55106827366706, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T09:30:00", + "amount": 10.536493085687397, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T09:35:00", + "amount": 10.93556951143635, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T09:40:00", + "amount": 11.890699805221772, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T09:45:00", + "amount": 12.81049769931439, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T09:50:00", + "amount": 13.681466188886816, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T09:55:00", + "amount": 13.910708100083081, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:00:00", + "amount": 13.508961468585511, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:05:00", + "amount": 12.466684146484779, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:10:00", + "amount": 10.781837253440187, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:15:00", + "amount": 9.0846795883076, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:20:00", + "amount": 7.013205180933435, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:25:00", + "amount": 4.568371172724007, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:30:00", + "amount": 1.767024244038235, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:35:00", + "amount": -1.3622394338251143, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:40:00", + "amount": -4.785438162555244, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:45:00", + "amount": -8.475077970817363, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:50:00", + "amount": -12.404320524954366, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:55:00", + "amount": -16.54706904749455, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T11:00:00", + "amount": -20.880282108817163, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T11:05:00", + "amount": -25.378865830960304, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T11:10:00", + "amount": -30.02894037283888, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T11:15:00", + "amount": -34.80866756164933, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T11:20:00", + "amount": -39.69232924579666, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T11:25:00", + "amount": -44.65904560451495, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T11:30:00", + "amount": -49.687928735135834, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T11:35:00", + "amount": -54.75929582600946, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T11:40:00", + "amount": -59.84482763266896, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T11:45:00", + "amount": -64.92715822050762, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T11:50:00", + "amount": -69.98976050270197, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T11:55:00", + "amount": -75.01694624021185, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T12:00:00", + "amount": -79.9932139266723, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T12:05:00", + "amount": -84.89974475270296, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T12:10:00", + "amount": -89.7546057953877, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T12:15:00", + "amount": -94.54389486544915, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T12:20:00", + "amount": -99.25471446517622, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T12:25:00", + "amount": -103.87808111694163, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T12:30:00", + "amount": -108.39054448779835, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T12:35:00", + "amount": -112.79562633788349, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T12:40:00", + "amount": -117.0769986742359, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T12:45:00", + "amount": -121.23136988437481, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T12:50:00", + "amount": -125.25210996088262, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T12:55:00", + "amount": -129.13368784015748, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T13:00:00", + "amount": -132.87168546991376, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T13:05:00", + "amount": -136.46281187668208, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T13:10:00", + "amount": -139.91076109601062, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T13:15:00", + "amount": -143.1920024921548, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T13:20:00", + "amount": -146.2986738071368, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T13:25:00", + "amount": -149.23710482648195, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T13:30:00", + "amount": -152.01257516166288, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T13:35:00", + "amount": -154.5675135333564, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T13:40:00", + "amount": -156.9226367135027, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T13:45:00", + "amount": -159.09407074832978, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T13:50:00", + "amount": -161.0914402564, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T13:55:00", + "amount": -163.0926823408534, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T14:00:00", + "amount": -163.39678416635502, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T14:05:00", + "amount": -163.65380824473786, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T14:10:00", + "amount": -163.86314957675157, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T14:15:00", + "amount": -164.02799500404936, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T14:20:00", + "amount": -164.15136192522291, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T14:25:00", + "amount": -164.2348489355287, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T14:30:00", + "amount": -164.28097710009604, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T14:35:00", + "amount": -164.2833333333333, + "unit": "mg/dL" + } +] \ No newline at end of file diff --git a/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_blend_glucose_input.json b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_blend_glucose_input.json new file mode 100644 index 0000000..4ee6599 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_blend_glucose_input.json @@ -0,0 +1,20 @@ +[ + { + "trend_arrow": "FLAT", + "system_time": "2015-10-30T17:16:36", + "display_time": "2015-10-30T09:17:27", + "glucose": 111 + }, + { + "trend_arrow": "FLAT", + "system_time": "2015-10-30T17:11:36", + "display_time": "2015-10-30T09:12:27", + "glucose": 111 + }, + { + "trend_arrow": "FLAT", + "system_time": "2015-10-30T17:06:36", + "display_time": "2015-10-30T09:07:28", + "glucose": 113 + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_blend_insulin_effect_input.json b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_blend_insulin_effect_input.json new file mode 100644 index 0000000..4d2a4a5 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_blend_insulin_effect_input.json @@ -0,0 +1,37 @@ +[ + { + "date": "2015-10-30T09:15:00", + "amount": -96.64432176805616, + "unit": "mg/dL" + }, + { + "date": "2015-10-30T09:20:00", + "amount": -96.91899878577772, + "unit": "mg/dL" + }, + { + "date": "2015-10-30T09:25:00", + "amount": -97.14706680030386, + "unit": "mg/dL" + }, + { + "date": "2015-10-30T09:30:00", + "amount": -97.30954106615258, + "unit": "mg/dL" + }, + { + "date": "2015-10-30T09:35:00", + "amount": -97.40780654695538, + "unit": "mg/dL" + }, + { + "date": "2015-10-30T09:40:00", + "amount": -97.44702440741187, + "unit": "mg/dL" + }, + { + "date": "2015-10-30T09:45:00", + "amount": -97.428432128785, + "unit": "mg/dL" + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_blend_momentum_input.json b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_blend_momentum_input.json new file mode 100644 index 0000000..e58c381 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_blend_momentum_input.json @@ -0,0 +1,7 @@ +[ + {"amount": -0.00, "date": "2015-10-30T09:15:00", "unit": "mg/dL"}, + {"amount": -0.51, "date": "2015-10-30T09:20:00", "unit": "mg/dL"}, + {"amount": -1.51, "date": "2015-10-30T09:25:00", "unit": "mg/dL"}, + {"amount": -2.51, "date": "2015-10-30T09:30:00", "unit": "mg/dL"}, + {"amount": -3.51, "date": "2015-10-30T09:35:00", "unit": "mg/dL"} +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_blend_output.json b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_blend_output.json new file mode 100644 index 0000000..a28bd15 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_blend_output.json @@ -0,0 +1 @@ +[{"date": "2015-10-30T09:17:27", "amount": 111.0, "unit": "mg/dL"}, {"date": "2015-10-30T09:20:00", "amount": 110.49, "unit": "mg/dL"}, {"date": "2015-10-30T09:25:00", "amount": 109.62122843753055, "unit": "mg/dL"}, {"date": "2015-10-30T09:30:00", "amount": 109.04278305705336, "unit": "mg/dL"}, {"date": "2015-10-30T09:35:00", "amount": 108.79723427144835, "unit": "mg/dL"}, {"date": "2015-10-30T09:40:00", "amount": 108.75801641099186, "unit": "mg/dL"}, {"date": "2015-10-30T09:45:00", "amount": 108.77660868961873, "unit": "mg/dL"}] \ No newline at end of file diff --git a/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_down_input.json b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_down_input.json new file mode 100644 index 0000000..b7247a1 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_down_input.json @@ -0,0 +1,37 @@ +[ + { + "date": "2015-10-16T09:50:00", + "amount": -11.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T09:55:00", + "amount": -14.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:00:00", + "amount": -17.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:05:00", + "amount": -20.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:10:00", + "amount": -23.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:15:00", + "amount": -26.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:20:00", + "amount": -29.0, + "unit": "mg/dL" + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_down_output.json b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_down_output.json new file mode 100644 index 0000000..8da0604 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_down_output.json @@ -0,0 +1 @@ +[{"date": "2015-10-16T09:51:08", "amount": 147.0, "unit": "mg/dL"}, {"date": "2015-10-16T09:55:00", "amount": 144.0, "unit": "mg/dL"}, {"date": "2015-10-16T10:00:00", "amount": 141.48288815336815, "unit": "mg/dL"}, {"date": "2015-10-16T10:05:00", "amount": 139.52941258933004, "unit": "mg/dL"}, {"date": "2015-10-16T10:10:00", "amount": 138.06988445366972, "unit": "mg/dL"}, {"date": "2015-10-16T10:15:00", "amount": 137.51056554586722, "unit": "mg/dL"}, {"date": "2015-10-16T10:20:00", "amount": 137.68859084523504, "unit": "mg/dL"}, {"date": "2015-10-16T10:25:00", "amount": 138.1133041621285, "unit": "mg/dL"}, {"date": "2015-10-16T10:30:00", "amount": 138.65064036101472, "unit": "mg/dL"}, {"date": "2015-10-16T10:35:00", "amount": 139.32919561319252, "unit": "mg/dL"}, {"date": "2015-10-16T10:40:00", "amount": 140.18295161697267, "unit": "mg/dL"}, {"date": "2015-10-16T10:45:00", "amount": 141.23940234368996, "unit": "mg/dL"}, {"date": "2015-10-16T10:50:00", "amount": 142.5253861270015, "unit": "mg/dL"}, {"date": "2015-10-16T10:55:00", "amount": 144.06699974437902, "unit": "mg/dL"}, {"date": "2015-10-16T11:00:00", "amount": 145.88728462544324, "unit": "mg/dL"}, {"date": "2015-10-16T11:05:00", "amount": 148.01133464815607, "unit": "mg/dL"}, {"date": "2015-10-16T11:10:00", "amount": 150.45302965360258, "unit": "mg/dL"}, {"date": "2015-10-16T11:15:00", "amount": 153.23420781458637, "unit": "mg/dL"}, {"date": "2015-10-16T11:20:00", "amount": 156.3805872827024, "unit": "mg/dL"}, {"date": "2015-10-16T11:25:00", "amount": 159.7346459582777, "unit": "mg/dL"}, {"date": "2015-10-16T11:30:00", "amount": 162.62633895934403, "unit": "mg/dL"}, {"date": "2015-10-16T11:35:00", "amount": 165.00641219768852, "unit": "mg/dL"}, {"date": "2015-10-16T11:40:00", "amount": 166.903184917778, "unit": "mg/dL"}, {"date": "2015-10-16T11:45:00", "amount": 168.33402305421913, "unit": "mg/dL"}, {"date": "2015-10-16T11:50:00", "amount": 169.31545369383554, "unit": "mg/dL"}, {"date": "2015-10-16T11:55:00", "amount": 169.8631650756672, "unit": "mg/dL"}, {"date": "2015-10-16T12:00:00", "amount": 169.9926587060791, "unit": "mg/dL"}, {"date": "2015-10-16T12:05:00", "amount": 169.7227533944518, "unit": "mg/dL"}, {"date": "2015-10-16T12:10:00", "amount": 169.03538206370123, "unit": "mg/dL"}, {"date": "2015-10-16T12:15:00", "amount": 167.94444690310482, "unit": "mg/dL"}, {"date": "2015-10-16T12:20:00", "amount": 166.4628454103736, "unit": "mg/dL"}, {"date": "2015-10-16T12:25:00", "amount": 164.5995610631349, "unit": "mg/dL"}, {"date": "2015-10-16T12:30:00", "amount": 162.3780441943358, "unit": "mg/dL"}, {"date": "2015-10-16T12:35:00", "amount": 159.7947730438392, "unit": "mg/dL"}, {"date": "2015-10-16T12:40:00", "amount": 156.8660756046061, "unit": "mg/dL"}, {"date": "2015-10-16T12:45:00", "amount": 153.59524348911737, "unit": "mg/dL"}, {"date": "2015-10-16T12:50:00", "amount": 149.98890670479062, "unit": "mg/dL"}, {"date": "2015-10-16T12:55:00", "amount": 146.14179727544717, "unit": "mg/dL"}, {"date": "2015-10-16T13:00:00", "amount": 142.4037996456909, "unit": "mg/dL"}, {"date": "2015-10-16T13:05:00", "amount": 138.81267323892257, "unit": "mg/dL"}, {"date": "2015-10-16T13:10:00", "amount": 135.36472401959404, "unit": "mg/dL"}, {"date": "2015-10-16T13:15:00", "amount": 132.08348262344984, "unit": "mg/dL"}, {"date": "2015-10-16T13:20:00", "amount": 128.97681130846786, "unit": "mg/dL"}, {"date": "2015-10-16T13:25:00", "amount": 126.0383802891227, "unit": "mg/dL"}, {"date": "2015-10-16T13:30:00", "amount": 123.26290995394177, "unit": "mg/dL"}, {"date": "2015-10-16T13:35:00", "amount": 120.70797158224826, "unit": "mg/dL"}, {"date": "2015-10-16T13:40:00", "amount": 118.35284840210196, "unit": "mg/dL"}, {"date": "2015-10-16T13:45:00", "amount": 116.18141436727487, "unit": "mg/dL"}, {"date": "2015-10-16T13:50:00", "amount": 114.18404485920465, "unit": "mg/dL"}, {"date": "2015-10-16T13:55:00", "amount": 112.18280277475125, "unit": "mg/dL"}, {"date": "2015-10-16T14:00:00", "amount": 111.87870094924963, "unit": "mg/dL"}, {"date": "2015-10-16T14:05:00", "amount": 111.62167687086679, "unit": "mg/dL"}, {"date": "2015-10-16T14:10:00", "amount": 111.41233553885309, "unit": "mg/dL"}, {"date": "2015-10-16T14:15:00", "amount": 111.2474901115553, "unit": "mg/dL"}, {"date": "2015-10-16T14:20:00", "amount": 111.12412319038174, "unit": "mg/dL"}, {"date": "2015-10-16T14:25:00", "amount": 111.04063618007595, "unit": "mg/dL"}, {"date": "2015-10-16T14:30:00", "amount": 110.99450801550861, "unit": "mg/dL"}, {"date": "2015-10-16T14:35:00", "amount": 110.99215178227135, "unit": "mg/dL"}] \ No newline at end of file diff --git a/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_flat_glucose_input.json b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_flat_glucose_input.json new file mode 100644 index 0000000..2ca9fb4 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_flat_glucose_input.json @@ -0,0 +1,6 @@ +[{ + "trend_arrow": "FLAT", + "system_time": "2015-10-16T16:54:46", + "display_time": "2015-10-16T09:54:08", + "glucose": 147 +}] diff --git a/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_flat_input.json b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_flat_input.json new file mode 100644 index 0000000..3b4fbe0 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_flat_input.json @@ -0,0 +1,37 @@ +[ + { + "date": "2015-10-16T09:50:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T09:55:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:00:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:05:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:10:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:15:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:20:00", + "amount": 0.0, + "unit": "mg/dL" + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_flat_output.json b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_flat_output.json new file mode 100644 index 0000000..d622b69 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_flat_output.json @@ -0,0 +1 @@ +[{"date": "2015-10-16T09:54:08", "amount": 147.0, "unit": "mg/dL"}, {"date": "2015-10-16T09:55:00", "amount": 147.0, "unit": "mg/dL"}, {"date": "2015-10-16T10:00:00", "amount": 147.004233551617, "unit": "mg/dL"}, {"date": "2015-10-16T10:05:00", "amount": 146.9926707724038, "unit": "mg/dL"}, {"date": "2015-10-16T10:10:00", "amount": 146.8958674737854, "unit": "mg/dL"}, {"date": "2015-10-16T10:15:00", "amount": 147.04445439937905, "unit": "mg/dL"}, {"date": "2015-10-16T10:20:00", "amount": 147.31900724491572, "unit": "mg/dL"}, {"date": "2015-10-16T10:25:00", "amount": 147.74372056180917, "unit": "mg/dL"}, {"date": "2015-10-16T10:30:00", "amount": 148.2810567606954, "unit": "mg/dL"}, {"date": "2015-10-16T10:35:00", "amount": 148.9596120128732, "unit": "mg/dL"}, {"date": "2015-10-16T10:40:00", "amount": 149.81336801665336, "unit": "mg/dL"}, {"date": "2015-10-16T10:45:00", "amount": 150.86981874337064, "unit": "mg/dL"}, {"date": "2015-10-16T10:50:00", "amount": 152.15580252668218, "unit": "mg/dL"}, {"date": "2015-10-16T10:55:00", "amount": 153.6974161440597, "unit": "mg/dL"}, {"date": "2015-10-16T11:00:00", "amount": 155.51770102512393, "unit": "mg/dL"}, {"date": "2015-10-16T11:05:00", "amount": 157.64175104783675, "unit": "mg/dL"}, {"date": "2015-10-16T11:10:00", "amount": 160.08344605328327, "unit": "mg/dL"}, {"date": "2015-10-16T11:15:00", "amount": 162.86462421426705, "unit": "mg/dL"}, {"date": "2015-10-16T11:20:00", "amount": 166.0110036823831, "unit": "mg/dL"}, {"date": "2015-10-16T11:25:00", "amount": 169.36506235795838, "unit": "mg/dL"}, {"date": "2015-10-16T11:30:00", "amount": 172.2567553590247, "unit": "mg/dL"}, {"date": "2015-10-16T11:35:00", "amount": 174.6368285973692, "unit": "mg/dL"}, {"date": "2015-10-16T11:40:00", "amount": 176.53360131745868, "unit": "mg/dL"}, {"date": "2015-10-16T11:45:00", "amount": 177.96443945389981, "unit": "mg/dL"}, {"date": "2015-10-16T11:50:00", "amount": 178.9458700935162, "unit": "mg/dL"}, {"date": "2015-10-16T11:55:00", "amount": 179.49358147534787, "unit": "mg/dL"}, {"date": "2015-10-16T12:00:00", "amount": 179.62307510575977, "unit": "mg/dL"}, {"date": "2015-10-16T12:05:00", "amount": 179.35316979413244, "unit": "mg/dL"}, {"date": "2015-10-16T12:10:00", "amount": 178.6657984633819, "unit": "mg/dL"}, {"date": "2015-10-16T12:15:00", "amount": 177.57486330278547, "unit": "mg/dL"}, {"date": "2015-10-16T12:20:00", "amount": 176.09326181005426, "unit": "mg/dL"}, {"date": "2015-10-16T12:25:00", "amount": 174.22997746281555, "unit": "mg/dL"}, {"date": "2015-10-16T12:30:00", "amount": 172.00846059401647, "unit": "mg/dL"}, {"date": "2015-10-16T12:35:00", "amount": 169.42518944351986, "unit": "mg/dL"}, {"date": "2015-10-16T12:40:00", "amount": 166.49649200428675, "unit": "mg/dL"}, {"date": "2015-10-16T12:45:00", "amount": 163.22565988879802, "unit": "mg/dL"}, {"date": "2015-10-16T12:50:00", "amount": 159.61932310447128, "unit": "mg/dL"}, {"date": "2015-10-16T12:55:00", "amount": 155.77221367512783, "unit": "mg/dL"}, {"date": "2015-10-16T13:00:00", "amount": 152.03421604537155, "unit": "mg/dL"}, {"date": "2015-10-16T13:05:00", "amount": 148.44308963860323, "unit": "mg/dL"}, {"date": "2015-10-16T13:10:00", "amount": 144.9951404192747, "unit": "mg/dL"}, {"date": "2015-10-16T13:15:00", "amount": 141.7138990231305, "unit": "mg/dL"}, {"date": "2015-10-16T13:20:00", "amount": 138.6072277081485, "unit": "mg/dL"}, {"date": "2015-10-16T13:25:00", "amount": 135.66879668880335, "unit": "mg/dL"}, {"date": "2015-10-16T13:30:00", "amount": 132.89332635362243, "unit": "mg/dL"}, {"date": "2015-10-16T13:35:00", "amount": 130.33838798192892, "unit": "mg/dL"}, {"date": "2015-10-16T13:40:00", "amount": 127.98326480178261, "unit": "mg/dL"}, {"date": "2015-10-16T13:45:00", "amount": 125.81183076695552, "unit": "mg/dL"}, {"date": "2015-10-16T13:50:00", "amount": 123.8144612588853, "unit": "mg/dL"}, {"date": "2015-10-16T13:55:00", "amount": 121.81321917443191, "unit": "mg/dL"}, {"date": "2015-10-16T14:00:00", "amount": 121.50911734893029, "unit": "mg/dL"}, {"date": "2015-10-16T14:05:00", "amount": 121.25209327054745, "unit": "mg/dL"}, {"date": "2015-10-16T14:10:00", "amount": 121.04275193853374, "unit": "mg/dL"}, {"date": "2015-10-16T14:15:00", "amount": 120.87790651123595, "unit": "mg/dL"}, {"date": "2015-10-16T14:20:00", "amount": 120.7545395900624, "unit": "mg/dL"}, {"date": "2015-10-16T14:25:00", "amount": 120.6710525797566, "unit": "mg/dL"}, {"date": "2015-10-16T14:30:00", "amount": 120.62492441518927, "unit": "mg/dL"}, {"date": "2015-10-16T14:35:00", "amount": 120.622568181952, "unit": "mg/dL"}] \ No newline at end of file diff --git a/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_up_input.json b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_up_input.json new file mode 100644 index 0000000..8d069d1 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_up_input.json @@ -0,0 +1,37 @@ +[ + { + "date": "2015-10-16T09:50:00", + "amount": 0.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T09:55:00", + "amount": 3.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:00:00", + "amount": 6.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:05:00", + "amount": 9.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:10:00", + "amount": 12.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:15:00", + "amount": 15.0, + "unit": "mg/dL" + }, + { + "date": "2015-10-16T10:20:00", + "amount": 18.0, + "unit": "mg/dL" + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_up_output.json b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_up_output.json new file mode 100644 index 0000000..c7c957c --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_momentum_up_output.json @@ -0,0 +1 @@ +[{"date": "2015-10-16T09:51:08", "amount": 147.0, "unit": "mg/dL"}, {"date": "2015-10-16T09:55:00", "amount": 150.0, "unit": "mg/dL"}, {"date": "2015-10-16T10:00:00", "amount": 152.55488815336815, "unit": "mg/dL"}, {"date": "2015-10-16T10:05:00", "amount": 154.47341258933002, "unit": "mg/dL"}, {"date": "2015-10-16T10:10:00", "amount": 155.6858844536697, "unit": "mg/dL"}, {"date": "2015-10-16T10:15:00", "amount": 156.5985655458672, "unit": "mg/dL"}, {"date": "2015-10-16T10:20:00", "amount": 157.048590845235, "unit": "mg/dL"}, {"date": "2015-10-16T10:25:00", "amount": 157.47330416212844, "unit": "mg/dL"}, {"date": "2015-10-16T10:30:00", "amount": 158.01064036101468, "unit": "mg/dL"}, {"date": "2015-10-16T10:35:00", "amount": 158.68919561319248, "unit": "mg/dL"}, {"date": "2015-10-16T10:40:00", "amount": 159.54295161697263, "unit": "mg/dL"}, {"date": "2015-10-16T10:45:00", "amount": 160.59940234368992, "unit": "mg/dL"}, {"date": "2015-10-16T10:50:00", "amount": 161.88538612700145, "unit": "mg/dL"}, {"date": "2015-10-16T10:55:00", "amount": 163.42699974437897, "unit": "mg/dL"}, {"date": "2015-10-16T11:00:00", "amount": 165.2472846254432, "unit": "mg/dL"}, {"date": "2015-10-16T11:05:00", "amount": 167.37133464815602, "unit": "mg/dL"}, {"date": "2015-10-16T11:10:00", "amount": 169.81302965360254, "unit": "mg/dL"}, {"date": "2015-10-16T11:15:00", "amount": 172.59420781458633, "unit": "mg/dL"}, {"date": "2015-10-16T11:20:00", "amount": 175.74058728270236, "unit": "mg/dL"}, {"date": "2015-10-16T11:25:00", "amount": 179.09464595827765, "unit": "mg/dL"}, {"date": "2015-10-16T11:30:00", "amount": 181.98633895934398, "unit": "mg/dL"}, {"date": "2015-10-16T11:35:00", "amount": 184.36641219768848, "unit": "mg/dL"}, {"date": "2015-10-16T11:40:00", "amount": 186.26318491777795, "unit": "mg/dL"}, {"date": "2015-10-16T11:45:00", "amount": 187.6940230542191, "unit": "mg/dL"}, {"date": "2015-10-16T11:50:00", "amount": 188.6754536938355, "unit": "mg/dL"}, {"date": "2015-10-16T11:55:00", "amount": 189.22316507566717, "unit": "mg/dL"}, {"date": "2015-10-16T12:00:00", "amount": 189.35265870607907, "unit": "mg/dL"}, {"date": "2015-10-16T12:05:00", "amount": 189.08275339445174, "unit": "mg/dL"}, {"date": "2015-10-16T12:10:00", "amount": 188.3953820637012, "unit": "mg/dL"}, {"date": "2015-10-16T12:15:00", "amount": 187.30444690310478, "unit": "mg/dL"}, {"date": "2015-10-16T12:20:00", "amount": 185.82284541037356, "unit": "mg/dL"}, {"date": "2015-10-16T12:25:00", "amount": 183.95956106313486, "unit": "mg/dL"}, {"date": "2015-10-16T12:30:00", "amount": 181.73804419433577, "unit": "mg/dL"}, {"date": "2015-10-16T12:35:00", "amount": 179.15477304383916, "unit": "mg/dL"}, {"date": "2015-10-16T12:40:00", "amount": 176.22607560460605, "unit": "mg/dL"}, {"date": "2015-10-16T12:45:00", "amount": 172.95524348911732, "unit": "mg/dL"}, {"date": "2015-10-16T12:50:00", "amount": 169.34890670479058, "unit": "mg/dL"}, {"date": "2015-10-16T12:55:00", "amount": 165.50179727544713, "unit": "mg/dL"}, {"date": "2015-10-16T13:00:00", "amount": 161.76379964569085, "unit": "mg/dL"}, {"date": "2015-10-16T13:05:00", "amount": 158.17267323892253, "unit": "mg/dL"}, {"date": "2015-10-16T13:10:00", "amount": 154.724724019594, "unit": "mg/dL"}, {"date": "2015-10-16T13:15:00", "amount": 151.4434826234498, "unit": "mg/dL"}, {"date": "2015-10-16T13:20:00", "amount": 148.33681130846782, "unit": "mg/dL"}, {"date": "2015-10-16T13:25:00", "amount": 145.39838028912266, "unit": "mg/dL"}, {"date": "2015-10-16T13:30:00", "amount": 142.62290995394173, "unit": "mg/dL"}, {"date": "2015-10-16T13:35:00", "amount": 140.06797158224822, "unit": "mg/dL"}, {"date": "2015-10-16T13:40:00", "amount": 137.7128484021019, "unit": "mg/dL"}, {"date": "2015-10-16T13:45:00", "amount": 135.54141436727483, "unit": "mg/dL"}, {"date": "2015-10-16T13:50:00", "amount": 133.5440448592046, "unit": "mg/dL"}, {"date": "2015-10-16T13:55:00", "amount": 131.5428027747512, "unit": "mg/dL"}, {"date": "2015-10-16T14:00:00", "amount": 131.2387009492496, "unit": "mg/dL"}, {"date": "2015-10-16T14:05:00", "amount": 130.98167687086675, "unit": "mg/dL"}, {"date": "2015-10-16T14:10:00", "amount": 130.77233553885304, "unit": "mg/dL"}, {"date": "2015-10-16T14:15:00", "amount": 130.60749011155525, "unit": "mg/dL"}, {"date": "2015-10-16T14:20:00", "amount": 130.4841231903817, "unit": "mg/dL"}, {"date": "2015-10-16T14:25:00", "amount": 130.4006361800759, "unit": "mg/dL"}, {"date": "2015-10-16T14:30:00", "amount": 130.35450801550857, "unit": "mg/dL"}, {"date": "2015-10-16T14:35:00", "amount": 130.3521517822713, "unit": "mg/dL"}] \ No newline at end of file diff --git a/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_no_momentum_output.json b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_no_momentum_output.json new file mode 100644 index 0000000..2e372eb --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_no_momentum_output.json @@ -0,0 +1 @@ +[{"date": "2015-10-16T09:51:08", "amount": 147.0, "unit": "mg/dL"}, {"date": "2015-10-16T09:55:00", "amount": 147.31844287141575, "unit": "mg/dL"}, {"date": "2015-10-16T10:00:00", "amount": 147.4405645526754, "unit": "mg/dL"}, {"date": "2015-10-16T10:05:00", "amount": 147.391291345801, "unit": "mg/dL"}, {"date": "2015-10-16T10:10:00", "amount": 147.16858437045187, "unit": "mg/dL"}, {"date": "2015-10-16T10:15:00", "amount": 147.40270242548388, "unit": "mg/dL"}, {"date": "2015-10-16T10:20:00", "amount": 147.73163954074346, "unit": "mg/dL"}, {"date": "2015-10-16T10:25:00", "amount": 148.1563528576369, "unit": "mg/dL"}, {"date": "2015-10-16T10:30:00", "amount": 148.69368905652314, "unit": "mg/dL"}, {"date": "2015-10-16T10:35:00", "amount": 149.37224430870094, "unit": "mg/dL"}, {"date": "2015-10-16T10:40:00", "amount": 150.2260003124811, "unit": "mg/dL"}, {"date": "2015-10-16T10:45:00", "amount": 151.28245103919838, "unit": "mg/dL"}, {"date": "2015-10-16T10:50:00", "amount": 152.56843482250991, "unit": "mg/dL"}, {"date": "2015-10-16T10:55:00", "amount": 154.11004843988744, "unit": "mg/dL"}, {"date": "2015-10-16T11:00:00", "amount": 155.93033332095166, "unit": "mg/dL"}, {"date": "2015-10-16T11:05:00", "amount": 158.05438334366448, "unit": "mg/dL"}, {"date": "2015-10-16T11:10:00", "amount": 160.496078349111, "unit": "mg/dL"}, {"date": "2015-10-16T11:15:00", "amount": 163.2772565100948, "unit": "mg/dL"}, {"date": "2015-10-16T11:20:00", "amount": 166.42363597821083, "unit": "mg/dL"}, {"date": "2015-10-16T11:25:00", "amount": 169.7776946537861, "unit": "mg/dL"}, {"date": "2015-10-16T11:30:00", "amount": 172.66938765485244, "unit": "mg/dL"}, {"date": "2015-10-16T11:35:00", "amount": 175.04946089319694, "unit": "mg/dL"}, {"date": "2015-10-16T11:40:00", "amount": 176.9462336132864, "unit": "mg/dL"}, {"date": "2015-10-16T11:45:00", "amount": 178.37707174972755, "unit": "mg/dL"}, {"date": "2015-10-16T11:50:00", "amount": 179.35850238934393, "unit": "mg/dL"}, {"date": "2015-10-16T11:55:00", "amount": 179.9062137711756, "unit": "mg/dL"}, {"date": "2015-10-16T12:00:00", "amount": 180.0357074015875, "unit": "mg/dL"}, {"date": "2015-10-16T12:05:00", "amount": 179.76580208996018, "unit": "mg/dL"}, {"date": "2015-10-16T12:10:00", "amount": 179.07843075920962, "unit": "mg/dL"}, {"date": "2015-10-16T12:15:00", "amount": 177.9874955986132, "unit": "mg/dL"}, {"date": "2015-10-16T12:20:00", "amount": 176.505894105882, "unit": "mg/dL"}, {"date": "2015-10-16T12:25:00", "amount": 174.6426097586433, "unit": "mg/dL"}, {"date": "2015-10-16T12:30:00", "amount": 172.4210928898442, "unit": "mg/dL"}, {"date": "2015-10-16T12:35:00", "amount": 169.8378217393476, "unit": "mg/dL"}, {"date": "2015-10-16T12:40:00", "amount": 166.90912430011448, "unit": "mg/dL"}, {"date": "2015-10-16T12:45:00", "amount": 163.63829218462575, "unit": "mg/dL"}, {"date": "2015-10-16T12:50:00", "amount": 160.031955400299, "unit": "mg/dL"}, {"date": "2015-10-16T12:55:00", "amount": 156.18484597095556, "unit": "mg/dL"}, {"date": "2015-10-16T13:00:00", "amount": 152.44684834119928, "unit": "mg/dL"}, {"date": "2015-10-16T13:05:00", "amount": 148.85572193443096, "unit": "mg/dL"}, {"date": "2015-10-16T13:10:00", "amount": 145.40777271510242, "unit": "mg/dL"}, {"date": "2015-10-16T13:15:00", "amount": 142.12653131895823, "unit": "mg/dL"}, {"date": "2015-10-16T13:20:00", "amount": 139.01986000397625, "unit": "mg/dL"}, {"date": "2015-10-16T13:25:00", "amount": 136.0814289846311, "unit": "mg/dL"}, {"date": "2015-10-16T13:30:00", "amount": 133.30595864945016, "unit": "mg/dL"}, {"date": "2015-10-16T13:35:00", "amount": 130.75102027775665, "unit": "mg/dL"}, {"date": "2015-10-16T13:40:00", "amount": 128.39589709761034, "unit": "mg/dL"}, {"date": "2015-10-16T13:45:00", "amount": 126.22446306278326, "unit": "mg/dL"}, {"date": "2015-10-16T13:50:00", "amount": 124.22709355471304, "unit": "mg/dL"}, {"date": "2015-10-16T13:55:00", "amount": 122.22585147025964, "unit": "mg/dL"}, {"date": "2015-10-16T14:00:00", "amount": 121.92174964475802, "unit": "mg/dL"}, {"date": "2015-10-16T14:05:00", "amount": 121.66472556637518, "unit": "mg/dL"}, {"date": "2015-10-16T14:10:00", "amount": 121.45538423436147, "unit": "mg/dL"}, {"date": "2015-10-16T14:15:00", "amount": 121.29053880706368, "unit": "mg/dL"}, {"date": "2015-10-16T14:20:00", "amount": 121.16717188589013, "unit": "mg/dL"}, {"date": "2015-10-16T14:25:00", "amount": 121.08368487558434, "unit": "mg/dL"}, {"date": "2015-10-16T14:30:00", "amount": 121.037556711017, "unit": "mg/dL"}, {"date": "2015-10-16T14:35:00", "amount": 121.03520047777974, "unit": "mg/dL"}] \ No newline at end of file diff --git a/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_non_zero_carb_input.json b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_non_zero_carb_input.json new file mode 100644 index 0000000..5eb68ca --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_non_zero_carb_input.json @@ -0,0 +1,157 @@ +[ + { + "value": 345.692710028959, + "startDate": "2016-03-05T00:05:00-0800", + "unit": "mg/dL" + }, + { + "value": 357.4968709800336, + "startDate": "2016-03-05T00:10:00-0800", + "unit": "mg/dL" + }, + { + "value": 368.2585079256211, + "startDate": "2016-03-05T00:15:00-0800", + "unit": "mg/dL" + }, + { + "value": 377.9776208657216, + "startDate": "2016-03-05T00:20:00-0800", + "unit": "mg/dL" + }, + { + "value": 386.6542098003353, + "startDate": "2016-03-05T00:25:00-0800", + "unit": "mg/dL" + }, + { + "value": 394.2882747294619, + "startDate": "2016-03-05T00:30:00-0800", + "unit": "mg/dL" + }, + { + "value": 400.9472251181222, + "startDate": "2016-03-05T00:35:00-0800", + "unit": "mg/dL" + }, + { + "value": 406.9048840115836, + "startDate": "2016-03-05T00:40:00-0800", + "unit": "mg/dL" + }, + { + "value": 412.1903892699285, + "startDate": "2016-03-05T00:45:00-0800", + "unit": "mg/dL" + }, + { + "value": 416.8037408931565, + "startDate": "2016-03-05T00:50:00-0800", + "unit": "mg/dL" + }, + { + "value": 420.7449388812682, + "startDate": "2016-03-05T00:55:00-0800", + "unit": "mg/dL" + }, + { + "value": 424.013983234263, + "startDate": "2016-03-05T01:00:00-0800", + "unit": "mg/dL" + }, + { + "value": 426.6108739521414, + "startDate": "2016-03-05T01:05:00-0800", + "unit": "mg/dL" + }, + { + "value": 428.5356110349032, + "startDate": "2016-03-05T01:10:00-0800", + "unit": "mg/dL" + }, + { + "value": 429.7881944825484, + "startDate": "2016-03-05T01:15:00-0800", + "unit": "mg/dL" + }, + { + "value": 430.461332114007, + "startDate": "2016-03-05T01:20:00-0800", + "unit": "mg/dL" + }, + { + "value": 430.885657674135, + "startDate": "2016-03-05T01:25:00-0800", + "unit": "mg/dL" + }, + { + "value": 431.0905044962657, + "startDate": "2016-03-05T01:30:00-0800", + "unit": "mg/dL" + }, + { + "value": 431.1111111111111, + "startDate": "2016-03-05T01:35:00-0800", + "unit": "mg/dL" + }, + { + "value": 431.1111111111111, + "startDate": "2016-03-05T01:40:00-0800", + "unit": "mg/dL" + }, + { + "value": 431.1111111111111, + "startDate": "2016-03-05T01:45:00-0800", + "unit": "mg/dL" + }, + { + "value": 431.1111111111111, + "startDate": "2016-03-05T01:50:00-0800", + "unit": "mg/dL" + }, + { + "value": 431.1111111111111, + "startDate": "2016-03-05T01:55:00-0800", + "unit": "mg/dL" + }, + { + "value": 431.1111111111111, + "startDate": "2016-03-05T02:00:00-0800", + "unit": "mg/dL" + }, + { + "value": 431.1111111111111, + "startDate": "2016-03-05T02:05:00-0800", + "unit": "mg/dL" + }, + { + "value": 431.1111111111111, + "startDate": "2016-03-05T02:10:00-0800", + "unit": "mg/dL" + }, + { + "value": 431.1111111111111, + "startDate": "2016-03-05T02:15:00-0800", + "unit": "mg/dL" + }, + { + "value": 431.1111111111111, + "startDate": "2016-03-05T02:20:00-0800", + "unit": "mg/dL" + }, + { + "value": 431.1111111111111, + "startDate": "2016-03-05T02:25:00-0800", + "unit": "mg/dL" + }, + { + "value": 431.1111111111111, + "startDate": "2016-03-05T02:30:00-0800", + "unit": "mg/dL" + }, + { + "value": 431.1111111111111, + "startDate": "2016-03-05T02:35:00-0800", + "unit": "mg/dL" + } +] \ No newline at end of file diff --git a/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_non_zero_glucose_input.json b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_non_zero_glucose_input.json new file mode 100644 index 0000000..238d951 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_non_zero_glucose_input.json @@ -0,0 +1,7 @@ +[ + { + "value": 178, + "startDate": "2016-03-05T00:03:14-0800", + "unit": "mg/dL" + } +] \ No newline at end of file diff --git a/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_non_zero_insulin_input.json b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_non_zero_insulin_input.json new file mode 100644 index 0000000..af6d5fd --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_non_zero_insulin_input.json @@ -0,0 +1,272 @@ +[ + { + "value": -262.7985192241191, + "startDate": "2016-03-05T00:05:00-0800", + "unit": "mg/dL" + }, + { + "value": -270.8286417720012, + "startDate": "2016-03-05T00:10:00-0800", + "unit": "mg/dL" + }, + { + "value": -278.8133758667809, + "startDate": "2016-03-05T00:15:00-0800", + "unit": "mg/dL" + }, + { + "value": -286.7284576274255, + "startDate": "2016-03-05T00:20:00-0800", + "unit": "mg/dL" + }, + { + "value": -294.5582531285091, + "startDate": "2016-03-05T00:25:00-0800", + "unit": "mg/dL" + }, + { + "value": -302.5967010990278, + "startDate": "2016-03-05T00:30:00-0800", + "unit": "mg/dL" + }, + { + "value": -310.381943957302, + "startDate": "2016-03-05T00:35:00-0800", + "unit": "mg/dL" + }, + { + "value": -316.6257775805158, + "startDate": "2016-03-05T00:40:00-0800", + "unit": "mg/dL" + }, + { + "value": -322.0179315638999, + "startDate": "2016-03-05T00:45:00-0800", + "unit": "mg/dL" + }, + { + "value": -327.3554504781727, + "startDate": "2016-03-05T00:50:00-0800", + "unit": "mg/dL" + }, + { + "value": -332.5782130464361, + "startDate": "2016-03-05T00:55:00-0800", + "unit": "mg/dL" + }, + { + "value": -337.6793353886575, + "startDate": "2016-03-05T01:00:00-0800", + "unit": "mg/dL" + }, + { + "value": -342.7443165330539, + "startDate": "2016-03-05T01:05:00-0800", + "unit": "mg/dL" + }, + { + "value": -347.7575443621537, + "startDate": "2016-03-05T01:10:00-0800", + "unit": "mg/dL" + }, + { + "value": -352.7116703841897, + "startDate": "2016-03-05T01:15:00-0800", + "unit": "mg/dL" + }, + { + "value": -357.6053319159832, + "startDate": "2016-03-05T01:20:00-0800", + "unit": "mg/dL" + }, + { + "value": -362.4252842472219, + "startDate": "2016-03-05T01:25:00-0800", + "unit": "mg/dL" + }, + { + "value": -367.1551449645343, + "startDate": "2016-03-05T01:30:00-0800", + "unit": "mg/dL" + }, + { + "value": -371.7924658623842, + "startDate": "2016-03-05T01:35:00-0800", + "unit": "mg/dL" + }, + { + "value": -376.3227442729362, + "startDate": "2016-03-05T01:40:00-0800", + "unit": "mg/dL" + }, + { + "value": -380.7370617907551, + "startDate": "2016-03-05T01:45:00-0800", + "unit": "mg/dL" + }, + { + "value": -385.0276152484062, + "startDate": "2016-03-05T01:50:00-0800", + "unit": "mg/dL" + }, + { + "value": -389.187729956454, + "startDate": "2016-03-05T01:55:00-0800", + "unit": "mg/dL" + }, + { + "value": -393.2118729434634, + "startDate": "2016-03-05T02:00:00-0800", + "unit": "mg/dL" + }, + { + "value": -397.0956661959993, + "startDate": "2016-03-05T02:05:00-0800", + "unit": "mg/dL" + }, + { + "value": -400.8358998986266, + "startDate": "2016-03-05T02:10:00-0800", + "unit": "mg/dL" + }, + { + "value": -404.4305456739103, + "startDate": "2016-03-05T02:15:00-0800", + "unit": "mg/dL" + }, + { + "value": -407.8711913354906, + "startDate": "2016-03-05T02:20:00-0800", + "unit": "mg/dL" + }, + { + "value": -411.1887683152845, + "startDate": "2016-03-05T02:25:00-0800", + "unit": "mg/dL" + }, + { + "value": -414.2483273403853, + "startDate": "2016-03-05T02:30:00-0800", + "unit": "mg/dL" + }, + { + "value": -417.237074474721, + "startDate": "2016-03-05T02:35:00-0800", + "unit": "mg/dL" + }, + { + "value": -419.3124522762179, + "startDate": "2016-03-05T02:40:00-0800", + "unit": "mg/dL" + }, + { + "value": -421.1959605419546, + "startDate": "2016-03-05T02:45:00-0800", + "unit": "mg/dL" + }, + { + "value": -422.9152625517117, + "startDate": "2016-03-05T02:50:00-0800", + "unit": "mg/dL" + }, + { + "value": -424.4368282040283, + "startDate": "2016-03-05T02:55:00-0800", + "unit": "mg/dL" + }, + { + "value": -425.7898805619185, + "startDate": "2016-03-05T03:00:00-0800", + "unit": "mg/dL" + }, + { + "value": -426.9822978697815, + "startDate": "2016-03-05T03:05:00-0800", + "unit": "mg/dL" + }, + { + "value": -428.0219419720182, + "startDate": "2016-03-05T03:10:00-0800", + "unit": "mg/dL" + }, + { + "value": -428.9115794359523, + "startDate": "2016-03-05T03:15:00-0800", + "unit": "mg/dL" + }, + { + "value": -429.7051332223956, + "startDate": "2016-03-05T03:20:00-0800", + "unit": "mg/dL" + }, + { + "value": -430.3667421802121, + "startDate": "2016-03-05T03:25:00-0800", + "unit": "mg/dL" + }, + { + "value": -430.9039751398021, + "startDate": "2016-03-05T03:30:00-0800", + "unit": "mg/dL" + }, + { + "value": -431.3241726915655, + "startDate": "2016-03-05T03:35:00-0800", + "unit": "mg/dL" + }, + { + "value": -431.629368308826, + "startDate": "2016-03-05T03:40:00-0800", + "unit": "mg/dL" + }, + { + "value": -431.8675074813201, + "startDate": "2016-03-05T03:45:00-0800", + "unit": "mg/dL" + }, + { + "value": -432.0478052203234, + "startDate": "2016-03-05T03:50:00-0800", + "unit": "mg/dL" + }, + { + "value": -432.128033449624, + "startDate": "2016-03-05T03:55:00-0800", + "unit": "mg/dL" + }, + { + "value": -432.1503561538819, + "startDate": "2016-03-05T04:00:00-0800", + "unit": "mg/dL" + }, + { + "value": -432.1593079386333, + "startDate": "2016-03-05T04:05:00-0800", + "unit": "mg/dL" + }, + { + "value": -432.1559703692269, + "startDate": "2016-03-05T04:10:00-0800", + "unit": "mg/dL" + }, + { + "value": -432.1487180963159, + "startDate": "2016-03-05T04:15:00-0800", + "unit": "mg/dL" + }, + { + "value": -432.1204246460239, + "startDate": "2016-03-05T04:20:00-0800", + "unit": "mg/dL" + }, + { + "value": -432.1333333333329, + "startDate": "2016-03-05T04:25:00-0800", + "unit": "mg/dL" + }, + { + "value": -432.1333333333329, + "startDate": "2016-03-05T04:30:00-0800", + "unit": "mg/dL" + } +] \ No newline at end of file diff --git a/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_non_zero_output.json b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_non_zero_output.json new file mode 100644 index 0000000..12e9d00 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/glucose_from_effects_non_zero_output.json @@ -0,0 +1,277 @@ +[ + { + "startDate": "2016-03-05T00:03:14-0800", + "unit": "mg/dL", + "value": 178 + }, + { + "startDate": "2016-03-05T00:05:00-0800", + "unit": "mg/dL", + "value": 178 + }, + { + "startDate": "2016-03-05T00:10:00-0800", + "unit": "mg/dL", + "value": 181.7740384031924 + }, + { + "startDate": "2016-03-05T00:15:00-0800", + "unit": "mg/dL", + "value": 184.5509412540002 + }, + { + "startDate": "2016-03-05T00:20:00-0800", + "unit": "mg/dL", + "value": 186.3549724334562 + }, + { + "startDate": "2016-03-05T00:25:00-0800", + "unit": "mg/dL", + "value": 187.2017658669863 + }, + { + "startDate": "2016-03-05T00:30:00-0800", + "unit": "mg/dL", + "value": 186.7973828255941 + }, + { + "startDate": "2016-03-05T00:35:00-0800", + "unit": "mg/dL", + "value": 185.6710903559803 + }, + { + "startDate": "2016-03-05T00:40:00-0800", + "unit": "mg/dL", + "value": 185.3849156262279 + }, + { + "startDate": "2016-03-05T00:45:00-0800", + "unit": "mg/dL", + "value": 185.2782669011887 + }, + { + "startDate": "2016-03-05T00:50:00-0800", + "unit": "mg/dL", + "value": 184.5540996101439 + }, + { + "startDate": "2016-03-05T00:55:00-0800", + "unit": "mg/dL", + "value": 183.2725350299922 + }, + { + "startDate": "2016-03-05T01:00:00-0800", + "unit": "mg/dL", + "value": 181.4404570407656 + }, + { + "startDate": "2016-03-05T01:05:00-0800", + "unit": "mg/dL", + "value": 178.9723666142475 + }, + { + "startDate": "2016-03-05T01:10:00-0800", + "unit": "mg/dL", + "value": 175.8838758679096 + }, + { + "startDate": "2016-03-05T01:15:00-0800", + "unit": "mg/dL", + "value": 172.1823332935188 + }, + { + "startDate": "2016-03-05T01:20:00-0800", + "unit": "mg/dL", + "value": 167.9618093931839 + }, + { + "startDate": "2016-03-05T01:25:00-0800", + "unit": "mg/dL", + "value": 163.5661826220732 + }, + { + "startDate": "2016-03-05T01:30:00-0800", + "unit": "mg/dL", + "value": 159.0411687268914 + }, + { + "startDate": "2016-03-05T01:35:00-0800", + "unit": "mg/dL", + "value": 154.424454443887 + }, + { + "startDate": "2016-03-05T01:40:00-0800", + "unit": "mg/dL", + "value": 149.894176033335 + }, + { + "startDate": "2016-03-05T01:45:00-0800", + "unit": "mg/dL", + "value": 145.4798585155161 + }, + { + "startDate": "2016-03-05T01:50:00-0800", + "unit": "mg/dL", + "value": 141.189305057865 + }, + { + "startDate": "2016-03-05T01:55:00-0800", + "unit": "mg/dL", + "value": 137.0291903498172 + }, + { + "startDate": "2016-03-05T02:00:00-0800", + "unit": "mg/dL", + "value": 133.0050473628078 + }, + { + "startDate": "2016-03-05T02:05:00-0800", + "unit": "mg/dL", + "value": 129.1212541102719 + }, + { + "startDate": "2016-03-05T02:10:00-0800", + "unit": "mg/dL", + "value": 125.3810204076446 + }, + { + "startDate": "2016-03-05T02:15:00-0800", + "unit": "mg/dL", + "value": 121.786374632361 + }, + { + "startDate": "2016-03-05T02:20:00-0800", + "unit": "mg/dL", + "value": 118.3457289707806 + }, + { + "startDate": "2016-03-05T02:25:00-0800", + "unit": "mg/dL", + "value": 115.0281519909867 + }, + { + "startDate": "2016-03-05T02:30:00-0800", + "unit": "mg/dL", + "value": 111.968592965886 + }, + { + "startDate": "2016-03-05T02:35:00-0800", + "unit": "mg/dL", + "value": 108.9798458315503 + }, + { + "startDate": "2016-03-05T02:40:00-0800", + "unit": "mg/dL", + "value": 106.9044680300533 + }, + { + "startDate": "2016-03-05T02:45:00-0800", + "unit": "mg/dL", + "value": 105.0209597643166 + }, + { + "startDate": "2016-03-05T02:50:00-0800", + "unit": "mg/dL", + "value": 103.3016577545596 + }, + { + "startDate": "2016-03-05T02:55:00-0800", + "unit": "mg/dL", + "value": 101.7800921022429 + }, + { + "startDate": "2016-03-05T03:00:00-0800", + "unit": "mg/dL", + "value": 100.4270397443528 + }, + { + "startDate": "2016-03-05T03:05:00-0800", + "unit": "mg/dL", + "value": 99.23462243648973 + }, + { + "startDate": "2016-03-05T03:10:00-0800", + "unit": "mg/dL", + "value": 98.19497833425302 + }, + { + "startDate": "2016-03-05T03:15:00-0800", + "unit": "mg/dL", + "value": 97.30534087031887 + }, + { + "startDate": "2016-03-05T03:20:00-0800", + "unit": "mg/dL", + "value": 96.51178708387565 + }, + { + "startDate": "2016-03-05T03:25:00-0800", + "unit": "mg/dL", + "value": 95.85017812605915 + }, + { + "startDate": "2016-03-05T03:30:00-0800", + "unit": "mg/dL", + "value": 95.31294516646912 + }, + { + "startDate": "2016-03-05T03:35:00-0800", + "unit": "mg/dL", + "value": 94.89274761470577 + }, + { + "startDate": "2016-03-05T03:40:00-0800", + "unit": "mg/dL", + "value": 94.58755199744527 + }, + { + "startDate": "2016-03-05T03:45:00-0800", + "unit": "mg/dL", + "value": 94.34941282495114 + }, + { + "startDate": "2016-03-05T03:50:00-0800", + "unit": "mg/dL", + "value": 94.16911508594782 + }, + { + "startDate": "2016-03-05T03:55:00-0800", + "unit": "mg/dL", + "value": 94.08888685664724 + }, + { + "startDate": "2016-03-05T04:00:00-0800", + "unit": "mg/dL", + "value": 94.06656415238936 + }, + { + "startDate": "2016-03-05T04:05:00-0800", + "unit": "mg/dL", + "value": 94.05761236763797 + }, + { + "startDate": "2016-03-05T04:10:00-0800", + "unit": "mg/dL", + "value": 94.06094993704434 + }, + { + "startDate": "2016-03-05T04:15:00-0800", + "unit": "mg/dL", + "value": 94.06820220995527 + }, + { + "startDate": "2016-03-05T04:20:00-0800", + "unit": "mg/dL", + "value": 94.09649566024731 + }, + { + "startDate": "2016-03-05T04:25:00-0800", + "unit": "mg/dL", + "value": 94.08358697293832 + }, + { + "startDate": "2016-03-05T04:30:00-0800", + "unit": "mg/dL", + "value": 94.08358697293832 + } +] \ No newline at end of file diff --git a/Tests/LoopAlgorithmTests/Fixtures/ice_minus_carb_effect_with_gaps_output.json b/Tests/LoopAlgorithmTests/Fixtures/ice_minus_carb_effect_with_gaps_output.json new file mode 100644 index 0000000..72af9c8 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/ice_minus_carb_effect_with_gaps_output.json @@ -0,0 +1,112 @@ +[ + { + "unit" : "mg\/dL", + "date" : "2018-08-16 01:13:44 +0000", + "value" : -7.9146779853452083 + }, + { + "unit" : "mg\/dL", + "date" : "2018-08-16 01:18:44 +0000", + "value" : -5.9281656186007314 + }, + { + "unit" : "mg\/dL", + "date" : "2018-08-16 01:23:44 +0000", + "value" : 1.2296261019331554 + }, + { + "unit" : "mg\/dL", + "date" : "2018-08-16 01:28:43 +0000", + "value" : 1.4065989188792711 + }, + { + "unit" : "mg\/dL", + "date" : "2018-08-16 02:03:43 +0000", + "value" : 6.0711968367636908 + }, + { + "unit" : "mg\/dL", + "date" : "2018-08-16 03:13:44 +0000", + "value" : 1.7358764710254451 + }, + { + "unit" : "mg\/dL", + "date" : "2018-08-16 03:18:44 +0000", + "value" : 0.5343295138009645 + }, + { + "unit" : "mg\/dL", + "date" : "2018-08-16 03:23:43 +0000", + "value" : 1.4847123043985215 + }, + { + "unit" : "mg\/dL", + "date" : "2018-08-16 03:28:44 +0000", + "value" : 1.6893378641238699 + }, + { + "unit" : "mg\/dL", + "date" : "2018-08-16 03:33:43 +0000", + "value" : 6.042541334805744 + }, + { + "unit" : "mg\/dL", + "date" : "2018-08-16 03:38:43 +0000", + "value" : -9.6914450613633392 + }, + { + "unit" : "mg\/dL", + "date" : "2018-08-16 03:43:44 +0000", + "value" : 0.58142124976613774 + }, + { + "unit" : "mg\/dL", + "date" : "2018-08-16 03:48:43 +0000", + "value" : -7.1923719688531209 + }, + { + "unit" : "mg\/dL", + "date" : "2018-08-16 03:53:44 +0000", + "value" : -2.9941217788457295 + }, + { + "unit" : "mg\/dL", + "date" : "2018-08-16 03:58:43 +0000", + "value" : -11.960291314911359 + }, + { + "unit" : "mg\/dL", + "date" : "2018-08-16 04:03:43 +0000", + "value" : -1.2512705141339 + }, + { + "unit" : "mg\/dL", + "date" : "2018-08-16 04:08:43 +0000", + "value" : -0.0014915619982254213 + }, + { + "unit" : "mg\/dL", + "date" : "2018-08-16 04:13:44 +0000", + "value" : -0.0056015222217293825 + }, + { + "unit" : "mg\/dL", + "date" : "2018-08-16 04:18:43 +0000", + "value" : 0.0052760433631586778 + }, + { + "unit" : "mg\/dL", + "date" : "2018-08-16 04:23:44 +0000", + "value" : -0.0051133014060256166 + }, + { + "unit" : "mg\/dL", + "date" : "2018-08-16 04:28:44 +0000", + "value" : 0.0025222980026429198 + }, + { + "unit" : "mg\/dL", + "date" : "2018-08-16 04:33:43 +0000", + "value" : -1.7950225310593533 + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/ice_minus_flat_carb_effect_output.json b/Tests/LoopAlgorithmTests/Fixtures/ice_minus_flat_carb_effect_output.json new file mode 100644 index 0000000..26c5d73 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/ice_minus_flat_carb_effect_output.json @@ -0,0 +1,42 @@ +[ + { + "value" : 3.6517114683289944, + "date" : "2018-08-26 00:48:28 +0000", + "unit" : "mg\/dL" + }, + { + "value" : 3.5831456523646765, + "date" : "2018-08-26 00:53:28 +0000", + "unit" : "mg\/dL" + }, + { + "value" : 1.4811396601622884, + "date" : "2018-08-26 00:58:28 +0000", + "unit" : "mg\/dL" + }, + { + "value" : 1.3650980083318285, + "date" : "2018-08-26 01:03:27 +0000", + "unit" : "mg\/dL" + }, + { + "value" : 2.2302804679379356, + "date" : "2018-08-26 01:08:28 +0000", + "unit" : "mg\/dL" + }, + { + "value" : 2.1065985817633233, + "date" : "2018-08-26 01:13:28 +0000", + "unit" : "mg\/dL" + }, + { + "value" : 4.974146844737705, + "date" : "2018-08-26 01:18:27 +0000", + "unit" : "mg\/dL" + }, + { + "value" : 4.8261191053223191, + "date" : "2018-08-26 01:23:28 +0000", + "unit" : "mg\/dL" + } +] diff --git a/Tests/LoopAlgorithmTests/GlucoseMathTests.swift b/Tests/LoopAlgorithmTests/GlucoseMathTests.swift index 8cb1a4f..775ca7a 100644 --- a/Tests/LoopAlgorithmTests/GlucoseMathTests.swift +++ b/Tests/LoopAlgorithmTests/GlucoseMathTests.swift @@ -17,21 +17,20 @@ extension XCTestCase { public struct GlucoseFixtureValue: GlucoseSampleValue { public let startDate: Date - public let quantity: LoopQuantity + let quantity: LoopQuantity + public var mgdl: Double { quantity.doubleValue(for: .milligramsPerDeciliter) } public let isDisplayOnly: Bool public let wasUserEntered: Bool public let provenanceIdentifier: String - public let condition: GlucoseCondition? - public let trendRate: LoopQuantity? + let trendRate: LoopQuantity? public var syncIdentifier: String? - public init(startDate: Date, quantity: LoopQuantity, isDisplayOnly: Bool, wasUserEntered: Bool, provenanceIdentifier: String?, condition: GlucoseCondition?, trendRate: LoopQuantity?) { + init(startDate: Date, quantity: LoopQuantity, isDisplayOnly: Bool, wasUserEntered: Bool, provenanceIdentifier: String?, trendRate: LoopQuantity?) { self.startDate = startDate self.quantity = quantity self.isDisplayOnly = isDisplayOnly self.wasUserEntered = wasUserEntered self.provenanceIdentifier = provenanceIdentifier ?? "com.loopkit.LoopKitTests" - self.condition = condition self.trendRate = trendRate } } @@ -49,10 +48,10 @@ final class GlucoseMathTests: XCTestCase { private struct MockGlucoseSample: GlucoseSampleValue { var startDate: Date var quantity: LoopQuantity + var mgdl: Double { quantity.doubleValue(for: .milligramsPerDeciliter) } var provenanceIdentifier: String var isDisplayOnly: Bool var wasUserEntered: Bool - var condition: GlucoseCondition? var trendRate: LoopQuantity? } @@ -66,7 +65,6 @@ final class GlucoseMathTests: XCTestCase { provenanceIdentifier: provenance, isDisplayOnly: displayOnly, wasUserEntered: false, - condition: nil, trendRate: nil ) } @@ -213,7 +211,6 @@ final class GlucoseMathTests: XCTestCase { isDisplayOnly: ($0["display_only"] as? Bool) ?? false, wasUserEntered: ($0["user_entered"] as? Bool) ?? false, provenanceIdentifier: $0["provenance_identifier"] as? String, - condition: ($0["condition"] as? String).flatMap { GlucoseCondition(rawValue: $0) }, trendRate: ($0["trend_rate"] as? Double).flatMap { LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: $0) } ) } @@ -429,7 +426,6 @@ final class GlucoseMathTests: XCTestCase { isDisplayOnly: false, wasUserEntered: true, provenanceIdentifier: nil, - condition: nil, trendRate: nil ) } diff --git a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift index de2053e..152ee7a 100644 --- a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift +++ b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift @@ -49,7 +49,7 @@ final class LoopAlgorithmTests: XCTestCase { let (input, recommendation) = loadScenario("suspend") - let output = LoopAlgorithm.run(input: input) + let output = Algorithm.run(input: input) XCTAssertEqual(output.recommendation, recommendation) } @@ -62,7 +62,7 @@ final class LoopAlgorithmTests: XCTestCase { let (input, recommendation) = loadScenario("carbs_with_isf_change") - let output = LoopAlgorithm.run(input: input) + let output = Algorithm.run(input: input) XCTAssertEqual(output.recommendation, recommendation) } @@ -73,7 +73,7 @@ final class LoopAlgorithmTests: XCTestCase { input.recommendationType = .manualBolus input.carbEntries.append(FixtureCarbEntry(absorptionTime: .hours(6), startDate: now, quantity: .carbs(50))) - let output = LoopAlgorithm.run(input: input) + let output = Algorithm.run(input: input) XCTAssertEqual(output.activeCarbs, 50) XCTAssertEqual(output.recommendation!.manual!.amount, 5.86, accuracy: 0.01) @@ -100,8 +100,8 @@ final class LoopAlgorithmTests: XCTestCase { ) - let outputA = LoopAlgorithm.run(input: a) - let outputB = LoopAlgorithm.run(input: b) + let outputA = Algorithm.run(input: a) + let outputB = Algorithm.run(input: b) XCTAssertEqual(outputA.activeCarbs, outputB.activeCarbs) XCTAssertEqual(outputA.activeInsulin, outputB.activeInsulin) @@ -148,7 +148,7 @@ final class LoopAlgorithmTests: XCTestCase { FixtureGlucoseSample(startDate: now.addingTimeInterval(.minutes(-3)), quantity: .glucose(145)), ] - let output = LoopAlgorithm.run(input: input) + let output = Algorithm.run(input: input) let carbStatus = output.effects.carbStatus.first! XCTAssertEqual(carbStatus.absorption!.observedProgress.doubleValue(for: .percent), 0.36, accuracy: 0.01) @@ -167,13 +167,13 @@ final class LoopAlgorithmTests: XCTestCase { let url = Bundle.module.url(forResource: "live_capture_input", withExtension: "json", subdirectory: "Fixtures")! let input = try! decoder.decode(LoopPredictionInput.self, from: try! Data(contentsOf: url)) - let prediction = LoopAlgorithm.generatePrediction( + let prediction = Algorithm.generatePrediction( start: input.glucoseHistory.last?.startDate ?? Date(), glucoseHistory: input.glucoseHistory, doses: input.doses, carbEntries: input.carbEntries, basal: input.basal, - sensitivity: input.sensitivity, + sensitivityMgdlPerUnit: input.sensitivityMgdlPerUnit, carbRatio: input.carbRatio, useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection ) @@ -207,12 +207,12 @@ final class LoopAlgorithmTests: XCTestCase { ] // With a spurious reading over the gradualTransitionsThreshold of 40 mg/dL, momentum and rc are turned off, and the forecast is lower. - var output = LoopAlgorithm.run(input: input) + var output = Algorithm.run(input: input) XCTAssertEqual(output.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 164.5, accuracy: 0.1) // With the threshold set high, our spurious reading is still considered gradual, and RC and momentum will be used, resulting in a higher forecast. input.gradualTransitionsThreshold = 60 - output = LoopAlgorithm.run(input: input) + output = Algorithm.run(input: input) XCTAssertEqual(output.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 216, accuracy: 0.1) } @@ -225,7 +225,7 @@ final class LoopAlgorithmTests: XCTestCase { let url = Bundle.module.url(forResource: "meal-bolus-isf", withExtension: "json", subdirectory: "Fixtures")! var input = try! decoder.decode(AlgorithmInputFixture.self, from: try! Data(contentsOf: url)) - let output = LoopAlgorithm.run(input: input) + let output = Algorithm.run(input: input) // Should recommend bolus to cover meal XCTAssertEqual(output.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 274.14, accuracy: 0.1) @@ -241,7 +241,7 @@ final class LoopAlgorithmTests: XCTestCase { ) ) - let output2 = LoopAlgorithm.run(input: input) + let output2 = Algorithm.run(input: input) // 150 mg/dL is the middle of the target range XCTAssertEqual(output2.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 150, accuracy: 0.1) @@ -256,7 +256,7 @@ final class LoopAlgorithmTests: XCTestCase { var input = try! decoder.decode( AlgorithmInputFixture.self, from: try! Data(contentsOf: url)) - let output = LoopAlgorithm.run(input: input) + let output = Algorithm.run(input: input) // Should recommend bolus to cover meal XCTAssertEqual(output.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 269.15, accuracy: 0.1) @@ -272,7 +272,7 @@ final class LoopAlgorithmTests: XCTestCase { ) ) - let output2 = LoopAlgorithm.run(input: input) + let output2 = Algorithm.run(input: input) // 150 mg/dL is the middle of the target range XCTAssertEqual(output2.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 150, accuracy: 0.1) @@ -291,18 +291,18 @@ final class LoopAlgorithmTests: XCTestCase { let isfEnd = now.addingTimeInterval(.hours(24)) // ISF Changing mid-aborption - input.sensitivity = [ - AbsoluteScheduleValue(startDate: isfStart, endDate: isfMid, value: .glucose(50)), - AbsoluteScheduleValue(startDate: isfMid, endDate: isfEnd, value: .glucose(100)) + input.sensitivityMgdlPerUnit = [ + AbsoluteScheduleValue(startDate: isfStart, endDate: isfMid, value: 50), + AbsoluteScheduleValue(startDate: isfMid, endDate: isfEnd, value: 100) ] // With Mid-absorption ISF flag = false - var output = LoopAlgorithm.run(input: input) + var output = Algorithm.run(input: input) XCTAssertEqual(output.effects.insulin.last!.quantity.doubleValue(for: .milligramsPerDeciliter), -50, accuracy: 0.5) // With Mid-absorption ISF flag = true input.useMidAbsorptionISF = true - output = LoopAlgorithm.run(input: input) + output = Algorithm.run(input: input) XCTAssertEqual(output.effects.insulin.last!.quantity.doubleValue(for: .milligramsPerDeciliter), -83, accuracy: 0.5) } @@ -325,7 +325,7 @@ final class LoopAlgorithmTests: XCTestCase { // Max activeInsulin = 2 x maxBolus = 16U input.maxBolus = 8 - var output = LoopAlgorithm.run(input: input) + var output = Algorithm.run(input: input) var recommendedBolus = output.recommendation!.automatic?.bolusUnits var activeInsulin = output.activeInsulin! XCTAssertEqual(activeInsulin, 8.0) @@ -333,7 +333,7 @@ final class LoopAlgorithmTests: XCTestCase { // Now try with maxBolus of 4; should not recommend any more insulin, as we're at our max iob input.maxBolus = 4 - output = LoopAlgorithm.run(input: input) + output = Algorithm.run(input: input) recommendedBolus = output.recommendation!.automatic?.bolusUnits activeInsulin = output.activeInsulin! XCTAssertEqual(activeInsulin, 8.0) @@ -360,7 +360,7 @@ final class LoopAlgorithmTests: XCTestCase { // Max activeInsulin = 2 x maxBolus = 16U input.maxBolus = 8 - var output = LoopAlgorithm.run(input: input) + var output = Algorithm.run(input: input) var recommendedRate = output.recommendation!.automatic!.basalAdjustment.unitsPerHour var activeInsulin = output.activeInsulin! XCTAssertEqual(activeInsulin, 8.0) @@ -368,7 +368,7 @@ final class LoopAlgorithmTests: XCTestCase { // Now try with maxBolus of 4; should only recommend scheduled basal (1U/hr), as we're at our max iob input.maxBolus = 4 - output = LoopAlgorithm.run(input: input) + output = Algorithm.run(input: input) recommendedRate = output.recommendation!.automatic!.basalAdjustment.unitsPerHour activeInsulin = output.activeInsulin! XCTAssertEqual(activeInsulin, 8.0) @@ -383,9 +383,9 @@ final class LoopAlgorithmTests: XCTestCase { // Sensitivity doubles in one hour let isfChangeTime = now.addingTimeInterval(.hours(1)) - input.sensitivity = [ - AbsoluteScheduleValue(startDate: now.addingTimeInterval(.hours(-48)), endDate: isfChangeTime, value: .glucose(50)), - AbsoluteScheduleValue(startDate: isfChangeTime, endDate: now.addingTimeInterval(.hours(48)), value: .glucose(100)) + input.sensitivityMgdlPerUnit = [ + AbsoluteScheduleValue(startDate: now.addingTimeInterval(.hours(-48)), endDate: isfChangeTime, value: 50), + AbsoluteScheduleValue(startDate: isfChangeTime, endDate: now.addingTimeInterval(.hours(48)), value: 100) ] // No insulin or carbs @@ -396,12 +396,12 @@ final class LoopAlgorithmTests: XCTestCase { // Without mid-absorption ISF input.useMidAbsorptionISF = false - var output = LoopAlgorithm.run(input: input) + var output = Algorithm.run(input: input) XCTAssertEqual(2.73, output.recommendation!.manual!.amount, accuracy: 0.01) // With mid-absorption ISF input.useMidAbsorptionISF = true - output = LoopAlgorithm.run(input: input) + output = Algorithm.run(input: input) XCTAssertEqual(1.49, output.recommendation!.manual!.amount, accuracy: 0.01) } @@ -424,25 +424,25 @@ final class LoopAlgorithmTests: XCTestCase { ] // Sensitivity doesn't cover start of basal dose - input.sensitivity = [ - AbsoluteScheduleValue(startDate: now, endDate: now.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration).dateCeiledToTimeInterval(GlucoseMath.defaultDelta), value: .glucose(50)), + input.sensitivityMgdlPerUnit = [ + AbsoluteScheduleValue(startDate: now, endDate: now.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration).dateCeiledToTimeInterval(GlucoseMath.defaultDelta), value: 50), ] - var output = LoopAlgorithm.run(input: input) + var output = Algorithm.run(input: input) guard case .failure(AlgorithmError.sensitivityTimelineStartsTooLate) = output.recommendationResult else { XCTFail("Expected sensitivityTimelineStartsTooLate failure") return } // Sensitivity does cover start of basal dose, but ends before temp basal effects end - input.sensitivity = [ + input.sensitivityMgdlPerUnit = [ AbsoluteScheduleValue( startDate: basalStart.dateFlooredToTimeInterval(GlucoseMath.defaultDelta), endDate: now.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration).dateCeiledToTimeInterval(GlucoseMath.defaultDelta), - value: .glucose(50) + value: 50 ), ] - output = LoopAlgorithm.run(input: input) + output = Algorithm.run(input: input) guard case .failure(AlgorithmError.sensitivityTimelineEndsTooEarly) = output.recommendationResult else { XCTFail("Expected sensitivityTimelineEndsTooEarly failure") return @@ -454,19 +454,19 @@ final class LoopAlgorithmTests: XCTestCase { start: input.predictionStart, duration: input.recommendationInsulinModel.effectDuration ) - let neededISFInterval = LoopAlgorithm.timelineIntervalForSensitivity( + let neededISFInterval = Algorithm.timelineIntervalForSensitivity( doses: input.doses, glucoseHistoryStart: input.glucoseHistory.first!.startDate, recommendationEffectInterval: recommendationEffectInterval ) - input.sensitivity = [ + input.sensitivityMgdlPerUnit = [ AbsoluteScheduleValue( startDate: neededISFInterval.start, endDate: neededISFInterval.end, - value: .glucose(50) + value: 50 ) ] - output = LoopAlgorithm.run(input: input) + output = Algorithm.run(input: input) guard case .success = output.recommendationResult else { XCTFail("Expected recommendationResult success") return @@ -481,11 +481,11 @@ final class LoopAlgorithmTests: XCTestCase { var input = try! decoder.decode(AlgorithmInputFixture.self, from: try! Data(contentsOf: url)) // A carb entry older than the start of the carb ratio and ISF timelines - let timelinesStart = min(input.carbRatio.first!.startDate, input.sensitivity.first!.startDate) + let timelinesStart = min(input.carbRatio.first!.startDate, input.sensitivityMgdlPerUnit.first!.startDate) let entryDate = timelinesStart.addingTimeInterval(.minutes(-30)) input.carbEntries.insert(FixtureCarbEntry(absorptionTime: .hours(3), startDate: entryDate, quantity: .carbs(20)), at: 0) - var output = LoopAlgorithm.run(input: input) + var output = Algorithm.run(input: input) guard case .failure(AlgorithmError.carbRatioOrSensitivityStartsAfterCarbEntry) = output.recommendationResult else { XCTFail("Expected carbRatioOrSensitivityStartsAfterCarbEntry failure") return @@ -494,15 +494,15 @@ final class LoopAlgorithmTests: XCTestCase { // Extend both timelines back to the entry let firstCarbRatio = input.carbRatio.first! input.carbRatio.insert(AbsoluteScheduleValue(startDate: entryDate, endDate: firstCarbRatio.startDate, value: firstCarbRatio.value), at: 0) - output = LoopAlgorithm.run(input: input) + output = Algorithm.run(input: input) guard case .failure(AlgorithmError.carbRatioOrSensitivityStartsAfterCarbEntry) = output.recommendationResult else { XCTFail("Expected failure while ISF still starts after the entry") return } - let firstSensitivity = input.sensitivity.first! - input.sensitivity.insert(AbsoluteScheduleValue(startDate: entryDate, endDate: firstSensitivity.startDate, value: firstSensitivity.value), at: 0) - output = LoopAlgorithm.run(input: input) + let firstSensitivity = input.sensitivityMgdlPerUnit.first! + input.sensitivityMgdlPerUnit.insert(AbsoluteScheduleValue(startDate: entryDate, endDate: firstSensitivity.startDate, value: firstSensitivity.value), at: 0) + output = Algorithm.run(input: input) guard case .success = output.recommendationResult else { XCTFail("Expected recommendationResult success") return @@ -528,16 +528,16 @@ final class LoopAlgorithmTests: XCTestCase { // Sensitivity doesn't cover forecast - input.sensitivity = [ + input.sensitivityMgdlPerUnit = [ AbsoluteScheduleValue( startDate: basalStart.dateFlooredToTimeInterval(GlucoseMath.defaultDelta), endDate: now.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration).dateCeiledToTimeInterval(GlucoseMath.defaultDelta), - value: .glucose(50) + value: 50 ), ] input.useMidAbsorptionISF = true - let output = LoopAlgorithm.run(input: input) + let output = Algorithm.run(input: input) guard case .failure(AlgorithmError.sensitivityTimelineEndsTooEarly) = output.recommendationResult else { XCTFail("Expected sensitivityTimelineEndsTooEarly failure") return @@ -567,7 +567,7 @@ final class LoopAlgorithmTests: XCTestCase { FixtureGlucoseSample(startDate: now.addingTimeInterval(.minutes(-0)), quantity: .glucose(120)), ] - var output = LoopAlgorithm.run(input: input) + var output = Algorithm.run(input: input) let basalAdjustment = output.recommendation!.automatic!.basalAdjustment XCTAssertEqual(basalAdjustment.unitsPerHour, 4.94, accuracy: 0.01) @@ -585,12 +585,12 @@ final class LoopAlgorithmTests: XCTestCase { input.predictionStart = input.predictionStart.addingTimeInterval(30) // Add more time to insulin sensitivity timeline to account for running temp basal - input.sensitivity[0].endDate = input.sensitivity[0].endDate.addingTimeInterval(.hours(1)) + input.sensitivityMgdlPerUnit[0].endDate = input.sensitivityMgdlPerUnit[0].endDate.addingTimeInterval(.hours(1)) // Manual recommendation does not cut off doses in progress, like a running temp basal input.recommendationType = .manualBolus - output = LoopAlgorithm.run(input: input) + output = Algorithm.run(input: input) XCTAssertEqual(output.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 105, accuracy: 0.5) } } diff --git a/Tests/LoopAlgorithmTests/LoopMathEffectsTests.swift b/Tests/LoopAlgorithmTests/LoopMathEffectsTests.swift new file mode 100644 index 0000000..9e8c089 --- /dev/null +++ b/Tests/LoopAlgorithmTests/LoopMathEffectsTests.swift @@ -0,0 +1,522 @@ +// +// LoopMathEffectsTests.swift +// LoopAlgorithm +// +// Created by Nathan Racklyeft on 3/2/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import XCTest +@testable import LoopAlgorithm + + +typealias RecentGlucoseValue = PredictedGlucoseValue + + +class LoopMathEffectsTests: XCTestCase { + + func loadGlucoseEffectFixture(_ resourceName: String, formatter: ISO8601DateFormatter) -> [GlucoseEffect] { + let fixture: [JSONDictionary] = loadFixture(resourceName) + + return fixture.map { + return GlucoseEffect(startDate: formatter.date(from: $0["date"] as! String)!, quantity: LoopQuantity(unit: LoopUnit(from: $0["unit"] as! String), doubleValue:$0["amount"] as! Double)) + } + } + + func loadGlucoseEffectFixture(_ resourceName: String, formatter: DateFormatter) -> [GlucoseEffect] { + let fixture: [JSONDictionary] = loadFixture(resourceName) + + return fixture.map { + return GlucoseEffect(startDate: formatter.date(from: $0["date"] as! String)!, quantity: LoopQuantity(unit: LoopUnit(from: $0["unit"] as! String), doubleValue: $0["value"] as! Double)) + } + } + + private func printFixture(_ glucoseEffect: [GlucoseEffect]) { + let unit = LoopUnit.milligramsPerDeciliter + + print("\n\n") + print(String(data: try! JSONSerialization.data( + withJSONObject: glucoseEffect.map({ (value) -> [String: Any] in + return [ + "date": String(describing: value.startDate), + "value": value.quantity.doubleValue(for: unit), + "unit": unit.unitString + ] + }), + options: .prettyPrinted), encoding: .utf8)!) + print("\n\n") + } + + func loadSampleValueFixture(_ resourceName: String) -> [(startDate: Date, quantity: LoopQuantity)] { + let fixture: [JSONDictionary] = loadFixture(resourceName) + let dateFormatter = ISO8601DateFormatter() + + return fixture.map { + (dateFormatter.date(from: $0["startDate"] as! String)!, LoopQuantity(unit: LoopUnit(from: $0["unit"] as! String), doubleValue: $0["value"] as! Double)) + } + } + + func loadGlucoseHistoryFixture(_ resourceName: String) -> RecentGlucoseValue { + let fixture: [JSONDictionary] = loadFixture(resourceName) + let dateFormatter = ISO8601DateFormatter.localTimeDate() + + return fixture.map { + return RecentGlucoseValue(startDate: dateFormatter.date(from: $0["display_time"] as! String)!, quantity: LoopQuantity(unit: LoopUnit.milligramsPerDeciliter, doubleValue:$0["glucose"] as! Double)) + }.first! + } + + func loadGlucoseValueFixture(_ resourceName: String) -> [PredictedGlucoseValue] { + let fixture: [JSONDictionary] = loadFixture(resourceName) + let dateFormatter = ISO8601DateFormatter.localTimeDate() + + return fixture.map { + return PredictedGlucoseValue(startDate: dateFormatter.date(from: $0["date"] as! String)!, quantity: LoopQuantity(unit: LoopUnit(from: $0["unit"] as! String), doubleValue:$0["amount"] as! Double)) + } + } + + lazy var carbEffect: [GlucoseEffect] = { + return self.loadGlucoseEffectFixture("glucose_from_effects_carb_effect_input", formatter: ISO8601DateFormatter.localTimeDate()) + }() + + lazy var insulinEffect: [GlucoseEffect] = { + return self.loadGlucoseEffectFixture("glucose_from_effects_insulin_effect_input", formatter: ISO8601DateFormatter.localTimeDate()) + }() + + func testPredictGlucoseNoMomentum() { + let glucose = loadGlucoseHistoryFixture("glucose_from_effects_glucose_input") + + let expected = loadGlucoseValueFixture("glucose_from_effects_no_momentum_output") + + let calculated = LoopMath.predictGlucose(startingAt: glucose, effects: carbEffect, insulinEffect) + + XCTAssertEqual(expected.count, calculated.count) + + for (expected, calculated) in zip(expected, calculated) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: Double(Float.ulpOfOne)) + } + } + + func testPredictGlucoseFlatMomentum() { + let glucose = loadGlucoseHistoryFixture("glucose_from_effects_momentum_flat_glucose_input") + let momentum = loadGlucoseEffectFixture("glucose_from_effects_momentum_flat_input", formatter: ISO8601DateFormatter.localTimeDate()) + let expected = loadGlucoseValueFixture("glucose_from_effects_momentum_flat_output") + + let calculated = LoopMath.predictGlucose(startingAt: glucose, momentum: momentum, effects: carbEffect, insulinEffect) + + XCTAssertEqual(expected.count, calculated.count) + + for (expected, calculated) in zip(expected, calculated) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: Double(Float.ulpOfOne)) + } + } + + func testPredictGlucoseUpMomentum() { + let glucose = loadGlucoseHistoryFixture("glucose_from_effects_glucose_input") + let momentum = loadGlucoseEffectFixture("glucose_from_effects_momentum_up_input", formatter: ISO8601DateFormatter.localTimeDate()) + let expected = loadGlucoseValueFixture("glucose_from_effects_momentum_up_output") + + let calculated = LoopMath.predictGlucose(startingAt: glucose, momentum: momentum, effects: carbEffect, insulinEffect) + + XCTAssertEqual(expected.count, calculated.count) + + for (expected, calculated) in zip(expected, calculated) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: Double(Float.ulpOfOne)) + } + } + + func testPredictGlucoseDownMomentum() { + let glucose = loadGlucoseHistoryFixture("glucose_from_effects_glucose_input") + let momentum = loadGlucoseEffectFixture("glucose_from_effects_momentum_down_input", formatter: ISO8601DateFormatter.localTimeDate()) + let expected = loadGlucoseValueFixture("glucose_from_effects_momentum_down_output") + + let calculated = LoopMath.predictGlucose(startingAt: glucose, momentum: momentum, effects: carbEffect, insulinEffect) + + XCTAssertEqual(expected.count, calculated.count) + + for (expected, calculated) in zip(expected, calculated) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: Double(Float.ulpOfOne)) + } + } + + func testPredictGlucoseBlendMomentum() { + let glucose = loadGlucoseHistoryFixture("glucose_from_effects_momentum_blend_glucose_input") + let momentum = loadGlucoseEffectFixture("glucose_from_effects_momentum_blend_momentum_input", formatter: ISO8601DateFormatter.localTimeDate()) + let insulinEffect = loadGlucoseEffectFixture("glucose_from_effects_momentum_blend_insulin_effect_input", formatter: ISO8601DateFormatter.localTimeDate()) + let expected = loadGlucoseValueFixture("glucose_from_effects_momentum_blend_output") + + let calculated = LoopMath.predictGlucose(startingAt: glucose, momentum: momentum, effects: insulinEffect) + + XCTAssertEqual(expected.count, calculated.count) + + for (expected, calculated) in zip(expected, calculated) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: Double(Float.ulpOfOne)) + } + } + + func testPredictGlucoseStartingEffectsNonZero() { + let glucose = loadSampleValueFixture("glucose_from_effects_non_zero_glucose_input").first! + let insulinEffect = loadSampleValueFixture("glucose_from_effects_non_zero_insulin_input").map { + GlucoseEffect(startDate: $0.startDate, quantity: $0.quantity) + } + let carbEffect = loadSampleValueFixture("glucose_from_effects_non_zero_carb_input").map { + GlucoseEffect(startDate: $0.startDate, quantity: $0.quantity) + } + let expected = loadSampleValueFixture("glucose_from_effects_non_zero_output").map { + GlucoseEffect(startDate: $0.startDate, quantity: $0.quantity) + } + + let calculated = LoopMath.predictGlucose(startingAt: RecentGlucoseValue(startDate: glucose.startDate, quantity: glucose.quantity), + effects: insulinEffect, carbEffect + ) + + XCTAssertEqual(expected.count, calculated.count) + + for (expected, calculated) in zip(expected, calculated) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: Double(Float.ulpOfOne)) + } + } + + func testDecayEffect() { + let calendar = Calendar(identifier: Calendar.Identifier.gregorian) + let glucoseDate = calendar.date(from: DateComponents(year: 2016, month: 2, day: 1, hour: 10, minute: 13, second: 20))! + let unit = LoopUnit.milligramsPerDeciliter + let glucose = FixtureGlucoseSample(startDate: glucoseDate, mgdl: 100) + + var startingEffect = LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: 2) + + var effects = glucose.decayEffect(atRate: startingEffect, for: .minutes(30)) + + // decayEffect is now a continuous function of the (unaligned) sample + // timestamp: the 10:13:20 glucose sits 1m40s before the first 5-min grid + // point, so the values interpolate rather than snapping to the grid. + // (testDecayEffectWithEvenGlucose, aligned to :00, still gives the integer + // grid values.) Irrational decimals, so compare with accuracy. + func assertEqual(_ expected: [Double], _ actual: [Double], line: UInt = #line) { + XCTAssertEqual(expected.count, actual.count, line: line) + for (e, a) in zip(expected, actual) { XCTAssertEqual(e, a, accuracy: 1e-6, line: line) } + } + + assertEqual([100, 103.555556, 112.888889, 120.222222, 125.555556, 128.888889, 130.222222], + effects.map { $0.quantity.doubleValue(for: unit) }) + + let startDate = effects.first!.startDate + XCTAssertEqual([0, 5, 10, 15, 20, 25, 30], effects.map { $0.startDate.timeIntervalSince(startDate).minutes }) + + startingEffect = LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -0.5) + effects = glucose.decayEffect(atRate: startingEffect, for: .minutes(30)) + assertEqual([100, 99.111111, 96.777778, 94.944444, 93.611111, 92.777778, 92.444444], + effects.map { $0.quantity.doubleValue(for: unit) }) + } + + func testDecayEffectWithEvenGlucose() { + let calendar = Calendar(identifier: Calendar.Identifier.gregorian) + let glucoseDate = calendar.date(from: DateComponents(year: 2016, month: 2, day: 1, hour: 10, minute: 15, second: 0))! + let unit = LoopUnit.milligramsPerDeciliter + let glucose = FixtureGlucoseSample(startDate: glucoseDate, mgdl: 100) + + var startingEffect = LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: 2) + + var effects = glucose.decayEffect(atRate: startingEffect, for: .minutes(30)) + + XCTAssertEqual([100, 110, 118, 124, 128, 130], effects.map { $0.quantity.doubleValue(for: unit) }) + + let startDate = effects.first!.startDate + XCTAssertEqual([0, 5, 10, 15, 20, 25], effects.map { $0.startDate.timeIntervalSince(startDate).minutes }) + + startingEffect = LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -0.5) + effects = glucose.decayEffect(atRate: startingEffect, for: .minutes(30)) + XCTAssertEqual([100, 97.5, 95.5, 94, 93, 92.5], effects.map { $0.quantity.doubleValue(for: unit) }) + } + + func testSubtractingCarbEffectFromICEWithGaps() { + let perMinute = LoopUnit.milligramsPerDeciliterPerMinute + let mgdl = LoopUnit.milligramsPerDeciliter + + let formatter = DateFormatter.descriptionFormatter + let f = { (input) in + return formatter.date(from: input)! + } + + let insulinCounteractionEffects = [ + GlucoseEffectVelocity(startDate: f("2018-08-16 01:03:43 +0000"), endDate: f("2018-08-16 01:08:43 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: -0.0063630385383878375)), + GlucoseEffectVelocity(startDate: f("2018-08-16 01:08:43 +0000"), endDate: f("2018-08-16 01:13:44 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: 0.3798225216554212)), + GlucoseEffectVelocity(startDate: f("2018-08-16 01:13:44 +0000"), endDate: f("2018-08-16 01:18:44 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: 0.5726970790754702)), + GlucoseEffectVelocity(startDate: f("2018-08-16 01:18:44 +0000"), endDate: f("2018-08-16 01:23:44 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: 0.7936837738660198)), + GlucoseEffectVelocity(startDate: f("2018-08-16 01:23:44 +0000"), endDate: f("2018-08-16 01:28:43 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: 1.2143200509835315)), + GlucoseEffectVelocity(startDate: f("2018-08-16 01:28:43 +0000"), endDate: f("2018-08-16 02:03:43 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: 1.214239367352738)), + GlucoseEffectVelocity(startDate: f("2018-08-16 02:03:43 +0000"), endDate: f("2018-08-16 03:13:44 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: 0.64964443000756)), + GlucoseEffectVelocity(startDate: f("2018-08-16 03:13:44 +0000"), endDate: f("2018-08-16 03:18:44 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: 0.40933503856266396)), + GlucoseEffectVelocity(startDate: f("2018-08-16 03:18:44 +0000"), endDate: f("2018-08-16 03:23:43 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: 0.5994115966821696)), + GlucoseEffectVelocity(startDate: f("2018-08-16 03:23:43 +0000"), endDate: f("2018-08-16 03:28:44 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: 0.640336708627245)), + GlucoseEffectVelocity(startDate: f("2018-08-16 03:28:44 +0000"), endDate: f("2018-08-16 03:33:43 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: 1.5109774027636143)), + GlucoseEffectVelocity(startDate: f("2018-08-16 03:33:43 +0000"), endDate: f("2018-08-16 03:38:43 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: -1.6358198764701966)), + GlucoseEffectVelocity(startDate: f("2018-08-16 03:38:43 +0000"), endDate: f("2018-08-16 03:43:44 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: 0.4187533857556986)), + GlucoseEffectVelocity(startDate: f("2018-08-16 03:43:44 +0000"), endDate: f("2018-08-16 03:48:43 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: -1.136005257968153)), + GlucoseEffectVelocity(startDate: f("2018-08-16 03:48:43 +0000"), endDate: f("2018-08-16 03:53:44 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: -0.29635521996668046)), + GlucoseEffectVelocity(startDate: f("2018-08-16 03:53:44 +0000"), endDate: f("2018-08-16 03:58:43 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: -1.070285990192832)), + GlucoseEffectVelocity(startDate: f("2018-08-16 03:58:43 +0000"), endDate: f("2018-08-16 04:03:43 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: -0.25025410282677996)), + GlucoseEffectVelocity(startDate: f("2018-08-16 04:03:43 +0000"), endDate: f("2018-08-16 04:08:43 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: 0.5598990284715561)), + GlucoseEffectVelocity(startDate: f("2018-08-16 04:08:43 +0000"), endDate: f("2018-08-16 04:13:44 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: 1.9616378142801167)), + GlucoseEffectVelocity(startDate: f("2018-08-16 04:13:44 +0000"), endDate: f("2018-08-16 04:18:43 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: 1.7593854114682483)), + GlucoseEffectVelocity(startDate: f("2018-08-16 04:18:43 +0000"), endDate: f("2018-08-16 04:23:44 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: 0.5467358931981837)), + GlucoseEffectVelocity(startDate: f("2018-08-16 04:23:44 +0000"), endDate: f("2018-08-16 04:28:44 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: 0.9335047268082058)), + GlucoseEffectVelocity(startDate: f("2018-08-16 04:28:44 +0000"), endDate: f("2018-08-16 04:33:43 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: -0.2823274349191665)), + ] + + let carbEffects = [ + GlucoseEffect(startDate: f("2018-08-16 01:00:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 69.38642455065255)), + GlucoseEffect(startDate: f("2018-08-16 01:05:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 69.38642455065255)), + GlucoseEffect(startDate: f("2018-08-16 01:10:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 72.18741125500856)), + GlucoseEffect(startDate: f("2018-08-16 01:15:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 82.00120184863087)), + GlucoseEffect(startDate: f("2018-08-16 01:20:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 90.79285286260895)), + GlucoseEffect(startDate: f("2018-08-16 01:25:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 93.5316456300059)), + GlucoseEffect(startDate: f("2018-08-16 01:30:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 98.19664696604428)), + GlucoseEffect(startDate: f("2018-08-16 01:35:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 98.5800323225078)), + GlucoseEffect(startDate: f("2018-08-16 01:40:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 100.09237800152016)), + GlucoseEffect(startDate: f("2018-08-16 01:45:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 101.60472368053252)), + GlucoseEffect(startDate: f("2018-08-16 01:50:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 103.11706935954487)), + GlucoseEffect(startDate: f("2018-08-16 01:55:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 104.6294150385572)), + GlucoseEffect(startDate: f("2018-08-16 02:00:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 129.38642455065255)), + GlucoseEffect(startDate: f("2018-08-16 02:05:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 129.38642455065255)), + GlucoseEffect(startDate: f("2018-08-16 02:10:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 132.18741125500856)), + GlucoseEffect(startDate: f("2018-08-16 02:15:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 142.00120184863087)), + GlucoseEffect(startDate: f("2018-08-16 02:20:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 150.79285286260895)), + GlucoseEffect(startDate: f("2018-08-16 02:25:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 153.5316456300059)), + GlucoseEffect(startDate: f("2018-08-16 02:30:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 158.19664696604428)), + GlucoseEffect(startDate: f("2018-08-16 02:35:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 158.5800323225078)), + GlucoseEffect(startDate: f("2018-08-16 02:40:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 160.09237800152016)), + GlucoseEffect(startDate: f("2018-08-16 02:45:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 161.60472368053252)), + GlucoseEffect(startDate: f("2018-08-16 02:50:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 163.11706935954487)), + GlucoseEffect(startDate: f("2018-08-16 02:55:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 164.6294150385572)), + GlucoseEffect(startDate: f("2018-08-16 03:00:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 166.14176071756955)), + GlucoseEffect(startDate: f("2018-08-16 03:05:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 167.65410639658188)), + GlucoseEffect(startDate: f("2018-08-16 03:10:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 169.16645207559424)), + GlucoseEffect(startDate: f("2018-08-16 03:15:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 170.6787977546066)), + GlucoseEffect(startDate: f("2018-08-16 03:20:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 172.19114343361895)), + GlucoseEffect(startDate: f("2018-08-16 03:25:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 173.70348911263127)), + GlucoseEffect(startDate: f("2018-08-16 03:30:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 175.21583479164363)), + GlucoseEffect(startDate: f("2018-08-16 03:35:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 176.72818047065596)), + GlucoseEffect(startDate: f("2018-08-16 03:40:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 178.2405261496683)), + GlucoseEffect(startDate: f("2018-08-16 03:45:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 179.75287182868067)), + GlucoseEffect(startDate: f("2018-08-16 03:50:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 181.26521750769302)), + GlucoseEffect(startDate: f("2018-08-16 03:55:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 182.77756318670535)), + GlucoseEffect(startDate: f("2018-08-16 04:00:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 189.38642455065255)), + GlucoseEffect(startDate: f("2018-08-16 04:05:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 189.38642455065255)), + GlucoseEffect(startDate: f("2018-08-16 04:10:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 192.18741125500856)), + GlucoseEffect(startDate: f("2018-08-16 04:15:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 202.00120184863087)), + GlucoseEffect(startDate: f("2018-08-16 04:20:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 210.79285286260895)), + GlucoseEffect(startDate: f("2018-08-16 04:25:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 213.5316456300059)), + GlucoseEffect(startDate: f("2018-08-16 04:30:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 218.19664696604428)), + GlucoseEffect(startDate: f("2018-08-16 04:35:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 218.5800323225078)), + GlucoseEffect(startDate: f("2018-08-16 04:40:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 220.09237800152016)), + GlucoseEffect(startDate: f("2018-08-16 04:45:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 221.60472368053252)), + GlucoseEffect(startDate: f("2018-08-16 04:50:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 223.11706935954487)), + GlucoseEffect(startDate: f("2018-08-16 04:55:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 224.6294150385572)), + GlucoseEffect(startDate: f("2018-08-16 05:00:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 226.14176071756955)), + GlucoseEffect(startDate: f("2018-08-16 05:05:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 227.65410639658188)), + GlucoseEffect(startDate: f("2018-08-16 05:10:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 229.16645207559424)), + GlucoseEffect(startDate: f("2018-08-16 05:15:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 230.6787977546066)), + GlucoseEffect(startDate: f("2018-08-16 05:20:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 232.19114343361895)), + GlucoseEffect(startDate: f("2018-08-16 05:25:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 233.70348911263127)), + GlucoseEffect(startDate: f("2018-08-16 05:30:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 235.21583479164363)), + GlucoseEffect(startDate: f("2018-08-16 05:35:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 236.72818047065596)), + GlucoseEffect(startDate: f("2018-08-16 05:40:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 238.2405261496683)), + GlucoseEffect(startDate: f("2018-08-16 05:45:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 239.75287182868067)), + GlucoseEffect(startDate: f("2018-08-16 05:50:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 241.26521750769302)), + GlucoseEffect(startDate: f("2018-08-16 05:55:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 242.77756318670535)), + GlucoseEffect(startDate: f("2018-08-16 06:00:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 244.28990886571768)), + GlucoseEffect(startDate: f("2018-08-16 06:05:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 245.80225454473003)), + GlucoseEffect(startDate: f("2018-08-16 06:10:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 247.3146002237424)), + GlucoseEffect(startDate: f("2018-08-16 06:15:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 248.82694590275474)), + GlucoseEffect(startDate: f("2018-08-16 06:20:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 250.33929158176707)), + GlucoseEffect(startDate: f("2018-08-16 06:25:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 251.85163726077943)), + GlucoseEffect(startDate: f("2018-08-16 06:30:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 253.16666666666669)), + GlucoseEffect(startDate: f("2018-08-16 06:35:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 253.16666666666669)), + GlucoseEffect(startDate: f("2018-08-16 06:40:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 253.16666666666669)), + ] + + let calculated = insulinCounteractionEffects.subtracting(carbEffects, withUniformInterval: .minutes(5)) + + let expected = loadGlucoseEffectFixture("ice_minus_carb_effect_with_gaps_output", formatter: DateFormatter.descriptionFormatter) + + XCTAssertSampleValueEqual(expected, calculated) + } + + func testSubtractingFlatCarbEffectFromICE() { + let perMinute = LoopUnit.milligramsPerDeciliterPerMinute + let mgdl = LoopUnit.milligramsPerDeciliter + + let formatter = DateFormatter.descriptionFormatter + let f = { (input) in + return formatter.date(from: input)! + } + + let insulinCounteractionEffects = [ + GlucoseEffectVelocity(startDate: f("2018-08-26 00:23:27 +0000"), endDate: f("2018-08-26 00:28:28 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: 0.3711911901542791)), + GlucoseEffectVelocity(startDate: f("2018-08-26 00:28:28 +0000"), endDate: f("2018-08-26 00:33:27 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: -0.4382943196158106)), + GlucoseEffectVelocity(startDate: f("2018-08-26 00:33:27 +0000"), endDate: f("2018-08-26 00:38:27 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: -0.24797050925219474)), + GlucoseEffectVelocity(startDate: f("2018-08-26 00:38:27 +0000"), endDate: f("2018-08-26 00:43:28 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: -0.05800368381887202)), + GlucoseEffectVelocity(startDate: f("2018-08-26 00:43:28 +0000"), endDate: f("2018-08-26 00:48:28 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: 0.7303422936657988)), + GlucoseEffectVelocity(startDate: f("2018-08-26 00:48:28 +0000"), endDate: f("2018-08-26 00:53:28 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: 0.7166291304729353)), + GlucoseEffectVelocity(startDate: f("2018-08-26 00:53:28 +0000"), endDate: f("2018-08-26 00:58:28 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: 0.2962279320324577)), + GlucoseEffectVelocity(startDate: f("2018-08-26 00:58:28 +0000"), endDate: f("2018-08-26 01:03:27 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: 0.2730196016663657)), + GlucoseEffectVelocity(startDate: f("2018-08-26 01:03:27 +0000"), endDate: f("2018-08-26 01:08:28 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: 0.44605609358758713)), + GlucoseEffectVelocity(startDate: f("2018-08-26 01:08:28 +0000"), endDate: f("2018-08-26 01:13:28 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: 0.42131971635266463)), + GlucoseEffectVelocity(startDate: f("2018-08-26 01:13:28 +0000"), endDate: f("2018-08-26 01:18:27 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: 0.9948293689475411)), + GlucoseEffectVelocity(startDate: f("2018-08-26 01:18:27 +0000"), endDate: f("2018-08-26 01:23:28 +0000"), quantity: LoopQuantity(unit: perMinute, doubleValue: 0.9652238210644638)), + ] + + let carbEffects = [ + GlucoseEffect(startDate: f("2018-08-26 00:45:00 +0000"), quantity: LoopQuantity(unit: mgdl, doubleValue: 385.8235294117647)) + ] + + let calculated = insulinCounteractionEffects.subtracting(carbEffects, withUniformInterval: .minutes(5)) + + let expected = loadGlucoseEffectFixture("ice_minus_flat_carb_effect_output", formatter: DateFormatter.descriptionFormatter) + + XCTAssertSampleValueEqual(expected, calculated) + } + + func testCombinedSumsWithGaps() { + let input = loadGlucoseEffectFixture("ice_minus_carb_effect_with_gaps_output", formatter: DateFormatter.descriptionFormatter) + let unit = LoopUnit.milligramsPerDeciliter + + let formatter = DateFormatter.descriptionFormatter + let f = { (input) in + return formatter.date(from: input)! + } + + let expected = [ + GlucoseChange(startDate: f("2018-08-16 01:13:44 +0000"), endDate: f("2018-08-16 01:13:44 +0000"), quantity: LoopQuantity(unit: unit, doubleValue: -7.914677985345208)), + GlucoseChange(startDate: f("2018-08-16 01:13:44 +0000"), endDate: f("2018-08-16 01:18:44 +0000"), quantity: LoopQuantity(unit: unit, doubleValue: -13.84284360394594)), + GlucoseChange(startDate: f("2018-08-16 01:13:44 +0000"), endDate: f("2018-08-16 01:23:44 +0000"), quantity: LoopQuantity(unit: unit, doubleValue: -12.613217502012784)), + GlucoseChange(startDate: f("2018-08-16 01:13:44 +0000"), endDate: f("2018-08-16 01:28:43 +0000"), quantity: LoopQuantity(unit: unit, doubleValue: -11.206618583133514)), + GlucoseChange(startDate: f("2018-08-16 02:03:43 +0000"), endDate: f("2018-08-16 02:03:43 +0000"), quantity: LoopQuantity(unit: unit, doubleValue: 6.071196836763691)), + GlucoseChange(startDate: f("2018-08-16 03:13:44 +0000"), endDate: f("2018-08-16 03:13:44 +0000"), quantity: LoopQuantity(unit: unit, doubleValue: 1.735876471025445)), + GlucoseChange(startDate: f("2018-08-16 03:13:44 +0000"), endDate: f("2018-08-16 03:18:44 +0000"), quantity: LoopQuantity(unit: unit, doubleValue: 2.2702059848264096)), + GlucoseChange(startDate: f("2018-08-16 03:13:44 +0000"), endDate: f("2018-08-16 03:23:43 +0000"), quantity: LoopQuantity(unit: unit, doubleValue: 3.754918289224931)), + GlucoseChange(startDate: f("2018-08-16 03:13:44 +0000"), endDate: f("2018-08-16 03:28:44 +0000"), quantity: LoopQuantity(unit: unit, doubleValue: 5.444256153348801)), + GlucoseChange(startDate: f("2018-08-16 03:13:44 +0000"), endDate: f("2018-08-16 03:33:43 +0000"), quantity: LoopQuantity(unit: unit, doubleValue: 11.486797488154545)), + GlucoseChange(startDate: f("2018-08-16 03:13:44 +0000"), endDate: f("2018-08-16 03:38:43 +0000"), quantity: LoopQuantity(unit: unit, doubleValue: 1.7953524267912058)), + GlucoseChange(startDate: f("2018-08-16 03:13:44 +0000"), endDate: f("2018-08-16 03:43:44 +0000"), quantity: LoopQuantity(unit: unit, doubleValue: 2.376773676557343)), + GlucoseChange(startDate: f("2018-08-16 03:18:44 +0000"), endDate: f("2018-08-16 03:48:43 +0000"), quantity: LoopQuantity(unit: unit, doubleValue: -6.551474763321222)), + GlucoseChange(startDate: f("2018-08-16 03:28:44 +0000"), endDate: f("2018-08-16 03:53:44 +0000"), quantity: LoopQuantity(unit: unit, doubleValue: -11.56463836036644)), + GlucoseChange(startDate: f("2018-08-16 03:28:44 +0000"), endDate: f("2018-08-16 03:58:43 +0000"), quantity: LoopQuantity(unit: unit, doubleValue: -23.524929675277797)), + GlucoseChange(startDate: f("2018-08-16 03:33:43 +0000"), endDate: f("2018-08-16 04:03:43 +0000"), quantity: LoopQuantity(unit: unit, doubleValue: -26.46553805353557)), + GlucoseChange(startDate: f("2018-08-16 03:38:43 +0000"), endDate: f("2018-08-16 04:08:43 +0000"), quantity: LoopQuantity(unit: unit, doubleValue: -32.50957095033954)), + GlucoseChange(startDate: f("2018-08-16 03:43:44 +0000"), endDate: f("2018-08-16 04:13:44 +0000"), quantity: LoopQuantity(unit: unit, doubleValue: -22.823727411197925)), + GlucoseChange(startDate: f("2018-08-16 03:48:43 +0000"), endDate: f("2018-08-16 04:18:43 +0000"), quantity: LoopQuantity(unit: unit, doubleValue: -23.399872617600906)), + GlucoseChange(startDate: f("2018-08-16 03:53:44 +0000"), endDate: f("2018-08-16 04:23:44 +0000"), quantity: LoopQuantity(unit: unit, doubleValue: -16.21261395015381)), + GlucoseChange(startDate: f("2018-08-16 04:03:43 +0000"), endDate: f("2018-08-16 04:28:44 +0000"), quantity: LoopQuantity(unit: unit, doubleValue: -1.2556785583940788)), + GlucoseChange(startDate: f("2018-08-16 04:03:43 +0000"), endDate: f("2018-08-16 04:33:43 +0000"), quantity: LoopQuantity(unit: unit, doubleValue: -3.0507010894534323)) + ] + + let calculated = input.combinedSums(of: .minutes(30)) + + XCTAssertSampleValueEqual(expected, calculated) + } + + + func testNetEffect() { + let formatter = DateFormatter.descriptionFormatter + let f = { (input) in + return formatter.date(from: input)! + } + + let unit = LoopUnit.milligramsPerDeciliter + + let input = [ + GlucoseEffect(startDate: f("2018-08-16 01:00:00 +0000"), quantity: LoopQuantity(unit: unit, doubleValue: 25)), + GlucoseEffect(startDate: f("2018-08-16 01:05:00 +0000"), quantity: LoopQuantity(unit: unit, doubleValue: 26)), + GlucoseEffect(startDate: f("2018-08-16 01:10:00 +0000"), quantity: LoopQuantity(unit: unit, doubleValue: 27)) + ] + + let calculated = input.netEffect() + + let expected = GlucoseChange( + startDate: f("2018-08-16 01:00:00 +0000"), + endDate: f("2018-08-16 01:10:00 +0000"), + quantity: LoopQuantity(unit: unit, doubleValue: 2)) + + XCTAssertEqual(expected, calculated) + } + + func testDateRange() { + let formatter = DateFormatter.descriptionFormatter + let f = { (input) in + return formatter.date(from: input)! + } + + let expected = [ + f("2018-08-16 01:00:00 +0000"), + f("2018-08-16 01:05:00 +0000"), + f("2018-08-16 01:10:00 +0000"), + f("2018-08-16 01:15:00 +0000"), + f("2018-08-16 01:20:00 +0000"), + f("2018-08-16 01:25:00 +0000"), + f("2018-08-16 01:30:00 +0000"), + f("2018-08-16 01:35:00 +0000"), + f("2018-08-16 01:40:00 +0000"), + f("2018-08-16 01:45:00 +0000"), + f("2018-08-16 01:50:00 +0000"), + f("2018-08-16 01:55:00 +0000"), + f("2018-08-16 02:00:00 +0000"), + f("2018-08-16 02:05:00 +0000"), + f("2018-08-16 02:10:00 +0000"), + f("2018-08-16 02:15:00 +0000"), + f("2018-08-16 02:20:00 +0000"), + f("2018-08-16 02:25:00 +0000"), + f("2018-08-16 02:30:00 +0000"), + f("2018-08-16 02:35:00 +0000"), + f("2018-08-16 02:40:00 +0000"), + f("2018-08-16 02:45:00 +0000"), + f("2018-08-16 02:50:00 +0000"), + f("2018-08-16 02:55:00 +0000"), + f("2018-08-16 03:00:00 +0000"), + f("2018-08-16 03:05:00 +0000"), + f("2018-08-16 03:10:00 +0000") + ] + + let roundedStartDate = f("2018-08-16 01:00:00 +0000") + let unroundedStartDate = f("2018-08-16 01:04:13 +0000") + + let roundedEndDate = f("2018-08-16 03:10:00 +0000") + let unroundedEndDate = f("2018-08-16 03:06:29 +0000") + + let roundedOutput = LoopMath.simulationDateRange(from: roundedStartDate, to: roundedEndDate, delta: .minutes(5)) + XCTAssertEqual(expected, roundedOutput) + + let unroundedOutput = LoopMath.simulationDateRange(from: unroundedStartDate, to: unroundedEndDate, delta: .minutes(5)) + XCTAssertEqual(expected, unroundedOutput) + + let differentDeltaExpected = expected + .enumerated() + .compactMap { $0.offset.isMultiple(of: 2) ? $0.element : nil } + let differentDeltaOutput = LoopMath.simulationDateRange(from: unroundedStartDate, to: unroundedEndDate, delta: .minutes(10)) + XCTAssertEqual(differentDeltaExpected, differentDeltaOutput) + } +} + +func XCTAssertSampleValueEqual(_ expression1: [T], _ expression2: [T], value: (T) -> Double, _ message: @autoclosure () -> String = "", file: StaticString = #filePath, line: UInt = #line) { + XCTAssertEqual(expression1.map(\.startDate), expression2.map(\.startDate), message(), file: file, line: line) + XCTAssertEqual(expression1.map(\.endDate), expression2.map(\.endDate), message(), file: file, line: line) + XCTAssertEqual(expression1.count, expression2.count, message(), file: file, line: line) + + for (effect, mirroredEffect) in zip(expression1, expression2) { + XCTAssertEqual(value(effect), value(mirroredEffect), accuracy: 0.0000000001, message(), file: file, line: line) + } +} + +func XCTAssertSampleValueEqual(_ expression1: [GlucoseEffect], _ expression2: [GlucoseEffect], _ message: @autoclosure () -> String = "", file: StaticString = #filePath, line: UInt = #line) { + XCTAssertSampleValueEqual(expression1, expression2, value: \.mgdl, message(), file: file, line: line) +} + +func XCTAssertSampleValueEqual(_ expression1: [GlucoseChange], _ expression2: [GlucoseChange], _ message: @autoclosure () -> String = "", file: StaticString = #filePath, line: UInt = #line) { + XCTAssertSampleValueEqual(expression1, expression2, value: \.mgdl, message(), file: file, line: line) +} diff --git a/Tests/LoopAlgorithmTests/Mocks/LoopAlgorithmInputMock.swift b/Tests/LoopAlgorithmTests/Mocks/LoopAlgorithmInputMock.swift index fc76742..f3bd1eb 100644 --- a/Tests/LoopAlgorithmTests/Mocks/LoopAlgorithmInputMock.swift +++ b/Tests/LoopAlgorithmTests/Mocks/LoopAlgorithmInputMock.swift @@ -29,10 +29,10 @@ extension AlgorithmInputFixture { doses: [], carbEntries: [], basal: [AbsoluteScheduleValue(startDate: d(.hours(-10)), endDate: now, value: 1.0)], - sensitivity: [AbsoluteScheduleValue(startDate: d(.hours(-10)), endDate: forecastEnd, value: .glucose(55))], + sensitivityMgdlPerUnit: [AbsoluteScheduleValue(startDate: d(.hours(-10)), endDate: forecastEnd, value: 55)], carbRatio: [AbsoluteScheduleValue(startDate: d(.hours(-10)), endDate: now, value: 10)], - target: [AbsoluteScheduleValue(startDate: d(.hours(-10)), endDate: now, value: ClosedRange(uncheckedBounds: (lower: .glucose(100), upper: .glucose(110))))], - suspendThreshold: .glucose(65), + targetMgdl: [AbsoluteScheduleValue(startDate: d(.hours(-10)), endDate: now, value: ClosedRange(uncheckedBounds: (lower: 100, upper: 110)))], + suspendThresholdMgdl: 65, maxBolus: 6, maxBasalRate: 8, recommendationInsulinType: .novolog, diff --git a/Tests/LoopAlgorithmTests/PrecomputedInsulinInputTests.swift b/Tests/LoopAlgorithmTests/PrecomputedInsulinInputTests.swift index 25f6096..3379dbc 100644 --- a/Tests/LoopAlgorithmTests/PrecomputedInsulinInputTests.swift +++ b/Tests/LoopAlgorithmTests/PrecomputedInsulinInputTests.swift @@ -31,13 +31,13 @@ final class PrecomputedInsulinInputTests: XCTestCase { let start = input.glucoseHistory.last!.startDate // Standard prediction (full annotation inside generatePrediction) - let standard = LoopAlgorithm.generatePrediction( + let standard = Algorithm.generatePrediction( start: start, glucoseHistory: input.glucoseHistory, doses: input.doses, carbEntries: input.carbEntries, basal: input.basal, - sensitivity: input.sensitivity, + sensitivityMgdlPerUnit: input.sensitivityMgdlPerUnit, carbRatio: input.carbRatio, useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection ) @@ -45,12 +45,12 @@ final class PrecomputedInsulinInputTests: XCTestCase { // Pre-annotate once (ISF-independent); no effects → standard inner glucoseEffects path let precomputed = PrecomputedInsulinInput.annotate(doses: input.doses, basal: input.basal) - let fast = LoopAlgorithm.generatePrediction( + let fast = Algorithm.generatePrediction( start: start, glucoseHistory: input.glucoseHistory, precomputedInsulin: precomputed, carbEntries: input.carbEntries, - sensitivity: input.sensitivity, + sensitivityMgdlPerUnit: input.sensitivityMgdlPerUnit, carbRatio: input.carbRatio, useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection ) @@ -79,13 +79,13 @@ final class PrecomputedInsulinInputTests: XCTestCase { let input = try loadInput() let start = input.glucoseHistory.last!.startDate - let standard = LoopAlgorithm.generatePrediction( + let standard = Algorithm.generatePrediction( start: start, glucoseHistory: input.glucoseHistory, doses: input.doses, carbEntries: input.carbEntries, basal: input.basal, - sensitivity: input.sensitivity, + sensitivityMgdlPerUnit: input.sensitivityMgdlPerUnit, carbRatio: input.carbRatio, useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection ) @@ -93,14 +93,14 @@ final class PrecomputedInsulinInputTests: XCTestCase { // ISF-sweep pattern: annotate once, compute effects per ISF value let precomputed = PrecomputedInsulinInput .annotate(doses: input.doses, basal: input.basal) - .withEffects(sensitivity: input.sensitivity) + .withEffects(sensitivityMgdlPerUnit: input.sensitivityMgdlPerUnit) - let fast = LoopAlgorithm.generatePrediction( + let fast = Algorithm.generatePrediction( start: start, glucoseHistory: input.glucoseHistory, precomputedInsulin: precomputed, carbEntries: input.carbEntries, - sensitivity: input.sensitivity, + sensitivityMgdlPerUnit: input.sensitivityMgdlPerUnit, carbRatio: input.carbRatio, useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection ) @@ -116,13 +116,13 @@ final class PrecomputedInsulinInputTests: XCTestCase { let input = try loadInput() let start = input.glucoseHistory.last!.startDate - let standard = LoopAlgorithm.generatePrediction( + let standard = Algorithm.generatePrediction( start: start, glucoseHistory: input.glucoseHistory, doses: input.doses, carbEntries: input.carbEntries, basal: input.basal, - sensitivity: input.sensitivity, + sensitivityMgdlPerUnit: input.sensitivityMgdlPerUnit, carbRatio: input.carbRatio, useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection ) @@ -130,12 +130,12 @@ final class PrecomputedInsulinInputTests: XCTestCase { // Simulate EvalCore: build once, then pass the (unsliced) annotated set let sliced = PrecomputedInsulinInput.annotate(doses: input.doses, basal: input.basal) - let fromSlice = LoopAlgorithm.generatePrediction( + let fromSlice = Algorithm.generatePrediction( start: start, glucoseHistory: input.glucoseHistory, precomputedInsulin: sliced, carbEntries: input.carbEntries, - sensitivity: input.sensitivity, + sensitivityMgdlPerUnit: input.sensitivityMgdlPerUnit, carbRatio: input.carbRatio, useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection ) @@ -162,40 +162,32 @@ final class PrecomputedInsulinInputTests: XCTestCase { for multiplier in multipliers { // Scale ISF — O(n_isf_segments), negligible. - // Preserve whatever unit the fixture uses by scaling the raw double - // and re-wrapping in the same unit. - let scaledSensitivity = input.sensitivity.map { entry -> AbsoluteScheduleValue in - let unit = entry.value.unit - let scaled = entry.value.doubleValue(for: unit) * multiplier - return AbsoluteScheduleValue( - startDate: entry.startDate, - endDate: entry.endDate, - value: LoopQuantity(unit: unit, doubleValue: scaled) - ) + let scaledSensitivity = input.sensitivityMgdlPerUnit.map { entry in + AbsoluteScheduleValue(startDate: entry.startDate, endDate: entry.endDate, value: entry.value * multiplier) } // Compute effects once for this ISF value — O(D × T), not per-step - let precomputed = base.withEffects(sensitivity: scaledSensitivity) + let precomputed = base.withEffects(sensitivityMgdlPerUnit: scaledSensitivity) XCTAssertNotNil(precomputed.insulinEffects, "withEffects should populate insulinEffects") // Verify it produces the same result as the standard path with the same scaled ISF - let standard = LoopAlgorithm.generatePrediction( + let standard = Algorithm.generatePrediction( start: start, glucoseHistory: input.glucoseHistory, doses: input.doses, carbEntries: input.carbEntries, basal: input.basal, - sensitivity: scaledSensitivity, + sensitivityMgdlPerUnit: scaledSensitivity, carbRatio: input.carbRatio, useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection ) - let fast = LoopAlgorithm.generatePrediction( + let fast = Algorithm.generatePrediction( start: start, glucoseHistory: input.glucoseHistory, precomputedInsulin: precomputed, carbEntries: input.carbEntries, - sensitivity: scaledSensitivity, + sensitivityMgdlPerUnit: scaledSensitivity, carbRatio: input.carbRatio, useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection )