|
|
@@ -0,0 +1,902 @@
|
|
|
+//
|
|
|
+// CarbMath.swift
|
|
|
+// CarbKit
|
|
|
+//
|
|
|
+// Created by Nathan Racklyeft on 1/16/16.
|
|
|
+// Copyright © 2016 Nathan Racklyeft. All rights reserved.
|
|
|
+//
|
|
|
+
|
|
|
+import Foundation
|
|
|
+import HealthKit
|
|
|
+
|
|
|
+
|
|
|
+struct CarbModelSettings {
|
|
|
+ var absorptionModel: CarbAbsorptionComputable
|
|
|
+ var initialAbsorptionTimeOverrun: Double
|
|
|
+ var adaptiveAbsorptionRateEnabled: Bool
|
|
|
+ var adaptiveRateStandbyIntervalFraction: Double
|
|
|
+
|
|
|
+ init(absorptionModel: CarbAbsorptionComputable, initialAbsorptionTimeOverrun: Double, adaptiveAbsorptionRateEnabled: Bool, adaptiveRateStandbyIntervalFraction: Double = 0.2) {
|
|
|
+ self.absorptionModel = absorptionModel
|
|
|
+ self.initialAbsorptionTimeOverrun = initialAbsorptionTimeOverrun
|
|
|
+ self.adaptiveAbsorptionRateEnabled = adaptiveAbsorptionRateEnabled
|
|
|
+ self.adaptiveRateStandbyIntervalFraction = adaptiveRateStandbyIntervalFraction
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+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 {
|
|
|
+ func absorbedCarbs(of total: Double, atTime time: TimeInterval, absorptionTime: TimeInterval) -> Double {
|
|
|
+ let percentTime = time / absorptionTime
|
|
|
+ return total * percentAbsorptionAtPercentTime(percentTime)
|
|
|
+ }
|
|
|
+
|
|
|
+ func unabsorbedCarbs(of total: Double, atTime time: TimeInterval, absorptionTime: TimeInterval) -> Double {
|
|
|
+ let percentTime = time / absorptionTime
|
|
|
+ return total * (1.0 - percentAbsorptionAtPercentTime(percentTime))
|
|
|
+ }
|
|
|
+
|
|
|
+ func absorptionTime(forPercentAbsorption percentAbsorption: Double, atTime time: TimeInterval) -> TimeInterval {
|
|
|
+ let percentTime = max(percentTimeAtPercentAbsorption(percentAbsorption), .ulpOfOne)
|
|
|
+ return time / percentTime
|
|
|
+ }
|
|
|
+
|
|
|
+ func timeToAbsorb(forPercentAbsorbed percentAbsorption: Double, totalAbsorptionTime: TimeInterval) -> TimeInterval {
|
|
|
+ let percentTime = percentTimeAtPercentAbsorption(percentAbsorption)
|
|
|
+ return percentTime * totalAbsorptionTime
|
|
|
+ }
|
|
|
+
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+// MARK: - Parabolic absorption as described by Scheiner
|
|
|
+// This is the integral approximation of the Scheiner GI curve found in Think Like a Pancreas, Fig 7-8, which first appeared in [GlucoDyn](https://github.com/kenstack/GlucoDyn)
|
|
|
+struct ParabolicAbsorption: CarbAbsorptionComputable {
|
|
|
+ func percentAbsorptionAtPercentTime(_ percentTime: Double) -> Double {
|
|
|
+ switch percentTime {
|
|
|
+ case let t where t <= 0.0:
|
|
|
+ return 0.0
|
|
|
+ case let t where t <= 0.5:
|
|
|
+ return 2.0 * pow(t, 2)
|
|
|
+ case let t where t < 1.0:
|
|
|
+ return -1.0 + 2.0 * t * (2.0 - t)
|
|
|
+ default:
|
|
|
+ return 1.0
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ func percentTimeAtPercentAbsorption(_ percentAbsorption: Double) -> Double {
|
|
|
+ switch percentAbsorption {
|
|
|
+ case let a where a <= 0:
|
|
|
+ return 0.0
|
|
|
+ case let a where a <= 0.5:
|
|
|
+ return sqrt(0.5 * a)
|
|
|
+ case let a where a < 1.0:
|
|
|
+ return 1.0 - sqrt(0.5 * (1.0 - a))
|
|
|
+ default:
|
|
|
+ return 1.0
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ func percentRateAtPercentTime(_ percentTime: Double) -> Double {
|
|
|
+ switch percentTime {
|
|
|
+ case let t where t > 0 && t <= 0.5:
|
|
|
+ return 4.0 * t
|
|
|
+ case let t where t > 0.5 && t < 1.0:
|
|
|
+ return 4.0 - 4.0 * t
|
|
|
+ default:
|
|
|
+ return 0.0
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+// MARK: - Linear absorption as a factor of reported duration
|
|
|
+struct LinearAbsorption: CarbAbsorptionComputable {
|
|
|
+ func percentAbsorptionAtPercentTime(_ percentTime: Double) -> Double {
|
|
|
+ switch percentTime {
|
|
|
+ case let t where t <= 0.0:
|
|
|
+ return 0.0
|
|
|
+ case let t where t < 1.0:
|
|
|
+ return t
|
|
|
+ default:
|
|
|
+ return 1.0
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ func percentTimeAtPercentAbsorption(_ percentAbsorption: Double) -> Double {
|
|
|
+ switch percentAbsorption {
|
|
|
+ case let a where a <= 0.0:
|
|
|
+ return 0.0
|
|
|
+ case let a where a < 1.0:
|
|
|
+ return a
|
|
|
+ default:
|
|
|
+ return 1.0
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ func percentRateAtPercentTime(_ percentTime: Double) -> Double {
|
|
|
+ switch percentTime {
|
|
|
+ case let t where t > 0.0 && t <= 1.0:
|
|
|
+ return 1.0
|
|
|
+ default:
|
|
|
+ return 0.0
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// MARK: - Piecewise linear absorption as a factor of reported duration
|
|
|
+/// Nonlinear carb absorption model where absorption rate increases linearly from zero to a maximum value at a fraction of absorption time equal to percentEndOfRise, then remains constant until a fraction of absorption time equal to percentStartOfFall, and then decreases linearly to zero at the end of absorption time
|
|
|
+/// - Parameters:
|
|
|
+/// - percentEndOfRise: the percentage of absorption time when absorption rate reaches maximum, must be strictly between 0 and 1
|
|
|
+/// - percentStartOfFall: the percentage of absorption time when absorption rate starts to decay, must be stritctly between 0 and 1 and greater than percentEndOfRise
|
|
|
+struct PiecewiseLinearAbsorption: CarbAbsorptionComputable {
|
|
|
+
|
|
|
+ let percentEndOfRise = 0.15
|
|
|
+ let percentStartOfFall = 0.5
|
|
|
+ var scale: Double {
|
|
|
+ return 2.0 / (1.0 + percentStartOfFall - percentEndOfRise)
|
|
|
+ }
|
|
|
+
|
|
|
+ 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
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ 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
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ func percentRateAtPercentTime(_ percentTime: Double) -> Double {
|
|
|
+ switch percentTime {
|
|
|
+ case let t where t > 0 && t < percentEndOfRise:
|
|
|
+ return scale * t / percentEndOfRise
|
|
|
+ case let t where t >= percentEndOfRise && t < percentStartOfFall:
|
|
|
+ return scale
|
|
|
+ case let t where t >= percentStartOfFall && t < 1.0:
|
|
|
+ return scale * ((1.0 - t) / (1.0 - percentStartOfFall))
|
|
|
+ default:
|
|
|
+ return 0.0
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+extension CarbEntry {
|
|
|
+
|
|
|
+ func carbsOnBoard(at date: Date, defaultAbsorptionTime: TimeInterval, delay: TimeInterval, absorptionModel: CarbAbsorptionComputable) -> Double {
|
|
|
+ let time = date.timeIntervalSince(startDate)
|
|
|
+ let value: Double
|
|
|
+
|
|
|
+ if time >= 0 {
|
|
|
+ value = absorptionModel.unabsorbedCarbs(of: quantity.doubleValue(for: HKUnit.gram()), atTime: time - delay, absorptionTime: absorptionTime ?? defaultAbsorptionTime)
|
|
|
+ } else {
|
|
|
+ value = 0
|
|
|
+ }
|
|
|
+
|
|
|
+ return value
|
|
|
+ }
|
|
|
+
|
|
|
+ // g
|
|
|
+ func absorbedCarbs(
|
|
|
+ at date: Date,
|
|
|
+ absorptionTime: TimeInterval,
|
|
|
+ delay: TimeInterval,
|
|
|
+ absorptionModel: CarbAbsorptionComputable
|
|
|
+ ) -> Double {
|
|
|
+ let time = date.timeIntervalSince(startDate)
|
|
|
+
|
|
|
+ return absorptionModel.absorbedCarbs(
|
|
|
+ of: quantity.doubleValue(for: .gram()),
|
|
|
+ atTime: time - delay,
|
|
|
+ absorptionTime: absorptionTime
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ // mg/dL / g * g
|
|
|
+ fileprivate func glucoseEffect(
|
|
|
+ at date: Date,
|
|
|
+ carbRatio: HKQuantity,
|
|
|
+ insulinSensitivity: HKQuantity,
|
|
|
+ defaultAbsorptionTime: TimeInterval,
|
|
|
+ delay: TimeInterval,
|
|
|
+ absorptionModel: CarbAbsorptionComputable
|
|
|
+ ) -> Double {
|
|
|
+ return insulinSensitivity.doubleValue(for: HKUnit.milligramsPerDeciliter) / carbRatio.doubleValue(for: .gram()) * absorbedCarbs(at: date, absorptionTime: absorptionTime ?? defaultAbsorptionTime, delay: delay, absorptionModel: absorptionModel)
|
|
|
+ }
|
|
|
+
|
|
|
+ fileprivate func estimatedAbsorptionTime(forAbsorbedCarbs carbs: Double, at date: Date, absorptionModel: CarbAbsorptionComputable) -> TimeInterval {
|
|
|
+ let time = date.timeIntervalSince(startDate)
|
|
|
+
|
|
|
+ return max(time, absorptionModel.absorptionTime(forPercentAbsorption: carbs / quantity.doubleValue(for: .gram()), atTime: time))
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+extension Collection where Element: CarbEntry {
|
|
|
+ fileprivate func simulationDateRange(
|
|
|
+ from start: Date? = nil,
|
|
|
+ to end: Date? = nil,
|
|
|
+ defaultAbsorptionTime: TimeInterval,
|
|
|
+ delay: TimeInterval,
|
|
|
+ delta: TimeInterval
|
|
|
+ ) -> (start: Date, end: Date)? {
|
|
|
+ guard count > 0 else {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+
|
|
|
+ if let start = start, let end = end {
|
|
|
+ return (start: start.dateFlooredToTimeInterval(delta), end: end.dateCeiledToTimeInterval(delta))
|
|
|
+ } else {
|
|
|
+ var minDate = first!.startDate
|
|
|
+ var maxDate = minDate
|
|
|
+
|
|
|
+ for sample in self {
|
|
|
+ if sample.startDate < minDate {
|
|
|
+ minDate = sample.startDate
|
|
|
+ }
|
|
|
+
|
|
|
+ let endDate = sample.endDate.addingTimeInterval(sample.absorptionTime ?? defaultAbsorptionTime).addingTimeInterval(delay)
|
|
|
+ if endDate > maxDate {
|
|
|
+ maxDate = endDate
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ start: (start ?? minDate).dateFlooredToTimeInterval(delta),
|
|
|
+ end: (end ?? maxDate).dateCeiledToTimeInterval(delta)
|
|
|
+ )
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Creates groups of entries that have overlapping absorption date intervals
|
|
|
+ ///
|
|
|
+ /// - Parameters:
|
|
|
+ /// - defaultAbsorptionTime: The default absorption time value, if not set on the entry
|
|
|
+ /// - Returns: An array of arrays representing groups of entries, in chronological order by entry startDate
|
|
|
+ func groupedByOverlappingAbsorptionTimes(
|
|
|
+ defaultAbsorptionTime: TimeInterval
|
|
|
+ ) -> [[Iterator.Element]] {
|
|
|
+ var batches: [[Iterator.Element]] = []
|
|
|
+
|
|
|
+ for entry in sorted(by: { $0.startDate < $1.startDate }) {
|
|
|
+ if let lastEntry = batches.last?.last,
|
|
|
+ lastEntry.startDate.addingTimeInterval(lastEntry.absorptionTime ?? defaultAbsorptionTime) > entry.startDate
|
|
|
+ {
|
|
|
+ batches[batches.count - 1].append(entry)
|
|
|
+ } else {
|
|
|
+ batches.append([entry])
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return batches
|
|
|
+ }
|
|
|
+
|
|
|
+ func carbsOnBoard(
|
|
|
+ from start: Date? = nil,
|
|
|
+ to end: Date? = nil,
|
|
|
+ defaultAbsorptionTime: TimeInterval,
|
|
|
+ absorptionModel: CarbAbsorptionComputable,
|
|
|
+ delay: TimeInterval = TimeInterval(minutes: 10),
|
|
|
+ delta: TimeInterval = TimeInterval(minutes: 5)
|
|
|
+ ) -> [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, quantity: HKQuantity(unit: HKUnit.gram(), doubleValue: value)))
|
|
|
+ date = date.addingTimeInterval(delta)
|
|
|
+ } while date <= endDate
|
|
|
+
|
|
|
+ return values
|
|
|
+ }
|
|
|
+
|
|
|
+ func glucoseEffects(
|
|
|
+ from start: Date? = nil,
|
|
|
+ to end: Date? = nil,
|
|
|
+ carbRatios: CarbRatioSchedule,
|
|
|
+ insulinSensitivities: InsulinSensitivitySchedule,
|
|
|
+ defaultAbsorptionTime: TimeInterval,
|
|
|
+ absorptionModel: CarbAbsorptionComputable,
|
|
|
+ delay: TimeInterval = TimeInterval(minutes: 10),
|
|
|
+ delta: TimeInterval = TimeInterval(minutes: 5)
|
|
|
+ ) -> [GlucoseEffect] {
|
|
|
+ guard let (startDate, endDate) = simulationDateRange(from: start, to: end, defaultAbsorptionTime: defaultAbsorptionTime, delay: delay, delta: delta) else {
|
|
|
+ return []
|
|
|
+ }
|
|
|
+
|
|
|
+ var date = startDate
|
|
|
+ var values = [GlucoseEffect]()
|
|
|
+ let unit = HKUnit.milligramsPerDeciliter
|
|
|
+
|
|
|
+ repeat {
|
|
|
+ let value = reduce(0.0) { (value, entry) -> Double in
|
|
|
+ return value + entry.glucoseEffect(
|
|
|
+ at: date,
|
|
|
+ carbRatio: carbRatios.quantity(at: entry.startDate),
|
|
|
+ insulinSensitivity: insulinSensitivities.quantity(at: entry.startDate),
|
|
|
+ defaultAbsorptionTime: defaultAbsorptionTime,
|
|
|
+ delay: delay,
|
|
|
+ absorptionModel: absorptionModel
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ values.append(GlucoseEffect(startDate: date, quantity: HKQuantity(unit: unit, doubleValue: value)))
|
|
|
+ date = date.addingTimeInterval(delta)
|
|
|
+ } while date <= endDate
|
|
|
+
|
|
|
+ return values
|
|
|
+ }
|
|
|
+
|
|
|
+ var totalCarbs: CarbValue? {
|
|
|
+ guard count > 0 else {
|
|
|
+ return nil
|
|
|
+ }
|
|
|
+
|
|
|
+ let unit = HKUnit.gram()
|
|
|
+ var startDate = Date.distantFuture
|
|
|
+ var totalGrams: Double = 0
|
|
|
+
|
|
|
+ for entry in self {
|
|
|
+ totalGrams += entry.quantity.doubleValue(for: unit)
|
|
|
+
|
|
|
+ if entry.startDate < startDate {
|
|
|
+ startDate = entry.startDate
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return CarbValue(startDate: startDate, quantity: HKQuantity(unit: unit, doubleValue: totalGrams))
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+
|
|
|
+// MARK: - Dyanamic absorption overrides
|
|
|
+extension Collection {
|
|
|
+ func dynamicCarbsOnBoard<T>(
|
|
|
+ from start: Date? = nil,
|
|
|
+ to end: Date? = nil,
|
|
|
+ defaultAbsorptionTime: TimeInterval,
|
|
|
+ absorptionModel: CarbAbsorptionComputable,
|
|
|
+ delay: TimeInterval = TimeInterval(minutes: 10),
|
|
|
+ delta: TimeInterval = TimeInterval(minutes: 5)
|
|
|
+ ) -> [CarbValue] where Element == CarbStatus<T> {
|
|
|
+ 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, quantity: HKQuantity(unit: HKUnit.gram(), doubleValue: value)))
|
|
|
+ date = date.addingTimeInterval(delta)
|
|
|
+ } while date <= endDate
|
|
|
+
|
|
|
+ return values
|
|
|
+ }
|
|
|
+
|
|
|
+ func dynamicGlucoseEffects<T>(
|
|
|
+ from start: Date? = nil,
|
|
|
+ to end: Date? = nil,
|
|
|
+ carbRatios: CarbRatioSchedule,
|
|
|
+ insulinSensitivities: InsulinSensitivitySchedule,
|
|
|
+ defaultAbsorptionTime: TimeInterval,
|
|
|
+ absorptionModel: CarbAbsorptionComputable,
|
|
|
+ delay: TimeInterval = TimeInterval(minutes: 10),
|
|
|
+ delta: TimeInterval = TimeInterval(minutes: 5)
|
|
|
+ ) -> [GlucoseEffect] where Element == CarbStatus<T> {
|
|
|
+ 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
|
|
|
+ let gram = HKUnit.gram()
|
|
|
+
|
|
|
+ repeat {
|
|
|
+ let value = reduce(0.0) { (value, entry) -> Double in
|
|
|
+ let csf = insulinSensitivities.quantity(at: entry.startDate).doubleValue(for: mgdL) / carbRatios.quantity(at: entry.startDate).doubleValue(for: gram)
|
|
|
+
|
|
|
+ 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<T>() -> CarbValue? where Element == CarbStatus<T> {
|
|
|
+ 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, quantity: HKQuantity(unit: gram, doubleValue: 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<T: CarbEntry> {
|
|
|
+
|
|
|
+ // MARK: Model settings
|
|
|
+
|
|
|
+ private var absorptionModel: CarbAbsorptionComputable
|
|
|
+
|
|
|
+ private var adaptiveAbsorptionRateEnabled: Bool
|
|
|
+
|
|
|
+ private var adaptiveRateStandbyIntervalFraction: Double
|
|
|
+
|
|
|
+ private var adaptiveRateStandbyInterval: TimeInterval {
|
|
|
+ return initialAbsorptionTime * adaptiveRateStandbyIntervalFraction
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: User-entered data
|
|
|
+
|
|
|
+ /// The carb entry input
|
|
|
+ let entry: T
|
|
|
+
|
|
|
+ /// The unit used for carb values
|
|
|
+ let carbUnit: HKUnit
|
|
|
+
|
|
|
+ /// The total grams entered for this entry
|
|
|
+ let entryGrams: Double
|
|
|
+
|
|
|
+ /// The total glucose effect expected for this entry, in glucose units
|
|
|
+ let entryEffect: Double
|
|
|
+
|
|
|
+ /// The carbohydrate-sensitivity factor for this entry, in glucose units per gram
|
|
|
+ let carbohydrateSensitivityFactor: Double
|
|
|
+
|
|
|
+ /// The absorption time for this entry before any absorption is observed
|
|
|
+ let initialAbsorptionTime: TimeInterval
|
|
|
+
|
|
|
+ // MARK: Minimum/maximum bounding factors
|
|
|
+
|
|
|
+ /// The maximum absorption time allowed for this entry, determining the minimum absorption rate
|
|
|
+ let maxAbsorptionTime: TimeInterval
|
|
|
+
|
|
|
+ /// An amount of time to wait after the entry date before minimum absorption is assumed to begin
|
|
|
+ let delay: TimeInterval
|
|
|
+
|
|
|
+ /// The maximum end date allowed for this entry's absorption
|
|
|
+ let maxEndDate: Date
|
|
|
+
|
|
|
+ /// The last date we have effects observed, or "now" in real-time analysis.
|
|
|
+ private let lastEffectDate: Date
|
|
|
+
|
|
|
+ /// The minimum-required carb absorption rate for this entry, in g/s
|
|
|
+ var minAbsorptionRate: Double {
|
|
|
+ return entryGrams / maxAbsorptionTime
|
|
|
+ }
|
|
|
+
|
|
|
+ /// The minimum amount of carbs we assume must have absorbed at the last observation date
|
|
|
+ private var minPredictedGrams: Double {
|
|
|
+ // We incorporate a delay when calculating minimum absorption values
|
|
|
+ let time = lastEffectDate.timeIntervalSince(entry.startDate) - delay
|
|
|
+ return absorptionModel.absorbedCarbs(of: entryGrams, atTime: time, absorptionTime: maxAbsorptionTime)
|
|
|
+ }
|
|
|
+
|
|
|
+ // MARK: Incremental observation
|
|
|
+
|
|
|
+ /// The date at which we observe all the carbs were absorbed. or nil if carb absorption has not finished
|
|
|
+ private var observedCompletionDate: Date?
|
|
|
+
|
|
|
+ /// The total observed effect for each entry, in glucose units
|
|
|
+ private(set) var observedEffect: Double = 0
|
|
|
+
|
|
|
+ /// The timeline of absorption amounts credited to this carb entry, in grams, for computation of historical COB and effect history
|
|
|
+ private(set) var observedTimeline: [CarbValue] = []
|
|
|
+
|
|
|
+ /// The amount of carbs we've observed absorbing
|
|
|
+ private var observedGrams: Double {
|
|
|
+ return observedEffect / carbohydrateSensitivityFactor
|
|
|
+ }
|
|
|
+
|
|
|
+ /// The amount of effect remaining until 100% of entry absorption is observed
|
|
|
+ var remainingEffect: Double {
|
|
|
+ return max(entryEffect - observedEffect, 0)
|
|
|
+ }
|
|
|
+
|
|
|
+ /// The dates over which we observed absorption, from start until 100% or last observed effect.
|
|
|
+ private var observedAbsorptionDates: DateInterval {
|
|
|
+ return DateInterval(start: entry.startDate, end: observedCompletionDate ?? lastEffectDate)
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
+ // MARK: Clamped results
|
|
|
+
|
|
|
+ /// The number of carbs absorbed, suitable for use in calculations.
|
|
|
+ /// This is bounded by minimumPredictedGrams and the entry total.
|
|
|
+ private var clampedGrams: Double {
|
|
|
+ let minPredictedGrams = self.minPredictedGrams
|
|
|
+
|
|
|
+ return min(entryGrams, max(minPredictedGrams, observedGrams))
|
|
|
+ }
|
|
|
+
|
|
|
+ private var percentAbsorbed: Double {
|
|
|
+ return clampedGrams / entryGrams
|
|
|
+ }
|
|
|
+
|
|
|
+ /// The amount of time needed to absorb observed grams
|
|
|
+ private var timeToAbsorbObservedCarbs: TimeInterval {
|
|
|
+ let time = lastEffectDate.timeIntervalSince(entry.startDate) - delay
|
|
|
+ guard time > 0 else {
|
|
|
+ return 0.0
|
|
|
+ }
|
|
|
+ var timeToAbsorb: TimeInterval
|
|
|
+ if adaptiveAbsorptionRateEnabled && time > adaptiveRateStandbyInterval {
|
|
|
+ // If adaptive absorption rate is enabled, and if the time since start of absorption is greater than the standby interval, the time to absorb observed carbs equals the obervation time
|
|
|
+ timeToAbsorb = time
|
|
|
+ } else {
|
|
|
+ // If adaptive absorption rate is disabled, or if the time since start of absorption is less than the standby interval, the time to absorb observed carbs is calculated based on the absorption model
|
|
|
+ timeToAbsorb = absorptionModel.timeToAbsorb(forPercentAbsorbed: percentAbsorbed, totalAbsorptionTime: initialAbsorptionTime)
|
|
|
+ }
|
|
|
+ return min(timeToAbsorb, maxAbsorptionTime)
|
|
|
+ }
|
|
|
+
|
|
|
+ /// The amount of time needed for the remaining entry grams to absorb
|
|
|
+ private var estimatedTimeRemaining: TimeInterval {
|
|
|
+ let time = lastEffectDate.timeIntervalSince(entry.startDate) - delay
|
|
|
+ guard time > 0 else {
|
|
|
+ return initialAbsorptionTime
|
|
|
+ }
|
|
|
+ let notToExceedTimeRemaining = max(maxAbsorptionTime - time, 0.0)
|
|
|
+ guard notToExceedTimeRemaining > 0 else {
|
|
|
+ return 0.0
|
|
|
+ }
|
|
|
+ var dynamicTimeRemaining: TimeInterval
|
|
|
+ if adaptiveAbsorptionRateEnabled && time > adaptiveRateStandbyInterval {
|
|
|
+ // If adaptive absorption rate is enabled, and if the time since start of absorption is greater than the standby interval, the remaining time is estimated assuming the observed relative absorption rate persists for the remaining carbs
|
|
|
+ let dynamicAbsorptionTime = absorptionModel.absorptionTime(forPercentAbsorption: percentAbsorbed, atTime: time)
|
|
|
+ dynamicTimeRemaining = dynamicAbsorptionTime - time
|
|
|
+ } else {
|
|
|
+ // If adaptive absorption rate is disabled, or if the time since start of absorption is less than the standby interval, the remaining time is estimated assuming the modeled absorption rate
|
|
|
+ dynamicTimeRemaining = initialAbsorptionTime - timeToAbsorbObservedCarbs
|
|
|
+ }
|
|
|
+ // time remaining must not extend beyond the maximum absorption time
|
|
|
+ return min(dynamicTimeRemaining, notToExceedTimeRemaining)
|
|
|
+ }
|
|
|
+
|
|
|
+ /// The timeline of observed absorption, if greater than the minimum required absorption.
|
|
|
+ private var clampedTimeline: [CarbValue]? {
|
|
|
+ return observedGrams >= minPredictedGrams ? observedTimeline : nil
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Configures a new builder
|
|
|
+ ///
|
|
|
+ /// - Parameters:
|
|
|
+ /// - entry: The carb entry input
|
|
|
+ /// - carbUnit: The unit used for carb values
|
|
|
+ /// - carbohydrateSensitivityFactor: The carbohydrate-sensitivity factor for the entry, in glucose units per gram
|
|
|
+ /// - initialAbsorptionTime: The absorption initially assigned to this entry before any absorption is observed
|
|
|
+ /// - maxAbsorptionTime: The maximum absorption time allowed for this entry, determining the minimum absorption rate
|
|
|
+ /// - delay: An amount of time to wait after the entry date before minimum absorption is assumed to begin
|
|
|
+ /// - lastEffectDate: The last recorded date of effect observation, used to initialize absorption at model defined rate
|
|
|
+ /// - initialObservedEffect: The initial amount of observed effect, in glucose units. Defaults to 0
|
|
|
+ /// - absorptionModel: The absorption model to use when computing remaining absorption
|
|
|
+ /// - adaptiveAbsorptionRateEnabled: Whether the remaining absorption rate changes based in observed absorption rate
|
|
|
+ /// - adaptiveRateStandbyIntervalFraction: The delay, specified as a fraction of total absorption time, before the absorption rate will change based on observed absorption rate. Only used if adaptiveAbsorptionRateEnabled is true.
|
|
|
+ init(entry: T, carbUnit: HKUnit, carbohydrateSensitivityFactor: Double, initialAbsorptionTime: TimeInterval, maxAbsorptionTime: TimeInterval, delay: TimeInterval, lastEffectDate: Date?, absorptionModel: CarbAbsorptionComputable, adaptiveAbsorptionRateEnabled: Bool, adaptiveRateStandbyIntervalFraction: Double, initialObservedEffect: Double = 0) {
|
|
|
+ self.entry = entry
|
|
|
+ self.carbUnit = carbUnit
|
|
|
+ self.carbohydrateSensitivityFactor = carbohydrateSensitivityFactor
|
|
|
+ self.initialAbsorptionTime = initialAbsorptionTime
|
|
|
+ self.maxAbsorptionTime = maxAbsorptionTime
|
|
|
+ self.delay = delay
|
|
|
+ self.observedEffect = initialObservedEffect
|
|
|
+ self.absorptionModel = absorptionModel
|
|
|
+ self.adaptiveAbsorptionRateEnabled = adaptiveAbsorptionRateEnabled
|
|
|
+ self.adaptiveRateStandbyIntervalFraction = adaptiveRateStandbyIntervalFraction
|
|
|
+ self.entryGrams = entry.quantity.doubleValue(for: carbUnit)
|
|
|
+ self.entryEffect = entryGrams * carbohydrateSensitivityFactor
|
|
|
+ self.maxEndDate = entry.startDate.addingTimeInterval(maxAbsorptionTime + delay)
|
|
|
+ self.lastEffectDate = min(
|
|
|
+ maxEndDate,
|
|
|
+ Swift.max(lastEffectDate ?? entry.startDate, entry.startDate)
|
|
|
+ )
|
|
|
+
|
|
|
+ }
|
|
|
+
|
|
|
+ /// Increments the builder state with the next glucose effect.
|
|
|
+ ///
|
|
|
+ /// This function should only be called with values in ascending date order.
|
|
|
+ ///
|
|
|
+ /// - Parameters:
|
|
|
+ /// - effect: The effect value, in glucose units corresponding to `carbohydrateSensitivityFactor`
|
|
|
+ /// - start: The start date of the effect
|
|
|
+ /// - end: The end date of the effect
|
|
|
+ func addNextEffect(_ effect: Double, start: Date, end: Date) {
|
|
|
+ guard start >= entry.startDate else {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ observedEffect += effect
|
|
|
+
|
|
|
+ if observedCompletionDate == nil {
|
|
|
+ // Continue recording the timeline until 100% of the carbs have been observed
|
|
|
+ observedTimeline.append(CarbValue(
|
|
|
+ startDate: start,
|
|
|
+ endDate: end,
|
|
|
+ quantity: HKQuantity(
|
|
|
+ unit: carbUnit,
|
|
|
+ doubleValue: 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<T> {
|
|
|
+ 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 schedule of carb ratios, in grams per unit
|
|
|
+ /// - insulinSensitivity: The schedule of insulin sensitivities, in units of insulin per glucose-unit
|
|
|
+ /// - 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
|
|
|
+ func map(
|
|
|
+ to effectVelocities: [GlucoseEffectVelocity],
|
|
|
+ carbRatio: CarbRatioSchedule?,
|
|
|
+ insulinSensitivity: InsulinSensitivitySchedule?,
|
|
|
+ absorptionTimeOverrun: Double,
|
|
|
+ defaultAbsorptionTime: TimeInterval,
|
|
|
+ delay: TimeInterval,
|
|
|
+ initialAbsorptionTimeOverrun: Double,
|
|
|
+ absorptionModel: CarbAbsorptionComputable,
|
|
|
+ adaptiveAbsorptionRateEnabled: Bool,
|
|
|
+ adaptiveRateStandbyIntervalFraction: Double
|
|
|
+ ) -> [CarbStatus<Element>] {
|
|
|
+ guard count > 0 else {
|
|
|
+ // TODO: Apply unmatched effects to meal prediction
|
|
|
+ return []
|
|
|
+ }
|
|
|
+
|
|
|
+ guard let carbRatios = carbRatio, let insulinSensitivities = insulinSensitivity else {
|
|
|
+ return map { (entry) in
|
|
|
+ CarbStatus(entry: entry, absorption: nil, observedTimeline: nil)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // for computation
|
|
|
+ let glucoseUnit = HKUnit.milligramsPerDeciliter
|
|
|
+ let carbUnit = HKUnit.gram()
|
|
|
+
|
|
|
+ let builders: [CarbStatusBuilder<Element>] = map { (entry) in
|
|
|
+ let carbRatio = carbRatios.quantity(at: entry.startDate)
|
|
|
+ let insulinSensitivity = insulinSensitivities.quantity(at: entry.startDate)
|
|
|
+ let initialAbsorptionTimeOverrun = initialAbsorptionTimeOverrun
|
|
|
+
|
|
|
+ return CarbStatusBuilder(
|
|
|
+ entry: entry,
|
|
|
+ carbUnit: carbUnit,
|
|
|
+ carbohydrateSensitivityFactor: insulinSensitivity.doubleValue(for: glucoseUnit) / carbRatio.doubleValue(for: carbUnit),
|
|
|
+ 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 }
|
|
|
+ }
|
|
|
+}
|