diff --git a/Sources/LoopAlgorithm/AlgorithmInput.swift b/Sources/LoopAlgorithm/AlgorithmInput.swift index 9c2ef60..1cc1e28 100644 --- a/Sources/LoopAlgorithm/AlgorithmInput.swift +++ b/Sources/LoopAlgorithm/AlgorithmInput.swift @@ -35,8 +35,18 @@ public protocol AlgorithmInput { var automaticBolusApplicationFactor: Double? { get } // Defaults to 0.4 var gradualTransitionsThreshold: Double? { get } var emulation: AlgorithmEmulationOptions? { get } // Defaults to nil (no emulation) + /// Optional algorithm experiments; the empty set is standard LoopAlgorithm. Conforming types that do + /// not provide it get the empty set. + var experiments: Set { get } + /// The person's volatility baseline for the `guardAndRunway` experiment's volatility band: 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 band runs at full depth. + var volatilityBaseline: Double? { get } } public extension AlgorithmInput { var emulation: AlgorithmEmulationOptions? { nil } + var experiments: Set { [] } + var volatilityBaseline: Double? { nil } } diff --git a/Sources/LoopAlgorithm/AlgorithmInputFixture.swift b/Sources/LoopAlgorithm/AlgorithmInputFixture.swift index de97e58..9493f06 100644 --- a/Sources/LoopAlgorithm/AlgorithmInputFixture.swift +++ b/Sources/LoopAlgorithm/AlgorithmInputFixture.swift @@ -36,6 +36,9 @@ public struct AlgorithmInputFixture: AlgorithmInput { public var automaticBolusApplicationFactor: Double? public var gradualTransitionsThreshold: Double? public var emulation: AlgorithmEmulationOptions? + /// Optional experiments; absent in existing fixtures, which therefore run standard LoopAlgorithm. + public var experiments: Set = [] + public var volatilityBaseline: Double? public var recommendationInsulinModel: InsulinModel { recommendationInsulinType.insulinModel @@ -152,6 +155,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.emulation = try container.decodeIfPresent(AlgorithmEmulationOptions.self, forKey: .emulation) + self.experiments = Set(try container.decodeIfPresent([AlgorithmExperiment].self, forKey: .experiments) ?? []) + self.volatilityBaseline = try container.decodeIfPresent(Double.self, forKey: .volatilityBaseline) } public func encode(to encoder: Encoder) throws { @@ -190,6 +195,10 @@ extension AlgorithmInputFixture: Codable { if let emulation { try container.encode(emulation, forKey: .emulation) } + if !experiments.isEmpty { + try container.encode(experiments.map(\.rawValue).sorted(), forKey: .experiments) + } + try container.encodeIfPresent(volatilityBaseline, forKey: .volatilityBaseline) } private enum CodingKeys: String, CodingKey { @@ -213,6 +222,8 @@ extension AlgorithmInputFixture: Codable { case automaticBolusApplicationFactor case gradualTransitionsThreshold case emulation + case experiments + case volatilityBaseline } } diff --git a/Sources/LoopAlgorithm/Experiments/AlgorithmExperiments.swift b/Sources/LoopAlgorithm/Experiments/AlgorithmExperiments.swift new file mode 100644 index 0000000..3367092 --- /dev/null +++ b/Sources/LoopAlgorithm/Experiments/AlgorithmExperiments.swift @@ -0,0 +1,116 @@ +// +// AlgorithmExperiments.swift +// LoopAlgorithm +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation + +/// Optional algorithm experiments. The empty set (the default everywhere) is standard LoopAlgorithm. +/// +/// Each experiment is an on/off switch with no tunable parameters, so that people who choose the same +/// switches pool into one group whose results can be compared. Experiments combine: each acts on its own +/// part of the algorithm, and a combination is its own group. +public enum AlgorithmExperiment: String, Codable, CaseIterable, Sendable { + + /// LoopEval guard + runway (C20 + C29 + C40). A lows guard, plus a little more correction when there + /// is room for it: + /// - **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 + /// 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 + /// 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 + + /// Integral retrospective correction: retrospective correction integrates the recent unexplained + /// glucose change instead of projecting only the last 30 minutes. The same as + /// `AlgorithmInput.useIntegralRetrospectiveCorrection`; either one enables it. + case integralRetrospectiveCorrection + + /// Glucose-based application factor, for automatic-bolus dosing: the share of the correction given + /// now rises with current glucose, from 0.2 at the lower correction target + 10 mg/dL to 0.8 at + /// 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 +} + +/// Loop's glucose-based application factor (`glucoseBasedApplicationFactor`). +enum GlucoseBasedApplicationFactor { + static let minFactor = 0.2 + static let maxFactor = 0.8 + static let lowerTargetDelta = 10.0 // mg/dL above the lower correction target where the ramp starts + static let rampTop = 200.0 // mg/dL where the factor reaches maxFactor + + /// `glucose` and `lowerTarget` in mg/dL. + static func factor(glucose: Double, lowerTarget: Double) -> Double { + let rampStart = lowerTarget + lowerTargetDelta + let slope = (maxFactor - minFactor) / (rampTop - rampStart) + return min(minFactor + max(glucose - rampStart, 0) * slope, maxFactor) + } +} + +/// The internal configuration the experiment expands to. Not public: the experiment is fixed. +struct ExperimentParameters: Equatable { + var postLowRiseCut: PostLowRiseCut? = nil + var volatilityBand: VolatilityBand? = nil + var runwayReserve: RunwayReserve? = nil + + static let none = ExperimentParameters() + + var needsVolatility: Bool { volatilityBand != nil || runwayReserve != nil } + + /// `volatilityBaseline` is the person's median σ5 (mg/dL per 5 min); only the band uses it. + /// Integral RC and the glucose-based application factor are switched where they act, not here. + init(experiments: Set, volatilityBaseline: Double?) { + if experiments.contains(.guardAndRunway) { + postLowRiseCut = PostLowRiseCut() + runwayReserve = RunwayReserve() + // Without a baseline (a new user, under a day of glucose) the band runs at full depth: the + // baseline only ever lowers it, for people whose glucose is volatile. Full depth is the safer + // side, fewer lows for a little time in range. + if let baseline = volatilityBaseline, baseline.isFinite, baseline > 0 { + volatilityBand = VolatilityBand(gain: Swift.min(1.0, 6.7 / baseline)) + } else { + volatilityBand = VolatilityBand(gain: 1.0) + } + } + } + + 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 RunwayReserve: Equatable { + var fraction = 0.7 // share of the reserve that is spent + var bandWidth = 1.0 // σ5 multiples below the forecast for the plausible fall + var exponent = 0.71 // band growth with horizon, as the volatility band + var horizon: TimeInterval = .minutes(180) + 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 + } +} diff --git a/Sources/LoopAlgorithm/Experiments/ExperimentalForecastAdjustments.swift b/Sources/LoopAlgorithm/Experiments/ExperimentalForecastAdjustments.swift new file mode 100644 index 0000000..8af1219 --- /dev/null +++ b/Sources/LoopAlgorithm/Experiments/ExperimentalForecastAdjustments.swift @@ -0,0 +1,116 @@ +// +// 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(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 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)) + } + } + /// The runway reserve's aim shift, mg/dL (0 when the gate is closed or there is no room). + /// + /// Suppose Loop makes the correction it already plans (the excess of eventual glucose over the target + /// midpoint) plus an extra E units. The plausible path τ minutes ahead is the forecast, less that + /// planned correction's effect so far, less `bandWidth × σ5 × (τ/5)^exponent`. If glucose then followed + /// it, suspending scheduled basal from now would take back `basal × ∫ acted` of insulin. E_max is the + /// largest E for which that path, with the basal taken back, stays above the suspend threshold at every + /// horizon out to `horizon` (a forecast shorter than `horizon` gives no runway). The shift spends `fraction` of it, expressed in mg/dL through the ISF and + /// capped at `maxAimShift`. + static func runwayAimShift(prediction: [PredictedGlucoseValue], reserve: ExperimentParameters.RunwayReserve, + currentGlucose: Double, activeCarbs: Double, volatility: Double, + sensitivity: Double, basalRate: Double, targetMidpoint: Double, + suspendThreshold: Double, insulinModel: InsulinModel) -> Double { + let unit = LoopUnit.milligramsPerDeciliter + guard volatility.isFinite, sensitivity > 0, currentGlucose >= reserve.minGlucose, + !(reserve.carbGate && 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 } + let eventual = last.quantity.doubleValue(for: unit) + // (τ, glucose) on the forecast's own points, with any gap longer than 5 min (Loop drops points + // once nothing is changing) filled every 5 min by linear interpolation. + var samples: [(tau: Double, value: Double)] = [] + for point in prediction { + let tau = point.startDate.timeIntervalSince(t0).minutes + let value = point.quantity.doubleValue(for: unit) + if let previous = samples.last, tau - previous.tau > 5.0 + 1e-9 { + var fill = previous.tau + 5.0 + while fill < tau - 1e-9 { + samples.append((fill, previous.value + (value - previous.value) * (fill - previous.tau) / (tau - previous.tau))) + fill += 5.0 + } + } + samples.append((tau, value)) + } + let excess = Swift.max(0, eventual - targetMidpoint) + let horizon = reserve.horizon.minutes + func acted(_ minutes: Double) -> Double { + minutes <= 0 ? 0 : Swift.max(0, Swift.min(1, 1 - insulinModel.percentEffectRemaining(at: .minutes(minutes)))) + } + var eMax = Double.infinity // units of extra insulin + var withheld = 0.0 // units of withheld basal that have acted by τ + var lastTau = 0.0 + for (tau, value) in samples { + if tau <= 0 { continue } + if tau > horizon { break } + withheld += basalRate / 60.0 * acted(0.5 * (tau + lastTau)) * (tau - lastTau) // midpoint rule + lastTau = tau + let d = acted(tau) + guard d > reserve.minEffectFraction else { continue } + let path = value - excess * d + - reserve.bandWidth * volatility * pow(Swift.max(tau, 5) / 5, reserve.exponent) + 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)) + } + + /// The target range lowered by `shift` mg/dL, floored at 70–80. + static func targetLowered(_ target: GlucoseRangeTimeline, by shift: Double) -> GlucoseRangeTimeline { + let unit = LoopUnit.milligramsPerDeciliter + return target.map { + AbsoluteScheduleValue( + startDate: $0.startDate, endDate: $0.endDate, + value: ClosedRange(uncheckedBounds: ( + lower: LoopQuantity(unit: unit, doubleValue: Swift.max(70.0, $0.value.lowerBound.doubleValue(for: unit) - shift)), + upper: LoopQuantity(unit: unit, doubleValue: Swift.max(80.0, $0.value.upperBound.doubleValue(for: unit) - shift))))) + } + } +} diff --git a/Sources/LoopAlgorithm/Experiments/GlucoseVolatility.swift b/Sources/LoopAlgorithm/Experiments/GlucoseVolatility.swift new file mode 100644 index 0000000..34ab2c4 --- /dev/null +++ b/Sources/LoopAlgorithm/Experiments/GlucoseVolatility.swift @@ -0,0 +1,73 @@ +// +// GlucoseVolatility.swift +// LoopAlgorithm +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import Foundation + +/// Causal short-term glucose volatility, used by the `guardAndRunway` 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(_ 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 `guardAndRunway` experiment's band: 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(_ 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(_ 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(_ 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 + } +} diff --git a/Sources/LoopAlgorithm/LoopAlgorithm.swift b/Sources/LoopAlgorithm/LoopAlgorithm.swift index 50974c8..cbf3b07 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithm.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithm.swift @@ -184,7 +184,8 @@ public struct LoopAlgorithm { carbAbsorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption(), gradualTransitionsThreshold: Double? = 40.0, momentumVelocityMaximum: LoopQuantity? = nil, - emulation: AlgorithmEmulationOptions = AlgorithmEmulationOptions() + emulation: AlgorithmEmulationOptions = AlgorithmEmulationOptions(), + experiments: Set = [] ) -> LoopPrediction where CarbType: CarbEntry, GlucoseType: GlucoseSampleValue, InsulinDoseType: InsulinDose { var prediction: [PredictedGlucoseValue] = [] @@ -259,14 +260,26 @@ public struct LoopAlgorithm { let rc: RetrospectiveCorrection - if useIntegralRetrospectiveCorrection { + // Experiments (no-ops when `experiments` is empty): the post-low rise-cut weights the rising side of + // retrospective correction while its gate is open. + var rcRiseScale = 1.0 + if let cut = ExperimentParameters(experiments: experiments, volatilityBaseline: nil).postLowRiseCut, + cut.isActive(glucoseHistory: glucoseHistory) { + rcRiseScale = cut.riseScale + } + + if useIntegralRetrospectiveCorrection || experiments.contains(.integralRetrospectiveCorrection) { rc = IntegralRetrospectiveCorrection( effectDuration: LoopMath.retrospectiveCorrectionEffectDuration, maxCorrectionVelocity: emulation.disableIRCVelocityCeiling ? nil : IntegralRetrospectiveCorrection.defaultMaxCorrectionVelocity, - useLegacyDecay: emulation.legacyRCDecay + useLegacyDecay: emulation.legacyRCDecay, + riseScale: rcRiseScale ) } else { - rc = StandardRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration, useLegacyDecay: emulation.legacyRCDecay) + rc = StandardRetrospectiveCorrection( + effectDuration: LoopMath.retrospectiveCorrectionEffectDuration, + useLegacyDecay: emulation.legacyRCDecay, + riseScale: rcRiseScale) } if let latestGlucose = glucoseHistory.last { @@ -605,6 +618,19 @@ public struct LoopAlgorithm { ) } + /// The automatic-bolus application factor: the glucose-based factor when that experiment is on (keyed on + /// current glucose and the lower correction target in effect now), else the input's constant. + static func automaticBolusApplicationFactor(input: any AlgorithmInput, currentGlucose: some GlucoseSampleValue) -> Double { + if input.experiments.contains(.glucoseBasedApplicationFactor), + let correctionRange = input.target.closestPrior(to: input.predictionStart)?.value + { + return GlucoseBasedApplicationFactor.factor( + glucose: currentGlucose.quantity.doubleValue(for: .milligramsPerDeciliter), + lowerTarget: correctionRange.lowerBound.doubleValue(for: .milligramsPerDeciliter)) + } + return input.automaticBolusApplicationFactor ?? defaultBolusPartialApplicationFactor + } + // Computes an amount of insulin to correct the given prediction public static func insulinCorrection( prediction: [PredictedGlucoseValue], @@ -787,9 +813,38 @@ public struct LoopAlgorithm { useMidAbsorptionISF: input.useMidAbsorptionISF, carbAbsorptionModel: input.carbAbsorptionModel.model, gradualTransitionsThreshold: input.gradualTransitionsThreshold, - emulation: input.emulation ?? AlgorithmEmulationOptions() + emulation: input.emulation ?? AlgorithmEmulationOptions(), + experiments: input.experiments ) + // Experiment adjustments before dosing (no-ops when `experiments` is empty). The runway reserve + // reads the forecast before the volatility band lowers it; its band term is its own. + let experimentParameters = ExperimentParameters( + experiments: input.experiments, + volatilityBaseline: input.experiments.contains(.guardAndRunway) + ? (input.volatilityBaseline ?? GlucoseVolatility.baseline(input.glucoseHistory)) : nil) + let volatility: Double? = experimentParameters.needsVolatility ? GlucoseVolatility.sigma5(input.glucoseHistory) : nil + var runwayShift = 0.0 + if let reserve = experimentParameters.runwayReserve, let volatility, + let sensitivity = input.sensitivity.closestPrior(to: input.predictionStart)?.value, + let target = input.target.closestPrior(to: input.predictionStart)?.value + { + let unit = LoopUnit.milligramsPerDeciliter + runwayShift = runwayAimShift( + prediction: prediction.glucose, reserve: reserve, + currentGlucose: latestGlucose.quantity.doubleValue(for: unit), + activeCarbs: prediction.activeCarbs ?? 0, volatility: volatility, + sensitivity: sensitivity.doubleValue(for: unit), basalRate: scheduledBasalRate, + targetMidpoint: 0.5 * (target.lowerBound.doubleValue(for: unit) + target.upperBound.doubleValue(for: unit)), + 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) + } + let sensitivityForDosing: [AbsoluteScheduleValue] if input.useMidAbsorptionISF { sensitivityForDosing = input.sensitivity @@ -805,10 +860,14 @@ public struct LoopAlgorithm { sensitivityForDosing = [sensitivityOverPrediction] } + // The runway shift lowers the aim of automatic dosing only; the manual bolus recommendation + // keeps the user's target. + let correctionTarget = runwayShift > 0 && input.recommendationType != .manualBolus + ? targetLowered(input.target, by: runwayShift) : input.target let correction = insulinCorrection( prediction: prediction.glucose, at: input.predictionStart, - target: input.target, + target: correctionTarget, suspendThreshold: suspendThreshold, sensitivity: sensitivityForDosing, insulinModel: input.recommendationInsulinModel) @@ -826,7 +885,7 @@ public struct LoopAlgorithm { case .automaticBolus: let recommendation = recommendAutomaticDose( for: correction, - applicationFactor: input.automaticBolusApplicationFactor ?? defaultBolusPartialApplicationFactor, + applicationFactor: automaticBolusApplicationFactor(input: input, currentGlucose: latestGlucose), neutralBasalRate: scheduledBasalRate, activeInsulin: prediction.activeInsulin!, maxBolus: input.maxBolus, diff --git a/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift b/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift index aa5088c..a132bb2 100644 --- a/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift +++ b/Sources/LoopAlgorithm/RetrospectiveCorrection/IntegralRetrospectiveCorrection.swift @@ -74,12 +74,18 @@ public class IntegralRetrospectiveCorrection: RetrospectiveCorrection { var correctionVelocity: LoopQuantity? var currentDate: Date = Date() + /// Experimental weight on the correction while the current discrepancy is positive (rising), set by + /// the post-low rise-cut (see `AlgorithmExperiment`). 1.0 is standard IRC. + let riseScale: Double + public init(effectDuration: TimeInterval, maxCorrectionVelocity: LoopQuantity? = IntegralRetrospectiveCorrection.defaultMaxCorrectionVelocity, - useLegacyDecay: Bool = false) { + useLegacyDecay: Bool = false, + riseScale: Double = 1.0) { self.effectDuration = effectDuration self.maxCorrectionVelocity = maxCorrectionVelocity self.useLegacyDecay = useLegacyDecay + self.riseScale = riseScale } /** @@ -199,6 +205,9 @@ public class IntegralRetrospectiveCorrection: RetrospectiveCorrection { let retrospectionTimeInterval = currentDiscrepancy.endDate.timeIntervalSince(currentDiscrepancy.startDate) let discrepancyTime = max(retrospectionTimeInterval, retrospectiveCorrectionGroupingInterval) + if riseScale != 1.0 && currentDiscrepancyValue >= 0 { + scaledCorrection *= riseScale + } var velocityValue = scaledCorrection / discrepancyTime // Bound the correction rate to a settings-free physiological ceiling (see diff --git a/Sources/LoopAlgorithm/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift b/Sources/LoopAlgorithm/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift index 135abbf..2060c82 100644 --- a/Sources/LoopAlgorithm/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift +++ b/Sources/LoopAlgorithm/RetrospectiveCorrection/StandardRetrospectiveCorrection.swift @@ -25,9 +25,14 @@ public class StandardRetrospectiveCorrection: RetrospectiveCorrection { /// All math is performed with glucose expressed in mg/dL private let unit = LoopUnit.milligramsPerDeciliter - public init(effectDuration: TimeInterval, useLegacyDecay: Bool = false) { + /// Experimental weight on a positive (rising) discrepancy, set by the post-low rise-cut (see + /// `AlgorithmExperiment`). 1.0 is standard RC. + let riseScale: Double + + public init(effectDuration: TimeInterval, useLegacyDecay: Bool = false, riseScale: Double = 1.0) { self.effectDuration = effectDuration self.useLegacyDecay = useLegacyDecay + self.riseScale = riseScale } public func computeEffect( @@ -47,7 +52,10 @@ public class StandardRetrospectiveCorrection: RetrospectiveCorrection { } // Standard retrospective correction math - let currentDiscrepancyValue = currentDiscrepancy.quantity.doubleValue(for: unit) + var currentDiscrepancyValue = currentDiscrepancy.quantity.doubleValue(for: unit) + if riseScale != 1.0 && currentDiscrepancyValue >= 0 { + currentDiscrepancyValue *= riseScale + } totalGlucoseCorrectionEffect = LoopQuantity(unit: unit, doubleValue: currentDiscrepancyValue) let retrospectionTimeInterval = currentDiscrepancy.endDate.timeIntervalSince(currentDiscrepancy.startDate) diff --git a/Tests/LoopAlgorithmTests/AlgorithmExperimentsTests.swift b/Tests/LoopAlgorithmTests/AlgorithmExperimentsTests.swift new file mode 100644 index 0000000..861954f --- /dev/null +++ b/Tests/LoopAlgorithmTests/AlgorithmExperimentsTests.swift @@ -0,0 +1,338 @@ +// +// AlgorithmExperimentsTests.swift +// LoopAlgorithmTests +// +// Copyright © 2026 LoopKit Authors. All rights reserved. +// + +import XCTest +@testable import LoopAlgorithm + +final class AlgorithmExperimentsTests: XCTestCase { + + private let unit = LoopUnit.milligramsPerDeciliter + + private func loadScenario(_ name: String) -> AlgorithmInputFixture { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let url = Bundle.module.url(forResource: name + "_scenario", withExtension: "json", subdirectory: "Fixtures")! + return try! decoder.decode(AlgorithmInputFixture.self, from: try! Data(contentsOf: url)) + } + + private func glucoseSeries(_ values: [Double], endingAt end: Date, spacing: TimeInterval = .minutes(5)) -> [FixtureGlucoseSample] { + values.enumerated().map { i, v in + FixtureGlucoseSample(startDate: end.addingTimeInterval(-Double(values.count - 1 - i) * spacing), quantity: .glucose(v)) + } + } + + private func assertSameOutput(_ a: AlgorithmOutput, _ b: AlgorithmOutput, + file: StaticString = #filePath, line: UInt = #line) { + XCTAssertEqual(a.recommendation, b.recommendation, file: file, line: line) + XCTAssertEqual(a.predictedGlucose.map { $0.quantity.doubleValue(for: unit) }, + b.predictedGlucose.map { $0.quantity.doubleValue(for: unit) }, file: file, line: line) + } + + // MARK: - Default is standard LoopAlgorithm + + func testExistingFixturesDecodeWithoutExperiments() { + XCTAssertTrue(loadScenario("suspend").experiments.isEmpty) + XCTAssertTrue(loadScenario("carbs_with_isf_change").experiments.isEmpty) + } + + func testGatedExperimentsAreInertWhenTheirGateIsClosed() { + // Mock: glucose rising 100 -> 160, no low. With carbs on board the band and the runway reserve are + // gated off, and there is no low for the post-low cut: the experiment must change nothing. + let now = ISO8601DateFormatter().date(from: "2024-01-03T12:00:00+0000")! + var input = AlgorithmInputFixture.mock(for: now) + input.recommendationType = .automaticBolus + input.carbEntries = [FixtureCarbEntry(absorptionTime: .hours(3), startDate: now.addingTimeInterval(.minutes(-30)), quantity: .carbs(30))] + let standard = LoopAlgorithm.run(input: input) + input.experiments = [.guardAndRunway] + input.volatilityBaseline = 5 + assertSameOutput(standard, LoopAlgorithm.run(input: input)) + } + + func testWithoutABaselineTheBandIsFullDepth() { + // No baseline and under a day of glucose (a new user): the band runs at full depth, and the + // baseline only lowers it. + let p = ExperimentParameters(experiments: [.guardAndRunway], volatilityBaseline: nil) + XCTAssertEqual(p.volatilityBand?.gain, 1.0) + XCTAssertEqual(p.volatilityBand, ExperimentParameters(experiments: [.guardAndRunway], volatilityBaseline: 5).volatilityBand) + XCTAssertNotNil(p.postLowRiseCut) + XCTAssertNotNil(p.runwayReserve) + XCTAssertEqual(ExperimentParameters(experiments: [.guardAndRunway], volatilityBaseline: .nan).volatilityBand?.gain, 1.0) + XCTAssertNil(ExperimentParameters(experiments: [], volatilityBaseline: nil).volatilityBand) + } + + func testRunwayFillsTheGapsInASparseForecast() { + // Loop drops forecast points once nothing changes: 0–55 min then a final point at 370. Filling the + // gap must give the same reserve as the dense flat forecast. + let now = Date() + let dense = flatForecast(200, from: now) + let sparse = Array(dense.prefix(12)) + [dense.last!] + func reserve(_ p: [PredictedGlucoseValue], _ volatility: Double) -> Double { + LoopAlgorithm.runwayAimShift(prediction: p, reserve: ExperimentParameters.RunwayReserve(), currentGlucose: 200, + activeCarbs: 0, volatility: volatility, sensitivity: 50, basalRate: 1.0, + targetMidpoint: 105, suspendThreshold: 70, + insulinModel: ExponentialInsulinModelPreset.rapidActingAdult) + } + XCTAssertEqual(reserve(sparse, 6), reserve(dense, 6), accuracy: 1e-9) + XCTAssertEqual(reserve(sparse, 20), 0) + } + + func testParameters() { + let p = ExperimentParameters(experiments: [.guardAndRunway], volatilityBaseline: 5) + XCTAssertEqual(p.volatilityBand?.gain, 1.0) // min(1, 6.7 / 5) + XCTAssertEqual(ExperimentParameters(experiments: [.guardAndRunway], volatilityBaseline: 10).volatilityBand!.gain, 0.67, accuracy: 1e-12) + XCTAssertEqual(p.postLowRiseCut, ExperimentParameters.PostLowRiseCut()) + XCTAssertEqual(p.runwayReserve, ExperimentParameters.RunwayReserve()) + XCTAssertEqual(p.runwayReserve?.fraction, 0.7) + XCTAssertEqual(p.runwayReserve?.minGlucose, 150) + XCTAssertEqual(ExperimentParameters(experiments: [], volatilityBaseline: 5), ExperimentParameters.none) + // Integral RC and the glucose-based factor are switched where they act; they add no parameters here. + XCTAssertEqual(ExperimentParameters(experiments: [.integralRetrospectiveCorrection, .glucoseBasedApplicationFactor], + volatilityBaseline: 5), ExperimentParameters.none) + } + + // MARK: - Retrospective correction gains + + func testStandardRCRiseScale() { + let now = Date() + let latest = SimpleGlucoseValue(startDate: now, quantity: .glucose(150)) + func total(_ discrepancy: Double, rise: Double) -> Double { + let rc = StandardRetrospectiveCorrection(effectDuration: .minutes(60), riseScale: rise) + let change = GlucoseChange(startDate: now.addingTimeInterval(.minutes(-30)), endDate: now, quantity: .glucose(discrepancy)) + _ = rc.computeEffect(startingAt: latest, retrospectiveGlucoseDiscrepanciesSummed: [change], + recencyInterval: .minutes(15), retrospectiveCorrectionGroupingInterval: .minutes(30)) + return rc.totalGlucoseCorrectionEffect!.doubleValue(for: unit) + } + XCTAssertEqual(total(10, rise: 0.3), 3, accuracy: 1e-9) + XCTAssertEqual(total(-10, rise: 0.3), -10, accuracy: 1e-9) // the falling side is untouched + XCTAssertEqual(total(10, rise: 1), 10, accuracy: 1e-9) + } + + // MARK: - Post-low rise-cut gate + + func testPostLowRiseCutGate() { + let now = Date() + let cut = ExperimentParameters.PostLowRiseCut() + XCTAssertTrue(cut.isActive(glucoseHistory: glucoseSeries([90, 65, 80, 120], endingAt: now))) + XCTAssertFalse(cut.isActive(glucoseHistory: glucoseSeries([90, 65, 120, 190], endingAt: now)), "released above 180") + XCTAssertFalse(cut.isActive(glucoseHistory: glucoseSeries([90, 100, 110, 120], endingAt: now)), "no low") + let old = [FixtureGlucoseSample(startDate: now.addingTimeInterval(.hours(-13)), quantity: .glucose(60))] + + glucoseSeries([100, 110, 120], endingAt: now) + XCTAssertFalse(cut.isActive(glucoseHistory: old), "low outside the 12 h window") + } + + func testPostLowRiseCutLowersTheForecastAfterALow() { + // Rebound after a low, with insulin on board: the unexplained rise is cut, so the forecast is lower. + let now = ISO8601DateFormatter().date(from: "2024-01-03T12:00:00+0000")! + var input = AlgorithmInputFixture.mock(for: now) + input.glucoseHistory = glucoseSeries([62, 66, 75, 90, 110, 130], endingAt: now.addingTimeInterval(.minutes(-1))) + let standard = LoopAlgorithm.run(input: input) + input.experiments = [.guardAndRunway] + let cut = LoopAlgorithm.run(input: input) + XCTAssertLessThan(cut.predictedGlucose.last!.quantity.doubleValue(for: unit), + standard.predictedGlucose.last!.quantity.doubleValue(for: unit)) + } + + // MARK: - Volatility + + func testSigma5() { + let now = Date() + XCTAssertEqual(GlucoseVolatility.sigma5(glucoseSeries([120, 120, 120, 120], endingAt: now)), (2 * 1.4 * 1.4).squareRoot(), accuracy: 1e-9) + // Constant 10 mg/dL steps: the EWMA of 100 is 100. + XCTAssertEqual(GlucoseVolatility.sigma5(glucoseSeries([100, 110, 120, 130, 140], endingAt: now)), 10, accuracy: 1e-9) + // A gap > 7 min restarts the estimate: only the steps after the gap count. + let gapped = glucoseSeries([100, 140, 180], endingAt: now.addingTimeInterval(.minutes(-30))) + + glucoseSeries([100, 102], endingAt: now) + XCTAssertEqual(GlucoseVolatility.sigma5(gapped), 2.0, accuracy: 1e-9) // one 2 mg/dL step; 4 > floor 3.92 + } + + func testSigma5OnOneMinuteCGM() { + // The same 10 mg/dL per 5 min trend sampled every minute must read like 5-minute data, not the floor. + let now = Date() + let oneMinute = (0...40).map { FixtureGlucoseSample(startDate: now.addingTimeInterval(.minutes(Double($0 - 40))), quantity: .glucose(100 + 2 * Double($0))) } + XCTAssertEqual(GlucoseVolatility.sigma5(oneMinute), 10, accuracy: 1e-9) + } + + func testBaselineIsTheMedianOfSigma5() { + let now = Date() + XCTAssertNil(GlucoseVolatility.baseline(glucoseSeries(Array(repeating: 120, count: 100), endingAt: now)), "under a day") + // A calm stretch then a longer +/-10 stretch: the median is volatile, above the floor and at most 10. + let flat = Array(repeating: 120.0, count: 100) + let zig = (0..<200).map { $0 % 2 == 0 ? 120.0 : 130.0 } + let b = GlucoseVolatility.baseline(glucoseSeries(flat + zig, endingAt: now))! + XCTAssertGreaterThan(b, (2 * 1.4 * 1.4).squareRoot()) + XCTAssertLessThanOrEqual(b, 10) + } + + func testVolatilityBandShape() { + let now = Date() + let glucose = (0...48).map { PredictedGlucoseValue(startDate: now.addingTimeInterval(.minutes(5 * Double($0))), quantity: .glucose(120)) } + let band = ExperimentParameters.VolatilityBand(gain: 1.0) + let banded = LoopAlgorithm.volatilityBanded(glucose, band: band, volatility: 4.0) + let offset: (Int) -> Double = { banded[$0].quantity.doubleValue(for: self.unit) - 120 } + XCTAssertEqual(offset(0), 0, accuracy: 1e-9) + XCTAssertEqual(offset(12), -4.0 * pow(12.0, 0.71), accuracy: 1e-9) // 60 min: full depth + XCTAssertEqual(offset(18), -4.0 * pow(12.0, 0.71) / 2, accuracy: 1e-9) // 90 min: half way down the taper + XCTAssertEqual(offset(24), 0, accuracy: 1e-9) // 120 min: gone + XCTAssertEqual(offset(48), 0, accuracy: 1e-9) // eventual untouched + } + + // MARK: - Runway reserve + + private func flatForecast(_ value: Double, from now: Date) -> [PredictedGlucoseValue] { + (0...72).map { PredictedGlucoseValue(startDate: now.addingTimeInterval(.minutes(5 * Double($0))), quantity: .glucose(value)) } + } + + private func shift(_ forecast: Double, current: Double? = nil, carbs: Double = 0, volatility: Double) -> Double { + let now = Date() + return LoopAlgorithm.runwayAimShift( + prediction: flatForecast(forecast, from: now), reserve: ExperimentParameters.RunwayReserve(), + currentGlucose: current ?? forecast, activeCarbs: carbs, volatility: volatility, + sensitivity: 50, basalRate: 1.0, targetMidpoint: 105, suspendThreshold: 70, + insulinModel: ExponentialInsulinModelPreset.rapidActingAdult) + } + + func testRunwayGates() { + XCTAssertEqual(shift(140, volatility: 2), 0, "below 150") + XCTAssertEqual(shift(200, carbs: 5, volatility: 2), 0, "carbs on board") + XCTAssertGreaterThan(shift(200, volatility: 2), 0) + let now = Date() + let short = Array(flatForecast(200, from: now).prefix(30)) // 145 min, short of 180 + XCTAssertEqual(LoopAlgorithm.runwayAimShift( + prediction: short, reserve: ExperimentParameters.RunwayReserve(), currentGlucose: 200, activeCarbs: 0, + volatility: 2, sensitivity: 50, basalRate: 1.0, targetMidpoint: 105, suspendThreshold: 70, + insulinModel: ExponentialInsulinModelPreset.rapidActingAdult), 0, "forecast shorter than the horizon") + } + + func testRunwayShrinksWithVolatilityAndIsCapped() { + let calm = shift(200, volatility: 2), mid = shift(200, volatility: 6), volatile = shift(200, volatility: 30) + XCTAssertGreaterThan(calm, mid) + XCTAssertGreaterThan(mid, 0) + XCTAssertEqual(volatile, 0, "a plausible fall reaches the suspend threshold: no room") + XCTAssertEqual(shift(400, volatility: 2), 60, "capped at 60 mg/dL") + XCTAssertLessThanOrEqual(calm, 60) + } + + func testRunwayAddsAutomaticInsulinOnACalmHighButNotToTheManualRecommendation() { + let now = ISO8601DateFormatter().date(from: "2024-01-03T12:00:00+0000")! + var input = AlgorithmInputFixture.mock(for: now) + input.glucoseHistory = glucoseSeries(Array(repeating: 220, count: 12), endingAt: now.addingTimeInterval(.minutes(-1))) + // No baseline: the band runs at full depth and only lowers the forecast, so a rise in automatic dosing + // is the runway's. No low: no post-low cut. + for type in [DoseRecommendationType.tempBasal, .automaticBolus] { + input.recommendationType = type + input.experiments = [] + let standard = LoopAlgorithm.run(input: input) + input.experiments = [.guardAndRunway] + let runway = LoopAlgorithm.run(input: input) + let banded = runway.predictedGlucose.map { $0.quantity.doubleValue(for: unit) } + let plain = standard.predictedGlucose.map { $0.quantity.doubleValue(for: unit) } + 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") + if type == .tempBasal { + XCTAssertGreaterThan(runway.recommendation!.automatic!.basalAdjustment.unitsPerHour, + standard.recommendation!.automatic!.basalAdjustment.unitsPerHour) + } else { + XCTAssertGreaterThan(runway.recommendation!.automatic!.bolusUnits!, standard.recommendation!.automatic!.bolusUnits!) + } + } + // The runway does not add to the manual recommendation; the band can only lower it. + input.recommendationType = .manualBolus + input.experiments = [] + let standard = LoopAlgorithm.run(input: input) + input.experiments = [.guardAndRunway] + let manual = LoopAlgorithm.run(input: input) + XCTAssertLessThanOrEqual(manual.recommendation?.manual?.amount ?? 0, standard.recommendation?.manual?.amount ?? 0) + } + + func testTargetLoweredFloors() { + let now = Date() + let target: GlucoseRangeTimeline = [AbsoluteScheduleValue(startDate: now, endDate: now.addingTimeInterval(.hours(1)), + value: ClosedRange(uncheckedBounds: (lower: .glucose(100), upper: .glucose(110))))] + let lowered = LoopAlgorithm.targetLowered(target, by: 25).first!.value + XCTAssertEqual(lowered.lowerBound.doubleValue(for: unit), 75) + XCTAssertEqual(lowered.upperBound.doubleValue(for: unit), 85) + let floored = LoopAlgorithm.targetLowered(target, by: 60).first!.value + XCTAssertEqual(floored.lowerBound.doubleValue(for: unit), 70) + XCTAssertEqual(floored.upperBound.doubleValue(for: unit), 80) + } + + // MARK: - Codable + + func testFixtureRoundTripCarriesTheExperiment() throws { + var input = loadScenario("suspend") + input.experiments = [.guardAndRunway] + input.volatilityBaseline = 4.2 + let encoder = JSONEncoder(); encoder.dateEncodingStrategy = .iso8601 + let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601 + let back = try decoder.decode(AlgorithmInputFixture.self, from: encoder.encode(input)) + XCTAssertEqual(back.experiments, [.guardAndRunway]) + XCTAssertEqual(back.volatilityBaseline, 4.2) + XCTAssertEqual(AlgorithmExperiment.allCases, [.guardAndRunway, .integralRetrospectiveCorrection, .glucoseBasedApplicationFactor]) + } + + func testFixtureRoundTripCarriesACombination() throws { + var input = loadScenario("suspend") + input.experiments = [.glucoseBasedApplicationFactor, .guardAndRunway, .integralRetrospectiveCorrection] + let encoder = JSONEncoder(); encoder.dateEncodingStrategy = .iso8601; encoder.outputFormatting = .sortedKeys + let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601 + let data = try encoder.encode(input) + XCTAssertEqual(try decoder.decode(AlgorithmInputFixture.self, from: data).experiments, input.experiments) + XCTAssertTrue(String(decoding: data, as: UTF8.self) + .contains(#""experiments":["glucoseBasedApplicationFactor","guardAndRunway","integralRetrospectiveCorrection"]"#)) + } + + // MARK: - Integral retrospective correction + + func testIntegralRCExperimentIsTheInputFlag() { + for name in ["suspend", "carbs_with_isf_change"] { + var flag = loadScenario(name) + flag.useIntegralRetrospectiveCorrection = true + var experiment = loadScenario(name) + experiment.experiments = [.integralRetrospectiveCorrection] + assertSameOutput(LoopAlgorithm.run(input: flag), LoopAlgorithm.run(input: experiment)) + flag.experiments = [.integralRetrospectiveCorrection] // both: still integral RC, once + assertSameOutput(LoopAlgorithm.run(input: flag), LoopAlgorithm.run(input: experiment)) + } + } + + // MARK: - Glucose-based application factor + + func testGlucoseBasedApplicationFactorRamp() { + // Lower target 100: the ramp runs 0.2 at 110 to 0.8 at 200. + XCTAssertEqual(GlucoseBasedApplicationFactor.factor(glucose: 90, lowerTarget: 100), 0.2, accuracy: 1e-12) + XCTAssertEqual(GlucoseBasedApplicationFactor.factor(glucose: 110, lowerTarget: 100), 0.2, accuracy: 1e-12) + XCTAssertEqual(GlucoseBasedApplicationFactor.factor(glucose: 155, lowerTarget: 100), 0.5, accuracy: 1e-12) + XCTAssertEqual(GlucoseBasedApplicationFactor.factor(glucose: 200, lowerTarget: 100), 0.8, accuracy: 1e-12) + XCTAssertEqual(GlucoseBasedApplicationFactor.factor(glucose: 260, lowerTarget: 100), 0.8, accuracy: 1e-12) + // Lower target 90: 0.2 at 100, 0.5 at 150. + XCTAssertEqual(GlucoseBasedApplicationFactor.factor(glucose: 150, lowerTarget: 90), 0.5, accuracy: 1e-12) + } + + func testGlucoseBasedApplicationFactorReplacesTheConstantForAutomaticBolusOnly() { + let now = ISO8601DateFormatter().date(from: "2024-01-03T12:00:00+0000")! + var input = AlgorithmInputFixture.mock(for: now) + input.glucoseHistory = glucoseSeries(Array(repeating: 155, count: 12), endingAt: now.addingTimeInterval(.minutes(-1))) + // Mock lower target 100, glucose 155: the factor is 0.5, against the default 0.4. + input.recommendationType = .automaticBolus + var constant = input + constant.automaticBolusApplicationFactor = 0.5 + input.automaticBolusApplicationFactor = 0.4 + input.experiments = [.glucoseBasedApplicationFactor] + let gbaf = LoopAlgorithm.run(input: input) + assertSameOutput(gbaf, LoopAlgorithm.run(input: constant)) + input.experiments = [] + XCTAssertGreaterThan(gbaf.recommendation!.automatic!.bolusUnits!, LoopAlgorithm.run(input: input).recommendation!.automatic!.bolusUnits!) + for type in [DoseRecommendationType.tempBasal, .manualBolus] { + input.recommendationType = type + input.experiments = [] + let standard = LoopAlgorithm.run(input: input) + input.experiments = [.glucoseBasedApplicationFactor] + assertSameOutput(standard, LoopAlgorithm.run(input: input)) + } + } +}