TreatmentsStateModel.swift 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909
  1. import Combine
  2. import CoreData
  3. import Foundation
  4. import LoopKit
  5. import Observation
  6. import SwiftUI
  7. import Swinject
  8. extension Treatments {
  9. @Observable final class StateModel: BaseStateModel<Provider> {
  10. @ObservationIgnored @Injected() var unlockmanager: UnlockManager!
  11. @ObservationIgnored @Injected() var apsManager: APSManager!
  12. @ObservationIgnored @Injected() var broadcaster: Broadcaster!
  13. @ObservationIgnored @Injected() var pumpHistoryStorage: PumpHistoryStorage!
  14. @ObservationIgnored @Injected() var settings: SettingsManager!
  15. @ObservationIgnored @Injected() var nsManager: NightscoutManager!
  16. @ObservationIgnored @Injected() var carbsStorage: CarbsStorage!
  17. @ObservationIgnored @Injected() var glucoseStorage: GlucoseStorage!
  18. @ObservationIgnored @Injected() var determinationStorage: DeterminationStorage!
  19. @ObservationIgnored @Injected() var bolusCalculationManager: BolusCalculationManager!
  20. var lowGlucose: Decimal = 70
  21. var highGlucose: Decimal = 180
  22. var glucoseColorScheme: GlucoseColorScheme = .staticColor
  23. var predictions: Predictions?
  24. var amount: Decimal = 0
  25. var insulinRecommended: Decimal = 0
  26. var insulinRequired: Decimal = 0
  27. var units: GlucoseUnits = .mgdL
  28. var threshold: Decimal = 0
  29. var maxBolus: Decimal = 0
  30. var maxExternal: Decimal { maxBolus * 3 }
  31. var maxIOB: Decimal = 0
  32. var maxCOB: Decimal = 0
  33. var errorString: Decimal = 0
  34. var evBG: Decimal = 0
  35. var insulin: Decimal = 0
  36. var isf: Decimal = 0
  37. var error: Bool = false
  38. var minGuardBG: Decimal = 0
  39. var minDelta: Decimal = 0
  40. var expectedDelta: Decimal = 0
  41. var minPredBG: Decimal = 0
  42. var lastLoopDate: Date?
  43. var isAwaitingDeterminationResult: Bool = false
  44. var carbRatio: Decimal = 0
  45. var addButtonPressed: Bool = false
  46. var target: Decimal = 0
  47. var cob: Int16 = 0
  48. var iob: Decimal = 0
  49. var currentBG: Decimal = 0
  50. var fifteenMinInsulin: Decimal = 0
  51. var deltaBG: Decimal = 0
  52. var targetDifferenceInsulin: Decimal = 0
  53. var targetDifference: Decimal = 0
  54. var wholeCob: Decimal = 0
  55. var wholeCobInsulin: Decimal = 0
  56. var iobInsulinReduction: Decimal = 0
  57. var wholeCalc: Decimal = 0
  58. var factoredInsulin: Decimal = 0
  59. var insulinCalculated: Decimal = 0
  60. var fraction: Decimal = 0
  61. var basal: Decimal = 0
  62. var fattyMeals: Bool = false
  63. var fattyMealFactor: Decimal = 0
  64. var useFattyMealCorrectionFactor: Bool = false
  65. var displayPresets: Bool = true
  66. var confirmBolus: Bool = false
  67. var currentBasal: Decimal = 0
  68. var currentCarbRatio: Decimal = 0
  69. var currentBGTarget: Decimal = 0
  70. var currentISF: Decimal = 0
  71. var sweetMeals: Bool = false
  72. var sweetMealFactor: Decimal = 0
  73. var useSuperBolus: Bool = false
  74. var superBolusInsulin: Decimal = 0
  75. var meal: [CarbsEntry]?
  76. var carbs: Decimal = 0
  77. var fat: Decimal = 0
  78. var protein: Decimal = 0
  79. var note: String = ""
  80. var date = Date()
  81. var carbsRequired: Decimal?
  82. var useFPUconversion: Bool = false
  83. var dish: String = ""
  84. var selection: MealPresetStored?
  85. var summation: [String] = []
  86. var maxCarbs: Decimal = 0
  87. var maxFat: Decimal = 0
  88. var maxProtein: Decimal = 0
  89. var id_: String = ""
  90. var summary: String = ""
  91. var externalInsulin: Bool = false
  92. var showInfo: Bool = false
  93. var glucoseFromPersistence: [GlucoseStored] = []
  94. var determination: [OrefDetermination] = []
  95. var preprocessedData: [(id: UUID, forecast: Forecast, forecastValue: ForecastValue)] = []
  96. var predictionsForChart: Predictions?
  97. var simulatedDetermination: Determination?
  98. @MainActor var determinationObjectIDs: [NSManagedObjectID] = []
  99. var minForecast: [Int] = []
  100. var maxForecast: [Int] = []
  101. @MainActor var minCount: Int = 12 // count of Forecasts drawn in 5 min distances, i.e. 12 means a min of 1 hour
  102. var forecastDisplayType: ForecastDisplayType = .cone
  103. var isSmoothingEnabled: Bool = false
  104. var stops: [Gradient.Stop] = []
  105. let now = Date.now
  106. let viewContext = CoreDataStack.shared.persistentContainer.viewContext
  107. let glucoseFetchContext = CoreDataStack.shared.newTaskContext()
  108. let determinationFetchContext = CoreDataStack.shared.newTaskContext()
  109. var isActive: Bool = false
  110. var showDeterminationFailureAlert = false
  111. var determinationFailureMessage = ""
  112. // Queue for handling Core Data change notifications
  113. private let queue = DispatchQueue(label: "TreatmentsStateModel.queue", qos: .userInitiated)
  114. private var coreDataPublisher: AnyPublisher<Set<NSManagedObjectID>, Never>?
  115. private var subscriptions = Set<AnyCancellable>()
  116. typealias PumpEvent = PumpEventStored.EventType
  117. var isBolusInProgress: Bool = false
  118. private var bolusProgressCancellable: AnyCancellable?
  119. func unsubscribe() {
  120. subscriptions.forEach { $0.cancel() }
  121. subscriptions.removeAll()
  122. }
  123. override func subscribe() {
  124. guard isActive else {
  125. return
  126. }
  127. debug(.bolusState, "subscribe fired")
  128. coreDataPublisher =
  129. changedObjectsOnManagedObjectContextDidSavePublisher()
  130. .receive(on: queue)
  131. .share()
  132. .eraseToAnyPublisher()
  133. registerHandlers()
  134. registerSubscribers()
  135. setupBolusStateConcurrently()
  136. subscribeToBolusProgress()
  137. }
  138. deinit {
  139. debug(.bolusState, "StateModel deinit called")
  140. }
  141. private var hasCleanedUp = false
  142. func cleanupTreatmentState() {
  143. guard !hasCleanedUp else { return }
  144. hasCleanedUp = true
  145. unsubscribe()
  146. bolusProgressCancellable?.cancel()
  147. broadcaster?.unregister(DeterminationObserver.self, observer: self)
  148. broadcaster?.unregister(BolusFailureObserver.self, observer: self)
  149. debug(.bolusState, "StateModel cleanup() finished")
  150. }
  151. private func setupBolusStateConcurrently() {
  152. debug(.bolusState, "Setting up bolus state concurrently...")
  153. Task {
  154. do {
  155. try await withThrowingTaskGroup(of: Void.self) { group in
  156. group.addTask {
  157. self.setupGlucoseArray()
  158. }
  159. group.addTask {
  160. self.setupDeterminationsAndForecasts()
  161. }
  162. group.addTask {
  163. await self.setupSettings()
  164. }
  165. group.addTask {
  166. self.registerObservers()
  167. }
  168. // Wait for all tasks to complete
  169. try await group.waitForAll()
  170. }
  171. } catch let error as NSError {
  172. debug(.default, "Failed to setup bolus state concurrently: \(error)")
  173. }
  174. }
  175. }
  176. /// Observes changes to the `bolusProgress` published by the `apsManager` to update the `isBolusInProgress` property in real time.
  177. ///
  178. /// - Important:
  179. /// - `apsManager.bolusProgress` is a `CurrentValueSubject<Decimal?, Never>`.
  180. /// - When a bolus starts, this subject emits `0` (or a fraction like `0.1, 0.5, etc.`).
  181. /// - When the bolus finishes, the subject is typically set to `nil`.
  182. /// - This treats ANY non-nil value as "bolus in progress."
  183. ///
  184. private func subscribeToBolusProgress() {
  185. bolusProgressCancellable = apsManager.bolusProgress
  186. .receive(on: DispatchQueue.main)
  187. .sink { [weak self] progressValue in
  188. guard let self = self else { return }
  189. // If progressValue is non-nil, a bolus is in progress.
  190. self.isBolusInProgress = (progressValue != nil)
  191. }
  192. }
  193. // MARK: - Basal
  194. private enum SettingType {
  195. case basal
  196. case carbRatio
  197. case bgTarget
  198. case isf
  199. }
  200. func getAllSettingsValues() async {
  201. await withTaskGroup(of: Void.self) { group in
  202. group.addTask {
  203. await self.getCurrentSettingValue(for: .basal)
  204. }
  205. group.addTask {
  206. await self.getCurrentSettingValue(for: .carbRatio)
  207. }
  208. group.addTask {
  209. await self.getCurrentSettingValue(for: .bgTarget)
  210. }
  211. group.addTask {
  212. await self.getCurrentSettingValue(for: .isf)
  213. }
  214. group.addTask {
  215. let getMaxBolus = await self.provider.getPumpSettings().maxBolus
  216. await MainActor.run {
  217. self.maxBolus = getMaxBolus
  218. }
  219. }
  220. group.addTask {
  221. let getPreferences = await self.provider.getPreferences()
  222. await MainActor.run {
  223. self.maxIOB = getPreferences.maxIOB
  224. self.maxCOB = getPreferences.maxCOB
  225. }
  226. }
  227. }
  228. }
  229. private func setupDeterminationsAndForecasts() {
  230. Task {
  231. async let getAllSettingsDefaults: () = getAllSettingsValues()
  232. async let setupDeterminations: () = setupDeterminationsArray()
  233. await getAllSettingsDefaults
  234. await setupDeterminations
  235. // Determination has updated, so we can use this to draw the initial Forecast Chart
  236. let forecastData = await mapForecastsForChart()
  237. await updateForecasts(with: forecastData)
  238. }
  239. }
  240. private func registerObservers() {
  241. broadcaster.register(DeterminationObserver.self, observer: self)
  242. broadcaster.register(BolusFailureObserver.self, observer: self)
  243. }
  244. @MainActor private func setupSettings() async {
  245. units = settingsManager.settings.units
  246. fraction = settings.settings.overrideFactor
  247. fattyMeals = settings.settings.fattyMeals
  248. fattyMealFactor = settings.settings.fattyMealFactor
  249. sweetMeals = settings.settings.sweetMeals
  250. sweetMealFactor = settings.settings.sweetMealFactor
  251. displayPresets = settings.settings.displayPresets
  252. confirmBolus = settings.settings.confirmBolus
  253. forecastDisplayType = settings.settings.forecastDisplayType
  254. lowGlucose = settingsManager.settings.low
  255. highGlucose = settingsManager.settings.high
  256. maxCarbs = settings.settings.maxCarbs
  257. maxFat = settings.settings.maxFat
  258. maxProtein = settings.settings.maxProtein
  259. useFPUconversion = settingsManager.settings.useFPUconversion
  260. isSmoothingEnabled = settingsManager.settings.smoothGlucose
  261. glucoseColorScheme = settingsManager.settings.glucoseColorScheme
  262. }
  263. private func getCurrentSettingValue(for type: SettingType) async {
  264. let now = Date()
  265. let calendar = Calendar.current
  266. let entries: [(start: String, value: Decimal)]
  267. switch type {
  268. case .basal:
  269. let basalEntries = await provider.getBasalProfile()
  270. entries = basalEntries.map { ($0.start, $0.rate) }
  271. case .carbRatio:
  272. let carbRatios = await provider.getCarbRatios()
  273. entries = carbRatios.schedule.map { ($0.start, $0.ratio) }
  274. case .bgTarget:
  275. let bgTargets = await provider.getBGTargets()
  276. entries = bgTargets.targets.map { ($0.start, $0.low) }
  277. case .isf:
  278. let isfValues = await provider.getISFValues()
  279. entries = isfValues.sensitivities.map { ($0.start, $0.sensitivity) }
  280. }
  281. for (index, entry) in entries.enumerated() {
  282. guard let entryTime = TherapySettingsUtil.parseTime(entry.start) else {
  283. debug(.default, "Invalid entry start time: \(entry.start)")
  284. continue
  285. }
  286. let entryComponents = calendar.dateComponents([.hour, .minute, .second], from: entryTime)
  287. let entryStartTime = calendar.date(
  288. bySettingHour: entryComponents.hour!,
  289. minute: entryComponents.minute!,
  290. second: entryComponents.second ?? 0, // Set seconds to 0 if not provided
  291. of: now
  292. )!
  293. let entryEndTime: Date
  294. if index < entries.count - 1 {
  295. if let nextEntryTime = TherapySettingsUtil.parseTime(entries[index + 1].start) {
  296. let nextEntryComponents = calendar.dateComponents([.hour, .minute, .second], from: nextEntryTime)
  297. entryEndTime = calendar.date(
  298. bySettingHour: nextEntryComponents.hour!,
  299. minute: nextEntryComponents.minute!,
  300. second: nextEntryComponents.second ?? 0,
  301. of: now
  302. )!
  303. } else {
  304. entryEndTime = calendar.date(byAdding: .day, value: 1, to: entryStartTime)!
  305. }
  306. } else {
  307. entryEndTime = calendar.date(byAdding: .day, value: 1, to: entryStartTime)!
  308. }
  309. if now >= entryStartTime, now < entryEndTime {
  310. await MainActor.run {
  311. switch type {
  312. case .basal:
  313. currentBasal = entry.value
  314. case .carbRatio:
  315. currentCarbRatio = entry.value
  316. case .bgTarget:
  317. currentBGTarget = entry.value
  318. case .isf:
  319. currentISF = entry.value
  320. }
  321. }
  322. return
  323. }
  324. }
  325. }
  326. // MARK: CALCULATIONS FOR THE BOLUS CALCULATOR
  327. /// Calculate insulin recommendation
  328. func calculateInsulin() async -> Decimal {
  329. // Safely get minPredBG on main thread
  330. let localMinPredBG = await MainActor.run {
  331. minPredBG
  332. }
  333. // Use the cob value of the simulation if carbs are backdated, otherwise set `simulatedCOB` to nil so that the cob value of the most recent determination gets used in the Bolus Calc Manager
  334. let simulatedCOB: Int16? = (simulatedDetermination != nil && date < Date()) ?
  335. Int16(truncating: NSNumber(value: (simulatedDetermination?.cob as NSDecimalNumber?)?.doubleValue ?? 0)) : nil
  336. let result = await bolusCalculationManager.handleBolusCalculation(
  337. carbs: carbs,
  338. useFattyMealCorrection: useFattyMealCorrectionFactor,
  339. useSuperBolus: useSuperBolus,
  340. lastLoopDate: apsManager.lastLoopDate,
  341. minPredBG: localMinPredBG,
  342. simulatedCOB: simulatedCOB
  343. )
  344. // Update state properties with calculation results on main thread
  345. await MainActor.run {
  346. targetDifference = result.targetDifference
  347. targetDifferenceInsulin = result.targetDifferenceInsulin
  348. wholeCob = result.wholeCob
  349. wholeCobInsulin = result.wholeCobInsulin
  350. iobInsulinReduction = result.iobInsulinReduction
  351. superBolusInsulin = result.superBolusInsulin
  352. wholeCalc = result.wholeCalc
  353. factoredInsulin = result.factoredInsulin
  354. fifteenMinInsulin = result.fifteenMinutesInsulin
  355. }
  356. return apsManager.roundBolus(amount: result.insulinCalculated)
  357. }
  358. // MARK: - Button tasks
  359. func invokeTreatmentsTask() {
  360. Task {
  361. debug(.bolusState, "invokeTreatmentsTask fired")
  362. await MainActor.run {
  363. self.addButtonPressed = true
  364. }
  365. let isInsulinGiven = amount > 0
  366. let isCarbsPresent = carbs > 0
  367. let isFatPresent = fat > 0
  368. let isProteinPresent = protein > 0
  369. if isCarbsPresent || isFatPresent || isProteinPresent {
  370. await saveMeal()
  371. }
  372. if isInsulinGiven {
  373. await handleInsulin(isExternal: externalInsulin)
  374. } else {
  375. hideModal()
  376. return
  377. }
  378. // If glucose data is stale end the custom loading animation by hiding the modal
  379. // Get date on Main thread
  380. let date = await MainActor.run {
  381. glucoseFromPersistence.first?.date
  382. }
  383. guard glucoseStorage.isGlucoseDataFresh(date) else {
  384. await MainActor.run {
  385. isAwaitingDeterminationResult = false
  386. showDeterminationFailureAlert = true
  387. determinationFailureMessage = "Glucose data is stale"
  388. }
  389. return hideModal()
  390. }
  391. }
  392. }
  393. // MARK: - Insulin
  394. private func handleInsulin(isExternal: Bool) async {
  395. debug(.bolusState, "handleInsulin fired")
  396. if !isExternal {
  397. await addPumpInsulin()
  398. } else {
  399. await addExternalInsulin()
  400. }
  401. }
  402. func addPumpInsulin() async {
  403. guard amount > 0 else {
  404. showModal(for: nil)
  405. return
  406. }
  407. let maxAmount = Double(min(amount, maxBolus))
  408. do {
  409. let authenticated = try await unlockmanager.unlock()
  410. if authenticated {
  411. // show loading animation
  412. await MainActor.run {
  413. self.isAwaitingDeterminationResult = true
  414. }
  415. await apsManager.enactBolus(amount: maxAmount, isSMB: false, callback: nil)
  416. } else {
  417. print("authentication failed")
  418. }
  419. } catch {
  420. print("authentication error for pump bolus: \(error.localizedDescription)")
  421. await MainActor.run {
  422. self.isAwaitingDeterminationResult = false
  423. self.showDeterminationFailureAlert = true
  424. self.determinationFailureMessage = error.localizedDescription
  425. }
  426. }
  427. }
  428. // MARK: - EXTERNAL INSULIN
  429. func addExternalInsulin() async {
  430. guard amount > 0 else {
  431. showModal(for: nil)
  432. return
  433. }
  434. await MainActor.run {
  435. self.amount = min(self.amount, self.maxBolus * 3)
  436. }
  437. do {
  438. let authenticated = try await unlockmanager.unlock()
  439. if authenticated {
  440. // show loading animation
  441. await MainActor.run {
  442. self.isAwaitingDeterminationResult = true
  443. }
  444. // store external dose to pump history
  445. await pumpHistoryStorage.storeExternalInsulinEvent(amount: amount, timestamp: date)
  446. // perform determine basal sync
  447. try await apsManager.determineBasalSync()
  448. } else {
  449. print("authentication failed")
  450. }
  451. } catch {
  452. print("authentication error for external insulin: \(error.localizedDescription)")
  453. await MainActor.run {
  454. self.isAwaitingDeterminationResult = false
  455. self.showDeterminationFailureAlert = true
  456. self.determinationFailureMessage = error.localizedDescription
  457. }
  458. }
  459. }
  460. // MARK: - Carbs
  461. func saveMeal() async {
  462. do {
  463. guard carbs > 0 || fat > 0 || protein > 0 else { return }
  464. await MainActor.run {
  465. self.carbs = min(self.carbs, self.maxCarbs)
  466. self.fat = min(self.fat, self.maxFat)
  467. self.protein = min(self.protein, self.maxProtein)
  468. self.id_ = UUID().uuidString
  469. }
  470. let carbsToStore = [CarbsEntry(
  471. id: id_,
  472. createdAt: now,
  473. actualDate: date,
  474. carbs: carbs,
  475. fat: fat,
  476. protein: protein,
  477. note: note,
  478. enteredBy: CarbsEntry.local,
  479. isFPU: false,
  480. fpuID: fat > 0 || protein > 0 ? UUID().uuidString : nil
  481. )]
  482. try await carbsStorage.storeCarbs(carbsToStore, areFetchedFromRemote: false)
  483. // only perform determine basal sync if the user doesn't use the pump bolus, otherwise the enact bolus func in the APSManger does a sync
  484. if amount <= 0 {
  485. await MainActor.run {
  486. self.isAwaitingDeterminationResult = true
  487. }
  488. try await apsManager.determineBasalSync()
  489. }
  490. } catch {
  491. debug(.default, "\(DebuggingIdentifiers.failed) Failed to save carbs: \(error)")
  492. }
  493. }
  494. // MARK: - Presets
  495. func deletePreset() {
  496. if selection != nil {
  497. viewContext.delete(selection!)
  498. do {
  499. guard viewContext.hasChanges else { return }
  500. try viewContext.save()
  501. } catch {
  502. print(error.localizedDescription)
  503. }
  504. carbs = 0
  505. fat = 0
  506. protein = 0
  507. }
  508. selection = nil
  509. }
  510. func removePresetFromNewMeal() {
  511. let a = summation.firstIndex(where: { $0 == selection?.dish! })
  512. if a != nil, summation[a ?? 0] != "" {
  513. summation.remove(at: a!)
  514. }
  515. }
  516. func addPresetToNewMeal() {
  517. if let selection = selection, let dish = selection.dish {
  518. summation.append(dish)
  519. }
  520. }
  521. func addNewPresetToWaitersNotepad(_ dish: String) {
  522. summation.append(dish)
  523. }
  524. func addToSummation() {
  525. summation.append(selection?.dish ?? "")
  526. }
  527. }
  528. }
  529. extension Treatments.StateModel: DeterminationObserver, BolusFailureObserver {
  530. func determinationDidUpdate(_: Determination) {
  531. guard isActive else {
  532. debug(.bolusState, "skipping determinationDidUpdate; view not active")
  533. return
  534. }
  535. DispatchQueue.main.async {
  536. debug(.bolusState, "determinationDidUpdate fired")
  537. self.isAwaitingDeterminationResult = false
  538. if self.addButtonPressed {
  539. self.hideModal()
  540. }
  541. }
  542. }
  543. func bolusDidFail() {
  544. DispatchQueue.main.async {
  545. debug(.bolusState, "bolusDidFail fired")
  546. self.isAwaitingDeterminationResult = false
  547. if self.addButtonPressed {
  548. self.hideModal()
  549. }
  550. }
  551. }
  552. }
  553. extension Treatments.StateModel {
  554. private func registerHandlers() {
  555. coreDataPublisher?.filteredByEntityName("OrefDetermination").sink { [weak self] _ in
  556. guard let self = self else { return }
  557. Task {
  558. await self.setupDeterminationsArray()
  559. let forecastData = await self.mapForecastsForChart()
  560. await self.updateForecasts(with: forecastData)
  561. }
  562. }.store(in: &subscriptions)
  563. // Due to the Batch insert this only is used for observing Deletion of Glucose entries
  564. coreDataPublisher?.filteredByEntityName("GlucoseStored").sink { [weak self] _ in
  565. guard let self = self else { return }
  566. self.setupGlucoseArray()
  567. }.store(in: &subscriptions)
  568. }
  569. private func registerSubscribers() {
  570. glucoseStorage.updatePublisher
  571. .receive(on: DispatchQueue.global(qos: .background))
  572. .sink { [weak self] _ in
  573. guard let self = self else { return }
  574. self.setupGlucoseArray()
  575. }
  576. .store(in: &subscriptions)
  577. }
  578. }
  579. // MARK: - Setup Glucose and Determinations
  580. extension Treatments.StateModel {
  581. // Glucose
  582. private func setupGlucoseArray() {
  583. Task {
  584. do {
  585. let ids = try await self.fetchGlucose()
  586. let glucoseObjects: [GlucoseStored] = try await CoreDataStack.shared
  587. .getNSManagedObject(with: ids, context: viewContext)
  588. await updateGlucoseArray(with: glucoseObjects)
  589. } catch {
  590. debug(
  591. .default,
  592. "\(DebuggingIdentifiers.failed) Error setting up glucose array: \(error)"
  593. )
  594. }
  595. }
  596. }
  597. private func fetchGlucose() async throws -> [NSManagedObjectID] {
  598. let results = try await CoreDataStack.shared.fetchEntitiesAsync(
  599. ofType: GlucoseStored.self,
  600. onContext: glucoseFetchContext,
  601. predicate: NSPredicate.glucose,
  602. key: "date",
  603. ascending: false
  604. )
  605. return try await glucoseFetchContext.perform {
  606. guard let fetchedResults = results as? [GlucoseStored] else {
  607. throw CoreDataError.fetchError(function: #function, file: #file)
  608. }
  609. return fetchedResults.map(\.objectID)
  610. }
  611. }
  612. @MainActor private func updateGlucoseArray(with objects: [GlucoseStored]) {
  613. // Store all objects for the forecast graph
  614. glucoseFromPersistence = objects
  615. // Always use the most recent reading for current glucose
  616. let lastGlucose = objects.first?.glucose ?? 0
  617. // Filter for readings less than 20 minutes old
  618. let twentyMinutesAgo = Date().addingTimeInterval(-20 * 60)
  619. let recentObjects = objects.filter {
  620. guard let date = $0.date else { return false }
  621. return date > twentyMinutesAgo
  622. }
  623. // Calculate delta using newest and oldest readings within 20-minute window
  624. let delta: Decimal
  625. if let newestInWindow = recentObjects.first?.glucose, let oldestInWindow = recentObjects.last?.glucose {
  626. // Newest is at index 0, oldest is at the last index
  627. delta = Decimal(newestInWindow) - Decimal(oldestInWindow)
  628. } else {
  629. // Not enough data points in the window
  630. delta = 0
  631. }
  632. currentBG = Decimal(lastGlucose)
  633. deltaBG = delta
  634. }
  635. // Determinations
  636. private func setupDeterminationsArray() async {
  637. do {
  638. let fetchedObjectIDs = try await determinationStorage.fetchLastDeterminationObjectID(
  639. predicate: NSPredicate.predicateFor30MinAgoForDetermination
  640. )
  641. await MainActor.run {
  642. determinationObjectIDs = fetchedObjectIDs
  643. }
  644. let determinationObjects: [OrefDetermination] = try await CoreDataStack.shared
  645. .getNSManagedObject(with: determinationObjectIDs, context: viewContext)
  646. updateDeterminationsArray(with: determinationObjects)
  647. } catch let error as CoreDataError {
  648. debug(.default, "Core Data error: \(error)")
  649. } catch {
  650. debug(.default, "Unexpected error: \(error)")
  651. }
  652. }
  653. private func mapForecastsForChart() async -> Determination? {
  654. do {
  655. let determinationObjects: [OrefDetermination] = try await CoreDataStack.shared
  656. .getNSManagedObject(with: determinationObjectIDs, context: determinationFetchContext)
  657. let determination = await determinationFetchContext.perform {
  658. let determinationObject = determinationObjects.first
  659. let forecastsSet = determinationObject?.forecasts ?? []
  660. let predictions = Predictions(
  661. iob: forecastsSet.extractValues(for: "iob"),
  662. zt: forecastsSet.extractValues(for: "zt"),
  663. cob: forecastsSet.extractValues(for: "cob"),
  664. uam: forecastsSet.extractValues(for: "uam")
  665. )
  666. return Determination(
  667. id: UUID(),
  668. reason: "",
  669. units: 0,
  670. insulinReq: 0,
  671. sensitivityRatio: 0,
  672. rate: 0,
  673. duration: 0,
  674. iob: 0,
  675. cob: 0,
  676. predictions: predictions.isEmpty ? nil : predictions,
  677. carbsReq: 0,
  678. temp: nil,
  679. reservoir: 0,
  680. insulinForManualBolus: 0,
  681. manualBolusErrorString: 0,
  682. carbRatio: 0,
  683. received: false
  684. )
  685. }
  686. guard !determinationObjects.isEmpty else {
  687. return nil
  688. }
  689. return determination
  690. } catch {
  691. debug(
  692. .default,
  693. "\(DebuggingIdentifiers.failed) Error mapping forecasts for chart: \(error)"
  694. )
  695. return nil
  696. }
  697. }
  698. private func updateDeterminationsArray(with objects: [OrefDetermination]) {
  699. Task { @MainActor in
  700. guard let mostRecentDetermination = objects.first else { return }
  701. determination = objects
  702. // setup vars for bolus calculation
  703. insulinRequired = (mostRecentDetermination.insulinReq ?? 0) as Decimal
  704. evBG = (mostRecentDetermination.eventualBG ?? 0) as Decimal
  705. minPredBG = (mostRecentDetermination.minPredBGFromReason ?? 0) as Decimal
  706. lastLoopDate = apsManager.lastLoopDate as Date?
  707. insulin = (mostRecentDetermination.insulinForManualBolus ?? 0) as Decimal
  708. target = (mostRecentDetermination.currentTarget ?? currentBGTarget as NSDecimalNumber) as Decimal
  709. isf = (mostRecentDetermination.insulinSensitivity ?? currentISF as NSDecimalNumber) as Decimal
  710. cob = mostRecentDetermination.cob as Int16
  711. iob = (mostRecentDetermination.iob ?? 0) as Decimal
  712. basal = (mostRecentDetermination.tempBasal ?? 0) as Decimal
  713. carbRatio = (mostRecentDetermination.carbRatio ?? currentCarbRatio as NSDecimalNumber) as Decimal
  714. insulinCalculated = await calculateInsulin()
  715. }
  716. }
  717. }
  718. extension Treatments.StateModel {
  719. @MainActor func updateForecasts(with forecastData: Determination? = nil) async {
  720. guard isActive else {
  721. return
  722. debug(.bolusState, "updateForecasts not fired")
  723. }
  724. debug(.bolusState, "updateForecasts fired")
  725. if let forecastData = forecastData {
  726. simulatedDetermination = forecastData
  727. debugPrint("\(DebuggingIdentifiers.failed) minPredBG: \(minPredBG)")
  728. } else {
  729. simulatedDetermination = await Task { [self] in
  730. debug(.bolusState, "calling simulateDetermineBasal to get forecast data")
  731. return await apsManager.simulateDetermineBasal(
  732. simulatedCarbsAmount: carbs,
  733. simulatedBolusAmount: amount,
  734. simulatedCarbsDate: date
  735. )
  736. }.value
  737. // Update evBG and minPredBG from simulated determination
  738. if let simDetermination = simulatedDetermination {
  739. evBG = Decimal(simDetermination.eventualBG ?? 0)
  740. minPredBG = simDetermination.minPredBGFromReason ?? 0
  741. debugPrint("\(DebuggingIdentifiers.inProgress) minPredBG: \(minPredBG)")
  742. }
  743. }
  744. predictionsForChart = simulatedDetermination?.predictions
  745. let nonEmptyArrays = [
  746. predictionsForChart?.iob,
  747. predictionsForChart?.zt,
  748. predictionsForChart?.cob,
  749. predictionsForChart?.uam
  750. ]
  751. .compactMap { $0 }
  752. .filter { !$0.isEmpty }
  753. guard !nonEmptyArrays.isEmpty else {
  754. minForecast = []
  755. maxForecast = []
  756. return
  757. }
  758. minCount = max(12, nonEmptyArrays.map(\.count).min() ?? 0)
  759. guard minCount > 0 else { return }
  760. async let minForecastResult = Task {
  761. await (0 ..< self.minCount).map { index in
  762. nonEmptyArrays.compactMap { $0.indices.contains(index) ? $0[index] : nil }.min() ?? 0
  763. }
  764. }.value
  765. async let maxForecastResult = Task {
  766. await (0 ..< self.minCount).map { index in
  767. nonEmptyArrays.compactMap { $0.indices.contains(index) ? $0[index] : nil }.max() ?? 0
  768. }
  769. }.value
  770. minForecast = await minForecastResult
  771. maxForecast = await maxForecastResult
  772. }
  773. }
  774. private extension Set where Element == Forecast {
  775. func extractValues(for type: String) -> [Int]? {
  776. let values = first { $0.type == type }?
  777. .forecastValues?
  778. .sorted { $0.index < $1.index }
  779. .compactMap { Int($0.value) }
  780. return values?.isEmpty ?? true ? nil : values
  781. }
  782. }
  783. private extension Predictions {
  784. var isEmpty: Bool {
  785. iob == nil && zt == nil && cob == nil && uam == nil
  786. }
  787. }