diff --git a/TidepoolServiceKit/Extensions/DoseEntry.swift b/TidepoolServiceKit/Extensions/DoseEntry.swift index 73b1461..257d6eb 100644 --- a/TidepoolServiceKit/Extensions/DoseEntry.swift +++ b/TidepoolServiceKit/Extensions/DoseEntry.swift @@ -8,6 +8,8 @@ import LoopKit import TidepoolKit +import LoopAlgorithm +import HealthKit /* DoseEntry @@ -204,8 +206,8 @@ extension DoseEntry: IdentifiableDatum { payload["deliveredUnits"] = deliveredUnits var datum = TAutomatedBasalDatum(time: datumTime, - duration: !isMutable ? datumDuration : 0, - expectedDuration: !isMutable && datumDuration < basalDatumExpectedDuration ? basalDatumExpectedDuration : nil, + duration: datumDuration, + expectedDuration: datumDuration < basalDatumExpectedDuration ? basalDatumExpectedDuration : nil, rate: datumRate, scheduleName: StoredSettings.activeScheduleNameDefault, insulinFormulation: datumInsulinFormulation) @@ -342,3 +344,190 @@ extension TNormalBolusDatum: TypedDatum { extension TInsulinDatum: TypedDatum { static var resolvedType: String { TDatum.DatumType.insulin.rawValue } } + +extension DoseEntry { + + /// Annotates a dose with the context of a history of scheduled basal rates + /// + /// If the dose crosses a schedule boundary, it will be split into multiple doses so each dose has a + /// single scheduled basal rate. + /// + /// - Parameter basalHistory: The history of basal schedule values to apply. Only schedule values overlapping the dose should be included. + /// - Returns: An array of annotated doses + fileprivate func annotated(with basalHistory: [AbsoluteScheduleValue]) -> [DoseEntry] { + + guard type == .tempBasal || type == .suspend, !basalHistory.isEmpty else { + return [self] + } + + if type == .suspend { + guard value == 0 else { + preconditionFailure("suspend with non-zero delivery") + } + } else { + guard unit != .units else { + preconditionFailure("temp basal without rate unsupported") + } + } + + if isMutable { + var newDose = self + let basal = basalHistory.first! + newDose.scheduledBasalRate = HKQuantity(unit: .internationalUnitsPerHour, doubleValue: basal.value) + return [newDose] + } + + var doses: [DoseEntry] = [] + + for (index, basalItem) in basalHistory.enumerated() { + let startDate: Date + let endDate: Date + + if index == 0 { + startDate = self.startDate + } else { + startDate = basalItem.startDate + } + + if index == basalHistory.count - 1 { + endDate = self.endDate + } else { + endDate = basalHistory[index + 1].startDate + } + + let segmentStartDate = max(startDate, self.startDate) + let segmentEndDate = max(startDate, min(endDate, self.endDate)) + let segmentDuration = segmentEndDate.timeIntervalSince(segmentStartDate) + let segmentPortion = (segmentDuration / duration) + + var annotatedDose = self + annotatedDose.startDate = segmentStartDate + annotatedDose.endDate = segmentEndDate + annotatedDose.scheduledBasalRate = HKQuantity(unit: .internationalUnitsPerHour, doubleValue: basalItem.value) + + if let deliveredUnits { + annotatedDose.deliveredUnits = deliveredUnits * segmentPortion + } + + doses.append(annotatedDose) + } + + if doses.count > 1 { + for (index, dose) in doses.enumerated() { + if let originalIdentifier = dose.syncIdentifier, index>0 { + doses[index].syncIdentifier = originalIdentifier + "\(index+1)/\(doses.count)" + } + } + } + + return doses + } + +} + + +extension Collection where Element == DoseEntry { + + /// Annotates a sequence of dose entries with the configured basal history + /// + /// Doses which cross time boundaries in the basal rate schedule are split into multiple entries. + /// + /// - Parameter basalHistory: A history of basal rates covering the timespan of these doses. + /// - Returns: An array of annotated dose entries + public func annotated(with basalHistory: [AbsoluteScheduleValue]) -> [DoseEntry] { + var annotatedDoses: [DoseEntry] = [] + + for dose in self { + let basalItems = basalHistory.filterDateRange(dose.startDate, dose.endDate) + annotatedDoses += dose.annotated(with: basalItems) + } + + return annotatedDoses + } + + + /// Assigns an automation status to any dose where automation is not already specified + /// + /// - Parameters: + /// - automationHistory: A history of automation periods. + /// - Returns: An array of doses, with the automation flag set based on automation history. Doses will be split if the automation state changes mid-dose. + + public func overlayAutomationHistory( + _ automationHistory: [AbsoluteScheduleValue] + ) -> [DoseEntry] { + + guard count > 0 else { + return [] + } + + var newEntries = [DoseEntry]() + + var automation = automationHistory + + // Assume automation if doses start before automationHistory + if let firstAutomation = automation.first, firstAutomation.startDate > first!.startDate { + automation.insert(AbsoluteScheduleValue(startDate: first!.startDate, endDate: firstAutomation.startDate, value: true), at: 0) + } + + // Overlay automation periods + func annotateDoseWithAutomation(dose: DoseEntry) { + + var addedCount = 0 + for period in automation { + if period.endDate > dose.startDate && period.startDate < dose.endDate { + var newDose = dose + + if dose.isMutable { + newDose.automatic = period.value + newEntries.append(newDose) + return + } + + newDose.startDate = Swift.max(period.startDate, dose.startDate) + newDose.endDate = Swift.min(period.endDate, dose.endDate) + if let delivered = dose.deliveredUnits { + newDose.deliveredUnits = newDose.duration / dose.duration * delivered + } + newDose.automatic = period.value + if addedCount > 0 { + newDose.syncIdentifier = "\(dose.syncIdentifierAsString)\(addedCount+1)" + } + newEntries.append(newDose) + addedCount += 1 + } + } + if addedCount == 0 { + // automation history did not cover dose; mark automatic as default + var newDose = dose + newDose.automatic = true + newEntries.append(newDose) + } + } + + for dose in self { + switch dose.type { + case .tempBasal, .basal, .suspend: + if dose.automatic == nil { + annotateDoseWithAutomation(dose: dose) + } else { + newEntries.append(dose) + } + default: + newEntries.append(dose) + break + } + } + return newEntries + } + +} + +extension DoseEntry { + var simpleDesc: String { + let seconds = Int(duration) + let automatic = automatic?.description ?? "na" + return "\(startDate) (\(seconds)s) - \(type) - isMutable:\(isMutable) automatic:\(automatic) value:\(value) delivered:\(String(describing: deliveredUnits)) scheduled:\(String(describing: scheduledBasalRate)) syncId:\(String(describing: syncIdentifier))" + } +} + + diff --git a/TidepoolServiceKit/TidepoolService.swift b/TidepoolServiceKit/TidepoolService.swift index 3089277..1c24509 100644 --- a/TidepoolServiceKit/TidepoolService.swift +++ b/TidepoolServiceKit/TidepoolService.swift @@ -328,13 +328,38 @@ extension TidepoolService: RemoteDataService { public var doseDataLimit: Int? { return 1000 } + private func annotateDoses(_ doses: [DoseEntry]) async throws -> [DoseEntry] { + guard !doses.isEmpty else { + return [] + } + + guard let remoteDataServiceDelegate else { + throw TidepoolServiceError.configuration + } + + let start = doses.map { $0.startDate }.min()! + let end = doses.map { $0.endDate }.max()! + + let basal = try await remoteDataServiceDelegate.getBasalHistory(startDate: start, endDate: end) + let dosesWithBasal = doses.annotated(with: basal) + + let automationHistory = try await remoteDataServiceDelegate.automationHistory(from: start, to: end) + return dosesWithBasal.overlayAutomationHistory(automationHistory) + + } + public func uploadDoseData(created: [DoseEntry], deleted: [DoseEntry]) async throws { guard let userId = userId, let hostIdentifier = hostIdentifier, let hostVersion = hostVersion else { throw TidepoolServiceError.configuration } - let _ = try await createData(created.flatMap { $0.data(for: userId, hostIdentifier: hostIdentifier, hostVersion: hostVersion) }) - let _ = try await deleteData(withSelectors: deleted.flatMap { $0.selectors }) + // Syncidentifiers may be changed + let annotatedCreated = try await annotateDoses(created) + let _ = try await createData(annotatedCreated.flatMap { $0.data(for: userId, hostIdentifier: hostIdentifier, hostVersion: hostVersion) }) + + // annotating these so we get the correct syncIdentifiers to delete + let annotatedDeleted = try await annotateDoses(deleted) + let _ = try await deleteData(withSelectors: annotatedDeleted.flatMap { $0.selectors }) } public var dosingDecisionDataLimit: Int? { return 50 } // Each can be up to 20K bytes of serialized JSON, target ~1M or less diff --git a/TidepoolServiceKitTests/Extensions/DoseEntryTests.swift b/TidepoolServiceKitTests/Extensions/DoseEntryTests.swift index 5027623..06a2a10 100644 --- a/TidepoolServiceKitTests/Extensions/DoseEntryTests.swift +++ b/TidepoolServiceKitTests/Extensions/DoseEntryTests.swift @@ -13,6 +13,7 @@ import Foundation import HealthKit import LoopKit import TidepoolKit +import LoopAlgorithm @testable import TidepoolServiceKit class DoseEntryDataTests: XCTestCase { @@ -549,7 +550,8 @@ class DoseEntryDataTests: XCTestCase { } ], "deliveryType" : "automated", - "duration" : 0, + "duration" : 1200000, + "expectedDuration" : 1800000, "id" : "f839af02f6832d7c81d636dbbbadbc01", "insulinFormulation" : { "simple" : { @@ -720,5 +722,134 @@ class DoseEntrySelectorTests: XCTestCase { XCTAssertEqual(doseEntry.selectors, [TDatum.Selector(origin: TDatum.Selector.Origin(id: "ab0a722d639669875017a899a5214677:basal/automated"))]) } + func testOverlayAutomationHistory_NoAutomationHistory() { + let doses: [DoseEntry] = [ + DoseEntry(type: .basal, startDate: Date(), endDate: Date().addingTimeInterval(3600), value: 1.0, unit: .unitsPerHour, syncIdentifier: "test", automatic: nil, manuallyEntered: false, isMutable: false) + ] + let result = doses.overlayAutomationHistory([]) + + XCTAssertEqual(result.count, 1) + XCTAssertEqual(result[0].automatic, true) // Default to true when no automation history + } + + func testOverlayAutomationHistory_SingleAutomationPeriod() { + let now = Date() + let doses: [DoseEntry] = [ + DoseEntry(type: .basal, startDate: now, endDate: now.addingTimeInterval(3600), value: 1.0, unit: .unitsPerHour, syncIdentifier: "test", automatic: nil, manuallyEntered: false, isMutable: false) + ] + let automationHistory: [AbsoluteScheduleValue] = [ + AbsoluteScheduleValue(startDate: now, endDate: now.addingTimeInterval(3600), value: false) + ] + + let result = doses.overlayAutomationHistory(automationHistory) + + XCTAssertEqual(result.count, 1) + XCTAssertEqual(result[0].automatic, false) + } + + func testOverlayAutomationHistory_MultipleAutomationPeriods() { + let now = Date() + let doses: [DoseEntry] = [ + DoseEntry(type: .basal, startDate: now, endDate: now.addingTimeInterval(3600), value: 1.0, unit: .unitsPerHour, syncIdentifier: "test", automatic: nil, manuallyEntered: false, isMutable: false) + ] + let automationHistory: [AbsoluteScheduleValue] = [ + AbsoluteScheduleValue(startDate: now, endDate: now.addingTimeInterval(1800), value: false), + AbsoluteScheduleValue(startDate: now.addingTimeInterval(1800), endDate: now.addingTimeInterval(3600), value: true) + ] + + let result = doses.overlayAutomationHistory(automationHistory) + + XCTAssertEqual(result.count, 2) + XCTAssertEqual(result[0].automatic, false) + XCTAssertEqual(result[1].automatic, true) + } + + func testOverlayAutomationHistory_PartialOverlap() { + let now = Date() + let doses: [DoseEntry] = [ + DoseEntry(type: .basal, startDate: now, endDate: now.addingTimeInterval(3600), value: 1.0, unit: .unitsPerHour, syncIdentifier: "test", automatic: nil, manuallyEntered: false, isMutable: false) + ] + let automationHistory: [AbsoluteScheduleValue] = [ + AbsoluteScheduleValue(startDate: now.addingTimeInterval(1800), endDate: now.addingTimeInterval(4800), value: false) + ] + + let result = doses.overlayAutomationHistory(automationHistory) + + XCTAssertEqual(result.count, 2) + XCTAssertEqual(result[0].automatic, true) + XCTAssertEqual(result[1].automatic, false) + } + + + func testOverlayAutomationHistory_NonBasalDoses() { + let now = Date() + let doses: [DoseEntry] = [ + DoseEntry(type: .bolus, startDate: now, endDate: now.addingTimeInterval(300), value: 2.0, unit: .unitsPerHour, automatic: nil, manuallyEntered: false, isMutable: false), + DoseEntry(type: .basal, startDate: now.addingTimeInterval(300), endDate: now.addingTimeInterval(3600), value: 1.0, unit: .unitsPerHour, automatic: nil, manuallyEntered: false, isMutable: false) + ] + let automationHistory: [AbsoluteScheduleValue] = [ + AbsoluteScheduleValue(startDate: now, endDate: now.addingTimeInterval(3600), value: false) + ] + + let result = doses.overlayAutomationHistory(automationHistory) + + XCTAssertEqual(result.count, 2) + XCTAssertNil(result[0].automatic) // Bolus dose should remain unchanged + XCTAssertEqual(result[1].automatic, false) + } + + func testOverlayAutomationHistory_PreexistingAutomationFlag() { + let now = Date() + let doses: [DoseEntry] = [ + DoseEntry(type: .basal, startDate: now, endDate: now.addingTimeInterval(3600), value: 1.0, unit: .unitsPerHour, automatic: true, manuallyEntered: false, isMutable: false) + ] + let automationHistory: [AbsoluteScheduleValue] = [ + AbsoluteScheduleValue(startDate: now, endDate: now.addingTimeInterval(3600), value: false) + ] + + let result = doses.overlayAutomationHistory(automationHistory) + + XCTAssertEqual(result.count, 1) + XCTAssertEqual(result[0].automatic, true) // Should not change preexisting automation flag + } + + func testOverlayAutomationHistory_DeliveredUnitsAdjustment() { + let now = Date() + let doses: [DoseEntry] = [ + DoseEntry(type: .basal, startDate: now, endDate: now.addingTimeInterval(3600), value: 1.0, unit: .unitsPerHour, deliveredUnits: 1.0, syncIdentifier: "test", automatic: nil, manuallyEntered: false, isMutable: false) + ] + let automationHistory: [AbsoluteScheduleValue] = [ + AbsoluteScheduleValue(startDate: now, endDate: now.addingTimeInterval(1800), value: false), + AbsoluteScheduleValue(startDate: now.addingTimeInterval(1800), endDate: now.addingTimeInterval(3600), value: true) + ] + + let result = doses.overlayAutomationHistory(automationHistory) + + XCTAssertEqual(result.count, 2) + XCTAssertEqual(result[0].deliveredUnits!, 0.5, accuracy: 0.001) + XCTAssertEqual(result[0].automatic, false) + XCTAssertEqual(result[1].deliveredUnits!, 0.5, accuracy: 0.001) + XCTAssertEqual(result[1].automatic, true) + } + + func testOverlayAutomationHistory_MutableDose() { + let now = Date() + let doses: [DoseEntry] = [ + DoseEntry(type: .basal, startDate: now, endDate: now.addingTimeInterval(3600), value: 1.0, unit: .unitsPerHour, deliveredUnits: 1.0, syncIdentifier: "test", automatic: nil, manuallyEntered: false, isMutable: true) + ] + let automationHistory: [AbsoluteScheduleValue] = [ + AbsoluteScheduleValue(startDate: now, endDate: now.addingTimeInterval(1800), value: false), + AbsoluteScheduleValue(startDate: now.addingTimeInterval(1800), endDate: now.addingTimeInterval(3600), value: true) + ] + + let result = doses.overlayAutomationHistory(automationHistory) + + XCTAssertEqual(result.count, 1) + XCTAssertEqual(result[0].deliveredUnits!, 1, accuracy: 0.001) + XCTAssertEqual(result[0].automatic, false) + XCTAssertEqual(result[0].duration, TimeInterval(hours: 1)) + } + + private static let dateFormatter = ISO8601DateFormatter() }