Skip to content

Commit 1e74a3c

Browse files
Phase 6: Performance optimizations, enhanced activity data, and meal card fixes
P1: Parallel HealthKit queries via async let (6 concurrent fetches) P2: Single-pass TIR zone counting (5-zone) replacing multiple filter passes P3: Pre-fetch raw data in DataAggregator, cache for cross-component reuse P4: Binary search for glucose lookups in FoodResponseAnalyzer P5: Pre-sorted glucose samples with binary search in AdvancedAnalyzers P6: Pre-compute AGP data in ViewModel instead of SwiftUI view body P7: Static DateFormatter in LoopInsightsTimeBlock.formatTime P8: Pre-sort schedule items before dose loops, pre-sort in ViewModel P9: Pre-convert glucose to parallel arrays avoiding repeated doubleValue calls P10: Pass precomputed hourly averages to circadian profile builder Also: enhanced step/activity data in AI prompts with time-of-day breakdowns and activity-glucose correlation analysis (2h lag), and meal card layout cleanup. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e62c500 commit 1e74a3c

12 files changed

Lines changed: 422 additions & 207 deletions

Loop/Localizable.xcstrings

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -629,6 +629,18 @@
629629
}
630630
}
631631
},
632+
"%@ [%lld chars]" : {
633+
"comment" : "A view that displays a meal with its food type, date, and glucose response. The \"Pre-Meal Advice\" tab in the Loop Insights app uses this view to show individual meal cards.",
634+
"isCommentAutoGenerated" : true,
635+
"localizations" : {
636+
"en" : {
637+
"stringUnit" : {
638+
"state" : "new",
639+
"value" : "%1$@ [%2$lld chars]"
640+
}
641+
}
642+
}
643+
},
632644
"%@ %@" : {
633645
"comment" : "The format for an active custom preset. (1: preset symbol)(2: preset name)",
634646
"localizations" : {
@@ -33872,10 +33884,10 @@
3387233884
"Review recommended — significant adjustments may help" : {
3387333885
"comment" : "LoopInsights score: review"
3387433886
},
33875-
"Rise > 50 mg/dL" : {
33887+
"Rise is > 50 mg/dL" : {
3387633888
"comment" : "LoopInsights meal legend orange"
3387733889
},
33878-
"Rise ≤ 50 mg/dL" : {
33890+
"Rise is ≤ 50 mg/dL" : {
3387933891
"comment" : "LoopInsights meal legend green"
3388033892
},
3388133893
"Rise: %+.0f mg/dL" : {

Loop/Managers/LoopInsights/LoopInsights_Coordinator.swift

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -123,16 +123,19 @@ final class LoopInsights_Coordinator: ObservableObject {
123123

124124
/// Build supplemental context for AI prompt enrichment from Phase 5 analyzers.
125125
/// Returns nil if no Phase 5 features are enabled.
126+
/// P3: Accept pre-fetched glucose + carbs to avoid duplicate data fetches.
127+
/// P10: Pass hourly averages to circadian profile builder.
126128
func buildSupplementalContext(
127129
stats: LoopInsightsAggregatedStats,
128-
glucoseSamples: [StoredGlucoseSample]? = nil
130+
glucoseSamples: [StoredGlucoseSample]? = nil,
131+
carbEntries: [StoredCarbEntry]? = nil
129132
) async -> String? {
130133
var context: [String] = []
131134

132135
let start = Date().addingTimeInterval(-stats.period.timeInterval)
133136
let end = Date()
134137

135-
// Fetch glucose samples once if not provided
138+
// P3: Use pre-fetched glucose, fall back to bridge only if not provided
136139
var resolvedGlucose: [StoredGlucoseSample]? = glucoseSamples
137140
if resolvedGlucose == nil, let bridge = dataProviderBridge {
138141
resolvedGlucose = try? await bridge.getGlucoseSamples(start: start, end: end)
@@ -142,9 +145,11 @@ final class LoopInsights_Coordinator: ObservableObject {
142145
if LoopInsights_FeatureFlags.circadianEnabled {
143146
// Circadian profile from glucose + sleep data
144147
if let samples = resolvedGlucose {
148+
// P10: Pass pre-computed hourly averages to avoid re-bucketing
145149
if let profile = LoopInsights_AdvancedAnalyzers.buildCircadianProfile(
146150
glucoseSamples: samples,
147-
sleepStats: stats.biometricStats?.sleep
151+
sleepStats: stats.biometricStats?.sleep,
152+
precomputedHourlyAverages: stats.glucoseStats.hourlyAverages
148153
) {
149154
context.append(LoopInsights_AdvancedAnalyzers.buildCircadianPromptContext(profile))
150155
}
@@ -163,11 +168,14 @@ final class LoopInsights_Coordinator: ObservableObject {
163168

164169
// Food response patterns
165170
if LoopInsights_FeatureFlags.foodResponseEnabled {
166-
if let bridge = dataProviderBridge,
167-
let carbEntries = try? await bridge.getCarbEntries(start: start, end: end),
168-
let glucSamples = resolvedGlucose {
171+
// P3: Use pre-fetched carbs, fall back to bridge only if not provided
172+
var resolvedCarbs: [StoredCarbEntry]? = carbEntries
173+
if resolvedCarbs == nil, let bridge = dataProviderBridge {
174+
resolvedCarbs = try? await bridge.getCarbEntries(start: start, end: end)
175+
}
176+
if let carbs = resolvedCarbs, let glucSamples = resolvedGlucose {
169177
let patterns = LoopInsights_FoodResponseAnalyzer.analyzeFoodResponses(
170-
carbEntries: carbEntries,
178+
carbEntries: carbs,
171179
glucoseSamples: glucSamples
172180
)
173181
let foodCtx = LoopInsights_FoodResponseAnalyzer.buildFoodResponsePromptContext(patterns)

Loop/Models/LoopInsights/LoopInsights_Models.swift

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -531,15 +531,17 @@ struct LoopInsightsTimeBlock: Codable, Identifiable, Equatable {
531531
return ((proposedValue - currentValue) / currentValue) * 100
532532
}
533533

534-
private static func formatTime(_ seconds: TimeInterval) -> String {
535-
let hours = Int(seconds) / 3600
536-
let minutes = (Int(seconds) % 3600) / 60
534+
private static let timeFormatter: DateFormatter = {
537535
let formatter = DateFormatter()
538-
formatter.dateFormat = hours >= 12 ? "h:mm a" : "h:mm a"
536+
formatter.dateFormat = "h:mm a"
537+
return formatter
538+
}()
539+
540+
private static func formatTime(_ seconds: TimeInterval) -> String {
539541
var calendar = Calendar.current
540542
calendar.timeZone = TimeZone.current
541543
let date = calendar.startOfDay(for: Date()).addingTimeInterval(seconds)
542-
return formatter.string(from: date)
544+
return timeFormatter.string(from: date)
543545
}
544546
}
545547

@@ -682,6 +684,7 @@ struct LoopInsightsAggregatedStats: Codable {
682684

683685
struct ActiveEnergyStats: Codable {
684686
let averageDailyCalories: Double
687+
let hourlyAverages: [Int: Double] // hour → avg kcal burned
685688
}
686689

687690
struct WeightStats: Codable {

Loop/Services/LoopInsights/LoopInsights_AIAnalysis.swift

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,44 @@ final class LoopInsights_AIAnalysis {
322322
if let steps = bio.steps {
323323
prompt += "### Steps/Activity\n"
324324
prompt += "- Average Daily Steps: \(String(format: "%.0f", steps.averageDailySteps))\n"
325+
if !steps.hourlyAverages.isEmpty {
326+
// Group into time-of-day activity levels
327+
let activityPeriods: [(name: String, hours: ClosedRange<Int>)] = [
328+
("Morning 6-10AM", 6...9), ("Midday 10AM-2PM", 10...13),
329+
("Afternoon 2-6PM", 14...17), ("Evening 6-10PM", 18...21)
330+
]
331+
for period in activityPeriods {
332+
let periodSteps = period.hours.compactMap { steps.hourlyAverages[$0] }
333+
if !periodSteps.isEmpty {
334+
let total = periodSteps.reduce(0, +)
335+
prompt += "- \(period.name): \(String(format: "%.0f", total)) avg steps\n"
336+
}
337+
}
338+
// Peak activity hour
339+
if let peakHour = steps.hourlyAverages.max(by: { $0.value < $1.value }) {
340+
prompt += "- Peak activity hour: \(String(format: "%02d", peakHour.key)):00 (\(String(format: "%.0f", peakHour.value)) steps)\n"
341+
}
342+
343+
// Activity-glucose correlation: compare high-activity hours to glucose
344+
let glucoseHourly = stats.glucoseStats.hourlyAverages
345+
if !glucoseHourly.isEmpty {
346+
var correlations: [String] = []
347+
let avgGlucose = stats.glucoseStats.averageGlucose
348+
for (hour, stepCount) in steps.hourlyAverages where stepCount > 200 {
349+
let postActivityHour = (hour + 2) % 24
350+
if let postGlucose = glucoseHourly[postActivityHour] {
351+
let delta = postGlucose - avgGlucose
352+
if abs(delta) > 10 {
353+
correlations.append("Activity at \(String(format: "%02d", hour)):00 → glucose \(delta > 0 ? "+" : "")\(String(format: "%.0f", delta)) mg/dL vs avg at \(String(format: "%02d", postActivityHour)):00")
354+
}
355+
}
356+
}
357+
if !correlations.isEmpty {
358+
prompt += "- **Activity-Glucose Correlations (2h lag)**:\n"
359+
for c in correlations.prefix(5) { prompt += " - \(c)\n" }
360+
}
361+
}
362+
}
325363
}
326364

327365
if let sleep = bio.sleep {
@@ -334,6 +372,19 @@ final class LoopInsights_AIAnalysis {
334372
if let energy = bio.activeEnergy {
335373
prompt += "### Active Energy\n"
336374
prompt += "- Average Daily Active Calories: \(String(format: "%.0f", energy.averageDailyCalories)) kcal\n"
375+
if !energy.hourlyAverages.isEmpty {
376+
let activityPeriods: [(name: String, hours: ClosedRange<Int>)] = [
377+
("Morning 6-10AM", 6...9), ("Midday 10AM-2PM", 10...13),
378+
("Afternoon 2-6PM", 14...17), ("Evening 6-10PM", 18...21)
379+
]
380+
for period in activityPeriods {
381+
let periodKcal = period.hours.compactMap { energy.hourlyAverages[$0] }
382+
if !periodKcal.isEmpty {
383+
let total = periodKcal.reduce(0, +)
384+
prompt += "- \(period.name): \(String(format: "%.0f", total)) avg kcal\n"
385+
}
386+
}
387+
}
337388
}
338389

339390
if let weight = bio.weight {

Loop/Services/LoopInsights/LoopInsights_AdvancedAnalyzers.swift

Lines changed: 64 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,14 @@ final class LoopInsights_AdvancedAnalyzers {
1919

2020
/// Build a circadian glucose profile using sleep data and glucose samples.
2121
/// Uses actual wake/bed times from HealthKit sleep data when available.
22+
/// P10: Accept optional pre-computed hourlyAverages to avoid re-bucketing glucose
2223
static func buildCircadianProfile(
2324
glucoseSamples: [StoredGlucoseSample],
24-
sleepStats: LoopInsightsAggregatedStats.SleepStats?
25+
sleepStats: LoopInsightsAggregatedStats.SleepStats?,
26+
precomputedHourlyAverages: [Int: Double]? = nil
2527
) -> LoopInsightsCircadianProfile? {
2628
guard !glucoseSamples.isEmpty else { return nil }
2729

28-
let calendar = Calendar.current
29-
3030
// Determine wake/bed hours from sleep data or use defaults
3131
let wakeHour: Int
3232
let bedHour: Int
@@ -38,31 +38,37 @@ final class LoopInsights_AdvancedAnalyzers {
3838
bedHour = 22
3939
}
4040

41-
// Bucket glucose by hour
42-
var hourlyBuckets: [Int: [Double]] = [:]
43-
for sample in glucoseSamples {
44-
let hour = calendar.component(.hour, from: sample.startDate)
45-
let value = sample.quantity.doubleValue(for: .milligramsPerDeciliter)
46-
hourlyBuckets[hour, default: []].append(value)
47-
}
48-
49-
let hourlyAvg: (Int) -> Double = { hour in
50-
guard let vals = hourlyBuckets[hour], !vals.isEmpty else { return 0 }
51-
return vals.reduce(0, +) / Double(vals.count)
41+
// P10: Reuse pre-computed hourly averages when available
42+
let hourlyAvg: (Int) -> Double
43+
if let precomputed = precomputedHourlyAverages {
44+
hourlyAvg = { hour in precomputed[hour] ?? 0 }
45+
} else {
46+
let calendar = Calendar.current
47+
var hourlyBuckets: [Int: [Double]] = [:]
48+
for sample in glucoseSamples {
49+
let hour = calendar.component(.hour, from: sample.startDate)
50+
let value = sample.quantity.doubleValue(for: .milligramsPerDeciliter)
51+
hourlyBuckets[hour, default: []].append(value)
52+
}
53+
hourlyAvg = { hour in
54+
guard let vals = hourlyBuckets[hour], !vals.isEmpty else { return 0 }
55+
return vals.reduce(0, +) / Double(vals.count)
56+
}
5257
}
5358

5459
// Pre-sleep: hour before bed
5560
let preSleepHour = (bedHour - 1 + 24) % 24
5661
let preSleepAvg = hourlyAvg(preSleepHour)
5762

58-
// Overnight: bed to wake
59-
var overnightValues: [Double] = []
63+
// Overnight: bed to wake (use hourlyAvg function to work with both precomputed and bucketed data)
64+
var overnightHourAvgs: [Double] = []
6065
var h = bedHour
6166
while h != wakeHour {
62-
if let vals = hourlyBuckets[h] { overnightValues.append(contentsOf: vals) }
67+
let avg = hourlyAvg(h)
68+
if avg > 0 { overnightHourAvgs.append(avg) }
6369
h = (h + 1) % 24
6470
}
65-
let overnightAvg = overnightValues.isEmpty ? 0 : overnightValues.reduce(0, +) / Double(overnightValues.count)
71+
let overnightAvg = overnightHourAvgs.isEmpty ? 0 : overnightHourAvgs.reduce(0, +) / Double(overnightHourAvgs.count)
6672

6773
// Wake glucose
6874
let wakeGlucose = hourlyAvg(wakeHour)
@@ -132,6 +138,12 @@ final class LoopInsights_AdvancedAnalyzers {
132138

133139
let totalMinutes = Double(periodDays) * 24 * 60
134140

141+
// P8: Pre-sort schedule items once instead of per-dose
142+
let sortedBasalItems = scheduledBasalItems.sorted { $0.startTime < $1.startTime }
143+
144+
// P5: Sort glucose samples by date for binary search overcorrection lookups
145+
let sortedGlucose = glucoseSamples.sorted { $0.startDate < $1.startDate }
146+
135147
for dose in doses {
136148
let durationMinutes = dose.endDate.timeIntervalSince(dose.startDate) / 60
137149
let hour = calendar.component(.hour, from: dose.startDate)
@@ -141,16 +153,23 @@ final class LoopInsights_AdvancedAnalyzers {
141153
totalSuspensionMinutes += durationMinutes
142154
hourlyDistribution[hour, default: 0] += durationMinutes
143155

144-
// Check for overcorrection: glucose > 180 within 2h after suspension ends
145-
let checkWindow = dose.endDate...dose.endDate.addingTimeInterval(2 * 3600)
146-
let reboundHigh = glucoseSamples.contains { sample in
147-
checkWindow.contains(sample.startDate) &&
148-
sample.quantity.doubleValue(for: .milligramsPerDeciliter) > 180
156+
// P5: Binary search for overcorrection check instead of linear scan
157+
let checkStart = dose.endDate
158+
let checkEnd = dose.endDate.addingTimeInterval(2 * 3600)
159+
let startIdx = Self.binarySearchFirstIndex(in: sortedGlucose, afterOrAt: checkStart)
160+
var reboundHigh = false
161+
for i in startIdx..<sortedGlucose.count {
162+
let sample = sortedGlucose[i]
163+
if sample.startDate > checkEnd { break }
164+
if sample.quantity.doubleValue(for: .milligramsPerDeciliter) > 180 {
165+
reboundHigh = true
166+
break
167+
}
149168
}
150169
if reboundHigh { overcorrectionEvents += 1 }
151170
} else if dose.type == .tempBasal {
152171
let rate = dose.unitsPerHour
153-
let scheduledRate = effectiveScheduledRate(at: dose.startDate, items: scheduledBasalItems)
172+
let scheduledRate = effectiveScheduledRate(at: dose.startDate, sortedItems: sortedBasalItems)
154173
if rate < scheduledRate {
155174
let minutes = durationMinutes
156175
subBasalMinutes += minutes
@@ -252,18 +271,18 @@ final class LoopInsights_AdvancedAnalyzers {
252271

253272
// MARK: - Helpers
254273

274+
/// Find the effective scheduled rate at a given date. Expects pre-sorted items (P8).
255275
private static func effectiveScheduledRate(
256276
at date: Date,
257-
items: [LoopInsightsTherapySnapshot.LoopInsightsScheduleItem]
277+
sortedItems: [LoopInsightsTherapySnapshot.LoopInsightsScheduleItem]
258278
) -> Double {
259279
let calendar = Calendar.current
260280
let secondsSinceMidnight = TimeInterval(
261281
calendar.component(.hour, from: date) * 3600 +
262282
calendar.component(.minute, from: date) * 60
263283
)
264-
let sorted = items.sorted { $0.startTime < $1.startTime }
265-
var result = sorted.first?.value ?? 0
266-
for item in sorted {
284+
var result = sortedItems.first?.value ?? 0
285+
for item in sortedItems {
267286
if item.startTime <= secondsSinceMidnight {
268287
result = item.value
269288
} else {
@@ -272,4 +291,21 @@ final class LoopInsights_AdvancedAnalyzers {
272291
}
273292
return result
274293
}
294+
295+
/// P5: Binary search to find the first glucose sample at or after `date` in a sorted array.
296+
static func binarySearchFirstIndex(
297+
in samples: [StoredGlucoseSample],
298+
afterOrAt date: Date
299+
) -> Int {
300+
var lo = 0, hi = samples.count
301+
while lo < hi {
302+
let mid = (lo + hi) / 2
303+
if samples[mid].startDate < date {
304+
lo = mid + 1
305+
} else {
306+
hi = mid
307+
}
308+
}
309+
return lo
310+
}
275311
}

0 commit comments

Comments
 (0)