DosingEngine.swift 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
  1. import Foundation
  2. enum DosingEngine {
  3. struct DosingInputs {
  4. let reason: String
  5. let carbsRequired: (carbs: Decimal, minutes: Decimal)?
  6. }
  7. /// struct to keep the relevant state needed for the output of the SMB decision logic
  8. struct SMBDecision {
  9. let isEnabled: Bool
  10. let manualBolusError: Int?
  11. let minGuardGlucose: Decimal?
  12. let reason: String?
  13. }
  14. /// checks to see if SMB are enabled via the profile
  15. private static func isProfileSmbEnabled(
  16. currentGlucose: Decimal,
  17. adjustedTargetGlucose: Decimal,
  18. profile: Profile,
  19. meal: ComputedCarbs,
  20. trioCustomOrefVariables: TrioCustomOrefVariables,
  21. clock: Date
  22. ) throws -> Bool {
  23. if trioCustomOrefVariables.smbIsOff {
  24. return false
  25. }
  26. if try isSmbScheduledOff(trioCustomOrefVariables: trioCustomOrefVariables, clock: clock) {
  27. return false
  28. }
  29. if trioCustomOrefVariables.shouldProtectDueToHIGH {
  30. return false
  31. }
  32. if !profile.allowSMBWithHighTemptarget, profile.temptargetSet == true, adjustedTargetGlucose > 100 {
  33. return false
  34. }
  35. if profile.enableSMBAlways {
  36. return true
  37. }
  38. if profile.enableSMBWithCOB, meal.mealCOB > 0 {
  39. return true
  40. }
  41. if profile.enableSMBAfterCarbs, meal.carbs > 0 {
  42. return true
  43. }
  44. if profile.enableSMBWithTemptarget, profile.temptargetSet == true, adjustedTargetGlucose < 100 {
  45. return true
  46. }
  47. if profile.enableSMBHighBg, currentGlucose >= profile.enableSMBHighBgTarget {
  48. return true
  49. }
  50. return false
  51. }
  52. /// helper function to check if SMB is scheduled off given the current timezone
  53. private static func isSmbScheduledOff(trioCustomOrefVariables: TrioCustomOrefVariables, clock: Date) throws -> Bool {
  54. guard trioCustomOrefVariables.smbIsScheduledOff else {
  55. return false
  56. }
  57. guard let currentHour = clock.hourInLocalTime.map({ Decimal($0) }) else {
  58. throw CalendarError.invalidCalendarHourOnly
  59. }
  60. let startHour = trioCustomOrefVariables.start
  61. let endHour = trioCustomOrefVariables.end
  62. // SMBs will be disabled from [start, end) local time
  63. if startHour < endHour, currentHour >= startHour && currentHour < endHour {
  64. // disable when the schedule does not wrap around midnight
  65. return true
  66. } else if startHour > endHour, currentHour >= startHour || currentHour < endHour {
  67. // disable when the schedule does wrap around midnight
  68. return true
  69. } else if startHour == 0, endHour == 0 {
  70. // schedule specifies the entire day
  71. return true
  72. } else if startHour == endHour, currentHour == startHour {
  73. // one hour of scheduled off SMB
  74. return true
  75. }
  76. return false
  77. }
  78. /// helper function for reason string glucose output
  79. private static func convertGlucose(profile: Profile, glucose: Decimal) -> Decimal {
  80. let units = profile.outUnits ?? .mgdL
  81. switch units {
  82. case .mgdL: return glucose.jsRounded()
  83. case .mmolL: return glucose.asMmolL
  84. }
  85. }
  86. /// Top level smb enabling logic
  87. ///
  88. /// This function includes both the profile / customOrefVariable checks from JS `enable_smb` as
  89. /// well as some of the later checks from `determineBasal` that can disable SMB
  90. static func makeSMBDosingDecision(
  91. profile: Profile,
  92. meal: ComputedCarbs,
  93. currentGlucose: Decimal,
  94. adjustedTargetGlucose: Decimal,
  95. minGuardGlucose: Decimal,
  96. threshold: Decimal,
  97. glucoseStatus: GlucoseStatus,
  98. trioCustomOrefVariables: TrioCustomOrefVariables,
  99. clock: Date
  100. ) throws -> SMBDecision {
  101. var smbIsEnabled = try isProfileSmbEnabled(
  102. currentGlucose: currentGlucose,
  103. adjustedTargetGlucose: adjustedTargetGlucose,
  104. profile: profile,
  105. meal: meal,
  106. trioCustomOrefVariables: trioCustomOrefVariables,
  107. clock: clock
  108. )
  109. // these last two checks are implemented outside of the core enable_smb
  110. // function in JS but we should keep all of the smb enabling logic
  111. // in one place. Note: We can't shortcut the return value because
  112. // the determineBasal logic always evaluates this logic
  113. var manualBolusError: Int?
  114. var minGuardGlucoseDecision: Decimal?
  115. var reason: String?
  116. if smbIsEnabled, minGuardGlucose < threshold {
  117. manualBolusError = 1
  118. minGuardGlucoseDecision = minGuardGlucose
  119. smbIsEnabled = false
  120. }
  121. let maxDeltaGlucoseThreshold = min(profile.maxDeltaBgThreshold, 0.4)
  122. if glucoseStatus.maxDelta > maxDeltaGlucoseThreshold * currentGlucose {
  123. reason =
  124. "maxDelta \(convertGlucose(profile: profile, glucose: glucoseStatus.maxDelta)) > \(100 * maxDeltaGlucoseThreshold)% of BG \(convertGlucose(profile: profile, glucose: currentGlucose)) - SMB disabled!, "
  125. smbIsEnabled = false
  126. }
  127. return SMBDecision(
  128. isEnabled: smbIsEnabled,
  129. manualBolusError: manualBolusError,
  130. minGuardGlucose: minGuardGlucoseDecision,
  131. reason: reason
  132. )
  133. }
  134. static func prepareDosingInputs(
  135. profile: Profile,
  136. mealData: ComputedCarbs,
  137. forecast: ForecastResult,
  138. naiveEventualGlucose: Decimal,
  139. threshold: Decimal,
  140. glucoseImpact: Decimal,
  141. deviation: Decimal,
  142. currentBasal: Decimal,
  143. overrideFactor: Decimal,
  144. adjustedSensitivity: Decimal,
  145. isfReason: String,
  146. tddReason: String,
  147. targetLog: String // This is a pre-formatted string from the JS
  148. ) -> DosingInputs {
  149. let lastIOBpredBG = forecast.iob.last ?? 0
  150. let lastCOBpredBG = forecast.cob?.last
  151. let lastUAMpredBG = forecast.uam?.last
  152. var reason =
  153. "\(isfReason), COB: \(mealData.mealCOB), Dev: \(deviation), BGI: \(glucoseImpact), CR: \(forecast.adjustedCarbRatio), Target: \(targetLog), minPredBG \(forecast.minForecastedGlucose), minGuardBG \(forecast.minGuardGlucose), IOBpredBG \(lastIOBpredBG)"
  154. if let lastCOB = lastCOBpredBG {
  155. reason += ", COBpredBG \(lastCOB)"
  156. }
  157. if let lastUAM = lastUAMpredBG {
  158. reason += ", UAMpredBG \(lastUAM)"
  159. }
  160. reason += tddReason
  161. reason += "; " // Start of conclusion
  162. let carbsRequiredResult = calculateCarbsRequired(
  163. profile: profile,
  164. mealData: mealData,
  165. naiveEventualGlucose: naiveEventualGlucose,
  166. minGuardGlucose: forecast.minGuardGlucose,
  167. threshold: threshold,
  168. iobForecast: forecast.iob,
  169. cobForecast: forecast.internalCob,
  170. carbImpact: forecast.carbImpact,
  171. remainingCarbImpactPeak: forecast.remainingCarbImpactPeak,
  172. currentBasal: currentBasal,
  173. overrideFactor: overrideFactor,
  174. adjustedSensitivity: adjustedSensitivity,
  175. adjustedCarbRatio: forecast.adjustedCarbRatio
  176. )
  177. if let result = carbsRequiredResult {
  178. reason += "\(result.carbs) add'l carbs req w/in \(result.minutes)m; "
  179. }
  180. return DosingInputs(reason: reason, carbsRequired: carbsRequiredResult)
  181. }
  182. /// Calculates the carbohydrates required to avoid a potential hypoglycemic event.
  183. ///
  184. /// - Returns: A tuple containing the required carbs and minutes until BG is below threshold, or `nil` if no carbs are required.
  185. static func calculateCarbsRequired(
  186. profile: Profile,
  187. mealData: ComputedCarbs,
  188. naiveEventualGlucose: Decimal,
  189. minGuardGlucose: Decimal,
  190. threshold: Decimal,
  191. iobForecast: [Decimal],
  192. cobForecast: [Decimal],
  193. carbImpact: Decimal,
  194. remainingCarbImpactPeak: Decimal,
  195. currentBasal: Decimal,
  196. overrideFactor: Decimal,
  197. adjustedSensitivity: Decimal,
  198. adjustedCarbRatio: Decimal
  199. ) -> (carbs: Decimal, minutes: Decimal)? {
  200. var carbsRequiredGlucose = naiveEventualGlucose
  201. if naiveEventualGlucose < 40 {
  202. carbsRequiredGlucose = min(minGuardGlucose, naiveEventualGlucose)
  203. }
  204. let glucoseUndershoot = threshold - carbsRequiredGlucose
  205. var minutesAboveThreshold = Decimal(240)
  206. let useCOBForecast = mealData.mealCOB > 0 && (carbImpact > 0 || remainingCarbImpactPeak > 0)
  207. let forecast = useCOBForecast ? cobForecast : iobForecast
  208. // At this point in the JS the forecasts have already been rounded
  209. for (index, glucose) in forecast.map({ $0.jsRounded() }).enumerated() {
  210. if glucose < threshold {
  211. minutesAboveThreshold = Decimal(5) * Decimal(index)
  212. break
  213. }
  214. }
  215. let zeroTempDuration = minutesAboveThreshold
  216. let zeroTempEffect = currentBasal * adjustedSensitivity * overrideFactor * zeroTempDuration / 60
  217. let mealCarbs = mealData.carbs
  218. let cobForCarbsRequired = max(0, mealData.mealCOB - (Decimal(0.25) * mealCarbs))
  219. guard adjustedCarbRatio > 0 else { return nil }
  220. let carbSensitivityFactor = adjustedSensitivity / adjustedCarbRatio
  221. guard carbSensitivityFactor > 0 else { return nil }
  222. var carbsRequired = (glucoseUndershoot - zeroTempEffect) / carbSensitivityFactor - cobForCarbsRequired
  223. carbsRequired = carbsRequired.rounded(toPlaces: 0)
  224. let carbsRequiredThreshold = profile.carbsReqThreshold
  225. if carbsRequired >= carbsRequiredThreshold, minutesAboveThreshold <= 45 {
  226. return (carbs: carbsRequired, minutes: minutesAboveThreshold)
  227. }
  228. return nil
  229. }
  230. /// Determines if a low glucose suspend is warranted.
  231. ///
  232. /// This function checks for low glucose conditions and may modify the determination object
  233. /// with a suspend recommendation and an updated reason string.
  234. ///
  235. /// - Returns: A tuple containing:
  236. /// - `setTempBasal`: A `Bool` that is `true` if `determineBasal` should exit and apply the recommendation immediately.
  237. /// - `determination`: The (potentially modified) determination object.
  238. static func lowGlucoseSuspend(
  239. currentGlucose: Decimal,
  240. minGuardGlucose: Decimal,
  241. iob: Decimal,
  242. minDelta: Decimal,
  243. expectedDelta: Decimal,
  244. threshold: Decimal,
  245. overrideFactor: Decimal,
  246. profile: Profile,
  247. adjustedSensitivity: Decimal,
  248. targetGlucose: Decimal,
  249. currentTemp: TempBasal,
  250. determination: Determination
  251. ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
  252. var newDetermination = determination
  253. guard let currentBasal = profile.currentBasal else {
  254. // Should have been checked earlier
  255. throw TempBasalFunctionError.invalidBasalRateOnProfile
  256. }
  257. let suspendThreshold = -currentBasal * overrideFactor * 20 / 60
  258. if currentGlucose < threshold, iob < suspendThreshold, minDelta > 0, minDelta > expectedDelta {
  259. let iobString = String(describing: iob)
  260. let suspendString = String(describing: suspendThreshold.jsRounded(scale: 2))
  261. let minDeltaString = String(describing: convertGlucose(profile: profile, glucose: minDelta))
  262. let expectedDeltaString = String(describing: convertGlucose(profile: profile, glucose: expectedDelta))
  263. newDetermination
  264. .reason +=
  265. "IOB \(iobString) < \(suspendString) and minDelta \(minDeltaString) > expectedDelta \(expectedDeltaString); "
  266. return (shouldSetTempBasal: false, determination: newDetermination)
  267. } else if currentGlucose < threshold || minGuardGlucose < threshold {
  268. let minGuardGlucoseString = String(describing: convertGlucose(profile: profile, glucose: minGuardGlucose))
  269. let thresholdString = String(describing: convertGlucose(profile: profile, glucose: threshold))
  270. newDetermination.reason += "minGuardBG \(minGuardGlucoseString) < \(thresholdString)"
  271. let glucoseUndershoot = targetGlucose - minGuardGlucose
  272. if minGuardGlucose < threshold {
  273. newDetermination.manualBolusErrorString = 2
  274. newDetermination.minGuardBG = minGuardGlucose
  275. }
  276. let worstCaseInsulinRequired = glucoseUndershoot / adjustedSensitivity
  277. var durationRequired = (60 * worstCaseInsulinRequired / (currentBasal * overrideFactor)).jsRounded()
  278. durationRequired = (durationRequired / 30).jsRounded() * 30
  279. durationRequired = max(30, min(120, durationRequired))
  280. let finalDetermination = try TempBasalFunctions.setTempBasal(
  281. rate: 0,
  282. duration: durationRequired,
  283. profile: profile,
  284. determination: newDetermination,
  285. currentTemp: currentTemp
  286. )
  287. return (shouldSetTempBasal: true, determination: finalDetermination)
  288. }
  289. return (shouldSetTempBasal: false, determination: determination)
  290. }
  291. /// Determines if a neutral temp basal should be skipped to avoid pump alerts.
  292. ///
  293. /// - Returns: A tuple containing:
  294. /// - `shouldSetTempBasal`: A `Bool` that is `true` if `determineBasal` should exit and apply the recommendation immediately.
  295. /// - `determination`: The (potentially modified) determination object.
  296. static func skipNeutralTempBasal(
  297. smbIsEnabled: Bool,
  298. profile: Profile,
  299. clock: Date,
  300. currentTemp: TempBasal,
  301. determination: Determination
  302. ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
  303. guard profile.skipNeutralTemps else {
  304. return (shouldSetTempBasal: false, determination: determination)
  305. }
  306. guard let totalMinutes = clock.minutesSinceMidnight else {
  307. throw CalendarError.invalidCalendar
  308. }
  309. let minute = totalMinutes % 60
  310. guard minute >= 55 else {
  311. return (shouldSetTempBasal: false, determination: determination)
  312. }
  313. if !smbIsEnabled {
  314. var newDetermination = determination
  315. let minutesLeft = 60 - minute
  316. newDetermination
  317. .reason +=
  318. "; Canceling temp at \(minutesLeft)min before turn of the hour to avoid beeping of MDT. SMB are disabled anyways."
  319. let finalDetermination = try TempBasalFunctions.setTempBasal(
  320. rate: 0,
  321. duration: 0,
  322. profile: profile,
  323. determination: newDetermination,
  324. currentTemp: currentTemp
  325. )
  326. return (shouldSetTempBasal: true, determination: finalDetermination)
  327. } else {
  328. // In the JS, this path logs to the console but does not modify determination.
  329. // We will do nothing here to match that behavior.
  330. return (shouldSetTempBasal: false, determination: determination)
  331. }
  332. }
  333. }