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 @@ -14,21 +14,25 @@ public struct AlgorithmOutput<CarbEntryType: CarbEntry> {
public var dosesRelativeToBasal: [BasalRelativeDose]
public var activeInsulin: Double?
public var activeCarbs: Double?
/// Set when the guardAndRunway experiment ran and dosing was recommended.
public var guardAndRunway: GuardAndRunwayOutput?

public init(
recommendationResult: Result<LoopAlgorithmDoseRecommendation, Error>,
predictedGlucose: [PredictedGlucoseValue],
effects: LoopAlgorithmEffects<CarbEntryType>,
dosesRelativeToBasal: [BasalRelativeDose],
activeInsulin: Double? = nil,
activeCarbs: Double? = nil
activeCarbs: Double? = nil,
guardAndRunway: GuardAndRunwayOutput? = nil
) {
self.recommendationResult = recommendationResult
self.predictedGlucose = predictedGlucose
self.effects = effects
self.dosesRelativeToBasal = dosesRelativeToBasal
self.activeInsulin = activeInsulin
self.activeCarbs = activeCarbs
self.guardAndRunway = guardAndRunway
}

public var recommendation: LoopAlgorithmDoseRecommendation? {
Expand Down
35 changes: 35 additions & 0 deletions Sources/LoopAlgorithm/Experiments/GuardAndRunwayOutput.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
//
// GuardAndRunwayOutput.swift
// LoopAlgorithm
//
// Copyright © 2026 LoopKit Authors. All rights reserved.
//

import Foundation

/// What Guard + Runway did on one run, for display.
public struct GuardAndRunwayOutput: Equatable {
/// Recent glucose volatility (σ5), mg/dL.
public var volatility: Double
/// False when active carbs kept the volatility band off.
public var bandApplied: Bool
/// How far the target for automatic dosing was lowered, mg/dL. Manual recommendations keep the target.
public var runwayShift: Double
/// True while the post-low cut scales down the rising side of retrospective correction.
public var postLowRiseCutActive: Bool

let band: ExperimentParameters.VolatilityBand

/// The band's depth multiplier, from the volatility baseline.
public var bandGain: Double { band.gain }

/// How far the band lowers the forecast at its deepest point (60 min), mg/dL; 0 when it was off.
public var bandDepth: Double {
bandApplied ? band.gain * volatility * pow(band.horizon.minutes / 5.0, band.exponent) : 0
}

/// `glucose` lowered by the band as this run applied it; unchanged when the band was off.
public func guarded(_ glucose: [PredictedGlucoseValue]) -> [PredictedGlucoseValue] {
bandApplied ? LoopAlgorithm.volatilityBanded(glucose, band: band, volatility: volatility) : glucose
}
}
17 changes: 12 additions & 5 deletions Sources/LoopAlgorithm/LoopAlgorithm.swift
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,7 @@ public struct LoopAlgorithm {

// Now validate/recommend
let result: Result<LoopAlgorithmDoseRecommendation,Error>
var guardAndRunway: GuardAndRunwayOutput?

do {
guard let latestGlucose = input.glucoseHistory.last else {
Expand Down Expand Up @@ -839,10 +840,15 @@ public struct LoopAlgorithm {
suspendThreshold: suspendThreshold.doubleValue(for: unit),
insulinModel: input.recommendationInsulinModel)
}
if let band = experimentParameters.volatilityBand, let volatility,
!(band.carbGate && (prediction.activeCarbs ?? 0) > 0)
{
prediction.glucose = volatilityBanded(prediction.glucose, band: band, volatility: volatility)
if let band = experimentParameters.volatilityBand, let volatility {
let bandApplied = !(band.carbGate && (prediction.activeCarbs ?? 0) > 0)
if bandApplied {
prediction.glucose = volatilityBanded(prediction.glucose, band: band, volatility: volatility)
}
guardAndRunway = GuardAndRunwayOutput(
volatility: volatility, bandApplied: bandApplied, runwayShift: runwayShift,
postLowRiseCutActive: experimentParameters.postLowRiseCut?.isActive(glucoseHistory: input.glucoseHistory) ?? false,
band: band)
}

let sensitivityForDosing: [AbsoluteScheduleValue<LoopQuantity>]
Expand Down Expand Up @@ -912,7 +918,8 @@ public struct LoopAlgorithm {
effects: prediction.effects,
dosesRelativeToBasal: prediction.dosesRelativeToBasal,
activeInsulin: prediction.activeInsulin,
activeCarbs: prediction.activeCarbs
activeCarbs: prediction.activeCarbs,
guardAndRunway: guardAndRunway
)
}
}
19 changes: 18 additions & 1 deletion Tests/LoopAlgorithmTests/AlgorithmExperimentsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,15 @@ final class AlgorithmExperimentsTests: XCTestCase {
let standard = LoopAlgorithm.run(input: input)
input.experiments = [.guardAndRunway]
input.volatilityBaseline = 5
assertSameOutput(standard, LoopAlgorithm.run(input: input))
let gated = LoopAlgorithm.run(input: input)
assertSameOutput(standard, gated)
XCTAssertNil(standard.guardAndRunway)
let output = gated.guardAndRunway!
XCTAssertFalse(output.bandApplied)
XCTAssertEqual(output.bandDepth, 0)
XCTAssertEqual(output.runwayShift, 0)
XCTAssertFalse(output.postLowRiseCutActive)
XCTAssertEqual(output.guarded(standard.predictedGlucose), standard.predictedGlucose)
}

func testWithoutABaselineTheBandIsFullDepth() {
Expand Down Expand Up @@ -134,6 +142,7 @@ final class AlgorithmExperimentsTests: XCTestCase {
let cut = LoopAlgorithm.run(input: input)
XCTAssertLessThan(cut.predictedGlucose.last!.quantity.doubleValue(for: unit),
standard.predictedGlucose.last!.quantity.doubleValue(for: unit))
XCTAssertTrue(cut.guardAndRunway!.postLowRiseCutActive)
}

// MARK: - Volatility
Expand Down Expand Up @@ -233,6 +242,14 @@ final class AlgorithmExperimentsTests: XCTestCase {
XCTAssertEqual(banded.count, plain.count)
XCTAssertEqual(banded.last, plain.last, "eventual glucose is unchanged")
XCTAssertTrue(zip(banded, plain).allSatisfy { $0 <= $1 }, "the band only lowers the forecast")
let output = runway.guardAndRunway!
XCTAssertTrue(output.bandApplied)
XCTAssertGreaterThan(output.runwayShift, 0)
XCTAssertFalse(output.postLowRiseCutActive)
XCTAssertEqual(output.guarded(standard.predictedGlucose), runway.predictedGlucose)
let deepest = zip(plain, banded).map { $0 - $1 }.max()!
XCTAssertLessThanOrEqual(deepest, output.bandDepth + 1e-9)
XCTAssertGreaterThan(deepest, 0.9 * output.bandDepth, "forecast points fall near the 60 min horizon")
if type == .tempBasal {
XCTAssertGreaterThan(runway.recommendation!.automatic!.basalAdjustment.unitsPerHour,
standard.recommendation!.automatic!.basalAdjustment.unitsPerHour)
Expand Down