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/Insulin/InsulinMath.swift b/Sources/LoopAlgorithm/Insulin/InsulinMath.swift index 3e250cf..b78edf3 100644 --- a/Sources/LoopAlgorithm/Insulin/InsulinMath.swift +++ b/Sources/LoopAlgorithm/Insulin/InsulinMath.swift @@ -397,41 +397,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 2e711a1..fce1746 100644 --- a/Sources/LoopAlgorithm/LoopAlgorithm.swift +++ b/Sources/LoopAlgorithm/LoopAlgorithm.swift @@ -179,7 +179,8 @@ public struct LoopAlgorithm { includingPositiveVelocityAndRC: Bool = true, useMidAbsorptionISF: Bool = false, carbAbsorptionModel: CarbAbsorptionComputable = PiecewiseLinearAbsorption(), - gradualTransitionsThreshold: Double? = 40.0 + gradualTransitionsThreshold: Double? = 40.0, + momentumVelocityMaximum: LoopQuantity? = nil ) -> LoopPrediction where CarbType: CarbEntry, GlucoseType: GlucoseSampleValue, InsulinDoseType: InsulinDose { var prediction: [PredictedGlucoseValue] = [] @@ -311,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 @@ -352,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 { @@ -371,7 +567,7 @@ public struct LoopAlgorithm { } // Computes an amount of insulin to correct the given prediction - static func insulinCorrection( + public static func insulinCorrection( prediction: [PredictedGlucoseValue], at deliveryDate: Date, target: GlucoseRangeTimeline, @@ -388,7 +584,7 @@ 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, @@ -420,7 +616,7 @@ 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, 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/SampleValue.swift b/Sources/LoopAlgorithm/SampleValue.swift index a7fee2e..fb978b2 100644 --- a/Sources/LoopAlgorithm/SampleValue.swift +++ b/Sources/LoopAlgorithm/SampleValue.swift @@ -98,3 +98,40 @@ public extension Sequence where Element: TimelineValue { return filterDateRange(interval.start, interval.end) } } + +/// Fast binary-search filter for ordered timeline arrays. Picks up when the +/// collection conforms to RandomAccessCollection with Int index (i.e. Array) +/// and the elements are sorted by startDate (which is the contract for all +/// schedule arrays — sensitivity / basal / carb-ratio / target — across this +/// codebase). Reduces filterDateRange from O(N) to O(log N) per call. +/// +/// LoopEval sims with per-step ISF schedules (`--candidate-isf-csv`) call +/// filterDateRange ~1.5M times on a 60-day window; this dropped sim time +/// from ~30 min to ~2 min on that workload. +public extension RandomAccessCollection where Element: TimelineValue, Index == Int { + func filterDateRange(_ startDate: Date?, _ endDate: Date?) -> [Element] { + guard !isEmpty else { return [] } + // 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.. — 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.. + + 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) + } +}