Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,23 @@ Please understand that this project:
* Is highly experimental
* Is not approved for therapy

## Usage

The entry point is `Algorithm.run(input:)`, which takes any type conforming to `AlgorithmInput` and returns the
glucose forecast, the effects behind it, and a dose recommendation. The API takes plain numbers in fixed units:

| Quantity | Unit |
|---|---|
| Glucose, targets, suspend threshold | mg/dL |
| Insulin sensitivity | mg/dL per unit |
| Carbs | grams |
| Carb ratio | grams per unit |
| Insulin | units |
| Basal rates | U/h |

Apps that store values in other units (mmol/L) convert before calling. `Algorithm.generatePrediction` produces a
forecast without a recommendation.

## Command Line Runner

There is a command line utility that will run the algorithm with a scenario input file:
Expand Down
31 changes: 25 additions & 6 deletions Sources/LoopAlgorithm/AlgorithmInput.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import Foundation


/// The inputs to `Algorithm.run(input:)`. Glucose values are in mg/dL, carbs in grams and insulin in units.
public protocol AlgorithmInput {
associatedtype CarbType: CarbEntry
associatedtype GlucoseType: GlucoseSampleValue
Expand All @@ -18,23 +19,35 @@ public protocol AlgorithmInput {
var glucoseHistory: [GlucoseType] { get }
var doses: [InsulinDoseType] { get }
var carbEntries: [CarbType] { get }
/// Scheduled basal rate in U/h
var basal: [AbsoluteScheduleValue<Double>] { get }
var sensitivity: [AbsoluteScheduleValue<LoopQuantity>] { get }
/// Insulin sensitivity in mg/dL per unit
var sensitivityMgdlPerUnit: [AbsoluteScheduleValue<Double>] { get }
/// Carb ratio in grams per unit
var carbRatio: [AbsoluteScheduleValue<Double>] { get }
var target: GlucoseRangeTimeline { get }
var suspendThreshold: LoopQuantity? { get }
/// Correction range in mg/dL
var targetMgdl: GlucoseRangeTimeline { get }
/// Suspend threshold in mg/dL
var suspendThresholdMgdl: Double? { get }
/// Maximum bolus in units
var maxBolus: Double { get }
var maxActiveInsulinMultiplier: Double? { get } // Defaults to 2 (2x maxBolus)
/// Limit on active insulin, as a multiple of `maxBolus`. Defaults to 2.
var maxActiveInsulinMultiplier: Double? { get }
/// Maximum basal rate in U/h
var maxBasalRate: Double { get }
var useIntegralRetrospectiveCorrection: Bool { get }
var includePositiveVelocityAndRC: Bool { get }
var useMidAbsorptionISF: Bool { get }
var carbAbsorptionModel: CarbAbsorptionModel { get }
var recommendationInsulinModel: InsulinModel { get }
var recommendationType: DoseRecommendationType { get }
var automaticBolusApplicationFactor: Double? { get } // Defaults to 0.4
/// Fraction of the correction given as an automatic bolus. Defaults to 0.4.
var automaticBolusApplicationFactor: Double? { get }
/// Largest jump in mg/dL between consecutive glucose readings for retrospective correction to apply.
/// Defaults to 40.
var gradualTransitionsThreshold: Double? { get }
var emulation: AlgorithmEmulationOptions? { get } // Defaults to nil (no emulation)
/// Options for reproducing earlier deployed algorithm behavior. Defaults to nil (no emulation).
var emulation: AlgorithmEmulationOptions? { get }
/// Optional algorithm experiments; the empty set is standard LoopAlgorithm. Conforming types that do
/// not provide it get the empty set.
var experiments: Set<ExperimentOption> { get }
Expand All @@ -50,3 +63,9 @@ public extension AlgorithmInput {
var experiments: Set<ExperimentOption> { [] }
var volatilityBaseline: Double? { nil }
}

extension AlgorithmInput {
var sensitivityQuantities: [AbsoluteScheduleValue<LoopQuantity>] { sensitivityMgdlPerUnit.mgdlQuantities }
var targetQuantities: QuantityRangeTimeline { targetMgdl.quantities }
var suspendThresholdQuantity: LoopQuantity? { suspendThresholdMgdl.map(LoopQuantity.mgdl) }
}
52 changes: 22 additions & 30 deletions Sources/LoopAlgorithm/AlgorithmInputFixture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ public struct AlgorithmInputFixture: AlgorithmInput {
public var doses: [FixtureInsulinDose]
public var carbEntries: [FixtureCarbEntry]
public var basal: [AbsoluteScheduleValue<Double>]
public var sensitivity: [AbsoluteScheduleValue<LoopQuantity>]
public var sensitivityMgdlPerUnit: [AbsoluteScheduleValue<Double>]
public var carbRatio: [AbsoluteScheduleValue<Double>]
public var target: GlucoseRangeTimeline
public var suspendThreshold: LoopQuantity?
public var targetMgdl: GlucoseRangeTimeline
public var suspendThresholdMgdl: Double?
public var maxBolus: Double
public var maxActiveInsulinMultiplier: Double?
public var maxBasalRate: Double
Expand Down Expand Up @@ -63,10 +63,10 @@ public struct AlgorithmInputFixture: AlgorithmInput {
doses: [FixtureInsulinDose],
carbEntries: [FixtureCarbEntry],
basal: [AbsoluteScheduleValue<Double>],
sensitivity: [AbsoluteScheduleValue<LoopQuantity>],
sensitivityMgdlPerUnit: [AbsoluteScheduleValue<Double>],
carbRatio: [AbsoluteScheduleValue<Double>],
target: GlucoseRangeTimeline,
suspendThreshold: LoopQuantity?,
targetMgdl: GlucoseRangeTimeline,
suspendThresholdMgdl: Double?,
maxBolus: Double,
maxActiveInsulinMultiplier: Double? = nil,
maxBasalRate: Double,
Expand All @@ -85,10 +85,10 @@ public struct AlgorithmInputFixture: AlgorithmInput {
self.doses = doses
self.carbEntries = carbEntries
self.basal = basal
self.sensitivity = sensitivity
self.sensitivityMgdlPerUnit = sensitivityMgdlPerUnit
self.carbRatio = carbRatio
self.target = target
self.suspendThreshold = suspendThreshold
self.targetMgdl = targetMgdl
self.suspendThresholdMgdl = suspendThresholdMgdl
self.maxBolus = maxBolus
self.maxActiveInsulinMultiplier = maxActiveInsulinMultiplier
self.maxBasalRate = maxBasalRate
Expand All @@ -114,19 +114,14 @@ extension AlgorithmInputFixture: Codable {
self.doses = try container.decode([FixtureInsulinDose].self, forKey: .doses)
self.carbEntries = try container.decode([FixtureCarbEntry].self, forKey: .carbEntries)
self.basal = try container.decode([AbsoluteScheduleValue<Double>].self, forKey: .basal)
let sensitivityMgdl = try container.decode([AbsoluteScheduleValue<Double>].self, forKey: .sensitivity)
self.sensitivity = sensitivityMgdl.map { AbsoluteScheduleValue(startDate: $0.startDate, endDate: $0.endDate, value: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: $0.value))}
self.sensitivityMgdlPerUnit = try container.decode([AbsoluteScheduleValue<Double>].self, forKey: .sensitivity)
self.carbRatio = try container.decode([AbsoluteScheduleValue<Double>].self, forKey: .carbRatio)
let targetMgdl = try container.decode([TargetEntry].self, forKey: .target)
self.target = targetMgdl.map {
let lower = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: $0.lowerBound)
let upper = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: $0.upperBound)
let range = ClosedRange(uncheckedBounds: (lower: lower, upper: upper))
let targetEntries = try container.decode([TargetEntry].self, forKey: .target)
self.targetMgdl = targetEntries.map {
let range = ClosedRange(uncheckedBounds: (lower: $0.lowerBound, upper: $0.upperBound))
return AbsoluteScheduleValue(startDate: $0.startDate, endDate: $0.endDate, value: range)
}
if let suspendThresholdMgdl = try container.decodeIfPresent(Double.self, forKey: .suspendThreshold) {
self.suspendThreshold = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: suspendThresholdMgdl)
}
self.suspendThresholdMgdl = try container.decodeIfPresent(Double.self, forKey: .suspendThreshold)
self.maxBolus = try container.decode(Double.self, forKey: .maxBolus)
self.maxActiveInsulinMultiplier = try container.decodeIfPresent(Double.self, forKey: .maxActiveInsulinMultiplier)
self.maxBasalRate = try container.decode(Double.self, forKey: .maxBasalRate)
Expand Down Expand Up @@ -168,16 +163,13 @@ extension AlgorithmInputFixture: Codable {
try container.encode(doses, forKey: .doses)
try container.encode(carbEntries, forKey: .carbEntries)
try container.encode(basal, forKey: .basal)
let sensitivityMgdl = sensitivity.map { AbsoluteScheduleValue(startDate: $0.startDate, endDate: $0.endDate, value: $0.value.doubleValue(for: .milligramsPerDeciliter)) }
try container.encode(sensitivityMgdl, forKey: .sensitivity)
try container.encode(sensitivityMgdlPerUnit, forKey: .sensitivity)
try container.encode(carbRatio, forKey: .carbRatio)
let targetMgdl = target.map {
let lower = $0.value.lowerBound.doubleValue(for: .milligramsPerDeciliter)
let upper = $0.value.upperBound.doubleValue(for: .milligramsPerDeciliter)
return TargetEntry(startDate: $0.startDate, endDate: $0.endDate, lowerBound: lower, upperBound: upper)
let targetEntries = targetMgdl.map {
TargetEntry(startDate: $0.startDate, endDate: $0.endDate, lowerBound: $0.value.lowerBound, upperBound: $0.value.upperBound)
}
try container.encode(targetMgdl, forKey: .target)
try container.encode(suspendThreshold?.doubleValue(for: .milligramsPerDeciliter), forKey: .suspendThreshold)
try container.encode(targetEntries, forKey: .target)
try container.encode(suspendThresholdMgdl, forKey: .suspendThreshold)
try container.encode(maxBolus, forKey: .maxBolus)
try container.encode(maxActiveInsulinMultiplier, forKey: .maxActiveInsulinMultiplier)
try container.encode(maxBasalRate, forKey: .maxBasalRate)
Expand Down Expand Up @@ -235,10 +227,10 @@ extension AlgorithmInputFixture {
doses: input.doses.map(\.asFixtureInsulinDose),
carbEntries: input.carbEntries.map(\.asFixtureCarbEntry),
basal: input.basal,
sensitivity: input.sensitivity,
sensitivityMgdlPerUnit: input.sensitivityMgdlPerUnit,
carbRatio: input.carbRatio,
target: input.target,
suspendThreshold: input.suspendThreshold,
targetMgdl: input.targetMgdl,
suspendThresholdMgdl: input.suspendThresholdMgdl,
maxBolus: input.maxBolus,
maxBasalRate: input.maxBasalRate,
useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection,
Expand Down
30 changes: 20 additions & 10 deletions Sources/LoopAlgorithm/Carbs/AbsorbedCarbValue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,19 @@ import Foundation

/// A quantity of carbs absorbed over a given date interval
public struct AbsorbedCarbValue: SampleValue {
/// The quantity of carbs absorbed
public let observed: LoopQuantity
/// The quantity of carbs absorbed, clamped to the original prediction
public let clamped: LoopQuantity
/// The quantity of carbs entered as eaten
public let total: LoopQuantity
/// The quantity of carbs expected to still absorb
public let remaining: LoopQuantity
let observed: LoopQuantity
let clamped: LoopQuantity
let total: LoopQuantity
let remaining: LoopQuantity

/// Grams of carbs absorbed
public var observedGrams: Double { observed.doubleValue(for: .gram) }
/// Grams of carbs absorbed, clamped to the original prediction
public var clampedGrams: Double { clamped.doubleValue(for: .gram) }
/// Grams of carbs entered as eaten
public var totalGrams: Double { total.doubleValue(for: .gram) }
/// Grams of carbs expected to still absorb
public var remainingGrams: Double { remaining.doubleValue(for: .gram) }
/// The dates over which absorption was observed
public let observedDate: DateInterval

Expand All @@ -37,7 +42,12 @@ public struct AbsorbedCarbValue: SampleValue {
return estimatedTimeRemaining > 0
}

public var observedProgress: LoopQuantity {
/// Observed absorption as a fraction of the total entered
public var observedFraction: Double {
observedProgress.doubleValue(for: .percent)
}

var observedProgress: LoopQuantity {
let gram = LoopUnit.gram
let totalGrams = total.doubleValue(for: gram)
let percent = LoopUnit.percent
Expand All @@ -54,7 +64,7 @@ public struct AbsorbedCarbValue: SampleValue {

// MARK: SampleValue

public var quantity: LoopQuantity {
var quantity: LoopQuantity {
return clamped
}

Expand Down
8 changes: 8 additions & 0 deletions Sources/LoopAlgorithm/Carbs/CarbEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ import Foundation


public protocol CarbEntry: SampleValue {
/// The carbohydrate amount in grams
var grams: Double { get }
var absorptionTime: TimeInterval? { get }
}

extension CarbEntry {
var quantity: LoopQuantity {
LoopQuantity(unit: .gram, doubleValue: grams)
}
}

42 changes: 40 additions & 2 deletions Sources/LoopAlgorithm/Carbs/CarbMath.swift
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ extension Collection {
return values
}

public func dynamicGlucoseEffects<T>(
func dynamicGlucoseEffects<T>(
from start: Date? = nil,
to end: Date? = nil,
carbRatios: [AbsoluteScheduleValue<Double>],
Expand Down Expand Up @@ -688,7 +688,7 @@ extension Collection where Element: CarbEntry {
/// - defaultAbsorptionTime: The absorption time to use for unspecified carb entries
/// - delay: The time to delay the dose effect
/// - Returns: A new array of `CarbStatus` values describing the absorbed carb quantities
public func map(
func map(
to effectVelocities: [GlucoseEffectVelocity],
carbRatio: [AbsoluteScheduleValue<Double>],
insulinSensitivity: [AbsoluteScheduleValue<LoopQuantity>],
Expand Down Expand Up @@ -790,3 +790,41 @@ extension Collection where Element: CarbEntry {
return builders.map { $0.result }
}
}

public extension Collection {
/// Carb effects from absorption status. Sensitivity is in mg/dL per unit.
func dynamicGlucoseEffects<T>(
from start: Date? = nil,
to end: Date? = nil,
carbRatios: [AbsoluteScheduleValue<Double>],
insulinSensitivitiesMgdlPerUnit: [AbsoluteScheduleValue<Double>],
defaultAbsorptionTime: TimeInterval = CarbMath.defaultAbsorptionTime,
absorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption(),
delay: TimeInterval = CarbMath.defaultEffectDelay,
delta: TimeInterval = GlucoseMath.defaultDelta
) -> [GlucoseEffect] where Element == CarbStatus<T> {
dynamicGlucoseEffects(from: start, to: end, carbRatios: carbRatios, insulinSensitivities: insulinSensitivitiesMgdlPerUnit.mgdlQuantities,
defaultAbsorptionTime: defaultAbsorptionTime, absorptionModel: absorptionModel, delay: delay, delta: delta)
}
}

public extension Collection where Element: CarbEntry {
/// Maps carb entries to observed absorption. Sensitivity is in mg/dL per unit.
func map(
to effectVelocities: [GlucoseEffectVelocity],
carbRatio: [AbsoluteScheduleValue<Double>],
insulinSensitivityMgdlPerUnit: [AbsoluteScheduleValue<Double>],
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<Element>] {
map(to: effectVelocities, carbRatio: carbRatio, insulinSensitivity: insulinSensitivityMgdlPerUnit.mgdlQuantities,
absorptionTimeOverrun: absorptionTimeOverrun, defaultAbsorptionTime: defaultAbsorptionTime, delay: delay,
initialAbsorptionTimeOverrun: initialAbsorptionTimeOverrun, absorptionModel: absorptionModel,
adaptiveAbsorptionRateEnabled: adaptiveAbsorptionRateEnabled, adaptiveRateStandbyIntervalFraction: adaptiveRateStandbyIntervalFraction)
}
}
4 changes: 2 additions & 2 deletions Sources/LoopAlgorithm/Carbs/CarbStatus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ public struct CarbStatus<T: CarbEntry> {

// Masquerade as a carb entry, substituting AbsorbedCarbValue's interpretation of absorption time
extension CarbStatus: SampleValue {
public var quantity: LoopQuantity {
return entry.quantity
public var grams: Double {
return entry.grams
}

public var startDate: Date {
Expand Down
2 changes: 1 addition & 1 deletion Sources/LoopAlgorithm/Carbs/CarbValue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public struct CarbValue: SampleValue {
public let endDate: Date
public var value: Double

public var quantity: LoopQuantity {
var quantity: LoopQuantity {
return LoopQuantity(unit: .gram, doubleValue: value)
}

Expand Down
19 changes: 14 additions & 5 deletions Sources/LoopAlgorithm/Carbs/FixtureCarbEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,29 @@ import Foundation
public struct FixtureCarbEntry: CarbEntry {
public var absorptionTime: TimeInterval?
public var startDate: Date
public var quantity: LoopQuantity
public var grams: Double
public var foodType: String?

public init(
absorptionTime: TimeInterval? = nil,
startDate: Date,
quantity: LoopQuantity,
grams: Double,
foodType: String? = nil
) {
self.absorptionTime = absorptionTime
self.startDate = startDate
self.quantity = quantity
self.grams = grams
self.foodType = foodType
}

init(
absorptionTime: TimeInterval? = nil,
startDate: Date,
quantity: LoopQuantity,
foodType: String? = nil
) {
self.init(absorptionTime: absorptionTime, startDate: startDate, grams: quantity.doubleValue(for: .gram), foodType: foodType)
}
}

extension FixtureCarbEntry: Codable {
Expand All @@ -32,15 +41,15 @@ extension FixtureCarbEntry: Codable {
self.init(
absorptionTime: try container.decodeIfPresent(TimeInterval.self, forKey: .absorptionTime),
startDate: try container.decode(Date.self, forKey: .date),
quantity: LoopQuantity(unit: .gram, doubleValue: try container.decode(Double.self, forKey: .grams))
grams: try container.decode(Double.self, forKey: .grams)
)
}

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)
try container.encode(grams, forKey: .grams)
}

private enum CodingKeys: String, CodingKey {
Expand Down
Loading