Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

LOOP-5071 Overlay basal and automation history #108

Merged
merged 3 commits into from
Oct 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 191 additions & 2 deletions TidepoolServiceKit/Extensions/DoseEntry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

import LoopKit
import TidepoolKit
import LoopAlgorithm
import HealthKit

/*
DoseEntry
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<Double>]) -> [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<Double>]) -> [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<Bool>]
) -> [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))"
}
}


29 changes: 27 additions & 2 deletions TidepoolServiceKit/TidepoolService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading