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
6 changes: 5 additions & 1 deletion Sources/LoopAlgorithm/AlgorithmOutput.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ public struct AlgorithmOutput<CarbEntryType: CarbEntry> {
public var activeCarbs: Double?
/// Set when the guardAndRunway experiment ran and dosing was recommended.
public var guardAndRunway: GuardAndRunwayOutput?
/// Set when the slowRetrospectiveCorrection experiment ran and dosing was recommended.
public var slowRetrospectiveCorrection: SlowRetrospectiveCorrectionOutput?

public init(
recommendationResult: Result<LoopAlgorithmDoseRecommendation, Error>,
Expand All @@ -24,7 +26,8 @@ public struct AlgorithmOutput<CarbEntryType: CarbEntry> {
dosesRelativeToBasal: [BasalRelativeDose],
activeInsulin: Double? = nil,
activeCarbs: Double? = nil,
guardAndRunway: GuardAndRunwayOutput? = nil
guardAndRunway: GuardAndRunwayOutput? = nil,
slowRetrospectiveCorrection: SlowRetrospectiveCorrectionOutput? = nil
) {
self.recommendationResult = recommendationResult
self.predictedGlucose = predictedGlucose
Expand All @@ -33,6 +36,7 @@ public struct AlgorithmOutput<CarbEntryType: CarbEntry> {
self.activeInsulin = activeInsulin
self.activeCarbs = activeCarbs
self.guardAndRunway = guardAndRunway
self.slowRetrospectiveCorrection = slowRetrospectiveCorrection
}

public var recommendation: LoopAlgorithmDoseRecommendation? {
Expand Down
44 changes: 37 additions & 7 deletions Sources/LoopAlgorithm/Experiments/AlgorithmExperiments.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,18 @@ public enum ExperimentOption: String, Codable, CaseIterable, Sendable {
/// - **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
/// - **Volatility band** (C29). While no carbs are on board, the low guard sees the first two hours of
/// the forecast lowered in proportion to recent glucose volatility: a lowered point below the suspend
/// threshold suspends, and the lowered minimum is the one the guard checks. The correction is sized,
/// and the forecast shown, from the forecast itself, so the band never lowers a correction it does not
/// stop. 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 carbs on board are under 20 g: 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
/// correction, scaled down linearly from no carbs on board to none at 20 g. 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
Expand All @@ -42,6 +45,21 @@ public enum ExperimentOption: String, Codable, CaseIterable, Sendable {
/// 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

/// Slow retrospective correction: a day-scale estimate of how much more or less insulin the person needs
/// than their settings say. Its level is the exponentially weighted mean (time constant 24 h) of the
/// glucose change the forecast model did not explain (insulin counteraction less carb effect, per
/// 5 minutes, at the scheduled ISF) over the glucose history passed in, up to 72 h, from zero at its start.
/// ISF for the whole run is multiplied by `1 − 0.05 × level`, within 0.7–1.3: glucose that keeps rising
/// more than the model expects lowers ISF, and glucose that keeps falling raises it.
///
/// It is computed from the inputs on every run, with no stored state, so it needs a long history: 72 h of
/// glucose, carb entries from 72 h plus the longest absorption time, and doses from 72 h plus the insulin
/// activity duration. The carb ratio and ISF timelines must start at or before the oldest carb entry
/// (otherwise `run` fails with `AlgorithmError.carbRatioOrSensitivityStartsAfterCarbEntry`). A meal whose entry is dropped while its absorption is still inside the
/// glucose history reads as unexplained rise and pushes the level up: pass carbs for the whole history.
/// With 12 h of history the level reaches only about 40 % of its working value.
case slowRetrospectiveCorrection
}

/// Loop's glucose-based application factor (`glucoseBasedApplicationFactor`).
Expand All @@ -64,6 +82,7 @@ struct ExperimentParameters: Equatable {
var postLowRiseCut: PostLowRiseCut? = nil
var volatilityBand: VolatilityBand? = nil
var runwayReserve: RunwayReserve? = nil
var slowRetrospectiveCorrection: SlowRetrospectiveCorrection? = nil

static let none = ExperimentParameters()

Expand All @@ -84,6 +103,9 @@ struct ExperimentParameters: Equatable {
volatilityBand = VolatilityBand(gain: 1.0)
}
}
if experiments.contains(.slowRetrospectiveCorrection) {
slowRetrospectiveCorrection = SlowRetrospectiveCorrection()
}
}

init() {}
Expand Down Expand Up @@ -111,6 +133,14 @@ struct ExperimentParameters: Equatable {
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
var carbFade = 20.0 // g of carbs on board over which the reserve fades out (0: any carbs close it)
}

struct SlowRetrospectiveCorrection: Equatable {
var timeConstant: TimeInterval = .hours(24)
var gain = 0.05 // ISF multiplier change per mg/dL per 5 min of level
var minMultiplier = 0.7
var maxMultiplier = 1.3
var window: TimeInterval = .hours(72)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ extension LoopAlgorithm {
suspendThreshold: Double, insulinModel: InsulinModel) -> Double {
let unit = LoopUnit.milligramsPerDeciliter
guard volatility.isFinite, sensitivity > 0, currentGlucose >= reserve.minGlucose,
!(reserve.carbGate && activeCarbs > 0),
reserve.carbFade > 0 ? activeCarbs < reserve.carbFade : 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 }
Expand Down Expand Up @@ -99,7 +99,68 @@ extension LoopAlgorithm {
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))
let carbScale = reserve.carbFade > 0 ? Swift.max(0, 1 - Swift.max(0, activeCarbs) / reserve.carbFade) : 1
return carbScale * Swift.min(reserve.maxAimShift, reserve.fraction * sensitivity * Swift.max(0, eMax))
}

/// The slow retrospective correction's level, mg/dL per 5 min: the exponentially weighted mean of the
/// unexplained glucose change (insulin counteraction less carb effect) over the counteraction effects
/// that end within `window` before `now`, from zero at the window's start. Each interval's change is
/// scaled to 5 minutes and weighted by its duration. `carbEffects` must cover the window.
static func slowRetrospectiveCorrectionLevel(counteraction: [GlucoseEffectVelocity], carbEffects: [GlucoseEffect],
now: Date, parameters: ExperimentParameters.SlowRetrospectiveCorrection) -> Double {
let unit = LoopUnit.milligramsPerDeciliter
var level = 0.0
var k = carbEffects.startIndex
for ice in counteraction where now.timeIntervalSince(ice.endDate) < parameters.window && ice.endDate <= now {
let duration = ice.endDate.timeIntervalSince(ice.startDate)
guard duration > 0 else { continue }
// Carb effect over the 5-min carb grid step ending at or before this interval's end.
while k + 1 < carbEffects.endIndex && carbEffects[k + 1].startDate <= ice.endDate.addingTimeInterval(1) { k += 1 }
var carb = 0.0
if k > carbEffects.startIndex && carbEffects[k].startDate <= ice.endDate.addingTimeInterval(1) {
carb = carbEffects[k].quantity.doubleValue(for: unit) - carbEffects[k - 1].quantity.doubleValue(for: unit)
}
let iceChange = ice.effect.quantity.doubleValue(for: unit)
guard iceChange.isFinite, carb.isFinite else { continue }
let residual = (iceChange - carb) * .minutes(5) / duration
let decay = exp(-duration / parameters.timeConstant)
level = decay * level + (1 - decay) * residual
}
return level
}

/// The ISF multiplier for a slow retrospective correction level.
static func slowRetrospectiveCorrectionMultiplier(level: Double, parameters: ExperimentParameters.SlowRetrospectiveCorrection) -> Double {
Swift.max(parameters.minMultiplier, Swift.min(parameters.maxMultiplier, 1 - parameters.gain * level))
}

/// `sensitivity` with every value multiplied by `multiplier`.
static func sensitivityScaled(_ sensitivity: [AbsoluteScheduleValue<LoopQuantity>], by multiplier: Double) -> [AbsoluteScheduleValue<LoopQuantity>] {
let unit = LoopUnit.milligramsPerDeciliter
return sensitivity.map {
AbsoluteScheduleValue(startDate: $0.startDate, endDate: $0.endDate,
value: LoopQuantity(unit: unit, doubleValue: $0.value.doubleValue(for: unit) * multiplier))
}
}

/// The low guard's decision on the volatility-banded forecast applied to the correction sized on the
/// forecast itself: a banded point below the suspend threshold suspends; otherwise the banded minimum
/// replaces the forecast's minimum, and the units and the point being corrected stay the forecast's.
static func guarded(_ correction: InsulinCorrection, by guardCorrection: InsulinCorrection) -> InsulinCorrection {
if case .suspend = guardCorrection { return guardCorrection }
let guardMinimum: GlucoseValue?
switch guardCorrection {
case .aboveRange(min: let m, correcting: _, minTarget: _, units: _): guardMinimum = m
case .entirelyBelowRange(min: let m, minTarget: _, units: _): guardMinimum = m
case .suspend(min: let m): guardMinimum = m
case .inRange: guardMinimum = nil
}
if case .aboveRange(min: _, correcting: let correcting, minTarget: let minTarget, units: let units) = correction,
let guardMinimum {
return .aboveRange(min: guardMinimum, correcting: correcting, minTarget: minTarget, units: units)
}
return correction
}

/// The target range lowered by `shift` mg/dL, floored at 70–80.
Expand Down
12 changes: 11 additions & 1 deletion Sources/LoopAlgorithm/Experiments/GuardAndRunwayOutput.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,18 @@ public struct GuardAndRunwayOutput: Equatable {
/// The band's depth multiplier, from the volatility baseline.
public var bandGain: Double { band.gain }

/// `glucose` lowered by the band as this run applied it; unchanged when the band was off.
/// `glucose` lowered by the band as the low guard saw it this run; unchanged when the band was off.
/// `AlgorithmOutput.predictedGlucose` is the forecast without the band.
public func guarded(_ glucose: [PredictedGlucoseValue]) -> [PredictedGlucoseValue] {
bandApplied ? LoopAlgorithm.volatilityBanded(glucose, band: band, volatility: volatility) : glucose
}
}

/// What the slow retrospective correction did on one run, for display.
public struct SlowRetrospectiveCorrectionOutput: Equatable {
/// The day-scale unexplained glucose change, mg/dL per 5 min: positive when glucose has been rising more
/// than the model expected.
public var level: Double
/// The multiplier applied to ISF on this run, 0.7–1.3.
public var multiplier: Double
}
Loading