GlucoseSnapshot.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. // LoopFollow
  2. // GlucoseSnapshot.swift
  3. import Foundation
  4. /// Canonical, source-agnostic glucose state used by
  5. /// Live Activity, future Watch complication, and CarPlay.
  6. ///
  7. struct GlucoseSnapshot: Codable, Equatable, Hashable {
  8. // MARK: - Units
  9. enum Unit: String, Codable, Hashable {
  10. case mgdl
  11. case mmol
  12. /// Human-readable display string for the unit (e.g. "mg/dL" or "mmol/L").
  13. var displayName: String {
  14. switch self {
  15. case .mgdl: return "mg/dL"
  16. case .mmol: return "mmol/L"
  17. }
  18. }
  19. }
  20. // MARK: - Core Glucose
  21. /// Glucose value in mg/dL (canonical internal unit).
  22. let glucose: Double
  23. /// Delta in mg/dL. May be 0.0 if unchanged.
  24. let delta: Double
  25. /// Trend direction (mapped from LoopFollow state).
  26. let trend: Trend
  27. /// Timestamp of reading.
  28. let updatedAt: Date
  29. // MARK: - Secondary Metrics
  30. /// Insulin On Board
  31. let iob: Double?
  32. /// Carbs On Board
  33. let cob: Double?
  34. /// Projected glucose in mg/dL (if available)
  35. let projected: Double?
  36. // MARK: - Extended InfoType Metrics
  37. /// Active override name (nil if no active override)
  38. let override: String?
  39. /// Recommended bolus in units (nil if not available)
  40. let recBolus: Double?
  41. /// CGM/uploader device battery % (nil if not available)
  42. let battery: Double?
  43. /// Pump battery % (nil if not available)
  44. let pumpBattery: Double?
  45. /// Formatted current basal rate string (empty if not available)
  46. let basalRate: String
  47. /// Pump reservoir in units (nil if >50U or unknown)
  48. let pumpReservoirU: Double?
  49. /// Autosensitivity ratio, e.g. 0.9 = 90% (nil if not available)
  50. let autosens: Double?
  51. /// Total daily dose in units (nil if not available)
  52. let tdd: Double?
  53. /// BG target low in mg/dL (nil if not available)
  54. let targetLowMgdl: Double?
  55. /// BG target high in mg/dL (nil if not available)
  56. let targetHighMgdl: Double?
  57. /// Insulin Sensitivity Factor in mg/dL per unit (nil if not available)
  58. let isfMgdlPerU: Double?
  59. /// Carb ratio in g per unit (nil if not available)
  60. let carbRatio: Double?
  61. /// Total carbs entered today in grams (nil if not available)
  62. let carbsToday: Double?
  63. /// Active profile name (nil if not available)
  64. let profileName: String?
  65. /// Sensor insert time as Unix epoch seconds UTC (0 = not set)
  66. let sageInsertTime: TimeInterval
  67. /// Cannula insert time as Unix epoch seconds UTC (0 = not set)
  68. let cageInsertTime: TimeInterval
  69. /// Insulin/pod insert time as Unix epoch seconds UTC (0 = not set)
  70. let iageInsertTime: TimeInterval
  71. /// Min predicted BG in mg/dL (nil if not available)
  72. let minBgMgdl: Double?
  73. /// Max predicted BG in mg/dL (nil if not available)
  74. let maxBgMgdl: Double?
  75. // MARK: - Unit Context
  76. /// User's preferred display unit. Values are always stored in mg/dL;
  77. /// this tells the display layer which unit to render.
  78. let unit: Unit
  79. // MARK: - Loop Status
  80. /// True when LoopFollow detects the loop has not reported in 15+ minutes (Nightscout only).
  81. let isNotLooping: Bool
  82. // MARK: - Renewal
  83. /// True when the Live Activity is within renewalWarning seconds of its renewal deadline.
  84. /// The extension renders a "Tap to update" overlay so the user knows renewal is imminent.
  85. let showRenewalOverlay: Bool
  86. // MARK: - Init
  87. init(
  88. glucose: Double,
  89. delta: Double,
  90. trend: Trend,
  91. updatedAt: Date,
  92. iob: Double?,
  93. cob: Double?,
  94. projected: Double?,
  95. override: String? = nil,
  96. recBolus: Double? = nil,
  97. battery: Double? = nil,
  98. pumpBattery: Double? = nil,
  99. basalRate: String = "",
  100. pumpReservoirU: Double? = nil,
  101. autosens: Double? = nil,
  102. tdd: Double? = nil,
  103. targetLowMgdl: Double? = nil,
  104. targetHighMgdl: Double? = nil,
  105. isfMgdlPerU: Double? = nil,
  106. carbRatio: Double? = nil,
  107. carbsToday: Double? = nil,
  108. profileName: String? = nil,
  109. sageInsertTime: TimeInterval = 0,
  110. cageInsertTime: TimeInterval = 0,
  111. iageInsertTime: TimeInterval = 0,
  112. minBgMgdl: Double? = nil,
  113. maxBgMgdl: Double? = nil,
  114. unit: Unit,
  115. isNotLooping: Bool,
  116. showRenewalOverlay: Bool = false,
  117. ) {
  118. self.glucose = glucose
  119. self.delta = delta
  120. self.trend = trend
  121. self.updatedAt = updatedAt
  122. self.iob = iob
  123. self.cob = cob
  124. self.projected = projected
  125. self.override = override
  126. self.recBolus = recBolus
  127. self.battery = battery
  128. self.pumpBattery = pumpBattery
  129. self.basalRate = basalRate
  130. self.pumpReservoirU = pumpReservoirU
  131. self.autosens = autosens
  132. self.tdd = tdd
  133. self.targetLowMgdl = targetLowMgdl
  134. self.targetHighMgdl = targetHighMgdl
  135. self.isfMgdlPerU = isfMgdlPerU
  136. self.carbRatio = carbRatio
  137. self.carbsToday = carbsToday
  138. self.profileName = profileName
  139. self.sageInsertTime = sageInsertTime
  140. self.cageInsertTime = cageInsertTime
  141. self.iageInsertTime = iageInsertTime
  142. self.minBgMgdl = minBgMgdl
  143. self.maxBgMgdl = maxBgMgdl
  144. self.unit = unit
  145. self.isNotLooping = isNotLooping
  146. self.showRenewalOverlay = showRenewalOverlay
  147. }
  148. // MARK: - Derived Convenience
  149. /// Age of reading in seconds.
  150. var age: TimeInterval {
  151. Date().timeIntervalSince(updatedAt)
  152. }
  153. /// Returns a copy of this snapshot with `showRenewalOverlay` set to the given value.
  154. /// All other fields are preserved exactly. Use this instead of manually copying
  155. /// every field when only the overlay flag needs to change.
  156. func withRenewalOverlay(_ value: Bool) -> GlucoseSnapshot {
  157. GlucoseSnapshot(
  158. glucose: glucose,
  159. delta: delta,
  160. trend: trend,
  161. updatedAt: updatedAt,
  162. iob: iob,
  163. cob: cob,
  164. projected: projected,
  165. override: override,
  166. recBolus: recBolus,
  167. battery: battery,
  168. pumpBattery: pumpBattery,
  169. basalRate: basalRate,
  170. pumpReservoirU: pumpReservoirU,
  171. autosens: autosens,
  172. tdd: tdd,
  173. targetLowMgdl: targetLowMgdl,
  174. targetHighMgdl: targetHighMgdl,
  175. isfMgdlPerU: isfMgdlPerU,
  176. carbRatio: carbRatio,
  177. carbsToday: carbsToday,
  178. profileName: profileName,
  179. sageInsertTime: sageInsertTime,
  180. cageInsertTime: cageInsertTime,
  181. iageInsertTime: iageInsertTime,
  182. minBgMgdl: minBgMgdl,
  183. maxBgMgdl: maxBgMgdl,
  184. unit: unit,
  185. isNotLooping: isNotLooping,
  186. showRenewalOverlay: value,
  187. )
  188. }
  189. // MARK: - Codable
  190. func encode(to encoder: Encoder) throws {
  191. var container = encoder.container(keyedBy: CodingKeys.self)
  192. try container.encode(glucose, forKey: .glucose)
  193. try container.encode(delta, forKey: .delta)
  194. try container.encode(trend, forKey: .trend)
  195. try container.encode(updatedAt.timeIntervalSince1970, forKey: .updatedAt)
  196. try container.encodeIfPresent(iob, forKey: .iob)
  197. try container.encodeIfPresent(cob, forKey: .cob)
  198. try container.encodeIfPresent(projected, forKey: .projected)
  199. try container.encodeIfPresent(override, forKey: .override)
  200. try container.encodeIfPresent(recBolus, forKey: .recBolus)
  201. try container.encodeIfPresent(battery, forKey: .battery)
  202. try container.encodeIfPresent(pumpBattery, forKey: .pumpBattery)
  203. try container.encode(basalRate, forKey: .basalRate)
  204. try container.encodeIfPresent(pumpReservoirU, forKey: .pumpReservoirU)
  205. try container.encodeIfPresent(autosens, forKey: .autosens)
  206. try container.encodeIfPresent(tdd, forKey: .tdd)
  207. try container.encodeIfPresent(targetLowMgdl, forKey: .targetLowMgdl)
  208. try container.encodeIfPresent(targetHighMgdl, forKey: .targetHighMgdl)
  209. try container.encodeIfPresent(isfMgdlPerU, forKey: .isfMgdlPerU)
  210. try container.encodeIfPresent(carbRatio, forKey: .carbRatio)
  211. try container.encodeIfPresent(carbsToday, forKey: .carbsToday)
  212. try container.encodeIfPresent(profileName, forKey: .profileName)
  213. try container.encode(sageInsertTime, forKey: .sageInsertTime)
  214. try container.encode(cageInsertTime, forKey: .cageInsertTime)
  215. try container.encode(iageInsertTime, forKey: .iageInsertTime)
  216. try container.encodeIfPresent(minBgMgdl, forKey: .minBgMgdl)
  217. try container.encodeIfPresent(maxBgMgdl, forKey: .maxBgMgdl)
  218. try container.encode(unit, forKey: .unit)
  219. try container.encode(isNotLooping, forKey: .isNotLooping)
  220. try container.encode(showRenewalOverlay, forKey: .showRenewalOverlay)
  221. }
  222. init(from decoder: Decoder) throws {
  223. let container = try decoder.container(keyedBy: CodingKeys.self)
  224. glucose = try container.decode(Double.self, forKey: .glucose)
  225. delta = try container.decode(Double.self, forKey: .delta)
  226. trend = try container.decode(Trend.self, forKey: .trend)
  227. updatedAt = try Date(timeIntervalSince1970: container.decode(Double.self, forKey: .updatedAt))
  228. iob = try container.decodeIfPresent(Double.self, forKey: .iob)
  229. cob = try container.decodeIfPresent(Double.self, forKey: .cob)
  230. projected = try container.decodeIfPresent(Double.self, forKey: .projected)
  231. override = try container.decodeIfPresent(String.self, forKey: .override)
  232. recBolus = try container.decodeIfPresent(Double.self, forKey: .recBolus)
  233. battery = try container.decodeIfPresent(Double.self, forKey: .battery)
  234. pumpBattery = try container.decodeIfPresent(Double.self, forKey: .pumpBattery)
  235. basalRate = try container.decodeIfPresent(String.self, forKey: .basalRate) ?? ""
  236. pumpReservoirU = try container.decodeIfPresent(Double.self, forKey: .pumpReservoirU)
  237. autosens = try container.decodeIfPresent(Double.self, forKey: .autosens)
  238. tdd = try container.decodeIfPresent(Double.self, forKey: .tdd)
  239. targetLowMgdl = try container.decodeIfPresent(Double.self, forKey: .targetLowMgdl)
  240. targetHighMgdl = try container.decodeIfPresent(Double.self, forKey: .targetHighMgdl)
  241. isfMgdlPerU = try container.decodeIfPresent(Double.self, forKey: .isfMgdlPerU)
  242. carbRatio = try container.decodeIfPresent(Double.self, forKey: .carbRatio)
  243. carbsToday = try container.decodeIfPresent(Double.self, forKey: .carbsToday)
  244. profileName = try container.decodeIfPresent(String.self, forKey: .profileName)
  245. sageInsertTime = try container.decodeIfPresent(Double.self, forKey: .sageInsertTime) ?? 0
  246. cageInsertTime = try container.decodeIfPresent(Double.self, forKey: .cageInsertTime) ?? 0
  247. iageInsertTime = try container.decodeIfPresent(Double.self, forKey: .iageInsertTime) ?? 0
  248. minBgMgdl = try container.decodeIfPresent(Double.self, forKey: .minBgMgdl)
  249. maxBgMgdl = try container.decodeIfPresent(Double.self, forKey: .maxBgMgdl)
  250. unit = try container.decode(Unit.self, forKey: .unit)
  251. isNotLooping = try container.decodeIfPresent(Bool.self, forKey: .isNotLooping) ?? false
  252. showRenewalOverlay = try container.decodeIfPresent(Bool.self, forKey: .showRenewalOverlay) ?? false
  253. }
  254. private enum CodingKeys: String, CodingKey {
  255. case glucose, delta, trend, updatedAt
  256. case iob, cob, projected
  257. case override, recBolus, battery, pumpBattery, basalRate, pumpReservoirU
  258. case autosens, tdd, targetLowMgdl, targetHighMgdl, isfMgdlPerU, carbRatio, carbsToday
  259. case profileName, sageInsertTime, cageInsertTime, iageInsertTime, minBgMgdl, maxBgMgdl
  260. case unit, isNotLooping, showRenewalOverlay
  261. }
  262. }
  263. // MARK: - Trend
  264. extension GlucoseSnapshot {
  265. enum Trend: String, Codable, Hashable {
  266. case up
  267. case upSlight
  268. case upFast
  269. case flat
  270. case down
  271. case downSlight
  272. case downFast
  273. case unknown
  274. init(from decoder: Decoder) throws {
  275. let container = try decoder.singleValueContainer()
  276. let raw = try container.decode(String.self)
  277. self = Trend(rawValue: raw) ?? .unknown
  278. }
  279. }
  280. }