From f980905970d6efb954d7b3d2b8bd9cf34730c0b9 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 19 Dec 2023 14:49:45 -0600 Subject: [PATCH 01/26] Initial extraction from LoopKit --- .gitignore | 8 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + Package.swift | 32 + .../LoopAlgorithm/AbsoluteScheduleValue.swift | 23 + Sources/LoopAlgorithm/AbsorbedCarbValue.swift | 80 ++ .../AutomaticDoseRecommendation.swift | 25 + Sources/LoopAlgorithm/CarbEntry.swift | 14 + Sources/LoopAlgorithm/CarbMath.swift | 908 +++++++++++++ Sources/LoopAlgorithm/CarbStatus.swift | 129 ++ Sources/LoopAlgorithm/CarbValue.swift | 49 + Sources/LoopAlgorithm/DoseEntry.swift | 295 ++++ Sources/LoopAlgorithm/DoseMath.swift | 379 ++++++ .../DoseRecommendationType.swift | 25 + Sources/LoopAlgorithm/DoseType.swift | 20 + Sources/LoopAlgorithm/DoseUnit.swift | 17 + Sources/LoopAlgorithm/DoubleRange.swift | 57 + .../ExponentialInsulinModel.swift | 99 ++ .../ExponentialInsulinModelPreset.swift | 86 ++ .../Extensions/ClosedRange.swift | 29 + Sources/LoopAlgorithm/Extensions/Date.swift | 27 + Sources/LoopAlgorithm/Extensions/Double.swift | 29 + .../LoopAlgorithm/Extensions/HKQuantity.swift | 17 + Sources/LoopAlgorithm/Extensions/HKUnit.swift | 62 + .../LoopAlgorithm/Extensions/Sequence.swift | 19 + .../Extensions/TimeInterval.swift | 35 + Sources/LoopAlgorithm/GlucoseChange.swift | 27 + Sources/LoopAlgorithm/GlucoseCondition.swift | 11 + Sources/LoopAlgorithm/GlucoseEffect.swift | 73 + .../LoopAlgorithm/GlucoseEffectVelocity.swift | 63 + Sources/LoopAlgorithm/GlucoseMath.swift | 230 ++++ Sources/LoopAlgorithm/GlucoseRange.swift | 78 ++ .../LoopAlgorithm/GlucoseSampleValue.swift | 32 + Sources/LoopAlgorithm/GlucoseTrend.swift | 83 ++ Sources/LoopAlgorithm/GlucoseValue.swift | 90 ++ Sources/LoopAlgorithm/InsulinMath.swift | 745 ++++++++++ Sources/LoopAlgorithm/InsulinModel.swift | 28 + .../LoopAlgorithm/InsulinModelProvider.swift | 46 + Sources/LoopAlgorithm/InsulinType.swift | 27 + Sources/LoopAlgorithm/InsulinValue.swift | 40 + .../IntegralRetrospectiveCorrection.swift | 199 +++ Sources/LoopAlgorithm/LoopAlgorithm.swift | 497 +++++++ .../LoopAlgorithmDoseRecommendation.swift | 35 + .../LoopAlgorithm/LoopAlgorithmInput.swift | 367 +++++ .../LoopAlgorithm/LoopAlgorithmOutput.swift | 43 + Sources/LoopAlgorithm/LoopMath.swift | 358 +++++ .../LoopAlgorithm/LoopPredictionInput.swift | 153 +++ .../ManualBolusRecommendation.swift | 114 ++ .../RetrospectiveCorrection.swift | 34 + Sources/LoopAlgorithm/SampleValue.swift | 130 ++ .../StandardRetrospectiveCorrection.swift | 68 + Sources/LoopAlgorithm/StoredCarbEntry.swift | 161 +++ .../LoopAlgorithm/StoredGlucoseSample.swift | 126 ++ .../TempBasalRecommendation.swift | 31 + .../Fixtures/carbs_with_isf_change_input.json | 980 ++++++++++++++ .../carbs_with_isf_change_recommendation.json | 5 + .../Fixtures/suspend_input.json | 1203 +++++++++++++++++ .../Fixtures/suspend_recommendation.json | 10 + .../LoopAlgorithmTests.swift | 49 + 58 files changed, 8608 insertions(+) create mode 100644 .gitignore create mode 100644 .swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Package.swift create mode 100644 Sources/LoopAlgorithm/AbsoluteScheduleValue.swift create mode 100644 Sources/LoopAlgorithm/AbsorbedCarbValue.swift create mode 100644 Sources/LoopAlgorithm/AutomaticDoseRecommendation.swift create mode 100644 Sources/LoopAlgorithm/CarbEntry.swift create mode 100644 Sources/LoopAlgorithm/CarbMath.swift create mode 100644 Sources/LoopAlgorithm/CarbStatus.swift create mode 100644 Sources/LoopAlgorithm/CarbValue.swift create mode 100644 Sources/LoopAlgorithm/DoseEntry.swift create mode 100644 Sources/LoopAlgorithm/DoseMath.swift create mode 100644 Sources/LoopAlgorithm/DoseRecommendationType.swift create mode 100644 Sources/LoopAlgorithm/DoseType.swift create mode 100644 Sources/LoopAlgorithm/DoseUnit.swift create mode 100644 Sources/LoopAlgorithm/DoubleRange.swift create mode 100644 Sources/LoopAlgorithm/ExponentialInsulinModel.swift create mode 100644 Sources/LoopAlgorithm/ExponentialInsulinModelPreset.swift create mode 100644 Sources/LoopAlgorithm/Extensions/ClosedRange.swift create mode 100644 Sources/LoopAlgorithm/Extensions/Date.swift create mode 100644 Sources/LoopAlgorithm/Extensions/Double.swift create mode 100644 Sources/LoopAlgorithm/Extensions/HKQuantity.swift create mode 100644 Sources/LoopAlgorithm/Extensions/HKUnit.swift create mode 100644 Sources/LoopAlgorithm/Extensions/Sequence.swift create mode 100644 Sources/LoopAlgorithm/Extensions/TimeInterval.swift create mode 100644 Sources/LoopAlgorithm/GlucoseChange.swift create mode 100644 Sources/LoopAlgorithm/GlucoseCondition.swift create mode 100644 Sources/LoopAlgorithm/GlucoseEffect.swift create mode 100644 Sources/LoopAlgorithm/GlucoseEffectVelocity.swift create mode 100644 Sources/LoopAlgorithm/GlucoseMath.swift create mode 100644 Sources/LoopAlgorithm/GlucoseRange.swift create mode 100644 Sources/LoopAlgorithm/GlucoseSampleValue.swift create mode 100644 Sources/LoopAlgorithm/GlucoseTrend.swift create mode 100644 Sources/LoopAlgorithm/GlucoseValue.swift create mode 100644 Sources/LoopAlgorithm/InsulinMath.swift create mode 100644 Sources/LoopAlgorithm/InsulinModel.swift create mode 100644 Sources/LoopAlgorithm/InsulinModelProvider.swift create mode 100644 Sources/LoopAlgorithm/InsulinType.swift create mode 100644 Sources/LoopAlgorithm/InsulinValue.swift create mode 100644 Sources/LoopAlgorithm/IntegralRetrospectiveCorrection.swift create mode 100644 Sources/LoopAlgorithm/LoopAlgorithm.swift create mode 100644 Sources/LoopAlgorithm/LoopAlgorithmDoseRecommendation.swift create mode 100644 Sources/LoopAlgorithm/LoopAlgorithmInput.swift create mode 100644 Sources/LoopAlgorithm/LoopAlgorithmOutput.swift create mode 100644 Sources/LoopAlgorithm/LoopMath.swift create mode 100644 Sources/LoopAlgorithm/LoopPredictionInput.swift create mode 100644 Sources/LoopAlgorithm/ManualBolusRecommendation.swift create mode 100644 Sources/LoopAlgorithm/RetrospectiveCorrection.swift create mode 100644 Sources/LoopAlgorithm/SampleValue.swift create mode 100644 Sources/LoopAlgorithm/StandardRetrospectiveCorrection.swift create mode 100644 Sources/LoopAlgorithm/StoredCarbEntry.swift create mode 100644 Sources/LoopAlgorithm/StoredGlucoseSample.swift create mode 100644 Sources/LoopAlgorithm/TempBasalRecommendation.swift create mode 100644 Tests/LoopAlgorithmTests/Fixtures/carbs_with_isf_change_input.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/carbs_with_isf_change_recommendation.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/suspend_input.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/suspend_recommendation.json create mode 100644 Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..de42ae3 --- /dev/null +++ b/Package.swift @@ -0,0 +1,32 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "LoopAlgorithm", + platforms: [ + .macOS(.v13), + .iOS(.v15), + .tvOS(.v15) + ], + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "LoopAlgorithm", + targets: ["LoopAlgorithm"]), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "LoopAlgorithm"), + .testTarget( + name: "LoopAlgorithmTests", + dependencies: ["LoopAlgorithm"], + resources: [ + .copy("Fixtures") + ] + ) + ] +) diff --git a/Sources/LoopAlgorithm/AbsoluteScheduleValue.swift b/Sources/LoopAlgorithm/AbsoluteScheduleValue.swift new file mode 100644 index 0000000..db4f141 --- /dev/null +++ b/Sources/LoopAlgorithm/AbsoluteScheduleValue.swift @@ -0,0 +1,23 @@ +// +// AbsoluteScheduleValue.swift +// +// Copyright © 2021 LoopKit Authors. All rights reserved. +// + +import Foundation + +public struct AbsoluteScheduleValue: TimelineValue { + public let startDate: Date + public let endDate: Date + public let value: T + + public init(startDate: Date, endDate: Date, value: T) { + self.startDate = startDate + self.endDate = endDate + self.value = value + } +} + +extension AbsoluteScheduleValue: Equatable where T: Equatable {} + +extension AbsoluteScheduleValue: Codable where T: Codable {} diff --git a/Sources/LoopAlgorithm/AbsorbedCarbValue.swift b/Sources/LoopAlgorithm/AbsorbedCarbValue.swift new file mode 100644 index 0000000..8896d18 --- /dev/null +++ b/Sources/LoopAlgorithm/AbsorbedCarbValue.swift @@ -0,0 +1,80 @@ +// +// AbsorbedCarbValue.swift +// LoopKit +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit + + +/// A quantity of carbs absorbed over a given date interval +public struct AbsorbedCarbValue: SampleValue { + /// The quantity of carbs absorbed + public let observed: HKQuantity + /// The quantity of carbs absorbed, clamped to the original prediction + public let clamped: HKQuantity + /// The quantity of carbs entered as eaten + public let total: HKQuantity + /// The quantity of carbs expected to still absorb + public let remaining: HKQuantity + /// The dates over which absorption was observed + public let observedDate: DateInterval + + /// The predicted time for the remaining carbs to absorb + public let estimatedTimeRemaining: TimeInterval + + // Total predicted absorption time for this carb entry + public var estimatedDate: DateInterval { + return DateInterval(start: observedDate.start, duration: observedDate.duration + estimatedTimeRemaining) + } + + /// The amount of time required to absorb observed carbs + public let timeToAbsorbObservedCarbs: TimeInterval + + /// Whether absorption is still in-progress + public var isActive: Bool { + return estimatedTimeRemaining > 0 + } + + public var observedProgress: HKQuantity { + let gram = HKUnit.gram() + let totalGrams = total.doubleValue(for: gram) + let percent = HKUnit.percent() + + guard totalGrams > 0 else { + return HKQuantity(unit: percent, doubleValue: 0) + } + + return HKQuantity( + unit: percent, + doubleValue: observed.doubleValue(for: gram) / totalGrams + ) + } + + public var clampedProgress: HKQuantity { + let gram = HKUnit.gram() + let totalGrams = total.doubleValue(for: gram) + let percent = HKUnit.percent() + + guard totalGrams > 0 else { + return HKQuantity(unit: percent, doubleValue: 0) + } + + return HKQuantity( + unit: percent, + doubleValue: clamped.doubleValue(for: gram) / totalGrams + ) + } + + // MARK: SampleValue + + public var quantity: HKQuantity { + return clamped + } + + public var startDate: Date { + return estimatedDate.start + } +} diff --git a/Sources/LoopAlgorithm/AutomaticDoseRecommendation.swift b/Sources/LoopAlgorithm/AutomaticDoseRecommendation.swift new file mode 100644 index 0000000..92413b1 --- /dev/null +++ b/Sources/LoopAlgorithm/AutomaticDoseRecommendation.swift @@ -0,0 +1,25 @@ +// +// AutomaticDoseRecommendation.swift +// LoopKit +// +// Created by Pete Schwamb on 1/16/21. +// Copyright © 2021 LoopKit Authors. All rights reserved. +// + +import Foundation + +public struct AutomaticDoseRecommendation: Equatable { + public var basalAdjustment: TempBasalRecommendation? + public var bolusUnits: Double? + + public init(basalAdjustment: TempBasalRecommendation?, bolusUnits: Double? = nil) { + self.basalAdjustment = basalAdjustment + self.bolusUnits = bolusUnits + } + + public var hasDosingChange: Bool { + return basalAdjustment != nil || bolusUnits != nil + } +} + +extension AutomaticDoseRecommendation: Codable {} diff --git a/Sources/LoopAlgorithm/CarbEntry.swift b/Sources/LoopAlgorithm/CarbEntry.swift new file mode 100644 index 0000000..9c6d39a --- /dev/null +++ b/Sources/LoopAlgorithm/CarbEntry.swift @@ -0,0 +1,14 @@ +// +// CarbEntry.swift +// +// Created by Nathan Racklyeft on 1/3/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import Foundation + + +public protocol CarbEntry: SampleValue { + var absorptionTime: TimeInterval? { get } +} + diff --git a/Sources/LoopAlgorithm/CarbMath.swift b/Sources/LoopAlgorithm/CarbMath.swift new file mode 100644 index 0000000..2c1e12a --- /dev/null +++ b/Sources/LoopAlgorithm/CarbMath.swift @@ -0,0 +1,908 @@ +// +// CarbMath.swift +// CarbKit +// +// Created by Nathan Racklyeft on 1/16/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import Foundation +import HealthKit + +public struct CarbMath { + public static let maximumAbsorptionTimeInterval: TimeInterval = .hours(10) + public static let defaultAbsorptionTime: TimeInterval = .hours(3) + public static let defaultAbsorptionTimeOverrun: Double = 1.5 + public static let defaultEffectDelay: TimeInterval = .minutes(10) +} + +public enum CarbAbsorptionModel { + case linear + case piecewiseLinear + + public var model: CarbAbsorptionComputable { + switch self { + case .linear: + return LinearAbsorption() + case .piecewiseLinear: + return PiecewiseLinearAbsorption() + } + } +} + +public struct CarbModelSettings { + var absorptionModel: CarbAbsorptionComputable + var initialAbsorptionTimeOverrun: Double + var adaptiveAbsorptionRateEnabled: Bool + var adaptiveRateStandbyIntervalFraction: Double + + init(absorptionModel: CarbAbsorptionComputable, initialAbsorptionTimeOverrun: Double, adaptiveAbsorptionRateEnabled: Bool, adaptiveRateStandbyIntervalFraction: Double = 0.2) { + self.absorptionModel = absorptionModel + self.initialAbsorptionTimeOverrun = initialAbsorptionTimeOverrun + self.adaptiveAbsorptionRateEnabled = adaptiveAbsorptionRateEnabled + self.adaptiveRateStandbyIntervalFraction = adaptiveRateStandbyIntervalFraction + } +} + +public protocol CarbAbsorptionComputable { + /// Returns the percentage of total carbohydrates absorbed as blood glucose at a specified interval after eating. + /// + /// - Parameters: + /// - percentTime: The percentage of the total absorption time + /// - Returns: The percentage of the total carbohydrates that have been absorbed as blood glucose + func percentAbsorptionAtPercentTime(_ percentTime: Double) -> Double + + /// Returns the percent of total absorption time for a percentage of total carbohydrates absorbed + /// + /// The is the inverse of perecentAbsorptionAtPercentTime( :percentTime: ) + /// + /// - Parameters: + /// - percentAbsorption: The percentage of the total carbohydrates that have been absorbed as blood glucose + /// - Returns: The percentage of the absorption time needed to absorb the percentage of the total carbohydrates + func percentTimeAtPercentAbsorption(_ percentAbsorption: Double) -> Double + + /// Returns the total absorption time for a percentage of total carbohydrates absorbed as blood glucose at a specified interval after eating. + /// + /// - Parameters: + /// - percentAbsorption: The percentage of the total carbohydrates that have been absorbed as blood glucose + /// - time: The interval after the carbohydrates were eaten + /// - Returns: The total time of carbohydrates absorption + func absorptionTime(forPercentAbsorption percentAbsorption: Double, atTime time: TimeInterval) -> TimeInterval + + /// Returns the number of total carbohydrates absorbed as blood glucose at a specified interval after eating + /// + /// - Parameters: + /// - total: The total number of carbohydrates eaten + /// - time: The interval after carbohydrates were eaten + /// - absorptionTime: The total time of carbohydrates absorption + /// - Returns: The number of total carbohydrates that have been absorbed as blood glucose + func absorbedCarbs(of total: Double, atTime time: TimeInterval, absorptionTime: TimeInterval) -> Double + + /// Returns the number of total carbohydrates not yet absorbed as blood glucose at a specified interval after eating + /// + /// - Parameters: + /// - total: The total number of carbs eaten + /// - time: The interval after carbohydrates were eaten + /// - absorptionTime: The total time of carb absorption + /// - Returns: The number of total carbohydrates that have not yet been absorbed as blood glucose + func unabsorbedCarbs(of total: Double, atTime time: TimeInterval, absorptionTime: TimeInterval) -> Double + + /// Returns the normalized rate of carbohydrates absorption at a specified percentage of the absorption time + /// + /// - Parameters: + /// - percentTime: The percentage of absorption time elapsed since the carbohydrates were eaten + /// - Returns: The percentage absorption rate at the percentage of absorption time + func percentRateAtPercentTime(_ percentTime: Double) -> Double +} + + +extension CarbAbsorptionComputable { + public func absorbedCarbs(of total: Double, atTime time: TimeInterval, absorptionTime: TimeInterval) -> Double { + let percentTime = time / absorptionTime + return total * percentAbsorptionAtPercentTime(percentTime) + } + + public func unabsorbedCarbs(of total: Double, atTime time: TimeInterval, absorptionTime: TimeInterval) -> Double { + let percentTime = time / absorptionTime + return total * (1.0 - percentAbsorptionAtPercentTime(percentTime)) + } + + public func absorptionTime(forPercentAbsorption percentAbsorption: Double, atTime time: TimeInterval) -> TimeInterval { + let percentTime = max(percentTimeAtPercentAbsorption(percentAbsorption), .ulpOfOne) + return time / percentTime + } + + func timeToAbsorb(forPercentAbsorbed percentAbsorption: Double, totalAbsorptionTime: TimeInterval) -> TimeInterval { + let percentTime = percentTimeAtPercentAbsorption(percentAbsorption) + return percentTime * totalAbsorptionTime + } + +} + + +// MARK: - Parabolic absorption as described by Scheiner +// This is the integral approximation of the Scheiner GI curve found in Think Like a Pancreas, Fig 7-8, which first appeared in [GlucoDyn](https://github.com/kenstack/GlucoDyn) +struct ParabolicAbsorption: CarbAbsorptionComputable { + func percentAbsorptionAtPercentTime(_ percentTime: Double) -> Double { + switch percentTime { + case let t where t <= 0.0: + return 0.0 + case let t where t <= 0.5: + return 2.0 * pow(t, 2) + case let t where t < 1.0: + return -1.0 + 2.0 * t * (2.0 - t) + default: + return 1.0 + } + } + + func percentTimeAtPercentAbsorption(_ percentAbsorption: Double) -> Double { + switch percentAbsorption { + case let a where a <= 0: + return 0.0 + case let a where a <= 0.5: + return sqrt(0.5 * a) + case let a where a < 1.0: + return 1.0 - sqrt(0.5 * (1.0 - a)) + default: + return 1.0 + } + } + + func percentRateAtPercentTime(_ percentTime: Double) -> Double { + switch percentTime { + case let t where t > 0 && t <= 0.5: + return 4.0 * t + case let t where t > 0.5 && t < 1.0: + return 4.0 - 4.0 * t + default: + return 0.0 + } + } +} + + +// MARK: - Linear absorption as a factor of reported duration +struct LinearAbsorption: CarbAbsorptionComputable { + func percentAbsorptionAtPercentTime(_ percentTime: Double) -> Double { + switch percentTime { + case let t where t <= 0.0: + return 0.0 + case let t where t < 1.0: + return t + default: + return 1.0 + } + } + + func percentTimeAtPercentAbsorption(_ percentAbsorption: Double) -> Double { + switch percentAbsorption { + case let a where a <= 0.0: + return 0.0 + case let a where a < 1.0: + return a + default: + return 1.0 + } + } + + func percentRateAtPercentTime(_ percentTime: Double) -> Double { + switch percentTime { + case let t where t > 0.0 && t <= 1.0: + return 1.0 + default: + return 0.0 + } + } +} + +// MARK: - Piecewise linear absorption as a factor of reported duration +/// Nonlinear carb absorption model where absorption rate increases linearly from zero to a maximum value at a fraction of absorption time equal to percentEndOfRise, then remains constant until a fraction of absorption time equal to percentStartOfFall, and then decreases linearly to zero at the end of absorption time +/// - Parameters: +/// - percentEndOfRise: the percentage of absorption time when absorption rate reaches maximum, must be strictly between 0 and 1 +/// - percentStartOfFall: the percentage of absorption time when absorption rate starts to decay, must be stritctly between 0 and 1 and greater than percentEndOfRise +public struct PiecewiseLinearAbsorption: CarbAbsorptionComputable { + + let percentEndOfRise = 0.15 + let percentStartOfFall = 0.5 + + var scale: Double { + return 2.0 / (1.0 + percentStartOfFall - percentEndOfRise) + } + + public init() { } + + public func percentAbsorptionAtPercentTime(_ percentTime: Double) -> Double { + switch percentTime { + case let t where t <= 0.0: + return 0.0 + case let t where t < percentEndOfRise: + return 0.5 * scale * pow(t, 2.0) / percentEndOfRise + case let t where t >= percentEndOfRise && t < percentStartOfFall: + return scale * (t - 0.5 * percentEndOfRise) + case let t where t >= percentStartOfFall && t < 1.0: + return scale * (percentStartOfFall - 0.5 * percentEndOfRise + + (t - percentStartOfFall) * (1.0 - 0.5 * (t - percentStartOfFall) / (1.0 - percentStartOfFall))) + default: + return 1.0 + } + } + + public func percentTimeAtPercentAbsorption(_ percentAbsorption: Double) -> Double { + switch percentAbsorption { + case let a where a <= 0: + return 0.0 + case let a where a > 0.0 && a < 0.5 * scale * percentEndOfRise: + return sqrt(2.0 * percentEndOfRise * a / scale) + case let a where a >= 0.5 * scale * percentEndOfRise && a < scale * (percentStartOfFall - 0.5 * percentEndOfRise): + return 0.5 * percentEndOfRise + a / scale + case let a where a >= scale * (percentStartOfFall - 0.5 * percentEndOfRise) && a < 1.0: + return 1.0 - sqrt((1.0 - percentStartOfFall) * + (1.0 + percentStartOfFall - percentEndOfRise) * (1.0 - a)) + default: + return 1.0 + } + } + + public func percentRateAtPercentTime(_ percentTime: Double) -> Double { + switch percentTime { + case let t where t > 0 && t < percentEndOfRise: + return scale * t / percentEndOfRise + case let t where t >= percentEndOfRise && t < percentStartOfFall: + return scale + case let t where t >= percentStartOfFall && t < 1.0: + return scale * ((1.0 - t) / (1.0 - percentStartOfFall)) + default: + return 0.0 + } + } +} + +extension CarbEntry { + + func carbsOnBoard(at date: Date, defaultAbsorptionTime: TimeInterval, delay: TimeInterval, absorptionModel: CarbAbsorptionComputable) -> Double { + let time = date.timeIntervalSince(startDate) + let value: Double + + if time >= 0 { + value = absorptionModel.unabsorbedCarbs(of: quantity.doubleValue(for: HKUnit.gram()), atTime: time - delay, absorptionTime: absorptionTime ?? defaultAbsorptionTime) + } else { + value = 0 + } + + return value + } + + // g + func absorbedCarbs( + at date: Date, + absorptionTime: TimeInterval, + delay: TimeInterval, + absorptionModel: CarbAbsorptionComputable + ) -> Double { + let time = date.timeIntervalSince(startDate) + + return absorptionModel.absorbedCarbs( + of: quantity.doubleValue(for: .gram()), + atTime: time - delay, + absorptionTime: absorptionTime + ) + } + + // mg/dL / g * g + fileprivate func glucoseEffect( + at date: Date, + carbRatio: HKQuantity, + insulinSensitivity: HKQuantity, + defaultAbsorptionTime: TimeInterval, + delay: TimeInterval, + absorptionModel: CarbAbsorptionComputable + ) -> Double { + return insulinSensitivity.doubleValue(for: HKUnit.milligramsPerDeciliter) / carbRatio.doubleValue(for: .gram()) * absorbedCarbs(at: date, absorptionTime: absorptionTime ?? defaultAbsorptionTime, delay: delay, absorptionModel: absorptionModel) + } + + fileprivate func estimatedAbsorptionTime(forAbsorbedCarbs carbs: Double, at date: Date, absorptionModel: CarbAbsorptionComputable) -> TimeInterval { + let time = date.timeIntervalSince(startDate) + + return max(time, absorptionModel.absorptionTime(forPercentAbsorption: carbs / quantity.doubleValue(for: .gram()), atTime: time)) + } +} + +extension Collection where Element: CarbEntry { + fileprivate func simulationDateRange( + from start: Date? = nil, + to end: Date? = nil, + defaultAbsorptionTime: TimeInterval, + delay: TimeInterval, + delta: TimeInterval + ) -> (start: Date, end: Date)? { + guard count > 0 else { + return nil + } + + if let start = start, let end = end { + return (start: start.dateFlooredToTimeInterval(delta), end: end.dateCeiledToTimeInterval(delta)) + } else { + var minDate = first!.startDate + var maxDate = minDate + + for sample in self { + if sample.startDate < minDate { + minDate = sample.startDate + } + + let endDate = sample.endDate.addingTimeInterval(sample.absorptionTime ?? defaultAbsorptionTime).addingTimeInterval(delay) + if endDate > maxDate { + maxDate = endDate + } + } + + return ( + start: (start ?? minDate).dateFlooredToTimeInterval(delta), + end: (end ?? maxDate).dateCeiledToTimeInterval(delta) + ) + } + } + + /// Creates groups of entries that have overlapping absorption date intervals + /// + /// - Parameters: + /// - defaultAbsorptionTime: The default absorption time value, if not set on the entry + /// - Returns: An array of arrays representing groups of entries, in chronological order by entry startDate + func groupedByOverlappingAbsorptionTimes( + defaultAbsorptionTime: TimeInterval + ) -> [[Iterator.Element]] { + var batches: [[Iterator.Element]] = [] + + for entry in sorted(by: { $0.startDate < $1.startDate }) { + if let lastEntry = batches.last?.last, + lastEntry.startDate.addingTimeInterval(lastEntry.absorptionTime ?? defaultAbsorptionTime) > entry.startDate + { + batches[batches.count - 1].append(entry) + } else { + batches.append([entry]) + } + } + + return batches + } + + func carbsOnBoard( + from start: Date? = nil, + to end: Date? = nil, + defaultAbsorptionTime: TimeInterval = CarbMath.defaultAbsorptionTime, + absorptionModel: CarbAbsorptionComputable, + delay: TimeInterval = CarbMath.defaultEffectDelay, + delta: TimeInterval = GlucoseMath.defaultDelta + ) -> [CarbValue] { + guard let (startDate, endDate) = simulationDateRange(from: start, to: end, defaultAbsorptionTime: defaultAbsorptionTime, delay: delay, delta: delta) else { + return [] + } + + var date = startDate + var values = [CarbValue]() + + repeat { + let value = reduce(0.0) { (value, entry) -> Double in + return value + entry.carbsOnBoard(at: date, defaultAbsorptionTime: defaultAbsorptionTime, delay: delay, absorptionModel: absorptionModel) + } + + values.append(CarbValue(startDate: date, value: value)) + date = date.addingTimeInterval(delta) + } while date <= endDate + + return values + } + + var totalCarbs: CarbValue? { + guard count > 0 else { + return nil + } + + let unit = HKUnit.gram() + var startDate = Date.distantFuture + var totalGrams: Double = 0 + + for entry in self { + totalGrams += entry.quantity.doubleValue(for: unit) + + if entry.startDate < startDate { + startDate = entry.startDate + } + } + + return CarbValue(startDate: startDate, value: totalGrams) + } +} + + +// MARK: - Dyanamic absorption overrides +extension Collection { + + public func dynamicCarbsOnBoard( + at date: Date, + absorptionModel: CarbAbsorptionComputable + ) -> Double where Element == CarbStatus { + reduce(0.0) { (value, entry) -> Double in + return value + entry.dynamicCarbsOnBoard( + at: date, + defaultAbsorptionTime: CarbMath.defaultAbsorptionTime, + delay: CarbMath.defaultEffectDelay, + delta: GlucoseMath.defaultDelta, + absorptionModel: absorptionModel + ) + } + } + + public func dynamicCarbsOnBoard( + from start: Date? = nil, + to end: Date? = nil, + absorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption() + ) -> [CarbValue] where Element == CarbStatus { + + guard let (startDate, endDate) = simulationDateRange( + from: start, + to: end, + defaultAbsorptionTime: CarbMath.defaultAbsorptionTime, + delay: CarbMath.defaultEffectDelay, + delta: GlucoseMath.defaultDelta + ) else { + return [] + } + + var date = startDate + var values = [CarbValue]() + + repeat { + let value = reduce(0.0) { (value, entry) -> Double in + return value + entry.dynamicCarbsOnBoard( + at: date, + defaultAbsorptionTime: CarbMath.defaultAbsorptionTime, + delay: CarbMath.defaultEffectDelay, + delta: GlucoseMath.defaultDelta, + absorptionModel: absorptionModel + ) + } + + values.append(CarbValue(startDate: date, value: value)) + date = date.addingTimeInterval(GlucoseMath.defaultDelta) + } while date <= endDate + + return values + } + + public func dynamicGlucoseEffects( + from start: Date? = nil, + to end: Date? = nil, + carbRatios: [AbsoluteScheduleValue], + insulinSensitivities: [AbsoluteScheduleValue], + defaultAbsorptionTime: TimeInterval = CarbMath.defaultAbsorptionTime, + absorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption(), + delay: TimeInterval = CarbMath.defaultEffectDelay, + delta: TimeInterval = GlucoseMath.defaultDelta + ) -> [GlucoseEffect] where Element == CarbStatus { + guard let (startDate, endDate) = simulationDateRange(from: start, to: end, defaultAbsorptionTime: defaultAbsorptionTime, delay: delay, delta: delta) else { + return [] + } + + var date = startDate + var values = [GlucoseEffect]() + let mgdL = HKUnit.milligramsPerDeciliter + + repeat { + let value = reduce(0.0) { (value, entry) -> Double in + guard let isf = insulinSensitivities.closestPrior(to: entry.startDate), let cr = carbRatios.closestPrior(to: entry.startDate) else { + preconditionFailure("Insulin Sensitivities and Carb Ratios must cover all CarbStatus start dates") + } + let csf = isf.value.doubleValue(for: mgdL) / cr.value + + return value + csf * entry.dynamicAbsorbedCarbs( + at: date, + absorptionTime: entry.absorptionTime ?? defaultAbsorptionTime, + delay: delay, + delta: delta, + absorptionModel: absorptionModel + ) + } + + values.append(GlucoseEffect(startDate: date, quantity: HKQuantity(unit: mgdL, doubleValue: value))) + date = date.addingTimeInterval(delta) + } while date <= endDate + + return values + } + + /// The quantity of carbs expected to still absorb at the last date of absorption + public func getClampedCarbsOnBoard() -> CarbValue? where Element == CarbStatus { + guard let firstAbsorption = first?.absorption else { + return nil + } + + let gram = HKUnit.gram() + var maxObservedEndDate = firstAbsorption.observedDate.end + var remainingTotalGrams: Double = 0 + + for entry in self { + guard let absorption = entry.absorption else { + continue + } + + maxObservedEndDate = Swift.max(maxObservedEndDate, absorption.observedDate.end) + remainingTotalGrams += absorption.remaining.doubleValue(for: gram) + } + + return CarbValue(startDate: maxObservedEndDate, value: remainingTotalGrams) + } +} + + +/// Aggregates and computes data about the absorption of a CarbEntry to create a CarbStatus value. +/// +/// There are three key components managed by this builder: +/// - The entry data as reported by the user +/// - The observed data as calculated from glucose changes relative to insulin curves +/// - The minimum/maximum amounts of absorption used to clamp our observation data within reasonable bounds +fileprivate class CarbStatusBuilder { + + // MARK: Model settings + + private var absorptionModel: CarbAbsorptionComputable + + private var adaptiveAbsorptionRateEnabled: Bool + + private var adaptiveRateStandbyIntervalFraction: Double + + private var adaptiveRateStandbyInterval: TimeInterval { + return initialAbsorptionTime * adaptiveRateStandbyIntervalFraction + } + + // MARK: User-entered data + + /// The carb entry input + let entry: T + + /// The unit used for carb values + let carbUnit: HKUnit + + /// The total grams entered for this entry + let entryGrams: Double + + /// The total glucose effect expected for this entry, in glucose units + let entryEffect: Double + + /// The carbohydrate-sensitivity factor for this entry, in glucose units per gram + let carbohydrateSensitivityFactor: Double + + /// The absorption time for this entry before any absorption is observed + let initialAbsorptionTime: TimeInterval + + // MARK: Minimum/maximum bounding factors + + /// The maximum absorption time allowed for this entry, determining the minimum absorption rate + let maxAbsorptionTime: TimeInterval + + /// An amount of time to wait after the entry date before minimum absorption is assumed to begin + let delay: TimeInterval + + /// The maximum end date allowed for this entry's absorption + let maxEndDate: Date + + /// The last date we have effects observed, or "now" in real-time analysis. + private let lastEffectDate: Date + + /// The minimum-required carb absorption rate for this entry, in g/s + var minAbsorptionRate: Double { + return entryGrams / maxAbsorptionTime + } + + /// The minimum amount of carbs we assume must have absorbed at the last observation date + private var minPredictedGrams: Double { + // We incorporate a delay when calculating minimum absorption values + let time = lastEffectDate.timeIntervalSince(entry.startDate) - delay + return absorptionModel.absorbedCarbs(of: entryGrams, atTime: time, absorptionTime: maxAbsorptionTime) + } + + // MARK: Incremental observation + + /// The date at which we observe all the carbs were absorbed. or nil if carb absorption has not finished + private var observedCompletionDate: Date? + + /// The total observed effect for each entry, in glucose units + private(set) var observedEffect: Double = 0 + + /// The timeline of absorption amounts credited to this carb entry, in grams, for computation of historical COB and effect history + private(set) var observedTimeline: [CarbValue] = [] + + /// The amount of carbs we've observed absorbing + private var observedGrams: Double { + return observedEffect / carbohydrateSensitivityFactor + } + + /// The amount of effect remaining until 100% of entry absorption is observed + var remainingEffect: Double { + return max(entryEffect - observedEffect, 0) + } + + /// The dates over which we observed absorption, from start until 100% or last observed effect. + private var observedAbsorptionDates: DateInterval { + return DateInterval(start: entry.startDate, end: observedCompletionDate ?? lastEffectDate) + } + + + // MARK: Clamped results + + /// The number of carbs absorbed, suitable for use in calculations. + /// This is bounded by minimumPredictedGrams and the entry total. + private var clampedGrams: Double { + let minPredictedGrams = self.minPredictedGrams + + return min(entryGrams, max(minPredictedGrams, observedGrams)) + } + + private var percentAbsorbed: Double { + return clampedGrams / entryGrams + } + + /// The amount of time needed to absorb observed grams + private var timeToAbsorbObservedCarbs: TimeInterval { + let time = lastEffectDate.timeIntervalSince(entry.startDate) - delay + guard time > 0 else { + return 0.0 + } + var timeToAbsorb: TimeInterval + if adaptiveAbsorptionRateEnabled && time > adaptiveRateStandbyInterval { + // If adaptive absorption rate is enabled, and if the time since start of absorption is greater than the standby interval, the time to absorb observed carbs equals the obervation time + timeToAbsorb = time + } else { + // If adaptive absorption rate is disabled, or if the time since start of absorption is less than the standby interval, the time to absorb observed carbs is calculated based on the absorption model + timeToAbsorb = absorptionModel.timeToAbsorb(forPercentAbsorbed: percentAbsorbed, totalAbsorptionTime: initialAbsorptionTime) + } + return min(timeToAbsorb, maxAbsorptionTime) + } + + /// The amount of time needed for the remaining entry grams to absorb + private var estimatedTimeRemaining: TimeInterval { + let time = lastEffectDate.timeIntervalSince(entry.startDate) - delay + guard time > 0 else { + return initialAbsorptionTime + } + let notToExceedTimeRemaining = max(maxAbsorptionTime - time, 0.0) + guard notToExceedTimeRemaining > 0 else { + return 0.0 + } + var dynamicTimeRemaining: TimeInterval + if adaptiveAbsorptionRateEnabled && time > adaptiveRateStandbyInterval { + // If adaptive absorption rate is enabled, and if the time since start of absorption is greater than the standby interval, the remaining time is estimated assuming the observed relative absorption rate persists for the remaining carbs + let dynamicAbsorptionTime = absorptionModel.absorptionTime(forPercentAbsorption: percentAbsorbed, atTime: time) + dynamicTimeRemaining = dynamicAbsorptionTime - time + } else { + // If adaptive absorption rate is disabled, or if the time since start of absorption is less than the standby interval, the remaining time is estimated assuming the modeled absorption rate + dynamicTimeRemaining = initialAbsorptionTime - timeToAbsorbObservedCarbs + } + // time remaining must not extend beyond the maximum absorption time + return min(dynamicTimeRemaining, notToExceedTimeRemaining) + } + + /// The timeline of observed absorption, if greater than the minimum required absorption. + private var clampedTimeline: [CarbValue]? { + return observedGrams >= minPredictedGrams ? observedTimeline : nil + } + + /// Configures a new builder + /// + /// - Parameters: + /// - entry: The carb entry input + /// - carbUnit: The unit used for carb values + /// - carbohydrateSensitivityFactor: The carbohydrate-sensitivity factor for the entry, in glucose units per gram + /// - initialAbsorptionTime: The absorption initially assigned to this entry before any absorption is observed + /// - maxAbsorptionTime: The maximum absorption time allowed for this entry, determining the minimum absorption rate + /// - delay: An amount of time to wait after the entry date before minimum absorption is assumed to begin + /// - lastEffectDate: The last recorded date of effect observation, used to initialize absorption at model defined rate + /// - initialObservedEffect: The initial amount of observed effect, in glucose units. Defaults to 0 + /// - absorptionModel: The absorption model to use when computing remaining absorption + /// - adaptiveAbsorptionRateEnabled: Whether the remaining absorption rate changes based in observed absorption rate + /// - adaptiveRateStandbyIntervalFraction: The delay, specified as a fraction of total absorption time, before the absorption rate will change based on observed absorption rate. Only used if adaptiveAbsorptionRateEnabled is true. + init(entry: T, carbUnit: HKUnit, carbohydrateSensitivityFactor: Double, initialAbsorptionTime: TimeInterval, maxAbsorptionTime: TimeInterval, delay: TimeInterval, lastEffectDate: Date?, absorptionModel: CarbAbsorptionComputable, adaptiveAbsorptionRateEnabled: Bool, adaptiveRateStandbyIntervalFraction: Double, initialObservedEffect: Double = 0) { + self.entry = entry + self.carbUnit = carbUnit + self.carbohydrateSensitivityFactor = carbohydrateSensitivityFactor + self.initialAbsorptionTime = initialAbsorptionTime + self.maxAbsorptionTime = maxAbsorptionTime + self.delay = delay + self.observedEffect = initialObservedEffect + self.absorptionModel = absorptionModel + self.adaptiveAbsorptionRateEnabled = adaptiveAbsorptionRateEnabled + self.adaptiveRateStandbyIntervalFraction = adaptiveRateStandbyIntervalFraction + self.entryGrams = entry.quantity.doubleValue(for: carbUnit) + self.entryEffect = entryGrams * carbohydrateSensitivityFactor + self.maxEndDate = entry.startDate.addingTimeInterval(maxAbsorptionTime + delay) + self.lastEffectDate = min( + maxEndDate, + Swift.max(lastEffectDate ?? entry.startDate, entry.startDate) + ) + + } + + /// Increments the builder state with the next glucose effect. + /// + /// This function should only be called with values in ascending date order. + /// + /// - Parameters: + /// - effect: The effect value, in glucose units corresponding to `carbohydrateSensitivityFactor` + /// - start: The start date of the effect + /// - end: The end date of the effect + func addNextEffect(_ effect: Double, start: Date, end: Date) { + guard start >= entry.startDate else { + return + } + + observedEffect += effect + + if observedCompletionDate == nil { + // Continue recording the timeline until 100% of the carbs have been observed + observedTimeline.append( + CarbValue( + startDate: start, + endDate: end, + value: effect / carbohydrateSensitivityFactor + ) + ) + + // Once 100% of the carbs are observed, track the endDate + if observedEffect + Double(Float.ulpOfOne) >= entryEffect { + observedCompletionDate = end + } + } + } + + /// The resulting CarbStatus value + var result: CarbStatus { + let absorption = AbsorbedCarbValue( + observed: HKQuantity(unit: carbUnit, doubleValue: observedGrams), + clamped: HKQuantity(unit: carbUnit, doubleValue: clampedGrams), + total: entry.quantity, + remaining: HKQuantity(unit: carbUnit, doubleValue: entryGrams - clampedGrams), + observedDate: observedAbsorptionDates, + estimatedTimeRemaining: estimatedTimeRemaining, + timeToAbsorbObservedCarbs: timeToAbsorbObservedCarbs + ) + + return CarbStatus( + entry: entry, + absorption: absorption, + observedTimeline: clampedTimeline + ) + } + + func absorptionRateAtTime(t: TimeInterval) -> Double { + let dynamicAbsorptionTime = min(observedAbsorptionDates.duration + estimatedTimeRemaining, maxAbsorptionTime) + guard dynamicAbsorptionTime > 0 else { + return 0.0 + } + // time t nomalized to absorption time + let percentTime = t / dynamicAbsorptionTime + let averageAbsorptionRate = entryGrams / dynamicAbsorptionTime + return averageAbsorptionRate * absorptionModel.percentRateAtPercentTime(percentTime) + } + +} + + +// MARK: - Sorted collections of CarbEntries +extension Collection where Element: CarbEntry { + /// Maps a sorted timeline of carb entries to the observed absorbed carbohydrates for each, from a timeline of glucose effect velocities. + /// + /// This makes some important assumptions: + /// - insulin effects, used with glucose to calculate counteraction, are "correct" + /// - carbs are absorbed completely in the order they were eaten without mixing or overlapping effects + /// + /// - Parameters: + /// - effectVelocities: A timeline of glucose effect velocities, ordered by start date + /// - carbRatio: The timeline of carb ratios, in grams per unit + /// - insulinSensitivity: The timeline of insulin sensitivities, in units of insulin per glucose-unit, covering the carb entries + /// - absorptionTimeOverrun: A multiplier for determining the minimum absorption time from the specified absorption time + /// - 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( + to effectVelocities: [GlucoseEffectVelocity], + carbRatio: [AbsoluteScheduleValue], + insulinSensitivity: [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] { + guard count > 0 else { + // TODO: Apply unmatched effects to meal prediction + return [] + } + + // for computation + let glucoseUnit = HKUnit.milligramsPerDeciliter + let carbUnit = HKUnit.gram() + + let builders: [CarbStatusBuilder] = map { (entry) in + guard + let entryCarbRatio = carbRatio.closestPrior(to: entry.startDate), + let entryInsulinSensitivity = insulinSensitivity.closestPrior(to: entry.startDate) else + { + preconditionFailure("Insulin sensitivity and carb ratio timelines must cover carb entry start dates") + } + + let initialAbsorptionTimeOverrun = initialAbsorptionTimeOverrun + + return CarbStatusBuilder( + entry: entry, + carbUnit: carbUnit, + carbohydrateSensitivityFactor: entryInsulinSensitivity.value.doubleValue(for: glucoseUnit) / entryCarbRatio.value, + initialAbsorptionTime: (entry.absorptionTime ?? defaultAbsorptionTime) * initialAbsorptionTimeOverrun, + maxAbsorptionTime: (entry.absorptionTime ?? defaultAbsorptionTime) * absorptionTimeOverrun, + delay: delay, + lastEffectDate: effectVelocities.last?.endDate, + absorptionModel: absorptionModel, + adaptiveAbsorptionRateEnabled: adaptiveAbsorptionRateEnabled, + adaptiveRateStandbyIntervalFraction: adaptiveRateStandbyIntervalFraction + ) + } + + for dxEffect in effectVelocities { + guard dxEffect.endDate > dxEffect.startDate else { + assertionFailure() + continue + } + + // calculate instantanous absorption rate for all active entries + + // Apply effect to all active entries + + // Select only the entries whose dates overlap the current date interval. + // These are not necessarily contiguous as maxEndDate varies between entries + let activeBuilders = builders.filter { (builder) -> Bool in + return dxEffect.startDate < builder.maxEndDate && dxEffect.startDate >= builder.entry.startDate + } + + // Ignore velocities < 0 when estimating carb absorption. + // These are most likely the result of insulin absorption increases such as + // during activity + var effectValue = Swift.max(0, dxEffect.effect.quantity.doubleValue(for: glucoseUnit)) + + // Sum the current absorption rates of each active entry to determine how to split the active effects + var totalRate = activeBuilders.reduce(0) { (totalRate, builder) -> Double in + let effectTime = dxEffect.startDate.timeIntervalSince(builder.entry.startDate) + let absorptionRateAtEffectTime = builder.absorptionRateAtTime(t: effectTime) + return totalRate + absorptionRateAtEffectTime + } + + for builder in activeBuilders { + // Apply a portion of the effect to this entry + let effectTime = dxEffect.startDate.timeIntervalSince(builder.entry.startDate) + let absorptionRateAtEffectTime = builder.absorptionRateAtTime(t: effectTime) + // If total rate is zero, assign zero to partial effect + var partialEffectValue: Double = 0.0 + if totalRate > 0 { + partialEffectValue = Swift.min(builder.remainingEffect, (absorptionRateAtEffectTime / totalRate) * effectValue) + totalRate -= absorptionRateAtEffectTime + effectValue -= partialEffectValue + } + + builder.addNextEffect(partialEffectValue, start: dxEffect.startDate, end: dxEffect.endDate) + + // If there's still remainder effects with no additional entries to account them to, count them as overrun on the final entry + if effectValue > Double(Float.ulpOfOne) && builder === activeBuilders.last! { + builder.addNextEffect(effectValue, start: dxEffect.startDate, end: dxEffect.endDate) + } + } + + // We have remaining effect and no activeBuilders (otherwise we would have applied the effect to the last one) + if effectValue > Double(Float.ulpOfOne) { + // TODO: Track "phantom meals" + } + } + + return builders.map { $0.result } + } +} diff --git a/Sources/LoopAlgorithm/CarbStatus.swift b/Sources/LoopAlgorithm/CarbStatus.swift new file mode 100644 index 0000000..c7887be --- /dev/null +++ b/Sources/LoopAlgorithm/CarbStatus.swift @@ -0,0 +1,129 @@ +// +// CarbStatus.swift +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit + + +public struct CarbStatus { + /// Details entered by the user + public let entry: T + + /// The last-computed absorption of the carbs + public let absorption: AbsorbedCarbValue? + + /// The timeline of observed carb absorption. Nil if observed absorption is less than the modeled minimum + public let observedTimeline: [CarbValue]? +} + + +// Masquerade as a carb entry, substituting AbsorbedCarbValue's interpretation of absorption time +extension CarbStatus: SampleValue { + public var quantity: HKQuantity { + return entry.quantity + } + + public var startDate: Date { + return entry.startDate + } +} + + +extension CarbStatus: CarbEntry { + public var absorptionTime: TimeInterval? { + return absorption?.estimatedDate.duration ?? entry.absorptionTime + } +} + + +extension CarbStatus { + + func dynamicCarbsOnBoard(at date: Date, defaultAbsorptionTime: TimeInterval, delay: TimeInterval, delta: TimeInterval, absorptionModel: CarbAbsorptionComputable) -> Double { + guard date >= startDate - delta, + let absorption = absorption + else { + // We have to have absorption info for dynamic calculation + return entry.carbsOnBoard(at: date, defaultAbsorptionTime: defaultAbsorptionTime, delay: delay, absorptionModel: absorptionModel) + } + + let unit = HKUnit.gram() + + guard let observedTimeline = observedTimeline, let observationEnd = observedTimeline.last?.endDate else { + // Less than minimum observed or observation not yet started; calc based on modeled absorption rate + let total = absorption.total.doubleValue(for: unit) + let time = date.timeIntervalSince(startDate) - delay + let absorptionTime = absorption.estimatedDate.duration + return absorptionModel.unabsorbedCarbs(of: total, atTime: time, absorptionTime: absorptionTime) + } + + guard date <= observationEnd else { + // Predicted absorption for remaining carbs, post-observation + let effectiveTime = date.timeIntervalSince(observationEnd) + absorption.timeToAbsorbObservedCarbs + let effectiveAbsorptionTime = absorption.timeToAbsorbObservedCarbs + absorption.estimatedTimeRemaining + let total = absorption.total.doubleValue(for: unit) + let unabsorbedAtEffectiveTime = absorptionModel.unabsorbedCarbs(of: total, atTime: effectiveTime, absorptionTime: effectiveAbsorptionTime) + let unabsorbedCarbs = max(unabsorbedAtEffectiveTime, 0.0) + return unabsorbedCarbs + } + + // Observed absorption + // TODO: This creates an O(n^2) situation for COB timelines + let total = entry.quantity.doubleValue(for: unit) + return max(observedTimeline.filter({ $0.endDate <= date }).reduce(total) { (total, value) -> Double in + return total - value.quantity.doubleValue(for: unit) + }, 0) + } + + func dynamicAbsorbedCarbs(at date: Date, absorptionTime: TimeInterval, delay: TimeInterval, delta: TimeInterval, absorptionModel: CarbAbsorptionComputable) -> Double { + guard date >= startDate, + let absorption = absorption + else { + // We have to have absorption info for dynamic calculation + return entry.absorbedCarbs(at: date, absorptionTime: absorptionTime, delay: delay, absorptionModel: absorptionModel) + } + + let unit = HKUnit.gram() + + guard let observedTimeline = observedTimeline, let observationEnd = observedTimeline.last?.endDate else { + // Less than minimum observed or observation not yet started; calc based on modeled absorption rate + let total = absorption.total.doubleValue(for: unit) + let time = date.timeIntervalSince(startDate) - delay + let absorptionTime = absorption.estimatedDate.duration + return absorptionModel.absorbedCarbs(of: total, atTime: time, absorptionTime: absorptionTime) + } + + guard date <= observationEnd else { + // Predicted absorption for remaining carbs, post-observation + let effectiveTime = date.timeIntervalSince(observationEnd) + absorption.timeToAbsorbObservedCarbs + let effectiveAbsorptionTime = absorption.timeToAbsorbObservedCarbs + absorption.estimatedTimeRemaining + let total = absorption.total.doubleValue(for: unit) + let absorbedAtEffectiveTime = absorptionModel.absorbedCarbs(of: total, atTime: effectiveTime, absorptionTime: effectiveAbsorptionTime) + let absorbedCarbs = min(absorbedAtEffectiveTime, total) + return absorbedCarbs + } + + // Observed absorption + // TODO: This creates an O(n^2) situation for carb effect timelines + var sum: Double = 0 + var beforeDate = observedTimeline.filter { (value) -> Bool in + value.startDate.addingTimeInterval(delta) <= date + } + + // Apply only a portion of the value if it extends past the final value + if let last = beforeDate.popLast() { + let observationInterval = DateInterval(start: last.startDate, end: last.endDate) + if observationInterval.duration > 0, + let calculationInterval = DateInterval(start: last.startDate, end: date).intersection(with: observationInterval) + { + sum += calculationInterval.duration / observationInterval.duration * last.quantity.doubleValue(for: unit) + } + } + + return min(beforeDate.reduce(sum) { (sum, value) -> Double in + return sum + value.quantity.doubleValue(for: unit) + }, quantity.doubleValue(for: unit)) + } +} diff --git a/Sources/LoopAlgorithm/CarbValue.swift b/Sources/LoopAlgorithm/CarbValue.swift new file mode 100644 index 0000000..6ae9a7d --- /dev/null +++ b/Sources/LoopAlgorithm/CarbValue.swift @@ -0,0 +1,49 @@ +// +// CarbValue.swift +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit + + +public struct CarbValue: SampleValue { + public let startDate: Date + public let endDate: Date + public var value: Double + + public var quantity: HKQuantity { + return HKQuantity(unit: .gram(), doubleValue: value) + } + + public init(startDate: Date, endDate: Date? = nil, value: Double) { + self.startDate = startDate + self.endDate = endDate ?? startDate + self.value = value + } +} + +extension CarbValue: Equatable {} + +extension CarbValue: 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.endDate = try container.decode(Date.self, forKey: .endDate) + self.value = try container.decode(Double.self, forKey: .value) + } + + 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(value, forKey: .value) + } + + private enum CodingKeys: String, CodingKey { + case startDate + case endDate + case value + } +} diff --git a/Sources/LoopAlgorithm/DoseEntry.swift b/Sources/LoopAlgorithm/DoseEntry.swift new file mode 100644 index 0000000..bbc567e --- /dev/null +++ b/Sources/LoopAlgorithm/DoseEntry.swift @@ -0,0 +1,295 @@ +// +// DoseEntry.swift +// Naterade +// +// Created by Nathan Racklyeft on 1/31/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import Foundation +import HealthKit + + +public struct DoseEntry: TimelineValue, Equatable { + public let type: DoseType + public let startDate: Date + public var endDate: Date + internal let value: Double + public let unit: DoseUnit + public let deliveredUnits: Double? + public let description: String? + public let insulinType: InsulinType? + public let automatic: Bool? + public let manuallyEntered: Bool + public internal(set) var syncIdentifier: String? + public let isMutable: Bool + public let wasProgrammedByPumpUI: Bool + + /// The scheduled basal rate during this dose entry + public internal(set) var scheduledBasalRate: HKQuantity? + + public init(suspendDate: Date, automatic: Bool? = nil, isMutable: Bool = false, wasProgrammedByPumpUI: Bool = false) { + self.init(type: .suspend, startDate: suspendDate, value: 0, unit: .units, automatic: automatic, isMutable: isMutable, wasProgrammedByPumpUI: wasProgrammedByPumpUI) + } + + public init(resumeDate: Date, insulinType: InsulinType? = nil, automatic: Bool? = nil, isMutable: Bool = false, wasProgrammedByPumpUI: Bool = false) { + self.init(type: .resume, startDate: resumeDate, value: 0, unit: .units, insulinType: insulinType, automatic: automatic, isMutable: isMutable, wasProgrammedByPumpUI: wasProgrammedByPumpUI) + } + + // If the insulin model field is nil, it's assumed that the model is the type of insulin the pump dispenses + public init(type: DoseType, startDate: Date, endDate: Date? = nil, value: Double, unit: DoseUnit, deliveredUnits: Double? = nil, description: String? = nil, syncIdentifier: String? = nil, scheduledBasalRate: HKQuantity? = nil, insulinType: InsulinType? = nil, automatic: Bool? = nil, manuallyEntered: Bool = false, isMutable: Bool = false, wasProgrammedByPumpUI: Bool = false) { + self.type = type + self.startDate = startDate + self.endDate = endDate ?? startDate + self.value = value + self.unit = unit + self.deliveredUnits = deliveredUnits + self.description = description + self.syncIdentifier = syncIdentifier + self.scheduledBasalRate = scheduledBasalRate + self.insulinType = insulinType + self.automatic = automatic + self.manuallyEntered = manuallyEntered + self.isMutable = isMutable + self.wasProgrammedByPumpUI = wasProgrammedByPumpUI + } +} + + +extension DoseEntry { + public static var units = HKUnit.internationalUnit() + + public static let unitsPerHour = HKUnit.internationalUnit().unitDivided(by: .hour()) + + private var hours: Double { + return endDate.timeIntervalSince(startDate).hours + } + + public var programmedUnits: Double { + switch unit { + case .units: + return value + case .unitsPerHour: + return value * hours + } + } + + public var unitsPerHour: Double { + switch unit { + case .units: + let hours = self.hours + guard hours != 0 else { + return 0 + } + + return value / hours + case .unitsPerHour: + return value + } + } + + /// The number of units delivered, net the basal rate scheduled during that time, which can be used to compute insulin on-board and glucose effects + public var netBasalUnits: Double { + switch type { + case .bolus: + return deliveredUnits ?? programmedUnits + case .basal: + return 0 + case .resume, .suspend, .tempBasal: + break + } + + guard hours > 0 else { + return 0 + } + + let scheduledUnitsPerHour: Double + if let basalRate = scheduledBasalRate { + scheduledUnitsPerHour = basalRate.doubleValue(for: DoseEntry.unitsPerHour) + } else { + scheduledUnitsPerHour = 0 + } + + let scheduledUnits = scheduledUnitsPerHour * hours + return unitsInDeliverableIncrements - scheduledUnits + } + + /// The rate of delivery, net the basal rate scheduled during that time, which can be used to compute insulin on-board and glucose effects + public var netBasalUnitsPerHour: Double { + switch type { + case .basal: + return 0 + case .bolus: + return self.unitsPerHour + default: + break + } + + guard let basalRate = scheduledBasalRate else { + return 0 + } + + let unitsPerHour = self.unitsPerHour - basalRate.doubleValue(for: DoseEntry.unitsPerHour) + + guard abs(unitsPerHour) > .ulpOfOne else { + return 0 + } + + return unitsPerHour + } + + /// The smallest increment per unit of hourly basal delivery + /// TODO: Is this 40 for x23 models? (yes - PS 7/26/2019) + /// MinimedPumpmanager will be updated to report deliveredUnits, so this will end up not being used. + private static let minimumMinimedIncrementPerUnit: Double = 20 + + /// Returns the delivered units, or rounds to nearest deliverable (mdt) increment + public var unitsInDeliverableIncrements: Double { + guard case .unitsPerHour = unit else { + return deliveredUnits ?? programmedUnits + } + + return deliveredUnits ?? round(programmedUnits * DoseEntry.minimumMinimedIncrementPerUnit) / DoseEntry.minimumMinimedIncrementPerUnit + } +} + +extension DoseEntry: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.type = try container.decode(DoseType.self, forKey: .type) + self.startDate = try container.decode(Date.self, forKey: .startDate) + self.endDate = try container.decode(Date.self, forKey: .endDate) + self.value = try container.decode(Double.self, forKey: .value) + self.unit = try container.decode(DoseUnit.self, forKey: .unit) + self.deliveredUnits = try container.decodeIfPresent(Double.self, forKey: .deliveredUnits) + self.description = try container.decodeIfPresent(String.self, forKey: .description) + self.syncIdentifier = try container.decodeIfPresent(String.self, forKey: .syncIdentifier) + self.insulinType = try container.decodeIfPresent(InsulinType.self, forKey: .insulinType) + if let scheduledBasalRate = try container.decodeIfPresent(Double.self, forKey: .scheduledBasalRate), + let scheduledBasalRateUnit = try container.decodeIfPresent(String.self, forKey: .scheduledBasalRateUnit) { + self.scheduledBasalRate = HKQuantity(unit: HKUnit(from: scheduledBasalRateUnit), doubleValue: scheduledBasalRate) + } + self.automatic = try container.decodeIfPresent(Bool.self, forKey: .automatic) + self.manuallyEntered = try container.decodeIfPresent(Bool.self, forKey: .manuallyEntered) ?? false + self.isMutable = try container.decodeIfPresent(Bool.self, forKey: .isMutable) ?? false + self.wasProgrammedByPumpUI = try container.decodeIfPresent(Bool.self, forKey: .wasProgrammedByPumpUI) ?? false + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(type, forKey: .type) + try container.encode(startDate, forKey: .startDate) + try container.encode(endDate, forKey: .endDate) + try container.encode(value, forKey: .value) + try container.encode(unit, forKey: .unit) + try container.encodeIfPresent(deliveredUnits, forKey: .deliveredUnits) + try container.encodeIfPresent(description, forKey: .description) + try container.encodeIfPresent(syncIdentifier, forKey: .syncIdentifier) + try container.encodeIfPresent(insulinType, forKey: .insulinType) + if let scheduledBasalRate = scheduledBasalRate { + try container.encode(scheduledBasalRate.doubleValue(for: DoseEntry.unitsPerHour), forKey: .scheduledBasalRate) + try container.encode(DoseEntry.unitsPerHour.unitString, forKey: .scheduledBasalRateUnit) + } + try container.encodeIfPresent(automatic, forKey: .automatic) + if manuallyEntered { + try container.encode(manuallyEntered, forKey: .manuallyEntered) + } + if isMutable { + try container.encode(isMutable, forKey: .isMutable) + } + if wasProgrammedByPumpUI { + try container.encode(wasProgrammedByPumpUI, forKey: .wasProgrammedByPumpUI) + } + } + + private enum CodingKeys: String, CodingKey { + case type + case startDate + case endDate + case value + case unit + case deliveredUnits + case description + case syncIdentifier + case scheduledBasalRate + case scheduledBasalRateUnit + case insulinType + case automatic + case manuallyEntered + case isMutable + case wasProgrammedByPumpUI + } +} + +extension DoseEntry: RawRepresentable { + public typealias RawValue = [String: Any] + + public init?(rawValue: [String: Any]) { + guard let rawType = rawValue["type"] as? DoseType.RawValue, + let type = DoseType(rawValue: rawType), + let startDate = rawValue["startDate"] as? Date, + let endDate = rawValue["endDate"] as? Date, + let value = rawValue["value"] as? Double, + let rawUnit = rawValue["unit"] as? DoseUnit.RawValue, + let unit = DoseUnit(rawValue: rawUnit), + let manuallyEntered = rawValue["manuallyEntered"] as? Bool + else { + return nil + } + + self.type = type + self.startDate = startDate + self.endDate = endDate + self.value = value + self.unit = unit + self.manuallyEntered = manuallyEntered + + self.deliveredUnits = rawValue["deliveredUnits"] as? Double + self.description = rawValue["description"] as? String + self.insulinType = (rawValue["insulinType"] as? InsulinType.RawValue).flatMap { InsulinType(rawValue: $0) } + self.automatic = rawValue["automatic"] as? Bool + self.syncIdentifier = rawValue["syncIdentifier"] as? String + self.scheduledBasalRate = (rawValue["scheduledBasalRate"] as? Double).flatMap { HKQuantity(unit: .internationalUnitsPerHour, doubleValue: $0) } + self.isMutable = rawValue["isMutable"] as? Bool ?? false + self.wasProgrammedByPumpUI = rawValue["wasProgrammedByPumpUI"] as? Bool ?? false + } + + public var rawValue: [String: Any] { + var rawValue: [String: Any] = [ + "type": type.rawValue, + "startDate": startDate, + "endDate": endDate, + "value": value, + "unit": unit.rawValue, + "manuallyEntered": manuallyEntered, + "isMutable": isMutable, + "wasProgrammedByPumpUI": wasProgrammedByPumpUI + ] + + rawValue["deliveredUnits"] = deliveredUnits + rawValue["description"] = description + rawValue["insulinType"] = insulinType?.rawValue + rawValue["automatic"] = automatic + rawValue["syncIdentifier"] = syncIdentifier + rawValue["scheduledBasalRate"] = scheduledBasalRate?.doubleValue(for: .internationalUnitsPerHour) + + return rawValue + } +} + +public extension Array where Element == DoseEntry { + func trimmed(from start: Date? = nil, to end: Date? = nil, onlyTrimTempBasals: Bool = false) -> [DoseEntry] { + return self.compactMap { (dose) -> DoseEntry? in + if let start, dose.endDate < start { + return nil + } + if let end, dose.startDate > end { + return nil + } + if onlyTrimTempBasals && dose.type != .tempBasal { + return dose + } + return dose.trimmed(from: start, to: end) + } + } +} diff --git a/Sources/LoopAlgorithm/DoseMath.swift b/Sources/LoopAlgorithm/DoseMath.swift new file mode 100644 index 0000000..adf7ee6 --- /dev/null +++ b/Sources/LoopAlgorithm/DoseMath.swift @@ -0,0 +1,379 @@ +// +// DoseMath.swift +// Naterade +// +// Created by Nathan Racklyeft on 3/8/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import Foundation +import HealthKit + +public enum InsulinCorrection { + case inRange + case aboveRange(min: GlucoseValue, correcting: GlucoseValue, minTarget: HKQuantity, units: Double) + case entirelyBelowRange(min: GlucoseValue, minTarget: HKQuantity, units: Double) + case suspend(min: GlucoseValue) +} + +extension InsulinCorrection { + /// The delivery units for the correction + private var units: Double { + switch self { + case .aboveRange(min: _, correcting: _, minTarget: _, units: let units): + return units + case .entirelyBelowRange(min: _, minTarget: _, units: let units): + return units + case .inRange, .suspend: + return 0 + } + } + + /// Determines the temp basal over `duration` needed to perform the correction. + /// + /// - Parameters: + /// - neutralBasalRate: The basal rate that should effect no glucose change + /// - maxBasalRate: The maximum allowed basal rate + /// - duration: The duration of the temporary basal + /// - rateRounder: The smallest fraction of a unit supported in basal delivery + /// - Returns: A temp basal recommendation + public func asTempBasal( + neutralBasalRate: Double, + maxBasalRate: Double, + duration: TimeInterval, + rateRounder: ((Double) -> Double)? = nil + ) -> TempBasalRecommendation { + var rate = units / (duration / TimeInterval(hours: 1)) // units/hour + switch self { + case .aboveRange, .inRange, .entirelyBelowRange: + rate += neutralBasalRate + case .suspend: + break + } + + rate = Swift.min(maxBasalRate, Swift.max(0, rate)) + + rate = rateRounder?(rate) ?? rate + + return TempBasalRecommendation( + unitsPerHour: rate, + duration: duration + ) + } + + private var bolusRecommendationNotice: BolusRecommendationNotice? { + switch self { + case .suspend(min: let minimum): + return .glucoseBelowSuspendThreshold(minGlucose: minimum) + case .inRange: + return .predictedGlucoseInRange + case .entirelyBelowRange(min: let min, minTarget: _, units: _): + return .allGlucoseBelowTarget(minGlucose: min) + case .aboveRange(min: let min, correcting: _, minTarget: let target, units: let units): + if units > 0 && min.quantity < target { + return .predictedGlucoseBelowTarget(minGlucose: min) + } else { + return nil + } + } + } + + /// Determines the bolus needed to perform the correction, subtracting any insulin already scheduled for + /// delivery, such as the remaining portion of an ongoing temp basal. + /// + /// - Parameters: + /// - maxBolus: The maximum allowable bolus value in units + /// - Returns: A bolus recommendation + public func asManualBolus(maxBolus: Double) -> ManualBolusRecommendation { + return ManualBolusRecommendation( + amount: Swift.min(maxBolus, Swift.max(0, units)), + notice: bolusRecommendationNotice + ) + } + + /// Determines the bolus amount to perform a partial application correction + /// + /// - Parameters: + /// - partialApplicationFactor: The fraction of needed insulin to deliver now + /// - maxBolus: The maximum allowable bolus value in units + /// - volumeRounder: Method to round computed dose to deliverable volume + /// - Returns: A bolus recommendation + public func asPartialBolus( + partialApplicationFactor: Double, + maxBolusUnits: Double, + volumeRounder: ((Double) -> Double)? = nil + ) -> Double { + + let partialDose = units * partialApplicationFactor + + return Swift.min(Swift.max(0, volumeRounder?(partialDose) ?? partialDose),volumeRounder?(maxBolusUnits) ?? maxBolusUnits) + } +} + + +extension TempBasalRecommendation { + /// Equates the recommended rate with another rate + /// + /// - Parameter unitsPerHour: The rate to compare + /// - Returns: Whether the rates are equal within Double precision + private func matchesRate(_ unitsPerHour: Double) -> Bool { + return abs(self.unitsPerHour - unitsPerHour) < .ulpOfOne + } + + /// Determines whether the recommendation is necessary given the current state of the pump + /// + /// - Parameters: + /// - date: The date the recommendation would be delivered + /// - scheduledBasalRate: The scheduled basal rate at `date` + /// - lastTempBasal: The previously set temp basal + /// - continuationInterval: The duration of time before an ongoing temp basal should be continued with a new command + /// - scheduledBasalRateMatchesPump: A flag describing whether `scheduledBasalRate` matches the scheduled basal rate of the pump. + /// If `false` and the recommendation matches `scheduledBasalRate`, the temp will be recommended + /// at the scheduled basal rate rather than recommending no temp. + /// - Returns: A temp basal recommendation + public func ifNecessary( + at date: Date, + neutralBasalRate: Double, + lastTempBasal: DoseEntry?, + continuationInterval: TimeInterval, + neutralBasalRateMatchesPump: Bool + ) -> TempBasalRecommendation? { + // Adjust behavior for the currently active temp basal + if let lastTempBasal = lastTempBasal, + lastTempBasal.type == .tempBasal, + lastTempBasal.endDate > date + { + /// If the last temp basal has the same rate, and has more than `continuationInterval` of time remaining, don't set a new temp + if matchesRate(lastTempBasal.unitsPerHour), + lastTempBasal.endDate.timeIntervalSince(date) > continuationInterval { + return nil + } else if matchesRate(neutralBasalRate), neutralBasalRateMatchesPump { + // If our new temp matches the scheduled rate of the pump, cancel the current temp + return .cancel + } + } else if matchesRate(neutralBasalRate), neutralBasalRateMatchesPump { + // If we recommend the in-progress scheduled basal rate of the pump, do nothing + return nil + } + + return self + } +} + +/// Computes a total insulin amount necessary to correct a glucose differential at a given sensitivity +/// +/// - Parameters: +/// - fromValue: The starting glucose value +/// - toValue: The desired glucose value +/// - effectedSensitivity: The sensitivity, in glucose-per-insulin-unit +/// - Returns: The insulin correction in units +private func insulinCorrectionUnits(fromValue: Double, toValue: Double, effectedSensitivity: Double) -> Double { + guard effectedSensitivity > 0 else { + preconditionFailure("Negative effected sensitivity: \(effectedSensitivity)") + } + + let glucoseCorrection = fromValue - toValue + + return glucoseCorrection / effectedSensitivity +} + +/// Computes a target glucose value for a correction, at a given time during the insulin effect duration +/// +/// - Parameters: +/// - percentEffectDuration: The percent of time elapsed of the insulin effect duration +/// - minValue: The minimum (starting) target value +/// - maxValue: The maximum (eventual) target value +/// - Returns: A target value somewhere between the minimum and maximum +private func targetGlucoseValue(percentEffectDuration: Double, minValue: Double, maxValue: Double) -> Double { + // The inflection point in time: before it we use minValue, after it we linearly blend from minValue to maxValue + let useMinValueUntilPercent = 0.5 + + guard percentEffectDuration > useMinValueUntilPercent else { + return minValue + } + + guard percentEffectDuration < 1 else { + return maxValue + } + + let slope = (maxValue - minValue) / (1 - useMinValueUntilPercent) + return minValue + slope * (percentEffectDuration - useMinValueUntilPercent) +} + +public typealias GlucoseRangeTimeline = [AbsoluteScheduleValue>] + +extension Collection where Element: GlucoseValue { + + /// For a collection of glucose prediction, determine the least amount of insulin delivered at + /// `date` to correct the predicted glucose to the middle of `correctionRange` at the time of prediction. + /// + /// - Parameters: + /// - correctionRange: The timeline of glucose ranges used for correction + /// - date: The date the insulin correction is delivered + /// - suspendThreshold: The glucose value below which only suspension is returned + /// - insulinSensitivityTimeline: The timeline of expected insulin sensitivity over the period of dose absorption + /// - model: The insulin effect model + /// - Returns: A correction value in units, or nil if no correction needed + func insulinCorrection( + to correctionRange: GlucoseRangeTimeline, + at date: Date, + suspendThreshold: HKQuantity, + insulinSensitivity: [AbsoluteScheduleValue], + model: InsulinModel + ) -> InsulinCorrection { + var minGlucose: GlucoseValue! + var eventualGlucose: GlucoseValue! + var correctingGlucose: GlucoseValue? + var minCorrectionUnits: Double? + var effectedSensitivityAtMinGlucose: Double? + + // Only consider predictions within the model's effect duration + let validDateRange = DateInterval(start: date, duration: model.effectDuration) + + let unit = HKUnit.milligramsPerDeciliter + + guard self.count > 0 else { + preconditionFailure("Unable to compute correction for empty glucose array") + } + + let suspendThresholdValue = suspendThreshold.doubleValue(for: unit) + + // For each prediction above target, determine the amount of insulin necessary to correct glucose based on the modeled effectiveness of the insulin at that time + for prediction in self { + guard validDateRange.contains(prediction.startDate) else { + continue + } + + + // If any predicted value is below the suspend threshold, return immediately + guard prediction.quantity >= suspendThreshold else { + return .suspend(min: prediction) + } + + eventualGlucose = prediction + + let predictedGlucoseValue = prediction.quantity.doubleValue(for: unit) + let time = prediction.startDate.timeIntervalSince(date) + + guard let correctionRangeItem = correctionRange.closestPrior(to: prediction.startDate) else { + preconditionFailure("Correction range must cover date: \(prediction.startDate)") + } + + // Compute the target value as a function of time since the dose started + let targetValue = targetGlucoseValue( + percentEffectDuration: time / model.effectDuration, + minValue: suspendThresholdValue, + maxValue: correctionRangeItem.value.averageValue(for: unit) + ) + + // Compute the dose required to bring this prediction to target: + // dose = (Glucose Δ) / (% effect × sensitivity) + + let isfSegments = insulinSensitivity.filterDateRange(date, prediction.startDate) + + let effectedSensitivity = isfSegments.reduce(0) { partialResult, segment in + let start = Swift.max(date, segment.startDate).timeIntervalSince(date) + let end = Swift.min(prediction.startDate, segment.endDate).timeIntervalSince(date) + let percentEffected = model.percentEffectRemaining(at: start) - model.percentEffectRemaining(at: end) + return percentEffected * segment.value.doubleValue(for: unit) + } + + // Update range statistics + if minGlucose == nil || prediction.quantity < minGlucose!.quantity { + minGlucose = prediction + effectedSensitivityAtMinGlucose = effectedSensitivity + } + + let correctionUnits = insulinCorrectionUnits( + fromValue: predictedGlucoseValue, + toValue: targetValue, + effectedSensitivity: Swift.max(.ulpOfOne, effectedSensitivity) + ) + + guard correctionUnits > 0 else { + continue + } + + // Update the correction only if we've found a new minimum + guard minCorrectionUnits == nil || correctionUnits < minCorrectionUnits! else { + continue + } + + correctingGlucose = prediction + minCorrectionUnits = correctionUnits + } + + // Choose either the minimum glucose or eventual glucose as the correction delta + let minGlucoseTargets = correctionRange.closestPrior(to: minGlucose.startDate)!.value + let eventualGlucoseTargets = correctionRange.closestPrior(to: eventualGlucose.startDate)!.value + + // Treat the mininum glucose when both are below range + if minGlucose.quantity < minGlucoseTargets.lowerBound && + eventualGlucose.quantity < eventualGlucoseTargets.lowerBound + { + let units = insulinCorrectionUnits( + fromValue: minGlucose.quantity.doubleValue(for: unit), + toValue: minGlucoseTargets.averageValue(for: unit), + effectedSensitivity: Swift.max(.ulpOfOne, effectedSensitivityAtMinGlucose!) + ) + + return .entirelyBelowRange( + min: minGlucose, + minTarget: minGlucoseTargets.lowerBound, + units: units + ) + } else if eventualGlucose.quantity > eventualGlucoseTargets.upperBound, + let minCorrectionUnits = minCorrectionUnits, let correctingGlucose = correctingGlucose + { + return .aboveRange( + min: minGlucose, + correcting: correctingGlucose, + minTarget: eventualGlucoseTargets.lowerBound, + units: minCorrectionUnits + ) + } else { + return .inRange + } + } + + + /// Recommends a bolus to conform a glucose prediction timeline to a correction range + /// + /// - Parameters: + /// - correctionRange: The timeline of correction ranges + /// - date: The date at which the bolus would apply, defaults to now + /// - suspendThreshold: A glucose value causing a recommendation of no insulin if any prediction falls below + /// - insulinSensitivity: The timeline of insulin sensitivities + /// - model: The insulin absorption model to be used for the recommended dose + /// - maxBolus: The maximum bolus to return + /// - Returns: A bolus recommendation + public func recommendedManualBolus( + to correctionRangeTimeline: GlucoseRangeTimeline, + at date: Date = Date(), + suspendThreshold: HKQuantity, + insulinSensitivity: [AbsoluteScheduleValue], + model: InsulinModel, + maxBolus: Double + ) -> ManualBolusRecommendation { + let correction = self.insulinCorrection( + to: correctionRangeTimeline, + at: date, + suspendThreshold: suspendThreshold, + insulinSensitivity: insulinSensitivity, + model: model + ) + + var bolus = correction.asManualBolus(maxBolus: maxBolus) + + // Handle the "current BG below target" notice here + // TODO: Don't assume in the future that the first item in the array is current BG + if case .predictedGlucoseBelowTarget? = bolus.notice, + let first = first, first.quantity < correctionRangeTimeline.closestPrior(to: first.startDate)!.value.lowerBound + { + bolus.notice = .currentGlucoseBelowTarget(glucose: first) + } + + return bolus + } + +} diff --git a/Sources/LoopAlgorithm/DoseRecommendationType.swift b/Sources/LoopAlgorithm/DoseRecommendationType.swift new file mode 100644 index 0000000..c3de4b6 --- /dev/null +++ b/Sources/LoopAlgorithm/DoseRecommendationType.swift @@ -0,0 +1,25 @@ +// +// DoseRecommendationType.swift +// LoopKit +// +// Created by Pete Schwamb on 10/12/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation + + +public enum DoseRecommendationType: String { + case manualBolus + case automaticBolus + case tempBasal + + var automated: Bool { + switch self { + case .automaticBolus, .tempBasal: + return true + case .manualBolus: + return false + } + } +} diff --git a/Sources/LoopAlgorithm/DoseType.swift b/Sources/LoopAlgorithm/DoseType.swift new file mode 100644 index 0000000..a119844 --- /dev/null +++ b/Sources/LoopAlgorithm/DoseType.swift @@ -0,0 +1,20 @@ +// +// DoseType.swift +// LoopKit +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import Foundation + + +/// A general set of ways insulin can be delivered by a pump +public enum DoseType: String, CaseIterable { + case basal + case bolus + case resume + case suspend + case tempBasal +} + +extension DoseType: Codable {} diff --git a/Sources/LoopAlgorithm/DoseUnit.swift b/Sources/LoopAlgorithm/DoseUnit.swift new file mode 100644 index 0000000..b15eca6 --- /dev/null +++ b/Sources/LoopAlgorithm/DoseUnit.swift @@ -0,0 +1,17 @@ +// +// DoseUnit.swift +// LoopKit +// +// Created by Nathan Racklyeft on 3/28/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import Foundation + + +public enum DoseUnit: String { + case unitsPerHour = "U/hour" + case units = "U" +} + +extension DoseUnit: Codable {} diff --git a/Sources/LoopAlgorithm/DoubleRange.swift b/Sources/LoopAlgorithm/DoubleRange.swift new file mode 100644 index 0000000..5741c5c --- /dev/null +++ b/Sources/LoopAlgorithm/DoubleRange.swift @@ -0,0 +1,57 @@ +// +// DoubleRange.swift +// + +import Foundation +import HealthKit + +public struct DoubleRange { + public let minValue: Double + public let maxValue: Double + + public init(minValue: Double, maxValue: Double) { + self.minValue = minValue + self.maxValue = maxValue + } + + public var isZero: Bool { + return abs(minValue) < .ulpOfOne && abs(maxValue) < .ulpOfOne + } +} + + +extension DoubleRange: RawRepresentable { + public typealias RawValue = [Double] + + public init?(rawValue: RawValue) { + guard rawValue.count == 2 else { + return nil + } + + minValue = rawValue[0] + maxValue = rawValue[1] + } + + public var rawValue: RawValue { + return [minValue, maxValue] + } +} + +extension DoubleRange: Equatable { + public static func ==(lhs: DoubleRange, rhs: DoubleRange) -> Bool { + return abs(lhs.minValue - rhs.minValue) < .ulpOfOne && + abs(lhs.maxValue - rhs.maxValue) < .ulpOfOne + } +} + +extension DoubleRange: Hashable {} + +extension DoubleRange: Codable {} + +extension DoubleRange { + public func quantityRange(for unit: HKUnit) -> ClosedRange { + let lowerBound = HKQuantity(unit: unit, doubleValue: minValue) + let upperBound = HKQuantity(unit: unit, doubleValue: maxValue) + return lowerBound...upperBound + } +} diff --git a/Sources/LoopAlgorithm/ExponentialInsulinModel.swift b/Sources/LoopAlgorithm/ExponentialInsulinModel.swift new file mode 100644 index 0000000..75e5451 --- /dev/null +++ b/Sources/LoopAlgorithm/ExponentialInsulinModel.swift @@ -0,0 +1,99 @@ +// +// ExponentialInsulinModel.swift +// InsulinKit +// +// Created by Pete Schwamb on 7/30/17. +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import Foundation + +public struct ExponentialInsulinModel { + public let actionDuration: TimeInterval + public let peakActivityTime: TimeInterval + public let delay: TimeInterval + + // Precomputed terms + fileprivate let τ: Double + fileprivate let a: Double + fileprivate let S: Double + + /// Configures a new exponential insulin model + /// + /// - Parameters: + /// - actionDuration: The total duration of insulin activity, excluding delay + /// - peakActivityTime: The time of the peak of insulin activity from dose. + /// - delay: The time to delay the dose effect + public init(actionDuration: TimeInterval, peakActivityTime: TimeInterval, delay: TimeInterval = 600) { + self.actionDuration = actionDuration + self.peakActivityTime = peakActivityTime + self.delay = delay + + self.τ = peakActivityTime * (1 - peakActivityTime / actionDuration) / (1 - 2 * peakActivityTime / actionDuration) + self.a = 2 * τ / actionDuration + self.S = 1 / (1 - a + (1 + a) * exp(-actionDuration / τ)) + } +} + +extension ExponentialInsulinModel: InsulinModel { + public var effectDuration: TimeInterval { + return self.actionDuration + self.delay + } + + /// Returns the percentage of total insulin effect remaining at a specified interval after delivery; + /// also known as Insulin On Board (IOB). + /// + /// This is a configurable exponential model as described here: https://github.com/LoopKit/Loop/issues/388#issuecomment-317938473 + /// Allows us to specify time of peak activity, as well as duration, and provides activity and IOB decay functions + /// Many thanks to Dragan Maksimovic (@dm61) for creating such a flexible way of adjusting an insulin curve + /// for use in closed loop systems. + /// + /// - Parameter time: The interval after insulin delivery + /// - Returns: The percentage of total insulin effect remaining + + public func percentEffectRemaining(at time: TimeInterval) -> Double { + let timeAfterDelay = time - delay + switch timeAfterDelay { + case let t where t <= 0: + return 1 + case let t where t >= actionDuration: + return 0 + default: + let t = timeAfterDelay + return 1 - S * (1 - a) * + ((pow(t, 2) / (τ * actionDuration * (1 - a)) - t / τ - 1) * exp(-t / τ) + 1) + } + } +} + +extension ExponentialInsulinModel: CustomDebugStringConvertible { + public var debugDescription: String { + return "ExponentialInsulinModel(actionDuration: \(actionDuration), peakActivityTime: \(peakActivityTime), delay: \(delay)" + } +} + +#if swift(>=4) +extension ExponentialInsulinModel: Decodable { + enum CodingKeys: String, CodingKey { + case actionDuration + case peakActivityTime + case delay + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let actionDuration: Double = try container.decode(Double.self, forKey: .actionDuration) + let peakActivityTime: Double = try container.decode(Double.self, forKey: .peakActivityTime) + let delay: Double = try container.decode(TimeInterval.self, forKey: .delay) + + self.init(actionDuration: actionDuration, peakActivityTime: peakActivityTime, delay: delay) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(actionDuration, forKey: .actionDuration) + try container.encode(peakActivityTime, forKey: .peakActivityTime) + try container.encode(delay, forKey: .delay) + } +} +#endif diff --git a/Sources/LoopAlgorithm/ExponentialInsulinModelPreset.swift b/Sources/LoopAlgorithm/ExponentialInsulinModelPreset.swift new file mode 100644 index 0000000..7ba9842 --- /dev/null +++ b/Sources/LoopAlgorithm/ExponentialInsulinModelPreset.swift @@ -0,0 +1,86 @@ +// +// ExponentialInsulinModelPreset.swift +// LoopKit +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import Foundation + +public enum ExponentialInsulinModelPreset: String, Codable { + case rapidActingAdult + case rapidActingChild + case fiasp + case lyumjev + case afrezza +} + + +// MARK: - Model generation +extension ExponentialInsulinModelPreset { + public var actionDuration: TimeInterval { + switch self { + case .rapidActingAdult: + return .minutes(360) + case .rapidActingChild: + return .minutes(360) + case .fiasp: + return .minutes(360) + case .lyumjev: + return .minutes(360) + case .afrezza: + return .minutes(300) + } + } + + public var peakActivity: TimeInterval { + switch self { + case .rapidActingAdult: + return .minutes(75) + case .rapidActingChild: + return .minutes(65) + case .fiasp: + return .minutes(55) + case .lyumjev: + return .minutes(55) + case.afrezza: + return .minutes(29) + } + } + + public var delay: TimeInterval { + switch self { + case .rapidActingAdult: + return .minutes(10) + case .rapidActingChild: + return .minutes(10) + case .fiasp: + return .minutes(10) + case .lyumjev: + return .minutes(10) + case.afrezza: + return .minutes(10) + } + } + + var model: InsulinModel { + return ExponentialInsulinModel(actionDuration: actionDuration, peakActivityTime: peakActivity, delay: delay) + } +} + + +extension ExponentialInsulinModelPreset: InsulinModel { + public var effectDuration: TimeInterval { + return model.effectDuration + } + + public func percentEffectRemaining(at time: TimeInterval) -> Double { + return model.percentEffectRemaining(at: time) + } +} + +extension ExponentialInsulinModelPreset: CustomDebugStringConvertible { + public var debugDescription: String { + return "\(self.rawValue)(\(String(reflecting: model)))" + } +} diff --git a/Sources/LoopAlgorithm/Extensions/ClosedRange.swift b/Sources/LoopAlgorithm/Extensions/ClosedRange.swift new file mode 100644 index 0000000..aef5434 --- /dev/null +++ b/Sources/LoopAlgorithm/Extensions/ClosedRange.swift @@ -0,0 +1,29 @@ +// +// ClosedRange.swift +// LoopKit +// +// Created by Michael Pangburn on 6/23/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// +import HealthKit + +extension ClosedRange { + func expandedToInclude(_ value: Bound) -> ClosedRange { + if value < lowerBound { + return value...upperBound + } else if value > upperBound { + return lowerBound...value + } else { + return self + } + } +} + +extension ClosedRange where Bound == HKQuantity { + public func averageValue(for unit: HKUnit) -> Double { + let minValue = lowerBound.doubleValue(for: unit) + let maxValue = upperBound.doubleValue(for: unit) + return (maxValue + minValue) / 2 + } +} + diff --git a/Sources/LoopAlgorithm/Extensions/Date.swift b/Sources/LoopAlgorithm/Extensions/Date.swift new file mode 100644 index 0000000..51e3a57 --- /dev/null +++ b/Sources/LoopAlgorithm/Extensions/Date.swift @@ -0,0 +1,27 @@ +// +// Date.swift +// +// Created by Nathan Racklyeft on 1/17/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import Foundation + + +public extension Date { + func dateFlooredToTimeInterval(_ interval: TimeInterval) -> Date { + if interval == 0 { + return self + } + + return Date(timeIntervalSinceReferenceDate: floor(self.timeIntervalSinceReferenceDate / interval) * interval) + } + + func dateCeiledToTimeInterval(_ interval: TimeInterval) -> Date { + if interval == 0 { + return self + } + + return Date(timeIntervalSinceReferenceDate: ceil(self.timeIntervalSinceReferenceDate / interval) * interval) + } +} diff --git a/Sources/LoopAlgorithm/Extensions/Double.swift b/Sources/LoopAlgorithm/Extensions/Double.swift new file mode 100644 index 0000000..349c65a --- /dev/null +++ b/Sources/LoopAlgorithm/Extensions/Double.swift @@ -0,0 +1,29 @@ +// +// Double.swift +// Naterade +// +// Created by Nathan Racklyeft on 2/12/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import Foundation + +extension Double: RawRepresentable { + public typealias RawValue = Double + + public init?(rawValue: RawValue) { + self = rawValue + } + + public var rawValue: RawValue { + return self + } +} + +infix operator =~ : ComparisonPrecedence + + extension Double { + static func =~ (lhs: Double, rhs: Double) -> Bool { + return fabs(lhs - rhs) < Double.ulpOfOne + } + } diff --git a/Sources/LoopAlgorithm/Extensions/HKQuantity.swift b/Sources/LoopAlgorithm/Extensions/HKQuantity.swift new file mode 100644 index 0000000..133de47 --- /dev/null +++ b/Sources/LoopAlgorithm/Extensions/HKQuantity.swift @@ -0,0 +1,17 @@ +// +// HKQuantity.swift +// LoopKit +// +// Created by Nathan Racklyeft on 3/10/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import Foundation +import HealthKit + + +extension HKQuantity: Comparable { } + +public func <(lhs: HKQuantity, rhs: HKQuantity) -> Bool { + return lhs.compare(rhs) == .orderedAscending +} diff --git a/Sources/LoopAlgorithm/Extensions/HKUnit.swift b/Sources/LoopAlgorithm/Extensions/HKUnit.swift new file mode 100644 index 0000000..e029a23 --- /dev/null +++ b/Sources/LoopAlgorithm/Extensions/HKUnit.swift @@ -0,0 +1,62 @@ +// +// HKUnit.swift +// Naterade +// +// Created by Nathan Racklyeft on 1/17/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import HealthKit + + +extension HKUnit { + static let milligramsPerDeciliter: HKUnit = { + return HKUnit.gramUnit(with: .milli).unitDivided(by: .literUnit(with: .deci)) + }() + + static let millimolesPerLiter: HKUnit = { + return HKUnit.moleUnit(with: .milli, molarMass: HKUnitMolarMassBloodGlucose).unitDivided(by: .liter()) + }() + + static let milligramsPerDeciliterPerMinute: HKUnit = { + return HKUnit.milligramsPerDeciliter.unitDivided(by: .minute()) + }() + + static let millimolesPerLiterPerMinute: HKUnit = { + return HKUnit.millimolesPerLiter.unitDivided(by: .minute()) + }() + + static let internationalUnitsPerHour: HKUnit = { + return HKUnit.internationalUnit().unitDivided(by: .hour()) + }() + + static let gramsPerUnit: HKUnit = { + return HKUnit.gram().unitDivided(by: .internationalUnit()) + }() + + var foundationUnit: Unit? { + if self == HKUnit.milligramsPerDeciliter { + return UnitConcentrationMass.milligramsPerDeciliter + } + + if self == HKUnit.millimolesPerLiter { + return UnitConcentrationMass.millimolesPerLiter(withGramsPerMole: HKUnitMolarMassBloodGlucose) + } + + if self == HKUnit.gram() { + return UnitMass.grams + } + + return nil + } + + /// The smallest value expected to be visible on a chart + var chartableIncrement: Double { + if self == .milligramsPerDeciliter { + return 1 + } else { + return 1 / 25 + } + } +} + diff --git a/Sources/LoopAlgorithm/Extensions/Sequence.swift b/Sources/LoopAlgorithm/Extensions/Sequence.swift new file mode 100644 index 0000000..190976c --- /dev/null +++ b/Sources/LoopAlgorithm/Extensions/Sequence.swift @@ -0,0 +1,19 @@ +// +// Sequence.swift +// LoopKit +// +// Created by Michael Pangburn on 6/23/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +extension Sequence { + func range(of metricForElement: (Element) throws -> Metric) rethrows -> ClosedRange? { + try lazy.map(metricForElement).reduce(nil) { range, metric in + if let range = range { + return range.expandedToInclude(metric) + } else { + return metric...metric + } + } + } +} diff --git a/Sources/LoopAlgorithm/Extensions/TimeInterval.swift b/Sources/LoopAlgorithm/Extensions/TimeInterval.swift new file mode 100644 index 0000000..8fd27dd --- /dev/null +++ b/Sources/LoopAlgorithm/Extensions/TimeInterval.swift @@ -0,0 +1,35 @@ +// +// TimeInterval.swift +// +// Created by Nathan Racklyeft on 1/9/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import Foundation + + +extension TimeInterval { + static func minutes(_ minutes: Double) -> TimeInterval { + return self.init(minutes: minutes) + } + + static func hours(_ hours: Double) -> TimeInterval { + return self.init(hours: hours) + } + + init(minutes: Double) { + self.init(minutes * 60) + } + + init(hours: Double) { + self.init(minutes: hours * 60) + } + + var minutes: Double { + return self / 60.0 + } + + var hours: Double { + return minutes / 60.0 + } +} diff --git a/Sources/LoopAlgorithm/GlucoseChange.swift b/Sources/LoopAlgorithm/GlucoseChange.swift new file mode 100644 index 0000000..6b8edce --- /dev/null +++ b/Sources/LoopAlgorithm/GlucoseChange.swift @@ -0,0 +1,27 @@ +// +// GlucoseChange.swift +// LoopKit +// +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import HealthKit + + +public struct GlucoseChange: SampleValue, Equatable { + public var startDate: Date + public var endDate: Date + public var quantity: HKQuantity +} + + +extension GlucoseChange { + mutating public func append(_ effect: GlucoseEffect) { + startDate = min(effect.startDate, startDate) + endDate = max(effect.endDate, endDate) + quantity = HKQuantity( + unit: .milligramsPerDeciliter, + doubleValue: quantity.doubleValue(for: .milligramsPerDeciliter) + effect.quantity.doubleValue(for: .milligramsPerDeciliter) + ) + } +} diff --git a/Sources/LoopAlgorithm/GlucoseCondition.swift b/Sources/LoopAlgorithm/GlucoseCondition.swift new file mode 100644 index 0000000..a1e140e --- /dev/null +++ b/Sources/LoopAlgorithm/GlucoseCondition.swift @@ -0,0 +1,11 @@ +// +// GlucoseCondition.swift +// +// 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/GlucoseEffect.swift b/Sources/LoopAlgorithm/GlucoseEffect.swift new file mode 100644 index 0000000..ec31731 --- /dev/null +++ b/Sources/LoopAlgorithm/GlucoseEffect.swift @@ -0,0 +1,73 @@ +// +// GlucoseEffect.swift +// +// Created by Nathan Racklyeft on 1/24/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import Foundation +import HealthKit + + +public struct GlucoseEffect: GlucoseValue, Equatable { + public let startDate: Date + public let quantity: HKQuantity + + public init(startDate: Date, quantity: HKQuantity) { + self.startDate = startDate + self.quantity = quantity + } +} + +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: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 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) + } + + private enum CodingKeys: String, CodingKey { + case startDate + case quantity + } +} + + +extension GlucoseEffect: Comparable { + public static func <(lhs: GlucoseEffect, rhs: GlucoseEffect) -> Bool { + return lhs.startDate < rhs.startDate + } +} + +public extension Array where Element == GlucoseEffect { + func asVelocities() -> [GlucoseEffectVelocity] { + guard count > 1 else { + return [] + } + let unit = HKUnit.milligramsPerDeciliter + var previousEffectValue: Double = first!.quantity.doubleValue(for: unit) + var previousEffectDate: Date = first!.startDate + + var velocities = [GlucoseEffectVelocity]() + + for effect in dropFirst() { + let value = effect.quantity.doubleValue(for: unit) + let delta = value - previousEffectValue + let timespan = effect.startDate.timeIntervalSince(previousEffectDate).minutes + let velocity = delta / timespan + + velocities.append(GlucoseEffectVelocity(startDate: previousEffectDate, endDate: effect.startDate, quantity: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: velocity))) + + previousEffectValue = value + previousEffectDate = effect.startDate + } + + return velocities + } +} diff --git a/Sources/LoopAlgorithm/GlucoseEffectVelocity.swift b/Sources/LoopAlgorithm/GlucoseEffectVelocity.swift new file mode 100644 index 0000000..391e190 --- /dev/null +++ b/Sources/LoopAlgorithm/GlucoseEffectVelocity.swift @@ -0,0 +1,63 @@ +// +// GlucoseEffectVelocity.swift +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit + + +/// The first-derivative of GlucoseEffect, blood glucose over time. +public struct GlucoseEffectVelocity: SampleValue { + public let startDate: Date + public let endDate: Date + public let quantity: HKQuantity + + public init(startDate: Date, endDate: Date, quantity: HKQuantity) { + self.startDate = startDate + self.endDate = endDate + self.quantity = quantity + } +} + + +extension GlucoseEffectVelocity { + static let perSecondUnit = HKUnit.milligramsPerDeciliter.unitDivided(by: .second()) + + /// 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: HKQuantity( + unit: .milligramsPerDeciliter, + doubleValue: velocityPerSecond * duration + ) + ) + } + + /// The integration of the velocity span from `start` to `end` + public func effect(from start: Date, to end: Date) -> GlucoseEffect? { + guard + start <= end, + startDate <= start, + end <= endDate + else { + return nil + } + + let duration = end.timeIntervalSince(start) + let velocityPerSecond = quantity.doubleValue(for: GlucoseEffectVelocity.perSecondUnit) + + return GlucoseEffect( + startDate: end, + quantity: HKQuantity( + unit: .milligramsPerDeciliter, + doubleValue: velocityPerSecond * duration + ) + ) + } +} diff --git a/Sources/LoopAlgorithm/GlucoseMath.swift b/Sources/LoopAlgorithm/GlucoseMath.swift new file mode 100644 index 0000000..84d54cd --- /dev/null +++ b/Sources/LoopAlgorithm/GlucoseMath.swift @@ -0,0 +1,230 @@ +// +// GlucoseMath.swift +// Naterade +// +// Created by Nathan Racklyeft on 1/24/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import Foundation +import HealthKit + +public struct GlucoseMath { + public static let momentumDataInterval: TimeInterval = .minutes(15) + public static let momentumDuration: TimeInterval = .minutes(15) + public static let defaultDelta: TimeInterval = .minutes(5) +} + +fileprivate extension Collection where Element == (x: Double, y: Double) { + /** + Calculates slope and intercept using linear regression + + This implementation is not suited for large datasets. + + - parameter points: An array of tuples containing x and y values + + - returns: A tuple of slope and intercept values + */ + func linearRegression() -> (slope: Double, intercept: Double) { + var sumX = 0.0 + var sumY = 0.0 + var sumXY = 0.0 + var sumX² = 0.0 + var sumY² = 0.0 + let count = Double(self.count) + + for point in self { + sumX += point.x + sumY += point.y + sumXY += (point.x * point.y) + sumX² += (point.x * point.x) + sumY² += (point.y * point.y) + } + + let slope = ((count * sumXY) - (sumX * sumY)) / ((count * sumX²) - (sumX * sumX)) + let intercept = (sumY * sumX² - (sumX * sumXY)) / (count * sumX² - (sumX * sumX)) + + return (slope: slope, intercept: intercept) + } +} + + +extension BidirectionalCollection where Element: GlucoseSampleValue, Index == Int { + + /// Whether the collection contains any calibration entries + /// Runtime: O(n) + public func containsCalibrations() -> Bool { + return filter({ $0.isDisplayOnly }).count > 0 + } + + /// Whether the collection can be considered continuous + /// + /// - Parameters: + /// - interval: The interval between readings, on average, used to determine if we have a contiguous set of values + /// - Returns: True if the samples are continuous + func isContinuous(within interval: TimeInterval = TimeInterval(minutes: 5)) -> Bool { + if let first = first, + let last = last, + // Ensure that the entries are contiguous + abs(first.startDate.timeIntervalSince(last.startDate)) < interval * TimeInterval(count) + { + return true + } + + return false + } + + /// Calculates the short-term predicted momentum effect using linear regression + /// + /// - Parameters: + /// - duration: The duration of the effects + /// - 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( + duration: TimeInterval = GlucoseMath.momentumDuration, + delta: TimeInterval = GlucoseMath.defaultDelta, + velocityMaximum: HKQuantity? = nil + ) -> [GlucoseEffect] { + + let velocityMax = velocityMaximum ?? HKQuantity(unit: HKUnit.milligramsPerDeciliter.unitDivided(by: .minute()), doubleValue: 4.0) + + guard + self.count > 2, // Linear regression isn't much use without 3 or more entries. + isContinuous() && !containsCalibrations() && hasSingleProvenance, + let firstSample = self.first, + let lastSample = self.last, + let (startDate, endDate) = LoopMath.simulationDateRangeForSamples([lastSample], duration: duration, delta: delta) + else { + return [] + } + + /// Choose a unit to use during raw value calculation + let unit = HKUnit.milligramsPerDeciliter + + let (slope: slope, intercept: _) = self.map { ( + x: $0.startDate.timeIntervalSince(firstSample.startDate), + y: $0.quantity.doubleValue(for: unit) + ) }.linearRegression() + + guard slope.isFinite else { + return [] + } + + let limitedSlope = Swift.min(slope, velocityMax.doubleValue(for: unit.unitDivided(by: .second()))) + + var date = startDate + var values = [GlucoseEffect]() + + repeat { + let value = Swift.max(0, date.timeIntervalSince(lastSample.startDate)) * limitedSlope + let momentumEffect = GlucoseEffect(startDate: date, quantity: HKQuantity(unit: unit, doubleValue: value)) + + values.append(momentumEffect) + date = date.addingTimeInterval(delta) + } while date <= endDate + + return values + } +} + + +extension Collection where Element: GlucoseSampleValue, Index == Int { + /// Whether the collection is all from the same source. + /// Runtime: O(n) + var hasSingleProvenance: Bool { + let firstProvenance = self.first?.provenanceIdentifier + + for sample in self { + if sample.provenanceIdentifier != firstProvenance { + return false + } + } + + return true + } + + /// Calculates a timeline of effect velocity (glucose/time) observed in glucose readings that counteract the specified effects. + /// + /// - Parameter effects: Glucose effects to be countered, in chronological order + /// - Returns: An array of velocities describing the change in glucose samples compared to the specified effects + public func counteractionEffects(to effects: [GlucoseEffect]) -> [GlucoseEffectVelocity] { + let mgdL = HKUnit.milligramsPerDeciliter + let velocityUnit = GlucoseEffectVelocity.perSecondUnit + var velocities = [GlucoseEffectVelocity]() + + var effectIndex = 0 + + guard self.count > 0, effects.count > 0 else { + return [] + } + + let startGlucoseIdx = self.firstIndex { $0.startDate >= effects.first!.startDate } + + guard var startGlucoseIdx else { + return [] + } + + var endGlucoseIdx = startGlucoseIdx + 1 + + while endGlucoseIdx != self.endIndex { + // Find a valid change in glucose, requiring identical provenance and no calibration + let startGlucose = self[startGlucoseIdx] + let endGlucose = self[endGlucoseIdx] + + let glucoseChange = endGlucose.quantity.doubleValue(for: mgdL) - startGlucose.quantity.doubleValue(for: mgdL) + let timeInterval = endGlucose.startDate.timeIntervalSince(startGlucose.startDate) + + guard timeInterval > .minutes(4) else { + endGlucoseIdx += 1 + continue + } + + defer { + startGlucoseIdx = endGlucoseIdx + endGlucoseIdx += 1 + } + + guard startGlucose.provenanceIdentifier == endGlucose.provenanceIdentifier, + !startGlucose.isDisplayOnly, !endGlucose.isDisplayOnly + else { + continue + } + + // Compare that to a change in insulin effects + guard effects.count > effectIndex else { + break + } + + var startEffect: GlucoseEffect? + var endEffect: GlucoseEffect? + + for effect in effects[effectIndex..= startGlucose.startDate { + startEffect = effect + } else if endEffect == nil && effect.startDate >= endGlucose.startDate { + endEffect = effect + break + } + + effectIndex += 1 + } + + guard let startEffectValue = startEffect?.quantity.doubleValue(for: mgdL), + let endEffectValue = endEffect?.quantity.doubleValue(for: mgdL) + else { + break + } + + let effectChange = endEffectValue - startEffectValue + let discrepancy = glucoseChange - effectChange + + let averageVelocity = HKQuantity(unit: velocityUnit, doubleValue: discrepancy / timeInterval) + let effect = GlucoseEffectVelocity(startDate: startGlucose.startDate, endDate: endGlucose.startDate, quantity: averageVelocity) + + velocities.append(effect) + } + + return velocities + } +} diff --git a/Sources/LoopAlgorithm/GlucoseRange.swift b/Sources/LoopAlgorithm/GlucoseRange.swift new file mode 100644 index 0000000..b086e25 --- /dev/null +++ b/Sources/LoopAlgorithm/GlucoseRange.swift @@ -0,0 +1,78 @@ +// +// GlucoseRange.swift +// LoopKit +// +// Created by Nathaniel Hamming on 2021-03-16. +// Copyright © 2021 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit + +public struct GlucoseRange { + public let range: DoubleRange + public let unit: HKUnit + + public init(minValue: Double, maxValue: Double, unit: HKUnit) { + self.init(range: DoubleRange(minValue: minValue, maxValue: maxValue), unit: unit) + } + + public init(range: DoubleRange, unit: HKUnit) { + precondition(unit == .milligramsPerDeciliter || unit == .millimolesPerLiter) + self.range = range + self.unit = unit + } + + public var isZero: Bool { + return abs(range.minValue) < .ulpOfOne && abs(range.maxValue) < .ulpOfOne + } + + public var quantityRange: ClosedRange { + range.quantityRange(for: unit) + } +} + +extension GlucoseRange: Hashable {} + +extension GlucoseRange: Equatable {} + +extension GlucoseRange: RawRepresentable { + public typealias RawValue = [String:Any] + + public init?(rawValue: RawValue) { + guard let rawRange = rawValue["range"] as? DoubleRange.RawValue, + let range = DoubleRange(rawValue: rawRange), + let bloodGlucoseUnit = rawValue["bloodGlucoseUnit"] as? String else + { + return nil + } + self.range = range + self.unit = HKUnit(from: bloodGlucoseUnit) + } + + public var rawValue: RawValue { + return [ + "range": range.rawValue, + "bloodGlucoseUnit": unit.unitString + ] + } +} + +extension GlucoseRange: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + unit = HKUnit(from: try container.decode(String.self, forKey: .bloodGlucoseUnit)) + range = try container.decode(DoubleRange.self, forKey: .range) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(range, forKey: .range) + try container.encode(unit.unitString, forKey: .bloodGlucoseUnit) + } + + private enum CodingKeys: String, CodingKey { + case bloodGlucoseUnit + case range + } +} diff --git a/Sources/LoopAlgorithm/GlucoseSampleValue.swift b/Sources/LoopAlgorithm/GlucoseSampleValue.swift new file mode 100644 index 0000000..427b469 --- /dev/null +++ b/Sources/LoopAlgorithm/GlucoseSampleValue.swift @@ -0,0 +1,32 @@ +// +// GlucoseSampleValue.swift +// LoopKit +// +// Created by Nathan Racklyeft on 3/6/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import HealthKit + +public protocol GlucoseSampleValue: GlucoseValue { + /// Uniquely identifies the source of the sample. + var provenanceIdentifier: String { get } + + /// Whether the glucose value was provided for visual consistency, rather than an actual, calibrated reading. + var isDisplayOnly: Bool { get } + + /// Whether the glucose value was entered by the user. + var wasUserEntered: Bool { get } + + /// Any condition applied to the sample. + var condition: GlucoseCondition? { get } + + /// The trend of the sample. + var trend: GlucoseTrend? { get } + + /// The trend rate of the sample. + var trendRate: HKQuantity? { get } + + /// The syncIdentifier of the sample. + var syncIdentifier: String? { get } +} diff --git a/Sources/LoopAlgorithm/GlucoseTrend.swift b/Sources/LoopAlgorithm/GlucoseTrend.swift new file mode 100644 index 0000000..a09daa6 --- /dev/null +++ b/Sources/LoopAlgorithm/GlucoseTrend.swift @@ -0,0 +1,83 @@ +// +// GlucoseTrend.swift +// Loop +// +// Created by Nate Racklyeft on 8/2/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import Foundation + + +public enum GlucoseTrend: Int, CaseIterable { + case upUpUp = 1 + case upUp = 2 + case up = 3 + case flat = 4 + case down = 5 + case downDown = 6 + case downDownDown = 7 + + public var symbol: String { + switch self { + case .upUpUp: + return "⇈" + case .upUp: + return "↑" + case .up: + return "↗︎" + case .flat: + return "→" + case .down: + return "↘︎" + case .downDown: + return "↓" + case .downDownDown: + return "⇊" + } + } + + public var arrows: String { + switch self { + case .upUpUp: + return "↑↑" + case .upUp: + return "↑" + case .up: + return "↗︎" + case .flat: + return "→" + case .down: + return "↘︎" + case .downDown: + return "↓" + case .downDownDown: + return "↓↓" + } + } +} + +extension GlucoseTrend { + public init?(symbol: String) { + switch symbol { + case "↑↑": + self = .upUpUp + case "↑": + self = .upUp + case "↗︎": + self = .up + case "→": + self = .flat + case "↘︎": + self = .down + case "↓": + self = .downDown + case "↓↓": + self = .downDownDown + default: + return nil + } + } +} + +extension GlucoseTrend: Codable {} diff --git a/Sources/LoopAlgorithm/GlucoseValue.swift b/Sources/LoopAlgorithm/GlucoseValue.swift new file mode 100644 index 0000000..dd01a80 --- /dev/null +++ b/Sources/LoopAlgorithm/GlucoseValue.swift @@ -0,0 +1,90 @@ +// +// GlucoseValue.swift +// +// Created by Nathan Racklyeft on 3/2/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + + +import HealthKit + + +public protocol GlucoseValue: SampleValue { +} + +public struct SimpleGlucoseValue: Equatable, GlucoseValue { + public let startDate: Date + public let endDate: Date + public let quantity: HKQuantity + + public init(startDate: Date, endDate: Date? = nil, quantity: HKQuantity) { + self.startDate = startDate + self.endDate = endDate ?? startDate + self.quantity = quantity + } + + public init(_ glucoseValue: GlucoseValue) { + self.startDate = glucoseValue.startDate + self.endDate = glucoseValue.endDate + self.quantity = glucoseValue.quantity + } +} + +extension SimpleGlucoseValue: 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.endDate = try container.decode(Date.self, forKey: .endDate) + self.quantity = HKQuantity(unit: HKUnit(from: try container.decode(String.self, forKey: .quantityUnit)), + doubleValue: 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(endDate, forKey: .endDate) + try container.encode(quantity.doubleValue(for: .milligramsPerDeciliter), forKey: .quantity) + try container.encode(HKUnit.milligramsPerDeciliter.unitString, forKey: .quantityUnit) + } + + private enum CodingKeys: String, CodingKey { + case startDate + case endDate + case quantity + case quantityUnit + } +} + +public struct PredictedGlucoseValue: Equatable, GlucoseValue { + public let startDate: Date + public let quantity: HKQuantity + + public init(startDate: Date, quantity: HKQuantity) { + self.startDate = startDate + self.quantity = quantity + } +} + +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 = HKQuantity(unit: HKUnit(from: try container.decode(String.self, forKey: .quantityUnit)), + doubleValue: 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(HKUnit.milligramsPerDeciliter.unitString, forKey: .quantityUnit) + } + + private enum CodingKeys: String, CodingKey { + case startDate + case quantity + case quantityUnit + } +} + +extension HKQuantitySample: GlucoseValue { } diff --git a/Sources/LoopAlgorithm/InsulinMath.swift b/Sources/LoopAlgorithm/InsulinMath.swift new file mode 100644 index 0000000..050cc25 --- /dev/null +++ b/Sources/LoopAlgorithm/InsulinMath.swift @@ -0,0 +1,745 @@ +// +// InsulinMath.swift +// Naterade +// +// Created by Nathan Racklyeft on 1/30/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import Foundation +import HealthKit + +public struct InsulinMath { + public static var defaultInsulinActivityDuration: TimeInterval = TimeInterval(hours: 6) + TimeInterval(minutes: 10) + public static var longestInsulinActivityDuration: TimeInterval = TimeInterval(hours: 6) + TimeInterval(minutes: 10) +} + +extension DoseEntry { + private func continuousDeliveryInsulinOnBoard(at date: Date, model: InsulinModel, delta: TimeInterval) -> Double { + let doseDuration = endDate.timeIntervalSince(startDate) // t1 + let time = date.timeIntervalSince(startDate) + var iob: Double = 0 + var doseDate = TimeInterval(0) // i + + repeat { + let segment: Double + + if doseDuration > 0 { + segment = max(0, min(doseDate + delta, doseDuration) - doseDate) / doseDuration + } else { + segment = 1 + } + + iob += segment * model.percentEffectRemaining(at: time - doseDate) + doseDate += delta + } while doseDate <= min(floor((time + model.delay) / delta) * delta, doseDuration) + + return iob + } + + func insulinOnBoard(at date: Date, model: InsulinModel, delta: TimeInterval) -> Double { + let time = date.timeIntervalSince(startDate) + guard time >= 0 else { + return 0 + } + + // Consider doses within the delta time window as momentary + if endDate.timeIntervalSince(startDate) <= 1.05 * delta { + return netBasalUnits * model.percentEffectRemaining(at: time) + } else { + return netBasalUnits * continuousDeliveryInsulinOnBoard(at: date, model: model, delta: delta) + } + } + + private func continuousDeliveryGlucoseEffect(at date: Date, model: InsulinModel, delta: TimeInterval) -> Double { + let doseDuration = endDate.timeIntervalSince(startDate) // t1 + let time = date.timeIntervalSince(startDate) + var value: Double = 0 + var doseDate = TimeInterval(0) // i + + repeat { + let segment: Double + + if doseDuration > 0 { + segment = max(0, min(doseDate + delta, doseDuration) - doseDate) / doseDuration + } else { + segment = 1 + } + + value += segment * (1.0 - model.percentEffectRemaining(at: time - doseDate)) + doseDate += delta + } while doseDate <= min(floor((time + model.delay) / delta) * delta, doseDuration) + + return value + } + + func glucoseEffect(at date: Date, model: InsulinModel, insulinSensitivity: Double, delta: TimeInterval) -> Double { + let time = date.timeIntervalSince(startDate) + + guard time >= 0 else { + return 0 + } + + // Consider doses within the delta time window as momentary + if endDate.timeIntervalSince(startDate) <= 1.05 * delta { + return netBasalUnits * -insulinSensitivity * (1.0 - model.percentEffectRemaining(at: time)) + } else { + return netBasalUnits * -insulinSensitivity * continuousDeliveryGlucoseEffect(at: date, model: model, delta: delta) + } + } + + func glucoseEffect(during interval: DateInterval, model: InsulinModel, insulinSensitivity: Double, delta: TimeInterval) -> Double { + let start = interval.start.timeIntervalSince(startDate) + let end = interval.end.timeIntervalSince(startDate) + + guard end-start >= 0 else { + return 0 + } + + // Consider doses within the delta time window as momentary + if endDate.timeIntervalSince(startDate) <= 1.05 * delta { + let effect = model.percentEffectRemaining(at: start) - model.percentEffectRemaining(at: end) + return netBasalUnits * -insulinSensitivity * effect + } else { + return netBasalUnits * -insulinSensitivity * continuousDeliveryGlucoseEffect(at: interval.end, model: model, delta: delta) + } + } + + + public func trimmed(from start: Date? = nil, to end: Date? = nil, syncIdentifier: String? = nil) -> DoseEntry { + + let originalDuration = endDate.timeIntervalSince(startDate) + + let startDate = max(start ?? .distantPast, self.startDate) + let endDate = max(startDate, min(end ?? .distantFuture, self.endDate)) + + var trimmedDeliveredUnits: Double? = deliveredUnits + var trimmedValue: Double = value + + if originalDuration > .ulpOfOne && (startDate > self.startDate || endDate < self.endDate) { + let updatedActualDelivery = unitsInDeliverableIncrements * (endDate.timeIntervalSince(startDate) / originalDuration) + if deliveredUnits != nil { + trimmedDeliveredUnits = updatedActualDelivery + } + if case .units = unit { + trimmedValue = updatedActualDelivery + } + } + + return DoseEntry( + type: type, + startDate: startDate, + endDate: endDate, + value: trimmedValue, + unit: unit, + deliveredUnits: trimmedDeliveredUnits, + description: description, + syncIdentifier: syncIdentifier, + scheduledBasalRate: scheduledBasalRate, + insulinType: insulinType, + automatic: automatic, + isMutable: isMutable, + wasProgrammedByPumpUI: wasProgrammedByPumpUI + ) + } +} + + +extension DoseEntry { + + /// Annotates a dose with the context of a history of scheduled basal rates + /// + /// If the dose crosses a schedule boundary, it will be split into multiple doses so each dose has a + /// single scheduled basal rate. + /// + /// - Parameter basalHistory: The history of basal schedule values to apply. Only schedule values overlapping the dose should be included. + /// - Returns: An array of annotated doses + fileprivate func annotated(with basalHistory: [AbsoluteScheduleValue]) -> [DoseEntry] { + + var doses: [DoseEntry] = [] + + for (index, basalItem) in basalHistory.enumerated() { + let startDate: Date + let endDate: Date + + // If we're splitting into multiple entries, keep the syncIdentifier unique + var syncIdentifier = self.syncIdentifier + if syncIdentifier != nil, basalHistory.count > 1 { + syncIdentifier! += " \(index + 1)/\(basalHistory.count)" + } + + if index == 0 { + startDate = self.startDate + } else { + startDate = basalItem.startDate + } + + if index == basalHistory.count - 1 { + endDate = self.endDate + } else { + endDate = basalHistory[index + 1].startDate + } + + var dose = trimmed(from: startDate, to: endDate, syncIdentifier: syncIdentifier) + + dose.scheduledBasalRate = HKQuantity(unit: DoseEntry.unitsPerHour, doubleValue: basalItem.value) + + doses.append(dose) + } + + return doses + } + + /// Annotates a dose with the specified insulin type. + /// + /// - Parameter insulinType: The insulin type to annotate the dose with. + /// - Returns: A dose annotated with the insulin model + public func annotated(with insulinType: InsulinType) -> DoseEntry { + return DoseEntry( + type: type, + startDate: startDate, + endDate: endDate, + value: value, + unit: unit, + deliveredUnits: deliveredUnits, + description: description, + syncIdentifier: syncIdentifier, + scheduledBasalRate: scheduledBasalRate, + insulinType: insulinType, + automatic: automatic, + isMutable: isMutable, + wasProgrammedByPumpUI: wasProgrammedByPumpUI + ) + } +} + +extension DoseEntry { + fileprivate var resolvingDelivery: DoseEntry { + guard !isMutable, deliveredUnits == nil else { + return self + } + + let resolvedUnits: Double + + if case .units = unit { + resolvedUnits = value + } else { + switch type { + case .tempBasal: + resolvedUnits = unitsInDeliverableIncrements + case .basal: + resolvedUnits = programmedUnits + default: + return self + } + } + return DoseEntry(type: type, startDate: startDate, endDate: endDate, value: value, unit: unit, deliveredUnits: resolvedUnits, description: description, syncIdentifier: syncIdentifier, scheduledBasalRate: scheduledBasalRate, insulinType: insulinType, automatic: automatic, isMutable: isMutable, wasProgrammedByPumpUI: wasProgrammedByPumpUI) + } +} + +extension Collection where Element: TimelineValue { + public var timespan: DateInterval { + + guard count > 0 else { + return DateInterval(start: Date(), duration: 0) + } + + var min: Date = .distantFuture + var max: Date = .distantPast + for value in self { + if value.startDate < min { + min = value.startDate + } + if value.endDate > max { + max = value.endDate + } + } + return DateInterval(start: min, end: max) + } +} + +extension Collection where Element == DoseEntry { + + /** + Maps a timeline of dose entries with overlapping start and end dates to a timeline of doses that represents actual insulin delivery. + + - returns: An array of reconciled insulin delivery history, as TempBasal and Bolus records + */ + func reconciled() -> [DoseEntry] { + + var reconciled: [DoseEntry] = [] + + var lastSuspend: DoseEntry? + var lastBasal: DoseEntry? + + for dose in self { + switch dose.type { + case .bolus: + reconciled.append(dose) + case .basal, .tempBasal: + if lastSuspend == nil, let last = lastBasal { + let endDate = Swift.min(last.endDate, dose.startDate) + + // Ignore 0-duration doses + if endDate > last.startDate { + reconciled.append(last.trimmed(from: nil, to: endDate, syncIdentifier: last.syncIdentifier)) + } + } else if let suspend = lastSuspend, dose.type == .tempBasal { + // Handle missing resume. Basal following suspend, with no resume. + reconciled.append(DoseEntry( + type: suspend.type, + startDate: suspend.startDate, + endDate: dose.startDate, + value: suspend.value, + unit: suspend.unit, + description: suspend.description ?? dose.description, + syncIdentifier: suspend.syncIdentifier, + insulinType: suspend.insulinType, + automatic: suspend.automatic, + isMutable: suspend.isMutable, + wasProgrammedByPumpUI: suspend.wasProgrammedByPumpUI + )) + lastSuspend = nil + } + + lastBasal = dose + case .resume: + if let suspend = lastSuspend { + + reconciled.append(DoseEntry( + type: suspend.type, + startDate: suspend.startDate, + endDate: dose.startDate, + value: suspend.value, + unit: suspend.unit, + description: suspend.description ?? dose.description, + syncIdentifier: suspend.syncIdentifier, + insulinType: suspend.insulinType, + automatic: suspend.automatic, + isMutable: suspend.isMutable, + wasProgrammedByPumpUI: suspend.wasProgrammedByPumpUI + )) + + lastSuspend = nil + + // Continue temp basals that may have started before suspending + if let last = lastBasal { + if last.endDate > dose.endDate { + lastBasal = DoseEntry( + type: last.type, + startDate: dose.endDate, + endDate: last.endDate, + value: last.value, + unit: last.unit, + description: last.description, + // We intentionally use the resume's identifier, as the basal entry has already been entered + syncIdentifier: dose.syncIdentifier, + insulinType: last.insulinType, + automatic: last.automatic, + isMutable: last.isMutable, + wasProgrammedByPumpUI: last.wasProgrammedByPumpUI + ) + } else { + lastBasal = nil + } + } + } + case .suspend: + if let last = lastBasal { + + reconciled.append(DoseEntry( + type: last.type, + startDate: last.startDate, + endDate: Swift.min(last.endDate, dose.startDate), + value: last.value, + unit: last.unit, + description: last.description, + syncIdentifier: last.syncIdentifier, + insulinType: last.insulinType, + automatic: last.automatic, + isMutable: last.isMutable, + wasProgrammedByPumpUI: last.wasProgrammedByPumpUI + )) + + if last.endDate <= dose.startDate { + lastBasal = nil + } + } + + lastSuspend = dose + } + } + + if let suspend = lastSuspend { + reconciled.append(DoseEntry( + type: suspend.type, + startDate: suspend.startDate, + endDate: nil, + value: suspend.value, + unit: suspend.unit, + description: suspend.description, + syncIdentifier: suspend.syncIdentifier, + insulinType: suspend.insulinType, + automatic: suspend.automatic, + isMutable: true, // Consider mutable until paired resume + wasProgrammedByPumpUI: suspend.wasProgrammedByPumpUI + )) + } else if let last = lastBasal, last.endDate > last.startDate { + reconciled.append(last) + } + + return reconciled.map { $0.resolvingDelivery } + } + + /// Annotates a sequence of dose entries with the configured basal history + /// + /// 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. + /// - Returns: An array of annotated dose entries + public func annotated(with basalHistory: [AbsoluteScheduleValue]) -> [DoseEntry] { + var annotatedDoses: [DoseEntry] = [] + + for dose in self { + let basalItems = basalHistory.filterDateRange(dose.startDate, dose.endDate) + annotatedDoses += dose.annotated(with: basalItems) + } + + return annotatedDoses + } + + + /** + Calculates the total insulin delivery for a collection of doses + + - returns: The total insulin insulin, in Units + */ + var totalDelivery: Double { + return reduce(0) { (total, dose) -> Double in + return total + dose.unitsInDeliverableIncrements + } + } + + /** + Calculates the timeline of insulin remaining for a collection of doses + + - parameter insulinModelProvider: A factory that can provide an insulin model given an insulin type + - parameter longestEffectDuration: The longest duration that a dose could be active. + - 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. + + - returns: A sequence of insulin amount remaining + */ + public func insulinOnBoard( + insulinModelProvider: InsulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: nil), + longestEffectDuration: TimeInterval = InsulinMath.defaultInsulinActivityDuration, + from start: Date? = nil, + to end: Date? = nil, + delta: TimeInterval = GlucoseMath.defaultDelta + ) -> [InsulinValue] { + guard let (start, end) = LoopMath.simulationDateRangeForSamples(self, from: start, to: end, duration: longestEffectDuration, delta: delta) else { + return [] + } + + var date = start + var values = [InsulinValue]() + + repeat { + let value = reduce(0) { (value, dose) -> Double in + return value + dose.insulinOnBoard(at: date, model: insulinModelProvider.model(for: dose.insulinType), delta: delta) + } + + values.append(InsulinValue(startDate: date, value: value)) + date = date.addingTimeInterval(delta) + } while date <= end + + return values + } + + /** + Calculates insulin remaining at a given point in time for a collection of doses + + - parameter insulinModelProvider: A factory that can provide an insulin model given an insulin type + - parameter date: The date at which to calculate remaining insulin. If nil, current date is used. + + - returns: A sequence of insulin amount remaining + */ + public func insulinOnBoard( + insulinModelProvider: InsulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: nil), + at date: Date? = nil + ) -> Double { + + let date = date ?? Date() + + return reduce(0) { (value, dose) -> Double in + return value + dose.insulinOnBoard(at: date, model: insulinModelProvider.model(for: dose.insulinType), delta: GlucoseMath.defaultDelta) + } + } + + + /// Calculates the timeline of glucose effects for a collection of doses. The ISF used for a given dose is based on the ISF in effect at the dose start time. + /// + /// - Parameters: + /// - insulinModelProvider: A factory that can provide an insulin model given an insulin type + /// - insulinSensitivityHistory: The timeline of glucose effect per unit of insulin + /// - start: The earliest date of effects to return + /// - 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( + insulinModelProvider: InsulinModelProvider, + insulinSensitivityHistory: [AbsoluteScheduleValue], + from start: Date? = nil, + to end: Date? = nil, + delta: TimeInterval = TimeInterval(/* minutes: */60 * 5) + ) -> [GlucoseEffect] { + + let activeEntries = self.filter({ entry in + entry.netBasalUnits != 0 + }) + + guard let (start, end) = LoopMath.simulationDateRangeForSamples(activeEntries, from: start, to: end, duration: InsulinMath.longestInsulinActivityDuration, delta: delta) else { + return [] + } + + var date = start + var values = [GlucoseEffect]() + let unit = HKUnit.milligramsPerDeciliter + + repeat { + let value = reduce(0) { (value, dose) -> Double in + + guard let isfScheduleValue = insulinSensitivityHistory.closestPrior(to: dose.startDate), isfScheduleValue.endDate >= dose.startDate else { + preconditionFailure("ISF History must cover dose startDates") + } + let isf = isfScheduleValue.value.doubleValue(for: unit) + let doseEffect = dose.glucoseEffect(at: date, model: insulinModelProvider.model(for: dose.insulinType), insulinSensitivity: isf, delta: delta) + return value + doseEffect + } + + values.append(GlucoseEffect(startDate: date, quantity: HKQuantity(unit: unit, doubleValue: value))) + date = date.addingTimeInterval(delta) + } while date <= end + + return values + } + + + /// Calculates the timeline of glucose effects for a collection of doses. Effects for a specific dose will vary over the course + /// of that dose's absoption interval based on the timeline of insulin sensitivity. + /// + /// - Parameters: + /// - insulinModelProvider: A factory that can provide an insulin model given an insulin type + /// - longestEffectDuration: The longest duration that a dose could be active. + /// - insulinSensitivityTimeline: A timeline of glucose effect per unit of insulin + /// - start: The earliest date of effects to return + /// - 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 glucoseEffects( + insulinModelProvider: InsulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: nil), + longestEffectDuration: TimeInterval = InsulinMath.defaultInsulinActivityDuration, + insulinSensitivityTimeline: [AbsoluteScheduleValue], + from start: Date? = nil, + to end: Date? = nil, + delta: TimeInterval = TimeInterval(/* minutes: */60 * 5) + ) -> [GlucoseEffect] { + guard let (start, end) = LoopMath.simulationDateRangeForSamples(self.filter({ entry in + entry.netBasalUnits != 0 + }), from: start, to: end, duration: longestEffectDuration, delta: delta) else { + return [] + } + + var lastDate = start + var date = start + var effectSum: Double = 0 + var values = [GlucoseEffect]() + let unit = HKUnit.milligramsPerDeciliter + + repeat { + // Sum effects over doses + let value = reduce(0) { (value, dose) -> Double in + guard date != lastDate else { + return 0 + } + + let model = insulinModelProvider.model(for: dose.insulinType) + + // Sum effects over pertinent ISF timeline segments + let isfSegments = insulinSensitivityTimeline.filterDateRange(lastDate, date) + return value + isfSegments.reduce(0, { partialResult, segment in + let start = Swift.max(lastDate, segment.startDate) + let end = Swift.min(date, segment.endDate) + return partialResult + dose.glucoseEffect(during: DateInterval(start: start, end: end), model: model, insulinSensitivity: segment.value.doubleValue(for: unit), delta: delta) + }) + } + + effectSum += value + values.append(GlucoseEffect(startDate: date, quantity: HKQuantity(unit: unit, doubleValue: effectSum))) + lastDate = date + date = date.addingTimeInterval(delta) + } while date <= end + + return values + } + + /// Calculates the timeline of glucose effects for a collection of doses at specified points in time. Effects for a specific dose will vary over the course + /// of that dose's absoption interval based on the timeline of insulin sensitivity. + /// + /// - Parameters: + /// - insulinModelProvider: A factory that can provide an insulin model given an insulin type + /// - longestEffectDuration: The longest duration that a dose could be active. + /// - insulinSensitivityTimeline: A timeline of glucose effect per unit of insulin + /// - 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( + insulinModelProvider: InsulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: nil), + longestEffectDuration: TimeInterval = InsulinMath.defaultInsulinActivityDuration, + insulinSensitivityTimeline: [AbsoluteScheduleValue], + effectDates: [Date], + delta: TimeInterval = TimeInterval(/* minutes: */60 * 5) + ) -> [GlucoseEffect] { + + var lastDate = effectDates.first! + var values = [GlucoseEffect]() + let unit = HKUnit.milligramsPerDeciliter + + for date in effectDates { + // Sum effects over doses + let value = reduce(0) { (value, dose) -> Double in + guard date != lastDate else { + return 0 + } + + let model = insulinModelProvider.model(for: dose.insulinType) + + // Sum effects over pertinent ISF timeline segments + let isfSegments = insulinSensitivityTimeline.filterDateRange(lastDate, date) + return value + isfSegments.reduce(0, { partialResult, segment in + let start = Swift.max(lastDate, segment.startDate) + let end = Swift.min(date, segment.endDate) + let effect = dose.glucoseEffect(during: DateInterval(start: start, end: end), model: model, insulinSensitivity: segment.value.doubleValue(for: unit), delta: delta) + return partialResult + effect + }) + } + + values.append(GlucoseEffect(startDate: date, quantity: HKQuantity(unit: unit, doubleValue: value))) + lastDate = date + } + + return values + } + + /// Fills any missing gaps in basal delivery with new doses based on the supplied basal history. Compared to `overlayBasalSchedule`, this uses a history of + /// of basal rates, rather than a daily schedule, so it can work across multiple schedule changes. This method is suitable for generating a display of basal delivery + /// that includes scheduled and temp basals. Boluses are not included in the returned array. + /// + /// - Parameters: + /// - basalHistory: A history of scheduled basal rates. The first record should have a timestamp matching or earlier than the start date of the first DoseEntry in this array. + /// - endDate: Infill to this date, if supplied. If not supplied, infill will stop at the last DoseEntry. + /// - gapPatchInterval: if the gap between two temp basals is less than this, then the start date of the second dose will be fudged to fill the gap. Used for display purposes. + /// - Returns: An array of doses, with new doses created for any gaps between basalHistory.first.startDate and the end date. + public func infill(with basalHistory: [AbsoluteScheduleValue], endDate: Date? = nil, gapPatchInterval: TimeInterval = 0) -> [DoseEntry] { + guard basalHistory.count > 0 else { + return Array(self) + } + + var newEntries = [DoseEntry]() + var curBasalIdx = basalHistory.startIndex + var lastDate = basalHistory[curBasalIdx].startDate + + func addBasalsBetween(startDate: Date, endDate: Date) { + while lastDate < endDate { + let entryEnd: Date + let nextBasalIdx = curBasalIdx + 1 + let curRate = basalHistory[curBasalIdx].value + if nextBasalIdx < basalHistory.endIndex && basalHistory[nextBasalIdx].startDate < endDate { + entryEnd = Swift.max(startDate, basalHistory[nextBasalIdx].startDate) + curBasalIdx = nextBasalIdx + } else { + entryEnd = endDate + } + + if lastDate != entryEnd { + newEntries.append( + DoseEntry( + type: .basal, + startDate: lastDate, + endDate: entryEnd, + value: curRate, + unit: .unitsPerHour)) + + lastDate = entryEnd + } + } + } + + for dose in self { + switch dose.type { + case .tempBasal, .basal, .suspend: + var doseStart = dose.startDate + if doseStart.timeIntervalSince(lastDate) > gapPatchInterval { + addBasalsBetween(startDate: lastDate, endDate: dose.startDate) + } else { + doseStart = lastDate + } + newEntries.append(DoseEntry( + type: dose.type, + startDate: doseStart, + endDate: dose.endDate, + value: dose.unitsPerHour, + unit: .unitsPerHour) + ) + lastDate = dose.endDate + case .resume: + assertionFailure("No resume events should be present in reconciled doses") + case .bolus: + break + } + } + + if let endDate, endDate > lastDate { + addBasalsBetween(startDate: lastDate, endDate: endDate) + } + + return newEntries + } + + /// Creates an array of DoseEntry values by unioning another array, de-duplicating by syncIdentifier + /// + /// - Parameter otherDoses: An array of doses to union + /// - Returns: A new array of doses + func appendedUnion(with otherDoses: [DoseEntry]) -> [DoseEntry] { + var union: [DoseEntry] = [] + var syncIdentifiers: Set = [] + + for dose in (self + otherDoses) { + if let syncIdentifier = dose.syncIdentifier { + let (inserted, _) = syncIdentifiers.insert(syncIdentifier) + if !inserted { + continue + } + } + + union.append(dose) + } + + return union + } +} + + +extension BidirectionalCollection where Element == DoseEntry { + /// The endDate of the last basal dose in the collection + var lastBasalEndDate: Date? { + for dose in self.reversed() { + if dose.type == .basal || dose.type == .tempBasal || dose.type == .resume { + return dose.endDate + } + } + + return nil + } +} diff --git a/Sources/LoopAlgorithm/InsulinModel.swift b/Sources/LoopAlgorithm/InsulinModel.swift new file mode 100644 index 0000000..00eca99 --- /dev/null +++ b/Sources/LoopAlgorithm/InsulinModel.swift @@ -0,0 +1,28 @@ +// +// InsulinModel.swift +// LoopKit +// +// Created by Pete Schwamb on 7/26/17. +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import Foundation + + +public protocol InsulinModel: CustomDebugStringConvertible { + + /// Returns the percentage of total insulin effect remaining at a specified interval after delivery; also known as Insulin On Board (IOB). + /// Return value is within the range of 0-1 + /// + /// - Parameters: + /// - time: The interval after insulin delivery + func percentEffectRemaining(at time: TimeInterval) -> Double + + /// The expected duration, including any effect delay, of an insulin dose, from the time of the dose + var effectDuration: TimeInterval { get } + + /// The time after the dose where the effect becomes non-zero + var delay: TimeInterval { get } +} + + diff --git a/Sources/LoopAlgorithm/InsulinModelProvider.swift b/Sources/LoopAlgorithm/InsulinModelProvider.swift new file mode 100644 index 0000000..f99aca8 --- /dev/null +++ b/Sources/LoopAlgorithm/InsulinModelProvider.swift @@ -0,0 +1,46 @@ +// +// InsulinModelProvider.swift +// LoopKit +// +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +public protocol InsulinModelProvider { + func model(for type: InsulinType?) -> InsulinModel +} + +public struct PresetInsulinModelProvider: InsulinModelProvider { + var defaultRapidActingModel: InsulinModel? + + public init(defaultRapidActingModel: InsulinModel?) { + self.defaultRapidActingModel = defaultRapidActingModel + } + + public func model(for type: InsulinType?) -> InsulinModel { + switch type { + case .fiasp: + return ExponentialInsulinModelPreset.fiasp + case .lyumjev: + return ExponentialInsulinModelPreset.lyumjev + case .afrezza: + return ExponentialInsulinModelPreset.afrezza + default: + return defaultRapidActingModel ?? ExponentialInsulinModelPreset.rapidActingAdult + } + } +} + +// Provides a fixed model, ignoring insulin type +public struct StaticInsulinModelProvider: InsulinModelProvider { + var model: InsulinModel + + public init(_ model: InsulinModel) { + self.model = model + } + + public func model(for type: InsulinType?) -> InsulinModel { + return model + } +} + + diff --git a/Sources/LoopAlgorithm/InsulinType.swift b/Sources/LoopAlgorithm/InsulinType.swift new file mode 100644 index 0000000..62e5eb0 --- /dev/null +++ b/Sources/LoopAlgorithm/InsulinType.swift @@ -0,0 +1,27 @@ +// +// InsulinType.swift +// LoopKit +// +// Created by Anna Quinlan on 12/8/20. +// Copyright © 2020 LoopKit Authors. All rights reserved. +// + +import Foundation + +public enum InsulinType: Int, Codable, CaseIterable { + case novolog + case humalog + case apidra + case fiasp + case lyumjev + case afrezza + + public var pumpAdministerable: Bool { + switch self { + case .afrezza: + return false + default: + return true + } + } +} diff --git a/Sources/LoopAlgorithm/InsulinValue.swift b/Sources/LoopAlgorithm/InsulinValue.swift new file mode 100644 index 0000000..2de3946 --- /dev/null +++ b/Sources/LoopAlgorithm/InsulinValue.swift @@ -0,0 +1,40 @@ +// +// InsulinValue.swift +// LoopKit +// +// Created by Nathan Racklyeft on 4/3/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import Foundation +import HealthKit + +public struct InsulinValue: TimelineValue, Equatable { + public let startDate: Date + public let value: Double + + public init(startDate: Date, value: Double) { + self.startDate = startDate + self.value = value + } + + public var quantity: HKQuantity { + HKQuantity(unit: .internationalUnit(), doubleValue: value) + } +} + +extension InsulinValue: Codable {} + +public extension Array where Element == InsulinValue { + func trimmed(from start: Date? = nil, to end: Date? = nil) -> [InsulinValue] { + return self.compactMap { entry in + if let start, entry.startDate < start { + return nil + } + if let end, entry.startDate > end { + return nil + } + return entry + } + } +} diff --git a/Sources/LoopAlgorithm/IntegralRetrospectiveCorrection.swift b/Sources/LoopAlgorithm/IntegralRetrospectiveCorrection.swift new file mode 100644 index 0000000..f23b7f8 --- /dev/null +++ b/Sources/LoopAlgorithm/IntegralRetrospectiveCorrection.swift @@ -0,0 +1,199 @@ +// +// IntegralRetrospectiveCorrection.swift +// Loop +// +// Created by Dragan Maksimovic on 9/19/21. +// Copyright © 2021 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit + +/** + Integral Retrospective Correction (IRC) calculates a correction effect in glucose prediction based on a timeline of past discrepancies between observed glucose movement and movement expected based on insulin and carb models. Integral retrospective correction acts as a proportional-integral-differential (PID) controller aimed at reducing modeling errors in glucose prediction. + + 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) + + /// RetrospectiveCorrection protocol variables + /// Standard effect duration + let effectDuration: TimeInterval + /// Overall retrospective correction effect + public var totalGlucoseCorrectionEffect: HKQuantity? + + /** + Integral retrospective correction parameters: + - currentDiscrepancyGain: Standard retrospective correction gain + - persistentDiscrepancyGain: Gain for persistent long-term modeling errors, must be greater than or equal to currentDiscrepancyGain + - correctionTimeConstant: How fast integral effect accumulates in response to persistent errors + - differentialGain: Differential effect gain + - delta: Glucose sampling time interval (5 min) + - maximumCorrectionEffectDuration: Maximum duration of the correction effect in glucose prediction + - retrospectiveCorrectionIntegrationInterval: Maximum duration over which to integrate retrospective correction changes + */ + static let currentDiscrepancyGain: Double = 1.0 + static let persistentDiscrepancyGain: Double = 2.0 // was 5.0 + static let correctionTimeConstant: TimeInterval = TimeInterval(minutes: 60.0) // was 90.0 + static let differentialGain: Double = 2.0 + static let delta: TimeInterval = TimeInterval(minutes: 5.0) + static let maximumCorrectionEffectDuration: TimeInterval = TimeInterval(minutes: 180.0) // was 240.0 + + /// Initialize computed integral retrospective correction parameters + static let integralForget: Double = exp( -delta.minutes / correctionTimeConstant.minutes ) + static let integralGain: Double = ((1 - integralForget) / integralForget) * + (persistentDiscrepancyGain - currentDiscrepancyGain) + static let proportionalGain: Double = currentDiscrepancyGain - integralGain + + /// All math is performed with glucose expressed in mg/dL + private let unit = HKUnit.milligramsPerDeciliter + + /// State variables reported in diagnostic issue report + var recentDiscrepancyValues: [Double] = [] + var integralCorrectionEffectDuration: TimeInterval? + var proportionalCorrection: Double = 0.0 + var integralCorrection: Double = 0.0 + var differentialCorrection: Double = 0.0 + var currentDate: Date = Date() + var ircStatus: String = "-" + + public init(effectDuration: TimeInterval) { + self.effectDuration = effectDuration + } + + /** + Calculates overall correction effect based on timeline of discrepancies, and updates glucoseCorrectionEffect + + - Parameters: + - glucose: Most recent glucose + - retrospectiveGlucoseDiscrepanciesSummed: Timeline of past discepancies + + - Returns: + - totalRetrospectiveCorrection: Overall glucose effect + */ + public func computeEffect( + startingAt startingGlucose: GlucoseValue, + retrospectiveGlucoseDiscrepanciesSummed: [GlucoseChange]?, + recencyInterval: TimeInterval, + retrospectiveCorrectionGroupingInterval: TimeInterval + ) -> [GlucoseEffect] { + + // Loop settings relevant for calculation of effect limits + // let settings = UserDefaults.appGroup?.loopSettings ?? LoopSettings() + currentDate = Date() + + // Last discrepancy should be recent, otherwise clear the effect and return + let glucoseDate = startingGlucose.startDate + var glucoseCorrectionEffect: [GlucoseEffect] = [] + guard let currentDiscrepancy = retrospectiveGlucoseDiscrepanciesSummed?.last, + glucoseDate.timeIntervalSince(currentDiscrepancy.endDate) <= recencyInterval + else { + ircStatus = "discrepancy not available, effect not computed." + totalGlucoseCorrectionEffect = nil + return( [] ) + } + + // Default values if we are not able to calculate integral retrospective correction + ircStatus = "defaulted to standard RC, past discrepancies or user settings not available." + let currentDiscrepancyValue = currentDiscrepancy.quantity.doubleValue(for: unit) + var scaledCorrection = currentDiscrepancyValue + totalGlucoseCorrectionEffect = HKQuantity(unit: unit, doubleValue: currentDiscrepancyValue) + integralCorrectionEffectDuration = effectDuration + + // Calculate integral retrospective correction if past discrepancies over integration interval are available and if user settings are available + if let pastDiscrepancies = retrospectiveGlucoseDiscrepanciesSummed?.filterDateRange(glucoseDate.addingTimeInterval(-Self.retrospectionInterval), glucoseDate) { + ircStatus = "effect computed successfully." + + // To reduce response delay, integral retrospective correction is computed over an array of recent contiguous discrepancy values having the same sign as the latest discrepancy value + recentDiscrepancyValues = [] + var nextDiscrepancy = currentDiscrepancy + let currentDiscrepancySign = currentDiscrepancy.quantity.doubleValue(for: unit).sign + for pastDiscrepancy in pastDiscrepancies.reversed() { + let pastDiscrepancyValue = pastDiscrepancy.quantity.doubleValue(for: unit) + if (pastDiscrepancyValue.sign == currentDiscrepancySign && + nextDiscrepancy.endDate.timeIntervalSince(pastDiscrepancy.endDate) + <= recencyInterval && abs(pastDiscrepancyValue) >= 0.1) + { + recentDiscrepancyValues.append(pastDiscrepancyValue) + nextDiscrepancy = pastDiscrepancy + } else { + break + } + } + recentDiscrepancyValues = recentDiscrepancyValues.reversed() + + // Integral effect math + integralCorrection = 0.0 + var integralCorrectionEffectMinutes = effectDuration.minutes - 2.0 * IntegralRetrospectiveCorrection.delta.minutes + for discrepancy in recentDiscrepancyValues { + integralCorrection = + IntegralRetrospectiveCorrection.integralForget * integralCorrection + + IntegralRetrospectiveCorrection.integralGain * discrepancy + integralCorrectionEffectMinutes += 2.0 * IntegralRetrospectiveCorrection.delta.minutes + } + // Limit effect duration + integralCorrectionEffectMinutes = min(integralCorrectionEffectMinutes, IntegralRetrospectiveCorrection.maximumCorrectionEffectDuration.minutes) + + // Differential effect math + var differentialDiscrepancy: Double = 0.0 + if recentDiscrepancyValues.count > 1 { + let previousDiscrepancyValue = recentDiscrepancyValues[recentDiscrepancyValues.count - 2] + differentialDiscrepancy = currentDiscrepancyValue - previousDiscrepancyValue + } + + // Overall glucose effect calculated as a sum of propotional, integral and differential effects + proportionalCorrection = IntegralRetrospectiveCorrection.proportionalGain * currentDiscrepancyValue + + // Differential effect added only when negative, to avoid upward stacking with momentum, while still mitigating sluggishness of retrospective correction when discrepancies start decreasing + if differentialDiscrepancy < 0.0 { + differentialCorrection = IntegralRetrospectiveCorrection.differentialGain * differentialDiscrepancy + } else { + differentialCorrection = 0.0 + } + + let totalCorrection = proportionalCorrection + integralCorrection + differentialCorrection + totalGlucoseCorrectionEffect = HKQuantity(unit: unit, doubleValue: totalCorrection) + integralCorrectionEffectDuration = TimeInterval(minutes: integralCorrectionEffectMinutes) + + // correction value scaled to account for extended effect duration + scaledCorrection = totalCorrection * effectDuration.minutes / integralCorrectionEffectDuration!.minutes + } + + let retrospectionTimeInterval = currentDiscrepancy.endDate.timeIntervalSince(currentDiscrepancy.startDate) + let discrepancyTime = max(retrospectionTimeInterval, retrospectiveCorrectionGroupingInterval) + let velocity = HKQuantity(unit: unit.unitDivided(by: .second()), doubleValue: scaledCorrection / discrepancyTime) + + // Update array of glucose correction effects + glucoseCorrectionEffect = startingGlucose.decayEffect(atRate: velocity, for: integralCorrectionEffectDuration!) + + // Return glucose correction effects + return( glucoseCorrectionEffect ) + } + + public var debugDescription: String { + let report: [String] = [ + "## IntegralRetrospectiveCorrection", + "", + "Last updated: \(currentDate)", + "Status: \(ircStatus)", + "currentDiscrepancyGain: \(IntegralRetrospectiveCorrection.currentDiscrepancyGain)", + "persistentDiscrepancyGain: \(IntegralRetrospectiveCorrection.persistentDiscrepancyGain)", + "correctionTimeConstant [min]: \(IntegralRetrospectiveCorrection.correctionTimeConstant.minutes)", + "proportionalGain: \(IntegralRetrospectiveCorrection.proportionalGain)", + "integralForget: \(IntegralRetrospectiveCorrection.integralForget)", + "integralGain: \(IntegralRetrospectiveCorrection.integralGain)", + "differentialGain: \(IntegralRetrospectiveCorrection.differentialGain)", + "Integration performed over \(recentDiscrepancyValues.count) most recent discrepancies having the same sign as the latest discrepancy value. Earliest-to-most-recent recentDiscrepancyValues [mg/dL]: \(recentDiscrepancyValues)", + "proportionalCorrection [mg/dL]: \(proportionalCorrection)", + "integralCorrection [mg/dL]: \(integralCorrection)", + "differentialCorrection [mg/dL]: \(differentialCorrection)", + "totalGlucoseCorrectionEffect: \(String(describing: totalGlucoseCorrectionEffect))", + "integralCorrectionEffectDuration [min]: \(String(describing: integralCorrectionEffectDuration?.minutes))" + ] + + return report.joined(separator: "\n") + } + +} diff --git a/Sources/LoopAlgorithm/LoopAlgorithm.swift b/Sources/LoopAlgorithm/LoopAlgorithm.swift new file mode 100644 index 0000000..8a7cc3b --- /dev/null +++ b/Sources/LoopAlgorithm/LoopAlgorithm.swift @@ -0,0 +1,497 @@ +// +// LoopAlgorithm.swift +// +// Created by Pete Schwamb on 6/30/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit + +public enum AlgorithmError: Error { + case missingGlucose + case glucoseTooOld + case basalTimelineIncomplete + case missingSuspendThreshold + case sensitivityTimelineIncomplete +} + +public struct LoopAlgorithmEffects { + public var insulin: [GlucoseEffect] + public var carbs: [GlucoseEffect] + public var carbStatus: [CarbStatus] + public var retrospectiveCorrection: [GlucoseEffect] + public var momentum: [GlucoseEffect] + public var insulinCounteraction: [GlucoseEffectVelocity] + public var retrospectiveGlucoseDiscrepancies: [GlucoseChange] + public var totalGlucoseCorrectionEffect: HKQuantity? + + public init( + insulin: [GlucoseEffect], + carbs: [GlucoseEffect], + carbStatus: [CarbStatus], + retrospectiveCorrection: [GlucoseEffect], + momentum: [GlucoseEffect], + insulinCounteraction: [GlucoseEffectVelocity], + retrospectiveGlucoseDiscrepancies: [GlucoseChange], + totalGlucoseCorrectionEffect: HKQuantity? = nil + ) { + self.insulin = insulin + self.carbs = carbs + self.carbStatus = carbStatus + self.retrospectiveCorrection = retrospectiveCorrection + self.momentum = momentum + self.insulinCounteraction = insulinCounteraction + self.retrospectiveGlucoseDiscrepancies = retrospectiveGlucoseDiscrepancies + self.totalGlucoseCorrectionEffect = totalGlucoseCorrectionEffect + } +} + +public struct AlgorithmEffectsOptions: OptionSet { + public let rawValue: UInt8 + + public static let carbs = AlgorithmEffectsOptions(rawValue: 1 << 0) + public static let insulin = AlgorithmEffectsOptions(rawValue: 1 << 1) + public static let momentum = AlgorithmEffectsOptions(rawValue: 1 << 2) + public static let retrospection = AlgorithmEffectsOptions(rawValue: 1 << 3) + + public static let all: AlgorithmEffectsOptions = [.carbs, .insulin, .momentum, .retrospection] + + public init(rawValue: UInt8) { + self.rawValue = rawValue + } +} + +public struct LoopPrediction { + public var glucose: [PredictedGlucoseValue] + public var effects: LoopAlgorithmEffects + public var dosesRelativeToBasal: [DoseEntry] + public var activeInsulin: Double? + public var activeCarbs: Double? +} + +public struct LoopAlgorithm { + /// Percentage of recommended dose to apply as bolus when using automatic bolus dosing strategy + static public let defaultBolusPartialApplicationFactor = 0.4 + + /// The duration of recommended temp basals + static public let tempBasalDuration = TimeInterval(minutes: 30) + + /// The amount of time since a given date that input data should be considered valid + public static let inputDataRecencyInterval = TimeInterval(minutes: 15) + + public static let insulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: nil) + + /// Generates a forecast predicting glucose. + /// + /// 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 + /// - 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. + /// - carbAbsorptionModel: A model conforming to CarbAbsorptionComputable that is used for computing carb absorption over time. + /// - 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: [StoredGlucoseSample], + doses: [DoseEntry], + carbEntries: [StoredCarbEntry], + basal: [AbsoluteScheduleValue], + sensitivity: [AbsoluteScheduleValue], + carbRatio: [AbsoluteScheduleValue], + algorithmEffectsOptions: AlgorithmEffectsOptions = .all, + useIntegralRetrospectiveCorrection: Bool = false, + includingPositiveVelocityAndRC: Bool = true, + carbAbsorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption() + ) -> LoopPrediction { + + var prediction: [PredictedGlucoseValue] = [] + var insulinEffects: [GlucoseEffect] = [] + var carbEffects: [GlucoseEffect] = [] + var retrospectiveCorrectionEffects: [GlucoseEffect] = [] + var momentumEffects: [GlucoseEffect] = [] + var insulinCounteractionEffects: [GlucoseEffectVelocity] = [] + var retrospectiveGlucoseDiscrepanciesSummed: [GlucoseChange] = [] + var totalGlucoseCorrectionEffect: HKQuantity? + var activeInsulin: Double? + var activeCarbs: Double? + var carbStatus: [CarbStatus] = [] + var dosesRelativeToBasal: [DoseEntry] = [] + + // Ensure basal history covers doses + if let doseStart = doses.first?.startDate, !basal.isEmpty, basal.first!.startDate <= doseStart { + // Overlay basal history on basal doses, splitting doses to get amount delivered relative to basal + dosesRelativeToBasal = doses.annotated(with: basal) + + insulinEffects = dosesRelativeToBasal.glucoseEffects( + insulinModelProvider: insulinModelProvider, + insulinSensitivityHistory: sensitivity, + from: start.addingTimeInterval(-CarbMath.maximumAbsorptionTimeInterval).dateFlooredToTimeInterval(GlucoseMath.defaultDelta), + to: nil) + + activeInsulin = dosesRelativeToBasal.insulinOnBoard(insulinModelProvider: insulinModelProvider, at: start) + + // ICE + insulinCounteractionEffects = glucoseHistory.counteractionEffects(to: insulinEffects) + } else { + activeInsulin = 0 + } + + // Carb Effects + carbStatus = carbEntries.map( + to: insulinCounteractionEffects, + carbRatio: carbRatio, + insulinSensitivity: sensitivity + ) + + carbEffects = carbStatus.dynamicGlucoseEffects( + from: start.addingTimeInterval(-IntegralRetrospectiveCorrection.retrospectionInterval), + carbRatios: carbRatio, + insulinSensitivities: sensitivity, + absorptionModel: carbAbsorptionModel + ) + + activeCarbs = carbStatus.dynamicCarbsOnBoard(at: start, absorptionModel: carbAbsorptionModel) + + // RC + let retrospectiveGlucoseDiscrepancies = insulinCounteractionEffects.subtracting(carbEffects) + retrospectiveGlucoseDiscrepanciesSummed = retrospectiveGlucoseDiscrepancies.combinedSums(of: LoopMath.retrospectiveCorrectionGroupingInterval * 1.01) + + let rc: RetrospectiveCorrection + + if useIntegralRetrospectiveCorrection { + rc = IntegralRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration) + } else { + rc = StandardRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration) + } + + if let latestGlucose = glucoseHistory.last { + retrospectiveCorrectionEffects = rc.computeEffect( + startingAt: latestGlucose, + retrospectiveGlucoseDiscrepanciesSummed: retrospectiveGlucoseDiscrepanciesSummed, + recencyInterval: TimeInterval(minutes: 15), + retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval + ) + + totalGlucoseCorrectionEffect = rc.totalGlucoseCorrectionEffect + + var effects = [[GlucoseEffect]]() + + if algorithmEffectsOptions.contains(.carbs) { + effects.append(carbEffects) + } + + if algorithmEffectsOptions.contains(.insulin) { + effects.append(insulinEffects) + } + + if algorithmEffectsOptions.contains(.retrospection) { + if !includingPositiveVelocityAndRC, let netRC = retrospectiveCorrectionEffects.netEffect(), netRC.quantity.doubleValue(for: .milligramsPerDeciliter) > 0 { + // positive RC is turned off + } else { + effects.append(retrospectiveCorrectionEffects) + } + } + + // Glucose Momentum + var useMomentum: Bool = true + if algorithmEffectsOptions.contains(.momentum) { + let momentumInputData = glucoseHistory.filterDateRange(start.addingTimeInterval(-GlucoseMath.momentumDataInterval), start) + momentumEffects = momentumInputData.linearMomentumEffect() + if !includingPositiveVelocityAndRC, let netMomentum = momentumEffects.netEffect(), netMomentum.quantity.doubleValue(for: .milligramsPerDeciliter) > 0 { + // positive momentum is turned off + useMomentum = false + } + } else { + useMomentum = false + } + + prediction = LoopMath.predictGlucose( + startingAt: latestGlucose, + momentum: useMomentum ? momentumEffects : [], + effects: effects + ) + + // Dosing requires prediction entries at least as long as the insulin model duration. + // If our prediction is shorter than that, then extend it here. + let finalDate = latestGlucose.startDate.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration) + if let last = prediction.last, last.startDate < finalDate { + prediction.append(PredictedGlucoseValue(startDate: finalDate, quantity: last.quantity)) + } + } + + return LoopPrediction( + glucose: prediction, + effects: LoopAlgorithmEffects( + insulin: insulinEffects, + carbs: carbEffects, + carbStatus: carbStatus, + retrospectiveCorrection: retrospectiveCorrectionEffects, + momentum: momentumEffects, + insulinCounteraction: insulinCounteractionEffects, + retrospectiveGlucoseDiscrepancies: retrospectiveGlucoseDiscrepanciesSummed, + totalGlucoseCorrectionEffect: totalGlucoseCorrectionEffect + ), + dosesRelativeToBasal: dosesRelativeToBasal, + activeInsulin: activeInsulin, + activeCarbs: activeCarbs + ) + } + + // Helper to generate prediction with LoopPredictionInput struct + public static func generatePrediction(input: LoopPredictionInput) -> LoopPrediction { + + return generatePrediction( + start: input.glucoseHistory.last?.startDate ?? Date(), + glucoseHistory: input.glucoseHistory, + doses: input.doses, + carbEntries: input.carbEntries, + basal: input.basal, + sensitivity: input.sensitivity, + carbRatio: input.carbRatio, + algorithmEffectsOptions: input.algorithmEffectsOptions, + useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection, + carbAbsorptionModel: input.carbAbsorptionModel.model + ) + } + + // Computes an amount of insulin to correct the given prediction + public static func insulinCorrection( + prediction: [PredictedGlucoseValue], + at deliveryDate: Date, + target: GlucoseRangeTimeline, + suspendThreshold: HKQuantity, + sensitivity: [AbsoluteScheduleValue], + insulinType: InsulinType + ) -> InsulinCorrection { + let insulinModel = insulinModelProvider.model(for: insulinType) + + return prediction.insulinCorrection( + to: target, + at: deliveryDate, + suspendThreshold: suspendThreshold, + insulinSensitivity: sensitivity, + model: insulinModel) + } + + // Computes a 30 minute temp basal dose to correct the given prediction + public static func recommendTempBasal( + for correction: InsulinCorrection, + at deliveryDate: Date, + neutralBasalRate: Double, + activeInsulin: Double, + maxBolus: Double, + maxBasalRate: Double + ) -> TempBasalRecommendation? { + + var maxBasalRate = maxBasalRate + + // TODO: Allow `highBasalThreshold` to be a configurable setting + if case .aboveRange(min: let min, correcting: _, minTarget: let highBasalThreshold, units: _) = correction, + min.quantity < highBasalThreshold + { + maxBasalRate = neutralBasalRate + } + + // Enforce max IOB, calculated from the user entered maxBolus + let automaticDosingIOBLimit = maxBolus * 2.0 + let iobHeadroom = automaticDosingIOBLimit - activeInsulin + + let maxThirtyMinuteRateToKeepIOBBelowLimit = iobHeadroom * (TimeInterval.hours(1) / tempBasalDuration) + neutralBasalRate // 30 minutes of a U/hr rate + maxBasalRate = Swift.min(maxThirtyMinuteRateToKeepIOBBelowLimit, maxBasalRate) + + return correction.asTempBasal( + neutralBasalRate: neutralBasalRate, + maxBasalRate: maxBasalRate, + duration: tempBasalDuration + ) + } + + // Computes a bolus or low-temp basal dose to correct the given prediction + public static func recommendAutomaticDose( + for correction: InsulinCorrection, + at deliveryDate: Date, + applicationFactor: Double, + neutralBasalRate: Double, + activeInsulin: Double, + maxBolus: Double, + maxBasalRate: Double + ) -> AutomaticDoseRecommendation? { + + + let deliveryHeadroom = max(0, maxBolus * 2.0 - activeInsulin) + + var deliveryMax = min(maxBolus * applicationFactor, deliveryHeadroom) + + if case .aboveRange(min: let min, correcting: _, minTarget: let minTarget, units: _) = correction, + min.quantity < minTarget + { + deliveryMax = 0 + } + + let temp: TempBasalRecommendation? = correction.asTempBasal( + neutralBasalRate: neutralBasalRate, + maxBasalRate: neutralBasalRate, + duration: .minutes(30) + ) + + let bolusUnits = correction.asPartialBolus( + partialApplicationFactor: applicationFactor, + maxBolusUnits: deliveryMax + ) + + if temp != nil || bolusUnits > 0 { + return AutomaticDoseRecommendation(basalAdjustment: temp, bolusUnits: bolusUnits) + } + + return nil + } + + // Computes a manual bolus to correct the given prediction + public static func recommendManualBolus( + for correction: InsulinCorrection, + maxBolus: Double, + currentGlucose: StoredGlucoseSample, + target: GlucoseRangeTimeline + ) -> ManualBolusRecommendation { + var bolus = correction.asManualBolus(maxBolus: maxBolus) + + if let targetAtCurrentGlucose = target.closestPrior(to: currentGlucose.startDate), + currentGlucose.quantity < targetAtCurrentGlucose.value.lowerBound + { + bolus.notice = .currentGlucoseBelowTarget(glucose: currentGlucose) + } + + return bolus + } + + public static func recommendDose(input: LoopAlgorithmInput) throws -> LoopAlgorithmDoseRecommendation { + let output = run(input: input) + switch output.recommendationResult { + case .success(let recommendation): + return recommendation + case .failure(let error): + throw error + } + } + + public static func run(input: LoopAlgorithmInput, effectOptions: AlgorithmEffectsOptions = .all) -> LoopAlgorithmOutput { + + // If we're running for automated dosing, we calculate a dose assuming that the current temp basal will be canceled + let inputDoses: [DoseEntry] + + if input.recommendationType.automated { + inputDoses = input.doses.trimmed(to: input.predictionStart, onlyTrimTempBasals: true) + } else { + inputDoses = input.doses + } + + // `generatePrediction` does a best-try to generate a prediction and associated effects. + // Outputs may be incomplete, if there are issues with the provided data. + // Assertions of data completeness/recency for dosing will be checked after. + // This is so we can communicate/visualize state to the user even if we can't make a dosing recommendation. + + let prediction = generatePrediction( + start: input.predictionStart, + glucoseHistory: input.glucoseHistory, + doses: inputDoses, + carbEntries: input.carbEntries, + basal: input.basal, + sensitivity: input.sensitivity, + carbRatio: input.carbRatio, + algorithmEffectsOptions: .all, + useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection, + includingPositiveVelocityAndRC: input.includePositiveVelocityAndRC, + carbAbsorptionModel: input.carbAbsorptionModel.model + ) + + // Now validate/recommend + let result: Result + + do { + guard let latestGlucose = input.glucoseHistory.last else { + throw AlgorithmError.missingGlucose + } + + guard input.predictionStart.timeIntervalSince(latestGlucose.startDate) < inputDataRecencyInterval else { + throw AlgorithmError.glucoseTooOld + } + + let forecastEnd = input.predictionStart.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration) + + guard let sensitivityEndDate = input.sensitivity.last?.endDate, sensitivityEndDate >= forecastEnd else { + throw AlgorithmError.sensitivityTimelineIncomplete + } + + guard let scheduledBasalRate = input.basal.closestPrior(to: input.predictionStart)?.value else { + throw AlgorithmError.basalTimelineIncomplete + } + + guard let suspendThreshold = input.suspendThreshold ?? input.target.closestPrior(to: input.predictionStart)?.value.lowerBound else { + throw AlgorithmError.missingSuspendThreshold + } + + // TODO: This is to be removed when implementing mid-absorption ISF changes + // This sets a single ISF value for the duration of the dose. + let correctionSensitivity = [input.sensitivity.first { $0.startDate <= input.predictionStart && $0.endDate >= input.predictionStart }!] + + let correction = insulinCorrection( + prediction: prediction.glucose, + at: input.predictionStart, + target: input.target, + suspendThreshold: suspendThreshold, + sensitivity: correctionSensitivity, + insulinType: input.recommendationInsulinType) + + switch input.recommendationType { + case .manualBolus: + let recommendation = recommendManualBolus( + for: correction, + maxBolus: input.maxBolus, + currentGlucose: latestGlucose, + target: input.target) + result = .success(.init(manual: recommendation)) + case .automaticBolus: + let recommendation = recommendAutomaticDose( + for: correction, + at: input.predictionStart, + applicationFactor: input.automaticBolusApplicationFactor ?? defaultBolusPartialApplicationFactor, + neutralBasalRate: scheduledBasalRate, + activeInsulin: prediction.activeInsulin!, + maxBolus: input.maxBolus, + maxBasalRate: input.maxBasalRate) + result = .success(.init(automatic: recommendation)) + case .tempBasal: + let recommendation = recommendTempBasal( + for: correction, + at: input.predictionStart, + neutralBasalRate: scheduledBasalRate, + activeInsulin: prediction.activeInsulin!, + maxBolus: input.maxBolus, + maxBasalRate: input.maxBasalRate) + result = .success(.init(automatic: AutomaticDoseRecommendation(basalAdjustment: recommendation))) + } + } catch { + result = .failure(error) + } + + return LoopAlgorithmOutput( + recommendationResult: result, + predictedGlucose: prediction.glucose, + effects: prediction.effects, + dosesRelativeToBasal: prediction.dosesRelativeToBasal, + activeInsulin: prediction.activeInsulin, + activeCarbs: prediction.activeCarbs + ) + } +} + diff --git a/Sources/LoopAlgorithm/LoopAlgorithmDoseRecommendation.swift b/Sources/LoopAlgorithm/LoopAlgorithmDoseRecommendation.swift new file mode 100644 index 0000000..56734dd --- /dev/null +++ b/Sources/LoopAlgorithm/LoopAlgorithmDoseRecommendation.swift @@ -0,0 +1,35 @@ +// +// LoopAlgorithmDoseRecommendation.swift +// LoopKit +// +// Created by Pete Schwamb on 10/11/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation + +public struct LoopAlgorithmDoseRecommendation: Equatable { + + public var manual: ManualBolusRecommendation? + public var automatic: AutomaticDoseRecommendation? + + public init(manual: ManualBolusRecommendation? = nil, automatic: AutomaticDoseRecommendation? = nil) { + self.manual = manual + self.automatic = automatic + } +} + +extension LoopAlgorithmDoseRecommendation: Codable {} + +extension LoopAlgorithmDoseRecommendation { + public func printFixture() { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + if let data = try? encoder.encode(self), + let json = String(data: data, encoding: .utf8) + { + print(json) + } + } +} diff --git a/Sources/LoopAlgorithm/LoopAlgorithmInput.swift b/Sources/LoopAlgorithm/LoopAlgorithmInput.swift new file mode 100644 index 0000000..db207bd --- /dev/null +++ b/Sources/LoopAlgorithm/LoopAlgorithmInput.swift @@ -0,0 +1,367 @@ +// +// LoopAlgorithmInput.swift +// LoopKit +// +// Created by Pete Schwamb on 9/21/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit + +public enum AlgorithmInputDecodingError: Error { + case invalidDoseRecommendationType + case invalidInsulinType + case doseRateMissing + case doseVolumeMissing +} + +public struct LoopAlgorithmInput { + public var predictionStart: Date + public var glucoseHistory: [StoredGlucoseSample] + public var doses: [DoseEntry] + public var carbEntries: [StoredCarbEntry] + public var basal: [AbsoluteScheduleValue] + public var sensitivity: [AbsoluteScheduleValue] + public var carbRatio: [AbsoluteScheduleValue] + public var target: GlucoseRangeTimeline + public var suspendThreshold: HKQuantity? + public var maxBolus: Double + public var maxBasalRate: Double + public var useIntegralRetrospectiveCorrection: Bool + public var includePositiveVelocityAndRC: Bool + public var carbAbsorptionModel: CarbAbsorptionModel = .piecewiseLinear + public var recommendationInsulinType: InsulinType = .novolog + public var recommendationType: DoseRecommendationType = .automaticBolus + public var automaticBolusApplicationFactor: Double? + + struct TargetEntry: Codable { + var startDate: Date + var endDate: Date + var lowerBound: Double + var upperBound: Double + } + + struct Dose: Codable { + var startDate: Date + var endDate: Date? + var volume: Double? + var type: DoseType + var insulinType: String? + } + + struct Glucose { + var value: Double + var isCalibration: Bool + var date: Date + } + + struct CarbEntry: Codable { + var grams: Double + var absorptionTime: TimeInterval? + var date: Date + } + + public init( + predictionStart: Date, + glucoseHistory: [StoredGlucoseSample], + doses: [DoseEntry], + carbEntries: [StoredCarbEntry], + basal: [AbsoluteScheduleValue], + sensitivity: [AbsoluteScheduleValue], + carbRatio: [AbsoluteScheduleValue], + target: GlucoseRangeTimeline, + suspendThreshold: HKQuantity?, + maxBolus: Double, + maxBasalRate: Double, + useIntegralRetrospectiveCorrection: Bool = false, + includePositiveVelocityAndRC: Bool = true, + carbAbsorptionModel: CarbAbsorptionModel = .piecewiseLinear, + recommendationInsulinType: InsulinType, + recommendationType: DoseRecommendationType, + automaticBolusApplicationFactor: Double? = nil + ) { + self.predictionStart = predictionStart + self.glucoseHistory = glucoseHistory + self.doses = doses + self.carbEntries = carbEntries + self.basal = basal + self.sensitivity = sensitivity + self.carbRatio = carbRatio + self.target = target + self.suspendThreshold = suspendThreshold + self.maxBolus = maxBolus + self.maxBasalRate = maxBasalRate + self.useIntegralRetrospectiveCorrection = useIntegralRetrospectiveCorrection + self.includePositiveVelocityAndRC = includePositiveVelocityAndRC + self.carbAbsorptionModel = carbAbsorptionModel + self.recommendationInsulinType = recommendationInsulinType + self.recommendationType = recommendationType + self.automaticBolusApplicationFactor = automaticBolusApplicationFactor + } +} + +extension LoopAlgorithmInput.Glucose: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.value = try container.decode(Double.self, forKey: .value) + self.isCalibration = try container.decodeIfPresent(Bool.self, forKey: .isCalibration) ?? false + self.date = try container.decode(Date.self, forKey: .date) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(value, forKey: .value) + if isCalibration { + try container.encode(isCalibration, forKey: .isCalibration) + } + try container.encode(date, forKey: .date) + + } + + private enum CodingKeys: String, CodingKey { + case value + case isCalibration + case date + } +} + + +extension LoopAlgorithmInput: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.predictionStart = try container.decode(Date.self, forKey: .predictionStart) + let glucose = try container.decode([Glucose].self, forKey: .glucoseHistory) + self.glucoseHistory = glucose.map { sample in + StoredGlucoseSample( + startDate: sample.date, + quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: sample.value), + isDisplayOnly: sample.isCalibration + ) + } + let doses = try container.decode([Dose].self, forKey: .doses) + self.doses = try doses.map({ dose in + let value: Double + let unit: DoseUnit + switch dose.type { + case .basal, .tempBasal, .bolus: + guard let decodedVolume = dose.volume else { + throw AlgorithmInputDecodingError.doseVolumeMissing + } + value = decodedVolume + unit = .units + default: + value = 0 + unit = .units + break + } + let insulinType: InsulinType? + if let insulinTypeIdentifier = dose.insulinType { + guard let decodedInsulinType = InsulinType(with: insulinTypeIdentifier) else { + throw AlgorithmInputDecodingError.invalidInsulinType + } + insulinType = decodedInsulinType + } else { + insulinType = nil + } + return DoseEntry(type: dose.type, startDate: dose.startDate, endDate: dose.endDate, value: value, unit: unit, insulinType: insulinType) + }) + let carbEntries = try container.decode([CarbEntry].self, forKey: .carbEntries) + self.carbEntries = carbEntries.map { entry in + StoredCarbEntry( + startDate: entry.date, + quantity: HKQuantity(unit: .gram(), doubleValue: entry.grams), + absorptionTime: entry.absorptionTime + ) + } + 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: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: $0.value))} + self.carbRatio = try container.decode([AbsoluteScheduleValue].self, forKey: .carbRatio) + let targetMgdl = try container.decode([TargetEntry].self, forKey: .target) + self.target = targetMgdl.map { + let lower = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: $0.lowerBound) + let upper = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: $0.upperBound) + let range = ClosedRange(uncheckedBounds: (lower: lower, upper: upper)) + return AbsoluteScheduleValue(startDate: $0.startDate, endDate: $0.endDate, value: range) + } + if let suspendThresholdMgdl = try container.decodeIfPresent(Double.self, forKey: .suspendThreshold) { + self.suspendThreshold = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: suspendThresholdMgdl) + } + self.maxBolus = try container.decode(Double.self, forKey: .maxBolus) + self.maxBasalRate = try container.decode(Double.self, forKey: .maxBasalRate) + self.useIntegralRetrospectiveCorrection = try container.decodeIfPresent(Bool.self, forKey: .useIntegralRetrospectiveCorrection) ?? false + self.includePositiveVelocityAndRC = try container.decodeIfPresent(Bool.self, forKey: .includePositiveVelocityAndRC) ?? true + + if let rawRecommendationInsulinType = try container.decodeIfPresent(String.self, forKey: .recommendationInsulinType) { + guard let decodedRecommendationInsulinType = InsulinType(with: rawRecommendationInsulinType) else { + throw AlgorithmInputDecodingError.invalidDoseRecommendationType + } + self.recommendationInsulinType = decodedRecommendationInsulinType + } else { + self.recommendationInsulinType = .novolog + } + + if let rawRecommendationType = try container.decodeIfPresent(String.self, forKey: .recommendationType) { + guard let decodedRecommendationType = DoseRecommendationType(rawValue: rawRecommendationType) else { + throw AlgorithmInputDecodingError.invalidDoseRecommendationType + } + self.recommendationType = decodedRecommendationType + } else { + self.recommendationType = .automaticBolus + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(predictionStart, forKey: .predictionStart) + try container.encode(glucoseHistory, forKey: .glucoseHistory) + let glucose = glucoseHistory.map { sample in + return Glucose( + value: sample.quantity.doubleValue(for: .milligramsPerDeciliter), + isCalibration: sample.isDisplayOnly, + date: sample.startDate) + } + try container.encode(glucose, forKey: .glucoseHistory) + let doses = doses.map { dose in + switch dose.type { + case .basal, .tempBasal, .bolus: + return Dose( + startDate: dose.startDate, + endDate: dose.endDate, + volume: dose.deliveredUnits ?? dose.programmedUnits, + type: dose.type, + insulinType: dose.insulinType?.identifierForAlgorithmInput) + default: + return Dose(startDate: dose.startDate, endDate: dose.endDate, type: dose.type, insulinType: dose.insulinType?.identifierForAlgorithmInput) + } + } + try container.encode(doses, forKey: .doses) + let carbEntries = carbEntries.map { entry in + CarbEntry( + grams: entry.quantity.doubleValue(for: .gram()), + absorptionTime: entry.absorptionTime, + date: entry.startDate) + } + 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(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) + } + try container.encode(targetMgdl, forKey: .target) + try container.encode(suspendThreshold?.doubleValue(for: .milligramsPerDeciliter), forKey: .suspendThreshold) + try container.encode(maxBolus, forKey: .maxBolus) + try container.encode(maxBasalRate, forKey: .maxBasalRate) + if useIntegralRetrospectiveCorrection { + try container.encode(useIntegralRetrospectiveCorrection, forKey: .useIntegralRetrospectiveCorrection) + } + if !includePositiveVelocityAndRC { + try container.encode(includePositiveVelocityAndRC, forKey: .includePositiveVelocityAndRC) + } + try container.encode(recommendationInsulinType.identifierForAlgorithmInput, forKey: .recommendationInsulinType) + try container.encode(recommendationType.rawValue, forKey: .recommendationType) + + } + + private enum CodingKeys: String, CodingKey { + case predictionStart + case glucoseHistory + case doses + case carbEntries + case basal + case sensitivity + case carbRatio + case target + case suspendThreshold + case maxBolus + case maxBasalRate + case useIntegralRetrospectiveCorrection + case includePositiveVelocityAndRC + case recommendationInsulinType + case recommendationType + } +} + + +// Default Codable implementation for insulin type is int, which is not very readable. Add more readable identifier +extension InsulinType { + var identifierForAlgorithmInput: String { + switch self { + case .afrezza: + return "afrezza" + case .novolog: + return "novolog" + case .humalog: + return "humalog" + case .apidra: + return "apidra" + case .fiasp: + return "fiasp" + case .lyumjev: + return "lyumjev" + } + } + + init?(with algorithmInputIdentifier: String) { + switch algorithmInputIdentifier { + case "afrezza": + self = .afrezza + case "novolog": + self = .novolog + case "humalog": + self = .humalog + case "apidra": + self = .apidra + case "fiasp": + self = .fiasp + case "lyumjev": + self = .lyumjev + default: + return nil + } + } +} + + +extension LoopAlgorithmInput { + + var simplifiedForFixture: LoopAlgorithmInput { + return LoopAlgorithmInput( + predictionStart: predictionStart, + glucoseHistory: glucoseHistory, + doses: doses, + carbEntries: carbEntries.map { + StoredCarbEntry(startDate: $0.startDate, quantity: $0.quantity, absorptionTime: $0.absorptionTime) + }, + basal: basal, + sensitivity: sensitivity, + carbRatio: carbRatio, + target: target, + suspendThreshold: suspendThreshold, + maxBolus: maxBolus, + maxBasalRate: maxBasalRate, + useIntegralRetrospectiveCorrection: useIntegralRetrospectiveCorrection, + recommendationInsulinType: recommendationInsulinType, + recommendationType: recommendationType + ) + } + + public func printFixture() { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + if let data = try? encoder.encode(self.simplifiedForFixture), + let json = String(data: data, encoding: .utf8) + { + print(json) + } + } +} diff --git a/Sources/LoopAlgorithm/LoopAlgorithmOutput.swift b/Sources/LoopAlgorithm/LoopAlgorithmOutput.swift new file mode 100644 index 0000000..7a6c66f --- /dev/null +++ b/Sources/LoopAlgorithm/LoopAlgorithmOutput.swift @@ -0,0 +1,43 @@ +// +// LoopAlgorithmOutput.swift +// +// +// Created by Pete Schwamb on 10/13/23. +// + +import Foundation +import HealthKit + +public struct LoopAlgorithmOutput { + public var recommendationResult: Result + public var predictedGlucose: [PredictedGlucoseValue] + public var effects: LoopAlgorithmEffects + public var dosesRelativeToBasal: [DoseEntry] + public var activeInsulin: Double? + public var activeCarbs: Double? + + public init( + recommendationResult: Result, + predictedGlucose: [PredictedGlucoseValue], + effects: LoopAlgorithmEffects, + dosesRelativeToBasal: [DoseEntry], + activeInsulin: Double? = nil, + activeCarbs: Double? = nil + ) { + self.recommendationResult = recommendationResult + self.predictedGlucose = predictedGlucose + self.effects = effects + self.dosesRelativeToBasal = dosesRelativeToBasal + self.activeInsulin = activeInsulin + self.activeCarbs = activeCarbs + } + + public var recommendation: LoopAlgorithmDoseRecommendation? { + switch recommendationResult { + case .success(let rec): + return rec + case .failure: + return nil + } + } +} diff --git a/Sources/LoopAlgorithm/LoopMath.swift b/Sources/LoopAlgorithm/LoopMath.swift new file mode 100644 index 0000000..c55cd91 --- /dev/null +++ b/Sources/LoopAlgorithm/LoopMath.swift @@ -0,0 +1,358 @@ +// +// LoopMath.swift +// +// Created by Nathan Racklyeft on 1/24/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import Foundation +import HealthKit + + +public enum LoopMath { + + /// The interval over which to aggregate changes in glucose for retrospective correction + public static let retrospectiveCorrectionGroupingInterval = TimeInterval(minutes: 30) + public static let retrospectiveCorrectionEffectDuration = TimeInterval(hours: 1) + + + static func simulationDateRangeForSamples( + _ samples: T, + from start: Date? = nil, + to end: Date? = nil, + duration: TimeInterval, + delay: TimeInterval = 0, + delta: TimeInterval + ) -> (start: Date, end: Date)? where T.Element: TimelineValue { + + if let start = start, let end = end { + return (start: start.dateFlooredToTimeInterval(delta), end: end.dateCeiledToTimeInterval(delta)) + } else { + guard samples.count > 0 else { + return nil + } + var minDate = samples.first!.startDate + var maxDate = minDate + + for sample in samples { + if sample.startDate < minDate { + minDate = sample.startDate + } + + if sample.endDate > maxDate { + maxDate = sample.endDate + } + } + + return ( + start: (start ?? minDate).dateFlooredToTimeInterval(delta), + end: (end ?? maxDate.addingTimeInterval(duration + delay)).dateCeiledToTimeInterval(delta) + ) + } + } + + /** + Calculates a range of time in `delta`-value intervals + + - parameter start: The range start date + - parameter end: The range end date + - parameter delta: The time differential for items in the returned range + + - returns: An array of dates + */ + public static func simulationDateRange( + from start: Date, + to end: Date, + delta: TimeInterval + ) -> [Date] { + let flooredStart = start.dateFlooredToTimeInterval(delta) + let ceiledEnd = end.dateCeiledToTimeInterval(delta) + + var output: [Date] = [] + var curr = flooredStart + repeat { + output.append(curr) + + let new = curr.addingTimeInterval(delta) + curr = new + } while curr <= ceiledEnd + + return output + } + + /** + Calculates a timeline of predicted glucose values from a variety of effects timelines. + + Each effect timeline: + - Is given equal weight, with the exception of the momentum effect timeline + - Can be of arbitrary size and start date + - Should be in ascending order + - Should have aligning dates with any overlapping timelines to ensure a smooth result + + - parameter startingGlucose: The starting glucose value + - parameter momentum: The momentum effect timeline determined from prior glucose values + - parameter effects: The glucose effect timelines to apply to the prediction. + + - returns: A timeline of glucose values + */ + public static func predictGlucose(startingAt startingGlucose: GlucoseValue, momentum: [GlucoseEffect] = [], effects: [GlucoseEffect]...) -> [PredictedGlucoseValue] { + return predictGlucose(startingAt: startingGlucose, momentum: momentum, effects: effects) + } + + /** + Calculates a timeline of predicted glucose values from a variety of effects timelines. + + Each effect timeline: + - Is given equal weight, with the exception of the momentum effect timeline + - Can be of arbitrary size and start date + - Should be in ascending order + - Should have aligning dates with any overlapping timelines to ensure a smooth result + + - parameter startingGlucose: The starting glucose value + - parameter momentum: The momentum effect timeline determined from prior glucose values + - parameter effects: The glucose effect timelines to apply to the prediction. + + - returns: A timeline of glucose values + */ + public static func predictGlucose(startingAt startingGlucose: GlucoseValue, momentum: [GlucoseEffect] = [], effects: [[GlucoseEffect]]) -> [PredictedGlucoseValue] { + var effectValuesAtDate: [Date: Double] = [:] + let unit = HKUnit.milligramsPerDeciliter + + for timeline in effects { + var previousEffectValue: Double = timeline.first?.quantity.doubleValue(for: unit) ?? 0 + + for effect in timeline { + let value = effect.quantity.doubleValue(for: unit) + effectValuesAtDate[effect.startDate] = (effectValuesAtDate[effect.startDate] ?? 0) + value - previousEffectValue + previousEffectValue = value + } + } + + // Blend the momentum effect linearly into the summed effect list + if momentum.count > 1 { + var previousEffectValue: Double = momentum[0].quantity.doubleValue(for: unit) + + // The blend begins delta minutes after after the last glucose (1.0) and ends at the last momentum point (0.0) + // We're assuming the first one occurs on or before the starting glucose. + let blendCount = momentum.count - 2 + + let timeDelta = momentum[1].startDate.timeIntervalSince(momentum[0].startDate) + + // The difference between the first momentum value and the starting glucose value + let momentumOffset = startingGlucose.startDate.timeIntervalSince(momentum[0].startDate) + + let blendSlope = 1.0 / Double(blendCount) + let blendOffset = momentumOffset / timeDelta * blendSlope + + for (index, effect) in momentum.enumerated() { + let value = effect.quantity.doubleValue(for: unit) + let effectValueChange = value - previousEffectValue + + let split = min(1.0, max(0.0, Double(momentum.count - index) / Double(blendCount) - blendSlope + blendOffset)) + let effectBlend = (1.0 - split) * (effectValuesAtDate[effect.startDate] ?? 0) + let momentumBlend = split * effectValueChange + + effectValuesAtDate[effect.startDate] = effectBlend + momentumBlend + + previousEffectValue = value + } + } + + let prediction = effectValuesAtDate.sorted { $0.0 < $1.0 }.reduce([PredictedGlucoseValue(startDate: startingGlucose.startDate, quantity: startingGlucose.quantity)]) { (prediction, effect) -> [PredictedGlucoseValue] in + if effect.0 > startingGlucose.startDate, let lastValue = prediction.last { + let nextValue = PredictedGlucoseValue( + startDate: effect.0, + quantity: HKQuantity(unit: unit, doubleValue: effect.1 + lastValue.quantity.doubleValue(for: unit)) + ) + return prediction + [nextValue] + } else { + return prediction + } + } + + return prediction + } +} + + +extension GlucoseValue { + /** + Calculates a timeline of glucose effects by applying a linear decay to a rate of change. + + - parameter rate: The glucose velocity + - parameter duration: The duration the effect should continue before ending + - parameter delta: The time differential for the returned values + + - returns: An array of glucose effects + */ + public func decayEffect(atRate rate: HKQuantity, for duration: TimeInterval, withDelta delta: TimeInterval = 5 * 60) -> [GlucoseEffect] { + guard let (startDate, endDate) = LoopMath.simulationDateRangeForSamples([self], duration: duration, delta: delta) else { + return [] + } + + let glucoseUnit = HKUnit.milligramsPerDeciliter + let velocityUnit = GlucoseEffectVelocity.perSecondUnit + + // The starting rate, which we will decay to 0 over the specified duration + let intercept = rate.doubleValue(for: velocityUnit) // mg/dL/s + let decayStartDate = startDate.addingTimeInterval(delta) + let slope = -intercept / (duration - delta) // mg/dL/s/s + + var values = [GlucoseEffect(startDate: startDate, quantity: quantity)] + var date = decayStartDate + var lastValue = quantity.doubleValue(for: glucoseUnit) + + repeat { + let value = lastValue + (intercept + slope * date.timeIntervalSince(decayStartDate)) * delta + values.append(GlucoseEffect(startDate: date, quantity: HKQuantity(unit: glucoseUnit, doubleValue: value))) + lastValue = value + date = date.addingTimeInterval(delta) + } while date < endDate + + return values + } +} + + +extension BidirectionalCollection where Element == GlucoseEffect { + + /// Sums adjacent glucose effects into buckets of the specified duration. + /// + /// Requires the receiver to be sorted chronologically by endDate + /// + /// - Parameter duration: The duration of each resulting summed element + /// - Returns: An array of summed effects + public func combinedSums(of duration: TimeInterval) -> [GlucoseChange] { + var sums = [GlucoseChange]() + sums.reserveCapacity(self.count) + var lastValidIndex = sums.startIndex + + for effect in reversed() { + sums.append(GlucoseChange(startDate: effect.startDate, endDate: effect.endDate, quantity: effect.quantity)) + + for sumsIndex in lastValidIndex..<(sums.endIndex - 1) { + guard sums[sumsIndex].endDate <= effect.endDate.addingTimeInterval(duration) else { + lastValidIndex += 1 + continue + } + + sums[sumsIndex].append(effect) + } + } + + return sums.reversed() + } + + /// Returns the net effect of the receiver as a GlucoseChange object + /// + /// Requires the receiver to be sorted chronologically by endDate + /// + /// - Returns: A single GlucoseChange representing the net effect + public func netEffect() -> GlucoseChange? { + guard let first = self.first, let last = self.last else { + return nil + } + + let net = last.quantity.doubleValue(for: .milligramsPerDeciliter) - first.quantity.doubleValue(for: .milligramsPerDeciliter) + + return GlucoseChange(startDate: first.startDate, endDate: last.endDate, quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: net)) + } + + /// Returns the net effect of a portion of receiver as a GlucoseChange object + /// + /// Requires the receiver to be sorted chronologically by endDate + /// + /// - Returns: A single GlucoseChange representing the net effect + public func netEffect(after startDate: Date) -> GlucoseChange? { + guard count > 1 else { + return nil + } + + guard var startingEffectIndex = firstIndex(where: { $0.startDate > startDate } ) else { + return nil + } + + if startingEffectIndex > startIndex { + startingEffectIndex = index(before: startingEffectIndex) + } + + let firstEffect = self[startingEffectIndex] + + let net = last!.quantity.doubleValue(for: .milligramsPerDeciliter) - firstEffect.quantity.doubleValue(for: .milligramsPerDeciliter) + + return GlucoseChange(startDate: firstEffect.startDate, endDate: last!.endDate, quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: net)) + } +} + +extension Sequence where Element: AdditiveArithmetic { + func sum() -> Element { + return reduce(.zero, +) + } +} + + +extension BidirectionalCollection where Element == GlucoseEffectVelocity { + + /// Subtracts an array of glucose effects with uniform intervals and no gaps from the collection of effect changes, which may not have uniform intervals. + /// + /// - Parameters: + /// - otherEffects: The array of glucose effects to subtract + /// - effectInterval: The time interval between elements in the otherEffects array + /// - Returns: A resulting array of glucose effects + public func subtracting(_ otherEffects: [GlucoseEffect], withUniformInterval effectInterval: TimeInterval = GlucoseMath.defaultDelta) -> [GlucoseEffect] { + // Trim both collections to match + let otherEffects = otherEffects.filterDateRange(self.first?.endDate, nil) + let effects = self.filterDateRange(otherEffects.first?.startDate, nil) + + var subtracted: [GlucoseEffect] = [] + + var previousOtherEffectValue = otherEffects.first?.quantity.doubleValue(for: .milligramsPerDeciliter) ?? 0 // mg/dL + var effectIndex = effects.startIndex + + for otherEffect in otherEffects.dropFirst() { + guard effectIndex < effects.endIndex else { + break + } + + let otherEffectValue = otherEffect.quantity.doubleValue(for: .milligramsPerDeciliter) + let otherEffectChange = otherEffectValue - previousOtherEffectValue + previousOtherEffectValue = otherEffectValue + + let effect = effects[effectIndex] + + // Our effect array may have gaps, or have longer segments than 5 minutes. + guard effect.endDate <= otherEffect.endDate else { + continue // Move on to the next other effect + } + + effectIndex += 1 + + let effectValue = effect.quantity.doubleValue(for: GlucoseEffectVelocity.perSecondUnit) // mg/dL/s + let effectValueMatchingOtherEffectInterval = effectValue * effectInterval // mg/dL + + subtracted.append(GlucoseEffect( + startDate: effect.endDate, + quantity: HKQuantity( + unit: .milligramsPerDeciliter, + doubleValue: effectValueMatchingOtherEffectInterval - otherEffectChange + ) + )) + } + + // If we have run out of otherEffect items, we assume the otherEffectChange remains zero + for effect in effects[effectIndex..] + + // 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-10h to t+6h + public var carbRatio: [AbsoluteScheduleValue] + + public var algorithmEffectsOptions: AlgorithmEffectsOptions + + public var useIntegralRetrospectiveCorrection: Bool = false + + public var includePositiveVelocityAndRC: Bool = true + + public var carbAbsorptionModel: CarbAbsorptionModel = .piecewiseLinear + + public init( + glucoseHistory: [StoredGlucoseSample], + doses: [DoseEntry], + carbEntries: [StoredCarbEntry], + basal: [AbsoluteScheduleValue], + sensitivity: [AbsoluteScheduleValue], + carbRatio: [AbsoluteScheduleValue], + algorithmEffectsOptions: AlgorithmEffectsOptions, + useIntegralRetrospectiveCorrection: Bool, + includePositiveVelocityAndRC: Bool + ) + { + self.glucoseHistory = glucoseHistory + self.doses = doses + self.carbEntries = carbEntries + self.basal = basal + self.sensitivity = sensitivity + self.carbRatio = carbRatio + self.algorithmEffectsOptions = algorithmEffectsOptions + self.useIntegralRetrospectiveCorrection = useIntegralRetrospectiveCorrection + self.includePositiveVelocityAndRC = includePositiveVelocityAndRC + } +} + + +extension LoopPredictionInput: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.glucoseHistory = try container.decode([StoredGlucoseSample].self, forKey: .glucoseHistory) + self.doses = try container.decode([DoseEntry].self, forKey: .doses) + self.carbEntries = try container.decode([StoredCarbEntry].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: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: $0.value))} + self.carbRatio = try container.decode([AbsoluteScheduleValue].self, forKey: .carbRatio) + if let algorithmEffectsOptionsRaw = try container.decodeIfPresent(AlgorithmEffectsOptions.RawValue.self, forKey: .algorithmEffectsOptions) { + self.algorithmEffectsOptions = AlgorithmEffectsOptions(rawValue: algorithmEffectsOptionsRaw) + } else { + self.algorithmEffectsOptions = .all + } + self.useIntegralRetrospectiveCorrection = try container.decodeIfPresent(Bool.self, forKey: .useIntegralRetrospectiveCorrection) ?? false + self.includePositiveVelocityAndRC = try container.decodeIfPresent(Bool.self, forKey: .includePositiveVelocityAndRC) ?? true + + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(glucoseHistory, forKey: .glucoseHistory) + 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(carbRatio, forKey: .carbRatio) + if algorithmEffectsOptions != .all { + try container.encode(algorithmEffectsOptions.rawValue, forKey: .algorithmEffectsOptions) + } + if !useIntegralRetrospectiveCorrection { + try container.encode(useIntegralRetrospectiveCorrection, forKey: .useIntegralRetrospectiveCorrection) + } + if !includePositiveVelocityAndRC { + try container.encode(includePositiveVelocityAndRC, forKey: .includePositiveVelocityAndRC) + } + } + + private enum CodingKeys: String, CodingKey { + case glucoseHistory + case doses + case carbEntries + case basal + case sensitivity + case carbRatio + case algorithmEffectsOptions + case useIntegralRetrospectiveCorrection + case includePositiveVelocityAndRC + } +} + +extension LoopPredictionInput { + + var simplifiedForFixture: LoopPredictionInput { + return LoopPredictionInput( + glucoseHistory: glucoseHistory.map { + return StoredGlucoseSample( + startDate: $0.startDate, + quantity: $0.quantity, + isDisplayOnly: $0.isDisplayOnly) + }, + doses: doses.map { + DoseEntry(type: $0.type, startDate: $0.startDate, endDate: $0.endDate, value: $0.value, unit: $0.unit) + }, + carbEntries: carbEntries.map { + StoredCarbEntry(startDate: $0.startDate, quantity: $0.quantity, absorptionTime: $0.absorptionTime) + }, + basal: basal, + sensitivity: sensitivity, + carbRatio: carbRatio, + algorithmEffectsOptions: algorithmEffectsOptions, + useIntegralRetrospectiveCorrection: useIntegralRetrospectiveCorrection, + includePositiveVelocityAndRC: includePositiveVelocityAndRC + ) + } + + public func printFixture() { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + if let data = try? encoder.encode(self.simplifiedForFixture), + let json = String(data: data, encoding: .utf8) + { + print(json) + } + } +} diff --git a/Sources/LoopAlgorithm/ManualBolusRecommendation.swift b/Sources/LoopAlgorithm/ManualBolusRecommendation.swift new file mode 100644 index 0000000..e73cf23 --- /dev/null +++ b/Sources/LoopAlgorithm/ManualBolusRecommendation.swift @@ -0,0 +1,114 @@ +// +// ManualBolusRecommendation.swift +// LoopKit +// +// Created by Pete Schwamb on 1/2/17. +// Copyright © 2017 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit + +public enum BolusRecommendationNotice { + case glucoseBelowSuspendThreshold(minGlucose: GlucoseValue) + case currentGlucoseBelowTarget(glucose: GlucoseValue) + case predictedGlucoseBelowTarget(minGlucose: GlucoseValue) + case predictedGlucoseInRange + case allGlucoseBelowTarget(minGlucose: GlucoseValue) +} + +extension BolusRecommendationNotice: Codable { + public init(from decoder: Decoder) throws { + if let string = try? decoder.singleValueContainer().decode(String.self) { + switch string { + case CodableKeys.predictedGlucoseInRange.rawValue: + self = .predictedGlucoseInRange + default: + throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "invalid enumeration")) + } + } else { + let container = try decoder.container(keyedBy: CodableKeys.self) + if let glucoseBelowSuspendThreshold = try container.decodeIfPresent(GlucoseBelowSuspendThreshold.self, forKey: .glucoseBelowSuspendThreshold) { + self = .glucoseBelowSuspendThreshold(minGlucose: glucoseBelowSuspendThreshold.minGlucose) + } else if let currentGlucoseBelowTarget = try container.decodeIfPresent(CurrentGlucoseBelowTarget.self, forKey: .currentGlucoseBelowTarget) { + self = .currentGlucoseBelowTarget(glucose: currentGlucoseBelowTarget.glucose) + } else if let predictedGlucoseBelowTarget = try container.decodeIfPresent(PredictedGlucoseBelowTarget.self, forKey: .predictedGlucoseBelowTarget) { + self = .predictedGlucoseBelowTarget(minGlucose: predictedGlucoseBelowTarget.minGlucose) + } else if let allGlucoseBelowTarget = try container.decodeIfPresent(AllGlucoseBelowTarget.self, forKey: .allGlucoseBelowTarget) { + self = .allGlucoseBelowTarget(minGlucose: allGlucoseBelowTarget.minGlucose) + } else { + throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "invalid enumeration")) + } + } + } + + public func encode(to encoder: Encoder) throws { + switch self { + case .glucoseBelowSuspendThreshold(let minGlucose): + var container = encoder.container(keyedBy: CodableKeys.self) + try container.encode(GlucoseBelowSuspendThreshold(minGlucose: SimpleGlucoseValue(minGlucose)), forKey: .glucoseBelowSuspendThreshold) + case .currentGlucoseBelowTarget(let glucose): + var container = encoder.container(keyedBy: CodableKeys.self) + try container.encode(CurrentGlucoseBelowTarget(glucose: SimpleGlucoseValue(glucose)), forKey: .currentGlucoseBelowTarget) + case .predictedGlucoseBelowTarget(let minGlucose): + var container = encoder.container(keyedBy: CodableKeys.self) + try container.encode(PredictedGlucoseBelowTarget(minGlucose: SimpleGlucoseValue(minGlucose)), forKey: .predictedGlucoseBelowTarget) + case .predictedGlucoseInRange: + var container = encoder.singleValueContainer() + try container.encode(CodableKeys.predictedGlucoseInRange.rawValue) + case .allGlucoseBelowTarget(minGlucose: let minGlucose): + var container = encoder.container(keyedBy: CodableKeys.self) + try container.encode(AllGlucoseBelowTarget(minGlucose: SimpleGlucoseValue(minGlucose)), forKey: .allGlucoseBelowTarget) + } + } + + private struct GlucoseBelowSuspendThreshold: Codable { + let minGlucose: SimpleGlucoseValue + } + + private struct CurrentGlucoseBelowTarget: Codable { + let glucose: SimpleGlucoseValue + } + + private struct PredictedGlucoseBelowTarget: Codable { + let minGlucose: SimpleGlucoseValue + } + + private struct AllGlucoseBelowTarget: Codable { + let minGlucose: SimpleGlucoseValue + } + + private enum CodableKeys: String, CodingKey { + case glucoseBelowSuspendThreshold + case currentGlucoseBelowTarget + case predictedGlucoseBelowTarget + case predictedGlucoseInRange + case allGlucoseBelowTarget + } +} + +public struct ManualBolusRecommendation { + public var amount: Double + public var notice: BolusRecommendationNotice? + + public var quantity: HKQuantity { + return HKQuantity(unit: .internationalUnit(), doubleValue: amount) + } + + public init(amount: Double, notice: BolusRecommendationNotice? = nil) { + self.amount = amount + self.notice = notice + } +} + +extension ManualBolusRecommendation: Codable {} + +extension ManualBolusRecommendation: Comparable { + public static func ==(lhs: ManualBolusRecommendation, rhs: ManualBolusRecommendation) -> Bool { + return lhs.amount == rhs.amount + } + + public static func <(lhs: ManualBolusRecommendation, rhs: ManualBolusRecommendation) -> Bool { + return lhs.amount < rhs.amount + } +} diff --git a/Sources/LoopAlgorithm/RetrospectiveCorrection.swift b/Sources/LoopAlgorithm/RetrospectiveCorrection.swift new file mode 100644 index 0000000..276a86a --- /dev/null +++ b/Sources/LoopAlgorithm/RetrospectiveCorrection.swift @@ -0,0 +1,34 @@ +// +// RetrospectiveCorrection.swift +// Loop +// +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit + + +/// Derives a continued glucose effect from recent prediction discrepancies +public protocol RetrospectiveCorrection: CustomDebugStringConvertible { + /// The maximum interval of historical glucose discrepancies that should be provided to the computation + static var retrospectionInterval: TimeInterval { get } + + /// Overall retrospective correction effect + var totalGlucoseCorrectionEffect: HKQuantity? { get } + + /// Calculates overall correction effect based on timeline of discrepancies, and updates glucoseCorrectionEffect + /// + /// - Parameters: + /// - startingAt: Initial glucose value + /// - retrospectiveGlucoseDiscrepanciesSummed: Timeline of past discepancies + /// - recencyInterval: how recent discrepancy data must be, otherwise effect will be cleared + /// - retrospectiveCorrectionGroupingInterval: Duration of discrepancy measurements + /// - Returns: Glucose correction effects + func computeEffect( + startingAt startingGlucose: GlucoseValue, + retrospectiveGlucoseDiscrepanciesSummed: [GlucoseChange]?, + recencyInterval: TimeInterval, + retrospectiveCorrectionGroupingInterval: TimeInterval + ) -> [GlucoseEffect] +} diff --git a/Sources/LoopAlgorithm/SampleValue.swift b/Sources/LoopAlgorithm/SampleValue.swift new file mode 100644 index 0000000..07563b0 --- /dev/null +++ b/Sources/LoopAlgorithm/SampleValue.swift @@ -0,0 +1,130 @@ +// +// SampleValue.swift +// +// Created by Nathan Racklyeft on 1/24/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import Foundation +import HealthKit + + +public protocol TimelineValue { + var startDate: Date { get } + var endDate: Date { get } +} + + +public extension TimelineValue { + var endDate: Date { + return startDate + } +} + + +public protocol SampleValue: TimelineValue { + var quantity: HKQuantity { get } +} + + +public extension Sequence where Element: TimelineValue { + /** + Returns the closest element in the sorted sequence prior to the specified date + + - parameter date: The date to use in the search + + - returns: The closest index, if any exist before the specified date + */ + func closestPrior(to date: Date) -> Iterator.Element? { + return elementsAdjacent(to: date).before + } + + /// Returns the elements immediately before and after the specified date + /// + /// - Parameter date: The date to use in the search + /// - Returns: The closest elements, if found + func elementsAdjacent(to date: Date) -> (before: Iterator.Element?, after: Iterator.Element?) { + var before: Iterator.Element? + var after: Iterator.Element? + + for value in self { + if value.startDate <= date { + before = value + } else { + after = value + break + } + } + + return (before, after) + } + + /// Returns all elements inmmediately adjacent to the specified date + /// + /// Use Sequence.elementsAdjacent(to:) if specific before/after references are necessary + /// + /// - Parameter date: The date to use in the search + /// - Returns: The closest elements, if found + func allElementsAdjacent(to date: Date) -> [Iterator.Element] { + let (before, after) = elementsAdjacent(to: date) + return [before, after].compactMap({ $0 }) + } + + /** + Returns an array of elements filtered by the specified date range. + + 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 + + - returns: A new array of elements + */ + func filterDateRange(_ startDate: Date?, _ endDate: Date?) -> [Iterator.Element] { + return filter { (value) -> Bool in + if let startDate = startDate, value.endDate < startDate { + return false + } + + if let endDate = endDate, value.startDate > endDate { + return false + } + + return true + } + } + + /** + Returns an array of elements filtered by the specified DateInterval. + + 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 + + - returns: A new array of elements + */ + func filterDateInterval(interval: DateInterval) -> [Iterator.Element] { + return filterDateRange(interval.start, interval.end) + } + +} + +public extension Sequence where Element: SampleValue { + func average(unit: HKUnit) -> HKQuantity? { + let (sum, count) = reduce(into: (sum: 0.0, count: 0)) { result, element in + result.0 += element.quantity.doubleValue(for: unit) + result.1 += 1 + } + + guard count > 0 else { + return nil + } + + let average = sum / Double(count) + + return HKQuantity(unit: unit, doubleValue: average) + } +} diff --git a/Sources/LoopAlgorithm/StandardRetrospectiveCorrection.swift b/Sources/LoopAlgorithm/StandardRetrospectiveCorrection.swift new file mode 100644 index 0000000..3ee70fe --- /dev/null +++ b/Sources/LoopAlgorithm/StandardRetrospectiveCorrection.swift @@ -0,0 +1,68 @@ +// +// StandardRetrospectiveCorrection.swift +// Loop +// +// Created by Dragan Maksimovic on 10/27/18. +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit + +/** + Standard Retrospective Correction (RC) calculates a correction effect in glucose prediction based on the most recent discrepancy between observed glucose movement and movement expected based on insulin and carb models. Standard retrospective correction acts as a proportional (P) controller aimed at reducing modeling errors in glucose prediction. + + 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 { + public static let retrospectionInterval = TimeInterval(minutes: 30) + + /// RetrospectiveCorrection protocol variables + /// Standard effect duration + let effectDuration: TimeInterval + /// Overall retrospective correction effect + public var totalGlucoseCorrectionEffect: HKQuantity? + + /// All math is performed with glucose expressed in mg/dL + private let unit = HKUnit.milligramsPerDeciliter + + public init(effectDuration: TimeInterval) { + self.effectDuration = effectDuration + } + + public func computeEffect( + startingAt startingGlucose: GlucoseValue, + retrospectiveGlucoseDiscrepanciesSummed: [GlucoseChange]?, + recencyInterval: TimeInterval, + retrospectiveCorrectionGroupingInterval: TimeInterval + ) -> [GlucoseEffect] { + // Last discrepancy should be recent, otherwise clear the effect and return + let glucoseDate = startingGlucose.startDate + guard let currentDiscrepancy = retrospectiveGlucoseDiscrepanciesSummed?.last, + glucoseDate.timeIntervalSince(currentDiscrepancy.endDate) <= recencyInterval + else { + totalGlucoseCorrectionEffect = nil + return [] + } + + // Standard retrospective correction math + let currentDiscrepancyValue = currentDiscrepancy.quantity.doubleValue(for: unit) + totalGlucoseCorrectionEffect = HKQuantity(unit: unit, doubleValue: currentDiscrepancyValue) + + let retrospectionTimeInterval = currentDiscrepancy.endDate.timeIntervalSince(currentDiscrepancy.startDate) + let discrepancyTime = max(retrospectionTimeInterval, retrospectiveCorrectionGroupingInterval) + let velocity = HKQuantity(unit: unit.unitDivided(by: .second()), doubleValue: currentDiscrepancyValue / discrepancyTime) + + // Update array of glucose correction effects + return startingGlucose.decayEffect(atRate: velocity, for: effectDuration) + } + + public var debugDescription: String { + let report: [String] = [ + "## StandardRetrospectiveCorrection", + "" + ] + + return report.joined(separator: "\n") + } +} diff --git a/Sources/LoopAlgorithm/StoredCarbEntry.swift b/Sources/LoopAlgorithm/StoredCarbEntry.swift new file mode 100644 index 0000000..18ed5bc --- /dev/null +++ b/Sources/LoopAlgorithm/StoredCarbEntry.swift @@ -0,0 +1,161 @@ +// +// StoredCarbEntry.swift +// +// Created by Nathan Racklyeft on 1/22/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import HealthKit +import CoreData + +public struct StoredCarbEntry: CarbEntry, Equatable { + + public let uuid: UUID? + + public static let defaultProvenanceIdentifier = "com.LoopKit.Loop" + + // MARK: - HealthKit Sync Support + + public let provenanceIdentifier: String + public let syncIdentifier: String? + public let syncVersion: Int? + + // MARK: - SampleValue + + public let startDate: Date + public let quantity: HKQuantity + + // MARK: - CarbEntry + + public let foodType: String? + public let absorptionTime: TimeInterval? + public let createdByCurrentApp: Bool + + // MARK: - User dates + + public let userCreatedDate: Date? + public let userUpdatedDate: Date? + + public init( + startDate: Date, + quantity: HKQuantity, + uuid: UUID? = nil, + provenanceIdentifier: String = Self.defaultProvenanceIdentifier, + syncIdentifier: String? = nil, + syncVersion: Int? = nil, + foodType: String? = nil, + absorptionTime: TimeInterval? = nil, + createdByCurrentApp: Bool = true, + userCreatedDate: Date? = nil, + userUpdatedDate: Date? = nil + ) { + self.uuid = uuid + self.provenanceIdentifier = provenanceIdentifier + self.syncIdentifier = syncIdentifier + self.syncVersion = syncVersion + self.startDate = startDate + self.quantity = quantity + self.foodType = foodType + self.absorptionTime = absorptionTime + self.createdByCurrentApp = createdByCurrentApp + self.userCreatedDate = userCreatedDate + self.userUpdatedDate = userUpdatedDate + } + + public var amount: Double { + quantity.doubleValue(for: .gram()) + } +} + +extension StoredCarbEntry: 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: HKQuantity(unit: .gram(), doubleValue: try container.decode(Double.self, forKey: .quantity)), + uuid: try container.decodeIfPresent(UUID.self, forKey: .uuid), + provenanceIdentifier: (try container.decodeIfPresent(String.self, forKey: .provenanceIdentifier)) ?? Self.defaultProvenanceIdentifier, + syncIdentifier: try container.decodeIfPresent(String.self, forKey: .syncIdentifier), + syncVersion: try container.decodeIfPresent(Int.self, forKey: .syncVersion), + foodType: try container.decodeIfPresent(String.self, forKey: .foodType), + absorptionTime: try container.decodeIfPresent(TimeInterval.self, forKey: .absorptionTime), + createdByCurrentApp: (try container.decodeIfPresent(Bool.self, forKey: .createdByCurrentApp)) ?? true, + userCreatedDate: try container.decodeIfPresent(Date.self, forKey: .userCreatedDate), + userUpdatedDate: try container.decodeIfPresent(Date.self, forKey: .userUpdatedDate) + ) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(uuid, forKey: .uuid) + if provenanceIdentifier != Self.defaultProvenanceIdentifier { + try container.encode(provenanceIdentifier, forKey: .provenanceIdentifier) + } + try container.encodeIfPresent(syncIdentifier, forKey: .syncIdentifier) + try container.encodeIfPresent(syncVersion, forKey: .syncVersion) + try container.encode(startDate, forKey: .startDate) + try container.encode(quantity.doubleValue(for: .gram()), forKey: .quantity) + try container.encodeIfPresent(foodType, forKey: .foodType) + try container.encodeIfPresent(absorptionTime, forKey: .absorptionTime) + if !createdByCurrentApp { + try container.encode(createdByCurrentApp, forKey: .createdByCurrentApp) + } + try container.encodeIfPresent(userCreatedDate, forKey: .userCreatedDate) + try container.encodeIfPresent(userUpdatedDate, forKey: .userUpdatedDate) + } + + private enum CodingKeys: String, CodingKey { + case uuid + case provenanceIdentifier + case syncIdentifier + case syncVersion + case startDate + case quantity + case foodType + case absorptionTime + case createdByCurrentApp + case userCreatedDate + case userUpdatedDate + } +} + +// MARK: - DEPRECATED - Used only for migration + +extension StoredCarbEntry { + typealias RawValue = [String: Any] + + init?(rawValue: RawValue) { + guard let + sampleUUIDString = rawValue["sampleUUID"] as? String, + let uuid = UUID(uuidString: sampleUUIDString), + let startDate = rawValue["startDate"] as? Date, + let unitString = rawValue["unitString"] as? String, + let value = rawValue["value"] as? Double, + let createdByCurrentApp = rawValue["createdByCurrentApp"] as? Bool else + { + return nil + } + + var syncIdentifier: String? + var syncVersion: Int? + + if let externalID = rawValue["externalId"] as? String { + syncIdentifier = externalID + syncVersion = 1 + } + + self.init( + startDate: startDate, + quantity: HKQuantity(unit: HKUnit(from: unitString), doubleValue: value), + uuid: uuid, + provenanceIdentifier: createdByCurrentApp ? HKSource.default().bundleIdentifier : "", + syncIdentifier: syncIdentifier, + syncVersion: syncVersion, + foodType: rawValue["foodType"] as? String, + absorptionTime: rawValue["absorptionTime"] as? TimeInterval, + createdByCurrentApp: createdByCurrentApp, + userCreatedDate: nil, + userUpdatedDate: nil + ) + } +} diff --git a/Sources/LoopAlgorithm/StoredGlucoseSample.swift b/Sources/LoopAlgorithm/StoredGlucoseSample.swift new file mode 100644 index 0000000..bd61526 --- /dev/null +++ b/Sources/LoopAlgorithm/StoredGlucoseSample.swift @@ -0,0 +1,126 @@ +// +// StoredGlucoseSample.swift +// LoopKit +// +// Copyright © 2018 LoopKit Authors. All rights reserved. +// + +import HealthKit + +public struct StoredGlucoseSample: GlucoseSampleValue, Equatable { + public let uuid: UUID? // Note this is the UUID from HealthKit. Nil if not (yet) stored in HealthKit. + + public static let defaultProvenanceIdentifier = "com.LoopKit.Loop" + + // MARK: - HealthKit Sync Support + + public let provenanceIdentifier: String + public let syncIdentifier: String? + public let syncVersion: Int? + public let device: HKDevice? + public let healthKitEligibleDate: Date? + + // MARK: - SampleValue + + public let startDate: Date + public let quantity: HKQuantity + + // MARK: - GlucoseSampleValue + + public let isDisplayOnly: Bool + public let wasUserEntered: Bool + public let condition: GlucoseCondition? + public let trend: GlucoseTrend? + public let trendRate: HKQuantity? + + public init( + uuid: UUID? = nil, + provenanceIdentifier: String = Self.defaultProvenanceIdentifier, + syncIdentifier: String? = nil, + syncVersion: Int? = nil, + startDate: Date, + quantity: HKQuantity, + condition: GlucoseCondition? = nil, + trend: GlucoseTrend? = nil, + trendRate: HKQuantity? = nil, + isDisplayOnly: Bool = false, + wasUserEntered: Bool = false, + device: HKDevice? = nil, + healthKitEligibleDate: Date? = nil) { + self.uuid = uuid + self.provenanceIdentifier = provenanceIdentifier + self.syncIdentifier = syncIdentifier + self.syncVersion = syncVersion + self.startDate = startDate + self.quantity = quantity + self.condition = condition + self.trend = trend + self.trendRate = trendRate + self.isDisplayOnly = isDisplayOnly + self.wasUserEntered = wasUserEntered + self.device = device + self.healthKitEligibleDate = healthKitEligibleDate + } +} + +extension StoredGlucoseSample: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let uuid = try container.decodeIfPresent(UUID.self, forKey: .uuid) + let provenanceIdentifier = try container.decodeIfPresent(String.self, forKey: .provenanceIdentifier) ?? Self.defaultProvenanceIdentifier + let wasUserEntered = try container.decodeIfPresent(Bool.self, forKey: .wasUserEntered) ?? false + let isDisplayOnly = try container.decodeIfPresent(Bool.self, forKey: .isDisplayOnly) ?? false + + self.init(uuid: uuid, + provenanceIdentifier: provenanceIdentifier, + syncIdentifier: try container.decodeIfPresent(String.self, forKey: .syncIdentifier), + syncVersion: try container.decodeIfPresent(Int.self, forKey: .syncVersion), + startDate: try container.decode(Date.self, forKey: .startDate), + quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: try container.decode(Double.self, forKey: .quantity)), + condition: try container.decodeIfPresent(GlucoseCondition.self, forKey: .condition), + trend: try container.decodeIfPresent(GlucoseTrend.self, forKey: .trend), + trendRate: try container.decodeIfPresent(Double.self, forKey: .trendRate).map { HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: $0) }, + isDisplayOnly: isDisplayOnly, + wasUserEntered: wasUserEntered, + healthKitEligibleDate: try container.decodeIfPresent(Date.self, forKey: .healthKitEligibleDate)) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(uuid, forKey: .uuid) + if provenanceIdentifier != Self.defaultProvenanceIdentifier { + try container.encode(provenanceIdentifier, forKey: .provenanceIdentifier) + } + try container.encodeIfPresent(syncIdentifier, forKey: .syncIdentifier) + try container.encodeIfPresent(syncVersion, forKey: .syncVersion) + try container.encode(startDate, forKey: .startDate) + try container.encode(quantity.doubleValue(for: .milligramsPerDeciliter), forKey: .quantity) + try container.encodeIfPresent(condition, forKey: .condition) + try container.encodeIfPresent(trend, forKey: .trend) + try container.encodeIfPresent(trendRate?.doubleValue(for: .milligramsPerDeciliterPerMinute), forKey: .trendRate) + if isDisplayOnly { + try container.encode(isDisplayOnly, forKey: .isDisplayOnly) + } + if wasUserEntered { + try container.encode(wasUserEntered, forKey: .wasUserEntered) + } + try container.encodeIfPresent(healthKitEligibleDate, forKey: .healthKitEligibleDate) + } + + private enum CodingKeys: String, CodingKey { + case uuid + case provenanceIdentifier + case syncIdentifier + case syncVersion + case startDate + case quantity + case condition + case trend + case trendRate + case isDisplayOnly + case wasUserEntered + case device + case healthKitEligibleDate + } +} diff --git a/Sources/LoopAlgorithm/TempBasalRecommendation.swift b/Sources/LoopAlgorithm/TempBasalRecommendation.swift new file mode 100644 index 0000000..9ba072e --- /dev/null +++ b/Sources/LoopAlgorithm/TempBasalRecommendation.swift @@ -0,0 +1,31 @@ +// +// TempBasalRecommendation.swift +// LoopKit +// +// Created by Darin Krauss on 5/21/19. +// Copyright © 2019 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit + +public struct TempBasalRecommendation: Equatable { + public var unitsPerHour: Double + public let duration: TimeInterval + + public var rateQuantity: HKQuantity { + return HKQuantity(unit: .internationalUnitsPerHour, doubleValue: unitsPerHour) + } + + /// A special command which cancels any existing temp basals + public static var cancel: TempBasalRecommendation { + return self.init(unitsPerHour: 0, duration: 0) + } + + public init(unitsPerHour: Double, duration: TimeInterval) { + self.unitsPerHour = unitsPerHour + self.duration = duration + } +} + +extension TempBasalRecommendation: Codable {} diff --git a/Tests/LoopAlgorithmTests/Fixtures/carbs_with_isf_change_input.json b/Tests/LoopAlgorithmTests/Fixtures/carbs_with_isf_change_input.json new file mode 100644 index 0000000..a5870fb --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/carbs_with_isf_change_input.json @@ -0,0 +1,980 @@ +{ + "basal" : [ + { + "endDate" : "2023-10-18T04:00:00Z", + "startDate" : "2023-10-17T18:27:31Z", + "value" : 0.8 + }, + { + "endDate" : "2023-10-18T10:41:25Z", + "startDate" : "2023-10-18T04:00:00Z", + "value" : 0.8 + } + ], + "carbEntries" : [ + { + "absorptionTime" : 10800, + "date" : "2023-10-18T11:00:06Z", + "grams" : 35 + } + ], + "carbRatio" : [ + { + "endDate" : "2023-10-18T04:00:00Z", + "startDate" : "2023-10-18T00:41:25Z", + "value" : 9.5 + }, + { + "endDate" : "2023-10-18T09:00:00Z", + "startDate" : "2023-10-18T04:00:00Z", + "value" : 12 + }, + { + "endDate" : "2023-10-18T10:47:46Z", + "startDate" : "2023-10-18T09:00:00Z", + "value" : 4.2 + }, + { + "endDate" : "2023-10-18T10:48:06Z", + "startDate" : "2023-10-18T10:47:46Z", + "value" : 4.3 + }, + { + "endDate" : "2023-10-18T14:00:00Z", + "startDate" : "2023-10-18T10:48:06Z", + "value" : 4.2 + }, + { + "endDate" : "2023-10-18T16:51:25Z", + "startDate" : "2023-10-18T14:00:00Z", + "value" : 7.5 + } + ], + "doses" : [ + { + "endDate" : "2023-10-17T18:47:27Z", + "startDate" : "2023-10-17T18:27:31Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T19:07:29Z", + "startDate" : "2023-10-17T18:47:27Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T19:27:29Z", + "startDate" : "2023-10-17T19:07:30Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T19:47:27Z", + "startDate" : "2023-10-17T19:27:29Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T20:02:29Z", + "startDate" : "2023-10-17T19:47:28Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T20:32:44Z", + "startDate" : "2023-10-17T20:32:28Z", + "type" : "bolus", + "volume" : 0.4 + }, + { + "endDate" : "2023-10-17T20:42:39Z", + "startDate" : "2023-10-17T20:42:31Z", + "type" : "bolus", + "volume" : 0.2 + }, + { + "endDate" : "2023-10-17T21:02:27Z", + "startDate" : "2023-10-17T20:57:29Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T20:58:52Z", + "startDate" : "2023-10-17T20:58:12Z", + "type" : "bolus", + "volume" : 1 + }, + { + "endDate" : "2023-10-17T21:22:32Z", + "startDate" : "2023-10-17T21:17:29Z", + "type" : "tempBasal", + "volume" : 0.05 + }, + { + "endDate" : "2023-10-17T21:37:38Z", + "startDate" : "2023-10-17T21:32:44Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T21:47:29Z", + "startDate" : "2023-10-17T21:47:27Z", + "type" : "bolus", + "volume" : 0.05 + }, + { + "endDate" : "2023-10-17T22:07:27Z", + "startDate" : "2023-10-17T21:57:33Z", + "type" : "tempBasal", + "volume" : 0.05 + }, + { + "endDate" : "2023-10-17T22:06:05Z", + "startDate" : "2023-10-17T22:00:05Z", + "type" : "bolus", + "volume" : 9 + }, + { + "endDate" : "2023-10-17T22:32:28Z", + "startDate" : "2023-10-17T22:07:27Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T22:42:29Z", + "startDate" : "2023-10-17T22:32:29Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T22:52:30Z", + "startDate" : "2023-10-17T22:47:32Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T23:02:57Z", + "startDate" : "2023-10-17T23:02:27Z", + "type" : "bolus", + "volume" : 0.75 + }, + { + "endDate" : "2023-10-17T23:12:53Z", + "startDate" : "2023-10-17T23:12:29Z", + "type" : "bolus", + "volume" : 0.6 + }, + { + "endDate" : "2023-10-17T23:17:46Z", + "startDate" : "2023-10-17T23:17:30Z", + "type" : "bolus", + "volume" : 0.4 + }, + { + "endDate" : "2023-10-17T23:22:47Z", + "startDate" : "2023-10-17T23:22:39Z", + "type" : "bolus", + "volume" : 0.2 + }, + { + "endDate" : "2023-10-17T23:42:43Z", + "startDate" : "2023-10-17T23:42:39Z", + "type" : "bolus", + "volume" : 0.1 + }, + { + "endDate" : "2023-10-17T23:47:31Z", + "startDate" : "2023-10-17T23:47:29Z", + "type" : "bolus", + "volume" : 0.05 + }, + { + "endDate" : "2023-10-17T23:52:30Z", + "startDate" : "2023-10-17T23:52:28Z", + "type" : "bolus", + "volume" : 0.05 + }, + { + "endDate" : "2023-10-18T00:42:28Z", + "startDate" : "2023-10-18T00:32:30Z", + "type" : "tempBasal", + "volume" : 0.05 + }, + { + "endDate" : "2023-10-18T00:47:31Z", + "startDate" : "2023-10-18T00:47:29Z", + "type" : "bolus", + "volume" : 0.05 + }, + { + "endDate" : "2023-10-18T00:52:29Z", + "startDate" : "2023-10-18T00:52:27Z", + "type" : "bolus", + "volume" : 0.05 + }, + { + "endDate" : "2023-10-18T01:17:26Z", + "startDate" : "2023-10-18T01:07:30Z", + "type" : "tempBasal", + "volume" : 0.05 + }, + { + "endDate" : "2023-10-18T01:22:29Z", + "startDate" : "2023-10-18T01:17:27Z", + "type" : "tempBasal", + "volume" : 0.05 + }, + { + "endDate" : "2023-10-18T02:02:33Z", + "startDate" : "2023-10-18T02:02:29Z", + "type" : "bolus", + "volume" : 0.1 + }, + { + "endDate" : "2023-10-18T02:07:33Z", + "startDate" : "2023-10-18T02:07:31Z", + "type" : "bolus", + "volume" : 0.05 + }, + { + "endDate" : "2023-10-18T02:12:34Z", + "startDate" : "2023-10-18T02:12:30Z", + "type" : "bolus", + "volume" : 0.1 + }, + { + "endDate" : "2023-10-18T03:02:32Z", + "startDate" : "2023-10-18T03:02:30Z", + "type" : "bolus", + "volume" : 0.05 + }, + { + "endDate" : "2023-10-18T03:07:43Z", + "startDate" : "2023-10-18T03:07:39Z", + "type" : "bolus", + "volume" : 0.1 + }, + { + "endDate" : "2023-10-18T03:27:39Z", + "startDate" : "2023-10-18T03:17:30Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-18T03:32:27Z", + "startDate" : "2023-10-18T03:27:39Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-18T03:37:30Z", + "startDate" : "2023-10-18T03:32:28Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-18T03:57:28Z", + "startDate" : "2023-10-18T03:52:32Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-18T04:22:32Z", + "startDate" : "2023-10-18T04:00:00Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-18T04:07:41Z", + "startDate" : "2023-10-18T04:07:31Z", + "type" : "bolus", + "volume" : 0.25 + }, + { + "endDate" : "2023-10-18T04:13:00Z", + "startDate" : "2023-10-18T04:12:34Z", + "type" : "bolus", + "volume" : 0.65 + }, + { + "endDate" : "2023-10-18T04:17:44Z", + "startDate" : "2023-10-18T04:17:30Z", + "type" : "bolus", + "volume" : 0.35 + }, + { + "endDate" : "2023-10-18T04:27:35Z", + "startDate" : "2023-10-18T04:27:29Z", + "type" : "bolus", + "volume" : 0.15 + }, + { + "endDate" : "2023-10-18T04:32:37Z", + "startDate" : "2023-10-18T04:32:33Z", + "type" : "bolus", + "volume" : 0.1 + }, + { + "endDate" : "2023-10-18T04:57:43Z", + "startDate" : "2023-10-18T04:47:28Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-18T05:07:27Z", + "startDate" : "2023-10-18T05:02:34Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-18T05:17:29Z", + "startDate" : "2023-10-18T05:12:49Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-18T05:22:27Z", + "startDate" : "2023-10-18T05:17:30Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-18T05:52:29Z", + "startDate" : "2023-10-18T05:42:30Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-18T06:22:43Z", + "startDate" : "2023-10-18T06:22:37Z", + "type" : "bolus", + "volume" : 0.15 + }, + { + "endDate" : "2023-10-18T06:27:33Z", + "startDate" : "2023-10-18T06:27:29Z", + "type" : "bolus", + "volume" : 0.1 + }, + { + "endDate" : "2023-10-18T06:37:31Z", + "startDate" : "2023-10-18T06:32:30Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-18T06:42:48Z", + "startDate" : "2023-10-18T06:37:32Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-18T06:47:41Z", + "startDate" : "2023-10-18T06:47:33Z", + "type" : "bolus", + "volume" : 0.2 + }, + { + "endDate" : "2023-10-18T06:52:36Z", + "startDate" : "2023-10-18T06:52:30Z", + "type" : "bolus", + "volume" : 0.15 + }, + { + "endDate" : "2023-10-18T06:57:35Z", + "startDate" : "2023-10-18T06:57:29Z", + "type" : "bolus", + "volume" : 0.15 + }, + { + "endDate" : "2023-10-18T07:37:27Z", + "startDate" : "2023-10-18T07:32:39Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-18T07:52:28Z", + "startDate" : "2023-10-18T07:37:27Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-18T07:57:32Z", + "startDate" : "2023-10-18T07:57:28Z", + "type" : "bolus", + "volume" : 0.1 + }, + { + "endDate" : "2023-10-18T08:42:28Z", + "startDate" : "2023-10-18T08:32:32Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-18T08:52:39Z", + "startDate" : "2023-10-18T08:52:29Z", + "type" : "bolus", + "volume" : 0.25 + }, + { + "endDate" : "2023-10-18T09:17:27Z", + "startDate" : "2023-10-18T09:12:29Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-18T09:52:34Z", + "startDate" : "2023-10-18T09:52:30Z", + "type" : "bolus", + "volume" : 0.1 + }, + { + "endDate" : "2023-10-18T09:57:45Z", + "startDate" : "2023-10-18T09:57:39Z", + "type" : "bolus", + "volume" : 0.15 + }, + { + "endDate" : "2023-10-18T10:02:49Z", + "startDate" : "2023-10-18T10:02:45Z", + "type" : "bolus", + "volume" : 0.1 + }, + { + "endDate" : "2023-10-18T10:17:49Z", + "startDate" : "2023-10-18T10:12:30Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-18T10:37:27Z", + "startDate" : "2023-10-18T10:32:29Z", + "type" : "tempBasal", + "volume" : 0 + } + ], + "glucoseHistory" : [ + { + "date" : "2023-10-18T00:42:23Z", + "value" : 136 + }, + { + "date" : "2023-10-18T00:47:23Z", + "value" : 142 + }, + { + "date" : "2023-10-18T00:52:23Z", + "value" : 144 + }, + { + "date" : "2023-10-18T00:57:23Z", + "value" : 147 + }, + { + "date" : "2023-10-18T01:02:23Z", + "value" : 152 + }, + { + "date" : "2023-10-18T01:07:23Z", + "value" : 138 + }, + { + "date" : "2023-10-18T01:12:24Z", + "value" : 142 + }, + { + "date" : "2023-10-18T01:17:23Z", + "value" : 134 + }, + { + "date" : "2023-10-18T01:22:23Z", + "value" : 125 + }, + { + "date" : "2023-10-18T01:27:24Z", + "value" : 123 + }, + { + "date" : "2023-10-18T01:32:23Z", + "value" : 118 + }, + { + "date" : "2023-10-18T01:37:23Z", + "value" : 111 + }, + { + "date" : "2023-10-18T01:42:23Z", + "value" : 111 + }, + { + "date" : "2023-10-18T01:47:23Z", + "value" : 108 + }, + { + "date" : "2023-10-18T01:52:23Z", + "value" : 106 + }, + { + "date" : "2023-10-18T01:57:23Z", + "value" : 106 + }, + { + "date" : "2023-10-18T02:02:23Z", + "value" : 109 + }, + { + "date" : "2023-10-18T02:07:23Z", + "value" : 116 + }, + { + "date" : "2023-10-18T02:12:23Z", + "value" : 122 + }, + { + "date" : "2023-10-18T02:17:23Z", + "value" : 123 + }, + { + "date" : "2023-10-18T02:22:23Z", + "value" : 124 + }, + { + "date" : "2023-10-18T02:27:23Z", + "value" : 127 + }, + { + "date" : "2023-10-18T02:32:23Z", + "value" : 127 + }, + { + "date" : "2023-10-18T02:37:23Z", + "value" : 123 + }, + { + "date" : "2023-10-18T02:42:24Z", + "value" : 120 + }, + { + "date" : "2023-10-18T02:47:24Z", + "value" : 122 + }, + { + "date" : "2023-10-18T02:52:24Z", + "value" : 120 + }, + { + "date" : "2023-10-18T02:57:24Z", + "value" : 125 + }, + { + "date" : "2023-10-18T03:02:23Z", + "value" : 132 + }, + { + "date" : "2023-10-18T03:07:23Z", + "value" : 133 + }, + { + "date" : "2023-10-18T03:12:24Z", + "value" : 133 + }, + { + "date" : "2023-10-18T03:17:23Z", + "value" : 119 + }, + { + "date" : "2023-10-18T03:22:23Z", + "value" : 116 + }, + { + "date" : "2023-10-18T03:27:23Z", + "value" : 115 + }, + { + "date" : "2023-10-18T03:32:23Z", + "value" : 116 + }, + { + "date" : "2023-10-18T03:37:23Z", + "value" : 118 + }, + { + "date" : "2023-10-18T03:42:24Z", + "value" : 117 + }, + { + "date" : "2023-10-18T03:47:24Z", + "value" : 114 + }, + { + "date" : "2023-10-18T03:52:23Z", + "value" : 107 + }, + { + "date" : "2023-10-18T03:57:23Z", + "value" : 105 + }, + { + "date" : "2023-10-18T04:02:23Z", + "value" : 107 + }, + { + "date" : "2023-10-18T04:07:23Z", + "value" : 112 + }, + { + "date" : "2023-10-18T04:12:24Z", + "value" : 127 + }, + { + "date" : "2023-10-18T04:17:23Z", + "value" : 128 + }, + { + "date" : "2023-10-18T04:22:23Z", + "value" : 128 + }, + { + "date" : "2023-10-18T04:27:24Z", + "value" : 129 + }, + { + "date" : "2023-10-18T04:32:23Z", + "value" : 129 + }, + { + "date" : "2023-10-18T04:37:23Z", + "value" : 129 + }, + { + "date" : "2023-10-18T04:42:23Z", + "value" : 128 + }, + { + "date" : "2023-10-18T04:47:23Z", + "value" : 125 + }, + { + "date" : "2023-10-18T04:52:23Z", + "value" : 125 + }, + { + "date" : "2023-10-18T04:57:23Z", + "value" : 125 + }, + { + "date" : "2023-10-18T05:02:23Z", + "value" : 124 + }, + { + "date" : "2023-10-18T05:07:23Z", + "value" : 123 + }, + { + "date" : "2023-10-18T05:12:23Z", + "value" : 121 + }, + { + "date" : "2023-10-18T05:17:23Z", + "value" : 120 + }, + { + "date" : "2023-10-18T05:22:23Z", + "value" : 118 + }, + { + "date" : "2023-10-18T05:27:23Z", + "value" : 117 + }, + { + "date" : "2023-10-18T05:32:23Z", + "value" : 116 + }, + { + "date" : "2023-10-18T05:37:24Z", + "value" : 116 + }, + { + "date" : "2023-10-18T05:42:24Z", + "value" : 110 + }, + { + "date" : "2023-10-18T05:47:23Z", + "value" : 108 + }, + { + "date" : "2023-10-18T05:52:24Z", + "value" : 109 + }, + { + "date" : "2023-10-18T05:57:23Z", + "value" : 110 + }, + { + "date" : "2023-10-18T06:02:24Z", + "value" : 109 + }, + { + "date" : "2023-10-18T06:07:23Z", + "value" : 107 + }, + { + "date" : "2023-10-18T06:12:23Z", + "value" : 108 + }, + { + "date" : "2023-10-18T06:17:23Z", + "value" : 109 + }, + { + "date" : "2023-10-18T06:22:24Z", + "value" : 111 + }, + { + "date" : "2023-10-18T06:27:24Z", + "value" : 111 + }, + { + "date" : "2023-10-18T06:32:23Z", + "value" : 107 + }, + { + "date" : "2023-10-18T06:37:24Z", + "value" : 103 + }, + { + "date" : "2023-10-18T06:42:23Z", + "value" : 108 + }, + { + "date" : "2023-10-18T06:47:24Z", + "value" : 111 + }, + { + "date" : "2023-10-18T06:52:24Z", + "value" : 114 + }, + { + "date" : "2023-10-18T06:57:24Z", + "value" : 117 + }, + { + "date" : "2023-10-18T07:02:23Z", + "value" : 117 + }, + { + "date" : "2023-10-18T07:07:24Z", + "value" : 115 + }, + { + "date" : "2023-10-18T07:12:24Z", + "value" : 113 + }, + { + "date" : "2023-10-18T07:17:24Z", + "value" : 113 + }, + { + "date" : "2023-10-18T07:22:24Z", + "value" : 112 + }, + { + "date" : "2023-10-18T07:27:24Z", + "value" : 112 + }, + { + "date" : "2023-10-18T07:32:23Z", + "value" : 109 + }, + { + "date" : "2023-10-18T07:37:24Z", + "value" : 107 + }, + { + "date" : "2023-10-18T07:42:24Z", + "value" : 104 + }, + { + "date" : "2023-10-18T07:47:23Z", + "value" : 102 + }, + { + "date" : "2023-10-18T07:52:23Z", + "value" : 107 + }, + { + "date" : "2023-10-18T07:57:24Z", + "value" : 108 + }, + { + "date" : "2023-10-18T08:02:23Z", + "value" : 106 + }, + { + "date" : "2023-10-18T08:07:23Z", + "value" : 104 + }, + { + "date" : "2023-10-18T08:12:24Z", + "value" : 104 + }, + { + "date" : "2023-10-18T08:17:24Z", + "value" : 103 + }, + { + "date" : "2023-10-18T08:22:23Z", + "value" : 102 + }, + { + "date" : "2023-10-18T08:27:23Z", + "value" : 102 + }, + { + "date" : "2023-10-18T08:32:24Z", + "value" : 92 + }, + { + "date" : "2023-10-18T08:37:24Z", + "value" : 93 + }, + { + "date" : "2023-10-18T08:42:23Z", + "value" : 95 + }, + { + "date" : "2023-10-18T08:47:23Z", + "value" : 96 + }, + { + "date" : "2023-10-18T08:52:24Z", + "value" : 106 + }, + { + "date" : "2023-10-18T08:57:24Z", + "value" : 102 + }, + { + "date" : "2023-10-18T09:02:24Z", + "value" : 102 + }, + { + "date" : "2023-10-18T09:07:24Z", + "value" : 102 + }, + { + "date" : "2023-10-18T09:12:24Z", + "value" : 96 + }, + { + "date" : "2023-10-18T09:17:24Z", + "value" : 98 + }, + { + "date" : "2023-10-18T09:22:24Z", + "value" : 100 + }, + { + "date" : "2023-10-18T09:27:24Z", + "value" : 99 + }, + { + "date" : "2023-10-18T09:32:23Z", + "value" : 97 + }, + { + "date" : "2023-10-18T09:37:24Z", + "value" : 99 + }, + { + "date" : "2023-10-18T09:42:23Z", + "value" : 100 + }, + { + "date" : "2023-10-18T09:47:24Z", + "value" : 100 + }, + { + "date" : "2023-10-18T09:52:24Z", + "value" : 103 + }, + { + "date" : "2023-10-18T09:57:24Z", + "value" : 106 + }, + { + "date" : "2023-10-18T10:02:24Z", + "value" : 108 + }, + { + "date" : "2023-10-18T10:07:23Z", + "value" : 106 + }, + { + "date" : "2023-10-18T10:12:23Z", + "value" : 101 + }, + { + "date" : "2023-10-18T10:17:23Z", + "value" : 106 + }, + { + "date" : "2023-10-18T10:22:23Z", + "value" : 105 + }, + { + "date" : "2023-10-18T10:27:24Z", + "value" : 108 + }, + { + "date" : "2023-10-18T10:32:24Z", + "value" : 101 + }, + { + "date" : "2023-10-18T10:37:24Z", + "value" : 104 + } + ], + "maxBasalRate" : 2.5, + "maxBolus" : 12, + "predictionStart" : "2023-10-18T10:41:25Z", + "recommendationInsulinType" : "novolog", + "recommendationType" : "manualBolus", + "sensitivity" : [ + { + "endDate" : "2023-10-18T02:00:00Z", + "startDate" : "2023-10-17T18:27:31Z", + "value" : 120 + }, + { + "endDate" : "2023-10-18T04:00:00Z", + "startDate" : "2023-10-18T02:00:00Z", + "value" : 60 + }, + { + "endDate" : "2023-10-18T11:00:00Z", + "startDate" : "2023-10-18T04:00:00Z", + "value" : 35 + }, + { + "endDate" : "2023-10-18T14:00:00Z", + "startDate" : "2023-10-18T11:00:00Z", + "value" : 60 + }, + { + "endDate" : "2023-10-18T16:51:25Z", + "startDate" : "2023-10-18T14:00:00Z", + "value" : 120 + } + ], + "suspendThreshold" : 67, + "target" : [ + { + "endDate" : "2023-10-18T16:55:00Z", + "lowerBound" : 90, + "startDate" : "2023-10-18T10:41:25Z", + "upperBound" : 105 + } + ] +} diff --git a/Tests/LoopAlgorithmTests/Fixtures/carbs_with_isf_change_recommendation.json b/Tests/LoopAlgorithmTests/Fixtures/carbs_with_isf_change_recommendation.json new file mode 100644 index 0000000..4166f01 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/carbs_with_isf_change_recommendation.json @@ -0,0 +1,5 @@ +{ + "manual" : { + "amount" : 12.0 + } +} diff --git a/Tests/LoopAlgorithmTests/Fixtures/suspend_input.json b/Tests/LoopAlgorithmTests/Fixtures/suspend_input.json new file mode 100644 index 0000000..c1bf98f --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/suspend_input.json @@ -0,0 +1,1203 @@ +{ + "basal" : [ + { + "endDate" : "2023-10-17T05:00:00Z", + "startDate" : "2023-10-17T04:49:07Z", + "value" : 0.8 + }, + { + "endDate" : "2023-10-17T07:00:00Z", + "startDate" : "2023-10-17T05:00:00Z", + "value" : 0.8 + }, + { + "endDate" : "2023-10-17T11:30:00Z", + "startDate" : "2023-10-17T07:00:00Z", + "value" : 0.75 + }, + { + "endDate" : "2023-10-17T17:00:00Z", + "startDate" : "2023-10-17T11:30:00Z", + "value" : 1.1 + }, + { + "endDate" : "2023-10-17T20:59:03Z", + "startDate" : "2023-10-17T17:00:00Z", + "value" : 1.2 + } + ], + "carbEntries" : [ + { + "absorptionTime" : 10800, + "date" : "2023-10-17T12:54:10Z", + "grams" : 15 + }, + { + "absorptionTime" : 10800, + "date" : "2023-10-17T20:45:30Z", + "grams" : 35 + } + ], + "carbRatio" : [ + { + "endDate" : "2023-10-17T11:00:00Z", + "startDate" : "2023-10-17T10:59:03Z", + "value" : 9 + }, + { + "endDate" : "2023-10-18T03:09:03Z", + "startDate" : "2023-10-17T11:00:00Z", + "value" : 8 + } + ], + "doses" : [ + { + "endDate" : "2023-10-17T04:49:21Z", + "startDate" : "2023-10-17T04:49:07Z", + "type" : "bolus", + "volume" : 0.35 + }, + { + "endDate" : "2023-10-17T04:54:18Z", + "startDate" : "2023-10-17T04:54:10Z", + "type" : "bolus", + "volume" : 0.2 + }, + { + "endDate" : "2023-10-17T04:59:17Z", + "startDate" : "2023-10-17T04:59:07Z", + "type" : "bolus", + "volume" : 0.25 + }, + { + "endDate" : "2023-10-17T05:09:25Z", + "startDate" : "2023-10-17T05:09:13Z", + "type" : "bolus", + "volume" : 0.3 + }, + { + "endDate" : "2023-10-17T05:14:32Z", + "startDate" : "2023-10-17T05:14:20Z", + "type" : "bolus", + "volume" : 0.3 + }, + { + "endDate" : "2023-10-17T05:19:23Z", + "startDate" : "2023-10-17T05:19:15Z", + "type" : "bolus", + "volume" : 0.2 + }, + { + "endDate" : "2023-10-17T05:24:11Z", + "startDate" : "2023-10-17T05:24:07Z", + "type" : "bolus", + "volume" : 0.1 + }, + { + "endDate" : "2023-10-17T05:29:15Z", + "startDate" : "2023-10-17T05:29:07Z", + "type" : "bolus", + "volume" : 0.2 + }, + { + "endDate" : "2023-10-17T05:59:08Z", + "startDate" : "2023-10-17T05:39:08Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T05:59:11Z", + "startDate" : "2023-10-17T05:59:09Z", + "type" : "bolus", + "volume" : 0.05 + }, + { + "endDate" : "2023-10-17T06:09:08Z", + "startDate" : "2023-10-17T06:04:31Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T06:09:12Z", + "startDate" : "2023-10-17T06:09:10Z", + "type" : "bolus", + "volume" : 0.05 + }, + { + "endDate" : "2023-10-17T06:14:13Z", + "startDate" : "2023-10-17T06:14:09Z", + "type" : "bolus", + "volume" : 0.1 + }, + { + "endDate" : "2023-10-17T06:19:11Z", + "startDate" : "2023-10-17T06:19:07Z", + "type" : "bolus", + "volume" : 0.1 + }, + { + "endDate" : "2023-10-17T06:24:11Z", + "startDate" : "2023-10-17T06:24:07Z", + "type" : "bolus", + "volume" : 0.1 + }, + { + "endDate" : "2023-10-17T06:29:14Z", + "startDate" : "2023-10-17T06:29:10Z", + "type" : "bolus", + "volume" : 0.1 + }, + { + "endDate" : "2023-10-17T06:34:11Z", + "startDate" : "2023-10-17T06:34:07Z", + "type" : "bolus", + "volume" : 0.1 + }, + { + "endDate" : "2023-10-17T06:39:14Z", + "startDate" : "2023-10-17T06:39:08Z", + "type" : "bolus", + "volume" : 0.15 + }, + { + "endDate" : "2023-10-17T06:54:12Z", + "startDate" : "2023-10-17T06:54:10Z", + "type" : "bolus", + "volume" : 0.05 + }, + { + "endDate" : "2023-10-17T06:59:13Z", + "startDate" : "2023-10-17T06:59:11Z", + "type" : "bolus", + "volume" : 0.05 + }, + { + "endDate" : "2023-10-17T07:04:13Z", + "startDate" : "2023-10-17T07:04:11Z", + "type" : "bolus", + "volume" : 0.05 + }, + { + "endDate" : "2023-10-17T07:09:18Z", + "startDate" : "2023-10-17T07:09:14Z", + "type" : "bolus", + "volume" : 0.1 + }, + { + "endDate" : "2023-10-17T07:19:09Z", + "startDate" : "2023-10-17T07:14:07Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T07:34:07Z", + "startDate" : "2023-10-17T07:19:09Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T07:39:08Z", + "startDate" : "2023-10-17T07:34:07Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T07:59:06Z", + "startDate" : "2023-10-17T07:39:09Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T08:19:07Z", + "startDate" : "2023-10-17T07:59:06Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T08:34:09Z", + "startDate" : "2023-10-17T08:19:08Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T08:44:06Z", + "startDate" : "2023-10-17T08:39:14Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T10:09:13Z", + "startDate" : "2023-10-17T09:59:17Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T10:39:13Z", + "startDate" : "2023-10-17T10:19:09Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T10:59:10Z", + "startDate" : "2023-10-17T10:39:14Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T11:19:07Z", + "startDate" : "2023-10-17T10:59:10Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T11:39:08Z", + "startDate" : "2023-10-17T11:19:08Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T11:44:09Z", + "startDate" : "2023-10-17T11:39:09Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T12:04:30Z", + "startDate" : "2023-10-17T12:04:08Z", + "type" : "bolus", + "volume" : 0.55 + }, + { + "endDate" : "2023-10-17T12:09:41Z", + "startDate" : "2023-10-17T12:09:11Z", + "type" : "bolus", + "volume" : 0.75 + }, + { + "endDate" : "2023-10-17T12:14:34Z", + "startDate" : "2023-10-17T12:14:10Z", + "type" : "bolus", + "volume" : 0.6 + }, + { + "endDate" : "2023-10-17T12:19:27Z", + "startDate" : "2023-10-17T12:19:07Z", + "type" : "bolus", + "volume" : 0.5 + }, + { + "endDate" : "2023-10-17T12:24:23Z", + "startDate" : "2023-10-17T12:24:09Z", + "type" : "bolus", + "volume" : 0.35 + }, + { + "endDate" : "2023-10-17T12:29:20Z", + "startDate" : "2023-10-17T12:29:10Z", + "type" : "bolus", + "volume" : 0.25 + }, + { + "endDate" : "2023-10-17T12:34:17Z", + "startDate" : "2023-10-17T12:34:07Z", + "type" : "bolus", + "volume" : 0.25 + }, + { + "endDate" : "2023-10-17T12:49:05Z", + "startDate" : "2023-10-17T12:44:07Z", + "type" : "tempBasal", + "volume" : 0.05 + }, + { + "endDate" : "2023-10-17T12:54:23Z", + "startDate" : "2023-10-17T12:54:07Z", + "type" : "bolus", + "volume" : 0.4 + }, + { + "endDate" : "2023-10-17T12:59:57Z", + "startDate" : "2023-10-17T12:59:11Z", + "type" : "bolus", + "volume" : 1.15 + }, + { + "endDate" : "2023-10-17T13:04:38Z", + "startDate" : "2023-10-17T13:04:08Z", + "type" : "bolus", + "volume" : 0.75 + }, + { + "endDate" : "2023-10-17T13:09:16Z", + "startDate" : "2023-10-17T13:09:08Z", + "type" : "bolus", + "volume" : 0.2 + }, + { + "endDate" : "2023-10-17T13:14:18Z", + "startDate" : "2023-10-17T13:14:16Z", + "type" : "bolus", + "volume" : 0.05 + }, + { + "endDate" : "2023-10-17T13:44:12Z", + "startDate" : "2023-10-17T13:29:15Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T13:49:09Z", + "startDate" : "2023-10-17T13:44:12Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T13:54:09Z", + "startDate" : "2023-10-17T13:49:09Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T13:59:06Z", + "startDate" : "2023-10-17T13:54:09Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T14:04:07Z", + "startDate" : "2023-10-17T13:59:07Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T14:09:09Z", + "startDate" : "2023-10-17T14:04:07Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T14:14:07Z", + "startDate" : "2023-10-17T14:09:09Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T14:24:09Z", + "startDate" : "2023-10-17T14:14:08Z", + "type" : "tempBasal", + "volume" : 0.05 + }, + { + "endDate" : "2023-10-17T14:39:22Z", + "startDate" : "2023-10-17T14:39:18Z", + "type" : "bolus", + "volume" : 0.1 + }, + { + "endDate" : "2023-10-17T14:44:17Z", + "startDate" : "2023-10-17T14:44:11Z", + "type" : "bolus", + "volume" : 0.15 + }, + { + "endDate" : "2023-10-17T14:49:13Z", + "startDate" : "2023-10-17T14:49:09Z", + "type" : "bolus", + "volume" : 0.1 + }, + { + "endDate" : "2023-10-17T14:54:30Z", + "startDate" : "2023-10-17T14:54:24Z", + "type" : "bolus", + "volume" : 0.15 + }, + { + "endDate" : "2023-10-17T14:59:11Z", + "startDate" : "2023-10-17T14:59:09Z", + "type" : "bolus", + "volume" : 0.05 + }, + { + "endDate" : "2023-10-17T15:04:15Z", + "startDate" : "2023-10-17T15:04:09Z", + "type" : "bolus", + "volume" : 0.15 + }, + { + "endDate" : "2023-10-17T15:09:21Z", + "startDate" : "2023-10-17T15:09:11Z", + "type" : "bolus", + "volume" : 0.25 + }, + { + "endDate" : "2023-10-17T15:19:10Z", + "startDate" : "2023-10-17T15:19:08Z", + "type" : "bolus", + "volume" : 0.05 + }, + { + "endDate" : "2023-10-17T15:24:11Z", + "startDate" : "2023-10-17T15:24:09Z", + "type" : "bolus", + "volume" : 0.05 + }, + { + "endDate" : "2023-10-17T15:29:21Z", + "startDate" : "2023-10-17T15:29:07Z", + "type" : "bolus", + "volume" : 0.35 + }, + { + "endDate" : "2023-10-17T15:34:25Z", + "startDate" : "2023-10-17T15:34:07Z", + "type" : "bolus", + "volume" : 0.45 + }, + { + "endDate" : "2023-10-17T15:44:14Z", + "startDate" : "2023-10-17T15:44:08Z", + "type" : "bolus", + "volume" : 0.15 + }, + { + "endDate" : "2023-10-17T15:49:13Z", + "startDate" : "2023-10-17T15:49:07Z", + "type" : "bolus", + "volume" : 0.15 + }, + { + "endDate" : "2023-10-17T15:59:12Z", + "startDate" : "2023-10-17T15:59:08Z", + "type" : "bolus", + "volume" : 0.1 + }, + { + "endDate" : "2023-10-17T16:09:06Z", + "startDate" : "2023-10-17T16:04:08Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T16:14:07Z", + "startDate" : "2023-10-17T16:09:07Z", + "type" : "tempBasal", + "volume" : 0.05 + }, + { + "endDate" : "2023-10-17T16:24:07Z", + "startDate" : "2023-10-17T16:19:08Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T16:24:20Z", + "startDate" : "2023-10-17T16:24:08Z", + "type" : "bolus", + "volume" : 0.3 + }, + { + "endDate" : "2023-10-17T16:29:33Z", + "startDate" : "2023-10-17T16:29:13Z", + "type" : "bolus", + "volume" : 0.5 + }, + { + "endDate" : "2023-10-17T16:34:25Z", + "startDate" : "2023-10-17T16:34:19Z", + "type" : "bolus", + "volume" : 0.15 + }, + { + "endDate" : "2023-10-17T16:39:17Z", + "startDate" : "2023-10-17T16:39:09Z", + "type" : "bolus", + "volume" : 0.2 + }, + { + "endDate" : "2023-10-17T16:44:23Z", + "startDate" : "2023-10-17T16:44:07Z", + "type" : "bolus", + "volume" : 0.4 + }, + { + "endDate" : "2023-10-17T16:49:21Z", + "startDate" : "2023-10-17T16:49:07Z", + "type" : "bolus", + "volume" : 0.35 + }, + { + "endDate" : "2023-10-17T16:54:21Z", + "startDate" : "2023-10-17T16:54:09Z", + "type" : "bolus", + "volume" : 0.3 + }, + { + "endDate" : "2023-10-17T17:04:07Z", + "startDate" : "2023-10-17T16:59:12Z", + "type" : "tempBasal", + "volume" : 0.05 + }, + { + "endDate" : "2023-10-17T17:09:20Z", + "startDate" : "2023-10-17T17:09:08Z", + "type" : "bolus", + "volume" : 0.3 + }, + { + "endDate" : "2023-10-17T17:14:14Z", + "startDate" : "2023-10-17T17:14:08Z", + "type" : "bolus", + "volume" : 0.15 + }, + { + "endDate" : "2023-10-17T17:19:12Z", + "startDate" : "2023-10-17T17:19:08Z", + "type" : "bolus", + "volume" : 0.1 + }, + { + "endDate" : "2023-10-17T17:24:19Z", + "startDate" : "2023-10-17T17:24:09Z", + "type" : "bolus", + "volume" : 0.25 + }, + { + "endDate" : "2023-10-17T17:29:20Z", + "startDate" : "2023-10-17T17:29:10Z", + "type" : "bolus", + "volume" : 0.25 + }, + { + "endDate" : "2023-10-17T17:34:11Z", + "startDate" : "2023-10-17T17:34:09Z", + "type" : "bolus", + "volume" : 0.05 + }, + { + "endDate" : "2023-10-17T17:39:17Z", + "startDate" : "2023-10-17T17:39:07Z", + "type" : "bolus", + "volume" : 0.25 + }, + { + "endDate" : "2023-10-17T17:54:13Z", + "startDate" : "2023-10-17T17:54:07Z", + "type" : "bolus", + "volume" : 0.15 + }, + { + "endDate" : "2023-10-17T18:14:07Z", + "startDate" : "2023-10-17T18:09:08Z", + "type" : "tempBasal", + "volume" : 0.05 + }, + { + "endDate" : "2023-10-17T18:19:16Z", + "startDate" : "2023-10-17T18:19:08Z", + "type" : "bolus", + "volume" : 0.2 + }, + { + "endDate" : "2023-10-17T18:39:09Z", + "startDate" : "2023-10-17T18:29:07Z", + "type" : "tempBasal", + "volume" : 0.1 + }, + { + "endDate" : "2023-10-17T18:44:11Z", + "startDate" : "2023-10-17T18:44:07Z", + "type" : "bolus", + "volume" : 0.1 + }, + { + "endDate" : "2023-10-17T18:54:08Z", + "startDate" : "2023-10-17T18:49:09Z", + "type" : "tempBasal", + "volume" : 0.05 + }, + { + "endDate" : "2023-10-17T18:59:10Z", + "startDate" : "2023-10-17T18:54:09Z", + "type" : "tempBasal", + "volume" : 0.05 + }, + { + "endDate" : "2023-10-17T19:09:07Z", + "startDate" : "2023-10-17T18:59:10Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T19:14:09Z", + "startDate" : "2023-10-17T19:09:07Z", + "type" : "tempBasal", + "volume" : 0.05 + }, + { + "endDate" : "2023-10-17T19:34:08Z", + "startDate" : "2023-10-17T19:14:10Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T19:54:07Z", + "startDate" : "2023-10-17T19:34:08Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T20:14:17Z", + "startDate" : "2023-10-17T19:54:08Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T20:34:08Z", + "startDate" : "2023-10-17T20:14:17Z", + "type" : "tempBasal", + "volume" : 0 + }, + { + "endDate" : "2023-10-17T20:39:48Z", + "startDate" : "2023-10-17T20:39:10Z", + "type" : "bolus", + "volume" : 0.95 + }, + { + "endDate" : "2023-10-17T20:44:44Z", + "startDate" : "2023-10-17T20:44:12Z", + "type" : "bolus", + "volume" : 0.8 + }, + { + "endDate" : "2023-10-17T20:49:23Z", + "startDate" : "2023-10-17T20:45:37Z", + "type" : "bolus", + "volume" : 5.65 + }, + { + "endDate" : "2023-10-17T20:54:23Z", + "startDate" : "2023-10-17T20:54:07Z", + "type" : "bolus", + "volume" : 0.4 + } + ], + "glucoseHistory" : [ + { + "date" : "2023-10-17T11:04:02Z", + "value" : 72 + }, + { + "date" : "2023-10-17T11:09:02Z", + "value" : 72 + }, + { + "date" : "2023-10-17T11:14:02Z", + "value" : 72 + }, + { + "date" : "2023-10-17T11:19:02Z", + "value" : 72 + }, + { + "date" : "2023-10-17T11:24:02Z", + "value" : 74 + }, + { + "date" : "2023-10-17T11:29:02Z", + "value" : 76 + }, + { + "date" : "2023-10-17T11:34:02Z", + "value" : 75 + }, + { + "date" : "2023-10-17T11:39:02Z", + "value" : 77 + }, + { + "date" : "2023-10-17T11:44:02Z", + "value" : 81 + }, + { + "date" : "2023-10-17T11:49:02Z", + "value" : 84 + }, + { + "date" : "2023-10-17T11:54:02Z", + "value" : 88 + }, + { + "date" : "2023-10-17T11:59:02Z", + "value" : 93 + }, + { + "date" : "2023-10-17T12:04:02Z", + "value" : 108 + }, + { + "date" : "2023-10-17T12:09:02Z", + "value" : 134 + }, + { + "date" : "2023-10-17T12:14:02Z", + "value" : 151 + }, + { + "date" : "2023-10-17T12:19:02Z", + "value" : 167 + }, + { + "date" : "2023-10-17T12:24:02Z", + "value" : 176 + }, + { + "date" : "2023-10-17T12:29:02Z", + "value" : 182 + }, + { + "date" : "2023-10-17T12:34:02Z", + "value" : 189 + }, + { + "date" : "2023-10-17T12:39:02Z", + "value" : 192 + }, + { + "date" : "2023-10-17T12:44:02Z", + "value" : 198 + }, + { + "date" : "2023-10-17T12:49:02Z", + "value" : 201 + }, + { + "date" : "2023-10-17T12:54:02Z", + "value" : 224 + }, + { + "date" : "2023-10-17T12:59:02Z", + "value" : 233 + }, + { + "date" : "2023-10-17T13:04:02Z", + "value" : 237 + }, + { + "date" : "2023-10-17T13:09:02Z", + "value" : 244 + }, + { + "date" : "2023-10-17T13:14:02Z", + "value" : 239 + }, + { + "date" : "2023-10-17T13:19:02Z", + "value" : 245 + }, + { + "date" : "2023-10-17T13:24:02Z", + "value" : 240 + }, + { + "date" : "2023-10-17T13:29:02Z", + "value" : 239 + }, + { + "date" : "2023-10-17T13:34:02Z", + "value" : 239 + }, + { + "date" : "2023-10-17T13:39:03Z", + "value" : 250 + }, + { + "date" : "2023-10-17T13:44:02Z", + "value" : 253 + }, + { + "date" : "2023-10-17T13:49:02Z", + "value" : 254 + }, + { + "date" : "2023-10-17T13:54:02Z", + "value" : 260 + }, + { + "date" : "2023-10-17T13:59:02Z", + "value" : 259 + }, + { + "date" : "2023-10-17T14:04:02Z", + "value" : 254 + }, + { + "date" : "2023-10-17T14:09:03Z", + "value" : 250 + }, + { + "date" : "2023-10-17T14:14:02Z", + "value" : 243 + }, + { + "date" : "2023-10-17T14:19:02Z", + "value" : 242 + }, + { + "date" : "2023-10-17T14:24:03Z", + "value" : 241 + }, + { + "date" : "2023-10-17T14:29:02Z", + "value" : 233 + }, + { + "date" : "2023-10-17T14:34:02Z", + "value" : 230 + }, + { + "date" : "2023-10-17T14:39:03Z", + "value" : 229 + }, + { + "date" : "2023-10-17T14:44:02Z", + "value" : 228 + }, + { + "date" : "2023-10-17T14:49:03Z", + "value" : 223 + }, + { + "date" : "2023-10-17T14:54:02Z", + "value" : 223 + }, + { + "date" : "2023-10-17T14:59:02Z", + "value" : 216 + }, + { + "date" : "2023-10-17T15:04:02Z", + "value" : 218 + }, + { + "date" : "2023-10-17T15:09:03Z", + "value" : 218 + }, + { + "date" : "2023-10-17T15:14:02Z", + "value" : 213 + }, + { + "date" : "2023-10-17T15:19:02Z", + "value" : 210 + }, + { + "date" : "2023-10-17T15:24:02Z", + "value" : 205 + }, + { + "date" : "2023-10-17T15:29:02Z", + "value" : 217 + }, + { + "date" : "2023-10-17T15:34:02Z", + "value" : 222 + }, + { + "date" : "2023-10-17T15:39:03Z", + "value" : 216 + }, + { + "date" : "2023-10-17T15:44:02Z", + "value" : 219 + }, + { + "date" : "2023-10-17T15:49:02Z", + "value" : 217 + }, + { + "date" : "2023-10-17T15:54:02Z", + "value" : 210 + }, + { + "date" : "2023-10-17T15:59:03Z", + "value" : 209 + }, + { + "date" : "2023-10-17T16:04:03Z", + "value" : 205 + }, + { + "date" : "2023-10-17T16:09:02Z", + "value" : 202 + }, + { + "date" : "2023-10-17T16:14:03Z", + "value" : 197 + }, + { + "date" : "2023-10-17T16:19:03Z", + "value" : 185 + }, + { + "date" : "2023-10-17T16:24:03Z", + "value" : 203 + }, + { + "date" : "2023-10-17T16:29:03Z", + "value" : 211 + }, + { + "date" : "2023-10-17T16:34:02Z", + "value" : 208 + }, + { + "date" : "2023-10-17T16:39:02Z", + "value" : 214 + }, + { + "date" : "2023-10-17T16:44:02Z", + "value" : 223 + }, + { + "date" : "2023-10-17T16:49:02Z", + "value" : 225 + }, + { + "date" : "2023-10-17T16:54:02Z", + "value" : 226 + }, + { + "date" : "2023-10-17T16:59:02Z", + "value" : 227 + }, + { + "date" : "2023-10-17T17:04:03Z", + "value" : 226 + }, + { + "date" : "2023-10-17T17:09:03Z", + "value" : 232 + }, + { + "date" : "2023-10-17T17:14:03Z", + "value" : 232 + }, + { + "date" : "2023-10-17T17:19:02Z", + "value" : 234 + }, + { + "date" : "2023-10-17T17:24:02Z", + "value" : 238 + }, + { + "date" : "2023-10-17T17:29:02Z", + "value" : 240 + }, + { + "date" : "2023-10-17T17:34:03Z", + "value" : 237 + }, + { + "date" : "2023-10-17T17:39:02Z", + "value" : 243 + }, + { + "date" : "2023-10-17T17:44:03Z", + "value" : 239 + }, + { + "date" : "2023-10-17T17:49:02Z", + "value" : 235 + }, + { + "date" : "2023-10-17T17:54:03Z", + "value" : 236 + }, + { + "date" : "2023-10-17T17:59:02Z", + "value" : 233 + }, + { + "date" : "2023-10-17T18:04:03Z", + "value" : 230 + }, + { + "date" : "2023-10-17T18:09:02Z", + "value" : 221 + }, + { + "date" : "2023-10-17T18:14:03Z", + "value" : 223 + }, + { + "date" : "2023-10-17T18:19:02Z", + "value" : 222 + }, + { + "date" : "2023-10-17T18:24:03Z", + "value" : 215 + }, + { + "date" : "2023-10-17T18:29:02Z", + "value" : 211 + }, + { + "date" : "2023-10-17T18:34:02Z", + "value" : 204 + }, + { + "date" : "2023-10-17T18:39:03Z", + "value" : 201 + }, + { + "date" : "2023-10-17T18:44:03Z", + "value" : 199 + }, + { + "date" : "2023-10-17T18:49:02Z", + "value" : 193 + }, + { + "date" : "2023-10-17T18:54:03Z", + "value" : 188 + }, + { + "date" : "2023-10-17T18:59:03Z", + "value" : 173 + }, + { + "date" : "2023-10-17T19:04:02Z", + "value" : 167 + }, + { + "date" : "2023-10-17T19:09:02Z", + "value" : 163 + }, + { + "date" : "2023-10-17T19:14:02Z", + "value" : 149 + }, + { + "date" : "2023-10-17T19:19:02Z", + "value" : 142 + }, + { + "date" : "2023-10-17T19:24:03Z", + "value" : 137 + }, + { + "date" : "2023-10-17T19:29:02Z", + "value" : 128 + }, + { + "date" : "2023-10-17T19:34:03Z", + "value" : 118 + }, + { + "date" : "2023-10-17T19:39:02Z", + "value" : 98 + }, + { + "date" : "2023-10-17T19:44:03Z", + "value" : 87 + }, + { + "date" : "2023-10-17T19:49:02Z", + "value" : 82 + }, + { + "date" : "2023-10-17T19:54:02Z", + "value" : 74 + }, + { + "date" : "2023-10-17T19:59:03Z", + "value" : 70 + }, + { + "date" : "2023-10-17T20:04:02Z", + "value" : 59 + }, + { + "date" : "2023-10-17T20:09:02Z", + "value" : 53 + }, + { + "date" : "2023-10-17T20:14:03Z", + "value" : 50 + }, + { + "date" : "2023-10-17T20:19:02Z", + "value" : 51 + }, + { + "date" : "2023-10-17T20:24:02Z", + "value" : 59 + }, + { + "date" : "2023-10-17T20:29:03Z", + "value" : 72 + }, + { + "date" : "2023-10-17T20:34:03Z", + "value" : 89 + }, + { + "date" : "2023-10-17T20:39:03Z", + "value" : 110 + }, + { + "date" : "2023-10-17T20:44:03Z", + "value" : 129 + }, + { + "date" : "2023-10-17T20:49:02Z", + "value" : 146 + }, + { + "date" : "2023-10-17T20:54:02Z", + "value" : 161 + }, + { + "date" : "2023-10-17T20:59:03Z", + "value" : 158 + } + ], + "maxBasalRate" : 4.1, + "maxBolus" : 9, + "predictionStart" : "2023-10-17T20:59:03Z", + "recommendationInsulinType" : "novolog", + "recommendationType" : "automaticBolus", + "sensitivity" : [ + { + "endDate" : "2023-10-17T05:00:00Z", + "startDate" : "2023-10-17T04:49:07Z", + "value" : 63 + }, + { + "endDate" : "2023-10-17T13:00:00Z", + "startDate" : "2023-10-17T05:00:00Z", + "value" : 65 + }, + { + "endDate" : "2023-10-17T18:30:00Z", + "startDate" : "2023-10-17T13:00:00Z", + "value" : 55 + }, + { + "endDate" : "2023-10-18T03:09:03Z", + "startDate" : "2023-10-17T18:30:00Z", + "value" : 63 + } + ], + "suspendThreshold" : 78, + "target" : [ + { + "endDate" : "2023-10-18T03:10:00Z", + "lowerBound" : 101, + "startDate" : "2023-10-17T20:59:03Z", + "upperBound" : 115 + } + ] +} diff --git a/Tests/LoopAlgorithmTests/Fixtures/suspend_recommendation.json b/Tests/LoopAlgorithmTests/Fixtures/suspend_recommendation.json new file mode 100644 index 0000000..12ee418 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/suspend_recommendation.json @@ -0,0 +1,10 @@ +{ + "automatic" : { + "basalAdjustment" : { + "duration" : 1800, + "unitsPerHour" : 0 + }, + "bolusUnits" : 0 + } +} + diff --git a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift new file mode 100644 index 0000000..5c40046 --- /dev/null +++ b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift @@ -0,0 +1,49 @@ +// +// LoopAlgorithmTests.swift +// LoopKitTests +// +// Created by Pete Schwamb on 10/18/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import XCTest +@testable import LoopAlgorithm + +final class LoopAlgorithmTests: XCTestCase { + + func loadScenario(_ name: String) -> (input: LoopAlgorithmInput, recommendation: LoopAlgorithmDoseRecommendation) { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + var url = Bundle.module.url(forResource: name + "_input", withExtension: "json", subdirectory: "Fixtures")! + let input = try! decoder.decode(LoopAlgorithmInput.self, from: try! Data(contentsOf: url)) + + url = Bundle.module.url(forResource: name + "_recommendation", withExtension: "json", subdirectory: "Fixtures")! + let recommendation = try! decoder.decode(LoopAlgorithmDoseRecommendation.self, from: try! Data(contentsOf: url)) + + return (input: input, recommendation: recommendation) + } + + func testSuspend() throws { + + let (input, recommendation) = loadScenario("suspend") + + let output = LoopAlgorithm.run(input: input) + + XCTAssertEqual(output.recommendation, recommendation) + } + + func testCarbsWithSensitivityChange() throws { + + // This test computes a dose with a future carb entry + // Between the time of dose and the startTime of the carb + // There is a significant ISF change (from 35 mg/dL/U to 60 mg/dL/U) + + let (input, recommendation) = loadScenario("carbs_with_isf_change") + + let output = LoopAlgorithm.run(input: input) + + XCTAssertEqual(output.recommendation, recommendation) + } + + +} From 660fe4907746d723fc02079612421fabd0f8e079 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 20 Dec 2023 11:43:36 -0600 Subject: [PATCH 02/26] Algorithm accepts any CarbEntry conforming type --- .../{ => Carbs}/AbsorbedCarbValue.swift | 0 .../LoopAlgorithm/{ => Carbs}/CarbEntry.swift | 0 .../LoopAlgorithm/{ => Carbs}/CarbMath.swift | 24 +-- .../{ => Carbs}/CarbStatus.swift | 26 +-- .../LoopAlgorithm/{ => Carbs}/CarbValue.swift | 0 .../Carbs/FixtureCarbEntry.swift | 40 +++++ .../{ => Glucose}/GlucoseChange.swift | 0 .../{ => Glucose}/GlucoseCondition.swift | 0 .../{ => Glucose}/GlucoseEffect.swift | 0 .../{ => Glucose}/GlucoseEffectVelocity.swift | 0 .../{ => Glucose}/GlucoseMath.swift | 0 .../{ => Glucose}/GlucoseRange.swift | 0 .../{ => Glucose}/GlucoseSampleValue.swift | 0 .../{ => Glucose}/GlucoseTrend.swift | 0 .../{ => Glucose}/GlucoseValue.swift | 0 .../{ => Glucose}/StoredGlucoseSample.swift | 0 .../{ => Insulin}/DoseEntry.swift | 0 .../{ => Insulin}/DoseMath.swift | 0 .../{ => Insulin}/DoseType.swift | 0 .../{ => Insulin}/DoseUnit.swift | 0 .../ExponentialInsulinModel.swift | 0 .../ExponentialInsulinModelPreset.swift | 0 .../{ => Insulin}/InsulinMath.swift | 0 .../{ => Insulin}/InsulinModel.swift | 0 .../{ => Insulin}/InsulinModelProvider.swift | 0 .../{ => Insulin}/InsulinType.swift | 0 .../{ => Insulin}/InsulinValue.swift | 0 Sources/LoopAlgorithm/LoopAlgorithm.swift | 18 +- .../LoopAlgorithm/LoopAlgorithmInput.swift | 25 +-- .../LoopAlgorithm/LoopPredictionInput.swift | 16 +- .../IntegralRetrospectiveCorrection.swift | 0 .../RetrospectiveCorrection.swift | 0 .../StandardRetrospectiveCorrection.swift | 0 Sources/LoopAlgorithm/StoredCarbEntry.swift | 161 ------------------ .../LoopAlgorithmInputMocks.swift | 33 ++++ .../LoopAlgorithmTests.swift | 4 +- 36 files changed, 120 insertions(+), 227 deletions(-) rename Sources/LoopAlgorithm/{ => Carbs}/AbsorbedCarbValue.swift (100%) rename Sources/LoopAlgorithm/{ => Carbs}/CarbEntry.swift (100%) rename Sources/LoopAlgorithm/{ => Carbs}/CarbMath.swift (98%) rename Sources/LoopAlgorithm/{ => Carbs}/CarbStatus.swift (88%) rename Sources/LoopAlgorithm/{ => Carbs}/CarbValue.swift (100%) create mode 100644 Sources/LoopAlgorithm/Carbs/FixtureCarbEntry.swift rename Sources/LoopAlgorithm/{ => Glucose}/GlucoseChange.swift (100%) rename Sources/LoopAlgorithm/{ => Glucose}/GlucoseCondition.swift (100%) rename Sources/LoopAlgorithm/{ => Glucose}/GlucoseEffect.swift (100%) rename Sources/LoopAlgorithm/{ => Glucose}/GlucoseEffectVelocity.swift (100%) rename Sources/LoopAlgorithm/{ => Glucose}/GlucoseMath.swift (100%) rename Sources/LoopAlgorithm/{ => Glucose}/GlucoseRange.swift (100%) rename Sources/LoopAlgorithm/{ => Glucose}/GlucoseSampleValue.swift (100%) rename Sources/LoopAlgorithm/{ => Glucose}/GlucoseTrend.swift (100%) rename Sources/LoopAlgorithm/{ => Glucose}/GlucoseValue.swift (100%) rename Sources/LoopAlgorithm/{ => Glucose}/StoredGlucoseSample.swift (100%) rename Sources/LoopAlgorithm/{ => Insulin}/DoseEntry.swift (100%) rename Sources/LoopAlgorithm/{ => Insulin}/DoseMath.swift (100%) rename Sources/LoopAlgorithm/{ => Insulin}/DoseType.swift (100%) rename Sources/LoopAlgorithm/{ => Insulin}/DoseUnit.swift (100%) rename Sources/LoopAlgorithm/{ => Insulin}/ExponentialInsulinModel.swift (100%) rename Sources/LoopAlgorithm/{ => Insulin}/ExponentialInsulinModelPreset.swift (100%) rename Sources/LoopAlgorithm/{ => Insulin}/InsulinMath.swift (100%) rename Sources/LoopAlgorithm/{ => Insulin}/InsulinModel.swift (100%) rename Sources/LoopAlgorithm/{ => Insulin}/InsulinModelProvider.swift (100%) rename Sources/LoopAlgorithm/{ => Insulin}/InsulinType.swift (100%) rename Sources/LoopAlgorithm/{ => Insulin}/InsulinValue.swift (100%) rename Sources/LoopAlgorithm/{ => RetrospectiveCorrection}/IntegralRetrospectiveCorrection.swift (100%) rename Sources/LoopAlgorithm/{ => RetrospectiveCorrection}/RetrospectiveCorrection.swift (100%) rename Sources/LoopAlgorithm/{ => RetrospectiveCorrection}/StandardRetrospectiveCorrection.swift (100%) delete mode 100644 Sources/LoopAlgorithm/StoredCarbEntry.swift create mode 100644 Tests/LoopAlgorithmTests/LoopAlgorithmInputMocks.swift diff --git a/Sources/LoopAlgorithm/AbsorbedCarbValue.swift b/Sources/LoopAlgorithm/Carbs/AbsorbedCarbValue.swift similarity index 100% rename from Sources/LoopAlgorithm/AbsorbedCarbValue.swift rename to Sources/LoopAlgorithm/Carbs/AbsorbedCarbValue.swift diff --git a/Sources/LoopAlgorithm/CarbEntry.swift b/Sources/LoopAlgorithm/Carbs/CarbEntry.swift similarity index 100% rename from Sources/LoopAlgorithm/CarbEntry.swift rename to Sources/LoopAlgorithm/Carbs/CarbEntry.swift diff --git a/Sources/LoopAlgorithm/CarbMath.swift b/Sources/LoopAlgorithm/Carbs/CarbMath.swift similarity index 98% rename from Sources/LoopAlgorithm/CarbMath.swift rename to Sources/LoopAlgorithm/Carbs/CarbMath.swift index 2c1e12a..6948bea 100644 --- a/Sources/LoopAlgorithm/CarbMath.swift +++ b/Sources/LoopAlgorithm/Carbs/CarbMath.swift @@ -419,10 +419,10 @@ extension Collection where Element: CarbEntry { // MARK: - Dyanamic absorption overrides extension Collection { - public func dynamicCarbsOnBoard( + public func dynamicCarbsOnBoard( at date: Date, absorptionModel: CarbAbsorptionComputable - ) -> Double where Element == CarbStatus { + ) -> Double where Element == CarbStatus { reduce(0.0) { (value, entry) -> Double in return value + entry.dynamicCarbsOnBoard( at: date, @@ -434,11 +434,11 @@ extension Collection { } } - public func dynamicCarbsOnBoard( + public func dynamicCarbsOnBoard( from start: Date? = nil, to end: Date? = nil, absorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption() - ) -> [CarbValue] where Element == CarbStatus { + ) -> [CarbValue] where Element == CarbStatus { guard let (startDate, endDate) = simulationDateRange( from: start, @@ -471,7 +471,7 @@ extension Collection { return values } - public func dynamicGlucoseEffects( + public func dynamicGlucoseEffects( from start: Date? = nil, to end: Date? = nil, carbRatios: [AbsoluteScheduleValue], @@ -480,7 +480,7 @@ extension Collection { absorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption(), delay: TimeInterval = CarbMath.defaultEffectDelay, delta: TimeInterval = GlucoseMath.defaultDelta - ) -> [GlucoseEffect] where Element == CarbStatus { + ) -> [GlucoseEffect] where Element == CarbStatus { guard let (startDate, endDate) = simulationDateRange(from: start, to: end, defaultAbsorptionTime: defaultAbsorptionTime, delay: delay, delta: delta) else { return [] } @@ -513,7 +513,7 @@ extension Collection { } /// The quantity of carbs expected to still absorb at the last date of absorption - public func getClampedCarbsOnBoard() -> CarbValue? where Element == CarbStatus { + public func getClampedCarbsOnBoard() -> CarbValue? where Element == CarbStatus { guard let firstAbsorption = first?.absorption else { return nil } @@ -756,7 +756,7 @@ fileprivate class CarbStatusBuilder { } /// The resulting CarbStatus value - var result: CarbStatus { + var result: CarbStatus { let absorption = AbsorbedCarbValue( observed: HKQuantity(unit: carbUnit, doubleValue: observedGrams), clamped: HKQuantity(unit: carbUnit, doubleValue: clampedGrams), @@ -768,9 +768,11 @@ fileprivate class CarbStatusBuilder { ) return CarbStatus( - entry: entry, absorption: absorption, - observedTimeline: clampedTimeline + observedTimeline: clampedTimeline, + quantity: entry.quantity, + startDate: entry.startDate, + originalAbsorptionTime: entry.absorptionTime ) } @@ -815,7 +817,7 @@ extension Collection where Element: CarbEntry { absorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption(), adaptiveAbsorptionRateEnabled: Bool = false, adaptiveRateStandbyIntervalFraction: Double = 0.2 - ) -> [CarbStatus] { + ) -> [CarbStatus] { guard count > 0 else { // TODO: Apply unmatched effects to meal prediction return [] diff --git a/Sources/LoopAlgorithm/CarbStatus.swift b/Sources/LoopAlgorithm/Carbs/CarbStatus.swift similarity index 88% rename from Sources/LoopAlgorithm/CarbStatus.swift rename to Sources/LoopAlgorithm/Carbs/CarbStatus.swift index c7887be..788ff08 100644 --- a/Sources/LoopAlgorithm/CarbStatus.swift +++ b/Sources/LoopAlgorithm/Carbs/CarbStatus.swift @@ -8,33 +8,25 @@ import Foundation import HealthKit -public struct CarbStatus { - /// Details entered by the user - public let entry: T - +public struct CarbStatus { /// The last-computed absorption of the carbs public let absorption: AbsorbedCarbValue? /// The timeline of observed carb absorption. Nil if observed absorption is less than the modeled minimum public let observedTimeline: [CarbValue]? -} + public var quantity: HKQuantity -// Masquerade as a carb entry, substituting AbsorbedCarbValue's interpretation of absorption time -extension CarbStatus: SampleValue { - public var quantity: HKQuantity { - return entry.quantity - } + public var startDate: Date - public var startDate: Date { - return entry.startDate - } + public var originalAbsorptionTime: TimeInterval? } +// Masquerade as a carb entry, substituting AbsorbedCarbValue's interpretation of absorption time extension CarbStatus: CarbEntry { public var absorptionTime: TimeInterval? { - return absorption?.estimatedDate.duration ?? entry.absorptionTime + return absorption?.estimatedDate.duration ?? originalAbsorptionTime } } @@ -46,7 +38,7 @@ extension CarbStatus { let absorption = absorption else { // We have to have absorption info for dynamic calculation - return entry.carbsOnBoard(at: date, defaultAbsorptionTime: defaultAbsorptionTime, delay: delay, absorptionModel: absorptionModel) + return carbsOnBoard(at: date, defaultAbsorptionTime: defaultAbsorptionTime, delay: delay, absorptionModel: absorptionModel) } let unit = HKUnit.gram() @@ -71,7 +63,7 @@ extension CarbStatus { // Observed absorption // TODO: This creates an O(n^2) situation for COB timelines - let total = entry.quantity.doubleValue(for: unit) + let total = quantity.doubleValue(for: unit) return max(observedTimeline.filter({ $0.endDate <= date }).reduce(total) { (total, value) -> Double in return total - value.quantity.doubleValue(for: unit) }, 0) @@ -82,7 +74,7 @@ extension CarbStatus { let absorption = absorption else { // We have to have absorption info for dynamic calculation - return entry.absorbedCarbs(at: date, absorptionTime: absorptionTime, delay: delay, absorptionModel: absorptionModel) + return absorbedCarbs(at: date, absorptionTime: absorptionTime, delay: delay, absorptionModel: absorptionModel) } let unit = HKUnit.gram() diff --git a/Sources/LoopAlgorithm/CarbValue.swift b/Sources/LoopAlgorithm/Carbs/CarbValue.swift similarity index 100% rename from Sources/LoopAlgorithm/CarbValue.swift rename to Sources/LoopAlgorithm/Carbs/CarbValue.swift diff --git a/Sources/LoopAlgorithm/Carbs/FixtureCarbEntry.swift b/Sources/LoopAlgorithm/Carbs/FixtureCarbEntry.swift new file mode 100644 index 0000000..f0612ba --- /dev/null +++ b/Sources/LoopAlgorithm/Carbs/FixtureCarbEntry.swift @@ -0,0 +1,40 @@ +// +// StoredCarbEntry.swift +// +// Created by Nathan Racklyeft on 1/22/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import HealthKit +import CoreData + +public struct FixtureCarbEntry: CarbEntry { + public var absorptionTime: TimeInterval? + public var startDate: Date + public var quantity: HKQuantity +} + +extension FixtureCarbEntry: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.init( + absorptionTime: try container.decodeIfPresent(TimeInterval.self, forKey: .absorptionTime), + startDate: try container.decode(Date.self, forKey: .date), + quantity: HKQuantity(unit: .gram(), doubleValue: try container.decode(Double.self, forKey: .grams)) + ) + } + + public func encode(to encoder: Encoder) throws { + 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) + } + + private enum CodingKeys: String, CodingKey { + case date + case grams + case absorptionTime + } +} + diff --git a/Sources/LoopAlgorithm/GlucoseChange.swift b/Sources/LoopAlgorithm/Glucose/GlucoseChange.swift similarity index 100% rename from Sources/LoopAlgorithm/GlucoseChange.swift rename to Sources/LoopAlgorithm/Glucose/GlucoseChange.swift diff --git a/Sources/LoopAlgorithm/GlucoseCondition.swift b/Sources/LoopAlgorithm/Glucose/GlucoseCondition.swift similarity index 100% rename from Sources/LoopAlgorithm/GlucoseCondition.swift rename to Sources/LoopAlgorithm/Glucose/GlucoseCondition.swift diff --git a/Sources/LoopAlgorithm/GlucoseEffect.swift b/Sources/LoopAlgorithm/Glucose/GlucoseEffect.swift similarity index 100% rename from Sources/LoopAlgorithm/GlucoseEffect.swift rename to Sources/LoopAlgorithm/Glucose/GlucoseEffect.swift diff --git a/Sources/LoopAlgorithm/GlucoseEffectVelocity.swift b/Sources/LoopAlgorithm/Glucose/GlucoseEffectVelocity.swift similarity index 100% rename from Sources/LoopAlgorithm/GlucoseEffectVelocity.swift rename to Sources/LoopAlgorithm/Glucose/GlucoseEffectVelocity.swift diff --git a/Sources/LoopAlgorithm/GlucoseMath.swift b/Sources/LoopAlgorithm/Glucose/GlucoseMath.swift similarity index 100% rename from Sources/LoopAlgorithm/GlucoseMath.swift rename to Sources/LoopAlgorithm/Glucose/GlucoseMath.swift diff --git a/Sources/LoopAlgorithm/GlucoseRange.swift b/Sources/LoopAlgorithm/Glucose/GlucoseRange.swift similarity index 100% rename from Sources/LoopAlgorithm/GlucoseRange.swift rename to Sources/LoopAlgorithm/Glucose/GlucoseRange.swift diff --git a/Sources/LoopAlgorithm/GlucoseSampleValue.swift b/Sources/LoopAlgorithm/Glucose/GlucoseSampleValue.swift similarity index 100% rename from Sources/LoopAlgorithm/GlucoseSampleValue.swift rename to Sources/LoopAlgorithm/Glucose/GlucoseSampleValue.swift diff --git a/Sources/LoopAlgorithm/GlucoseTrend.swift b/Sources/LoopAlgorithm/Glucose/GlucoseTrend.swift similarity index 100% rename from Sources/LoopAlgorithm/GlucoseTrend.swift rename to Sources/LoopAlgorithm/Glucose/GlucoseTrend.swift diff --git a/Sources/LoopAlgorithm/GlucoseValue.swift b/Sources/LoopAlgorithm/Glucose/GlucoseValue.swift similarity index 100% rename from Sources/LoopAlgorithm/GlucoseValue.swift rename to Sources/LoopAlgorithm/Glucose/GlucoseValue.swift diff --git a/Sources/LoopAlgorithm/StoredGlucoseSample.swift b/Sources/LoopAlgorithm/Glucose/StoredGlucoseSample.swift similarity index 100% rename from Sources/LoopAlgorithm/StoredGlucoseSample.swift rename to Sources/LoopAlgorithm/Glucose/StoredGlucoseSample.swift diff --git a/Sources/LoopAlgorithm/DoseEntry.swift b/Sources/LoopAlgorithm/Insulin/DoseEntry.swift similarity index 100% rename from Sources/LoopAlgorithm/DoseEntry.swift rename to Sources/LoopAlgorithm/Insulin/DoseEntry.swift diff --git a/Sources/LoopAlgorithm/DoseMath.swift b/Sources/LoopAlgorithm/Insulin/DoseMath.swift similarity index 100% rename from Sources/LoopAlgorithm/DoseMath.swift rename to Sources/LoopAlgorithm/Insulin/DoseMath.swift diff --git a/Sources/LoopAlgorithm/DoseType.swift b/Sources/LoopAlgorithm/Insulin/DoseType.swift similarity index 100% rename from Sources/LoopAlgorithm/DoseType.swift rename to Sources/LoopAlgorithm/Insulin/DoseType.swift diff --git a/Sources/LoopAlgorithm/DoseUnit.swift b/Sources/LoopAlgorithm/Insulin/DoseUnit.swift similarity index 100% rename from Sources/LoopAlgorithm/DoseUnit.swift rename to Sources/LoopAlgorithm/Insulin/DoseUnit.swift diff --git a/Sources/LoopAlgorithm/ExponentialInsulinModel.swift b/Sources/LoopAlgorithm/Insulin/ExponentialInsulinModel.swift similarity index 100% rename from Sources/LoopAlgorithm/ExponentialInsulinModel.swift rename to Sources/LoopAlgorithm/Insulin/ExponentialInsulinModel.swift diff --git a/Sources/LoopAlgorithm/ExponentialInsulinModelPreset.swift b/Sources/LoopAlgorithm/Insulin/ExponentialInsulinModelPreset.swift similarity index 100% rename from Sources/LoopAlgorithm/ExponentialInsulinModelPreset.swift rename to Sources/LoopAlgorithm/Insulin/ExponentialInsulinModelPreset.swift diff --git a/Sources/LoopAlgorithm/InsulinMath.swift b/Sources/LoopAlgorithm/Insulin/InsulinMath.swift similarity index 100% rename from Sources/LoopAlgorithm/InsulinMath.swift rename to Sources/LoopAlgorithm/Insulin/InsulinMath.swift diff --git a/Sources/LoopAlgorithm/InsulinModel.swift b/Sources/LoopAlgorithm/Insulin/InsulinModel.swift similarity index 100% rename from Sources/LoopAlgorithm/InsulinModel.swift rename to Sources/LoopAlgorithm/Insulin/InsulinModel.swift diff --git a/Sources/LoopAlgorithm/InsulinModelProvider.swift b/Sources/LoopAlgorithm/Insulin/InsulinModelProvider.swift similarity index 100% rename from Sources/LoopAlgorithm/InsulinModelProvider.swift rename to Sources/LoopAlgorithm/Insulin/InsulinModelProvider.swift diff --git a/Sources/LoopAlgorithm/InsulinType.swift b/Sources/LoopAlgorithm/Insulin/InsulinType.swift similarity index 100% rename from Sources/LoopAlgorithm/InsulinType.swift rename to Sources/LoopAlgorithm/Insulin/InsulinType.swift diff --git a/Sources/LoopAlgorithm/InsulinValue.swift b/Sources/LoopAlgorithm/Insulin/InsulinValue.swift similarity index 100% rename from Sources/LoopAlgorithm/InsulinValue.swift rename to Sources/LoopAlgorithm/Insulin/InsulinValue.swift diff --git a/Sources/LoopAlgorithm/LoopAlgorithm.swift b/Sources/LoopAlgorithm/LoopAlgorithm.swift index 8a7cc3b..0a59bd0 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithm.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithm.swift @@ -19,7 +19,7 @@ public enum AlgorithmError: Error { public struct LoopAlgorithmEffects { public var insulin: [GlucoseEffect] public var carbs: [GlucoseEffect] - public var carbStatus: [CarbStatus] + public var carbStatus: [CarbStatus] public var retrospectiveCorrection: [GlucoseEffect] public var momentum: [GlucoseEffect] public var insulinCounteraction: [GlucoseEffectVelocity] @@ -29,7 +29,7 @@ public struct LoopAlgorithmEffects { public init( insulin: [GlucoseEffect], carbs: [GlucoseEffect], - carbStatus: [CarbStatus], + carbStatus: [CarbStatus], retrospectiveCorrection: [GlucoseEffect], momentum: [GlucoseEffect], insulinCounteraction: [GlucoseEffectVelocity], @@ -100,11 +100,11 @@ public struct LoopAlgorithm { /// - carbAbsorptionModel: A model conforming to CarbAbsorptionComputable that is used for computing carb absorption over time. /// - Returns: A LoopPrediction struct containing the predicted glucose and the computed intermediate effects used to make the prediction - public static func generatePrediction( + public static func generatePrediction( start: Date, glucoseHistory: [StoredGlucoseSample], doses: [DoseEntry], - carbEntries: [StoredCarbEntry], + carbEntries: [CarbType], basal: [AbsoluteScheduleValue], sensitivity: [AbsoluteScheduleValue], carbRatio: [AbsoluteScheduleValue], @@ -112,7 +112,7 @@ public struct LoopAlgorithm { useIntegralRetrospectiveCorrection: Bool = false, includingPositiveVelocityAndRC: Bool = true, carbAbsorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption() - ) -> LoopPrediction { + ) -> LoopPrediction where CarbType: CarbEntry { var prediction: [PredictedGlucoseValue] = [] var insulinEffects: [GlucoseEffect] = [] @@ -124,7 +124,7 @@ public struct LoopAlgorithm { var totalGlucoseCorrectionEffect: HKQuantity? var activeInsulin: Double? var activeCarbs: Double? - var carbStatus: [CarbStatus] = [] + var carbStatus: [CarbStatus] = [] var dosesRelativeToBasal: [DoseEntry] = [] // Ensure basal history covers doses @@ -248,7 +248,7 @@ public struct LoopAlgorithm { } // Helper to generate prediction with LoopPredictionInput struct - public static func generatePrediction(input: LoopPredictionInput) -> LoopPrediction { + public static func generatePrediction(input: LoopPredictionInput) -> LoopPrediction { return generatePrediction( start: input.glucoseHistory.last?.startDate ?? Date(), @@ -374,7 +374,7 @@ public struct LoopAlgorithm { return bolus } - public static func recommendDose(input: LoopAlgorithmInput) throws -> LoopAlgorithmDoseRecommendation { + public static func recommendDose(input: LoopAlgorithmInput) throws -> LoopAlgorithmDoseRecommendation { let output = run(input: input) switch output.recommendationResult { case .success(let recommendation): @@ -384,7 +384,7 @@ public struct LoopAlgorithm { } } - public static func run(input: LoopAlgorithmInput, effectOptions: AlgorithmEffectsOptions = .all) -> LoopAlgorithmOutput { + public static func run(input: LoopAlgorithmInput, effectOptions: AlgorithmEffectsOptions = .all) -> LoopAlgorithmOutput { // If we're running for automated dosing, we calculate a dose assuming that the current temp basal will be canceled let inputDoses: [DoseEntry] diff --git a/Sources/LoopAlgorithm/LoopAlgorithmInput.swift b/Sources/LoopAlgorithm/LoopAlgorithmInput.swift index db207bd..fcf051b 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithmInput.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithmInput.swift @@ -16,11 +16,11 @@ public enum AlgorithmInputDecodingError: Error { case doseVolumeMissing } -public struct LoopAlgorithmInput { +public struct LoopAlgorithmInput { public var predictionStart: Date public var glucoseHistory: [StoredGlucoseSample] public var doses: [DoseEntry] - public var carbEntries: [StoredCarbEntry] + public var carbEntries: [CarbType] public var basal: [AbsoluteScheduleValue] public var sensitivity: [AbsoluteScheduleValue] public var carbRatio: [AbsoluteScheduleValue] @@ -56,7 +56,7 @@ public struct LoopAlgorithmInput { var date: Date } - struct CarbEntry: Codable { + public struct CarbEntry: Codable { var grams: Double var absorptionTime: TimeInterval? var date: Date @@ -66,7 +66,7 @@ public struct LoopAlgorithmInput { predictionStart: Date, glucoseHistory: [StoredGlucoseSample], doses: [DoseEntry], - carbEntries: [StoredCarbEntry], + carbEntries: [CarbType], basal: [AbsoluteScheduleValue], sensitivity: [AbsoluteScheduleValue], carbRatio: [AbsoluteScheduleValue], @@ -128,7 +128,7 @@ extension LoopAlgorithmInput.Glucose: Codable { } -extension LoopAlgorithmInput: Codable { +extension LoopAlgorithmInput: Codable where CarbType == FixtureCarbEntry { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -168,14 +168,7 @@ extension LoopAlgorithmInput: Codable { } return DoseEntry(type: dose.type, startDate: dose.startDate, endDate: dose.endDate, value: value, unit: unit, insulinType: insulinType) }) - let carbEntries = try container.decode([CarbEntry].self, forKey: .carbEntries) - self.carbEntries = carbEntries.map { entry in - StoredCarbEntry( - startDate: entry.date, - quantity: HKQuantity(unit: .gram(), doubleValue: entry.grams), - absorptionTime: entry.absorptionTime - ) - } + 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: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: $0.value))} @@ -331,16 +324,14 @@ extension InsulinType { } -extension LoopAlgorithmInput { +extension LoopAlgorithmInput where CarbType == FixtureCarbEntry { var simplifiedForFixture: LoopAlgorithmInput { return LoopAlgorithmInput( predictionStart: predictionStart, glucoseHistory: glucoseHistory, doses: doses, - carbEntries: carbEntries.map { - StoredCarbEntry(startDate: $0.startDate, quantity: $0.quantity, absorptionTime: $0.absorptionTime) - }, + carbEntries: carbEntries, basal: basal, sensitivity: sensitivity, carbRatio: carbRatio, diff --git a/Sources/LoopAlgorithm/LoopPredictionInput.swift b/Sources/LoopAlgorithm/LoopPredictionInput.swift index e8112c7..17ee267 100644 --- a/Sources/LoopAlgorithm/LoopPredictionInput.swift +++ b/Sources/LoopAlgorithm/LoopPredictionInput.swift @@ -9,7 +9,7 @@ import Foundation import HealthKit -public struct LoopPredictionInput { +public struct LoopPredictionInput { // Algorithm input time range: t-10h to t public var glucoseHistory: [StoredGlucoseSample] @@ -17,7 +17,7 @@ public struct LoopPredictionInput { public var doses: [DoseEntry] // Algorithm input time range: t-10h to t - public var carbEntries: [StoredCarbEntry] + public var carbEntries: [CarbType] // Expected time range coverage: t-16h to t public var basal: [AbsoluteScheduleValue] @@ -39,7 +39,7 @@ public struct LoopPredictionInput { public init( glucoseHistory: [StoredGlucoseSample], doses: [DoseEntry], - carbEntries: [StoredCarbEntry], + carbEntries: [CarbType], basal: [AbsoluteScheduleValue], sensitivity: [AbsoluteScheduleValue], carbRatio: [AbsoluteScheduleValue], @@ -61,13 +61,13 @@ public struct LoopPredictionInput { } -extension LoopPredictionInput: Codable { +extension LoopPredictionInput: Codable where CarbType == FixtureCarbEntry { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.glucoseHistory = try container.decode([StoredGlucoseSample].self, forKey: .glucoseHistory) self.doses = try container.decode([DoseEntry].self, forKey: .doses) - self.carbEntries = try container.decode([StoredCarbEntry].self, forKey: .carbEntries) + 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: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: $0.value))} @@ -115,7 +115,7 @@ extension LoopPredictionInput: Codable { } } -extension LoopPredictionInput { +extension LoopPredictionInput where CarbType == FixtureCarbEntry { var simplifiedForFixture: LoopPredictionInput { return LoopPredictionInput( @@ -128,9 +128,7 @@ extension LoopPredictionInput { doses: doses.map { DoseEntry(type: $0.type, startDate: $0.startDate, endDate: $0.endDate, value: $0.value, unit: $0.unit) }, - carbEntries: carbEntries.map { - StoredCarbEntry(startDate: $0.startDate, quantity: $0.quantity, absorptionTime: $0.absorptionTime) - }, + carbEntries: carbEntries, basal: basal, sensitivity: sensitivity, carbRatio: carbRatio, diff --git a/Sources/LoopAlgorithm/IntegralRetrospectiveCorrection.swift b/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift similarity index 100% rename from Sources/LoopAlgorithm/IntegralRetrospectiveCorrection.swift rename to Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift diff --git a/Sources/LoopAlgorithm/RetrospectiveCorrection.swift b/Sources/LoopAlgorithm/RetrospectiveCorrection/RetrospectiveCorrection.swift similarity index 100% rename from Sources/LoopAlgorithm/RetrospectiveCorrection.swift rename to Sources/LoopAlgorithm/RetrospectiveCorrection/RetrospectiveCorrection.swift diff --git a/Sources/LoopAlgorithm/StandardRetrospectiveCorrection.swift b/Sources/LoopAlgorithm/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift similarity index 100% rename from Sources/LoopAlgorithm/StandardRetrospectiveCorrection.swift rename to Sources/LoopAlgorithm/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift diff --git a/Sources/LoopAlgorithm/StoredCarbEntry.swift b/Sources/LoopAlgorithm/StoredCarbEntry.swift deleted file mode 100644 index 18ed5bc..0000000 --- a/Sources/LoopAlgorithm/StoredCarbEntry.swift +++ /dev/null @@ -1,161 +0,0 @@ -// -// StoredCarbEntry.swift -// -// Created by Nathan Racklyeft on 1/22/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import HealthKit -import CoreData - -public struct StoredCarbEntry: CarbEntry, Equatable { - - public let uuid: UUID? - - public static let defaultProvenanceIdentifier = "com.LoopKit.Loop" - - // MARK: - HealthKit Sync Support - - public let provenanceIdentifier: String - public let syncIdentifier: String? - public let syncVersion: Int? - - // MARK: - SampleValue - - public let startDate: Date - public let quantity: HKQuantity - - // MARK: - CarbEntry - - public let foodType: String? - public let absorptionTime: TimeInterval? - public let createdByCurrentApp: Bool - - // MARK: - User dates - - public let userCreatedDate: Date? - public let userUpdatedDate: Date? - - public init( - startDate: Date, - quantity: HKQuantity, - uuid: UUID? = nil, - provenanceIdentifier: String = Self.defaultProvenanceIdentifier, - syncIdentifier: String? = nil, - syncVersion: Int? = nil, - foodType: String? = nil, - absorptionTime: TimeInterval? = nil, - createdByCurrentApp: Bool = true, - userCreatedDate: Date? = nil, - userUpdatedDate: Date? = nil - ) { - self.uuid = uuid - self.provenanceIdentifier = provenanceIdentifier - self.syncIdentifier = syncIdentifier - self.syncVersion = syncVersion - self.startDate = startDate - self.quantity = quantity - self.foodType = foodType - self.absorptionTime = absorptionTime - self.createdByCurrentApp = createdByCurrentApp - self.userCreatedDate = userCreatedDate - self.userUpdatedDate = userUpdatedDate - } - - public var amount: Double { - quantity.doubleValue(for: .gram()) - } -} - -extension StoredCarbEntry: 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: HKQuantity(unit: .gram(), doubleValue: try container.decode(Double.self, forKey: .quantity)), - uuid: try container.decodeIfPresent(UUID.self, forKey: .uuid), - provenanceIdentifier: (try container.decodeIfPresent(String.self, forKey: .provenanceIdentifier)) ?? Self.defaultProvenanceIdentifier, - syncIdentifier: try container.decodeIfPresent(String.self, forKey: .syncIdentifier), - syncVersion: try container.decodeIfPresent(Int.self, forKey: .syncVersion), - foodType: try container.decodeIfPresent(String.self, forKey: .foodType), - absorptionTime: try container.decodeIfPresent(TimeInterval.self, forKey: .absorptionTime), - createdByCurrentApp: (try container.decodeIfPresent(Bool.self, forKey: .createdByCurrentApp)) ?? true, - userCreatedDate: try container.decodeIfPresent(Date.self, forKey: .userCreatedDate), - userUpdatedDate: try container.decodeIfPresent(Date.self, forKey: .userUpdatedDate) - ) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encodeIfPresent(uuid, forKey: .uuid) - if provenanceIdentifier != Self.defaultProvenanceIdentifier { - try container.encode(provenanceIdentifier, forKey: .provenanceIdentifier) - } - try container.encodeIfPresent(syncIdentifier, forKey: .syncIdentifier) - try container.encodeIfPresent(syncVersion, forKey: .syncVersion) - try container.encode(startDate, forKey: .startDate) - try container.encode(quantity.doubleValue(for: .gram()), forKey: .quantity) - try container.encodeIfPresent(foodType, forKey: .foodType) - try container.encodeIfPresent(absorptionTime, forKey: .absorptionTime) - if !createdByCurrentApp { - try container.encode(createdByCurrentApp, forKey: .createdByCurrentApp) - } - try container.encodeIfPresent(userCreatedDate, forKey: .userCreatedDate) - try container.encodeIfPresent(userUpdatedDate, forKey: .userUpdatedDate) - } - - private enum CodingKeys: String, CodingKey { - case uuid - case provenanceIdentifier - case syncIdentifier - case syncVersion - case startDate - case quantity - case foodType - case absorptionTime - case createdByCurrentApp - case userCreatedDate - case userUpdatedDate - } -} - -// MARK: - DEPRECATED - Used only for migration - -extension StoredCarbEntry { - typealias RawValue = [String: Any] - - init?(rawValue: RawValue) { - guard let - sampleUUIDString = rawValue["sampleUUID"] as? String, - let uuid = UUID(uuidString: sampleUUIDString), - let startDate = rawValue["startDate"] as? Date, - let unitString = rawValue["unitString"] as? String, - let value = rawValue["value"] as? Double, - let createdByCurrentApp = rawValue["createdByCurrentApp"] as? Bool else - { - return nil - } - - var syncIdentifier: String? - var syncVersion: Int? - - if let externalID = rawValue["externalId"] as? String { - syncIdentifier = externalID - syncVersion = 1 - } - - self.init( - startDate: startDate, - quantity: HKQuantity(unit: HKUnit(from: unitString), doubleValue: value), - uuid: uuid, - provenanceIdentifier: createdByCurrentApp ? HKSource.default().bundleIdentifier : "", - syncIdentifier: syncIdentifier, - syncVersion: syncVersion, - foodType: rawValue["foodType"] as? String, - absorptionTime: rawValue["absorptionTime"] as? TimeInterval, - createdByCurrentApp: createdByCurrentApp, - userCreatedDate: nil, - userUpdatedDate: nil - ) - } -} diff --git a/Tests/LoopAlgorithmTests/LoopAlgorithmInputMocks.swift b/Tests/LoopAlgorithmTests/LoopAlgorithmInputMocks.swift new file mode 100644 index 0000000..1989f72 --- /dev/null +++ b/Tests/LoopAlgorithmTests/LoopAlgorithmInputMocks.swift @@ -0,0 +1,33 @@ +// +// File.swift +// +// +// Created by Pete Schwamb on 12/20/23. +// + +import Foundation +@testable import LoopAlgorithm + +extension LoopAlgorithmInput { + static func mock(for date: Date) -> LoopAlgorithmInput { + + return LoopAlgorithmInput( + predictionStart: date, + glucoseHistory: [], + doses: [], + carbEntries: [], + basal: [], + sensitivity: [], + carbRatio: [], + target: [], + suspendThreshold: .init(unit: .milligramsPerDeciliter, doubleValue: 65), + maxBolus: 6, + maxBasalRate: 8, + useIntegralRetrospectiveCorrection: false, + includePositiveVelocityAndRC: false, + carbAbsorptionModel: .piecewiseLinear, + recommendationInsulinType: .novolog, + recommendationType: .manualBolus + ) + } +} diff --git a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift index 5c40046..7558159 100644 --- a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift +++ b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift @@ -11,7 +11,7 @@ import XCTest final class LoopAlgorithmTests: XCTestCase { - func loadScenario(_ name: String) -> (input: LoopAlgorithmInput, recommendation: LoopAlgorithmDoseRecommendation) { + func loadScenario(_ name: String) -> (input: LoopAlgorithmInput, recommendation: LoopAlgorithmDoseRecommendation) { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 var url = Bundle.module.url(forResource: name + "_input", withExtension: "json", subdirectory: "Fixtures")! @@ -44,6 +44,4 @@ final class LoopAlgorithmTests: XCTestCase { XCTAssertEqual(output.recommendation, recommendation) } - - } From 01526ab544b2b022bdfd4d37667365b375b8ec03 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 20 Dec 2023 12:03:09 -0600 Subject: [PATCH 03/26] Algorithm accepts any GlucoseSampleValue conforming type --- .../Glucose/GlucoseSampleValue.swift | 15 ---- .../Glucose/StoredGlucoseSample.swift | 75 ++----------------- Sources/LoopAlgorithm/LoopAlgorithm.swift | 14 ++-- .../LoopAlgorithm/LoopAlgorithmInput.swift | 33 +++++--- .../LoopAlgorithm/LoopPredictionInput.swift | 25 ++++--- .../LoopAlgorithmTests.swift | 2 +- 6 files changed, 52 insertions(+), 112 deletions(-) diff --git a/Sources/LoopAlgorithm/Glucose/GlucoseSampleValue.swift b/Sources/LoopAlgorithm/Glucose/GlucoseSampleValue.swift index 427b469..4665c37 100644 --- a/Sources/LoopAlgorithm/Glucose/GlucoseSampleValue.swift +++ b/Sources/LoopAlgorithm/Glucose/GlucoseSampleValue.swift @@ -14,19 +14,4 @@ public protocol GlucoseSampleValue: GlucoseValue { /// Whether the glucose value was provided for visual consistency, rather than an actual, calibrated reading. var isDisplayOnly: Bool { get } - - /// Whether the glucose value was entered by the user. - var wasUserEntered: Bool { get } - - /// Any condition applied to the sample. - var condition: GlucoseCondition? { get } - - /// The trend of the sample. - var trend: GlucoseTrend? { get } - - /// The trend rate of the sample. - var trendRate: HKQuantity? { get } - - /// The syncIdentifier of the sample. - var syncIdentifier: String? { get } } diff --git a/Sources/LoopAlgorithm/Glucose/StoredGlucoseSample.swift b/Sources/LoopAlgorithm/Glucose/StoredGlucoseSample.swift index bd61526..666ccbd 100644 --- a/Sources/LoopAlgorithm/Glucose/StoredGlucoseSample.swift +++ b/Sources/LoopAlgorithm/Glucose/StoredGlucoseSample.swift @@ -7,120 +7,57 @@ import HealthKit -public struct StoredGlucoseSample: GlucoseSampleValue, Equatable { - public let uuid: UUID? // Note this is the UUID from HealthKit. Nil if not (yet) stored in HealthKit. +public struct FixtureGlucoseSample: GlucoseSampleValue, Equatable { public static let defaultProvenanceIdentifier = "com.LoopKit.Loop" - // MARK: - HealthKit Sync Support - public let provenanceIdentifier: String - public let syncIdentifier: String? - public let syncVersion: Int? - public let device: HKDevice? - public let healthKitEligibleDate: Date? - - // MARK: - SampleValue - public let startDate: Date public let quantity: HKQuantity - - // MARK: - GlucoseSampleValue - public let isDisplayOnly: Bool - public let wasUserEntered: Bool - public let condition: GlucoseCondition? - public let trend: GlucoseTrend? - public let trendRate: HKQuantity? public init( - uuid: UUID? = nil, provenanceIdentifier: String = Self.defaultProvenanceIdentifier, - syncIdentifier: String? = nil, - syncVersion: Int? = nil, startDate: Date, quantity: HKQuantity, - condition: GlucoseCondition? = nil, - trend: GlucoseTrend? = nil, - trendRate: HKQuantity? = nil, - isDisplayOnly: Bool = false, - wasUserEntered: Bool = false, - device: HKDevice? = nil, - healthKitEligibleDate: Date? = nil) { - self.uuid = uuid + isDisplayOnly: Bool = false + ) { self.provenanceIdentifier = provenanceIdentifier - self.syncIdentifier = syncIdentifier - self.syncVersion = syncVersion self.startDate = startDate self.quantity = quantity - self.condition = condition - self.trend = trend - self.trendRate = trendRate self.isDisplayOnly = isDisplayOnly - self.wasUserEntered = wasUserEntered - self.device = device - self.healthKitEligibleDate = healthKitEligibleDate } } -extension StoredGlucoseSample: Codable { +extension FixtureGlucoseSample: Codable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - let uuid = try container.decodeIfPresent(UUID.self, forKey: .uuid) let provenanceIdentifier = try container.decodeIfPresent(String.self, forKey: .provenanceIdentifier) ?? Self.defaultProvenanceIdentifier - let wasUserEntered = try container.decodeIfPresent(Bool.self, forKey: .wasUserEntered) ?? false let isDisplayOnly = try container.decodeIfPresent(Bool.self, forKey: .isDisplayOnly) ?? false - self.init(uuid: uuid, - provenanceIdentifier: provenanceIdentifier, - syncIdentifier: try container.decodeIfPresent(String.self, forKey: .syncIdentifier), - syncVersion: try container.decodeIfPresent(Int.self, forKey: .syncVersion), + self.init(provenanceIdentifier: provenanceIdentifier, startDate: try container.decode(Date.self, forKey: .startDate), quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: try container.decode(Double.self, forKey: .quantity)), - condition: try container.decodeIfPresent(GlucoseCondition.self, forKey: .condition), - trend: try container.decodeIfPresent(GlucoseTrend.self, forKey: .trend), - trendRate: try container.decodeIfPresent(Double.self, forKey: .trendRate).map { HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: $0) }, - isDisplayOnly: isDisplayOnly, - wasUserEntered: wasUserEntered, - healthKitEligibleDate: try container.decodeIfPresent(Date.self, forKey: .healthKitEligibleDate)) + isDisplayOnly: isDisplayOnly) } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - try container.encodeIfPresent(uuid, forKey: .uuid) if provenanceIdentifier != Self.defaultProvenanceIdentifier { try container.encode(provenanceIdentifier, forKey: .provenanceIdentifier) } - try container.encodeIfPresent(syncIdentifier, forKey: .syncIdentifier) - try container.encodeIfPresent(syncVersion, forKey: .syncVersion) try container.encode(startDate, forKey: .startDate) try container.encode(quantity.doubleValue(for: .milligramsPerDeciliter), forKey: .quantity) - try container.encodeIfPresent(condition, forKey: .condition) - try container.encodeIfPresent(trend, forKey: .trend) - try container.encodeIfPresent(trendRate?.doubleValue(for: .milligramsPerDeciliterPerMinute), forKey: .trendRate) if isDisplayOnly { try container.encode(isDisplayOnly, forKey: .isDisplayOnly) } - if wasUserEntered { - try container.encode(wasUserEntered, forKey: .wasUserEntered) - } - try container.encodeIfPresent(healthKitEligibleDate, forKey: .healthKitEligibleDate) } private enum CodingKeys: String, CodingKey { - case uuid case provenanceIdentifier - case syncIdentifier - case syncVersion case startDate case quantity - case condition - case trend - case trendRate case isDisplayOnly - case wasUserEntered - case device - case healthKitEligibleDate } } diff --git a/Sources/LoopAlgorithm/LoopAlgorithm.swift b/Sources/LoopAlgorithm/LoopAlgorithm.swift index 0a59bd0..305691d 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithm.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithm.swift @@ -100,9 +100,9 @@ public struct LoopAlgorithm { /// - carbAbsorptionModel: A model conforming to CarbAbsorptionComputable that is used for computing carb absorption over time. /// - Returns: A LoopPrediction struct containing the predicted glucose and the computed intermediate effects used to make the prediction - public static func generatePrediction( + public static func generatePrediction( start: Date, - glucoseHistory: [StoredGlucoseSample], + glucoseHistory: [GlucoseType], doses: [DoseEntry], carbEntries: [CarbType], basal: [AbsoluteScheduleValue], @@ -112,7 +112,7 @@ public struct LoopAlgorithm { useIntegralRetrospectiveCorrection: Bool = false, includingPositiveVelocityAndRC: Bool = true, carbAbsorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption() - ) -> LoopPrediction where CarbType: CarbEntry { + ) -> LoopPrediction where CarbType: CarbEntry, GlucoseType: GlucoseSampleValue { var prediction: [PredictedGlucoseValue] = [] var insulinEffects: [GlucoseEffect] = [] @@ -248,7 +248,7 @@ public struct LoopAlgorithm { } // Helper to generate prediction with LoopPredictionInput struct - public static func generatePrediction(input: LoopPredictionInput) -> LoopPrediction { + public static func generatePrediction(input: LoopPredictionInput) -> LoopPrediction { return generatePrediction( start: input.glucoseHistory.last?.startDate ?? Date(), @@ -360,7 +360,7 @@ public struct LoopAlgorithm { public static func recommendManualBolus( for correction: InsulinCorrection, maxBolus: Double, - currentGlucose: StoredGlucoseSample, + currentGlucose: GlucoseSampleValue, target: GlucoseRangeTimeline ) -> ManualBolusRecommendation { var bolus = correction.asManualBolus(maxBolus: maxBolus) @@ -374,7 +374,7 @@ public struct LoopAlgorithm { return bolus } - public static func recommendDose(input: LoopAlgorithmInput) throws -> LoopAlgorithmDoseRecommendation { + public static func recommendDose(input: LoopAlgorithmInput) throws -> LoopAlgorithmDoseRecommendation { let output = run(input: input) switch output.recommendationResult { case .success(let recommendation): @@ -384,7 +384,7 @@ public struct LoopAlgorithm { } } - public static func run(input: LoopAlgorithmInput, effectOptions: AlgorithmEffectsOptions = .all) -> LoopAlgorithmOutput { + public static func run(input: LoopAlgorithmInput, effectOptions: AlgorithmEffectsOptions = .all) -> LoopAlgorithmOutput { // If we're running for automated dosing, we calculate a dose assuming that the current temp basal will be canceled let inputDoses: [DoseEntry] diff --git a/Sources/LoopAlgorithm/LoopAlgorithmInput.swift b/Sources/LoopAlgorithm/LoopAlgorithmInput.swift index fcf051b..52862fa 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithmInput.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithmInput.swift @@ -16,9 +16,9 @@ public enum AlgorithmInputDecodingError: Error { case doseVolumeMissing } -public struct LoopAlgorithmInput { +public struct LoopAlgorithmInput { public var predictionStart: Date - public var glucoseHistory: [StoredGlucoseSample] + public var glucoseHistory: [GlucoseType] public var doses: [DoseEntry] public var carbEntries: [CarbType] public var basal: [AbsoluteScheduleValue] @@ -64,7 +64,7 @@ public struct LoopAlgorithmInput { public init( predictionStart: Date, - glucoseHistory: [StoredGlucoseSample], + glucoseHistory: [GlucoseType], doses: [DoseEntry], carbEntries: [CarbType], basal: [AbsoluteScheduleValue], @@ -128,14 +128,14 @@ extension LoopAlgorithmInput.Glucose: Codable { } -extension LoopAlgorithmInput: Codable where CarbType == FixtureCarbEntry { +extension LoopAlgorithmInput: Codable where CarbType == FixtureCarbEntry, GlucoseType == FixtureGlucoseSample { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.predictionStart = try container.decode(Date.self, forKey: .predictionStart) let glucose = try container.decode([Glucose].self, forKey: .glucoseHistory) self.glucoseHistory = glucose.map { sample in - StoredGlucoseSample( + FixtureGlucoseSample( startDate: sample.date, quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: sample.value), isDisplayOnly: sample.isCalibration @@ -324,14 +324,27 @@ extension InsulinType { } -extension LoopAlgorithmInput where CarbType == FixtureCarbEntry { +extension LoopAlgorithmInput { - var simplifiedForFixture: LoopAlgorithmInput { - return LoopAlgorithmInput( + var simplifiedForFixture: LoopAlgorithmInput { + return LoopAlgorithmInput( predictionStart: predictionStart, - glucoseHistory: glucoseHistory, + glucoseHistory: glucoseHistory.map { + FixtureGlucoseSample( + provenanceIdentifier: $0.provenanceIdentifier, + startDate: $0.startDate, + quantity: $0.quantity, + isDisplayOnly: $0.isDisplayOnly + ) + }, doses: doses, - carbEntries: carbEntries, + carbEntries: carbEntries.map { + FixtureCarbEntry( + absorptionTime: $0.absorptionTime, + startDate: $0.startDate, + quantity: $0.quantity + ) + }, basal: basal, sensitivity: sensitivity, carbRatio: carbRatio, diff --git a/Sources/LoopAlgorithm/LoopPredictionInput.swift b/Sources/LoopAlgorithm/LoopPredictionInput.swift index 17ee267..0c1bab2 100644 --- a/Sources/LoopAlgorithm/LoopPredictionInput.swift +++ b/Sources/LoopAlgorithm/LoopPredictionInput.swift @@ -9,9 +9,9 @@ import Foundation import HealthKit -public struct LoopPredictionInput { +public struct LoopPredictionInput { // Algorithm input time range: t-10h to t - public var glucoseHistory: [StoredGlucoseSample] + public var glucoseHistory: [GlucoseType] // Algorithm input time range: t-16h to t public var doses: [DoseEntry] @@ -37,7 +37,7 @@ public struct LoopPredictionInput { public var carbAbsorptionModel: CarbAbsorptionModel = .piecewiseLinear public init( - glucoseHistory: [StoredGlucoseSample], + glucoseHistory: [GlucoseType], doses: [DoseEntry], carbEntries: [CarbType], basal: [AbsoluteScheduleValue], @@ -61,11 +61,11 @@ public struct LoopPredictionInput { } -extension LoopPredictionInput: Codable where CarbType == FixtureCarbEntry { +extension LoopPredictionInput: Codable where CarbType == FixtureCarbEntry, GlucoseType == FixtureGlucoseSample { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - self.glucoseHistory = try container.decode([StoredGlucoseSample].self, forKey: .glucoseHistory) + self.glucoseHistory = try container.decode([FixtureGlucoseSample].self, forKey: .glucoseHistory) self.doses = try container.decode([DoseEntry].self, forKey: .doses) self.carbEntries = try container.decode([FixtureCarbEntry].self, forKey: .carbEntries) self.basal = try container.decode([AbsoluteScheduleValue].self, forKey: .basal) @@ -115,12 +115,12 @@ extension LoopPredictionInput: Codable where CarbType == FixtureCarbEntry { } } -extension LoopPredictionInput where CarbType == FixtureCarbEntry { +extension LoopPredictionInput { - var simplifiedForFixture: LoopPredictionInput { - return LoopPredictionInput( + var simplifiedForFixture: LoopPredictionInput { + return LoopPredictionInput( glucoseHistory: glucoseHistory.map { - return StoredGlucoseSample( + return FixtureGlucoseSample( startDate: $0.startDate, quantity: $0.quantity, isDisplayOnly: $0.isDisplayOnly) @@ -128,7 +128,12 @@ extension LoopPredictionInput where CarbType == FixtureCarbEntry { doses: doses.map { DoseEntry(type: $0.type, startDate: $0.startDate, endDate: $0.endDate, value: $0.value, unit: $0.unit) }, - carbEntries: carbEntries, + carbEntries: carbEntries.map { + return FixtureCarbEntry( + absorptionTime: $0.absorptionTime, + startDate: $0.startDate, + quantity: $0.quantity) + }, basal: basal, sensitivity: sensitivity, carbRatio: carbRatio, diff --git a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift index 7558159..37e2fd8 100644 --- a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift +++ b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift @@ -11,7 +11,7 @@ import XCTest final class LoopAlgorithmTests: XCTestCase { - func loadScenario(_ name: String) -> (input: LoopAlgorithmInput, recommendation: LoopAlgorithmDoseRecommendation) { + func loadScenario(_ name: String) -> (input: LoopAlgorithmInput, recommendation: LoopAlgorithmDoseRecommendation) { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 var url = Bundle.module.url(forResource: name + "_input", withExtension: "json", subdirectory: "Fixtures")! From f25086c51667ad0cb10e2f37747dfb9b615aff47 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 21 Dec 2023 14:37:40 -0600 Subject: [PATCH 04/26] Algorithm accepts any InsulinDose conforming type --- Sources/LoopAlgorithm/Extensions/HKUnit.swift | 9 - Sources/LoopAlgorithm/Insulin/DoseEntry.swift | 295 ------------- Sources/LoopAlgorithm/Insulin/DoseMath.swift | 2 +- Sources/LoopAlgorithm/Insulin/DoseType.swift | 7 +- .../Insulin/FixtureInsulinDose.swift | 47 +++ .../LoopAlgorithm/Insulin/InsulinDose.swift | 28 ++ .../LoopAlgorithm/Insulin/InsulinMath.swift | 388 ++---------------- .../Insulin/InsulinModelProvider.swift | 1 - .../Insulin/RelativeDelivery.swift | 56 +++ Sources/LoopAlgorithm/LoopAlgorithm.swift | 30 +- .../LoopAlgorithm/LoopAlgorithmInput.swift | 84 +--- .../LoopAlgorithm/LoopAlgorithmOutput.swift | 4 +- .../LoopAlgorithm/LoopPredictionInput.swift | 22 +- .../LoopAlgorithmTests.swift | 2 +- 14 files changed, 216 insertions(+), 759 deletions(-) delete mode 100644 Sources/LoopAlgorithm/Insulin/DoseEntry.swift create mode 100644 Sources/LoopAlgorithm/Insulin/FixtureInsulinDose.swift create mode 100644 Sources/LoopAlgorithm/Insulin/InsulinDose.swift create mode 100644 Sources/LoopAlgorithm/Insulin/RelativeDelivery.swift diff --git a/Sources/LoopAlgorithm/Extensions/HKUnit.swift b/Sources/LoopAlgorithm/Extensions/HKUnit.swift index e029a23..2b8d760 100644 --- a/Sources/LoopAlgorithm/Extensions/HKUnit.swift +++ b/Sources/LoopAlgorithm/Extensions/HKUnit.swift @@ -49,14 +49,5 @@ extension HKUnit { return nil } - - /// The smallest value expected to be visible on a chart - var chartableIncrement: Double { - if self == .milligramsPerDeciliter { - return 1 - } else { - return 1 / 25 - } - } } diff --git a/Sources/LoopAlgorithm/Insulin/DoseEntry.swift b/Sources/LoopAlgorithm/Insulin/DoseEntry.swift deleted file mode 100644 index bbc567e..0000000 --- a/Sources/LoopAlgorithm/Insulin/DoseEntry.swift +++ /dev/null @@ -1,295 +0,0 @@ -// -// DoseEntry.swift -// Naterade -// -// Created by Nathan Racklyeft on 1/31/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation -import HealthKit - - -public struct DoseEntry: TimelineValue, Equatable { - public let type: DoseType - public let startDate: Date - public var endDate: Date - internal let value: Double - public let unit: DoseUnit - public let deliveredUnits: Double? - public let description: String? - public let insulinType: InsulinType? - public let automatic: Bool? - public let manuallyEntered: Bool - public internal(set) var syncIdentifier: String? - public let isMutable: Bool - public let wasProgrammedByPumpUI: Bool - - /// The scheduled basal rate during this dose entry - public internal(set) var scheduledBasalRate: HKQuantity? - - public init(suspendDate: Date, automatic: Bool? = nil, isMutable: Bool = false, wasProgrammedByPumpUI: Bool = false) { - self.init(type: .suspend, startDate: suspendDate, value: 0, unit: .units, automatic: automatic, isMutable: isMutable, wasProgrammedByPumpUI: wasProgrammedByPumpUI) - } - - public init(resumeDate: Date, insulinType: InsulinType? = nil, automatic: Bool? = nil, isMutable: Bool = false, wasProgrammedByPumpUI: Bool = false) { - self.init(type: .resume, startDate: resumeDate, value: 0, unit: .units, insulinType: insulinType, automatic: automatic, isMutable: isMutable, wasProgrammedByPumpUI: wasProgrammedByPumpUI) - } - - // If the insulin model field is nil, it's assumed that the model is the type of insulin the pump dispenses - public init(type: DoseType, startDate: Date, endDate: Date? = nil, value: Double, unit: DoseUnit, deliveredUnits: Double? = nil, description: String? = nil, syncIdentifier: String? = nil, scheduledBasalRate: HKQuantity? = nil, insulinType: InsulinType? = nil, automatic: Bool? = nil, manuallyEntered: Bool = false, isMutable: Bool = false, wasProgrammedByPumpUI: Bool = false) { - self.type = type - self.startDate = startDate - self.endDate = endDate ?? startDate - self.value = value - self.unit = unit - self.deliveredUnits = deliveredUnits - self.description = description - self.syncIdentifier = syncIdentifier - self.scheduledBasalRate = scheduledBasalRate - self.insulinType = insulinType - self.automatic = automatic - self.manuallyEntered = manuallyEntered - self.isMutable = isMutable - self.wasProgrammedByPumpUI = wasProgrammedByPumpUI - } -} - - -extension DoseEntry { - public static var units = HKUnit.internationalUnit() - - public static let unitsPerHour = HKUnit.internationalUnit().unitDivided(by: .hour()) - - private var hours: Double { - return endDate.timeIntervalSince(startDate).hours - } - - public var programmedUnits: Double { - switch unit { - case .units: - return value - case .unitsPerHour: - return value * hours - } - } - - public var unitsPerHour: Double { - switch unit { - case .units: - let hours = self.hours - guard hours != 0 else { - return 0 - } - - return value / hours - case .unitsPerHour: - return value - } - } - - /// The number of units delivered, net the basal rate scheduled during that time, which can be used to compute insulin on-board and glucose effects - public var netBasalUnits: Double { - switch type { - case .bolus: - return deliveredUnits ?? programmedUnits - case .basal: - return 0 - case .resume, .suspend, .tempBasal: - break - } - - guard hours > 0 else { - return 0 - } - - let scheduledUnitsPerHour: Double - if let basalRate = scheduledBasalRate { - scheduledUnitsPerHour = basalRate.doubleValue(for: DoseEntry.unitsPerHour) - } else { - scheduledUnitsPerHour = 0 - } - - let scheduledUnits = scheduledUnitsPerHour * hours - return unitsInDeliverableIncrements - scheduledUnits - } - - /// The rate of delivery, net the basal rate scheduled during that time, which can be used to compute insulin on-board and glucose effects - public var netBasalUnitsPerHour: Double { - switch type { - case .basal: - return 0 - case .bolus: - return self.unitsPerHour - default: - break - } - - guard let basalRate = scheduledBasalRate else { - return 0 - } - - let unitsPerHour = self.unitsPerHour - basalRate.doubleValue(for: DoseEntry.unitsPerHour) - - guard abs(unitsPerHour) > .ulpOfOne else { - return 0 - } - - return unitsPerHour - } - - /// The smallest increment per unit of hourly basal delivery - /// TODO: Is this 40 for x23 models? (yes - PS 7/26/2019) - /// MinimedPumpmanager will be updated to report deliveredUnits, so this will end up not being used. - private static let minimumMinimedIncrementPerUnit: Double = 20 - - /// Returns the delivered units, or rounds to nearest deliverable (mdt) increment - public var unitsInDeliverableIncrements: Double { - guard case .unitsPerHour = unit else { - return deliveredUnits ?? programmedUnits - } - - return deliveredUnits ?? round(programmedUnits * DoseEntry.minimumMinimedIncrementPerUnit) / DoseEntry.minimumMinimedIncrementPerUnit - } -} - -extension DoseEntry: Codable { - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - self.type = try container.decode(DoseType.self, forKey: .type) - self.startDate = try container.decode(Date.self, forKey: .startDate) - self.endDate = try container.decode(Date.self, forKey: .endDate) - self.value = try container.decode(Double.self, forKey: .value) - self.unit = try container.decode(DoseUnit.self, forKey: .unit) - self.deliveredUnits = try container.decodeIfPresent(Double.self, forKey: .deliveredUnits) - self.description = try container.decodeIfPresent(String.self, forKey: .description) - self.syncIdentifier = try container.decodeIfPresent(String.self, forKey: .syncIdentifier) - self.insulinType = try container.decodeIfPresent(InsulinType.self, forKey: .insulinType) - if let scheduledBasalRate = try container.decodeIfPresent(Double.self, forKey: .scheduledBasalRate), - let scheduledBasalRateUnit = try container.decodeIfPresent(String.self, forKey: .scheduledBasalRateUnit) { - self.scheduledBasalRate = HKQuantity(unit: HKUnit(from: scheduledBasalRateUnit), doubleValue: scheduledBasalRate) - } - self.automatic = try container.decodeIfPresent(Bool.self, forKey: .automatic) - self.manuallyEntered = try container.decodeIfPresent(Bool.self, forKey: .manuallyEntered) ?? false - self.isMutable = try container.decodeIfPresent(Bool.self, forKey: .isMutable) ?? false - self.wasProgrammedByPumpUI = try container.decodeIfPresent(Bool.self, forKey: .wasProgrammedByPumpUI) ?? false - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(type, forKey: .type) - try container.encode(startDate, forKey: .startDate) - try container.encode(endDate, forKey: .endDate) - try container.encode(value, forKey: .value) - try container.encode(unit, forKey: .unit) - try container.encodeIfPresent(deliveredUnits, forKey: .deliveredUnits) - try container.encodeIfPresent(description, forKey: .description) - try container.encodeIfPresent(syncIdentifier, forKey: .syncIdentifier) - try container.encodeIfPresent(insulinType, forKey: .insulinType) - if let scheduledBasalRate = scheduledBasalRate { - try container.encode(scheduledBasalRate.doubleValue(for: DoseEntry.unitsPerHour), forKey: .scheduledBasalRate) - try container.encode(DoseEntry.unitsPerHour.unitString, forKey: .scheduledBasalRateUnit) - } - try container.encodeIfPresent(automatic, forKey: .automatic) - if manuallyEntered { - try container.encode(manuallyEntered, forKey: .manuallyEntered) - } - if isMutable { - try container.encode(isMutable, forKey: .isMutable) - } - if wasProgrammedByPumpUI { - try container.encode(wasProgrammedByPumpUI, forKey: .wasProgrammedByPumpUI) - } - } - - private enum CodingKeys: String, CodingKey { - case type - case startDate - case endDate - case value - case unit - case deliveredUnits - case description - case syncIdentifier - case scheduledBasalRate - case scheduledBasalRateUnit - case insulinType - case automatic - case manuallyEntered - case isMutable - case wasProgrammedByPumpUI - } -} - -extension DoseEntry: RawRepresentable { - public typealias RawValue = [String: Any] - - public init?(rawValue: [String: Any]) { - guard let rawType = rawValue["type"] as? DoseType.RawValue, - let type = DoseType(rawValue: rawType), - let startDate = rawValue["startDate"] as? Date, - let endDate = rawValue["endDate"] as? Date, - let value = rawValue["value"] as? Double, - let rawUnit = rawValue["unit"] as? DoseUnit.RawValue, - let unit = DoseUnit(rawValue: rawUnit), - let manuallyEntered = rawValue["manuallyEntered"] as? Bool - else { - return nil - } - - self.type = type - self.startDate = startDate - self.endDate = endDate - self.value = value - self.unit = unit - self.manuallyEntered = manuallyEntered - - self.deliveredUnits = rawValue["deliveredUnits"] as? Double - self.description = rawValue["description"] as? String - self.insulinType = (rawValue["insulinType"] as? InsulinType.RawValue).flatMap { InsulinType(rawValue: $0) } - self.automatic = rawValue["automatic"] as? Bool - self.syncIdentifier = rawValue["syncIdentifier"] as? String - self.scheduledBasalRate = (rawValue["scheduledBasalRate"] as? Double).flatMap { HKQuantity(unit: .internationalUnitsPerHour, doubleValue: $0) } - self.isMutable = rawValue["isMutable"] as? Bool ?? false - self.wasProgrammedByPumpUI = rawValue["wasProgrammedByPumpUI"] as? Bool ?? false - } - - public var rawValue: [String: Any] { - var rawValue: [String: Any] = [ - "type": type.rawValue, - "startDate": startDate, - "endDate": endDate, - "value": value, - "unit": unit.rawValue, - "manuallyEntered": manuallyEntered, - "isMutable": isMutable, - "wasProgrammedByPumpUI": wasProgrammedByPumpUI - ] - - rawValue["deliveredUnits"] = deliveredUnits - rawValue["description"] = description - rawValue["insulinType"] = insulinType?.rawValue - rawValue["automatic"] = automatic - rawValue["syncIdentifier"] = syncIdentifier - rawValue["scheduledBasalRate"] = scheduledBasalRate?.doubleValue(for: .internationalUnitsPerHour) - - return rawValue - } -} - -public extension Array where Element == DoseEntry { - func trimmed(from start: Date? = nil, to end: Date? = nil, onlyTrimTempBasals: Bool = false) -> [DoseEntry] { - return self.compactMap { (dose) -> DoseEntry? in - if let start, dose.endDate < start { - return nil - } - if let end, dose.startDate > end { - return nil - } - if onlyTrimTempBasals && dose.type != .tempBasal { - return dose - } - return dose.trimmed(from: start, to: end) - } - } -} diff --git a/Sources/LoopAlgorithm/Insulin/DoseMath.swift b/Sources/LoopAlgorithm/Insulin/DoseMath.swift index adf7ee6..5e49d43 100644 --- a/Sources/LoopAlgorithm/Insulin/DoseMath.swift +++ b/Sources/LoopAlgorithm/Insulin/DoseMath.swift @@ -134,7 +134,7 @@ extension TempBasalRecommendation { public func ifNecessary( at date: Date, neutralBasalRate: Double, - lastTempBasal: DoseEntry?, + lastTempBasal: (any InsulinDose)?, continuationInterval: TimeInterval, neutralBasalRateMatchesPump: Bool ) -> TempBasalRecommendation? { diff --git a/Sources/LoopAlgorithm/Insulin/DoseType.swift b/Sources/LoopAlgorithm/Insulin/DoseType.swift index a119844..ba86f12 100644 --- a/Sources/LoopAlgorithm/Insulin/DoseType.swift +++ b/Sources/LoopAlgorithm/Insulin/DoseType.swift @@ -9,12 +9,9 @@ import Foundation /// A general set of ways insulin can be delivered by a pump -public enum DoseType: String, CaseIterable { - case basal +public enum InsulinDoseType: String, CaseIterable, Equatable { case bolus - case resume - case suspend case tempBasal } -extension DoseType: Codable {} +extension InsulinDoseType: Codable {} diff --git a/Sources/LoopAlgorithm/Insulin/FixtureInsulinDose.swift b/Sources/LoopAlgorithm/Insulin/FixtureInsulinDose.swift new file mode 100644 index 0000000..57f16db --- /dev/null +++ b/Sources/LoopAlgorithm/Insulin/FixtureInsulinDose.swift @@ -0,0 +1,47 @@ +// +// FixtureInsulinDose.swift +// +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation + +public struct FixtureInsulinDose: InsulinDose, Equatable { + public var type: InsulinDoseType + + public var startDate: Date + + public var endDate: Date + + public var volume: Double + + public var insulinType: InsulinType? +} + +extension FixtureInsulinDose: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.type = try container.decode(InsulinDoseType.self, forKey: .type) + self.startDate = try container.decode(Date.self, forKey: .startDate) + self.endDate = try container.decode(Date.self, forKey: .endDate) + self.volume = try container.decode(Double.self, forKey: .volume) + self.insulinType = try container.decodeIfPresent(InsulinType.self, forKey: .insulinType) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(type, forKey: .type) + try container.encode(startDate, forKey: .startDate) + try container.encode(endDate, forKey: .endDate) + try container.encode(volume, forKey: .volume) + try container.encodeIfPresent(insulinType, forKey: .insulinType) + } + + private enum CodingKeys: String, CodingKey { + case type + case startDate + case endDate + case volume + case insulinType + } +} diff --git a/Sources/LoopAlgorithm/Insulin/InsulinDose.swift b/Sources/LoopAlgorithm/Insulin/InsulinDose.swift new file mode 100644 index 0000000..1e0f1aa --- /dev/null +++ b/Sources/LoopAlgorithm/Insulin/InsulinDose.swift @@ -0,0 +1,28 @@ +// +// InsulinDose.swift +// +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation +import HealthKit + + +public protocol InsulinDose: TimelineValue { + var type: InsulinDoseType { get } + var startDate: Date { get } + var endDate: Date { get } + var volume: Double { get } + var insulinType: InsulinType? { get } +} + +extension InsulinDose { + var duration: TimeInterval { + return endDate.timeIntervalSince(startDate) + } + + var unitsPerHour: Double { + return volume / duration.hours + } + +} diff --git a/Sources/LoopAlgorithm/Insulin/InsulinMath.swift b/Sources/LoopAlgorithm/Insulin/InsulinMath.swift index 050cc25..c5ffbac 100644 --- a/Sources/LoopAlgorithm/Insulin/InsulinMath.swift +++ b/Sources/LoopAlgorithm/Insulin/InsulinMath.swift @@ -14,7 +14,7 @@ public struct InsulinMath { public static var longestInsulinActivityDuration: TimeInterval = TimeInterval(hours: 6) + TimeInterval(minutes: 10) } -extension DoseEntry { +extension BasalRelativeDose { private func continuousDeliveryInsulinOnBoard(at date: Date, model: InsulinModel, delta: TimeInterval) -> Double { let doseDuration = endDate.timeIntervalSince(startDate) // t1 let time = date.timeIntervalSince(startDate) @@ -104,48 +104,10 @@ extension DoseEntry { return netBasalUnits * -insulinSensitivity * continuousDeliveryGlucoseEffect(at: interval.end, model: model, delta: delta) } } - - - public func trimmed(from start: Date? = nil, to end: Date? = nil, syncIdentifier: String? = nil) -> DoseEntry { - - let originalDuration = endDate.timeIntervalSince(startDate) - - let startDate = max(start ?? .distantPast, self.startDate) - let endDate = max(startDate, min(end ?? .distantFuture, self.endDate)) - - var trimmedDeliveredUnits: Double? = deliveredUnits - var trimmedValue: Double = value - - if originalDuration > .ulpOfOne && (startDate > self.startDate || endDate < self.endDate) { - let updatedActualDelivery = unitsInDeliverableIncrements * (endDate.timeIntervalSince(startDate) / originalDuration) - if deliveredUnits != nil { - trimmedDeliveredUnits = updatedActualDelivery - } - if case .units = unit { - trimmedValue = updatedActualDelivery - } - } - - return DoseEntry( - type: type, - startDate: startDate, - endDate: endDate, - value: trimmedValue, - unit: unit, - deliveredUnits: trimmedDeliveredUnits, - description: description, - syncIdentifier: syncIdentifier, - scheduledBasalRate: scheduledBasalRate, - insulinType: insulinType, - automatic: automatic, - isMutable: isMutable, - wasProgrammedByPumpUI: wasProgrammedByPumpUI - ) - } } -extension DoseEntry { +extension InsulinDose { /// Annotates a dose with the context of a history of scheduled basal rates /// @@ -154,20 +116,22 @@ extension DoseEntry { /// /// - Parameter basalHistory: The history of basal schedule values to apply. Only schedule values overlapping the dose should be included. /// - Returns: An array of annotated doses - fileprivate func annotated(with basalHistory: [AbsoluteScheduleValue]) -> [DoseEntry] { + fileprivate func annotated(with basalHistory: [AbsoluteScheduleValue]) -> [BasalRelativeDose] { - var doses: [DoseEntry] = [] + guard type == .tempBasal else { + preconditionFailure("basalDeliveryTotal called on dose that is not a temp basal!") + } + + guard duration > .ulpOfOne else { + preconditionFailure("basalDeliveryTotal called on dose with no duration!") + } + + var doses: [BasalRelativeDose] = [] for (index, basalItem) in basalHistory.enumerated() { let startDate: Date let endDate: Date - // If we're splitting into multiple entries, keep the syncIdentifier unique - var syncIdentifier = self.syncIdentifier - if syncIdentifier != nil, basalHistory.count > 1 { - syncIdentifier! += " \(index + 1)/\(basalHistory.count)" - } - if index == 0 { startDate = self.startDate } else { @@ -180,61 +144,22 @@ extension DoseEntry { endDate = basalHistory[index + 1].startDate } - var dose = trimmed(from: startDate, to: endDate, syncIdentifier: syncIdentifier) + let segmentStartDate = max(startDate, self.startDate) + let segmentEndDate = max(startDate, min(endDate, self.endDate)) + let segmentDuration = segmentEndDate.timeIntervalSince(segmentStartDate) - dose.scheduledBasalRate = HKQuantity(unit: DoseEntry.unitsPerHour, doubleValue: basalItem.value) + let annotatedDose = BasalRelativeDose( + type: .tempBasal(scheduledRate: basalItem.value), + startDate: segmentStartDate, + endDate: segmentEndDate, + volume: volume * (segmentDuration / duration) + ) - doses.append(dose) + doses.append(annotatedDose) } return doses } - - /// Annotates a dose with the specified insulin type. - /// - /// - Parameter insulinType: The insulin type to annotate the dose with. - /// - Returns: A dose annotated with the insulin model - public func annotated(with insulinType: InsulinType) -> DoseEntry { - return DoseEntry( - type: type, - startDate: startDate, - endDate: endDate, - value: value, - unit: unit, - deliveredUnits: deliveredUnits, - description: description, - syncIdentifier: syncIdentifier, - scheduledBasalRate: scheduledBasalRate, - insulinType: insulinType, - automatic: automatic, - isMutable: isMutable, - wasProgrammedByPumpUI: wasProgrammedByPumpUI - ) - } -} - -extension DoseEntry { - fileprivate var resolvingDelivery: DoseEntry { - guard !isMutable, deliveredUnits == nil else { - return self - } - - let resolvedUnits: Double - - if case .units = unit { - resolvedUnits = value - } else { - switch type { - case .tempBasal: - resolvedUnits = unitsInDeliverableIncrements - case .basal: - resolvedUnits = programmedUnits - default: - return self - } - } - return DoseEntry(type: type, startDate: startDate, endDate: endDate, value: value, unit: unit, deliveredUnits: resolvedUnits, description: description, syncIdentifier: syncIdentifier, scheduledBasalRate: scheduledBasalRate, insulinType: insulinType, automatic: automatic, isMutable: isMutable, wasProgrammedByPumpUI: wasProgrammedByPumpUI) - } } extension Collection where Element: TimelineValue { @@ -258,138 +183,7 @@ extension Collection where Element: TimelineValue { } } -extension Collection where Element == DoseEntry { - - /** - Maps a timeline of dose entries with overlapping start and end dates to a timeline of doses that represents actual insulin delivery. - - - returns: An array of reconciled insulin delivery history, as TempBasal and Bolus records - */ - func reconciled() -> [DoseEntry] { - - var reconciled: [DoseEntry] = [] - - var lastSuspend: DoseEntry? - var lastBasal: DoseEntry? - - for dose in self { - switch dose.type { - case .bolus: - reconciled.append(dose) - case .basal, .tempBasal: - if lastSuspend == nil, let last = lastBasal { - let endDate = Swift.min(last.endDate, dose.startDate) - - // Ignore 0-duration doses - if endDate > last.startDate { - reconciled.append(last.trimmed(from: nil, to: endDate, syncIdentifier: last.syncIdentifier)) - } - } else if let suspend = lastSuspend, dose.type == .tempBasal { - // Handle missing resume. Basal following suspend, with no resume. - reconciled.append(DoseEntry( - type: suspend.type, - startDate: suspend.startDate, - endDate: dose.startDate, - value: suspend.value, - unit: suspend.unit, - description: suspend.description ?? dose.description, - syncIdentifier: suspend.syncIdentifier, - insulinType: suspend.insulinType, - automatic: suspend.automatic, - isMutable: suspend.isMutable, - wasProgrammedByPumpUI: suspend.wasProgrammedByPumpUI - )) - lastSuspend = nil - } - - lastBasal = dose - case .resume: - if let suspend = lastSuspend { - - reconciled.append(DoseEntry( - type: suspend.type, - startDate: suspend.startDate, - endDate: dose.startDate, - value: suspend.value, - unit: suspend.unit, - description: suspend.description ?? dose.description, - syncIdentifier: suspend.syncIdentifier, - insulinType: suspend.insulinType, - automatic: suspend.automatic, - isMutable: suspend.isMutable, - wasProgrammedByPumpUI: suspend.wasProgrammedByPumpUI - )) - - lastSuspend = nil - - // Continue temp basals that may have started before suspending - if let last = lastBasal { - if last.endDate > dose.endDate { - lastBasal = DoseEntry( - type: last.type, - startDate: dose.endDate, - endDate: last.endDate, - value: last.value, - unit: last.unit, - description: last.description, - // We intentionally use the resume's identifier, as the basal entry has already been entered - syncIdentifier: dose.syncIdentifier, - insulinType: last.insulinType, - automatic: last.automatic, - isMutable: last.isMutable, - wasProgrammedByPumpUI: last.wasProgrammedByPumpUI - ) - } else { - lastBasal = nil - } - } - } - case .suspend: - if let last = lastBasal { - - reconciled.append(DoseEntry( - type: last.type, - startDate: last.startDate, - endDate: Swift.min(last.endDate, dose.startDate), - value: last.value, - unit: last.unit, - description: last.description, - syncIdentifier: last.syncIdentifier, - insulinType: last.insulinType, - automatic: last.automatic, - isMutable: last.isMutable, - wasProgrammedByPumpUI: last.wasProgrammedByPumpUI - )) - - if last.endDate <= dose.startDate { - lastBasal = nil - } - } - - lastSuspend = dose - } - } - - if let suspend = lastSuspend { - reconciled.append(DoseEntry( - type: suspend.type, - startDate: suspend.startDate, - endDate: nil, - value: suspend.value, - unit: suspend.unit, - description: suspend.description, - syncIdentifier: suspend.syncIdentifier, - insulinType: suspend.insulinType, - automatic: suspend.automatic, - isMutable: true, // Consider mutable until paired resume - wasProgrammedByPumpUI: suspend.wasProgrammedByPumpUI - )) - } else if let last = lastBasal, last.endDate > last.startDate { - reconciled.append(last) - } - - return reconciled.map { $0.resolvingDelivery } - } +extension Collection where Element: InsulinDose { /// Annotates a sequence of dose entries with the configured basal history /// @@ -397,28 +191,23 @@ extension Collection where Element == DoseEntry { /// /// - Parameter basalSchedule: A history of basal rates covering the timespan of these doses. /// - Returns: An array of annotated dose entries - public func annotated(with basalHistory: [AbsoluteScheduleValue]) -> [DoseEntry] { - var annotatedDoses: [DoseEntry] = [] + public func annotated(with basalHistory: [AbsoluteScheduleValue]) -> [BasalRelativeDose] { + var annotatedDoses: [BasalRelativeDose] = [] for dose in self { - let basalItems = basalHistory.filterDateRange(dose.startDate, dose.endDate) - annotatedDoses += dose.annotated(with: basalItems) + if dose.type == .tempBasal { + let basalItems = basalHistory.filterDateRange(dose.startDate, dose.endDate) + annotatedDoses += dose.annotated(with: basalItems) + } else { + annotatedDoses.append(BasalRelativeDose.fromBolus(dose: dose)) + } } return annotatedDoses } +} - - /** - Calculates the total insulin delivery for a collection of doses - - - returns: The total insulin insulin, in Units - */ - var totalDelivery: Double { - return reduce(0) { (total, dose) -> Double in - return total + dose.unitsInDeliverableIncrements - } - } +extension Collection where Element == BasalRelativeDose { /** Calculates the timeline of insulin remaining for a collection of doses @@ -631,115 +420,4 @@ extension Collection where Element == DoseEntry { return values } - - /// Fills any missing gaps in basal delivery with new doses based on the supplied basal history. Compared to `overlayBasalSchedule`, this uses a history of - /// of basal rates, rather than a daily schedule, so it can work across multiple schedule changes. This method is suitable for generating a display of basal delivery - /// that includes scheduled and temp basals. Boluses are not included in the returned array. - /// - /// - Parameters: - /// - basalHistory: A history of scheduled basal rates. The first record should have a timestamp matching or earlier than the start date of the first DoseEntry in this array. - /// - endDate: Infill to this date, if supplied. If not supplied, infill will stop at the last DoseEntry. - /// - gapPatchInterval: if the gap between two temp basals is less than this, then the start date of the second dose will be fudged to fill the gap. Used for display purposes. - /// - Returns: An array of doses, with new doses created for any gaps between basalHistory.first.startDate and the end date. - public func infill(with basalHistory: [AbsoluteScheduleValue], endDate: Date? = nil, gapPatchInterval: TimeInterval = 0) -> [DoseEntry] { - guard basalHistory.count > 0 else { - return Array(self) - } - - var newEntries = [DoseEntry]() - var curBasalIdx = basalHistory.startIndex - var lastDate = basalHistory[curBasalIdx].startDate - - func addBasalsBetween(startDate: Date, endDate: Date) { - while lastDate < endDate { - let entryEnd: Date - let nextBasalIdx = curBasalIdx + 1 - let curRate = basalHistory[curBasalIdx].value - if nextBasalIdx < basalHistory.endIndex && basalHistory[nextBasalIdx].startDate < endDate { - entryEnd = Swift.max(startDate, basalHistory[nextBasalIdx].startDate) - curBasalIdx = nextBasalIdx - } else { - entryEnd = endDate - } - - if lastDate != entryEnd { - newEntries.append( - DoseEntry( - type: .basal, - startDate: lastDate, - endDate: entryEnd, - value: curRate, - unit: .unitsPerHour)) - - lastDate = entryEnd - } - } - } - - for dose in self { - switch dose.type { - case .tempBasal, .basal, .suspend: - var doseStart = dose.startDate - if doseStart.timeIntervalSince(lastDate) > gapPatchInterval { - addBasalsBetween(startDate: lastDate, endDate: dose.startDate) - } else { - doseStart = lastDate - } - newEntries.append(DoseEntry( - type: dose.type, - startDate: doseStart, - endDate: dose.endDate, - value: dose.unitsPerHour, - unit: .unitsPerHour) - ) - lastDate = dose.endDate - case .resume: - assertionFailure("No resume events should be present in reconciled doses") - case .bolus: - break - } - } - - if let endDate, endDate > lastDate { - addBasalsBetween(startDate: lastDate, endDate: endDate) - } - - return newEntries - } - - /// Creates an array of DoseEntry values by unioning another array, de-duplicating by syncIdentifier - /// - /// - Parameter otherDoses: An array of doses to union - /// - Returns: A new array of doses - func appendedUnion(with otherDoses: [DoseEntry]) -> [DoseEntry] { - var union: [DoseEntry] = [] - var syncIdentifiers: Set = [] - - for dose in (self + otherDoses) { - if let syncIdentifier = dose.syncIdentifier { - let (inserted, _) = syncIdentifiers.insert(syncIdentifier) - if !inserted { - continue - } - } - - union.append(dose) - } - - return union - } -} - - -extension BidirectionalCollection where Element == DoseEntry { - /// The endDate of the last basal dose in the collection - var lastBasalEndDate: Date? { - for dose in self.reversed() { - if dose.type == .basal || dose.type == .tempBasal || dose.type == .resume { - return dose.endDate - } - } - - return nil - } } diff --git a/Sources/LoopAlgorithm/Insulin/InsulinModelProvider.swift b/Sources/LoopAlgorithm/Insulin/InsulinModelProvider.swift index f99aca8..ebffca2 100644 --- a/Sources/LoopAlgorithm/Insulin/InsulinModelProvider.swift +++ b/Sources/LoopAlgorithm/Insulin/InsulinModelProvider.swift @@ -1,6 +1,5 @@ // // InsulinModelProvider.swift -// LoopKit // // Copyright © 2017 LoopKit Authors. All rights reserved. // diff --git a/Sources/LoopAlgorithm/Insulin/RelativeDelivery.swift b/Sources/LoopAlgorithm/Insulin/RelativeDelivery.swift new file mode 100644 index 0000000..63ee754 --- /dev/null +++ b/Sources/LoopAlgorithm/Insulin/RelativeDelivery.swift @@ -0,0 +1,56 @@ +// +// BasalRelativeDose.swift +// +// +// Created by Pete Schwamb on 12/21/23. +// Copyright © 2023 LoopKit Authors. All rights reserved. +// + +import Foundation + +public enum BasalRelativeDoseType { + case bolus + case tempBasal(scheduledRate: Double) +} + +public struct BasalRelativeDose: TimelineValue { + public var type: BasalRelativeDoseType + public var startDate: Date + public var endDate: Date + public var volume: Double + public var insulinType: InsulinType? + + var duration: TimeInterval { + return endDate.timeIntervalSince(startDate) + } +} + +extension BasalRelativeDose { + /// The number of units delivered, net the basal rate scheduled during that time, which can be used to compute insulin on-board and glucose effects + public var netBasalUnits: Double { + + if case .tempBasal(let scheduledRate) = type { + guard duration.hours > 0 else { + return 0 + } + let scheduledUnits = scheduledRate * duration.hours + return volume - scheduledUnits + } else { + return volume + } + } +} + +extension BasalRelativeDose { + static func fromBolus(dose: InsulinDose) -> BasalRelativeDose { + precondition(dose.type == .bolus, "Dose passed to fromBolus() must be a bolus.") + + return BasalRelativeDose( + type: .bolus, + startDate: dose.startDate, + endDate: dose.endDate, + volume: dose.volume, + insulinType: dose.insulinType + ) + } +} diff --git a/Sources/LoopAlgorithm/LoopAlgorithm.swift b/Sources/LoopAlgorithm/LoopAlgorithm.swift index 305691d..479d28b 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithm.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithm.swift @@ -65,7 +65,7 @@ public struct AlgorithmEffectsOptions: OptionSet { public struct LoopPrediction { public var glucose: [PredictedGlucoseValue] public var effects: LoopAlgorithmEffects - public var dosesRelativeToBasal: [DoseEntry] + public var dosesRelativeToBasal: [BasalRelativeDose] public var activeInsulin: Double? public var activeCarbs: Double? } @@ -100,10 +100,10 @@ public struct LoopAlgorithm { /// - carbAbsorptionModel: A model conforming to CarbAbsorptionComputable that is used for computing carb absorption over time. /// - Returns: A LoopPrediction struct containing the predicted glucose and the computed intermediate effects used to make the prediction - public static func generatePrediction( + public static func generatePrediction( start: Date, glucoseHistory: [GlucoseType], - doses: [DoseEntry], + doses: [InsulinDoseType], carbEntries: [CarbType], basal: [AbsoluteScheduleValue], sensitivity: [AbsoluteScheduleValue], @@ -112,7 +112,7 @@ public struct LoopAlgorithm { useIntegralRetrospectiveCorrection: Bool = false, includingPositiveVelocityAndRC: Bool = true, carbAbsorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption() - ) -> LoopPrediction where CarbType: CarbEntry, GlucoseType: GlucoseSampleValue { + ) -> LoopPrediction where CarbType: CarbEntry, GlucoseType: GlucoseSampleValue, InsulinDoseType: InsulinDose { var prediction: [PredictedGlucoseValue] = [] var insulinEffects: [GlucoseEffect] = [] @@ -125,7 +125,7 @@ public struct LoopAlgorithm { var activeInsulin: Double? var activeCarbs: Double? var carbStatus: [CarbStatus] = [] - var dosesRelativeToBasal: [DoseEntry] = [] + var dosesRelativeToBasal: [BasalRelativeDose] = [] // Ensure basal history covers doses if let doseStart = doses.first?.startDate, !basal.isEmpty, basal.first!.startDate <= doseStart { @@ -248,7 +248,7 @@ public struct LoopAlgorithm { } // Helper to generate prediction with LoopPredictionInput struct - public static func generatePrediction(input: LoopPredictionInput) -> LoopPrediction { + public static func generatePrediction(input: LoopPredictionInput) -> LoopPrediction { return generatePrediction( start: input.glucoseHistory.last?.startDate ?? Date(), @@ -374,7 +374,7 @@ public struct LoopAlgorithm { return bolus } - public static func recommendDose(input: LoopAlgorithmInput) throws -> LoopAlgorithmDoseRecommendation { + public static func recommendDose(input: LoopAlgorithmInput) throws -> LoopAlgorithmDoseRecommendation { let output = run(input: input) switch output.recommendationResult { case .success(let recommendation): @@ -384,16 +384,16 @@ public struct LoopAlgorithm { } } - public static func run(input: LoopAlgorithmInput, effectOptions: AlgorithmEffectsOptions = .all) -> LoopAlgorithmOutput { + public static func run(input: LoopAlgorithmInput, effectOptions: AlgorithmEffectsOptions = .all) -> LoopAlgorithmOutput { // If we're running for automated dosing, we calculate a dose assuming that the current temp basal will be canceled - let inputDoses: [DoseEntry] - if input.recommendationType.automated { - inputDoses = input.doses.trimmed(to: input.predictionStart, onlyTrimTempBasals: true) - } else { - inputDoses = input.doses - } + // TODO: we can change the effects to not use future delivery, instead of having to modify the array +// if input.recommendationType.automated { +// inputDoses = input.doses.trimmed(to: input.predictionStart, onlyTrimTempBasals: true) +// } else { +// inputDoses = input.doses +// } // `generatePrediction` does a best-try to generate a prediction and associated effects. // Outputs may be incomplete, if there are issues with the provided data. @@ -403,7 +403,7 @@ public struct LoopAlgorithm { let prediction = generatePrediction( start: input.predictionStart, glucoseHistory: input.glucoseHistory, - doses: inputDoses, + doses: input.doses, carbEntries: input.carbEntries, basal: input.basal, sensitivity: input.sensitivity, diff --git a/Sources/LoopAlgorithm/LoopAlgorithmInput.swift b/Sources/LoopAlgorithm/LoopAlgorithmInput.swift index 52862fa..bb4187a 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithmInput.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithmInput.swift @@ -16,10 +16,10 @@ public enum AlgorithmInputDecodingError: Error { case doseVolumeMissing } -public struct LoopAlgorithmInput { +public struct LoopAlgorithmInput { public var predictionStart: Date public var glucoseHistory: [GlucoseType] - public var doses: [DoseEntry] + public var doses: [InsulinDoseType] public var carbEntries: [CarbType] public var basal: [AbsoluteScheduleValue] public var sensitivity: [AbsoluteScheduleValue] @@ -42,30 +42,16 @@ public struct LoopAlgorithmInput], sensitivity: [AbsoluteScheduleValue], @@ -128,7 +114,7 @@ extension LoopAlgorithmInput.Glucose: Codable { } -extension LoopAlgorithmInput: Codable where CarbType == FixtureCarbEntry, GlucoseType == FixtureGlucoseSample { +extension LoopAlgorithmInput: Codable where CarbType == FixtureCarbEntry, GlucoseType == FixtureGlucoseSample, InsulinDoseType == FixtureInsulinDose { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -141,33 +127,8 @@ extension LoopAlgorithmInput: Codable where CarbType == FixtureCarbEntry, Glucos isDisplayOnly: sample.isCalibration ) } - let doses = try container.decode([Dose].self, forKey: .doses) - self.doses = try doses.map({ dose in - let value: Double - let unit: DoseUnit - switch dose.type { - case .basal, .tempBasal, .bolus: - guard let decodedVolume = dose.volume else { - throw AlgorithmInputDecodingError.doseVolumeMissing - } - value = decodedVolume - unit = .units - default: - value = 0 - unit = .units - break - } - let insulinType: InsulinType? - if let insulinTypeIdentifier = dose.insulinType { - guard let decodedInsulinType = InsulinType(with: insulinTypeIdentifier) else { - throw AlgorithmInputDecodingError.invalidInsulinType - } - insulinType = decodedInsulinType - } else { - insulinType = nil - } - return DoseEntry(type: dose.type, startDate: dose.startDate, endDate: dose.endDate, value: value, unit: unit, insulinType: insulinType) - }) + + 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) @@ -219,26 +180,7 @@ extension LoopAlgorithmInput: Codable where CarbType == FixtureCarbEntry, Glucos date: sample.startDate) } try container.encode(glucose, forKey: .glucoseHistory) - let doses = doses.map { dose in - switch dose.type { - case .basal, .tempBasal, .bolus: - return Dose( - startDate: dose.startDate, - endDate: dose.endDate, - volume: dose.deliveredUnits ?? dose.programmedUnits, - type: dose.type, - insulinType: dose.insulinType?.identifierForAlgorithmInput) - default: - return Dose(startDate: dose.startDate, endDate: dose.endDate, type: dose.type, insulinType: dose.insulinType?.identifierForAlgorithmInput) - } - } try container.encode(doses, forKey: .doses) - let carbEntries = carbEntries.map { entry in - CarbEntry( - grams: entry.quantity.doubleValue(for: .gram()), - absorptionTime: entry.absorptionTime, - date: entry.startDate) - } 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)) } @@ -326,8 +268,8 @@ extension InsulinType { extension LoopAlgorithmInput { - var simplifiedForFixture: LoopAlgorithmInput { - return LoopAlgorithmInput( + var simplifiedForFixture: LoopAlgorithmInput { + return LoopAlgorithmInput( predictionStart: predictionStart, glucoseHistory: glucoseHistory.map { FixtureGlucoseSample( @@ -337,7 +279,15 @@ extension LoopAlgorithmInput { isDisplayOnly: $0.isDisplayOnly ) }, - doses: doses, + doses: doses.map({ + FixtureInsulinDose( + type: $0.type, + startDate: $0.startDate, + endDate: $0.endDate, + volume: $0.volume, + insulinType: $0.insulinType + ) + }), carbEntries: carbEntries.map { FixtureCarbEntry( absorptionTime: $0.absorptionTime, diff --git a/Sources/LoopAlgorithm/LoopAlgorithmOutput.swift b/Sources/LoopAlgorithm/LoopAlgorithmOutput.swift index 7a6c66f..c15f714 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithmOutput.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithmOutput.swift @@ -12,7 +12,7 @@ public struct LoopAlgorithmOutput { public var recommendationResult: Result public var predictedGlucose: [PredictedGlucoseValue] public var effects: LoopAlgorithmEffects - public var dosesRelativeToBasal: [DoseEntry] + public var dosesRelativeToBasal: [BasalRelativeDose] public var activeInsulin: Double? public var activeCarbs: Double? @@ -20,7 +20,7 @@ public struct LoopAlgorithmOutput { recommendationResult: Result, predictedGlucose: [PredictedGlucoseValue], effects: LoopAlgorithmEffects, - dosesRelativeToBasal: [DoseEntry], + dosesRelativeToBasal: [BasalRelativeDose], activeInsulin: Double? = nil, activeCarbs: Double? = nil ) { diff --git a/Sources/LoopAlgorithm/LoopPredictionInput.swift b/Sources/LoopAlgorithm/LoopPredictionInput.swift index 0c1bab2..f952c8f 100644 --- a/Sources/LoopAlgorithm/LoopPredictionInput.swift +++ b/Sources/LoopAlgorithm/LoopPredictionInput.swift @@ -9,12 +9,12 @@ import Foundation import HealthKit -public struct LoopPredictionInput { +public struct LoopPredictionInput { // Algorithm input time range: t-10h to t public var glucoseHistory: [GlucoseType] // Algorithm input time range: t-16h to t - public var doses: [DoseEntry] + public var doses: [InsulinDoseType] // Algorithm input time range: t-10h to t public var carbEntries: [CarbType] @@ -38,7 +38,7 @@ public struct LoopPredictionInput], sensitivity: [AbsoluteScheduleValue], @@ -61,12 +61,12 @@ public struct LoopPredictionInput].self, forKey: .basal) let sensitivityMgdl = try container.decode([AbsoluteScheduleValue].self, forKey: .sensitivity) @@ -117,8 +117,8 @@ extension LoopPredictionInput: Codable where CarbType == FixtureCarbEntry, Gluco extension LoopPredictionInput { - var simplifiedForFixture: LoopPredictionInput { - return LoopPredictionInput( + var simplifiedForFixture: LoopPredictionInput { + return LoopPredictionInput( glucoseHistory: glucoseHistory.map { return FixtureGlucoseSample( startDate: $0.startDate, @@ -126,7 +126,13 @@ extension LoopPredictionInput { isDisplayOnly: $0.isDisplayOnly) }, doses: doses.map { - DoseEntry(type: $0.type, startDate: $0.startDate, endDate: $0.endDate, value: $0.value, unit: $0.unit) + FixtureInsulinDose( + type: $0.type == .bolus ? .bolus : .tempBasal, + startDate: $0.startDate, + endDate: $0.endDate, + volume: $0.volume, + insulinType: $0.insulinType + ) }, carbEntries: carbEntries.map { return FixtureCarbEntry( diff --git a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift index 37e2fd8..618ec41 100644 --- a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift +++ b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift @@ -11,7 +11,7 @@ import XCTest final class LoopAlgorithmTests: XCTestCase { - func loadScenario(_ name: String) -> (input: LoopAlgorithmInput, recommendation: LoopAlgorithmDoseRecommendation) { + func loadScenario(_ name: String) -> (input: LoopAlgorithmInput, recommendation: LoopAlgorithmDoseRecommendation) { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 var url = Bundle.module.url(forResource: name + "_input", withExtension: "json", subdirectory: "Fixtures")! From 37a432c66c81ada7b653c8da9f2a55f903288e11 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 3 Jan 2024 13:18:17 -0600 Subject: [PATCH 05/26] Cleanup and add more tests --- .periphery.yml | 1 + .../Carbs/AbsorbedCarbValue.swift | 15 --- Sources/LoopAlgorithm/Carbs/CarbMath.swift | 110 ------------------ .../Extensions/ClosedRange.swift | 12 -- Sources/LoopAlgorithm/Extensions/Double.swift | 8 -- Sources/LoopAlgorithm/Extensions/HKUnit.swift | 20 ---- .../LoopAlgorithm/Extensions/Sequence.swift | 19 --- ...ample.swift => FixtureGlucoseSample.swift} | 2 +- .../Glucose/GlucoseCondition.swift | 11 -- .../LoopAlgorithm/Glucose/GlucoseEffect.swift | 27 ----- .../Glucose/GlucoseEffectVelocity.swift | 22 ---- .../LoopAlgorithm/Glucose/GlucoseRange.swift | 78 ------------- .../LoopAlgorithm/Glucose/GlucoseTrend.swift | 83 ------------- Sources/LoopAlgorithm/Insulin/DoseMath.swift | 50 -------- .../LoopAlgorithm/Insulin/InsulinValue.swift | 14 --- Sources/LoopAlgorithm/LoopAlgorithm.swift | 57 +++++---- Sources/LoopAlgorithm/LoopMath.swift | 32 ----- Sources/LoopAlgorithm/SampleValue.swift | 29 ----- .../TempBasalRecommendation.swift | 5 - .../LoopAlgorithmInputMocks.swift | 33 ------ .../LoopAlgorithmTests.swift | 79 +++++++++++++ .../Mocks/LoopAlgorithmInputMock.swift | 54 +++++++++ 22 files changed, 168 insertions(+), 593 deletions(-) create mode 100644 .periphery.yml delete mode 100644 Sources/LoopAlgorithm/Extensions/Sequence.swift rename Sources/LoopAlgorithm/Glucose/{StoredGlucoseSample.swift => FixtureGlucoseSample.swift} (98%) delete mode 100644 Sources/LoopAlgorithm/Glucose/GlucoseCondition.swift delete mode 100644 Sources/LoopAlgorithm/Glucose/GlucoseRange.swift delete mode 100644 Sources/LoopAlgorithm/Glucose/GlucoseTrend.swift delete mode 100644 Tests/LoopAlgorithmTests/LoopAlgorithmInputMocks.swift create mode 100644 Tests/LoopAlgorithmTests/Mocks/LoopAlgorithmInputMock.swift diff --git a/.periphery.yml b/.periphery.yml new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/.periphery.yml @@ -0,0 +1 @@ +{} diff --git a/Sources/LoopAlgorithm/Carbs/AbsorbedCarbValue.swift b/Sources/LoopAlgorithm/Carbs/AbsorbedCarbValue.swift index 8896d18..0060b41 100644 --- a/Sources/LoopAlgorithm/Carbs/AbsorbedCarbValue.swift +++ b/Sources/LoopAlgorithm/Carbs/AbsorbedCarbValue.swift @@ -53,21 +53,6 @@ public struct AbsorbedCarbValue: SampleValue { ) } - public var clampedProgress: HKQuantity { - let gram = HKUnit.gram() - let totalGrams = total.doubleValue(for: gram) - let percent = HKUnit.percent() - - guard totalGrams > 0 else { - return HKQuantity(unit: percent, doubleValue: 0) - } - - return HKQuantity( - unit: percent, - doubleValue: clamped.doubleValue(for: gram) / totalGrams - ) - } - // MARK: SampleValue public var quantity: HKQuantity { diff --git a/Sources/LoopAlgorithm/Carbs/CarbMath.swift b/Sources/LoopAlgorithm/Carbs/CarbMath.swift index 6948bea..c6feacf 100644 --- a/Sources/LoopAlgorithm/Carbs/CarbMath.swift +++ b/Sources/LoopAlgorithm/Carbs/CarbMath.swift @@ -30,20 +30,6 @@ public enum CarbAbsorptionModel { } } -public struct CarbModelSettings { - var absorptionModel: CarbAbsorptionComputable - var initialAbsorptionTimeOverrun: Double - var adaptiveAbsorptionRateEnabled: Bool - var adaptiveRateStandbyIntervalFraction: Double - - init(absorptionModel: CarbAbsorptionComputable, initialAbsorptionTimeOverrun: Double, adaptiveAbsorptionRateEnabled: Bool, adaptiveRateStandbyIntervalFraction: Double = 0.2) { - self.absorptionModel = absorptionModel - self.initialAbsorptionTimeOverrun = initialAbsorptionTimeOverrun - self.adaptiveAbsorptionRateEnabled = adaptiveAbsorptionRateEnabled - self.adaptiveRateStandbyIntervalFraction = adaptiveRateStandbyIntervalFraction - } -} - public protocol CarbAbsorptionComputable { /// Returns the percentage of total carbohydrates absorbed as blood glucose at a specified interval after eating. /// @@ -119,49 +105,6 @@ extension CarbAbsorptionComputable { } - -// MARK: - Parabolic absorption as described by Scheiner -// This is the integral approximation of the Scheiner GI curve found in Think Like a Pancreas, Fig 7-8, which first appeared in [GlucoDyn](https://github.com/kenstack/GlucoDyn) -struct ParabolicAbsorption: CarbAbsorptionComputable { - func percentAbsorptionAtPercentTime(_ percentTime: Double) -> Double { - switch percentTime { - case let t where t <= 0.0: - return 0.0 - case let t where t <= 0.5: - return 2.0 * pow(t, 2) - case let t where t < 1.0: - return -1.0 + 2.0 * t * (2.0 - t) - default: - return 1.0 - } - } - - func percentTimeAtPercentAbsorption(_ percentAbsorption: Double) -> Double { - switch percentAbsorption { - case let a where a <= 0: - return 0.0 - case let a where a <= 0.5: - return sqrt(0.5 * a) - case let a where a < 1.0: - return 1.0 - sqrt(0.5 * (1.0 - a)) - default: - return 1.0 - } - } - - func percentRateAtPercentTime(_ percentTime: Double) -> Double { - switch percentTime { - case let t where t > 0 && t <= 0.5: - return 4.0 * t - case let t where t > 0.5 && t < 1.0: - return 4.0 - 4.0 * t - default: - return 0.0 - } - } -} - - // MARK: - Linear absorption as a factor of reported duration struct LinearAbsorption: CarbAbsorptionComputable { func percentAbsorptionAtPercentTime(_ percentTime: Double) -> Double { @@ -300,12 +243,6 @@ extension CarbEntry { ) -> Double { return insulinSensitivity.doubleValue(for: HKUnit.milligramsPerDeciliter) / carbRatio.doubleValue(for: .gram()) * absorbedCarbs(at: date, absorptionTime: absorptionTime ?? defaultAbsorptionTime, delay: delay, absorptionModel: absorptionModel) } - - fileprivate func estimatedAbsorptionTime(forAbsorbedCarbs carbs: Double, at date: Date, absorptionModel: CarbAbsorptionComputable) -> TimeInterval { - let time = date.timeIntervalSince(startDate) - - return max(time, absorptionModel.absorptionTime(forPercentAbsorption: carbs / quantity.doubleValue(for: .gram()), atTime: time)) - } } extension Collection where Element: CarbEntry { @@ -393,26 +330,6 @@ extension Collection where Element: CarbEntry { return values } - - var totalCarbs: CarbValue? { - guard count > 0 else { - return nil - } - - let unit = HKUnit.gram() - var startDate = Date.distantFuture - var totalGrams: Double = 0 - - for entry in self { - totalGrams += entry.quantity.doubleValue(for: unit) - - if entry.startDate < startDate { - startDate = entry.startDate - } - } - - return CarbValue(startDate: startDate, value: totalGrams) - } } @@ -511,28 +428,6 @@ extension Collection { return values } - - /// The quantity of carbs expected to still absorb at the last date of absorption - public func getClampedCarbsOnBoard() -> CarbValue? where Element == CarbStatus { - guard let firstAbsorption = first?.absorption else { - return nil - } - - let gram = HKUnit.gram() - var maxObservedEndDate = firstAbsorption.observedDate.end - var remainingTotalGrams: Double = 0 - - for entry in self { - guard let absorption = entry.absorption else { - continue - } - - maxObservedEndDate = Swift.max(maxObservedEndDate, absorption.observedDate.end) - remainingTotalGrams += absorption.remaining.doubleValue(for: gram) - } - - return CarbValue(startDate: maxObservedEndDate, value: remainingTotalGrams) - } } @@ -590,11 +485,6 @@ fileprivate class CarbStatusBuilder { /// The last date we have effects observed, or "now" in real-time analysis. private let lastEffectDate: Date - /// The minimum-required carb absorption rate for this entry, in g/s - var minAbsorptionRate: Double { - return entryGrams / maxAbsorptionTime - } - /// The minimum amount of carbs we assume must have absorbed at the last observation date private var minPredictedGrams: Double { // We incorporate a delay when calculating minimum absorption values diff --git a/Sources/LoopAlgorithm/Extensions/ClosedRange.swift b/Sources/LoopAlgorithm/Extensions/ClosedRange.swift index aef5434..b7bea3c 100644 --- a/Sources/LoopAlgorithm/Extensions/ClosedRange.swift +++ b/Sources/LoopAlgorithm/Extensions/ClosedRange.swift @@ -7,18 +7,6 @@ // import HealthKit -extension ClosedRange { - func expandedToInclude(_ value: Bound) -> ClosedRange { - if value < lowerBound { - return value...upperBound - } else if value > upperBound { - return lowerBound...value - } else { - return self - } - } -} - extension ClosedRange where Bound == HKQuantity { public func averageValue(for unit: HKUnit) -> Double { let minValue = lowerBound.doubleValue(for: unit) diff --git a/Sources/LoopAlgorithm/Extensions/Double.swift b/Sources/LoopAlgorithm/Extensions/Double.swift index 349c65a..5075c45 100644 --- a/Sources/LoopAlgorithm/Extensions/Double.swift +++ b/Sources/LoopAlgorithm/Extensions/Double.swift @@ -19,11 +19,3 @@ extension Double: RawRepresentable { return self } } - -infix operator =~ : ComparisonPrecedence - - extension Double { - static func =~ (lhs: Double, rhs: Double) -> Bool { - return fabs(lhs - rhs) < Double.ulpOfOne - } - } diff --git a/Sources/LoopAlgorithm/Extensions/HKUnit.swift b/Sources/LoopAlgorithm/Extensions/HKUnit.swift index 2b8d760..ade4708 100644 --- a/Sources/LoopAlgorithm/Extensions/HKUnit.swift +++ b/Sources/LoopAlgorithm/Extensions/HKUnit.swift @@ -22,10 +22,6 @@ extension HKUnit { return HKUnit.milligramsPerDeciliter.unitDivided(by: .minute()) }() - static let millimolesPerLiterPerMinute: HKUnit = { - return HKUnit.millimolesPerLiter.unitDivided(by: .minute()) - }() - static let internationalUnitsPerHour: HKUnit = { return HKUnit.internationalUnit().unitDivided(by: .hour()) }() @@ -33,21 +29,5 @@ extension HKUnit { static let gramsPerUnit: HKUnit = { return HKUnit.gram().unitDivided(by: .internationalUnit()) }() - - var foundationUnit: Unit? { - if self == HKUnit.milligramsPerDeciliter { - return UnitConcentrationMass.milligramsPerDeciliter - } - - if self == HKUnit.millimolesPerLiter { - return UnitConcentrationMass.millimolesPerLiter(withGramsPerMole: HKUnitMolarMassBloodGlucose) - } - - if self == HKUnit.gram() { - return UnitMass.grams - } - - return nil - } } diff --git a/Sources/LoopAlgorithm/Extensions/Sequence.swift b/Sources/LoopAlgorithm/Extensions/Sequence.swift deleted file mode 100644 index 190976c..0000000 --- a/Sources/LoopAlgorithm/Extensions/Sequence.swift +++ /dev/null @@ -1,19 +0,0 @@ -// -// Sequence.swift -// LoopKit -// -// Created by Michael Pangburn on 6/23/20. -// Copyright © 2020 LoopKit Authors. All rights reserved. -// - -extension Sequence { - func range(of metricForElement: (Element) throws -> Metric) rethrows -> ClosedRange? { - try lazy.map(metricForElement).reduce(nil) { range, metric in - if let range = range { - return range.expandedToInclude(metric) - } else { - return metric...metric - } - } - } -} diff --git a/Sources/LoopAlgorithm/Glucose/StoredGlucoseSample.swift b/Sources/LoopAlgorithm/Glucose/FixtureGlucoseSample.swift similarity index 98% rename from Sources/LoopAlgorithm/Glucose/StoredGlucoseSample.swift rename to Sources/LoopAlgorithm/Glucose/FixtureGlucoseSample.swift index 666ccbd..20ad8a1 100644 --- a/Sources/LoopAlgorithm/Glucose/StoredGlucoseSample.swift +++ b/Sources/LoopAlgorithm/Glucose/FixtureGlucoseSample.swift @@ -1,5 +1,5 @@ // -// StoredGlucoseSample.swift +// FixtureGlucoseSample.swift // LoopKit // // Copyright © 2018 LoopKit Authors. All rights reserved. diff --git a/Sources/LoopAlgorithm/Glucose/GlucoseCondition.swift b/Sources/LoopAlgorithm/Glucose/GlucoseCondition.swift deleted file mode 100644 index a1e140e..0000000 --- a/Sources/LoopAlgorithm/Glucose/GlucoseCondition.swift +++ /dev/null @@ -1,11 +0,0 @@ -// -// GlucoseCondition.swift -// -// 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 ec31731..3532af8 100644 --- a/Sources/LoopAlgorithm/Glucose/GlucoseEffect.swift +++ b/Sources/LoopAlgorithm/Glucose/GlucoseEffect.swift @@ -44,30 +44,3 @@ extension GlucoseEffect: Comparable { return lhs.startDate < rhs.startDate } } - -public extension Array where Element == GlucoseEffect { - func asVelocities() -> [GlucoseEffectVelocity] { - guard count > 1 else { - return [] - } - let unit = HKUnit.milligramsPerDeciliter - var previousEffectValue: Double = first!.quantity.doubleValue(for: unit) - var previousEffectDate: Date = first!.startDate - - var velocities = [GlucoseEffectVelocity]() - - for effect in dropFirst() { - let value = effect.quantity.doubleValue(for: unit) - let delta = value - previousEffectValue - let timespan = effect.startDate.timeIntervalSince(previousEffectDate).minutes - let velocity = delta / timespan - - velocities.append(GlucoseEffectVelocity(startDate: previousEffectDate, endDate: effect.startDate, quantity: HKQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: velocity))) - - previousEffectValue = value - previousEffectDate = effect.startDate - } - - return velocities - } -} diff --git a/Sources/LoopAlgorithm/Glucose/GlucoseEffectVelocity.swift b/Sources/LoopAlgorithm/Glucose/GlucoseEffectVelocity.swift index 391e190..a2be034 100644 --- a/Sources/LoopAlgorithm/Glucose/GlucoseEffectVelocity.swift +++ b/Sources/LoopAlgorithm/Glucose/GlucoseEffectVelocity.swift @@ -38,26 +38,4 @@ extension GlucoseEffectVelocity { ) ) } - - /// The integration of the velocity span from `start` to `end` - public func effect(from start: Date, to end: Date) -> GlucoseEffect? { - guard - start <= end, - startDate <= start, - end <= endDate - else { - return nil - } - - let duration = end.timeIntervalSince(start) - let velocityPerSecond = quantity.doubleValue(for: GlucoseEffectVelocity.perSecondUnit) - - return GlucoseEffect( - startDate: end, - quantity: HKQuantity( - unit: .milligramsPerDeciliter, - doubleValue: velocityPerSecond * duration - ) - ) - } } diff --git a/Sources/LoopAlgorithm/Glucose/GlucoseRange.swift b/Sources/LoopAlgorithm/Glucose/GlucoseRange.swift deleted file mode 100644 index b086e25..0000000 --- a/Sources/LoopAlgorithm/Glucose/GlucoseRange.swift +++ /dev/null @@ -1,78 +0,0 @@ -// -// GlucoseRange.swift -// LoopKit -// -// Created by Nathaniel Hamming on 2021-03-16. -// Copyright © 2021 LoopKit Authors. All rights reserved. -// - -import Foundation -import HealthKit - -public struct GlucoseRange { - public let range: DoubleRange - public let unit: HKUnit - - public init(minValue: Double, maxValue: Double, unit: HKUnit) { - self.init(range: DoubleRange(minValue: minValue, maxValue: maxValue), unit: unit) - } - - public init(range: DoubleRange, unit: HKUnit) { - precondition(unit == .milligramsPerDeciliter || unit == .millimolesPerLiter) - self.range = range - self.unit = unit - } - - public var isZero: Bool { - return abs(range.minValue) < .ulpOfOne && abs(range.maxValue) < .ulpOfOne - } - - public var quantityRange: ClosedRange { - range.quantityRange(for: unit) - } -} - -extension GlucoseRange: Hashable {} - -extension GlucoseRange: Equatable {} - -extension GlucoseRange: RawRepresentable { - public typealias RawValue = [String:Any] - - public init?(rawValue: RawValue) { - guard let rawRange = rawValue["range"] as? DoubleRange.RawValue, - let range = DoubleRange(rawValue: rawRange), - let bloodGlucoseUnit = rawValue["bloodGlucoseUnit"] as? String else - { - return nil - } - self.range = range - self.unit = HKUnit(from: bloodGlucoseUnit) - } - - public var rawValue: RawValue { - return [ - "range": range.rawValue, - "bloodGlucoseUnit": unit.unitString - ] - } -} - -extension GlucoseRange: Codable { - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - unit = HKUnit(from: try container.decode(String.self, forKey: .bloodGlucoseUnit)) - range = try container.decode(DoubleRange.self, forKey: .range) - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(range, forKey: .range) - try container.encode(unit.unitString, forKey: .bloodGlucoseUnit) - } - - private enum CodingKeys: String, CodingKey { - case bloodGlucoseUnit - case range - } -} diff --git a/Sources/LoopAlgorithm/Glucose/GlucoseTrend.swift b/Sources/LoopAlgorithm/Glucose/GlucoseTrend.swift deleted file mode 100644 index a09daa6..0000000 --- a/Sources/LoopAlgorithm/Glucose/GlucoseTrend.swift +++ /dev/null @@ -1,83 +0,0 @@ -// -// GlucoseTrend.swift -// Loop -// -// Created by Nate Racklyeft on 8/2/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation - - -public enum GlucoseTrend: Int, CaseIterable { - case upUpUp = 1 - case upUp = 2 - case up = 3 - case flat = 4 - case down = 5 - case downDown = 6 - case downDownDown = 7 - - public var symbol: String { - switch self { - case .upUpUp: - return "⇈" - case .upUp: - return "↑" - case .up: - return "↗︎" - case .flat: - return "→" - case .down: - return "↘︎" - case .downDown: - return "↓" - case .downDownDown: - return "⇊" - } - } - - public var arrows: String { - switch self { - case .upUpUp: - return "↑↑" - case .upUp: - return "↑" - case .up: - return "↗︎" - case .flat: - return "→" - case .down: - return "↘︎" - case .downDown: - return "↓" - case .downDownDown: - return "↓↓" - } - } -} - -extension GlucoseTrend { - public init?(symbol: String) { - switch symbol { - case "↑↑": - self = .upUpUp - case "↑": - self = .upUp - case "↗︎": - self = .up - case "→": - self = .flat - case "↘︎": - self = .down - case "↓": - self = .downDown - case "↓↓": - self = .downDownDown - default: - return nil - } - } -} - -extension GlucoseTrend: Codable {} diff --git a/Sources/LoopAlgorithm/Insulin/DoseMath.swift b/Sources/LoopAlgorithm/Insulin/DoseMath.swift index 5e49d43..041c774 100644 --- a/Sources/LoopAlgorithm/Insulin/DoseMath.swift +++ b/Sources/LoopAlgorithm/Insulin/DoseMath.swift @@ -110,56 +110,6 @@ extension InsulinCorrection { } } - -extension TempBasalRecommendation { - /// Equates the recommended rate with another rate - /// - /// - Parameter unitsPerHour: The rate to compare - /// - Returns: Whether the rates are equal within Double precision - private func matchesRate(_ unitsPerHour: Double) -> Bool { - return abs(self.unitsPerHour - unitsPerHour) < .ulpOfOne - } - - /// Determines whether the recommendation is necessary given the current state of the pump - /// - /// - Parameters: - /// - date: The date the recommendation would be delivered - /// - scheduledBasalRate: The scheduled basal rate at `date` - /// - lastTempBasal: The previously set temp basal - /// - continuationInterval: The duration of time before an ongoing temp basal should be continued with a new command - /// - scheduledBasalRateMatchesPump: A flag describing whether `scheduledBasalRate` matches the scheduled basal rate of the pump. - /// If `false` and the recommendation matches `scheduledBasalRate`, the temp will be recommended - /// at the scheduled basal rate rather than recommending no temp. - /// - Returns: A temp basal recommendation - public func ifNecessary( - at date: Date, - neutralBasalRate: Double, - lastTempBasal: (any InsulinDose)?, - continuationInterval: TimeInterval, - neutralBasalRateMatchesPump: Bool - ) -> TempBasalRecommendation? { - // Adjust behavior for the currently active temp basal - if let lastTempBasal = lastTempBasal, - lastTempBasal.type == .tempBasal, - lastTempBasal.endDate > date - { - /// If the last temp basal has the same rate, and has more than `continuationInterval` of time remaining, don't set a new temp - if matchesRate(lastTempBasal.unitsPerHour), - lastTempBasal.endDate.timeIntervalSince(date) > continuationInterval { - return nil - } else if matchesRate(neutralBasalRate), neutralBasalRateMatchesPump { - // If our new temp matches the scheduled rate of the pump, cancel the current temp - return .cancel - } - } else if matchesRate(neutralBasalRate), neutralBasalRateMatchesPump { - // If we recommend the in-progress scheduled basal rate of the pump, do nothing - return nil - } - - return self - } -} - /// Computes a total insulin amount necessary to correct a glucose differential at a given sensitivity /// /// - Parameters: diff --git a/Sources/LoopAlgorithm/Insulin/InsulinValue.swift b/Sources/LoopAlgorithm/Insulin/InsulinValue.swift index 2de3946..ee97b5c 100644 --- a/Sources/LoopAlgorithm/Insulin/InsulinValue.swift +++ b/Sources/LoopAlgorithm/Insulin/InsulinValue.swift @@ -24,17 +24,3 @@ public struct InsulinValue: TimelineValue, Equatable { } extension InsulinValue: Codable {} - -public extension Array where Element == InsulinValue { - func trimmed(from start: Date? = nil, to end: Date? = nil) -> [InsulinValue] { - return self.compactMap { entry in - if let start, entry.startDate < start { - return nil - } - if let end, entry.startDate > end { - return nil - } - return entry - } - } -} diff --git a/Sources/LoopAlgorithm/LoopAlgorithm.swift b/Sources/LoopAlgorithm/LoopAlgorithm.swift index 479d28b..9bbdcc4 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithm.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithm.swift @@ -83,8 +83,10 @@ public struct LoopAlgorithm { public static let insulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: nil) /// 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. @@ -128,17 +130,42 @@ public struct LoopAlgorithm { var dosesRelativeToBasal: [BasalRelativeDose] = [] // Ensure basal history covers doses - if let doseStart = doses.first?.startDate, !basal.isEmpty, basal.first!.startDate <= doseStart { + let doseStart = doses.first?.startDate ?? start + if !basal.isEmpty, basal.first!.startDate <= doseStart { // Overlay basal history on basal doses, splitting doses to get amount delivered relative to basal dosesRelativeToBasal = doses.annotated(with: basal) + activeInsulin = dosesRelativeToBasal.insulinOnBoard(insulinModelProvider: insulinModelProvider, at: start) + + var minDate = start + var maxDate = start + + for dose in dosesRelativeToBasal { + if dose.startDate < minDate { + minDate = dose.startDate + } + + let doseEnd = dose.endDate.addingTimeInterval(insulinModelProvider.model(for: dose.insulinType).effectDuration) + + if doseEnd > maxDate { + maxDate = doseEnd + } + } + + // Extend range of insulin effects to cover glucose, if needed + if let glucoseStart = glucoseHistory.first?.startDate, glucoseStart < minDate { + minDate = glucoseStart + } + + if let glucoseEnd = glucoseHistory.last?.endDate, glucoseEnd > maxDate { + maxDate = glucoseEnd + } + insulinEffects = dosesRelativeToBasal.glucoseEffects( insulinModelProvider: insulinModelProvider, insulinSensitivityHistory: sensitivity, - from: start.addingTimeInterval(-CarbMath.maximumAbsorptionTimeInterval).dateFlooredToTimeInterval(GlucoseMath.defaultDelta), - to: nil) - - activeInsulin = dosesRelativeToBasal.insulinOnBoard(insulinModelProvider: insulinModelProvider, at: start) + from: minDate, + to: maxDate) // ICE insulinCounteractionEffects = glucoseHistory.counteractionEffects(to: insulinEffects) @@ -286,7 +313,6 @@ public struct LoopAlgorithm { // Computes a 30 minute temp basal dose to correct the given prediction public static func recommendTempBasal( for correction: InsulinCorrection, - at deliveryDate: Date, neutralBasalRate: Double, activeInsulin: Double, maxBolus: Double, @@ -319,7 +345,6 @@ public struct LoopAlgorithm { // Computes a bolus or low-temp basal dose to correct the given prediction public static func recommendAutomaticDose( for correction: InsulinCorrection, - at deliveryDate: Date, applicationFactor: Double, neutralBasalRate: Double, activeInsulin: Double, @@ -384,21 +409,7 @@ public struct LoopAlgorithm { } } - public static func run(input: LoopAlgorithmInput, effectOptions: AlgorithmEffectsOptions = .all) -> LoopAlgorithmOutput { - - // If we're running for automated dosing, we calculate a dose assuming that the current temp basal will be canceled - - // TODO: we can change the effects to not use future delivery, instead of having to modify the array -// if input.recommendationType.automated { -// inputDoses = input.doses.trimmed(to: input.predictionStart, onlyTrimTempBasals: true) -// } else { -// inputDoses = input.doses -// } - - // `generatePrediction` does a best-try to generate a prediction and associated effects. - // Outputs may be incomplete, if there are issues with the provided data. - // Assertions of data completeness/recency for dosing will be checked after. - // This is so we can communicate/visualize state to the user even if we can't make a dosing recommendation. + public static func run(input: LoopAlgorithmInput) -> LoopAlgorithmOutput { let prediction = generatePrediction( start: input.predictionStart, @@ -463,7 +474,6 @@ public struct LoopAlgorithm { case .automaticBolus: let recommendation = recommendAutomaticDose( for: correction, - at: input.predictionStart, applicationFactor: input.automaticBolusApplicationFactor ?? defaultBolusPartialApplicationFactor, neutralBasalRate: scheduledBasalRate, activeInsulin: prediction.activeInsulin!, @@ -473,7 +483,6 @@ public struct LoopAlgorithm { case .tempBasal: let recommendation = recommendTempBasal( for: correction, - at: input.predictionStart, neutralBasalRate: scheduledBasalRate, activeInsulin: prediction.activeInsulin!, maxBolus: input.maxBolus, diff --git a/Sources/LoopAlgorithm/LoopMath.swift b/Sources/LoopAlgorithm/LoopMath.swift index c55cd91..b371705 100644 --- a/Sources/LoopAlgorithm/LoopMath.swift +++ b/Sources/LoopAlgorithm/LoopMath.swift @@ -257,40 +257,8 @@ extension BidirectionalCollection where Element == GlucoseEffect { return GlucoseChange(startDate: first.startDate, endDate: last.endDate, quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: net)) } - - /// Returns the net effect of a portion of receiver as a GlucoseChange object - /// - /// Requires the receiver to be sorted chronologically by endDate - /// - /// - Returns: A single GlucoseChange representing the net effect - public func netEffect(after startDate: Date) -> GlucoseChange? { - guard count > 1 else { - return nil - } - - guard var startingEffectIndex = firstIndex(where: { $0.startDate > startDate } ) else { - return nil - } - - if startingEffectIndex > startIndex { - startingEffectIndex = index(before: startingEffectIndex) - } - - let firstEffect = self[startingEffectIndex] - - let net = last!.quantity.doubleValue(for: .milligramsPerDeciliter) - firstEffect.quantity.doubleValue(for: .milligramsPerDeciliter) - - return GlucoseChange(startDate: firstEffect.startDate, endDate: last!.endDate, quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: net)) - } } -extension Sequence where Element: AdditiveArithmetic { - func sum() -> Element { - return reduce(.zero, +) - } -} - - extension BidirectionalCollection where Element == GlucoseEffectVelocity { /// Subtracts an array of glucose effects with uniform intervals and no gaps from the collection of effect changes, which may not have uniform intervals. diff --git a/Sources/LoopAlgorithm/SampleValue.swift b/Sources/LoopAlgorithm/SampleValue.swift index 07563b0..f3e1153 100644 --- a/Sources/LoopAlgorithm/SampleValue.swift +++ b/Sources/LoopAlgorithm/SampleValue.swift @@ -59,17 +59,6 @@ public extension Sequence where Element: TimelineValue { return (before, after) } - /// Returns all elements inmmediately adjacent to the specified date - /// - /// Use Sequence.elementsAdjacent(to:) if specific before/after references are necessary - /// - /// - Parameter date: The date to use in the search - /// - Returns: The closest elements, if found - func allElementsAdjacent(to date: Date) -> [Iterator.Element] { - let (before, after) = elementsAdjacent(to: date) - return [before, after].compactMap({ $0 }) - } - /** Returns an array of elements filtered by the specified date range. @@ -109,22 +98,4 @@ public extension Sequence where Element: TimelineValue { func filterDateInterval(interval: DateInterval) -> [Iterator.Element] { return filterDateRange(interval.start, interval.end) } - -} - -public extension Sequence where Element: SampleValue { - func average(unit: HKUnit) -> HKQuantity? { - let (sum, count) = reduce(into: (sum: 0.0, count: 0)) { result, element in - result.0 += element.quantity.doubleValue(for: unit) - result.1 += 1 - } - - guard count > 0 else { - return nil - } - - let average = sum / Double(count) - - return HKQuantity(unit: unit, doubleValue: average) - } } diff --git a/Sources/LoopAlgorithm/TempBasalRecommendation.swift b/Sources/LoopAlgorithm/TempBasalRecommendation.swift index 9ba072e..5bb597a 100644 --- a/Sources/LoopAlgorithm/TempBasalRecommendation.swift +++ b/Sources/LoopAlgorithm/TempBasalRecommendation.swift @@ -17,11 +17,6 @@ public struct TempBasalRecommendation: Equatable { return HKQuantity(unit: .internationalUnitsPerHour, doubleValue: unitsPerHour) } - /// A special command which cancels any existing temp basals - public static var cancel: TempBasalRecommendation { - return self.init(unitsPerHour: 0, duration: 0) - } - public init(unitsPerHour: Double, duration: TimeInterval) { self.unitsPerHour = unitsPerHour self.duration = duration diff --git a/Tests/LoopAlgorithmTests/LoopAlgorithmInputMocks.swift b/Tests/LoopAlgorithmTests/LoopAlgorithmInputMocks.swift deleted file mode 100644 index 1989f72..0000000 --- a/Tests/LoopAlgorithmTests/LoopAlgorithmInputMocks.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// File.swift -// -// -// Created by Pete Schwamb on 12/20/23. -// - -import Foundation -@testable import LoopAlgorithm - -extension LoopAlgorithmInput { - static func mock(for date: Date) -> LoopAlgorithmInput { - - return LoopAlgorithmInput( - predictionStart: date, - glucoseHistory: [], - doses: [], - carbEntries: [], - basal: [], - sensitivity: [], - carbRatio: [], - target: [], - suspendThreshold: .init(unit: .milligramsPerDeciliter, doubleValue: 65), - maxBolus: 6, - maxBasalRate: 8, - useIntegralRetrospectiveCorrection: false, - includePositiveVelocityAndRC: false, - carbAbsorptionModel: .piecewiseLinear, - recommendationInsulinType: .novolog, - recommendationType: .manualBolus - ) - } -} diff --git a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift index 618ec41..a1ffc6d 100644 --- a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift +++ b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift @@ -44,4 +44,83 @@ final class LoopAlgorithmTests: XCTestCase { XCTAssertEqual(output.recommendation, recommendation) } + + func testAlgorithmShouldBeDateIndependent() throws { + let now = Date() + var a = LoopAlgorithmInputFixture.mock(for: now) + var b = LoopAlgorithmInputFixture.mock(for: now.addingTimeInterval(.minutes(-2.5))) + + a.carbEntries.append( + FixtureCarbEntry( + startDate: a.predictionStart.addingTimeInterval(-.minutes(30)), + quantity: .carbs(value: 10) + ) + ) + + b.carbEntries.append( + FixtureCarbEntry( + startDate: b.predictionStart.addingTimeInterval(-.minutes(30)), + quantity: .carbs(value: 10) + ) + ) + + + let outputA = LoopAlgorithm.run(input: a) + let outputB = LoopAlgorithm.run(input: b) + + XCTAssertEqual(outputA.activeCarbs, outputB.activeCarbs) + XCTAssertEqual(outputA.activeInsulin, outputB.activeInsulin) + + XCTAssertEqual(outputA.effects.carbs.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 190.0) + XCTAssertEqual(outputB.effects.carbs.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 190.0) + + XCTAssertEqual(outputA.effects.insulin.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 0.0) + XCTAssertEqual(outputB.effects.insulin.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 0.0) + + XCTAssertEqual(outputA.effects.momentum.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 0.0) + XCTAssertEqual(outputB.effects.momentum.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 0.0) + + // TODO: +// XCTAssertEqual(outputA.effects.retrospectiveCorrection.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 0) +// XCTAssertEqual(outputB.effects.retrospectiveCorrection.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 0) +// +// XCTAssertEqual(outputA.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 283.7, accuracy: 0.01) +// XCTAssertEqual(outputB.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 283.7, accuracy: 0.01) + } + + + func testObservedProgressForCarbStatus() throws { + let date = ISO8601DateFormatter().date(from: "2024-01-03T12:00:00+0000")! + var input = LoopAlgorithmInputFixture.mock(for: date) + + let now = input.predictionStart + + // Add carbs (20g should be 2U at 10g/U) + input.carbEntries.append( + FixtureCarbEntry( + startDate: now.addingTimeInterval(-.minutes(30)), + quantity: .carbs(value: 20) + ) + ) + + // Rising BG from carb absorption + input.glucoseHistory = [ + FixtureGlucoseSample(startDate: now.addingTimeInterval(.minutes(-18)), quantity: .glucose(value: 105)), + FixtureGlucoseSample(startDate: now.addingTimeInterval(.minutes(-13)), quantity: .glucose(value: 115)), + FixtureGlucoseSample(startDate: now.addingTimeInterval(.minutes(-8)), quantity: .glucose(value: 120)), + FixtureGlucoseSample(startDate: now.addingTimeInterval(.minutes(-3)), quantity: .glucose(value: 145)), + ] + + let output = LoopAlgorithm.run(input: input) + + let carbStatus = output.effects.carbStatus.first! + XCTAssertEqual(carbStatus.absorption!.observedProgress.doubleValue(for: .percent()), 0.11, accuracy: 0.01) + + XCTAssert(carbStatus.absorption!.isActive) + + let basalAdjustment = output.recommendation!.automatic!.basalAdjustment + + XCTAssertEqual(basalAdjustment!.unitsPerHour, 5.06, accuracy: 0.01) + } + } diff --git a/Tests/LoopAlgorithmTests/Mocks/LoopAlgorithmInputMock.swift b/Tests/LoopAlgorithmTests/Mocks/LoopAlgorithmInputMock.swift new file mode 100644 index 0000000..97c665a --- /dev/null +++ b/Tests/LoopAlgorithmTests/Mocks/LoopAlgorithmInputMock.swift @@ -0,0 +1,54 @@ +// +// LoopAlgorithmInput.swift +// +// +// Created by Pete Schwamb on 1/2/24. +// + +import Foundation +import HealthKit +@testable import LoopAlgorithm + +public typealias LoopAlgorithmInputFixture = LoopAlgorithmInput + +extension LoopAlgorithmInput { + /// Mocks stable, in range glucose, no insulin, no carbs, with reasonable settings + static func mock(for now: Date = Date()) -> LoopAlgorithmInputFixture { + + func d(_ interval: TimeInterval) -> Date { + return now.addingTimeInterval(interval) + } + + return LoopAlgorithmInputFixture( + predictionStart: now, + glucoseHistory: [ + FixtureGlucoseSample(startDate: d(.minutes(-18)), quantity: .glucose(value: 105)), + FixtureGlucoseSample(startDate: d(.minutes(-13)), quantity: .glucose(value: 105)), + FixtureGlucoseSample(startDate: d(.minutes(-8)), quantity: .glucose(value: 105)), + FixtureGlucoseSample(startDate: d(.minutes(-3)), quantity: .glucose(value: 105)), + ], + doses: [], + carbEntries: [], + basal: [AbsoluteScheduleValue(startDate: d(.hours(-10)), endDate: now, value: 1.0)], + sensitivity: [AbsoluteScheduleValue(startDate: d(.hours(-10)), endDate: now.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration), value: .glucose(value: 190))], + carbRatio: [AbsoluteScheduleValue(startDate: d(.hours(-10)), endDate: now, value: 10)], + target: [AbsoluteScheduleValue(startDate: d(.hours(-10)), endDate: now, value: ClosedRange(uncheckedBounds: (lower: .glucose(value: 100), upper: .glucose(value: 110))))], + suspendThreshold: .glucose(value: 70), + maxBolus: 6, + maxBasalRate: 9, + recommendationInsulinType: .novolog, + recommendationType: .tempBasal + ) + } +} + +extension HKQuantity { + static func glucose(value: Double) -> HKQuantity { + return .init(unit: .milligramsPerDeciliter, doubleValue: value) + } + + static func carbs(value: Double) -> HKQuantity { + return .init(unit: .gram(), doubleValue: value) + } + +} From 99b3d08f672ba44c56076ebb78da89066fb00634 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 3 Jan 2024 16:47:25 -0600 Subject: [PATCH 06/26] Updates for building with Learn --- Sources/LoopAlgorithm/Carbs/CarbMath.swift | 4 ++-- .../Insulin/ExponentialInsulinModelPreset.swift | 8 +------- Sources/LoopAlgorithm/Insulin/InsulinModel.swift | 2 +- Sources/LoopAlgorithm/Insulin/RelativeDelivery.swift | 8 ++++++++ Sources/LoopAlgorithm/LoopMath.swift | 2 +- 5 files changed, 13 insertions(+), 11 deletions(-) diff --git a/Sources/LoopAlgorithm/Carbs/CarbMath.swift b/Sources/LoopAlgorithm/Carbs/CarbMath.swift index c6feacf..9ae8147 100644 --- a/Sources/LoopAlgorithm/Carbs/CarbMath.swift +++ b/Sources/LoopAlgorithm/Carbs/CarbMath.swift @@ -203,7 +203,7 @@ public struct PiecewiseLinearAbsorption: CarbAbsorptionComputable { extension CarbEntry { - func carbsOnBoard(at date: Date, defaultAbsorptionTime: TimeInterval, delay: TimeInterval, absorptionModel: CarbAbsorptionComputable) -> Double { + public func carbsOnBoard(at date: Date, defaultAbsorptionTime: TimeInterval, delay: TimeInterval, absorptionModel: CarbAbsorptionComputable) -> Double { let time = date.timeIntervalSince(startDate) let value: Double @@ -217,7 +217,7 @@ extension CarbEntry { } // g - func absorbedCarbs( + public func absorbedCarbs( at date: Date, absorptionTime: TimeInterval, delay: TimeInterval, diff --git a/Sources/LoopAlgorithm/Insulin/ExponentialInsulinModelPreset.swift b/Sources/LoopAlgorithm/Insulin/ExponentialInsulinModelPreset.swift index 7ba9842..83086ab 100644 --- a/Sources/LoopAlgorithm/Insulin/ExponentialInsulinModelPreset.swift +++ b/Sources/LoopAlgorithm/Insulin/ExponentialInsulinModelPreset.swift @@ -63,7 +63,7 @@ extension ExponentialInsulinModelPreset { } } - var model: InsulinModel { + public var model: InsulinModel { return ExponentialInsulinModel(actionDuration: actionDuration, peakActivityTime: peakActivity, delay: delay) } } @@ -78,9 +78,3 @@ extension ExponentialInsulinModelPreset: InsulinModel { return model.percentEffectRemaining(at: time) } } - -extension ExponentialInsulinModelPreset: CustomDebugStringConvertible { - public var debugDescription: String { - return "\(self.rawValue)(\(String(reflecting: model)))" - } -} diff --git a/Sources/LoopAlgorithm/Insulin/InsulinModel.swift b/Sources/LoopAlgorithm/Insulin/InsulinModel.swift index 00eca99..26a1d07 100644 --- a/Sources/LoopAlgorithm/Insulin/InsulinModel.swift +++ b/Sources/LoopAlgorithm/Insulin/InsulinModel.swift @@ -9,7 +9,7 @@ import Foundation -public protocol InsulinModel: CustomDebugStringConvertible { +public protocol InsulinModel { /// Returns the percentage of total insulin effect remaining at a specified interval after delivery; also known as Insulin On Board (IOB). /// Return value is within the range of 0-1 diff --git a/Sources/LoopAlgorithm/Insulin/RelativeDelivery.swift b/Sources/LoopAlgorithm/Insulin/RelativeDelivery.swift index 63ee754..3bcf1ca 100644 --- a/Sources/LoopAlgorithm/Insulin/RelativeDelivery.swift +++ b/Sources/LoopAlgorithm/Insulin/RelativeDelivery.swift @@ -23,6 +23,14 @@ public struct BasalRelativeDose: TimelineValue { var duration: TimeInterval { return endDate.timeIntervalSince(startDate) } + + public init(type: BasalRelativeDoseType, startDate: Date, endDate: Date, volume: Double, insulinType: InsulinType? = nil) { + self.type = type + self.startDate = startDate + self.endDate = endDate + self.volume = volume + self.insulinType = insulinType + } } extension BasalRelativeDose { diff --git a/Sources/LoopAlgorithm/LoopMath.swift b/Sources/LoopAlgorithm/LoopMath.swift index b371705..ff0c782 100644 --- a/Sources/LoopAlgorithm/LoopMath.swift +++ b/Sources/LoopAlgorithm/LoopMath.swift @@ -16,7 +16,7 @@ public enum LoopMath { public static let retrospectiveCorrectionEffectDuration = TimeInterval(hours: 1) - static func simulationDateRangeForSamples( + public static func simulationDateRangeForSamples( _ samples: T, from start: Date? = nil, to end: Date? = nil, From b06a050c7311465ff6ba7da9ba38c638f2e32100 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 4 Jan 2024 12:58:10 -0600 Subject: [PATCH 07/26] type -> deliveryType --- Sources/LoopAlgorithm/Glucose/GlucoseChange.swift | 6 ++++++ Sources/LoopAlgorithm/Glucose/GlucoseMath.swift | 2 +- Sources/LoopAlgorithm/Insulin/DoseType.swift | 6 +++--- Sources/LoopAlgorithm/Insulin/FixtureInsulinDose.swift | 6 +++--- Sources/LoopAlgorithm/Insulin/InsulinDose.swift | 2 +- Sources/LoopAlgorithm/Insulin/InsulinMath.swift | 4 ++-- Sources/LoopAlgorithm/Insulin/RelativeDelivery.swift | 2 +- Sources/LoopAlgorithm/LoopAlgorithmInput.swift | 2 +- Sources/LoopAlgorithm/LoopPredictionInput.swift | 2 +- 9 files changed, 19 insertions(+), 13 deletions(-) diff --git a/Sources/LoopAlgorithm/Glucose/GlucoseChange.swift b/Sources/LoopAlgorithm/Glucose/GlucoseChange.swift index 6b8edce..ce49794 100644 --- a/Sources/LoopAlgorithm/Glucose/GlucoseChange.swift +++ b/Sources/LoopAlgorithm/Glucose/GlucoseChange.swift @@ -12,6 +12,12 @@ public struct GlucoseChange: SampleValue, Equatable { public var startDate: Date public var endDate: Date public var quantity: HKQuantity + + public init(startDate: Date, endDate: Date, quantity: HKQuantity) { + self.startDate = startDate + self.endDate = endDate + self.quantity = quantity + } } diff --git a/Sources/LoopAlgorithm/Glucose/GlucoseMath.swift b/Sources/LoopAlgorithm/Glucose/GlucoseMath.swift index 84d54cd..1fcd59b 100644 --- a/Sources/LoopAlgorithm/Glucose/GlucoseMath.swift +++ b/Sources/LoopAlgorithm/Glucose/GlucoseMath.swift @@ -62,7 +62,7 @@ extension BidirectionalCollection where Element: GlucoseSampleValue, Index == In /// - Parameters: /// - interval: The interval between readings, on average, used to determine if we have a contiguous set of values /// - Returns: True if the samples are continuous - func isContinuous(within interval: TimeInterval = TimeInterval(minutes: 5)) -> Bool { + public func isContinuous(within interval: TimeInterval = TimeInterval(5 * 60)) -> Bool { if let first = first, let last = last, // Ensure that the entries are contiguous diff --git a/Sources/LoopAlgorithm/Insulin/DoseType.swift b/Sources/LoopAlgorithm/Insulin/DoseType.swift index ba86f12..87f4f1b 100644 --- a/Sources/LoopAlgorithm/Insulin/DoseType.swift +++ b/Sources/LoopAlgorithm/Insulin/DoseType.swift @@ -9,9 +9,9 @@ import Foundation /// A general set of ways insulin can be delivered by a pump -public enum InsulinDoseType: String, CaseIterable, Equatable { +public enum InsulinDeliveryType: String, CaseIterable, Equatable { case bolus - case tempBasal + case basal } -extension InsulinDoseType: Codable {} +extension InsulinDeliveryType: Codable {} diff --git a/Sources/LoopAlgorithm/Insulin/FixtureInsulinDose.swift b/Sources/LoopAlgorithm/Insulin/FixtureInsulinDose.swift index 57f16db..246a140 100644 --- a/Sources/LoopAlgorithm/Insulin/FixtureInsulinDose.swift +++ b/Sources/LoopAlgorithm/Insulin/FixtureInsulinDose.swift @@ -7,7 +7,7 @@ import Foundation public struct FixtureInsulinDose: InsulinDose, Equatable { - public var type: InsulinDoseType + public var deliveryType: InsulinDeliveryType public var startDate: Date @@ -21,7 +21,7 @@ public struct FixtureInsulinDose: InsulinDose, Equatable { extension FixtureInsulinDose: Codable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - self.type = try container.decode(InsulinDoseType.self, forKey: .type) + self.deliveryType = try container.decode(InsulinDeliveryType.self, forKey: .type) self.startDate = try container.decode(Date.self, forKey: .startDate) self.endDate = try container.decode(Date.self, forKey: .endDate) self.volume = try container.decode(Double.self, forKey: .volume) @@ -30,7 +30,7 @@ extension FixtureInsulinDose: Codable { public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(type, forKey: .type) + try container.encode(deliveryType, forKey: .type) try container.encode(startDate, forKey: .startDate) try container.encode(endDate, forKey: .endDate) try container.encode(volume, forKey: .volume) diff --git a/Sources/LoopAlgorithm/Insulin/InsulinDose.swift b/Sources/LoopAlgorithm/Insulin/InsulinDose.swift index 1e0f1aa..37519a4 100644 --- a/Sources/LoopAlgorithm/Insulin/InsulinDose.swift +++ b/Sources/LoopAlgorithm/Insulin/InsulinDose.swift @@ -9,7 +9,7 @@ import HealthKit public protocol InsulinDose: TimelineValue { - var type: InsulinDoseType { get } + var deliveryType: InsulinDeliveryType { get } var startDate: Date { get } var endDate: Date { get } var volume: Double { get } diff --git a/Sources/LoopAlgorithm/Insulin/InsulinMath.swift b/Sources/LoopAlgorithm/Insulin/InsulinMath.swift index c5ffbac..fd0b77b 100644 --- a/Sources/LoopAlgorithm/Insulin/InsulinMath.swift +++ b/Sources/LoopAlgorithm/Insulin/InsulinMath.swift @@ -118,7 +118,7 @@ extension InsulinDose { /// - Returns: An array of annotated doses fileprivate func annotated(with basalHistory: [AbsoluteScheduleValue]) -> [BasalRelativeDose] { - guard type == .tempBasal else { + guard deliveryType == .basal else { preconditionFailure("basalDeliveryTotal called on dose that is not a temp basal!") } @@ -195,7 +195,7 @@ extension Collection where Element: InsulinDose { var annotatedDoses: [BasalRelativeDose] = [] for dose in self { - if dose.type == .tempBasal { + if dose.deliveryType == .basal { let basalItems = basalHistory.filterDateRange(dose.startDate, dose.endDate) annotatedDoses += dose.annotated(with: basalItems) } else { diff --git a/Sources/LoopAlgorithm/Insulin/RelativeDelivery.swift b/Sources/LoopAlgorithm/Insulin/RelativeDelivery.swift index 3bcf1ca..abc7e84 100644 --- a/Sources/LoopAlgorithm/Insulin/RelativeDelivery.swift +++ b/Sources/LoopAlgorithm/Insulin/RelativeDelivery.swift @@ -51,7 +51,7 @@ extension BasalRelativeDose { extension BasalRelativeDose { static func fromBolus(dose: InsulinDose) -> BasalRelativeDose { - precondition(dose.type == .bolus, "Dose passed to fromBolus() must be a bolus.") + precondition(dose.deliveryType == .bolus, "Dose passed to fromBolus() must be a bolus.") return BasalRelativeDose( type: .bolus, diff --git a/Sources/LoopAlgorithm/LoopAlgorithmInput.swift b/Sources/LoopAlgorithm/LoopAlgorithmInput.swift index bb4187a..a147425 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithmInput.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithmInput.swift @@ -281,7 +281,7 @@ extension LoopAlgorithmInput { }, doses: doses.map({ FixtureInsulinDose( - type: $0.type, + deliveryType: $0.deliveryType, startDate: $0.startDate, endDate: $0.endDate, volume: $0.volume, diff --git a/Sources/LoopAlgorithm/LoopPredictionInput.swift b/Sources/LoopAlgorithm/LoopPredictionInput.swift index f952c8f..d9f1683 100644 --- a/Sources/LoopAlgorithm/LoopPredictionInput.swift +++ b/Sources/LoopAlgorithm/LoopPredictionInput.swift @@ -127,7 +127,7 @@ extension LoopPredictionInput { }, doses: doses.map { FixtureInsulinDose( - type: $0.type == .bolus ? .bolus : .tempBasal, + deliveryType: $0.deliveryType == .bolus ? .bolus : .basal, startDate: $0.startDate, endDate: $0.endDate, volume: $0.volume, From f2b61869c38eb5c23dcde7e9b3a125526798a451 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 4 Jan 2024 14:15:20 -0600 Subject: [PATCH 08/26] Expose InsulinDose.duration --- Sources/LoopAlgorithm/Insulin/InsulinDose.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/LoopAlgorithm/Insulin/InsulinDose.swift b/Sources/LoopAlgorithm/Insulin/InsulinDose.swift index 37519a4..9223142 100644 --- a/Sources/LoopAlgorithm/Insulin/InsulinDose.swift +++ b/Sources/LoopAlgorithm/Insulin/InsulinDose.swift @@ -17,7 +17,7 @@ public protocol InsulinDose: TimelineValue { } extension InsulinDose { - var duration: TimeInterval { + public var duration: TimeInterval { return endDate.timeIntervalSince(startDate) } From f9638f409280f73e5d746797e9c5a5087d43c978 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 11 Jan 2024 18:40:11 -0600 Subject: [PATCH 09/26] Adding tests --- Sources/LoopAlgorithm/Insulin/DoseMath.swift | 23 +- Sources/LoopAlgorithm/LoopAlgorithm.swift | 10 +- .../ManualBolusRecommendation.swift | 10 +- .../CorrectionDosingTests.swift | 548 ++++++++++++++++++ .../Fixtures/carbs_with_isf_change_input.json | 66 +-- .../Fixtures/suspend_input.json | 76 +-- .../Mocks/PredictedGlucoseMocks.swift | 124 ++++ 7 files changed, 766 insertions(+), 91 deletions(-) create mode 100644 Tests/LoopAlgorithmTests/CorrectionDosingTests.swift create mode 100644 Tests/LoopAlgorithmTests/Mocks/PredictedGlucoseMocks.swift diff --git a/Sources/LoopAlgorithm/Insulin/DoseMath.swift b/Sources/LoopAlgorithm/Insulin/DoseMath.swift index 041c774..ea73de8 100644 --- a/Sources/LoopAlgorithm/Insulin/DoseMath.swift +++ b/Sources/LoopAlgorithm/Insulin/DoseMath.swift @@ -64,14 +64,14 @@ extension InsulinCorrection { private var bolusRecommendationNotice: BolusRecommendationNotice? { switch self { case .suspend(min: let minimum): - return .glucoseBelowSuspendThreshold(minGlucose: minimum) + return .glucoseBelowSuspendThreshold(minGlucose: SimpleGlucoseValue(minimum)) case .inRange: return .predictedGlucoseInRange case .entirelyBelowRange(min: let min, minTarget: _, units: _): - return .allGlucoseBelowTarget(minGlucose: min) + return .allGlucoseBelowTarget(minGlucose: SimpleGlucoseValue(min)) case .aboveRange(min: let min, correcting: _, minTarget: let target, units: let units): if units > 0 && min.quantity < target { - return .predictedGlucoseBelowTarget(minGlucose: min) + return .predictedGlucoseBelowTarget(minGlucose: SimpleGlucoseValue(min)) } else { return nil } @@ -152,7 +152,7 @@ private func targetGlucoseValue(percentEffectDuration: Double, minValue: Double, public typealias GlucoseRangeTimeline = [AbsoluteScheduleValue>] -extension Collection where Element: GlucoseValue { +extension Array where Element: GlucoseValue { /// For a collection of glucose prediction, determine the least amount of insulin delivered at /// `date` to correct the predicted glucose to the middle of `correctionRange` at the time of prediction. @@ -177,24 +177,27 @@ extension Collection where Element: GlucoseValue { var minCorrectionUnits: Double? var effectedSensitivityAtMinGlucose: Double? - // Only consider predictions within the model's effect duration - let validDateRange = DateInterval(start: date, duration: model.effectDuration) - let unit = HKUnit.milligramsPerDeciliter guard self.count > 0 else { preconditionFailure("Unable to compute correction for empty glucose array") } + // If this is not true, then this method will return very large doses. For example, it takes a *lot* of + // insulin to bring a predicted glucose of 200 down to range within 30 minutes, so we assert that the + // prediction includes values out to the end of insulin activity. + guard self.last!.startDate >= date.addingTimeInterval(model.effectDuration) else { + preconditionFailure("Minimization method requires that glucose prediction covers at least the insulin effect duration.") + } + let suspendThresholdValue = suspendThreshold.doubleValue(for: unit) // For each prediction above target, determine the amount of insulin necessary to correct glucose based on the modeled effectiveness of the insulin at that time for prediction in self { - guard validDateRange.contains(prediction.startDate) else { + guard prediction.startDate >= date else { continue } - // If any predicted value is below the suspend threshold, return immediately guard prediction.quantity >= suspendThreshold else { return .suspend(min: prediction) @@ -320,7 +323,7 @@ extension Collection where Element: GlucoseValue { if case .predictedGlucoseBelowTarget? = bolus.notice, let first = first, first.quantity < correctionRangeTimeline.closestPrior(to: first.startDate)!.value.lowerBound { - bolus.notice = .currentGlucoseBelowTarget(glucose: first) + bolus.notice = .currentGlucoseBelowTarget(glucose: SimpleGlucoseValue(first)) } return bolus diff --git a/Sources/LoopAlgorithm/LoopAlgorithm.swift b/Sources/LoopAlgorithm/LoopAlgorithm.swift index 9bbdcc4..7a343f2 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithm.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithm.swift @@ -250,7 +250,7 @@ public struct LoopAlgorithm { // Dosing requires prediction entries at least as long as the insulin model duration. // If our prediction is shorter than that, then extend it here. - let finalDate = latestGlucose.startDate.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration) + let finalDate = start.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration) if let last = prediction.last, last.startDate < finalDate { prediction.append(PredictedGlucoseValue(startDate: finalDate, quantity: last.quantity)) } @@ -292,7 +292,7 @@ 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, @@ -311,7 +311,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, @@ -343,7 +343,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, @@ -393,7 +393,7 @@ public struct LoopAlgorithm { if let targetAtCurrentGlucose = target.closestPrior(to: currentGlucose.startDate), currentGlucose.quantity < targetAtCurrentGlucose.value.lowerBound { - bolus.notice = .currentGlucoseBelowTarget(glucose: currentGlucose) + bolus.notice = .currentGlucoseBelowTarget(glucose: SimpleGlucoseValue(currentGlucose)) } return bolus diff --git a/Sources/LoopAlgorithm/ManualBolusRecommendation.swift b/Sources/LoopAlgorithm/ManualBolusRecommendation.swift index e73cf23..ff5c58b 100644 --- a/Sources/LoopAlgorithm/ManualBolusRecommendation.swift +++ b/Sources/LoopAlgorithm/ManualBolusRecommendation.swift @@ -9,12 +9,12 @@ import Foundation import HealthKit -public enum BolusRecommendationNotice { - case glucoseBelowSuspendThreshold(minGlucose: GlucoseValue) - case currentGlucoseBelowTarget(glucose: GlucoseValue) - case predictedGlucoseBelowTarget(minGlucose: GlucoseValue) +public enum BolusRecommendationNotice: Equatable { + case glucoseBelowSuspendThreshold(minGlucose: SimpleGlucoseValue) + case currentGlucoseBelowTarget(glucose: SimpleGlucoseValue) + case predictedGlucoseBelowTarget(minGlucose: SimpleGlucoseValue) case predictedGlucoseInRange - case allGlucoseBelowTarget(minGlucose: GlucoseValue) + case allGlucoseBelowTarget(minGlucose: SimpleGlucoseValue) } extension BolusRecommendationNotice: Codable { diff --git a/Tests/LoopAlgorithmTests/CorrectionDosingTests.swift b/Tests/LoopAlgorithmTests/CorrectionDosingTests.swift new file mode 100644 index 0000000..d352b3e --- /dev/null +++ b/Tests/LoopAlgorithmTests/CorrectionDosingTests.swift @@ -0,0 +1,548 @@ +// +// CorrectionDosingTests.swift +// +// +// Created by Nathan Racklyeft on 3/8/16. +// Copyright © 2016 LoopKit Authors. All rights reserved. +// + +import XCTest +import HealthKit +@testable import LoopAlgorithm + +class CorrectionDosingTests: XCTestCase { + + var testDate: Date { + return PredictedGlucoseMocks.testDate + } + + let suspendThreshold: HKQuantity = .glucose(value: 55) + let maxBasalRate = 3.0 + + var target: GlucoseRangeTimeline! + var sensitivity: [AbsoluteScheduleValue]! + let basalRate = 1.0 + + override func setUp() { + target = [AbsoluteScheduleValue( + startDate: testDate.addingTimeInterval(.hours(-24)), + endDate: testDate.addingTimeInterval(.hours(24)), + value: DoubleRange(minValue: 90, maxValue: 120).quantityRange(for: .milligramsPerDeciliter) + )] + + sensitivity = [AbsoluteScheduleValue( + startDate: testDate.addingTimeInterval(.hours(-24)), + endDate: testDate.addingTimeInterval(.hours(24)), + value: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 60) + )] + } + + + + func testNoChange() { + let correction = LoopAlgorithm.insulinCorrection( + prediction: PredictedGlucoseMocks.noChangePrediction(), + at: testDate, + target: target, + suspendThreshold: suspendThreshold, + sensitivity: sensitivity, + insulinType: .novolog + ) + + let recommendation = LoopAlgorithm.recommendTempBasal( + for: correction, + neutralBasalRate: basalRate, + activeInsulin: 0, + maxBolus: 6, + maxBasalRate: maxBasalRate + ) + + XCTAssertEqual(recommendation?.unitsPerHour, basalRate) + XCTAssertEqual(recommendation?.duration, .minutes(30)) + + let automaticDose = LoopAlgorithm.recommendAutomaticDose( + for: correction, + applicationFactor: 0.5, + neutralBasalRate: basalRate, + activeInsulin: 0, + maxBolus: 6, + maxBasalRate: maxBasalRate + ) + + XCTAssertEqual(automaticDose?.bolusUnits, 0) + XCTAssertEqual(automaticDose?.basalAdjustment?.unitsPerHour, basalRate) + + let manualDose = LoopAlgorithm.recommendManualBolus( + for: correction, + maxBolus: 6, + currentGlucose: FixtureGlucoseSample(startDate: PredictedGlucoseMocks.testDate, quantity: .glucose(value: 120)), + target: target + ) + + XCTAssertEqual(manualDose.amount, 0) + XCTAssertEqual(manualDose.notice, .predictedGlucoseInRange) + } + + func testStartHighEndInRange() { + let correction = LoopAlgorithm.insulinCorrection( + prediction: PredictedGlucoseMocks.startHighEndInRangePrediction(), + at: testDate, + target: target, + suspendThreshold: suspendThreshold, + sensitivity: sensitivity, + insulinType: .novolog + ) + + let recommendation = LoopAlgorithm.recommendTempBasal( + for: correction, + neutralBasalRate: basalRate, + activeInsulin: 0, + maxBolus: 6, + maxBasalRate: maxBasalRate + ) + + XCTAssertEqual(recommendation?.unitsPerHour, basalRate) + XCTAssertEqual(recommendation?.duration, .minutes(30)) + + let automaticDose = LoopAlgorithm.recommendAutomaticDose( + for: correction, + applicationFactor: 0.5, + neutralBasalRate: basalRate, + activeInsulin: 0, + maxBolus: 6, + maxBasalRate: maxBasalRate + ) + + XCTAssertEqual(automaticDose?.bolusUnits, 0) + XCTAssertEqual(automaticDose?.basalAdjustment?.unitsPerHour, basalRate) + + let manualDose = LoopAlgorithm.recommendManualBolus( + for: correction, + maxBolus: 6, + currentGlucose: FixtureGlucoseSample(startDate: PredictedGlucoseMocks.testDate, quantity: .glucose(value: 120)), + target: target + ) + + XCTAssertEqual(manualDose.amount, 0) + XCTAssertEqual(manualDose.notice, .predictedGlucoseInRange) + } + + func testStartLowEndInRange() { + let correction = LoopAlgorithm.insulinCorrection( + prediction: PredictedGlucoseMocks.startLowEndInRangePrediction(), + at: testDate, + target: target, + suspendThreshold: suspendThreshold, + sensitivity: sensitivity, + insulinType: .novolog + ) + + let recommendation = LoopAlgorithm.recommendTempBasal( + for: correction, + neutralBasalRate: basalRate, + activeInsulin: 0, + maxBolus: 6, + maxBasalRate: maxBasalRate + ) + + XCTAssertEqual(recommendation?.unitsPerHour, 1) + XCTAssertEqual(recommendation?.duration, .minutes(30)) + + let automaticDose = LoopAlgorithm.recommendAutomaticDose( + for: correction, + applicationFactor: 0.4, + neutralBasalRate: basalRate, + activeInsulin: 0, + maxBolus: 6, + maxBasalRate: maxBasalRate + ) + + XCTAssertEqual(automaticDose?.bolusUnits, 0) + XCTAssertEqual(automaticDose?.basalAdjustment?.unitsPerHour, basalRate) + XCTAssertEqual(automaticDose?.basalAdjustment?.duration, .minutes(30)) + + let manualDose = LoopAlgorithm.recommendManualBolus( + for: correction, + maxBolus: 6, + currentGlucose: FixtureGlucoseSample(startDate: PredictedGlucoseMocks.testDate, quantity: .glucose(value: 120)), + target: target + ) + + XCTAssertEqual(manualDose.amount, 0) + XCTAssertEqual(manualDose.notice, .predictedGlucoseInRange) + } + + func testCorrectLowAtMin() { + let correction = LoopAlgorithm.insulinCorrection( + prediction: PredictedGlucoseMocks.correctLowAtMin(), + at: testDate, + target: target, + suspendThreshold: suspendThreshold, + sensitivity: sensitivity, + insulinType: .novolog + ) + + let recommendation = LoopAlgorithm.recommendTempBasal( + for: correction, + neutralBasalRate: basalRate, + activeInsulin: 0, + maxBolus: 6, + maxBasalRate: maxBasalRate + ) + + XCTAssertEqual(recommendation?.unitsPerHour, 1) + XCTAssertEqual(recommendation?.duration, .minutes(30)) + + let automaticDose = LoopAlgorithm.recommendAutomaticDose( + for: correction, + applicationFactor: 0.4, + neutralBasalRate: basalRate, + activeInsulin: 0, + maxBolus: 6, + maxBasalRate: maxBasalRate + ) + + XCTAssertEqual(automaticDose?.bolusUnits, 0) + XCTAssertEqual(automaticDose?.basalAdjustment?.unitsPerHour, basalRate) + XCTAssertEqual(automaticDose?.basalAdjustment?.duration, .minutes(30)) + + let manualDose = LoopAlgorithm.recommendManualBolus( + for: correction, + maxBolus: 6, + currentGlucose: FixtureGlucoseSample(startDate: PredictedGlucoseMocks.testDate, quantity: .glucose(value: 120)), + target: target + ) + + XCTAssertEqual(manualDose.amount, 0) + XCTAssertEqual(manualDose.notice, .predictedGlucoseInRange) + } + + + func testStartHighEndLow() { + let correction = LoopAlgorithm.insulinCorrection( + prediction: PredictedGlucoseMocks.startHighEndLow(), + at: testDate, + target: target, + suspendThreshold: suspendThreshold, + sensitivity: sensitivity, + insulinType: .novolog + ) + + let recommendation = LoopAlgorithm.recommendTempBasal( + for: correction, + neutralBasalRate: basalRate, + activeInsulin: 0, + maxBolus: 6, + maxBasalRate: maxBasalRate + ) + + XCTAssertEqual(recommendation?.unitsPerHour, 0) + XCTAssertEqual(recommendation?.duration, .minutes(30)) + + let automaticDose = LoopAlgorithm.recommendAutomaticDose( + for: correction, + applicationFactor: 0.4, + neutralBasalRate: basalRate, + activeInsulin: 0, + maxBolus: 6, + maxBasalRate: maxBasalRate + ) + + XCTAssertEqual(automaticDose?.bolusUnits, 0) + XCTAssertEqual(automaticDose?.basalAdjustment?.unitsPerHour, 0) + XCTAssertEqual(automaticDose?.basalAdjustment?.duration, .minutes(30)) + + let manualDose = LoopAlgorithm.recommendManualBolus( + for: correction, + maxBolus: 6, + currentGlucose: FixtureGlucoseSample(startDate: PredictedGlucoseMocks.testDate, quantity: .glucose(value: 120)), + target: target + ) + + XCTAssertEqual(manualDose.amount, 0) + if case .allGlucoseBelowTarget(minGlucose: let glucose) = manualDose.notice { + XCTAssertEqual(60, glucose.quantity.doubleValue(for: .milligramsPerDeciliter)) + } else { + XCTFail("Wrong .notice: \(String(describing: manualDose.notice))") + } + } + + func testStartLowEndHigh() { + let correction = LoopAlgorithm.insulinCorrection( + prediction: PredictedGlucoseMocks.startLowEndHigh(), + at: testDate, + target: target, + suspendThreshold: suspendThreshold, + sensitivity: sensitivity, + insulinType: .novolog + ) + + let recommendation = LoopAlgorithm.recommendTempBasal( + for: correction, + neutralBasalRate: basalRate, + activeInsulin: 0, + maxBolus: 6, + maxBasalRate: maxBasalRate + ) + + XCTAssertEqual(recommendation?.unitsPerHour, 1.0) + XCTAssertEqual(recommendation?.duration, .minutes(30)) + + let automaticDose = LoopAlgorithm.recommendAutomaticDose( + for: correction, + applicationFactor: 0.4, + neutralBasalRate: basalRate, + activeInsulin: 0, + maxBolus: 6, + maxBasalRate: maxBasalRate + ) + + XCTAssertEqual(automaticDose?.bolusUnits, 0) + XCTAssertEqual(automaticDose?.basalAdjustment?.unitsPerHour, basalRate) + XCTAssertEqual(automaticDose?.basalAdjustment?.duration, .minutes(30)) + + let manualDose = LoopAlgorithm.recommendManualBolus( + for: correction, + maxBolus: 6, + currentGlucose: FixtureGlucoseSample(startDate: PredictedGlucoseMocks.testDate, quantity: .glucose(value: 120)), + target: target + ) + + XCTAssertEqual(manualDose.amount, 1.6, accuracy: 0.05) + if case .predictedGlucoseBelowTarget(minGlucose: let glucose) = manualDose.notice { + XCTAssertEqual(60, glucose.quantity.doubleValue(for: .milligramsPerDeciliter)) + } else { + XCTFail("Wrong .notice: \(String(describing: manualDose.notice))") + } + } + + func testFlatAndHigh() { + let correction = LoopAlgorithm.insulinCorrection( + prediction: PredictedGlucoseMocks.flatAndHigh(), + at: testDate, + target: target, + suspendThreshold: suspendThreshold, + sensitivity: sensitivity, + insulinType: .novolog + ) + + let recommendation = LoopAlgorithm.recommendTempBasal( + for: correction, + neutralBasalRate: basalRate, + activeInsulin: 0, + maxBolus: 6, + maxBasalRate: maxBasalRate + ) + + XCTAssertEqual(recommendation?.unitsPerHour, 3.0) + XCTAssertEqual(recommendation?.duration, .minutes(30)) + + let automaticDose = LoopAlgorithm.recommendAutomaticDose( + for: correction, + applicationFactor: 0.4, + neutralBasalRate: basalRate, + activeInsulin: 0, + maxBolus: 6, + maxBasalRate: maxBasalRate + ) + + XCTAssertEqual(automaticDose!.bolusUnits!, 0.65, accuracy: 0.05) + XCTAssertEqual(automaticDose?.basalAdjustment?.unitsPerHour, basalRate) + XCTAssertEqual(automaticDose?.basalAdjustment?.duration, .minutes(30)) + + let manualDose = LoopAlgorithm.recommendManualBolus( + for: correction, + maxBolus: 6, + currentGlucose: FixtureGlucoseSample(startDate: PredictedGlucoseMocks.testDate, quantity: .glucose(value: 120)), + target: target + ) + + XCTAssertEqual(manualDose.amount, 1.6, accuracy: 0.05) + XCTAssertNil(manualDose.notice) + } + + + func testHighAndFalling() { + let correction = LoopAlgorithm.insulinCorrection( + prediction: PredictedGlucoseMocks.highAndFalling(), + at: testDate, + target: target, + suspendThreshold: suspendThreshold, + sensitivity: sensitivity, + insulinType: .novolog + ) + + let recommendation = LoopAlgorithm.recommendTempBasal( + for: correction, + neutralBasalRate: basalRate, + activeInsulin: 0, + maxBolus: 6, + maxBasalRate: maxBasalRate + ) + + XCTAssertEqual(recommendation!.unitsPerHour, 1.63, accuracy: 0.05) + XCTAssertEqual(recommendation?.duration, .minutes(30)) + + let automaticDose = LoopAlgorithm.recommendAutomaticDose( + for: correction, + applicationFactor: 0.4, + neutralBasalRate: basalRate, + activeInsulin: 0, + maxBolus: 6, + maxBasalRate: maxBasalRate + ) + + XCTAssertEqual(automaticDose!.bolusUnits!, 0.10, accuracy: 0.05) + XCTAssertEqual(automaticDose?.basalAdjustment?.unitsPerHour, basalRate) + XCTAssertEqual(automaticDose?.basalAdjustment?.duration, .minutes(30)) + + let manualDose = LoopAlgorithm.recommendManualBolus( + for: correction, + maxBolus: 6, + currentGlucose: FixtureGlucoseSample(startDate: PredictedGlucoseMocks.testDate, quantity: .glucose(value: 120)), + target: target + ) + + XCTAssertEqual(manualDose.amount, 0.30, accuracy: 0.05) + XCTAssertNil(manualDose.notice) + } + + func testInRangeAndRising() { + let correction = LoopAlgorithm.insulinCorrection( + prediction: PredictedGlucoseMocks.inRangeAndRising(), + at: testDate, + target: target, + suspendThreshold: suspendThreshold, + sensitivity: sensitivity, + insulinType: .novolog + ) + + let recommendation = LoopAlgorithm.recommendTempBasal( + for: correction, + neutralBasalRate: basalRate, + activeInsulin: 0, + maxBolus: 6, + maxBasalRate: maxBasalRate + ) + + XCTAssertEqual(recommendation!.unitsPerHour, 1.63, accuracy: 0.05) + XCTAssertEqual(recommendation?.duration, .minutes(30)) + + let automaticDose = LoopAlgorithm.recommendAutomaticDose( + for: correction, + applicationFactor: 0.4, + neutralBasalRate: basalRate, + activeInsulin: 0, + maxBolus: 6, + maxBasalRate: maxBasalRate + ) + + XCTAssertEqual(automaticDose!.bolusUnits!, 0.10, accuracy: 0.05) + XCTAssertEqual(automaticDose?.basalAdjustment?.unitsPerHour, basalRate) + XCTAssertEqual(automaticDose?.basalAdjustment?.duration, .minutes(30)) + + let manualDose = LoopAlgorithm.recommendManualBolus( + for: correction, + maxBolus: 6, + currentGlucose: FixtureGlucoseSample(startDate: PredictedGlucoseMocks.testDate, quantity: .glucose(value: 120)), + target: target + ) + + XCTAssertEqual(manualDose.amount, 0.30, accuracy: 0.05) + XCTAssertNil(manualDose.notice) + } + + func testHighAndRising() { + let correction = LoopAlgorithm.insulinCorrection( + prediction: PredictedGlucoseMocks.highAndRising(), + at: testDate, + target: target, + suspendThreshold: suspendThreshold, + sensitivity: sensitivity, + insulinType: .novolog + ) + + let recommendation = LoopAlgorithm.recommendTempBasal( + for: correction, + neutralBasalRate: basalRate, + activeInsulin: 0, + maxBolus: 6, + maxBasalRate: maxBasalRate + ) + + XCTAssertEqual(recommendation!.unitsPerHour, 3.0, accuracy: 0.05) + XCTAssertEqual(recommendation?.duration, .minutes(30)) + + let automaticDose = LoopAlgorithm.recommendAutomaticDose( + for: correction, + applicationFactor: 0.4, + neutralBasalRate: basalRate, + activeInsulin: 0, + maxBolus: 6, + maxBasalRate: maxBasalRate + ) + + XCTAssertEqual(automaticDose!.bolusUnits!, 0.5, accuracy: 0.05) + XCTAssertEqual(automaticDose?.basalAdjustment?.unitsPerHour, basalRate) + XCTAssertEqual(automaticDose?.basalAdjustment?.duration, .minutes(30)) + + let manualDose = LoopAlgorithm.recommendManualBolus( + for: correction, + maxBolus: 6, + currentGlucose: FixtureGlucoseSample(startDate: PredictedGlucoseMocks.testDate, quantity: .glucose(value: 120)), + target: target + ) + + XCTAssertEqual(manualDose.amount, 1.25, accuracy: 0.05) + XCTAssertNil(manualDose.notice) + } + + func testVeryLowAndRising() { + let correction = LoopAlgorithm.insulinCorrection( + prediction: PredictedGlucoseMocks.veryLowAndRising(), + at: testDate, + target: target, + suspendThreshold: suspendThreshold, + sensitivity: sensitivity, + insulinType: .novolog + ) + + let recommendation = LoopAlgorithm.recommendTempBasal( + for: correction, + neutralBasalRate: basalRate, + activeInsulin: 0, + maxBolus: 6, + maxBasalRate: maxBasalRate + ) + + XCTAssertEqual(recommendation!.unitsPerHour, 0, accuracy: 0.05) + XCTAssertEqual(recommendation?.duration, .minutes(30)) + + let automaticDose = LoopAlgorithm.recommendAutomaticDose( + for: correction, + applicationFactor: 0.4, + neutralBasalRate: basalRate, + activeInsulin: 0, + maxBolus: 6, + maxBasalRate: maxBasalRate + ) + + XCTAssertEqual(automaticDose!.bolusUnits!, 0.0, accuracy: 0.05) + XCTAssertEqual(automaticDose?.basalAdjustment?.unitsPerHour, 0) + XCTAssertEqual(automaticDose?.basalAdjustment?.duration, .minutes(30)) + + let manualDose = LoopAlgorithm.recommendManualBolus( + for: correction, + maxBolus: 6, + currentGlucose: FixtureGlucoseSample(startDate: PredictedGlucoseMocks.testDate, quantity: .glucose(value: 120)), + target: target + ) + + XCTAssertEqual(manualDose.amount, 0, accuracy: 0.05) + if case .glucoseBelowSuspendThreshold(minGlucose: let glucose) = manualDose.notice { + XCTAssertEqual(50, glucose.quantity.doubleValue(for: .milligramsPerDeciliter)) + } else { + XCTFail("Wrong .notice: \(String(describing: manualDose.notice))") + } + } +} diff --git a/Tests/LoopAlgorithmTests/Fixtures/carbs_with_isf_change_input.json b/Tests/LoopAlgorithmTests/Fixtures/carbs_with_isf_change_input.json index a5870fb..4185c54 100644 --- a/Tests/LoopAlgorithmTests/Fixtures/carbs_with_isf_change_input.json +++ b/Tests/LoopAlgorithmTests/Fixtures/carbs_with_isf_change_input.json @@ -54,31 +54,31 @@ { "endDate" : "2023-10-17T18:47:27Z", "startDate" : "2023-10-17T18:27:31Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-17T19:07:29Z", "startDate" : "2023-10-17T18:47:27Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-17T19:27:29Z", "startDate" : "2023-10-17T19:07:30Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-17T19:47:27Z", "startDate" : "2023-10-17T19:27:29Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-17T20:02:29Z", "startDate" : "2023-10-17T19:47:28Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { @@ -96,7 +96,7 @@ { "endDate" : "2023-10-17T21:02:27Z", "startDate" : "2023-10-17T20:57:29Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { @@ -108,13 +108,13 @@ { "endDate" : "2023-10-17T21:22:32Z", "startDate" : "2023-10-17T21:17:29Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0.05 }, { "endDate" : "2023-10-17T21:37:38Z", "startDate" : "2023-10-17T21:32:44Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { @@ -126,7 +126,7 @@ { "endDate" : "2023-10-17T22:07:27Z", "startDate" : "2023-10-17T21:57:33Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0.05 }, { @@ -138,19 +138,19 @@ { "endDate" : "2023-10-17T22:32:28Z", "startDate" : "2023-10-17T22:07:27Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-17T22:42:29Z", "startDate" : "2023-10-17T22:32:29Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-17T22:52:30Z", "startDate" : "2023-10-17T22:47:32Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { @@ -198,7 +198,7 @@ { "endDate" : "2023-10-18T00:42:28Z", "startDate" : "2023-10-18T00:32:30Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0.05 }, { @@ -216,13 +216,13 @@ { "endDate" : "2023-10-18T01:17:26Z", "startDate" : "2023-10-18T01:07:30Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0.05 }, { "endDate" : "2023-10-18T01:22:29Z", "startDate" : "2023-10-18T01:17:27Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0.05 }, { @@ -258,31 +258,31 @@ { "endDate" : "2023-10-18T03:27:39Z", "startDate" : "2023-10-18T03:17:30Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-18T03:32:27Z", "startDate" : "2023-10-18T03:27:39Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-18T03:37:30Z", "startDate" : "2023-10-18T03:32:28Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-18T03:57:28Z", "startDate" : "2023-10-18T03:52:32Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-18T04:22:32Z", "startDate" : "2023-10-18T04:00:00Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { @@ -318,31 +318,31 @@ { "endDate" : "2023-10-18T04:57:43Z", "startDate" : "2023-10-18T04:47:28Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-18T05:07:27Z", "startDate" : "2023-10-18T05:02:34Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-18T05:17:29Z", "startDate" : "2023-10-18T05:12:49Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-18T05:22:27Z", "startDate" : "2023-10-18T05:17:30Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-18T05:52:29Z", "startDate" : "2023-10-18T05:42:30Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { @@ -360,13 +360,13 @@ { "endDate" : "2023-10-18T06:37:31Z", "startDate" : "2023-10-18T06:32:30Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-18T06:42:48Z", "startDate" : "2023-10-18T06:37:32Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { @@ -390,13 +390,13 @@ { "endDate" : "2023-10-18T07:37:27Z", "startDate" : "2023-10-18T07:32:39Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-18T07:52:28Z", "startDate" : "2023-10-18T07:37:27Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { @@ -408,7 +408,7 @@ { "endDate" : "2023-10-18T08:42:28Z", "startDate" : "2023-10-18T08:32:32Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { @@ -420,7 +420,7 @@ { "endDate" : "2023-10-18T09:17:27Z", "startDate" : "2023-10-18T09:12:29Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { @@ -444,13 +444,13 @@ { "endDate" : "2023-10-18T10:17:49Z", "startDate" : "2023-10-18T10:12:30Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-18T10:37:27Z", "startDate" : "2023-10-18T10:32:29Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 } ], diff --git a/Tests/LoopAlgorithmTests/Fixtures/suspend_input.json b/Tests/LoopAlgorithmTests/Fixtures/suspend_input.json index c1bf98f..48056ee 100644 --- a/Tests/LoopAlgorithmTests/Fixtures/suspend_input.json +++ b/Tests/LoopAlgorithmTests/Fixtures/suspend_input.json @@ -102,7 +102,7 @@ { "endDate" : "2023-10-17T05:59:08Z", "startDate" : "2023-10-17T05:39:08Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { @@ -114,7 +114,7 @@ { "endDate" : "2023-10-17T06:09:08Z", "startDate" : "2023-10-17T06:04:31Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { @@ -186,79 +186,79 @@ { "endDate" : "2023-10-17T07:19:09Z", "startDate" : "2023-10-17T07:14:07Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-17T07:34:07Z", "startDate" : "2023-10-17T07:19:09Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-17T07:39:08Z", "startDate" : "2023-10-17T07:34:07Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-17T07:59:06Z", "startDate" : "2023-10-17T07:39:09Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-17T08:19:07Z", "startDate" : "2023-10-17T07:59:06Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-17T08:34:09Z", "startDate" : "2023-10-17T08:19:08Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-17T08:44:06Z", "startDate" : "2023-10-17T08:39:14Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-17T10:09:13Z", "startDate" : "2023-10-17T09:59:17Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-17T10:39:13Z", "startDate" : "2023-10-17T10:19:09Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-17T10:59:10Z", "startDate" : "2023-10-17T10:39:14Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-17T11:19:07Z", "startDate" : "2023-10-17T10:59:10Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-17T11:39:08Z", "startDate" : "2023-10-17T11:19:08Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-17T11:44:09Z", "startDate" : "2023-10-17T11:39:09Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { @@ -306,7 +306,7 @@ { "endDate" : "2023-10-17T12:49:05Z", "startDate" : "2023-10-17T12:44:07Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0.05 }, { @@ -342,49 +342,49 @@ { "endDate" : "2023-10-17T13:44:12Z", "startDate" : "2023-10-17T13:29:15Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-17T13:49:09Z", "startDate" : "2023-10-17T13:44:12Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-17T13:54:09Z", "startDate" : "2023-10-17T13:49:09Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-17T13:59:06Z", "startDate" : "2023-10-17T13:54:09Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-17T14:04:07Z", "startDate" : "2023-10-17T13:59:07Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-17T14:09:09Z", "startDate" : "2023-10-17T14:04:07Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-17T14:14:07Z", "startDate" : "2023-10-17T14:09:09Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-17T14:24:09Z", "startDate" : "2023-10-17T14:14:08Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0.05 }, { @@ -474,19 +474,19 @@ { "endDate" : "2023-10-17T16:09:06Z", "startDate" : "2023-10-17T16:04:08Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-17T16:14:07Z", "startDate" : "2023-10-17T16:09:07Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0.05 }, { "endDate" : "2023-10-17T16:24:07Z", "startDate" : "2023-10-17T16:19:08Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { @@ -534,7 +534,7 @@ { "endDate" : "2023-10-17T17:04:07Z", "startDate" : "2023-10-17T16:59:12Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0.05 }, { @@ -588,7 +588,7 @@ { "endDate" : "2023-10-17T18:14:07Z", "startDate" : "2023-10-17T18:09:08Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0.05 }, { @@ -600,7 +600,7 @@ { "endDate" : "2023-10-17T18:39:09Z", "startDate" : "2023-10-17T18:29:07Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0.1 }, { @@ -612,49 +612,49 @@ { "endDate" : "2023-10-17T18:54:08Z", "startDate" : "2023-10-17T18:49:09Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0.05 }, { "endDate" : "2023-10-17T18:59:10Z", "startDate" : "2023-10-17T18:54:09Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0.05 }, { "endDate" : "2023-10-17T19:09:07Z", "startDate" : "2023-10-17T18:59:10Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-17T19:14:09Z", "startDate" : "2023-10-17T19:09:07Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0.05 }, { "endDate" : "2023-10-17T19:34:08Z", "startDate" : "2023-10-17T19:14:10Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-17T19:54:07Z", "startDate" : "2023-10-17T19:34:08Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-17T20:14:17Z", "startDate" : "2023-10-17T19:54:08Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { "endDate" : "2023-10-17T20:34:08Z", "startDate" : "2023-10-17T20:14:17Z", - "type" : "tempBasal", + "type" : "basal", "volume" : 0 }, { diff --git a/Tests/LoopAlgorithmTests/Mocks/PredictedGlucoseMocks.swift b/Tests/LoopAlgorithmTests/Mocks/PredictedGlucoseMocks.swift new file mode 100644 index 0000000..894f4d0 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Mocks/PredictedGlucoseMocks.swift @@ -0,0 +1,124 @@ +// +// File.swift +// +// +// Created by Pete Schwamb on 1/11/24. +// + +import XCTest +import HealthKit +@testable import LoopAlgorithm + +class PredictedGlucoseMocks { + + static let testDate = ISO8601DateFormatter().date(from: "2024-01-03T12:00:00+0000")! + + var testDate: Date { + return PredictedGlucoseMocks.testDate + } + + static func noChangePrediction() -> [PredictedGlucoseValue] { + return [ + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(0)), quantity: .glucose(value: 100)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(6.2)), quantity: .glucose(value: 100)) + ] + } + + static func startHighEndInRangePrediction() -> [PredictedGlucoseValue] { + return [ + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(0.0)), quantity: .glucose(value: 200)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(0.5)), quantity: .glucose(value: 180)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(1.0)), quantity: .glucose(value: 150)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(1.5)), quantity: .glucose(value: 120)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(6.2)), quantity: .glucose(value: 100)) + ] + } + + static func startLowEndInRangePrediction() -> [PredictedGlucoseValue] { + return [ + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(0.0)), quantity: .glucose(value: 60)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(0.5)), quantity: .glucose(value: 70)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(1.0)), quantity: .glucose(value: 80)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(1.5)), quantity: .glucose(value: 90)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(6.2)), quantity: .glucose(value: 100)) + ] + } + + static func correctLowAtMin() -> [PredictedGlucoseValue] { + return [ + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(0.0)), quantity: .glucose(value: 100)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(0.5)), quantity: .glucose(value: 90)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(1.0)), quantity: .glucose(value: 85)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(1.5)), quantity: .glucose(value: 90)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(6.2)), quantity: .glucose(value: 100)) + ] + } + + static func startHighEndLow() -> [PredictedGlucoseValue] { + return [ + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(0.0)), quantity: .glucose(value: 200)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(0.5)), quantity: .glucose(value: 160)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(1.0)), quantity: .glucose(value: 120)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(1.5)), quantity: .glucose(value: 80)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(6.2)), quantity: .glucose(value: 60)) + ] + } + + static func startLowEndHigh() -> [PredictedGlucoseValue] { + return [ + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(0.0)), quantity: .glucose(value: 60)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(0.5)), quantity: .glucose(value: 80)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(1.0)), quantity: .glucose(value: 120)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(1.5)), quantity: .glucose(value: 160)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(6.2)), quantity: .glucose(value: 200)) + ] + } + + static func flatAndHigh() -> [PredictedGlucoseValue] { + return [ + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(0.0)), quantity: .glucose(value: 200)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(6.2)), quantity: .glucose(value: 200)) + ] + } + + static func highAndFalling() -> [PredictedGlucoseValue] { + return [ + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(0.0)), quantity: .glucose(value: 240)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(1.0)), quantity: .glucose(value: 220)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(2.0)), quantity: .glucose(value: 200)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(3.0)), quantity: .glucose(value: 160)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(6.2)), quantity: .glucose(value: 124)) + ] + } + + static func inRangeAndRising() -> [PredictedGlucoseValue] { + return [ + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(0.0)), quantity: .glucose(value: 90)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(1.0)), quantity: .glucose(value: 100)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(2.0)), quantity: .glucose(value: 110)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(3.0)), quantity: .glucose(value: 120)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(6.2)), quantity: .glucose(value: 125)) + ] + } + + static func highAndRising() -> [PredictedGlucoseValue] { + return [ + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(0.0)), quantity: .glucose(value: 140)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(1.0)), quantity: .glucose(value: 150)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(2.0)), quantity: .glucose(value: 160)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(3.0)), quantity: .glucose(value: 170)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(6.2)), quantity: .glucose(value: 180)) + ] + } + + static func veryLowAndRising() -> [PredictedGlucoseValue] { + return [ + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(0.0)), quantity: .glucose(value: 60)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(1.0)), quantity: .glucose(value: 50)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(2.0)), quantity: .glucose(value: 60)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(3.0)), quantity: .glucose(value: 70)), + PredictedGlucoseValue(startDate: testDate.addingTimeInterval(.hours(6.2)), quantity: .glucose(value: 100)) + ] + } + +} From 86651794fa2321ccdcf8b7048082f76c2dd7588d Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 16 Jan 2024 12:54:31 -0600 Subject: [PATCH 10/26] Add user entered flag to glucose sample --- .../Glucose/FixtureGlucoseSample.swift | 14 ++++++++++++-- .../LoopAlgorithm/Glucose/GlucoseSampleValue.swift | 3 +++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/Sources/LoopAlgorithm/Glucose/FixtureGlucoseSample.swift b/Sources/LoopAlgorithm/Glucose/FixtureGlucoseSample.swift index 20ad8a1..3e13596 100644 --- a/Sources/LoopAlgorithm/Glucose/FixtureGlucoseSample.swift +++ b/Sources/LoopAlgorithm/Glucose/FixtureGlucoseSample.swift @@ -15,17 +15,20 @@ public struct FixtureGlucoseSample: GlucoseSampleValue, Equatable { public let startDate: Date public let quantity: HKQuantity public let isDisplayOnly: Bool + public let wasUserEntered: Bool public init( provenanceIdentifier: String = Self.defaultProvenanceIdentifier, startDate: Date, quantity: HKQuantity, - isDisplayOnly: Bool = false + isDisplayOnly: Bool = false, + wasUserEntered: Bool = false ) { self.provenanceIdentifier = provenanceIdentifier self.startDate = startDate self.quantity = quantity self.isDisplayOnly = isDisplayOnly + self.wasUserEntered = wasUserEntered } } @@ -35,11 +38,14 @@ extension FixtureGlucoseSample: Codable { let provenanceIdentifier = try container.decodeIfPresent(String.self, forKey: .provenanceIdentifier) ?? Self.defaultProvenanceIdentifier let isDisplayOnly = try container.decodeIfPresent(Bool.self, forKey: .isDisplayOnly) ?? false + let wasUserEntered = try container.decodeIfPresent(Bool.self, forKey: .wasUserEntered) ?? false self.init(provenanceIdentifier: provenanceIdentifier, startDate: try container.decode(Date.self, forKey: .startDate), quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: try container.decode(Double.self, forKey: .quantity)), - isDisplayOnly: isDisplayOnly) + isDisplayOnly: isDisplayOnly, + wasUserEntered: wasUserEntered + ) } public func encode(to encoder: Encoder) throws { @@ -52,6 +58,9 @@ extension FixtureGlucoseSample: Codable { if isDisplayOnly { try container.encode(isDisplayOnly, forKey: .isDisplayOnly) } + if wasUserEntered { + try container.encode(wasUserEntered, forKey: .wasUserEntered) + } } private enum CodingKeys: String, CodingKey { @@ -59,5 +68,6 @@ extension FixtureGlucoseSample: Codable { case startDate case quantity case isDisplayOnly + case wasUserEntered } } diff --git a/Sources/LoopAlgorithm/Glucose/GlucoseSampleValue.swift b/Sources/LoopAlgorithm/Glucose/GlucoseSampleValue.swift index 4665c37..6a3f356 100644 --- a/Sources/LoopAlgorithm/Glucose/GlucoseSampleValue.swift +++ b/Sources/LoopAlgorithm/Glucose/GlucoseSampleValue.swift @@ -14,4 +14,7 @@ public protocol GlucoseSampleValue: GlucoseValue { /// Whether the glucose value was provided for visual consistency, rather than an actual, calibrated reading. var isDisplayOnly: Bool { get } + + /// Whether the glucose value is user entered, as opposed to a CGM value. + var wasUserEntered: Bool { get } } From bfcc441dc25169d3dc1a9fa7390199d59dc5bca3 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Thu, 25 Jan 2024 17:56:56 -0600 Subject: [PATCH 11/26] Adding CarbMathTests --- Sources/LoopAlgorithm/Carbs/CarbMath.swift | 23 - Sources/LoopAlgorithm/Insulin/DoseType.swift | 11 + .../TempBasalRecommendation.swift | 5 + Tests/LoopAlgorithmTests/CarbMathTests.swift | 743 ++++++++++ .../Extensions/DateFormatter.swift | 33 + .../Extensions/TimeZone.swift | 16 + .../Fixtures/carb_entry_input.json | 20 + ..._glucose_effect_fully_observed_output.json | 373 +++++ ...se_effect_never_fully_observed_output.json | 1092 +++++++++++++++ ...c_glucose_effect_none_observed_output.json | 372 +++++ ...cose_effect_partially_observed_output.json | 372 +++++ .../Fixtures/ice_1_hour_input.json | 92 ++ .../Fixtures/ice_1_hour_output.json | 372 +++++ .../Fixtures/ice_35_min_input.json | 37 + .../Fixtures/ice_35_min_none_output.json | 372 +++++ ...ce_35_min_none_piecewiselinear_output.json | 372 +++++ .../Fixtures/ice_35_min_partial_output.json | 372 +++++ ...l_piecewiselinear_adaptiverate_output.json | 372 +++++ ...35_min_partial_piecewiselinear_output.json | 372 +++++ .../Fixtures/ice_slow_absorption.json | 92 ++ .../Fixtures/ice_slow_absorption_output.json | 1092 +++++++++++++++ ...low_absorption_piecewiselinear_output.json | 1092 +++++++++++++++ .../LoopAlgorithmTests/InsulinMathTests.swift | 1246 +++++++++++++++++ 23 files changed, 8920 insertions(+), 23 deletions(-) create mode 100644 Tests/LoopAlgorithmTests/CarbMathTests.swift create mode 100644 Tests/LoopAlgorithmTests/Extensions/DateFormatter.swift create mode 100644 Tests/LoopAlgorithmTests/Extensions/TimeZone.swift create mode 100644 Tests/LoopAlgorithmTests/Fixtures/carb_entry_input.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/dynamic_glucose_effect_fully_observed_output.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/dynamic_glucose_effect_never_fully_observed_output.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/dynamic_glucose_effect_none_observed_output.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/dynamic_glucose_effect_partially_observed_output.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/ice_1_hour_input.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/ice_1_hour_output.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/ice_35_min_input.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/ice_35_min_none_output.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/ice_35_min_none_piecewiselinear_output.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/ice_35_min_partial_output.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/ice_35_min_partial_piecewiselinear_adaptiverate_output.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/ice_35_min_partial_piecewiselinear_output.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/ice_slow_absorption.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/ice_slow_absorption_output.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/ice_slow_absorption_piecewiselinear_output.json create mode 100644 Tests/LoopAlgorithmTests/InsulinMathTests.swift diff --git a/Sources/LoopAlgorithm/Carbs/CarbMath.swift b/Sources/LoopAlgorithm/Carbs/CarbMath.swift index 9ae8147..bf10ce9 100644 --- a/Sources/LoopAlgorithm/Carbs/CarbMath.swift +++ b/Sources/LoopAlgorithm/Carbs/CarbMath.swift @@ -281,29 +281,6 @@ extension Collection where Element: CarbEntry { } } - /// Creates groups of entries that have overlapping absorption date intervals - /// - /// - Parameters: - /// - defaultAbsorptionTime: The default absorption time value, if not set on the entry - /// - Returns: An array of arrays representing groups of entries, in chronological order by entry startDate - func groupedByOverlappingAbsorptionTimes( - defaultAbsorptionTime: TimeInterval - ) -> [[Iterator.Element]] { - var batches: [[Iterator.Element]] = [] - - for entry in sorted(by: { $0.startDate < $1.startDate }) { - if let lastEntry = batches.last?.last, - lastEntry.startDate.addingTimeInterval(lastEntry.absorptionTime ?? defaultAbsorptionTime) > entry.startDate - { - batches[batches.count - 1].append(entry) - } else { - batches.append([entry]) - } - } - - return batches - } - func carbsOnBoard( from start: Date? = nil, to end: Date? = nil, diff --git a/Sources/LoopAlgorithm/Insulin/DoseType.swift b/Sources/LoopAlgorithm/Insulin/DoseType.swift index 87f4f1b..27a460a 100644 --- a/Sources/LoopAlgorithm/Insulin/DoseType.swift +++ b/Sources/LoopAlgorithm/Insulin/DoseType.swift @@ -12,6 +12,17 @@ import Foundation public enum InsulinDeliveryType: String, CaseIterable, Equatable { case bolus case basal + + init?(fixtureValue: String) { + switch fixtureValue { + case "TempBasal": + self = .basal + case "Bolus": + self = .bolus + default: + return nil + } + } } extension InsulinDeliveryType: Codable {} diff --git a/Sources/LoopAlgorithm/TempBasalRecommendation.swift b/Sources/LoopAlgorithm/TempBasalRecommendation.swift index 5bb597a..d1bac6f 100644 --- a/Sources/LoopAlgorithm/TempBasalRecommendation.swift +++ b/Sources/LoopAlgorithm/TempBasalRecommendation.swift @@ -13,6 +13,11 @@ public struct TempBasalRecommendation: Equatable { public var unitsPerHour: Double public let duration: TimeInterval + /// A special command which cancels any existing temp basals + public static var cancel: TempBasalRecommendation { + return self.init(unitsPerHour: 0, duration: 0) + } + public var rateQuantity: HKQuantity { return HKQuantity(unit: .internationalUnitsPerHour, doubleValue: unitsPerHour) } diff --git a/Tests/LoopAlgorithmTests/CarbMathTests.swift b/Tests/LoopAlgorithmTests/CarbMathTests.swift new file mode 100644 index 0000000..b66c86b --- /dev/null +++ b/Tests/LoopAlgorithmTests/CarbMathTests.swift @@ -0,0 +1,743 @@ +// +// CarbMathTests.swift +// CarbKitTests +// +// Created by Nathan Racklyeft on 1/18/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import XCTest +import HealthKit +@testable import LoopAlgorithm + +class CarbMathTests: XCTestCase { + + public func loadFixture(_ resourceName: String) -> T { + let url = Bundle.module.url(forResource: resourceName, withExtension: "json", subdirectory: "Fixtures")! + return try! JSONSerialization.jsonObject(with: Data(contentsOf: url), options: []) as! T + } + + private func loadEffectOutputFixture(_ name: String) -> [GlucoseEffect] { + let fixture: [JSONDictionary] = loadFixture(name) + let dateFormatter = ISO8601DateFormatter.localTimeDate(timeZone: TimeZone(secondsFromGMT: 0)!) + + return fixture.map { + return GlucoseEffect(startDate: dateFormatter.date(from: $0["date"] as! String)!, quantity: HKQuantity(unit: HKUnit(from: $0["unit"] as! String), doubleValue:$0["amount"] as! Double)) + } + } + + private func loadCOBOutputFixture(_ name: String) -> [CarbValue] { + let fixture: [JSONDictionary] = loadFixture(name) + let dateFormatter = ISO8601DateFormatter.localTimeDate(timeZone: TimeZone(secondsFromGMT: 0)!) + + return fixture.map { + return CarbValue(startDate: dateFormatter.date(from: $0["date"] as! String)!, value: $0["amount"] as! Double) + } + } + + private func loadHistoryFixture(_ name: String) -> [FixtureCarbEntry] { + let fixture: [JSONDictionary] = loadFixture(name) + return carbEntriesFromFixture(fixture) + } + + private func loadCarbEntryFixture() -> [FixtureCarbEntry] { + let fixture: [JSONDictionary] = loadFixture("carb_entry_input") + return carbEntriesFromFixture(fixture) + } + + private func carbEntriesFromFixture(_ fixture: [JSONDictionary]) -> [FixtureCarbEntry] { + let dateFormatter = ISO8601DateFormatter.localTimeDate(timeZone: TimeZone(secondsFromGMT: 0)!) + + return fixture.map { + let absorptionTime: TimeInterval? + if let absorptionTimeMinutes = $0["absorption_time"] as? Double { + absorptionTime = TimeInterval(minutes: absorptionTimeMinutes) + } else { + absorptionTime = nil + } + let startAt = dateFormatter.date(from: $0["start_at"] as! String)! + return FixtureCarbEntry( + absorptionTime: absorptionTime, + startDate: startAt, + quantity: HKQuantity(unit: HKUnit(from: $0["unit"] as! String), doubleValue: $0["amount"] as! Double) + ) + } + } + + + private func loadICEInputFixture(_ name: String) -> [GlucoseEffectVelocity] { + let fixture: [JSONDictionary] = loadFixture(name) + let dateFormatter = ISO8601DateFormatter.localTimeDate(timeZone: TimeZone(secondsFromGMT: 0)!) + + let unit = HKUnit.milligramsPerDeciliter.unitDivided(by: .minute()) + + return fixture.map { + let quantity = HKQuantity(unit: unit, doubleValue: $0["velocity"] as! Double) + return GlucoseEffectVelocity( + startDate: dateFormatter.date(from: $0["start_at"] as! String)!, + endDate: dateFormatter.date(from: $0["end_at"] as! String)!, + quantity: quantity) + } + } + + func testCarbEffectWithZeroEntry() { + let inputICE = loadICEInputFixture("ice_35_min_input") + let startDate = inputICE[0].startDate // "2015-10-15T21:30:12" + let endDate = inputICE.last!.startDate + let carbEntry = FixtureCarbEntry( + absorptionTime: .minutes(120), + startDate: startDate, + quantity: HKQuantity(unit: HKUnit.gram(), doubleValue: 0) + ) + + let carbRatio = [AbsoluteScheduleValue(startDate: startDate, endDate: endDate, value: 8.0)] + let isf = [AbsoluteScheduleValue(startDate: startDate, endDate: endDate, value: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 40))] + + let statuses = [carbEntry].map( + to: inputICE, + carbRatio: carbRatio, + insulinSensitivity: isf, + absorptionModel: LinearAbsorption() + ) + + XCTAssertEqual(statuses.count, 1) + XCTAssertEqual(statuses[0].absorption?.estimatedTimeRemaining, 0) + } + + func testDynamicGlucoseEffectAbsorptionNoneObserved() { + let inputICE = loadICEInputFixture("ice_35_min_input") + let carbEntries = loadCarbEntryFixture() + let output = loadEffectOutputFixture("dynamic_glucose_effect_none_observed_output") + + let startDate = inputICE[0].startDate // "2015-10-15T21:30:12" + let endDate = inputICE.last!.startDate + + let carbRatio = [AbsoluteScheduleValue(startDate: startDate, endDate: endDate, value: 9.0)] + let isf = [AbsoluteScheduleValue(startDate: startDate, endDate: endDate, value: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 40))] + + let futureCarbEntry = carbEntries[2] + + let statuses = [futureCarbEntry].map( + to: inputICE, + carbRatio: carbRatio, + insulinSensitivity: isf, + initialAbsorptionTimeOverrun: 2.0, + absorptionModel: LinearAbsorption() + ) + + XCTAssertEqual(statuses.count, 1) + + // Full absorption remains + XCTAssertEqual(statuses[0].absorption!.estimatedTimeRemaining, TimeInterval(hours: 4), accuracy: 1) + + let effects = statuses.dynamicGlucoseEffects( + from: inputICE[0].startDate, + to: inputICE[0].startDate.addingTimeInterval(TimeInterval(hours: 6)), + carbRatios: carbRatio, + insulinSensitivities: isf, + absorptionModel: LinearAbsorption(), + delay: 0 + ) + + XCTAssertEqual(output.count, effects.count) + + for (expected, calculated) in zip(output, effects) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: Double(Float.ulpOfOne)) + } + } + + func testDynamicAbsorptionNoneObserved() { + let inputICE = loadICEInputFixture("ice_35_min_input") + let carbEntries = loadCarbEntryFixture() + let output = loadCOBOutputFixture("ice_35_min_none_output") + + let startDate = inputICE[0].startDate // "2015-10-15T21:30:12" + let endDate = inputICE.last!.startDate + + let carbRatio = [AbsoluteScheduleValue(startDate: startDate, endDate: endDate, value: 8.0)] + let isf = [AbsoluteScheduleValue(startDate: startDate, endDate: endDate, value: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 40))] + + let futureCarbEntry = carbEntries[2] + + let statuses = [futureCarbEntry].map( + to: inputICE, + carbRatio: carbRatio, + insulinSensitivity: isf, + initialAbsorptionTimeOverrun: 2.0, + absorptionModel: LinearAbsorption() + ) + + XCTAssertEqual(statuses.count, 1) + + // Full absorption remains + XCTAssertEqual(statuses[0].absorption!.estimatedTimeRemaining, TimeInterval(hours: 4), accuracy: 1) + + let carbsOnBoard = statuses.dynamicCarbsOnBoard( + from: inputICE[0].startDate, + to: inputICE[0].startDate.addingTimeInterval(TimeInterval(hours: 6)), + absorptionModel: LinearAbsorption()) + + let unit = HKUnit.gram() + + XCTAssertEqual(output.count, carbsOnBoard.count) + + for (expected, calculated) in zip(output, carbsOnBoard) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: HKUnit.gram()), calculated.quantity.doubleValue(for: HKUnit.gram()), accuracy: Double(Float.ulpOfOne)) + } + + XCTAssertEqual(carbsOnBoard.first!.quantity.doubleValue(for: unit), 0, accuracy: 1) + XCTAssertEqual(carbsOnBoard.last!.quantity.doubleValue(for: unit), 0, accuracy: 1) + } + + func testDynamicAbsorptionPartiallyObserved() { + let inputICE = loadICEInputFixture("ice_35_min_input") + let carbEntries = loadCarbEntryFixture() + let output = loadCOBOutputFixture("ice_35_min_partial_output") + + let startDate = inputICE[0].startDate // "2015-10-15T21:30:12" + let endDate = inputICE.last!.startDate + + let carbRatio = [AbsoluteScheduleValue(startDate: startDate, endDate: endDate, value: 8.0)] + let isf = [AbsoluteScheduleValue(startDate: startDate, endDate: endDate, value: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 40))] + + let statuses = [carbEntries[0]].map( + to: inputICE, + carbRatio: carbRatio, + insulinSensitivity: isf, + initialAbsorptionTimeOverrun: 2.0, + absorptionModel: LinearAbsorption() + ) + + XCTAssertEqual(statuses.count, 1) + + XCTAssertEqual(statuses[0].absorption!.estimatedTimeRemaining, 8509, accuracy: 1) + + let absorption = statuses[0].absorption! + let unit = HKUnit.gram() + + XCTAssertEqual(absorption.observed.doubleValue(for: unit), 18, accuracy: Double(Float.ulpOfOne)) + + let carbsOnBoard = statuses.dynamicCarbsOnBoard( + from: inputICE[0].startDate, + to: inputICE[0].startDate.addingTimeInterval(TimeInterval(hours: 6)), + absorptionModel: LinearAbsorption() + ) + + XCTAssertEqual(output.count, carbsOnBoard.count) + + for (expected, calculated) in zip(output, carbsOnBoard) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: HKUnit.gram()), calculated.quantity.doubleValue(for: HKUnit.gram()), accuracy: Double(Float.ulpOfOne)) + } + + XCTAssertEqual(carbsOnBoard.first!.quantity.doubleValue(for: unit), 0, accuracy: 1) + XCTAssertEqual(carbsOnBoard[2].quantity.doubleValue(for: unit), 44, accuracy: 1) + XCTAssertEqual(carbsOnBoard[25].quantity.doubleValue(for: unit), 9, accuracy: 1) + XCTAssertEqual(carbsOnBoard.last!.quantity.doubleValue(for: unit), 0, accuracy: 1) + } + + func testDynamicGlucoseEffectAbsorptionPartiallyObserved() { + let inputICE = loadICEInputFixture("ice_35_min_input") + let carbEntries = loadCarbEntryFixture() + let output = loadEffectOutputFixture("dynamic_glucose_effect_partially_observed_output") + + let startDate = inputICE[0].startDate // "2015-10-15T21:30:12" + let endDate = inputICE.last!.startDate + + let carbRatio = [AbsoluteScheduleValue(startDate: startDate, endDate: endDate, value: 8.0)] + let isf = [AbsoluteScheduleValue(startDate: startDate, endDate: endDate, value: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 40))] + + let statuses = [carbEntries[0]].map( + to: inputICE, + carbRatio: carbRatio, + insulinSensitivity: isf, + initialAbsorptionTimeOverrun: 2.0, + absorptionModel: LinearAbsorption() + ) + + XCTAssertEqual(statuses.count, 1) + + XCTAssertEqual(statuses[0].absorption!.estimatedTimeRemaining, 8509, accuracy: 1) + + let absorption = statuses[0].absorption! + let unit = HKUnit.gram() + + XCTAssertEqual(absorption.observed.doubleValue(for: unit), 18, accuracy: Double(Float.ulpOfOne)) + + let effects = statuses.dynamicGlucoseEffects( + from: inputICE[0].startDate, + to: inputICE[0].startDate.addingTimeInterval(TimeInterval(hours: 6)), + carbRatios: carbRatio, + insulinSensitivities: isf, + absorptionModel: LinearAbsorption() + ) + + XCTAssertEqual(output.count, effects.count) + + for (expected, calculated) in zip(output, effects) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: Double(Float.ulpOfOne)) + } + } + + + func testDynamicAbsorptionFullyObserved() { + let inputICE = loadICEInputFixture("ice_1_hour_input") + let carbEntries = loadCarbEntryFixture() + let output = loadCOBOutputFixture("ice_1_hour_output") + + let startDate = inputICE[0].startDate // "2015-10-15T21:30:12" + let endDate = inputICE.last!.startDate + + let carbRatio = [AbsoluteScheduleValue(startDate: startDate, endDate: endDate, value: 8.0)] + let isf = [AbsoluteScheduleValue(startDate: startDate, endDate: endDate, value: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 40))] + + let statuses = [carbEntries[0]].map( + to: inputICE, + carbRatio: carbRatio, + insulinSensitivity: isf, + absorptionTimeOverrun: 2.0, + initialAbsorptionTimeOverrun: 2.0, + absorptionModel: LinearAbsorption() + ) + + XCTAssertEqual(statuses.count, 1) + XCTAssertNotNil(statuses[0].absorption) + + // No remaining absorption + XCTAssertEqual(statuses[0].absorption!.estimatedTimeRemaining, 0, accuracy: 1) + + let absorption = statuses[0].absorption! + let unit = HKUnit.gram() + + // All should be absorbed + XCTAssertEqual(absorption.observed.doubleValue(for: unit), 44, accuracy: 1) + + let carbsOnBoard = statuses.dynamicCarbsOnBoard( + from: inputICE[0].startDate, + to: inputICE[0].startDate.addingTimeInterval(TimeInterval(hours: 6)), + absorptionModel: LinearAbsorption() + ) + + XCTAssertEqual(output.count, carbsOnBoard.count) + + for (expected, calculated) in zip(output, carbsOnBoard) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: HKUnit.gram()), calculated.quantity.doubleValue(for: HKUnit.gram()), accuracy: Double(Float.ulpOfOne)) + } + + XCTAssertEqual(carbsOnBoard[0].quantity.doubleValue(for: unit), 0, accuracy: 1) + XCTAssertEqual(carbsOnBoard[1].quantity.doubleValue(for: unit), 44, accuracy: 1) + XCTAssertEqual(carbsOnBoard[2].quantity.doubleValue(for: unit), 44, accuracy: 1) + XCTAssertEqual(carbsOnBoard[10].quantity.doubleValue(for: unit), 21, accuracy: 1) + XCTAssertEqual(carbsOnBoard[17].quantity.doubleValue(for: unit), 7, accuracy: 1) + XCTAssertEqual(carbsOnBoard[18].quantity.doubleValue(for: unit), 4, accuracy: 1) + XCTAssertEqual(carbsOnBoard[30].quantity.doubleValue(for: unit), 0, accuracy: 1) + } + + func testDynamicGlucoseEffectsAbsorptionFullyObserved() { + let inputICE = loadICEInputFixture("ice_1_hour_input") + let carbEntries = loadCarbEntryFixture() + let output = loadEffectOutputFixture("dynamic_glucose_effect_fully_observed_output") + + let startDate = inputICE[0].startDate // "2015-10-15T21:30:12" + let endDate = inputICE.last!.startDate + + let carbRatio = [AbsoluteScheduleValue(startDate: startDate, endDate: endDate, value: 8.0)] + let isf = [AbsoluteScheduleValue(startDate: startDate, endDate: endDate, value: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 40))] + + let statuses = [carbEntries[0]].map( + to: inputICE, + carbRatio: carbRatio, + insulinSensitivity: isf, + absorptionModel: LinearAbsorption() + ) + + XCTAssertEqual(statuses.count, 1) + XCTAssertNotNil(statuses[0].absorption) + + // No remaining absorption + XCTAssertEqual(statuses[0].absorption!.estimatedTimeRemaining, 0, accuracy: 1) + + let absorption = statuses[0].absorption! + let unit = HKUnit.gram() + + // All should be absorbed + XCTAssertEqual(absorption.observed.doubleValue(for: unit), 44, accuracy: 1) + + let effects = statuses.dynamicGlucoseEffects( + from: inputICE[0].startDate, + to: inputICE[0].startDate.addingTimeInterval(TimeInterval(hours: 6)), + carbRatios: carbRatio, + insulinSensitivities: isf, + absorptionModel: LinearAbsorption() + ) + + XCTAssertEqual(output.count, effects.count) + + for (expected, calculated) in zip(output, effects) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: Double(Float.ulpOfOne)) + } + } + + func testDynamicAbsorptionNeverFullyObserved() { + let inputICE = loadICEInputFixture("ice_slow_absorption") + let carbEntries = loadCarbEntryFixture() + let output = loadCOBOutputFixture("ice_slow_absorption_output") + + let startDate = inputICE[0].startDate // "2015-10-15T21:30:12" + let endDate = inputICE.last!.startDate + + let carbRatio = [AbsoluteScheduleValue(startDate: startDate, endDate: endDate, value: 8.0)] + let isf = [AbsoluteScheduleValue(startDate: startDate, endDate: endDate, value: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 40))] + + let statuses = [carbEntries[1]].map( + to: inputICE, + carbRatio: carbRatio, + insulinSensitivity: isf, + absorptionTimeOverrun: 2.0, + delay: 0, + initialAbsorptionTimeOverrun: 2.0, + absorptionModel: LinearAbsorption() + ) + + XCTAssertEqual(statuses.count, 1) + XCTAssertNotNil(statuses[0].absorption) + + XCTAssertEqual(statuses[0].absorption!.estimatedTimeRemaining, 10488, accuracy: 1) + + // Check 12 hours later + let carbsOnBoard = statuses.dynamicCarbsOnBoard( + from: inputICE[0].startDate, + to: inputICE[0].startDate.addingTimeInterval(TimeInterval(hours: 18)), + absorptionModel: LinearAbsorption() + ) + + let unit = HKUnit.gram() + + XCTAssertEqual(output.count, carbsOnBoard.count) + + for (expected, calculated) in zip(output, carbsOnBoard) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: HKUnit.gram()), calculated.quantity.doubleValue(for: HKUnit.gram()), accuracy: Double(Float.ulpOfOne)) + } + + XCTAssertEqual(carbsOnBoard.first!.quantity.doubleValue(for: unit), 0, accuracy: 1) + XCTAssertEqual(carbsOnBoard[5].quantity.doubleValue(for: unit), 30, accuracy: 1) + XCTAssertEqual(carbsOnBoard.last!.quantity.doubleValue(for: unit), 0, accuracy: 1) + } + + func testDynamicGlucoseEffectsAbsorptionNeverFullyObserved() { + let inputICE = loadICEInputFixture("ice_slow_absorption") + let carbEntries = loadCarbEntryFixture() + let output = loadEffectOutputFixture("dynamic_glucose_effect_never_fully_observed_output") + + let startDate = inputICE[0].startDate // "2015-10-15T21:30:12" + let endDate = inputICE.last!.startDate + + let carbRatio = [AbsoluteScheduleValue(startDate: startDate, endDate: endDate, value: 8.0)] + let isf = [AbsoluteScheduleValue(startDate: startDate, endDate: endDate, value: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 40))] + + let statuses = [carbEntries[1]].map( + to: inputICE, + carbRatio: carbRatio, + insulinSensitivity: isf, + absorptionTimeOverrun: 2.0, + delay: 0, + initialAbsorptionTimeOverrun: 2.0, + absorptionModel: LinearAbsorption() + ) + + XCTAssertEqual(statuses.count, 1) + XCTAssertNotNil(statuses[0].absorption) + + XCTAssertEqual(statuses[0].absorption!.estimatedTimeRemaining, 10488, accuracy: 1) + + // Check 12 hours later + let effects = statuses.dynamicGlucoseEffects( + from: inputICE[0].startDate, + to: inputICE[0].startDate.addingTimeInterval(TimeInterval(hours: 18)), + carbRatios: carbRatio, + insulinSensitivities: isf, + absorptionModel: LinearAbsorption() + ) + + XCTAssertEqual(output.count, effects.count) + + for (expected, calculated) in zip(output, effects) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: Double(Float.ulpOfOne)) + } + } + + // Aditional tests for nonlinear and adaptive-rate carb absorption models + + func testDynamicAbsorptionPiecewiseLinearNoneObserved() { + let inputICE = loadICEInputFixture("ice_35_min_input") + let carbEntries = loadCarbEntryFixture() + let output = loadCOBOutputFixture("ice_35_min_none_piecewiselinear_output") + + let startDate = inputICE[0].startDate // "2015-10-15T21:30:12" + let endDate = inputICE.last!.startDate + + let carbRatio = [AbsoluteScheduleValue(startDate: startDate, endDate: endDate, value: 8.0)] + let isf = [AbsoluteScheduleValue(startDate: startDate, endDate: endDate, value: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 40))] + + let futureCarbEntry = carbEntries[2] + + let statuses = [futureCarbEntry].map( + to: inputICE, + carbRatio: carbRatio, + insulinSensitivity: isf + ) + + XCTAssertEqual(statuses.count, 1) + + // Full absorption remains + XCTAssertEqual(statuses[0].absorption!.estimatedTimeRemaining, TimeInterval(hours: 3), accuracy: 1) + + let carbsOnBoard = statuses.dynamicCarbsOnBoard( + from: inputICE[0].startDate, + to: inputICE[0].startDate.addingTimeInterval(TimeInterval(hours: 6)), + absorptionModel: PiecewiseLinearAbsorption() + ) + + let unit = HKUnit.gram() + + XCTAssertEqual(output.count, carbsOnBoard.count) + + for (expected, calculated) in zip(output, carbsOnBoard) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: HKUnit.gram()), calculated.quantity.doubleValue(for: HKUnit.gram()), accuracy: Double(Float.ulpOfOne)) + } + + XCTAssertEqual(carbsOnBoard.first!.quantity.doubleValue(for: unit), 0, accuracy: 1) + XCTAssertEqual(carbsOnBoard.last!.quantity.doubleValue(for: unit), 0, accuracy: 1) + } + + func testDynamicAbsorptionPiecewiseLinearPartiallyObserved() { + let inputICE = loadICEInputFixture("ice_35_min_input") + let carbEntries = loadCarbEntryFixture() + let output = loadCOBOutputFixture("ice_35_min_partial_piecewiselinear_output") + + let startDate = inputICE[0].startDate // "2015-10-15T21:30:12" + let endDate = inputICE.last!.startDate + + let carbRatio = [AbsoluteScheduleValue(startDate: startDate, endDate: endDate, value: 8.0)] + let isf = [AbsoluteScheduleValue(startDate: startDate, endDate: endDate, value: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 40))] + + let statuses = [carbEntries[0]].map( + to: inputICE, + carbRatio: carbRatio, + insulinSensitivity: isf + ) + + XCTAssertEqual(statuses.count, 1) + + XCTAssertEqual(statuses[0].absorption!.estimatedTimeRemaining, 7008, accuracy: 1) + + let absorption = statuses[0].absorption! + let unit = HKUnit.gram() + + XCTAssertEqual(absorption.observed.doubleValue(for: unit), 18, accuracy: Double(Float.ulpOfOne)) + + let carbsOnBoard = statuses.dynamicCarbsOnBoard( + from: inputICE[0].startDate, + to: inputICE[0].startDate.addingTimeInterval(TimeInterval(hours: 6)), + absorptionModel: PiecewiseLinearAbsorption() + ) + + XCTAssertEqual(output.count, carbsOnBoard.count) + + for (expected, calculated) in zip(output, carbsOnBoard) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: HKUnit.gram()), calculated.quantity.doubleValue(for: HKUnit.gram()), accuracy: Double(Float.ulpOfOne)) + } + + XCTAssertEqual(carbsOnBoard.first!.quantity.doubleValue(for: unit), 0, accuracy: 1) + XCTAssertEqual(carbsOnBoard[2].quantity.doubleValue(for: unit), 44, accuracy: 1) + XCTAssertEqual(carbsOnBoard[20].quantity.doubleValue(for: unit), 5, accuracy: 1) + XCTAssertEqual(carbsOnBoard.last!.quantity.doubleValue(for: unit), 0, accuracy: 1) + } + + func testDynamicAbsorptionPiecewiseLinearFullyObserved() { + let inputICE = loadICEInputFixture("ice_1_hour_input") + let carbEntries = loadCarbEntryFixture() + let output = loadCOBOutputFixture("ice_1_hour_output") + + let startDate = inputICE[0].startDate // "2015-10-15T21:30:12" + let endDate = inputICE.last!.startDate + + let carbRatio = [AbsoluteScheduleValue(startDate: startDate, endDate: endDate, value: 8.0)] + let isf = [AbsoluteScheduleValue(startDate: startDate, endDate: endDate, value: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 40))] + + let statuses = [carbEntries[0]].map( + to: inputICE, + carbRatio: carbRatio, + insulinSensitivity: isf + ) + + XCTAssertEqual(statuses.count, 1) + XCTAssertNotNil(statuses[0].absorption) + + // No remaining absorption + XCTAssertEqual(statuses[0].absorption!.estimatedTimeRemaining, 0, accuracy: 1) + + let absorption = statuses[0].absorption! + let unit = HKUnit.gram() + + // All should be absorbed + XCTAssertEqual(absorption.observed.doubleValue(for: unit), 44, accuracy: 1) + + let carbsOnBoard = statuses.dynamicCarbsOnBoard( + from: inputICE[0].startDate, + to: inputICE[0].startDate.addingTimeInterval(TimeInterval(hours: 6)), + absorptionModel: PiecewiseLinearAbsorption() + ) + + XCTAssertEqual(output.count, carbsOnBoard.count) + + for (expected, calculated) in zip(output, carbsOnBoard) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: HKUnit.gram()), calculated.quantity.doubleValue(for: HKUnit.gram()), accuracy: Double(Float.ulpOfOne)) + } + + XCTAssertEqual(carbsOnBoard[0].quantity.doubleValue(for: unit), 0, accuracy: 1) + XCTAssertEqual(carbsOnBoard[1].quantity.doubleValue(for: unit), 44, accuracy: 1) + XCTAssertEqual(carbsOnBoard[2].quantity.doubleValue(for: unit), 44, accuracy: 1) + XCTAssertEqual(carbsOnBoard[10].quantity.doubleValue(for: unit), 21, accuracy: 1) + XCTAssertEqual(carbsOnBoard[17].quantity.doubleValue(for: unit), 7, accuracy: 1) + XCTAssertEqual(carbsOnBoard[18].quantity.doubleValue(for: unit), 4, accuracy: 1) + XCTAssertEqual(carbsOnBoard[30].quantity.doubleValue(for: unit), 0, accuracy: 1) + } + + func testDynamicAbsorptionPiecewiseLinearNeverFullyObserved() { + let inputICE = loadICEInputFixture("ice_slow_absorption") + let carbEntries = loadCarbEntryFixture() + let output = loadCOBOutputFixture("ice_slow_absorption_piecewiselinear_output") + + let startDate = inputICE[0].startDate // "2015-10-15T21:30:12" + let endDate = inputICE.last!.startDate + + let carbRatio = [AbsoluteScheduleValue(startDate: startDate, endDate: endDate, value: 8.0)] + let isf = [AbsoluteScheduleValue(startDate: startDate, endDate: endDate, value: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 40))] + + let statuses = [carbEntries[1]].map( + to: inputICE, + carbRatio: carbRatio, + insulinSensitivity: isf, + delay: 0 + ) + + XCTAssertEqual(statuses.count, 1) + XCTAssertNotNil(statuses[0].absorption) + + XCTAssertEqual(statuses[0].absorption!.estimatedTimeRemaining, 6888, accuracy: 1) + + // Check 12 hours later + let carbsOnBoard = statuses.dynamicCarbsOnBoard( + from: inputICE[0].startDate, + to: inputICE[0].startDate.addingTimeInterval(TimeInterval(hours: 18)), + absorptionModel: PiecewiseLinearAbsorption() + ) + + let unit = HKUnit.gram() + + XCTAssertEqual(output.count, carbsOnBoard.count) + + for (expected, calculated) in zip(output, carbsOnBoard) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: HKUnit.gram()), calculated.quantity.doubleValue(for: HKUnit.gram()), accuracy: Double(Float.ulpOfOne)) + } + + XCTAssertEqual(carbsOnBoard.first!.quantity.doubleValue(for: unit), 0, accuracy: 1) + XCTAssertEqual(carbsOnBoard[5].quantity.doubleValue(for: unit), 30, accuracy: 1) + XCTAssertEqual(carbsOnBoard.last!.quantity.doubleValue(for: unit), 0, accuracy: 1) + } + + func testDynamicAbsorptionPiecewiseLinearAdaptiveRatePartiallyObserved() { + let inputICE = loadICEInputFixture("ice_35_min_input") + let carbEntries = loadCarbEntryFixture() + let output = loadCOBOutputFixture("ice_35_min_partial_piecewiselinear_adaptiverate_output") + + let startDate = inputICE[0].startDate // "2015-10-15T21:30:12" + let endDate = inputICE.last!.startDate + + let carbRatio = [AbsoluteScheduleValue(startDate: startDate, endDate: endDate, value: 8.0)] + let isf = [AbsoluteScheduleValue(startDate: startDate, endDate: endDate, value: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 40))] + + let statuses = [carbEntries[0]].map( + to: inputICE, + carbRatio: carbRatio, + insulinSensitivity: isf, + delay: TimeInterval(minutes: 0), + initialAbsorptionTimeOverrun: 1.0, + adaptiveAbsorptionRateEnabled: true + ) + + XCTAssertEqual(statuses.count, 1) + + XCTAssertEqual(statuses[0].absorption!.estimatedTimeRemaining, 3326, accuracy: 1) + + let absorption = statuses[0].absorption! + let unit = HKUnit.gram() + + XCTAssertEqual(absorption.observed.doubleValue(for: unit), 18, accuracy: Double(Float.ulpOfOne)) + + let carbsOnBoard = statuses.dynamicCarbsOnBoard( + from: inputICE[0].startDate, + to: inputICE[0].startDate.addingTimeInterval(TimeInterval(hours: 6)), + absorptionModel: PiecewiseLinearAbsorption() + ) + + XCTAssertEqual(output.count, carbsOnBoard.count) + + for (expected, calculated) in zip(output, carbsOnBoard) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: HKUnit.gram()), calculated.quantity.doubleValue(for: HKUnit.gram()), accuracy: Double(Float.ulpOfOne)) + } + + XCTAssertEqual(carbsOnBoard.first!.quantity.doubleValue(for: unit), 0, accuracy: 1) + XCTAssertEqual(carbsOnBoard[2].quantity.doubleValue(for: unit), 44, accuracy: 1) + XCTAssertEqual(carbsOnBoard[10].quantity.doubleValue(for: unit), 15, accuracy: 1) + XCTAssertEqual(carbsOnBoard.last!.quantity.doubleValue(for: unit), 0, accuracy: 1) + } + + func testDynamicAbsorptionMultipleEntries() { + let inputICE = loadICEInputFixture("ice_35_min_input") + let carbEntries = loadCarbEntryFixture() + + let dateFormatter = ISO8601DateFormatter.localTimeDate(timeZone: TimeZone(secondsFromGMT: 0)!) + let startDate = inputICE[0].startDate + let changeDate = dateFormatter.date(from: "2015-10-16T04:30:00")! + let endDate = dateFormatter.date(from: "2015-10-16T06:00:00")! + + let carbRatio = [ + AbsoluteScheduleValue(startDate: startDate, endDate: changeDate, value: 8.0), + AbsoluteScheduleValue(startDate: changeDate, endDate: endDate, value: 9.0) + ] + let isf = [AbsoluteScheduleValue(startDate: startDate, endDate: endDate, value: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 40))] + + let statuses = carbEntries.map( + to: inputICE, + carbRatio: carbRatio, + insulinSensitivity: isf, + absorptionTimeOverrun: 2.0, + delay: TimeInterval(minutes: 0), + initialAbsorptionTimeOverrun: 2.0, + absorptionModel: LinearAbsorption() + ) + + // Tuple structure: (observed absorption, estimated time remaining) + let expected = [(16.193665456944906, 9100.254941363484), (1.806334543055097, 13532.959419333554) , (0, 14400)] + XCTAssertEqual(expected.count, statuses.count) + + for (expected, calculated) in zip(expected, statuses) { + XCTAssertEqual(expected.0, calculated.absorption?.observed.doubleValue(for: HKUnit.gram())) + XCTAssertEqual(expected.1, calculated.absorption?.estimatedTimeRemaining) + } + } +} diff --git a/Tests/LoopAlgorithmTests/Extensions/DateFormatter.swift b/Tests/LoopAlgorithmTests/Extensions/DateFormatter.swift new file mode 100644 index 0000000..7a35adc --- /dev/null +++ b/Tests/LoopAlgorithmTests/Extensions/DateFormatter.swift @@ -0,0 +1,33 @@ +// +// NSDateFormatter.swift +// Naterade +// +// Created by Nathan Racklyeft on 11/25/15. +// Copyright © 2015 Nathan Racklyeft. All rights reserved. +// + +import Foundation + + +// MARK: - Extensions useful in parsing fixture dates +extension ISO8601DateFormatter { + static func localTimeDate(timeZone: TimeZone = .currentFixed) -> Self { + let formatter = self.init() + + formatter.formatOptions = .withInternetDateTime + formatter.formatOptions.subtract(.withTimeZone) + formatter.timeZone = timeZone + + return formatter + } +} + + +extension DateFormatter { + static var descriptionFormatter: DateFormatter { + let formatter = self.init() + formatter.dateFormat = "yyyy-MM-dd HH:mm:ssZZZZZ" + + return formatter + } +} diff --git a/Tests/LoopAlgorithmTests/Extensions/TimeZone.swift b/Tests/LoopAlgorithmTests/Extensions/TimeZone.swift new file mode 100644 index 0000000..4b912b9 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Extensions/TimeZone.swift @@ -0,0 +1,16 @@ +// +// TimeZone.swift +// LoopKit +// +// Created by Nate Racklyeft on 10/2/16. +// Copyright © 2016 LoopKit Authors. All rights reserved. +// + +import Foundation + + +extension TimeZone { + static var currentFixed: TimeZone { + return TimeZone(secondsFromGMT: TimeZone.current.secondsFromGMT())! + } +} diff --git a/Tests/LoopAlgorithmTests/Fixtures/carb_entry_input.json b/Tests/LoopAlgorithmTests/Fixtures/carb_entry_input.json new file mode 100644 index 0000000..d6b3d75 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/carb_entry_input.json @@ -0,0 +1,20 @@ +[ + { + "amount": 44, + "start_at": "2015-10-15T21:35:12", + "absorption_time": 120, + "unit": "g" + }, + { + "amount": 30, + "start_at": "2015-10-15T21:55:00", + "absorption_time": 120, + "unit": "g" + }, + { + "amount": 30, + "start_at": "2015-10-15T23:00:00", + "absorption_time": 120, + "unit": "g" + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/dynamic_glucose_effect_fully_observed_output.json b/Tests/LoopAlgorithmTests/Fixtures/dynamic_glucose_effect_fully_observed_output.json new file mode 100644 index 0000000..ee49919 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/dynamic_glucose_effect_fully_observed_output.json @@ -0,0 +1,373 @@ +[ + { + "date" : "2015-10-15T21:30:00", + "amount" : 0, + "unit": "mg/dL" + + }, + { + "date" : "2015-10-15T21:35:00", + "amount" : 0, + "unit": "mg/dL" + }, + { + "amount" : 0, + "date" : "2015-10-15T21:40:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T21:45:00", + "amount" : 25.000000000000004, + "unit": "mg/dL" + }, + { + "amount" : 40, + "date" : "2015-10-15T21:50:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T21:55:00", + "amount" : 50.000000000000007, + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T22:00:00", + "amount" : 65, + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T22:05:00", + "amount" : 90, + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T22:10:00", + "amount" : 90, + "unit": "mg/dL" + }, + { + "amount" : 90, + "date" : "2015-10-15T22:15:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T22:20:00", + "amount" : 115, + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T22:25:00", + "amount" : 135, + "unit": "mg/dL" + }, + { + "amount" : 150, + "date" : "2015-10-15T22:30:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T22:35:00", + "amount" : 160, + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T22:40:00", + "amount" : 165, + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T22:45:00", + "amount" : 170, + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T22:50:00", + "amount" : 175, + "unit": "mg/dL" + }, + { + "amount" : 185, + "date" : "2015-10-15T22:55:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T23:00:00", + "amount" : 200, + "unit": "mg/dL" + }, + { + "amount" : 220, + "date" : "2015-10-15T23:05:00", + "unit": "mg/dL" + }, + { + "amount" : 220, + "date" : "2015-10-15T23:10:00", + "unit": "mg/dL" + }, + { + "amount" : 220, + "date" : "2015-10-15T23:15:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T23:20:00", + "amount" : 220, + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T23:25:00", + "amount" : 220, + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T23:30:00", + "amount" : 220, + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T23:35:00", + "amount" : 220, + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T23:40:00", + "amount" : 220, + "unit": "mg/dL" + }, + { + "amount" : 220, + "date" : "2015-10-15T23:45:00", + "unit": "mg/dL" + }, + { + "amount" : 220, + "date" : "2015-10-15T23:50:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T23:55:00", + "amount" : 220, + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T00:00:00", + "amount" : 220, + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T00:05:00", + "amount" : 220, + "unit": "mg/dL" + }, + { + "amount" : 220, + "date" : "2015-10-16T00:10:00", + "unit": "mg/dL" + }, + { + "amount" : 220, + "date" : "2015-10-16T00:15:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T00:20:00", + "amount" : 220, + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T00:25:00", + "amount" : 220, + "unit": "mg/dL" + }, + { + "amount" : 220, + "date" : "2015-10-16T00:30:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T00:35:00", + "amount" : 220, + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T00:40:00", + "amount" : 220, + "unit": "mg/dL" + }, + { + "amount" : 220, + "date" : "2015-10-16T00:45:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T00:50:00", + "amount" : 220, + "unit": "mg/dL" + }, + { + "amount" : 220, + "date" : "2015-10-16T00:55:00", + "unit": "mg/dL" + }, + { + "amount" : 220, + "date" : "2015-10-16T01:00:00", + "unit": "mg/dL" + }, + { + "amount" : 220, + "date" : "2015-10-16T01:05:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T01:10:00", + "amount" : 220, + "unit": "mg/dL" + }, + { + "amount" : 220, + "date" : "2015-10-16T01:15:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T01:20:00", + "amount" : 220, + "unit": "mg/dL" + }, + { + "amount" : 220, + "date" : "2015-10-16T01:25:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T01:30:00", + "amount" : 220, + "unit": "mg/dL" + }, + { + "amount" : 220, + "date" : "2015-10-16T01:35:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T01:40:00", + "amount" : 220, + "unit": "mg/dL" + }, + { + "amount" : 220, + "date" : "2015-10-16T01:45:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T01:50:00", + "amount" : 220, + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T01:55:00", + "amount" : 220, + "unit": "mg/dL" + }, + { + "amount" : 220, + "date" : "2015-10-16T02:00:00", + "unit": "mg/dL" + }, + { + "amount" : 220, + "date" : "2015-10-16T02:05:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T02:10:00", + "amount" : 220, + "unit": "mg/dL" + }, + { + "amount" : 220, + "date" : "2015-10-16T02:15:00", + "unit": "mg/dL" + }, + { + "amount" : 220, + "date" : "2015-10-16T02:20:00", + "unit": "mg/dL" + }, + { + "amount" : 220, + "date" : "2015-10-16T02:25:00", + "unit": "mg/dL" + }, + { + "amount" : 220, + "date" : "2015-10-16T02:30:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T02:35:00", + "amount" : 220, + "unit": "mg/dL" + }, + { + "amount" : 220, + "date" : "2015-10-16T02:40:00", + "unit": "mg/dL" + }, + { + "amount" : 220, + "date" : "2015-10-16T02:45:00", + "unit": "mg/dL" + }, + { + "amount" : 220, + "date" : "2015-10-16T02:50:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T02:55:00", + "amount" : 220, + "unit": "mg/dL" + }, + { + "amount" : 220, + "date" : "2015-10-16T03:00:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T03:05:00", + "amount" : 220, + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T03:10:00", + "amount" : 220, + "unit": "mg/dL" + }, + { + "amount" : 220, + "date" : "2015-10-16T03:15:00", + "unit": "mg/dL" + }, + { + "amount" : 220, + "date" : "2015-10-16T03:20:00", + "unit": "mg/dL" + }, + { + "amount" : 220, + "date" : "2015-10-16T03:25:00", + "unit": "mg/dL" + }, + { + "amount" : 220, + "date" : "2015-10-16T03:30:00", + "unit": "mg/dL" + }, + { + "amount" : 220, + "date" : "2015-10-16T03:35:00", + "unit": "mg/dL" + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/dynamic_glucose_effect_never_fully_observed_output.json b/Tests/LoopAlgorithmTests/Fixtures/dynamic_glucose_effect_never_fully_observed_output.json new file mode 100644 index 0000000..279f604 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/dynamic_glucose_effect_never_fully_observed_output.json @@ -0,0 +1,1092 @@ +[ + { + "unit" : "mg\/dL", + "date" : "2015-10-15T21:30:00", + "amount" : 0 + }, + { + "amount" : 0, + "unit" : "mg\/dL", + "date" : "2015-10-15T21:35:00" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-15T21:40:00", + "amount" : 0 + }, + { + "amount" : 0, + "unit" : "mg\/dL", + "date" : "2015-10-15T21:45:00" + }, + { + "date" : "2015-10-15T21:50:00", + "unit" : "mg\/dL", + "amount" : 0 + }, + { + "amount" : 0, + "unit" : "mg\/dL", + "date" : "2015-10-15T21:55:00" + }, + { + "amount" : 0, + "unit" : "mg\/dL", + "date" : "2015-10-15T22:00:00" + }, + { + "date" : "2015-10-15T22:05:00", + "amount" : 0, + "unit" : "mg\/dL" + }, + { + "amount" : 3.125, + "unit" : "mg\/dL", + "date" : "2015-10-15T22:10:00" + }, + { + "date" : "2015-10-15T22:15:00", + "amount" : 6.25, + "unit" : "mg\/dL" + }, + { + "amount" : 9.375, + "date" : "2015-10-15T22:20:00", + "unit" : "mg\/dL" + }, + { + "amount" : 12.5, + "date" : "2015-10-15T22:25:00", + "unit" : "mg\/dL" + }, + { + "amount" : 15.625, + "date" : "2015-10-15T22:30:00", + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "amount" : 18.75, + "date" : "2015-10-15T22:35:00" + }, + { + "unit" : "mg\/dL", + "amount" : 21.875, + "date" : "2015-10-15T22:40:00" + }, + { + "date" : "2015-10-15T22:45:00", + "amount" : 25, + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-15T22:50:00", + "amount" : 28.125 + }, + { + "amount" : 31.25, + "unit" : "mg\/dL", + "date" : "2015-10-15T22:55:00" + }, + { + "date" : "2015-10-15T23:00:00", + "amount" : 34.375, + "unit" : "mg\/dL" + }, + { + "amount" : 37.5, + "unit" : "mg\/dL", + "date" : "2015-10-15T23:05:00" + }, + { + "amount" : 40.625, + "date" : "2015-10-15T23:10:00", + "unit" : "mg\/dL" + }, + { + "amount" : 43.75, + "unit" : "mg\/dL", + "date" : "2015-10-15T23:15:00" + }, + { + "date" : "2015-10-15T23:20:00", + "amount" : 46.875, + "unit" : "mg\/dL" + }, + { + "amount" : 50, + "date" : "2015-10-15T23:25:00", + "unit" : "mg\/dL" + }, + { + "amount" : 53.125, + "unit" : "mg\/dL", + "date" : "2015-10-15T23:30:00" + }, + { + "unit" : "mg\/dL", + "amount" : 56.25, + "date" : "2015-10-15T23:35:00" + }, + { + "date" : "2015-10-15T23:40:00", + "unit" : "mg\/dL", + "amount" : 59.375 + }, + { + "date" : "2015-10-15T23:45:00", + "amount" : 62.5, + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-15T23:50:00", + "amount" : 65.625 + }, + { + "date" : "2015-10-15T23:55:00", + "amount" : 68.75, + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-16T00:00:00", + "amount" : 71.875, + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T00:05:00", + "amount" : 75 + }, + { + "amount" : 78.125000000000014, + "unit" : "mg\/dL", + "date" : "2015-10-16T00:10:00" + }, + { + "date" : "2015-10-16T00:15:00", + "unit" : "mg\/dL", + "amount" : 81.25 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T00:20:00", + "amount" : 84.375 + }, + { + "date" : "2015-10-16T00:25:00", + "unit" : "mg\/dL", + "amount" : 87.5 + }, + { + "date" : "2015-10-16T00:30:00", + "amount" : 90.625, + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-16T00:35:00", + "amount" : 93.75, + "unit" : "mg\/dL" + }, + { + "amount" : 96.875, + "unit" : "mg\/dL", + "date" : "2015-10-16T00:40:00" + }, + { + "unit" : "mg\/dL", + "amount" : 100, + "date" : "2015-10-16T00:45:00" + }, + { + "amount" : 103.125, + "unit" : "mg\/dL", + "date" : "2015-10-16T00:50:00" + }, + { + "date" : "2015-10-16T00:55:00", + "unit" : "mg\/dL", + "amount" : 106.25 + }, + { + "amount" : 109.375, + "unit" : "mg\/dL", + "date" : "2015-10-16T01:00:00" + }, + { + "date" : "2015-10-16T01:05:00", + "amount" : 112.5, + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T01:10:00", + "amount" : 115.625 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T01:15:00", + "amount" : 118.75 + }, + { + "amount" : 121.875, + "unit" : "mg\/dL", + "date" : "2015-10-16T01:20:00" + }, + { + "amount" : 125, + "date" : "2015-10-16T01:25:00", + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-16T01:30:00", + "unit" : "mg\/dL", + "amount" : 128.125 + }, + { + "date" : "2015-10-16T01:35:00", + "unit" : "mg\/dL", + "amount" : 131.25 + }, + { + "amount" : 134.375, + "date" : "2015-10-16T01:40:00", + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-16T01:45:00", + "unit" : "mg\/dL", + "amount" : 137.5 + }, + { + "date" : "2015-10-16T01:50:00", + "amount" : 140.625, + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "amount" : 143.75, + "date" : "2015-10-16T01:55:00" + }, + { + "amount" : 146.875, + "date" : "2015-10-16T02:00:00", + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "amount" : 150, + "date" : "2015-10-16T02:05:00" + }, + { + "date" : "2015-10-16T02:10:00", + "unit" : "mg\/dL", + "amount" : 150 + }, + { + "amount" : 150, + "date" : "2015-10-16T02:15:00", + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "amount" : 150, + "date" : "2015-10-16T02:20:00" + }, + { + "date" : "2015-10-16T02:25:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T02:30:00", + "amount" : 150 + }, + { + "amount" : 150, + "date" : "2015-10-16T02:35:00", + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-16T02:40:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "amount" : 150, + "unit" : "mg\/dL", + "date" : "2015-10-16T02:45:00" + }, + { + "date" : "2015-10-16T02:50:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T02:55:00", + "amount" : 150 + }, + { + "unit" : "mg\/dL", + "amount" : 150, + "date" : "2015-10-16T03:00:00" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T03:05:00", + "amount" : 150 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T03:10:00", + "amount" : 150 + }, + { + "date" : "2015-10-16T03:15:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-16T03:20:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-16T03:25:00", + "unit" : "mg\/dL", + "amount" : 150 + }, + { + "amount" : 150, + "date" : "2015-10-16T03:30:00", + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T03:35:00", + "amount" : 150 + }, + { + "date" : "2015-10-16T03:40:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "amount" : 150, + "unit" : "mg\/dL", + "date" : "2015-10-16T03:45:00" + }, + { + "amount" : 150, + "date" : "2015-10-16T03:50:00", + "unit" : "mg\/dL" + }, + { + "amount" : 150, + "unit" : "mg\/dL", + "date" : "2015-10-16T03:55:00" + }, + { + "date" : "2015-10-16T04:00:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "amount" : 150, + "date" : "2015-10-16T04:05:00" + }, + { + "date" : "2015-10-16T04:10:00", + "unit" : "mg\/dL", + "amount" : 150 + }, + { + "unit" : "mg\/dL", + "amount" : 150, + "date" : "2015-10-16T04:15:00" + }, + { + "unit" : "mg\/dL", + "amount" : 150, + "date" : "2015-10-16T04:20:00" + }, + { + "amount" : 150, + "unit" : "mg\/dL", + "date" : "2015-10-16T04:25:00" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T04:30:00", + "amount" : 150 + }, + { + "date" : "2015-10-16T04:35:00", + "unit" : "mg\/dL", + "amount" : 150 + }, + { + "date" : "2015-10-16T04:40:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-16T04:45:00", + "unit" : "mg\/dL", + "amount" : 150 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T04:50:00", + "amount" : 150 + }, + { + "amount" : 150, + "unit" : "mg\/dL", + "date" : "2015-10-16T04:55:00" + }, + { + "unit" : "mg\/dL", + "amount" : 150, + "date" : "2015-10-16T05:00:00" + }, + { + "date" : "2015-10-16T05:05:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "amount" : 150, + "unit" : "mg\/dL", + "date" : "2015-10-16T05:10:00" + }, + { + "amount" : 150, + "unit" : "mg\/dL", + "date" : "2015-10-16T05:15:00" + }, + { + "date" : "2015-10-16T05:20:00", + "unit" : "mg\/dL", + "amount" : 150 + }, + { + "amount" : 150, + "unit" : "mg\/dL", + "date" : "2015-10-16T05:25:00" + }, + { + "amount" : 150, + "unit" : "mg\/dL", + "date" : "2015-10-16T05:30:00" + }, + { + "date" : "2015-10-16T05:35:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "amount" : 150, + "unit" : "mg\/dL", + "date" : "2015-10-16T05:40:00" + }, + { + "date" : "2015-10-16T05:45:00", + "unit" : "mg\/dL", + "amount" : 150 + }, + { + "date" : "2015-10-16T05:50:00", + "unit" : "mg\/dL", + "amount" : 150 + }, + { + "date" : "2015-10-16T05:55:00", + "unit" : "mg\/dL", + "amount" : 150 + }, + { + "amount" : 150, + "date" : "2015-10-16T06:00:00", + "unit" : "mg\/dL" + }, + { + "amount" : 150, + "date" : "2015-10-16T06:05:00", + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-16T06:10:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "amount" : 150, + "unit" : "mg\/dL", + "date" : "2015-10-16T06:15:00" + }, + { + "date" : "2015-10-16T06:20:00", + "unit" : "mg\/dL", + "amount" : 150 + }, + { + "date" : "2015-10-16T06:25:00", + "unit" : "mg\/dL", + "amount" : 150 + }, + { + "date" : "2015-10-16T06:30:00", + "unit" : "mg\/dL", + "amount" : 150 + }, + { + "amount" : 150, + "date" : "2015-10-16T06:35:00", + "unit" : "mg\/dL" + }, + { + "amount" : 150, + "unit" : "mg\/dL", + "date" : "2015-10-16T06:40:00" + }, + { + "amount" : 150, + "unit" : "mg\/dL", + "date" : "2015-10-16T06:45:00" + }, + { + "date" : "2015-10-16T06:50:00", + "unit" : "mg\/dL", + "amount" : 150 + }, + { + "date" : "2015-10-16T06:55:00", + "unit" : "mg\/dL", + "amount" : 150 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T07:00:00", + "amount" : 150 + }, + { + "amount" : 150, + "unit" : "mg\/dL", + "date" : "2015-10-16T07:05:00" + }, + { + "unit" : "mg\/dL", + "amount" : 150, + "date" : "2015-10-16T07:10:00" + }, + { + "amount" : 150, + "date" : "2015-10-16T07:15:00", + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T07:20:00", + "amount" : 150 + }, + { + "amount" : 150, + "unit" : "mg\/dL", + "date" : "2015-10-16T07:25:00" + }, + { + "unit" : "mg\/dL", + "amount" : 150, + "date" : "2015-10-16T07:30:00" + }, + { + "amount" : 150, + "date" : "2015-10-16T07:35:00", + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T07:40:00", + "amount" : 150 + }, + { + "date" : "2015-10-16T07:45:00", + "unit" : "mg\/dL", + "amount" : 150 + }, + { + "date" : "2015-10-16T07:50:00", + "unit" : "mg\/dL", + "amount" : 150 + }, + { + "date" : "2015-10-16T07:55:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T08:00:00", + "amount" : 150 + }, + { + "amount" : 150, + "date" : "2015-10-16T08:05:00", + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T08:10:00", + "amount" : 150 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T08:15:00", + "amount" : 150 + }, + { + "date" : "2015-10-16T08:20:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T08:25:00", + "amount" : 150 + }, + { + "unit" : "mg\/dL", + "amount" : 150, + "date" : "2015-10-16T08:30:00" + }, + { + "amount" : 150, + "unit" : "mg\/dL", + "date" : "2015-10-16T08:35:00" + }, + { + "amount" : 150, + "unit" : "mg\/dL", + "date" : "2015-10-16T08:40:00" + }, + { + "unit" : "mg\/dL", + "amount" : 150, + "date" : "2015-10-16T08:45:00" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T08:50:00", + "amount" : 150 + }, + { + "date" : "2015-10-16T08:55:00", + "unit" : "mg\/dL", + "amount" : 150 + }, + { + "date" : "2015-10-16T09:00:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "amount" : 150, + "date" : "2015-10-16T09:05:00", + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T09:10:00", + "amount" : 150 + }, + { + "date" : "2015-10-16T09:15:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-16T09:20:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-16T09:25:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T09:30:00", + "amount" : 150 + }, + { + "date" : "2015-10-16T09:35:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "amount" : 150, + "date" : "2015-10-16T09:40:00" + }, + { + "date" : "2015-10-16T09:45:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "amount" : 150, + "date" : "2015-10-16T09:50:00" + }, + { + "date" : "2015-10-16T09:55:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-16T10:00:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T10:05:00", + "amount" : 150 + }, + { + "date" : "2015-10-16T10:10:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "amount" : 150, + "date" : "2015-10-16T10:15:00" + }, + { + "amount" : 150, + "date" : "2015-10-16T10:20:00", + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T10:25:00", + "amount" : 150 + }, + { + "date" : "2015-10-16T10:30:00", + "unit" : "mg\/dL", + "amount" : 150 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T10:35:00", + "amount" : 150 + }, + { + "date" : "2015-10-16T10:40:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-16T10:45:00", + "unit" : "mg\/dL", + "amount" : 150 + }, + { + "date" : "2015-10-16T10:50:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "amount" : 150, + "unit" : "mg\/dL", + "date" : "2015-10-16T10:55:00" + }, + { + "amount" : 150, + "unit" : "mg\/dL", + "date" : "2015-10-16T11:00:00" + }, + { + "unit" : "mg\/dL", + "amount" : 150, + "date" : "2015-10-16T11:05:00" + }, + { + "date" : "2015-10-16T11:10:00", + "unit" : "mg\/dL", + "amount" : 150 + }, + { + "amount" : 150, + "unit" : "mg\/dL", + "date" : "2015-10-16T11:15:00" + }, + { + "date" : "2015-10-16T11:20:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-16T11:25:00", + "unit" : "mg\/dL", + "amount" : 150 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T11:30:00", + "amount" : 150 + }, + { + "date" : "2015-10-16T11:35:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "amount" : 150, + "date" : "2015-10-16T11:40:00" + }, + { + "date" : "2015-10-16T11:45:00", + "unit" : "mg\/dL", + "amount" : 150 + }, + { + "unit" : "mg\/dL", + "amount" : 150, + "date" : "2015-10-16T11:50:00" + }, + { + "date" : "2015-10-16T11:55:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-16T12:00:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "amount" : 150, + "unit" : "mg\/dL", + "date" : "2015-10-16T12:05:00" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T12:10:00", + "amount" : 150 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T12:15:00", + "amount" : 150 + }, + { + "unit" : "mg\/dL", + "amount" : 150, + "date" : "2015-10-16T12:20:00" + }, + { + "amount" : 150, + "unit" : "mg\/dL", + "date" : "2015-10-16T12:25:00" + }, + { + "date" : "2015-10-16T12:30:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-16T12:35:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "amount" : 150, + "unit" : "mg\/dL", + "date" : "2015-10-16T12:40:00" + }, + { + "amount" : 150, + "date" : "2015-10-16T12:45:00", + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "amount" : 150, + "date" : "2015-10-16T12:50:00" + }, + { + "date" : "2015-10-16T12:55:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T13:00:00", + "amount" : 150 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T13:05:00", + "amount" : 150 + }, + { + "date" : "2015-10-16T13:10:00", + "unit" : "mg\/dL", + "amount" : 150 + }, + { + "amount" : 150, + "unit" : "mg\/dL", + "date" : "2015-10-16T13:15:00" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T13:20:00", + "amount" : 150 + }, + { + "amount" : 150, + "unit" : "mg\/dL", + "date" : "2015-10-16T13:25:00" + }, + { + "unit" : "mg\/dL", + "amount" : 150, + "date" : "2015-10-16T13:30:00" + }, + { + "amount" : 150, + "date" : "2015-10-16T13:35:00", + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T13:40:00", + "amount" : 150 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T13:45:00", + "amount" : 150 + }, + { + "amount" : 150, + "date" : "2015-10-16T13:50:00", + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-16T13:55:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T14:00:00", + "amount" : 150 + }, + { + "amount" : 150, + "unit" : "mg\/dL", + "date" : "2015-10-16T14:05:00" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T14:10:00", + "amount" : 150 + }, + { + "amount" : 150, + "unit" : "mg\/dL", + "date" : "2015-10-16T14:15:00" + }, + { + "amount" : 150, + "unit" : "mg\/dL", + "date" : "2015-10-16T14:20:00" + }, + { + "unit" : "mg\/dL", + "amount" : 150, + "date" : "2015-10-16T14:25:00" + }, + { + "date" : "2015-10-16T14:30:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-16T14:35:00", + "unit" : "mg\/dL", + "amount" : 150 + }, + { + "amount" : 150, + "date" : "2015-10-16T14:40:00", + "unit" : "mg\/dL" + }, + { + "amount" : 150, + "date" : "2015-10-16T14:45:00", + "unit" : "mg\/dL" + }, + { + "amount" : 150, + "unit" : "mg\/dL", + "date" : "2015-10-16T14:50:00" + }, + { + "amount" : 150, + "unit" : "mg\/dL", + "date" : "2015-10-16T14:55:00" + }, + { + "date" : "2015-10-16T15:00:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T15:05:00", + "amount" : 150 + }, + { + "date" : "2015-10-16T15:10:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "amount" : 150, + "date" : "2015-10-16T15:15:00", + "unit" : "mg\/dL" + }, + { + "amount" : 150, + "date" : "2015-10-16T15:20:00", + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T15:25:00", + "amount" : 150 + }, + { + "date" : "2015-10-16T15:30:00", + "amount" : 150, + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T15:35:00", + "amount" : 150 + } +] \ No newline at end of file diff --git a/Tests/LoopAlgorithmTests/Fixtures/dynamic_glucose_effect_none_observed_output.json b/Tests/LoopAlgorithmTests/Fixtures/dynamic_glucose_effect_none_observed_output.json new file mode 100644 index 0000000..0dd9782 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/dynamic_glucose_effect_none_observed_output.json @@ -0,0 +1,372 @@ +[ + { + "date" : "2015-10-15T21:30:00", + "amount" : 0, + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T21:35:00", + "amount" : 0, + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T21:40:00", + "amount" : 0, + "unit": "mg/dL" + }, + { + "amount" : 0, + "date" : "2015-10-15T21:45:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T21:50:00", + "amount" : 0, + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T21:55:00", + "amount" : 0, + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T22:00:00", + "amount" : 0, + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T22:05:00", + "amount" : 0, + "unit": "mg/dL" + }, + { + "amount" : 0, + "date" : "2015-10-15T22:10:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T22:15:00", + "amount" : 0, + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T22:20:00", + "amount" : 0, + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T22:25:00", + "amount" : 0, + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T22:30:00", + "amount" : 0, + "unit": "mg/dL" + }, + { + "amount" : 0, + "date" : "2015-10-15T22:35:00", + "unit": "mg/dL" + }, + { + "amount" : 0, + "date" : "2015-10-15T22:40:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T22:45:00", + "amount" : 0, + "unit": "mg/dL" + }, + { + "amount" : 0, + "date" : "2015-10-15T22:50:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T22:55:00", + "amount" : 0, + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T23:00:00", + "amount" : 0, + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T23:05:00", + "amount" : 2.7777777777777777, + "unit": "mg/dL" + }, + { + "amount" : 5.5555555555555554, + "date" : "2015-10-15T23:10:00", + "unit": "mg/dL" + }, + { + "amount" : 8.3333333333333339, + "date" : "2015-10-15T23:15:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T23:20:00", + "amount" : 11.111111111111111, + "unit": "mg/dL" + }, + { + "amount" : 13.888888888888889, + "date" : "2015-10-15T23:25:00", + "unit": "mg/dL" + }, + { + "amount" : 16.666666666666668, + "date" : "2015-10-15T23:30:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T23:35:00", + "amount" : 19.444444444444446, + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T23:40:00", + "amount" : 22.222222222222221, + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T23:45:00", + "amount" : 25, + "unit": "mg/dL" + }, + { + "date" : "2015-10-15T23:50:00", + "amount" : 27.777777777777779, + "unit": "mg/dL" + }, + { + "amount" : 30.555555555555557, + "date" : "2015-10-15T23:55:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T00:00:00", + "amount" : 33.333333333333336, + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T00:05:00", + "amount" : 36.111111111111114, + "unit": "mg/dL" + }, + { + "amount" : 38.888888888888893, + "date" : "2015-10-16T00:10:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T00:15:00", + "amount" : 41.666666666666671, + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T00:20:00", + "amount" : 44.444444444444443, + "unit": "mg/dL" + }, + { + "amount" : 47.222222222222221, + "date" : "2015-10-16T00:25:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T00:30:00", + "amount" : 50, + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T00:35:00", + "amount" : 52.777777777777779, + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T00:40:00", + "amount" : 55.555555555555557, + "unit": "mg/dL" + }, + { + "amount" : 58.333333333333336, + "date" : "2015-10-16T00:45:00", + "unit": "mg/dL" + }, + { + "amount" : 61.111111111111114, + "date" : "2015-10-16T00:50:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T00:55:00", + "amount" : 63.888888888888893, + "unit": "mg/dL" + }, + { + "amount" : 66.666666666666671, + "date" : "2015-10-16T01:00:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T01:05:00", + "amount" : 69.444444444444457, + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T01:10:00", + "amount" : 72.222222222222229, + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T01:15:00", + "amount" : 75, + "unit": "mg/dL" + }, + { + "amount" : 77.777777777777786, + "date" : "2015-10-16T01:20:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T01:25:00", + "amount" : 80.555555555555557, + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T01:30:00", + "amount" : 83.333333333333343, + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T01:35:00", + "amount" : 86.111111111111114, + "unit": "mg/dL" + }, + { + "amount" : 88.888888888888886, + "date" : "2015-10-16T01:40:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T01:45:00", + "amount" : 91.666666666666671, + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T01:50:00", + "amount" : 94.444444444444443, + "unit": "mg/dL" + }, + { + "amount" : 97.222222222222229, + "date" : "2015-10-16T01:55:00", + "unit": "mg/dL" + }, + { + "amount" : 100, + "date" : "2015-10-16T02:00:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T02:05:00", + "amount" : 102.77777777777779, + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T02:10:00", + "amount" : 105.55555555555556, + "unit": "mg/dL" + }, + { + "amount" : 108.33333333333334, + "date" : "2015-10-16T02:15:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T02:20:00", + "amount" : 111.11111111111111, + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T02:25:00", + "amount" : 113.8888888888889, + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T02:30:00", + "amount" : 116.66666666666667, + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T02:35:00", + "amount" : 119.44444444444444, + "unit": "mg/dL" + }, + { + "amount" : 122.22222222222223, + "date" : "2015-10-16T02:40:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T02:45:00", + "amount" : 125, + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T02:50:00", + "amount" : 127.77777777777779, + "unit": "mg/dL" + }, + { + "amount" : 130.55555555555557, + "date" : "2015-10-16T02:55:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T03:00:00", + "amount" : 133.33333333333334, + "unit": "mg/dL" + }, + { + "amount" : 133.33333333333334, + "date" : "2015-10-16T03:05:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T03:10:00", + "amount" : 133.33333333333334, + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T03:15:00", + "amount" : 133.33333333333334, + "unit": "mg/dL" + }, + { + "amount" : 133.33333333333334, + "date" : "2015-10-16T03:20:00", + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T03:25:00", + "amount" : 133.33333333333334, + "unit": "mg/dL" + }, + { + "date" : "2015-10-16T03:30:00", + "amount" : 133.33333333333334, + "unit": "mg/dL" + }, + { + "amount" : 133.33333333333334, + "date" : "2015-10-16T03:35:00", + "unit": "mg/dL" + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/dynamic_glucose_effect_partially_observed_output.json b/Tests/LoopAlgorithmTests/Fixtures/dynamic_glucose_effect_partially_observed_output.json new file mode 100644 index 0000000..e3e1fb4 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/dynamic_glucose_effect_partially_observed_output.json @@ -0,0 +1,372 @@ +[ + { + "amount" : 0, + "unit" : "mg\/dL", + "date" : "2015-10-15T21:30:00" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-15T21:35:00", + "amount" : 0 + }, + { + "unit" : "mg\/dL", + "amount" : 0, + "date" : "2015-10-15T21:40:00" + }, + { + "amount" : 25.000000000000004, + "date" : "2015-10-15T21:45:00", + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-15T21:50:00", + "amount" : 40, + "unit" : "mg\/dL" + }, + { + "amount" : 50.000000000000007, + "unit" : "mg\/dL", + "date" : "2015-10-15T21:55:00" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-15T22:00:00", + "amount" : 65 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-15T22:05:00", + "amount" : 90 + }, + { + "date" : "2015-10-15T22:10:00", + "amount" : 94.399999999999991, + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "amount" : 98.983333333333334, + "date" : "2015-10-15T22:15:00" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-15T22:20:00", + "amount" : 103.56666666666668 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-15T22:25:00", + "amount" : 108.15000000000001 + }, + { + "date" : "2015-10-15T22:30:00", + "unit" : "mg\/dL", + "amount" : 112.73333333333333 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-15T22:35:00", + "amount" : 117.31666666666668 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-15T22:40:00", + "amount" : 121.90000000000001 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-15T22:45:00", + "amount" : 126.48333333333333 + }, + { + "date" : "2015-10-15T22:50:00", + "amount" : 131.06666666666666, + "unit" : "mg\/dL" + }, + { + "amount" : 135.65000000000001, + "unit" : "mg\/dL", + "date" : "2015-10-15T22:55:00" + }, + { + "date" : "2015-10-15T23:00:00", + "unit" : "mg\/dL", + "amount" : 140.23333333333335 + }, + { + "amount" : 144.81666666666666, + "unit" : "mg\/dL", + "date" : "2015-10-15T23:05:00" + }, + { + "amount" : 149.40000000000001, + "date" : "2015-10-15T23:10:00", + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-15T23:15:00", + "amount" : 153.98333333333335, + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-15T23:20:00", + "amount" : 158.56666666666666, + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-15T23:25:00", + "unit" : "mg\/dL", + "amount" : 163.15000000000001 + }, + { + "unit" : "mg\/dL", + "amount" : 167.73333333333335, + "date" : "2015-10-15T23:30:00" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-15T23:35:00", + "amount" : 172.31666666666669 + }, + { + "date" : "2015-10-15T23:40:00", + "unit" : "mg\/dL", + "amount" : 176.90000000000001 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-15T23:45:00", + "amount" : 181.48333333333335 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-15T23:50:00", + "amount" : 186.06666666666669 + }, + { + "amount" : 190.65000000000001, + "date" : "2015-10-15T23:55:00", + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T00:00:00", + "amount" : 195.23333333333335 + }, + { + "date" : "2015-10-16T00:05:00", + "amount" : 199.81666666666669, + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-16T00:10:00", + "amount" : 204.40000000000001, + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-16T00:15:00", + "amount" : 208.98333333333335, + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "amount" : 213.56666666666669, + "date" : "2015-10-16T00:20:00" + }, + { + "amount" : 218.15000000000001, + "date" : "2015-10-16T00:25:00", + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T00:30:00", + "amount" : 220 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T00:35:00", + "amount" : 220 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T00:40:00", + "amount" : 220 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T00:45:00", + "amount" : 220 + }, + { + "date" : "2015-10-16T00:50:00", + "amount" : 220, + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T00:55:00", + "amount" : 220 + }, + { + "amount" : 220, + "unit" : "mg\/dL", + "date" : "2015-10-16T01:00:00" + }, + { + "amount" : 220, + "date" : "2015-10-16T01:05:00", + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T01:10:00", + "amount" : 220 + }, + { + "unit" : "mg\/dL", + "amount" : 220, + "date" : "2015-10-16T01:15:00" + }, + { + "date" : "2015-10-16T01:20:00", + "amount" : 220, + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T01:25:00", + "amount" : 220 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T01:30:00", + "amount" : 220 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T01:35:00", + "amount" : 220 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T01:40:00", + "amount" : 220 + }, + { + "date" : "2015-10-16T01:45:00", + "amount" : 220, + "unit" : "mg\/dL" + }, + { + "amount" : 220, + "unit" : "mg\/dL", + "date" : "2015-10-16T01:50:00" + }, + { + "amount" : 220, + "date" : "2015-10-16T01:55:00", + "unit" : "mg\/dL" + }, + { + "amount" : 220, + "unit" : "mg\/dL", + "date" : "2015-10-16T02:00:00" + }, + { + "amount" : 220, + "date" : "2015-10-16T02:05:00", + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T02:10:00", + "amount" : 220 + }, + { + "unit" : "mg\/dL", + "amount" : 220, + "date" : "2015-10-16T02:15:00" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T02:20:00", + "amount" : 220 + }, + { + "amount" : 220, + "date" : "2015-10-16T02:25:00", + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T02:30:00", + "amount" : 220 + }, + { + "amount" : 220, + "date" : "2015-10-16T02:35:00", + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-16T02:40:00", + "unit" : "mg\/dL", + "amount" : 220 + }, + { + "date" : "2015-10-16T02:45:00", + "amount" : 220, + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-16T02:50:00", + "amount" : 220, + "unit" : "mg\/dL" + }, + { + "date" : "2015-10-16T02:55:00", + "unit" : "mg\/dL", + "amount" : 220 + }, + { + "date" : "2015-10-16T03:00:00", + "unit" : "mg\/dL", + "amount" : 220 + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T03:05:00", + "amount" : 220 + }, + { + "unit" : "mg\/dL", + "amount" : 220, + "date" : "2015-10-16T03:10:00" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T03:15:00", + "amount" : 220 + }, + { + "amount" : 220, + "unit" : "mg\/dL", + "date" : "2015-10-16T03:20:00" + }, + { + "unit" : "mg\/dL", + "amount" : 220, + "date" : "2015-10-16T03:25:00" + }, + { + "amount" : 220, + "date" : "2015-10-16T03:30:00", + "unit" : "mg\/dL" + }, + { + "unit" : "mg\/dL", + "date" : "2015-10-16T03:35:00", + "amount" : 220 + } +] \ No newline at end of file diff --git a/Tests/LoopAlgorithmTests/Fixtures/ice_1_hour_input.json b/Tests/LoopAlgorithmTests/Fixtures/ice_1_hour_input.json new file mode 100644 index 0000000..861aec5 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/ice_1_hour_input.json @@ -0,0 +1,92 @@ +[ + { + "velocity": 0.0, + "start_at": "2015-10-15T21:30:12", + "end_at": "2015-10-15T21:35:12" + }, + { + "velocity": 5.0, + "start_at": "2015-10-15T21:35:12", + "end_at": "2015-10-15T21:40:12" + }, + { + "velocity": 3.0, + "start_at": "2015-10-15T21:40:12", + "end_at": "2015-10-15T21:45:12" + }, + { + "velocity": 2.0, + "start_at": "2015-10-15T21:45:12", + "end_at": "2015-10-15T21:50:12" + }, + { + "velocity": 3.0, + "start_at": "2015-10-15T21:50:12", + "end_at": "2015-10-15T21:55:12" + }, + { + "velocity": 5.0, + "start_at": "2015-10-15T21:55:12", + "end_at": "2015-10-15T22:00:12" + }, + { + "velocity": -2.0, + "start_at": "2015-10-15T22:00:12", + "end_at": "2015-10-15T22:05:12" + }, + { + "velocity": -1.0, + "start_at": "2015-10-15T22:05:12", + "end_at": "2015-10-15T22:10:12" + }, + { + "velocity": 5.0, + "start_at": "2015-10-15T22:10:12", + "end_at": "2015-10-15T22:15:12" + }, + { + "velocity": 4.0, + "start_at": "2015-10-15T22:15:12", + "end_at": "2015-10-15T22:20:12" + }, + { + "velocity": 3.0, + "start_at": "2015-10-15T22:20:12", + "end_at": "2015-10-15T22:25:12" + }, + { + "velocity": 2.0, + "start_at": "2015-10-15T22:25:12", + "end_at": "2015-10-15T22:30:12" + }, + { + "velocity": 1.0, + "start_at": "2015-10-15T22:30:12", + "end_at": "2015-10-15T22:35:12" + }, + { + "velocity": 1.0, + "start_at": "2015-10-15T22:35:12", + "end_at": "2015-10-15T22:40:12" + }, + { + "velocity": 1.0, + "start_at": "2015-10-15T22:40:12", + "end_at": "2015-10-15T22:45:12" + }, + { + "velocity": 2.0, + "start_at": "2015-10-15T22:45:12", + "end_at": "2015-10-15T22:50:12" + }, + { + "velocity": 3.0, + "start_at": "2015-10-15T22:50:12", + "end_at": "2015-10-15T22:55:12" + }, + { + "velocity": 4.0, + "start_at": "2015-10-15T22:55:12", + "end_at": "2015-10-15T23:00:12" + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/ice_1_hour_output.json b/Tests/LoopAlgorithmTests/Fixtures/ice_1_hour_output.json new file mode 100644 index 0000000..f30ca90 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/ice_1_hour_output.json @@ -0,0 +1,372 @@ +[ + { + "unit" : "g", + "date" : "2015-10-15T21:30:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T21:35:00", + "amount" : 44 + }, + { + "unit" : "g", + "date" : "2015-10-15T21:40:00", + "amount" : 44 + }, + { + "unit" : "g", + "date" : "2015-10-15T21:45:00", + "amount" : 39 + }, + { + "unit" : "g", + "date" : "2015-10-15T21:50:00", + "amount" : 36 + }, + { + "unit" : "g", + "date" : "2015-10-15T21:55:00", + "amount" : 34 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:00:00", + "amount" : 31 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:05:00", + "amount" : 26 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:10:00", + "amount" : 26 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:15:00", + "amount" : 26 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:20:00", + "amount" : 21 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:25:00", + "amount" : 17 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:30:00", + "amount" : 14 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:35:00", + "amount" : 12 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:40:00", + "amount" : 11 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:45:00", + "amount" : 10 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:50:00", + "amount" : 9 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:55:00", + "amount" : 7 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:00:00", + "amount" : 4 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:05:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:10:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:15:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:20:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:25:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:30:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:35:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:40:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:45:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:50:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:55:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:00:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:05:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:10:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:15:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:20:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:25:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:30:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:35:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:40:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:45:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:50:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:55:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:00:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:05:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:10:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:15:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:20:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:25:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:30:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:35:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:40:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:45:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:50:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:55:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:00:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:05:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:10:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:15:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:20:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:25:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:30:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:35:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:40:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:45:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:50:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:55:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:00:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:05:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:10:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:15:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:20:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:25:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:30:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:35:00", + "amount" : 0 + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/ice_35_min_input.json b/Tests/LoopAlgorithmTests/Fixtures/ice_35_min_input.json new file mode 100644 index 0000000..78f7cdd --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/ice_35_min_input.json @@ -0,0 +1,37 @@ +[ + { + "velocity": 0.0, + "start_at": "2015-10-15T21:30:12", + "end_at": "2015-10-15T21:35:12" + }, + { + "velocity": 5.0, + "start_at": "2015-10-15T21:35:12", + "end_at": "2015-10-15T21:40:12" + }, + { + "velocity": 3.0, + "start_at": "2015-10-15T21:40:12", + "end_at": "2015-10-15T21:45:12" + }, + { + "velocity": 2.0, + "start_at": "2015-10-15T21:45:12", + "end_at": "2015-10-15T21:50:12" + }, + { + "velocity": 3.0, + "start_at": "2015-10-15T21:50:12", + "end_at": "2015-10-15T21:55:12" + }, + { + "velocity": 5.0, + "start_at": "2015-10-15T21:55:12", + "end_at": "2015-10-15T22:00:12" + }, + { + "velocity": -2.0, + "start_at": "2015-10-15T22:00:12", + "end_at": "2015-10-15T22:05:12" + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/ice_35_min_none_output.json b/Tests/LoopAlgorithmTests/Fixtures/ice_35_min_none_output.json new file mode 100644 index 0000000..3efc5c6 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/ice_35_min_none_output.json @@ -0,0 +1,372 @@ +[ + { + "unit" : "g", + "date" : "2015-10-15T21:30:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T21:35:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T21:40:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T21:45:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T21:50:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T21:55:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:00:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:05:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:10:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:15:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:20:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:25:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:30:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:35:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:40:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:45:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:50:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:55:00", + "amount" : 30 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:00:00", + "amount" : 30 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:05:00", + "amount" : 30 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:10:00", + "amount" : 30 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:15:00", + "amount" : 29.375 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:20:00", + "amount" : 28.75 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:25:00", + "amount" : 28.125 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:30:00", + "amount" : 27.5 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:35:00", + "amount" : 26.875 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:40:00", + "amount" : 26.25 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:45:00", + "amount" : 25.625 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:50:00", + "amount" : 25 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:55:00", + "amount" : 24.375 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:00:00", + "amount" : 23.75 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:05:00", + "amount" : 23.125 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:10:00", + "amount" : 22.5 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:15:00", + "amount" : 21.875 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:20:00", + "amount" : 21.25 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:25:00", + "amount" : 20.625 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:30:00", + "amount" : 20 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:35:00", + "amount" : 19.375 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:40:00", + "amount" : 18.75 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:45:00", + "amount" : 18.125 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:50:00", + "amount" : 17.5 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:55:00", + "amount" : 16.875 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:00:00", + "amount" : 16.25 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:05:00", + "amount" : 15.625 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:10:00", + "amount" : 15 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:15:00", + "amount" : 14.375 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:20:00", + "amount" : 13.75 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:25:00", + "amount" : 13.125 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:30:00", + "amount" : 12.5 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:35:00", + "amount" : 11.875 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:40:00", + "amount" : 11.25 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:45:00", + "amount" : 10.625 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:50:00", + "amount" : 10 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:55:00", + "amount" : 9.375 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:00:00", + "amount" : 8.75 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:05:00", + "amount" : 8.125 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:10:00", + "amount" : 7.5 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:15:00", + "amount" : 6.875 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:20:00", + "amount" : 6.25 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:25:00", + "amount" : 5.625 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:30:00", + "amount" : 5 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:35:00", + "amount" : 4.375 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:40:00", + "amount" : 3.75 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:45:00", + "amount" : 3.125 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:50:00", + "amount" : 2.5 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:55:00", + "amount" : 1.875 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:00:00", + "amount" : 1.25 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:05:00", + "amount" : 0.625 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:10:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:15:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:20:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:25:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:30:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:35:00", + "amount" : 0 + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/ice_35_min_none_piecewiselinear_output.json b/Tests/LoopAlgorithmTests/Fixtures/ice_35_min_none_piecewiselinear_output.json new file mode 100644 index 0000000..f5b909e --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/ice_35_min_none_piecewiselinear_output.json @@ -0,0 +1,372 @@ +[ + { + "amount" : 0, + "date" : "2015-10-15T21:30:00", + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-15T21:35:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T21:40:00", + "amount" : 0 + }, + { + "amount" : 0, + "date" : "2015-10-15T21:45:00", + "unit" : "g" + }, + { + "amount" : 0, + "date" : "2015-10-15T21:50:00", + "unit" : "g" + }, + { + "date" : "2015-10-15T21:55:00", + "unit" : "g", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:00:00", + "amount" : 0 + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-15T22:05:00" + }, + { + "unit" : "g", + "date" : "2015-10-15T22:10:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:15:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:20:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:25:00", + "amount" : 0 + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-15T22:30:00" + }, + { + "date" : "2015-10-15T22:35:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-15T22:40:00", + "amount" : 0, + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-15T22:45:00", + "amount" : 0 + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-15T22:50:00" + }, + { + "date" : "2015-10-15T22:55:00", + "amount" : 30, + "unit" : "g" + }, + { + "amount" : 30, + "unit" : "g", + "date" : "2015-10-15T23:00:00" + }, + { + "unit" : "g", + "date" : "2015-10-15T23:05:00", + "amount" : 30 + }, + { + "date" : "2015-10-15T23:10:00", + "amount" : 30, + "unit" : "g" + }, + { + "amount" : 29.885688157293096, + "unit" : "g", + "date" : "2015-10-15T23:15:00" + }, + { + "date" : "2015-10-15T23:20:00", + "amount" : 29.542752629172384, + "unit" : "g" + }, + { + "date" : "2015-10-15T23:25:00", + "amount" : 28.97119341563786, + "unit" : "g" + }, + { + "date" : "2015-10-15T23:30:00", + "amount" : 28.171010516689527, + "unit" : "g" + }, + { + "amount" : 27.142203932327391, + "unit" : "g", + "date" : "2015-10-15T23:35:00" + }, + { + "date" : "2015-10-15T23:40:00", + "amount" : 25.925925925925927, + "unit" : "g" + }, + { + "amount" : 24.691358024691358, + "date" : "2015-10-15T23:45:00", + "unit" : "g" + }, + { + "date" : "2015-10-15T23:50:00", + "amount" : 23.456790123456791, + "unit" : "g" + }, + { + "date" : "2015-10-15T23:55:00", + "unit" : "g", + "amount" : 22.222222222222221 + }, + { + "amount" : 20.987654320987655, + "date" : "2015-10-16T00:00:00", + "unit" : "g" + }, + { + "amount" : 19.753086419753085, + "date" : "2015-10-16T00:05:00", + "unit" : "g" + }, + { + "date" : "2015-10-16T00:10:00", + "amount" : 18.518518518518519, + "unit" : "g" + }, + { + "date" : "2015-10-16T00:15:00", + "unit" : "g", + "amount" : 17.283950617283953 + }, + { + "unit" : "g", + "amount" : 16.049382716049383, + "date" : "2015-10-16T00:20:00" + }, + { + "unit" : "g", + "date" : "2015-10-16T00:25:00", + "amount" : 14.814814814814813 + }, + { + "date" : "2015-10-16T00:30:00", + "amount" : 13.580246913580249, + "unit" : "g" + }, + { + "amount" : 12.345679012345679, + "unit" : "g", + "date" : "2015-10-16T00:35:00" + }, + { + "unit" : "g", + "date" : "2015-10-16T00:40:00", + "amount" : 11.111111111111114 + }, + { + "date" : "2015-10-16T00:45:00", + "unit" : "g", + "amount" : 9.910836762688616 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:50:00", + "amount" : 8.7791495198902609 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:55:00", + "amount" : 7.716049382716049 + }, + { + "amount" : 6.7215363511659802, + "date" : "2015-10-16T01:00:00", + "unit" : "g" + }, + { + "date" : "2015-10-16T01:05:00", + "unit" : "g", + "amount" : 5.7956104252400564 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:10:00", + "amount" : 4.9382716049382722 + }, + { + "amount" : 4.1495198902606383, + "unit" : "g", + "date" : "2015-10-16T01:15:00" + }, + { + "amount" : 3.4293552812071395, + "date" : "2015-10-16T01:20:00", + "unit" : "g" + }, + { + "date" : "2015-10-16T01:25:00", + "unit" : "g", + "amount" : 2.7777777777777768 + }, + { + "amount" : 2.1947873799725635, + "date" : "2015-10-16T01:30:00", + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-16T01:35:00", + "amount" : 1.6803840877914999 + }, + { + "unit" : "g", + "amount" : 1.2345679012345689, + "date" : "2015-10-16T01:40:00" + }, + { + "unit" : "g", + "amount" : 0.85733882030178399, + "date" : "2015-10-16T01:45:00" + }, + { + "unit" : "g", + "date" : "2015-10-16T01:50:00", + "amount" : 0.54869684499314175 + }, + { + "unit" : "g", + "amount" : 0.30864197530864557, + "date" : "2015-10-16T01:55:00" + }, + { + "date" : "2015-10-16T02:00:00", + "unit" : "g", + "amount" : 0.13717421124828544 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:05:00", + "amount" : 0.03429355281207469 + }, + { + "date" : "2015-10-16T02:10:00", + "unit" : "g", + "amount" : 0 + }, + { + "date" : "2015-10-16T02:15:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-16T02:20:00", + "amount" : 0, + "unit" : "g" + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T02:25:00" + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T02:30:00" + }, + { + "date" : "2015-10-16T02:35:00", + "unit" : "g", + "amount" : 0 + }, + { + "amount" : 0, + "date" : "2015-10-16T02:40:00", + "unit" : "g" + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-16T02:45:00" + }, + { + "unit" : "g", + "date" : "2015-10-16T02:50:00", + "amount" : 0 + }, + { + "date" : "2015-10-16T02:55:00", + "amount" : 0, + "unit" : "g" + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T03:00:00" + }, + { + "date" : "2015-10-16T03:05:00", + "unit" : "g", + "amount" : 0 + }, + { + "date" : "2015-10-16T03:10:00", + "amount" : 0, + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-16T03:15:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:20:00", + "amount" : 0 + }, + { + "date" : "2015-10-16T03:25:00", + "unit" : "g", + "amount" : 0 + }, + { + "date" : "2015-10-16T03:30:00", + "unit" : "g", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:35:00", + "amount" : 0 + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/ice_35_min_partial_output.json b/Tests/LoopAlgorithmTests/Fixtures/ice_35_min_partial_output.json new file mode 100644 index 0000000..794e58f --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/ice_35_min_partial_output.json @@ -0,0 +1,372 @@ +[ + { + "unit" : "g", + "date" : "2015-10-15T21:30:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T21:35:00", + "amount" : 44 + }, + { + "unit" : "g", + "date" : "2015-10-15T21:40:00", + "amount" : 44 + }, + { + "unit" : "g", + "date" : "2015-10-15T21:45:00", + "amount" : 39 + }, + { + "unit" : "g", + "date" : "2015-10-15T21:50:00", + "amount" : 36 + }, + { + "unit" : "g", + "date" : "2015-10-15T21:55:00", + "amount" : 34 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:00:00", + "amount" : 31 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:05:00", + "amount" : 26 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:10:00", + "amount" : 25.12 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:15:00", + "amount" : 24.20333333333333 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:20:00", + "amount" : 23.28666666666667 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:25:00", + "amount" : 22.37 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:30:00", + "amount" : 21.45333333333333 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:35:00", + "amount" : 20.53666666666667 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:40:00", + "amount" : 19.62 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:45:00", + "amount" : 18.70333333333333 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:50:00", + "amount" : 17.78666666666667 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:55:00", + "amount" : 16.87 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:00:00", + "amount" : 15.95333333333333 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:05:00", + "amount" : 15.03666666666667 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:10:00", + "amount" : 14.12 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:15:00", + "amount" : 13.20333333333333 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:20:00", + "amount" : 12.28666666666667 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:25:00", + "amount" : 11.37 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:30:00", + "amount" : 10.45333333333333 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:35:00", + "amount" : 9.536666666666664 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:40:00", + "amount" : 8.619999999999997 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:45:00", + "amount" : 7.703333333333331 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:50:00", + "amount" : 6.786666666666665 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:55:00", + "amount" : 5.869999999999999 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:00:00", + "amount" : 4.95333333333333 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:05:00", + "amount" : 4.036666666666664 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:10:00", + "amount" : 3.119999999999997 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:15:00", + "amount" : 2.20333333333333 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:20:00", + "amount" : 1.286666666666664 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:25:00", + "amount" : 0.3699999999999981 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:30:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:35:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:40:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:45:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:50:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:55:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:00:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:05:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:10:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:15:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:20:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:25:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:30:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:35:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:40:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:45:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:50:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:55:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:00:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:05:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:10:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:15:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:20:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:25:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:30:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:35:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:40:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:45:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:50:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:55:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:00:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:05:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:10:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:15:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:20:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:25:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:30:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:35:00", + "amount" : 0 + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/ice_35_min_partial_piecewiselinear_adaptiverate_output.json b/Tests/LoopAlgorithmTests/Fixtures/ice_35_min_partial_piecewiselinear_adaptiverate_output.json new file mode 100644 index 0000000..b70ec14 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/ice_35_min_partial_piecewiselinear_adaptiverate_output.json @@ -0,0 +1,372 @@ +[ + { + "unit" : "g", + "date" : "2015-10-15T21:30:00", + "amount" : 0 + }, + { + "date" : "2015-10-15T21:35:00", + "amount" : 44, + "unit" : "g" + }, + { + "amount" : 44, + "date" : "2015-10-15T21:40:00", + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-15T21:45:00", + "amount" : 39 + }, + { + "date" : "2015-10-15T21:50:00", + "amount" : 36, + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-15T21:55:00", + "amount" : 34 + }, + { + "date" : "2015-10-15T22:00:00", + "amount" : 31, + "unit" : "g" + }, + { + "date" : "2015-10-15T22:05:00", + "amount" : 26, + "unit" : "g" + }, + { + "amount" : 22.337777777777781, + "unit" : "g", + "date" : "2015-10-15T22:10:00" + }, + { + "date" : "2015-10-15T22:15:00", + "amount" : 18.522962962962964, + "unit" : "g" + }, + { + "unit" : "g", + "amount" : 14.746841212121204, + "date" : "2015-10-15T22:20:00" + }, + { + "amount" : 11.341165286195286, + "date" : "2015-10-15T22:25:00", + "unit" : "g" + }, + { + "amount" : 8.38199609427609, + "unit" : "g", + "date" : "2015-10-15T22:30:00" + }, + { + "amount" : 5.869333636363633, + "unit" : "g", + "date" : "2015-10-15T22:35:00" + }, + { + "date" : "2015-10-15T22:40:00", + "amount" : 3.803177912457913, + "unit" : "g" + }, + { + "amount" : 2.1835289225589243, + "date" : "2015-10-15T22:45:00", + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-15T22:50:00", + "amount" : 1.0103866666666668 + }, + { + "amount" : 0.28375114478115027, + "date" : "2015-10-15T22:55:00", + "unit" : "g" + }, + { + "date" : "2015-10-15T23:00:00", + "amount" : 0.0036223569023552393, + "unit" : "g" + }, + { + "date" : "2015-10-15T23:05:00", + "amount" : 0, + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-15T23:10:00", + "amount" : 0 + }, + { + "date" : "2015-10-15T23:15:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-15T23:20:00", + "amount" : 0, + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-15T23:25:00", + "amount" : 0 + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-15T23:30:00" + }, + { + "date" : "2015-10-15T23:35:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-15T23:40:00", + "unit" : "g", + "amount" : 0 + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-15T23:45:00" + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-15T23:50:00" + }, + { + "amount" : 0, + "date" : "2015-10-15T23:55:00", + "unit" : "g" + }, + { + "date" : "2015-10-16T00:00:00", + "amount" : 0, + "unit" : "g" + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-16T00:05:00" + }, + { + "date" : "2015-10-16T00:10:00", + "unit" : "g", + "amount" : 0 + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T00:15:00" + }, + { + "amount" : 0, + "date" : "2015-10-16T00:20:00", + "unit" : "g" + }, + { + "date" : "2015-10-16T00:25:00", + "amount" : 0, + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-16T00:30:00", + "amount" : 0 + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T00:35:00" + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-16T00:40:00" + }, + { + "unit" : "g", + "date" : "2015-10-16T00:45:00", + "amount" : 0 + }, + { + "date" : "2015-10-16T00:50:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-16T00:55:00", + "amount" : 0, + "unit" : "g" + }, + { + "amount" : 0, + "date" : "2015-10-16T01:00:00", + "unit" : "g" + }, + { + "amount" : 0, + "date" : "2015-10-16T01:05:00", + "unit" : "g" + }, + { + "amount" : 0, + "date" : "2015-10-16T01:10:00", + "unit" : "g" + }, + { + "date" : "2015-10-16T01:15:00", + "amount" : 0, + "unit" : "g" + }, + { + "amount" : 0, + "date" : "2015-10-16T01:20:00", + "unit" : "g" + }, + { + "date" : "2015-10-16T01:25:00", + "amount" : 0, + "unit" : "g" + }, + { + "amount" : 0, + "date" : "2015-10-16T01:30:00", + "unit" : "g" + }, + { + "amount" : 0, + "date" : "2015-10-16T01:35:00", + "unit" : "g" + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-16T01:40:00" + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T01:45:00" + }, + { + "amount" : 0, + "date" : "2015-10-16T01:50:00", + "unit" : "g" + }, + { + "date" : "2015-10-16T01:55:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-16T02:00:00", + "unit" : "g", + "amount" : 0 + }, + { + "date" : "2015-10-16T02:05:00", + "amount" : 0, + "unit" : "g" + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T02:10:00" + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-16T02:15:00" + }, + { + "date" : "2015-10-16T02:20:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-16T02:25:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-16T02:30:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-16T02:35:00", + "unit" : "g", + "amount" : 0 + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T02:40:00" + }, + { + "date" : "2015-10-16T02:45:00", + "amount" : 0, + "unit" : "g" + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T02:50:00" + }, + { + "date" : "2015-10-16T02:55:00", + "unit" : "g", + "amount" : 0 + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T03:00:00" + }, + { + "date" : "2015-10-16T03:05:00", + "amount" : 0, + "unit" : "g" + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-16T03:10:00" + }, + { + "date" : "2015-10-16T03:15:00", + "unit" : "g", + "amount" : 0 + }, + { + "amount" : 0, + "date" : "2015-10-16T03:20:00", + "unit" : "g" + }, + { + "date" : "2015-10-16T03:25:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-16T03:30:00", + "amount" : 0, + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-16T03:35:00", + "amount" : 0 + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/ice_35_min_partial_piecewiselinear_output.json b/Tests/LoopAlgorithmTests/Fixtures/ice_35_min_partial_piecewiselinear_output.json new file mode 100644 index 0000000..fb3ed22 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/ice_35_min_partial_piecewiselinear_output.json @@ -0,0 +1,372 @@ +[ + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-15T21:30:00" + }, + { + "unit" : "g", + "amount" : 44, + "date" : "2015-10-15T21:35:00" + }, + { + "date" : "2015-10-15T21:40:00", + "amount" : 44, + "unit" : "g" + }, + { + "amount" : 39, + "date" : "2015-10-15T21:45:00", + "unit" : "g" + }, + { + "amount" : 36, + "date" : "2015-10-15T21:50:00", + "unit" : "g" + }, + { + "date" : "2015-10-15T21:55:00", + "unit" : "g", + "amount" : 34 + }, + { + "date" : "2015-10-15T22:00:00", + "unit" : "g", + "amount" : 31 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:05:00", + "amount" : 26 + }, + { + "amount" : 24.261728395061731, + "unit" : "g", + "date" : "2015-10-15T22:10:00" + }, + { + "unit" : "g", + "amount" : 22.451028806584361, + "date" : "2015-10-15T22:15:00" + }, + { + "unit" : "g", + "amount" : 20.640329218106992, + "date" : "2015-10-15T22:20:00" + }, + { + "unit" : "g", + "date" : "2015-10-15T22:25:00", + "amount" : 18.829629629629629 + }, + { + "unit" : "g", + "amount" : 17.018930041152263, + "date" : "2015-10-15T22:30:00" + }, + { + "date" : "2015-10-15T22:35:00", + "unit" : "g", + "amount" : 15.226392359812111 + }, + { + "amount" : 13.526438084549193, + "unit" : "g", + "date" : "2015-10-15T22:40:00" + }, + { + "unit" : "g", + "amount" : 11.92707823086835, + "date" : "2015-10-15T22:45:00" + }, + { + "date" : "2015-10-15T22:50:00", + "amount" : 10.428312798769589, + "unit" : "g" + }, + { + "unit" : "g", + "amount" : 9.0301417882528927, + "date" : "2015-10-15T22:55:00" + }, + { + "amount" : 7.7325651993182838, + "unit" : "g", + "date" : "2015-10-15T23:00:00" + }, + { + "date" : "2015-10-15T23:05:00", + "amount" : 6.53558303196575, + "unit" : "g" + }, + { + "date" : "2015-10-15T23:10:00", + "amount" : 5.4391952861952868, + "unit" : "g" + }, + { + "date" : "2015-10-15T23:15:00", + "amount" : 4.4434019620068952, + "unit" : "g" + }, + { + "amount" : 3.5482030594005942, + "date" : "2015-10-15T23:20:00", + "unit" : "g" + }, + { + "amount" : 2.7535985783763595, + "unit" : "g", + "date" : "2015-10-15T23:25:00" + }, + { + "date" : "2015-10-15T23:30:00", + "amount" : 2.0595885189341958, + "unit" : "g" + }, + { + "amount" : 1.466172881074113, + "date" : "2015-10-15T23:35:00", + "unit" : "g" + }, + { + "date" : "2015-10-15T23:40:00", + "amount" : 0.97335166479610624, + "unit" : "g" + }, + { + "date" : "2015-10-15T23:45:00", + "amount" : 0.58112487010018032, + "unit" : "g" + }, + { + "amount" : 0.28949249698633039, + "date" : "2015-10-15T23:50:00", + "unit" : "g" + }, + { + "amount" : 0.098454545454546682, + "date" : "2015-10-15T23:55:00", + "unit" : "g" + }, + { + "amount" : 0.0080110155048438436, + "unit" : "g", + "date" : "2015-10-16T00:00:00" + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-16T00:05:00" + }, + { + "date" : "2015-10-16T00:10:00", + "unit" : "g", + "amount" : 0 + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T00:15:00" + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T00:20:00" + }, + { + "amount" : 0, + "date" : "2015-10-16T00:25:00", + "unit" : "g" + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T00:30:00" + }, + { + "date" : "2015-10-16T00:35:00", + "unit" : "g", + "amount" : 0 + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-16T00:40:00" + }, + { + "date" : "2015-10-16T00:45:00", + "amount" : 0, + "unit" : "g" + }, + { + "amount" : 0, + "date" : "2015-10-16T00:50:00", + "unit" : "g" + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T00:55:00" + }, + { + "unit" : "g", + "date" : "2015-10-16T01:00:00", + "amount" : 0 + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-16T01:05:00" + }, + { + "unit" : "g", + "date" : "2015-10-16T01:10:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:15:00", + "amount" : 0 + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T01:20:00" + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T01:25:00" + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-16T01:30:00" + }, + { + "unit" : "g", + "date" : "2015-10-16T01:35:00", + "amount" : 0 + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T01:40:00" + }, + { + "unit" : "g", + "date" : "2015-10-16T01:45:00", + "amount" : 0 + }, + { + "date" : "2015-10-16T01:50:00", + "amount" : 0, + "unit" : "g" + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T01:55:00" + }, + { + "amount" : 0, + "date" : "2015-10-16T02:00:00", + "unit" : "g" + }, + { + "date" : "2015-10-16T02:05:00", + "unit" : "g", + "amount" : 0 + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-16T02:10:00" + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T02:15:00" + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-16T02:20:00" + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T02:25:00" + }, + { + "unit" : "g", + "date" : "2015-10-16T02:30:00", + "amount" : 0 + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-16T02:35:00" + }, + { + "date" : "2015-10-16T02:40:00", + "amount" : 0, + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-16T02:45:00", + "amount" : 0 + }, + { + "date" : "2015-10-16T02:50:00", + "amount" : 0, + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-16T02:55:00", + "amount" : 0 + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T03:00:00" + }, + { + "date" : "2015-10-16T03:05:00", + "amount" : 0, + "unit" : "g" + }, + { + "amount" : 0, + "date" : "2015-10-16T03:10:00", + "unit" : "g" + }, + { + "date" : "2015-10-16T03:15:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-16T03:20:00", + "amount" : 0, + "unit" : "g" + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T03:25:00" + }, + { + "unit" : "g", + "date" : "2015-10-16T03:30:00", + "amount" : 0 + }, + { + "amount" : 0, + "date" : "2015-10-16T03:35:00", + "unit" : "g" + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/ice_slow_absorption.json b/Tests/LoopAlgorithmTests/Fixtures/ice_slow_absorption.json new file mode 100644 index 0000000..62f9716 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/ice_slow_absorption.json @@ -0,0 +1,92 @@ +[ + { + "velocity": 0.0, + "start_at": "2015-10-15T21:30:12", + "end_at": "2015-10-15T21:35:12" + }, + { + "velocity": 2.0, + "start_at": "2015-10-15T21:35:12", + "end_at": "2015-10-15T21:40:12" + }, + { + "velocity": 3.0, + "start_at": "2015-10-15T21:40:12", + "end_at": "2015-10-15T21:45:12" + }, + { + "velocity": 2.0, + "start_at": "2015-10-15T21:45:12", + "end_at": "2015-10-15T21:50:12" + }, + { + "velocity": 1.0, + "start_at": "2015-10-15T21:50:12", + "end_at": "2015-10-15T21:55:12" + }, + { + "velocity": 0.0, + "start_at": "2015-10-15T21:55:12", + "end_at": "2015-10-15T22:00:12" + }, + { + "velocity": -2.0, + "start_at": "2015-10-15T22:00:12", + "end_at": "2015-10-15T22:05:12" + }, + { + "velocity": -1.0, + "start_at": "2015-10-15T22:05:12", + "end_at": "2015-10-15T22:10:12" + }, + { + "velocity": 0.0, + "start_at": "2015-10-15T22:10:12", + "end_at": "2015-10-15T22:15:12" + }, + { + "velocity": 1.0, + "start_at": "2015-10-15T22:15:12", + "end_at": "2015-10-15T22:20:12" + }, + { + "velocity": 0.0, + "start_at": "2015-10-15T22:20:12", + "end_at": "2015-10-15T22:25:12" + }, + { + "velocity": 0.0, + "start_at": "2015-10-15T22:25:12", + "end_at": "2015-10-15T22:30:12" + }, + { + "velocity": -1.0, + "start_at": "2015-10-15T22:30:12", + "end_at": "2015-10-15T22:35:12" + }, + { + "velocity": -2.0, + "start_at": "2015-10-15T22:35:12", + "end_at": "2015-10-15T22:40:12" + }, + { + "velocity": -2.0, + "start_at": "2015-10-15T22:40:12", + "end_at": "2015-10-15T22:45:12" + }, + { + "velocity": -3.0, + "start_at": "2015-10-15T22:45:12", + "end_at": "2015-10-15T22:50:12" + }, + { + "velocity": -3.0, + "start_at": "2015-10-15T22:50:12", + "end_at": "2015-10-15T22:55:12" + }, + { + "velocity": -2.0, + "start_at": "2015-10-15T22:55:12", + "end_at": "2015-10-15T23:00:12" + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/ice_slow_absorption_output.json b/Tests/LoopAlgorithmTests/Fixtures/ice_slow_absorption_output.json new file mode 100644 index 0000000..f23dc4d --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/ice_slow_absorption_output.json @@ -0,0 +1,1092 @@ +[ + { + "unit" : "g", + "date" : "2015-10-15T21:30:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T21:35:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T21:40:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T21:45:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-15T21:50:00", + "amount" : 30 + }, + { + "unit" : "g", + "date" : "2015-10-15T21:55:00", + "amount" : 30 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:00:00", + "amount" : 30 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:05:00", + "amount" : 30 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:10:00", + "amount" : 29.375 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:15:00", + "amount" : 28.75 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:20:00", + "amount" : 28.125 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:25:00", + "amount" : 27.5 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:30:00", + "amount" : 26.875 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:35:00", + "amount" : 26.25 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:40:00", + "amount" : 25.625 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:45:00", + "amount" : 25 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:50:00", + "amount" : 24.375 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:55:00", + "amount" : 23.75 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:00:00", + "amount" : 23.125 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:05:00", + "amount" : 22.5 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:10:00", + "amount" : 21.875 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:15:00", + "amount" : 21.25 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:20:00", + "amount" : 20.625 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:25:00", + "amount" : 20 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:30:00", + "amount" : 19.375 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:35:00", + "amount" : 18.75 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:40:00", + "amount" : 18.125 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:45:00", + "amount" : 17.5 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:50:00", + "amount" : 16.875 + }, + { + "unit" : "g", + "date" : "2015-10-15T23:55:00", + "amount" : 16.25 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:00:00", + "amount" : 15.625 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:05:00", + "amount" : 15 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:10:00", + "amount" : 14.375 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:15:00", + "amount" : 13.75 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:20:00", + "amount" : 13.125 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:25:00", + "amount" : 12.5 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:30:00", + "amount" : 11.875 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:35:00", + "amount" : 11.25 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:40:00", + "amount" : 10.625 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:45:00", + "amount" : 10 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:50:00", + "amount" : 9.375 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:55:00", + "amount" : 8.75 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:00:00", + "amount" : 8.125 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:05:00", + "amount" : 7.5 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:10:00", + "amount" : 6.875 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:15:00", + "amount" : 6.25 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:20:00", + "amount" : 5.625 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:25:00", + "amount" : 5 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:30:00", + "amount" : 4.375 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:35:00", + "amount" : 3.75 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:40:00", + "amount" : 3.125 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:45:00", + "amount" : 2.5 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:50:00", + "amount" : 1.875 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:55:00", + "amount" : 1.25 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:00:00", + "amount" : 0.625 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:05:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:10:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:15:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:20:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:25:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:30:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:35:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:40:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:45:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:50:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:55:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:00:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:05:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:10:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:15:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:20:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:25:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:30:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:35:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:40:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:45:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:50:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T03:55:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T04:00:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T04:05:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T04:10:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T04:15:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T04:20:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T04:25:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T04:30:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T04:35:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T04:40:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T04:45:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T04:50:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T04:55:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T05:00:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T05:05:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T05:10:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T05:15:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T05:20:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T05:25:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T05:30:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T05:35:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T05:40:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T05:45:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T05:50:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T05:55:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T06:00:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T06:05:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T06:10:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T06:15:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T06:20:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T06:25:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T06:30:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T06:35:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T06:40:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T06:45:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T06:50:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T06:55:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T07:00:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T07:05:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T07:10:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T07:15:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T07:20:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T07:25:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T07:30:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T07:35:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T07:40:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T07:45:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T07:50:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T07:55:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T08:00:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T08:05:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T08:10:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T08:15:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T08:20:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T08:25:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T08:30:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T08:35:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T08:40:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T08:45:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T08:50:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T08:55:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T09:00:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T09:05:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T09:10:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T09:15:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T09:20:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T09:25:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T09:30:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T09:35:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T09:40:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T09:45:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T09:50:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T09:55:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T10:00:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T10:05:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T10:10:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T10:15:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T10:20:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T10:25:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T10:30:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T10:35:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T10:40:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T10:45:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T10:50:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T10:55:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T11:00:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T11:05:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T11:10:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T11:15:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T11:20:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T11:25:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T11:30:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T11:35:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T11:40:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T11:45:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T11:50:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T11:55:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T12:00:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T12:05:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T12:10:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T12:15:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T12:20:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T12:25:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T12:30:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T12:35:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T12:40:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T12:45:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T12:50:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T12:55:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T13:00:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T13:05:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T13:10:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T13:15:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T13:20:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T13:25:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T13:30:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T13:35:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T13:40:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T13:45:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T13:50:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T13:55:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T14:00:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T14:05:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T14:10:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T14:15:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T14:20:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T14:25:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T14:30:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T14:35:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T14:40:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T14:45:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T14:50:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T14:55:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T15:00:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T15:05:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T15:10:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T15:15:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T15:20:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T15:25:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T15:30:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T15:35:00", + "amount" : 0 + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/ice_slow_absorption_piecewiselinear_output.json b/Tests/LoopAlgorithmTests/Fixtures/ice_slow_absorption_piecewiselinear_output.json new file mode 100644 index 0000000..09bb9b4 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/ice_slow_absorption_piecewiselinear_output.json @@ -0,0 +1,1092 @@ +[ + { + "unit" : "g", + "date" : "2015-10-15T21:30:00", + "amount" : 0 + }, + { + "amount" : 0, + "date" : "2015-10-15T21:35:00", + "unit" : "g" + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-15T21:40:00" + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-15T21:45:00" + }, + { + "amount" : 30, + "unit" : "g", + "date" : "2015-10-15T21:50:00" + }, + { + "date" : "2015-10-15T21:55:00", + "unit" : "g", + "amount" : 30 + }, + { + "date" : "2015-10-15T22:00:00", + "amount" : 30, + "unit" : "g" + }, + { + "amount" : 30, + "date" : "2015-10-15T22:05:00", + "unit" : "g" + }, + { + "amount" : 29.885688157293096, + "date" : "2015-10-15T22:10:00", + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-15T22:15:00", + "amount" : 29.542752629172384 + }, + { + "date" : "2015-10-15T22:20:00", + "amount" : 28.97119341563786, + "unit" : "g" + }, + { + "amount" : 28.171010516689527, + "unit" : "g", + "date" : "2015-10-15T22:25:00" + }, + { + "date" : "2015-10-15T22:30:00", + "unit" : "g", + "amount" : 27.142203932327391 + }, + { + "unit" : "g", + "date" : "2015-10-15T22:35:00", + "amount" : 25.925925925925927 + }, + { + "amount" : 24.691358024691358, + "unit" : "g", + "date" : "2015-10-15T22:40:00" + }, + { + "date" : "2015-10-15T22:45:00", + "amount" : 23.456790123456791, + "unit" : "g" + }, + { + "amount" : 22.222222222222221, + "unit" : "g", + "date" : "2015-10-15T22:50:00" + }, + { + "amount" : 20.987654320987655, + "date" : "2015-10-15T22:55:00", + "unit" : "g" + }, + { + "date" : "2015-10-15T23:00:00", + "amount" : 19.753086419753085, + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-15T23:05:00", + "amount" : 18.518518518518519 + }, + { + "date" : "2015-10-15T23:10:00", + "unit" : "g", + "amount" : 17.283950617283953 + }, + { + "amount" : 16.049382716049383, + "unit" : "g", + "date" : "2015-10-15T23:15:00" + }, + { + "unit" : "g", + "date" : "2015-10-15T23:20:00", + "amount" : 14.814814814814813 + }, + { + "unit" : "g", + "amount" : 13.580246913580249, + "date" : "2015-10-15T23:25:00" + }, + { + "date" : "2015-10-15T23:30:00", + "unit" : "g", + "amount" : 12.345679012345679 + }, + { + "date" : "2015-10-15T23:35:00", + "amount" : 11.111111111111114, + "unit" : "g" + }, + { + "date" : "2015-10-15T23:40:00", + "unit" : "g", + "amount" : 9.910836762688616 + }, + { + "date" : "2015-10-15T23:45:00", + "unit" : "g", + "amount" : 8.7791495198902609 + }, + { + "amount" : 7.716049382716049, + "date" : "2015-10-15T23:50:00", + "unit" : "g" + }, + { + "date" : "2015-10-15T23:55:00", + "unit" : "g", + "amount" : 6.7215363511659802 + }, + { + "date" : "2015-10-16T00:00:00", + "amount" : 5.7956104252400564, + "unit" : "g" + }, + { + "date" : "2015-10-16T00:05:00", + "amount" : 4.9382716049382722, + "unit" : "g" + }, + { + "unit" : "g", + "amount" : 4.1495198902606383, + "date" : "2015-10-16T00:10:00" + }, + { + "amount" : 3.4293552812071395, + "date" : "2015-10-16T00:15:00", + "unit" : "g" + }, + { + "unit" : "g", + "amount" : 2.7777777777777768, + "date" : "2015-10-16T00:20:00" + }, + { + "date" : "2015-10-16T00:25:00", + "unit" : "g", + "amount" : 2.1947873799725635 + }, + { + "unit" : "g", + "date" : "2015-10-16T00:30:00", + "amount" : 1.6803840877914999 + }, + { + "amount" : 1.2345679012345689, + "date" : "2015-10-16T00:35:00", + "unit" : "g" + }, + { + "amount" : 0.85733882030178399, + "date" : "2015-10-16T00:40:00", + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-16T00:45:00", + "amount" : 0.54869684499314175 + }, + { + "unit" : "g", + "amount" : 0.30864197530864557, + "date" : "2015-10-16T00:50:00" + }, + { + "amount" : 0.13717421124828544, + "unit" : "g", + "date" : "2015-10-16T00:55:00" + }, + { + "unit" : "g", + "date" : "2015-10-16T01:00:00", + "amount" : 0.03429355281207469 + }, + { + "date" : "2015-10-16T01:05:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-16T01:10:00", + "unit" : "g", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T01:15:00", + "amount" : 0 + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T01:20:00" + }, + { + "date" : "2015-10-16T01:25:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-16T01:30:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-16T01:35:00", + "amount" : 0, + "unit" : "g" + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-16T01:40:00" + }, + { + "date" : "2015-10-16T01:45:00", + "amount" : 0, + "unit" : "g" + }, + { + "amount" : 0, + "date" : "2015-10-16T01:50:00", + "unit" : "g" + }, + { + "amount" : 0, + "date" : "2015-10-16T01:55:00", + "unit" : "g" + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-16T02:00:00" + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T02:05:00" + }, + { + "unit" : "g", + "date" : "2015-10-16T02:10:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:15:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T02:20:00", + "amount" : 0 + }, + { + "amount" : 0, + "date" : "2015-10-16T02:25:00", + "unit" : "g" + }, + { + "date" : "2015-10-16T02:30:00", + "unit" : "g", + "amount" : 0 + }, + { + "date" : "2015-10-16T02:35:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-16T02:40:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-16T02:45:00", + "amount" : 0, + "unit" : "g" + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-16T02:50:00" + }, + { + "date" : "2015-10-16T02:55:00", + "amount" : 0, + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-16T03:00:00", + "amount" : 0 + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T03:05:00" + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-16T03:10:00" + }, + { + "unit" : "g", + "date" : "2015-10-16T03:15:00", + "amount" : 0 + }, + { + "date" : "2015-10-16T03:20:00", + "unit" : "g", + "amount" : 0 + }, + { + "amount" : 0, + "date" : "2015-10-16T03:25:00", + "unit" : "g" + }, + { + "amount" : 0, + "date" : "2015-10-16T03:30:00", + "unit" : "g" + }, + { + "amount" : 0, + "date" : "2015-10-16T03:35:00", + "unit" : "g" + }, + { + "date" : "2015-10-16T03:40:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-16T03:45:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-16T03:50:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-16T03:55:00", + "unit" : "g", + "amount" : 0 + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-16T04:00:00" + }, + { + "amount" : 0, + "date" : "2015-10-16T04:05:00", + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-16T04:10:00", + "amount" : 0 + }, + { + "date" : "2015-10-16T04:15:00", + "amount" : 0, + "unit" : "g" + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T04:20:00" + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-16T04:25:00" + }, + { + "date" : "2015-10-16T04:30:00", + "unit" : "g", + "amount" : 0 + }, + { + "date" : "2015-10-16T04:35:00", + "amount" : 0, + "unit" : "g" + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-16T04:40:00" + }, + { + "date" : "2015-10-16T04:45:00", + "unit" : "g", + "amount" : 0 + }, + { + "amount" : 0, + "date" : "2015-10-16T04:50:00", + "unit" : "g" + }, + { + "date" : "2015-10-16T04:55:00", + "amount" : 0, + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-16T05:00:00", + "amount" : 0 + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T05:05:00" + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T05:10:00" + }, + { + "unit" : "g", + "date" : "2015-10-16T05:15:00", + "amount" : 0 + }, + { + "amount" : 0, + "date" : "2015-10-16T05:20:00", + "unit" : "g" + }, + { + "date" : "2015-10-16T05:25:00", + "amount" : 0, + "unit" : "g" + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-16T05:30:00" + }, + { + "amount" : 0, + "date" : "2015-10-16T05:35:00", + "unit" : "g" + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T05:40:00" + }, + { + "amount" : 0, + "date" : "2015-10-16T05:45:00", + "unit" : "g" + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T05:50:00" + }, + { + "amount" : 0, + "date" : "2015-10-16T05:55:00", + "unit" : "g" + }, + { + "date" : "2015-10-16T06:00:00", + "unit" : "g", + "amount" : 0 + }, + { + "amount" : 0, + "date" : "2015-10-16T06:05:00", + "unit" : "g" + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-16T06:10:00" + }, + { + "unit" : "g", + "date" : "2015-10-16T06:15:00", + "amount" : 0 + }, + { + "date" : "2015-10-16T06:20:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-16T06:25:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-16T06:30:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-16T06:35:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-16T06:40:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-16T06:45:00", + "unit" : "g", + "amount" : 0 + }, + { + "date" : "2015-10-16T06:50:00", + "unit" : "g", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T06:55:00", + "amount" : 0 + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T07:00:00" + }, + { + "date" : "2015-10-16T07:05:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-16T07:10:00", + "amount" : 0, + "unit" : "g" + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-16T07:15:00" + }, + { + "amount" : 0, + "date" : "2015-10-16T07:20:00", + "unit" : "g" + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-16T07:25:00" + }, + { + "date" : "2015-10-16T07:30:00", + "amount" : 0, + "unit" : "g" + }, + { + "amount" : 0, + "date" : "2015-10-16T07:35:00", + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-16T07:40:00", + "amount" : 0 + }, + { + "date" : "2015-10-16T07:45:00", + "unit" : "g", + "amount" : 0 + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-16T07:50:00" + }, + { + "amount" : 0, + "date" : "2015-10-16T07:55:00", + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-16T08:00:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T08:05:00", + "amount" : 0 + }, + { + "date" : "2015-10-16T08:10:00", + "amount" : 0, + "unit" : "g" + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T08:15:00" + }, + { + "date" : "2015-10-16T08:20:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-16T08:25:00", + "amount" : 0, + "unit" : "g" + }, + { + "amount" : 0, + "date" : "2015-10-16T08:30:00", + "unit" : "g" + }, + { + "amount" : 0, + "date" : "2015-10-16T08:35:00", + "unit" : "g" + }, + { + "date" : "2015-10-16T08:40:00", + "unit" : "g", + "amount" : 0 + }, + { + "date" : "2015-10-16T08:45:00", + "unit" : "g", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T08:50:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T08:55:00", + "amount" : 0 + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T09:00:00" + }, + { + "amount" : 0, + "date" : "2015-10-16T09:05:00", + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-16T09:10:00", + "amount" : 0 + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-16T09:15:00" + }, + { + "date" : "2015-10-16T09:20:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-16T09:25:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-16T09:30:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-16T09:35:00", + "unit" : "g", + "amount" : 0 + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T09:40:00" + }, + { + "date" : "2015-10-16T09:45:00", + "amount" : 0, + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-16T09:50:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T09:55:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T10:00:00", + "amount" : 0 + }, + { + "date" : "2015-10-16T10:05:00", + "amount" : 0, + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-16T10:10:00", + "amount" : 0 + }, + { + "date" : "2015-10-16T10:15:00", + "amount" : 0, + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-16T10:20:00", + "amount" : 0 + }, + { + "date" : "2015-10-16T10:25:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-16T10:30:00", + "amount" : 0, + "unit" : "g" + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-16T10:35:00" + }, + { + "date" : "2015-10-16T10:40:00", + "amount" : 0, + "unit" : "g" + }, + { + "amount" : 0, + "date" : "2015-10-16T10:45:00", + "unit" : "g" + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T10:50:00" + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T10:55:00" + }, + { + "date" : "2015-10-16T11:00:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-16T11:05:00", + "amount" : 0, + "unit" : "g" + }, + { + "amount" : 0, + "date" : "2015-10-16T11:10:00", + "unit" : "g" + }, + { + "date" : "2015-10-16T11:15:00", + "amount" : 0, + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-16T11:20:00", + "amount" : 0 + }, + { + "amount" : 0, + "date" : "2015-10-16T11:25:00", + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-16T11:30:00", + "amount" : 0 + }, + { + "amount" : 0, + "date" : "2015-10-16T11:35:00", + "unit" : "g" + }, + { + "date" : "2015-10-16T11:40:00", + "unit" : "g", + "amount" : 0 + }, + { + "amount" : 0, + "date" : "2015-10-16T11:45:00", + "unit" : "g" + }, + { + "date" : "2015-10-16T11:50:00", + "amount" : 0, + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-16T11:55:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T12:00:00", + "amount" : 0 + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T12:05:00" + }, + { + "date" : "2015-10-16T12:10:00", + "amount" : 0, + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-16T12:15:00", + "amount" : 0 + }, + { + "date" : "2015-10-16T12:20:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-16T12:25:00", + "amount" : 0, + "unit" : "g" + }, + { + "amount" : 0, + "date" : "2015-10-16T12:30:00", + "unit" : "g" + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T12:35:00" + }, + { + "date" : "2015-10-16T12:40:00", + "amount" : 0, + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-16T12:45:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T12:50:00", + "amount" : 0 + }, + { + "amount" : 0, + "date" : "2015-10-16T12:55:00", + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-16T13:00:00", + "amount" : 0 + }, + { + "date" : "2015-10-16T13:05:00", + "amount" : 0, + "unit" : "g" + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T13:10:00" + }, + { + "date" : "2015-10-16T13:15:00", + "amount" : 0, + "unit" : "g" + }, + { + "date" : "2015-10-16T13:20:00", + "amount" : 0, + "unit" : "g" + }, + { + "unit" : "g", + "date" : "2015-10-16T13:25:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T13:30:00", + "amount" : 0 + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T13:35:00" + }, + { + "unit" : "g", + "date" : "2015-10-16T13:40:00", + "amount" : 0 + }, + { + "date" : "2015-10-16T13:45:00", + "unit" : "g", + "amount" : 0 + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T13:50:00" + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-16T13:55:00" + }, + { + "date" : "2015-10-16T14:00:00", + "unit" : "g", + "amount" : 0 + }, + { + "date" : "2015-10-16T14:05:00", + "amount" : 0, + "unit" : "g" + }, + { + "amount" : 0, + "date" : "2015-10-16T14:10:00", + "unit" : "g" + }, + { + "date" : "2015-10-16T14:15:00", + "unit" : "g", + "amount" : 0 + }, + { + "date" : "2015-10-16T14:20:00", + "unit" : "g", + "amount" : 0 + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-16T14:25:00" + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T14:30:00" + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-16T14:35:00" + }, + { + "unit" : "g", + "date" : "2015-10-16T14:40:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T14:45:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T14:50:00", + "amount" : 0 + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T14:55:00" + }, + { + "unit" : "g", + "date" : "2015-10-16T15:00:00", + "amount" : 0 + }, + { + "amount" : 0, + "unit" : "g", + "date" : "2015-10-16T15:05:00" + }, + { + "amount" : 0, + "date" : "2015-10-16T15:10:00", + "unit" : "g" + }, + { + "amount" : 0, + "date" : "2015-10-16T15:15:00", + "unit" : "g" + }, + { + "amount" : 0, + "date" : "2015-10-16T15:20:00", + "unit" : "g" + }, + { + "unit" : "g", + "amount" : 0, + "date" : "2015-10-16T15:25:00" + }, + { + "unit" : "g", + "date" : "2015-10-16T15:30:00", + "amount" : 0 + }, + { + "unit" : "g", + "date" : "2015-10-16T15:35:00", + "amount" : 0 + } +] diff --git a/Tests/LoopAlgorithmTests/InsulinMathTests.swift b/Tests/LoopAlgorithmTests/InsulinMathTests.swift new file mode 100644 index 0000000..4564c79 --- /dev/null +++ b/Tests/LoopAlgorithmTests/InsulinMathTests.swift @@ -0,0 +1,1246 @@ +// +// InsulinMathTests.swift +// InsulinMathTests +// +// Created by Nathan Racklyeft on 1/27/16. +// Copyright © 2016 Nathan Racklyeft. All rights reserved. +// + +import XCTest +import HealthKit +@testable import LoopAlgorithm + +public typealias JSONDictionary = [String: Any] + +//extension DoseUnit { +// var unit: HKUnit { +// switch self { +// case .units: +// return .internationalUnit() +// case .unitsPerHour: +// return HKUnit(from: "IU/hr") +// } +// } +//} +// +//class InsulinMathTests: XCTestCase { +// +// var fixtureDateformatter: DateFormatter! +// +// private let fixtureTimeZone = TimeZone(secondsFromGMT: -0 * 60 * 60)! +// +// private let insulinType: InsulinType = .novolog +// +// private let model = ExponentialInsulinModel(actionDuration: TimeInterval(minutes: 360), peakActivityTime: TimeInterval(minutes: 75)) +// +// let insulinModelSettings = StaticInsulinModelProvider(ExponentialInsulinModelPreset.rapidActingAdult) +// let insulinModelDuration = ExponentialInsulinModelPreset.rapidActingAdult.effectDuration +// +// private func fixtureDate(_ input: String) -> Date { +// return fixtureDateformatter.date(from: input)! +// } +// +// func loadFixture(_ resourceName: String) -> T { +// let url = Bundle.module.url(forResource: resourceName, withExtension: "json", subdirectory: "Fixtures")! +// return try! JSONSerialization.jsonObject(with: Data(contentsOf: url), options: []) as! T +// } +// +// override func setUp() { +// fixtureDateformatter = DateFormatter.descriptionFormatter +// fixtureDateformatter.timeZone = fixtureTimeZone +// } +// +// private func printInsulinValues(_ insulinValues: [InsulinValue]) { +// print("\n\n") +// print(String(data: try! JSONSerialization.data( +// withJSONObject: insulinValues.map({ (value) -> [String: Any] in +// return [ +// "date": ISO8601DateFormatter.localTimeDate(timeZone: fixtureTimeZone).string(from: value.startDate), +// "value": value.value, +// "unit": "U" +// ] +// }), +// options: .prettyPrinted), encoding: .utf8)!) +// print("\n\n") +// } +// +// func loadDoseFixture(_ resourceName: String, insulinType: InsulinType? = .novolog) -> [FixtureInsulinDose] { +// let fixture: [JSONDictionary] = loadFixture(resourceName) +// let dateFormatter = ISO8601DateFormatter.localTimeDate(timeZone: fixtureTimeZone) +// +// return fixture.compactMap({ (rawValue: JSONDictionary) -> FixtureInsulinDose? in +// guard let unit = DoseUnit(rawValue: $0["unit"] as! String), +// let deliveryType = InsulinDeliveryType(fixtureValue: $0["type"] as! String) +// else { +// return nil +// } +// +// var dose = FixtureInsulinDose( +// type: type, +// startDate: dateFormatter.date(from: $0["start_at"] as! String)!, +// endDate: dateFormatter.date(from: $0["end_at"] as! String)!, +// value: $0["amount"] as! Double, +// unit: unit, +// deliveredUnits: $0["delivered"] as? Double, +// description: $0["description"] as? String, +// syncIdentifier: $0["raw"] as? String, +// insulinType: insulinType, +// automatic: $0["automatic"] as? Bool, +// manuallyEntered: $0["manuallyEntered"] as? Bool ?? false, +// isMutable: $0["isMutable"] as? Bool ?? false +// ) +// +// if let scheduled = $0["scheduled"] as? Double { +// dose.scheduledBasalRate = HKQuantity(unit: unit.unit, doubleValue: scheduled) +// } +// +// return dose +// }) +// } +// +// func loadInsulinValueFixture(_ resourceName: String) -> [InsulinValue] { +// let fixture: [JSONDictionary] = loadFixture(resourceName) +// let dateFormatter = ISO8601DateFormatter.localTimeDate(timeZone: fixtureTimeZone) +// +// return fixture.map { +// return InsulinValue(startDate: dateFormatter.date(from: $0["date"] as! String)!, value: $0["value"] as! Double) +// } +// } +// +// func loadGlucoseEffectFixture(_ resourceName: String) -> [GlucoseEffect] { +// let fixture: [JSONDictionary] = loadFixture(resourceName) +// let dateFormatter = ISO8601DateFormatter.localTimeDate(timeZone: fixtureTimeZone) +// +// return fixture.map { +// return GlucoseEffect(startDate: dateFormatter.date(from: $0["date"] as! String)!, quantity: HKQuantity(unit: HKUnit(from: $0["unit"] as! String), doubleValue:$0["amount"] as! Double)) +// } +// } +// +//// var insulinSensitivitySchedule: InsulinSensitivitySchedule { +//// return InsulinSensitivitySchedule(unit: HKUnit.milligramsPerDeciliter, dailyItems: [RepeatingScheduleValue(startTime: 0.0, value: 40.0)], timeZone: fixtureTimeZone)! +//// } +// +// func testDoseEntriesFromReservoirValues() { +// let input = loadReservoirFixture("reservoir_history_with_rewind_and_prime_input") +// let output = loadDoseFixture("reservoir_history_with_rewind_and_prime_output").reversed() +// +// let doses = input.doseEntries +// +// XCTAssertEqual(output.count, doses.count) +// +// for (expected, calculated) in zip(output, doses) { +// XCTAssertEqual(expected.startDate, calculated.startDate) +// XCTAssertEqual(expected.endDate, calculated.endDate) +// XCTAssertEqual(expected.value, calculated.value, accuracy: Double(Float.ulpOfOne)) +// XCTAssertEqual(expected.unit, calculated.unit) +// } +// } +// +// func testContinuousReservoirValues() { +// var input = loadReservoirFixture("reservoir_history_with_rewind_and_prime_input") +// let within = TimeInterval(minutes: 30) +// let dateFormatter = ISO8601DateFormatter.localTimeDate(timeZone: fixtureTimeZone) +// XCTAssertTrue(input.isContinuous(from: dateFormatter.date(from: "2016-01-30T16:40:00")!, to: dateFormatter.date(from: "2016-01-30T20:40:00")!, within: within)) +// +// // We don't assert whether it's "stale". +// XCTAssertTrue(input.isContinuous(from: dateFormatter.date(from: "2016-01-30T16:40:00")!, to: dateFormatter.date(from: "2016-01-30T22:40:00")!, within: within)) +// XCTAssertTrue(input.isContinuous(from: dateFormatter.date(from: "2016-01-30T16:40:00")!, to: Date(), within: within)) +// +// // The values must extend the startDate boundary +// XCTAssertFalse(input.isContinuous(from: dateFormatter.date(from: "2016-01-30T15:00:00")!, to: dateFormatter.date(from: "2016-01-30T20:40:00")!, within: within)) +// +// // (the boundary condition is GTE) +// XCTAssertTrue(input.isContinuous(from: dateFormatter.date(from: "2016-01-30T16:00:42")!, to: dateFormatter.date(from: "2016-01-30T20:40:00")!, within: within)) +// +// // Rises in reservoir volume taint the entire range +// XCTAssertFalse(input.isContinuous(from: dateFormatter.date(from: "2016-01-30T15:55:00")!, to: dateFormatter.date(from: "2016-01-30T20:40:00")!, within: within)) +// +// // Any values of 0 taint the entire range +// input.append(NewReservoirValue(startDate: dateFormatter.date(from: "2016-01-30T20:37:00")!, unitVolume: 0)) +// +// XCTAssertFalse(input.isContinuous(from: dateFormatter.date(from: "2016-01-30T16:40:00")!, to: dateFormatter.date(from: "2016-01-30T20:40:00")!, within: within)) +// +// // As long as the 0 is within the date interval bounds +// XCTAssertTrue(input.isContinuous(from: dateFormatter.date(from: "2016-01-30T16:40:00")!, to: dateFormatter.date(from: "2016-01-30T19:40:00")!, within: within)) +// } +// +// func testNonContinuousReservoirValues() { +// let input = loadReservoirFixture("reservoir_history_with_continuity_holes") +// +// let dateFormatter = ISO8601DateFormatter.localTimeDate(timeZone: fixtureTimeZone) +// XCTAssertTrue(input.isContinuous(from: dateFormatter.date(from: "2016-01-30T18:30:00")!, to: dateFormatter.date(from: "2016-01-30T20:40:00")!, within: .minutes(30))) +// +// XCTAssertFalse(input.isContinuous(from: dateFormatter.date(from: "2016-01-30T17:30:00")!, to: dateFormatter.date(from: "2016-01-30T20:40:00")!, within: .minutes(30))) +// } +// +// func testIOBFromSuspend() { +// let input = loadDoseFixture("suspend_dose") +// let reconciledOutput = loadDoseFixture("suspend_dose_reconciled") +// let normalizedOutput = loadDoseFixture("suspend_dose_reconciled_normalized") +// let iobOutput = loadInsulinValueFixture("suspend_dose_reconciled_normalized_iob") +// let basals = loadBasalRateScheduleFixture("basal") +// +// let reconciled = input.reconciled() +// +// XCTAssertEqual(reconciledOutput.count, reconciled.count) +// +// for (expected, calculated) in zip(reconciledOutput, reconciled) { +// XCTAssertEqual(expected.startDate, calculated.startDate) +// XCTAssertEqual(expected.endDate, calculated.endDate) +// XCTAssertEqual(expected.value, calculated.value) +// XCTAssertEqual(expected.unit, calculated.unit) +// } +// +// let normalized = reconciled.annotated(with: basals) +// +// XCTAssertEqual(normalizedOutput.count, normalized.count) +// +// for (expected, calculated) in zip(normalizedOutput, normalized) { +// XCTAssertEqual(expected.startDate, calculated.startDate) +// XCTAssertEqual(expected.endDate, calculated.endDate) +// XCTAssertEqual(expected.value, calculated.netBasalUnitsPerHour, accuracy: Double(Float.ulpOfOne)) +// } +// +// let iob = normalized.insulinOnBoard(insulinModelProvider: walshModelSettings, longestEffectDuration: walshModelDuration) +// +// XCTAssertEqual(iobOutput.count, iob.count) +// +// for (expected, calculated) in zip(iobOutput, iob) { +// XCTAssertEqual(expected.startDate, calculated.startDate) +// XCTAssertEqual(expected.value, calculated.value, accuracy: Double(Float.ulpOfOne)) +// } +// } +// +// func testIOBFromDoses() { +// let input = loadDoseFixture("normalized_doses", insulinType: .novolog) +// let output = loadInsulinValueFixture("iob_from_doses_output") +// +// measure { +// _ = input.insulinOnBoard(insulinModelProvider: walshModelSettings, longestEffectDuration: walshModelDuration) +// } +// +// let iob = input.insulinOnBoard(insulinModelProvider: walshModelSettings, longestEffectDuration: walshModelDuration) +// +// XCTAssertEqual(output.count, iob.count) +// +// for (expected, calculated) in zip(output, iob) { +// XCTAssertEqual(expected.startDate, calculated.startDate) +// XCTAssertEqual(expected.value, calculated.value, accuracy: 0.5) +// } +// } +// +// func testIOBFromNoDoses() { +// let input: [DoseEntry] = [] +// +// let iob = input.insulinOnBoard(insulinModelProvider: insulinModelSettings, longestEffectDuration: insulinModelDuration) +// +// XCTAssertEqual(0, iob.count) +// } +// +// func testInsulinOnBoardLimitsForExponentialModel() { +// let insulinModel = ExponentialInsulinModel(actionDuration: TimeInterval(minutes: 360), peakActivityTime: TimeInterval(minutes: 75), delay: TimeInterval(minutes: 0)) +// let childModel = ExponentialInsulinModel(actionDuration: TimeInterval(minutes: 360), peakActivityTime: TimeInterval(minutes: 65), delay: TimeInterval(minutes: 0)) +// +// XCTAssertEqual(1, insulinModel.percentEffectRemaining(at: .minutes(-1)), accuracy: 0.001) +// XCTAssertEqual(1, insulinModel.percentEffectRemaining(at: .minutes(0)), accuracy: 0.001) +// XCTAssertEqual(0, insulinModel.percentEffectRemaining(at: .minutes(360)), accuracy: 0.001) +// XCTAssertEqual(0, insulinModel.percentEffectRemaining(at: .minutes(361)), accuracy: 0.001) +// +// // Test random point +// XCTAssertEqual(0.5110493617156, insulinModel.percentEffectRemaining(at: .minutes(108)), accuracy: 0.001) +// +// // Test for child curve +// XCTAssertEqual(0.6002510111374046, childModel.percentEffectRemaining(at: .minutes(82)), accuracy: 0.001) +// +// } +// +// func testIOBFromDosesExponential() { +// let input = loadDoseFixture("normalized_doses", insulinType: .novolog) +// let output = loadInsulinValueFixture("iob_from_doses_exponential_output") +// +// measure { +// _ = input.insulinOnBoard(insulinModelProvider: insulinModelSettings, longestEffectDuration: insulinModelDuration) +// } +// +// let iob = input.insulinOnBoard(insulinModelProvider: insulinModelSettings, longestEffectDuration: insulinModelDuration) +// +// XCTAssertEqual(output.count, iob.count) +// +// for (expected, calculated) in zip(output, iob) { +// XCTAssertEqual(expected.startDate, calculated.startDate) +// XCTAssertEqual(expected.value, calculated.value, accuracy: 0.5) +// } +// } +// +// func testIOBFromBolusExponential() { +// let input = loadDoseFixture("bolus_dose", insulinType: .novolog) +// let output = loadInsulinValueFixture("iob_from_bolus_exponential_output") +// +// let iob = input.insulinOnBoard(insulinModelProvider: insulinModelSettings, longestEffectDuration: insulinModelDuration) +// +// XCTAssertEqual(output.count, iob.count) +// +// for (expected, calculated) in zip(output, iob) { +// XCTAssertEqual(expected.startDate, calculated.startDate) +// XCTAssertEqual(expected.value, calculated.value, accuracy: Double(Float.ulpOfOne)) +// } +// } +// +// +// func testIOBFromBolus() { +// for hours in [2, 3, 4, 5, 5.2, 6, 7] as [Double] { +// let actionDuration = TimeInterval(hours: hours) +// let model = WalshInsulinModel(actionDuration: actionDuration) +// let insulinModelProvider = StaticInsulinModelProvider( model) +// let input = loadDoseFixture("bolus_dose", insulinType: .novolog) +// let output = loadInsulinValueFixture("iob_from_bolus_\(Int(actionDuration.minutes))min_output") +// +// let iob = input.insulinOnBoard(insulinModelProvider: insulinModelProvider, longestEffectDuration: model.effectDuration) +// +// XCTAssertEqual(output.count, iob.count) +// +// for (expected, calculated) in zip(output, iob) { +// XCTAssertEqual(expected.startDate, calculated.startDate) +// XCTAssertEqual(expected.value, calculated.value, accuracy: Double(Float.ulpOfOne)) +// } +// } +// } +// +// func testIOBFromDosesWithDifferentInsulinCurves() { +// let formatter = DateFormatter.descriptionFormatter +// let f = { (input) in +// return formatter.date(from: input)! +// } +// let output = loadInsulinValueFixture("iob_from_multiple_curves_output") +// +// let doses = [ +// DoseEntry(type: .basal, startDate: f("2018-05-15 14:42:36 +0000"), endDate: f("2018-05-16 14:42:36 +0000"), value: 0.84999999999999998, unit: .unitsPerHour, syncIdentifier: "7b02646a070f120e2200", scheduledBasalRate: nil), +// DoseEntry(type: .bolus, startDate: f("2018-05-15 14:44:46 +0000"), endDate: f("2018-05-15 14:44:46 +0000"), value: 0.9, unit: .units, syncIdentifier: "01004a004a006d006e22354312", scheduledBasalRate: nil), +// DoseEntry(type: .tempBasal, startDate: f("2018-05-15 14:42:36 +0000"), endDate: f("2018-05-15 14:42:36 +0000"), value: 0.0, unit: .unitsPerHour, syncIdentifier: "1600646a074f12", scheduledBasalRate: nil), +// DoseEntry(type: .tempBasal, startDate: f("2018-05-15 14:32:51 +0000"), endDate: f("2018-05-15 15:02:51 +0000"), value: 1.8999999999999999, unit: .unitsPerHour, syncIdentifier: "16017360074f12", scheduledBasalRate: nil), +// DoseEntry(type: .bolus, startDate: f("2018-05-15 14:52:51 +0000"), endDate: f("2018-05-15 15:52:51 +0000"), value: 0.9, unit: .units, syncIdentifier: "01004a004a006d006e22354312", scheduledBasalRate: nil), +// ] +// +// let iobWithoutModel = doses.insulinOnBoard(insulinModelProvider: insulinModelSettings, longestEffectDuration: insulinModelDuration) +// +// let dosesWithModel = [ +// DoseEntry(type: .basal, startDate: f("2018-05-15 14:42:36 +0000"), endDate: f("2018-05-16 14:42:36 +0000"), value: 0.84999999999999998, unit: .unitsPerHour, syncIdentifier: "7b02646a070f120e2200", scheduledBasalRate: nil), +// DoseEntry(type: .bolus, startDate: f("2018-05-15 14:44:46 +0000"), endDate: f("2018-05-15 14:44:46 +0000"), value: 0.9, unit: .units, syncIdentifier: "01004a004a006d006e22354312", scheduledBasalRate: nil, insulinType: .fiasp), +// DoseEntry(type: .tempBasal, startDate: f("2018-05-15 14:42:36 +0000"), endDate: f("2018-05-15 14:42:36 +0000"), value: 0.0, unit: .unitsPerHour, syncIdentifier: "1600646a074f12", scheduledBasalRate: nil), +// DoseEntry(type: .tempBasal, startDate: f("2018-05-15 14:32:51 +0000"), endDate: f("2018-05-15 15:02:51 +0000"), value: 1.8999999999999999, unit: .unitsPerHour, syncIdentifier: "16017360074f12", scheduledBasalRate: nil), +// DoseEntry(type: .bolus, startDate: f("2018-05-15 14:52:51 +0000"), endDate: f("2018-05-15 15:52:51 +0000"), value: 0.9, unit: .units, syncIdentifier: "01004a004a006d006e22354312", scheduledBasalRate: nil, insulinType: .novolog), +// ] +// +// let insulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: ExponentialInsulinModelPreset.rapidActingChild) +// let iobWithModel = dosesWithModel.insulinOnBoard(insulinModelProvider: insulinModelProvider, longestEffectDuration: ExponentialInsulinModelPreset.rapidActingChild.effectDuration) +// +// XCTAssertEqual(iobWithoutModel.count, iobWithModel.count) +// +// for (expected, calculated) in zip(output, iobWithModel) { +// XCTAssertEqual(expected.startDate, calculated.startDate) +// XCTAssertEqual(expected.value, calculated.value, accuracy: Double(Float.ulpOfOne)) +// } +// +// } +// +// func testIOBFromReservoirDoses() { +// let input = loadDoseFixture("normalized_reservoir_history_output") +// let output = loadInsulinValueFixture("iob_from_reservoir_output") +// +// measure { +// _ = input.insulinOnBoard(insulinModelProvider: walshModelSettings, longestEffectDuration: walshModelDuration) +// } +// +// let iob = input.insulinOnBoard(insulinModelProvider: walshModelSettings, longestEffectDuration: walshModelDuration) +// +// XCTAssertEqual(output.count, iob.count) +// +// for (expected, calculated) in zip(output, iob) { +// XCTAssertEqual(expected.startDate, calculated.startDate) +// XCTAssertEqual(expected.value, calculated.value, accuracy: 0.4) +// } +// } +// +// func testNormalizeReservoirDoses() { +// let input = loadDoseFixture("reservoir_history_with_rewind_and_prime_output") +// let output = loadDoseFixture("normalized_reservoir_history_output") +// let basals = loadBasalRateScheduleFixture("basal") +// +// measure { +// _ = input.annotated(with: basals) +// } +// +// let doses = input.annotated(with: basals) +// +// XCTAssertEqual(output.count, doses.count) +// +// // Total delivery on split doses should add up to delivery from original doses +// XCTAssertEqual( +// input.map {$0.unitsInDeliverableIncrements}.reduce(0,+), +// doses.map {$0.unitsInDeliverableIncrements}.reduce(0,+), +// accuracy: Double(Float.ulpOfOne)) +// +// for (expected, calculated) in zip(output, doses) { +// XCTAssertEqual(expected.startDate, calculated.startDate) +// XCTAssertEqual(expected.endDate, calculated.endDate) +// XCTAssertEqual(expected.value, calculated.unitsPerHour, accuracy: Double(Float.ulpOfOne)) +// XCTAssertEqual(expected.scheduledBasalRate, calculated.scheduledBasalRate) +// } +// } +// +// func testNormalizeEdgeCaseDoses() { +// let input = loadDoseFixture("normalize_edge_case_doses_input") +// let output = loadDoseFixture("normalize_edge_case_doses_output") +// let basals = loadBasalRateScheduleFixture("basal") +// +// measure { +// _ = input.annotated(with: basals) +// } +// +// let doses = input.annotated(with: basals) +// +// XCTAssertEqual(output.count, doses.count) +// +// for (expected, calculated) in zip(output, doses) { +// XCTAssertEqual(expected.startDate, calculated.startDate) +// XCTAssertEqual(expected.endDate, calculated.endDate) +// XCTAssertEqual(expected.value, calculated.unit == .units ? calculated.netBasalUnits : calculated.netBasalUnitsPerHour) +// XCTAssertEqual(expected.unit, calculated.unit) +// } +// } +// +// func testNormalizeEdgeCaseDosesMutable() { +// let input = loadDoseFixture("normalize_edge_case_doses_mutable_input") +// let output = loadDoseFixture("normalize_edge_case_doses_mutable_output") +// let basals = loadBasalRateScheduleFixture("basal") +// +// measure { +// _ = input.annotated(with: basals) +// } +// +// let doses = input.annotated(with: basals) +// +// XCTAssertEqual(output.count, doses.count) +// +// for (expected, calculated) in zip(output, doses) { +// XCTAssertEqual(expected.startDate, calculated.startDate) +// XCTAssertEqual(expected.endDate, calculated.endDate) +// XCTAssertEqual(expected.value, calculated.unit == .units ? calculated.netBasalUnits : calculated.netBasalUnitsPerHour) +// XCTAssertEqual(expected.unit, calculated.unit) +// XCTAssertEqual(expected.isMutable, calculated.isMutable) +// XCTAssertEqual(expected.deliveredUnits, calculated.deliveredUnits) +// } +// } +// +// func testReconcileTempBasals() { +// // Fixture contains numerous overlapping temp basals, as well as a Suspend event interleaved with a temp basal +// let input = loadDoseFixture("reconcile_history_input") +// let output = loadDoseFixture("reconcile_history_output").sorted { $0.startDate < $1.startDate } +// +// let doses = input.reconciled().sorted { $0.startDate < $1.startDate } +// +// XCTAssertEqual(output.count, doses.count) +// +// for (expected, calculated) in zip(output, doses) { +// XCTAssertEqual(expected.startDate, calculated.startDate) +// XCTAssertEqual(expected.endDate, calculated.endDate) +// XCTAssertEqual(expected.value, calculated.value) +// XCTAssertEqual(expected.unit, calculated.unit) +// XCTAssertEqual(expected.syncIdentifier, calculated.syncIdentifier) +// XCTAssertEqual(expected.deliveredUnits, calculated.deliveredUnits) +// } +// } +// +// func testReconcileResumeBeforeRewind() { +// let input = loadDoseFixture("reconcile_resume_before_rewind_input") +// let output = loadDoseFixture("reconcile_resume_before_rewind_output") +// +// let doses = input.reconciled() +// +// XCTAssertEqual(output.count, doses.count) +// +// for (expected, calculated) in zip(output, doses) { +// XCTAssertEqual(expected.startDate, calculated.startDate) +// XCTAssertEqual(expected.endDate, calculated.endDate) +// XCTAssertEqual(expected.value, calculated.value) +// XCTAssertEqual(expected.unit, calculated.unit) +// XCTAssertEqual(expected.syncIdentifier, calculated.syncIdentifier) +// XCTAssertEqual(expected.deliveredUnits, calculated.deliveredUnits) +// } +// } +// +// func testGlucoseEffectFromBolus() { +// let input = loadDoseFixture("bolus_dose") +// let output = loadGlucoseEffectFixture("effect_from_bolus_output") +// let insulinSensitivitySchedule = self.insulinSensitivitySchedule +// +// measure { +// _ = input.glucoseEffects(insulinModelProvider: walshModelSettings, longestEffectDuration: walshModelDuration, insulinSensitivity: insulinSensitivitySchedule) +// } +// +// let effects = input.glucoseEffects(insulinModelProvider: walshModelSettings, longestEffectDuration: walshModelDuration, insulinSensitivity: insulinSensitivitySchedule) +// +// XCTAssertEqual(Float(output.count), Float(effects.count), accuracy: 1.0) +// +// for (expected, calculated) in zip(output, effects) { +// XCTAssertEqual(expected.startDate, calculated.startDate) +// XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: 1.0) +// } +// } +// +// func testGlucoseEffectFromShortTempBasal() { +// let input = loadDoseFixture("short_basal_dose") +// let output = loadGlucoseEffectFixture("effect_from_bolus_output") +// let insulinSensitivitySchedule = self.insulinSensitivitySchedule +// +// measure { +// _ = input.glucoseEffects(insulinModelProvider: walshModelSettings, longestEffectDuration: walshModelDuration, insulinSensitivity: insulinSensitivitySchedule) +// } +// +// let effects = input.glucoseEffects(insulinModelProvider: walshModelSettings, longestEffectDuration: walshModelDuration, insulinSensitivity: insulinSensitivitySchedule) +// +// XCTAssertEqual(output.count, effects.count) +// +// for (expected, calculated) in zip(output, effects) { +// XCTAssertEqual(expected.startDate, calculated.startDate) +// XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: Double(Float.ulpOfOne)) +// } +// } +// +// func testGlucoseEffectFromTempBasal() { +// let input = loadDoseFixture("basal_dose") +// let output = loadGlucoseEffectFixture("effect_from_basal_output") +// let insulinSensitivitySchedule = self.insulinSensitivitySchedule +// +// measure { +// _ = input.glucoseEffects(insulinModelProvider: walshModelSettings, longestEffectDuration: walshModelDuration, insulinSensitivity: insulinSensitivitySchedule) +// } +// +// let effects = input.glucoseEffects(insulinModelProvider: walshModelSettings, longestEffectDuration: walshModelDuration, insulinSensitivity: insulinSensitivitySchedule) +// +// XCTAssertEqual(output.count, effects.count) +// +// for (expected, calculated) in zip(output, effects) { +// XCTAssertEqual(expected.startDate, calculated.startDate) +// XCTAssertEqual(expected.quantity.doubleValue(for: HKUnit.milligramsPerDeciliter), calculated.quantity.doubleValue(for: HKUnit.milligramsPerDeciliter), accuracy: 1.0, String(describing: expected.startDate)) +// } +// } +// +// func testGlucoseEffectFromTempBasalExponential() { +// let input = loadDoseFixture("basal_dose_with_delivered", insulinType: .novolog) +// let output = loadGlucoseEffectFixture("effect_from_basal_output_exponential") +// +// let effects = input.glucoseEffects(insulinModelProvider: insulinModelSettings, longestEffectDuration: insulinModelDuration, insulinSensitivity: insulinSensitivitySchedule) +// +// XCTAssertEqual(output.count, effects.count) +// +// for (expected, calculated) in zip(output, effects) { +// XCTAssertEqual(expected.startDate, calculated.startDate) +// XCTAssertEqual(expected.quantity.doubleValue(for: HKUnit.milligramsPerDeciliter), calculated.quantity.doubleValue(for: HKUnit.milligramsPerDeciliter), accuracy: 1.0, String(describing: expected.startDate)) +// print(expected.quantity.doubleValue(for: HKUnit.milligramsPerDeciliter), calculated.quantity.doubleValue(for: HKUnit.milligramsPerDeciliter)) +// } +// } +// +// func testGlucoseEffectFromHistory() { +// let input = loadDoseFixture("normalized_doses") +// let output = loadGlucoseEffectFixture("effect_from_history_output") +// let insulinSensitivitySchedule = self.insulinSensitivitySchedule +// +// measure { +// _ = input.glucoseEffects(insulinModelProvider: walshModelSettings, longestEffectDuration: walshModelDuration, insulinSensitivity: insulinSensitivitySchedule) +// } +// +// let effects = input.glucoseEffects(insulinModelProvider: walshModelSettings, longestEffectDuration: walshModelDuration, insulinSensitivity: insulinSensitivitySchedule) +// +// XCTAssertEqual(output.count, effects.count) +// +// for (expected, calculated) in zip(output, effects) { +// XCTAssertEqual(expected.startDate, calculated.startDate) +// XCTAssertEqual(expected.quantity.doubleValue(for: HKUnit.milligramsPerDeciliter), calculated.quantity.doubleValue(for: HKUnit.milligramsPerDeciliter), accuracy: 3.0) +// } +// } +// +// func testGlucoseEffectFromNoDoses() { +// let input: [DoseEntry] = [] +// let insulinSensitivitySchedule = self.insulinSensitivitySchedule +// +// let effects = input.glucoseEffects(insulinModelProvider: insulinModelSettings, longestEffectDuration: insulinModelDuration, insulinSensitivity: insulinSensitivitySchedule) +// +// XCTAssertEqual(0, effects.count) +// } +// +// func testTotalDelivery() { +// let input = loadDoseFixture("normalize_edge_case_doses_input") +// let output = input.totalDelivery +// +// XCTAssertEqual(18.8, output, accuracy: 0.01) +// } +// +// func testTrimContinuingDoses() { +// let dateFormatter = ISO8601DateFormatter.localTimeDate(timeZone: fixtureTimeZone) +// let input = loadDoseFixture("normalized_doses").reversed() +// +// // Last temp ends at 2015-10-15T22:29:50 +// let endDate = dateFormatter.date(from: "2015-10-15T22:25:50")! +// let trimmed = input.map { $0.trimmed(to: endDate) } +// +// print(input, "\n\n\n") +// print(trimmed) +// +// XCTAssertEqual(endDate, trimmed.last!.endDate) +// XCTAssertEqual(input.count, trimmed.count) +// } +// +// func testTrimmedMaintainsMutability() { +// let dateFormatter = ISO8601DateFormatter.localTimeDate(timeZone: fixtureTimeZone) +// let input = loadDoseFixture("normalized_doses").reversed() +// +// // Last temp ends at 2015-10-15T22:29:50 +// let endDate = dateFormatter.date(from: "2015-10-15T22:25:50")! +// let trimmed = input.map { $0.trimmed(to: endDate) } +// +// XCTAssertTrue(trimmed.last!.isMutable) +// } +// +// func testDosesOverlayBasalProfile() { +// let dateFormatter = ISO8601DateFormatter.localTimeDate(timeZone: fixtureTimeZone) +// let input = loadDoseFixture("reconcile_history_output").sorted { $0.startDate < $1.startDate } +// let output = loadDoseFixture("doses_overlay_basal_profile_output") +// let basals = loadBasalRateScheduleFixture("basal") +// +// let doses = input.annotated(with: basals).overlayBasalSchedule( +// basals, +// // A start date before the first entry should generate a basal +// startingAt: dateFormatter.date(from: "2016-02-15T14:01:04")!, +// endingAt: Date(), +// insertingBasalEntries: true +// ) +// +// XCTAssertEqual(output.count, doses.count) +// +// XCTAssertEqual(doses.first?.startDate, dateFormatter.date(from: "2016-02-15T14:01:04")!) +// +// for (expected, calculated) in zip(output, doses) { +// XCTAssertEqual(expected.startDate, calculated.startDate) +// XCTAssertEqual(expected.endDate, calculated.endDate) +// XCTAssertEqual(expected.value, calculated.value) +// XCTAssertEqual(expected.unit, calculated.unit) +// +// if let syncID = expected.syncIdentifier { +// XCTAssertEqual(syncID, calculated.syncIdentifier!) +// } +// } +// +// // Test trimming end +// let dosesTrimmedEnd = input[0.. DoseEntry in +// let startDate = self.fixtureDate("2018-07-16 03:49:00 +0000") +// let endDate = startDate.addingTimeInterval(TimeInterval(minutes: 5)) +// +// let tempBasalRate = 1.0 +// +// return DoseEntry( +// type: .tempBasal, +// startDate: startDate, +// endDate: endDate, +// value: tempBasalRate, +// unit: .unitsPerHour, +// deliveredUnits: deliveredUnits) +// } +// +// XCTAssertEqual(0.1, makeDose(nil).unitsInDeliverableIncrements, accuracy: .ulpOfOne) +// XCTAssertEqual(0.05, makeDose(0.05).unitsInDeliverableIncrements, accuracy: .ulpOfOne) +// } +// +// func testDoseEntryAnnotateShouldSplitDosesProportionally() { +// let startDate = self.fixtureDate("2018-07-16 11:59:00 +0000") +// let endDate = startDate.addingTimeInterval(TimeInterval(minutes: 5)) +// +// let tempBasalRate = 1.0 +// +// let dose = DoseEntry( +// type: .tempBasal, +// startDate: startDate, +// endDate: endDate, +// value: tempBasalRate, +// unit: .unitsPerHour, +// deliveredUnits: 0.1 +// ) +// +// let delivery = dose.unitsInDeliverableIncrements +// +// let basals = loadBasalRateScheduleFixture("basal") +// +// let splitDoses = [dose].annotated(with: basals) +// +// XCTAssertEqual(2, splitDoses.count) +// +// // A 5 minute dose starting one minute before midnight, split at midnight, means split should be 1/5, 4/5 +// XCTAssertEqual(delivery * 1.0/5.0, splitDoses[0].unitsInDeliverableIncrements, accuracy: .ulpOfOne) +// XCTAssertEqual(delivery * 4.0/5.0, splitDoses[1].unitsInDeliverableIncrements, accuracy: .ulpOfOne) +// } +// +// func testDoseEntryWithoutDeliveredUnitsShouldSplitDosesProportionally() { +// let startDate = self.fixtureDate("2018-07-16 11:59:00 +0000") +// let endDate = startDate.addingTimeInterval(TimeInterval(minutes: 5)) +// +// let tempBasalRate = 1.0 +// +// let dose = DoseEntry( +// type: .tempBasal, +// startDate: startDate, +// endDate: endDate, +// value: tempBasalRate, +// unit: .unitsPerHour, +// deliveredUnits: 0.05 +// ) +// +// let delivery = dose.unitsInDeliverableIncrements +// +// let basals = loadBasalRateScheduleFixture("basal") +// +// let splitDoses = [dose].annotated(with: basals) +// +// XCTAssertEqual(2, splitDoses.count) +// +// // A 5 minute dose starting one minute before midnight, split at midnight, means split should be 1/5, 4/5 +// XCTAssertEqual(delivery * 1.0/5.0, splitDoses[0].unitsInDeliverableIncrements, accuracy: .ulpOfOne) +// XCTAssertEqual(delivery * 4.0/5.0, splitDoses[1].unitsInDeliverableIncrements, accuracy: .ulpOfOne) +// } +// +//} From 97d594670988562e8d0f8fceb41d85287043b49c Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 5 Feb 2024 19:54:10 -0600 Subject: [PATCH 12/26] Adding more insulin math tests --- .../LoopAlgorithm/Insulin/InsulinMath.swift | 17 +- .../Insulin/InsulinModelProvider.swift | 2 +- .../Insulin/RelativeDelivery.swift | 8 +- Tests/LoopAlgorithmTests/CarbMathTests.swift | 2 + .../Fixtures/effect_from_basal_output.json | 437 ++++++ .../Fixtures/effect_from_bolus_output.json | 382 +++++ .../LoopAlgorithmTests/InsulinMathTests.swift | 1389 ++--------------- 7 files changed, 992 insertions(+), 1245 deletions(-) create mode 100644 Tests/LoopAlgorithmTests/Fixtures/effect_from_basal_output.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/effect_from_bolus_output.json diff --git a/Sources/LoopAlgorithm/Insulin/InsulinMath.swift b/Sources/LoopAlgorithm/Insulin/InsulinMath.swift index fd0b77b..33d546b 100644 --- a/Sources/LoopAlgorithm/Insulin/InsulinMath.swift +++ b/Sources/LoopAlgorithm/Insulin/InsulinMath.swift @@ -122,10 +122,6 @@ extension InsulinDose { preconditionFailure("basalDeliveryTotal called on dose that is not a temp basal!") } - guard duration > .ulpOfOne else { - preconditionFailure("basalDeliveryTotal called on dose with no duration!") - } - var doses: [BasalRelativeDose] = [] for (index, basalItem) in basalHistory.enumerated() { @@ -148,11 +144,18 @@ extension InsulinDose { let segmentEndDate = max(startDate, min(endDate, self.endDate)) let segmentDuration = segmentEndDate.timeIntervalSince(segmentStartDate) + let segmentVolume: Double + if duration > 0 { + segmentVolume = volume * (segmentDuration / duration) + } else { + segmentVolume = 0 + } + let annotatedDose = BasalRelativeDose( - type: .tempBasal(scheduledRate: basalItem.value), + type: .basal(scheduledRate: basalItem.value), startDate: segmentStartDate, endDate: segmentEndDate, - volume: volume * (segmentDuration / duration) + volume: segmentVolume ) doses.append(annotatedDose) @@ -220,7 +223,7 @@ extension Collection where Element == BasalRelativeDose { - returns: A sequence of insulin amount remaining */ - public func insulinOnBoard( + public func insulinOnBoardTimeline( insulinModelProvider: InsulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: nil), longestEffectDuration: TimeInterval = InsulinMath.defaultInsulinActivityDuration, from start: Date? = nil, diff --git a/Sources/LoopAlgorithm/Insulin/InsulinModelProvider.swift b/Sources/LoopAlgorithm/Insulin/InsulinModelProvider.swift index ebffca2..2dfc294 100644 --- a/Sources/LoopAlgorithm/Insulin/InsulinModelProvider.swift +++ b/Sources/LoopAlgorithm/Insulin/InsulinModelProvider.swift @@ -11,7 +11,7 @@ public protocol InsulinModelProvider { public struct PresetInsulinModelProvider: InsulinModelProvider { var defaultRapidActingModel: InsulinModel? - public init(defaultRapidActingModel: InsulinModel?) { + public init(defaultRapidActingModel: InsulinModel? = nil) { self.defaultRapidActingModel = defaultRapidActingModel } diff --git a/Sources/LoopAlgorithm/Insulin/RelativeDelivery.swift b/Sources/LoopAlgorithm/Insulin/RelativeDelivery.swift index abc7e84..9515b58 100644 --- a/Sources/LoopAlgorithm/Insulin/RelativeDelivery.swift +++ b/Sources/LoopAlgorithm/Insulin/RelativeDelivery.swift @@ -8,9 +8,9 @@ import Foundation -public enum BasalRelativeDoseType { +public enum BasalRelativeDoseType: Equatable { case bolus - case tempBasal(scheduledRate: Double) + case basal(scheduledRate: Double) } public struct BasalRelativeDose: TimelineValue { @@ -20,7 +20,7 @@ public struct BasalRelativeDose: TimelineValue { public var volume: Double public var insulinType: InsulinType? - var duration: TimeInterval { + public var duration: TimeInterval { return endDate.timeIntervalSince(startDate) } @@ -37,7 +37,7 @@ extension BasalRelativeDose { /// The number of units delivered, net the basal rate scheduled during that time, which can be used to compute insulin on-board and glucose effects public var netBasalUnits: Double { - if case .tempBasal(let scheduledRate) = type { + if case .basal(let scheduledRate) = type { guard duration.hours > 0 else { return 0 } diff --git a/Tests/LoopAlgorithmTests/CarbMathTests.swift b/Tests/LoopAlgorithmTests/CarbMathTests.swift index b66c86b..d692be8 100644 --- a/Tests/LoopAlgorithmTests/CarbMathTests.swift +++ b/Tests/LoopAlgorithmTests/CarbMathTests.swift @@ -10,6 +10,8 @@ import XCTest import HealthKit @testable import LoopAlgorithm +public typealias JSONDictionary = [String: Any] + class CarbMathTests: XCTestCase { public func loadFixture(_ resourceName: String) -> T { diff --git a/Tests/LoopAlgorithmTests/Fixtures/effect_from_basal_output.json b/Tests/LoopAlgorithmTests/Fixtures/effect_from_basal_output.json new file mode 100644 index 0000000..bb1a62d --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/effect_from_basal_output.json @@ -0,0 +1,437 @@ +[ + { + "amount" : 0, + "date" : "2015-07-13T12:00:00", + "unit" : "mg/dL" + }, + { + "amount" : 0, + "date" : "2015-07-13T12:05:00", + "unit" : "mg/dL" + }, + { + "amount" : 0, + "date" : "2015-07-13T12:10:00", + "unit" : "mg/dL" + }, + { + "amount" : -0.0080330114162826156, + "date" : "2015-07-13T12:15:00", + "unit" : "mg/dL" + }, + { + "amount" : -0.038849033428092566, + "date" : "2015-07-13T12:20:00", + "unit" : "mg/dL" + }, + { + "amount" : -0.10535384721456284, + "date" : "2015-07-13T12:25:00", + "unit" : "mg/dL" + }, + { + "amount" : -0.2187716638654017, + "date" : "2015-07-13T12:30:00", + "unit" : "mg/dL" + }, + { + "amount" : -0.38879547078199039, + "date" : "2015-07-13T12:35:00", + "unit" : "mg/dL" + }, + { + "amount" : -0.62372592753840428, + "date" : "2015-07-13T12:40:00", + "unit" : "mg/dL" + }, + { + "amount" : -0.93059960668140429, + "date" : "2015-07-13T12:45:00", + "unit" : "mg/dL" + }, + { + "amount" : -1.3153073229267587, + "date" : "2015-07-13T12:50:00", + "unit" : "mg/dL" + }, + { + "amount" : -1.7827032454462588, + "date" : "2015-07-13T12:55:00", + "unit" : "mg/dL" + }, + { + "amount" : -2.3367054422379145, + "date" : "2015-07-13T13:00:00", + "unit" : "mg/dL" + }, + { + "amount" : -2.9803884627450197, + "date" : "2015-07-13T13:05:00", + "unit" : "mg/dL" + }, + { + "amount" : -3.7160685247632168, + "date" : "2015-07-13T13:10:00", + "unit" : "mg/dL" + }, + { + "amount" : -4.537348822667461, + "date" : "2015-07-13T13:15:00", + "unit" : "mg/dL" + }, + { + "amount" : -5.430507496682818, + "date" : "2015-07-13T13:20:00", + "unit" : "mg/dL" + }, + { + "amount" : -6.3831248705024457, + "date" : "2015-07-13T13:25:00", + "unit" : "mg/dL" + }, + { + "amount" : -7.383981350855815, + "date" : "2015-07-13T13:30:00", + "unit" : "mg/dL" + }, + { + "amount" : -8.4229625446901171, + "date" : "2015-07-13T13:35:00", + "unit" : "mg/dL" + }, + { + "amount" : -9.490971116416274, + "date" : "2015-07-13T13:40:00", + "unit" : "mg/dL" + }, + { + "amount" : -10.579844937837562, + "date" : "2015-07-13T13:45:00", + "unit" : "mg/dL" + }, + { + "amount" : -11.682281111704016, + "date" : "2015-07-13T13:50:00", + "unit" : "mg/dL" + }, + { + "amount" : -12.791765476429436, + "date" : "2015-07-13T13:55:00", + "unit" : "mg/dL" + }, + { + "amount" : -13.902507224472615, + "date" : "2015-07-13T14:00:00", + "unit" : "mg/dL" + }, + { + "amount" : -15.009378290317489, + "date" : "2015-07-13T14:05:00", + "unit" : "mg/dL" + }, + { + "amount" : -16.107857185980087, + "date" : "2015-07-13T14:10:00", + "unit" : "mg/dL" + }, + { + "amount" : -17.193976982608607, + "date" : "2015-07-13T14:15:00", + "unit" : "mg/dL" + }, + { + "amount" : -18.264277156108786, + "date" : "2015-07-13T14:20:00", + "unit" : "mg/dL" + }, + { + "amount" : -19.315759032895308, + "date" : "2015-07-13T14:25:00", + "unit" : "mg/dL" + }, + { + "amount" : -20.345844588913721, + "date" : "2015-07-13T14:30:00", + "unit" : "mg/dL" + }, + { + "amount" : -21.352338371063585, + "date" : "2015-07-13T14:35:00", + "unit" : "mg/dL" + }, + { + "amount" : -22.333392325146022, + "date" : "2015-07-13T14:40:00", + "unit" : "mg/dL" + }, + { + "amount" : -23.287473328517439, + "date" : "2015-07-13T14:45:00", + "unit" : "mg/dL" + }, + { + "amount" : -24.21333323881225, + "date" : "2015-07-13T14:50:00", + "unit" : "mg/dL" + }, + { + "amount" : -25.109981282454111, + "date" : "2015-07-13T14:55:00", + "unit" : "mg/dL" + }, + { + "amount" : -25.976658618257098, + "date" : "2015-07-13T15:00:00", + "unit" : "mg/dL" + }, + { + "amount" : -26.812814922273176, + "date" : "2015-07-13T15:05:00", + "unit" : "mg/dL" + }, + { + "amount" : -27.618086850213118, + "date" : "2015-07-13T15:10:00", + "unit" : "mg/dL" + }, + { + "amount" : -28.392278243297827, + "date" : "2015-07-13T15:15:00", + "unit" : "mg/dL" + }, + { + "amount" : -29.135341952323728, + "date" : "2015-07-13T15:20:00", + "unit" : "mg/dL" + }, + { + "amount" : -29.847363163086847, + "date" : "2015-07-13T15:25:00", + "unit" : "mg/dL" + }, + { + "amount" : -30.528544114140047, + "date" : "2015-07-13T15:30:00", + "unit" : "mg/dL" + }, + { + "amount" : -31.179190105188912, + "date" : "2015-07-13T15:35:00", + "unit" : "mg/dL" + }, + { + "amount" : -31.799696701294931, + "date" : "2015-07-13T15:40:00", + "unit" : "mg/dL" + }, + { + "amount" : -32.39053804447812, + "date" : "2015-07-13T15:45:00", + "unit" : "mg/dL" + }, + { + "amount" : -32.952256190323006, + "date" : "2015-07-13T15:50:00", + "unit" : "mg/dL" + }, + { + "amount" : -33.485451392816486, + "date" : "2015-07-13T15:55:00", + "unit" : "mg/dL" + }, + { + "amount" : -33.990773265907926, + "date" : "2015-07-13T16:00:00", + "unit" : "mg/dL" + }, + { + "amount" : -34.46891275520359, + "date" : "2015-07-13T16:05:00", + "unit" : "mg/dL" + }, + { + "amount" : -34.920594857809405, + "date" : "2015-07-13T16:10:00", + "unit" : "mg/dL" + }, + { + "amount" : -35.346572032639322, + "date" : "2015-07-13T16:15:00", + "unit" : "mg/dL" + }, + { + "amount" : -35.747618247528401, + "date" : "2015-07-13T16:20:00", + "unit" : "mg/dL" + }, + { + "amount" : -36.124523613248925, + "date" : "2015-07-13T16:25:00", + "unit" : "mg/dL" + }, + { + "amount" : -36.478089558039898, + "date" : "2015-07-13T16:30:00", + "unit" : "mg/dL" + }, + { + "amount" : -36.809124499541682, + "date" : "2015-07-13T16:35:00", + "unit" : "mg/dL" + }, + { + "amount" : -37.118439974091196, + "date" : "2015-07-13T16:40:00", + "unit" : "mg/dL" + }, + { + "amount" : -37.406847186195435, + "date" : "2015-07-13T16:45:00", + "unit" : "mg/dL" + }, + { + "amount" : -37.675153943670864, + "date" : "2015-07-13T16:50:00", + "unit" : "mg/dL" + }, + { + "amount" : -37.924161946430267, + "date" : "2015-07-13T16:55:00", + "unit" : "mg/dL" + }, + { + "amount" : -38.154664399224039, + "date" : "2015-07-13T17:00:00", + "unit" : "mg/dL" + }, + { + "amount" : -38.367443920812725, + "date" : "2015-07-13T17:05:00", + "unit" : "mg/dL" + }, + { + "amount" : -38.563270724071351, + "date" : "2015-07-13T17:10:00", + "unit" : "mg/dL" + }, + { + "amount" : -38.742901043412431, + "date" : "2015-07-13T17:15:00", + "unit" : "mg/dL" + }, + { + "amount" : -38.907075787672774, + "date" : "2015-07-13T17:20:00", + "unit" : "mg/dL" + }, + { + "amount" : -39.056519398247971, + "date" : "2015-07-13T17:25:00", + "unit" : "mg/dL" + }, + { + "amount" : -39.191938893784517, + "date" : "2015-07-13T17:30:00", + "unit" : "mg/dL" + }, + { + "amount" : -39.314023084160553, + "date" : "2015-07-13T17:35:00", + "unit" : "mg/dL" + }, + { + "amount" : -39.423441937809876, + "date" : "2015-07-13T17:40:00", + "unit" : "mg/dL" + }, + { + "amount" : -39.520846087674585, + "date" : "2015-07-13T17:45:00", + "unit" : "mg/dL" + }, + { + "amount" : -39.606866462217795, + "date" : "2015-07-13T17:50:00", + "unit" : "mg/dL" + }, + { + "amount" : -39.682114028992764, + "date" : "2015-07-13T17:55:00", + "unit" : "mg/dL" + }, + { + "amount" : -39.747179639255556, + "date" : "2015-07-13T18:00:00", + "unit" : "mg/dL" + }, + { + "amount" : -39.802633963028484, + "date" : "2015-07-13T18:05:00", + "unit" : "mg/dL" + }, + { + "amount" : -39.849027504876638, + "date" : "2015-07-13T18:10:00", + "unit" : "mg/dL" + }, + { + "amount" : -39.887128845005222, + "date" : "2015-07-13T18:15:00", + "unit" : "mg/dL" + }, + { + "amount" : -39.917902640635887, + "date" : "2015-07-13T18:20:00", + "unit" : "mg/dL" + }, + { + "amount" : -39.94226172251777, + "date" : "2015-07-13T18:25:00", + "unit" : "mg/dL" + }, + { + "amount" : -39.961068811447753, + "date" : "2015-07-13T18:30:00", + "unit" : "mg/dL" + }, + { + "amount" : -39.975138238953662, + "date" : "2015-07-13T18:35:00", + "unit" : "mg/dL" + }, + { + "amount" : -39.985237665417124, + "date" : "2015-07-13T18:40:00", + "unit" : "mg/dL" + }, + { + "amount" : -39.992089789662622, + "date" : "2015-07-13T18:45:00", + "unit" : "mg/dL" + }, + { + "amount" : -39.996374044725805, + "date" : "2015-07-13T18:50:00", + "unit" : "mg/dL" + }, + { + "amount" : -39.998728275144508, + "date" : "2015-07-13T18:55:00", + "unit" : "mg/dL" + }, + { + "amount" : -39.999750391692004, + "date" : "2015-07-13T19:00:00", + "unit" : "mg/dL" + }, + { + "amount" : -40, + "date" : "2015-07-13T19:05:00", + "unit" : "mg/dL" + }, + { + "amount" : -40, + "date" : "2015-07-13T19:10:00", + "unit" : "mg/dL" + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/effect_from_bolus_output.json b/Tests/LoopAlgorithmTests/Fixtures/effect_from_bolus_output.json new file mode 100644 index 0000000..92c9638 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/effect_from_bolus_output.json @@ -0,0 +1,382 @@ +[ + { + "amount" : 0, + "date" : "2015-07-13T12:00:00", + "unit" : "mg/dL" + }, + { + "amount" : 0, + "date" : "2015-07-13T12:05:00", + "unit" : "mg/dL" + }, + { + "amount" : 0, + "date" : "2015-07-13T12:10:00", + "unit" : "mg/dL" + }, + { + "amount" : -0.03358203815813976, + "date" : "2015-07-13T12:15:00", + "unit" : "mg/dL" + }, + { + "amount" : -0.30906641125849843, + "date" : "2015-07-13T12:20:00", + "unit" : "mg/dL" + }, + { + "amount" : -0.83384755295929702, + "date" : "2015-07-13T12:25:00", + "unit" : "mg/dL" + }, + { + "amount" : -1.5761536883151539, + "date" : "2015-07-13T12:30:00", + "unit" : "mg/dL" + }, + { + "amount" : -2.50703318144335, + "date" : "2015-07-13T12:35:00", + "unit" : "mg/dL" + }, + { + "amount" : -3.60014053836333, + "date" : "2015-07-13T12:40:00", + "unit" : "mg/dL" + }, + { + "amount" : -4.8315372431427939, + "date" : "2015-07-13T12:45:00", + "unit" : "mg/dL" + }, + { + "amount" : -6.1795064587701942, + "date" : "2015-07-13T12:50:00", + "unit" : "mg/dL" + }, + { + "amount" : -7.6243806847808315, + "date" : "2015-07-13T12:55:00", + "unit" : "mg/dL" + }, + { + "amount" : -9.1483815205968799, + "date" : "2015-07-13T13:00:00", + "unit" : "mg/dL" + }, + { + "amount" : -10.735470737021346, + "date" : "2015-07-13T13:05:00", + "unit" : "mg/dL" + }, + { + "amount" : -12.371211908552288, + "date" : "2015-07-13T13:10:00", + "unit" : "mg/dL" + }, + { + "amount" : -14.042641906353557, + "date" : "2015-07-13T13:15:00", + "unit" : "mg/dL" + }, + { + "amount" : -15.738151596009118, + "date" : "2015-07-13T13:20:00", + "unit" : "mg/dL" + }, + { + "amount" : -17.447375125774755, + "date" : "2015-07-13T13:25:00", + "unit" : "mg/dL" + }, + { + "amount" : -19.161087230080394, + "date" : "2015-07-13T13:30:00", + "unit" : "mg/dL" + }, + { + "amount" : -20.871108009684235, + "date" : "2015-07-13T13:35:00", + "unit" : "mg/dL" + }, + { + "amount" : -22.57021468427499, + "date" : "2015-07-13T13:40:00", + "unit" : "mg/dL" + }, + { + "amount" : -24.252059845598563, + "date" : "2015-07-13T13:45:00", + "unit" : "mg/dL" + }, + { + "amount" : -25.911095769475409, + "date" : "2015-07-13T13:50:00", + "unit" : "mg/dL" + }, + { + "amount" : -27.542504373493454, + "date" : "2015-07-13T13:55:00", + "unit" : "mg/dL" + }, + { + "amount" : -29.142132433822358, + "date" : "2015-07-13T14:00:00", + "unit" : "mg/dL" + }, + { + "amount" : -30.70643169960238, + "date" : "2015-07-13T14:05:00", + "unit" : "mg/dL" + }, + { + "amount" : -32.232403566815883, + "date" : "2015-07-13T14:10:00", + "unit" : "mg/dL" + }, + { + "amount" : -33.717547995543114, + "date" : "2015-07-13T14:15:00", + "unit" : "mg/dL" + }, + { + "amount" : -35.159816375127399, + "date" : "2015-07-13T14:20:00", + "unit" : "mg/dL" + }, + { + "amount" : -36.557568061108107, + "date" : "2015-07-13T14:25:00", + "unit" : "mg/dL" + }, + { + "amount" : -37.909530325902857, + "date" : "2015-07-13T14:30:00", + "unit" : "mg/dL" + }, + { + "amount" : -39.214761482205738, + "date" : "2015-07-13T14:35:00", + "unit" : "mg/dL" + }, + { + "amount" : -40.472616953985515, + "date" : "2015-07-13T14:40:00", + "unit" : "mg/dL" + }, + { + "amount" : -41.682718084880989, + "date" : "2015-07-13T14:45:00", + "unit" : "mg/dL" + }, + { + "amount" : -42.844923487762465, + "date" : "2015-07-13T14:50:00", + "unit" : "mg/dL" + }, + { + "amount" : -43.959302752314166, + "date" : "2015-07-13T14:55:00", + "unit" : "mg/dL" + }, + { + "amount" : -45.026112339748536, + "date" : "2015-07-13T15:00:00", + "unit" : "mg/dL" + }, + { + "amount" : -46.045773505239225, + "date" : "2015-07-13T15:05:00", + "unit" : "mg/dL" + }, + { + "amount" : -47.018852099403119, + "date" : "2015-07-13T15:10:00", + "unit" : "mg/dL" + }, + { + "amount" : -47.946040110218867, + "date" : "2015-07-13T15:15:00", + "unit" : "mg/dL" + }, + { + "amount" : -48.828138816181415, + "date" : "2015-07-13T15:20:00", + "unit" : "mg/dL" + }, + { + "amount" : -49.66604343029887, + "date" : "2015-07-13T15:25:00", + "unit" : "mg/dL" + }, + { + "amount" : -50.460729122778048, + "date" : "2015-07-13T15:30:00", + "unit" : "mg/dL" + }, + { + "amount" : -51.213238317951465, + "date" : "2015-07-13T15:35:00", + "unit" : "mg/dL" + }, + { + "amount" : -51.92466916820694, + "date" : "2015-07-13T15:40:00", + "unit" : "mg/dL" + }, + { + "amount" : -52.596165114419556, + "date" : "2015-07-13T15:45:00", + "unit" : "mg/dL" + }, + { + "amount" : -53.228905448686284, + "date" : "2015-07-13T15:50:00", + "unit" : "mg/dL" + }, + { + "amount" : -53.824096801051873, + "date" : "2015-07-13T15:55:00", + "unit" : "mg/dL" + }, + { + "amount" : -54.382965477416512, + "date" : "2015-07-13T16:00:00", + "unit" : "mg/dL" + }, + { + "amount" : -54.90675058095669, + "date" : "2015-07-13T16:05:00", + "unit" : "mg/dL" + }, + { + "amount" : -55.396697854191657, + "date" : "2015-07-13T16:10:00", + "unit" : "mg/dL" + }, + { + "amount" : -55.854054183312051, + "date" : "2015-07-13T16:15:00", + "unit" : "mg/dL" + }, + { + "amount" : -56.280062710572132, + "date" : "2015-07-13T16:20:00", + "unit" : "mg/dL" + }, + { + "amount" : -56.675958504455402, + "date" : "2015-07-13T16:25:00", + "unit" : "mg/dL" + }, + { + "amount" : -57.042964740968166, + "date" : "2015-07-13T16:30:00", + "unit" : "mg/dL" + }, + { + "amount" : -57.382289352817452, + "date" : "2015-07-13T16:35:00", + "unit" : "mg/dL" + }, + { + "amount" : -57.695122106401499, + "date" : "2015-07-13T16:40:00", + "unit" : "mg/dL" + }, + { + "amount" : -57.982632069499331, + "date" : "2015-07-13T16:45:00", + "unit" : "mg/dL" + }, + { + "amount" : -58.245965435302779, + "date" : "2015-07-13T16:50:00", + "unit" : "mg/dL" + }, + { + "amount" : -58.486243671003763, + "date" : "2015-07-13T16:55:00", + "unit" : "mg/dL" + }, + { + "amount" : -58.704561961543426, + "date" : "2015-07-13T17:00:00", + "unit" : "mg/dL" + }, + { + "amount" : -58.901987921359122, + "date" : "2015-07-13T17:05:00", + "unit" : "mg/dL" + }, + { + "amount" : -59.079560549040551, + "date" : "2015-07-13T17:10:00", + "unit" : "mg/dL" + }, + { + "amount" : -59.238289401738811, + "date" : "2015-07-13T17:15:00", + "unit" : "mg/dL" + }, + { + "amount" : -59.379153967968783, + "date" : "2015-07-13T17:20:00", + "unit" : "mg/dL" + }, + { + "amount" : -59.503103219118117, + "date" : "2015-07-13T17:25:00", + "unit" : "mg/dL" + }, + { + "amount" : -59.611055321529705, + "date" : "2015-07-13T17:30:00", + "unit" : "mg/dL" + }, + { + "amount" : -59.703897492469864, + "date" : "2015-07-13T17:35:00", + "unit" : "mg/dL" + }, + { + "amount" : -59.782485984636409, + "date" : "2015-07-13T17:40:00", + "unit" : "mg/dL" + }, + { + "amount" : -59.847646185107976, + "date" : "2015-07-13T17:45:00", + "unit" : "mg/dL" + }, + { + "amount" : -59.90017281579248, + "date" : "2015-07-13T17:50:00", + "unit" : "mg/dL" + }, + { + "amount" : -59.94083022350739, + "date" : "2015-07-13T17:55:00", + "unit" : "mg/dL" + }, + { + "amount" : -59.970352748819771, + "date" : "2015-07-13T18:00:00", + "unit" : "mg/dL" + }, + { + "amount" : -59.989445163698214, + "date" : "2015-07-13T18:05:00", + "unit" : "mg/dL" + }, + { + "amount" : -59.998783168883897, + "date" : "2015-07-13T18:10:00", + "unit" : "mg/dL" + }, + { + "amount" : -60, + "date" : "2015-07-13T18:15:00", + "unit" : "mg/dL" + } +] diff --git a/Tests/LoopAlgorithmTests/InsulinMathTests.swift b/Tests/LoopAlgorithmTests/InsulinMathTests.swift index 4564c79..fd64280 100644 --- a/Tests/LoopAlgorithmTests/InsulinMathTests.swift +++ b/Tests/LoopAlgorithmTests/InsulinMathTests.swift @@ -10,1237 +10,160 @@ import XCTest import HealthKit @testable import LoopAlgorithm -public typealias JSONDictionary = [String: Any] +class InsulinMathTests: XCTestCase { -//extension DoseUnit { -// var unit: HKUnit { -// switch self { -// case .units: -// return .internationalUnit() -// case .unitsPerHour: -// return HKUnit(from: "IU/hr") -// } -// } -//} -// -//class InsulinMathTests: XCTestCase { -// -// var fixtureDateformatter: DateFormatter! -// -// private let fixtureTimeZone = TimeZone(secondsFromGMT: -0 * 60 * 60)! -// -// private let insulinType: InsulinType = .novolog -// -// private let model = ExponentialInsulinModel(actionDuration: TimeInterval(minutes: 360), peakActivityTime: TimeInterval(minutes: 75)) -// -// let insulinModelSettings = StaticInsulinModelProvider(ExponentialInsulinModelPreset.rapidActingAdult) -// let insulinModelDuration = ExponentialInsulinModelPreset.rapidActingAdult.effectDuration -// -// private func fixtureDate(_ input: String) -> Date { -// return fixtureDateformatter.date(from: input)! -// } -// -// func loadFixture(_ resourceName: String) -> T { -// let url = Bundle.module.url(forResource: resourceName, withExtension: "json", subdirectory: "Fixtures")! -// return try! JSONSerialization.jsonObject(with: Data(contentsOf: url), options: []) as! T -// } -// -// override func setUp() { -// fixtureDateformatter = DateFormatter.descriptionFormatter -// fixtureDateformatter.timeZone = fixtureTimeZone -// } -// -// private func printInsulinValues(_ insulinValues: [InsulinValue]) { -// print("\n\n") -// print(String(data: try! JSONSerialization.data( -// withJSONObject: insulinValues.map({ (value) -> [String: Any] in -// return [ -// "date": ISO8601DateFormatter.localTimeDate(timeZone: fixtureTimeZone).string(from: value.startDate), -// "value": value.value, -// "unit": "U" -// ] -// }), -// options: .prettyPrinted), encoding: .utf8)!) -// print("\n\n") -// } -// -// func loadDoseFixture(_ resourceName: String, insulinType: InsulinType? = .novolog) -> [FixtureInsulinDose] { -// let fixture: [JSONDictionary] = loadFixture(resourceName) -// let dateFormatter = ISO8601DateFormatter.localTimeDate(timeZone: fixtureTimeZone) -// -// return fixture.compactMap({ (rawValue: JSONDictionary) -> FixtureInsulinDose? in -// guard let unit = DoseUnit(rawValue: $0["unit"] as! String), -// let deliveryType = InsulinDeliveryType(fixtureValue: $0["type"] as! String) -// else { -// return nil -// } -// -// var dose = FixtureInsulinDose( -// type: type, -// startDate: dateFormatter.date(from: $0["start_at"] as! String)!, -// endDate: dateFormatter.date(from: $0["end_at"] as! String)!, -// value: $0["amount"] as! Double, -// unit: unit, -// deliveredUnits: $0["delivered"] as? Double, -// description: $0["description"] as? String, -// syncIdentifier: $0["raw"] as? String, -// insulinType: insulinType, -// automatic: $0["automatic"] as? Bool, -// manuallyEntered: $0["manuallyEntered"] as? Bool ?? false, -// isMutable: $0["isMutable"] as? Bool ?? false -// ) -// -// if let scheduled = $0["scheduled"] as? Double { -// dose.scheduledBasalRate = HKQuantity(unit: unit.unit, doubleValue: scheduled) -// } -// -// return dose -// }) -// } -// -// func loadInsulinValueFixture(_ resourceName: String) -> [InsulinValue] { -// let fixture: [JSONDictionary] = loadFixture(resourceName) -// let dateFormatter = ISO8601DateFormatter.localTimeDate(timeZone: fixtureTimeZone) -// -// return fixture.map { -// return InsulinValue(startDate: dateFormatter.date(from: $0["date"] as! String)!, value: $0["value"] as! Double) -// } -// } -// -// func loadGlucoseEffectFixture(_ resourceName: String) -> [GlucoseEffect] { -// let fixture: [JSONDictionary] = loadFixture(resourceName) -// let dateFormatter = ISO8601DateFormatter.localTimeDate(timeZone: fixtureTimeZone) -// -// return fixture.map { -// return GlucoseEffect(startDate: dateFormatter.date(from: $0["date"] as! String)!, quantity: HKQuantity(unit: HKUnit(from: $0["unit"] as! String), doubleValue:$0["amount"] as! Double)) -// } -// } -// -//// var insulinSensitivitySchedule: InsulinSensitivitySchedule { -//// return InsulinSensitivitySchedule(unit: HKUnit.milligramsPerDeciliter, dailyItems: [RepeatingScheduleValue(startTime: 0.0, value: 40.0)], timeZone: fixtureTimeZone)! -//// } -// -// func testDoseEntriesFromReservoirValues() { -// let input = loadReservoirFixture("reservoir_history_with_rewind_and_prime_input") -// let output = loadDoseFixture("reservoir_history_with_rewind_and_prime_output").reversed() -// -// let doses = input.doseEntries -// -// XCTAssertEqual(output.count, doses.count) -// -// for (expected, calculated) in zip(output, doses) { -// XCTAssertEqual(expected.startDate, calculated.startDate) -// XCTAssertEqual(expected.endDate, calculated.endDate) -// XCTAssertEqual(expected.value, calculated.value, accuracy: Double(Float.ulpOfOne)) -// XCTAssertEqual(expected.unit, calculated.unit) -// } -// } -// -// func testContinuousReservoirValues() { -// var input = loadReservoirFixture("reservoir_history_with_rewind_and_prime_input") -// let within = TimeInterval(minutes: 30) -// let dateFormatter = ISO8601DateFormatter.localTimeDate(timeZone: fixtureTimeZone) -// XCTAssertTrue(input.isContinuous(from: dateFormatter.date(from: "2016-01-30T16:40:00")!, to: dateFormatter.date(from: "2016-01-30T20:40:00")!, within: within)) -// -// // We don't assert whether it's "stale". -// XCTAssertTrue(input.isContinuous(from: dateFormatter.date(from: "2016-01-30T16:40:00")!, to: dateFormatter.date(from: "2016-01-30T22:40:00")!, within: within)) -// XCTAssertTrue(input.isContinuous(from: dateFormatter.date(from: "2016-01-30T16:40:00")!, to: Date(), within: within)) -// -// // The values must extend the startDate boundary -// XCTAssertFalse(input.isContinuous(from: dateFormatter.date(from: "2016-01-30T15:00:00")!, to: dateFormatter.date(from: "2016-01-30T20:40:00")!, within: within)) -// -// // (the boundary condition is GTE) -// XCTAssertTrue(input.isContinuous(from: dateFormatter.date(from: "2016-01-30T16:00:42")!, to: dateFormatter.date(from: "2016-01-30T20:40:00")!, within: within)) -// -// // Rises in reservoir volume taint the entire range -// XCTAssertFalse(input.isContinuous(from: dateFormatter.date(from: "2016-01-30T15:55:00")!, to: dateFormatter.date(from: "2016-01-30T20:40:00")!, within: within)) -// -// // Any values of 0 taint the entire range -// input.append(NewReservoirValue(startDate: dateFormatter.date(from: "2016-01-30T20:37:00")!, unitVolume: 0)) -// -// XCTAssertFalse(input.isContinuous(from: dateFormatter.date(from: "2016-01-30T16:40:00")!, to: dateFormatter.date(from: "2016-01-30T20:40:00")!, within: within)) -// -// // As long as the 0 is within the date interval bounds -// XCTAssertTrue(input.isContinuous(from: dateFormatter.date(from: "2016-01-30T16:40:00")!, to: dateFormatter.date(from: "2016-01-30T19:40:00")!, within: within)) -// } -// -// func testNonContinuousReservoirValues() { -// let input = loadReservoirFixture("reservoir_history_with_continuity_holes") -// -// let dateFormatter = ISO8601DateFormatter.localTimeDate(timeZone: fixtureTimeZone) -// XCTAssertTrue(input.isContinuous(from: dateFormatter.date(from: "2016-01-30T18:30:00")!, to: dateFormatter.date(from: "2016-01-30T20:40:00")!, within: .minutes(30))) -// -// XCTAssertFalse(input.isContinuous(from: dateFormatter.date(from: "2016-01-30T17:30:00")!, to: dateFormatter.date(from: "2016-01-30T20:40:00")!, within: .minutes(30))) -// } -// -// func testIOBFromSuspend() { -// let input = loadDoseFixture("suspend_dose") -// let reconciledOutput = loadDoseFixture("suspend_dose_reconciled") -// let normalizedOutput = loadDoseFixture("suspend_dose_reconciled_normalized") -// let iobOutput = loadInsulinValueFixture("suspend_dose_reconciled_normalized_iob") -// let basals = loadBasalRateScheduleFixture("basal") -// -// let reconciled = input.reconciled() -// -// XCTAssertEqual(reconciledOutput.count, reconciled.count) -// -// for (expected, calculated) in zip(reconciledOutput, reconciled) { -// XCTAssertEqual(expected.startDate, calculated.startDate) -// XCTAssertEqual(expected.endDate, calculated.endDate) -// XCTAssertEqual(expected.value, calculated.value) -// XCTAssertEqual(expected.unit, calculated.unit) -// } -// -// let normalized = reconciled.annotated(with: basals) -// -// XCTAssertEqual(normalizedOutput.count, normalized.count) -// -// for (expected, calculated) in zip(normalizedOutput, normalized) { -// XCTAssertEqual(expected.startDate, calculated.startDate) -// XCTAssertEqual(expected.endDate, calculated.endDate) -// XCTAssertEqual(expected.value, calculated.netBasalUnitsPerHour, accuracy: Double(Float.ulpOfOne)) -// } -// -// let iob = normalized.insulinOnBoard(insulinModelProvider: walshModelSettings, longestEffectDuration: walshModelDuration) -// -// XCTAssertEqual(iobOutput.count, iob.count) -// -// for (expected, calculated) in zip(iobOutput, iob) { -// XCTAssertEqual(expected.startDate, calculated.startDate) -// XCTAssertEqual(expected.value, calculated.value, accuracy: Double(Float.ulpOfOne)) -// } -// } -// -// func testIOBFromDoses() { -// let input = loadDoseFixture("normalized_doses", insulinType: .novolog) -// let output = loadInsulinValueFixture("iob_from_doses_output") -// -// measure { -// _ = input.insulinOnBoard(insulinModelProvider: walshModelSettings, longestEffectDuration: walshModelDuration) -// } -// -// let iob = input.insulinOnBoard(insulinModelProvider: walshModelSettings, longestEffectDuration: walshModelDuration) -// -// XCTAssertEqual(output.count, iob.count) -// -// for (expected, calculated) in zip(output, iob) { -// XCTAssertEqual(expected.startDate, calculated.startDate) -// XCTAssertEqual(expected.value, calculated.value, accuracy: 0.5) -// } -// } -// -// func testIOBFromNoDoses() { -// let input: [DoseEntry] = [] -// -// let iob = input.insulinOnBoard(insulinModelProvider: insulinModelSettings, longestEffectDuration: insulinModelDuration) -// -// XCTAssertEqual(0, iob.count) -// } -// -// func testInsulinOnBoardLimitsForExponentialModel() { -// let insulinModel = ExponentialInsulinModel(actionDuration: TimeInterval(minutes: 360), peakActivityTime: TimeInterval(minutes: 75), delay: TimeInterval(minutes: 0)) -// let childModel = ExponentialInsulinModel(actionDuration: TimeInterval(minutes: 360), peakActivityTime: TimeInterval(minutes: 65), delay: TimeInterval(minutes: 0)) -// -// XCTAssertEqual(1, insulinModel.percentEffectRemaining(at: .minutes(-1)), accuracy: 0.001) -// XCTAssertEqual(1, insulinModel.percentEffectRemaining(at: .minutes(0)), accuracy: 0.001) -// XCTAssertEqual(0, insulinModel.percentEffectRemaining(at: .minutes(360)), accuracy: 0.001) -// XCTAssertEqual(0, insulinModel.percentEffectRemaining(at: .minutes(361)), accuracy: 0.001) -// -// // Test random point -// XCTAssertEqual(0.5110493617156, insulinModel.percentEffectRemaining(at: .minutes(108)), accuracy: 0.001) -// -// // Test for child curve -// XCTAssertEqual(0.6002510111374046, childModel.percentEffectRemaining(at: .minutes(82)), accuracy: 0.001) -// -// } -// -// func testIOBFromDosesExponential() { -// let input = loadDoseFixture("normalized_doses", insulinType: .novolog) -// let output = loadInsulinValueFixture("iob_from_doses_exponential_output") -// -// measure { -// _ = input.insulinOnBoard(insulinModelProvider: insulinModelSettings, longestEffectDuration: insulinModelDuration) -// } -// -// let iob = input.insulinOnBoard(insulinModelProvider: insulinModelSettings, longestEffectDuration: insulinModelDuration) -// -// XCTAssertEqual(output.count, iob.count) -// -// for (expected, calculated) in zip(output, iob) { -// XCTAssertEqual(expected.startDate, calculated.startDate) -// XCTAssertEqual(expected.value, calculated.value, accuracy: 0.5) -// } -// } -// -// func testIOBFromBolusExponential() { -// let input = loadDoseFixture("bolus_dose", insulinType: .novolog) -// let output = loadInsulinValueFixture("iob_from_bolus_exponential_output") -// -// let iob = input.insulinOnBoard(insulinModelProvider: insulinModelSettings, longestEffectDuration: insulinModelDuration) -// -// XCTAssertEqual(output.count, iob.count) -// -// for (expected, calculated) in zip(output, iob) { -// XCTAssertEqual(expected.startDate, calculated.startDate) -// XCTAssertEqual(expected.value, calculated.value, accuracy: Double(Float.ulpOfOne)) -// } -// } -// -// -// func testIOBFromBolus() { -// for hours in [2, 3, 4, 5, 5.2, 6, 7] as [Double] { -// let actionDuration = TimeInterval(hours: hours) -// let model = WalshInsulinModel(actionDuration: actionDuration) -// let insulinModelProvider = StaticInsulinModelProvider( model) -// let input = loadDoseFixture("bolus_dose", insulinType: .novolog) -// let output = loadInsulinValueFixture("iob_from_bolus_\(Int(actionDuration.minutes))min_output") -// -// let iob = input.insulinOnBoard(insulinModelProvider: insulinModelProvider, longestEffectDuration: model.effectDuration) -// -// XCTAssertEqual(output.count, iob.count) -// -// for (expected, calculated) in zip(output, iob) { -// XCTAssertEqual(expected.startDate, calculated.startDate) -// XCTAssertEqual(expected.value, calculated.value, accuracy: Double(Float.ulpOfOne)) -// } -// } -// } -// -// func testIOBFromDosesWithDifferentInsulinCurves() { -// let formatter = DateFormatter.descriptionFormatter -// let f = { (input) in -// return formatter.date(from: input)! -// } -// let output = loadInsulinValueFixture("iob_from_multiple_curves_output") -// -// let doses = [ -// DoseEntry(type: .basal, startDate: f("2018-05-15 14:42:36 +0000"), endDate: f("2018-05-16 14:42:36 +0000"), value: 0.84999999999999998, unit: .unitsPerHour, syncIdentifier: "7b02646a070f120e2200", scheduledBasalRate: nil), -// DoseEntry(type: .bolus, startDate: f("2018-05-15 14:44:46 +0000"), endDate: f("2018-05-15 14:44:46 +0000"), value: 0.9, unit: .units, syncIdentifier: "01004a004a006d006e22354312", scheduledBasalRate: nil), -// DoseEntry(type: .tempBasal, startDate: f("2018-05-15 14:42:36 +0000"), endDate: f("2018-05-15 14:42:36 +0000"), value: 0.0, unit: .unitsPerHour, syncIdentifier: "1600646a074f12", scheduledBasalRate: nil), -// DoseEntry(type: .tempBasal, startDate: f("2018-05-15 14:32:51 +0000"), endDate: f("2018-05-15 15:02:51 +0000"), value: 1.8999999999999999, unit: .unitsPerHour, syncIdentifier: "16017360074f12", scheduledBasalRate: nil), -// DoseEntry(type: .bolus, startDate: f("2018-05-15 14:52:51 +0000"), endDate: f("2018-05-15 15:52:51 +0000"), value: 0.9, unit: .units, syncIdentifier: "01004a004a006d006e22354312", scheduledBasalRate: nil), -// ] -// -// let iobWithoutModel = doses.insulinOnBoard(insulinModelProvider: insulinModelSettings, longestEffectDuration: insulinModelDuration) -// -// let dosesWithModel = [ -// DoseEntry(type: .basal, startDate: f("2018-05-15 14:42:36 +0000"), endDate: f("2018-05-16 14:42:36 +0000"), value: 0.84999999999999998, unit: .unitsPerHour, syncIdentifier: "7b02646a070f120e2200", scheduledBasalRate: nil), -// DoseEntry(type: .bolus, startDate: f("2018-05-15 14:44:46 +0000"), endDate: f("2018-05-15 14:44:46 +0000"), value: 0.9, unit: .units, syncIdentifier: "01004a004a006d006e22354312", scheduledBasalRate: nil, insulinType: .fiasp), -// DoseEntry(type: .tempBasal, startDate: f("2018-05-15 14:42:36 +0000"), endDate: f("2018-05-15 14:42:36 +0000"), value: 0.0, unit: .unitsPerHour, syncIdentifier: "1600646a074f12", scheduledBasalRate: nil), -// DoseEntry(type: .tempBasal, startDate: f("2018-05-15 14:32:51 +0000"), endDate: f("2018-05-15 15:02:51 +0000"), value: 1.8999999999999999, unit: .unitsPerHour, syncIdentifier: "16017360074f12", scheduledBasalRate: nil), -// DoseEntry(type: .bolus, startDate: f("2018-05-15 14:52:51 +0000"), endDate: f("2018-05-15 15:52:51 +0000"), value: 0.9, unit: .units, syncIdentifier: "01004a004a006d006e22354312", scheduledBasalRate: nil, insulinType: .novolog), -// ] -// -// let insulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: ExponentialInsulinModelPreset.rapidActingChild) -// let iobWithModel = dosesWithModel.insulinOnBoard(insulinModelProvider: insulinModelProvider, longestEffectDuration: ExponentialInsulinModelPreset.rapidActingChild.effectDuration) -// -// XCTAssertEqual(iobWithoutModel.count, iobWithModel.count) -// -// for (expected, calculated) in zip(output, iobWithModel) { -// XCTAssertEqual(expected.startDate, calculated.startDate) -// XCTAssertEqual(expected.value, calculated.value, accuracy: Double(Float.ulpOfOne)) -// } -// -// } -// -// func testIOBFromReservoirDoses() { -// let input = loadDoseFixture("normalized_reservoir_history_output") -// let output = loadInsulinValueFixture("iob_from_reservoir_output") -// -// measure { -// _ = input.insulinOnBoard(insulinModelProvider: walshModelSettings, longestEffectDuration: walshModelDuration) -// } -// -// let iob = input.insulinOnBoard(insulinModelProvider: walshModelSettings, longestEffectDuration: walshModelDuration) -// -// XCTAssertEqual(output.count, iob.count) -// -// for (expected, calculated) in zip(output, iob) { -// XCTAssertEqual(expected.startDate, calculated.startDate) -// XCTAssertEqual(expected.value, calculated.value, accuracy: 0.4) -// } -// } -// -// func testNormalizeReservoirDoses() { -// let input = loadDoseFixture("reservoir_history_with_rewind_and_prime_output") -// let output = loadDoseFixture("normalized_reservoir_history_output") -// let basals = loadBasalRateScheduleFixture("basal") -// -// measure { -// _ = input.annotated(with: basals) -// } -// -// let doses = input.annotated(with: basals) -// -// XCTAssertEqual(output.count, doses.count) -// -// // Total delivery on split doses should add up to delivery from original doses -// XCTAssertEqual( -// input.map {$0.unitsInDeliverableIncrements}.reduce(0,+), -// doses.map {$0.unitsInDeliverableIncrements}.reduce(0,+), -// accuracy: Double(Float.ulpOfOne)) -// -// for (expected, calculated) in zip(output, doses) { -// XCTAssertEqual(expected.startDate, calculated.startDate) -// XCTAssertEqual(expected.endDate, calculated.endDate) -// XCTAssertEqual(expected.value, calculated.unitsPerHour, accuracy: Double(Float.ulpOfOne)) -// XCTAssertEqual(expected.scheduledBasalRate, calculated.scheduledBasalRate) -// } -// } -// -// func testNormalizeEdgeCaseDoses() { -// let input = loadDoseFixture("normalize_edge_case_doses_input") -// let output = loadDoseFixture("normalize_edge_case_doses_output") -// let basals = loadBasalRateScheduleFixture("basal") -// -// measure { -// _ = input.annotated(with: basals) -// } -// -// let doses = input.annotated(with: basals) -// -// XCTAssertEqual(output.count, doses.count) -// -// for (expected, calculated) in zip(output, doses) { -// XCTAssertEqual(expected.startDate, calculated.startDate) -// XCTAssertEqual(expected.endDate, calculated.endDate) -// XCTAssertEqual(expected.value, calculated.unit == .units ? calculated.netBasalUnits : calculated.netBasalUnitsPerHour) -// XCTAssertEqual(expected.unit, calculated.unit) -// } -// } -// -// func testNormalizeEdgeCaseDosesMutable() { -// let input = loadDoseFixture("normalize_edge_case_doses_mutable_input") -// let output = loadDoseFixture("normalize_edge_case_doses_mutable_output") -// let basals = loadBasalRateScheduleFixture("basal") -// -// measure { -// _ = input.annotated(with: basals) -// } -// -// let doses = input.annotated(with: basals) -// -// XCTAssertEqual(output.count, doses.count) -// -// for (expected, calculated) in zip(output, doses) { -// XCTAssertEqual(expected.startDate, calculated.startDate) -// XCTAssertEqual(expected.endDate, calculated.endDate) -// XCTAssertEqual(expected.value, calculated.unit == .units ? calculated.netBasalUnits : calculated.netBasalUnitsPerHour) -// XCTAssertEqual(expected.unit, calculated.unit) -// XCTAssertEqual(expected.isMutable, calculated.isMutable) -// XCTAssertEqual(expected.deliveredUnits, calculated.deliveredUnits) -// } -// } -// -// func testReconcileTempBasals() { -// // Fixture contains numerous overlapping temp basals, as well as a Suspend event interleaved with a temp basal -// let input = loadDoseFixture("reconcile_history_input") -// let output = loadDoseFixture("reconcile_history_output").sorted { $0.startDate < $1.startDate } -// -// let doses = input.reconciled().sorted { $0.startDate < $1.startDate } -// -// XCTAssertEqual(output.count, doses.count) -// -// for (expected, calculated) in zip(output, doses) { -// XCTAssertEqual(expected.startDate, calculated.startDate) -// XCTAssertEqual(expected.endDate, calculated.endDate) -// XCTAssertEqual(expected.value, calculated.value) -// XCTAssertEqual(expected.unit, calculated.unit) -// XCTAssertEqual(expected.syncIdentifier, calculated.syncIdentifier) -// XCTAssertEqual(expected.deliveredUnits, calculated.deliveredUnits) -// } -// } -// -// func testReconcileResumeBeforeRewind() { -// let input = loadDoseFixture("reconcile_resume_before_rewind_input") -// let output = loadDoseFixture("reconcile_resume_before_rewind_output") -// -// let doses = input.reconciled() -// -// XCTAssertEqual(output.count, doses.count) -// -// for (expected, calculated) in zip(output, doses) { -// XCTAssertEqual(expected.startDate, calculated.startDate) -// XCTAssertEqual(expected.endDate, calculated.endDate) -// XCTAssertEqual(expected.value, calculated.value) -// XCTAssertEqual(expected.unit, calculated.unit) -// XCTAssertEqual(expected.syncIdentifier, calculated.syncIdentifier) -// XCTAssertEqual(expected.deliveredUnits, calculated.deliveredUnits) -// } -// } -// -// func testGlucoseEffectFromBolus() { -// let input = loadDoseFixture("bolus_dose") -// let output = loadGlucoseEffectFixture("effect_from_bolus_output") -// let insulinSensitivitySchedule = self.insulinSensitivitySchedule -// -// measure { -// _ = input.glucoseEffects(insulinModelProvider: walshModelSettings, longestEffectDuration: walshModelDuration, insulinSensitivity: insulinSensitivitySchedule) -// } -// -// let effects = input.glucoseEffects(insulinModelProvider: walshModelSettings, longestEffectDuration: walshModelDuration, insulinSensitivity: insulinSensitivitySchedule) -// -// XCTAssertEqual(Float(output.count), Float(effects.count), accuracy: 1.0) -// -// for (expected, calculated) in zip(output, effects) { -// XCTAssertEqual(expected.startDate, calculated.startDate) -// XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: 1.0) -// } -// } -// -// func testGlucoseEffectFromShortTempBasal() { -// let input = loadDoseFixture("short_basal_dose") -// let output = loadGlucoseEffectFixture("effect_from_bolus_output") -// let insulinSensitivitySchedule = self.insulinSensitivitySchedule -// -// measure { -// _ = input.glucoseEffects(insulinModelProvider: walshModelSettings, longestEffectDuration: walshModelDuration, insulinSensitivity: insulinSensitivitySchedule) -// } -// -// let effects = input.glucoseEffects(insulinModelProvider: walshModelSettings, longestEffectDuration: walshModelDuration, insulinSensitivity: insulinSensitivitySchedule) -// -// XCTAssertEqual(output.count, effects.count) -// -// for (expected, calculated) in zip(output, effects) { -// XCTAssertEqual(expected.startDate, calculated.startDate) -// XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: Double(Float.ulpOfOne)) -// } -// } -// -// func testGlucoseEffectFromTempBasal() { -// let input = loadDoseFixture("basal_dose") -// let output = loadGlucoseEffectFixture("effect_from_basal_output") -// let insulinSensitivitySchedule = self.insulinSensitivitySchedule -// -// measure { -// _ = input.glucoseEffects(insulinModelProvider: walshModelSettings, longestEffectDuration: walshModelDuration, insulinSensitivity: insulinSensitivitySchedule) -// } -// -// let effects = input.glucoseEffects(insulinModelProvider: walshModelSettings, longestEffectDuration: walshModelDuration, insulinSensitivity: insulinSensitivitySchedule) -// -// XCTAssertEqual(output.count, effects.count) -// -// for (expected, calculated) in zip(output, effects) { -// XCTAssertEqual(expected.startDate, calculated.startDate) -// XCTAssertEqual(expected.quantity.doubleValue(for: HKUnit.milligramsPerDeciliter), calculated.quantity.doubleValue(for: HKUnit.milligramsPerDeciliter), accuracy: 1.0, String(describing: expected.startDate)) -// } -// } -// -// func testGlucoseEffectFromTempBasalExponential() { -// let input = loadDoseFixture("basal_dose_with_delivered", insulinType: .novolog) -// let output = loadGlucoseEffectFixture("effect_from_basal_output_exponential") -// -// let effects = input.glucoseEffects(insulinModelProvider: insulinModelSettings, longestEffectDuration: insulinModelDuration, insulinSensitivity: insulinSensitivitySchedule) -// -// XCTAssertEqual(output.count, effects.count) -// -// for (expected, calculated) in zip(output, effects) { -// XCTAssertEqual(expected.startDate, calculated.startDate) -// XCTAssertEqual(expected.quantity.doubleValue(for: HKUnit.milligramsPerDeciliter), calculated.quantity.doubleValue(for: HKUnit.milligramsPerDeciliter), accuracy: 1.0, String(describing: expected.startDate)) -// print(expected.quantity.doubleValue(for: HKUnit.milligramsPerDeciliter), calculated.quantity.doubleValue(for: HKUnit.milligramsPerDeciliter)) -// } -// } -// -// func testGlucoseEffectFromHistory() { -// let input = loadDoseFixture("normalized_doses") -// let output = loadGlucoseEffectFixture("effect_from_history_output") -// let insulinSensitivitySchedule = self.insulinSensitivitySchedule -// -// measure { -// _ = input.glucoseEffects(insulinModelProvider: walshModelSettings, longestEffectDuration: walshModelDuration, insulinSensitivity: insulinSensitivitySchedule) -// } -// -// let effects = input.glucoseEffects(insulinModelProvider: walshModelSettings, longestEffectDuration: walshModelDuration, insulinSensitivity: insulinSensitivitySchedule) -// -// XCTAssertEqual(output.count, effects.count) -// -// for (expected, calculated) in zip(output, effects) { -// XCTAssertEqual(expected.startDate, calculated.startDate) -// XCTAssertEqual(expected.quantity.doubleValue(for: HKUnit.milligramsPerDeciliter), calculated.quantity.doubleValue(for: HKUnit.milligramsPerDeciliter), accuracy: 3.0) -// } -// } -// -// func testGlucoseEffectFromNoDoses() { -// let input: [DoseEntry] = [] -// let insulinSensitivitySchedule = self.insulinSensitivitySchedule -// -// let effects = input.glucoseEffects(insulinModelProvider: insulinModelSettings, longestEffectDuration: insulinModelDuration, insulinSensitivity: insulinSensitivitySchedule) -// -// XCTAssertEqual(0, effects.count) -// } -// -// func testTotalDelivery() { -// let input = loadDoseFixture("normalize_edge_case_doses_input") -// let output = input.totalDelivery -// -// XCTAssertEqual(18.8, output, accuracy: 0.01) -// } -// -// func testTrimContinuingDoses() { -// let dateFormatter = ISO8601DateFormatter.localTimeDate(timeZone: fixtureTimeZone) -// let input = loadDoseFixture("normalized_doses").reversed() -// -// // Last temp ends at 2015-10-15T22:29:50 -// let endDate = dateFormatter.date(from: "2015-10-15T22:25:50")! -// let trimmed = input.map { $0.trimmed(to: endDate) } -// -// print(input, "\n\n\n") -// print(trimmed) -// -// XCTAssertEqual(endDate, trimmed.last!.endDate) -// XCTAssertEqual(input.count, trimmed.count) -// } -// -// func testTrimmedMaintainsMutability() { -// let dateFormatter = ISO8601DateFormatter.localTimeDate(timeZone: fixtureTimeZone) -// let input = loadDoseFixture("normalized_doses").reversed() -// -// // Last temp ends at 2015-10-15T22:29:50 -// let endDate = dateFormatter.date(from: "2015-10-15T22:25:50")! -// let trimmed = input.map { $0.trimmed(to: endDate) } -// -// XCTAssertTrue(trimmed.last!.isMutable) -// } -// -// func testDosesOverlayBasalProfile() { -// let dateFormatter = ISO8601DateFormatter.localTimeDate(timeZone: fixtureTimeZone) -// let input = loadDoseFixture("reconcile_history_output").sorted { $0.startDate < $1.startDate } -// let output = loadDoseFixture("doses_overlay_basal_profile_output") -// let basals = loadBasalRateScheduleFixture("basal") -// -// let doses = input.annotated(with: basals).overlayBasalSchedule( -// basals, -// // A start date before the first entry should generate a basal -// startingAt: dateFormatter.date(from: "2016-02-15T14:01:04")!, -// endingAt: Date(), -// insertingBasalEntries: true -// ) -// -// XCTAssertEqual(output.count, doses.count) -// -// XCTAssertEqual(doses.first?.startDate, dateFormatter.date(from: "2016-02-15T14:01:04")!) -// -// for (expected, calculated) in zip(output, doses) { -// XCTAssertEqual(expected.startDate, calculated.startDate) -// XCTAssertEqual(expected.endDate, calculated.endDate) -// XCTAssertEqual(expected.value, calculated.value) -// XCTAssertEqual(expected.unit, calculated.unit) -// -// if let syncID = expected.syncIdentifier { -// XCTAssertEqual(syncID, calculated.syncIdentifier!) -// } -// } -// -// // Test trimming end -// let dosesTrimmedEnd = input[0.. DoseEntry in -// let startDate = self.fixtureDate("2018-07-16 03:49:00 +0000") -// let endDate = startDate.addingTimeInterval(TimeInterval(minutes: 5)) -// -// let tempBasalRate = 1.0 -// -// return DoseEntry( -// type: .tempBasal, -// startDate: startDate, -// endDate: endDate, -// value: tempBasalRate, -// unit: .unitsPerHour, -// deliveredUnits: deliveredUnits) -// } -// -// XCTAssertEqual(0.1, makeDose(nil).unitsInDeliverableIncrements, accuracy: .ulpOfOne) -// XCTAssertEqual(0.05, makeDose(0.05).unitsInDeliverableIncrements, accuracy: .ulpOfOne) -// } -// -// func testDoseEntryAnnotateShouldSplitDosesProportionally() { -// let startDate = self.fixtureDate("2018-07-16 11:59:00 +0000") -// let endDate = startDate.addingTimeInterval(TimeInterval(minutes: 5)) -// -// let tempBasalRate = 1.0 -// -// let dose = DoseEntry( -// type: .tempBasal, -// startDate: startDate, -// endDate: endDate, -// value: tempBasalRate, -// unit: .unitsPerHour, -// deliveredUnits: 0.1 -// ) -// -// let delivery = dose.unitsInDeliverableIncrements -// -// let basals = loadBasalRateScheduleFixture("basal") -// -// let splitDoses = [dose].annotated(with: basals) -// -// XCTAssertEqual(2, splitDoses.count) -// -// // A 5 minute dose starting one minute before midnight, split at midnight, means split should be 1/5, 4/5 -// XCTAssertEqual(delivery * 1.0/5.0, splitDoses[0].unitsInDeliverableIncrements, accuracy: .ulpOfOne) -// XCTAssertEqual(delivery * 4.0/5.0, splitDoses[1].unitsInDeliverableIncrements, accuracy: .ulpOfOne) -// } -// -// func testDoseEntryWithoutDeliveredUnitsShouldSplitDosesProportionally() { -// let startDate = self.fixtureDate("2018-07-16 11:59:00 +0000") -// let endDate = startDate.addingTimeInterval(TimeInterval(minutes: 5)) -// -// let tempBasalRate = 1.0 -// -// let dose = DoseEntry( -// type: .tempBasal, -// startDate: startDate, -// endDate: endDate, -// value: tempBasalRate, -// unit: .unitsPerHour, -// deliveredUnits: 0.05 -// ) -// -// let delivery = dose.unitsInDeliverableIncrements -// -// let basals = loadBasalRateScheduleFixture("basal") -// -// let splitDoses = [dose].annotated(with: basals) -// -// XCTAssertEqual(2, splitDoses.count) -// -// // A 5 minute dose starting one minute before midnight, split at midnight, means split should be 1/5, 4/5 -// XCTAssertEqual(delivery * 1.0/5.0, splitDoses[0].unitsInDeliverableIncrements, accuracy: .ulpOfOne) -// XCTAssertEqual(delivery * 4.0/5.0, splitDoses[1].unitsInDeliverableIncrements, accuracy: .ulpOfOne) -// } -// -//} + private let fixtureTimeZone = TimeZone(secondsFromGMT: -0 * 60 * 60)! + + private func printGlucoseEffect(_ insulinValues: [GlucoseEffect]) { + print("\n\n") + print(String(data: try! JSONSerialization.data( + withJSONObject: insulinValues.map({ (value) -> [String: Any] in + return [ + "date": ISO8601DateFormatter.localTimeDate(timeZone: fixtureTimeZone).string(from: value.startDate), + "amount": value.quantity.doubleValue(for: .milligramsPerDeciliter), + "unit": "mg/dL" + ] + }), + options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]), encoding: .utf8)!) + print("\n\n") + } + + + public func loadFixture(_ resourceName: String) -> T { + let url = Bundle.module.url(forResource: resourceName, withExtension: "json", subdirectory: "Fixtures")! + return try! JSONSerialization.jsonObject(with: Data(contentsOf: url), options: []) as! T + } + + func loadGlucoseEffectFixture(_ resourceName: String) -> [GlucoseEffect] { + let fixture: [JSONDictionary] = loadFixture(resourceName) + let dateFormatter = ISO8601DateFormatter.localTimeDate(timeZone: fixtureTimeZone) + + return fixture.map { + return GlucoseEffect(startDate: dateFormatter.date(from: $0["date"] as! String)!, quantity: HKQuantity(unit: HKUnit(from: $0["unit"] as! String), doubleValue:$0["amount"] as! Double)) + } + } + + let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + }() + + func testGlucoseEffectFromBolus() { + + let startDate = dateFormatter.date(from: "2015-07-13T12:02:37")! + + let input: [BasalRelativeDose] = [ + BasalRelativeDose( + type: .bolus, + startDate: startDate, + endDate: startDate, + volume: 1.5 + ) + ] + + let output = loadGlucoseEffectFixture("effect_from_bolus_output") + + let sensitivity: [AbsoluteScheduleValue] = [ + AbsoluteScheduleValue( + startDate: startDate, + endDate: startDate.addingTimeInterval(InsulinMath.longestInsulinActivityDuration), + value: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 40) + ) + ] + + measure { + _ = input.glucoseEffects(insulinModelProvider: PresetInsulinModelProvider(), insulinSensitivityHistory: sensitivity) + } + + let effects = input.glucoseEffects(insulinModelProvider: PresetInsulinModelProvider(), insulinSensitivityHistory: sensitivity) + + XCTAssertEqual(output.count, effects.count) + + for (expected, calculated) in zip(output, effects) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: 1.0) + } + } + + func testGlucoseEffectFromShortTempBasal() { + let startDate = dateFormatter.date(from: "2015-07-13T12:02:37")! + let endDate = dateFormatter.date(from: "2015-07-13T12:07:37")! + + let input: [BasalRelativeDose] = [ + BasalRelativeDose( + type: .basal(scheduledRate: 0.0), + startDate: startDate, + endDate: endDate, + volume: 18.0 * TimeInterval.minutes(5).hours + ) + ] + + let output = loadGlucoseEffectFixture("effect_from_bolus_output") + + let sensitivity: [AbsoluteScheduleValue] = [ + AbsoluteScheduleValue( + startDate: startDate, + endDate: startDate.addingTimeInterval(InsulinMath.longestInsulinActivityDuration), + value: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 40) + ) + ] + + measure { + _ = input.glucoseEffects(insulinModelProvider: PresetInsulinModelProvider(), insulinSensitivityHistory: sensitivity) + } + + let effects = input.glucoseEffects(insulinModelProvider: PresetInsulinModelProvider(), insulinSensitivityHistory: sensitivity) + + XCTAssertEqual(output.count+1, effects.count) + + for (expected, calculated) in zip(output, effects) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual( + expected.quantity.doubleValue(for: .milligramsPerDeciliter), + calculated.quantity.doubleValue(for: .milligramsPerDeciliter), + accuracy: 0.01) + } + } + + func testGlucoseEffectFromTempBasal() { + let startDate = dateFormatter.date(from: "2015-07-13T12:00:00")! + let endDate = dateFormatter.date(from: "2015-07-13T13:00:00")! + + let input: [BasalRelativeDose] = [ + BasalRelativeDose( + type: .basal(scheduledRate: 1.0), + startDate: startDate, + endDate: endDate, + volume: 2.0 + ) + ] + + let output = loadGlucoseEffectFixture("effect_from_basal_output") + + let sensitivity: [AbsoluteScheduleValue] = [ + AbsoluteScheduleValue( + startDate: startDate, + endDate: startDate.addingTimeInterval(InsulinMath.longestInsulinActivityDuration), + value: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 40) + ) + ] + + measure { + _ = input.glucoseEffects(insulinModelProvider: PresetInsulinModelProvider(), insulinSensitivityHistory: sensitivity) + } + + let effects = input.glucoseEffects(insulinModelProvider: PresetInsulinModelProvider(), insulinSensitivityHistory: sensitivity) + + printGlucoseEffect(effects) + + XCTAssertEqual(output.count, effects.count) + + for (expected, calculated) in zip(output, effects) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: HKUnit.milligramsPerDeciliter), calculated.quantity.doubleValue(for: HKUnit.milligramsPerDeciliter), accuracy: 1.0, String(describing: expected.startDate)) + } + } +} From 6aaf325af0e28656cc08edb8b92703e6ae9b1571 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 6 Feb 2024 11:01:31 -0600 Subject: [PATCH 13/26] Moving more tests over --- .../Insulin/FixtureInsulinDose.swift | 10 +- .../LoopAlgorithm/LoopAlgorithmInput.swift | 4 +- ...on => carbs_with_isf_change_scenario.json} | 0 .../Fixtures/dose_history.json | 116 ++++++++++++++++++ ...spend_input.json => suspend_scenario.json} | 0 .../LoopAlgorithmTests/InsulinMathTests.swift | 88 ++++++++++++- .../LoopAlgorithmTests.swift | 2 +- 7 files changed, 215 insertions(+), 5 deletions(-) rename Tests/LoopAlgorithmTests/Fixtures/{carbs_with_isf_change_input.json => carbs_with_isf_change_scenario.json} (100%) create mode 100644 Tests/LoopAlgorithmTests/Fixtures/dose_history.json rename Tests/LoopAlgorithmTests/Fixtures/{suspend_input.json => suspend_scenario.json} (100%) diff --git a/Sources/LoopAlgorithm/Insulin/FixtureInsulinDose.swift b/Sources/LoopAlgorithm/Insulin/FixtureInsulinDose.swift index 246a140..0c8c748 100644 --- a/Sources/LoopAlgorithm/Insulin/FixtureInsulinDose.swift +++ b/Sources/LoopAlgorithm/Insulin/FixtureInsulinDose.swift @@ -16,6 +16,14 @@ public struct FixtureInsulinDose: InsulinDose, Equatable { public var volume: Double public var insulinType: InsulinType? + + public init(deliveryType: InsulinDeliveryType, startDate: Date, endDate: Date, volume: Double, insulinType: InsulinType? = nil) { + self.deliveryType = deliveryType + self.startDate = startDate + self.endDate = endDate + self.volume = volume + self.insulinType = insulinType + } } extension FixtureInsulinDose: Codable { @@ -34,7 +42,7 @@ extension FixtureInsulinDose: Codable { try container.encode(startDate, forKey: .startDate) try container.encode(endDate, forKey: .endDate) try container.encode(volume, forKey: .volume) - try container.encodeIfPresent(insulinType, forKey: .insulinType) + try container.encodeIfPresent(insulinType?.stringValue, forKey: .insulinType) } private enum CodingKeys: String, CodingKey { diff --git a/Sources/LoopAlgorithm/LoopAlgorithmInput.swift b/Sources/LoopAlgorithm/LoopAlgorithmInput.swift index a147425..3e77163 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithmInput.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithmInput.swift @@ -201,7 +201,7 @@ extension LoopAlgorithmInput: Codable where CarbType == FixtureCarbEntry, Glucos if !includePositiveVelocityAndRC { try container.encode(includePositiveVelocityAndRC, forKey: .includePositiveVelocityAndRC) } - try container.encode(recommendationInsulinType.identifierForAlgorithmInput, forKey: .recommendationInsulinType) + try container.encode(recommendationInsulinType.stringValue, forKey: .recommendationInsulinType) try container.encode(recommendationType.rawValue, forKey: .recommendationType) } @@ -228,7 +228,7 @@ extension LoopAlgorithmInput: Codable where CarbType == FixtureCarbEntry, Glucos // Default Codable implementation for insulin type is int, which is not very readable. Add more readable identifier extension InsulinType { - var identifierForAlgorithmInput: String { + var stringValue: String { switch self { case .afrezza: return "afrezza" diff --git a/Tests/LoopAlgorithmTests/Fixtures/carbs_with_isf_change_input.json b/Tests/LoopAlgorithmTests/Fixtures/carbs_with_isf_change_scenario.json similarity index 100% rename from Tests/LoopAlgorithmTests/Fixtures/carbs_with_isf_change_input.json rename to Tests/LoopAlgorithmTests/Fixtures/carbs_with_isf_change_scenario.json diff --git a/Tests/LoopAlgorithmTests/Fixtures/dose_history.json b/Tests/LoopAlgorithmTests/Fixtures/dose_history.json new file mode 100644 index 0000000..f28b649 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/dose_history.json @@ -0,0 +1,116 @@ +[ + { + "endDate" : "2015-10-15T22:29:50Z", + "startDate" : "2015-10-15T22:00:00Z", + "type" : "basal", + "volume" : 1.6035416666666666 + }, + { + "endDate" : "2015-10-15T22:00:00Z", + "startDate" : "2015-10-15T21:59:50Z", + "type" : "basal", + "volume" : 0.0092361111111111099 + }, + { + "endDate" : "2015-10-15T21:43:49Z", + "startDate" : "2015-10-15T21:39:49Z", + "type" : "basal", + "volume" : 0.073333333333333334 + }, + { + "endDate" : "2015-10-15T21:35:12Z", + "startDate" : "2015-10-15T21:35:12Z", + "type" : "bolus", + "volume" : 4 + }, + { + "endDate" : "2015-10-15T21:14:34Z", + "startDate" : "2015-10-15T21:04:34Z", + "type" : "basal", + "volume" : 0.34583333333333333 + }, + { + "endDate" : "2015-10-15T21:04:04Z", + "startDate" : "2015-10-15T21:00:04Z", + "type" : "basal", + "volume" : 0.18000000000000002 + }, + { + "endDate" : "2015-10-15T20:59:52Z", + "startDate" : "2015-10-15T20:39:52Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2015-10-15T20:39:34Z", + "startDate" : "2015-10-15T20:34:34Z", + "type" : "basal", + "volume" : 0.0041666666666666666 + }, + { + "endDate" : "2015-10-15T20:33:51Z", + "startDate" : "2015-10-15T20:29:51Z", + "type" : "basal", + "volume" : 0.063333333333333325 + }, + { + "endDate" : "2015-10-15T20:19:37Z", + "startDate" : "2015-10-15T20:14:37Z", + "type" : "basal", + "volume" : 0.11458333333333333 + }, + { + "endDate" : "2015-10-15T20:13:52Z", + "startDate" : "2015-10-15T20:09:52Z", + "type" : "basal", + "volume" : 0.13833333333333334 + }, + { + "endDate" : "2015-10-15T20:05:10Z", + "startDate" : "2015-10-15T20:05:10Z", + "type" : "bolus", + "volume" : 3.2999999999999998 + }, + { + "endDate" : "2015-10-15T20:09:51Z", + "startDate" : "2015-10-15T19:54:51Z", + "type" : "basal", + "volume" : 0.92500000000000004 + }, + { + "endDate" : "2015-10-15T19:25:54Z", + "startDate" : "2015-10-15T19:25:54Z", + "type" : "bolus", + "volume" : 5 + }, + { + "endDate" : "2015-10-15T19:53:52Z", + "startDate" : "2015-10-15T19:24:52Z", + "type" : "basal", + "volume" : 1.7883333333333333 + }, + { + "endDate" : "2015-10-15T19:24:39Z", + "startDate" : "2015-10-15T19:19:39Z", + "type" : "basal", + "volume" : 0.033333333333333333 + }, + { + "endDate" : "2015-10-15T19:19:07Z", + "startDate" : "2015-10-15T19:00:07Z", + "type" : "basal", + "volume" : 0.063333333333333339 + }, + { + "endDate" : "2015-10-15T18:44:54Z", + "startDate" : "2015-10-15T18:14:54Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2015-10-15T18:14:35Z", + "startDate" : "2015-10-15T18:09:35Z", + "type" : "basal", + "volume" : 0.064583333333333326 + } +] diff --git a/Tests/LoopAlgorithmTests/Fixtures/suspend_input.json b/Tests/LoopAlgorithmTests/Fixtures/suspend_scenario.json similarity index 100% rename from Tests/LoopAlgorithmTests/Fixtures/suspend_input.json rename to Tests/LoopAlgorithmTests/Fixtures/suspend_scenario.json diff --git a/Tests/LoopAlgorithmTests/InsulinMathTests.swift b/Tests/LoopAlgorithmTests/InsulinMathTests.swift index fd64280..3127b6d 100644 --- a/Tests/LoopAlgorithmTests/InsulinMathTests.swift +++ b/Tests/LoopAlgorithmTests/InsulinMathTests.swift @@ -43,6 +43,14 @@ class InsulinMathTests: XCTestCase { } } + func loadDoseFixtures(_ name: String) -> [FixtureInsulinDose] { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let url = Bundle.module.url(forResource: name, withExtension: "json", subdirectory: "Fixtures")! + return try! decoder.decode([FixtureInsulinDose].self, from: try! Data(contentsOf: url)) + } + + let dateFormatter: DateFormatter = { let formatter = DateFormatter() formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" @@ -157,13 +165,91 @@ class InsulinMathTests: XCTestCase { let effects = input.glucoseEffects(insulinModelProvider: PresetInsulinModelProvider(), insulinSensitivityHistory: sensitivity) + XCTAssertEqual(output.count, effects.count) + + for (expected, calculated) in zip(output, effects) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: HKUnit.milligramsPerDeciliter), calculated.quantity.doubleValue(for: HKUnit.milligramsPerDeciliter), accuracy: 1.0, String(describing: expected.startDate)) + } + } + + func testGlucoseEffectFromNoDoses() { + let input: [BasalRelativeDose] = [] + + let startDate = dateFormatter.date(from: "2015-07-13T12:00:00")! + + let sensitivity: [AbsoluteScheduleValue] = [ + AbsoluteScheduleValue( + startDate: startDate, + endDate: startDate.addingTimeInterval(InsulinMath.longestInsulinActivityDuration), + value: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 40) + ) + ] + + let effects = input.glucoseEffects(insulinModelProvider: PresetInsulinModelProvider(), insulinSensitivityHistory: sensitivity) + + XCTAssertEqual(0, effects.count) + } + + func testGlucoseEffectFromHistory() { + let input = loadDoseFixtures("dose_history") + let output = loadGlucoseEffectFixture("effect_from_history_output") + + let startDate = input.last!.startDate + let endDate = input.first!.endDate + + let sensitivity: [AbsoluteScheduleValue] = [ + AbsoluteScheduleValue( + startDate: startDate, + endDate: endDate.addingTimeInterval(InsulinMath.longestInsulinActivityDuration), + value: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 40) + ) + ] + + let basal = [ + AbsoluteScheduleValue( + startDate: startDate, + endDate: dateFormatter.date(from: "2015-10-15T20:30:00")!, + value: 1.0), + AbsoluteScheduleValue( + startDate: dateFormatter.date(from: "2015-10-15T20:30:00")!, + endDate: dateFormatter.date(from: "2015-10-15T21:00:00")!, + value: 0.8), + AbsoluteScheduleValue( + startDate: dateFormatter.date(from: "2015-10-15T21:00:00")!, + endDate: dateFormatter.date(from: "2015-10-15T18:30:00")!, + value: 1.0), + AbsoluteScheduleValue( + startDate: dateFormatter.date(from: "2015-10-15T18:30:00")!, + endDate: dateFormatter.date(from: "2015-10-15T19:00:00")!, + value: 0.8), + AbsoluteScheduleValue( + startDate: dateFormatter.date(from: "2015-10-15T19:00:00")!, + endDate: endDate, + value: 1.0), + ] + + let basalRelativeDoses = input.annotated(with: basal) + + measure { + _ = basalRelativeDoses.glucoseEffects( + insulinModelProvider: PresetInsulinModelProvider(), + insulinSensitivityHistory: sensitivity + ) + } + + let effects = basalRelativeDoses.glucoseEffects( + insulinModelProvider: PresetInsulinModelProvider(), + insulinSensitivityHistory: sensitivity + ) + printGlucoseEffect(effects) XCTAssertEqual(output.count, effects.count) for (expected, calculated) in zip(output, effects) { XCTAssertEqual(expected.startDate, calculated.startDate) - XCTAssertEqual(expected.quantity.doubleValue(for: HKUnit.milligramsPerDeciliter), calculated.quantity.doubleValue(for: HKUnit.milligramsPerDeciliter), accuracy: 1.0, String(describing: expected.startDate)) + XCTAssertEqual(expected.quantity.doubleValue(for: HKUnit.milligramsPerDeciliter), calculated.quantity.doubleValue(for: HKUnit.milligramsPerDeciliter), accuracy: 3.0) } } } diff --git a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift index a1ffc6d..8eea386 100644 --- a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift +++ b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift @@ -14,7 +14,7 @@ final class LoopAlgorithmTests: XCTestCase { func loadScenario(_ name: String) -> (input: LoopAlgorithmInput, recommendation: LoopAlgorithmDoseRecommendation) { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 - var url = Bundle.module.url(forResource: name + "_input", withExtension: "json", subdirectory: "Fixtures")! + var url = Bundle.module.url(forResource: name + "_scenario", withExtension: "json", subdirectory: "Fixtures")! let input = try! decoder.decode(LoopAlgorithmInput.self, from: try! Data(contentsOf: url)) url = Bundle.module.url(forResource: name + "_recommendation", withExtension: "json", subdirectory: "Fixtures")! From 34df98bbd35bb7c458f5588d4192dd05cb407236 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 6 Feb 2024 11:02:35 -0600 Subject: [PATCH 14/26] Add fixture --- .../Fixtures/effect_from_history_output.json | 643 ++++++++++++++++++ 1 file changed, 643 insertions(+) create mode 100644 Tests/LoopAlgorithmTests/Fixtures/effect_from_history_output.json diff --git a/Tests/LoopAlgorithmTests/Fixtures/effect_from_history_output.json b/Tests/LoopAlgorithmTests/Fixtures/effect_from_history_output.json new file mode 100644 index 0000000..26aabee --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/effect_from_history_output.json @@ -0,0 +1,643 @@ +[ + { + "amount" : 0, + "date" : "2015-10-15T18:05:00", + "unit" : "mg/dL" + }, + { + "amount" : 0, + "date" : "2015-10-15T18:10:00", + "unit" : "mg/dL" + }, + { + "amount" : 0, + "date" : "2015-10-15T18:15:00", + "unit" : "mg/dL" + }, + { + "amount" : 1.3043523621691923e-05, + "date" : "2015-10-15T18:20:00", + "unit" : "mg/dL" + }, + { + "amount" : 0.0021171754398167379, + "date" : "2015-10-15T18:25:00", + "unit" : "mg/dL" + }, + { + "amount" : 0.015851208667674282, + "date" : "2015-10-15T18:30:00", + "unit" : "mg/dL" + }, + { + "amount" : 0.055514791405933378, + "date" : "2015-10-15T18:35:00", + "unit" : "mg/dL" + }, + { + "amount" : 0.13359980957047504, + "date" : "2015-10-15T18:40:00", + "unit" : "mg/dL" + }, + { + "amount" : 0.25920595848964806, + "date" : "2015-10-15T18:45:00", + "unit" : "mg/dL" + }, + { + "amount" : 0.43890218862183972, + "date" : "2015-10-15T18:50:00", + "unit" : "mg/dL" + }, + { + "amount" : 0.67796921901131779, + "date" : "2015-10-15T18:55:00", + "unit" : "mg/dL" + }, + { + "amount" : 0.97438367300228079, + "date" : "2015-10-15T19:00:00", + "unit" : "mg/dL" + }, + { + "amount" : 1.3200728192309623, + "date" : "2015-10-15T19:05:00", + "unit" : "mg/dL" + }, + { + "amount" : 1.7077031861007532, + "date" : "2015-10-15T19:10:00", + "unit" : "mg/dL" + }, + { + "amount" : 2.1367595861802906, + "date" : "2015-10-15T19:15:00", + "unit" : "mg/dL" + }, + { + "amount" : 2.6130525292846287, + "date" : "2015-10-15T19:20:00", + "unit" : "mg/dL" + }, + { + "amount" : 3.1414976678226494, + "date" : "2015-10-15T19:25:00", + "unit" : "mg/dL" + }, + { + "amount" : 3.7249940700872339, + "date" : "2015-10-15T19:30:00", + "unit" : "mg/dL" + }, + { + "amount" : 4.3650444945144482, + "date" : "2015-10-15T19:35:00", + "unit" : "mg/dL" + }, + { + "amount" : 4.7067887589638886, + "date" : "2015-10-15T19:40:00", + "unit" : "mg/dL" + }, + { + "amount" : 4.1383883720203354, + "date" : "2015-10-15T19:45:00", + "unit" : "mg/dL" + }, + { + "amount" : 2.7130078786157612, + "date" : "2015-10-15T19:50:00", + "unit" : "mg/dL" + }, + { + "amount" : 0.49566594829725535, + "date" : "2015-10-15T19:55:00", + "unit" : "mg/dL" + }, + { + "amount" : -2.4528927366116524, + "date" : "2015-10-15T20:00:00", + "unit" : "mg/dL" + }, + { + "amount" : -6.0713893730192376, + "date" : "2015-10-15T20:05:00", + "unit" : "mg/dL" + }, + { + "amount" : -10.303628576997706, + "date" : "2015-10-15T20:10:00", + "unit" : "mg/dL" + }, + { + "amount" : -15.101169057677367, + "date" : "2015-10-15T20:15:00", + "unit" : "mg/dL" + }, + { + "amount" : -20.716457217195174, + "date" : "2015-10-15T20:20:00", + "unit" : "mg/dL" + }, + { + "amount" : -27.380270637242482, + "date" : "2015-10-15T20:25:00", + "unit" : "mg/dL" + }, + { + "amount" : -34.949534211562913, + "date" : "2015-10-15T20:30:00", + "unit" : "mg/dL" + }, + { + "amount" : -43.286545985378389, + "date" : "2015-10-15T20:35:00", + "unit" : "mg/dL" + }, + { + "amount" : -52.264027741079879, + "date" : "2015-10-15T20:40:00", + "unit" : "mg/dL" + }, + { + "amount" : -61.76646699369725, + "date" : "2015-10-15T20:45:00", + "unit" : "mg/dL" + }, + { + "amount" : -71.680918819657265, + "date" : "2015-10-15T20:50:00", + "unit" : "mg/dL" + }, + { + "amount" : -81.900465930344126, + "date" : "2015-10-15T20:55:00", + "unit" : "mg/dL" + }, + { + "amount" : -92.328165568362309, + "date" : "2015-10-15T21:00:00", + "unit" : "mg/dL" + }, + { + "amount" : -102.87748849853307, + "date" : "2015-10-15T21:05:00", + "unit" : "mg/dL" + }, + { + "amount" : -113.47146208796229, + "date" : "2015-10-15T21:10:00", + "unit" : "mg/dL" + }, + { + "amount" : -124.06101315660501, + "date" : "2015-10-15T21:15:00", + "unit" : "mg/dL" + }, + { + "amount" : -134.62009905018957, + "date" : "2015-10-15T21:20:00", + "unit" : "mg/dL" + }, + { + "amount" : -145.1217841778539, + "date" : "2015-10-15T21:25:00", + "unit" : "mg/dL" + }, + { + "amount" : -155.53243783066668, + "date" : "2015-10-15T21:30:00", + "unit" : "mg/dL" + }, + { + "amount" : -165.81576158842529, + "date" : "2015-10-15T21:35:00", + "unit" : "mg/dL" + }, + { + "amount" : -175.94014779129205, + "date" : "2015-10-15T21:40:00", + "unit" : "mg/dL" + }, + { + "amount" : -185.87826522474734, + "date" : "2015-10-15T21:45:00", + "unit" : "mg/dL" + }, + { + "amount" : -195.96262752949394, + "date" : "2015-10-15T21:50:00", + "unit" : "mg/dL" + }, + { + "amount" : -206.52914016717139, + "date" : "2015-10-15T21:55:00", + "unit" : "mg/dL" + }, + { + "amount" : -217.47340056280586, + "date" : "2015-10-15T22:00:00", + "unit" : "mg/dL" + }, + { + "amount" : -228.70047046491561, + "date" : "2015-10-15T22:05:00", + "unit" : "mg/dL" + }, + { + "amount" : -240.12529726332727, + "date" : "2015-10-15T22:10:00", + "unit" : "mg/dL" + }, + { + "amount" : -251.690438990204, + "date" : "2015-10-15T22:15:00", + "unit" : "mg/dL" + }, + { + "amount" : -263.36153925216848, + "date" : "2015-10-15T22:20:00", + "unit" : "mg/dL" + }, + { + "amount" : -275.10715080098271, + "date" : "2015-10-15T22:25:00", + "unit" : "mg/dL" + }, + { + "amount" : -286.89908382548953, + "date" : "2015-10-15T22:30:00", + "unit" : "mg/dL" + }, + { + "amount" : -298.71214261916953, + "date" : "2015-10-15T22:35:00", + "unit" : "mg/dL" + }, + { + "amount" : -310.52328534383491, + "date" : "2015-10-15T22:40:00", + "unit" : "mg/dL" + }, + { + "amount" : -322.29421713089812, + "date" : "2015-10-15T22:45:00", + "unit" : "mg/dL" + }, + { + "amount" : -333.97464519098088, + "date" : "2015-10-15T22:50:00", + "unit" : "mg/dL" + }, + { + "amount" : -345.52048542110253, + "date" : "2015-10-15T22:55:00", + "unit" : "mg/dL" + }, + { + "amount" : -356.8933206733189, + "date" : "2015-10-15T23:00:00", + "unit" : "mg/dL" + }, + { + "amount" : -368.05989966830873, + "date" : "2015-10-15T23:05:00", + "unit" : "mg/dL" + }, + { + "amount" : -378.99167375942488, + "date" : "2015-10-15T23:10:00", + "unit" : "mg/dL" + }, + { + "amount" : -389.66436893407285, + "date" : "2015-10-15T23:15:00", + "unit" : "mg/dL" + }, + { + "amount" : -400.05759060934326, + "date" : "2015-10-15T23:20:00", + "unit" : "mg/dL" + }, + { + "amount" : -410.15445893826853, + "date" : "2015-10-15T23:25:00", + "unit" : "mg/dL" + }, + { + "amount" : -419.94127249255814, + "date" : "2015-10-15T23:30:00", + "unit" : "mg/dL" + }, + { + "amount" : -429.40719832776898, + "date" : "2015-10-15T23:35:00", + "unit" : "mg/dL" + }, + { + "amount" : -438.54398656819569, + "date" : "2015-10-15T23:40:00", + "unit" : "mg/dL" + }, + { + "amount" : -447.34570777179891, + "date" : "2015-10-15T23:45:00", + "unit" : "mg/dL" + }, + { + "amount" : -455.80851145079703, + "date" : "2015-10-15T23:50:00", + "unit" : "mg/dL" + }, + { + "amount" : -463.93040423153343, + "date" : "2015-10-15T23:55:00", + "unit" : "mg/dL" + }, + { + "amount" : -471.71104623839892, + "date" : "2015-10-16T00:00:00", + "unit" : "mg/dL" + }, + { + "amount" : -479.15156438132186, + "date" : "2015-10-16T00:05:00", + "unit" : "mg/dL" + }, + { + "amount" : -486.2543813150387, + "date" : "2015-10-16T00:10:00", + "unit" : "mg/dL" + }, + { + "amount" : -493.023058921406, + "date" : "2015-10-16T00:15:00", + "unit" : "mg/dL" + }, + { + "amount" : -499.46215486351923, + "date" : "2015-10-16T00:20:00", + "unit" : "mg/dL" + }, + { + "amount" : -505.5770310131204, + "date" : "2015-10-16T00:25:00", + "unit" : "mg/dL" + }, + { + "amount" : -511.37357039402116, + "date" : "2015-10-16T00:30:00", + "unit" : "mg/dL" + }, + { + "amount" : -516.85813478586385, + "date" : "2015-10-16T00:35:00", + "unit" : "mg/dL" + }, + { + "amount" : -522.03765726190443, + "date" : "2015-10-16T00:40:00", + "unit" : "mg/dL" + }, + { + "amount" : -526.91960305812609, + "date" : "2015-10-16T00:45:00", + "unit" : "mg/dL" + }, + { + "amount" : -531.51186924154661, + "date" : "2015-10-16T00:50:00", + "unit" : "mg/dL" + }, + { + "amount" : -535.82267812482121, + "date" : "2015-10-16T00:55:00", + "unit" : "mg/dL" + }, + { + "amount" : -539.86069160301474, + "date" : "2015-10-16T01:00:00", + "unit" : "mg/dL" + }, + { + "amount" : -543.63492920431247, + "date" : "2015-10-16T01:05:00", + "unit" : "mg/dL" + }, + { + "amount" : -547.15451907267538, + "date" : "2015-10-16T01:10:00", + "unit" : "mg/dL" + }, + { + "amount" : -550.42846838462219, + "date" : "2015-10-16T01:15:00", + "unit" : "mg/dL" + }, + { + "amount" : -553.46562056285904, + "date" : "2015-10-16T01:20:00", + "unit" : "mg/dL" + }, + { + "amount" : -556.27482496187201, + "date" : "2015-10-16T01:25:00", + "unit" : "mg/dL" + }, + { + "amount" : -558.86493785268158, + "date" : "2015-10-16T01:30:00", + "unit" : "mg/dL" + }, + { + "amount" : -561.24477785349723, + "date" : "2015-10-16T01:35:00", + "unit" : "mg/dL" + }, + { + "amount" : -563.43359411530798, + "date" : "2015-10-16T01:40:00", + "unit" : "mg/dL" + }, + { + "amount" : -565.45900786932339, + "date" : "2015-10-16T01:45:00", + "unit" : "mg/dL" + }, + { + "amount" : -567.32945007449439, + "date" : "2015-10-16T01:50:00", + "unit" : "mg/dL" + }, + { + "amount" : -569.05270789672886, + "date" : "2015-10-16T01:55:00", + "unit" : "mg/dL" + }, + { + "amount" : -570.6363878436191, + "date" : "2015-10-16T02:00:00", + "unit" : "mg/dL" + }, + { + "amount" : -572.0877702437632, + "date" : "2015-10-16T02:05:00", + "unit" : "mg/dL" + }, + { + "amount" : -573.41396516542841, + "date" : "2015-10-16T02:10:00", + "unit" : "mg/dL" + }, + { + "amount" : -574.6219981579759, + "date" : "2015-10-16T02:15:00", + "unit" : "mg/dL" + }, + { + "amount" : -575.7275007450005, + "date" : "2015-10-16T02:20:00", + "unit" : "mg/dL" + }, + { + "amount" : -576.74580130146126, + "date" : "2015-10-16T02:25:00", + "unit" : "mg/dL" + }, + { + "amount" : -577.68106518094305, + "date" : "2015-10-16T02:30:00", + "unit" : "mg/dL" + }, + { + "amount" : -578.53713343337517, + "date" : "2015-10-16T02:35:00", + "unit" : "mg/dL" + }, + { + "amount" : -579.31768623633491, + "date" : "2015-10-16T02:40:00", + "unit" : "mg/dL" + }, + { + "amount" : -580.02630527022711, + "date" : "2015-10-16T02:45:00", + "unit" : "mg/dL" + }, + { + "amount" : -580.66622392121678, + "date" : "2015-10-16T02:50:00", + "unit" : "mg/dL" + }, + { + "amount" : -581.24043672229402, + "date" : "2015-10-16T02:55:00", + "unit" : "mg/dL" + }, + { + "amount" : -581.7518346710234, + "date" : "2015-10-16T03:00:00", + "unit" : "mg/dL" + }, + { + "amount" : -582.20324063049941, + "date" : "2015-10-16T03:05:00", + "unit" : "mg/dL" + }, + { + "amount" : -582.59740660912655, + "date" : "2015-10-16T03:10:00", + "unit" : "mg/dL" + }, + { + "amount" : -582.93757898138779, + "date" : "2015-10-16T03:15:00", + "unit" : "mg/dL" + }, + { + "amount" : -583.22741489495911, + "date" : "2015-10-16T03:20:00", + "unit" : "mg/dL" + }, + { + "amount" : -583.47030192860075, + "date" : "2015-10-16T03:25:00", + "unit" : "mg/dL" + }, + { + "amount" : -583.66919134980412, + "date" : "2015-10-16T03:30:00", + "unit" : "mg/dL" + }, + { + "amount" : -583.8267089590006, + "date" : "2015-10-16T03:35:00", + "unit" : "mg/dL" + }, + { + "amount" : -583.94537949189805, + "date" : "2015-10-16T03:40:00", + "unit" : "mg/dL" + }, + { + "amount" : -584.02762789860844, + "date" : "2015-10-16T03:45:00", + "unit" : "mg/dL" + }, + { + "amount" : -584.08632593727748, + "date" : "2015-10-16T03:50:00", + "unit" : "mg/dL" + }, + { + "amount" : -584.13502294212412, + "date" : "2015-10-16T03:55:00", + "unit" : "mg/dL" + }, + { + "amount" : -584.17434487516277, + "date" : "2015-10-16T04:00:00", + "unit" : "mg/dL" + }, + { + "amount" : -584.20485800454787, + "date" : "2015-10-16T04:05:00", + "unit" : "mg/dL" + }, + { + "amount" : -584.22710644921244, + "date" : "2015-10-16T04:10:00", + "unit" : "mg/dL" + }, + { + "amount" : -584.24216197536089, + "date" : "2015-10-16T04:15:00", + "unit" : "mg/dL" + }, + { + "amount" : -584.25155129938207, + "date" : "2015-10-16T04:20:00", + "unit" : "mg/dL" + }, + { + "amount" : -584.2566906636182, + "date" : "2015-10-16T04:25:00", + "unit" : "mg/dL" + }, + { + "amount" : -584.2589075785753, + "date" : "2015-10-16T04:30:00", + "unit" : "mg/dL" + }, + { + "amount" : -584.2594444444444, + "date" : "2015-10-16T04:35:00", + "unit" : "mg/dL" + }, + { + "amount" : -584.2594444444444, + "date" : "2015-10-16T04:40:00", + "unit" : "mg/dL" + } +] + From ca487e87eb0e27c69e565cda0d4756e22f14d09f Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 12 Feb 2024 12:31:47 -0600 Subject: [PATCH 15/26] CarbStatus has associated type again, to allow callers to use different CarbEntry types --- Sources/LoopAlgorithm/Carbs/CarbMath.swift | 75 ++++++++++++------- Sources/LoopAlgorithm/Carbs/CarbStatus.swift | 27 ++++--- .../Carbs/FixtureCarbEntry.swift | 1 + .../Glucose/FixtureGlucoseSample.swift | 3 +- .../Glucose/GlucoseCondition.swift | 12 +++ .../Glucose/GlucoseEffectVelocity.swift | 22 ++++++ .../Glucose/GlucoseSampleValue.swift | 6 ++ .../LoopAlgorithm/Insulin/InsulinMath.swift | 2 +- .../LoopAlgorithm/Insulin/InsulinType.swift | 9 --- .../Insulin/RelativeDelivery.swift | 2 + Sources/LoopAlgorithm/LoopAlgorithm.swift | 20 ++--- .../LoopAlgorithm/LoopAlgorithmOutput.swift | 6 +- 12 files changed, 127 insertions(+), 58 deletions(-) create mode 100644 Sources/LoopAlgorithm/Glucose/GlucoseCondition.swift diff --git a/Sources/LoopAlgorithm/Carbs/CarbMath.swift b/Sources/LoopAlgorithm/Carbs/CarbMath.swift index bf10ce9..6923c25 100644 --- a/Sources/LoopAlgorithm/Carbs/CarbMath.swift +++ b/Sources/LoopAlgorithm/Carbs/CarbMath.swift @@ -313,10 +313,10 @@ extension Collection where Element: CarbEntry { // MARK: - Dyanamic absorption overrides extension Collection { - public func dynamicCarbsOnBoard( + public func dynamicCarbsOnBoard( at date: Date, absorptionModel: CarbAbsorptionComputable - ) -> Double where Element == CarbStatus { + ) -> Double where Element == CarbStatus { reduce(0.0) { (value, entry) -> Double in return value + entry.dynamicCarbsOnBoard( at: date, @@ -328,19 +328,15 @@ extension Collection { } } - public func dynamicCarbsOnBoard( + public func dynamicCarbsOnBoard( from start: Date? = nil, to end: Date? = nil, - absorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption() - ) -> [CarbValue] where Element == CarbStatus { - - guard let (startDate, endDate) = simulationDateRange( - from: start, - to: end, - defaultAbsorptionTime: CarbMath.defaultAbsorptionTime, - delay: CarbMath.defaultEffectDelay, - delta: GlucoseMath.defaultDelta - ) else { + defaultAbsorptionTime: TimeInterval = TimeInterval(3 /* hours */ * 60 /* minutes */ * 60 /* seconds */), + absorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption(), + delay: TimeInterval = TimeInterval(10 /* minutes */ * 60 /* seconds */), + delta: TimeInterval = TimeInterval(5 /* minutes */ * 60 /* seconds */) + ) -> [CarbValue] where Element == CarbStatus { + guard let (startDate, endDate) = simulationDateRange(from: start, to: end, defaultAbsorptionTime: defaultAbsorptionTime, delay: delay, delta: delta) else { return [] } @@ -351,21 +347,21 @@ extension Collection { let value = reduce(0.0) { (value, entry) -> Double in return value + entry.dynamicCarbsOnBoard( at: date, - defaultAbsorptionTime: CarbMath.defaultAbsorptionTime, - delay: CarbMath.defaultEffectDelay, - delta: GlucoseMath.defaultDelta, + defaultAbsorptionTime: defaultAbsorptionTime, + delay: delay, + delta: delta, absorptionModel: absorptionModel ) } values.append(CarbValue(startDate: date, value: value)) - date = date.addingTimeInterval(GlucoseMath.defaultDelta) + date = date.addingTimeInterval(delta) } while date <= endDate return values } - public func dynamicGlucoseEffects( + public func dynamicGlucoseEffects( from start: Date? = nil, to end: Date? = nil, carbRatios: [AbsoluteScheduleValue], @@ -374,7 +370,7 @@ extension Collection { absorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption(), delay: TimeInterval = CarbMath.defaultEffectDelay, delta: TimeInterval = GlucoseMath.defaultDelta - ) -> [GlucoseEffect] where Element == CarbStatus { + ) -> [GlucoseEffect] where Element == CarbStatus { guard let (startDate, endDate) = simulationDateRange(from: start, to: end, defaultAbsorptionTime: defaultAbsorptionTime, delay: delay, delta: delta) else { return [] } @@ -390,6 +386,15 @@ extension Collection { } let csf = isf.value.doubleValue(for: mgdL) / cr.value + let val = entry.dynamicAbsorbedCarbs( + at: date, + absorptionTime: entry.absorptionTime ?? defaultAbsorptionTime, + delay: delay, + delta: delta, + absorptionModel: absorptionModel + ) + print("csf @\(date) = \(isf.value.doubleValue(for: mgdL)) / \(cr.value) = \(csf), val = \(val), \(entry.quantity.doubleValue(for: .gram()))g") + return value + csf * entry.dynamicAbsorbedCarbs( at: date, absorptionTime: entry.absorptionTime ?? defaultAbsorptionTime, @@ -405,6 +410,28 @@ extension Collection { return values } + + /// The quantity of carbs expected to still absorb at the last date of absorption + public func getClampedCarbsOnBoard() -> CarbValue? where Element == CarbStatus { + guard let firstAbsorption = first?.absorption else { + return nil + } + + let gram = HKUnit.gram() + var maxObservedEndDate = firstAbsorption.observedDate.end + var remainingTotalGrams: Double = 0 + + for entry in self { + guard let absorption = entry.absorption else { + continue + } + + maxObservedEndDate = Swift.max(maxObservedEndDate, absorption.observedDate.end) + remainingTotalGrams += absorption.remaining.doubleValue(for: gram) + } + + return CarbValue(startDate: maxObservedEndDate, value: remainingTotalGrams) + } } @@ -623,7 +650,7 @@ fileprivate class CarbStatusBuilder { } /// The resulting CarbStatus value - var result: CarbStatus { + var result: CarbStatus { let absorption = AbsorbedCarbValue( observed: HKQuantity(unit: carbUnit, doubleValue: observedGrams), clamped: HKQuantity(unit: carbUnit, doubleValue: clampedGrams), @@ -635,11 +662,9 @@ fileprivate class CarbStatusBuilder { ) return CarbStatus( + entry: entry, absorption: absorption, - observedTimeline: clampedTimeline, - quantity: entry.quantity, - startDate: entry.startDate, - originalAbsorptionTime: entry.absorptionTime + observedTimeline: clampedTimeline ) } @@ -684,7 +709,7 @@ extension Collection where Element: CarbEntry { absorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption(), adaptiveAbsorptionRateEnabled: Bool = false, adaptiveRateStandbyIntervalFraction: Double = 0.2 - ) -> [CarbStatus] { + ) -> [CarbStatus] { guard count > 0 else { // TODO: Apply unmatched effects to meal prediction return [] diff --git a/Sources/LoopAlgorithm/Carbs/CarbStatus.swift b/Sources/LoopAlgorithm/Carbs/CarbStatus.swift index 788ff08..f78d00a 100644 --- a/Sources/LoopAlgorithm/Carbs/CarbStatus.swift +++ b/Sources/LoopAlgorithm/Carbs/CarbStatus.swift @@ -1,5 +1,6 @@ // // CarbStatus.swift +// LoopKit // // Copyright © 2017 LoopKit Authors. All rights reserved. // @@ -8,25 +9,33 @@ import Foundation import HealthKit -public struct CarbStatus { +public struct CarbStatus { + /// Details entered by the user + public let entry: T + /// The last-computed absorption of the carbs public let absorption: AbsorbedCarbValue? /// The timeline of observed carb absorption. Nil if observed absorption is less than the modeled minimum public let observedTimeline: [CarbValue]? +} - public var quantity: HKQuantity - public var startDate: Date +// Masquerade as a carb entry, substituting AbsorbedCarbValue's interpretation of absorption time +extension CarbStatus: SampleValue { + public var quantity: HKQuantity { + return entry.quantity + } - public var originalAbsorptionTime: TimeInterval? + public var startDate: Date { + return entry.startDate + } } -// Masquerade as a carb entry, substituting AbsorbedCarbValue's interpretation of absorption time extension CarbStatus: CarbEntry { public var absorptionTime: TimeInterval? { - return absorption?.estimatedDate.duration ?? originalAbsorptionTime + return absorption?.estimatedDate.duration ?? entry.absorptionTime } } @@ -38,7 +47,7 @@ extension CarbStatus { let absorption = absorption else { // We have to have absorption info for dynamic calculation - return carbsOnBoard(at: date, defaultAbsorptionTime: defaultAbsorptionTime, delay: delay, absorptionModel: absorptionModel) + return entry.carbsOnBoard(at: date, defaultAbsorptionTime: defaultAbsorptionTime, delay: delay, absorptionModel: absorptionModel) } let unit = HKUnit.gram() @@ -63,7 +72,7 @@ extension CarbStatus { // Observed absorption // TODO: This creates an O(n^2) situation for COB timelines - let total = quantity.doubleValue(for: unit) + let total = entry.quantity.doubleValue(for: unit) return max(observedTimeline.filter({ $0.endDate <= date }).reduce(total) { (total, value) -> Double in return total - value.quantity.doubleValue(for: unit) }, 0) @@ -74,7 +83,7 @@ extension CarbStatus { let absorption = absorption else { // We have to have absorption info for dynamic calculation - return absorbedCarbs(at: date, absorptionTime: absorptionTime, delay: delay, absorptionModel: absorptionModel) + return entry.absorbedCarbs(at: date, absorptionTime: absorptionTime, delay: delay, absorptionModel: absorptionModel) } let unit = HKUnit.gram() diff --git a/Sources/LoopAlgorithm/Carbs/FixtureCarbEntry.swift b/Sources/LoopAlgorithm/Carbs/FixtureCarbEntry.swift index f0612ba..c2cd40d 100644 --- a/Sources/LoopAlgorithm/Carbs/FixtureCarbEntry.swift +++ b/Sources/LoopAlgorithm/Carbs/FixtureCarbEntry.swift @@ -12,6 +12,7 @@ public struct FixtureCarbEntry: CarbEntry { public var absorptionTime: TimeInterval? public var startDate: Date public var quantity: HKQuantity + public var foodType: String? } extension FixtureCarbEntry: Codable { diff --git a/Sources/LoopAlgorithm/Glucose/FixtureGlucoseSample.swift b/Sources/LoopAlgorithm/Glucose/FixtureGlucoseSample.swift index 3e13596..11faa28 100644 --- a/Sources/LoopAlgorithm/Glucose/FixtureGlucoseSample.swift +++ b/Sources/LoopAlgorithm/Glucose/FixtureGlucoseSample.swift @@ -8,7 +8,6 @@ import HealthKit public struct FixtureGlucoseSample: GlucoseSampleValue, Equatable { - public static let defaultProvenanceIdentifier = "com.LoopKit.Loop" public let provenanceIdentifier: String @@ -16,6 +15,8 @@ public struct FixtureGlucoseSample: GlucoseSampleValue, Equatable { public let quantity: HKQuantity public let isDisplayOnly: Bool public let wasUserEntered: Bool + public var condition: GlucoseCondition? + public var trendRate: HKQuantity? public init( provenanceIdentifier: String = Self.defaultProvenanceIdentifier, diff --git a/Sources/LoopAlgorithm/Glucose/GlucoseCondition.swift b/Sources/LoopAlgorithm/Glucose/GlucoseCondition.swift new file mode 100644 index 0000000..2ff6815 --- /dev/null +++ b/Sources/LoopAlgorithm/Glucose/GlucoseCondition.swift @@ -0,0 +1,12 @@ +// +// 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/GlucoseEffectVelocity.swift b/Sources/LoopAlgorithm/Glucose/GlucoseEffectVelocity.swift index a2be034..391e190 100644 --- a/Sources/LoopAlgorithm/Glucose/GlucoseEffectVelocity.swift +++ b/Sources/LoopAlgorithm/Glucose/GlucoseEffectVelocity.swift @@ -38,4 +38,26 @@ extension GlucoseEffectVelocity { ) ) } + + /// The integration of the velocity span from `start` to `end` + public func effect(from start: Date, to end: Date) -> GlucoseEffect? { + guard + start <= end, + startDate <= start, + end <= endDate + else { + return nil + } + + let duration = end.timeIntervalSince(start) + let velocityPerSecond = quantity.doubleValue(for: GlucoseEffectVelocity.perSecondUnit) + + return GlucoseEffect( + startDate: end, + quantity: HKQuantity( + unit: .milligramsPerDeciliter, + doubleValue: velocityPerSecond * duration + ) + ) + } } diff --git a/Sources/LoopAlgorithm/Glucose/GlucoseSampleValue.swift b/Sources/LoopAlgorithm/Glucose/GlucoseSampleValue.swift index 6a3f356..0575d72 100644 --- a/Sources/LoopAlgorithm/Glucose/GlucoseSampleValue.swift +++ b/Sources/LoopAlgorithm/Glucose/GlucoseSampleValue.swift @@ -17,4 +17,10 @@ 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: HKQuantity? { get } } diff --git a/Sources/LoopAlgorithm/Insulin/InsulinMath.swift b/Sources/LoopAlgorithm/Insulin/InsulinMath.swift index 33d546b..ea66471 100644 --- a/Sources/LoopAlgorithm/Insulin/InsulinMath.swift +++ b/Sources/LoopAlgorithm/Insulin/InsulinMath.swift @@ -255,7 +255,7 @@ extension Collection where Element == BasalRelativeDose { - parameter insulinModelProvider: A factory that can provide an insulin model given an insulin type - parameter date: The date at which to calculate remaining insulin. If nil, current date is used. - - returns: A sequence of insulin amount remaining + - returns: Insulin amount remaining at specified time */ public func insulinOnBoard( insulinModelProvider: InsulinModelProvider = PresetInsulinModelProvider(defaultRapidActingModel: nil), diff --git a/Sources/LoopAlgorithm/Insulin/InsulinType.swift b/Sources/LoopAlgorithm/Insulin/InsulinType.swift index 62e5eb0..94fb93d 100644 --- a/Sources/LoopAlgorithm/Insulin/InsulinType.swift +++ b/Sources/LoopAlgorithm/Insulin/InsulinType.swift @@ -15,13 +15,4 @@ public enum InsulinType: Int, Codable, CaseIterable { case fiasp case lyumjev case afrezza - - public var pumpAdministerable: Bool { - switch self { - case .afrezza: - return false - default: - return true - } - } } diff --git a/Sources/LoopAlgorithm/Insulin/RelativeDelivery.swift b/Sources/LoopAlgorithm/Insulin/RelativeDelivery.swift index 9515b58..492f75e 100644 --- a/Sources/LoopAlgorithm/Insulin/RelativeDelivery.swift +++ b/Sources/LoopAlgorithm/Insulin/RelativeDelivery.swift @@ -47,6 +47,8 @@ extension BasalRelativeDose { return volume } } + + } extension BasalRelativeDose { diff --git a/Sources/LoopAlgorithm/LoopAlgorithm.swift b/Sources/LoopAlgorithm/LoopAlgorithm.swift index 7a343f2..f90c336 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithm.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithm.swift @@ -16,10 +16,10 @@ public enum AlgorithmError: Error { case sensitivityTimelineIncomplete } -public struct LoopAlgorithmEffects { +public struct LoopAlgorithmEffects { public var insulin: [GlucoseEffect] public var carbs: [GlucoseEffect] - public var carbStatus: [CarbStatus] + public var carbStatus: [CarbStatus] public var retrospectiveCorrection: [GlucoseEffect] public var momentum: [GlucoseEffect] public var insulinCounteraction: [GlucoseEffectVelocity] @@ -29,7 +29,7 @@ public struct LoopAlgorithmEffects { public init( insulin: [GlucoseEffect], carbs: [GlucoseEffect], - carbStatus: [CarbStatus], + carbStatus: [CarbStatus], retrospectiveCorrection: [GlucoseEffect], momentum: [GlucoseEffect], insulinCounteraction: [GlucoseEffectVelocity], @@ -62,9 +62,9 @@ public struct AlgorithmEffectsOptions: OptionSet { } } -public struct LoopPrediction { +public struct LoopPrediction { public var glucose: [PredictedGlucoseValue] - public var effects: LoopAlgorithmEffects + public var effects: LoopAlgorithmEffects public var dosesRelativeToBasal: [BasalRelativeDose] public var activeInsulin: Double? public var activeCarbs: Double? @@ -114,7 +114,7 @@ public struct LoopAlgorithm { useIntegralRetrospectiveCorrection: Bool = false, includingPositiveVelocityAndRC: Bool = true, carbAbsorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption() - ) -> LoopPrediction where CarbType: CarbEntry, GlucoseType: GlucoseSampleValue, InsulinDoseType: InsulinDose { + ) -> LoopPrediction where CarbType: CarbEntry, GlucoseType: GlucoseSampleValue, InsulinDoseType: InsulinDose { var prediction: [PredictedGlucoseValue] = [] var insulinEffects: [GlucoseEffect] = [] @@ -126,7 +126,7 @@ public struct LoopAlgorithm { var totalGlucoseCorrectionEffect: HKQuantity? var activeInsulin: Double? var activeCarbs: Double? - var carbStatus: [CarbStatus] = [] + //var carbStatus: [CarbStatus] = [] var dosesRelativeToBasal: [BasalRelativeDose] = [] // Ensure basal history covers doses @@ -174,7 +174,7 @@ public struct LoopAlgorithm { } // Carb Effects - carbStatus = carbEntries.map( + let carbStatus = carbEntries.map( to: insulinCounteractionEffects, carbRatio: carbRatio, insulinSensitivity: sensitivity @@ -275,7 +275,7 @@ public struct LoopAlgorithm { } // Helper to generate prediction with LoopPredictionInput struct - public static func generatePrediction(input: LoopPredictionInput) -> LoopPrediction { + public static func generatePrediction(input: LoopPredictionInput) -> LoopPrediction { return generatePrediction( start: input.glucoseHistory.last?.startDate ?? Date(), @@ -409,7 +409,7 @@ public struct LoopAlgorithm { } } - public static func run(input: LoopAlgorithmInput) -> LoopAlgorithmOutput { + public static func run(input: LoopAlgorithmInput) -> LoopAlgorithmOutput { let prediction = generatePrediction( start: input.predictionStart, diff --git a/Sources/LoopAlgorithm/LoopAlgorithmOutput.swift b/Sources/LoopAlgorithm/LoopAlgorithmOutput.swift index c15f714..d2d3656 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithmOutput.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithmOutput.swift @@ -8,10 +8,10 @@ import Foundation import HealthKit -public struct LoopAlgorithmOutput { +public struct LoopAlgorithmOutput { public var recommendationResult: Result public var predictedGlucose: [PredictedGlucoseValue] - public var effects: LoopAlgorithmEffects + public var effects: LoopAlgorithmEffects public var dosesRelativeToBasal: [BasalRelativeDose] public var activeInsulin: Double? public var activeCarbs: Double? @@ -19,7 +19,7 @@ public struct LoopAlgorithmOutput { public init( recommendationResult: Result, predictedGlucose: [PredictedGlucoseValue], - effects: LoopAlgorithmEffects, + effects: LoopAlgorithmEffects, dosesRelativeToBasal: [BasalRelativeDose], activeInsulin: Double? = nil, activeCarbs: Double? = nil From 64db07460f67165641382d4c681608b076f5918e Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Mon, 12 Feb 2024 12:33:42 -0600 Subject: [PATCH 16/26] Update file headers --- Sources/LoopAlgorithm/AutomaticDoseRecommendation.swift | 2 +- Sources/LoopAlgorithm/Carbs/AbsorbedCarbValue.swift | 2 +- Sources/LoopAlgorithm/Carbs/CarbStatus.swift | 2 +- Sources/LoopAlgorithm/DoseRecommendationType.swift | 2 +- Sources/LoopAlgorithm/Extensions/ClosedRange.swift | 2 +- Sources/LoopAlgorithm/Extensions/HKQuantity.swift | 2 +- Sources/LoopAlgorithm/Glucose/FixtureGlucoseSample.swift | 2 +- Sources/LoopAlgorithm/Glucose/GlucoseChange.swift | 2 +- Sources/LoopAlgorithm/Glucose/GlucoseSampleValue.swift | 2 +- Sources/LoopAlgorithm/Insulin/DoseType.swift | 2 +- Sources/LoopAlgorithm/Insulin/DoseUnit.swift | 2 +- .../LoopAlgorithm/Insulin/ExponentialInsulinModelPreset.swift | 2 +- Sources/LoopAlgorithm/Insulin/InsulinModel.swift | 2 +- Sources/LoopAlgorithm/Insulin/InsulinType.swift | 2 +- Sources/LoopAlgorithm/Insulin/InsulinValue.swift | 2 +- Sources/LoopAlgorithm/LoopAlgorithmDoseRecommendation.swift | 2 +- Sources/LoopAlgorithm/LoopAlgorithmInput.swift | 2 +- Sources/LoopAlgorithm/ManualBolusRecommendation.swift | 2 +- Sources/LoopAlgorithm/TempBasalRecommendation.swift | 2 +- Tests/LoopAlgorithmTests/Extensions/TimeZone.swift | 2 +- Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift | 2 +- 21 files changed, 21 insertions(+), 21 deletions(-) diff --git a/Sources/LoopAlgorithm/AutomaticDoseRecommendation.swift b/Sources/LoopAlgorithm/AutomaticDoseRecommendation.swift index 92413b1..1bdc1d4 100644 --- a/Sources/LoopAlgorithm/AutomaticDoseRecommendation.swift +++ b/Sources/LoopAlgorithm/AutomaticDoseRecommendation.swift @@ -1,6 +1,6 @@ // // AutomaticDoseRecommendation.swift -// LoopKit +// LoopAlgorithm // // Created by Pete Schwamb on 1/16/21. // Copyright © 2021 LoopKit Authors. All rights reserved. diff --git a/Sources/LoopAlgorithm/Carbs/AbsorbedCarbValue.swift b/Sources/LoopAlgorithm/Carbs/AbsorbedCarbValue.swift index 0060b41..4d50f11 100644 --- a/Sources/LoopAlgorithm/Carbs/AbsorbedCarbValue.swift +++ b/Sources/LoopAlgorithm/Carbs/AbsorbedCarbValue.swift @@ -1,6 +1,6 @@ // // AbsorbedCarbValue.swift -// LoopKit +// LoopAlgorithm // // Copyright © 2017 LoopKit Authors. All rights reserved. // diff --git a/Sources/LoopAlgorithm/Carbs/CarbStatus.swift b/Sources/LoopAlgorithm/Carbs/CarbStatus.swift index f78d00a..dc96074 100644 --- a/Sources/LoopAlgorithm/Carbs/CarbStatus.swift +++ b/Sources/LoopAlgorithm/Carbs/CarbStatus.swift @@ -1,6 +1,6 @@ // // CarbStatus.swift -// LoopKit +// LoopAlgorithm // // Copyright © 2017 LoopKit Authors. All rights reserved. // diff --git a/Sources/LoopAlgorithm/DoseRecommendationType.swift b/Sources/LoopAlgorithm/DoseRecommendationType.swift index c3de4b6..d5c307c 100644 --- a/Sources/LoopAlgorithm/DoseRecommendationType.swift +++ b/Sources/LoopAlgorithm/DoseRecommendationType.swift @@ -1,6 +1,6 @@ // // DoseRecommendationType.swift -// LoopKit +// LoopAlgorithm // // Created by Pete Schwamb on 10/12/23. // Copyright © 2023 LoopKit Authors. All rights reserved. diff --git a/Sources/LoopAlgorithm/Extensions/ClosedRange.swift b/Sources/LoopAlgorithm/Extensions/ClosedRange.swift index b7bea3c..4637dd4 100644 --- a/Sources/LoopAlgorithm/Extensions/ClosedRange.swift +++ b/Sources/LoopAlgorithm/Extensions/ClosedRange.swift @@ -1,6 +1,6 @@ // // ClosedRange.swift -// LoopKit +// LoopAlgorithm // // Created by Michael Pangburn on 6/23/20. // Copyright © 2020 LoopKit Authors. All rights reserved. diff --git a/Sources/LoopAlgorithm/Extensions/HKQuantity.swift b/Sources/LoopAlgorithm/Extensions/HKQuantity.swift index 133de47..ccad47c 100644 --- a/Sources/LoopAlgorithm/Extensions/HKQuantity.swift +++ b/Sources/LoopAlgorithm/Extensions/HKQuantity.swift @@ -1,6 +1,6 @@ // // HKQuantity.swift -// LoopKit +// LoopAlgorithm // // Created by Nathan Racklyeft on 3/10/16. // Copyright © 2016 Nathan Racklyeft. All rights reserved. diff --git a/Sources/LoopAlgorithm/Glucose/FixtureGlucoseSample.swift b/Sources/LoopAlgorithm/Glucose/FixtureGlucoseSample.swift index 11faa28..a54a4d6 100644 --- a/Sources/LoopAlgorithm/Glucose/FixtureGlucoseSample.swift +++ b/Sources/LoopAlgorithm/Glucose/FixtureGlucoseSample.swift @@ -1,6 +1,6 @@ // // FixtureGlucoseSample.swift -// LoopKit +// LoopAlgorithm // // Copyright © 2018 LoopKit Authors. All rights reserved. // diff --git a/Sources/LoopAlgorithm/Glucose/GlucoseChange.swift b/Sources/LoopAlgorithm/Glucose/GlucoseChange.swift index ce49794..9a2ea56 100644 --- a/Sources/LoopAlgorithm/Glucose/GlucoseChange.swift +++ b/Sources/LoopAlgorithm/Glucose/GlucoseChange.swift @@ -1,6 +1,6 @@ // // GlucoseChange.swift -// LoopKit +// LoopAlgorithm // // Copyright © 2018 LoopKit Authors. All rights reserved. // diff --git a/Sources/LoopAlgorithm/Glucose/GlucoseSampleValue.swift b/Sources/LoopAlgorithm/Glucose/GlucoseSampleValue.swift index 0575d72..111411d 100644 --- a/Sources/LoopAlgorithm/Glucose/GlucoseSampleValue.swift +++ b/Sources/LoopAlgorithm/Glucose/GlucoseSampleValue.swift @@ -1,6 +1,6 @@ // // GlucoseSampleValue.swift -// LoopKit +// LoopAlgorithm // // Created by Nathan Racklyeft on 3/6/16. // Copyright © 2016 Nathan Racklyeft. All rights reserved. diff --git a/Sources/LoopAlgorithm/Insulin/DoseType.swift b/Sources/LoopAlgorithm/Insulin/DoseType.swift index 27a460a..5c26224 100644 --- a/Sources/LoopAlgorithm/Insulin/DoseType.swift +++ b/Sources/LoopAlgorithm/Insulin/DoseType.swift @@ -1,6 +1,6 @@ // // DoseType.swift -// LoopKit +// LoopAlgorithm // // Copyright © 2017 LoopKit Authors. All rights reserved. // diff --git a/Sources/LoopAlgorithm/Insulin/DoseUnit.swift b/Sources/LoopAlgorithm/Insulin/DoseUnit.swift index b15eca6..2f4b0f9 100644 --- a/Sources/LoopAlgorithm/Insulin/DoseUnit.swift +++ b/Sources/LoopAlgorithm/Insulin/DoseUnit.swift @@ -1,6 +1,6 @@ // // DoseUnit.swift -// LoopKit +// LoopAlgorithm // // Created by Nathan Racklyeft on 3/28/16. // Copyright © 2016 Nathan Racklyeft. All rights reserved. diff --git a/Sources/LoopAlgorithm/Insulin/ExponentialInsulinModelPreset.swift b/Sources/LoopAlgorithm/Insulin/ExponentialInsulinModelPreset.swift index 83086ab..2a17926 100644 --- a/Sources/LoopAlgorithm/Insulin/ExponentialInsulinModelPreset.swift +++ b/Sources/LoopAlgorithm/Insulin/ExponentialInsulinModelPreset.swift @@ -1,6 +1,6 @@ // // ExponentialInsulinModelPreset.swift -// LoopKit +// LoopAlgorithm // // Copyright © 2017 LoopKit Authors. All rights reserved. // diff --git a/Sources/LoopAlgorithm/Insulin/InsulinModel.swift b/Sources/LoopAlgorithm/Insulin/InsulinModel.swift index 26a1d07..deb62f7 100644 --- a/Sources/LoopAlgorithm/Insulin/InsulinModel.swift +++ b/Sources/LoopAlgorithm/Insulin/InsulinModel.swift @@ -1,6 +1,6 @@ // // InsulinModel.swift -// LoopKit +// LoopAlgorithm // // Created by Pete Schwamb on 7/26/17. // Copyright © 2017 LoopKit Authors. All rights reserved. diff --git a/Sources/LoopAlgorithm/Insulin/InsulinType.swift b/Sources/LoopAlgorithm/Insulin/InsulinType.swift index 94fb93d..59f0e4b 100644 --- a/Sources/LoopAlgorithm/Insulin/InsulinType.swift +++ b/Sources/LoopAlgorithm/Insulin/InsulinType.swift @@ -1,6 +1,6 @@ // // InsulinType.swift -// LoopKit +// LoopAlgorithm // // Created by Anna Quinlan on 12/8/20. // Copyright © 2020 LoopKit Authors. All rights reserved. diff --git a/Sources/LoopAlgorithm/Insulin/InsulinValue.swift b/Sources/LoopAlgorithm/Insulin/InsulinValue.swift index ee97b5c..c8c665c 100644 --- a/Sources/LoopAlgorithm/Insulin/InsulinValue.swift +++ b/Sources/LoopAlgorithm/Insulin/InsulinValue.swift @@ -1,6 +1,6 @@ // // InsulinValue.swift -// LoopKit +// LoopAlgorithm // // Created by Nathan Racklyeft on 4/3/16. // Copyright © 2016 Nathan Racklyeft. All rights reserved. diff --git a/Sources/LoopAlgorithm/LoopAlgorithmDoseRecommendation.swift b/Sources/LoopAlgorithm/LoopAlgorithmDoseRecommendation.swift index 56734dd..adafa97 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithmDoseRecommendation.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithmDoseRecommendation.swift @@ -1,6 +1,6 @@ // // LoopAlgorithmDoseRecommendation.swift -// LoopKit +// LoopAlgorithm // // Created by Pete Schwamb on 10/11/23. // Copyright © 2023 LoopKit Authors. All rights reserved. diff --git a/Sources/LoopAlgorithm/LoopAlgorithmInput.swift b/Sources/LoopAlgorithm/LoopAlgorithmInput.swift index 3e77163..bbc0465 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithmInput.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithmInput.swift @@ -1,6 +1,6 @@ // // LoopAlgorithmInput.swift -// LoopKit +// LoopAlgorithm // // Created by Pete Schwamb on 9/21/23. // Copyright © 2023 LoopKit Authors. All rights reserved. diff --git a/Sources/LoopAlgorithm/ManualBolusRecommendation.swift b/Sources/LoopAlgorithm/ManualBolusRecommendation.swift index ff5c58b..68d80de 100644 --- a/Sources/LoopAlgorithm/ManualBolusRecommendation.swift +++ b/Sources/LoopAlgorithm/ManualBolusRecommendation.swift @@ -1,6 +1,6 @@ // // ManualBolusRecommendation.swift -// LoopKit +// LoopAlgorithm // // Created by Pete Schwamb on 1/2/17. // Copyright © 2017 LoopKit Authors. All rights reserved. diff --git a/Sources/LoopAlgorithm/TempBasalRecommendation.swift b/Sources/LoopAlgorithm/TempBasalRecommendation.swift index d1bac6f..b5c5971 100644 --- a/Sources/LoopAlgorithm/TempBasalRecommendation.swift +++ b/Sources/LoopAlgorithm/TempBasalRecommendation.swift @@ -1,6 +1,6 @@ // // TempBasalRecommendation.swift -// LoopKit +// LoopAlgorithm // // Created by Darin Krauss on 5/21/19. // Copyright © 2019 LoopKit Authors. All rights reserved. diff --git a/Tests/LoopAlgorithmTests/Extensions/TimeZone.swift b/Tests/LoopAlgorithmTests/Extensions/TimeZone.swift index 4b912b9..0205875 100644 --- a/Tests/LoopAlgorithmTests/Extensions/TimeZone.swift +++ b/Tests/LoopAlgorithmTests/Extensions/TimeZone.swift @@ -1,6 +1,6 @@ // // TimeZone.swift -// LoopKit +// LoopAlgorithm // // Created by Nate Racklyeft on 10/2/16. // Copyright © 2016 LoopKit Authors. All rights reserved. diff --git a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift index 8eea386..33fa485 100644 --- a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift +++ b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift @@ -1,6 +1,6 @@ // // LoopAlgorithmTests.swift -// LoopKitTests +// LoopAlgorithmTests // // Created by Pete Schwamb on 10/18/23. // Copyright © 2023 LoopKit Authors. All rights reserved. From 3ddff2aaa1aa1003105d5f5dfc9d66f0812e380b Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 13 Feb 2024 11:51:33 -0600 Subject: [PATCH 17/26] Rename totalGlucoseCorrectionEffect to indicate it is the effect for retrospective correction, and guard against future basal when doing automatic dosing --- Sources/LoopAlgorithm/Carbs/CarbMath.swift | 9 --------- .../Insulin/RelativeDelivery.swift | 2 -- Sources/LoopAlgorithm/LoopAlgorithm.swift | 20 +++++++++++++------ .../IntegralRetrospectiveCorrection.swift | 2 +- 4 files changed, 15 insertions(+), 18 deletions(-) diff --git a/Sources/LoopAlgorithm/Carbs/CarbMath.swift b/Sources/LoopAlgorithm/Carbs/CarbMath.swift index 6923c25..45d01d1 100644 --- a/Sources/LoopAlgorithm/Carbs/CarbMath.swift +++ b/Sources/LoopAlgorithm/Carbs/CarbMath.swift @@ -386,15 +386,6 @@ extension Collection { } let csf = isf.value.doubleValue(for: mgdL) / cr.value - let val = entry.dynamicAbsorbedCarbs( - at: date, - absorptionTime: entry.absorptionTime ?? defaultAbsorptionTime, - delay: delay, - delta: delta, - absorptionModel: absorptionModel - ) - print("csf @\(date) = \(isf.value.doubleValue(for: mgdL)) / \(cr.value) = \(csf), val = \(val), \(entry.quantity.doubleValue(for: .gram()))g") - return value + csf * entry.dynamicAbsorbedCarbs( at: date, absorptionTime: entry.absorptionTime ?? defaultAbsorptionTime, diff --git a/Sources/LoopAlgorithm/Insulin/RelativeDelivery.swift b/Sources/LoopAlgorithm/Insulin/RelativeDelivery.swift index 492f75e..9515b58 100644 --- a/Sources/LoopAlgorithm/Insulin/RelativeDelivery.swift +++ b/Sources/LoopAlgorithm/Insulin/RelativeDelivery.swift @@ -47,8 +47,6 @@ extension BasalRelativeDose { return volume } } - - } extension BasalRelativeDose { diff --git a/Sources/LoopAlgorithm/LoopAlgorithm.swift b/Sources/LoopAlgorithm/LoopAlgorithm.swift index f90c336..8155f69 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithm.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithm.swift @@ -14,6 +14,7 @@ public enum AlgorithmError: Error { case basalTimelineIncomplete case missingSuspendThreshold case sensitivityTimelineIncomplete + case futureBasalNotAllowed } public struct LoopAlgorithmEffects { @@ -24,7 +25,7 @@ public struct LoopAlgorithmEffects { public var momentum: [GlucoseEffect] public var insulinCounteraction: [GlucoseEffectVelocity] public var retrospectiveGlucoseDiscrepancies: [GlucoseChange] - public var totalGlucoseCorrectionEffect: HKQuantity? + public var totalRetrospectiveCorrectionEffect: HKQuantity? public init( insulin: [GlucoseEffect], @@ -34,7 +35,7 @@ public struct LoopAlgorithmEffects { momentum: [GlucoseEffect], insulinCounteraction: [GlucoseEffectVelocity], retrospectiveGlucoseDiscrepancies: [GlucoseChange], - totalGlucoseCorrectionEffect: HKQuantity? = nil + totalRetrospectiveCorrectionEffect: HKQuantity? = nil ) { self.insulin = insulin self.carbs = carbs @@ -43,7 +44,7 @@ public struct LoopAlgorithmEffects { self.momentum = momentum self.insulinCounteraction = insulinCounteraction self.retrospectiveGlucoseDiscrepancies = retrospectiveGlucoseDiscrepancies - self.totalGlucoseCorrectionEffect = totalGlucoseCorrectionEffect + self.totalRetrospectiveCorrectionEffect = totalRetrospectiveCorrectionEffect } } @@ -123,7 +124,7 @@ public struct LoopAlgorithm { var momentumEffects: [GlucoseEffect] = [] var insulinCounteractionEffects: [GlucoseEffectVelocity] = [] var retrospectiveGlucoseDiscrepanciesSummed: [GlucoseChange] = [] - var totalGlucoseCorrectionEffect: HKQuantity? + var totalRetrospectiveCorrectionEffect: HKQuantity? var activeInsulin: Double? var activeCarbs: Double? //var carbStatus: [CarbStatus] = [] @@ -209,7 +210,7 @@ public struct LoopAlgorithm { retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval ) - totalGlucoseCorrectionEffect = rc.totalGlucoseCorrectionEffect + totalRetrospectiveCorrectionEffect = rc.totalGlucoseCorrectionEffect var effects = [[GlucoseEffect]]() @@ -266,7 +267,7 @@ public struct LoopAlgorithm { momentum: momentumEffects, insulinCounteraction: insulinCounteractionEffects, retrospectiveGlucoseDiscrepancies: retrospectiveGlucoseDiscrepanciesSummed, - totalGlucoseCorrectionEffect: totalGlucoseCorrectionEffect + totalRetrospectiveCorrectionEffect: totalRetrospectiveCorrectionEffect ), dosesRelativeToBasal: dosesRelativeToBasal, activeInsulin: activeInsulin, @@ -437,6 +438,13 @@ public struct LoopAlgorithm { throw AlgorithmError.glucoseTooOld } + // When running the algorithm for automated dosing, future basal should not be included + if let basalEnd = input.doses.filter({ $0.deliveryType == .basal }).map({ $0.endDate }).max() { + guard !input.recommendationType.automated || basalEnd <= input.predictionStart else { + throw AlgorithmError.futureBasalNotAllowed + } + } + let forecastEnd = input.predictionStart.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration) guard let sensitivityEndDate = input.sensitivity.last?.endDate, sensitivityEndDate >= forecastEnd else { diff --git a/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift b/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift index f23b7f8..87054db 100644 --- a/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift +++ b/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift @@ -189,7 +189,7 @@ public class IntegralRetrospectiveCorrection: RetrospectiveCorrection { "proportionalCorrection [mg/dL]: \(proportionalCorrection)", "integralCorrection [mg/dL]: \(integralCorrection)", "differentialCorrection [mg/dL]: \(differentialCorrection)", - "totalGlucoseCorrectionEffect: \(String(describing: totalGlucoseCorrectionEffect))", + "totalRetrospectiveCorrectionEffect: \(String(describing: totalGlucoseCorrectionEffect))", "integralCorrectionEffectDuration [min]: \(String(describing: integralCorrectionEffectDuration?.minutes))" ] From 4f7bf03d0a2bc5fdc4e8e26e35c943f8e2d6980e Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Tue, 20 Feb 2024 17:32:25 -0600 Subject: [PATCH 18/26] Adding tests from Loop that were focused on LoopAlgorithm --- Sources/LoopAlgorithm/Insulin/DoseMath.swift | 11 +- Sources/LoopAlgorithm/LoopAlgorithm.swift | 10 +- .../carbs_with_isf_change_recommendation.json | 2 +- .../LoopAlgorithmTests.swift | 130 ++++++++++++++++-- .../Mocks/LoopAlgorithmInputMock.swift | 16 +-- 5 files changed, 147 insertions(+), 22 deletions(-) diff --git a/Sources/LoopAlgorithm/Insulin/DoseMath.swift b/Sources/LoopAlgorithm/Insulin/DoseMath.swift index ea73de8..514857d 100644 --- a/Sources/LoopAlgorithm/Insulin/DoseMath.swift +++ b/Sources/LoopAlgorithm/Insulin/DoseMath.swift @@ -158,10 +158,10 @@ extension Array where Element: GlucoseValue { /// `date` to correct the predicted glucose to the middle of `correctionRange` at the time of prediction. /// /// - Parameters: - /// - correctionRange: The timeline of glucose ranges used for correction + /// - correctionRange: The timeline of glucose ranges used for correction. Must cover the range of prediction timestamp contained in this array. /// - date: The date the insulin correction is delivered /// - suspendThreshold: The glucose value below which only suspension is returned - /// - insulinSensitivityTimeline: The timeline of expected insulin sensitivity over the period of dose absorption + /// - insulinSensitivityTimeline: The timeline of expected insulin sensitivity over the period of dose absorption. Must cover the range of prediction timestamp contained in this array. /// - model: The insulin effect model /// - Returns: A correction value in units, or nil if no correction needed func insulinCorrection( @@ -224,13 +224,20 @@ extension Array where Element: GlucoseValue { let isfSegments = insulinSensitivity.filterDateRange(date, prediction.startDate) + var isfEnd: TimeInterval? + let effectedSensitivity = isfSegments.reduce(0) { partialResult, segment in let start = Swift.max(date, segment.startDate).timeIntervalSince(date) let end = Swift.min(prediction.startDate, segment.endDate).timeIntervalSince(date) let percentEffected = model.percentEffectRemaining(at: start) - model.percentEffectRemaining(at: end) + isfEnd = end return percentEffected * segment.value.doubleValue(for: unit) } + guard let isfEnd, isfEnd >= prediction.startDate.timeIntervalSince(date) else { + preconditionFailure("Sensitivity timeline must cover date: \(prediction.startDate)") + } + // Update range statistics if minGlucose == nil || prediction.quantity < minGlucose!.quantity { minGlucose = prediction diff --git a/Sources/LoopAlgorithm/LoopAlgorithm.swift b/Sources/LoopAlgorithm/LoopAlgorithm.swift index 8155f69..9eea57d 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithm.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithm.swift @@ -461,14 +461,20 @@ public struct LoopAlgorithm { // TODO: This is to be removed when implementing mid-absorption ISF changes // This sets a single ISF value for the duration of the dose. - let correctionSensitivity = [input.sensitivity.first { $0.startDate <= input.predictionStart && $0.endDate >= input.predictionStart }!] + let sensitivityAtPredictionStart = input.sensitivity.first { $0.startDate <= input.predictionStart && $0.endDate >= input.predictionStart }! + let sensitivityOverPrediction = AbsoluteScheduleValue( + startDate: sensitivityAtPredictionStart.startDate, + endDate: forecastEnd, + value: sensitivityAtPredictionStart.value + ) + let sensitivityForDosing = [sensitivityOverPrediction] let correction = insulinCorrection( prediction: prediction.glucose, at: input.predictionStart, target: input.target, suspendThreshold: suspendThreshold, - sensitivity: correctionSensitivity, + sensitivity: sensitivityForDosing, insulinType: input.recommendationInsulinType) switch input.recommendationType { diff --git a/Tests/LoopAlgorithmTests/Fixtures/carbs_with_isf_change_recommendation.json b/Tests/LoopAlgorithmTests/Fixtures/carbs_with_isf_change_recommendation.json index 4166f01..b4e70af 100644 --- a/Tests/LoopAlgorithmTests/Fixtures/carbs_with_isf_change_recommendation.json +++ b/Tests/LoopAlgorithmTests/Fixtures/carbs_with_isf_change_recommendation.json @@ -1,5 +1,5 @@ { "manual" : { - "amount" : 12.0 + "amount" : 10.546890782709953 } } diff --git a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift index 33fa485..62902a4 100644 --- a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift +++ b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift @@ -32,6 +32,14 @@ final class LoopAlgorithmTests: XCTestCase { XCTAssertEqual(output.recommendation, recommendation) } + func loadPredictedGlucoseFixture(_ name: String) -> [PredictedGlucoseValue] { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let url = Bundle.module.url(forResource: name, withExtension: "json", subdirectory: "Fixtures")! + return try! decoder.decode([PredictedGlucoseValue].self, from: try! Data(contentsOf: url)) + } + func testCarbsWithSensitivityChange() throws { // This test computes a dose with a future carb entry @@ -71,18 +79,19 @@ final class LoopAlgorithmTests: XCTestCase { XCTAssertEqual(outputA.activeCarbs, outputB.activeCarbs) XCTAssertEqual(outputA.activeInsulin, outputB.activeInsulin) - XCTAssertEqual(outputA.effects.carbs.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 190.0) - XCTAssertEqual(outputB.effects.carbs.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 190.0) + XCTAssertEqual(outputA.effects.carbs.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 55.0) + XCTAssertEqual(outputB.effects.carbs.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 55.0) XCTAssertEqual(outputA.effects.insulin.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 0.0) XCTAssertEqual(outputB.effects.insulin.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 0.0) - XCTAssertEqual(outputA.effects.momentum.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 0.0) - XCTAssertEqual(outputB.effects.momentum.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 0.0) + XCTAssertEqual(outputA.effects.retrospectiveCorrection.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 165) + XCTAssertEqual(outputB.effects.retrospectiveCorrection.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 165) - // TODO: -// XCTAssertEqual(outputA.effects.retrospectiveCorrection.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 0) -// XCTAssertEqual(outputB.effects.retrospectiveCorrection.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 0) + // Even though all the input data is the same (just shifted in time), momentum effect varies in relation to how offset + // the glucose samples are from the simulation timeline (at exact 5 minute intervals from the top of the hour) +// XCTAssertEqual(outputA.effects.momentum.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 0.0) +// XCTAssertEqual(outputB.effects.momentum.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 0.0) // // XCTAssertEqual(outputA.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 283.7, accuracy: 0.01) // XCTAssertEqual(outputB.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 283.7, accuracy: 0.01) @@ -114,13 +123,116 @@ final class LoopAlgorithmTests: XCTestCase { let output = LoopAlgorithm.run(input: input) let carbStatus = output.effects.carbStatus.first! - XCTAssertEqual(carbStatus.absorption!.observedProgress.doubleValue(for: .percent()), 0.11, accuracy: 0.01) + XCTAssertEqual(carbStatus.absorption!.observedProgress.doubleValue(for: .percent()), 0.36, accuracy: 0.01) XCTAssert(carbStatus.absorption!.isActive) let basalAdjustment = output.recommendation!.automatic!.basalAdjustment - XCTAssertEqual(basalAdjustment!.unitsPerHour, 5.06, accuracy: 0.01) + XCTAssertEqual(basalAdjustment!.unitsPerHour, 5.83, accuracy: 0.01) + } + + func testLiveCaptureWithFunctionalAlgorithm() { + // This matches the "testForecastFromLiveCaptureInputData" test of LoopDataManagerDosingTests, + // Using the same input data, but generating the forecast using the LoopAlgorithm.generatePrediction() + // function. + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + 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( + start: input.glucoseHistory.last?.startDate ?? Date(), + glucoseHistory: input.glucoseHistory, + doses: input.doses, + carbEntries: input.carbEntries, + basal: input.basal, + sensitivity: input.sensitivity, + carbRatio: input.carbRatio, + useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection + ) + + let expectedPredictedGlucose = loadPredictedGlucoseFixture("live_capture_predicted_glucose") + + XCTAssertEqual(expectedPredictedGlucose.count, prediction.glucose.count) + + let defaultAccuracy = 1.0 / 40.0 + + for (expected, calculated) in zip(expectedPredictedGlucose, prediction.glucose) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: .milligramsPerDeciliter), calculated.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: defaultAccuracy) + } + } + + func testAutoBolusMaxIOBClamping() async { + let now = ISO8601DateFormatter().date(from: "2020-03-11T12:13:14-0700")! + + var input = LoopAlgorithmInputFixture.mock(for: now) + input.recommendationType = .automaticBolus + + // 8U bolus on board, and 100g carbs; CR = 10, so that should be 10U to cover the carbs + input.doses = [FixtureInsulinDose( + deliveryType: .bolus, + startDate: now.addingTimeInterval(-.minutes(5)), + endDate: now.addingTimeInterval(-.minutes(4)), + volume: 8 + )] + input.carbEntries = [ + FixtureCarbEntry(startDate: now.addingTimeInterval(.minutes(-5)), quantity: .carbs(value: 100)) + ] + + // Max activeInsulin = 2 x maxBolus = 16U + input.maxBolus = 8 + var output = LoopAlgorithm.run(input: input) + var recommendedBolus = output.recommendation!.automatic?.bolusUnits + var activeInsulin = output.activeInsulin! + XCTAssertEqual(activeInsulin, 8.0) + XCTAssertEqual(recommendedBolus!, 1.66, accuracy: 0.01) + + // 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) + recommendedBolus = output.recommendation!.automatic?.bolusUnits + activeInsulin = output.activeInsulin! + XCTAssertEqual(activeInsulin, 8.0) + XCTAssertEqual(recommendedBolus!, 0, accuracy: 0.01) + } + + func testTempBasalMaxIOBClamping() { + let now = ISO8601DateFormatter().date(from: "2020-03-11T12:13:14-0700")! + + var input = LoopAlgorithmInput.mock(for: now) + input.recommendationType = .tempBasal + + // 8U bolus on board, and 100g carbs; CR = 10, so that should be 10U to cover the carbs + input.doses = [FixtureInsulinDose( + deliveryType: .bolus, + startDate: now.addingTimeInterval(-.minutes(5)), + endDate: now.addingTimeInterval(-.minutes(4)), + volume: 8 + )] + + input.carbEntries = [ + FixtureCarbEntry(startDate: now.addingTimeInterval(.minutes(-5)), quantity: .carbs(value: 100)) + ] + + // Max activeInsulin = 2 x maxBolus = 16U + input.maxBolus = 8 + var output = LoopAlgorithm.run(input: input) + var recommendedRate = output.recommendation!.automatic!.basalAdjustment!.unitsPerHour + var activeInsulin = output.activeInsulin! + XCTAssertEqual(activeInsulin, 8.0) + XCTAssertEqual(recommendedRate, 8.0, accuracy: 0.01) + + // 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) + recommendedRate = output.recommendation!.automatic!.basalAdjustment!.unitsPerHour + activeInsulin = output.activeInsulin! + XCTAssertEqual(activeInsulin, 8.0) + XCTAssertEqual(recommendedRate, 1.0, accuracy: 0.01) } } diff --git a/Tests/LoopAlgorithmTests/Mocks/LoopAlgorithmInputMock.swift b/Tests/LoopAlgorithmTests/Mocks/LoopAlgorithmInputMock.swift index 97c665a..785fd62 100644 --- a/Tests/LoopAlgorithmTests/Mocks/LoopAlgorithmInputMock.swift +++ b/Tests/LoopAlgorithmTests/Mocks/LoopAlgorithmInputMock.swift @@ -11,7 +11,7 @@ import HealthKit public typealias LoopAlgorithmInputFixture = LoopAlgorithmInput -extension LoopAlgorithmInput { +extension LoopAlgorithmInputFixture { /// Mocks stable, in range glucose, no insulin, no carbs, with reasonable settings static func mock(for now: Date = Date()) -> LoopAlgorithmInputFixture { @@ -22,20 +22,20 @@ extension LoopAlgorithmInput { return LoopAlgorithmInputFixture( predictionStart: now, glucoseHistory: [ - FixtureGlucoseSample(startDate: d(.minutes(-18)), quantity: .glucose(value: 105)), - FixtureGlucoseSample(startDate: d(.minutes(-13)), quantity: .glucose(value: 105)), - FixtureGlucoseSample(startDate: d(.minutes(-8)), quantity: .glucose(value: 105)), - FixtureGlucoseSample(startDate: d(.minutes(-3)), quantity: .glucose(value: 105)), + FixtureGlucoseSample(startDate: d(.minutes(-19)), quantity: .glucose(value: 100)), + FixtureGlucoseSample(startDate: d(.minutes(-14)), quantity: .glucose(value: 120)), + FixtureGlucoseSample(startDate: d(.minutes(-9)), quantity: .glucose(value: 140)), + FixtureGlucoseSample(startDate: d(.minutes(-4)), quantity: .glucose(value: 160)), ], doses: [], carbEntries: [], basal: [AbsoluteScheduleValue(startDate: d(.hours(-10)), endDate: now, value: 1.0)], - sensitivity: [AbsoluteScheduleValue(startDate: d(.hours(-10)), endDate: now.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration), value: .glucose(value: 190))], + sensitivity: [AbsoluteScheduleValue(startDate: d(.hours(-10)), endDate: now.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration), value: .glucose(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(value: 100), upper: .glucose(value: 110))))], - suspendThreshold: .glucose(value: 70), + suspendThreshold: .glucose(value: 65), maxBolus: 6, - maxBasalRate: 9, + maxBasalRate: 8, recommendationInsulinType: .novolog, recommendationType: .tempBasal ) From f9945ec8a0169be67bdbaf52da4eb5b03f28e278 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 21 Feb 2024 14:34:58 -0600 Subject: [PATCH 19/26] Adding more for ManualBolusRecommendation --- Sources/LoopAlgorithm/Extensions/Double.swift | 21 -- .../LoopAlgorithm/Glucose/GlucoseValue.swift | 6 +- Sources/LoopAlgorithm/Insulin/DoseType.swift | 11 - Sources/LoopAlgorithm/LoopAlgorithm.swift | 3 +- .../ManualBolusRecommendation.swift | 10 +- .../IntegralRetrospectiveCorrection.swift | 36 +-- .../RetrospectiveCorrection.swift | 4 +- .../StandardRetrospectiveCorrection.swift | 11 - .../TempBasalRecommendation.swift | 9 - .../Extensions/DateFormatter.swift | 2 +- .../Extensions/TimeZone.swift | 16 - .../Fixtures/iob_timeline.json | 306 ++++++++++++++++++ .../LoopAlgorithmTests/InsulinMathTests.swift | 16 + .../LoopAlgorithmTests.swift | 6 +- .../ManualBolusRecommendationTests.swift | 163 ++++++++++ 15 files changed, 499 insertions(+), 121 deletions(-) delete mode 100644 Sources/LoopAlgorithm/Extensions/Double.swift delete mode 100644 Tests/LoopAlgorithmTests/Extensions/TimeZone.swift create mode 100644 Tests/LoopAlgorithmTests/Fixtures/iob_timeline.json create mode 100644 Tests/LoopAlgorithmTests/ManualBolusRecommendationTests.swift diff --git a/Sources/LoopAlgorithm/Extensions/Double.swift b/Sources/LoopAlgorithm/Extensions/Double.swift deleted file mode 100644 index 5075c45..0000000 --- a/Sources/LoopAlgorithm/Extensions/Double.swift +++ /dev/null @@ -1,21 +0,0 @@ -// -// Double.swift -// Naterade -// -// Created by Nathan Racklyeft on 2/12/16. -// Copyright © 2016 Nathan Racklyeft. All rights reserved. -// - -import Foundation - -extension Double: RawRepresentable { - public typealias RawValue = Double - - public init?(rawValue: RawValue) { - self = rawValue - } - - public var rawValue: RawValue { - return self - } -} diff --git a/Sources/LoopAlgorithm/Glucose/GlucoseValue.swift b/Sources/LoopAlgorithm/Glucose/GlucoseValue.swift index dd01a80..dcd36df 100644 --- a/Sources/LoopAlgorithm/Glucose/GlucoseValue.swift +++ b/Sources/LoopAlgorithm/Glucose/GlucoseValue.swift @@ -34,7 +34,7 @@ extension SimpleGlucoseValue: 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.endDate = try container.decode(Date.self, forKey: .endDate) + self.endDate = try container.decodeIfPresent(Date.self, forKey: .endDate) ?? self.startDate self.quantity = HKQuantity(unit: HKUnit(from: try container.decode(String.self, forKey: .quantityUnit)), doubleValue: try container.decode(Double.self, forKey: .quantity)) } @@ -42,7 +42,9 @@ extension SimpleGlucoseValue: Codable { 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) + if endDate != startDate { + try container.encode(endDate, forKey: .endDate) + } try container.encode(quantity.doubleValue(for: .milligramsPerDeciliter), forKey: .quantity) try container.encode(HKUnit.milligramsPerDeciliter.unitString, forKey: .quantityUnit) } diff --git a/Sources/LoopAlgorithm/Insulin/DoseType.swift b/Sources/LoopAlgorithm/Insulin/DoseType.swift index 5c26224..faa7d53 100644 --- a/Sources/LoopAlgorithm/Insulin/DoseType.swift +++ b/Sources/LoopAlgorithm/Insulin/DoseType.swift @@ -12,17 +12,6 @@ import Foundation public enum InsulinDeliveryType: String, CaseIterable, Equatable { case bolus case basal - - init?(fixtureValue: String) { - switch fixtureValue { - case "TempBasal": - self = .basal - case "Bolus": - self = .bolus - default: - return nil - } - } } extension InsulinDeliveryType: Codable {} diff --git a/Sources/LoopAlgorithm/LoopAlgorithm.swift b/Sources/LoopAlgorithm/LoopAlgorithm.swift index 9eea57d..9069104 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithm.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithm.swift @@ -461,10 +461,11 @@ public struct LoopAlgorithm { // TODO: This is to be removed when implementing mid-absorption ISF changes // This sets a single ISF value for the duration of the dose. + let sensitivityEnd = max(forecastEnd, prediction.effects.insulin.last?.startDate ?? .distantPast) let sensitivityAtPredictionStart = input.sensitivity.first { $0.startDate <= input.predictionStart && $0.endDate >= input.predictionStart }! let sensitivityOverPrediction = AbsoluteScheduleValue( startDate: sensitivityAtPredictionStart.startDate, - endDate: forecastEnd, + endDate: sensitivityEnd, value: sensitivityAtPredictionStart.value ) let sensitivityForDosing = [sensitivityOverPrediction] diff --git a/Sources/LoopAlgorithm/ManualBolusRecommendation.swift b/Sources/LoopAlgorithm/ManualBolusRecommendation.swift index 68d80de..46cb0b9 100644 --- a/Sources/LoopAlgorithm/ManualBolusRecommendation.swift +++ b/Sources/LoopAlgorithm/ManualBolusRecommendation.swift @@ -91,10 +91,6 @@ public struct ManualBolusRecommendation { public var amount: Double public var notice: BolusRecommendationNotice? - public var quantity: HKQuantity { - return HKQuantity(unit: .internationalUnit(), doubleValue: amount) - } - public init(amount: Double, notice: BolusRecommendationNotice? = nil) { self.amount = amount self.notice = notice @@ -103,12 +99,8 @@ public struct ManualBolusRecommendation { extension ManualBolusRecommendation: Codable {} -extension ManualBolusRecommendation: Comparable { +extension ManualBolusRecommendation: Equatable { public static func ==(lhs: ManualBolusRecommendation, rhs: ManualBolusRecommendation) -> Bool { return lhs.amount == rhs.amount } - - public static func <(lhs: ManualBolusRecommendation, rhs: ManualBolusRecommendation) -> Bool { - return lhs.amount < rhs.amount - } } diff --git a/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift b/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift index 87054db..50a33fa 100644 --- a/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift +++ b/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift @@ -16,7 +16,7 @@ import HealthKit */ public class IntegralRetrospectiveCorrection: RetrospectiveCorrection { - public static let retrospectionInterval = TimeInterval(minutes: 180) + static let retrospectionInterval = TimeInterval(minutes: 180) /// RetrospectiveCorrection protocol variables /// Standard effect duration @@ -57,8 +57,7 @@ public class IntegralRetrospectiveCorrection: RetrospectiveCorrection { var integralCorrection: Double = 0.0 var differentialCorrection: Double = 0.0 var currentDate: Date = Date() - var ircStatus: String = "-" - + public init(effectDuration: TimeInterval) { self.effectDuration = effectDuration } @@ -90,13 +89,11 @@ public class IntegralRetrospectiveCorrection: RetrospectiveCorrection { guard let currentDiscrepancy = retrospectiveGlucoseDiscrepanciesSummed?.last, glucoseDate.timeIntervalSince(currentDiscrepancy.endDate) <= recencyInterval else { - ircStatus = "discrepancy not available, effect not computed." totalGlucoseCorrectionEffect = nil return( [] ) } // Default values if we are not able to calculate integral retrospective correction - ircStatus = "defaulted to standard RC, past discrepancies or user settings not available." let currentDiscrepancyValue = currentDiscrepancy.quantity.doubleValue(for: unit) var scaledCorrection = currentDiscrepancyValue totalGlucoseCorrectionEffect = HKQuantity(unit: unit, doubleValue: currentDiscrepancyValue) @@ -104,8 +101,7 @@ public class IntegralRetrospectiveCorrection: RetrospectiveCorrection { // Calculate integral retrospective correction if past discrepancies over integration interval are available and if user settings are available if let pastDiscrepancies = retrospectiveGlucoseDiscrepanciesSummed?.filterDateRange(glucoseDate.addingTimeInterval(-Self.retrospectionInterval), glucoseDate) { - ircStatus = "effect computed successfully." - + // To reduce response delay, integral retrospective correction is computed over an array of recent contiguous discrepancy values having the same sign as the latest discrepancy value recentDiscrepancyValues = [] var nextDiscrepancy = currentDiscrepancy @@ -171,29 +167,5 @@ public class IntegralRetrospectiveCorrection: RetrospectiveCorrection { // Return glucose correction effects return( glucoseCorrectionEffect ) } - - public var debugDescription: String { - let report: [String] = [ - "## IntegralRetrospectiveCorrection", - "", - "Last updated: \(currentDate)", - "Status: \(ircStatus)", - "currentDiscrepancyGain: \(IntegralRetrospectiveCorrection.currentDiscrepancyGain)", - "persistentDiscrepancyGain: \(IntegralRetrospectiveCorrection.persistentDiscrepancyGain)", - "correctionTimeConstant [min]: \(IntegralRetrospectiveCorrection.correctionTimeConstant.minutes)", - "proportionalGain: \(IntegralRetrospectiveCorrection.proportionalGain)", - "integralForget: \(IntegralRetrospectiveCorrection.integralForget)", - "integralGain: \(IntegralRetrospectiveCorrection.integralGain)", - "differentialGain: \(IntegralRetrospectiveCorrection.differentialGain)", - "Integration performed over \(recentDiscrepancyValues.count) most recent discrepancies having the same sign as the latest discrepancy value. Earliest-to-most-recent recentDiscrepancyValues [mg/dL]: \(recentDiscrepancyValues)", - "proportionalCorrection [mg/dL]: \(proportionalCorrection)", - "integralCorrection [mg/dL]: \(integralCorrection)", - "differentialCorrection [mg/dL]: \(differentialCorrection)", - "totalRetrospectiveCorrectionEffect: \(String(describing: totalGlucoseCorrectionEffect))", - "integralCorrectionEffectDuration [min]: \(String(describing: integralCorrectionEffectDuration?.minutes))" - ] - - return report.joined(separator: "\n") - } - + } diff --git a/Sources/LoopAlgorithm/RetrospectiveCorrection/RetrospectiveCorrection.swift b/Sources/LoopAlgorithm/RetrospectiveCorrection/RetrospectiveCorrection.swift index 276a86a..e2724e8 100644 --- a/Sources/LoopAlgorithm/RetrospectiveCorrection/RetrospectiveCorrection.swift +++ b/Sources/LoopAlgorithm/RetrospectiveCorrection/RetrospectiveCorrection.swift @@ -10,9 +10,7 @@ import HealthKit /// Derives a continued glucose effect from recent prediction discrepancies -public protocol RetrospectiveCorrection: CustomDebugStringConvertible { - /// The maximum interval of historical glucose discrepancies that should be provided to the computation - static var retrospectionInterval: TimeInterval { get } +public protocol RetrospectiveCorrection { /// Overall retrospective correction effect var totalGlucoseCorrectionEffect: HKQuantity? { get } diff --git a/Sources/LoopAlgorithm/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift b/Sources/LoopAlgorithm/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift index 3ee70fe..6809ef7 100644 --- a/Sources/LoopAlgorithm/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift +++ b/Sources/LoopAlgorithm/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift @@ -15,8 +15,6 @@ import HealthKit 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 { - public static let retrospectionInterval = TimeInterval(minutes: 30) - /// RetrospectiveCorrection protocol variables /// Standard effect duration let effectDuration: TimeInterval @@ -56,13 +54,4 @@ public class StandardRetrospectiveCorrection: RetrospectiveCorrection { // Update array of glucose correction effects return startingGlucose.decayEffect(atRate: velocity, for: effectDuration) } - - public var debugDescription: String { - let report: [String] = [ - "## StandardRetrospectiveCorrection", - "" - ] - - return report.joined(separator: "\n") - } } diff --git a/Sources/LoopAlgorithm/TempBasalRecommendation.swift b/Sources/LoopAlgorithm/TempBasalRecommendation.swift index b5c5971..b77256e 100644 --- a/Sources/LoopAlgorithm/TempBasalRecommendation.swift +++ b/Sources/LoopAlgorithm/TempBasalRecommendation.swift @@ -13,15 +13,6 @@ public struct TempBasalRecommendation: Equatable { public var unitsPerHour: Double public let duration: TimeInterval - /// A special command which cancels any existing temp basals - public static var cancel: TempBasalRecommendation { - return self.init(unitsPerHour: 0, duration: 0) - } - - public var rateQuantity: HKQuantity { - return HKQuantity(unit: .internationalUnitsPerHour, doubleValue: unitsPerHour) - } - public init(unitsPerHour: Double, duration: TimeInterval) { self.unitsPerHour = unitsPerHour self.duration = duration diff --git a/Tests/LoopAlgorithmTests/Extensions/DateFormatter.swift b/Tests/LoopAlgorithmTests/Extensions/DateFormatter.swift index 7a35adc..449703f 100644 --- a/Tests/LoopAlgorithmTests/Extensions/DateFormatter.swift +++ b/Tests/LoopAlgorithmTests/Extensions/DateFormatter.swift @@ -11,7 +11,7 @@ import Foundation // MARK: - Extensions useful in parsing fixture dates extension ISO8601DateFormatter { - static func localTimeDate(timeZone: TimeZone = .currentFixed) -> Self { + static func localTimeDate(timeZone: TimeZone) -> Self { let formatter = self.init() formatter.formatOptions = .withInternetDateTime diff --git a/Tests/LoopAlgorithmTests/Extensions/TimeZone.swift b/Tests/LoopAlgorithmTests/Extensions/TimeZone.swift deleted file mode 100644 index 0205875..0000000 --- a/Tests/LoopAlgorithmTests/Extensions/TimeZone.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// TimeZone.swift -// LoopAlgorithm -// -// Created by Nate Racklyeft on 10/2/16. -// Copyright © 2016 LoopKit Authors. All rights reserved. -// - -import Foundation - - -extension TimeZone { - static var currentFixed: TimeZone { - return TimeZone(secondsFromGMT: TimeZone.current.secondsFromGMT())! - } -} diff --git a/Tests/LoopAlgorithmTests/Fixtures/iob_timeline.json b/Tests/LoopAlgorithmTests/Fixtures/iob_timeline.json new file mode 100644 index 0000000..76e39e9 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/iob_timeline.json @@ -0,0 +1,306 @@ +[ + { + "startDate" : "2015-10-15T19:00:00Z", + "value" : 2 + }, + { + "startDate" : "2015-10-15T19:05:00Z", + "value" : 2 + }, + { + "startDate" : "2015-10-15T19:10:00Z", + "value" : 2 + }, + { + "startDate" : "2015-10-15T19:15:00Z", + "value" : 1.9951801931502304 + }, + { + "startDate" : "2015-10-15T19:20:00Z", + "value" : 1.981510386792914 + }, + { + "startDate" : "2015-10-15T19:25:00Z", + "value" : 1.9600971117281178 + }, + { + "startDate" : "2015-10-15T19:30:00Z", + "value" : 1.9319493100094967 + }, + { + "startDate" : "2015-10-15T19:35:00Z", + "value" : 1.8979857158500468 + }, + { + "startDate" : "2015-10-15T19:40:00Z", + "value" : 1.8590417259461516 + }, + { + "startDate" : "2015-10-15T19:45:00Z", + "value" : 1.8158757925142 + }, + { + "startDate" : "2015-10-15T19:50:00Z", + "value" : 1.7691753702527875 + }, + { + "startDate" : "2015-10-15T19:55:00Z", + "value" : 1.7195624464883 + }, + { + "startDate" : "2015-10-15T20:00:00Z", + "value" : 1.6675986819250066 + }, + { + "startDate" : "2015-10-15T20:05:00Z", + "value" : 1.6137901876957368 + }, + { + "startDate" : "2015-10-15T20:10:00Z", + "value" : 1.5585919627890816 + }, + { + "startDate" : "2015-10-15T20:15:00Z", + "value" : 1.5024120144076845 + }, + { + "startDate" : "2015-10-15T20:20:00Z", + "value" : 1.4456151823836993 + }, + { + "startDate" : "2015-10-15T20:25:00Z", + "value" : 1.3885266874363413 + }, + { + "startDate" : "2015-10-15T20:30:00Z", + "value" : 1.3314354217974742 + }, + { + "startDate" : "2015-10-15T20:35:00Z", + "value" : 1.2745969995494661 + }, + { + "startDate" : "2015-10-15T20:40:00Z", + "value" : 1.2182365829104569 + }, + { + "startDate" : "2015-10-15T20:45:00Z", + "value" : 1.1625514996614277 + }, + { + "startDate" : "2015-10-15T20:50:00Z", + "value" : 1.1077136659329168 + }, + { + "startDate" : "2015-10-15T20:55:00Z", + "value" : 1.053871827653046 + }, + { + "startDate" : "2015-10-15T21:00:00Z", + "value" : 1.0011536330991004 + }, + { + "startDate" : "2015-10-15T21:05:00Z", + "value" : 0.9496675481888108 + }, + { + "startDate" : "2015-10-15T21:10:00Z", + "value" : 0.8995046253915229 + }, + { + "startDate" : "2015-10-15T21:15:00Z", + "value" : 0.8507401364305736 + }, + { + "startDate" : "2015-10-15T21:20:00Z", + "value" : 0.8034350782835928 + }, + { + "startDate" : "2015-10-15T21:25:00Z", + "value" : 0.7576375613644275 + }, + { + "startDate" : "2015-10-15T21:30:00Z", + "value" : 0.7133840881864251 + }, + { + "startDate" : "2015-10-15T21:35:00Z", + "value" : 0.6707007302595474 + }, + { + "startDate" : "2015-10-15T21:40:00Z", + "value" : 0.629604210460996 + }, + { + "startDate" : "2015-10-15T21:45:00Z", + "value" : 0.5901028976385798 + }, + { + "startDate" : "2015-10-15T21:50:00Z", + "value" : 0.5521977197560268 + }, + { + "startDate" : "2015-10-15T21:55:00Z", + "value" : 0.5158830014679305 + }, + { + "startDate" : "2015-10-15T22:00:00Z", + "value" : 0.4811472316173049 + }, + { + "startDate" : "2015-10-15T22:05:00Z", + "value" : 0.4479737657791627 + }, + { + "startDate" : "2015-10-15T22:10:00Z", + "value" : 0.4163414686275597 + }, + { + "startDate" : "2015-10-15T22:15:00Z", + "value" : 0.38622530057974824 + }, + { + "startDate" : "2015-10-15T22:20:00Z", + "value" : 0.3575968528680511 + }, + { + "startDate" : "2015-10-15T22:25:00Z", + "value" : 0.33042483490655594 + }, + { + "startDate" : "2015-10-15T22:30:00Z", + "value" : 0.3046755175545104 + }, + { + "startDate" : "2015-10-15T22:35:00Z", + "value" : 0.28031313563022997 + }, + { + "startDate" : "2015-10-15T22:40:00Z", + "value" : 0.25730025279738666 + }, + { + "startDate" : "2015-10-15T22:45:00Z", + "value" : 0.23559809172866686 + }, + { + "startDate" : "2015-10-15T22:50:00Z", + "value" : 0.21516683224909183 + }, + { + "startDate" : "2015-10-15T22:55:00Z", + "value" : 0.19596587997184267 + }, + { + "startDate" : "2015-10-15T23:00:00Z", + "value" : 0.17795410776243648 + }, + { + "startDate" : "2015-10-15T23:05:00Z", + "value" : 0.16109007220176408 + }, + { + "startDate" : "2015-10-15T23:10:00Z", + "value" : 0.14533220706407168 + }, + { + "startDate" : "2015-10-15T23:15:00Z", + "value" : 0.13063899568180037 + }, + { + "startDate" : "2015-10-15T23:20:00Z", + "value" : 0.11696912393460246 + }, + { + "startDate" : "2015-10-15T23:25:00Z", + "value" : 0.1042816154742443 + }, + { + "startDate" : "2015-10-15T23:30:00Z", + "value" : 0.09253595067991927 + }, + { + "startDate" : "2015-10-15T23:35:00Z", + "value" : 0.08169217072916934 + }, + { + "startDate" : "2015-10-15T23:40:00Z", + "value" : 0.07171096806767241 + }, + { + "startDate" : "2015-10-15T23:45:00Z", + "value" : 0.06255376446612293 + }, + { + "startDate" : "2015-10-15T23:50:00Z", + "value" : 0.05418277776383573 + }, + { + "startDate" : "2015-10-15T23:55:00Z", + "value" : 0.04656107831619649 + }, + { + "startDate" : "2015-10-16T00:00:00Z", + "value" : 0.03965263608617464 + }, + { + "startDate" : "2015-10-16T00:05:00Z", + "value" : 0.03342235924855608 + }, + { + "startDate" : "2015-10-16T00:10:00Z", + "value" : 0.027836125108890997 + }, + { + "startDate" : "2015-10-16T00:15:00Z", + "value" : 0.02286080407715163 + }, + { + "startDate" : "2015-10-16T00:20:00Z", + "value" : 0.01846427737840095 + }, + { + "startDate" : "2015-10-16T00:25:00Z", + "value" : 0.014615449129122826 + }, + { + "startDate" : "2015-10-16T00:30:00Z", + "value" : 0.011284253357994878 + }, + { + "startDate" : "2015-10-16T00:35:00Z", + "value" : 0.008441656503545225 + }, + { + "startDate" : "2015-10-16T00:40:00Z", + "value" : 0.0060596558780789955 + }, + { + "startDate" : "2015-10-16T00:45:00Z", + "value" : 0.004111274547295096 + }, + { + "startDate" : "2015-10-16T00:50:00Z", + "value" : 0.0025705530379138697 + }, + { + "startDate" : "2015-10-16T00:55:00Z", + "value" : 0.0014125382512175655 + }, + { + "startDate" : "2015-10-16T01:00:00Z", + "value" : 0.0006132699284966403 + }, + { + "startDate" : "2015-10-16T01:05:00Z", + "value" : 0.00014976498479968292 + }, + { + "startDate" : "2015-10-16T01:10:00Z", + "value" : 0 + }, + { + "startDate" : "2015-10-16T01:15:00Z", + "value" : 0 + } +] diff --git a/Tests/LoopAlgorithmTests/InsulinMathTests.swift b/Tests/LoopAlgorithmTests/InsulinMathTests.swift index 3127b6d..a3724eb 100644 --- a/Tests/LoopAlgorithmTests/InsulinMathTests.swift +++ b/Tests/LoopAlgorithmTests/InsulinMathTests.swift @@ -252,4 +252,20 @@ class InsulinMathTests: XCTestCase { XCTAssertEqual(expected.quantity.doubleValue(for: HKUnit.milligramsPerDeciliter), calculated.quantity.doubleValue(for: HKUnit.milligramsPerDeciliter), accuracy: 3.0) } } + + func testInsulinOnBoardTimeline() { + let start = dateFormatter.date(from: "2015-10-15T19:00:00")! + let doses = [ + BasalRelativeDose(type: .bolus, startDate: start, endDate: start.addingTimeInterval(.minutes(1)), volume: 2) + ] + let timeline = doses.insulinOnBoardTimeline() + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let url = Bundle.module.url(forResource: "iob_timeline", withExtension: "json", subdirectory: "Fixtures")! + let expectedValues = try! decoder.decode([InsulinValue].self, from: try! Data(contentsOf: url)) + + XCTAssertEqual(timeline, expectedValues) + } + } diff --git a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift index 62902a4..8aafaa5 100644 --- a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift +++ b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift @@ -132,11 +132,7 @@ final class LoopAlgorithmTests: XCTestCase { XCTAssertEqual(basalAdjustment!.unitsPerHour, 5.83, accuracy: 0.01) } - func testLiveCaptureWithFunctionalAlgorithm() { - // This matches the "testForecastFromLiveCaptureInputData" test of LoopDataManagerDosingTests, - // Using the same input data, but generating the forecast using the LoopAlgorithm.generatePrediction() - // function. - + func testLiveCapture() { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 diff --git a/Tests/LoopAlgorithmTests/ManualBolusRecommendationTests.swift b/Tests/LoopAlgorithmTests/ManualBolusRecommendationTests.swift new file mode 100644 index 0000000..5f04d5a --- /dev/null +++ b/Tests/LoopAlgorithmTests/ManualBolusRecommendationTests.swift @@ -0,0 +1,163 @@ +// +// ManualBolusRecommendationTests.swift +// +// +// Created by Pete Schwamb on 2/21/24. +// + +import Foundation + +import XCTest +@testable import LoopAlgorithm + +final class ManualBolusRecommendationTests: XCTestCase { + + let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + }() + + let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + encoder.dateEncodingStrategy = .iso8601 + return encoder + }() + + private let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() + + + func testRecommendationWithoutNoticeCodable() throws { + var recommendation = ManualBolusRecommendation(amount: 1.0, notice: nil) + let encoded = try encoder.encode(recommendation) + XCTAssertEqual( + """ + { + "amount" : 1 + } + """, + String(data: encoded , encoding: .utf8)!) + + var decoded = try decoder.decode(ManualBolusRecommendation.self, from: encoded) + } + + func testAllGlucoseBelowTargetCodable() throws { + let startDate = dateFormatter.date(from: "2015-07-13T12:02:37")! + var recommendation = ManualBolusRecommendation(amount: 0, notice: .allGlucoseBelowTarget(minGlucose: .init(startDate: startDate, quantity: .glucose(value: 55)))) + let encoded = try encoder.encode(recommendation) + XCTAssertEqual( + """ + { + "amount" : 0, + "notice" : { + "allGlucoseBelowTarget" : { + "minGlucose" : { + "quantity" : 55, + "quantityUnit" : "mg/dL", + "startDate" : "2015-07-13T12:02:37Z" + } + } + } + } + """, + String(data: encoded , encoding: .utf8)!) + + var decoded = try decoder.decode(ManualBolusRecommendation.self, from: encoded) + } + + func testCurrentGlucoseBelowTargetCodable() throws { + let startDate = dateFormatter.date(from: "2015-07-13T12:02:37")! + var recommendation = ManualBolusRecommendation(amount: 0, notice: .currentGlucoseBelowTarget(glucose: .init(startDate: startDate, quantity: .glucose(value: 65)))) + let encoded = try encoder.encode(recommendation) + XCTAssertEqual( + """ + { + "amount" : 0, + "notice" : { + "currentGlucoseBelowTarget" : { + "glucose" : { + "quantity" : 65, + "quantityUnit" : "mg/dL", + "startDate" : "2015-07-13T12:02:37Z" + } + } + } + } + """, + String(data: encoded , encoding: .utf8)!) + + var decoded = try decoder.decode(ManualBolusRecommendation.self, from: encoded) + } + + func testPredictedGlucoseBelowTargetCodable() throws { + let startDate = dateFormatter.date(from: "2015-07-13T12:02:37")! + var recommendation = ManualBolusRecommendation(amount: 0, notice: .predictedGlucoseBelowTarget(minGlucose: .init(startDate: startDate, quantity: .glucose(value: 65)))) + let encoded = try encoder.encode(recommendation) + XCTAssertEqual( + """ + { + "amount" : 0, + "notice" : { + "predictedGlucoseBelowTarget" : { + "minGlucose" : { + "quantity" : 65, + "quantityUnit" : "mg/dL", + "startDate" : "2015-07-13T12:02:37Z" + } + } + } + } + """, + String(data: encoded , encoding: .utf8)!) + + var decoded = try decoder.decode(ManualBolusRecommendation.self, from: encoded) + } + + func testPredictedGlucoseInRangeCodable() throws { + let startDate = dateFormatter.date(from: "2015-07-13T12:02:37")! + var recommendation = ManualBolusRecommendation(amount: 0, notice: .predictedGlucoseInRange) + let encoded = try encoder.encode(recommendation) + XCTAssertEqual( + """ + { + "amount" : 0, + "notice" : "predictedGlucoseInRange" + } + """, + String(data: encoded , encoding: .utf8)!) + + var decoded = try decoder.decode(ManualBolusRecommendation.self, from: encoded) + } + + func testGlucoseBelowSuspendThresholdCodable() throws { + let startDate = dateFormatter.date(from: "2015-07-13T12:02:37")! + var recommendation = ManualBolusRecommendation(amount: 0, notice: .glucoseBelowSuspendThreshold(minGlucose: .init(startDate: startDate, quantity: .glucose(value: 55)))) + let encoded = try encoder.encode(recommendation) + XCTAssertEqual( + """ + { + "amount" : 0, + "notice" : { + "glucoseBelowSuspendThreshold" : { + "minGlucose" : { + "quantity" : 55, + "quantityUnit" : "mg/dL", + "startDate" : "2015-07-13T12:02:37Z" + } + } + } + } + """, + String(data: encoded , encoding: .utf8)!) + + var decoded = try decoder.decode(ManualBolusRecommendation.self, from: encoded) + } +} + From d21c525d5d7f551662cbc9f21aced79ed04e0312 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 21 Feb 2024 15:19:35 -0600 Subject: [PATCH 20/26] Remove DoubleRange, and add missing fixtures --- Sources/LoopAlgorithm/DoubleRange.swift | 57 -- .../IntegralRetrospectiveCorrection.swift | 2 +- .../CorrectionDosingTests.swift | 5 +- .../Fixtures/live_capture_input.json | 920 ++++++++++++++++++ .../live_capture_predicted_glucose.json | 392 ++++++++ 5 files changed, 1317 insertions(+), 59 deletions(-) delete mode 100644 Sources/LoopAlgorithm/DoubleRange.swift create mode 100644 Tests/LoopAlgorithmTests/Fixtures/live_capture_input.json create mode 100644 Tests/LoopAlgorithmTests/Fixtures/live_capture_predicted_glucose.json diff --git a/Sources/LoopAlgorithm/DoubleRange.swift b/Sources/LoopAlgorithm/DoubleRange.swift deleted file mode 100644 index 5741c5c..0000000 --- a/Sources/LoopAlgorithm/DoubleRange.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// DoubleRange.swift -// - -import Foundation -import HealthKit - -public struct DoubleRange { - public let minValue: Double - public let maxValue: Double - - public init(minValue: Double, maxValue: Double) { - self.minValue = minValue - self.maxValue = maxValue - } - - public var isZero: Bool { - return abs(minValue) < .ulpOfOne && abs(maxValue) < .ulpOfOne - } -} - - -extension DoubleRange: RawRepresentable { - public typealias RawValue = [Double] - - public init?(rawValue: RawValue) { - guard rawValue.count == 2 else { - return nil - } - - minValue = rawValue[0] - maxValue = rawValue[1] - } - - public var rawValue: RawValue { - return [minValue, maxValue] - } -} - -extension DoubleRange: Equatable { - public static func ==(lhs: DoubleRange, rhs: DoubleRange) -> Bool { - return abs(lhs.minValue - rhs.minValue) < .ulpOfOne && - abs(lhs.maxValue - rhs.maxValue) < .ulpOfOne - } -} - -extension DoubleRange: Hashable {} - -extension DoubleRange: Codable {} - -extension DoubleRange { - public func quantityRange(for unit: HKUnit) -> ClosedRange { - let lowerBound = HKQuantity(unit: unit, doubleValue: minValue) - let upperBound = HKQuantity(unit: unit, doubleValue: maxValue) - return lowerBound...upperBound - } -} diff --git a/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift b/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift index 50a33fa..b1b0c9f 100644 --- a/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift +++ b/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift @@ -16,7 +16,7 @@ import HealthKit */ public class IntegralRetrospectiveCorrection: RetrospectiveCorrection { - static let retrospectionInterval = TimeInterval(minutes: 180) + public static let retrospectionInterval = TimeInterval(minutes: 180) /// RetrospectiveCorrection protocol variables /// Standard effect duration diff --git a/Tests/LoopAlgorithmTests/CorrectionDosingTests.swift b/Tests/LoopAlgorithmTests/CorrectionDosingTests.swift index d352b3e..c0ec559 100644 --- a/Tests/LoopAlgorithmTests/CorrectionDosingTests.swift +++ b/Tests/LoopAlgorithmTests/CorrectionDosingTests.swift @@ -24,10 +24,13 @@ class CorrectionDosingTests: XCTestCase { let basalRate = 1.0 override func setUp() { + let lowerBound = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 90) + let upperBound = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 120) + target = [AbsoluteScheduleValue( startDate: testDate.addingTimeInterval(.hours(-24)), endDate: testDate.addingTimeInterval(.hours(24)), - value: DoubleRange(minValue: 90, maxValue: 120).quantityRange(for: .milligramsPerDeciliter) + value: lowerBound...upperBound )] sensitivity = [AbsoluteScheduleValue( diff --git a/Tests/LoopAlgorithmTests/Fixtures/live_capture_input.json b/Tests/LoopAlgorithmTests/Fixtures/live_capture_input.json new file mode 100644 index 0000000..6340ebe --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/live_capture_input.json @@ -0,0 +1,920 @@ +{ + "carbEntries" : [ + { + "absorptionTime" : 10800, + "grams" : 22, + "date" : "2023-06-22T19:20:53Z" + }, + { + "absorptionTime" : 10800, + "grams" : 75, + "date" : "2023-06-22T21:04:45Z" + }, + { + "absorptionTime" : 10800, + "grams" : 47, + "date" : "2023-06-23T02:10:13Z" + } + ], + "doses" : [ + { + "endDate" : "2023-06-22T16:22:40Z", + "startDate" : "2023-06-22T16:12:40Z", + "type" : "basal", + "volume" : 0.050000000000000003 + }, + { + "endDate" : "2023-06-22T16:17:54Z", + "startDate" : "2023-06-22T16:17:46Z", + "type" : "bolus", + "volume" : 0.20000000000000001 + }, + { + "endDate" : "2023-06-22T16:32:40Z", + "startDate" : "2023-06-22T16:22:40Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-22T16:47:39Z", + "startDate" : "2023-06-22T16:32:40Z", + "type" : "basal", + "volume" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-22T16:57:41Z", + "startDate" : "2023-06-22T16:47:39Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-22T17:02:38Z", + "startDate" : "2023-06-22T16:57:41Z", + "type" : "basal", + "volume" : 0.050000000000000003 + }, + { + "endDate" : "2023-06-22T17:07:38Z", + "startDate" : "2023-06-22T17:02:38Z", + "type" : "basal", + "volume" : 0.0041666666666666666 + }, + { + "endDate" : "2023-06-22T17:22:45Z", + "startDate" : "2023-06-22T17:07:38Z", + "type" : "basal", + "volume" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-22T17:12:46Z", + "startDate" : "2023-06-22T17:12:42Z", + "type" : "bolus", + "volume" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-22T17:27:39Z", + "startDate" : "2023-06-22T17:22:45Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-22T17:27:39Z", + "startDate" : "2023-06-22T17:27:39Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-22T17:32:39Z", + "startDate" : "2023-06-22T17:27:39Z", + "type" : "basal", + "volume" : 0.0041666666666666666 + }, + { + "endDate" : "2023-06-22T18:07:38Z", + "startDate" : "2023-06-22T17:32:39Z", + "type" : "basal", + "volume" : 0.25 + }, + { + "endDate" : "2023-06-22T17:32:45Z", + "startDate" : "2023-06-22T17:32:41Z", + "type" : "bolus", + "volume" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-22T17:42:40Z", + "startDate" : "2023-06-22T17:42:38Z", + "type" : "bolus", + "volume" : 0.050000000000000003 + }, + { + "endDate" : "2023-06-22T17:47:43Z", + "startDate" : "2023-06-22T17:47:39Z", + "type" : "bolus", + "volume" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-22T18:12:38Z", + "startDate" : "2023-06-22T18:07:38Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-22T19:17:40Z", + "startDate" : "2023-06-22T18:12:38Z", + "type" : "basal", + "volume" : 0.45000000000000001 + }, + { + "endDate" : "2023-06-22T19:02:43Z", + "startDate" : "2023-06-22T19:02:39Z", + "type" : "bolus", + "volume" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-22T19:22:43Z", + "startDate" : "2023-06-22T19:17:40Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-22T19:21:49Z", + "startDate" : "2023-06-22T19:21:01Z", + "type" : "bolus", + "volume" : 1.2 + }, + { + "endDate" : "2023-06-22T19:37:37Z", + "startDate" : "2023-06-22T19:22:43Z", + "type" : "basal", + "volume" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-22T19:27:43Z", + "startDate" : "2023-06-22T19:27:39Z", + "type" : "bolus", + "volume" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-22T19:57:48Z", + "startDate" : "2023-06-22T19:37:37Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-22T19:57:48Z", + "startDate" : "2023-06-22T19:57:48Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-22T20:02:39Z", + "startDate" : "2023-06-22T19:57:48Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-22T20:07:40Z", + "startDate" : "2023-06-22T20:02:39Z", + "type" : "basal", + "volume" : 0.050000000000000003 + }, + { + "endDate" : "2023-06-22T20:12:40Z", + "startDate" : "2023-06-22T20:07:40Z", + "type" : "basal", + "volume" : 0.0083333333333333332 + }, + { + "endDate" : "2023-06-22T20:52:45Z", + "startDate" : "2023-06-22T20:12:40Z", + "type" : "basal", + "volume" : 0.25 + }, + { + "endDate" : "2023-06-22T21:07:43Z", + "startDate" : "2023-06-22T20:52:45Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-22T21:07:49Z", + "startDate" : "2023-06-22T21:04:51Z", + "type" : "bolus", + "volume" : 4.4500000000000002 + }, + { + "endDate" : "2023-06-22T21:47:38Z", + "startDate" : "2023-06-22T21:07:43Z", + "type" : "basal", + "volume" : 0.25 + }, + { + "endDate" : "2023-06-22T21:12:42Z", + "startDate" : "2023-06-22T21:12:40Z", + "type" : "bolus", + "volume" : 0.050000000000000003 + }, + { + "endDate" : "2023-06-22T22:07:39Z", + "startDate" : "2023-06-22T21:47:38Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-22T23:42:40Z", + "startDate" : "2023-06-22T22:07:39Z", + "type" : "basal", + "volume" : 0.65000000000000002 + }, + { + "endDate" : "2023-06-22T22:27:46Z", + "startDate" : "2023-06-22T22:27:38Z", + "type" : "bolus", + "volume" : 0.20000000000000001 + }, + { + "endDate" : "2023-06-22T22:37:44Z", + "startDate" : "2023-06-22T22:37:40Z", + "type" : "bolus", + "volume" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-22T22:42:42Z", + "startDate" : "2023-06-22T22:42:40Z", + "type" : "bolus", + "volume" : 0.050000000000000003 + }, + { + "endDate" : "2023-06-22T23:52:44Z", + "startDate" : "2023-06-22T23:42:40Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-22T23:57:46Z", + "startDate" : "2023-06-22T23:52:44Z", + "type" : "basal", + "volume" : 0.050000000000000003 + }, + { + "endDate" : "2023-06-23T00:02:37Z", + "startDate" : "2023-06-22T23:57:46Z", + "type" : "basal", + "volume" : 0.0040416666666666665 + }, + { + "endDate" : "2023-06-23T01:02:52Z", + "startDate" : "2023-06-23T00:02:37Z", + "type" : "basal", + "volume" : 0.40000000000000002 + }, + { + "endDate" : "2023-06-23T00:07:42Z", + "startDate" : "2023-06-23T00:07:40Z", + "type" : "bolus", + "volume" : 0.050000000000000003 + }, + { + "endDate" : "2023-06-23T00:12:44Z", + "startDate" : "2023-06-23T00:12:38Z", + "type" : "bolus", + "volume" : 0.14999999999999999 + }, + { + "endDate" : "2023-06-23T00:22:43Z", + "startDate" : "2023-06-23T00:22:39Z", + "type" : "bolus", + "volume" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-23T00:27:49Z", + "startDate" : "2023-06-23T00:27:41Z", + "type" : "bolus", + "volume" : 0.20000000000000001 + }, + { + "endDate" : "2023-06-23T00:32:43Z", + "startDate" : "2023-06-23T00:32:39Z", + "type" : "bolus", + "volume" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-23T00:37:58Z", + "startDate" : "2023-06-23T00:37:48Z", + "type" : "bolus", + "volume" : 0.25 + }, + { + "endDate" : "2023-06-23T00:42:47Z", + "startDate" : "2023-06-23T00:42:39Z", + "type" : "bolus", + "volume" : 0.20000000000000001 + }, + { + "endDate" : "2023-06-23T00:47:44Z", + "startDate" : "2023-06-23T00:47:40Z", + "type" : "bolus", + "volume" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-23T00:52:51Z", + "startDate" : "2023-06-23T00:52:45Z", + "type" : "bolus", + "volume" : 0.14999999999999999 + }, + { + "endDate" : "2023-06-23T01:12:49Z", + "startDate" : "2023-06-23T01:02:52Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-23T01:17:41Z", + "startDate" : "2023-06-23T01:12:49Z", + "type" : "basal", + "volume" : 0.050000000000000003 + }, + { + "endDate" : "2023-06-23T01:12:54Z", + "startDate" : "2023-06-23T01:12:50Z", + "type" : "bolus", + "volume" : 0.10000000000000001 + }, + { + "endDate" : "2023-06-23T01:37:39Z", + "startDate" : "2023-06-23T01:17:41Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-23T01:37:39Z", + "startDate" : "2023-06-23T01:37:39Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-23T01:42:38Z", + "startDate" : "2023-06-23T01:37:39Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-23T02:07:42Z", + "startDate" : "2023-06-23T01:42:38Z", + "type" : "basal", + "volume" : 0.14999999999999999 + }, + { + "endDate" : "2023-06-23T01:47:46Z", + "startDate" : "2023-06-23T01:47:38Z", + "type" : "bolus", + "volume" : 0.20000000000000001 + }, + { + "endDate" : "2023-06-23T01:52:47Z", + "startDate" : "2023-06-23T01:52:39Z", + "type" : "bolus", + "volume" : 0.20000000000000001 + }, + { + "endDate" : "2023-06-23T01:57:50Z", + "startDate" : "2023-06-23T01:57:40Z", + "type" : "bolus", + "volume" : 0.25 + }, + { + "endDate" : "2023-06-23T02:02:49Z", + "startDate" : "2023-06-23T02:02:39Z", + "type" : "bolus", + "volume" : 0.25 + }, + { + "endDate" : "2023-06-23T02:07:36Z", + "startDate" : "2023-06-23T02:04:30Z", + "type" : "bolus", + "volume" : 4.6500000000000004 + }, + { + "endDate" : "2023-06-23T02:27:44Z", + "startDate" : "2023-06-23T02:07:42Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-23T02:27:44Z", + "startDate" : "2023-06-23T02:27:44Z", + "type" : "basal", + "volume" : 0 + }, + { + "endDate" : "2023-06-23T02:47:39Z", + "startDate" : "2023-06-23T02:27:44Z", + "type" : "basal", + "volume" : 0 + } + ], + "glucoseHistory" : [ + { + "quantity" : 120, + "startDate" : "2023-06-22T16:42:33Z" + }, + { + "quantity" : 119, + "startDate" : "2023-06-22T16:47:33Z" + }, + { + "quantity" : 120, + "startDate" : "2023-06-22T16:52:34Z" + }, + { + "quantity" : 118, + "startDate" : "2023-06-22T16:57:34Z" + }, + { + "quantity" : 115, + "startDate" : "2023-06-22T17:02:34Z" + }, + { + "quantity" : 120, + "startDate" : "2023-06-22T17:07:34Z" + }, + { + "quantity" : 121, + "startDate" : "2023-06-22T17:12:34Z" + }, + { + "quantity" : 119, + "startDate" : "2023-06-22T17:17:34Z" + }, + { + "quantity" : 116, + "startDate" : "2023-06-22T17:22:34Z" + }, + { + "quantity" : 115, + "startDate" : "2023-06-22T17:27:34Z" + }, + { + "quantity" : 124, + "startDate" : "2023-06-22T17:32:34Z" + }, + { + "quantity" : 114, + "startDate" : "2023-06-22T17:37:34Z" + }, + { + "quantity" : 124, + "startDate" : "2023-06-22T17:42:34Z" + }, + { + "quantity" : 124, + "startDate" : "2023-06-22T17:47:33Z" + }, + { + "quantity" : 124, + "startDate" : "2023-06-22T17:52:34Z" + }, + { + "quantity" : 126, + "startDate" : "2023-06-22T17:57:33Z" + }, + { + "quantity" : 125, + "startDate" : "2023-06-22T18:02:34Z" + }, + { + "quantity" : 118, + "startDate" : "2023-06-22T18:07:34Z" + }, + { + "quantity" : 122, + "startDate" : "2023-06-22T18:12:33Z" + }, + { + "quantity" : 123, + "startDate" : "2023-06-22T18:17:34Z" + }, + { + "quantity" : 123, + "startDate" : "2023-06-22T18:22:34Z" + }, + { + "quantity" : 121, + "startDate" : "2023-06-22T18:27:34Z" + }, + { + "quantity" : 118, + "startDate" : "2023-06-22T18:32:34Z" + }, + { + "quantity" : 116, + "startDate" : "2023-06-22T18:37:34Z" + }, + { + "quantity" : 118, + "startDate" : "2023-06-22T18:42:34Z" + }, + { + "quantity" : 115, + "startDate" : "2023-06-22T18:47:34Z" + }, + { + "quantity" : 117, + "startDate" : "2023-06-22T18:52:34Z" + }, + { + "quantity" : 125, + "startDate" : "2023-06-22T18:57:34Z" + }, + { + "quantity" : 122, + "startDate" : "2023-06-22T19:02:34Z" + }, + { + "quantity" : 119, + "startDate" : "2023-06-22T19:07:34Z" + }, + { + "quantity" : 120, + "startDate" : "2023-06-22T19:12:34Z" + }, + { + "quantity" : 112, + "startDate" : "2023-06-22T19:17:34Z" + }, + { + "quantity" : 111, + "startDate" : "2023-06-22T19:22:34Z" + }, + { + "quantity" : 114, + "startDate" : "2023-06-22T19:27:34Z" + }, + { + "quantity" : 117, + "startDate" : "2023-06-22T19:32:34Z" + }, + { + "quantity" : 107, + "startDate" : "2023-06-22T19:37:34Z" + }, + { + "quantity" : 113, + "startDate" : "2023-06-22T19:42:34Z" + }, + { + "quantity" : 117, + "startDate" : "2023-06-22T19:47:34Z" + }, + { + "quantity" : 109, + "startDate" : "2023-06-22T19:52:34Z" + }, + { + "quantity" : 117, + "startDate" : "2023-06-22T19:57:34Z" + }, + { + "quantity" : 121, + "startDate" : "2023-06-22T20:02:34Z" + }, + { + "quantity" : 121, + "startDate" : "2023-06-22T20:07:34Z" + }, + { + "quantity" : 127, + "startDate" : "2023-06-22T20:12:34Z" + }, + { + "quantity" : 133, + "startDate" : "2023-06-22T20:17:34Z" + }, + { + "quantity" : 131, + "startDate" : "2023-06-22T20:22:34Z" + }, + { + "quantity" : 132, + "startDate" : "2023-06-22T20:27:34Z" + }, + { + "quantity" : 134, + "startDate" : "2023-06-22T20:32:34Z" + }, + { + "quantity" : 134, + "startDate" : "2023-06-22T20:37:34Z" + }, + { + "quantity" : 139, + "startDate" : "2023-06-22T20:42:34Z" + }, + { + "quantity" : 139, + "startDate" : "2023-06-22T20:47:34Z" + }, + { + "quantity" : 132, + "startDate" : "2023-06-22T20:52:34Z" + }, + { + "quantity" : 118, + "startDate" : "2023-06-22T20:57:34Z" + }, + { + "quantity" : 123, + "startDate" : "2023-06-22T21:02:34Z" + }, + { + "quantity" : 122, + "startDate" : "2023-06-22T21:07:34Z" + }, + { + "quantity" : 119, + "startDate" : "2023-06-22T21:12:34Z" + }, + { + "quantity" : 116, + "startDate" : "2023-06-22T21:17:34Z" + }, + { + "quantity" : 113, + "startDate" : "2023-06-22T21:22:34Z" + }, + { + "quantity" : 111, + "startDate" : "2023-06-22T21:27:34Z" + }, + { + "quantity" : 112, + "startDate" : "2023-06-22T21:32:34Z" + }, + { + "quantity" : 107, + "startDate" : "2023-06-22T21:37:34Z" + }, + { + "quantity" : 102, + "startDate" : "2023-06-22T21:42:34Z" + }, + { + "quantity" : 95, + "startDate" : "2023-06-22T21:47:34Z" + }, + { + "quantity" : 96, + "startDate" : "2023-06-22T21:52:34Z" + }, + { + "quantity" : 89, + "startDate" : "2023-06-22T21:57:34Z" + }, + { + "quantity" : 95, + "startDate" : "2023-06-22T22:02:34Z" + }, + { + "quantity" : 95, + "startDate" : "2023-06-22T22:07:34Z" + }, + { + "quantity" : 93, + "startDate" : "2023-06-22T22:12:34Z" + }, + { + "quantity" : 98, + "startDate" : "2023-06-22T22:17:35Z" + }, + { + "quantity" : 95, + "startDate" : "2023-06-22T22:22:35Z" + }, + { + "quantity" : 101, + "startDate" : "2023-06-22T22:27:34Z" + }, + { + "quantity" : 97, + "startDate" : "2023-06-22T22:32:34Z" + }, + { + "quantity" : 108, + "startDate" : "2023-06-22T22:37:35Z" + }, + { + "quantity" : 109, + "startDate" : "2023-06-22T22:42:34Z" + }, + { + "quantity" : 109, + "startDate" : "2023-06-22T22:47:34Z" + }, + { + "quantity" : 114, + "startDate" : "2023-06-22T22:52:34Z" + }, + { + "quantity" : 115, + "startDate" : "2023-06-22T22:57:34Z" + }, + { + "quantity" : 114, + "startDate" : "2023-06-22T23:02:34Z" + }, + { + "quantity" : 121, + "startDate" : "2023-06-22T23:07:34Z" + }, + { + "quantity" : 119, + "startDate" : "2023-06-22T23:12:34Z" + }, + { + "quantity" : 117, + "startDate" : "2023-06-22T23:17:34Z" + }, + { + "quantity" : 120, + "startDate" : "2023-06-22T23:22:35Z" + }, + { + "quantity" : 122, + "startDate" : "2023-06-22T23:27:34Z" + }, + { + "quantity" : 123, + "startDate" : "2023-06-22T23:32:34Z" + }, + { + "quantity" : 127, + "startDate" : "2023-06-22T23:37:34Z" + }, + { + "quantity" : 118, + "startDate" : "2023-06-22T23:42:35Z" + }, + { + "quantity" : 120, + "startDate" : "2023-06-22T23:47:34Z" + }, + { + "quantity" : 119, + "startDate" : "2023-06-22T23:52:35Z" + }, + { + "quantity" : 115, + "startDate" : "2023-06-22T23:57:34Z" + }, + { + "quantity" : 116, + "startDate" : "2023-06-23T00:02:34Z" + }, + { + "quantity" : 133, + "startDate" : "2023-06-23T00:07:34Z" + }, + { + "quantity" : 145, + "startDate" : "2023-06-23T00:12:34Z" + }, + { + "quantity" : 140, + "startDate" : "2023-06-23T00:17:34Z" + }, + { + "quantity" : 161, + "startDate" : "2023-06-23T00:22:35Z" + }, + { + "quantity" : 166, + "startDate" : "2023-06-23T00:27:34Z" + }, + { + "quantity" : 172, + "startDate" : "2023-06-23T00:32:35Z" + }, + { + "quantity" : 182, + "startDate" : "2023-06-23T00:37:35Z" + }, + { + "quantity" : 184, + "startDate" : "2023-06-23T00:42:35Z" + }, + { + "quantity" : 185, + "startDate" : "2023-06-23T00:47:34Z" + }, + { + "quantity" : 190, + "startDate" : "2023-06-23T00:52:35Z" + }, + { + "quantity" : 182, + "startDate" : "2023-06-23T00:57:34Z" + }, + { + "quantity" : 166, + "startDate" : "2023-06-23T01:02:35Z" + }, + { + "quantity" : 174, + "startDate" : "2023-06-23T01:07:34Z" + }, + { + "quantity" : 179, + "startDate" : "2023-06-23T01:12:34Z" + }, + { + "quantity" : 166, + "startDate" : "2023-06-23T01:17:35Z" + }, + { + "quantity" : 134, + "startDate" : "2023-06-23T01:22:34Z" + }, + { + "quantity" : 131, + "startDate" : "2023-06-23T01:27:35Z" + }, + { + "quantity" : 129, + "startDate" : "2023-06-23T01:32:34Z" + }, + { + "quantity" : 136, + "startDate" : "2023-06-23T01:37:34Z" + }, + { + "quantity" : 152, + "startDate" : "2023-06-23T01:42:34Z" + }, + { + "quantity" : 162, + "startDate" : "2023-06-23T01:47:35Z" + }, + { + "quantity" : 165, + "startDate" : "2023-06-23T01:52:34Z" + }, + { + "quantity" : 172, + "startDate" : "2023-06-23T01:57:34Z" + }, + { + "quantity" : 176, + "startDate" : "2023-06-23T02:02:35Z" + }, + { + "quantity" : 165, + "startDate" : "2023-06-23T02:07:35Z" + }, + { + "quantity" : 172, + "startDate" : "2023-06-23T02:12:34Z" + }, + { + "quantity" : 170, + "startDate" : "2023-06-23T02:17:35Z" + }, + { + "quantity" : 177, + "startDate" : "2023-06-23T02:22:35Z" + }, + { + "quantity" : 176, + "startDate" : "2023-06-23T02:27:35Z" + }, + { + "quantity" : 173, + "startDate" : "2023-06-23T02:32:34Z" + }, + { + "quantity" : 180, + "startDate" : "2023-06-23T02:37:35Z" + } + ], + "basal" : [ + { + "endDate" : "2023-06-23T05:00:00Z", + "startDate" : "2023-06-22T10:00:00Z", + "value" : 0.45000000000000001 + } + ], + "carbRatio" : [ + { + "endDate" : "2023-06-23T07:00:00Z", + "startDate" : "2023-06-22T07:00:00Z", + "value" : 11 + } + ], + "sensitivity" : [ + { + "endDate" : "2023-06-23T05:00:00Z", + "startDate" : "2023-06-22T10:00:00Z", + "value" : 60 + } + ], +} diff --git a/Tests/LoopAlgorithmTests/Fixtures/live_capture_predicted_glucose.json b/Tests/LoopAlgorithmTests/Fixtures/live_capture_predicted_glucose.json new file mode 100644 index 0000000..b77cb55 --- /dev/null +++ b/Tests/LoopAlgorithmTests/Fixtures/live_capture_predicted_glucose.json @@ -0,0 +1,392 @@ +[ + { + "quantity" : 180, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T02:37:35Z" + }, + { + "quantity" : 180.29132150657966, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T02:40:00Z" + }, + { + "quantity" : 180.52987493690765, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T02:45:00Z" + }, + { + "quantity" : 179.77931710835796, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T02:50:00Z" + }, + { + "quantity" : 177.81435588000684, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T02:55:00Z" + }, + { + "quantity" : 175.04920382978105, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T03:00:00Z" + }, + { + "quantity" : 172.09884468881066, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T03:05:00Z" + }, + { + "quantity" : 169.0341959170697, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T03:10:00Z" + }, + { + "quantity" : 165.91852357330802, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T03:15:00Z" + }, + { + "quantity" : 162.78787379965794, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T03:20:00Z" + }, + { + "quantity" : 159.67566374385987, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T03:25:00Z" + }, + { + "quantity" : 156.6278000530812, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T03:30:00Z" + }, + { + "quantity" : 153.68497899133908, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T03:35:00Z" + }, + { + "quantity" : 150.85857622089654, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T03:40:00Z" + }, + { + "quantity" : 148.1797464838103, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T03:45:00Z" + }, + { + "quantity" : 145.67546444468488, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T03:50:00Z" + }, + { + "quantity" : 143.36889813413907, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T03:55:00Z" + }, + { + "quantity" : 141.27978455565565, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T04:00:00Z" + }, + { + "quantity" : 139.4249156157845, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T04:05:00Z" + }, + { + "quantity" : 137.7082164432302, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T04:10:00Z" + }, + { + "quantity" : 135.9914530272836, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T04:15:00Z" + }, + { + "quantity" : 134.2827664300858, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T04:20:00Z" + }, + { + "quantity" : 132.58882252103788, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T04:25:00Z" + }, + { + "quantity" : 130.91436540926705, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T04:30:00Z" + }, + { + "quantity" : 129.26245506698106, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T04:35:00Z" + }, + { + "quantity" : 127.63445215517064, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T04:40:00Z" + }, + { + "quantity" : 126.02931442610466, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T04:45:00Z" + }, + { + "quantity" : 124.44584453318035, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T04:50:00Z" + }, + { + "quantity" : 122.88145382927624, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T04:55:00Z" + }, + { + "quantity" : 121.33291804466413, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T05:00:00Z" + }, + { + "quantity" : 119.79660318395023, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T05:05:00Z" + }, + { + "quantity" : 118.26822621269756, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T05:10:00Z" + }, + { + "quantity" : 116.74288846240054, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T05:15:00Z" + }, + { + "quantity" : 115.21516364934988, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T05:20:00Z" + }, + { + "quantity" : 113.67917795139525, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T05:25:00Z" + }, + { + "quantity" : 112.12868274578355, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T05:30:00Z" + }, + { + "quantity" : 110.55712056957398, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T05:35:00Z" + }, + { + "quantity" : 108.95768482515078, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T05:40:00Z" + }, + { + "quantity" : 107.32337371691418, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T05:45:00Z" + }, + { + "quantity" : 105.64703887119052, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T05:50:00Z" + }, + { + "quantity" : 103.92146136061618, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T05:55:00Z" + }, + { + "quantity" : 102.13957364029821, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T06:00:00Z" + }, + { + "quantity" : 100.29425666336888, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T06:05:00Z" + }, + { + "quantity" : 98.37810372588095, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T06:10:00Z" + }, + { + "quantity" : 96.38393930539169, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T06:15:00Z" + }, + { + "quantity" : 94.30446350902744, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T06:20:00Z" + }, + { + "quantity" : 92.24204127278486, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T06:25:00Z" + }, + { + "quantity" : 90.33818302395392, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T06:30:00Z" + }, + { + "quantity" : 88.58657375772682, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T06:35:00Z" + }, + { + "quantity" : 86.9796355549934, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T06:40:00Z" + }, + { + "quantity" : 85.50932186775859, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T06:45:00Z" + }, + { + "quantity" : 84.16822997919033, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T06:50:00Z" + }, + { + "quantity" : 82.94837192653554, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T06:55:00Z" + }, + { + "quantity" : 81.84224397138112, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T07:00:00Z" + }, + { + "quantity" : 80.8433012790305, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T07:05:00Z" + }, + { + "quantity" : 79.94514990703274, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T07:10:00Z" + }, + { + "quantity" : 79.1425285689858, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T07:15:00Z" + }, + { + "quantity" : 78.43073701607969, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T07:20:00Z" + }, + { + "quantity" : 77.80513210408813, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T07:25:00Z" + }, + { + "quantity" : 77.26038909817899, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T07:30:00Z" + }, + { + "quantity" : 76.79214128522554, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T07:35:00Z" + }, + { + "quantity" : 76.39636603545401, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T07:40:00Z" + }, + { + "quantity" : 76.06917517261084, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T07:45:00Z" + }, + { + "quantity" : 75.80681469169488, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T07:50:00Z" + }, + { + "quantity" : 75.60563685065486, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T07:55:00Z" + }, + { + "quantity" : 75.46174433219417, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T08:00:00Z" + }, + { + "quantity" : 75.3700976935867, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T08:05:00Z" + }, + { + "quantity" : 75.32563190200372, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T08:10:00Z" + }, + { + "quantity" : 75.32301505961473, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T08:15:00Z" + }, + { + "quantity" : 75.33414614640142, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T08:20:00Z" + }, + { + "quantity" : 75.34232624108009, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T08:25:00Z" + }, + { + "quantity" : 75.34805924470882, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T08:30:00Z" + }, + { + "quantity" : 75.35181912391843, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T08:35:00Z" + }, + { + "quantity" : 75.35405041818424, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T08:40:00Z" + }, + { + "quantity" : 75.35517138501669, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T08:45:00Z" + }, + { + "quantity" : 75.35557365902051, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T08:50:00Z" + }, + { + "quantity" : 75.35562264689557, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T08:55:00Z" + }, + { + "quantity" : 75.35562264689557, + "quantityUnit" : "mg\/dL", + "startDate" : "2023-06-23T09:00:00Z" + } +] From 341e0ba42ac0e9dc79c0670e691d9d7cbffae96e Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 21 Feb 2024 15:23:43 -0600 Subject: [PATCH 21/26] Cleanup warnings --- .../CorrectionDosingTests.swift | 4 +-- .../ManualBolusRecommendationTests.swift | 30 +++++++++++-------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/Tests/LoopAlgorithmTests/CorrectionDosingTests.swift b/Tests/LoopAlgorithmTests/CorrectionDosingTests.swift index c0ec559..14afaa1 100644 --- a/Tests/LoopAlgorithmTests/CorrectionDosingTests.swift +++ b/Tests/LoopAlgorithmTests/CorrectionDosingTests.swift @@ -24,8 +24,8 @@ class CorrectionDosingTests: XCTestCase { let basalRate = 1.0 override func setUp() { - let lowerBound = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 90) - let upperBound = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 120) + let lowerBound: HKQuantity = .glucose(value: 90) + let upperBound: HKQuantity = .glucose(value: 120) target = [AbsoluteScheduleValue( startDate: testDate.addingTimeInterval(.hours(-24)), diff --git a/Tests/LoopAlgorithmTests/ManualBolusRecommendationTests.swift b/Tests/LoopAlgorithmTests/ManualBolusRecommendationTests.swift index 5f04d5a..3a98656 100644 --- a/Tests/LoopAlgorithmTests/ManualBolusRecommendationTests.swift +++ b/Tests/LoopAlgorithmTests/ManualBolusRecommendationTests.swift @@ -35,7 +35,7 @@ final class ManualBolusRecommendationTests: XCTestCase { func testRecommendationWithoutNoticeCodable() throws { - var recommendation = ManualBolusRecommendation(amount: 1.0, notice: nil) + let recommendation = ManualBolusRecommendation(amount: 1.0, notice: nil) let encoded = try encoder.encode(recommendation) XCTAssertEqual( """ @@ -45,12 +45,13 @@ final class ManualBolusRecommendationTests: XCTestCase { """, String(data: encoded , encoding: .utf8)!) - var decoded = try decoder.decode(ManualBolusRecommendation.self, from: encoded) + let decoded = try decoder.decode(ManualBolusRecommendation.self, from: encoded) + XCTAssertEqual(decoded, recommendation) } func testAllGlucoseBelowTargetCodable() throws { let startDate = dateFormatter.date(from: "2015-07-13T12:02:37")! - var recommendation = ManualBolusRecommendation(amount: 0, notice: .allGlucoseBelowTarget(minGlucose: .init(startDate: startDate, quantity: .glucose(value: 55)))) + let recommendation = ManualBolusRecommendation(amount: 0, notice: .allGlucoseBelowTarget(minGlucose: .init(startDate: startDate, quantity: .glucose(value: 55)))) let encoded = try encoder.encode(recommendation) XCTAssertEqual( """ @@ -69,12 +70,13 @@ final class ManualBolusRecommendationTests: XCTestCase { """, String(data: encoded , encoding: .utf8)!) - var decoded = try decoder.decode(ManualBolusRecommendation.self, from: encoded) + let decoded = try decoder.decode(ManualBolusRecommendation.self, from: encoded) + XCTAssertEqual(decoded, recommendation) } func testCurrentGlucoseBelowTargetCodable() throws { let startDate = dateFormatter.date(from: "2015-07-13T12:02:37")! - var recommendation = ManualBolusRecommendation(amount: 0, notice: .currentGlucoseBelowTarget(glucose: .init(startDate: startDate, quantity: .glucose(value: 65)))) + let recommendation = ManualBolusRecommendation(amount: 0, notice: .currentGlucoseBelowTarget(glucose: .init(startDate: startDate, quantity: .glucose(value: 65)))) let encoded = try encoder.encode(recommendation) XCTAssertEqual( """ @@ -93,12 +95,13 @@ final class ManualBolusRecommendationTests: XCTestCase { """, String(data: encoded , encoding: .utf8)!) - var decoded = try decoder.decode(ManualBolusRecommendation.self, from: encoded) + let decoded = try decoder.decode(ManualBolusRecommendation.self, from: encoded) + XCTAssertEqual(decoded, recommendation) } func testPredictedGlucoseBelowTargetCodable() throws { let startDate = dateFormatter.date(from: "2015-07-13T12:02:37")! - var recommendation = ManualBolusRecommendation(amount: 0, notice: .predictedGlucoseBelowTarget(minGlucose: .init(startDate: startDate, quantity: .glucose(value: 65)))) + let recommendation = ManualBolusRecommendation(amount: 0, notice: .predictedGlucoseBelowTarget(minGlucose: .init(startDate: startDate, quantity: .glucose(value: 65)))) let encoded = try encoder.encode(recommendation) XCTAssertEqual( """ @@ -117,12 +120,13 @@ final class ManualBolusRecommendationTests: XCTestCase { """, String(data: encoded , encoding: .utf8)!) - var decoded = try decoder.decode(ManualBolusRecommendation.self, from: encoded) + let decoded = try decoder.decode(ManualBolusRecommendation.self, from: encoded) + XCTAssertEqual(decoded, recommendation) } func testPredictedGlucoseInRangeCodable() throws { let startDate = dateFormatter.date(from: "2015-07-13T12:02:37")! - var recommendation = ManualBolusRecommendation(amount: 0, notice: .predictedGlucoseInRange) + let recommendation = ManualBolusRecommendation(amount: 0, notice: .predictedGlucoseInRange) let encoded = try encoder.encode(recommendation) XCTAssertEqual( """ @@ -133,12 +137,13 @@ final class ManualBolusRecommendationTests: XCTestCase { """, String(data: encoded , encoding: .utf8)!) - var decoded = try decoder.decode(ManualBolusRecommendation.self, from: encoded) + let decoded = try decoder.decode(ManualBolusRecommendation.self, from: encoded) + XCTAssertEqual(decoded, recommendation) } func testGlucoseBelowSuspendThresholdCodable() throws { let startDate = dateFormatter.date(from: "2015-07-13T12:02:37")! - var recommendation = ManualBolusRecommendation(amount: 0, notice: .glucoseBelowSuspendThreshold(minGlucose: .init(startDate: startDate, quantity: .glucose(value: 55)))) + let recommendation = ManualBolusRecommendation(amount: 0, notice: .glucoseBelowSuspendThreshold(minGlucose: .init(startDate: startDate, quantity: .glucose(value: 55)))) let encoded = try encoder.encode(recommendation) XCTAssertEqual( """ @@ -157,7 +162,8 @@ final class ManualBolusRecommendationTests: XCTestCase { """, String(data: encoded , encoding: .utf8)!) - var decoded = try decoder.decode(ManualBolusRecommendation.self, from: encoded) + let decoded = try decoder.decode(ManualBolusRecommendation.self, from: encoded) + XCTAssertEqual(decoded, recommendation) } } From 8bcb00b475bdac449dcf9891ff00bfd4a92e25e7 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 21 Feb 2024 16:49:05 -0600 Subject: [PATCH 22/26] Add IntegralRetrospectiveCorrectionTests and more cleanup --- .../AutomaticDoseRecommendation.swift | 4 -- Sources/LoopAlgorithm/Extensions/HKUnit.swift | 17 ------ .../Glucose/GlucoseEffectVelocity.swift | 24 +------- .../LoopAlgorithm/Insulin/InsulinMath.swift | 21 ------- Sources/LoopAlgorithm/LoopAlgorithm.swift | 2 + .../LoopAlgorithm/LoopAlgorithmInput.swift | 55 ------------------- .../LoopAlgorithm/LoopPredictionInput.swift | 46 ---------------- ...IntegralRetrospectiveCorrectionTests.swift | 48 ++++++++++++++++ 8 files changed, 51 insertions(+), 166 deletions(-) create mode 100644 Tests/LoopAlgorithmTests/Mocks/IntegralRetrospectiveCorrectionTests.swift diff --git a/Sources/LoopAlgorithm/AutomaticDoseRecommendation.swift b/Sources/LoopAlgorithm/AutomaticDoseRecommendation.swift index 1bdc1d4..a3a64b0 100644 --- a/Sources/LoopAlgorithm/AutomaticDoseRecommendation.swift +++ b/Sources/LoopAlgorithm/AutomaticDoseRecommendation.swift @@ -16,10 +16,6 @@ public struct AutomaticDoseRecommendation: Equatable { self.basalAdjustment = basalAdjustment self.bolusUnits = bolusUnits } - - public var hasDosingChange: Bool { - return basalAdjustment != nil || bolusUnits != nil - } } extension AutomaticDoseRecommendation: Codable {} diff --git a/Sources/LoopAlgorithm/Extensions/HKUnit.swift b/Sources/LoopAlgorithm/Extensions/HKUnit.swift index ade4708..0c524a7 100644 --- a/Sources/LoopAlgorithm/Extensions/HKUnit.swift +++ b/Sources/LoopAlgorithm/Extensions/HKUnit.swift @@ -8,26 +8,9 @@ import HealthKit - extension HKUnit { static let milligramsPerDeciliter: HKUnit = { return HKUnit.gramUnit(with: .milli).unitDivided(by: .literUnit(with: .deci)) }() - - static let millimolesPerLiter: HKUnit = { - return HKUnit.moleUnit(with: .milli, molarMass: HKUnitMolarMassBloodGlucose).unitDivided(by: .liter()) - }() - - static let milligramsPerDeciliterPerMinute: HKUnit = { - return HKUnit.milligramsPerDeciliter.unitDivided(by: .minute()) - }() - - static let internationalUnitsPerHour: HKUnit = { - return HKUnit.internationalUnit().unitDivided(by: .hour()) - }() - - static let gramsPerUnit: HKUnit = { - return HKUnit.gram().unitDivided(by: .internationalUnit()) - }() } diff --git a/Sources/LoopAlgorithm/Glucose/GlucoseEffectVelocity.swift b/Sources/LoopAlgorithm/Glucose/GlucoseEffectVelocity.swift index 391e190..056959a 100644 --- a/Sources/LoopAlgorithm/Glucose/GlucoseEffectVelocity.swift +++ b/Sources/LoopAlgorithm/Glucose/GlucoseEffectVelocity.swift @@ -23,7 +23,7 @@ public struct GlucoseEffectVelocity: SampleValue { extension GlucoseEffectVelocity { - static let perSecondUnit = HKUnit.milligramsPerDeciliter.unitDivided(by: .second()) + public static let perSecondUnit = HKUnit.milligramsPerDeciliter.unitDivided(by: .second()) /// The integration of the velocity span public var effect: GlucoseEffect { @@ -38,26 +38,4 @@ extension GlucoseEffectVelocity { ) ) } - - /// The integration of the velocity span from `start` to `end` - public func effect(from start: Date, to end: Date) -> GlucoseEffect? { - guard - start <= end, - startDate <= start, - end <= endDate - else { - return nil - } - - let duration = end.timeIntervalSince(start) - let velocityPerSecond = quantity.doubleValue(for: GlucoseEffectVelocity.perSecondUnit) - - return GlucoseEffect( - startDate: end, - quantity: HKQuantity( - unit: .milligramsPerDeciliter, - doubleValue: velocityPerSecond * duration - ) - ) - } } diff --git a/Sources/LoopAlgorithm/Insulin/InsulinMath.swift b/Sources/LoopAlgorithm/Insulin/InsulinMath.swift index ea66471..e5b3273 100644 --- a/Sources/LoopAlgorithm/Insulin/InsulinMath.swift +++ b/Sources/LoopAlgorithm/Insulin/InsulinMath.swift @@ -165,27 +165,6 @@ extension InsulinDose { } } -extension Collection where Element: TimelineValue { - public var timespan: DateInterval { - - guard count > 0 else { - return DateInterval(start: Date(), duration: 0) - } - - var min: Date = .distantFuture - var max: Date = .distantPast - for value in self { - if value.startDate < min { - min = value.startDate - } - if value.endDate > max { - max = value.endDate - } - } - return DateInterval(start: min, end: max) - } -} - extension Collection where Element: InsulinDose { /// Annotates a sequence of dose entries with the configured basal history diff --git a/Sources/LoopAlgorithm/LoopAlgorithm.swift b/Sources/LoopAlgorithm/LoopAlgorithm.swift index 9069104..13356c5 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithm.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithm.swift @@ -202,6 +202,8 @@ public struct LoopAlgorithm { rc = StandardRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration) } + + if let latestGlucose = glucoseHistory.last { retrospectiveCorrectionEffects = rc.computeEffect( startingAt: latestGlucose, diff --git a/Sources/LoopAlgorithm/LoopAlgorithmInput.swift b/Sources/LoopAlgorithm/LoopAlgorithmInput.swift index bbc0465..3cf829f 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithmInput.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithmInput.swift @@ -264,58 +264,3 @@ extension InsulinType { } } } - - -extension LoopAlgorithmInput { - - var simplifiedForFixture: LoopAlgorithmInput { - return LoopAlgorithmInput( - predictionStart: predictionStart, - glucoseHistory: glucoseHistory.map { - FixtureGlucoseSample( - provenanceIdentifier: $0.provenanceIdentifier, - startDate: $0.startDate, - quantity: $0.quantity, - isDisplayOnly: $0.isDisplayOnly - ) - }, - doses: doses.map({ - FixtureInsulinDose( - deliveryType: $0.deliveryType, - startDate: $0.startDate, - endDate: $0.endDate, - volume: $0.volume, - insulinType: $0.insulinType - ) - }), - carbEntries: carbEntries.map { - FixtureCarbEntry( - absorptionTime: $0.absorptionTime, - startDate: $0.startDate, - quantity: $0.quantity - ) - }, - basal: basal, - sensitivity: sensitivity, - carbRatio: carbRatio, - target: target, - suspendThreshold: suspendThreshold, - maxBolus: maxBolus, - maxBasalRate: maxBasalRate, - useIntegralRetrospectiveCorrection: useIntegralRetrospectiveCorrection, - recommendationInsulinType: recommendationInsulinType, - recommendationType: recommendationType - ) - } - - public func printFixture() { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - encoder.dateEncodingStrategy = .iso8601 - if let data = try? encoder.encode(self.simplifiedForFixture), - let json = String(data: data, encoding: .utf8) - { - print(json) - } - } -} diff --git a/Sources/LoopAlgorithm/LoopPredictionInput.swift b/Sources/LoopAlgorithm/LoopPredictionInput.swift index d9f1683..f20bc3d 100644 --- a/Sources/LoopAlgorithm/LoopPredictionInput.swift +++ b/Sources/LoopAlgorithm/LoopPredictionInput.swift @@ -114,49 +114,3 @@ extension LoopPredictionInput: Codable where CarbType == FixtureCarbEntry, Gluco case includePositiveVelocityAndRC } } - -extension LoopPredictionInput { - - var simplifiedForFixture: LoopPredictionInput { - return LoopPredictionInput( - glucoseHistory: glucoseHistory.map { - return FixtureGlucoseSample( - startDate: $0.startDate, - quantity: $0.quantity, - isDisplayOnly: $0.isDisplayOnly) - }, - doses: doses.map { - FixtureInsulinDose( - deliveryType: $0.deliveryType == .bolus ? .bolus : .basal, - startDate: $0.startDate, - endDate: $0.endDate, - volume: $0.volume, - insulinType: $0.insulinType - ) - }, - carbEntries: carbEntries.map { - return FixtureCarbEntry( - absorptionTime: $0.absorptionTime, - startDate: $0.startDate, - quantity: $0.quantity) - }, - basal: basal, - sensitivity: sensitivity, - carbRatio: carbRatio, - algorithmEffectsOptions: algorithmEffectsOptions, - useIntegralRetrospectiveCorrection: useIntegralRetrospectiveCorrection, - includePositiveVelocityAndRC: includePositiveVelocityAndRC - ) - } - - public func printFixture() { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - encoder.dateEncodingStrategy = .iso8601 - if let data = try? encoder.encode(self.simplifiedForFixture), - let json = String(data: data, encoding: .utf8) - { - print(json) - } - } -} diff --git a/Tests/LoopAlgorithmTests/Mocks/IntegralRetrospectiveCorrectionTests.swift b/Tests/LoopAlgorithmTests/Mocks/IntegralRetrospectiveCorrectionTests.swift new file mode 100644 index 0000000..c8f810c --- /dev/null +++ b/Tests/LoopAlgorithmTests/Mocks/IntegralRetrospectiveCorrectionTests.swift @@ -0,0 +1,48 @@ +// +// IntegralRetrospectiveCorrectionTests.swift +// +// +// Created by Pete Schwamb on 2/21/24. +// + +import XCTest +@testable import LoopAlgorithm + +final class IntegralRetrospectiveCorrectionTests: XCTestCase { + + let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + }() + + + func testIntegralRestrospectiveCorrection() { + let startDate = dateFormatter.date(from: "2015-07-13T12:02:37")! + + func d(_ interval: TimeInterval) -> Date { + return startDate.addingTimeInterval(interval) + } + + let startingGlucose = SimpleGlucoseValue(startDate: startDate, quantity: .glucose(value: 100)) + + // +10 mg/dL over 30 minutes + let retrospectiveGlucoseDiscrepanciesSummed = [ + GlucoseChange(startDate: d(.minutes(-30)), endDate: startDate, quantity: .glucose(value: 10)) + ] + + let irc = IntegralRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration) + + let effect = irc.computeEffect( + startingAt: startingGlucose, + retrospectiveGlucoseDiscrepanciesSummed: retrospectiveGlucoseDiscrepanciesSummed, + recencyInterval: TimeInterval(minutes: 15), + retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval + ) + + XCTAssertEqual(effect.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 110) + XCTAssertEqual(effect.last?.startDate, dateFormatter.date(from: "2015-07-13T13:00:00")!) + } +} From 9c880cd7760a0e9385528e234367cac3ed473bcf Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 21 Feb 2024 16:56:38 -0600 Subject: [PATCH 23/26] Cleanup --- .../LoopAlgorithmDoseRecommendation.swift | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/Sources/LoopAlgorithm/LoopAlgorithmDoseRecommendation.swift b/Sources/LoopAlgorithm/LoopAlgorithmDoseRecommendation.swift index adafa97..7f8024f 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithmDoseRecommendation.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithmDoseRecommendation.swift @@ -20,16 +20,3 @@ public struct LoopAlgorithmDoseRecommendation: Equatable { } extension LoopAlgorithmDoseRecommendation: Codable {} - -extension LoopAlgorithmDoseRecommendation { - public func printFixture() { - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - encoder.dateEncodingStrategy = .iso8601 - if let data = try? encoder.encode(self), - let json = String(data: data, encoding: .utf8) - { - print(json) - } - } -} From ac4dfed050df5282880513508814fe4f037ecba9 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 21 Feb 2024 17:16:36 -0600 Subject: [PATCH 24/26] Update versions in docs --- Package.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index de42ae3..7ad9082 100644 --- a/Package.swift +++ b/Package.swift @@ -8,7 +8,8 @@ let package = Package( platforms: [ .macOS(.v13), .iOS(.v15), - .tvOS(.v15) + .tvOS(.v15), + .watchOS(.v8) ], products: [ // Products define the executables and libraries a package produces, making them visible to other packages. From 8f9b0b102ec5877c5d3ad3f3c94373a573dae6d0 Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 21 Feb 2024 17:19:09 -0600 Subject: [PATCH 25/26] Add license --- LICENSE.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 LICENSE.md diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..20cdf5c --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,24 @@ +# Loop is released under the MIT license (MIT) +With exceptions for frameworks and graphics, noted below. + +Copyright (c) 2024 LoopKit Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + From a03c657561efbf3b37b155d0b33d2304a1dbd8ec Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Wed, 21 Feb 2024 17:53:12 -0600 Subject: [PATCH 26/26] Add circleci build config --- .circleci/config.yml | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .circleci/config.yml diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..88a4444 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,38 @@ +version: 2.1 + +# +# Jobs +# + +jobs: + test: + macos: + xcode: 15.2.0 + steps: + - checkout + - run: + name: Test + command: | + set -o pipefail && xcodebuild -scheme LoopAlgorithm test -destination "platform=iOS Simulator,name=iPhone 15,OS=latest" | xcpretty + - store_test_results: + path: test_output + package: + macos: + xcode: 15.2.0 + steps: + - checkout + - run: + name: Build LoopAlgorithmPackage + command: | + set -o pipefail && xcodebuild build -scheme LoopAlgorithm -destination "platform=iOS Simulator,name=iPhone 15,OS=latest" | xcpretty +# +# Workflows +# + +workflows: + version: 2.1 + build_and_test: + jobs: + - test + - package +