ForecastGenerator.swift 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  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 || trioCustomOrefVariables.isfAndCr {
  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 iobResult = forecastIOB(
  53. startingGlucose: glucose,
  54. glucoseImpactSeries: glucoseImpactSeries,
  55. iobData: iobData,
  56. carbImpact: carbImpact,
  57. dynamicIsfState: preferences.dynamicIsfState(profile: profile, trioCustomOrefVariables: trioCustomOrefVariables),
  58. insulinFactor: dynamicIsfResult?.insulinFactor,
  59. tdd: trioCustomOrefVariables.tdd(profile: profile),
  60. adjustmentFactorLogrithmic: profile.adjustmentFactor
  61. )
  62. let cobResult = forecastCOB(
  63. startingGlucose: glucose,
  64. glucoseImpactSeries: glucoseImpactSeries,
  65. carbImpact: carbImpact,
  66. carbImpactParams: carbImpactParams
  67. )
  68. let uamResult = forecastUAM(
  69. startingGlucose: glucose,
  70. glucoseImpactSeries: glucoseImpactSeries,
  71. mealData: mealData,
  72. uamCarbImpact: uamCarbImpact,
  73. carbImpact: carbImpact,
  74. iobData: iobData,
  75. dynamicIsfState: preferences.dynamicIsfState(profile: profile, trioCustomOrefVariables: trioCustomOrefVariables),
  76. insulinFactor: dynamicIsfResult?.insulinFactor,
  77. tdd: trioCustomOrefVariables.tdd(profile: profile),
  78. adjustmentFactorLogrithmic: profile.adjustmentFactor
  79. )
  80. let ztResult = forecastZT(
  81. startingGlucose: glucose,
  82. glucoseImpactSeriesWithZeroTemp: glucoseImpactSeriesWithZeroTemp,
  83. targetBG: targetGlucose,
  84. iobData: iobData,
  85. dynamicIsfState: preferences.dynamicIsfState(profile: profile, trioCustomOrefVariables: trioCustomOrefVariables),
  86. insulinFactor: dynamicIsfResult?.insulinFactor,
  87. tdd: trioCustomOrefVariables.tdd(profile: profile),
  88. adjustmentFactorLogrithmic: profile.adjustmentFactor
  89. )
  90. let initialForecasts = calculateMinMaxForecastedGlucose(
  91. currentGlucose: glucose,
  92. iobForecast: iobResult,
  93. cobForecast: cobResult,
  94. uamForecast: uamResult,
  95. ztForecast: ztResult,
  96. carbImpactDuration: carbImpactParams.carbImpactDuration,
  97. remainingCarbImpactPeak: carbImpactParams.remainingCarbImpactPeak,
  98. uamEnabled: profile.enableUAM
  99. )
  100. let blendedForecasts = Self.blendForecasts(
  101. iobResult: initialForecasts.iob,
  102. cobResult: initialForecasts.cob,
  103. uamResult: initialForecasts.uam,
  104. ztResult: initialForecasts.zt,
  105. carbs: mealData.carbs,
  106. mealCOB: mealData.mealCOB,
  107. enableUAM: profile.enableUAM,
  108. carbImpactDuration: carbImpactParams.carbImpactDuration,
  109. remainingCarbImpactPeak: carbImpactParams.remainingCarbImpactPeak,
  110. fractionCarbsLeft: mealData.carbs > 0 ? mealData.mealCOB / mealData.carbs : Decimal(0),
  111. threshold: threshold,
  112. targetGlucose: targetGlucose,
  113. currentGlucose: glucose
  114. )
  115. var eventualGlucose = eventualGlucose
  116. var finalCobForecast: [Decimal]?
  117. if mealData.mealCOB > 0, carbImpact > 0 || carbImpactParams.remainingCarbImpactPeak > 0 {
  118. finalCobForecast = cobResult.forecasts
  119. if let lastCobGlucose = cobResult.forecasts.last {
  120. eventualGlucose = max(eventualGlucose, lastCobGlucose.jsRounded())
  121. }
  122. }
  123. var finalUamForecast: [Decimal]?
  124. if profile.enableUAM, carbImpact > 0 || carbImpactParams.remainingCarbImpactPeak > 0 {
  125. finalUamForecast = uamResult.forecasts
  126. if let lastUamGlucose = uamResult.forecasts.last {
  127. eventualGlucose = max(eventualGlucose, lastUamGlucose.jsRounded())
  128. }
  129. }
  130. return ForecastResult(
  131. iob: iobResult.forecasts,
  132. cob: finalCobForecast,
  133. uam: finalUamForecast,
  134. zt: ztResult.forecasts,
  135. internalCob: cobResult.forecasts,
  136. internalUam: uamResult.forecasts,
  137. eventualGlucose: eventualGlucose,
  138. minForecastedGlucose: blendedForecasts.minForecastedGlucose,
  139. minIOBForecastedGlucose: initialForecasts.iob.minForecastGlucose,
  140. minGuardGlucose: blendedForecasts.minGuardGlucose,
  141. carbImpact: carbImpact,
  142. remainingCarbImpactPeak: carbImpactParams.remainingCarbImpactPeak,
  143. adjustedCarbRatio: adjustedCarbRatio
  144. )
  145. }
  146. /// This function does the min/max glucose forecasts at the end of the main forecast loop
  147. /// in JS. It operates on raw forecasts and there is a cross dependency between IOB
  148. /// predictions and the UAM predictions, so we need to pull out this logic here
  149. static func calculateMinMaxForecastedGlucose(
  150. currentGlucose: Decimal,
  151. iobForecast: IndividualForecast,
  152. cobForecast: IndividualForecast,
  153. uamForecast: IndividualForecast,
  154. ztForecast: IndividualForecast,
  155. carbImpactDuration: Decimal,
  156. remainingCarbImpactPeak: Decimal,
  157. uamEnabled: Bool
  158. ) -> AllForecasts {
  159. // FIXME: we need to make sure that these will all be the same length
  160. // but since they're running their loops on the same data they should be
  161. let minCount = min(
  162. iobForecast.rawForecasts.count,
  163. cobForecast.rawForecasts.count,
  164. uamForecast.rawForecasts.count
  165. )
  166. var maxIobForecastGlucose = currentGlucose
  167. var maxCobForecastGlucose = currentGlucose
  168. var maxUamForecastGlucose = currentGlucose
  169. var minIobForecastGlucose = Decimal(999)
  170. var minCobForecastGlucose = Decimal(999)
  171. var minUamForecastGlucose = Decimal(999)
  172. let insulinPeak5m = 18
  173. // start at 1 because the first entry is currentGlucose
  174. for index in 1 ..< minCount {
  175. let length = index + 1
  176. let currentIobForecastGlucose = iobForecast.rawForecasts[index]
  177. let currentCobForecastGlucose = cobForecast.rawForecasts[index]
  178. let currentUamForecastGlucose = uamForecast.rawForecasts[index]
  179. // the max calculations don't get rounded in JS
  180. if length > insulinPeak5m, currentIobForecastGlucose < minIobForecastGlucose {
  181. minIobForecastGlucose = currentIobForecastGlucose.jsRounded()
  182. }
  183. if currentIobForecastGlucose > maxIobForecastGlucose {
  184. maxIobForecastGlucose = currentIobForecastGlucose
  185. }
  186. if carbImpactDuration != 0 || remainingCarbImpactPeak > 0, length > insulinPeak5m,
  187. currentCobForecastGlucose < minCobForecastGlucose
  188. {
  189. minCobForecastGlucose = currentCobForecastGlucose.jsRounded()
  190. }
  191. // BUG: I can't tell if the comparison against maxIobForecastGlucose is
  192. // intentional or not, but this is what is in JS
  193. if carbImpactDuration != 0 || remainingCarbImpactPeak > 0, currentCobForecastGlucose > maxIobForecastGlucose {
  194. maxCobForecastGlucose = currentCobForecastGlucose
  195. }
  196. if uamEnabled, length > 12, currentUamForecastGlucose < minUamForecastGlucose {
  197. minUamForecastGlucose = currentUamForecastGlucose.jsRounded()
  198. }
  199. // BUG: I can't tell if the comparison against maxIobForecastGlucose is
  200. // intentional or not, but this is what is in JS
  201. if uamEnabled, currentUamForecastGlucose > maxIobForecastGlucose {
  202. maxUamForecastGlucose = currentUamForecastGlucose
  203. }
  204. }
  205. minIobForecastGlucose = max(39, minIobForecastGlucose)
  206. minCobForecastGlucose = max(39, minCobForecastGlucose)
  207. minUamForecastGlucose = max(39, minUamForecastGlucose)
  208. return AllForecasts(
  209. iob: IOBForecast(
  210. forecasts: iobForecast.forecasts,
  211. minGuardGlucose: iobForecast.minGuardGlucose,
  212. minForecastGlucose: minIobForecastGlucose,
  213. maxForecastGlucose: maxIobForecastGlucose,
  214. lastForecastGlucose: iobForecast.rawForecasts.last ?? currentGlucose
  215. ),
  216. zt: ZTForecast(
  217. forecasts: ztForecast.forecasts,
  218. minGuardGlucose: ztForecast.minGuardGlucose
  219. ),
  220. cob: COBForecast(
  221. forecasts: cobForecast.forecasts,
  222. minGuardGlucose: cobForecast.minGuardGlucose,
  223. minForecastGlucose: minCobForecastGlucose,
  224. maxForecastGlucose: maxCobForecastGlucose,
  225. lastForecastGlucose: cobForecast.rawForecasts.last ?? currentGlucose
  226. ),
  227. uam: UAMForecast(
  228. forecasts: uamForecast.forecasts,
  229. minGuardGlucose: uamForecast.minGuardGlucose,
  230. minForecastGlucose: minUamForecastGlucose,
  231. maxForecastGlucose: maxUamForecastGlucose,
  232. duration: uamForecast.duration!,
  233. lastForecastGlucose: uamForecast.rawForecasts.last ?? currentGlucose
  234. ) // I don't love the force unwrap here but it should always be set
  235. )
  236. }
  237. /// Calculates the dynamic remaining carb absorption time in hours, per oref0 logic.
  238. /// - Parameters:
  239. /// - sensitivityRatio: ratio from autosens (usually 1.0 if not present)
  240. /// - mealCOB: unabsorbed carbs (grams)
  241. /// - lastCarbTime: timestamp of last carb entry (Date? or nil)
  242. /// - currentTime: now
  243. /// - Returns: Remaining CA time in hours (Decimal)
  244. static func calculateRemainingCarbAbsorptionTime(
  245. sensitivityRatio: Decimal,
  246. maxMealAbsorptionTime _: Decimal,
  247. mealCOB: Decimal,
  248. lastCarbTime: Date?,
  249. currentTime: Date
  250. ) -> Decimal {
  251. var minRemainingCarbAbsorptionTime: Decimal = 3 // hours
  252. if sensitivityRatio > 0 {
  253. minRemainingCarbAbsorptionTime = minRemainingCarbAbsorptionTime / sensitivityRatio
  254. }
  255. var remainingCarbAbsorptionTime = minRemainingCarbAbsorptionTime
  256. if mealCOB > 0 {
  257. let assumedCarbAbsorptionRate: Decimal = 20 // g/h
  258. minRemainingCarbAbsorptionTime = max(minRemainingCarbAbsorptionTime, mealCOB / assumedCarbAbsorptionRate)
  259. if let lastCarbTime = lastCarbTime {
  260. let lastCarbAgeMin = Decimal(currentTime.timeIntervalSince(lastCarbTime) / 60).jsRounded()
  261. remainingCarbAbsorptionTime = minRemainingCarbAbsorptionTime + (1.5 * lastCarbAgeMin) / 60
  262. remainingCarbAbsorptionTime = remainingCarbAbsorptionTime.jsRounded(scale: 1)
  263. }
  264. }
  265. return remainingCarbAbsorptionTime
  266. }
  267. /// Mirrors the oref0 JS logic for selecting/blending min/avg/guard BGs.
  268. static func blendForecasts(
  269. iobResult: IOBForecast,
  270. cobResult: COBForecast,
  271. uamResult: UAMForecast,
  272. ztResult: ZTForecast,
  273. carbs: Decimal,
  274. mealCOB _: Decimal,
  275. enableUAM: Bool,
  276. carbImpactDuration: Decimal,
  277. remainingCarbImpactPeak: Decimal,
  278. fractionCarbsLeft: Decimal,
  279. threshold: Decimal,
  280. targetGlucose: Decimal,
  281. currentGlucose: Decimal
  282. ) -> ForecastBlendingResult {
  283. // 1. Calculate minZTUAMForecastGlucose ("minZTUAMPredBG" in JS)
  284. var minZTUAMForecastGlucose = uamResult.minForecastGlucose
  285. if ztResult.minGuardGlucose < threshold {
  286. minZTUAMForecastGlucose = (uamResult.minForecastGlucose + ztResult.minGuardGlucose) / 2
  287. } else if ztResult.minGuardGlucose < targetGlucose {
  288. let blendPct = (ztResult.minGuardGlucose - threshold) / (targetGlucose - threshold)
  289. let blendedMinZTGuardGlucose = uamResult.minForecastGlucose * blendPct + ztResult.minGuardGlucose * (1 - blendPct)
  290. minZTUAMForecastGlucose = (uamResult.minForecastGlucose + blendedMinZTGuardGlucose) / 2
  291. } else if ztResult.minGuardGlucose > uamResult.minForecastGlucose {
  292. minZTUAMForecastGlucose = (uamResult.minForecastGlucose + ztResult.minGuardGlucose) / 2
  293. }
  294. minZTUAMForecastGlucose = minZTUAMForecastGlucose.jsRounded()
  295. // 2. avgForecastGlucose blending (like avgPredBG)
  296. let avgerageForecastGlucose: Decimal
  297. if uamResult.minForecastGlucose < 999, cobResult.minForecastGlucose < 999 {
  298. avgerageForecastGlucose = (
  299. (1 - fractionCarbsLeft) * uamResult.lastForecastGlucose + fractionCarbsLeft * cobResult.lastForecastGlucose
  300. ).jsRounded()
  301. } else if cobResult.minForecastGlucose < 999 {
  302. avgerageForecastGlucose =
  303. ((iobResult.lastForecastGlucose + cobResult.lastForecastGlucose) / 2)
  304. .jsRounded()
  305. } else if uamResult.minForecastGlucose < 999 {
  306. avgerageForecastGlucose =
  307. ((iobResult.lastForecastGlucose + uamResult.lastForecastGlucose) / 2)
  308. .jsRounded()
  309. } else {
  310. avgerageForecastGlucose = iobResult.lastForecastGlucose.jsRounded()
  311. }
  312. let adjustedAverageForecastGlucose = max(avgerageForecastGlucose, ztResult.minGuardGlucose)
  313. // 3. minGuardGlucose
  314. let minGuardGlucose: Decimal
  315. if carbImpactDuration > 0 || remainingCarbImpactPeak > 0 {
  316. if enableUAM {
  317. minGuardGlucose = (
  318. fractionCarbsLeft * cobResult.minGuardGlucose + (1 - fractionCarbsLeft) * uamResult.minGuardGlucose
  319. ).jsRounded()
  320. } else {
  321. minGuardGlucose = cobResult.minGuardGlucose.jsRounded()
  322. }
  323. } else if enableUAM {
  324. minGuardGlucose = uamResult.minGuardGlucose.jsRounded()
  325. } else {
  326. minGuardGlucose = iobResult.minGuardGlucose.jsRounded()
  327. }
  328. // 4. minForecastedGlucose ("minPredBG")
  329. var minForecastedGlucose: Decimal = iobResult.minForecastGlucose.jsRounded()
  330. if carbs > 0 {
  331. if !enableUAM, cobResult.minForecastGlucose < 999 {
  332. minForecastedGlucose = max(iobResult.minForecastGlucose, cobResult.minForecastGlucose)
  333. } else if cobResult.minForecastGlucose < 999 {
  334. let blendedMinForecastGlucose = fractionCarbsLeft * cobResult
  335. .minForecastGlucose + (1 - fractionCarbsLeft) * minZTUAMForecastGlucose
  336. minForecastedGlucose = max(
  337. iobResult.minForecastGlucose,
  338. cobResult.minForecastGlucose,
  339. blendedMinForecastGlucose
  340. ).jsRounded()
  341. } else if enableUAM {
  342. minForecastedGlucose = minZTUAMForecastGlucose
  343. } else {
  344. minForecastedGlucose = minGuardGlucose
  345. }
  346. } else if enableUAM {
  347. minForecastedGlucose = max(iobResult.minForecastGlucose, minZTUAMForecastGlucose).jsRounded()
  348. }
  349. // Clamp minForecastedGlucose to not exceed adjustedAvgForecastGlucose
  350. minForecastedGlucose = min(minForecastedGlucose, adjustedAverageForecastGlucose)
  351. // JS: If maxCOBPredBG > bg, don't trust UAM too much
  352. if cobResult.maxForecastGlucose > currentGlucose {
  353. minForecastedGlucose = min(minForecastedGlucose, cobResult.maxForecastGlucose)
  354. }
  355. return ForecastBlendingResult(
  356. minForecastedGlucose: minForecastedGlucose,
  357. avgForecastedGlucose: adjustedAverageForecastGlucose,
  358. minGuardGlucose: minGuardGlucose
  359. )
  360. }
  361. /// Trims trailing flat-line points beyond a “lookback” count
  362. public static func trimFlatTails(_ series: [Decimal], lookback: Int) -> [Decimal] {
  363. guard series.count > lookback, lookback >= 0 else {
  364. return series
  365. }
  366. let maxToRemove = series.count - lookback
  367. let reversedSeries = series.map({ $0.jsRounded() }).reversed()
  368. var removeCount = 0
  369. for (curr, next) in zip(reversedSeries, reversedSeries.dropFirst()) {
  370. guard curr == next else {
  371. break
  372. }
  373. removeCount += 1
  374. }
  375. removeCount = min(maxToRemove, removeCount)
  376. return Array(series.dropLast(removeCount))
  377. }
  378. /// Trims trailing ZT points once they are rising and above target
  379. public static func trimZTTails(series: [Decimal], targetBG: Decimal) -> [Decimal] {
  380. let lookback = 7 // i > 6 in JS
  381. guard series.count > lookback else {
  382. return series
  383. }
  384. let maxToRemove = series.count - lookback
  385. let reversedSeries = series.map({ $0.jsRounded() }).reversed()
  386. var removeCount = 0
  387. for (curr, next) in zip(reversedSeries, reversedSeries.dropFirst()) {
  388. if next >= curr || curr <= targetBG {
  389. break
  390. }
  391. removeCount += 1
  392. }
  393. removeCount = min(maxToRemove, removeCount)
  394. return Array(series.dropLast(removeCount))
  395. }
  396. }