DetermineBasalGenerator.swift 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. import Foundation
  2. protocol OverrideHandler {
  3. func overrideProfileParameters(profile: Profile, override: Override?) throws -> Profile
  4. // TODO: handle mutation of profile parameters that the user can alter using Overrides
  5. /// This could also possibly be handled via an extension of our existing `ProfileGenerator` (?)
  6. }
  7. enum DeterminationGenerator {
  8. // override data can just be fetched from the DB
  9. // handling via overrideManager ?
  10. static func generate(
  11. profile: Profile,
  12. currentTemp: TempBasal,
  13. iobData: [IobResult],
  14. mealData: ComputedCarbs,
  15. autosensData: Autosens,
  16. reservoirData _: Reservoir,
  17. glucoseStatus: GlucoseStatus?,
  18. currentTime: Date
  19. ) throws -> Determination? {
  20. try checkDeterminationInputs(
  21. glucoseStatus: glucoseStatus,
  22. currentTemp: currentTemp,
  23. iobData: iobData,
  24. profile: profile,
  25. currentTime: currentTime,
  26. )
  27. guard let glucoseStatus = glucoseStatus else { throw DeterminationError.missingInputs }
  28. let currentGlucose: Decimal = glucoseStatus.glucose
  29. if let errorDetermination = handleTempBasalCases(
  30. glucoseStatus: glucoseStatus,
  31. profile: profile,
  32. currentTemp: currentTemp,
  33. currentTime: currentTime
  34. ) {
  35. return errorDetermination
  36. }
  37. let sensitivityRatio = calculateSensitivityRatio(
  38. profile: profile,
  39. autosens: autosensData,
  40. targetGlucose: profile.targetBg ?? 120,
  41. temptargetSet: profile.temptargetSet ?? false
  42. )
  43. let basal = computeAdjustedBasal(
  44. currentBasalRate: profile.currentBasal ?? profile.basalFor(time: currentTime),
  45. sensitivityRatio: sensitivityRatio
  46. )
  47. let sensitivity = computeAdjustedSensitivity(
  48. sensitivity: profile.sens ?? profile.sensitivityFor(time: currentTime),
  49. sensitivityRatio: sensitivityRatio
  50. )
  51. // Safety check: current temp vs. last temp in iob
  52. if !checkCurrentTempBasalRateSafety(
  53. currentTemp: currentTemp,
  54. lastTempTarget: iobData[0].lastTemp,
  55. currentTime: currentTime
  56. ) {
  57. let reason =
  58. "Safety check: currentTemp does not match lastTemp in IOB or lastTemp ended long ago; canceling temp basal."
  59. return Determination(
  60. id: UUID(),
  61. reason: reason,
  62. units: nil,
  63. insulinReq: nil,
  64. eventualBG: nil,
  65. sensitivityRatio: nil,
  66. rate: 0,
  67. duration: 0,
  68. iob: iobData[0].iob,
  69. cob: nil,
  70. predictions: nil,
  71. deliverAt: currentTime,
  72. carbsReq: nil,
  73. temp: .absolute,
  74. bg: glucoseStatus.glucose,
  75. reservoir: nil,
  76. isf: profile.sens,
  77. timestamp: currentTime,
  78. tdd: nil,
  79. current_target: profile.targetBg,
  80. insulinForManualBolus: nil,
  81. manualBolusErrorString: nil,
  82. minDelta: nil,
  83. expectedDelta: nil,
  84. minGuardBG: nil,
  85. minPredBG: nil,
  86. threshold: nil,
  87. carbRatio: profile.carbRatio,
  88. received: false
  89. )
  90. }
  91. let (adjustedGlucoseTargets, threshold) = adjustGlucoseTargets(
  92. profile: profile,
  93. autosens: autosensData,
  94. temptargetSet: profile.temptargetSet ?? false,
  95. targetGlucose: profile.targetBg ?? 100, // TODO: grab from therapy settings
  96. minGlucose: profile.minBg ?? 70, // TODO: can we force unwrap?
  97. maxGlucose: profile.maxBg ?? 180,
  98. noise: 1
  99. )
  100. let glucoseImpactSeries = buildGlucoseImpactSeries(iobDataSeries: iobData, sensitivity: sensitivity)
  101. let currentGlucoseImpact = glucoseImpactSeries[0]
  102. let minDelta = min(glucoseStatus.delta, glucoseStatus.shortAvgDelta)
  103. let minAvgDelta = min(glucoseStatus.shortAvgDelta, glucoseStatus.longAvgDelta)
  104. let longAvgDelta = glucoseStatus.longAvgDelta
  105. let intervals: Decimal = 6 // 30 / 5
  106. var deviation = (intervals * (minDelta - currentGlucoseImpact)).rounded(toPlaces: 0)
  107. if deviation < 0 {
  108. deviation = (intervals * (minAvgDelta - currentGlucoseImpact)).rounded(toPlaces: 0)
  109. if deviation < 0 {
  110. deviation = (intervals * (longAvgDelta - currentGlucoseImpact)).rounded(toPlaces: 0)
  111. }
  112. }
  113. // Calculate what oref calls "naive eventual glucose"
  114. let currentIob = iobData[0].iob
  115. let naiveEventualGlucose: Decimal
  116. if currentIob > 0 {
  117. naiveEventualGlucose = (currentGlucose - (currentIob * sensitivity)).rounded(toPlaces: 0)
  118. } else {
  119. naiveEventualGlucose =
  120. (currentGlucose - (currentIob * min(profile.sens ?? profile.sensitivityFor(time: currentTime), sensitivity)))
  121. .rounded(toPlaces: 0)
  122. }
  123. let eventualGlucose = naiveEventualGlucose + deviation
  124. // Safety: if we ever get an invalid Decimal (very rare with Decimal), handle
  125. guard eventualGlucose.isFinite else {
  126. throw DeterminationError.eventualGlucoseCalculationError(sensitivity: sensitivity, deviation: deviation)
  127. }
  128. let forecastGenerator = ForecastGenerator()
  129. let forecastResult = forecastGenerator.generate(
  130. glucose: currentGlucose,
  131. glucoseImpactSeries: glucoseImpactSeries,
  132. iobData: iobData,
  133. mealData: mealData,
  134. profile: profile,
  135. adjustedSensitivity: sensitivity,
  136. sensitivityRatio: sensitivityRatio,
  137. naiveEventualGlucose: naiveEventualGlucose,
  138. eventualGlucose: eventualGlucose,
  139. threshold: threshold,
  140. currentTime: currentTime
  141. )
  142. // used for pre dosing decision sanity later on
  143. let expectedDelta = calculateExpectedDelta(
  144. targetGlucose: profile.targetBg ?? 100,
  145. eventualGlucose: eventualGlucose,
  146. glucoseImpact: currentGlucoseImpact
  147. )
  148. // TODO: STOPPING at LINE 1152
  149. // FIXME: properly populate all fields!
  150. let temporaryResult = Determination(
  151. id: UUID(),
  152. reason: "FOR TESTING: output after forecasting",
  153. units: nil,
  154. insulinReq: nil,
  155. eventualBG: Int(forecastResult.eventualGlucose),
  156. sensitivityRatio: sensitivityRatio, // this would only the AS-adjusted one for now
  157. rate: nil,
  158. duration: nil,
  159. iob: iobData.first?.iob,
  160. cob: mealData.mealCOB,
  161. predictions: Predictions(iob: forecastResult.iob.map { Int($0) }, zt: forecastResult.zt.map { Int($0) }, cob: forecastResult.cob.map { Int($0) }, uam: forecastResult.uam.map { Int($0) } ),
  162. deliverAt: currentTime,
  163. carbsReq: nil,
  164. temp: nil,
  165. bg: currentGlucose,
  166. reservoir: nil,
  167. isf: nil,
  168. timestamp: currentTime,
  169. tdd: nil,
  170. current_target: nil,
  171. insulinForManualBolus: nil,
  172. manualBolusErrorString: nil,
  173. minDelta: nil,
  174. expectedDelta: expectedDelta,
  175. minGuardBG: forecastResult.minGuardGlucose,
  176. minPredBG: forecastResult.minForecastedGlucose,
  177. threshold: threshold,
  178. carbRatio: nil,
  179. received: false,
  180. )
  181. // TODO: how to handle output?
  182. // TODO: how to handle logging?
  183. return temporaryResult
  184. }
  185. static func checkDeterminationInputs(
  186. glucoseStatus: GlucoseStatus?,
  187. currentTemp _: TempBasal?,
  188. iobData: [IobResult]?,
  189. profile: Profile?,
  190. currentTime: Date = Date()
  191. ) throws {
  192. guard let glucoseStatus = glucoseStatus else {
  193. throw DeterminationError.missingGlucoseStatus
  194. }
  195. guard let profile = profile else {
  196. throw DeterminationError.missingProfile
  197. }
  198. let glucoseAge = currentTime.timeIntervalSince(glucoseStatus.date)
  199. if glucoseAge > 15 * 60 {
  200. throw DeterminationError.staleGlucoseData(ageMinutes: glucoseAge / 60)
  201. }
  202. if glucoseStatus.glucose < 39 || glucoseStatus.glucose > 600 {
  203. throw DeterminationError.glucoseOutOfRange(glucose: glucoseStatus.glucose)
  204. }
  205. if glucoseStatus.delta == 0 {
  206. throw DeterminationError.noDelta
  207. }
  208. guard let _ = iobData else {
  209. throw DeterminationError.missingIob
  210. }
  211. }
  212. static func handleTempBasalCases(
  213. glucoseStatus: GlucoseStatus,
  214. profile: Profile,
  215. currentTemp: TempBasal?,
  216. currentTime: Date
  217. ) -> Determination? {
  218. let glucose = glucoseStatus.glucose
  219. let noise = glucoseStatus.noise
  220. let bgTime = glucoseStatus.date
  221. let minAgo = Decimal(currentTime.timeIntervalSince(bgTime) / 60) // minutes
  222. let shortAvgDelta = glucoseStatus.shortAvgDelta
  223. let longAvgDelta = glucoseStatus.longAvgDelta
  224. let delta = glucoseStatus.delta
  225. let device = glucoseStatus.device
  226. // Always use profile-supplied basal
  227. let basal = profile.currentBasal ?? profile.basalFor(time: currentTime)
  228. // Compose tick for log
  229. let tick: String = (delta > -0.5) ? "+\(delta.rounded(toPlaces: 0))" : "\(delta.rounded(toPlaces: 0))"
  230. let minDelta = min(delta, shortAvgDelta)
  231. let minAvgDelta = min(shortAvgDelta, longAvgDelta)
  232. let maxDelta = max(delta, shortAvgDelta, longAvgDelta)
  233. var reason = ""
  234. // === ERROR CONDITIONS ===
  235. // xDrip code 38 = sensor error; BG <= 10 = ???/calibrating; noise >= 3 = high noise
  236. if glucose <= 10 || glucose == 38 || noise >= 3 {
  237. reason = "CGM is calibrating, in ??? state, or noise is high"
  238. }
  239. // minAgo (BG age) > 12 or < -5 = old/future BG
  240. if minAgo > 12 || minAgo < -5 {
  241. reason =
  242. "If current system time \(currentTime) is correct, then BG data is too old. The last BG data was read \(minAgo) min ago at \(bgTime)"
  243. }
  244. // CGM data unchanged (flat)
  245. if shortAvgDelta == 0 && longAvgDelta == 0 {
  246. if glucoseStatus.lastCalIndex != nil, glucoseStatus.lastCalIndex! < 3 {
  247. reason = "CGM was just calibrated"
  248. } else {
  249. reason =
  250. "CGM data is unchanged (\(glucose)+\(delta)) for 5m w/ \(shortAvgDelta) mg/dL ~15m change & \(longAvgDelta) mg/dL ~45m change"
  251. }
  252. }
  253. let errorDetected =
  254. glucose <= 10 ||
  255. glucose == 38 ||
  256. noise >= 3 ||
  257. minAgo > 12 ||
  258. minAgo < -5 ||
  259. (shortAvgDelta == 0 && longAvgDelta == 0)
  260. // === IF ERROR, CANCEL/SHORTEN TEMPS ===
  261. guard errorDetected, let currentTemp = currentTemp else { return nil }
  262. if currentTemp.rate >= basal {
  263. // Cancel high temp: set 0U/hr for 0m (neutralizes)
  264. let reasonWithAction = reason + ". Canceling high temp basal of \(currentTemp.rate)U/hr."
  265. return Determination(
  266. id: UUID(),
  267. reason: reasonWithAction,
  268. units: nil,
  269. insulinReq: nil,
  270. eventualBG: nil,
  271. sensitivityRatio: nil,
  272. rate: 0,
  273. duration: 0,
  274. iob: nil,
  275. cob: nil,
  276. predictions: nil,
  277. deliverAt: currentTime,
  278. carbsReq: nil,
  279. temp: .absolute,
  280. bg: glucose,
  281. reservoir: nil,
  282. isf: profile.sens,
  283. timestamp: currentTime,
  284. tdd: nil,
  285. current_target: profile.targetBg,
  286. insulinForManualBolus: nil,
  287. manualBolusErrorString: nil,
  288. minDelta: minDelta,
  289. expectedDelta: nil,
  290. minGuardBG: nil,
  291. minPredBG: nil,
  292. threshold: nil,
  293. carbRatio: profile.carbRatio,
  294. received: false
  295. )
  296. } else if currentTemp.rate == 0, currentTemp.duration > 30 {
  297. // Shorten long zero temp to 30m
  298. let reasonWithAction = reason + ". Shortening \(currentTemp.duration)m long zero temp to 30m."
  299. return Determination(
  300. id: UUID(),
  301. reason: reasonWithAction,
  302. units: nil,
  303. insulinReq: nil,
  304. eventualBG: nil,
  305. sensitivityRatio: nil,
  306. rate: 0,
  307. duration: 30,
  308. iob: nil,
  309. cob: nil,
  310. predictions: nil,
  311. deliverAt: currentTime,
  312. carbsReq: nil,
  313. temp: .absolute,
  314. bg: glucose,
  315. reservoir: nil,
  316. isf: profile.sens,
  317. timestamp: currentTime,
  318. tdd: nil,
  319. current_target: profile.targetBg,
  320. insulinForManualBolus: nil,
  321. manualBolusErrorString: nil,
  322. minDelta: minDelta,
  323. expectedDelta: nil,
  324. minGuardBG: nil,
  325. minPredBG: nil,
  326. threshold: nil,
  327. carbRatio: profile.carbRatio,
  328. received: false
  329. )
  330. } else {
  331. // Do nothing (temp already safe)
  332. let reasonWithAction = reason + ". Temp \(currentTemp.rate) <= current basal \(basal)U/hr; doing nothing."
  333. return Determination(
  334. id: UUID(),
  335. reason: reasonWithAction,
  336. units: nil,
  337. insulinReq: nil,
  338. eventualBG: nil,
  339. sensitivityRatio: nil,
  340. rate: currentTemp.rate,
  341. duration: Decimal(currentTemp.duration),
  342. iob: nil,
  343. cob: nil,
  344. predictions: nil,
  345. deliverAt: currentTime,
  346. carbsReq: nil,
  347. temp: currentTemp.temp,
  348. bg: glucose,
  349. reservoir: nil,
  350. isf: profile.sens,
  351. timestamp: currentTime,
  352. tdd: nil,
  353. current_target: profile.targetBg,
  354. insulinForManualBolus: nil,
  355. manualBolusErrorString: nil,
  356. minDelta: minDelta,
  357. expectedDelta: nil,
  358. minGuardBG: nil,
  359. minPredBG: nil,
  360. threshold: nil,
  361. carbRatio: profile.carbRatio,
  362. received: false
  363. )
  364. }
  365. }
  366. }