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
10 changes: 10 additions & 0 deletions Sources/LoopAlgorithm/AlgorithmInput.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,18 @@ public protocol AlgorithmInput {
var automaticBolusApplicationFactor: Double? { get } // Defaults to 0.4
var gradualTransitionsThreshold: Double? { get }
var emulation: AlgorithmEmulationOptions? { get } // Defaults to nil (no emulation)
/// Optional algorithm experiments; the empty set is standard LoopAlgorithm. Conforming types that do
/// not provide it get the empty set.
var experiments: Set<AlgorithmExperiment> { get }
/// The person's volatility baseline for the `guardAndRunway` experiment's volatility band: the median
/// σ5 (mg/dL per 5 min) over their last four weeks of glucose, from `GlucoseVolatility.baseline(_:)`.
/// When nil it is computed from `glucoseHistory` if that holds at least a day of readings; otherwise
/// the band runs at full depth.
var volatilityBaseline: Double? { get }
}

public extension AlgorithmInput {
var emulation: AlgorithmEmulationOptions? { nil }
var experiments: Set<AlgorithmExperiment> { [] }
var volatilityBaseline: Double? { nil }
}
11 changes: 11 additions & 0 deletions Sources/LoopAlgorithm/AlgorithmInputFixture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ public struct AlgorithmInputFixture: AlgorithmInput {
public var automaticBolusApplicationFactor: Double?
public var gradualTransitionsThreshold: Double?
public var emulation: AlgorithmEmulationOptions?
/// Optional experiments; absent in existing fixtures, which therefore run standard LoopAlgorithm.
public var experiments: Set<AlgorithmExperiment> = []
public var volatilityBaseline: Double?

public var recommendationInsulinModel: InsulinModel {
recommendationInsulinType.insulinModel
Expand Down Expand Up @@ -152,6 +155,8 @@ extension AlgorithmInputFixture: Codable {
self.automaticBolusApplicationFactor = try container.decodeIfPresent(Double.self, forKey: .automaticBolusApplicationFactor)
self.gradualTransitionsThreshold = try container.decodeIfPresent(Double.self, forKey: .gradualTransitionsThreshold) ?? 40.0
self.emulation = try container.decodeIfPresent(AlgorithmEmulationOptions.self, forKey: .emulation)
self.experiments = Set(try container.decodeIfPresent([AlgorithmExperiment].self, forKey: .experiments) ?? [])
self.volatilityBaseline = try container.decodeIfPresent(Double.self, forKey: .volatilityBaseline)
}

public func encode(to encoder: Encoder) throws {
Expand Down Expand Up @@ -190,6 +195,10 @@ extension AlgorithmInputFixture: Codable {
if let emulation {
try container.encode(emulation, forKey: .emulation)
}
if !experiments.isEmpty {
try container.encode(experiments.map(\.rawValue).sorted(), forKey: .experiments)
}
try container.encodeIfPresent(volatilityBaseline, forKey: .volatilityBaseline)
}

private enum CodingKeys: String, CodingKey {
Expand All @@ -213,6 +222,8 @@ extension AlgorithmInputFixture: Codable {
case automaticBolusApplicationFactor
case gradualTransitionsThreshold
case emulation
case experiments
case volatilityBaseline
}
}

Expand Down
116 changes: 116 additions & 0 deletions Sources/LoopAlgorithm/Experiments/AlgorithmExperiments.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
//
// AlgorithmExperiments.swift
// LoopAlgorithm
//
// Copyright © 2026 LoopKit Authors. All rights reserved.
//

import Foundation

/// Optional algorithm experiments. The empty set (the default everywhere) is standard LoopAlgorithm.
///
/// Each experiment is an on/off switch with no tunable parameters, so that people who choose the same
/// switches pool into one group whose results can be compared. Experiments combine: each acts on its own
/// part of the algorithm, and a combination is its own group.
public enum AlgorithmExperiment: String, Codable, CaseIterable, Sendable {

/// LoopEval guard + runway (C20 + C29 + C40). A lows guard, plus a little more correction when there
/// is room for it:
/// - **Post-low rise-cut** (C20). For 12 h after a reading below 70 mg/dL, and while glucose is
/// below 180, the rising side of retrospective correction is weighted 0.3×: the rebound after a
/// low (rescue carbs, counter-regulation) is not treated as a lasting need for insulin.
/// - **Volatility band** (C29). While no carbs are on board, the first two hours of the forecast are
/// lowered in proportion to recent glucose volatility, so the low guard sees it. Eventual glucose
/// is unchanged. Its depth is keyed to the person's median volatility
/// (`AlgorithmInput.volatilityBaseline`), and is full depth until a baseline exists.
/// - **Runway reserve** (C40). While glucose is 150 or above and no carbs are on board: how much
/// extra insulin could still be taken back by suspending scheduled basal, if glucose then fell
/// along the forecast less one σ of recent volatility, without reaching the suspend threshold
/// within 3 h? 70 % of that amount (at most 60 mg/dL's worth) is added to the automatic
/// correction. Calm glucose leaves room; volatile glucose leaves little or none. It is applied as
/// an internal lowering of the correction aim for automatic dosing only: the user's target, the
/// manual bolus recommendation and the suspend threshold are unchanged.
case guardAndRunway

/// Integral retrospective correction: retrospective correction integrates the recent unexplained
/// glucose change instead of projecting only the last 30 minutes. The same as
/// `AlgorithmInput.useIntegralRetrospectiveCorrection`; either one enables it.
case integralRetrospectiveCorrection

/// Glucose-based application factor, for automatic-bolus dosing: the share of the correction given
/// now rises with current glucose, from 0.2 at the lower correction target + 10 mg/dL to 0.8 at
/// 200 mg/dL, instead of a constant. It replaces `AlgorithmInput.automaticBolusApplicationFactor`
/// and, through it, the automatic-bolus cap. Temp-basal dosing is unaffected.
case glucoseBasedApplicationFactor
}

/// Loop's glucose-based application factor (`glucoseBasedApplicationFactor`).
enum GlucoseBasedApplicationFactor {
static let minFactor = 0.2
static let maxFactor = 0.8
static let lowerTargetDelta = 10.0 // mg/dL above the lower correction target where the ramp starts
static let rampTop = 200.0 // mg/dL where the factor reaches maxFactor

/// `glucose` and `lowerTarget` in mg/dL.
static func factor(glucose: Double, lowerTarget: Double) -> Double {
let rampStart = lowerTarget + lowerTargetDelta
let slope = (maxFactor - minFactor) / (rampTop - rampStart)
return min(minFactor + max(glucose - rampStart, 0) * slope, maxFactor)
}
}

/// The internal configuration the experiment expands to. Not public: the experiment is fixed.
struct ExperimentParameters: Equatable {
var postLowRiseCut: PostLowRiseCut? = nil
var volatilityBand: VolatilityBand? = nil
var runwayReserve: RunwayReserve? = nil

static let none = ExperimentParameters()

var needsVolatility: Bool { volatilityBand != nil || runwayReserve != nil }

/// `volatilityBaseline` is the person's median σ5 (mg/dL per 5 min); only the band uses it.
/// Integral RC and the glucose-based application factor are switched where they act, not here.
init(experiments: Set<AlgorithmExperiment>, volatilityBaseline: Double?) {
if experiments.contains(.guardAndRunway) {
postLowRiseCut = PostLowRiseCut()
runwayReserve = RunwayReserve()
// Without a baseline (a new user, under a day of glucose) the band runs at full depth: the
// baseline only ever lowers it, for people whose glucose is volatile. Full depth is the safer
// side, fewer lows for a little time in range.
if let baseline = volatilityBaseline, baseline.isFinite, baseline > 0 {
volatilityBand = VolatilityBand(gain: Swift.min(1.0, 6.7 / baseline))
} else {
volatilityBand = VolatilityBand(gain: 1.0)
}
}
}

init() {}

struct PostLowRiseCut: Equatable {
var riseScale = 0.3
var window: TimeInterval = .hours(12)
var lowThreshold = 70.0 // mg/dL
var releaseGlucose = 180.0 // mg/dL
}

struct VolatilityBand: Equatable {
var gain: Double
var horizon: TimeInterval = .minutes(60)
var taperEnd: TimeInterval = .minutes(120)
var exponent = 0.71
var carbGate = true
}

struct RunwayReserve: Equatable {
var fraction = 0.7 // share of the reserve that is spent
var bandWidth = 1.0 // σ5 multiples below the forecast for the plausible fall
var exponent = 0.71 // band growth with horizon, as the volatility band
var horizon: TimeInterval = .minutes(180)
var maxAimShift = 60.0 // mg/dL
var minGlucose = 150.0 // mg/dL
var minEffectFraction = 0.05 // horizons where less of a dose has acted are skipped
var carbGate = true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
//
// ExperimentalForecastAdjustments.swift
// LoopAlgorithm
//
// Copyright © 2026 LoopKit Authors. All rights reserved.
//

import Foundation

extension ExperimentParameters.PostLowRiseCut {
/// True when current glucose is below `releaseGlucose` and some reading within `window` before it
/// was below `lowThreshold`.
func isActive<GlucoseType: GlucoseValue>(glucoseHistory: [GlucoseType]) -> Bool {
let unit = LoopUnit.milligramsPerDeciliter
guard let latest = glucoseHistory.last,
latest.quantity.doubleValue(for: unit) < releaseGlucose else { return false }
for sample in glucoseHistory.reversed() {
if latest.startDate.timeIntervalSince(sample.startDate) > window { break }
if sample.quantity.doubleValue(for: unit) < lowThreshold { return true }
}
return false
}
}

extension LoopAlgorithm {

/// The volatility band: each predicted point τ minutes ahead is lowered by
/// `gain × σ5 × (τ/5)^exponent` up to `horizon`, tapering to zero by `taperEnd`.
static func volatilityBanded(_ glucose: [PredictedGlucoseValue], band: ExperimentParameters.VolatilityBand,
volatility: Double) -> [PredictedGlucoseValue] {
guard band.gain > 0, let t0 = glucose.first?.startDate else { return glucose }
let unit = LoopUnit.milligramsPerDeciliter
let horizon = band.horizon.minutes
let taperEnd = Swift.max(band.taperEnd.minutes, horizon + 1)
let atHorizon = pow(Swift.max(horizon, 5.0) / 5.0, band.exponent)
return glucose.map { point in
let tau = point.startDate.timeIntervalSince(t0).minutes
var shape = 0.0
if tau <= 0 { shape = 0 }
else if tau <= horizon { shape = pow(Swift.max(tau, 5.0) / 5.0, band.exponent) }
else if tau < taperEnd { shape = atHorizon * (taperEnd - tau) / (taperEnd - horizon) }
let offset = -band.gain * volatility * shape
return PredictedGlucoseValue(startDate: point.startDate,
quantity: LoopQuantity(unit: unit, doubleValue: point.quantity.doubleValue(for: unit) + offset))
}
}
/// The runway reserve's aim shift, mg/dL (0 when the gate is closed or there is no room).
///
/// Suppose Loop makes the correction it already plans (the excess of eventual glucose over the target
/// midpoint) plus an extra E units. The plausible path τ minutes ahead is the forecast, less that
/// planned correction's effect so far, less `bandWidth × σ5 × (τ/5)^exponent`. If glucose then followed
/// it, suspending scheduled basal from now would take back `basal × ∫ acted` of insulin. E_max is the
/// largest E for which that path, with the basal taken back, stays above the suspend threshold at every
/// horizon out to `horizon` (a forecast shorter than `horizon` gives no runway). The shift spends `fraction` of it, expressed in mg/dL through the ISF and
/// capped at `maxAimShift`.
static func runwayAimShift(prediction: [PredictedGlucoseValue], reserve: ExperimentParameters.RunwayReserve,
currentGlucose: Double, activeCarbs: Double, volatility: Double,
sensitivity: Double, basalRate: Double, targetMidpoint: Double,
suspendThreshold: Double, insulinModel: InsulinModel) -> Double {
let unit = LoopUnit.milligramsPerDeciliter
guard volatility.isFinite, sensitivity > 0, currentGlucose >= reserve.minGlucose,
!(reserve.carbGate && activeCarbs > 0),
let t0 = prediction.first?.startDate, let last = prediction.last,
// A forecast that stops short of the horizon cannot show a fall beyond its end: no runway.
last.startDate.timeIntervalSince(t0) >= reserve.horizon else { return 0 }
let eventual = last.quantity.doubleValue(for: unit)
// (τ, glucose) on the forecast's own points, with any gap longer than 5 min (Loop drops points
// once nothing is changing) filled every 5 min by linear interpolation.
var samples: [(tau: Double, value: Double)] = []
for point in prediction {
let tau = point.startDate.timeIntervalSince(t0).minutes
let value = point.quantity.doubleValue(for: unit)
if let previous = samples.last, tau - previous.tau > 5.0 + 1e-9 {
var fill = previous.tau + 5.0
while fill < tau - 1e-9 {
samples.append((fill, previous.value + (value - previous.value) * (fill - previous.tau) / (tau - previous.tau)))
fill += 5.0
}
}
samples.append((tau, value))
}
let excess = Swift.max(0, eventual - targetMidpoint)
let horizon = reserve.horizon.minutes
func acted(_ minutes: Double) -> Double {
minutes <= 0 ? 0 : Swift.max(0, Swift.min(1, 1 - insulinModel.percentEffectRemaining(at: .minutes(minutes))))
}
var eMax = Double.infinity // units of extra insulin
var withheld = 0.0 // units of withheld basal that have acted by τ
var lastTau = 0.0
for (tau, value) in samples {
if tau <= 0 { continue }
if tau > horizon { break }
withheld += basalRate / 60.0 * acted(0.5 * (tau + lastTau)) * (tau - lastTau) // midpoint rule
lastTau = tau
let d = acted(tau)
guard d > reserve.minEffectFraction else { continue }
let path = value - excess * d
- reserve.bandWidth * volatility * pow(Swift.max(tau, 5) / 5, reserve.exponent)
eMax = Swift.min(eMax, (path + sensitivity * withheld - suspendThreshold) / (sensitivity * d))
}
guard eMax.isFinite else { return 0 }
return Swift.min(reserve.maxAimShift, reserve.fraction * sensitivity * Swift.max(0, eMax))
}

/// The target range lowered by `shift` mg/dL, floored at 70–80.
static func targetLowered(_ target: GlucoseRangeTimeline, by shift: Double) -> GlucoseRangeTimeline {
let unit = LoopUnit.milligramsPerDeciliter
return target.map {
AbsoluteScheduleValue(
startDate: $0.startDate, endDate: $0.endDate,
value: ClosedRange(uncheckedBounds: (
lower: LoopQuantity(unit: unit, doubleValue: Swift.max(70.0, $0.value.lowerBound.doubleValue(for: unit) - shift)),
upper: LoopQuantity(unit: unit, doubleValue: Swift.max(80.0, $0.value.upperBound.doubleValue(for: unit) - shift)))))
}
}
}
73 changes: 73 additions & 0 deletions Sources/LoopAlgorithm/Experiments/GlucoseVolatility.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
//
// GlucoseVolatility.swift
// LoopAlgorithm
//
// Copyright © 2026 LoopKit Authors. All rights reserved.
//

import Foundation

/// Causal short-term glucose volatility, used by the `guardAndRunway` experiment.
public enum GlucoseVolatility {

/// EWMA weight on the previous variance.
public static let lambda = 0.875
/// CGM noise level; the variance is floored at 2 × noise².
public static let noise = 1.4

/// σ5 now: the exponentially weighted RMS of the 5-minute glucose increment, in mg/dL per 5 min,
/// over the last ~5 h of readings.
public static func sigma5<GlucoseType: GlucoseValue>(_ glucoseHistory: [GlucoseType]) -> Double {
let series = sigma5Series(fiveMinute(glucoseHistory).suffix(60))
return series.last ?? (2.0 * noise * noise).squareRoot()
}

/// The person's volatility baseline for the `guardAndRunway` experiment's band: the median of σ5 over a glucose
/// history. LoopEval used each person's own median; four weeks of history gave a stable value
/// (about 9 % drift between successive four-week windows). Returns nil with less than a day of readings.
public static func baseline<GlucoseType: GlucoseValue>(_ glucoseHistory: [GlucoseType]) -> Double? {
let series = sigma5Series(fiveMinute(glucoseHistory))
guard series.count >= 288 else { return nil }
let sorted = series.sorted()
let mid = sorted.count / 2
return sorted.count % 2 == 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]
}

/// Thin to 5-minute spacing: keep a reading only when it is at least 4 minutes after the last one
/// kept. With 5-minute CGM every reading is kept; with 1-minute CGM the estimator would otherwise
/// see only 1-minute increments, which it ignores, and never leave its floor.
static func fiveMinute<GlucoseType: GlucoseValue>(_ glucoseHistory: [GlucoseType]) -> [(date: Date, value: Double)] {
let unit = LoopUnit.milligramsPerDeciliter
var kept: [(date: Date, value: Double)] = []
for sample in glucoseHistory {
if let last = kept.last, sample.startDate.timeIntervalSince(last.date) < .minutes(4) { continue }
kept.append((sample.startDate, sample.quantity.doubleValue(for: unit)))
}
return kept
}

/// σ5 after each reading. Readings 2–7 min apart contribute (scaled to 5 min); a gap of more than
/// 7 min restarts the estimate, which reads as the floor until the next usable pair.
static func sigma5Series<S: Sequence>(_ samples: S) -> [Double] where S.Element == (date: Date, value: Double) {
let floorVariance = 2.0 * noise * noise
var variance = Double.nan
var previous: (date: Date, value: Double)? = nil
var out: [Double] = []
for sample in samples {
if let previous {
let minutes = sample.date.timeIntervalSince(previous.date) / 60.0
if minutes > 2.0 && minutes <= 7.0 {
let d5 = (sample.value - previous.value) * (5.0 / minutes)
variance = variance.isNaN
? Swift.max(d5 * d5, floorVariance)
: Swift.max(lambda * variance + (1 - lambda) * d5 * d5, floorVariance)
} else if minutes > 7.0 {
variance = .nan
}
}
previous = sample
out.append(variance.isNaN ? floorVariance.squareRoot() : variance.squareRoot())
}
return out
}
}
Loading