Skip to content
Closed
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
13 changes: 13 additions & 0 deletions Sources/LoopAlgorithm/AlgorithmInput.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,17 @@ public protocol AlgorithmInput {
var recommendationType: DoseRecommendationType { get }
var automaticBolusApplicationFactor: Double? { get } // Defaults to 0.4
var gradualTransitionsThreshold: Double? { get }
/// Optional algorithm experiment; nil is standard LoopAlgorithm. Conforming types that do not
/// provide it get nil.
var experiment: AlgorithmExperiment? { get }
/// The person's volatility baseline for the `stack` experiment: 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 stack runs
/// its post-low rise-cut only.
var volatilityBaseline: Double? { get }
}

extension AlgorithmInput {
public var experiment: AlgorithmExperiment? { nil }
public var volatilityBaseline: Double? { nil }
}
9 changes: 9 additions & 0 deletions Sources/LoopAlgorithm/AlgorithmInputFixture.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ public struct AlgorithmInputFixture: AlgorithmInput {
public var recommendationType: DoseRecommendationType = .automaticBolus
public var automaticBolusApplicationFactor: Double?
public var gradualTransitionsThreshold: Double?
/// Optional experiment; absent in existing fixtures, which therefore run standard LoopAlgorithm.
public var experiment: AlgorithmExperiment?
public var volatilityBaseline: Double?

public var recommendationInsulinModel: InsulinModel {
recommendationInsulinType.insulinModel
Expand Down Expand Up @@ -148,6 +151,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.experiment = try container.decodeIfPresent(AlgorithmExperiment.self, forKey: .experiment)
self.volatilityBaseline = try container.decodeIfPresent(Double.self, forKey: .volatilityBaseline)

}

Expand Down Expand Up @@ -184,6 +189,8 @@ extension AlgorithmInputFixture: Codable {
try container.encode(recommendationType.rawValue, forKey: .recommendationType)
try container.encode(automaticBolusApplicationFactor, forKey: .automaticBolusApplicationFactor)
try container.encode(gradualTransitionsThreshold, forKey: .gradualTransitionsThreshold)
try container.encodeIfPresent(experiment, forKey: .experiment)
try container.encodeIfPresent(volatilityBaseline, forKey: .volatilityBaseline)
}

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

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

import Foundation

/// An optional algorithm experiment. `nil` (the default everywhere) is standard LoopAlgorithm.
///
/// There are deliberately only two, so that people who choose to try one pool into large groups whose
/// results can be compared. Each is a fixed configuration that was evaluated in LoopEval (closed-loop
/// counterfactual replay of real CGM and insulin data, scored against the standard algorithm swept over
/// insulin needs); neither has tunable parameters.
public enum AlgorithmExperiment: String, Codable, CaseIterable, Sendable {

/// LoopEval C01. Integral retrospective correction (switched on by the experiment, whatever the
/// integral-RC setting), with the falling side of the correction weighted 1.5× and the rising side
/// 0.5×, and the correction rate capped at 4 mg/dL per minute. It reacts faster to glucose
/// running below the forecast and more slowly to glucose running above it.
case asymmetricIntegralRC

/// LoopEval C30, three parts that act in different situations:
/// - **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.
/// - **Calm-high licence** (C23/C25). When glucose is 180 or above, volatility is at or below the
/// person's own median and no carbs are on board, the automatic-bolus application factor is
/// doubled (capped at 1). Automatic-bolus dosing only.
///
/// The band depth and the licence threshold are keyed to the person's median volatility; supply it
/// as `AlgorithmInput.volatilityBaseline` (see `GlucoseVolatility.baseline(_:)`).
case stack
}

/// The internal configuration each experiment expands to. Not public: the experiments are fixed.
struct ExperimentParameters: Equatable {
var retrospectiveCorrectionDropScale = 1.0
var retrospectiveCorrectionRiseScale = 1.0
var integralRetrospectiveCorrectionMaxVelocity: Double? = nil // mg/dL per minute
var forceIntegralRetrospectiveCorrection = false
var postLowRiseCut: PostLowRiseCut? = nil
var volatilityBand: VolatilityBand? = nil
var calmHighLicence: CalmHighLicence? = nil

static let none = ExperimentParameters()

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

/// `volatilityBaseline` is the person's median σ5 (mg/dL per 5 min); only the stack uses it.
init(experiment: AlgorithmExperiment?, volatilityBaseline: Double?) {
switch experiment {
case nil:
break
case .asymmetricIntegralRC:
retrospectiveCorrectionDropScale = 1.5
retrospectiveCorrectionRiseScale = 0.5
integralRetrospectiveCorrectionMaxVelocity = 4.0
forceIntegralRetrospectiveCorrection = true
case .stack:
postLowRiseCut = PostLowRiseCut()
if let baseline = volatilityBaseline, baseline.isFinite, baseline > 0 {
volatilityBand = VolatilityBand(gain: Swift.min(1.0, 6.7 / baseline))
calmHighLicence = CalmHighLicence(maxVolatility: baseline)
}
}
}

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 CalmHighLicence: Equatable {
var applicationFactorScale = 2.0
var minGlucose = 180.0 // mg/dL
var maxVolatility: Double // mg/dL per 5 min
var carbGate = true
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//
// 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 ExperimentParameters.CalmHighLicence {
func isActive(currentGlucose: Double, volatility: Double, activeCarbs: Double) -> Bool {
if carbGate && activeCarbs > 0 { return false }
return volatility.isFinite && currentGlucose >= minGlucose && volatility <= maxVolatility
}
}

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))
}
}
}
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 `stack` 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 `stack` experiment: 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
}
}
49 changes: 43 additions & 6 deletions Sources/LoopAlgorithm/LoopAlgorithm.swift
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,8 @@ public struct LoopAlgorithm {
includingPositiveVelocityAndRC: Bool = true,
useMidAbsorptionISF: Bool = false,
carbAbsorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption(),
gradualTransitionsThreshold: Double? = 40.0
gradualTransitionsThreshold: Double? = 40.0,
experiment: AlgorithmExperiment? = nil
) -> LoopPrediction<CarbType> where CarbType: CarbEntry, GlucoseType: GlucoseSampleValue, InsulinDoseType: InsulinDose {

var prediction: [PredictedGlucoseValue] = []
Expand Down Expand Up @@ -254,10 +255,26 @@ public struct LoopAlgorithm {

let rc: RetrospectiveCorrection

if useIntegralRetrospectiveCorrection {
rc = IntegralRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration)
// Experiments (no-ops when `experiment` is nil): asymmetric RC gains, and the post-low
// rise-cut, which replaces the rise gain while its gate is open.
let experimentParameters = ExperimentParameters(experiment: experiment, volatilityBaseline: nil)
let rcDropScale = experimentParameters.retrospectiveCorrectionDropScale
var rcRiseScale = experimentParameters.retrospectiveCorrectionRiseScale
if let cut = experimentParameters.postLowRiseCut, cut.isActive(glucoseHistory: glucoseHistory) {
rcRiseScale = cut.riseScale
}

if useIntegralRetrospectiveCorrection || experimentParameters.forceIntegralRetrospectiveCorrection {
rc = IntegralRetrospectiveCorrection(
effectDuration: LoopMath.retrospectiveCorrectionEffectDuration,
dropScale: rcDropScale,
riseScale: rcRiseScale,
maxVelocity: experimentParameters.integralRetrospectiveCorrectionMaxVelocity)
} else {
rc = StandardRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration)
rc = StandardRetrospectiveCorrection(
effectDuration: LoopMath.retrospectiveCorrectionEffectDuration,
dropScale: rcDropScale,
riseScale: rcRiseScale)
}

if let latestGlucose = glucoseHistory.last {
Expand Down Expand Up @@ -550,9 +567,22 @@ public struct LoopAlgorithm {
includingPositiveVelocityAndRC: input.includePositiveVelocityAndRC,
useMidAbsorptionISF: input.useMidAbsorptionISF,
carbAbsorptionModel: input.carbAbsorptionModel.model,
gradualTransitionsThreshold: input.gradualTransitionsThreshold
gradualTransitionsThreshold: input.gradualTransitionsThreshold,
experiment: input.experiment
)

// Experiments that adjust the forecast before dosing (no-ops when `experiment` is nil).
let experimentParameters = ExperimentParameters(
experiment: input.experiment,
volatilityBaseline: input.experiment == .stack
? (input.volatilityBaseline ?? GlucoseVolatility.baseline(input.glucoseHistory)) : nil)
let volatility: Double? = experimentParameters.needsVolatility ? GlucoseVolatility.sigma5(input.glucoseHistory) : nil
if let band = experimentParameters.volatilityBand, let volatility,
!(band.carbGate && (prediction.activeCarbs ?? 0) > 0)
{
prediction.glucose = volatilityBanded(prediction.glucose, band: band, volatility: volatility)
}

let sensitivityForDosing: [AbsoluteScheduleValue<LoopQuantity>]
if input.useMidAbsorptionISF {
sensitivityForDosing = input.sensitivity
Expand Down Expand Up @@ -587,9 +617,16 @@ public struct LoopAlgorithm {
target: input.target)
result = .success(.init(manual: recommendation))
case .automaticBolus:
var applicationFactor = input.automaticBolusApplicationFactor ?? defaultBolusPartialApplicationFactor
if let licence = experimentParameters.calmHighLicence, let volatility,
licence.isActive(currentGlucose: latestGlucose.quantity.doubleValue(for: .milligramsPerDeciliter),
volatility: volatility, activeCarbs: prediction.activeCarbs ?? 0)
{
applicationFactor = Swift.min(1.0, applicationFactor * licence.applicationFactorScale)
}
let recommendation = recommendAutomaticDose(
for: correction,
applicationFactor: input.automaticBolusApplicationFactor ?? defaultBolusPartialApplicationFactor,
applicationFactor: applicationFactor,
neutralBasalRate: scheduledBasalRate,
activeInsulin: prediction.activeInsulin!,
maxBolus: input.maxBolus,
Expand Down
Loading