AlarmTask.swift 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. // LoopFollow
  2. // AlarmTask.swift
  3. import Foundation
  4. extension MainViewController {
  5. func scheduleAlarmTask(initialDelay: TimeInterval = 30) {
  6. let firstRun = Date().addingTimeInterval(initialDelay)
  7. TaskScheduler.shared.scheduleTask(id: .alarmCheck, nextRun: firstRun) { [weak self] in
  8. guard let self = self else { return }
  9. self.alarmTaskAction()
  10. }
  11. }
  12. func alarmTaskAction() {
  13. DispatchQueue.main.async {
  14. let now = Date().timeIntervalSince1970
  15. let latestOverrideStart = self.overrideGraphData.last { $0.date <= now }?.date
  16. let latestOverrideEnd = self.overrideGraphData.last { $0.endDate <= now }?.endDate
  17. let latestTempTargetStart = self.tempTargetGraphData.last { $0.date <= now }?.date
  18. let latestTempTargetEnd = self.tempTargetGraphData.last { $0.endDate <= now }?.endDate
  19. let recBolus = Observable.shared.deviceRecBolus.value
  20. let COB = self.latestCOB?.value
  21. let sensorInsertedAt = Storage.shared.sageInsertTime.value
  22. let pumpInsertTime = Storage.shared.cageInsertTime.value
  23. let latestPumpVol = self.latestPumpVolume
  24. let bolusEntries = self.bolusData.map { BolusEntry(units: $0.value, date: Date(timeIntervalSince1970: $0.date)) }
  25. let latestBattery = Observable.shared.deviceBatteryLevel.value
  26. let latestPumpBattery = Observable.shared.pumpBatteryLevel.value
  27. let recentCarbs: [CarbSample] = self.carbData.map { CarbSample(grams: $0.value, date: Date(timeIntervalSince1970: $0.date)) }
  28. let alarmData = AlarmData(
  29. bgReadings: self.bgData
  30. .suffix(24)
  31. .map { GlucoseValue(sgv: $0.sgv, date: Date(timeIntervalSince1970: $0.date)) }, /// These are oldest .. newest
  32. predictionData: self.alarmPredictionData(), /// These are oldest .. newest
  33. expireDate: Storage.shared.expirationDate.value,
  34. lastLoopTime: Observable.shared.alertLastLoopTime.value,
  35. latestOverrideStart: latestOverrideStart,
  36. latestOverrideEnd: latestOverrideEnd,
  37. latestTempTargetStart: latestTempTargetStart,
  38. latestTempTargetEnd: latestTempTargetEnd,
  39. recBolus: recBolus,
  40. COB: COB,
  41. sageInsertTime: sensorInsertedAt,
  42. pumpInsertTime: pumpInsertTime,
  43. latestPumpVolume: latestPumpVol,
  44. IOB: self.latestIOB?.value,
  45. recentBoluses: bolusEntries,
  46. latestBattery: latestBattery,
  47. latestBatteryIsCharging: Observable.shared.deviceBatteryIsCharging.value,
  48. latestPumpBattery: latestPumpBattery,
  49. batteryHistory: self.deviceBatteryData,
  50. recentCarbs: recentCarbs,
  51. dbSizePercentage: Observable.shared.dbSizePercentage.value
  52. )
  53. let finalAlarmData: AlarmData
  54. if Observable.shared.debug.value {
  55. self.saveLatestAlarmDataToFile(alarmData)
  56. finalAlarmData = self.loadTestAlarmData() ?? alarmData
  57. } else {
  58. finalAlarmData = alarmData
  59. }
  60. LogManager.shared.log(category: .alarm, message: "Checking alarms based on \(finalAlarmData)", isDebug: true)
  61. LogManager.shared.log(category: .alarm, message: "Alarms \(Storage.shared.alarms.value)", isDebug: true)
  62. AlarmManager.shared.checkAlarms(data: finalAlarmData)
  63. TaskScheduler.shared.rescheduleTask(id: .alarmCheck, to: Date().addingTimeInterval(60))
  64. }
  65. }
  66. /// Builds the forward glucose series the low alarm looks ahead in,
  67. /// oldest .. newest at 5-minute spacing.
  68. ///
  69. /// Loop reports a single forecast, already stored in `predictionData`. Trio
  70. /// reports four forecasts, collapsed into the lowest value per point in time
  71. /// by `lowestForecast(forecasts:start:cap:)`.
  72. func alarmPredictionData() -> [GlucoseValue] {
  73. if Storage.shared.device.value == "Loop" {
  74. return predictionData
  75. .prefix(MainViewController.alarmForecastPointCap)
  76. .map { GlucoseValue(sgv: $0.sgv, date: Date(timeIntervalSince1970: $0.date)) }
  77. }
  78. guard let predBGs = openAPSPredBGs else { return [] }
  79. let forecasts = ["ZT", "IOB", "COB", "UAM"].compactMap { predBGs[$0] }
  80. return MainViewController.lowestForecast(
  81. forecasts: forecasts,
  82. start: openAPSPredUpdatedTime ?? Date().timeIntervalSince1970
  83. )
  84. }
  85. /// Maximum number of forward points (5-minute spacing) the low alarm looks at:
  86. /// 12 points = 60 minutes, matching the predictive look-ahead's upper bound.
  87. static let alarmForecastPointCap = 12
  88. /// Collapses several forecasts into a single series by taking the **lowest**
  89. /// value at each point in time, oldest .. newest at 5-minute spacing.
  90. ///
  91. /// Trio/OpenAPS reports four forecasts (ZT, IOB, COB, UAM) rather than the
  92. /// single one Loop provides, so this lets the predictive-low alarm fire if
  93. /// *any* forecast dips to or below the threshold. Empty forecasts are ignored,
  94. /// and each point uses whichever forecasts still extend that far.
  95. static func lowestForecast(
  96. forecasts: [[Double]],
  97. start: TimeInterval,
  98. cap: Int = alarmForecastPointCap
  99. ) -> [GlucoseValue] {
  100. let nonEmpty = forecasts.filter { !$0.isEmpty }
  101. guard !nonEmpty.isEmpty else { return [] }
  102. let count = min(nonEmpty.map { $0.count }.max() ?? 0, cap)
  103. return (0 ..< count).compactMap { i in
  104. let valuesAtIndex = nonEmpty.compactMap { i < $0.count ? $0[i] : nil }
  105. guard let lowest = valuesAtIndex.min() else { return nil }
  106. return GlucoseValue(
  107. sgv: Int(lowest.rounded()),
  108. date: Date(timeIntervalSince1970: start + Double(i) * 300)
  109. )
  110. }
  111. }
  112. func saveLatestAlarmDataToFile(_ alarmData: AlarmData) {
  113. let encoder = JSONEncoder()
  114. encoder.dateEncodingStrategy = .iso8601
  115. do {
  116. let data = try encoder.encode(alarmData)
  117. let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("latestAlarmData.json")
  118. try data.write(to: url)
  119. } catch {
  120. LogManager.shared.log(category: .alarm, message: "Failed to save latest AlarmData: \(error)", isDebug: true)
  121. }
  122. }
  123. func loadTestAlarmData() -> AlarmData? {
  124. let fileManager = FileManager.default
  125. let url = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!.appendingPathComponent("testAlarmData.json")
  126. if fileManager.fileExists(atPath: url.path) {
  127. do {
  128. let data = try Data(contentsOf: url)
  129. let decoder = JSONDecoder()
  130. decoder.dateDecodingStrategy = .iso8601
  131. let alarmData = try decoder.decode(AlarmData.self, from: data)
  132. LogManager.shared.log(category: .alarm, message: "Loaded test AlarmData from \(url.path)", isDebug: true)
  133. return alarmData
  134. } catch {
  135. LogManager.shared.log(category: .alarm, message: "Failed to load test AlarmData: \(error)", isDebug: true)
  136. }
  137. }
  138. return nil
  139. }
  140. }