DosingDecisionStore.swift 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554
  1. //
  2. // DosingDecisionStore.swift
  3. // LoopKit
  4. //
  5. // Created by Darin Krauss on 10/14/19.
  6. // Copyright © 2019 LoopKit Authors. All rights reserved.
  7. //
  8. import os.log
  9. import Foundation
  10. import CoreData
  11. public protocol DosingDecisionStoreDelegate: AnyObject {
  12. /**
  13. Informs the delegate that the dosing decision store has updated dosing decision data.
  14. - Parameter dosingDecisionStore: The dosing decision store that has updated dosing decision data.
  15. */
  16. func dosingDecisionStoreHasUpdatedDosingDecisionData(_ dosingDecisionStore: DosingDecisionStore)
  17. }
  18. public class DosingDecisionStore {
  19. public weak var delegate: DosingDecisionStoreDelegate?
  20. private let store: PersistenceController
  21. private let expireAfter: TimeInterval
  22. private let dataAccessQueue = DispatchQueue(label: "com.loopkit.DosingDecisionStore.dataAccessQueue", qos: .utility)
  23. public let log = OSLog(category: "DosingDecisionStore")
  24. public init(store: PersistenceController, expireAfter: TimeInterval) {
  25. self.store = store
  26. self.expireAfter = expireAfter
  27. }
  28. public func storeDosingDecision(_ dosingDecision: StoredDosingDecision, completion: @escaping () -> Void) {
  29. dataAccessQueue.async {
  30. if let data = self.encodeDosingDecision(dosingDecision) {
  31. self.store.managedObjectContext.performAndWait {
  32. let object = DosingDecisionObject(context: self.store.managedObjectContext)
  33. object.data = data
  34. object.date = dosingDecision.date
  35. self.store.save()
  36. }
  37. }
  38. self.purgeExpiredDosingDecisions()
  39. completion()
  40. }
  41. }
  42. public var expireDate: Date {
  43. return Date(timeIntervalSinceNow: -expireAfter)
  44. }
  45. private func purgeExpiredDosingDecisions() {
  46. purgeDosingDecisionObjects(before: expireDate)
  47. }
  48. public func purgeDosingDecisions(before date: Date, completion: @escaping (Error?) -> Void) {
  49. dataAccessQueue.async {
  50. self.purgeDosingDecisionObjects(before: date, completion: completion)
  51. }
  52. }
  53. private func purgeDosingDecisionObjects(before date: Date, completion: ((Error?) -> Void)? = nil) {
  54. dispatchPrecondition(condition: .onQueue(dataAccessQueue))
  55. var purgeError: Error?
  56. store.managedObjectContext.performAndWait {
  57. do {
  58. let count = try self.store.managedObjectContext.purgeObjects(of: DosingDecisionObject.self, matching: NSPredicate(format: "date < %@", date as NSDate))
  59. self.log.info("Purged %d DosingDecisionObjects", count)
  60. } catch let error {
  61. self.log.error("Unable to purge DosingDecisionObjects: %{public}@", String(describing: error))
  62. purgeError = error
  63. }
  64. }
  65. if let purgeError = purgeError {
  66. completion?(purgeError)
  67. return
  68. }
  69. delegate?.dosingDecisionStoreHasUpdatedDosingDecisionData(self)
  70. completion?(nil)
  71. }
  72. private static var encoder: PropertyListEncoder = {
  73. let encoder = PropertyListEncoder()
  74. encoder.outputFormat = .binary
  75. return encoder
  76. }()
  77. private func encodeDosingDecision(_ dosingDecision: StoredDosingDecision) -> Data? {
  78. do {
  79. return try Self.encoder.encode(dosingDecision)
  80. } catch let error {
  81. self.log.error("Error encoding StoredDosingDecision: %@", String(describing: error))
  82. return nil
  83. }
  84. }
  85. private static var decoder = PropertyListDecoder()
  86. private func decodeDosingDecision(fromData data: Data) -> StoredDosingDecision? {
  87. do {
  88. return try Self.decoder.decode(StoredDosingDecision.self, from: data)
  89. } catch let error {
  90. self.log.error("Error decoding StoredDosingDecision: %@", String(describing: error))
  91. return nil
  92. }
  93. }
  94. }
  95. extension DosingDecisionStore {
  96. public struct QueryAnchor: Equatable, RawRepresentable {
  97. public typealias RawValue = [String: Any]
  98. internal var modificationCounter: Int64
  99. public init() {
  100. self.modificationCounter = 0
  101. }
  102. public init?(rawValue: RawValue) {
  103. guard let modificationCounter = rawValue["modificationCounter"] as? Int64 else {
  104. return nil
  105. }
  106. self.modificationCounter = modificationCounter
  107. }
  108. public var rawValue: RawValue {
  109. var rawValue: RawValue = [:]
  110. rawValue["modificationCounter"] = modificationCounter
  111. return rawValue
  112. }
  113. }
  114. public enum DosingDecisionQueryResult {
  115. case success(QueryAnchor, [StoredDosingDecision])
  116. case failure(Error)
  117. }
  118. public func executeDosingDecisionQuery(fromQueryAnchor queryAnchor: QueryAnchor?, limit: Int, completion: @escaping (DosingDecisionQueryResult) -> Void) {
  119. dataAccessQueue.async {
  120. var queryAnchor = queryAnchor ?? QueryAnchor()
  121. var queryResult = [StoredDosingDecisionData]()
  122. var queryError: Error?
  123. guard limit > 0 else {
  124. completion(.success(queryAnchor, []))
  125. return
  126. }
  127. let enqueueTime = DispatchTime.now()
  128. self.store.managedObjectContext.performAndWait {
  129. let startTime = DispatchTime.now()
  130. defer {
  131. let endTime = DispatchTime.now()
  132. let queueWait = Double(startTime.uptimeNanoseconds - enqueueTime.uptimeNanoseconds) / 1_000_000_000
  133. let fetchWait = Double(endTime.uptimeNanoseconds - startTime.uptimeNanoseconds) / 1_000_000_000
  134. self.log.debug("executeDosingDecisionQuery (anchor = %{public}@: queueWait(%.03f), fetch(%.03f)", String(describing: queryAnchor), queueWait, fetchWait)
  135. }
  136. let storedRequest: NSFetchRequest<DosingDecisionObject> = DosingDecisionObject.fetchRequest()
  137. storedRequest.predicate = NSPredicate(format: "modificationCounter > %d", queryAnchor.modificationCounter)
  138. storedRequest.sortDescriptors = [NSSortDescriptor(key: "modificationCounter", ascending: true)]
  139. storedRequest.fetchLimit = limit
  140. do {
  141. let stored = try self.store.managedObjectContext.fetch(storedRequest)
  142. if let modificationCounter = stored.max(by: { $0.modificationCounter < $1.modificationCounter })?.modificationCounter {
  143. queryAnchor.modificationCounter = modificationCounter
  144. }
  145. queryResult.append(contentsOf: stored.compactMap { StoredDosingDecisionData(date: $0.date, data: $0.data) })
  146. } catch let error {
  147. queryError = error
  148. return
  149. }
  150. }
  151. if let queryError = queryError {
  152. completion(.failure(queryError))
  153. return
  154. }
  155. // Decoding a large number of dosing decision can be very CPU intensive and may take considerable wall clock time.
  156. // Do not block DosingDecisionStore dataAccessQueue. Perform work and callback in global utility queue.
  157. DispatchQueue.global(qos: .utility).async {
  158. completion(.success(queryAnchor, queryResult.compactMap { self.decodeDosingDecision(fromData: $0.data) }))
  159. }
  160. }
  161. }
  162. }
  163. public struct StoredDosingDecisionData {
  164. public let date: Date
  165. public let data: Data
  166. public init(date: Date, data: Data) {
  167. self.date = date
  168. self.data = data
  169. }
  170. }
  171. public typealias HistoricalGlucoseValue = PredictedGlucoseValue
  172. public struct StoredDosingDecision {
  173. public var date: Date
  174. public var controllerTimeZone: TimeZone
  175. public var reason: String
  176. public var settings: Settings?
  177. public var scheduleOverride: TemporaryScheduleOverride?
  178. public var controllerStatus: ControllerStatus?
  179. public var pumpManagerStatus: PumpManagerStatus?
  180. public var pumpStatusHighlight: StoredDeviceHighlight?
  181. public var cgmManagerStatus: CGMManagerStatus?
  182. public var lastReservoirValue: LastReservoirValue?
  183. public var historicalGlucose: [HistoricalGlucoseValue]?
  184. public var originalCarbEntry: StoredCarbEntry?
  185. public var carbEntry: StoredCarbEntry?
  186. public var manualGlucoseSample: StoredGlucoseSample?
  187. public var carbsOnBoard: CarbValue?
  188. public var insulinOnBoard: InsulinValue?
  189. public var glucoseTargetRangeSchedule: GlucoseRangeSchedule?
  190. public var predictedGlucose: [PredictedGlucoseValue]?
  191. public var automaticDoseRecommendation: AutomaticDoseRecommendation?
  192. public var manualBolusRecommendation: ManualBolusRecommendationWithDate?
  193. public var manualBolusRequested: Double?
  194. public var warnings: [Issue]
  195. public var errors: [Issue]
  196. public var syncIdentifier: UUID
  197. public init(date: Date = Date(),
  198. controllerTimeZone: TimeZone = TimeZone.current,
  199. reason: String,
  200. settings: Settings? = nil,
  201. scheduleOverride: TemporaryScheduleOverride? = nil,
  202. controllerStatus: ControllerStatus? = nil,
  203. pumpManagerStatus: PumpManagerStatus? = nil,
  204. pumpStatusHighlight: StoredDeviceHighlight? = nil,
  205. cgmManagerStatus: CGMManagerStatus? = nil,
  206. lastReservoirValue: LastReservoirValue? = nil,
  207. historicalGlucose: [HistoricalGlucoseValue]? = nil,
  208. originalCarbEntry: StoredCarbEntry? = nil,
  209. carbEntry: StoredCarbEntry? = nil,
  210. manualGlucoseSample: StoredGlucoseSample? = nil,
  211. carbsOnBoard: CarbValue? = nil,
  212. insulinOnBoard: InsulinValue? = nil,
  213. glucoseTargetRangeSchedule: GlucoseRangeSchedule? = nil,
  214. predictedGlucose: [PredictedGlucoseValue]? = nil,
  215. automaticDoseRecommendation: AutomaticDoseRecommendation? = nil,
  216. manualBolusRecommendation: ManualBolusRecommendationWithDate? = nil,
  217. manualBolusRequested: Double? = nil,
  218. warnings: [Issue] = [],
  219. errors: [Issue] = [],
  220. syncIdentifier: UUID = UUID()) {
  221. self.date = date
  222. self.controllerTimeZone = controllerTimeZone
  223. self.reason = reason
  224. self.settings = settings
  225. self.scheduleOverride = scheduleOverride
  226. self.controllerStatus = controllerStatus
  227. self.pumpManagerStatus = pumpManagerStatus
  228. self.pumpStatusHighlight = pumpStatusHighlight
  229. self.cgmManagerStatus = cgmManagerStatus
  230. self.lastReservoirValue = lastReservoirValue
  231. self.historicalGlucose = historicalGlucose
  232. self.originalCarbEntry = originalCarbEntry
  233. self.carbEntry = carbEntry
  234. self.manualGlucoseSample = manualGlucoseSample
  235. self.carbsOnBoard = carbsOnBoard
  236. self.insulinOnBoard = insulinOnBoard
  237. self.glucoseTargetRangeSchedule = glucoseTargetRangeSchedule
  238. self.predictedGlucose = predictedGlucose
  239. self.automaticDoseRecommendation = automaticDoseRecommendation
  240. self.manualBolusRecommendation = manualBolusRecommendation
  241. self.manualBolusRequested = manualBolusRequested
  242. self.warnings = warnings
  243. self.errors = errors
  244. self.syncIdentifier = syncIdentifier
  245. }
  246. public struct Settings: Codable, Equatable {
  247. public let syncIdentifier: UUID
  248. public init(syncIdentifier: UUID) {
  249. self.syncIdentifier = syncIdentifier
  250. }
  251. }
  252. public struct ControllerStatus: Codable, Equatable {
  253. public enum BatteryState: String, Codable {
  254. case unknown
  255. case unplugged
  256. case charging
  257. case full
  258. }
  259. public let batteryState: BatteryState?
  260. public let batteryLevel: Float?
  261. public init(batteryState: BatteryState? = nil, batteryLevel: Float? = nil) {
  262. self.batteryState = batteryState
  263. self.batteryLevel = batteryLevel
  264. }
  265. }
  266. public struct LastReservoirValue: Codable {
  267. public let startDate: Date
  268. public let unitVolume: Double
  269. public init(startDate: Date, unitVolume: Double) {
  270. self.startDate = startDate
  271. self.unitVolume = unitVolume
  272. }
  273. }
  274. public struct Issue: Codable, Equatable {
  275. public let id: String
  276. public let details: [String: String]?
  277. public init(id: String, details: [String: String]? = nil) {
  278. self.id = id
  279. self.details = details?.isEmpty == false ? details : nil
  280. }
  281. }
  282. public struct StoredDeviceHighlight: Codable, Equatable, DeviceStatusHighlight {
  283. public var localizedMessage: String
  284. public var imageName: String
  285. public var state: DeviceStatusHighlightState
  286. public init(localizedMessage: String, imageName: String, state: DeviceStatusHighlightState) {
  287. self.localizedMessage = localizedMessage
  288. self.imageName = imageName
  289. self.state = state
  290. }
  291. }
  292. }
  293. public struct ManualBolusRecommendationWithDate: Codable {
  294. public let recommendation: ManualBolusRecommendation
  295. public let date: Date
  296. public init(recommendation: ManualBolusRecommendation, date: Date) {
  297. self.recommendation = recommendation
  298. self.date = date
  299. }
  300. }
  301. extension StoredDosingDecision: Codable {
  302. public init(from decoder: Decoder) throws {
  303. let container = try decoder.container(keyedBy: CodingKeys.self)
  304. self.init(date: try container.decode(Date.self, forKey: .date),
  305. controllerTimeZone: try container.decode(TimeZone.self, forKey: .controllerTimeZone),
  306. reason: try container.decode(String.self, forKey: .reason),
  307. settings: try container.decodeIfPresent(Settings.self, forKey: .settings),
  308. scheduleOverride: try container.decodeIfPresent(TemporaryScheduleOverride.self, forKey: .scheduleOverride),
  309. controllerStatus: try container.decodeIfPresent(ControllerStatus.self, forKey: .controllerStatus),
  310. pumpManagerStatus: try container.decodeIfPresent(PumpManagerStatus.self, forKey: .pumpManagerStatus),
  311. pumpStatusHighlight: try container.decodeIfPresent(StoredDeviceHighlight.self, forKey: .pumpStatusHighlight),
  312. cgmManagerStatus: try container.decodeIfPresent(CGMManagerStatus.self, forKey: .cgmManagerStatus),
  313. lastReservoirValue: try container.decodeIfPresent(LastReservoirValue.self, forKey: .lastReservoirValue),
  314. historicalGlucose: try container.decodeIfPresent([HistoricalGlucoseValue].self, forKey: .historicalGlucose),
  315. originalCarbEntry: try container.decodeIfPresent(StoredCarbEntry.self, forKey: .originalCarbEntry),
  316. carbEntry: try container.decodeIfPresent(StoredCarbEntry.self, forKey: .carbEntry),
  317. manualGlucoseSample: try container.decodeIfPresent(StoredGlucoseSample.self, forKey: .manualGlucoseSample),
  318. carbsOnBoard: try container.decodeIfPresent(CarbValue.self, forKey: .carbsOnBoard),
  319. insulinOnBoard: try container.decodeIfPresent(InsulinValue.self, forKey: .insulinOnBoard),
  320. glucoseTargetRangeSchedule: try container.decodeIfPresent(GlucoseRangeSchedule.self, forKey: .glucoseTargetRangeSchedule),
  321. predictedGlucose: try container.decodeIfPresent([PredictedGlucoseValue].self, forKey: .predictedGlucose),
  322. automaticDoseRecommendation: try container.decodeIfPresent(AutomaticDoseRecommendation.self, forKey: .automaticDoseRecommendation),
  323. manualBolusRecommendation: try container.decodeIfPresent(ManualBolusRecommendationWithDate.self, forKey: .manualBolusRecommendation),
  324. manualBolusRequested: try container.decodeIfPresent(Double.self, forKey: .manualBolusRequested),
  325. warnings: try container.decodeIfPresent([Issue].self, forKey: .warnings) ?? [],
  326. errors: try container.decodeIfPresent([Issue].self, forKey: .errors) ?? [],
  327. syncIdentifier: try container.decode(UUID.self, forKey: .syncIdentifier))
  328. }
  329. public func encode(to encoder: Encoder) throws {
  330. var container = encoder.container(keyedBy: CodingKeys.self)
  331. try container.encode(date, forKey: .date)
  332. try container.encode(controllerTimeZone, forKey: .controllerTimeZone)
  333. try container.encode(reason, forKey: .reason)
  334. try container.encodeIfPresent(settings, forKey: .settings)
  335. try container.encodeIfPresent(scheduleOverride, forKey: .scheduleOverride)
  336. try container.encodeIfPresent(controllerStatus, forKey: .controllerStatus)
  337. try container.encodeIfPresent(pumpManagerStatus, forKey: .pumpManagerStatus)
  338. try container.encodeIfPresent(pumpStatusHighlight, forKey: .pumpStatusHighlight)
  339. try container.encodeIfPresent(cgmManagerStatus, forKey: .cgmManagerStatus)
  340. try container.encodeIfPresent(lastReservoirValue, forKey: .lastReservoirValue)
  341. try container.encodeIfPresent(historicalGlucose, forKey: .historicalGlucose)
  342. try container.encodeIfPresent(originalCarbEntry, forKey: .originalCarbEntry)
  343. try container.encodeIfPresent(carbEntry, forKey: .carbEntry)
  344. try container.encodeIfPresent(manualGlucoseSample, forKey: .manualGlucoseSample)
  345. try container.encodeIfPresent(carbsOnBoard, forKey: .carbsOnBoard)
  346. try container.encodeIfPresent(insulinOnBoard, forKey: .insulinOnBoard)
  347. try container.encodeIfPresent(glucoseTargetRangeSchedule, forKey: .glucoseTargetRangeSchedule)
  348. try container.encodeIfPresent(predictedGlucose, forKey: .predictedGlucose)
  349. try container.encodeIfPresent(automaticDoseRecommendation, forKey: .automaticDoseRecommendation)
  350. try container.encodeIfPresent(manualBolusRecommendation, forKey: .manualBolusRecommendation)
  351. try container.encodeIfPresent(manualBolusRequested, forKey: .manualBolusRequested)
  352. try container.encodeIfPresent(!warnings.isEmpty ? warnings : nil, forKey: .warnings)
  353. try container.encodeIfPresent(!errors.isEmpty ? errors : nil, forKey: .errors)
  354. try container.encode(syncIdentifier, forKey: .syncIdentifier)
  355. }
  356. private enum CodingKeys: String, CodingKey {
  357. case date
  358. case controllerTimeZone
  359. case reason
  360. case settings
  361. case scheduleOverride
  362. case controllerStatus
  363. case pumpManagerStatus
  364. case pumpStatusHighlight
  365. case cgmManagerStatus
  366. case lastReservoirValue
  367. case historicalGlucose
  368. case originalCarbEntry
  369. case carbEntry
  370. case manualGlucoseSample
  371. case carbsOnBoard
  372. case insulinOnBoard
  373. case glucoseTargetRangeSchedule
  374. case predictedGlucose
  375. case automaticDoseRecommendation
  376. case manualBolusRecommendation
  377. case manualBolusRequested
  378. case warnings
  379. case errors
  380. case syncIdentifier
  381. }
  382. }
  383. // MARK: - Critical Event Log Export
  384. extension DosingDecisionStore: CriticalEventLog {
  385. private var exportProgressUnitCountPerObject: Int64 { 33 }
  386. private var exportFetchLimit: Int { Int(criticalEventLogExportProgressUnitCountPerFetch / exportProgressUnitCountPerObject) }
  387. public var exportName: String { "DosingDecisions.json" }
  388. public func exportProgressTotalUnitCount(startDate: Date, endDate: Date? = nil) -> Result<Int64, Error> {
  389. var result: Result<Int64, Error>?
  390. self.store.managedObjectContext.performAndWait {
  391. do {
  392. let request: NSFetchRequest<DosingDecisionObject> = DosingDecisionObject.fetchRequest()
  393. request.predicate = self.exportDatePredicate(startDate: startDate, endDate: endDate)
  394. let objectCount = try self.store.managedObjectContext.count(for: request)
  395. result = .success(Int64(objectCount) * exportProgressUnitCountPerObject)
  396. } catch let error {
  397. result = .failure(error)
  398. }
  399. }
  400. return result!
  401. }
  402. public func export(startDate: Date, endDate: Date, to stream: OutputStream, progress: Progress) -> Error? {
  403. let encoder = JSONStreamEncoder(stream: stream)
  404. var modificationCounter: Int64 = 0
  405. var fetching = true
  406. var error: Error?
  407. while fetching && error == nil {
  408. self.store.managedObjectContext.performAndWait {
  409. do {
  410. guard !progress.isCancelled else {
  411. throw CriticalEventLogError.cancelled
  412. }
  413. let request: NSFetchRequest<DosingDecisionObject> = DosingDecisionObject.fetchRequest()
  414. request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [NSPredicate(format: "modificationCounter > %d", modificationCounter),
  415. self.exportDatePredicate(startDate: startDate, endDate: endDate)])
  416. request.sortDescriptors = [NSSortDescriptor(key: "modificationCounter", ascending: true)]
  417. request.fetchLimit = self.exportFetchLimit
  418. let objects = try self.store.managedObjectContext.fetch(request)
  419. if objects.isEmpty {
  420. fetching = false
  421. return
  422. }
  423. try encoder.encode(objects)
  424. modificationCounter = objects.last!.modificationCounter
  425. progress.completedUnitCount += Int64(objects.count) * exportProgressUnitCountPerObject
  426. } catch let fetchError {
  427. error = fetchError
  428. }
  429. }
  430. }
  431. if let closeError = encoder.close(), error == nil {
  432. error = closeError
  433. }
  434. return error
  435. }
  436. private func exportDatePredicate(startDate: Date, endDate: Date? = nil) -> NSPredicate {
  437. var predicate = NSPredicate(format: "date >= %@", startDate as NSDate)
  438. if let endDate = endDate {
  439. predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [predicate, NSPredicate(format: "date < %@", endDate as NSDate)])
  440. }
  441. return predicate
  442. }
  443. }
  444. // MARK: - Core Data (Bulk) - TEST ONLY
  445. extension DosingDecisionStore {
  446. public func addStoredDosingDecisions(dosingDecisions: [StoredDosingDecision], completion: @escaping (Error?) -> Void) {
  447. guard !dosingDecisions.isEmpty else {
  448. completion(nil)
  449. return
  450. }
  451. dataAccessQueue.async {
  452. var error: Error?
  453. self.store.managedObjectContext.performAndWait {
  454. for dosingDecision in dosingDecisions {
  455. guard let data = self.encodeDosingDecision(dosingDecision) else {
  456. continue
  457. }
  458. let object = DosingDecisionObject(context: self.store.managedObjectContext)
  459. object.data = data
  460. object.date = dosingDecision.date
  461. }
  462. error = self.store.save()
  463. }
  464. guard error == nil else {
  465. completion(error)
  466. return
  467. }
  468. self.log.info("Added %d DosingDecisionObjects", dosingDecisions.count)
  469. self.delegate?.dosingDecisionStoreHasUpdatedDosingDecisionData(self)
  470. completion(nil)
  471. }
  472. }
  473. }