|
@@ -16,7 +16,10 @@ protocol SingleForecasting {
|
|
|
mealData: ComputedCarbs,
|
|
mealData: ComputedCarbs,
|
|
|
profile: Profile,
|
|
profile: Profile,
|
|
|
carbImpact: Decimal,
|
|
carbImpact: Decimal,
|
|
|
- deviation: Decimal
|
|
|
|
|
|
|
+ deviation: Decimal,
|
|
|
|
|
+ adjustedSensitivity: Decimal,
|
|
|
|
|
+ sensitivityRatio: Decimal,
|
|
|
|
|
+ currentTime: Date
|
|
|
) -> [Decimal]
|
|
) -> [Decimal]
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -28,12 +31,15 @@ struct IOBForecastGenerator: SingleForecasting {
|
|
|
mealData _: ComputedCarbs,
|
|
mealData _: ComputedCarbs,
|
|
|
profile _: Profile,
|
|
profile _: Profile,
|
|
|
carbImpact _: Decimal,
|
|
carbImpact _: Decimal,
|
|
|
- deviation: Decimal
|
|
|
|
|
|
|
+ deviation: Decimal,
|
|
|
|
|
+ adjustedSensitivity _: Decimal,
|
|
|
|
|
+ sensitivityRatio _: Decimal,
|
|
|
|
|
+ currentTime _: Date
|
|
|
) -> [Decimal] {
|
|
) -> [Decimal] {
|
|
|
var result = [startingGlucose]
|
|
var result = [startingGlucose]
|
|
|
for (count, glucoseImpact) in glucoseImpactSeries.enumerated() {
|
|
for (count, glucoseImpact) in glucoseImpactSeries.enumerated() {
|
|
|
- let predDev = deviation * (1 - min(1, Decimal(count) / (60 / 5)))
|
|
|
|
|
- let next = result.last! + glucoseImpact + predDev
|
|
|
|
|
|
|
+ let forecastedDeviation = deviation * (1 - min(1, Decimal(count) / (60 / 5)))
|
|
|
|
|
+ let next = result.last! + glucoseImpact + forecastedDeviation
|
|
|
result.append(next.clamp(lowerBound: 39, upperBound: 401))
|
|
result.append(next.clamp(lowerBound: 39, upperBound: 401))
|
|
|
}
|
|
}
|
|
|
return ForecastGenerator.trimFlatTails(result, lookback: 90 / 5)
|
|
return ForecastGenerator.trimFlatTails(result, lookback: 90 / 5)
|
|
@@ -48,59 +54,99 @@ struct COBForecastGenerator: SingleForecasting {
|
|
|
mealData: ComputedCarbs,
|
|
mealData: ComputedCarbs,
|
|
|
profile: Profile,
|
|
profile: Profile,
|
|
|
carbImpact: Decimal,
|
|
carbImpact: Decimal,
|
|
|
- deviation: Decimal
|
|
|
|
|
|
|
+ deviation: Decimal,
|
|
|
|
|
+ adjustedSensitivity: Decimal,
|
|
|
|
|
+ sensitivityRatio: Decimal,
|
|
|
|
|
+ currentTime: Date
|
|
|
) -> [Decimal] {
|
|
) -> [Decimal] {
|
|
|
// Start with the current BG
|
|
// Start with the current BG
|
|
|
var result = [startingGlucose]
|
|
var result = [startingGlucose]
|
|
|
|
|
|
|
|
- // carb-sensitivity factor (mg/dL per gram)
|
|
|
|
|
- guard let sens = profile.sens,
|
|
|
|
|
- let carbRatio = profile.carbRatio
|
|
|
|
|
- else {
|
|
|
|
|
- fatalError("Profile must have sens and carbRatio")
|
|
|
|
|
- }
|
|
|
|
|
- let csf = sens / carbRatio // FIXME: this needs to be the AS-adjusted sens, not profile.sens
|
|
|
|
|
|
|
+ let carbSensivityFactor = adjustedSensitivity / (profile.carbRatio ?? profile.carbRatioFor(time: currentTime))
|
|
|
|
|
|
|
|
// Initial carb impact in mg/dL per 5m
|
|
// Initial carb impact in mg/dL per 5m
|
|
|
- let initialCarbImpact = carbImpact * csf
|
|
|
|
|
|
|
+ let initialCarbImpact = carbImpact * carbSensivityFactor
|
|
|
|
|
+ let maxCarbAbsorptionRate: Decimal = 30 // g/h
|
|
|
|
|
+ let maxCarbImpact = (maxCarbAbsorptionRate * carbSensivityFactor * 5 / 60).rounded(toPlaces: 1)
|
|
|
|
|
+ let cappedCarbImpact = min(initialCarbImpact, maxCarbImpact)
|
|
|
|
|
+
|
|
|
|
|
+ let computedRemainingCarbAbsorptionTime = Self.calculateRemainingCarbAbsorptionTime(
|
|
|
|
|
+ sensitivityRatio: sensitivityRatio,
|
|
|
|
|
+ maxMealAbsorptionTime: profile.maxMealAbsorptionTime,
|
|
|
|
|
+ mealCOB: mealData.mealCOB,
|
|
|
|
|
+ lastCarbTime: Date(timeIntervalSince1970: mealData.lastCarbTime),
|
|
|
|
|
+ currentTime: currentTime
|
|
|
|
|
+ )
|
|
|
|
|
+ // Clamp remainingTime for more robustness
|
|
|
|
|
+ let remainingCarbAbsorptionTime = min(computedRemainingCarbAbsorptionTime, profile.maxMealAbsorptionTime)
|
|
|
|
|
|
|
|
|
|
+ // Convert remainingCarbAbsorptionTime (hours) to intervals (each 5m):
|
|
|
|
|
+ let dynamicAbsorptionIntervals = Int((remainingCarbAbsorptionTime * 60) / 5)
|
|
|
// Number of 5-minute intervals over which we expect *all* carbs to absorb
|
|
// Number of 5-minute intervals over which we expect *all* carbs to absorb
|
|
|
- let absorptionIntervals = Int(profile.maxMealAbsorptionTime * Decimal(60) / 5)
|
|
|
|
|
|
|
+ let maxAbsorptionIntervals = Int(profile.maxMealAbsorptionTime * Decimal(60) / 5)
|
|
|
|
|
+ // Use smaller of both computed intervals, the dynamic and the max-clamped one as the actual # of decay triangle interval
|
|
|
|
|
+ let triangleIntervals = min(dynamicAbsorptionIntervals, maxAbsorptionIntervals)
|
|
|
|
|
+
|
|
|
|
|
+ // Total CI (mg/dL)
|
|
|
|
|
+ let totalCarbImpact = max(0, cappedCarbImpact / 5 * 60 * remainingCarbAbsorptionTime / 2)
|
|
|
|
|
+ // Total carbs absorbed from CI (g)
|
|
|
|
|
+ let totalCarbsAbsorbed: Decimal = totalCarbImpact / carbSensivityFactor
|
|
|
|
|
|
|
|
|
|
+ // Remaining carbs cap/fraction logic
|
|
|
|
|
+ let remainingCarbsCap = min(90, profile.remainingCarbsCap)
|
|
|
|
|
+ let remainingCarbsFraction = min(1, profile.remainingCarbsFraction)
|
|
|
|
|
+ let remainingCarbsIgnore = 1 - remainingCarbsFraction
|
|
|
|
|
+
|
|
|
|
|
+ var remainingCarbs = max(0, mealData.mealCOB - totalCarbsAbsorbed - mealData.carbs * remainingCarbsIgnore)
|
|
|
|
|
+ remainingCarbs = min(remainingCarbsCap, remainingCarbs)
|
|
|
|
|
+
|
|
|
|
|
+ // /\ triangle for remaining carbs
|
|
|
// Peak impact (mg/dL per 5m) of the *remaining* carbs
|
|
// Peak impact (mg/dL per 5m) of the *remaining* carbs
|
|
|
- let remainingCarbImpactPeak = mealData.mealCOB * csf
|
|
|
|
|
|
|
+ let remainingCarbImpactPeak: Decimal
|
|
|
|
|
+ if remainingCarbAbsorptionTime > 0 {
|
|
|
|
|
+ remainingCarbImpactPeak = (remainingCarbs * carbSensivityFactor * 5 / 60) / (remainingCarbAbsorptionTime / 2)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ remainingCarbImpactPeak = 0
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
// How many intervals we spread the initial CI decay over?
|
|
// How many intervals we spread the initial CI decay over?
|
|
|
- // We use twice the absorption window (so that by 2× the window, CI has decayed to zero).
|
|
|
|
|
- let decayIntervals = max(absorptionIntervals * 2, 1)
|
|
|
|
|
|
|
+ // We use twice the absorption window (so that by 2x the window, CI has decayed to zero).
|
|
|
|
|
+ let decayIntervals = max(maxAbsorptionIntervals * 2, 1)
|
|
|
|
|
|
|
|
// Helper: negative deviation only (never positive)
|
|
// Helper: negative deviation only (never positive)
|
|
|
- let predDev = min(0, deviation)
|
|
|
|
|
|
|
+ let forecastedDeviation = min(0, deviation)
|
|
|
|
|
|
|
|
- // Build prediction out to glucoseImpactSeries.count (usually 48)
|
|
|
|
|
|
|
+ // Build forecast out to glucoseImpactSeries.count (usually 48)
|
|
|
for seriesCount in 1 ..< glucoseImpactSeries.count {
|
|
for seriesCount in 1 ..< glucoseImpactSeries.count {
|
|
|
let insulinEffect = glucoseImpactSeries[seriesCount]
|
|
let insulinEffect = glucoseImpactSeries[seriesCount]
|
|
|
|
|
|
|
|
// Linearly decay the *observed* carb impact from initialCI → 0
|
|
// Linearly decay the *observed* carb impact from initialCI → 0
|
|
|
let decayFactor = max(0, 1 - seriesCount / decayIntervals)
|
|
let decayFactor = max(0, 1 - seriesCount / decayIntervals)
|
|
|
- let forecastedCarbImpact = initialCarbImpact * Decimal(decayFactor)
|
|
|
|
|
|
|
+ let forecastedCarbImpact = cappedCarbImpact * Decimal(decayFactor)
|
|
|
|
|
|
|
|
// Add a simple triangle bump for remaining carbs:
|
|
// Add a simple triangle bump for remaining carbs:
|
|
|
// – ramp up linearly to peak over the first half of the window,
|
|
// – ramp up linearly to peak over the first half of the window,
|
|
|
// – ramp down linearly over the second half,
|
|
// – ramp down linearly over the second half,
|
|
|
// – zero afterwards.
|
|
// – zero afterwards.
|
|
|
let triangle: Decimal
|
|
let triangle: Decimal
|
|
|
- if seriesCount <= absorptionIntervals {
|
|
|
|
|
- triangle = remainingCarbImpactPeak * (Decimal(seriesCount) / Decimal(absorptionIntervals))
|
|
|
|
|
- } else if seriesCount <= decayIntervals {
|
|
|
|
|
- triangle = remainingCarbImpactPeak * (Decimal(decayIntervals - seriesCount) / Decimal(absorptionIntervals))
|
|
|
|
|
|
|
+ if triangleIntervals > 0, seriesCount <= triangleIntervals {
|
|
|
|
|
+ // FIXME: integer division here might be slightly off for odd number intervals.
|
|
|
|
|
+ // FIXME: For perfect symmetry we could use let halfTriangle = (triangleIntervals + 1) / 2 — Change this?!
|
|
|
|
|
+ let halfTriangle = triangleIntervals / 2
|
|
|
|
|
+ if seriesCount <= halfTriangle {
|
|
|
|
|
+ // Ramp up
|
|
|
|
|
+ triangle = remainingCarbImpactPeak * Decimal(seriesCount) / Decimal(halfTriangle)
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // Ramp down
|
|
|
|
|
+ triangle = remainingCarbImpactPeak * Decimal(triangleIntervals - seriesCount) / Decimal(halfTriangle)
|
|
|
|
|
+ }
|
|
|
} else {
|
|
} else {
|
|
|
triangle = 0
|
|
triangle = 0
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
let next = result.last!
|
|
let next = result.last!
|
|
|
+ insulinEffect
|
|
+ insulinEffect
|
|
|
- + predDev
|
|
|
|
|
|
|
+ + forecastedDeviation
|
|
|
+ forecastedCarbImpact
|
|
+ forecastedCarbImpact
|
|
|
+ triangle
|
|
+ triangle
|
|
|
|
|
|
|
@@ -109,6 +155,36 @@ struct COBForecastGenerator: SingleForecasting {
|
|
|
|
|
|
|
|
return ForecastGenerator.trimFlatTails(result, lookback: 12)
|
|
return ForecastGenerator.trimFlatTails(result, lookback: 12)
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ /// Calculates the dynamic remaining carb absorption time in hours, per oref0 logic.
|
|
|
|
|
+ /// - Parameters:
|
|
|
|
|
+ /// - sensitivityRatio: ratio from autosens (usually 1.0 if not present)
|
|
|
|
|
+ /// - mealCOB: unabsorbed carbs (grams)
|
|
|
|
|
+ /// - lastCarbTime: timestamp of last carb entry (Date? or nil)
|
|
|
|
|
+ /// - currentTime: now
|
|
|
|
|
+ /// - Returns: Remaining CA time in hours (Decimal)
|
|
|
|
|
+ private static func calculateRemainingCarbAbsorptionTime(
|
|
|
|
|
+ sensitivityRatio: Decimal,
|
|
|
|
|
+ maxMealAbsorptionTime: Decimal,
|
|
|
|
|
+ mealCOB: Decimal,
|
|
|
|
|
+ lastCarbTime: Date?,
|
|
|
|
|
+ currentTime: Date
|
|
|
|
|
+ ) -> Decimal {
|
|
|
|
|
+ var minRemainingCarbAbsorptionTime: Decimal = min(3, maxMealAbsorptionTime) // hours
|
|
|
|
|
+ if sensitivityRatio > 0 {
|
|
|
|
|
+ minRemainingCarbAbsorptionTime = minRemainingCarbAbsorptionTime / sensitivityRatio
|
|
|
|
|
+ }
|
|
|
|
|
+ if mealCOB > 0 {
|
|
|
|
|
+ let assumedCarbAbsorptionRate: Decimal = 20 // g/h
|
|
|
|
|
+ minRemainingCarbAbsorptionTime = max(minRemainingCarbAbsorptionTime, mealCOB / assumedCarbAbsorptionRate)
|
|
|
|
|
+ }
|
|
|
|
|
+ var remainingCarbAbsorptionTime = minRemainingCarbAbsorptionTime
|
|
|
|
|
+ if let lastCarbTime = lastCarbTime {
|
|
|
|
|
+ let lastCarbAgeMin = Decimal(currentTime.timeIntervalSince(lastCarbTime) / 60)
|
|
|
|
|
+ remainingCarbAbsorptionTime += 1.5 * (lastCarbAgeMin / 60)
|
|
|
|
|
+ }
|
|
|
|
|
+ return remainingCarbAbsorptionTime.rounded(toPlaces: 1)
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// Forecast sub-generator for “unannounced meal” impact (UAM)
|
|
/// Forecast sub-generator for “unannounced meal” impact (UAM)
|
|
@@ -119,7 +195,10 @@ struct UAMForecastGenerator: SingleForecasting {
|
|
|
mealData: ComputedCarbs,
|
|
mealData: ComputedCarbs,
|
|
|
profile _: Profile,
|
|
profile _: Profile,
|
|
|
carbImpact: Decimal,
|
|
carbImpact: Decimal,
|
|
|
- deviation: Decimal
|
|
|
|
|
|
|
+ deviation: Decimal,
|
|
|
|
|
+ adjustedSensitivity _: Decimal,
|
|
|
|
|
+ sensitivityRatio _: Decimal,
|
|
|
|
|
+ currentTime _: Date
|
|
|
) -> [Decimal] {
|
|
) -> [Decimal] {
|
|
|
var result = [startingGlucose]
|
|
var result = [startingGlucose]
|
|
|
|
|
|
|
@@ -143,7 +222,10 @@ struct ZTForecastGenerator: SingleForecasting {
|
|
|
mealData: ComputedCarbs,
|
|
mealData: ComputedCarbs,
|
|
|
profile: Profile,
|
|
profile: Profile,
|
|
|
carbImpact: Decimal,
|
|
carbImpact: Decimal,
|
|
|
- deviation: Decimal
|
|
|
|
|
|
|
+ deviation: Decimal,
|
|
|
|
|
+ adjustedSensitivity: Decimal,
|
|
|
|
|
+ sensitivityRatio: Decimal,
|
|
|
|
|
+ currentTime: Date
|
|
|
) -> [Decimal] {
|
|
) -> [Decimal] {
|
|
|
// essentially insulin effect only, but with zero-temp ISF if needed
|
|
// essentially insulin effect only, but with zero-temp ISF if needed
|
|
|
IOBForecastGenerator().forecast(
|
|
IOBForecastGenerator().forecast(
|
|
@@ -152,7 +234,10 @@ struct ZTForecastGenerator: SingleForecasting {
|
|
|
mealData: mealData,
|
|
mealData: mealData,
|
|
|
profile: profile,
|
|
profile: profile,
|
|
|
carbImpact: carbImpact,
|
|
carbImpact: carbImpact,
|
|
|
- deviation: deviation
|
|
|
|
|
|
|
+ deviation: deviation,
|
|
|
|
|
+ adjustedSensitivity: adjustedSensitivity,
|
|
|
|
|
+ sensitivityRatio: sensitivityRatio,
|
|
|
|
|
+ currentTime: currentTime
|
|
|
)
|
|
)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|