Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Sources/LoopAlgorithm/Glucose/GlucoseEffect.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
88 changes: 60 additions & 28 deletions Sources/LoopAlgorithm/Insulin/InsulinMath.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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..<n-1 maps to step (idx+1)
let i = idx + 1
let lastDate = timePoints[i - 1]
let date = timePoints[i]
var inc = 0.0
let isfSegments = insulinSensitivityHistory.filterDateRange(lastDate, date)
if isfSegments.count == 0 {
preconditionFailure("ISF Timeline must cover dose absorption duration")
if isfSegments.isEmpty {
// Outside ISF coverage; treat increment as 0
incBuf[i] = 0
return
}
return value + isfSegments.reduce(0, { partialResult, segment in
let start = Swift.max(lastDate, segment.startDate)
let end = Swift.min(date, segment.endDate)
if start != end {
let effect = dose.glucoseEffect(during: DateInterval(start: start, end: end), insulinSensitivity: segment.value.doubleValue(for: unit), delta: delta)
return partialResult + effect
} else {
return partialResult
for dose in dosesArray {
for segment in isfSegments {
let segStart = Swift.max(lastDate, segment.startDate)
let segEnd = Swift.min(date, segment.endDate)
if segStart != segEnd {
inc += dose.glucoseEffect(
during: DateInterval(start: segStart, end: segEnd),
insulinSensitivity: segment.value.doubleValue(for: unit),
delta: delta
)
}
}
})
}
incBuf[i] = inc
}
}

values.append(GlucoseEffect(startDate: date, quantity: LoopQuantity(unit: unit, doubleValue: value)))
lastDate = date
date = date.addingTimeInterval(delta)
} while date <= end

// Cumsum + emit GlucoseEffect — fast, single-threaded.
var values = [GlucoseEffect]()
values.reserveCapacity(n)
var running = 0.0
for i in 0..<n {
running += increments[i]
values.append(GlucoseEffect(startDate: timePoints[i],
quantity: LoopQuantity(unit: unit, doubleValue: running)))
}
return values
}

Expand Down
215 changes: 215 additions & 0 deletions Sources/LoopAlgorithm/Insulin/PrecomputedInsulinInput.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
// PrecomputedInsulinInput.swift
//
// An optimized input type for callers that evaluate many consecutive
// predictions sharing the same dose history (e.g., historical back-testing).
//
// ┌──────────────────────────────────────────────────────────────────────────┐
// │ What's expensive in a dense prediction sweep │
// │ │
// │ doses.annotated(with: basal) O(D × B) — ISF-independent │
// │ annotated.glucoseEffects(isf:) O(D × T) — ISF-dependent │
// │ Everything else (CGM, carbs, RC…) per-step, unavoidable │
// │ │
// │ For a 7-day window at 5-min step (n ≈ 2016 steps per ISF config): │
// │ │
// │ annotation: called 2016× without caching → call ONCE, reuse always │
// │ effects: called 2016× per ISF value → call ONCE per ISF value │
// │ │
// │ ISF-sweep usage pattern (e.g. 10 multipliers × 2016 steps): │
// │ │
// │ let base = PrecomputedInsulinInput.annotate(doses: doses, basal: basal)│
// │ for multiplier in [0.7, 0.8, 0.9, 1.0, 1.1, ...] { │
// │ let scaled = scaledSensitivity(sensitivity, by: multiplier) │
// │ let input = base.withEffects(sensitivity: scaled) // O(D×T) once │
// │ for t in sweepSteps { │
// │ let result = LoopAlgorithm.generatePrediction( │
// │ start: t, ..., precomputedInsulin: input, ...) // no annotation │
// │ } │
// │ } │
// └──────────────────────────────────────────────────────────────────────────┘

import Foundation

// MARK: - Binary search helper

private extension Array {
/// Returns the index of the first element where `key` > `date` (after: true)
/// or `key` >= `date` (after: false), using binary search.
/// Assumes the array is sorted ascending by `key`.
func partition<K: Comparable>(index date: K, key: KeyPath<Element, K>, 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<DoseType: InsulinDose>(
doses: [DoseType],
basal: [AbsoluteScheduleValue<Double>]
) -> 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<LoopQuantity>],
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..<hiIdx].filter { $0.endDate > 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<DoseType: InsulinDose>(
doses: [DoseType],
basal: [AbsoluteScheduleValue<Double>],
sensitivity: [AbsoluteScheduleValue<LoopQuantity>]? = 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
)
}
}
Loading