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/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)" + ) + } + } + } +}