QuickPickBolusesManager.swift 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
  1. // LoopFollow
  2. // QuickPickBolusesManager.swift
  3. import Foundation
  4. struct QuickPickBolus: Identifiable, Equatable {
  5. let id = UUID()
  6. let units: Double
  7. static func == (lhs: QuickPickBolus, rhs: QuickPickBolus) -> Bool {
  8. lhs.id == rhs.id
  9. }
  10. }
  11. final class QuickPickBolusesManager: ObservableObject {
  12. static let shared = QuickPickBolusesManager()
  13. @Published private(set) var quickPickBoluses: [QuickPickBolus] = []
  14. private static let maxEntries = 500
  15. private static let maxAgeDays = 90.0
  16. private static let sigma: Double = 60.0
  17. private static let halfLife: Double = 10.0
  18. private static let minScore: Double = 0.1
  19. private static let maxResults = 5
  20. private init() {}
  21. // MARK: - Public API
  22. func recordBolus(units: Double, at date: Date = Date()) {
  23. let entry = RemoteBolusHistoryEntry(units: units, date: date)
  24. var history = Storage.shared.remoteBolusHistory.value
  25. history.append(entry)
  26. history = Self.pruned(history, now: date)
  27. Storage.shared.remoteBolusHistory.value = history
  28. }
  29. func refresh(now: Date = Date(), stepIncrement: Double, maxBolus: Double) {
  30. let history = Storage.shared.remoteBolusHistory.value
  31. quickPickBoluses = Self.computeQuickPickBoluses(
  32. from: history,
  33. now: now,
  34. stepIncrement: stepIncrement,
  35. maxBolus: maxBolus
  36. )
  37. }
  38. // MARK: - Scoring (static for testability)
  39. static func computeQuickPickBoluses(
  40. from history: [RemoteBolusHistoryEntry],
  41. now: Date,
  42. stepIncrement: Double,
  43. maxBolus: Double
  44. ) -> [QuickPickBolus] {
  45. guard stepIncrement > 0 else { return [] }
  46. let nowMinute = {
  47. let cal = Calendar.current
  48. return cal.component(.hour, from: now) * 60 + cal.component(.minute, from: now)
  49. }()
  50. let nowDOW = Calendar.current.component(.weekday, from: now)
  51. var groups: [Double: Double] = [:]
  52. for entry in history {
  53. let rounded = (entry.units / stepIncrement).rounded(.down) * stepIncrement
  54. let amount = roundToFraction(rounded, stepIncrement: stepIncrement)
  55. guard amount > 0, amount <= maxBolus else { continue }
  56. let t = timeOfDayScore(entryMinute: entry.minuteOfDay, nowMinute: nowMinute)
  57. let d = dayOfWeekScore(entryDOW: entry.dayOfWeek, nowDOW: nowDOW)
  58. let daysAgo = now.timeIntervalSince(entry.date) / 86400.0
  59. let r = recencyScore(daysAgo: daysAgo)
  60. groups[amount, default: 0] += t * d * r
  61. }
  62. return groups
  63. .filter { $0.value >= minScore }
  64. .sorted { $0.value > $1.value }
  65. .prefix(maxResults)
  66. .map { QuickPickBolus(units: $0.key) }
  67. }
  68. static func timeOfDayScore(entryMinute: Int, nowMinute: Int) -> Double {
  69. let diff = abs(entryMinute - nowMinute)
  70. let circularDiff = Double(min(diff, 1440 - diff))
  71. return exp(-(circularDiff * circularDiff) / (2 * sigma * sigma))
  72. }
  73. static func dayOfWeekScore(entryDOW: Int, nowDOW: Int) -> Double {
  74. if entryDOW == nowDOW { return 1.0 }
  75. let nowWeekend = nowDOW == 1 || nowDOW == 7
  76. let entryWeekend = entryDOW == 1 || entryDOW == 7
  77. if nowWeekend == entryWeekend { return 0.7 }
  78. return 0.15
  79. }
  80. static func recencyScore(daysAgo: Double) -> Double {
  81. pow(0.5, daysAgo / halfLife)
  82. }
  83. // MARK: - Helpers
  84. private static func pruned(_ history: [RemoteBolusHistoryEntry], now: Date) -> [RemoteBolusHistoryEntry] {
  85. let cutoff = now.addingTimeInterval(-maxAgeDays * 86400)
  86. var filtered = history.filter { $0.date > cutoff }
  87. if filtered.count > maxEntries {
  88. filtered.sort { $0.date > $1.date }
  89. filtered = Array(filtered.prefix(maxEntries))
  90. }
  91. return filtered
  92. }
  93. private static func roundToFraction(_ value: Double, stepIncrement: Double) -> Double {
  94. let digits = fractionDigits(for: stepIncrement)
  95. let p = pow(10.0, Double(digits))
  96. return (value * p).rounded() / p
  97. }
  98. private static func fractionDigits(for step: Double) -> Int {
  99. if step >= 1 { return 0 }
  100. var v = step
  101. var digits = 0
  102. while digits < 6, abs(v.rounded() - v) > 1e-10 {
  103. v *= 10
  104. digits += 1
  105. }
  106. return min(max(digits, 0), 5)
  107. }
  108. }