Skip to content

Commit

Permalink
Merge pull request LoopKit#2056 from LoopKit/novalegra-missed-meal-ig…
Browse files Browse the repository at this point in the history
…nore-calibration

Disable missed meal detection if there are user-entered or calibration BGs
  • Loading branch information
ps2 authored Aug 25, 2023
2 parents f3dc84f + 9996d81 commit 3367e60
Show file tree
Hide file tree
Showing 3 changed files with 109 additions and 20 deletions.
33 changes: 23 additions & 10 deletions Loop/Managers/LoopDataManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -910,8 +910,8 @@ extension LoopDataManager {
notify(forChange: .loopFinished)

if FeatureFlags.missedMealNotifications {
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 @@ -921,15 +921,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)
}
)
}
}
}

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 @@ -66,13 +66,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 @@ -214,12 +223,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 @@ -295,3 +305,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 containsCalibrations() || 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 @@ -175,6 +191,8 @@ class MealDetectionManagerTests: XCTestCase {
var bolusUnits: Double?
var bolusDurationEstimator: ((Double) -> TimeInterval?)!

fileprivate var glucoseSamples: [MockGlucoseSample]!

@discardableResult func setUp(for testType: MissedMealTestType) -> [GlucoseEffectVelocity] {
carbStore = CarbStore(
cacheStore: PersistenceController(directoryURL: URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).appendingPathComponent(UUID().uuidString, isDirectory: true)),
Expand Down Expand Up @@ -209,6 +227,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 @@ -263,7 +283,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 @@ -275,7 +295,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 @@ -288,7 +308,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 @@ -301,7 +321,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 @@ -314,7 +334,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 @@ -326,7 +346,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 @@ -339,7 +359,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 @@ -460,6 +480,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

0 comments on commit 3367e60

Please sign in to comment.