SingleForecasting.swift 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. import Foundation
  2. /// Common interface for a single forecast pipeline
  3. protocol SingleForecasting {
  4. /// - Parameters:
  5. /// - startingGlucose: the current glucose
  6. /// - glucoseImpactSeries: the series of BGI (insulin effect) ticks
  7. /// - mealData: absorption & COB info
  8. /// - profile: user profile (for carbRatio, DIA, etc)
  9. /// - carbImpact: current carb impact (mg/dL per 5m)
  10. /// - deviation: current deviation (mg/dL per 5m)
  11. /// - Returns: a capped/clamped array of future BGs, one per 5-minute interval
  12. func forecast(
  13. startingGlucose: Decimal,
  14. glucoseImpactSeries: [Decimal],
  15. mealData: ComputedCarbs,
  16. profile: Profile,
  17. carbImpact: Decimal,
  18. deviation: Decimal,
  19. adjustedSensitivity: Decimal,
  20. sensitivityRatio: Decimal,
  21. currentTime: Date
  22. ) -> [Decimal]
  23. }
  24. /// Forecast sub-generator for insulin-only effect (IOB)
  25. struct IOBForecastGenerator: SingleForecasting {
  26. public func forecast(
  27. startingGlucose: Decimal,
  28. glucoseImpactSeries: [Decimal],
  29. mealData _: ComputedCarbs,
  30. profile _: Profile,
  31. carbImpact _: Decimal,
  32. deviation: Decimal,
  33. adjustedSensitivity _: Decimal,
  34. sensitivityRatio _: Decimal,
  35. currentTime _: Date
  36. ) -> [Decimal] {
  37. var result = [startingGlucose]
  38. for (count, glucoseImpact) in glucoseImpactSeries.enumerated() {
  39. let forecastedDeviation = deviation * (1 - min(1, Decimal(count) / (60 / 5)))
  40. let next = result.last! + glucoseImpact + forecastedDeviation
  41. result.append(next.clamp(lowerBound: 39, upperBound: 401))
  42. }
  43. return ForecastGenerator.trimFlatTails(result, lookback: 90 / 5)
  44. }
  45. }
  46. /// Forecast sub-generator for carb-only effect (COB + UAM piece)
  47. struct COBForecastGenerator: SingleForecasting {
  48. public func forecast(
  49. startingGlucose: Decimal,
  50. glucoseImpactSeries: [Decimal],
  51. mealData: ComputedCarbs,
  52. profile: Profile,
  53. carbImpact: Decimal,
  54. deviation: Decimal,
  55. adjustedSensitivity: Decimal,
  56. sensitivityRatio: Decimal,
  57. currentTime: Date
  58. ) -> [Decimal] {
  59. // Start with the current BG
  60. var result = [startingGlucose]
  61. let carbSensivityFactor = adjustedSensitivity / (profile.carbRatio ?? profile.carbRatioFor(time: currentTime))
  62. // Initial carb impact in mg/dL per 5m
  63. let initialCarbImpact = carbImpact * carbSensivityFactor
  64. let maxCarbAbsorptionRate: Decimal = 30 // g/h
  65. let maxCarbImpact = (maxCarbAbsorptionRate * carbSensivityFactor * 5 / 60).rounded(toPlaces: 1)
  66. let cappedCarbImpact = min(initialCarbImpact, maxCarbImpact)
  67. let computedRemainingCarbAbsorptionTime = Self.calculateRemainingCarbAbsorptionTime(
  68. sensitivityRatio: sensitivityRatio,
  69. maxMealAbsorptionTime: profile.maxMealAbsorptionTime,
  70. mealCOB: mealData.mealCOB,
  71. lastCarbTime: Date(timeIntervalSince1970: mealData.lastCarbTime),
  72. currentTime: currentTime
  73. )
  74. // Clamp remainingTime for more robustness
  75. let remainingCarbAbsorptionTime = min(computedRemainingCarbAbsorptionTime, profile.maxMealAbsorptionTime)
  76. // Convert remainingCarbAbsorptionTime (hours) to intervals (each 5m):
  77. let dynamicAbsorptionIntervals = Int((remainingCarbAbsorptionTime * 60) / 5)
  78. // Number of 5-minute intervals over which we expect *all* carbs to absorb
  79. let maxAbsorptionIntervals = Int(profile.maxMealAbsorptionTime * Decimal(60) / 5)
  80. // Use smaller of both computed intervals, the dynamic and the max-clamped one as the actual # of decay triangle interval
  81. let triangleIntervals = min(dynamicAbsorptionIntervals, maxAbsorptionIntervals)
  82. // Total CI (mg/dL)
  83. let totalCarbImpact = max(0, cappedCarbImpact / 5 * 60 * remainingCarbAbsorptionTime / 2)
  84. // Total carbs absorbed from CI (g)
  85. let totalCarbsAbsorbed: Decimal = totalCarbImpact / carbSensivityFactor
  86. // Remaining carbs cap/fraction logic
  87. let remainingCarbsCap = min(90, profile.remainingCarbsCap)
  88. let remainingCarbsFraction = min(1, profile.remainingCarbsFraction)
  89. let remainingCarbsIgnore = 1 - remainingCarbsFraction
  90. var remainingCarbs = max(0, mealData.mealCOB - totalCarbsAbsorbed - mealData.carbs * remainingCarbsIgnore)
  91. remainingCarbs = min(remainingCarbsCap, remainingCarbs)
  92. // /\ triangle for remaining carbs
  93. // Peak impact (mg/dL per 5m) of the *remaining* carbs
  94. let remainingCarbImpactPeak: Decimal
  95. if remainingCarbAbsorptionTime > 0 {
  96. remainingCarbImpactPeak = (remainingCarbs * carbSensivityFactor * 5 / 60) / (remainingCarbAbsorptionTime / 2)
  97. } else {
  98. remainingCarbImpactPeak = 0
  99. }
  100. // How many intervals we spread the initial CI decay over?
  101. // We use twice the absorption window (so that by 2x the window, CI has decayed to zero).
  102. let decayIntervals = max(maxAbsorptionIntervals * 2, 1)
  103. // Helper: negative deviation only (never positive)
  104. let forecastedDeviation = min(0, deviation)
  105. // Build forecast out to glucoseImpactSeries.count (usually 48)
  106. for seriesCount in 1 ..< glucoseImpactSeries.count {
  107. let insulinEffect = glucoseImpactSeries[seriesCount]
  108. // Linearly decay the *observed* carb impact from initialCI → 0
  109. let decayFactor = max(0, 1 - seriesCount / decayIntervals)
  110. let forecastedCarbImpact = cappedCarbImpact * Decimal(decayFactor)
  111. // Add a simple triangle bump for remaining carbs:
  112. // – ramp up linearly to peak over the first half of the window,
  113. // – ramp down linearly over the second half,
  114. // – zero afterwards.
  115. let triangle: Decimal
  116. if triangleIntervals > 0, seriesCount <= triangleIntervals {
  117. // FIXME: integer division here might be slightly off for odd number intervals.
  118. // FIXME: For perfect symmetry we could use let halfTriangle = (triangleIntervals + 1) / 2 — Change this?!
  119. let halfTriangle = triangleIntervals / 2
  120. if seriesCount <= halfTriangle {
  121. // Ramp up
  122. triangle = remainingCarbImpactPeak * Decimal(seriesCount) / Decimal(halfTriangle)
  123. } else {
  124. // Ramp down
  125. triangle = remainingCarbImpactPeak * Decimal(triangleIntervals - seriesCount) / Decimal(halfTriangle)
  126. }
  127. } else {
  128. triangle = 0
  129. }
  130. let next = result.last!
  131. + insulinEffect
  132. + forecastedDeviation
  133. + forecastedCarbImpact
  134. + triangle
  135. result.append(next.clamp(lowerBound: 39, upperBound: 1500))
  136. }
  137. return ForecastGenerator.trimFlatTails(result, lookback: 12)
  138. }
  139. /// Calculates the dynamic remaining carb absorption time in hours, per oref0 logic.
  140. /// - Parameters:
  141. /// - sensitivityRatio: ratio from autosens (usually 1.0 if not present)
  142. /// - mealCOB: unabsorbed carbs (grams)
  143. /// - lastCarbTime: timestamp of last carb entry (Date? or nil)
  144. /// - currentTime: now
  145. /// - Returns: Remaining CA time in hours (Decimal)
  146. private static func calculateRemainingCarbAbsorptionTime(
  147. sensitivityRatio: Decimal,
  148. maxMealAbsorptionTime: Decimal,
  149. mealCOB: Decimal,
  150. lastCarbTime: Date?,
  151. currentTime: Date
  152. ) -> Decimal {
  153. var minRemainingCarbAbsorptionTime: Decimal = min(3, maxMealAbsorptionTime) // hours
  154. if sensitivityRatio > 0 {
  155. minRemainingCarbAbsorptionTime = minRemainingCarbAbsorptionTime / sensitivityRatio
  156. }
  157. if mealCOB > 0 {
  158. let assumedCarbAbsorptionRate: Decimal = 20 // g/h
  159. minRemainingCarbAbsorptionTime = max(minRemainingCarbAbsorptionTime, mealCOB / assumedCarbAbsorptionRate)
  160. }
  161. var remainingCarbAbsorptionTime = minRemainingCarbAbsorptionTime
  162. if let lastCarbTime = lastCarbTime {
  163. let lastCarbAgeMin = Decimal(currentTime.timeIntervalSince(lastCarbTime) / 60)
  164. remainingCarbAbsorptionTime += 1.5 * (lastCarbAgeMin / 60)
  165. }
  166. return remainingCarbAbsorptionTime.rounded(toPlaces: 1)
  167. }
  168. }
  169. /// Forecast sub-generator for “unannounced meal” impact (UAM)
  170. struct UAMForecastGenerator: SingleForecasting {
  171. public func forecast(
  172. startingGlucose: Decimal,
  173. glucoseImpactSeries: [Decimal],
  174. mealData: ComputedCarbs,
  175. profile _: Profile,
  176. carbImpact: Decimal,
  177. deviation: Decimal,
  178. adjustedSensitivity _: Decimal,
  179. sensitivityRatio _: Decimal,
  180. currentTime _: Date
  181. ) -> [Decimal] {
  182. var result = [startingGlucose]
  183. let slope = min(deviation, -(mealData.slopeFromMinDeviation / 3))
  184. for seriesCount in 1 ..< 48 {
  185. let forecastedGlucoseImpact = glucoseImpactSeries[seriesCount]
  186. let forecastedUnannouncedCarbImpact = max(0, carbImpact + slope * Decimal(seriesCount))
  187. let next = result.last! + forecastedGlucoseImpact + min(0, deviation) + forecastedUnannouncedCarbImpact
  188. result.append(next.clamp(lowerBound: 39, upperBound: 401))
  189. }
  190. return ForecastGenerator.trimFlatTails(result, lookback: 12)
  191. }
  192. }
  193. /// Forecast sub-generator for “zero-temp” baseline (ZT)
  194. struct ZTForecastGenerator: SingleForecasting {
  195. public func forecast(
  196. startingGlucose: Decimal,
  197. glucoseImpactSeries: [Decimal],
  198. mealData: ComputedCarbs,
  199. profile: Profile,
  200. carbImpact: Decimal,
  201. deviation: Decimal,
  202. adjustedSensitivity: Decimal,
  203. sensitivityRatio: Decimal,
  204. currentTime: Date
  205. ) -> [Decimal] {
  206. // essentially insulin effect only, but with zero-temp ISF if needed
  207. IOBForecastGenerator().forecast(
  208. startingGlucose: startingGlucose,
  209. glucoseImpactSeries: glucoseImpactSeries.map { /* TODO: use iobWithZeroTemp.activity */ $0 },
  210. mealData: mealData,
  211. profile: profile,
  212. carbImpact: carbImpact,
  213. deviation: deviation,
  214. adjustedSensitivity: adjustedSensitivity,
  215. sensitivityRatio: sensitivityRatio,
  216. currentTime: currentTime
  217. )
  218. }
  219. }