|
|
@@ -40,7 +40,7 @@ extension Stat.StateModel {
|
|
|
let tddResults = try await fetchTDDStoredRecords()
|
|
|
|
|
|
// Fetch data for hourly statistics (BolusStored and TempBasalStored for day view)
|
|
|
- let (bolusResults, tempBasalResults) = try await fetchHourlyInsulinRecords()
|
|
|
+ let (bolusResults, tempBasalResults, suspendEvents, resumeEvents) = try await fetchHourlyInsulinRecords()
|
|
|
|
|
|
// MARK: - Process Data on Background Context
|
|
|
|
|
|
@@ -57,11 +57,15 @@ extension Stat.StateModel {
|
|
|
|
|
|
// Process hourly statistics from BolusStored and TempBasalStored
|
|
|
if let fetchedBoluses = bolusResults as? [BolusStored],
|
|
|
- let fetchedTempBasals = tempBasalResults as? [TempBasalStored]
|
|
|
+ let fetchedTempBasals = tempBasalResults as? [TempBasalStored],
|
|
|
+ let fetchedSuspendEvents = suspendEvents as? [PumpEventStored],
|
|
|
+ let fetchedResumeEvents = resumeEvents as? [PumpEventStored]
|
|
|
{
|
|
|
hourlyStats = self.processHourlyInsulinData(
|
|
|
boluses: fetchedBoluses,
|
|
|
tempBasals: fetchedTempBasals,
|
|
|
+ suspendEvents: fetchedSuspendEvents,
|
|
|
+ resumeEvents: fetchedResumeEvents,
|
|
|
calendar: calendar
|
|
|
)
|
|
|
}
|
|
|
@@ -92,7 +96,7 @@ extension Stat.StateModel {
|
|
|
/// Fetches BolusStored and TempBasalStored records from CoreData for hourly statistics
|
|
|
/// - Returns: A tuple containing the results of both fetch requests
|
|
|
/// - Note: Fetches records from the last 20 days for detailed hourly view
|
|
|
- private func fetchHourlyInsulinRecords() async throws -> (bolus: Any, tempBasal: Any) {
|
|
|
+ private func fetchHourlyInsulinRecords() async throws -> (bolus: Any, tempBasal: Any, suspendEvents: Any, resumeEvents: Any) {
|
|
|
// Calculate date range for hourly statistics (last 20 days)
|
|
|
let now = Date()
|
|
|
let twentyDaysAgo = Calendar.current.date(byAdding: .day, value: -20, to: now) ?? now
|
|
|
@@ -124,21 +128,67 @@ extension Stat.StateModel {
|
|
|
batchSize: 100
|
|
|
)
|
|
|
|
|
|
- return (bolusResults, tempBasalResults)
|
|
|
+ // Create a combined predicate for suspension and resume events
|
|
|
+ let suspendResumeTypes = [
|
|
|
+ PumpEventStored.EventType.pumpSuspend.rawValue,
|
|
|
+ PumpEventStored.EventType.pumpResume.rawValue
|
|
|
+ ]
|
|
|
+
|
|
|
+ let suspendResumePredicate = NSPredicate(
|
|
|
+ format: "timestamp >= %@ AND timestamp <= %@ AND type IN %@",
|
|
|
+ twentyDaysAgo as NSDate,
|
|
|
+ now as NSDate,
|
|
|
+ suspendResumeTypes
|
|
|
+ )
|
|
|
+
|
|
|
+ // Fetch both suspension and resume events in a single query
|
|
|
+ let suspendResumeResults = try await CoreDataStack.shared.fetchEntitiesAsync(
|
|
|
+ ofType: PumpEventStored.self,
|
|
|
+ onContext: tddTaskContext,
|
|
|
+ predicate: suspendResumePredicate,
|
|
|
+ key: "timestamp",
|
|
|
+ ascending: true,
|
|
|
+ batchSize: 100
|
|
|
+ )
|
|
|
+
|
|
|
+ // Filter the results within the context's perform closure to ensure thread safety
|
|
|
+ let (suspendEvents, resumeEvents) = await tddTaskContext.perform {
|
|
|
+ var suspendEventsArray: [PumpEventStored] = []
|
|
|
+ var resumeEventsArray: [PumpEventStored] = []
|
|
|
+
|
|
|
+ if let pumpEvents = suspendResumeResults as? [PumpEventStored] {
|
|
|
+ for event in pumpEvents {
|
|
|
+ if event.type == PumpEventStored.EventType.pumpSuspend.rawValue {
|
|
|
+ suspendEventsArray.append(event)
|
|
|
+ } else if event.type == PumpEventStored.EventType.pumpResume.rawValue {
|
|
|
+ resumeEventsArray.append(event)
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return (suspendEventsArray, resumeEventsArray)
|
|
|
+ }
|
|
|
+
|
|
|
+ return (bolusResults, tempBasalResults, suspendEvents, resumeEvents)
|
|
|
}
|
|
|
|
|
|
/// Processes bolus and temporary basal data to create hourly insulin statistics
|
|
|
/// - Parameters:
|
|
|
/// - boluses: Array of BolusStored objects containing bolus insulin data
|
|
|
/// - tempBasals: Array of TempBasalStored objects containing temporary basal rate data
|
|
|
+ /// - suspendEvents: Array of PumpEventStored objects with type pumpSuspend
|
|
|
+ /// - resumeEvents: Array of PumpEventStored objects with type pumpResume
|
|
|
/// - calendar: Calendar instance used for date calculations and grouping
|
|
|
/// - Returns: Array of TDDStats objects representing hourly insulin amounts
|
|
|
/// - Note: This method calculates the actual duration of temporary basal rates by using the time
|
|
|
/// difference between consecutive events, rather than relying on the planned duration.
|
|
|
/// It also properly distributes insulin amounts across hour boundaries for accurate hourly statistics.
|
|
|
+ /// Suspension events are taken into account to prevent counting insulin during pump suspensions.
|
|
|
private func processHourlyInsulinData(
|
|
|
boluses: [BolusStored],
|
|
|
tempBasals: [TempBasalStored],
|
|
|
+ suspendEvents: [PumpEventStored],
|
|
|
+ resumeEvents: [PumpEventStored],
|
|
|
calendar: Calendar
|
|
|
) -> [TDDStats] {
|
|
|
// Dictionary to store insulin amounts indexed by hour
|
|
|
@@ -162,6 +212,11 @@ extension Stat.StateModel {
|
|
|
insulinByHour[hourDate, default: 0] += amount
|
|
|
}
|
|
|
|
|
|
+ // MARK: - Create Suspend-Resume Pairs
|
|
|
+
|
|
|
+ // Create pairs of suspend and resume events
|
|
|
+ let suspendResumePairs = createSuspendResumePairs(suspendEvents: suspendEvents, resumeEvents: resumeEvents)
|
|
|
+
|
|
|
// MARK: - Process Temporary Basal Insulin
|
|
|
|
|
|
// Sort temp basals chronologically for accurate duration calculation
|
|
|
@@ -202,10 +257,12 @@ extension Stat.StateModel {
|
|
|
// MARK: Distribute Insulin Across Hours
|
|
|
|
|
|
// Handle temp basals that span multiple hours by distributing insulin appropriately
|
|
|
+ // taking into account suspension periods
|
|
|
distributeInsulinAcrossHours(
|
|
|
startTime: timestamp,
|
|
|
durationInHours: durationInHours,
|
|
|
rate: rate,
|
|
|
+ suspendResumePairs: suspendResumePairs,
|
|
|
insulinByHour: &insulinByHour,
|
|
|
calendar: calendar
|
|
|
)
|
|
|
@@ -222,20 +279,57 @@ extension Stat.StateModel {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ /// Creates pairs of suspend and resume events
|
|
|
+ /// - Parameters:
|
|
|
+ /// - suspendEvents: Array of PumpEventStored objects with type pumpSuspend
|
|
|
+ /// - resumeEvents: Array of PumpEventStored objects with type pumpResume
|
|
|
+ /// - Returns: Array of tuples containing suspend and resume event pairs
|
|
|
+ /// - Note: This method pairs suspend events with the next resume event chronologically
|
|
|
+ private func createSuspendResumePairs(
|
|
|
+ suspendEvents: [PumpEventStored],
|
|
|
+ resumeEvents: [PumpEventStored]
|
|
|
+ ) -> [(suspend: PumpEventStored, resume: PumpEventStored)] {
|
|
|
+ // Sort events chronologically
|
|
|
+ let sortedSuspendEvents = suspendEvents.sorted { ($0.timestamp ?? Date.distantPast) < ($1.timestamp ?? Date.distantPast) }
|
|
|
+ let sortedResumeEvents = resumeEvents.sorted { ($0.timestamp ?? Date.distantPast) < ($1.timestamp ?? Date.distantPast) }
|
|
|
+
|
|
|
+ // Create pairs of suspend + resume events
|
|
|
+ var pairs: [(suspend: PumpEventStored, resume: PumpEventStored)] = []
|
|
|
+
|
|
|
+ // Iterate through suspend events and find matching resume events
|
|
|
+ for suspendEvent in sortedSuspendEvents {
|
|
|
+ guard let suspendTime = suspendEvent.timestamp else { continue }
|
|
|
+
|
|
|
+ // Find the first resume event that occurs after this suspend event
|
|
|
+ if let resumeEvent = sortedResumeEvents.first(where: {
|
|
|
+ guard let resumeTime = $0.timestamp else { return false }
|
|
|
+ return resumeTime > suspendTime
|
|
|
+ }) {
|
|
|
+ // Create a pair and add it to the array
|
|
|
+ pairs.append((suspend: suspendEvent, resume: resumeEvent))
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return pairs
|
|
|
+ }
|
|
|
+
|
|
|
/// Distributes insulin from a temporary basal rate across multiple hours
|
|
|
/// - Parameters:
|
|
|
/// - startTime: The start time of the temporary basal rate
|
|
|
/// - durationInHours: The duration of the temporary basal rate in hours
|
|
|
/// - rate: The insulin rate in units per hour (U/h)
|
|
|
+ /// - suspendResumePairs: Array of suspend-resume event pairs to account for suspension periods
|
|
|
/// - insulinByHour: Dictionary to store insulin amounts by hour (modified in-place)
|
|
|
/// - calendar: Calendar instance used for date calculations
|
|
|
/// - Note: This method handles the case where a temporary basal spans multiple hours by
|
|
|
/// calculating the exact amount of insulin delivered in each hour. It accounts for
|
|
|
- /// partial hours at the beginning and end of the temporary basal period.
|
|
|
+ /// partial hours at the beginning and end of the temporary basal period, as well as
|
|
|
+ /// suspension periods where no insulin is delivered.
|
|
|
private func distributeInsulinAcrossHours(
|
|
|
startTime: Date,
|
|
|
durationInHours: Double,
|
|
|
rate: Double,
|
|
|
+ suspendResumePairs: [(suspend: PumpEventStored, resume: PumpEventStored)],
|
|
|
insulinByHour: inout [Date: Double],
|
|
|
calendar: Calendar
|
|
|
) {
|
|
|
@@ -249,6 +343,9 @@ extension Stat.StateModel {
|
|
|
return // Exit if we can't create a valid hour date
|
|
|
}
|
|
|
|
|
|
+ // Calculate end time of the temp basal
|
|
|
+ let endTime = startTime.addingTimeInterval(durationInHours * 3600)
|
|
|
+
|
|
|
// MARK: - Handle First Hour (Partial)
|
|
|
|
|
|
// Calculate how many minutes remain in the first hour after the start time
|
|
|
@@ -257,10 +354,20 @@ extension Stat.StateModel {
|
|
|
// Calculate how many hours of the temp basal occur in the first hour (capped at remaining time)
|
|
|
let hoursInFirstHour = min(durationInHours, minutesInFirstHour / 60.0)
|
|
|
|
|
|
- // Add insulin for the first partial hour
|
|
|
+ // Add insulin for the first partial hour, accounting for any suspensions
|
|
|
if hoursInFirstHour > 0 {
|
|
|
- // Insulin = rate (U/h) * fraction of hour
|
|
|
- insulinByHour[startHourDate, default: 0] += rate * hoursInFirstHour
|
|
|
+ // Calculate the end time of the first hour segment
|
|
|
+ let firstHourEndTime = startTime.addingTimeInterval(hoursInFirstHour * 3600)
|
|
|
+
|
|
|
+ // Calculate effective duration excluding suspension periods
|
|
|
+ let effectiveDuration = calculateEffectiveDuration(
|
|
|
+ from: startTime,
|
|
|
+ to: firstHourEndTime,
|
|
|
+ suspendResumePairs: suspendResumePairs
|
|
|
+ )
|
|
|
+
|
|
|
+ // Insulin = rate (U/h) * effective duration (h)
|
|
|
+ insulinByHour[startHourDate, default: 0] += rate * effectiveDuration
|
|
|
}
|
|
|
|
|
|
// MARK: - Handle Subsequent Hours
|
|
|
@@ -276,8 +383,20 @@ extension Stat.StateModel {
|
|
|
// Calculate how much of this hour is covered (max 1 hour)
|
|
|
let hoursToAdd = min(remainingDuration, 1.0)
|
|
|
|
|
|
- // Add insulin for this hour: rate (U/h) * fraction of hour
|
|
|
- insulinByHour[currentHourDate, default: 0] += rate * hoursToAdd
|
|
|
+ // Calculate the start and end times for this hour segment
|
|
|
+ let hourStartTime = calendar
|
|
|
+ .date(from: calendar.dateComponents([.year, .month, .day, .hour], from: currentHourDate)) ?? currentHourDate
|
|
|
+ let hourEndTime = hourStartTime.addingTimeInterval(hoursToAdd * 3600)
|
|
|
+
|
|
|
+ // Calculate effective duration excluding suspension periods
|
|
|
+ let effectiveDuration = calculateEffectiveDuration(
|
|
|
+ from: hourStartTime,
|
|
|
+ to: hourEndTime,
|
|
|
+ suspendResumePairs: suspendResumePairs
|
|
|
+ )
|
|
|
+
|
|
|
+ // Add insulin for this hour: rate (U/h) * effective duration (h)
|
|
|
+ insulinByHour[currentHourDate, default: 0] += rate * effectiveDuration
|
|
|
|
|
|
// Reduce remaining duration and move to next hour
|
|
|
remainingDuration -= hoursToAdd
|
|
|
@@ -285,6 +404,46 @@ extension Stat.StateModel {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ /// Calculates the effective duration of insulin delivery, excluding suspension periods
|
|
|
+ /// - Parameters:
|
|
|
+ /// - startTime: The start time of the period
|
|
|
+ /// - endTime: The end time of the period
|
|
|
+ /// - suspendResumePairs: Array of suspend-resume event pairs
|
|
|
+ /// - Returns: The effective duration in hours, excluding suspension periods
|
|
|
+ /// - Note: This method calculates how much of a time period was not affected by pump suspensions
|
|
|
+ private func calculateEffectiveDuration(
|
|
|
+ from startTime: Date,
|
|
|
+ to endTime: Date,
|
|
|
+ suspendResumePairs: [(suspend: PumpEventStored, resume: PumpEventStored)]
|
|
|
+ ) -> Double {
|
|
|
+ // Total duration in hours
|
|
|
+ let totalDuration = endTime.timeIntervalSince(startTime) / 3600.0
|
|
|
+
|
|
|
+ // Calculate total suspended time within this period
|
|
|
+ var suspendedDuration = 0.0
|
|
|
+
|
|
|
+ for pair in suspendResumePairs {
|
|
|
+ guard let suspendTime = pair.suspend.timestamp,
|
|
|
+ let resumeTime = pair.resume.timestamp
|
|
|
+ else {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+
|
|
|
+ // Check if this suspension overlaps with our period
|
|
|
+ if suspendTime < endTime, resumeTime > startTime {
|
|
|
+ // Calculate overlap start and end
|
|
|
+ let overlapStart = max(startTime, suspendTime)
|
|
|
+ let overlapEnd = min(endTime, resumeTime)
|
|
|
+
|
|
|
+ // Add the overlapping duration to our suspended time
|
|
|
+ suspendedDuration += overlapEnd.timeIntervalSince(overlapStart) / 3600.0
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // Return effective duration (total minus suspended)
|
|
|
+ return max(0.0, totalDuration - suspendedDuration)
|
|
|
+ }
|
|
|
+
|
|
|
/// Processes TDDStored records to create daily Total Daily Dose statistics
|
|
|
/// - Parameters:
|
|
|
/// - tdds: Array of TDDStored objects containing daily insulin data
|