| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927 |
- import Foundation
- enum DosingEngine {
- struct DosingInputs {
- let reason: String
- let carbsRequired: (carbs: Decimal, minutes: Decimal)?
- let rawCarbsRequired: Decimal
- }
- /// struct to keep the relevant state needed for the output of the SMB decision logic
- struct SMBDecision {
- let isEnabled: Bool
- let minGuardGlucose: Decimal?
- let reason: String?
- }
- /// checks to see if SMB are enabled via the profile
- private static func isProfileSmbEnabled(
- currentGlucose: Decimal,
- adjustedTargetGlucose: Decimal,
- profile: Profile,
- meal: ComputedCarbs,
- trioCustomOrefVariables: TrioCustomOrefVariables,
- clock: Date
- ) throws -> Bool {
- if trioCustomOrefVariables.smbIsOff {
- return false
- }
- if try isSmbScheduledOff(trioCustomOrefVariables: trioCustomOrefVariables, clock: clock) {
- return false
- }
- if !profile.allowSMBWithHighTemptarget, profile.temptargetSet == true, adjustedTargetGlucose > 100 {
- return false
- }
- if profile.enableSMBAlways {
- return true
- }
- if profile.enableSMBWithCOB, meal.mealCOB > 0 {
- return true
- }
- if profile.enableSMBAfterCarbs, meal.carbs > 0 {
- return true
- }
- if profile.enableSMBWithTemptarget, profile.temptargetSet == true, adjustedTargetGlucose < 100 {
- return true
- }
- if profile.enableSMBHighBg, currentGlucose >= profile.enableSMBHighBgTarget {
- return true
- }
- return false
- }
- /// helper function to check if SMB is scheduled off given the current timezone
- private static func isSmbScheduledOff(trioCustomOrefVariables: TrioCustomOrefVariables, clock: Date) throws -> Bool {
- guard trioCustomOrefVariables.smbIsScheduledOff else {
- return false
- }
- guard let currentHour = clock.hourInLocalTime.map({ Decimal($0) }) else {
- throw CalendarError.invalidCalendarHourOnly
- }
- let startHour = trioCustomOrefVariables.start
- let endHour = trioCustomOrefVariables.end
- // SMBs will be disabled from [start, end) local time
- if startHour < endHour, currentHour >= startHour && currentHour < endHour {
- // disable when the schedule does not wrap around midnight
- return true
- } else if startHour > endHour, currentHour >= startHour || currentHour < endHour {
- // disable when the schedule does wrap around midnight
- return true
- } else if startHour == 0, endHour == 0 {
- // schedule specifies the entire day
- return true
- } else if startHour == endHour, currentHour == startHour {
- // one hour of scheduled off SMB
- return true
- }
- return false
- }
- /// helper function for reason string glucose output
- static func convertGlucose(profile: Profile, glucose: Decimal) -> Decimal {
- let units = profile.outUnits ?? .mgdL
- switch units {
- case .mgdL: return glucose.jsRounded()
- case .mmolL: return glucose.asMmolL
- }
- }
- /// Top level smb enabling logic
- ///
- /// This function includes both the profile / customOrefVariable checks from JS `enable_smb` as
- /// well as some of the later checks from `determineBasal` that can disable SMB
- static func makeSMBDosingDecision(
- profile: Profile,
- meal: ComputedCarbs,
- currentGlucose: Decimal,
- adjustedTargetGlucose: Decimal,
- minGuardGlucose: Decimal,
- threshold: Decimal,
- glucoseStatus: GlucoseStatus,
- trioCustomOrefVariables: TrioCustomOrefVariables,
- clock: Date
- ) throws -> SMBDecision {
- var smbIsEnabled = try isProfileSmbEnabled(
- currentGlucose: currentGlucose,
- adjustedTargetGlucose: adjustedTargetGlucose,
- profile: profile,
- meal: meal,
- trioCustomOrefVariables: trioCustomOrefVariables,
- clock: clock
- )
- // these last two checks are implemented outside of the core enable_smb
- // function in JS but we should keep all of the smb enabling logic
- // in one place. Note: We can't shortcut the return value because
- // the determineBasal logic always evaluates this logic
- var minGuardGlucoseDecision: Decimal?
- var reason: String?
- if smbIsEnabled, minGuardGlucose < threshold {
- minGuardGlucoseDecision = minGuardGlucose
- smbIsEnabled = false
- }
- let maxDeltaGlucoseThreshold = min(profile.maxDeltaBgThreshold, 0.4)
- if glucoseStatus.maxDelta > maxDeltaGlucoseThreshold * currentGlucose {
- reason =
- "maxDelta \(convertGlucose(profile: profile, glucose: glucoseStatus.maxDelta)) > \(100 * maxDeltaGlucoseThreshold)% of BG \(convertGlucose(profile: profile, glucose: currentGlucose)) - SMB disabled!, "
- smbIsEnabled = false
- }
- return SMBDecision(
- isEnabled: smbIsEnabled,
- minGuardGlucose: minGuardGlucoseDecision,
- reason: reason
- )
- }
- static func prepareDosingInputs(
- profile: Profile,
- mealData: ComputedCarbs,
- forecast: ForecastResult,
- naiveEventualGlucose: Decimal,
- threshold: Decimal,
- glucoseImpact: Decimal,
- deviation: Decimal,
- currentBasal: Decimal,
- overrideFactor: Decimal,
- adjustedSensitivity: Decimal,
- isfReason: String,
- tddReason: String,
- targetLog: String // This is a pre-formatted string from the JS
- ) -> DosingInputs {
- let lastIOBpredBG = (forecast.iob.last ?? 0).jsRounded()
- let lastCOBpredBG = forecast.cob?.last?.jsRounded()
- let lastUAMpredBG = forecast.uam?.last?.jsRounded()
- var reason =
- "\(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)"
- if let lastCOB = lastCOBpredBG {
- reason += ", COBpredBG \(lastCOB)"
- }
- if let lastUAM = lastUAMpredBG {
- reason += ", UAMpredBG \(lastUAM)"
- }
- reason += tddReason
- reason += "; " // Start of conclusion
- let carbsRequiredResult = calculateCarbsRequired(
- mealData: mealData,
- naiveEventualGlucose: naiveEventualGlucose,
- minGuardGlucose: forecast.minGuardGlucose,
- threshold: threshold,
- iobForecast: forecast.iob,
- cobForecast: forecast.internalCob,
- carbImpact: forecast.carbImpact,
- remainingCarbImpactPeak: forecast.remainingCarbImpactPeak,
- currentBasal: currentBasal,
- overrideFactor: overrideFactor,
- adjustedSensitivity: adjustedSensitivity,
- adjustedCarbRatio: forecast.adjustedCarbRatio
- )
- var carbsRequired: (carbs: Decimal, minutes: Decimal)?
- if carbsRequiredResult.carbs >= profile.carbsReqThreshold, carbsRequiredResult.minutes <= 45 {
- // Note: carbs message is added in DetermineBasalGenerator after smbReason to match JS order
- carbsRequired = carbsRequiredResult
- }
- return DosingInputs(reason: reason, carbsRequired: carbsRequired, rawCarbsRequired: carbsRequiredResult.carbs)
- }
- /// Calculates the carbohydrates required to avoid a potential hypoglycemic event.
- ///
- /// - Returns: A tuple containing the required carbs and minutes until glucose is below threshold.
- static func calculateCarbsRequired(
- mealData: ComputedCarbs,
- naiveEventualGlucose: Decimal,
- minGuardGlucose: Decimal,
- threshold: Decimal,
- iobForecast: [Decimal],
- cobForecast: [Decimal],
- carbImpact: Decimal,
- remainingCarbImpactPeak: Decimal,
- currentBasal: Decimal,
- overrideFactor: Decimal,
- adjustedSensitivity: Decimal,
- adjustedCarbRatio: Decimal
- ) -> (carbs: Decimal, minutes: Decimal) {
- var carbsRequiredGlucose = naiveEventualGlucose
- if naiveEventualGlucose < 40 {
- carbsRequiredGlucose = min(minGuardGlucose, naiveEventualGlucose)
- }
- let glucoseUndershoot = threshold - carbsRequiredGlucose
- var minutesAboveThreshold = Decimal(240)
- let useCOBForecast = mealData.mealCOB > 0 && (carbImpact > 0 || remainingCarbImpactPeak > 0)
- let forecast = useCOBForecast ? cobForecast : iobForecast
- // At this point in the JS the forecasts have already been rounded
- for (index, glucose) in forecast.map({ $0.jsRounded() }).enumerated() {
- if glucose < threshold {
- minutesAboveThreshold = Decimal(5) * Decimal(index)
- break
- }
- }
- let zeroTempDuration = minutesAboveThreshold
- let zeroTempEffect = currentBasal * adjustedSensitivity * overrideFactor * zeroTempDuration / 60
- let mealCarbs = mealData.carbs
- let cobForCarbsRequired = max(0, mealData.mealCOB - (Decimal(0.25) * mealCarbs))
- guard adjustedCarbRatio > 0 else { return (carbs: 0, minutes: minutesAboveThreshold) }
- let carbSensitivityFactor = adjustedSensitivity / adjustedCarbRatio
- guard carbSensitivityFactor > 0 else { return (carbs: 0, minutes: minutesAboveThreshold) }
- var carbsRequired = (glucoseUndershoot - zeroTempEffect) / carbSensitivityFactor - cobForCarbsRequired
- carbsRequired = carbsRequired.jsRounded()
- return (carbs: carbsRequired, minutes: minutesAboveThreshold)
- }
- /// Determines if a low glucose suspend is warranted.
- ///
- /// This function checks for low glucose conditions and may modify the determination object
- /// with a suspend recommendation and an updated reason string.
- ///
- /// - Returns: A tuple containing:
- /// - `setTempBasal`: A `Bool` that is `true` if `determineBasal` should exit and apply the recommendation immediately.
- /// - `determination`: The (potentially modified) determination object.
- static func lowGlucoseSuspend(
- currentGlucose: Decimal,
- minGuardGlucose: Decimal,
- iob: Decimal,
- minDelta: Decimal,
- expectedDelta: Decimal,
- threshold: Decimal,
- overrideFactor: Decimal,
- profile: Profile,
- adjustedSensitivity: Decimal,
- targetGlucose: Decimal,
- currentTemp: TempBasal,
- determination: Determination
- ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
- var newDetermination = determination
- guard let currentBasal = profile.currentBasal else {
- // Should have been checked earlier
- throw TempBasalFunctionError.invalidBasalRateOnProfile
- }
- let suspendThreshold = -currentBasal * overrideFactor * 20 / 60
- if currentGlucose < threshold, iob < suspendThreshold, minDelta > 0, minDelta > expectedDelta {
- let iobString = String(describing: iob)
- let suspendString = String(describing: suspendThreshold.jsRounded(scale: 2))
- let minDeltaString = String(describing: convertGlucose(profile: profile, glucose: minDelta))
- let expectedDeltaString = String(describing: convertGlucose(profile: profile, glucose: expectedDelta))
- newDetermination
- .reason +=
- "IOB \(iobString) < \(suspendString) and minDelta \(minDeltaString) > expectedDelta \(expectedDeltaString); "
- return (shouldSetTempBasal: false, determination: newDetermination)
- } else if currentGlucose < threshold || minGuardGlucose < threshold {
- let minGuardGlucoseString = String(describing: convertGlucose(profile: profile, glucose: minGuardGlucose))
- let thresholdString = String(describing: convertGlucose(profile: profile, glucose: threshold))
- newDetermination.reason += "minGuardBG \(minGuardGlucoseString)<\(thresholdString)"
- let glucoseUndershoot = targetGlucose - minGuardGlucose
- if minGuardGlucose < threshold {
- newDetermination.minGuardBG = minGuardGlucose
- }
- let worstCaseInsulinRequired = glucoseUndershoot / adjustedSensitivity
- var durationRequired = (60 * worstCaseInsulinRequired / currentBasal * overrideFactor).jsRounded()
- durationRequired = (durationRequired / 30).jsRounded() * 30
- durationRequired = max(30, min(120, durationRequired))
- let finalDetermination = try TempBasalFunctions.setTempBasal(
- rate: 0,
- duration: durationRequired,
- profile: profile,
- determination: newDetermination,
- currentTemp: currentTemp
- )
- return (shouldSetTempBasal: true, determination: finalDetermination)
- }
- return (shouldSetTempBasal: false, determination: determination)
- }
- /// Determines if a neutral temp basal should be skipped to avoid pump alerts.
- ///
- /// - Returns: A tuple containing:
- /// - `shouldSetTempBasal`: A `Bool` that is `true` if `determineBasal` should exit and apply the recommendation immediately.
- /// - `determination`: The (potentially modified) determination object.
- static func skipNeutralTempBasal(
- smbIsEnabled: Bool,
- profile: Profile,
- clock: Date,
- currentTemp: TempBasal,
- determination: Determination
- ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
- guard profile.skipNeutralTemps else {
- return (shouldSetTempBasal: false, determination: determination)
- }
- guard let totalMinutes = clock.minutesSinceMidnight else {
- throw CalendarError.invalidCalendar
- }
- let minute = totalMinutes % 60
- guard minute >= 55 else {
- return (shouldSetTempBasal: false, determination: determination)
- }
- if !smbIsEnabled {
- var newDetermination = determination
- let minutesLeft = 60 - minute
- newDetermination
- .reason +=
- "; Canceling temp at \(minutesLeft)min before turn of the hour to avoid beeping of MDT. SMB are disabled anyways."
- let finalDetermination = try TempBasalFunctions.setTempBasal(
- rate: 0,
- duration: 0,
- profile: profile,
- determination: newDetermination,
- currentTemp: currentTemp
- )
- return (shouldSetTempBasal: true, determination: finalDetermination)
- } else {
- // In the JS, this path logs to the console but does not modify determination.
- // We will do nothing here to match that behavior.
- return (shouldSetTempBasal: false, determination: determination)
- }
- }
- /// Handles the case where eventual glucose is predicted to be low.
- ///
- /// - Returns: A tuple containing:
- /// - `shouldSetTempBasal`: A `Bool` that is `true` if `determineBasal` should exit and apply the recommendation immediately.
- /// - `determination`: The (potentially modified) determination object.
- static func handleLowEventualGlucose(
- eventualGlucose: Decimal,
- minGlucose: Decimal,
- targetGlucose: Decimal,
- minDelta: Decimal,
- expectedDelta: Decimal,
- carbsRequired: Decimal,
- naiveEventualGlucose: Decimal,
- glucoseStatus: GlucoseStatus,
- currentTemp: TempBasal,
- basal: Decimal,
- profile: Profile,
- determination: Determination,
- adjustedSensitivity: Decimal,
- overrideFactor: Decimal
- ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
- guard eventualGlucose < minGlucose else {
- return (shouldSetTempBasal: false, determination: determination)
- }
- var newDetermination = determination
- newDetermination
- .reason +=
- "Eventual BG \(convertGlucose(profile: profile, glucose: eventualGlucose)) < \(convertGlucose(profile: profile, glucose: minGlucose))"
- // if 5m or 30m avg glucose is rising faster than expected delta
- // BUG: in JS it's doing a "truthiness" check for carbs required
- // but if you get a negative carbsRequired it will evaluate
- // to true when it should be false (negative carbs required
- // means no carbs required)
- if minDelta > expectedDelta, minDelta > 0, carbsRequired == 0 {
- if naiveEventualGlucose < 40 {
- newDetermination.reason += ", naive_eventualBG < 40. "
- let finalDetermination = try TempBasalFunctions.setTempBasal(
- rate: 0,
- duration: 30,
- profile: profile,
- determination: newDetermination,
- currentTemp: currentTemp
- )
- return (shouldSetTempBasal: true, determination: finalDetermination)
- }
- if glucoseStatus.delta > minDelta {
- newDetermination
- .reason +=
- ", but Delta \(convertGlucose(profile: profile, glucose: glucoseStatus.delta)) > expectedDelta \(convertGlucose(profile: profile, glucose: expectedDelta))"
- } else {
- let minDeltaFormatted = String(format: "%.2f", Double(truncating: minDelta.jsRounded(scale: 2) as NSNumber))
- newDetermination
- .reason +=
- ", but Min. Delta \(minDeltaFormatted) > Exp. Delta \(convertGlucose(profile: profile, glucose: expectedDelta))"
- }
- let roundedBasal = TempBasalFunctions.roundBasal(profile: profile, basalRate: basal)
- let roundedCurrentRate = TempBasalFunctions.roundBasal(profile: profile, basalRate: currentTemp.rate)
- if currentTemp.duration > 15, roundedBasal == roundedCurrentRate {
- newDetermination.reason += ", temp \(currentTemp.rate) ~ req \(basal)U/hr. "
- return (shouldSetTempBasal: true, determination: newDetermination)
- } else {
- newDetermination.reason += "; setting current basal of \(basal) as temp. "
- let finalDetermination = try TempBasalFunctions.setTempBasal(
- rate: basal,
- duration: 30,
- profile: profile,
- determination: newDetermination,
- currentTemp: currentTemp
- )
- return (shouldSetTempBasal: true, determination: finalDetermination)
- }
- }
- // calculate 30m low-temp required to get projected glucose up to target
- var insulinRequired = 2 * min(0, (eventualGlucose - targetGlucose) / adjustedSensitivity)
- insulinRequired = insulinRequired.jsRounded(scale: 2)
- let naiveInsulinRequired = min(0, (naiveEventualGlucose - targetGlucose) / adjustedSensitivity).jsRounded(scale: 2)
- if minDelta < 0, minDelta > expectedDelta {
- let newInsulinRequired = (insulinRequired * (minDelta / expectedDelta)).jsRounded(scale: 2)
- insulinRequired = newInsulinRequired
- }
- var rate = basal + (2 * insulinRequired)
- rate = TempBasalFunctions.roundBasal(profile: profile, basalRate: rate)
- let insulinScheduled = Decimal(currentTemp.duration) * (currentTemp.rate - basal) / 60
- let minInsulinRequired = min(insulinRequired, naiveInsulinRequired)
- if insulinScheduled < minInsulinRequired - basal * 0.3 {
- let rateFormatted = String(format: "%.2f", Double(truncating: currentTemp.rate.jsRounded(scale: 2) as NSNumber))
- newDetermination
- .reason += ", \(currentTemp.duration)m@\(rateFormatted) is a lot less than needed. "
- let finalDetermination = try TempBasalFunctions.setTempBasal(
- rate: rate,
- duration: 30,
- profile: profile,
- determination: newDetermination,
- currentTemp: currentTemp
- )
- return (shouldSetTempBasal: true, determination: finalDetermination)
- }
- if currentTemp.duration > 5, rate >= currentTemp.rate * 0.8 {
- newDetermination.reason += ", temp \(currentTemp.rate) ~< req \(rate)U/hr. "
- return (shouldSetTempBasal: true, determination: newDetermination)
- } else {
- if rate <= 0 {
- guard let currentBasal = profile.currentBasal else {
- throw TempBasalFunctionError.invalidBasalRateOnProfile
- }
- let glucoseUndershoot = targetGlucose - naiveEventualGlucose
- let worstCaseInsulinRequired = glucoseUndershoot / adjustedSensitivity
- var durationRequired = (60 * worstCaseInsulinRequired / currentBasal * overrideFactor).jsRounded()
- if durationRequired < 0 {
- durationRequired = 0
- } else {
- durationRequired = (durationRequired / 30).jsRounded() * 30
- durationRequired = min(120, max(0, durationRequired))
- }
- if durationRequired > 0 {
- newDetermination.reason += ", setting \(durationRequired)m zero temp. "
- let finalDetermination = try TempBasalFunctions.setTempBasal(
- rate: rate,
- duration: durationRequired,
- profile: profile,
- determination: newDetermination,
- currentTemp: currentTemp
- )
- return (shouldSetTempBasal: true, determination: finalDetermination)
- }
- } else {
- newDetermination.reason += ", setting \(rate)U/hr. "
- }
- let finalDetermination = try TempBasalFunctions.setTempBasal(
- rate: rate,
- duration: 30,
- profile: profile,
- determination: newDetermination,
- currentTemp: currentTemp
- )
- return (shouldSetTempBasal: true, determination: finalDetermination)
- }
- }
- /// Handles the case where glucose is falling faster than expected.
- ///
- /// - Returns: A tuple containing:
- /// - `shouldSetTempBasal`: A `Bool` that is `true` if `determineBasal` should exit and apply the recommendation immediately.
- /// - `determination`: The (potentially modified) determination object.
- static func glucoseFallingFasterThanExpected(
- eventualGlucose: Decimal,
- minGlucose: Decimal,
- minDelta: Decimal,
- expectedDelta: Decimal,
- glucoseStatus: GlucoseStatus,
- currentTemp: TempBasal,
- basal: Decimal,
- smbIsEnabled: Bool,
- profile: Profile,
- determination: Determination
- ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
- guard minDelta < expectedDelta else {
- return (shouldSetTempBasal: false, determination: determination)
- }
- var newDetermination = determination
- if !smbIsEnabled {
- if glucoseStatus.delta < minDelta {
- newDetermination
- .reason +=
- "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))"
- } else {
- let minDeltaFormatted = String(format: "%.2f", Double(truncating: minDelta.jsRounded(scale: 2) as NSNumber))
- newDetermination
- .reason +=
- "Eventual BG \(convertGlucose(profile: profile, glucose: eventualGlucose)) > \(convertGlucose(profile: profile, glucose: minGlucose)) but Min. Delta \(minDeltaFormatted) < Exp. Delta \(convertGlucose(profile: profile, glucose: expectedDelta))"
- }
- let roundedBasal = TempBasalFunctions.roundBasal(profile: profile, basalRate: basal)
- let roundedCurrentRate = TempBasalFunctions.roundBasal(profile: profile, basalRate: currentTemp.rate)
- if currentTemp.duration > 15, roundedBasal == roundedCurrentRate {
- newDetermination.reason += ", temp \(currentTemp.rate) ~ req \(basal)U/hr. "
- return (shouldSetTempBasal: true, determination: newDetermination)
- } else {
- newDetermination.reason += "; setting current basal of \(basal) as temp. "
- let finalDetermination = try TempBasalFunctions.setTempBasal(
- rate: basal,
- duration: 30,
- profile: profile,
- determination: newDetermination,
- currentTemp: currentTemp
- )
- return (shouldSetTempBasal: true, determination: finalDetermination)
- }
- }
- return (shouldSetTempBasal: false, determination: determination)
- }
- /// Handles the case where the eventual or forecasted glucose is less than the max glucose.
- ///
- /// - Returns: A tuple containing:
- /// - `shouldSetTempBasal`: A `Bool` that is `true` if `determineBasal` should exit and apply the recommendation immediately.
- /// - `determination`: The (potentially modified) determination object.
- static func eventualOrForecastGlucoseLessThanMax(
- eventualGlucose: Decimal,
- maxGlucose: Decimal,
- minForecastGlucose: Decimal,
- currentTemp: TempBasal,
- basal: Decimal,
- smbIsEnabled: Bool,
- profile: Profile,
- determination: Determination
- ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
- guard min(eventualGlucose, minForecastGlucose) < maxGlucose else {
- return (shouldSetTempBasal: false, determination: determination)
- }
- var newDetermination = determination
- newDetermination.minPredBG = minForecastGlucose
- if !smbIsEnabled {
- newDetermination
- .reason +=
- "\(convertGlucose(profile: profile, glucose: eventualGlucose))-\(convertGlucose(profile: profile, glucose: minForecastGlucose)) in range: no temp required"
- let roundedBasal = TempBasalFunctions.roundBasal(profile: profile, basalRate: basal)
- let roundedCurrentRate = TempBasalFunctions.roundBasal(profile: profile, basalRate: currentTemp.rate)
- if currentTemp.duration > 15, roundedBasal == roundedCurrentRate {
- newDetermination.reason += ", temp \(currentTemp.rate) ~ req \(basal)U/hr. "
- return (shouldSetTempBasal: true, determination: newDetermination)
- } else {
- newDetermination.reason += "; setting current basal of \(basal) as temp. "
- let finalDetermination = try TempBasalFunctions.setTempBasal(
- rate: basal,
- duration: 30,
- profile: profile,
- determination: newDetermination,
- currentTemp: currentTemp
- )
- return (shouldSetTempBasal: true, determination: finalDetermination)
- }
- }
- return (shouldSetTempBasal: false, determination: determination)
- }
- /// Handles the case where IOB is greater than the max IOB.
- ///
- /// - Returns: A tuple containing:
- /// - `shouldSetTempBasal`: A `Bool` that is `true` if `determineBasal` should exit and apply the recommendation immediately.
- /// - `determination`: The (potentially modified) determination object.
- static func iobGreaterThanMax(
- iob: Decimal,
- maxIob: Decimal,
- currentTemp: TempBasal,
- basal: Decimal,
- profile: Profile,
- determination: Determination
- ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
- guard iob > maxIob else {
- return (shouldSetTempBasal: false, determination: determination)
- }
- var newDetermination = determination
- newDetermination.reason += "IOB \(iob.jsRounded(scale: 2)) > max_iob \(maxIob)"
- let roundedBasal = TempBasalFunctions.roundBasal(profile: profile, basalRate: basal)
- let roundedCurrentRate = TempBasalFunctions.roundBasal(profile: profile, basalRate: currentTemp.rate)
- if currentTemp.duration > 15, roundedBasal == roundedCurrentRate {
- newDetermination.reason += ", temp \(currentTemp.rate) ~ req \(basal)U/hr. "
- return (shouldSetTempBasal: true, determination: newDetermination)
- } else {
- newDetermination.reason += "; setting current basal of \(basal) as temp. "
- let finalDetermination = try TempBasalFunctions.setTempBasal(
- rate: basal,
- duration: 30,
- profile: profile,
- determination: newDetermination,
- currentTemp: currentTemp
- )
- return (shouldSetTempBasal: true, determination: finalDetermination)
- }
- }
- /// Calculates the insulin required to bring the projected glucose down to the target.
- ///
- /// - Returns: A tuple containing:
- /// - `insulinRequired`: The calculated amount of insulin needed.
- /// - `determination`: The (potentially modified) determination object with the reason updated.
- static func calculateInsulinRequired(
- minForecastGlucose: Decimal,
- eventualGlucose: Decimal,
- targetGlucose: Decimal,
- adjustedSensitivity: Decimal,
- maxIob: Decimal,
- currentIob: Decimal,
- determination: Determination
- ) -> (insulinRequired: Decimal, determination: Determination) {
- var newDetermination = determination
- var insulinRequired = (
- (min(minForecastGlucose, eventualGlucose) - targetGlucose) / adjustedSensitivity
- ).jsRounded(scale: 2)
- if insulinRequired > maxIob - currentIob {
- newDetermination.reason += "max_iob \(maxIob), "
- // Important: on this path insulinRequired gets rounded
- // to three decimal places, not 2 like on the default path
- insulinRequired = (maxIob - currentIob).jsRounded(scale: 3)
- }
- newDetermination.insulinReq = insulinRequired
- return (insulinRequired, newDetermination)
- }
- /// Determines the maxBolus possible for a Super Micro Bolus (SMB)
- static func determineMaxBolus(
- currentBasal: Decimal,
- currentIob: Decimal,
- adjustedCarbRatio: Decimal,
- mealData: ComputedCarbs,
- profile: Profile,
- trioCustomOrefVariables: TrioCustomOrefVariables
- ) -> Decimal {
- let mealInsulinRequired = (mealData.mealCOB / adjustedCarbRatio).jsRounded(scale: 3)
- let overrideFactor = trioCustomOrefVariables.overrideFactor()
- var smbMinutesSetting = profile.maxSMBBasalMinutes
- if trioCustomOrefVariables.useOverride, trioCustomOrefVariables.advancedSettings {
- smbMinutesSetting = trioCustomOrefVariables.smbMinutes
- }
- var uamMinutesSetting = profile.maxUAMSMBBasalMinutes
- if trioCustomOrefVariables.useOverride, trioCustomOrefVariables.advancedSettings {
- uamMinutesSetting = trioCustomOrefVariables.uamMinutes
- }
- if currentIob > mealInsulinRequired, currentIob > 0 {
- if uamMinutesSetting > 0 {
- return (currentBasal * overrideFactor * uamMinutesSetting / 60).jsRounded(scale: 1)
- } else {
- // Note: It should be impossible to have uamMinutesSetting of 0 so this shouldn't execute
- return (currentBasal * overrideFactor * 30 / 60).jsRounded(scale: 1)
- }
- } else {
- return (currentBasal * overrideFactor * smbMinutesSetting / 60).jsRounded(scale: 1)
- }
- }
- /// Determines if a Super Micro Bolus (SMB) should be delivered and calculates its size and associated temp basal.
- ///
- /// - Returns: A tuple containing:
- /// - `shouldSetTempBasal`: `true` if an SMB (or associated low temp) was enacted and the process should exit.
- /// - `determination`: The (potentially modified) determination object containing the decision.
- static func determineSMBDelivery(
- insulinRequired: Decimal,
- microBolusAllowed: Bool,
- smbIsEnabled: Bool,
- currentGlucose: Decimal,
- threshold: Decimal,
- profile: Profile,
- trioCustomOrefVariables: TrioCustomOrefVariables,
- mealData: ComputedCarbs,
- iobData: [IobResult],
- currentTime: Date,
- targetGlucose: Decimal,
- naiveEventualGlucose: Decimal,
- minIOBForecastedGlucose: Decimal,
- adjustedSensitivity: Decimal,
- adjustedCarbRatio: Decimal,
- basal: Decimal,
- determination: Determination
- ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
- var newDetermination = determination
- guard microBolusAllowed, smbIsEnabled, currentGlucose > threshold else {
- return (false, newDetermination)
- }
- guard let currentBasal = profile.currentBasal else {
- // Should be impossible if we got this far
- throw TempBasalFunctionError.invalidBasalRateOnProfile
- }
- guard let currentIob = iobData.first?.iob else {
- return (false, newDetermination)
- }
- let maxBolus = determineMaxBolus(
- currentBasal: currentBasal,
- currentIob: currentIob,
- adjustedCarbRatio: adjustedCarbRatio,
- mealData: mealData,
- profile: profile,
- trioCustomOrefVariables: trioCustomOrefVariables
- )
- let smbDeliveryRatio = min(profile.smbDeliveryRatio, 1)
- let roundSmbTo = 1 / profile.bolusIncrement
- let microBolusWithoutRounding = min(insulinRequired * smbDeliveryRatio, maxBolus)
- let microBolus = (microBolusWithoutRounding * roundSmbTo).floor() / roundSmbTo
- let worstCaseInsulinRequired = (targetGlucose - (naiveEventualGlucose + minIOBForecastedGlucose) / 2) /
- adjustedSensitivity
- var durationRequired = (60 * worstCaseInsulinRequired / currentBasal * trioCustomOrefVariables.overrideFactor())
- .jsRounded()
- // if insulinRequired > 0 but not enough for a microBolus, don't set an SMB zero temp
- if insulinRequired > 0, microBolus < profile.bolusIncrement {
- durationRequired = 0
- }
- var smbLowTempRequired: Decimal = 0
- if durationRequired <= 0 {
- durationRequired = 0
- } else if durationRequired >= 30 {
- durationRequired = (durationRequired / 30).jsRounded() * 30
- durationRequired = min(60, max(0, durationRequired))
- } else {
- // Note: we're using the fully adjusted basal here
- smbLowTempRequired = (basal * durationRequired / 30).jsRounded(scale: 2)
- durationRequired = 30
- }
- newDetermination.reason += " insulinReq \(insulinRequired)"
- if microBolus >= maxBolus {
- newDetermination.reason += "; maxBolus \(maxBolus)"
- }
- if durationRequired > 0 {
- newDetermination.reason += "; setting \(durationRequired)m low temp of \(smbLowTempRequired)U/h"
- }
- newDetermination.reason += ". "
- var smbInterval: Decimal = 3
- if !profile.smbInterval.isNaN {
- smbInterval = min(10, max(1, profile.smbInterval))
- }
- // minutes since last bolus
- let lastBolusAge: Decimal?
- if let lastBolusTime = iobData.first?.lastBolusTime {
- let millisecondsSince1970 = Decimal(currentTime.timeIntervalSince1970 * 1000)
- lastBolusAge = ((millisecondsSince1970 - Decimal(lastBolusTime)) / 60000).jsRounded(scale: 1)
- } else {
- lastBolusAge = nil
- }
- if let lastBolusAge {
- // BUG: JS rounds minutes independently from seconds, causing double-counting when
- // minutes rounds up. E.g., 0.6 min = 36 sec, but JS outputs "1m 36s" (96 sec).
- // Correct logic would be:
- // let totalSeconds = Int(((smbInterval - lastBolusAge) * 60).jsRounded())
- // let nextBolusMinutes = totalSeconds / 60
- // let nextBolusSeconds = totalSeconds % 60
- // Keeping JS behavior for now to match outputs.
- let nextBolusMinutes = (smbInterval - lastBolusAge).jsRounded()
- let nextBolusSeconds = Int(((smbInterval - lastBolusAge) * 60).jsRounded()) % 60
- if lastBolusAge > smbInterval {
- if microBolus > 0 {
- newDetermination.units = microBolus
- newDetermination.reason += "Microbolusing \(microBolus)U. "
- }
- } else {
- newDetermination.reason += "Waiting \(nextBolusMinutes)m \(nextBolusSeconds)s to microbolus again. "
- }
- }
- if durationRequired > 0 {
- newDetermination.rate = smbLowTempRequired
- newDetermination.duration = durationRequired
- return (true, newDetermination)
- }
- return (false, newDetermination)
- }
- /// Determines and sets a high temp basal if required to bring glucose down.
- ///
- /// - Returns: The final determination object with the high temp set (if applicable).
- static func determineHighTempBasal(
- insulinRequired: Decimal,
- basal: Decimal,
- profile: Profile,
- currentTemp: TempBasal,
- determination: Determination
- ) throws -> Determination {
- var newDetermination = determination
- var rate = basal + (2 * insulinRequired)
- rate = TempBasalFunctions.roundBasal(profile: profile, basalRate: rate)
- let maxSafeBasal = try TempBasalFunctions.getMaxSafeBasalRate(profile: profile)
- if rate > maxSafeBasal {
- newDetermination.reason += "adj. req. rate: \(rate) to maxSafeBasal: \(maxSafeBasal.jsRounded(scale: 2)), "
- rate = TempBasalFunctions.roundBasal(profile: profile, basalRate: maxSafeBasal)
- }
- let insulinScheduled = Decimal(currentTemp.duration) * (currentTemp.rate - basal) / 60
- if insulinScheduled >= insulinRequired * 2 {
- let rateFormatted = String(format: "%.2f", Double(truncating: currentTemp.rate.jsRounded(scale: 2) as NSNumber))
- newDetermination.reason +=
- "\(currentTemp.duration)m@\(rateFormatted) > 2 * insulinReq. Setting temp basal of \(rate)U/hr. "
- let finalDetermination = try TempBasalFunctions.setTempBasal(
- rate: rate,
- duration: 30,
- profile: profile,
- determination: newDetermination,
- currentTemp: currentTemp
- )
- return finalDetermination
- }
- if currentTemp.duration == 0 {
- newDetermination.reason += "no temp, setting \(rate)U/hr. "
- let finalDetermination = try TempBasalFunctions.setTempBasal(
- rate: rate,
- duration: 30,
- profile: profile,
- determination: newDetermination,
- currentTemp: currentTemp
- )
- return finalDetermination
- }
- let roundedRate = TempBasalFunctions.roundBasal(profile: profile, basalRate: rate)
- let roundedCurrentRate = TempBasalFunctions.roundBasal(profile: profile, basalRate: currentTemp.rate)
- if currentTemp.duration > 5, roundedRate <= roundedCurrentRate {
- newDetermination.reason += "temp \(currentTemp.rate) >~ req \(rate)U/hr. "
- return newDetermination
- }
- newDetermination.reason += "temp \(currentTemp.rate)<\(rate)U/hr. "
- let finalDetermination = try TempBasalFunctions.setTempBasal(
- rate: rate,
- duration: 30,
- profile: profile,
- determination: newDetermination,
- currentTemp: currentTemp
- )
- return finalDetermination
- }
- }
|