| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577 |
- //
- // DoseMath.swift
- // Naterade
- //
- // Created by Nathan Racklyeft on 3/8/16.
- // Copyright © 2016 Nathan Racklyeft. All rights reserved.
- //
- import Foundation
- import HealthKit
- private 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:
- /// - scheduledBasalRate: The scheduled basal rate at the time the correction is delivered
- /// - 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
- fileprivate func asTempBasal(
- scheduledBasalRate: Double,
- maxBasalRate: Double,
- duration: TimeInterval,
- rateRounder: ((Double) -> Double)?
- ) -> TempBasalRecommendation {
- var rate = units / (duration / TimeInterval(hours: 1)) // units/hour
- switch self {
- case .aboveRange, .inRange, .entirelyBelowRange:
- rate += scheduledBasalRate
- case .suspend:
- break
- }
- rate = Swift.min(maxBasalRate, Swift.max(0, rate))
- rate = rateRounder?(rate) ?? rate
- return TempBasalRecommendation(
- unitsPerHour: rate,
- duration: duration
- )
- }
- private var bolusRecommendationNotice: BolusRecommendationNotice? {
- switch self {
- case .suspend(min: let minimum):
- return .glucoseBelowSuspendThreshold(minGlucose: minimum)
- case .inRange:
- return .predictedGlucoseInRange
- case .entirelyBelowRange(min: let min, minTarget: _, units: _):
- return .allGlucoseBelowTarget(minGlucose: min)
- case .aboveRange(min: let min, correcting: _, minTarget: let target, units: let units):
- if units > 0 && min.quantity < target {
- return .predictedGlucoseBelowTarget(minGlucose: min)
- } else {
- return nil
- }
- }
- }
- /// Determines the bolus needed to perform the correction, subtracting any insulin already scheduled for
- /// delivery, such as the remaining portion of an ongoing temp basal.
- ///
- /// - Parameters:
- /// - pendingInsulin: The number of units expected to be delivered, but not yet reflected in the correction
- /// - maxBolus: The maximum allowable bolus value in units
- /// - volumeRounder: Method to round computed dose to deliverable volume
- /// - Returns: A bolus recommendation
- fileprivate func asManualBolus(
- pendingInsulin: Double,
- maxBolus: Double,
- volumeRounder: ((Double) -> Double)?
- ) -> ManualBolusRecommendation {
- var units = self.units - pendingInsulin
- units = Swift.min(maxBolus, Swift.max(0, units))
- units = volumeRounder?(units) ?? units
- return ManualBolusRecommendation(
- amount: units,
- pendingInsulin: pendingInsulin,
- 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
- fileprivate func asPartialBolus(
- partialApplicationFactor: Double,
- maxBolusUnits: Double,
- volumeRounder: ((Double) -> Double)?
- ) -> Double {
- let partialDose = units * partialApplicationFactor
- return Swift.min(Swift.max(0, volumeRounder?(partialDose) ?? partialDose),volumeRounder?(maxBolusUnits) ?? maxBolusUnits)
- }
- }
- extension TempBasalRecommendation {
- /// Equates the recommended rate with another rate
- ///
- /// - Parameter unitsPerHour: The rate to compare
- /// - Returns: Whether the rates are equal within Double precision
- private func matchesRate(_ unitsPerHour: Double) -> Bool {
- return abs(self.unitsPerHour - unitsPerHour) < .ulpOfOne
- }
- /// Determines whether the recommendation is necessary given the current state of the pump
- ///
- /// - Parameters:
- /// - date: The date the recommendation would be delivered
- /// - scheduledBasalRate: The scheduled basal rate at `date`
- /// - lastTempBasal: The previously set temp basal
- /// - continuationInterval: The duration of time before an ongoing temp basal should be continued with a new command
- /// - scheduledBasalRateMatchesPump: A flag describing whether `scheduledBasalRate` matches the scheduled basal rate of the pump.
- /// If `false` and the recommendation matches `scheduledBasalRate`, the temp will be recommended
- /// at the scheduled basal rate rather than recommending no temp.
- /// - Returns: A temp basal recommendation
- func ifNecessary(
- at date: Date,
- scheduledBasalRate: Double,
- lastTempBasal: DoseEntry?,
- continuationInterval: TimeInterval,
- scheduledBasalRateMatchesPump: Bool
- ) -> TempBasalRecommendation? {
- // Adjust behavior for the currently active temp basal
- if let lastTempBasal = lastTempBasal,
- lastTempBasal.type == .tempBasal,
- lastTempBasal.endDate > date
- {
- /// If the last temp basal has the same rate, and has more than `continuationInterval` of time remaining, don't set a new temp
- if matchesRate(lastTempBasal.unitsPerHour),
- lastTempBasal.endDate.timeIntervalSince(date) > continuationInterval {
- return nil
- } else if matchesRate(scheduledBasalRate), scheduledBasalRateMatchesPump {
- // If our new temp matches the scheduled rate of the pump, cancel the current temp
- return .cancel
- }
- } else if matchesRate(scheduledBasalRate), scheduledBasalRateMatchesPump {
- // If we recommend the in-progress scheduled basal rate of the pump, do nothing
- return nil
- }
- return self
- }
- }
- /// Computes a total insulin amount necessary to correct a glucose differential at a given sensitivity
- ///
- /// - Parameters:
- /// - fromValue: The starting glucose value
- /// - toValue: The desired glucose value
- /// - effectedSensitivity: The sensitivity, in glucose-per-insulin-unit
- /// - Returns: The insulin correction in units
- private func insulinCorrectionUnits(fromValue: Double, toValue: Double, effectedSensitivity: Double) -> Double? {
- guard effectedSensitivity > 0 else {
- return nil
- }
- 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)
- }
- extension Collection where Element: GlucoseValue {
- /// For a collection of glucose prediction, determine the least amount of insulin delivered at
- /// `date` to correct the predicted glucose to the middle of `correctionRange` at the time of prediction.
- ///
- /// - Parameters:
- /// - correctionRange: The schedule of glucose values used for correction
- /// - date: The date the insulin correction is delivered
- /// - suspendThreshold: The glucose value below which only suspension is returned
- /// - sensitivity: The insulin sensitivity at the time of delivery
- /// - model: The insulin effect model
- /// - Returns: A correction value in units, or nil if no correction needed
- private func insulinCorrection(
- to correctionRange: GlucoseRangeSchedule,
- at date: Date,
- suspendThreshold: HKQuantity,
- sensitivity: HKQuantity,
- model: InsulinModel
- ) -> InsulinCorrection? {
- let effectDuration = model.effectDuration
- let timeline = [AbsoluteScheduleValue(startDate: date, endDate: date.addingTimeInterval(effectDuration), value: sensitivity)]
- return insulinCorrection(
- to: correctionRange,
- at: date,
- suspendThreshold: suspendThreshold,
- insulinSensitivityTimeline: timeline,
- model: model)
- }
- /// 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 schedule of glucose values used for correction
- /// - date: The date the insulin correction is delivered
- /// - suspendThreshold: The glucose value below which only suspension is returned
- /// - insulinSensitivityTimeline: The timeline of expected insulin sensitivity over the period of dose absorption
- /// - model: The insulin effect model
- /// - Returns: A correction value in units, or nil if no correction needed
- private func insulinCorrection(
- to correctionRange: GlucoseRangeSchedule,
- at date: Date,
- suspendThreshold: HKQuantity,
- insulinSensitivityTimeline: [AbsoluteScheduleValue<HKQuantity>],
- model: InsulinModel
- ) -> InsulinCorrection? {
- var minGlucose: GlucoseValue?
- var eventualGlucose: GlucoseValue?
- var correctingGlucose: GlucoseValue?
- var minCorrectionUnits: Double?
- var effectedSensitivityAtMinGlucose: Double?
- // Only consider predictions within the model's effect duration
- let validDateRange = DateInterval(start: date, duration: model.effectDuration)
- let unit = correctionRange.unit
- let suspendThresholdValue = suspendThreshold.doubleValue(for: unit)
- // For each prediction above target, determine the amount of insulin necessary to correct glucose based on the modeled effectiveness of the insulin at that time
- for prediction in self {
- guard validDateRange.contains(prediction.startDate) else {
- continue
- }
- // If any predicted value is below the suspend threshold, return immediately
- guard prediction.quantity >= suspendThreshold else {
- print("Suspend!")
- return .suspend(min: prediction)
- }
- eventualGlucose = prediction
- let predictedGlucoseValue = prediction.quantity.doubleValue(for: unit)
- let time = prediction.startDate.timeIntervalSince(date)
- // Compute the target value as a function of time since the dose started
- let targetValue = targetGlucoseValue(
- percentEffectDuration: time / model.effectDuration,
- minValue: suspendThresholdValue,
- maxValue: correctionRange.quantityRange(at: prediction.startDate).averageValue(for: unit)
- )
- // Compute the dose required to bring this prediction to target:
- // dose = (Glucose Δ) / (% effect × sensitivity)
- let isfSegments = insulinSensitivityTimeline.filterDateRange(date, prediction.startDate)
- let effectedSensitivity = isfSegments.reduce(0) { partialResult, segment in
- let start = Swift.max(date, segment.startDate).timeIntervalSince(date)
- let end = Swift.min(prediction.startDate, segment.endDate).timeIntervalSince(date)
- let percentEffected = model.percentEffectRemaining(at: start) - model.percentEffectRemaining(at: end)
- return percentEffected * segment.value.doubleValue(for: unit)
- }
- // Update range statistics
- if minGlucose == nil || prediction.quantity < minGlucose!.quantity {
- minGlucose = prediction
- effectedSensitivityAtMinGlucose = effectedSensitivity
- }
- guard let correctionUnits = insulinCorrectionUnits(
- fromValue: predictedGlucoseValue,
- toValue: targetValue,
- effectedSensitivity: Swift.max(.ulpOfOne, effectedSensitivity)
- ), 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
- }
- guard let eventualGlucose, let minGlucose else {
- return nil
- }
- // Choose either the minimum glucose or eventual glucose as the correction delta
- let minGlucoseTargets = correctionRange.quantityRange(at: minGlucose.startDate)
- let eventualGlucoseTargets = correctionRange.quantityRange(at: eventualGlucose.startDate)
- // Treat the mininum glucose when both are below range
- if minGlucose.quantity < minGlucoseTargets.lowerBound &&
- eventualGlucose.quantity < eventualGlucoseTargets.lowerBound
- {
- guard let units = insulinCorrectionUnits(
- fromValue: minGlucose.quantity.doubleValue(for: unit),
- toValue: minGlucoseTargets.averageValue(for: unit),
- effectedSensitivity: Swift.max(.ulpOfOne, effectedSensitivityAtMinGlucose!)
- ) else {
- return nil
- }
- 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 temporary basal rate to conform a glucose prediction timeline to a correction range
- ///
- /// Returns nil if the normal scheduled basal, or active temporary basal, is sufficient.
- ///
- /// - Parameters:
- /// - correctionRange: The schedule of correction ranges
- /// - date: The date at which the temp basal would be scheduled, defaults to now
- /// - suspendThreshold: A glucose value causing a recommendation of no insulin if any prediction falls below
- /// - sensitivity: The schedule of insulin sensitivities
- /// - model: The insulin absorption model
- /// - basalRates: The schedule of basal rates
- /// - additionalActiveInsulinClamp: Max amount of additional insulin above scheduled basal rate allowed to be scheduled
- /// - maxBasalRate: The maximum allowed basal rate
- /// - lastTempBasal: The previously set temp basal
- /// - rateRounder: Closure that rounds recommendation to nearest supported rate. If nil, no rounding is performed
- /// - isBasalRateScheduleOverrideActive: A flag describing whether a basal rate schedule override is in progress
- /// - duration: The duration of the temporary basal
- /// - continuationInterval: The duration of time before an ongoing temp basal should be continued with a new command
- /// - Returns: The recommended temporary basal rate and duration
- public func recommendedTempBasal(
- to correctionRange: GlucoseRangeSchedule,
- at date: Date = Date(),
- suspendThreshold: HKQuantity?,
- sensitivity: InsulinSensitivitySchedule,
- model: InsulinModel,
- basalRates: BasalRateSchedule,
- maxBasalRate: Double,
- additionalActiveInsulinClamp: Double? = nil,
- lastTempBasal: DoseEntry?,
- rateRounder: ((Double) -> Double)? = nil,
- isBasalRateScheduleOverrideActive: Bool = false,
- duration: TimeInterval = TimeInterval(30 * 60),
- continuationInterval: TimeInterval = TimeInterval(60 * 11)
- ) -> TempBasalRecommendation? {
- let correction = self.insulinCorrection(
- to: correctionRange,
- at: date,
- suspendThreshold: suspendThreshold ?? correctionRange.quantityRange(at: date).lowerBound,
- sensitivity: sensitivity.quantity(at: date),
- model: model
- )
- let scheduledBasalRate = basalRates.value(at: date)
- 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 = scheduledBasalRate
- }
- if let additionalActiveInsulinClamp {
- let maxThirtyMinuteRateToKeepIOBBelowLimit = additionalActiveInsulinClamp * 2.0 + scheduledBasalRate // 30 minutes of a U/hr rate
- maxBasalRate = Swift.min(maxThirtyMinuteRateToKeepIOBBelowLimit, maxBasalRate)
- }
- let temp = correction?.asTempBasal(
- scheduledBasalRate: scheduledBasalRate,
- maxBasalRate: maxBasalRate,
- duration: duration,
- rateRounder: rateRounder
- )
- return temp?.ifNecessary(
- at: date,
- scheduledBasalRate: scheduledBasalRate,
- lastTempBasal: lastTempBasal,
- continuationInterval: continuationInterval,
- scheduledBasalRateMatchesPump: !isBasalRateScheduleOverrideActive
- )
- }
- /// Recommends a dose suitable for automatic enactment. Uses boluses for high corrections, and temp basals for low corrections.
- ///
- /// Returns nil if the normal scheduled basal, or active temporary basal, is sufficient.
- ///
- /// - Parameters:
- /// - correctionRange: The schedule of correction ranges
- /// - date: The date at which the temp basal would be scheduled, defaults to now
- /// - suspendThreshold: A glucose value causing a recommendation of no insulin if any prediction falls below
- /// - sensitivity: The schedule of insulin sensitivities
- /// - model: The insulin absorption model
- /// - basalRates: The schedule of basal rates
- /// - maxBasalRate: The maximum allowed basal rate
- /// - lastTempBasal: The previously set temp basal
- /// - rateRounder: Closure that rounds recommendation to nearest supported rate. If nil, no rounding is performed
- /// - isBasalRateScheduleOverrideActive: A flag describing whether a basal rate schedule override is in progress
- /// - duration: The duration of the temporary basal
- /// - continuationInterval: The duration of time before an ongoing temp basal should be continued with a new command
- /// - Returns: The recommended dosing, or nil if no dose adjustment recommended
- public func recommendedAutomaticDose(
- to correctionRange: GlucoseRangeSchedule,
- at date: Date = Date(),
- suspendThreshold: HKQuantity?,
- sensitivity: InsulinSensitivitySchedule,
- model: InsulinModel,
- basalRates: BasalRateSchedule,
- maxAutomaticBolus: Double,
- partialApplicationFactor: Double,
- lastTempBasal: DoseEntry?,
- volumeRounder: ((Double) -> Double)? = nil,
- rateRounder: ((Double) -> Double)? = nil,
- isBasalRateScheduleOverrideActive: Bool = false,
- duration: TimeInterval = TimeInterval(30 * 60),
- continuationInterval: TimeInterval = TimeInterval(11 * 60)
- ) -> AutomaticDoseRecommendation? {
- guard let correction = self.insulinCorrection(
- to: correctionRange,
- at: date,
- suspendThreshold: suspendThreshold ?? correctionRange.quantityRange(at: date).lowerBound,
- sensitivity: sensitivity.quantity(at: date),
- model: model
- ) else {
- return nil
- }
- let scheduledBasalRate = basalRates.value(at: date)
- var maxAutomaticBolus = maxAutomaticBolus
- if case .aboveRange(min: let min, correcting: _, minTarget: let doseThreshold, units: _) = correction,
- min.quantity < doseThreshold
- {
- maxAutomaticBolus = 0
- }
- var temp: TempBasalRecommendation? = correction.asTempBasal(
- scheduledBasalRate: scheduledBasalRate,
- maxBasalRate: scheduledBasalRate,
- duration: duration,
- rateRounder: rateRounder
- )
- temp = temp?.ifNecessary(
- at: date,
- scheduledBasalRate: scheduledBasalRate,
- lastTempBasal: lastTempBasal,
- continuationInterval: continuationInterval,
- scheduledBasalRateMatchesPump: !isBasalRateScheduleOverrideActive
- )
- let bolusUnits = correction.asPartialBolus(
- partialApplicationFactor: partialApplicationFactor,
- maxBolusUnits: maxAutomaticBolus,
- volumeRounder: volumeRounder
- )
- if temp != nil || bolusUnits > 0 {
- return AutomaticDoseRecommendation(basalAdjustment: temp, bolusUnits: bolusUnits)
- }
- return nil
- }
- /// Recommends a bolus to conform a glucose prediction timeline to a correction range
- ///
- /// - Parameters:
- /// - correctionRange: The schedule 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
- /// - sensitivity: The schedule of insulin sensitivities
- /// - model: The insulin absorption model
- /// - pendingInsulin: The number of units expected to be delivered, but not yet reflected in the correction
- /// - maxBolus: The maximum bolus to return
- /// - volumeRounder: Closure that rounds recommendation to nearest supported bolus volume. If nil, no rounding is performed
- /// - Returns: A bolus recommendation
- public func recommendedManualBolus(
- to correctionRange: GlucoseRangeSchedule,
- at date: Date = Date(),
- suspendThreshold: HKQuantity?,
- sensitivity: InsulinSensitivitySchedule,
- model: InsulinModel,
- pendingInsulin: Double,
- maxBolus: Double,
- volumeRounder: ((Double) -> Double)? = nil
- ) -> ManualBolusRecommendation {
- guard let correction = self.insulinCorrection(
- to: correctionRange,
- at: date,
- suspendThreshold: suspendThreshold ?? correctionRange.quantityRange(at: date).lowerBound,
- sensitivity: sensitivity.quantity(at: date),
- model: model
- ) else {
- return ManualBolusRecommendation(amount: 0, pendingInsulin: pendingInsulin)
- }
- var bolus = correction.asManualBolus(
- pendingInsulin: pendingInsulin,
- maxBolus: maxBolus,
- volumeRounder: volumeRounder
- )
- // 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 < correctionRange.quantityRange(at: first.startDate).lowerBound
- {
- bolus.notice = .currentGlucoseBelowTarget(glucose: first)
- }
- return bolus
- }
- }
|