IobHistoryTests.swift 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637
  1. import Foundation
  2. import Testing
  3. @testable import Trio
  4. @Suite("Calculate Temp Treatments Tests") struct CalculateTempTreatmentsTests {
  5. // Helper function to create a basic basal profile
  6. func createBasicBasalProfile() -> [BasalProfileEntry] {
  7. [
  8. BasalProfileEntry(
  9. start: "00:00:00",
  10. minutes: 0,
  11. rate: 1
  12. )
  13. ]
  14. }
  15. @Test("should calculate temp basals with defaults") func calculateTempBasalsWithDefaults() async throws {
  16. let basalprofile = createBasicBasalProfile()
  17. let now = Calendar.current.startOfDay(for: Date()) + 30.minutesToSeconds
  18. let timestamp30mAgo = now - 30.minutesToSeconds
  19. let pumpHistory = [
  20. ComputedPumpHistoryEvent.forTest(
  21. type: .tempBasal,
  22. timestamp: timestamp30mAgo,
  23. duration: nil,
  24. rate: 2,
  25. temp: .absolute
  26. ),
  27. ComputedPumpHistoryEvent.forTest(
  28. type: .tempBasalDuration,
  29. timestamp: timestamp30mAgo,
  30. durationMin: 30
  31. )
  32. ]
  33. var profile = Profile()
  34. profile.currentBasal = 1
  35. profile.maxDailyBasal = 1
  36. profile.dia = 3
  37. profile.basalprofile = basalprofile
  38. profile.suspendZerosIob = false
  39. let treatments = try IobHistory.calcTempTreatments(
  40. history: pumpHistory,
  41. profile: profile,
  42. clock: now,
  43. autosens: nil,
  44. zeroTempDuration: nil
  45. )
  46. // Filter temp basals (excluding zero temps)
  47. let tempBasals = treatments.filter { $0.rate != nil }
  48. // Test expected number of temp basals
  49. #expect(tempBasals.count == 2) // Original temp plus split zero temps
  50. // First entry should be actual temp basal
  51. #expect(tempBasals[0].rate == 2)
  52. #expect(tempBasals[0].duration == 30)
  53. // Following entries should be zero temps
  54. #expect(tempBasals[1].rate == 0)
  55. #expect(tempBasals[1].duration == 0)
  56. // 30m at 2 U/h - 1U/h -> 0.5U
  57. #expect(treatments.netInsulin().isWithin(0.01, of: 0.5))
  58. }
  59. @Test("should handle overlapping temp basals") func handleOverlappingTempBasals() async throws {
  60. let basalprofile = createBasicBasalProfile()
  61. let now = Calendar.current.startOfDay(for: Date()) + 30.minutesToSeconds
  62. let timestamp30mAgo = now - 30.minutesToSeconds
  63. let timestamp15mAgo = now - 15.minutesToSeconds
  64. let pumpHistory = [
  65. ComputedPumpHistoryEvent.forTest(
  66. type: .tempBasal,
  67. timestamp: timestamp30mAgo,
  68. duration: nil,
  69. rate: 2,
  70. temp: .absolute
  71. ),
  72. ComputedPumpHistoryEvent.forTest(
  73. type: .tempBasalDuration,
  74. timestamp: timestamp30mAgo,
  75. durationMin: 30
  76. ),
  77. ComputedPumpHistoryEvent.forTest(
  78. type: .tempBasal,
  79. timestamp: timestamp15mAgo,
  80. durationMin: nil,
  81. rate: 3,
  82. temp: .absolute
  83. ),
  84. ComputedPumpHistoryEvent.forTest(
  85. type: .tempBasalDuration,
  86. timestamp: timestamp15mAgo,
  87. durationMin: 30
  88. )
  89. ]
  90. var profile = Profile()
  91. profile.dia = 3
  92. profile.currentBasal = 1
  93. profile.maxDailyBasal = 1
  94. profile.basalprofile = basalprofile
  95. profile.suspendZerosIob = false
  96. let treatments = try IobHistory.calcTempTreatments(
  97. history: pumpHistory,
  98. profile: profile,
  99. clock: now,
  100. autosens: nil,
  101. zeroTempDuration: nil
  102. )
  103. // Get only non-zero temp basals
  104. let tempBasals = treatments.filter { ($0.rate ?? 0) > 0 && ($0.duration ?? 0) > 0 }
  105. #expect(tempBasals.count == 2)
  106. #expect(tempBasals[0].rate == 2)
  107. #expect(tempBasals[0].duration == 15)
  108. #expect(tempBasals[1].rate == 3)
  109. #expect(tempBasals[1].duration == 16)
  110. // in this case, the JS returns an incorrect adjusted tempBasal set
  111. // so we rely on counting the basals only
  112. // net 1 U/h for 15m and 2 U/h for 15m -> 0.75 U
  113. // but there is buggy rounding behavior so the answer will
  114. // be 0.8
  115. #expect(treatments.netInsulin().isWithin(0.01, of: 0.8))
  116. }
  117. @Test("should handle pump suspends and resumes") func handlePumpSuspendsAndResumes() async throws {
  118. let basalprofile = createBasicBasalProfile()
  119. let now = Calendar.current.startOfDay(for: Date()) + 30.minutesToSeconds
  120. let timestamp30mAgo = now - 30.minutesToSeconds
  121. let timestamp15mAgo = now - 15.minutesToSeconds
  122. let pumpHistory = [
  123. ComputedPumpHistoryEvent.forTest(
  124. type: .tempBasal,
  125. timestamp: timestamp30mAgo,
  126. duration: nil,
  127. rate: 2,
  128. temp: .absolute
  129. ),
  130. ComputedPumpHistoryEvent.forTest(
  131. type: .tempBasalDuration,
  132. timestamp: timestamp30mAgo,
  133. durationMin: 30
  134. ),
  135. ComputedPumpHistoryEvent.forTest(
  136. type: .pumpSuspend,
  137. timestamp: timestamp15mAgo
  138. ),
  139. ComputedPumpHistoryEvent.forTest(
  140. type: .pumpResume,
  141. timestamp: now
  142. )
  143. ]
  144. var profile = Profile()
  145. profile.dia = 3
  146. profile.basalprofile = basalprofile
  147. profile.currentBasal = 1
  148. profile.maxDailyBasal = 1
  149. profile.suspendZerosIob = true
  150. let treatments = try IobHistory.calcTempTreatments(
  151. history: pumpHistory,
  152. profile: profile,
  153. clock: now,
  154. autosens: nil,
  155. zeroTempDuration: nil
  156. )
  157. // Original temp should exist but be shortened
  158. let origTemp = treatments.first { $0.rate == 2 }
  159. #expect(origTemp != nil)
  160. #expect(origTemp?.duration == 15)
  161. // 15m at 2U/h - 1U/h -> 0.25U
  162. // 15m at 0U/h - 1U/h -> -0.25U
  163. // Total: 0
  164. #expect(treatments.netInsulin().isWithin(0.01, of: 0))
  165. }
  166. @Test("should handle basal profile changes") func handleBasalProfileChanges() async throws {
  167. let basalprofile = [
  168. BasalProfileEntry(
  169. start: "00:00:00",
  170. minutes: 0,
  171. rate: 1
  172. ),
  173. BasalProfileEntry(
  174. start: "00:30:00",
  175. minutes: 30,
  176. rate: 2
  177. )
  178. ]
  179. let startingPoint = Calendar.current.startOfDay(for: Date())
  180. let endingPoint = startingPoint + 45.minutesToSeconds
  181. let pumpHistory = [
  182. ComputedPumpHistoryEvent.forTest(
  183. type: .tempBasal,
  184. timestamp: startingPoint,
  185. duration: nil,
  186. rate: 3,
  187. temp: .absolute
  188. ),
  189. ComputedPumpHistoryEvent.forTest(
  190. type: .tempBasalDuration,
  191. timestamp: startingPoint,
  192. durationMin: 60
  193. )
  194. ]
  195. var profile = Profile()
  196. profile.dia = 3
  197. profile.basalprofile = basalprofile
  198. profile.currentBasal = 1
  199. profile.maxDailyBasal = 2
  200. profile.suspendZerosIob = false
  201. let treatments = try IobHistory.calcTempTreatments(
  202. history: pumpHistory,
  203. profile: profile,
  204. clock: endingPoint,
  205. autosens: nil,
  206. zeroTempDuration: nil
  207. )
  208. let tempBasals = treatments.filter { ($0.rate ?? 0) != 0 && ($0.duration ?? 0) > 0 }
  209. #expect(!tempBasals.isEmpty)
  210. // Should split temp basal at profile change
  211. // Note: This is a little different from JS since we use the split output
  212. // and we divide up one tempbasal into two, but it should end up with the
  213. // same result for IoB
  214. #expect(tempBasals[0].rate == 3)
  215. // 30m at 3 U/h - 1 U/h -> 1U
  216. // 15m at 3 U/h - 2 U/h - 0.25U
  217. // 1.25U total
  218. print(treatments.prettyPrintedJSON!)
  219. #expect(treatments.netInsulin().isWithin(0.01, of: 1.25))
  220. }
  221. @Test("should properly record boluses") func properlyRecordBoluses() async throws {
  222. let basalprofile = createBasicBasalProfile()
  223. let now = Calendar.current.startOfDay(for: Date())
  224. let pumpHistory = [
  225. ComputedPumpHistoryEvent.forTest(
  226. type: .bolus,
  227. timestamp: now,
  228. amount: 2
  229. )
  230. ]
  231. var profile = Profile()
  232. profile.dia = 3
  233. profile.basalprofile = basalprofile
  234. profile.currentBasal = 1
  235. profile.maxDailyBasal = 1
  236. profile.suspendZerosIob = false
  237. let treatments = try IobHistory.calcTempTreatments(
  238. history: pumpHistory,
  239. profile: profile,
  240. clock: now,
  241. autosens: nil,
  242. zeroTempDuration: nil
  243. )
  244. let boluses = treatments.filter { $0.insulin != nil }
  245. #expect(boluses.count == 1)
  246. #expect(boluses[0].insulin == 2)
  247. }
  248. @Test("should add zero temp with specified duration") func addZeroTempWithSpecifiedDuration() async throws {
  249. let basalprofile = createBasicBasalProfile()
  250. let now = Calendar.current.startOfDay(for: Date()) + 30.minutesToSeconds
  251. let timestamp30mAgo = now - 30.minutesToSeconds
  252. let pumpHistory = [
  253. ComputedPumpHistoryEvent.forTest(
  254. type: .tempBasal,
  255. timestamp: timestamp30mAgo,
  256. duration: nil,
  257. rate: 2,
  258. temp: .absolute
  259. ),
  260. ComputedPumpHistoryEvent.forTest(
  261. type: .tempBasalDuration,
  262. timestamp: timestamp30mAgo,
  263. durationMin: 30
  264. )
  265. ]
  266. var profile = Profile()
  267. profile.dia = 3
  268. profile.basalprofile = basalprofile
  269. profile.currentBasal = 1
  270. profile.maxDailyBasal = 1
  271. profile.suspendZerosIob = false
  272. // Test with 120 min zero temp duration
  273. let treatments = try IobHistory.calcTempTreatments(
  274. history: pumpHistory,
  275. profile: profile,
  276. clock: now,
  277. autosens: nil,
  278. zeroTempDuration: 120
  279. )
  280. // Get only the zero temps
  281. let zeroTemps = treatments.filter { ($0.rate ?? 0) == 0 && ($0.duration ?? 0) > 0 }
  282. #expect(!zeroTemps.isEmpty)
  283. #expect(!zeroTemps.isEmpty)
  284. // Verify zero temp has correct duration
  285. let duration = zeroTemps.map({ $0.duration! }).reduce(0, +)
  286. #expect(duration == 120)
  287. // Verify zero temp starts 1 min in future
  288. let expectedStart = now + 60 // 1 minute in future
  289. #expect(zeroTemps[0].timestamp == expectedStart)
  290. // 30m at 2U/h - 1U/h -> 0.5
  291. // 120m at 0U/h - 1U/h -> -2.0
  292. // Total -> -1.5U
  293. #expect(treatments.netInsulin().isWithin(0.01, of: -1.5))
  294. }
  295. @Test("should handle zero temp with basal profile changes") func handleZeroTempWithBasalProfileChanges() async throws {
  296. let basalprofile = [
  297. BasalProfileEntry(
  298. start: "00:00:00",
  299. minutes: 0,
  300. rate: 1
  301. ),
  302. BasalProfileEntry(
  303. start: "00:30:00",
  304. minutes: 30,
  305. rate: 2
  306. )
  307. ]
  308. let startingPoint = Calendar.current.startOfDay(for: Date())
  309. let pumpHistory = [
  310. ComputedPumpHistoryEvent.forTest(
  311. type: .tempBasal,
  312. timestamp: startingPoint,
  313. duration: nil,
  314. rate: 3,
  315. temp: .absolute
  316. ),
  317. ComputedPumpHistoryEvent.forTest(
  318. type: .tempBasalDuration,
  319. timestamp: startingPoint,
  320. durationMin: 60
  321. )
  322. ]
  323. var profile = Profile()
  324. profile.dia = 3
  325. profile.basalprofile = basalprofile
  326. profile.currentBasal = 1
  327. profile.maxDailyBasal = 2
  328. profile.suspendZerosIob = false
  329. // Test with 90 min zero temp duration
  330. let treatments = try IobHistory.calcTempTreatments(
  331. history: pumpHistory,
  332. profile: profile,
  333. clock: startingPoint + 60.minutesToSeconds,
  334. autosens: nil,
  335. zeroTempDuration: 90
  336. )
  337. // Get zero temps
  338. let zeroTemps = treatments.filter { ($0.rate ?? 0) == 0 && ($0.duration ?? 0) > 0 }
  339. #expect(!zeroTemps.isEmpty)
  340. // Verify zero temp duration
  341. let duration = zeroTemps.map({ $0.duration! }).reduce(0, +)
  342. #expect(duration == 90)
  343. let expectedStart = startingPoint + 61.minutesToSeconds // 1 minute in future
  344. #expect(zeroTemps[0].timestamp == expectedStart)
  345. // 30m at 3U/h - 1U/h -> 1U
  346. // 30m at 3U/h - 2U/h -> 0.5U
  347. // 90m at 0U/h - 2U/h -> -3U
  348. // Total: -1.5U
  349. #expect(treatments.netInsulin().isWithin(0.01, of: -1.5))
  350. }
  351. @Test("should add zero temp when suspended") func addZeroTempWhenSuspended() async throws {
  352. let basalprofile = createBasicBasalProfile()
  353. let now = Calendar.current.startOfDay(for: Date()) + 30.minutesToSeconds
  354. let timestamp30mAgo = now - 30.minutesToSeconds
  355. let timestamp15mAgo = now - 15.minutesToSeconds
  356. let pumpHistory = [
  357. ComputedPumpHistoryEvent.forTest(
  358. type: .tempBasal,
  359. timestamp: timestamp30mAgo,
  360. duration: nil,
  361. rate: 2,
  362. temp: .absolute
  363. ),
  364. ComputedPumpHistoryEvent.forTest(
  365. type: .tempBasalDuration,
  366. timestamp: timestamp30mAgo,
  367. durationMin: 30
  368. ),
  369. ComputedPumpHistoryEvent.forTest(
  370. type: .pumpSuspend,
  371. timestamp: timestamp15mAgo
  372. )
  373. ]
  374. var profile = Profile()
  375. profile.dia = 3
  376. profile.basalprofile = basalprofile
  377. profile.currentBasal = 1
  378. profile.maxDailyBasal = 1
  379. profile.suspendZerosIob = true
  380. // Test with 60 min zero temp duration
  381. let treatments = try IobHistory.calcTempTreatments(
  382. history: pumpHistory,
  383. profile: profile,
  384. clock: now,
  385. autosens: nil,
  386. zeroTempDuration: 60
  387. )
  388. let tempBasals = treatments.filter { $0.type == .tempBasal }
  389. #expect(tempBasals[0].duration == 15)
  390. #expect(tempBasals[0].timestamp == timestamp30mAgo)
  391. #expect(tempBasals[0].rate == 2)
  392. // 15m at 2U/h - 1U/h -> 0.25U
  393. // 15m at 0U/h - 1U/h -> -0.25U
  394. // 60m at 0U/h - 1U/h -> -1
  395. // Total: -1U
  396. #expect(treatments.netInsulin().isWithin(0.01, of: -1))
  397. }
  398. @Test("should omit zero temp and split temp basal around suspend") func splitTempBasalFromSuspend() async throws {
  399. let basalprofile = [
  400. BasalProfileEntry(
  401. start: "00:00:00",
  402. minutes: 0,
  403. rate: 1.2
  404. )
  405. ]
  406. let now = Calendar.current.startOfDay(for: Date()) + 30.minutesToSeconds
  407. let timestamp30mAgo = now - 30.minutesToSeconds
  408. let timestamp20mAgo = now - 20.minutesToSeconds
  409. let timestamp10mAgo = now - 10.minutesToSeconds
  410. let pumpHistory = [
  411. ComputedPumpHistoryEvent.forTest(
  412. type: .tempBasal,
  413. timestamp: timestamp30mAgo,
  414. duration: nil,
  415. rate: 2.4,
  416. temp: .absolute
  417. ),
  418. ComputedPumpHistoryEvent.forTest(
  419. type: .tempBasalDuration,
  420. timestamp: timestamp30mAgo,
  421. durationMin: 30
  422. ),
  423. ComputedPumpHistoryEvent.forTest(
  424. type: .pumpSuspend,
  425. timestamp: timestamp20mAgo
  426. ),
  427. ComputedPumpHistoryEvent.forTest(
  428. type: .pumpResume,
  429. timestamp: timestamp10mAgo
  430. )
  431. ]
  432. var profile = Profile()
  433. profile.dia = 3
  434. profile.basalprofile = basalprofile
  435. profile.currentBasal = 1.2
  436. profile.maxDailyBasal = 1.2
  437. profile.suspendZerosIob = true
  438. let treatments = try IobHistory.calcTempTreatments(
  439. history: pumpHistory,
  440. profile: profile,
  441. clock: now,
  442. autosens: nil,
  443. zeroTempDuration: nil
  444. )
  445. let tempBasals = treatments.filter { $0.type == .tempBasal }
  446. #expect(tempBasals[0].duration == 10)
  447. #expect(tempBasals[0].timestamp == timestamp30mAgo)
  448. #expect(tempBasals[0].rate == 2.4)
  449. #expect(tempBasals[1].rate == 0)
  450. #expect(tempBasals.count == 2) // the original temp basal + last zero
  451. // 10m at 2.4U/h - 1.2U/h -> 0.2U
  452. // 10m at 0U/h - 1.2U/h -> -0.2U
  453. // 10m at 2.4U/h - 1.2U/h -> 0.2U
  454. // Total: 0.2
  455. #expect(treatments.netInsulin().isWithin(0.01, of: 0.2))
  456. }
  457. @Test("should produce -0.7 IoB") func zerosIoBAroundSuspend() async throws {
  458. let basalprofile = [
  459. BasalProfileEntry(
  460. start: "00:00:00",
  461. minutes: 0,
  462. rate: 0.65
  463. )
  464. ]
  465. let now = Calendar.current.startOfDay(for: Date()) + 60.minutesToSeconds
  466. let pumpHistory = [
  467. ComputedPumpHistoryEvent.forTest(
  468. type: .tempBasal,
  469. timestamp: now - 45.minutesToSeconds,
  470. duration: nil,
  471. rate: 0,
  472. temp: .absolute
  473. ),
  474. ComputedPumpHistoryEvent.forTest(
  475. type: .tempBasalDuration,
  476. timestamp: now - 45.minutesToSeconds,
  477. durationMin: 60
  478. ),
  479. ComputedPumpHistoryEvent.forTest(
  480. type: .pumpSuspend,
  481. timestamp: now - 40.minutesToSeconds
  482. ),
  483. ComputedPumpHistoryEvent.forTest(
  484. type: .pumpResume,
  485. timestamp: now - 39.minutesToSeconds
  486. )
  487. ]
  488. var profile = Profile()
  489. profile.dia = 10
  490. profile.basalprofile = basalprofile
  491. profile.currentBasal = 0.65
  492. profile.maxDailyBasal = 0.65
  493. profile.suspendZerosIob = true
  494. let autosens = Autosens(ratio: 1.4, newisf: 29)
  495. let treatments = try IobHistory.calcTempTreatments(
  496. history: pumpHistory,
  497. profile: profile,
  498. clock: now,
  499. autosens: autosens,
  500. zeroTempDuration: nil
  501. )
  502. #expect(treatments.netInsulin().isWithin(0.01, of: -0.7))
  503. }
  504. @Test(
  505. "should handle temp basal overlapping resume with prior suspension"
  506. ) func handleTempBasalOverlappingResumeWithPriorSuspension() async throws {
  507. let basalprofile = createBasicBasalProfile()
  508. let now = Calendar.current.startOfDay(for: Date()) + 10.hoursToSeconds // Ensure we are well past 8h ago
  509. let resumeTime = now - 30.minutesToSeconds
  510. // Temp basal starts 10 mins before resume, lasts 40 mins.
  511. // So it ends 30 mins after resume.
  512. let tempStart = resumeTime - 10.minutesToSeconds
  513. let tempDuration = 40
  514. let pumpHistory = [
  515. ComputedPumpHistoryEvent.forTest(
  516. type: .pumpResume,
  517. timestamp: resumeTime
  518. ),
  519. ComputedPumpHistoryEvent.forTest(
  520. type: .tempBasal,
  521. timestamp: tempStart,
  522. duration: nil,
  523. rate: 2,
  524. temp: .absolute
  525. ),
  526. ComputedPumpHistoryEvent.forTest(
  527. type: .tempBasalDuration,
  528. timestamp: tempStart,
  529. durationMin: tempDuration
  530. )
  531. ]
  532. var profile = Profile()
  533. profile.dia = 3
  534. profile.basalprofile = basalprofile
  535. profile.currentBasal = 1
  536. profile.maxDailyBasal = 1
  537. profile.suspendZerosIob = true
  538. let treatments = try IobHistory.calcTempTreatments(
  539. history: pumpHistory,
  540. profile: profile,
  541. clock: now,
  542. autosens: nil,
  543. zeroTempDuration: nil
  544. )
  545. let tempBasals = treatments.filter { $0.type == .tempBasal && $0.rate == 2 }
  546. #expect(tempBasals.count == 1)
  547. if let temp = tempBasals.first {
  548. // Should start at resumeTime
  549. #expect(temp.timestamp == resumeTime)
  550. // Should have duration of 30 minutes
  551. #expect(temp.duration == 30)
  552. }
  553. }
  554. }