ForecastGenerator.swift 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. import Foundation
  2. /// The top-level orchestrator
  3. enum ForecastGenerator {
  4. public static func generate(
  5. glucose: Decimal,
  6. glucoseStatus: GlucoseStatus,
  7. currentGlucoseImpact: Decimal,
  8. glucoseImpactSeries: [Decimal],
  9. glucoseImpactSeriesWithZeroTemp: [Decimal],
  10. iobData: [IobResult],
  11. mealData: ComputedCarbs,
  12. profile: Profile,
  13. preferences: Preferences,
  14. trioCustomOrefVariables: TrioCustomOrefVariables,
  15. dynamicIsfResult: DynamicISFResult?,
  16. targetGlucose: Decimal,
  17. adjustedSensitivity: Decimal,
  18. sensitivityRatio: Decimal,
  19. naiveEventualGlucose _: Decimal,
  20. eventualGlucose: Decimal,
  21. threshold: Decimal,
  22. currentTime: Date
  23. ) -> ForecastResult {
  24. let profileCarbRatio = profile.carbRatio ?? profile.carbRatioFor(time: currentTime)
  25. let adjustedCarbRatio: Decimal
  26. if trioCustomOrefVariables.useOverride, trioCustomOrefVariables.cr {
  27. let overrideFactor = trioCustomOrefVariables.overridePercentage / 100
  28. adjustedCarbRatio = profileCarbRatio / overrideFactor
  29. } else {
  30. adjustedCarbRatio = profileCarbRatio
  31. }
  32. let carbSensitivityFactor = adjustedSensitivity / adjustedCarbRatio
  33. let minDelta = min(glucoseStatus.delta, glucoseStatus.shortAvgDelta)
  34. // this carbImpact is `ci` in JS
  35. var carbImpact = (minDelta - currentGlucoseImpact).jsRounded(scale: 1)
  36. let maxCarbAbsorptionRate = Decimal(30)
  37. let maxCI = (maxCarbAbsorptionRate * carbSensitivityFactor * Decimal(5) / Decimal(60)).jsRounded(scale: 1)
  38. if carbImpact > maxCI {
  39. carbImpact = maxCI
  40. }
  41. let carbImpactParams = CarbImpactParams.calculate(
  42. carbSensitivityFactor: carbSensitivityFactor,
  43. profile: profile,
  44. mealData: mealData,
  45. carbImpact: carbImpact,
  46. sensitivityRatio: sensitivityRatio,
  47. currentTime: currentTime
  48. )
  49. // this is `uci` in JS, it isn't limited by maxCI
  50. let uamCarbImpact = (minDelta - currentGlucoseImpact).jsRounded(scale: 1)
  51. // JS oref initializes all xxxPredBGs array with current glucose, we do the same, then generate
  52. let iobForecast = forecastIOB(
  53. startingGlucose: glucose,
  54. glucoseImpactSeries: glucoseImpactSeries,
  55. iobData: iobData,
  56. carbImpact: carbImpact,
  57. dynamicIsfState: preferences.dynamicIsfState(),
  58. insulinFactor: dynamicIsfResult?.insulinFactor,
  59. tdd: trioCustomOrefVariables.tdd(profile: profile),
  60. adjustmentFactorLogrithmic: profile.adjustmentFactor
  61. )
  62. let cobForecast = forecastCOB(
  63. startingGlucose: glucose,
  64. glucoseImpactSeries: glucoseImpactSeries,
  65. carbImpact: carbImpact,
  66. carbImpactParams: carbImpactParams
  67. )
  68. let uamForecast = forecastUAM(
  69. startingGlucose: glucose,
  70. glucoseImpactSeries: glucoseImpactSeries,
  71. mealData: mealData,
  72. uamCarbImpact: uamCarbImpact,
  73. carbImpact: carbImpact
  74. )
  75. let ztForecast = forecastZT(
  76. startingGlucose: glucose,
  77. glucoseImpactSeriesWithZeroTemp: glucoseImpactSeriesWithZeroTemp,
  78. targetBG: targetGlucose,
  79. iobData: iobData,
  80. dynamicIsfState: preferences.dynamicIsfState(),
  81. insulinFactor: dynamicIsfResult?.insulinFactor,
  82. tdd: trioCustomOrefVariables.tdd(profile: profile),
  83. adjustmentFactorLogrithmic: profile.adjustmentFactor
  84. )
  85. let computedForecastSelection = Self.computeForecastSelection(
  86. iob: iobForecast,
  87. cob: cobForecast,
  88. uam: uamForecast,
  89. zt: ztForecast,
  90. currentGlucose: glucose
  91. )
  92. let blendedForecasts = Self.blendForecasts(
  93. selectionResult: computedForecastSelection,
  94. carbs: mealData.carbs,
  95. mealCOB: mealData.mealCOB,
  96. enableUAM: profile.enableUAM,
  97. carbImpactDuration: carbImpactParams.carbImpactDuration,
  98. remainingCarbImpactPeak: carbImpactParams.remainingCarbImpactPeak,
  99. fractionCarbsLeft: mealData.carbs > 0 ? mealData.mealCOB / mealData.carbs : 0,
  100. threshold: threshold,
  101. targetGlucose: profile.targetBg ?? 100,
  102. currentGlucose: glucose
  103. )
  104. // FIXME: Revisit this after I get predBG working
  105. /*
  106. var eventualGlucose = eventualGlucose
  107. if let finalCOBGlucose = cobForecast.last {
  108. eventualGlucose = max(eventualGlucose, finalCOBGlucose)
  109. }
  110. if let finalUAMGlucose = uamForecast.last {
  111. eventualGlucose = max(eventualGlucose, finalUAMGlucose)
  112. }
  113. */
  114. return ForecastResult(
  115. iob: iobForecast,
  116. cob: cobForecast,
  117. uam: uamForecast,
  118. zt: ztForecast,
  119. eventualGlucose: eventualGlucose,
  120. minForecastedGlucose: blendedForecasts.minForecastedGlucose,
  121. minGuardGlucose: blendedForecasts.minGuardGlucose
  122. )
  123. }
  124. /// Calculates the dynamic remaining carb absorption time in hours, per oref0 logic.
  125. /// - Parameters:
  126. /// - sensitivityRatio: ratio from autosens (usually 1.0 if not present)
  127. /// - mealCOB: unabsorbed carbs (grams)
  128. /// - lastCarbTime: timestamp of last carb entry (Date? or nil)
  129. /// - currentTime: now
  130. /// - Returns: Remaining CA time in hours (Decimal)
  131. static func calculateRemainingCarbAbsorptionTime(
  132. sensitivityRatio: Decimal,
  133. maxMealAbsorptionTime _: Decimal,
  134. mealCOB: Decimal,
  135. lastCarbTime: Date?,
  136. currentTime: Date
  137. ) -> Decimal {
  138. var minRemainingCarbAbsorptionTime: Decimal = 3 // hours
  139. if sensitivityRatio > 0 {
  140. minRemainingCarbAbsorptionTime = minRemainingCarbAbsorptionTime / sensitivityRatio
  141. }
  142. var remainingCarbAbsorptionTime = minRemainingCarbAbsorptionTime
  143. if mealCOB > 0 {
  144. let assumedCarbAbsorptionRate: Decimal = 20 // g/h
  145. minRemainingCarbAbsorptionTime = max(minRemainingCarbAbsorptionTime, mealCOB / assumedCarbAbsorptionRate)
  146. if let lastCarbTime = lastCarbTime {
  147. let lastCarbAgeMin = Decimal(currentTime.timeIntervalSince(lastCarbTime) / 60).jsRounded()
  148. remainingCarbAbsorptionTime = minRemainingCarbAbsorptionTime + 1.5 * (lastCarbAgeMin / 60)
  149. remainingCarbAbsorptionTime = remainingCarbAbsorptionTime.jsRounded(scale: 1)
  150. }
  151. }
  152. return remainingCarbAbsorptionTime
  153. }
  154. static func computeForecastSelection(
  155. iob: [Decimal],
  156. cob: [Decimal],
  157. uam: [Decimal],
  158. zt: [Decimal],
  159. currentGlucose: Decimal
  160. ) -> ForecastSelectionResult {
  161. // In the JS, minPredBG is only considered after insulin peak, so use dropFirst
  162. let iobAfter90min = iob.dropFirst(18) // 90m at 5m intervals = 18
  163. let cobAfter90min = cob.dropFirst(18)
  164. let uamAfter60min = uam.dropFirst(12) // 60m at 5m intervals = 12
  165. let minIOBForecastGlucose = iobAfter90min.min() ?? Decimal(999)
  166. let minCOBForecastGlucose = cobAfter90min.min() ?? Decimal(999)
  167. let minUAMForecastGlucose = uamAfter60min.min() ?? Decimal(999)
  168. let minIOBGuardGlucose = iob.min() ?? Decimal(999)
  169. let minCOBGuardGlucose = cob.min() ?? Decimal(999)
  170. let minUAMGuardGlucose = uam.min() ?? Decimal(999)
  171. let minZTGuardGlucose = zt.min() ?? Decimal(999)
  172. let maxIOBForecastGlucose = iob.max() ?? currentGlucose
  173. let maxCOBForecastGlucose = cob.max() ?? currentGlucose
  174. let maxUAMForecastGlucose = uam.max() ?? currentGlucose
  175. let lastIOBForecastGlucose = iob.last ?? currentGlucose
  176. let lastCOBForecastGlucose = cob.last ?? currentGlucose
  177. let lastUAMForecastGlucose = uam.last ?? currentGlucose
  178. let lastZTForecastGlucose = zt.last ?? currentGlucose
  179. return ForecastSelectionResult(
  180. minIOBForecastGlucose: minIOBForecastGlucose,
  181. minCOBForecastGlucose: minCOBForecastGlucose,
  182. minUAMForecastGlucose: minUAMForecastGlucose,
  183. minIOBGuardGlucose: minIOBGuardGlucose,
  184. minCOBGuardGlucose: minCOBGuardGlucose,
  185. minUAMGuardGlucose: minUAMGuardGlucose,
  186. minZTGuardGlucose: minZTGuardGlucose,
  187. maxIOBForecastGlucose: maxIOBForecastGlucose,
  188. maxCOBForecastGlucose: maxCOBForecastGlucose,
  189. maxUAMForecastGlucose: maxUAMForecastGlucose,
  190. lastIOBForecastGlucose: lastIOBForecastGlucose,
  191. lastCOBForecastGlucose: lastCOBForecastGlucose,
  192. lastUAMForecastGlucose: lastUAMForecastGlucose,
  193. lastZTForecastGlucose: lastZTForecastGlucose
  194. )
  195. }
  196. /// Mirrors the oref0 JS logic for selecting/blending min/avg/guard BGs.
  197. static func blendForecasts(
  198. selectionResult: ForecastSelectionResult,
  199. carbs: Decimal,
  200. mealCOB _: Decimal,
  201. enableUAM: Bool,
  202. carbImpactDuration: Decimal,
  203. remainingCarbImpactPeak: Decimal,
  204. fractionCarbsLeft: Decimal,
  205. threshold: Decimal,
  206. targetGlucose: Decimal,
  207. currentGlucose: Decimal
  208. ) -> ForecastBlendingResult {
  209. // 1. Calculate minZTUAMForecastGlucose ("minZTUAMPredBG" in JS)
  210. var minZTUAMForecastGlucose = selectionResult.minUAMForecastGlucose
  211. if selectionResult.minZTGuardGlucose < threshold {
  212. minZTUAMForecastGlucose = ((selectionResult.minUAMForecastGlucose + selectionResult.minZTGuardGlucose) / 2)
  213. .rounded()
  214. } else if selectionResult.minZTGuardGlucose < targetGlucose {
  215. let blendPct = (selectionResult.minZTGuardGlucose - threshold) / (targetGlucose - threshold)
  216. let blendedMinZTGuardGlucose = selectionResult.minUAMForecastGlucose * blendPct + selectionResult
  217. .minZTGuardGlucose * (1 - blendPct)
  218. minZTUAMForecastGlucose = ((selectionResult.minUAMForecastGlucose + blendedMinZTGuardGlucose) / 2).rounded()
  219. } else if selectionResult.minZTGuardGlucose > selectionResult.minUAMForecastGlucose {
  220. minZTUAMForecastGlucose = ((selectionResult.minUAMForecastGlucose + selectionResult.minZTGuardGlucose) / 2)
  221. .rounded()
  222. }
  223. // 2. avgForecastGlucose blending (like avgPredBG)
  224. let avgForecastGlucose: Decimal
  225. if selectionResult.minUAMForecastGlucose < 999, selectionResult.minCOBForecastGlucose < 999 {
  226. avgForecastGlucose = (
  227. (1 - fractionCarbsLeft) * selectionResult
  228. .lastUAMForecastGlucose + fractionCarbsLeft * selectionResult.lastCOBForecastGlucose
  229. ).rounded()
  230. } else if selectionResult.minCOBForecastGlucose < 999 {
  231. avgForecastGlucose = ((selectionResult.lastIOBForecastGlucose + selectionResult.lastCOBForecastGlucose) / 2)
  232. .rounded()
  233. } else if selectionResult.minUAMForecastGlucose < 999 {
  234. avgForecastGlucose = ((selectionResult.lastIOBForecastGlucose + selectionResult.lastUAMForecastGlucose) / 2)
  235. .rounded()
  236. } else {
  237. avgForecastGlucose = selectionResult.lastIOBForecastGlucose.rounded()
  238. }
  239. let adjustedAvgForecastGlucose = max(avgForecastGlucose, selectionResult.minZTGuardGlucose)
  240. // 3. minGuardGlucose
  241. let minGuardGlucose: Decimal
  242. if carbImpactDuration > 0 || remainingCarbImpactPeak > 0 {
  243. if enableUAM {
  244. minGuardGlucose = (
  245. fractionCarbsLeft * selectionResult
  246. .minCOBGuardGlucose + (1 - fractionCarbsLeft) * selectionResult.minUAMGuardGlucose
  247. ).rounded()
  248. } else {
  249. minGuardGlucose = selectionResult.minCOBGuardGlucose.rounded()
  250. }
  251. } else if enableUAM {
  252. minGuardGlucose = selectionResult.minUAMGuardGlucose.rounded()
  253. } else {
  254. minGuardGlucose = selectionResult.minIOBGuardGlucose.rounded()
  255. }
  256. // 4. minForecastedGlucose ("minPredBG")
  257. var minForecastedGlucose: Decimal = selectionResult.minIOBForecastGlucose.rounded()
  258. if carbs > 0 {
  259. if !enableUAM, selectionResult.minCOBForecastGlucose < 999 {
  260. minForecastedGlucose = max(selectionResult.minIOBForecastGlucose, selectionResult.minCOBForecastGlucose)
  261. } else if selectionResult.minCOBForecastGlucose < 999 {
  262. let blendedMinForecastGlucose = fractionCarbsLeft * selectionResult
  263. .minCOBForecastGlucose + (1 - fractionCarbsLeft) * minZTUAMForecastGlucose
  264. minForecastedGlucose = max(
  265. selectionResult.minIOBForecastGlucose,
  266. selectionResult.minCOBForecastGlucose,
  267. blendedMinForecastGlucose
  268. ).rounded()
  269. } else if enableUAM {
  270. minForecastedGlucose = minZTUAMForecastGlucose
  271. } else {
  272. minForecastedGlucose = minGuardGlucose
  273. }
  274. } else if enableUAM {
  275. minForecastedGlucose = max(selectionResult.minIOBForecastGlucose, minZTUAMForecastGlucose).rounded()
  276. }
  277. // Clamp minForecastedGlucose to not exceed adjustedAvgForecastGlucose
  278. minForecastedGlucose = min(minForecastedGlucose, adjustedAvgForecastGlucose)
  279. // JS: If maxCOBPredBG > bg, don't trust UAM too much
  280. if selectionResult.maxCOBForecastGlucose > currentGlucose {
  281. minForecastedGlucose = min(minForecastedGlucose, selectionResult.maxCOBForecastGlucose)
  282. }
  283. return ForecastBlendingResult(
  284. minForecastedGlucose: minForecastedGlucose,
  285. avgForecastedGlucose: adjustedAvgForecastGlucose,
  286. minGuardGlucose: minGuardGlucose
  287. )
  288. }
  289. /// Trims trailing flat-line points beyond a “lookback” count
  290. public static func trimFlatTails(_ series: [Decimal], lookback: Int) -> [Decimal] {
  291. guard series.count > lookback, lookback >= 0 else {
  292. return series
  293. }
  294. let maxToRemove = series.count - lookback
  295. let reversedSeries = series.map({ $0.jsRounded() }).reversed()
  296. var removeCount = 0
  297. for (curr, next) in zip(reversedSeries, reversedSeries.dropFirst()) {
  298. guard curr == next else {
  299. break
  300. }
  301. removeCount += 1
  302. }
  303. removeCount = min(maxToRemove, removeCount)
  304. return Array(series.dropLast(removeCount))
  305. }
  306. /// Trims trailing ZT points once they are rising and above target
  307. public static func trimZTTails(series: [Decimal], targetBG: Decimal) -> [Decimal] {
  308. let lookback = 7 // i > 6 in JS
  309. guard series.count > lookback else {
  310. return series
  311. }
  312. let maxToRemove = series.count - lookback
  313. let reversedSeries = series.map({ $0.jsRounded() }).reversed()
  314. var removeCount = 0
  315. for (curr, next) in zip(reversedSeries, reversedSeries.dropFirst()) {
  316. if next >= curr || curr <= targetBG {
  317. break
  318. }
  319. removeCount += 1
  320. }
  321. removeCount = min(maxToRemove, removeCount)
  322. return Array(series.dropLast(removeCount))
  323. }
  324. }