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
+
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/.periphery.yml b/.periphery.yml
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/.periphery.yml
@@ -0,0 +1 @@
+{}
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/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.
+
+
diff --git a/Package.swift b/Package.swift
new file mode 100644
index 0000000..7ad9082
--- /dev/null
+++ b/Package.swift
@@ -0,0 +1,33 @@
+// 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),
+ .watchOS(.v8)
+ ],
+ 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/AutomaticDoseRecommendation.swift b/Sources/LoopAlgorithm/AutomaticDoseRecommendation.swift
new file mode 100644
index 0000000..a3a64b0
--- /dev/null
+++ b/Sources/LoopAlgorithm/AutomaticDoseRecommendation.swift
@@ -0,0 +1,21 @@
+//
+// AutomaticDoseRecommendation.swift
+// LoopAlgorithm
+//
+// 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
+ }
+}
+
+extension AutomaticDoseRecommendation: Codable {}
diff --git a/Sources/LoopAlgorithm/Carbs/AbsorbedCarbValue.swift b/Sources/LoopAlgorithm/Carbs/AbsorbedCarbValue.swift
new file mode 100644
index 0000000..4d50f11
--- /dev/null
+++ b/Sources/LoopAlgorithm/Carbs/AbsorbedCarbValue.swift
@@ -0,0 +1,65 @@
+//
+// AbsorbedCarbValue.swift
+// LoopAlgorithm
+//
+// 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
+ )
+ }
+
+ // MARK: SampleValue
+
+ public var quantity: HKQuantity {
+ return clamped
+ }
+
+ public var startDate: Date {
+ return estimatedDate.start
+ }
+}
diff --git a/Sources/LoopAlgorithm/Carbs/CarbEntry.swift b/Sources/LoopAlgorithm/Carbs/CarbEntry.swift
new file mode 100644
index 0000000..9c6d39a
--- /dev/null
+++ b/Sources/LoopAlgorithm/Carbs/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/Carbs/CarbMath.swift b/Sources/LoopAlgorithm/Carbs/CarbMath.swift
new file mode 100644
index 0000000..45d01d1
--- /dev/null
+++ b/Sources/LoopAlgorithm/Carbs/CarbMath.swift
@@ -0,0 +1,793 @@
+//
+// 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 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: - 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 {
+
+ public 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
+ public 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)
+ }
+}
+
+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)
+ )
+ }
+ }
+
+ 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
+ }
+}
+
+
+// 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,
+ 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 []
+ }
+
+ var date = startDate
+ var values = [CarbValue]()
+
+ repeat {
+ let value = reduce(0.0) { (value, entry) -> Double in
+ return value + entry.dynamicCarbsOnBoard(
+ at: date,
+ defaultAbsorptionTime: defaultAbsorptionTime,
+ delay: delay,
+ delta: delta,
+ absorptionModel: absorptionModel
+ )
+ }
+
+ values.append(CarbValue(startDate: date, value: value))
+ date = date.addingTimeInterval(delta)
+ } 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 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/Carbs/CarbStatus.swift b/Sources/LoopAlgorithm/Carbs/CarbStatus.swift
new file mode 100644
index 0000000..dc96074
--- /dev/null
+++ b/Sources/LoopAlgorithm/Carbs/CarbStatus.swift
@@ -0,0 +1,130 @@
+//
+// CarbStatus.swift
+// LoopAlgorithm
+//
+// 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/Carbs/CarbValue.swift b/Sources/LoopAlgorithm/Carbs/CarbValue.swift
new file mode 100644
index 0000000..6ae9a7d
--- /dev/null
+++ b/Sources/LoopAlgorithm/Carbs/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/Carbs/FixtureCarbEntry.swift b/Sources/LoopAlgorithm/Carbs/FixtureCarbEntry.swift
new file mode 100644
index 0000000..c2cd40d
--- /dev/null
+++ b/Sources/LoopAlgorithm/Carbs/FixtureCarbEntry.swift
@@ -0,0 +1,41 @@
+//
+// 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
+ public var foodType: String?
+}
+
+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/DoseRecommendationType.swift b/Sources/LoopAlgorithm/DoseRecommendationType.swift
new file mode 100644
index 0000000..d5c307c
--- /dev/null
+++ b/Sources/LoopAlgorithm/DoseRecommendationType.swift
@@ -0,0 +1,25 @@
+//
+// DoseRecommendationType.swift
+// LoopAlgorithm
+//
+// 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/Extensions/ClosedRange.swift b/Sources/LoopAlgorithm/Extensions/ClosedRange.swift
new file mode 100644
index 0000000..4637dd4
--- /dev/null
+++ b/Sources/LoopAlgorithm/Extensions/ClosedRange.swift
@@ -0,0 +1,17 @@
+//
+// ClosedRange.swift
+// LoopAlgorithm
+//
+// Created by Michael Pangburn on 6/23/20.
+// Copyright © 2020 LoopKit Authors. All rights reserved.
+//
+import HealthKit
+
+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/HKQuantity.swift b/Sources/LoopAlgorithm/Extensions/HKQuantity.swift
new file mode 100644
index 0000000..ccad47c
--- /dev/null
+++ b/Sources/LoopAlgorithm/Extensions/HKQuantity.swift
@@ -0,0 +1,17 @@
+//
+// HKQuantity.swift
+// LoopAlgorithm
+//
+// 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..0c524a7
--- /dev/null
+++ b/Sources/LoopAlgorithm/Extensions/HKUnit.swift
@@ -0,0 +1,16 @@
+//
+// 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))
+ }()
+}
+
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/Glucose/FixtureGlucoseSample.swift b/Sources/LoopAlgorithm/Glucose/FixtureGlucoseSample.swift
new file mode 100644
index 0000000..a54a4d6
--- /dev/null
+++ b/Sources/LoopAlgorithm/Glucose/FixtureGlucoseSample.swift
@@ -0,0 +1,74 @@
+//
+// FixtureGlucoseSample.swift
+// LoopAlgorithm
+//
+// Copyright © 2018 LoopKit Authors. All rights reserved.
+//
+
+import HealthKit
+
+public struct FixtureGlucoseSample: GlucoseSampleValue, Equatable {
+ public static let defaultProvenanceIdentifier = "com.LoopKit.Loop"
+
+ public let provenanceIdentifier: String
+ public let startDate: Date
+ 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,
+ startDate: Date,
+ quantity: HKQuantity,
+ isDisplayOnly: Bool = false,
+ wasUserEntered: Bool = false
+ ) {
+ self.provenanceIdentifier = provenanceIdentifier
+ self.startDate = startDate
+ self.quantity = quantity
+ self.isDisplayOnly = isDisplayOnly
+ self.wasUserEntered = wasUserEntered
+ }
+}
+
+extension FixtureGlucoseSample: Codable {
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+
+ 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,
+ wasUserEntered: wasUserEntered
+ )
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ if provenanceIdentifier != Self.defaultProvenanceIdentifier {
+ try container.encode(provenanceIdentifier, forKey: .provenanceIdentifier)
+ }
+ try container.encode(startDate, forKey: .startDate)
+ try container.encode(quantity.doubleValue(for: .milligramsPerDeciliter), forKey: .quantity)
+ if isDisplayOnly {
+ try container.encode(isDisplayOnly, forKey: .isDisplayOnly)
+ }
+ if wasUserEntered {
+ try container.encode(wasUserEntered, forKey: .wasUserEntered)
+ }
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case provenanceIdentifier
+ case startDate
+ case quantity
+ case isDisplayOnly
+ case wasUserEntered
+ }
+}
diff --git a/Sources/LoopAlgorithm/Glucose/GlucoseChange.swift b/Sources/LoopAlgorithm/Glucose/GlucoseChange.swift
new file mode 100644
index 0000000..9a2ea56
--- /dev/null
+++ b/Sources/LoopAlgorithm/Glucose/GlucoseChange.swift
@@ -0,0 +1,33 @@
+//
+// GlucoseChange.swift
+// LoopAlgorithm
+//
+// 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
+
+ public init(startDate: Date, endDate: Date, quantity: HKQuantity) {
+ self.startDate = startDate
+ self.endDate = endDate
+ self.quantity = quantity
+ }
+}
+
+
+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/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/GlucoseEffect.swift b/Sources/LoopAlgorithm/Glucose/GlucoseEffect.swift
new file mode 100644
index 0000000..3532af8
--- /dev/null
+++ b/Sources/LoopAlgorithm/Glucose/GlucoseEffect.swift
@@ -0,0 +1,46 @@
+//
+// 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
+ }
+}
diff --git a/Sources/LoopAlgorithm/Glucose/GlucoseEffectVelocity.swift b/Sources/LoopAlgorithm/Glucose/GlucoseEffectVelocity.swift
new file mode 100644
index 0000000..056959a
--- /dev/null
+++ b/Sources/LoopAlgorithm/Glucose/GlucoseEffectVelocity.swift
@@ -0,0 +1,41 @@
+//
+// 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 {
+ public 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
+ )
+ )
+ }
+}
diff --git a/Sources/LoopAlgorithm/Glucose/GlucoseMath.swift b/Sources/LoopAlgorithm/Glucose/GlucoseMath.swift
new file mode 100644
index 0000000..1fcd59b
--- /dev/null
+++ b/Sources/LoopAlgorithm/Glucose/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
+ public func isContinuous(within interval: TimeInterval = TimeInterval(5 * 60)) -> 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/Glucose/GlucoseSampleValue.swift b/Sources/LoopAlgorithm/Glucose/GlucoseSampleValue.swift
new file mode 100644
index 0000000..111411d
--- /dev/null
+++ b/Sources/LoopAlgorithm/Glucose/GlucoseSampleValue.swift
@@ -0,0 +1,26 @@
+//
+// GlucoseSampleValue.swift
+// LoopAlgorithm
+//
+// 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 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/Glucose/GlucoseValue.swift b/Sources/LoopAlgorithm/Glucose/GlucoseValue.swift
new file mode 100644
index 0000000..dcd36df
--- /dev/null
+++ b/Sources/LoopAlgorithm/Glucose/GlucoseValue.swift
@@ -0,0 +1,92 @@
+//
+// 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.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))
+ }
+
+ public func encode(to encoder: Encoder) throws {
+ var container = encoder.container(keyedBy: CodingKeys.self)
+ try container.encode(startDate, forKey: .startDate)
+ 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)
+ }
+
+ 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/Insulin/DoseMath.swift b/Sources/LoopAlgorithm/Insulin/DoseMath.swift
new file mode 100644
index 0000000..514857d
--- /dev/null
+++ b/Sources/LoopAlgorithm/Insulin/DoseMath.swift
@@ -0,0 +1,339 @@
+//
+// 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: SimpleGlucoseValue(minimum))
+ case .inRange:
+ return .predictedGlucoseInRange
+ case .entirelyBelowRange(min: let min, minTarget: _, units: _):
+ 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: SimpleGlucoseValue(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)
+ }
+}
+
+/// 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 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.
+ ///
+ /// - Parameters:
+ /// - 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. 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(
+ 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?
+
+ 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 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)
+ }
+
+ 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)
+
+ 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
+ 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: SimpleGlucoseValue(first))
+ }
+
+ return bolus
+ }
+
+}
diff --git a/Sources/LoopAlgorithm/Insulin/DoseType.swift b/Sources/LoopAlgorithm/Insulin/DoseType.swift
new file mode 100644
index 0000000..faa7d53
--- /dev/null
+++ b/Sources/LoopAlgorithm/Insulin/DoseType.swift
@@ -0,0 +1,17 @@
+//
+// DoseType.swift
+// LoopAlgorithm
+//
+// Copyright © 2017 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+
+
+/// A general set of ways insulin can be delivered by a pump
+public enum InsulinDeliveryType: String, CaseIterable, Equatable {
+ case bolus
+ case basal
+}
+
+extension InsulinDeliveryType: Codable {}
diff --git a/Sources/LoopAlgorithm/Insulin/DoseUnit.swift b/Sources/LoopAlgorithm/Insulin/DoseUnit.swift
new file mode 100644
index 0000000..2f4b0f9
--- /dev/null
+++ b/Sources/LoopAlgorithm/Insulin/DoseUnit.swift
@@ -0,0 +1,17 @@
+//
+// DoseUnit.swift
+// LoopAlgorithm
+//
+// 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/Insulin/ExponentialInsulinModel.swift b/Sources/LoopAlgorithm/Insulin/ExponentialInsulinModel.swift
new file mode 100644
index 0000000..75e5451
--- /dev/null
+++ b/Sources/LoopAlgorithm/Insulin/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/Insulin/ExponentialInsulinModelPreset.swift b/Sources/LoopAlgorithm/Insulin/ExponentialInsulinModelPreset.swift
new file mode 100644
index 0000000..2a17926
--- /dev/null
+++ b/Sources/LoopAlgorithm/Insulin/ExponentialInsulinModelPreset.swift
@@ -0,0 +1,80 @@
+//
+// ExponentialInsulinModelPreset.swift
+// LoopAlgorithm
+//
+// 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)
+ }
+ }
+
+ public 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)
+ }
+}
diff --git a/Sources/LoopAlgorithm/Insulin/FixtureInsulinDose.swift b/Sources/LoopAlgorithm/Insulin/FixtureInsulinDose.swift
new file mode 100644
index 0000000..0c8c748
--- /dev/null
+++ b/Sources/LoopAlgorithm/Insulin/FixtureInsulinDose.swift
@@ -0,0 +1,55 @@
+//
+// FixtureInsulinDose.swift
+//
+// Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+
+public struct FixtureInsulinDose: InsulinDose, Equatable {
+ public var deliveryType: InsulinDeliveryType
+
+ public var startDate: Date
+
+ public var endDate: Date
+
+ 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 {
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ 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)
+ 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(deliveryType, forKey: .type)
+ try container.encode(startDate, forKey: .startDate)
+ try container.encode(endDate, forKey: .endDate)
+ try container.encode(volume, forKey: .volume)
+ try container.encodeIfPresent(insulinType?.stringValue, 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..9223142
--- /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 deliveryType: InsulinDeliveryType { get }
+ var startDate: Date { get }
+ var endDate: Date { get }
+ var volume: Double { get }
+ var insulinType: InsulinType? { get }
+}
+
+extension InsulinDose {
+ public 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
new file mode 100644
index 0000000..e5b3273
--- /dev/null
+++ b/Sources/LoopAlgorithm/Insulin/InsulinMath.swift
@@ -0,0 +1,405 @@
+//
+// 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 BasalRelativeDose {
+ 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)
+ }
+ }
+}
+
+
+extension InsulinDose {
+
+ /// 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]) -> [BasalRelativeDose] {
+
+ guard deliveryType == .basal else {
+ preconditionFailure("basalDeliveryTotal called on dose that is not a temp basal!")
+ }
+
+ var doses: [BasalRelativeDose] = []
+
+ for (index, basalItem) in basalHistory.enumerated() {
+ let startDate: Date
+ let endDate: Date
+
+ if index == 0 {
+ startDate = self.startDate
+ } else {
+ startDate = basalItem.startDate
+ }
+
+ if index == basalHistory.count - 1 {
+ endDate = self.endDate
+ } else {
+ endDate = basalHistory[index + 1].startDate
+ }
+
+ let segmentStartDate = max(startDate, self.startDate)
+ 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: .basal(scheduledRate: basalItem.value),
+ startDate: segmentStartDate,
+ endDate: segmentEndDate,
+ volume: segmentVolume
+ )
+
+ doses.append(annotatedDose)
+ }
+
+ return doses
+ }
+}
+
+extension Collection where Element: InsulinDose {
+
+ /// 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]) -> [BasalRelativeDose] {
+ var annotatedDoses: [BasalRelativeDose] = []
+
+ for dose in self {
+ if dose.deliveryType == .basal {
+ let basalItems = basalHistory.filterDateRange(dose.startDate, dose.endDate)
+ annotatedDoses += dose.annotated(with: basalItems)
+ } else {
+ annotatedDoses.append(BasalRelativeDose.fromBolus(dose: dose))
+ }
+ }
+
+ return annotatedDoses
+ }
+}
+
+extension Collection where Element == BasalRelativeDose {
+
+ /**
+ 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 insulinOnBoardTimeline(
+ 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: Insulin amount remaining at specified time
+ */
+ 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
+ }
+}
diff --git a/Sources/LoopAlgorithm/Insulin/InsulinModel.swift b/Sources/LoopAlgorithm/Insulin/InsulinModel.swift
new file mode 100644
index 0000000..deb62f7
--- /dev/null
+++ b/Sources/LoopAlgorithm/Insulin/InsulinModel.swift
@@ -0,0 +1,28 @@
+//
+// InsulinModel.swift
+// LoopAlgorithm
+//
+// Created by Pete Schwamb on 7/26/17.
+// Copyright © 2017 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+
+
+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
+ ///
+ /// - 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/Insulin/InsulinModelProvider.swift b/Sources/LoopAlgorithm/Insulin/InsulinModelProvider.swift
new file mode 100644
index 0000000..2dfc294
--- /dev/null
+++ b/Sources/LoopAlgorithm/Insulin/InsulinModelProvider.swift
@@ -0,0 +1,45 @@
+//
+// InsulinModelProvider.swift
+//
+// 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? = nil) {
+ 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/Insulin/InsulinType.swift b/Sources/LoopAlgorithm/Insulin/InsulinType.swift
new file mode 100644
index 0000000..59f0e4b
--- /dev/null
+++ b/Sources/LoopAlgorithm/Insulin/InsulinType.swift
@@ -0,0 +1,18 @@
+//
+// InsulinType.swift
+// LoopAlgorithm
+//
+// 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
+}
diff --git a/Sources/LoopAlgorithm/Insulin/InsulinValue.swift b/Sources/LoopAlgorithm/Insulin/InsulinValue.swift
new file mode 100644
index 0000000..c8c665c
--- /dev/null
+++ b/Sources/LoopAlgorithm/Insulin/InsulinValue.swift
@@ -0,0 +1,26 @@
+//
+// InsulinValue.swift
+// LoopAlgorithm
+//
+// 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 {}
diff --git a/Sources/LoopAlgorithm/Insulin/RelativeDelivery.swift b/Sources/LoopAlgorithm/Insulin/RelativeDelivery.swift
new file mode 100644
index 0000000..9515b58
--- /dev/null
+++ b/Sources/LoopAlgorithm/Insulin/RelativeDelivery.swift
@@ -0,0 +1,64 @@
+//
+// BasalRelativeDose.swift
+//
+//
+// Created by Pete Schwamb on 12/21/23.
+// Copyright © 2023 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+
+public enum BasalRelativeDoseType: Equatable {
+ case bolus
+ case basal(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?
+
+ public 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 {
+ /// 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 .basal(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.deliveryType == .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
new file mode 100644
index 0000000..13356c5
--- /dev/null
+++ b/Sources/LoopAlgorithm/LoopAlgorithm.swift
@@ -0,0 +1,523 @@
+//
+// 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
+ case futureBasalNotAllowed
+}
+
+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 totalRetrospectiveCorrectionEffect: HKQuantity?
+
+ public init(
+ insulin: [GlucoseEffect],
+ carbs: [GlucoseEffect],
+ carbStatus: [CarbStatus],
+ retrospectiveCorrection: [GlucoseEffect],
+ momentum: [GlucoseEffect],
+ insulinCounteraction: [GlucoseEffectVelocity],
+ retrospectiveGlucoseDiscrepancies: [GlucoseChange],
+ totalRetrospectiveCorrectionEffect: HKQuantity? = nil
+ ) {
+ self.insulin = insulin
+ self.carbs = carbs
+ self.carbStatus = carbStatus
+ self.retrospectiveCorrection = retrospectiveCorrection
+ self.momentum = momentum
+ self.insulinCounteraction = insulinCounteraction
+ self.retrospectiveGlucoseDiscrepancies = retrospectiveGlucoseDiscrepancies
+ self.totalRetrospectiveCorrectionEffect = totalRetrospectiveCorrectionEffect
+ }
+}
+
+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: [BasalRelativeDose]
+ 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.
+ /// Outputs may be incomplete, if there are issues with the provided data, but as many intermediate derived fields as can be computed, will be computed.
+ ///
+ /// Returns nil if the normal scheduled basal, or active temporary basal, is sufficient.
+ ///
+ ///
+ /// - Parameters:
+ /// - start: The starting time of the glucose prediction.
+ /// - glucoseHistory: History of glucose values: t-10h to t. Must include at least one value.
+ /// - doses: History of insulin doses: t-16h to t
+ /// - carbEntries: History of carb entries: t-10h to t
+ /// - basal: Scheduled basal rate timeline: t-16h to t
+ /// - sensitivity: Insulin sensitivity timeline: t-16h to t (eventually with mid-absorption isf changes, it will be t-10h to t)
+ /// - carbRatio: Carb ratio timeline: t-10h to t+6h
+ /// - 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: [GlucoseType],
+ doses: [InsulinDoseType],
+ carbEntries: [CarbType],
+ basal: [AbsoluteScheduleValue],
+ sensitivity: [AbsoluteScheduleValue],
+ carbRatio: [AbsoluteScheduleValue],
+ algorithmEffectsOptions: AlgorithmEffectsOptions = .all,
+ useIntegralRetrospectiveCorrection: Bool = false,
+ includingPositiveVelocityAndRC: Bool = true,
+ carbAbsorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption()
+ ) -> LoopPrediction where CarbType: CarbEntry, GlucoseType: GlucoseSampleValue, InsulinDoseType: InsulinDose {
+
+ var prediction: [PredictedGlucoseValue] = []
+ var insulinEffects: [GlucoseEffect] = []
+ var carbEffects: [GlucoseEffect] = []
+ var retrospectiveCorrectionEffects: [GlucoseEffect] = []
+ var momentumEffects: [GlucoseEffect] = []
+ var insulinCounteractionEffects: [GlucoseEffectVelocity] = []
+ var retrospectiveGlucoseDiscrepanciesSummed: [GlucoseChange] = []
+ var totalRetrospectiveCorrectionEffect: HKQuantity?
+ var activeInsulin: Double?
+ var activeCarbs: Double?
+ //var carbStatus: [CarbStatus] = []
+ var dosesRelativeToBasal: [BasalRelativeDose] = []
+
+ // Ensure basal history covers doses
+ 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: minDate,
+ to: maxDate)
+
+ // ICE
+ insulinCounteractionEffects = glucoseHistory.counteractionEffects(to: insulinEffects)
+ } else {
+ activeInsulin = 0
+ }
+
+ // Carb Effects
+ let 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
+ )
+
+ totalRetrospectiveCorrectionEffect = 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 = start.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,
+ totalRetrospectiveCorrectionEffect: totalRetrospectiveCorrectionEffect
+ ),
+ 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
+ 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
+ static func recommendTempBasal(
+ for correction: InsulinCorrection,
+ 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
+ static func recommendAutomaticDose(
+ for correction: InsulinCorrection,
+ 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: GlucoseSampleValue,
+ 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: SimpleGlucoseValue(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) -> LoopAlgorithmOutput {
+
+ let prediction = generatePrediction(
+ start: input.predictionStart,
+ glucoseHistory: input.glucoseHistory,
+ doses: input.doses,
+ 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
+ }
+
+ // 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 {
+ 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 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: sensitivityEnd,
+ value: sensitivityAtPredictionStart.value
+ )
+ let sensitivityForDosing = [sensitivityOverPrediction]
+
+ let correction = insulinCorrection(
+ prediction: prediction.glucose,
+ at: input.predictionStart,
+ target: input.target,
+ suspendThreshold: suspendThreshold,
+ sensitivity: sensitivityForDosing,
+ 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,
+ 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,
+ 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..7f8024f
--- /dev/null
+++ b/Sources/LoopAlgorithm/LoopAlgorithmDoseRecommendation.swift
@@ -0,0 +1,22 @@
+//
+// LoopAlgorithmDoseRecommendation.swift
+// LoopAlgorithm
+//
+// 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 {}
diff --git a/Sources/LoopAlgorithm/LoopAlgorithmInput.swift b/Sources/LoopAlgorithm/LoopAlgorithmInput.swift
new file mode 100644
index 0000000..3cf829f
--- /dev/null
+++ b/Sources/LoopAlgorithm/LoopAlgorithmInput.swift
@@ -0,0 +1,266 @@
+//
+// LoopAlgorithmInput.swift
+// LoopAlgorithm
+//
+// 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: [GlucoseType]
+ public var doses: [InsulinDoseType]
+ public var carbEntries: [CarbType]
+ 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 Glucose {
+ var value: Double
+ var isCalibration: Bool
+ var date: Date
+ }
+
+ public init(
+ predictionStart: Date,
+ glucoseHistory: [GlucoseType],
+ doses: [InsulinDoseType],
+ carbEntries: [CarbType],
+ 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 where CarbType == FixtureCarbEntry, GlucoseType == FixtureGlucoseSample, InsulinDoseType == FixtureInsulinDose {
+ 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
+ FixtureGlucoseSample(
+ startDate: sample.date,
+ quantity: HKQuantity(unit: .milligramsPerDeciliter, doubleValue: sample.value),
+ isDisplayOnly: sample.isCalibration
+ )
+ }
+
+ self.doses = try container.decode([FixtureInsulinDose].self, forKey: .doses)
+ self.carbEntries = try container.decode([FixtureCarbEntry].self, forKey: .carbEntries)
+ self.basal = try container.decode([AbsoluteScheduleValue].self, forKey: .basal)
+ let sensitivityMgdl = try container.decode([AbsoluteScheduleValue].self, forKey: .sensitivity)
+ self.sensitivity = sensitivityMgdl.map { AbsoluteScheduleValue(startDate: $0.startDate, endDate: $0.endDate, value: 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)
+ 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)
+ 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.stringValue, 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 stringValue: 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
+ }
+ }
+}
diff --git a/Sources/LoopAlgorithm/LoopAlgorithmOutput.swift b/Sources/LoopAlgorithm/LoopAlgorithmOutput.swift
new file mode 100644
index 0000000..d2d3656
--- /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: [BasalRelativeDose]
+ public var activeInsulin: Double?
+ public var activeCarbs: Double?
+
+ public init(
+ recommendationResult: Result,
+ predictedGlucose: [PredictedGlucoseValue],
+ effects: LoopAlgorithmEffects,
+ dosesRelativeToBasal: [BasalRelativeDose],
+ 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..ff0c782
--- /dev/null
+++ b/Sources/LoopAlgorithm/LoopMath.swift
@@ -0,0 +1,326 @@
+//
+// 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)
+
+
+ public 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))
+ }
+}
+
+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.. {
+ // Algorithm input time range: t-10h to t
+ public var glucoseHistory: [GlucoseType]
+
+ // Algorithm input time range: t-16h to t
+ public var doses: [InsulinDoseType]
+
+ // Algorithm input time range: t-10h to t
+ public var carbEntries: [CarbType]
+
+ // Expected time range coverage: t-16h to t
+ public var basal: [AbsoluteScheduleValue]
+
+ // 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: [GlucoseType],
+ doses: [InsulinDoseType],
+ carbEntries: [CarbType],
+ 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 where CarbType == FixtureCarbEntry, GlucoseType == FixtureGlucoseSample, InsulinDoseType == FixtureInsulinDose {
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+
+ self.glucoseHistory = try container.decode([FixtureGlucoseSample].self, forKey: .glucoseHistory)
+ self.doses = try container.decode([FixtureInsulinDose].self, forKey: .doses)
+ self.carbEntries = try container.decode([FixtureCarbEntry].self, forKey: .carbEntries)
+ self.basal = try container.decode([AbsoluteScheduleValue].self, forKey: .basal)
+ let sensitivityMgdl = try container.decode([AbsoluteScheduleValue].self, forKey: .sensitivity)
+ self.sensitivity = sensitivityMgdl.map { AbsoluteScheduleValue(startDate: $0.startDate, endDate: $0.endDate, value: 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
+ }
+}
diff --git a/Sources/LoopAlgorithm/ManualBolusRecommendation.swift b/Sources/LoopAlgorithm/ManualBolusRecommendation.swift
new file mode 100644
index 0000000..46cb0b9
--- /dev/null
+++ b/Sources/LoopAlgorithm/ManualBolusRecommendation.swift
@@ -0,0 +1,106 @@
+//
+// ManualBolusRecommendation.swift
+// LoopAlgorithm
+//
+// Created by Pete Schwamb on 1/2/17.
+// Copyright © 2017 LoopKit Authors. All rights reserved.
+//
+
+import Foundation
+import HealthKit
+
+public enum BolusRecommendationNotice: Equatable {
+ case glucoseBelowSuspendThreshold(minGlucose: SimpleGlucoseValue)
+ case currentGlucoseBelowTarget(glucose: SimpleGlucoseValue)
+ case predictedGlucoseBelowTarget(minGlucose: SimpleGlucoseValue)
+ case predictedGlucoseInRange
+ case allGlucoseBelowTarget(minGlucose: SimpleGlucoseValue)
+}
+
+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 init(amount: Double, notice: BolusRecommendationNotice? = nil) {
+ self.amount = amount
+ self.notice = notice
+ }
+}
+
+extension ManualBolusRecommendation: Codable {}
+
+extension ManualBolusRecommendation: Equatable {
+ 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
new file mode 100644
index 0000000..b1b0c9f
--- /dev/null
+++ b/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift
@@ -0,0 +1,171 @@
+//
+// 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()
+
+ 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 {
+ totalGlucoseCorrectionEffect = nil
+ return( [] )
+ }
+
+ // Default values if we are not able to calculate integral retrospective correction
+ 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) {
+
+ // 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 )
+ }
+
+}
diff --git a/Sources/LoopAlgorithm/RetrospectiveCorrection/RetrospectiveCorrection.swift b/Sources/LoopAlgorithm/RetrospectiveCorrection/RetrospectiveCorrection.swift
new file mode 100644
index 0000000..e2724e8
--- /dev/null
+++ b/Sources/LoopAlgorithm/RetrospectiveCorrection/RetrospectiveCorrection.swift
@@ -0,0 +1,32 @@
+//
+// 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 {
+
+ /// 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/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift b/Sources/LoopAlgorithm/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift
new file mode 100644
index 0000000..6809ef7
--- /dev/null
+++ b/Sources/LoopAlgorithm/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift
@@ -0,0 +1,57 @@
+//
+// 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 {
+ /// 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)
+ }
+}
diff --git a/Sources/LoopAlgorithm/SampleValue.swift b/Sources/LoopAlgorithm/SampleValue.swift
new file mode 100644
index 0000000..f3e1153
--- /dev/null
+++ b/Sources/LoopAlgorithm/SampleValue.swift
@@ -0,0 +1,101 @@
+//
+// 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 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)
+ }
+}
diff --git a/Sources/LoopAlgorithm/TempBasalRecommendation.swift b/Sources/LoopAlgorithm/TempBasalRecommendation.swift
new file mode 100644
index 0000000..b77256e
--- /dev/null
+++ b/Sources/LoopAlgorithm/TempBasalRecommendation.swift
@@ -0,0 +1,22 @@
+//
+// TempBasalRecommendation.swift
+// LoopAlgorithm
+//
+// 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 init(unitsPerHour: Double, duration: TimeInterval) {
+ self.unitsPerHour = unitsPerHour
+ self.duration = duration
+ }
+}
+
+extension TempBasalRecommendation: Codable {}
diff --git a/Tests/LoopAlgorithmTests/CarbMathTests.swift b/Tests/LoopAlgorithmTests/CarbMathTests.swift
new file mode 100644
index 0000000..d692be8
--- /dev/null
+++ b/Tests/LoopAlgorithmTests/CarbMathTests.swift
@@ -0,0 +1,745 @@
+//
+// 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
+
+public typealias JSONDictionary = [String: Any]
+
+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/CorrectionDosingTests.swift b/Tests/LoopAlgorithmTests/CorrectionDosingTests.swift
new file mode 100644
index 0000000..14afaa1
--- /dev/null
+++ b/Tests/LoopAlgorithmTests/CorrectionDosingTests.swift
@@ -0,0 +1,551 @@
+//
+// 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() {
+ let lowerBound: HKQuantity = .glucose(value: 90)
+ let upperBound: HKQuantity = .glucose(value: 120)
+
+ target = [AbsoluteScheduleValue(
+ startDate: testDate.addingTimeInterval(.hours(-24)),
+ endDate: testDate.addingTimeInterval(.hours(24)),
+ value: lowerBound...upperBound
+ )]
+
+ 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/Extensions/DateFormatter.swift b/Tests/LoopAlgorithmTests/Extensions/DateFormatter.swift
new file mode 100644
index 0000000..449703f
--- /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) -> 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/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/carbs_with_isf_change_recommendation.json b/Tests/LoopAlgorithmTests/Fixtures/carbs_with_isf_change_recommendation.json
new file mode 100644
index 0000000..b4e70af
--- /dev/null
+++ b/Tests/LoopAlgorithmTests/Fixtures/carbs_with_isf_change_recommendation.json
@@ -0,0 +1,5 @@
+{
+ "manual" : {
+ "amount" : 10.546890782709953
+ }
+}
diff --git a/Tests/LoopAlgorithmTests/Fixtures/carbs_with_isf_change_scenario.json b/Tests/LoopAlgorithmTests/Fixtures/carbs_with_isf_change_scenario.json
new file mode 100644
index 0000000..4185c54
--- /dev/null
+++ b/Tests/LoopAlgorithmTests/Fixtures/carbs_with_isf_change_scenario.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" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-17T19:07:29Z",
+ "startDate" : "2023-10-17T18:47:27Z",
+ "type" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-17T19:27:29Z",
+ "startDate" : "2023-10-17T19:07:30Z",
+ "type" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-17T19:47:27Z",
+ "startDate" : "2023-10-17T19:27:29Z",
+ "type" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-17T20:02:29Z",
+ "startDate" : "2023-10-17T19:47:28Z",
+ "type" : "basal",
+ "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" : "basal",
+ "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" : "basal",
+ "volume" : 0.05
+ },
+ {
+ "endDate" : "2023-10-17T21:37:38Z",
+ "startDate" : "2023-10-17T21:32:44Z",
+ "type" : "basal",
+ "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" : "basal",
+ "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" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-17T22:42:29Z",
+ "startDate" : "2023-10-17T22:32:29Z",
+ "type" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-17T22:52:30Z",
+ "startDate" : "2023-10-17T22:47:32Z",
+ "type" : "basal",
+ "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" : "basal",
+ "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" : "basal",
+ "volume" : 0.05
+ },
+ {
+ "endDate" : "2023-10-18T01:22:29Z",
+ "startDate" : "2023-10-18T01:17:27Z",
+ "type" : "basal",
+ "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" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-18T03:32:27Z",
+ "startDate" : "2023-10-18T03:27:39Z",
+ "type" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-18T03:37:30Z",
+ "startDate" : "2023-10-18T03:32:28Z",
+ "type" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-18T03:57:28Z",
+ "startDate" : "2023-10-18T03:52:32Z",
+ "type" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-18T04:22:32Z",
+ "startDate" : "2023-10-18T04:00:00Z",
+ "type" : "basal",
+ "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" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-18T05:07:27Z",
+ "startDate" : "2023-10-18T05:02:34Z",
+ "type" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-18T05:17:29Z",
+ "startDate" : "2023-10-18T05:12:49Z",
+ "type" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-18T05:22:27Z",
+ "startDate" : "2023-10-18T05:17:30Z",
+ "type" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-18T05:52:29Z",
+ "startDate" : "2023-10-18T05:42:30Z",
+ "type" : "basal",
+ "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" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-18T06:42:48Z",
+ "startDate" : "2023-10-18T06:37:32Z",
+ "type" : "basal",
+ "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" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-18T07:52:28Z",
+ "startDate" : "2023-10-18T07:37:27Z",
+ "type" : "basal",
+ "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" : "basal",
+ "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" : "basal",
+ "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" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-18T10:37:27Z",
+ "startDate" : "2023-10-18T10:32:29Z",
+ "type" : "basal",
+ "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/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/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/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/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"
+ }
+]
+
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/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/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"
+ }
+]
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/Fixtures/suspend_scenario.json b/Tests/LoopAlgorithmTests/Fixtures/suspend_scenario.json
new file mode 100644
index 0000000..48056ee
--- /dev/null
+++ b/Tests/LoopAlgorithmTests/Fixtures/suspend_scenario.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" : "basal",
+ "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" : "basal",
+ "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" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-17T07:34:07Z",
+ "startDate" : "2023-10-17T07:19:09Z",
+ "type" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-17T07:39:08Z",
+ "startDate" : "2023-10-17T07:34:07Z",
+ "type" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-17T07:59:06Z",
+ "startDate" : "2023-10-17T07:39:09Z",
+ "type" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-17T08:19:07Z",
+ "startDate" : "2023-10-17T07:59:06Z",
+ "type" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-17T08:34:09Z",
+ "startDate" : "2023-10-17T08:19:08Z",
+ "type" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-17T08:44:06Z",
+ "startDate" : "2023-10-17T08:39:14Z",
+ "type" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-17T10:09:13Z",
+ "startDate" : "2023-10-17T09:59:17Z",
+ "type" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-17T10:39:13Z",
+ "startDate" : "2023-10-17T10:19:09Z",
+ "type" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-17T10:59:10Z",
+ "startDate" : "2023-10-17T10:39:14Z",
+ "type" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-17T11:19:07Z",
+ "startDate" : "2023-10-17T10:59:10Z",
+ "type" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-17T11:39:08Z",
+ "startDate" : "2023-10-17T11:19:08Z",
+ "type" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-17T11:44:09Z",
+ "startDate" : "2023-10-17T11:39:09Z",
+ "type" : "basal",
+ "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" : "basal",
+ "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" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-17T13:49:09Z",
+ "startDate" : "2023-10-17T13:44:12Z",
+ "type" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-17T13:54:09Z",
+ "startDate" : "2023-10-17T13:49:09Z",
+ "type" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-17T13:59:06Z",
+ "startDate" : "2023-10-17T13:54:09Z",
+ "type" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-17T14:04:07Z",
+ "startDate" : "2023-10-17T13:59:07Z",
+ "type" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-17T14:09:09Z",
+ "startDate" : "2023-10-17T14:04:07Z",
+ "type" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-17T14:14:07Z",
+ "startDate" : "2023-10-17T14:09:09Z",
+ "type" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-17T14:24:09Z",
+ "startDate" : "2023-10-17T14:14:08Z",
+ "type" : "basal",
+ "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" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-17T16:14:07Z",
+ "startDate" : "2023-10-17T16:09:07Z",
+ "type" : "basal",
+ "volume" : 0.05
+ },
+ {
+ "endDate" : "2023-10-17T16:24:07Z",
+ "startDate" : "2023-10-17T16:19:08Z",
+ "type" : "basal",
+ "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" : "basal",
+ "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" : "basal",
+ "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" : "basal",
+ "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" : "basal",
+ "volume" : 0.05
+ },
+ {
+ "endDate" : "2023-10-17T18:59:10Z",
+ "startDate" : "2023-10-17T18:54:09Z",
+ "type" : "basal",
+ "volume" : 0.05
+ },
+ {
+ "endDate" : "2023-10-17T19:09:07Z",
+ "startDate" : "2023-10-17T18:59:10Z",
+ "type" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-17T19:14:09Z",
+ "startDate" : "2023-10-17T19:09:07Z",
+ "type" : "basal",
+ "volume" : 0.05
+ },
+ {
+ "endDate" : "2023-10-17T19:34:08Z",
+ "startDate" : "2023-10-17T19:14:10Z",
+ "type" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-17T19:54:07Z",
+ "startDate" : "2023-10-17T19:34:08Z",
+ "type" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-17T20:14:17Z",
+ "startDate" : "2023-10-17T19:54:08Z",
+ "type" : "basal",
+ "volume" : 0
+ },
+ {
+ "endDate" : "2023-10-17T20:34:08Z",
+ "startDate" : "2023-10-17T20:14:17Z",
+ "type" : "basal",
+ "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/InsulinMathTests.swift b/Tests/LoopAlgorithmTests/InsulinMathTests.swift
new file mode 100644
index 0000000..a3724eb
--- /dev/null
+++ b/Tests/LoopAlgorithmTests/InsulinMathTests.swift
@@ -0,0 +1,271 @@
+//
+// 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
+
+class InsulinMathTests: XCTestCase {
+
+ 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))
+ }
+ }
+
+ 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"
+ 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)
+
+ 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: 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
new file mode 100644
index 0000000..8aafaa5
--- /dev/null
+++ b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift
@@ -0,0 +1,234 @@
+//
+// LoopAlgorithmTests.swift
+// LoopAlgorithmTests
+//
+// 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 + "_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")!
+ 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 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
+ // 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)
+ }
+
+ 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), 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.retrospectiveCorrection.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 165)
+ XCTAssertEqual(outputB.effects.retrospectiveCorrection.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 165)
+
+ // 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)
+ }
+
+
+ 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.36, accuracy: 0.01)
+
+ XCTAssert(carbStatus.absorption!.isActive)
+
+ let basalAdjustment = output.recommendation!.automatic!.basalAdjustment
+
+ XCTAssertEqual(basalAdjustment!.unitsPerHour, 5.83, accuracy: 0.01)
+ }
+
+ func testLiveCapture() {
+ 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/ManualBolusRecommendationTests.swift b/Tests/LoopAlgorithmTests/ManualBolusRecommendationTests.swift
new file mode 100644
index 0000000..3a98656
--- /dev/null
+++ b/Tests/LoopAlgorithmTests/ManualBolusRecommendationTests.swift
@@ -0,0 +1,169 @@
+//
+// 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 {
+ let recommendation = ManualBolusRecommendation(amount: 1.0, notice: nil)
+ let encoded = try encoder.encode(recommendation)
+ XCTAssertEqual(
+ """
+ {
+ "amount" : 1
+ }
+ """,
+ String(data: encoded , encoding: .utf8)!)
+
+ 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")!
+ let 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)!)
+
+ 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")!
+ let 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)!)
+
+ 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")!
+ let 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)!)
+
+ 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")!
+ let recommendation = ManualBolusRecommendation(amount: 0, notice: .predictedGlucoseInRange)
+ let encoded = try encoder.encode(recommendation)
+ XCTAssertEqual(
+ """
+ {
+ "amount" : 0,
+ "notice" : "predictedGlucoseInRange"
+ }
+ """,
+ String(data: encoded , encoding: .utf8)!)
+
+ 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")!
+ let 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)!)
+
+ let decoded = try decoder.decode(ManualBolusRecommendation.self, from: encoded)
+ XCTAssertEqual(decoded, recommendation)
+ }
+}
+
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")!)
+ }
+}
diff --git a/Tests/LoopAlgorithmTests/Mocks/LoopAlgorithmInputMock.swift b/Tests/LoopAlgorithmTests/Mocks/LoopAlgorithmInputMock.swift
new file mode 100644
index 0000000..785fd62
--- /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 LoopAlgorithmInputFixture {
+ /// 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(-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: 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: 65),
+ maxBolus: 6,
+ maxBasalRate: 8,
+ 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)
+ }
+
+}
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))
+ ]
+ }
+
+}