DynamicISF.swift 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. import Foundation
  2. /// Represents the successful output of a dynamic ISF calculation.
  3. struct DynamicISFResult {
  4. /// The final sensitivity ratio, after all calculations and clamping.
  5. let ratio: Decimal
  6. /// The ratio of 24h TDD to the 14-day average TDD, clamped by autosens limits.
  7. let tddRatio: Decimal
  8. /// The calculated insulin factor (120 - peak time), used in the logarithmic formula.
  9. let insulinFactor: Decimal
  10. /// The ratio before clamping was applied.
  11. let uncappedRatio: Decimal
  12. /// The limit value if the ratio was clamped, nil otherwise.
  13. let limitValue: Decimal?
  14. }
  15. enum DynamicISF {
  16. /// Calculates the dynamic ISF ratio and related values.
  17. ///
  18. /// This function ports the core logic from `determine-basal.js` for dynamic ISF.
  19. /// - Parameters:
  20. /// - profile: The user's profile, containing settings like autosens limits and insulin curve type.
  21. /// - preferences: The user's preferences, containing feature flags like `useNewFormula` and `sigmoid`.
  22. /// - currentGlucose: The most recent glucose reading.
  23. /// - tdd: The total daily dose of insulin, used as a key input for the logarithmic formula.
  24. /// - profileTarget: The effective, override-adjusted blood glucose target. Used in the sigmoid formula.
  25. /// - sensitivity: The effective, override-adjusted insulin sensitivity (ISF). Used in the logarithmic formula.
  26. /// - trioCustomOrefVariables: Custom variables containing TDD averages needed for the TDD ratio calculation.
  27. /// - Returns: A `DynamicISFResult` struct on success, or `nil` if the feature is disabled or preconditions are not met.
  28. static func calculate(
  29. profile: Profile,
  30. preferences: Preferences,
  31. currentGlucose: Decimal,
  32. trioCustomOrefVariables: TrioCustomOrefVariables
  33. ) -> DynamicISFResult? {
  34. let tdd = trioCustomOrefVariables.tdd(profile: profile)
  35. guard preferences.useNewFormula, tdd > 0, var sensitivity = profile.sens,
  36. let profileTarget = profile.profileTarget(trioCustomOrefVariables: trioCustomOrefVariables)
  37. else {
  38. return nil
  39. }
  40. sensitivity = trioCustomOrefVariables.override(sensitivity: sensitivity)
  41. let minLimit = min(profile.autosensMin, profile.autosensMax)
  42. let maxLimit = max(profile.autosensMin, profile.autosensMax)
  43. // If the limits are invalid, disable dynamicISF
  44. guard maxLimit > minLimit, maxLimit >= 1, minLimit <= 1 else {
  45. return nil
  46. }
  47. guard preferences.dynamicIsfState(profile: profile, trioCustomOrefVariables: trioCustomOrefVariables) != .off else {
  48. return nil
  49. }
  50. let bg = currentGlucose
  51. var tdd24h_14d_Ratio: Decimal
  52. if trioCustomOrefVariables.average_total_data > 0 {
  53. tdd24h_14d_Ratio = trioCustomOrefVariables.weightedAverage / trioCustomOrefVariables.average_total_data
  54. } else {
  55. tdd24h_14d_Ratio = 1
  56. }
  57. let clampedTddRatio = tdd24h_14d_Ratio.clamp(lowerBound: minLimit, upperBound: maxLimit).rounded(scale: 2)
  58. let insulinFactor: Decimal
  59. if preferences.useCustomPeakTime {
  60. insulinFactor = 120 - profile.insulinPeakTime
  61. } else {
  62. switch profile.curve {
  63. case .rapidActing: insulinFactor = 120 - 65
  64. case .ultraRapid: insulinFactor = 120 - 50
  65. default: insulinFactor = 120 - 65
  66. }
  67. }
  68. var newRatio: Decimal
  69. if preferences.sigmoid {
  70. let autosensInterval = maxLimit - minLimit
  71. let bgDev = (bg - profileTarget) * 0.0555
  72. let tddFactor = clampedTddRatio
  73. var maxMinusOne = maxLimit - 1
  74. // BUG: Note this fudge factor is to avoid a divide by zero but produces
  75. // unintuitive (and incorrect) results. See the unit tests for an example
  76. if maxLimit == 1 { maxMinusOne = maxLimit + 0.01 - 1 }
  77. let fixOffset = Decimal.log10(1 / maxMinusOne - minLimit / maxMinusOne) / Decimal(Foundation.log10(M_E))
  78. let exponent = bgDev * preferences.adjustmentFactorSigmoid * tddFactor + fixOffset
  79. newRatio = autosensInterval / (1 + Decimal.exp(-exponent)) + minLimit
  80. } else {
  81. newRatio = sensitivity * preferences.adjustmentFactor * tdd * (Decimal.log((bg / insulinFactor) + 1) / 1800)
  82. }
  83. let clampedRatio = newRatio.clamp(lowerBound: minLimit, upperBound: maxLimit)
  84. let limitValue: Decimal? = if newRatio > maxLimit {
  85. maxLimit
  86. } else if newRatio < minLimit {
  87. minLimit
  88. } else {
  89. nil
  90. }
  91. return DynamicISFResult(
  92. ratio: clampedRatio,
  93. tddRatio: clampedTddRatio,
  94. insulinFactor: insulinFactor,
  95. uncappedRatio: newRatio,
  96. limitValue: limitValue
  97. )
  98. }
  99. }
  100. extension Decimal {
  101. static func exp(_ x: Decimal) -> Decimal {
  102. Decimal(Foundation.exp(Double(x)))
  103. }
  104. static func log10(_ x: Decimal) -> Decimal {
  105. Decimal(Foundation.log10(Double(x)))
  106. }
  107. static func log(_ x: Decimal) -> Decimal {
  108. Decimal(Foundation.log(Double(x)))
  109. }
  110. }
  111. extension TrioCustomOrefVariables {
  112. func tdd(profile: Profile) -> Decimal {
  113. if profile.weightPercentage < 1, weightedAverage > 1 {
  114. return weightedAverage
  115. } else {
  116. return currentTDD
  117. }
  118. }
  119. }
  120. enum DynamicIsfState {
  121. case off
  122. case sigmoid
  123. case logrithmic
  124. }
  125. extension Preferences {
  126. func dynamicIsfState(profile: Profile, trioCustomOrefVariables: TrioCustomOrefVariables) -> DynamicIsfState {
  127. guard useNewFormula else { return .off }
  128. // Turn off when autosens.min = autosens.max
  129. // BUG: This check matches the JS logic but there should
  130. // be a check for max > min. It's impossible in the UI to have
  131. // min > max so I'll leave it out (and we do a proper check
  132. // elsewhere in DynamicISF)
  133. let minLimit = min(profile.autosensMax, profile.autosensMin)
  134. let maxLimit = max(profile.autosensMax, profile.autosensMin)
  135. if maxLimit == minLimit || minLimit > 1 || maxLimit < 1 {
  136. return .off
  137. }
  138. return sigmoid ? .sigmoid : .logrithmic
  139. }
  140. }