DetermineBasalAggressiveDosingTests.swift 37 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175
  1. import Foundation
  2. import Testing
  3. @testable import Trio
  4. @Suite("DetermineBasalAggressiveDosingTests") struct DetermineBasalAggressiveDosingTests {
  5. private func callCalculateInsulinRequired(
  6. minForecastGlucose: Decimal,
  7. eventualGlucose: Decimal,
  8. targetGlucose: Decimal,
  9. adjustedSensitivity: Decimal,
  10. maxIob: Decimal,
  11. currentIob: Decimal
  12. ) -> (insulinRequired: Decimal, determination: Determination) {
  13. let determination = Determination(
  14. id: UUID(),
  15. reason: "",
  16. units: nil,
  17. insulinReq: nil,
  18. eventualBG: nil,
  19. sensitivityRatio: nil,
  20. rate: nil,
  21. duration: nil,
  22. iob: nil,
  23. cob: nil,
  24. predictions: nil,
  25. deliverAt: nil,
  26. carbsReq: nil,
  27. temp: nil,
  28. bg: nil,
  29. reservoir: nil,
  30. isf: nil,
  31. timestamp: nil,
  32. tdd: nil,
  33. current_target: nil,
  34. minDelta: nil,
  35. expectedDelta: nil,
  36. minGuardBG: nil,
  37. minPredBG: nil,
  38. threshold: nil,
  39. carbRatio: nil,
  40. received: nil
  41. )
  42. return DosingEngine.calculateInsulinRequired(
  43. minForecastGlucose: minForecastGlucose,
  44. eventualGlucose: eventualGlucose,
  45. targetGlucose: targetGlucose,
  46. adjustedSensitivity: adjustedSensitivity,
  47. maxIob: maxIob,
  48. currentIob: currentIob,
  49. determination: determination
  50. )
  51. }
  52. @Test("should calculate insulin required based on minPredBG when it is lower") func testCalculateBasedOnMinForecast() {
  53. // minPredBG (150) < eventualBG (180)
  54. // (150 - 100) / 50 = 1.0 U
  55. let result = callCalculateInsulinRequired(
  56. minForecastGlucose: 150,
  57. eventualGlucose: 180,
  58. targetGlucose: 100,
  59. adjustedSensitivity: 50,
  60. maxIob: 5,
  61. currentIob: 0
  62. )
  63. #expect(result.insulinRequired == 1.0)
  64. #expect(result.determination.insulinReq == 1.0)
  65. }
  66. @Test("should calculate insulin required based on eventualBG when it is lower") func testCalculateBasedOnEventual() {
  67. // eventualBG (140) < minPredBG (160)
  68. // (140 - 100) / 40 = 1.0 U
  69. let result = callCalculateInsulinRequired(
  70. minForecastGlucose: 160,
  71. eventualGlucose: 140,
  72. targetGlucose: 100,
  73. adjustedSensitivity: 40,
  74. maxIob: 5,
  75. currentIob: 0
  76. )
  77. #expect(result.insulinRequired == 1.0)
  78. #expect(result.determination.insulinReq == 1.0)
  79. }
  80. @Test("should cap insulinReq at max_iob - current_iob") func testCapAtMaxIOB() {
  81. // (200 - 100) / 20 = 5.0 U required
  82. // max_iob (3) - current_iob (1) = 2.0 U available space
  83. let result = callCalculateInsulinRequired(
  84. minForecastGlucose: 200,
  85. eventualGlucose: 200,
  86. targetGlucose: 100,
  87. adjustedSensitivity: 20,
  88. maxIob: 3,
  89. currentIob: 1
  90. )
  91. #expect(result.insulinRequired == 2.0)
  92. #expect(result.determination.reason.contains("max_iob 3"))
  93. }
  94. @Test("should not cap if insulinReq is within max_iob limits") func testNoCapWithinLimits() {
  95. // (140 - 100) / 20 = 2.0 U required
  96. // max_iob (5) - current_iob (1) = 4.0 U available space
  97. let result = callCalculateInsulinRequired(
  98. minForecastGlucose: 140,
  99. eventualGlucose: 140,
  100. targetGlucose: 100,
  101. adjustedSensitivity: 20,
  102. maxIob: 5,
  103. currentIob: 1
  104. )
  105. #expect(result.insulinRequired == 2.0)
  106. #expect(!result.determination.reason.contains("max_iob"))
  107. }
  108. @Test("should handle negative IOB increasing available space") func testNegativeIOBIncreasesSpace() {
  109. // (200 - 100) / 20 = 5.0 U required
  110. // max_iob (3) - current_iob (-1) = 4.0 U available space
  111. let result = callCalculateInsulinRequired(
  112. minForecastGlucose: 200,
  113. eventualGlucose: 200,
  114. targetGlucose: 100,
  115. adjustedSensitivity: 20,
  116. maxIob: 3,
  117. currentIob: -1
  118. )
  119. #expect(result.insulinRequired == 4.0)
  120. #expect(result.determination.reason.contains("max_iob 3"))
  121. }
  122. @Test("should handle negative insulinReq correctly") func testNegativeInsulinReq() {
  123. // (90 - 100) / 50 = -0.2 U
  124. let result = callCalculateInsulinRequired(
  125. minForecastGlucose: 90,
  126. eventualGlucose: 95,
  127. targetGlucose: 100,
  128. adjustedSensitivity: 50,
  129. maxIob: 5,
  130. currentIob: 0
  131. )
  132. #expect(result.insulinRequired == -0.2)
  133. }
  134. @Test("should round calculations to 2 decimal places") func testRounding() {
  135. // (133 - 100) / 30 = 1.1
  136. let result = callCalculateInsulinRequired(
  137. minForecastGlucose: 133,
  138. eventualGlucose: 133,
  139. targetGlucose: 100,
  140. adjustedSensitivity: 30,
  141. maxIob: 5,
  142. currentIob: 0
  143. )
  144. #expect(result.insulinRequired == 1.1)
  145. }
  146. private func callDetermineSMBDelivery(
  147. insulinRequired: Decimal,
  148. microBolusAllowed: Bool = true,
  149. smbIsEnabled: Bool = true,
  150. currentGlucose: Decimal = 120,
  151. threshold: Decimal = 60,
  152. profile: Profile,
  153. mealData: ComputedCarbs = ComputedCarbs(
  154. carbs: 0,
  155. mealCOB: 0,
  156. currentDeviation: 0,
  157. maxDeviation: 0,
  158. minDeviation: 0,
  159. slopeFromMaxDeviation: 0,
  160. slopeFromMinDeviation: 0,
  161. allDeviations: [],
  162. lastCarbTime: 0
  163. ),
  164. iobData: [IobResult],
  165. trioCustomOrefVariables: TrioCustomOrefVariables? = nil,
  166. adjustedCarbRatio: Decimal = 10,
  167. basal: Decimal = 1.0,
  168. naiveEventualGlucose: Decimal = 120,
  169. minIOBForecastedGlucose: Decimal = 120
  170. ) throws -> (shouldSetTempBasal: Bool, determination: Determination) {
  171. let determination = Determination(
  172. id: UUID(),
  173. reason: "",
  174. units: nil,
  175. insulinReq: nil,
  176. eventualBG: nil,
  177. sensitivityRatio: nil,
  178. rate: nil,
  179. duration: nil,
  180. iob: nil,
  181. cob: nil,
  182. predictions: nil,
  183. deliverAt: nil,
  184. carbsReq: nil,
  185. temp: nil,
  186. bg: nil,
  187. reservoir: nil,
  188. isf: nil,
  189. timestamp: nil,
  190. tdd: nil,
  191. current_target: nil,
  192. minDelta: nil,
  193. expectedDelta: nil,
  194. minGuardBG: nil,
  195. minPredBG: nil,
  196. threshold: nil,
  197. carbRatio: nil,
  198. received: nil
  199. )
  200. var finalTrioCustomOrefVariables: TrioCustomOrefVariables
  201. if let customOrefVars = trioCustomOrefVariables {
  202. finalTrioCustomOrefVariables = customOrefVars
  203. } else {
  204. finalTrioCustomOrefVariables = TrioCustomOrefVariables(
  205. average_total_data: 0,
  206. weightedAverage: 0,
  207. currentTDD: 0,
  208. past2hoursAverage: 0,
  209. date: Date(),
  210. overridePercentage: 100,
  211. useOverride: false,
  212. duration: 0,
  213. unlimited: false,
  214. overrideTarget: 0,
  215. smbIsOff: false,
  216. advancedSettings: false,
  217. isfAndCr: false,
  218. isf: false,
  219. cr: false,
  220. smbIsScheduledOff: false,
  221. start: 0,
  222. end: 0,
  223. smbMinutes: 30,
  224. uamMinutes: 30
  225. )
  226. }
  227. return try DosingEngine.determineSMBDelivery(
  228. insulinRequired: insulinRequired,
  229. microBolusAllowed: microBolusAllowed,
  230. smbIsEnabled: smbIsEnabled,
  231. currentGlucose: currentGlucose,
  232. threshold: threshold,
  233. profile: profile,
  234. trioCustomOrefVariables: finalTrioCustomOrefVariables,
  235. mealData: mealData,
  236. iobData: iobData,
  237. currentTime: Date(),
  238. targetGlucose: profile.targetBg ?? 100,
  239. naiveEventualGlucose: naiveEventualGlucose,
  240. minIOBForecastedGlucose: minIOBForecastedGlucose,
  241. adjustedSensitivity: profile.sens ?? 40,
  242. adjustedCarbRatio: adjustedCarbRatio,
  243. basal: basal,
  244. determination: determination
  245. )
  246. }
  247. @Test("should calculate correct microbolus with rounding") func testMicroBolusRounding() throws {
  248. var profile = Profile()
  249. profile.currentBasal = 1.0
  250. profile.maxSMBBasalMinutes = 30
  251. profile.smbDeliveryRatio = 0.5
  252. profile.bolusIncrement = 0.1
  253. profile.sens = 40
  254. profile.targetBg = 100
  255. let now = Date()
  256. let lastBolusTime = UInt64(now.addingTimeInterval(-600).timeIntervalSince1970 * 1000) // 10 minutes ago
  257. let dummyIobWithZeroTemp = IobResult.IobWithZeroTemp(
  258. iob: 0,
  259. activity: 0,
  260. basaliob: 0,
  261. bolusiob: 0,
  262. netbasalinsulin: 0,
  263. bolusinsulin: 0,
  264. time: Date()
  265. )
  266. let iobData = [IobResult(
  267. iob: 0,
  268. activity: 0,
  269. basaliob: 0,
  270. bolusiob: 0,
  271. netbasalinsulin: 0,
  272. bolusinsulin: 0,
  273. time: Date(),
  274. iobWithZeroTemp: dummyIobWithZeroTemp,
  275. lastBolusTime: lastBolusTime,
  276. lastTemp: nil
  277. )]
  278. // insulinReq = 1.55
  279. // maxBolus = 1.0 * 30/60 = 0.5
  280. // smb = min(1.55 * 0.5, 0.5) = min(0.775, 0.5) = 0.5
  281. // 0.5 is already rounded.
  282. // Let's try a case where maxBolus is higher.
  283. profile.maxSMBBasalMinutes = 60
  284. // maxBolus = 1.0 * 60/60 = 1.0
  285. // smb = min(1.55 * 0.5, 1.0) = 0.775
  286. // rounded down to 0.1 increment: 0.7
  287. let result = try callDetermineSMBDelivery(
  288. insulinRequired: 1.55,
  289. profile: profile,
  290. iobData: iobData
  291. )
  292. #expect(result.determination.units == 0.7)
  293. }
  294. @Test("should apply override factor to maxBolus") func testOverrideFactorMaxBolus() throws {
  295. var profile = Profile()
  296. profile.currentBasal = 1.0
  297. profile.maxSMBBasalMinutes = 30
  298. profile.smbDeliveryRatio = 0.5
  299. profile.bolusIncrement = 0.1
  300. profile.sens = 40
  301. profile.targetBg = 100
  302. let now = Date()
  303. let lastBolusTime = UInt64(now.addingTimeInterval(-600).timeIntervalSince1970 * 1000) // 10 minutes ago
  304. let dummyIobWithZeroTemp = IobResult.IobWithZeroTemp(
  305. iob: 0,
  306. activity: 0,
  307. basaliob: 0,
  308. bolusiob: 0,
  309. netbasalinsulin: 0,
  310. bolusinsulin: 0,
  311. time: Date()
  312. )
  313. let iobData = [IobResult(
  314. iob: 0,
  315. activity: 0,
  316. basaliob: 0,
  317. bolusiob: 0,
  318. netbasalinsulin: 0,
  319. bolusinsulin: 0,
  320. time: Date(),
  321. iobWithZeroTemp: dummyIobWithZeroTemp,
  322. lastBolusTime: lastBolusTime,
  323. lastTemp: nil
  324. )]
  325. // Override factor 2.0 (200%)
  326. // maxBolus = 1.0 * 2.0 * 30/60 = 1.0
  327. // insulinReq = 3.0
  328. // smb = min(3.0 * 0.5, 1.0) = min(1.5, 1.0) = 1.0
  329. var customOrefVars = TrioCustomOrefVariables(
  330. average_total_data: 0,
  331. weightedAverage: 0,
  332. currentTDD: 0,
  333. past2hoursAverage: 0,
  334. date: Date(),
  335. overridePercentage: 200, // 2.0 * 100
  336. useOverride: true,
  337. duration: 0,
  338. unlimited: false,
  339. overrideTarget: 0,
  340. smbIsOff: false,
  341. advancedSettings: false,
  342. isfAndCr: false,
  343. isf: false,
  344. cr: false,
  345. smbIsScheduledOff: false,
  346. start: 0,
  347. end: 0,
  348. smbMinutes: 30,
  349. uamMinutes: 30
  350. )
  351. let result = try callDetermineSMBDelivery(
  352. insulinRequired: 3.0,
  353. profile: profile,
  354. iobData: iobData,
  355. trioCustomOrefVariables: customOrefVars
  356. )
  357. #expect(result.determination.units == 1.0)
  358. }
  359. @Test(
  360. "should override smbMinutes and uamMinutes when useOverride and advancedSettings are true"
  361. ) func testOverrideSmbUamMinutes() throws {
  362. var profile = Profile()
  363. profile.currentBasal = 1.0
  364. profile.maxSMBBasalMinutes = 30 // 0.5U
  365. profile.maxUAMSMBBasalMinutes = 30 // 0.5U
  366. profile.smbDeliveryRatio = 0.5
  367. profile.bolusIncrement = 0.1
  368. profile.sens = 40
  369. profile.targetBg = 100
  370. let now = Date()
  371. let lastBolusTime = UInt64(now.addingTimeInterval(-600).timeIntervalSince1970 * 1000)
  372. // Case 1: Regular SMB (IOB <= mealInsulinReq)
  373. // insulinReq = 3.0
  374. // maxBolus should be 1.0 (60 mins override) instead of 0.5 (30 mins profile)
  375. // smb = min(3.0 * 0.5, 1.0) = 1.0
  376. var customOrefVars = TrioCustomOrefVariables(
  377. average_total_data: 0,
  378. weightedAverage: 0,
  379. currentTDD: 0,
  380. past2hoursAverage: 0,
  381. date: Date(),
  382. overridePercentage: 100,
  383. useOverride: true,
  384. duration: 0,
  385. unlimited: false,
  386. overrideTarget: 0,
  387. smbIsOff: false,
  388. advancedSettings: true,
  389. isfAndCr: false,
  390. isf: false,
  391. cr: false,
  392. smbIsScheduledOff: false,
  393. start: 0,
  394. end: 0,
  395. smbMinutes: 60,
  396. uamMinutes: 60
  397. )
  398. let dummyIobWithZeroTemp = IobResult.IobWithZeroTemp(
  399. iob: 0,
  400. activity: 0,
  401. basaliob: 0,
  402. bolusiob: 0,
  403. netbasalinsulin: 0,
  404. bolusinsulin: 0,
  405. time: Date()
  406. )
  407. let iobData = [IobResult(
  408. iob: 0,
  409. activity: 0,
  410. basaliob: 0,
  411. bolusiob: 0,
  412. netbasalinsulin: 0,
  413. bolusinsulin: 0,
  414. time: Date(),
  415. iobWithZeroTemp: dummyIobWithZeroTemp,
  416. lastBolusTime: lastBolusTime,
  417. lastTemp: nil
  418. )]
  419. let result = try callDetermineSMBDelivery(
  420. insulinRequired: 3.0,
  421. profile: profile,
  422. iobData: iobData,
  423. trioCustomOrefVariables: customOrefVars
  424. )
  425. #expect(result.determination.units == 1.0)
  426. // Case 2: UAM SMB (IOB > mealInsulinReq)
  427. // mealCOB = 10, CR = 10 => mealInsulinReq = 1.0
  428. // iob = 1.5
  429. let mealData = ComputedCarbs(
  430. carbs: 0,
  431. mealCOB: 10,
  432. currentDeviation: 0,
  433. maxDeviation: 0,
  434. minDeviation: 0,
  435. slopeFromMaxDeviation: 0,
  436. slopeFromMinDeviation: 0,
  437. allDeviations: [],
  438. lastCarbTime: 0
  439. )
  440. let uamIobData = [IobResult(
  441. iob: 1.5,
  442. activity: 0,
  443. basaliob: 0,
  444. bolusiob: 0,
  445. netbasalinsulin: 0,
  446. bolusinsulin: 0,
  447. time: Date(),
  448. iobWithZeroTemp: dummyIobWithZeroTemp,
  449. lastBolusTime: lastBolusTime,
  450. lastTemp: nil
  451. )]
  452. let uamResult = try callDetermineSMBDelivery(
  453. insulinRequired: 3.0,
  454. profile: profile,
  455. mealData: mealData,
  456. iobData: uamIobData,
  457. trioCustomOrefVariables: customOrefVars
  458. )
  459. #expect(uamResult.determination.units == 1.0)
  460. }
  461. @Test(
  462. "should not override smbMinutes and uamMinutes when advancedSettings is false"
  463. ) func testNoOverrideWhenAdvancedSettingsFalse() throws {
  464. var profile = Profile()
  465. profile.currentBasal = 1.0
  466. profile.maxSMBBasalMinutes = 30 // 0.5U
  467. profile.smbDeliveryRatio = 0.5
  468. profile.bolusIncrement = 0.1
  469. profile.sens = 40
  470. profile.targetBg = 100
  471. let now = Date()
  472. let lastBolusTime = UInt64(now.addingTimeInterval(-600).timeIntervalSince1970 * 1000)
  473. var customOrefVars = TrioCustomOrefVariables(
  474. average_total_data: 0,
  475. weightedAverage: 0,
  476. currentTDD: 0,
  477. past2hoursAverage: 0,
  478. date: Date(),
  479. overridePercentage: 100,
  480. useOverride: true,
  481. duration: 0,
  482. unlimited: false,
  483. overrideTarget: 0,
  484. smbIsOff: false,
  485. advancedSettings: false,
  486. isfAndCr: false,
  487. isf: false,
  488. cr: false,
  489. smbIsScheduledOff: false,
  490. start: 0,
  491. end: 0,
  492. smbMinutes: 60,
  493. uamMinutes: 60
  494. )
  495. let dummyIobWithZeroTemp = IobResult.IobWithZeroTemp(
  496. iob: 0,
  497. activity: 0,
  498. basaliob: 0,
  499. bolusiob: 0,
  500. netbasalinsulin: 0,
  501. bolusinsulin: 0,
  502. time: Date()
  503. )
  504. let iobData = [IobResult(
  505. iob: 0,
  506. activity: 0,
  507. basaliob: 0,
  508. bolusiob: 0,
  509. netbasalinsulin: 0,
  510. bolusinsulin: 0,
  511. time: Date(),
  512. iobWithZeroTemp: dummyIobWithZeroTemp,
  513. lastBolusTime: lastBolusTime,
  514. lastTemp: nil
  515. )]
  516. // insulinReq = 3.0
  517. // maxBolus should be 0.5 (30 mins from profile), ignoring override 60 because advancedSettings is false
  518. // smb = min(1.5, 0.5) = 0.5
  519. let result = try callDetermineSMBDelivery(
  520. insulinRequired: 3.0,
  521. profile: profile,
  522. iobData: iobData,
  523. trioCustomOrefVariables: customOrefVars
  524. )
  525. #expect(result.determination.units == 0.5)
  526. }
  527. @Test("should use overridePercentage from custom vars if provided") func testOverridePercentageFromCustomVars() throws {
  528. var profile = Profile()
  529. profile.currentBasal = 1.0
  530. profile.maxSMBBasalMinutes = 30 // 0.5U
  531. profile.smbDeliveryRatio = 0.5
  532. profile.bolusIncrement = 0.1
  533. profile.sens = 40
  534. profile.targetBg = 100
  535. let now = Date()
  536. let lastBolusTime = UInt64(now.addingTimeInterval(-600).timeIntervalSince1970 * 1000)
  537. var customOrefVars = TrioCustomOrefVariables(
  538. average_total_data: 0,
  539. weightedAverage: 0,
  540. currentTDD: 0,
  541. past2hoursAverage: 0,
  542. date: Date(),
  543. overridePercentage: 200, // 200%
  544. useOverride: true,
  545. duration: 0,
  546. unlimited: false,
  547. overrideTarget: 0,
  548. smbIsOff: false,
  549. advancedSettings: false,
  550. isfAndCr: false,
  551. isf: false,
  552. cr: false,
  553. smbIsScheduledOff: false,
  554. start: 0,
  555. end: 0,
  556. smbMinutes: 30,
  557. uamMinutes: 30
  558. )
  559. let dummyIobWithZeroTemp = IobResult.IobWithZeroTemp(
  560. iob: 0,
  561. activity: 0,
  562. basaliob: 0,
  563. bolusiob: 0,
  564. netbasalinsulin: 0,
  565. bolusinsulin: 0,
  566. time: Date()
  567. )
  568. let iobData = [IobResult(
  569. iob: 0,
  570. activity: 0,
  571. basaliob: 0,
  572. bolusiob: 0,
  573. netbasalinsulin: 0,
  574. bolusinsulin: 0,
  575. time: Date(),
  576. iobWithZeroTemp: dummyIobWithZeroTemp,
  577. lastBolusTime: lastBolusTime,
  578. lastTemp: nil
  579. )]
  580. // maxBolus = 1.0 * 2.0 * 30/60 = 1.0
  581. // insulinReq = 3.0
  582. // smb = min(3.0 * 0.5, 1.0) = 1.0
  583. let result = try callDetermineSMBDelivery(
  584. insulinRequired: 3.0,
  585. profile: profile,
  586. iobData: iobData,
  587. trioCustomOrefVariables: customOrefVars
  588. )
  589. #expect(result.determination.units == 1.0)
  590. }
  591. @Test("should not bolus if within SMB interval") func testSMBInterval() throws {
  592. var profile = Profile()
  593. profile.currentBasal = 1.0
  594. profile.maxSMBBasalMinutes = 30
  595. profile.smbDeliveryRatio = 0.5
  596. profile.bolusIncrement = 0.1
  597. profile.smbInterval = 3
  598. profile.sens = 40
  599. profile.targetBg = 100
  600. // Last bolus 1 minute ago
  601. let now = Date()
  602. let lastBolusTime = UInt64(now.addingTimeInterval(-60).timeIntervalSince1970 * 1000)
  603. let dummyIobWithZeroTemp = IobResult.IobWithZeroTemp(
  604. iob: 0,
  605. activity: 0,
  606. basaliob: 0,
  607. bolusiob: 0,
  608. netbasalinsulin: 0,
  609. bolusinsulin: 0,
  610. time: Date()
  611. )
  612. let iobData = [IobResult(
  613. iob: 0,
  614. activity: 0,
  615. basaliob: 0,
  616. bolusiob: 0,
  617. netbasalinsulin: 0,
  618. bolusinsulin: 0,
  619. time: now,
  620. iobWithZeroTemp: dummyIobWithZeroTemp,
  621. lastBolusTime: lastBolusTime,
  622. lastTemp: nil
  623. )]
  624. // Setup conditions where temp basal is required so it returns true, but NO units
  625. // worstCase > 0 => low pred
  626. // (100 - (90+90)/2) / 40 = 0.25
  627. // duration = 60 * 0.25 / 1 = 15m
  628. // 15m -> <30m -> sets smbLowTempReq -> returns true
  629. let result = try callDetermineSMBDelivery(
  630. insulinRequired: 1.0,
  631. profile: profile,
  632. iobData: iobData,
  633. naiveEventualGlucose: 90,
  634. minIOBForecastedGlucose: 90
  635. )
  636. #expect(result.shouldSetTempBasal == true)
  637. #expect(result.determination.units == nil)
  638. #expect(result.determination.reason.contains("Waiting"))
  639. }
  640. @Test("should return false if SMB conditions not met") func testGuardConditions() throws {
  641. var profile = Profile()
  642. profile.currentBasal = 1.0
  643. let dummyIobWithZeroTemp = IobResult.IobWithZeroTemp(
  644. iob: 0,
  645. activity: 0,
  646. basaliob: 0,
  647. bolusiob: 0,
  648. netbasalinsulin: 0,
  649. bolusinsulin: 0,
  650. time: Date()
  651. )
  652. let iobData = [IobResult(
  653. iob: 0,
  654. activity: 0,
  655. basaliob: 0,
  656. bolusiob: 0,
  657. netbasalinsulin: 0,
  658. bolusinsulin: 0,
  659. time: Date(),
  660. iobWithZeroTemp: dummyIobWithZeroTemp,
  661. lastBolusTime: nil,
  662. lastTemp: nil
  663. )]
  664. // bg (100) < threshold (110)
  665. let result = try callDetermineSMBDelivery(
  666. insulinRequired: 1.0,
  667. currentGlucose: 100,
  668. threshold: 110,
  669. profile: profile,
  670. iobData: iobData
  671. )
  672. #expect(result.shouldSetTempBasal == false)
  673. }
  674. private func callDetermineHighTempBasal(
  675. insulinRequired: Decimal,
  676. basal: Decimal,
  677. profile: Profile,
  678. currentTemp: TempBasal
  679. ) throws -> Determination {
  680. let determination = Determination(
  681. id: UUID(),
  682. reason: "",
  683. units: nil,
  684. insulinReq: nil,
  685. eventualBG: nil,
  686. sensitivityRatio: nil,
  687. rate: nil,
  688. duration: nil,
  689. iob: nil,
  690. cob: nil,
  691. predictions: nil,
  692. deliverAt: nil,
  693. carbsReq: nil,
  694. temp: nil,
  695. bg: nil,
  696. reservoir: nil,
  697. isf: nil,
  698. timestamp: nil,
  699. tdd: nil,
  700. current_target: nil,
  701. minDelta: nil,
  702. expectedDelta: nil,
  703. minGuardBG: nil,
  704. minPredBG: nil,
  705. threshold: nil,
  706. carbRatio: nil,
  707. received: nil
  708. )
  709. return try DosingEngine.determineHighTempBasal(
  710. insulinRequired: insulinRequired,
  711. basal: basal,
  712. profile: profile,
  713. currentTemp: currentTemp,
  714. determination: determination
  715. )
  716. }
  717. @Test("should set high temp if no temp is running") func testSetHighTempNoTemp() throws {
  718. var profile = Profile()
  719. profile.maxBasal = 5.0
  720. profile.maxDailyBasal = 5.0
  721. profile.currentBasal = 1.0 // Unused by logic but good for completeness
  722. // insulinReq = 1.0. basal = 1.0. rate = 1.0 + 2*1.0 = 3.0.
  723. let result = try callDetermineHighTempBasal(
  724. insulinRequired: 1.0,
  725. basal: 1.0,
  726. profile: profile,
  727. currentTemp: TempBasal(duration: 0, rate: 0, temp: .absolute, timestamp: Date())
  728. )
  729. #expect(result.rate == 3.0)
  730. #expect(result.duration == 30)
  731. #expect(result.reason.contains("no temp, setting 3U/hr"))
  732. }
  733. @Test("should cap rate at maxSafeBasal") func testCapAtMaxSafeBasal() throws {
  734. var profile = Profile()
  735. profile.maxBasal = 2.0 // Restrict max basal
  736. profile.maxDailyBasal = 2.0
  737. profile.currentBasalSafetyMultiplier = 4
  738. profile.maxDailySafetyMultiplier = 3
  739. profile.currentBasal = 1.0
  740. // insulinReq = 1.0. basal = 1.0. rate = 3.0. Max = 2.0.
  741. let result = try callDetermineHighTempBasal(
  742. insulinRequired: 1.0,
  743. basal: 1.0,
  744. profile: profile,
  745. currentTemp: TempBasal(duration: 0, rate: 0, temp: .absolute, timestamp: Date())
  746. )
  747. #expect(result.rate == 2.0)
  748. #expect(result.reason.contains("adj. req. rate: 3"))
  749. #expect(result.reason.contains("maxSafeBasal: 2"))
  750. }
  751. @Test("should reduce temp if current temp delivers >2x required insulin") func testReduceTempIfScheduledTooHigh() throws {
  752. var profile = Profile()
  753. profile.maxBasal = 5.0
  754. profile.maxDailyBasal = 5.0
  755. profile.currentBasal = 1.0
  756. // insulinReq = 0.5. 2x = 1.0 U.
  757. // basal = 1.0. rate = 1.0 + 1.0 = 2.0.
  758. // Current temp: rate 4.0, duration 30m.
  759. // insulinScheduled = 30 * (4.0 - 1.0) / 60 = 1.5 U.
  760. let result = try callDetermineHighTempBasal(
  761. insulinRequired: 0.5,
  762. basal: 1.0,
  763. profile: profile,
  764. currentTemp: TempBasal(duration: 30, rate: 4.0, temp: .absolute, timestamp: Date())
  765. )
  766. #expect(result.rate == 2.0)
  767. #expect(result.reason.contains("> 2 * insulinReq"))
  768. }
  769. @Test("should do nothing if current temp is sufficient") func testDoNothingIfSufficient() throws {
  770. var profile = Profile()
  771. profile.maxBasal = 5.0
  772. profile.maxDailyBasal = 5.0
  773. profile.currentBasal = 1.0
  774. // insulinReq = 1.0. rate = 3.0.
  775. // Current temp: rate 3.0, duration 30m.
  776. let result = try callDetermineHighTempBasal(
  777. insulinRequired: 1.0,
  778. basal: 1.0,
  779. profile: profile,
  780. currentTemp: TempBasal(duration: 30, rate: 3.0, temp: .absolute, timestamp: Date())
  781. )
  782. // Should return determination without setting rate/duration (nil implies unchanged in this context check?)
  783. // Wait, determineHighTempBasal returns a Determination. If it calls setTempBasal, rate/duration are set.
  784. // If it falls through, it returns 'determination' (which has nil rate/duration).
  785. #expect(result.rate == nil)
  786. #expect(result.duration == nil)
  787. #expect(result.reason.contains("temp 3 >~ req 3U/hr"))
  788. }
  789. @Test("should set new temp if current temp is insufficient") func testSetNewTempIfInsufficient() throws {
  790. var profile = Profile()
  791. profile.maxBasal = 5.0
  792. profile.maxDailyBasal = 5.0
  793. profile.currentBasal = 1.0
  794. // insulinReq = 1.0. rate = 3.0.
  795. // Current temp: rate 2.0.
  796. let result = try callDetermineHighTempBasal(
  797. insulinRequired: 1.0,
  798. basal: 1.0,
  799. profile: profile,
  800. currentTemp: TempBasal(duration: 30, rate: 2.0, temp: .absolute, timestamp: Date())
  801. )
  802. #expect(result.rate == 3.0)
  803. #expect(result.duration == 30)
  804. #expect(result.reason.contains("temp 2<3U/hr"))
  805. }
  806. @Test("should set 30m zero temp if durationReq is between 30 and 45") func testSet30mZeroTemp() throws {
  807. var profile = Profile()
  808. profile.currentBasal = 1.0
  809. profile.maxSMBBasalMinutes = 30
  810. profile.smbDeliveryRatio = 0.5
  811. profile.bolusIncrement = 0.1
  812. profile.sens = 50
  813. profile.targetBg = 100
  814. let dummyIobWithZeroTemp = IobResult.IobWithZeroTemp(
  815. iob: 0,
  816. activity: 0,
  817. basaliob: 0,
  818. bolusiob: 0,
  819. netbasalinsulin: 0,
  820. bolusinsulin: 0,
  821. time: Date()
  822. )
  823. let iobData = [IobResult(
  824. iob: 0,
  825. activity: 0,
  826. basaliob: 0,
  827. bolusiob: 0,
  828. netbasalinsulin: 0,
  829. bolusinsulin: 0,
  830. time: Date(),
  831. iobWithZeroTemp: dummyIobWithZeroTemp,
  832. lastBolusTime: nil,
  833. lastTemp: nil
  834. )]
  835. // worstCaseInsulinReq needs to result in durationReq ~ 35
  836. // duration = 60 * worst / basal => 35 = 60 * worst / 1.0 => worst = 0.583
  837. // worst = (100 - avg)/50 => avg = 70.85
  838. let result = try callDetermineSMBDelivery(
  839. insulinRequired: 1.0,
  840. profile: profile,
  841. iobData: iobData,
  842. naiveEventualGlucose: 70.85,
  843. minIOBForecastedGlucose: 70.85
  844. )
  845. #expect(result.shouldSetTempBasal == true)
  846. #expect(result.determination.rate == 0)
  847. #expect(result.determination.duration == 30)
  848. }
  849. @Test("should set 60m zero temp if durationReq is > 45") func testSet60mZeroTemp() throws {
  850. var profile = Profile()
  851. profile.currentBasal = 1.0
  852. profile.maxSMBBasalMinutes = 30
  853. profile.smbDeliveryRatio = 0.5
  854. profile.bolusIncrement = 0.1
  855. profile.sens = 50
  856. profile.targetBg = 100
  857. let dummyIobWithZeroTemp = IobResult.IobWithZeroTemp(
  858. iob: 0,
  859. activity: 0,
  860. basaliob: 0,
  861. bolusiob: 0,
  862. netbasalinsulin: 0,
  863. bolusinsulin: 0,
  864. time: Date()
  865. )
  866. let iobData = [IobResult(
  867. iob: 0,
  868. activity: 0,
  869. basaliob: 0,
  870. bolusiob: 0,
  871. netbasalinsulin: 0,
  872. bolusinsulin: 0,
  873. time: Date(),
  874. iobWithZeroTemp: dummyIobWithZeroTemp,
  875. lastBolusTime: nil,
  876. lastTemp: nil
  877. )]
  878. // worstCaseInsulinReq needs to result in durationReq ~ 50
  879. // 50 = 60 * worst / 1.0 => worst = 0.833
  880. // worst = (100 - avg)/50 => avg = 58.35
  881. let result = try callDetermineSMBDelivery(
  882. insulinRequired: 1.0,
  883. profile: profile,
  884. iobData: iobData,
  885. naiveEventualGlucose: 58.35,
  886. minIOBForecastedGlucose: 58.35
  887. )
  888. #expect(result.shouldSetTempBasal == true)
  889. #expect(result.determination.rate == 0)
  890. #expect(result.determination.duration == 60)
  891. }
  892. @Test("should cap zero temp duration at 60m") func testCapZeroTempAt60m() throws {
  893. var profile = Profile()
  894. profile.currentBasal = 1.0
  895. profile.maxSMBBasalMinutes = 30
  896. profile.smbDeliveryRatio = 0.5
  897. profile.bolusIncrement = 0.1
  898. profile.sens = 50
  899. profile.targetBg = 100
  900. let dummyIobWithZeroTemp = IobResult.IobWithZeroTemp(
  901. iob: 0,
  902. activity: 0,
  903. basaliob: 0,
  904. bolusiob: 0,
  905. netbasalinsulin: 0,
  906. bolusinsulin: 0,
  907. time: Date()
  908. )
  909. let iobData = [IobResult(
  910. iob: 0,
  911. activity: 0,
  912. basaliob: 0,
  913. bolusiob: 0,
  914. netbasalinsulin: 0,
  915. bolusinsulin: 0,
  916. time: Date(),
  917. iobWithZeroTemp: dummyIobWithZeroTemp,
  918. lastBolusTime: nil,
  919. lastTemp: nil
  920. )]
  921. // worstCaseInsulinReq needs to result in durationReq > 75
  922. // 100 = 60 * worst / 1.0 => worst = 1.66
  923. // worst = (100 - avg)/50 => avg = 17
  924. let result = try callDetermineSMBDelivery(
  925. insulinRequired: 1.0,
  926. profile: profile,
  927. iobData: iobData,
  928. naiveEventualGlucose: 17,
  929. minIOBForecastedGlucose: 17
  930. )
  931. #expect(result.shouldSetTempBasal == true)
  932. #expect(result.determination.rate == 0)
  933. #expect(result.determination.duration == 60)
  934. }
  935. @Test("should set low temp if durationReq < 30") func testSetLowTemp() throws {
  936. var profile = Profile()
  937. profile.currentBasal = 1.0
  938. profile.maxSMBBasalMinutes = 30
  939. profile.smbDeliveryRatio = 0.5
  940. profile.bolusIncrement = 0.1
  941. profile.sens = 50
  942. profile.targetBg = 100
  943. let dummyIobWithZeroTemp = IobResult.IobWithZeroTemp(
  944. iob: 0,
  945. activity: 0,
  946. basaliob: 0,
  947. bolusiob: 0,
  948. netbasalinsulin: 0,
  949. bolusinsulin: 0,
  950. time: Date()
  951. )
  952. let iobData = [IobResult(
  953. iob: 0,
  954. activity: 0,
  955. basaliob: 0,
  956. bolusiob: 0,
  957. netbasalinsulin: 0,
  958. bolusinsulin: 0,
  959. time: Date(),
  960. iobWithZeroTemp: dummyIobWithZeroTemp,
  961. lastBolusTime: nil,
  962. lastTemp: nil
  963. )]
  964. // worstCaseInsulinReq needs to result in durationReq = 15
  965. // 15 = 60 * worst / 1.0 => worst = 0.25
  966. // worst = (100 - avg)/50 => avg = 87.5
  967. let result = try callDetermineSMBDelivery(
  968. insulinRequired: 1.0,
  969. profile: profile,
  970. iobData: iobData,
  971. basal: 1.0,
  972. naiveEventualGlucose: 87.5,
  973. minIOBForecastedGlucose: 87.5
  974. )
  975. // Rate = basal * 15/30 = 1.0 * 0.5 = 0.5
  976. #expect(result.shouldSetTempBasal == true)
  977. #expect(result.determination.rate == 0.5)
  978. #expect(result.determination.duration == 30)
  979. }
  980. @Test("should not set temp if insulinReq > 0 but microBolus < increment") func testNoTempIfMicroBolusTooSmall() throws {
  981. var profile = Profile()
  982. profile.currentBasal = 1.0
  983. profile.maxSMBBasalMinutes = 30
  984. profile.smbDeliveryRatio = 0.5
  985. profile.bolusIncrement = 0.1
  986. profile.sens = 50
  987. profile.targetBg = 100
  988. let dummyIobWithZeroTemp = IobResult.IobWithZeroTemp(
  989. iob: 0,
  990. activity: 0,
  991. basaliob: 0,
  992. bolusiob: 0,
  993. netbasalinsulin: 0,
  994. bolusinsulin: 0,
  995. time: Date()
  996. )
  997. let iobData = [IobResult(
  998. iob: 0,
  999. activity: 0,
  1000. basaliob: 0,
  1001. bolusiob: 0,
  1002. netbasalinsulin: 0,
  1003. bolusinsulin: 0,
  1004. time: Date(),
  1005. iobWithZeroTemp: dummyIobWithZeroTemp,
  1006. lastBolusTime: nil,
  1007. lastTemp: nil
  1008. )]
  1009. // insulinReq = 0.05 (positive but small)
  1010. // microBolus < 0.1
  1011. // durationReq = 15m (via predictions)
  1012. let result = try callDetermineSMBDelivery(
  1013. insulinRequired: 0.05,
  1014. profile: profile,
  1015. iobData: iobData,
  1016. basal: 1.0,
  1017. naiveEventualGlucose: 87.5,
  1018. minIOBForecastedGlucose: 87.5
  1019. )
  1020. #expect(result.shouldSetTempBasal == false)
  1021. }
  1022. @Test("should not set temp if durationReq <= 0") func testNoTempIfDurationReqNegative() throws {
  1023. var profile = Profile()
  1024. profile.currentBasal = 1.0
  1025. profile.maxSMBBasalMinutes = 30
  1026. profile.smbDeliveryRatio = 0.5
  1027. profile.bolusIncrement = 0.1
  1028. profile.sens = 50
  1029. profile.targetBg = 100
  1030. let dummyIobWithZeroTemp = IobResult.IobWithZeroTemp(
  1031. iob: 0,
  1032. activity: 0,
  1033. basaliob: 0,
  1034. bolusiob: 0,
  1035. netbasalinsulin: 0,
  1036. bolusinsulin: 0,
  1037. time: Date()
  1038. )
  1039. let iobData = [IobResult(
  1040. iob: 0,
  1041. activity: 0,
  1042. basaliob: 0,
  1043. bolusiob: 0,
  1044. netbasalinsulin: 0,
  1045. bolusinsulin: 0,
  1046. time: Date(),
  1047. iobWithZeroTemp: dummyIobWithZeroTemp,
  1048. lastBolusTime: nil,
  1049. lastTemp: nil
  1050. )]
  1051. // High predictions => negative worstCase => negative durationReq
  1052. // avg = 150 > target 100
  1053. let result = try callDetermineSMBDelivery(
  1054. insulinRequired: 1.0,
  1055. profile: profile,
  1056. iobData: iobData,
  1057. basal: 1.0,
  1058. naiveEventualGlucose: 150,
  1059. minIOBForecastedGlucose: 150
  1060. )
  1061. #expect(result.shouldSetTempBasal == false)
  1062. }
  1063. }