DosingEngine.swift 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927
  1. import Foundation
  2. enum DosingEngine {
  3. struct DosingInputs {
  4. let reason: String
  5. let carbsRequired: (carbs: Decimal, minutes: Decimal)?
  6. let rawCarbsRequired: Decimal
  7. }
  8. /// struct to keep the relevant state needed for the output of the SMB decision logic
  9. struct SMBDecision {
  10. let isEnabled: Bool
  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 !profile.allowSMBWithHighTemptarget, profile.temptargetSet == true, adjustedTargetGlucose > 100 {
  30. return false
  31. }
  32. if profile.enableSMBAlways {
  33. return true
  34. }
  35. if profile.enableSMBWithCOB, meal.mealCOB > 0 {
  36. return true
  37. }
  38. if profile.enableSMBAfterCarbs, meal.carbs > 0 {
  39. return true
  40. }
  41. if profile.enableSMBWithTemptarget, profile.temptargetSet == true, adjustedTargetGlucose < 100 {
  42. return true
  43. }
  44. if profile.enableSMBHighBg, currentGlucose >= profile.enableSMBHighBgTarget {
  45. return true
  46. }
  47. return false
  48. }
  49. /// helper function to check if SMB is scheduled off given the current timezone
  50. private static func isSmbScheduledOff(trioCustomOrefVariables: TrioCustomOrefVariables, clock: Date) throws -> Bool {
  51. guard trioCustomOrefVariables.smbIsScheduledOff else {
  52. return false
  53. }
  54. guard let currentHour = clock.hourInLocalTime.map({ Decimal($0) }) else {
  55. throw CalendarError.invalidCalendarHourOnly
  56. }
  57. let startHour = trioCustomOrefVariables.start
  58. let endHour = trioCustomOrefVariables.end
  59. // SMBs will be disabled from [start, end) local time
  60. if startHour < endHour, currentHour >= startHour && currentHour < endHour {
  61. // disable when the schedule does not wrap around midnight
  62. return true
  63. } else if startHour > endHour, currentHour >= startHour || currentHour < endHour {
  64. // disable when the schedule does wrap around midnight
  65. return true
  66. } else if startHour == 0, endHour == 0 {
  67. // schedule specifies the entire day
  68. return true
  69. } else if startHour == endHour, currentHour == startHour {
  70. // one hour of scheduled off SMB
  71. return true
  72. }
  73. return false
  74. }
  75. /// helper function for reason string glucose output
  76. static func convertGlucose(profile: Profile, glucose: Decimal) -> Decimal {
  77. let units = profile.outUnits ?? .mgdL
  78. switch units {
  79. case .mgdL: return glucose.jsRounded()
  80. case .mmolL: return glucose.asMmolL
  81. }
  82. }
  83. /// Top level smb enabling logic
  84. ///
  85. /// This function includes both the profile / customOrefVariable checks from JS `enable_smb` as
  86. /// well as some of the later checks from `determineBasal` that can disable SMB
  87. static func makeSMBDosingDecision(
  88. profile: Profile,
  89. meal: ComputedCarbs,
  90. currentGlucose: Decimal,
  91. adjustedTargetGlucose: Decimal,
  92. minGuardGlucose: Decimal,
  93. threshold: Decimal,
  94. glucoseStatus: GlucoseStatus,
  95. trioCustomOrefVariables: TrioCustomOrefVariables,
  96. clock: Date
  97. ) throws -> SMBDecision {
  98. var smbIsEnabled = try isProfileSmbEnabled(
  99. currentGlucose: currentGlucose,
  100. adjustedTargetGlucose: adjustedTargetGlucose,
  101. profile: profile,
  102. meal: meal,
  103. trioCustomOrefVariables: trioCustomOrefVariables,
  104. clock: clock
  105. )
  106. // these last two checks are implemented outside of the core enable_smb
  107. // function in JS but we should keep all of the smb enabling logic
  108. // in one place. Note: We can't shortcut the return value because
  109. // the determineBasal logic always evaluates this logic
  110. var minGuardGlucoseDecision: Decimal?
  111. var reason: String?
  112. if smbIsEnabled, minGuardGlucose < threshold {
  113. minGuardGlucoseDecision = minGuardGlucose
  114. smbIsEnabled = false
  115. }
  116. let maxDeltaGlucoseThreshold = min(profile.maxDeltaBgThreshold, 0.4)
  117. if glucoseStatus.maxDelta > maxDeltaGlucoseThreshold * currentGlucose {
  118. reason =
  119. "maxDelta \(convertGlucose(profile: profile, glucose: glucoseStatus.maxDelta)) > \(100 * maxDeltaGlucoseThreshold)% of BG \(convertGlucose(profile: profile, glucose: currentGlucose)) - SMB disabled!, "
  120. smbIsEnabled = false
  121. }
  122. return SMBDecision(
  123. isEnabled: smbIsEnabled,
  124. minGuardGlucose: minGuardGlucoseDecision,
  125. reason: reason
  126. )
  127. }
  128. static func prepareDosingInputs(
  129. profile: Profile,
  130. mealData: ComputedCarbs,
  131. forecast: ForecastResult,
  132. naiveEventualGlucose: Decimal,
  133. threshold: Decimal,
  134. glucoseImpact: Decimal,
  135. deviation: Decimal,
  136. currentBasal: Decimal,
  137. overrideFactor: Decimal,
  138. adjustedSensitivity: Decimal,
  139. isfReason: String,
  140. tddReason: String,
  141. targetLog: String // This is a pre-formatted string from the JS
  142. ) -> DosingInputs {
  143. let lastIOBpredBG = (forecast.iob.last ?? 0).jsRounded()
  144. let lastCOBpredBG = forecast.cob?.last?.jsRounded()
  145. let lastUAMpredBG = forecast.uam?.last?.jsRounded()
  146. var reason =
  147. "\(isfReason), COB: \(mealData.mealCOB.jsRounded()), Dev: \(deviation.jsRounded()), BGI: \(glucoseImpact.jsRounded()), CR: \(forecast.adjustedCarbRatio.jsRounded(scale: 1)), Target: \(targetLog), minPredBG \(forecast.minForecastedGlucose.jsRounded()), minGuardBG \(forecast.minGuardGlucose.jsRounded()), IOBpredBG \(lastIOBpredBG)"
  148. if let lastCOB = lastCOBpredBG {
  149. reason += ", COBpredBG \(lastCOB)"
  150. }
  151. if let lastUAM = lastUAMpredBG {
  152. reason += ", UAMpredBG \(lastUAM)"
  153. }
  154. reason += tddReason
  155. reason += "; " // Start of conclusion
  156. let carbsRequiredResult = calculateCarbsRequired(
  157. mealData: mealData,
  158. naiveEventualGlucose: naiveEventualGlucose,
  159. minGuardGlucose: forecast.minGuardGlucose,
  160. threshold: threshold,
  161. iobForecast: forecast.iob,
  162. cobForecast: forecast.internalCob,
  163. carbImpact: forecast.carbImpact,
  164. remainingCarbImpactPeak: forecast.remainingCarbImpactPeak,
  165. currentBasal: currentBasal,
  166. overrideFactor: overrideFactor,
  167. adjustedSensitivity: adjustedSensitivity,
  168. adjustedCarbRatio: forecast.adjustedCarbRatio
  169. )
  170. var carbsRequired: (carbs: Decimal, minutes: Decimal)?
  171. if carbsRequiredResult.carbs >= profile.carbsReqThreshold, carbsRequiredResult.minutes <= 45 {
  172. // Note: carbs message is added in DetermineBasalGenerator after smbReason to match JS order
  173. carbsRequired = carbsRequiredResult
  174. }
  175. return DosingInputs(reason: reason, carbsRequired: carbsRequired, rawCarbsRequired: carbsRequiredResult.carbs)
  176. }
  177. /// Calculates the carbohydrates required to avoid a potential hypoglycemic event.
  178. ///
  179. /// - Returns: A tuple containing the required carbs and minutes until glucose is below threshold.
  180. static func calculateCarbsRequired(
  181. mealData: ComputedCarbs,
  182. naiveEventualGlucose: Decimal,
  183. minGuardGlucose: Decimal,
  184. threshold: Decimal,
  185. iobForecast: [Decimal],
  186. cobForecast: [Decimal],
  187. carbImpact: Decimal,
  188. remainingCarbImpactPeak: Decimal,
  189. currentBasal: Decimal,
  190. overrideFactor: Decimal,
  191. adjustedSensitivity: Decimal,
  192. adjustedCarbRatio: Decimal
  193. ) -> (carbs: Decimal, minutes: Decimal) {
  194. var carbsRequiredGlucose = naiveEventualGlucose
  195. if naiveEventualGlucose < 40 {
  196. carbsRequiredGlucose = min(minGuardGlucose, naiveEventualGlucose)
  197. }
  198. let glucoseUndershoot = threshold - carbsRequiredGlucose
  199. var minutesAboveThreshold = Decimal(240)
  200. let useCOBForecast = mealData.mealCOB > 0 && (carbImpact > 0 || remainingCarbImpactPeak > 0)
  201. let forecast = useCOBForecast ? cobForecast : iobForecast
  202. // At this point in the JS the forecasts have already been rounded
  203. for (index, glucose) in forecast.map({ $0.jsRounded() }).enumerated() {
  204. if glucose < threshold {
  205. minutesAboveThreshold = Decimal(5) * Decimal(index)
  206. break
  207. }
  208. }
  209. let zeroTempDuration = minutesAboveThreshold
  210. let zeroTempEffect = currentBasal * adjustedSensitivity * overrideFactor * zeroTempDuration / 60
  211. let mealCarbs = mealData.carbs
  212. let cobForCarbsRequired = max(0, mealData.mealCOB - (Decimal(0.25) * mealCarbs))
  213. guard adjustedCarbRatio > 0 else { return (carbs: 0, minutes: minutesAboveThreshold) }
  214. let carbSensitivityFactor = adjustedSensitivity / adjustedCarbRatio
  215. guard carbSensitivityFactor > 0 else { return (carbs: 0, minutes: minutesAboveThreshold) }
  216. var carbsRequired = (glucoseUndershoot - zeroTempEffect) / carbSensitivityFactor - cobForCarbsRequired
  217. carbsRequired = carbsRequired.jsRounded()
  218. return (carbs: carbsRequired, minutes: minutesAboveThreshold)
  219. }
  220. /// Determines if a low glucose suspend is warranted.
  221. ///
  222. /// This function checks for low glucose conditions and may modify the determination object
  223. /// with a suspend recommendation and an updated reason string.
  224. ///
  225. /// - Returns: A tuple containing:
  226. /// - `setTempBasal`: A `Bool` that is `true` if `determineBasal` should exit and apply the recommendation immediately.
  227. /// - `determination`: The (potentially modified) determination object.
  228. static func lowGlucoseSuspend(
  229. currentGlucose: Decimal,
  230. minGuardGlucose: Decimal,
  231. iob: Decimal,
  232. minDelta: Decimal,
  233. expectedDelta: Decimal,
  234. threshold: Decimal,
  235. overrideFactor: Decimal,
  236. profile: Profile,
  237. adjustedSensitivity: Decimal,
  238. targetGlucose: Decimal,
  239. currentTemp: TempBasal,
  240. determination: Determination
  241. ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
  242. var newDetermination = determination
  243. guard let currentBasal = profile.currentBasal else {
  244. // Should have been checked earlier
  245. throw TempBasalFunctionError.invalidBasalRateOnProfile
  246. }
  247. let suspendThreshold = -currentBasal * overrideFactor * 20 / 60
  248. if currentGlucose < threshold, iob < suspendThreshold, minDelta > 0, minDelta > expectedDelta {
  249. let iobString = String(describing: iob)
  250. let suspendString = String(describing: suspendThreshold.jsRounded(scale: 2))
  251. let minDeltaString = String(describing: convertGlucose(profile: profile, glucose: minDelta))
  252. let expectedDeltaString = String(describing: convertGlucose(profile: profile, glucose: expectedDelta))
  253. newDetermination
  254. .reason +=
  255. "IOB \(iobString) < \(suspendString) and minDelta \(minDeltaString) > expectedDelta \(expectedDeltaString); "
  256. return (shouldSetTempBasal: false, determination: newDetermination)
  257. } else if currentGlucose < threshold || minGuardGlucose < threshold {
  258. let minGuardGlucoseString = String(describing: convertGlucose(profile: profile, glucose: minGuardGlucose))
  259. let thresholdString = String(describing: convertGlucose(profile: profile, glucose: threshold))
  260. newDetermination.reason += "minGuardBG \(minGuardGlucoseString)<\(thresholdString)"
  261. let glucoseUndershoot = targetGlucose - minGuardGlucose
  262. if minGuardGlucose < threshold {
  263. newDetermination.minGuardBG = minGuardGlucose
  264. }
  265. let worstCaseInsulinRequired = glucoseUndershoot / adjustedSensitivity
  266. var durationRequired = (60 * worstCaseInsulinRequired / currentBasal * overrideFactor).jsRounded()
  267. durationRequired = (durationRequired / 30).jsRounded() * 30
  268. durationRequired = max(30, min(120, durationRequired))
  269. let finalDetermination = try TempBasalFunctions.setTempBasal(
  270. rate: 0,
  271. duration: durationRequired,
  272. profile: profile,
  273. determination: newDetermination,
  274. currentTemp: currentTemp
  275. )
  276. return (shouldSetTempBasal: true, determination: finalDetermination)
  277. }
  278. return (shouldSetTempBasal: false, determination: determination)
  279. }
  280. /// Determines if a neutral temp basal should be skipped to avoid pump alerts.
  281. ///
  282. /// - Returns: A tuple containing:
  283. /// - `shouldSetTempBasal`: A `Bool` that is `true` if `determineBasal` should exit and apply the recommendation immediately.
  284. /// - `determination`: The (potentially modified) determination object.
  285. static func skipNeutralTempBasal(
  286. smbIsEnabled: Bool,
  287. profile: Profile,
  288. clock: Date,
  289. currentTemp: TempBasal,
  290. determination: Determination
  291. ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
  292. guard profile.skipNeutralTemps else {
  293. return (shouldSetTempBasal: false, determination: determination)
  294. }
  295. guard let totalMinutes = clock.minutesSinceMidnight else {
  296. throw CalendarError.invalidCalendar
  297. }
  298. let minute = totalMinutes % 60
  299. guard minute >= 55 else {
  300. return (shouldSetTempBasal: false, determination: determination)
  301. }
  302. if !smbIsEnabled {
  303. var newDetermination = determination
  304. let minutesLeft = 60 - minute
  305. newDetermination
  306. .reason +=
  307. "; Canceling temp at \(minutesLeft)min before turn of the hour to avoid beeping of MDT. SMB are disabled anyways."
  308. let finalDetermination = try TempBasalFunctions.setTempBasal(
  309. rate: 0,
  310. duration: 0,
  311. profile: profile,
  312. determination: newDetermination,
  313. currentTemp: currentTemp
  314. )
  315. return (shouldSetTempBasal: true, determination: finalDetermination)
  316. } else {
  317. // In the JS, this path logs to the console but does not modify determination.
  318. // We will do nothing here to match that behavior.
  319. return (shouldSetTempBasal: false, determination: determination)
  320. }
  321. }
  322. /// Handles the case where eventual glucose is predicted to be low.
  323. ///
  324. /// - Returns: A tuple containing:
  325. /// - `shouldSetTempBasal`: A `Bool` that is `true` if `determineBasal` should exit and apply the recommendation immediately.
  326. /// - `determination`: The (potentially modified) determination object.
  327. static func handleLowEventualGlucose(
  328. eventualGlucose: Decimal,
  329. minGlucose: Decimal,
  330. targetGlucose: Decimal,
  331. minDelta: Decimal,
  332. expectedDelta: Decimal,
  333. carbsRequired: Decimal,
  334. naiveEventualGlucose: Decimal,
  335. glucoseStatus: GlucoseStatus,
  336. currentTemp: TempBasal,
  337. basal: Decimal,
  338. profile: Profile,
  339. determination: Determination,
  340. adjustedSensitivity: Decimal,
  341. overrideFactor: Decimal
  342. ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
  343. guard eventualGlucose < minGlucose else {
  344. return (shouldSetTempBasal: false, determination: determination)
  345. }
  346. var newDetermination = determination
  347. newDetermination
  348. .reason +=
  349. "Eventual BG \(convertGlucose(profile: profile, glucose: eventualGlucose)) < \(convertGlucose(profile: profile, glucose: minGlucose))"
  350. // if 5m or 30m avg glucose is rising faster than expected delta
  351. // BUG: in JS it's doing a "truthiness" check for carbs required
  352. // but if you get a negative carbsRequired it will evaluate
  353. // to true when it should be false (negative carbs required
  354. // means no carbs required)
  355. if minDelta > expectedDelta, minDelta > 0, carbsRequired == 0 {
  356. if naiveEventualGlucose < 40 {
  357. newDetermination.reason += ", naive_eventualBG < 40. "
  358. let finalDetermination = try TempBasalFunctions.setTempBasal(
  359. rate: 0,
  360. duration: 30,
  361. profile: profile,
  362. determination: newDetermination,
  363. currentTemp: currentTemp
  364. )
  365. return (shouldSetTempBasal: true, determination: finalDetermination)
  366. }
  367. if glucoseStatus.delta > minDelta {
  368. newDetermination
  369. .reason +=
  370. ", but Delta \(convertGlucose(profile: profile, glucose: glucoseStatus.delta)) > expectedDelta \(convertGlucose(profile: profile, glucose: expectedDelta))"
  371. } else {
  372. let minDeltaFormatted = String(format: "%.2f", Double(truncating: minDelta.jsRounded(scale: 2) as NSNumber))
  373. newDetermination
  374. .reason +=
  375. ", but Min. Delta \(minDeltaFormatted) > Exp. Delta \(convertGlucose(profile: profile, glucose: expectedDelta))"
  376. }
  377. let roundedBasal = TempBasalFunctions.roundBasal(profile: profile, basalRate: basal)
  378. let roundedCurrentRate = TempBasalFunctions.roundBasal(profile: profile, basalRate: currentTemp.rate)
  379. if currentTemp.duration > 15, roundedBasal == roundedCurrentRate {
  380. newDetermination.reason += ", temp \(currentTemp.rate) ~ req \(basal)U/hr. "
  381. return (shouldSetTempBasal: true, determination: newDetermination)
  382. } else {
  383. newDetermination.reason += "; setting current basal of \(basal) as temp. "
  384. let finalDetermination = try TempBasalFunctions.setTempBasal(
  385. rate: basal,
  386. duration: 30,
  387. profile: profile,
  388. determination: newDetermination,
  389. currentTemp: currentTemp
  390. )
  391. return (shouldSetTempBasal: true, determination: finalDetermination)
  392. }
  393. }
  394. // calculate 30m low-temp required to get projected glucose up to target
  395. var insulinRequired = 2 * min(0, (eventualGlucose - targetGlucose) / adjustedSensitivity)
  396. insulinRequired = insulinRequired.jsRounded(scale: 2)
  397. let naiveInsulinRequired = min(0, (naiveEventualGlucose - targetGlucose) / adjustedSensitivity).jsRounded(scale: 2)
  398. if minDelta < 0, minDelta > expectedDelta {
  399. let newInsulinRequired = (insulinRequired * (minDelta / expectedDelta)).jsRounded(scale: 2)
  400. insulinRequired = newInsulinRequired
  401. }
  402. var rate = basal + (2 * insulinRequired)
  403. rate = TempBasalFunctions.roundBasal(profile: profile, basalRate: rate)
  404. let insulinScheduled = Decimal(currentTemp.duration) * (currentTemp.rate - basal) / 60
  405. let minInsulinRequired = min(insulinRequired, naiveInsulinRequired)
  406. if insulinScheduled < minInsulinRequired - basal * 0.3 {
  407. let rateFormatted = String(format: "%.2f", Double(truncating: currentTemp.rate.jsRounded(scale: 2) as NSNumber))
  408. newDetermination
  409. .reason += ", \(currentTemp.duration)m@\(rateFormatted) is a lot less than needed. "
  410. let finalDetermination = try TempBasalFunctions.setTempBasal(
  411. rate: rate,
  412. duration: 30,
  413. profile: profile,
  414. determination: newDetermination,
  415. currentTemp: currentTemp
  416. )
  417. return (shouldSetTempBasal: true, determination: finalDetermination)
  418. }
  419. if currentTemp.duration > 5, rate >= currentTemp.rate * 0.8 {
  420. newDetermination.reason += ", temp \(currentTemp.rate) ~< req \(rate)U/hr. "
  421. return (shouldSetTempBasal: true, determination: newDetermination)
  422. } else {
  423. if rate <= 0 {
  424. guard let currentBasal = profile.currentBasal else {
  425. throw TempBasalFunctionError.invalidBasalRateOnProfile
  426. }
  427. let glucoseUndershoot = targetGlucose - naiveEventualGlucose
  428. let worstCaseInsulinRequired = glucoseUndershoot / adjustedSensitivity
  429. var durationRequired = (60 * worstCaseInsulinRequired / currentBasal * overrideFactor).jsRounded()
  430. if durationRequired < 0 {
  431. durationRequired = 0
  432. } else {
  433. durationRequired = (durationRequired / 30).jsRounded() * 30
  434. durationRequired = min(120, max(0, durationRequired))
  435. }
  436. if durationRequired > 0 {
  437. newDetermination.reason += ", setting \(durationRequired)m zero temp. "
  438. let finalDetermination = try TempBasalFunctions.setTempBasal(
  439. rate: rate,
  440. duration: durationRequired,
  441. profile: profile,
  442. determination: newDetermination,
  443. currentTemp: currentTemp
  444. )
  445. return (shouldSetTempBasal: true, determination: finalDetermination)
  446. }
  447. } else {
  448. newDetermination.reason += ", setting \(rate)U/hr. "
  449. }
  450. let finalDetermination = try TempBasalFunctions.setTempBasal(
  451. rate: rate,
  452. duration: 30,
  453. profile: profile,
  454. determination: newDetermination,
  455. currentTemp: currentTemp
  456. )
  457. return (shouldSetTempBasal: true, determination: finalDetermination)
  458. }
  459. }
  460. /// Handles the case where glucose is falling faster than expected.
  461. ///
  462. /// - Returns: A tuple containing:
  463. /// - `shouldSetTempBasal`: A `Bool` that is `true` if `determineBasal` should exit and apply the recommendation immediately.
  464. /// - `determination`: The (potentially modified) determination object.
  465. static func glucoseFallingFasterThanExpected(
  466. eventualGlucose: Decimal,
  467. minGlucose: Decimal,
  468. minDelta: Decimal,
  469. expectedDelta: Decimal,
  470. glucoseStatus: GlucoseStatus,
  471. currentTemp: TempBasal,
  472. basal: Decimal,
  473. smbIsEnabled: Bool,
  474. profile: Profile,
  475. determination: Determination
  476. ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
  477. guard minDelta < expectedDelta else {
  478. return (shouldSetTempBasal: false, determination: determination)
  479. }
  480. var newDetermination = determination
  481. if !smbIsEnabled {
  482. if glucoseStatus.delta < minDelta {
  483. newDetermination
  484. .reason +=
  485. "Eventual BG \(convertGlucose(profile: profile, glucose: eventualGlucose)) > \(convertGlucose(profile: profile, glucose: minGlucose)) but Delta \(convertGlucose(profile: profile, glucose: glucoseStatus.delta)) < Exp. Delta \(convertGlucose(profile: profile, glucose: expectedDelta))"
  486. } else {
  487. let minDeltaFormatted = String(format: "%.2f", Double(truncating: minDelta.jsRounded(scale: 2) as NSNumber))
  488. newDetermination
  489. .reason +=
  490. "Eventual BG \(convertGlucose(profile: profile, glucose: eventualGlucose)) > \(convertGlucose(profile: profile, glucose: minGlucose)) but Min. Delta \(minDeltaFormatted) < Exp. Delta \(convertGlucose(profile: profile, glucose: expectedDelta))"
  491. }
  492. let roundedBasal = TempBasalFunctions.roundBasal(profile: profile, basalRate: basal)
  493. let roundedCurrentRate = TempBasalFunctions.roundBasal(profile: profile, basalRate: currentTemp.rate)
  494. if currentTemp.duration > 15, roundedBasal == roundedCurrentRate {
  495. newDetermination.reason += ", temp \(currentTemp.rate) ~ req \(basal)U/hr. "
  496. return (shouldSetTempBasal: true, determination: newDetermination)
  497. } else {
  498. newDetermination.reason += "; setting current basal of \(basal) as temp. "
  499. let finalDetermination = try TempBasalFunctions.setTempBasal(
  500. rate: basal,
  501. duration: 30,
  502. profile: profile,
  503. determination: newDetermination,
  504. currentTemp: currentTemp
  505. )
  506. return (shouldSetTempBasal: true, determination: finalDetermination)
  507. }
  508. }
  509. return (shouldSetTempBasal: false, determination: determination)
  510. }
  511. /// Handles the case where the eventual or forecasted glucose is less than the max glucose.
  512. ///
  513. /// - Returns: A tuple containing:
  514. /// - `shouldSetTempBasal`: A `Bool` that is `true` if `determineBasal` should exit and apply the recommendation immediately.
  515. /// - `determination`: The (potentially modified) determination object.
  516. static func eventualOrForecastGlucoseLessThanMax(
  517. eventualGlucose: Decimal,
  518. maxGlucose: Decimal,
  519. minForecastGlucose: Decimal,
  520. currentTemp: TempBasal,
  521. basal: Decimal,
  522. smbIsEnabled: Bool,
  523. profile: Profile,
  524. determination: Determination
  525. ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
  526. guard min(eventualGlucose, minForecastGlucose) < maxGlucose else {
  527. return (shouldSetTempBasal: false, determination: determination)
  528. }
  529. var newDetermination = determination
  530. newDetermination.minPredBG = minForecastGlucose
  531. if !smbIsEnabled {
  532. newDetermination
  533. .reason +=
  534. "\(convertGlucose(profile: profile, glucose: eventualGlucose))-\(convertGlucose(profile: profile, glucose: minForecastGlucose)) in range: no temp required"
  535. let roundedBasal = TempBasalFunctions.roundBasal(profile: profile, basalRate: basal)
  536. let roundedCurrentRate = TempBasalFunctions.roundBasal(profile: profile, basalRate: currentTemp.rate)
  537. if currentTemp.duration > 15, roundedBasal == roundedCurrentRate {
  538. newDetermination.reason += ", temp \(currentTemp.rate) ~ req \(basal)U/hr. "
  539. return (shouldSetTempBasal: true, determination: newDetermination)
  540. } else {
  541. newDetermination.reason += "; setting current basal of \(basal) as temp. "
  542. let finalDetermination = try TempBasalFunctions.setTempBasal(
  543. rate: basal,
  544. duration: 30,
  545. profile: profile,
  546. determination: newDetermination,
  547. currentTemp: currentTemp
  548. )
  549. return (shouldSetTempBasal: true, determination: finalDetermination)
  550. }
  551. }
  552. return (shouldSetTempBasal: false, determination: determination)
  553. }
  554. /// Handles the case where IOB is greater than the max IOB.
  555. ///
  556. /// - Returns: A tuple containing:
  557. /// - `shouldSetTempBasal`: A `Bool` that is `true` if `determineBasal` should exit and apply the recommendation immediately.
  558. /// - `determination`: The (potentially modified) determination object.
  559. static func iobGreaterThanMax(
  560. iob: Decimal,
  561. maxIob: Decimal,
  562. currentTemp: TempBasal,
  563. basal: Decimal,
  564. profile: Profile,
  565. determination: Determination
  566. ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
  567. guard iob > maxIob else {
  568. return (shouldSetTempBasal: false, determination: determination)
  569. }
  570. var newDetermination = determination
  571. newDetermination.reason += "IOB \(iob.jsRounded(scale: 2)) > max_iob \(maxIob)"
  572. let roundedBasal = TempBasalFunctions.roundBasal(profile: profile, basalRate: basal)
  573. let roundedCurrentRate = TempBasalFunctions.roundBasal(profile: profile, basalRate: currentTemp.rate)
  574. if currentTemp.duration > 15, roundedBasal == roundedCurrentRate {
  575. newDetermination.reason += ", temp \(currentTemp.rate) ~ req \(basal)U/hr. "
  576. return (shouldSetTempBasal: true, determination: newDetermination)
  577. } else {
  578. newDetermination.reason += "; setting current basal of \(basal) as temp. "
  579. let finalDetermination = try TempBasalFunctions.setTempBasal(
  580. rate: basal,
  581. duration: 30,
  582. profile: profile,
  583. determination: newDetermination,
  584. currentTemp: currentTemp
  585. )
  586. return (shouldSetTempBasal: true, determination: finalDetermination)
  587. }
  588. }
  589. /// Calculates the insulin required to bring the projected glucose down to the target.
  590. ///
  591. /// - Returns: A tuple containing:
  592. /// - `insulinRequired`: The calculated amount of insulin needed.
  593. /// - `determination`: The (potentially modified) determination object with the reason updated.
  594. static func calculateInsulinRequired(
  595. minForecastGlucose: Decimal,
  596. eventualGlucose: Decimal,
  597. targetGlucose: Decimal,
  598. adjustedSensitivity: Decimal,
  599. maxIob: Decimal,
  600. currentIob: Decimal,
  601. determination: Determination
  602. ) -> (insulinRequired: Decimal, determination: Determination) {
  603. var newDetermination = determination
  604. var insulinRequired = (
  605. (min(minForecastGlucose, eventualGlucose) - targetGlucose) / adjustedSensitivity
  606. ).jsRounded(scale: 2)
  607. if insulinRequired > maxIob - currentIob {
  608. newDetermination.reason += "max_iob \(maxIob), "
  609. // Important: on this path insulinRequired gets rounded
  610. // to three decimal places, not 2 like on the default path
  611. insulinRequired = (maxIob - currentIob).jsRounded(scale: 3)
  612. }
  613. newDetermination.insulinReq = insulinRequired
  614. return (insulinRequired, newDetermination)
  615. }
  616. /// Determines the maxBolus possible for a Super Micro Bolus (SMB)
  617. static func determineMaxBolus(
  618. currentBasal: Decimal,
  619. currentIob: Decimal,
  620. adjustedCarbRatio: Decimal,
  621. mealData: ComputedCarbs,
  622. profile: Profile,
  623. trioCustomOrefVariables: TrioCustomOrefVariables
  624. ) -> Decimal {
  625. let mealInsulinRequired = (mealData.mealCOB / adjustedCarbRatio).jsRounded(scale: 3)
  626. let overrideFactor = trioCustomOrefVariables.overrideFactor()
  627. var smbMinutesSetting = profile.maxSMBBasalMinutes
  628. if trioCustomOrefVariables.useOverride, trioCustomOrefVariables.advancedSettings {
  629. smbMinutesSetting = trioCustomOrefVariables.smbMinutes
  630. }
  631. var uamMinutesSetting = profile.maxUAMSMBBasalMinutes
  632. if trioCustomOrefVariables.useOverride, trioCustomOrefVariables.advancedSettings {
  633. uamMinutesSetting = trioCustomOrefVariables.uamMinutes
  634. }
  635. if currentIob > mealInsulinRequired, currentIob > 0 {
  636. if uamMinutesSetting > 0 {
  637. return (currentBasal * overrideFactor * uamMinutesSetting / 60).jsRounded(scale: 1)
  638. } else {
  639. // Note: It should be impossible to have uamMinutesSetting of 0 so this shouldn't execute
  640. return (currentBasal * overrideFactor * 30 / 60).jsRounded(scale: 1)
  641. }
  642. } else {
  643. return (currentBasal * overrideFactor * smbMinutesSetting / 60).jsRounded(scale: 1)
  644. }
  645. }
  646. /// Determines if a Super Micro Bolus (SMB) should be delivered and calculates its size and associated temp basal.
  647. ///
  648. /// - Returns: A tuple containing:
  649. /// - `shouldSetTempBasal`: `true` if an SMB (or associated low temp) was enacted and the process should exit.
  650. /// - `determination`: The (potentially modified) determination object containing the decision.
  651. static func determineSMBDelivery(
  652. insulinRequired: Decimal,
  653. microBolusAllowed: Bool,
  654. smbIsEnabled: Bool,
  655. currentGlucose: Decimal,
  656. threshold: Decimal,
  657. profile: Profile,
  658. trioCustomOrefVariables: TrioCustomOrefVariables,
  659. mealData: ComputedCarbs,
  660. iobData: [IobResult],
  661. currentTime: Date,
  662. targetGlucose: Decimal,
  663. naiveEventualGlucose: Decimal,
  664. minIOBForecastedGlucose: Decimal,
  665. adjustedSensitivity: Decimal,
  666. adjustedCarbRatio: Decimal,
  667. basal: Decimal,
  668. determination: Determination
  669. ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
  670. var newDetermination = determination
  671. guard microBolusAllowed, smbIsEnabled, currentGlucose > threshold else {
  672. return (false, newDetermination)
  673. }
  674. guard let currentBasal = profile.currentBasal else {
  675. // Should be impossible if we got this far
  676. throw TempBasalFunctionError.invalidBasalRateOnProfile
  677. }
  678. guard let currentIob = iobData.first?.iob else {
  679. return (false, newDetermination)
  680. }
  681. let maxBolus = determineMaxBolus(
  682. currentBasal: currentBasal,
  683. currentIob: currentIob,
  684. adjustedCarbRatio: adjustedCarbRatio,
  685. mealData: mealData,
  686. profile: profile,
  687. trioCustomOrefVariables: trioCustomOrefVariables
  688. )
  689. let smbDeliveryRatio = min(profile.smbDeliveryRatio, 1)
  690. let roundSmbTo = 1 / profile.bolusIncrement
  691. let microBolusWithoutRounding = min(insulinRequired * smbDeliveryRatio, maxBolus)
  692. let microBolus = (microBolusWithoutRounding * roundSmbTo).floor() / roundSmbTo
  693. let worstCaseInsulinRequired = (targetGlucose - (naiveEventualGlucose + minIOBForecastedGlucose) / 2) /
  694. adjustedSensitivity
  695. var durationRequired = (60 * worstCaseInsulinRequired / currentBasal * trioCustomOrefVariables.overrideFactor())
  696. .jsRounded()
  697. // if insulinRequired > 0 but not enough for a microBolus, don't set an SMB zero temp
  698. if insulinRequired > 0, microBolus < profile.bolusIncrement {
  699. durationRequired = 0
  700. }
  701. var smbLowTempRequired: Decimal = 0
  702. if durationRequired <= 0 {
  703. durationRequired = 0
  704. } else if durationRequired >= 30 {
  705. durationRequired = (durationRequired / 30).jsRounded() * 30
  706. durationRequired = min(60, max(0, durationRequired))
  707. } else {
  708. // Note: we're using the fully adjusted basal here
  709. smbLowTempRequired = (basal * durationRequired / 30).jsRounded(scale: 2)
  710. durationRequired = 30
  711. }
  712. newDetermination.reason += " insulinReq \(insulinRequired)"
  713. if microBolus >= maxBolus {
  714. newDetermination.reason += "; maxBolus \(maxBolus)"
  715. }
  716. if durationRequired > 0 {
  717. newDetermination.reason += "; setting \(durationRequired)m low temp of \(smbLowTempRequired)U/h"
  718. }
  719. newDetermination.reason += ". "
  720. var smbInterval: Decimal = 3
  721. if !profile.smbInterval.isNaN {
  722. smbInterval = min(10, max(1, profile.smbInterval))
  723. }
  724. // minutes since last bolus
  725. let lastBolusAge: Decimal?
  726. if let lastBolusTime = iobData.first?.lastBolusTime {
  727. let millisecondsSince1970 = Decimal(currentTime.timeIntervalSince1970 * 1000)
  728. lastBolusAge = ((millisecondsSince1970 - Decimal(lastBolusTime)) / 60000).jsRounded(scale: 1)
  729. } else {
  730. lastBolusAge = nil
  731. }
  732. if let lastBolusAge {
  733. // BUG: JS rounds minutes independently from seconds, causing double-counting when
  734. // minutes rounds up. E.g., 0.6 min = 36 sec, but JS outputs "1m 36s" (96 sec).
  735. // Correct logic would be:
  736. // let totalSeconds = Int(((smbInterval - lastBolusAge) * 60).jsRounded())
  737. // let nextBolusMinutes = totalSeconds / 60
  738. // let nextBolusSeconds = totalSeconds % 60
  739. // Keeping JS behavior for now to match outputs.
  740. let nextBolusMinutes = (smbInterval - lastBolusAge).jsRounded()
  741. let nextBolusSeconds = Int(((smbInterval - lastBolusAge) * 60).jsRounded()) % 60
  742. if lastBolusAge > smbInterval {
  743. if microBolus > 0 {
  744. newDetermination.units = microBolus
  745. newDetermination.reason += "Microbolusing \(microBolus)U. "
  746. }
  747. } else {
  748. newDetermination.reason += "Waiting \(nextBolusMinutes)m \(nextBolusSeconds)s to microbolus again. "
  749. }
  750. }
  751. if durationRequired > 0 {
  752. newDetermination.rate = smbLowTempRequired
  753. newDetermination.duration = durationRequired
  754. return (true, newDetermination)
  755. }
  756. return (false, newDetermination)
  757. }
  758. /// Determines and sets a high temp basal if required to bring glucose down.
  759. ///
  760. /// - Returns: The final determination object with the high temp set (if applicable).
  761. static func determineHighTempBasal(
  762. insulinRequired: Decimal,
  763. basal: Decimal,
  764. profile: Profile,
  765. currentTemp: TempBasal,
  766. determination: Determination
  767. ) throws -> Determination {
  768. var newDetermination = determination
  769. var rate = basal + (2 * insulinRequired)
  770. rate = TempBasalFunctions.roundBasal(profile: profile, basalRate: rate)
  771. let maxSafeBasal = try TempBasalFunctions.getMaxSafeBasalRate(profile: profile)
  772. if rate > maxSafeBasal {
  773. newDetermination.reason += "adj. req. rate: \(rate) to maxSafeBasal: \(maxSafeBasal.jsRounded(scale: 2)), "
  774. rate = TempBasalFunctions.roundBasal(profile: profile, basalRate: maxSafeBasal)
  775. }
  776. let insulinScheduled = Decimal(currentTemp.duration) * (currentTemp.rate - basal) / 60
  777. if insulinScheduled >= insulinRequired * 2 {
  778. let rateFormatted = String(format: "%.2f", Double(truncating: currentTemp.rate.jsRounded(scale: 2) as NSNumber))
  779. newDetermination.reason +=
  780. "\(currentTemp.duration)m@\(rateFormatted) > 2 * insulinReq. Setting temp basal of \(rate)U/hr. "
  781. let finalDetermination = try TempBasalFunctions.setTempBasal(
  782. rate: rate,
  783. duration: 30,
  784. profile: profile,
  785. determination: newDetermination,
  786. currentTemp: currentTemp
  787. )
  788. return finalDetermination
  789. }
  790. if currentTemp.duration == 0 {
  791. newDetermination.reason += "no temp, setting \(rate)U/hr. "
  792. let finalDetermination = try TempBasalFunctions.setTempBasal(
  793. rate: rate,
  794. duration: 30,
  795. profile: profile,
  796. determination: newDetermination,
  797. currentTemp: currentTemp
  798. )
  799. return finalDetermination
  800. }
  801. let roundedRate = TempBasalFunctions.roundBasal(profile: profile, basalRate: rate)
  802. let roundedCurrentRate = TempBasalFunctions.roundBasal(profile: profile, basalRate: currentTemp.rate)
  803. if currentTemp.duration > 5, roundedRate <= roundedCurrentRate {
  804. newDetermination.reason += "temp \(currentTemp.rate) >~ req \(rate)U/hr. "
  805. return newDetermination
  806. }
  807. newDetermination.reason += "temp \(currentTemp.rate)<\(rate)U/hr. "
  808. let finalDetermination = try TempBasalFunctions.setTempBasal(
  809. rate: rate,
  810. duration: 30,
  811. profile: profile,
  812. determination: newDetermination,
  813. currentTemp: currentTemp
  814. )
  815. return finalDetermination
  816. }
  817. }