IobJsonTests.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. import Foundation
  2. import Testing
  3. @testable import Trio
  4. /// This test suite is to help us debug and verify iob errors from Trio devices
  5. ///
  6. /// There are two key components. First, we have a version of the Javascript that has a number
  7. /// of bugs fixed. We don't want to fix the real Javascript, so we put this fixed Javascript in the
  8. /// testing bundle and use it to run comparisons. If the error we see in the field is one that we know
  9. /// about and have fixed in JS, the Swift and JS implementations will produce the same results. You
  10. /// can find the fixed JS here:
  11. /// https://github.com/kingst/trio-oref/tree/tcd-fixes-for-swift-comparison
  12. ///
  13. /// Second, we have a server that runs (part of `trio-oref-logs`) to serve error logs captured
  14. /// from the field. This server needs to run on the same machine as the simulator where this test runs.
  15. /// You can find more information about it from the `trio-oref-logs` repo.
  16. @Suite("IoB using real pump history JSON", .serialized) struct IobJsonTests {
  17. let timeZoneForTests = TimeZoneForTests()
  18. struct IobHistoryResult: Codable {
  19. var insulin: Decimal?
  20. var rate: Decimal?
  21. var duration: Decimal?
  22. var timestamp: String?
  23. var started_at: String?
  24. var created_at: String?
  25. var date: Decimal?
  26. enum CodingKeys: String, CodingKey {
  27. case insulin
  28. case rate
  29. case duration
  30. case timestamp
  31. case started_at
  32. case created_at
  33. case date
  34. }
  35. }
  36. static func pumpIsSuspended(history: [PumpHistoryEvent]) -> Bool {
  37. // The JS implementation of IoB when the pump is suspend is so fundamentally
  38. // broken that I wasn't able to fix it in JS. So we'll just skip these, but I
  39. // verified them by hand and the Swift implementation appears to be correct
  40. if let mostRecentSuspendResumeEvent = history.filter({ $0.type == .pumpSuspend || $0.type == .pumpResume })
  41. .first
  42. {
  43. return mostRecentSuspendResumeEvent.type == .pumpSuspend
  44. }
  45. return false
  46. }
  47. // Note: This test case has a memory leak so limit your inputs
  48. // to about 250 files at a time
  49. @Test(
  50. "IoB should produce same results for fixed JS and different for bundle JS",
  51. .enabled(if: ReplayTests.enabled)
  52. ) func replayErrorInputs() async throws {
  53. let files = try await HttpFiles.listFiles()
  54. let testingTimezone = ReplayTests.timezone
  55. for filePath in files {
  56. let algorithmComparison = try await HttpFiles.downloadFile(at: filePath)
  57. print("Checking \(filePath) @ \(algorithmComparison.createdAt)")
  58. guard algorithmComparison.timezone == testingTimezone else {
  59. continue
  60. }
  61. guard let iobInputs = algorithmComparison.iobInput else {
  62. print("Skipping, no iobInputs found")
  63. if let str = algorithmComparison.comparisonError {
  64. print(str)
  65. }
  66. if let str = algorithmComparison.swiftException {
  67. print(str)
  68. }
  69. continue
  70. }
  71. if IobJsonTests.pumpIsSuspended(history: iobInputs.history) {
  72. print("Skipping, known issue with JS and currently suspended pumps")
  73. continue
  74. }
  75. timeZoneForTests.setTimezone(identifier: algorithmComparison.timezone)
  76. try await checkFixedJsAgainstSwift(iobInputs: iobInputs)
  77. // try await checkBundleJsAgainstSwift(iobInputs: iobInputs)
  78. timeZoneForTests.resetTimezone()
  79. }
  80. }
  81. func checkFixedJsAgainstSwift(iobInputs: IobInputs) async throws {
  82. let openAps = OpenAPSFixed()
  83. let iobResultSwift = OpenAPSSwift.iob(
  84. pumphistory: iobInputs.history,
  85. profile: try JSONBridge.to(iobInputs.profile),
  86. clock: iobInputs.clock,
  87. autosens: try JSONBridge.to(iobInputs.autosens)
  88. )
  89. let iobResultJavascript = await openAps.iobJavascript(
  90. pumphistory: iobInputs.history,
  91. profile: try JSONBridge.to(iobInputs.profile),
  92. clock: iobInputs.clock,
  93. autosens: try JSONBridge.to(iobInputs.autosens)
  94. )
  95. // In suspendedPrior mode (first suspend/resume event is a Resume), JS incorrectly
  96. // returns pre-resume temp basals in lastTemp because history.js line 566 uses
  97. // tempHistory instead of splitHistory. Swift correctly handles this case.
  98. if case let .success(jsRawJson) = iobResultJavascript,
  99. let jsIobEntries = try? JSONBridge.iobResult(from: jsRawJson),
  100. let jsLastTempDate = jsIobEntries.first?.lastTemp?.date
  101. {
  102. let suspendResumeEvents = iobInputs.history
  103. .filter { $0.type == .pumpSuspend || $0.type == .pumpResume }
  104. .sorted { $0.timestamp < $1.timestamp }
  105. if let firstEvent = suspendResumeEvents.first,
  106. firstEvent.type == .pumpResume
  107. {
  108. let firstResumeTime = UInt64(firstEvent.timestamp.timeIntervalSince1970 * 1000)
  109. if jsLastTempDate < firstResumeTime {
  110. print("Skipping, known issue with JS lastTemp in suspendedPrior mode")
  111. return
  112. }
  113. }
  114. }
  115. let comparison = JSONCompare.createComparison(
  116. function: .iob,
  117. swift: iobResultSwift,
  118. swiftDuration: 0.1,
  119. javascript: iobResultJavascript,
  120. javascriptDuration: 0.1,
  121. iobInputs: nil,
  122. mealInputs: nil,
  123. autosensInputs: nil,
  124. determineBasalInputs: nil,
  125. makeProfileInputs: nil
  126. )
  127. if comparison.resultType == .valueDifference {
  128. print(comparison.differences!.prettyPrintedJSON!)
  129. }
  130. if comparison.resultType != .matching {
  131. print("REPLAY ERROR: Fixed JS didn't match")
  132. }
  133. #expect(comparison.resultType == .matching)
  134. }
  135. func checkBundleJsAgainstSwift(iobInputs: IobInputs) async throws {
  136. let openAps = OpenAPS(storage: BaseFileStorage(), tddStorage: MockTDDStorage())
  137. let iobResultSwift = OpenAPSSwift.iob(
  138. pumphistory: iobInputs.history,
  139. profile: try JSONBridge.to(iobInputs.profile),
  140. clock: iobInputs.clock,
  141. autosens: try JSONBridge.to(iobInputs.autosens)
  142. )
  143. let iobResultJavascript = await openAps.iobJavascript(
  144. pumphistory: iobInputs.history,
  145. profile: try JSONBridge.to(iobInputs.profile),
  146. clock: iobInputs.clock,
  147. autosens: try JSONBridge.to(iobInputs.autosens)
  148. )
  149. let comparison = JSONCompare.createComparison(
  150. function: .iob,
  151. swift: iobResultSwift,
  152. swiftDuration: 0.1,
  153. javascript: iobResultJavascript,
  154. javascriptDuration: 0.1,
  155. iobInputs: nil,
  156. mealInputs: nil,
  157. autosensInputs: nil,
  158. determineBasalInputs: nil,
  159. makeProfileInputs: nil
  160. )
  161. if comparison.resultType != .valueDifference {
  162. print("REPLAY ERROR: bundle JS did't produce value difference")
  163. }
  164. #expect(comparison.resultType == .valueDifference)
  165. }
  166. func checkHistoryConsistency(swiftTreatments: [ComputedPumpHistoryEvent], jsTreatments: [IobHistoryResult]) {
  167. let swiftNetBolus = swiftTreatments.compactMap(\.insulin).filter({ $0 >= 0.1 }).reduce(0, +)
  168. let jsNetBolus = jsTreatments.compactMap(\.insulin).filter({ $0 >= 0.1 }).reduce(0, +)
  169. let swiftNetBasal = swiftTreatments.compactMap(\.insulin).filter({ $0 < 0.1 }).reduce(0, +)
  170. let jsNetBasal = jsTreatments.compactMap(\.insulin).filter({ $0 < 0.1 }).reduce(0, +)
  171. #expect(swiftNetBasal == jsNetBasal)
  172. #expect(swiftNetBolus == jsNetBolus)
  173. }
  174. func checkRunningBasal(swiftTreatments: [ComputedPumpHistoryEvent], jsTreatments: [IobHistoryResult]) {
  175. let swiftBasals = swiftTreatments.filter({ $0.rate != nil }).filter({ $0.duration! > 0 })
  176. let jsBasals = jsTreatments.filter({ $0.rate != nil }).filter({ $0.duration! > 0 })
  177. #expect(swiftBasals.count == jsBasals.count)
  178. for (swift, js) in zip(swiftBasals, jsBasals) {
  179. #expect(Decimal(swift.date) == js.date!)
  180. #expect(swift.duration!.isWithin(0.01, of: js.duration!))
  181. #expect(swift.rate == js.rate)
  182. let start = js.date!
  183. let end = js.date! + js.duration! * 60 * 1000
  184. let swiftTempBolus = swiftTreatments
  185. .filter({ Decimal($0.date) >= start && Decimal($0.date) < end && $0.insulin != nil && $0.insulin! < 0.1 })
  186. .map({ $0.insulin! }).reduce(0, +)
  187. let jsTempBolus = jsTreatments
  188. .filter({ $0.date! >= start && $0.date! < end && $0.insulin != nil && $0.insulin! < 0.1 }).map({ $0.insulin! })
  189. .reduce(0, +)
  190. if swiftTempBolus != jsTempBolus {
  191. print("temp bolus @ \(swift.timestamp) mismatch swift: \(swiftTempBolus) js: \(jsTempBolus)")
  192. }
  193. #expect(swiftTempBolus == jsTempBolus)
  194. }
  195. }
  196. @Test("Debug utility for checking one IOB error", .enabled(if: false)) func debugSignleIobError() async throws {
  197. let algorithmComparison = try await HttpFiles.downloadFile(at: "/files/dd31e618-5023-40ca-ab7e-0fdd2475fbd9.2.json")
  198. let iobInputs = algorithmComparison.iobInput!
  199. timeZoneForTests.setTimezone(identifier: algorithmComparison.timezone)
  200. try await checkFixedJsAgainstSwift(iobInputs: iobInputs)
  201. timeZoneForTests.resetTimezone()
  202. }
  203. @Test("Debug utility for checking iob-history", .enabled(if: false)) func debugIobHistory() async throws {
  204. let algorithmComparison = try await HttpFiles.downloadFile(at: "/files/dd31e618-5023-40ca-ab7e-0fdd2475fbd9.2.json")
  205. let iobInputs = algorithmComparison.iobInput!
  206. timeZoneForTests.setTimezone(identifier: algorithmComparison.timezone)
  207. let swiftIobHistory = try IobHistory.calcTempTreatments(
  208. history: iobInputs.history.map { $0.computedEvent() },
  209. profile: iobInputs.profile,
  210. clock: iobInputs.clock,
  211. autosens: iobInputs.autosens,
  212. zeroTempDuration: nil
  213. )
  214. let openAps = OpenAPSFixed()
  215. let jsIobHistoryRaw = try await openAps.iobHistory(
  216. pumphistory: iobInputs.history,
  217. profile: JSONBridge.to(iobInputs.profile),
  218. clock: iobInputs.clock,
  219. autosens: JSONBridge.to(iobInputs.autosens),
  220. zeroTempDuration: RawJSON.null
  221. )
  222. let jsIobHistory = try JSONDecoder().decode([IobHistoryResult].self, from: jsIobHistoryRaw.rawJSON.data(using: .utf8)!)
  223. let encoder = JSONCoding.encoder
  224. var output = try encoder.encode(swiftIobHistory)
  225. var sharedDir = FileManager.default.temporaryDirectory
  226. var outputURL = sharedDir.appendingPathComponent("swift_treatments.json")
  227. print("Writing to: \(outputURL.path)")
  228. try output.write(to: outputURL)
  229. output = try encoder.encode(jsIobHistory)
  230. sharedDir = FileManager.default.temporaryDirectory
  231. outputURL = sharedDir.appendingPathComponent("js_treatments.json")
  232. print("Writing to: \(outputURL.path)")
  233. try output.write(to: outputURL)
  234. output = try encoder.encode(iobInputs)
  235. sharedDir = FileManager.default.temporaryDirectory
  236. outputURL = sharedDir.appendingPathComponent("js_iob_input_error.json")
  237. print("Writing to: \(outputURL.path)")
  238. try output.write(to: outputURL)
  239. checkHistoryConsistency(swiftTreatments: swiftIobHistory, jsTreatments: jsIobHistory)
  240. checkRunningBasal(swiftTreatments: swiftIobHistory, jsTreatments: jsIobHistory)
  241. timeZoneForTests.resetTimezone()
  242. }
  243. }