diff --git a/Sources/LoopAlgorithm/AlgorithmOutput.swift b/Sources/LoopAlgorithm/AlgorithmOutput.swift index b980d7b..a820988 100644 --- a/Sources/LoopAlgorithm/AlgorithmOutput.swift +++ b/Sources/LoopAlgorithm/AlgorithmOutput.swift @@ -14,6 +14,8 @@ public struct AlgorithmOutput { 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, @@ -21,7 +23,8 @@ public struct AlgorithmOutput { effects: LoopAlgorithmEffects, dosesRelativeToBasal: [BasalRelativeDose], activeInsulin: Double? = nil, - activeCarbs: Double? = nil + activeCarbs: Double? = nil, + guardAndRunway: GuardAndRunwayOutput? = nil ) { self.recommendationResult = recommendationResult self.predictedGlucose = predictedGlucose @@ -29,6 +32,7 @@ public struct AlgorithmOutput { self.dosesRelativeToBasal = dosesRelativeToBasal self.activeInsulin = activeInsulin self.activeCarbs = activeCarbs + self.guardAndRunway = guardAndRunway } public var recommendation: LoopAlgorithmDoseRecommendation? { diff --git a/Sources/LoopAlgorithm/Experiments/GuardAndRunwayOutput.swift b/Sources/LoopAlgorithm/Experiments/GuardAndRunwayOutput.swift new file mode 100644 index 0000000..5afe682 --- /dev/null +++ b/Sources/LoopAlgorithm/Experiments/GuardAndRunwayOutput.swift @@ -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 + } +} diff --git a/Sources/LoopAlgorithm/LoopAlgorithm.swift b/Sources/LoopAlgorithm/LoopAlgorithm.swift index d905bd7..e5a3fa1 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithm.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithm.swift @@ -752,6 +752,7 @@ public struct LoopAlgorithm { // Now validate/recommend let result: Result + var guardAndRunway: GuardAndRunwayOutput? do { guard let latestGlucose = input.glucoseHistory.last else { @@ -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] @@ -912,7 +918,8 @@ public struct LoopAlgorithm { effects: prediction.effects, dosesRelativeToBasal: prediction.dosesRelativeToBasal, activeInsulin: prediction.activeInsulin, - activeCarbs: prediction.activeCarbs + activeCarbs: prediction.activeCarbs, + guardAndRunway: guardAndRunway ) } } diff --git a/Tests/LoopAlgorithmTests/AlgorithmExperimentsTests.swift b/Tests/LoopAlgorithmTests/AlgorithmExperimentsTests.swift index 6a6bcf5..5ea3fa9 100644 --- a/Tests/LoopAlgorithmTests/AlgorithmExperimentsTests.swift +++ b/Tests/LoopAlgorithmTests/AlgorithmExperimentsTests.swift @@ -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() { @@ -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 @@ -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)