diff --git a/.circleci/config.yml b/.circleci/config.yml index f989715..7caff5f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -7,24 +7,26 @@ version: 2.1 jobs: test: macos: - xcode: 16.0.0 + xcode: 26.1.0 + resource_class: m4pro.medium steps: - checkout - run: name: Test command: | - set -o pipefail && xcodebuild -scheme LoopAlgorithm test -destination "platform=iOS Simulator,name=iPhone 16,OS=latest" | xcpretty + set -o pipefail && xcodebuild -scheme LoopAlgorithm test -destination "platform=iOS Simulator,name=iPhone 17,OS=latest" | xcpretty - store_test_results: path: test_output package: macos: - xcode: 16.0.0 + xcode: 26.1.0 + resource_class: m4pro.medium steps: - checkout - run: name: Build LoopAlgorithmPackage command: | - set -o pipefail && xcodebuild build -scheme LoopAlgorithm -destination "platform=iOS Simulator,name=iPhone 16,OS=latest" | xcpretty + set -o pipefail && xcodebuild build -scheme LoopAlgorithm -destination "platform=iOS Simulator,name=iPhone 17,OS=latest" | xcpretty # # Workflows # diff --git a/Sources/LoopAlgorithm/AlgorithmInput.swift b/Sources/LoopAlgorithm/AlgorithmInput.swift index d6af8aa..e2139ab 100644 --- a/Sources/LoopAlgorithm/AlgorithmInput.swift +++ b/Sources/LoopAlgorithm/AlgorithmInput.swift @@ -24,6 +24,7 @@ public protocol AlgorithmInput { var target: GlucoseRangeTimeline { get } var suspendThreshold: LoopQuantity? { get } var maxBolus: Double { get } + var maxActiveInsulinMultiplier: Double? { get } // Defaults to 2 (2x maxBolus) var maxBasalRate: Double { get } var useIntegralRetrospectiveCorrection: Bool { get } var includePositiveVelocityAndRC: Bool { get } @@ -31,7 +32,6 @@ public protocol AlgorithmInput { var carbAbsorptionModel: CarbAbsorptionModel { get } var recommendationInsulinModel: InsulinModel { get } var recommendationType: DoseRecommendationType { get } - var automaticBolusApplicationFactor: Double? { get } + var automaticBolusApplicationFactor: Double? { get } // Defaults to 0.4 + var gradualTransitionsThreshold: Double? { get } } - - diff --git a/Sources/LoopAlgorithm/AlgorithmInputFixture.swift b/Sources/LoopAlgorithm/AlgorithmInputFixture.swift index 17110eb..4355778 100644 --- a/Sources/LoopAlgorithm/AlgorithmInputFixture.swift +++ b/Sources/LoopAlgorithm/AlgorithmInputFixture.swift @@ -25,6 +25,7 @@ public struct AlgorithmInputFixture: AlgorithmInput { public var target: GlucoseRangeTimeline public var suspendThreshold: LoopQuantity? public var maxBolus: Double + public var maxActiveInsulinMultiplier: Double? public var maxBasalRate: Double public var useIntegralRetrospectiveCorrection: Bool public var includePositiveVelocityAndRC: Bool @@ -33,6 +34,7 @@ public struct AlgorithmInputFixture: AlgorithmInput { public var recommendationInsulinType: FixtureInsulinType = .novolog public var recommendationType: DoseRecommendationType = .automaticBolus public var automaticBolusApplicationFactor: Double? + public var gradualTransitionsThreshold: Double? public var recommendationInsulinModel: InsulinModel { recommendationInsulinType.insulinModel @@ -62,6 +64,7 @@ public struct AlgorithmInputFixture: AlgorithmInput { target: GlucoseRangeTimeline, suspendThreshold: LoopQuantity?, maxBolus: Double, + maxActiveInsulinMultiplier: Double? = nil, maxBasalRate: Double, useIntegralRetrospectiveCorrection: Bool = false, useMidAbsorptionISF: Bool = false, @@ -69,7 +72,8 @@ public struct AlgorithmInputFixture: AlgorithmInput { carbAbsorptionModel: CarbAbsorptionModel = .piecewiseLinear, recommendationInsulinType: FixtureInsulinType, recommendationType: DoseRecommendationType, - automaticBolusApplicationFactor: Double? = nil + automaticBolusApplicationFactor: Double? = nil, + gradualTransitionsThreshold: Double? = 40.0 ) { self.predictionStart = predictionStart self.glucoseHistory = glucoseHistory @@ -81,6 +85,7 @@ public struct AlgorithmInputFixture: AlgorithmInput { self.target = target self.suspendThreshold = suspendThreshold self.maxBolus = maxBolus + self.maxActiveInsulinMultiplier = maxActiveInsulinMultiplier self.maxBasalRate = maxBasalRate self.useIntegralRetrospectiveCorrection = useIntegralRetrospectiveCorrection self.includePositiveVelocityAndRC = includePositiveVelocityAndRC @@ -89,6 +94,7 @@ public struct AlgorithmInputFixture: AlgorithmInput { self.recommendationInsulinType = recommendationInsulinType self.recommendationType = recommendationType self.automaticBolusApplicationFactor = automaticBolusApplicationFactor + self.gradualTransitionsThreshold = gradualTransitionsThreshold } } @@ -116,6 +122,7 @@ extension AlgorithmInputFixture: Codable { self.suspendThreshold = LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: suspendThresholdMgdl) } self.maxBolus = try container.decode(Double.self, forKey: .maxBolus) + self.maxActiveInsulinMultiplier = try container.decodeIfPresent(Double.self, forKey: .maxActiveInsulinMultiplier) self.maxBasalRate = try container.decode(Double.self, forKey: .maxBasalRate) self.useIntegralRetrospectiveCorrection = try container.decodeIfPresent(Bool.self, forKey: .useIntegralRetrospectiveCorrection) ?? false self.includePositiveVelocityAndRC = try container.decodeIfPresent(Bool.self, forKey: .includePositiveVelocityAndRC) ?? true @@ -140,6 +147,7 @@ extension AlgorithmInputFixture: Codable { } self.automaticBolusApplicationFactor = try container.decodeIfPresent(Double.self, forKey: .automaticBolusApplicationFactor) + self.gradualTransitionsThreshold = try container.decodeIfPresent(Double.self, forKey: .gradualTransitionsThreshold) ?? 40.0 } @@ -163,6 +171,7 @@ extension AlgorithmInputFixture: Codable { try container.encode(targetMgdl, forKey: .target) try container.encode(suspendThreshold?.doubleValue(for: .milligramsPerDeciliter), forKey: .suspendThreshold) try container.encode(maxBolus, forKey: .maxBolus) + try container.encode(maxActiveInsulinMultiplier, forKey: .maxActiveInsulinMultiplier) try container.encode(maxBasalRate, forKey: .maxBasalRate) if useIntegralRetrospectiveCorrection { try container.encode(useIntegralRetrospectiveCorrection, forKey: .useIntegralRetrospectiveCorrection) @@ -174,6 +183,7 @@ extension AlgorithmInputFixture: Codable { try container.encode(recommendationInsulinType.rawValue, forKey: .recommendationInsulinType) try container.encode(recommendationType.rawValue, forKey: .recommendationType) try container.encode(automaticBolusApplicationFactor, forKey: .automaticBolusApplicationFactor) + try container.encode(gradualTransitionsThreshold, forKey: .gradualTransitionsThreshold) } private enum CodingKeys: String, CodingKey { @@ -187,6 +197,7 @@ extension AlgorithmInputFixture: Codable { case target case suspendThreshold case maxBolus + case maxActiveInsulinMultiplier case maxBasalRate case useIntegralRetrospectiveCorrection case includePositiveVelocityAndRC @@ -194,6 +205,7 @@ extension AlgorithmInputFixture: Codable { case recommendationInsulinType case recommendationType case automaticBolusApplicationFactor + case gradualTransitionsThreshold } } @@ -217,7 +229,8 @@ extension AlgorithmInputFixture { carbAbsorptionModel: input.carbAbsorptionModel, recommendationInsulinType: .novolog, recommendationType: input.recommendationType, - automaticBolusApplicationFactor: input.automaticBolusApplicationFactor + automaticBolusApplicationFactor: input.automaticBolusApplicationFactor, + gradualTransitionsThreshold: input.gradualTransitionsThreshold ) let encoder = JSONEncoder() @@ -255,4 +268,3 @@ extension CarbEntry { ) } } - diff --git a/Sources/LoopAlgorithm/AutomaticDoseRecommendation.swift b/Sources/LoopAlgorithm/AutomaticDoseRecommendation.swift index 68439a4..29965fe 100644 --- a/Sources/LoopAlgorithm/AutomaticDoseRecommendation.swift +++ b/Sources/LoopAlgorithm/AutomaticDoseRecommendation.swift @@ -38,4 +38,12 @@ public struct AutomaticDoseRecommendation: Equatable { } } -extension AutomaticDoseRecommendation: Codable {} +extension AutomaticDoseRecommendation: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + // Provide default TempBasalRecommendation if basalAdjustment is missing + self.basalAdjustment = try container.decodeIfPresent(TempBasalRecommendation.self, forKey: .basalAdjustment) ?? TempBasalRecommendation(unitsPerHour: 0, duration: 0) + self.bolusUnits = try container.decodeIfPresent(Double.self, forKey: .bolusUnits) + self.direction = try container.decode(Direction.self, forKey: .direction) + } +} diff --git a/Sources/LoopAlgorithm/Carbs/CarbMath.swift b/Sources/LoopAlgorithm/Carbs/CarbMath.swift index fecc42e..53b5779 100644 --- a/Sources/LoopAlgorithm/Carbs/CarbMath.swift +++ b/Sources/LoopAlgorithm/Carbs/CarbMath.swift @@ -15,9 +15,9 @@ public struct CarbMath { public static let defaultEffectDelay: TimeInterval = .minutes(10) } -public enum CarbAbsorptionModel { - case linear - case piecewiseLinear +public enum CarbAbsorptionModel: String, Codable { + case linear = "linear" + case piecewiseLinear = "piecewiseLinear" public var model: CarbAbsorptionComputable { switch self { diff --git a/Sources/LoopAlgorithm/Glucose/GlucoseEffect.swift b/Sources/LoopAlgorithm/Glucose/GlucoseEffect.swift index dbcf015..9572f80 100644 --- a/Sources/LoopAlgorithm/Glucose/GlucoseEffect.swift +++ b/Sources/LoopAlgorithm/Glucose/GlucoseEffect.swift @@ -8,7 +8,7 @@ import Foundation -public struct GlucoseEffect: GlucoseValue, Equatable { +public struct GlucoseEffect: GlucoseValue, Equatable, Sendable { public let startDate: Date public let quantity: LoopQuantity diff --git a/Sources/LoopAlgorithm/Glucose/GlucoseMath.swift b/Sources/LoopAlgorithm/Glucose/GlucoseMath.swift index a69d2a6..9987980 100644 --- a/Sources/LoopAlgorithm/Glucose/GlucoseMath.swift +++ b/Sources/LoopAlgorithm/Glucose/GlucoseMath.swift @@ -61,7 +61,7 @@ extension BidirectionalCollection where Element: GlucoseSampleValue, Index == In /// - Parameters: /// - interval: The interval between readings, on average, used to determine if we have a contiguous set of values /// - Returns: True if the samples are continuous - public func isContinuous(within interval: TimeInterval = TimeInterval(5 * 60)) -> Bool { + func isContinuous(within interval: TimeInterval = TimeInterval(minutes: 5)) -> Bool { if let first = first, let last = last, // Ensure that the entries are contiguous @@ -73,6 +73,34 @@ extension BidirectionalCollection where Element: GlucoseSampleValue, Index == In return false } + /// Whether the collection has gradual transitions (no large glucose jumps between consecutive readings) + /// + /// - Parameters: + /// - gradualTransitionThreshold: Maximum allowed difference between consecutive readings in mg/dL (default 40.0) + /// - Returns: True if all consecutive differences are within the threshold + public func hasGradualTransitions(gradualTransitionThreshold: Double = 40.0) -> Bool { + guard count > 1 else { + return false // A single point could be a spike and should not be used for momentum calculation + } + + // Check glucose value continuity (no large transitions) + let unit = LoopUnit.milligramsPerDeciliter + for i in 0..<(count - 1) { + let current = self[self.index(self.startIndex, offsetBy: i)] + let next = self[self.index(self.startIndex, offsetBy: i + 1)] + + let currentValue = current.quantity.doubleValue(for: unit) + let nextValue = next.quantity.doubleValue(for: unit) + let difference = abs(nextValue - currentValue) + + if difference > gradualTransitionThreshold { + return false + } + } + + return true + } + /// Calculates the short-term predicted momentum effect using linear regression /// /// - Parameters: @@ -90,7 +118,7 @@ extension BidirectionalCollection where Element: GlucoseSampleValue, Index == In guard self.count > 2, // Linear regression isn't much use without 3 or more entries. - isContinuous() && !containsCalibrations() && hasSingleProvenance, + hasGradualTransitions() && isContinuous() && !containsCalibrations() && hasSingleProvenance, let firstSample = self.first, let lastSample = self.last, let (startDate, endDate) = LoopMath.simulationDateRangeForSamples([lastSample], duration: duration, delta: delta) diff --git a/Sources/LoopAlgorithm/Insulin/InsulinMath.swift b/Sources/LoopAlgorithm/Insulin/InsulinMath.swift index 620af78..f272c20 100644 --- a/Sources/LoopAlgorithm/Insulin/InsulinMath.swift +++ b/Sources/LoopAlgorithm/Insulin/InsulinMath.swift @@ -18,21 +18,31 @@ extension BasalRelativeDose { private func continuousDeliveryInsulinOnBoard(at date: Date, delta: TimeInterval) -> Double { let doseDuration = endDate.timeIntervalSince(startDate) // t1 let time = date.timeIntervalSince(startDate) - var iob: Double = 0 - var doseDate = TimeInterval(0) // i - - repeat { - let segment: Double - if doseDuration > 0 { - segment = max(0, min(doseDate + delta, doseDuration) - doseDate) / doseDuration - } else { - segment = 1 - } + guard doseDuration > 0 else { + return insulinModel.percentEffectRemaining(at: time) + } - iob += segment * insulinModel.percentEffectRemaining(at: time - doseDate) + // Integrate the delivered fraction of the dose up to `time`, in `delta` + // steps. Previously the loop's upper bound was quantized to the delta + // grid (`floor((time + delay) / delta) * delta`), so a whole chunk was + // added discontinuously each time `time` crossed a delta boundary — + // producing a delta-scale ripple in IOB for any segment longer than one + // delta (i.e. essentially every real temp basal / suspend). Integrating + // up to `time` and weighting a partial final chunk by delivery-so-far + // makes IOB continuous. Each chunk's remaining-effect is sampled at its + // midpoint (a midpoint Riemann sum), which converges to the continuous + // integral without stepping. + var iob: Double = 0 + let upper = min(time, doseDuration) + var doseDate = TimeInterval(0) // i + while doseDate < upper { + let chunkEnd = min(doseDate + delta, upper) + let segment = (chunkEnd - doseDate) / doseDuration + let mid = (doseDate + chunkEnd) / 2 + iob += segment * insulinModel.percentEffectRemaining(at: time - mid) doseDate += delta - } while doseDate <= min(floor((time + insulinModel.delay) / delta) * delta, doseDuration) + } return iob } @@ -397,37 +407,73 @@ extension Collection where Element == BasalRelativeDose { return [] } - var lastDate = start - var date = start - var values = [GlucoseEffect]() let unit = LoopUnit.milligramsPerDeciliter + let dosesArray = Array(self) + + // Build the list of time points up front. timePoints[i] is the date at + // which the cumulative effect through that time is recorded. + // increments[i] = effect contribution during (timePoints[i-1], timePoints[i]]; + // increments[0] = 0 (base case — no doses applied yet at start). + var timePoints: [Date] = [] + do { + var d = start + while d <= end { + timePoints.append(d) + d = d.addingTimeInterval(delta) + } + } + let n = timePoints.count + guard n > 1 else { + return timePoints.map { GlucoseEffect(startDate: $0, quantity: LoopQuantity(unit: unit, doubleValue: 0)) } + } - var value: Double = 0 - repeat { - // Sum effects over doses - value = reduce(value) { (value, dose) -> Double in - guard date != lastDate else { - return 0 - } - - // Sum effects over pertinent ISF timeline segments + // Parallelize the per-step increments across CPU cores. Each step's + // increment depends only on its own (lastDate, date) interval — there's + // no cross-step dependency until the final cumsum. + var increments = [Double](repeating: 0, count: n) + + // Reduce loop body to a closure-free static-like body to keep + // capture/Sendable surface minimal. concurrentPerform's closure isn't + // @Sendable, so this is tolerated by the compiler. + increments.withUnsafeMutableBufferPointer { incBuf in + DispatchQueue.concurrentPerform(iterations: n - 1) { idx in + // idx in 0.. `date` (after: true) + /// or `key` >= `date` (after: false), using binary search. + /// Assumes the array is sorted ascending by `key`. + func partition(index date: K, key: KeyPath, after: Bool) -> Int { + var lo = 0, hi = count + while lo < hi { + let mid = (lo + hi) / 2 + let k = self[mid][keyPath: key] + if after ? k <= date : k < date { lo = mid + 1 } else { hi = mid } + } + return lo + } +} + +// MARK: - PrecomputedInsulinInput + +/// Pre-annotated insulin data for use in multi-step prediction sweeps. +/// +/// **Typical usage — ISF sweep:** +/// ```swift +/// // 1. Annotate once (ISF-independent, reused across all multipliers) +/// let base = PrecomputedInsulinInput.annotate(doses: doses, basal: basal) +/// +/// // 2. For each ISF value: compute effects once, sweep all time steps +/// for multiplier in isfMultipliers { +/// let input = base.withEffects(sensitivity: scale(sensitivity, by: multiplier), +/// from: sweepStart, to: sweepEnd + activityDuration) +/// for t in sweepSteps { +/// let prediction = LoopAlgorithm.generatePrediction( +/// start: t, glucoseHistory: cgm[t], precomputedInsulin: input, ...) +/// } +/// } +/// ``` +/// +/// **Note on `Sendable`:** Not conformed because `BasalRelativeDose` stores +/// `any InsulinModel`, a non-Sendable existential. Sweeps run on a single +/// actor so this is not limiting in practice. +public struct PrecomputedInsulinInput { + + // MARK: - Stored properties + + /// Doses annotated against the scheduled basal timeline. + /// + /// ISF-independent — build once with `annotate(doses:basal:)` and reuse + /// across every ISF multiplier in a sweep. + public var annotatedDoses: [BasalRelativeDose] + + /// Pre-computed glucose-effect timeline for `annotatedDoses` at a + /// specific ISF schedule. + /// + /// When non-nil, `generatePrediction` uses this directly instead of + /// calling `glucoseEffects(insulinSensitivityHistory:from:to:)`. + /// + /// **ISF sweeps:** rebuild this once per multiplier using `withEffects(sensitivity:)`. + /// The `annotatedDoses` array is unchanged and does not need to be rebuilt. + /// + /// **Timeline coverage:** must cover + /// `[glucoseHistory.first.startDate, sweepEnd + defaultInsulinActivityDuration]` + /// for all steps in the sweep. Pass a generous `to:` date when calling + /// `withEffects(sensitivity:from:to:)`. + public var insulinEffects: [GlucoseEffect]? + + // MARK: - Init + + public init(annotatedDoses: [BasalRelativeDose], insulinEffects: [GlucoseEffect]? = nil) { + self.annotatedDoses = annotatedDoses + self.insulinEffects = insulinEffects + } +} + +// MARK: - Factory methods + +extension PrecomputedInsulinInput { + + /// **Step 1 of 2 for ISF sweeps.** + /// + /// Annotates a full-window dose list against the basal timeline once. + /// The result can be reused across all ISF multipliers — annotation does + /// not depend on ISF. + /// + /// - Parameters: + /// - doses: All insulin doses for the sweep window, sorted by startDate. + /// - basal: Scheduled basal timeline covering the same window. + /// - Returns: A `PrecomputedInsulinInput` with `insulinEffects == nil`. + /// Call `withEffects(sensitivity:from:to:)` before passing to + /// `generatePrediction`. + public static func annotate( + doses: [DoseType], + basal: [AbsoluteScheduleValue] + ) -> PrecomputedInsulinInput { + PrecomputedInsulinInput(annotatedDoses: doses.annotated(with: basal)) + } + + /// **Step 2 of 2 for ISF sweeps.** + /// + /// Computes the glucose-effect timeline for the already-annotated doses + /// at the given ISF schedule. Call once per ISF multiplier value; then + /// pass the result into every `generatePrediction` call for that multiplier. + /// + /// - Parameters: + /// - sensitivity: The (possibly scaled) ISF timeline for this sweep config. + /// - from: Start of the effect timeline. Defaults to earliest dose start. + /// Should be <= `glucoseHistory.first.startDate` for the first eval step. + /// - to: End of the effect timeline. Should cover + /// `sweepEnd + defaultInsulinActivityDuration` to avoid truncation at + /// the tail of long sweeps. + /// - useMidAbsorptionISF: Use mid-absorption ISF computation. + /// - Returns: A new `PrecomputedInsulinInput` with `insulinEffects` populated. + public func withEffects( + sensitivity: [AbsoluteScheduleValue], + from: Date? = nil, + to: Date? = nil, + useMidAbsorptionISF: Bool = false + ) -> PrecomputedInsulinInput { + let effects: [GlucoseEffect] + if useMidAbsorptionISF { + effects = annotatedDoses.glucoseEffectsMidAbsorptionISF( + insulinSensitivityHistory: sensitivity, + from: from, + to: to + ) + } else { + effects = annotatedDoses.glucoseEffects( + insulinSensitivityHistory: sensitivity, + from: from, + to: to + ) + } + return PrecomputedInsulinInput(annotatedDoses: annotatedDoses, insulinEffects: effects) + } + + /// Returns a copy with `annotatedDoses` sliced to doses that overlap + /// `[from, to]`, and `insulinEffects` unchanged (the full pre-built + /// timeline is always passed through — generatePrediction only reads + /// the entries it needs). + /// + /// Use this per evaluation step to pass only the relevant dose window + /// into `generatePrediction`, matching what the standard path does when + /// it calls `doses.annotated(with: basal)` on the per-step slice. + /// + /// `annotatedDoses` must be sorted by `startDate`. + public func sliced(from: Date, to: Date) -> PrecomputedInsulinInput { + // Keep annotated doses that overlap [from, to]: + // dose.startDate <= to AND dose.endDate > from + // + // annotatedDoses is sorted by startDate, so we can binary-search for + // the upper bound (first startDate > to) and then linear-scan backward + // from there. For the lower bound we use a linear filter on endDate + // since the array is NOT sorted by endDate. + // + // In practice the dose arrays are small (~100-200 entries per 16h + // window) so the linear endDate check is negligible. + let hiIdx = annotatedDoses.partition(index: to, key: \.startDate, after: false) + let slicedDoses = annotatedDoses[0.. from } + return PrecomputedInsulinInput(annotatedDoses: slicedDoses, insulinEffects: insulinEffects) + } + + /// Convenience: annotate and compute effects in one call. + /// + /// Use when running a single config (no ISF sweep). For ISF sweeps, + /// prefer `annotate(doses:basal:)` + `withEffects(sensitivity:from:to:)` + /// so annotation cost is paid only once. + public static func build( + doses: [DoseType], + basal: [AbsoluteScheduleValue], + sensitivity: [AbsoluteScheduleValue]? = nil, + effectsFrom: Date? = nil, + effectsTo: Date? = nil, + useMidAbsorptionISF: Bool = false + ) -> PrecomputedInsulinInput { + let base = annotate(doses: doses, basal: basal) + guard let sensitivity else { return base } + return base.withEffects( + sensitivity: sensitivity, + from: effectsFrom, + to: effectsTo, + useMidAbsorptionISF: useMidAbsorptionISF + ) + } +} diff --git a/Sources/LoopAlgorithm/LoopAlgorithm.swift b/Sources/LoopAlgorithm/LoopAlgorithm.swift index 0bb3c06..fce1746 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithm.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithm.swift @@ -178,7 +178,9 @@ public struct LoopAlgorithm { useIntegralRetrospectiveCorrection: Bool = false, includingPositiveVelocityAndRC: Bool = true, useMidAbsorptionISF: Bool = false, - carbAbsorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption() + carbAbsorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption(), + gradualTransitionsThreshold: Double? = 40.0, + momentumVelocityMaximum: LoopQuantity? = nil ) -> LoopPrediction where CarbType: CarbEntry, GlucoseType: GlucoseSampleValue, InsulinDoseType: InsulinDose { var prediction: [PredictedGlucoseValue] = [] @@ -259,8 +261,6 @@ public struct LoopAlgorithm { rc = StandardRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration) } - - if let latestGlucose = glucoseHistory.last { retrospectiveCorrectionEffects = rc.computeEffect( startingAt: latestGlucose, @@ -282,9 +282,28 @@ public struct LoopAlgorithm { } if algorithmEffectsOptions.contains(.retrospection) { - if !includingPositiveVelocityAndRC, let netRC = retrospectiveCorrectionEffects.netEffect(), netRC.quantity.doubleValue(for: .milligramsPerDeciliter) > 0 { - // positive RC is turned off - } else { + // Check if glucose data is smooth enough for RC + // Use the same input window as retrospective correction + var useRC: Bool = true + + // Don't apply RC if glucose has large jumps + let rcTransitionData = glucoseHistory.filterDateRange( + start.addingTimeInterval(-LoopMath.retrospectiveCorrectionGroupingInterval), + start + ) + + if !rcTransitionData.hasGradualTransitions(gradualTransitionThreshold: gradualTransitionsThreshold ?? 40.0) { + useRC = false + } + + // Don't apply positive RC if that setting is disabled + if !includingPositiveVelocityAndRC, + let netRC = retrospectiveCorrectionEffects.netEffect(), + netRC.quantity.doubleValue(for: .milligramsPerDeciliter) > 0 { + useRC = false + } + + if useRC { effects.append(retrospectiveCorrectionEffects) } } @@ -293,7 +312,7 @@ public struct LoopAlgorithm { var useMomentum: Bool = true if algorithmEffectsOptions.contains(.momentum) { let momentumInputData = glucoseHistory.filterDateRange(start.addingTimeInterval(-GlucoseMath.momentumDataInterval), start) - momentumEffects = momentumInputData.linearMomentumEffect() + momentumEffects = momentumInputData.linearMomentumEffect(velocityMaximum: momentumVelocityMaximum) if !includingPositiveVelocityAndRC, let netMomentum = momentumEffects.netEffect(), netMomentum.quantity.doubleValue(for: .milligramsPerDeciliter) > 0 { // positive momentum is turned off useMomentum = false @@ -334,6 +353,201 @@ public struct LoopAlgorithm { ) } + /// Generates a forecast using pre-annotated insulin data. + /// + /// This overload is optimised for multi-step historical sweeps where the + /// same dose history is evaluated at many consecutive time points. By + /// accepting a `PrecomputedInsulinInput` the caller can: + /// + /// 1. **Skip `annotated(with: basal)`** — the most expensive per-step + /// operation (~O(doses × basalSegments)). Annotate the full window + /// once with `PrecomputedInsulinInput.build(...)`, then slice + /// `annotatedDoses` to the lookback window for each call. + /// + /// 2. **Skip `glucoseEffects(...)`** — when `precomputedInsulin.insulinEffects` + /// is non-nil the function clips the pre-built effect timeline to the + /// needed range instead of recomputing from scratch. This is only + /// valid when ISF does not change between steps (i.e. you are NOT + /// sweeping ISF multipliers). + /// + /// All other effects (carbs, RC, momentum) are computed normally. + /// + /// - Parameters: + /// - start: The starting time of the glucose prediction. + /// - glucoseHistory: History of glucose values: t-10h to t. + /// - precomputedInsulin: Pre-annotated dose data for this step. Caller + /// must slice `annotatedDoses` to `[t - insulinLookback, t]` (or + /// `[t - lookback, t + 6h]` for future-insulin mode). + /// - carbEntries: History of carb entries. + /// - sensitivity: ISF timeline — still required for carb + RC effects. + /// - carbRatio: Carb ratio timeline. + /// - algorithmEffectsOptions: Which effects to include. + /// - useIntegralRetrospectiveCorrection: Use integral RC. + /// - includingPositiveVelocityAndRC: Include positive velocity/RC. + /// - useMidAbsorptionISF: Use mid-absorption ISF (ignored when + /// `precomputedInsulin.insulinEffects` is non-nil). + /// - carbAbsorptionModel: Carb absorption model. + /// - gradualTransitionsThreshold: RC smoothness gate (default 40 mg/dL). + /// - Returns: A `LoopPrediction` struct. `dosesRelativeToBasal` is + /// populated from `precomputedInsulin.annotatedDoses`. + public static func generatePrediction( + start: Date, + glucoseHistory: [GlucoseType], + precomputedInsulin: PrecomputedInsulinInput, + carbEntries: [CarbType], + sensitivity: [AbsoluteScheduleValue], + carbRatio: [AbsoluteScheduleValue], + algorithmEffectsOptions: AlgorithmEffectsOptions = .all, + useIntegralRetrospectiveCorrection: Bool = false, + includingPositiveVelocityAndRC: Bool = true, + useMidAbsorptionISF: Bool = false, + carbAbsorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption(), + gradualTransitionsThreshold: Double? = 40.0, + momentumVelocityMaximum: LoopQuantity? = nil + ) -> LoopPrediction where CarbType: CarbEntry, GlucoseType: GlucoseSampleValue { + + let dosesRelativeToBasal = precomputedInsulin.annotatedDoses + let activeInsulin = dosesRelativeToBasal.insulinOnBoard(at: start) + + // ── Insulin effects ────────────────────────────────────────────────────── + // Fast path: clip the pre-computed effect timeline to the needed range. + // Slow path: compute from annotated doses (still faster than the full + // overload because annotation is already done). + let insulinEffects: [GlucoseEffect] + if let prebuilt = precomputedInsulin.insulinEffects { + // Use the pre-built effects directly. Extra entries (outside the + // needed range) are harmless; counteractionEffects() and + // predictGlucose() only consume entries within their required window. + // Pass the full array — callers should pre-build with a generous + // `effectsTo` covering the full sweep end + activity duration. + insulinEffects = prebuilt + } else { + var effectsInterval = dosesRelativeToBasal.effectsInterval() ?? DateInterval(start: start, end: start) + if let glucoseStart = glucoseHistory.first?.startDate, glucoseStart < effectsInterval.start { + effectsInterval = effectsInterval.extendedToInclude(glucoseStart) + } + if let glucoseEnd = glucoseHistory.last?.endDate, glucoseEnd > effectsInterval.end { + effectsInterval = effectsInterval.extendedToInclude(glucoseEnd) + } + if useMidAbsorptionISF { + insulinEffects = dosesRelativeToBasal.glucoseEffectsMidAbsorptionISF( + insulinSensitivityHistory: sensitivity, + from: effectsInterval.start, + to: effectsInterval.end + ) + } else { + insulinEffects = dosesRelativeToBasal.glucoseEffects( + insulinSensitivityHistory: sensitivity, + from: effectsInterval.start, + to: effectsInterval.end + ) + } + } + + // ── ICE, carbs, RC, momentum — identical to the standard overload ──────── + let insulinCounteractionEffects = glucoseHistory.counteractionEffects(to: insulinEffects) + + let carbStatus = carbEntries.map( + to: insulinCounteractionEffects, + carbRatio: carbRatio, + insulinSensitivity: sensitivity + ) + let carbEffects = carbStatus.dynamicGlucoseEffects( + from: start.addingTimeInterval(-IntegralRetrospectiveCorrection.retrospectionInterval), + carbRatios: carbRatio, + insulinSensitivities: sensitivity, + absorptionModel: carbAbsorptionModel + ) + let activeCarbs = carbStatus.dynamicCarbsOnBoard(at: start, absorptionModel: carbAbsorptionModel) + + let retrospectiveGlucoseDiscrepancies = insulinCounteractionEffects.subtracting(carbEffects) + let retrospectiveGlucoseDiscrepanciesSummed = retrospectiveGlucoseDiscrepancies + .combinedSums(of: LoopMath.retrospectiveCorrectionGroupingInterval * 1.01) + + let rc: RetrospectiveCorrection = useIntegralRetrospectiveCorrection + ? IntegralRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration) + : StandardRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration) + + var prediction: [PredictedGlucoseValue] = [] + var retrospectiveCorrectionEffects: [GlucoseEffect] = [] + var momentumEffects: [GlucoseEffect] = [] + var totalRetrospectiveCorrectionEffect: LoopQuantity? + + if let latestGlucose = glucoseHistory.last { + retrospectiveCorrectionEffects = rc.computeEffect( + startingAt: latestGlucose, + retrospectiveGlucoseDiscrepanciesSummed: retrospectiveGlucoseDiscrepanciesSummed, + recencyInterval: TimeInterval(minutes: 15), + retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval + ) + totalRetrospectiveCorrectionEffect = rc.totalGlucoseCorrectionEffect + + var effects = [[GlucoseEffect]]() + if algorithmEffectsOptions.contains(.carbs) { effects.append(carbEffects) } + if algorithmEffectsOptions.contains(.insulin) { effects.append(insulinEffects) } + + if algorithmEffectsOptions.contains(.retrospection) { + var useRC = true + let rcTransitionData = glucoseHistory.filterDateRange( + start.addingTimeInterval(-LoopMath.retrospectiveCorrectionGroupingInterval), + start + ) + if !rcTransitionData.hasGradualTransitions(gradualTransitionThreshold: gradualTransitionsThreshold ?? 40.0) { + useRC = false + } + if !includingPositiveVelocityAndRC, + let netRC = retrospectiveCorrectionEffects.netEffect(), + netRC.quantity.doubleValue(for: .milligramsPerDeciliter) > 0 { + useRC = false + } + if useRC { effects.append(retrospectiveCorrectionEffects) } + } + + var useMomentum = true + if algorithmEffectsOptions.contains(.momentum) { + let momentumInputData = glucoseHistory.filterDateRange( + start.addingTimeInterval(-GlucoseMath.momentumDataInterval), start + ) + momentumEffects = momentumInputData.linearMomentumEffect(velocityMaximum: momentumVelocityMaximum) + if !includingPositiveVelocityAndRC, + let netMomentum = momentumEffects.netEffect(), + netMomentum.quantity.doubleValue(for: .milligramsPerDeciliter) > 0 { + useMomentum = false + } + } else { + useMomentum = false + } + + prediction = LoopMath.predictGlucose( + startingAt: latestGlucose, + momentum: useMomentum ? momentumEffects : [], + effects: effects + ) + + let finalDate = start.addingTimeInterval(InsulinMath.defaultInsulinActivityDuration) + if let last = prediction.last, last.startDate < finalDate { + prediction.append(PredictedGlucoseValue(startDate: finalDate, quantity: last.quantity)) + } + } + + return LoopPrediction( + glucose: prediction, + effects: LoopAlgorithmEffects( + insulin: insulinEffects, + carbs: carbEffects, + carbStatus: carbStatus, + retrospectiveCorrection: retrospectiveCorrectionEffects, + momentum: momentumEffects, + insulinCounteraction: insulinCounteractionEffects, + retrospectiveGlucoseDiscrepancies: retrospectiveGlucoseDiscrepanciesSummed, + totalRetrospectiveCorrectionEffect: totalRetrospectiveCorrectionEffect + ), + dosesRelativeToBasal: dosesRelativeToBasal, + activeInsulin: activeInsulin, + activeCarbs: activeCarbs + ) + } + // Helper to generate prediction with LoopPredictionInput struct public static func generatePrediction(input: LoopPredictionInput) -> LoopPrediction { @@ -347,12 +561,13 @@ public struct LoopAlgorithm { carbRatio: input.carbRatio, algorithmEffectsOptions: input.algorithmEffectsOptions, useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection, - carbAbsorptionModel: input.carbAbsorptionModel.model + carbAbsorptionModel: input.carbAbsorptionModel.model, + gradualTransitionsThreshold: input.gradualTransitionsThreshold ) } // Computes an amount of insulin to correct the given prediction - static func insulinCorrection( + public static func insulinCorrection( prediction: [PredictedGlucoseValue], at deliveryDate: Date, target: GlucoseRangeTimeline, @@ -369,12 +584,13 @@ public struct LoopAlgorithm { } // Computes a 30 minute temp basal dose to correct the given prediction - static func recommendTempBasal( + public static func recommendTempBasal( for correction: InsulinCorrection, neutralBasalRate: Double, activeInsulin: Double, maxBolus: Double, - maxBasalRate: Double + maxBasalRate: Double, + maxActiveInsulin: Double ) -> TempBasalRecommendation { var maxBasalRate = maxBasalRate @@ -386,12 +602,11 @@ public struct LoopAlgorithm { maxBasalRate = neutralBasalRate } - // Enforce max IOB, calculated from the user entered maxBolus - let automaticDosingIOBLimit = maxBolus * 2.0 - let iobHeadroom = automaticDosingIOBLimit - activeInsulin + // Enforce max active insulin + let activeInsulinHeadroom = maxActiveInsulin - activeInsulin - let maxThirtyMinuteRateToKeepIOBBelowLimit = iobHeadroom * (TimeInterval.hours(1) / tempBasalDuration) + neutralBasalRate // 30 minutes of a U/hr rate - maxBasalRate = Swift.min(maxThirtyMinuteRateToKeepIOBBelowLimit, maxBasalRate) + let maxThirtyMinuteRateToKeepActiveInsulinBelowLimit = activeInsulinHeadroom * (TimeInterval.hours(1) / tempBasalDuration) + neutralBasalRate // 30 minutes of a U/hr rate + maxBasalRate = Swift.min(maxThirtyMinuteRateToKeepActiveInsulinBelowLimit, maxBasalRate) return correction.asTempBasal( neutralBasalRate: neutralBasalRate, @@ -401,17 +616,18 @@ public struct LoopAlgorithm { } // Computes a bolus or low-temp basal dose to correct the given prediction - static func recommendAutomaticDose( + public static func recommendAutomaticDose( for correction: InsulinCorrection, applicationFactor: Double, neutralBasalRate: Double, activeInsulin: Double, maxBolus: Double, - maxBasalRate: Double + maxBasalRate: Double, + maxActiveInsulin: Double ) -> AutomaticDoseRecommendation { - let deliveryHeadroom = max(0, maxBolus * 2.0 - activeInsulin) + let deliveryHeadroom = max(0, maxActiveInsulin - activeInsulin) var deliveryMax = min(maxBolus * applicationFactor, deliveryHeadroom) @@ -529,7 +745,8 @@ public struct LoopAlgorithm { useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection, includingPositiveVelocityAndRC: input.includePositiveVelocityAndRC, useMidAbsorptionISF: input.useMidAbsorptionISF, - carbAbsorptionModel: input.carbAbsorptionModel.model + carbAbsorptionModel: input.carbAbsorptionModel.model, + gradualTransitionsThreshold: input.gradualTransitionsThreshold ) let sensitivityForDosing: [AbsoluteScheduleValue] @@ -555,6 +772,8 @@ public struct LoopAlgorithm { sensitivity: sensitivityForDosing, insulinModel: input.recommendationInsulinModel) + let maxActiveInsulin = input.maxBolus * (input.maxActiveInsulinMultiplier ?? 2) + switch input.recommendationType { case .manualBolus: let recommendation = recommendManualBolus( @@ -570,7 +789,8 @@ public struct LoopAlgorithm { neutralBasalRate: scheduledBasalRate, activeInsulin: prediction.activeInsulin!, maxBolus: input.maxBolus, - maxBasalRate: input.maxBasalRate) + maxBasalRate: input.maxBasalRate, + maxActiveInsulin: maxActiveInsulin) result = .success(.init(automatic: recommendation)) case .tempBasal: let recommendation = recommendTempBasal( @@ -578,7 +798,8 @@ public struct LoopAlgorithm { neutralBasalRate: scheduledBasalRate, activeInsulin: prediction.activeInsulin!, maxBolus: input.maxBolus, - maxBasalRate: input.maxBasalRate) + maxBasalRate: input.maxBasalRate, + maxActiveInsulin: maxActiveInsulin) result = .success(.init(automatic: AutomaticDoseRecommendation(basalAdjustment: recommendation, direction: .from(correction: correction)))) } } catch { @@ -595,4 +816,3 @@ public struct LoopAlgorithm { ) } } - diff --git a/Sources/LoopAlgorithm/LoopMath.swift b/Sources/LoopAlgorithm/LoopMath.swift index 13889fc..7fc9e48 100644 --- a/Sources/LoopAlgorithm/LoopMath.swift +++ b/Sources/LoopAlgorithm/LoopMath.swift @@ -192,19 +192,25 @@ extension GlucoseValue { let glucoseUnit = LoopUnit.milligramsPerDeciliter let velocityUnit = GlucoseEffectVelocity.perSecondUnit - // The starting rate, which we will decay to 0 over the specified duration - let intercept = rate.doubleValue(for: velocityUnit) // mg/dL/s - let decayStartDate = startDate.addingTimeInterval(delta) - let slope = -intercept / (duration - delta) // mg/dL/s/s + let firstChange = rate.doubleValue(for: velocityUnit) * delta // mg/dL/s * s = mg/dL + let secondChange = firstChange * (1 - delta / (duration - delta)) + + // Solve for f(t) = a*t^2 + b*t + c, where t is relative to self.startDate. + // f(0) = c + // f(delta) - c = firstChange = a*delta^2 + b*delta + // f(2*delta) - c = firstChange + secondChange = 4*a*delta^2 + 2*b*delta + // --> firstChange - secondChange = 2*a*delta^2 + let c = quantity.doubleValue(for: glucoseUnit) + let a = (secondChange - firstChange) / (2 * delta * delta) // mg/dL/s^2 + let b = (firstChange + secondChange - 4 * a * delta * delta) / (2 * delta) // mg/dL/s var values = [GlucoseEffect(startDate: startDate, quantity: quantity)] - var date = decayStartDate - var lastValue = quantity.doubleValue(for: glucoseUnit) + var date = startDate.addingTimeInterval(delta) repeat { - let value = lastValue + (intercept + slope * date.timeIntervalSince(decayStartDate)) * delta + let time = min(duration, date.timeIntervalSince(self.startDate)) + let value = a * time * time + b * time + c values.append(GlucoseEffect(startDate: date, quantity: LoopQuantity(unit: glucoseUnit, doubleValue: value))) - lastValue = value date = date.addingTimeInterval(delta) } while date < endDate diff --git a/Sources/LoopAlgorithm/LoopPredictionInput.swift b/Sources/LoopAlgorithm/LoopPredictionInput.swift index 759978c..491dd4c 100644 --- a/Sources/LoopAlgorithm/LoopPredictionInput.swift +++ b/Sources/LoopAlgorithm/LoopPredictionInput.swift @@ -34,6 +34,8 @@ public struct LoopPredictionInput], algorithmEffectsOptions: AlgorithmEffectsOptions, useIntegralRetrospectiveCorrection: Bool, - includePositiveVelocityAndRC: Bool + includePositiveVelocityAndRC: Bool, + carbAbsorptionModel: CarbAbsorptionModel, + gradualTransitionsThreshold: Double? = 40.0 ) { self.glucoseHistory = glucoseHistory @@ -56,6 +60,8 @@ public struct LoopPredictionInput [Element] { + guard !isEmpty else { return [] } + // This binary-search filter is only correct when the elements are sorted + // ascending by startDate. Catch contract violations in debug builds; the + // check is compiled out of release builds, so there is no runtime cost. + assert( + zip(self, dropFirst()).allSatisfy { $0.startDate <= $1.startDate }, + "filterDateRange requires elements sorted ascending by startDate" + ) + // Lower bound: first index where element.endDate >= startDate + var lo = startIndex + if let startDate { + var l = startIndex, r = endIndex + while l < r { + let m = (l + r) / 2 + if self[m].endDate < startDate { l = m + 1 } else { r = m } + } + lo = l + } + // Upper bound: first index where element.startDate > endDate + var hi = endIndex + if let endDate { + var l = lo, r = endIndex + while l < r { + let m = (l + r) / 2 + if self[m].startDate <= endDate { l = m + 1 } else { r = m } + } + hi = l + } + guard lo < hi else { return [] } + return Array(self[lo..(_ resourceName: String) -> T { - let url = Bundle.module.url(forResource: resourceName, withExtension: "json", subdirectory: "Fixtures")! - return try! JSONSerialization.jsonObject(with: Data(contentsOf: url), options: []) as! T - } - private func loadEffectOutputFixture(_ name: String) -> [GlucoseEffect] { let fixture: [JSONDictionary] = loadFixture(name) let dateFormatter = ISO8601DateFormatter.localTimeDate(timeZone: TimeZone(secondsFromGMT: 0)!) diff --git a/Tests/LoopAlgorithmTests/CorrectionDosingTests.swift b/Tests/LoopAlgorithmTests/CorrectionDosingTests.swift index b630ac3..fa9e696 100644 --- a/Tests/LoopAlgorithmTests/CorrectionDosingTests.swift +++ b/Tests/LoopAlgorithmTests/CorrectionDosingTests.swift @@ -57,7 +57,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(recommendation.unitsPerHour, basalRate) @@ -69,7 +70,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(automaticDose.bolusUnits, 0) @@ -101,7 +103,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(recommendation.unitsPerHour, basalRate) @@ -113,7 +116,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(automaticDose.bolusUnits, 0) @@ -145,7 +149,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(recommendation.unitsPerHour, 1) @@ -157,7 +162,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(automaticDose.bolusUnits, 0) @@ -190,7 +196,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(recommendation.unitsPerHour, 1) @@ -202,7 +209,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(automaticDose.bolusUnits, 0) @@ -236,7 +244,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(recommendation.unitsPerHour, 0) @@ -248,7 +257,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(automaticDose.bolusUnits, 0) @@ -285,7 +295,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(recommendation.unitsPerHour, 1.0) @@ -297,7 +308,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(automaticDose.bolusUnits, 0) @@ -334,7 +346,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(recommendation.unitsPerHour, 3.0) @@ -346,7 +359,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(automaticDose.bolusUnits!, 0.65, accuracy: 0.05) @@ -380,7 +394,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(recommendation.unitsPerHour, 1.63, accuracy: 0.05) @@ -392,7 +407,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(automaticDose.bolusUnits!, 0.10, accuracy: 0.05) @@ -425,7 +441,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(recommendation.unitsPerHour, 1.63, accuracy: 0.05) @@ -437,7 +454,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(automaticDose.bolusUnits!, 0.10, accuracy: 0.05) @@ -470,7 +488,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(recommendation.unitsPerHour, 3.0, accuracy: 0.05) @@ -482,7 +501,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(automaticDose.bolusUnits!, 0.5, accuracy: 0.05) @@ -515,7 +535,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(recommendation.unitsPerHour, 0, accuracy: 0.05) @@ -527,7 +548,8 @@ class CorrectionDosingTests: XCTestCase { neutralBasalRate: basalRate, activeInsulin: 0, maxBolus: 6, - maxBasalRate: maxBasalRate + maxBasalRate: maxBasalRate, + maxActiveInsulin: 12 ) XCTAssertEqual(automaticDose.bolusUnits!, 0.0, accuracy: 0.05) diff --git a/Tests/LoopAlgorithmTests/Extensions/DateFormatter.swift b/Tests/LoopAlgorithmTests/Extensions/DateFormatter.swift index 449703f..2abfbd7 100644 --- a/Tests/LoopAlgorithmTests/Extensions/DateFormatter.swift +++ b/Tests/LoopAlgorithmTests/Extensions/DateFormatter.swift @@ -10,8 +10,14 @@ import Foundation // MARK: - Extensions useful in parsing fixture dates +extension TimeZone { + static var currentFixed: TimeZone { + return TimeZone(secondsFromGMT: TimeZone.current.secondsFromGMT())! + } +} + extension ISO8601DateFormatter { - static func localTimeDate(timeZone: TimeZone) -> Self { + static func localTimeDate(timeZone: TimeZone = .currentFixed) -> Self { let formatter = self.init() formatter.formatOptions = .withInternetDateTime diff --git a/Tests/LoopAlgorithmTests/FilterDateRangeTests.swift b/Tests/LoopAlgorithmTests/FilterDateRangeTests.swift new file mode 100644 index 0000000..8a1b798 --- /dev/null +++ b/Tests/LoopAlgorithmTests/FilterDateRangeTests.swift @@ -0,0 +1,138 @@ +// +// FilterDateRangeTests.swift +// LoopAlgorithm +// +// Tests for the binary-search filterDateRange overload on +// RandomAccessCollection — must produce identical output +// to the Sequence-based linear-filter version. +// + +import XCTest +@testable import LoopAlgorithm + +final class FilterDateRangeTests: XCTestCase { + + /// Minimal TimelineValue with a date range. + private struct Sample: TimelineValue, Equatable, CustomStringConvertible { + let startDate: Date + let endDate: Date + let id: Int + var description: String { "Sample(id=\(id), start=\(startDate.timeIntervalSinceReferenceDate.rounded()), end=\(endDate.timeIntervalSinceReferenceDate.rounded()))" } + } + + /// Linear-scan reference implementation (the Sequence-based version that + /// the binary-search overload must match). + private func linearFilter(_ items: [Sample], _ start: Date?, _ end: Date?) -> [Sample] { + return items.filter { value in + if let start, value.endDate < start { return false } + if let end, value.startDate > end { return false } + return true + } + } + + private func contiguousSamples(count: Int, segmentSeconds: TimeInterval = 300, + startingAt: Date = Date(timeIntervalSince1970: 1700000000)) -> [Sample] { + return (0..(_ resourceName: String) -> T { + let url = Bundle.module.url(forResource: resourceName, withExtension: "json", subdirectory: "Fixtures")! + return try! JSONSerialization.jsonObject(with: Data(contentsOf: url), options: []) as! T + } +} + +public struct GlucoseFixtureValue: GlucoseSampleValue { + public let startDate: Date + public let quantity: LoopQuantity + public let isDisplayOnly: Bool + public let wasUserEntered: Bool + public let provenanceIdentifier: String + public let condition: GlucoseCondition? + public let trendRate: LoopQuantity? + public var syncIdentifier: String? + + public init(startDate: Date, quantity: LoopQuantity, isDisplayOnly: Bool, wasUserEntered: Bool, provenanceIdentifier: String?, condition: GlucoseCondition?, trendRate: LoopQuantity?) { + self.startDate = startDate + self.quantity = quantity + self.isDisplayOnly = isDisplayOnly + self.wasUserEntered = wasUserEntered + self.provenanceIdentifier = provenanceIdentifier ?? "com.loopkit.LoopKitTests" + self.condition = condition + self.trendRate = trendRate + } +} + +extension GlucoseFixtureValue: Comparable { + public static func <(lhs: GlucoseFixtureValue, rhs: GlucoseFixtureValue) -> Bool { + return lhs.startDate < rhs.startDate + } +} + +final class GlucoseMathTests: XCTestCase { + + // MARK: - Helper to create a mock GlucoseSampleValue + + private struct MockGlucoseSample: GlucoseSampleValue { + var startDate: Date + var quantity: LoopQuantity + var provenanceIdentifier: String + var isDisplayOnly: Bool + var wasUserEntered: Bool + var condition: GlucoseCondition? + var trendRate: LoopQuantity? + } + + private func sample(at date: Date, + glucose mgdL: Double, + provenance: String = "test", + displayOnly: Bool = false) -> MockGlucoseSample { + MockGlucoseSample( + startDate: date, + quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: mgdL), + provenanceIdentifier: provenance, + isDisplayOnly: displayOnly, + wasUserEntered: false, + condition: nil, + trendRate: nil + ) + } + + func testHasGradualTransitions_SingleSample_ReturnsFalse() { + let now = Date() + let samples: [MockGlucoseSample] = [sample(at: now, glucose: 120)] + + XCTAssertFalse(samples.hasGradualTransitions(), + "A single sample should be considered a possible spike -> false") + } + + func testHasGradualTransitions_TwoSamplesWithinThreshold_ReturnsTrue() { + let base = Date() + let s1 = sample(at: base, glucose: 100) + let s2 = sample(at: base.addingTimeInterval(.minutes(5)), glucose: 110) // +10 mg/dL + + let samples = [s1, s2] + XCTAssertTrue(samples.hasGradualTransitions(), + "10 mg/dL change is less than or equal to default 40 mg/dL -> true") + } + + func testHasGradualTransitions_TwoSamplesExceedingThreshold_ReturnsFalse() { + let base = Date() + let s1 = sample(at: base, glucose: 100) + let s2 = sample(at: base.addingTimeInterval(.minutes(5)), glucose: 150) // +50 mg/dL + + let samples = [s1, s2] + XCTAssertFalse(samples.hasGradualTransitions(), + "50 mg/dL change exceeds default 40 mg/dL -> false") + } + + func testHasGradualTransitions_MultipleSamplesAllWithinThreshold_ReturnsTrue() { + let base = Date() + let samples: [MockGlucoseSample] = [ + sample(at: base, glucose: 100), + sample(at: base + .minutes(5), glucose: 115), + sample(at: base + .minutes(10), glucose: 125), + sample(at: base + .minutes(15), glucose: 118) + ] // max delta = 15 mg/dL + + XCTAssertTrue(samples.hasGradualTransitions(), + "All consecutive changes less than or equal to 40 mg/dL -> true") + } + + func testHasGradualTransitions_OneJumpExceedsThreshold_ReturnsFalse() { + let base = Date() + let samples: [MockGlucoseSample] = [ + sample(at: base, glucose: 100), + sample(at: base + .minutes(5), glucose: 115), + sample(at: base + .minutes(10), glucose: 160) // +45 mg/dL jump + ] + + XCTAssertFalse(samples.hasGradualTransitions(), + "A single jump of 45 mg/dL exceeds 40 mg/dL -> false") + } + + func testHasGradualTransitions_CustomThreshold() { + let base = Date() + let samples: [MockGlucoseSample] = [ + sample(at: base, glucose: 100), + sample(at: base + .minutes(5), glucose: 150) // +50 mg/dL + ] + + // 50 mg/dL is greater than 40, but less than or equal to 55 -> should pass with 55 + XCTAssertTrue(samples.hasGradualTransitions(gradualTransitionThreshold: 55), + "Custom threshold of 55 mg/dL allows a 50 mg/dL change") + XCTAssertFalse(samples.hasGradualTransitions(gradualTransitionThreshold: 45), + "Custom threshold of 45 mg/dL rejects a 50 mg/dL change") + } + + // MARK: - Supporting checks used by other GlucoseMath methods + + func testIsContinuous_EmptyCollection_ReturnsFalse() { + let samples: [MockGlucoseSample] = [] + XCTAssertFalse(samples.isContinuous(), + "Empty collection is not continuous") + } + + func testIsContinuous_Regular5MinSpacing_ReturnsTrue() { + let base = Date() + let samples: [MockGlucoseSample] = (0..<6).map { + sample(at: base + .minutes(Double($0 * 5)), glucose: 100 + Double($0)) + } + + XCTAssertTrue(samples.isContinuous(within: .minutes(5.5)), + "Samples every 5 min are within a 5.5 min tolerance -> true") + } + + func testIsContinuous_GapLargerThanTolerance_ReturnsFalse() { + let base = Date() + let samples: [MockGlucoseSample] = [ + sample(at: base, glucose: 100), + sample(at: base + .minutes(5), glucose: 105), + sample(at: base + .minutes(20), glucose: 110) // 15 min gap + ] + + XCTAssertFalse(samples.isContinuous(within: .minutes(6)), + "A 15 min gap exceeds a 6 min tolerance -> false") + } + + func testContainsCalibrations_NoCalibrations_ReturnsFalse() { + let samples = (0..<3).map { sample(at: Date() + .minutes(Double($0*5)), glucose: 100) } + XCTAssertFalse(samples.containsCalibrations(), + "No display-only samples -> false") + } + + func testContainsCalibrations_HasCalibration_ReturnsTrue() { + let base = Date() + let samples: [MockGlucoseSample] = [ + sample(at: base, glucose: 100), + sample(at: base + .minutes(5), glucose: 105, displayOnly: true) // calibration + ] + + XCTAssertTrue(samples.containsCalibrations(), + "One display-only sample -> true") + } + + func testHasSingleProvenance_AllSame_ReturnsTrue() { + let samples = (0..<4).map { sample(at: Date() + .minutes(Double($0*5)), glucose: 100, provenance: "CGM") } + XCTAssertTrue(samples.hasSingleProvenance, + "All samples share the same provenance -> true") + } + + func testHasSingleProvenance_DifferentProvenance_ReturnsFalse() { + let base = Date() + let samples: [MockGlucoseSample] = [ + sample(at: base, glucose: 100, provenance: "CGM"), + sample(at: base + .minutes(5), glucose: 105, provenance: "Manual") + ] + + XCTAssertFalse(samples.hasSingleProvenance, + "Different provenance identifiers -> false") + } + + func loadInputFixture(_ resourceName: String) -> [GlucoseFixtureValue] { + let fixture: [JSONDictionary] = loadFixture(resourceName) + let dateFormatter = ISO8601DateFormatter.localTimeDate() + + return fixture.map { + return GlucoseFixtureValue( + startDate: dateFormatter.date(from: $0["date"] as! String)!, + quantity: LoopQuantity(unit: LoopUnit.milligramsPerDeciliter, doubleValue: $0["amount"] as! Double), + isDisplayOnly: ($0["display_only"] as? Bool) ?? false, + wasUserEntered: ($0["user_entered"] as? Bool) ?? false, + provenanceIdentifier: $0["provenance_identifier"] as? String, + condition: ($0["condition"] as? String).flatMap { GlucoseCondition(rawValue: $0) }, + trendRate: ($0["trend_rate"] as? Double).flatMap { LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: $0) } + ) + } + } + + func loadOutputFixture(_ resourceName: String) -> [GlucoseEffect] { + let fixture: [JSONDictionary] = loadFixture(resourceName) + let dateFormatter = ISO8601DateFormatter.localTimeDate() + + return fixture.map { + return GlucoseEffect(startDate: dateFormatter.date(from: $0["date"] as! String)!, quantity: LoopQuantity(unit: LoopUnit(from: $0["unit"] as! String), doubleValue: $0["amount"] as! Double)) + } + } + + func loadEffectVelocityFixture(_ resourceName: String) -> [GlucoseEffectVelocity] { + let fixture: [JSONDictionary] = loadFixture(resourceName) + let dateFormatter = ISO8601DateFormatter.localTimeDate() + + return fixture.map { + return GlucoseEffectVelocity(startDate: dateFormatter.date(from: $0["startDate"] as! String)!, endDate: dateFormatter.date(from: $0["endDate"] as! String)!, quantity: LoopQuantity(unit: LoopUnit(from: $0["unit"] as! String), doubleValue:$0["value"] as! Double)) + } + } + + func testMomentumEffectForBouncingGlucose() { + let input = loadInputFixture("momentum_effect_bouncing_glucose_input") + let output = loadOutputFixture("momentum_effect_bouncing_glucose_output") + + let effects = input.linearMomentumEffect(duration: .minutes(30)) + let unit = LoopUnit.milligramsPerDeciliter + + XCTAssertEqual(output.count, effects.count) + + for (expected, calculated) in zip(output, effects) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: unit), calculated.quantity.doubleValue(for: unit), accuracy: Double(Float.ulpOfOne)) + } + } + + func testMomentumEffectForRisingGlucose() { + let input = loadInputFixture("momentum_effect_rising_glucose_input") + let output = loadOutputFixture("momentum_effect_rising_glucose_output") + + let effects = input.linearMomentumEffect(duration: .minutes(30)) + let unit = LoopUnit.milligramsPerDeciliter + + XCTAssertEqual(output.count, effects.count) + + for (expected, calculated) in zip(output, effects) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: unit), calculated.quantity.doubleValue(for: unit), accuracy: Double(Float.ulpOfOne)) + } + } + + func testMomentumEffectForRisingGlucoseDoubles() { + let input = loadInputFixture("momentum_effect_rising_glucose_double_entries_input") + let output = loadOutputFixture("momentum_effect_rising_glucose_output") + + let effects = input.linearMomentumEffect(duration: .minutes(30)) + let unit = LoopUnit.milligramsPerDeciliter + + XCTAssertEqual(output.count, effects.count) + + for (expected, calculated) in zip(output, effects) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: unit), calculated.quantity.doubleValue(for: unit), accuracy: Double(Float.ulpOfOne)) + } + } + + func testMomentumEffectForFallingGlucose() { + let input = loadInputFixture("momentum_effect_falling_glucose_input") + let output = loadOutputFixture("momentum_effect_falling_glucose_output") + + let effects = input.linearMomentumEffect(duration: .minutes(30)) + let unit = LoopUnit.milligramsPerDeciliter + + XCTAssertEqual(output.count, effects.count) + + for (expected, calculated) in zip(output, effects) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: unit), calculated.quantity.doubleValue(for: unit), accuracy: Double(Float.ulpOfOne)) + } + } + + func testMomentumEffectForFallingGlucoseDuplicates() { + var input = loadInputFixture("momentum_effect_falling_glucose_input") + let output = loadOutputFixture("momentum_effect_falling_glucose_output") + input.append(contentsOf: input) + input.sort(by: <) + + let effects = input.linearMomentumEffect(duration: .minutes(30)) + let unit = LoopUnit.milligramsPerDeciliter + + XCTAssertEqual(output.count, effects.count) + + for (expected, calculated) in zip(output, effects) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: unit), calculated.quantity.doubleValue(for: unit), accuracy: Double(Float.ulpOfOne)) + } + } + + func testMomentumEffectForStableGlucose() { + let input = loadInputFixture("momentum_effect_stable_glucose_input") + let output = loadOutputFixture("momentum_effect_stable_glucose_output") + + let effects = input.linearMomentumEffect(duration: .minutes(30)) + let unit = LoopUnit.milligramsPerDeciliter + + XCTAssertEqual(output.count, effects.count) + + for (expected, calculated) in zip(output, effects) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: unit), calculated.quantity.doubleValue(for: unit), accuracy: Double(Float.ulpOfOne)) + } + } + + func testMomentumEffectForDuplicateGlucose() { + let input = loadInputFixture("momentum_effect_duplicate_glucose_input") + let effects = input.linearMomentumEffect() + + XCTAssertEqual(0, effects.count) + } + + func testMomentumEffectForEmptyGlucose() { + let input = [GlucoseFixtureValue]() + let effects = input.linearMomentumEffect() + + XCTAssertEqual(0, effects.count) + } + + func testMomentumEffectForSpacedOutGlucose() { + let input = loadInputFixture("momentum_effect_incomplete_glucose_input") + let effects = input.linearMomentumEffect() + + XCTAssertEqual(0, effects.count) + } + + func testMomentumEffectForTooFewGlucose() { + let input = loadInputFixture("momentum_effect_bouncing_glucose_input")[0...1] + let effects = input.linearMomentumEffect() + + XCTAssertEqual(0, effects.count) + } + + func testMomentumEffectForDisplayOnlyGlucose() { + let input = loadInputFixture("momentum_effect_display_only_glucose_input") + let effects = input.linearMomentumEffect() + + XCTAssertEqual(0, effects.count) + } + + func testMomentumEffectForMixedProvenanceGlucose() { + let input = loadInputFixture("momentum_effect_mixed_provenance_glucose_input") + let effects = input.linearMomentumEffect() + + XCTAssertEqual(0, effects.count) + } + + func testCounteractionEffectsForFallingGlucose() { + let input = loadInputFixture("counteraction_effect_falling_glucose_input") + let insulinEffect = loadOutputFixture("counteraction_effect_falling_glucose_insulin") + let output = loadEffectVelocityFixture("counteraction_effect_falling_glucose_output") + + let effects = input.counteractionEffects(to: insulinEffect) + let unit = LoopUnit.milligramsPerDeciliterPerMinute + + XCTAssertEqual(output.count, effects.count) + + for (expected, calculated) in zip(output, effects) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: unit), calculated.quantity.doubleValue(for: unit), accuracy: Double(Float.ulpOfOne)) + } + } + + func testCounteractionEffectsForFallingGlucoseDuplicates() { + var input = loadInputFixture("counteraction_effect_falling_glucose_input") + input.append(contentsOf: input) + input.sort(by: <) + let insulinEffect = loadOutputFixture("counteraction_effect_falling_glucose_insulin") + let output = loadEffectVelocityFixture("counteraction_effect_falling_glucose_output") + + let effects = input.counteractionEffects(to: insulinEffect) + let unit = LoopUnit.milligramsPerDeciliterPerMinute + + XCTAssertEqual(output.count, effects.count) + + for (expected, calculated) in zip(output, effects) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.quantity.doubleValue(for: unit), calculated.quantity.doubleValue(for: unit), accuracy: Double(Float.ulpOfOne)) + } + } + + func testCounteractionEffectsForFallingGlucoseAlmostDuplicates() { + let input = loadInputFixture("counteraction_effect_falling_glucose_almost_duplicates_input") + let insulinEffect = loadOutputFixture("counteraction_effect_falling_glucose_insulin") + let output = loadEffectVelocityFixture("counteraction_effect_falling_glucose_almost_duplicates_output") + + let effects = input.counteractionEffects(to: insulinEffect) + let unit = LoopUnit.milligramsPerDeciliterPerMinute + + XCTAssertEqual(output.count, effects.count) + + for (expected, calculated) in zip(output, effects) { + XCTAssertEqual(expected.startDate, calculated.startDate) + XCTAssertEqual(expected.endDate, calculated.endDate) + XCTAssertEqual(expected.quantity.doubleValue(for: unit), calculated.quantity.doubleValue(for: unit), accuracy: Double(Float.ulpOfOne)) + } + } + + func testCounteractionEffectsForNoGlucose() { + let input = [GlucoseFixtureValue]() + let insulinEffect = loadOutputFixture("counteraction_effect_falling_glucose_insulin") + let output = [GlucoseEffectVelocity]() + + let effects = input.counteractionEffects(to: insulinEffect) + + XCTAssertEqual(output.count, effects.count) + } + +} diff --git a/Tests/LoopAlgorithmTests/InsulinMathBasalSegmentRippleTests.swift b/Tests/LoopAlgorithmTests/InsulinMathBasalSegmentRippleTests.swift new file mode 100644 index 0000000..14546e1 --- /dev/null +++ b/Tests/LoopAlgorithmTests/InsulinMathBasalSegmentRippleTests.swift @@ -0,0 +1,83 @@ +// +// InsulinMathBasalSegmentRippleTests.swift +// +// Regression test for the delta-scale IOB ripple that `insulinOnBoard` produced +// for basal segments longer than one `delta` (i.e. essentially every real temp +// basal / suspend). `continuousDeliveryInsulinOnBoard` previously quantized its +// integration bound to the delta grid, stepping IOB at each delta boundary. It +// now integrates continuously up to `time`, so a single long segment yields the +// same smooth IOB as the equivalent finely-subdivided delivery. +// + +import XCTest +@testable import LoopAlgorithm + +final class InsulinMathBasalSegmentRippleTests: XCTestCase { + + private let fmt: DateFormatter = { + let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + f.locale = Locale(identifier: "en_US_POSIX"); f.timeZone = TimeZone(secondsFromGMT: 0) + return f + }() + + private func curvatureRMS(_ a: [Double]) -> Double { + guard a.count >= 3 else { return 0 } + var d2: [Double] = [] + for i in 2.. Double { + let n = Swift.min(a.count, b.count) + var m = 0.0 + for i in 0.. BasalRelativeDose { + let hrs = b.timeIntervalSince(a) / 3600 + return BasalRelativeDose(type: .basal(scheduledRate: schedRate), + startDate: a, endDate: b, volume: deliveredRate * hrs) + } + let single = [basal(start, start.addingTimeInterval(segMin * 60))] + let fine = (0.. [Double] { + doses.insulinOnBoardTimeline(from: start, to: end).map { $0.value } + } + let iobSingle = iob(single) + let iobFine = iob(fine) + + let maxDiff = maxAbsDiff(iobSingle, iobFine) + let curvSingle = curvatureRMS(iobSingle) + let curvFine = curvatureRMS(iobFine) + print(String(format: "\n[ripple-fix] single-vs-fine maxΔ=%.4f U curvature single=%.5f fine=%.5f\n", + maxDiff, curvSingle, curvFine)) + + // The single long segment is now as smooth as the finely-subdivided + // delivery (no delta-scale ripple). Pre-fix curvature was ~0.18 (>20×). + XCTAssertLessThan(curvSingle, 3 * curvFine + 0.005, + "long-segment IOB should be ~as smooth as the fine subdivision") + // And close to the finely-subdivided equivalent delivery. + XCTAssertLessThan(maxDiff, 0.05, "long-segment IOB should match the fine subdivision") + } +} diff --git a/Tests/LoopAlgorithmTests/InsulinMathTests.swift b/Tests/LoopAlgorithmTests/InsulinMathTests.swift index b38046f..bc5b47a 100644 --- a/Tests/LoopAlgorithmTests/InsulinMathTests.swift +++ b/Tests/LoopAlgorithmTests/InsulinMathTests.swift @@ -27,12 +27,6 @@ class InsulinMathTests: XCTestCase { print("\n\n") } - - public func loadFixture(_ resourceName: String) -> T { - let url = Bundle.module.url(forResource: resourceName, withExtension: "json", subdirectory: "Fixtures")! - return try! JSONSerialization.jsonObject(with: Data(contentsOf: url), options: []) as! T - } - func loadGlucoseEffectFixture(_ resourceName: String) -> [GlucoseEffect] { let fixture: [JSONDictionary] = loadFixture(resourceName) let dateFormatter = ISO8601DateFormatter.localTimeDate(timeZone: fixtureTimeZone) @@ -243,17 +237,10 @@ class InsulinMathTests: XCTestCase { ) ] + // A well-formed basal schedule: sorted ascending, contiguous, and non-overlapping. let basal = [ AbsoluteScheduleValue( startDate: startDate, - endDate: dateFormatter.date(from: "2015-10-15T20:30:00")!, - value: 1.0), - AbsoluteScheduleValue( - startDate: dateFormatter.date(from: "2015-10-15T20:30:00")!, - endDate: dateFormatter.date(from: "2015-10-15T21:00:00")!, - value: 0.8), - AbsoluteScheduleValue( - startDate: dateFormatter.date(from: "2015-10-15T21:00:00")!, endDate: dateFormatter.date(from: "2015-10-15T18:30:00")!, value: 1.0), AbsoluteScheduleValue( @@ -262,6 +249,14 @@ class InsulinMathTests: XCTestCase { value: 0.8), AbsoluteScheduleValue( startDate: dateFormatter.date(from: "2015-10-15T19:00:00")!, + endDate: dateFormatter.date(from: "2015-10-15T20:30:00")!, + value: 1.0), + AbsoluteScheduleValue( + startDate: dateFormatter.date(from: "2015-10-15T20:30:00")!, + endDate: dateFormatter.date(from: "2015-10-15T21:00:00")!, + value: 0.8), + AbsoluteScheduleValue( + startDate: dateFormatter.date(from: "2015-10-15T21:00:00")!, endDate: endDate, value: 1.0), ] diff --git a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift index f704dac..0784e30 100644 --- a/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift +++ b/Tests/LoopAlgorithmTests/LoopAlgorithmTests.swift @@ -11,6 +11,20 @@ import XCTest final class LoopAlgorithmTests: XCTestCase { + private func sample(at date: Date, + glucose mgdL: Double, + provenance: String = "test", + displayOnly: Bool = false) -> FixtureGlucoseSample { + FixtureGlucoseSample( + provenanceIdentifier: provenance, + startDate: date, + quantity: LoopQuantity(unit: .milligramsPerDeciliter, doubleValue: mgdL), + isDisplayOnly: displayOnly, + wasUserEntered: false + ) + } + + func loadScenario(_ name: String) -> (input: AlgorithmInputFixture, recommendation: LoopAlgorithmDoseRecommendation) { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 @@ -62,7 +76,7 @@ final class LoopAlgorithmTests: XCTestCase { let output = LoopAlgorithm.run(input: input) XCTAssertEqual(output.activeCarbs, 50) - XCTAssertEqual(output.recommendation!.manual!.amount, 5.83, accuracy: 0.01) + XCTAssertEqual(output.recommendation!.manual!.amount, 5.86, accuracy: 0.01) } @@ -98,8 +112,8 @@ final class LoopAlgorithmTests: XCTestCase { XCTAssertEqual(outputA.effects.insulin.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 0.0) XCTAssertEqual(outputB.effects.insulin.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 0.0) - XCTAssertEqual(outputA.effects.retrospectiveCorrection.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 165) - XCTAssertEqual(outputB.effects.retrospectiveCorrection.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 165) + XCTAssertEqual(outputA.effects.retrospectiveCorrection.last?.quantity.doubleValue(for: .milligramsPerDeciliter) ?? 0, 165, accuracy: 0.05) + XCTAssertEqual(outputB.effects.retrospectiveCorrection.last?.quantity.doubleValue(for: .milligramsPerDeciliter) ?? 0, 165, accuracy: 0.05) // These tests fail, because the momentum effect is *not* time independent yet. // Even though all the input data is the same (just shifted in time), momentum effect varies in relation to how offset @@ -176,18 +190,46 @@ final class LoopAlgorithmTests: XCTestCase { } } + func testSpuriousReadingDisablesRCAndMomentum() { + + let base = ISO8601DateFormatter().date(from: "2024-01-03T12:00:00+0000")! + + var input = AlgorithmInputFixture.mock(for: base.addingTimeInterval(.minutes(30))) + + input.glucoseHistory = [ + sample(at: base, glucose: 100), + sample(at: base + .minutes(5), glucose: 115), + sample(at: base + .minutes(10), glucose: 125), + sample(at: base + .minutes(15), glucose: 179), // +45 jump + sample(at: base + .minutes(20), glucose: 148), + sample(at: base + .minutes(25), glucose: 158), + sample(at: base + .minutes(30), glucose: 168) + ] + + // With a spurious reading over the gradualTransitionsThreshold of 40 mg/dL, momentum and rc are turned off, and the forecast is lower. + var output = LoopAlgorithm.run(input: input) + XCTAssertEqual(output.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 164.5, accuracy: 0.1) + + // With the threshold set high, our spurious reading is still considered gradual, and RC and momentum will be used, resulting in a higher forecast. + input.gradualTransitionsThreshold = 60 + output = LoopAlgorithm.run(input: input) + XCTAssertEqual(output.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 216, accuracy: 0.1) + + } + + func testMealBolusScenario() { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 - let url = Bundle.module.url(forResource: "meal-bolus", withExtension: "json", subdirectory: "Fixtures")! + let url = Bundle.module.url(forResource: "meal-bolus-isf", withExtension: "json", subdirectory: "Fixtures")! var input = try! decoder.decode(AlgorithmInputFixture.self, from: try! Data(contentsOf: url)) let output = LoopAlgorithm.run(input: input) // Should recommend bolus to cover meal - XCTAssertEqual(output.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 274, accuracy: 0.1) - XCTAssertEqual(output.recommendation!.manual!.amount, 1.9, accuracy: 0.01) + XCTAssertEqual(output.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 274.14, accuracy: 0.1) + XCTAssertEqual(output.recommendation!.manual!.amount, 1.91, accuracy: 0.01) // Now check forecast if bolus recommendation is accepted and delivered. input.doses.append( @@ -203,11 +245,40 @@ final class LoopAlgorithmTests: XCTestCase { // 150 mg/dL is the middle of the target range XCTAssertEqual(output2.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 150, accuracy: 0.1) - } + func testMealBolusNoMidAbsorptionISFScenario() { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let url = Bundle.module.url( + forResource: "meal-bolus-no-isf", withExtension: "json", subdirectory: "Fixtures")! + var input = try! decoder.decode( + AlgorithmInputFixture.self, from: try! Data(contentsOf: url)) + + let output = LoopAlgorithm.run(input: input) + + // Should recommend bolus to cover meal + XCTAssertEqual(output.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 269.15, accuracy: 0.1) + XCTAssertEqual(output.recommendation!.manual!.amount, 2.16, accuracy: 0.01) + + // Now check forecast if bolus recommendation is accepted and delivered. + input.doses.append( + .init( + deliveryType: .bolus, + startDate: input.predictionStart, + endDate: input.predictionStart.addingTimeInterval(30), + volume: output.recommendation!.manual!.amount + ) + ) + + let output2 = LoopAlgorithm.run(input: input) + + // 150 mg/dL is the middle of the target range + XCTAssertEqual(output2.predictedGlucose.last!.quantity.doubleValue(for: .milligramsPerDeciliter), 150, accuracy: 0.1) + } - func testMidAborptionISFFlag() { + func testMidAbsorptionISFFlag() { let now = ISO8601DateFormatter().date(from: "2024-01-03T00:00:00+0000")! var input = AlgorithmInputFixture.mock(for: now) @@ -258,7 +329,7 @@ final class LoopAlgorithmTests: XCTestCase { var recommendedBolus = output.recommendation!.automatic?.bolusUnits var activeInsulin = output.activeInsulin! XCTAssertEqual(activeInsulin, 8.0) - XCTAssertEqual(recommendedBolus!, 1.66, accuracy: 0.01) + XCTAssertEqual(recommendedBolus!, 1.69, accuracy: 0.01) // Now try with maxBolus of 4; should not recommend any more insulin, as we're at our max iob input.maxBolus = 4 @@ -326,12 +397,12 @@ final class LoopAlgorithmTests: XCTestCase { // Without mid-absorption ISF input.useMidAbsorptionISF = false var output = LoopAlgorithm.run(input: input) - XCTAssertEqual(2.58, output.recommendation!.manual!.amount, accuracy: 0.01) + XCTAssertEqual(2.73, output.recommendation!.manual!.amount, accuracy: 0.01) // With mid-absorption ISF input.useMidAbsorptionISF = true output = LoopAlgorithm.run(input: input) - XCTAssertEqual(1.41, output.recommendation!.manual!.amount, accuracy: 0.01) + XCTAssertEqual(1.49, output.recommendation!.manual!.amount, accuracy: 0.01) } func testIncompleteISFTimelineDetected() { diff --git a/Tests/LoopAlgorithmTests/LoopMathTests.swift b/Tests/LoopAlgorithmTests/LoopMathTests.swift new file mode 100644 index 0000000..ff3bac5 --- /dev/null +++ b/Tests/LoopAlgorithmTests/LoopMathTests.swift @@ -0,0 +1,48 @@ +// +// LoopMathTests.swift +// LoopAlgorithm +// + +import XCTest +@testable import LoopAlgorithm + +class LoopMathTests: XCTestCase { + + /// `decayEffect` previously accumulated the decay step-by-step starting from + /// the simulation-grid boundary (the sample's `startDate` floored to `delta`), + /// so two samples sitting on opposite sides of a 5-minute boundary produced + /// different effect values at the same future absolute timestamp. With the + /// continuous formulation, a sub-`delta` shift in the input timestamp only + /// shifts the output series by one slot and leaves shared-timestamp values + /// effectively unchanged. + func testDecayEffectIsContinuousAcrossSimulationBoundary() { + let calendar = Calendar(identifier: .gregorian) + let alignedDate = calendar.date(from: DateComponents(year: 2024, month: 1, day: 1, hour: 10, minute: 15, second: 0))! + let shiftedDate = alignedDate.addingTimeInterval(-1e-6) + + let rate = LoopQuantity(unit: .milligramsPerDeciliterPerMinute, doubleValue: -0.5) + + let alignedSample = FixtureGlucoseSample(startDate: alignedDate, quantity: .glucose(100)) + let shiftedSample = FixtureGlucoseSample(startDate: shiftedDate, quantity: .glucose(100)) + + let alignedEffects = alignedSample.decayEffect(atRate: rate, for: .minutes(30)) + let shiftedEffects = shiftedSample.decayEffect(atRate: rate, for: .minutes(30)) + + // The shifted sample's floored start lands one `delta` earlier, so its + // series has one extra leading entry equal to the sample's value. + XCTAssertEqual(shiftedEffects.count, alignedEffects.count + 1) + XCTAssertEqual(shiftedEffects[0].quantity.doubleValue(for: .milligramsPerDeciliter), 100, accuracy: 1e-9) + + // Shared timestamps should produce shared values. + let mgdl = LoopUnit.milligramsPerDeciliter + for (index, aligned) in alignedEffects.enumerated() { + let shifted = shiftedEffects[index + 1] + XCTAssertEqual(aligned.startDate, shifted.startDate) + XCTAssertEqual( + aligned.quantity.doubleValue(for: mgdl), + shifted.quantity.doubleValue(for: mgdl), + accuracy: 1e-6 + ) + } + } +} diff --git a/Tests/LoopAlgorithmTests/Mocks/IntegralRetrospectiveCorrectionTests.swift b/Tests/LoopAlgorithmTests/Mocks/IntegralRetrospectiveCorrectionTests.swift index da43231..d30ba76 100644 --- a/Tests/LoopAlgorithmTests/Mocks/IntegralRetrospectiveCorrectionTests.swift +++ b/Tests/LoopAlgorithmTests/Mocks/IntegralRetrospectiveCorrectionTests.swift @@ -42,7 +42,84 @@ final class IntegralRetrospectiveCorrectionTests: XCTestCase { retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval ) - XCTAssertEqual(effect.last?.quantity.doubleValue(for: .milligramsPerDeciliter), 110) + XCTAssertEqual(effect.last?.quantity.doubleValue(for: .milligramsPerDeciliter) ?? 0, 110, accuracy: 0.05) XCTAssertEqual(effect.last?.startDate, dateFormatter.date(from: "2015-07-13T13:00:00")!) } + + // MARK: - Correction-rate clamp (settings-free) + // + // A physiological ceiling on the RC correction rate replaces the deployed-LoopKit + // integral clamp, which scaled the bound by ISF×basal and the target range. See + // `defaultMaxCorrectionVelocity`. + + private let capMgdlPerSec = 4.0 / 60.0 + + /// `count` contiguous 5-min discrepancies of `valuePerStep` mg/dL, ending at `endingAt`. + private func windup(endingAt: Date, count: Int, valuePerStep: Double) -> [GlucoseChange] { + (0.. [GlucoseEffect] { + irc.computeEffect( + startingAt: from, + retrospectiveGlucoseDiscrepanciesSummed: discrepancies, + recencyInterval: TimeInterval(minutes: 15), + retrospectiveCorrectionGroupingInterval: LoopMath.retrospectiveCorrectionGroupingInterval) + } + + func testCorrectionRateClampedToCeiling() { + let start = dateFormatter.date(from: "2015-07-13T12:00:00")! + let g = SimpleGlucoseValue(startDate: start, quantity: .glucose(150)) + // Huge sustained under-prediction so the correction rate exceeds the ceiling. + let discrepancies = windup(endingAt: start, count: 18, valuePerStep: 250) + + let unclamped = IntegralRetrospectiveCorrection( + effectDuration: LoopMath.retrospectiveCorrectionEffectDuration, maxCorrectionVelocity: nil) + let unclampedEffect = compute(unclamped, from: g, discrepancies) + XCTAssertGreaterThan(unclamped.correctionVelocity!.doubleValue(for: .milligramsPerDeciliterPerSecond), + capMgdlPerSec, "windup must exceed the ceiling so the clamp is exercised") + + // Default ceiling (4 mg/dL/min). + let clamped = IntegralRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration) + let clampedEffect = compute(clamped, from: g, discrepancies) + XCTAssertEqual(clamped.correctionVelocity!.doubleValue(for: .milligramsPerDeciliterPerSecond), + capMgdlPerSec, accuracy: 1e-9) + // Clamping the rate reduces the forecast excursion. + XCTAssertLessThan(clampedEffect.last!.quantity.doubleValue(for: .milligramsPerDeciliter), + unclampedEffect.last!.quantity.doubleValue(for: .milligramsPerDeciliter)) + } + + func testCorrectionRateClampIsSymmetricForNegativeDiscrepancies() { + let start = dateFormatter.date(from: "2015-07-13T12:00:00")! + let g = SimpleGlucoseValue(startDate: start, quantity: .glucose(90)) + let discrepancies = windup(endingAt: start, count: 18, valuePerStep: -250) + + let clamped = IntegralRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration) + _ = compute(clamped, from: g, discrepancies) + XCTAssertEqual(clamped.correctionVelocity!.doubleValue(for: .milligramsPerDeciliterPerSecond), + -capMgdlPerSec, accuracy: 1e-9) + } + + func testCorrectionRateUnclampedWhenBelowCeiling() { + let start = dateFormatter.date(from: "2015-07-13T12:00:00")! + let g = SimpleGlucoseValue(startDate: start, quantity: .glucose(120)) + // Small windup: rate stays under the ceiling, so the clamp must not alter anything. + let discrepancies = windup(endingAt: start, count: 3, valuePerStep: 5) + + let clamped = IntegralRetrospectiveCorrection(effectDuration: LoopMath.retrospectiveCorrectionEffectDuration) + let e1 = compute(clamped, from: g, discrepancies) + XCTAssertLessThan(abs(clamped.correctionVelocity!.doubleValue(for: .milligramsPerDeciliterPerSecond)), + capMgdlPerSec) + + let noClamp = IntegralRetrospectiveCorrection( + effectDuration: LoopMath.retrospectiveCorrectionEffectDuration, maxCorrectionVelocity: nil) + let e2 = compute(noClamp, from: g, discrepancies) + XCTAssertEqual(e1.last!.quantity.doubleValue(for: .milligramsPerDeciliter), + e2.last!.quantity.doubleValue(for: .milligramsPerDeciliter), accuracy: 1e-9) + } } diff --git a/Tests/LoopAlgorithmTests/PrecomputedInsulinInputTests.swift b/Tests/LoopAlgorithmTests/PrecomputedInsulinInputTests.swift new file mode 100644 index 0000000..25f6096 --- /dev/null +++ b/Tests/LoopAlgorithmTests/PrecomputedInsulinInputTests.swift @@ -0,0 +1,215 @@ +// PrecomputedInsulinInputTests.swift +// +// Verifies that generatePrediction(precomputedInsulin:) produces bit-identical +// output to the standard overload, and that the pre-built effects fast-path +// also matches. + +import XCTest +@testable import LoopAlgorithm + +final class PrecomputedInsulinInputTests: XCTestCase { + + // MARK: - Fixture loading (mirrors LoopAlgorithmTests.swift) + + typealias Input = LoopPredictionInput + + private func loadInput() throws -> Input { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let url = Bundle.module.url( + forResource: "live_capture_input", + withExtension: "json", + subdirectory: "Fixtures" + )! + return try decoder.decode(Input.self, from: Data(contentsOf: url)) + } + + // MARK: - Test: annotated-only fast path matches standard output + + func testPrecomputedAnnotationMatchesStandard() throws { + let input = try loadInput() + let start = input.glucoseHistory.last!.startDate + + // Standard prediction (full annotation inside generatePrediction) + let standard = LoopAlgorithm.generatePrediction( + start: start, + glucoseHistory: input.glucoseHistory, + doses: input.doses, + carbEntries: input.carbEntries, + basal: input.basal, + sensitivity: input.sensitivity, + carbRatio: input.carbRatio, + useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection + ) + + // Pre-annotate once (ISF-independent); no effects → standard inner glucoseEffects path + let precomputed = PrecomputedInsulinInput.annotate(doses: input.doses, basal: input.basal) + + let fast = LoopAlgorithm.generatePrediction( + start: start, + glucoseHistory: input.glucoseHistory, + precomputedInsulin: precomputed, + carbEntries: input.carbEntries, + sensitivity: input.sensitivity, + carbRatio: input.carbRatio, + useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection + ) + + XCTAssertEqual(standard.glucose.count, fast.glucose.count, + "Prediction point count should match") + for (s, f) in zip(standard.glucose, fast.glucose) { + XCTAssertEqual(s.startDate, f.startDate) + XCTAssertEqual( + s.quantity.doubleValue(for: .milligramsPerDeciliter), + f.quantity.doubleValue(for: .milligramsPerDeciliter), + accuracy: 0.001, + "Mismatch at \(s.startDate)" + ) + } + XCTAssertEqual(standard.activeInsulin ?? 0, fast.activeInsulin ?? 0, accuracy: 0.001) + } + + // MARK: - Test: pre-built effects path compiles and returns a prediction + // + // Bit-identical output is NOT guaranteed (see PrecomputedInsulinInput.insulinEffects + // for the timeline-snapping caveat). This test only verifies that the fast + // path runs without crashing and returns the expected number of points. + + func testPrebuiltEffectsFastPathRunsWithoutError() throws { + let input = try loadInput() + let start = input.glucoseHistory.last!.startDate + + let standard = LoopAlgorithm.generatePrediction( + start: start, + glucoseHistory: input.glucoseHistory, + doses: input.doses, + carbEntries: input.carbEntries, + basal: input.basal, + sensitivity: input.sensitivity, + carbRatio: input.carbRatio, + useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection + ) + + // ISF-sweep pattern: annotate once, compute effects per ISF value + let precomputed = PrecomputedInsulinInput + .annotate(doses: input.doses, basal: input.basal) + .withEffects(sensitivity: input.sensitivity) + + let fast = LoopAlgorithm.generatePrediction( + start: start, + glucoseHistory: input.glucoseHistory, + precomputedInsulin: precomputed, + carbEntries: input.carbEntries, + sensitivity: input.sensitivity, + carbRatio: input.carbRatio, + useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection + ) + + XCTAssertEqual(standard.glucose.count, fast.glucose.count, + "Pre-built effects path should return the same number of prediction points") + XCTAssertNotNil(fast.activeInsulin) + } + + // MARK: - Test: sliced annotated doses round-trip + + func testSlicedAnnotatedDosesMatchStandard() throws { + let input = try loadInput() + let start = input.glucoseHistory.last!.startDate + + let standard = LoopAlgorithm.generatePrediction( + start: start, + glucoseHistory: input.glucoseHistory, + doses: input.doses, + carbEntries: input.carbEntries, + basal: input.basal, + sensitivity: input.sensitivity, + carbRatio: input.carbRatio, + useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection + ) + + // Simulate EvalCore: build once, then pass the (unsliced) annotated set + let sliced = PrecomputedInsulinInput.annotate(doses: input.doses, basal: input.basal) + + let fromSlice = LoopAlgorithm.generatePrediction( + start: start, + glucoseHistory: input.glucoseHistory, + precomputedInsulin: sliced, + carbEntries: input.carbEntries, + sensitivity: input.sensitivity, + carbRatio: input.carbRatio, + useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection + ) + + for (s, f) in zip(standard.glucose, fromSlice.glucose) { + XCTAssertEqual( + s.quantity.doubleValue(for: .milligramsPerDeciliter), + f.quantity.doubleValue(for: .milligramsPerDeciliter), + accuracy: 0.001 + ) + } + } + + // MARK: - Test: ISF sweep pattern — annotate once, withEffects per multiplier + + func testISFSweepPattern() throws { + let input = try loadInput() + let start = input.glucoseHistory.last!.startDate + + // Annotate ONCE — shared across all ISF values + let base = PrecomputedInsulinInput.annotate(doses: input.doses, basal: input.basal) + + let multipliers: [Double] = [0.7, 0.8, 0.9, 1.0, 1.1, 1.2, 1.3] + + for multiplier in multipliers { + // Scale ISF — O(n_isf_segments), negligible. + // Preserve whatever unit the fixture uses by scaling the raw double + // and re-wrapping in the same unit. + let scaledSensitivity = input.sensitivity.map { entry -> AbsoluteScheduleValue in + let unit = entry.value.unit + let scaled = entry.value.doubleValue(for: unit) * multiplier + return AbsoluteScheduleValue( + startDate: entry.startDate, + endDate: entry.endDate, + value: LoopQuantity(unit: unit, doubleValue: scaled) + ) + } + + // Compute effects once for this ISF value — O(D × T), not per-step + let precomputed = base.withEffects(sensitivity: scaledSensitivity) + XCTAssertNotNil(precomputed.insulinEffects, "withEffects should populate insulinEffects") + + // Verify it produces the same result as the standard path with the same scaled ISF + let standard = LoopAlgorithm.generatePrediction( + start: start, + glucoseHistory: input.glucoseHistory, + doses: input.doses, + carbEntries: input.carbEntries, + basal: input.basal, + sensitivity: scaledSensitivity, + carbRatio: input.carbRatio, + useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection + ) + + let fast = LoopAlgorithm.generatePrediction( + start: start, + glucoseHistory: input.glucoseHistory, + precomputedInsulin: precomputed, + carbEntries: input.carbEntries, + sensitivity: scaledSensitivity, + carbRatio: input.carbRatio, + useIntegralRetrospectiveCorrection: input.useIntegralRetrospectiveCorrection + ) + + XCTAssertEqual(standard.glucose.count, fast.glucose.count, + "Count mismatch at ISF multiplier \(multiplier)") + for (s, f) in zip(standard.glucose, fast.glucose) { + XCTAssertEqual( + s.quantity.doubleValue(for: .milligramsPerDeciliter), + f.quantity.doubleValue(for: .milligramsPerDeciliter), + accuracy: 0.001, + "ISF \(multiplier)×: mismatch at \(s.startDate)" + ) + } + } + } +} diff --git a/Tests/LoopAlgorithmTests/StandardRetrospectiveCorrectionTests.swift b/Tests/LoopAlgorithmTests/StandardRetrospectiveCorrectionTests.swift new file mode 100644 index 0000000..9703f84 --- /dev/null +++ b/Tests/LoopAlgorithmTests/StandardRetrospectiveCorrectionTests.swift @@ -0,0 +1,233 @@ +// +// StandardRetrospectiveCorrectionTests.swift +// LoopAlgorithm +// +// Unit tests for StandardRetrospectiveCorrection (the P-only retrospective +// correction controller). Standard RC takes the most-recent +// prediction-vs-actual discrepancy and projects it forward as a decaying +// glucose effect over `effectDuration` (default 60 min). +// + +import XCTest +@testable import LoopAlgorithm + +final class StandardRetrospectiveCorrectionTests: XCTestCase { + + private let unit = LoopUnit.milligramsPerDeciliter + + // MARK: - Helpers + + private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + }() + + private func date(_ s: String) -> Date { + return dateFormatter.date(from: s)! + } + + private func change(from start: Date, to end: Date, mgdl: Double) -> GlucoseChange { + return GlucoseChange(startDate: start, endDate: end, quantity: .glucose(mgdl)) + } + + private func makeRC() -> StandardRetrospectiveCorrection { + return StandardRetrospectiveCorrection( + effectDuration: LoopMath.retrospectiveCorrectionEffectDuration + ) + } + + // MARK: - Recency gating + + func testStaleDiscrepancyClearsEffect() { + // Discrepancy ends > recencyInterval before the starting glucose date. + // Effect must be empty + totalGlucoseCorrectionEffect must be nil. + let glucoseDate = date("2025-01-01T12:00:00") + let startingGlucose = SimpleGlucoseValue(startDate: glucoseDate, quantity: .glucose(100)) + let discrepancy = change( + from: glucoseDate.addingTimeInterval(-.minutes(60)), + to: glucoseDate.addingTimeInterval(-.minutes(30)), + mgdl: 10 + ) + let rc = makeRC() + // recencyInterval = 15 min, but discrepancy ends 30 min ago → stale + let effect = rc.computeEffect( + startingAt: startingGlucose, + retrospectiveGlucoseDiscrepanciesSummed: [discrepancy], + recencyInterval: .minutes(15), + retrospectiveCorrectionGroupingInterval: .minutes(30) + ) + XCTAssertTrue(effect.isEmpty, "stale discrepancy should produce no effect") + XCTAssertNil(rc.totalGlucoseCorrectionEffect) + } + + func testNilDiscrepancyListReturnsEmpty() { + let glucoseDate = date("2025-01-01T12:00:00") + let startingGlucose = SimpleGlucoseValue(startDate: glucoseDate, quantity: .glucose(100)) + let rc = makeRC() + let effect = rc.computeEffect( + startingAt: startingGlucose, + retrospectiveGlucoseDiscrepanciesSummed: nil, + recencyInterval: .minutes(15), + retrospectiveCorrectionGroupingInterval: .minutes(30) + ) + XCTAssertTrue(effect.isEmpty) + XCTAssertNil(rc.totalGlucoseCorrectionEffect) + } + + func testEmptyDiscrepancyListReturnsEmpty() { + let glucoseDate = date("2025-01-01T12:00:00") + let startingGlucose = SimpleGlucoseValue(startDate: glucoseDate, quantity: .glucose(100)) + let rc = makeRC() + let effect = rc.computeEffect( + startingAt: startingGlucose, + retrospectiveGlucoseDiscrepanciesSummed: [], + recencyInterval: .minutes(15), + retrospectiveCorrectionGroupingInterval: .minutes(30) + ) + XCTAssertTrue(effect.isEmpty) + XCTAssertNil(rc.totalGlucoseCorrectionEffect) + } + + // MARK: - Total correction effect + + func testTotalCorrectionEffectEqualsLatestDiscrepancy() { + // Standard RC: totalGlucoseCorrectionEffect == latest discrepancy magnitude. + let glucoseDate = date("2025-01-01T12:00:00") + let startingGlucose = SimpleGlucoseValue(startDate: glucoseDate, quantity: .glucose(120)) + let rc = makeRC() + _ = rc.computeEffect( + startingAt: startingGlucose, + retrospectiveGlucoseDiscrepanciesSummed: [ + change(from: glucoseDate.addingTimeInterval(-.minutes(30)), + to: glucoseDate, mgdl: 15) + ], + recencyInterval: .minutes(15), + retrospectiveCorrectionGroupingInterval: .minutes(30) + ) + XCTAssertEqual(rc.totalGlucoseCorrectionEffect?.doubleValue(for: unit), 15.0) + } + + // MARK: - Effect projection + + func testPositiveDiscrepancyProjectsForward() { + // +12 mg/dL discrepancy over 30 min → decay over 60-min effectDuration. + // The integrated effect should ramp from 0 at startingGlucose to ~+12 + // at endDate of effectDuration. + let glucoseDate = date("2025-01-01T12:00:00") + let startingGlucose = SimpleGlucoseValue(startDate: glucoseDate, quantity: .glucose(100)) + let rc = makeRC() + let effect = rc.computeEffect( + startingAt: startingGlucose, + retrospectiveGlucoseDiscrepanciesSummed: [ + change(from: glucoseDate.addingTimeInterval(-.minutes(30)), + to: glucoseDate, mgdl: 12) + ], + recencyInterval: .minutes(15), + retrospectiveCorrectionGroupingInterval: .minutes(30) + ) + XCTAssertFalse(effect.isEmpty) + // Last sample should be roughly startingGlucose + 12 (decay applies the + // proportional correction over the effect window) + let last = effect.last!.quantity.doubleValue(for: unit) + XCTAssertEqual(last, 112.0, accuracy: 0.5, + "last projected glucose ≈ starting + discrepancy") + } + + func testNegativeDiscrepancyProjectsDownward() { + let glucoseDate = date("2025-01-01T12:00:00") + let startingGlucose = SimpleGlucoseValue(startDate: glucoseDate, quantity: .glucose(150)) + let rc = makeRC() + let effect = rc.computeEffect( + startingAt: startingGlucose, + retrospectiveGlucoseDiscrepanciesSummed: [ + change(from: glucoseDate.addingTimeInterval(-.minutes(30)), + to: glucoseDate, mgdl: -10) + ], + recencyInterval: .minutes(15), + retrospectiveCorrectionGroupingInterval: .minutes(30) + ) + XCTAssertFalse(effect.isEmpty) + let last = effect.last!.quantity.doubleValue(for: unit) + XCTAssertEqual(last, 140.0, accuracy: 0.5, + "last projected glucose ≈ starting + (negative) discrepancy") + XCTAssertEqual(rc.totalGlucoseCorrectionEffect?.doubleValue(for: unit), -10.0) + } + + func testEffectStartsAtStartingGlucoseValue() { + // First effect sample should equal startingGlucose value (correction + // has not yet had time to apply). + let glucoseDate = date("2025-01-01T12:00:00") + let startingGlucose = SimpleGlucoseValue(startDate: glucoseDate, quantity: .glucose(110)) + let rc = makeRC() + let effect = rc.computeEffect( + startingAt: startingGlucose, + retrospectiveGlucoseDiscrepanciesSummed: [ + change(from: glucoseDate.addingTimeInterval(-.minutes(30)), + to: glucoseDate, mgdl: 20) + ], + recencyInterval: .minutes(15), + retrospectiveCorrectionGroupingInterval: .minutes(30) + ) + XCTAssertFalse(effect.isEmpty) + XCTAssertEqual(effect.first!.quantity.doubleValue(for: unit), 110.0, accuracy: 0.01) + XCTAssertEqual(effect.first!.startDate, glucoseDate) + } + + // MARK: - Multiple discrepancies — only LATEST is used + + func testOnlyMostRecentDiscrepancyIsUsed() { + // Standard RC uses ONLY .last — older entries are ignored even when + // they would change the answer (this is exactly what IntegralRC fixes). + let glucoseDate = date("2025-01-01T12:00:00") + let startingGlucose = SimpleGlucoseValue(startDate: glucoseDate, quantity: .glucose(100)) + let rc = makeRC() + let effect = rc.computeEffect( + startingAt: startingGlucose, + retrospectiveGlucoseDiscrepanciesSummed: [ + change(from: glucoseDate.addingTimeInterval(-.minutes(150)), + to: glucoseDate.addingTimeInterval(-.minutes(120)), mgdl: 50), // big older + change(from: glucoseDate.addingTimeInterval(-.minutes(60)), + to: glucoseDate.addingTimeInterval(-.minutes(30)), mgdl: 30), // medium older + change(from: glucoseDate.addingTimeInterval(-.minutes(30)), + to: glucoseDate, mgdl: 5), // small latest + ], + recencyInterval: .minutes(15), + retrospectiveCorrectionGroupingInterval: .minutes(30) + ) + // Total effect must reflect ONLY the most recent (+5), not the larger older ones. + XCTAssertEqual(rc.totalGlucoseCorrectionEffect?.doubleValue(for: unit), 5.0) + XCTAssertEqual(effect.last!.quantity.doubleValue(for: unit), 105.0, accuracy: 0.5) + } + + // MARK: - Grouping interval clamps short discrepancies + + func testShortDiscrepancyClampedByGroupingInterval() { + // If the discrepancy's interval is shorter than retrospectiveCorrection- + // GroupingInterval, the velocity calc uses groupingInterval as the + // denominator (not the actual interval). This protects against over- + // amplified projections from very short discrepancies. + let glucoseDate = date("2025-01-01T12:00:00") + let startingGlucose = SimpleGlucoseValue(startDate: glucoseDate, quantity: .glucose(100)) + let rc = makeRC() + // Discrepancy is +10 over only 5 minutes (very short window). If we + // used 5 min as the denominator, velocity would be 6× larger. + let effect = rc.computeEffect( + startingAt: startingGlucose, + retrospectiveGlucoseDiscrepanciesSummed: [ + change(from: glucoseDate.addingTimeInterval(-.minutes(5)), + to: glucoseDate, mgdl: 10) + ], + recencyInterval: .minutes(15), + retrospectiveCorrectionGroupingInterval: .minutes(30) + ) + XCTAssertFalse(effect.isEmpty) + // Total effect over the effectDuration should still be ~+10 (the + // proportional correction), but spread over the full duration not + // amplified for the short window. + let last = effect.last!.quantity.doubleValue(for: unit) + XCTAssertEqual(last, 110.0, accuracy: 0.5) + } +}