Skip to content
Closed
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
33 changes: 23 additions & 10 deletions Loop/Managers/LoopDataManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -874,8 +874,8 @@ extension LoopDataManager {
logger.default("Loop ended")
notify(forChange: .loopFinished)

let carbEffectStart = now().addingTimeInterval(-MissedMealSettings.maxRecency)
carbStore.getGlucoseEffects(start: carbEffectStart, end: now(), effectVelocities: insulinCounteractionEffects) {[weak self] result in
let samplesStart = now().addingTimeInterval(-MissedMealSettings.maxRecency)
carbStore.getGlucoseEffects(start: samplesStart, end: now(), effectVelocities: insulinCounteractionEffects) {[weak self] result in
guard
let self = self,
case .success((_, let carbEffects)) = result
Expand All @@ -885,15 +885,28 @@ extension LoopDataManager {
}
return
}

self.mealDetectionManager.generateMissedMealNotificationIfNeeded(
insulinCounteractionEffects: self.insulinCounteractionEffects,
carbEffects: carbEffects,
pendingAutobolusUnits: self.recommendedAutomaticDose?.recommendation.bolusUnits,
bolusDurationEstimator: { [unowned self] bolusAmount in
return self.delegate?.loopDataManager(self, estimateBolusDuration: bolusAmount)

glucoseStore.getGlucoseSamples(start: samplesStart, end: now()) {[weak self] result in
guard
let self = self,
case .success(let glucoseSamples) = result
else {
if case .failure(let error) = result {
self?.logger.error("Failed to fetch glucose samples to check for missed meal: %{public}@", String(describing: error))
}
return
}
)

self.mealDetectionManager.generateMissedMealNotificationIfNeeded(
glucoseSamples: glucoseSamples,
insulinCounteractionEffects: self.insulinCounteractionEffects,
carbEffects: carbEffects,
pendingAutobolusUnits: self.recommendedAutomaticDose?.recommendation.bolusUnits,
bolusDurationEstimator: { [unowned self] bolusAmount in
return self.delegate?.loopDataManager(self, estimateBolusDuration: bolusAmount)
}
)
}
}

// 5 second delay to allow stores to cache data before it is read by widget
Expand Down
24 changes: 21 additions & 3 deletions Loop/Managers/Missed Meal Detection/MealDetectionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,22 @@ class MealDetectionManager {
}

// MARK: Meal Detection
func hasMissedMeal(insulinCounteractionEffects: [GlucoseEffectVelocity], carbEffects: [GlucoseEffect], completion: @escaping (MissedMealStatus) -> Void) {
func hasMissedMeal(glucoseSamples: [some GlucoseSampleValue], insulinCounteractionEffects: [GlucoseEffectVelocity], carbEffects: [GlucoseEffect], completion: @escaping (MissedMealStatus) -> Void) {
let delta = TimeInterval(minutes: 5)

let intervalStart = currentDate(timeIntervalSinceNow: -MissedMealSettings.maxRecency)
let intervalEnd = currentDate(timeIntervalSinceNow: -MissedMealSettings.minRecency)
let now = self.currentDate


let filteredGlucoseValues = glucoseSamples.filter { intervalStart <= $0.startDate && $0.startDate <= now }

/// Only try to detect if there's a missed meal if there are no calibration/user-entered BGs,
/// since these can cause large jumps
guard !filteredGlucoseValues.containsUserEntered() else {
completion(.noMissedMeal)
return
}

let filteredCarbEffects = carbEffects.filterDateRange(intervalStart, now)

/// Compute how much of the ICE effect we can't explain via our entered carbs
Expand Down Expand Up @@ -213,12 +222,13 @@ class MealDetectionManager {
/// - pendingAutobolusUnits: any autobolus units that are still being delivered. Used to delay the missed meal notification to avoid notifying during an autobolus.
/// - bolusDurationEstimator: estimator of bolus duration that takes the units of the bolus as an input. Used to delay the missed meal notification to avoid notifying during an autobolus.
func generateMissedMealNotificationIfNeeded(
glucoseSamples: [some GlucoseSampleValue],
insulinCounteractionEffects: [GlucoseEffectVelocity],
carbEffects: [GlucoseEffect],
pendingAutobolusUnits: Double? = nil,
bolusDurationEstimator: @escaping (Double) -> TimeInterval?
) {
hasMissedMeal(insulinCounteractionEffects: insulinCounteractionEffects, carbEffects: carbEffects) {[weak self] status in
hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: insulinCounteractionEffects, carbEffects: carbEffects) {[weak self] status in
self?.manageMealNotifications(for: status, pendingAutobolusUnits: pendingAutobolusUnits, bolusDurationEstimator: bolusDurationEstimator)
}
}
Expand Down Expand Up @@ -294,3 +304,11 @@ class MealDetectionManager {
completionHandler(report.joined(separator: "\n"))
}
}

fileprivate extension BidirectionalCollection where Element: GlucoseSampleValue, Index == Int {
/// Returns whether there are any user-entered or calibration points
/// Runtime: O(n)
func containsUserEntered() -> Bool {
return !isCalibrated() || filter({ $0.wasUserEntered }).count != 0
}
}
72 changes: 65 additions & 7 deletions LoopTests/Managers/MealDetectionManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,22 @@ import LoopCore
import LoopKit
@testable import Loop

fileprivate class MockGlucoseSample: GlucoseSampleValue {
let provenanceIdentifier = ""
let isDisplayOnly: Bool
let wasUserEntered: Bool
let condition: LoopKit.GlucoseCondition? = nil
let trendRate: HKQuantity? = nil
let quantity: HKQuantity = HKQuantity(unit: .milligramsPerDeciliter, doubleValue: 100)
let startDate: Date

init(startDate: Date, isDisplayOnly: Bool = false, wasUserEntered: Bool = false) {
self.startDate = startDate
self.isDisplayOnly = isDisplayOnly
self.wasUserEntered = wasUserEntered
}
}

enum MissedMealTestType {
private static var dateFormatter = ISO8601DateFormatter.localTimeDate()

Expand Down Expand Up @@ -160,6 +176,8 @@ class MealDetectionManagerTests: XCTestCase {
var bolusUnits: Double?
var bolusDurationEstimator: ((Double) -> TimeInterval?)!

fileprivate var glucoseSamples: [MockGlucoseSample]!

@discardableResult func setUp(for testType: MissedMealTestType) -> [GlucoseEffectVelocity] {
let healthStore = HKHealthStoreMock()

Expand Down Expand Up @@ -198,6 +216,8 @@ class MealDetectionManagerTests: XCTestCase {
test_currentDate: testType.currentDate
)

glucoseSamples = [MockGlucoseSample(startDate: now)]

bolusDurationEstimator = { units in
self.bolusUnits = units
return self.pumpManager.estimatedDuration(toBolus: units)
Expand Down Expand Up @@ -252,7 +272,7 @@ class MealDetectionManagerTests: XCTestCase {

let updateGroup = DispatchGroup()
updateGroup.enter()
mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in
mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in
XCTAssertEqual(status, .noMissedMeal)
updateGroup.leave()
}
Expand All @@ -264,7 +284,7 @@ class MealDetectionManagerTests: XCTestCase {

let updateGroup = DispatchGroup()
updateGroup.enter()
mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in
mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in
XCTAssertEqual(status, .noMissedMeal)
updateGroup.leave()
}
Expand All @@ -277,7 +297,7 @@ class MealDetectionManagerTests: XCTestCase {

let updateGroup = DispatchGroup()
updateGroup.enter()
mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in
mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in
XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 55))
updateGroup.leave()
}
Expand All @@ -290,7 +310,7 @@ class MealDetectionManagerTests: XCTestCase {

let updateGroup = DispatchGroup()
updateGroup.enter()
mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in
mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in
XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 25))
updateGroup.leave()
}
Expand All @@ -303,7 +323,7 @@ class MealDetectionManagerTests: XCTestCase {

let updateGroup = DispatchGroup()
updateGroup.enter()
mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in
mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in
XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 50))
updateGroup.leave()
}
Expand All @@ -315,7 +335,7 @@ class MealDetectionManagerTests: XCTestCase {

let updateGroup = DispatchGroup()
updateGroup.enter()
mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in
mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in
XCTAssertEqual(status, .noMissedMeal)
updateGroup.leave()
}
Expand All @@ -328,7 +348,7 @@ class MealDetectionManagerTests: XCTestCase {

let updateGroup = DispatchGroup()
updateGroup.enter()
mealDetectionManager.hasMissedMeal(insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in
mealDetectionManager.hasMissedMeal(glucoseSamples: glucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in
XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 40))
updateGroup.leave()
}
Expand Down Expand Up @@ -436,6 +456,44 @@ class MealDetectionManagerTests: XCTestCase {
XCTAssertEqual(bolusUnits, 4.5)
XCTAssertEqual(mealDetectionManager.lastMissedMealNotification?.deliveryTime, expectedDeliveryTime2)
}

func testHasCalibrationPoints_NoNotification() {
let testType = MissedMealTestType.manyMeals
let counteractionEffects = setUp(for: testType)

let calibratedGlucoseSamples = [MockGlucoseSample(startDate: now), MockGlucoseSample(startDate: now, isDisplayOnly: true)]

let updateGroup = DispatchGroup()
updateGroup.enter()
mealDetectionManager.hasMissedMeal(glucoseSamples: calibratedGlucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in
XCTAssertEqual(status, .noMissedMeal)
updateGroup.leave()
}
updateGroup.wait()

let manualGlucoseSamples = [MockGlucoseSample(startDate: now), MockGlucoseSample(startDate: now, wasUserEntered: true)]
updateGroup.enter()
mealDetectionManager.hasMissedMeal(glucoseSamples: manualGlucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in
XCTAssertEqual(status, .noMissedMeal)
updateGroup.leave()
}
updateGroup.wait()
}

func testHasTooOldCalibrationPoint_NoImpactOnNotificationDelivery() {
let testType = MissedMealTestType.manyMeals
let counteractionEffects = setUp(for: testType)

let tooOldCalibratedGlucoseSamples = [MockGlucoseSample(startDate: now, isDisplayOnly: false), MockGlucoseSample(startDate: now.addingTimeInterval(-MissedMealSettings.maxRecency-1), isDisplayOnly: true)]

let updateGroup = DispatchGroup()
updateGroup.enter()
mealDetectionManager.hasMissedMeal(glucoseSamples: tooOldCalibratedGlucoseSamples, insulinCounteractionEffects: counteractionEffects, carbEffects: mealDetectionCarbEffects(using: counteractionEffects)) { status in
XCTAssertEqual(status, .hasMissedMeal(startTime: testType.missedMealDate!, carbAmount: 40))
updateGroup.leave()
}
updateGroup.wait()
}
}

extension MealDetectionManagerTests {
Expand Down