GlucoseSimulatorSource.swift 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. /// Glucose source - Blood Glucose Simulator
  2. ///
  3. /// Source publish fake data about glucose's level, creates ascending and descending trends
  4. ///
  5. /// Enter point of Source is GlucoseSimulatorSource.fetch method. Method is called from FetchGlucoseManager module.
  6. /// Not more often than a specified period (default - 300 seconds), it returns a Combine-publisher that publishes data on glucose values (global type BloodGlucose). If there is no up-to-date data (or the publication period has not passed yet), then a publisher of type Empty is returned, otherwise it returns a publisher of type Just.
  7. ///
  8. /// Simulator composition
  9. /// ===================
  10. ///
  11. /// class GlucoseSimulatorSource - main class
  12. /// protocol BloodGlucoseGenerator
  13. /// - OscillatingGenerator: BloodGlucoseGenerator - Generates sinusoidal glucose values around a center point
  14. import Combine
  15. import Foundation
  16. import LoopKitUI
  17. // MARK: - Glucose simulator
  18. /// A class that simulates glucose values for testing purposes.
  19. /// This class implements the GlucoseSource protocol and provides simulated glucose readings
  20. /// using different generator strategies.
  21. final class GlucoseSimulatorSource: GlucoseSource {
  22. var cgmManager: CGMManagerUI?
  23. var glucoseManager: FetchGlucoseManager?
  24. private enum Config {
  25. /// Minimum time period between data publications (in seconds)
  26. static let workInterval: TimeInterval = 300
  27. /// Default number of blood glucose items to generate at first run
  28. /// 288 = 1 day * 24 hours * 60 minutes * 60 seconds / workInterval
  29. static let defaultBGItems = 288
  30. }
  31. /// The last glucose value that was generated
  32. @Persisted(key: "GlucoseSimulatorLastGlucose") private var lastGlucose = 100
  33. /// The date of the last fetch operation
  34. @Persisted(key: "GlucoseSimulatorLastFetchDate") private var lastFetchDate: Date! = nil
  35. /// Initializes the glucose simulator source
  36. /// Sets up the initial fetch date if not already set
  37. init() {
  38. if lastFetchDate == nil {
  39. var lastDate = Date()
  40. for _ in 1 ... Config.defaultBGItems {
  41. lastDate = lastDate.addingTimeInterval(-Config.workInterval)
  42. }
  43. lastFetchDate = lastDate
  44. }
  45. }
  46. /// The glucose generator used to create simulated values
  47. /// Uses OscillatingGenerator to create a sinusoidal pattern around 120 mg/dL
  48. private lazy var generator: BloodGlucoseGenerator = {
  49. OscillatingGenerator()
  50. }()
  51. /// Determines if new glucose values can be generated based on the time elapsed since the last fetch
  52. private var canGenerateNewValues: Bool {
  53. guard let lastDate = lastFetchDate else { return true }
  54. if Calendar.current.dateComponents([.second], from: lastDate, to: Date()).second! >= Int(Config.workInterval) {
  55. return true
  56. } else {
  57. return false
  58. }
  59. }
  60. /// Fetches new glucose values if enough time has passed since the last fetch
  61. /// - Parameter timer: Optional dispatch timer (not used in this implementation)
  62. /// - Returns: A publisher that emits an array of BloodGlucose objects
  63. func fetch(_: DispatchTimer?) -> AnyPublisher<[BloodGlucose], Never> {
  64. guard canGenerateNewValues else {
  65. return Just([]).eraseToAnyPublisher()
  66. }
  67. let glucoses = generator.getBloodGlucoses(
  68. startDate: lastFetchDate,
  69. finishDate: Date(),
  70. withInterval: Config.workInterval
  71. )
  72. if let lastItem = glucoses.last {
  73. lastGlucose = lastItem.glucose!
  74. lastFetchDate = Date()
  75. }
  76. return Just(glucoses).eraseToAnyPublisher()
  77. }
  78. /// Fetches new glucose values if needed
  79. /// - Returns: A publisher that emits an array of BloodGlucose objects
  80. func fetchIfNeeded() -> AnyPublisher<[BloodGlucose], Never> {
  81. fetch(nil)
  82. }
  83. }
  84. // MARK: - Glucose generator
  85. /// Protocol defining the interface for glucose generators
  86. /// Implementations of this protocol provide different strategies for generating glucose values
  87. protocol BloodGlucoseGenerator {
  88. /// Generates blood glucose values between the specified dates at the given interval
  89. /// - Parameters:
  90. /// - startDate: The start date for generating values
  91. /// - finishDate: The end date for generating values
  92. /// - interval: The time interval between generated values
  93. /// - Returns: An array of BloodGlucose objects
  94. func getBloodGlucoses(startDate: Date, finishDate: Date, withInterval: TimeInterval) -> [BloodGlucose]
  95. }
  96. /// A glucose generator that creates a sinusoidal pattern around a center value
  97. /// This generator simulates a realistic oscillating glucose pattern with configurable parameters
  98. class OscillatingGenerator: BloodGlucoseGenerator {
  99. /// Default values for simulator parameters
  100. enum Defaults {
  101. static let centerValue: Double = 120.0
  102. static let amplitude: Double = 45.0
  103. static let period: Double = 10800.0 // 3 hours in seconds
  104. static let noiseAmplitude: Double = 5.0
  105. static let produceStaleValues: Bool = false
  106. }
  107. /// UserDefaults keys for storing simulator parameters
  108. private enum UserDefaultsKeys {
  109. static let centerValue = "GlucoseSimulator_CenterValue"
  110. static let amplitude = "GlucoseSimulator_Amplitude"
  111. static let period = "GlucoseSimulator_Period"
  112. static let noiseAmplitude = "GlucoseSimulator_NoiseAmplitude"
  113. static let produceStaleValues = "GlucoseSimulator_ProduceStaleValues"
  114. }
  115. /// Amplitude of the oscillation (±45 mg/dL to create range from ~80 to ~170)
  116. private var amplitude: Double {
  117. get { UserDefaults.standard.double(forKey: UserDefaultsKeys.amplitude) != 0 ?
  118. UserDefaults.standard.double(forKey: UserDefaultsKeys.amplitude) :
  119. Defaults.amplitude }
  120. set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.amplitude) }
  121. }
  122. /// Period of the oscillation in seconds (3 hours = 10800 seconds)
  123. private var period: Double {
  124. get { UserDefaults.standard.double(forKey: UserDefaultsKeys.period) != 0 ?
  125. UserDefaults.standard.double(forKey: UserDefaultsKeys.period) :
  126. Defaults.period }
  127. set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.period) }
  128. }
  129. /// Center value of the oscillation (target glucose level)
  130. private var centerValue: Double {
  131. get { UserDefaults.standard.double(forKey: UserDefaultsKeys.centerValue) != 0 ?
  132. UserDefaults.standard.double(forKey: UserDefaultsKeys.centerValue) :
  133. Defaults.centerValue }
  134. set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.centerValue) }
  135. }
  136. /// Amplitude of random noise to add to the values (±5 mg/dL)
  137. private var noiseAmplitude: Double {
  138. get { UserDefaults.standard.double(forKey: UserDefaultsKeys.noiseAmplitude) != 0 ?
  139. UserDefaults.standard.double(forKey: UserDefaultsKeys.noiseAmplitude) :
  140. Defaults.noiseAmplitude }
  141. set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.noiseAmplitude) }
  142. }
  143. /// Whether to produce stale (unchanging) glucose values
  144. var produceStaleValues: Bool {
  145. get { UserDefaults.standard.bool(forKey: UserDefaultsKeys.produceStaleValues) }
  146. set { UserDefaults.standard.set(newValue, forKey: UserDefaultsKeys.produceStaleValues) }
  147. }
  148. /// Start date for the simulation
  149. private let startup = Date()
  150. /// Last generated glucose value for stale mode
  151. private var lastGeneratedGlucose: Int?
  152. /// Provides information string to describe the simulator as glucose source
  153. func sourceInfo() -> [String: Any]? {
  154. [GlucoseSourceKey.description.rawValue: "Glucose simulator"]
  155. }
  156. /// Reset all parameters to default values
  157. func resetToDefaults() {
  158. centerValue = Defaults.centerValue
  159. amplitude = Defaults.amplitude
  160. period = Defaults.period
  161. noiseAmplitude = Defaults.noiseAmplitude
  162. produceStaleValues = Defaults.produceStaleValues
  163. lastGeneratedGlucose = nil
  164. }
  165. /// Generates blood glucose values between the specified dates at the given interval
  166. /// - Parameters:
  167. /// - startDate: The start date for generating values
  168. /// - finishDate: The end date for generating values
  169. /// - interval: The time interval between generated values
  170. /// - Returns: An array of BloodGlucose objects with sinusoidal pattern
  171. func getBloodGlucoses(startDate: Date, finishDate: Date, withInterval interval: TimeInterval) -> [BloodGlucose] {
  172. var result = [BloodGlucose]()
  173. var currentDate = startDate
  174. while currentDate <= finishDate {
  175. let glucose: Int
  176. let direction: BloodGlucose.Direction
  177. if produceStaleValues, lastGeneratedGlucose != nil {
  178. // In stale mode, use the last generated glucose value
  179. glucose = lastGeneratedGlucose!
  180. direction = .flat
  181. } else {
  182. // Generate a new glucose value
  183. glucose = generate(date: currentDate)
  184. direction = calculateDirection(at: currentDate)
  185. lastGeneratedGlucose = glucose
  186. }
  187. // Create BloodGlucose with the correct constructor
  188. let bloodGlucose = BloodGlucose(
  189. id: UUID().uuidString,
  190. sgv: glucose,
  191. direction: direction,
  192. date: Decimal(Int(currentDate.timeIntervalSince1970) * 1000),
  193. dateString: currentDate,
  194. unfiltered: Decimal(glucose),
  195. filtered: nil,
  196. noise: nil,
  197. glucose: glucose,
  198. type: nil,
  199. activationDate: startup,
  200. sessionStartDate: startup,
  201. transmitterID: "SIMULATOR"
  202. )
  203. result.append(bloodGlucose)
  204. currentDate = currentDate.addingTimeInterval(interval)
  205. }
  206. return result
  207. }
  208. /// Generates a glucose value for the specified date using a sinusoidal function
  209. /// - Parameter date: The date for which to generate the glucose value
  210. /// - Returns: An integer representing the glucose value in mg/dL
  211. private func generate(date: Date) -> Int {
  212. // Time in seconds since 1970
  213. let timeSeconds = date.timeIntervalSince1970
  214. // Calculate sine value
  215. let sinValue = sin(2.0 * .pi * timeSeconds / period)
  216. // Random noise
  217. let noise = Double.random(in: -noiseAmplitude ... noiseAmplitude)
  218. // Calculate glucose value: center + amplitude * sine + noise
  219. let glucoseValue = centerValue + amplitude * sinValue + noise
  220. // Return as integer
  221. return Int(glucoseValue)
  222. }
  223. /// Calculates the direction (trend) of glucose change at the specified date
  224. /// - Parameter date: The date for which to calculate the direction
  225. /// - Returns: A BloodGlucose.Direction value indicating the trend
  226. private func calculateDirection(at date: Date) -> BloodGlucose.Direction {
  227. // Time in seconds since 1970
  228. let timeSeconds = date.timeIntervalSince1970
  229. // Calculate derivative of sine function (cosine)
  230. let cosValue = cos(2.0 * .pi * timeSeconds / period)
  231. // Slope of the curve at this point
  232. let slope = -amplitude * 2.0 * .pi / period * cosValue
  233. // Determine direction based on slope
  234. if abs(slope) < 0.2 {
  235. return .flat
  236. } else if slope > 0 {
  237. if slope > 1.0 {
  238. return .singleUp
  239. } else {
  240. return .fortyFiveUp
  241. }
  242. } else {
  243. if slope < -1.0 {
  244. return .singleDown
  245. } else {
  246. return .fortyFiveDown
  247. }
  248. }
  249. }
  250. }