From 3826f0e47241e1e61f49b794d300691b08391d2f Mon Sep 17 00:00:00 2001 From: Pete Schwamb Date: Sun, 25 Oct 2026 09:33:55 -0500 Subject: [PATCH] Report what Guard + Runway did in AlgorithmOutput MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AlgorithmOutput.guardAndRunway carries the run's volatility (σ5), whether the volatility band was applied or kept off by active carbs, its depth at the 60 min horizon, the runway's aim shift and whether the post-low rise cut is active. guarded(_:) applies the same band to another forecast, so a display can compare forecasts with and without it. --- Sources/LoopAlgorithm/AlgorithmOutput.swift | 6 +++- .../Experiments/GuardAndRunwayOutput.swift | 35 +++++++++++++++++++ Sources/LoopAlgorithm/LoopAlgorithm.swift | 17 ++++++--- .../AlgorithmExperimentsTests.swift | 19 +++++++++- 4 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 Sources/LoopAlgorithm/Experiments/GuardAndRunwayOutput.swift 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)