|
@@ -0,0 +1,265 @@
|
|
|
|
|
+import Foundation
|
|
|
|
|
+import Swinject
|
|
|
|
|
+
|
|
|
|
|
+protocol TDDStorage {
|
|
|
|
|
+ func calculateTDD(pumpHistory: [PumpHistoryEvent], basalProfile: [BasalProfileEntry]) async -> TDDResult
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Structure containing the results of TDD calculations
|
|
|
|
|
+struct TDDResult {
|
|
|
|
|
+ let total: Decimal
|
|
|
|
|
+ let bolus: Decimal
|
|
|
|
|
+ let tempBasal: Decimal
|
|
|
|
|
+ let scheduledBasal: Decimal
|
|
|
|
|
+ let weightedAverage: Decimal?
|
|
|
|
|
+ let hoursOfData: Double
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Implementation of the TDD Calculator
|
|
|
|
|
+final class BaseTDDStorage: TDDStorage, Injectable {
|
|
|
|
|
+ init(resolver: Resolver) {
|
|
|
|
|
+ injectServices(resolver)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Main function to calculate TDD from pump history and basal profile
|
|
|
|
|
+ /// - Parameters:
|
|
|
|
|
+ /// - pumpHistory: Array of pump history events
|
|
|
|
|
+ /// - basalProfile: Array of basal profile entries
|
|
|
|
|
+ /// - Returns: TDDResult containing all calculated values
|
|
|
|
|
+ func calculateTDD(pumpHistory: [PumpHistoryEvent], basalProfile: [BasalProfileEntry]) async -> TDDResult {
|
|
|
|
|
+ debug(.apsManager, "Starting TDD calculation with \(pumpHistory.count) pump events")
|
|
|
|
|
+
|
|
|
|
|
+ var bolusInsulin: Decimal = 0
|
|
|
|
|
+ var tempInsulin: Decimal = 0
|
|
|
|
|
+ var scheduledBasalInsulin: Decimal = 0
|
|
|
|
|
+
|
|
|
|
|
+ let pumpData = calculatePumpDataHours(pumpHistory)
|
|
|
|
|
+ debug(.apsManager, "Hours of pump data available: \(pumpData)")
|
|
|
|
|
+
|
|
|
|
|
+ if pumpData < 23.9, pumpData > 21 {
|
|
|
|
|
+ let missingHours = 24 - pumpData
|
|
|
|
|
+ debug(.apsManager, "Filling \(missingHours) missing hours with scheduled basals")
|
|
|
|
|
+ if let lastEntry = pumpHistory.last {
|
|
|
|
|
+ let endDate = lastEntry.timestamp
|
|
|
|
|
+ let endDateAdjusted = endDate.addingTimeInterval(-missingHours * 3600)
|
|
|
|
|
+ scheduledBasalInsulin = calculateScheduledBasalInsulin(
|
|
|
|
|
+ from: endDate,
|
|
|
|
|
+ to: endDateAdjusted,
|
|
|
|
|
+ basalProfile: basalProfile
|
|
|
|
|
+ )
|
|
|
|
|
+ debug(.apsManager, "Added scheduled basal insulin: \(scheduledBasalInsulin)U")
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ bolusInsulin = calculateBolusInsulin(pumpHistory)
|
|
|
|
|
+ debug(.apsManager, "Total bolus insulin: \(bolusInsulin)U")
|
|
|
|
|
+
|
|
|
|
|
+ tempInsulin = calculateTempBasalInsulin(pumpHistory)
|
|
|
|
|
+ debug(.apsManager, "Total temp basal insulin: \(tempInsulin)U")
|
|
|
|
|
+
|
|
|
|
|
+ let total = bolusInsulin + tempInsulin + scheduledBasalInsulin
|
|
|
|
|
+ let weightedAverage = calculateWeightedAverage()
|
|
|
|
|
+
|
|
|
|
|
+ debug(.apsManager, """
|
|
|
|
|
+ TDD Summary:
|
|
|
|
|
+ - Total: \(total)U
|
|
|
|
|
+ - Bolus: \(bolusInsulin)U (\((bolusInsulin / total * 100).rounded(toPlaces: 1))%)
|
|
|
|
|
+ - Temp Basal: \(tempInsulin)U (\((tempInsulin / total * 100).rounded(toPlaces: 1))%)
|
|
|
|
|
+ - Scheduled Basal: \(scheduledBasalInsulin)U (\((scheduledBasalInsulin / total * 100).rounded(toPlaces: 1))%)
|
|
|
|
|
+ - weightedAverage: \(weightedAverage ?? 0)
|
|
|
|
|
+ - Hours of Data: \(pumpData)
|
|
|
|
|
+ """)
|
|
|
|
|
+
|
|
|
|
|
+ return TDDResult(
|
|
|
|
|
+ total: total,
|
|
|
|
|
+ bolus: bolusInsulin,
|
|
|
|
|
+ tempBasal: tempInsulin,
|
|
|
|
|
+ scheduledBasal: scheduledBasalInsulin,
|
|
|
|
|
+ weightedAverage: weightedAverage,
|
|
|
|
|
+ hoursOfData: pumpData
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Calculates the number of hours of available pump history data
|
|
|
|
|
+ /// - Parameter pumpHistory: Array of pump history events
|
|
|
|
|
+ /// - Returns: Number of hours of available data
|
|
|
|
|
+ private func calculatePumpDataHours(_ pumpHistory: [PumpHistoryEvent]) -> Double {
|
|
|
|
|
+ guard let firstEvent = pumpHistory.last, // we are fetching in a descending order
|
|
|
|
|
+ let lastEvent = pumpHistory.first
|
|
|
|
|
+ else {
|
|
|
|
|
+ return 0
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let startDate = firstEvent.timestamp
|
|
|
|
|
+ var endDate = lastEvent.timestamp
|
|
|
|
|
+
|
|
|
|
|
+ // If last event is a temp basal, use current time
|
|
|
|
|
+ if lastEvent.type == .tempBasalDuration {
|
|
|
|
|
+ endDate = Date()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return Double(endDate.timeIntervalSince(startDate)) / 3600.0
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Calculates total bolus insulin from pump history
|
|
|
|
|
+ /// - Parameter pumpHistory: Array of pump history events
|
|
|
|
|
+ /// - Returns: Total bolus insulin
|
|
|
|
|
+ private func calculateBolusInsulin(_ pumpHistory: [PumpHistoryEvent]) -> Decimal {
|
|
|
|
|
+ pumpHistory
|
|
|
|
|
+ .filter { $0.type == .bolus }
|
|
|
|
|
+ .reduce(Decimal(0)) { sum, event in
|
|
|
|
|
+ sum + (event.amount ?? 0)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Calculates insulin delivered via temporary basal rates
|
|
|
|
|
+ /// - Parameter pumpHistory: Array of pump history events
|
|
|
|
|
+ /// - Returns: Total temporary basal insulin
|
|
|
|
|
+ private func calculateTempBasalInsulin(_ pumpHistory: [PumpHistoryEvent]) -> Decimal {
|
|
|
|
|
+ var totalInsulin: Decimal = 0
|
|
|
|
|
+
|
|
|
|
|
+ for (index, event) in pumpHistory.enumerated() {
|
|
|
|
|
+ guard event.type == .tempBasal,
|
|
|
|
|
+ let rate = event.amount,
|
|
|
|
|
+ rate > 0,
|
|
|
|
|
+ index > 0 else { continue }
|
|
|
|
|
+
|
|
|
|
|
+ let duration = Decimal(pumpHistory[index - 1].duration ?? 0) / 60 // Convert to hours
|
|
|
|
|
+ let insulin = accountForIncrements(rate * duration)
|
|
|
|
|
+ totalInsulin += insulin
|
|
|
|
|
+
|
|
|
|
|
+ debug(.apsManager, "Temp basal: \(rate)U/hr for \(duration)hr = \(insulin)U")
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return totalInsulin
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Calculates insulin delivered via scheduled basal rates
|
|
|
|
|
+ /// - Parameters:
|
|
|
|
|
+ /// - from: Start date
|
|
|
|
|
+ /// - to: End date
|
|
|
|
|
+ /// - basalProfile: Array of basal profile entries
|
|
|
|
|
+ /// - Returns: Total scheduled basal insulin
|
|
|
|
|
+ private func calculateScheduledBasalInsulin(from: Date, to: Date, basalProfile: [BasalProfileEntry]) -> Decimal {
|
|
|
|
|
+ var totalInsulin: Decimal = 0
|
|
|
|
|
+ var currentDate = from
|
|
|
|
|
+
|
|
|
|
|
+ while currentDate < to {
|
|
|
|
|
+ let timeString = makeBaseString(from: currentDate)
|
|
|
|
|
+ guard let basalRate = findBasalRate(for: timeString, in: basalProfile) else { continue }
|
|
|
|
|
+
|
|
|
|
|
+ let nextScheduleTime = findNextScheduleTime(after: timeString, in: basalProfile)
|
|
|
|
|
+ let duration = calculateDuration(currentTime: timeString, nextScheduleTime: nextScheduleTime, endDate: to)
|
|
|
|
|
+
|
|
|
|
|
+ let insulin = accountForIncrements(basalRate * Decimal(duration))
|
|
|
|
|
+ totalInsulin += insulin
|
|
|
|
|
+
|
|
|
|
|
+ currentDate = currentDate.addingTimeInterval(duration * 3600)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return totalInsulin
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Rounds insulin amounts according to pump increment constraints
|
|
|
|
|
+ /// - Parameter insulin: Raw insulin amount
|
|
|
|
|
+ /// - Returns: Rounded insulin amount
|
|
|
|
|
+ private func accountForIncrements(_ insulin: Decimal) -> Decimal {
|
|
|
|
|
+ let minimalDose: Decimal = 0.05 // For Omnipod, 0.1 for other pumps
|
|
|
|
|
+ let incrementsRaw = insulin / minimalDose
|
|
|
|
|
+
|
|
|
|
|
+ if incrementsRaw >= 1 {
|
|
|
|
|
+ // Convert to NSDecimalNumber to use its rounding capabilities
|
|
|
|
|
+ let nsIncrements = NSDecimalNumber(decimal: incrementsRaw)
|
|
|
|
|
+ let roundedIncrements = nsIncrements.rounding(
|
|
|
|
|
+ accordingToBehavior:
|
|
|
|
|
+ NSDecimalNumberHandler(
|
|
|
|
|
+ roundingMode: .down,
|
|
|
|
|
+ scale: 0,
|
|
|
|
|
+ raiseOnExactness: false,
|
|
|
|
|
+ raiseOnOverflow: false,
|
|
|
|
|
+ raiseOnUnderflow: false,
|
|
|
|
|
+ raiseOnDivideByZero: false
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ return (roundedIncrements.decimalValue * minimalDose).rounded(toPlaces: 3)
|
|
|
|
|
+ }
|
|
|
|
|
+ return 0
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Formats a date to time string in "HH:mm:ss" format
|
|
|
|
|
+ /// - Parameter date: Date to format
|
|
|
|
|
+ /// - Returns: Formatted time string
|
|
|
|
|
+ private func makeBaseString(from date: Date) -> String {
|
|
|
|
|
+ let formatter = DateFormatter()
|
|
|
|
|
+ formatter.dateFormat = "HH:mm:ss"
|
|
|
|
|
+ return formatter.string(from: date)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Finds the basal rate for a specific time in the profile
|
|
|
|
|
+ /// - Parameters:
|
|
|
|
|
+ /// - timeString: Time string in "HH:mm:ss" format
|
|
|
|
|
+ /// - profile: Array of basal profile entries
|
|
|
|
|
+ /// - Returns: Basal rate if found
|
|
|
|
|
+ private func findBasalRate(for timeString: String, in profile: [BasalProfileEntry]) -> Decimal? {
|
|
|
|
|
+ profile.first { $0.start == timeString }?.rate
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Finds the next scheduled time in the basal profile
|
|
|
|
|
+ /// - Parameters:
|
|
|
|
|
+ /// - time: Current time string
|
|
|
|
|
+ /// - profile: Array of basal profile entries
|
|
|
|
|
+ /// - Returns: Next scheduled time
|
|
|
|
|
+ private func findNextScheduleTime(after time: String, in profile: [BasalProfileEntry]) -> String {
|
|
|
|
|
+ guard let currentIndex = profile.firstIndex(where: { $0.start == time }) else {
|
|
|
|
|
+ return profile[0].start
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let nextIndex = (currentIndex + 1) % profile.count
|
|
|
|
|
+ return profile[nextIndex].start
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Calculates duration between two schedule times
|
|
|
|
|
+ /// - Parameters:
|
|
|
|
|
+ /// - currentTime: Current time string
|
|
|
|
|
+ /// - nextScheduleTime: Next schedule time string
|
|
|
|
|
+ /// - endDate: End date for calculations
|
|
|
|
|
+ /// - Returns: Duration in hours
|
|
|
|
|
+ private func calculateDuration(currentTime: String, nextScheduleTime: String, endDate _: Date) -> Double {
|
|
|
|
|
+ let formatter = DateFormatter()
|
|
|
|
|
+ formatter.dateFormat = "HH:mm:ss"
|
|
|
|
|
+
|
|
|
|
|
+ guard let time1 = formatter.date(from: currentTime),
|
|
|
|
|
+ let time2 = formatter.date(from: nextScheduleTime)
|
|
|
|
|
+ else {
|
|
|
|
|
+ return 0
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ var difference = time2.timeIntervalSince(time1) / 3600
|
|
|
|
|
+ if difference < 0 {
|
|
|
|
|
+ difference += 24
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return difference
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Calculates weighted average of TDD from historical data
|
|
|
|
|
+ /// - Returns: Weighted average if available
|
|
|
|
|
+ private func calculateWeightedAverage() -> Decimal? {
|
|
|
|
|
+ // Implementation of weighted average calculation
|
|
|
|
|
+ // Would use historical TDD data from Core Data
|
|
|
|
|
+ nil
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Extension for rounding Decimal numbers
|
|
|
|
|
+extension Decimal {
|
|
|
|
|
+ /// Rounds a decimal to specified number of places
|
|
|
|
|
+ /// - Parameter places: Number of decimal places
|
|
|
|
|
+ /// - Returns: Rounded decimal
|
|
|
|
|
+ func rounded(toPlaces places: Int) -> Decimal {
|
|
|
|
|
+ var value = self
|
|
|
|
|
+ var result = Decimal()
|
|
|
|
|
+ NSDecimalRound(&result, &value, places, .plain)
|
|
|
|
|
+ return result
|
|
|
|
|
+ }
|
|
|
|
|
+}
|